単語の埋め込みのしくみ:共起・word2vec から文の埋め込み・ベクトル検索まで
コンピューターで言葉を扱うには、単語や文章を数値に変える必要があります。今の自然言語処理では、単語や文を数百〜数千個の数の並び(ベクトル)で表し、意味の近いものほどベクトルも近くなるようにします。これを 埋め込み(embedding)と呼びます。ChatGPT などの大規模言語モデルの入り口にも、社内文書から質問に関係する部分を探す検索のしくみにも、この埋め込みが使われています。
アテンションと Transformer のしくみ では「埋め込みは単語の番号の行を表から取り出すだけで、表の値は学習で決まる」と説明しました。この記事では、その表の値が 何を手がかりに、どう決まるのかを説明します。
結論を先に書くと、埋め込みは次の考え方でできています。
- 似た文脈に出てくる単語は、意味も似ている(分布仮説)。周りにどんな単語が来るかを数えれば、単語の意味をベクトルにできる
- word2vec は、周りの単語を当てる問題を解きながら、ベクトルを学習する。数え上げて圧縮する方法と、本質的には同じことをしている
- ベクトルの向きに意味の関係が表れる。「王 − 男 + 女」を計算すると「女王」に近くなる
- 文や文書もベクトルにでき、近いベクトルを探せば意味の近い文書が見つかる。大量の文書では、すべてと比べずに近似で探す
説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。
環境
計算例を動かした環境です。
| 項目 | バージョン |
|---|---|
| Python | 3.13 |
| NumPy | 2.5 |
単語を番号のままにしておけない理由
one-hot 表現ではどの単語も同じくらい遠い
いちばん単純な表し方は、語彙の数だけ 0 を並べ、その単語の位置だけを 1 にする one-hot 表現です。語彙が 5 万語なら、5 万個の数のうち 1 つだけが 1 のベクトルになります。
この表し方では、どの 2 つの単語のベクトルも、内積は 0、距離はすべて同じです。「猫」と「犬」も、「猫」と「冠」も、同じくらい違う単語として扱われ、「猫について学んだことを犬にも生かす」ことができません。
似た意味の単語を近くに置きたい
そこで、数百次元くらいのベクトルで、似た意味の単語ほど近くなるように表します。近さは、ふつう コサイン類似度(2 つのベクトルの向きがどれだけそろっているか。1 が同じ向き、0 が直角)で測ります。問題は、「似た意味」をどうやって数値にするかです。
分布仮説:周りの単語で意味が決まる
共起行列と PPMI
言語学には、「単語の意味は、その単語の周りに現れる単語で分かる」という 分布仮説があります。「__が ニャー と 鳴く」「__に えさを あげる」の空欄に入る単語は、知らない単語でも動物だと見当が付きます。
この考えをそのまま数にしたのが 共起行列です。文章の中で、ある単語の前後 2 語以内に、ほかの単語が何回出てきたかを数えて表にします。ただし、「が」「を」のようにどこにでも出てくる単語は、回数が多いだけで意味の手がかりにはなりません。そこで、「偶然一緒に出てくると期待される回数」に比べて何倍多いかの対数(PMI、自己相互情報量)を取り、負の値を 0 にした PPMI を使います。
小さなコーパスで単語のベクトルを作る
王・女王・男・女と、猫・犬が出てくる 3,000 文の小さな文章の集まり(コーパス)を作り、PPMI を特異値分解で 16 次元に圧縮して、単語のベクトルにしてみます。
import numpy as np
# 小さなコーパスを作る(単語は空白で区切る)
rng = np.random.default_rng(0)
people = [("王", "彼", True), ("女王", "彼女", True), ("男", "彼", False), ("女", "彼女", False)]
animals = [("猫", "ニャー と 鳴く"), ("犬", "ワン と 吠える")]
sentences = []
for _ in range(3000):
w, pron, royal = people[rng.integers(4)]
a, sound = animals[rng.integers(2)]
choices = [f"{pron} は {w} だ", f"{w} が 笑う", f"{a} が {sound}", f"{a} が 庭 を 走る", f"{w} が {a} を 飼う"]
choices += [f"{w} が 冠 を かぶる", f"{w} が 国 を 治める"] if royal else [f"{w} が 畑 を 耕す"]
sentences.append(choices[rng.integers(len(choices))].split())
vocab = sorted({w for s in sentences for w in s})
index = {w: i for i, w in enumerate(vocab)}
print(len(sentences), len(vocab))
# 共起行列:前後 2 語以内に一緒に出てきた回数
C = np.zeros((len(vocab), len(vocab)))
for s in sentences:
for i, w in enumerate(s):
for j in range(max(0, i - 2), min(len(s), i + 3)):
if j != i:
C[index[w], index[s[j]]] += 1
# PPMI:偶然より多く一緒に出てくる度合い(正の値だけ残す)
total = C.sum()
expected = C.sum(axis=1, keepdims=True) * C.sum(axis=0, keepdims=True) / total
with np.errstate(divide="ignore"):
ppmi = np.maximum(np.log(C / expected), 0)
# 特異値分解で 16 次元に圧縮し、単語のベクトルにする
U, S, Vt = np.linalg.svd(ppmi)
vec = U[:, :16] * S[:16]
def nearest(v, exclude=(), k=3):
unit = vec / np.linalg.norm(vec, axis=1, keepdims=True)
sims = unit @ (v / np.linalg.norm(v)) # コサイン類似度
return [(vocab[i], round(float(sims[i]), 2)) for i in np.argsort(-sims) if vocab[i] not in exclude][:k]
for w in ["猫", "王", "女"]:
print(w, nearest(vec[index[w]], exclude=[w]))
print(nearest(vec[index["王"]] - vec[index["男"]] + vec[index["女"]], exclude=["王", "男", "女"])) # 王 − 男 + 女
cos = lambda a, b: round(float(a @ b / np.linalg.norm(a) / np.linalg.norm(b)), 3)
print(cos(vec[index["王"]], vec[index["女王"]]),
cos(vec[index["王"]] - vec[index["女王"]], vec[index["男"]] - vec[index["女"]]))
実行結果:
3000 27
猫 [('犬', 0.56), ('鳴く', 0.53), ('走る', 0.51)]
王 [('女王', 0.81), ('男', 0.72), ('は', 0.53)]
女 [('女王', 0.74), ('男', 0.74), ('は', 0.63)]
[('女王', 0.99), ('は', 0.54), ('治める', 0.37)]
0.808 0.981
1 行目は文の数と語彙の数です。2〜4 行目は、各単語にいちばん近い単語 3 つとコサイン類似度です。「猫」にいちばん近いのは「犬」、「王」にいちばん近いのは「女王」になりました。どちらの組も、同じ文の中に一緒に出てくることはほとんどないのに、「__が 庭を 走る」「__が 冠を かぶる」のように 周りに来る単語が似ているので、近いベクトルになったのです。
王 − 男 + 女 ≒ 女王
5 行目は、「王」のベクトルから「男」を引いて「女」を足したベクトルに、いちばん近い単語です(計算に使った 3 語は除いています)。「女王」が 0.99 という、ほぼ同じ向きで見つかりました。「王」と「女王」そのものの類似度は 0.808 なので、「男 → 女」の差を足したことで、ぐっと「女王」に近づいたことになります。
最後の行の右の値は、「王 − 女王」のベクトルと「男 − 女」のベクトルのコサイン類似度で、0.981 とほぼ同じ向きです。つまり、ベクトルの空間の中に「性別」を表す向きがあり、王と女王、男と女は、その向きにそろってずれています。このコーパスでは、「王」と「男」は「彼」と、「女王」と「女」は「彼女」と一緒に出てくるので、その違いが同じ向きの差として表れました。
このような足し算・引き算(アナロジー)が成り立つことは、2013 年の word2vec の論文で広く知られるようになりました。ただし、実際の大きなコーパスで作ったベクトルでも、いつもきれいに成り立つわけではありません。
word2vec:周りの単語を当てながら学ぶ
skip-gram と負例サンプリング
共起行列は、語彙が数十万語になると巨大になります。2013 年に Google の研究者が発表した word2vec は、表を作らずに、ニューラルネットワークの学習としてベクトルを求めます。代表的な skip-gram は、次の問題をくり返し解きます。
- 文の中のある単語から、その前後に出てくる単語を当てる
すべての語彙について確率を出すと計算が重いので、負例サンプリングを使います。本当に周りにあった単語(正例)1 つと、ランダムに選んだ単語(負例)数個について、「この組は本当に近くに出てくるか」を当てる 2 値の分類(ロジスティック回帰)にするのです。単語 w のベクトル v と、周りの単語 c のベクトル u の内積をシグモイドに通したものを、「近くに出てくる確率」とします。
同じコーパスで、word2vec を NumPy で学習させてみます。上のコードの続きです(この環境では 10 秒ほどかかりました)。
# word2vec(skip-gram + 負例サンプリング)を同じコーパスで学習する
rng = np.random.default_rng(1)
dim, window, n_neg, lr = 16, 2, 5, 0.01
W_in = rng.normal(0, 0.1, (len(vocab), dim)) # 単語のベクトル(これを埋め込みとして使う)
W_out = np.zeros((len(vocab), dim)) # 周りの単語として使うときのベクトル
counts = C.sum(axis=1)
neg_p = counts**0.75 / (counts**0.75).sum() # 負例は出現回数の 0.75 乗に比例して選ぶ
def sigmoid(x):
return 1 / (1 + np.exp(-x))
for epoch in range(3):
for s in sentences:
for i, w in enumerate(s):
for j in range(max(0, i - window), min(len(s), i + window + 1)):
if j == i:
continue
# 本当に周りにあった単語(正例 1 個)と、ランダムに選んだ単語(負例 5 個)
targets = np.r_[index[s[j]], rng.choice(len(vocab), n_neg, p=neg_p)]
labels = np.r_[1.0, np.zeros(n_neg)]
v = W_in[index[w]]
p = sigmoid(W_out[targets] @ v) # 「周りにありそうか」の確率
g = p - labels # ロジスティック回帰と同じ形の勾配
W_in[index[w]] -= lr * g @ W_out[targets]
W_out[targets] -= lr * g[:, None] * v
vec = W_in
for w in ["猫", "王"]:
print(w, nearest(vec[index[w]], exclude=[w]))
print(nearest(vec[index["王"]] - vec[index["男"]] + vec[index["女"]], exclude=["王", "男", "女"]))
print(cos(vec[index["王"]], vec[index["女王"]]),
cos(vec[index["王"]] - vec[index["女王"]], vec[index["男"]] - vec[index["女"]]))
実行結果:
猫 [('犬', 0.9), ('笑う', 0.67), ('冠', 0.63)]
王 [('女王', 0.98), ('男', 0.88), ('女', 0.83)]
[('女王', 0.98), ('を', 0.59), ('ワン', 0.54)]
0.976 0.91
word2vec でも、「猫」に近いのは「犬」、「王」に近いのは「女王」になり、「王 − 男 + 女」は「女王」になりました。「王 − 女王」と「男 − 女」の向きも 0.91 とよくそろっています。この小さなコーパスでは「王」と「女王」の類似度がもともと 0.976 と高いので、アナロジーの効果は PPMI の例ほどはっきりしませんが、性別の向きがそろっていることは確かめられます。
数え上げる方法と同じことをしている
word2vec は、共起行列を数える方法とまったく違う方法に見えますが、2014 年の研究で、skip-gram と負例サンプリングは「PMI の行列を、正例と負例の比で少しずらしたものを、2 つの行列の積に分解している」のと同じだと示されました。周りの単語との関係を数にするという点で、2 つの方法は同じ分布仮説に基づいています。word2vec は、それを巨大な表を作らずに、文章を流し読みしながら少しずつ計算できるのが長所です。
同じころ、共起の回数の対数を直接 2 つのベクトルの内積で近似する GloVe も発表され、word2vec と並んでよく使われました。
埋め込みは偏りも覚える
埋め込みは、学習した文章に含まれる関係をそのまま覚えます。そのため、「男 − 女」の向きに「プログラマー − 主婦」のような、社会の偏見を反映した関係も入り込むことが 2016 年に指摘されました。埋め込みを使ったシステムは、この偏りを結果に持ち込むことがあるので、注意が必要です。
文脈で意味が変わる単語
word2vec や GloVe では、1 つの単語に 1 つのベクトルしかありません。ところが、「はし」が「橋」なのか「箸」なのか「端」なのかは、文脈で決まります。英語の "bank" も、「銀行」と「土手」で意味が違います。
2018 年の ELMo や BERT は、文全体を読んでから、文の中の各単語のベクトルを作ります。同じ単語でも、文脈が違えば違うベクトルになるので、多義語を区別できます。BERT は Transformer の記事 で説明したエンコーダーで、アテンションで文中のほかの単語の情報を混ぜ込むことで、文脈に応じたベクトルを作っています。
| 方法 | 単語のベクトル | 例 |
|---|---|---|
| 共起行列 + 圧縮 | 単語ごとに 1 つ | LSA・PPMI + SVD |
| 予測で学習する埋め込み | 単語ごとに 1 つ | word2vec・GloVe・fastText |
| 文脈に応じた埋め込み | 文の中の位置ごとに違う | ELMo・BERT・今の LLM の内部 |
文の埋め込み:文書をベクトルで探す
単語のベクトルの平均で文を表す
単語だけでなく、文や文書もベクトルにできれば、「意味の近い文書を探す」ことができます。いちばん簡単な方法は、文中の単語のベクトルの平均を取ることです。word2vec のベクトルを使って試してみます。上のコードの続きです。
def sentence_vec(text):
return np.mean([vec[index[w]] for w in text.split()], axis=0) # 単語のベクトルの平均
docs = ["王 が 国 を 治める", "女 が 畑 を 耕す", "犬 が 庭 を 走る", "猫 が ニャー と 鳴く"]
query = "女王 が 冠 を かぶる"
q = sentence_vec(query)
for d in docs:
print(d, cos(q, sentence_vec(d)))
print(cos(sentence_vec("男 が 犬 を 飼う"), sentence_vec("犬 が 男 を 飼う")))
実行結果:
王 が 国 を 治める 0.956
女 が 畑 を 耕す 0.824
犬 が 庭 を 走る 0.81
猫 が ニャー と 鳴く 0.384
1.0
「女王 が 冠 を かぶる」という質問に対して、同じ単語は「が」「を」しか共通していない「王 が 国 を 治める」がいちばん近い文として見つかりました。単語が一致しなくても、意味の近い文を探せるのが、埋め込みを使った検索(ベクトル検索・意味検索)の強みです。
ただし、最後の行のとおり、「男 が 犬 を 飼う」と「犬 が 男 を 飼う」は、単語が同じなので、平均を取るとまったく同じベクトル(類似度 1.0)になってしまいます。平均では語順の情報が失われるのです。今の文の埋め込みは、BERT のような Transformer に文全体を読ませ、「意味の近い文の組は近く、遠い組は遠く」なるように追加で学習したモデル(2019 年の Sentence-BERT など)で作るのが主流です。
ベクトル検索:大量の文書から近いものを速く探す
総当たりは遅い
質問のベクトルに近い文書を探すいちばん確実な方法は、すべての文書とのコサイン類似度を計算して上位を取る総当たりです。しかし、文書が何千万件にもなると、質問のたびに全件と計算するのは時間がかかります。そこで、少しだけ正確さを犠牲にして、ずっと速く探す近似の方法(近似最近傍探索)が使われます。
IVF:グループに分けて、近いグループだけを探す
代表的な方法の 1 つが IVF(転置ファイル)です。
- あらかじめ、文書のベクトルを k-means でいくつかのグループに分けておく(クラスタリングの記事 で説明した方法)
- 質問が来たら、まず質問に近いグループの中心を数個選ぶ
- 選んだグループに入っている文書とだけ類似度を計算する
10 万件の文書で、総当たりで求めた本当の上位 10 件のうち、どれだけを IVF で見つけられるか(再現率)を、調べるグループの数ごとに比べてみます。
import numpy as np
# 10 万件の文書のベクトル(64 次元)。似た話題ごとに 200 のまとまりがある
rng = np.random.default_rng(0)
topics = rng.standard_normal((200, 64))
docs = topics[rng.integers(200, size=100000)] + 0.5 * rng.standard_normal((100000, 64))
docs /= np.linalg.norm(docs, axis=1, keepdims=True)
queries = topics[rng.integers(200, size=100)] + 0.5 * rng.standard_normal((100, 64))
queries /= np.linalg.norm(queries, axis=1, keepdims=True)
# 1. 総当たり:すべての文書とのコサイン類似度を計算して上位 10 件を取る(正解)
exact = np.argsort(-(queries @ docs.T), axis=1)[:, :10]
# 2. IVF:文書を k-means で 256 のグループに分けておき、質問に近いグループの中だけを探す
centers = docs[rng.choice(100000, 256, replace=False)]
for _ in range(10):
assign = (docs @ centers.T).argmax(axis=1)
centers = np.array([docs[assign == j].mean(axis=0) if (assign == j).any() else centers[j] for j in range(256)])
centers /= np.linalg.norm(centers, axis=1, keepdims=True)
assign = (docs @ centers.T).argmax(axis=1)
for n_probe in [1, 4, 16]:
hits, scanned = 0, 0
for q, truth in zip(queries, exact):
groups = np.argsort(-(centers @ q))[:n_probe] # 質問に近いグループを n_probe 個選ぶ
cand = np.flatnonzero(np.isin(assign, groups))
top = cand[np.argsort(-(docs[cand] @ q))[:10]]
hits += len(set(top) & set(truth))
scanned += len(cand)
print(n_probe, round(scanned / len(queries) / 100000, 3), round(hits / (10 * len(queries)), 3))
実行結果:
1 0.005 0.899
4 0.017 1.0
16 0.064 1.0
左から、調べるグループの数、全文書のうち類似度を計算した割合、本当の上位 10 件を見つけられた割合です。いちばん近いグループ 1 つだけを調べると、全体の 0.5% の文書との計算で、上位 10 件の 89.9% を見つけられました。グループを 4 つに増やすと、全体の 1.7% との計算で 100% 見つかっています。総当たりの約 60 分の 1 の計算で、同じ答えが得られたことになります。
実際のデータは、このようにはっきりしたまとまりを持たないことも多いので、ここまで効率よくはいきません。調べるグループの数を増やすほど正確に、減らすほど速くなるので、その釣り合いを調整して使います。ほかに、ベクトルを短い符号に圧縮する 直積量子化や、近い点どうしをつないだグラフをたどる HNSW などの方法もよく使われ、FAISS などのライブラリやベクトルデータベースに組み込まれています。
埋め込みの手法の流れ
この記事で紹介した手法を年代順に並べます。
| 年 | 手法・出来事 | ポイント |
|---|---|---|
| 1954・1957 | 分布仮説(ハリス・ファース) | 単語の意味は、周りに現れる単語で分かる |
| 1990 | 潜在意味解析(LSA) | 単語と文書の表を特異値分解で圧縮する |
| 2003 | ニューラル言語モデル(ベンジオ) | 次の単語の予測と同時に、単語のベクトルを学習する |
| 2011 | 直積量子化による近似最近傍探索 | ベクトルを短い符号に圧縮して、速く探す |
| 2013 | word2vec | skip-gram と負例サンプリングで、大量の文章から速く学習する |
| 2014 | GloVe・word2vec と PMI の関係 | 共起の回数から学ぶ方法と、予測で学ぶ方法がつながる |
| 2016 | HNSW・埋め込みの偏りの指摘 | グラフをたどる近似最近傍探索。埋め込みが社会の偏見を覚えることを示す |
| 2017 | fastText・FAISS | 単語の一部(文字の並び)も使う埋め込み。大規模なベクトル検索のライブラリ |
| 2018 | ELMo・BERT | 文脈に応じて、同じ単語でも違うベクトルを作る |
| 2019 | Sentence-BERT | 意味の近い文が近くなるように、文の埋め込みを学習する |
まとめ
- one-hot 表現では、どの単語どうしも同じくらい遠く、意味の近さを表せない
- 分布仮説に基づき、周りに来る単語を数えて PPMI を取り、圧縮すると、意味の近い単語が近いベクトルになる
- word2vec は、周りの単語を当てる問題を負例サンプリングで解きながらベクトルを学習する。本質的には PMI の行列を分解しているのと同じ
- ベクトルの差に意味の関係が表れ、「王 − 男 + 女」が「女王」に近くなる。一方で、学習した文章の偏りも覚える
- 単語ごとに 1 つのベクトルでは多義語を区別できないので、BERT などは文脈に応じたベクトルを作る
- 文の埋め込みを使うと、単語が一致しなくても意味の近い文書を探せる。単語のベクトルの平均では語順が失われる
- 大量の文書のベクトル検索では、IVF などの近似最近傍探索で、ごく一部の文書だけと比べて速く探す
参考
- Z. S. Harris, "Distributional Structure", Word 1954
- J. R. Firth, "A synopsis of linguistic theory 1930–1955", Studies in Linguistic Analysis 1957
- S. Deerwester et al., "Indexing by Latent Semantic Analysis", Journal of the American Society for Information Science 1990
- Y. Bengio et al., "A Neural Probabilistic Language Model", JMLR 2003
- H. Jégou et al., "Product Quantization for Nearest Neighbor Search", IEEE TPAMI 2011
- T. Mikolov et al., "Efficient Estimation of Word Representations in Vector Space", 2013(arXiv:1301.3781)
- T. Mikolov et al., "Distributed Representations of Words and Phrases and their Compositionality", NeurIPS 2013(arXiv:1310.4546)
- J. Pennington et al., "GloVe: Global Vectors for Word Representation", EMNLP 2014
- O. Levy and Y. Goldberg, "Neural Word Embedding as Implicit Matrix Factorization", NeurIPS 2014
- T. Bolukbasi et al., "Man is to Computer Programmer as Woman is to Homemaker? Debiasing Word Embeddings", NeurIPS 2016(arXiv:1607.06520)
- Y. A. Malkov and D. A. Yashunin, "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs", IEEE TPAMI 2020(arXiv:1603.09320)
- P. Bojanowski et al., "Enriching Word Vectors with Subword Information", TACL 2017(arXiv:1607.04606)
- J. Johnson et al., "Billion-scale similarity search with GPUs", IEEE Transactions on Big Data 2019(arXiv:1702.08734)
- M. E. Peters et al., "Deep contextualized word representations", NAACL 2018(arXiv:1802.05365)
- J. Devlin et al., "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding", NAACL 2019(arXiv:1810.04805)
- N. Reimers and I. Gurevych, "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks", EMNLP 2019(arXiv:1908.10084)