学習が壊れた時の診断学 — loss発散・NaN・停滞の切り分け
学習が壊れる形は「発散」「NaN」「停滞」の3つしかありません。損失曲線の形から原因を絞り込む対応表を軸に、なぜ発散するのかを式と動く図で押さえ、NaNの発生地点を特定するコード、停滞の切り分け手順までを前提知識ゼロから解説します。
On the difficulty of training Recurrent Neural Networks
一次資料 — この記事の根拠
この解説の公開 2026-08-27
On the difficulty of training Recurrent Neural NetworksarXiv:1211.5063論文ページ·PDFMixed Precision TrainingarXiv:1710.03740論文ページ·PDF
比喩: エンジンがかからない車を、順番に疑う
車が動かないとき、いきなりエンジンを分解する人はいません。「セルは回るか」「回るならガソリンは入っているか」「入っているなら火花は飛んでいるか」と、症状から原因の候補を絞る順番が決まっています。整備士が速いのは手先が器用だからではなく、この順番を持っているからです。
ニューラルネットの学習も同じです。損失(loss)のグラフが変な形をしているとき、多くの人はいきなりハイパーパラメータをいじり始めます。学習率を10分の1にしてみる、バッチサイズを変えてみる。数時間かけて回して、また変な形になる。これは「エンジンがかからないから、とりあえずタイヤを交換する」に近い行為です。
この記事の目的は、その順番を手に入れることです。理屈より先に、まず「壊れ方の分類」を頭に入れてください。
学習の壊れ方は3種類しかない
見た目は無数にあるようで、実際に起きているのは次の3つのどれかです。
- 発散(diverge): 損失が下がるどころか、増え続ける。たいてい指数的に跳ね上がって、数十ステップで手がつけられない値になる。
- NaN: 損失が
nanやinfになる。一度なると以降ずっとnanのまま、二度と戻らない。 - 停滞(plateau): 損失が下がらない、あるいは途中で下げ止まる。エラーは出ないので一番タチが悪い。
この3つは原因の集合が違います。発散は「更新が大きすぎる」ことがほぼ全て。NaNは「数値の範囲を踏み外した」ことがほぼ全て。停滞は原因が最も散らばっていて、データ・実装・最適化のどこにでもあり得ます。だから最初にやるのは、パラメータをいじることではなく、いま自分が3つのどれを見ているのかを確定させることです。
症状→原因の対応表
損失曲線と、いくつかの計測値の組み合わせから候補を絞ります。ここでいう「勾配ノルム」とは、全パラメータの勾配を1本の長いベクトルとみなしたときの長さです。
| 症状 | 最も疑うべき原因 | 確認方法 | 対処 |
|---|---|---|---|
| 最初の数十ステップで損失が跳ね上がる | 学習率が大きすぎる | 勾配ノルムが単調に増える | 学習率を1/10、warmupを入れる |
| しばらく順調→突然スパイク→戻らない | 外れ値バッチ+クリップ無し | スパイク直前のバッチを保存して再現 | 勾配クリッピング(norm 1.0付近) |
損失が急に nan |
ゼロ割り・log(0)・fp16のオーバーフロー | nan になった最初のステップの入力を保存 |
後述のNaN特定手順へ |
損失がずっと ln(クラス数) 付近で平ら |
ラベルと入力の対応が壊れている | ラベルをシャッフルしても同じ損失か | データローダの結合部を確認 |
| 訓練損失は下がるが検証損失が上がる | 過学習 | 訓練/検証の差がいつ開いたか | 正則化・データ拡張・早期終了 |
| 訓練損失すら下がらない(1バッチでも) | 実装バグ(勾配が届いていない) | 1バッチ過学習テスト | requires_grad・optimizerの引数 |
| 途中から緩やかに下げ止まる | 学習率が大きすぎて底で振動 | 損失のギザギザ幅が減らない | 学習率を減衰させる |
| ステップごとに損失がガタガタ | バッチサイズが小さすぎる | 移動平均を取ると下がっている | バッチを増やす・勾配累積 |
fp16でだけ nan、fp32だと正常 |
勾配のアンダーフロー/オーバーフロー | 精度を切り替えて同一seedで比較 | loss scaling、bf16へ変更 |
この表は暗記するものではなく、「まず何を測るか」を決めるためのものです。どの行でも、対処の前に必ず「確認方法」が挟まっている点に注目してください。
なぜ発散するのか(式で見る)
勾配降下法の更新は、一行で書けます。
は ステップ目のパラメータ(重みの値)、 は損失を各パラメータで微分したもの=坂の傾き、(イータ)は学習率=一歩の大きさです。要するに「坂を下る向きに、学習率の分だけ動く」と言っているだけです。
では、一歩が大きすぎると何が起きるか。いちばん単純な谷、( は谷の急さ)で考えます。傾きは なので、式(1)に代入すると更新は になります。つまり毎ステップ、 という同じ数を掛け続けるだけです。
掛ける数の絶対値が1を超えると値は毎回大きくなる — つまり学習率が「谷の急さの2倍の逆数」を超えた瞬間に、そのパラメータは指数的に吹き飛びます。境界の手前でも が負なら符号が毎回反転し、谷の左右を跳ね回りながらゆっくり収束します。これが「損失がギザギザして下がりが悪い」状態の正体です。
重要なのは、この閾値が谷の急さ で決まることです。 は層ごと・パラメータごとに違うので、「モデル全体で1つの学習率」は常にどこかにとって大きすぎ、どこかにとって小さすぎる。AdamやRMSPropが勾配の大きさで割り算をしているのは、この違いをパラメータごとに吸収するためです。
図で確かめてほしいのは、発散が「だんだん悪くなる」現象ではなく、ある値を境に一気に切り替わる現象だという点です。だから発散したときに学習率を0.9倍しても意味がなく、10分の1にするのが定石になります。warmup(最初だけ学習率をゼロから上げる)が効くのも同じ理屈で、勾配が大きい初期だけ一歩を小さくしておけば境界を踏まずに済みます(学習率スケジュール — warmupとcosineの理由)。
NaNはどこで生まれるか
NaN(Not a Number)は「計算結果が数として定義できない」ときに生まれる値です。生まれ方は実質的に次の5つしかありません。
- 0 ÷ 0: 正規化で分母がゼロになる。マスクが全部ゼロのバッチ、標準偏差ゼロの特徴量など
- ∞ − ∞ / 0 × ∞: 片方が先に無限大になった後の演算
- log(0): 確率がちょうど0になった状態で対数を取る。交差エントロピーの典型
- √(負): 分散が丸め誤差で負になる、Adamの など
- オーバーフロー由来: 大きすぎる値が
infになり、そのinf同士の演算でNaNへ
NaNには厄介な性質が2つあります。ひとつは伝染すること。NaNを含む足し算・掛け算の結果は必ずNaNなので、1つのパラメータが汚染された次のステップには、それに繋がる全てが汚染されます。もうひとつは自分自身と等しくないこと。x != x が真になるのはNaNだけで、これがそのまま検出コードになります。
半精度(fp16)では範囲の狭さが直接効いてきます。fp16が表せる最大値は 65504 で、これを超えた値は inf に、おおよそ より小さい勾配はゼロに潰れます。fp32はおよそ まで表せるので同じ計算が通る。「fp32では通るのにfp16でだけ壊れる」ときは、ほぼ確実にこの範囲の問題です。対処が loss scaling(損失を定数倍して勾配を表現可能な範囲へ持ち上げる)で、それを不要にする形式が bf16 です(混合精度学習 — fp16/bf16/fp8で壊れずに速くする)。
NaNの発生地点を特定する(コード)
「NaNが出た」と分かってから、それがどの層のどの演算で生まれたかを特定するまでが本番です。まず、毎ステップ安く監視します。
loss = model(x, y)
if not torch.isfinite(loss):
torch.save({"x": x, "y": y, "step": step}, "nan_batch.pt") # 犯行現場を保存
raise RuntimeError(f"loss={loss.item()} at step {step}")
ここで注意すべきは、原因が1〜数ステップ前の更新にあることが多い点です。損失自体は健全でも、勾配が inf になってパラメータが汚染され、その次のステップで初めて損失がNaNになる。だから勾配ノルムも同時に見ます。
loss.backward()
gn = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
if not torch.isfinite(gn): # クリップ関数は総ノルムを返す
opt.zero_grad(set_to_none=True) # 汚染された勾配は捨てる
continue # このバッチは飛ばす
clip_grad_norm_ は全パラメータをまとめた勾配ノルムを返すので、クリッピングと監視を同時にこなせます。ノルムが有限でないバッチを捨てるだけで、外れ値1件で数時間の学習が壊れる事故はかなり防げます。
それでも発生源が分からないときの最後の手段がこれです。
torch.autograd.set_detect_anomaly(True) # 数倍遅くなる。原因特定の時だけ
これを有効にすると、逆伝播でNaNを生んだ演算が例外とともに報告され、順伝播側の該当行までスタックトレースが出ます。劇的に遅くなるので、常時オンにはしません。
停滞の3つの顔
停滞は原因が最も散らばっているので、さらに細かく分けます。
(a) 最初からまったく下がらない。 最適化の問題ではなく、ほぼ実装バグです。optimizerに一部のパラメータを渡し忘れている、requires_grad=False のまま凍結されている、zero_grad() の位置が間違っている、といったもの。見分ける最短の方法が次節の1バッチ過学習テストです。
(b) ある値で平らになる。 その値が に近いなら(10クラス分類なら約2.30)、モデルは「全クラス均等に予測する」以上のことを何も学べていません。データローダで画像とラベルを別々にシャッフルする、collate の実装で並びがずれる、といったバグは、エラーを出さずにこの症状だけを出します。
活性化が飽和している場合も同じ形になります。ReLUの入力が全て負になれば勾配はゼロで、その先の重みは永久に更新されません(dead ReLU)。sigmoid/tanhも入力の絶対値が大きい領域では傾きがほぼゼロです。重みの初期化と正規化層は、まさにこれを避けるための装置です(重みの初期化と正則化)。
(c) 訓練損失は下がるのに、検証損失が上がり始める。 これは壊れているのではなく過学習で、モデルが訓練データを覚え込む段階に入っただけです。対処は学習を直すことではなく、容量・正則化・データ量のバランスを取ることです。
切り分けの手順(上から順にやる)
診断は、安くて決定的なものから順に実行します。
- 初期損失を確認する。 ランダム初期化の分類器は全クラスを均等に予測するはずなので、交差エントロピーは ( はクラス数)になります。ずれていたら出力層かloss関数の使い方が間違っています。1ステップも回さずに分かる、最も安い検査です。
- 1バッチだけを過学習させる。 同じ数十サンプルを何百ステップも繰り返し学習させ、損失がほぼゼロまで落ちるかを見ます。落ちないならデータでも学習率でもなく実装バグ。落ちるなら勾配は正しく流れているので、以降はデータと最適化だけを疑えばよくなります。数分で終わります。
- 正則化を全部切る。 データ拡張・dropout・weight decayを一時的に止めて再現するか見る。再現しないなら犯人はその中にいます。
- 精度をfp32に固定する。 直るなら範囲の問題、直らないならアルゴリズムの問題と切り分けられます。
- seedを固定して再現させる。 再現しないバグは直せません。データ順序・初期化・dropoutのseedを全部固定し、同じステップで同じ症状が出ることを先に確認します。
この順番の要点は、1つ試すごとに候補が半分以下に減ることです。ハイパーパラメータを勘で動かす作業には、この性質がありません。
現場ではこう使う
誰が、いつ。 モデルを学習させる立場の人(MLエンジニア、研究者、基盤モデルの学習を回す人)が、新しいデータセットや構成で最初に回した時、そしてスケールを上げた時に必ず直面します。特にモデルを大きくした直後は要注意で、小さいモデルで安定していた学習率がそのままでは発散する形で表れます。
触るパラメータ・ツール名。 学習率 lr、warmupステップ数、勾配クリップの閾値(torch.nn.utils.clip_grad_norm_ の max_norm、言語モデルでは1.0前後が広く使われます)、Adamの eps(fp16では 1e-8 が小さすぎて分母がゼロに潰れることがあり、1e-6 程度に上げる運用があります)、AMPの GradScaler。監視はTensorBoardやWeights & Biasesで、損失に加えて必ず勾配ノルムと学習率を同じ画面に出します。この3本が揃っていれば、上の対応表はその場で引けます。
事故になる落とし穴。
- 勾配クリップの位置: AMP使用時にスケールされたままの勾配へクリップをかけると、閾値が実質的に無効になります。
scaler.unscale_(optimizer)を先に呼ぶこと。 - スパイクの再現を諦める: 「たまに起きる」で放置すると、本番の長時間学習で必ず起きます。発生時のバッチとseedを保存する仕組みを最初から入れておく。
- チェックポイントの上書き: NaNになった後の重みで上書きすると、正常な状態に戻れなくなります。保存前に
torch.isfiniteを確認し、世代を残す。 - 1回の実験で複数箇所を変える: 直っても何が効いたか分からず、次に同じ症状が出たときにゼロからやり直しになります。
問われること。 「損失が突然NaNになった。何から調べる?」は実務でも面接でも定番です。原因を当てにいくのではなく、(1) 再現条件を固定する → (2) 勾配ノルムと損失を同時に見て発生ステップを特定する → (3) fp32で再現するか確認して範囲の問題か切り分ける → (4) 該当バッチを保存して最小再現を作る、と順番で答えるのが筋です。対処法を並べるより、この順番を持っていることのほうが評価されます。
まとめ
- 学習の壊れ方は発散・NaN・停滞の3つ。まずどれかを確定させる
- 発散は学習率の閾値問題。境界を超えた瞬間に指数的に飛ぶので、対処は「少し下げる」ではなく「1桁下げる」
- NaNの生まれ方は5通りしかない。勾配ノルムを毎ステップ見て非有限のバッチを捨てるだけで多くが防げる
- 停滞は最も原因が広い。1バッチ過学習テストで実装バグかどうかを最初に切り分ける
- 診断は安くて決定的な検査から順に。1つ試すごとに候補が半分になる手順を持つ
損失が下がらない原因の多くは、モデルの理論ではなく計測の不足です。損失・勾配ノルム・学習率の3本を最初から記録しておけば、この対応表はそのまま使えます。
コメント
コメントにはログインが必要です