ディープラーニング習得、次の一歩

ディープラーニング習得、次の一歩

挑戦! word2vecで自然言語処理(Keras+TensorFlow使用)

2018年1月30日

自然言語のベクトル化手法の一つである「word2vec」を使って、単語間の関連性を表現してみよう。Keras(+TensorFlow)を使って実装する。

石垣哲郎 石垣 哲郎

ディープラーニングと自然言語処理

 画像認識や音声認識の分野では、すでに圧倒的ともいえる成果を誇っているのがディープラーニングである。猫画像識別のニュースに驚かされてからわずか数年のうちに、例えば「GAN(Generative Adversarial Network)」という技術が開発されていて、これを使うと、文字どおり何もないところから写真と見まがう画像を生成することができる。まさに、「十分に進歩した科学は魔法と区別がつかない」。

 では、ディープラーニングのもう一方の有力分野である自然言語処理の状況はどうであろうか。こちらでは、「seq2seq(sequence to sequence)」という手法があって、それを応用したここ数年のWeb翻訳における非線形、不連続な精度の向上は、誰しもが体感するところである。

 しかしながら、文章要約の分野では、学習データをどのようにして準備するのかという問題もあって、実用レベルへの到達はもう少し先のようである。録音データから議事録が自動で作成されるサービスがあれば大ヒット間違いなしと筆者などは思うわけであるが、それは差し当たり夢のままである。逆に言うと、これから開発されてくる技術がまだまだあるということであり、応用先の広さも相まって、自然言語処理分野では、近い将来さらに大きな波がやってくると筆者は考えている。

自然言語のベクトル化手法「word2vec」

 自然言語をディープラーニングで扱う場合、何らかの方法で単語をベクトルデータに変換する必要がある。しゃれた言い方では、n次元実数空間への埋め込みであるが、要はn個の実数の組に対応させることである。とにかく、数字に変換しないと始まらない。

 word2vecとは、このような自然言語のベクトル化手法の一つで、単語間の関連性を、対応するベクトル間演算(足し引き)で表現できるようにしよう、というものである。例えば、

king(の埋め込みベクトル) - man(の埋め込みベクトル) + woman(の埋め込みベクトル) = queen(の埋め込みベクトル)

が成り立つように埋め込みベクトルを決定する、ということである。結構途方もない話であるが、詳細は例えばMikolov他著の以下の論文を参照されたい。

 また、上記の論文に関しては、筆者が作成したプレゼン資料がある。ご参考になれば幸いである。

本稿のゴール

 word2vecのニューラルネットワーク(以下、ニューラルネット)を実際に構築して、日本語を対象としたベクトル表現を得る。日本語は形態素解析を用いて単語に分解し、単語単位にベクトル表現を得るようにする。

 本稿では、Kerasを使って実装する。また、訓練データのボリュームは個人レベルでも取り扱いが容易な、数十万語程度の規模を想定している。この規模だと汎用的なベクトル表現の獲得は難しいが、算用数字と漢数字の関連付け(「一」 - 「1」 + 「2」 = 「二」)程度は実現できた。

 なお、TensorFlowやKerasはインストール済みを前提に論を進める。

word2vecの特徴

 自然言語のベクトル化手法にもいろいろあるが、一番単純なのが、one-hotベクトルへの変換である。語彙数がNであるとき、各単語に0からN-1までのインデックスiを振る。各単語に対し、i次元の値が1で、それ以外の値が0のベクトル(1つの次元だけが1で他が0のため、one-hotベクトルと呼ばれる)に対応させると、確かにN次元実数空間への埋め込みが実現できる。

 しかしながら、この手法はニューラルネットの入力次元数が語彙数となるため、必然的に重み行列の要素数も巨大なものになる。これでは後々の訓練や予測がしんどいので、もっと入力次元を減らしたいところであるが、何とKerasには、その名も「Embedding」レイヤーというものが標準で用意されており、指定された次元への埋め込みベクトルを生成してくれる。例え語彙数が10000であっても、次元数を100と指定すれば、100次元の実数ベクトルが生成される。ベクトルの各要素はランダムに決定され、当然ながらone-hotベクトルではない。

 word2vecはこのような、語彙数より少ない次元Embedding数のベクトル化手法である。その特徴は、先にも記したとおり、単語間の関連性をベクトル表現に反映しているところであるが、その手法には、CBOW(Continuous Bag-of-Words Model)Continuous Skip-gram Model(以下、skip-gramと表記)の2種類がある。

 CBOWは、前後の単語からターゲットの単語を予測しようというものである。一方skip-gramはその逆に、特定の単語が与えられたときに、その前後の単語を当てようというものである。

 以下にそれぞれのイメージを図示する。図においてVは語彙集合、|V|はその要素数すなわち語彙数、Dは埋め込みベクトルの次元である。また、wは各単語のone-hotベクトル表現、vはその埋め込みベクトル表現、Ewからvへの変換行列、E'vを後述する階層化softmax空間に射影する行列である。実装的には、EがKerasのEmbeddingレイヤー、E'がDenseレイヤーに相当する。畳み込みもLSTMも無く、MLP(Multi-Layer Perceptron、多層パーセプトロン)でもないシンプルなネットワーク構成が、word2vecの特徴である。

図1 CBOW
図2 skip-gram

 図の中にある、「階層化softmax」というのは、計算量削減の手法である。softmax算出時の次元数は、ラベルデータのone-hotベクトルとのcategorical_crossentropy(多クラスの交差エントロピー)計算を行う関係上、必ず語彙数になるが、訓練データである文章量が巨大になる(上述の論文では、億オーダの単語量の訓練データを使用している)と語彙数も膨大になり、常識的な時間内で計算が終わらなくなる。

 そこで階層化softmaxである。|V|次元出力ベクトルの|V|個のデータを2つに分け、それぞれをまた2つに分け、というように2分割を繰り返していくと、各要素を2分探索木に対応させることができ、その階層数は(だいたい)“log(|V|)”になる。各要素は各層の「オン/オフ」の組み合わせで一意に表現できるので、ニューラルネットのノードを各層に対応させると、出力ベクトル各要素の値は各層の活性化関数(2択なのでsigmoid)の積で表現できる。

 すなわち、softmaxを用いずに出力ベクトルの確率分布表現を得ることができた。あとは普通にcategorical_crossentropyによる誤差評価を行うだけである。図3に階層化softmaxのイメージを示す。

図3 階層化softmaxのイメージ

 なお、本稿は訓練データのボリュームを、個人が自宅PCで処理できる程度である数十万語程度と想定している。これくらいの量だと階層化softmaxを適用するまでもないので、以下、これを使用しない前提で論を進めていく。

訓練データの入手

 ある程度のボリュームがある日本語テキストなら、何でもよい。筆者は青空文庫から、江戸川乱歩の著作を入手して使用した。後で形態素解析にかける関係上、現代仮名遣いの口語調の文書が良い。

形態素解析

 形態素解析とは、簡単に言うと日本語の文章を単語に分解することである。今日では各種の形態素解析ソフトが利用可能になっているが、筆者は京大黒橋・河原研究室のJUMAN++を使用した。

 以下にJUMAN++のURLを記す。ここからソフトウェアをダウンロードし、PCにインストールして対象文章を入力すると、分かち書きされた出力がカンマ区切りで得られるので、これをCSVファイルとして保存しておく。

 JUMAN++の分かち書きは情報量が豊富だが、今回は分かち書きの結果(各行の先頭、つまりCSVに変換したときの一番左の列)のみ使用している。また、分割した単語に別の読み方がある場合、「@」で始まる行が出現するが、今回は不要なのでその行は削除しておく。

 地の文の他に、ルビや、見出し指定などの記号が文書内に含まれている。筆者はそのまま学習に使用したが、ここは好みで取り除いてもよい。CSVファイルの加工には、筆者はExcelを使用した。

CSVファイルの取り込み

 いよいよ実装である。ニューラルネット自体は前述のとおりEmbeddingとDenseだけなので、拍子抜けするくらい簡単である。コードの大部分は、元データの訓練データへの整形である。なお、参考情報として、筆者が実行したソースコードを以下に示す。これらをJupyter Notebook上で、コードブロック単位に実行した。

 まず、各種import宣言である。

Python
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import csv
import pandas as pd
import random
import numpy.random as nr
import sys
import h5py
import math

from __future__ import print_function
from keras.layers.core import Activation
from keras.layers.core import Dense
from keras.layers.core import Flatten
from keras.models import Sequential
from keras.layers.embeddings import Embedding
from keras.callbacks import EarlyStopping
from keras.initializers import glorot_uniform
from keras.initializers import uniform
from keras.optimizers import RMSprop
from keras.utils import np_utils
リスト1 import宣言

 次に、csv読み込み処理である。取り込みファイルが3つなのは、筆者が元テキストを3つに分けて形態素解析にかけたからで、時に意味はない。行方向(要は「下」方向)に結合して1つの行列にする。この時点で、行列の各要素はテキストである。

Python
# 元データ
df1 = csv.reader(open('rampo_separate.csv', 'r'))
df2 = csv.reader(open('rampo_separate2.csv', 'r'))
df3 = csv.reader(open('rampo_separate3.csv', 'r'))


data1 = [ v for v in df1]
data2 = [ v for v in df2]
data3 = [ v for v in df3]

mat1 = np.array(data1)
mat2 = np.array(data2)
mat3 = np.array(data3)

mat = np.r_[mat1[:,0], mat2[:,0], mat3[:,0]]
print(mat.shape)
リスト2 CSVファイル読み込み

辞書データの作成

 出現する単語に一意のインデックス番号を付与し、インデックス←→単語文字列の両引きが出来る辞書を作成する。

 単語数が多いとニューラルネットの出力次元数が大きくなり、学習コストが高まる。そこで、出現頻度が低い単語(以下の事例では3回以下)は思い切って割り切り、十把ひとからげに「UNK」文字列に置き換える。これで、筆者の例では出力次元数が1/3以下になった。

 ソースコード上では念のために置き換えた単語のリストを作成してあるが、その後の処理では使用していない。

Python
words = sorted(list(set(mat)))
cnt = np.zeros(len(words))

print('total words:', len(words))
word_indices = dict((w, i) for i, w in enumerate(words))  # 単語をキーにインデックス検索
indices_word = dict((i, w) for i, w in enumerate(words))  # インデックスをキーに単語を検索

# 単語の出現数をカウント
for j in range (0, len(mat)):
  cnt[word_indices[mat[j]]] += 1

# 出現頻度の少ない単語を「UNK」で置き換え
words_unk = []                           # 未知語一覧

for k in range(0, len(words)):
  if cnt[k] <= 3 :
    words_unk.append(words[k])
    words[k] = 'UNK'

print('低頻度語数:', len(words_unk))    # words_unkはunkに変換された単語のリスト

words = sorted(list(set(words)))
print('total words:', len(words))
word_indices = dict((w, i) for i, w in enumerate(words))  # 単語をキーにインデックス検索
indices_word = dict((i, w) for i, w in enumerate(words))  # インデックスをキーに単語を検索
リスト3 辞書データ作成

訓練データ作成

 元データは文字列のリストなので、これを学習データにするには、先に作った辞書を使って数字に置き換える必要がある。置き換えの際に、低頻度単語には「UNK」文字列のインデックスを使用する。

 入力の1文字に対し、出力は前後n文字の合計2n文字なので、入力次元は1、出力次元は2nである。ソース上では前後の文字数nを変数maxlenで指定している。

 汎化性能獲得のため、一般にはコーパスを訓練データ/評価データ/テストデータの3つに分割するが、今回は未知データを予測の対象としないので、全データを訓練データに使用する。

Python
maxlen = 10                   # 前後の語数

mat_urtext = np.zeros((len(mat), 1), dtype=int)
for i in range(0, len(mat)):
  # 出現頻度の低い単語のインデックスをunkのそれに置き換え
  if mat[i] in word_indices :       
    mat_urtext[i, 0] = word_indices[mat[i]]
  else:
    mat_urtext[i, 0] = word_indices['UNK']

print(mat_urtext.shape)

len_seq = len(mat_urtext) - maxlen
data = []
target = []
for i in range(maxlen, len_seq):
  data.append(mat_urtext[i])
  target.extend(mat_urtext[i-maxlen:i])
  target.extend(mat_urtext[i+1:i+1+maxlen])

x_train = np.array(data).reshape(len(data), 1)
t_train = np.array(target).reshape(len(data), maxlen*2)

z = zip(x_train, t_train)
nr.seed(12345)
nr.shuffle(z)                 # シャッフル
x_train, t_train = zip(*z)

x_train = np.array(x_train).reshape(len(data), 1)
t_train = np.array(t_train).reshape(len(data), maxlen*2)

print(x_train.shape, t_train.shape)
リスト4 訓練データ作成

Python 2対応。Python 3の場合は、

z = zip(x_train, t_train)の行を、

z = list(zip(x_train, t_train))に書き換える必要がある。

これを行わない場合、shuffle関数の行でTypeError: object of type 'zip' has no len()というエラーが発生する。

ニューラルネットワーク構築

 ニューラルネットはKerasのSequentialモデルを用いて構築する。先に述べたように、本質的にはEmbeddingとDenseの2層だけである。

 学習の発散を防ぐため、EarlyStopping(=学習を適切なタイミングで早期に打ち切る機能)を使用する。一般には評価データから算出される損失関数val_lossを監視対象にし、これが増加に転じたところで学習を停止させるが、今回の場合は評価データを使用しないことと、損失関数lossが減少を続けていても、正解率categorical_accuracyはかえって値が悪くなることがあるので、categorical_accuracyを監視対象に指定する。

 また、重み行列の初期値は乱数で与えられるが、これが試行のたびに毎回同じになるように、乱数のseed指定を行う。

Python
class Prediction :
  def __init__(self, input_dim, output_dim):
    self.input_dim = input_dim
    self.output_dim = output_dim
            
  def create_model(self):
    model = Sequential()
    model.add(Embedding(self.input_dim, self.output_dim, input_length=1, embeddings_initializer=uniform(seed=20170719)))
    model.add(Flatten())
    model.add(Dense(self.input_dim, use_bias=False, kernel_initializer=glorot_uniform(seed=20170719)))
    model.add(Activation("softmax"))
    model.compile(loss="categorical_crossentropy", optimizer="RMSprop", metrics=['categorical_accuracy'])
    print('#2')
    return model
  
  # 学習
  def train(self, x_train, t_train,batch_size, epochs, maxlen, emb_param) :
    early_stopping = EarlyStopping(monitor='categorical_accuracy', patience=1, verbose=1)
    print ('#1', t_train.shape)
    model = self.create_model()
    #model.load_weights(emb_param)    # 埋め込みパラメーターセット。ファイルをロードして学習を再開したいときに有効にする
    print ('#3')
    model.fit(x_train, t_train, batch_size=batch_size, epochs=epochs, verbose=1,
          shuffle=True, callbacks=[early_stopping], validation_split=0.0)
    return model
リスト5 ニューラルネットワーク本体

メイン処理

 上記で定義したニューラルネットを動かして、学習を進める。入力次元数input_dimは単語数、出力次元数output_dimは埋め込みベクトルの次元数である。

 今回はラベルデータ(すなわち予測単語)が複数あるので、ニューラルネットに渡すラベルデータに工夫を施す。図4にイメージを示す。

 1つの入力に対してn個のラベルベクトルがある場合、普通のやり方は図4の左側のように、それぞれのラベルベクトルごとにcategorical_crossentropy損失関数計算を合計n回行って、それらを足し込むことになる。すなわち、入力データ数がn倍になったのと同様の計算量が必要になる。

 ところが、入力ベクトルが同じなら、この計算はラベルベクトル側から見て線形である。したがって、図4の右側のように分配法則的に、n個のラベルベクトルを足し合わせた後でcategorical_crossentropy損失関数計算を行えば、その計算回数は1回で済む。

 この考えに従って、ラベルベクトルを「n-hotベクトル」的に整形して、訓練を実施する。変則的なやり方であるが、計算量が大幅に削減できるので、今回はこのやり方を採用した。

 なお、後で使えるように、学習済みのパラメーターをセーブしてある。

図4 ラベルデータ加工の考え方
Python
vec_dim = 100
epochs = 10
batch_size = 200
input_dim = len(words)
output_dim = vec_dim

emb_param = 'param_skip_gram_2_1.hdf5'    # 学習済みパラメーターファイル名
prediction = Prediction(input_dim, output_dim)
row = t_train.shape[0]

t_one_hot = np.zeros((row, input_dim), dtype='int8')    # ラベルデータをN-hot化
for i in range(0, row) :
  for j in range(0, maxlen*2):
    t_one_hot[i, t_train[i,j]] = 1
  
x_train = x_train.reshape(row,1)
model = prediction.train(x_train, t_one_hot, batch_size, epochs, maxlen, emb_param)

model.save_weights(emb_param)           # 学習済みパラメーターセーブ
リスト5 メイン処理

評価

 埋め込み表現のベクトル演算を試してみる。実は、get_weights()load_weights()save_weights()で扱っているパラメーターファイルは、重み行列のリストになっている。この0番目の行列が埋め込み行列であり、i行目の行ベクトルがインデックスiの単語のベクトル表現になっている。したがって、埋め込み行列に各単語のone-hotベクトルを掛けることで、その単語の埋め込みベクトル表現を得ることができる。

 演算の結果がどの単語に近いのかは、「コサイン類似度」で評価する。コサイン類似度とは一言でいうと、2つのベクトルが見込む角度の余弦である。当然、値の範囲は-1から1で、1に近いほど(要は大きいほど)類似度が高いと評価する。

 以下のコードのwordX(X=0~2)に試してみたい単語を記述し、実行すると、第5候補まで表示される。なお、語彙に無い単語を指定すると、エラーになる。

Python
param_lstm = model.get_weights()
param = param_lstm[0]
word0 = '一'
word1 = '1'
word2 = '2'
vec0 = param[word_indices[word0],:]
vec1 = param[word_indices[word1],:]
vec2 = param[word_indices[word2],:]

vec = vec0 - vec1 + vec2
vec_norm = math.sqrt(np.dot(vec, vec))

w_list = [word_indices[word0], word_indices[word1], word_indices[word2]]
dist = -1.0
m = 0
for j in range(0, 5) :
  dist = -1.0
  m = 0
  for i in range(0, len(words)) :
    if i not in w_list :
      dist0 = np.dot(vec, param[i,:])
      dist0 = dist0 / vec_norm / math.sqrt(np.dot(param[i,:], param[i,:]))
      if dist < dist0 :
        dist = dist0
        m = i
  print('第' + str(j+1) + '候補:')
  print('コサイン類似度=', dist, ' ', m, ' ', indices_word[m])
  w_list.append(m)
リスト6 埋め込みベクトル演算の評価

 冒頭に記述したとおり、「一」 - 「1」 + 「2」は「二」になる。ただし、算用数字は全角で指定すること。

 また、「一」 - 「1」 + 「3」も「三」になった。

 しかし、「一」 - 「1」 + 「4」は「四」ではなく「二」になった。

 一方、「父」 - 「娘」 + 「早苗」の答えとして、「岩瀬」を得た。早苗というのは、作品『黒蜥蜴』の登場人物、岩瀬庄兵衛の娘である。この事例では人物関係を見事に再現できたが、大抵はピントのずれた回答が戻ってくる。これは学習データの枠内での結果なので、偏りが出るのはやむを得ないところである。汎用性の高い結果を得るには、やはりそれなりの学習データ量が必要ということであろう。

 上記のコードをちょっと修正すると、指定した単語に類似していると判定された単語のリストを得ることができる(具体的には、コードのword0類似度を調べたい単語を指定し、vec = param[word_indices[word0],:] とする。word1word2、およびベクトルの足し算は不要なので削除する)。筆者は江戸川乱歩の著作を学習データに使用したので、「明智」で試したところ、以下の結果を得た。

順位 コサイン類似度 単語
第1候補0.75769793602
第2候補0.739668367405早苗
第3候補0.726747738227……
第4候補0.718360613538夫人
第5候補0.717179543556岩瀬
表1 「明智」に類似すると評価された単語

 「小五郎」とか「探偵」とかを期待したのだが、一番類似した単語が「?」だというのは文字通り謎である。あとの人名は前述の、明智小五郎が活躍する探偵小説「黒蜥蜴」の登場人物である。

 そこで、「小五郎」で試してみた。以下がその結果である。

順位 コサイン類似度 単語
第1候補0.725420430273探偵
第2候補0.691843578865ゆうべ
第3候補0.661063818008エエ
第4候補0.66042666185明智
第5候補0.659064832085ホテル
表2 「小五郎」に類似すると評価された単語

 今度は、「探偵」とか「明智」とか、期待する単語が候補に現れた

Further Study

 冒頭に例示したMikolovの論文では、計算量を削減するため、出力ベクトルの個数を入力ごとにランダムに増減させている。また、計算量削減の手法として、ネガィテブサンプリングというものがよく用いられる。ネット上に実装例も見いだせるので、興味のある方は参考にされたい。

終わりに

 本稿では、word2vecの簡単な実装例を紹介した。word2vecは意味の近さによる単語の分類(正確には文章中の出現位置の類似性)が可能になるため、自然言語処理における広範囲の応用が期待できると言われている。大量の訓練データを必要とするところが大変であるが、実際にword2vecを使用するような局面で本稿がご参考になれば、幸いである。

  • このエントリーをはてなブックマークに追加
ディープラーニング習得、次の一歩
1. Kerasを用いたディープラーニング(LSTM)による株価予測

ディープラーニングのチュートリアルが一通り終わったら、次に何をやる? 今回は、誰にでも簡単にできる「株価予測」をテーマに、LSTMのニューラルネットワークを、Kerasを使って実装する方法を説明する。

2017年12月11日(月)
ディープラーニング習得、次の一歩
2. 【現在、表示中】≫ 挑戦! word2vecで自然言語処理(Keras+TensorFlow使用)

自然言語のベクトル化手法の一つである「word2vec」を使って、単語間の関連性を表現してみよう。Keras(+TensorFlow)を使って実装する。

2018年1月30日(火)
ディープラーニング習得、次の一歩
3. word2vecリターンズ! 品詞分類による精度改善

Keras(+TensorFlow)を使って自然言語のベクトル化手法「word2vec」を実装。学習データに品詞分類を追加することによって、前回よりも予測精度が改善するかを検証する。

2018年4月5日(木)
ディープラーニング習得、次の一歩
4. ディープラーニングで自動筆記 - Kerasを用いた文書生成(前編)

ディープラーニングによる自然言語処理の一つ「文書生成」にチャレンジしてみよう。ネットワークにLSTM、ライブラリにKeras+TensorFlowを採用し、徐々に精度を改善していくステップを説明する。

2018年7月5日(木)
ディープラーニング習得、次の一歩
5. ディープラーニングで自動筆記 - Kerasを用いた文書生成(後編)

「文書生成」チャレンジの後編。ネットワークにLSTM、ライブラリにKeras+TensorFlowを採用し、さらに精度を改善していく。最後に、全然関係ない入力文章から、江戸川乱歩風文書が生成されるかを試す。

2018年8月8日(水)
Deep Insider の SNS :
DATUM AI セミナー≪マーケティング編≫ | DATUM STUDIO株式会社