哎我跟你说啊,最近不是老有人问“神经网络到底咋搭起来的”…我就想起我当年刚学的时候,也是被一堆框架名词绕晕,什么 Layer 啊 Module 啊 Optimizer 啊,听起来很高级,结果一看源码:哦…就是一堆矩阵乘法加上链式求导嘛,对吧。

然后我就干了个很“土”的事:把框架先扔一边,用 100行左右的 Python 手搓一个小 MLP(两层全连接),能跑、能训、loss 真能下的那种。你别说,这玩意跟线上排查故障一个味儿:先把链路拉直了,再谈“优雅”。(就跟我以前查那种“日志里看着都正常,结果是隐藏默认配置坑你”的感觉一模一样…)

我先把场景说清楚哈:我们就拿 XOR 当小白鼠。XOR 这玩意你用一层线性回归怎么都学不会,但两层神经网络一下就“开窍”,特别适合验证你写的反向传播是不是写对了。你要是能把 XOR 训到接近 0 的 loss,基本就说明:前向、反向、参数更新、广播维度……都没踩坑。

下面这段代码,我自己写的,尽量压到 100 行上下(含空行注释差不多就那样,你别拿尺子量我哈…),核心点就三块: 1)线性层 forward/backward 2)ReLU、Sigmoid 3)BCE loss(带数值稳定)+ SGD 更新

# 100-ish lines: tiny neural net from scratch (numpy)
import numpy as np

def sigmoid(x):
    x = np.clip(x, -5050)
    return 1 / (1 + np.exp(-x))

def relu(x):
    return np.maximum(0, x)

class Linear:
    def __init__(self, in_dim, out_dim):
        # He init-ish for relu
        self.W = np.random.randn(in_dim, out_dim) * np.sqrt(2.0 / in_dim)
        self.b = np.zeros((1, out_dim))
        self.x = None
        self.dW = None
        self.db = None

    def forward(self, x):
        self.x = x
        return x @ self.W + self.b

    def backward(self, grad_out):
        # grad_out: dL/dy
        self.dW = self.x.T @ grad_out
        self.db = grad_out.sum(axis=0, keepdims=True)
        return grad_out @ self.W.T  # dL/dx

class MLP:
    def __init__(self, in_dim, hidden_dim, out_dim):
        self.l1 = Linear(in_dim, hidden_dim)
        self.l2 = Linear(hidden_dim, out_dim)
        self.z1 = None
        self.a1 = None
        self.z2 = None
        self.yhat = None

    def forward(self, x):
        self.z1 = self.l1.forward(x)
        self.a1 = relu(self.z1)
        self.z2 = self.l2.forward(self.a1)
        self.yhat = sigmoid(self.z2)
        return self.yhat

    def backward(self, x, y):
        # Binary cross entropy: L = -[ y log(p) + (1-y) log(1-p) ]
        eps = 1e-8
        p = np.clip(self.yhat, eps, 1 - eps)

        # dL/dz2 for sigmoid + BCE simplifies nicely: (p - y)
        grad_z2 = (p - y) / y.shape[0]

        grad_a1 = self.l2.backward(grad_z2)

        grad_z1 = grad_a1.copy()
        grad_z1[self.z1 <= 0] = 0  # ReLU'

        _ = self.l1.backward(grad_z1)

    def step(self, lr):
        for layer in (self.l1, self.l2):
            layer.W -= lr * layer.dW
            layer.b -= lr * layer.db

def bce_loss(y, p):
    eps = 1e-8
    p = np.clip(p, eps, 1 - eps)
    return -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))

if __name__ == "__main__":
    np.random.seed(7)

    # XOR dataset
    X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=np.float32)
    y = np.array([[0],[1],[1],[0]], dtype=np.float32)

    net = MLP(in_dim=2, hidden_dim=8, out_dim=1)
    lr = 0.3

    for epoch in range(15001):
        p = net.forward(X)
        loss = bce_loss(y, p)
        net.backward(X, y)
        net.step(lr)

        if epoch % 500 == 0:
            pred = (p > 0.5).astype(int)
            acc = (pred == y).mean()
            print(f"epoch={epoch:4d} loss={loss:.4f} acc={acc:.2f}")

    print("final prob:", net.forward(X).ravel())

你跑起来大概会看到 loss 一路往下掉,acc 最后基本 1.00。要是你跑出来 loss 卡住不动、或者 acc 永远 0.50,那八成是下面这种坑(我当年也…嗯踩过):

  • 维度广播b 一定要 (1, out_dim),别写成 (out_dim,) 然后你以为没事,结果反向 db 求和的时候就怪怪的。
  • 梯度平均:我在 grad_z2 = (p - y) / batch_size 这里除了一下,不然 lr 你得重新调。
  • 数值稳定log(0) 会直接炸,clip 一下别硬刚。
  • 初始化:你要是全 0 初始化,网络就“集体摆烂”,学不动(参数对称性那事儿…懂的都懂)。

然后你会突然发现,所谓“优雅搭建神经网络”,真不是花里胡哨的 API,而是你把这条链路心里有数: 前向就是堆算子;反向就是把每个算子的局部导数乘回去;更新就是 param -= lr * grad。框架做的事情,本质就是把这套东西封装成积木,还顺便给你做了 autograd、混精、分布式、编译加速这些“工程活”。

你要是想再往“优雅”靠一步(但还不引入 PyTorch 那种重量级),可以干两件事: 1)给每个算子都统一成 forward/backward 接口,再写个 Sequential 去串起来(代码会更像框架) 2)把参数收集做成 parameters(),优化器单独写个 SGD(momentum=0.9),训练循环就会很“顺手”

不过我建议你先别急着抽象,先把上面这段跑通,跑通以后你再回头看任何框架的 nn.Linear / nn.ReLU / loss.backward(),你会有一种……怎么说呢,“哦原来你也就这点事”的通透感。