3.命令行参数管理与训练进度条
第 3 章 — 命令行参数管理与训练进度条(argparse + tqdm)
第 2 章结束时,
EPOCHS、BATCH_SIZE、lr、num_workers、root="./data"还硬编码在源码里,每调一次超参数就要改代码、改了什么无从追溯;同时训练循环像"黑盒",几百个 batch 静悄悄地跑,看不到任何进度。本章解决这两个问题:用标准库
argparse把所有超参数变成命令行参数,用社区库tqdm在终端显示 batch 级实时进度条。
一、本章要解决的问题
- 第 2 章的超参数(epochs、batch_size、lr、num_workers)全是源码里的常量。想调一下学习率,就得打开文件改一行数字—— 改了什么、什么时候改的、跟上次实验比差在哪,完全说不清 。
- 想做一组对比实验(比如 lr 从 1e-3 扫到 1e-4),只能复制脚本或反复改源码。实验参数对不上号,结果就成了"玄学"。
- 一个 epoch 要跑几百个 batch(batch_size=64 时训练集 45000 张 ≈ 704 步),屏幕却只在整个 epoch 结束后打印一行结果。卡住了?还要多久?进度到哪了? 只能干等,毫无反馈。
二、核心概念速览
本章有 5 个新概念,先花 5 分钟读完,再看代码会轻松很多。
1. 命令行参数(程序入口传参)
命令行参数,就是启动程序时在命令里额外传给程序的一串"选项"信息。打个比方:去餐厅点餐,python train.py 等于"来一份招牌套餐"(全部用默认配置),python train.py --epochs 30 等于"招牌套餐,主食换大份"——菜还是那道菜,但几个关键选项由你当场说了算,厨师(代码)不用每次重新改菜谱。注意:命令行传进来的东西原本都是字符串,所以 add_argument 里要声明 type=int、type=float,argparse 才会帮你转成正确的数字。
2. argparse 的工作原理(定义参数 → 解析 → Namespace 对象)
argparse 的工作分三步: 定义 → 解析 → 使用 。第一步用 add_argument 定义参数,相当于印好一张"菜单",写明每个选项的类型、默认值和帮助文字;第二步 parse_args() 读取你敲的命令,相当于"服务员对照菜单读你的点单";第三步它把结果打包成一个 Namespace 对象,代码里用 args.epochs、args.lr 这种属性名直接取值,就像服务员把点好的菜装进托盘端上来。整个过程全自动:--help 自动生成说明、类型自动转换、传错参数自动报错。
3. 为什么超参数要参数化(可追溯、可对比实验)
超参数写死在源码里,就像"凭手感放盐"——这次为什么跑得好、上次为什么跑得差,全靠记忆,无法追溯。参数化之后,每次实验是什么配置,看一眼训练开头打印的"训练配置"就一清二楚,等于给每场实验拍了张"身份证照",事后能准确回答"这个结果是用哪组参数跑出来的"。更重要的是它能支撑对比实验:在命令行里循环换参数跑几组,结果摆在一起谁优谁劣一目了然;同一个命令拿到别人机器上也能复现出相同配置的实验。
4. tqdm 进度条(为什么训练时有用)
tqdm 是一个社区进度条库,用法简单到一行:把可迭代对象包起来,例如 tqdm(loader, desc="Train", leave=False),循环每处理完一个 batch,终端就自动刷新一次进度条,显示已完成 batch 数、百分比、速度和预计剩余时间。训练时它特别有用:一个 epoch 要跑几百个 batch,快的几秒、慢的几十秒,没有进度条你只能干等,甚至分不清程序是"正在算"还是"卡死了"。有了它,训练节奏一眼可见,desc="Train" 和 desc="Val" 还能区分当前在跑训练还是验证。

5. --batch-size 在命令行与代码中的命名转换(- 变 _)
命令行里的参数名习惯用连字符 --batch-size,读起来清爽;但 Python 的变量名不允许出现连字符。argparse 的约定是:把参数名里的 - 自动替换成 _,所以代码里统一写成 args.batch_size。这是新手最常踩的坑之一:命令行敲 --batch-size,代码里却写 args.batch-size,直接 AttributeError。记住对应关系:命令行用 -,代码里用 _。
三、解决思路
- 超参数集中化:用标准库
argparse定义全部超参数(零依赖,无需安装),并把默认值设为第 2 章的值——这样不加任何参数时,行为与第 2 章完全一致,渐进式改造不破坏可运行性。 - 环境参数化:
--device auto自动检测 GPU/CPU(也允许手动指定cuda/cpu);--data-dir允许把数据集放到别的磁盘;--num-workers控制 DataLoader 的加载进程数。 - 实验留痕:训练开始前把全部参数打印一遍,作为每次实验的"身份证"——这一条很便宜,但对实验可追溯性极其重要。
- 进度可视化:用
tqdm包裹loader,训练/验证循环立即获得 batch 级进度条,desc="Train"/"Val"区分阶段,leave=False避免进度条残留在终端。 - 训练流程不变:模型、三段数据划分、指标记录、测试集最终评估全部沿用第 2 章,本章只动"入口传参"和"循环可视化"两处。
trade-off:argparse 是标准库,学得快、零依赖,但参数一多命令行会变得冗长难读——这个问题留到第 8 章用 YAML 配置文件解决;tqdm 是社区库,需要先
pip install tqdm(一行命令),换来的是每时每刻看得见的训练进度。
四、代码变更
相对第 2 章的改动:
+ import argparse
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
from collections import defaultdict
+ from tqdm import tqdm # 社区库:进度条(先 pip install tqdm)
+ # ----------------------------------------------------------------------------
+ # 0. 命令行参数:所有超参数集中在这里,带默认值和 help 说明
+ # ----------------------------------------------------------------------------
+ def parse_args():
+ parser = argparse.ArgumentParser(description="CIFAR-10 图像分类训练")
+ parser.add_argument("--epochs", type=int, default=10, help="训练轮数")
+ parser.add_argument("--batch-size", type=int, default=64, help="每个 batch 的样本数")
+ parser.add_argument("--lr", type=float, default=1e-3, help="学习率")
+ parser.add_argument("--data-dir", type=str, default="./data", help="数据集存放目录")
+ parser.add_argument("--num-workers", type=int, default=2, help="DataLoader 数据加载进程数")
+ parser.add_argument("--device", type=str, default="auto", help="运行设备:auto/cuda/cpu")
+ return parser.parse_args()
+
+ args = parse_args()
# 设备(原来无条件自动检测,现在支持手动指定)
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ if args.device == "auto":
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ else:
+ device = torch.device(args.device)
print(f"Using device: {device}")
# ... 模型定义 SimpleCNN 不变 ...
# 数据(root 与进程数不再写死)
- train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
- test_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)
+ train_dataset = datasets.CIFAR10(root=args.data_dir, train=True, download=True, transform=transform)
+ test_dataset = datasets.CIFAR10(root=args.data_dir, train=False, download=True, transform=transform)
train_dataset, val_dataset = random_split(
train_dataset, [45000, len(train_dataset) - 45000]
)
- BATCH_SIZE = 64
- train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)
- val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
- test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
+ train_loader = DataLoader(train_dataset, batch_size=args.batch_size,
+ shuffle=True, num_workers=args.num_workers)
+ val_loader = DataLoader(val_dataset, batch_size=args.batch_size,
+ shuffle=False, num_workers=args.num_workers)
+ test_loader = DataLoader(test_dataset, batch_size=args.batch_size,
+ shuffle=False, num_workers=args.num_workers)
# 优化器
- optimizer = optim.Adam(model.parameters(), lr=1e-3)
+ optimizer = optim.Adam(model.parameters(), lr=args.lr)
# 训练/验证函数(循环被 tqdm 包裹,获得 batch 级进度条)
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss, correct, total = 0.0, 0, 0
- for images, labels in loader:
+ for images, labels in tqdm(loader, desc="Train", leave=False):
...
def validate(model, loader, criterion, device):
model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
- for images, labels in loader:
+ for images, labels in tqdm(loader, desc="Val", leave=False):
...
+ # 新增:训练前打印完整参数 —— 每次实验的"身份证"
+ print("=" * 60)
+ print("训练配置:")
+ for k, v in vars(args).items():
+ print(f" {k:12s} = {v}")
+ print("=" * 60)
metrics = defaultdict(list)
- EPOCHS = 10
- for epoch in range(1, EPOCHS + 1):
+ for epoch in range(1, args.epochs + 1):
...
print(f"Epoch {epoch:02d}/{args.epochs} | ...")
test_loss, test_acc = validate(model, test_loader, criterion, device)
print(f"\n[Test] loss {test_loss:.4f} acc {test_acc:.4f}")五、完整代码
先安装本章唯一的新依赖(社区进度条库):
pip install tqdm创建 train.py(完整版):
import argparse
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
from collections import defaultdict
from tqdm import tqdm # 社区库:进度条
# ----------------------------------------------------------------------------
# 0. 命令行参数:所有超参数集中在这里,带默认值和 help 说明
# ----------------------------------------------------------------------------
def parse_args():
# 创建解析器,description 会显示在 --help 的第一行
parser = argparse.ArgumentParser(description="CIFAR-10 图像分类训练")
# 训练超参数:type 告诉 argparse 转成什么类型,default 保证不加参数也能跑
parser.add_argument("--epochs", type=int, default=10, help="训练轮数")
parser.add_argument("--batch-size", type=int, default=64, help="每个 batch 的样本数")
parser.add_argument("--lr", type=float, default=1e-3, help="学习率")
# 数据与运行环境
parser.add_argument("--data-dir", type=str, default="./data", help="数据集存放目录")
parser.add_argument("--num-workers", type=int, default=2, help="DataLoader 数据加载进程数")
parser.add_argument("--device", type=str, default="auto", help="运行设备:auto/cuda/cpu")
return parser.parse_args()
args = parse_args() # 解析命令行 -> Namespace 对象,之后用 args.xxx 取值
# ----------------------------------------------------------------------------
# 1. 设备(auto = 有 GPU 用 GPU,没有就用 CPU;也可手动指定 cuda/cpu)
# ----------------------------------------------------------------------------
if args.device == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(args.device) # 手动指定,例如 --device cuda
print(f"Using device: {device}")
# ----------------------------------------------------------------------------
# 2. 模型:SimpleCNN(与第 2 章相同,3 个卷积块 + 全局平均池化 + 分类头)
# ----------------------------------------------------------------------------
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# 块1:3 -> 32 通道,padding=1 保持 32x32 分辨率
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 32x32 -> 16x16
# 块2:32 -> 64 通道
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16x16 -> 8x8
# 块3:64 -> 128 通道
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 8x8 -> 4x4
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 任意输入尺寸 -> 1x1
nn.Flatten(), # (B,128,1,1) -> (B,128)
nn.Linear(128, num_classes), # 128 维特征 -> 10 类 logits
)
def forward(self, x):
# 前向传播:特征提取 -> 分类。x 形状: (batch, 3, 32, 32)
return self.classifier(self.features(x))
# ----------------------------------------------------------------------------
# 3. 数据:训练/验证/测试三段划分(root 与进程数都来自命令行)
# ----------------------------------------------------------------------------
transform = transforms.Compose([
transforms.ToTensor(), # PIL 图片 -> tensor(0~1)
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
train_dataset = datasets.CIFAR10(root=args.data_dir, train=True, download=True, transform=transform)
test_dataset = datasets.CIFAR10(root=args.data_dir, train=False, download=True, transform=transform)
# 从训练集切 5000 张做验证集(与第 2 章相同)
train_dataset, val_dataset = random_split(
train_dataset, [45000, len(train_dataset) - 45000]
)
train_loader = DataLoader(train_dataset, batch_size=args.batch_size,
shuffle=True, num_workers=args.num_workers)
val_loader = DataLoader(val_dataset, batch_size=args.batch_size,
shuffle=False, num_workers=args.num_workers)
test_loader = DataLoader(test_dataset, batch_size=args.batch_size,
shuffle=False, num_workers=args.num_workers)
# ----------------------------------------------------------------------------
# 4. 损失函数 + 优化器(lr 来自命令行)
# ----------------------------------------------------------------------------
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=args.lr)
# ----------------------------------------------------------------------------
# 5. 训练与验证函数:tqdm 包裹 loader,显示 batch 级进度条
# ----------------------------------------------------------------------------
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss, correct, total = 0.0, 0, 0
# tqdm 包裹 loader:显示 batch 级进度条;desc 是进度条前缀文字,leave=False 不残留
for images, labels in tqdm(loader, desc="Train", leave=False):
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad() # 清零梯度(梯度会累加,不清会叠加)
outputs = model(images) # 前向传播 -> (batch, 10) 的 logits
loss = criterion(outputs, labels)
loss.backward() # 反向传播:算出每个参数的梯度
optimizer.step() # 按梯度更新一次参数
total_loss += loss.item() * images.size(0) # 累加损失(乘样本数,后面加权平均)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total # 返回 平均loss, 准确率
def validate(model, loader, criterion, device):
model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # 验证不计算梯度,省显存、更快
for images, labels in tqdm(loader, desc="Val", leave=False):
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total
# ----------------------------------------------------------------------------
# 6. 训练前打印完整参数 —— 每次实验的"身份证",可追溯、可对比
# ----------------------------------------------------------------------------
print("=" * 60)
print("训练配置:")
for k, v in vars(args).items(): # vars(args) 把 Namespace 转成字典
print(f" {k:12s} = {v}")
print("=" * 60)
# ----------------------------------------------------------------------------
# 7. 主训练循环:指标记录 + 每个 epoch 打印结果
# ----------------------------------------------------------------------------
metrics = defaultdict(list)
for epoch in range(1, args.epochs + 1):
train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc = validate(model, val_loader, criterion, device)
metrics["train_loss"].append(train_loss)
metrics["train_acc"].append(train_acc)
metrics["val_loss"].append(val_loss)
metrics["val_acc"].append(val_acc)
print(f"Epoch {epoch:02d}/{args.epochs} | "
f"Train loss {train_loss:.4f} acc {train_acc:.4f} | "
f"Val loss {val_loss:.4f} acc {val_acc:.4f}")
# 训练结束,用从未参与训练过程的测试集做最终评估
test_loss, test_acc = validate(model, test_loader, criterion, device)
print(f"\n[Test] loss {test_loss:.4f} acc {test_acc:.4f}")运行示例:
# 1) 不传参数 = 使用全部默认值(行为与第 2 章一致)
python train.py
# 预期:先打印 Using device 与"训练配置"(6 个参数的默认值),
# 然后每个 epoch 出现 Train/Val 两条实时进度条,
# epoch 结束后打印该轮 loss/acc,最后打印 [Test] 结果。
# 2) 调参:跑 30 个 epoch、batch 128、学习率 1e-4
python train.py --epochs 30 --batch-size 128 --lr 1e-4
# 预期:训练配置里 epochs=30、batch_size=128、lr=0.0001;
# 训练进度条长度变为 45000/128 ≈ 352 步,验证进度条约 39 步。
# 3) 查看全部参数说明
python train.py --help
# 预期:打印 argparse 自动生成的 usage 以及每个参数的 type/default/help。六、本章小结
- 学到了什么
argparse三步走:add_argument定义参数 →parse_args()解析 →args.xxx取值,全程零依赖。- 命令行参数名
--batch-size在代码里自动变成args.batch_size(-变_)。 - 所有默认值继承第 2 章,保证"不加参数 = 上一章行为",渐进式改造不断档。
- 训练前打印全部参数是实验可追溯性的第一道保障——每次实验自带"身份证"。
tqdm包住 loader 即可获得 batch 级进度条:desc是前缀文字(区分 Train/Val),leave=False不残留。
- 常见坑
- 忘了
pip install tqdm,import tqdm直接ModuleNotFoundError。 - 在代码里写
args.batch-size会AttributeError,必须写args.batch_size。 --device cuda在无 GPU 的机器上直接崩溃,用默认的auto兜底更稳妥。- 进度条与多行 print 混排时终端会"闪",用
desc区分阶段、leave=False让进度条跑完即消失。
- 忘了
- 下一章预告:固定学习率训练到后期,loss 不再下降、收敛越来越慢——第 4 章引入学习率调度器,让 lr 随训练动态调整。
七、动手练习
- 跑一次参数扫描:执行
for lr in 1e-3 1e-4 3e-4; do python train.py --lr $lr --epochs 5; done,观察三组实验收敛速度的差异,并确认每次运行开头打印的"训练配置"各不相同。 - 加参数:新增
--val-ratio(验证集比例,默认 0.1),用args.val_ratio计算random_split的切分数,别忘了在--help里能看到它的说明。 - 玩转进度条:把
leave=False改成leave=True,跑一个 epoch 观察终端残留多条进度条的效果;再把desc="Train"改成desc=f"Epoch {epoch}"(需要把 epoch 传入train_one_epoch或利用闭包),体会进度条如何"报站"。 - 配置即记录:训练开始时把
vars(args)用json.dump存成config.json,跑两组不同参数后对比两个 json,体验"参数留痕"对实验对比的价值。
