神经网络梯度计算

• 117 min read • 23369 words
Tags: Deep Learning NLP
Categories: NLP

本文章是对 Gradient Notes 的整理与简单实现。

数学部分生成:Gemini-2.5-pro, 代码部分+整理:fyerfyer

神经网络梯度计算

1. 向量化梯度

虽然计算神经网络相对于单个参数的梯度是一个很好的练习,但在实践中,这样做往往相当缓慢。相反,将所有内容保持为矩阵/向量形式会更有效率。向量化梯度的基本构建模块是雅可比矩阵

假设我们有一个函数 f:RnRmf: \mathbb{R}^n \rightarrow \mathbb{R}^m,它将一个长度为 nn 的向量映射到一个长度为 mm 的向量:f(x)=[f1(x1,...,xn),f2(x1,...,xn),...,fm(x1,...,xn)]f(x) = [f_1(x_1, ..., x_n), f_2(x_1, ..., x_n), ..., f_m(x_1, ..., x_n)]。那么,它的雅可比矩阵是一个 m×nm \times n 的矩阵,定义如下:

fx=[f1x1f1xnfmx1fmxn]\frac{\partial f}{\partial x} = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \cdots & \frac{\partial f_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial f_m}{\partial x_1} & \cdots & \frac{\partial f_m}{\partial x_n} \end{bmatrix}

也就是说,(fx)ij=fixj(\frac{\partial f}{\partial x})_{ij} = \frac{\partial f_i}{\partial x_j}(这只是一个标准的非向量导数)。雅可比矩阵对我们很有用,因为我们只需通过乘以雅可比矩阵,就可以将链式法则应用于向量值函数

举个例子,假设我们有一个函数 f(x)=[f1(x),f2(x)]f(x) = [f_1(x), f_2(x)],它将一个标量映射到一个大小为 2 的向量;还有一个函数 g(y)=[g1(y1,y2),g2(y1,y2)]g(y) = [g_1(y_1, y_2), g_2(y_1, y_2)],它将一个大小为 2 的向量映射到一个大小为 2 的向量。现在,我们将它们复合起来得到 g(f(x))=[g1(f1(x),f2(x)),g2(f1(x),f2(x))]g(f(x)) = [g_1(f_1(x), f_2(x)), g_2(f_1(x), f_2(x))]。使用常规的链式法则,我们可以计算 gg 的导数(即雅可比矩阵):

gx=[g1f1f1x+g1f2f2xg2f1f1x+g2f2f2x]\frac{\partial g}{\partial x} = \begin{bmatrix} \frac{\partial g_1}{\partial f_1} \frac{\partial f_1}{\partial x} + \frac{\partial g_1}{\partial f_2} \frac{\partial f_2}{\partial x} \\ \frac{\partial g_2}{\partial f_1} \frac{\partial f_1}{\partial x} + \frac{\partial g_2}{\partial f_2} \frac{\partial f_2}{\partial x} \end{bmatrix}

我们可以看到,这与乘以两个雅可比矩阵的结果是相同的

gx=gffx=[g1f1g1f2g2f1g2f2][f1xf2x]\frac{\partial g}{\partial x} = \frac{\partial g}{\partial f} \frac{\partial f}{\partial x} = \begin{bmatrix} \frac{\partial g_1}{\partial f_1} & \frac{\partial g_1}{\partial f_2} \\ \frac{\partial g_2}{\partial f_1} & \frac{\partial g_2}{\partial f_2} \end{bmatrix} \begin{bmatrix} \frac{\partial f_1}{\partial x} \\ \frac{\partial f_2}{\partial x} \end{bmatrix}

2. 一些恒等式的计算

(1) 矩阵乘以列向量对列向量的导数 (z=Wxz = Wx,求 zx\frac{\partial z}{\partial x}?)

假设 WRn×mW \in \mathbb{R}^{n \times m}。我们可以将 zz 看作一个函数,它将一个 mm 维向量映射到一个 nn 维向量。所以它的雅可比矩阵将是 n×mn \times m。注意:

zi=k=1mWikxkz_i = \sum_{k=1}^{m} W_{ik} x_k

所以,雅可比矩阵的一个元素 (zx)ij(\frac{\partial z}{\partial x})_{ij} 将是:

(zx)ij=zixj=xjk=1mWikxk=k=1mWikxkxj=Wij(\frac{\partial z}{\partial x})_{ij} = \frac{\partial z_i}{\partial x_j} = \frac{\partial}{\partial x_j} \sum_{k=1}^{m} W_{ik} x_k = \sum_{k=1}^{m} W_{ik} \frac{\partial x_k}{\partial x_j} = W_{ij}

因为当 k=jk=jxkxj=1\frac{\partial x_k}{\partial x_j} = 1,否则为 0。所以我们看到:

zx=W\frac{\partial z}{\partial x} = W


(2) 行向量乘以矩阵对行向量的导数 (z=xWz = xW,求 zx\frac{\partial z}{\partial x}?)

与 (1) 类似的计算表明:

zx=WT\frac{\partial z}{\partial x} = W^T


(3) 向量与自身的导数 (z=xz = x,求 zx\frac{\partial z}{\partial x}?)

我们有 zi=xiz_i = x_i。所以:

(zx)ij=zixj=xixj={1if i=j0otherwise(\frac{\partial z}{\partial x})_{ij} = \frac{\partial z_i}{\partial x_j} = \frac{\partial x_i}{\partial x_j} = \begin{cases} 1 & \text{if } i=j \\ 0 & \text{otherwise} \end{cases}

所以我们看到雅可比矩阵 zx\frac{\partial z}{\partial x} 是一个对角矩阵,其中 (i,i)(i, i) 处的元素为 1。这正是单位矩阵:

zx=I\frac{\partial z}{\partial x} = I

在应用链式法则时,这一项会消失,因为任何矩阵或向量乘以单位矩阵都不会改变。


(4) 逐元素函数应用于向量 (z=f(x)z = f(x),求 zx\frac{\partial z}{\partial x}?)

由于 ff 是逐元素应用的,我们有 zi=f(xi)z_i = f(x_i)。所以:

(zx)ij=zixj=f(xi)xj={f(xi)if i=j0otherwise(\frac{\partial z}{\partial x})_{ij} = \frac{\partial z_i}{\partial x_j} = \frac{\partial f(x_i)}{\partial x_j} = \begin{cases} f'(x_i) & \text{if } i=j \\ 0 & \text{otherwise} \end{cases}

所以我们看到雅可比矩阵 zx\frac{\partial z}{\partial x} 是一个对角矩阵,其中 (i,i)(i, i) 处的元素是应用于 xix_iff 的导数。我们可以写成:

zx=diag(f(x))\frac{\partial z}{\partial x} = \text{diag}(f'(x))

由于乘以一个对角矩阵等同于逐元素乘以其对角线,我们也可以在应用链式法则时写作 f(x)\circ f'(x)


(5) 矩阵乘以列向量对矩阵的导数 (z=Wx,δ=Jzz = Wx, \delta = \frac{\partial J}{\partial z},求 JW=JzzW\frac{\partial J}{\partial W} = \frac{\partial J}{\partial z} \frac{\partial z}{\partial W}?)

这比其他恒等式要复杂一些。在上面的问题表述中包含 δ=Jz\delta = \frac{\partial J}{\partial z} 的原因稍后会变得清晰。

首先,假设我们有一个损失函数 JJ(一个标量),并且我们正在计算它关于矩阵 WRn×mW \in \mathbb{R}^{n \times m} 的梯度。我们可以将 JJ 看作是 WW 的一个函数,它接受 nmnm 个输入(WW 的元素)到一个输出(JJ)。这意味着雅可比矩阵 JW\frac{\partial J}{\partial W} 将是一个 1×nm1 \times nm 的向量。但在实践中,这种排列梯度的方式不是很有用。如果导数能在一个像下面这样的 n×mn \times m 矩阵中会好得多:

JW=[JW11JW1mJWn1JWnm]\frac{\partial J}{\partial W} = \begin{bmatrix} \frac{\partial J}{\partial W_{11}} & \cdots & \frac{\partial J}{\partial W_{1m}} \\ \vdots & \ddots & \vdots \\ \frac{\partial J}{\partial W_{n1}} & \cdots & \frac{\partial J}{\partial W_{nm}} \end{bmatrix}

由于这个矩阵的形状与 WW 相同,我们在进行梯度下降时,只需从 WW 中减去它(乘以学习率)。因此(在稍微滥用符号的情况下),让我们找到这个矩阵作为 JW\frac{\partial J}{\partial W}

当计算 zW\frac{\partial z}{\partial W} 时,这种排列梯度的方式会变得复杂。与 JJ 不同,zz 是一个向量。因此,如果我们试图将梯度排列得像 JW\frac{\partial J}{\partial W} 那样,zW\frac{\partial z}{\partial W} 将会是一个 n×m×nn \times m \times n 的张量!幸运的是,我们可以通过转而计算关于单个权重 WijW_{ij} 的梯度来避免这个问题。zWij\frac{\partial z}{\partial W_{ij}} 只是一个向量,处理起来容易得多。我们有:

zk=l=1mWklxlz_k = \sum_{l=1}^{m} W_{kl} x_l zkWij=Wijl=1mWklxl\frac{\partial z_k}{\partial W_{ij}} = \frac{\partial}{\partial W_{ij}} \sum_{l=1}^{m} W_{kl} x_l

注意 WklWij=1\frac{\partial W_{kl}}{\partial W_{ij}} = 1 如果 k=ik=il=jl=j,否则为 0。因此,如果 kik \neq i,和中的所有项都为零,梯度也为零。否则,和中唯一非零的元素是当 l=jl=j 时,我们得到 xjx_j。因此我们发现 zkWij=xj\frac{\partial z_k}{\partial W_{ij}} = x_j 如果 k=ik=i,否则为 0。另一种写法是:

zWij=[0xj0]i-th element\frac{\partial z}{\partial W_{ij}} = \begin{bmatrix} 0 \\ \vdots \\ x_j \\ \vdots \\ 0 \end{bmatrix} \leftarrow i \text{-th element}

现在让我们计算 JWij\frac{\partial J}{\partial W_{ij}}

JWij=JzzWij=δzWij=k=1mδkzkWij=δixj\frac{\partial J}{\partial W_{ij}} = \frac{\partial J}{\partial z} \frac{\partial z}{\partial W_{ij}} = \delta \frac{\partial z}{\partial W_{ij}} = \sum_{k=1}^{m} \delta_k \frac{\partial z_k}{\partial W_{ij}} = \delta_i x_j

(和中唯一的非零项是 δiziWij\delta_i \frac{\partial z_i}{\partial W_{ij}})。为了得到 JW\frac{\partial J}{\partial W},我们需要一个矩阵,其中 (i,j)(i, j) 处的元素是 δixj\delta_i x_j。这个矩阵等于外积:

JW=δxT\frac{\partial J}{\partial W} = \delta x^T


(6) 行向量乘以矩阵对矩阵的导数 (z=xW,δ=Jzz = xW, \delta = \frac{\partial J}{\partial z},求 JW=δzW\frac{\partial J}{\partial W} = \delta \frac{\partial z}{\partial W}?)

与 (5) 类似的计算表明:

JW=xTδ\frac{\partial J}{\partial W} = x^T \delta


(7) 交叉熵损失对 logits 的导数 (y^=softmax(θ),J=CE(y,y^)\hat{y} = \text{softmax}(\theta), J = \text{CE}(y, \hat{y}),求 Jθ\frac{\partial J}{\partial \theta}?)

梯度是:

Jθ=y^y\frac{\partial J}{\partial \theta} = \hat{y} - y

(如果 yy 是列向量,则为 (y^y)T(\hat{y} - y)^T)。

3. 神经网络示例

我们计算一个使用交叉熵损失训练的单层神经网络的梯度。模型的前向传播过程如下:

  • x=inputx = \text{input}
  • z=Wx+b1z = Wx + b_1
  • h=ReLU(z)h = \text{ReLU}(z)
  • θ=Uh+b2\theta = Uh + b_2
  • y^=softmax(θ)\hat{y} = \text{softmax}(\theta)
  • J=CE(y,y^)J = \text{CE}(y, \hat{y})

a.a. 数学推导

将模型分解为可能的最简单的部分是有帮助的,所以请注意我们定义了 zzθ\theta 来将网络层中的线性变换与激活函数分开。模型参数的维度是:

xRDx×1b1RDh×1WRDh×Dxb2RNc×1URNc×Dhx \in \mathbb{R}^{D_x \times 1} \quad b_1 \in \mathbb{R}^{D_h \times 1} \quad W \in \mathbb{R}^{D_h \times D_x} \quad b_2 \in \mathbb{R}^{N_c \times 1} \quad U \in \mathbb{R}^{N_c \times D_h}

其中 DxD_x 是我们输入的大小,DhD_h 是我们隐藏层的大小,NcN_c 是类的数量。

在这个例子中,我们将计算网络的所有梯度:

JU,Jb2,JW,Jb1,Jx\frac{\partial J}{\partial U}, \frac{\partial J}{\partial b_2}, \frac{\partial J}{\partial W}, \frac{\partial J}{\partial b_1}, \frac{\partial J}{\partial x}

首先, ReLU(x)=max(x,0)\text{ReLU}(x) = \max(x, 0)。这意味着:

ReLU(x)={1if x>00otherwise=sgn(ReLU(x))\text{ReLU}'(x) = \begin{cases} 1 & \text{if } x > 0 \\ 0 & \text{otherwise} \end{cases} = \text{sgn}(\text{ReLU}(x))

其中 sgn 是符号函数。注意,我们能够用激活函数本身来表示激活函数的导数。

现在让我们写出 JU\frac{\partial J}{\partial U}Jb2\frac{\partial J}{\partial b_2} 的链式法则:

JU=Jy^y^θθU\frac{\partial J}{\partial U} = \frac{\partial J}{\partial \hat{y}} \frac{\partial \hat{y}}{\partial \theta} \frac{\partial \theta}{\partial U} Jb2=Jy^y^θθb2\frac{\partial J}{\partial b_2} = \frac{\partial J}{\partial \hat{y}} \frac{\partial \hat{y}}{\partial \theta} \frac{\partial \theta}{\partial b_2}

注意 Jy^y^θ=Jθ\frac{\partial J}{\partial \hat{y}} \frac{\partial \hat{y}}{\partial \theta}=\frac{\partial J}{\partial \theta} 在两个梯度中都存在。这使得数学计算有点繁琐。更糟糕的是,如果我们不使用自动微分来实现模型,计算 y^θ\frac{\partial \hat{y}}{\partial \theta} 两次会很低效。因此,定义一些变量来表示中间导数会很有帮助

δ1=Jθδ2=Jz\delta_1 = \frac{\partial J}{\partial \theta} \quad \quad \delta_2 = \frac{\partial J}{\partial z}

这些可以被认为是反向传播时传递给 θ\thetazz 的误差信号。我们可以如下计算它们:

  • δ1=Jθ=(y^y)T\delta_1 = \frac{\partial J}{\partial \theta} = (\hat{y} - y)^T
    • 这就是恒等式 (7)
  • δ2=Jz=Jθθhhz\delta_2 = \frac{\partial J}{\partial z} = \frac{\partial J}{\partial \theta} \frac{\partial \theta}{\partial h} \frac{\partial h}{\partial z}
    • 使用链式法则
  • δ2=δ1θhhz\delta_2 = \delta_1 \frac{\partial \theta}{\partial h} \frac{\partial h}{\partial z}
    • 代入 δ1\delta_1=
  • δ2=δ1Uhz\delta_2 = \delta_1 U \frac{\partial h}{\partial z}
    • 使用恒等式 (1)
  • δ2=δ1UReLU(z)\delta_2 = \delta_1 U \circ \text{ReLU}'(z)
    • 使用恒等式 (4)
  • δ2=δ1Usgn(h)\delta_2 = \delta_1 U \circ \text{sgn}(h)
    • 我们之前计算过这个

现在我们可以使用误差项来计算我们的梯度。注意,当为列向量项计算梯度时,我们转置我们的答案以遵循形状约定。

  • JU=JθθU=δ1ThT\frac{\partial J}{\partial U} = \frac{\partial J}{\partial \theta} \frac{\partial \theta}{\partial U} = \delta_1^T h^T
    • 使用恒等式 (5)
  • Jb2=Jθθb2=δ1T\frac{\partial J}{\partial b_2} = \frac{\partial J}{\partial \theta} \frac{\partial \theta}{\partial b_2} = \delta_1^T
    • 使用恒等式 (3) 并转置
  • JW=JzzW=δ2TxT\frac{\partial J}{\partial W} = \frac{\partial J}{\partial z} \frac{\partial z}{\partial W} = \delta_2^T x^T
    • 使用恒等式 (5)
  • Jb1=Jzzb1=δ2T\frac{\partial J}{\partial b_1} = \frac{\partial J}{\partial z} \frac{\partial z}{\partial b_1} = \delta_2^T
    • 使用恒等式 (3) 并转置
  • Jx=Jzzx=(δ2W)T\frac{\partial J}{\partial x} = \frac{\partial J}{\partial z} \frac{\partial z}{\partial x} = (\delta_2 W)^T
    • 使用恒等式 (1) 并转置

b.b. 代码实现

整个过程可以被拆分成下面的层:

  1. LinearReLULayer: 处理 z=Wx+b1z = Wx + b_1h=ReLU(z)h = \text{ReLU}(z)
  2. LinearLayer: 处理 θ=Uh+b2\theta = Uh + b_2
  3. SoftmaxCrossEntropyLoss: 处理 y^=softmax(θ)\hat{y} = softmax(\theta)J=CE(y,y^)J = CE(y, \hat{y})
i.i. LinearReLULayer

这个类合并了两个数学步骤:

  • z=Wx+b1z = Wx + b_1 (线性变换)
  • h=ReLU(z)h = \text{ReLU}(z) (ReLU激活)

前向传播

def forward(self, x, W, b):
    # z = Wx + b_1
    z = W @ x + b
    # h = ReLU(z)
    h = np.maximum(0, z)
    # Cache values needed for backpropagation
    self.cache['x'] = x
    self.cache['W'] = W
    self.cache['z'] = z 
    return h

反向传播

反向传播的目标是计算损失 JJWWb1b1 的梯度 dWdWdb1db1,以及将梯度传导到上一层(计算 dxdx)。它接收一个参数 dhdh,这是后一层传过来的梯度。

def backward(self, dh):
	# Retrieve cached values from the forward pass
	z = self.cache['z']
	x = self.cache['x']
	W = self.cache['W']
    
	# 1. Compute the gradient of ReLU
	# dReLU/dz = 1 if z > 0, else 0
	drelu = np.where(z > 0, 1, 0)
    
	# 2. Apply the chain rule to propagate the gradient from h to z
	# dJ/dz = dJ/dh * dh/dz
	dz = dh * drelu
    
	# 3. Compute gradients for parameters W and b
	# dJ/dW = dJ/dz * dz/dW = dz * x^T
	dW = dz @ x.T
	# dJ/db = dJ/dz * dz/db = dz * 1
	db = dz
    
	# 4. Compute and return the gradient to be passed to the previous layer
	# dJ/dx = dJ/dz * dz/dx = W^T * dz
	dx = W.T @ dz
    
	return dx, dW, db
ii.ii. LinearLayer

这个类只实现了一个数学步骤:θ=Uh+b2θ = Uh + b_2。它的 forwardbackward 方法与 LinearReLULayer 中的线性部分原理完全相同,只是没有 ReLU 激活。

class LinearLayer:
  	"""A linear output layer"""
    def __init__(self):
    	self.cache = {}

    def forward(self, h, U, b):
    	"""h: (H, 1), U: (C, H), b: (C, 1) -> o: (C, 1)"""
    	o = U @ h + b
    	self.cache['h'] = h
    	self.cache['U'] = U
    	return o

    def backward(self, do):
    	"""do: (C, 1) -> dh: (H, 1), dU: (C, H), db: (C, 1)"""
    	h = self.cache['h']
    	U = self.cache['U']
        
    	dU = do @ h.T
    	db = do
    	dh = U.T @ do
        
    	return dh, dU, db
iii.iii. SoftmaxCrossEntropyLoss

这个类合并了最后两个数学步骤:y^=softmax(θ)\hat{y} = \text{softmax}(\theta)J=CE(y,y^)J = \text{CE}(y, \hat{y})

前向传播

def forward(self, o, y):
	# o_stable prevents overflow in exp(o)
	o_stable = o - np.max(o)
	exp_o = np.exp(o_stable)
	# ŷ = softmax(θ)
	y_hat = exp_o / np.sum(exp_o)
  
	# J = CE(y, ŷ) = -Σ y_i * log(ŷ_i)
	loss = -np.sum(y * np.log(y_hat + 1e-9))
  
	# Cache ŷ and y for backpropagation
	self.cache['y_hat'] = y_hat
	self.cache['y'] = y
	return loss

反向传播

反向传播中,我们使用先前的公式 dθ=y^yd\theta=\hat{y}-y 来计算出 dθd\thetadθd\theta会传给 LinearLayer 的后向传播方法,作为它的输入 dθd\theta

def backward(self):
    y_hat = self.cache['y_hat']
    y = self.cache['y']
    # dJ/dθ = ŷ - y
    do = y_hat - y
    return do
iv.iv. 训练流程整合

整个神经网络的训练过程如下:

  1. 初始化: 创建 W1, b1, W2, b2 这些需要学习的参数。
  2. 实例化层: layer1, layer2, loss_fn
  3. 训练循环:

前向传播:

h = layer1.forward(x_train, W1, b1)  # x -> h
o = layer2.forward(h, W2, b2)        # h -> o (θ)
loss = loss_fn.forward(o, y_train)   # o -> loss (J)

这里完全按照数学公式的顺序,将数据从头到尾计算一遍,得到最终的损失。

反向传播:

这是最关键的一步。梯度从后往前传播。loss_fn 首先计算出初始梯度 do,然后把它传给 layer2layer2 利用 do 计算出自己内部参数的梯度 dW2, db2,同时计算出需要传给前一层的梯度 dhlayer1 再利用 dh 计算出自己参数的梯度 dW1, db1

do = loss_fn.backward()              # dJ/dθ
dh, dW2, db2 = layer2.backward(do)   # dJ/dθ -> dJ/dh, dJ/dW2, dJ/db2
dx, dW1, db1 = layer1.backward(dh)   # dJ/dh -> dJ/dx, dJ/dW1, dJ/db1

参数更新:

我们用计算出的梯度来更新参数。我们希望损失 J 变小,所以参数要向着梯度的反方向移动一小步(步长由 learning_rate 控制)。

W1 -= learning_rate * dW1
b1 -= learning_rate * db1
W2 -= learning_rate * dW2
b2 -= learning_rate * db2

完整程序如下:

if __name__ == '__main__':
  # 1. Define network dimensions and hyperparameters
    D_in = 10  # Input dimension
    H = 64     # Hidden layer dimension
    D_out = 5  # Number of output classes
    learning_rate = 0.01
    epochs = 200

    # 2. Initialize parameters
    W1 = np.random.randn(H, D_in) * 0.1
    b1 = np.zeros((H, 1))
    W2 = np.random.randn(D_out, H) * 0.1
    b2 = np.zeros((D_out, 1))

    # 3. Initialize network layers
    layer1 = LinearReLULayer()
    layer2 = LinearLayer()
    loss_fn = SoftmaxCrossEntropyLoss()

    # 4. Generate a random training sample
    x_train = np.random.randn(D_in, 1)
    y_true_index = np.random.randint(0, D_out) # Randomly select a true class
    y_train = np.zeros((D_out, 1))
    y_train[y_true_index] = 1
    
    print(f"Input dimension: {D_in}, Hidden layer dimension: {H}, Number of output classes: {D_out}")
    print(f"Randomly generated true class index: {y_true_index}")
    print(f"Training started, total {epochs} epochs...")

    # 5. Training loop
    for epoch in range(epochs):
        # a. Forward pass
        h = layer1.forward(x_train, W1, b1)
        o = layer2.forward(h, W2, b2)
        loss = loss_fn.forward(o, y_train)

        if epoch % 20 == 0:
            print(f"Epoch {epoch}, Loss: {loss:.6f}")

        # b. Backward pass
        do = loss_fn.backward()
        dh, dW2, db2 = layer2.backward(do)
        dx, dW1, db1 = layer1.backward(dh)

        # c. Gradient descent parameter update
        W1 -= learning_rate * dW1
        b1 -= learning_rate * db1
        W2 -= learning_rate * dW2
        b2 -= learning_rate * db2

    print("\nTraining complete!")
    # Check final loss and prediction
    final_h = layer1.forward(x_train, W1, b1)
    final_o = layer2.forward(final_h, W2, b2)
    final_loss = loss_fn.forward(final_o, y_train)
    final_prediction = np.argmax(final_o)

4. 通过维度匹配推导梯度

在进行复杂的梯度计算时,一个非常强大且实用的技巧是检查并匹配维度。这个方法不仅可以作为复杂推导的健全性检查,甚至可以在不进行详细的逐元素推导的情况下,帮助我们直接得出正确的梯度表达式。

其核心思想非常简单:损失函数 JJ (一个标量)对某个参数矩阵 WW 的梯度 JW\frac{\partial J}{\partial W},其维度必须与 WW 本身的维度完全相同。这是梯度下降更新规则 Wnew=WoldαJWW_{new} = W_{old} - \alpha \frac{\partial J}{\partial W} 能够成立的基础。

让我们重新审视前面的一些例子,看看如何应用这个技巧。

a.a. 重访恒等式 z=Wxz = Wx

  • 目标: 计算 JW\frac{\partial J}{\partial W}
  • 已知维度:
    • W:Dh×DxW: D_h \times D_x
    • x:Dx×1x: D_x \times 1
    • z:Dh×1z: D_h \times 1
  • 梯度维度:
    • 根据维度匹配原则,JW\frac{\partial J}{\partial W} 的维度必须是 Dh×DxD_h \times D_x
    • 反向传播传来的上游梯度是 δ2=Jz\delta _2 = \frac{\partial J}{\partial z}。在代码实现中,这通常是一个维度为 Dh×1D_h \times 1 的列向量。
  • 推导:
    • 我们需要组合上游梯度 δ2\delta _2 (维度 Dh×1D_h \times 1) 和前向传播中的输入 xx (维度 Dx×1D_x \times 1),来得到一个维度为 Dh×DxD_h \times D_x 的矩阵。
    • 唯一能实现这个维度转换的运算是外积:δ2xT\delta _2 x^T
    • 检查维度:(Dh×1)(1×Dx)(Dh×Dx)(D_h \times 1) (1 \times D_x) \rightarrow (D_h \times D_x)
    • 这与我们代码实现中的 dW = dz @ x.T 完全吻合,也与原始推导 δ2TxT\delta _2^T x^T (假设 δ2\delta _21×Dh1 \times D_h 的行向量)的结果在维度上是一致的。

b. 重访神经网络示例中的 JU\frac{\partial J}{\partial U}

  • 目标: 计算 JU\frac{\partial J}{\partial U}
  • 已知维度:
    • U:Nc×DhU: N_c \times D_h
    • h:Dh×1h: D_h \times 1
    • theta:Nc×1theta: N_c \times 1
  • 梯度维度:
    • JU\frac{\partial J}{\partial U} 的维度必须是 Nc×DhN_c \times D_h
    • 上游梯度是 δ1=Jθ\delta_1 = \frac{\partial J}{\partial \theta},其维度为 Nc×1N_c \times 1
  • 推导:
    • 我们需要组合 δ1\delta_1 (维度 Nc×1N_c \times 1) 和 hh (维度 Dh×1D_h \times 1),得到一个 Nc×DhN_c \times D_h 的矩阵。
    • 同样,唯一可行的方式是外积:δ1h.T\delta _1 h.T
    • 检查维度:(Nc×1)(1×Dh)(Nc×Dh)(N_c \times 1) (1 \times D_h) \rightarrow (N_c '\times D_h)
    • 这也与我们代码实现中的 dU = do @ h.T 完全一致。

c.c. 总结

当对梯度计算公式感到困惑时,不妨退后一步,检查你所拥有的各个变量(通常是上游梯度和前向传播的输入)的维度。然后,思考如何通过合法的矩阵运算(矩阵乘法、转置、外积等)将它们组合起来,以得到一个与要求解的参数梯度维度相匹配的结果。在绝大多数情况下,只有一种组合方式是正确的。这个简单的技巧可以帮我们避免许多常见的错误。