深度学习进阶:自然语言处理|4.1.2 QA|grads 列表与省略号 [...] 详解
grads 列表、[0]、[…] 与 Embedding 梯度清零
1.self.grads[0]是什么?
classMatMul:def__init__(self,W):self.params=[W]self.grads=[np.zeros_like(W)]params和grads是一一对应的列表:
self.params = [ W ] → params[0] 是 W self.grads = [ dW ] → grads[0] 是 W 的梯度槽如果一层有两个参数,例如全连接层的W和b:
self.params=[W,b]self.grads=[dW,db]对应关系就是:
params[0] = W → grads[0] = dW params[1] = b → grads[1] = db所以[0]没有特殊含义,只是“取第 0 个参数对应的梯度”。
2. 为什么用[...],而不是直接赋值?
核心区别:
grads[0] = dW → 让 grads[0] 指向一个新数组 grads[0][...] = dW → 把 dW 的值写进原数组,数组对象不变优化器通常会提前拿到梯度数组的引用。如果你换掉数组,优化器还指向旧数组;如果你原地改数组,优化器能看到新梯度。
实际代码验证:普通赋值会让引用断开
importnumpyasnp grads=[np.zeros((3,2))]optimizer_grad_ref=grads[0]# 模拟优化器提前保存梯度引用old_id=id(grads[0])dW_new=np.array([[1.,1.],[0.,0.],[1.,1.]])grads[0]=dW_new# 普通赋值:换成新数组print("grads[0] 还是旧数组吗?",id(grads[0])==old_id)print("optimizer 还指向旧数组吗?",id(optimizer_grad_ref)==old_id)print("optimizer 看到的梯度:\n",optimizer_grad_ref)print("grads[0] 当前内容:\n",grads[0])输出:
grads[0] 还是旧数组吗? False optimizer 还指向旧数组吗? True optimizer 看到的梯度: [[0. 0.] [0. 0.] [0. 0.]] grads[0] 当前内容: [[1. 1.] [0. 0.] [1. 1.]]图解:
普通赋值后: optimizer_grad_ref ──→ 旧数组 [[0,0],[0,0],[0,0]] grads[0] ───────────→ 新数组 [[1,1],[0,0],[1,1]]结论:grads[0]有新梯度,但优化器还看着旧的零数组。
实际代码验证:原地赋值不会让引用断开
importnumpyasnp grads=[np.zeros((3,2))]optimizer_grad_ref=grads[0]old_id=id(grads[0])dW_new=np.array([[1.,1.],[0.,0.],[1.,1.]])grads[0][...]=dW_new# 原地赋值:不换数组,只改内容print("grads[0] 还是旧数组吗?",id(grads[0])==old_id)print("optimizer 还指向旧数组吗?",id(optimizer_grad_ref)==old_id)print("optimizer 看到的梯度:\n",optimizer_grad_ref)print("grads[0] 当前内容:\n",grads[0])输出:
grads[0] 还是旧数组吗? True optimizer 还指向旧数组吗? True optimizer 看到的梯度: [[1. 1.] [0. 0.] [1. 1.]] grads[0] 当前内容: [[1. 1.] [0. 0.] [1. 1.]]图解:
原地赋值后: optimizer_grad_ref ─┐ ├──→ 同一个数组,内容变成 [[1,1],[0,0],[1,1]] grads[0] ───────────┘结论:[...]的价值是保持数组对象不变,只更新里面的数据。
3. Embedding 层为什么先dW[...] = 0?
Embedding 的反向传播代码:
defbackward(self,dout):dW,=self.grads dW[...]=0dW[self.idx]=dout# 不太好的方式returnNonedW[...] = 0清掉的是上一轮 mini-batch 留在 dW 里的旧梯度;当前梯度还在dout里,并没有被清掉。
设:
W.shape = (5, 3) dW.shape = (5, 3)上一轮反向传播后,dW里可能残留:
词 ID dW 0 [0, 0, 0] 1 [1, 1, 1] ← 上一轮残留 2 [0, 0, 0] 3 [3, 3, 3] ← 上一轮残留 4 [0, 0, 0]本轮只有词 ID2出现:
idx=[2]dout=[[9,9,9]]如果不清零,直接写入本轮梯度:
错误结果: 词 ID dW 0 [0, 0, 0] 1 [1, 1, 1] ← 错:旧梯度还在 2 [9, 9, 9] ← 对:本轮梯度 3 [3, 3, 3] ← 错:旧梯度还在 4 [0, 0, 0]正确流程是先清零,再写入:
dW[...] = 0 词 ID dW 0 [0, 0, 0] 1 [0, 0, 0] 2 [0, 0, 0] 3 [0, 0, 0] 4 [0, 0, 0] 然后 dW[idx] = dout 词 ID dW 0 [0, 0, 0] 1 [0, 0, 0] 2 [9, 9, 9] ← 本轮梯度 3 [0, 0, 0] 4 [0, 0, 0]所以dW[...] = 0不是覆盖本轮梯度,而是先擦掉旧缓存。
4. 为什么还要创建和W一样大的dW?
Embedding 层前向传播只取出W的几行:
out=W[idx]所以反向传播时,理论上也只需要更新这几行:
W 是大矩阵: 词 ID W 0 [...] 1 [...] 2 [...] ← 本轮用到,需要更新 3 [...] 4 [...] ← 本轮用到,需要更新因此更节省的表示方式其实是:
需要更新的行号:idx = [2, 4] 这些行的梯度: dout = [[...], [...]]也就是说,不一定非要创建一个和W一样大的完整dW:
完整 dW: 词 ID dW 0 [0, 0, 0] 1 [0, 0, 0] 2 [a, a, a] ← 有用 3 [0, 0, 0] 4 [b, b, b] ← 有用其中大部分行都是 0,真正有用的只有idx对应的几行。
但书中这里仍然创建完整dW,是为了兼容已经实现好的优化器:
optimizer.update(params,grads)优化器默认认为:
params[0] 是完整的 W grads[0] 也是和 W 形状相同的完整 dW所以当前写法牺牲了一点效率,换来和已有训练框架的统一接口。
一句话:Embedding 的梯度本质上是稀疏的,只需要idx + dout;但为了适配通用 Optimizer,代码把它展开成完整的dW。
5. 真正会覆盖梯度的问题:dW[self.idx] = dout
dW[...] = 0是必要的;真正“不太好”的是:
dW[self.idx]=dout覆盖只会出现在一个条件下:同一次backward()里,idx中有重复的词 ID。
例如一个 mini-batch 里取了 3 个词:
idx = [2, 2, 4]含义是:
第 1 个样本用了词 ID 2 第 2 个样本也用了词 ID 2 ← 重复 第 3 个样本用了词 ID 4这种情况很常见,比如一句话里同一个词出现多次,或者一个 batch 的不同句子都出现了同一个词。
如果idx没有重复,例如:
idx = [1, 2, 4]那么dW[self.idx] = dout不会发生覆盖,因为每个dout都写入不同的行。
实际代码验证:重复词 ID 才会覆盖
importnumpyasnp dW=np.zeros((5,3))idx=np.array([2,2,4])dout=np.array([[1.,1.,1.],# 第一次给词 ID 2 的梯度[2.,2.,2.],# 第二次给词 ID 2 的梯度[4.,4.,4.]])# 给词 ID 4 的梯度dW[idx]=doutprint(dW)输出:
[[0. 0. 0.] [0. 0. 0.] [2. 2. 2.] [0. 0. 0.] [4. 4. 4.]]词 ID2出现了两次:
第一次:dW[2] = [1, 1, 1] 第二次:dW[2] = [2, 2, 2] ← 覆盖第一次但正确结果应该是:
dW[2] = [1, 1, 1] + [2, 2, 2] = [3, 3, 3]正确写法:np.add.at
importnumpyasnp dW=np.zeros((5,3))idx=np.array([2,2,4])dout=np.array([[1.,1.,1.],[2.,2.,2.],[4.,4.,4.]])np.add.at(dW,idx,dout)print(dW)输出:
[[0. 0. 0.] [0. 0. 0.] [3. 3. 3.] [0. 0. 0.] [4. 4. 4.]]图解:
idx = [2, 2, 4] [1,1,1] ─┐ ├──→ dW[2] = [3,3,3] [2,2,2] ─┘ [4,4,4] ───→ dW[4] = [4,4,4]6. 为什么重复词梯度是相加,不是求平均?
假设词 ID2是“猫”:
句子:猫 喜欢 猫 idx = [2, 5, 2]Embedding 前向传播中,两个“猫”都使用同一行参数W[2]:
第 1 个“猫” → W[2] 第 3 个“猫” → W[2]如果反向传播传回来:
第 1 个“猫”的梯度:[1, 1, 1] 第 3 个“猫”的梯度:[2, 2, 2]那么W[2]收到的总梯度是:
W[2] 的梯度 = [1, 1, 1] + [2, 2, 2] = [3, 3, 3]原因很简单:同一行参数W[2]被用了两次,就通过两个位置影响 loss;两个位置的影响要合并,合并方式是相加。
如果求平均:
([1, 1, 1] + [2, 2, 2]) / 2 = [1.5, 1.5, 1.5]这不是默认反向传播规则,而是额外的“按出现次数缩放”策略。
什么时候会平均?当模型公式里本来就写了平均,例如:
句子向量 = (猫 + 喜欢 + 猫) / 3这时/3会进入传回 Embedding 层的dout,Embedding 层仍然只负责把同一个词 ID 的多份梯度相加。
一句话:重复词梯度默认相加;如果要按词频平均,应该由模型公式、loss 计算或优化策略决定,而不是在np.add.at这里自动除以次数。
7. 核心结论
Embedding 层更稳妥的写法是:
defbackward(self,dout):dW,=self.grads dW[...]=0np.add.at(dW,self.idx,dout)returnNone对应三件事:
dW, = self.grads → 取出 W 对应的梯度槽 dW[...] = 0 → 原地清空旧梯度,数组对象不变 np.add.at(dW, self.idx, dout) → 把本轮梯度累加到对应词 ID,重复词不会被覆盖一句话:[...]解决“引用不断开/旧梯度清零”的问题;np.add.at解决“重复词梯度累加”的问题。
