SCDVを使ってハマった話

以下で紹介されているSCDVという手法を使って自然言語処理をしていたとき、ちょっと問題に遭遇したのでそれのメモです。
[文書ベクトルをお手軽に高い精度で作れるSCDVって実際どうなのか日本語コーパスで実験した(EMNLP2017)](https://qiita.com/fufufukakaka/items/a7316273908a7c400868)


## 問題
SCDV後の単語ベクトルのうち、以下のような単語がすべて0ベクトルになっていた。（単語は例です。）

- iPhone
- 雨
- キャッシュ・フロー

## 調査
SCDVはword2vecで作成した単語の分散表現に対し、その分散表現をGMMでクラスタリングしたときの各単語が各クラスに属する確率とIDF値を用いて、より高次元の分散表現に変換することで意味をより細かく取得できる手法なのですが、上記で挙げたような単語はword2vecで作成した分散表現の際は0ベクトルではありませんでした。

なので、word2vecからSCDVで高次元の分散表現を構築する際に0ベクトルになってしまったようなので、いろいろ調べていると、word2vecを条件付き確率とIDF値で高次元なベクトルに変換する以下の処理で例外が出た結果0ベクトルになっていることがわかりました。

```python
def get_probability_word_vectors(featurenames, word_centroid_map, num_clusters, word_idf_dict):
    # This function computes probability word-cluster vectors
    prob_wordvecs = {}
    for word in word_centroid_map:
        prob_wordvecs[word] = np.zeros( num_clusters * num_features, dtype="float32" )
        for index in range(0, num_clusters):
            try:
                prob_wordvecs[word][index*num_features:(index+1)*num_features] = model[word] * word_centroid_prob_map[word][index] * word_idf_dict[word]
            except:
                continue
```

word2vec時には正しくベクトル化されていたので、原因はIDF値の取得が失敗していることだとわかりました。


### scikit-learnのTfidfVectorizerの単語の正規化について
上記の問題が`TfidfVectorizer`を使ってIDF値を求める際の問題だとわかり、さらに調査をしたところ`TfidfVectorizer`内の単語の正規化やtokenizeに関する処理であることがわかりました。

これを調べるためにscikit-learnの`TfidfVectorizer`ソースコードをいろいろ読むことになったのでご参考までに載せておきます。
https://github.com/scikit-learn/scikit-learn/blob/f0ab589f/sklearn/feature_extraction/text.py

#### 英字の大文字は小文字に変換される
知らなかったことがそもそもマズイかもですが、`TfidfVectorizer`ではデフォルトで英字の大文字は小文字に変換するようです。

```python
s = ["iPhone Google Ｍｉｃｒｏｓｏｆｔ"]
tv = TfidfVectorizer(dtype=np.float32)
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['google', 'iphone', 'ｍｉｃｒｏｓｏｆｔ']
```
これによりIDF値を格納する`word_idf_dict[word]`内にはiPhoneという単語はなく例外となってしまいました。（iphoneはもちろんあるけど、word2vec側ではiPhoneの表記でベクトル化されているので、iphoneのIDF値は使われませんでした。）

大文字は大文字のまま処理してほしいときは`TfidfVectorizer`のインスタンス生成時に`lowercase=False`を指定すればよいです。

```python
s = ["iPhone Google Ｍｉｃｒｏｓｏｆｔ"]
tv = TfidfVectorizer(dtype=np.float32, lowercase=False)
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['Google', 'iPhone', 'Ｍｉｃｒｏｓｏｆｔ']
```



#### デフォルトでは１文字の単語は認識されない
以下で言及されています。
[TadaoYamaokaの日記](http://tadaoyamaoka.hatenablog.com/entry/2017/04/25/224748)

`TfidfVectorizer`はデフォルトで1文字の単語は認識されないように設定されています。英語なら問題無いかもしれませんが、日本語では1文字で意味のある単語はたくさんあるのでこの仕様は少々困ります。


```python
s = ["今日 天気 雨 です"]
tv = TfidfVectorizer(dtype=np.float32)
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['です', '今日', '天気']
```

TadaoYamaokaさんの日記で記載されているように、`TfidfVectorizer`のデフォルトのトークンパターンを変更すればよいです。

```python
s = ["今日 天気 雨 です"]
tv = TfidfVectorizer(dtype=np.float32, token_pattern="(?u)\\b\\w+\\b")
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['です', '今日', '天気', '雨']
```

#### \w の意味
最後に「キャッシュ・フロー」のように単語の途中で記号が入るケースですが、まずは`TfidfVectorizer`の動きを確認します。

```python
s = ["キャッシュ・フロー ほげ＋ふが ほげほげ-ふがふが"]
tv = TfidfVectorizer(dtype=np.float32, token_pattern="(?u)\\b\\w+\\b")
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['ふが', 'ふがふが', 'ほげ', 'ほげほげ', 'キャッシュ', 'フロー']
```

挙動を見てわかるように単語中に記号（半角、全角どちらも）が含まれると、単語がそこで分割されてしまいます。（`token_pattern`は指定しなくても同じです。）

この挙動を理解するためには上記の`token_pattern="(?u)\\b\\w+\\b"`の意味を理解する必要があります。

参考：[pythonの正規表現のリファレンス](https://docs.python.jp/3/library/re.html)

**■　(?u)**
> 後方互換性のため、re.U フラグ (およびそれと同義の re.UNICODE と埋め込みで使用する (?u)) はまだ存在していますが、文字列のマッチのデフォルトが Unicode になった Python 3 では冗長です (そして Unicode マッチングではバイト列は扱えません)。

ちょっと何言ってるかよくわかりませんが、`(?u)`を指定することにより、Unicode文字列の意味での正規表現を扱う、という意味になるようです。

**■　\b**
> 空文字列とマッチしますが、単語の先頭か末尾の時だけです。 単語とは単語文字の並びとして定義されます。 形式的に記述すると、 \b は \w 文字および \W 文字の間 (およびその逆)、あるいは \w と文字列の開始/終了との間の境界として定義されています。 例えば、r'\bfoo\b' は 'foo' , 'foo.' , '(foo)', 'bar foo baz' にマッチしますが、'foobar', 'foo3' にはマッチしません。

だそうです。単語の境界を指定している感じですかね。

**■　\w**
> ユニコード (str) パターンに対して:
任意の Unicode 単語文字にマッチします。これにはあらゆる言語で単語の一部になりうる文字、数字、およびアンダースコアが含まれます。ASCII フラグを使用すると [a-zA-Z0-9_] のみにマッチします。ただし、このフラグは正規表現全体に作用しますので、明示的に [a-zA-Z0-9_] と指定する方が良い場合があるかもしれません。
8bit (bytes) パターンに対して:
ASCII 文字セットでの英数字とアンダースコアにマッチします。これは [a-zA-Z0-9_] と等価です。 LOCALE が使われている場合は、現在のロケールで英数字と見なせる文字とアンダースコアにマッチします。

`(?u)`を指定しているので、ユニコード（str）パターンに対して、なのですが、これだけ見てもなんだかよくわかりません。「・」をUnicodeに変換すると、

```python
print("・".encode('unicode-escape'))
# b'\\u30fb'
```

なので、`[a-zA-Z0-9_]`にマッチして問題ないように思いますが、「単語の一部になりうる文字」の意味がいまいちしっくり来ませんでした。単語の定義もよくわらんし... 「・」は単語の一部ではないということのようですが...

で、正規表現のリファレンスの別ページに答えが書いてありました。
[正規表現 HOWTO](https://docs.python.jp/3/howto/regex.html)
> 一つ例をお見せしましょう: \w は任意の英数字文字にマッチします。バイト列パターンに対しては、これは文字クラス [a-zA-Z0-9_] と等価です。ユニコードパターンに対しては、 \w は unicodedata モジュールで提供されている Unicode データベースで letters としてマークされている全ての文字とマッチします。正規表現のコンパイル時に re.ASCII フラグを与えることにより、 \w を、より制限された定義で使うことが出来ます。

要はユニコードパターンの時は正規表現もくそもなく、`letters`のマークがついてる文字とマッチする、ということのようです。

データベースまでは見てないのですが、「・」や「-」を始め、全角、半角の記号にはUnicodeデータベース上では`letters`のラベルがついていないことになります。完全に決め打ちってことですかね。



## SCDVを使うときの注意点

私の使い方が良くなかっただけかもですが、SCDVを使って自然言語処理をする際は、word2vec側の処理と`TfidfVectorizer`での「単語」をちゃんと統一して扱うように気をつけたほうがよさそうです。

word2vecで単語の分散表現を作成する際は、散々データ分析を行ってユースケースに合う単語の意味を抽出するように前処理を行っているはずなので、word2vec側の処理を`TfidfVectorizer`側に合わせるのは望ましくないと思います。

SCDVで`TfidfVectorizer`を使うのは基本的にIDF値がほしいだけなので、SCDVで高精度な分散表現を作成する際は、IDF値は自分で関数を用意するなりして、計算するほうが良さげと感じました。


以下のように`analyzer`を指定するのがよさげ


## `TfidfVectorizer` の `analyzer` を指定する（2020/3/8 追記）
上でいろいろ書いてしまいましたが、記事を書いてた当時は知りませんでしたが(ホント無知の自分が恥ずかしい...)、`TfidfVectorizer`には`analyzer`という引数があり、ここに`TfidfVectorizer`でTF-IDF値を求める際のオリジナルの形態素解析を指定できます。
これを使えば上で書いたようなSCDVを使うときの、Word2Vecで分散表現を作成したときの形態素解析結果と`TfidfVectorizer`内の正規表現の差は生まれないと思います。
`analyzer`の活用方法は例えば以下の２つが考えられます。どちらも上で書いたような`TfidfVectorizer`内部の正規化処理がされずに、こちらが意図した形態素でTF-IDF値が計算されていることがわかるかと思います。

### 自身で作成した形態素解析関数を`analyzer`に指定する
以下のようにすればOK

```py
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab

tagger = MeCab.Tagger("-Ochasen")

# 形態素解析エンジンはMeCabを仕様
# MeCabで名詞だけを取得する形態素解析関数を定義
# stringを受け取って、形態素のlistを返す
def my_tokenizer(sentence):
    wakati = []
    chasen_result = tagger.parse(sentence)
    for line in chasen_result.split("\n"):
        elems = line.split("\t")
        if len(elems) < 4:
            continue
        word = elems[0]
        pos = elems[3]
        if "名詞" in pos:
            wakati.append(word)
    return wakati

# テスト
print(my_tokenizer("人工知能は人間から仕事を奪った。"))
# ['人工知能', '人間', '仕事']

s = ["人工知能は人間から仕事を奪った。", "今日の天気は晴れです。"]

# TfidfVectorizerのインスタンス生成時に以下のように上で定義した関数を指定
tv = TfidfVectorizer(analyzer=my_tokenizer)
tfidfmatrix = tv.fit_transform(s)
wordname = tv.get_feature_names()
print(wordname)
# ['人工知能', '人間', '今日の天気', '仕事', '晴れ']
```

### 外部ファイルに形態素解析結果を書き込んでおく
用途がどれだけあるかわかりませんが、形態素解析の結果を外部ファイルに書き込んでおき、`TfidfVectorizer`にわたすときは以下のようにスペース区切りで分かち書きをするように指定するのもありかと思います。
(なにかしらの理由で`TfidfVectorizer`に作成した形態素解析エンジンが指定できない場合。形態素解析をする処理だけ言語が違うとか？)

```py
# 形態素解析の結果が書き込まれた外部ファイルwakati_result.txtの中身が例えば以下のように半角スペースで形態素が区切られていた場合
with open('wakati_result.txt', 'r') as f:
    wakati_result = f.read()
print(wakati_result)
# 人工知能 人間 仕事 奪った 今日 天気 晴れ です 雨 キャッシュ・フロー iPhone Google Ｍｉｃｒｏｓｏｆｔ

# analyzerには半角スペースで区切るように指定するだけ
tv = TfidfVectorizer(analyzer=lambda x: x.split(" "))
tfidfmatrix = tv.fit_transform([wakati_result])
wordname = tv.get_feature_names()
print(wordname)
# ['Google', 'iPhone', 'です', 'キャッシュ・フロー', '人工知能', '人間', '今日', '仕事', '天気', '奪った', '晴れ', '雨', 'Ｍｉｃｒｏｓｏｆｔ']
```



おわり