本篇文章仅作为个人记录用途,不提供任何知识内容正确性的保障。若文章有问题,请联系我并告知,感激不尽。

本篇学习笔记的所有内容都基于https://www.learnpytorch.io/

本文主要内容:PyTorch建立模型的基本流程。

1. 数据

在machine learning中,数据是你能够想象到的任何行。任何形式,例如表格、图片、视频或音频都是数据。
Machine learning有两部分:

  1. 将你的数据,无论它是什么,转化为数字
  2. 挑选或构建一个模型,来尽可能的进行学习
    但是我们现在没有任何数据,因此我们需要创造一些。现在我们来创建我们的数据,是一条直线。
    我们将用linear regression来创建有未知参数的数据,然后用PyTorch来看看是否可以用gradient descent方法进行对这些参数的估计。
import torch

# Create known parameters
weight = 0.7
bias = 0.3

# Create data
start = 0
end = 1
step = 0.02
X = torch.arange(start, end, stop).unsqueeze(dim=1)
y = weight * X + bias

X[:10], y[:10] # check first ten data

接下来,我们会构建一个模型,来学习X(features)和y(labels)的关系。

2. 将数据拆分为训练与测试集

在构建模型之前,我们需要将数据进行拆分。机器学习中最重要的一步之一,就是构建训练和测试集(training and test set),有时也有validation set。
这三种数据集有不同的目的:

数据集目的数据占比使用频率
训练集(training set)让模型从数据中学习60-80%总是需要
验证集(validation set)让模型根据这些数据进行调整10-20%有时需要
测试集(testing set)对模型进行评估10-20%总是需要

接下来,我们只会用到训练集与测试集,我们可以通过拆分Xy张量来创建他们。

train_split = int(0.8 * len(X)) # 80% of data used for traning set, 20% for testing set

X_train, y_train = X[:train_split], y[:train_split]
X_test, y_test = X[train_split:], y[train_split:]

len(X_train), len(y_train), len(X_test), len(y_test)

# output: (40, 40, 10, 10)

我们现在的训练集有40个样本,测试集有10个样本。接下来我们创建一个函数将他们可视化。

import matplotlib.pyplot as plt
def plot_predictions(train_data=X_train,
    train_labels=y_train,
    test_data=X_test,
    test_labels=y_test,
    predictions=None):
    plt.figure(figsize=(10, 7))
    # Plot training data in blue
    plt.scatter(train_data, train_labels, c='b', s=4, label='Training data')
    # Plot test data in green
    plt.scatter(test_data, test_labels, c='g', s=4, label='Testing data')
    if predictions is not None:
        plt.scatter(test_data, predictions, c='r', s=4, label='Predictions')
    # show the legend
    plt.legend(prop={'size': 14});
plot_predictions()

我们就能看到我们的数据是一条直线,训练集为蓝色,测试集为绿色!

3. 创建模型

现在我们有了数据,接下来我们要创建模型,用蓝色的点(训练集)来预测绿色的点(测试集)。

3.1 复习:Python中的OOP

在这一部分,我发现有大量的OOP相关内容,如果没有很好的掌握Python OOP,看起来会非常吃力。而我对于这个知识已经忘记的差不多了,因此就顺便进行复习吧!(这一部分在参考网站中是没有的,但我在这里也仅仅是过一遍)

  1. objects and class

    class BankAccount:
     currency = 'HKD'
     def __init__(self, customer, account_number, balance=0):
         self.customer = customer
         self.account_number = =account_number
         self.balance = balance

    在这段代码中我们能看到创建了一个名为BankAccount的class,并使用def __init__(self, ...)进行初始化。初始化中,定义了很多属性,包括:customer, account_number, balance
    接下来:

     def deposit(self, amount):
     if amount > 0:
         self.balance += amount
     else:
         print('Invalid deposit amount:', amount)

    这里我们定义了一个方法(method),注意定义方法的时候,需要加入self,但方法中调用属性并不需要在括号中添加,只需要self.attribute,这是因为传入的self这个实例已经带有所有属性,且可修改。

  2. methods and atrributes
    在上面的程序中,customer, account_numberbalance是属性。
    deposit是方法。
    def check_balance(self):
    print('The balance of account number {:d} is {:s}{:.2f}'.format(self.account_number, self.currency, self.balance))

这样我们又定义了一个方法,这个方法没有任何输入的值,但调用了实例的属性。

  1. inheritance and polymorphism
    一个子类(subclass)可以被如下定义:
    class SubClass(BaseClass1, BaseClass2, ...):
    这里有一个例子:

    class SavingAccount(BankAccount):
     def __init__(self, customer, account_number, interest_rate, balance=0):
         self.interest_rate = interest_rate
         super().__init__(customer, account_number, balance)
     
     def add_interest(self):
         self.balance *= (1. + self.interest_rate / 100)

在上面的代码中,我们定义了一个BankAccount的子类SavingAccount

  • 子类可以继承父类的所有属性/方法
  • 子类可以新增自己的属性/方法
  • 子类可以重写(override)父类已有的方法

在上面的代码中:class SavingAccount(BankAccount):就说明,SavingAccount自动自动拥有 currencycustomeraccount_numberbalancedeposit()check_balance()等方法和属性。
def __init__(...)是override,因为这个子类需要一个新的属性interest_rate
self.interest_rate = interest_rate先新增了自己的属性
super().__init__(customer, account_number, balance)负责继承父类的三个属性。

def add_interest(self)为新增的方法。

方法重写:子类可以重写父类的方法,使其名称相同,但行为不同。

我觉得到继承这里就差不多了,可以直接开始学习新知识了。关于OOP的更多知识这里就不展开了。

PyTorch构建模型

PyTorch已经有4个必要的模块,你可以用他们来创建任何你可以想想到的神经网络。
他们是:
torch.nn, torch.optim, torch.utils.data.Dataset, torch.utils.data.DataLoader
接下来我们将关注于前两个,进行说明。

  1. torch.nn
    torch.nn包含所有computational graphs所需的模块。
  2. torch.nn.Parameter
    储存可以用在nn.Module中的tensor。如果requires_grad=True,则会自动计算梯度(gradient),也叫做"autograd"。
  3. torch.nn.Module
    这是所有神经网络模块的基础,所有神经网络的构建模块都是其子类。如果你要在PyTorch中构建神经网络,你的模型应该继承nn.Module,需要实现forward()方法。
  4. torch.optim
    包含很多优化算法,这些算法能够告诉模型的存储在nn.Parameter中的参数如何进行最好的改变,以减少损失。
  5. def forward()
    所有nn.Module的子类都需要一个forward()方法,该方法定义了对传递给特定nn.Module的数据进行计算。

以上的看起来非常复杂,我在这里是看不懂的。接下来我尝试稍微理解一下:
torch.nn包含来创建神经网络所需要的所有模块,这些模块进行组合就能够搭建任意复杂的神经网络。(例如包括线性层nn.Linear,激活函数nn.ReLU等等),因此你需要from torch import nn
nn.Parameter是一种特殊的tensor,将tensor用这个进行包装后,PyTorch就知道这个张量是模型的参数,可以在训练中被更新,PyTorch也就能自动帮助他们计算梯度。
nn.module当你在建立一个class的时候需要继承的。
torch.optim中包含很多优化方法,可以将nn.Parameter中的参数进行优化。
forward()会告诉PyTorch如何计算数据,由于mm.Module是通用模版,因此在自己设定的时候需要进行重写(override)。

在这里,torch.nntorch.optim是模块,nn.Module是类,很容易搞混关系。
可以看看这个关系图(ChatGPT):

torch
├── nn
│   ├── Module          ← 所有模型的基类
│   ├── Linear          ← 一种层
│   ├── MSELoss         ← 一种损失函数
│   └── ...
└── optim
    ├── SGD             ← 优化器
    ├── Adam
    └── ...

如果还没完全理解,接下来我们用一段代码的例子理解以上几个的用法以及关系。

import torch
from torch import nn
# Create linear regression model class
class LinearRegressionModel(nn.Module):
    # Almost everything of machine learning is in nn.Module
    # So we inherite it.
    def __init__(self):
        super().__init__() #inherite all attributes and methods from nn
        self.weights = nn.Parameter(torch.randn(1,
                                                requires_grad=True,
                                                dtype=torch.float))
        # Create a parameter called weights using nn.Parameter
        # At first, it is random number, using torch.randn, rather than rand
        # This parameter could autgrad and its type is float
        self.bias = nn.Parameter(torch.randn(1,
                                             requires_grad=True,
                                             dtype=torch.float))
        # Same as weights, create a parameter called bias.
        # Forward method to define the computation in the model
    
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # self make forward could use properties in init
        # x is input data, and it's tensor
        # -> torch.Tensor means that this function return tensor
        return self.weights * x + self.bias
        # This is the linear regression formula

这样,基本上nn.Module, nn.Parameter, forward()的用法以及用途可以有更好的理解。

3.2 查看一个PyTorch模型的内容

接下来我们要创建一个用我们创建的类创建一个模型实例,并且用.parameters()查看其参数!

torch.manual_seed(42)

# Create a instance of the model, subclass of nn.module
model_0 = LinearRegressionModel()

#Check the nn.Parameter(s) within the nn.Module subclass we created
list(model_0.parameters())

# output: 
# [Parameter containing:
#  tensor([0.3367], requires_grad=True),
#  Parameter containing:
#  tensor([0.1288], requires_grad=True)]

.parameters()是继承自nn.Module父类的方法,可以返回模型里所有可训练的参数。list()是Python内置函数,可以讲可迭代对象(iterable)进行转换为列表。
因此list(model.parameters())可以将model这个模型中的所有参数列出。

.parameters()的用处很大,可以向优化器等中传递模型中的参数!

如果我们想要查看模型的状态(state),就可以使用.state_dict()

model_0.state_dict()

# output: OrderedDict([('weights', tensor([0.3367])), ('bias', tensor([0.1288]))])

.state_dict()可以让我们看到模型目前的状态,这个方法将状态打包为一个字典(dict)
这个字典中:key是参数的名字,如weights, bias。value对应着tensor。

torch.inference_mode()预测

# make predictions with model
with torch.interence_mode():
    y_preds = model_0(X_test)

看到这个代码,你可能会有很多疑问:

  1. with是什么?
  2. .interference_mode()不是一个函数吗?为什么后面能加:?
  3. LinearRegression这个子类中没有定义能传入任何参数,为什么model_0能够传入参数X_test
    接下来,我们就来一个一个解决这些问题。

首先with是Python的上下文管理器(context manager)语法,其作用是:在进入一段代码前执行一些准备操作,在离开这段代码时自动清理或恢复状态。
在这段代码中,torch.inference_mode()关闭了一些操作,如梯度追踪、autograd等,使得前向计算更快,而当缩进代码执行完毕后,这个状态被清理,恢复为原来的状态。
第二个问题也同理,是由于context manager的语法。

最后第三个问题,为何model_0可以传入参数。
这是由于在nn.Module父类中,定义了一个特殊方法__call__(),这个方法能让对象(实例)想函数一样被调用,也就允许向实例中传入参数。
传入的参数X_test,会变成forward()函数的输入!因此可以返回y_preds

值得注意的是,在nn.Module父类中,方法__call__()的定义为:(以下内容有ChatGPT提供)

# 省略了一些注释与额外逻辑
def __call__(self, *input, **kwargs):
    result = self.forward(*input, **kwargs)
    return result

torch.no_grad()的作用与torch.inference_mode()相同。

讲完这三个问题,我们也就可以理解这两行代码是如何运作,进行预测的了。

接下来我们看一看预测的结果:

# Check the predictions
print(f'Numbers of testing samples: {len(X_test)}')
print(f'Numbers of prediction made: {len(y_preds)}')
print(f'Predicted values:\n{y_preds}')
Output:
Numbers of testing samples: 10
Numbers of prediction made: 10
Predicted values:
tensor([[0.3982],
        [0.4049],
        [0.4116],
        [0.4184],
        [0.4251],
        [0.4318],
        [0.4386],
        [0.4453],
        [0.4520],
        [0.4588]])

接下来可以使用刚才的plot_predictions()函数进行可视化。

这里可以看到,红色的点全部在绿色的下面,可以说是一点都不沾边了,预测的相当差。
我们检查一下与真实值相差多少:

y_test - y_preds

'''
Output:
tensor([[0.4618],
        [0.4691],
        [0.4764],
        [0.4836],
        [0.4909],
        [0.4982],
        [0.5054],
        [0.5127],
        [0.5200],
        [0.5272]])
'''

可以说差的相当多了。但别忘记,我们现在还没有开始训练模型!
是时候进行改变了。

4. 训练模型

目前为止,我们的模型用随机的参数来计算预测,因此就是简单的随机猜数字。为了解决这个问题,我们可以不断对参数进行更新。
在这个例子中,我们知道参数weights=0.7, bias=0.3,但是大多数情况下,我们也不知道模型中的理想参数具体是多少。因此,需要写代码让模型自己寻找最佳的参数。

4.1 在PyTorch中创建损失函数(loss function)和优化器(optimizer)

为了让我们的模型能够自动更新参数,我们需要在模型训练中增加两样东西:损失函数与优化器。接下来我们先尝试理解这两个东西具体是什么。

损失函数:衡量你的模型的预测结果(例如y_preds)与真实值(例如y_test)差了多少,差得越少,模型越好。
torch.nn模块中,PyTorch内置来很多常见的损失函数。
例如:

优化器:告诉你的模型应该如何更新其参数以得到最低的损失。
torch.optim模块中,有很多内置类型:

  • SGD: torch.optim.SGD()
  • Adam: torch.optim.Adam()

如果你想了解更多关于Optimizer的内容,可以看这个网页:https://docs.pytorch.org/docs/stable/optim.html

这里,很容易搞混这几个模块与刚才的nn.Module的层级关系,可以去前面的“建造模型”部分查看。

回到我们的代码。
我们要用MAE(torch.nn.L1Loss())作为我们的损失函数。
MAE是Mean absolute error,测量与真实值的差值的绝对值。

优化器方面,我们要用SGD,torch.optim.SGD(params, lr)
其中:
param是需要优化的模型参数,如weightsbias
lr学习率(learning rate),简单来说,学习率决定了优化器在每次更新参数的时候,调整的幅度大大小。

  • 如果学习率过高,那么每一步调整太大,虽然学习的快,但有可能跳过最优解,导致无法找到合适的值。
  • 如果学习率过小,速度太慢,有可能没学到之前就停滞了。
  • 学习率是一个超参数(hyperparameter),是人手动设置的,不是模型学出来的。
  • 常见的起始学习率:简单线性回归:0.01,深度网络:0.001或更低
  • 学习率可以通过训练过程动态调整,叫做学习率调度(learning rate scheduling)

接下来我们直接开始创建损失函数&优化器

# Create a loss function
loss_fn = nn.L1Loss() 

# Create a optimizer
optimizer = torch.optim.SGD(params=model_0.parameters(),
                            lr = 0.01)

这里可能会感到疑惑:params不是可以更新的参数吗?为什么会是model_0.parameters()而不是model_0.weightsmodel_0.bias
这是因为有时候参数会很多,手动维护太麻烦了。
而我们前文讲到了.parameters()能够返回模型中所有参数!所以在这里会很好用。

注意nn.L1Loss()torch.optim.SGD()是两个,而loss_fnoptimizer是两个实例

4.2 在PyTorch中创建一个优化循环

接下来我们要创建优化循环(training loop and testing loop),来让模型不停的优化。
优化循环包括:训练虚幻与测试循环。

训练循环包括模型遍历训练数据集,并学习featureslabels之间的关系。
测试循环包括遍历模型测试集的数据,以及评估其学的多好。

4.3 创建一个训练循环(training loop)

对于一个训练循环,我们有以下几步需要做:

序号名称作用代码
1forward pass模型遍历所有训练数据一次,执行forward()函数计算model(x_train)
2计算损失模型的输出,也就是预测,与真实值相对比,并评估相差多少loss = loss_fn(y_pred, y_train)
3zero gradients优化器的梯度设置到0,以便针对特定的训练数据计算optimizer.zero_grad()
4对损失函数进行反向传播计算损失函数相对于每个参数(requires_grad=True)的梯度。这被称为反向传播。loss.backward()
5更新优化器更新参数optimizer.step()

以上是一个比较简洁的表格,介绍了五个步骤。接下来我详细的讲解一下这五个步骤,以便有一个更深刻的理解:

  1. Forward pass
    做什么:将输入给模型,按照forward()中的定义预测y_pred
    model(x_train)经过nn.Module.__call__调用到forward(x_train)
    参与计算、且requires_grad=True的张量会被记录到计算图中,供后面backward()使用。
  2. Calculate loss
    做什么:用存是函数比较预测的y_pred与标签(真实值)y_train,对差值进行评估。
  3. Zero gradients
    做什么:把上一轮积累在参数上的梯度清空。因为PyTorch的梯度默认累加,不清零的话会把多次的梯度叠在一起。
    调用optimizer这个实例中的zero_grad()方法
  4. Perform backpropagation on the loss
    做什么:依据链式法则,沿着计算图计算所有可训练参数相对于loss的梯度,填写到每个参数的.grad里。
    只有requires_grad=True的参数得到梯度。
  5. Optimizer step (gradient decent)
    做什么:优化器读取每个参数的.grad,按照选定算法(SGD)和学习率(lr)更新参数值,使得下次预测更加接近标签。
    这一步会修改参数数据本身(param.data),不会更改.grad

现在可能会遇到的问题:

计算图是什么?

loss是什么类型?
loss是一个特殊的张量,是一个torch.tensor对象。

梯度是什么?

参数的.grad是什么?存储在哪里?

目前这些问题的大部分回答我都看不懂,所以等以后再进行详细研究。
如果你现在就想彻底理解这些概念,这里有两个视频可能会有帮助:
https://www.youtube.com/embed/Ilg3gGewQ5U?si=Fbgzgf32uiJmDzA8
https://www.youtube.com/embed/IHZwWFHWa-w?si=tCI9FjDGaAvX_tbU

注意:以上的顺序是很灵活的,只需要符合几个规则就可以:

  • 在进行backpropagation(loss.backward())前计算loss(loss = ...)
  • 在计算损失的梯度(loss.backward())之前进行梯度清零(optimizer.zero_grad())
  • 在backpropagation(loss.backward())后更新优化器(optimizer.step())

4.4 创建一个测试循环(testing loop)

序号名称作用代码
1forward pass模型遍历所有测试数据,使用forward()进行计算model(x_test)
2计算损失模型的输出,也就是预测,与真实值相对比,并评估相差多少loss = loss_fn(y_pred, y_test)
3计算评估矩阵(optional)除了损失值意外,可能还需要计算其他指标,如准确率custom functions

注意:测试循环不包括backpropagation (loss.backward())或更新优化器(optimizer.step()),这是因为在测试循环中,没有参数被改变

接下来我们将代码运行:

torch.manual_seed(42)
# Set a number of epochs, this is also a hyperparameter
epochs = 100

# Create emply loss list to track values
train_loss_values = []
test_loss_values = []
epoch_count = []
for epoch in range(epochs):
    # Training
    
    # Set the model to training mode
    model_0.train()
     
    # 1. Forward pass
    y_pred = model_0(X_train)
    
    # 2. Calculate the loss
    loss = loss_fn(y_pred, y_train)
    
    # 3. Optimizer zero grad
    optimizer.zero_grad()
    
    # 4. Perform backpropagation
    loss.backward()
    
    # 5. Step the optimizer (gradient descent)
    optimizer.step()
    
    # Testing
    
    # Set the model to evaluating mode, turn off things
    model_0.eval()
    
    # Truns off gradient tracking & couple of other things
    with torch.inference_mode():
        # Do the forward pass
        test_pred = model_0(X_test)
        
        # Calculate the loss
        test_loss = loss_fn(test_pred, y_test.type(torch.float))
    
    if epoch % 10 == 0:
    epoch_count.append(epoch)
    train_loss_values.append(loss.detach().numpy())
    test_loss_values.append(test_loss.detach().numpy())
    print(f"Epoch: {epoch} | MAE Train Loss: {loss} | MAE Test Loss: {test_loss}")

接下来我尝试解释一下上面的代码中的training loop部分:
model_0.train():打开training mode,将所有需要gradient的参数设置为"require gradient",模型需要更新参数。在训练阶段使用。

model_0.eval():打开evaluating mode,关闭所有gradient tracking,模型只做预测。在测试/验证阶段使用。

loss = loss_fn(y_pred, y_train)loss_fn是我们之前定义的损失函数。注意这个函数中,需要先输入预测的结果,再输入目标。
(虽然由于我们用的是绝对值损失函数,因此真种情况下,顺序反了也没关系)

loss.backward()为backpropagation,这一步计算了梯度(gradient)。
optimizer.step()进行gradient descent,利用上一步中计算的gradient对参数进行更新。而参数更新后,梯度不会自动清零,而会进行累积。
因此需要optimizer.zero_grad()进行梯度清零。

ChatGPT生成的讲解图:

┌──────────────────────────────────────────────┐
│               ① Forward Pass                │
│ model(X_train) → y_pred                      │
│  模型进行前向传播,得到预测值。                   │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│               ② Compute Loss                │
│ loss = loss_fn(y_pred, y_train)              │
│  比较预测与真实值,计算损失。                     │
│  这一步不会计算梯度,只会建立计算图。              │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│               ③ Zero Grad                   │
│ optimizer.zero_grad()                        │
│  清空上一次迭代累积的梯度,避免梯度累加。           │
│  确保接下来计算的梯度只属于当前 batch。            │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│               ④ Backward Pass                │
│ loss.backward()                              │
│  反向传播:沿计算图计算所有参数的梯度,            │
│  将 ∂loss/∂param 存入 param.grad。            │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│               ⑤ Update Parameters            │
│ optimizer.step()                             │
│  优化器读取 param.grad,并更新参数:             │
│  param = param - lr * param.grad             │
│  不会计算梯度,只会修改参数的值。                  │
└──────────────────────────────────────────────┘

简单理解:
梯度(gradient):一个曲线的斜率
gradient descent就是让梯度最小(为0)->损失(loss)最小

上面的代码的输出结果大概会是这样:

'''
output:
Epoch: 0 | MAE Train Loss: 0.28983935713768005 | MAE Test Loss: 0.4541231691837311
Epoch: 10 | MAE Train Loss: 0.1746293306350708 | MAE Test Loss: 0.3194132149219513
Epoch: 20 | MAE Train Loss: 0.07638873159885406 | MAE Test Loss: 0.19773726165294647
Epoch: 30 | MAE Train Loss: 0.05069301277399063 | MAE Test Loss: 0.13647659122943878
Epoch: 40 | MAE Train Loss: 0.04463795945048332 | MAE Test Loss: 0.1100495308637619
Epoch: 50 | MAE Train Loss: 0.04098063334822655 | MAE Test Loss: 0.09699545800685883
Epoch: 60 | MAE Train Loss: 0.0375034399330616 | MAE Test Loss: 0.08666229248046875
Epoch: 70 | MAE Train Loss: 0.03407188132405281 | MAE Test Loss: 0.07907666265964508
Epoch: 80 | MAE Train Loss: 0.030638623982667923 | MAE Test Loss: 0.07080408930778503
Epoch: 90 | MAE Train Loss: 0.027199819684028625 | MAE Test Loss: 0.06253156810998917
'''

用可视化图像表示:

# Plot the loss curves
plt.plot(epoch_count, train_loss_values, label='Train loss')
plt.plot(epoch_count, test_loss_values, label='Test loss')
plt.title('Training and test loss curves')
plt.ylabel('Loss')
plt.xlabel('Epochs')
plt.legend();

5. 开始预测!(inference)

在上面的过程中,我们编写了优化循环,包括训练循环与测试循环。
我们训练完模型,接下来就需要让他进行预测了。

在我们让模型进行预测(也叫做performing inference)的时候,需要记住3件事情

  1. 将模型设置为evaluation mode (model.eval())
  2. 用inference mode context manager进行预测(with torch.inference_mode(): ...)
  3. 所有预测结果应该使用同一个设备上的对象进行(例如,数据与模型仅在GPU或者数据和模型仅在CPU中)
# 1. Set the model to evaluation mode
model_0.eval()

# 2. Set up the inference mode in context manager
with torch.inference_mode():
  y_preds_new = model_0(X_test)


y_preds_new

这个输出的结果大概是:

tensor([[0.8141],
        [0.8256],
        [0.8372],
        [0.8488],
        [0.8603],
        [0.8719],
        [0.8835],
        [0.8950],
        [0.9066],
        [0.9182]])

接下来可视化这些结果:

Plot_predictions(predictions=y_preds_new)

我们就可以看到我们现在预测的数据与之前相比已经接近很多了。

6. 保存并加载一个PyTorch模型(save and load)

现在我们已经训练了一个模型,接下来我们要对其进行保存并导出至其他地方。

对于保存和加载一个模型,PyTorch中一共有3种主要方法:

PyTorch Method作用
torch.save使用Python的pickle工具将序列化的对象保存到磁盘。可以使用torch.save保存模型、tensor以及其他Python对象,如字典
torch.load使用picle工具的反序列化功能,将序列化的对象文件反序列化并加载到内存中。您还可以设置对象加载到具体哪个设备(CPU/GPU)中。
torch.nn.Module.load_state_dict使用已经保存的state_dict()对象加载模型的参数字典(model.state_dict())

我看到这些描述后的问题就是: pickle是什么?
简单来说,pickle是Python自带的序列化(serialization)模块。它能够将Python中的对象(例如列表、字典,包括模型)保存到文件,再从文件中恢复过来。

6.1 保存一个PyTorch模型的state_dict()

对于保存与加载模型的一个推荐的方法就是通过保存和加载模型的state_dict()
我们首先来说一下这个过程的步骤:

  1. 使用Python的pathlib模块,创建一个字典,叫做models
  2. 创建一个文件路径(file path)来保存模型
  3. 调用torch.save(obj, f),其中obj是目标模型的state_dict()f是保存模型的文件名

注:PyTorch保存的模型或对象的文件通常以.pt.pth结尾,例如saved_model_01.pth

由于我没有学习过pathlib这个模块,所以看这部分的代码稍显吃力。

from pathlib import Path

# 1. Create model directory
MODEL_PATH = Path("models")
MODEL_PATH.mkdir(parents=True, exist_ok=True)

# 2. Create model save path
MODEL_NAME = '01_pytorch_workflow_model_0.pth'
MODEL_SAVE_PATH = MODEL_PATH / MODEL_NAME

# 3. Save the model state dict
print(f"Saving model to: {MODEL_SAVE_PATH}")
torch.save(obj=model_0.state_dict(), f=MODEL_SAVE_PATH)

# output: Saving model to: models/01_pytorch_workflow_model_0.pth

由于我没学过pathlib,因此我接下来借助ChatGPT的力量,解释一下这个代码的内容。

这个代码的整体目标,就是将model_0的参数weightsbias保存到硬盘上,以便以后进行使用,不需要重新训练。

第一步:创建文件保存目录
MODEL_PATH = Path(“models”)表示一个路径对象,对应当前文件夹下的models文件夹。
MODEL_PATH.mkdir(parents=True, exist_ok=True)表示创建了这个文件夹。

其中:
`parents=True`允许创建多级目录
`exist_ok=True`是如果文件夹已经存在,不报错

这两行代码的最终结果就是:在当前项目文件夹下创建了一个models文件夹。

第二步:创建模型保存路径
MODEL_NAME = ...:文件名
MODEL_SAVE_PATH = MODEL_PATH / MODEL_NAME:用pathlib/运算符拼接成完整路径
结果:`MODEL_SAVE_PATH = 'models/01_pytorch_workflow_model_0.pth'1st

第三步:将模型参数保存
print(f"Saving model to: {MODEL_SAVE_PATH}"):打印保存路径
torch.save(obj=model_0.state_dict(), f=MODEL_SAVE_PATH):保存模型参数

其中:
`model.state_dict()`返回一个字典,里面保存了模型参数
`torch.save()`将这个对象(字典)序列化并且保存在文件中,底层调用的是`pickle`模块

最终结果:

models/
└── 01_pytorch_workflow_model_0.pth

接下来,可以查看保存的文件路径:

!ls -l models/01_pytorch_workflow_model_0.pth

# output: -rw-r--r-- 1 root root 2117 Oct 31 09:56 models/01_pytorch_workflow_model_0.pth

6.2 加载一个已保存的PyTorch模型的state_dict()

现在我们在models/01_pytorch_workflow_model_0.pth已经有一个已保存的模型的参数,接下来我们要用torch.nn.Module.load_state_dict(torch.load(f))进行加载,其中f是我们保存模型参数的文件路径

为什么要在torch.nn.module.load_state_dict()中调用torch.load()
因为我们仅仅保存了一个模型的state_dict(),也就是这个模型的参数,而没有保存整个模型。首先,我们需要使用torch.load()加载模型的参数(state_dict()),然后传递到我们模型的新实例中(他是nn.module的子类)。

为什么不直接保存整个模型?
保存整个模型,而不仅仅保存参数(state_dict())显得更加直觉,然而,Pytorch官方并不推荐这样做。这是因为当保存整个模型的时候,PyTorch会将模型的类名、模块的完整导入途径以及各种信息一起打包。这意味着如果模型文件与你当时的代码绑定了,如果你将来想要更改模型类的名称】文件路径等等信息,都会报错。
因此只保存参数字典(state_dict())更加灵活,不依赖任何类名与代码结构。

接下来,我们就用LinearRegressionModel()的另一个实例,来用load_state_dict()导入参数。

# Create a new instance of our model
loaded_model_0 = LinearRegressionModel()

# Load the state_dict of our saved model
loaded_model_0.load_state_dict(torch.load(f=MODEL_SAVE_PATH))

# output: <All keys matched successfully>

我们先建立了一个新的实例,然后从MODEL_SAVE_PATH这个途径中导入了参数。

接下来我们用这个新的模型做一些预测(inference),看看是否与旧的模型相同:

loaded_model_0.eval()
with torch.inference_mode():
  loaded_y_preds = loaded_model_0(X_test)

loaded_y_preds == y_preds_new

# output: 
‘’’tensor([[True],
        [True],
        [True],
        [True],
        [True],
        [True],
        [True],
        [True],
        [True],
        [True]])
‘’’

我们可以看到,新模型与旧模型预测的数据是完全相同的,也就说明我们训练后的参数导入了新模型。

7. 总结

我们在这一节已经学习了相当多的内容,包括:
创建数据、拆分数据、创建模型、如何创建参数、如何创建loss function & optimizer,如何创建优化循环,如何用模型进行预测,以及如何将模型保存并加载。
总的来说内容真的相当多,并且某些过程现阶段还无法完全理解其原理。因此还需要记忆以及练习。

接下来总结一些需要注意的点:

  1. 在拆分数据的时候,如果想拆分成80%, 20%,那么train_split = int(0.8 * len(X)),注意要转换为整型!
  2. 在创建损失函数的时候多使用的函数属于nn,而不是torch!因此要写nn.L1Loss()
  3. 在创建优化器的时候,所传递的参数有:要修改的参数,也就是modol.parameters(),注意parameters()要小写!
  4. 在定义模型子类的时候,slef.weight = nn.Parameter(torch.randn(1, requires_grad=True, dtype=torch.float))requires_grad等要写在randn()括号里面,不要写在外面。

最后在给一下练习的答案,其实就是改了两个数字,但修改自己不看笔记重新写一遍,毕竟容易出问题的地方真是太多了。

import torch
from torch import nn
import matplotlib.pyplot as plt

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Device: {device}")

import matplotlib.pyplot as plt
def plot_predictions(train_data=X_train,
                    train_labels=y_train,
                    test_data=X_test,
                    test_labels=y_test,
                    predictions=None):
    plt.figure(figsize=(10, 7))
    # Plot training data in blue
    plt.scatter(train_data, train_labels, c='b', s=4, label='Training data')
    # Plot test data in green
    plt.scatter(test_data, test_labels, c='g', s=4, label='Testing data')
    if predictions is not None:
        plt.scatter(test_data, predictions, c='r', s=4, label='Predictions')
    # show the legend
    plt.legend(prop={'size': 14});
    
weight = 0.3
bias = 0.9
start = 0
end = 1
step = 0.01
X = torch.tensor(torch.arange(start, end, step)).unsqueeze(dim=1)
y = X * weight + bias

train_split = int(0.8 * len(X))
X_train = X[:train_split]
y_train = y[:train_split]
X_test = X[train_split:]
y_test = y[train_split:]

class LinearRegression(nn.Module):
    def __init__(self):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(1, requires_grad=True, dtype=torch.float))
        self.bias = nn.Parameter(torch.randn(1, requires_grad=True, dtype=torch.float))
    
    def forward(self, x):
        return x * self.weight + self.bias
    
model = LinearRegression()
L_fn = nn.L1Loss()
optimizer = torch.optim.SGD(params=model.parameters(), lr=0.001)

epochs = 10000
for i in range(epochs):
    model.train()
    y_pred_train = model(X_train)
    loss = L_fn(y_pred_train, y_train)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    model.eval()
    with torch.inference_mode():
        y_pred_test = model(X_test)
        loss = L_fn(y_pred_test, y_test)
    
    if i % 1000 == 0:
        print(f"weight: {model.weight}, bias: {model.bias}, loss: {loss}")

model.eval()
with torch.inference_mode():
    y_prediction = model(X_test)
plot_predictions(predictions=y_prediction)

代码本身应该没什么问题,但plot_predictions函数定义部分,直接复制的话可能有缩进格式问题,需要手动修改。