書籍「15Stepで踏破 自然言語処理アプリケーション開発入門」をやってみる - 2章Step04メモ「特徴抽出」

# 内容
[15stepで踏破 自然言語処理アプリケーション入門](https://www.amazon.co.jp/dp/4865941320) を読み進めていくにあたっての自分用のメモです。
今回は2章Step04で、自分なりのポイントをメモります。

# 準備
- 個人用MacPC：MacOS Mojave バージョン10.14.6
- docker version：Client, Server共にバージョン19.03.2

# 章の概要
これまでに作成した対話エージェントではBoWを用いて特徴抽出していたが、TF-IDF,BM25,N-gramといった様々な特徴抽出法について学び、文字列を適切な特徴ベクトルに変換することを目指す。

# 04.1 Bag of Words再訪
## Bag of Wordsの性質
BoWは単語の出現頻度をベクトル化したもので、「私」や「好き」といった単語が含まれる「私の嗜好を表す」文意の類似性をある程度捉えられている。

一方、語順情報を含んでいないことからそれぞれメリデメがある。
次節以降の04.2と04.3では文全体の単語頻度や文の長さを考慮した改善、04.4と04.5では単語の分割方法を変えることによる改善を紹介する。

- メリット
    - 日本語のような語順が自由な言語では、下記の2文は同じベクトルになる
        - *明日友達と遊園地に遊びに行く*
        - *友達と遊園地に明日遊びに行く*
- デメリット
    - 主語と目的語が入れ替わったような文が、同じベクトルになってしまう
        - *犬が人を噛んだ*
        - *人が犬を噛んだ*

Bag of Wordsの名前由来は、文を単語（Word）に分解して、袋（Bag）にバラバラに放り込み、順序を無視して個数だけ数えるというイメージらしい。

## 未知語
CountVectorizerの.fitでの辞書作成と.transformのベクトル化する際、ベクトル化する際に会えて無視したい単語がある場合等は、対象とする文集合を分けることができる。

# 04.2 TF-IDF
## BoWの問題点とTF-IDFによる解決

- BoWの問題点：文を特徴付ける単語と文を特徴付けない単語が同等に扱われてしまう
- TF-IDFによる解決：文を特徴付けない単語の寄与を小さく補正する
    - いろいろな文に広く登場する単語は一般的な単語であるため、個々の文の意味を表現する上で重要でない


## Scikit-learnによるTF-IDFの計算
CountVectorizer（BoW）の代わりにTfidfVectorizerを用いる。

## TF-IDFの計算方法
最終的には、単語頻度のTF（TermFrequency）と文書頻度の逆数の対数IDF（InverseDocumentFrequency）を乗じた値になる。
`TF-IDF(t,d) = TF(t,d)・IDF(t)`

- TF：文中に頻繁に登場すると値が大きくなる
- IDF：多くの文に登場すると値が小さくなる

# 04.3 BM25
TF-IDFにさらに文の長さを考慮するよう修正を加えたものである。

# 04.4 単語N-gram
今まではわかち書きした結果、1単語を1次元として扱っていたので**単語uni-gram**の手法と言える。
これに対し、2単語をまとめて1次元として扱う手法を**単語bi-gram**と呼ぶ。
また3単語をまとめる場合は**単語tri-gram**で、これらをまとめて**単語N-gram**と呼ぶ。

```bash
# わかち書き結果
東京 / から / 大阪 / に / 行く

# uni-gramは5次元
1.東京
2.から
3.大阪
4.に
5.行く

# bi-gramは4次元
1.東京から
2.から大阪
3.大阪に
4.に行く
```

## 考慮すべき事項
単語N-gramを使うと、BoWでは無視していた語順情報を、ある程度考慮に入れた特徴抽出ができるようになる。
その一方で、**Nを増やすほど次元は増えていくし、特徴がスパースになるので汎化性能はその文落ちてしまう**ことになるので、N-gramを使う際は上記のトレードオフを考慮する必要がある。

## Scikit-learnによるBoW、TF-IDFでの利用
CountVectorizerやTfidVectorizerのコンストラクタに `ngram_range=(最小値,最大値)` 引数を与える。
最小値と最大値を与えることによって、指定範囲内のN-gramを全て特徴ベクトルにすることができる。（例：uni-gramとbi-gramを両方使って辞書を生成することも可能）

# 04.5 文字N-gram
単語ではなく、文字に対してN字をひとまとまりの語彙としてBoWを構成する考え方である。

## 文字N-gramの特徴（考慮すべき点）
単語の表記ゆれに強かったり、そもそも形態素解析（わかち書き）をしないので複合語や未知語にも強い。
その一方で、**文字列としては似ていて意味が異なる単語・文を区別する能力が小さくなったり、日本語は文字の種類が多いため、次元数が大きくなる可能性がある**。

# 04.6 複数特徴量の結合
単語N-gramで複数のN-gramを結合して特徴ベクトルとして扱えたように、異なる特徴量を結合することができる。

```python
# 特徴ベクトルをそれぞれ算出した後に結合する場合
bow1 = bow1_vectorizer.fit_transform(texts)
bow2 = bow2_vectorizer.fit_transform(texts)

feature = spicy.sparse.hstack((bow1, bow2))

# scikit-learn.pipeline.FeatureUnionを用いる場合
combined = FeatureUnion(
  [
    ('bow', word_bow_vectorizer),
    ('char_bigram', char_bigram_vectorizer),
  ])

feature = combined.fit_transform(texts)
```

## 複数の特徴量を連結する際の考慮事項
- 次元が大きくなる
- 性質の違う特徴量を連結すると精度が下がる可能性がある
    - 値の範囲が大きく違う
    - 疎性が大きく違う

# 04.7 その他アドホックな特徴量
下記のような特徴量も加えることができる。

- 文の長さ（下記の例）
- 句点で区切った場合の文の数（下記の例）
- 特定の単語の出現回数

## Scikit-learnによるアドホックな特徴量の結合
途中経過を確認しておく。

```python:test_sklearn_adhoc_union.py
### メインソースは省略

import print

print('# num_sentences - \'こんにちは。こんばんは。\':')
print([sent for sent in rx_periods.split(texts[0]) if len(sent) > 0])

print('\n# [{} for .. in ..]')
print([{text} for text in texts])

textStats = TextStats()
print('\n# TextStats.fit():' + str(type(textStats.fit(texts))))
fitTransformTextStats = textStats.fit_transform(texts)
print('\n# TextStats.fit_transform():'+ str(type(fitTransformTextStats)))
pprint.pprint(fitTransformTextStats)

dictVectorizer = DictVectorizer()
print('\n# DictVectorizer.fit():' + str(type(dictVectorizer.fit(fitTransformTextStats))))
fitTransformDictVectorizer = dictVectorizer.fit_transform(textStats.transform(texts))
print('\n# DictVectorizer.fit_transform():' + str(type(fitTransformDictVectorizer)))
pprint.pprint(fitTransformDictVectorizer.toarray())

countVectorizer = CountVectorizer(analyzer = 'char', ngram_range = (2, 2))
print('\n# CountVectorizer.fit():' + str(type(countVectorizer.fit(texts))))
```

```bash:実行結果
$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python test_sklearn_adhoc_union.py
# num_sentences - 'こんにちは。こんばんは。':
['こんにちは', 'こんばんは']

# [{} for .. in ..]
[{'こんにちは。こんばんは。'}, {'焼肉が食べたい'}]

# TextStats.fit():<class '__main__.TextStats'>

# TextStats.fit_transform():<class 'list'>
[{'length': 12, 'num_sentences': 2}, {'length': 7, 'num_sentences': 1}]

# DictVectorizer.fit():<class 'sklearn.feature_extraction.dict_vectorizer.DictVectorizer'>

# DictVectorizer.fit_transform():<class 'scipy.sparse.csr.csr_matrix'>
array([[12.,  2.],
       [ 7.,  1.]])

# CountVectorizer.fit():<class 'sklearn.feature_extraction.text.CountVectorizer'>
```

# 04.8 ベクトル空間モデル
線形代数の2次元/3次元ベクトル空間をイメージする。

## 識別器の役割
3次元ベクトル空間でかつ二値分類（どちらのクラスに属すか）の場合は、判断する際の境界を識別面・決定境界と呼ぶ。

- 学習：教師データの内容を満たすようにベクトル空間中に境界を引く処理
- 予測：新たに入力された特徴量が境界のどちら側にあるかを判断する処理

# 04.9 対話エージェントへの適用
前章からの追加・変更点

1. 特徴量：BoW → TF-IDF
2. 単語N-gramを追加（uni-gram、bi-gram、tri-gram)

```python
~~

pipeline = Pipeline([
  # ('vectorizer', CountVectorizer(tokenizer = tokenizer),↓
  ('vectorizer', TfidVectorizer(
      tokenizer = tokenizer,
      ngram_range=(1,3))),
~~
```

```bash:実行結果
# evaluate_dialogue_agent.pyの読み込みモジュール名を修正
from dialogue_agent import DialogueAgent
↓
from dialogue_agent_with_preprocessing_and_tfidf import DialogueAgent

$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python evaluate_dialogue_agent.py
0.58510638
```
- 通常実装(Step01)：37.2%
- 前処理追加(Step02)：43.6%
- 前処理＋特徴抽出変更(Step04)：58.5%