NLP(自然言語処理):kerasとLSTMを用いて学習と予測を行う

Webスクレイピング:URLから記事の本文を抽出するにおいて,VOA Learning English から本文を抽出しました。これを用いて,ディープラーニングを行います。

TensorFlow と Keras

ディープラーニングを行うために,TensorFlow と Keras をインストールする必要があります。

ディープラーニングの手順

まず,学習するテキストファイルを読み込み,辞書を作成します。次に,文字をベクトルに変換し,モデルの入力値と答えを作成します。学習に必要がデータが完成したら,モデルを構築し,学習を行います。最後に,学習済みのモデルを保存します。

今回は,5つの単語をもとに,次の単語を予測するモデルを構築します。

テキストの読み込み

with io.open('articles.txt', encoding='utf-8') as f:
    text = f.read().splitlines()

テキストファイルarticles.txtを読み込みます。大きさは318KBです。

texts = text.replace('eos', 'eos\n').splitlines()

文末記号eosに改行文字を加え,.spilitlines()でリストtextsに行ごとのテキストを格納します。

辞書の作成

Tokenizerを用いると,テキストの前処理を簡単に行うことができます。TokenizerはKerasに含まれています。

ディープラーニングでは,文字をそのままデータとして用いることはできません。扱うことができるのは数だけです。そこで,辞書を作成し,単語を数に置き換えます。

tokenizer = Tokenizer()

オブジェクトtokenizerを作成します。テキストを自動的に処理する機械が作られ,その名前がtokenizerである,とイメージするとよいでしょう。

tokenizer.fit_on_texts(texts)

.fit_on_texts()tokenizerにテキストを与えます。

char_indices = tokenizer.word_index

.word_indexは辞書型リストを作成し,リストchar_indicesに格納します。

char_indices = {'eos': 1, 'the': 2, 'to': 3, ... , 'respects': 6950}

テキストで使われている単語に数が割り当てられました。リストは単語の出現頻度の順に並んでいます。単語の総数は6950語です。

また,tokenizerは自動的に単語を小文字に変換し,ダブルクォーテーションやコロンなどの記号を取り除きます。

indices_char = dict([(value, key) for (key, value) in char_indices.items()])

逆引き辞書を作成し,indices_charに格納します。dict()は辞書型のリストを作成します。

indices_char = {1: 'the', 2: 'in', 3: 'to', 4: 'and', 5: 'of', .... , 390: 'jr', 391: 'editor'}

予測の結果は単語を表す数で返されます。その後,逆引き辞書を用いて数を単語に戻し,結果を確認します。

単語のベクトル化

pre_vec = tokenizer.texts_to_sequences(texts)

.texts_to_sequences() は単語をベクトル化し,リストpre_vecに格納します。

pre_vec =
[[1, 30, 68, 14, 69, 125, 70, 5, 126, 2, 37, 3, 9, 2, 38, 3, 127, 1, 128, 5, 12, 129, 1, 130, 131],
 [1, 10, 71, 13, 132, 3, 133, 45, 18, 20],
 [1, 20, 46, 47, 6, 2, 134, 21, 1, 14, 4, 135, 21, 1, 136, 137],

 ......

]

一つの単語は一つの数によって表されます。これは値が一次元ベクトルであることと同じです。しかし,通常はこれをベクトルとは言いません。

実際には単語に複数の値を持たせることができます。たとえば,ある単語を(13,87,25)として表すなら,これは三次元ベクトルになり,三階のテンソルとも言います。

一次元ベクトルの値はほとんど意味を持ちません。私たちがその値から知ることができるのは,単語の出現頻度だけです。

ベクトルの次数を増やすと,単語により多くの情報を与えることができ,より高度な学習を行うことができます。

texts_vec = []
for line in range(len(pre_vec)):
    if len(pre_vec[line]) > 9:
        texts_vec.append(pre_vec[line])

len(pre_vec)は文章の行数を表し,len(pre_vec[line])は行番号lineにおける単語数を表します。

単語数が10以上の場合に,変換されたベクトルをリストtexts_vecに格納し,短い行をカットします。

今回は5つの単語をもとに次の単語を予測するので,それぞれの行は少なくとも6つの単語を持つ必要があります。

データセットを用意する

seq_length = 5

今回は,5つの単語をもとに次の単語を予測します。これをseq_lengthで表します。

入力値は(バッチサイズ,タイムステップ,入力次数)の形状をとります。

5つの値の組がタイムステップです。タイムステップは単語を一つずつずらしながら作られ,その合計がバッチサイズになります。そして,それぞれのタイムステップに続く1つの単語を答えとします。入力次数は単語ベクトルの次数のことです。今回は,次数は1となります。

タイムステップを作成します。

for line in range(len(texts_vec)):
    for i in range(len(texts_vec[line])-5):

lineは行番号を表し,iは行の中の単語の位置を表します。iはそれぞれの行の単語数より5つ少ない値まで変化します。

        x.append(texts_vec[line][i:i+5])
        y.append(texts_vec[line][i+5])

xはモデルに与える入力値です。[i:i+5]はリストの範囲を表します。例えばiの値が0なら,0から4までを意味します。0から5までではないことに注意してください。

繰り返し処理はline=0i=0から始まります。.append()を用いてtexts_vec[0][0]からtexts_vec[0][4]までの値をリストxに追加します。次にi=1となり,texts_vec[0][1]からtexts[0][5]までの値を追加します。こうして,一つずつ移動しながら5つの値の組を抽出していきます。

x = 
[[1, 30, 68, 14, 69],
 [30, 68, 14, 69, 125],
 [68, 14, 69, 125, 70],

 ......

]

yは答えです。line=0i=0のとき,text_vec[0][5]が追加されます。

y =
[125,
 70,
 5

 ......

]

yは予測されるべき値を意味します。モデルはxで与えられた入力値に対して,yと同じ値を出力するように訓練されます。学習が進んでいないモデルはランダムな値を返しますが,学習が進むと高い確率でyと同じ値を出力するようになります。

x = np.reshape(x,(len(x),seq_length,1))

np.reshape()はリストの形状を変更します。モデルに与える入力値は三次元のベクトルでなければなりません。

x =
[[[  1]
  [ 30]
  [ 68]
  [ 14]
  [ 69]]

 [[ 30]
  [ 68]
  [ 14]
  [ 69]
  [125]]

 [[ 68]
  [ 14]
  [ 69]
  [125]
  [ 70]]

 ......

]

この方法とは別に,初めに空の三次元のリストを用意し,値を一つずつ格納する方法もあります。しかし,np.reshape()の方が処理が高速です。

one-hot 形式のリストを作成する

y = np_utils.to_categorical(y, len(char_indices)+1)

np_utils.to_categorical() はリストを one-hot 形式に変換します。

例えば,すべての単語が0から4までの値で表されるとき,それぞれの値は one-hot形式では以下のように表されます。

0 ---> [1,0,0,0,0]
1 ---> [0,1,0,0,0]
2 ---> [0,0,1,0,0]
3 ---> [0,0,0,1,0]
4 ---> [0,0,0,0,1]

今回,単語の総数は6950語ですが,モデルは出力値として単語それぞれについての確率を返します。

result =
[[5.21713228e-10 3.66212561e-07 1.54906775e-05 6.71785965e-05
  2.40655282e-07 3.59350683e-09 4.46252926e-07 4.50365405e-07
  2.32367552e-06 3.77001937e-11 2.18020944e-08 2.20234284e-07
  2.27694059e-07 1.82693594e-08 1.24455757e-09 1.38001795e-07

  ......

]]

リストの中から最も確率の高いものを,予測値resultとします。

このとき,モデルは答えyresultを比較し,予測値が答えに近づくようにモデルを変更します。

モデルの構築

model = Sequential()

オブジェクトmodelを構築します。modelは自然言語を学習する機械をイメージするとよいでしょう。modelに入力を与え,出力を受け取ります。Sequential()はモデルが鎖状に連結されたものであることを示しています。

学習プロセスの概要です。それぞれの単語はベクトルに変換され,xに格納されます。5つの単語の組み(タイムステップ)を一つずつLSTMに与え,Denseレイヤーに集約します。Denseレイヤーは辞書にある単語それぞれの確率を出力し,確率の最も高いものを予測値として決定します。また,出力値とあらかじめ用意された答えyとのずれを計算し,ネットワークを遡ってずれが小さくなるように修正します。

model.add(LSTM(128, input_shape=(seq_length, 1)))

モデルにLSTMレイヤーを加えます。LSTMは実際に学習を行うためのレイヤーです。ニューロンを128個設置します。

input_shape=(seq_length, 1)はそれぞれのニューロンにおける入力値の形状を表します。seq_length=5なので,それぞれのニューロンに(5,1)の大きさのデータが与えられます。

model.add(Dense(y.shape[1], activation='softmax'))

LSTMの出力をDenseレイヤーに集約します。Denseレイヤーはそれぞれの単語の確率を出力します。y.shape[1]は辞書に登録された単語の総数を表します。

activation='softmax'は活性化関数としてsoftmax関数を用いることを表します。softmax関数はそれぞれの単語についての確率を求めます。

optimizer = Adam(lr=0.01)

最適化関数としてAdamを指定します。最適化関数は出力値と答えとの誤差を修正するための関数です。

lr=0.01は学習率です。学習率の値を大きくすると学習の速度が速くなる一方で,精度が向上しないという問題が起きます。反対に,学習率の値を小さくすると精度が向上する一方で,学習の速度が遅くなるという問題が起きます。

model.compile(loss='categorical_crossentropy', optimizer=optimizer, metrics=['accuracy'])

loss='categorical_crossentropy'は損失関数として交差エントロピー誤差を用いることを表します。損失関数は,結果と答えの誤差を表す尺度です。モデルは損失関数の値をもとに,その値が小さくなるようにネットワークを変更します。

metrics=['accuracy']はモデルを評価するための方法として精度を用いることを表します。

理解の難しい関数がいくつも登場しましたが,これらの関数の持つ役割は実際に学習を進めていきながら段階的に理解していきます。

学習の実行

model.fit(x, y, epochs=100, verbose=1)

model.fit()でモデルに入力値xと答えyを与え,学習を行います。

学習はepochという単位で行われます。epochを増やすと学習が段階的に進み,精度が向上します。今回は,100 epoch学習を行います。

verboseは進行状況の表示方法を指定します。verbose=0とすると進行状況は表示されません。

.fit()を実行すると以下のような出力が得られます。

Epoch 1/100
37858/37858 [==============================] - 7s 193us/step - loss: 6.9415 - accuracy: 0.0759

......

Epoch 100/100
37858/37858 [==============================] - 6s 171us/step - loss: 4.4052 - accuracy: 0.1814

バッチサイズは37858で,これは5個の単語の組み(タイムステップ)が37858個あることを示しています。学習の結果,モデルは18.14パーセントの精度を得ました。これは十分な結果とは言えません。

入力値の正規化

x = x / float(len(char_indices))

結果を改善するために,入力値を正規化します。float()は整数を浮動小数点に変換します。入力値の最大は辞書の大きさに等しく,値を辞書の大きさで割るとx0から1の間の小数になります。これを正規化と呼びます。

モデルは,それぞれの単語についての確率を0から1の値で出力します。このとき,入力と出力の尺度を合わせると,学習効率が改善するのです。

Epoch 100/100
37858/37858 [==============================] - 6s 166us/step - loss: 1.7827 - accuracy: 0.5174

精度が51.74パーセントに改善しました。

モデルの保存

model.save('model_voa.h5')

学習したモデルをファイルmodel_voa.h5に保存します。保存したモデルをあとから読み込んで,予測を行うことができます。

np.save('char_indices_voa', char_indices)
np.save('indices_char_voa', indices_char)
np.save('x_voa.npy', x)
np.save('y_voa.npy', y)

辞書とデータセットをファイルに保存します。これらのファイルはあとで予測を行うときに必要になります。

全体のコードを示します。

import numpy as np
import sys
import io
import os
os.environ['TF_CPP_MIN_LOG_LEVEL']='2'
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.optimizers import Adam
from keras.utils import np_utils
from keras.preprocessing.text import Tokenizer
#read the text
with io.open('articles.txt', encoding='utf-8') as f:
    text = f.read()
texts = text.replace('eos', 'eos\n').splitlines()
#make the dictionary
tokenizer = Tokenizer()
tokenizer.fit_on_texts(texts)
char_indices = tokenizer.word_index
#make the inverted dictionary
indices_char = dict([(value, key) for (key, value) in char_indices.items()])
np.save('char_indices_voa', char_indices)
np.save('indices_char_voa', indices_char)
#vectorization and short lines cut
pre_vec = tokenizer.texts_to_sequences(texts)
texts_vec = []
for line in range(len(pre_vec)):
    if len(pre_vec[line]) > 9:
        texts_vec.append(pre_vec[line])
#make dataset
print('make the dataset....')
seq_length = 5
chars = []
x = []
y = []
for line in range(len(texts_vec)):
    for i in range(len(texts_vec[line])-5):
        x.append(texts_vec[line][i:i+5])
        y.append(texts_vec[line][i+5])
np.save('x_voa.npy', x)
np.save('y_voa.npy', y)
x = np.reshape(x,(len(x),seq_length,1))
x = x / float(len(char_indices))
#convert y into one-hot
y = np_utils.to_categorical(y, len(char_indices)+1)
#build the model
print('build the model....')
model = Sequential()
model.add(LSTM(128, input_shape=(seq_length, 1)))
model.add(Dense(y.shape[1], activation='softmax'))
optimizer = Adam(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer, metrics=['accuracy'])
#learning
model.fit(x, y, epochs=100, verbose=1)
#save the model
model.save('model_voa.h5')

結果を予測する

構築したモデルが,実際に予測できるかどうかを確認します。

with io.open('articles.txt', encoding='utf-8') as f:
    text = f.read()
texts = text.replace('eos', 'eos\n').splitlines()
char_indices = np.load('char_indices_voa.npy', allow_pickle=True).tolist()
indices_char = np.load('indices_char_voa.npy', allow_pickle=True).tolist()
x = np.load('x_voa.npy', allow_pickle=True)
y = np.load('y_voa.npy', allow_pickle=True)

保存されたテキスト,辞書,データセットを読み込みます。

model = load_model('model_voa.h5')

保存されたモデルを読み込みます。

for pattern in range(5):

繰り返し処理によって,予測を5回行います。

    for i in range(seq_length):
        x_pred.append(x[pattern][i])
        chars.append(indices_char[x[pattern][i]])

.append()はデータセットで作成した値をリストx_predに追加します。

また,表示のために逆引き辞書を用いて値を単語に戻し,リストcharsに追加します。

    x_pred = np.reshape(x_pred,(1, seq_length, 1))
    x_pred = x_pred / float(len(char_indices))

学習のときと同様,入力値を(バッチサイズ,タイムステップ,入力次数)の形状に変更し,正規化します。

    prediction = model.predict(x_pred, verbose=0)

model.predict()x_predを与え,予測を行います。それぞれの単語の確率がリストpredictionに格納されます。

    index = np.argmax(prediction)

np.argmax()はリストにある値から最大値を求めます。最大値をindexに格納します。

    result = indices_char[index]

逆引き辞書を用いて値を単語に戻し,resultに格納します。

    char = ' '.join(chars)

リストcharsを一つの文字列charに連結します。

    print(char)
    print('  answer:'+str(indices_char[y[pattern]]))
    print('  prediction:'+str(result))

コードを実行し,結果を表示します。

the european union eu started
  answer:sending
  prediction:sending
european union eu started sending
  answer:millions
  prediction:millions
union eu started sending millions
  answer:of
  prediction:of
eu started sending millions of
  answer:dollars
  prediction:dollars
started sending millions of dollars
  answer:in
  prediction:eos

出力と答えが一致し,高い確率で正しく予測されていることが分かります。

モデルは高い精度を持ちますが,これは学習用データをそのまま予測に用いているからです。本来,AIは未知のデータに対して妥当な結果を得ることが目標です。そのため,この結果はあくまで不十分なものに過ぎません。

最後にコード全体を示します。

import numpy as np
import sys
import io
import os
os.environ['TF_CPP_MIN_LOG_LEVEL']='2'
from keras.models import load_model
#read the text
with io.open('articles.txt', encoding='utf-8') as f:
    text = f.read()
texts = text.replace('eos', 'eos\n').splitlines()
#read the dictionaries
char_indices = np.load('char_indices_voa.npy', allow_pickle=True).tolist()
indices_char = np.load('indices_char_voa.npy', allow_pickle=True).tolist()
x = np.load('x_voa.npy', allow_pickle=True)
y = np.load('y_voa.npy', allow_pickle=True)
#load the model
model = load_model('model_voa.h5')
#prediction
seq_length = 5
for pattern in range(5):
    x_pred = []
    chars = []
    for i in range(seq_length):
        x_pred.append(x[pattern][i])
        chars.append(indices_char[x[pattern][i]])
    x_pred = np.reshape(x_pred,(1, seq_length, 1))
    x_pred = x_pred / float(len(char_indices))
    prediction = model.predict(x_pred, verbose=0)
    index = np.argmax(prediction)
    result = indices_char[index]
    char = ' '.join(chars)
    print(char)
    print('  answer:'+str(indices_char[y[pattern]]))
    print('  prediction:'+str(result))