AIって結局何なのかよく分からないので、とりあえず100日間勉強してみた Day45
経緯についてはこちらをご参照ください。
■本日の進捗
●n-gramを理解
■はじめに
引き続き「Pythonではじめる機械学習(オライリー・ジャパン)」で学んでいきます。
今回はBoW自体の発展的アプローチ手法であるn-gram拡張を学んでいきます。
■n-gramに拡張したBoW
n-gramとは、連続したn個の単語のことで、単語の並びを考慮するために用いられます。
これまでBag-of-Wordsで特徴量抽出を行い、テキストデータに対する機械学習モデルを構築してきました。予測精度の向上のために特徴量選択や重み付けをしましたが、その度に重要な単語を無視してしまったり、その逆の事象も起こっていて大きく精度が上がることはありませんでした。
単純なBag-of-Wordsがこうした問題を含む大きな原因は、ドキュメントを1つひとつの単語で切り出してトークン化していることにあります。下記の簡単なドキュメントを例としてn-gramに分解してみます。
- This is a very good movie (Class 1)
- This is not a very good movie (Class 0)
- This is a very bad movie (Class 0)
1-gram(unigram)は通常のBoWのことで、1単語ずつトークン化していくので、全てのドキュメントに共通している “This, is, a, very, movie” はあまり意味を持たなそうです。つまり1つ目のドキュメントで学べることは “good” のみですし、Class 0にラベリングされている2つ目のドキュメントにも “good” が含まれてしまっています。
2-gram(bigram)は2単語ずつトークン化していくので、1つ目のドキュメントは、「this is」、「is a」、「a very」、「very good」、「good movie」と分解できます。今度は1つ目のドキュメントから “is a” と “very good” 、 “good movie” の3トークンが意味を持ちそうです。
3-gram(trigram)は3文字ずつトークン化していくので、同じ要領で、「this is a」、「is a very」、「a very good」、「very good movie」と分解していき、その重要度は実際に学習させてみないと分かりませんが、”a very good” と “very good movie” などのトークンは特に情報量が多そうです。
つまり単語の並びが重要な表現に対して、1-gram BoWでは必要以上に分解してしまい意味を失う可能性があり、n-gram BoWではこういった単語が並んで意味を持つ表現や文脈を極力保ちながらトークン化することができます。
また、下記のような場合には更に強力な効果を発揮します。
- It is raining today, so it is not a sunny day (Class 0)
- It is not raining today, so it is a sunny day (Class 1)
この例文は全く逆のことを言っているのに、1-gramで単語に分解するとトークンも出現回数も全く同じになります。こういうドキュメントに対しては、”not” がどの単語と一緒に並んでいるのかが非常に重要な意味を持ちます。
scikit-learnでは(Count/Tfidf)Vectorizerの引数にngram_rangeを設定することでBoWをn-gramにすることができます。
import numpy as np from sklearn.datasets import load_files from sklearn.feature_extraction.text import CountVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV reviews_train = load_files("C:/Users/****/Documents/Python/aclImdb/train") text_train, y_train = reviews_train.data, reviews_train.target reviews_test = load_files("C:/Users/****/Documents/Python/aclImdb/test") text_test, y_test = reviews_test.data, reviews_test.target vectorizer = CountVectorizer(ngram_range=(2, 2)) X_train = vectorizer.fit_transform(text_train) model = LogisticRegression(max_iter=10000) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10]} grid = GridSearchCV(model, param_grid, cv=5, n_jobs=-1) grid.fit(X_train, y_train) print("best C:", grid.best_params_) print("cv best score: {:.2f}".format(grid.best_score_))
best C: {'C': 10}
cv best score: 0.88
2-gramの際はスコアは1-gramと同等です。
import numpy as np from sklearn.datasets import load_files from sklearn.feature_extraction.text import CountVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV reviews_train = load_files("C:/Users/****/Documents/Python/aclImdb/train") text_train, y_train = reviews_train.data, reviews_train.target reviews_test = load_files("C:/Users/****/Documents/Python/aclImdb/test") text_test, y_test = reviews_test.data, reviews_test.target vectorizer = CountVectorizer(ngram_range=(3, 3)) X_train = vectorizer.fit_transform(text_train) model = LogisticRegression(max_iter=10000) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10]} grid = GridSearchCV(model, param_grid, cv=5, n_jobs=-1) grid.fit(X_train, y_train) print("best C:", grid.best_params_) print("cv best score: {:.2f}".format(grid.best_score_))
best C: {'C': 10}
cv best score: 0.83
3-gramではスコアが悪化してしまっています。n-gramの数を上げていくと次元が急激に増加し、過剰適合が起こる可能性もあります。また、3以上になってくると特定の組み合わせのトークンしか捉えられなくなり、逆に文脈が欠如する可能性もあります。
折角文脈を捉えられる可能性があったのに、結局1単語で意味を持つトークン(例えばdisappointmentやenjoyableなど)の邪魔をしてしまうのであればどうすればいいのでしょうか。
実は、n-gramは(例えば1-gramと2-gramなどで)組み合わせることが可能です。1-gramの良さはそのままに、単語の並びを重視することができます。
import numpy as np from sklearn.datasets import load_files from sklearn.feature_extraction.text import CountVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV reviews_train = load_files("C:/Users/****/Documents/Python/aclImdb/train") text_train, y_train = reviews_train.data, reviews_train.target reviews_test = load_files("C:/Users/****/Documents/Python/aclImdb/test") text_test, y_test = reviews_test.data, reviews_test.target vectorizer = CountVectorizer(ngram_range=(1, 2)) X_train = vectorizer.fit_transform(text_train) model = LogisticRegression(max_iter=10000) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10]} grid = GridSearchCV(model, param_grid, cv=5, n_jobs=-1) grid.fit(X_train, y_train) print("best C:", grid.best_params_) print("cv best score: {:.2f}".format(grid.best_score_))
best C: {'C': 1}
cv best score: 0.90
1-gramと2-gramで実施してみました。スコアは大台を超えてくれて大きく精度向上しています。ただし、組み合わせを増やすとその分だけ特徴量が増えて計算コストも増大することは注意しないといけません。
coef_の値も見てみたいと思います。学習結果は変わったのでしょうか。
import numpy as np from sklearn.datasets import load_files from sklearn.feature_extraction.text import CountVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV reviews_train = load_files("C:/Users/****/Documents/Python/aclImdb/train") text_train, y_train = reviews_train.data, reviews_train.target reviews_test = load_files("C:/Users/****/Documents/Python/aclImdb/test") text_test, y_test = reviews_test.data, reviews_test.target vectorizer = CountVectorizer(ngram_range=(1, 2)) X_train = vectorizer.fit_transform(text_train) X_test = vectorizer.transform(text_test) model = LogisticRegression(max_iter=10000) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10]} grid = GridSearchCV(model, param_grid, cv=5, n_jobs=-1) grid.fit(X_train, y_train) best_model = grid.best_estimator_ feature_names = vectorizer.get_feature_names_out() coefficients = best_model.coef_[0] n = 20 class_0_features = np.argsort(coefficients)[:n] print(f"Class 0 (Negative) top {n} features:") for idx in class_0_features: print(f"{feature_names[idx]}: {coefficients[idx]:.3f}") print() class_1_features = np.argsort(coefficients)[-n:][::-1] print(f"Class 1 (Positive) top {n} features:") for idx in class_1_features: print(f"{feature_names[idx]}: {coefficients[idx]:.3f}") print()
Class 0 (Negative) top 20 features:
worst: -1.607
awful: -1.510
boring: -1.424
waste: -1.332
disappointment: -1.168
poor: -1.140
poorly: -1.109
terrible: -1.100
the worst: -1.054
disappointing: -1.039
worse: -1.030
horrible: -1.026
dull: -0.986
lacks: -0.956
lame: -0.951
mess: -0.944
not worth: -0.895
annoying: -0.864
avoid: -0.848
save: -0.847
Class 1 (Positive) top 20 features:
excellent: 1.250
perfect: 1.127
wonderful: 1.042
superb: 0.947
amazing: 0.891
enjoyable: 0.876
brilliant: 0.813
today: 0.799
great: 0.793
incredible: 0.746
must see: 0.717
fun: 0.709
well worth: 0.700
wonderfully: 0.700
better than: 0.694
rare: 0.692
favorite: 0.680
enjoyed: 0.676
refreshing: 0.666
highly: 0.656
分かりづらかったので各クラス上位20特徴量ずつ表示しています。
2-gramにして増えた特徴量はそれほど多くはないですが、”not worth” や “must see” , “well worth” 辺りが増えたのは効果的だったのではないでしょうか。(現に “worth” を抽出するのと、”not worth” と “well worth” を分けて抽出するのでは意味が全く異なることになりますが、今回のモデルはこれを学習できています。)
■おわりに
単純なBoWを用いた際に重要度が高かった特徴量はそれ1つで意味を持つようなものがほとんどでした(これはある意味当たり前で、機械学習モデルからすれば訓練データが1単語ずつしかないのだからそこから重要度を学習するしかない)。2-gramだけにすると効果はほとんどなかったが、1-gramと組み合わせることで当初の情報量はそのままに、2-gramの文脈を重視するメリットを組み込むことができました。
計算コストは確かに有意に増えますが、引数を追加するだけで精度を上げられるので簡単ですし、コーパスから上手く意味を取れる組合せを見つけられれば効果的に使えるでしょう。
■参考文献
- Andreas C. Muller, Sarah Guido. Pythonではじめる機械学習. 中田 秀基 訳. オライリー・ジャパン. 2017. 392p.
- ChatGPT. 4o mini. OpenAI. 2024. https://chatgpt.com/
- API Reference. scikit-learn.org. https://scikit-learn.org/stable/api/index.html
- Maas, Andrew L. and Daly, Raymond E. and Pham, Peter T. and Huang, Dan and Ng, Andrew Y. and Potts, Christopher, Learning Word Vectors for Sentiment Analysis, Proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies, June, 2011, Portland, Oregon, USA, Association for Computational Linguistics, 142–150, http://www.aclweb.org/anthology/P11-1015
- Potts, Christopher. 2011. On the negativity of negation. In Nan Li and David Lutz, eds., Proceedings of Semantics and Linguistic Theory 20, 636-659.