Featured image of post PyTorch 学习笔记

PyTorch 学习笔记

学习记录 PyTorch,记录开发学习过程

PyTorch 实操笔记

1. 简单介绍

  • PyTorch 是一个深度学习框架,主要用于研究和生产环境
  • 由Meta AI(Facebook)人工智能研究小组开发的一种基于Lua编写的Torch库的Python实现的深度学习库,目前被广泛应用于学术界和工业界,相较于Tensorflow2.x,PyTorch在API的设计上更加简洁、优雅和易懂。

2. 前置技术依赖


3. 环境配置

  1. 查看显卡 cuda 版本
1
2
# windows 命令行中输入命令查看显卡信息
nvidia-smi

PyTorch 向下兼容 CUDA 版本,根据 CUDA 版本安装合适的 PyTorch 版本:

  1. 安装或更新 CUDA

2.1 通过官网安装 CUDA

CUDA 官网 下载并安装合适版本的 CUDA

2.2 Conda 安装 CUDA 工具包

  • 这种方式可以为每个 Conda 环境单独安装 CUDA 版本,避免版本冲突
1
2
3
4
5
6
7
8
# CUDA 
conda install cuda -c nvidia

# 指定版本安装,例如安装 CUDA 11.8.0
conda install cuda -c nvidia/label/cuda-11.8.0

# 安装完成后,使用以下命令测试安装成功
nvcc -V
  1. 安装 PyTorch

根据官网提供的安装命令安装 PyTorch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 示例:安装带有 CUDA 13.0 支持的 PyTorch 
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu130

# 验证 CUDA PyTorch
import torch

print(torch.__version__)
print(torch.version.cuda)
print(torch.backends.cudnn.version())
print(torch.cuda.is_available())
print(torch.cuda.get_device_name(0))

4. 基础用法

4.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

# 张量创建
## 使用数据创建
data = [[1, 2], [3, 4]]
x_data = torch.tensor(data)

##使用 NumPy 数组创建
np_array = np.array(data)
x_np = torch.from_numpy(np_array)

## 使用已有张量创建
x_ones = torch.ones_like(x_data)  # retains the properties of x_data
x_rand = torch.rand_like(x_data, dtype=torch.float) # overrides the datatype of x_data

## 通过随机或常量创建
shape = (2, 3,)
rand_tensor = torch.rand(shape) # [0,1]随机分布
rand_tensor = torch.randn(shape)# 正态分布
ones_tensor = torch.ones(shape)
zeros_tensor = torch.zeros(shape)

# 张量属性
tensor = torch.rand(3, 4)

print(f"Shape of tensor: {tensor.shape}")
print(f"Datatype of tensor: {tensor.dtype}")
print(f"Device tensor is stored on: {tensor.device}")
 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
# 张量操作

## 移动到 GPU(如果可用)
if torch.cuda.is_available():
    tensor = tensor.to("cuda")
print(f"Device tensor is stored on: {tensor.device}")


## 加法操作

import torch
x = torch.rand(4, 3) 
y = torch.rand(4, 3) 
# 方式1
print(x + y)

# 方式2
print(torch.add(x, y))

# 方式3 in-place,原值修改
y.add_(x) 
print(y)

## 索引操作

# 取第二列
y-x[0,:]
y+=1 # 源 tensor 也被修改
操作是否创建新张量是否共享内存是否原地修改备注
b = a仅复制引用
b = a.clone()深拷贝,新建
b.copy_(a)原地复制数据
b = a[:]浅拷贝(切片共享内存)
b = a[[0, 2]]花式索引,深拷贝
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
## 维度变换

# torch.view() 新建视图张量,和原张量共享内存
x = torch.randn(4, 4)
y = x.view(16) # 展平为一维张量
z = x.view(-1, 8) # 自动计算行数,列数为8 自动计算为(2,8)


# 先 clone 再 view,避免共享内存 
x = torch.randn(4, 4)
y = x.clone().view(16) # 展平为一维张量
# clone操作会被记录在计算图中,梯度回传到副本时也会传到源 Tensor

## 取值操作
# 可以使用 .item() 来获得这个 value,而不获得其他结构

x = torch.randn(1) 
print(type(x)) # tensor
print(type(x.item())) # float
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
## 广播机制
# 当对两个形状不同的 Tensor 按元素运算时,可能会触发广播(broadcasting)机制:先适当复制元素使这两个 Tensor 形状相同后再按元素运算。

x = torch.arange(1, 3).view(1, 2)
print(x)
y = torch.arange(1, 4).view(3, 1)
print(y)
print(x + y)

#output:
tensor([[1, 2]])
tensor([[1],
        [2],
        [3]])
tensor([[2, 3],
        [3, 4],
        [4, 5]])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
## 节省内存

before = id(Y) # id() 函数获取内存地址
Y = Y + X
id(Y) == before # False # Y 已经被重新分配了内存

# 原地操作,节省开销

## 切片

before = id(X)
X[:] = X + Y
# or X += Y
id(X) == before # True  内存地址未变

4.2 自动求导

PyTorch 中,所有神经网络的核心是 autograd 包。autograd包为张量上的所有操作提供了自动求导机制。它是一个在运行时定义 ( define-by-run )的框架,这意味着反向传播是根据代码如何运行来决定的,并且每次迭代可以是不同的。

1
2
3
4
5
6
7
8
# 设置 torch,Tensor 的 requires_grad 属性为 True ,表示需要计算梯度
x = torch.ones(2, 2, requires_grad=True)
# 调用 .backward() 进行反向传播 计算所有梯度,并累加到 .grad 属性中
y = x + 2
z = y * y * 3
out = z.mean()# 计算均值
out.backward()
print(x.grad) # [[4.5, 4.5], [4.5, 4.5]]

backward_1

  • 只有标量函数(即输出为单个元素的张量)的 .backward() 方法才能被调用。如上例中,out 为 z 的均值,z 中每一个元素对 out 的梯度贡献均等,因此 x.grad 中的元素均为 18/4=4.5 。
1
2
3
4
5
6
7
8
# .detach() 方法 分离目标张量出计算图,防止跟踪,阻断反向传播

x = torch.randn(3, requires_grad=True)
y = x * 2
z = y.mean()
# 分离 z 出计算图
z_detached = (z.detach())+1
z_detached.backward() # 报错,z_detached 不在计算图中
1
2
3
4
5
6
7
## Function 类
# Function 对象表示一个张量操作,并且知道如何计算该操作的前向传播和反向传播。它们与张量通过 `grad_fn` 连接。
x = torch.randn(3, requires_grad=True)
y = x + 2
print(y.grad_fn)  # <AddBackward0 object at ...>
z = y * y * 3
print(z.grad_fn)  # <MulBackward0 object at ...>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
## 梯度清零
# 默认情况下,每次调用 .backward() 时,梯度会累加到 .grad 属性中。因此,在反向传播之前,通常需要将梯度清零。
x = torch.randn(3, requires_grad=True)
y = x.sum()
y.backward()
print(x.grad)  # 输出梯度 tensor([1., 1., 1.])
z=y*2
x.grad.zero_()  # 清零
z.backward()
print(x.grad)  # tensor([2., 2., 2.])
# 如果不清零 累加为 tensor([3., 3., 3.])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
## 雅可比矩阵向量积
# 对于非标量输出,可以通过向 .backward() 传递一个与输出形状相同的张量来计算雅可比矩阵向量积(Jacobian-vector product)。
x = torch.randn(3, requires_grad=True)
print(x)

y = x * 2
i = 0
while y.data.norm() < 1000:
    y = y * 2
    i = i + 1
print(y)
print(i)

v = torch.tensor([0.1, 1.0, 0.0001], dtype=torch.float)
y.backward(v)

print(x.grad)

# output:

tensor([5.1200e+01, 5.1200e+02, 5.1200e-02])
 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
## no_grad 环境
# 在 `with torch.no_grad()` 环境中,所有计算都不会被跟踪
print(x.requires_grad)
print((x ** 2).requires_grad)

with torch.no_grad():
    print((x ** 2).requires_grad)

# output:
True
True
False

## 修改 tensor 的数值,不被 autograd 记录(即不会影响反向传播), 则对 tensor.data 进行操作:
x = torch.ones(1,requires_grad=True)

print(x.data) # 还是一个tensor
print(x.data.requires_grad) # 但是已经是独立于计算图之外

y = 2 * x
x.data *= 100 # 只改变了值,不会记录在计算图,所以不会影响梯度传播

y.backward()
print(x) # 更改data的值也会影响tensor的值 
print(x.grad)

# output:
tensor([1.])
False
tensor([100.], requires_grad=True)
tensor([2.])

4.3 并行计算 CUDA

我们可以使用 .cuda() 方法将模型、张量或数据移动到 GPU 上进行计算:

  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
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
# 选择指定显卡 
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "0"  # 设置默认使用的 GPU 设备为 0 号

## 使用 0,1 号两块显卡
CUDA_VISIBLE_DEVICES=0,1 python your_code.py
# os.environ["CUDA_VISIBLE_DEVICES"] = "0,1"

# 将模型移动到 GPU
model = MyModel()
model.cuda()  # 或者 model.to('cuda')
# 将张量移动到 GPU
tensor = tensor.cuda()  # 或者 tensor.to('cuda')

for image,label in data_loader:
    image = image.cuda()
    label = label.cuda()
    # 将图像和标签移动到 GPU 上进行训练

# 多卡训练

## 单机多卡 DP DataParallel

model = Net()
model.cuda() # 模型显示转移到CUDA上

if torch.cuda.device_count() > 1: # 含有多张GPU的卡
	model = nn.DataParallel(model) # 单机多卡DP训练

# 指定使用的GPU卡号

model = nn.DataParallel(model, device_ids=[0,1]) # 使用第0和第1张卡进行并行训练

#通过DP进行分布式多卡训练的方式容易造成负载不均衡,有可能第一块GPU显存占用更多,因为输出默认都会被gather到第一块GPU上。

## 多机多卡 DDP DistributedDataParallel

## 针对每个GPU,启动一个进程,然后这些进程在最开始的时候会保持一致(模型的初始化参数也一致,每个进程拥有自己的优化器),同时在更新模型的时候,梯度传播也是完全一致的,这样就可以保证任何一个GPU上面的模型参数就是完全一致的,所以这样就不会出现DataParallel那样显存不均衡的问题。

## GROUP:进程组,默认情况下,只有一个组,一个 job 即为一个组,也即一个 world。(当需要进行更加精细的通信时,可以通过 new_group 接口,使用 world 的子集,创建新组,用于集体通信等。)

## WORLD_SIZE:表示全局进程个数。如果是多机多卡就表示机器数量,如果是单机多卡就表示 GPU 数量。

## RANK:表示进程序号,用于进程间通讯,表征进程优先级。rank = 0 的主机为 master 节点。 如果是多机多卡就表示对应第几台机器,如果是单机多卡,由于一个进程内就只有一个 GPU,所以 rank 也就表示第几块 GPU。

## LOCAL_RANK:表示进程内,GPU 编号,非显式参数,由 torch.distributed.launch 内部指定。例如,多机多卡中 rank = 3,local_rank = 0 表示第 3 个进程内的第 1 块 GPU。

import os
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, distributed
from torch import nn, optim
import argparse

def setup(rank, world_size):
    # 初始化进程组
    dist.init_process_group(
        backend='nccl',          # GPU 通常用 nccl, CPU 可用 gloo
        init_method='env://',    # 使用环境变量初始化
        world_size=world_size,   # 进程总数
        rank=rank                # 当前进程编号
    )

def cleanup():
    dist.destroy_process_group()

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--local_rank", type=int)
    args = parser.parse_args()

    # 1️⃣ 设置 GPU 设备
    local_rank = args.local_rank
    torch.cuda.set_device(local_rank)

    # 2️⃣ 初始化分布式环境
    setup(local_rank, world_size=int(os.environ["WORLD_SIZE"]))

    # 3️⃣ 创建模型并移动到对应 GPU
    model = nn.Linear(10, 1).to(local_rank)

    # 4️⃣ 使用 DDP 包装模型
    model = DDP(model, device_ids=[local_rank], output_device=local_rank)

    # 5️⃣ 数据加载(使用 DistributedSampler)
    dataset = torch.utils.data.TensorDataset(torch.randn(100, 10), torch.randn(100, 1))
    sampler = distributed.DistributedSampler(dataset)
    dataloader = DataLoader(dataset, batch_size=8, sampler=sampler)

    # 6️⃣ 定义优化器与损失
    optimizer = optim.SGD(model.parameters(), lr=0.01)
    loss_fn = nn.MSELoss()

    # 7️⃣ 训练循环
    for epoch in range(5):
        sampler.set_epoch(epoch)  # 保证每个 epoch 数据不同
        for x, y in dataloader:
            x, y = x.to(local_rank), y.to(local_rank)
            pred = model(x)
            loss = loss_fn(pred, y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        if local_rank == 0:  # 只在主进程打印日志
            print(f"Epoch {epoch} loss: {loss.item():.4f}")

    cleanup()

if __name__ == "__main__":
    main()

4.4 概率

1
2
3
4
5
6
7
# from torch.distributions import multinomial
import torch
from torch.distributions import multinomial
fair_probs = torch.ones([6]) / 6 # 公平骰子的概率分布
multinomial.Multinomial(10000, fair_probs).sample() # 掷10000次骰子的结果
counts / 1000  # 相对频率作为估计值
# tensor([0.1670, 0.1680, 0.1670, 0.1670, 0.1650, 0.1660])

4.5 查看函数用法

1
2
?# 在 Jupyter Notebook 中使用 ? 查看函数用法
torch.sum?

5. 深度学习整体流程

阶段核心内容关键说明
1. 数据准备加载与预处理数据使用 DatasetDataLoader,进行划分(train/val/test)与归一化、增强等处理
2. 模型定义构建神经网络结构继承 nn.Module,在 forward() 定义前向传播
3. 损失函数选择优化目标常用:分类用 CrossEntropyLoss,回归用 MSELoss
4. 优化器设置定义参数更新规则AdamSGD,指定学习率与权重衰减
5. 训练循环前向传播 → 计算损失 → 反向传播 → 更新参数核心训练逻辑,包含梯度清零与反向传播
6. 验证评估在验证集上测试模型性能关闭梯度计算,评估准确率、损失等指标
7. 模型保存保存最佳模型权重使用 torch.save(model.state_dict(), path)
8. 测试与推理使用训练好的模型预测加载权重后在新数据上进行 model.eval() 推理

5.1 线性回归

  1. 线性模型

1

  1. 损失函数

2

  1. 梯度下降

3

5.2 模型预测

给定“已学习”的线性回归模型 $\hat{\mathbf{w}}^{\mathrm{T}} \mathbf{x} + \hat{b}$ , 现在我们可以通过房屋面积$x_1$和房龄$x_2$来估计一个(未包含在训练数据中的)新房屋价格。 给定特征估计目标的过程通常称为预测(prediction)或推断(inference)。

  1. 矢量化加速
  • 标量计算:逐个样本计算预测值 (for 循环)
  • 矢量化计算:通过矩阵运算一次性计算多个样本的预测值
  • 对计算进行矢量化,时处理整个小批量的样本
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
n=100000
a=torch.ones([n])
b=torch.ones([n])

# for
c = torch.zeros(n)
for i in range(n):
    c[i] = a[i] + b[i]

# 矢量化
d = a + b
  1. 正态分布与平方损失
  • 随机变量 $x$ 服从均值为 $\mu$ 、方差为 $\sigma^2$ 的正态分布,记为 $x \sim \mathcal{N}(\mu, \sigma^2)$ ,其概率密度函数为: $$p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}\exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)$$
1
2
3
4

def normal(x, mu, sigma):
    p = 1 / math.sqrt(2 * math.pi * sigma**2)
    return p * np.exp(-0.5 / sigma**2 * (x - mu)**2)

5.3 线性回归的 nn 典型实现


1️⃣ 简要概括典型结构

一个典型的线性回归神经网络结构主要包括:

  1. 数据集(Dataset & DataLoader):生成或读取训练数据,并按 batch 迭代
  2. 模型(Model):核心层是 nn.Linear,表示线性映射 (y = Xw + b)
  3. 损失函数(Loss):回归问题常用 MSELoss 或 L1Loss
  4. 优化器(Optimizer):更新参数 w、b,常用 SGD 或 Adam

总体结构: 数据 → 模型前向 → 损失计算 → 反向传播 → 参数更新


2️⃣ 每一部分的具体代码实现

数据集与 DataLoader
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import torch
from torch.utils.data import TensorDataset, DataLoader

# 随机生成数据
n_samples, n_features = 1000, 3
X = torch.randn(n_samples, n_features)
true_w = torch.tensor([2.0, -3.4, 1.5])
true_b = 4.2
y = X @ true_w + true_b + 0.01 * torch.randn(n_samples)
y = y.view(-1, 1)

# 打包数据集并创建 DataLoader
dataset = TensorDataset(X, y)
loader = DataLoader(dataset, batch_size=32, shuffle=True)

模型定义

方法 A:继承 nn.Module

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import torch.nn as nn

class LinearRegressionModel(nn.Module):
    def __init__(self, input_dim, output_dim):
        super().__init__()
        self.linear = nn.Linear(input_dim, output_dim)
    
    def forward(self, x):
        return self.linear(x)

model = LinearRegressionModel(n_features, 1)

方法 B:nn.Sequential

1
model = nn.Sequential(nn.Linear(n_features, 1))

损失函数
1
2
criterion = nn.MSELoss()  # 均方误差
# 也可以使用 nn.L1Loss()

优化器
1
2
3
4
5
import torch.optim as optim

optimizer = optim.SGD(model.parameters(), lr=0.01)
# 或使用 Adam
# optimizer = optim.Adam(model.parameters(), lr=0.01)

3️⃣ 带实际例子的完整实现

 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
num_epochs = 50

for epoch in range(num_epochs):
    for X_batch, y_batch in loader:
        # 前向传播
        y_pred = model(X_batch)
        
        # 计算损失
        loss = criterion(y_pred, y_batch)
        
        # 清空梯度
        optimizer.zero_grad()
        
        # 反向传播
        loss.backward()
        
        # 更新参数
        optimizer.step()
    
    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")

# 查看学习到的参数
for name, param in model.named_parameters():
    print(name, param.data)

4️⃣ 注意事项

  1. 梯度清空

    • 每次更新前必须调用 optimizer.zero_grad() 或手动清零 param.grad
    • 否则梯度会累加,导致参数更新过大
  2. 学习率选择

    • lr 太大 → 训练不稳定
    • lr 太小 → 收敛慢
  3. Batch 平均梯度

    • 使用小批量时,梯度通常按 batch 平均更新
    • 保持更新幅度稳定
  4. 模型参数访问

    • model.parameters() → 所有可训练参数
    • param.grad → 当前 batch 的梯度
  5. 数据维度

    • 输入 X shape [batch_size, n_features]
    • 输出 y shape [batch_size, 1]

5.4 softmax 回归的 nn 典型实现

  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
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import torch
import torchvision
from torch.utils import data
from torchvision import transforms
import matplotlib.pyplot as plt
from torch import nn
from IPython import display

# ---------------- 数据加载 ----------------
def get_dataloader_workers():
    """使用 4 个进程来读取数据(Windows/WSL 可改为0)"""
    return 4

def load_data_fashion_mnist(batch_size, resize=None):
    """下载 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(), pin_memory=True),
            data.DataLoader(mnist_test, batch_size, shuffle=False,
                            num_workers=get_dataloader_workers(), pin_memory=True))

# ---------------- 工具函数 ----------------
class Accumulator:
    """在 n 个变量上累加"""
    def __init__(self, n):
        self.data = [0.0] * n
    def add(self, *args):
        self.data = [a + float(b) for a, b in zip(self.data, args)]
    def reset(self):
        self.data = [0.0] * len(self.data)
    def __getitem__(self, idx):
        return self.data[idx]

def accuracy(y_hat, y):
    """计算预测正确的数量"""
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(axis=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())

def evaluate_accuracy(net, data_iter, device):
    """计算模型在数据集上的精度"""
    net.eval()
    metric = Accumulator(2)
    with torch.no_grad():
        for X, y in data_iter:
            X, y = X.to(device), y.to(device)
            metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]

# ---------------- 可视化 ----------------
def set_axes(ax, xlabel, ylabel, xlim, ylim, xscale, yscale, legend):
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    ax.set_xscale(xscale)
    ax.set_yscale(yscale)
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    if legend:
        ax.legend(legend)
    ax.grid(True)

class Animator:
    """在 Jupyter Notebook 中动态绘制曲线"""
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 fmts=('-', 'm--', 'g-.', 'r:'), figsize=(5, 3)):
        self.fig, self.ax = plt.subplots(figsize=figsize)
        self.fmts = fmts
        self.X, self.Y = [], []
        self.xlabel = xlabel
        self.ylabel = ylabel
        self.legend = legend
        self.xlim = xlim
        self.ylim = ylim
        self.xscale = xscale
        self.yscale = yscale

    def add(self, x, y):
        if not hasattr(y, "__len__"):
            y = [y]
        if not hasattr(x, "__len__"):
            x = [x] * len(y)
        if not self.X:
            self.X = [[] for _ in range(len(y))]
        if not self.Y:
            self.Y = [[] for _ in range(len(y))]
        for i, (a, b) in enumerate(zip(x, y)):
            self.X[i].append(a)
            self.Y[i].append(b)
        self.ax.cla()
        for xi, yi, fmt in zip(self.X, self.Y, self.fmts):
            self.ax.plot(xi, yi, fmt)
        set_axes(self.ax, self.xlabel, self.ylabel, self.xlim,
                 self.ylim, self.xscale, self.yscale, self.legend)
        display.display(self.fig)
        display.clear_output(wait=True)

# ---------------- 模型初始化 ----------------
def init_weights(m):
    if isinstance(m, nn.Linear):
        nn.init.normal_(m.weight, std=0.01)
        nn.init.zeros_(m.bias)

# ---------------- 训练函数 ----------------
def train_epoch_ch3(net, train_iter, loss, updater, device):
    """训练一个 epoch"""
    net.train()
    metric = Accumulator(3)
    for X, y in train_iter:
        X, y = X.to(device), y.to(device)
        y_hat = net(X)
        l = loss(y_hat, y)
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()
            l.mean().backward()
            updater.step()
        else:
            l.sum().backward()
            updater(X.shape[0])
        metric.add(l.sum().item(), 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, device):
    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, device)
        test_acc = evaluate_accuracy(net, test_iter, device)
        animator.add(epoch + 1, train_metrics + (test_acc,))
    train_loss, train_acc = train_metrics
    print(f'Train loss: {train_loss:.4f}, Train acc: {train_acc:.4f}, Test acc: {test_acc:.4f}')

# ---------------- 主程序 ----------------
batch_size = 1024
num_epochs = 10
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

train_iter, test_iter = load_data_fashion_mnist(batch_size)

net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))


net.apply(init_weights)
net = net.to(device)

trainer = torch.optim.SGD(net.parameters(), lr=0.1)
loss = nn.CrossEntropyLoss(reduction='none')

train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer, device)

5.5 多层感知机(multilayer perceptron,MLP)

  1. 为什么要用多层感知机(MLP)
  • 单层线性层只能做直线映射,无法处理复杂非线性关系
  • 加了隐藏层 + 非线性激活函数后,网络可以拟合复杂函数。
  • 理论上:单隐层+足够神经元能逼近任意函数,但训练困难。
  • 深层网络更高效:用更少神经元就能表达复杂模式。
  1. 激活函数
  • 是神经元输出的“变形器”,把输入映射成输出。
  • 主要作用:
    1. 引入非线性,让网络能处理复杂关系。
    2. 控制输出范围。
  • 常见类型:
    • Sigmoid:0~1,适合概率
    • Tanh:-1~1
    • ReLU:负数→0,正数→原值
    • Leaky ReLU/GELU/Softmax:不同改进或多分类用
  1. 激活值
  • 神经元经过激活函数后的输出。
  • 输出越大 → “活跃”,输出小或0 → “不活跃”。
  • 和权重不同,它是神经元对输入的响应。

顺便说一句,这些知识已经让你掌握了一个类似于1990年左右深度学习从业者的工具。

红温了👆

5.6 模型选择、欠拟合和过拟合

我们不想让模型只会做这样的事情:“那是鲍勃!我记得他!他有痴呆症!”。 原因很简单:当我们将来部署该模型时,模型需要判断从未见过的患者。 只有当模型真正发现了一种泛化模式时,才会作出有效的预测。 更正式地说,我们的目标是发现某些模式, 这些模式捕捉到了我们训练集潜在总体的规律。 如果成功做到了这点,即使是对以前从未遇到过的个体, 模型也可以成功地评估风险。 如何发现可以泛化的模式是机器学习的根本问题。

深度学习的目标是拟合输入与输出之间的潜在规律(可泛化模式),而非训练集特定的偶然特性。过拟合是模型对训练集偶然特性过度拟合的结果,本质上不是规律,而是对噪声的“误学习”。数据设计不合理或噪声过多会加剧这种误学习,使之在最后临门一脚时远离真正的规律。

假设硬币是公平的,无论我们想出什么算法,泛化误差始终是0.5。 然而,对于大多数算法,我们应该期望训练误差会更低(取决于运气)。 考虑数据集{0,1,1,1,0,1}。 我们的算法不需要额外的特征,将倾向于总是预测多数类, 从我们有限的样本来看,它似乎是1占主流。 在这种情况下,总是预测类1的模型将产生0.33的误差, 这比我们的泛化误差要好得多。 当我们逐渐增加数据量,正面比例明显偏离0.5时,这种情况 的可能性将会降低, 我们的训练误差将与泛化误差相匹配。

在许多实际问题中,目标本身可能存在随机性或噪声(前者类似经典物理中的复杂因素,后者类似量子力学的不确定性),导致训练集和测试集即使来自同一分布,也无法完全一致。这种固有的不确定性构成了不可约误差,是泛化误差的一部分。换句话说,模型无法完全重建目标规律,只能在有限样本和有限模型容量条件下尽可能逼近数据分布中的潜在规律。训练误差与泛化误差之间的差异还会受到样本有限性和模型容量限制的影响。随着训练数据量增加,训练误差通常会逐渐接近泛化误差,但不可约误差始终存在,因此泛化误差几乎总是大于零。

fit

5.7 权重衰减

一种简单的方法是通过线性函数 $f(x)=w^Tx$ 中的权重向量的某个范数来度量其复杂性, 例如 $||w||^2$ 。 要保证权重向量比较小, 最常用方法是将其范数作为惩罚项加到最小化损失的问题中。 将原来的训练目标最小化训练标签上的预测损失, 调整为最小化预测损失和惩罚项之和。 现在,如果我们的权重向量增长的太大, 我们的学习算法可能会更集中于最小化权重范数 $||w||^2$ 。这正是我们想要的.

在神经网络中,权重向量越大,模型对输入变化越敏感,函数曲线更陡峭,能表达更多复杂模式,函数族复杂度高,容易过拟合。L2正则化(权重衰减)通过在损失函数中加入权重平方的惩罚项,限制权重增长,使输出平滑,降低模型复杂度,从而提高泛化能力。

此外,为什么我们首先使用 $L_2$ 范数,而不是 $L_1$ 范数。事实上,这个选择在整个统计领域中都是有效的和受欢迎的。$L_2$ 正则化线性模型构成经典的岭回归(ridge regression)算法,$L_1$ 正则化线性回归是统计学中类似的基本模型,通常被称为套索回归(lasso regression)。 使用 $L_2$ 范数的一个原因是它对权重向量的大分量施加了巨大的惩罚。 这使得我们的学习算法偏向于在大量特征上均匀分布权重的模型。 在实践中,这可能使它们对单个变量中的观测误差更为稳定。 相比之下,$L_1$ 惩罚会导致模型将权重集中在一小部分特征上, 而将其他权重清除为零。 这称为特征选择(feature selection),这可能是其他场景下需要的。

L2 范数正则化(岭回归)会均匀压缩所有特征的权重,使模型平稳、对噪声不敏感,因此通常优先使用;而 L1 范数正则化(套索回归)会将部分权重压为零,实现特征选择,适合需要筛掉不重要特征的场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def l2_penalty(w):
    return torch.sum(w.pow(2)) / 2

def train(lambd):
    w, b = init_params()
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    num_epochs, lr = 100, 0.003
    animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
                            xlim=[5, num_epochs], legend=['train', 'test'])
    for epoch in range(num_epochs):
        for X, y in train_iter:
            # 增加了L2范数惩罚项,
            # 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
            l = loss(net(X), y) + lambd * l2_penalty(w)
            l.sum().backward()
            d2l.sgd([w, b], lr, batch_size)
        if (epoch + 1) % 5 == 0:
            animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
                                     d2l.evaluate_loss(net, test_iter, loss)))
    print('w的L2范数是:', torch.norm(w).item())
1
train(lambd=0)

overfit

1
train(lambd=3)

weight_decay

5.8 暂退法

泛化性和灵活性之间的这种基本权衡被描述为偏差-方差权衡(bias-variance tradeoff)。 线性模型有很高的偏差:它们只能表示一小类函数。 然而,这些模型的方差很低:它们在不同的随机数据样本上可以得出相似的结果。

深度神经网络位于偏差-方差谱的另一端。 与线性模型不同,神经网络并不局限于单独查看每个特征,而是学习特征之间的交互。 例如,神经网络可能推断“尼日利亚”和“西联汇款”一起出现在电子邮件中表示垃圾邮件, 但单独出现则不表示垃圾邮件。

简单性的另一个角度是平滑性,即函数不应该对其输入的微小变化敏感。 例如,当我们对图像进行分类时,我们预计向像素添加一些随机噪声应该是基本无影响的。 1995年,克里斯托弗·毕晓普证明了 具有输入噪声的训练等价于Tikhonov正则化 (Bishop, 1995)。 这项工作用数学证实了“要求函数光滑”和“要求函数对输入的随机噪声具有适应性”之间的联系。

泛化与特征选择:模型只有关注真实、稳定的特征,而忽略噪声特征,才能具有良好的泛化能力。在深度神经网络中,噪声特征对输出的影响应尽可能微小,从而保证模型在未见数据上的稳健性。

这个想法被称为暂退法(dropout)。 暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。 这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元。 在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零。

需要说明的是,暂退法的原始论文提到了一个关于有性繁殖的类比: 神经网络过拟合与每一层都依赖于前一层激活值相关,称这种情况为“共适应性”。 作者认为,暂退法会破坏共适应性,就像有性生殖会破坏共适应的基因一样。

在训练中随机破坏部分神经元的共适应性,使模型不依赖特定神经元组合,从而忽略训练集中的噪声或无关特征,提高泛化能力。

 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
dropout1, dropout2 = 0.2, 0.5

class Net(nn.Module):
    def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
                 is_training = True):
        super(Net, self).__init__()
        self.num_inputs = num_inputs
        self.training = is_training
        self.lin1 = nn.Linear(num_inputs, num_hiddens1)
        self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
        self.lin3 = nn.Linear(num_hiddens2, num_outputs)
        self.relu = nn.ReLU()

    def forward(self, X):
        H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
        # 只有在训练模型时才使用dropout
        if self.training == True:
            # 在第一个全连接层之后添加一个dropout层
            H1 = dropout_layer(H1, dropout1)
        H2 = self.relu(self.lin2(H1))
        if self.training == True:
            # 在第二个全连接层之后添加一个dropout层
            H2 = dropout_layer(H2, dropout2)
        out = self.lin3(H2)
        return out


net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

调整 dropout 比例可以控制正则化强度,通常在 0.2-0.5 之间选择。过高会导致欠拟合,过低则正则化效果不明显,这就是调参吧。

5.9 前向传播与反向传播

  • 前向传播在神经网络定义的计算图中按顺序计算和存储中间变量,它的顺序是从输入层到输出层。
  • 反向传播按相反的顺序(从输出层到输入层)计算和存储神经网络的中间变量和参数的梯度。
  • 在训练深度学习模型时,前向传播和反向传播是相互依赖的。
  • 训练比预测需要更多的内存。

前向传播定义了目标函数在当前参数处的取值,用于计算模型输出和损失,并构建计算图;反向传播计算该目标函数在参数空间中的一阶导数,在该计算图上应用链式法则,计算损失对各参数的梯度,而学习过程本质上是在参数空间中沿负梯度方向进行迭代优化。

5.10 数值稳定性和模型初始化

5.10.1 梯度消失与爆炸

在深度神经网络中,参数更新依赖反向传播得到的梯度,而梯度必须按链式法则从后向前逐层相乘传播;由于越靠前的层离损失函数越远,其梯度要经历更多层的导数“缩放”,如果每层的梯度因子(权重大小与激活函数导数)整体小于 1,就会在连乘中迅速衰减,导致前面层梯度接近 0、参数几乎不更新,这就是梯度消失;反之,如果这些因子整体大于 1,梯度会在连乘中指数放大,使参数一次更新就发生剧烈变化甚至数值溢出,这就是梯度爆炸;因此,常见现象是后面层因路径短仍能正常更新,而前面层因路径长率先“学不动”,而真正的模型收敛应表现为在损失已足够低的前提下所有层梯度同时变小。

5.10.2 权重初始化

在多层感知机中,隐藏单元在同一层的权重如果完全相同,前向传播会产生相同激活,反向传播的梯度也相同,导致这些神经元始终沿同一方向更新,相当于浪费了隐藏单元的容量,网络表达能力受限。随机初始化打破这种对称性,使每个隐藏单元获得不同起点,前向输出和梯度逐渐分化,从而学习到不同的特征组合,提升网络的表达能力和泛化性能。

Xavier 初始化通过根据输入输出神经元数量合理随机化权重,既打破对称性,又控制每层输出方差,防止梯度消失或爆炸,使深层网络训练更稳定。

5.11 深度学习计算


6. 常见问题与解决办法


7. tips


7. 参考资料


潇洒人间一键仙
使用 Hugo 构建
主题 StackJimmy 设计