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

自然言語処理 Advent Calendar 2017 - Qiita の24日目です。

シンプルな対話エージェントを作ることをゴールに、自然言語処理タスクの一種であるText classificationとその周辺のプログラミングを解説するチュートリアルです。

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

  • 言語:Python3

一部内容を http://tuttieee.hatenablog.com/entry/bag-of-words に移動し、追記しました。

また、本稿は、拙著「15Stepで踏破 自然言語処理アプリケーション開発入門」の第2章 Step1の内容を、出版社の許可を得て掲載しています。

15Stepで踏破 自然言語処理アプリケーション開発入門

■1章 演習に入るまえの予備知識
1 序論・自然言語処理機械学習
2 本書の執筆・開発環境
3 機械学習のためのPythonの基礎
4 数値計算ライブラリNumPy
5 本書で利用するその他の主要ライブラリ

■2章 基礎を押さえる7ステップ
Step 01 対話エージェントを作ってみる
Step 02 前処理
Step 03 形態素解析とわかち書き
Step 04 特徴抽出
Step 05 特徴量変換
Step 06 識別器
Step 07 評価

■3章 ニューラルネットワークの6ステップ
Step 08 ニューラルネットワーク入門
Step 09 ニューラルネットワークによる識別器
Step 10 ニューラルネットワークの詳細と改善
Step 11 Word Embeddings
Step 12 Convolutional Neural Networks
Step 13 Recurrent Neural Networks

■4章 2ステップの実践知識
Step 14 ハイパーパラメータ探索
Step 15 データ収集

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

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

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

まず、これから本記事で作成する対話エージェントプログラムの概要を紹介します。 このプログラムは、以下のように動作します。

talk_with_dialogue_agent.py(イメージ)
training_data = [
    (
        [
            '自然言語処理をしたいんだけど、どうすればいい?',
            '日本語の自然言語処理について知りたい',
            ...,
        ],
        'この本を読んでね!',
    ),
    (
        [
            '好きなプログラミング言語は?',
            'どの言語使うのがいいかな',
            ...,
        ],
        'Pythonがおすすめです^^',
    ),
    (
        [
            '良いコードが書けたよ',
            '凄いのが出来た',
            ...,
        ],
        'それはよかったですね!',
    ),
    ...,
]

dialogue_agent = DialogueAgent()
dialogue_agent.train(training_data)
print(dialogue_agent.reply('どのプログラミング言語がいいかな'))  # 'Pythonがおすすめです^^'

(あくまでイメージですよ!)

このように、最終的に DialogueAgent が、ユーザーの入力(この例では「どのプログラミング言語がいいかな」)に対して、適切な応答(この例では「Pythonがおすすめです^^」)を返せるようになることが目標です。

まず、ユーザーからの入力文として想定される文をある程度の数用意し、同じような意味・文意・話題の文をまとめておきます。例えば、「自然言語処理をしたいんだけど、どうすればいい?」や「日本語の自然言語処理について知りたい」といった入力文は“自然言語処理の学習について聞く質問”としてまとめ、「好きなプログラミング言語は?」や「どの言語使うのがいいかな」といった入力文は“おすすめのプログラミング言語を聞く質問”としてまとめることができます。これらのまとまり(ここでは文の分類)を クラス(class) と呼びます。プログラミング言語のクラス(class)とは違うものなので、混同しないように気をつけてください。

また、入力文の各クラスについて、この対話エージェントの応答文を予め決めておきます。「自然言語処理をしたいんだけど、どうすればいい?」や「日本語の自然言語処理について知りたい」などの“自然言語処理の学習について聞く質問”クラスに対しては「この本を読んでね!」と応答し、「好きなプログラミング言語は?」や「どの言語使うのがいいかな」などの“おすすめのプログラミング言語を聞く質問”クラスに対しては「Pythonがおすすめです^^」と応答する、といった具合です。

次に、この クラス分けされた複数のユーザー入力文例と、各クラスに対する応答の組 を対話エージェントに 学習(train) させます。 「学習」が具体的にどのようなプログラムで表現される、どのような処理なのかはこのあとしっかり解説していきますが、 学習によって対話エージェントは「どのような質問文がきたらどの回答文を返せばよいか」のパターンを習得します。

その後ユーザーが文を入力すると、対話エージェントはその文がどのクラスに該当するかを 予測(predict) し、対応する応答文を返します。 これによって、(一問一答の)会話を実現することができます。

これは、Text classificationという問題設定です。

ここで大事なのは、ユーザーからの入力文が、学習のときに与えた文例と完全に一致していなくても、対話エージェントが動作することです。上の例で言うと、ユーザーが「どのプログラミング言語がいいかな」という文章を対話エージェントに入力していますが、たとえ予め用意し学習に用いた文章例群 training_data に「どのプログラミング言語がいいかな」という文章が含まれていなかったとしても、対話エージェントは「Pythonがおすすめです^^」という応答を返せるようになります。 つまり、この対話エージェントは、training_data に含まれた文とユーザー入力とを単純に照合して応答するわけではないのです。そのような「ある入力Xに対しては応答Yを返す」という単純なルールを用意するだけのやり方では、用意すべきルールが膨大な数になってしまい、すぐに破綻してしまいます。

この対話エージェントはそうではなく、予め与えられたいくつかのクラスとそれぞれに属する文の例を元に、それぞれのクラスに分類されうる文の傾向のようなものを自動で獲得します。その結果、予め与えられた文とはある程度異なる新たな文が入力されても、その傾向に当てはめて推論し、適した応答を選べるようになります。 このような、学習に使わなかったデータについても正しく予測を行えることを 汎化(generalization) といいます。汎化は機械学習の重要な性質の一つです。

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

talk_with_dialogue_agent.py(より具体的なイメージ)
training_data = [
    (0, '自然言語処理をしたいんだけど、どうすればいい?'),
    (0, '日本語の自然言語処理について知りたい'),
    ...,
    (1, '好きなプログラミング言語は?'),
    (1, 'どの言語使うのがいいかな'),
    ...,
    (2, '良いコードが書けたよ'),
    (2, '凄いのが出来た'),
    ...,
]

dialogue_agent = DialogueAgent()
dialogue_agent.train(training_data)
predicted_class = dialogue_agent.predict('どのプログラミング言語がいいかな')

replies = [
    'この本を読んでね!'
    'Pythonがおすすめです^^',
    'それはよかったですね!',
]
print(replies[predicted_class])  # 'Pythonがおすすめです^^'

各クラスにはIDをつけて管理することにします。

自然言語処理の学習について聞く質問”クラスは 0 、“おすすめのプログラミング言語を聞く質問”クラスは 1 、…というように、連続した自然数をクラスIDとして振っていきます(プログラミングではよくあるやつですね)。 すると、この対話エージェントは「自然文を入力し、その文が属すると予測されるクラスのクラスIDを出力する」システムとして設計されることになります。 それと対応して、学習のための入力データすなわち 学習データ(training data) は、入力文例とクラスIDの組のリストになります(talk_with_dialogue_agent.py(より具体的なイメージ)training_data 変数。文とクラスIDのtupleのlist)。

学習後、ユーザーが文を入力すると、対話エージェントはその文が属するクラスのIDを予測し、そのクラスIDに対応する回答文を返します。 応答文は単純にlistとして用意しておけば、クラスIDをインデックスにしてアクセスできます(replies 変数)。

以上で作るべきシステムがはっきりしましたね。「対話エージェント」をもう少しブレイクダウンして、「文を入力すると、その文が属するクラスを予測し、クラスIDを出力するシステム」を作っていくという目標が定まりました。

それでは、このシステムがどのような処理で構成されるのか(DialogueAgent クラスを具体的にどう実装すればよいのか)、詳しく見ていきましょう。

形態素解析

対話エージェントに与えられるデータは、学習データ中の入力文例も実際のユーザの入力文も、ただの日本語の文章であり、プログラム的に見ればただのバイト列です。 このデータをどのようにプログラムで扱えばいいのでしょう?

最初のステップは、文を単語に分解することです。 文を単語に分解できれば、各単語に番号を割り当てることができ、文を数値の配列として扱えるようになります。

ch03 morph1

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

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

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

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

Note

実はこの説明は正確ではありません。

まず、形態素とは文を構成する「意味をもつ最小の単位」のことで、「単語」とは若干意味が異なります。 例えば、「自然言語処理」は一つの単語ですが、これはさらに「自然」「言語」「処理」という形態素に分解できます(これをもって、「自然言語処理」という単語は複合語と呼ばれます)。 また他の例として、丁寧語「お花」は形態素「お」「花」に分解できます。

そして、文を形態素のレベルにまで分解することを「形態素解析」と呼びます。

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

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

MeCabのインストール

以下の手順で適宜インストールしてください。

DebianLinuxDebianUbuntuなど)の場合

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

$ sudo apt install mecab libmecab-dev mecab-ipadic

OSXの場合

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

$ brew install mecab mecab-ipadic

mecab に加えて mecab-ipadic というパッケージをインストールしていますが、これはMeCabが使う「辞書」というファイルです。

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

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

シェルでMeCabを実行
$ mecab

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

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

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

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

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 プロパティから、対応する単語の表層形 [4] を得ることができます。

また、 node.feature には品詞などの情報が格納されています。

>>> node.next.surface
'私'
>>> node.next.feature
'名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ'

mecab-python3 にはバージョン0.7時点で、MeCab0.996と併用すると、初回の tagger.parseToNode() 呼び出しで得られる node が正しく動作しないというバグがあります[5]。 そのため、一回 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を使うことで、わかち書きを行う関数を簡単に実装することができました。

この tokenize() は、今後も頻繁に登場します。 このあとのサンプルコードで tokenize() を利用するときは、 tokenizer.py を同階層に配置する想定で、以下のようにインポートすることにします。読者の皆さんは適宜自分の環境やディレクトリ構造に合わせて読み替えてください。

from tokenizer import tokenize
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(' ')

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

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

特徴ベクトル化

前節までで、日本語の文をわかち書きできるようになりました。プログラム上の計算で扱うために、このわかち書きされた文章(文字列)をさらに変換し、コンピュータで計算可能な形式にする必要があります。より具体的に言うと、1つの文章を1つの ベクトル で表すのです。

文字列をベクトルで表すための一連の処理

Figure 1. 文字列をベクトルで表すための一連の処理

この「ベクトル」、高校や大学の数学で扱う「ベクトル」そのものではあるのですが、ここではまだベクトルの演算だとか線形代数といった話は出てきません。 この段階では、「ある決まった個数の数値のまとまり」という程度の理解で差し支えありません。 Python的に言えば、数値(intやfloat)を要素とする、ある決まった長さのlistやtupleもしくはnumpy配列です(機械学習のプログラミングではnumpy配列を使うことがほとんどです)。 この「ある決まった長さ」は10とか1000とか100000とか、34029とか981921とか、システムや手法によって変わります。

また、この「ある決まった長さ」をベクトルの 次元数 といいます。 以下のようななんらかのベクトル 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)  # 単語のlistからベクトルを得る手法。これから解説。
# vector == [0, 0.35, 1.23, 0, 0, ..., 2.43]

機械学習の分野では、このベクトルのことを 特徴量(feature)特徴ベクトル(feature vector といい、特徴ベクトルを計算することを 特徴抽出(feature extraction) といいます。また、文字列から特徴抽出する操作を指して「文字列を特徴ベクトル化する」と言ったりもします。特徴抽出を行なう手法を 特徴抽出器(feature extractor) と呼びます。プログラミングの文脈では、特徴抽出の手法を実装した関数やオブジェクト(上の例では vectorize)を指して特徴抽出器と呼んでも良いでしょう。

「特徴ベクトル」という用語には、「元となった文章が持っていた 特徴 が反映された数値列」という意味が込められています。 逆に言えば、元となった文字列の情報をうまく反映した数値列を出力できるよう、 vectorize() を適切に設計します。

Bag of Words

特徴抽出の手法の1つで、とても基本的で広く使われているのが Bag of Words です。省略してBoWと表記することもあります。

処理の内容を見ていきましょう。 以下の例では、「私は私のことが好きなあなたが好きです」と「私はラーメンが好きです」の2つの文(文字列)が入力されたとします。

1: 各単語に番号(インデックス)を割り当てる

文字列はわかち書きによって単語に分解されています。 各単語にユニークな番号=インデックスを割り当てます。

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

私 / は / 私 / の / こと / が / 好き / な / あなた / が / 好き / です
私はラーメンが好きです

私 / は / ラーメン / が / 好き / です
単語 番号

0

あなた

1

ラーメン

2

好き

3

こと

4

5

6

7

8

です

9

また、この単語→番号の対応表を 語彙(vocabulary)辞書(dictionary) と呼びます。

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: 文ごとに、各単語(番号)の登場回数を並べる

それぞれの文(文字列)について、各単語の登場回数を並べて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
from tokenizer import tokenize  # (1)


def calc_bow(tokenized_texts):  # (2)
    # Build vocabulary (3)
    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 (4)
    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. tokenizer.pyで作成したわかち書き関数をインポート

  2. Bag of Words計算関数

  3. 辞書生成ループ

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

得られる変数の値(見やすいように一部整形)
>>> 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)。

Tip
collections.Counter の利用

Pythonの標準ライブラリに collections というモジュールがあり、 このモジュールの Counter クラスを使うと、 よりシンプルにBag of Wordsを実装することができます。

bag_of_words_counter_ver.py
from collections import Counter

from tokenizer import tokenize


def calc_bow(tokenized_texts):
    counts = [Counter(tokenized_text)
              for tokenized_text in tokenized_texts]  # (1)
    sum_counts = sum(counts, Counter())  # (2)
    vocabulary = sum_counts.keys()

    bow = [[count[word] for word in vocabulary]
           for count in counts]  # (3)

    return bow
  1. collections.Counter は以下のように、与えられたlistの要素をカウントできる。これを使って、tokenized_texts の各要素(わかち書きされた単語のlist)に含まれる各単語の数を数える。

    collections.Counter の例
    >>> Counter(['A', 'A', 'A', 'B', 'B', 'C'])
    Counter({'A': 3, 'B': 2, 'C': 1})
  2. 上で作った Counter インスタンスを全て足し合わせ、tokenized_texts 全体の語彙を得る。sum は各要素を加算していく組み込み関数だが、第2引数で最初の加算に使う値 start を指定できる。start はデフォルトで 0 だが、Counter インスタンス同士の加算を行いたいので、ここでは Counter()(全ての要素のカウンタが0の Counter)を指定する。

  3. countsvocabulary を組み合わせて、最終的なBag of Wordsを得る。ここでは2重のリスト内包表記を使っている。

Pythonには充実した標準ライブラリが整備されています。 標準ライブラリには、今回利用した collections.`Counter のようにある程度抽象度が高く汎用的な機能が揃っているので、どんな機能があるか知っておくと便利です。

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
from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize  # (1)

texts = [
    '私は私のことが好きなあなたが好きです',
    '私はラーメンが好きです。',
    '富士山は日本一高い山です',
]

# Bag of Words計算
vectorizer = CountVectorizer(tokenizer=tokenize)  # (2)
vectorizer.fit(texts)  # (3)
bow = vectorizer.transform(texts)  # (4)
  1. tokenizer.pyで作成したわかち書き関数をインポート

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

  3. 辞書の生成

  4. 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化

対話エージェントの学習につかう学習データ training_data.csv を用意します。

training_data.csv(一部抜粋)
label,text
48,昨日作ったお菓子の写真だよ
48,手作りクッキー作ったんだよ
48,ケーキ美味しそうでしょ。
25,口が渇く
25,のどがカラカラです
25,やっと部活が終わった
20,やることないよ。
20,退屈だなぁ
20,何して時間潰そうかな

前節の sklearn.feature_extraction.text.CountVectorizer による実装を使って、この学習データをBag of Words化してみましょう。

extract_bow_from_training_data.py
from os.path import dirname, join, normpath

import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize  # (1)

# データ読み込み
BASE_DIR = normpath(dirname(__file__))
csv_path = join(BASE_DIR, './training_data.csv')  # (2)
training_data = pd.read_csv(csv_path)  # (3)
training_texts = training_data['text']

# Bag of Words計算  (4)
vectorizer = CountVectorizer(tokenizer=tokenize)
vectorizer.fit(training_texts)
bow = vectorizer.transform(training_texts)
  1. tokenizer.pyで作成したわかち書き関数をインポートします。

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

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

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

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

このサンプルコードで、training_data.csv に含まれる学習データから抽出したBoWが変数 bow に得られます。

まとめ

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

識別器

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

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

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

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

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

scikit-learnから使う

識別器には様々な手法があります。 アタックする問題の性質によって適するものを選ぶ、もしくは適するものを見つけるために試行錯誤する必要があるのですが、ここではとりあえず SVMSupport Vector Machineサポートベクターマシン を使います(他の識別器の紹介や詳しい解説は別途)。 識別器としては簡単に比較的高い性能が出せるため、広く選ばれる手法です。 仰々しい名前でびっくりしてしまいますが、理論の詳細を一旦スキップしてとりあえずプログラムで利用するだけなら、scikit-learnで実装が提供されているため、すぐに使うことができます。

from sklearn.svm import SVC  # (1)


training_data = [
    [ ... ],
    [ ... ],
    ...
]  # 学習データの特徴ベクトル (2)
training_labels = [0, 1, ...]  # 学習データのクラスID (3)

classifier = SVC()  # (4)
classifier.fit(training_data, training_labels)  # (5)

test_data = [
    [ ... ],
    [ ... ],
    ...
]  # ユーザー入力の特徴ベクトル
predictions = 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() メソッドを呼ぶことで、予測を行えます。入力する test_datatraining_data と同様2次元配列で、返り値 predictionstraining_labels と同様1次元配列です。

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

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

dialogue_agent.py
from os.path import dirname, join, normpath

import MeCab
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.svm import SVC

MECAB_DIC_DIR = '/usr/lib/mecab/dic/mecab-ipadic-neologd'  # (1)


class DialogueAgent:
    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(tokenizer=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])  # (6)
    predicted_class_id = predictions[0]  # (7)

    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 を入れてください。

  6. .predict() は文字列のリストを受け取るので、 [input_text] としています。

  7. .predict() はクラスIDの配列を返すので、その0番目を取り出します。

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

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

replies.csv(一部抜粋)
私は〇〇といいます
ラーメンとか好きですよ
うーん、特に無いです
良かったですね!
それは辛いですね

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

$ python dialogue_agent.py
私は〇〇といいます

入力文として指定した '名前を教えてよ' に対して、「私は〇〇といいます」と応答を返してくれます(「〇〇」の部分は自分で名前をつけてみてください)。 入力文 input_text の内容を変えて、いろいろ試してみましょう。

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

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

実際に試してみると分かりますが、実はこの実装はまだあまり性能がよくありません。 トンチンカンな返答を返すことがよくあります。

「疲れたよー」に対して「こんにちは」と返してしまう
疲れたよー
こんにちは

これからの章で、対話エージェントの性能を上げる(識別性能を上げる)ための様々な手法を紹介していきます。

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

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

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

dialogue_agent_sklearn_pipeline.py
from os.path import dirname, join, normpath

import MeCab
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC

MECAB_DIC_DIR = '/usr/lib/mecab/dic/mecab-ipadic-neologd'


class DialogueAgent:
    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(tokenizer=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を予測させました。

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

評価

機械学習を使ったシステムを作成したら、その性能を評価する必要があります。 定量的な指標で機械学習システムの性能を評価することで、あるシステムXと別のシステムYのどちらが優れているかを客観的に示せるようになります。 また、客観的な指標があることで、システムを改善したときに本当に効果があったのか、むしろ悪化していないかをチェックすることができます。

対話エージェントを評価してみる

前節では学習データ training_data.csv を使って対話エージェントを学習させました。この対話エージェントにはわかち書き・特徴抽出器(BoW)・識別器(SVM)が含まれますが、これらのうち特徴抽出器と識別器は学習によって必要なパラメータを獲得します。

このシステムを評価するために、「学習データとは別の」入力文例とクラスIDの組のリストを用意します。これを テストデータ と呼びます。例えば以下の test_data.csv のようなデータです。これらのクラスIDが、各入力文に対する「正解」のクラスIDということになります。

test_data.csv(一部抜粋)
label,text
48,ほら、ケーキを買ってきたんだ
48,クッキー焼いてみたんだけど
25,喉がヒリヒリする
25,暑くて喉がカラカラだよ。

テストデータの入力文例をシステムに入力してクラスIDを予測させ、それをテストデータに定義された正解のクラスIDと比較することで、システムを評価します。

例えば、ここでは「テストデータのうち何件の予測が正解と一致したか」を指標としてみましょう(他の評価指標の紹介や詳しい解説は別途)。 これは 正解率 (Accuracy) と呼ばれる指標で、scikit-learnではこれを計算するための sklearn.metrics.accuracy_score が提供されています。

以上の内容を踏まえて、前節で作成した DialogueAgent を評価するコードを書いてみます。

evaluate_dialogue_agent.py
from os.path import dirname, join, normpath

import pandas as pd
from sklearn.metrics import accuracy_score

from dialogue_agent import DialogueAgent  # (1)

if __name__ == '__main__':
    BASE_DIR = normpath(dirname(__file__))

    # Training
    training_data = pd.read_csv(join(BASE_DIR, './training_data.csv'))

    dialogue_agent = DialogueAgent()
    dialogue_agent.train(training_data['text'], training_data['label'])  # (2)

    # Evaluation
    test_data = pd.read_csv(join(BASE_DIR, './test_data.csv'))  # (3)

    predictions = dialogue_agent.predict(test_data['text'])  # (4)

    print(accuracy_score(test_data['label'], predictions))  # (5)
  1. 前節で作成した DialogueAgent をimportします。

  2. dialogue_agent.py と同様に DialogueAgent を学習します。

  3. 同じディレクトリに置いた test_data.csv からテストデータを読み込みます。

  4. 予測を行います。.predict() は文字列のリストを引数に取るので、テストデータ全件をまとめて予測することができます。

  5. 予測結果と正解クラスIDを比較して評価します。ここでは sklearn.metrics.accuracy_score で正解率を計算します。

実行結果
0.3829787234042553

この対話エージェントの性能を、正解率によって評価することができました。 約38%しか正解できていません。

現状の対話エージェントの実装について、前節では「性能がよくない」と書きましたが、それを定量的に確認することができました。 このあとは対話エージェントの性能を改善することを目指して様々な手法を紹介し、組み込んでいきますが、 「性能を改善する」とはすなわち、この評価結果の数字を上げることなのです。

世の中の機械学習システムはほぼ全て評価とセットです。 定量的な評価の結果を良くすることを目標に、機械学習システムを改善していくのです。

Note

ここでは対話システム全体を評価するにあたって「文とクラスIDの組のリスト」をテストデータとしました。 例えば識別器のみを評価対象とするなら、特徴ベクトルとクラスIDの組のリストをテストデータとして、識別器の入出力のみを評価すればよいことになります。この場合、特徴抽出器は固定のものを使い、評価対象外ということになります。

評価したい対象に合わせて必要なテストデータを準備します。

まとめ

本節では、前節までで作成した対話エージェントシステムを定量的に評価する手法を解説しました。

評価に用いるテストデータは、学習データとは違うものを用意する必要があります。


1. 「わかち書き」の元来の意味は、『三省堂 大辞林』によれば、「文を書く時、ある単位ごとに区切って、その間に空白を置くこと。また、その書き方。「たかいやまへのぼる」の類。単語ごとに分ける、文節ごとに分ける、両者を折衷するなどがある。分別書き方。」となっています。これに対し、自然言語処理の文脈では、空白で区切られていない文を区切る処理を指して「わかち書き」という用語を使っています。本記事における「わかち書き」は後者の意味です。
4. その単語・形態素の文中での形。例えば、"僕 / は / 泣か / ない" の中の「泣か」は、原型は「泣く」ですが、文中では活用されて「泣か」になっています。この場合、「泣か」が表層形になります。
5. 新バージョン0.996.1もリリースされていますが、こちらには parseToNode が正しく動作しないバグがあるため、本記事ではバージョン0.7を使います。今後、問題の修正されたバージョンがリリースされるかもしれないので、チェックしてみてください。