案例 5:干豆分类中的激活、初始化与优化器消融#

学习目标与记号#

  为什么同一个全连接网络使用不同激活函数、参数初始化方法和优化算法时,会表现出不同的训练速度与稳定性?本案例使用 UCI Dry Bean 数据的 16 个形态特征预测 7 类干豆。在每组比较中,保持其他条件相同,只改变一个需要研究的因素,并根据实验结果分析该因素可能带来的影响。

  本案例用 \(n\) 表示样本量,用 \(d_l\) 表示第 \(l\) 层的神经元数量;批量样本按行存放,层编号写作上标 \([l]\)主要学习目标如下:

  1. 比较 sigmoid、tanh、ReLU 和 Leaky ReLU 的取值范围以及它们对梯度传播的影响;

  2. 根据激活函数选择 Xavier 或 He 初始化,并检查实际权重的标准差;

  3. 说明小批量随机梯度下降(mini-batch stochastic gradient descent,mini-batch SGD)、Momentum、RMSprop 和 Adam 各自保存并使用哪些信息;

  4. 结合激活值标准差、零激活比例和梯度范数分析训练过程,而不只查看最终准确率;

  5. 在数据、划分、网络、随机种子、训练轮数和评价方法相同的情况下,只改变一个因素进行比较。

  对应正文: “激活函数”“梯度下降及其衍生算法”和“多隐藏层全连接神经网络”。

数据来源与许可#

  • 数据: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 步:读取 ARFF 原始表#

  压缩包同时包含 Excel 与 ARFF。本案例读取 ARFF,避免把提取文件写入源码树。ARFF 的属性定义给出列名,最后一列 Class 是字符串类别;其余列应能转换为有限浮点数。读取后核对行数、列数、标签种类和缺失值。

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, '类别': sorted(raw['Class'].unique())})
feature_display_names = {'Area': '面积', 'Perimeter': '周长', 'MajorAxisLength': '长轴长度', 'MinorAxisLength': '短轴长度', 'AspectRation': '纵横比', 'Eccentricity': '离心率', 'ConvexArea': '凸包面积', 'EquivDiameter': '等效直径', 'Extent': '范围占比', 'Solidity': '实心度', 'roundness': '圆度', 'Compactness': '紧致度', 'ShapeFactor1': '形状因子 1', 'ShapeFactor2': '形状因子 2', 'ShapeFactor3': '形状因子 3', 'ShapeFactor4': '形状因子 4', 'Class': '类别'}
display(raw.head(3).rename(columns=feature_display_names))
uci_dry_bean.zip:4.52 MiB;SHA-256:0a64eff5be87f48c3dbbfc0a12a56c5d5b5167ef8e61cd45d69b3e7c7130c06f
{'原始数据维度': (13611, 17), '类别': ['BARBUNYA', 'BOMBAY', 'CALI', 'DERMASON', 'HOROZ', 'SEKER', 'SIRA']}
面积 周长 长轴长度 短轴长度 纵横比 离心率 凸包面积 等效直径 范围占比 实心度 圆度 紧致度 形状因子 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 SEKER
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 SEKER
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 SEKER

第 2 步:检查类别、数值尺度和偏态#

  AreaPerimeter 等特征的数值较大,而 ShapeFactor 等特征的数值较小。若不进行标准化,初始梯度可能主要受到数值较大特征的影响。各类别的样本数量也不完全相同,因此后面同时报告准确率与宏平均 F1:宏平均 F1 先分别计算每个类别的 F1 分数,再对这些分数取平均,使样本较少的类别不会被样本较多的类别完全遮蔽。

  发现两个特征高度相关,并不意味着可以根据全部数据直接删除其中一个。任何根据数据作出的特征选择都只能在训练数据内部完成。

class_counts = raw['Class'].value_counts().sort_index()
display(class_counts.rename('样本数').to_frame())
class_counts.plot.bar(title='七类干豆的样本数')
plt.ylabel('样本数')
plt.show()
display(raw[feature_names].describe().T[['mean', 'std', 'min', 'max']].rename(index=feature_display_names, columns={'mean': '均值', 'std': '标准差', 'min': '最小值', 'max': '最大值'}))
print('最大/最小非零特征标准差之比:', raw[feature_names].std().max() / raw[feature_names].std().replace(0, np.nan).min())
样本数
Class
BARBUNYA 1322
BOMBAY 522
CALI 1630
DERMASON 3546
HOROZ 1928
SEKER 2027
SIRA 2636
../../_images/2ba5ec00170862dfa3842fe7cbc0ae566a2c9506bf9bc89a2b30e2d7b67809a8.png
均值 标准差 最小值 最大值
面积 53048.284549 29324.095717 20420.000000 254616.000000
周长 855.283459 214.289696 524.736000 1985.370000
长轴长度 320.141867 85.694186 183.601165 738.860153
短轴长度 202.270714 44.970091 122.512653 460.198497
纵横比 1.583242 0.246678 1.024868 2.430306
离心率 0.750895 0.092002 0.218951 0.911423
凸包面积 53768.200206 29774.915817 20684.000000 263261.000000
等效直径 253.064220 59.177120 161.243764 569.374358
范围占比 0.749733 0.049086 0.555315 0.866195
实心度 0.987143 0.004660 0.919246 0.994677
圆度 0.873282 0.059520 0.489618 0.990685
紧致度 0.799864 0.061713 0.640577 0.987303
形状因子 1 0.006564 0.001128 0.002778 0.010451
形状因子 2 0.001716 0.000596 0.000564 0.003665
形状因子 3 0.643590 0.098996 0.410339 0.974767
形状因子 4 0.995063 0.004366 0.947687 0.999733
最大/最小非零特征标准差之比: 49968404.40671968

第 3 步:划分数据、标准化与快速模式#

  先按类别比例划分训练集、验证集和测试集,再只在训练集上计算标准化所需的均值与标准差。验证集用于选择设置,测试集只在模型与训练方案确定后使用。类别按照固定顺序映射为 0--6;该映射只由类别名称决定,不使用特征分布。

  快速模式从训练集中按类别比例选择 7,000 个样本,并减少训练轮数;验证集与测试集仍然保持独立。输入矩阵的维度为 \(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.float32)
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)
if FAST_MODE and len(X_train) > 7000:
    X_train, _, y_train, _ = train_test_split(X_train, y_train, train_size=7000, stratify=y_train, random_state=SEED)
scaler = StandardScaler().fit(X_train)
X_train_z = scaler.transform(X_train).astype(np.float32)
X_validation_z = scaler.transform(X_validation).astype(np.float32)
X_test_z = scaler.transform(X_test).astype(np.float32)
assert X_train_z.shape == (len(y_train), 16)
assert X_validation_z.shape == (len(y_validation), 16)
assert y_train.ndim == 1 and set(np.unique(y_train)) == set(range(7))
print({'训练输入维度': X_train_z.shape, '验证输入维度': X_validation_z.shape, '测试输入维度': X_test_z.shape})
{'训练输入维度': (7000, 16), '验证输入维度': (2042, 16), '测试输入维度': (2042, 16)}

第 4 步:多项逻辑回归基线#

  Softmax 逻辑回归只学习线性类别边界。它训练快、参数少,是判断隐藏层是否带来价值的必要基线。基线和神经网络共享同一训练集标准化器;此处只报告验证指标,不提前读取测试集。

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

baseline = LogisticRegression(max_iter=2000, random_state=SEED).fit(X_train_z, y_train)
baseline_validation_probability = baseline.predict_proba(X_validation_z)
baseline_validation_prediction = baseline_validation_probability.argmax(axis=1)
print({'验证准确率': accuracy_score(y_validation, baseline_validation_prediction), '验证宏平均 F1': f1_score(y_validation, baseline_validation_prediction, average='macro'), '验证对数损失': log_loss(y_validation, baseline_validation_probability)})
{'验证准确率': 0.9240940254652301, '验证宏平均 F1': 0.9372042177941607, '验证对数损失': 0.21058820188045502}

第 5 步:激活函数与相应的参数初始化#

  sigmoid 函数和 tanh 函数在线性运算结果绝对值很大时,导数趋近于 0;ReLU 在负半轴输出 0;Leaky ReLU 则为负输入保留一个较小斜率。为了降低前向传播中的激活值和后向传播中的梯度在多层网络中过快缩小或放大的风险,可以采用以下常见选择:

  • sigmoid 函数或 tanh 函数通常配合 Xavier 初始化,权重尺度同时考虑输入神经元数量(fan-in)和输出神经元数量(fan-out);

  • ReLU 或 Leaky ReLU 通常配合 He 初始化,权重标准差约为 \(\sqrt{2/\text{fan-in}}\)

  这些方法只是常用起点,并不能保证所有任务都训练稳定。下面显式初始化参数,并检查每层权重的实际标准差是否接近目标值。

ACTIVATIONS = {
    'sigmoid': nn.Sigmoid,
    'tanh': nn.Tanh,
    'relu': nn.ReLU,
    'leaky_relu': lambda: nn.LeakyReLU(0.01),
}

class BeanMLP(nn.Module):
    def __init__(self, activation='relu', initialization='he'):
        super().__init__()
        self.hidden1 = nn.Linear(16, 64)
        self.activation1 = ACTIVATIONS[activation]()
        self.hidden2 = nn.Linear(64, 32)
        self.activation2 = ACTIVATIONS[activation]()
        self.output = nn.Linear(32, 7)
        self.reset_parameters(initialization)

    def reset_parameters(self, initialization):
        for layer in (self.hidden1, self.hidden2, self.output):
            if initialization == 'he':
                nn.init.kaiming_normal_(layer.weight, nonlinearity='relu')
            elif initialization == 'xavier':
                nn.init.xavier_normal_(layer.weight)
            else:
                raise ValueError(initialization)
            nn.init.zeros_(layer.bias)

    def forward(self, X):
        return self.output(self.activation2(self.hidden2(self.activation1(self.hidden1(X)))))

probe_model = BeanMLP('relu', 'he')
assert probe_model(torch.zeros(5, 16)).shape == (5, 7)
layer_rows = []
for name, layer in [('隐藏层一', probe_model.hidden1), ('隐藏层二', probe_model.hidden2), ('输出层', probe_model.output)]:
    layer_rows.append({'网络层': name, '权重维度': str(tuple(layer.weight.shape)), '权重标准差': float(layer.weight.std())})
display(pd.DataFrame(layer_rows))
网络层 权重维度 权重标准差
0 隐藏层一 (64, 16) 0.341396
1 隐藏层二 (32, 64) 0.178684
2 输出层 (7, 32) 0.274470

第 6 步:优化器分别保存什么历史#

  Mini-batch SGD 只使用当前批次梯度。Momentum 保存梯度的一阶指数移动平均;RMSprop 保存平方梯度的指数移动平均并逐坐标缩放;Adam 同时保存一阶矩与二阶原点矩,并在早期做偏差修正。

  不同优化器的“学习率”数值不可直接视为同一种有效步长。为了教学比较,给每种方法使用常见起点,并保持网络、初始化、批次、轮数和数据顺序相同。结论应理解为这些明确设置下的比较,而不是优化器的永久排名。

def make_optimizer(name, parameters):
    if name == 'sgd':
        return torch.optim.SGD(parameters, lr=0.03)
    if name == 'momentum':
        return torch.optim.SGD(parameters, lr=0.03, momentum=0.9)
    if name == 'rmsprop':
        return torch.optim.RMSprop(parameters, lr=0.001, alpha=0.99)
    if name == 'adam':
        return torch.optim.Adam(parameters, lr=0.001)
    raise ValueError(name)

optimizer_rows = []
for optimizer_name in ('sgd', 'momentum', 'rmsprop', 'adam'):
    temporary_model = BeanMLP('relu', 'he')
    optimizer_rows.append({'优化器': optimizer_name, '实现类': type(make_optimizer(optimizer_name, temporary_model.parameters())).__name__})
display(pd.DataFrame(optimizer_rows))
优化器 实现类
0 sgd SGD
1 momentum SGD
2 rmsprop RMSprop
3 adam Adam

第 7 步:统一训练器与梯度尺度诊断#

  每个候选都从同一随机种子初始化,并用相同批量顺序训练。首个批次记录:

  • 两个隐藏层激活的标准差;

  • 绝对值小于 \(10^{-8}\) 的激活比例;

  • 所有参数梯度的整体 \(\ell_2\) 范数。

  这些量连接到教材中的信号尺度:Sigmoid 饱和可能使梯度变小,ReLU 的零比例过高可能表示许多单元暂时不传播梯度;梯度过大则可能导致更新不稳定。

train_tensors = TensorDataset(torch.from_numpy(X_train_z), torch.from_numpy(y_train))
validation_tensors = TensorDataset(torch.from_numpy(X_validation_z), torch.from_numpy(y_validation))
criterion = nn.CrossEntropyLoss()

def evaluate_model(model, dataset):
    loader = DataLoader(dataset, batch_size=512, shuffle=False)
    model.eval()
    logits_parts, target_parts = [], []
    with torch.no_grad():
        for X_batch, y_batch in loader:
            logits_parts.append(model(X_batch.to(DEVICE)).cpu())
            target_parts.append(y_batch)
    logits = torch.cat(logits_parts)
    target = torch.cat(target_parts)
    probability = torch.softmax(logits, dim=1).numpy()
    prediction = probability.argmax(axis=1)
    return {'loss': log_loss(target.numpy(), probability), 'accuracy': accuracy_score(target.numpy(), prediction), 'macro_f1': f1_score(target.numpy(), prediction, average='macro')}

def train_configuration(activation, initialization, optimizer_name, epochs=None):
    torch.manual_seed(SEED)
    model = BeanMLP(activation, initialization).to(DEVICE)
    optimizer = make_optimizer(optimizer_name, model.parameters())
    generator = torch.Generator().manual_seed(SEED)
    loader = DataLoader(train_tensors, batch_size=128, shuffle=True, generator=generator)
    epochs = epochs or (8 if FAST_MODE else 20)
    history, diagnostics = [], None
    started = time.perf_counter()
    for epoch in range(1, epochs + 1):
        model.train()
        total_loss = 0.0
        for batch_number, (X_batch, y_batch) in enumerate(loader):
            X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
            optimizer.zero_grad(set_to_none=True)
            first_linear = model.hidden1(X_batch)
            first_activation = model.activation1(first_linear)
            second_linear = model.hidden2(first_activation)
            second_activation = model.activation2(second_linear)
            logits = model.output(second_activation)
            assert logits.shape == (y_batch.shape[0], 7)
            loss = criterion(logits, y_batch)
            if not torch.isfinite(loss):
                raise FloatingPointError('损失出现 NaN 或无穷值')
            loss.backward()
            if diagnostics is None:
                gradient_norm = torch.sqrt(sum((parameter.grad.detach() ** 2).sum() for parameter in model.parameters() if parameter.grad is not None)).item()
                diagnostics = {'activation1_std': first_activation.std().item(), 'activation2_std': second_activation.std().item(), 'activation1_near_zero': (first_activation.abs() < 1e-8).float().mean().item(), 'activation2_near_zero': (second_activation.abs() < 1e-8).float().mean().item(), 'gradient_norm': gradient_norm}
            optimizer.step()
            total_loss += loss.item() * y_batch.shape[0]
        validation_metrics = evaluate_model(model, validation_tensors)
        history.append({'epoch': epoch, 'train_loss': total_loss / len(train_tensors), **{f'validation_{key}': value for key, value in validation_metrics.items()}})
    elapsed = time.perf_counter() - started
    return model, pd.DataFrame(history), {**diagnostics, 'seconds': elapsed}

probe_trained, probe_history, probe_diagnostics = train_configuration('relu', 'he', 'adam', epochs=2)
metric_display_names = {'configuration': '配置', 'optimizer': '优化器', 'activation1_std': '第一层激活标准差', 'activation2_std': '第二层激活标准差', 'activation1_near_zero': '第一层激活近零比例', 'activation2_near_zero': '第二层激活近零比例', 'gradient_norm': '梯度范数', 'seconds': '耗时(秒)', 'epoch': '训练轮次', 'train_loss': '训练损失', 'validation_loss': '验证损失', 'validation_accuracy': '验证准确率', 'validation_macro_f1': '验证宏平均 F1'}
print({metric_display_names.get(key, key): value for key, value in probe_diagnostics.items()})
display(probe_history.rename(columns=metric_display_names))
{'第一层激活标准差': 0.7840772271156311, '第二层激活标准差': 0.6998082995414734, '第一层激活近零比例': 0.5106201171875, '第二层激活近零比例': 0.53271484375, '梯度范数': 2.735503673553467, '耗时(秒)': 0.08857883300515823}
训练轮次 训练损失 验证损失 验证准确率 验证宏平均 F1
0 1 1.372310 0.694818 0.815867 0.819933
1 2 0.513434 0.387688 0.886386 0.900127

第 8 步:比较激活函数与初始化组合#

  第一组实验只改变“激活函数及其常用初始化”这一组合,所有模型都使用 Adam。由于激活函数与初始化方法相互影响,这里比较的是若干常用组合,而不是把二者的作用完全分开。

  程序保存完整的验证曲线,并记录第一个批次的激活值与梯度信息。若某个组合在训练初期的梯度很小、第二层激活值的标准差明显减小,而且验证损失下降缓慢,就可以进一步检查激活函数是否进入饱和区,或者信号是否在逐层传播时不断缩小。

activation_runs = []
activation_curves = []
activation_models = {}
for activation, initialization in [('sigmoid', 'xavier'), ('tanh', 'xavier'), ('relu', 'he'), ('leaky_relu', 'he')]:
    model_i, history_i, diagnostics_i = train_configuration(activation, initialization, 'adam')
    name = f'{activation}+{initialization}'
    activation_models[name] = model_i
    history_i['configuration'] = name
    activation_curves.append(history_i)
    activation_runs.append({'configuration': name, **diagnostics_i, **history_i.iloc[-1].drop(['epoch', 'configuration']).to_dict()})
activation_results = pd.DataFrame(activation_runs).sort_values('validation_macro_f1', ascending=False)
display(activation_results.rename(columns=metric_display_names))
activation_curve_table = pd.concat(activation_curves, ignore_index=True)
for name, group in activation_curve_table.groupby('configuration'):
    plt.plot(group['epoch'], group['validation_loss'], marker='o', label=name)
plt.xlabel('训练轮次'); plt.ylabel('验证交叉熵'); plt.legend(); plt.title('激活与初始化组合'); plt.show()
配置 第一层激活标准差 第二层激活标准差 第一层激活近零比例 第二层激活近零比例 梯度范数 耗时(秒) 训练损失 验证损失 验证准确率 验证宏平均 F1
2 relu+he 0.784077 0.699808 0.51062 0.532715 2.735504 0.273356 0.209378 0.217747 0.923604 0.936274
3 leaky_relu+he 0.786476 0.703685 0.00000 0.000000 2.761046 0.274534 0.209608 0.218066 0.923604 0.936000
1 tanh+xavier 0.401405 0.383707 0.00000 0.000000 2.340863 0.311643 0.218046 0.219432 0.918217 0.931571
0 sigmoid+xavier 0.119582 0.111430 0.00000 0.000000 0.633639 0.286363 0.489277 0.466120 0.859941 0.742384
../../_images/445e3eb156bfb216706e2426df5d090d61ba57ddc3ae659ec1d94d37ce9c60c1.png

第 9 步:比较不同优化算法#

  第二组实验固定使用 ReLU 和 He 初始化,只改变优化算法。所有运行使用相同的参数初始化种子和批次顺序,因此曲线差异更可能来自参数更新规则。候选学习率在运行实验前已经确定;更严谨的比较应给每种优化算法相同数量的学习率候选,并使用多个随机种子重复实验。

  同时记录运行时间,因为保存更多历史状态可能增加内存占用和计算量;解释预测表现时也要考虑这些计算成本。

optimizer_runs = []
optimizer_curves = []
optimizer_models = {}
for optimizer_name in ('sgd', 'momentum', 'rmsprop', 'adam'):
    model_i, history_i, diagnostics_i = train_configuration('relu', 'he', optimizer_name)
    optimizer_models[optimizer_name] = model_i
    history_i['optimizer'] = optimizer_name
    optimizer_curves.append(history_i)
    optimizer_runs.append({'optimizer': optimizer_name, **diagnostics_i, **history_i.iloc[-1].drop(['epoch', 'optimizer']).to_dict()})
optimizer_results = pd.DataFrame(optimizer_runs).sort_values('validation_macro_f1', ascending=False)
display(optimizer_results.rename(columns=metric_display_names))
optimizer_curve_table = pd.concat(optimizer_curves, ignore_index=True)
for name, group in optimizer_curve_table.groupby('optimizer'):
    plt.plot(group['epoch'], group['validation_loss'], marker='o', label=name)
plt.xlabel('训练轮次'); plt.ylabel('验证交叉熵'); plt.legend(); plt.title('优化器比较'); plt.show()
优化器 第一层激活标准差 第二层激活标准差 第一层激活近零比例 第二层激活近零比例 梯度范数 耗时(秒) 训练损失 验证损失 验证准确率 验证宏平均 F1
2 rmsprop 0.784077 0.699808 0.51062 0.532715 2.735504 0.261375 0.195128 0.211293 0.926053 0.938021
3 adam 0.784077 0.699808 0.51062 0.532715 2.735504 0.271565 0.209378 0.217747 0.923604 0.936274
1 momentum 0.784077 0.699808 0.51062 0.532715 2.735504 0.248386 0.187588 0.212484 0.922135 0.935844
0 sgd 0.784077 0.699808 0.51062 0.532715 2.735504 0.247682 0.260650 0.256405 0.911851 0.927543
../../_images/ac1a26055908b48a3620594b238b9d458793561bd0526ee6bd0014ede1335be3.png

第 10 步:用验证集确定方案后评价测试集#

  先根据验证集宏平均 F1 选择激活函数与初始化组合,以及优化算法。为了控制本案例的运行时间,最终模型采用前两组实验中各自表现最好的设置重新训练。只有在这些设置确定后,程序才转换测试集并评价一次。

  同时报告准确率、宏平均 F1、交叉熵、参数量和训练时间。各候选网络的参数量相同,因此它们之间的表现差异不能解释为网络参数数量不同;运行时间仍可能因激活函数计算和优化算法保存的状态不同而变化。

best_activation_name = activation_results.iloc[0]['configuration']
best_activation, best_initialization = best_activation_name.split('+')
best_optimizer = str(optimizer_results.iloc[0]['optimizer'])
final_model, final_history, final_diagnostics = train_configuration(best_activation, best_initialization, best_optimizer)
test_tensors = TensorDataset(torch.from_numpy(X_test_z), torch.from_numpy(y_test))
test_metrics = evaluate_model(final_model, test_tensors)
baseline_test_probability = baseline.predict_proba(X_test_z)
baseline_test_prediction = baseline_test_probability.argmax(axis=1)
comparison = pd.DataFrame([
    {'model': 'selected MLP', **test_metrics, 'parameters': sum(p.numel() for p in final_model.parameters()), 'train_seconds': final_diagnostics['seconds']},
    {'model': 'softmax logistic baseline', 'loss': log_loss(y_test, baseline_test_probability), 'accuracy': accuracy_score(y_test, baseline_test_prediction), 'macro_f1': f1_score(y_test, baseline_test_prediction, average='macro'), 'parameters': baseline.coef_.size + baseline.intercept_.size, 'train_seconds': np.nan},
])
print({'选定的激活与初始化': best_activation_name, '选定的优化器': best_optimizer})
comparison_display = comparison.replace({'selected MLP': '选定的 MLP', 'softmax logistic baseline': 'Softmax 逻辑回归基线'}).rename(columns={'model': '模型', 'loss': '损失', 'accuracy': '准确率', 'macro_f1': '宏平均 F1', 'parameters': '参数量', 'train_seconds': '训练耗时(秒)'})
display(comparison_display)
{'选定的激活与初始化': 'relu+he', '选定的优化器': 'rmsprop'}
模型 损失 准确率 宏平均 F1 参数量 训练耗时(秒)
0 选定的 MLP 0.210414 0.921645 0.933993 3399 0.269322
1 Softmax 逻辑回归基线 0.217908 0.918707 0.931175 119 NaN

第 11 步:类别级错误分析#

  宏平均 F1 较低通常意味着一个或多个类别召回率或精确率较差。下面绘制最终模型的混淆矩阵,并列出每类召回率。相互混淆可能来自形态特征重叠,也可能来自样本量、测量误差或模型边界;混淆矩阵本身不能证明原因。

  错误分析在测试后只用于描述局限,不回头选择配置。

from sklearn.metrics import confusion_matrix, classification_report

final_model.eval()
with torch.no_grad():
    final_logits = final_model(torch.from_numpy(X_test_z).to(DEVICE)).cpu()
final_prediction = final_logits.argmax(dim=1).numpy()
confusion = confusion_matrix(y_test, final_prediction)
plt.figure(figsize=(7, 6))
plt.imshow(confusion, cmap='Blues')
plt.xticks(range(7), classes, rotation=45, ha='right')
plt.yticks(range(7), classes)
plt.xlabel('预测类别'); plt.ylabel('真实类别'); plt.colorbar(); plt.tight_layout(); plt.show()
class_report = pd.DataFrame(classification_report(y_test, final_prediction, target_names=classes, output_dict=True)).T
display(class_report.loc[classes].sort_values('recall').rename(columns={'precision': '精确率', 'recall': '召回率', 'f1-score': 'F1 分数', 'support': '样本数'}))
../../_images/f7fd7c0d646b52e53162ec9b2c759becbf74e217eab9684968f4fc953bdc82c8.png
精确率 召回率 F1 分数 样本数
DERMASON 0.932806 0.887218 0.909441 532.0
BARBUNYA 0.941799 0.894472 0.917526 199.0
SIRA 0.841608 0.901266 0.870416 395.0
CALI 0.927126 0.934694 0.930894 245.0
SEKER 0.944625 0.953947 0.949264 304.0
HOROZ 0.955479 0.965398 0.960413 289.0
BOMBAY 1.000000 1.000000 1.000000 78.0

结论、局限与常见错误#

  • 激活函数与初始化共同影响前向传播中的激活值和后向传播中的梯度;只看最终准确率可能忽略激活饱和、零激活过多或梯度异常。

  • “Adam 在本次实验中表现最好”不表示 Adam 在所有任务中都最好。比较不同优化算法时,应为每种算法提供相同数量的学习率候选,并分别完成选择。

  • 各候选必须使用相同的数据划分、随机种子集合、批次顺序、训练轮数和评价程序。若同时改变网络宽度、学习率与优化算法,就无法判断结果变化主要来自哪个因素。

  • 快速模式的训练轮数较少,可能更有利于训练初期收敛较快的方法,不能据此判断较长训练下的最终排序。

  • 标准化所需的均值与标准差只能根据训练集计算,不能在验证集或测试集上重新计算。

  • Dry Bean 数据来自特定的成像与样本处理流程,模型在该数据上的类别表现不能直接推广到其他设备、产地或生产检测系统。

综合练习#

  1. 绘制四种激活函数及其导数,并计算输入为 \(-10\)0 和 10 时的函数值与导数。

  2. 固定使用 ReLU,分别用标准差 0.001、He 初始化和标准差 2 初始化权重。在其他条件相同的情况下,比较各层激活值与梯度的数值尺度。

  3. 为每种优化算法分别设置 12 个按照对数间隔排列的学习率,只用验证集选择学习率;确定所有设置后,再在测试集上比较一次。

  4. 使用 5 个随机种子重复运行表现最好的两个设置,报告宏平均 F1 的均值、标准差、运行失败次数和实际运行时间。

  5. 记录 Momentum 的速度变量、RMSprop 的平方梯度移动平均,以及 Adam 保存的一阶矩和二阶原点矩,说明为什么这些状态的维度与对应参数相同。

  6. 分别让各算法执行相同的参数更新次数,或者处理相同数量的训练样本,再比较实验结论是否改变。