在一家玩具厂的流水线上,塑料粒 x 要经过注塑(h)、上色(g)、组装(f)三道工序,最终变成成品 y。老板很想知道:每多采购 1 公斤塑料粒,利润会涨多少?于是工厂请来一位“驻厂工程师”。这位工程师不碰任何机器,只做一件事:把《工序手册》改写一遍——在每道工序后面附上一页“成本回执单”(pullback),上面写清楚“这道工序的产出变化 1 单位时,它的投料会变化多少”。新版手册发下去,生产线照常正向跑一遍,产品下线;工程师再拿着成品利润,按手册从最后一道工序往回走,逐站把“影响系数”算出来——这些系数,就是数学里的梯度。Julia 的 Zygote 库,就是这位“改写工序手册的工程师”,它的技术叫“源到源变换”(source-to-source transformation)。这篇文章从自动微分家族的三种求导方式讲起,带你走进 Zygote 的前向模式、反向模式与 pullback 机制,最后学会用它求循环、递归、甚至整个神经网络的梯度。
学会 Zygote 自动微分,等于掌握“把代码改写成自带求导能力”的本事。梯度计算、科学计算参数反演、深度学习训练都将为你所用,这是进入可微编程世界的入门钥匙。
可视化演示
展示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”……整条线不需要新增任何测量设备,只需把相邻敏感度相乘,就能从原料口一路算到成品口。自动微分要做的,就是把这套乘法自动化、精确化。
- 拆解复合链条把从 x 到 y 的长路切成 n 段,u₀ 是原材料,uₙ 是成品,每段都是一个基本运算。
- 每段局部导数相邻两步的关系由基本运算给出,导数直接查表得到,简单可靠。
- 相邻相乘约分把 n 个局部导数连乘,中间的 u₁ 到 u_{n-1} 成对抵消,剩下端到端的总导数。
前向模式
前向模式像是“挂标签上路”:给投料口挂一张“敏感性标签”,塑料粒走到哪一道工序,标签就被那一道工序的局部规则更新一次。当材料最终变成成品下线时,标签上的数字已经是 dy/dx。整个过程是顺着生产方向的,所以叫“前向”。
数学上,这张标签是一个“对偶数” a+bε,其中 ε 是一个满足 ε²=0 的无穷小记号。实部 a 走正常数值,虚部 b 走导数信息,两者在一次正向遍历中同步更新。每经过一个基本运算,实部按原规则计算,虚部按该运算的导数规则更新——一路顺流而下,值算完了,导数也到了。
如果输入有 n 个变量、输出有 m 个,前向模式每跑一趟只能算一个“方向”(一个输入)的导数,所以总的成本正比于输入维数 n。这也决定了它的适用场景:输入少、输出多时非常划算。
前向模式适合“输入少、输出多”的流水线——比如只有一种原材料,却要同时监控十几个成品指标,挂一次标签全部搞定。但如果原材料有十万种(像神经网络里的参数),挂十万次标签的代价就太大了。这时,就该反向模式登场了。
- 设定种子方向想求哪个输入的导数,就给对应分量置 1,其余分量置 0,这张“标签”从起点出发。
- 沿途更新导数每经过一个基本运算,标签上的导数按该运算的局部规则刷新一次。
- 雅可比矩阵相乘整个遍历过程等价于雅可比矩阵 J 与方向向量做矩阵乘法,一趟正向扫描完成全部所需信息。
反向模式
深度学习的场景恰好相反:原材料成千上万(模型参数),成品却只有一个(损失值)。老板真正想要的是“一次下线,把整条流水线上所有原材料的贡献系数同时算清”。反向模式正是为此而生:从成品出发,逆着流水线走回原料库,沿途把每个工位的敏感度一一记下。
反向模式的核心概念叫“伴随值”,记作 \bar{u} = ∂L/∂u,意思是“最终损失 L 对某个中间量 u 的敏感度”。信号从输出端出发,每经过一条边,就乘上这条边的局部导数,不断往回“拉”。当多条支路汇合到同一个节点时,各路贡献的敏感度要做加法——这就是梯度累积。
反向模式通常先把前向过程“记账”(保存中间值),再倒序回放。因为输出只有 1 个、输入有 n 个,它只需要一趟反向遍历,就能拿到全部 n 个输入的梯度。这正是深度学习反向传播的数学原理。
反向模式就像“全厂追溯系统”:产品一落地,终端扫码,系统沿着产线逐站回填每个工位的贡献值。一次扫码,整条线的敏感度全部到账。前向模式挂一次标签只算一个方向,反向模式扫一次码算全部方向——难怪深度学习离不开它。
- 输出端种子从损失 L 对输出 y_k 的导数出发,这是反向遍历的起点信号。
- 沿边回传敏感度每条边把下游敏感度乘上局部导数,送回上游节点,一步一乘地逆流而上。
- 汇合处求和多条路径汇入同一个输入时,把各路贡献的敏感度累加,得到该输入的总梯度。
源到源变换
前面两种模式都需要一个“账房先生”在运行时逐笔记账。Zygote 却另辟蹊径:它干脆把《工序手册》重写一遍。它不对原材料和机器动手,只改手册——在每道工序后面附上“成本回执单”的写法。这就是源到源变换:代码本身被改写成“会自己算梯度”的新代码,而不是在外面搭一套追踪账本。
具体怎么做?Julia 编译器会把源码降低成一种中间表示(IR,可以理解为编译器的“内部草稿语言”)。Zygote 在这个 IR 层遍历函数体,把每一个基本运算替换成“前向计算 + 反向规则”的配对版本,生成一份新的 IR 再交还给编译器继续优化。因为是编译期改写而不是运行时追踪,Zygote 几乎没有运行时开销,产出的仍然是可读、可调试的 Julia 代码。
源到源变换的另一大好处是能处理控制流:if、for、while、递归这些“结构性的工序安排”,都可以被展开或配对,逐层生成对应的反向操作。这是它相比“运行时账本派”自动微分工具的核心优势。
改写后的手册照常指挥生产,只是每道工序多了一栏“敏感度回填说明”。老板拿到的新手册,就是“可微编程”的起点——因为他不再需要另请账房先生,代码自己就带着算梯度的说明书。
- 变换算子 TZygote 在 IR 层遍历函数体,对每个赋值语句生成配套的反向语句。
- 保留前向原样y = f(x) 的计算逻辑一字节不变,产品照常按原配方生产。
- 追加反向规则新代码额外返回输入侧敏感度,由“局部导数乘以下游敏感度”给出。
Zygote核心
pullback 是这本新手册里最核心的一页“回执单”:上面写着,当你知道“成品对我的产出有多敏感”时,如何反算出“我对我的投料有多敏感”。它的口头禅是八个字:把敏感度拉回上游。在 Zygote 里,每个基本函数 f 都配有一个 pullback,签名等价于“输入下游敏感度 ȳ,输出上游敏感度 x̄”。
复合函数由编译器自动嵌套这些 pullback:前向算到哪一层,反向就从那一层接住,像一串回执单首尾相连。对循环,循环体会被展开成串行基本运算,逐层记录各自的回执单;对递归,调用链展开后同样按顺序逐层回传。这样,Julia 的任意结构化控制流都能被求导。
除了默认规则,Zygote 还提供了自定义梯度入口 @adjoint。当某个算子的默认反向规则数值不稳定(比如朴素实现的 softmax),或者你手里有更优的解析导数时,可以用 @adjoint 为它手写一份“专家回执单”,Zygote 会自动优先采用。
至此,流水线上每一道工序——哪怕是老师傅手工打磨的“隐函数”——都有了专属回执单。Zygote 不再只是一个求导工具,而是让任意 Julia 代码变得“可微”的基础设施。你写程序,它负责让程序知道自己的导数。
- 拉回语义pullback 的输入是下游敏感度 ȳ,输出是上游敏感度 x̄,方向与生产方向正好相反。
- 依赖前向值许多运算的导数要用到中间结果 x、y,所以前向过程的值必须保存下来供反向取用。
- 自定义梯度入口通过 @adjoint,用户可以把默认 pullback 替换为自己提供的反向函数 g。
梯度计算实战
有了每道工序的敏感度表,老板就可以做“精准改善”:哪道工序对利润影响最大,就优先调整哪道工序的参数;调完再跑一遍,再算敏感度,再调……深度学习训练就是这个循环的自动化版本:前向跑一遍算损失,反向算梯度,然后沿梯度的反方向小步修正参数,反复迭代直到损失收敛。
梯度指向损失上升最快的方向,所以参数要往反方向走,更新公式写作 θ ← θ − η∇L。这里的 ∇L 不需要手推,一行 Zygote.gradient 调用即可得到损失对全部参数的梯度向量。Flux 是 Julia 的深度学习框架,它把 Zygote 当作求导引擎:定义模型 → 组合损失 → 调用 gradient → 交给优化器更新参数,整个训练循环就像一条“参数质量控制流水线”。
Pytorch 用户熟悉的“损失.backward()”与“优化器.step()”,在 Flux 中对应的是一句 Flux.train!。底层把前向、反向、更新串成一体,而其中最关键的梯度计算,全部由 Zygote 的源到源变换自动完成。
训练一个神经网络,与老板反复调整工序参数没有本质区别——只是把“敏感度”改叫“梯度”,把“调整幅度”改叫“学习率”。Zygote 负责把敏感的数学部分藏在编译器里,你只需要专注写模型本身。
- 损失函数 L衡量模型输出与真实目标的差距,训练目标就是让这个差距越来越小。
- 梯度向量Zygote 一次反向调用,返回损失对全部参数的偏导数组。
- 学习率 η控制每次沿反方向迈出的步子大小,太大容易震荡,太小收敛太慢。
- 迭代更新重复“前向算损失→反向求梯度→反方向小步更新”,直到损失不再下降。
可微编程应用
故事还没完。驻厂工程师发现,只要手册改得好,什么工序都能挂回执单:物理仿真可以反演参数,神经网络可以端到端训练,连你自己发明的新算子也能接进这条自动求导流水线。这就是可微编程的愿景——让“写程序”和“求导数”成为同一件事。
在科学计算里,Zygote 常被用来对微分方程求解器求导,实现“根据观测反推模型参数”的参数反演;在深度学习中,它支撑 Flux 训练 CNN、RNN 等模型;生态上,Enzyme、Diffractor 等新一代工具也在沿着源到源的路线演进,把自动微分扩展到更广的编译场景。
无论哪个领域,底层的数学都是同一个链式法则在多层结构上的反复应用。以一层神经网络为例:误差信号从输出层反向传来,乘上激活函数的局部导数,再与上一层激活值做外积,就得到本层权重的梯度。Zygote 把这一整套“反向下坡”的机械劳动完全自动化。
一部好的工序手册能覆盖整座工厂;一套好的自动微分系统,则能让“你写的每一段 Julia 代码,天然知道自己如何求导”。科学计算与 AI 的交叉地带,正因此变得越来越宽。
- 误差信号 δ⁽ˡ⁾损失对第 l 层输出的敏感度,从输出层开始逐层反向传递。
- 激活局部导数误差逐元素乘上激活函数导数值,相当于对信号做一次“灵敏度过滤”。
- 权重梯度外积当前层误差与上一层激活值做外积,得到本层权重的梯度矩阵。
- 逐层共享同一机制无论网络多深,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 演示
"""小型反向模式自动微分:模仿 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()
def __init__(self, value, pullback=None, children=())每个变量自带“回执单”(pullback)和“原料清单”(children),对应手册里每道工序的敏感度说明。def pullback(grad): return [grad, grad]加法的回执规则:下游梯度原样分给两个加数,一分钱不差。def pullback(grad): return [grad * other.value, grad * self.value]乘法的回执规则:对甲求导时把乙当作常数,交叉相乘。def topo(node)拓扑排序,先叶子后根,保证反向处理时每个节点的梯度已经齐备。self.grad = 1.0给输出端挂上种子梯度,就像给成品利润标上"1"作为追溯起点。for node in reversed(order)从根到叶子逐站执行各节点的 pullback,把敏感度一层层拉回上游。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. 与数值微分相比,自动微分的核心优势是?
- A.需要人为设定差分步长
- B.不产生任何截断误差← 正确答案
- C.完全不需要链式法则
- D.只能在静态图上运行
答案:B。数值微分用有限差分近似,步长 h 既怕截断误差又怕舍入误差;自动微分按基本运算的精确规则组装导数,没有截断误差。B 正确。A 是数值微分的缺点,C、D 与自动微分的事实不符。
Q2. 符号微分在处理复杂函数时的主要瓶颈是?
- A.无法使用任何解析公式
- B.推导结果随复杂度膨胀← 正确答案
- C.只能用于线性函数求导
- D.无法保留中间变量信息
答案:B。符号微分像数学家手推整条公式,嵌套越深表达式越庞大,即“表达式膨胀”。B 正确;A、C、D 都不符合符号微分的特性,符号微分恰恰擅长解析推导且依赖中间变量。
Q3. 前向模式自动微分最适合哪类场景?
- A.输入很少而输出很多的模型← 正确答案
- B.输入很多而输出很少的模型
- C.含大量循环的复杂程序
- D.参数规模超过一亿的神经网络
答案:A。前向模式每趟求一个方向导数,需要跑的趟数等于输入维数,所以输入少输出多时最划算。深度学习参数远多于损失,适合反向模式,因此选 A。
Q4. 反向模式中“伴随值”(adjoint)的含义是?
- A.最终损失对中间变量的导数← 正确答案
- B.中间变量对输入变量的导数
- C.模型参数的初始取值
- D.梯度下降更新的步长
答案:A。反向模式定义伴随值 \bar{u}=∂L/∂u,即损失对中间量 u 的敏感度,从输出端往回逐层计算。B 把方向弄反了,C、D 与伴随值概念无关。
Q5. 关于 Zygote 的源到源变换,正确的说法有?
- A.在编译器 IR 层改写函数体← 正确答案
- B.为基本运算绑定反向规则← 正确答案
- C.依赖运行时解释器逐行追踪
- D.对带全局副作用的代码直接求导
答案:AB。Zygote 在 lowering 后的 IR 上做改写,并给每个基本运算配 pullback 规则,所以 A、B 正确。C 是“运行时追踪”派的做法,D 中副作用代码不被支持,Zygote 只保证纯计算的正确求导。
Q6. 在 Zygote 中,pullback 函数的核心作用是?
- A.将输出梯度映射回输入侧← 正确答案
- B.计算损失函数的数值
- C.完成一次前向网络推理
- D.用差分近似雅可比矩阵
答案:A。pullback 的签名等价于 \bar{x} = \bar{y}·f'(x),把下游敏感度“拉回”成上游敏感度。B、C 都是前向计算的工作,D 是数值微分的做法,与 pullback 的精确回传机制不符。
Q7. Zygote 对循环与递归求导时,正确的描述有?
- A.循环体会被展开并逐层记录← 正确答案
- B.递归调用会累积链式回传← 正确答案
- C.控制流语句无法出现在被导函数中
- D.与基本运算采用完全相同的规则
答案:AB。Zygote 能处理结构化控制流:循环展开成串行基本运算,递归按调用链累积 pullback,因此 A、B 正确;C 错在 Zygote 恰恰支持循环递归,D 错在控制流需要展开配对处理,而非当作原子运算。
Q8. 链式法则的正确数学形式是?
- 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,理解正确的有?
- A.可为数值不稳定的算子提供稳定梯度← 正确答案
- B.可覆盖 Zygote 对库函数的默认规则← 正确答案
- C.只能修改返回值不能修改反向逻辑
- D.定义后反向传播会完全失效
答案:AB。@adjoint 允许用户同时指定前向输出和反向 pullback,最常用在 logsumexp、softmax 等需要数值稳定的函数上,且自定义规则优先级更高。C、D 与 Zygote 的文档行为不符。
Q10. 梯度下降更新参数的公式正确的是?
- A.参数加学习率乘梯度的结果
- B.参数减学习率乘梯度的结果← 正确答案
- C.参数除学习率乘梯度的结果
- D.学习率减参数乘梯度的结果
答案:B。梯度指向损失上升最快的方向,参数应沿反方向移动,即 θ←θ−η∇L。A 方向反了会让损失越来越大,C、D 的运算形式不符合梯度下降的定义。