Part 3 线性神经网络
约 3510 字大约 12 分钟
2025-05-26
我们已经简单学习了 PyTorch 的基本使用,以及了解了深度学习的核心概念——反向传播。接下来我们以最简单的神经网络——线性神经网络为例,介绍更重要的一些概念。
1 线性回归的数学基础
1.1 线性回归
寻找自变量和因变量之间的关系的过程被称为回归。例如一元线性回归:对于一系列坐标为(x,y)的散点,寻找到一个关系y=f(x),使得这个关系能够较好地反映散点的分布。
回归是为了预测,当我们根据已有的散点得到一个关系y=f(x)后,就可以计算出未知的数据(当然不可能是绝对精确的)。
线性回归不囿于一个自变量对应一个因变量,也有多元线性回归的案例。例如我们现在是二手房的中介,二手房的价格会和房屋面积和房龄挂钩:
Price=w1Aera+w2Year+b
其中Aera和Year为两个不相关的自变量,我们称之为特征,而Price为需要预测的因变量,我们称之为标签。w1和w2称之为权重,表明某一特征对标签的影响。b为偏置,用于确定当所有特征均为 0 时标签的值。权重和偏置合称模型的参数。
因此一个具有n个自变量的线性回归模型应该为:
y^=i=1∑nwixi+b
将wi和xi作为向量输入,线性回归又可以写成向量点积的形式:
y^=wTx+b
其中w和x都为n维向量。
这样我们就会知道如何通过一组已经确定的特征来预测标签。
而当我们有m条数据时:
y1y2y3⋮ymx11x21x31⋮xm1x12x22x32⋮xm2x13x23x33⋮xm3⋯⋯⋯⋯x1nx2nx3n⋮xmn
我们当然可以所有的标签写成向量y,把所有的特征写成矩阵X。
即对于特征矩阵X和一一对应的权重向量w,有:
y^=Xw+b
注意,这里我们用了带帽子的y^而不是y。带帽子的通常y^表示预测得到的值,而不带帽子的y为真实值。
这样我们就得到了一个使用多组特征来预测多个标签的大号的线性模型。它其实就是m个y^=wTx+b拼起来的方程组。
1.2 损失函数
我们已经知道了线性模型的任务:确定一组权重和偏置来预测未知标签。那么预测质量应该如何度量?自然应该将预测值y^和真实值y进行比较。
量化预测值和真实值之间差距的函数称为损失函数。当损失函数为 0 时,这组权重就可以完美预测标签(尽管这几乎不可能)。
先来认识在回归问题中最经典的损失函数:平方误差。当第i个样本的预测值为y^i、真实值为yi时,损失函数
l(i)(w,b)=21(y^i−yi)2
容易看出损失函数只与模型参数w和b有关,因为:
y^i=wTxi+b
xi和yi都为真实值,即常数。
当然了,这只是一个样本,我们需要度量在整个数据集n个样本的预测质量。我们使用n个样本的损失函数的均值:
L(w,b)=n1i=1∑nl(i)(w,b)
我们希望能找到一组模型参数w和b,使得所有样本的损失最小。这就是神经网络的最终目的。
1.3 随机梯度下降
线性回归是一个很好解的问题,因为要求w的话,只需要进行一些简单的操作即可得到一个公式来表达。这个公式称为解析解。
但并非所有问题都有解析解,我们只能使用数值解法来求解问题。
既然损失函数越低越好,那么我们是否可以先确定下一组初始的模型参数,然后找到损失函数下降的方向,针对性地调整这组参数呢?
自然是可行的。这种被称为梯度下降的方法几乎可以用来训练所有模型。
于是新的一组参数:
(w,b)t+1=(w,b)t−∇(w,b)l(w,b)
梯度的大小一般难以控制,为了避免步子太大扯着蛋,我们将其乘以一个非常小的常数η:
(w,b)t+1=(w,b)t−η∇(w,b)l(w,b)
常数η称为学习率。
有的时候数据集太大,计算梯度的过程非常慢,我们就可以从中随机抽取一小批的B条数据来近似整个数据集:
(w,b)t+1=(w,b)t−Bηi∈B∑∇(w,b)l(i)(w,b)
常数B称为批量大小。
这种更新参数的方法称为小批量随机梯度下降。学习率η和批量大小B是不受训练过程影响、可以手动调整的参数,称之为超参数。所谓的调参就是选择超参数的过程。
在训练完成(达到迭代次数或者损失函数满足条件)后,我们记录下这组模型参数,就可以用来计算一组特征的标签了。不过,即使我们提供的数据是完全线性的,在有限次迭代内也不会使得损失完全等于 0,而是只能无限趋近。这就是数值解法的特点之一。
1.4 预测
在得到计算好的模型参数后,就可以用来计算一组给定特征的标签了。这个过程称为预测。事实上,更难的是找到一组参数能够在从未训练过的数据集上达到较小的损失,这一挑战称为泛化。
2 从零实现线性回归
回顾一下线性模型的工作原理:
初始化模型参数
从数据集中读取小批量
计算梯度和损失函数
更新参数,重复步骤 3. 直至满足停机要求(达到迭代次数或者达到预计损失)
2.1 数据集准备
在训练模型之前,我们需要先准备一个数据集。当然我们没有真实数据集,所以只能自己生成一个模拟。
我们使用如下参数生成数据集:
y=Xw+b+ϵ
其中w=[2,−3.4]T,b=4.2,ϵ为服从均值为 0、标准差为 0.01 的正态分布的噪声项(因为真实数据集不可能是完美线性的)。
我们直接给出在 PyTorch 中的正态分布方法:torch.normal()
。
相关信息
torch.normal(mean, std, size=None, out=None)
接收四个参数,输出一个张量:
mean
:均值,可以是一个 float 标量或者一个张量。
std
:标准差,可以是一个 float 标量或者一个张量。
size
(可选):当mean
和std
是标量时,需要此参数指定生成张量的形状(张量的每个元素都符合指定的正态分布)。
out
(可选):指定输出的张量。
定义一个函数,这个函数能够按照我们设定好的真实参数生成我们想要数量的样本。
def generate_dataset(true_w, true_b, samples_size):
X = torch.normal(0, 1, (samples_size, len(true_w)))
y = torch.matmul(X, true_w) + true_b
y += torch.normal(0, 0.01, y.shape)
return X, y
2.2 随机小批量读取数据集
按照批次大小,每次从数据集中随机选取个样本用于训练:
def read_dataset(features, labels, batch_size):
features_num = features.shape[0]
# 生成对应个数的索引并打乱
indices = torch.randperm(features_num)
for i in range(0, features_num, batch_size):
# 取出每个 batch 的样本索引
batch_indices = indices[i:i + batch_size]
# 根据索引取出样本
yield features[batch_indices], labels[batch_indices]
2.3 计算梯度下降和损失函数
首先再次明确:计算的是模型参数w和b。模型参数以向量的方式传入并计算。
def sgd(params, batch_size, lr):
with torch.no_grad():
# 逐个参数计算梯度并更新
for param in params:
param -= lr * param.grad / batch_size
# 清零梯度
param.grad.zero_()
相关信息
无需关心梯度如何计算。事实上,每个torch.nn.Parameter
都有一个grad
属性,用来存放该参数的梯度值。这个值是在调用.backward()
后由自动求导引擎自动计算并填充的。
然后实现均方误差损失函数:
def loss(y_hat, y):
return torch.mean((y_hat - y) ** 2)/2
2.4 训练
在训练之前,我们还需要定义最重要的线性模型:
def linear_model(X, w, b):
return torch.matmul(X, w) + b
所有模块准备完成,准备组装成最终的训练模型。
def train(X, y, batch_size, lr, epochs):
# 初始化参数
w = torch.normal(0, 0.01, true_w.shape, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
# 迭代训练
for epoch in epochs:
# 按批次取出样本
for batch_X, batch_y in read_dataset(X, y, batch_size):
# 根据模型计算预测值
y_hat = linear_model(batch_X, w, b)
# 计算损失
l = loss(y_hat, batch_y)
# 反向传播
l.backward()
# 更新参数
sgd([w, b], batch_size, lr)
# 清零梯度
w.grad.zero_()
b.grad.zero_()
print(f'Epoch {epoch + 1}, Loss: {l.item()}')
return w, b
在主函数中完成整个流程。
if __name__ == "__main__":
# 生成数据集
samples_size = 1000
true_w = torch.tensor([2, -3.4])
true_b = 4.2
X, y = generate_dataset(true_w, true_b, samples_size)
# 设置超参数
batch_size = 10
lr = 0.05
# 训练模型
epochs = range(5)
w, b = train(X, y, batch_size, lr, epochs)
print(f'w: {w.squeeze().tolist()}, b: {b.item()}')
运行模型,得到结果:
Epoch 1, Loss: 4.8588762283325195
Epoch 2, Loss: 0.8850000500679016
Epoch 3, Loss: 0.09257102012634277
Epoch 4, Loss: 0.022525204345583916
Epoch 5, Loss: 0.0018273761961609125
w: [1.9857627153396606, -3.364879608154297], b: 4.167705535888672
可以看到计算得到的参数和真实参数差距非常小,如果我们进行更多次迭代(20次),这个差距会越来越小:
w: [2.000117063522339, -3.4001355171203613], b: 4.2001142501831055
3 使用框架实现线性回归
尽管我们已经动手写了一个可用的线性回归,但还是太粗糙简陋了。
深度学习框架可以为我们提供更标准、简洁的实现。
数据集准备仍然需要我们自己完成,PyTorch 已经为我们准备好了其他模块。
3.1 使用DataLoader
读取数据集
PyTorch已经为我们准备好了专门用来读取数据的工具,在使用之前,需要先导入:
from torch.utils import data
data
模块中提供了.TensorDataset
类和.DataLoader
类。
TensorDataset
接收若干个张量,并将多个张量打包成一个数据集。
DataLoader
用来按批次读取数据集,并提供乱序、并行加载等功能,提供迭代接口用于for batch_X, batch_y in dataloader
形式的训练循环。
def read_dataset(features, labels, batch_size, is_train=True):
dataset = data.TensorDataset(features, labels)
return data.DataLoader(dataset, batch_size, shuffle=is_train)
3.2 使用nn
模块定义线性模型和损失函数
在使用前需要先导入模块。
from torch import nn
nn 是神经网络的缩写。nn
模块提供了一个类Sequential
用于将多个层连接在一起。当给定输入数据时,Sequential
实例将数据传入第一层,然后将第一层的输出传入第二层,以此类推。
nn
模块中也提供了线性模型Linear
供我们使用,它接收两个参数,分别为输入特征和输出特征的形状。
nn.Sequential(
nn.Linear(true_w.shape[0], 1)
)
这样我们就定义了一个只有 1 层的线性模型。
均方误差损失函数直接使用nn.MSELoss
类即可。它接收预测值和真值,自动计算均方误差。
loss = nn.MSELoss()
3.3 使用SGD
模块定义梯度下降算法
随机梯度下降算法也有现成的模块。
torch.optim.SGD(net.parameters(), lr)
3.4 训练
所有模块准备就绪,拼装成训练函数即可:
def train(data_loader, lr, epochs):
# 定义线性神经网络
net = nn.Sequential(
nn.Linear(true_w.shape[0], 1)
)
# 初始化模型参数
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)
loss = nn.MSELoss()
optimizer = torch.optim.SGD(net.parameters(), lr)
for epoch in range(epochs):
for batch_X, batch_y in data_loader:
# 前向传播
y_hat = net(batch_X).squeeze(-1)
# 计算损失
l = loss(y_hat, batch_y)
# 反向传播
optimizer.zero_grad()
l.backward()
# 更新参数
optimizer.step()
print(f'Epoch {epoch + 1}, Loss: {l.item()}')
return net[0].weight, net[0].bias
在初始化模型参数时,我们直接使用net[0]
选中第一层线性网络(好吧,我们的模型只有一层),也就是Linear
层。Linear
类在实例化时自动创建了权重.weight
和偏置bias
,我们直接使用即可。
在前向传播计算y_hat
时,从net
中自动计算出的是一个形状为 [10, 1] 的张量,而我们的batch_y
是一个形状为 [10] 的张量。形状不匹配,可能会触发广播机制,要用.squeeze()
方法统一形状。
相关信息
[10] 是“一维张量”,没有明确的方向。它只是一个值的序列,没有“行”或“列”的概念。
在数学上,线性代数区分行向量(1×n)和列向量(n×1),但 PyTorch 中 [10] 是一维的,不能区分这两者。
[10, 1] 明确表示“列向量”。二维张量,有 10 行、1 列,通常是列向量。
[1, 10] 明确表示“行向量”。二维张量,有 1 行、10 列,通常是行向量。
反向传播的过程中,l.backward()
仅仅是计算每个参数的梯度,而optimizer.step()
这一步才是更新参数的值。
主函数如下:
if __name__ == '__main__':
# 生成数据集
samples_size = 1000
true_w = torch.tensor([2, -3.4])
true_b = 4.2
X, y = generate_dataset(true_w, true_b, samples_size)
# 设置超参数
batch_size = 10
lr = 0.03
# 读取数据集
data_loader = read_dataset(X, y, batch_size, is_train=True)
# 训练
epochs = 3
w, b = train(data_loader, lr, epochs)
print(f'w: {w.squeeze().tolist()}, b: {b.item()}')
启动训练,得到结果:
w: [2.0002284049987793, -3.4005472660064697], b: 4.199688911437988