本文基于d2l项目内容整理,介绍过拟合现象及其对抗方法,包括模型选择策略和多项式拟合实验。

1. 训练误差与泛化误差

在机器学习中,我们需要区分两个重要的概念:

核心概念:

  • 训练误差 (training error):模型用训练数据集计算得到的误差
  • 泛化误差 (generalization error):将模型应用到来自总体的样本时,模型误差的期望

在实践中无法获得总体,而只能在总体随机抽取未在训练集中使用过的数据作为测试集,用测试集估计总体的泛化误差。


2. 过拟合及其对抗方法

只有当模型学到了真正的泛化模式,捕捉到了总体规律,才能作出有效的预测。

2.1 拟合状态的分类

欠拟合 (underfitting)

一开始,模型在训练集上表现一般,处于欠拟合状态。这通常意味着:

  • 模型过于简单,无法捕捉数据的复杂模式
  • 训练时间不足
  • 特征选择不当

过拟合 (overfitting)

随着训练的进行,当模型在训练集上表现良好,但在新数据上的误差较高时,认为发生了过拟合。这意味着:

  • 模型过于复杂,捕捉到了来自训练数据的噪声和不必要的细节
  • 没有学习到数据的总体模式或规律
  • 泛化能力差

合适拟合 (good fit)

理想状态下,模型既能在训练集上表现良好,又能在测试集上保持较好的性能,实现了良好的泛化。

2.2 防止过拟合的方法

为了实现合适拟合并防止过拟合,有效的方法包括:

收集更大的训练集,有助于得到更全面的模式

选用复杂度适中的模型(但过于简单的模型可能表达力不足)

使用正则化技术(如$L_1$或$L_2$正则化)惩罚模型的复杂度

训练时随机丢弃 (dropout) 部分神经元,减少模型复杂度

在验证集误差开始增加时提前停止 (early stopping)训练

使用数据增强的方式(旋转、平移、缩放等)生成更多样的训练数据

使用交叉验证选择模型的最佳参数

深度学习中的特殊情况:

在深度学习领域,往往允许一定程度的过拟合。毕竟,比起训练误差与泛化误差的差距,泛化误差更值得被关注。

若没有更广泛和多样的数据,简单模型往往更优于复杂的深度学习模型。从这个视角来看,深度学习的发展一定程度上归功于廉价存储、互联及数字化经济带来的海量数据集。


3. 使用验证集指导模型选择

模型选择是确定模型类别(如决策树之于线性模型)及其超参数(如批量大小、学习率等)的过程。

例如,希望在 MLP 模型中,找到最合适的隐藏层数量、隐藏单元数量、不同激活函数的最佳组合。

3.1 验证集

在模型选择过程中,训练集的泛化误差由测试集估计。但若在模型选择好前就使用了测试集,将带来过拟合的风险。理想情况下,每个测试数据只使用一次就丢弃,但实践中难以有充足的数据支持。

数据集划分策略:

可行的方案是将所有数据分成 3 份:

  • 训练集:用于训练模型参数
  • 验证集 (validation set):用于评估模型选择,指导调参
  • 测试集:用于最终评估模型性能

验证集和测试集在实践中并未清晰地使用和表述,甚至被混淆。在实际项目中,实验结果更多地来自验证集。

3.2 K 折交叉验证

当训练数据稀缺时,甚至难以组织完整的验证集。幸运的是,K 折交叉验证 (K-fold cross-validation) 通过将数据集划分为 K 个子集(折 fold),多次训练和评估模型,提高了数据的利用率。

K 折交叉验证的实现步骤:

  1. 数据划分:将完整的数据集随机分成 K 个(近似)等大的子集

    • 选择 K 值为 5 或 10,能在计算成本和评估稳定性间权衡平衡
  2. 迭代训练与验证:每次选择 K-1 个子集作为训练集,余下的一个子集作为验证集,训练模型,评估模型性能

  3. 重复迭代:重复迭代 K 次,每次都使用不同的验证集

  4. 计算平均性能:将 K 次迭代的结果(如准确率、精确率、召回率等)求均值以作为模型的整体性能

K 折交叉验证的优势:

  • 提高数据利用率
  • 在有限数据集上可靠地评估模型性能
  • 减小过拟合的风险
  • 提供更稳定的性能估计

4. 使用多项式探索拟合

4.1 多项式数据生成

我们使用以下三阶多项式生成数据集进行实验:

其中:

  • 为了避免较大的梯度值和损失值,将原始的系数除以$i!$
  • 噪声项$\epsilon \sim \mathcal{N}(0, 0.1^2)$

更通用的$n$阶多项式形式为:

其中,$a_i$为第$i$项系数,噪声项$\epsilon \sim \mathcal{N}(0, \sigma^2)$。

4.2 核心函数实现

多项式数据生成函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def polynomial_gen(polynomial_coefficients: list, size, noise_std=0.1) -> tuple[Tensor, Tensor]:
"""
生成 n 阶多项式数据
.. math:: y = \\sum_{i=0}^{n} \\frac{a_i}{i!} x^i + \\epsilon
:param polynomial_coefficients: a_i
:param size: 生成的数据集大小
:param noise_std: 多项式的噪声项
:return: 特征张量与标签张量的元组
"""
polynomial_coefficients = np.array(polynomial_coefficients)
orders = np.arange(len(polynomial_coefficients))

x = np.random.normal(size=(size, len(polynomial_coefficients)))
features = np.power(x, orders) / np.vectorize(math.factorial)(orders)
labels = features @ polynomial_coefficients
labels += np.random.normal(scale=noise_std, size=labels.shape)
return torch.from_numpy(features).to(torch.float32), torch.from_numpy(labels).to(torch.float32)

特征维度调整函数

为了在模型选择时方便缩放模型复杂度,即改变假设的多项式阶数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def resize_features(features: torch.Tensor, to_columns: int) -> torch.Tensor:
"""
为每一列生成新的随机数据并扩展特征张量

:param features: 原始特征张量
:param to_columns: 新特征张量的目标列数(编号从 1 开始)
:return: 扩展后的特征张量
"""
num_samples, num_features = features.shape
if to_columns > num_features:
new_data = torch.randn(num_samples, to_columns - num_features)
expanded_features = torch.cat((features, new_data), dim=1)
return expanded_features
else:
return features[:, :to_columns]

数据集划分函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def split_dataset(features: torch.Tensor, labels: torch.Tensor, train_ratio: float = 0.8) -> tuple:
"""
将数据集划分为训练集和验证集

:param features: 特征张量
:param labels: 标签张量
:param train_ratio: 训练集的比例,默认值为 0.8
:return: 训练集和验证集的特征及标签
"""
num_samples = features.size(0)
num_train = int(num_samples * train_ratio)

indices = torch.randperm(num_samples)

train_indices = indices[:num_train]
val_indices = indices[num_train:]

train_features = features[train_indices]
train_labels = labels[train_indices]
val_features = features[val_indices]
val_labels = labels[val_indices]

return train_features, train_labels, val_features, val_labels

4.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def train_test(train_features, train_labels, test_features, test_labels, epochs=1000):
net = nn.Sequential(nn.Linear(in_features=train_features.size(1), out_features=1, bias=False))
net.apply(lambda m: nn.init.normal_(m.weight, mean=0, std=0.01) if isinstance(m, nn.Linear) else None)

criterion = nn.MSELoss()
optimizer = optim.SGD(net.parameters(), LR)

# 用于保存损失和 MSE 的列表
train_losses = []
val_losses = []
train_mses = []
val_mses = []

for epoch in range(epochs):
# 训练阶段
net.train()
optimizer.zero_grad()
train_outputs = net(train_features).squeeze()
train_loss = criterion(train_outputs, train_labels)
train_loss.backward()
optimizer.step()

# 验证阶段
net.eval()
with torch.no_grad():
val_outputs = net(test_features).squeeze()
val_loss = criterion(val_outputs, test_labels)

# 记录损失和 MSE
train_losses.append(train_loss.item())
val_losses.append(val_loss.item())
train_mses.append(calculate_accuracy(train_outputs, train_labels))
val_mses.append(calculate_accuracy(val_outputs, test_labels))

# 输出训练和验证的损失
if (epoch + 1) % 100 == 0:
print(f'Epoch [{epoch + 1:4}/{epochs}]:'
f'Train Loss: {train_loss.item():.4f}, Train MSE: {train_mses[-1]:.4f}, '
f'Validation Loss: {val_loss.item():.4f}, Validation MSE: {val_mses[-1]:.4f}')

param = net[0].weight.data.numpy().flatten()
return param, train_losses, val_losses, train_mses, val_mses
查看完整代码实现
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
import math
import numpy as np
import torch
from matplotlib import pyplot as plt
from torch import Tensor, nn, optim

LR = 0.01

def calculate_accuracy(outputs: Tensor, labels: Tensor) -> float:
"""
计算模型的精度

:param outputs: 模型输出
:param labels: 真实标签
:return: 精度
"""
with torch.no_grad():
# 计算均方误差(MSE)
mse = nn.MSELoss()(outputs, labels)
return mse.item() # 返回损失值

def plot_training_history(train_losses, val_losses, train_mses, val_mses):
"""
绘制训练损失、验证损失、训练 MSE 和验证 MSE 的变化过程

:param train_losses: 训练损失列表
:param val_losses: 验证损失列表
:param train_mses: 训练 MSE 列表
:param val_mses: 验证 MSE 列表
"""

# 绘制损失变化
plt.subplot(1, 2, 1)
plt.plot(train_losses, label='Train Loss', color='#81BBF8')
plt.plot(val_losses, label='Validation Loss', color='#0C68CA')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('Training and Validation Loss')
plt.yscale('log') # 设置纵坐标为对数坐标
plt.legend()

# 绘制 MSE 变化
plt.subplot(1, 2, 2)
plt.plot(train_mses, label='Train MSE', color='#F1A2AB')
plt.plot(val_mses, label='Validation MSE', color='#AD1A2B')
plt.xlabel('Epoch')
plt.ylabel('MSE')
plt.title('Training and Validation MSE')
plt.yscale('log') # 设置纵坐标为对数坐标
plt.legend()

plt.tight_layout() # 自动调整子图间距
plt.show() # 展示图表

5. 拟合实验结果

通过改变多项式的阶数来改变模型的容量,我们可以观察不同拟合状态的表现。

5.1 简单模型欠拟合

1
2
3
4
5
6
7
8
9
coeffs = [5.0, 1.2, -3.4, 5.6]  # 三阶多项式,共 4 项系数
SIZE = 1000
EPOCHS = 1000

features, labels = polynomial_gen(coeffs, SIZE)
features = resize_features(features, to_columns=2) # 只使用2个特征

train_f, train_l, val_f, val_l = split_dataset(features, labels)
learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
1
2
3
4
5
真实的多项式系数:[5.0, 1.2, -3.4, 5.6]
习得的多项式系数:[3.1565626 1.1513788]

Epoch [1000/1000]:Train Loss: 18.4820, Train MSE: 18.4820,
Validation Loss: 25.3429, Validation MSE: 25.3429

欠拟合分析:

数据来自三阶多项式,应在拟合过程中使用 4 个系数。但若仅使用 2 个,本质上使用的是线性模型,对复杂的数据表现为欠拟合。

  • 训练损失和验证损失都较高
  • 模型无法捕捉数据的复杂模式
  • 需要增加模型复杂度

5.2 复杂模型过拟合

1
2
3
4
5
coeffs = [5.0, 1.2, -3.4, 5.6]  # 三阶多项式,共 4 项系数
features = resize_features(features, to_columns=7) # 使用7个特征

train_f, train_l, val_f, val_l = split_dataset(features, labels)
learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
1
2
3
4
5
6
真实的多项式系数:[5.0, 1.2, -3.4, 5.6]
习得的多项式系数:[ 4.9833040e+00 1.1989247e+00 -3.3791604e+00 5.5992527e+00
3.9804489e-03 -4.9315928e-03 6.2198155e-03]

Epoch [1000/1000]:Train Loss: 0.0101, Train MSE: 0.0101,
Validation Loss: 0.0101, Validation MSE: 0.0101

过拟合分析:

使用 7 个系数的更高阶多项式拟合将更容易捕捉到噪声,对较简单的数据表现为过拟合。

  • 训练损失很低,但泛化能力可能受限
  • 模型学习了额外的噪声特征
  • 需要正则化或减少模型复杂度

5.3 合适模型合适拟合

1
2
3
4
5
coeffs = [5.0, 1.2, -3.4, 5.6]  # 三阶多项式,共 4 项系数
features = resize_features(features, to_columns=4) # 使用4个特征

train_f, train_l, val_f, val_l = split_dataset(features, labels)
learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
1
2
3
4
5
真实的多项式系数:[5.0, 1.2, -3.4, 5.6]
习得的多项式系数:[ 5.004878 1.1933998 -3.4009356 5.6035047]

Epoch [1000/1000]:Train Loss: 0.0096, Train MSE: 0.0096,
Validation Loss: 0.0104, Validation MSE: 0.0104

合适拟合分析:

使用 4 个系数,模型复杂性与数据复杂性匹配,表现为合适拟合。

  • 训练损失和验证损失都较低且接近
  • 学习到的系数与真实系数非常接近
  • 模型具有良好的泛化能力

总结

通过多项式拟合实验,我们可以清楚地观察到:

  1. 欠拟合:模型过于简单,无法捕捉数据的复杂模式
  2. 过拟合:模型过于复杂,学习了噪声而非真实模式
  3. 合适拟合:模型复杂度与数据复杂度匹配,具有良好的泛化能力

“限制特征的数量”是缓解过拟合问题的有效方法之一。在实际应用中,我们需要通过验证集和交叉验证等方法来选择合适的模型复杂度。