自然言語処理の前処理に、nグラム(n-gram)という概念があります。
これは隣り合って出現したn単語のことです。nの値が小さいときは特別な名前がついていて、
n=1の場合をユニグラム(unigram)、n=2の場合をバイグラム(bigram)、n=3の場合をトライグラム(trigram)と呼びます。
ネットで検索すると、「n単語」のことではなく、隣り合って出現した「n文字」をnグラムと言うという説明も見かけた(例えば、Wikipediaもそうです)ので、誤解を避けるためこの記事のタイトルは単語nグラムとしましたが、面倒なので以下記事中でnグラムと書いたら単語nグラムを指すものとします。
「言語処理のための機械学習入門」などの書籍でも、単語nグラムのことをnグラムと呼んでいるのでおかしくはないと思っています。(P. 62)
自然言語処理を勉強し始めた駆け出しの頃、テキストをBoWでベクトル化する時に、ユニグラムだけでなく、バイグラムを加えて単語間のつながりを考慮するというアイデアを知って、これはいいアイデアだと思って試したりしました。しかし、バイグラムを使って機械学習の精度が上がった経験というのはほとんどなく、その後もたまに試すけど有効だった覚えがほぼ無くだんだん試さなくなってきていました。
しかし、最近機械学習とは少し違う目的でnグラムを使いたいことがあったので、この機会にsciki-learnでBoWを作る時のnグラム関連の引数の挙動をまとめておこうと思ったのでこの記事に整理していきます。
今回は、サンプルに使うテキストデータはあらかじめ分かち書きしたやつを用意しておきます。題材はいつもメロスなので今回は幸福の王子にしました。(青空文庫から拝借)
corpus = [
"町 の 上 に 高い 柱 が そびえる 、 その 上 に 幸福 の 王子 の 像 が 立つ て いる ます た 。",
"王子 の 像 は 全体 を 薄い 純金 で 覆う れる 、 目 は 二つ の 輝く サファイア で 、 王子 の 剣 の つ か に は 大きな 赤い ルビー が 光る て いる ます た 。",
"王子 は 皆 の 自慢 です た 。",
"「 風見鶏 と 同じ くらい に 美しい 」 と 、 芸術 的 だ センス が ある という 評判 を 得る たい がる て いる 一 人 の 市会 議員 が 言う ます た 。",
"「 もっとも 風見鶏 ほど 便利 じゃ ない が ね 」 と 付け加える て 言う ます た 。",
"これ は 夢想 家 だ と 思う れる ない よう に 、 と 心配 する た から です 。",
"実際 に は 彼 は 夢想 家 なんか じゃ ない た の です が 。",
]
さて、早速やっていきましょう。
scikit-learnのテキストの前処理には、BoWを作るCountVectorizer と、 tf-idfを作るTfidfVectorizer がありますが、nグラムに関しては両方とも同じくngram_range という引数で設定することができます。(最小)何グラムから(最大)何グラムまでを学習に含めるかをタプルで指定するもので、デフォルトは、 ngram_range=(1, 1) です。(ユニグラムのみ)。
バイグラムを学習させたければ(2, 2)と指定すればよく、ユニグラムからトライグラムまで学習したいなら(1, 3)です。
とりあえず、(1, 2)でやってみます。1文字の単語も学習させるため、token_patternも指定します。
from sklearn.feature_extraction.text import CountVectorizer
# モデル作成
bow_model = CountVectorizer(
token_pattern="(?u)\\b\\w+\\b",
ngram_range=(1, 2),
)
# 学習
bow_model.fit(corpus)
# 学習した単語の先頭10個
print(bow_model.get_feature_names()[:10])
# ['ある', 'ある という', 'いる', 'いる ます', 'いる 一', 'か', 'か に', 'から', 'から です', 'が']
get_feature_names() で学習した単語を取得してみましたが、「ある」「いる」「か」などの1単語の語彙に混ざって、「ある という」「いる ます」などのバイグラムの語彙も混ざっていますね。
ユニグラムとバイグラムで合計、194の語彙を学習しているので、このモデルを使ってBoWを作ると、テキスト数(7) * 語彙数(194)の疎行列になります。
print(len(bow_model.get_feature_names()))
# 194
print(bow_model.transform(corpus).shape)
# (7, 194)
さて、もう少しライブラリの挙動を詳しくみていきましょう。次はstopwordとの関連です。
CountVectorizer は stop_wordsという引数で学習に含めない単語を明示的に指定できます。
試しにてにをは的な文字をいくつか入れていみます。(ここでは学習対象はバイグラムだけにします)
# モデル作成
bow_model = CountVectorizer(
token_pattern="(?u)\\b\\w+\\b",
ngram_range=(2, 2),
stop_words=["て", "に", "を", "は", "が", "の", "た",]
)
# 学習
bow_model.fit(corpus)
# 学習した単語の一部
print(bow_model.get_feature_names()[50: 60])
# ['夢想 家', '大きな 赤い', '実際 彼', '家 だ', '家 なんか', '市会 議員', '幸福 王子', '彼 夢想', '得る たい', '心配 する']
学習した語彙の一部を表示していますが、わかりやすいのは「幸福 王子」というペアが含まれていることです。これはもちろん「幸福 の 王子」の部分から学習されたものです。
「の」が stopwordに含まれているので、まず、「の」が取り除かれて、「幸福 王子」になってからバイグラムの学習が行われたのだとわかります。
「幸福 の」と「の 王子」を学習してからstopwordを除くわけでは無いということが確認できました。
ちなみに、token_patternがデフォルトの場合、1文字の単語は学習されませんが、この時も似たような挙動になります。
# モデル作成
bow_model = CountVectorizer(
ngram_range=(2, 2),
)
# 学習
bow_model.fit(corpus)
# 学習した単語の一部
print(bow_model.get_feature_names()[30: 40])
# ['同じ くらい', '夢想 なんか', '夢想 思う', '大きな 赤い', '実際 夢想', '市会 議員', '幸福 王子', '得る たい', '心配 する', '思う れる']
続いて、学習結果に含まれる単語を出現頻度で間引くmid_df/max_df の挙動を確認しておきます。min_dfの方がわかりやすいのでそちらを例に使います。
min_df=3(3テキスト以上に含まれる単語だけ学習する。3回以上では無いので注意)と指定してみます。
# モデル作成
bow_model = CountVectorizer(
token_pattern="(?u)\\b\\w+\\b",
ngram_range=(1, 2),
min_df=3,
)
# 学習
bow_model.fit(corpus)
# 学習した単語
print(bow_model.get_feature_names())
# ['いる', 'が', 'た', 'て', 'て いる', 'です', 'と', 'ない', 'に', 'の', 'は', 'ます', 'ます た', '王子']
「て いる」は3テキストに含まれているので学習されていますね。その一方で「王子 は」は1テキストにしか含まれていないので学習結果に含まれていません。「王子」と「は」はそれぞれ3テキスト以上に含まれていますが、「王子 は」という並びで登場したのが1回だけだったので対象外になっているのです。
このことから、min_dfによる間引きはまずn-gramを学習してその後に行われていることがわかりますね。stop_wordsと実行タイミングが違うので注意しましょう。
例示はしませんがmax_dfも話は同様です。ちなみに、 max_df=0.7 とすると、 ユニグラムの「た」は学習結果らから除外されて、バイグラムの「ます た」などは含まれることが確認できます。