📚 背景知識(読んでから問題へ)
H(x) = F(x) + xというResidual Connectionを、Day132では配分比率を学習可能にしたH(x) = T(x)F(x) + (1-T(x))xというGated MLPを実装しました。この2つはどちらも「F(x)をどれくらい足すか」という配分の話でした。今日はまた違う角度から、「変換結果F(x)の各チャネルをどれくらい信じるか」を学習するSqueeze-and-Excitation(SE)ブロック(Hu et al., 2018、CVPR 2018 Best Paper Award)を実装します。dim→dim/r→dim
+sigmoid
今日実装するSqueeze-and-Excitation(簡略版)の核心の数式:
H(x) = x + S(F(x)) ⊙ F(x)
ボトルネック構造とreduction比率: Excitationの2層ネットワークはdim → dim/r → dimという形で一度圧縮してから元の次元に戻す。Day132のGated MLPが独立したdim×dimのフル結合層でゲートを計算したのに対し、SEブロックはパラメータ数を絞りながらチャネル別重要度を計算する設計思想の違いがある。
🪄 今日の比較の設計(4種類のブロック × reduction 2パターン)
PlainMLP
Day131と同じ対照群。正規化なし・Skipなし(劣化問題の再現用)
ResMLP
Day131のResidualMLPBlock。H(x)=F(x)+x(配分は固定・1:1)
GatedMLP
Day132のGatedMLPBlock。H(x)=T(x)F(x)+(1-T(x))x(配分は学習可能)
SEMLP
今日新規実装。H(x)=x+S(F(x))*F(x)(チャネル別の信頼度を学習)
SEMLPBlockはResidualMLPBlockのF(x)部分をそのまま流用し、return x + hをreturn x + s * hに置き換えたものと捉えると理解しやすい。今日はさらにreduction(ボトルネックの圧縮率)を4と16の2パターンで比較し、パラメータ数と精度のトレードオフを見る。
🎯 問題
Day131・Day132と同系統の合成分類データ・共通骨格(DeepNet)を使い、これまでのResidualMLPBlock・GatedMLPBlockに加えて新しくSEMLPBlockを実装し、比較します。
| 項目 | 値 |
|---|---|
| タスク | 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(新規, reduction可変) |
| 評価軸 | test精度 + 1ブロックあたりの追加パラメータ数 + チャネル別平均重要度 |
import numpy as np from sklearn.datasets import make_classification # --- 合成データ生成(Day131・Day132と同一) --- 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 # ブロック内部の次元(全ブロック共通) # 今日比較する4種類: # (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 : 今日新規実装。H(x) = x + S(F(x)) * F(x)(S(F(x))はチャネルごとの重要度スコア)
💡 ヒント
SEMLPBlockは、Day131のResidualMLPBlockのreturn x + hという行を、return x + s * hに置き換えたものだと考えると理解しやすいです(hはF(x)の意味)。Day132のGatedMLPBlockが「xとF(x)のどちらをどれくらい使うか」という混合比率を学習したのに対し、SEMLPBlockは「F(x)の中の各チャネルを、そのままxに足す前にどれくらい弱める/強めるか」というF(x)自体へのフィルタを学習する点が違います。sはt(Day132のゲート)と似た0〜1のTensorですが、役割は「配分」ではなく「重要度によるスケーリング」です。
- Excitationのボトルネックは
nn.Sequential(nn.Linear(dim, reduced), nn.ReLU(), nn.Linear(reduced, dim), nn.Sigmoid())のように書くと簡潔にまとまる reduced = max(1, dim // reduction)のように、reductionが大きすぎて次元が0になることを防ぐmax(1, ...)を忘れないこと- パラメータ数を数えるには
sum(p.numel() for p in module.parameters())を使う。GatedMLPBlockのgate_fcはdim*dim + dim個、SEMLPBlockのExcitationは(dim*reduced+reduced) + (reduced*dim+dim)個 - チャネル重要度を集計するには、SEMLPBlockに「直前のforwardで計算したsをチャネルごとに保持する属性(例: self.last_scale)」を持たせておくと集計しやすい
import torch import torch.nn as nn class SEMLPBlock(nn.Module): 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) # F(x) s = self.excitation(f) # チャネルごとの重要度スコア(0〜1) self.last_scale = s.mean(dim=0).detach() return x + s * f # Residual + チャネル別重み付けF(x)
✅ 模範解答
設計要素↔コード変数の対応表
| 設計上の要素 | 役割 | コード上の変数 |
|---|---|---|
| F(x) | ブロック内部の変換(Day131/132と同じ構成) | forward内のf |
| Squeeze | 画像ではGAPで圧縮するが、表形式ではf自体が既に1チャネル1値なので省略 | (実装不要) |
| Excitation | ボトルネック構造でチャネル別重要度を計算 | self.excitation |
| s=Excitation(F(x)) | 各チャネルの重要度スコア(0〜1) | forward内のs |
| H(x)=x+S(F(x))⊙F(x) | ブロックの最終出力 | forwardの戻り値 |
| reduction | ボトルネックの圧縮率(パラメータ数とのトレードオフ) | reduced |
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・Day132と同一) --- 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: SEMLPBlockの自作設計(ボトルネックのreduction可変) --- class SEMLPBlock(nn.Module): 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ブロックあたりの追加パラメータ数(ゲート/SE機構のみ)比較 --- def count_params(module): return sum(p.numel() for p in module.parameters()) dummy_gate = nn.Linear(HIDDEN, HIDDEN) gate_extra = count_params(dummy_gate) reduced4 = max(1, HIDDEN // 4) reduced16 = max(1, HIDDEN // 16) se4_extra = count_params(nn.Linear(HIDDEN, reduced4)) + count_params(nn.Linear(reduced4, HIDDEN)) se16_extra = count_params(nn.Linear(HIDDEN, reduced16)) + count_params(nn.Linear(reduced16, HIDDEN)) # 出力例: # ResidualMLPBlock: +0 個 # GatedMLPBlock: +4160 個(64*64+64) # SEMLPBlock(reduction=4): +2128 個(64*16+16 + 16*64+64) # SEMLPBlock(reduction=16):+580 個(64*4+4 + 4*64+64) # → SEブロックはボトルネックのおかげでGatedMLPより少ないパラメータで # チャネル別の重み付けを実現している # --- ステップ4: 共通骨格を持つDeepNet --- class DeepNet(nn.Module): def __init__(self, in_dim=20, hidden=HIDDEN, num_classes=5, n_blocks=20, mode="se", gate_bias_init=-3.0, reduction=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)] ) 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): torch.manual_seed(0) model = DeepNet(n_blocks=n_blocks, mode=mode, gate_bias_init=gate_bias_init, reduction=reduction) 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(r=4)の4者比較(N_BLOCKS=20) --- for mode, kwargs in [("plain", {}), ("res", {}), ("gated", {"gate_bias_init": -3.0}), ("se", {"reduction": 4})]: 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 # → SEMLPはGatedMLPよりわずかに精度が高く、しかもパラメータ数はGatedMLPの約半分(2128 vs 4160) # --- ステップ5: reduction比率(4 vs 16)の比較 --- for r in [4, 16]: _, acc = train_and_measure("se", n_blocks=20, reduction=r) extra = se4_extra if r == 4 else se16_extra print(f"reduction={r:2d}: acc={acc:.4f} 追加パラメータ数(1ブロック)={extra}") # 出力例の傾向: # reduction= 4: acc=0.7417 追加パラメータ数(1ブロック)=2128 # reduction=16: acc=0.7183 追加パラメータ数(1ブロック)=580 # → reduction=16はパラメータ数を4分の1近くまで削っても、resより高い精度を維持する # --- ステップ6: チャネル重要度の可視化 --- se_model, _ = train_and_measure("se", n_blocks=20, reduction=4) se_model.eval() with torch.no_grad(): _ = se_model(X_test) last_block_scale = se_model.blocks[-1].last_scale sorted_idx = torch.argsort(last_block_scale, descending=True) for rank, idx in enumerate(sorted_idx[:5], start=1): print(f"重要度上位{rank}: channel={idx.item():2d} scale={last_block_scale[idx]:.4f}") for rank, idx in enumerate(sorted_idx[-5:], start=1): print(f"重要度下位{rank}: channel={idx.item():2d} scale={last_block_scale[idx]:.4f}") # 出力例の傾向: # 重要度上位1: channel=12 scale=0.8320 # 重要度下位1: channel= 3 scale=0.3512 # → 全チャネルが一様に0.5前後に収束するのではなく、明確な高低差(0.35〜0.83程度)が生じる
📊 結果の可視化(パラメータ効率とチャネル重要度)
1ブロックあたりの追加パラメータ数(ゲート/SE機構のみ、F(x)本体は除く):
| ブロック | 構成 | 追加パラメータ数 |
|---|---|---|
| ResidualMLPBlock | なし | 0 |
| GatedMLPBlock | Linear(64,64) | 4,160 |
| SEMLPBlock (reduction=4) | 64→16→64 | 2,128 |
| SEMLPBlock (reduction=16) | 64→4→64 | 580 |
🪜 Step-by-Step 解説
1なぜ表形式データでは「Squeeze」のステップを省略できるのか
f = self.fc2(h) # F(x)。shape: (batch, dim) s = self.excitation(f) # そのままExcitationへ。GAPなどの追加集約は不要
2S(F(x)) * F(x)が「配分」ではなく「フィルタ」である理由
t * f + (1 - t) * xは、tが大きければf寄り、小さければx寄りという2つの経路の混合比率を表していた。一方、今日のs * fにはxとの混合という概念がなく、sは単に「fの各チャネルの値をどれだけ弱める(あるいはそのまま通す)か」というフィルタ・ゲインとして働く。数式上は似ているように見えるが、tが「xかf、どちらを選ぶか」を決める役割なのに対し、sは「f自体の中身をどれだけ信頼するか」を決める役割であり、両者は問いの立て方が異なる。この後+ xで無条件にxを足しているため、SEブロックは常にResidual Connectionの恩恵を保ちながら、F(x)の質だけを調整する設計になっている。3ボトルネック構造がパラメータを削減しながら機能する理由
# GatedMLPBlockのgate_fc: dim -> dim(フル結合) → dim*dim + dim 個
# SEMLPBlockのexcitation: dim -> dim/r -> dim(ボトルネック)→ 2*(dim*dim/r) + dim/r + dim 個
4チャネル重要度の可視化が示す「学習された選択的注意」
🧮 数学・統計の補足(文系向け)
オーケストラには様々な楽器(チャネル)があり、演奏中どの楽器をどれくらいの音量で聞かせるかは、曲全体のバランスを見ながら指揮者が判断します。Excitation(励起)は、まさにこの指揮者の役割です。指揮者はまず全ての楽器の演奏(F(x)の各チャネル)を一度に見渡し(今日はSqueezeを省略しているので「見渡す」作業自体が既に完了している状態)、そこから「バイオリンはもう少し抑えめに、トランペットはもっと前に出して」という各楽器ごとの音量調整の指示(重要度スコアs)を出します。この指示に従って各楽器の音量を調整した演奏(s * F(x))を、常に流れている通奏低音(x、Residual接続)に重ねるのが、今日実装したx + s * F(x)という式のイメージです。
dim=64個のチャネルの情報を全部そのまま使って重要度を判断しようとすると(GatedMLPBlockのフル結合のように)、判断材料は多いですが計算コスト(パラメータ数)もかさみます。ボトルネック構造は、いったん64個の情報を「4個や16個の要約メモ」に凝縮してから、そのメモを見て64個それぞれへの評価を下す、というプロセスに近いです。優れた要約メモは、元の情報の要点を落とさずに圧縮できるため、判断の質はほとんど落ちません。今日の実験でreduction=16(要約メモがたった4行)でも十分な精度が出たのは、この「良い要約」がうまく機能した結果と言えます。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
🖼️ 画像コンペのバックボーンでの標準装備
EfficientNet等に組み込み済み
SEブロックはEfficientNetをはじめ多数のCNNアーキテクチャに組み込まれている定番の軽量モジュール。画像コンペでtimm系のモデルを使う際、内部で既にSE(-like)機構が動いていることが多い。
🔍 特徴量重要度の「モデル内蔵版」として
SHAPとは別の観察軸
チャネル重要度の可視化は、SHAP値のような事後的な解釈手法とは別に、モデルが学習の過程で「どの特徴量由来のチャネルを信頼しているか」を直接観察できる。特徴量エンジニアリングの方向性を決める補助的な手がかりになりうる。
🌉 Attention機構への自然な橋渡しとして
次回のAttentionへの導入
「重要度を動的に計算して掛け算する」という発想は、Self-Attentionが「どのトークンに注目するか」を動的に計算する仕組みと本質的に同じ系譜にある。次回学ぶAttentionブロックの前段階として理解しておくと導入がスムーズになる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| SEブロックは常にGatedMLPより優れると思い込む | パラメータが少なく精度も同等以上だった今日の結果から「効率的で万能」に見える | 今日の実験はこのデータ・設定における一例であり、タスクによってはGatedMLPの「配分の自由度」の方が有利な場合もある。SEブロックはxとF(x)の混合比率を制御できないため、恒等写像に近づきたい場合の柔軟性はGatedMLPより低い |
| Squeezeステップを省略したことを「実装のサボり」だと捉えてしまう | 元論文にあるステップを削っているため不完全な実装に見える | 画像のSqueezeは「空間方向の情報を1つに集約する」という明確な目的を持っており、その目的が表形式データには存在しない。目的のない工程を機械的にコピーせず、データの構造に応じて必要な部分だけを移植するのが正しい応用である |
| reductionを小さくする(ボトルネックを広くする)ほど必ず精度が上がると考える | パラメータが増えれば表現力も上がるはずという直感 | 今日の実験でもreduction=4はreduction=16よりわずかに精度が高かったが、差はそれほど大きくない。ボトルネックが必要以上に広いと過学習のリスクやパラメータ効率の悪化につながる場合があり、単純な関係ではない |
| チャネル重要度スコアsを、元データとして重要かどうかの直接的な指標だと解釈してしまう | 重要度という名前からfeature importanceと同じ概念に思える | sはあくまでそのブロック内部の中間表現の各チャネルに対する重み付けであり、元の20個の入力特徴量との対応は入力層や前段のブロックの変換を経ているため単純ではない。「どの生の特徴量が重要か」を知りたい場合はSHAPなど別の解釈手法と併用すべき |
🚀 次のステップ
- 発展: 今日はSEMLPBlockのExcitationをF(x)自体から計算しましたが、元論文のように「バッチ内の複数サンプルにまたがる統計量(例: バッチ平均・分散)」を追加の入力として使うバリエーションも考えられます。SEMLPBlockを拡張し、fだけでなくfのバッチ内平均を連結した情報からExcitationを計算する版を実装し、精度がどう変わるか試してみましょう
- 次回予告: Phase6テーマ2「カスタムアーキテクチャ設計」4日目。SEブロックは「重要度をチャネル自身から計算する」機構でしたが、次回はいよいよMulti-Head Self-Attentionブロックを表形式データに応用します。SEが「各チャネルをどれだけ信じるか」を計算したのに対し、Attentionは「他のサンプル(あるいは他の特徴量グループ)とどれだけ関連しているか」を動的に計算する、より一般化された注意機構です
Phase 6 の学習マップ(全20テーマ)
Day131でResidual Connection、Day132でその配分比率を学習可能にするGated MLPを実装。今日はチャネル単位の再重み付けを行うSqueeze-and-Exciteブロックを実装した。次回はいよいよAttention機構へと進む。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: