Home About Contact
PyTorch , BERT , Word Embeddings , Hugging Face

テキスト、文章の分散表現(Embeddings)を取得する

SentenceTransformers を使うことで、文章やテキストの分散表現を計算できる。

You can use this framework to compute sentence / text embeddings...

今回はこれをやってみたので、その備忘録です。 とりあえず、なんとなくできたというレベルなので、その点ご了承ください。 使用したのはこちらの日本語BERTモデル bert-large-japanese-v2 です。

bert-japanese

環境の確認

Chromebook Linux の Python3 環境+ venv を使用しました。

$ python3 --version
Python 3.9.2

仮想環境の準備とモデルのロードまで

それでは、はじめます。

はじめに sbert 環境を作成して、有効にします。

$ python3 -m venv ~/.local/sbert
$ source ~/.local/sbert/bin/activate
(sbert) $

とりあえず必須の sentence-transformers を入れます。

$ pip install sentence-transformers

PyTorch など依存するライブラリもインストールされます。 何がインストールされたか確認します。

$ pip list
Package                  Version
------------------------ ----------
certifi                  2023.5.7
charset-normalizer       3.1.0
click                    8.1.3
cmake                    3.26.3
filelock                 3.12.0
fsspec                   2023.5.0
huggingface-hub          0.14.1
idna                     3.4
Jinja2                   3.1.2
joblib                   1.2.0
lit                      16.0.5
MarkupSafe               2.1.2
mpmath                   1.3.0
networkx                 3.1
nltk                     3.8.1
numpy                    1.24.3
nvidia-cublas-cu11       11.10.3.66
nvidia-cuda-cupti-cu11   11.7.101
nvidia-cuda-nvrtc-cu11   11.7.99
nvidia-cuda-runtime-cu11 11.7.99
nvidia-cudnn-cu11        8.5.0.96
nvidia-cufft-cu11        10.9.0.58
nvidia-curand-cu11       10.2.10.91
nvidia-cusolver-cu11     11.4.0.1
nvidia-cusparse-cu11     11.7.4.91
nvidia-nccl-cu11         2.14.3
nvidia-nvtx-cu11         11.7.91
packaging                23.1
Pillow                   9.5.0
pip                      20.3.4
pkg-resources            0.0.0
PyYAML                   6.0
regex                    2023.5.5
requests                 2.31.0
scikit-learn             1.2.2
scipy                    1.10.1
sentence-transformers    2.2.2
sentencepiece            0.1.99
setuptools               44.1.1
sympy                    1.12
threadpoolctl            3.1.0
tokenizers               0.13.3
torch                    2.0.1
torchvision              0.15.2
tqdm                     4.65.0
transformers             4.29.2
triton                   2.0.0
typing-extensions        4.6.1
urllib3                  2.0.2
wheel                    0.40.0

これで、これから書くコードに必要なものがすべて揃っているか不明ですが、とにかく先に進みましょう。

$ touch main.py

main.py にコードを書いていきます。

from sentence_transformers import models

MY_MODEL = "cl-tohoku/bert-large-japanese-v2"

transformer = models.Transformer(MY_MODEL)
print( transformer )

まずは BERTの日本語モデルをロードできるか試すところまで。 ネットで検索すると、だいたい こちら: bert-base-japanese-whole-word-masking が使用されていました。

ここでは、モデルの一覧 から、最近つくられたモデル bert-large-japanese-v2 をつかってみました。

v2 / v3 , char ありなし large 付きなど、どれ使えばいいの? そもそも、bert-base-japanese-whole-word-masking でないと正常に動かないのか? わかりません。

実行。

$ python3 main.py
...
ModuleNotFoundError: You need to install fugashi to use MecabTokenizer.

fugashi が必要と怒られました。 さらに、unidic_lite も必要です。

$ pip install fugashi
$ pip install unidic-lite 

これでようやく python3 main.py が実行できるようになりました。

分散表現を得る

モデルが読み込めるようになったので、テキストから分散表現を得ることにします。

sentenceTransformer をインポート:

from sentence_transformers import SentenceTransformer

transformer と pooling から st (SentenceTransformのインスタンス) を生成。

pooling = models.Pooling(
    transformer.get_word_embedding_dimension(),
    pooling_mode_mean_tokens=True,
    pooling_mode_cls_token=False,
    pooling_mode_max_tokens=False)

st = SentenceTransformer(modules=[transformer, pooling])

この models.Pooling() のオプション指定で、どうやって文章/テキストの分散表現を計算するか指定できるらしい。 pooling_mode_mean_tokens=True だとその文章に含まれる各トークンの Embedding の平均(mean) だとか。

最後に分散表現を取得します。

embedding = st.encode("春はあけぼの")
print( f"{embedding.shape} / {type(embedding)}" )

実行すると、以下が標準出力されます。

(1024,) / <class 'numpy.ndarray'>

分散表現(Embedding)は 1024 次元のベクトルになっているようです。

embedding = st.encode(テキスト) で分散表現を取得できました。 この テキスト 部分は文字列の代わりに文字列の配列を渡してやれば、一度に複数のテキストの分散表現を計算できます。 便利ですね。

ここまでのコードを確認:

from sentence_transformers import models
from sentence_transformers import SentenceTransformer

MY_MODEL = "cl-tohoku/bert-large-japanese-v2"

transformer = models.Transformer(MY_MODEL)

pooling = models.Pooling(
    transformer.get_word_embedding_dimension(),
    pooling_mode_mean_tokens=True,
    pooling_mode_cls_token=False,
    pooling_mode_max_tokens=False)

st = SentenceTransformer(modules=[transformer, pooling])

embedding = st.encode("春はあけぼの")
print( f"{embedding.shape} / {type(embedding)}' )

テキスト同士の類似度計算

それでは、「春はあけぼの」「夏は夜」「秋は夕暮れ」「冬はつとめて」の4つを一度に調べてみます。 さらに、それぞれがどのくらい似ているかの類似度計算もしてみます。

2つのベクトルの類似度計算にはコサイン類似度を使います。

この関数は、今ちょっと調べてそれをコピペしただけなので、信用しないでください。

import numpy as np

def cos_sim(v0, v1):
    return np.dot(v0, v1) / (np.linalg.norm(v0) * np.linalg.norm(v1))

この関数を使って類似度を計算します。

texts = [
    "春はあけぼの",
    "夏は夜",
    "秋は夕暮れ",
    "冬はつとめて"]

embeddings = st.encode(texts)

v0 = embeddings[0]
v1 = embeddings[1]
v2 = embeddings[2]
v3 = embeddings[3]

print( "---" )
print( f"{cos_sim(v0, v1)} = ({texts[0]} x {texts[1]})" )
print( f"{cos_sim(v0, v2)} = ({texts[0]} x {texts[2]})" )
print( f"{cos_sim(v0, v3)} = ({texts[0]} x {texts[3]})" )

print( "---" )
print( f"{cos_sim(v1, v2)} = ({texts[1]} x {texts[2]})"  )
print( f"{cos_sim(v1, v3)} = ({texts[1]} x {texts[3]})" )

print( "---" )
print( f"{cos_sim(v2, v3)} = ({texts[1]} x {texts[3]})" )

実行します。

$ python3 main.py
---
0.8403152823448181 = (春はあけぼの x 夏は夜)
0.8672553300857544 = (春はあけぼの x 秋は夕暮れ)
0.8746604919433594 = (春はあけぼの x 冬はつとめて)
---
0.9026705026626587 = (夏は夜 x 秋は夕暮れ)
0.8899562954902649 = (夏は夜 x 冬はつとめて)
---
0.8595765829086304 = (夏は夜 x 冬はつとめて)

「夏は夜 x 秋は夕暮れ」 の組合わせがもっとも類似度が高い結果になりました。 どうなんでしょう。よくわかりません。

「春はあけぼの」に代えて「吾輩は猫である」と類似度比較をしてみます。

texts = [
    #"春はあけぼの",
    "吾輩は猫である",
    "夏は夜",
    "秋は夕暮れ",
    "冬はつとめて"]

実行。

$ python3 main.py
---
0.6487482190132141 = (吾輩は猫である x 夏は夜)
0.6635769605636597 = (吾輩は猫である x 秋は夕暮れ)
0.6761958003044128 = (吾輩は猫である x 冬はつとめて)
---
0.9026705026626587 = (夏は夜 x 秋は夕暮れ)
0.8899564146995544 = (夏は夜 x 冬はつとめて)
---
0.859576404094696 = (夏は夜 x 冬はつとめて)

「吾輩は猫である」との比較ではいずれも類似度が低くなりました。 機能している気がします。

おまけ) Tokenizer を使う

分散表現の計算から少し外れますが、transformers の BertJapaneseTokenizer を使うことで、どのようにテキストが分割されるかを調べることができる。 これを使ってみます。

from transformers import BertJapaneseTokenizer

tokenizer = BertJapaneseTokenizer.from_pretrained(MY_MODEL)
input_ids = tokenizer(texts, add_special_tokens=False)["input_ids"]
for tok in input_ids:
    print(tokenizer.decode(tok))

これで実行してみると次のように分割されていた。

吾輩 は 猫 で ある
夏 は 夜
秋 は 夕暮れ
冬 は つとめ 

「冬はつとめて」について最後の「て」が抜けているが、たぶん サブワード(すなわち)特殊な単語として扱われたからだろう。(違うかもしれない。)

試しに、add_special_tokens=True として処理すると、以下のように出力された。

[CLS] 吾輩 は 猫 で ある [SEP]
[CLS] 夏 は 夜 [SEP]
[CLS] 秋 は 夕暮れ [SEP]
[CLS] 冬 は つとめ て [SEP]

つまり、その部分(「つとめて」の「て」)がトークナイザーによって無視されたわけではない。

まとめ

ここまでの結果の main.py をメモしておく。

from transformers import BertJapaneseTokenizer
import numpy as np

from sentence_transformers import models
from sentence_transformers import SentenceTransformer

def cos_sim(v0, v1):
    return np.dot(v0, v1) / (np.linalg.norm(v0) * np.linalg.norm(v1))


MY_MODEL = "cl-tohoku/bert-large-japanese-v2"

transformer = models.Transformer(MY_MODEL)

pooling = models.Pooling(
    transformer.get_word_embedding_dimension(),
    pooling_mode_mean_tokens=True,
    pooling_mode_cls_token=False,
    pooling_mode_max_tokens=False)

st = SentenceTransformer(modules=[transformer, pooling])

texts = [
    #"春はあけぼの",
    "吾輩は猫である",
    "夏は夜",
    "秋は夕暮れ",
    "冬はつとめて"]

embeddings = st.encode(texts)

v0 = embeddings[0]
v1 = embeddings[1]
v2 = embeddings[2]
v3 = embeddings[3]

print( "---" )
print( f"{cos_sim(v0, v1)} = ({texts[0]} x {texts[1]})" )
print( f"{cos_sim(v0, v2)} = ({texts[0]} x {texts[2]})" )
print( f"{cos_sim(v0, v3)} = ({texts[0]} x {texts[3]})" )

print( "---" )
print( f"{cos_sim(v1, v2)} = ({texts[1]} x {texts[2]})"  )
print( f"{cos_sim(v1, v3)} = ({texts[1]} x {texts[3]})" )

print( "---" )
print( f"{cos_sim(v2, v3)} = ({texts[1]} x {texts[3]})" )

print( "------" )

tokenizer = BertJapaneseTokenizer.from_pretrained(MY_MODEL)
#input_ids = tokenizer(texts, add_special_tokens=False)["input_ids"]
input_ids = tokenizer(texts, add_special_tokens=True)["input_ids"]
for tok in input_ids:
    print(tokenizer.decode(tok))

以上です。

追伸

今更だが、large モデル(bert-large-japanese-v2)を使うと分散表現のベクトル数が 1024 になるのだが、もし bert-base-japanese-v2 を使えば、ベクトル数が 768 になる。ベクトル数が少ないモデルを使った方が開発時は都合がよいかもしれない。

ちなみに、bert-base-japanese-v2 の場合、分散表現を使った類似度計算結果は以下のようになった。

---
0.5335454344749451 = (吾輩は猫である x 夏は夜)
0.4863240718841553 = (吾輩は猫である x 秋は夕暮れ)
0.43913841247558594 = (吾輩は猫である x 冬はつとめて)
---
0.8856226205825806 = (夏は夜 x 秋は夕暮れ)
0.4752297103404999 = (夏は夜 x 冬はつとめて)
---
0.44759073853492737 = (夏は夜 x 冬はつとめて)

「吾輩は猫である x 秋は夕暮れ」の類似度と 「夏は夜 x 冬はつとめて」の類似度が同じくらいになってしまった。 やはり、large モデルの方が性能がよいのだろうか?

ならば、 cl-tohoku/bert-base-japanese-v3 ならどうなるのであろうか?
ということでやってみた。

---
0.6999399065971375 = (吾輩は猫である x 夏は夜)
0.6908717155456543 = (吾輩は猫である x 秋は夕暮れ)
0.7120431661605835 = (吾輩は猫である x 冬はつとめて)
---
0.8742066621780396 = (夏は夜 x 秋は夕暮れ)
0.7716454863548279 = (夏は夜 x 冬はつとめて)
---
0.8147321939468384 = (夏は夜 x 冬はつとめて)

どうなのか・・・「吾輩は猫である」との類似度が低く出るのは変わらない。

以上です。