Day 128 — SOTA手法の追跡・論文実装③ — 学習を安定させるExponential Moving Average(EMA)

2026-08-18 金 / Phase 6(Grandmaster、テーマ1/5 の 3日目) 理論→コーディング 数式→実装の翻訳 / 重みの平滑化 / raw vs EMA weightの比較

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

🔁
Day126でLabel Smoothing、Day127でSAMを扱いました。今日はPhase6テーマ1「SOTA手法の追跡・論文実装」の3日目として、Day127の予告どおりExponential Moving Average(EMA、指数移動平均)による重みの安定化を扱います。SAMが「更新の仕方(Optimizerの中身)」を改造する手法だったのに対し、EMAは「学習ループの外側から、できあがった重みの履歴を後処理する」という全く違うアプローチです。
📈
なぜ「重みの履歴を平均する」だけで効果があるのか: 学習率がある程度大きい状態が続くと、重みwは損失の谷底にきれいに収束せず、谷底の周りをウロウロと小刻みに揺れ続けることがよくあります。この状態で「たまたま最後に測った1点」の重みをそのまま採用すると、そのタイミングが運悪く調子の悪いときだった場合、テスト精度が本来の実力より低く出てしまうことがあります。株価の日次終値と25日移動平均線の関係と同じで、EMAは重みに対して同じ平滑化を行うという発想です。
🎯
今日の題材: Exponential Moving Average of weights(Polyak averagingの系譜、多くのSOTA実装・論文で標準的に使われる手法)

数式は非常にシンプルで核心は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の適用ステップ(今日の学習ループの流れ)

1

①forward/backward

通常どおりlossを計算しbackward()する

2

②optimizer.step()

生のパラメータθtを通常どおり更新する

3

③ema.update(model)

更新直後の生の重みを"覗き見"してEMA重みも1ステップ更新する

4

④評価時にEMA重みを注入

load_state_dictでモデルにEMA重みを一時的に差し替える

5

⑤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 程度で固定し、後半エポックでも重みが揺れ続けるようにする
1
数式→コードの翻訳(メイン): EMAの数式を、学習ループの各ステップの直後に呼び出すEMAクラス(update(model)メソッドを持つ)として実装する
2
通常の学習ループ: epochs=80、lr=0.02程度の減衰なし固定学習率で学習する(後半でも重みが揺れ続ける状況を意図的に作る)
3
EMA付き学習: optimizer.step()の直後に毎ステップema.update(model)を呼び、EMA重みを並行更新する(decay=0.99)
4
比較実験: 最後20エポックについて、raw weightとEMA weightそれぞれのtest精度を記録し、平均・標準偏差を比較する
5
decayの感度確認: decayを[0.9, 0.99, 0.999]の3通りで試し、最後20エポックの平均・標準偏差の変化を確認する
6
(考察): EMA weightがraw weightと比べ「ばらつき」「平均」にどう影響するかを100字程度で結論づける

💡 ヒント

ヒント1方向性

EMAの実装で最も重要なのは、「EMA重みは学習に直接使わない」という点です。EMAはあくまで「観測用の副産物」であり、loss.backward()optimizer.step()が更新するのは常に生のモデルパラメータです。EMA重みはoptimizer.step()の後に、生の重みを"覗き見"して更新するだけの、学習ループに対して受け身の存在だと考えてください。

ヒント2アプローチ
  • 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)で機械的に計算できる。「ばらつきが小さい=安定している」という評価軸を数値で語れるようにする
ヒント3コード骨格
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  (揺れは最小だが追従が遅く平均が伸び悩む)
🧭
タスク6・考察: 学習率を大きめに固定し重みが後半エポックでも揺れ続ける状況で、EMA weightはraw weightに対しテスト精度の標準偏差(ばらつき)を明確に縮小し、平均も同等以上だった。ただしdecayを大きくしすぎる(0.999)と最新の重みへの追従が遅れ、平均精度がむしろ下がる傾向も確認でき、decayは「安定性」と「追従の速さ」のトレードオフとして選ぶ必要があると分かった。

📊 安定性の可視化(最後20エポックの test精度: raw vs EMA)

最後20エポックのtest精度(raw weight=赤 / EMA weight=緑) 0.60 0.80 epoch61 epoch80 EMA(安定・なだらか) raw(揺れが残る) → EMA weightはエポックごとの上下動が小さく、平均でも安定して高い水準を保ちやすい
🔍
この図は模式的なイメージ(実際の数値は実行環境により変動)。重要なのは「rawの折れ線がギザギザ=1点だけを見ると運の要素が大きい」のに対し、「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
🔗
なぜこうするか: θEMAt = d·θEMAt−1 + (1−d)θtは「前回のEMA重み」と「今の生の重み」の2つを知っていれば計算できる。そのためEMAは状態(前回のEMA重み)をどこかに保持し続ける必要があるという設計上の要請が生まれ、自然と「self.shadowという辞書を持つクラス」という実装形になる。SAM(Day127)が「1ステップの中で完結する関数」だったのに対し、EMAは「学習の全ステップをまたいで状態を蓄積し続けるオブジェクト」である点が対照的である。

2なぜ「evaluateの前後でload_state_dictを2回呼ぶ」手順が必要か

backup = ema.apply_to(model)   # EMA重みを注入
ema_acc = evaluate(model)
model.load_state_dict(backup)  # raw weightへ復元
🧩
なぜこうするか: EMA重みは学習には使われず、あくまで「評価専用の別人格」として扱われる。もし復元を忘れてEMA重みのままループの次のイテレーションに進んでしまうと、次のステップのloss.backward()がEMA重みに対して計算されてしまい、学習全体が壊れる。「EMA重みは覗き見るだけで、学習に使う本体は常にraw weight」という原則を、コード上で明示的に守る必要がある。

3decayによって「安定性」と「追従の速さ」がどう変わるかを実験で確認する

🧪
なぜこうするか: 数式だけを見るとdecayは0〜1のハイパーパラメータとしか分からないが、実際に[0.9, 0.99, 0.999]を試して比較することで、「decayが大きいほど過去の記憶を強く保つ=ばらつきは減るが、最新の良い重みへの反応も遅れる」というトレードオフが体感できる。論文を読んだだけでは掴めない「効き方の感覚」を、小さな実験で先に掴んでおくことが実務でのチューニング速度に直結する。

4tail(最後20エポックだけを見る)という比較設計の意図

⚖️
なぜこうするか: EMAは初期化時点でraw weightと同じ値からスタートするが、decayが大きいほど「昔の値(=学習が進んでいない初期の重み)」への依存が長く残るため、学習序盤はEMA重みの方がraw weightより性能が低いことがある。この「立ち上がりの遅れ」を比較に含めると不当に不利な評価になる。実務でも「EMAは学習が十分に進んでから評価・採用する」のが定石であり、tailで最後の数十エポックだけを比較するのはこの定石をそのまま実験設計に反映したものである。

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

📉
「指数移動平均」を、株価の移動平均線で理解する:

「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テーマ)

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

Day126でLabel Smoothing(損失関数の改造)、Day127でSAM(Optimizerの改造)、Day128でEMA(学習ループ外での重みの後処理)と、「論文→実装」ワークフローを異なる切り口で3回反復。次はさらに別のSOTA手法へ。

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

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