Python 现代开发学习笔记

(三十五)训练曲线、预测散点与混淆矩阵

1. 读取普通历史记录画曲线

# 项目文件:training_project/src/readingtorch/plots.py
import argparse
import json
from pathlib import Path

import matplotlib

import numpy as np
import torch

from .checkpoint import load_checkpoint
from .data import read_raw
from .predict import Predictor

matplotlib.use("Agg")  # 直接输出文件,命令行运行不需要打开绘图窗口。
import matplotlib.pyplot as plt  # noqa: E402

plt.rcParams.update(
    {
        "font.family": "Microsoft YaHei",
        "axes.unicode_minus": False,
        "figure.facecolor": "#F5F7FB",
        "axes.facecolor": "white",
        "text.color": "#24324A",
        "axes.labelcolor": "#24324A",
    }
)


def plot_run(folder, data):
    folder = Path(folder)
    report = json.loads((folder / "history.json").read_text(encoding="utf-8"))
    history = report["history"]
    fig, ax = plt.subplots(figsize=(8, 4.6), layout="constrained")
    ax.plot(
        [r["epoch"] for r in history],
        [r["train"]["loss"] for r in history],
        label="训练损失",
        color="#4657B5",
    )
    ax.plot(
        [r["epoch"] for r in history],
        [r["val"]["loss"] for r in history],
        label="验证损失",
        color="#087F82",
    )
    ax.axvline(report["best_epoch"], color="#A85D11", linestyle="--", label="选中轮数")
    if all(row["train"]["loss"] > 0 and row["val"]["loss"] > 0 for row in history):
        ax.set_yscale("log")  # 把后期较小的损失变化一起展开。
    ax.set(
        xlabel="完成轮数",
        ylabel="平均损失(本图为对数刻度)" if ax.get_yscale() == "log" else "平均损失",
        title="回归训练" if report["task"] == "regression" else "图形分类训练",
    )
    ax.legend()
    ax.grid(alpha=0.18)
    fig.savefig(folder / "loss.png", dpi=180, bbox_inches="tight", pad_inches=0.15)
    plt.close(fig)

横轴是已经完成的轮数,纵轴是相应平均损失。这次曲线使用对数刻度,让从一左右下降到万分之一附近的变化都能展开;纵轴相同距离表示相同比例的变化。训练曲线来自每批更新前取得的损失,验证曲线来自该轮结束后的固定模型,因此二者的采样时刻也要一起理解。虚线标出验证集选中的轮数。

# 续写同一文件:training_project/src/readingtorch/plots.py
    # 已选定的模型和保存的测试编号一起用于结果图。
    predictor = Predictor(folder / "best.pt")
    saved = load_checkpoint(folder / "best.pt")
    x, y = read_raw(report["task"], Path(data))
    ids = saved["splits"]["test"]

2. 用测试样本检查预测分布

# 续写同一文件:training_project/src/readingtorch/plots.py
    # 测试集预测在模型选择完成后读取,分别用散点和计数矩阵表达。
    if report["task"] == "regression":
        prediction = [predictor.pages(*x[index].tolist()) for index in ids]
        actual = y[ids, 0].tolist()
        fig, ax = plt.subplots(figsize=(6.5, 5), layout="constrained")
        ax.scatter(actual, prediction, color="#087F82", s=30, label="测试样本")
        ax.plot(
            [min(actual), max(actual)],
            [min(actual), max(actual)],
            "--",
            color="#A85D11",
            label="预测等于实际",
        )
        ax.set(xlabel="实际页数", ylabel="预测页数", title="回归模型的 48 条测试记录")
        ax.legend()
    else:
        confusion = np.zeros((3, 3), dtype=int)
        with torch.inference_mode():
            predicted = predictor.model(x[ids]).argmax(1)
        for true, guess in zip(y[ids], predicted):
            confusion[int(true), int(guess)] += 1
        fig, ax = plt.subplots(figsize=(6.5, 5), layout="constrained")
        ax.imshow(confusion, cmap="GnBu")
        for row in range(3):
            for column in range(3):
                ax.text(
                    column,
                    row,
                    str(confusion[row, column]),
                    ha="center",
                    va="center",
                    fontsize=18,
                    color="white"
                    if confusion[row, column] > confusion.max() / 2
                    else "#24324A",
                )
        ax.set(
            xticks=range(3),
            yticks=range(3),
            xticklabels=saved["classes"],
            yticklabels=saved["classes"],
            xlabel="预测类别",
            ylabel="实际类别",
            title="72 张测试图的混淆矩阵",
        )
    fig.savefig(folder / "test.png", dpi=180, bbox_inches="tight", pad_inches=0.15)
    plt.close(fig)

回归图把实际页数放在横轴、预测页数放在纵轴;靠近“预测等于实际”的虚线表示误差较小。分类的混淆矩阵以行表示实际类别、列表示预测类别,每个格子统计落在该组合的张数,主对角线是判断正确的样本。

# 续写同一文件:training_project/src/readingtorch/plots.py
def main():
    # 图表入口接收训练输出目录与对应数据文件。
    parser = argparse.ArgumentParser(description="绘制一次训练的曲线与测试结果")
    parser.add_argument("folder")
    parser.add_argument("--data", required=True)
    args = parser.parse_args()
    plot_run(args.folder, args.data)


if __name__ == "__main__":
    main()
# 从训练输出与原数据生成曲线和测试图,不重新训练。
uv run python -m readingtorch.plots runs/regression --data data/reading.csv
uv run python -m readingtorch.plots runs/images --data data/symbols.npz
回归模型三十轮的实际训练与验证损失,纵轴使用对数刻度,虚线标出第二十四轮
四十八条回归测试记录的实际页数与预测页数散点,对角线表示预测等于实际
图形分类十八轮的实际训练与验证损失,纵轴使用对数刻度,虚线标出第十二轮
三类图形的测试混淆矩阵,每类二十四张落在各自对角格,总计七十二张

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