![深度学习进阶:自然语言处理|4.1.2 QA|grads 列表与省略号 [...] 详解](http://pic.xiahunao.cn/yaotu/深度学习进阶:自然语言处理|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和bself.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_refgrads[0]# 模拟优化器提前保存梯度引用old_idid(grads[0])dW_newnp.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_refgrads[0]old_idid(grads[0])dW_newnp.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[...] 0Embedding 的反向传播代码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一样大的dWEmbedding 层前向传播只取出W的几行outW[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] doutdW[...] 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 dWnp.zeros((5,3))idxnp.array([2,2,4])doutnp.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.atimportnumpyasnp dWnp.zeros((5,3))idxnp.array([2,2,4])doutnp.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 层的doutEmbedding 层仍然只负责把同一个词 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解决“重复词梯度累加”的问题。