書籍「15Stepで踏破 自然言語処理アプリケーション開発入門」をやってみる - 2章Step01メモ「対話エージェントを作ってみる」


内容

15stepで踏破 自然言語処理アプリケーション入門 を読み進めていくにあたっての自分用のメモです。
今回は2章Step01で、自分なりのポイントをメモります。

準備

  • 個人用MacPC:MacOS Mojave バージョン10.14.6
  • docker version:Client, Server共にバージョン19.03.2

章の概要

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

  • わかち書き
  • 特徴ベクトル化
  • 識別
  • 評価

docker環境でのスクリプト実行方法

# 実行したいスクリプトdialogue_agent.pyが存在するディレクトリで実行
#  ただし、実行に必要なcsvも同じディレクトリにあるとする

# docker run -it -v $(pwd):/usr/src/app/ <dockerイメージ>:<タグ> python <実行スクリプト>
$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python dialogue_agent.py

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

作るべきは「文を入力すると、その文が属するクラスを予測し、クラスIDを出力するシステム」である。テキスト分類という問題設定である。

# 対話エージェントシステムの実行イメージ
~~

dialogue_agent = DialogueAgent()
dialogue_agent.train(training_data)
predicted_class = dialogue_agent.predict('入力文')

~~

01.2 わかち書き

文を単語に分解することをわかち書きという。英語のように単語の間にスペースがある言語は不要である。
日本語におけるわかち書きを行うソフトウェアとして広く利用されているのがMeCab(めかぶ)である。
品詞情報の付与まで含んだわかち書きを形態素解析と呼ぶ。

表層形のみを取得したい場合はparseToNodeを用いる。(mecab-python3は0.996.2より古いバージョンで正しく動作しないバグがあるらしい)

import MeCab

tagger = MeCab.Tagger()
node = tagger.parseToNode('<入力文>')

# 最初と最後のnode.surfaceは空文字列となる
while node:
  print(node.surface)
  node = node.next

-Owakati引数を与えてMeCab.Tagger()を実行すると、コマンドラインで$ mecab -Owakatiと実行した時のようにスペース(' ')で分割したわかち書きの結果のみを出力できる。
ただし、半角スペースを含む単語が登場した時に、区切り文字と単語の一部の半角スペースが区別できず正しくわかち書きできないのでこの実装は避けた方が良い。(辞書によっては半角スペースを含んだ単語を扱うので)

01.3 特徴ベクトル化

1つの文章を(決まった長さの)1つのベクトルで表すことにより、コンピュータで計算可能な形式となる。

Bag of Words

  1. 単語にインデックスを割り当てる
  2. 文ごとに単語の登場回数を数える
  3. 文ごとに各単語の登場回数を並べる

下記の通り、文章を決まった長さ(ここでは長さ10)のベクトルで表す。

BagofWordsの例
# 私は私のことが好きなあなたが好きです
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の実装

コード詳細は省略。
内包表記の結果を確認しておく。

test_bag_of_words.py
from tokenizer import tokenize
import pprint

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

tokenized_texts = [tokenize(text) for text in texts]
pprint.pprint(tokenized_texts)

bow = [[0] * 14 for i in range(len(tokenized_texts))]
pprint.pprint(bow)
実行結果
$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python test_bag_of_words.py
[['私', 'は', '私', 'が', '好き', 'な', 'あなた', 'が', '好き', 'です'],
 ['私', 'は', 'ラーメン', 'が', '好き', 'です'],
 ['富士山', 'は', '日本一', '高い', '山', 'です']]
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]

Column collections.Counterの利用

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

途中経過を確認しておく。ベクトル化の結果は上記の「Bag of Wordsの実装」結果と同じである。

test_bag_of_words_counter_ver.py
from collections import Counter
from tokenizer import tokenize

import pprint

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

tokenized_texts = [tokenize(text) for text in texts]

print('# Counter(..)')
print(Counter(tokenized_texts[0]))

counts = [Counter(tokenized_text) for tokenized_text in tokenized_texts]
print('# [Counter(..) for .. in ..]')
pprint.pprint(counts)

sum_counts = sum(counts, Counter())
print('# sum(.., Counter())')
pprint.pprint(sum_counts)

vocabulary = sum_counts.keys()
print('# sum_counts.keys')
print(vocabulary)

print('# [[count[..] for .. in .. ] for .. in ..]')
pprint.pprint([[count[word] for word in vocabulary] for count in counts])
実行結果
$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python test_bag_of_words_counter_ver.py
# Counter(..)
Counter({'私': 2, 'が': 2, '好き': 2, 'は': 1, 'の': 1, 'こと': 1, 'な': 1, 'あなた': 1, 'です': 1})
# [Counter(..) for .. in ..]
[Counter({'私': 2,
          'が': 2,
          '好き': 2,
          'は': 1,
          'の': 1,
          'こと': 1,
          'な': 1,
          'あなた': 1,
          'です': 1}),
 Counter({'私': 1, 'は': 1, 'ラーメン': 1, 'が': 1, '好き': 1, 'です': 1}),
 Counter({'富士山': 1, 'は': 1, '日本一': 1, '高い': 1, '山': 1, 'です': 1})]
# sum(.., Counter())
Counter({'私': 3,
         'は': 3,
         'が': 3,
         '好き': 3,
         'です': 3,
         'の': 1,
         'こと': 1,
         'な': 1,
         'あなた': 1,
         'ラーメン': 1,
         '富士山': 1,
         '日本一': 1,
         '高い': 1,
         '山': 1})
# sum_counts.keys
dict_keys(['私', 'は', 'の', 'こと', 'が', '好き', 'な', 'あなた', 'です', 'ラーメン', '富士山', '日本一', '高い', '山'])
# [[count[..] for .. in .. ] for .. in ..]
[[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]]

scikit-learnによるBoWの計算

上述のように手動で実装することもできるが、scikit-learnではBoWを計算する機能を持ったクラスとしてsklearn.feature_extraction.text.CountVectorizerが提供されているので、実装はこれを使う。

vectorizer = CountVectorizer(tokenizer = tokenize) # tokenizerにcollable(関数、メソッド)を指定して、文の分割方法を指定する
vectorizer.fit(texts) # 辞書作成
bow = vectorizer.transform(texts) # BoWを計算

01.4 識別器

機械学習の文脈で、特徴ベクトルを入力し、そのクラスIDを出力することを識別と呼び、それを行うオブジェクトや手法を識別器と呼ぶ。

Scikit-learnの要素をpipelineで束ねる

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

pipeline例
from sklearn.pipeline import Pipeline

pipeline = Pipeline([
  ('vectorizer', CountVectorizer(tokenizer = tokenizer),
  ('classifier', SVC()),
])

# vectorizer.fit() +
# vectorizer.transform() +
# classifier.fit()
pipeline.fit(texts, labels)

# vectorizer.transform() +
# classifier.predict()
pipeline.predict(texts) #

01.5 評価

定量的な指標で機械学習システムの性能を評価する。

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

様々な指標があるが、ここではaccuracy(正解率)を見てみる。
下記の例の通り、accuracyはテストデータに対するモデルの識別結果とテストデータのラベルが合っている割合を計算する。

from dialogue_agent import DialogueAgent

dialogue_agent = DialogueAgent()
dialogue_agent.train(<train_text>, <train_label>)

predictions = dialogue_agent.predict(<test_text>)

print(accuracy_score(<test_label>, predictions))
実行結果
0.37234042...

正解率はまだ37%しかない。