HN 日本語サマリー

← 一覧へ戻る
AI・機械学習

loss.backward() が実際に行っていること

What loss.backward() actually does (oraziorillo.com)

9 pointsby oraziorillo3 コメント

要約

このブログ記事は、PyTorchのような深層学習フレームワークにおける `loss.backward()` の内部メカニズムを解説しています。ニューラルネットワークの訓練において、損失関数(loss)の勾配を計算するために、計算グラフを逆方向にたどる逆伝播(バックプロパゲーション)の原理を、簡単な例とカスタム自動微分エンジン「micrograd」を用いて説明しています。これにより、多数のパラメータに対する勾配を効率的に計算する方法を理解できます。

全文翻訳

loss.backward() が実際に行っていること 2026年7月9日 ニューラルネットワークを訓練したことがあるなら、その裏で何が起こっているのか正確には言えないまま、loss.backward() と入力したことがある可能性が高いでしょう。この記事の終わりまでには、PyTorchのようなエンジンの背後にあるコアメカニズムを十分に理解し、自分で書けるようになるはずです。 実践的に進めるために、ここでは私が最近書いたスカラー値自動微分エンジンであるmicrogradを頻繁に参照します。これはAndrej Karpathyのmicrogradに触発されたものです。 C言語を知っている必要はありません。簡単なコードが読めて、ニューラルネットワークと微積分に関する基本的な知識があれば大丈夫です。 そもそも何を計算しているのか? ニューラルネットワークの訓練とは、損失を最小化することです。損失とは、ネットワークが現在どれだけ間違っているかを測定する単一の数値です。勾配降下法でこれを最小化します。つまり、各パラメータを、損失を下げる方向に少しずつ動かし、それを繰り返します。 そのためには、各パラメータ p について、次のことを知る必要があります。 もし p をほんの少し動かしたら、損失はどれだけ、そしてどちらの方向に変化するか? その量が導関数 dLoss/dp です。 各パラメータについてそれを計算し、それに対して小さなステップを踏み出せば、学習の1ステップが完了します。 p->data -= learning_rate * p->grad; /* 学習率 * 勾配 */ したがって、問題全体は1つの質問に集約されます。どうすれば、すべての p について dLoss/dp を一度に取得できるでしょうか?実際のネットワークには数千から数兆ものパラメータがあり、損失は1つだけです。この形状(多数の入力、1つの出力)を覚えておいてください。なぜなら、後続のすべてがうまくいく理由だからです。 一度に1つの操作:局所的な導関数と連鎖律 巨大なネットワーク全体の導関数を知る必要はありません。個々の操作の導関数を、孤立した状態で知るだけで十分です。 この記事で使い続ける2つの操作について説明します。 加算、a + b:a を 1 だけ動かすと、結果は 1 だけ動きます。したがって、∂(a+b)/∂a = 1 です。 乗算、a ⋅ b:a を動かすと、結果は b だけ変化します。したがって、∂(a⋅b)/∂a = b です。つまり、もう一方のオペランドです。 減算、双曲線正接など、他のすべての演算子も同様に機能します。簡単にするために、加算と乗算のみを示し、他はこれらの2つのバリエーションとします。 これらは局所的な導関数です。つまり、他のオペランドを固定した状態で、ある操作の出力を直接の入力の1つを動かしたときにどのように変化するかを示します。 興味深いのは、それらを結合すること、それが連鎖律です。z が y に依存し、y が x に依存する場合: dz/dx = dz/dy ⋅ dy/dx 導関数は、パスに沿って乗算することで合成されます。x が遠く離れた z にどのように影響するかを知るには、z から x へのパスをたどり、通過する局所的な導関数を乗算します。それがバックプロパゲーションのエンジンです。 もう1つの注意点があります。x が複数のパスを通って z に到達する場合、寄与は加算されます。 dz/dx = (dz/dx)path 1 + (dz/dx)path 2 + ⋯ 私たちの実行例 この記事の残りの部分で、この例を使用します。 Value *a = value_create_leaf(2.0); Value *b = value_create_leaf(3.0); Value *e = value_mul(a, b); /* e = a * b = 6 */ Value *L = value_mul(e, a); /* L = e * a = 12 */ Value は micrograd の唯一の基本型です。単一の倍精度数(double)をラップします。関数の正確な名前は一時的に無視して、数学だけを読んでください。まず e = a ⋅ b を計算し、次に L = e ⋅ a を計算します。代入すると、L = a ⋅ b ⋅ a = a² ⋅ b となります。a=2、b=3 の場合、L=12 です。 a は2回使用されていることに注意してください。一度は e を作るために、もう一度は直接 L のためにです。これは上記の複数パスのケースであり、4行のコードに隠れています。 手動で L を微分してみましょう。 dL/da と dL/db を求めたいのです。 出力から始めて逆方向に連鎖させます。 L = e ⋅ a なので、局所的な導関数は ∂(e⋅a)/∂e = a = 2 と ∂(e⋅a)/∂a = e = 6 です。 e = a ⋅ b なので、∂e/∂a = b = 3 と ∂e/∂b = a = 2 です。 次に、連鎖律でそれらを組み立てます。 b は簡単です。b は e を通してのみ L に到達します。 dL/db = dL/de ⋅ ∂e/∂b = a ⋅ a = 2 ⋅ 2 = 4 a は興味深いケースです。2つのパス(e を通るパスと直接のパス)を通って L に到達するため、それらを加算します。 dL/da = (dL/de ⋅ ∂e/∂a) + (∂L/∂a)directly = a ⋅ b + e = 2 ⋅ 3 + 6 = 12 e を通るパスからの 6 と、直接のパスからの 6 で、合計 12 です。 なぜ逆方向に進むのか これで導関数を計算するためのすべてのピースが揃いました。しかし、それらをどのように計算するかは非常に重要です。 連鎖律を適用できる方向は2つあります。 順方向(Forward)。1つの入力(例えば a)を選択し、その影響をグラフを通して順方向にプッシュします。つまり、∂e/∂a を計算し、次に ∂L/∂a を計算します。1回のスキャンで、a に関するすべての導関数が得られます。しかし、a についてしか学べませんでした。b についても知るには、もう一度全体のスキャンを実行する必要があります。入力ごとに1回のスキャンです。 逆方向(Backward)。出力 L から開始し、dL/dL = 1 でシードし、入力を向かって逆方向に影響をプッシュします。1回のスキャンで、dL/de、dL/da、dL/db(出力に対するすべての入力の導関数)がすべて埋められます。1回のスキャンで、すべての入力の導関数が得られます。 ここで、私たちの問題の形状を思い出してください。多数のパラメータ、1つの損失です。順方向モードはパラメータごとに1回のスキャンを必要とします。パラメータが100万個ある場合は壊滅的です。逆方向モードは1回のスキャンで済み、すべてのパラメータの勾配を一度に提供します。この非対称性が、ニューラルネットワークがそもそも訓練可能である唯一の理由です。 これは逆モード自動微分であり、「バックプロパゲーション」はMLの世界でのその名前です。microgradが行うグラフ構築後のすべては、単一の逆方向スキャンです。 ノードの勾配がその背後にあるノードに供給される前に、その勾配が完了する必要があることに注意してください。例えば、dL/da をもう一度見てみましょう。e を通るパスと直接のパスの両方がその寄与を統合するまで、それは完了しませんでした。ノードが間違った順序で訪問されると、半加算された勾配を伝播してしまいます。 これを避けるために、グラフをソートして、各ノードがリスト内で依存するノードの後に来るようにし、その後リストを逆順に歩きます。これが逆トポロジカル順序であり、バックプロパゲーションアルゴリズムに従った逆方向パスの最初のステップです。 グラフは自己構築される したがって、逆方向パスにはグラフが必要です。つまり、どの操作がどのオペランドからどの値を生み出したかの記録です。しかし、あなたは決してそのグラフを明示的に構築しません。単に順方向計算を実行するだけで、グラフは副作用として現れます。 秘密は、Value が単なる数値ではないということです。それは、どこから来たのかを覚えている数値です。 typedef struct Value { double data; /* このノードが保持するスカラー値 */ double grad; /* dLoss/dThisValue、逆方向パスによって埋められる */ struct Value **prev; /* オペランド(グラフ内の前のノード) */ int32_t op_code; /* このValueを生み出した操作 */ /* メモリ管理フィールドは省略 */ } Value; 操作によって生成された Value は、prev を介して計算元となったオペランドを指し、op_code を介して操作自体をタグ付けします。任意のノードから prev ポインタをたどると、計算グラフを逆方向に歩くことになります。 しかし、ノードはどうやって配線されるのでしょうか?単一の操作が何をするかを見てみましょう。乗算の例です(エラー処理は省略しています)。 Value *value_mul(Value