基于Transformer的手写文本识别:从原理到工程实践
简介基于Transformer架构的手写文本识别系统项目面向OCR与文档智能识别方向的开发者和研究人员提供一套免字符分割的端到端序列识别方案。整体采用编码器-解码器结构运用多头自注意力与二维相对位置编码配合弹性形变增强、笔画归一化、课程学习等策略可有效处理连笔与倾斜文本。压缩包共18个文件以9个Python源码文件为主体覆盖main.py、data/generator.py、evaluation.py、network/model.py、engine.py等关键模块并附Transformer_ocr.ipynb、requirements.txt、Readme与LICENSE整体约132KB目录便于按模块阅读。配套训练流水线、超参配置、推理部署接口、数据增强工具和可视化分析组件IAM英文手写库行级准确率达94.7%CASIA-HWDB中文数据集达91.2%相较LSTM-CTC架构错误率降低23.6%。已有133人学习浏览适合深入理解Transformer序列识别并快速复现实验的读者。1. 手写文本识别为什么值得换到 Transformer 这条路上手写文本识别HTR和印刷体 OCR 是两种难度。印刷体字符形状规整单字模型就够用手写字形连续、连笔、粘连按单字切分基本不可靠必须整行建模。传统做法用 CNN 提特征、双向 RNN 串上下文、CTC 对齐这套组合在短句和工整手写体上效果尚可但一遇到长句和潦草笔迹跨度过长的上下文依赖就会让识别结果“前面认出、后面串词”。Transformer 把序列里任意两个位置直接拉上注意力边能在一行六十个字的范围里同时看到头部和尾部这是它更适合做手写长文本的结构性原因。这篇文章解决的不只是“换一个模型”而是完整走通一条可落地的路径手写行图像如何准备、Transformer 如何建模、训练参数怎么设、推理阶段有哪些坑。适合的人群是已经跑通过基础 OCR、想把手写识别做实做细的开发者以及做文档电子化、课堂笔记识别、单据关键信息抽取的从业者。反直觉的结论先放在这里手写识别场景里把数据标注做对带来的收益往往比把模型从几百万参数提到上千万更大。2. 用 Transformer 做手写识别从序列建模到 attention 的选型逻辑2.1 CNNRNNCTC 路线为什么在长文本手写场景吃紧过去几年做手写识别主流方案是把行图像先过几层卷积得到序列特征再接一个双向 RNNLSTM/GRU建模字符间的依赖最后用 CTC 做帧对齐。这个组合在单词识别、短行识别上表现很好因为词内部字符依赖距离短RNN 足够覆盖。但手写整行文本长度经常在几十到上百个字符连笔和涂改让字符边界模糊双向 RNN 的问题就暴露了信息要一步步沿时间步传递相隔二十个字符的上下文基本被压缩掉RNN 在 batch 内是串行展开训练速度也上不去。CTC 本身假设输出帧在给定当前上下文下条件独立对长距离语言约束的建模能力有限。于是注意力机制直接跨距离建边成了自然备选。另一个常被忽略的问题是误差传播。双向 RNN 里如果某一帧特征被噪声干扰它会顺着时间步影响后续所有帧的隐状态在长句上经常表现为“一个字符的识别错误带动半行乱掉”。Transformer 的多头注意力虽然也存在信息混叠但每个位置的输出是全局加权和局部噪声不会沿时间方向线性传播实际调参时更容易定位是哪一帧出了问题。这一点在做源码解析和工程排障时非常重要你会更希望模型把错误显式地暴露在某个位置上而不是藏在一条长长的 LSTM 链路里。2.2 Encoder-only 与 Encoder-Decoder两条 Transformer 路线的选择Transformer 做 HTR 有两条常见路线。第一条是 Encoder-only图像切成 patch 后进编码器输出帧级概率对齐任务仍然交给 CTC相当于把 LSTM 换成了 Transformer。优点是非常容易收敛、显存占用低、训练速度快CTC 不允许预测符提前发生相当于模型只需要学习帧级分类后期还能外接 n-gram 语言模型做二次修正。我在做第一版手写识别时几乎总是先用这条路线打底因为它的 Loss 下降行为直观出了问题好排查。第二条是 Encoder-Decoder图像编码后解码器按自回归方式逐字符生成文本。优点是不再依赖 CTC 的独立假设能显式建模字符间的条件概率整句语义连贯性更好缺点是需要更多成对标注数据训练过程更长推理是逐步解码存在累计误差一旦某一位错后面会连锁反应。常见的做法是数据量在万行级别以下时先用 Encoder-only CTC 拿到基线当数据量足够、且对整句语义连贯性要求很高时再迁移到 Encoder-Decoder。不要一上来就上自回归解码器在标注样本不多时自回归模型的“幻觉”和重复问题会让你怀疑人生。2.3 注意力计算、位置编码与序列长度三个必须先确认的参数在搭模型之前有三个参数决定整个方案能不能跑起来。第一是序列长度patch 宽度取 16 像素、行图像高 48 像素时一个 300 像素宽的行会被切成 18-19 个 patch计算量还好但如果扫描后的行图像宽度达到 1200 像素序列长度就是 75注意力矩阵是序列长度的平方此时 self-attention 的显存开销才开始显现。我的经验是先把 patch 宽度固定在 12 或 16对原始行图像做宽度归一化超过 1024 像素就等比缩放不要无脑保留原始分辨率。第二是位置编码。常见选择是可学习参数和正弦编码两种。可学习参数很容易训练但训练长度和推理长度不一致时超过 max_len 的部分会直接越界报错正弦编码外推性好一些但手写笔迹对绝对位置敏感实际用下来未必更好。我常用的做法是可学习位置编码同时在训练时通过对输入宽度做随机截断或缩放让模型见过不同长度推理时把输入限制在训练见过的最大长度以内。第三是模型容量与防过拟合。手写单行图像的信息量远小于整页文档常见配置是 encoder depth 6、dim 256、head 8dropout 在小样本上至少给到 0.1样本只有几百张时甚至要 0.2 起步。3. 数据准备与标注手写行文本的裁剪、字典与增强3.1 从扫描件到行级图像投影切分的实现与边界条件Transformer 行识别模型处理的是行级图像不是整页。所以第一步是把页面切成干净的行。常见做法是水平投影法对二值化图像做水平投影统计每一行里属于笔迹的像素数量连续超过阈值的区域就是一行。这个方案对“近似水平书写、无倾斜”的数据集效果最好代码量也最少。import cv2 import numpy as np def segment_lines(image_path, min_height20, threshold_ratio0.02): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 白底深色笔迹场景若是黑底白字二值化方向要反过来 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) h, w binary.shape # 水平投影每行中属于笔迹的像素数量 horiz_proj np.sum(binary 255, axis1) threshold max(int(w * threshold_ratio), 3) in_line horiz_proj threshold lines [] start None for i, flag in enumerate(in_line): if flag and start is None: start i elif not flag and start is not None: if i - start min_height: lines.append((start, i)) start None return img, lines img, lines segment_lines(scan_page.png) for idx, (y0, y1) in enumerate(lines[:10]): line_img img[y0:y1, :] cv2.imwrite(fline_{idx}.png, line_img)说明两个参数。threshold_ratio按纸张宽度比例设置投影阈值目的是把横格线、浅色底纹过滤掉避免被当成文本行min_height过滤页眉下划线、噪点产生的细长条。这套代码处理平整的打印稿没问题但真实手写页面经常有倾斜如果倾斜超过 3 度同一行里的字会被投影切成两段。常见的做法是先做倾斜校正用cv2.minAreaRect估算文本块角度后旋转再走投影还有一种更省事的方式是直接放弃整页切分用带rotate参数的数据增强让模型硬扛轻微歪斜但倾斜超过 5 度时这条捷径会翻车。另一个边界条件是跨行长笔画。手写经常会有一条长撇从上一行末尾甩到下一行水平投影会把这些跨行像素算进相邻行导致行边界偏移。我的处理方式是切完行后用字符高度统计做二次校验如果某一行明显高于中位数 1.5 倍就对它的上下边界做腐蚀裁剪把细长的跨行笔迹截掉。3.2 标签文本的归一化与字典构建先定字符表再设计 Loss标签文本的标准化直接决定字典质量。常见的做法是统一做这几件事全角数字转半角英文统一成小写如果业务不需要区分大小写中文简体繁体按业务需求二选一去掉肉眼都看不清的标注字符。这样做的原因很直接手写识别的字符表越小每个字符的样本数量越充足模型越容易收敛。如果一个全角逗号和半角逗号分别占两个类每类样本都少一半纯属浪费模型容量。def build_vocab(texts, vocab_path): # blank0 留给 CTCpad1unk2其余字符从 3 开始编号 chars set() for t in texts: chars.update(t.strip()) chars sorted(chars) with open(vocab_path, w, encodingutf-8) as f: f.write(blank\npad\nunk\n) for ch in chars: f.write(ch \n) print(f词汇表大小{len(chars) 3})这里有个坑很多标注文本是从 Excel 或标注平台导出的字符串里会混入不可见字符、全角空格、零宽字符。t.strip()只能去掉首尾空白中间的全角空格却在字典里占一个位置。我一般在构建字典前多做一步过滤只保留[\u4e00-\u9fff a-z A-Z 0-9 常见标点]范围内的字符其余全部替换成unk。标点符号也要谨慎句号和逗号少量样本时模型分不清是笔迹还是噪声前期可以直接把低频标点从字典中删除让标注文本里只保留高频标点。3.3 数据增强与按长度分桶的采样策略手写识别的数据增强与印刷体 OCR 不同。印刷体可以放心做透视、腐蚀、锐化手写样本本身就有大量笔画粗细变化过度增强会把字形弄坏。我常用的增强组合包括小角度随机旋转±2 度、宽度缩放0.9-1.1、高斯噪声、笔画腐蚀模拟圆珠笔断墨、随机亮度和对比度扰动。注意不要用太强的遮挡或加矩形黑块手写字符的笔画信息密度高局部遮挡会直接让字符不可辨识等于给数据打错误标签。def light_augment(img): angle random.uniform(-2, 2) scale random.uniform(0.9, 1.1) M cv2.getRotationMatrix2D((img.shape[1] // 2, img.shape[0] // 2), angle, scale) img cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) img img np.random.normal(0, 2, img.shape).astype(np.float32) return img训练时还有一个重要问题batch 内样本长度差异巨大。手写数据集中有短行“你好”也有长行几十字如果直接把所有样本随意扔进一个 batchpadding 会把内存撑爆短样本的有效计算被浪费。常见的做法是按字符长度或图像宽度分桶采样把样本分到多个长度桶里每个 batch 从同一个桶内采样这样 batch 内宽度差异控制在合理范围。实现上可以维护一个桶索引训练每个 epoch 前重新打乱桶内顺序这比单纯随机采样在显存利用率和收敛速度上都有明显提升。4. 基于 Transformer 的手写识别模型实现从 Patch Embedding 到 CTC 解码4.1 数据管线与可变长 batch 处理手写行图像宽度不固定PyTorch 的数据管线必须处理变长输入。常见做法是等比缩放行图像到固定高度宽度保持可变然后在 collate 时按 batch 内最大宽度补零。高度我一般取 48太矮会丢失笔画细节太高会增加 patch 数量、拖慢训练。import torch from torch.utils.data import Dataset import cv2 import numpy as np class LineDataset(Dataset): def __init__(self, pairs, token2id, img_h48, max_w1024, augmentNone): self.pairs pairs # [(image_path, text), ...] self.token2id token2id self.img_h img_h self.max_w max_w self.augment augment def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, text self.pairs[idx] img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) h, w img.shape scale self.img_h / h new_w min(int(w * scale), self.max_w) img cv2.resize(img, (new_w, self.img_h)) img img.astype(np.float32) / 255.0 if self.augment: img self.augment(img) label [self.token2id.get(c, 2) for c in text] # 2 是 unk return img, np.array(label, dtypenp.int64), len(label) def collate_lines(batch): imgs, labels, label_lens zip(*batch) widths [im.shape[1] for im in imgs] max_w max(widths) img_tensor np.zeros((len(imgs), 1, 48, max_w), dtypenp.float32) for i, im in enumerate(imgs): img_tensor[i, 0, :, : im.shape[1]] im img_tensor torch.from_numpy(img_tensor) label_lens torch.tensor(label_lens, dtypetorch.long) targets torch.cat([torch.from_numpy(l) for l in labels]) return img_tensor, targets, label_lens, torch.tensor(widths)这段代码里最关键的是collate_lines中的 padding 策略。图像按单通道合成(B, 1, H, max_w)的 batch标签则被拉直成一个一维张量targets配合label_lens告诉 CTC 每个样本的标签长度。注意这里没有给图像生成 padding maskTransformer 编码器会把 padding 区域也当作有效像素去计算注意力严格做法是额外生成一个 mask 传给forward我会在下一节说明。对宽度超过max_w的样本直接等比缩放截断宁可损失一点边缘字符也不要让批量矩阵爆炸。4.2 Patch Embedding 与位置编码把二维图像压成一维序列Transformer 吃的是序列所以要把图像的宽度方向切成长度相等的 patch。常见做法有两种一种是用 Conv2d 做带步长的 patch 投影另一种是直接用 Linear 把每个竖条压平。我倾向于先用 Linear 版本做基线因为代码直观数据量上去以后再换 Conv2d 或再加两层卷积 stem收敛速度会更快但对超参数更敏感。import torch.nn as nn import torch.nn.functional as F import math class PatchEmbed(nn.Module): def __init__(self, img_h48, patch_w16, dim256): super().__init__() self.patch_w patch_w self.img_h img_h self.proj nn.Linear(img_h * patch_w, dim) def forward(self, x): # x: (B, 1, H, W) B, C, H, W x.shape if W % self.patch_w ! 0: x F.pad(x, (0, self.patch_w - W % self.patch_w)) W x.shape[-1] num_patches W // self.patch_w # 按宽度方向切成 patch每个 patch 是高维向量 x x.reshape(B, 1, H, num_patches, self.patch_w) x x.permute(0, 3, 1, 2, 4).reshape(B, num_patches, H * self.patch_w) return self.proj(x)patch_w直接决定序列长度一个 512 像素宽的行patch_w16 时序列长度为 32patch_w8 时变成 64注意力计算量翻四倍。对中文手写体patch_w16 通常够用英文手写词更紧凑patch_w 可以降到 12。位置编码用可学习版本max_len取训练数据 95 分位宽度对应的 patch 数再加一点余量。class PositionalEncoding(nn.Module): def __init__(self, max_len128, dim256, dropout0.1): super().__init__() self.pe nn.Parameter(torch.zeros(1, max_len, dim)) nn.init.trunc_normal_(self.pe, std0.02) self.dropout nn.Dropout(dropout) def forward(self, x): return self.dropout(x self.pe[:, : x.size(1), :])可学习位置编码的初始化很重要全零初始化会让训练初期梯度平稳直接随机初始化反而容易让模型在早期依赖位置信息而不是字符内容。trunc_normal_的标准差取 0.02这是视觉 Transformer 的常见经验值。推理时如果输入宽度超过max_lenself.pe[:, : x.size(1)]不会报错但会少一段位置编码实际效果很差所以训练阶段一定要让训练数据的宽度分布包含推理时可能出现的宽度范围。4.3 Transformer 编码器与 CTC 输出层模型主体实现模型主体就是把 Patch Embedding、位置编码、标准 Transformer Encoder 和输出层串起来。输出层是nn.Linear(dim, num_classes)每个 patch 位置输出一个字符概率分布最后用 CTC Loss 对齐到文本。class HTRTransformer(nn.Module): def __init__(self, img_h48, patch_w16, dim256, depth6, heads8, num_classes100, dropout0.1, max_len128): super().__init__() self.patch_embed PatchEmbed(img_h, patch_w, dim) self.pos_embed PositionalEncoding(max_len, dim, dropout) encoder_layer nn.TransformerEncoderLayer( d_modeldim, nheadheads, dim_feedforwarddim * 4, dropoutdropout, activationgelu, batch_firstTrue) self.encoder nn.TransformerEncoder(encoder_layer, num_layersdepth) self.head nn.Linear(dim, num_classes) def forward(self, x, src_key_padding_maskNone): x self.patch_embed(x) x self.pos_embed(x) x self.encoder(x, src_key_padding_masksrc_key_padding_mask) return self.head(x)参数方面dim_feedforwarddim*4是 Transformer 的常见选择改成 dim*2 能省不少显存、但表征能力会下降。activationgelu比 relu 收敛更平滑已经是视觉任务的默认选择。batch_firstTrue让代码维度更好读PyTorch 新版里这是推荐写法。depth我建议从 6 起步不要一开始就用 12 层手写识别是一行图像上的任务输入信息量远小于整页文档层数堆上去收益不大反而更容易在小数据集上过拟合。CTC 预测的目标是帧级概率所以输出维度num_classes必须和字典大小一致且blank位置必须是 0。这个细节在判断错误时最容易踩如果字典构建时没有把blank放在索引 0CTC Loss 的blank0会直接报错或静默对齐到错误字符。4.4 训练配置学习率、warmup、dropout 与收敛判断CTC 训练手写识别的收敛行为与普通分类任务不太一样。它要同时学习“每帧是什么字符”和“帧如何对齐到字符”两个目标所以学习率过高时模型会走捷径把所有帧都预测成 blank导致 Loss 表面下降、实际全空。常见做法是用 AdamW warmup 余弦退火峰值学习率控制在 5e-4 到 1e-3 之间batch size 小的时候偏向 5e-4。import torch model HTRTransformer(num_classeslen(vocab), max_len128) optimizer torch.optim.AdamW(model.parameters(), lr5e-4, weight_decay1e-4) criterion torch.nn.CTCLoss(blank0, zero_infinityTrue) warmup_steps 500 total_steps len(train_loader) * epochs def lr_lambda(step): if step warmup_steps: return step / warmup_steps progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1 math.cos(math.pi * progress)) scheduler torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) for batch in train_loader: imgs, targets, target_lens, widths batch logits model(imgs) # (B, T, C) logits logits.transpose(0, 1).log_softmax(2) # CTC 要求 (T, B, C) input_lens torch.full(size(imgs.size(0),), fill_valuelogits.size(0), dtypetorch.long) loss criterion(logits, targets, input_lens, target_lens) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step()代码里input_lens直接填了logits.size(0)即 batch 内统一的最大帧数。这是常见做法的简化版虽然 padding 区域的帧也会参与 CTC 计算但由于它们对应的 logits 是 padding 像素产生的对梯度的影响有限。更严谨的做法是传入widths // patch_w作为每个样本的真实帧数并同步构造src_key_padding_mask。clip_grad_norm_建议给到 5.0Transformer 在训练初期偶尔会出现梯度过大的尖峰不 clip 的话 Loss 会突然跳到 NaN。收敛判断标准有两个一是训练 Loss 降到初始值的一半以下二是采样几个 batch 做贪心解码看输出是否出现连续非空字符。只看 Loss 不看解码结果是很容易被欺骗的因为 CTC Loss 下降可以完全由 blank 概率上升驱动。5. 手写识别从训练到推理的常见坑现象、原因与排查办法5.1 训练 Loss 在降预测却全是空白和重复字符现象训练过程中 Loss 从几十降到几看起来正常但把验证集图像送进去贪心解码输出只有blank或者同一个字符重复十几次。这个现象在 Transformer CTC 上非常典型几乎每个从 CNN 分类转过来的人都会遇到一次。原因是 CTC 对 blank 列有天然偏好模型发现把所有帧都预测为 blank 可以直接把对齐损失降到最低当模型容量大、训练数据少时这种“偷懒解”比真正学字符更容易被梯度下降找到。另一个隐藏原因是字典里字符类别不平衡某些字符出现几百次另一些只出现几次损失会被高频字符主导。解决办法是先给模型加 dropout 和更保守的峰值学习率降到 3e-4同时按字符频率做标签重加权如果不行把blank概率单独截断观察打印每个样本帧级输出的 argmax 分布确认是不是 blank 占了绝大多数。还有一个实用技巧训练前几百步用贪心解码跑一个最小验证集单独打印输出字符串别只看 Loss。5.2 验证集字符错误率低但整行文本读起来错乱现象用字符错误率 CER 评估指标已经降到 10% 以内但随机挑几张图看发现识别出的字符和原文本完全对不上甚至出现“第一句位置识别出了第二句的字”。原因是手写文本里字符没有固定边界CTC 对齐是自由的模型不需要精准定位字符只要整帧序列的重叠匹配度高就能拿到低 CER尤其当行切分不准确时输入图像里混入了上一行的残缺笔迹模型不知道哪部分是有效内容就会用一个比较低的概率分散在正确和错误的内容上累积下来 CER 不高但视觉上明显错位。排查时先看测试图像的行边界是不是干净用可视化工具把切分结果贴到原页上检查再按行长度把验证集分桶分别看长行和短行的 CER通常问题集中在长行上。解决办法有两个方向一是改进切行确保一行图像里只有一行文本二是在解码后按标点符号位置做分词对拍把 CER 的统计粒度从字符级提到词组级更容易暴露错位。5.3 中文数字和英文数字混写时错误率居高不下现象一个手写票据里既有“2024年”又有中文大写“二〇二四”模型要么把阿拉伯数字识别成中文数字要么混用。原因很直接数字字符在整本数据里占比不高尤其是“〇”和“零”、“1”和“l”、“0”和“o”这些字形近似字符在分辨率有限时几乎是不可分的。而且如果在字典构建时把“〇”和“零”都保留两者样本都少模型学不到区分度。解决方法是业务优先如果下游系统只要阿拉伯数字就不要在字典里放“〇、零、壹、贰”把标注文本全部归一化为阿拉伯数字如果两个都要就按字符出现频次做过采样或者单独为数字类样本加粗笔画增强。另一个常见做法是把字形近似的字符合并成一个类识别后由规则层做转换比如“l”和“1”合并再由上下文规则修正。5.4 长句子推理时提前截断或疯狂重复现象短行10 字符以内识别挺好长行超过 30 字符后Encoder-only CTC 的输出会出现尾部大量 blankDecoder 路线的输出则出现“我我我我”或“今天今天今天”式重复直到撞上最大长度限制。原因分两种对 Encoder-only 来说训练样本里长句占比少模型对长序列的帧到字符映射没见过足够多样的情况尾部帧大概率被预测成 blank对 Decoder 路线来说自回归解码时一旦某一步预测偏了后继输入被带偏进入重复循环。解决方法是先增加长句样本把标注文本按字符长度分成多个桶让长句桶的采样权重不低于短句桶然后在解码阶段加重复惩罚对已经解码出的 token 对应的概率乘一个衰减系数。CTC 路线还可以在推理时限制输出长度范围用输入宽度和平均字符宽度粗估字符数过滤掉过短或过长的解码路径。5.5 GPU 训练 OOMpadding 与 batch 分配策略现象torch.OutOfMemoryError在训练前几个 batch 就出现或者训练半天后突然爆显存。原因通常是变长输入 padding 到 batch 内最大宽度一个 batch 里混入一条 1000 像素宽的长行其他短样本全部被垫到 1000显存按最大宽度分配短样本的计算白白浪费。解决办法是分桶采样每个 batch 内宽度差异控制在 1.5 倍以内如果还是 OOM把max_w从 1024 降到 768损失一点长行精度换取整体训练可行。另外检查一下是否把 padding mask 传给了 Transformer如果没传padding 区域的 patch 也会参与 attention 矩阵计算白白占用显存传了 mask 后这部分计算被跳过显存占用能明显下降。平时调试用torch.cuda.max_memory_allocated()记录峰值别再靠感觉调 batch size。6. 让模型真正可交付beam search 解码与注意力可视化验证6.1 用 beam search 替代贪心解码参数与实现要点CTC 训练出的模型推理阶段用贪心解码通常只达到 90% 的效果。简单升级是 beam search在每个时间步保留 top-k 条候选路径最后按得分选最优。写一个最简版本并不复杂import math def ctc_beam_search(log_probs, blank0, beam_width8): # log_probs: (T, C) 对数概率T 为帧数 beam [([], 0.0)] # 每条候选是 (token序列, score) for t in range(log_probs.shape[0]): new_beam [] for tokens, score in beam: for idx, lp in enumerate(log_probs[t]): new_tokens tokens[:] # 简单去重非 blank 且与上一帧相同合并 if not (idx blank or (tokens and idx tokens[-1])): new_tokens tokens [idx] new_beam.append((new_tokens, score lp.item())) # 按得分截断到 top-k new_beam sorted(new_beam, keylambda x: x[1], reverseTrue)[:beam_width] beam new_beam return beam[0]这个版本为了可读性省略了“路径合并”和“长度归一化”但足以说明 beam 的作用。beam_width取 8 在大多数手写场景是性价比最高的选择5 以下增益不足16 以上速度明显变慢但准确率提升不大。推理时还要加一个简单约束输出序列的字符数不能超过输入图像宽度除以最小字符宽度否则路径一定不合法。6.2 注意力热力图快速定位模型在做“真识别”还是“抄模板”训练完成后我习惯性的先验证一步把 encoder 最后几层的 attention map 拿出来做平均和识别出的字符按位置对齐生成一张热度图。如果热度图显示某个字符的注意力集中在它自己对应的图像区域上说明模型在认真看字形如果注意力散落在整行各处说明模型可能在靠上下文硬猜这种样本在真实业务里最容易翻车。实现时在forward里返回 one layer 的 attention用torch.mean跨 head 平均再用matplotlib画到原图上。这个习惯帮我抓出过不少训练数据标注错误的 case注意力越集中越能确信数据没问题注意力越散先去查标注文本和图像是否真的匹配。最后留一个教训我最初追求模型结构复杂度直接上了自回归解码器结果小数据集上反复出现重复生成和早期分词错误后来先用 Encoder-only CTC 跑通基线和数据校验流程再逐步升级整个调试周期缩短了将近一半。现在我的习惯永远是“先让最简单的 Transformer 结构在数据上通过注意力验证再去谈更大容量的模型”。手写识别的难点从来不在模型而在数据边界和工程排障的细致程度希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Beav浏览器AI控制原理剖析:MCP、Native Host与Chrome扩展如何串联成Agent操控链路

Beav浏览器AI控制原理剖析:MCP、Native Host与Chrome扩展如何串联成Agent操控链路

人工智能AI 应用AI 写作媒体生成工作流自动化网页爬虫浏览器控制 【免费下载链接】Beav 小红书 AI 运营工作台|小红书采集、评论区下载、素材库、选题、AI写作、小红书全域解决方案,开箱即用,一键安装,小红书AI工作台,…

2026/10/11 22:54:38 阅读更多 →
如何用emulate在本地完整测试Webhook:GitHub App签名、Slack事件与Stripe验签全覆盖

如何用emulate在本地完整测试Webhook:GitHub App签名、Slack事件与Stripe验签全覆盖

【免费下载链接】emulate Local API emulation for CI and no-network sandboxes 项目地址: https://gitcode.com/gh_mirrors/emul/emulate 点击查看 免费下载 emulate 是一个运行在本地的 API 模拟服务(API emulation),专为 CI …

2026/10/11 22:53:37 阅读更多 →
VGA2USB驱动安装与UVC协议桥接实战指南

VGA2USB驱动安装与UVC协议桥接实战指南

简介:本资源为VGA2USB视频采集设备专用驱动程序及配套开发套件,面向嵌入式开发者、音视频采集系统集成工程师及多媒体应用开发者,解决传统VGA模拟信号无法直连现代USB接口计算机的硬件兼容性问题。压缩包共507个文件,46.66MB&…

2026/10/11 22:53:37 阅读更多 →

最新新闻

同城家政服务平台搭建,多商户派单方案详解

同城家政服务平台搭建,多商户派单方案详解

同城家政服务平台搭建:多商户入驻与智能派单方案详解同城家政行业早已从单一门店自营模式,转向多商户平台化联营发展。平台整合全城多家家政公司、个体服务商、持证服务师傅,统一承接用户订单,通过智能调度完成订单分发与履约。相…

2026/10/11 23:39:46 阅读更多 →
PDF加密权限解除实战:用qpdf免费命令行一键解锁

PDF加密权限解除实战:用qpdf免费命令行一键解锁

上周同事甩过来一个PDF,说打印店打不了,让我帮忙看看。我一看,文件本身没坏,是加了权限限制——允许查看,但打印和复制都被锁了。这种问题我一年能遇到几十次:文档在手机上看一点毛病没有,真要用…

2026/10/11 23:39:46 阅读更多 →
大数据缓存实战:Redis与Alluxio定位配置与踩坑

大数据缓存实战:Redis与Alluxio定位配置与踩坑

干大数据这行的人,迟早会被一个词拦住:慢。任务跑得慢、查询出得慢、报表刷得慢,追根问底,大多不是因为计算引擎不给力,而是存储访问拖了后腿。我在几个大数据平台的项目里折腾过缓存方案,常用的两样是Redi…

2026/10/11 23:39:46 阅读更多 →
基于蝴蝶优化算法的IEEE30节点无功优化Matlab实现与参数调优

基于蝴蝶优化算法的IEEE30节点无功优化Matlab实现与参数调优

1. 从"网损"到算法:先搞懂无功优化到底在优化什么说到电力系统优化调度,"有功优化"大家都很熟——机组出多少钱、发多少有功,直接影响运行成本。但大部分人第一次接触"无功优化"时都会有一个疑问:无…

2026/10/11 23:39:46 阅读更多 →
四月修复版H5农场养殖鸡蛋理财鸡源码部署与支付对接避坑指南

四月修复版H5农场养殖鸡蛋理财鸡源码部署与支付对接避坑指南

简介:最新修复版H5农场牧场养殖理财鸡游戏运营源码,定位为可直接运营的网站游戏项目,适合有建站基础、希望搭建休闲理财类H5游戏的个人或团队二次开发。资源包共2271个文件,约88.4MB,主体由HTML页面、JavaScript逻辑、…

2026/10/11 23:39:46 阅读更多 →
改进版Q-learning实战:Double Q、n步回报与经验回放

改进版Q-learning实战:Double Q、n步回报与经验回放

简介:基于Q-learning的改进版强化学习算法项目,聚焦路径规划场景,面向MATLAB用户及强化学习入门者。项目针对经典Q-learning收敛慢的问题,融合学习率衰减、动态ε-greedy探索、经验回放、目标网络与双线性更新等改进策略&#xff…

2026/10/11 23:38:45 阅读更多 →

日新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 10:45:37 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:53 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:54 阅读更多 →