<think>我们正在实现卷积层的反向传播函数conv_backward_naive。这个函数将接收上游传来的梯度dout和之前缓存的值(包括输入x、权重w、偏置b和卷积参数conv_param),然后计算相对于输入x、权重w和偏置b的梯度。
步骤:
1. 从缓存中恢复前向传播时保存的变量(x, w, b, conv_param)和参数(stride, pad)。
2. 初始化梯度:dx, dw, db,其形状分别与x, w, b相同。
3. 根据前向传播的填充方式,对dx进行相应的填充(因为我们需要计算填充区域的梯度,但最后要去掉填充部分)。
4. 按照前向传播的卷积过程,遍历每个样本、每个滤波器、每个输出位置,计算:
- db: 每个滤波器的偏置梯度是dout中对应滤波器的所有位置梯度之和。
- dw: 每个滤波器的权重梯度是输入对应区域与dout对应位置的乘积之和(按通道求和)。
- dx: 输入梯度(填充后的)是每个位置由权重与dout对应位置的乘积累加(注意多个滤波器对同一个输入区域的影响需要累加)。
具体实现:
前向传播时,我们计算了:
out[n, f, i, j] = np.sum(x_padded[n, :, h_start:h_end, w_start:w_end] * w[f]) + b[f]
反向传播时:
- 偏置b的梯度:db[f] = sum_{n,i,j} dout[n, f, i, j]
- 权重w的梯度:对于每个滤波器f,dw[f] = sum_{n,i,j} (x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]),注意这里是对所有样本和所有位置的累加。
- 输入x的梯度:对于每个样本n,每个位置(i,j)和每个滤波器f,dout[n,f,i,j]会乘以滤波器w[f]的每个权重,然后累加到输入x_padded对应的区域[n, :, h_start:h_end, w_start:w_end]上。由于同一个输入位置可能被多个输出位置和多个滤波器使用,因此需要累加。
注意:dx是计算在填充后的输入上的,最后需要去掉填充部分,得到原始输入的梯度。
实现细节:
1. 获取缓存中的值:x, w, b, conv_param = cache
2. 获取参数:pad = conv_param['pad'], stride = conv_param['stride']
3. 初始化梯度:
dx = np.zeros_like(x)
dw = np.zeros_like(w)
db = np.zeros_like(b)
4. 对输入x进行填充(和正向传播一样),但这里我们不需要填充后的输入数据,而是需要填充后的梯度数组(初始化为0),然后我们将梯度累加到这个填充数组上,最后再裁剪掉填充部分得到dx。
因此,我们创建一个填充后的dx_padded:
dx_padded = np.pad(dx, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant', constant_values=0)
5. 获取输入和输出的尺寸:
N, C, H, W = x.shape
F, _, HH, WW = w.shape
_, _, H_out, W_out = dout.shape
6. 计算db:
for f in range(F):
db[f] = np.sum(dout[:, f, :, :])
7. 计算dw和dx_padded:
遍历样本n,滤波器f,以及输出位置i, j:
# 定位输入窗口
h_start = i * stride
h_end = h_start + HH
w_start = j * stride
w_end = w_start + WW
# 当前梯度值:dout[n, f, i, j] 是一个标量
# 对于dw[f]:累加 x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]
# 对于dx_padded:将 w[f] * dout[n, f, i, j] 累加到 dx_padded[n, :, h_start:h_end, w_start:w_end]
注意:前向传播时我们使用了填充后的输入x_padded,这里反向传播同样操作在填充后的梯度dx_padded上。
8. 由于我们遍历了所有位置,所以dw[f]已经累积了所有样本和所有位置。但注意,在实现时,我们可以在循环中累加。
9. 最后,从dx_padded中裁剪出未填充的部分,得到dx:
dx = dx_padded[:, :, pad:pad+H, pad:pad+W]
但是注意:前向传播时我们使用了填充,而反向传播时,我们计算的是原始输入(未填充)的梯度,所以填充部分不应该保留。因此,我们通过裁剪得到dx。
然而,我们也可以先初始化一个与x形状相同的dx(全0),然后创建一个填充版本的dx_padded(与正向传播的x_padded形状相同),然后我们在循环中更新dx_padded,最后裁剪得到dx。
具体代码结构:
步骤:
- 恢复缓存
- 获取参数
- 初始化梯度
- 填充dx为dx_padded(初始为0)
- 计算db
- 循环计算dw和dx_padded
- 裁剪dx_padded得到dx
注意:由于输入x可能有填充,而dx的梯度只需要原始区域,所以最后裁剪。
但是,在循环中,我们需要前向传播时的填充输入x_padded吗?在反向传播中,计算dw时,我们需要前向传播的输入数据(因为dw = x * dout),所以我们需要缓存x_padded吗?
实际上,在正向传播中,我们缓存的是原始的x(未填充),所以这里我们需要重新填充?或者我们在正向传播时缓存了填充后的x_padded?
回顾正向传播函数conv_forward_naive,我们缓存的是(x, w, b, conv_param),其中x是原始输入。所以这里我们需要重新填充原始输入x,用于计算dw(因为dw需要乘以输入数据)。
因此,在反向传播中,我们需要重新进行填充:
x_padded = np.pad(x, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant')
但是,为了避免重复计算,我们可以在正向传播时缓存填充后的x_padded。然而,在作业中,正向传播缓存的是(x, w, b, conv_param),所以我们只能重新填充。
所以,在反向传播中:
x_padded = np.pad(x, ((0,0),(0,0),(pad,pad),(pad,pad)), mode='constant', constant_values=0)
然后,我们使用这个x_padded来计算dw。
然而,我们也可以不缓存填充后的输入,因为我们可以通过原始输入和pad参数重新构建。这样,在反向传播中,我们重新填充x得到x_padded。
因此,具体步骤调整:
1. 从缓存中取出x(原始输入,未填充),w, b, conv_param。
2. 获取pad, stride。
3. 填充x:x_padded = np.pad(...) [用于计算dw]
4. 初始化dx:np.zeros_like(x),然后创建dx_padded = np.pad(dx, ...) [注意:这里我们初始化为0,然后累加,最后裁剪]
但是,注意:dx_padded的初始值应该是0,然后我们通过循环将每个位置的反向传播梯度累加进去。
代码实现:
由于我们需要计算三个梯度:dx, dw, db。
初始化:
db = 前面计算的方式
dw = np.zeros_like(w)
dx = np.zeros_like(x) # 然后创建dx_padded = np.pad(dx, ...) [注意:这样dx_padded的初始值就是0,且形状是填充后的]
然后循环:
for n in range(N): # 每个样本
for f in range(F): # 每个滤波器
for i in range(H_out):
for j in range(W_out):
# 计算窗口位置
h_start = i * stride
h_end = h_start + HH
w_start = j * stride
w_end = w_start + WW
# 计算dw[f]:需要x_padded的对应窗口
# 注意:dout[n, f, i, j] 是一个标量
dw[f] += x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]
# 计算dx_padded:将w[f]乘以dout[n,f,i,j]加到dx_padded的对应窗口
dx_padded[n, :, h_start:h_end, w_start:w_end] += w[f] * dout[n, f, i, j]
最后,从dx_padded中取出未填充部分:
dx = dx_padded[:, :, pad:pad+H, pad:pad+W]
但是,注意:这样实现会有四重循环,效率较低,但题目要求naive实现,所以可以接受。
另外,db的计算可以不用循环,但这里我们也可以放在循环外,用向量化方式计算。
优化db计算:
db = np.sum(dout, axis=(0,2,3)) # 按样本、高度、宽度求和,保留滤波器维度
因此,我们可以不用循环f来计算db。
总结代码:
注意:输入x的尺寸是(N, C, H, W),dout的尺寸是(N, F, H_out, W_out)
步骤:
pad = conv_param['pad']
stride = conv_param['stride']
N, C, H, W = x.shape
F, C, HH, WW = w.shape
H_out = (H + 2 * pad - HH) // stride + 1
W_out = (W + 2 * pad - WW) // stride + 1
# 检查dout的尺寸
assert dout.shape == (N, F, H_out, W_out)
# 对输入x进行填充(用于计算dw)
x_padded = np.pad(x, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant', constant_values=0)
# 初始化梯度
dx = np.zeros_like(x)
dw = np.zeros_like(w)
db = np.zeros_like(b)
# 创建dx的填充版本(初始为0),用于累加
dx_padded = np.pad(dx, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant', constant_values=0)
# 计算db: 对每个滤波器f,对dout的所有样本和所有空间位置求和
db = np.sum(dout, axis=(0,2,3)) # 形状为(F,)
# 四重循环
for n in range(N):
for f in range(F):
for i in range(H_out):
for j in range(W_out):
# 定位窗口
h_start = i * stride
h_end = h_start + HH
w_start = j * stride
w_end = w_start + WW
# 计算dw[f]: 累加 x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]
dw[f] += x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]
# 计算dx_padded: 累加 w[f] * dout[n, f, i, j]
dx_padded[n, :, h_start:h_end, w_start:w_end] += w[f] * dout[n, f, i, j]
# 裁剪dx_padded得到dx
dx = dx_padded[:, :, pad:pad+H, pad:pad+W]
返回dx, dw, db
注意:我们这里使用了两个填充数组:x_padded(用于计算dw)和dx_padded(用于计算dx)。x_padded是前向传播时的输入填充,而dx_padded是反向传播时用于累加梯度的填充数组。
但是,我们也可以不用dx_padded,而是直接操作dx的填充版本?我们上面就是这样做的。
然而,还有一种更节省内存的方法:我们不需要一开始就创建dx_padded,而是在循环中直接操作一个填充数组,最后再裁剪。我们上面的做法就是先创建了一个填充数组(初始为0),然后累加,最后裁剪。
这个实现是直接的,但效率不高。不过题目要求naive实现,所以可以接受。
测试:运行数值梯度检查。
注意:由于循环较多,对于大尺寸输入可能会很慢,但测试时使用小尺寸数据。
最后返回 (dx, dw, db)
代码实现如下:</think>### 卷积层反向传播实现
在 `cs231n/layers.py` 中实现卷积层的反向传播函数 `conv_backward_naive`:
```python
def conv_backward_naive(dout, cache):
"""
实现卷积层的反向传播
输入:
- dout: 上游梯度 (N, F, H', W')
- cache: 前向传播时缓存的元组 (x, w, b, conv_param)
返回:
- dx: 输入数据梯度 (N, C, H, W)
- dw: 滤波器权重梯度 (F, C, HH, WW)
- db: 偏置项梯度 (F,)
"""
# 从缓存中获取前向传播数据
x, w, b, conv_param = cache
pad = conv_param.get('pad', 0)
stride = conv_param.get('stride', 1)
# 获取各维度尺寸
N, C, H, W = x.shape
F, _, HH, WW = w.shape
_, _, H_out, W_out = dout.shape
# 初始化梯度
dx = np.zeros_like(x)
dw = np.zeros_like(w)
db = np.zeros_like(b)
# 对输入进行填充(与前向传播一致)
x_padded = np.pad(x, ((0, 0), (0, 0), (pad, pad), (pad, pad)), mode='constant')
dx_padded = np.pad(dx, ((0, 0), (0, 0), (pad, pad), (pad, pad)), mode='constant')
# 反向传播计算
for n in range(N): # 遍历每个样本
for f in range(F): # 遍历每个滤波器
for i in range(H_out): # 遍历输出高度
for j in range(W_out): # 遍历输出宽度
# 计算输入窗口位置
h_start = i * stride
h_end = h_start + HH
w_start = j * stride
w_end = w_start + WW
# 计算偏置梯度
db[f] += dout[n, f, i, j]
# 计算权重梯度
dw[f] += x_padded[n, :, h_start:h_end, w_start:w_end] * dout[n, f, i, j]
# 计算输入梯度
dx_padded[n, :, h_start:h_end, w_start:w_end] += w[f] * dout[n, f, i, j]
# 从填充梯度中提取有效部分
dx = dx_padded[:, :, pad:pad+H, pad:pad+W]
return dx, dw, db
```
### 反向传播关键步骤说明:
1. **梯度初始化**:
- `dx`: 输入数据梯度,形状与原始输入 `x` 相同
- `dw`: 滤波器权重梯度,形状与权重 `w` 相同
- `db`: 偏置项梯度,形状与偏置 `b` 相同
2. **填充处理**:
- 前向传播中的填充需要在反向传播中保持一致
- 创建填充版本的 `dx_padded` 用于中间计算
3. **梯度计算**:
- **偏置梯度 (db)**: 累加所有位置的上游梯度
- **权重梯度 (dw)**: 输入数据窗口 × 上游梯度
- **输入梯度 (dx)**: 滤波器权重 × 上游梯度
4. **梯度累积**:
- 每个输出位置对梯度的贡献需要累加
- 同一输入位置可能被多个输出位置使用
### 数值梯度检查方法:
```python
from cs231n.gradient_check import eval_numerical_gradient_array
# 生成随机测试数据
x = np.random.randn(2, 3, 8, 8)
w = np.random.randn(5, 3, 3, 3)
b = np.random.randn(5)
dout = np.random.randn(2, 5, 4, 4)
conv_param = {'stride': 2, 'pad': 1}
# 前向传播获取缓存
_, cache = conv_forward_naive(x, w, b, conv_param)
# 计算解析梯度
dx, dw, db = conv_backward_naive(dout, cache)
# 数值梯度检查
dx_num = eval_numerical_gradient_array(lambda x: conv_forward_naive(x, w, b, conv_param)[0], x, dout)
dw_num = eval_numerical_gradient_array(lambda w: conv_forward_naive(x, w, b, conv_param)[0], w, dout)
db_num = eval_numerical_gradient_array(lambda b: conv_forward_naive(x, w, b, conv_param)[0], b, dout)
# 比较误差
print('dx 误差:', rel_error(dx_num, dx))
print('dw 误差:', rel_error(dw_num, dw))
print('db 误差:', rel_error(db_num, db))
```
### 反向传播计算要点:
1. **链式法则应用**:输出梯度 → 权重/输入梯度
2. **位置对应关系**:输出位置 (i,j) 对应输入区域 (h_start:h_end, w_start:w_end)
3. **梯度累加原则**:每个输出位置对输入/权重梯度的贡献需要累加
4. **填充处理**:梯度计算需考虑前向传播的填充,最后去除填充