AI by Hand 的核心思路很简单:挑一个小规模的神经网络或注意力模块,把每一次矩阵乘、激活、归一化、反向求导,都用具体的数字亲手算一遍。它不是某个新框架的代号,也不是要多贵的 GPU 才能跑的实验,更像一套适合自己动手建立模型直觉的学习方法。很多人学 AI 是“先装框架,再调接口”,模型跑通就以为懂了。但模型为什么输出这个值、梯度为什么往这个方向走、参数更新后 loss 究竟怎么变,如果没亲手算过,后续调参和排查基本只能靠猜。
这篇文章的价值就在这里。我会用两套能拿笔算的迷你样例,把神经网络前向传播、反向传播,以及 Transformer 里的自注意力机制完整拆开。你不需要先装大模型推理环境,也不需要准备数据集。只要有一个能跑 Python 和 NumPy 的环境,就可以一边手算一边验证。读完你会得到一个比“会调用”更扎实的判断力:看到维度不对、梯度消失、注意力权重分布异常这些问题时,能够更快定位原因。
1. 先搞清楚“AI by Hand”到底在练什么
1.1 它训练的是数字直觉,不是模型能力
很多人第一次接触神经网络,面对的是 PyTorch 里十几行代码。model.train()一跑,loss 下降,准确率上升,一切看起来都很顺利。但问到“第一层权重更新了多少”“sigmoid 在输入为 3 和输入为 -3 时的导数分别是多少”,就答不上来了。
AI by Hand 的做法正好反过来。它不追求一次性跑一个大模型,而是把一个只有几个神经元的网络,一条一条数值地算给你看。输入是 1.0、2.0,权重是 0.2、-0.4,经过线性加权后得到什么,再经过激活函数后变成什么,全部写出来。
这个过程训练的是数字直觉。有了这种直觉,你看一个模型结构时,脑子里不只是“层”和“参数”两个抽象概念,而是一连串可以预判的数值流动。这对后面做调参、做部署、做性能优化都非常有用。
1.2 它解决的是“懂了框架,没懂模型”的问题
框架把太多细节封装掉了,这是它能流行起来的原因,也是学习时最容易踩的坑。你调用的Linear层,内部是一次矩阵乘加偏置;你调用的CrossEntropyLoss,内部要经过 softmax 再取负对数;你调用的backward(),内部是一整条链式法则。封装是好的,但如果你从没打开看过,出了问题只能把希望寄托在试错上。
AI by Hand 更像是对这些封装的“逆向说明”。你可以选很小的样例,手动算出前向输出,再用框架算一遍,两边对得上,就说明你对层的理解是正确的;对不上,说明某个形状、某个公式或某个维度理解错了。这种验证方式比单纯看文档印象深得多。
我一般建议把这套方法用在三个阶段:刚学机器学习时补基础、已经会用框架但想理解参数更新、做模型部署或量化前确认自己对计算流程的判断。它不替代框架,但它能帮你少走很多弯路。
2. 动手前的准备:环境、样例和验收标准
2.1 软件准备:一个 Python 环境和 NumPy 就够
这类学习实验对硬件没有要求。CPU 就可以跑,内存 8GB 上下也能很从容,因为样例本身非常小。不需要 GPU,也不需要先下载大模型权重。
如果你只是想先跟着算一遍,不写代码也行。找一张白纸、一个计算器,或者用 Excel 记录中间值,都能完成大部分工作。但为了让结果可验证,我建议还是准备一个 Python 环境。
建议的依赖如下:
| 项目 | 用途 | 是否必须 |
|---|---|---|
| Python 3 | 运行脚本 | 是 |
| NumPy | 做矩阵运算和验证 | 是 |
| Jupyter Notebook | 逐格记录和对照 | 可选 |
| PyTorch 或 TensorFlow | 对照自动微分结果 | 可选 |
这里要提醒一点:如果你装了 PyTorch,只用来做自动微分对照,一般 CPU 版本就够,不需要为了这篇文章去下载 CUDA 版本。等你真正训练更大的模型时,再考虑 GPU 环境。
2.2 样例要小,数字要能心算或口算
手算最忌讳一上来就用真实模型。真实模型动辄几十层,参数量巨大,没人能逐层手算。你应该先把问题压缩到最小可验证的尺寸。
拿神经网络来说,我建议从“2 个输入、2 个隐藏神经元、1 个输出神经元”开始。只有这样才能把每一步中间结果完整列出来。拿 Transformer 来说,我建议从“3 个 token、每个 token 2 维向量、单头注意力”开始。数字越整齐,越容易定位自己是不是算错。
还有一个经验:样例的权重和输入不要用随机小数,而是自己定一组容易出整数的值。比如输入取 1.0 和 2.0,权重取 0.2、0.5、-0.4 这类位数少的数。这样计算过程不会被繁琐的小数干扰,注意力可以集中在公式上。
2.3 先定义“算对了”的标准
手算没有标准答案就没有意义。每次动手前,你要先想清楚怎么验收。
对前向传播来说,验收标准是预测值和中间层输出。预测值来自你自己的计算,但你还需要一个权威来源做交叉验证,那就是代码。对反向传播来说,验收标准是每个参数的梯度,可以和自动微分的结果对照。对注意力机制来说,验收标准是注意力权重矩阵每一行之和是否为 1,以及输出向量的维度是否正确。
我的习惯是先手算一遍,把结果记在纸上,再用 NumPy 跑一遍。两边数值在小数点后两三位内一致,就认为理解没有大偏差;如果差得很多,通常不是浮点精度问题,而是某个公式或维度理解错了。
3. 用一个两层神经网络把前向传播算明白
3.1 一个迷你网络的具体配置
我先给一组用于演示的数字。这组数字是我为了手算方便自己定的,不是任何真实模型导出的权重,你只需要把它当成一个“最小可复现样例”。
网络结构如下:
- 输入层:2 个特征,取值为
x = [1.0, 2.0] - 隐藏层:2 个神经元,激活函数用 sigmoid
- 输出层:1 个神经元,激活函数用 sigmoid
- 预测目标:真实值
y = 0.7
第一层权重用W1表示,形状是 2×2:
W1 = [ [0.2, -0.4], [0.5, 0.2] ]第一层偏置:
b1 = [0.1, 0.0]第二层权重W2是长度为 2 的向量:
W2 = [0.6, -0.3]第二层偏置:
b2 = 0.05请记住这里的布局。我使用的是“一行是一个样本”的写法,输入向量是行向量,权重矩阵的每一列对应一个隐藏神经元。这样x @ W1的结果就是一个长度为 2 的向量。如果你习惯用列向量,公式方向会相反,后面对照代码时比较容易出错。
3.2 从输入到隐藏层:线性加权加激活
隐藏层第一个神经元的输入是:
z1_1 = 1.0 × 0.2 + 2.0 × 0.5 + 0.1 = 1.3隐藏层第二个神经元的输入是:
z1_2 = 1.0 × (-0.4) + 2.0 × 0.2 + 0.0 = 0.0sigmoid 公式是:
sigmoid(z) = 1 / (1 + exp(-z))所以第一个隐藏神经元的输出是:
h1 = sigmoid(1.3) ≈ 0.7858第二个隐藏神经元的输出是:
h2 = sigmoid(0.0) = 0.5到这里,你已经完成了隐藏层的全部计算。中间结果:
| 神经元 | 线性加权结果 | 激活后输出 |
|---|---|---|
| 隐藏单元 1 | 1.3 | 0.7858 |
| 隐藏单元 2 | 0.0 | 0.5 |
注意,这一刻你看到的h = [0.7858, 0.5],就是框架里隐藏层的输出。以后如果中间层加了 Dropout、BatchNorm 或 LayerNorm,你会知道它是作用在这一层输出上的,而不是作用在原始输入上。
3.3 从隐藏层到输出:得到预测值
输出层同样先做线性加权,再做 sigmoid:
z2 = 0.7858 × 0.6 + 0.5 × (-0.3) + 0.05 = 0.4715 - 0.15 + 0.05 = 0.3715预测值:
pred = sigmoid(0.3715) ≈ 0.5918如果只看结果,pred = 0.5918,和真实值y = 0.7还有差距。这个差距会用来计算损失,也会通过反向传播转化为梯度。
按照均方误差来算,损失可以取:
loss = 0.5 × (0.7 - 0.5918)^2 ≈ 0.00585到这里,前向传播就完整算完了。你也许会问,这点数有什么用?它的用处是让你知道,框架里那行model(x)并不是魔法。它无非是把这样的线性加权、加偏置、激活函数一路执行下去。当你以后看到一个Linear(in_features=2, out_features=2),你会立刻知道它对应一个 2×2 的权重矩阵和一个长度为 2 的偏置向量。
4. 反向传播也手算一遍,才能真正理解训练
4.1 输出层的梯度:误差、sigmoid 导数和链式法则
前向传播只是“读”模型,反向传播才是“训”模型。反向传播的核心是链式法则:从损失函数出发,逐层计算每个参数对损失的贡献。
先算输出层。输出层激活值是pred = 0.5918,目标值是y = 0.7。sigmoid 的导数有一个很友好的形式:
sigmoid'(z) = sigmoid(z) × (1 - sigmoid(z))对输出层来说,损失对z2的导数可以写成:
delta2 = (pred - y) × pred × (1 - pred)代入数值:
pred - y = 0.5918 - 0.7 = -0.1082 pred × (1 - pred) = 0.5918 × 0.4082 ≈ 0.2416 delta2 ≈ -0.0261这个delta2是整条反向传播链的起点。所有上游参数的梯度,都要从它开始乘下去。
4.2 从输出层往输入层逐层回传
第二层权重W2 = [0.6, -0.3]的梯度,等于delta2乘以上一层对应神经元的输出。
dL/dW2_1 = delta2 × h1 = -0.0261 × 0.7858 ≈ -0.0205 dL/dW2_2 = delta2 × h2 = -0.0261 × 0.5 ≈ -0.0131W2两个分量都是负数梯度。按梯度下降的更新规则,参数会往梯度相反方向移动,所以W2会增大一点。这里要注意,梯度符号和 loss 下降方向的关系,是很多初学者忽略的点。你手动算一次,就能清晰地看到“负梯度”是什么意思。
接下来把误差回传到隐藏层。隐藏层第一个神经元的误差信号:
delta1_1 = delta2 × W2_1 × sigmoid'(1.3)已知sigmoid'(1.3) ≈ 0.7858 × 0.2142 ≈ 0.1683,所以:
delta1_1 ≈ -0.0261 × 0.6 × 0.1683 ≈ -0.00264隐藏层第二个神经元的误差信号,其中sigmoid'(0) = 0.25:
delta1_2 = -0.0261 × (-0.3) × 0.25 ≈ 0.00196到这里你会看到一个非常重要的现象:误差信号从输出层的-0.0261,传到隐藏层第一个神经元后变成-0.00264,缩小了将近十倍。这就是“梯度消失”这个说法的来源。
只要网络层数够深,或者激活函数选得不合适,误差信号会一层层缩小,最后传到前面几层时几乎为零。手动算过一个三层甚至四层网络后,你会对“为什么不用 sigmoid 做深层隐藏层、为什么 ReLU 系列更常用”有更直接的体会,而不只是背结论。
隐藏层权重W1的梯度,可以用同样的规则继续算:
dL/dW1[0,0] = delta1_1 × x1 = -0.00264 × 1.0 ≈ -0.00264 dL/dW1[1,0] = delta1_1 × x2 = -0.00264 × 2.0 ≈ -0.00528 dL/dW1[0,1] = delta1_2 × x1 = 0.00196 × 1.0 ≈ 0.00196 dL/dW1[1,1] = delta1_2 × x2 = 0.00196 × 2.0 ≈ 0.00392偏置的梯度也一并记下来。b2的梯度就是delta2 ≈ -0.0261,b1的梯度是[delta1_1, delta1_2] ≈ [-0.00264, 0.00196]。
4.3 用自动微分结果做对照
如果你装了 PyTorch,可以用一个非常短的脚本和自己的手算结果对照。对这个例子,只需要把相同结构搭出来,调用backward(),然后查看每个参数的.grad。
import torch W1 = torch.tensor([[0.2, -0.4], [0.5, 0.2]], requires_grad=True) b1 = torch.tensor([0.1, 0.0], requires_grad=True) W2 = torch.tensor([[0.6], [-0.3]], requires_grad=True) b2 = torch.tensor([0.05], requires_grad=True) x = torch.tensor([1.0, 2.0]) y = torch.tensor([0.7]) h = torch.sigmoid(x @ W1 + b1) pred = torch.sigmoid(h @ W2 + b2) loss = 0.5 * ((pred - y) ** 2).sum() loss.backward() print("W1.grad:\n", W1.grad) print("b1.grad:", b1