モジュール 08 · 第 3 回

誤差逆伝播法を手書きする

どの数も自分がどう計算されたかを覚えている、数十行のクラスを書けば、損失から出発して連鎖律で勾配をすべてのパラメータまで伝え戻せます。数値微分で確かめ、それを使って小さなネットワークに XOR を学習させます。

  • 約 60 分
  • 難易度:中級
  • 検証:2026-09-14 純粋な Python、乱数シード固定

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

前の課の勾配は、ある具体的な式を相手に手で導いたものでした。しかしニューラルネットワークは何層も重なった複雑な関数で、パラメータは数百、数千もあり、そのすべての微分を手で導くのは不可能です。

この課では、コンピュータにすべてのパラメータの勾配を自動で計算させる小さな道具を書きます。その名前は誤差逆伝播法(backpropagation)です。PyTorch の最も中心的な機能がこれで、この課では純粋な Python で数十行しかない版を書きます。書き終えれば、loss.backward() という 1 行の裏で何が起きているのかがわかります。

連鎖律

まず簡単な例を見ましょう。y = 3xz = y² だとします。x がほんの少し変わると、z はどれだけ変わるでしょうか。

二段階で考えます。x がほんの少し変わると、y はその 3 倍変わります(x についての y の微分は 3)。y がほんの少し変わると、z はその 2y 倍変わります(y についての z の微分は 2y)。ですから x がほんの少し変わると、z3 × 2y 倍変わります。

z 对 x 的导数 = (z 对 y 的导数) × (y 对 x 的导数)

これが連鎖律です。一続きの演算の微分は、各ステップの微分を掛け合わせたものに等しい。各ステップの微分はそのステップ自身だけで決まり、局所微分と呼ばれます。

ニューラルネットワークは長い一続きの演算です。掛けて、足して、さらに非線形の関数を通し、それが層ごとに続いて、最後に損失が計算されます。各ステップの局所微分さえわかれば、連鎖律を使って損失から一歩ずつ掛け戻していき、各パラメータについての損失の微分が得られます。

どの数にも帳簿を付けさせる

やり方はこうです。演算をするたびに、「各入力についての結果の局所微分」を書き留めておきます。Num というクラスを書き、値を保存するだけでなく、[(入力の数, 局所微分), ...] というリストも保存させます。

class Num:
    """一个会记账的数:记下自己的值、梯度,以及"我对每个上游数的局部导数"。"""

    def __init__(self, value, parents=()):
        self.value = value
        self.grad = 0.0
        self.parents = parents  # [(上游的 Num, 局部导数), ...]

    def __add__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a + b 对 a 的导数是 1,对 b 的导数也是 1
        return Num(self.value + other.value, [(self, 1.0), (other, 1.0)])

    def __mul__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a × b 对 a 的导数是 b,对 b 的导数是 a
        return Num(self.value * other.value, [(self, other.value), (other, self.value)])

    def __pow__(self, n):
        # x 的 n 次方,导数是 n × x 的 (n-1) 次方
        return Num(self.value ** n, [(self, n * self.value ** (n - 1))])

    def tanh(self):
        t = math.tanh(self.value)
        # tanh 的导数是 1 - tanh²
        return Num(t, [(self, 1 - t * t)])

どの演算も、自分の局所微分さえ知っていればよいのです。

  • 足し算 a + ba が少し変われば結果も同じだけ変わるので、a についても b についても局所微分は 1。
  • 掛け算 a × ba が少し変わると結果は b 倍変わるので、a についての局所微分は bb についての局所微分は a
  • べき乗 xⁿ:局所微分は n × xⁿ⁻¹ で、高校の数学で習いました。
  • tanh:任意の数を -1 から 1 の間に押し込む関数で、ニューラルネットワークでよく使われます。その微分は 1 - tanh²

Python の演算子オーバーロード(__add____mul__ など)を使えば、a * b + c のようなふつうの書き方で自動的に Num のオブジェクトが作られ、計算の過程全体がこっそり記録されます。

(完全なコードには引き算と、ふつうの数も演算に参加できるようにする数行があります。code/08-neural-nets/backprop.py を見てください。)

誤差逆伝播法

計算が終わると、どの Num も自分がどの数から計算されたかを知っていて、計算の過程全体が一枚のグラフになっています。誤差逆伝播法とは、最終的な結果(損失)から出発して、このグラフに沿って戻っていくことです。

    def backward(self):
        """从这个数(通常是损失)出发,把梯度传给所有上游的数。"""
        order, seen = [], set()

        def visit(node):  # 先访问完所有上游,再把自己放进列表:得到一个"从上游到下游"的顺序
            if id(node) not in seen:
                seen.add(id(node))
                for parent, _ in node.parents:
                    visit(parent)
                order.append(node)

        visit(self)
        self.grad = 1.0  # 损失对自己的导数是 1
        for node in reversed(order):  # 从下游往上游,链式法则:上游梯度 += 下游梯度 × 局部导数
            for parent, local in node.parents:
                parent.grad += node.grad * local

二つのステップがあります。

  1. 順番を並べるvisit は、ある数が必ずそれを計算したすべての数の後に並ぶようにします。逆順にたどれば損失から戻っていくことになり、しかもある数を処理する時点では、それに依存するすべての数がすでに処理済みで、その数の勾配はもう全部足し終わっています。
  2. 勾配を伝える。自分自身についての損失の微分は 1 です。戻っていく一歩ごとに、連鎖律を使います。上流の勾配に「下流の勾配 × 局所微分」を足すのです。

なぜそのまま代入せず「足す」(+=)のでしょうか。一つの数が何回も使われることがあるからです。たとえば y = x * x では、x は掛け算の二つの入力の両方で、二つの経路から戻ってくる勾配を足し合わせる必要があります。

確かめる:数値微分と比べる

書いたらまずテストします。小さな式 f = (a × b + c)²a=2, b=-3, c=10 を使います。

a, b, c = Num(2.0), Num(-3.0), Num(10.0)
f = (a * b + c) ** 2
f.backward()
print(f"  f = {f.value}")
print(f"  自动算出的梯度:df/da={a.grad}, df/db={b.grad}, df/dc={c.grad}")

さらに前の課の数値の方法(今回はより正確な「左右それぞれに少し動かす」)で確かめます。

def numeric(fn, x, h=1e-6):
    return (fn(x + h) - fn(x - h)) / (2 * h)
== 1. f = (a × b + c)²,a=2, b=-3, c=10
  f = 16.0
  自动算出的梯度:df/da=-24.0, df/db=16.0, df/dc=8.0
  数值求导核对: df/da≈-24.0000, df/db≈16.0000, df/dc≈8.0000

完全に一致しました。手で計算しても確かめられます。a × b + c = 4f = 4² = 16(a×b+c) についての f の微分は 2 × 4 = 8 なので、df/dc = 8df/da = 8 × b = -24df/db = 8 × a = 16 です。

それでニューラルネットワークを組み立てる

自動微分があれば、ニューラルネットワークを組み立てられます。

ニューロンがすることは単純です。各入力に重みを掛けて足し合わせ、バイアスを足し、最後に tanh を通します。

class Neuron:
    def __init__(self, n_inputs):
        self.w = [Num(random.uniform(-1, 1)) for _ in range(n_inputs)]
        self.b = Num(0.0)

    def __call__(self, xs):
        total = self.b
        for w, x in zip(self.w, xs):
            total = total + w * x
        return total.tanh()

最後の tanh を取り除けば、第 1 課のあの直線そのもので、ただ入力が 1 個から複数になっただけです。tanh のような非線形関数(活性化関数とも呼ぶ)は欠かせません。これがないと、何層もの直線を重ねても結果は直線のままで、複雑な規則は何も学べません。

ニューロンを一列に並べれば層になり、二つの層を重ねれば小さなネットワークになります。入力 2 個 → 隠れニューロン 4 個 → 出力 1 個。パラメータを数えてみましょう。隠れ層は 4 個のニューロンがそれぞれ重み 2 個とバイアス 1 個を持つので計 12 個、出力層は 1 個のニューロンが重み 4 個とバイアス 1 個を持つので計 5 個。全部で 17 個のパラメータです。

XOR を学習する

XOR(排他的論理和):二つの入力が同じなら -1 を、違えば 1 を出力します(tanh の出力は -1 から 1 の間なので、ふつう言う 0 と 1 の代わりに -1 と 1 を使います)。

これは古典的な例で、直線ではできないからです。平面にこの四つの点を描くと、(0,1)(1,0)(0,0)(1,1) を一本の直線で分けることはできません。隠れ層と非線形関数が必要です。

学習のループは前の課の勾配降下法とまったく同じで、ただ勾配を計算するステップが loss.backward() に替わるだけです。

data = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
lr = 0.1
for epoch in range(1, 301):
    loss = Num(0.0)
    for xs, y in data:
        pred = output(hidden(xs))[0]
        loss = loss + (pred - y) ** 2
    for p in params:
        p.grad = 0.0  # 每一轮都要清零,否则梯度会一直累加
    loss.backward()
    for p in params:
        p.value -= lr * p.grad

各ラウンドの始めに勾配をゼロにする必要がある点に注意してください。backward+= を使うので、ゼロにしないと、このラウンドの勾配が前のラウンドの勾配に足されてしまいます。これはとても典型的な誤りで、PyTorch を使うときも同じです(次の課の optimizer.zero_grad())。

== 2. 网络:2 个输入 → 4 个隐藏神经元 → 1 个输出,共 17 个参数
  第   1 轮  损失 4.1022
  第  10 轮  损失 3.7902
  第  50 轮  损失 0.1093
  第 100 轮  损失 0.0346
  第 200 轮  损失 0.0134
  第 300 轮  损失 0.0081
  训练后的预测:
    输入 [0, 0] → -0.965(目标 -1)
    输入 [0, 1] → +0.952(目标 +1)
    输入 [1, 0] → +0.953(目标 +1)
    输入 [1, 1] → -0.951(目标 -1)

最初の損失は 4.1 で、四つの予測はほとんどすべて外れています。最初の 10 ラウンドはゆっくりしか進みませんが、そのあと急に下がり始め、50 ラウンドで 0.1 まで下がりました。300 ラウンド後には、四つの予測はどれも目標にとても近くなっています。

直線では学べない規則を、17 個のパラメータの小さなネットワークが学びました。その過程で誰も「XOR とは何か」を教えていません。ただ何度も何度も損失を計算し、勾配を計算し、勾配の逆方向にパラメータを調整しただけです。

私たちが書いたもの

振り返ると、この課の数十行のコードには、ディープラーニングのフレームワークの最も中心的なものがすでに含まれています。

  • 自動微分:各演算が局所微分を記録し、誤差逆伝播法が連鎖律で勾配を伝え戻す。
  • ニューロンと層:重み付きの和を取り、バイアスを足し、活性化関数を通す。
  • 学習のループ:順方向に損失を計算し、勾配をゼロにし、逆伝播し、パラメータを更新する。

もちろんとても遅いです。どの数も Python のオブジェクトで、少し大きなネットワークならこうしたオブジェクトが数百万個になります。次の課では PyTorch に替えます。PyTorch がしていることはまったく同じですが、一度に数の塊(テンソル)をまとめて処理し、最適化された低レベルのコードで計算するので、何千倍、何万倍も速くなります。

練習問題

  1. Numrelu メソッドを加えてください。入力が 0 より大きければそのまま出力し、そうでなければ 0 を出力します。その局所微分は何でしょうか。書けたら数値微分で確かめてください。
  2. 学習のループで勾配をゼロにしている 2 行を削除して実行し直し、何が起きるか見てください。
  3. 隠れ層のニューロンを 4 個から 1 個、2 個に変えても XOR を学習できるでしょうか。乱数のシードもいくつか替えて試してください。

確認テスト

1. 連鎖律とは何ですか?誤差逆伝播法とどんな関係がありますか?

連鎖律:一続きの演算の微分は、各ステップの局所微分の積に等しい。誤差逆伝播法は連鎖律を体系的に使うものです。損失から出発して計算の過程に沿って戻り、一歩進むごとにそのステップの局所微分を掛け、最終的に各パラメータについての損失の微分を得ます。

2. 誤差逆伝播法で、勾配をそのまま代入するのではなく += で足し合わせるのはなぜですか?

一つの数が計算の中で何回も使われることがあるからです。たとえば y = x × x では x が 2 回出てきます。使われるたびに一本の経路が勾配を伝え戻してくるので、それらの勾配を足し合わせて初めて完全な微分になります。

3. 一本の直線では XOR を学べず、隠れ層と tanh を加えたネットワークなら学べるのはなぜですか?

XOR の四つの点は一本の直線では分けられません。何層もの直線を重ねても結果は一本の直線のままなので、鍵は非線形の活性化関数 tanh にあります。これによってネットワークは曲がった境界を組み合わせて作れるようになり、四つの点を分けられるのです。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…