-
対象:プログラミングはしたことあるが、機械学習はやったことがない人
-
言語:Python3
対話エージェントシステム概要
この記事では、簡単な対話エージェントをプログラムすることで、自然言語処理プログラミングの要素の一部を体験していきます。
名前
プログラミングにおいて名前は重要です。これから作る対話エージェントにも名前を付けましょう(プログラムを作るとき、命名に一番時間を取られたりすることもありますね)。
ここでは「Tiwa」という名前を付けることにしました。
もっとイケてるオリジナルの名前を付けると、よりシステムに愛着が湧くかもしれません。その場合は以後の Tiwa
を適宜オリジナルの名前に読み替えてください。
対話エージェントシステム
これから作る対話エージェントプログラムは、以下のように動作します。 最終的に Tiwa
は、ユーザーの入力(この例では「あなたの名前は何?」)に対して、適切な応答(この例では「僕はTiwaといいます」)を返せるようになることが目標です。
training_data = [
(
[
'名前は?',
'なんて呼べばいい?',
...,
],
'僕はTiwaといいます',
),
(
[
'好きな食べ物は?',
'何が食べたい?',
...,
],
'ラーメン!',
),
(
[
'最近いいことがあったんだ',
'美味しいお店を見つけたよ',
...,
],
'それはよかったですね!',
),
...,
]
tiwa = Tiwa()
tiwa.train(training_data)
print(tiwa.reply('あなたの名前は何?')) # '僕はTiwaといいます'
(あくまでイメージですよ!)
まず、ユーザーからの入力文として想定される文をある程度用意し、同じような意味・文意・話題の文をまとめておきます。例えば、「名前は?」や「なんて呼べばいい?」といった入力文は“名前を聞く質問”としてまとめ、「好きな食べ物は?」や「何が食べたい?」といった入力文は“好きな食べ物を聞く質問”としてまとめることができます。これらのまとまり(ここでは文の分類)を クラス(class) と呼びます。プログラミング言語のクラス(class)とは違うものなので、混同しないように気をつけてください。
また、入力文の各クラスについて、Tiwaの応答文を予め決めておきます。「名前は?」や「なんて呼べばいい?」などの“名前を聞く質問”クラスに対しては「僕はTiwaといいます」と応答し、「好きな食べ物は?」や「何が食べたい?」などの“好きな食べ物を聞く質問”クラスに対しては「ラーメン!」と応答する、といった具合です。
次に、この クラス分けされた複数のユーザー入力文例と、各クラスに対する応答の組 をTiwaに 学習(train) させます。 「学習」が具体的にどのようなプログラムで表現される、どのような処理なのかはこのあとしっかり解説していきますが、 学習によってTiwaは「どのような質問文がきたらどの回答文を返せばよいか」のパターンを習得します。
その後ユーザーが文を入力すると、Tiwaはその文がどのクラスに該当するかを 予測(predict) し、対応する応答文を返します。 これによって、(一問一答の)会話を実現することができます。
(これは、一般的にText classificationと呼ばれる問題設定です。)
ここで大事なのは、ユーザーからの入力文が、学習のときに与えた文例と完全に一致していなくても、対話エージェントは動作するということです。上の例で言うと、ユーザーが「あなたの名前は何?」という文章をTiwaに入力していますが、たとえ予め用意し学習に用いた文章例群 training_data
に「あなたの名前は何?」という文章が含まれていなかったとしても、Tiwaは「僕はTiwaです」という応答を返せるようになります。 つまり、Tiwaは、ユーザー入力を training_data
に含まれた文と単純に照合して応答するわけではないのです。そのような、ある入力Xに対しては応答Yを返す、という単純なルールを大量に用意するだけのやり方では、用意すべきルールが膨大な数になってしまい、すぐに破綻してしまいます。 この対話エージェントはそうではなく、予め与えられたいくつかのクラスとそれぞれに属する文の例を元に、それぞれのクラスに分類されうる文の傾向のようなものを自動で獲得することで、予め与えられた文とはある程度異なる新たな文が入力されても、その傾向に当てはめて推論し、適した応答を選べるようになります。 このような、学習に使わなかったデータについても正しく予測を行えることを 汎化(generalization) といい、機械学習の重要な性質の一つになります。
以上が対話システムのざっくりした概要でした。 機械学習システムをプログラムとして組み上げるために、もう少し具体化してみましょう。
training_data = [
(0, '名前は?'),
(0, 'なんて呼べばいい?'),
...,
(1, '好きな食べ物は?'),
(1, '何が食べたい?'),
...,
(2, '最近いいことがあったんだ'),
(2, '美味しいお店を見つけたよ'),
...
]
tiwa = Tiwa()
tiwa.train(training_data)
predicted_class = tiwa.predict('あなたの名前は何?')
replies = [
'僕はTiwaといいます',
'ラーメン!',
'それは良かったですね!',
]
print(replies[predicted_class]) # '僕はTiwaといいます'
各クラスにはIDをつけて管理することにします。
“名前を聞く質問”クラスは 0
、“好きな食べ物を聞く質問”クラスは 1
、…というように、連続した自然数をクラスIDとして振っていきます(プログラミングではよくあるやつですね)。 すると、Tiwaは「自然文を入力し、その文が属すると予測されるクラスのクラスIDを出力する」システムとして設計されることになります。 それと対応して、学習のための入力データ 学習データ(training data) は、入力文例とクラスIDの組のリストになります(talk_with_dialogue_agent.py(より具体的なイメージ)の training_data
変数。文とクラスIDのtupleのlist)。
学習後、ユーザーが文を入力すると、Tiwaはその文が属するクラスのIDを予測し、予測されたクラスIDに対応する回答文を返します。 応答文は単純にlistとして用意しておけば、クラスIDをインデックスとしてアクセスできます(replies
変数)。
以上で作るべきシステムがはっきりしましたね。「対話エージェント」をもう少しブレイクダウンして、「文を入力すると、その文が属するクラスを予測し、クラスIDを出力するシステム」を作っていくという目標が定まりました。
それでは、このシステムががどのような処理で構成されるのか(Tiwa
クラスを具体的にどう実装すればよいのか)、詳しく見ていきましょう。
形態素解析
Tiwa
に与えられるデータは、学習データ中の入力文例も実際のユーザの入力文も、ただの日本語の文章で、プログラム的に見ればただのバイト列です。 このデータをどのようにプログラムで扱えばいいのでしょう?
最初のステップは、文を単語に分解することです。 文を単語に分解できれば、各単語に番号を割り当てられるようになり、そうすると文を数値の配列として扱うことができるようになります。
私は私のことが好きなあなたが好きです 私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です
単語 | 番号 |
---|---|
私 |
0 |
あなた |
1 |
好き |
2 |
こと |
3 |
が |
4 |
は |
5 |
の |
6 |
な |
7 |
です |
8 |
[0, 5, 0, 6, 3, 4, 2, 7, 1, 4, 2, 8]
数値の配列にすることで、かなりプログラムで扱いやすくなりますね(文を数値の配列に変換したあとの具体的な処理は次節以降で解説します)。
英語のように単語の間にスペースのある言語であれば、文を単語に区切る処理は不要なことがほとんどなのですが、 日本語のように単語の間にスペースが無い言語は、単語の境界を判定する処理を行なって、文を分解する必要があります。
Note
|
ではMeCabが行なうのは何なのかというと、MeCabが文を分割するとき、どの単位に区切るかは後述する「辞書」に依存します。 これによって、本当の意味での形態素解析ができたり、複合語は1つのまとまりとして扱って文を分解できたりします。 この解説では一旦、「単語」や「形態素」をあまり厳密に区別せずに使います。 |
コマンドラインから試してみる
シェルで mecab
コマンドを実行すると、入力受付状態になります。
$ mecab
この状態で、日本語文を入力し、Enterを押すと、形態素解析結果が表示されます。
$ mecab
私は私のことが好きなあなたが好きです
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
こと 名詞,非自立,一般,*,*,*,こと,コト,コト
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ
あなた 名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
EOS
正しく単語ごとに分割され、品詞などの情報も付与されていますね。
各行のフォーマットは、以下のようになっています[2]。
表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
(このフォーマットは後述する 辞書 に依存しており、どの辞書を使うかによって変わります。)
Pythonから呼び出す
pipでインストールできます。
$ pip install mecab-python3
import MeCab
tagger = MeCab.Tagger()
print(tagger.parse('私は私のことが好きなあなたが好きです'))
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ は 助詞,係助詞,*,*,*,*,は,ハ,ワ 私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ の 助詞,連体化,*,*,*,*,の,ノ,ノ こと 名詞,非自立,一般,*,*,*,こと,コト,コト が 助詞,格助詞,一般,*,*,*,が,ガ,ガ 好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ あなた 名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ が 助詞,格助詞,一般,*,*,*,が,ガ,ガ 好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス EOS
parse()
で、shellからMeCabを実行したときの結果がそのまま得られました。 しかし、この形式の文字列が得られても、改行で区切ったり \t
や ,
で区切ったりしてパースする必要があり、不便です。
よりプログラムで扱いやすい形式の返り値が得られるメソッドも用意されています。
import MeCab
tagger = MeCab.Tagger()
tagger.parse('') # workaround
node = tagger.parseToNode('私は私のことが好きなあなたが好きです')
print(node.surface)
print(node.next.surface)
print(node.next.next.surface)
print(node.next.next.next.surface)
print(node.next.next.next.next.surface)
print(node.next.next.next.next.next.surface)
私 は 私 の こと
このように、 parseToNode
で得られるnodeオブジェクトは、わかち書き結果の一番最初の単語を表しており、次の単語を表すnodeオブジェクトへの参照 next
を次々に辿っていくことで、わかち書き結果の各単語を表すnodeオブジェクトに順々にアクセスすることができます。 そして、各nodeオブジェクトの surface
プロパティから、対応する単語の表層形 [3] を得ることができます。
また、 node.feature
には品詞などの情報が格納されています。
>>> node.next.surface
'私'
>>> node.next.feature
'名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ'
(mecab-python3
にはバージョン0.7時点で、MeCab0.996と合わせて使うと、初回の tagger.parseToNode()
呼び出しで得られる node
が正しく動作しないというバグがあります。 そのため、一回 tagger.parse('')
を実行して回避しています。)
以下のように、node.next.next…
を最後まで辿るループを回せば、すべての単語の情報を得ることができます。
import MeCab
tagger = MeCab.Tagger()
tagger.parse('') # workaround
node = tagger.parseToNode('私は私のことが好きなあなたが好きです')
while node:
print(node.surface)
node = node.next
私 は 私 の こと が 好き な あなた が 好き です
これを使って、与えられた文字列をわかち書きして結果を単語のlistとして返す、簡単な関数を作ってみましょう。
import MeCab
tagger = MeCab.Tagger()
tagger.parse('') # workaround
def tokenize(text):
node = tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
この tokenize
関数を実行してみるとこのようになります。
>>> tokenize('私は私のことが好きなあなたが好きです')
['私', 'は', '私', 'の', 'こと', 'が', '好き', 'な', 'あなた', 'が', '好き', 'です']
MeCabを使うことで、わかち書きを行う関数を簡単に実装することができました。
Note
|
実行結果
私 は 私 の こと が 好き な あなた が 好き です これを使って、以下のようなわかち書き関数の実装も考えられます。 tokenizer_buggy.py
しかし実はこの実装には問題があります。 半角スペースを区切り文字にしているので、半角スペースを含む単語が登場した時に、区切り文字としての半角スペースと単語の一部としての半角スペースが混ざってしまい、正しくわかち書きできません。 後述する辞書によっては、半角スペースを含んだ単語を扱うことになります。この実装は避けたほうがよいでしょう。 |
辞書について
MeCabによるわかち書きは、予め構築された 辞書 に基いて行われます。 辞書とは、その名の通り、様々な単語を網羅したデータベースです。
シェルでMeCabを実行では mecab
コマンドを引数なしで実行し、明示的には辞書を指定しなかったため意識しませんでしたが、 その場合でもデフォルト辞書を用いてMeCabが起動するようになっており、やはり背後では何らかの辞書が読み込まれています。
MeCabにおける辞書は、単語・形態素の列挙というだけではなく、より詳細な情報も保持するデータベースです。 例えば、MeCab実行結果例で表示された以下の出力は、MeCabが利用する辞書に登録されている情報です。
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
このように、文を単語・形態素に分割したあと、各要素に付与された品詞や原型などの情報を得ることができます(MeCabの出力フォーマットで紹介したとおりです)。 この出力は利用している辞書を元にしているため、MeCabを使った形態素解析によって得られる情報は、辞書にどのような情報が登録されているかに依存します。
辞書はまた、このような詳細な形態素情報だけではなく、形態素解析の結果に直接影響を与える「ある形態素の出現しやすさ」や「形態素どうしの連結しやすさ」という情報も保持しています。 詳細な理論説明は省きますが、MeCabはこの情報を使ってわかち書きを行います。 つまり、辞書が持っているパラメータは、わかち書き(形態素解析)の精度にも直接影響を与えます。
では、具体的にどのような辞書があり、どれを選べば良いのでしょうか。
- IPA辞書
-
MeCabが公式に推奨している最も基本的な辞書です。基本的な語彙がカバーされています。IPAコーパスというデータに基いています。
- Unidic辞書
-
UniDicというデータに基づいた辞書です。「短単位」という、表記ゆれのない統一した表記を得ることができます。厳密な意味での「形態素解析」に近く、分割される単位が小さいことも特徴です("自動車"が"自動 / 車"に分割されるなど)。
- JUMAN辞書
-
MeCabとは別のJUMANという形態素解析器で使われている辞書をMeCab用に移植した辞書です。 IPAdicとは違うポリシーで品詞情報が付与されています。 また、代表表記などのメタ情報が付与されているのも特徴です(例えば、「行う」と「行なう」の代表表記はともに「行う」となる)。 京都コーパスというデータに基いています。
- NEologd
-
IPA辞書を基に、単語の数を大幅に拡張した辞書です。 品詞情報などの形式はIPAdicを踏襲しています。 インターネットから単語をクローリングして語彙を拡張しており、頻繁に更新されているため、新語への対応力がとても高いです。 UniDicなどと異なり、この辞書では固有名詞などのまとまりは分割されません。形態素解析ではなく、「固有表現抽出」のための辞書と言われます。
NEologdのインストールと実行
紹介した辞書のうち、NEologdを使ってみましょう。
公式ドキュメント( https://github.com/neologd/mecab-ipadic-neologd )などを参考に、適宜インストールしてください。
インストール後、shellで mecab
コマンドを実行するときに -d
オプションで辞書を指定します。
$ mecab -d /usr/lib/mecab/dic/mecab-ipadic-neologd
/usr/local/lib/mecab/dic
は辞書のインストール先ディレクトリですが、環境によって変わります。mecab-config --dicdir
コマンドで確かめることができます。
$ mecab-config --dicdir
/usr/lib/mecab/dic
実際に固有名詞や新語を含んだ文を分かち書きさせてみましょう。そのような単語も分割されることなく一つの単語として認識されることが分かると思います。 次の例では「Deep Learning」が1語として認識され、原形が「深層学習」、読みが「ディープラーニング」であるという情報まで得られています(IPAdicなどで試すと「Deep」と「Learning」の2語に分かれてしまいます)。 IPAdicの語彙が収集された当時は存在しなかった「Deep Learning」という単語も、NEologdには収録されているからです。
近年Deep Learningの研究が盛んだ
近年 名詞,副詞可能,*,*,*,*,近年,キンネン,キンネン
Deep Learning 名詞,固有名詞,一般,*,*,*,深層学習,ディープラーニング,ディープラーニング
の 助詞,連体化,*,*,*,*,の,ノ,ノ
研究 名詞,サ変接続,*,*,*,*,研究,ケンキュウ,ケンキュー
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
盛ん 名詞,形容動詞語幹,*,*,*,*,盛ん,サカン,サカン
だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
EOS
それでは次にPythonからMeCabを呼び出すときにNEologdを辞書として利用するようにしてみましょう。 shellからmecabを使うときに指定するのと同様のオプションを MeCab.Tagger()
の引数に指定することができるので、以下のようなコードで辞書を指定してPythonからMeCabを利用できます。
tagger = MeCab.Tagger('-d /usr/lib/mecab/dic/mecab-ipadic-neologd')
tokenizer.pyでNEologdを使うようにしてみると、わかち書きの結果は以下のようになります。NEologdを利用したわかち書きができていますね。
>>> tokenize('近年Deep Learningの研究が盛んだ')
['近年', 'Deep Learning', 'の', '研究', 'が', '盛ん', 'だ']
特徴ベクトル化
前節までで、日本語の文をわかち書きできるようになりました。プログラム上の計算で扱うために、このわかち書きされた文章(文字列)をさらに変換し、コンピュータで計算可能な形式する必要があります。より具体的に言うと、1つの文章を1つの ベクトル で表します。
この「ベクトル」、高校や大学の数学で扱う「ベクトル」そのものではあるのですが、ここではまだベクトルの演算だとか線形代数といった話は出てきません。 この段階では、ある決まった個数の数値のまとまり、程度の理解で差し支えありません。 Python的に言えば、 数値(intやfloat)を要素とする、ある決まった長さのlistもしくはnumpy配列 です(機械学習のプログラミングではnumpy配列を使うことがほとんどです)。 この「ある決まった長さ」は10とか100とか1000とか10000とか、システム・手法によって変わります。
また、この「ある決まった長さ」をベクトルの 次元数 といいます。 以下のようななんらかの特徴ベクトル vec
があったとすると、その次元数は5となります(len(vec) == 5
)。「次元数5の特徴ベクトル」「5次元の特徴ベクトル」といったりします。
vec = [0.1, 0.2, 0.3, 0.4, 0.5]
文をベクトルに変換するプログラムは、大枠として以下のようになります。 文をわかち書きによって単語ごとに分解して「単語のlist」として表現したあと、その単語のlistをもとに何らかの手法でベクトルを得ます。
tokens = tokenize('私は私のことが好きなあなたが好きです')
# tokens == ['私', 'は', '私', 'の', 'こと', 'が', '好き', 'な', 'あなた', 'が', '好き', 'です']
vector = vectorize(tokens)
# vector == [0, 0.35, 1.23, 0, 0, ..., 2.43]
このベクトルのことを 特徴ベクトル(feature vector) 、特徴ベクトルを計算することを 特徴抽出(feature extraction) といいます。また、このような操作を「文字列を特徴ベクトル化する」と言ったりもします。 「特徴ベクトル」という用語には、元となった文章が持っていた特徴が反映された数値列、という意味が込められています。 逆に言えば、元となった文字列の情報をうまく反映した数値列を出力できるよう、 vectorize()
をうまく設計します。
次の節で、特徴抽出の手法について具体的に解説していきます。
Bag of Words
特徴抽出の手法の1つで、とても基本的で広く使われているのが Bag of Words です。省略してBoWと表記することもあります。
処理の内容を見ていきましょう。
1: 各単語に番号(インデックス)を割り当てる
文字列はわかち書きによって単語に分解されています。 各単語にユニークな番号=インデックスを割り当てます。
私は私のことが好きなあなたが好きです 私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です
私はラーメンが好きです 私 / は / ラーメン / が / 好き / です
単語 | 番号 |
---|---|
私 |
0 |
あなた |
1 |
ラーメン |
2 |
好き |
3 |
こと |
4 |
が |
5 |
は |
6 |
の |
7 |
な |
8 |
です |
9 |
2: 各単語(番号)の登場回数をカウントする
「私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です」
単語 | 番号 | 登場回数 |
---|---|---|
私 |
0 |
2 |
あなた |
1 |
1 |
ラーメン |
2 |
0 |
好き |
3 |
2 |
こと |
4 |
1 |
が |
5 |
2 |
は |
6 |
1 |
の |
7 |
1 |
な |
8 |
1 |
です |
9 |
1 |
「私 / は / ラーメン / が / 好き / です」
単語 | 番号 | 登場回数 |
---|---|---|
私 |
0 |
1 |
あなた |
1 |
0 |
ラーメン |
2 |
1 |
好き |
3 |
1 |
こと |
4 |
0 |
が |
5 |
1 |
は |
6 |
1 |
の |
7 |
0 |
な |
8 |
0 |
です |
9 |
1 |
3: 各単語(番号)の登場回数を並べる
1つの文章について、各単語の登場回数を並べてlistにします。 各単語に割り当てた番号(インデックス)がそのままlistのインデックスとなり、対応する位置に単語の出現回数を代入します。
# 私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です
bow0 = [2, 1, 0, 2, 1, 2, 1, 1, 1, 1]
# 私 / は / ラーメン / が / 好き / です
bow1 = [1, 0, 1, 1, 0, 1, 1, 0, 0, 1]
このようにして得られたベクトル(Bag of Wordsの例における bow0
や bow1
)が、特徴ベクトルとなります。 この変換手法のことをBag of Wordsと呼んだり、得られるベクトルのことをBag of Wordsと呼んだりします (「文をBag of Wordsで特徴ベクトル化する」と言ったりも、「文をBag of Words化する」と言ったりもします)。
この手法によって得られるベクトルの次元数は、語彙の数になります。 上の例では語彙の数、すなわち単語の種類数が10なので、特徴ベクトル bow0
, bow1
の次元数も10になります(len(bow0) == 10
)。つまり、語彙を固定しておけば、入力される文字列の長さがどうであれ、常に一定のサイズのベクトルが得られることになります。
以上ような処理によって、1つの文章を1つのベクトル(list)に変換することができました。 日本語で書かれた元々の文は、長さの一定しない、コンピュータにとっては大した意味のないバイト列でした。 しかし、Bag of Words化することで、ある決まった長さで、コンピュータにとって扱いやすい数値の配列に変換することができます。
Bag of Wordsの実装
import MeCab
(1)
tagger = MeCab.Tagger()
tagger.parse('') # workaround
def tokenize(text): (2)
node = tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
def calc_bow(tokenized_texts): (3)
# Build vocabulary (4)
vocabulary = {}
for tokenized_text in tokenized_texts:
for token in tokenized_text:
if token not in vocabulary:
vocabulary[token] = len(vocabulary)
n_vocab = len(vocabulary)
# Build BoW Feature Vector (5)
bow = [[0] * n_vocab for i in range(len(tokenized_texts))]
for i, tokenized_text in enumerate(tokenized_texts):
for token in tokenized_text:
index = vocabulary[token]
bow[i][index] += 1
return vocabulary, bow
# 入力文のlist
texts = [
'私は私のことが好きなあなたが好きです',
'私はラーメンが好きです',
'富士山は日本一高い山です',
]
tokenized_texts = [tokenize(text) for text in texts]
vocabulary, bow = calc_bow(tokenized_texts)
-
わかち書きのためのMeCabの準備(tokenizer.pyと同じ)
-
わかち書き関数(tokenizer.pyと同じ)
-
Bag of Words計算関数
-
辞書生成ループ
-
単語出現回数カウントループ
>>> vocabulary
{'私': 0, 'は': 1, 'の': 2, 'こと': 3, 'が': 4, '好き': 5, 'な': 6, 'あなた': 7, 'です': 8, 'ラーメン': 9, '富士山': 10, '日本一': 11, '高い': 12, '山': 13}
>>> bow
[
[2, 1, 1, 1, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1]
]
このプログラムはBag of Wordsの説明に即した形で、1つ目のforループで辞書の生成、2つ目のforループで各単語の出現回数のカウントを行っています。
この例では文を3つ入力していて、それぞれからBoWが得られるので、 bow
は2次元配列で1次元目の長さは入力した文の数である3になります(len(bow) == 3
)。 また、語彙 vocabulary
は13単語得られました。従って、それぞれのBoWのサイズ=特徴ベクトルの次元数は13になります(len(bow[0]) == 13
)。
Note
|
応用課題
辞書の生成と単語の出現回数のカウントを1つのループで行うことで、処理を効率化できます。 やってみましょう。 (辞書の作成が完了する前にlistを生成してしまうと、単語を追加するたびにメモリを確保することになり、逆に効率が落ちてしまいます。dictなどをうまく使います。) |
scikit-learnによるBoWの計算
以上のように、BoWは非常に単純なアルゴリズムなので、一度は自分で実装してみることをおすすめします。 具体的に何が行われているのか、よく理解できます。
ただ、ここからはscikit-learnに用意されているBoW計算用クラスを利用することにします。 scikit-learnにはBag of Wordsを含む様々な特徴抽出アルゴリズムが用意されており、よく整理され統一されたAPIが提供されています。 このライブラリを利用してプログラムを書いておくことで、あとで様々なアルゴリズムを試したりするのが楽になります。
scikit-learnではBag of Wordsを計算する機能をもったクラスとして sklearn.feature_extraction.text.CountVectorizer
が提供されているので、これを使ってBoWを計算するコードを書いてみましょう。
import MeCab
from sklearn.feature_extraction.text import CountVectorizer
(1)
tagger = MeCab.Tagger()
tagger.parse('') # workaround
def tokenize(text): (2)
node = tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
texts = [
'私は私のことが好きなあなたが好きです',
'私はラーメンが好きです。',
'富士山は日本一高い山です',
]
# Bag of Words計算
vectorizer = CountVectorizer(analyzer=tokenize) (3)
vectorizer.fit(texts) (4)
bow = vectorizer.transform(texts) (5)
-
わかち書きのためのMeCabの準備(tokenizer.pyと同じ)
-
わかち書き関数(tokenizer.pyと同じ)
-
CountVectorizer
のコンストラクタにはanalyzer
引数でわかち書き関数を渡します。analyzer
を指定しない場合のデフォルト設定ではスペースで文を単語に区切る処理が行われますが、これは英語のような単語をスペースで区切る言語を想定した動作です。analyzer
にcallable(関数、メソッドなど)を指定するとそれが文の分割に使われるので、日本語を対象にする場合は、自前で実装したわかち書き関数を指定するようにします。 -
辞書の生成
-
BoWの計算
bag_of_words.pyでは辞書の生成と単語の出現回数のカウントが2つのループに分かれていましたが、 sklearn.feature_extraction.text.CountVectorizer
ではこの2つに対応して CountVectorizer.fit()
, CountVectorizer.transform()
というメソッドがあります。
CountVectorizer.fit()
で辞書が作成され、一度辞書の作成を行ったインスタンスでは、 transform()
を呼ぶことで、その辞書に基づいたBag of Wordsが生成されます。
Note
|
BoWはほとんどの要素がゼロのベクトルになります。 ある文をBoWに変換する時、その文中に登場しない単語に対応する要素はゼロになりますが、一般的にそのようなゼロ要素のほうが非ゼロ要素より多くなるからです。語彙が増えれば増えるほど、ゼロ要素が多くなる確率は上がります。 このような、ほとんどの要素の値がゼロで、一部の要素だけが非ゼロの値をもつようなベクトル・行列を 疎ベクトル(sparse vector) 、 疎行列(sparse matrix) といいます。 疎ベクトル・疎行列を表す際には、通常の多次元配列のように値を全てメモリに記録するのではなく、非ゼロ要素のインデックスと値のみを記録するようにしたほうがメモリ効率がよくなります。
本記事で扱うプログラムでは
(疎行列は |
未知語
辞書生成とBoW計算はそれぞれ分かれているため、辞書の作成に使う文集合と、BoWの計算対象となる文集合は一致しなくても構いません。
texts_A = [
...
]
vectorizer.fit(texts_A)
texts_B = [
...
]
bow = vectorizer.transform(texts_B)
この場合、辞書の生成はあくまで fit()
に渡された texts_A
に含まれる文に基いて行われるので、そこに登場しなかった単語が texts_B
に含まれる文のなかにあったとしても、BoWを計算するときには無視されます。
この記事の例だとこのようにBoWを計算する対象とは違う文集合で辞書を作成するメリットはあまりありませんが、例えば
-
texts_B
にあえて無視したい単語がある場合(無意味な絵文字、顔文字や、無意味な単語など)
texts_A
のほうに、 texts_B
を表現するのに十分な種類の単語が含まれるように注意する必要があります。
学習データのBoW化
Tiwaの学習につかう学習データ training_data.csv
を用意しましょう。以下のようなCSVです。
label,text
0,君の名前は?
0,自己紹介してよ
0,なんて呼べばいい?
0,お前誰だよ
0,君は何?
0,はじめまして!
0,あなたの名前は?
0,なんて名前なの?
0,名前を教えて
0,何ていうの?
1,好きな食べ物は?
1,カレー好き?
1,何食べたい?
1,好きな食べ物はある?
1,どんな食事が好きなの?
1,ラーメン好き?
1,好きな食べ物を教えてよ
1,今度ご飯食べに行こうよ
1,ご飯何が好き?
1,いつも何食べてるの?
2,最近いいことがあったんだ
2,美味しいお店を見つけたよ
前節の sklearn.feature_extraction.text.CountVectorizer
による実装を使って、この学習データをBag of Words化してみましょう。
from os.path import normpath, dirname, join
import MeCab
from sklearn.feature_extraction.text import CountVectorizer
import pandas as pd
(1)
tagger = MeCab.Tagger()
tagger.parse('') # workaround
def tokenize(text): (2)
node = tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
# データ読み込み
BASE_DIR = normpath(dirname(__file__))
csv_path = join(BASE_DIR, './training_data.csv') (3)
training_data = pd.read_csv(csv_path) (4)
training_texts = training_data['text']
# Bag of Words計算 (5)
vectorizer = CountVectorizer(analyzer=tokenize)
vectorizer.fit(training_texts)
bow = vectorizer.transform(training_texts)
-
わかち書きのためのMeCabの準備(tokenizer.pyと同じ)
-
わかち書き関数(tokenizer.pyと同じ)
-
ここではCSVファイルの読み込みに
pandas
を使っています。Pythonのcsv
モジュールなど、他の方法でも構いません。 -
sklearn_example.pyと同様、
sklearn.feature_extraction.text.CountVectorizer
を使ったBoW計算。
データのロード以外はsklearn_example.pyと同じです。
Bag of Wordsの性質
ここでは、Bag of Wordsがどういった性質の特徴ベクトルなのかを考えてみます。
まず、先述したとおり、Bag of Wordsは元となった文章の特徴を表したベクトルになっています。 例えば、
-
私は私のことが好きなあなたが好きです
-
私はラーメンが好きです
-
富士山は日本一高い山です
これを見て分かる通り、 「私はラーメンが好きです」のBoWは、「富士山は日本一高い山です」のBoWよりも「私は私のことが好きなあなたが好きです」のBoWに似ています。 これは、「私は私のことが好きなあなたが好きです」という文と、「私はラーメンが好きです」という文が、ともに“私”という主語の嗜好を表した文である、という類似性を捉えているといえます。
BoWは単語の出現頻度をベクトル化したものなので、この例でいえば「私」や「好き」といった単語に対応する値がプラスになり、似るのだ、というのは至極当然の結果のように思えます。確かにそのとおりなのですが、この例が示しているのは、「私」や「好き」といった 単語の出現頻度の類似性 が、これらの2文は“私”という主語の嗜好を表した文であるという 文意の類似性 をある程度捉えているということなのです。
プログラムで扱いやすい固定長の配列を得るために、元の日本語の文から単語の出現頻度という情報のみを抜き出して特徴ベクトルにしましたが、文意を捉えるためには(ある程度は)それで十分だというのが、この手法の面白いところです。
また、Bag of Wordsは単語の出現頻度という情報は保持していますが、それは逆に言うと他の情報は捨ててしまっているということです。 有名な例で「犬が人を噛んだ」と「人が犬を噛んだ」というのがあります[4]。この2つの文は全く違うことを表していて、区別するのが望ましいケースが大半だと思われますが、BoWではこの2つを区別することができません。どちらの文からBoWを作っても、同じベクトルができてしまいます。 この例ではBoWは単語の出現順序という情報を捨ててしまっていることが問題なのだと考えることができます。
ただし、情報を捨てていることが必ずしも悪いこととは言えないのが面白いところです。 例えば「明日友達と遊園地に遊びに行く」も「友達と遊園地に明日遊びに行く」も語順が異なりますが同じ意味です。 この2文はBoWにすることで語順情報が捨てられ、同じ文意の文章だと考えることできるようになります。 特に日本語のような語順が自由な言語では、語順を無視することで文意の同一性を捉えられるというケースは一般的にあります。
最初に述べたとおり、BoWはあくまで基本的な手法ですので、このように問題もあります。 (BoWを改良する方法の一つとして、「明日友達と遊園地に遊びに行く」も「友達と遊園地に明日遊びに行く」は同じベクトルに変換され、「犬が人を噛んだ」と「人が犬を噛んだ」は違うベクトルに変換されるような手法を考えてみましょう。わかち書きの結果だけでなく、形態素解析で得られる品詞情報「助詞」を使うのが一つの手ですね。)
Tip
|
COLUMN: Google検索における特徴抽出
Googleで「人が犬を噛んだ」と「犬が人を噛んだ」を検索してみました。 3, 4番目が違いますが、ほぼ同じ結果になりました。
さすがにGoogleが単純なBoWを使っているとは考えられませんが、単語をわかち書きした上である程度は順序情報を無視するような処理が行われていると予想できます。 (Googleの検索結果は変化しえますし、様々な要因を考慮してパーソナライズされているため、読者の方が試しても同じ結果になるとは限りません) |
最後に、Bag of Wordsという名前の由来に触れておきます。 Bag of Wordsは単語の出現順序を無視して出現回数だけを数えるものでした。 これが、文を単語(Word)に分解して、袋(Bag)にバラバラに放り込み、個数を数えるというイメージにつながっています。 順序を無視して個数だけ数える、という特徴から連想されるネーミングになっています。
まとめ
この節では、日本語(自然言語)の文章を特徴ベクトル化する手法について解説しました。 日本語の文字列は、コンピュータにとっては意味を解析しづらいバイト列で、かつ長さがバラバラなので、そのままではプログラムで扱うのは難しいのですが、特徴ベクトルに変換することで、「実数値を要素にもつ、固定長の配列」として表現できるようになります。
識別器
Tiwaに応答を実現させる際には、対話エージェントシステムで見たように、 入力文例とクラスIDの組のリスト という学習データを使って入力文とクラスIDの対応を 学習 させ、その後ユーザー入力文に対するクラスIDを 予測 させるのでした。 この節では、いよいよ学習と予測を行なう部分を作っていきます。
機械学習の文脈で、特徴ベクトルを入力し、そのクラスIDを出力することを 識別(classification) 、それを行なうオブジェクトや手法を 識別器(classifier) と呼びます。 識別器はまず、ある程度の数の 特徴ベクトルとクラスIDの組のリスト を学習データとして読み込み、特徴ベクトルとクラスIDの関係を学習します。 学習の済んだ識別器に特徴ベクトルを入力すると、その特徴ベクトルに対応するクラスIDが予測されます。
前節までの、わかち書き→Bag of Words化、という処理はいわば「前処理」で、これによって入力文例やユーザーの入力文は特徴ベクトル(Bag of Words)に変換されます。入力文例の特徴ベクトルと対応するクラスIDの組のリストで識別器を学習することで、ユーザー入力の特徴ベクトルを入力するとそのクラスIDを出力する識別器が出来上がります。
scikit-learnから使う
識別器には様々な手法があります。 アタックする問題の性質によって適するものを選ぶ・もしくは適するものを見つけるために試行錯誤する必要があるのですが、ここではとりあえず SVM(Support Vector Machine; サポートベクターマシン) を使います(他の識別器の紹介や詳しい解説は後述)。 識別器としては簡単に比較的高い性能が出せるため、広く選ばれる手法です。 仰々しい名前でびっくりしてしまいますが、理論の詳細を一旦スキップしてとりあえずプログラムで使うだけなら、scikit-learnで実装が提供されているため、すぐに使うことができます。
from sklearn.svm import SVC (1)
training_data = ... # 学習データの特徴ベクトル (2)
training_labels = ... # 学習データのクラスID (3)
classifier = SVC() (4)
classifier.fit(training_data, training_labels) (5)
test_data = ... # ユーザー入力の特徴ベクトル
prediction = classifier.predict(test_data) (6)
-
scikit-learnで提供されている、SVMによる識別器の実装
sklearn.svm.SVC
をインポートします -
学習データの特徴ベクトルです。学習データ1つの特徴ベクトルは1次元配列で、それが複数あるので、2次元配列になります。
-
学習データのクラスIDです。学習データ1つのクラスIDはint型で、それが複数あるので、1次元配列になります。
-
sklearn.svm.SVC
をインスタンス化します。 -
fit()
メソッドを呼ぶことで、学習が行われます。引数は学習データです。 -
5で学習された識別器インスタンス
classifier
のpredict()
メソッドを呼ぶことで、予測を行えます。
対話エージェントシステムを作る
これで、機械学習を利用した対話システムを作るために必要な最低限の道具が揃いました。 では、ここまでで紹介した「わかち書き→Bag of Words化→識別器による学習&予測」という一連の処理を使い、対話エージェントTiwaを実現するコードを書いてみましょう。
from os.path import normpath, dirname, join
import MeCab
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.svm import SVC
import pandas as pd
MECAB_DIC_DIR = '/usr/lib/mecab/dic/mecab-ipadic-neologd' (1)
class DialogueAgent(object):
def __init__(self):
self.tagger = MeCab.Tagger('-d {}'.format(MECAB_DIC_DIR))
self.tagger.parse('') # workaround
def _tokenize(self, text):
node = self.tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
def train(self, texts, labels):
vectorizer = CountVectorizer(analyzer=self._tokenize)
bow = vectorizer.fit_transform(texts) (2)
classifier = SVC()
classifier.fit(bow, labels)
(3)
self.vectorizer = vectorizer
self.classifier = classifier
def predict(self, texts):
bow = self.vectorizer.transform(texts)
return self.classifier.predict(bow)
if __name__ == '__main__':
BASE_DIR = normpath(dirname(__file__))
training_data = pd.read_csv(join(BASE_DIR, './training_data.csv')) (4)
dialogue_agent = DialogueAgent()
dialogue_agent.train(training_data['text'], training_data['label'])
with open(join(BASE_DIR, './replies.csv')) as f: (5)
replies = f.read().split('\n')
input_text = '名前は?'
predictions = dialogue_agent.predict([input_text])
predicted_class_id = predictions[0]
print(replies[predicted_class_id])
学習データ training_data.csv
は 学習データのBoW化 のものと同じです。
応答文データ replies.csv
は以下の通り、各クラスIDに対応する応答文が1行ごとに書いてあるデータになります。
僕はTiwaといいます
ラーメン!
このコードを実行すると、以下のようになります。
$ python dialogue_agent.py
僕はTiwaといいます
入力文として指定した '名前は?'
に対して、「僕はTiwaといいます」と応答を返してくれます。 入力文 input_text
の内容を変えて、いろいろ試してみましょう。
Note
|
応用課題
|
scikit-learnを使うときの小技: Pipeline化する
scikit-learnが提供する各コンポーネントは fit()
, predict()
, transform()
などの統一されたAPIを持つよう設計されています。これらは sklearn.pipeline.Pipeline
でまとめることができます。
Pipeline
を使うようにdialogue_agent.pyを書き換えてみると、以下のようになります。
from os.path import normpath, dirname, join
import MeCab
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.svm import SVC
from sklearn.pipeline import Pipeline
import pandas as pd
MECAB_DIC_DIR = '/usr/lib/mecab/dic/mecab-ipadic-neologd'
class DialogueAgent(object):
def __init__(self):
self.tagger = MeCab.Tagger('-d {}'.format(MECAB_DIC_DIR))
self.tagger.parse('') # workaround
def _tokenize(self, text):
node = self.tagger.parseToNode(text)
tokens = []
while node:
if node.surface != '':
tokens.append(node.surface)
node = node.next
return tokens
def train(self, texts, labels):
pipeline = Pipeline([ (1)
('vectorizer', CountVectorizer(analyzer=self._tokenize)),
('classifier', SVC()),
])
pipeline.fit(texts, labels) (2)
self.pipeline = pipeline
def predict(self, texts):
return self.pipeline.predict(texts) (3)
if __name__ == '__main__':
BASE_DIR = normpath(dirname(__file__))
training_data = pd.read_csv(join(BASE_DIR, './training_data.csv'))
dialogue_agent = DialogueAgent()
dialogue_agent.train(training_data['text'], training_data['label'])
with open(join(BASE_DIR, './replies.csv')) as f:
replies = f.read().split('\n')
input_text = '名前は?'
predictions = dialogue_agent.predict([input_text])
predicted_class_id = predictions[0]
print(replies[predicted_class_id])
-
vectorizer
,classifier
がpipeline
にまとめられます。 -
pipeline.fit()
の内部で、vectorizer.fit()
,vectorizer.transform()
とclassifier.fit()
が呼ばれます。 -
pipeline.predict()
の内部で、vectorizer.transform()
とclassifier.predict()
が呼ばれます。
dialogue_agent.pyから変わったのは、 DialogueAgent.train()
と DialogueAgent.predict()
の部分です。 CountVectorizer
でBoWを計算→ SVC
にBoWを入力して学習、という流れが、 pipeline
で表現されています。 このように、 Pipeline
は fit()
や predict()
を最初の段から次々に実行し、それぞれの段の出力を次の段に伝播させていくようにできています。
Pipeline
を使うことで bow
変数はコード上に登場しなくなり、また vectorizer
や classifier
といった変数が pipeline
1つにまとめられ、スッキリしました。 処理に必要な全てのパーツが pipeline
にまとまっているので、 DialogueAgent.train()
で学習したあとに DialogueAgent.predict()
でも参照しなければならない変数がこの1つになり、取り回しが楽になっています。
まとめ
この節では、識別器の一例としてSVMをとりあげ、理論の詳細は一旦省きつつ、scikit-learnライブラリを利用することで実際にプログラム中で動かしました。特徴ベクトルと対応するクラスIDを識別器に入力して学習し、学習の済んだ識別器に特徴ベクトルを入力しクラスIDを予測させました。
また、ここまで学んだことを組み合わせ、対話エージェントシステムTiwaを作成しました。