ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习验证码识别:从模型训练到落地的完整路线

深度学习验证码识别:从模型训练到落地的完整路线

简介:深度学习中图像分类是最基础也最核心的任务之一,它要求模型从像素级输入中提取语义特征并输出离散标签。卷积神经网络作为处理图像的主流架构,通过多层卷积与池化自动学习边缘、纹理等层次化特征,配合交叉熵损失与反向传播算法完成端到端训练。这一技术范式在自动化办公、数据采集、安全校验等场景具有广泛应用,其中验证码识别便是典型工程实践。验证码识别将整图识别拆解为单字符分类,用轻量级CNN即可高效完成,涵盖数据生成、预处理、模型训练、评估与推理全流程,是入门计算机视觉的理想项目。本文从图像分类的基本原理切入,系统讲解基于PyTorch的验证码识别模型构建与训练细节,并分享真实落地中的常见问题与进阶优化方向。

1. 深度学习验证码识别:从模型训练到落地的一条完整路线

验证码识别是计算机视觉里一个特别适合入门的项目,它把「图像分类」这件事压缩到了最小闭环:输入是一张几十像素的小图,输出是一个字符串。你不需要目标检测、不需要语义分割,只需要把图片分类做对,就能拿到结果。这份基于深度学习的字符型数字验证码识别项目,走的就是这条最小闭环路线——用 Python 搭建字符模型、训练分类器、把四到五位数字从图片里还原出来。它适合刚学完神经网络基础、想用真实任务练手的读者,也适合需要批量处理数字验证码的从业者。网上有很多号称「识别率99%」的教程,但大多数要么隐藏了关键的数据预处理,要么模型训练完根本没法用于推理。这份资源好在下游完整:数据生成、模型训练、测试评估、预测脚本全都有,照着跑就能出自己的模型。

2. 认清问题的本质:这是分类任务,不是识别任务

很多新手把验证码识别想成「用一个模型直接看图输出字符串」,这个理解会带来一连串错误决策。实际工程里要做的是拆分:先把验证码图片切成单个字符,再用图像分类模型逐一识别,最后把结果拼接成字符串。这个思路决定了后面所有的数据准备、模型设计和评估方式。

2.1 为什么把问题拆成「单字符分类」而不是「整串识别」

直接做整串识别在技术上不是不行,但工程代价完全不同。整串识别意味着要让模型自己学会字符之间的位置关系、间距变化、字符数量变化,这需要大量样本和更强的网络结构,常见做法是引入 CRNN 加 CTC 损失。而拆成单字符分类后,模型只需要学会一个任务:这张小图是哪个数字。这是经典的 10 分类问题,一个轻量级 CNN 就能做得很好。

这个项目用的是后者。先把验证码图片按字符宽度切分成单字符图,再逐个送入模型。这样的好处有三个:第一,训练样本需求量小,每个字符只需要几百到几千个样本就能收敛;第二,模型结构简单,训练速度快,CPU 也能跑;第三,排查问题时思路清晰——识别错了能立刻定位是哪一步出了问题,是切偏了还是分类错了。

2.2 输入数据长什么样:尺寸、通道、归一化

数据是一切的上游。这个项目里的验证码图片有几种典型规格:有的带彩色噪点背景,有的只有纯色字符,有的是扭曲变形字体。模型的输入必须统一固定尺寸,这是 CNN 的硬性要求。

常见做法是把单字符图片统一缩放到 28×28 或 32×32,灰度化后变成单通道。数字 28 不是随便定的,它和 MNIST 数据集一致,很多公开预训练模型的输入就是这个尺寸。如果你打算从头训练而不是迁移学习,28×28 足够用。归一化要把像素值从 0–255 缩放到 0–1 之间,这一步在 PyTorch 里通常用transforms.ToTensor()完成:

from torchvision import transforms # 训练阶段的数据增强与预处理 train_transform = transforms.Compose([ transforms.Resize((28, 28)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor(), # 像素值从 0-255 归一化到 0-1 transforms.Normalize((0.5,), (0.5,)) # 标准化到 [-1, 1],加速收敛 ])

这里要解释两个关键点。ToTensor()做的事情不只是归一化,它还会把 HWC 格式的 numpy 数组转成 CHW 格式的 Tensor,这个格式是 PyTorch 卷积层要求的。Normalize((0.5,), (0.5,))的作用是把像素分布拉到以 0 为中心、标准差为 1 的区间,这样梯度更新更稳定,尤其是使用 ReLU 激活函数时,输入落在负区间也能触发梯度流动。

2.3 样本从哪里来:人工标注成本太高,首选程序化生成

训练分类器需要大量带标签数据。对验证码识别这个场景,人工标注几百张图片再切分字符,不仅耗时,而且容易出错——标注一张不到一小时,切分时字符边界判断错了,标签和图像就错位了。这个项目采用的是程序化生成样本,用 Python 的 PIL 库模拟验证码的生成逻辑:

from PIL import Image, ImageDraw, ImageFont import random import string def generate_sample(width=120, height=40, char_count=4): # 创建空白画布,白底 img = Image.new('RGB', (width, height), (255, 255, 255)) draw = ImageDraw.Draw(img) # 随机生成 4 位数字 chars = ''.join(random.choices(string.digits, k=char_count)) # 给每个字符加随机位移和字体大小扰动 font_size = random.randint(28, 32) font = ImageFont.truetype("arial.ttf", font_size) x_offset = 5 for ch in chars: draw.text((x_offset, random.randint(2, 8)), ch, font=font, fill=(0, 0, 0)) x_offset += random.randint(22, 26) # 加干扰线 for _ in range(3): start = (random.randint(0, 20), random.randint(0, height)) end = (random.randint(80, 120), random.randint(0, height)) draw.line([start, end], fill=(random.randint(0, 150),) * 3, width=1) return img, chars

生成样本的核心逻辑是「扰动」。字符位置的随机位移、字体大小的浮动、干扰线的位置变化,这些扰动加得越多,模型泛化能力越好。但要注意扰动幅度不能超过真实验证码的范围——如果你目标验证码的字符间距是固定的,你在生成训练数据时把间距扰动做得过大,模型反而学不到正确特征。函数里random.randint(22, 26)这个间距范围是根据常见验证码字符宽度估算的,实际项目里要先手动切一两张真实验证码,量一下字符的实际宽度再定。

3. 模型选型与训练参数:小模型优先,别一上来就上 ResNet

验证码单字符识别是一个简单视觉任务,图片尺寸小、类别少、特征清晰。这种任务用大模型纯属浪费算力,而且容易过拟合——训练集上 99.9%,验证集上只有 97%,这种落差说明模型在死记训练样本而不是在学习字符的笔画特征。选模型的第一原则是:结构简单到能快速迭代,表达能力刚好够用。

3.1 一个能打的轻量级 CNN 结构

这个项目用的基础结构是「卷积 + 池化 + 全连接」的标准组合,类似 LeNet 的变体。两到三个卷积层足够提取数字笔画、边缘、交叉点这些特征。更深的网络在这个任务上不会带来质的提升,反而让训练时间翻倍、调参难度上升。

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() # 第一层:1通道输入,32个卷积核,提取低阶边缘特征 self.conv1 = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2) ) # 第二层:32→64,提取更高阶的笔画组合特征 self.conv2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2) ) # 全连接:64*7*7 → 128 → 10 self.fc = nn.Sequential( nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), # 防止小样本过拟合的关键 nn.Linear(128, num_classes) ) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = x.view(x.size(0), -1) # 展平,进入全连接层 x = self.fc(x) return x

这个结构的参数设计是经过权衡的。kernel_size=3是卷积核的黄金尺寸,既能捕捉局部特征又不会引入过多参数。padding=1保证卷积后特征图尺寸不变,这样输出尺寸的计算很简单:28×28 经过第一层池化变 14×14,再经过第二层变 7×7,最后展平成 64×7×7 = 3136 维向量。Dropout(0.5)是全连接层的保命符,训练时随机丢弃一半神经元,迫使网络不过度依赖某个特定通路。推理时 Dropout 自动关闭,不需要额外处理。

3.2 损失函数与优化器:这些细节决定训练效率

损失函数选交叉熵,这个没有悬念——多分类任务的标准选择,内部已经包含了 softmax 操作。真正需要调的是优化器和学习率。这个项目适合用 Adam,它对学习率不敏感,默认的lr=0.001在大多数情况下都能收敛。SGD 虽然最终精度可能更高,但对学习率和动量的配比很敏感,新手容易在调参上耗掉大量时间。

import torch.optim as optim model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() # 多分类交叉熵,内部执行 softmax optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) # 训练循环骨架 for epoch in range(30): running_loss = 0.0 for inputs, labels in train_loader: optimizer.zero_grad() outputs = model(inputs) # 前向传播 loss = criterion(outputs, labels) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新权重 running_loss += loss.item() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}')

weight_decay=1e-4是 L2 正则化,防止权重过大导致过拟合。batch_size建议设在 64 到 128 之间,太小会导致梯度震荡,太大则每个 epoch 的更新次数太少、收敛变慢。epochs=30不是硬指标,当训练损失降到 0.01 以下且验证准确率连续三个 epoch 不再上升时,就该停了。这里有一个很多初学者会犯的错误:盯着训练准确率看,忽略了验证准确率。如果验证准确率停滞在 95% 左右而训练准确率已经 100%,说明过拟合了,优先调整Dropout比例或增加样本扰动,而不是继续加训练轮数。

3.3 训练集和验证集怎么切:别让数据泄露毁掉你的评估

数据划分是评估模型泛化能力的地基。如果划分不合理,验证集上的表现就失真,你会在错误的方向上浪费时间。常见做法是训练集 80%、验证集 20%,同时保证每个类别的样本在验证集中都有分布。这里最容易犯的错是「数据泄露」:先生成所有样本,再做归一化时用了全量数据的均值和标准差,这会让模型在训练时偷看到验证集的信息。

from sklearn.model_selection import train_test_split # X 是所有单字符图片的像素数组,y 是对应的数字标签 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )

stratify=y这个参数很多人会忽略,但它很重要。如果某个数字在训练集中占比 30%,验证集占比 10%,模型会对这个数字过度拟合,验证结果也会虚高。按类别比例分层抽样后,每个数字在训练集和验证集中的占比一致,评估结果才可信。random_state=42固定随机种子,保证每次复现时划分一致——这不只是玄学,当你想对照不同模型结构的效果差异时,数据划分不一致会让对比失去意义。

4. 完整训练与评估流程:从零到 98% 准确率的标准化操作

前面把模型结构和关键参数讲透了,这一章把训练流程串起来。一套标准的深度学习训练流程包含:数据准备 → 模型实例化 → 训练循环 → 保存模型 → 加载评估。每一步都有需要注意的细节,尤其是模型保存格式和加载时的踩坑点。

4.1 数据组织:单字符图片的目录结构与 Dataset 实现

程序化生成的数据用什么方式组织,决定了后续读取效率。建议每个字符类别建立独立子目录,目录名就是标签——这是最直观也最不容易出错的方案。PyTorch 的ImageFolder能直接读取这种结构,省去手动写标签映射的功夫。

from torchvision import datasets from torch.utils.data import DataLoader # 假设数据目录结构为:data/train/0/xxx.png, data/train/1/xxx.png ... train_dataset = datasets.ImageFolder( root='data/train/', transform=train_transform # 使用 2.2 节定义的预处理流程 ) # 验证集只做基础预处理,不做数据增强 val_dataset = datasets.ImageFolder( root='data/val/', transform=transforms.Compose([ transforms.Resize((28, 28)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

ImageFolder会自动把子目录名映射为整数标签,你可以通过train_dataset.class_to_idx查看映射关系。注意验证集不要做数据增强,增强只用于训练集——验证集的任务是反映真实分布,加了扰动反而会低估模型真实能力。shuffle=True只对训练集生效,验证集保持顺序即可,因为验证集不需要梯度更新,顺序不影响结果。

4.2 模型保存与加载:两种格式的适用边界

训练完成后保存模型,这里有两种选择,很多项目里这两者的混淆会造成很隐性的问题。第一种只保存权重参数,第二种保存整个模型。我强烈建议用前者,原因在于迁移性和代码可维护性。

# 训练完成后,保存权重参数(推荐) torch.save(model.state_dict(), 'model.pth') # 使用时需要先实例化模型结构,再加载权重 model = SimpleCNN(num_classes=10) model.load_state_dict(torch.load('model.pth', map_location='cpu')) model.eval() # 切换到评估模式 # 如果保存了完整模型 # torch.save(model, 'model_complete.pth') # loaded_model = torch.load('model_complete.pth')

model.eval()这行代码很容易被漏掉。它做的事情是关闭 Dropout 和 BatchNorm 的训练行为。如果忘了调用,模型在推理时的输出会带有随机性——同一张图两次预测可能得到不同结果。map_location='cpu'解决的是设备不一致问题:在 GPU 上训练的模型,拿到没 GPU 的机器上加载时,不指定这个参数会直接报错。

4.3 准确率评估:别只会算整体准确率

整体准确率是个懒惰的指标。假设 10 个类别均匀分布,模型把「8」全认错,整体准确率只损失 10%,但你完全不知道问题出在哪。正确的做法是逐类别看准确率,或者直接看混淆矩阵。

import torch def evaluate(model, val_loader): model.eval() correct_per_class = [0] * 10 total_per_class = [0] * 10 with torch.no_grad(): # 推理时不需要计算梯度,省内存 for inputs, labels in val_loader: outputs = model(inputs) _, predicted = torch.max(outputs, 1) # 取最大概率对应的类别 for i in range(len(labels)): label = labels[i].item() total_per_class[label] += 1 if predicted[i].item() == label: correct_per_class[label] += 1 for i in range(10): acc = correct_per_class[i] / total_per_class[i] print(f'数字 {i} 准确率: {acc:.4f}') return sum(correct_per_class) / sum(total_per_class) # 调用评估 accuracy = evaluate(model, val_loader)

torch.no_grad()的用途是关闭自动求导机制,推理阶段不需要反向传播,省掉梯度计算能显著降低内存占用并加速推理。逐类别打印准确率后,你能立刻看出模型在哪些数字上表现差。根据我的经验,最容易混淆的是 3/8、1/7 这类笔画结构接近的数字对,如果某个类别准确率明显低于均值,先检查这个类别的训练样本数量,再考虑是否需要给这类样本加更多扰动。

4.4 训练日志记录:这部分该调整

训练过程记录是很容易被忽视的环节。不建议只在终端打印 epoch 损失然后就完事,应该把每个 epoch 的训练损失、验证准确率、学习率写入文件。没有日志,你过两天回头看模型时,完全不知道它是在什么参数下训练出来的。

import csv from datetime import datetime log_file = f'train_log_{datetime.now().strftime("%Y%m%d_%H%M")}.csv' with open(log_file, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['epoch', 'train_loss', 'val_acc', 'lr']) for epoch in range(30): # 训练代码... current_lr = optimizer.param_groups[0]['lr'] val_acc = evaluate(model, val_loader) writer.writerow([epoch + 1, avg_loss, val_acc, current_lr])

optimizer.param_groups[0]['lr']这一行能获取当前实际学习率,如果你后面加了学习率调度器,这个值会动态变化,记录下来便于复盘。我的习惯是每次实验保存独立的日志文件,模型权重文件也带上实验编号,这样若干版本模型并存时,你能通过日志对比不同超参数组合的实际表现。

5. 实战:推理脚本与四个典型踩坑记录

训练完模型后,真正要投入使用还需要写推理脚本。这个环节的坑比训练还多,因为训练时数据都是处理好的,而真实环境的验证码图片千奇百怪:背景颜色不统一、字符位置偏移、干扰线密集、甚至字符粘连。下面先给出一段完整的推理代码,再列四个我在实际操作中踩过且一定会再遇到的坑。

5.1 推理脚本:从原始图片到最终字符串

推理流程和训练时的预处理必须完全一致,这是最容易出问题的地方。如果训练时用了灰度化 + 归一化,推理时也必须做同样的操作,否则模型的输入分布不一致,准确率会大幅下降。

from PIL import Image import torch import numpy as np def predict_captcha(model, img_path, char_width_range=(22, 26)): # 读取图片并转灰度 img = Image.open(img_path).convert('L') width, height = img.size # 假设字符数为 4,先等分切割再微调 # 注意:这里的切割宽度要根据实际验证码做调整 char_count = 4 segment_width = width // char_count predictions = [] transform = transforms.Compose([ transforms.Resize((28, 28)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) model.eval() with torch.no_grad(): for i in range(char_count): # 按等分位置切割字符,左右各留 2 像素余量避免切到邻居 left = max(0, i * segment_width - 2) right = min(width, (i + 1) * segment_width + 2) char_img = img.crop((left, 0, right, height)) # 转成模型输入格式 char_tensor = transform(char_img).unsqueeze(0) # 增加 batch 维度 output = model(char_tensor) _, predicted = torch.max(output, 1) predictions.append(str(predicted.item())) return ''.join(predictions) # 使用示例 result = predict_captcha(model, 'test_captcha.png') print(f'识别结果: {result}')

unsqueeze(0)这行代码值得解释:模型期望的输入形状是(batch_size, channel, height, width),单张图经过 transform 后形状是(1, 28, 28),缺少 batch 维度,unsqueeze(0)把它变成(1, 1, 28, 28)。crop操作保留整张图的高度,只按宽度方向切割。切割位置左右各留 2 像素的余量,这是因为字符可能有轻微的左右位移,余量能避免把字符边缘切掉。如果验证码字符是歪斜的,这个简单等分切割就不够用了,需要改用投影法定位字符边界。

5.2 踩坑一:训练时用白底图片,预测时遇到灰底图片直接崩溃

现象:训练时模型准确率 98%,拿到真实验证码上一跑,准确率掉到 50% 以下。仔细看图片,真实验证码的背景是浅灰色而不是纯白色。

原因:训练数据里背景是纯白(255, 255, 255),归一化后像素值接近 0.5,模型学到了「0.5 附近是背景」这个特征。但真实图片的灰色背景像素值是(200, 200, 200),归一化后完全不同。

解决:数据生成时不要只用纯白背景,把背景像素值做成随机分布,比如random.randint(200, 255)。这样模型学到的是「亮的是背景、暗的是字符」而不是「某个具体亮度值」。从那以后我每次都强制数据生成脚本加背景扰动,这条规则救了我很多次。

5.3 踩坑二:验证码字符粘连导致切割位置偏移

现象:四个字符等分切割,前两个字符识别正确,后两个错误率极高。打印切割后的图片一看,第三个字符有一半被切到了第四块里。

原因:等分切割的前提是每个字符宽度一致,但验证码生成时字符间距往往不固定,甚至字符之间有轻微重叠。等分切割遇到紧挨的字符会直接把一个字符拦腰切断,模型看到半个「9」自然认不出来。

解决:改用垂直投影法。把图片转成 numpy 数组后,逐列统计黑色像素的数量,找到字符间的「低谷」位置作为切割点。

def segment_by_projection(img_array): # img_array: 灰度图 numpy 数组,形状 (height, width) height, width = img_array.shape # 统计每列黑色像素数量(像素值 < 128 视为字符部分) col_sum = np.sum(img_array < 128, axis=0) # 找到所有连续的非零区间,这些就是字符的左右边界 in_char = False boundaries = [] for x in range(width): if col_sum[x] > 0 and not in_char: left = x in_char = True elif col_sum[x] == 0 and in_char: right = x boundaries.append((left, right)) in_char = False return boundaries

投影法的原理很简单:字符笔画覆盖的列会有较多黑色像素,字符间隙的列几乎没有黑色像素。连续的非零区间就是一个字符的边界。这个方法对干扰线有一定鲁棒性,但如果干扰线也是深色的,会把间隙填满,这时候需要先做形态学开运算去除细线干扰,或者设置一个阈值——列像素数低于某个值就视为间隙。

5.4 踩坑三:训练样本里数字分布不均,模型对冷门数字识别差

现象:模型对 0、1、2 识别率 99%,对 8、9 只有 90%。查看训练数据后发现,生成的样本里因为随机数种子问题,8 和 9 的样本数量明显偏少。

原因:程序化生成样本时用了random.choices,短批次内数字分布不均匀是正常的,但累计几万张后仍然可能偏差几个百分点。模型在样本多的类别上过拟合,在样本少的类别上学习不足。

解决:生成数据时用np.random.choice配合replace=False保证每个数字的样本数量完全一致,或者在数据加载时给每个类别设置采样权重。WeightedRandomSampler可以做到:给样本少的类别分配更高的采样概率,让每个 epoch 模型都能均衡地看到每个数字。

5.5 踩坑四:模型训练完忘记 eval 模式,推理结果时好时坏

现象:同一张验证码图片,两次识别结果不一样,一次正确一次错误。排查了一天,最后发现是推理脚本里漏了model.eval()。

原因:即使你的模型没有 Dropout,只要用了 BatchNorm 层,训练模式下它会用当前 batch 的均值和方差做归一化。推理时 batch 可能只有 1 张图,BatchNorm 用单张图的均值方差做归一化,输出完全失真。

解决:推理脚本里固定写model.eval(),并且在模型里尽量少用 BatchNorm,改用 LayerNorm 或 GroupNorm,后两种归一化方式不受 batch 大小影响。这个问题的隐蔽性在于,验证集评估时通常也会用 DataLoader 加载数据、默认 batch 是 64,所以你在评估时不会发现问题,但到了单张推理场景就翻车。

6. 进阶:把准确率从 96% 推到 99% 的三个方向

6.1 数据增强:模拟真实环境的低成本方案

通用数据增强对验证码识别是有效的,但要注意方式。普通图像分类任务常用的随机裁剪、随机旋转在这个场景需要严格控制幅度。验证码字符如果旋转超过 15 度,人眼都难以分辨,模型更不可能学会。有效的增强是:轻微旋转 ±5 度、随机平移 1-2 像素、加入高斯噪声、模拟 JPEG 压缩噪声。这些增强的意义在于让模型对摄像头的采图噪声、压缩失真免疫。

# 增强版训练预处理 train_transform_advanced = transforms.Compose([ transforms.Resize((36, 36)), # 稍微放大,为旋转留余量 transforms.RandomRotation(degrees=5), transforms.RandomAffine(translate=(0.05, 0.05)), transforms.CenterCrop((28, 28)), # 裁剪回标准尺寸 transforms.Grayscale(num_output_channels=1), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])

先放大到 36 再做旋转和裁剪,能避免旋转产生的黑边被 Resize 回 28×28 时引入不必要的干扰。

6.2 多模型投票:用少量代码再提 1% 准确率

训练三个结构相同但随机种子不同的模型,推理时让三个模型分别预测,取多数投票结果。这个方法不需要新结构、不需要调参,纯粹利用不同初始化的模型犯错位置不同这一点做容错。实现起来也很简单,预测时把三个模型的输出概率相加取最大值,等价于一个三模型的软投票集成。晚点还可以换用 BlurPool 或 test-time augmentation,但这些方法在验证码这样的小图上提升有限,性价比不高。

6.3 困难样本挖掘:把模型逼到极限

训练完成后找出验证集中预测错误的样本,把它们单独挑出来。饭点看这批样本你会发现两类问题:一类是切割后字符确实模糊到人眼都难判断,属于数据本身问题,优化数据生成逻辑;另一类是模型把 3 认成了 8,这类样本单独拿出来加大扰动重新训练一轮,模型通常会记住这些「易混点」,下一轮准确率会有肉眼可见的提升。困难样本挖掘在视觉任务里都是涨点利器。我现在的流程是跑完一轮训练,必看错误样本,如果错误样本里没有能改进的点,这个迭代就不算完成。

每做一个验证码识别项目,我会把样本生成、模型结构、训练参数、日志记录全部留档打包。那份记录里,数据生成程序是后续所有步骤的根基——样本和真实分布偏差每大一分,后面模型做的所有努力都相当于在错误地基上盖楼。验证码识别是入门视觉任务最好的练手项目,你可以用很短的时间走完「数据 → 模型 → 训练 → 部署」的完整链路,而不是花几周时间搭环境、读文档、最后只跑通一个例子却不知道下一步该干什么。希望这份项目资源能帮你把整条链路走顺,找到自己的调试手感。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进