跳至内容
5/30第 5 章,共 30 章

从零实现 Backpropagation:先造引擎,再搭网络

用纯 Python 写一个 120 行 autodiff 引擎,对照 PyTorch 精确到 16 位小数,并通过删除 zero_grad 理解它的作用。

本页内容

课程讲到第四章,中间还有一个空洞。

第 3 章给了我们 gradient descent:要改进一个参数,就找到 loss 对它的斜率,然后向下坡走一步。第 4 章给了我们一个值得下降的 loss。但在两章里,导数都是手算出来的——一个模型、一个参数、一行微积分,正好能放在一页纸上。

现在叠两层。第一层的输出送入第二层,所以第一层里的每个权重都会通过第二层里的每个神经元影响 loss。一个有两层隐藏层、每层一百个单元的网络大约有两万个参数,而每个参数都需要同一个 loss 对它的偏导。手算这件事不是繁琐;而是不可能,并且本课程后面每一种架构都会继续不可能。

出路不是更好的记号。出路是意识到:复合函数的导数可以由程序根据计算本身的结构机械地算出——而且如果方向选对,得到全部两万个导数的成本大约只相当于计算一次 loss。

这个机制叫 reverse-mode automatic differentiation。应用到神经网络上时,它叫 backpropagation。本章结束时,你会用大约 120 行无库 Python 写出一个这样的东西,用 PyTorch 检查它,并用它解决在第 1 章杀死 perceptron 的 XOR 问题。

在建造机器之前,必须先解决一个问题,因为如果答案是另一种,就没有什么可建造的了。

perceptron 在 XOR 上失败,是因为一条直线无法分开四个点。显然的修复办法是堆叠:让输入经过一个线性层,再经过另一个。这样有用吗?

没有,证明只要两行。线性层是 h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1。把它喂给另一个线性层,y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2,代入可得:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

这个复合仍然是 Wx+bW\mathbf{x} + \mathbf{b},其中 W=W2W1W = W_2W_1 且 b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2。一叠线性层等价于一个线性层。 十层也好,一千层也好:仍然是一条线,仍然做不了 XOR。

这件事值得亲眼看见,而不是只相信它:

linear_is_linear.pyPYTHON
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)

two_layers = W2 @ (W1 @ x + b1) + b2
one_layer  = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

不是近似相等。是逐 bit 完全相同,因为它只是同一组算术的重新排列。

所以深度本身买不到任何东西。真正买到东西的是在线性层之间放一个非线性函数——这就是 activation function 存在的全部原因。它们不是生物学上的装饰,也不是 normalisation 技巧。没有它,第二层只是摆设。

纸面上的 chain rule,以及共享节点

链接到此部分:纸面上的 chain rule,以及共享节点

现在进入数学部分,而它只是你已经知道的一条规则,用在一个稍微陌生的地方。

单变量 chain rule 说,如果 LL 依赖于 cc,而 cc 依赖于 xx,那么 dLdx=dLdc⋅dcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}。导数沿着链相乘。

这里真正重要的是:当一个变量流向不止一条下游路径时会发生什么。如果 xx 通过 aa 影响 LL,同时又通过 bb 影响它,那么这些贡献会相加:

dLdx=∂L∂a∂a∂x+∂L∂b∂b∂x\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

沿路径相乘,跨路径求和。这就是 backpropagation 的全部。本章后面的每个实现细节——包括代码里的 +=,以及让每个第一次写训练循环的人都踩坑的 zero_grad() 调用——都是第二个词的直接后果。

看一个由五个操作组成的具体电路,其中 x=0.5x = 0.5 且 y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh⁡(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

注意 xx 出现了三次:在 aa 里,在 bb 里,还直接出现在 LL 里。在纸上从右到左做 backward pass,从 dLdL=1\frac{dL}{dL} = 1 开始:

L=d+xL = d + x,所以 ∂L∂d=1\frac{\partial L}{\partial d} = 1,而直接路径贡献 ∂L∂x=1\frac{\partial L}{\partial x} = 1。加法会分发传入的 gradient,原样给到两个输入。

d=tanh⁡(c)d = \tanh(c) 且 c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33,所以 dLdc=1−tanh⁡2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444。

c=abc = ab,所以 dLda=dLdc⋅b=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 且 dLdb=dLdc⋅a=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711。乘法会交换:每个输入的 gradient 都由另一个输入的值缩放。

通过 aa:dLda⋅y=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501。通过 bb:dLdb⋅1=0.1711\frac{dL}{db} \cdot 1 = 0.1711。直接路径:11。

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

记住这个数字。几页之后,一个程序会在没有被告知这些细节的情况下生成它。

新文章直达你的收件箱

AI 新闻、指南和产品更新——有值得你花时间阅读的内容时,我们会发一封简短邮件。

让它可编程的洞见是:上面的每一步都是局部的。要把 gradient 推过乘法节点,你只需要传入的 gradient 和两个已存储的输入值——不需要知道电路其余部分。每个操作都知道如何对自己求导。

所以,做一个会记住自己从何而来的数字。

value.pyPYTHON
class Value:
    """A number that remembers where it came from."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)      
        self._op = _op

四个字段。data 是数值。grad 累积 ∂L∂self\frac{\partial L}{\partial \text{self}}。_prev 是生成当前节点所依赖的 Value 集合——也就是图的边。_backward 是每个操作安装的闭包:它知道如何把这个节点的 gradient 向后推一步,推给输入。

每个运算符都有同样的形状:计算输出,记录父节点,安装局部规则。

value.py (continued)PYTHON
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += out.grad       
            other.grad += out.grad      

        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")

        def _backward():
            self.grad += other.data * out.grad   
            other.grad += self.data * out.grad   

        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1 - t * t) * out.grad

        out._backward = _backward
        return out

    def relu(self):
        out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")

        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad

        out._backward = _backward
        return out

把四个 _backward 函数体当成一张表来读,纸面推导里的流动模式就在那里:

操作它如何处理 gradient
+分发——同一个 gradient 给每个输入
*交换——每个输入都由另一个输入的值缩放
relu路由——让它通过,或完全阻断
tanh衰减——按 1−t21 - t^2 缩放,最大为 1,通常更小

每一个都使用 +=,从不使用 =。这就是「跨路径求和」规则的编码。一个节点如果流向两个消费者,就会被调用两次,而两个贡献会自己加起来。

然后是驱动器,这是唯一具有全局知识的部分:

value.py (continued)PYTHON
    def backward(self):
        order, seen = [], set()

        def build(v):
            if v in seen:
                return
            seen.add(v)
            for child in v._prev:
                build(child)
            order.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(order):       
            v._backward()               

build 会生成图的拓扑顺序:每个节点都出现在它所有输入之后。反向遍历这个列表可以保证,当你调用某个节点的 _backward 时,它自己的 gradient 已经完整——它下游的每个消费者都已经贡献过了。顺序错了,你就会把一个半成品 gradient 往后推,结果答案错了,却没有任何错误信息。

check.pyPYTHON
x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)
TEXT
forward:  a=0.7000  b=1.9000  c=1.3300  d=0.8692  L=1.3692
backward: dL/dd=1.0000  dL/dc=0.2444  dL/da=0.4644  dL/db=0.1711
          dL/dx=1.8212   dL/dy=0.4033

1.8212。同一个数字,来自一个程序;这个程序只被告知了 + 的规则、* 的规则、tanh 的规则,而没有被告知这个电路的任何信息。

做两个独立检查,因为「它匹配我的推导」是一个很弱的测试,尤其当推导和实现都出自同一个人。

数值微分。 轻推输入,然后测量。中心差分 L(x+h)−L(x−h)2h\frac{L(x+h) - L(x-h)}{2h} 不用任何微积分就能估计导数:

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

对照 PyTorch,它有一个由专业人士编写的工业级 autodiff 引擎:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

在 2×10−162 \times 10^{-16} 处一致,这是 64 位浮点数的机器 epsilon:两个引擎执行的是相同的算术。把数值检查收好——它是调试新层 backward pass 的工具,也是错误 gradient 能被找到的原因。

同一个电路,换一组输入。设 x=2x = 2 且 y=−3y = -3,这会使 c=6c = 6:

TEXT
x=0.5, y=1.4:  dL/dc = 0.244400     three paths into x:  0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025     three paths into x:  0.0001 + -0.0001 + 1.0000 = 0.9999

穿过 tanh⁡\tanh 节点的 gradient 下降了 9,945 倍。它上游的一切——在真实网络里,就是它之前的每一层——几乎什么都收不到。电路中经过它的两条路径都沉默了;只有绕过 tanh⁡\tanh 的直接连接还携带信号。

这就是 vanishing gradient 问题,压缩在一个节点里。叠四十层 tanh⁡\tanh,再把四十个这样的因子相乘,早期层就会完全停止学习。顺便说,这也是一个关于 skip connection 的论据,而且你在这里能看到它的微缩版:绕过非线性的那条路径是唯一存活下来的路径。

zero_grad 实际做了什么,以及为什么这个 bug 会藏起来

链接到此部分:zero_grad 实际做了什么,以及为什么这个 bug 会藏起来

每个 _backward 都使用 +=。这是正确的——路径就是这样求和的。但它有一个会抓住所有人的后果:gradients 也会在多次调用 backward() 之间累积。引擎不知道你的第二次调用是新的训练步,而不是同一张图里的另一条路径。

所以训练循环必须清空它们:

train.pyPYTHON
for step in range(steps):
    ys = [model(x) for x, _ in DATA]
    loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))

    for p in model.parameters():   
        p.grad = 0.0

    loss.backward()
    for p in model.parameters():
        p.data -= lr * p.grad

这在 PyTorch 里就是 optimizer.zero_grad(),通常的建议是:忘记它会破坏训练。那我们删掉这两行,看看它到底有多坏。同样的 seeds、同样的一切,XOR 训练 200 步:

learning rateseed重置后不重置
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 4.000000, 3/4

在小 learning rate 下,有 bug 的版本每一行都赢。正确版本停滞时,它反而收敛。

这不是偶然,而且值得理解,因为它解释了为什么这个 bug 如此难抓。如果你从不清空 gradient,那么在第 kk 步,参数会被迄今为止计算出的所有 gradient 之和更新。在一个 loss 大致持续指向同一方向的情况下,这个和会稳定增长,效果就像 learning rate 自己变大。在 η=0.05\eta = 0.05,正确算法还在爬行时,这种失控的步长看起来完全像是一种修复。

再看底部三行。在 η=0.3\eta = 0.3,同一机制把模型炸开了——loss 8.0 是模型塌缩到常数 ±1\pm 1 时的分数,是四个最大错误答案会产生的 16 的一半——而正确版本现在可以干净地收敛。

所以诚实的说法不是「一定要调用 zero_grad,否则模型不会训练」。而是:没有它,你运行的就不再是 gradient descent。 你运行的是某种步长以无人选择的速率向上漂移的东西;它会看似有效,有时甚至比真算法更好,直到它突然失效——那时你会去怪 learning rate、初始化或数据。这就是机器学习里最糟糕 bug 的形状:它们不会崩溃,而是把算法变成另一种算法,并且偶尔得分更高。

引擎完成之后,神经网络几乎没几行代码。一个神经元是 dot product、bias 和 activation;一层是一组神经元;一个网络是一组层。

nn.pyPYTHON
class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]


class Layer:
    def __init__(self, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

这里面没有 backward pass。一行都没有。Value 类已经知道如何对这些类碰巧构建出的任何东西求导,这也是先写它的意义:autodiff 引擎不知道自己正在被用于神经网络。

现在回到第 1 章的问题。两个输入、两个隐藏单元、一个输出、九个参数:

TEXT
step   1: loss 4.156690
step  10: loss 4.005572
step  50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597

[0, 0] -> -0.9081  (target -1)  ok
[0, 1] -> +0.8934  (target +1)  ok
[1, 0] -> +0.8906  (target +1)  ok
[1, 1] -> -0.9207  (target -1)  ok

四个全对。没有任何 perceptron 能计算的函数——第 1 章用四个不等式证明它要求 bb 同时为正又为负——现在由自动找到的九个数字计算出来了。

令人满意的部分不是它能工作,而是你能看到它如何工作。因为只有两个隐藏单元,中间表示就是平面上的一个点,你可以直接打印出来。

训练到 loss 0.001241 后,每个输入经过隐藏层后落在这里,而输出神经元对它做了这些事:

输入隐藏层输出输出分数标签
(0,0)(0, 0)(+0.8206, −0.8474)(+0.8206,\ -0.8474)−2.4045-2.4045−1-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(−0.8401, −0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, −0.8550)(+0.8368,\ -0.8550)−2.4786-2.4786−1-1

看第一行和第四行。输入 (0,0)(0,0) 和 (1,1)(1,1) 是正方形里对角相对的两个角——在这个问题中,两点之间不可能更远——而隐藏层把它们映射到 (0.82,−0.85)(0.82, -0.85) 和 (0.84,−0.86)(0.84, -0.86)。几乎是同一个点。 这一层把平面折叠起来,让两个被拒绝的角落落到彼此上方;一旦它们在同一个地方,一条线就能把它们和另外两个点分开。

而输出神经元正是那条线。它学到的参数是 w=(−3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893)、b=+2.7697b = +2.7697,所以它的决策边界是

−3.1153 h1+3.0893 h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

这是一条直线——一个 perceptron,和第 1 章里的对象相同,没有改变。它当时不能解决 XOR,现在也不能。改变的是它不再看原始输入;它看的是第一层为它构造出的空间,在这个空间里,问题是线性可分的。

这就是学到的表示。值得精确一点,因为这个短语在本课程余下部分、乃至整个领域里都会被宽泛使用。它不是压缩,不是摘要,也不是某种神秘意义上的 embedding。它是一种坐标变换,是学出来而不是设计出来的;它唯一的工作,就是让下一层的工作变简单。

universal approximation theorem,以及它没有说什么

链接到此部分:universal approximation theorem,以及它没有说什么

这里有一个定理,而且它经常被糟糕地引用。

Cybenko 在 1989 年、Hornik 在 1991 年证明:一个带单隐藏层和合适 activation function 的 feedforward network,只要隐藏单元足够多,就可以在紧集上以任意精度逼近任意连续函数。34 这是一个真实且重要的结果:它说明架构本身不是限制。

现在读读它省略了什么。它没有说需要多少单元——这个界可能大到天文数字。它没有说权重可以被找到;它断言的是存在性,而从随机起点开始的 gradient descent 不是神谕。它也没有说明在你没见过的数据上的行为,而那正是第 6 章的后半部分。

「存在」和「可找到」之间的差距不是学术问题。这里是同一个 XOR 问题,每种设置 50 次随机初始化、1000 步,只改变隐藏层大小:

隐藏单元达到 4/4 的初始化次数
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

在最低可用架构下,四次运行里有一次永远到不了——它会停在某种无法下降出去的配置中,正是第 3 章在一维曲面上展示过的 local minimum。加一个单元,失败几乎消失;不是因为网络表达能力变强了(两个单元已经足够——38 次成功证明了这一点),而是因为额外维度给了 descent 更多逃逸方向。

然后八个单元反而比四个略差。在固定 learning rate 和步数预算下,更多容量并不单调更好。任何告诉你「网络卡住的修复办法总是做大网络」的人,都是从这张表中间那段外推。

这和第 1 章的收敛定理是同一课;在第 10 章讨论 scaling laws 时,它也会以那一章给出的形式再次出现:对 loss 的预测,不等于对你付费购买的能力的预测;两者之间的距离,就是工程存在的地方。

查看详情

可选:矩阵形式,以及为什么上面的代码没有使用它。

这里的一切都是一次写一个标量,这是看清机制最清楚的方式,也是执行起来最慢的方式。实践中,一层是矩阵乘法,而 y=Wx\mathbf{y} = W\mathbf{x} 的 backward pass 是

∂L∂W=∂L∂yx⊤,∂L∂x=W⊤∂L∂y\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

转置不是需要死记的技巧;它们是「跨路径求和」规则在路径由矩阵条目索引时的样子。一般对象是 Jacobian,也就是所有输出对所有输入的全部偏导组成的矩阵;reverse mode 精确地说是在不真正构造 Jacobian 的情况下计算 vector-Jacobian product——这很重要,因为一个有 4096 个输入和 4096 个输出的层,其矩阵有一千六百万个条目,永远不值得构建。

你不需要这些也能跟上后面的章节;标量版本能完成矩阵版本做的一切,只是更慢。它会在第 9 章变得必要,因为那时 shape 不再显而易见。

你现在有了一个能训练的网络。这个成就比它看起来要小,因为你拥有的网络在四个样本上训练,并且也在同样四个样本上衡量。

把同样的代码跑在真实数据集上,一组新问题就会出现,而它们都不是关于 gradients 的。loss 下降一阵后停住。或者它在训练数据上下降,在其他所有东西上上升。或者从第一步起就完全不动,最后发现原因是初始随机权重的范围。或者某个单元的输入在第三个 epoch 对每个样本都漂到了负数,从那以后它就一直死着,悄无声息地带走了模型容量的一块。

这些不是罕见故障;它们是一个刚写出来的网络的正常状态,而且没有一个会主动宣告自己。gradient 是正确的——你已经用 PyTorch 检查到十六位小数——但模型仍然学不会。

第 6 章讲的就是这些:初始化、normalisation、overfitting 和 regularisation,以及在改任何东西之前先诊断到底是哪一种正在发生的习惯。这就是能运行的网络与能工作的网络之间的区别。


本章的 Value 类直接源自 Andrej Karpathy 的 micrograd,他的视频 The spelled-out intro to neural networks and backpropagation: building micrograd 是你能花在这份材料上的最佳三小时,如果你想听另一个人用另一种方式讲解的话。他 2016 年的文章 Yes you should understand backprop 论证了为什么你应该亲手写一个,并且是 Stanford CS224n 的指定阅读。CS231n 关于 backpropagation 的笔记(cs231n.github.io/optimization-2)是上面表格中那些流动模式的经典讲解。若想把数学理解为图上的微积分,而不是神经网络民间传说,Deisenroth、Faisal 和 Ong 的 Mathematics for Machine Learning 第 5.6 章异常清晰;Baydin、Pearlmutter、Radul 和 Siskind 的综述 Automatic Differentiation in Machine Learning: a Survey(arXiv:1502.05767)则是整个领域的参考资料,也包括上文讨论的 forward/reverse 取舍。

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. 硕士论文,University of Helsinki(1970)。Reverse-mode accumulation,比它进入这个领域早十六年,而且动机完全不同。 ↩

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536(1986)。这篇论文让该方法为人所知,也提供了把隐藏单元解读为 learned representations 的来源;本章的 隐藏层做了什么 一节正是围绕这个解读做测量。 ↩

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314(1989)。 ↩

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257(1991)。推广了 Cybenko:结果依赖于 activation 是非多项式,而不是依赖于它是 sigmoidal。 ↩

本章视频

用通俗语言讲解本章概念的短视频。

查看全部视频

新文章直达你的收件箱

AI 新闻、指南和产品更新——有值得你花时间阅读的内容时,我们会发一封简短邮件。

更喜欢用消息接收?同样的内容,也在这里:WhatsApp 社群 (在新标签页打开)Telegram 频道 (在新标签页打开)

作者

David Vicente Campos

NeuraLIA Labs 创始人、MyRealFood 联合创始人

我是莱昂大学毕业的计算机工程师。我共同创立了 MyRealFood,并在那里作为 CTO 打造了一款数百万人用来吃得更健康的应用;我还创立了 NeuraLIA Labs,在这里我打造 AI 产品。我在本站写下一路走来所必须理解的内容,就像我希望当初有人向我讲解的那样。

了解作者更多信息

由 NeuraLIA Labs 发布。

课程目录

Abstract desk scene with blank papers, geometric forms, and a glowing network suggesting AI-assisted mathematics.
openai10 分钟阅读

OpenAI 的数学声明暴露了 AI 的验证瓶颈

有关 OpenAI 模型解决重大数学问题的报道指向一个更深层的问题:生成证明越来越便宜,而验证、归属和发表仍以人类速度推进。

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 分钟阅读

Jev AI 模型为决策而生,而非写作

TypeSafe AI 的 Jev 正受到关注,因为它把软件智能视为一个概率问题:选择正确分支,附上置信度,并避免在代码只需要决策时还花钱让 LLM 写文本。

让你的 AI 今天就开始工作

所有模型和工具都在一处。免费开始,无需信用卡。