神经网络是可以求导的数学表达式
查看原文 00:37现代神经网络的训练系统包含许多部件,因此容易显得神秘。Andrej Karpathy 从一个更小的问题入手:如果预测和误差由基本运算构成,我们如何知道每个权重应该向哪个方向调整?他的回答是先构建处理单个数值的自动微分引擎 micrograd,再用它训练一个小网络。
前向传播计算表达式的结果;反向传播沿计算图倒着走,利用链式法则计算最终输出对每个中间值和输入的响应。同一套机制也适用于神经网络之外的数学表达式。micrograd 为了便于理解而逐个处理标量;生产系统把数值组成张量,借助并行运算提高效率。课程提到的代码行数和仓库内容反映的是 2022 年录制时的版本。
导数描述当前位置附近的响应
查看原文 08:11考虑 f(x) = 3x² − 4x + 5。当 x = 3 时,输出为 20。让 x 增加一个小量 h,重新计算输出,再用输出变化量除以 h:(f(x+h) − f(x))/h。导数的精确定义是极限 f′(x) = lim(h→0) (f(x+h) − f(x))/h;本函数的导数为 6x − 4,在 3 处恰好是 14。取有限的 h 时,差商只是这个斜率的近似;在 −3 处为负,在 2/3 处为零。h 并非越小越好:计算机浮点数精度有限,过小反而会损害估计。
有多个输入时,固定其他输入,只改变一个。例如 d = a*b + c,取 a = 2、b = −3、c = 10,输出为 4。三个偏导数分别是 ∂d/∂a = −3、∂d/∂b = 2、∂d/∂c = 1。它们描述的是局部响应,不能直接预测任意大幅度调整的结果。
例如,固定 b 和 c,只让 a 增加 h = 0.0001,d 会从 4 降到 3.9997。用变化量除以 h,结果约为 −3:因为 b 为负,a 增大时乘积会变得更负。对 b 和 c 分别重复这个实验,就能得到另外两个敏感度。数值检查只能估计导数;引擎使用的仍是精确的局部求导规则。
Value 对象记录计算图
查看原文 19:19Karpathy 用 Value 对象包裹每个标量,保存数值 data、初始为零的 grad,以及产生它的输入和运算。重载加法与乘法后,每次运算都创建新的 Value 并记录前驱。图中既可以画出数值方框,也可以画出运算节点;后者是辅助阅读的符号,并非额外的 Value。
例如 L = (a*b + c)*f,取 a = 2、b = −3、c = 10、f = −2,中间结果依次为 a*b = −6、d = 4,最终 L = −8。计算图保存了从输出追溯到输入所需的运算关系。
链式法则把梯度向后传递
查看原文 29:26每个节点的 grad 表示最终输出对该节点的导数。从 ∂L/∂L = 1 开始。在 L = d*f 中,对 d 的局部导数是 f = −2,对 f 的局部导数是 d = 4。加法对两个输入的局部导数都是 1,因此将传入梯度原样传给两个输入;乘法则把传入梯度分别乘以另一个输入。
链式法则将一条路径上的影响相乘:∂L/∂a = (∂L/∂d)(∂d/∂(a*b))(∂(a*b)/∂a) = (−2)*1*(−3) = 6。同理,∂L/∂b = −4。可以用小幅有限差分检查结果。在可微点且梯度非零时,沿梯度方向走足够小的一步会在局部提高输出;要降低损失,则沿相反方向移动。
中间梯度让计算过程可以逐步检查:d.grad = −2、f.grad = 4;加法把 −2 传给 c 和 a*b;乘法再得到 a.grad = 6、b.grad = −4。Karpathy 用微小的输入变化检验这些结果,然后沿梯度方向走一小步,观察到 L 上升。
神经元在加权求和后加入非线性
查看原文 53:04一个神经元先组合输入和权重:n = w1*x1 + w2*x2 + b,然后应用激活函数。偏置 b 调整响应位置;非线性激活使网络能够表达超出简单线性变换的关系。课程使用 tanh,它把实数平滑映射到 −1 与 1 之间。若输出为 t,其局部导数是 1 − t²。
例子取 x1 = 2、x2 = 0、w1 = −3、w2 = 1。偏置约为 6.8814,使 n 约为 0.8814,tanh(n) 约为 0.7071。把输出梯度设为 1 后,tanh 的局部导数 1 − 0.7071² 约为 0.5。两个加法节点都把 0.5 向后传递,得到权重梯度 0.5*x1 = 1 和 0.5*x2 = 0;输入梯度则为 0.5*w1 = −1.5 和 0.5*w2 = 0.5。
无论把 tanh 实现成一个带局部导数的独立运算,还是拆成指数与算术运算,同一套反向机制都适用。在课程例子中,如果一个输入为零,对应权重在这次计算中的梯度也是零:此时改变该权重不会改变乘积。
逆序执行并累加贡献,才能自动反向传播
查看原文 69:08每个结果 Value 保存一个反向函数,把传入梯度经过本运算的局部导数传给输入。为了自动确定顺序,先拓扑遍历计算图,让前驱排在结果之前,再逆序处理。一个节点向前驱传递梯度时,下游的所有贡献都应已经到齐。输出节点的梯度初始化为 1。
下面是根据笔记本提炼的示意伪代码,不是完整可运行的实现。每个闭包记住前向计算时的两个输入和结果;out.grad 是最终输出对 out 的导数,也就是传入的上游梯度:
add(a, b):
out = Value(a.data + b.data, parents={a, b})
out._backward = () => { a.grad += out.grad; b.grad += out.grad }
return out
multiply(a, b):
out = Value(a.data * b.data, parents={a, b})
out._backward = () => {
a.grad += b.data * out.grad
b.grad += a.data * out.grad
}
return out对 L = (a*b+c)*f,前向传播依次记录乘法、加法、乘法。反向传播将 L.grad 设为 1,向 d 和 f 分别传入 −2 和 4,再向加法的两个输入各传入 −2,最后把上游的 −2 分别乘以乘法的另一个操作数,得到 a.grad = 6 和 b.grad = −4。
遍历过程同样可以写成伪代码:每个不同节点只访问一次,先处理前驱再加入列表,最后逆序执行:
visit(node):
if node not in visited:
add node to visited
for parent in node.parents: visit(parent)
append node to topo
visit(output)
output.grad = 1
for node in reverse(topo): node._backward()如果反向函数用赋值覆盖梯度,就会出现隐蔽错误。对于 b = a + a,两条路径各贡献 1,所以 db/da = 2;赋值两次却只剩 1。访问集合和图中只保留一个 a,但加法闭包仍记着两个操作数引用,会执行两次贡献;画面本身没有清楚区分两条边。分叉后重新汇合也会遇到同样问题。因此每次贡献要用 += 累加,并让梯度从零开始。把普通数值包装成 Value,并实现反向运算符、常数幂、减法与除法,可以支持更多表达式;这里的幂法则假设指数是常数。
拆解过程也说明,运算的抽象边界可以灵活选择。tanh(x) = (exp(2x) − 1)/(exp(2x) + 1) 既可以作为一个运算,也可以拆成更小的计算图。课程实现了两种方式,并检查输出和叶节点梯度一致。为此还加入指数运算和常数幂求导规则 d(x^k)/dx = k*x^(k−1);除法改写为乘以倒数,减法改写为加上相反数。
分层组合后,引擎就能构成小型网络
查看原文 104:08Neuron 持有权重和偏置;Layer 组合多个神经元;多层感知机(MLP)把一层的输出交给下一层。例子中,三个输入经过两个各有四个神经元的隐藏层,再到一个输出神经元,总共包含 41 个可训练权重和偏置。输入作为固定数据,训练改变的是这些参数。
前向构造很直接:每个神经元计算 tanh(sum(w_i*x_i) + b);同一层的神经元分别接收同一组输入,MLP 再把每层输出传给下一层。收集参数时,依次汇总各层各神经元的权重和偏置。
笔记本中的四组输入和目标分别是:
[2.0, 3.0, -1.0] -> 1.0
[3.0, -1.0, 0.5] -> -1.0
[0.5, 1.0, 1.0] -> -1.0
[1.0, 1.0, -1.0] -> 1.0如果四个预测依次为 p1 至 p4,画面中的损失就是 (p1−1)² + (p2+1)² + (p3+1)² + (p4−1)²。损失函数把预测与目标的差平方,再合成一个数。讲者称它为均方误差,但演示代码实际把平方项相加。损失越低,代表预测越接近这些训练样本的目标。四次前向计算共享参数,反向传播时各次计算的贡献会累加到这些参数上。
训练交替计算新梯度并更新参数
查看原文 121:47训练循环依次计算预测和损失、清零参数梯度、对损失执行反向传播,并更新每个参数:p.data += −learning_rate * p.grad,然后重复。学习率决定步长:太小会使进展缓慢,太大则可能走出局部梯度有效的范围,导致不稳定。演示中的大步长一度使训练失控,后来恰好落到低损失处;这不能证明该步长可靠。这些反复手动更新也保留了旧参数梯度,Karpathy 后来才发现这个错误,因此不能把当时的表现单独归因于学习率。
重新初始化网络后的一次手动更新中,一个损失梯度为负的权重从约 0.854 增至 0.857;更新所有参数并重新前向计算后,损失从约 4.84 降到 4.36。这是当次随机初始化的观察值,并非其他运行必然得到的数值。
用简洁的伪代码表示,顺序如下:
predictions = network(inputs)
loss = sum((prediction - target)^2)
for parameter in parameters: parameter.grad = 0
loss.backward()
for parameter in parameters: parameter.data -= learning_rate * parameter.gradKarpathy 在自己的循环中发现了第二种不同的累积问题。一次反向传播中用 += 合并不同路径是必要的;但如果下次反向传播之前没有清零参数梯度,旧迭代的梯度也会加进来。最初的循环虽然有这个错误,在简单的四样本问题上看似仍能工作。重新初始化后,修正循环在每次反向传播前将所有参数的 grad 置零,损失下降较慢,但继续迭代也能降到较低水平;损失看似下降本身不能证明训练循环正确。
生产框架保留同样的思路,同时加入更多机制
查看原文 136:46课程使用要求计算梯度且精度匹配的单元素 PyTorch 张量,对照前向结果和梯度。张量是标量组成的数组:例如 2 行 3 列的张量包含六个数。为了与 Python 的双精度数值公平对照,演示把单元素张量转为双精度,并明确让叶节点参与梯度计算。调用 backward 后得到与前例相同的梯度;.item() 可以从单元素张量取出数值。2022 年的仓库使用 ReLU,而课堂实现使用 tanh;仓库测试比较 micrograd 与 PyTorch,录制后的代码可能已经改变。
更复杂的演示加入了批次(每次更新处理的样本子集)、最大间隔损失、L2 正则化和逐渐减小的学习率。Karpathy 提到正则化与过拟合、泛化有关,但没有在本讲详细解释这些概念。他还在 PyTorch 的 CPU 和 GPU 代码中找到相同的 tanh 反向公式,周围则有设备和数据类型相关处理。自定义运算同样需要提供前向计算和反向规则。更大的系统和语言模型使用不同的架构、损失与优化细节,但仍保留计算任务损失、传播梯度、更新参数这一核心过程。