文章
努力加载图片中...
【学习笔记】刘二《PyTorch深度学习实践》- 2
  • 6481 字

  • 9 分钟

  • 14 次

  • 2026-06-29
标签:

1 逻辑斯蒂回归

1.1 逻辑斯蒂回归学习

逻辑斯蒂回归(Logistic Regression),虽然叫回归,但实际做的是分类。

在数字手写识别中,模型的最终目的是将图片手写字,输出为最符合的数字(1、2、3、4 等等)。也就是说 y^\hat{y} 的值域是 {1,2,3,...,9}\left \{ 1, 2, 3, ..., 9 \right \} ,是具体的数字。

但是对于线性回归模型来说很难根据输入内容输出到具体的值。因为在手写识别场景下,模型做的不是数值的计算,而是概率的计算

输入一张图片给模型,模型计算这个图片属于这些值的概率,最终输出的是概率最大的那个值,这其实就是分类问题。

所以,得寻找一个方法,能够将某个预测值,映射到 [0,1]\left [0, 1 \right] 这个概率区间。所以这里,就需要用到逻辑斯蒂公式了:

σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}}

这个公式也叫 Sigmoid 函数。从公式可以看出 x+,y1x \rightarrow +\infty, y \rightarrow 1x=0,y=0.5x = 0, y = 0.5x,y0x \rightarrow -\infty, y \rightarrow 0。也就是说这个函数值域在 (0,1)\left (0, 1 \right) 这个区间。

其曲线是这样的: image.png

观察曲线,当 x 小于某个值时,y 趋近于 0,当 x 大于某个值时,y 趋近于 1,并且当 y 趋近某个值(0 或 1)时,导数越小。根据这个特性,如果画出导数的图,就可以发现其类似正态分布。这种函数叫做饱和函数

介绍完了这个函数,那我们通过这个函数,将模型的预测值,映射到 [0,1]\left [0, 1 \right] 这个概率区间呢?很简单,将 x 替换成 y^\hat{y} 就行了:

σ(y^)=11+ey^\sigma(\hat{y}) = \frac{1}{1 + e^{-\hat{y}}}

这样嵌套进来的函数,也称激活函数。由于增加了一层逻辑斯蒂函数,所以计算图需要增加一层: image.png

回到线性回归模型,最初的线性回归模型是根据学习时长 x,预测考试分数 y。为了学习逻辑斯蒂回归,我们需要将这个问题转变为分类问题。假设考试分数大于 6 分,就是考试合格。那么考试合格(Pass)与不合格(Fail),就是两个分类,我们记 Pass 为 1,Fail 为 0,这样就将该问题转变为分类问题了。

由于上述问题的类别只有两个,因此也称为二分类问题(Binary Classification)。

在原先的预测考试分数的线性回归问题中,损失函数是 loss=(y^y)2loss = (\hat{y} - y)^2 ,但是这个损失函数在分类问题是不适用的。

因为这个损失函数计算的是两个函数值之间的差,而到了概率层面,我们并不需要计算两个概率之间的差。那怎么计算损失呢?

仔细想想,在原先的预测考试分数的线性回归问题之中,我们为什么计算两个函数值之间的差?是因为我们要知道模型的预测值与真实值之间的差异

对于概率来说,真实的概率分布是 0 和 1,而预测的概率分布,当然就是 0 和 1 之间的任何数。也就是说,我们要衡量模型输出的概率分布与真实分布之间的差异。在概率学当中,有一个名为交叉熵(Cross-Entropy)的公式,就是用于计算两个概率分布之间的差异的,公式为:

LCE=i=1nyilog(y^i)L_{CE} = -\sum_{i=1}^{n} y_i \log(\hat{y}_i)

其中 yiy_i 是真实标签,y^i\hat{y}_i 是模型预测的概率分布,nn 是分类类别的数量。

在二分类问题中,由于真实标签的分布概率不是 0 就是 1,因此公式可以简化为:

loss=(ylogy^+(1y)log(1y^))loss = -(y \log\hat{y} + (1 - y) \log(1 - \hat{y}) )

为什么能这么简化?我们来做一个简单的推导: 对于二分类问题,其分类类别为 2 ,所以 n=2n=2 ,得到:

loss=(y1logy1^+y2logy2^)loss = -(y_1 \log\hat{y_1} + y_2 \log\hat{y_2} )

对于两个类别的真实标签的概率 yiy_i 来说,y1=1y2y_1 = 1 - y_2 。 同样的,预测值也是这样:y1^=1y2^\hat{y_1} = 1 - \hat{y_2}。将二者带入,得到:

loss=(y1logy1^+(1y1)log(1y1^)loss = -(y_1 \log\hat{y_1} + (1-y_1) \log(1-\hat{y_1} )

于是就变成了简化版的:

loss=(ylogy^+(1y)log(1y^))loss = -(y \log\hat{y} + (1 - y) \log(1 - \hat{y}) )

这就是逻辑斯蒂回归的损失函数,这个损失函数也叫 BCE(Binary Cross-Entropy) Loss

做一个简单的代数运算: image.png

从表中可以看出了,当预测概率越接近于真实概率,损失越小,说明这个损失函数确实体现了衡量模型输出的概率分布与真实分布之间的差异这个作用。

1.2 代码实现

知道了预测值如何输出与损失函数,那就可以计算梯度,更新权重了。 我们的代码变化很小。首先,在 models 包下,创建 LogisticRegressionModel.py

python
from torch import nn class LogisticRegressionModel(nn.Module): def __init__(self) -> None: super(LogisticRegressionModel, self).__init__() self.linear = nn.Linear(1, 1) def forward(self, x): # 这里,套一层 sigmoid 函数 y_pred = nn.functional.sigmoid(self.linear(x)) return y_pred

然后,和 models 包同级,新建一个 main.py 文件:

python
from models.LogisticRegressionModel import LogisticRegressionModel from torch import Tensor, nn, optim # 数据集 x_data = Tensor([[1.0], [2.0], [3.0], [4.0]]) # 这里需要变化,因为是分类问题,就变成类别,这里为了提高准确率,我用了四个数据 y_data = Tensor([[0], [0], [1], [1]]) # 训练 def training(): # 使用模型 model = LogisticRegressionModel() # 这里的损失函数,要换成 BCE criterion = nn.BCELoss(size_average=False) # 优化器 optimizer = optim.SGD(model.parameters(), lr=0.01) # 训练 1000 轮 for epoch in range(1000): # 计算预测值 y_pred = model(x_data) # 计算损失值 loss = criterion(y_pred, y_data) print(f"Epoch - {epoch}, loss = {loss.item():.10f}") # 清空梯度 optimizer.zero_grad() # 反向传播 loss.backward() # 更新权重 optimizer.step() print(f"w = {model.linear.weight.item():.10f}") print(f"b = {model.linear.bias.item():.10f}") # 使用新输入测试效果 x_test = Tensor([[5.0]]) print(f"Predict: x = 5.0, y = {model(x_test).data}") if __name__ == "__main__": training()

运行结果: image.png

1.3 复盘

  • 线性回归不能用于分类问题的原因
  • 逻辑斯蒂公式,图像
  • 二分类问题的概念
  • 交叉熵公式
  • 逻辑斯蒂回归的损失函数(在二分类问题中)
  • 利用 PyTorch ,使用逻辑斯蒂回归训练模型的代码实现思路

2 处理多维特征的输入

2.1 多维输入

在实际训练过程中,我们的输入不一定是一个 x ,也可能是多个 x,对于多个 x,各自又有相应的权重参数。以下面的数据为例: image.png

这里一共有 8 个输入,每个输入称为一个特征

如果我们通过线性回归模型来看,输入是这 N x 8 的一个矩阵,输出是 N x 1 的矩阵,那么我们的 ω\omega ,就需要是一个 8 x 1 的一个矩阵,bb 需要是一个 N x 1 的矩阵。

代码实现里,不需要复杂的变化,我们最初的模型定义:

python
from torch import nn class LogisticRegressionModel(nn.Module): def __init__(self) -> None: super(LogisticRegressionModel, self).__init__() self.linear = nn.Linear(1, 1) def forward(self, x): y_pred = nn.functional.sigmoid(self.linear(x)) return y_pred

nn.Linear(1, 1) 这里,我们定义了输入和输出的维度都是 1 维的。现在我们的输入维度变了,因此要改这个地方。

python
from torch import nn class LogisticRegressionModel(nn.Module): def __init__(self) -> None: super(LogisticRegressionModel, self).__init__() self.linear = nn.Linear(8, 1) def forward(self, x): y_pred = nn.functional.sigmoid(self.linear(x)) return y_pred

就可以了。

2.2 多层神经网络

从上述的数据图中,可以看出,我们定义的 LogisticRegressionModel 这个单层模型,它接收一个 8 维的输入,输出 1 维的结果。

可以看出,神经网络的每一层,其实就是通过矩阵变换,将 N 维的输入,转换为 M 维的输出。

对于矩阵来说,矩阵其实是一个空间函数。矩阵的变换,其实是将 N 维的空间,映射到 M 维上,是一个空间的变换。而这个空间变换,通常是非线性的,这也是神经网络的设计目的,在维度之间,寻找一个能够将一个维度(空间)变换到另一个维度(空间)的非线性函数。

所以说,我们要构建多层的神经网络,就取决于我们需要在每一层做什么样的空间变换,这个空间变换的所带来的效果是否符合我们的预期。当然,这个寻找的方法在后续才会学到了。

回到这里,我们的 LogisticRegressionModel 是将 8 维的输入转换为 1 维的输出,那么如果我们要构建多层的神经网络,是不是就可以从分段降维的角度出发?可以先将 8 维转化为 6 维,再将 6 维转化为 4 维,再将 4 维转化 1 维度。这样就可以构建出一个简单的多层神经网络了。

以 diabetes.csv (需要去原视频下载)的数据为训练集,以 LogisticRegressionModel 为基础,构建一个多层的神经网络分类模型。

首先,在 models 包下,创建 MultiLogisticRegressionModel.py 文件:

python
from torch import nn class MultiLogisticRegressionModel(nn.Module): def __init__(self) -> None: super(MultiLogisticRegressionModel, self).__init__() self.linear_1 = nn.Linear(8, 6) self.linear_2 = nn.Linear(6, 4) self.linear_3 = nn.Linear(4, 1) self.activate = nn.Sigmoid() def forward(self, x): x = self.activate(self.linear_1(x)) x = self.activate(self.linear_2(x)) x = self.activate(self.linear_3(x)) return x

在和 models 包同级,创建 main.py

python
import numpy as np import torch from models.MultiLogisticRegressionModel import MultiLogisticRegressionModel from torch import nn, optim # 加载训练集 def loadData(): raw_data = np.loadtxt( "data/diabetes_for_train.csv", delimiter=",", dtype=np.float32 ) return (torch.from_numpy(raw_data[:, :-1]), torch.from_numpy(raw_data[:, [-1]])) # 加载测试集,并执行预测 def predict(model: MultiLogisticRegressionModel): raw_data = np.loadtxt( "data/diabetes_for_predict.csv", delimiter=",", dtype=np.float32 ) x_data = torch.from_numpy(raw_data[:, :-1]) y_data = torch.from_numpy(raw_data[:, [-1]]) result = model(x_data).data print(f"Predict: y_pred = {result}, y = {y_data}") # 训练 def training(): model = MultiLogisticRegressionModel() x_data, y_data = loadData() criterion = nn.BCELoss(size_average=True) optimizer = optim.SGD(model.parameters(), lr=0.01) for epoch in range(1000): y_pred = model(x_data) loss = criterion(y_pred, y_data) print(f"Epoch - {epoch}, loss = {loss.item():.10f}") optimizer.zero_grad() loss.backward() optimizer.step() predict(model) if __name__ == "__main__": training()

执行结果: image.png

可以看到,输出的结果区分度不大,说明训练效果不好。我稍微调整一下:将前两层,使用 ReLU 激活函数,优化器使用 Adam,并将学习率降到 0.001,最后将训练轮次调整到 10000 轮:

python
from torch import nn class MultiLogisticRegressionModel(nn.Module): def __init__(self) -> None: super(MultiLogisticRegressionModel, self).__init__() self.linear_1 = nn.Linear(8, 6) self.linear_2 = nn.Linear(6, 4) self.linear_3 = nn.Linear(4, 1) self.activate_1 = nn.ReLU() self.activate_2 = nn.Sigmoid() def forward(self, x): x = self.activate_1(self.linear_1(x)) x = self.activate_1(self.linear_2(x)) x = self.activate_2(self.linear_3(x)) return x

运行结果: image.png

2.3 复盘

  1. 在之前模型训练代码上,支持多维输入的方法
  2. 矩阵变换与神经网络层的关系
  3. 在之前模型训练代码上,支持多层神经网络的方法

3 加载数据集

3.1 DataLoader 使用

之前我们知道,Mini-Batch 的使用是在时间复杂度和模型性能之间的一个折中方案。但是目前为止,我们的模型训练都是使用全量的加载。

Mini-Batch 不需要手动实现,PyTorch 已经有十分好用的封装。但是在使用之前,需要了解几个概念:

  1. Epoch:对所以训练样本,完成一次正向传播和反向传播的过程。
  2. Batch-Size:进行一次正向传播和反向传播需要的训练样本数量。
  3. Iteration:完成一次 Epoch 所需的 Batch-Size 数量。

此外,还需要知道一个重要的概念:Shuffle。Shuffle 是指将需要训练的数据集随机打乱。在 DataLoader 中,进行 Shuffle 和 Mini-Batch 的流程是这样的: image.png

Loader 是一个可迭代对象,使用 yield 来逐个获取每个 Mini-Batch。

3.2 代码实现

首先,新增一个 datasets 包,创建 DiabetesDataset.py:

python
from torch.utils.data import Dataset import numpy as np import torch class DiabetesDataset(Dataset): def __init__(self, file_path: str) -> None: xy = np.loadtxt(file_path, delimiter=",", dtype=np.float32) self.len = xy.shape[0] self.x_data = torch.from_numpy(xy[:, :-1]) self.y_data = torch.from_numpy(xy[:, [-1]]) def __getitem__(self, index): return self.x_data[index], self.y_data[index] def __len__(self): return self.len

加载数据的代码和之前的类似

然后,在与 datasets 包同级的目录下, 创建 main.py

python
from datasets.DiabetesDataset import DiabetesDataset from models.MultiLogisticRegressionModel import MultiLogisticRegressionModel from torch import nn, optim from torch.utils.data import DataLoader # 预测测试 def predict(model: MultiLogisticRegressionModel): dataset = DiabetesDataset("data/diabetes_for_predict.csv") test_loader = DataLoader( dataset=dataset, batch_size=32, shuffle=False, num_workers=0 ) y_pred_list = [] labels_list = [] for i, data in enumerate(test_loader, 0): inputs, labels = data y_pred = model(inputs) y_pred_list.append(y_pred) labels_list.append(labels) print(f"Predict: \n y_pred = {y_pred_list} \n") print(f"y = {labels_list}") # 训练 def training(): # 加载数据集 dataset = DiabetesDataset("data/diabetes_for_train.csv") # 创建 Loader train_loader = DataLoader( dataset=dataset, batch_size=32, shuffle=True, num_workers=0 ) # 创建模型 model = MultiLogisticRegressionModel() # 创建损失函数和优化器 criterion = nn.BCELoss(size_average=True) optimizer = optim.Adam(model.parameters(), lr=0.001) # 进行 1000 轮训练 for epoch in range(1000): # Mini-Batch 加载 for i, data in enumerate(train_loader, 0): # 获取 Batch 的 x 和 y inputs, labels = data # 正向传播 y_pred = model(inputs) loss = criterion(y_pred, labels) # 反向传播 optimizer.zero_grad() loss.backward() # 更新权重 optimizer.step() print(f"Epoch {epoch} down") # 预测测试 predict(model) if __name__ == "__main__": training()

注意,为了方便观测结果,测试集的加载不要启用 Shuffle。

运行结果: image.png

这里有个非常奇怪的现象。在训练时,当我将 num_workers 设为 2 时,我发现完成一轮 Epoch 居然要 3~5 秒左右,而改为 0 时明显变快,大概每秒完成 10~15 Epoch。但是明明 num_workers 是为了增速的并行读取,为什么加了数量反而变慢了?

我询问了 DS,它告诉我原因:因为代码中的读取文件的逻辑,是将整个文件直接读取到内存当中,然后做 Shuffle 和 Load。既然已经将文件读取好了,num_workers 并行读取就没有意义了。而且由于 num_workers 是启动进程来并且处理,进程之间的调度和通信本身就需要耗费时间,所以就会出现长的延迟。

所以如果要一直向硬盘中读取文件,那设置 num_workers 就非常有用。但如果已经将数据全部读取到内存中了,num_workers 的设置反而是不必要的累赘。

3.3 课后题

从 Kaggle 中下载泰坦尼克号的数据集 Titanic - Machine Learning from Disaster | Kaggle,使用本课程的方法,训练出模型,并使用其中的测试集测试,将结果上传到 Kaggle 中评分。

由于该数据集涉及到字符串、缺失值等等处理,我暂时不会做。

3.3 复盘

  1. Epoch、Batch-Size、Iteration 的概念
  2. DataLoader 中加载数据的大致流程
  3. 使用 Dataset 和 DataLoader 加载数据的代码实现思路

4 多分类问题

4.1 Softmax Classifier

在二分类问题中,由于模型输出的结果是 y^\hat{y}1y^1-\hat{y} ,所以不需要担心概率小于零,大于 1 这样的问题。

但是在多分类问题中,如果以二分类思路来看,也就是将每个分类都当作是一个二分类问题:如果是这个标签,那么这个标签的真实概率为 1 ,其他为 0。

但是这样有个弊端,就是输出的结果是独立的,假设有 10 个分类类别,那么就会输出 10 个独立的二分类的概率。比如是第一个类别的概率为 0.8,是第二个类别的概率是 0.9,这样的情况。

在实际上,我们通常需要采集这 10 个中概率最大的输出,作为模型的最终输出结果。然而,模型输出了:第一个类别的概率为 0.8,是第二个类别的概率是 0.9。如果我们取 0.9,那意味着是其他类别的概率就是 0 了,但是模型说第一个类别的概率为 0.8,它难道完全和第一个分类不同吗?这是一个非常矛盾的点。

因此,在这个多分类问题上,我们希望的是,如果输出的某个分类类别的概率比较大,那么其他分类的概率应该要比较小。并且,这十个分类,应当要看成一个整体,也就是单个的概率要大于等于 0,而整体的概率和要小于等于 1。这样来做的话,我们的模型输出的概率,那就是一个正确的概率分布,而不是每个独立的概率,这样才不会矛盾。

为了解决这个问题,这里就引入一个公式,叫做 Softmax Function,其公式是这样的:

P(y=i)=ezij=0K1ezj,i{0,...K1}P(y=i)=\frac{\mathrm{e}^{z_i}}{\sum_{j=0}^{K-1} \mathrm{e}^{z_j}}, i \in \{0,... K-1\}

这个公式其实很像:

P(a)=aa+b+cP(a)=\frac{a}{a + b + c}

区别在于给 a、b、c 都加了一个 e 。这是因为这个函数通常作为激活函数,直接放在回归模型后面,而回归模型通常输出的结果可能为正数,也可能负数。所以为了保证结果为正数,就把结果放在 exe^x 中。

通过这个 Softmax 函数,就可以将多个独立的概率,输出为一个单个的概率要大于等于 0,而整体的概率和要小于等于 1 的概率分布,如图所示: image.png

既然模型的预测输出值变了,那损失函数自然就不能使用原来的损失函数。在计算损失函数前,需要了解 One-hot 的概念。

One-hot,也称独热编码,指的是只有一个位置为 1,其余位置全是 0 的向量。假设我们要对图片中的猫、狗、兔这三个动物做分类,那么它们的独热编码就是:

  • 猫:[1, 0, 0]
  • 狗:[0, 1, 0]
  • 兔:[0, 0, 1]

如果输入的是一张猫的图片,那么模型的输出 Y 应该是一个这样的 [1, 0, 0] 的向量。

现在,我们将思路拉回到损失函数上。通过 Softmax ,我们成功将模型输出,从独立概率集转化为了概率分布集。那么我们如何设计损失函数?在二分类问题上,我们的损失函数是从交叉熵公式出发的,那么我们这里也通过交叉熵公式出发:

LCE=i=1nyilog(y^i)L_{CE} = -\sum_{i=1}^{n} y_i \log(\hat{y}_i)

在二分类问题中,由于只有两个分类,所以我们的损失函数可以简化成这样:

loss=(ylogy^+(1y)log(1y^))loss = -(y \log\hat{y} + (1 - y) \log(1 - \hat{y}) )

而二分类问题,如果通过独热编码来说明,那就是让模型的输出:

  • 猫:[1, 0]
  • 狗:[0, 1]

如果通过独热编码,应用到交叉熵公式中呢?以猫 [1, 0, 0] 为例:

loss=(1logy^1+0logy^2+0logy^3)loss = -(1·\log{\hat{y}_1} + 0·\log{\hat{y}_2} + 0·\log{\hat{y}_3})

于是就变成了这样:

loss()=logy^1loss(猫) = -\log{\hat{y}_1}

其他的也是:

  • 狗:loss()=logy^2loss(狗) = -\log{\hat{y}_2}
  • 兔:loss()=logy^3loss(兔) = -\log{\hat{y}_3}

这下是不是清晰了?再回想一下 y=logxy = -\log{x} 的曲线: image.png

y=logxy = -\log{x} 的曲线中可以看出,当 x 越接近于 1,y 的值越小,而 x 越接近于 0,y的值越大。

也就是说,通过 Softmax 和 One-hot ,其损失函数其实就是:

loss=ylogy^loss = -y\log{\hat{y}}

从含义上讲,通过我们的猫、狗、兔的损失函数:

  • 猫:loss()=logy^1loss(猫) = -\log{\hat{y}_1}
  • 狗:loss()=logy^2loss(狗) = -\log{\hat{y}_2}
  • 兔:loss()=logy^3loss(兔) = -\log{\hat{y}_3}

当真实分类为猫时,只需要猫那一项的损失值,这就是为什么我们不求和,不做完整交叉熵公式的原因。取对数的意思,就是当 y^\hat{y} 这个预测概率越解决于 1,其损失越小;越接近于0,其损失值越大。这种取预测正确类别概率的负对数的损失函数,也叫做 NLLLoss (Negative Log Likelihood Loss)。 image.png

老师这里说,需要先将 y^\hat{y} 做对数运算,再传递给 NLLLoss。意思其实不是对 y^\hat{y} 做独立的对数运算,假设结果为 r,然后传递到 loss=ylogrloss = -y\log{r} 中。其实是因为 NLLLoss 只是将输入变成负数,也就是 loss=inputloss = - input,所以需要在 NLLLoss 前,增加一层对 y^\hat{y} 的 Log 运算,再传递给 NLLLoss 取负数。

在 PyTorch 中,也提供了 NLLLoss 的函数,但是用的比较多的,是另一个叫做 CrossEntropyLoss(CRL) 的函数。这个函数包含了从 Softmax 、取 Log (通常称为 LogSoftmax)到 NLLLoss 的所有流程。

所以,当使用 CrossEntropyLoss 时,不需要对预测值使用激活函数 Softmax,直接将回归模型的预测值传递给 CrossEntropyLoss 就行。

4.2 使用 Softmax Classifier 训练 MNIST Datasets

MNIST 数据集是一个手写数字数据集,输入是一个 28 x 28 的黑白图片。在 PyTorch 中,图片通常有三个维度:宽(w)、高(h)、通道数(c)。如果是黑白图片,只有一个通道,如果是彩色图片,通常有 RGB 三层通道。在 PyTorch 中,通常以这样的向量表示:[c, w, h]

因此,我们的模型输入 X,其实是 N 个 [1, 28, 28]的矩阵: [N, 1, 28, 28]。图片的每个像素都对应 28 x 28 矩阵的一个值,这个值通常是 0 到 1 的一个浮点数概率。

还有一个问题,就是我们的线性回归模型,直接受一个 M x N 的矩阵输入,但我们这个是[N, 1, 28, 28],所以需要将其形状,改成 [N, 784]

在进行图片处理时,不需要我们手动将像素点转化为概率,也不需要手动将输入形状改成 [N, 784],只需要使用 torchvision 提供的 transform 就可以转换。

由于输入是 [N, 784],也就是说,模型输入的 feature 是 782 ,而输出的 feature 需要是 10 (10 个数字),所以,我们的模型得构建一个神经网络,逐步将 782 降维到 10。

在 models 中,创建 MnistNet.py

python
from torch import nn class MnistNet(nn.Module): def __init__(self) -> None: super(MnistNet, self).__init__() self.linear_1 = nn.Linear(784, 512) self.linear_2 = nn.Linear(512, 256) self.linear_3 = nn.Linear(256, 128) self.linear_4 = nn.Linear(128, 64) self.linear_5 = nn.Linear(64, 10) self.activate_1 = nn.ReLU() def forward(self, x): x = x.view(-1, 784) x = self.activate_1(self.linear_1(x)) x = self.activate_1(self.linear_2(x)) x = self.activate_1(self.linear_3(x)) x = self.activate_1(self.linear_4(x)) # 注意,最后一层不需要激活函数 return self.linear_5(x)

最后一层不需要激活函数,因为我们要使用 CEL。

在与 models 包同级目录下,创建 main.py

python
import torch import torch.optim as optim from models.MnistNet import MnistNet from torch.utils.data import DataLoader from torchvision import datasets, transforms # 单轮训练 def singleEpoch( epoch: int, train_loader: DataLoader, optimizer: optim.SGD, model: MnistNet, criterion: torch.nn.CrossEntropyLoss, ): # 当前损失 current_loss = 0.0 # 启动训练 for index, data in enumerate(train_loader, 0): # 从数据集中获取输入和实际值 inputs, targets = data # 正向传播 outputs = model(inputs) loss = criterion(outputs, targets) # 反向传播 optimizer.zero_grad() loss.backward() # 更新权重 optimizer.step() # 累加当前损失 current_loss += loss.item() # 每 300 轮,输出一次损失 if index % 300 == 299: print(f"[{epoch + 1}, {index + 1}] loss: {(current_loss / 300):.3f}") current_loss = 0.0 # 测试集验证 def test( test_loader: DataLoader, model: MnistNet, ): # 正确数,总数 correct = 0 total = 0 # 不需要计算梯度 with torch.no_grad(): # 获取数据验证 for data in test_loader: # 从数据集中获取数据 inputs, targets = data # 输出预测结果 ouputs = model(inputs) # 找到每个样本中,概率最大的类别 _, predicted = torch.max(ouputs.data, dim=1) # 记录总数 total += targets.size(0) # 与实际值比较,若一致则增加正确数 correct += (predicted == targets).sum().item() print(f"Accuracy on test set: {100 * correct / total} %") def training(): # mini-batch 大小 batch_size = 64 # 将图像转化为 Tensor transform = transforms.Compose( [transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))] ) # 读取训练集 train_dataset = datasets.MNIST( root="data/mnist/", train=True, download=True, transform=transform ) # 读取测试集 test_dataset = datasets.MNIST( root="data/mnist/", train=False, download=True, transform=transform ) # 初始化 DataLoader train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size) test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size) # 创建神经网络、损失函数和优化器 model = MnistNet() criterion = torch.nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.5) # 进行 10 轮训练,每一轮都进行一次测试 for epoch in range(10): singleEpoch(epoch, train_loader, optimizer, model, criterion) test(test_loader, model) if __name__ == "__main__": training()

注意,在 transform 代码中,ToTensor 方法会将输入的图片转化为 [c, w, h]的形式,并将其像素值映射到 0 到 1 的概率区间(归一化)。 在 Normalize (标准化)方法中,其中的参数是均值和标准差。其目的是将输入的概率分布标准化至标准正态分布中(均值为0,标准差为1)。据说这样训练效果比较好?

运行结果: image.png

准确率从 88% 上升,然后稳定在 97.5 % 左右。

4.3 课后题

尝试使用本章的思路,解决 Otto Group Product Classification Challenge | Kaggle 这个中的问题。暂时没空做。

4.2 复盘

  • 多分类中需要解决的问题
  • Softmax 的公式
  • One-hot 的概念
  • Softmax 的损失函数
  • NLLLoss 与 CrossEntropyLoss 的概念与区别
  • 利用 Softmax + CrossEntropyLoss 训练 Mnist Dataset 的思路

5 说明

作者: Xigrut发布时间: 2026-06-29 17:42:52上次编辑时间: 2026-06-30 15:20:46 许可协议: CC BY-NC-SA 4.0
留言区