3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解】高校数学 (+α) で理解する自動微分

Last updated at Posted at 2025-12-10

はじめに

この記事は、42Tokyo Advent Calendar 2025 の11日目の記事です。

※ 余談ですが、去年も記事を書いています: Rustで作るラムダ計算インタプリタ【基本概念から実装まで】。関数型やラムダ計算に興味のある方はこちらも読んでみてください。

深層学習において、モデルがどれだけ複雑でも 勾配法 によって統一的に最適化が行える、ということはご存知の方も多いのではないでしょうか。

そこで必要になる勾配は、PyTorchのような深層学習フレームワークを使えば backward() を呼ぶだけで簡単に計算されますが、その裏側で動いているアルゴリズムは 自動微分 と呼ばれるものです。

この記事では、自動微分アルゴリズムについて、高校数学レベルの微積分 (連鎖律) の定義から出発し、それがどのように計算グラフ上のアルゴリズムに落とし込まれるのか、その数学的な対応関係を紐解きます。

TL;DR

  • 自動微分の正体は、解析学における 合成関数の微分公式 (連鎖律: Chain Rule) そのものです
  • 複雑に見える多変数関数の連鎖律は、計算グラフ上では以下の極めて単純なルールに帰着します
    • 直列のノード接続 $\rightarrow$ 導関数の積 (掛け算)
    • 並列のノード接続 $\rightarrow$ 導関数の和 (足し算)
  • 自動微分とは、この「積」と「和」のルールに従って、グラフ上のパス (経路) の寄与を集計し、与えられた入力点での関数の導関数を求めるアルゴリズムに他なりません

placeholder_tldr.png


1. 直列は掛け算

まずは基本となる1変数の合成関数を考えます。
関数 $y = f(u)$ と $u = g(x)$ があるとき、合成関数 $y = f(g(x))$ の $x$ に対する導関数は以下の連鎖律で与えられます。

\frac{dy}{dx} = \frac{dy}{du} \frac{du}{dx}

この式は、導関数のになっています。これを計算グラフの視点で見ると、「$x$ から $u$、そして $y$ へ」という信号の流れにおける、各ステップの感度を掛け合わせる操作に対応します。

つまり、計算グラフにおいてノードが直列に繋がっている場合、その勾配は各エッジの微分の掛け算となります。

placeholder_series_chain.png


2. 並列は足し算

次に、多変数の場合を考えます。

多変数関数 $z = f(x_1, x_2, \dots, x_d)$ において、各変数 $x_i$ がさらにある変数 $t$ の関数 $x_i = g_i(t)$ であるとします。

このとき、$z$ の $t$ に対する導関数 $\frac{dz}{dt}$ (あるいは偏導関数 $\frac{\partial z}{\partial t}$) は、多変数関数の連鎖律によって次のように表されます。

\begin{aligned}
\frac{\partial z}{\partial t} &= \frac{\partial z}{\partial x_1}\frac{\partial x_1}{\partial t} + \frac{\partial z}{\partial x_2}\frac{\partial x_2}{\partial t} + \dots + \frac{\partial z}{\partial x_d}\frac{\partial x_d}{\partial t} \\
&= \sum_{i=1}^{d} \frac{\partial z}{\partial x_i}\frac{\partial x_i}{\partial t}
\end{aligned}

これを計算グラフの視点で見ると、$t$ から $z$ へ向かう経路が $x_1, x_2, \dots$ と複数に分岐している状態、つまり並列接続になっています。
この数式は、「並列な経路からの勾配は、すべて足し合わせる」というルールに対応します。

これは、全微分

dz = \frac{\partial z}{\partial x_1}dx_1 + \dots + \frac{\partial z}{\partial x_d}dx_d

の両辺を微小変化 $dt$ で割り、各 $dx_i = \frac{\partial x_i}{\partial t}dt$ を代入した形と比べると直感的です。
「入力の微小変化が出力にどれだけ寄与したか」を並列パスごとに計算し、それらを合算するのがグラフ上での和の操作に相当します。

placeholder_parallel_chain.png


3. ニューラルネットワークの計算グラフ

ここまでの「直列=積」「並列=和」という2つのルールを組み合わせることで、どんなに複雑なニューラルネットワークの勾配計算も説明できます。

例として、分岐と合流を含むシンプルな計算グラフを考えます (1変数1出力) 。

placeholder_nn_paths.png

入力 $x$ から出力 $y$ に至る過程で、経路が上側 ($u_1 \to z_1 \to v$) と下側 ($u_2 \to z_2 \to v$) に分岐し、再び合流する場合、その導関数 $\frac{dy}{dx}$ は以下のようになります。

\frac{dy}{dx} = \underbrace{\frac{du_1}{dx}\frac{dz_1}{du_1}\frac{dv}{dz_1}\frac{dy}{dv}}_{\text{経路1 (積) }} + \underbrace{\frac{du_2}{dx}\frac{dz_2}{du_2}\frac{dv}{dz_2}\frac{dy}{dv}}_{\text{経路2 (積) }}

これをグラフ構造と照らし合わせてみると、

  1. 直列に繋がっているエッジの導関数を掛け合わせる (パスごとの積)
  2. 並列に分岐しているパスの結果を足し合わせる (パス同士の和)

自動微分 (正確には逆列挙型のReverse Mode AD) は、このグラフ上の計算を効率的に行うためのアルゴリズムに過ぎません。


3.1. なぜ逆方向に計算するのか

ここまで見てきたニューラルネットの例では、勾配が出力から入力へと逆方向に流れていました。自動微分には、入力から出力へ勾配を運ぶ Forward Mode と、出力から入力へ勾配を戻す Reverse Mode の2方式があります。

Forward Mode (順方向)

  • $x \mapsto y$ の順伝播と同時に、ある入力方向に沿った感度 $\frac{dy}{dx}$ を追跡する
  • 入力次元が $N$ のスカラー関数 $f:\mathbb{R}^N \rightarrow \mathbb{R}$ の完全な勾配を得るには、方向を変えながら $N$ 回 のパスが必要

Reverse Mode (逆方向)

  • 出力側で $\frac{\partial y}{\partial y}=1$ をセットし、計算グラフを逆順にたどりながら勾配を蓄積する
  • 1回の逆伝播 だけで全入力の勾配 $\nabla f$ を同時に取得できる。出力が1つ (損失関数) のニューラルネットでは、この利点が決定的

パラメータ数が数百万〜数十億にも及ぶ深層学習モデルで Forward Mode を $N$ 回まわすのは現実的ではなく、それゆえReverse Modeが用いられることが標準的です。


4. 具体的な関数で確かめてみる

理論が正しいことを確認するために、少し複雑な関数を使って実際に手計算で得られる結果とpytorchを使って得られる結果を比べてみましょう。

ここでは、以下の関数を用いて検証します:

y = \sqrt{x} (\log x + \frac{1}{x^3})

以降は定義域を $x>0$ と仮定します。この範囲であれば各項が滑らかに定義され、微分可能性が担保されます。

4-1. 記号微分

まずは、実際に上の関数の微分係数を手動で計算してみます。

\begin{aligned}
\frac{dy}{dx} &= \frac{1}{2 \sqrt{x}} \left( \log x + \frac{1}{x^3} \right) + \sqrt{x} \left( \frac{1}{x} - \frac{3}{x^4} \right) \\
&= \frac{\log x}{2 \sqrt{x}} + \frac{1}{\sqrt{x}} - \frac{5}{2} \frac{1}{x^3 \sqrt{x}}
\end{aligned}

これに、例として $x = 4$ を代入して計算すると、

\begin{aligned}
\frac{dy}{dx} |_{x=4} &= \frac{\log 4}{4} + \frac{1}{2} - \frac{5}{2} \cdot \frac{1}{128} \\
&= \frac{\log 2}{2} + \frac{123}{256} \\
&\simeq 0.83
\end{aligned}

4-2. 計算グラフの可視化: 順伝播 (Forward)

続いて、以下のステップを自動微分で機械的に解いてみましょう。

PyTorchのようなフレームワークは、私たちがコードを書くと裏側で計算グラフと呼ばれるネットワーク構造を構築します。
今回の関数 $y = \sqrt{x} (\log x + \frac{1}{x^3})$ を要素ごとの計算に分解すると、以下のようなグラフになります。

forward_step0.png

  • **ノード名 (箱の上段) ** … 与式と一致するよう x, a, b, c, d, y の変数を用意しています。$a=\sqrt{x}$、$b=\log x$、$c=x^{-3}$、$d=b+c$、$y=a\cdot d$ を表します。
  • **左下 (data 列) ** … 順伝播で得られたスカラー値です。まだ計算が到達していないノードは空欄になります。
  • **右下 (grad 列) ** … 逆伝播で得られた勾配です。逆伝播前は空欄で、y.backward() を実行して初めて値が入ります。

Forwardでの処理の可視化

  1. Step 1 – $x$ から $\sqrt{x}, \log x, x^{-3}$ へ同時に分岐し、$a, b, c$ に値が入ります

forward_step1.png

  1. Step 2 – 並列に存在する $b$ と $c$ から $d=b+c$ を計算します

forward_step2.png

  1. Step 3 – 最後に $a$ と $d$ を掛け合わせて $y$ が完成します。ここで初めて出力が得られ、forward フェーズは終了です

forward_step3.png

4-3. 計算グラフの可視化: 逆伝播 (Backward)

y.backward() を実行すると、今度はグラフを右から左へ遡る処理が始まります。これが逆伝播です。各ノードの右下に、赤色で勾配が埋まっていきます。

Backwardでの処理の可視化

Step 1 – 出力から積の内側へ
まず $\frac{\partial y}{\partial y}=1$ をセットします。 $y = a \cdot d$ですから、
$\frac{\partial y}{\partial a} = d = 1.40$、$\frac{\partial y}{\partial d} = a = 2.0$ と計算されます。

backward_step1.png

Step 2 – 和の分配
$d=b+c$ なので、$\frac{\partial d}{\partial b} = \frac{\partial d}{\partial c} = 1$ です。

従って、

\frac{\partial y}{\partial b} = \frac{\partial y}{\partial d} \cdot \frac{\partial d}{\partial b} = \frac{\partial y}{\partial d}
\frac{\partial y}{\partial c} = \frac{\partial y}{\partial d} \cdot \frac{\partial d}{\partial c} = \frac{\partial y}{\partial d}

として計算され、 $d$に流れた勾配がそのまま $b$ と $c$ のそれぞれにコピーされます。

backward_step2.png

Step 3 – 入力へ集約
最終的に $x$ が3つの経路 ($x\to a$, $x\to b$, $x\to c$) から勾配を受け取り、その和として $\frac{dy}{dx}$ が確定します。

\begin{aligned}
\frac{\partial y}{\partial x}
&= \underbrace{\frac{\partial y}{\partial a} \cdot \frac{\partial a}{\partial x}}_{(1)\;x \rightarrow a}
 + \underbrace{\frac{\partial y}{\partial b} \cdot \frac{\partial b}{\partial x}}_{(2)\;x \rightarrow b}
 + \underbrace{\frac{\partial y}{\partial c} \cdot \frac{\partial c}{\partial x}}_{(3)\;x \rightarrow c}
\end{aligned}

(1) ~ (3) のそれぞれ勾配の計算式は、

\frac{\partial a}{\partial x} = \frac{1}{2 \sqrt{x}}, \frac{\partial b}{\partial x} = \frac{1}{x}, \frac{\partial c}{\partial x} = - \frac{3}{x^4}

となるので、これらを合算すると、

  \begin{aligned}
  \frac{\partial y}{\partial x}
  &= \frac{\partial y}{\partial a} \cdot \frac{\partial a}{\partial x}
   + \frac{\partial y}{\partial b} \cdot \frac{\partial b}{\partial x}
   + \frac{\partial y}{\partial c} \cdot \frac{\partial c}{\partial x} \\
  &= 1.40 \cdot \frac{1}{4} + 2.00 \cdot \frac{1}{4} + 2.00 \cdot - \frac{3}{256} \\
  &\simeq 0.35 + 0.50 - 0.02 \\
  &= 0.83
  \end{aligned}

backward_step3.png

以上のように自動微分は「グラフの接続ルールに従って値を流す」、という非常に機械的な手順で複雑な勾配の計算を可能にしています。

4-4. PyTorchによる自動微分

では実際にPyTorchを使ってこちらの計算をしてみましょう。

import torch
print(torch.__version__)

x_tensor = torch.tensor(4.0, requires_grad=True)

# 計算グラフの構築
# y = sqrt(x) * (log(x) + x^-3)
a = torch.sqrt(x_tensor)
b = torch.log(x_tensor)
c = x_tensor ** -3
d = b + c
y = a * d

# PyTorchでは通常、メモリ節約のために中間変数の勾配は計算直後に破棄される
# ここでは確認のために明示的に保持
for t in (a, b, c, d, y):
    t.retain_grad()

# 逆伝播 (Backward)
y.backward()

for name, tensor in [('a', a), ('b', b), ('c', c), ('d', d), ('y', y)]:
    print(f"{name}.data={tensor.item():.4f}, {name}.grad={tensor.grad}")

print(f"AutoGrad Result (x=4.0): {x_tensor.grad.item():.2f}")

実行結果:

2.9.0+cu126
a.data=2.0000, a.grad=1.4019193649291992
b.data=1.3863, b.grad=2.0
c.data=0.0156, c.grad=2.0
d.data=1.4019, d.grad=2.0
y.data=2.8038, y.grad=1.0
AutoGrad Result (x=4.0): 0.83

ここまで手計算で確認してきた値と一致していることがわかります。

PyTorch は torch.Tensor 型に対して、+, -, *, /, ** などの演算子や torch.sqrt, torch.log といった関数をオーバーライド (あるいは定義) しています。これらの演算が呼び出されるたびに内部で演算結果ノードとエッジが計算グラフに登録される仕組みです。そのため、演算に参加する数値はすべて Tensor でなければならない ことに注意しましょう。

この型の注意点さえ守れば、PyTorch が自動的に計算グラフを組み立て、backward() 一発で本記事のような複雑な勾配計算を人間の手計算と同じレベルの正確さで実行してくれます。

実際の PyTorch が内部で保持する計算グラフは有向非巡回グラフ (DAG) です。torch.Tensor 同士の演算が呼ばれるたびに、新しいノードとエッジが追加され、出力テンソルはそれらを指す参照を保持します。
中間ノードは「入力テンソル群 + 演算 = 出力テンソル」という規則で逐次生成され、ループ構造を含まないためグラフにサイクルは生じません。この構造があるからこそ、逆伝播では終端ノードからトポロジカル順序で勾配を伝搬できます。

ここまでの例から自然に理解されるように、PyTorch では loss.backward() を呼ぶたびに勾配が既存値に加算されます (accumulate_grad.h) 。

学習ループでは optimizer.step() のあとに optimizer.zero_grad()model.zero_grad() を呼び忘れないように気をつけましょう。

まとめ

デモ程度の関数であれば手計算やscipyのような数値微分でも十分ですが、本格的なニューラルネットワークではパラメータ数が数百万〜数十億のオーダーになる場合もあります。

こうした際に、「最終的な出力が入力に関しての基本的な演算の組み合わせで表現できるならば、確実に勾配を求めることができる」という保証は極めて重要であり、その土台が自動微分アルゴリズムになります。

余談ですが自動微分フレームワークとしては以下のmicrogradが有名です。
単変数用の自動微分エンジンがわずか94行で実装されています。

皆さんもこれを機に、自動微分エンジン実装に挑戦してみてください!


(以下、私が実装してみたものです。よければ見てみてください!)

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?