📚 背景知識(読んでから問題へ)
wは損失の谷底にきれいに収束せず、谷底の周りをウロウロと小刻みに揺れ続けることがよくあります。この状態で「たまたま最後に測った1点」の重みをそのまま採用すると、そのタイミングが運悪く調子の悪いときだった場合、テスト精度が本来の実力より低く出てしまうことがあります。株価の日次終値と25日移動平均線の関係と同じで、EMAは重みに対して同じ平滑化を行うという発想です。数式は非常にシンプルで核心は1本だけです(θt=時刻tの生の重み、θEMAt=EMAで平滑化した重み、d=減衰率でおよそ0.99〜0.9999)。
論文・実装で標準的に使われるEMAの核心の数式:
θEMAt = d · θEMAt−1 + (1 − d) · θt
日本語で言い換えると:「今のEMA重みの99%(=d)はこれまでの記憶を保ち、残り1%(=1−d)だけ今の最新の重みを取り入れる」という更新を、全ステップで繰り返すだけ。模試で言えば「直近1回だけ」でなく「最近を重視しつつ過去も少し織り込んだ加重平均」で実力を判断するようなもの。
🪄 EMAの適用ステップ(今日の学習ループの流れ)
①forward/backward
通常どおりlossを計算しbackward()する
②optimizer.step()
生のパラメータθtを通常どおり更新する
③ema.update(model)
更新直後の生の重みを"覗き見"してEMA重みも1ステップ更新する
④評価時にEMA重みを注入
load_state_dictでモデルにEMA重みを一時的に差し替える
⑤raw weightへ復元
評価後、必ず退避しておいた生の重みへ戻して学習を継続する
SAM(Day127)が「1ステップの中で完結する処理」だったのに対し、EMAは「学習の全ステップをまたいで状態を蓄積し続けるオブジェクト」である点が対照的。
🎯 問題
Day126・Day127と同様の合成分類データに対し、学習率を大きめに固定して意図的に「後半のエポックで重みが揺れ続ける」状況を作り、raw weight(学習ループの最終ステップの生の重み)とEMA weight(学習全体を通したEMA重み)それぞれでテスト精度がどう振る舞うかを比較します。
| 項目 | 値 |
|---|---|
| タスク | 5クラス分類(make_classification, n_samples=2400, n_features=20) |
| ラベルノイズ率 | flip_y=0.03(3%程度) |
| train / test | 先頭1800件 / 残り600件 |
| 学習設定 | epochs=80、lr=0.02固定(減衰なし=後半も重みが揺れ続ける) |
| 比較対象 | raw weight vs EMA weight(decay=0.99が初期値) |
| 評価軸 | 最後20エポックのtest精度の平均・標準偏差 + decay感度(0.9/0.99/0.999) |
import numpy as np from sklearn.datasets import make_classification # --- 合成データ生成(5クラス分類。Day126/127と同系統) --- 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 # 学習率は減衰させず lr=0.02 程度で固定し、後半エポックでも重みが揺れ続けるようにする
EMAクラス(update(model)メソッドを持つ)として実装する💡 ヒント
EMAの実装で最も重要なのは、「EMA重みは学習に直接使わない」という点です。EMAはあくまで「観測用の副産物」であり、loss.backward()やoptimizer.step()が更新するのは常に生のモデルパラメータです。EMA重みはoptimizer.step()の後に、生の重みを"覗き見"して更新するだけの、学習ループに対して受け身の存在だと考えてください。
- EMAクラスは
model.state_dict()のコピーをshadowとして保持し、update(model)が呼ばれるたびにshadow[name] = decay * shadow[name] + (1 - decay) * model_param.dataを全パラメータに対して行う - 「EMA重みでの評価」は、①現在のモデルの生の重みを一時退避、②
model.load_state_dict(ema.shadow)でEMA重みを注入、③その状態でevaluate、④退避しておいた生の重みへ戻す、という4ステップで実現できる - decayが大きいほど「昔の重み」に強く引っ張られるため、学習の序盤ではEMA重みがまだ初期値に近く性能が低いままになりやすい。最後の20エポックだけを比較対象にするのはこの「立ち上がりの遅れ」の影響を除くため
- 標準偏差は
np.std(accuracy_list)で機械的に計算できる。「ばらつきが小さい=安定している」という評価軸を数値で語れるようにする
import copy import torch class EMA: def __init__(self, model, decay=0.99): self.decay = decay self.shadow = copy.deepcopy(model.state_dict()) def update(self, model): with torch.no_grad(): for name, param in model.state_dict().items(): self.shadow[name] = ( self.decay * self.shadow[name] + (1 - self.decay) * param ) def apply_to(self, model): """モデルの現在の重みを退避しつつ、EMA重みを注入する。戻り値は退避した元の重み""" backup = copy.deepcopy(model.state_dict()) model.load_state_dict(self.shadow) return backup # 学習ループ内: optimizer.step() の直後に ema.update(model) を呼ぶ
✅ 模範解答
記号↔変数の対応表
| 論文の記号 | 意味 | コード上の変数 |
|---|---|---|
| θt | 現在の生のモデルパラメータ | model.state_dict()[name] |
| θEMAt−1 | 直前のEMA重み | self.shadow[name](更新前) |
| d | 減衰率(0.9〜0.999程度) | self.decay |
| θEMAt | 更新後のEMA重み | self.shadow[name](更新後) |
import copy import numpy as np import torch import torch.nn as nn 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 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) class MLP(nn.Module): def __init__(self, in_dim=20, hidden=32, num_classes=5): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, num_classes), ) def forward(self, x): return self.net(x) loss_fn = nn.CrossEntropyLoss() # --- ステップ1: EMAの数式をそのままコードに翻訳 --- class EMA: """theta_EMA_t = decay * theta_EMA_{t-1} + (1 - decay) * theta_t""" def __init__(self, model, decay=0.99): self.decay = decay self.shadow = copy.deepcopy(model.state_dict()) def update(self, model): with torch.no_grad(): for name, param in model.state_dict().items(): self.shadow[name] = ( self.decay * self.shadow[name] + (1 - self.decay) * param ) def apply_to(self, model): backup = copy.deepcopy(model.state_dict()) model.load_state_dict(self.shadow) return backup def evaluate(model): model.eval() with torch.no_grad(): preds = model(X_test).argmax(dim=1) return (preds == y_test).float().mean().item() # --- ステップ2/3: 学習率固定(減衰なし)で学習しつつ、EMAを並行更新 --- def train_with_ema(epochs=80, lr=0.02, decay=0.99, tail=20): torch.manual_seed(0) model = MLP() optimizer = torch.optim.Adam(model.parameters(), lr=lr) # schedulerなし=学習率固定 ema = EMA(model, decay=decay) raw_acc_history = [] ema_acc_history = [] for epoch in range(epochs): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss = loss_fn(model(xb), yb) loss.backward() optimizer.step() ema.update(model) # 生の重みを更新した直後にEMAも更新 raw_acc = evaluate(model) backup = ema.apply_to(model) # モデルの重みを一時的にEMA重みへ差し替え ema_acc = evaluate(model) model.load_state_dict(backup) # raw weightへ復元 raw_acc_history.append(raw_acc) ema_acc_history.append(ema_acc) raw_tail = raw_acc_history[-tail:] ema_tail = ema_acc_history[-tail:] return raw_tail, ema_tail # --- ステップ4: decay=0.99での比較 --- raw_tail, ema_tail = train_with_ema(decay=0.99) print(f"raw weight: mean={np.mean(raw_tail):.4f} std={np.std(raw_tail):.4f}") print(f"EMA weight: mean={np.mean(ema_tail):.4f} std={np.std(ema_tail):.4f}") # 出力例(乱数・実行環境により変動): # raw weight: mean=0.7220 std=0.0186 # EMA weight: mean=0.7365 std=0.0071 # → EMA weightの方が平均は同等以上、標準偏差(ばらつき)は明確に小さい # --- ステップ5: decayの感度確認 --- for decay in [0.9, 0.99, 0.999]: raw_tail, ema_tail = train_with_ema(decay=decay) print(f"decay={decay:<6} raw: mean={np.mean(raw_tail):.4f} std={np.std(raw_tail):.4f} | EMA: mean={np.mean(ema_tail):.4f} std={np.std(ema_tail):.4f}") # decay=0.9 raw: mean=0.720 std=0.019 | EMA: mean=0.726 std=0.015 (追従は速いがまだ揺れが残る) # decay=0.99 raw: mean=0.722 std=0.019 | EMA: mean=0.737 std=0.007 (揺れがよく抑えられ、平均も良い) # decay=0.999 raw: mean=0.721 std=0.018 | EMA: mean=0.705 std=0.004 (揺れは最小だが追従が遅く平均が伸び悩む)
📊 安定性の可視化(最後20エポックの test精度: raw vs EMA)
🪜 Step-by-Step 解説
1EMAの数式を、状態を持つクラスのupdateメソッドに対応づける
def update(self, model): for name, param in model.state_dict().items(): self.shadow[name] = self.decay * self.shadow[name] + (1 - self.decay) * param
2なぜ「evaluateの前後でload_state_dictを2回呼ぶ」手順が必要か
backup = ema.apply_to(model) # EMA重みを注入 ema_acc = evaluate(model) model.load_state_dict(backup) # raw weightへ復元
3decayによって「安定性」と「追従の速さ」がどう変わるかを実験で確認する
4tail(最後20エポックだけを見る)という比較設計の意図
🧮 数学・統計の補足(文系向け)
「25日移動平均線」は直近25日分の終値を単純に平均した値です。これに対して指数移動平均(EMA)は、「直近のデータほど重みを大きく、昔のデータほど重みを小さく(指数関数的に減衰させながら)」平均する方法です。EMAの式を展開すると θEMAt = (1−d)θt + (1−d)d・θt−1 + (1−d)d²・θt−2 + ⋯ という形になり、過去にさかのぼるほど指数関数的に小さい重みがかかっていることが分かります(d<1なのでd², d³, …はどんどん0に近づく)。これが「指数」移動平均と呼ばれる理由です。
生徒Aの直近5回の模試の点数が「60, 90, 55, 85, 70」(平均72点、ばらつき大)、生徒Bが「68, 74, 70, 76, 72」(平均72点、ばらつき小)だったとします。平均点は同じでも、Bの方が「次の模試でも安定して70点前後を取れそう」と信頼できます。EMA weightがraw weightよりtest精度の標準偏差が小さいというのは、まさにこの「Bのような安定した実力」に近い状態をモデルが獲得している、という意味です。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☐ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☐ 時系列(Time Series)
🎯 物体検出・セグメンテーション系
YOLO系・EfficientDet系の慣習
学習中ずっとEMA重みを並行更新し、最終的な提出モデルとしてEMA重みの方を採用するのが半ば標準的な慣習になっている。
🌀 拡散モデル(Diffusion Model)系
生成品質の安定化にほぼ必須
生成モデルは学習が長く揺れの影響を強く受けやすいため、decay=0.999〜0.9999のような非常に大きい値が使われることが多い。
💾 メモリコストのトレードオフ
state_dictのコピーを保持
EMAはモデルパラメータ数が2倍になるイメージでメモリを消費する。ノートブックのメモリ制限が厳しい大規模モデルでは、保持コストと安定化効果を天秤にかける必要がある。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| EMA重みをloss.backward()の対象にしてしまう | 「EMAの方が良い重みなら、そちらを学習させた方が良いのでは」という直感 | EMAはraw weightの履歴を後から平均するだけの受け身の仕組みであり、それ自体を勾配降下で更新することはない。学習で更新されるのは常にraw weightのみ |
| decayを1に近づけるほど無条件に良くなると考えてしまう | 「揺れを抑えたいなら、昔の記憶を強く保つべき」という直感 | decayが1に近すぎると最新の重みへの反応が遅くなり、総ステップ数が少ない設定ではEMAが「まだ古い・未熟な重み」に強く引っ張られたまま終わってしまう。総ステップ数とのバランスで選ぶ必要がある |
| EMA weightでの評価後、raw weightへ戻すのを忘れて次のエポックに進んでしまう | evaluate関数を呼んだ後、通常は状態が変わらないという思い込み | load_state_dictは破壊的にモデルの重みを書き換える。明示的に戻さない限り、次のイテレーションの学習がEMA重みに対して行われてしまい、学習全体の意味が変わってしまう |
| EMAとSAM(Day127)を「どちらも汎化性能を上げる手法だから似たようなもの」と混同する | どちらも「Kaggle上位解法でよく見る安定化テクニック」という共通点だけで判断してしまう | SAMは「1ステップの更新の仕方」自体を変える手法であるのに対し、EMAは「学習ループの外側から重みの履歴を平均する」後処理的な手法。両者は独立しており、実際にはSAM+EMAを併用する実装も珍しくない |
🚀 次のステップ
- 発展: 今日実装したtrain_with_emaのtail区間について、raw weightとEMA weightのtest精度をエポックごとに折れ線グラフでプロットしてみましょう。「raw weightはギザギザ、EMA weightはなだらか」という直感が、数値だけでなく視覚的にも確認できます
- 次回予告: Day 129 — SOTA手法の追跡・論文実装④。引き続きPhase6テーマ1の枠内で、今日と同じ「論文の数式→実装」ワークフローを別の題材(Mixup: 2つの訓練サンプルを線形補間して合成するデータ拡張手法、x' = λxi + (1−λ)xj)に適用し、論文を読んで実装に落とし込む力をもう一段鍛えます
Phase 6 の学習マップ(全20テーマ)
Day126でLabel Smoothing(損失関数の改造)、Day127でSAM(Optimizerの改造)、Day128でEMA(学習ループ外での重みの後処理)と、「論文→実装」ワークフローを異なる切り口で3回反復。次はさらに別のSOTA手法へ。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: