(十五)训练、选权重与最终评估
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 页。用这些结果检查实现后,继续改模型时应比较验证组与错误样本;反复根据测试结果挑参数,会让测试组参与选择。