在一家玩具厂的流水线上,塑料粒 x 要经过注塑(h)、上色(g)、组装(f)三道工序,最终变成成品 y。老板很想知道:每多采购 1 公斤塑料粒,利润会涨多少?于是工厂请来一位“驻厂工程师”。这位工程师不碰任何机器,只做一件事:把《工序手册》改写一遍——在每道工序后面附上一页“成本回执单”(pullback),上面写清楚“这道工序的产出变化 1 单位时,它的投料会变化多少”。新版手册发下去,生产线照常正向跑一遍,产品下线;工程师再拿着成品利润,按手册从最后一道工序往回走,逐站把“影响系数”算出来——这些系数,就是数学里的梯度。Julia 的 Zygote 库,就是这位“改写工序手册的工程师”,它的技术叫“源到源变换”(source-to-source transformation)。这篇文章从自动微分家族的三种求导方式讲起,带你走进 Zygote 的前向模式、反向模式与 pullback 机制,最后学会用它求循环、递归、甚至整个神经网络的梯度。

岗位关联

学会 Zygote 自动微分,等于掌握“把代码改写成自带求导能力”的本事。梯度计算、科学计算参数反演、深度学习训练都将为你所用,这是进入可微编程世界的入门钥匙。

目录
  1. 可视化演示
  2. 自动微分基础
  3. 前向模式
  4. 反向模式
  5. 源到源变换
  6. Zygote核心
  7. 梯度计算实战
  8. 可微编程应用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Zygote源到源变换:计算图x→sin→*→+→f,前向(Fwd)求值+反向(Bwd)求梯度,Src→Src源变换生成梯度代码。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Zygote自动微分原理 计算图:x→sin→*→+→f,前向(Fwd)计算原函数值,反向(Bwd)通过链式法则传播梯度,源到源(Src→Src)变换生成梯度代码 三种模式:Fwd(前向AD)→Rev(反向AD)→Cust(自定义梯度),支持Flux集成、Loop循环和Rec递归求导 gradient(f, x) = Zygote.pullback(f, x)[2](1)[1], ∂f/∂x = chain rule

自动微分基础

老板一开始试了两招笨办法。第一招叫“微调试产”(数值微分):偷偷把投料量改动一点点,看利润差多少,拿差值当导数。可料秤有精度、产线有波动,步长取大了是截断误差,取小了读数被噪声吞掉,怎么调都不踏实。第二招叫“数学家全程推公式”(符号微分):请一位数学家盯着整条产线,把“利润对投料量”的完整公式手推出来。工序一多,公式就像雪球一样越滚越大,最后膨胀成一大摞根本没法维护的纸。

自动微分走的是第三条路:它不猜,也不推整条公式,只信一条铁律——链式法则。每个基本运算(加、减、乘、除、指数、三角)的导数都是查表可知的小规则,把复合过程拆成一段一段,段与段之间用链式法则乘起来,既精确又不膨胀。Zygote 正是踩着这条铁律工作:它把 y=f(x) 拆成中间变量链 u₀=x → u₁ → … → uₙ=y,每步都是基本运算,每段的局部导数都简单明确,最后连乘,就是端到端的导数。

这就像给流水线每一段都装一块“局部敏感度表”:注塑段写着“对投料敏感度 0.8”,上色段写着“对注塑件敏感度 0.5”……整条线不需要新增任何测量设备,只需把相邻敏感度相乘,就能从原料口一路算到成品口。自动微分要做的,就是把这套乘法自动化、精确化。

dydx=dydun⋅dundun−1⋯du1dx\frac{\mathrm{d}y}{\mathrm{d}x} = \frac{\mathrm{d}y}{\mathrm{d}u_n}\cdot\frac{\mathrm{d}u_n}{\mathrm{d}u_{n-1}}\cdots\frac{\mathrm{d}u_1}{\mathrm{d}x}
复合函数求导不是把整条公式展开,而是把“相邻两步的敏感度”逐段相乘,中间变量像分数约分一样成对消去。
  1. 拆解复合链条把从 x 到 y 的长路切成 n 段,u₀ 是原材料,uₙ 是成品,每段都是一个基本运算。
    u0=x,ui+1=hi(ui),un=yu_0 = x,\quad u_{i+1} = h_i(u_i),\quad u_n = y
  2. 每段局部导数相邻两步的关系由基本运算给出,导数直接查表得到,简单可靠。
    dui+1dui=hi′(ui)\frac{\mathrm{d}u_{i+1}}{\mathrm{d}u_i} = h_i'(u_i)
  3. 相邻相乘约分把 n 个局部导数连乘,中间的 u₁ 到 u_{n-1} 成对抵消,剩下端到端的总导数。
    ∏i=0n−1dui+1dui=dydx\prod_{i=0}^{n-1}\frac{\mathrm{d}u_{i+1}}{\mathrm{d}u_i} = \frac{\mathrm{d}y}{\mathrm{d}x}

前向模式

前向模式像是“挂标签上路”:给投料口挂一张“敏感性标签”,塑料粒走到哪一道工序,标签就被那一道工序的局部规则更新一次。当材料最终变成成品下线时,标签上的数字已经是 dy/dx。整个过程是顺着生产方向的,所以叫“前向”。

数学上,这张标签是一个“对偶数” a+bε,其中 ε 是一个满足 ε²=0 的无穷小记号。实部 a 走正常数值,虚部 b 走导数信息,两者在一次正向遍历中同步更新。每经过一个基本运算,实部按原规则计算,虚部按该运算的导数规则更新——一路顺流而下,值算完了,导数也到了。

如果输入有 n 个变量、输出有 m 个,前向模式每跑一趟只能算一个“方向”(一个输入)的导数,所以总的成本正比于输入维数 n。这也决定了它的适用场景:输入少、输出多时非常划算。

前向模式适合“输入少、输出多”的流水线——比如只有一种原材料,却要同时监控十几个成品指标,挂一次标签全部搞定。但如果原材料有十万种(像神经网络里的参数),挂十万次标签的代价就太大了。这时,就该反向模式登场了。

y˙=J(x)⋅x˙,Jij=∂yi∂xj\dot{\boldsymbol{y}} = \boldsymbol{J}(\boldsymbol{x})\cdot\dot{\boldsymbol{x}}, \qquad \boldsymbol{J}_{ij} = \frac{\partial y_i}{\partial x_j}
雅可比矩阵 J 像一张“敏感性表格”,列出每个输出对每个输入的敏感度;前向模式就是让 J 与方向向量做一次乘积。
  1. 设定种子方向想求哪个输入的导数,就给对应分量置 1,其余分量置 0,这张“标签”从起点出发。
    x˙=(1,0,…,0)T\dot{\boldsymbol{x}} = (1,0,\ldots,0)^{\mathsf{T}}
  2. 沿途更新导数每经过一个基本运算,标签上的导数按该运算的局部规则刷新一次。
    u˙=f′(x)⋅x˙\dot{u} = f'(x)\cdot\dot{x}
  3. 雅可比矩阵相乘整个遍历过程等价于雅可比矩阵 J 与方向向量做矩阵乘法,一趟正向扫描完成全部所需信息。
    y˙=J(x)⋅x˙\dot{\boldsymbol{y}} = \boldsymbol{J}(\boldsymbol{x})\cdot\dot{\boldsymbol{x}}

反向模式

深度学习的场景恰好相反:原材料成千上万(模型参数),成品却只有一个(损失值)。老板真正想要的是“一次下线,把整条流水线上所有原材料的贡献系数同时算清”。反向模式正是为此而生:从成品出发,逆着流水线走回原料库,沿途把每个工位的敏感度一一记下。

反向模式的核心概念叫“伴随值”,记作 \bar{u} = ∂L/∂u,意思是“最终损失 L 对某个中间量 u 的敏感度”。信号从输出端出发,每经过一条边,就乘上这条边的局部导数,不断往回“拉”。当多条支路汇合到同一个节点时,各路贡献的敏感度要做加法——这就是梯度累积。

反向模式通常先把前向过程“记账”(保存中间值),再倒序回放。因为输出只有 1 个、输入有 n 个,它只需要一趟反向遍历,就能拿到全部 n 个输入的梯度。这正是深度学习反向传播的数学原理。

反向模式就像“全厂追溯系统”:产品一落地,终端扫码,系统沿着产线逐站回填每个工位的贡献值。一次扫码,整条线的敏感度全部到账。前向模式挂一次标签只算一个方向,反向模式扫一次码算全部方向——难怪深度学习离不开它。

xˉ=∑k=1myˉk⋅∂yk∂x,yˉk=∂L∂yk\bar{x} = \sum_{k=1}^{m}\bar{y}_{k}\cdot\frac{\partial y_{k}}{\partial x}, \qquad \bar{y}_k = \frac{\partial L}{\partial y_k}
反向模式先把“损失对输出的敏感度”当作种子,再沿计算链逐层乘上局部导数,最后把所有汇入同一输入的敏感度加起来。
  1. 输出端种子从损失 L 对输出 y_k 的导数出发,这是反向遍历的起点信号。
    yˉk=∂L∂yk\bar{y}_k = \frac{\partial L}{\partial y_k}
  2. 沿边回传敏感度每条边把下游敏感度乘上局部导数,送回上游节点,一步一乘地逆流而上。
    uˉi=uˉj⋅∂uj∂ui\bar{u}_i = \bar{u}_j\cdot\frac{\partial u_j}{\partial u_i}
  3. 汇合处求和多条路径汇入同一个输入时,把各路贡献的敏感度累加,得到该输入的总梯度。
    xˉ=∑kyˉk⋅∂yk∂x\bar{x} = \sum_{k}\bar{y}_k\cdot\frac{\partial y_k}{\partial x}

源到源变换

前面两种模式都需要一个“账房先生”在运行时逐笔记账。Zygote 却另辟蹊径:它干脆把《工序手册》重写一遍。它不对原材料和机器动手,只改手册——在每道工序后面附上“成本回执单”的写法。这就是源到源变换:代码本身被改写成“会自己算梯度”的新代码,而不是在外面搭一套追踪账本。

具体怎么做?Julia 编译器会把源码降低成一种中间表示(IR,可以理解为编译器的“内部草稿语言”)。Zygote 在这个 IR 层遍历函数体,把每一个基本运算替换成“前向计算 + 反向规则”的配对版本,生成一份新的 IR 再交还给编译器继续优化。因为是编译期改写而不是运行时追踪,Zygote 几乎没有运行时开销,产出的仍然是可读、可调试的 Julia 代码。

源到源变换的另一大好处是能处理控制流:if、for、while、递归这些“结构性的工序安排”,都可以被展开或配对,逐层生成对应的反向操作。这是它相比“运行时账本派”自动微分工具的核心优势。

改写后的手册照常指挥生产,只是每道工序多了一栏“敏感度回填说明”。老板拿到的新手册,就是“可微编程”的起点——因为他不再需要另请账房先生,代码自己就带着算梯度的说明书。

T(y=f(x))  ⟹  (y,  xˉ)=(f(x),  yˉ⋅f′(x))\mathcal{T}\big(y = f(x)\big) \;\Longrightarrow\; \big(y,\; \bar{x}\big) = \big(f(x),\; \bar{y}\cdot f'(x)\big)
变换算子 T 把“只算值的函数”改写成“同时算值和反向敏感度的增广函数”,这是 Zygote 对每段 Julia 代码做的事。
  1. 变换算子 TZygote 在 IR 层遍历函数体,对每个赋值语句生成配套的反向语句。
    T: IR(f) ⟼ IR(faug)\mathcal{T}:\ \mathrm{IR}(f)\ \longmapsto\ \mathrm{IR}\big(f^{\mathrm{aug}}\big)
  2. 保留前向原样y = f(x) 的计算逻辑一字节不变,产品照常按原配方生产。
    y=f(x)y = f(x)
  3. 追加反向规则新代码额外返回输入侧敏感度,由“局部导数乘以下游敏感度”给出。
    xˉ=yˉ⋅f′(x)\bar{x} = \bar{y}\cdot f'(x)

Zygote核心

pullback 是这本新手册里最核心的一页“回执单”:上面写着,当你知道“成品对我的产出有多敏感”时,如何反算出“我对我的投料有多敏感”。它的口头禅是八个字:把敏感度拉回上游。在 Zygote 里,每个基本函数 f 都配有一个 pullback,签名等价于“输入下游敏感度 ȳ,输出上游敏感度 x̄”。

复合函数由编译器自动嵌套这些 pullback:前向算到哪一层,反向就从那一层接住,像一串回执单首尾相连。对循环,循环体会被展开成串行基本运算,逐层记录各自的回执单;对递归,调用链展开后同样按顺序逐层回传。这样,Julia 的任意结构化控制流都能被求导。

除了默认规则,Zygote 还提供了自定义梯度入口 @adjoint。当某个算子的默认反向规则数值不稳定(比如朴素实现的 softmax),或者你手里有更优的解析导数时,可以用 @adjoint 为它手写一份“专家回执单”,Zygote 会自动优先采用。

至此,流水线上每一道工序——哪怕是老师傅手工打磨的“隐函数”——都有了专属回执单。Zygote 不再只是一个求导工具,而是让任意 Julia 代码变得“可微”的基础设施。你写程序,它负责让程序知道自己的导数。

xˉ=pullbackf(yˉ),y=f(x)\bar{x} = \mathrm{pullback}_{f}(\bar{y}), \qquad y = f(x)
pullback 把“输出对下游的敏感度”映射为“输入对上游的敏感度”,每一个基本运算的导数都封装在这张回执单里。
  1. 拉回语义pullback 的输入是下游敏感度 ȳ,输出是上游敏感度 x̄,方向与生产方向正好相反。
    xˉ=yˉ⋅f′(x)\bar{x} = \bar{y}\cdot f'(x)
  2. 依赖前向值许多运算的导数要用到中间结果 x、y,所以前向过程的值必须保存下来供反向取用。
    store: x, y\mathrm{store}:\ x,\ y
  3. 自定义梯度入口通过 @adjoint,用户可以把默认 pullback 替换为自己提供的反向函数 g。
    xˉ=g(yˉ),g=user-defined via @adjoint\bar{x} = g(\bar{y}), \qquad g = \text{user-defined via @adjoint}

梯度计算实战

有了每道工序的敏感度表,老板就可以做“精准改善”:哪道工序对利润影响最大,就优先调整哪道工序的参数;调完再跑一遍,再算敏感度,再调……深度学习训练就是这个循环的自动化版本:前向跑一遍算损失,反向算梯度,然后沿梯度的反方向小步修正参数,反复迭代直到损失收敛。

梯度指向损失上升最快的方向,所以参数要往反方向走,更新公式写作 θ ← θ − η∇L。这里的 ∇L 不需要手推,一行 Zygote.gradient 调用即可得到损失对全部参数的梯度向量。Flux 是 Julia 的深度学习框架,它把 Zygote 当作求导引擎:定义模型 → 组合损失 → 调用 gradient → 交给优化器更新参数,整个训练循环就像一条“参数质量控制流水线”。

Pytorch 用户熟悉的“损失.backward()”与“优化器.step()”,在 Flux 中对应的是一句 Flux.train!。底层把前向、反向、更新串成一体,而其中最关键的梯度计算,全部由 Zygote 的源到源变换自动完成。

训练一个神经网络,与老板反复调整工序参数没有本质区别——只是把“敏感度”改叫“梯度”,把“调整幅度”改叫“学习率”。Zygote 负责把敏感的数学部分藏在编译器里,你只需要专注写模型本身。

θ←θ−η ∇θL(θ)\theta \leftarrow \theta - \eta\,\nabla_{\theta}\mathcal{L}(\theta)
参数更新的规矩只有一句:沿着损失下降最快的方向,迈一小步。步长由学习率 η 控制,方向由梯度 ∇L 给出。
  1. 损失函数 L衡量模型输出与真实目标的差距,训练目标就是让这个差距越来越小。
    L(θ)=1N∑iℓ(fθ(xi), yi)\mathcal{L}(\theta) = \frac{1}{N}\sum_{i}\ell\big(f_{\theta}(x_i),\, y_i\big)
  2. 梯度向量Zygote 一次反向调用,返回损失对全部参数的偏导数组。
    ∇θL=(∂L∂θ1, …, ∂L∂θm)\nabla_{\theta}\mathcal{L} = \left(\frac{\partial \mathcal{L}}{\partial\theta_1},\ \ldots,\ \frac{\partial \mathcal{L}}{\partial\theta_m}\right)
  3. 学习率 η控制每次沿反方向迈出的步子大小,太大容易震荡,太小收敛太慢。
    η∈(0,1)\eta \in (0,1)
  4. 迭代更新重复“前向算损失→反向求梯度→反方向小步更新”,直到损失不再下降。
    θ(t+1)=θ(t)−η ∇θL(θ(t))\theta^{(t+1)} = \theta^{(t)} - \eta\,\nabla_{\theta}\mathcal{L}\big(\theta^{(t)}\big)

可微编程应用

故事还没完。驻厂工程师发现,只要手册改得好,什么工序都能挂回执单:物理仿真可以反演参数,神经网络可以端到端训练,连你自己发明的新算子也能接进这条自动求导流水线。这就是可微编程的愿景——让“写程序”和“求导数”成为同一件事。

在科学计算里,Zygote 常被用来对微分方程求解器求导,实现“根据观测反推模型参数”的参数反演;在深度学习中,它支撑 Flux 训练 CNN、RNN 等模型;生态上,Enzyme、Diffractor 等新一代工具也在沿着源到源的路线演进,把自动微分扩展到更广的编译场景。

无论哪个领域,底层的数学都是同一个链式法则在多层结构上的反复应用。以一层神经网络为例:误差信号从输出层反向传来,乘上激活函数的局部导数,再与上一层激活值做外积,就得到本层权重的梯度。Zygote 把这一整套“反向下坡”的机械劳动完全自动化。

一部好的工序手册能覆盖整座工厂;一套好的自动微分系统,则能让“你写的每一段 Julia 代码,天然知道自己如何求导”。科学计算与 AI 的交叉地带,正因此变得越来越宽。

∂L∂W(l)=δ(l)⋅(a(l−1))T,δ(l)=(W(l+1))Tδ(l+1)⊙σ′(z(l))\frac{\partial\mathcal{L}}{\partial W^{(l)}} = \delta^{(l)}\cdot\big(a^{(l-1)}\big)^{\mathsf{T}}, \qquad \delta^{(l)} = \big(W^{(l+1)}\big)^{\mathsf{T}}\delta^{(l+1)}\odot\sigma'\big(z^{(l)}\big)
一层网络的权重梯度 = 该层误差信号 × 上一层激活值;误差信号又由更下一层的误差回传而来。整个网络就是靠这条链式法则串起来的。
  1. 误差信号 δ⁽ˡ⁾损失对第 l 层输出的敏感度,从输出层开始逐层反向传递。
    δ(L)=∇aL⊙σ′(z(L))\delta^{(L)} = \nabla_{a}\mathcal{L}\odot\sigma'\big(z^{(L)}\big)
  2. 激活局部导数误差逐元素乘上激活函数导数值,相当于对信号做一次“灵敏度过滤”。
    ⊙ σ′(z(l))\odot\,\sigma'\big(z^{(l)}\big)
  3. 权重梯度外积当前层误差与上一层激活值做外积,得到本层权重的梯度矩阵。
    ∂L∂W(l)=δ(l)⋅(a(l−1))T\frac{\partial\mathcal{L}}{\partial W^{(l)}} = \delta^{(l)}\cdot\big(a^{(l-1)}\big)^{\mathsf{T}}
  4. 逐层共享同一机制无论网络多深,Zygote 都用链式法则自动完成这些回传,无需手写任意一层的梯度。

AI 应用场景

深度学习模型训练

阶段:模型训练与优化(反向传播)

解决的问题:手写反向传播容易出错、难以维护,数值微分又慢又不精确。Zygote 对模型代码自动求梯度,把“求导”从人工劳动变成编译器服务,训练循环只需调用 gradient 即可获得全部参数梯度。

具体案例:用 Flux 训练手写数字识别模型。定义好卷积神经网络后,在训练循环里调用 Zygote.gradient(() -> loss(model, x, y), Flux.params(model)),一次取得所有层权重和偏置的梯度,优化器据此更新参数。整个训练过程就像工厂里反复“算敏感度→调工序”。

科学计算参数反演

阶段:科学计算与参数估计(前向仿真 + 梯度优化)

解决的问题:传统试错法调整物理参数费时费力,数值差分又容易引入噪声。Zygote 对仿真代码直接求导,让“根据观测反推参数”变成一次标准的梯度下降。

具体案例:用 SIR 传染病模型拟合每日新增病例曲线。模型根据传播率 β、恢复率 γ 预测病例数,Zygote 求出损失对 β、γ 的梯度,迭代更新参数直至预测曲线贴合真实数据。这正对应着“拿成品利润反推每道工序贡献”的故事。

自定义算子的可微实现

阶段:框架与算子开发

解决的问题:默认反向规则可能在数值上不稳定(例如朴素 softmax 或 logsumexp 的中间项溢出)。通过 @adjoint 手写梯度,既保证数值稳定,又保留可微编程的便捷。

具体案例:在 Julia 中为 logsumexp 定义 @adjoint,先把最大值提取出来做减法,避免 exp 爆炸;训练时 Zygote 自动采用这份“专家回执单”,而其他算子仍走默认规则。

Python 演示

python91 行
"""小型反向模式自动微分:模仿 Zygote 的 pullback 思路"""import math  class Var:    def __init__(self, value, pullback=None, children=()):        self.value = value        self._pullback = pullback        self.children = list(children)        self.grad = 0.0     def __add__(self, other):        other = ensure_var(other)        def pullback(grad):            return [grad, grad]        return Var(self.value + other.value, pullback, (self, other))     def __mul__(self, other):        other = ensure_var(other)        def pullback(grad):            return [grad * other.value, grad * self.value]        return Var(self.value * other.value, pullback, (self, other))     def __pow__(self, n):        def pullback(grad):            return [grad * n * self.value ** (n - 1)]        return Var(self.value ** n, pullback, (self,))     def __neg__(self):        return self * (-1)     def __sub__(self, other):        return self + (-ensure_var(other))     def __truediv__(self, other):        other = ensure_var(other)        def pullback(grad):            return [grad / other.value, -grad * self.value / (other.value ** 2)]        return Var(self.value / other.value, pullback, (self, other))     def backward(self):        order = []        seen = set()         def topo(node):            if node is None or id(node) in seen:                return            seen.add(id(node))            for c in node.children:                topo(c)            order.append(node)         topo(self)        self.grad = 1.0        for node in reversed(order):            if node._pullback is None:                continue            g_list = node._pullback(node.grad)            for child, g in zip(node.children, g_list):                if g is not None:                    child.grad += g  def ensure_var(v):    return v if isinstance(v, Var) else Var(v)  def sigmoid(x):    s = 1.0 / (1.0 + math.exp(-x.value))    def pullback(grad):        return [grad * s * (1.0 - s)]    return Var(s, pullback, (x,))  def main():    x1 = Var(1.0)    x2 = Var(2.0)    w1 = Var(0.5)    w2 = Var(-0.3)    z = x1 * w1 + x2 * w2    y = sigmoid(z)    y.backward()    print("y =", y.value)    print("d y / d x1 =", x1.grad)    print("d y / d x2 =", x2.grad)    print("d y / d w1 =", w1.grad)    print("d y / d w2 =", w2.grad)  if __name__ == "__main__":    main()
目的:用约 80 行 Python 实现一个极简反向模式自动微分引擎,每个 Var 都像一道工序一样携带 pullback“回执单”,演示了 Zygote 赖以工作的核心机制。
  1. def __init__(self, value, pullback=None, children=())每个变量自带“回执单”(pullback)和“原料清单”(children),对应手册里每道工序的敏感度说明。
  2. def pullback(grad): return [grad, grad]加法的回执规则:下游梯度原样分给两个加数,一分钱不差。
  3. def pullback(grad): return [grad * other.value, grad * self.value]乘法的回执规则:对甲求导时把乙当作常数,交叉相乘。
  4. def topo(node)拓扑排序,先叶子后根,保证反向处理时每个节点的梯度已经齐备。
  5. self.grad = 1.0给输出端挂上种子梯度,就像给成品利润标上"1"作为追溯起点。
  6. for node in reversed(order)从根到叶子逐站执行各节点的 pullback,把敏感度一层层拉回上游。
  7. child.grad += g梯度累积:多路汇入同一输入时,把各支路贡献的敏感度相加,对应反向模式的求和规则。

常见误区

误区既然 Zygote 是源到源变换,那我把整个项目传进去,它应该什么函数都能求导吧?连读写文件、打印日志、修改全局变量的代码也应该不在话下。

正解Zygote 只对“纯的、可追踪的计算”求导。它的变换依赖把函数体里的运算替换成带反向规则的版本;遇到副作用(全局变量赋值、IO)或不可微操作(取整、比较分支改变结构)时会报错或给出错误结果。就像改写《工序手册》只能在“有明确工艺参数”的工位生效,砸钉子、手工打磨这类“手工作业”没法自动给出敏感度。

为何常见:“源到源”三个字太有迷惑性,容易让人以为是“万能代码改写器”,实际上它只覆盖编译器能识别并替换的运算。

误区数值微分把 h 取得很小就是自动微分吧?反正都是算导数,精度差不了多少。

正解数值微分靠差分 (f(x+h)−f(x))/h,步长 h 的选取要同时对抗截断误差和舍入误差,两头为难;自动微分根本不取 h,而是把每个基本运算的导数规则通过链式法则精确组装,误差只来自浮点舍入,没有截断误差。

为何常见:两者都在“算导数”,而且数值微分实现最简单、最先被教材提到,初学者很容易把二者画等号。

误区深度学习教程通篇都在讲反向传播,所以反向模式一定是自动微分的最终答案,任何时候都比前向模式快。

正解前向模式跑一趟得到一个方向导数,成本正比于输入维数,适合输入少输出多;反向模式跑一趟得到全部输入梯度,成本正比于输出维数,适合输入多输出少。维度关系一颠倒,结论也跟着颠倒。

为何常见:深度学习恰好是“参数极多、损失极少”的问题,反向模式的优势太显眼,让人误以为它是绝对的王者。

误区源到源嘛,就是读入源码文本,用正则表达式把 `+` 换成带导数的 `+`,把 `*` 换成带导数的 `*`。

正解Zygote 是在 Julia 编译器 lowering 之后的中间表示(IR)上做变换,能拿到类型、变量作用域和控制流信息。这正是它能处理循环、递归和自定义结构的原因,纯文本替换做不到这些。

为何常见:“源到源”字面上像文本处理,容易联想到 grep/sed 一类的工具,忽略了编译器的存在。

误区Zygote 这么强大,以后所有梯度都交给它,手推梯度这门手艺可以彻底退休了。

正解数值不稳定的算子(如朴素 softmax)、隐函数、带约束的优化场景,仍然需要 @adjoint 手写梯度。自动微分给的是“默认规则”,专家规则永远有存在价值——就像再好的自动化流水线,关键工序仍要老师傅把关。

为何常见:工具越强,越容易让人高估它的边界,直到在 NaN 损失面前碰壁才想起自定义梯度。

面试题

Q1. 与数值微分相比,自动微分的核心优势是?

单选题 · easy

  • A.需要人为设定差分步长
  • B.不产生任何截断误差← 正确答案
  • C.完全不需要链式法则
  • D.只能在静态图上运行

答案:B。数值微分用有限差分近似,步长 h 既怕截断误差又怕舍入误差;自动微分按基本运算的精确规则组装导数,没有截断误差。B 正确。A 是数值微分的缺点,C、D 与自动微分的事实不符。

Q2. 符号微分在处理复杂函数时的主要瓶颈是?

单选题 · easy

  • A.无法使用任何解析公式
  • B.推导结果随复杂度膨胀← 正确答案
  • C.只能用于线性函数求导
  • D.无法保留中间变量信息

答案:B。符号微分像数学家手推整条公式,嵌套越深表达式越庞大,即“表达式膨胀”。B 正确;A、C、D 都不符合符号微分的特性,符号微分恰恰擅长解析推导且依赖中间变量。

Q3. 前向模式自动微分最适合哪类场景?

单选题 · mid

  • A.输入很少而输出很多的模型← 正确答案
  • B.输入很多而输出很少的模型
  • C.含大量循环的复杂程序
  • D.参数规模超过一亿的神经网络

答案:A。前向模式每趟求一个方向导数,需要跑的趟数等于输入维数,所以输入少输出多时最划算。深度学习参数远多于损失,适合反向模式,因此选 A。

Q4. 反向模式中“伴随值”(adjoint)的含义是?

单选题 · mid

  • A.最终损失对中间变量的导数← 正确答案
  • B.中间变量对输入变量的导数
  • C.模型参数的初始取值
  • D.梯度下降更新的步长

答案:A。反向模式定义伴随值 \bar{u}=∂L/∂u,即损失对中间量 u 的敏感度,从输出端往回逐层计算。B 把方向弄反了,C、D 与伴随值概念无关。

Q5. 关于 Zygote 的源到源变换,正确的说法有?

多选题 · mid

  • A.在编译器 IR 层改写函数体← 正确答案
  • B.为基本运算绑定反向规则← 正确答案
  • C.依赖运行时解释器逐行追踪
  • D.对带全局副作用的代码直接求导

答案:AB。Zygote 在 lowering 后的 IR 上做改写,并给每个基本运算配 pullback 规则,所以 A、B 正确。C 是“运行时追踪”派的做法,D 中副作用代码不被支持,Zygote 只保证纯计算的正确求导。

Q6. 在 Zygote 中,pullback 函数的核心作用是?

单选题 · extreme

  • A.将输出梯度映射回输入侧← 正确答案
  • B.计算损失函数的数值
  • C.完成一次前向网络推理
  • D.用差分近似雅可比矩阵

答案:A。pullback 的签名等价于 \bar{x} = \bar{y}·f'(x),把下游敏感度“拉回”成上游敏感度。B、C 都是前向计算的工作,D 是数值微分的做法,与 pullback 的精确回传机制不符。

Q7. Zygote 对循环与递归求导时,正确的描述有?

多选题 · hard

  • A.循环体会被展开并逐层记录← 正确答案
  • B.递归调用会累积链式回传← 正确答案
  • C.控制流语句无法出现在被导函数中
  • D.与基本运算采用完全相同的规则

答案:AB。Zygote 能处理结构化控制流:循环展开成串行基本运算,递归按调用链累积 pullback,因此 A、B 正确;C 错在 Zygote 恰恰支持循环递归,D 错在控制流需要展开配对处理,而非当作原子运算。

Q8. 链式法则的正确数学形式是?

单选题 · easy

  • A.dy/dx 等于 dy/du 与 du/dx 之积← 正确答案
  • B.dy/dx 等于 du/dx 除以 dy/du 之商
  • C.dy/dx 等于 dy/du 与 du/dx 之和
  • D.dy/dx 等于 dx/du 乘以 dx/dy 之积

答案:A。复合函数求导的关键是“逐段相乘、中间量约掉”,A 正确。B、C 的运算形式错误,D 把方向写反了。

Q9. 关于自定义梯度 @adjoint,理解正确的有?

多选题 · mid

  • A.可为数值不稳定的算子提供稳定梯度← 正确答案
  • B.可覆盖 Zygote 对库函数的默认规则← 正确答案
  • C.只能修改返回值不能修改反向逻辑
  • D.定义后反向传播会完全失效

答案:AB。@adjoint 允许用户同时指定前向输出和反向 pullback,最常用在 logsumexp、softmax 等需要数值稳定的函数上,且自定义规则优先级更高。C、D 与 Zygote 的文档行为不符。

Q10. 梯度下降更新参数的公式正确的是?

单选题 · hard

  • A.参数加学习率乘梯度的结果
  • B.参数减学习率乘梯度的结果← 正确答案
  • C.参数除学习率乘梯度的结果
  • D.学习率减参数乘梯度的结果

答案:B。梯度指向损失上升最快的方向,参数应沿反方向移动,即 θ←θ−η∇L。A 方向反了会让损失越来越大,C、D 的运算形式不符合梯度下降的定义。