动手学习深度学习——线性神经网络

不到10年时间,人工智能革命已从研究实验室席卷至广阔的工业界,并触及我们日常生活的方方面面。

线性回归

线性模型

一个实际的线性模型如下:(经典买房)

模型假设:

  • 假设1:影响房价的关键因素是卧室个数、卫生间个数和居住面积,分别记为:$x_1$、$x_2$、$x_3$。
  • 假设2:成交价是关键因素的加权和:
    $$
    y = w_1x_1 + w_2x_2 + w_3x_3 + b
    $$

更广泛的定义:

  • 给定n维输入:$\mathbf{x} = [x_1, x_2, \dots, x_n]^T$
  • 线性模型有一个n维权重和一个标量偏差:
    $$
    \mathbf{w} = [w_1, w_2, \dots, w_n]^T, \quad b
    $$
  • 输出是输入的加权和:
    $$
    y = w_1x_1 + w_2x_2 + \dots + w_nx_n + b
    $$
  • 向量版本(简化表达):$y = \langle \mathbf{w}, \mathbf{x} \rangle + b$

    $\langle \mathbf{w}, \mathbf{x} \rangle$ 表示向量 $\mathbf{w}$$\mathbf{x}$内积 ,在欧几里得空间中它等同于点积 $\mathbf{w}^T\mathbf{x}$

线性模型可以看做是单层神经网络:

1768547895884

  • 输入的维度为:d
  • 输出的维度为:1
  • 每个箭头表示一个权重w。

衡量预估质量

比较真实值和预估值,例如房屋售价和估价。

假设y是真实值,$\hat{y}$ 是估计值。

一个常见的损失为:(平方损失)

$$
\ell(y, \hat{y}) = \frac{1}{2}(y - \hat{y})^2
$$

训练数据

收集一些数据点来决定参数值(权重和偏差),例如过去6个月卖的房子。这些数据就称为训练数据

训练数据通常越多越好。

假设我们有n个样本记为:

$$
\mathbf{X} = [\mathbf{x}_1, \mathbf{x}_2, \dots, \mathbf{x}_n]^T \quad \mathbf{y} = [y_1, y_2, \dots, y_n]^T
$$

参数学习

根据求所有估计值的损失平均值,我们得到损失函数

1768548648304

计算目标:找到一个w,b的对应值,最小化损失函数:

$$
\mathbf{w}^, \mathbf{b}^ = \underset{\mathbf{w}, b}{\arg\min} \ell(\mathbf{X}, \mathbf{y}, \mathbf{w}, b)
$$

$\mathbf{w}^, \mathbf{b}^$ :星号通常表示 最优值 ,即让损失函数达到最小的参数组合。

基础优化方法

梯度下降

步骤:

  • 挑选一个随机初始值$w_0$

  • 重复迭代参数t = 1, 2, 3

    $$
    \mathbf{w}t = \mathbf{w}{t-1} - \eta \frac{\partial \ell}{\partial \mathbf{w}_{t-1}}
    $$

    • 沿梯度方向将增加损失函数值
    • 学习率$\eta$:步长的超参数

超参数:需要人为设置的参数。

小批量随机梯度下降

我们很少直接使用梯度下降法,因为在整个训练集上计算梯度代价很大。一个深度神经网络模型可能需要数分钟至数小时才能计算一次梯度。

我们可以随机采样b个样本$i_1, i_2, …, i_b$来近似损失:

$$
\frac{1}{b} \sum_{i \in I_b} \ell(\mathbf{x}_i, y_i, \mathbf{w})
$$

b是批量大小,也是一个重要的超参数,

批量大小的设置准则:

  • 不能太小:每次计算量太小,不适合并行来最大利用计算资源。
  • 不能太大:内存消耗增加,浪费计算。

小批量随机梯度下降是深度学习框架默认的求解算法

线性回归的从零实现

导入包:

1
2
3
4
%matplotlib inline #将绘图结果直接嵌入到 Notebook 的单元格下方,而不是弹出一个独立的窗口。
import random
import torch
from d2l import torch as d2l

生成数据集

为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。人造数据集的一个好处是我们知道真实的参数w和b。

我们使用线性模型参数$\mathbf{w} = [2, -3.4]^T, \quad b = 4.2$和噪声项$\epsilon$生成数据集以及标签:

$$
\mathbf{y} = \mathbf{X}\mathbf{w} + b + \epsilon
$$

生成数据集函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def synthetic_data(w, b, num_examples):  #@save
"""生成y=Xw+b+噪声"""

# torch.normal()产生服从标准正太分布(均值为0,方差为1)的随机数。
# 形状为(样本数量num_examples,特征维度len(w))
X = torch.normal(0, 1, (num_examples, len(w)))

# torch.matmul()计算X * w,然后加上偏置b
y = torch.matmul(X, w) + b

# 添加一个服从正太分布(0, 0,01)的随机噪声
y += torch.normal(0, 0.01, y.shape)

# 返回特征X和处理后的y
# 确保y.reshpe((-1, 1))是一个列向量
return X, y.reshape((-1, 1))

torch.matmul():是一个高度优化的、全能型的乘法函数。

  • 处理一维向量 :如果两个输入都是一维的,它计算的是 点积
  • 处理二维矩阵 :行为与 torch.mm 一致。
  • 处理高维张量(广播机制) :如果输入维度大于 2(例如 Batch 数据 $[B, N, M]$),它会执行 批处理矩阵乘法 。它会保持前面的 Batch 维度不变,只对最后两个维度进行矩阵乘法。

生成数据:

1768554441461

1768554469868

其中第二个特征 features[: , 1]labels的散点图,可以直观观察到两者之间的线性关系:

1768554674981

读取数据集

训练模型时需要对数据集进行遍历,每次抽取一小批样本,并使用它们来更新我们的模型。由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据

我们定义一个 data_iter函数,该函数接受批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量。每个小批量包含一组特征和标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def data_iter(batch_size, features, labels):

# 获取样本的总数,此处是1000
num_examples = len(features)

# 生成一个从0 到 num_example-1的索引列表
indices = list(range(num_examples))

# 这些样本是随机读取的,没有特定的顺序
# 将索引顺序打乱
random.shuffle(indices)

# 开始循环,步长为batch_size
# 从0开始,每次跳过batch_size个样本,直到遍历完整个数据集
for i in range(0, num_examples, batch_size):
# 提取当前批次的索引
# 使用min(i + batch_size, num_examples)防止最后一个批次超出总数
# 将切片的索引转换成张量,以便后序进行高级索引操作
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])

# 使用yield返回当前的特征和标签
# 这使得函数变成一个生成器,每次调用时只返回一个小批量,节省内存
yield features[batch_indices], labels[batch_indices]

当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。 上面实现的迭代对教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。 在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。

初始化模型参数

我们将w随机初始化为满足(0, 0.01)正态分布的向量

将b初始化为0标量。

1768556066819

定义模型

作用:将模型的输入和参数同模型的输出关联起来

线性模型定义:

1
2
3
def linreg(X, w, b):  #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

定义损失函数

损失函数我们使用均方损失:

1
2
3
def squared_loss(y_hat, y):  #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

我们将真实值 y的形状转为和预测 y_hat相同的形状。

定义优化算法

优化算法我们选择使用小批量随机梯度下降法

下面的函数实现小批量随机梯度下降更新。该函数接受模型参数集合、学习速率和批量大小作为输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def sgd(params, lr, batch_size):  #@save
"""小批量随机梯度下降"""

# 开启一个上下文管理器,在该区域内不进行梯度计算和记录
# 更新参数的操作不属于模型学习的一部分,不需要计算更新过程本身的梯度,这可以节省内存和计算资源
with torch.no_grad():
# 遍历参数列表中的每一个参数(如权重矩阵w和偏置向量b)
for param in params:
# 根据梯度下降公式更新此参数
# 除以batch_size是为了取平均梯度,使其不随批量大小的改变而产生剧烈的波动
param -= lr * param.grad / batch_size

#手动将梯度归0
param.grad.zero_()

为什么要除以batch_size?

在《动手学深度学习》的实现中,损失函数(Loss)是直接求和(l.sum())而不是取平均。这意味着:

  • 如果 batch_size 是 10,param.grad 就是 10 个样本梯度的总和。
  • 如果 batch_size 是 100,param.grad 就是 100 个样本梯度的总和。
    为了保证无论 batch_size 选多大,我们每一步更新的“力度”是基本稳定的,我们需要除以 batch_size 来获得 平均梯度

训练

在每次迭代中,

  • 我们取一小批量训练样本,并通过我们的模型来获得一组预测。
  • 计算损失
  • 反向传播,存储每个参数的梯度
  • 调用优化算法sgd来更新模型参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和y的小批量损失
# 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
# 并以此计算关于[w,b]的梯度
l.sum().backward() # 这里对应上面sgd中除以batch_size
sgd([w, b], lr, batch_size) # 使用参数的梯度更新参数
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

训练效果:

1768558375508

线性回归的简介实现

使用深度学习框架来简洁地实现线性回归模型。

生成数据集

生成数据集没有什么不同,与之前从0实现完全一致,不过我们额外导入了一些包:

1
2
3
4
5
6
7
8
import numpy as np
import torch
from torch.utils import data # 用于定义数据集、迭代数据、高效加载数据
from d2l import torch as d2l

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = d2l.synthetic_data(true_w, true_b, 1000)

我们可以使用框架中现有的API来读取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def load_array(data_arrays, batch_size, is_train=True):  #@save
"""构造一个PyTorch数据迭代器"""

# 将传入的多个数组(张量)封装进TensorDataset
# *data_arrays 表示对元组进行解包,等价于data.TensorDataset(features, labels)
dataset = data.TensorDataset(*data_arrays)

# 构造Pytorch 的DataLoader,用于按批次读取数据
# dataset: 刚才创建的数据集对象
# batch_size: 每次读取多少条数据
# shuffle: 为True则打乱数据顺序,增加模型的泛化能力
# 返回一个可迭代对象
return data.DataLoader(dataset, batch_size, shuffle=is_train)

batch_size = 10
data_iter = load_array((features, labels), batch_size)

我们可以尝试通过迭代器获取数据第一项:等价于 next(iter(data_iter))

1768705051202

定义模型

对于标准的深度学习模型,我们可以使用框架的预定义好的层。这使我们只需要关注使用哪些层来构造模型,而不必关注层的实现细节。

1
2
3
4
5
6
7
8
9
# 从pytorch库中导入神经网络模块,并简写为nn 
from torch import nn

# 定义一个名为net的网络
# nn.Sequential 是一个顺序容器,它会将放入其中的层按顺序串联起来
# nn.Linear(2,1) 定义了一个线性层(全连接层)
# - 第一个参数 2:输入特征的维度(即输入的 X 有 2 列)
# - 第二个参数 1:输出特征的维度(即输出的 y 有 1 列)
net = nn.Sequential(nn.Linear(2,1))

Sequential类将多个层串联到一起。当给定输入数据时,Sequential实例将数据传入第一层,然后将第一层的输出作为第二层的输入,以此类推。

在上面的例子中,我们的模型只包含一个层,因此实际上不需要Sequential。但是由于以后的模型都是多层的,这里使用Sequential会让我们熟悉标准流水线。

初始化模型参数

在使用net之前,我们需要初始化模型参数。在这里为线性模型中的权重和偏置。

1
2
3
4
5
6
7
8
9
# 访问网络中的第一层(索引为0),获取其权重参数(weight)
# .data 表示直接操作张量数据
# .normal_(0.0.01)是一个原地操作(以_结尾)
# 将权重初始化为均值为0、标准差为0.01的正太分布随机数
net[0].weight.data.normal_(0, 0.01)

# 访问网络中的第一层0,获取其偏置参数(bias)
# .fill_(0) 是一个原地操作,将偏置的所有元素初始化为0
net[0].bias.data.fill_(0)

定义损失函数

计算均方误差使用MSELoss类,也被称为平方$L_2$范数。默认情况下,它返回所有样本损失的平均值。

1
loss = nn.MSELoss()

定义优化算法

小批量随机梯度下降算法是一种优化神经网络的标准工具, PyTorch在 optim模块中实现了该算法的许多变种。

当我们实例化SGD实例时,我们需要指定优化的参数以及优化算法所需要的超参数字典。

1
2
3
4
5
# 定义一个优化器(optimizer),这里命名为trainer
# torch.optm.SGD: 选择随机梯度下降算法(Stochastic Gradient Descent)算法
# net.parameters(): 告诉优化器需要更新模型中的哪些参数
# lr = 0.03: 设置学习率(learn rate)为0.03
trainer = torch.optim.SGD(net.parameters(), lr=0.03)

训练

在每个迭代周期里,我们将完整遍历一次数据集(train_data), 不停地从中获取一个小批量的输入和相应的标签。 对于每一个小批量,我们会进行以下步骤:

  • 通过net(X)生成预测并计算损失l(前向传播)
  • 通过反向传播来计算梯度
  • 通过调用优化器来更新模型参数

1768708166682

softmax回归

softmax回归虽然名字叫做回归,但其实是一个分类问题。

回归:

  • 单连续数值输出
  • 自然区间R
  • 跟真实值的区别作为损失

1768790075842

分类:

  • 通常多个输出
  • 输出i是预测为第i类的置信度
  • 网络架构:
    $$
    \begin{align*}
    o_1 &= x_1 w_{11} + x_2 w_{12} + x_3 w_{13} + x_4 w_{14} + b_1, \
    o_2 &= x_1 w_{21} + x_2 w_{22} + x_3 w_{23} + x_4 w_{24} + b_2, \
    o_3 &= x_1 w_{31} + x_2 w_{32} + x_3 w_{33} + x_4 w_{34} + b_3.
    \end{align*}
    $$

1768790089245

从回归到多类分类

多分类问题我们需要对类别进行编码。常见的编码方式有一位有效编码

$$
\mathbf{y} = [y_1, y_2, \dots, y_n]^\top
$$

$$
y_i =
\begin{cases}
1 & \text{if } i = y \
0 & \text{otherwise}
\end{cases}
$$

如果真实的类别为第i个,则$y_i = 1$,其他类别均为0

使用均方损失训练

最大值最为预测:$\hat{y} = \mathop{\mathrm{argmax}}\limits_{i} o_i$

训练目标:需要更置信的识别正确类

$$
o_y - o_i \geq \Delta(y, i)
$$

softmax

神经网络直接计算出来的输出可以是任意实数(有正有负,有大有小),很难直接解释为概率。

为了解决,引入Softmax函数:

$$
\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})
$$

$$
\hat{y}_i = \frac{\exp(o_i)}{\sum_k \exp(o_k)}
$$

作用:

  • 非负:通过指数函数 $\exp(o_i)$,将所有输出变成正数。
  • 和为1:通过除以所有项的总和 $\sum_k \exp(o_k)$,对数据进行归一化,使得所有类别的预测概率加起来等于 1。

其中概率$y$和$\hat{y}$的区别作为损失。

交叉熵

交叉熵是信息论中的概念:用于衡量两个概率分布的相似度:

公式:

$$
H(\mathbf{p}, \mathbf{q}) = \sum_i - p_i \log(q_i)
$$

  • $\mathbf{p}$ (真实分布): 代表事实。例如这张图是猫,$\mathbf{p}$ 就是 $[1, 0, 0]$(猫的概率是1)。
  • $\mathbf{q}$ (预测分布): 代表模型的猜测。例如模型觉得是猫的概率是 0.7,$\mathbf{q}$ 就是 $[0.7, 0.2, 0.1]$

直观理解: 如果预测 ($\mathbf{q}$) 越接近真实 ($\mathbf{p}$),这个交叉熵的值就越小;反之越大。

损失函数

我们通过计算信息熵作为损失函数:

$$
l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{i} y_i \log \hat{y}_i = - \log \hat{y}_y
$$

假设正确答案是第 $i$ 类,那么只有 $y_y = 1$,其他的 $y_i$ 全是 0。

训练模型时,我们只需要关注 模型对“正确类别”预测了多大的概率 。如果正确类别的预测概率越高(接近1),$-\log$ 值就越小(损失越小)。

梯度

其梯度就是真实概率和预测概率的区别:

$$
\frac{\partial l}{\partial o_i} = \text{softmax}(\mathbf{o})_i - y_i = \hat{y}_i - y_i
$$

常见损失函数

L2 Loss

$$
l(y, y’) = \frac{1}{2}(y - y’)^2
$$

1768881850602

最小化 MSE(平方),你在预测“平均值”(Mean)。此处不再证明推导。俺数学也不好T_T

均方损失MSE的数学解释

基础假设:世界是有“噪声”的。现实中的数据从来不是完美的直线。真实数据$y$等于理想的预测值$\mathbf{w}^\top \mathbf{x} + b$加上一个”噪声”$\epsilon$

$$
y = \mathbf{w}^\top \mathbf{x} + b + \epsilon
$$

均方损失MSE中,我们假设这个噪声是服从正态分布(高斯分布)的。正太分布概率密度函数如下:

$$
p(z) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp \left( -\frac{1}{2\sigma^2}(z - \mu)^2 \right).
$$

  • $z$ :随机变量的具体取值。
  • $\mu$ :这个分布的 均值 (也就是钟形曲线最高点对应的中心位置)。
  • $(z - \mu)$ :表示“当前值”距离“中心”有多远。

概率密度函数用于描述连续性随机变量的分布情况。

对于离散变量(比如掷骰子),我们可以说“掷出 6 的概率是 1/6”;但对于连续变量(比如一个人的精确身高),由于取值有无限种可能, 取到某一个精确数值(如刚好 175.0000…cm)的概率在数学上是 0

概率密度本身不是概率,但它在某个区间上的积分才是概率。如果你想知道随机变量 $X$ 落在区间 $[a, b]$ 内的概率,只需要对 PDF 求积分。

函数图像一般如下:

1768879253278

现在回到线性回归的公式:

$$
y = \mathbf{w}^\top \mathbf{x} + b + \epsilon
$$

其中噪声 $\epsilon \sim \mathcal{N}(0, \sigma^2)$

  • $\mathbf{w}^\top \mathbf{x} + b$ 是一个 确定值 (一旦 $\mathbf{x}$ 给定,预测出的直线上的点就是固定的)。
  • $\epsilon$ 是一个 随机波动 (它是以 0 为中心的)。

如果你把一个“固定值”加上一个“以 0 为中心的随机波动”,得到的结果 $y$ 会是什么分布?
答案是:$y$ 也会服从正态分布,只是中心变了。

  • $y$ 的均值(中心 $\mu$) = $\mathbf{w}^\top \mathbf{x} + b$ (也就是预测值)。
  • $y$ 的方差(宽窄 $\sigma^2$) = 噪声的方差 $\sigma^2$

所以,我们可以得出结论:

$$
y \sim \mathcal{N}(\mathbf{w}^\top \mathbf{x} + b, ,, \sigma^2)
$$

因此我们可以写出通过给定的$x$观测到特定的$y$似然 (likelihood):

$$
P(y \mid \mathbf{x}) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp\left( -\frac{1}{2\sigma^2} (y - \mathbf{w}^\top \mathbf{x} - b)^2 \right).
$$

接下来,在线性归回预测中,我们通常要找到模型参数($w$$b$),让这堆点出现在这条线周围的“可能性”最大。在数学上,我们将这一过程称为极大似然估计。

极大似然估计:既然我们已经看到了这一组数据$x$ 和 $y$),那么我们要找的那个模型参数($w$$b$),应该是让这组数据出现的概率最大的那个。

  • 逻辑:寻找一个参数,使得实验结果出现的可能性(似然度)达到最大。

  • 似然函数$L(\theta)$:

    $$
    L(\theta) = P(x_1, x_2, …, x_n | \theta) = \prod_{i=1}^{n} P(x_i | \theta)
    $$

于是我们需要找到$w$ 和 $b$的最优值是使整个数据集的似然函数最大的值:

$$
P(\mathbf{y} \mid \mathbf{X}) = \prod_{i=1}^{n} p(y^{(i)} \mid \mathbf{x}^{(i)}).
$$

为了计算这个“最大概率”,推导过程做了一系列数学变形:

  • 连乘变连加: 因为所有样本的概率是要乘起来的,乘法很难算,所以科学家取了对数(log),把乘法变成了加法。
  • 最大变最小: 我们原本想让概率(似然) 最大化 。但是数学上通常习惯求 最小值 (比如损失函数)。所以,给公式加了个负号,把“求最大”变成了“求最小负对数似然”。

为了计算这个“最大概率”,推导过程做了一系列数学变形:

  • 连乘变连加: 因为所有样本的概率是要乘起来的,乘法很难算,所以科学家取了对数(log),把乘法变成了加法。
  • 最大变最小: 我们原本想让概率(似然) 最大化 。但是数学上通常习惯求 最小值 (比如损失函数)。所以,给公式加了个负号,把“求最大”变成了“求最小负对数似然”。

因此可以得到数学公式:

$$
-\log P(\mathbf{y} \mid \mathbf{X}) = \sum_{i=1}^{n} \frac{1}{2} \log(2\pi\sigma^2) + \frac{1}{2\sigma^2} \left( y^{(i)} - \mathbf{w}^\top \mathbf{x}^{(i)} - b \right)^2.
$$

现在我们只需要假设$\sigma$是某个固定常数就可以忽略第一项, 因为第一项不依赖于和。 现在第二项除了常数外,其余部分和前面介绍的均方误差是一样的。 幸运的是,上面式子的解并不依赖于。 因此,在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计

L1 Loss

$$
l(y, y’) = |y - y’|
$$

1768882147553

L1 Loss:最小化 MAE(绝对值),你在预测“中位数”(Median)。

Huber’s Robust Loss

$$
l(y, y’) = \begin{cases} |y - y’| - \frac{1}{2} & \text{if } |y - y’| > 1 \ \frac{1}{2}(y - y’)^2 & \text{otherwise} \end{cases}
$$

1768882495706

图像分类数据集

这里介绍Fashion-MNIST数据集,作为我们后续softmax回归而使用的数据集。

Fashion-MNIST 数据集 :

  • 包含 10 类服装图像(如恤、裤子、裙子等)。
  • 每张图像是 $28 \times 28$ 像素的灰度图。
  • 训练集共有 60,000 个样本,测试集共有 10,000 个样本。

读取数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 实例化一个ToTensor转换对象
# 它的作用是将PTL图片或numpy.ndarray 转换为形状为(C,H,W)的浮点张量
# 同时会自动将像素值从[0,255] 缩放到[0,1]范围内
trans = transforms.ToTensor()

mnist_train = torchvision.datasets.FashionMNIST(
root="../data", #指定数据集下载或存储的相对路径
train=True, # 指定加载训练集
transform=trans, # 应用上面定义的trans转换操作,将图像转换为Tensor
download=True # 如果没有数据集则自动下载
)
mnist_test = torchvision.datasets.FashionMNIST(
root="../data",
train=False,
transform=trans,
download=True
)

transforms.ToTensor():不仅改变数据类型,还进行了归一化处理。

$$
\text{Pixel}{\text{new}} = \frac{\text{Pixel}{\text{old}}}{255}
$$

归一化:它的目的是将不同量纲(单位)或取值范围的数据,转换到同一个特定的区间内(通常是 $[0, 1]$$[-1, 1]$)。

  • 加速模型收敛
  • 防止大数吃小数,特征权重的平衡
  • 避免数值计算问题

我们来看一下Tensor的形状:

1769139201029

数据集由灰度图像组成,其通道数为1。

图像的通道:图像中某一类信息的独立数据层。你可以把一张图像想象成由多个透明的“图层”叠加而成,每个图层记录一种特定的信息,例如红色、绿色、蓝色、透明度等。

灰度图(1通道):

  • 只有一个通道,记录亮度。
  • 每个像素一个值:0(黑) ~ 255(白)

RGB图像(3通道):

  • R 红
  • G 绿
  • B 蓝

读取小批量

我们使用内置的 data.DataLoader()读取数据。

1
2
3
4
5
6
7
8
batch_size = 256

def get_dataloader_workers(): #@save
"""使用4个进程来读取数据"""
return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
num_workers=get_dataloader_workers())

遍历一遍数据的时间:

1769139913776

遍历一遍数据的时间为4.28秒。

有时,读取训练数据会成为训练瓶颈。一般要求数据读取速度大于训练速度。

我们可以汇总上述代码,写出一个完整的数据导入函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def load_data_fashion_mnist(batch_size, resize=None):  #@save
"""下载Fashion-MNIST数据集,然后将其加载到内存中"""
trans = [transforms.ToTensor()]
if resize:
trans.insert(0, transforms.Resize(resize))
trans = transforms.Compose(trans)
mnist_train = torchvision.datasets.FashionMNIST(
root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
root="../data", train=False, transform=trans, download=True)
return (data.DataLoader(mnist_train, batch_size, shuffle=True,
num_workers=get_dataloader_workers()),
data.DataLoader(mnist_test, batch_size, shuffle=False,
num_workers=get_dataloader_workers()))

softmax回归的从零开始实现

首先导入包以及数据集:

1
2
3
4
5
6
import torch
from IPython import display
from d2l import torch as d2l

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

初始化参数模型

和之前线性回归的例子一样,这里的每个样本都将用固定长度的向量表示。原始数据集中的每个样本都是28 * 28 的图像。 本节将展平每个图像,把它们看作长度为784(28的平方)的向量。

在softmax回归中:

  • 输出与类别一样多,即有10个类别
  • 输入维度为784

因此,权重将构成一个$784 \times 10$的矩阵,偏置将构成一个$1 \times 10$的行向量。与线性回归一样,我们将使用正态分布初始化我们的权重W,偏置b初始化为0。

1
2
3
4
5
num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

定义softmax操作

softmax表达式:

$$
\operatorname{softmax}(\mathbf{X}){i j}=\frac{\exp \left(\mathbf{X}{i j}\right)}{\sum_{k} \exp \left(\mathbf{X}_{i k}\right)}.
$$

步骤:

  1. 对每个项求幂
  2. 对每一行求和(小批量中每一行是一个样本),得到每个样本的规范化常数。
  3. 将每一行除以其规范化常数,确保结果的和为1。
1
2
3
4
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制

定义模型

定义softmax操作后,我们可以实现softmax回归模型。下面的代码定义了输入如何通过网络映射到输出。

1
2
def net(X):
return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

将数据传递到模型之前,我们使用reshape函数将每张原始图像展平为向量。

定义损失函数

回归问题的损失函数使用:交叉熵损失函数。

$$
l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{i} y_i \log \hat{y}_i = - \log \hat{y}_y
$$

假设我们有一个数据样本y_hat,其中包含2个样本在3个类别的预测概率,以及它们对应的标签y。

1
2
y = torch.tensor([0, 2])
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])

有了y,我们知道在第一个样本中,第一类是正确的预测;而在第二个样本中,第三类是正确的预测。

接下来我们要获取每个样本中对于正确预测的概率,我们可以使用for,循环,

1
2
3
4
5
6
7
8
9
10
11
12
13
# 使用 for 循环实现
results = []
for i in range(len(y)):
# i 是样本索引(0, 1)
# label 是该样本的真实类别索引(0, 2)
label = y[i]
# 从 y_hat 的第 i 行提取第 label 列的值
prob = y_hat[i, label]
results.append(prob)

# 将结果转回 Tensor 格式
result_tensor = torch.tensor(results)
print(result_tensor) # 输出: tensor([0.1000, 0.5000])

高级索引

然而for循环往往是低效的,我们可以使用高级索引,直接得到:

1
y_hat[[0, 1], y]

这种写法别称为整数数组索引,它是高级索引中的一种。

  • 核心逻辑:提供每一维度的索引列表,来精确”挑出”张量中的特定元素。

确定行索引号:使用第一个列表[0,1]

确定列索引号:使用的是第二个列表y,即 [0.2]

一一对应配对:

  • 第一对坐标:(行索引[0], 列索引[0]) $\rightarrow$ (0, 0)
  • 第二对坐标:(行索引[1], 列索引[1]) $\rightarrow$ (1, 2)

最后,它会从 y_hat 中取出这些坐标的值,并组成一个新的张量:
[y_hat[0, 0], y_hat[1, 2]]

现在,我们只需一行代码集合实现交叉熵损失函数:

1
2
def cross_entropy(y_hat, y):
return - torch.log(y_hat[range(len(y_hat)), y])

分类精度

给定预测分布y_hat,当我们必须输出硬预测时,我们通常选择预测概率最高的类。

如Gmail必须将电子邮件分类为

  • “Primary(主要邮件)”
  • “Social(社交邮件)”
  • “Updates(更新邮件)”
  • “Forums(论坛邮件)”。

Gmail做分类时可能在内部估计概率,但最终它必须在类中选择一个。当预测与标签分类y一致时,即是正确的。

分类精度即正确预测数量与总预测数量之比。虽然直接优化精度可能很困难(因为精度的计算不可导),但是精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def accuracy(y_hat, y):  #@save
"""计算预测正确的数量"""

# 检查 y_hat是否为二维矩阵
# 如果维度大于1且列数大于1,说明y_hat存储的是预测分数/概率
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
# 获取一行中最大值所在的索引,即预测的类别标签
y_hat = y_hat.argmax(axis=1)
# 将y_hat的数据类型转换为与真实标签y一致,并进行逐元素比较
# cmp是一个布尔类型的Tensor, 预测正确为True,错误为False
cmp = y_hat.type(y.dtype) == y
# 将布尔张量转换为与y相同类型的数字(True = 1, False = 0)
# 求和得到预测正确的总数,最后转化为float返回
return float(cmp.type(y.dtype).sum())

然后我们计算得到分类精度:

1
accuracy(y_hat, y) / len(y)

同样,对于任意数据迭代器 data_iter可访问的数据集, 我们可以评估在任意模型 net的精度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def evaluate_accuracy(net, data_iter):  #@save
"""计算在指定数据集上模型的精度"""

# 初始化一个累加器Accumulator,用于存储2个变量
# metric[0]:预测正确的样本总数
# metric[1]:总样本数
metric = Accumulator(2)

# 禁用梯度计算,减少内存消耗并加快计算速度
with torch.no_grad():
# 遍历数据迭代器中的每一个Batch
for X, y in data_iter:
# net(X) 是模型的前向传播预测结果
# accuracy(...) 计算当前批次中样本预测正确的数量
# metric.add 将这两个值累加到累加器中
metric.add(accuracy(net(X), y), y.numel())

# 计算并返回总精度:预测正确数/样本总数
return metric[0] / metric[1]

其中Accumulator为一个自定义累加器,用于对多个变量进行累加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Accumulator:  #@save
"""在n个变量上累加"""
def __init__(self, n):
# 初始化一个包含 n 个浮点数的列表,全部设为 0.0
# 每个位置对应一个你想要追踪的指标(如:损失、正确数、总样本数)
self.data = [0.0] * n

def add(self, *args):
# 接收任意数量的参数,并将它们分别加到对应的 self.data 插槽中
# 例如:metric.add(a, b) 会执行 self.data[0] += a, self.data[1] += b
self.data = [a + float(b) for a, b in zip(self.data, args)]

def reset(self):
# 重置所有累加器为 0.0
self.data = [0.0] * len(self.data)

def __getitem__(self, idx):
# 允许通过索引访问累加的数据,例如 metric[0]
return self.data[idx]

训练

首先我们定义一个训练函数来代表一个迭代周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def train_epoch_ch3(net, train_iter, loss, updater):  #@save
"""训练模型一个迭代周期"""

# 初始化累加器,准备追踪3个变量:
# metric[0]:训练损失总和
# metric[1]:预测正确的样本数
# metric[2]:样本总数
metric = Accumulator(3)

# 遍历训练数据集的每一个Batch
for X, y in train_iter:
# 1. 前向传播:计算预测值y_hat
y_hat = net(X)

# 2. 计算损失:比较预测值 y_hat和真实标签 y
l = loss(y_hat, y)

# 3. 反向传播,更新参数
# 对损失综总和求导,并在更新是除以批量大小
l.sum().backward()
updater(X.shape[0])

# 4. 统计数据:将当前batch的损失总和、正确数和样本数累加到metric
metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
# 返回训练损失和训练精度
return metric[0] / metric[2], metric[1] / metric[2]

其中updater函数与线性回归中的实现一致:

1
2
3
lr = 0.1
def updater(batch_size):
return d2l.sgd([W, b], lr, batch_size)

然后我们可以实现一个训练函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):  #@save
"""训练模型"""
# 初始化动画绘制器 Animator,用于在训练过程中实时绘制损失和准确率曲线
# 不是与模型训练过程无关,先不关注,但在下面给出实现
animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
legend=['train loss', 'train acc', 'test acc'])
# 按照指定的(num_epochs)进行循环训练
for epoch in range(num_epochs):
# 训练一个epch
train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
# 评估模型,在测试级上计算准确率
test_acc = evaluate_accuracy(net, test_iter)
# 更新图表
animator.add(epoch + 1, train_metrics + (test_acc,))

# 训练结束后,获取最后一个epoch的训练指标
train_loss, train_acc = train_metrics

# --- 以下是代码检查(断言),确保训练结果符合预期,若不满足则报错 ---
assert train_loss < 0.5, train_loss
assert train_acc <= 1 and train_acc > 0.7, train_acc
assert test_acc <= 1 and test_acc > 0.7, test_acc

Animator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Animator:  #@save
"""在动画中绘制数据"""
def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
ylim=None, xscale='linear', yscale='linear',
fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, ncols=1,
figsize=(3.5, 2.5)):
# 增量地绘制多条线
if legend is None:
legend = []
d2l.use_svg_display()
self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
if nrows * ncols == 1:
self.axes = [self.axes, ]
# 使用lambda函数捕获参数
self.config_axes = lambda: d2l.set_axes(
self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
self.X, self.Y, self.fmts = None, None, fmts

def add(self, x, y):
# 向图表中添加多个数据点
if not hasattr(y, "__len__"):
y = [y]
n = len(y)
if not hasattr(x, "__len__"):
x = [x] * n
if not self.X:
self.X = [[] for _ in range(n)]
if not self.Y:
self.Y = [[] for _ in range(n)]
for i, (a, b) in enumerate(zip(x, y)):
if a is not None and b is not None:
self.X[i].append(a)
self.Y[i].append(b)
self.axes[0].cla()
for x, y, fmt in zip(self.X, self.Y, self.fmts):
self.axes[0].plot(x, y, fmt)
self.config_axes()
display.display(self.fig)
display.clear_output(wait=True)

现在我们可以进行10轮的训练:

1769164122135

预测

现在训练已经完成,我们的模型已经准备好对图像进行分类预测。 给定一系列图像,我们将比较它们的实际标签(文本输出的第一行)和模型预测(文本输出的第二行)。

1769164225932

softmax的简洁实现

通过深度学习框架的高级API也能更方便地实现softmax回归模型。

首先导入包与数据:

1
2
3
4
5
6
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

初始化模型参数

softmax回归的输出是一个全连接层。因此,为了实现我们的模型,我们只需在Sequential中添加一个带有10给输出的的全连接层。

全连接层:前一层的所有神经元,都与当前层的所有神经元相连。

数学上的表达:

  • 对于全连接层,每一个输出 $y_j$ 都是所有输入 $x_i$ 的线性组合。如果有 $d$ 个输入和 $q$ 个输出,其计算公式为:

    $$
    y_j = \sum_{i=1}^{d} x_i w_{ij} + b_j
    $$

1
2
3
4
5
6
7
8
9
10
11
12
13
# nn.Flatten():将多维的输入张量(如28x28的图像)展平为一维向量(784维)
# 这样做是因为后面的全连接层nn.Linear只能接受一维特征向量作为输入
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
# 检查当前层m是否是nn.Linear(全连接层)类型
if type(m) == nn.Linear:
# 使用正太分布(均值为0, 标准差为0.01)来初始化该层的权重
nn.init.normal_(m.weight, std=0.01)

# 将init_weights函数递归地应用到net的每一个子层上
# 即使net中有很多层,apply也会自动遍历并对符合条件的层执行初始化逻辑
net.apply(init_weights);

softmax的实现

直接计算softmax函数:

$$
\hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}
$$

上溢风险

如果输入的某个$o_k$很大,$\exp(o_k)$会爆炸,变成 inf无穷大,导致无法计算。这种情况下我们无法得到一个明确定义的交叉熵值。

解决技巧:减去最大值

为了防止exp爆炸,在计算指数前,先让所有的$o_k$减去它们中的最大值$\max(o_k)$:

$$
\hat{y}_j = \frac{\exp(o_j - \max(o_k))}{\sum_k \exp(o_k - \max(o_k))}
$$

这样最大的指数就变成了 $\exp(0) = 1$,所有的值都缩放到 $(0, 1]$ 之间,彻底解决了上溢问题。

下溢风险

在减去和规范化步骤之后,可能有些$o_j - max(o_k)$具有较大的负值。由于精度受限$exp(o_j - max(o_k))$将有接近0的值,即下溢。这些值可能会四舍五入为0。使得$\hat{y}_j = 0$,并使$\log(0)$ = -inf

。反向传播几步后,我们可能会发现自己面对一屏幕可怕的 nan结果。

解决技巧:Log-Sum-Exp技巧

尽管我们要计算指数函数,但我们最终在计算交叉熵损失时会取它们的对数。通过将softmax和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题。

$$
\begin{aligned}
\log \left(\hat{y}{j}\right) & =\log \left(\frac{\exp \left(o{j}-\max \left(o_{k}\right)\right)}{\sum_{k} \exp \left(o_{k}-\max \left(o_{k}\right)\right)}\right) \
& =\log \left(\exp \left(o_{j}-\max \left(o_{k}\right)\right)\right)-\log \left(\sum_{k} \exp \left(o_{k}-\max \left(o_{k}\right)\right)\right) \
& =o_{j}-\max \left(o_{k}\right)-\log \left(\sum_{k} \exp \left(o_{k}-\max \left(o_{k}\right)\right)\right).
\end{aligned}
$$

如上面的等式所示,我们避免计算$\exp (o_{j}-\max (o_{k}))$,而也可以直接使$o_{j} - \max(o_{k})$,因为$\log(\exp(\cdot))$被抵消了。

1
loss = nn.CrossEntropyLoss(reduction='none')

当我们使用PyTorch的 CrossEntropyLoss时,我们的模型最后一层不应该再加Softmax激活函数。我们应该直接把未规范化的预测值传递给损失函数,因为内部它会帮我们高效且稳定地完成图片里说的这些复杂计算。

优化算法

在这里,我们使用学习率为0.1的小批量随机梯度下降作为优化算法。 这与我们在线性回归例子中的相同,这说明了优化器的普适性。

1
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

训练函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def train_epoch_ch3(net, train_iter, loss, updater):  #@save
"""训练模型一个迭代周期(定义见第3章)"""
# 将模型设置为训练模式
if isinstance(net, torch.nn.Module):
net.train()
# 训练损失总和、训练准确度总和、样本数
metric = Accumulator(3)
for X, y in train_iter:
# 计算梯度并更新参数
y_hat = net(X)
l = loss(y_hat, y)
# 使用PyTorch内置的优化器和损失函数
# 梯度清零,防止梯度累加
updater.zero_grad()
# 反向传播,计算梯度
l.mean().backward()
# 更新模型参数
updater.step()

metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
# 返回训练损失和训练精度
return metric[0] / metric[2], metric[1] / metric[2]

def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater): #@save
"""训练模型(定义见第3章)"""
animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
legend=['train loss', 'train acc', 'test acc'])
for epoch in range(num_epochs):
train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
test_acc = evaluate_accuracy(net, test_iter)
animator.add(epoch + 1, train_metrics + (test_acc,))
train_loss, train_acc = train_metrics
assert train_loss < 0.5, train_loss
assert train_acc <= 1 and train_acc > 0.7, train_acc
assert test_acc <= 1 and test_acc > 0.7, test_acc

1769175797353

bug

在最后的softmax回归的简洁实现中,遇到错误:

1769175892875

修复:

按住ctrl然后点击d2l,进入d2l.torch包中,在包中添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def accuracy(y_hat, y):  #@save
"""计算预测正确的数量"""

# 检查 y_hat是否为二维矩阵
# 如果维度大于1且列数大于1,说明y_hat存储的是预测分数/概率
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
# 获取一行中最大值所在的索引,即预测的类别标签
y_hat = y_hat.argmax(axis=1)
# 将y_hat的数据类型转换为与真实标签y一致,并进行逐元素比较
# cmp是一个布尔类型的Tensor, 预测正确为True,错误为False
cmp = y_hat.type(y.dtype) == y
# 将布尔张量转换为与y相同类型的数字(True = 1, False = 0)
# 求和得到预测正确的总数,最后转化为float返回
return float(cmp.type(y.dtype).sum())

def evaluate_accuracy(net, data_iter): #@save
"""计算在指定数据集上模型的精度"""

# 初始化一个累加器Accumulator,用于存储2个变量
# metric[0]:预测正确的样本总数
# metric[1]:总样本数
metric = Accumulator(2)

# 禁用梯度计算,减少内存消耗并加快计算速度
with torch.no_grad():
# 遍历数据迭代器中的每一个Batch
for X, y in data_iter:
# net(X) 是模型的前向传播预测结果
# accuracy(...) 计算当前批次中样本预测正确的数量
# metric.add 将这两个值累加到累加器中
metric.add(accuracy(net(X), y), y.numel())

# 计算并返回总精度:预测正确数/样本总数
return metric[0] / metric[1]


def train_epoch_ch3(net, train_iter, loss, updater): #@save
"""训练模型一个迭代周期(定义见第3章)"""
# 将模型设置为训练模式
if isinstance(net, torch.nn.Module):
net.train()
# 训练损失总和、训练准确度总和、样本数
metric = Accumulator(3)
for X, y in train_iter:
# 计算梯度并更新参数
y_hat = net(X)
l = loss(y_hat, y)
# 使用PyTorch内置的优化器和损失函数
# 梯度清零,防止梯度累加
updater.zero_grad()
# 反向传播,计算梯度
l.mean().backward()
# 更新模型参数
updater.step()

metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
# 返回训练损失和训练精度
return metric[0] / metric[2], metric[1] / metric[2]

def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater): #@save
"""训练模型(定义见第3章)"""
animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
legend=['train loss', 'train acc', 'test acc'])
for epoch in range(num_epochs):
train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
test_acc = evaluate_accuracy(net, test_iter)
animator.add(epoch + 1, train_metrics + (test_acc,))
train_loss, train_acc = train_metrics
assert train_loss < 0.5, train_loss
assert train_acc <= 1 and train_acc > 0.7, train_acc
assert test_acc <= 1 and test_acc > 0.7, test_acc

动手学习深度学习——线性神经网络
http://blog.ulna520.com/2026/01/16/动手学习深度学习_线性神经网络_20260116_145806/
Posted on
January 16, 2026
Licensed under