scikit-learnで5行でできる類似テキスト検索
この記事はKaggle Advent Calendar 2022の16日目の記事です。
0.はじめに
KaggleではShopeeやH&Mコンペなどの(e-)commerceに関連した、類似商品検索やレコメンド系のコンペが度々開催されます。
これらのコンペには商品名や商品画像、ユーザーの購入履歴といったデータが含まれており、商品・ユーザーベースの特徴の抽出を行う必要があります。さらにその特徴を用いて候補となる商品を並び出して、機械学習を用いて最終的に出力する商品を予測します。
本記事では、このようなコンペの第一歩として、商品名などテキストに注目した特徴抽出をscikit-learnを主に使った手法を紹介します!
なお、私自身コンペを通して学んだことを書いているので、誤りやより良い方法もあるかと思います。何かあればコメントで共有していただけると助かります🙇
1.類似テキスト検索
類似テキスト検索は「テキストの特徴量抽出」と「特徴量に基づく近傍探索」の2つの処理からなります。
scikit-learnでは、テキストの特徴量抽出は、文章中に出現する単語や文字の頻度からベクトル化(一般にBag-of-Words(BoW)と呼ばれる手法)するCountVectorizerおよびTfidfVectorizerと、近傍探索はNearestNeighbors等が提供されています。
1.a.テキスト特徴抽出
CountVectorizerについて
始めにCountVectorizerの処理について見ていきましょう。
コード自体はとてもシンプルです。デフォルトのパラメータの場合は単語がスペースで区切られていればそれを1単語とみなして、単語を列方向に、その文章で何回出現したかを行方向で表します。
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
'私 は パン です 。',
'私 は パン を 食べる 。',
'僕 の ご飯 は ご飯 です 。'
]
vectorizer = CountVectorizer(ngram_range=(1, 1), analyzer='word')
X = vectorizer.fit_transform(corpus)
この際、返り値はsparse matrix(疎行列)で与えられます。疎行列は、要素のほとんどが0であるような行列で、少ないメモリで効率よく計算できる構造になっています。
実際にどのような値が入っているかを確認するには、X.toarray()
として密行列に変換すると良いです。また、vectorizer.get_feature_names_out()
とすると各次元にベクトル化された文字を確認することができます。
# pandasでどんなベクトル化をしているか確認してみる
import pandas as pd
X_arr = X.toarray()
df = pd.DataFrame(X_arr, columns=vectorizer.get_feature_names_out())
display(df)
ご飯 | です | パン | 食べる | |
---|---|---|---|---|
0 | 0 | 1 | 1 | 0 |
1 | 0 | 0 | 1 | 1 |
2 | 2 | 1 | 0 | 0 |
TfidfVectorizerについて
この単純に出現回数をカウントする方法を改良し、1つの文章中にどれだけ多い頻度でその単語が出現するか(tf)と、全ての文章の中でどれだけ少ない頻度で出現する珍しい単語か(idf)を表現する手法がtf-idfです。scikit-learnではTfidfVectorizerが存在します。
コード自体は先ほどのCountVectorizerの部分をTfidfVectorizerに書き換えるだけですが、経験上、検索タスクや文章分類においてもtf-idfの方が精度が良いことが多いです。
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(ngram_range=(1, 1), analyzer='word')
X = vectorizer.fit_transform(corpus)
X_arr = X.toarray()
df = pd.DataFrame(X_arr, columns=vectorizer.get_feature_names_out())
display(df)
ご飯 | です | パン | 食べる | |
---|---|---|---|---|
0 | 0 | 0.707107 | 0.707107 | 0 |
1 | 0 | 0 | 0.605349 | 0.795961 |
2 | 0.934702 | 0.355432 | 0 | 0 |
日本語等での実装上の問題点
scikit-learnでBoWを実装すること自体は非常にシンプルです。
しかし、この処理では単語をスペース区切りで判断しているため、日本語等の言語でのちょっとした問題点として、文章の分かち書きを行って単語レベルに分解する必要があります。
日本語ではmecab等の形態素解析エンジンが存在するため前処理に組み込めばいいですが、多言語データの場合だとそうはいきません。言語判定を行い日本語なら日本語の形態素解析ツールで処理して、他言語なら……と非常に面倒な作業になってしまいます。
そういった場合に簡単にBoWを行う方法として、単語レベルで見るのではなく、文字レベルで見る方法があります。
文字レベルでみる analyzer="char" or "char_wb"
文字レベルで解析するには引数をanalyzer="char"
と変更することで行えます。ただデフォルトの設定のままなら1文字しか読まないためngram_range=(1, 3)
として、1文字から3文字までの文字範囲でベクトル化することができます。
corpus = [
'私はパンです。',
'私はパンを食べる。',
'僕のご飯はご飯です。'
]
vectorizer = TfidfVectorizer(ngram_range=(1, 3), analyzer='char')
X = vectorizer.fit_transform(corpus)
vectorizer.get_feature_names_out()
X_arr = X.toarray()
df = pd.DataFrame(X_arr, columns=vectorizer.get_feature_names_out())
display(df)
。 | ご | ご飯 | ご飯で | ご飯は | す | す。 | で | です | です。 | の | のご | のご飯 | は | はご | はご飯 | はパ | はパン | べ | べる | べる。 | る | る。 | を | を食 | を食べ | パ | パン | パンで | パンを | ン | ンで | ンです | ンを | ンを食 | 僕 | 僕の | 僕のご | 私 | 私は | 私はパ | 食 | 食べ | 食べる | 飯 | 飯で | 飯です | 飯は | 飯はご | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0.176348 | 0 | 0 | 0 | 0 | 0.22708 | 0.22708 | 0.22708 | 0.22708 | 0.22708 | 0 | 0 | 0 | 0.176348 | 0 | 0 | 0.22708 | 0.22708 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.22708 | 0.22708 | 0.298583 | 0 | 0.22708 | 0.298583 | 0.298583 | 0 | 0 | 0 | 0 | 0 | 0.22708 | 0.22708 | 0.22708 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0.134353 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.134353 | 0 | 0 | 0.173004 | 0.173004 | 0.227479 | 0.227479 | 0.227479 | 0.227479 | 0.227479 | 0.227479 | 0.227479 | 0.227479 | 0.173004 | 0.173004 | 0 | 0.227479 | 0.173004 | 0 | 0 | 0.227479 | 0.227479 | 0 | 0 | 0 | 0.173004 | 0.173004 | 0.173004 | 0.227479 | 0.227479 | 0.227479 | 0 | 0 | 0 | 0 | 0 |
2 | 0.108576 | 0.367672 | 0.367672 | 0.183836 | 0.183836 | 0.139812 | 0.139812 | 0.139812 | 0.139812 | 0.139812 | 0.183836 | 0.183836 | 0.183836 | 0.108576 | 0.183836 | 0.183836 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.183836 | 0.183836 | 0.183836 | 0 | 0 | 0 | 0 | 0 | 0 | 0.367672 | 0.183836 | 0.183836 | 0.183836 | 0.183836 |
このように1文字レベルから3文字レベルでの頻度特徴を抽出することができます。
このngram_range
は単語レベルの計測でも変更することができます。またngram_range
を大きくすることで4文字や5文字レベルで頻度の特徴を作ることができますが、次元数が指数関数的に増えてしまうのでメモリに乗り切らない場合や計算時間が必要になります(私は(1, 3)
で行うことが多いです)。
またchar_wb
を指定すると、英語のようなスペースで区切られた単語に対して、単語ごとに区切った上で計測することができます。データに応じて使い分けるのが良さそうです。
1.b NearestNeighborsを用いた近傍探索
NearestNeighborsについて
最近傍探索のメソッドとしてsckit-learnではNearestNeighborsが提供されています。
これは距離空間において最も近い点を探すアルゴリズムで、非常に高速に近傍点を探索することができます。
先ほど紹介したBoWとあわせて実際に試してみましょう。
最初に紹介したBoWを使いテキスト特徴量を作成します。
corpus = [
'私はパン高校',
'株式会社ご飯食べる',
'僕はパンになる会社',
'ご飯ご飯大学',
'ご飯ご飯大学',
'私はパン大学',
'実はホットケーキ派高校',
'僕もホットケーキ好き会社',
'犬ミミ食パン',
'沖縄行きたい'
]
vectorizer = TfidfVectorizer(ngram_range=(1, 3), analyzer='char')
X = vectorizer.fit_transform(corpus)
次にNearestNeighborsを用いてそれぞれのテキストに近いテキストを探索しましょう。
実装自体非常に簡単でパラメータを指定し、,fit()
でテキスト特徴を入力して.kneighbors()
で近傍探索を行いたい変数を入力します。
from sklearn.neighbors import NearestNeighbors
nbrs = NearestNeighbors(n_neighbors=3, algorithm='brute', metric='euclidean')
nbrs.fit(X)
distances, indices = nbrs.kneighbors(X)
すると以下のような感じでdistancesとindicesは返されます。
distance_0 | distance_1 | distance_2 | index_0 | index_1 | index_2 | |
---|---|---|---|---|---|---|
0 | 0 | 0.994311 | 1.26063 | 0 | 5 | 2 |
1 | 0 | 1.26102 | 1.26102 | 1 | 3 | 4 |
2 | 0 | 1.2564 | 1.26063 | 2 | 5 | 0 |
3 | 0 | 0 | 1.26102 | 3 | 4 | 1 |
4 | 0 | 0 | 1.26102 | 3 | 4 | 1 |
5 | 0 | 0.994311 | 1.2564 | 5 | 0 | 2 |
6 | 0 | 1.07483 | 1.29224 | 6 | 7 | 0 |
7 | 0 | 1.07483 | 1.33762 | 7 | 6 | 2 |
8 | 0 | 1.33513 | 1.33719 | 8 | 5 | 0 |
9 | 0 | 1.38832 | 1.41421 | 9 | 7 | 3 |
distance
が全て0になるのは、学習時と近傍探索時に同じテキスト特徴量を用いているため自分自身のテキストを検索してしまっているからです。そのため、近傍点を使うには1-index以降の用いると良いでしょう。ただ、同じ特徴を持つ点(テキストの場合一言一句同じ場合)がある場合は自分自身が1-index以降に出現する可能性があるため注意が必要です。
NearestNeighborsは疎行列でも密行列でも入力が可能のため、TfidfVectorizerの出力結果をそのまま扱い高速に計算することが可能です。
このベクトライズ以外でも疎行列として扱えるデータの場合は変換したほうがメモリ的にも優しく高速に計算できます。(参考: Python, SciPy(scipy.sparse)で疎行列を生成・変換)
NearestNeighborsの引数で気をつけたいのが、近傍数を指定するn_neighbors
と、探索のアルゴリズムを指定するalgorithm
と、距離関数を指定するmetric
です。
algorithm
では、速度に問題がなければ総当たり的に計算し精度が良いbtrue
を用いることをオススメしますが、サンプル数が増えるとその限りではないかもしれません。
nadareさんから、algorithm
によって精度が変わることない、とのことです(手元で簡単に試してみたところ精度の変化はありませんでした)。そのため'brute'
ではなく'auto'
で問題なさそうです!
metric
では'euclidean'
の他に‘cosine’
や'manhattan'
などが使うことができます。データに合わせた距離を用いることで精度向上につながるかもしれません[1][2]。
scikit-learnだけで5行でできる類似テキスト検索
以上のことを踏まえると、(前提としてパッケージのimportとテキストの読み込みを行なっていれば)なんとscikit-learnだけで5行で類似テキスト検索できます!🙌
vectorizer = TfidfVectorizer(ngram_range=(1, 3), analyzer='char')
X = vectorizer.fit_transform(corpus)
nbrs = NearestNeighbors(n_neighbors=3, algorithm='brute', metric='euclidean')
nbrs.fit(X)
distances, indices = nbrs.kneighbors(X)
2.BoWの利点や改善点
2.a.Bag-of-Wordsを薦める理由
テキスト特徴量を抽出する方法はBoW以外にもあります。
特に深層学習モデルのBERTを用いて事前学習されたBERTのCLSトークンの埋め込みを使う方法や、距離学習といった手法でよりリッチにテキスト特徴を抽出する方法もあります。
それでも個人的にBoWで特徴抽出を行う利点として、
- 非常に高速に行える
- GPUがいらない
- 次元削減を行うことで勾配ブースティング木などの特徴量として使うことができる
- 多言語であっても処理を変える必要がない
といった点があると思っています。
CPU環境であっても数万程度のサンプル数なら十分に高速に動くことができ、BERTを使う場合にはGPU環境が必要になります。また、このテキスト特徴を次元削減することでモデルに変数として加えることができて、序盤に試す手法として良いと考えています(参考: テーブルデータ向けの自然言語特徴抽出術)。
データ数が十分にあれば多言語であっても同じ処理で使うことができるのも魅力的です。
ただ一方で、Kaggleで競い合う以上BoWで精度が十分ということは少なく、BERTなどを使った方法も考えなければなりません。
2.b.SentenceTransformerを用いた簡易的なembedding
SentenceTransformersはテキストや画像のembedding(ベクトル化)のフレームワークで、sentence-transformerが公開しているモデル以外でもhuggingfaceで公開されているモデルを使って文章を埋め込むことができます。
実装も非常に簡単で、studio-ousia/luke-japanese-base
というモデルを用いてテキスト特徴を抽出しましょう[3]。
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('studio-ousia/luke-japanese-base')
X = model.encode(corpus, batch_size=32, show_progress_bar=True, device=None)
こちらも非常に簡単にテキスト特徴を抽出することができます。
引数としてはbatch_size
の指定や、GPUを用いる場合はdevice
で指定する必要があります。
テキスト検索において、tfidfと事前学習済みBERTのどちらが優れているかは個人的には試してみないとわからないといった感じなので、こちらも実験する際に両方試してみるのが良さそうです。
2.c.cumlを用いた高速な類似テキスト検索
最後にcumlを用いたtfidfとNearestNeighborsを試しましょう。
cumlはRAPIDSが提供するGPUによって高速に処理が可能なパッケージで、scikit-learn likeなAPIで扱いやすいものとなっています。
Kaggleで開催される100万件あるような大規模なデータであっても高速に処理することができ、またKaggle notebookでacceralatorをGPUに選択することで簡単に使うことができます[4]。
コードは以下の通りです[5]。
import cudf
from cuml.feature_extraction.text import TfidfVectorizer
from cuml.neighbors import NearestNeighbors
vectorizer = TfidfVectorizer(ngram_range=(1, 3), analyzer='char')
X = vectorizer.fit_transform(cudf.Series(corpus))
nbrs = NearestNeighbors(n_neighbors=3, algorithm='brute', metric='euclidean')
nbrs.fit(X)
distances, indices = nbrs.kneighbors(X)
0.おわりに
本記事では、scikit-learnでテキスト特徴量抽出から近傍探索を行い、また簡単に事前学習済みBERTでテキスト特徴量を抽出する方法や、GPUで高速化するcumlについて紹介しました[6]。
ここで紹介した内容は個人的には汎用性が高いと思っており、例えば画像検索であれば事前学習済みのCNN/VisionTransformerを用いて特徴量抽出したり、テストデータや特定のデータに似たデータを抽出する際にも応用が効くかもしれません。
私自身、情報検索や推薦系はこれから勉強したさがあるので、他の手法やもっといい方法があれば教えてもらえると嬉しいです!
-
この距離自体も特徴量として扱えることがあり、単純に閾値を決めてそれに達していなかったら類似していないとみなしてもいいかもしれません ↩︎
-
Google Smartphone Decimeter Challengeでは地理空間データだったため
'manhattan'
が自チームで活躍しました ↩︎ -
この記事を書いている2022/12/15現在、
studio-ousia/luke-japanese-large
が日本語BERTで下流タスク解くモデルで最強ではと勝手に思っています ↩︎ -
google colabratoryだと依然としてcuml等が使えないのでcumlが必要な前処理部分をkaggle notebookでやってモデルの学習はcolabでやるみたいなことをしていました ↩︎
-
cudf.Seriesでないと通らなかった、なぜ? ↩︎
-
本当はFoursquareコンペのデータで速度を検証したかったけどデータ消えていた😢 ↩︎
Discussion