1
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-02-20

概要

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

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

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())
['あらゆる 現実 を', 'た の だ', 'の 方 へ', 'へ 捻じ 曲げ', 'を 自分 の', '捻じ 曲げ た', '方 へ 捻じ', '曲げ た の', '現実 を 自分', '自分 の 方']

経緯

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

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が適用されなかった。公式ドキュメントにも「ngram_rangeはanalyzerがcallbale出ない場合のみ、適用される」と書いてある。analyzerとtokenizerの違いをきちんと理解できていなかった。

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

ちなみにソースコード上でも、analyzerがNoneの場合のみ、tokenizerとngramが実行されそうな雰囲気を感じられる。

補足

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

analyzerにngramの処理も含めた関数を渡す

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

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の外で分かち書き(スペース区切り)したドキュメントを渡してみる。英語がそう(スペース区切り)なので、うまくいくはず。

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の引数を指定する。

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の手前(スペース区切りの文字列にするところ)までの処理でとめる必要がある。

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になってしまい、失敗する。

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がうまく生成できなかったことをきっかけとして、tokenizeranalyzerpreprocessorの挙動について手を動かしながら勉強してみた。
そもそも公式ドキュメントに書いてあることを見落としていただけなので褒められたことではないが、理解を整理するきっかけになってよかった。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0