案例 7:学习曲线中的偏差—方差与正则化#

学习目标与记号#

  模型容量增加一定会改善模型在新样本上的表现吗?正则化何时能够缓解高方差,何时又会使模型的偏差增大?本案例使用 UCI Dry Bean 数据集中的 16 个形态特征,通过训练集与验证集上的学习曲线、模型容量比较、\(\ell_1\)\(\ell_2\) 正则化以及提前停止,帮助读者理解这些问题。

  本案例主要讨论以下内容:

  1. 说明训练集、验证集和测试集在学习参数、选择方案与最终评价中的不同作用;

  2. 根据训练集与验证集之间的表现差异,判断模型是否可能存在较高偏差或较高方差;

  3. 比较模型容量和训练样本量对学习曲线的影响;

  4. 区分 \(\ell_1\) 正则化、\(\ell_2\) 正则化、权重衰减和提前停止的作用;

  5. 识别使用全部数据计算标准化统计量、反复查看测试结果以及根据测试结果选择训练轮数等数据泄漏问题。

  记训练集、验证集和测试集分别为 \(S_{\mathrm{train}}\)\(S_{\mathrm{val}}\)\(S_{\mathrm{test}}\)交叉熵损失记为 \(\mathcal{J}\)模型与训练方案确定后,测试集只用于一次最终评价。

数据来源与任务范围#

  • 数据集:UCI Dry Bean;

  • 说明页:https://archive.ics.uci.edu/dataset/602/dry;

  • 匿名 HTTPS 下载地址:https://archive.ics.uci.edu/static/public/602/dry+bean+dataset.zip;

  • 许可:CC BY 4.0;

  • 数据规模:13,611 个样本、16 个连续特征和 7 个类别。

  压缩包下载到环境变量 AI_COURSE_DATA_DIR 指定的目录;若未设置该变量,则使用用户缓存目录。程序默认采用快速模式,以减少学习曲线中需要比较的样本量以及神经网络的训练轮数;需要进行较完整的实验时,可关闭快速模式。

from pathlib import Path
import hashlib
import io
import os
import shutil
import tempfile
import time
import urllib.request
import zipfile

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

SEED = 42
FAST_MODE = os.environ.get('AI_COURSE_FAST_MODE', '1') != '0'
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
CACHE_ROOT = Path(os.environ.get('AI_COURSE_DATA_DIR', Path.home() / '.cache' / 'ai-course-cases')).expanduser()
CACHE_ROOT.mkdir(parents=True, exist_ok=True)

def download(url, filename):
    destination = CACHE_ROOT / filename
    if not destination.exists():
        request = urllib.request.Request(url, headers={'User-Agent': 'ai-course-case/1.0'})
        with urllib.request.urlopen(request, timeout=180) as response:
            with tempfile.NamedTemporaryFile(dir=CACHE_ROOT, delete=False) as tmp:
                shutil.copyfileobj(response, tmp)
                temporary = Path(tmp.name)
        temporary.replace(destination)
    digest = hashlib.sha256(destination.read_bytes()).hexdigest()
    print(f'{destination.name}{destination.stat().st_size / 1024**2:.2f} MiB;SHA-256:{digest}')
    return destination

print({'计算设备': str(DEVICE), '快速模式': FAST_MODE, '缓存目录': str(CACHE_ROOT)})
{'计算设备': 'cpu', '快速模式': True, '缓存目录': '/private/tmp/ai-course-case-data'}

第 1 步:读取数据并确定预测目标#

  模型输入为 16 个形态特征,观测标签为 7 个干豆品种之一。读取 ARFF 文件后,程序把以字节形式保存的标签转换为字符串,把各项特征转换为 64 位浮点数,并检查数据中是否存在无穷大或无效数值。这样可以在训练开始前发现数据类型或数值转换方面的问题。

from scipy.io import arff

URL = 'https://archive.ics.uci.edu/static/public/602/dry+bean+dataset.zip'
archive = download(URL, 'uci_dry_bean.zip')
with zipfile.ZipFile(archive) as zf:
    members = [name for name in zf.namelist() if name.lower().endswith('.arff')]
    if len(members) != 1:
        raise RuntimeError(f'未唯一找到 ARFF:{members}')
    text = zf.read(members[0]).decode('utf-8', errors='replace')
records, metadata = arff.loadarff(io.StringIO(text))
raw = pd.DataFrame(records)
raw['Class'] = raw['Class'].str.decode('utf-8')
feature_names = [column for column in raw.columns if column != 'Class']
raw[feature_names] = raw[feature_names].astype(np.float64)
assert raw.shape == (13611, 17)
assert raw['Class'].nunique() == 7
assert np.isfinite(raw[feature_names].to_numpy()).all()
print({'原始数据维度': raw.shape, '类别数': raw['Class'].nunique()})
feature_display_names = {'Area': '面积', 'Perimeter': '周长', 'MajorAxisLength': '长轴长度', 'MinorAxisLength': '短轴长度', 'AspectRation': '纵横比', 'Eccentricity': '离心率', 'ConvexArea': '凸包面积', 'EquivDiameter': '等效直径', 'Extent': '范围占比', 'Solidity': '实心度', 'roundness': '圆度', 'Compactness': '紧致度', 'ShapeFactor1': '形状因子 1', 'ShapeFactor2': '形状因子 2', 'ShapeFactor3': '形状因子 3', 'ShapeFactor4': '形状因子 4', 'Class': '类别'}
class_display_names = {'BARBUNYA': '巴尔布尼亚豆', 'BOMBAY': '孟买豆', 'CALI': '卡利豆', 'DERMASON': '德尔马松豆', 'HOROZ': '霍罗兹豆', 'SEKER': '塞克尔豆', 'SIRA': '西拉豆'}
raw_display = raw.head(3).rename(columns=feature_display_names)
raw_display['类别'] = raw_display['类别'].map(class_display_names)
display(raw_display)
uci_dry_bean.zip:4.52 MiB;SHA-256:0a64eff5be87f48c3dbbfc0a12a56c5d5b5167ef8e61cd45d69b3e7c7130c06f
{'原始数据维度': (13611, 17), '类别数': 7}
面积 周长 长轴长度 短轴长度 纵横比 离心率 凸包面积 等效直径 范围占比 实心度 圆度 紧致度 形状因子 1 形状因子 2 形状因子 3 形状因子 4 类别
0 28395.0 610.291 208.178117 173.888747 1.197191 0.549812 28715.0 190.141097 0.763923 0.988856 0.958027 0.913358 0.007332 0.003147 0.834222 0.998724 塞克尔豆
1 28734.0 638.018 200.524796 182.734419 1.097356 0.411785 29172.0 191.272750 0.783968 0.984986 0.887034 0.953861 0.006979 0.003564 0.909851 0.998430 塞克尔豆
2 29380.0 624.110 212.826130 175.931143 1.209713 0.562727 29690.0 193.410904 0.778113 0.989559 0.947849 0.908774 0.007244 0.003048 0.825871 0.999066 塞克尔豆

第 2 步:检查数据并说明不可约误差#

  各类别的样本数量并不完全相同,部分特征之间也具有较强的相关性。训练误差不为 0,既可能是模型表达能力不足造成的,也可能与类别重叠、测量噪声或参数尚未充分优化有关。仅根据一个数据集,无法把这些原因与理论上的不可约噪声准确分开。

  因此,本案例只根据“训练集上的表现较差”或“训练集与验证集之间的差距较大”等可以直接观察到的现象进行判断,不把它们当作偏差和方差的精确估计。下面先查看类别数量和特征相关性,为后续分析模型容量提供背景。

counts = raw['Class'].value_counts().sort_index()
counts_display = counts.rename(index=class_display_names)
display(counts_display.rename('样本数').rename_axis('类别').to_frame())
counts_display.plot.bar(title='类别样本数'); plt.xlabel('类别'); plt.ylabel('样本数'); plt.show()
correlation = raw[feature_names].corr().abs()
upper = correlation.where(np.triu(np.ones(correlation.shape), 1).astype(bool))
top_correlations = upper.stack().sort_values(ascending=False).head(8)
top_correlations.index = pd.MultiIndex.from_tuples([(feature_display_names[first], feature_display_names[second]) for first, second in top_correlations.index], names=['特征一', '特征二'])
display(top_correlations.rename('绝对相关系数').to_frame())
样本数
类别
巴尔布尼亚豆 1322
孟买豆 522
卡利豆 1630
德尔马松豆 3546
霍罗兹豆 1928
塞克尔豆 2027
西拉豆 2636
../../_images/1bea42c500fb4383dbc50c043725f24bd60391796c2aa7be1deb20cddea248d0.png
绝对相关系数
特征一 特征二
面积 凸包面积 0.999939
紧致度 形状因子 3 0.998686
周长 等效直径 0.991380
纵横比 紧致度 0.987687
凸包面积 等效直径 0.985226
面积 等效直径 0.984968
离心率 形状因子 3 0.981058
纵横比 形状因子 3 0.978592

第 3 步:划分数据并保留测试集#

  按照 \(70\%\)\(15\%\)\(15\%\) 的比例进行分层划分。训练集用于学习模型参数和计算预处理所需的统计量,验证集用于比较模型容量、正则化强度和停止轮数。模型结构、预处理方法与训练方案确定后,才使用测试集进行最终评价。

  标准化器 StandardScaler 只能根据训练集计算均值和标准差。虽然标准化过程不使用观测标签,但若根据全部数据计算这些统计量,验证集和测试集的信息仍会提前进入训练过程。程序把 7 个类别依次映射为 0--6;输入矩阵和观测标签向量的维度应分别为 \(n\times16\)\(n\)

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

classes = sorted(raw['Class'].unique())
class_to_id = {name: index for index, name in enumerate(classes)}
X = raw[feature_names].to_numpy(np.float64)
y = raw['Class'].map(class_to_id).to_numpy(np.int64)
X_train, X_hold, y_train, y_hold = train_test_split(X, y, test_size=0.30, stratify=y, random_state=SEED)
X_validation, X_test, y_validation, y_test = train_test_split(X_hold, y_hold, test_size=0.50, stratify=y_hold, random_state=SEED)
scaler = StandardScaler().fit(X_train)
X_train_z = scaler.transform(X_train)
X_validation_z = scaler.transform(X_validation)
X_test_z = scaler.transform(X_test)
assert X_train_z.shape == (len(y_train), 16)
assert X_validation_z.shape == (len(y_validation), 16)
assert X_test_z.shape == (len(y_test), 16)
assert np.allclose(X_train_z.mean(axis=0), 0, atol=1e-12)
print({'训练集维度': X_train_z.shape, '验证集维度': X_validation_z.shape, '测试集维度': X_test_z.shape})
{'训练集维度': (9527, 16), '验证集维度': (2042, 16), '测试集维度': (2042, 16)}

第 4 步:建立多数类基线与线性模型基线#

  多数类基线始终预测训练集中样本最多的类别,用来说明仅根据类别比例能够达到的准确率(accuracy)。多项逻辑回归则用来衡量线性分类边界能够达到的表现。若复杂模型只提高了训练集指标,却没有在验证集上超过线性模型,说明更强的拟合能力没有转化为更好的泛化表现,模型可能存在较高方差。

  两个基线模型都只使用训练集学习参数,并在验证集上进行比较。

from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, log_loss

baselines = {
    'most_frequent': DummyClassifier(strategy='most_frequent'),
    'multinomial_logistic': LogisticRegression(max_iter=2000, random_state=SEED),
}
baseline_rows = []
for name, estimator in baselines.items():
    estimator.fit(X_train_z, y_train)
    prediction = estimator.predict(X_validation_z)
    probability = estimator.predict_proba(X_validation_z)
    baseline_rows.append({'model': name, 'validation_accuracy': accuracy_score(y_validation, prediction), 'validation_macro_f1': f1_score(y_validation, prediction, average='macro'), 'validation_log_loss': log_loss(y_validation, probability, labels=np.arange(7))})
baseline_display = pd.DataFrame(baseline_rows)
baseline_display['model'] = baseline_display['model'].map({'most_frequent': '多数类', 'multinomial_logistic': '多项逻辑回归'})
display(baseline_display.rename(columns={'model': '模型', 'validation_accuracy': '验证集准确率', 'validation_macro_f1': '验证集宏平均 F1', 'validation_log_loss': '验证集交叉熵'}))
模型 验证集准确率 验证集宏平均 F1 验证集交叉熵
0 多数类 0.260529 0.059052 26.653240
1 多项逻辑回归 0.923604 0.936601 0.208532

第 5 步:根据训练集与验证集的差距理解模型容量#

  容量较小的模型可能无法充分拟合训练数据,此时训练损失和验证损失都会较高。随着容量增大,训练损失通常会降低,但验证损失可能先降低后升高。为了便于比较,本步骤保持数据划分、正则化设置和评价方法不变,只改变多项式的次数:degree=1 只使用原始特征,degree=2 增加特征的平方项和两两乘积,degree=3 进一步提高模型容量。

  PolynomialFeatures 根据输入特征构造多项式项,不需要从验证集计算统计量;标准化器和逻辑回归模型仍只根据训练子集学习。为了减少运行时间,快速模式中的三次多项式模型使用较小的训练子集。

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures

capacity_train_size = 3500 if FAST_MODE else len(X_train)
capacity_index, _ = train_test_split(np.arange(len(X_train)), train_size=capacity_train_size, stratify=y_train, random_state=SEED) if capacity_train_size < len(X_train) else (np.arange(len(X_train)), None)
capacity_rows = []
capacity_models = {}
for degree in (1, 2, 3):
    estimator = make_pipeline(
        PolynomialFeatures(degree=degree, include_bias=False),
        StandardScaler(),
        LogisticRegression(C=10.0, max_iter=1500, random_state=SEED),
    )
    started = time.perf_counter()
    estimator.fit(X_train[capacity_index], y_train[capacity_index])
    elapsed = time.perf_counter() - started
    capacity_models[degree] = estimator
    for split_name, X_part, y_part in [('train', X_train[capacity_index], y_train[capacity_index]), ('validation', X_validation, y_validation)]:
        probability = estimator.predict_proba(X_part)
        prediction = probability.argmax(axis=1)
        capacity_rows.append({'degree': degree, 'split': split_name, 'log_loss': log_loss(y_part, probability), 'accuracy': accuracy_score(y_part, prediction), 'macro_f1': f1_score(y_part, prediction, average='macro'), 'seconds': elapsed})
capacity_table = pd.DataFrame(capacity_rows)
capacity_display = capacity_table.replace({'split': {'train': '训练集', 'validation': '验证集'}}).rename(columns={'degree': '多项式次数', 'split': '数据划分', 'log_loss': '交叉熵', 'accuracy': '准确率', 'macro_f1': '宏平均 F1', 'seconds': '训练耗时(秒)'})
display(capacity_display)
assert np.isfinite(capacity_table[['log_loss', 'accuracy', 'macro_f1']].to_numpy()).all()
多项式次数 数据划分 交叉熵 准确率 宏平均 F1 训练耗时(秒)
0 1 训练集 0.212807 0.924571 0.936051 0.027867
1 1 验证集 0.211968 0.922135 0.935448 0.027867
2 2 训练集 0.191776 0.928571 0.938780 0.116204
3 2 验证集 0.211282 0.924584 0.937678 0.116204
4 3 训练集 0.176408 0.934571 0.944248 0.808040
5 3 验证集 0.214048 0.925563 0.937594 0.808040

第 6 步:绘制容量的训练—验证差距#

  若多项式次数增大时训练损失持续降低,而验证损失不降反升,说明新增容量主要记住了训练数据中的细节,验证误差随之增大;这是模型可能具有较高方差的证据。若两种损失都很高且较为接近,则模型可能具有较高偏差,也可能尚未充分完成参数优化。

  这种判断要求各模型使用相同的数据划分和评价方法。若训练样本数或正则化强度也不同,就不能把结果只按多项式次数排列后,直接把差异归因于模型容量。

pivot_loss = capacity_table.pivot(index='degree', columns='split', values='log_loss').rename(columns={'train': '训练集', 'validation': '验证集'}).rename_axis(index='多项式次数', columns='数据划分')
pivot_loss.plot(marker='o', title='模型容量与训练/验证损失')
plt.xlabel('多项式次数'); plt.ylabel('多分类交叉熵'); plt.xticks([1, 2, 3]); plt.show()
pivot_accuracy = capacity_table.pivot(index='degree', columns='split', values='accuracy').rename(columns={'train': '训练集', 'validation': '验证集'}).rename_axis(index='多项式次数', columns='数据划分')
pivot_accuracy.plot(marker='o', title='模型容量与训练/验证准确率')
plt.xlabel('多项式次数'); plt.ylabel('准确率'); plt.ylim(0, 1); plt.xticks([1, 2, 3]); plt.show()
../../_images/2958ab718452c8a86d07ff4642c3c00e4725db74233fc49a9be3d70896311769.png ../../_images/0cd5a6c00e9b55ca9d4250a25b3e17ac1a296ffe29f0dac186fc5f4e419511c6.png

第 7 步:用学习曲线观察训练样本量的影响#

  固定 degree=2 和正则化设置,只改变训练样本量。每个训练子集都从原训练集中按类别比例生成,验证集保持不变。训练样本较少时,复杂模型容易在训练集上得到很低的损失,却在验证集上表现较差;随着训练样本增加,两条曲线通常会逐渐接近。

  这种学习曲线是一种便于实际使用的判断方法,并不是理论偏差—方差的精确分解。快速模式比较 4 种训练样本量,完整模式比较 6 种训练样本量。

sizes = [350, 1000, 2500, 5000] if FAST_MODE else [350, 700, 1500, 3000, 6000, len(X_train)]
learning_rows = []
for sample_size in sizes:
    actual_size = min(sample_size, len(X_train))
    if actual_size < len(X_train):
        subset_index, _ = train_test_split(np.arange(len(X_train)), train_size=actual_size, stratify=y_train, random_state=SEED)
    else:
        subset_index = np.arange(len(X_train))
    estimator = make_pipeline(PolynomialFeatures(degree=2, include_bias=False), StandardScaler(), LogisticRegression(C=1.0, max_iter=1500, random_state=SEED))
    estimator.fit(X_train[subset_index], y_train[subset_index])
    for split_name, X_part, y_part in [('train', X_train[subset_index], y_train[subset_index]), ('validation', X_validation, y_validation)]:
        probability = estimator.predict_proba(X_part)
        learning_rows.append({'sample_size': actual_size, 'split': split_name, 'log_loss': log_loss(y_part, probability)})
learning_table = pd.DataFrame(learning_rows).drop_duplicates(['sample_size', 'split'])
learning_display = learning_table.replace({'split': {'train': '训练集', 'validation': '验证集'}}).rename(columns={'sample_size': '训练样本量', 'split': '数据划分', 'log_loss': '交叉熵'})
display(learning_display)
for name, group in learning_table.groupby('split'):
    plt.plot(group['sample_size'], group['log_loss'], marker='o', label={'train': '训练集', 'validation': '验证集'}[name])
plt.xscale('log'); plt.xlabel('训练样本量(对数轴)'); plt.ylabel('交叉熵'); plt.legend(title='数据划分'); plt.title('学习曲线'); plt.show()
训练样本量 数据划分 交叉熵
0 350 训练集 0.193574
1 350 验证集 0.239348
2 1000 训练集 0.188836
3 1000 验证集 0.222727
4 2500 训练集 0.189150
5 2500 验证集 0.217557
6 5000 训练集 0.201342
7 5000 验证集 0.209110
../../_images/5c2f18fa0400fe8babbfa8c953a4d46fcedfb292093a8beb017d67ba22c3bf3c.png

第 8 步:\(\ell_1\)\(\ell_2\) 正则化与参数稀疏性#

  逻辑回归的训练目标可以加入 \(\lambda\lVert\boldsymbol{W}\rVert_1\)\(\tfrac{\lambda}{2}\lVert\boldsymbol{W}\rVert_2^2\)\(\ell_1\) 范数在参数为 0 的位置不可导,优化结果更容易恰好落在 0 上,因此常产生稀疏参数;\(\ell_2\) 正则化通常会平滑地缩小各项权重,却不一定使权重恰好等于 0。scikit-learn 使用 C 表示正则化强度的倒数,因此 C 越小,惩罚越强。

  比较时保持 degree=2训练集和求解方法不变,只改变正则项类型与 C程序统计接近 0 的系数比例以及训练集和验证集损失,用来观察惩罚过强是否使偏差增大,以及惩罚过弱是否使训练集与验证集之间的差距扩大。

regularization_size = 4000 if FAST_MODE else len(X_train)
regularization_index, _ = train_test_split(np.arange(len(X_train)), train_size=regularization_size, stratify=y_train, random_state=SEED) if regularization_size < len(X_train) else (np.arange(len(X_train)), None)
regularization_rows = []
regularization_models = {}
for penalty in ('l1', 'l2'):
    for C in (0.03, 0.3, 3.0):
        estimator = make_pipeline(
            PolynomialFeatures(degree=2, include_bias=False),
            StandardScaler(),
            LogisticRegression(penalty=penalty, C=C, solver='saga', max_iter=2500, random_state=SEED, tol=2e-3),
        )
        estimator.fit(X_train[regularization_index], y_train[regularization_index])
        coefficients = estimator[-1].coef_
        regularization_models[(penalty, C)] = estimator
        row = {'penalty': penalty, 'C': C, 'near_zero_fraction': float((np.abs(coefficients) < 1e-6).mean()), 'weight_l2_norm': float(np.linalg.norm(coefficients))}
        for split_name, X_part, y_part in [('train', X_train[regularization_index], y_train[regularization_index]), ('validation', X_validation, y_validation)]:
            probability = estimator.predict_proba(X_part)
            row[f'{split_name}_loss'] = log_loss(y_part, probability)
            row[f'{split_name}_macro_f1'] = f1_score(y_part, probability.argmax(axis=1), average='macro')
        regularization_rows.append(row)
regularization_table = pd.DataFrame(regularization_rows).sort_values('validation_loss')
display(regularization_table.rename(columns={'penalty': '正则项', 'C': '逆正则化强度 C', 'near_zero_fraction': '近零系数占比', 'weight_l2_norm': '权重 L2 范数', 'train_loss': '训练集损失', 'train_macro_f1': '训练集宏平均 F1', 'validation_loss': '验证集损失', 'validation_macro_f1': '验证集宏平均 F1'}))
正则项 逆正则化强度 C 近零系数占比 权重 L2 范数 训练集损失 训练集宏平均 F1 验证集损失 验证集宏平均 F1
5 l2 3.00 0.000000 4.839091 0.213199 0.935739 0.210525 0.936148
2 l1 3.00 0.169173 4.705733 0.213561 0.936017 0.210741 0.937314
4 l2 0.30 0.000000 4.371111 0.214920 0.935075 0.211931 0.937794
1 l1 0.30 0.627820 3.967679 0.223863 0.934523 0.220229 0.937032
3 l2 0.03 0.000000 2.946213 0.229559 0.936188 0.226100 0.936009
0 l1 0.03 0.907895 3.932258 0.310702 0.925841 0.311060 0.925015

第 9 步:提前停止与权重衰减#

  提前停止是一种隐式正则化方法:当验证损失连续若干轮没有改善时停止训练,并恢复验证损失最低时保存的模型参数,而不是直接使用最后一轮的参数。PyTorch 中的 AdamW 把权重衰减与 Adam 的自适应梯度更新分开计算,因此不能简单地把它等同于在 Adam 的损失函数中加入 \(\ell_2\) 惩罚。

  下面训练一个隐藏层神经元较多的多层感知机(multilayer perceptron,MLP),保存每轮的训练损失、验证损失、最佳模型参数和实际训练轮数。快速模式最多训练 35 轮,完整模式最多训练 80 轮;测试集不参与停止时点的选择。

train_dataset = TensorDataset(torch.from_numpy(X_train_z.astype(np.float32)), torch.from_numpy(y_train))
validation_dataset = TensorDataset(torch.from_numpy(X_validation_z.astype(np.float32)), torch.from_numpy(y_validation))

class WideMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.network = nn.Sequential(nn.Linear(16, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 7))
    def forward(self, X_batch):
        return self.network(X_batch)

def dataset_loss(model, dataset):
    model.eval()
    total = 0.0
    with torch.no_grad():
        for X_batch, y_batch in DataLoader(dataset, batch_size=512):
            total += nn.functional.cross_entropy(model(X_batch.to(DEVICE)), y_batch.to(DEVICE), reduction='sum').item()
    return total / len(dataset)

torch.manual_seed(SEED)
wide_model = WideMLP().to(DEVICE)
optimizer = torch.optim.AdamW(wide_model.parameters(), lr=1e-3, weight_decay=1e-3)
generator = torch.Generator().manual_seed(SEED)
loader = DataLoader(train_dataset, batch_size=128, shuffle=True, generator=generator)
best_loss, best_state, bad_epochs = np.inf, None, 0
patience = 7
stop_history = []
for epoch in range(1, (35 if FAST_MODE else 80) + 1):
    wide_model.train()
    for X_batch, y_batch in loader:
        X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
        optimizer.zero_grad(set_to_none=True)
        logits = wide_model(X_batch)
        assert logits.shape == (y_batch.shape[0], 7)
        loss = nn.functional.cross_entropy(logits, y_batch)
        loss.backward()
        optimizer.step()
    train_loss = dataset_loss(wide_model, train_dataset)
    validation_loss = dataset_loss(wide_model, validation_dataset)
    stop_history.append({'epoch': epoch, 'train_loss': train_loss, 'validation_loss': validation_loss})
    if validation_loss < best_loss - 1e-4:
        best_loss = validation_loss
        best_state = {key: value.detach().cpu().clone() for key, value in wide_model.state_dict().items()}
        bad_epochs = 0
    else:
        bad_epochs += 1
        if bad_epochs >= patience:
            break
wide_model.load_state_dict(best_state)
stop_history = pd.DataFrame(stop_history)
print('实际轮数:', len(stop_history), '最佳验证损失:', best_loss)
stop_history_display = stop_history.rename(columns={'epoch': '训练轮次', 'train_loss': '训练集损失', 'validation_loss': '验证集损失'})
stop_history_display.plot(x='训练轮次', y=['训练集损失', '验证集损失'], title='提前停止曲线')
plt.xlabel('训练轮次'); plt.ylabel('交叉熵'); plt.show()
实际轮数: 20 最佳验证损失: 0.1966325496483036
../../_images/c39ffb8dcf10e3acb97edae0b88bb9ca091241f8662e2229d7a6af869102c50c.png

第 10 步:确定正则化方案后评价测试集#

  根据验证集交叉熵选择最佳的 \(\ell_1\)\(\ell_2\) 正则化方案;提前停止全连接网络的训练轮数也已经由验证集确定。完成这些选择后,才在测试集上比较模型表现。结果同时报告交叉熵、准确率、宏平均 F1、参数量与参数稀疏程度,避免只选取一个对某种方法有利的指标。

  这里并不是在完全相同的计算预算下比较所有方法:采用 SAGA 求解方法的多项式逻辑回归与采用 AdamW 的全连接网络具有不同的计算过程。本案例的结果用于说明正则化与泛化之间的关系;若要对模型优劣作正式结论,还需要统一超参数选择范围和计算预算。

best_key = (str(regularization_table.iloc[0]['penalty']), float(regularization_table.iloc[0]['C']))
best_regularized = regularization_models[best_key]
regularized_probability = best_regularized.predict_proba(X_test)
regularized_prediction = regularized_probability.argmax(axis=1)

test_tensor = torch.from_numpy(X_test_z.astype(np.float32)).to(DEVICE)
wide_model.eval()
with torch.no_grad():
    mlp_logits = wide_model(test_tensor).cpu().numpy()
mlp_probability = np.exp(mlp_logits - mlp_logits.max(axis=1, keepdims=True))
mlp_probability /= mlp_probability.sum(axis=1, keepdims=True)
mlp_prediction = mlp_probability.argmax(axis=1)
final_results = pd.DataFrame([
    {'model': f'多项式逻辑回归 {best_key}', 'test_loss': log_loss(y_test, regularized_probability), 'test_accuracy': accuracy_score(y_test, regularized_prediction), 'test_macro_f1': f1_score(y_test, regularized_prediction, average='macro'), 'parameters': best_regularized[-1].coef_.size + best_regularized[-1].intercept_.size},
    {'model': 'AdamW 多层感知机 + 提前停止', 'test_loss': log_loss(y_test, mlp_probability), 'test_accuracy': accuracy_score(y_test, mlp_prediction), 'test_macro_f1': f1_score(y_test, mlp_prediction, average='macro'), 'parameters': sum(parameter.numel() for parameter in wide_model.parameters())},
])
display(final_results.rename(columns={'model': '模型', 'test_loss': '测试集损失', 'test_accuracy': '测试集准确率', 'test_macro_f1': '测试集宏平均 F1', 'parameters': '参数量'}))
模型 测试集损失 测试集准确率 测试集宏平均 F1 参数量
0 多项式逻辑回归 ('l2', 3.0) 0.220092 0.919687 0.931611 1071
1 AdamW 多层感知机 + 提前停止 0.197901 0.924584 0.935039 19591

第 11 步:用各类别表现分析剩余误差#

  整体宏平均 F1 可能掩盖某些类别之间的持续混淆。下面查看使用提前停止的全连接网络在各类别上的召回率与主要误判情况。若某个类别在训练集和测试集上的表现都较差,可能是模型表达能力不足、现有特征难以区分该类别,或者类别本身存在重叠;若训练表现很好而测试表现明显较差,才更符合模型可能具有较高方差的判断。程序没有保存训练集上各类别的预测时,就不能只根据测试集混淆矩阵断定原因。

  测试集错误分析只用于记录模型局限,不再据此选择正则化方案。

from sklearn.metrics import classification_report, confusion_matrix

report = pd.DataFrame(classification_report(y_test, mlp_prediction, target_names=classes, output_dict=True)).T
report_display = report.loc[classes].sort_values('recall').rename(index=class_display_names, columns={'precision': '精确率', 'recall': '召回率', 'f1-score': 'F1 分数', 'support': '样本数'}).rename_axis('类别')
display(report_display)
confusion = confusion_matrix(y_test, mlp_prediction)
plt.figure(figsize=(7, 6)); plt.imshow(confusion, cmap='Blues'); plt.colorbar()
class_tick_labels = [class_display_names[name] for name in classes]
plt.xticks(range(7), class_tick_labels, rotation=45, ha='right'); plt.yticks(range(7), class_tick_labels)
plt.xlabel('预测类别'); plt.ylabel('真实类别'); plt.tight_layout(); plt.show()
精确率 召回率 F1 分数 样本数
类别
西拉豆 0.855422 0.898734 0.876543 395.0
巴尔布尼亚豆 0.927461 0.899497 0.913265 199.0
德尔马松豆 0.932302 0.906015 0.918970 532.0
卡利豆 0.925926 0.918367 0.922131 245.0
塞克尔豆 0.953947 0.953947 0.953947 304.0
霍罗兹豆 0.955479 0.965398 0.960413 289.0
孟买豆 1.000000 1.000000 1.000000 78.0
../../_images/902888d20290cb184c06f5d7868125e7707a767473b3d1f04ce7284c68e44082.png

本案例小结与局限#

  • 偏差—方差分解描述的是重复随机生成训练集时预测误差之间的理论关系。一次实验中的训练集与验证集差距只能帮助判断问题,不能直接作为偏差或方差的精确估计;

  • 提高模型容量通常会降低训练误差,却可能扩大训练集与验证集之间的差距;正则化过强又可能使两个数据集上的表现同时变差;

  • \(\ell_1\) 正则化倾向于产生稀疏参数,\(\ell_2\) 正则化通常使参数平滑缩小。AdamW 中与自适应梯度更新分开的权重衰减,不能在所有情况下都视为损失函数中的 \(\ell_2\) 惩罚;

  • 使用提前停止时,应恢复验证表现最佳时保存的模型参数,因为最后一轮不一定是最好的一轮;

  • 数据预处理方法、特征选择、模型容量、正则化强度 C 和训练轮数都不能根据测试结果反复调整;

  • 快速模式会减少训练预算与候选数量。本案例的结果不能证明双下降现象,也不能直接推广到其他成像设备或干豆产地。

综合练习#

  1. 用多个随机训练集重复拟合一元回归,数值估计平方误差下的偏差、方差和噪声项。

  2. 给学习曲线每个样本量使用 5 个分层子集和 5 个种子,画均值与误差条。

  3. 固定 degree=2,把 C 从 \(10^{-3}\)\(10^3\) 对数扫描,比较权重范数、稀疏性和训练—验证差距。

  4. 在 MLP 中分别比较无正则、Adam 的 L2 损失惩罚、AdamW 权重衰减和提前停止;严格统一更新次数。

  5. 故意先在全数据上标准化,解释为什么即使标签未参与仍是泄漏,以及影响为何可能很小却原则上错误。

  6. 设计能观察双下降的实验,并说明需要控制参数量、训练到插值、重复随机种子和计算预算。