日本語テキストの前処理:neologdn、大文字小文字、Unicode正規化
自然言語処理 #2 Advent Calendar 2019 - Qiita の1日目です。
本稿は、拙著「15Stepで踏破 自然言語処理アプリケーション開発入門」の第2章 Step2の内容を、出版社の許可を得て掲載しています。
■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 データ収集
前処理
前処理とは
前回の記事では、対話エージェントの作成にあたり、以下の流れでText classificationの仕組みを構築しました。
-
わかち書き
-
特徴抽出(Bag of Words化)
-
識別器を学習&識別器で予測
とりあえず動くものができたものの、まだ完璧ではなく、いくつも問題が残っています。 例えば、
Pythonは好きですか
Pythonは好きですか
0,あなたが好きです
1,ラーメン好き!
ラーメンが好きです
以上のような問題を回避するには、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
以下に使用例を示します。
import neologdn
print(neologdn.normalize('「初めてのTensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です
このように表記ゆれが吸収され、文字が統一されていることがわかります。
小文字化/大文字化
neologdn.normalize
の変換ルールにはアルファベットの大文字・小文字変換は含まれていないので、次のような表記ゆれを吸収することはできません。
「初めての TensorFlow」は定価2200円+税です 「初めての tensorflow」は定価2200円+税です
しかし、Pythonの str
型の組み込みメソッドである .lower()
または .upper()
を使って小文字または大文字に表記を統一することで、これを補うことができます。
text = '「初めての TensorFlow」は定価2200円+税です'
print(text.lower())
「初めての tensorflow」は定価2200円+税です
特に固有名詞などではアルファベットの大文字・小文字の区別が大事なこともあるため、この処理を行うことがよいとは一概にはいえませんが、有用だと思われるケースでは使っていきましょう。
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には様々な文字と仕様が定義されているため、同一と思える文字でも細かい表記(というか、バイト表現)が異なることがあります。必要な場面ではこれらをそのまま区別して扱えばよいのですが、「文意を読み取る」ことを目的とした自然言語処理においては、多くの場合この表記ゆれは無視するのが好ましいのです。
import unicodedata
normalized = unicodedata.normalize('NFKC', '㈱ほげほげ')
assert normalized == '(株)ほげほげ'
print(normalized)
(株)ほげほげ
このように unicodedata.normalize
メソッドによって ㈱
が (株)
に変換され、表記ゆれを吸収できます。この変換ルールはUnicodeに仕様としていくつか定義されていて、そのうちの1つである NFKC
を第1引数で指定しています。
また、2つ目の例の問題も解決できます。上記のBoWのサンプルコードを修正し、tokenize
の前にUnicode正規化を行うようにしてみます。
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では「あ」を 0x3042
で表現することになっています。 この 0x3042
を コードポイント(code point) と呼び、それがUnicodeのコードポイントであることを示すために「 U+3042
」と表記します。
「あ」のコードポイントが 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の等価性 といいます。 この等価性には「 0x30C7
と U+30C6 U+3099
は等価である」というルールも定義されています。このルールを参照することで、0x30C7
と U+30C6 U+3099
が同じ文字を表していることがわかるのです。
そして、Unicode正規化とは、この等価性に基づき合成済み文字を分解したり合成したりすることです。 分解は合成済み文字を結合文字列に変換する処理( 0x30C7
→ U+30C6 U+3099
)、合成は結合文字列を合成済み文字に変換する処理( U+30C6 U+3099
→ 0x30C7
)です。 分解も合成もその名のとおりですね。
Unicode正規化には4種類あり、分解・合成の適用方法と、用いる等価性が異なっています。
Unicodeの等価性には 正準等価性(canonical equivalence) と 互換等価性(compatibility equivalence) の2つがあります。これらは、どの文字を同等とみなすかの基準が異なっています。
unicode_normalization_sample.pyで unicodedata.normalize
の第1引数に与えた 'NFKC'
は、上記の正規化の種類を指定していたのです。 実際にアプリケーションに利用する時は、そのアプリケーションが扱う問題やデータの性質に合わせ、どの正規化を用いるのかを決定する必要があります。 互換等価性に基づいた変換を含むNFCD・NFKCはNFD・NFCより大胆な変換を行いますが、 本書のケース(文字の細かい差異は無視して文の意味に注目するため、カタカナの全角半角などは吸収してしまいたい)では前者の方が適していると判断し、本節の例には NFKC
を採用しました。
なお、等価性や正規化の厳密な仕様は、 http://unicode.org/reports/tr15/ を参照してください。 また、本記事の内容は[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)を参考にしました。Unicode正規化に限らず、文字コードについて深く知りたいときは参照してみてください。
[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)
- 作者: 矢野啓介
- 出版社/メーカー: 技術評論社
- 発売日: 2018/12/28
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
Bag of Words詳解
本稿は、拙著「15Stepで踏破 自然言語処理アプリケーション開発入門」の第2章 Step04の内容の一部を、出版社の許可を得て掲載しています。
■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 データ収集
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ではベクトルの各要素が、それぞれ特定の単語に対応しているので、横軸にその単語を並べています。
これを見てわかるとおり、 「私はラーメンが好きです」のBoWは、「富士山は日本一高い山です」のBoWよりも「私は私のことが好きなあなたが好きです」のBoWに似ています(値がゼロでない次元の一致が多いということです)。 これは、「私は私のことが好きなあなたが好きです」という⽂と、「私はラーメンが好きです」という⽂が、ともに“私”という主語の嗜好を表した⽂である、という類似性を捉えているといえます。
BoWは単語の出現頻度をベクトル化したものなので、この例でいえば「私」や「好き」といった単語に対応する値がプラスになり、似るのだ、というのは至極当然の結果のように思えます。確かにそのとおりなのですが、この例が⽰しているのは、もう少し深⻑な事柄です。それは、「これら2つの⽂章はいずれも“私”という主語の嗜好を表している」という⽂意の類似性を、「私」や「好き」といった単語の出現頻度の類似性で、ある程度捉えられているということです。すなわち、これら2つの文章はいずれも「“私”という主語の嗜好を表している」という点で類似していると考えられるのです。
ここでは、プログラムで扱いやすい固定長の配列を得るために、元の日本語の文から単語の出現頻度という情報のみを抜き出して特徴ベクトルにしましたが、文意を捉えるためには(ある程度は)それで十分だというのが、この手法の面白いところです。
また、単語の出現頻度のみに注目するということは、他の情報(語順情報など)は無視しているということですが、これはより積極的なメリットになりえます。 例えば、「明日友達と遊園地に遊びに行く」と「友達と遊園地に明日遊びに行く」という例を考えてみましょう。この2文は語順は異なりますが同じ意味です。 そして、BoWを求めると、同じ特徴ベクトルが得られるのがわかるでしょう。 語順の違いを無視したことにより、文意の同一性を特徴ベクトルの同一性に反映できたのです。 これは語順の違いを無視するBoWの長所を端的に示す例です。 特に日本語のような語順が自由な言語では、語順を無視することで文意の同一性を捉えることができるケースはよくあります。
ただし、語順情報が重要な役割を持つ場合もあり、そのような時には上記のようなBoWの性質はデメリットになりえます。 有名な例に「犬が人を噛んだ」と「人が犬を噛んだ」というのがあります[1]。この2つの文はまったく違うことを表していて、大半のケースでは区別するのが望ましいと思われますが、BoWではこの2つを区別できません。どちらの文からBoWを作っても、同じベクトルができてしまいます。 この例では、単語の出現順序の違いから2文の意味が大きく異なっていますが、BoWはまさに単語の出現順序情報を捨ててしまい、この2文を区別できなくなってしまっています。
最初に述べたとおりBoWは基本的な手法ですので、利点に加え、このような限界もあります。そこで、BoWを改良する方法の1つとして、「明日友達と遊園地に遊びに行く」と「友達と遊園地に明日遊びに行く」が同じベクトルに変換され、「犬が人を噛んだ」と「人が犬を噛んだ」は違うベクトルに変換されるような手法を考えてみましょう。わかち書きの結果だけでなく、形態素解析で得られる品詞情報「助詞」を使うのが1つの手ですね。
Tip
|
Google検索における特徴抽出
人が犬を噛んだ
犬が人を噛んだ
|
最後に、Bag of Wordsという名前の由来に触れておきます。 Bag of Wordsは単語の出現順序を無視して出現回数だけを数えるものでした。 これが、文を単語(Word)に分解して、袋(Bag)にバラバラに放り込み、個数を数えるというイメージにつながっています。 「順序を無視して個数だけ数える」という特徴から連想されるネーミングになっています。
未知語
辞書生成と特徴ベクトルの算出(特徴抽出)は分かれているため、辞書の作成に使う文集合と、特徴抽出の対象となる文集合が同じである必要はありません。
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
を表現するのに十分な種類の単語が含まれるよう注意しなければなりません。
Text classificationで、簡単な対話エージェントを作ってみるチュートリアル
Text classificationで作る簡単な対話エージェント
自然言語処理 Advent Calendar 2017 - Qiita の24日目です。
シンプルな対話エージェントを作ることをゴールに、自然言語処理タスクの一種であるText classificationとその周辺のプログラミングを解説するチュートリアルです。
-
対象:プログラミングはしたことあるが、機械学習はやったことがない人
-
言語:Python3
一部内容を http://tuttieee.hatenablog.com/entry/bag-of-words に移動し、追記しました。
また、本稿は、拙著「15Stepで踏破 自然言語処理アプリケーション開発入門」の第2章 Step1の内容を、出版社の許可を得て掲載しています。
■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 データ収集
対話エージェントシステム概要
この記事では、簡単な対話エージェントを題材にして、自然言語処理プログラミングの要素の一部を体験していきます。
対話エージェントシステム
まず、これから本記事で作成する対話エージェントプログラムの概要を紹介します。 このプログラムは、以下のように動作します。
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) といいます。汎化は機械学習の重要な性質の一つです。
以上が対話システムのざっくりした概要でした。 機械学習システムをプログラムとして組み上げるために、もう少し具体化してみましょう。
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
クラスを具体的にどう実装すればよいのか)、詳しく見ていきましょう。
形態素解析
対話エージェントに与えられるデータは、学習データ中の入力文例も実際のユーザの入力文も、ただの日本語の文章であり、プログラム的に見ればただのバイト列です。 このデータをどのようにプログラムで扱えばいいのでしょう?
最初のステップは、文を単語に分解することです。 文を単語に分解できれば、各単語に番号を割り当てることができ、文を数値の配列として扱えるようになります。
数値の配列にすることで、かなりプログラムで扱いやすくなりますね(文を数値の配列に変換したあとの具体的な処理は次節以降で解説します)。
英語のように単語の間にスペースのある言語であれば、文を単語に区切る処理はほとんどの場合不要なのですが、 日本語のように単語の間にスペースが無い言語は、単語の境界を判定する処理を行なって、文を分解する必要があります。
Note
|
実はこの説明は正確ではありません。 まず、形態素とは文を構成する「意味をもつ最小の単位」のことで、「単語」とは若干意味が異なります。 例えば、「自然言語処理」は一つの単語ですが、これはさらに「自然」「言語」「処理」という形態素に分解できます(これをもって、「自然言語処理」という単語は複合語と呼ばれます)。 また他の例として、丁寧語「お花」は形態素「お」「花」に分解できます。 ではMeCabはいったい何を行っているのでしょう。 MeCabが文を分割するとき、どの単位に区切るかは後述する「辞書」に依存します。 これによって、本当の意味での形態素解析ができたり、複合語は1つのまとまりとして扱って文を分解できたりします。 この解説では一旦、「単語」や「形態素」といった用語をあまり厳密に区別せずに使います。 |
MeCabのインストール
以下の手順で適宜インストールしてください。
コマンドラインから試してみる
シェルで mecab
コマンドを実行すると、入力受付状態になります。
$ mecab
この状態で、日本語文を入力し、Enterを押すと、形態素解析の結果が表示されます。
$ mecab
私は私のことが好きなあなたが好きです
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
こと 名詞,非自立,一般,*,*,*,こと,コト,コト
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ
あなた 名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ
です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
EOS
正しく単語ごとに分割され、品詞などの情報も付与されていますね。
各行のフォーマットは、以下のようになっています[3]。
表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
(このフォーマットは後述する 辞書 に依存しており、どの辞書を使うかによって変わります。)
Pythonから呼び出す
pipでインストールできます。
$ pip install mecab-python3
import MeCab
tagger = MeCab.Tagger()
print(tagger.parse('私は私のことが好きなあなたが好きです'))
私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ は 助詞,係助詞,*,*,*,*,は,ハ,ワ 私 名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ の 助詞,連体化,*,*,*,*,の,ノ,ノ こと 名詞,非自立,一般,*,*,*,こと,コト,コト が 助詞,格助詞,一般,*,*,*,が,ガ,ガ 好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ あなた 名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ が 助詞,格助詞,一般,*,*,*,が,ガ,ガ 好き 名詞,形容動詞語幹,*,*,*,*,好き,スキ,スキ です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス EOS
parse()
で、shellからMeCabを実行したときの結果をそのまま得ることができました。 しかし、この形式の文字列が得られても、改行で区切ったり \t
や ,
で区切ったりしてパースする必要があり、不便です。
よりプログラムで扱いやすい形式の返り値が得られるメソッドも用意されています。
import MeCab
tagger = MeCab.Tagger()
tagger.parse('') # workaround
node = tagger.parseToNode('私は私のことが好きなあなたが好きです')
print(node.surface)
print(node.next.surface)
print(node.next.next.surface)
print(node.next.next.next.surface)
print(node.next.next.next.next.surface)
print(node.next.next.next.next.next.surface)
私 は 私 の こと
このように、 parseToNode
で得られるnodeオブジェクトは、わかち書き結果の一番最初の単語を表しています。そして、次の単語を表すnodeオブジェクトへの参照 next
を次々に辿っていくことで、わかち書き結果の各単語を表すnodeオブジェクトに順々にアクセスすることができます。 そして、各nodeオブジェクトの surface
プロパティから、対応する単語の表層形 [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として返す、簡単な関数を作ってみましょう。
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
|
実行結果
私 は 私 の こと が 好き な あなた が 好き です これを使って、以下のようなわかち書き関数の実装も考えられます。 tokenizer_buggy.py
しかし実はこの実装には問題があります。 半角スペースを区切り文字にしているので、半角スペースを含む単語が登場した時に、区切り文字としての半角スペースと単語の一部としての半角スペースが混ざってしまい、正しくわかち書きできません。 後述する辞書によっては、半角スペースを含んだ単語を扱うことになります。この実装は避けたほうがよいでしょう。 |
特徴ベクトル化
前節までで、日本語の文をわかち書きできるようになりました。プログラム上の計算で扱うために、このわかち書きされた文章(文字列)をさらに変換し、コンピュータで計算可能な形式にする必要があります。より具体的に言うと、1つの文章を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をもとに何らかの手法でベクトルを得ます。
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の例における bow0
や bow1
)が、特徴ベクトルとなります。 この変換手法のことをBag of Wordsと呼んだり、得られるベクトルのことをBag of Wordsと呼んだりします (「文をBag of Wordsで特徴ベクトル化する」と言ったりも、「文をBag of Words化する」と言ったりもします)。
この手法によって得られるベクトルの次元数は、語彙の数になります。 上の例では語彙の数、すなわち単語の種類数が10なので、特徴ベクトル bow0
, bow1
の次元数も10になります(len(bow0) == 10
)。つまり、語彙を固定しておけば、入力される文字列の長さがどうであれ、常に一定のサイズのベクトルが得られることになります。
以上ような処理によって、1つの文章を1つのベクトル(list)に変換することができました。 日本語で書かれた元々の文は、長さの一定しない、コンピュータにとっては大した意味のないバイト列でした。 しかし、Bag of Words化することで、ある決まった長さで、コンピュータにとって扱いやすい数値の配列に変換することができます。
Bag of Wordsの実装
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)
-
tokenizer.pyで作成したわかち書き関数をインポート
-
Bag of Words計算関数
-
辞書生成ループ
-
単語出現回数カウントループ
>>> vocabulary
{'私': 0, 'は': 1, 'の': 2, 'こと': 3, 'が': 4, '好き': 5, 'な': 6, 'あなた': 7, 'です': 8, 'ラーメン': 9, '富士山': 10, '日本一': 11, '高い': 12, '山': 13}
>>> bow
[
[2, 1, 1, 1, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1]
]
このプログラムはBag of Wordsの説明に即した形で、1番目のforループで辞書の生成を行い、2番目のforループで各単語の出現回数のカウントを行っています。
この例では文を3つ入力していて、それぞれからBoWが得られるので、 bow
は2次元配列で1次元目の長さは入力した文の数である3になります(len(bow) == 3
)。 また、語彙 vocabulary
は13単語得られました。従って、それぞれのBoWのサイズ=特徴ベクトルの次元数は13になります(len(bow[0]) == 13
)。
Tip
|
collections.Counter の利用Pythonの標準ライブラリに bag_of_words_counter_ver.py
Pythonには充実した標準ライブラリが整備されています。 標準ライブラリには、今回利用した |
scikit-learnによるBoWの計算
以上のように、BoWは非常に単純なアルゴリズムなので、一度は自分で実装してみることをおすすめします。 具体的に何が行われているのか、よく理解できます。
ただ、ここからはscikit-learnに用意されているBoW計算用クラスを利用することにします。 scikit-learnにはBag of Wordsを含む様々な特徴抽出アルゴリズムが用意されており、よく整理され統一されたAPIが提供されています。 このライブラリを利用してプログラムを書いておくことで、後に様々なアルゴリズムを容易に試せるようになります。
scikit-learnではBag of Wordsを計算する機能をもったクラスとして sklearn.feature_extraction.text.CountVectorizer
が提供されているので、これを使ってBoWを計算するコードを書いてみましょう。
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)
-
tokenizer.pyで作成したわかち書き関数をインポート
-
CountVectorizer
のコンストラクタにはtokenizer
引数でわかち書き関数を渡します。tokenizer
を指定しない場合のデフォルト設定ではスペースで文を単語に区切る処理が行われますが、これは英語のような単語をスペースで区切る言語を想定した動作です。tokenizer
にcallable(関数、メソッドなど)を指定するとそれが文の分割に使われるので、日本語を対象にする場合は、自前で実装したわかち書き関数を指定するようにします。 -
辞書の生成
-
BoWの計算
bag_of_words.pyでは辞書の生成と単語の出現回数のカウントが2つのループに分かれていましたが、 sklearn.feature_extraction.text.CountVectorizer
ではこの2つに対応して CountVectorizer.fit()
, CountVectorizer.transform()
というメソッドがあります。
CountVectorizer.fit()
で辞書が作成され、一度辞書の作成を行ったインスタンスでは、 transform()
を呼ぶことで、その辞書に基づいたBag of Wordsが生成されます。
Note
|
BoWはほとんどの要素がゼロのベクトルになります。 ある文をBoWに変換する時、その文中に登場しない単語に対応する要素はゼロになりますが、一般的にそのようなゼロ要素のほうが非ゼロ要素より多くなるからです。語彙が増えれば増えるほど、ゼロ要素が多くなる確率は上がります。 このような、ほとんどの要素の値がゼロで、一部の要素だけが非ゼロの値をもつようなベクトル・行列を 疎ベクトル(sparse vector) 、 疎行列(sparse matrix) といいます。 疎ベクトル・疎行列を表す際には、通常の多次元配列のように値を全てメモリに記録するのではなく、非ゼロ要素のインデックスと値のみを記録するようにしたほうがメモリ効率がよくなります。 このような疎行列の実装が
本章で扱うプログラムでは
(疎行列は |
学習データのBoW化
対話エージェントの学習につかう学習データ training_data.csv
を用意します。
label,text
48,昨日作ったお菓子の写真だよ
48,手作りクッキー作ったんだよ
48,ケーキ美味しそうでしょ。
25,口が渇く
25,のどがカラカラです
25,やっと部活が終わった
20,やることないよ。
20,退屈だなぁ
20,何して時間潰そうかな
前節の sklearn.feature_extraction.text.CountVectorizer
による実装を使って、この学習データをBag of Words化してみましょう。
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)
-
tokenizer.pyで作成したわかち書き関数をインポートします。
-
ここではCSVファイルの読み込みに
pandas
を使っています。Pythonのcsv
モジュールなど、他の方法でも構いません。 -
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を出力する識別器が出来上がります。
scikit-learnから使う
識別器には様々な手法があります。 アタックする問題の性質によって適するものを選ぶ、もしくは適するものを見つけるために試行錯誤する必要があるのですが、ここではとりあえず SVM(Support 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)
-
scikit-learnで提供されている、SVMによる識別器の実装
sklearn.svm.SVC
をインポートします。 -
学習データの特徴ベクトルです。学習データ1つの特徴ベクトルは1次元配列で、それが複数あるので、2次元配列になります。
-
学習データのクラスIDです。学習データ1つのクラスIDはint型で、それが複数あるので、1次元配列になります。
-
sklearn.svm.SVC
をインスタンス化します。 -
fit()
メソッドを呼ぶことで、学習が行われます。引数は学習データです。 -
5で学習された識別器インスタンス
classifier
のpredict()
メソッドを呼ぶことで、予測を行えます。入力するtest_data
はtraining_data
と同様2次元配列で、返り値predictions
はtraining_labels
と同様1次元配列です。
対話エージェントシステムを作る
以上で、機械学習を利用した対話システムを作るために必要な最低限の道具が揃いました。 では、ここまでで紹介した「わかち書き→Bag of Words化→識別器による学習&予測」という一連の処理を使い、対話エージェントを実現するコードを書いてみましょう。
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])
-
MeCabで利用する辞書がインストールされているパスを指定します(デフォルトのインストールディレクトリはshellで
mecab-config --dicdir
を実行すると調べることができます)。 -
CountVectorizer.fit_transform()
は、.fit()
による語彙の獲得と、.transform()
による特徴ベクトル化を一度に行なうメソッドです。 -
辞書を生成済みの
vectorizer
、学習済みのclassifier
は予測のときに使うので、インスタンス変数として保持しておきます。 -
.predict()
は文字列のリストを受け取るので、[input_text]
としています。 -
.predict()
はクラスIDの配列を返すので、その0番目を取り出します。
学習データ training_data.csv
は 学習データのBoW化 のものと同じです。
応答文データ replies.csv
は以下のとおり、各クラスIDに対応する応答文が1行ごとに書いてあります。
私は〇〇といいます
ラーメンとか好きですよ
うーん、特に無いです
良かったですね!
それは辛いですね
このコードを実行すると、以下のようになります。
$ python dialogue_agent.py
私は〇〇といいます
入力文として指定した '名前を教えてよ'
に対して、「私は〇〇といいます」と応答を返してくれます(「〇〇」の部分は自分で名前をつけてみてください)。 入力文 input_text
の内容を変えて、いろいろ試してみましょう。
Note
|
応用課題
|
実際に試してみると分かりますが、実はこの実装はまだあまり性能がよくありません。 トンチンカンな返答を返すことがよくあります。
疲れたよー
こんにちは
これからの章で、対話エージェントの性能を上げる(識別性能を上げる)ための様々な手法を紹介していきます。
scikit-learnを使うときのTips: Pipeline化する
scikit-learnが提供する各コンポーネントは、 fit()
, predict()
, transform()
などの統一されたAPIを持つよう設計されています。これらは sklearn.pipeline.Pipeline
でまとめることができます。
Pipeline
を使うように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.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])
-
vectorizer
,classifier
がpipeline
にまとめられます。 -
pipeline.fit()
の内部で、vectorizer.fit()
,vectorizer.transform()
とclassifier.fit()
が呼ばれます。 -
pipeline.predict()
の内部で、vectorizer.transform()
とclassifier.predict()
が呼ばれます。
dialogue_agent.pyから変わったのは、 DialogueAgent.train()
と DialogueAgent.predict()
の部分です。 CountVectorizer
でBoWを計算→ SVC
にBoWを入力して学習、という流れが、 pipeline
で表現されています。 このように、 Pipeline
は fit()
や predict()
を最初の段から次々に実行し、それぞれの段の出力を次の段に伝えていくようにできています。
Pipeline
を使うことで bow
変数はコード上に登場しなくなり、また vectorizer
や classifier
といった変数が pipeline
1つにまとめられ、スッキリしました。 処理に必要な全てのパーツが pipeline
にまとまっているので、 DialogueAgent.train()
で学習したあとに DialogueAgent.predict()
でも参照しなければならない変数がこの1つになり、取り回しが楽になっています。
まとめ
この節では、識別器の一例としてSVMをとりあげました。理論の詳細は一旦省きつつ、scikit-learnライブラリを利用することで、実際にプログラム中で動かしました。特徴ベクトルと対応するクラスIDを識別器に入力して学習し、学習の済んだ識別器に特徴ベクトルを入力しクラスIDを予測させました。
また、ここまで学んだことを組み合わせ、対話エージェントシステムを作成しました。
評価
機械学習を使ったシステムを作成したら、その性能を評価する必要があります。 定量的な指標で機械学習システムの性能を評価することで、あるシステムXと別のシステムYのどちらが優れているかを客観的に示せるようになります。 また、客観的な指標があることで、システムを改善したときに本当に効果があったのか、むしろ悪化していないかをチェックすることができます。
対話エージェントを評価してみる
前節では学習データ training_data.csv
を使って対話エージェントを学習させました。この対話エージェントにはわかち書き・特徴抽出器(BoW)・識別器(SVM)が含まれますが、これらのうち特徴抽出器と識別器は学習によって必要なパラメータを獲得します。
このシステムを評価するために、「学習データとは別の」入力文例とクラスIDの組のリストを用意します。これを テストデータ と呼びます。例えば以下の test_data.csv
のようなデータです。これらのクラスIDが、各入力文に対する「正解」のクラスIDということになります。
label,text
48,ほら、ケーキを買ってきたんだ
48,クッキー焼いてみたんだけど
25,喉がヒリヒリする
25,暑くて喉がカラカラだよ。
テストデータの入力文例をシステムに入力してクラスIDを予測させ、それをテストデータに定義された正解のクラスIDと比較することで、システムを評価します。
例えば、ここでは「テストデータのうち何件の予測が正解と一致したか」を指標としてみましょう(他の評価指標の紹介や詳しい解説は別途)。 これは 正解率 (Accuracy) と呼ばれる指標で、scikit-learnではこれを計算するための sklearn.metrics.accuracy_score
が提供されています。
以上の内容を踏まえて、前節で作成した DialogueAgent
を評価するコードを書いてみます。
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)
-
前節で作成した
DialogueAgent
をimportします。 -
dialogue_agent.py と同様に
DialogueAgent
を学習します。 -
同じディレクトリに置いた
test_data.csv
からテストデータを読み込みます。 -
予測を行います。
.predict()
は文字列のリストを引数に取るので、テストデータ全件をまとめて予測することができます。 -
予測結果と正解クラスIDを比較して評価します。ここでは
sklearn.metrics.accuracy_score
で正解率を計算します。
0.3829787234042553
この対話エージェントの性能を、正解率によって評価することができました。 約38%しか正解できていません。
現状の対話エージェントの実装について、前節では「性能がよくない」と書きましたが、それを定量的に確認することができました。 このあとは対話エージェントの性能を改善することを目指して様々な手法を紹介し、組み込んでいきますが、 「性能を改善する」とはすなわち、この評価結果の数字を上げることなのです。
Note
|
ここでは対話システム全体を評価するにあたって「文とクラスIDの組のリスト」をテストデータとしました。 例えば識別器のみを評価対象とするなら、特徴ベクトルとクラスIDの組のリストをテストデータとして、識別器の入出力のみを評価すればよいことになります。この場合、特徴抽出器は固定のものを使い、評価対象外ということになります。 評価したい対象に合わせて必要なテストデータを準備します。 |
parseToNode
が正しく動作しないバグがあるため、本記事ではバージョン0.7を使います。今後、問題の修正されたバージョンがリリースされるかもしれないので、チェックしてみてください。