RでTransformerのAttention機構:Softmax

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

Softmaxとは

Softmaxとは、任意の実数のベクトルを、合計が1になる「確率分布」に変換する関数です。

機械学習において、モデルの出力を「確率」として解釈したい場面(分類問題で各クラスに属する確率を出したい、など)で広く使われます。

数式は次の通りです。

\[\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}}\]

つまり「各要素を\(e\)のべき乗にしてから、全体の合計で割る(正規化する)」という操作です。

一見、\(x_i / \sum_j x_j\) のように単純に合計で割るだけでも「合計が1になる分布」は作れそうです。

しかし、これには以下の問題が生じます。

  • 入力に負の数が含まれると、負の確率が出てきてしまい、確率として成立しない
  • 入力全体の水準(スケール)に結果が左右されてしまい、同じ相対的な差を持つ入力でも、水準次第で確率差が大きく見えたり、ほとんど消えてしまったりする。

例えば、差はどちらも「1」の場合、

入力A: c(1, 2, 3)      (差は常に1)
入力B: c(101, 102, 103) (差は常に1。Aに100を加えたのみ)

単純な正規化(x / sum(x))では、

Aの結果: 1/6=0.167,  2/6=0.333,  3/6=0.500   → 差がはっきり見える
Bの結果: 101/306=0.330, 102/306=0.333, 103/306=0.336  → ほぼ均等になり、差が消えてしまう

つまり、同じ「差が1」という情報のはずなのに、Bのように全体の水準が大きいだけで、確率としてはほとんど差がないように見えてしまいます。

\(e^x\)(指数関数)を使うことで、

  1. どんな実数でも必ず正の値になる、
  2. 値の差が指数的に強調される(大きい値はより大きく、小さい値はより小さく評価される)、

という2つの性質が同時に得られます。

また、\(e^x\)\(x\)が大きいとすぐに巨大な数になってしまい、浮動小数点演算で「無限大(Inf)」になってしまうことがあります。

その回避のため、「各値から最大値を引いてから\(e^x\)を計算する」という工夫をします。

これは数学的にsoftmaxの結果を一切変えない(shift invariance、シフト不変性)ことが証明できる操作です。

Part 1: Softmax の定義

\[\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}}\] 「どんな実数ベクトルも、合計が1になる正の値の並び(= 確率分布) に変換する」関数です。

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

scores <- c(2.0, 1.0, 0.5, -1.0)
probs <- softmax(scores)

cat("=== 入力 (スコア) ===\n")
print(scores)
cat("\n=== Softmax適用後 (確率) ===\n")
print(round(probs, 4))
cat(sprintf("\n合計 (必ず1になる): %.6f\n", sum(probs)))
=== 入力 (スコア) ===
[1]  2.0  1.0  0.5 -1.0

=== Softmax適用後 (確率) ===
[1] 0.6095 0.2242 0.1360 0.0303

合計 (必ず1になる): 1.000000

全ての値が正になり、合計が1になっています。

また、元のスコアの大小関係 (2.0 > 1.0 > 0.5 > -1.0) が、確率の大小関係にもそのまま保たれています(単調性)。

Part 2: 単純な正規化(合計で割るだけ)では問題の生じる理由

# 単純な正規化: 各値を合計で割るだけ
plain_normalize <- function(x) {
  x / sum(x)
}

cat("=== 単純な正規化 vs Softmax の比較 ===\n")

# 負の値を含む例
scores_with_negative <- c(3.0, -2.0, 1.0)

cat("入力:", scores_with_negative, "\n")
cat(
  "単純な正規化:", round(plain_normalize(scores_with_negative), 4),
  " <- 負の「確率」が出てしまう\n"
)
cat(
  "Softmax:    ", round(softmax(scores_with_negative), 4),
  " <- 全て正の値で合計1\n"
)

# 値の差の強調具合を比較する例
#
# 以下の2つを意図的に対比させています:
#   scores_close: 差が小さく(0.1刻み)、値自体も小さい
#     -> e^x はxが小さい範囲ではテイラー展開 e^x ≈ 1+x により
#        ほぼ線形になるため、単純な正規化とSoftmaxの結果は
#        ほとんど一致する (指数関数の非線形性がまだ効かない)
#   scores_far: 差が大きい(4刻み)
#     -> e^x の非線形性がはっきり効き、Softmaxは単純な正規化
#        よりも明確に「勝者(最大値)」に確率を強く寄せる
#
# つまりSoftmaxが単純な正規化と違う結果を生むのは、あくまで
# 「値の差が大きい」場合に限られます

scores_close <- c(1.0, 1.1, 1.2) # わずかな差
scores_far <- c(1.0, 5.0, 9.0) # 大きな差

cat("\n差が小さい入力:", scores_close, "\n")
cat("  単純な正規化:", round(plain_normalize(scores_close), 4), "\n")
cat("  Softmax:    ", round(softmax(scores_close), 4), "\n")

cat("\n差が大きい入力:", scores_far, "\n")
cat("  単純な正規化:", round(plain_normalize(scores_far), 4), "\n")
cat("  Softmax:    ", round(softmax(scores_far), 4), "\n")
=== 単純な正規化 vs Softmax の比較 ===
入力: 3 -2 1 
単純な正規化: 1.5 -1 0.5  <- 負の「確率」が出てしまう
Softmax:     0.8756 0.0059 0.1185  <- 全て正の値で合計1

差が小さい入力: 1 1.1 1.2 
  単純な正規化: 0.303 0.3333 0.3636 
  Softmax:     0.3006 0.3322 0.3672 

差が大きい入力: 1 5 9 
  単純な正規化: 0.0667 0.3333 0.6 
  Softmax:     3e-04 0.018 0.9817 

Softmax は、単純な正規化の場合よりも、入力の差が大きいほど、確率の差もより極端に強調する傾向が確認できます。

Part 3: シフト不変性 (Shift Invariance)

全ての入力に同じ定数を足しても、Softmaxの出力は変わらない、という性質です。

これは \(\frac{e^{x_i + c}}{\displaystyle\sum_j e^{x_j + c}}\) を展開すると\(e^c\) が分子分母で約分されて消えることから数学的に導けます。

\[
\text{softmax}(x_i + c) = \frac{e^{x_i + c}}{\displaystyle\sum_j e^{x_j + c}}
\]

まず分子を指数法則 \(e^{a+b} = e^a e^b\) を使って分解します。

\[
= \frac{e^{x_i}\, e^{c}}{\displaystyle\sum_j e^{x_j + c}}
\]

分母についても、各項\(e^{x_j+c}\)を同様に分解します。\(e^c\)\(j\)に依存しない定数なので、和の外に出せます。

\[
\sum_j e^{x_j + c} = \sum_j e^{x_j} e^{c} = e^{c} \sum_j e^{x_j}
\]

これを代入すると、

\[
\text{softmax}(x_i + c) = \frac{e^{x_i}\, e^{c}}{e^{c} \displaystyle\sum_j e^{x_j}}
\]

分子・分母に共通する\(e^c\)を約分すると、

\[
= \frac{e^{x_i}}{\displaystyle\sum_j e^{x_j}} = \text{softmax}(x_i)
\]

すなわち、

\[
\text{softmax}(x_i + c) = \text{softmax}(x_i) \quad \text{for any constant } c
\]

が成り立ちます。

これが「シフト不変性」の数学的な根拠です。

\(c = -\displaystyle\max_k x_k\) を選ぶことで、指数計算前の最大値を必ず0以下に抑えられるため、結果はまったく変えずにexp()のオーバーフローを防ぐことが出来ます。

x <- c(2.0, 1.0, 0.5, -1.0)
x_shifted <- x + 100 # 全体に定数100を加える

cat("=== シフト不変性の確認 ===\n")
cat("元の入力の softmax:        ", round(softmax(x), 6), "\n")
cat("+100 した入力の softmax:   ", round(softmax(x_shifted), 6), "\n")
=== シフト不変性の確認 ===
元の入力の softmax:         0.60946 0.224208 0.135989 0.030343 
+100 した入力の softmax:    0.60946 0.224208 0.135989 0.030343 

Part 4: オーバーフロー問題と、その対策

exp(x) は x が大きいとすぐに天文学的な数値になり、表現できる範囲を超えてしまいます (Inf になります)。

Part 3 のシフト不変性を利用し、「計算前に最大値を引く」ことで、数学的な結果を変えずにオーバーフローを防ぐことが出来ます。

x_large <- c(1000, 999, 998)

cat("=== オーバーフロー問題の実演 ===\n")

naive_softmax <- function(x) {
  exp(x) / sum(exp(x)) # 対策なし
}

cat("対策なしの softmax:\n")
result_naive <- naive_softmax(x_large)
print(result_naive) # NaN になる (Inf / Inf = NaN)

# 数値的に安定な softmax: 計算前に最大値を引く
stable_softmax <- function(x) {
  x_shifted <- x - max(x) # 最大値を0にする (シフト不変性より結果は不変)
  exp(x_shifted) / sum(exp(x_shifted))
}

cat("\n数値的に安定な softmax (最大値を引いてから計算):\n")
result_stable <- stable_softmax(x_large)
print(round(result_stable, 4))
=== オーバーフロー問題の実演 ===
対策なしの softmax:
[1] NaN NaN NaN

数値的に安定な softmax (最大値を引いてから計算):
[1] 0.6652 0.2447 0.0900

Part 5: 温度 (Temperature) パラメータの効果

softmax(x / T) のように、入力全体を定数 T (温度) で割ってからsoftmax を適用することがあります。

  • T が小さい(<1) -> 分布がより「尖る」(一強になりやすい)
  • T が大きい(>1) -> 分布がより「均される」(平坦になりやすい)
  • T -> 0 -> 最大値だけが1、他は0 (argmaxに近づく)
  • T -> 無限大 -> 完全に一様分布に近づく
softmax_with_temperature <- function(x, T = 1) {
  softmax(x / T)
}

base_scores <- c(3.0, 1.0, 0.2, -1.0)
temperatures <- c(0.3, 1.0, 3.0, 10.0)

temp_results <- lapply(temperatures, function(T) {
  data.frame(
    category = factor(seq_along(base_scores)),
    probability = softmax_with_temperature(base_scores, T),
    temperature = paste0("T = ", T)
  )
})
temp_df <- do.call(rbind, temp_results)
temp_df$temperature <- factor(temp_df$temperature, levels = paste0("T = ", temperatures))

cat("=== 温度による分布の変化 ===\n")
for (T in temperatures) {
  cat(sprintf(
    "T = %-5s : %s\n", T,
    paste(round(softmax_with_temperature(base_scores, T), 3), collapse = "  ")
  ))
}
=== 温度による分布の変化 ===
T = 0.3   : 0.999  0.001  0  0
T = 1     : 0.823  0.111  0.05  0.015
T = 3     : 0.461  0.237  0.181  0.121
T = 10    : 0.308  0.252  0.233  0.207

Part 6: ggplot2 による可視化

library(ggplot2)

# --- 図1: 基本のSoftmax変換 (スコア -> 確率) ---
plot_df1 <- data.frame(
  category = factor(seq_along(scores)),
  value = c(scores, probs),
  type = rep(c("元のスコア", "Softmax後の確率"), each = length(scores))
)
plot_df1$type <- factor(plot_df1$type, levels = c("元のスコア", "Softmax後の確率"))

p1 <- ggplot(plot_df1, aes(x = category, y = value, fill = category)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = round(value, 2)), vjust = -0.5, size = 3.5) +
  facet_wrap(~type, scales = "free_y") +
  labs(
    title = "Softmax: スコアを確率分布に変換する",
    x = "カテゴリ", y = "値"
  ) +
  theme_minimal(base_size = 12)

print(p1)

# --- 図2: 単純な正規化 vs Softmax (差の強調具合の比較) ---
compare_df <- data.frame(
  category = factor(rep(seq_along(scores_far), 2)),
  probability = c(plain_normalize(scores_far), softmax(scores_far)),
  method = rep(c("単純な正規化 (x/sum(x))", "Softmax"), each = length(scores_far))
)

p2 <- ggplot(compare_df, aes(x = category, y = probability, fill = method)) +
  geom_col(position = "dodge") +
  geom_text(aes(label = round(probability, 3)),
    position = position_dodge(width = 0.9), vjust = -0.5, size = 3.2
  ) +
  labs(
    title = "単純な正規化 vs Softmax",
    subtitle = paste("入力:", paste(scores_far, collapse = ", ")),
    x = "カテゴリ", y = "確率", fill = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")

print(p2)

# --- 図3: 温度パラメータによる分布の変化 ---
p3 <- ggplot(temp_df, aes(x = category, y = probability, fill = category)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = round(probability, 2)), vjust = -0.5, size = 3) +
  facet_wrap(~temperature, nrow = 1) +
  labs(
    title = "温度 (Temperature) による Softmax 分布の変化",
    subtitle = paste("元のスコア:", paste(base_scores, collapse = ", ")),
    x = "カテゴリ", y = "確率"
  ) +
  theme_minimal(base_size = 12) +
  coord_cartesian(ylim = c(0, 1))

print(p3)
Figure 1
Figure 2
Figure 3

以上です。