HZNU OS Lab

实验过程与代码解析

使用参考代码训练模型,并改进模型性能

使用大语言模型

如果你遇到了麻烦,请学会向大语言模型求助,使用类似 CursorTraeTrae CNClaude CodeKimi CodeCodex 等工具可以获得强得多的编程智能。

获取参考代码

仓库地址:HZNUOperatingSystem/SideStory-CNN-Image-Filter

本次实验的模版代码需要从以上 GitHub 仓库中获取,请使用 git 命令或在 GitHub Web 界面下载源代码压缩包获取。获取源代码相关问题请自行使用搜索引擎大语言模型解决。

关于数据集

SS1 附带的数据集请从钉钉课程群中,或线下拷贝获得。参考代码的 data 目录中包含相关数据集描述文件格式,你也可以使用你自己的数据集用于训练。但出于公平起见,如果你在完成实验部分,尽可能使用课程提供的数据集。我们不保证使用自己的数据集时的评分。

环境设置

请选择以下对应的平台查看设置环境的基本方法

不管你是 Linux 实体机、云服务器还是 WSL,你都必须确保你的平台具有 NVIDIA 显卡;如果没有,请不要强行尝试在 CPU 上训练,否则完成实验的周期会超过实验提交的期限。你可以在关于训练处查看更多有关信息。

关于 WSL

对于 WSL2 而言,你需要使用 NVIDIA 官方 WSL CUDA 驱动程序配置显卡直通;你可以参考在 WSL 上启用 NVIDIA CUDA 完成配置。

对于非 WSL 的 Linux 服务器或实体机,你可以根据你的发行版选择合适的包管理器安装 CUDA 组件,或下载 CUDA 工具包直接安装,对于两种情况,请参考 CUDA Installation Guide For Linux,相关问题请使用大语言模型解决。

如果你还没有安装 uv,请在 Shell 中使用以下命令:

curl -LsSf https://astral.sh/uv/install.sh | sh

你需要确保你的平台具有 NVIDIA 显卡;如果没有,请不要强行尝试在 CPU 上训练,否则完成实验的周期会超过实验提交的期限。你可以在关于训练处查看更多有关信息。

对于 Windows 系统,你可以参考 CUDA Installation Guide for Microsoft Windows 安装 CUDA 工具包(直接下载官方安装程序即可)。

如果你还没有安装 uv,请在 Powershell 中使用以下命令:

irm https://astral.sh/uv/install.ps1 | iex

关于芯片

你只能使用搭载 Apple SiliconMac 进行训练,关于配置要求查看关于训练

如果你还没有安装 uv,请在 Shell 中使用以下命令:

curl -LsSf https://astral.sh/uv/install.sh | sh

同步依赖

在你的 Shell 中进入你获取的参考代码仓库,执行以下命令。

uv sync --python 3.12

关于 Python 版本

不管是完成本次实验还是你最近想进行的一些其他尝试,请使用 3.12 版本以保持稳定。

关于某些服务器

在一部分预装了 CUDA 工具包的云服务器上,安装好依赖后可能会在运行时出现警告并退回 CPU 学习,这是因为 PyTorch 本身的构建过程CUDA 版本相关,你可以采取降级 PyTorch 版本或升级系统 CUDA 工具包版本解决。

根目录主要结构

使用命令编辑器查看项目目录,以下仅列出一些关键的路径。

train.toml
infer.toml
dataset.py
infer.py
model.py
onnx_export.py
train.py
validation.py
fw.py
pyproject.toml

相信阅读了让机器学会经验表示并学习复杂关系的同学一眼就可以猜出这些代码的作用,这正是规范化命名的意义,让分享变得更有效率。没有理解也没有关系,随后的内容将会包含对这部分代码的解读。

使用 fw.py

项目根目录的 fw.py 包含了本次实验用到的所有功能的入口,它是一个 CLI 程序,和你日常使用的 git 等如出一辙,你可以运行如下代码查看关于命令行参数的帮助。

uv run fw.py --help

usage: fw.py [-h] COMMAND ...

(HZNU OS Course Lab) Side Story: NN Image Filter

options:
  -h, --help     show this help message and exit

commands:
    train        Train the image restoration model.
    infer        Run model inference.
    onnx-export  Export a checkpoint to ONNX.

对于每一个具体的命令,你可以在打出命令之后使用 --help,这也是一个规范,几乎所有的现代 CLI 程序都可以使用这个参数来查看帮助。

uv run fw.py train --help

usage: fw.py [-h] COMMAND ... infer [-h] [--ckpt CKPT] --input INPUT [--output OUTPUT] [run_dir]

Run model inference.

positional arguments:
  run_dir          Run directory that contains best.pt and will receive outputs/.

options:
  -h, --help       show this help message and exit
  --ckpt CKPT      Checkpoint path to load manually.
  --input INPUT    Input .datalist.csv, image file, or directory of images.
  --output OUTPUT  Output directory. Required when using --ckpt.

基本操作

训练

你可以使用以下命令开启训练,它将自动选择设备并读取 configs/train.toml 文件,根据其中的设置开始训练。

uv run train.py

疑问

你的训练可能会出现问题,如果你跑了一段时间,你应该会感觉有些奇怪;请根据我们之前提到的一些核心概念,检查什么导致了这个原因;你也可以使用人工智能来帮助你判断。

潜在风险

如果你在云服务器上训练,请务必使用 screentmuxZellij 托管,否则 SSH 意外连接断开会导致训练中断

关于 train.toml 文件,你可以查看里面的注释来了解它们的用途,也可以查看 config.py 中的 TrainConfig 类并使用 LSP 跟踪它的调用来搞清楚它是怎么被使用的。

训练的输出类似下图所示:

Train Script Output

我希望这个输出能让你感到没那么害怕,我刻意没有使用学术圈的风格,你可以清晰的看到大部分信息,包括这个 EPOCH(训练轮数) 的 Loss学习率等,总之是一些你已经在让机器学会经验了解过的东西。

对于下面三行,如果你仔细和 train.toml 比对,你会发现那就是其中设置的指标,你可以修改那个配置文件来隐藏一些指标,或增加更多你在意的指标到 watched_best 中。

训练完成后,会在项目根的 runs/ 文件夹下创建一个数字名字的文件夹,和你在训练输出看到的编号一样,它的结构类似如下:

best.pt
last.pt
nn_filter.tar
terminal.log

前两个文件是检查点(Checkpoint),接着是这次运行时的 nn_filter 代码备份,最后是终端日志,和你在训练时看到的相同。

延伸

现在的验证集实际上和测试集的意义是基本一样的!我们似乎遗漏了什么?搜索关于模型训练Patience(耐心)」的概念,并尝试在框架代码中实现 Early Stop

推理

你可以使用以下命令在训练之后进行推理。

uv run fw.py infer runs/X # 换成你的文件夹编号

更多其他 infer 命令用法可以查看 --help 或让 Agent 检查代码并告诉你。

在运行时,它会使用 configs/infer.toml 配置的测试集描述文件,并在随后打出选择的指标的平均值;推理的产物存放在 runs/X/outputs 下,你可以打开比对。

导出 ONNX 检查点

你可以使用以下命令导出 .onnx 文件,并将它们部署到其他地方运行。

uv run fw.py onnx-export runs/X # 换成你的文件夹编号

你可以使用 --precision 参数控制导出的精度;其中,int8 导出精度可能会对你完成附加的在手机上部署模型的实验有所帮助。

训练循环代码解读

以下部分包含对参考代码库核心的解读,其使用了非常常见的 PyTorch 项目模式,对构建其他类似项目有较好的学习价值。

启动设置

源码文件夹下的 train.py 负责整个模型的训练任务,模型训练的控制train_model() 函数开始,首先是基本的启动设置部分。

def train_model(
    config: TrainConfig, *, device: torch.device | None = None
) -> Path:
    set_seed(config.seed) 
    training_device = device if device is not None else get_device() 

设置一个随机数种子,用于可靠的复现结果;这是因为在 PyTorch 初始化张量优化器等大量的地方使用了伪随机数,这也是模型多样性的一个由来。

找到一个合适的设备,我们会在之后复用这个值;一般情况,我们需要把训练时的参数张量数据等需要使用到的内容搬运到同一个设备上

数据集构建

接着,我们需要构建运行时数据集,这里我们使用了一个在 dataset.py 定义的一个帮助函数 create_dataset() 来使用统一函数创建训练集验证集

train_dataset, train_summary = create_dataset(
    config.train_manifest,
    color_mode=config.color_mode,
    patch_size=config.patch_size, 
    build_mode=(
        'patch-grid' if config.patch_size is not None else 'full-image'
    ),
)
val_dataset, val_summary = create_dataset(
    config.val_manifest,
    color_mode=config.color_mode,
    build_mode='full-image',
)

这里的 patch_size 是和卷积神经网络息息相关的一个数据切分的概念,感兴趣的同学可以向大模型询问,或等待上一篇 CNN 的文章鸽出来

你可以使用 LSP 跳转到这个帮助函数的定义位置,其实现了一个带缓存数据加载器;对于数据集而言,我们只需要关心 dataset.py 中的 ImageRestorationDataset 类即可。

class ImageRestorationDataset(Dataset[tuple[torch.Tensor, torch.Tensor]]):

一个 PyTorch 数据集需要继承 torch.utils.data.Dataset 类,并实现关键方法

def __len__(self) -> int: 
    return len(self.samples)

def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]: 
    sample = self.samples[index]
    pair = self.pairs[sample.pair_index]
    if sample.top is None or sample.left is None or sample.size is None:
        return pair.source, pair.target

    top = sample.top
    left = sample.left
    bottom = top + sample.size
    right = left + sample.size
    return (
        pair.source[:, top:bottom, left:right],
        pair.target[:, top:bottom, left:right],
    )

这里的两个方法分别表示了你打算如何让 PyTorch 知道你的数据集有多大,以及如何从你的数据集里获取数据;通过这样的方法,PyTorch 就可以不关心你数据集实际上的格式、构造甚至存放的位置,只要你能给它返回张量,它就可以接受;从而实现解耦

数据加载器

接着我们要实现一个数据加载器(Data Loader),让 PyTorch 可以正式引入数据。

loaders = build_training_loaders( 
    train_dataset=train_dataset,
    val_dataset=val_dataset,
    config=config,
    train_has_mixed_resolution=train_summary.has_mixed_resolution,
    device=training_device,
)

这一样是一个参考代码里的帮助函数,我们通过 LSP 进行两次跳转就可以找到 training_setup.py 中的 create_loader() 函数,里面返回的 DataLoader 正是 PyTorch 要求的类型。

return DataLoader(
    dataset,
    batch_size=batch_size, 
    shuffle=shuffle, 
    num_workers=num_workers,
    persistent_workers=num_workers > 0,
    pin_memory=device.type == 'cuda',
)

Dataset 描述类如何取 1 个数据,而 DataLoader 用于定义从数据集中取数据的某种策略。这里的 batch_size 代表了一批次取的数据量,取的数据越多,对 VRAM 的占用就越大,但同时,训练需要的批数就越少。shuffle 控制用于告诉数据加载器是否打乱数据,这可以有效防止学习数据的顺序模型产生影响,并使得我们难以排除干扰。

模型

接下来的几行我们创建了一个模型,并把它搬运到我们的目标设备上;因为这些代码目前是运行在 CPU 上的,所以一开始这些模型参数是存储在内存中的,我们需要把它们搬运到显存(VRAM),才可以让显卡训练。

model = CNNFilter(
    in_channels=color_mode_channels(config.color_mode)
).to(training_device)

接着我们粗略看一下模型的结构。

class CNNFilter(nn.Module):
    def __init__(
        self,
        in_channels: int = 3,
        base_channels: int = 64,
        num_blocks: int = 8,
    ) -> None:
        super().__init__()
        self.input_conv = nn.Conv2d( 
            in_channels,
            base_channels,
            kernel_size=3,
            padding=1,
        )
        self.blocks = nn.Sequential(
            *[ResidualBlock(base_channels) for _ in range(num_blocks)] 
        )
        self.output_conv = nn.Conv2d( 
            base_channels,
            in_channels,
            kernel_size=3,
            padding=1,
        )
        self.apply(_init_weights)
        nn.init.zeros_(self.output_conv.weight)
        if self.output_conv.bias is not None:
            nn.init.zeros_(self.output_conv.bias)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        residual = x
        x = self.input_conv(x) 
        x = self.blocks(x) 
        x = self.output_conv(x) 
        return torch.clamp(x + residual, 0.0, 1.0)

forward() 前向传播可以看出,我们的网络首先是一个二维卷积,提升通道数;接着是一堆称为 ResidualBlock 的叫做残差块的隐藏层,最后是一个和第一个卷积镜像的二维卷积。接着,我们可以找到 ResidualBlock 块的代码。

class ResidualBlock(nn.Module):
    def __init__(self, channels: int) -> None:
        super().__init__()
        self.conv1 = nn.Conv2d( 
            channels,
            channels,
            kernel_size=3,
            padding=1,
            bias=False,
        )
        self.bn1 = nn.BatchNorm2d(channels) 
        self.gelu = nn.GELU() 
        self.conv2 = nn.Conv2d( 
            channels,
            channels,
            kernel_size=3,
            padding=1,
            bias=False,
        )
        self.bn2 = nn.BatchNorm2d(channels) 
        self._init_residual_scale()

    def _init_residual_scale(self) -> None:
        nn.init.zeros_(self.conv2.weight)

参考代码里残差块的配置是 Conv -> BN -> GELU -> Conv -> BN,并在如下前向传播中进行残差连接(ResNet 风格)。

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        residual = x
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.gelu(x)
        x = self.conv2(x)
        x = self.bn2(x)
        return x + residual # 残差连接 

Loss、LR 规划器、优化器

train.py 接下来的 build_training_components() 中,我们设置了在之前的章节谈论过的「Loss 函数」、「优化器」和一个比较陌生的概念「学习率规划器」。

criterion = nn.MSELoss() 
optimizer = torch.optim.AdamW( 
    model.parameters(),
    lr=lr,
    betas=(0.9, 0.999),
    eps=1e-8,
    weight_decay=1e-4,
)
return OneCycleLR( 
    optimizer,
    max_lr=lr,
    total_steps=total_steps,
    pct_start=0.3,
    div_factor=div_factor,
    final_div_factor=initial_lr / lr_min,
    anneal_strategy='cos',
)

有兴趣的同学请自行跟大语言模型询问并了解「学习率优化器(LR Scheduler)」以及这里 AdamW 优化器及 MSE Loss 的相关知识。

核心训练循环

接下来是跟前向传播反向传播相关的代码,也是模型进行学习的核心,所有我们谈论过的概念都汇聚在此。

def train_epoch(
    model: nn.Module,
    loader: DataLoader,
    train_state: TrainStepState,
    device: torch.device,
) -> float:
    model.train() # 将模型调整为训练模式,允许参数学习 
    total_loss = 0.0
    sample_count = 0
    for low_batch, high_batch in progress(loader, desc='train'):
        low, high = low_batch.to(device), high_batch.to(device) # 取数据
        train_state.optimizer.zero_grad(set_to_none=True) # 清除累计梯度
        pred = model(low) # 前向传播 
        loss = train_state.criterion(pred, high) # 计算损失 
        loss.backward() # 反向传播 
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        train_state.optimizer.step() # 步进优化器
        train_state.scheduler.step() # 步进规划器
        total_loss += loss.item() * low.shape[0] # 累计组内 Loss
        sample_count += low.shape[0]
    return total_loss / sample_count

询问大语言模型

这是信息量最大的一段代码,对于这里看不懂的概念,你只需要将这部分代码复制粘贴大语言模型一定会猜出其他的结构;类似这样带有明显流程的代码,最适合和大语言模型交流学习。

验证

validation.py 中,evalute() 函数包含了我们验证模型的逻辑,还记得验证吗,这是训练模型的定期考试

def evaluate(
    self,
    model: nn.Module,
    loader: DataLoader,
    device: torch.device,
) -> ValidationSummary:
    model.eval() # 切换模型到评估模式 
    total_loss = 0.0
    sample_count = 0
    with torch.no_grad(): # 关闭不必的梯度计算 
        for low_batch, high_batch in progress(loader, desc='val'):
            low = low_batch.to(device)
            high = high_batch.to(device)
            prediction = model(low) # 前向传播 
            loss = self.criterion(prediction, high) # 计算损失 
            total_loss += loss.item() * low.shape[0]
            sample_count += low.shape[0]
            self.status_tracker.update(
                prediction.detach().cpu(),
                high.detach().cpu(),
                anchor=low.detach().cpu(),
                batch_size=low.shape[0],
            )

还可以做什么

你可以搜寻ResNet 更为先进的模型结构,或自己进行一些尝试;修改 model.py 中的模型代码,初始化代码、Loss 函数(比如现在的 MSE 有什么问题?)LR Scheduler 及选用不同优化器,尝试取得更好的效果。

如果你对这方面暂时没有头绪,可以请教大语言模型或查看完整的体系神经网络教材。

Lab Note

修改参考代码,或重新建立一个项目;尝试改进模型性能,包括但不限于模型本身LossLR SchedulerOptimizer改进(更换)。不包括增加通道数、增加网络层数等增量式改进。

更好的指标有如下两种,均在测试集上评估:在 PSNR 增益或者 SSIM 增益上超过当前模型 10% 及以上;或在模型正负 10% 的指标内优化训练、推理的速度,减少 20% 以上的训练时间

本文作者