训练就是在调参数,而深度学习之所以能够成功,关键就是反向传播,也就是所谓的链式法则求梯度,这样我们才可以更新参数。不然的话,深度学习发展不起来,起码没有反向传播的话,无法到达今天的参数量规模,而且是无法想象那种。
在之前的学习中,我们都是直接用PyTorch的autograd自动算梯度的。我们要来学习反向梯度,就完全不用autograd和.backward()了,自己手写梯度计算。
这里使用MNIST数据,手写一个 2 层的 MLP (784-> 128 -> 10)的前向传播 + 反向传播 + 参数更新。
数据加载
import torchimport torch.nn.functional as Ffrom torch.utils.data import DataLoaderfrom torchvision import datasets, transforms
def load_mnist(batch_size=64): transform = transforms.Compose([ transforms.ToTensor(), # 28x28 -> (1,28,28),像素 0~1 transforms.Normalize((0.1307,), (0.3081,)), # 标准化 ]) train_set = datasets.MNIST(root="./data", train=True, download=True, transform=transform) test_set = datasets.MNIST(root="./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True) test_loader = DataLoader(test_set, batch_size=256) return train_loader, test_loader
定义模型
class TwoLayerMLP
: def __init__(self, in_dim=784, hidden_dim=128, out_dim=10): pass
def to(self, device): pass
def forward(self, X): pass
def backward(self, X, logits, y): pass
def update(self, lr=0.01): pass
骨架就是这样子:
__init__初始化参数,所谓的模型,就是一堆参数。
所以这里我们都自己写的话,可以说我们从零开始,手写实现深度学习的训练,这对于理解深度学习,是非常好的。
初始化参数
def __init__(self, in_dim=784, hidden_dim=128, out_dim=10): # 用 Xavier 类似的初始化:使梯度不至于一开始就消失/爆炸 self.W1=torch.randn(in_dim,hidden_dim)*(2.0/(in_dim+hidden_dim)**0.5) self.b1 = torch.zeros(hidden_dim) self.W2=torch.randn(hidden_dim,out_dim)*(2.0/(hidden_dim+out_dim)**0.5) self.b2 = torch.zeros(out_dim) # 梯度缓存(前向时存中间量,反传时写梯度) self.gradW1 = None self.gradb1 = None self.gradW2 = None self.gradb2 = None
# 前向中间量缓存 self._cache = None
这里三部分,第一部分是参数,虽然经常说从随机数开始,但不是只用随机数。因为一层网络的计算就是个加权求和。那么结果会随in_dim增加,输出Z的方差量级可能会太大,一层层传下去,要么爆炸要么消失。所以后面要接个缩放因子。
我们在学正态分布的时候,sd的缩放不就是 。这里是一样的,只考虑进去的,就是1/sqrt(in),考虑两头就是 1/sqrt(in + out)。 乘以 2 是微调,当然也不是随便乘的,是按激活函数匹配方差的关键常数,现在代码中的计算,就接近于 Xavier 初始化。
第二部分就是缓存梯度,这个要在反向的时候计算。
第三部分是前向传播的时候,缓存中间量,就是每一层 Z = W * x + b 和 A = activation(Z)这种。在反向传播的时候,需要拿来计算的。
参数搬家
def to(self, device): self.W1 = self.W1.to(device) self.b1 = self.b1.to(device) self.W2 = self.W2.to(device) self.b2 = self.b2.to(device) return self
这里因为我们是自己写的,没有用 nn.Module,当然也就没有它的 .to()了,所以需要自己写。
前向传播
def forward(self, X): Z1 = X @ self.W1 + self.b1 A1 = torch.relu(Z1) Z2 = A1 @ self.W2 + self.b2 self._cache = (Z1, A1, Z2)
return Z2
前向传播,返回logits,中间量缓存。
反向传播
def backward(self, X, y): Z1, A1, Z2 = self._cache N = X.size(0) Y = F.one_hot(y, num_classes=10).float() dL_dZ2 = (torch.softmax(Z2, dim=1) - Y) / N dL_dW2 = A1.T @ dL_dZ2 dL_db2 = dL_dZ2.sum(0)
dL_dA1 = dL_dZ2 @ self.W2.T dL_dZ1 = dL_dA1 * (Z1 > 0)
dL_dW1 = X.T @ dL_dZ1 dL_db1 = dL_dZ1.sum(0)
self.gradW1 = dL_dW1 self.gradb1 = dL_db1 self.gradW2 = dL_dW2 self.gradb2 = dL_db2
这里数据是手写数字分类,最后这一层用softmax,是每一种输出的概率,它是一个向量,而Y是一个标签,我们要把它变成一个向量,是这一类的,它的位置是1,其它位置是0。
dL_dZ2的计算:
交叉熵:
softmax 的雅可比:
两者相乘时 与 抵消,交叉项收拢,且 ,最后只剩:
dL_dW2的计算:
前向的时候是Z2 = A1 @ self.W2 + self.b2 ,就相当于W2进行线性变换得到Z2,那么我们算出了Z2的微分之后,就反过来给线性变换回去,所以就是:dL_dW2 = A1.T @ dL_dZ2。
dL_dA1的计算也是一样的道理。而dL_db2和dL_db1这个就是直接对所有样本求和。
而 dL_dZ1 = dL_dA1 * (Z1 > 0)这个是按照ReLU来算的,如果是不同的激活函数,算法也不一样。现在用ReLU比较多,除了计算比sigmod要简单之外,sigmoid还有一个问题,就是sigmoid 导数最大只有 0.25,多层连乘会让梯度指数变小(梯度消失)。ReLU 在激活区导数为 1,不会被乘小,当然ReLU也有自己的坑。
更新参数
def update(self, lr=0.01): self.W1 -= lr * self.gradW1 self.b1 -= lr * self.gradb1 self.W2 -= lr * self.gradW2 self.b2 -= lr * self.gradb2
反向已经算好的前提下,更新就是减掉学习率与梯度的乘积。
训练与评估
def train_one_epoch(model, loader, lr, device): total_loss = 0.0 for X, y in loader: X, y = X.to(device), y.to(device) X = X.view(X.size(0), -1) logits = model.forward(X) model.backward(X, y) model.update(lr) loss = F.cross_entropy(logits, y) total_loss += loss.item() return total_loss / len(loader)
遍历所有 batch,每次前向,再反传,最后更新参数。
其中 X = X.view(X.size(0), -1) 这句是用来展平数据的,X原来是 (N, 1, 28, 28),要变成模型期望的 (N, 784)。
后面的loss是为了让我们观察loss变化。
def evaluate(model, loader, device): correct, total = 0, 0 with torch.no_grad(): for X, y in loader: X,y=X.to(device), y.to(device) X = X.view(X.size(0), -1) logits = model.forward(X) correct+=(logits.argmax(dim=1)==y).sum().item() total += y.size(0) return correct / total * 100
评估就是前向传播,模型返回logits,用argmax返回预测类别。最后返回一个准确率。
最后跑一下:
def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"使用设备: {device}\n")
train_loader, test_loader = load_mnist() model = TwoLayerMLP(784, 128, 10).to(device) # 自定义类的 .to() 我们手写了
num_epochs = 10 lr = 0.1 for epoch in range(1, num_epochs + 1): loss = train_one_epoch(model, train_loader, lr, device) acc = evaluate(model, test_loader, device) print(f"Epoch {epoch:2d}/{num_epochs} | 训练损失: {loss:.4f} | 测试准确率: {acc:.2f}%")
print(f"\n训练完成!最终准确率: {acc:.2f}%")
if __name__ == "__main__": main()
输出是:
使用设备: cuda
Epoch 1/10 | 训练损失: 0.2346 | 测试准确率: 96.23%Epoch 2/10 | 训练损失: 0.1053 | 测试准确率: 96.69%Epoch 3/10 | 训练损失: 0.0736 | 测试准确率: 97.16%Epoch
4/10 | 训练损失: 0.0574 | 测试准确率: 97.32%Epoch 5/10 | 训练损失: 0.0446 | 测试准确率: 97.22%Epoch 6/10 | 训练损失: 0.0352 | 测试准确率: 97.52%Epoch 7/10 | 训练损失: 0.0282 | 测试准确率: 97.71%Epoch 8/10 | 训练损失: 0.0230 | 测试准确率: 97.66%Epoch 9/10 | 训练损失: 0.0181 | 测试准确率: 97.30%Epoch 10/10 | 训练损失: 0.0148 | 测试准确率: 97.79%
训练完成!最终准确率: 97.79%
梯度校验
我自己写了反向传播计算梯度,但是我到底对不对?那么就可以用 torch.autograd 来当正确答案,来比较一下。
def gradient_check(model, X, y): W1 = model.W1.detach().clone().requires_grad_(True) b1 = model.b1.detach().clone().requires_grad_(True) W2 = model.W2.detach().clone().requires_grad_(True) b2 = model.b2.detach().clone().requires_grad_(True)
Z1 = X @ W1 + b1 A1 = torch.relu(Z1) Z2 = A1 @ W2 + b2 loss = F.cross_entropy(Z2, y) loss.backward()
model.forward(X) model.backward(X, y)
for (name, ref, mine) in [("W1", W1, model.gradW1), ("b1",b1, model.gradb1), ("W2",W2, model.gradW2), ("b2",b2, model.gradb2)]: diff = (ref.grad - mine).abs().max().item() rel = diff / ref.grad.abs().max().item() print(f"{name}: 最大绝对误差={diff:.3e}, 相对误差={rel:.3e}")
这里要把当前参数复制一份,然后打开requires_grad 让 torch 反传。中间就是一样的前向,loss.backward() 就是让 autograd算出来梯度。
然后用模型里写的前向和反向再跑一下,就得到了手工实现版本计算的梯度。
后面就是比较一下。
要实际跑一下就是:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")train_loader, test_loader = load_mnist()X, y = next(iter(train_loader))X, y = X.to(device), y.to(device)X = X.view(X.size(0), -1)model = TwoLayerMLP(784, 128, 10).to(device) gradient_check(model, X, y)
我们加载训练集,然后拿到一个batch,初始化模型,把模型和数据传进去比较,最后结果是:
W1: 最大绝对误差=5.960e-08, 相对误差=1.472e-07b1: 最大绝对误差=1.490e-08, 相对误差=6.603e-08W2: 最大绝对误差=
2.384e-07, 相对误差=1.552e-07b2: 最大绝对误差=7.451e-09, 相对误差=1.761e-08