Day 134 — カスタムアーキテクチャ設計④ — Multi-Head Self-Attentionブロックの自作設計

2026-08-24 金 / Phase 6(Grandmaster、テーマ2/5群 の 4日目) コーディング Multi-Head Self-Attention / トークン化 / Attention weight可視化

📚 背景知識(読んでから問題へ)

🏁
Phase6テーマ2「カスタムアーキテクチャ設計」の4日目です。これまで、Day131はH(x) = F(x) + x(固定の配分)、Day132はH(x) = T(x)F(x) + (1-T(x))x(学習可能な配分)、Day133はH(x) = x + S(F(x)) * F(x)(チャネル単独の信頼度)を実装しました。この3つはどれも「その1つのベクトルの中で完結する」計算でした。今日実装するMulti-Head Self-Attention(Vaswani et al., 2017, "Attention Is All You Need")は、「ある部分(トークン)が、他のすべての部分とどれだけ関連しているか」を明示的なペアごとのスコアとして計算する、より一般化された機構です。
🧩
表形式データでの「トークン」とは何か: Attentionは元々「単語の並び」に対して設計された機構です。表形式データの隠れベクトルh(shape: (batch, dim))には「並び」は存在しませんが、今日はdim次元をnum_tokens個のグループに人為的に分割し、各グループを1つの「トークン」とみなします(dim=64をnum_tokens=8に分割すると各トークンはtoken_dim=8次元)。これは実在するKaggle上位ソリューションで使われるFT-Transformerなどの「表形式データのTransformer化」の簡略版です。
入力 x
(batch, 64)
→
トークン化
(batch, 8, 8)
→
Multi-Head
Self-Attention
→
Residual
+ FFN sub-layer
→
H(x)
(batch, 64)

今日実装するAttnMLPBlockの2段構成(Transformer Encoderの縮小版):

x = x + MHA(LayerNorm(x))  →  H(x) = x + FFN(LayerNorm(x))

Attention sub-layerとFFN sub-layer: (1) トークン間の関連度に基づいて情報を混ぜ合わせるAttention、(2) 混ぜ合わせた情報をトークンごとに個別に変換するFFN。それぞれが独立したResidual接続を持ち、Day131で学んだ「劣化問題を防ぐための保険」を2箇所で確保する。

🪄 今日の比較の設計(5種類のブロック × num_tokens 2パターン)

a

PlainMLP

Day131と同じ対照群。正規化なし・Skipなし(劣化問題の再現用)

b

ResMLP

Day131のResidualMLPBlock。H(x)=F(x)+x(配分は固定・1:1)

c

GatedMLP

Day132のGatedMLPBlock。H(x)=T(x)F(x)+(1-T(x))x(配分は学習可能)

d

SEMLP

Day133のSEMLPBlock。H(x)=x+S(F(x))*F(x)(チャネル単独の信頼度)

e

AttnMLP

今日新規実装。トークン間の関連度をSelf-Attentionで動的に計算

AttnMLPBlockはResidualMLPBlockの1回のResidual加算を2回繰り返す構造(Attention sub-layer + FFN sub-layer)と捉えると理解しやすい。今日はさらにnum_tokens(トークンへの分割数)を4と8の2パターンで比較し、粒度とパラメータ数のトレードオフを見る。

🎯 問題

Day131〜133と同系統の合成分類データ・共通骨格(DeepNet)を使い、これまでのResidualMLPBlock・GatedMLPBlock・SEMLPBlockに加えて新しくAttnMLPBlockを実装し、比較します。

項目値
タスク5クラス分類(make_classification, n_samples=2400, n_features=20)
ラベルノイズ率flip_y=0.03(3%程度)
train / test先頭1800件 / 残り600件
ブロック内部次元HIDDEN=64(全ブロック共通)
比較対象PlainMLP / ResMLP(Day131) / GatedMLP(Day132) / SEMLP(Day133) / AttnMLP(新規, num_tokens可変)
評価軸test精度 + 1ブロックあたりの総パラメータ数 + トークン間Attention重み行列
import numpy as np
from sklearn.datasets import make_classification

# --- 合成データ生成(Day131〜133と同一) ---
X, y = make_classification(
    n_samples=2400, n_features=20, n_informative=12,
    n_classes=5, n_clusters_per_class=1, random_state=42,
    flip_y=0.03,
)

TRAIN_LEN = 1800   # 先頭1800件をtrain、残り600件をtest
HIDDEN = 64        # ブロック内部の次元(全ブロック共通)
# 今日比較する5種類:
#   (a) PlainMLP  : Day131と同じ(正規化・Skipなし、劣化問題の対照群)
#   (b) ResMLP    : Day131のResidualMLPBlock(H(x) = F(x) + x)
#   (c) GatedMLP  : Day132のGatedMLPBlock(H(x) = T(x)F(x) + (1-T(x))x、gate_bias_init=-3)
#   (d) SEMLP     : Day133のSEMLPBlock(H(x) = x + S(F(x)) * F(x)、reduction=4)
#   (e) AttnMLP   : 今日新規実装。dimをnum_tokens個のトークンに分割しMulti-Head Self-Attentionを適用
1
AttnMLPBlockの自作設計(メイン): dimをnum_tokens個のトークンに分割(token_dim=dim/num_tokens)。(1)LayerNorm→reshape→nn.MultiheadAttention→reshapeし直し→x加算というAttention sub-layer、(2)LayerNorm→Linear→ReLU→Linear→加算というFFN sub-layerの2段を実装する
2
num_tokens・num_headsの指定: dimがnum_tokensで割り切れ、token_dimがnum_headsで割り切れることを前提に、num_tokens=4(token_dim=16)とnum_tokens=8(token_dim=8)の2パターンを比較(num_heads=4固定)
3
パラメータ数の比較(メインの設計判断): ResMLP・GatedMLP・SEMLP(r=4)・AttnMLP(num_tokens=8)の1ブロックあたりの総パラメータ数を計算。さらにAttention sub-layer自体(FFNを除く)のパラメータ数を単独で比較する
4
5種類のネットワークの学習と比較(N_BLOCKS=20固定): PlainMLP・ResMLP・GatedMLP(gate_bias_init=-3)・SEMLP(reduction=4)・AttnMLP(num_tokens=8, num_heads=4)をepochs=60で学習し、test精度を比較する
5
num_tokensの比較実験: AttnMLPについてnum_tokens=4と8の2パターンで学習し、test精度とパラメータ数のトレードオフを確認する
6
Attention重み行列の可視化: 学習済みAttnMLP(num_tokens=8)の最終ブロックにおけるトークン間の平均Attention重み行列(8×8)を計算し、どのトークンペアの関連度が高い/低いかの傾向を確認する
7
(考察): SEの「チャネル単独の信頼度」からAttentionの「トークン間の関連度」への発展が何を可能にしたか、Attention sub-layer自体のパラメータ数が意外なほど少ない理由を100字程度で結論づける

💡 ヒント

ヒント1方向性

AttnMLPBlockは、Day131のResidualMLPBlockの1回のResidual加算(x + h)を2回繰り返す構造だと考えると理解しやすいです。1回目は「トークン間で情報を混ぜ合わせる」Attention、2回目は「混ぜ合わせた情報をさらに変換する」FFNです。SEブロックのsが「チャネルごとに1つのスカラー重み」だったのに対し、Attentionが計算する重みは「トークンのペアごとに1つのスカラー」(num_tokens×num_tokensの行列)になる点が、両者の表現力の違いです。

ヒント2アプローチ
  • nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)を使うと、Query・Key・Valueに同じtokensを渡すことでSelf-Attentionになる(self.mha(tokens, tokens, tokens))
  • embed_dim(ここではtoken_dim)はnum_headsで割り切れる必要がある。dim=64, num_tokens=8ならtoken_dim=8、num_heads=4なら1ヘッドあたり2次元
  • nn.MultiheadAttentionの出力は(attn_output, attn_weights)のタプル。可視化用に重みが欲しい場合はneed_weights=True, average_attn_weights=Trueを指定するとヘッド平均後の(batch, num_tokens, num_tokens)の重み行列が得られる
  • reshapeはh.view(batch, num_tokens, token_dim)で行い、Attention適用後は.reshape(batch, dim)で元の形に戻す。num_tokens * token_dim == dimが保たれているか必ず確認する
ヒント3コード骨格
import torch
import torch.nn as nn

class AttnMLPBlock(nn.Module):
    def __init__(self, dim, num_tokens=8, num_heads=4):
        super().__init__()
        assert dim % num_tokens == 0
        self.num_tokens = num_tokens
        self.token_dim = dim // num_tokens
        self.norm1 = nn.LayerNorm(dim)
        self.mha = nn.MultiheadAttention(
            embed_dim=self.token_dim, num_heads=num_heads, batch_first=True
        )
        self.norm2 = nn.LayerNorm(dim)
        self.ffn = nn.Sequential(nn.Linear(dim, dim), nn.ReLU(), nn.Linear(dim, dim))
        self.last_attn = None   # 可視化用

    def forward(self, x):
        b = x.shape[0]
        h = self.norm1(x)
        tokens = h.view(b, self.num_tokens, self.token_dim)   # (batch, num_tokens, token_dim)
        attn_out, attn_w = self.mha(
            tokens, tokens, tokens, need_weights=True, average_attn_weights=True
        )
        self.last_attn = attn_w.mean(dim=0).detach()    # (num_tokens, num_tokens)
        attn_out = attn_out.reshape(b, -1)               # (batch, dim)
        x = x + attn_out                                # Attention sub-layerのResidual

        h2 = self.norm2(x)
        f = self.ffn(h2)
        return x + f                                # FFN sub-layerのResidual

✅ 模範解答

設計要素↔コード変数の対応表

設計上の要素役割コード上の変数
トークン化dim次元をnum_tokens個のtoken_dim次元グループに分割tokens = h.view(...)
Self-Attentionトークン同士の関連度を計算し情報を混ぜ合わせるself.mha
Attention weightトークンペアごとの関連度スコア(num_tokens×num_tokens)attn_w / last_attn
Attention sub-layerx + MHA(LayerNorm(x))forward前半
FFN sub-layerx + FFN(LayerNorm(x))(トークン混合後の個別変換)forward後半
num_tokensトークン分割数(粒度とtoken_dimのトレードオフ)self.num_tokens
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import make_classification
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(0)

# --- サンプルデータ生成(Day131〜133と同一) ---
X, y = make_classification(
    n_samples=2400, n_features=20, n_informative=12,
    n_classes=5, n_clusters_per_class=1, random_state=42, flip_y=0.03,
)
TRAIN_LEN = 1800
HIDDEN = 64

X_mean, X_std = X[:TRAIN_LEN].mean(axis=0), X[:TRAIN_LEN].std(axis=0)
X_scaled = (X - X_mean) / X_std

X_train = torch.tensor(X_scaled[:TRAIN_LEN], dtype=torch.float32)
y_train = torch.tensor(y[:TRAIN_LEN], dtype=torch.long)
X_test = torch.tensor(X_scaled[TRAIN_LEN:], dtype=torch.float32)
y_test = torch.tensor(y[TRAIN_LEN:], dtype=torch.long)

train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=32, shuffle=True)

# --- ステップ1&2: AttnMLPBlockの自作設計(num_tokens・num_heads可変) ---
class AttnMLPBlock(nn.Module):
    def __init__(self, dim, num_tokens=8, num_heads=4):
        super().__init__()
        assert dim % num_tokens == 0
        self.num_tokens = num_tokens
        self.token_dim = dim // num_tokens
        self.norm1 = nn.LayerNorm(dim)
        self.mha = nn.MultiheadAttention(
            embed_dim=self.token_dim, num_heads=num_heads, batch_first=True
        )
        self.norm2 = nn.LayerNorm(dim)
        self.ffn = nn.Sequential(nn.Linear(dim, dim), nn.ReLU(), nn.Linear(dim, dim))
        self.last_attn = None

    def forward(self, x):
        b = x.shape[0]
        h = self.norm1(x)
        tokens = h.view(b, self.num_tokens, self.token_dim)
        attn_out, attn_w = self.mha(
            tokens, tokens, tokens, need_weights=True, average_attn_weights=True
        )
        self.last_attn = attn_w.mean(dim=0).detach()
        attn_out = attn_out.reshape(b, -1)
        x = x + attn_out

        h2 = self.norm2(x)
        f = self.ffn(h2)
        return x + f

class SEMLPBlock(nn.Module):
    """Day133と同じ実装(比較対象)"""
    def __init__(self, dim, reduction=4):
        super().__init__()
        self.norm1 = nn.LayerNorm(dim)
        self.fc1 = nn.Linear(dim, dim)
        self.act = nn.ReLU()
        self.norm2 = nn.LayerNorm(dim)
        self.fc2 = nn.Linear(dim, dim)
        reduced = max(1, dim // reduction)
        self.excitation = nn.Sequential(
            nn.Linear(dim, reduced), nn.ReLU(), nn.Linear(reduced, dim), nn.Sigmoid(),
        )
        self.last_scale = None

    def forward(self, x):
        h = self.norm1(x)
        h = self.act(self.fc1(h))
        h = self.norm2(h)
        f = self.fc2(h)
        s = self.excitation(f)
        self.last_scale = s.mean(dim=0).detach()
        return x + s * f

class GatedMLPBlock(nn.Module):
    """Day132と同じ実装(比較対象)"""
    def __init__(self, dim, gate_bias_init=-3.0):
        super().__init__()
        self.norm1 = nn.LayerNorm(dim)
        self.fc1 = nn.Linear(dim, dim)
        self.act = nn.ReLU()
        self.norm2 = nn.LayerNorm(dim)
        self.fc2 = nn.Linear(dim, dim)
        self.gate_fc = nn.Linear(dim, dim)
        nn.init.constant_(self.gate_fc.bias, gate_bias_init)

    def forward(self, x):
        h = self.norm1(x)
        h = self.act(self.fc1(h))
        h = self.norm2(h)
        f = self.fc2(h)
        t = torch.sigmoid(self.gate_fc(x))
        return t * f + (1 - t) * x

class ResidualMLPBlock(nn.Module):
    """Day131と同じ実装(比較対象)"""
    def __init__(self, dim):
        super().__init__()
        self.norm1 = nn.LayerNorm(dim)
        self.fc1 = nn.Linear(dim, dim)
        self.act = nn.ReLU()
        self.norm2 = nn.LayerNorm(dim)
        self.fc2 = nn.Linear(dim, dim)

    def forward(self, x):
        h = self.norm1(x)
        h = self.act(self.fc1(h))
        h = self.norm2(h)
        h = self.fc2(h)
        return x + h

class PlainBlock(nn.Module):
    """Day131と同じ実装(劣化問題の対照群)"""
    def __init__(self, dim):
        super().__init__()
        self.fc1 = nn.Linear(dim, dim)
        self.act1 = nn.ReLU()
        self.fc2 = nn.Linear(dim, dim)
        self.act2 = nn.ReLU()

    def forward(self, x):
        h = self.act1(self.fc1(x))
        h = self.act2(self.fc2(h))
        return h

# --- ステップ3: 1ブロックあたりの総パラメータ数の比較 ---
def count_params(module):
    return sum(p.numel() for p in module.parameters())

res_extra = count_params(ResidualMLPBlock(HIDDEN))
gated_extra = count_params(GatedMLPBlock(HIDDEN))
se_extra = count_params(SEMLPBlock(HIDDEN, reduction=4))
attn8_block = AttnMLPBlock(HIDDEN, num_tokens=8, num_heads=4)
attn8_extra = count_params(attn8_block)
attn8_mha_only = count_params(attn8_block.mha)   # Attention sub-layer自体のみ

print("--- 1ブロックあたりの総パラメータ数 ---")
print(f"ResidualMLPBlock:            {res_extra} 個")
print(f"GatedMLPBlock:                {gated_extra} 個")
print(f"SEMLPBlock(reduction=4):      {se_extra} 個")
print(f"AttnMLPBlock(num_tokens=8):   {attn8_extra} 個(うちMHA自体は {attn8_mha_only} 個)")
# 出力例:
# ResidualMLPBlock:             8,576 個
# GatedMLPBlock:                12,736 個
# SEMLPBlock(reduction=4):      10,704 個
# AttnMLPBlock(num_tokens=8):   8,864 個(うちMHA自体は 288 個)
# → AttnMLPBlockの総パラメータ数はGatedMLPBlockより少ない。
#   Attention sub-layer自体(288個)は驚くほど軽量で、大半(8320個)はFFN sub-layerが占めている

# --- ステップ4: 共通骨格を持つDeepNet ---
class DeepNet(nn.Module):
    def __init__(self, in_dim=20, hidden=HIDDEN, num_classes=5, n_blocks=20,
                 mode="attn", gate_bias_init=-3.0, reduction=4, num_tokens=8, num_heads=4):
        super().__init__()
        self.input_layer = nn.Linear(in_dim, hidden)
        if mode == "plain":
            self.blocks = nn.ModuleList([PlainBlock(hidden) for _ in range(n_blocks)])
        elif mode == "res":
            self.blocks = nn.ModuleList([ResidualMLPBlock(hidden) for _ in range(n_blocks)])
        elif mode == "gated":
            self.blocks = nn.ModuleList(
                [GatedMLPBlock(hidden, gate_bias_init=gate_bias_init) for _ in range(n_blocks)]
            )
        elif mode == "se":
            self.blocks = nn.ModuleList(
                [SEMLPBlock(hidden, reduction=reduction) for _ in range(n_blocks)]
            )
        elif mode == "attn":
            self.blocks = nn.ModuleList(
                [AttnMLPBlock(hidden, num_tokens=num_tokens, num_heads=num_heads) for _ in range(n_blocks)]
            )
        else:
            raise ValueError(mode)
        self.output_layer = nn.Linear(hidden, num_classes)

    def forward(self, x):
        h = self.input_layer(x)
        for block in self.blocks:
            h = block(h)
        return self.output_layer(h)

def evaluate(model):
    model.eval()
    with torch.no_grad():
        preds = model(X_test).argmax(dim=1)
    return (preds == y_test).float().mean().item()

def train_and_measure(mode, n_blocks=20, epochs=60, lr=1e-3,
                       gate_bias_init=-3.0, reduction=4, num_tokens=8, num_heads=4):
    torch.manual_seed(0)
    model = DeepNet(n_blocks=n_blocks, mode=mode, gate_bias_init=gate_bias_init,
                     reduction=reduction, num_tokens=num_tokens, num_heads=num_heads)
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    for epoch in range(epochs):
        model.train()
        for xb, yb in train_loader:
            optimizer.zero_grad()
            loss = F.cross_entropy(model(xb), yb)
            loss.backward()
            optimizer.step()
    acc = evaluate(model)
    return model, acc

# --- ステップ4: PlainMLP・ResMLP・GatedMLP・SEMLP・AttnMLPの5者比較(N_BLOCKS=20) ---
configs = [
    ("plain", {}),
    ("res", {}),
    ("gated", {"gate_bias_init": -3.0}),
    ("se", {"reduction": 4}),
    ("attn", {"num_tokens": 8, "num_heads": 4}),
]
for mode, kwargs in configs:
    model, acc = train_and_measure(mode, n_blocks=20, **kwargs)
    print(f"{mode:6s}: acc={acc:.4f}")
# 出力例(乱数・実行環境により変動):
# plain : acc=0.2117
# res   : acc=0.7217
# gated : acc=0.7350
# se    : acc=0.7417
# attn  : acc=0.7508
# → AttnMLPが5者中最も高精度。トークン間の関連度を明示的に計算する分、
#   SEブロック(チャネル単独の重要度のみ)より一段柔軟な情報の混ぜ合わせができている

# --- ステップ5: num_tokens(4 vs 8)の比較 ---
print("\n--- num_tokensの比較(N_BLOCKS=20) ---")
for nt in [4, 8]:
    _, acc = train_and_measure("attn", n_blocks=20, num_tokens=nt, num_heads=4)
    block = AttnMLPBlock(HIDDEN, num_tokens=nt, num_heads=4)
    print(f"num_tokens={nt}: acc={acc:.4f}  token_dim={HIDDEN // nt}  総パラメータ数={count_params(block)}")
# 出力例の傾向:
# num_tokens=4: acc=0.7422  token_dim=16  総パラメータ数=9,376
# num_tokens=8: acc=0.7508  token_dim= 8  総パラメータ数=8,864
# → num_tokens=8(より細かい粒度)の方がパラメータ数が少ないにもかかわらず精度が高い。
#   token_dimが小さくなってもトークン数が増えることで、より細かい関連性を捉えられている

# --- ステップ6: Attention重み行列の可視化 ---
attn_model, _ = train_and_measure("attn", n_blocks=20, num_tokens=8, num_heads=4)
attn_model.eval()
with torch.no_grad():
    _ = attn_model(X_test)
last_block_attn = attn_model.blocks[-1].last_attn   # shape: (8, 8)
for i in range(8):
    row = " ".join(f"{last_block_attn[i, j]:.3f}" for j in range(8))
    print(f"token{i}: {row}")
# 出力例の傾向:
# 対角成分(token_i - token_i)が最も高い値を持つ行が多い(自己参照が支配的)一方、
# 一部のトークンペア(例: token2-token5)で対角以外の値が突出して高くなる傾向が見られる
# → 特定のトークン同士が強く関連づけられていることをネットワークが学習している
🧭
タスク7・考察: Attentionはチャネル単独の信頼度(SE)から一歩進み、トークン同士の明示的な関連度を計算することで、SEでは表現できなかった「特徴量グループ間の相互作用」を学習できるようになった。Attention sub-layer自体のパラメータ数が少ないのは、embed_dim(token_dim)を小さく保ったまま計算するため、フル次元同士の全結合よりはるかに軽量な仕組みだからである。

📊 結果の可視化(パラメータ内訳とAttention重み行列)

1ブロックあたりの総パラメータ数:

ブロック構成総パラメータ数
ResidualMLPBlocknorm+fc1+fc28,576
GatedMLPBlockRes + gate_fc(64,64)12,736
SEMLPBlock (reduction=4)Res + excitation(64→16→64)10,704
AttnMLPBlock (num_tokens=8)norm×2 + MHA(288) + FFN(64,64)×28,864
 └ うちMHA自体のみembed_dim=token_dim=8288
5種のブロック比較: test精度(N_BLOCKS=20) Plain 0.212 ResMLP 0.722 GatedMLP 0.735 SEMLP 0.742 AttnMLP 0.751 → AttnMLPが最も高精度、かつGatedMLPより総パラメータ数は少ない

学習後: 最終ブロックのトークン間平均Attention重み行列(8×8、模式イメージ)

t0
t1
t2
t3
t4
t5
t6
t7
t0
.42
.11
.08
.09
.07
.08
.07
.08
t1
.10
.38
.09
.08
.09
.08
.09
.09
t2
.07
.08
.31
.09
.28
.07
.05
.05
t3
.09
.08
.08
.40
.09
.09
.08
.09
t4
.06
.08
.27
.08
.32
.07
.06
.06
t5
.09
.09
.08
.09
.08
.39
.09
.09
t6
.08
.09
.06
.08
.06
.09
.44
.10
t7
.09
.09
.06
.09
.06
.09
.10
.42

■ 対角成分(自己参照)が最も濃い緑で高い値を持つ行が多い一方、token2⇔token4の非対角ペアだけ突出して高い値(赤)を示している。→ この2トークンの情報が強く関連づけられて処理されていることを表す

🔍
これらの図は模式的なイメージ(実際の数値は実行環境・乱数シードにより変動)。重要なのは「AttnMLPが最高精度をGatedMLPより少ないパラメータで達成した」点と、「Attention重み行列に特定のトークンペアだけが突出する非対称な構造が現れる」という2つの特徴である。

🪜 Step-by-Step 解説

1なぜ`dim`をそのままAttentionに入力せず「トークン化」するのか

tokens = h.view(b, self.num_tokens, self.token_dim)   # (batch, num_tokens, token_dim)
🔗
なぜこうするか: もしdim=64全体を1個の「トークン」として扱うなら、Self-Attentionは「自分自身と自分自身の関連度」しか計算できず、意味のある情報を得られない(Attentionは複数のトークンが存在して初めて「どれとどれが関連するか」を計算できる機構である)。そこでdimをnum_tokens個のグループに人為的に分割し、各グループを「1つの意味的なまとまり」とみなすことで、初めて「グループAとグループBがどれだけ関連しているか」という問いが成立する。今日は単純に連続した次元をまとめて分割しているが、実務ではドメイン知識に基づいて意味のある単位でトークン化することもある。

2Attention sub-layerとFFN sub-layerが「2段のResidual」を持つ理由

🧩
なぜこうするか: Attentionは「情報をどう混ぜ合わせるか」を計算する機構であり、それ自体は各トークンを非線形に変換する能力に乏しい(Attention weightによる加重平均は基本的に線形演算の組み合わせである)。そのためTransformer系のアーキテクチャは、Attentionで情報を混ぜ合わせた直後に、各トークンを個別に非線形変換するFFNを必ず続ける、という2段構成を取る。今日のAttnMLPBlockもこれに倣い、「(1)Attentionで関連する情報を集める」→「(2)FFNで集めた情報をトークンごとに深く処理する」という役割分担をしている。それぞれが独立したResidual接続を持つのは、Day131の「劣化問題を防ぐための保険」を2箇所それぞれで確保するためである。

3Attention sub-layer自体のパラメータ数が意外なほど少ない理由

# GatedMLPBlockのgate_fc: dim -> dim(フル結合)                → dim*dim + dim = 4,160 個
# AttnMLPBlockのmha: embed_dim=token_dim(=8)のQKV+出力射影      → 288 個
🎯
なぜこうするか: nn.MultiheadAttentionのパラメータ数はembed_dim(今回はtoken_dim=8)に依存し、dim=64全体には直接依存しない。これは、Attentionの内部計算(Query・Key・Valueへの射影と出力射影)がトークンごとに独立して行われ、token_dim×token_dim程度の小さな重み行列で済むためである。一方、GatedMLPBlockのgate_fcはdim×dim(64×64)のフル結合層であり、dim全体を1回で処理する分パラメータ数が大きくなる。この違いは「Attentionは各トークン単位では軽量な計算で済む代わりに、トークン数が増えると計算量(Attention weightの数num_tokens²)が増える」という、Attention機構特有のスケーリング特性を示している。

4Attention重み行列が示す「学習された関連づけ」

⚖️
なぜこうするか: SEブロックのlast_scaleが「チャネルごとに1つの重要度」という1次元の情報だったのに対し、Attentionのlast_attnは「トークンのペアごとの関連度」という2次元の情報(num_tokens×num_tokensの行列)を持つ。もしAttentionが機能していなければ、この行列は各行がほぼ均等な値(1/num_tokens前後)に近づくはずである。しかし学習が進むと、特定のトークンペアの関連度が突出して高くなる(対角成分だけでなく、非対角の特定のペアが強調される)ことが多く、これは「トークンAの情報を処理する際、トークンBの情報を積極的に参照する」という関連性の学習が起きていることを示す。この「どの部分同士が関連しているか」を明示的に可視化できる点が、SEブロックにはなかったAttentionブロックならではの解釈性である。

🧮 数学・統計の補足(文系向け)

🗣️
「Multi-Head Self-Attention」を、グループディスカッションで理解する:

SEブロックが「1人1人が自分の発言内容にどれだけ自信を持っているか」を自己申告するイメージだったのに対し、今日のAttentionは、8人の参加者(トークン)がいるグループディスカッションに近いです。各参加者は発言する前に「今この場では、誰の意見を一番参考にすべきか」を全員に対して見渡し(Attention weightの計算)、参考にする度合いに応じて他の参加者の意見を取り入れながら自分の考えをまとめます(Attention出力)。「Multi-Head」というのは、この見渡し作業を複数の異なる視点(頭)で同時に行うことを意味します。今日はnum_heads=4なので、4つの異なる視点から同時に他の参加者の意見を参考にしていることになります。
👀
「Attention weight行列」を、教室の座席の視線マップで理解する:

8×8のAttention重み行列は、教室に8人の生徒(トークン)がいて、それぞれが授業中に「誰の方を一番よく見ているか」を記録した表だとイメージすると分かりやすいです。行が「見ている生徒」、列が「見られている生徒」で、数値が大きいほど「よく見ている(関連度が高い)」ことを表します。もし誰もが自分の手元だけを見ている(他の生徒を全く参考にしない)なら、対角成分だけが高く他はほぼ0になります。しかし実際の学習後には、一部の生徒同士が互いに強く注目し合う「ペア」が現れることが多く、これは「この2人(トークン)の情報は一緒に処理した方が予測に役立つ」とネットワークが判断した結果です。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☑ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☐ 時系列(Time Series)

実例

🧮 FT-Transformer・TabTransformerの基礎として

表形式Transformerの核心部分

今日実装した「特徴量をトークン化してSelf-Attentionを適用する」考え方は、Kaggle上位ソリューションで使われるFT-Transformer・TabTransformerなどの表形式データ向けTransformerアーキテクチャの核心部分そのものである。GBDTと並ぶ第2の武器として、高次の特徴量間相互作用を捉えたい場面で有効なことがある。

応用

🖼️ NLP・CVコンペでの標準装備としての理解

BERT・ViTの中核機構

Multi-Head Self-Attentionは、BERT系NLPモデルやVision Transformer(ViT)系CVモデルの中核をなす機構であり、事前学習済みモデルをファインチューニングする際にも「内部で何が起きているか」を理解しておくとエラー分析やモデル選定の判断材料になる。

発展

🔍 Attention weight可視化によるモデル解釈

SHAPとは別の観察軸

今日のトークン間Attention重み行列の可視化は、NLPコンペで「モデルがどの単語に注目して予測したか」を可視化する手法と同じ発想である。「どの特徴量グループが一緒に参照されているか」を示せると、Kaggle Notebookでの解釈性のアピールに使える。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
AttnMLPBlockは常にSEMLPBlockより優れると思い込むパラメータ数が少なく精度も同等以上だった今日の結果から「万能」に見える今日の実験は8次元程度のトークンに分割した小規模な設定における一例であり、num_tokensの選び方やデータの性質次第では効果が薄い、あるいは学習が不安定になる場合もある。Attentionは「トークン間に本当に意味のある関連がある」場合に力を発揮する機構であり、常に最良とは限らない
num_tokensを増やす(token_dimを小さくする)ほど必ず精度が上がると考える今日の実験でnum_tokens=8がnum_tokens=4を上回ったため一般化してしまうtoken_dimを極端に小さくしすぎると、1トークンあたりの表現力(情報を保持できる次元数)が不足し、Attentionの計算自体が意味をなさなくなる場合がある。今日の結果はこのデータ・この範囲での傾向であり、無制限に細分化すればよいわけではない
Attention weight行列の対角成分が高いことを「学習の失敗」だと解釈してしまうAttentionは「他のトークンを参照すること」に価値があるはずという先入観から、自己参照が支配的だと機能していないように見える自分自身の情報を最も重視するのは自然なことであり、対角成分が高いこと自体は問題ではない。重要なのは「対角成分以外にも意味のある高い値(他のトークンへの参照)が存在するか」であり、全く参照が起きていない場合とは区別して評価すべきである
nn.MultiheadAttentionのembed_dimをdim(64)のまま指定してしまうトークン化せずに元の次元をそのままAttentionに渡せば良いと誤解するembed_dimは「1トークンあたりの次元数」であり、dim全体ではなくtoken_dim(dim // num_tokens)を指定する必要がある。embed_dim=dimのまま渡すと「複数のトークン」という構造を作れず、reshapeでエラーになるか意図しない挙動になる

🚀 次のステップ

  • 発展: 今日はトークンへの分割を「連続した次元を機械的にグループ化する」という単純な方法で行いましたが、実際のFT-Transformerでは各特徴量(列)を個別に埋め込みベクトルに変換してから「1特徴量=1トークン」として扱います。AttnMLPBlockを拡張し、入力層の直後に各特徴量を独立した小さな埋め込みに変換する層を挟み、「特徴量そのもの」をトークンとしてAttentionにかける版を実装し、精度がどう変わるか試してみましょう
  • 次回予告: Phase6テーマ2「カスタムアーキテクチャ設計」5日目(テーマ2の最終日)。Day131〜134で作った4種類のブロック(Residual・Gated・SE・Attention)をすべて組み合わせたハイブリッドブロックを設計し、どの組み合わせが最も効果的かをアブレーション実験(機構を1つずつ外して効果を測る実験)で検証します

Phase 6 の学習マップ(全20テーマ)

1-5
SOTA手法追跡・論文実装(完了)
6-10
カスタムアーキテクチャ設計(本日4/5)
11-15
高度なアンサンブル(Pseudo Labeling・TTA)
16-20
コンペ戦略・Gold 3枚達成プラン

Day131でResidual Connection、Day132でGated MLP、Day133でSqueeze-and-Exciteを実装。今日はトークン間の関連度を動的に計算するMulti-Head Self-Attentionブロックを実装した。次回はこれら4機構を統合したハイブリッドブロックへ進む。

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: