AI by Hand:用手算搞懂神经网络与Transformer核心原理
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×2W1 [ [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到这里你已经完成了隐藏层的全部计算。中间结果神经元线性加权结果激活后输出隐藏单元 11.30.7858隐藏单元 20.00.5注意这一刻你看到的h [0.7858, 0.5]就是框架里隐藏层的输出。以后如果中间层加了 Dropout、BatchNorm 或 LayerNorm你会知道它是作用在这一层输出上的而不是作用在原始输入上。3.3 从隐藏层到输出得到预测值输出层同样先做线性加权再做 sigmoidz2 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_features2, out_features2)你会立刻知道它对应一个 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.25delta1_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.0261b1的梯度是[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_gradTrue) b1 torch.tensor([0.1, 0.0], requires_gradTrue) W2 torch.tensor([[0.6], [-0.3]], requires_gradTrue) b2 torch.tensor([0.05], requires_gradTrue) 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