Lesson 08 · 約 25 分鐘

核心數學與直覺

線代 / 機率 / 微積分 — 只挑用得到的

🛠 跟著做

共 2 支 · 在本機 venv 跑 · 還沒裝環境?

10_math_foundations.py
python
📦 套件: numpy
🐙 在 GitHub 看完整原始碼
📄 原始碼
"""
10_math_foundations.py
================================
第 0 站「數學基礎」的可跑版本——把路線圖裡「用 numpy 手刻線性回歸 + 梯度下降」
和「自己驗證鏈鎖律 / 反向傳播算得對不對」變成看得見的數字。
對照講義 docs/00(第 0 站)、docs/08(深入講義:梯度下降與反向傳播)。

這支程式回答三個問題:
  1. 「梯度」到底是什麼?→ 用「數值梯度」(把參數動一點點看 loss 變多少)驗證。
  2. 鏈鎖律對不對?→ 手算解析梯度,和數值梯度比對(這叫 gradient check,業界除錯必備)。
  3. 梯度下降為什麼能讓 loss 變小?→ 手刻線性回歸,看 loss 一路下降。

執行:python3 10_math_foundations.py   (只需要 numpy)
"""

import sys
import numpy as np

# Windows 主控台預設可能是 cp950(Big5),遇到 ² ∇ ← 這類符號會報錯;
# 統一把輸出切成 UTF-8(VS Code 內建終端機預設就是 UTF-8,可正確顯示中文與數學符號)。
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

np.random.seed(0)


# ──────────────────────────────────────────────────────────────
# 1. 數值梯度:梯度的「定義」就是「動一點點,看輸出變多少」
# ──────────────────────────────────────────────────────────────
def numerical_gradient(f, x, eps=1e-6):
    """
    對函數 f 在點 x 的每個分量做中央差分:
        ∂f/∂xᵢ ≈ ( f(x+eps·eᵢ) − f(x−eps·eᵢ) ) / (2·eps)
    這是「梯度的定義」,慢但幾乎一定對,所以拿來當「標準答案」驗證解析梯度。
    """
    grad = np.zeros_like(x, dtype=float)
    it = np.nditer(x, flags=["multi_index"])
    while not it.finished:
        idx = it.multi_index
        old = x[idx]
        x[idx] = old + eps
        f_plus = f(x)
        x[idx] = old - eps
        f_minus = f(x)
        x[idx] = old
        grad[idx] = (f_plus - f_minus) / (2 * eps)
        it.iternext()
    return grad


def demo_gradient_meaning():
    print("=" * 64)
    print("1) 梯度是什麼:f(x,y) = x² + 3y,在 (2, 1) 的梯度")
    print("=" * 64)
    f = lambda v: v[0] ** 2 + 3 * v[1]
    point = np.array([2.0, 1.0])
    g_num = numerical_gradient(f, point.copy())
    g_analytic = np.array([2 * point[0], 3.0])     # 手算:∂f/∂x=2x, ∂f/∂y=3
    print(f"  解析梯度(手算公式):{g_analytic}")
    print(f"  數值梯度(動一點點):{g_num.round(5)}")
    print("  → 兩者相同。梯度 = 『往哪個方向走,f 上升最快』;反方向就是讓 f 變小的方向。\n")


# ──────────────────────────────────────────────────────────────
# 2. 鏈鎖律 / 反向傳播:手算梯度 vs 數值梯度(gradient check)
# ──────────────────────────────────────────────────────────────
def demo_chain_rule():
    print("=" * 64)
    print("2) 鏈鎖律驗證:一個小神經元 loss = (sigmoid(w·x + b) − y)²")
    print("=" * 64)
    x = np.array([1.5, -2.0, 1.0])
    y = 1.0
    w = np.array([0.2, -0.5, 0.1])
    b = 0.3

    def sigmoid(z):
        return 1.0 / (1.0 + np.exp(-z))

    def forward(w, b):
        z = w @ x + b
        a = sigmoid(z)
        return (a - y) ** 2

    # 手算解析梯度(一層一層套鏈鎖律):
    #   loss = (a - y)²,  a = sigmoid(z),  z = w·x + b
    #   dloss/da = 2(a - y)
    #   da/dz    = a(1 - a)            ← sigmoid 的導數
    #   dz/dw    = x ,  dz/db = 1
    z = w @ x + b
    a = sigmoid(z)
    dloss_da = 2 * (a - y)
    da_dz = a * (1 - a)
    dz = dloss_da * da_dz              # = dloss/dz
    grad_w_analytic = dz * x
    grad_b_analytic = dz * 1.0

    # 數值梯度當標準答案
    grad_w_num = numerical_gradient(lambda ww: forward(ww, b), w.copy())
    grad_b_num = numerical_gradient(lambda bb: forward(w, bb[0]), np.array([b]))[0]

    print(f"  ∂loss/∂w 解析:{grad_w_analytic.round(6)}")
    print(f"  ∂loss/∂w 數值:{grad_w_num.round(6)}")
    print(f"  ∂loss/∂b 解析:{grad_b_analytic:.6f}   數值:{grad_b_num:.6f}")
    rel_err = np.abs(grad_w_analytic - grad_w_num).max()
    print(f"  最大誤差:{rel_err:.2e} → 幾乎為 0,代表手算的反向傳播是對的。")
    print("  這就是 PyTorch .backward() 在做的事,只是它自動幫你套完整張計算圖。\n")


# ──────────────────────────────────────────────────────────────
# 3. 梯度下降:手刻線性回歸(不准用現成函式庫)
# ──────────────────────────────────────────────────────────────
def demo_linear_regression():
    print("=" * 64)
    print("3) 梯度下降:手刻線性回歸 y ≈ w·x + b(資料來自 y = 2x + 1 + 雜訊)")
    print("=" * 64)
    # 造資料
    X = np.linspace(-3, 3, 40)
    true_w, true_b = 2.0, 1.0
    Y = true_w * X + true_b + np.random.randn(40) * 0.5

    w, b = 0.0, 0.0          # 從零開始
    lr = 0.05
    n = len(X)
    for step in range(201):
        pred = w * X + b
        loss = np.mean((pred - Y) ** 2)            # 均方誤差
        # 梯度(對 MSE 手算):
        grad_w = np.mean(2 * (pred - Y) * X)
        grad_b = np.mean(2 * (pred - Y))
        # 更新:θ ← θ − η·∇L
        w -= lr * grad_w
        b -= lr * grad_b
        if step % 40 == 0:
            print(f"  step {step:3d} | loss={loss:6.3f} | w={w:.3f} b={b:.3f}")
    print(f"\n  學到的 w={w:.3f}, b={b:.3f}(真值 w=2, b=1)。")
    print("  loss 一路下降 → 這就是『所有模型訓練』的最小核心,從這裡放大就是訓練 GPT。\n")


if __name__ == "__main__":
    demo_gradient_meaning()
    demo_chain_rule()
    demo_linear_regression()
    print("一句話:梯度下降 = 反覆『算梯度、往反方向走一小步』。")
    print("會 gradient check(解析 vs 數值)你就有了除錯一切模型的底牌。")
04_autograd_from_scratch.py
python
🐙 在 GitHub 看完整原始碼
📄 原始碼
"""
04_autograd_from_scratch.py
================================
手刻一個「自動微分(autograd)」引擎,再用它訓練一個小神經網路解 XOR。
這補上 docs/01 講的「反向傳播 backprop」——PyTorch 的 .backward() 底層就是這個想法。

核心觀念:
  - 每個數值都用一個 Value 物件包起來,記住「自己是怎麼被算出來的」(建立計算圖)。
  - forward:照算式往前算出答案。
  - backward:用鏈鎖律,從輸出往回,把梯度傳回每個參數(這就是反向傳播)。
  - 有了梯度,就能做梯度下降 θ ← θ − η·∇L 來訓練。

執行:python3 04_autograd_from_scratch.py   (純 Python,不需 numpy、不需 GPU)
參考:Andrej Karpathy 的 micrograd。
"""

import math
import random

random.seed(1337)


class Value:
    """一個純量,外加它的梯度與「怎麼算出來的」紀錄。"""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None      # 這個節點把梯度往回傳的方法
        self._prev = set(_children)
        self._op = _op

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")
        def _backward():               # d(out)/d(self)=1, d(out)/d(other)=1
            self.grad += out.grad
            other.grad += out.grad
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")
        def _backward():               # 乘法的微分:互相是對方的係數
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

    def __pow__(self, p):              # 只支援常數次方
        assert isinstance(p, (int, float))
        out = Value(self.data ** p, (self,), f"**{p}")
        def _backward():
            self.grad += (p * self.data ** (p - 1)) * out.grad
        out._backward = _backward
        return out

    def tanh(self):                    # 非線性激活函數
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")
        def _backward():
            self.grad += (1 - t * t) * out.grad   # tanh 的導數 = 1 - tanh²
        out._backward = _backward
        return out

    # 一些方便的運算
    def __neg__(self): return self * -1
    def __sub__(self, other): return self + (-other)
    def __radd__(self, other): return self + other
    def __rmul__(self, other): return self * other
    def __truediv__(self, other): return self * other ** -1

    def backward(self):
        """反向傳播:先把節點做拓撲排序,再從輸出往回呼叫每個 _backward。"""
        topo, visited = [], set()
        def build(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build(child)
                topo.append(v)
        build(self)
        self.grad = 1.0                # 輸出對自己的梯度 = 1
        for v in reversed(topo):
            v._backward()


# ---------- 用上面的引擎組一個小神經網路 ----------
class Neuron:
    def __init__(self, n_in):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(n_in)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]


class Layer:
    def __init__(self, n_in, n_out):
        self.neurons = [Neuron(n_in) for _ in range(n_out)]

    def __call__(self, x):
        outs = [n(x) for n in self.neurons]
        return outs[0] if len(outs) == 1 else outs

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, n_in, sizes):
        dims = [n_in] + sizes
        self.layers = [Layer(dims[i], dims[i + 1]) for i in range(len(sizes))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]


def main():
    # XOR 問題:輸入兩個 0/1,輸出它們是否「不同」。用 +1/-1 當標籤配合 tanh。
    X = [[0, 0], [0, 1], [1, 0], [1, 1]]
    Y = [-1.0, 1.0, 1.0, -1.0]         # XOR:相同→-1,不同→+1

    model = MLP(2, [4, 4, 1])          # 2 輸入 → 兩層各 4 神經元 → 1 輸出
    n_params = len(model.parameters())
    print(f"模型參數量:{n_params}(每個都是一個會記梯度的 Value)\n開始訓練 XOR ...\n")

    lr = 0.1
    for epoch in range(200):
        # forward:算預測與損失(均方誤差)
        preds = [model(x) for x in X]
        loss = sum((p - y) ** 2 for p, y in zip(preds, Y))

        # backward:先把梯度歸零,再反向傳播
        for p in model.parameters():
            p.grad = 0.0
        loss.backward()

        # 梯度下降:沿梯度反方向更新參數
        for p in model.parameters():
            p.data -= lr * p.grad

        if epoch % 40 == 0 or epoch == 199:
            print(f"epoch {epoch:3d} | loss = {loss.data:.4f}")

    print("\n訓練後預測(target:相同→-1,不同→+1):")
    for x, y in zip(X, Y):
        pred = model(x).data
        ok = "✓" if (pred > 0) == (y > 0) else "✗"
        print(f"  輸入 {x} → 預測 {pred:+.2f}(應為 {y:+.0f}{ok}")
    print("\n重點:上面那條 loss.backward() 就是反向傳播;PyTorch 幫你把這套做到大規模、跑在 GPU 上。")


if __name__ == "__main__":
    main()