从斜率到反向传播:构建一个微型 autograd 引擎
在我们关于神经网络如何学习的文章中,我们通过模型的训练循环来跟踪模型:进行预测、使用损失函数测量其误差、计算梯度并更新权重。为了调整权重,我们使用了梯度导数,它告诉我们损失相对于每个权重如何变化。梯度下降使用这些导数来选择更新的方向。
在本文中,我们想重点讨论如何计算这些梯度。如果一个模型的损失是 ,我们如何计算出增加一个权重会减少它?我们要微分哪个函数,它的导数规则从何而来?
为了回答这个问题,我们需要跟踪产生损失的计算。预测和损失一起形成模型参数的函数。我们将从 开始构建,通过单独的操作计算导数,并用 Python 实现一个小型自动微分引擎。然后我们将用它来训练模型。
我们的引擎使用反向模式自动微分或自动微分:它记录当前参数值的前向评估中的操作,然后向后应用导数规则。我们还将使用有限差异来实现两个不同的目的:可视化斜率的含义并独立检查引擎的答案。那些近点估计属于解释和验证;训练循环从自动差分中获取梯度。
你只需要基本的 Python 和代数。我们将在使用微积分时介绍它。该实现遵循 Andrej Karpathy 的微梯度讲座 中教授的标量计算方法,使用图边缘上的局部导数的显式表。它不需要机器学习库。
从函数及其导数开始
考虑:
在 处,输出为 。幂律给出了它的导数,写作 或 :
导数是该点切线的斜率。它告诉我们输出对输入的微小变化有多敏感:
导数就是我们想要的数量。有几种方法可以计算或近似该数量。
获得相同导数的三种方法
保持函数 和评估点 固定:
| 方法 | 它的用途 | 在处计算 | 结果 |
|---|---|---|---|
| 符号分化 | 应用于表达式的规则 | 导出,然后代入 | |
| 有限差异 | 附近输入处的输出 | 约 | |
| 自动微分 | 执行的操作及其局部衍生规则 | 对于 x * x,添加捐款 |
符号微分产生一个导数表达式,我们可以稍后对其进行评估。 Autodiff 使用计算过程中记录的值来评估导数规则。对于这个平滑函数,两者都根据代码中的浮点运算计算所选点的导数。有限差分通过比较不同点的输出来估计它。
神经网络反向传播使用第三种方法。我们为基本运算提供导数规则,引擎通过链式规则将它们组合起来。它可以计算当前参数值下的梯度,而无需为整个网络生成一个大的符号表达式。
通过一次乘法进行自动微分
编写与程序相同的函数:
def f(x):
return x * x我们已经知道乘法的导数规则。若,则其局部导数为:
对于 x * x,两个操作数引用相同的输入。在 处,每个操作数是 ,因此每个局部导数也是 。 中的更改通过两个操作数路径影响结果。他们的贡献还包括:
选择,然后选择。依次跟踪每个操作数,可以看到累计梯度达到。
Scroll the graph sideways to follow the multiplication and addition.
Each grad is the derivative of the final loss with respect to that node. The trace uses the actual addition and multiplication edges from the scalar engine. Changing a starts a new trace.
小部件使用输出相对于自身的导数开始向后传递:。每条路径将传入梯度乘以其局部导数。在普通Python中,算术是:
x = 5.0
left, right = x, x
q = left * right # forward: 25.0
output_grad = 1.0 # dq/dq
x_grad = 0.0
x_grad += output_grad * right # left operand contributes 5.0
x_grad += output_grad * left # right operand contributes 5.0
print(x_grad) # 10.0该导数所需的每个值都来自 的计算。我们使用了乘法规则和保存的操作数。表中的评估属于有限差分法;上面的自动微分轨迹由单个评估点完成。
自动化来自于记录这些操作数路径并为我们应用它们的衍生规则。稍后,当我们编写 q = x * x 并调用 q.backward() 时,我们的 Value 类将进行相同的计算。
了解运算并了解形状
假设我们只有一张表:
| 观察到的输出 | |
|---|---|
| 1 | 1 |
| 2 | 4 |
| 3 | 9 |
| 10 | 100 |
这些有限观测值不能唯一地确定列出的点之间的函数或其在列出的点处的精确导数。多个函数可以与每一行一致并且具有不同的斜率。从可用的附近样本计算有限差分估计是一种选择。
通过程序 return x * x,我们获得了更多信息:我们知道生成输出的操作,因此我们可以应用它们的导数规则。神经网络同样具有已知的预测和损失计算。训练示例可能以表格形式出现,但模型和损失是可执行的数学函数。
了解这些操作并不会自动让我们了解整个曲线。考虑:
我们可以使用这些公式评估任何选定点的值和导数。仅仅通过阅读它们,你就能想象出每一个上升、下降和转折点吗?将 移动到下方,然后选择 。在完整绘图可见之前,可以使用局部值和斜率。
The value and derivative are computed from known rules at the selected point. Revealing the curve additionally samples the function across the displayed interval.
公式完全指定了函数。了解其整体几何形状需要额外的评估或分析。绘图为我们提供了选定时间间隔内的更多信息,而导数描述了某一点的局部敏感性。
对于神经网络来说,损失可能取决于数百万个权重和偏差。我们知道在选定的参数设置下计算 的程序。它的梯度给出了局部敏感性。这些计算并没有找到高维空间中其他地方的每个最小值。训练使用局部信息来选择更新,然后在更新的参数处计算新的梯度。
使用附近的点来可视化坡度
现在回到 ,了解有限差分的两种用途中的第一种:构建坡度直觉。我们已经通过 autodiff 计算了它的导数。这个实验说明了导数的几何意义。
比较 和 。他们的平均斜率是:
对于一般非零输入变化 :
当 接近零时,该比率接近导数 。在 处,有限差分估计接近 。
将 简化为 ,观察橙色割线逼近紫色切线。然后将 移至负值:负斜率意味着输入的小幅增加会降低输出。
这种邻近点计算是有限差分。稍后,我们将使用另一个有限差分来独立验证引擎。在训练实验中,梯度来自记录的操作及其局部导数规则。
对于 ,确切的输出变化是 。导数捕获一阶部分;它不能准确预测任意的有限变化。
预测为我们提供了一些可以评估的东西
现在假设模型接收输入 并且应该预测目标 。给它一个权重 和一个偏差 :
中的帽子标志着一个预测。在 和 处,预测为 ,比目标低 。
培训应该尽量减少什么?即使我们需要它增加,最小化预测本身也会导致它下降。我们需要一个函数来衡量预测与目标之间的差异。
选择平方误差:
目前损失为。零意味着预测与本示例的目标匹配。
将模型的计算代入损失:
这就是我们需要的连接。损失现在是我们可以更改的参数的函数。对于固定训练示例:
数字 本身并不能告诉我们要向哪个方向移动。对于,和都会产生损失,但它们需要朝相反的方向移动。我们区分产生损失的函数,并在当前参数下进行评估。
相同的微积分,以权重作为变量
保持训练输入 、目标 和偏差 固定。损失变成一个变量的普通函数:
我们可以手动微分这个小表达式并替换当前权重,就像我们对 所做的那样:
| 步骤 | 熟悉的功能 | 固定训练数据下的模型损失 |
|---|---|---|
| 编写函数 | ||
| 差异化 | ||
| 插入当前值 | ||
| 获得导数 |
命名变量的字母已从 更改为 。训练输入和目标是内部的固定数字;我们要问的是损失对权重有何反应。当我们使用整个批次时,同样的区别也适用:批次的输入和目标保持固定,而我们对参数进行区分。
该模型足够小,可以扩展到多项式。对于较大的网络,autodiff 会记录当前参数设置下的前向操作,并向后评估其局部导数规则。它计算参数导数的数值,而无需首先构建网络梯度的扩展符号公式。
我们采用哪种导数?
对于重量,我们想要:
可以理解为:如果我们改变重量,保持其他独立量固定,损失变化的速度有多快? 使用符号 是因为 取决于几个变量。对于此计算,输入、目标和偏差保持固定。
模型和损失形成一个组合。首先计算预测,然后减去目标,然后对误差求平方:
每个操作都有一个局部导数:
| 操作 | 有什么变化? | 本地衍生品 |
|---|---|---|
| 更改,保持固定 | ||
| 更改,保持固定 | ||
| 改 |
链式法则沿着路径将这些敏感性相乘:
在我们的起始值下, 和 :
因此,重量增加 应该会使损失改变大约 。确切的新损失是 ,是 的变化。随着步长变小,协议会改善。
对于偏差,最后一个因子发生变化:。所以:
所有可训练参数的导数一起形成梯度。这里该向量是 ,顺序为 。
损失的选择改变了学习信号
该模型告诉我们它的预测如何响应权重。损失告诉我们预测的变化对目标有多重要。
对于相同的预测,比较两个损失:
| 损失 | 关于预测的导数 | 关于重量的导数 |
|---|---|---|
| 平方误差: | ||
| 绝对误差: | ,远离零 | ,远离零 |
在我们的起点,平方误差的预测梯度为 ,绝对误差的预测梯度为 。乘以相同的模型导数 ,得到权重梯度 和 。
下面在 和 之间切换。移动 ,同时观察预测梯度和权重梯度。输入保持固定,因此只有第一个因素发生变化。
x = 2, y = 5, and b = 0 stay fixed. This experiment updates only w.
当误差幅度较大时,平方误差会产生较大的预测梯度。每当误差非零时,绝对误差相对于预测具有恒定的梯度大小。其导数为零时未定义;小部件在那里选择零的次梯度。
这两种损失都没有提供微分规则。这些规则来自微积分。 损失的选择决定了这些规则作用的函数,因此决定了通过模型传播的初始信号。
衍生品成为更新
梯度下降减去缩放梯度:
学习率设置步长。对于平方误差, 和 :
将偏差保持为零,新的预测为 ,新的损失为 。在损失实验中使用 和 来重现此计算。
这里我们只更新了重量。如果我们还从相同的梯度更新偏差,则它会变为 ,从而为该示例提供预测 和损失为零。这些是不同的更新,因此会产生不同的结果。
负导数表明增加参数;正导数表明减少它。这是当地的说法。大的步长可能会超过最小值并增加损失。反向传播计算梯度;优化器及其学习率决定了如何处理它。
反向传播是通过图的链式法则
我们可以将计算记录为图表。每个盒子都保存一个数字,每个操作将其结果与用于生成它的值连接起来。
从最右边的框开始。损失相对于其自身的导数为一:
然后向后工作。一个盒子接收一个梯度,告诉我们最终的损失如何响应该盒子的值。要将贡献传递到其输入之一,请乘以运算的局部导数:
选择 ,然后选择 ,直到到达 和 。显示的每个梯度都是最终损失的导数,即使该框代表中间结果。
Scroll the graph sideways to follow all the operations.
| Value | Forward value | Gradient |
|---|---|---|
| w | 1 | — |
| x | 2 | — |
| p = w × x | 2 | — |
| b | 0 | — |
| ŷ = p + b | 2 | — |
| y (target) | 5 | — |
| e = ŷ − y | -3 | — |
| L = e² | 9 | — |
该图还给出了关于输入和目标的导数。这并不能让他们接受训练。我们选择优化器更新哪些值:此处仅更新 和 。在更深的网络中,一层的“输入”可能是前一层的输出,因此它的梯度成为返回到早期参数的路线。
当一个值有多个导致损失的路径时,就会出现一个重要的区别。来自这些路径的贡献添加。例如:
在 中,该导数为 。向后传递必须保留所有三个贡献,包括乘法中 的两次使用。这就是为什么我们的引擎在传播梯度时会使用 +=。
建立一个记住其计算结果的数字
普通的 Python 浮点数存储一个值。我们的 Value 还将存储:
grad:所选最终输出相对于该值的导数。edges:(parent, local_derivative)对的序列,记录如何计算该值。
加法和乘法成为图形构建运算。对于 ,两个边导数都是 。对于,它们是和。
这是引擎的第一部分,包括整数幂和我们稍后将使用的激活:
import math
class Value:
def __init__(self, data, edges=()):
self.data = float(data)
self.grad = 0.0
self.edges = tuple(edges) # (parent, local derivative) pairs
@staticmethod
def wrap(value):
return value if isinstance(value, Value) else Value(value)
def __add__(self, other):
other = Value.wrap(other)
return Value(self.data + other.data, ((self, 1.0), (other, 1.0)))
def __mul__(self, other):
other = Value.wrap(other)
return Value(self.data * other.data,
((self, other.data), (other, self.data)))
def __pow__(self, exponent):
if not isinstance(exponent, int) or exponent < 1:
raise ValueError("This engine supports positive integer powers only")
return Value(self.data ** exponent,
((self, exponent * self.data ** (exponent - 1)),))
def tanh(self):
output = math.tanh(self.data)
return Value(output, ((self, 1.0 - output * output),))当我们将两个 Value 对象相乘时,__mul__ 立即计算输出并保存两条边及其局部导数。这些导数值是使用操作数的当前数据来计算的。还没有学到什么;我们正在记录前向计算。
我们直接实现幂,因此 (prediction - target) ** 2 记录了平方运算。该引擎有意仅支持正整数幂。这涵盖了我们的示例,但没有引入负数的分数幂等领域。
双曲正切 tanh 是具有导数 的平滑激活函数。它让网络表达非线性关系。
Python 的便利运算符使引擎使用起来很愉快。反向运算符允许使用 2 * w 和 sum(...) 等表达式:
def __neg__(self):
return self * -1.0
def __sub__(self, other):
return self + -Value.wrap(other)
def __rsub__(self, other):
return Value.wrap(other) + -self
__radd__ = __add__
__rmul__ = __mul__这些方法都属于Value内部。 complete Python file 包括引擎、模型和可运行的示例。也可以download tinygrad.py。
按正确的顺序访问图表
为了计算梯度,我们需要在该结果的所有下游贡献到达之后,在其父结果之前处理每个结果。
首先,深度优先遍历在附加节点本身之前访问节点的父节点。这创建了拓扑排序。 seen 集确保共享节点仅附加一次。颠倒顺序将最终输出放在第一位。
将此方法添加到 Value 中:
def backward(self):
order, seen = [], set()
def visit(node):
if node in seen:
return
seen.add(node)
for parent, _ in node.edges:
visit(parent)
order.append(node)
visit(self)
for node in order:
node.grad = 0.0
self.grad = 1.0
for node in reversed(order):
for parent, local_derivative in node.edges:
parent.grad += node.grad * local_derivative共有三个单独的步骤:清除可达图中的旧梯度,将最终输出的梯度播种为 1,然后向后遍历以累积贡献。
seen 设置重复数据删除节点,而 edges 保留每个操作数出现。在a * a中,两条边指向同一个父节点。两者都执行其 += 更新。用一组父项替换 edges 而不保留相应的贡献将失去这种区别。
我们的 backward() 会在每次调用时故意清除可达梯度。这使得对同一未更改图的重复调用可重现。它与通过向后调用累积叶梯度并需要单独的归零步骤的 API 不同。在一次向后传递中,所有图路径的贡献仍然会累积。
此实现在前向传递期间保存数值局部导数。向后更改 after 参数数据,然后为下一次迭代构建新的图表。更改数据后在旧图表上向后重新运行将使用陈旧的本地导数。
递归图遍历使这个教学引擎变得简短。长计算链,例如对数千个示例进行求和的损失,可能会超出 Python 的递归限制;迭代遍历避免了这个限制。
根据算术检查引擎
将完整文件另存为 tinygrad.py。在同一目录中的另一个 Python 文件中:
from tinygrad import Value
w, b = Value(1), Value(0)
prediction = w * 2 + b
loss = (prediction - 5) ** 2
loss.backward()
print(loss.data) # 9.0
print(w.grad) # -12.0
print(b.grad) # -6.0
a = Value(3)
q = a * a + a
q.backward()
print(a.grad) # 7.0: all three paths contribute该引擎从未收到过该模型重量梯度的特殊公式。它记录了乘法、加法、减法和平方,然后将它们的局部导数组合起来。
使用有限差分来检查结果
这是一开始就宣布的第二个目的:独立验证。引擎已经从 处记录的操作中获得了 w.grad。要检查其实现,请分别评估该权重两侧的损失:
def numeric_loss(weight):
return (weight * 2 - 5) ** 2
h = 1e-5
finite_difference = (numeric_loss(1 + h) - numeric_loss(1 - h)) / (2 * h)
assert abs(w.grad - finite_difference) < 1e-6这个中心差异是数值检查,而不是引擎的微分算法。以这种方式检查每个参数需要对每个参数进行两次评估。反向模式自动微分而是重用单个记录的图来计算标量输出的所有参数梯度,其工作量与图的大小成正比。
的极小值最终会引入浮点消除,因此对于此数值检查而言,较小并不总是更好。下面的训练循环通过在当前图上调用 backward() 来获取其梯度。
从几个例子中学习
一个训练示例无法确定唯一的行:许多 对满足 。添加更多示例:
| 输入 | 目标 |
|---|---|
| 0 | 1 |
| 1 | 3 |
| 2 | 5 |
| 3 | 7 |
这些点位于 上,因此我们可以检查训练是否找到 和 。
将损失定义为四个平方误差的平均值:
共享权重出现在所有四个预测中。在反向过程中,每条路径贡献相同的 w.grad。乘以 可以缩放所有贡献以匹配平均损失。求和而不是平均会将两个梯度乘以四。
使用 检查一项更新,然后使用 。尝试从重置状态开始学习速率 ,看看损失是否增长,即使梯度计算正确。
Each click builds a fresh graph, averages four squared errors, runs backward(), and updates both w and b. The line plot rescales if the parameters grow; the loss plot uses log₁₀, with a floor of 10⁻¹².
该小部件使用上面标量引擎的 JavaScript 对应部分。它的梯度是通过每一步的新图来计算的。以下是 Python 中的相同训练循环:
def train_line(steps=1000, learning_rate=0.05):
data = [(0, 1), (1, 3), (2, 5), (3, 7)]
w, b = Value(-1), Value(0)
for _ in range(steps):
loss = sum((w * x + b - y) ** 2 for x, y in data) * (1 / len(data))
loss.backward() # also clears this graph's old gradients
for parameter in (w, b):
parameter.data -= learning_rate * parameter.grad
final_loss = sum((w.data * x + b.data - y) ** 2 for x, y in data) / len(data)
return w.data, b.data, final_loss在任一参数更改之前,两个梯度都来自同一前向传递。下一次迭代将根据更新的参数重新计算预测、损失和导数。返回的损失是在上次更新后评估的,因此它描述了返回的权重。
对神经网络使用相同的引擎
到目前为止,该模型是仿射的。小型神经网络不需要新的微分机制:它的前向传递是我们已经支持的操作的更大组成。
神经元将输入乘以权重,添加偏差,并可选择应用 tanh。 MLP 链接神经元层。对于这个回归示例,我们保持最后一层是线性的:
import random
class Neuron:
def __init__(self, n_inputs, rng, nonlinear=True):
self.weights = [Value(rng.uniform(-1, 1)) for _ in range(n_inputs)]
self.bias = Value(0)
self.nonlinear = nonlinear
def __call__(self, inputs):
total = sum((w * x for w, x in zip(self.weights, inputs)), self.bias)
return total.tanh() if self.nonlinear else total
def parameters(self):
return self.weights + [self.bias]
class MLP:
def __init__(self, sizes, seed=42):
rng = random.Random(seed)
self.layers = [
[Neuron(n_in, rng, nonlinear=i < len(sizes) - 2)
for _ in range(n_out)]
for i, (n_in, n_out) in enumerate(zip(sizes, sizes[1:]))
]
def __call__(self, inputs):
for layer in self.layers:
inputs = [neuron(inputs) for neuron in layer]
return inputs
def parameters(self):
return [p for layer in self.layers for neuron in layer
for p in neuron.parameters()]MLP([2, 4, 1]) 创建两个输入、四个具有 tanh 的隐藏神经元和一个线性输出。其参数为 8 个隐藏权重、4 个隐藏偏差、4 个输出权重和 1 个输出偏差:17 个可训练标量。
在 XOR 上对其进行训练,将相等的输入编码为 ,将不同的输入编码为 。没有两个输入的仿射函数能够完全拟合所有四个目标;隐藏的非线性激活允许模型拟合它们。
def train_xor(steps=1000, learning_rate=0.1):
# Same inputs twice equal -1; different inputs equal +1.
examples = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
model = MLP([2, 4, 1], seed=42)
parameters = model.parameters()
for _ in range(steps):
loss = sum((model(x)[0] - y) ** 2 for x, y in examples) * 0.25
loss.backward()
for parameter in parameters:
parameter.data -= learning_rate * parameter.grad
predictions = [model(x)[0].data for x, _ in examples]
final_loss = sum((p - y) ** 2 for p, (_, y) in zip(predictions, examples)) / 4
return predictions, final_loss训练结构保持不变:构建预测和标量损失,调用 backward(),并更新参数。引擎看到操作和边缘。它不需要知道哪些操作属于神经元或层。
运行完整的文件:
python3 tinygrad.py使用提供的种子、1,000 个步骤和学习率 ,XOR 预测四舍五入到小数点后四位 [-1.0, 1.0, 1.0, -1.0]。这是一个具有固定数据和初始化的小型演示,而不是对任意网络或学习率的保证。
损失造成了什么
我们从 的衍生物开始,它不需要模型或训练的概念。然后我们选择一个预测函数并附加一个损失。替换使损失成为参数的函数,这给我们提出了一个精确的问题:如果一个参数发生变化,这个目标会如何变化?
损失定义了目标及其对预测的敏感性。该模型提供了从参数到预测的路径。反向传播结合了沿这些路径的局部导数,在路径相交处添加贡献。梯度下降使用生成的参数梯度来选择更新。
对于通过可用导数规则的运算构建的模型,标量损失为我们的微分引擎提供了一个起始输出,以及优化器要减少的目标。
对于更广泛的神经网络处理,请参阅神经网络如何学习:反向传播和梯度下降。有关将向后函数附加到操作的补充实现,请参阅 Karpathy 的 micrograd 引擎 和他的 神经网络:零到英雄材料。