"""
=============================================================================
第 3 讲 · 代码 2：从零写一个自动微分引擎 —— 计算图与反向传播
=============================================================================

对应讲义：lectures/lecture03_neural_networks_backprop.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 01 是"手推公式 + 数值检验"。真实的神经网络有成百上千个运算，不可能每次都手推。
PyTorch、TensorFlow 等框架能**自动**算出任意计算的梯度，靠的就是**反向传播**（backpropagation）：

    1. 前向传播：一边计算结果，一边把计算过程记录成一张**计算图**
       （节点 = 中间结果，边 = "谁由谁算出来"）。
    2. 反向传播：从最终的输出（损失）出发，按**拓扑顺序的逆序**访问每个节点，
       用"上游梯度 × 局部梯度 = 下游梯度"把梯度一路传回到每个输入。

本文件用纯 Python 实现一个只处理**标量**的迷你引擎（思路来自 Andrej Karpathy 的 micrograd），
大约 100 行，却包含了 PyTorch autograd 的全部核心思想。

    演示 1：一个简单表达式的前向、反向，与数值梯度对比
    演示 2：三种"门"的梯度规律：加法分发、乘法交换、max 路由
    演示 3：一个变量被用了多次时，梯度必须**累加**（一个经典 bug）
    演示 4：一个神经元 tanh(w·x + b)
    演示 5：用这个引擎训练一个小神经网络解决 XOR 问题
    演示 6：为什么是"反向"？—— 前向模式 vs 反向模式

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_micrograd.py

只用 Python 标准库。运行时间约 2 秒。
"""

import math
import random


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


# =============================================================================
# 第 1 部分：Value —— 计算图中的一个节点
# =============================================================================
class Value:
    """
    计算图中的一个标量节点：保存数值 data、梯度 grad，以及"自己是怎么算出来的"。

    【思路】
        每次做运算（比如 c = a * b），都创建一个新的 Value c，并记下：
            c._prev     = {a, b}            （c 的"父节点"，也就是输入）
            c._op       = "*"               （做了什么运算，只用于打印）
            c._backward = 一个小函数         （知道 c.grad 后，怎样把梯度加到 a.grad、b.grad 上）
        对于乘法，局部梯度是 ∂c/∂a = b、∂c/∂b = a，所以：
            a.grad += b.data * c.grad
            b.grad += a.data * c.grad
        这就是"下游梯度 = 上游梯度 × 局部梯度"。注意是 +=，不是 =（演示 3 解释为什么）。

    【用法】
        a, b = Value(2.0), Value(-3.0)
        c = a * b + a
        c.backward()
        print(a.grad, b.grad)        # ∂c/∂a = b + 1 = -2，∂c/∂b = a = 2

    【边界】
        - 只支持标量。真实框架处理的是张量（多维数组），原理完全相同，只是局部梯度变成了
          代码 01 中的雅可比矩阵（实际实现时用更高效的等价运算）。
        - backward() 默认只调用一次。多次调用会让梯度继续累加，需要先 zero_grad()。

    【Python 小知识】
        __add__、__mul__ 等"魔法方法"让我们可以用 a + b、a * b 这样的普通写法来创建计算图。
        __radd__、__rmul__ 处理左边是普通数字的情况：2 * a 会调用 a.__rmul__(2)。
        lambda 和内部函数（闭包）可以"记住"创建它时的变量，这里用来记住 a、b、c 是谁。
    """

    def __init__(self, data, _prev=(), _op="", label=""):
        """
        【参数】
            data  : float，节点的数值
            _prev : tuple[Value]，父节点（由运算自动填写，用户不用管）
            _op   : str，产生这个节点的运算
            label : str，名字（只用于打印）
        """
        self.data = float(data)
        self.grad = 0.0
        self._prev = set(_prev)
        self._op = _op
        self._backward = lambda: None          # 叶子节点（用户直接创建的）没有父节点，什么都不做
        self.label = label

    def __repr__(self):
        name = f"{self.label}=" if self.label else ""
        return f"Value({name}{self.data:.4f}, grad={self.grad:.4f})"

    # ---------------- 基本运算 ----------------
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            # 加法的局部梯度都是 1：上游梯度原样"分发"给两个输入
            self.grad += 1.0 * out.grad
            other.grad += 1.0 * 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():
            # 乘法：∂(ab)/∂a = b，∂(ab)/∂b = a —— 梯度"交换"了对方的值
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

    def __pow__(self, k):
        assert isinstance(k, (int, float)), "这里只支持常数次幂"
        out = Value(self.data ** k, (self,), f"**{k}")

        def _backward():
            self.grad += k * self.data ** (k - 1) * out.grad      # (x^k)' = k x^(k-1)
        out._backward = _backward
        return out

    def exp(self):
        out = Value(math.exp(self.data), (self,), "exp")

        def _backward():
            self.grad += out.data * out.grad                       # (e^x)' = e^x，正好是输出本身
        out._backward = _backward
        return out

    def log(self):
        out = Value(math.log(self.data), (self,), "log")

        def _backward():
            self.grad += (1.0 / self.data) * out.grad              # (log x)' = 1/x
        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1 - t ** 2) * out.grad                   # tanh' = 1 - tanh²
        out._backward = _backward
        return out

    def relu(self):
        out = Value(max(0.0, self.data), (self,), "relu")

        def _backward():
            self.grad += (1.0 if self.data > 0 else 0.0) * out.grad
        out._backward = _backward
        return out

    def maximum(self, other):
        """max(a, b)：梯度只流向较大的那个输入（"路由"）。"""
        other = other if isinstance(other, Value) else Value(other)
        out = Value(max(self.data, other.data), (self, other), "max")

        def _backward():
            if self.data >= other.data:
                self.grad += out.grad
            else:
                other.grad += out.grad
        out._backward = _backward
        return out

    # 由基本运算组合出来的运算（它们的梯度会自动由基本运算算出，不用另写）
    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * other ** -1

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __rsub__(self, other):
        return Value(other) - self

    def sigmoid(self):
        return 1 / (1 + (-self).exp())

    def __rtruediv__(self, other):
        return Value(other) * self ** -1

    # ---------------- 反向传播 ----------------
    def backward(self):
        """
        从当前节点（通常是损失）开始，把梯度反向传播到计算图中的所有节点。

        【思路】
            1. 拓扑排序：用深度优先搜索把所有节点排成一个列表，保证"每个节点都排在它的父节点之后"。
            2. 自己对自己的梯度是 1（∂L/∂L = 1）。
            3. 按拓扑顺序的**逆序**调用每个节点的 _backward：
               轮到某个节点时，所有用到它的下游节点都已经处理完了，它的 grad 已经是最终值，
               可以放心地把梯度继续往上游传。

        【边界】
            - 计算图必须无环（前向计算本来就不会产生环）。
            - 梯度是累加的：如果之前已经调用过 backward，请先把所有节点的 grad 清零。
        """
        topo, visited = [], set()

        def build(v):
            if v not in visited:
                visited.add(v)
                for parent in v._prev:
                    build(parent)
                topo.append(v)
        build(self)
        self.grad = 1.0
        for node in reversed(topo):
            node._backward()


def numerical_grad(f, values, eps=1e-6):
    """
    数值梯度：依次扰动每个输入的 data，用中心差分估计 ∂f/∂x。

    【参数】
        f      : 函数，输入一组 Value，返回一个 Value（每次调用都重新构建计算图）
        values : list[Value]，要求梯度的输入

    【返回】
        list[float]
    """
    grads = []
    for v in values:
        old = v.data
        v.data = old + eps
        fp = f().data
        v.data = old - eps
        fm = f().data
        v.data = old
        grads.append((fp - fm) / (2 * eps))
    return grads


def print_graph(root):
    """把计算图按"从输出到输入"的顺序打印成缩进的树（共享的节点只展开一次）。"""
    seen = set()

    def visit(v, depth):
        tag = f"[{v._op}]" if v._op else "[输入]"
        print("    " + "  " * depth + f"{tag:<7s}{v}")
        if id(v) in seen:
            return
        seen.add(id(v))
        for p in sorted(v._prev, key=lambda x: x.label):
            visit(p, depth + 1)
    visit(root, 0)


# =============================================================================
# 演示
# =============================================================================
def demo_simple_expression():
    """演示 1：f = (a * b + c) * d。"""
    print_header("演示 1：一个简单表达式  f = (a·b + c)·d")
    a, b, c, d = Value(2.0, label="a"), Value(-3.0, label="b"), Value(10.0, label="c"), Value(-2.0, label="d")
    e = a * b
    e.label = "e"
    s = e + c
    s.label = "s"
    f = s * d
    f.label = "f"
    f.backward()
    print("\n  前向：e = a·b = -6，s = e + c = 4，f = s·d = -8")
    print("  反向后的计算图（每个节点的 grad 就是 ∂f/∂该节点）：")
    print_graph(f)
    num = numerical_grad(lambda: (a * b + c) * d, [a, b, c, d])
    print(f"\n  数值梯度 ∂f/∂(a,b,c,d) = {[round(g, 4) for g in num]}")
    print("  手算：∂f/∂d = s = 4；∂f/∂s = d = -2；∂f/∂c = ∂f/∂s · 1 = -2；∂f/∂a = ∂f/∂s · b = 6；∂f/∂b = ∂f/∂s · a = -4")
    assert all(abs(x.grad - g) < 1e-6 for x, g in zip([a, b, c, d], num))


def demo_gates():
    """演示 2：加法、乘法、max 三种门的梯度规律。"""
    print_header("演示 2：三种'门'的梯度规律（上游梯度都设为 1）")
    for name, make in [("加法 x + y", lambda x, y: x + y), ("乘法 x · y", lambda x, y: x * y),
                       ("max(x, y)", lambda x, y: x.maximum(y))]:
        x, y = Value(3.0), Value(-1.0)
        make(x, y).backward()
        print(f"    {name:<12s}x = 3, y = -1 → ∂/∂x = {x.grad:+.0f}，∂/∂y = {y.grad:+.0f}")
    print("""
  规律（讲义第 5.3 节）：
    - 加法门：把上游梯度**原样分发**给所有输入（局部梯度都是 1）。
    - 乘法门：把上游梯度乘以**另一个**输入的值（"交换"）。所以输入的数值大小会影响梯度大小。
    - max 门：只把梯度**路由**给较大的那个输入，较小的那个梯度为 0。ReLU = max(0, x) 也是这样。""")


def demo_accumulation_bug():
    """演示 3：一个变量被用了多次时，梯度必须累加。"""
    print_header("演示 3：同一个变量被用了两次 —— 梯度要累加")
    a = Value(3.0, label="a")
    f = a * a + a                      # f = a² + a，∂f/∂a = 2a + 1 = 7
    f.backward()
    print(f"\n  f = a·a + a，a = 3：引擎算出 ∂f/∂a = {a.grad}（正确答案 2a + 1 = 7）")

    # 模拟一个"用 = 而不是 +=" 的错误实现
    class BuggyValue(Value):
        def __mul__(self, other):
            out = BuggyValue(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 __add__(self, other):
            out = BuggyValue(self.data + other.data, (self, other), "+")

            def _backward():
                self.grad = out.grad
                other.grad = out.grad
            out._backward = _backward
            return out
    b = BuggyValue(3.0)
    g = b * b + b
    g.backward()
    print(f"  如果 _backward 里写成 = 而不是 +=：算出 ∂f/∂a = {b.grad}  ← 错了！")
    print("""
  原因：a 在计算图里有三条"出边"（两次作为乘法的输入、一次作为加法的输入），
    多元链式法则说：∂f/∂a = 沿每条路径的梯度**之和**。用 = 会让后算的路径覆盖先算的。
    这和代码 01 演示 5 "一个词在窗口里出现两次，梯度要加起来"、第 1 讲的 np.add.at 是同一个道理。""")


def demo_neuron():
    """演示 4：一个神经元。"""
    print_header("演示 4：一个神经元  out = tanh(w1·x1 + w2·x2 + b)")
    x1, x2 = Value(2.0, label="x1"), Value(0.0, label="x2")
    w1, w2, b = Value(-3.0, label="w1"), Value(1.0, label="w2"), Value(6.8813735870195432, label="b")
    out = (x1 * w1 + x2 * w2 + b).tanh()
    out.backward()
    print(f"\n  out = {out.data:.4f}")
    for v in [w1, w2, b, x1, x2]:
        print(f"    ∂out/∂{v.label:<3s}= {v.grad:+.4f}")
    print("""
  观察：x2 = 0，所以 ∂out/∂w2 = x2 · (...) = 0 —— 输入为 0 的那条边，权重学不到任何东西。
    tanh 的局部梯度 1 - tanh² 在这里是 0.5，梯度被"打了五折"再往回传。""")


class Neuron:
    """一个神经元：tanh(Σ w_i x_i + b)，参数随机初始化在 [-1, 1]。"""

    def __init__(self, n_in, rng):
        self.w = [Value(rng.uniform(-1, 1)) for _ in range(n_in)]
        self.b = Value(0.0)

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

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


class MLP:
    """
    多层感知机：若干层神经元，每层的输出作为下一层的输入。最后一层输出一个数。

    【参数】
        n_in   : 输入维度
        hidden : list[int]，每个隐藏层的神经元个数
        seed   : 随机种子
    """

    def __init__(self, n_in, hidden, seed=0):
        rng = random.Random(seed)
        sizes = [n_in] + hidden + [1]
        self.layers = [[Neuron(sizes[i], rng) for _ in range(sizes[i + 1])] for i in range(len(sizes) - 1)]

    def __call__(self, x):
        for layer in self.layers:
            x = [neuron(x) for neuron in layer]
        return x[0]

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


def demo_xor():
    """演示 5：用自制的引擎训练一个小 MLP 解决 XOR。"""
    print_header("演示 5：用自制引擎训练 MLP 解决 XOR（线性模型做不到的问题）")
    X = [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]]
    Y = [-1.0, 1.0, 1.0, -1.0]                  # 输入相同输出 -1，不同输出 1（tanh 的取值范围是 [-1, 1]）
    model = MLP(2, [4, 4], seed=1)
    params = model.parameters()
    print(f"\n  网络结构 2 → 4 → 4 → 1，共 {len(params)} 个参数；损失 = 平方误差之和；学习率 0.1\n")
    for step in range(201):
        preds = [model(x) for x in X]
        loss = sum((p - y) ** 2 for p, y in zip(preds, Y))
        for p in params:
            p.grad = 0.0                           # 每一步之前清零梯度（PyTorch 的 optimizer.zero_grad()）
        loss.backward()
        for p in params:
            p.data -= 0.1 * p.grad
        if step % 40 == 0:
            print(f"    第 {step:>3d} 步：损失 {loss.data:.4f}，预测 {[round(p.data, 2) for p in preds]}")
    print("""
  观察：XOR 的四个点不能用一条直线分开（讲义第 3.3 节），但两层非线性神经元可以。
    整个训练循环 = 前向（建图）→ 清零梯度 → 反向传播 → 沿负梯度更新参数，和 PyTorch 的写法一模一样。""")


def demo_forward_vs_reverse():
    """演示 6：前向模式与反向模式的计算量。"""
    print_header("演示 6：为什么是'反向'传播？")
    print("""
  求导的顺序有两种：
    前向模式：从**一个输入**出发，往前推出"它对所有中间结果、最终输出"的导数。
              n 个输入 → 要做 n 遍。
    反向模式：从**一个输出**出发，往回推出"它对所有中间结果、所有输入"的导数。
              m 个输出 → 要做 m 遍。
  神经网络的情形是：参数（输入）有几百万到几千亿个，损失（输出）只有 1 个。
  所以反向模式只需要 **1 遍**反向传播，就得到损失对所有参数的梯度，代价和一次前向传播差不多。
  这就是反向传播之所以是深度学习基石的原因。""")
    # 一个小验证：计算量（节点访问次数）和前向差不多
    model = MLP(10, [16, 16], seed=0)
    x = [Value(0.1 * i) for i in range(10)]
    out = model(x)
    topo, seen = [], set()

    def build(v):
        if v not in seen:
            seen.add(v)
            for p in v._prev:
                build(p)
            topo.append(v)
    build(out)
    out.backward()
    print(f"  例子：10 → 16 → 16 → 1 的 MLP，计算图共 {len(topo)} 个节点、{len(model.parameters())} 个参数；")
    print(f"  一次反向传播访问每个节点恰好一次，就得到了全部 {len(model.parameters())} 个参数的梯度。")


if __name__ == "__main__":
    demo_simple_expression()
    demo_gates()
    demo_accumulation_bug()
    demo_neuron()
    demo_xor()
    demo_forward_vs_reverse()
