跳转至

五:训练与优化

写出一个标准的PyTorch训练循环骨架

一个标准的训练循环包含数据加载、前向传播、损失计算、反向传播、参数更新、学习率调整和评估等步骤。

import torch
from torch.utils.data import DataLoader
from tqdm import tqdm

def train_one_epoch(model, train_loader, optimizer, loss_fn, device, epoch, scaler=None):
    model.train()
    total_loss = 0.0
    progress_bar = tqdm(train_loader, desc=f'Epoch {epoch}')
    for batch_idx, (inputs, targets) in enumerate(progress_bar):
        inputs, targets = inputs.to(device), targets.to(device)

        optimizer.zero_grad()  # 清零梯度

        if scaler is not None:  # 混合精度
            with torch.cuda.amp.autocast():
                outputs = model(inputs)
                loss = loss_fn(outputs, targets)
            scaler.scale(loss).backward()
            scaler.step(optimizer)
            scaler.update()
        else:
            outputs = model(inputs)
            loss = loss_fn(outputs, targets)
            loss.backward()
            optimizer.step()

        total_loss += loss.item()
        progress_bar.set_postfix({'loss': total_loss / (batch_idx + 1)})

    return total_loss / len(train_loader)

def validate(model, val_loader, loss_fn, device):
    model.eval()
    total_loss = 0.0
    with torch.no_grad():
        for inputs, targets in val_loader:
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = model(inputs)
            loss = loss_fn(outputs, targets)
            total_loss += loss.item()
    return total_loss / len(val_loader)

关键点:

  • model.train()model.eval() 切换模式(影响Dropout、BatchNorm等)。

  • optimizer.zero_grad() 必须在 backward() 之前。

  • 混合精度时使用 GradScaler,具体见第4问。


2. optimizer.zero_grad() 为什么要在 loss.backward() 之前调用?

因为 PyTorch 的梯度是累积的。如果不先清零,本次 backward() 计算出的梯度会与之前迭代的梯度加在一起,导致:

  • 梯度污染:模型参数更新使用了过期的梯度方向。

  • 训练不稳定:梯度量级可能异常增大,导致梯度爆炸。

  • 无法正确模拟当前batch对参数的影响。

因此,在每次开始一个新的 mini-batch 时,必须将参数的梯度清零。optimizer.zero_grad() 遍历优化器管理的所有参数,将它们的 .grad 设为 None(或填零)。设为 None 更高效,因为它避免了额外的内存操作。

如果在梯度累积场景下,则需要在累积期间不清零,在累积完成后、更新参数之前才清零。


梯度累积如何实现?等效于增大batch size吗?

实现:

在显存不足以使用大batch时,通过连续处理多个小batch,累加它们的梯度,然后统一更新参数。等效于扩大全局batch size,因为参数更新的梯度是多个小batch梯度的平均。

accumulation_steps = 4
optimizer.zero_grad()
for i, (inputs, targets) in enumerate(train_loader):
    outputs = model(inputs)
    loss = loss_fn(outputs, targets) / accumulation_steps  # 缩放损失
    loss.backward()
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

原理:每次 backward() 的梯度会累加到 .grad 中。当累积了 K 步后,总的梯度相当于原始大batch(大小为 K * micro_batch_size)的梯度。为了保证更新量级一致,loss 需要除以 K

等效性:从参数更新的数学期望上看,梯度累积等效于增大batch size(假设batch间独立同分布)。但需要注意,某些层如BatchNorm的统计量是在每个micro-batch内计算的,因此与真正的大batch会略有差异。对于LayerNorm或大多数NLP模型,影响可忽略。


如何使用 torch.cuda.amp 进行混合精度训练?写出关键步骤。

混合精度训练自动将前向、反向中的部分计算转为 FP16,以减少显存占用并利用 Tensor Core 加速,同时保持关键部分为 FP32。

关键步骤:

  1. 创建 GradScaler:scaler = torch.cuda.amp.GradScaler()

  2. 使用 autocast 上下文:在模型前向传播时包裹 with torch.cuda.amp.autocast():

  3. 缩放损失并反向传播:scaler.scale(loss).backward()

  4. 更新参数:scaler.step(optimizer),然后 scaler.update()

完整示例:

model = MyModel().to(device)
optimizer = torch.optim.AdamW(model.parameters())
scaler = torch.cuda.amp.GradScaler()

for inputs, targets in train_loader:
    inputs, targets = inputs.to(device), targets.to(device)
    optimizer.zero_grad()

    with torch.cuda.amp.autocast():
        outputs = model(inputs)
        loss = loss_fn(outputs, targets)

    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

优点:显存降低30%~50%,训练速度提升(在Ampere及之后的GPU上),精度损失极小。推荐使用BF16时可直接用 torch.bfloat16 而无需 GradScaler


使用混合精度训练时,GradScaler 的作用是什么?

GradScaler 解决 FP16 梯度下溢的问题。由于 FP16 的动态范围小,许多小梯度会变为零,导致参数无法更新。GradScaler 在反向传播前将 loss 乘以一个大的缩放因子(如 65536),使梯度整体放大到 FP16 可表示范围内,然后在更新参数前再将梯度除以该因子恢复原始量级。

工作机制:

  • scaler.scale(loss).backward():自动缩放损失并反向传播。

  • scaler.step(optimizer):判断梯度是否包含 Inf/NaN,若没有,则执行 optimizer.step() 并更新缩放因子(根据需要增大);若出现溢出,则跳过该步更新,并减小缩放因子。

  • scaler.update():更新缩放因子的动态调整。

这样既利用了 FP16 的加速优势,又保证了数值稳定性,避免手动调整。


如何设置梯度裁剪?max_norm 参数如何选择?

梯度裁剪通过限制梯度的 L2 范数,防止梯度爆炸。在 PyTorch 中,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)

设置方法:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

通常放在 loss.backward() 之后、optimizer.step() 之前。

max_norm 选择:

  • 经验值:全参微调常设为 1.0;使用 PEFT(如 LoRA)时梯度通常较小,可设为 5.0 或更高。

  • 动态确定:在训练初期监控梯度范数,取一个略高于正常范围的值为上限。若训练中频繁触发裁剪(日志中 grad_norm 总是等于 max_norm),说明学习率可能过大或数据有问题。

  • 观察损失曲线:若损失频繁出现尖峰,说明可能发生梯度爆炸,需降低 max_norm 或减小学习率。

注意:梯度裁剪是对所有参数梯度的全局范数进行约束,是保证训练稳定性的重要手段。


学习率调度器如何使用?StepLR和CosineAnnealingLR的区别。

学习率调度器用于在训练过程中动态调整学习率,以加速收敛、防止震荡和跳出局部最优。

使用方式:

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
for epoch in range(epochs):
    train(...)
    scheduler.step()

StepLR vs CosineAnnealingLR:

  • StepLR:每经过 step_size 个epoch,学习率乘以 gamma(例如0.1)。呈阶梯状下降,简单可控,但可能不够平滑。

  • CosineAnnealingLR:学习率按照余弦曲线从初始值逐渐衰减到 eta_min(通常为0或接近0)。曲线先平缓后加速下降,使训练后期更精细地收敛。通常与 warmup 结合使用(如 CosineAnnealingWarmRestarts),是当前最常用的学习率策略之一,尤其在SFT中。

选择建议:对于微调,优先使用 CosineAnnealingLR,配合 warmup 能更稳定地找到更优解。


如何根据验证损失动态调整学习率?ReduceLROnPlateau的用法。

ReduceLROnPlateau 监控某个指标(如验证损失),当指标停止改善时,降低学习率。

用法:

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.5, patience=5, min_lr=1e-6
)
for epoch in range(epochs):
    train_loss = train(...)
    val_loss = validate(...)
    scheduler.step(val_loss)  # 传入监控的指标

关键参数:

  • mode='min':当指标不再下降时触发。

  • factor=0.5:触发时将学习率乘以该因子(减半)。

  • patience=5:允许指标连续不改善的 epoch 数。

  • min_lr:学习率的下限。

优点:无需预设训练总长度,能根据训练动态自动调整,常用于微调中避免过拟合。


写出一个包含 warmup 的学习率调度示例。

Warmup 使学习率在训练初期从小值线性增加到目标值,有助于稳定训练。可与余弦退火结合。

import torch
from torch.optim import AdamW
from torch.optim.lr_scheduler import LambdaLR

def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps, min_lr=0.0):
    def lr_lambda(current_step):
        if current_step < num_warmup_steps:
            return float(current_step) / float(max(1, num_warmup_steps))
        progress = float(current_step - num_warmup_steps) / float(max(1, num_training_steps - num_warmup_steps))
        return max(min_lr, 0.5 * (1.0 + torch.cos(torch.pi * progress)))
    return LambdaLR(optimizer, lr_lambda)

optimizer = AdamW(model.parameters(), lr=2e-5)
scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=100, num_training_steps=2000)

for step, batch in enumerate(train_loader):
    train(...)
    scheduler.step()

说明:前100步线性增加学习率,之后按余弦曲线衰减。这是当前LLM微调中最常用的策略。LambdaLR 允许自定义任意函数,灵活性高。

也可以使用 HuggingFace transformers 中的 get_cosine_schedule_with_warmup,功能相同。

在训练中如何保存最佳模型?根据什么指标选择?

保存最佳模型通常依赖于一个监控指标,当该指标创下历史最佳时,将当前模型的参数(state_dict)保存到磁盘。

最佳指标选择:

  • 验证损失(Validation Loss):最通用的指标,越低越好。但需注意,损失最低的模型不一定是生成质量最好的,可能会过拟合或模板化。

  • 特定任务指标:对于分类任务,选择验证集准确率(Accuracy)或 F1-score;对于生成任务,可以选择 BLEU、ROUGE 或通过 GPT-4 评判的胜率。在微调中,我更推荐使用与业务目标直接相关的指标。

  • 安全与对齐指标:在安全敏感的微调中,会同时监控“安全拒绝率”和“误拒绝率”,只在两者都达标的情况下才考虑保存。

实现方法:

best_val_loss = float('inf')
for epoch in range(epochs):
    train_loss = train_one_epoch(...)
    val_loss = validate(...)

    if val_loss < best_val_loss:
        best_val_loss = val_loss
        torch.save({
            'epoch': epoch,
            'model_state_dict': model.state_dict(),
            'optimizer_state_dict': optimizer.state_dict(),
            'loss': best_val_loss,
        }, 'best_model.pt')
        print(f'New best model saved with val_loss: {best_val_loss:.4f}')

保存内容:不仅保存模型权重,还应保存优化器状态、当前 epoch 和最佳指标值,以便后续恢复训练或进行断点续训。

注意事项:

  • 不要仅凭训练损失保存模型,因为训练损失可以持续下降而验证损失可能已反弹。

  • 可以同时保存多个“最佳”模型,例如“最佳验证损失”和“最佳验证准确率”,但最终选择哪个上线,应通过离线评估和在线 A/B 测试决定。


如何从断点恢复训练?需要保存和加载哪些内容?

从断点恢复训练需要完整地保存训练状态,而不仅仅是模型权重。这包括:

  • 模型参数:model.state_dict()

  • 优化器状态:optimizer.state_dict()(包含动量、方差等)

  • 学习率调度器状态:scheduler.state_dict()

  • 训练进度:当前的 epochglobal_step

  • 随机数状态:torch.random.get_rng_state() 以及 cudanumpy 的随机状态,以确保数据增强和 dropout 的可复现。

  • 数据加载器状态(可选,较复杂):对于 IterableDataset 流式数据,可能需要记录文件偏移量;对于 Map-style 数据集,DistributedSampler 需要设置正确的 epoch

保存检查点:

checkpoint = {
    'epoch': epoch,
    'global_step': global_step,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'scheduler_state_dict': scheduler.state_dict(),
    'loss': current_loss,
    'rng_states': {
        'torch': torch.random.get_rng_state(),
        'cuda': torch.cuda.get_rng_state_all(),
        'numpy': np.random.get_state(),
    }
}
torch.save(checkpoint, f'checkpoint_epoch_{epoch}.pt')

恢复训练:

checkpoint = torch.load('checkpoint_epoch_10.pt')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
start_epoch = checkpoint['epoch'] + 1

# 恢复随机种子(关键!)
torch.random.set_rng_state(checkpoint['rng_states']['torch'])
torch.cuda.set_rng_state_all(checkpoint['rng_states']['cuda'])
np.random.set_state(checkpoint['rng_states']['numpy'])

注意事项:

  • 在分布式训练中,torch.save 通常只在 rank0 执行,避免多卡同时写入冲突。

  • 恢复时,分布式环境必须与保存时完全一致(卡数、模型并行策略),否则状态字典的键可能不匹配。


模型训练中遇到显存不足,如何优化?

显存不足(OOM)是训练中最常见的问题。优化策略按性价比从高到低排序:

  1. 减小 micro_batch_size,使用梯度累积 这是最直接的方法。将 batch_size 降到显存允许的最小值,然后通过梯度累积模拟需要的全局 batch size。

  2. 开启混合精度训练 使用 torch.cuda.amp 自动混合精度(FP16/BF16),能将显存占用降低约 30%~50%,且几乎不影响精度。在支持 BF16 的硬件上优先使用 BF16。

  3. 开启梯度检查点 通过 torch.utils.checkpointmodel.gradient_checkpointing_enable() 以计算时间换取显存空间,能将激活值的显存占用降低 70% 以上。

  4. 使用 PEFT(如 LoRA)替代全参数微调

LoRA 只训练极少量的低秩适配器,冻结原始参数。显存节省可达 80% 以上,且几乎不影响下游任务性能。

  1. 量化模型权重 使用 bitsandbytes 的 4-bit 或 8-bit 量化加载基座模型(QLoRA),可将模型权重显存降至 1/4 以下。

  2. 使用更高效的注意力实现

开启 Flash Attention-2,能大幅降低长序列训练时的激活显存,同时加速训练。

  1. 调整数据加载策略 减少 num_workers、使用 pin_memory=False 或直接在 GPU 上创建数据集(如 device='cuda')可以释放部分显存。

  2. 使用 CPU/NVMe Offload

DeepSpeed ZeRO-Offload 可以将优化器状态甚至参数卸载到 CPU 或 NVMe,以牺牲速度为代价,换取在极小显存上训练超大模型的能力。

实践诊断:先用 torch.cuda.memory_summary() 查看是哪些部分占用了大量显存,再有针对性地优化。


使用 torch.utils.checkpoint 进行梯度检查点,如何节省显存?

原理:梯度检查点在前向传播时丢弃部分中间激活值,在反向传播需要时重新计算这些激活值,以计算时间换取显存空间。

实现方式:

  • 对单个模块:torch.utils.checkpoint.checkpoint(module, input)

  • 对整个模型:使用 HuggingFace 的 model.gradient_checkpointing_enable()

image.png

计算代价:重计算会带来额外的计算量,训练速度通常下降 20%~30%。这是“时间换空间”的典型例子。

分布式微调中的配置:

  • 与 ZeRO/FSDP、Flash Attention 等正交,可叠加使用。

  • 通常对每个 Transformer Block 设置检查点。


为什么有时 batch size 越大,需要调整学习率?

线性缩放法则:当 batch size 增大 K 倍时,每个参数的梯度估计更准确(噪声更小),因此可以采用更大的步长(学习率)来更新参数,而不会导致训练震荡。一个简单且广泛使用的规则是线性缩放法则:lr = base_lr * (batch_size / base_batch_size)

理论直觉:

  • 小 batch:梯度噪声大,每一步方向不确定,需要小步慢走,否则极易震荡。

  • 大 batch:梯度方向更接近全局最优方向,可以迈大步,加速收敛。

注意事项:

  • 线性缩放法则仅在一定范围内有效(如 batch size 增大 4~8 倍),当 batch size 极其巨大时(如 32k+),学习率不能无限增大,需要配合 warmup 和更保守的调整。

  • 对于自适应优化器(如 Adam),学习率调整的需求不如 SGD 强烈,但一定程度的调整仍有帮助。


如何使用 TorchMetrics 库来简化指标计算?

TorchMetrics 提供了一套与 PyTorch 无缝集成的、可微的、跨设备的评估指标,避免手动管理预测和目标的累积。

基本用法:

import torchmetrics

# 定义指标
accuracy = torchmetrics.Accuracy(task="multiclass", num_classes=10)

for batch in dataloader:
    inputs, targets = batch
    outputs = model(inputs)
    # 指标自动累积
    acc = accuracy(outputs, targets)

# 计算总结果
total_acc = accuracy.compute()
accuracy.reset()  # 进入下一轮评估前重置

优势:

  • 自动处理多卡同步(通过 sync_on_compute=True)。

  • 支持 .to(device) 移动到指定设备。

  • 内置大量指标(F1, BLEU, Perplexity 等),减少重复代码。

  • 可组合使用 MetricCollection 同时跟踪多个指标。


如何实现一个训练过程中自动调整 batch size 的策略?

自动调整 batch size 常用于在显存受限时动态平衡吞吐量和稳定性。一个简单的策略是:根据当前显存占用,动态增加或减少 batch size,或根据梯度范数变化调整。

示例:基于 OOM 的自适应 batch size:

current_batch_size = 64
while True:
    try:
        train_with_batch_size(current_batch_size)
        # 成功,尝试增大
        current_batch_size = int(current_batch_size * 1.1)
    except RuntimeError as e:
        if 'out of memory' in str(e):
            # OOM,减小并重试
            current_batch_size = int(current_batch_size * 0.8)
            torch.cuda.empty_cache()
            continue
        else:
            raise e

更精细的做法是基于梯度噪声尺度(GNS)动态调整,但工程实现复杂。工业界更常用的是固定 micro-batch size,通过梯度累积来灵活调整有效 batch size。


解释“余弦退火重启”学习率调度及其优势。

余弦退火重启(CosineAnnealingWarmRestarts)是将学习率按余弦曲线周期性地衰减,并在每个周期结束时重置到初始值,开始一个新的周期。

优势:

  • 跳出局部最优:周期性地提高学习率,使模型有机会跳出尖锐的局部极小值,找到更宽泛、泛化能力更好的解。

  • 更好的最终性能:相比单调衰减,重启策略通常能获得更高的最终准确率。

  • 简化调参:不需要精细设置衰减的 epoch 数,只需设定周期长度和最小学习率。

使用方式:

scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
    optimizer, T_0=10, T_mult=2, eta_min=1e-6
)

T_0 为第一个周期的长度,T_mult 控制后续周期的放大倍数。每个周期内学习率从初始值余弦衰减到 eta_min

在微调中的应用:适合多轮增量微调或数据飞轮场景,让模型在多次重启中持续探索更优的对齐状态。


在训练 RNN 时,如何处理变长序列?pack_padded_sequence 的作用。

RNN 要求一个 batch 内的所有序列长度相同,否则无法并行计算。pack_padded_sequence 将填充后的变长序列压缩为一个紧凑的 PackedSequence 对象,RNN 在其上运算时自动跳过填充部分,既避免浪费计算,也防止填充值污染隐藏状态。

使用流程:

  1. 将序列按长度降序排列。

  2. 使用 pad_sequence 填充到最长序列。

  3. 调用 pack_padded_sequence,传入填充后的张量、序列长度列表和 batch_first=True

  4. PackedSequence 送入 RNN。

  5. RNN 输出仍然是 PackedSequence,可用 pad_packed_sequence 解包回普通张量。

from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

# inputs: (batch, seq_len, feature_dim), lengths: list of seq lengths
sorted_lengths, sorted_idx = lengths.sort(descending=True)
sorted_inputs = inputs[sorted_idx]
packed = pack_padded_sequence(sorted_inputs, sorted_lengths, batch_first=True)
packed_output, hidden = rnn(packed)
output, _ = pad_packed_sequence(packed_output, batch_first=True)

对于现代大模型(Transformer),此技术已不常用,因为 Transformer 使用 attention_mask 来忽略填充位置。但对于某些轻量级或特定结构(如 LSTM 语言模型),依然有效。


如何训练一个自编码器?重构损失如何定义?

自编码器(Autoencoder)的训练目标是让输出尽可能还原输入。因此,重构损失衡量输入和输出之间的差异。

常用重构损失:

  • 均方误差(MSE):nn.MSELoss(),适用于连续值输入(如图像像素值)。

  • 二元交叉熵(BCE):nn.BCEWithLogitsLoss(),当输入值在 [0,1] 之间且被视为概率时。

  • L1 损失:nn.L1Loss(),对异常值更鲁棒。

训练循环示例(图像自编码器):

encoder = Encoder()
decoder = Decoder()
loss_fn = nn.MSELoss()

for images, _ in train_loader:
    images = images.to(device)
    encoded = encoder(images)
    reconstructed = decoder(encoded)
    loss = loss_fn(reconstructed, images)  # 目标为原始输入
    loss.backward()
    optimizer.step()

正则化:可加入 KL 散度(如 VAE)或稀疏性约束来学习更鲁棒的潜在表示。


对抗训练如何在 PyTorch 中实现?比如 FGSM。

对抗训练通过向输入注入微小扰动,使模型对此类攻击具有鲁棒性。FGSM(Fast Gradient Sign Method) 是一种快速生成对抗样本的方法。

实现步骤:

  1. 正常前向传播,计算损失。

  2. 计算输入图像的梯度(requires_grad=True)。

  3. 使用梯度符号生成扰动:perturbation = epsilon * gradient.sign()

  4. 将扰动加到原始图像上,形成对抗样本。

  5. 用对抗样本再次计算损失,反向传播更新参数。

def fgsm_attack(model, images, labels, epsilon, loss_fn):
    images.requires_grad = True
    outputs = model(images)
    loss = loss_fn(outputs, labels)
    loss.backward()
    # 生成扰动
    perturbed = images + epsilon * images.grad.sign()
    return torch.clamp(perturbed, 0, 1)  # 保持合法像素范围

# 在训练循环中
for images, labels in train_loader:
    # 正常训练步骤(可选)
    # ...
    # 生成对抗样本
    adv_images = fgsm_attack(model, images, labels, epsilon=0.007, loss_fn=loss_fn)
    # 用对抗样本训练
    outputs = model(adv_images)
    loss = loss_fn(outputs, labels)
    loss.backward()
    optimizer.step()

注意:通常将正常样本和对抗样本的损失混合(例如各占50%),以保持对干净数据的泛化能力。