Text classificationで、簡単な対話エージェントを作ってみるチュートリアル

  • 対象:プログラミングはしたことあるが、機械学習はやったことがない人

  • 言語:Python3

対話エージェントシステム概要

この記事では、簡単な対話エージェントをプログラムすることで、自然言語処理プログラミングの要素の一部を体験していきます。

名前

プログラミングにおいて名前は重要です。これから作る対話エージェントにも名前を付けましょう(プログラムを作るとき、命名に一番時間を取られたりすることもありますね)。

ここでは「Tiwa」という名前を付けることにしました。

もっとイケてるオリジナルの名前を付けると、よりシステムに愛着が湧くかもしれません。その場合は以後の Tiwa を適宜オリジナルの名前に読み替えてください。

対話エージェントシステム

これから作る対話エージェントプログラムは、以下のように動作します。 最終的に Tiwa は、ユーザーの入力(この例では「あなたの名前は何?」)に対して、適切な応答(この例では「僕はTiwaといいます」)を返せるようになることが目標です。

talk_with_dialogue_agent.py(イメージ)
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) といい、機械学習の重要な性質の一つになります。

以上が対話システムのざっくりした概要でした。 機械学習システムをプログラムとして組み上げるために、もう少し具体化してみましょう。

talk_with_dialogue_agent.py(より具体的なイメージ)
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]

数値の配列にすることで、かなりプログラムで扱いやすくなりますね(文を数値の配列に変換したあとの具体的な処理は次節以降で解説します)。

英語のように単語の間にスペースのある言語であれば、文を単語に区切る処理は不要なことがほとんどなのですが、 日本語のように単語の間にスペースが無い言語は、単語の境界を判定する処理を行なって、文を分解する必要があります。

文を単語に分解することを わかち書き といい、日本語においてわかち書きを行うためのソフトウェアとして広く利用されているのがMeCab(めかぶ)[1]です。

MeCabは文を単語に分解するだけではなく、各単語の品詞や読みなど、情報を付与してくれます。 このような品詞情報の付与まで含んだわかち書きを 形態素解析 と呼びます。

Note

実はこの説明は正確ではありません。 まず、形態素とは文を構成する「意味をもつ最小の単位」のことで、「単語」とは若干意味が異なります。 そして、文を形態素に分解することを「形態素解析」と呼びます。

ではMeCabが行なうのは何なのかというと、MeCabが文を分割するとき、どの単位に区切るかは後述する「辞書」に依存します。 これによって、本当の意味での形態素解析ができたり、複合語は1つのまとまりとして扱って文を分解できたりします。

この解説では一旦、「単語」や「形態素」をあまり厳密に区別せずに使います。

MeCabのインストール

公式ページなどを参考に、適宜インストールしてください。

Ubuntuの場合

aptでインストールできます。

$ sudo apt-get install mecab libmecab-dev mecab-ipadic

OSXの場合

homebrewでインストールできます。

$ brew install mecab  mecab-ipadic

コマンドラインから試してみる

シェルで mecab コマンドを実行すると、入力受付状態になります。

シェルでMeCabを実行
$ mecab

この状態で、日本語文を入力し、Enterを押すと、形態素解析結果が表示されます。

MeCab実行結果例
$ mecab
私は私のことが好きなあなたが好きです
私	名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
私	名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
こと	名詞,非自立,一般,*,*,*,こと,コト,コト
が	助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き	名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
な	助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ
あなた	名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ
が	助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き	名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
です	助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
EOS

正しく単語ごとに分割され、品詞などの情報も付与されていますね。

各行のフォーマットは、以下のようになっています[2]

MeCabの出力フォーマット
表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音

(このフォーマットは後述する 辞書 に依存しており、どの辞書を使うかによって変わります。)

Pythonから呼び出す

MeCabには、Pythonから呼び出すためのライブラリが用意されています。 PythonからMeCabを使ってみましょう。

pipでインストールできます。

$ pip install mecab-python3

インストールできたら、実際にPythonからMeCabを使うコードを動かしてみましょう。以下が簡単なサンプルコードになります。

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として返す、簡単な関数を作ってみましょう。

tokenizer.py
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

MeCab.Tagger() には、shellから mecab コマンドを実行する時に指定するオプションと同じオプションを渡すことができます。 -Owakati オプションを指定すると、各単語の詳細な情報は出力せず、スペース(' ')で分割したわかち書きの結果のみを出力するようにできます。

import MeCab

tagger = MeCab.Tagger('-Owakati')
tagger.parse('')  # workaround

print(tagger.parse('私は私のことが好きなあなたが好きです'))
実行結果
私 は 私 の こと が 好き な あなた が 好き です

これを使って、以下のようなわかち書き関数の実装も考えられます。

tokenizer_buggy.py
import MeCab

tagger = MeCab.Tagger('-Owakati')
tagger.parse('')  # workaround


def tokenize(text):
    return tagger.parse(text).strip().split(' ')

しかし実はこの実装には問題があります。 半角スペースを区切り文字にしているので、半角スペースを含む単語が登場した時に、区切り文字としての半角スペースと単語の一部としての半角スペースが混ざってしまい、正しくわかち書きできません。

後述する辞書によっては、半角スペースを含んだ単語を扱うことになります。この実装は避けたほうがよいでしょう。

辞書について

MeCabによるわかち書きは、予め構築された 辞書 に基いて行われます。 辞書とは、その名の通り、様々な単語を網羅したデータベースです。

シェルで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つの ベクトル で表します。

f:id:tuttieee:20171228041121j:plain

Figure 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をもとに何らかの手法でベクトルを得ます。

vectorize.py(イメージ)
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

また、この単語→番号の対応表を 語彙(vocabulary)辞書(dictionary) と呼びます(辞書についてで解説したMeCabの辞書とは違うので注意)。

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のインデックスとなり、対応する位置に単語の出現回数を代入します。

Bag of Wordsの例
# 私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です
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の例における bow0bow1)が、特徴ベクトルとなります。 この変換手法のことを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の実装

それでは実際にPythonでBoWのアルゴリズムを実装してみましょう。

bag_of_words.py
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)
  1. わかち書きのためのMeCabの準備(tokenizer.pyと同じ)

  2. わかち書き関数(tokenizer.pyと同じ)

  3. Bag of Words計算関数

  4. 辞書生成ループ

  5. 単語出現回数カウントループ

得られる変数の値(見やすいように一部整形)
>>> 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を計算するコードを書いてみましょう。

sklearn_example.py
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)
  1. わかち書きのためのMeCabの準備(tokenizer.pyと同じ)

  2. わかち書き関数(tokenizer.pyと同じ)

  3. CountVectorizer のコンストラクタには analyzer 引数でわかち書き関数を渡します。 analyzer を指定しない場合のデフォルト設定ではスペースで文を単語に区切る処理が行われますが、これは英語のような単語をスペースで区切る言語を想定した動作です。 analyzer にcallable(関数、メソッドなど)を指定するとそれが文の分割に使われるので、日本語を対象にする場合は、自前で実装したわかち書き関数を指定するようにします。

  4. 辞書の生成

  5. BoWの計算

bag_of_words.pyでは辞書の生成と単語の出現回数のカウントが2つのループに分かれていましたが、 sklearn.feature_extraction.text.CountVectorizer ではこの2つに対応して CountVectorizer.fit(), CountVectorizer.transform() というメソッドがあります。

CountVectorizer.fit() で辞書が作成され、一度辞書の作成を行ったインスタンスでは、 transform() を呼ぶことで、その辞書に基づいたBag of Wordsが生成されます。

Note

CountVectorizer.transform の返り値(上記サンプルコードの bow 変数)をよく見てみると、listでもnumpy配列(numpy.ndarray)でもありません。

>>> bow
<3x15 sparse matrix of type '<class 'numpy.int64'>'
	with 22 stored elements in Compressed Sparse Row format>

BoWはほとんどの要素がゼロのベクトルになります。 ある文をBoWに変換する時、その文中に登場しない単語に対応する要素はゼロになりますが、一般的にそのようなゼロ要素のほうが非ゼロ要素より多くなるからです。語彙が増えれば増えるほど、ゼロ要素が多くなる確率は上がります。

このような、ほとんどの要素の値がゼロで、一部の要素だけが非ゼロの値をもつようなベクトル・行列を 疎ベクトル(sparse vector疎行列(sparse matrix) といいます。

疎ベクトル・疎行列を表す際には、通常の多次元配列のように値を全てメモリに記録するのではなく、非ゼロ要素のインデックスと値のみを記録するようにしたほうがメモリ効率がよくなります。

このような疎行列の実装が scipy.sparse パッケージで提供されており、上記bow 変数は scipy.sparse.csr.csr_matrix クラスのインスタンスだったのです。

>>> bow.__class__
<class 'scipy.sparse.csr.csr_matrix'>

print(bow) してみると、非ゼロ要素のインデックスと値が記録されているのがわかります。

>>> print(bow)
  (0, 1)	1
  (0, 2)	2
  (0, 3)	1
  (0, 4)	1
  (0, 5)	1
  (0, 6)	1
  (0, 7)	1
  (0, 9)	2
  (0, 13)	2
  (1, 0)	1
  (1, 2)	1
  (1, 4)	1
  (1, 7)	1
  (1, 8)	1
  (1, 9)	1
  (1, 13)	1
  (2, 4)	1
  (2, 7)	1
  (2, 10)	1
  (2, 11)	1
  (2, 12)	1
  (2, 14)	1

本記事で扱うプログラムでは bowscipy.sparse.csr.csr_matrix のまま扱うので、特に意識しなくて大丈夫ですが、 今後自分でプログラムを書く時には、利用するライブラリによっては scipy.sparse の疎行列インスタンスに対応していない場合があります。 そのようなときは、 .toarray() でnumpy配列に変換することができます。

>>> bow.toarray()
array([[0, 1, 2, 1, 1, 1, 1, 1, 0, 2, 0, 0, 0, 2, 0],
       [1, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0],
       [0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1]])

(疎行列は scipy.sparse の疎行列インスタンスとして表現したほうがメモリ効率がよいので、scipy.sparse が使える場合はなるべくそのまま使うことをおすすめします。)

未知語

辞書生成と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です。

training_data.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化してみましょう。

extract_bow_from_training_data.py
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)
  1. わかち書きのためのMeCabの準備(tokenizer.pyと同じ)

  2. わかち書き関数(tokenizer.pyと同じ)

  3. 実行時は training_data.csv をこのスクリプトと同じディレクトリに入れてください。

  4. ここではCSVファイルの読み込みに pandas を使っています。Pythoncsv モジュールなど、他の方法でも構いません。

  5. sklearn_example.pyと同様、 sklearn.feature_extraction.text.CountVectorizer を使ったBoW計算。

データのロード以外はsklearn_example.pyと同じです。

Bag of Wordsの性質

ここでは、Bag of Wordsがどういった性質の特徴ベクトルなのかを考えてみます。

まず、先述したとおり、Bag of Wordsは元となった文章の特徴を表したベクトルになっています。 例えば、

  • 私は私のことが好きなあなたが好きです

  • 私はラーメンが好きです

  • 富士山は日本一高い山です

という3つの例文から得られたBoWの各要素の値を棒グラフで表したものがグラフ化したBag of Wordsになります。 BoWはベクトルの各要素がある単語に対応しているので、横軸にその単語を並べています。

f:id:tuttieee:20171228041212p:plain

Figure 2. グラフ化した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番目が違いますが、ほぼ同じ結果になりました。

人が犬を噛んだ

f:id:tuttieee:20171228041242p:plain

犬が人を噛んだ

f:id:tuttieee:20171228041256p:plain

さすがにGoogleが単純なBoWを使っているとは考えられませんが、単語をわかち書きした上である程度は順序情報を無視するような処理が行われていると予想できます。

Googleの検索結果は変化しえますし、様々な要因を考慮してパーソナライズされているため、読者の方が試しても同じ結果になるとは限りません)

最後に、Bag of Wordsという名前の由来に触れておきます。 Bag of Wordsは単語の出現順序を無視して出現回数だけを数えるものでした。 これが、文を単語(Word)に分解して、袋(Bag)にバラバラに放り込み、個数を数えるというイメージにつながっています。 順序を無視して個数だけ数える、という特徴から連想されるネーミングになっています。

f:id:tuttieee:20171228041323j:plain

Figure 3. "Bag" of Words

まとめ

この節では、日本語(自然言語)の文章を特徴ベクトル化する手法について解説しました。 日本語の文字列は、コンピュータにとっては意味を解析しづらいバイト列で、かつ長さがバラバラなので、そのままではプログラムで扱うのは難しいのですが、特徴ベクトルに変換することで、「実数値を要素にもつ、固定長の配列」として表現できるようになります。

識別器

Tiwaに応答を実現させる際には、対話エージェントシステムで見たように、 入力文例とクラスIDの組のリスト という学習データを使って入力文とクラスIDの対応を 学習 させ、その後ユーザー入力文に対するクラスIDを 予測 させるのでした。 この節では、いよいよ学習と予測を行なう部分を作っていきます。

機械学習の文脈で、特徴ベクトルを入力し、そのクラスIDを出力することを 識別(classification) 、それを行なうオブジェクトや手法を 識別器(classifier) と呼びます。 識別器はまず、ある程度の数の 特徴ベクトルとクラスIDの組のリスト を学習データとして読み込み、特徴ベクトルとクラスIDの関係を学習します。 学習の済んだ識別器に特徴ベクトルを入力すると、その特徴ベクトルに対応するクラスIDが予測されます。

前節までの、わかち書き→Bag of Words化、という処理はいわば「前処理」で、これによって入力文例やユーザーの入力文は特徴ベクトル(Bag of Words)に変換されます。入力文例の特徴ベクトルと対応するクラスIDの組のリストで識別器を学習することで、ユーザー入力の特徴ベクトルを入力するとそのクラスIDを出力する識別器が出来上がります。

f:id:tuttieee:20171228041339j:plain

Figure 4. 対話エージェントシステムの学習と予測

scikit-learnから使う

識別器には様々な手法があります。 アタックする問題の性質によって適するものを選ぶ・もしくは適するものを見つけるために試行錯誤する必要があるのですが、ここではとりあえず SVMSupport 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)
  1. scikit-learnで提供されている、SVMによる識別器の実装 sklearn.svm.SVC をインポートします

  2. 学習データの特徴ベクトルです。学習データ1つの特徴ベクトルは1次元配列で、それが複数あるので、2次元配列になります。

  3. 学習データのクラスIDです。学習データ1つのクラスIDはint型で、それが複数あるので、1次元配列になります。

  4. sklearn.svm.SVCインスタンス化します。

  5. fit() メソッドを呼ぶことで、学習が行われます。引数は学習データです。

  6. 5で学習された識別器インスタンス classifierpredict() メソッドを呼ぶことで、予測を行えます。

対話エージェントシステムを作る

これで、機械学習を利用した対話システムを作るために必要な最低限の道具が揃いました。 では、ここまでで紹介した「わかち書き→Bag of Words化→識別器による学習&予測」という一連の処理を使い、対話エージェントTiwaを実現するコードを書いてみましょう。

dialogue_agent.py
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])
  1. MeCabで利用する辞書がインストールされているパスを指定します。(デフォルトのインストールディレクトリはshellで mecab-config --dicdir を実行すると調べることができます。)

  2. CountVectorizer.fit_transform() は、 .fit() による語彙の獲得と .transform() による特徴ベクトル化を一度に行なうメソッドです。

  3. 辞書を生成済みの vectorizer 、学習済みの classifier は予測のときに使うので、インスタンス変数として保持しておきます。

  4. 実行時は学習データ training_data.csv をこのスクリプトと同じディレクトリに入れてください。

  5. 実行時は返答文リスト replies.csv をこのスクリプトと同じディレクトリに入れてください。

学習データ training_data.csv学習データのBoW化 のものと同じです。

応答文データ replies.csv は以下の通り、各クラスIDに対応する応答文が1行ごとに書いてあるデータになります。

replies.csv
僕はTiwaといいます
ラーメン!

このコードを実行すると、以下のようになります。

$ python dialogue_agent.py
僕はTiwaといいます

入力文として指定した '名前は?' に対して、「僕はTiwaといいます」と応答を返してくれます。 入力文 input_text の内容を変えて、いろいろ試してみましょう。

Note
応用課題
  1. input 関数を使ってユーザー入力をインタラクティブに受け取り、応答するようにしてみましょう。また、ループでずっと会話を続けられるようにしてみましょう。

  2. training_data.csvreplies.csv にデータを追加し、会話のパターンを増やしてみましょう。また、「さようなら」などの例文で別れの挨拶クラスを作り、ユーザーが別れの挨拶をしたらループを抜けて会話を終了するようにしてみましょう。

scikit-learnを使うときの小技: Pipeline化する

scikit-learnが提供する各コンポーネントfit(), predict(), transform() などの統一されたAPIを持つよう設計されています。これらは sklearn.pipeline.Pipeline でまとめることができます。

Pipeline を使うようにdialogue_agent.pyを書き換えてみると、以下のようになります。

dialogue_agent_sklearn_pipeline.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])
  1. vectorizer, classifierpipeline にまとめられます。

  2. pipeline.fit() の内部で、 vectorizer.fit(), vectorizer.transform()classifier.fit() が呼ばれます。

  3. pipeline.predict() の内部で、 vectorizer.transform()classifier.predict() が呼ばれます。

dialogue_agent.pyから変わったのは、 DialogueAgent.train()DialogueAgent.predict() の部分です。 CountVectorizer でBoWを計算→ SVC にBoWを入力して学習、という流れが、 pipeline で表現されています。 このように、 Pipelinefit()predict() を最初の段から次々に実行し、それぞれの段の出力を次の段に伝播させていくようにできています。

Pipeline を使うことで bow 変数はコード上に登場しなくなり、また vectorizerclassifier といった変数が pipeline 1つにまとめられ、スッキリしました。 処理に必要な全てのパーツが pipeline にまとまっているので、 DialogueAgent.train() で学習したあとに DialogueAgent.predict() でも参照しなければならない変数がこの1つになり、取り回しが楽になっています。

まとめ

この節では、識別器の一例としてSVMをとりあげ、理論の詳細は一旦省きつつ、scikit-learnライブラリを利用することで実際にプログラム中で動かしました。特徴ベクトルと対応するクラスIDを識別器に入力して学習し、学習の済んだ識別器に特徴ベクトルを入力しクラスIDを予測させました。

また、ここまで学んだことを組み合わせ、対話エージェントシステムTiwaを作成しました。

本記事で扱わなかったこと・TODO

  • 前処理

  • MeCab以外の形態素解析

  • BoW以外の特徴量

  • 特徴量エンジニアリング

  • 特徴量前処理・正規化

  • SVM以外の識別器

  • 評価手法


3. その単語・形態素の文中での形。例えば、"僕 / は / 泣か / ない" の中の「泣か」は、原型は「泣く」ですが、文中では活用されて「泣か」になっています。この場合、「泣か」が表層形になります。
4. 正確にはこの例文はBoWの説明のための例文として有名なわけではありません。気になる人は"Man bites dog"でググってみましょう。