Python技术迷

100行Python代码,带你优雅地搭建神经网络

昨天晚上十一点多,我在公司楼下拿着奶茶吹风,准备收工回去打两把游戏,我们组那个小李突然微信语音锤过来一句:

“哥,你能不能用那种……就一百行 Python,给我整一个最简神经网络?别一上来就是 PyTorch、TensorFlow 那种,我脑袋疼。”

我嘴上说你这人怎么总是临下班提需求,手已经很诚实地打开了编辑器。想了想,干脆把这个过程写下来,当个完整小demo,以后再有人问,直接甩链接就完事了。

别一上来就想着 GPT 那种怪物,咱今天只干一件超级小的事:

用差不多 100 行 Python,自己撸一个“从 0 到 1”的神经网络,搞定一个经典的 XOR(异或)问题:

  • 输入两个 0/1
  • 只有在两个数不一样的时候输出 1,否则输出 0

这个问题线性模型搞不定,必须上一个有隐藏层的神经网络,刚好能把“非线性”这种抽象玩意儿变成肉眼可见的东西。

【先把数学吓人的地方都说人话】

神经网络那几件事,翻译成打工人的语言就三点:

  • 前向:给你一堆输入,算一算现在模型是怎么“瞎猜”的
  • 计算误差:看一眼猜得离真实答案有多远
  • 反向传播:按贡献把锅甩回每一层、每一个参数,然后统一扣工资——也就是梯度下降更新参数

为了不把你劝退,今天只搞:

  • 一个隐藏层
  • 激活函数用 sigmoid
  • 损失用最常见的二分类交叉熵

所有东西都写死在一个文件里,运行就能看结果,没任何框架依赖,只有 numpy。

【先把骨架写出来】

我当时是在地库等电梯的时候,在手机上备忘录里把代码骨架先敲了一遍,大概长这样:

import numpy as np

np.random.seed(42)

# 1. 准备数据:XOR 真值表
X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
], dtype=np.float32)

y = np.array([[0], [1], [1], [0]], dtype=np.float32)

# 2. 一些超参数
input_size = 2
hidden_size = 4
output_size = 1
lr = 0.1
epochs = 10000

# 3. 初始化参数(权重 + 偏置)
W1 = np.random.randn(input_size, hidden_size)
b1 = np.zeros((1, hidden_size))
W2 = np.random.randn(hidden_size, output_size)
b2 = np.zeros((1, output_size))

defsigmoid(x):
return1 / (1 + np.exp(-x))

defsigmoid_grad(x):
    s = sigmoid(x)
return s * (1 - s)

defbinary_cross_entropy(y_true, y_pred, eps=1e-8):
    y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(y_true * np.log(y_pred) +
                    (1 - y_true) * np.log(1 - y_pred))

for epoch in range(epochs):
# 前向传播
    z1 = X @ W1 + b1          # (4, 2) @ (2, 4) -> (4, 4)
    a1 = sigmoid(z1)          # 隐藏层输出
    z2 = a1 @ W2 + b2         # (4, 4) @ (4, 1) -> (4, 1)
    y_pred = sigmoid(z2)      # 最终输出(概率)

# 计算损失
    loss = binary_cross_entropy(y, y_pred)

# 反向传播
    dz2 = (y_pred - y)              # dL/dz2
    dW2 = a1.T @ dz2 / len(X)
    db2 = np.mean(dz2, axis=0, keepdims=True)

    da1 = dz2 @ W2.T
    dz1 = da1 * sigmoid_grad(z1)
    dW1 = X.T @ dz1 / len(X)
    db1 = np.mean(dz1, axis=0, keepdims=True)

# 梯度下降更新参数
    W1 -= lr * dW1
    b1 -= lr * db1
    W2 -= lr * dW2
    b2 -= lr * db2

if epoch % 2000 == 0:
        print(f"epoch {epoch}, loss = {loss:.4f}")

# 训练完成后,看一下预测结果
y_out = (y_pred > 0.5).astype(int)
print("预测结果:")
print(np.hstack([X, y, y_out]))

你可以直接把这一坨复制到 xor_nn.py 里,装个 numpy 然后 python xor_nn.py 跑一下,终端会慢慢把 loss 打到很小,最后输出类似:

[0,0] -> 0[0,1] -> 1[1,0] -> 1[1,1] -> 0

就说明这个小破网已经学会 XOR 了。

当时小李问得很细,我就一边给他讲一边拎代码里的几行出来解释,你要是现在正困,也可以先收藏,醒了再看。

先看数据那块,其实就是把真值表写成矩阵而已:

X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
], dtype=np.float32)

y = np.array([[0], [1], [1], [0]], dtype=np.float32)

你可以想象成 4 条样本:

  • 第一条:输入 0 0,标签是 0
  • 第二条:输入 0 1,标签是 1 … 这种极简数据特别适合调试,不用去下什么 MNIST。

接着是网络结构和超参数:

input_size = 2
hidden_size = 4
output_size = 1
lr = 0.1
epochs = 10000

输入 2 维很好理解,输出 1 维是因为我们做的是二分类(输出一个概率);隐藏层我随手写了 4,你也可以改成 3、8 之类的玩玩,反正太小学不会,太大容易乱动但也能拟合。

初始化参数这一段是重点之一:

W1 = np.random.randn(input_size, hidden_size)
b1 = np.zeros((1, hidden_size))
W2 = np.random.randn(hidden_size, output_size)
b2 = np.zeros((1, output_size))
  • W1 把输入投到隐藏层
  • b1 是隐藏层的偏置
  • W2 把隐藏层投到输出层
  • b2 是输出层偏置

你可以把每一个 W 想象成“你现在给某个特征多少权重”,偏置就是“给你一点起步价”。

【前向传播:这一坨矩阵乘,别怕】

那几行矩阵乘看着有点吓人,其实完全可以照着维度看:

z1 = X @ W1 + b1      # (4, 2) @ (2, 4) -> (4, 4)
a1 = sigmoid(z1)
z2 = a1 @ W2 + b2     # (4, 4) @ (4, 1) -> (4, 1)
y_pred = sigmoid(z2)
  • z1:把每条输入乘以 W1 加偏置,得到隐藏层的“原始信号”
  • a1:套一层 sigmoid,等于给每个隐藏神经元加一个“非线性脑回路”
  • z2:隐藏层再乘 W2 + b2
  • y_pred:再 sigmoid 一下,变成 0~1 之间的概率

为啥大家这么喜欢 sigmoid?因为它输入可以是任何实数,输出永远在 (0,1) 里,正好可以被当成概率。

【损失函数那几行,是“谁挨骂”的依据】

defbinary_cross_entropy(y_true, y_pred, eps=1e-8):
    y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(y_true * np.log(y_pred) +
                    (1 - y_true) * np.log(1 - y_pred))

交叉熵其实就一件事:

  • 你让模型预测接近真实标签,就奖励
  • 差得远就狠狠扣分

np.clip 是为了防止 log(0) 把你整成 nan。

【反向传播:统一算账,谁的问题扣谁的钱】

然后就是那坨最容易让人退群的反向传播:

dz2 = (y_pred - y)              # dL/dz2
dW2 = a1.T @ dz2 / len(X)
db2 = np.mean(dz2, axis=0, keepdims=True)

da1 = dz2 @ W2.T
dz1 = da1 * sigmoid_grad(z1)
dW1 = X.T @ dz1 / len(X)
db1 = np.mean(dz1, axis=0, keepdims=True)

别去死磕每一个偏导的公式,把大致的逻辑记住就行:

  • dz2 = y_pred - y:输出层的误差,就是“你猜的 - 真实的”
  • dW2、db2:根据这个误差,算第二层的权重和偏置应该怎么改
  • da1 = dz2 @ W2.T:误差往前传到隐藏层
  • dz1 = da1 * sigmoid_grad(z1):乘上激活函数的导数,说明“这个隐藏层输出对最终损失的影响程度”
  • dW1、db1:同理,对第一层参数算应不应该调

这么绕来绕去的目的只有一个:别冤枉任何一个参数,每个人干了多少错事,就改多少。

【更新:给每个参数微微推一小步】

W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2

学习率 lr 就是“你骂人有多狠”:

  • 太大:一脚把参数踢飞,loss 抖成 disco
  • 太小:骂了半天也不长记性,训练巨慢

我现在一般会先用 0.1 尝试一下,如果 loss 抖得厉害,就改成 0.01 再试。

【差不多 100 行,其实已经是一个“迷你框架”了】

你回头再看一下这个脚本,其实已经有了一个框架的基本元素:

  • 参数初始化
  • 前向
  • 损失
  • 反向
  • 参数更新
  • 训练循环 + 打日志

如果你愿意稍微工程化一点,把这些包成一个类也很顺手,例如:

classTinyNN:
def__init__(self, input_size, hidden_size, output_size):
        self.W1 = np.random.randn(input_size, hidden_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size)
        self.b2 = np.zeros((1, output_size))

defforward(self, X):
        self.z1 = X @ self.W1 + self.b1
        self.a1 = sigmoid(self.z1)
        self.z2 = self.a1 @ self.W2 + self.b2
        self.y_pred = sigmoid(self.z2)
return self.y_pred

defbackward(self, X, y, lr):
        dz2 = self.y_pred - y
        dW2 = self.a1.T @ dz2 / len(X)
        db2 = np.mean(dz2, axis=0, keepdims=True)

        da1 = dz2 @ self.W2.T
        dz1 = da1 * sigmoid_grad(self.z1)
        dW1 = X.T @ dz1 / len(X)
        db1 = np.mean(dz1, axis=0, keepdims=True)

        self.W1 -= lr * dW1
        self.b1 -= lr * db1
        self.W2 -= lr * dW2
        self.b2 -= lr * db2

再配一个训练循环,总行数也还是在一百行上下晃悠。等你哪天看 PyTorch 的 nn.Module,会发现其实也是这味道,只不过人家帮你封装好了各种算子、自动求导、显卡并行之类的。

再往后,如果哪天你打开 PyTorch:

  • 看到 nn.Linear,你就会想到:哦,这就是帮我管 W 和 b 的
  • 看到 loss.backward(),你就知道:这货把反向传播那堆公式帮我全推了

脑子里有了这 100 行打底,你再学别的框架,会轻松很多,不会动不动就怀疑人生。

行了,我这边外卖到了,今天就先唠到这,后面有空再整一篇“用同样风格写个卷积网络”的版本。