📚 背景知識(読んでから問題へ)
H(x) = F(x) + xというResidual Connectionを実装し、「変換結果F(x)」と「入力x」を常に1:1で足し算する設計を学びました。今日はこの設計を一歩発展させ、「足す割合」自体をネットワークに学習させるHighway Networks(Srivastava et al., 2015)のゲート機構に挑みます。H(x) = F(x) + xには選択の余地がありません。ブロックが「今回はほとんど何もしたくない」と思っても「ほぼ全部F(x)を使いたい」と思っても、足し算の重みは常に固定です。Highway Networksは、入力xから計算されるTransform Gate T(x) = sigmoid(Linear(x))(0〜1の値を要素ごとに出力)を使い、H(x) = T(x)⊙F(x) + (1-T(x))⊙xという式で「配分比率」自体を学習可能にします。0〜1の配分比率
Highway Networkの核心の数式:
H(x) = T(x) ⊙ F(x) + (1 − T(x)) ⊙ x
ゲートの初期値がなぜ重要か: Transform GateのLinear層のバイアスを学習開始時にわざと負の大きな値(例: -3)に初期化すると、sigmoid(-3)≈0.047となりH(x)≈x(ほぼ完全な恒等写像)からスタートできる。ネットワークは学習が進むにつれ、必要な場所だけゲートを少しずつ開いていく。
🪄 今日の比較の設計(3種類のブロック × ゲート初期化2パターン)
PlainMLP
Day131と同じ対照群。正規化なし・Skipなし(劣化問題の再現用)
ResMLP
Day131のResidualMLPBlock。H(x)=F(x)+x(配分は固定・1:1)
GatedMLP
今日新規実装。H(x)=T(x)F(x)+(1-T(x))x(配分は学習可能)
GatedMLPBlockはResidualMLPBlockのF(x)部分をそのまま流用し、return x + hをreturn t*h + (1-t)*xに置き換えたものと捉えると理解しやすい。今日はさらにgate_bias_init(ゲートの初期開き具合)を0と-3の2パターンで比較する。
🎯 問題
Day131と同系統の合成分類データ・共通骨格(DeepNet)を使い、ResidualMLPBlockに加えて新しくGatedMLPBlockを実装し、比較します。
| 項目 | 値 |
|---|---|
| タスク | 5クラス分類(make_classification, n_samples=2400, n_features=20) |
| ラベルノイズ率 | flip_y=0.03(3%程度) |
| train / test | 先頭1800件 / 残り600件 |
| ブロック内部次元 | HIDDEN=64(全ブロック共通) |
| 比較対象 | PlainMLP / ResMLP(Day131) / GatedMLP(新規, gate_bias_init可変) |
| 評価軸 | test精度 + 入力層の勾配ノルム(1バッチ目) + ブロック別の平均ゲート値 |
import numpy as np from sklearn.datasets import make_classification # --- 合成データ生成(Day131と同一) --- 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 # ブロック内部の次元(全ブロック共通) # 今日比較する3種類: # (a) PlainMLP : Day131と同じ(正規化・Skipなし、劣化問題の対照群) # (b) ResMLP : Day131のResidualMLPBlock(H(x) = F(x) + x、配分は固定) # (c) GatedMLP : 今日新規実装。H(x) = T(x)*F(x) + (1-T(x))*x(配分は学習可能)
💡 ヒント
GatedMLPBlockは、Day131のResidualMLPBlockのreturn x + hという1行を、return t * h + (1 - t) * xに置き換えたものだと考えると理解しやすいです。ResidualMLPBlockは「xを1倍・F(x)を1倍」という固定の配分でしたが、GatedMLPBlockは「xを(1-t)倍・F(x)をt倍」という入力に応じて変化する配分にする、というのが今日実装する差分のすべてです。tは0〜1の範囲の値を要素ごとに持つTensorであることに注意してください(スカラーではありません)。
- Transform GateのLinear層は、F(x)を計算するfc1・fc2とは別の、独立したLinear(dim, dim)層として用意する(重みを共有してはいけない)
- バイアスを負の値に初期化するには
nn.init.constant_(self.gate_fc.bias, gate_bias_init)のように書く sigmoid(-3) ≈ 0.047、sigmoid(0) = 0.5。gate_bias_init=-3ならゲートは学習開始時「ほぼ閉じている(≈5%だけF(x)を使う)」状態からスタートし、0なら「五分五分」からスタートする- ブロックごとの平均ゲート値を集計するには、GatedMLPBlockに「直前のforwardで計算したtの平均値を保持する属性(例: self.last_gate_mean)」を持たせておくと集計しやすい
import torch import torch.nn as nn class GatedMLPBlock(nn.Module): 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) # Transform Gate専用の層 nn.init.constant_(self.gate_fc.bias, gate_bias_init) # ← ここが今日のキモ self.last_gate_mean = 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) t = torch.sigmoid(self.gate_fc(x)) # T(x) 0〜1の要素ごとのゲート self.last_gate_mean = t.mean().item() return t * f + (1 - t) * x # Highway: T(x)F(x) + (1-T(x))x
✅ 模範解答
設計要素↔コード変数の対応表
| 設計上の要素 | 役割 | コード上の変数 |
|---|---|---|
| F(x) | ブロック内部の変換(Day131と同じ構成) | forward内のf |
| T(x)=sigmoid(Linear(x)) | 配分比率を決めるTransform Gate | forward内のt |
| H(x)=T(x)⊙F(x)+(1-T(x))⊙x | ブロックの最終出力 | forwardの戻り値 |
| gate_bias_init | 学習開始時の配分(0=五分五分, -3=ほぼ閉) | gate_fc.bias |
| last_gate_mean | 深さ方向のゲートの開き具合の可視化用 | last_gate_mean |
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と同一) --- 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: GatedMLPBlockの自作設計(ゲートのバイアス初期化つき) --- class GatedMLPBlock(nn.Module): 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) self.last_gate_mean = None 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)) self.last_gate_mean = t.mean().item() 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: 共通骨格を持つDeepNet(GatedMLPはgate_bias_initを渡せるようにする) --- class DeepNet(nn.Module): def __init__(self, in_dim=20, hidden=HIDDEN, num_classes=5, n_blocks=20, mode="gated", gate_bias_init=-3.0): 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)] ) 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 gate_means(self): """gatedモードの場合、各ブロックの直近の平均ゲート値をリストで返す""" return [b.last_gate_mean for b in self.blocks if hasattr(b, "last_gate_mean")] 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): torch.manual_seed(0) model = DeepNet(n_blocks=n_blocks, mode=mode, gate_bias_init=gate_bias_init) optimizer = torch.optim.Adam(model.parameters(), lr=lr) first_batch_grad_norm = None 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() if first_batch_grad_norm is None: first_batch_grad_norm = model.input_layer.weight.grad.norm().item() optimizer.step() acc = evaluate(model) return model, acc, first_batch_grad_norm # --- ステップ4: PlainMLP・ResMLP・GatedMLP(bias=-3)の3者比較(N_BLOCKS=20) --- for mode, kwargs in [("plain", {}), ("res", {}), ("gated", {"gate_bias_init": -3.0})]: model, acc, grad_norm = train_and_measure(mode, n_blocks=20, **kwargs) print(f"{mode:6s}: acc={acc:.4f} input_layer勾配ノルム(1バッチ目)={grad_norm:.6f}") # 出力例(乱数・実行環境により変動): # plain : acc=0.2117 input_layer勾配ノルム(1バッチ目)=0.000031 # res : acc=0.7217 input_layer勾配ノルム(1バッチ目)=0.183760 # gated : acc=0.7350 input_layer勾配ノルム(1バッチ目)=0.241300 # → gatedはresよりわずかに精度が高く、勾配ノルムも大きい(ゲートが「ほぼ恒等写像」から # ゆるやかに開いていくため、深い場合でも勾配が伝わりやすい経路が保たれる) # --- ステップ5: ゲートのバイアス初期化(0 vs -3)の比較 --- for bias_init in [0.0, -3.0]: _, acc, grad_norm = train_and_measure("gated", n_blocks=20, gate_bias_init=bias_init) print(f"gate_bias_init={bias_init:5.1f}: acc={acc:.4f} 勾配ノルム(1バッチ目)={grad_norm:.6f}") # 出力例の傾向: # gate_bias_init= 0.0: acc=0.6650 勾配ノルム(1バッチ目)=0.052300 # gate_bias_init= -3.0: acc=0.7350 勾配ノルム(1バッチ目)=0.241300 # → bias=0(五分五分スタート)はresより悪化することがある。bias=-3(ほぼ恒等写像 # スタート)の方が安定して高精度・高勾配ノルムを維持する # --- ステップ6: 深さ方向のゲートの開き具合を可視化 --- gated_model, _, _ = train_and_measure("gated", n_blocks=20, gate_bias_init=-3.0) gated_model.eval() with torch.no_grad(): _ = gated_model(X_test) # forwardを1回走らせてlast_gate_meanを更新 gate_profile = gated_model.gate_means() for i, g in enumerate(gate_profile, start=1): print(f"block {i:2d}: gate_mean={g:.4f}") # 出力例の傾向: # block 1: gate_mean=0.61 浅い層は比較的ゲートが開く # block 10: gate_mean=0.34 # block 20: gate_mean=0.09 深い層はゲートがほぼ閉じたまま=ほぼ恒等写像として機能
📊 結果の可視化(ゲート初期化の効果・ブロック別ゲート開度)
🪜 Step-by-Step 解説
1T(x)はスカラーではなく「次元ごとのゲート」であることの意味
t = torch.sigmoid(self.gate_fc(x)) # shape: (batch, dim) return t * f + (1 - t) * x # 要素ごとの積(アダマール積)
2gate_fcをfc1・fc2と共有してはいけない理由
3なぜgate_bias_init=-3が0より優れた結果になったのか
# sigmoid(-3) ≈ 0.047 → 学習開始時は「95%くらいxをそのまま通す」
# sigmoid(0) = 0.5 → 学習開始時は「五分五分で混ぜる」
4深さ方向のゲートの開き具合のパターンが示すもの
🧮 数学・統計の補足(文系向け)
Residual Connectionは「エスカレーター(x)に乗ったまま、決まった分だけ必ず階段(F(x))も歩く」という設計でした。今日のGated MLPはさらに一歩進んで、「階段をどれだけ歩くかを、その場の状況を見て自分で決められる蛇口」を追加したイメージです。蛇口が完全に閉まっていれば(T(x)≈0)エスカレーターの力だけで移動し、蛇口を開けば開けるほど(T(x)が1に近づくほど)階段を歩く量(F(x)の影響)が増えていきます。「バイアスを-3にして学習開始時は蛇口をほぼ閉じておく」という工夫は、「最初は安全側に倒しておき、必要になった分だけ少しずつ蛇口を開けることを覚えさせる」という初期化の知恵だと考えると分かりやすいです。
sigmoid関数は、どんな実数の入力(−∞〜+∞)を受け取っても、必ず0〜1の範囲に収まる出力を返す関数です。体温計の目盛りが「実際の体の状態」を「決まった範囲の数値」に変換して表示するのに似ています。gate_bias_init=-3にすると、ゲートを決めるLinear層の出力が「かなり低い体温」を指すように設定されるため、sigmoidを通した後の目盛り(ゲートの開き具合)は常に低め(0に近い)からスタートします。学習が進むにつれて、必要な場所だけ「体温」が上がり、目盛り(ゲート)が徐々に開いていく、というイメージです。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
🔀 LSTM/GRUのゲート機構との共通点
忘却ゲート・更新ゲートと同じ道具立て
「シグモイドで0〜1の配分比率を学習する」という発想は、LSTM/GRUの「忘却ゲート」「更新ゲート」と全く同じ数学的道具立てである。時系列コンペでLSTM/GRUを使う際、内部で何が起きているかをこの「蛇口」のイメージで理解しておくと応用が効きやすい。
🎛️ GLU系Transformerブロックの前段階
Gated Linear Unitへの導入
TransformerのFeed-Forward層やAttention出力の合成にも、単純な加算ではなくゲート付き合成(GLU: Gated Linear Unitなど)を使う設計が近年広く使われている。今日のGatedMLPBlockはGLU系アーキテクチャの直感的な導入として位置づけられる。
🔎 「責任分担」を可視化する武器として
解釈性のアピールにもなる
ブロック別ゲート平均値の可視化は、単に精度を競うだけでなく「モデルが内部で何をしているか」を人間が解釈するための手段にもなる。「浅い層と深い層で役割が分かれている」という説明ができれば、Kaggle Notebookの質の高さのアピールにもなる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| GatedMLPは常にResidualMLPより優れると思い込む | 「配分を学習できる方が柔軟で万能」という直感 | パラメータ数が増える分、データが少ない・エポック数が短い状況では学習が追いつかず、シンプルなResidualの方が安定することもある。今日の実験結果は今日のデータ・設定下での傾向であり、常に一般化できるとは限らない |
| gate_bias_initの初期化を「好みの問題」程度に軽視してしまう | バイアスの初期値はネットワークの精度に直接関係しない些細な設定に見える | 深いネットワークでは、学習開始時の「実質的な関数」が恒等写像に近いかどうかが、その後の学習の安定性を大きく左右する。0と-3で最終精度に無視できない差が出たのはその一例 |
| Transform Gate T(x)をブロック全体で1つのスカラー値だと勘違いする | 「開くか閉じるか」という言葉のイメージから単純な0/1やスカラーを連想しやすい | 実装上T(x)はxと同じ次元数を持つベクトル(Tensor)であり、次元ごとに異なる開閉度を学習する。「ブロック全体が何%開いているか」ではなく「各次元がそれぞれ何%開いているか」という粒度の細かい機構である |
| ゲートが学習後に「常に0.5前後」に収束すると思い込む | シグモイドの初期値が0.5に近い(bias=0の場合)ため、そこに落ち着きそうに感じる | ブロック別可視化で見たように、学習後のゲート値は層によって大きく偏る(浅い層は高め、深い層は低め)ことが多く、「五分五分に落ち着く」という予想は必ずしも正しくない |
🚀 次のステップ
- 発展: 今日はT(x)をブロック内部のLinear層1つから計算しましたが、Highway Networks以降の研究では「複数のゲート(更新ゲート・出力ゲートなど役割の異なる複数のゲート)を組み合わせる」設計(LSTM/GRUなど)も広く使われています。GatedMLPBlockを拡張し、T(x)とは別に「出力に直接掛けるゲート」をもう1つ追加した2ゲート版を実装し、精度やゲートの振る舞いがどう変わるか試してみましょう
- 次回予告: Phase6テーマ2「カスタムアーキテクチャ設計」3日目。今日のResidual/Gated系ブロックとは異なるアプローチとして、Squeeze-and-Excitation(SE)ブロックを自作します。「各特徴量チャネルの重要度を、そのチャネル自身の統計量(平均値など)から動的に再重み付けする」という、Attentionの前身にあたる軽量な機構を表形式データに応用します
Phase 6 の学習マップ(全20テーマ)
Day131でResidual Connectionを実装し、今日はその配分比率を学習可能にするGated MLPブロックを実装。次回はチャネル単位の再重み付けを行うSqueeze-and-Exciteブロックへと進む。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: