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