Python 现代开发学习笔记

(十五)训练、选权重与最终评估

1. 沿用已经理解的模型结构

# 项目文件:model_project/src/modeldelivery/model.py
from torch import nn


class ReadingRegressor(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = nn.Linear(2, 1)  # 沿用上一章两个特征到一个目标的结构。

    def forward(self, x):
        return self.linear(x)

仍是两个特征到一个目标的线性层。这里把重点放在数据与文件交接,梯度推导可以回查第十六章。数据、模型与训练函数分别放进模块,可以在更换模型结构时继续使用相同的准备和发布入口。

# 项目文件:model_project/src/modeldelivery/training.py
import math

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

from .model import ReadingRegressor


def fit(data, epochs=35, seed=7):
    if epochs < 1:
        raise ValueError("训练轮数需要大于零")
    # 固定 CPU 计算和随机来源,便于在新进程中核对同一轮训练。
    torch.set_num_threads(1)
    torch.manual_seed(seed)
    torch.use_deterministic_algorithms(True)
    x, y = torch.from_numpy(data.x), torch.from_numpy(data.y)
    train_ids, val_ids = data.splits["train"], data.splits["val"]
    loader = DataLoader(
        TensorDataset(x[train_ids], y[train_ids]),
        batch_size=24,
        shuffle=True,
        generator=torch.Generator().manual_seed(seed + 1),
    )
    model, loss_fn = ReadingRegressor(), nn.MSELoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=0.03)
    best_loss, best_epoch, best, history = float("inf"), 0, None, []

torch.from_numpy() 接收准备好的数组,训练下标选出 144 条;每批 24 条,一轮六批。随机种子分别控制模型初始化与批次打乱,CPU 线程数设为一,便于核对两次运行。

2. 只让训练批次更新参数

# 续写同一文件:model_project/src/modeldelivery/training.py
    # 训练批次更新参数,验证只读取并保存较优快照。
    for epoch in range(epochs):
        model.train()
        total = 0.0
        for features, target in loader:
            optimizer.zero_grad(set_to_none=True)
            loss = loss_fn(model(features), target)
            if not torch.isfinite(loss):
                raise ValueError("训练损失出现非有限数值,请检查这批输入")
            loss.backward()
            optimizer.step()
            total += loss.item() * len(features)
        model.eval()
        with torch.inference_mode():
            validation = loss_fn(model(x[val_ids]), y[val_ids]).item()
        history.append(
            {
                "epoch": epoch + 1,
                "train_mse": total / len(train_ids),
                "val_mse": validation,
            }
        )
        if validation < best_loss:
            best_loss, best_epoch = validation, epoch + 1
            best = {
                name: value.detach().cpu().clone()
                for name, value in model.state_dict().items()
            }

每个训练批次先清梯度、计算损失、反向传播、更新参数。累加损失时乘本批样本数,最后除以训练总数;这样得到样本平均值。验证阶段调用 eval() 和 inference_mode(),只比较当前权重;验证误差更小时,克隆一份独立参数快照。

3. 选择结束后读取测试组

# 续写同一文件:model_project/src/modeldelivery/training.py
    # 轮数选择结束后,才用选定快照读取测试组。
    model.load_state_dict(best)
    test_ids = data.splits["test"]
    with torch.inference_mode():
        test_mse = loss_fn(model(x[test_ids]), y[test_ids]).item()
    scale = data.scaler["y_scale"].item()
    baseline = math.sqrt(float((data.y[test_ids] ** 2).mean())) * scale
    metrics = {
        "best_epoch": best_epoch,
        "val_rmse": math.sqrt(best_loss) * scale,
        "test_rmse": math.sqrt(test_mse) * scale,
        "baseline_rmse": baseline,
        "counts": {name: len(ids) for name, ids in data.splits.items()},
    }
    return best, metrics, history

测试组在训练循环结束、装回 best 后读取一次。模型输出是标准化目标,RMSE 乘上 y_scale 才回到页数单位。基线始终预测训练目标均值,在标准化空间中就是零,因此 data.y[test_ids]**2 是它的平方误差。

本次训练 35 轮,验证选择第 35 轮;48 条测试记录 RMSE 约 0.371 页,训练均值基线约 23.523 页。用这些结果检查实现后,继续改模型时应比较验证组与错误样本;反复根据测试结果挑参数,会让测试组参与选择。

逐批更新参数、逐轮验证并克隆较优权重,再装回选中快照读取测试组,分别标出每一步的作用

笔记正文与全部题解 · Ctrl / ⌘ K