RでTransformerのAttention機構:Embedding

Rを利用して、TransformerAttention機構におけるEmbeddingを確認します。

Embeddingとは

Embedding とは、単語やトークンのような離散的な記号を、意味を表現できる連続値のベクトルに変換したものです。

単純な方法(例えば、one-hot vector)では単語同士の意味的な近さを表現できませんが、embeddingでは「意味が近い単語は、ベクトル空間上でも近くに配置される」という性質を持たせます。

BERT(Bidirectional Encoder Representations from Transformers)のようなTransformerベースのモデルには、embeddingが2種類存在します。

  1. 静的(static)embedding

    • モデル内部の埋め込み行列(lookup table)から取り出す、入力段階の生のベクトル。
    • 同じ単語なら、文脈に関係なく常に同じベクトルになります。
  2. 文脈化(contextual)embedding

    • 複数のself-attention層を通過した後の出力ベクトル。
    • 同じ単語でも、文脈によって異なるベクトルになります。
    • 例えば”bank”は「川岸」の意味でも「銀行」の意味でも、静的embeddingでは同じベクトルですが、BERTの出力(文脈化embedding)では異なるベクトルになります

事前準備

# パッケージの読み込み
library(reticulate)
library(ggplot2)

transformers <- import("transformers")
torch <- import("torch")

model_name <- "bert-base-uncased"
tokenizer <- transformers$AutoTokenizer$from_pretrained(model_name)
model <- transformers$AutoModel$from_pretrained(model_name)
# model$eval()

Part 1: 静的 embedding (embedding 行列そのもの) を確認

BERTの内部には、各語彙(トークン)を対応するベクトルに変換する「埋め込み行列」が存在します。

これは Transformer 層(attention)を通る の、生のルックアップテーブルであり、同じ単語なら文脈に関係なく常に同じベクトルを返します。

具体的には、model$embeddings$word_embeddings$weightから、“bank”という単語1つに対応する固定ベクトルをそのまま取り出します。

これは文脈に一切依存しない、埋め込み行列の生の値です。

get_static_embedding <- function(word) {
  # 単語をトークンIDに変換
  token_id <- tokenizer$convert_tokens_to_ids(word)
  # 埋め込み行列から該当行を取り出す
  embedding_matrix <- model$embeddings$word_embeddings$weight$detach()$numpy()
  embedding_matrix[token_id + 1, ] # Python は0始まり、Rは1始まりなので+1
}

static_bank <- get_static_embedding("bank")

cat("=== 'bank' の静的 embedding (先頭6次元のみ表示) ===\n")
print(round(static_bank[1:6], 3))
cat(sprintf("次元数: %d\n", length(static_bank)))
=== 'bank' の静的 embedding (先頭6次元のみ表示) ===
[1] -0.019 -0.065 -0.091 -0.078 -0.025  0.022
次元数: 768

このベクトルは “bank” という文字列そのものに紐づいており、「川岸のbank」であろうと「銀行のbank」であろうと関係なく、常に同一のベクトルが返ります。

これが静的embeddingの特徴であり、同時に限界です(多義語の意味の違いを表現できない)。

なお、768次元というのは、BERT-base の隠れ層の次元数(hidden_size)そのものです。

BERTには「base」(768次元、12層)と「large」(1024次元、24層)の2種類があり、bert-base-uncasedはbaseモデルですのでこの数字になります。

表示された6つの数値([-0.019, -0.065, -0.091, -0.078, -0.025, 0.022])自体には個々に解釈できる意味はありません。

学習によって得られた768個の数値の組み合わせ全体で”bank”という単語の意味を表現しているだけで、「1次元目が何を表すか」といった人間にわかる対応関係はありません。

この768次元のベクトルが“bank”という文字列にのみ紐づいた、たった1つの固定値になります。

Part 2: 文脈化 (contextual) embedding を取り出す関数

実際に文をBERTに入力し、self-attention層を通過した後の出力(last_hidden_state)から、指定した単語のベクトルを取り出す関数です。

これは周辺の単語の情報を取り込んで更新された、文脈依存のベクトルになります。

BERTのWordPieceトークン化でサブワードに分割される単語にも対応できるよう、再構成ロジックを入れています。

get_contextual_embeddings <- function(sentence) {
  inputs <- tokenizer(sentence, return_tensors = "pt")

  with(torch$no_grad(), {
    outputs <- model(
      input_ids = inputs$input_ids,
      attention_mask = inputs$attention_mask
    )
  })

  # last_hidden_state: (1, seq_len, hidden_dim) -> (seq_len, hidden_dim)
  hidden <- outputs$last_hidden_state$squeeze()$numpy()

  tokens <- unlist(tokenizer$convert_ids_to_tokens(
    inputs$input_ids$squeeze()$tolist()
  ))

  list(tokens = tokens, embeddings = hidden)
}

# 指定した単語に対応するベクトルを取り出すヘルパー関数。
# BERT は WordPiece という方式でトークン化するため、
# 単語が複数のサブワードに分割される場合がある
# (例: "deposited" -> "deposit" + "##ed")。
# その場合は該当するサブワードのベクトルを平均する。

get_word_vector <- function(res, target_word) {
  target_word <- tolower(target_word)
  tokens <- res$tokens

  # ケース1: 単語がそのまま1トークンとして存在する
  exact_idx <- which(tokens == target_word)
  if (length(exact_idx) >= 1) {
    return(res$embeddings[exact_idx[1], ])
  }

  # ケース2: サブワードに分割されている場合、再構成しながら探す
  for (i in seq_along(tokens)) {
    if (!startsWith(tokens[i], "##") && startsWith(target_word, tokens[i])) {
      reconstructed <- tokens[i]
      idxs <- i
      j <- i
      while (reconstructed != target_word &&
        j < length(tokens) && startsWith(tokens[j + 1], "##")) {
        j <- j + 1
        reconstructed <- paste0(reconstructed, sub("^##", "", tokens[j]))
        idxs <- c(idxs, j)
      }
      if (reconstructed == target_word) {
        return(colMeans(res$embeddings[idxs, , drop = FALSE]))
      }
    }
  }

  stop(sprintf(
    "単語 '%s' が見つかりませんでした。トークン列: %s",
    target_word, paste(tokens, collapse = " ")
  ))
}

Part 3: 同じ単語 “bank” を異なる文脈で使い、ベクトルを比較する

以下の3つの文脈での”bank”を比較します。

  • “I sat on the river bank…”(川岸)
  • “I put my money in the bank…”(銀行)
  • “The bank approved my loan…”(銀行)

同じ”bank”という単語でも、銀行の意味同士の類似度が高く、川岸の意味とは類似度が下がることを、実際のコサイン類似度の数値で確認します。

sentence_river <- "I sat on the river bank and watched the fish swim"
sentence_deposit <- "I put my money in the bank yesterday"
sentence_loan <- "The bank approved my loan application"

res_river <- get_contextual_embeddings(sentence_river)
res_deposit <- get_contextual_embeddings(sentence_deposit)
res_loan <- get_contextual_embeddings(sentence_loan)

bank_river <- get_word_vector(res_river, "bank") # 川岸の意味
bank_deposit <- get_word_vector(res_deposit, "bank") # 銀行の意味
bank_loan <- get_word_vector(res_loan, "bank") # 銀行の意味

cosine_sim <- function(a, b) sum(a * b) / (sqrt(sum(a^2)) * sqrt(sum(b^2)))

cat("\n=== 'bank' の文脈化 embedding 同士のコサイン類似度 ===\n")
cat(sprintf(
  "river-bank  vs  deposit-bank : %.3f  (意味が違う)\n",
  cosine_sim(bank_river, bank_deposit)
))
cat(sprintf(
  "deposit-bank vs  loan-bank    : %.3f  (意味が同じ)\n",
  cosine_sim(bank_deposit, bank_loan)
))
cat(sprintf(
  "river-bank  vs  loan-bank     : %.3f  (意味が違う)\n",
  cosine_sim(bank_river, bank_loan)
))

# 参考: 静的embeddingと文脈化embeddingの差も確認
cat(sprintf(
  "\n静的embedding vs river文脈でのbank   : %.3f\n",
  cosine_sim(static_bank, bank_river)
))
cat(sprintf(
  "静的embedding vs deposit文脈でのbank : %.3f\n",
  cosine_sim(static_bank, bank_deposit)
))

=== 'bank' の文脈化 embedding 同士のコサイン類似度 ===
river-bank  vs  deposit-bank : 0.468  (意味が違う)
deposit-bank vs  loan-bank    : 0.771  (意味が同じ)
river-bank  vs  loan-bank     : 0.453  (意味が違う)

静的embedding vs river文脈でのbank   : 0.207
静的embedding vs deposit文脈でのbank : 0.247

「銀行」の意味同士(0.771) > 「川岸」と「銀行」(0.468, 0.453)

同じ単語”bank”でも、意味が同じ(“銀行”同士)なら類似度が高く(0.771)、意味が違う(川岸 vs 銀行)なら類似度が下がっています(0.468, 0.453)。

これはself-attention層が、周囲の単語(“money”や”loan” vs “river”や”fish”)の情報を取り込んで、“bank”のベクトルをその都度書き換えている証拠です。

静的embeddingとの類似度が低い(0.207, 0.247)

文脈化された”bank”のベクトルが、Part 1で見た静的embeddingとはどちらも似ていない(0.2程度)事も確認できます。

これは、self-attention層を通過する過程で、ベクトルが「川岸」寄りにも「銀行」寄りにも、元の静的embeddingから大きく離れた場所まで移動していることを示しています。

つまりBERTの各層は、単なる微調整ではなく、文脈情報を使って表現を作り替えているということです。

なお、川岸と銀行の類似度(0.468, 0.453)がまだ比較的高く見えるのは、両方とも”the bank”という同じ構文パターン・同じ単語自体の情報を土台にしているためで、完全に無関係な単語同士(例えば”bank”と”apple”)と比べれば、この数値でも十分低い部類に入ります。

sentence_apple <- "I ate an apple for breakfast"
res_apple <- get_contextual_embeddings(sentence_apple)
word_apple <- get_word_vector(res_apple, "apple")

cat(sprintf("apple vs bank(river)   : %.3f\n", cosine_sim(word_apple, bank_river)))
cat(sprintf("apple vs bank(deposit) : %.3f\n", cosine_sim(word_apple, bank_deposit)))
cat(sprintf("apple vs bank(loan)    : %.3f\n", cosine_sim(word_apple, bank_loan)))
apple vs bank(river)   : 0.269
apple vs bank(deposit) : 0.249
apple vs bank(loan)    : 0.277
組み合わせ類似度関係性
bank(deposit) vs bank(loan)0.77同じ意味
bank(river) vs bank(loan/deposit)0.45〜0.47同じ単語・違う意味
apple vs bank(いずれか)0.25〜0.28無関係な単語

Part 4: 周辺の単語も含めて ggplot2 で可視化する

“bank”の3つのベクトルに加え、“river”/“fish”(川関連)、“money”/“loan”(お金関連)もPCAで2次元に落として散布図にし、“bank(川岸)”が川関連クラスタ側に、“bank(銀行)”がお金関連クラスタ側に引き寄せられている様子を確認します。

word_money <- get_word_vector(res_deposit, "money")
word_loan <- get_word_vector(res_loan, "loan")
word_river <- get_word_vector(res_river, "river")
word_fish <- get_word_vector(res_river, "fish")

embedding_matrix <- rbind(
  `bank (river)`   = bank_river,
  `bank (deposit)` = bank_deposit,
  `bank (loan)`    = bank_loan,
  river            = word_river,
  fish             = word_fish,
  money            = word_money,
  loan             = word_loan
)

category <- c(
  "bank(川岸)", "bank(銀行)", "bank(銀行)",
  "川関連", "川関連", "お金関連", "お金関連"
)

# 768次元を2次元に圧縮して可視化 (PCA)
pca <- prcomp(embedding_matrix, center = TRUE, scale. = FALSE)
plot_df <- data.frame(
  word = rownames(embedding_matrix),
  x = pca$x[, 1],
  y = pca$x[, 2],
  category = category
)

p1 <- ggplot(plot_df, aes(x = x, y = y, color = category, label = word)) +
  geom_point(size = 3) +
  geom_text(vjust = -0.9, size = 3.5, show.legend = FALSE) +
  labs(
    title = "BERTの文脈化embedding: 'bank'は意味によって異なる位置に",
    subtitle = "同じ単語'bank'でも、周辺の単語(river/money/loan)に引き寄せられている",
    x = "主成分1", y = "主成分2", color = "カテゴリ"
  ) +
  theme_minimal(base_size = 12)

print(p1)
Figure 1

bank(loan)・bank(deposit)が”money”・“loan”の側に、bank(river)が”river”の側に位置

図の右下に”bank (loan)“と”bank (deposit)“が近接して配置され、その上方には”money”や”loan”があります。

一方、左下には”bank (river)“が”river”のすぐ近くに配置されています。

これは前回の類似度の数値(0.771 vs 0.468/0.453)が示していたことを、2次元の位置関係として視覚的に裏付けています。

“fish”だけが離れた位置にある点

“fish”は主成分2が突出して高く、他の単語から離れています。

これはPCAが768次元の情報のうち分散が最大の2方向だけを抜き出しているためで、“river”文脈における別の意味的側面(例えば”泳ぐ”や”生き物”に関する情報)が主成分2に強く反映された結果と考えられます。

“bank (river)”や”river”自体は主成分1・2ともに比較的小さい値にまとまっており、両者の近さ自体は保たれています。

全体として

横軸(主成分1)が「川岸的な意味」と「金融的な意味」を分ける軸としてほぼ機能しており、同じ”bank”という単語が、周辺の文脈次第で意味空間上のまったく異なる場所に写像されることが、この散布図からも確認できます。

Part 5: 3つの “bank” 同士の類似度をヒートマップで可視化

bank_vectors <- rbind(
  `bank (river)`   = bank_river,
  `bank (deposit)` = bank_deposit,
  `bank (loan)`    = bank_loan
)

labels <- rownames(bank_vectors)
sim_matrix <- outer(
  seq_along(labels), seq_along(labels),
  Vectorize(function(i, j) cosine_sim(bank_vectors[i, ], bank_vectors[j, ]))
)
rownames(sim_matrix) <- colnames(sim_matrix) <- labels

sim_df <- expand.grid(word1 = labels, word2 = labels)
sim_df$similarity <- as.vector(sim_matrix)

p2 <- ggplot(sim_df, aes(x = word1, y = word2, fill = similarity)) +
  geom_tile(color = "white") +
  geom_text(aes(label = sprintf("%.2f", similarity)), size = 4) +
  scale_fill_gradient2(
    low = "steelblue", mid = "white", high = "firebrick",
    midpoint = 0.7
  ) +
  scale_y_discrete(limits = rev(labels)) +
  labs(
    title = "同じ単語'bank'でも意味によって類似度が変わる",
    x = NULL, y = NULL, fill = "類似度"
  ) +
  theme_minimal(base_size = 12) +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

print(p2)
Figure 2

bank(deposit) と bank(loan) の組み合わせだけが赤みがかっている(0.77)

同じ「銀行」の意味を持つ2つのbank同士は、色スケール上でも明確に暖色寄りになっています。

bank(river)を含む組み合わせは青寄り(0.47, 0.45)

“銀行”の意味のbankと”川岸”の意味のbankの組み合わせは、どちらも似たような低め(0.45〜0.47)の値で、色も同じ青系に揃っています。

これは Figure 1 の散布図(PCA)で見た「bank(river)が孤立し、bank(deposit)とbank(loan)が近接する」という位置関係を、数値行列としてそのまま裏付ける結果です。

以上です。