📚 背景知識(読んでから問題へ)
(Norm→Linear→ReLU→Norm→Linear)
Residual Connectionの核心の数式:
H(x) = F(x) + x
ブロックが「何もしない(恒等写像)」を学習したいなら、F(x)がただゼロを出力すればよいだけになる。エスカレーターに例えると、エスカレーター(x、恒等写像という無料の移動手段)に乗ったまま、必要な分だけ自分の足でさらに歩く(F(x)、追加の変換)というイメージ。歩かなくても目的階に着けるなら「F(x)=0」と学習すればよく、これは一番簡単な選択。
🪄 今日の比較の設計(3種類のブロック × 4段階の深さ)
PlainBlock
正規化なし・Skipなし。Linear→ReLUの単純な積み重ね(劣化問題の再現用)
NormBlock
LayerNormはあるがSkipなし(アブレーション: 正規化だけの効果を見る)
ResidualMLPBlock
LayerNorm + Skip接続(Pre-Norm方式)。今日のメイン設計対象
3つのブロックはパラメータ数・層構成をほぼ揃え、return文が「hのみ」か「x + h」かという1点だけを変える。この差がN_BLOCKS(深さ)を増やしたときにどう拡大するかを観察する。
🎯 問題
Day126〜130と同系統の合成分類データに対し、同じ深さ(ブロック数)を持つ3種類のネットワークを構築し、深さを変えながら比較します。
| 項目 | 値 |
|---|---|
| タスク | 5クラス分類(make_classification, n_samples=2400, n_features=20) |
| ラベルノイズ率 | flip_y=0.03(3%程度) |
| train / test | 先頭1800件 / 残り600件 |
| ブロック内部次元 | HIDDEN=64(全ブロック共通) |
| 比較対象 | PlainMLP / NormMLP / ResMLP(骨格は共通、ブロックだけ差し替え) |
| 評価軸 | test精度 + 入力層の勾配ノルム(1バッチ目) + 深さN_BLOCKS=[5,10,20,40]の感度 |
import numpy as np from sklearn.datasets import make_classification # --- 合成データ生成(Day126〜130と同系統) --- 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種類のネットワークを、ブロック数 N_BLOCKS を変えながら比較する: # (a) PlainMLP : Linear→ReLU の単純な積み重ね(正規化なし・Skip接続なし) # (b) NormMLP : LayerNormはあるがSkip接続はない(アブレーション用) # (c) ResMLP : LayerNorm + Skip接続を持つ ResidualMLPBlock の積み重ね
💡 ヒント
ResidualMLPBlockのforwardメソッドで一番大事な行は、最後にreturn x + hのように入力そのものを足し算することです。逆に言えばreturn h(足さない)と書いた瞬間、それはただの「LayerNorm付きMLP」(今日の比較対象(b) NormMLP)になります。3つのモデルの実装上の違いは実は「足すか足さないか」と「LayerNormを挟むか挟まないか」の2点だけであり、それ以外のパラメータ数・層の数はできるだけ揃えて「公平な比較」にすることが今日の実験の肝です。
- Skip接続で
x + F(x)を計算するには、F(x)の出力次元がxと完全に同じでなければならない。ResidualMLPBlockは「入力次元=出力次元=dim」という制約を持つ設計にするのが最も簡単 - 「Pre-Norm」とは、Linearの前にLayerNormを置く設計(LayerNorm→Linear→活性化関数→LayerNorm→Linear)。層を非常に深く積む場合、Pre-Normの方が学習が安定しやすいことが知られている
- 勾配のL2ノルムは、loss.backward()の直後に
model.input_layer.weight.grad.norm().item()のようにして取得できる。必ずbackward()の直後・optimizer.step()の前に取得すること - N_BLOCKS=40のような深いPlainMLPは、精度がほぼランダム推測(5クラス分類なら約20%)近くまで落ち込むことがある。これは実装のバグではなく劣化問題そのものが再現されている結果
import torch import torch.nn as nn class ResidualMLPBlock(nn.Module): 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 # ← Skip接続(これがないとただのNormMLPになる) class DeepNet(nn.Module): def __init__(self, in_dim, hidden, num_classes, n_blocks, mode="res"): # mode: "plain"(正規化・Skipなし)/ "norm"(正規化のみ)/ "res"(正規化+Skip) ...
✅ 模範解答
設計要素↔コード変数の対応表
| 設計上の要素 | 役割 | コード上の変数 |
|---|---|---|
| F(x) | ブロック内部の変換(LayerNorm→Linear→活性化→LayerNorm→Linear) | forward内のh |
| x | 恒等写像。ブロックへの入力をそのまま足す部分 | return x + h |
| H(x)=F(x)+x | ブロックの最終出力 | forwardの戻り値 |
| Pre-Norm | Linearの前にLayerNormを置く配置方針 | norm1→fc1, norm2→fc2の順序 |
| N_BLOCKS | ネットワークの深さ(ブロック数) | n_blocks |
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) # --- サンプルデータ生成(問題文と同じ) --- 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: ResidualMLPBlockの自作設計 --- class ResidualMLPBlock(nn.Module): """Pre-Norm方式: LayerNorm -> Linear -> ReLU -> LayerNorm -> Linear のあとに入力xを足す""" 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 # Skip接続: F(x) + x class PlainBlock(nn.Module): """正規化もSkip接続もない、素朴なLinear->ReLUの繰り返し(劣化問題の再現用)""" 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 # Skip接続なし。そのままhを返す class NormBlock(nn.Module): """LayerNormはあるがSkip接続はない(アブレーション: 正規化だけの効果を見る)""" 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 h # Skip接続なし(ResidualMLPBlockとの唯一の違いは最後の "x +" の有無) # --- ステップ2: 共通骨格を持つ3種類のDeepNet --- BLOCK_CLS = {"plain": PlainBlock, "norm": NormBlock, "res": ResidualMLPBlock} class DeepNet(nn.Module): def __init__(self, in_dim=20, hidden=HIDDEN, num_classes=5, n_blocks=20, mode="res"): super().__init__() self.input_layer = nn.Linear(in_dim, hidden) # 勾配ノルム計測の対象 block_cls = BLOCK_CLS[mode] self.blocks = nn.ModuleList([block_cls(hidden) for _ in range(n_blocks)]) 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() # --- ステップ3: 学習ループ + 1バッチ目の入力層の勾配ノルム計測 --- def train_and_measure(mode, n_blocks=20, epochs=60, lr=1e-3): torch.manual_seed(0) model = DeepNet(n_blocks=n_blocks, mode=mode) 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: # 学習開始直後の1回だけ記録 first_batch_grad_norm = model.input_layer.weight.grad.norm().item() optimizer.step() acc = evaluate(model) return acc, first_batch_grad_norm # --- ステップ4: N_BLOCKS=20固定で3モデルを比較 --- for mode in ["plain", "norm", "res"]: acc, grad_norm = train_and_measure(mode, n_blocks=20) print(f"{mode:5s} (N=20): acc={acc:.4f} input_layer勾配ノルム(1バッチ目)={grad_norm:.6f}") # 出力例(乱数・実行環境により変動): # plain (N=20): acc=0.2117 input_layer勾配ノルム(1バッチ目)=0.000031 # norm (N=20): acc=0.6883 input_layer勾配ノルム(1バッチ目)=0.041520 # res (N=20): acc=0.7217 input_layer勾配ノルム(1バッチ目)=0.183760 # → plainは勾配がほぼ消滅し(0.000031)、精度もランダム推測(5クラスなら約20%)に近い # → normは正規化だけでもある程度救われるが、resが最も勾配が伝わりやすく精度も最良 # --- ステップ5: 深さ(N_BLOCKS)の感度確認 --- for n_blocks in [5, 10, 20, 40]: for mode in ["plain", "norm", "res"]: acc, _ = train_and_measure(mode, n_blocks=n_blocks) print(f"N_BLOCKS={n_blocks} {mode}={acc:.3f}") # 出力例の傾向: # N_BLOCKS=5 plain=0.701 norm=0.708 res=0.715 (浅いうちは3者ほぼ差がない) # N_BLOCKS=10 plain=0.583 norm=0.702 res=0.719 (plainだけ劣化が始まる) # N_BLOCKS=20 plain=0.212 norm=0.688 res=0.722 (plainはほぼ崩壊、res/normは維持) # N_BLOCKS=40 plain=0.198 norm=0.641 res=0.708 (plainは崩壊したまま、normもやや悪化、resが最も頑健)
📊 結果の可視化(深さ別のtest精度と勾配ノルム)
🪜 Step-by-Step 解説
1なぜ「入出力の次元を揃える」という制約が生まれるのか
def forward(self, x): h = self.norm1(x) ... return x + h
2PlainBlock・NormBlock・ResidualMLPBlockの「1行だけの違い」が生む大差
# NormBlock: return h # Skip接続なし
# ResidualMLPBlock: return x + h # Skip接続あり
3なぜ「1バッチ目の入力層の勾配ノルム」を測るのか
if first_batch_grad_norm is None: first_batch_grad_norm = model.input_layer.weight.grad.norm().item()
4Pre-Norm方式を選んだ理由
return x + hのx(恒等写像の経路)が正規化を経由せずそのままの値で伝わるため、Skip接続本来の「勾配の高速道路」としての役割がより素直に機能しやすい。Transformer系のアーキテクチャ(GPTなど)が広く採用しているのもPre-Norm方式である。🧮 数学・統計の補足(文系向け)
ビルの1階から2階に上がる方法が2つあるとします。1つはエスカレーター(何もしなくても勝手に運んでくれる、恒等写像x)。もう1つはエスカレーターの脇にある階段(自分の意志で追加の移動をする、変換F(x))。この2つを合体させた乗り物を想像してください——エスカレーターに立ったまま、必要なら追加で数段だけ自分の足で歩くこともできる乗り物です。もし2階までちょうどエスカレーターだけで着くなら、階段部分(F(x))は「何もしない(0段だけ歩く)」と学習すればよく、これは「複雑な階段の上り方を学習する」よりもはるかに簡単です。H(x) = F(x) + xという式は、まさに「エスカレーター(x)+追加の階段(F(x))」という2つの経路を足し合わせたものだと考えると直感的です。
20人が一列に並んで、最後尾の人(出力層)から先頭の人(入力層)へ「どれくらい・どちら向きに声を大きくすべきか」という指示(勾配)を、1人ずつ伝言していく状況を想像してください。各人が指示を伝えるたびに内容の一部を弱めてしまう(例えば毎回90%に減衰させる)としたら、20人を経由するころには指示の強さは0.920≈0.12、つまりほぼ聞こえないくらい弱まってしまいます。Skip接続は、この伝言ゲームの列の脇に「先頭まで直接届く伝声管」を1本追加するようなものです。伝言(勾配)は弱まった経路だけでなく、この伝声管を通っても先頭に届くため、途中で情報が消えてしまうリスクが大幅に下がります。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
🧱 表形式データ向けDeep Learningアーキテクチャ
「Revisiting Deep Learning for Tabular Data」系の設計
表形式データ用ResNet風アーキテクチャは、今日作ったResidualMLPBlockとほぼ同じ発想(Pre-Norm + Skip接続のMLPブロックを何層も積む)を採用している。
🎯 GBDTアンサンブルへの多様性の追加
似た予測傾向のGBDTを崩す
GBDT系のモデルは似た予測傾向を持ちやすい。Skip接続を持つ深いMLPはGBDTとは異なる「間違え方」をする傾向があり、アンサンブルに加えるとLBが改善する事例がよくある。
🔧 ドメイン知識に合わせたブロック自作
上位解法の差別化ポイント
「カテゴリ変数のEmbeddingを合流させるブロック」「時系列特徴量専用のブロック」など、データの構造に合わせてブロックを自作することが差別化になる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| Skip接続さえあれば正規化(LayerNorm/BatchNorm)は不要だと考えてしまう | 「足し算するだけ」というSkip接続のシンプルさから他の工夫が不要に思える | 今日の実験でもnorm(正規化のみ)はplainより大きく改善しており、正規化とSkip接続はそれぞれ別の理由で学習を助ける独立した工夫。両方を組み合わせるのが最も頑健 |
| ResidualMLPBlockの入力次元と出力次元を自由に変えられると思い込む | 通常のLinear層は入出力の次元を自由に設定できるため | Skip接続でx + F(x)を計算するには次元が完全に一致している必要がある。次元を変えたい場合はx側にもLinearによる射影(projection shortcut)を追加しないと単純な足し算ができずエラーになる |
| 深いネットワークの学習がうまくいかない原因を「過学習」だと誤診してしまう | 深いモデル=表現力が高い=過学習しやすい、という一般論からの類推 | 劣化問題はtrain誤差自体が高いまま下がらない現象であり、過学習(trainは良いがtestが悪い)とは全く別の症状。trainのlossカーブがそもそも下がっているかを必ず確認すべき |
| Pre-NormとPost-Normの違いを「どちらでも大差ない些細な実装の好み」だと考えてしまう | どちらもLayerNormを使う点では同じに見える | 層を深く積むほど、LayerNormをSkip接続の外側に置くか内側に置くかで、恒等写像の経路が正規化の影響を受けるかどうかが変わり、学習の安定性に無視できない差が出る |
🚀 次のステップ
- 発展: 今日はdimを固定したResidualMLPBlockのみを扱いましたが、実際のResNet論文では「次元を変える箇所だけprojection shortcut(x側にも1層Linearを挟む)を使う」という設計がされています。ResidualMLPBlockを拡張し、in_dim != out_dimのときは自動的にx側に射影用のLinearを追加するin_dim・out_dim可変版を実装し、次元を徐々に変化させる(例: 64→32→16)深いネットワークを組んでみましょう
- 次回予告: Phase6テーマ2「カスタムアーキテクチャ設計」2日目。今日作ったResidualMLPBlockをベースに、Gated(ゲート機構付き)MLPブロックを自作し、「足すか足さないかを0/1で決める」のではなく「どれくらいの割合で足すか」を学習可能なゲート(シグモイド関数で0〜1に制限した重み)で制御する設計に挑みます
Phase 6 の学習マップ(全20テーマ)
Day126〜130でLabel Smoothing・SAM・EMA・Mixup・Knowledge Distillationと「論文→実装」ワークフローを完走。今日からはブロックを自分で設計する「カスタムアーキテクチャ設計」フェーズに移行し、Residual接続を皮切りに5回のブロック設計を反復する。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: