数字加英文的图片验证码识别,是很多人踏入 PyTorch 视觉实战的第一块试金石。它看着简单:不就几十个字符的分类嘛。真上手才知道麻烦在于——你得先有一批带标签的图,然后得让模型在旋转、粘连、干扰线、背景噪点这堆东西里把 4 个字符一个不差地认出来,最后还得在真实图片上维持住这个准确率。我自己在这条路上折腾过几轮,从最初拿几千张样本训出一个“单字符准确率 99.6%、整串准确率 71%”的模型,到后来把整串准确率稳定在 97% 以上,中间踩的坑基本都集中在数据构造和评估口径这两块,模型结构反倒是最好解决的部分。
这篇内容围绕 PyTorch 训练一个数字加英文的验证码识别模型展开,从字符集界定、样本渲染、网络选型、训练配置,一直讲到分布漂移补齐和推理导出。适合已经会用 PyTorch 跑通 MNIST、想真正做一个端到端小项目的人;也适合手上有一批存量图片需要自动转录、想评估 OCR 方案可行性的同学。文中所有代码都是可以直接跑起来的最小实现,不是伪代码。
需要提前说明一点:这类模型请只用于你自己拥有或有明确授权的系统,比如内部系统的自动化测试、历史数据归档转录、模型能力研究。拿去做别人系统的自动化登录,性质就变了,这个边界自己心里要有数。
1. 先把任务边界划清楚:字符集、长度与"不变量"
动手写第一行代码之前,有三件事必须先定死:字符表里有哪些字符、一张图固定几个字符、以及什么在变什么不变。这三件事决定了你后面所有工作的上限,定错了后面很难救回来。
1.1 字符集里删掉哪几个字符,直接决定上限
多数数字加英文的验证码,表面上用 36 个字符(0-9 加 A-Z)。但你真去看图片会发现,0 和 O、1 和 I 和 l、2 和 Z、5 和 S、8 和 B 在低分辨率、带旋转、加模糊之后,人类自己都分不清。这不是模型能力问题,是信息在图像里本来就被抹掉了。
我的处理办法是直接把易混淆字符从字符表里踢掉,只保留 31 个:
CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 去掉 0 O 1 I L S Z B这里我把 S、Z、B 也去掉了,因为在我的样本里它们和 5、2、8 的混淆率明显高于其他组合。如果你的样本里字体很清晰、没有任何形变,那可以只删 0O1Il。判断标准很简单:拿 500 张样本让人工去标,标错的那些字符对,就是你要删的。
这个删除动作的收益是双重的。一是识别准确率直接上一个台阶;二是字符表从 36 降到 31,定长 4 字符的组合空间从 36⁴≈168 万降到 31⁴≈92 万,理论上需要的样本量也跟着降。代价是你只能识别这个子集,如果真实数据里确实会出现 0 和 O,那就得老老实实把它们加回来,然后接受那几个字符上永远偏低的准确率。
注意:字符表一旦确定,必须落盘成一个文件(比如
charset.json),训练、验证、推理三处都从这里读。我见过最常见的一类线上事故,就是训练脚本里写死了字符表,推理脚本里手打了一遍,顺序不一样,模型输出全部错位,而且准确率看着还挺高——因为大部分位置碰巧对上了。
1.2 定长还是变长:两条技术路线的分水岭
定长和变长,对应的模型设计完全是两套东西,这是第二个要提前定的事。
如果你的目标图片永远是 4 个字符,那就走整图多标签分类:一个 CNN 骨干,最后接一个长度为4 × num_classes的全连接层,输出 reshape 成(batch, 4, num_classes),直接按位置算交叉熵。这条路简单、收敛快、推理快,我在 4 字符场景下基本都选它。
如果长度是 4 到 6 不固定,或者同一批数据里长度不一致,那就得走CRNN + CTC,让模型输出一条时间序列,靠 CTC 对齐,解码时合并重复和空白。这条路工程复杂度高一个量级,训练也更难收敛,但它是变长场景唯一省事的解法——否则你就得为每种长度单独训一个模型,或者训一个长度分类器再做分支,维护成本反而更高。
先问清楚业务上长度是否真会变。很多人的数据其实是定长的,只是没统计过,白折腾了 CRNN。
1.3 一个必须提前想清楚的问题:数据从哪来
真实项目的成败八成压在数据上。典型来源有三种:
| 来源 | 优点 | 现实问题 |
|---|---|---|
| 自己用字体渲染 | 标签绝对准确、量和难度可任意调、零成本 | 和真实图片分布有差距 |
| 从业务系统导出历史图片人工标注 | 分布完全一致 | 量少、标注慢,一张图人工录入约 3-5 秒,标 2 万张要好几天 |
| 混合:渲染预训练 + 少量真实微调 | 兼顾量和分布 | 需要设计微调流程 |
我推荐第三条路,后面第 5 节会详细讲微调怎么做。这里先记住一个结论:纯渲染数据训出来的模型,在真实图片上掉 10 到 20 个百分点是常态,别指望一次到位。
样本量的估算可以算一算。定长 4 字符、31 类字符表,要保证每个字符在每个位置上至少出现 100 次,那么每个位置至少需要 3100 张,四个位置取最大值,最少约 1.2 万张。这只是下限,实际我一般会准备 5 万到 10 万张,让模型见到足够多的形变组合。变长场景再加 30% 余量。
2. 自建验证码渲染器:把"数据分布"捏在自己手里
渲染器的价值不只是"造数据",更重要的是它是一个可调参数的空间——你能精确控制难度,从而做课程学习;也能精确复现某类真实样本的形态,做定向补数据。这东西写一次,后面反复用。
2.1 用 Pillow 画一张能用的样本
核心思路是逐字符绘制,每个字符单独做旋转和位移,再贴回画布。这样比整串文字一次性画上去更接近真实验证码的形态(真实样本里字符几乎不可能在同一条基线上)。
import random from PIL import Image, ImageDraw, ImageFont, ImageFilter FONT_PATHS = ["./fonts/Sans-Bold.ttf", "./fonts/Mono-Bold.ttf"] def _rand_color(low=0, high=120): return tuple(random.randint(low, high) for _ in range(3)) def gen_captcha(text, size=(160, 64), font_size=42): w, h = size img = Image.new("RGB", size, (255, 255, 255)) draw = ImageDraw.Draw(img) # 1. 背景噪点 for _ in range(random.randint(80, 200)): draw.point((random.randrange(w), random.randrange(h)), fill=_rand_color(150, 255)) # 2. 逐字符绘制 font = ImageFont.truetype(random.choice(FONT_PATHS), font_size) step = w // (len(text) + 1) for i, ch in enumerate(text): cell = Image.new("RGBA", (font_size * 2, font_size * 2), (0, 0, 0, 0)) d = ImageDraw.Draw(cell) d.text((font_size // 2, font_size // 4), ch, font=font, fill=_rand_color(0, 100) + (255,)) cell = cell.rotate(random.uniform(-25, 25), resample=Image.BICUBIC, expand=False) x = step * (i + 1) - font_size // 2 + random.randint(-4, 4) y = (h - font_size) // 2 + random.randint(-6, 6) img.paste(cell, (x, y), cell) # 3. 干扰线 for _ in range(random.randint(0, 3)): pts = [(random.randrange(w), random.randrange(h)) for _ in range(2)] draw.line(pts, fill=_rand_color(0, 160), width=1) # 4. 随机模糊 if random.random() < 0.5: img = img.filter(ImageFilter.GaussianBlur(random.uniform(0.3, 0.9))) return img几个容易忽略的点。第一,字体要多种,至少准备 3 到 5 种字重和字形差异大的字体,单一字体训出来的模型对字形变化极其敏感。第二,旋转角度和位移幅度要用真实样本校准,不要凭感觉给 ±25 度,如果真实样本只有 ±10 度,你给大了模型会学到一堆用不上的不变性,白白浪费容量。第三,颜色抖动要保留,真实数据里存在扫描、压缩、屏幕截图带来的色偏,训练时见过总比没见过好。
2.2 干扰强度分三档,按课程学习的方式喂给模型
一上来就用最难的样本训,模型大概率卡在损失降不下去的状态。我的做法是把难度分三档,先易后难。
| 档位 | 干扰线 | 噪点 | 旋转 | 模糊 | 用途 |
|---|---|---|---|---|---|
| 简单 | 0 条 | ≤50 点 | ±8 度 | 无 | 前 3 个 epoch 热身 |
| 中等 | 1-2 条 | 50-120 点 | ±15 度 | 概率 0.3 | 主要训练集 |
| 困难 | 2-4 条 | 120-250 点 | ±25 度 | 概率 0.6 | 后 1/3 训练阶段 |
具体操作上,不需要分三个数据集,而是给每个样本带一个难度等级字段,在每个 epoch 开始时按当前进度重新计算允许的难度。前 20% 的训练步只用简单和中等,中间 50% 中等为主,最后 30% 引入困难样本。这样做的实质是让模型先学到"字符长什么样"这个基础表征,再去学"怎么从干扰里把它抠出来",收敛速度快很多,实测收敛所需的 epoch 能从 40 降到 25 左右。
2.3 字符表落盘与样本命名规范
渲染脚本跑之前,把这几样东西固定下来:
import json, os os.makedirs("assets", exist_ok=True) with open("assets/charset.json", "w", encoding="utf-8") as f: json.dump({"charset": CHARSET, "seq_len": 4, "img_size": [64, 160]}, f, ensure_ascii=False, indent=2)样本命名直接用标签本身,比如A7K2_000381.png,好处是标签和文件一一对应、肉眼可检查、写 Dataset 时不用额外维护一份映射表。文件名里的流水号是为了避免重复标签冲突。
这里有个细节值得强调:生成时一定要记录每张图的难度参数。存成一个meta.csv,字段包括文件名、标签、难度档、字体、旋转角度。后面模型在某一类样本上表现差的时候,你能立刻定位是字体问题还是干扰问题,不用抓瞎。
3. 网络结构选型:整图多标签分类 vs CRNN+CTC
结构这块我不建议一上来就堆复杂模型。对 4 字符的定长任务,一个三层卷积加一个全连接头就能打到 97% 以上,上 ResNet 甚至 Transformer 是浪费。结构的复杂度应该由任务约束决定,不是由"看起来先进"决定。
3.1 定长场景:一个 CNN 加多头分类就够
下面这个结构是我在多个类似项目里反复用的基线,参数量不到 200 万,单张 64×160 的图在 CPU 上推理也就几毫秒。
import torch import torch.nn as nn class CaptchaNet(nn.Module): def __init__(self, num_classes, seq_len=4, width=32): super().__init__() def block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.Conv2d(cout, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) # 64x160 -> 32x80 -> 16x40 -> 8x20 self.features = nn.Sequential( block(1, width), block(width, width * 2), block(width * 2, width * 4) ) self.pool = nn.AdaptiveAvgPool2d(1) self.head = nn.Linear(width * 4, seq_len * num_classes) self.seq_len = seq_len self.num_classes = num_classes def forward(self, x): x = self.features(x) x = self.pool(x).flatten(1) x = self.head(x) return x.view(-1, self.seq_len, self.num_classes) # (N, 4, 31)这个设计的核心假设是:字符位置是相对固定的。因为验证码渲染时字符大致沿水平方向等间距排布,所以"第 1 个输出头负责第 1 个字符"这个强假设是成立的。反过来,如果你把它用在字符位置完全随机、甚至字符重叠的样本上,性能会明显下降——因为位置信息被打乱了。
损失函数直接按位置算交叉熵然后求平均:
criterion = nn.CrossEntropyLoss(label_smoothing=0.05) logits = model(imgs) # (N, 4, C) loss = criterion(logits.reshape(-1, model.num_classes), targets.reshape(-1)) # targets: (N, 4)label_smoothing=0.05这个小参数我建议加上。它防止模型对某个位置输出极端置信度,实测在真实图片上的泛化会稍好一点,代价是训练集准确率看着没那么漂亮。
我在早期版本里试过给每个位置单独一个 loss 权重,想让模型更关注难位置。结论是收益很小,反而增加了调参负担,后面就去掉了。位置之间的难度差异,靠数据均衡(保证每个位置每个字符出现次数接近)来解决更直接。
3.2 变长场景:CRNN + CTC 的适用边界与代价
长度不固定时,CRNN 是主流解法。CNN 抽特征,把高度方向压成 1,得到一个(N, T, C)的序列,BiLSTM 建模上下文,最后线性层输出每个时间步的字符分布,用 CTC 损失做对齐。
class CRNN(nn.Module): def __init__(self, num_classes, hidden=128, width=32): super().__init__() # 这里 blank 占 0,字符从 1 开始 self.cnn = nn.Sequential( nn.Conv2d(1, width, 3, padding=1), nn.BatchNorm2d(width), nn.ReLU(True), nn.MaxPool2d((2, 2)), nn.Conv2d(width, width * 2, 3, padding=1), nn.BatchNorm2d(width * 2), nn.ReLU(True), nn.MaxPool2d((2, 2)), nn.Conv2d(width * 2, width * 4, 3, padding=1), nn.BatchNorm2d(width * 4), nn.ReLU(True), nn.MaxPool2d((2, 1)), # 高度压到 1/8,宽度只压 1/4 ) self.rnn = nn.LSTM(width * 4, hidden, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden * 2, num_classes) def forward(self, x): f = self.cnn(x) # (N, C, H', W') f = f.mean(dim=2) # 高度取均值 -> (N, C, W') f = f.permute(0, 2, 1) # (N, T, C) out, _ = self.rnn(f) return self.fc(out) # (N, T, num_classes)训练时要注意三件事,全都是 CTC 特有的坑:
第一,CTCLoss默认blank=0,所以字符索引要从 1 开始,字符表前面要留一个空位。这个偏移忘了,模型会稳定收敛到一个错误结果,而且损失看着还在降。
第二,input_lengths必须传每个样本的有效时间步,target_lengths传每个样本的真实字符数。如果你做 padding 到固定尺寸,实际 T 是固定的,但target_lengths每个样本不同,必须逐样本传,不能图省事传个标量。
第三,zero_infinity=True一定要开。当某个样本的 T 小于 target 长度时(理论上不该发生,但 padding 和池化算错时就会),CTC 损失会返回 inf,一次 inf 就能把整个训练搞崩。
CTC 的代价是什么?收敛慢,通常要比分类路线多 50% 到 100% 的 epoch;解码要额外处理,容易在重复字符上出错。所以长度真的固定,就别用它。
3.3 输入尺寸与感受野的取舍
输入尺寸的选择上,我踩过一次坑。最初我用 32×100 的小尺寸,训练速度飞快,训练集准确率也高,但真实图片上一直上不去。后来把尺寸加到 64×160,同一个模型结构,整串准确率直接涨了 6 个百分点。
原因很直白:字符在低分辨率下笔画粘连,尤其像 M、W、K 这类结构复杂的字符,缩小到 32 像素高之后基本糊成一团。32×100 的输入,单个字符大概只有 15×20 的像素区域,笔画宽度不到 1 像素,模型能拿到的信息太少。
经验值供参考:字符高度至少 32 像素(推荐 40 到 48),单字符宽度至少 20 像素,整个输入高度取字符高度 × 1.2。我做 4 字符 160 宽的图,输入就是 64×160。
另一个取舍是下采样次数。三次 2×2 池化把 64×160 压到 8×20,通道数到 128,这个配置下每个位置的特征感受野刚好覆盖 1 到 2 个字符宽度,是合适的。如果只做两次池化,特征图太大,全连接层参数暴涨;做四次池化,特征图只剩 4×10,4 个字符的信息挤在 40 个位置里,反而容易丢。
4. 训练配置:学习率、损失与指标口径
配置这块,我想强调的重点不是"用什么超参数",而是指标口径必须一开始就定对。我在这一块栽的跟头比在模型结构上多得多。
4.1 损失函数和优化器的具体参数
我目前在用的这套配置,在 5 万到 10 万样本的量级上比较稳:
import torch from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CaptchaNet(num_classes=len(CHARSET), seq_len=4).to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.05) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=1e-3, total_steps=epochs * len(train_loader), pct_start=0.15, anneal_strategy="cos" ) scaler = torch.amp.GradScaler("cuda")几个参数的来历。AdamW而不是Adam,是因为带 BatchNorm 的卷积网络对 L2 正则的处理方式敏感,AdamW 的解耦权重衰减在这里更干净。lr=3e-4起步,用 OneCycle 拉到1e-3再退火,这个组合让我基本不用手调学习率,训 25 到 30 个 epoch 就能到底。weight_decay=1e-4是很轻的正则,主要防过拟合,不要给大,给到 1e-2 会明显拖慢收敛。
混合精度训练建议开,显存占用能降四成左右,batch 可以开到 256 甚至 512,训练速度提升接近一倍。写法上注意 GradScaler 的 scale/unscale 配对,别漏了scaler.update()。
batch size 我建议尽量开大(128 到 512)。验证码识别这个任务里,BatchNorm 的统计量质量对结果影响不小,batch 太小统计量抖动厉害,训练曲线会很毛躁。如果显存不够,用梯度累积凑等效 batch 也行。
训练主循环:
for epoch in range(epochs): model.train() for imgs, targets in train_loader: imgs, targets = imgs.to(device), targets.to(device) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda"): logits = model(imgs) loss = criterion(logits.reshape(-1, model.num_classes), targets.reshape(-1)) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update() scheduler.step()梯度裁剪给 5.0。这个任务里梯度爆炸不算常见,但在难度切换的那个 epoch(简单样本切到困难样本),损失偶尔会尖峰一下,裁剪能兜住。
4.2 只看整串准确率,别看单字符准确率
这是我最想强调的一条。单字符准确率在这个任务里几乎没有参考价值,甚至会误导你。
举个我自己的例子。早期一个模型,单字符准确率 99.6%,我看着挺满意,结果整串准确率只有 71%。算一下就明白了:0.996⁴ ≈ 0.984,那是理论值;实际上错误集中在少数几个字符和少数几个位置上,而且错误不是独立事件——一个字符糊了,往往整张图都比较糊,相邻位置也容易错。所以真实整串准确率远低于理论上限。
更糟的是,这个指标会让你误判优化方向。单字符准确率 99.6% 时你觉得"就差一点了",但实际上那 0.4% 的错误集中在 S/5、B/8 这几个字符对上,你就是把模型训到天荒地老也救不回来,该做的是改字符表。
所以评估函数直接这么写:
@torch.no_grad() def evaluate(model, loader, device): model.eval() total, exact, char_total, char_correct = 0, 0, 0, 0 for imgs, targets in loader: imgs, targets = imgs.to(device), targets.to(device) pred = model(imgs).argmax(dim=-1) # (N, 4) exact += (pred == targets).all(dim=1).sum().item() char_correct += (pred == targets).sum().item() char_total += targets.numel() total += targets.size(0) return exact / total, char_correct / char_total两个数都算,但以整串准确率为唯一决策依据。单字符准确率只用来判断"是模型不行还是字符表不行"。
还有一个更细的观测维度值得加:逐位置准确率和混淆矩阵。逐位置准确率能告诉你第几个字符最难认(通常是第一个和最后一个,因为边缘容易缺笔画);混淆矩阵能直接暴露哪几对字符在互串。我一般每 5 个 epoch 打一次混淆矩阵,看过几次之后对模型的问题就心里有数了。
4.3 训练曲线的三种典型异常形态
训练过程中观察 loss 和整串准确率的变化,有几种典型形态需要能识别出来:
| 形态 | 表现 | 根因 | 处理 |
|---|---|---|---|
| 高原型 | loss 快速降到 0.3 左右就不动了 | 字符表里有无法区分的字符对 | 查混淆矩阵,删字符或加分辨率 |
| 抖动型 | loss 上下大幅震荡,准确率不涨 | 学习率过大或 batch 过小 | 降 lr 到 1e-4,加 batch |
| 阶梯型 | 准确率在某个值附近反复,切换难度后掉一截再恢复 | 难度课程切换太猛 | 拉长过渡区间 |
| 过拟合型 | 训练集 99%,验证集 78% 不涨 | 训练集与验证集分布不一致 | 检查验证集是否来自真实数据 |
第三种"阶梯型"其实是正常的,只要掉下去之后能恢复并创新高就没问题。真正要警惕的是掉下去之后再也上不来——那说明模型容量已经被前面的简单样本固化住了,需要回退难度或者提高初始学习率。
5. 从训练集到真实图片:分布漂移怎么补
前面铺垫了这么多,真正决定这个模型能不能用的是这一节。渲染数据训得好,不代表真实图片能认。
5.1 真实样本和渲染样本的差距在哪
我做过一次对比,把同一个模型分别在一批真实图片和一批渲染图片上测试,整串准确率差了 15 个百分点。把错例一张张翻出来看,差距集中在四个地方:
第一是压缩噪声。真实图片经过 JPEG 压缩后,字符边缘会出现块状伪影,尤其在笔画密集的地方。Pillow 渲染出来的图是干净的,模型没见过这种伪影。解决办法很简单,生成时随机做一次低质量 JPEG 重编码:img.save(buf, "JPEG", quality=random.randint(30, 75)),然后再读回来。这一步成本极低,收益明显。
第二是抗锯齿方式不同。不同渲染引擎抗锯齿算法不一样,笔画边缘的灰度过渡差别很大。多字体、多字号、加上轻微的高斯模糊,基本能覆盖。
第三是背景不干净。真实图片可能是截屏,带着浅灰底色、边框、或者上一层的残影。渲染时给背景加一点轻微渐变色和随机边框能模拟一部分。
第四是字符间距和基线。真实的验证码生成器往往对字符做了更大幅度的基线抖动,甚至上下错位到几乎不在同一行。渲染器里把y方向的随机范围从 ±6 加大到 ±12 试试。
提示:不用追求完全对齐分布。目标不是让渲染数据以假乱真,而是让模型在渲染数据上学到足够通用的字符表征,剩下的差距靠少量真实数据微调来补。
5.2 小样本微调的具体做法
微调的关键是别把预训练学到的东西冲掉。我用的流程是这样:
先冻结卷积骨干,只训练最后的分类头,用真实数据跑 3 到 5 个 epoch,学习率给1e-3。这一步是让分类头适配新的分布,不动底层特征。
然后解冻全部参数,用很小的学习率(1e-5到3e-5)整体微调 10 个 epoch 左右,用余弦退火。学习率给大了会把预训练特征直接冲散,实测超过1e-4就明显能看出验证集准确率先涨后掉。
# 阶段一:只训头 for p in model.features.parameters(): p.requires_grad = False opt1 = torch.optim.AdamW(model.head.parameters(), lr=1e-3) # ... 训练 3-5 epoch ... # 阶段二:整体微调 for p in model.parameters(): p.requires_grad = True opt2 = torch.optim.AdamW(model.parameters(), lr=2e-5)真实数据的量,我的经验是 2000 到 5000 张就能带来非常明显的提升。如果只有几百张,建议把渲染数据和真实数据按 4:1 混在一起训练,别单独拿真实数据微调,容易过拟合到那几百张上。
还有个细节:真实数据获取成本高,标注一定要双人交叉校验。我自己标过 3000 张,事后抽检发现错标率大约 1.5%,这些错标会直接变成模型的天花板——你训得再好,也只能到 98.5%。这 1.5% 的噪声在整串准确率上会被放大到 6 个百分点左右(1 - 0.985⁴ ≈ 5.9%)。
5.3 解码与后处理里的细节坑
分类路线在推理时,有一个容易忽略的点:不要用 argmax 拿到结果就完事,要同时看置信度。当某个位置的最高置信度低于阈值(比如 0.6),把它标记成"待复核",交给人工或者二次模型处理。这个机制在真实场景里非常有用,它把整体错误率压下来的方式不是提高准确率,而是把不确定的样本摘出去。
CTC 路线的解码有两个必踩的坑。一是合并重复我之前必须先去掉 blank,顺序反了会把 "AAB" 解成 "AB";二是 blank 索引一定要和训练时一致,训练用 0 做 blank、推理用最后一类做 blank,结果会全错但不报错。
贪心解码的正确写法:
def ctc_greedy_decode(logits, charset, blank=0): idx = logits.argmax(dim=-1) # (N, T) results = [] for seq in idx.tolist(): out, prev = [], None for i in seq: if i != blank and i != prev: out.append(charset[i - 1]) # 训练时字符从 1 开始 prev = i results.append("".join(out)) return results如果发现解码结果里出现大量重复字符或者长度明显不对,八成是input_lengths传错了,或者 CNN 的宽度下采样次数和预期不符,导致 T 比实际需要的小。
6. 导出、批量推理与上线前的验收
模型训完只是半成品,能不能在推理环境跑出和验证时一样的数字,是另一个问题。
6.1 TorchScript 导出与推理端对齐
我倾向于用 TorchScript 导出,比 ONNX 少一层算子兼容的麻烦,而且能直接把预处理也包进去。
model.eval() example = torch.randn(1, 1, 64, 160).to(device) traced = torch.jit.trace(model, example) traced.save("captcha_model.pt")导出后必须做一次对齐验证:拿 200 张验证集图片,分别用原始模型和导出模型跑一遍,逐张对比输出。我遇到过一次导出后准确率掉了 3 个点,查了半天发现是AdaptiveAvgPool2d在 trace 时把输入尺寸写死了,实际推理时的尺寸和 trace 时不一致。
推理端的预处理必须和训练时逐行对齐:转灰度、Resize 到 64×160、ToTensor、Normalize 的均值和方差。这四步任何一步不一致,准确率都会掉。我的做法是把预处理写成一个单独的类,训练和推理共用同一份代码,从根上消灭这个问题。
from torchvision import transforms infer_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((64, 160)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ])Resize的插值方式也要一致。默认是双线性,如果训练时用了Image.BICUBIC,推理也要用。这种细节在 64×160 这种小尺寸上影响不小,笔画少的字符可能就因为插值方式不同糊掉了。
6.2 批量推理与预处理一致性
真实场景往往是一批图片一起处理,逐个推理浪费吞吐。批量推理时注意两点:一是按尺寸分组,避免大量 padding 浪费算力;二是把torch.no_grad()和model.eval()都用上,前者省显存,后者让 BatchNorm 用滑动统计量而不是当前 batch 统计量——这一条如果忘了,批量推理的结果会随 batch 组成而变,非常隐蔽。
CPU 推理的话,把线程数调一下有明显收益:
torch.set_num_threads(4) torch.set_num_interop_threads(1)线程数不是越多越好,超过物理核数反而会因为调度开销变慢。我在 8 核机器上实测,4 线程比 8 线程快 15% 左右。
对于吞吐要求高的场景,可以把 batch 开到 64 甚至 128,单张耗时能降到原来的三分之一。但要注意 batch 内的图片尺寸必须统一,所以预处理阶段要先按尺寸分桶。
6.3 上线前的验收清单
在把模型接进业务流程之前,我会跑一遍下面这份清单。每一条都是我被坑过之后加上去的。
| 检查项 | 具体做法 | 不通过的后果 |
|---|---|---|
| 字符表一致性 | 训练/推理读同一个 json | 输出全错位 |
| 预处理一致性 | 共用同一个 transform 对象 | 准确率掉 5-15 点 |
| 模型模式 | 确认model.eval()和no_grad | 结果随 batch 波动 |
| 空图/坏图处理 | 喂全白、全黑、1×1 图 | 直接抛异常中断批次 |
| 置信度阈值 | 低置信样本走复核 | 错误静默流入下游 |
| 端到端抽检 | 抽 500 张真实图人工核对 | 掩盖系统性偏差 |
| 性能基线 | 记录单张耗时和吞吐 | 上线后才发现扛不住 |
"空图/坏图处理"这一条值得多说一句。真实数据里一定会有损坏的文件、0 字节的图、纯色图。如果不做捕获,一张坏图就能让整个批次的推理中断,而这类问题在测试环境往往复现不出来。我的做法是在 Dataset 的__getitem__里包一层 try,读失败就返回一张均值填充的占位图,同时打日志计数。占位图的识别结果会被置信度机制拦下来,不会流到下游。
最后提一个实际经验:上线后一定要持续采样真实数据回训。模型上线时 97% 的准确率,跑三个月可能掉到 92%,因为业务侧可能悄悄改了验证码的渲染参数。我一般会每个月抽 1000 张线上图人工标注,如果发现准确率掉了 3 个点以上,就触发一次微调。这套机制比任何离线评估都可靠——毕竟真实世界不会停在你的验证集上。
写到这里,回忆一下整个过程,最耗时间的从来不是调模型结构,而是搞数据和对齐各种一致性。字符表、预处理、blank 索引、评估口径,这四个"一致性"做对了,剩下的就是等训练跑完。做错了其中任何一个,你会得到一堆看着漂亮但毫无意义的数字,然后在真实环境里被打回原形。