RでTransformerのAttention機構:BERT

BERTとは

BERT(Bidirectional Encoder Representations from Transformers)は、2018年にGoogleが発表した言語モデルです。

Bidirectional(双方向)のとおり、文中のある単語を処理する際、その左側と右側の両方の文脈を同時に参照できるモデルです。

事前学習タスク: Masked Language Model (MLM)

BERTの双方向性は、文中の単語をランダムに[MASK]という特殊トークンに置き換え、周囲の単語(左右両方)から、隠された単語が何であるかを予測させるという事前訓練を大量のテキストで行った結果の「左右双方の文脈を総合的に使って単語の意味を理解する」能力です。

以下は、BERTのアーキテクチャ情報(層数、head数、パラメータ数など)を実際のモデルから確認し、事前学習タスクである Masked Language Model (MLM) を実際に動かし、BERTの「双方向性」が予測にどう効いているかを、文脈の一部を隠す実験で確認します。

アーキテクチャ情報の確認

model$configから、語彙数・埋め込み次元(768)・層数(12)・head数(12)などを取得し、さらに埋め込み層とEncoder部分それぞれのパラメータ数も実際に計算して表示します。

library(reticulate)

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

model_name <- "bert-base-uncased"
tokenizer <- transformers$AutoTokenizer$from_pretrained(model_name)

# MLM(穴埋め)タスク用のヘッド付きモデルを読み込む。
# 内部的には encoder 部分 (model$bert) と、
# 語彙全体への予測ヘッド (model$cls) を両方持っている
model <- transformers$BertForMaskedLM$from_pretrained(model_name)
# model$eval()

config <- model$config

# 総パラメータ数、および埋め込み部分とEncoder部分の内訳を確認
count_params <- function(module) {
  total <- 0
  for (p in iterate(module$parameters())) {
    total <- total + as.integer(p$numel())
  }
  total
}

embedding_params <- count_params(model$bert$embeddings)
encoder_params <- count_params(model$bert$encoder)
total_params <- count_params(model)

bert_summary <- data.frame(
  項目 = c(
    "語彙数 (vocab_size)",
    "埋め込み次元 (hidden_size)",
    "Encoder層数 (num_hidden_layers)",
    "Attention head数 (1層あたり)",
    "1 head あたりの次元",
    "Feed-forward層の中間次元",
    "最大入力トークン数",
    "埋め込み層のパラメータ数",
    "Encoder(12層)のパラメータ数",
    "モデル全体のパラメータ数"
  ),
= c(
    format(config$vocab_size, big.mark = ","),
    format(config$hidden_size, big.mark = ","),
    format(config$num_hidden_layers, big.mark = ","),
    format(config$num_attention_heads, big.mark = ","),
    format(as.integer(config$hidden_size / config$num_attention_heads), big.mark = ","),
    format(config$intermediate_size, big.mark = ","),
    format(config$max_position_embeddings, big.mark = ","),
    format(embedding_params, big.mark = ","),
    format(encoder_params, big.mark = ","),
    format(total_params, big.mark = ",")
  )
)

knitr::kable(bert_summary,
  format = "html", align = c("l", "r"),
  caption = "BERT-base のアーキテクチャ情報"
)
Table 1: BERT-base のアーキテクチャ情報
項目
語彙数 (vocab_size)30,522
埋め込み次元 (hidden_size)768
Encoder層数 (num_hidden_layers)12
Attention head数 (1層あたり)12
1 head あたりの次元64
Feed-forward層の中間次元3,072
最大入力トークン数512
埋め込み層のパラメータ数23,837,184
Encoder(12層)のパラメータ数85,054,464
モデル全体のパラメータ数109,514,298

語彙数(vocab_size): 30,522

BERTがWordPieceトークン化で扱える、単語・サブワードの総数です。

この数の中に、[CLS][SEP][MASK][PAD][UNK]のような特殊トークンも含まれています。

3万強という数は、英語の全単語をカバーするには少なく見えますが、“unbelievable” → “un” + “##believable”のようにサブワード単位に分解することで、多くの未知語をサブワード列として表現できるようになっています。

埋め込み次元(hidden_size): 768

各トークンを表すベクトルの次元数です。

「静的embedding(入力埋め込み)」も「文脈化embedding」も、どちらもこの768次元のベクトルとして表現されます。

この数値は、モデルの”表現力”の目安になり、BERT-large(1024次元)はこれより大きく、より複雑な情報を保持できます。

Encoder層数: 12

self-attention + feed-forward層のセットを12回重ねている、ということを示します。

層が深いほど、より抽象的・高次な文脈情報を段階的に構築できます。

「浅い層は構文的な関係、深い層は意味的な関係を捉えやすい」という一般的な傾向も、この12層構造の中で生まれています。

Attention head数: 12、1headあたりの次元: 64

1つのEncoder層の中で、12個のattentionが並列に計算されていることを示します。

\(768 = 12 × 64\)という関係になっており、各Attention Headは768次元の入力をそれぞれ異なる64次元空間へ線形写像した上で、独立にAttentionを計算します。最後に12個のHeadの出力を結合して768次元に戻します。

「主語と動詞の関係を捉えるhead」や「前置詞の対象(係り受け)を捉えるhead」のように、一部のheadは構文や照応など特定のパターンを学習することが観察されていますが、役割は固定ではなく、複数のheadが似た機能を担うこともあります。

Feed-forward層の中間次元: 3072

各Encoder層には、self-attentionに加えて、単語ごとに独立に処理する全結合層(feed-forward network)も含まれます。

\(3072 = 768 \times 4\)という関係になっており、一度768次元から3072次元へと拡張してから、再び768次元に戻す、という構造になっています。

この部分は、attentionで集めた情報を、さらに非線形に変換・加工する役割を担っています。

最大入力トークン数: 512

一度に処理できるトークン数の上限です。

学習可能な位置埋め込み(各トークンの「何番目か」という情報)が、この512個分しか事前に用意されていないため、それを超える長さの文章は、そのままでは入力できません。

長い文書を扱う場合は、分割する、あるいはLongformerのような別のモデルを使う、といった対処が必要になります。

パラメータ数の内訳

  • 埋め込み層: 約2,384万個

    • 単語埋め込み(\(30522 \times 768 \approx 2,344万\))に加え、位置埋め込み(\(512 \times 768\))や文タイプ埋め込み(\(2 \times 768\))などが含まれます。
  • Encoder(12層): 約8,505万個

    • 12層分のQuery/Key/Value/出力の重み行列、および各層のfeed-forward層(768↔︎3072の変換)の重みが主な内訳です。
  • モデル全体: 約1億951万個 (約110M)

    • 単純計算(埋め込み層+Encoder層)で約1億889万個となり、残り約60万個がBertForMaskedLMの予測ヘッド(LayerNormやバイアス項等)の分です。
    • 出力ヘッドの巨大な線形変換行列(768→30,522)には埋め込み層の重みがそのまま再利用(Weight Tying)されるため、パラメータ数が急増することなく約1.1億個に収まっています。

Masked Language Modelの実演

BertForMaskedLMを使い、“The capital of France is [MASK].”の[MASK]部分をBERTに予測させ、上位5候補とその確率を表示します。

softmax <- function(x) {
  x_shifted <- x - max(x)
  exp(x_shifted) / sum(exp(x_shifted))
}

get_top_predictions <- function(sentence, tokenizer, model, top_k = 5) {
  inputs <- tokenizer(sentence, return_tensors = "pt")

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

  logits <- outputs$logits$squeeze()$detach()$numpy() # (seq_len, vocab_size)

  token_ids <- inputs$input_ids$squeeze()$tolist()
  mask_pos <- which(unlist(token_ids) == tokenizer$mask_token_id)

  mask_logits <- logits[mask_pos, ]
  probs <- softmax(mask_logits)

  top_idx <- order(probs, decreasing = TRUE)[1:top_k]
  top_token_ids <- as.list(as.integer(top_idx - 1)) # Rの1-indexを Pythonの0-indexに戻す
  top_tokens <- unlist(tokenizer$convert_ids_to_tokens(top_token_ids))

  data.frame(token = top_tokens, probability = probs[top_idx])
}

sentence1 <- sprintf("The capital of France is %s.", tokenizer$mask_token)
cat(sprintf("=== MLM予測: \"%s\" ===\n", sentence1))
pred1 <- get_top_predictions(sentence1, tokenizer, model)
print(pred1)
=== MLM予測: "The capital of France is [MASK]." ===
      token probability
1     paris  0.41678833
2     lille  0.07141636
3      lyon  0.06339231
4 marseille  0.04444733
5     tours  0.03029704

上位予測: paris (41.7%)

“The capital of France is [MASK].”という文に対し、BERTは最も高い確率(約41.7%)で”paris”を予測しています。

BERTはこの文について明示的に「フランスの首都はパリだ」と教え込まれたわけではなく、大量のテキストを読む中で、Masked Language Modelという訓練を通じて、この関係性を統計的パターンとして自然に獲得しています。

2位以下: lille, lyon, marseille, tours(いずれもフランスの都市)

注目すべきは、2位以降の候補が”lille”(7.1%)、“lyon”(6.3%)、“marseille”(4.4%)、“tours”(3.0%)と、全てフランスの実在の都市名で占められている点です。

ロンドンや東京のような、フランス以外の都市は候補に出てきていません。

これは、BERTが単に「首都は何か」という事実を丸暗記しているだけでなく、“The capital of France is ___“という文の構造から、「フランスに存在する都市名」という文法的・意味的なカテゴリを正しく絞り込めていることを示しています。

つまり、正解(paris)を知らなかったとしても、少なくとも”荒唐無稽な単語”(例えば動詞や、全く無関係な国の都市名)を候補に挙げるようなことはなく、意味的に筋の通った候補群の中から予測している、ということです。

確率の絶対値について

1位のparisでも41.7%と、100%には遠く及びません。

これは、この文だけでは「パリ」以外の可能性(例えば、ある文脈次第では”lille”などの都市も文法的にはあり得る)も一定程度残っている、とモデルが判断していることを表しています。

BERTの予測は、常に「唯一の正解」を断定するのではなく、もっともらしさの度合いを表す確率分布として出力される、という点も、この数値からうかがえます。

双方向性(Bidirectionality)の効果を確認

“I went to the [MASK] to withdraw some cash.”という、右側の文脈(“お金を引き出す”)がないと意味が確定しない文を使います。

attention_maskを操作して[MASK]より右側の情報を意図的に隠した場合と、通常(双方向)の場合とで、“bank”と予測される確率がどう変わるかを比較します。

これにより、BERTが実際に右側の文脈を活用していることを具体的な数値で確認します。

sentence2 <- sprintf("I went to the %s to withdraw some cash.", tokenizer$mask_token)
cat(sprintf("=== 双方向性の確認: \"%s\" ===\n", sentence2))

inputs2 <- tokenizer(sentence2, return_tensors = "pt")
token_ids2 <- unlist(inputs2$input_ids$squeeze()$tolist())
mask_pos2 <- which(token_ids2 == tokenizer$mask_token_id)
seq_len2 <- length(token_ids2)

# --- 通常 (双方向): attention_mask は全て1のまま ---
attn_full <- inputs2$attention_mask

# --- 右側を隠す (擬似的な単方向化): [MASK]より後ろの
#     トークンへのattentionを0にする ---
attn_restricted_vec <- rep(1L, seq_len2)
if (mask_pos2 < seq_len2) {
  attn_restricted_vec[(mask_pos2 + 1):seq_len2] <- 0L
}
attn_restricted <- torch$tensor(list(as.integer(attn_restricted_vec)), dtype = torch$long)

run_mlm <- function(input_ids, attention_mask, mask_pos) {
  with(torch$no_grad(), {
    outputs <- model(input_ids = input_ids, attention_mask = attention_mask)
  })
  logits <- outputs$logits$squeeze()$detach()$numpy()
  softmax(logits[mask_pos, ])
}

probs_full <- run_mlm(inputs2$input_ids, attn_full, mask_pos2)
probs_restricted <- run_mlm(inputs2$input_ids, attn_restricted, mask_pos2)

bank_id <- tokenizer$convert_tokens_to_ids("bank")

cat("\n--- 通常(双方向、右側の文脈あり) ---\n")
top_full <- order(probs_full, decreasing = TRUE)[1:5]
print(data.frame(
  token = unlist(tokenizer$convert_ids_to_tokens(as.list(as.integer(top_full - 1)))),
  probability = probs_full[top_full]
))
cat(sprintf("'bank' に割り当てられた確率: %.4f\n", probs_full[bank_id + 1]))

cat("\n--- 右側の文脈を隠した場合(擬似的な単方向) ---\n")
top_restricted <- order(probs_restricted, decreasing = TRUE)[1:5]
print(data.frame(
  token = unlist(tokenizer$convert_ids_to_tokens(as.list(as.integer(top_restricted - 1)))),
  probability = probs_restricted[top_restricted]
))
cat(sprintf("'bank' に割り当てられた確率: %.4f\n", probs_restricted[bank_id + 1]))
=== 双方向性の確認: "I went to the [MASK] to withdraw some cash." ===

--- 通常(双方向、右側の文脈あり) ---
    token probability
1    bank 0.939929784
2   store 0.014431105
3     atm 0.006800272
4  office 0.003027452
5 counter 0.002636326
'bank' に割り当てられた確率: 0.9399

--- 右側の文脈を隠した場合(擬似的な単方向) ---
  token probability
1     " 0.033660974
2   the 0.016721579
3     . 0.015956820
4     ) 0.008887717
5     , 0.004931649
'bank' に割り当てられた確率: 0.0001

通常(双方向)の場合: "bank" が93.99%

“I went to the [MASK] to withdraw some cash.”という文全体を見せた場合、BERTは”bank”に93.99%という高い確率を割り当てています。

2位以下も”store”(1.4%)、“atm”(0.7%)、“office”(0.3%)、“counter”(0.3%)と、いずれも「お金を引き出せそうな場所・物」に関連する、意味的に筋の通った候補になっています。

“to withdraw some cash”という右側の文脈を、BERTが的確に活用できていることが分かります。

右側の文脈を隠した場合: "bank" はわずか0.01%

一方、[MASK]より右側へのattentionを断ち切ると、結果は大きく異なります。

"   0.034
the 0.017
.   0.016
)   0.009
,   0.005

上位候補が、意味のある単語ですらなく、句読点や記号(“、the、.、)、,)になっています

“bank”の確率は0.9399から0.0001へと、実に約9400分の1にまで激減しました。

以上です。