fastai動かしてみたLesson4① 自然言語処理

今回はLesson4 https://course.fast.ai/videos/?lesson=4

notebookはこちら https://github.com/fastai/course-v3/tree/master/nbs/dl1

自然言語処理、協調フィルタリング、テーブル状のデータの機械学習について学んでいく

自然言語処理

NLPとはNatural Language Processingつまり自然言語処理の事である
つまり、何らかの文を使って色々やる事である
例えば文章の分類を行い、メールのスパムを分類したり、フェイクニュースを発見したり、Twitter上で自分の制作物について発言しているものを探したり…

前回の最後にちょっとふれたように、映画レビューのデータを使ってそれが高評価か低評価かをFastaiで分類していく
データセットには25000件のレビューがあり、それぞれのレビューにはちょっとしか情報が含まれていない(この映画が好きか嫌いかとか)
今回のニューラルネットの重み行列はランダムな数から始まる。つまりもし初めにランダムな数から高評価か低評価かを見極めようとすると、文字通り25000件の0と1が表示される

これではどのように英語を使うかを学ぶのに明らかに情報が足りない、つまりそれらのレビューが高評価か低評価かを理解する上で、英語力が足りないということ(要は語彙力が足りないという事?)

自然言語処理における転移学習

自然言語処理は転移学習を使うとうまくいくらしいので、転移学習をする
キーポイントは前回までの画像分類と同じで、自分が行いたいモデルと異なる訓練済みモデルを使う事だ
例えばImageNetは1000カテゴリの写真を分類するために訓練されたモデルで、それを人間が自分自身のモデルに合うように微調整を行う
今回はそれを映画レビューの分類ではなく、言語モデルと呼ばれる事前に訓練されたモデルから始める

言語モデルは文の次に出てくる単語を予測する物だ

例えば
「I’d like to eat a hot ___」:明らかに「dog」
「It was a hot __」:おそらく「day」

昔はNLPに対してはn-gramという手法を取っていた
これは2つか3つの単語のペアがどれぐらいの頻度で隣になるかの傾向を使うものである
詳しくは:https://qiita.com/kazmaw/items/4df328cba6429ec210fb

しかしながら、n-gramだけでは、映画レビューの様な物からは十分な情報を得ることが出来ない

そこで、ニューラルネットを使って次に来る単語を予測するように訓練すると、多くの情報を得ることが出来る
すなわち、2000ワードのレビューがあったなら1999回予測する機会がある

今回は転移学習を行うので、Wikipediaからデータを持ってきてみる

Wikitext 103

Stephen Merityとその同僚達によってWikitext103というデータセットを作ったので、それらを今回は使う。

Fastaiの作者が既にWikipediaのデータを使って言語モデル作っているので、それを使うことが出来る

従って画像分類では訓練済みのImageNetモデルを使ったが、それと同様に訓練済みのWikitextモデルを使って転移学習を行う事が出来る

WikitextのFine-tuning

Wikitextモデルで訓練済みモデルをつくれれば、「my favorite actor is Tom ____」など予測して充てることが出来るようになる

今回は映画のレビューが高評価か低評価かわからなくても、訓練に利用する事が出来る
したがって、今回は訓練とモデルのFine-tuningにラベルを必要としない

これを自己教師あり学習(self supervised) という

基本的な動かし方

いつも通りデータを持ってくる

path = untar_data(URL,IMDB_SAMPLE)  
path.ls()  

df = pd.read_csv(path/'texts.csv')  
df.head()  

Databunchを作って保存

data_lm = TextDataBunch.from_csv(path, ''texts.csv")  
data_lm.save()  

ロードする

data  = TextDataBunch.load(path)  

classification databunchとして読み込むとどうなるだろうか

data = TextClasDataBunch.load(path)  
data.show_batch()  

ここから、トークンとしてそれぞれの単語を分割していく
ここでは「it's」を「's」といったトークンになる事もある

更に、numericalizationを行う
これは、表示される全ての固有のトークンが何なのかを見つけ、それらの大きなリストを作成する事である

data.vocab.itos[:10]  

['xxunk', 'xxpad', 'the', ',', '.', 'and', 'a', 'of', 'to', 'is']

この固有である可能性を持つのトークンのリストをvocabularyといい、上記のように見ることが出来る
そして、トークンがvocabのどこにあるのかというIDに置き換える

data.train_ds[0][0]  

array([ 43, 44, 40, 34, 171, 62, 6, 352, 3, 47])

この過程をnumericalizationという

単語数が多すぎると重み行列が大きくなりすぎるので、vocabをデフォルトでは6万語以下に制限している
ある単語が2回以上出現しなければ、その単語のトークンをxxunkとして扱う
他にもxxfldやxxupなど、特別なトークンが存在する
詳しくはfastai.text.transform.py参照

ここからはDatablockAPIを使っていく

data = (TextList.from_csv(path, 'texts.csv', cols='text')  
                .split_from_df(col=2)  
                .label_from_df(cols=0)  
                .databunch())
path = untar_data(URLs.IMDB)  
path.ls()  

[PosixPath('/home/jhoward/.fastai/data/imdb/imdb.vocab'),
PosixPath('/home/jhoward/.fastai/data/imdb/models'),
PosixPath('/home/jhoward/.fastai/data/imdb/tmp_lm'),
PosixPath('/home/jhoward/.fastai/data/imdb/train'),
PosixPath('/home/jhoward/.fastai/data/imdb/test'),
PosixPath('/home/jhoward/.fastai/data/imdb/README'),
PosixPath('/home/jhoward/.fastai/data/imdb/tmp_clas')]

(path/'train').ls()  

[PosixPath('/home/jhoward/.fastai/data/imdb/train/pos'),
PosixPath('/home/jhoward/.fastai/data/imdb/train/unsup'),
PosixPath('/home/jhoward/.fastai/data/imdb/train/unsupBow.feat'),
PosixPath('/home/jhoward/.fastai/data/imdb/train/labeledBow.feat'),
PosixPath('/home/jhoward/.fastai/data/imdb/train/neg')]

今回は25000件のレビューをtrainingに、25000件のインタビューをvalidationに、
50000件の教師無しレビュー
のデータを使う

言語モデルを作成する

Wikitext103を訓練する必要はないので、IMDB言語モデルでfine-tuningを行う

bs=48  

data_lm = (TextList.from_folder(path)  
           #Inputs: all the text files in path  
            .filter_by_folder(include=['train', 'test'])   
           #We may have other temp folders that contain text files so we only keep what's in train and test  
            .random_split_by_pct(0.1)  
           #We randomly split and keep 10% (10,000 reviews) for validation  
            .label_for_lm()             
           #We want to do a language model so we label accordingly  
            .databunch(bs=bs))  
data_lm.save('tmp_lm')

なぜここでは事前にあらかじめ用意されたtrainとtestを使わずに、ランダムに10%を分割してつかうのだろうか?
これは、転移学習の素晴らしい点の1つで、検証データ(valid)は別にしておく必要があるけれども、実際にはラベルを持っておくだけでよい
そのため、ラベルをテストデータに使ってはいけない

Kaggleコンペティションで考えると、Kaggleがラベル提供していないので、ラベルを使う事が出来ない
しかし独立変数を使うことが出来る

従ってこの場合、テストデータである文章を訓練に使うことが出来る

つまり言語モデルを作るときは、訓練とテストのデータを連結し、より少ない検証データに分割し、より多くのデータを訓練に使えるようになる

data_lm = TextLMDataBunch.load(path, 'tmp_lm', bs=bs)  

data_lm.show_batch()

Trainingする

今までのCNNと異なり、今回は言語モデル学習器を作成する
これはRNN(Recurrent Neural Network)である
詳しくは後の講義で説明されるらしい
簡単に言うと今までと基本的には同じとかなんとか
入力されたデータは重み行列になり、そこで負数を0に置き換える
そして別の行列と掛け合わされる

画像の時はImageNetの訓練済みモデルを使っていたが、今回はWikitext103を使う

learn = language_model_learner(data_lm, pretrained_model=URLs.WT103, drop_mult=0.3)  

learn.lr_find()  

learn.recorder.plot(skip_end=15)  

drop_mult=0.3 というのはドロップアウトの量を設定している
以前正則化(regularization)について講義で話したが、正則化を減らし、underfittingを防ぐことが出来るようになる
ここでは、初めにfitさせた時にunderfittingしていたので、1より小さい数をdropoutとして設定する事で、underfittingを防いでいる

学習率は1e-2辺りがよさそう

learn.fit_one_cycle(1, 1e-2, moms=(0.8,0.7))  

Total time: 12:42
epoch train_loss valid_loss accuracy
1 4.591534 4.429290 0.251909 (12:42)

unfreezeして、変数を可変にする

learn.unfreeze()  
learn.fit_one_cycle(10, 1e-3, moms=(0.8,0.7))  

Total time: 2:22:17
epoch train_loss valid_loss accuracy
1 4.307920 4.245430 0.271067 (14:14)
2 4.253745 4.162714 0.281017 (14:13)
3 4.166390 4.114120 0.287092 (14:14)
4 4.099329 4.068735 0.292060 (14:10)
5 4.048801 4.035339 0.295645 (14:12)
6 3.980410 4.009860 0.298551 (14:12)
7 3.947437 3.991286 0.300850 (14:14)
8 3.897383 3.977569 0.302463 (14:15)
9 3.866736 3.972447 0.303147 (14:14)
10 3.847952 3.972852 0.303105 (14:15)~~~

予測してみる

learn.predict('I liked this movie because ', 100, temperature=1.1, min_p=0.001)  

'I liked this movie because of course after yeah funny later that the world reason settings - the movie that perfect the kill of the same plot - a mention of the most of course . do xxup diamonds and the " xxup disappeared kill of course and the movie niece , from the care more the story of the let character , " i was a lot 's the little performance is not only . the excellent for the most of course , with the minutes night on the into movies ( ! , in the movie its the first ever ! \n\n a'

ここで注意だが、これは良い文章生成システムになるように設計されてはいない
これは漠然とした何かを作成しているかをチェックするように設計されている

ここで、映画レビューモデルを既に作成している
だから分類器に文章生成モデルを読み込ませるために、文章生成モデルを保存する(すなわち、分類器のために事前に訓練されたモデルを保存する)

それを分類器に読み込ませる(load)ためにencoderとして保存する

learn.save_encoder('fine_tuned_enc')  

分類

data_clas = (TextList.from_folder(path, vocab=data_lm.vocab)  
             #grab all the text files in path  
             .split_by_folder(valid='test')  
             #split by train and valid folder (that only keeps 'train' and 'test' so no need to filter)  
             .label_from_folder(classes=['neg', 'pos'])  
             #remove docs with labels not in above list (i.e. 'unsup')  
             .filter_missing_y()  
             #label them all with their folders  
             .databunch(bs=50))  
data_clas.save('tmp_clas')

ここでは言語モデルで使用されたvocabと同じものを使う必要がある
もし言語モデルで単語番号10が「the」
となっていれば、分類器でも単語番号10を「the」にする必要がある
そうでなければ訓練済みモデルを使う事の意味が無くなる

ラベルを付け、Databunchを作る

data_clas = TextClasDataBunch.load(path, 'tmp_clas', bs=bs)  
data_clas.show_batch()

learn = text_classifier_learner(data_clas, drop_mult=0.5)  

learn.load_encoder('fine_tuned_enc')  
learn.freeze()

今回は言語モデルを作るのではなく、テキスト分類モデルを作成する
ここで最も大事な事は、訓練済みモデルを読み込むという事だ
具体的に言うと、モデルの半分はエンコーダーといわれるものだ(ちょっとここ不明?)

learn.lr_find()  
learn.recorder.plot()

learn.fit_one_cycle(1, 2e-2, moms=(0.8,0.7))  

Total time: 02:46
epoch train_loss valid_loss accuracy
1 0.294225 0.210385 0.918960 (02:46)~~~

learn.save('first')  
learn.load('first');  
learn.freeze_to(-2)  
learn.fit_one_cycle(1, slice(1e-2/(2.6**4),1e-2), moms=(0.8,0.7))

Total time: 03:03
epoch train_loss valid_loss accuracy
1 0.268781 0.180993 0.930760 (03:03)

3分で訓練が終わり、すでに最大で92%の精度を出している
これが転移学習のいい点で、初めに特定の分野のモデルを訓練する事に時間がかかるが、モデルを作れば後の作業にはあまり時間がかからないという利点がある

少しだけ訓練してunfreezeとfreeze_toを繰り返す
freeze_toはunfreezeの逆で、最後の2層以外を固定するという事だ

learn.save('second')  

learn.load('second');  
learn.freeze_to(-3)  
learn.fit_one_cycle(1, slice(5e-3/(2.6**4),5e-3), moms=(0.8,0.7))

Total time: 04:06
epoch train_loss valid_loss accuracy
1 0.211133 0.161494 0.941280 (04:06)

learn.save('third')  

learn.load('third');  
learn.unfreeze()  
learn.fit_one_cycle(2, slice(1e-3/(2.6**4),1e-3), moms=(0.8,0.7))

Total time: 10:01
epoch train_loss valid_loss accuracy
1 0.188145 0.155038 0.942480 (05:00)
2 0.159475 0.153531 0.944040 (05:01)

ここで何故$$2.6^4$$が出てきたのかというと、4乗は今後説明するので、2.6に関しては
スライスの最下部と最上部の間の差は、基本的にモデルの最下位層が学習する速度と最上層が学習する速度の差である
そのため、これは識別学習率(discriminative learning rate)と呼ばれる
それで、この問題は層から層へ行くにつれて、どれくらい私は学習率を減らすのかという事だ
NLPのRNNの場合、2.6が最適であると見つけた(Fastaiの作者Jeremy howard曰く)

この結果を昨年(2017)の最先端技術と比較すると、2017年の記録が最高となっており、それを越す事が出来ました

Fastaiの作者は95.1%の記録を出したそうです

参考:https://github.com/hiromis/notes/blob/master/Lesson4.md