深度学习进阶:自然语言处理|4.1.2 QA|grads 列表与省略号 [...] 详解

grads 列表、[0]、[...] 与 Embedding 梯度清零


1. self.grads[0] 是什么?

python 复制代码
class MatMul:
    def __init__(self, W):
        self.params = [W]
        self.grads  = [np.zeros_like(W)]

paramsgrads 是一一对应的列表:

text 复制代码
self.params = [ W ]          → params[0] 是 W
self.grads  = [ dW ]         → grads[0]  是 W 的梯度槽

如果一层有两个参数,例如全连接层的 Wb

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 解决"重复词梯度累加"的问题。

相关推荐
手写码匠6 小时前
Android 17 适配实战指南:新特性解读、隐私变更与迁移全攻略
人工智能·深度学习·算法·aigc
YueJoy.AI6 小时前
创业团队如何管理远程工作
人工智能·ai·语言模型
端平入洛6 小时前
单个感知机为何无法解决异或问题?
人工智能·深度学习
卷卷说风控7 小时前
【卷卷观察】Google I/O 炸场背后:AI 行业正在经历一场“越南战争“
人工智能
SLD_Allen7 小时前
AI-Infra双轨战略:承托当下GPU算力,布局未来CPU替代
人工智能·gpu算力·ai-infra
wait7 小时前
Vibe Coding 开发技巧
前端·javascript·人工智能
bloxed7 小时前
【AI大模型--NumPy-06】随机数生成与蒙特卡洛模拟
人工智能·numpy
szxinmai主板定制专家7 小时前
基于ZYNQ MPSOC图像采集与压缩系统总体设计方案
linux·arm开发·人工智能·嵌入式硬件·fpga开发
GOTXX7 小时前
SenseNova U1 实战体验:API 调用 + OpenClaw 接入全流程
服务器·网络·人工智能·语言模型