社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  机器学习算法

深度学习连载12:反向传播

Y叔 余光创 • 16 小时前 • 21 次点击  

训练就是在调参数,而深度学习之所以能够成功,关键就是反向传播,也就是所谓的链式法则求梯度,这样我们才可以更新参数。不然的话,深度学习发展不起来,起码没有反向传播的话,无法到达今天的参数量规模,而且是无法想象那种。

在之前的学习中,我们都是直接用PyTorch的autograd自动算梯度的。我们要来学习反向梯度,就完全不用autograd和.backward()了,自己手写梯度计算。

这里使用MNIST数据,手写一个 2 层的 MLP (784-> 128 -> 10)的前向传播 + 反向传播 + 参数更新。


数据加载

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
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

定义模型

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
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__初始化参数,所谓的模型,就是一堆参数。
  • to是把参数搬到 GPU 或 CPU
  • forward就是前向传播
  • backward就是反向传播
  • update就是更新参数

所以这里我们都自己写的话,可以说我们从零开始,手写实现深度学习的训练,这对于理解深度学习,是非常好的。

初始化参数

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
    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)这种。在反向传播的时候,需要拿来计算的。

参数搬家

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
    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()了,所以需要自己写。

前向传播

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
    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,中间量缓存。

反向传播

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
    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也有自己的坑。

更新参数

  • 1
  • 2
  • 3
  • 4
  • 5
    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

反向已经算好的前提下,更新就是减掉学习率与梯度的乘积。

训练与评估

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
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变化。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
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返回预测类别。最后返回一个准确率。

最后跑一下:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
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()

输出是:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
使用设备: 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 来当正确答案,来比较一下。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
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算出来梯度。

然后用模型里写的前向和反向再跑一下,就得到了手工实现版本计算的梯度。

后面就是比较一下。

要实际跑一下就是:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
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,初始化模型,把模型和数据传进去比较,最后结果是:

  • 1
  • 2
  • 3
  • 4
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

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/202460