日本語テキストの前処理:neologdn、大文字小文字、Unicode正規化

前処理とは

前回の記事では、対話エージェントの作成にあたり、以下の流れでText classificationの仕組みを構築しました。

  1. わかち書き

  2. 特徴抽出(Bag of Words化)

  3. 識別器を学習&識別器で予測

とりあえず動くものができたものの、まだ完璧ではなく、いくつも問題が残っています。 例えば、

Pythonは好きですか

という文を含んだ学習データで対話エージェントを学習させても、語の表記ゆれに対応できていないため、ユーザが

Pythonは好きですか

という文を入力したら、正しく応答できないでしょう(「Python」が、一つ目の文は半角、二つ目は全角で書かれています)。 また、例えば

0,あなたが好きです
1,ラーメン好き!

のようなデータを含む学習データで対話エージェントを学習させたあとに、ユーザ入力として

ラーメンが好きです

が来た場合、「が」や「です」の共通性に引きずられて、クラスID=0の入力文と判断してしまうかもしれません。

以上のような問題を回避するには、Text classificationの処理に入る前に、テキストを適切に整形することです。この記事では、それに役立つ手法の例を紹介していきます。

正規化

コンピュータに入力される文章というのは、表記がかなり不揃いです。 例えば、次の3つの文字列は同じ文ですが、使われている文字は異なっています。

「初めてのTensorFlow」は定価2200円+税です
「初めての TensorFlow」は定価2200円+税です
「初めての TensorFlow」は定価2200円+税です
  • “初めての”と“TensorFlow”の間のスペースの有無

  • スペースの全角半角

  • “TensorFlow”の全角半角

  • “2200”の全角半角

  • “+”の全角半角

  • “「”、“」”の全角半角

などなど……。

ほとんどの場合、このような表記のゆれは文意に影響を与えないので、無視するのが人間の自然な感覚でしょう。 しかし、 前回の記事で作ったシステムは、これらの文を別物と捉えてしまいます。

例えば、前回の記事の http://tuttieee.hatenablog.com/entry/dialogue-agent-tutorial-text-classification#mecab-tokenizer で定義した tokenize() を使って上の3文をわかち書きしてみると、以下のようになります。

>>> tokenize('「初めてのTensorFlow」は定価2200円+税です')
['「', '初めて', 'の', 'TensorFlow', '」', 'は', '定価', '2200', '円', '+', '税', 'です']
>>> tokenize('「初めての TensorFlow」は定価2200円+税です')
['「', '初めて', 'の', '\u3000', 'TensorFlow', '」', 'は', '定価', '2', '2', '0', '0', '円', '+', '税', 'です']
>>> tokenize('「初めての tensorflow」は定価2200円+税です')
['「', '初めて', 'の', 'tensorflow', '」', 'は', '定価', '2200', '円', '+', '税', 'です']

異なったわかち書きの結果が得られるため、異なったBag of Wordsが得られ、結果として識別器の予測結果も違ってしまいます。

また、2文目に注目すると、2200 が1文字ずつに分解されて '2', '2', '0', '0' になってしまっています。 ここでわかち書きのために使っているMeCab辞書は“連続する半角数字”を1つの単語として処理するようになっていますが、全角数字はそうではないからです。 このように、わかち書きのために利用するライブラリ(ここではMeCab)やその辞書が想定していない表記の文をわかち書き処理に入力すると、正しい結果は得られません。

以上のような問題を回避するため、表記のゆれを吸収し、ある一定の表記に統一する処理を行うことが一般的です。これを文字列の 正規化(normalization) といいます。 文字列の正規化は他の処理の前に行うことが一般的ですが、このように他のメインの処理の前に行う処理を指して 前処理(preprocessing) と呼ぶこともあります。

Note

「正規化」という名前のついた処理は、機械学習の文脈ではほかにも登場します。 本節では「文の表記ゆれをなくすために、1つの決まった表記に統一するための文字列変換処理」のことを、「文字列の正規化」として紹介します。 今後ほかの文脈で何らかの“正規化”処理が登場しても、それが「何を」「どのように」正規化する処理なのかを理解して、混乱しないようにしてください。

neologdn

複数の正規化処理をまとめた neologdn という便利なライブラリがあるので使ってみましょう。

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

$ pip install neologdn

以下に使用例を示します。

neologdn_sample.py
import neologdn

print(neologdn.normalize('「初めてのTensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
実行結果
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です

このように表記ゆれが吸収され、文字が統一されていることがわかります。

neologdnのGitHubリポジトリ[1]ではほかにも例が紹介されているので、確認してみてください。

neologdnが行うのは、MeCab辞書([mecab-dic]で後述)の一種であるNEologdのデータを生成する時に使われている正規化処理です。 具体的にどのような正規化が行われているかがNEologdの公式wikiで紹介されており[2]、これをC実装でライブラリ化したものがneologdnです。 このWikiにはPythonで書かれたサンプルコードがありますが、neologdnは関数1つに正規化処理がまとまっていて使い勝手がよいのに加え、Cで実装されているので高速であるという利点もあります。

小文字化/大文字化

neologdn.normalize の変換ルールにはアルファベットの大文字・小文字変換は含まれていないので、次のような表記ゆれを吸収することはできません。

「初めての TensorFlow」は定価2200円+税です
「初めての tensorflow」は定価2200円+税です

しかし、Pythonstr 型の組み込みメソッドである .lower() または .upper() を使って小文字または大文字に表記を統一することで、これを補うことができます。

text = '「初めての TensorFlow」は定価2200円+税です'
print(text.lower())
実行結果
「初めての tensorflow」は定価2200円+税です

特に固有名詞などではアルファベットの大文字・小文字の区別が大事なこともあるため、この処理を行うことがよいとは一概にはいえませんが、有用だと思われるケースでは使っていきましょう。

この小文字化・大文字化処理を行うかどうかを、形態素解析の結果によって判定するというのも1つの手です。 例えばMeCabのほとんどの辞書で形態素解析の結果として品詞情報が得られるため、それを参照し、固有名詞には小文字化・大文字化を行わず、他の単語に対しては行うようにする、などです。

Unicode正規化

Unicodeは現在、文字コードの事実上の標準といえるほど広く使われています。 また、Python 3では文字列型はUnicodeで表現されており[3]Pythonで(特に日本語のような非ASCII文字圏の)自然言語処理を行うのであれば、Unicodeの扱いは避けては通れません。 本節では、Unicodeで文字を扱う際の正規化について見ていきます。

Unicode正規化概説

前節とは違った、以下のような例を考えてみましょう。

㈱ほげほげ
(株)ほげほげ

Unicodeには「㈱」のような文字も定義されており、1つの文字として表現できます。 一方で「(株)」 は「(」「株」「)」の3文字を連ねた文字列ですが、「㈱」も「(株)」も意味としては同じなので、この表記ゆれを吸収できると便利です。

また、もう1つ別の例として、次のコードを見てみましょう。 前回の記事で取り上げた、わかち書き&BoW化を行うコードです。texts には同じ文が2つ入っており、これらをわかち書きした結果をもとにBoWを計算します。同じ文をBoW化するので、同じBoWが得られるはずです。

from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize

texts = [
    'ディスプレイを買った',
    'ディスプレイを買った',
]

vectorizer = CountVectorizer(tokenizer=tokenize)
vectorizer.fit(texts)

print('bow:')
print(vectorizer.transform(texts).toarray())

print('vocabulary:')
print(vectorizer.vocabulary_)
実行結果
bow:
[[1 1 0 0 0 1 1]
 [1 1 1 1 1 0 1]]
vocabulary:
{'ディスプレイ': 5, 'を': 1, '買っ': 6, 'た': 0, 'テ': 4, '゙': 2, 'ィスプレイ': 3}

ところがどうでしょう。異なるBoWが得られてしまいました([1 1 0 0 0 1 1][1 1 1 1 1 0 1])。

BoWの語彙をチェックしてみると、'テ' ' ゙', 'ィスプレイ' という何やら怪しげな語彙が獲得されています。 実は2文目に含まれる「デ」は 結合文字列(combining character sequence) と呼ばれる特殊な表現で、複数の文字(正確には「コードポイント」、後述)の組み合わせで表現される文字なのでした。この例だと「テ」と「 ゙」(濁点)を組み合わせて1つの文字「デ」になります。これは特殊な例ではなく、例えばMacOSのfinderでファイル名やフォルダ名を編集すると、結合文字列が使われてしまいます[4]

それに対して1文目の「デ」は 合成済み文字(precomposed character) と呼ばれ、1つの文字として「デ」を表現します。

以上のように、Unicodeには様々な文字と仕様が定義されているため、同一と思える文字でも細かい表記(というか、バイト表現)が異なることがあります。必要な場面ではこれらをそのまま区別して扱えばよいのですが、「文意を読み取る」ことを目的とした自然言語処理においては、多くの場合この表記ゆれは無視するのが好ましいのです。

Unicodeにはまさにこの表記ゆれの吸収を可能にする正規化の仕様が定義されており、Pythonでは標準モジュールとして提供されている unicodedata から利用することができます。

unicode_normalization_sample.py
import unicodedata

normalized = unicodedata.normalize('NFKC', '㈱ほげほげ')

assert normalized == '(株)ほげほげ'
print(normalized)
実行結果
(株)ほげほげ

このように unicodedata.normalize メソッドによって (株) に変換され、表記ゆれを吸収できます。この変換ルールはUnicodeに仕様としていくつか定義されていて、そのうちの1つである NFKC を第1引数で指定しています。

また、2つ目の例の問題も解決できます。上記のBoWのサンプルコードを修正し、tokenize の前にUnicode正規化を行うようにしてみます。

unicode_normalization_bow_sample.py
import unicodedata

from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize


def normalize_and_tokenize(text):
    normalized = unicodedata.normalize('NFKC', text)
    return tokenize(normalized)


texts = [
    'ディスプレイを買った',
    'ディスプレイを買った',
]

vectorizer = CountVectorizer(tokenizer=normalize_and_tokenize)
vectorizer.fit(texts)

print('bow:')
print(vectorizer.transform(texts).toarray())

print('vocabulary:')
print(vectorizer.vocabulary_)
実行結果
bow:
[[1 1 1 1]
 [1 1 1 1]]
vocabulary:
{'ディスプレイ': 2, 'を': 1, '買っ': 3, 'た': 0}

すると今度は、期待したとおりの結果が得られました。結合文字列の「デ」が合成済み文字の「デ」に変換されたのです。

Unicode正規化詳解

Unicode正規化についてもう少し詳しく見ていきましょう。 Unicode正規化を理解するには、Unicodeの仕様をある程度知らなければなりませんが、ここでは必要な部分を少しだけかいつまんで解説します(Unicodeに興味のある方は、ぜひ詳細な仕様も調べてみてください)。

Unicode文字コードの一種です。 そして文字コードとは、コンピュータ内部で、文字をどのようなバイト列で表現するかを定めたルールのことです。

例えばUnicodeでは「あ」を 0x3042 で表現することになっています。 この 0x3042コードポイント(code point) と呼び、それがUnicodeのコードポイントであることを示すために「 U+3042 」と表記します。

Pythonで実際に確かめてみましょう。Pythonには次の2つの組み込み関数が用意されています。

ord()

Unicode文字を受け取り、コードポイントを表す整数を返す。

chr()

コードポイントを表す整数を受け取り、Unicode文字を返す。

「あ」のコードポイントが 0x3042 であることを見てみましょう。

>>> hex(ord('あ'))
'0x3042'
>>> chr(0x3042)
'あ'

そして上の例で登場した「デ」のコードポイントは U+30C7 です。

>>> hex(ord('デ'))
'0x30c7'
>>> chr(0x30C7)
'デ'

また、「テ」のコードポイントは U+30C6 で、「 ゙」(濁点)のコードポイントは U+3099 です。 この2つを連続して並べると、Unicodeでは結合文字列として解釈され、1文字として扱われます。

>>> chr(0x30C6) + chr(0x3099)
'デ'

結合文字列を作る場合、先頭の文字(この例では「テ」)を 基底文字(base character) といい、後に続くコードポイント(この例では「 ゙」)を 結合文字(combining character) といいます。

さて、「デ」を表すには、合成済み文字 0x30C7 を使うか、2つのコードポイント U+30C6 U+3099 を並べて結合文字列として表現するかという2種類の方法があることがわかりました。 しかしこれは上の例で見たように、同じ文字に複数の表現方法が存在するということでもあり、場合によっては不便です。

この問題に対してUnicodeは、「同じ文字として扱うべきコードポイントの組を定義する」という方法で対応しました。これを Unicodeの等価性 といいます。 この等価性には「 0x30C7U+30C6 U+3099 は等価である」というルールも定義されています。このルールを参照することで、0x30C7U+30C6 U+3099 が同じ文字を表していることがわかるのです。

そして、Unicode正規化とは、この等価性に基づき合成済み文字を分解したり合成したりすることです。 分解は合成済み文字を結合文字列に変換する処理( 0x30C7U+30C6 U+3099 )、合成は結合文字列を合成済み文字に変換する処理( U+30C6 U+30990x30C7)です。 分解も合成もその名のとおりですね。

Unicodeの分解と合成

Figure 1. Unicodeの分解と合成

Unicode正規化には4種類あり、分解・合成の適用方法と、用いる等価性が異なっています。

NFD (Normalization Form Canonical Decomposition)

正準等価性による分解。 bib

NFC (Normalization Form Canonical Composition)

正準等価性による分解→正準等価性による合成。

NFCD (Normalization Form Compatibility Decomposition)

互換等価性による分解。

NFKC (Normalization Form Compatibility Composition)

互換等価性による分解→正準等価性による合成。

Unicodeの等価性には 正準等価性(canonical equivalence)互換等価性(compatibility equivalence) の2つがあります。これらは、どの文字を同等とみなすかの基準が異なっています。

正準等価性 (canonical equivalence)

見た目も機能も同じ文字を等価とみなす等価性。例えば「デ」と「テ」+「 ゙」。

互換等価性 (compatibility equivalence)

正準等価性よりも範囲の広い等価性。 見た目や機能が異なる可能性はあるが、同じ文字がもとになっているもの[5]を等価とみなす。例えば「テ」と「テ」、「ℌ」と「ℍ」と「H」、など。 互換等価性は正準等価性を含むが、その逆は成り立たない。

unicode_normalization_sample.pyunicodedata.normalize の第1引数に与えた 'NFKC' は、上記の正規化の種類を指定していたのです。 実際にアプリケーションに利用する時は、そのアプリケーションが扱う問題やデータの性質に合わせ、どの正規化を用いるのかを決定する必要があります。 互換等価性に基づいた変換を含むNFCD・NFKCはNFD・NFCより大胆な変換を行いますが、 本書のケース(文字の細かい差異は無視して文の意味に注目するため、カタカナの全角半角などは吸収してしまいたい)では前者の方が適していると判断し、本節の例には NFKC を採用しました。

なお、等価性や正規化の厳密な仕様は、 http://unicode.org/reports/tr15/ を参照してください。 また、本記事の内容は[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)を参考にしました。Unicode正規化に限らず、文字コードについて深く知りたいときは参照してみてください。

 

[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)

[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)

 

 


4. macOS High Sierraで確認。
5. Unicodeの仕様には「同一の抽象文字を表す(represent the same abstract character)」と表現されています。

Bag of Words詳解

文字列をベクトル(固定長の list またはnumpy配列)に変換する「特徴抽出」の一手法として、 Text classificationで、簡単な対話エージェントを作ってみるチュートリアルではBag of Wordsを紹介しました。 Bag of Wordsは、それぞれの文における単語の出現回数をカウントして、それを特徴量とするというシンプルな手法でした。 実装や使い方については上記の記事の中で解説したので、ここではBag of Wordsの性質を詳しく見てみましょう。

Bag of Wordsの性質

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

まず、[feature-vectorization]で述べたとおり、Bag of Wordsは元となった文の特徴を表したベクトルになっています。 例えば、次の3つの例文があるとします。

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

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

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

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

グラフ化したBag of Words

Figure 1. グラフ化したBag of Words

これを見てわかるとおり、 「私はラーメンが好きです」のBoWは、「富士山は日本一高い山です」のBoWよりも「私は私のことが好きなあなたが好きです」のBoWに似ています(値がゼロでない次元の一致が多いということです)。 これは、「私は私のことが好きなあなたが好きです」という⽂と、「私はラーメンが好きです」という⽂が、ともに“私”という主語の嗜好を表した⽂である、という類似性を捉えているといえます。

BoWは単語の出現頻度をベクトル化したものなので、この例でいえば「私」や「好き」といった単語に対応する値がプラスになり、似るのだ、というのは至極当然の結果のように思えます。確かにそのとおりなのですが、この例が⽰しているのは、もう少し深⻑な事柄です。それは、「これら2つの⽂章はいずれも“私”という主語の嗜好を表している」という⽂意の類似性を、「私」や「好き」といった単語の出現頻度の類似性で、ある程度捉えられているということです。すなわち、これら2つの文章はいずれも「“私”という主語の嗜好を表している」という点で類似していると考えられるのです。

ここでは、プログラムで扱いやすい固定長の配列を得るために、元の日本語の文から単語の出現頻度という情報のみを抜き出して特徴ベクトルにしましたが、文意を捉えるためには(ある程度は)それで十分だというのが、この手法の面白いところです。

また、単語の出現頻度のみに注目するということは、他の情報(語順情報など)は無視しているということですが、これはより積極的なメリットになりえます。 例えば、「明日友達と遊園地に遊びに行く」と「友達と遊園地に明日遊びに行く」という例を考えてみましょう。この2文は語順は異なりますが同じ意味です。 そして、BoWを求めると、同じ特徴ベクトルが得られるのがわかるでしょう。 語順の違いを無視したことにより、文意の同一性を特徴ベクトルの同一性に反映できたのです。 これは語順の違いを無視するBoWの長所を端的に示す例です。 特に日本語のような語順が自由な言語では、語順を無視することで文意の同一性を捉えることができるケースはよくあります。

ただし、語順情報が重要な役割を持つ場合もあり、そのような時には上記のようなBoWの性質はデメリットになりえます。 有名な例に「犬が人を噛んだ」と「人が犬を噛んだ」というのがあります[1]。この2つの文はまったく違うことを表していて、大半のケースでは区別するのが望ましいと思われますが、BoWではこの2つを区別できません。どちらの文からBoWを作っても、同じベクトルができてしまいます。 この例では、単語の出現順序の違いから2文の意味が大きく異なっていますが、BoWはまさに単語の出現順序情報を捨ててしまい、この2文を区別できなくなってしまっています。

最初に述べたとおりBoWは基本的な手法ですので、利点に加え、このような限界もあります。そこで、BoWを改良する方法の1つとして、「明日友達と遊園地に遊びに行く」と「友達と遊園地に明日遊びに行く」が同じベクトルに変換され、「犬が人を噛んだ」と「人が犬を噛んだ」は違うベクトルに変換されるような手法を考えてみましょう。わかち書きの結果だけでなく、形態素解析で得られる品詞情報「助詞」を使うのが1つの手ですね。

Tip
Google検索における特徴抽出

Googleで「人が犬を噛んだ」と「犬が人を噛んだ」を検索してみました[2]。 4、5番目が違いますが、ほぼ同じ結果になりました。

人が犬を噛んだ

人が犬を噛んだ



犬が人を噛んだ

犬が人を噛んだ



まさかGoogleがBoWだけに頼っているとは思えませんが、単語をわかち書きした上で、ある程度は順序情報を無視するような処理が行われていると予想できます(Googleの検索結果は変化しますし、様々な要因を考慮してパーソナライズされているため、読者が試しても同じ結果になるとは限らないことに注意してください)。

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

"Bag" of Words

Figure 2. "Bag" of Words

未知語

辞書生成と特徴ベクトルの算出(特徴抽出)は分かれているため、辞書の作成に使う文集合と、特徴抽出の対象となる文集合が同じである必要はありません。

texts_A = [
    ...
]

vectorizer.fit(texts_A)

texts_B = [
    ...
]

bow = vectorizer.transform(texts_B)

この場合、辞書の生成はあくまでも、.fit() に渡された texts_A に含まれる文に基づいて行われます。 その後 texts_B を対象に .transform() で特徴抽出を行うわけですが、 texts_A に登場しなかった単語は辞書にも含まれないので、BoW化の時には無視されます。

3章の例の範囲では、このようにBoW化の対象とは異なる文集合を使って辞書を作成するメリットはあまりありません。しかし例えば、texts_B にあえて無視したい単語(無意味な絵文字や顔文字、無意味な単語など)がある場合などには有効でしょう。

逆に、BoW化の対象となる文集合と、BoW辞書の構築に用いる文集合を分ける場合は、辞書の生成に使う文集合 texts_A のほうに texts_B を表現するのに十分な種類の単語が含まれるよう注意しなければなりません。


1. 正確にはこの例文はBoWの説明のための例文として有名なわけではありません。気になる方は“Man bites dog”で検索してみましょう。
2. 2019-01-31時点の結果。Google Chromeのシークレットウィンドウで閲覧。

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を使います。今後、問題の修正されたバージョンがリリースされるかもしれないので、チェックしてみてください。