ディープラーニングの学習は、損失関数をパラメータで微分し、その勾配を使ってパラメータを更新するというプロセスの繰り返しです。この章では、PyTorchがこの微分計算を自動的に行う仕組み、autograd(自動微分)を深く理解します。計算グラフの構築から、requires_grad、backward()、勾配の蓄積とリセット、そしてtorch.no_grad()による微分の停止まで、実際に手を動かしながら学んでいきましょう。
学習目標
- ✅ 自動微分の仕組みを説明できる
- ✅ 計算グラフと勾配計算の関係を理解する
- ✅ requires_gradとbackward()を使いこなせる
- ✅ 勾配の蓄積と初期化を正しく扱える
- ✅ torch.no_grad()等で微分を適切に停止できる
1. 自動微分とは何か
自動微分(Automatic Differentiation, AD)とは、プログラム内で定義された関数の微分値(勾配)を、計算過程を利用して正確かつ効率的に求める技術です。ニューラルネットワークの学習では、損失関数を数百万個のパラメータそれぞれについて微分する必要があり、これを手作業で計算するのは現実的ではありません。autogradは、この膨大な微分計算を自動化してくれます。
微分計算の3つのアプローチ
関数の微分を計算する方法には、大きく分けて3つのアプローチがあります。
| 手法 | 仕組み | 課題 |
|---|---|---|
| 数値微分(Numerical Differentiation) | 有限差分 $\frac{f(x+h)-f(x-h)}{2h}$ で近似 | 丸め誤差・打ち切り誤差があり、変数が多いと計算コストが増大 |
| 記号微分(Symbolic Differentiation) | 数式そのものを微分規則で変形 | 式が複雑になると数式が爆発的に長くなる(式の膨張) |
| 自動微分(Automatic Differentiation) | 計算過程を記録し、連鎖律を機械的に適用 | 実装がやや複雑だが、誤差が小さく計算も効率的 |
PyTorchのautogradは、このうち自動微分、それも逆モード自動微分(Reverse-mode Automatic Differentiation)という方式を採用しています。逆モードは、入力変数が多く出力が少ない(典型的には、パラメータが数百万個あり、出力は損失というスカラー値1つ)というディープラーニングの構造に非常に適しています。
💡 なぜ逆モードがディープラーニングに向いているのか
逆モード自動微分は、1回の逆伝播(backward pass)で、出力1つに対するすべての入力の勾配を同時に計算できます。ニューラルネットワークは「大量のパラメータ → 1つの損失値」という構造を持つため、この特性が非常によく噛み合います。
まずは、数値微分とautogradの結果を比較して、autogradが正確な勾配を返すことを確認してみましょう。
import torch
def f(x):
return x ** 2
# 数値微分(有限差分法による近似)
def numerical_grad(f, x, eps=1e-5):
return (f(x + eps) - f(x - eps)) / (2 * eps)
x_value = 3.0
approx_grad = numerical_grad(f, x_value)
print(f"数値微分による近似勾配: {approx_grad:.6f}")
# 自動微分(PyTorch autograd)
x = torch.tensor(x_value, requires_grad=True)
y = f(x)
y.backward()
print(f"自動微分による正確な勾配: {x.grad.item():.6f}")
# f(x) = x^2 の導関数は 2x なので、x=3 のとき正解は 6.0
数値微分は近似値(6.000000に極めて近いがわずかに誤差を含む値)を返すのに対し、autogradは連鎖律に基づいて解析的に正確な値 6.0 を返します。この違いは、モデルが大規模になるほど学習の安定性に大きく影響します。
2. 計算グラフと勾配計算
autogradの中心にあるのが計算グラフ(Computational Graph)です。計算グラフとは、演算をノード(節点)、データの流れをエッジ(辺)として表現した有向非巡回グラフ(Directed Acyclic Graph, DAG)です。PyTorchでは、テンソルに対して演算を行うたびに、この計算グラフが実行時に動的に構築されます(この方式はDefine-by-Runと呼ばれます)。
順伝播と逆伝播
計算グラフには2つの向きの処理があります。
- 順伝播(Forward Pass): 入力から出力に向かって計算を実行し、同時に計算グラフを構築する
- 逆伝播(Backward Pass / Backpropagation): 出力から入力に向かって連鎖律(Chain Rule)を適用し、各変数の勾配を計算する
連鎖律とは、合成関数の微分を「各段階の局所的な微分の積」として計算できるという数学的な性質です。$y = g(f(x))$ のとき、次の式が成り立ちます。
$$\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx} \quad (u = f(x))$$
autogradは、順伝播で構築したグラフをたどりながら、この連鎖律をノードごとに機械的に適用していきます。実際にコードで確認してみましょう。
import torch
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 # y = x^2
z = y * 3 # z = 3y = 3x^2
w = z + 1 # w = z + 1 = 3x^2 + 1
# 各テンソルは、自分を生成した演算への参照(grad_fn)を持つ
print(f"y.grad_fn: {y.grad_fn}")
print(f"z.grad_fn: {z.grad_fn}")
print(f"w.grad_fn: {w.grad_fn}")
# 出力例:
# y.grad_fn: <PowBackward0 object at 0x7f8b1c0a3400>
# z.grad_fn: <MulBackward0 object at 0x7f8b1c0a3460>
# w.grad_fn: <AddBackward0 object at 0x7f8b1c0a34c0>
w.backward()
print(f"dw/dx = {x.grad.item()}")
# w = 3x^2 + 1 なので dw/dx = 6x = 6*2 = 12.0
それぞれのテンソルが持つ grad_fn 属性が、計算グラフのエッジをたどるための手がかりになります。w.backward() を呼び出すと、PyTorchは w → z → y → x という経路を逆向きにたどり、各段階の局所的な微分を掛け合わせて最終的な勾配を求めます。この様子を図で表すと、次のようになります。
実線は順伝播(forward)の計算経路、点線は逆伝播(backward)の勾配が流れる経路を表しています。計算グラフのおかげで、どれだけ演算が複雑に連なっても、連鎖律を機械的に適用するだけで正確な勾配が得られるのです。
3. torch.Tensorとrequires_grad
PyTorchのテンソルに勾配計算を追跡させるかどうかは、requires_grad属性で制御します。これがTrueのテンソルに対する演算は、すべて計算グラフに記録されます。
requires_gradの設定方法
設定方法は主に2通りあります。テンソル作成時に指定する方法と、作成後に有効化する方法です。
import torch
# スカラーのTensor(0次元)
x_scalar = torch.tensor(3.0, requires_grad=True)
print(f"x_scalar: {x_scalar}, requires_grad: {x_scalar.requires_grad}, is_leaf: {x_scalar.is_leaf}")
# ベクトルのTensor(1次元)
x_vector = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
print(f"x_vector: {x_vector}, requires_grad: {x_vector.requires_grad}, is_leaf: {x_vector.is_leaf}")
# 作成後にrequires_gradを有効化する
y = torch.randn(3)
print(f"作成直後のrequires_grad: {y.requires_grad}") # False
y.requires_grad_(True) # 末尾のアンダースコアはインプレース操作を示す
print(f"設定後のrequires_grad: {y.requires_grad}") # True
ここで登場した葉テンソル(Leaf Tensor)という概念にも触れておきましょう。ユーザーが直接作成したrequires_grad=Trueのテンソル(上の例のx_scalarやx_vector)は「葉」であり、is_leafがTrueになります。一方、演算によって新しく生成されたテンソルは「葉ではない」中間テンソルとなります。
⚠️ 整数型テンソルはrequires_gradにできない
勾配計算は浮動小数点数(または複素数)でのみ意味を持つため、整数型のテンソルにrequires_grad=Trueを指定するとエラーになります。以下のコードで確認してみましょう。
import torch
try:
int_tensor = torch.tensor([1, 2, 3], requires_grad=True)
except RuntimeError as e:
print(f"エラー: {e}")
# 出力例:
# エラー: Only Tensors of floating point and complex dtype can require gradients
# 解決策: dtypeをfloat32などの浮動小数点型に指定する
float_tensor = torch.tensor([1, 2, 3], dtype=torch.float32, requires_grad=True)
print(f"float_tensor: {float_tensor}, requires_grad: {float_tensor.requires_grad}")
葉テンソルと中間テンソルの勾配の違い
デフォルトでは、backward()実行後に.gradに値が格納されるのは葉テンソルだけです。中間テンソルの.gradはメモリ節約のためNoneのままになります。中間テンソルの勾配も確認したい場合は、retain_grad()を呼び出しておく必要があります。
import torch
x = torch.tensor(2.0, requires_grad=True) # 葉テンソル
y = x ** 2 # 中間テンソル(非leaf)
z = y * 3 # 中間テンソル(非leaf)
z.backward()
print(f"x.grad (leaf): {x.grad}") # 12.0 → 値が格納される
print(f"y.grad (non-leaf): {y.grad}") # None → デフォルトでは保持されない
# 中間テンソルの勾配を保持したい場合は retain_grad() を使う
x2 = torch.tensor(2.0, requires_grad=True)
y2 = x2 ** 2
y2.retain_grad() # y2の勾配を保持するよう指定
z2 = y2 * 3
z2.backward()
print(f"y2.grad (retained): {y2.grad}") # dz2/dy2 = 3.0 → 保持されて値が入る
4. backward()メソッドの使い方
backward()メソッドは、逆モード自動微分を実行し、計算グラフ内のすべての葉テンソル(requires_grad=Trueのもの)について勾配を計算し、それぞれの.grad属性に格納します。
スカラー出力に対するbackward()
出力がスカラー(要素数1)の場合、引数なしでbackward()を呼び出すだけで勾配が計算できます。複数の変数がある場合も同様です。
import torch
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(3.0, requires_grad=True)
c = a ** 2 + b ** 3 # c = a^2 + b^3 = 4 + 27 = 31
c.backward()
print(f"dc/da = {a.grad}") # dc/da = 2a = 4.0
print(f"dc/db = {b.grad}") # dc/db = 3b^2 = 27.0
ベクトル出力に対するbackward()
出力がスカラーでない場合(ベクトルや行列の場合)、backward()には出力と同じ形状のgradient引数を渡す必要があります。これは、出力側から流れてくる「上流の勾配」を表すベクトルで、数学的にはヤコビアン-ベクトル積(Jacobian-Vector Product)を計算していることに相当します。
import torch
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2 # ベクトル出力: y = [1, 4, 9]
# yはスカラーではないため、backward()には勾配ベクトルを渡す必要がある
gradient = torch.tensor([1.0, 1.0, 1.0])
y.backward(gradient=gradient)
print(f"x.grad: {x.grad}") # dy/dx = 2x = [2, 4, 6]
すべて1のベクトルをgradientとして渡すことは、出力の合計に対する勾配を求めることと等価です。実際、損失関数を計算する際は、以下のようにsum()やmean()でスカラーに変換してからbackward()を呼ぶのが一般的なパターンです。
import torch
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2
loss = y.sum() # ベクトルをスカラーに変換
loss.backward()
print(f"x.grad: {x.grad}") # [2, 4, 6] → 直前の例と同じ結果になる
💡 実務でのポイント
ニューラルネットワークの学習では、損失関数の出力は常にスカラーになるように設計します。そのため、実際のコードではgradient引数を明示的に渡す場面はあまり多くなく、loss.backward()のようにシンプルに書けることがほとんどです。
retain_graphオプション
デフォルトでは、backward()を1回実行すると、メモリ節約のため計算グラフの中間バッファは解放されます。そのため、同じグラフに対してbackward()を2回呼ぶとエラーになります。グラフを保持したまま複数回逆伝播を行いたい場合は、retain_graph=Trueを指定します。
import torch
x = torch.tensor(2.0, requires_grad=True)
y = x ** 3 # y = x^3
y.backward(retain_graph=True) # グラフを解放せず保持する
print(f"1回目の勾配: {x.grad}") # dy/dx = 3x^2 = 12.0
# retain_graph=Trueを指定したので、再度backward()が実行できる
x.grad.zero_()
y.backward()
print(f"2回目の勾配: {x.grad}") # dy/dx = 3x^2 = 12.0(同じグラフで再計算)
5. 勾配の蓄積と初期化
PyTorchのautogradには、初学者がつまずきやすい重要な仕様があります。それは、.gradはデフォルトで加算(蓄積)されるという点です。backward()を呼ぶたびに新しい勾配が上書きされるのではなく、既存の.gradの値に足し込まれます。
import torch
x = torch.tensor(2.0, requires_grad=True)
# 1回目の逆伝播
y1 = x ** 2
y1.backward()
print(f"1回目の勾配: {x.grad}") # dy1/dx = 2x = 4.0
# 2回目の逆伝播(勾配を初期化せずに実行すると加算される)
y2 = x ** 4
y2.backward()
print(f"2回目の勾配(蓄積後): {x.grad}") # 4.0 + dy2/dx(=4x^3=32.0) = 36.0
# 勾配をゼロにリセットする
x.grad.zero_()
y3 = x ** 4
y3.backward()
print(f"リセット後の勾配: {x.grad}") # 32.0 のみ(蓄積されていない)
⚠️ 重要
この「勾配が蓄積される」という仕様は、複数のミニバッチにまたがって勾配を合算する「勾配累積(Gradient Accumulation)」というテクニックには便利ですが、通常の学習ループでリセットを忘れると、過去のステップの勾配が混ざり込み、パラメータの更新が不安定になります。
実際の学習ループでは、tensor.grad.zero_()を1つずつ呼ぶ代わりに、torch.optimのオプティマイザが持つoptimizer.zero_grad()を使うのが標準的です。以下は、線形回帰モデルを勾配降下法で学習する最小限のループの例です。
import torch
torch.manual_seed(0)
w = torch.randn(1, requires_grad=True)
b = torch.randn(1, requires_grad=True)
optimizer = torch.optim.SGD([w, b], lr=0.01)
X = torch.randn(20, 1)
y_true = 2 * X + 1
for step in range(3):
y_pred = w * X + b
loss = ((y_pred - y_true) ** 2).mean()
optimizer.zero_grad() # 各ステップの最初に勾配をリセットする
loss.backward()
optimizer.step() # 勾配を使ってパラメータを更新
print(f"Step {step+1}: loss={loss.item():.4f}")
このループを実行すると、optimizer.zero_grad()によって毎ステップ勾配がリセットされるため、各ステップのlossはステップを追うごとに小さくなっていく様子が確認できます。optimizer.zero_grad()を呼び忘れると、勾配が際限なく蓄積し、学習が正しく進みません。
6. 微分を停止するテクニック
常にすべての演算の勾配を追跡する必要はありません。モデルの推論時や、一部のパラメータを固定したい転移学習の場面などでは、勾配の追跡を意図的に止めることで、メモリ使用量と計算時間を削減できます。PyTorchには主に3つの方法があります。
方法1: torch.no_grad()
torch.no_grad()は、そのブロック内で行われる演算について計算グラフの構築自体を止めるコンテキストマネージャです。推論(inference)時に最もよく使われます。
import torch
x = torch.tensor(2.0, requires_grad=True)
# 通常の計算 - 計算グラフが構築される
y = x ** 2
print(f"y.requires_grad: {y.requires_grad}") # True
# torch.no_grad()の中では計算グラフが構築されない
with torch.no_grad():
z = x ** 2
print(f"z.requires_grad: {z.requires_grad}") # False
# 推論時によく使われるパターン
def predict(model_w, model_b, x_input):
with torch.no_grad():
return model_w * x_input + model_b
w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
prediction = predict(w, b, torch.tensor(5.0))
print(f"予測値: {prediction}, requires_grad: {prediction.requires_grad}")
# 予測値: 11.0, requires_grad: False
方法2: detach()
detach()は、あるテンソルと同じ値を持ちながら計算グラフからは切り離された、新しいテンソルを返します。元のテンソルとメモリ(データ)を共有する点に注意が必要です。
import torch
x = torch.tensor(3.0, requires_grad=True)
y = x ** 2
# detach()でグラフから切り離した新しいTensorを作成
y_detached = y.detach()
print(f"y.requires_grad: {y.requires_grad}") # True
print(f"y_detached.requires_grad: {y_detached.requires_grad}") # False
# yとy_detachedはメモリを共有するため、一方をインプレースで変更すると他方にも影響する
y_detached.mul_(2)
print(f"変更後のy: {y}") # yの値も18.0に変わってしまう
⚠️ detach()のメモリ共有に注意
detach()で得たテンソルをインプレース演算(mul_()やadd_()など、末尾にアンダースコアが付くメソッド)で変更すると、元のテンソルの値も変わってしまいます。値を完全に独立させたい場合は、y.detach().clone()のようにclone()を併用しましょう。
方法3: requires_grad_(False)
テンソル自体のrequires_grad属性を恒久的にFalseへ切り替えることもできます。転移学習で、事前学習済みモデルの一部の層を「凍結(freeze)」してパラメータを更新しないようにする際によく使われます。
import torch
# 転移学習でよくあるパターン: 一部のパラメータを凍結する
w1 = torch.randn(3, requires_grad=True)
w2 = torch.randn(3, requires_grad=True)
# w1を凍結(勾配計算の対象から外す)
w1.requires_grad_(False)
x = torch.randn(3)
y = (w1 * x).sum() + (w2 * x).sum()
y.backward()
print(f"w1.grad: {w1.grad}") # None(凍結されているため勾配が計算されない)
print(f"w2.grad is None: {w2.grad is None}") # False(w2は通常どおり勾配が計算される)
3つの方法にはそれぞれ適した使いどころがあります。使い分けの目安を以下の表にまとめます。
| 手法 | 適用範囲 | 主な用途 |
|---|---|---|
torch.no_grad() |
コードブロック全体 | 推論、評価ループ、パラメータ更新時の一時的な追跡停止 |
tensor.detach() |
特定のテンソル1つ | グラフの一部だけを切り離す、ログ記録用に値だけ取り出す |
tensor.requires_grad_(False) |
特定のテンソル1つ(恒久的) | 転移学習でのパラメータ凍結 |
演習問題
演習1:スカラー関数の自動微分
関数 $f(x) = 3x^3 - 2x^2 + 5$ について、$x=2$ における微分値 $\frac{df}{dx}$ をautogradを使って計算してください。
# ここにコードを書く
import torch
x = torch.tensor(2.0, requires_grad=True)
# f(x) = 3x^3 - 2x^2 + 5 を計算し、backward()を呼び出してください
解答を見る
import torch
x = torch.tensor(2.0, requires_grad=True)
f = 3 * x ** 3 - 2 * x ** 2 + 5
f.backward()
print(f"df/dx = {x.grad.item()}")
# df/dx = 9x^2 - 4x = 9*4 - 4*2 = 36 - 8 = 28.0
数学的には $\frac{df}{dx} = 9x^2 - 4x$ であり、$x=2$ を代入すると $9(4) - 4(2) = 28$ となります。autogradの計算結果と一致します。
演習2:ベクトルの勾配と蓄積の確認
ベクトル $x = [1, 2, 3, 4]$(requires_grad=True)について、$y = x^2$ の y.sum() を backward()して $\frac{\partial y}{\partial x}$ を求めてください。続けて、同じxに対して z = x**3 の勾配を(x.gradをリセットせずに)計算し、勾配が加算されることを確認してください。
解答を見る
import torch
x = torch.tensor([1.0, 2.0, 3.0, 4.0], requires_grad=True)
y = x ** 2
y.sum().backward()
print(f"1回目 (dy/dx = 2x): {x.grad}")
# tensor([2., 4., 6., 8.])
# リセットせずに続けて z = x^3 の勾配を計算
z = x ** 3
z.sum().backward()
print(f"2回目 (蓄積後): {x.grad}")
# dz/dx = 3x^2 = [3, 12, 27, 48] が加算され
# tensor([ 5., 16., 33., 56.]) になる
1回目の勾配 [2, 4, 6, 8] に、2回目の勾配 [3, 12, 27, 48] が加算され、最終的に [5, 16, 33, 56] となります。リセットせずにbackward()を繰り返すと勾配が加算され続けることが確認できます。
演習3:torch.no_grad()による推論
学習済みのパラメータ $w=2.5$、$b=1.0$ を持つ線形モデル $y = wx + b$ について、torch.no_grad()を使って $x=4.0$ に対する推論を実装し、出力テンソルのrequires_gradがFalseになっていることを確認してください。
解答を見る
import torch
w = torch.tensor(2.5, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
x = torch.tensor(4.0)
with torch.no_grad():
y_pred = w * x + b
print(f"予測値: {y_pred.item()}") # 2.5*4 + 1.0 = 11.0
print(f"requires_grad: {y_pred.requires_grad}") # False
torch.no_grad()ブロックの中では、wやbがrequires_grad=Trueであっても、その演算結果であるy_predは計算グラフに記録されず、requires_gradはFalseになります。推論時にこの方法を使うことで、不要な計算グラフの構築を避け、メモリと計算時間を節約できます。
学習目標の振り返り
この章の冒頭で掲げた学習目標を振り返ってみましょう。
- ✅ 自動微分の仕組みを説明できる: 数値微分・記号微分との違いを理解し、autogradが連鎖律を利用して正確な勾配を計算することを学びました
- ✅ 計算グラフと勾配計算の関係を理解する: 順伝播で動的に構築される計算グラフと、逆伝播での連鎖律の適用の流れを確認しました
- ✅ requires_gradとbackward()を使いこなせる: スカラー・ベクトルそれぞれのテンソルでの
requires_gradの設定と、backward()の呼び出し方(gradient引数を含む)を実践しました - ✅ 勾配の蓄積と初期化を正しく扱える:
.gradがデフォルトで蓄積される仕様と、zero_()・optimizer.zero_grad()によるリセットの重要性を確認しました - ✅ torch.no_grad()等で微分を適切に停止できる:
torch.no_grad()、detach()、requires_grad_(False)という3つの手法と、それぞれの使い分けを学びました
まとめ
この章では、PyTorchのautograd(自動微分)の仕組みを学びました。
- ✅ autogradは、数値微分や記号微分とは異なり、計算グラフと連鎖律を使って正確かつ効率的に勾配を求める
- ✅ PyTorchは演算のたびに計算グラフを動的に構築し(Define-by-Run)、各テンソルは
grad_fnを通じて生成元の演算を記録する - ✅
requires_grad=Trueのテンソルは「葉テンソル」となり、backward()後に.gradへ勾配が格納される(中間テンソルはretain_grad()が必要) - ✅ スカラー出力は引数なしで
backward()を呼べるが、ベクトル出力にはgradient引数が必要になる - ✅
.gradはデフォルトで蓄積されるため、学習ループでは毎ステップzero_grad()でリセットする必要がある - ✅
torch.no_grad()・detach()・requires_grad_(False)を使い分けることで、不要な勾配追跡を止め、メモリと計算コストを節約できる
🎉 次のステップ
autogradの仕組みが分かったところで、次章ではこの自動微分を土台に、torch.nn.Moduleを使ったニューラルネットワークの構築方法を学びます。これまで手動で書いてきた線形モデルが、より体系的なクラス設計でどう表現されるのか、ぜひ楽しみにしていてください。
参考リソース