sklearn.feature_extraction.textのvectorizerで日本語の単語ngramを生成する

# 概要
`sklearn.feature_extraction.text`のvectorizerで日本語の単語ngramを生成しようとして苦戦したのでメモ。

結論としては、vectorizerの引数で、`tokenizer=lambda x: mecab.parse(x).strip().split()`（mecabはMeCabの分かち書きTaggerオブジェクト）としたうえで、`ngram_range=(3,3)`などと指定すればよい。分かち書き関数を`analyzer`ではなく`tokenizer`に指定するのがポイント。

```Python
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab
mecab = MeCab.Tagger("-Owakati")

vectorizer = TfidfVectorizer(
  tokenizer=lambda x: mecab.parse(x).strip().split(),
  ngram_range=(3,3)
)

# 確認
documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]
vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

```Python
['あらゆる 現実 を', 'た の だ', 'の 方 へ', 'へ 捻じ 曲げ', 'を 自分 の', '捻じ 曲げ た', '方 へ 捻じ', '曲げ た の', '現実 を 自分', '自分 の 方']
```

# 経緯
`sklearn.feature_extraction.text`のvectorizerで日本語の単語ngramを生成しようとしたが、`analyzer`で分かち書きの関数を指定すると、unigramにしかならなかった。

```Python
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab
mecab = MeCab.Tagger("-Owakati")

vectorizer = TfidfVectorizer(
  analyzer=lambda x: mecab.parse(x).strip().split(),
  ngram_range=(3,3)
)

# 経緯
documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]
vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

# 原因と解決方法

`analyzer`にcallableな値を指定していたため`ngram_range`が適用されなかった。[公式ドキュメント](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html)にも「ngram_rangeはanalyzerがcallbale出ない場合のみ、適用される」と書いてある。analyzerとtokenizerの違いをきちんと理解できていなかった。

```
ngram_rangetuple (min_n, max_n), default=(1, 1)
...(略)... Only applies if analyzer is not callable.
```

ちなみに[ソースコード](https://github.com/scikit-learn/scikit-learn/blob/95119c13af77c76e150b753485c662b7c52a41a2/sklearn/feature_extraction/text.py#L75-L112)上でも、analyzerがNoneの場合のみ、tokenizerとngramが実行されそうな雰囲気を感じられる。

# 補足

要は`analyzer`にcallableな値を渡さなければよいので、概要で書いた以外にもいろいろ実現方法がありそう。せっかくなので試してみる。

## analyzerにngramの処理も含めた関数を渡す
まず、analyzerにngramのtokenを生成する記述も含めて渡す方法。
結果は問題なさそうだが、普通に面倒なので、あえてをこれをやる理由はない。

```Python
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab
mecab = MeCab.Tagger("-Owakati")

documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]

# ngramを作る関数
def ngram(l, n):
  return list(zip(*[l[i:] for i in range(n)]))

# テキストを単語に分かち書きして指定された数字のngramを返す関数を返す（生成する）関数
def analyze(min_n=1, max_n=1):

  def a(text):
    tokens = mecab.parse(text).strip().split() #単語のリストにする
    res = []
    for n in range(min_n, max_n+1):
      res += [" ".join(v) for v in ngram(tokens, n)] #vectorizerのfeature_name似合わせるため、スペースでjoinする
    return res
  
  return a

vectorizer = TfidfVectorizer(
  analyzer = analyze(3,3)
)

vectorizer.fit(documents)
print(vectorizer.get_feature_names())

```

```
['あらゆる 現実 を', 'た の だ', 'の 方 へ', 'へ 捻じ 曲げ', 'を 自分 の', '捻じ 曲げ た', '方 へ 捻じ', '曲げ た の', '現実 を 自分', '自分 の 方']
```

## 事前にスペース区切りしたdocumentを渡す
vectorizerの外で分かち書き（スペース区切り）したドキュメントを渡してみる。英語がそう（スペース区切り）なので、うまくいくはず。

```Python
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab
mecab = MeCab.Tagger("-Owakati")

documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]

# 事前に単語のスペース区切りに変換しておく
documents = [ mecab.parse(v).strip() for v in documents]

vectorizer = TfidfVectorizer(
  ngram_range=(3,3)
)

vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

```
['あらゆる 現実 自分', '現実 自分 捻じ', '自分 捻じ 曲げ']
```

悪くない結果だが、`vectorizer`のデフォルト設定により、1文字の単語が除外されている。
1文字の単語も含める場合、`token_pattern`の引数を指定する。

```Python
vectorizer = TfidfVectorizer(
  ngram_range=(3,3),
  # (?u)はunicodeの指定（たぶん）、\bは単語の境界。\w+は1文字以上にマッチ。デフォルトは\w\w+となっており、2文字以上のみマッチする
  token_pattern = r"(?u)\b\w+\b" 
)

vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

```
['あらゆる 現実 を', 'た の だ', 'の 方 へ', 'へ 捻じ 曲げ', 'を 自分 の', '捻じ 曲げ た', '方 へ 捻じ', '曲げ た の', '現実 を 自分', '自分 の 方']
```

# preprocessorに分かち書き関数を指定する
`preprocessor`引数に分かち書き関数を渡しても良い。`tokenizer`と違って、splitの手前（スペース区切りの文字列にするところ）までの処理でとめる必要がある。

```Python
from sklearn.feature_extraction.text import TfidfVectorizer
import MeCab
mecab = MeCab.Tagger("-Owakati")

vectorizer = TfidfVectorizer(
  # tokenizerでsplitをやめる代わりに、analyzerを明示的に指定する
  preprocessor=lambda x: mecab.parse(x).strip(),
  analyzer="word", # 省略可
  token_pattern = r"(?u)\b\w+\b", #1文字の単語も含めたい場合、必要
  ngram_range=(3,3)
)

# 確認
documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]
vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

```
['あらゆる 現実 を', 'た の だ', 'の 方 へ', 'へ 捻じ 曲げ', 'を 自分 の', '捻じ 曲げ た', '方 へ 捻じ', '曲げ た の', '現実 を 自分', '自分 の 方']
```


なお`preprocessor`ではなく`tokenizer`に同じ関数を渡すと、charのngramになってしまい、失敗する。

```Python
vectorizer = TfidfVectorizer(
  tokenizer=lambda x: mecab.parse(x).strip(),
  ngram_range=(3,3)
)

# 確認
documents = [
  "あらゆる現実を自分の方へ捻じ曲げたのだ"
]
vectorizer.fit(documents)
print(vectorizer.get_feature_names())
```

```
['  た  ', '  の  ', '  へ  ', '  を  ', '  捻 じ', '  方  ', '  曲 げ', '  現 実', '  自 分', 'あ ら ゆ', 'げ   た', 'じ   曲', 'た   の', 'の   だ', 'の   方', 'へ   捻', 'ゆ る  ', 'ら ゆ る', 'る   現', 'を   自', '分   の', '実   を', '捻 じ  ', '方   へ', '曲 げ  ', '現 実  ', '自 分  ']
```

analyzerのデフォルト値はwordのため、（analyzerを指定しない場合は）渡した文字列がスペースでsplitされることが期待される。実際、`preprocessor`に関数を渡した場合はそのような挙動になっていそうである。一方で、`tokenizer`に同様の関数を渡した場合は、`analyzer="word"`の処理は無視（あるいは上書き？）されてしまい、スペースによるsplitは実施されないようである。

# おわりに
scikit-learnのvectorizerで日本語の単語ngramがうまく生成できなかったことをきっかけとして、`tokenizer`、`analyzer`、`preprocessor`の挙動について手を動かしながら勉強してみた。
そもそも公式ドキュメントに書いてあることを見落としていただけなので褒められたことではないが、理解を整理するきっかけになってよかった。