简介:基于Python实现的字母数字识别,是一份面向课程设计与深度学习初学者的实战型代码包。项目借助TensorFlow 2框架搭建残差网络,在手写英文字母与数字标准数据集上进行训练与识别,运行环境为Windows 10、Python 3.7与TF 2.1,学习者可按说明直接还原实验流程。压缩包内有五十个文件,涵盖模型构建与训练测试的Python源码、训练完成的模型权重与检查点文件、训练日志与可视化图表、项目说明文档及开源许可协议,全部内容压缩后约104.79MB,目录组织清晰,便于按模块查找。目前已有二百八十人学习下载,具有一定参考热度。通过该代码包,读者能系统了解数据加载、网络设计、训练调参与模型导出的完整链路,既可作为神经网络入门实践,也能为课程设计或毕业设计提供基础。
1. 手写字母数字识别:为什么我建议从 EMNIST + ResNet 入手
我刚开始做手写字母数字识别时,直接在 EMNIST 数据集上跑 LeNet,准确率停在 92% 就上不去了,字母和数字互相混淆的情况特别多。后来换了这个基于 Python 实现的字母数字识别项目,它用 TensorFlow 2.1 重写了一版 ResNet,EMNIST 上的表现一下子拉到 97% 以上。这个资源不只是一个模型,而是一整套可运行的代码:训练脚本、测试脚本、推理 demo,还带着已经训练好的 checkpoint,装好 python3.7 和 tf2.1 就能直接复现。适合刚学完 TensorFlow 基础、正在找课程设计题目,或者想看看 ResNet 在这种 28x28 小图分类上怎么落地的读者。下面我会把环境搭建、代码结构、训练参数和踩过的坑一条条拆开讲。
2. 先把项目跑起来:tf2.1 环境、model.py 和三个入口脚本
一份完整的识别工程,拿到手第一件事不是看网络结构,而是先把环境装好、把入口脚本跟 README 对应起来。这个项目里我数了一下,核心代码集中在mytrain.py、test.py、demo.py,模型定义在model.py,训练好的权重放在checkpoints/下面。这章先把环境搞干净,再拆模型定义和三个脚本的调用关系。
2.1 环境准备:Python 3.7 + TensorFlow 2.1 的安装顺序
项目 README 里写得很明确:Windows 10 + TensorFlow 2.1 + Python 3.7。这个组合在现在看确实有点老,但模型代码是基于tf.keras写的,换到 TF 2.10+ 很可能会遇到行为变化,所以第一遍复现建议老老实实按这个版本来。我一般会用 Anaconda 先建一个独立环境,避免把其他项目的依赖搞乱。如果你还没装 Anaconda,先去官网下 installer,再照着官方 python 安装教程把 3.7 装进去。下面三条命令是最基础的:
conda create -n alnum python=3.7 conda activate alnum pip install tensorflow-cpu==2.1.0 pillow numpy逻辑说明:第一行创建名为alnum的 Python 3.7 环境;第二行激活它;第三行安装 TensorFlow 2.1 的 CPU 版,以及图像处理所需的 Pillow 和数值计算所需的 NumPy。这里特意写tensorflow-cpu,而不是tensorflow,是因为 TF 2.1 的默认安装包在 Windows 上会带 GPU 支持,import 时一旦探测不到 CUDA 就会直接崩溃。这个项目训练 EMNIST 这种 28x28 小图,CPU 完全跑得动,没必要让驱动背锅。
参数说明:python=3.7是 conda 的版本参数,TF 2.1 对 Python 3.7 支持最完整;tensorflow-cpu==2.1.0严格锁版本,避免 pip 自动拉到新的大版本。如果网络不好,可以在pip install后面加-i https://pypi.tuna.tsinghua.edu.cn/simple换成国内镜像,TF 的包体积比较大,建议耐心等。
装完后,用下面这段确认环境是好的:
python -c "import tensorflow as tf; print(tf.__version__)"如果输出2.1.0,说明环境没问题。如果 import 阶段崩了,大概率是 CUDA 动态库缺失,这一条在第 4 章的避坑部分会专门说。不想折腾驱动就保持 CPU 版;愿意折腾的话,可以用conda install cudatoolkit=10.1配一个干净的 CUDA 层,但属于锦上添花。
另外,如果你平时用 vscode 写代码,建完环境以后记得在 vscode 里按Ctrl+Shift+P调出Python: Select Interpreter,把解释器指向alnum环境。这一步不做的话,即使终端里激活了环境,编辑器里运行的脚本还是可能用错解释器。
2.2 model.py 里的 ResNet:残差块在 tf2 中怎么写
这个项目最核心的部分是model.py,它实现了 ResNet 的一个精简版本。之所以用 ResNet 而不是 VGG,是因为 EMNIST 的字符类别数多,数字 10 类加上字母后常见配置是 47 类或 62 类。网络一旦加深就会出现退化问题:训练集 loss 降不下去,特征图越深越难优化。残差结构通过一条 skip connection 把梯度直接回传,让 18 层这种深度在 28x28 的小图上也能稳定收敛。
我读代码时,把它的基础残差块整理成了下面的样子,和原始文件的核心逻辑一致,变量名做了统一:
import tensorflow as tf class BasicBlock(tf.keras.layers.Layer): def __init__(self, filters, stride=1, use_shortcut=False, **kwargs): super().__init__(**kwargs) self.conv1 = tf.keras.layers.Conv2D( filters, 3, strides=stride, padding='same', use_bias=False) self.bn1 = tf.keras.layers.BatchNormalization() self.conv2 = tf.keras.layers.Conv2D( filters, 3, strides=1, padding='same', use_bias=False) self.bn2 = tf.keras.layers.BatchNormalization() if use_shortcut: self.shortcut = tf.keras.models.Sequential([ tf.keras.layers.Conv2D( filters, 1, strides=stride, use_bias=False), tf.keras.layers.BatchNormalization() ]) else: self.shortcut = None def call(self, inputs, training=False): x = self.conv1(inputs) x = self.bn1(x, training=training) x = tf.nn.relu(x) x = self.conv2(x) x = self.bn2(x, training=training) shortcut = self.shortcut(inputs) if self.shortcut is not None else inputs return tf.nn.relu(x + shortcut)逻辑说明:BasicBlock是 ResNet18/34 的基础模块。两个 3x3 卷积中间夹 BN 和 ReLU,最后把卷积输出直接加到 shortcut 上。当stride=2或者通道数变化时,shortcut 必须用 1x1 卷积把空间尺寸和通道数对齐,否则相加时会报 shape 错误。training参数是给 BN 层用的:训练时统计批内均值方差,推理时用移动平均。
参数说明:use_bias=False是因为 BN 在卷积之后做标准化,卷积的 bias 会被抵消,留着反而增加参数。padding='same'保证 stride=1 卷积后尺寸不变;stride=2 时,空间尺寸减半,由 shortcut 里的同 stride 卷积对齐。这里的use_shortcut并非常见实现里的自动判断,而是调用方在卷积层下采样时显式传True,这样结构清晰。
有了基础块,再把它堆起来就是完整的 ResNet18。下面是简化后的组装逻辑:
class ResNet18(tf.keras.Model): def __init__(self, num_classes=47): super().__init__() self.conv1 = tf.keras.layers.Conv2D(64, 7, strides=2, padding='same') self.bn1 = tf.keras.layers.BatchNormalization() self.pool1 = tf.keras.layers.MaxPool2D(pool_size=3, strides=2, padding='same') self.gap = tf.keras.layers.GlobalAveragePooling2D() self.fc = tf.keras.layers.Dense(num_classes) def call(self, inputs, training=False): x = tf.nn.relu(self.bn1(self.conv1(inputs), training=training)) x = self.pool1(x) x = self.gap(x) return self.fc(x)这里为了展示结构,把中间四个残差堆叠略掉了,实际代码会在self.pool1后面接 4 个 BasicBlock 组,最后经过全局平均池化输出到全连接层。注意我把GlobalAveragePooling2D放到__init__里,这样每次call不会重复创建层实例,保存权重时才不会出现奇怪的名字。
参数说明:num_classes是从characters.txt读出来的类别数,默认 47 对应 EMNIST Balanced 的划分;如果你下载的是 Full 划分,要改成 62。strides=2的下采样集中在前几层,后续残差块保持空间分辨率,这是 ResNet 的标准设计。
2.3 三个脚本怎么配合:先训练,再测试,最后 demo
README.md把流程写得很清楚,但我第一次看还是犹豫该先跑哪个。合理顺序是:mytrain.py训练,test.py评估,demo.py做推理。按顺序执行下面三条命令:
python mytrain.py --epochs 80 --batch_size 128 python test.py --checkpoint checkpoints/pro1-10.ckpt python demo.py --image imgs/demo.png逻辑说明:mytrain.py会把训练过程中的权重周期性地写到checkpoints/目录,形成pro1-10.ckpt.index和pro1-10.ckpt.data-0000*这样的分片文件;test.py需要指定 checkpoint 路径,在验证集上算准确率;demo.py则读取一张手写图,输出预测的字符和置信度。
参数说明:--epochs控制训练轮数,这个项目在 80 epoch 时效果最好,压缩包里的imgs/20epochs.png和imgs/80epochs.png已经画出了对比;--batch_size默认 128,显存不足就降到 64;--checkpoint指向权重前缀,注意只给pro1-10.ckpt,不要带.index或.data-*后缀。
如果你不想训练,直接跳过第一步也能体验完整流程,因为压缩包里已经带了训练好的 checkpoint。以下是我跑 demo 时看到的效果:
Predicted: '5' (confidence: 0.9823)这里有个体验问题:EMNIST 是 28x28 灰度图,所以脚本只会把输入图 resize 成 28x28,不会帮你增强对比度。你最好喂干净的黑底白字图像,和训练集分布保持一致,不然预测置信度会明显下降。三个脚本的分工关系,用一张表概括就是:
| 脚本 | 职责 | 核心参数 |
|---|---|---|
| mytrain.py | 训练模型并生成 checkpoint | epochs, batch_size |
| test.py | 加载 checkpoint,计算验证集准确率 | checkpoint |
| demo.py | 加载权重,对单张图片做预测 | image |
这样环境、模型、脚本都清楚了,下一步进入训练细节。
3. 训练与微调:EMNIST 数据映射、参数调整与 checkpoint 恢复
训练这一步是项目最值得研究的部分。这一章从数据预处理讲到参数设置,再到 checkpoint 的恢复,按实际操作顺序展开。如果你只是想要一个能用的模型,压缩包里已经给你了;如果你想改模型继续调精度,这章是基础。
3.1 数据加载与字符映射:characters.txt 的作用
项目根目录放着characters.txt,我第一次没注意它,直到 demo.py 预测出乱码才回头看。这个文件决定了模型输出层每个索引对应的字符,顺序必须和训练时一致。内容大致是一行一个字符,数字在前,字母在后:
0 1 ... 9 A B ... Z如果你打开发现行数不是 62 而是 47,说明这个项目用的是 EMNIST Balanced 划分,它把大小写形状接近的字母合并了,比如a和A合并成一类。这种划分在实践里更合理,因为原始 EMNIST Full 里0和O本来就不容易区分,硬做 62 类只会让准确率难看。
在mytrain.py里,加载数据的同时会读这个文件:
with open('characters.txt', 'r', encoding='utf-8') as f: characters = [line.strip() for line in f if line.strip()] num_classes = len(characters) print(f'字符类别数: {num_classes}')逻辑说明:每一行去掉空白后存成一个元素,索引就是模型输出层的类别编号。训练时tf.keras.losses.SparseCategoricalCrossentropy要求标签是 0 到num_classes-1的整数,推理时拿输出索引去characters列表里取回字符。如果你发现 demo 预测结果总是对不上号,先检查这个文件是不是被改过。
参数说明:encoding='utf-8'是必须的,因为字符文件里可能有非法字符,不指定编码在 Windows 上容易抛 UnicodeDecodeError。另外,读取后过滤空行,避免末尾换行符产生一个空类别。
数据预处理方面,EMNIST 图像已经是 28x28 灰度,但像素范围是 0-255。不归一化直接喂网络,训练会非常不稳定。常见做法是除以 255:
train_images = train_images.reshape(-1, 28, 28, 1).astype('float32') / 255.0这里reshape(-1, 28, 28, 1)把二维 28x28 变成四维张量,最后一维是通道数,astype('float32')保证计算时不和 double 混在一起,/255.0把数值压到 0 和 1 之间。
如果你的训练脚本需要自己获取数据,常见做法是用np.load('emnist-balanced.npz'),Windows 下别直接拿官方 .mat 文件,那个结构要绕一层 HDF5,不值当。项目里如果已经内置了数据读取方式,那更省事。
3.2 训练参数设置:epochs、batch_size 与学习率衰减
项目里放了 20 epoch 和 80 epoch 的训练曲线图,对比很直观:20 epoch 时 loss 还在明显下降,80 epoch 才趋于平缓。所以这个项目默认推荐 80 epoch 是经过验证的。下面是一组可以直接用参数表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| epochs | 80 | 小图分类不需要训练太久,100 epoch 以上提升有限 |
| batch_size | 128 | 显存不足就降到 64,BN 层对 batch size 比较敏感 |
| 学习率 | 0.001 | Adam 默认初始值,基本不用调 |
| 衰减 | ExponentialDecay(decay_rate=0.9, decay_steps=1000) | 让 loss 后期稳定下降 |
| 优化器 | Adam | 比 SGD 收敛快,适合课程设计 |
在代码里,学习率衰减可以这样写:
lr_schedule = tf.keras.optimizers.schedules.ExponentialDecay( initial_learning_rate=0.001, decay_steps=1000, decay_rate=0.9, staircase=True) optimizer = tf.keras.optimizers.Adam(learning_rate=lr_schedule) model.compile(optimizer=optimizer, loss=tf.keras.losses.SparseCategoricalCrossentropy(), metrics=['accuracy'])逻辑说明:ExponentialDecay每经过decay_steps步,学习率乘一次decay_rate。设置staircase=True之后,学习率按阶梯下降,而不是每个 step 都变。训练后期学习率变小,loss 不容易在最优解附近来回震荡。
参数说明:decay_steps=1000意味着大概每跑 8 个 epoch(batch_size=128,训练集 8 万张)学习率降 10%。如果只想快速验证代码能跑通,可以把 epochs 先拉到 5,看 loss 是否在下降,再放回 80。另外要注意,model.compile里的metrics最好用accuracy而不是acc,tf2.1 里acc已经废弃。
如果训练集类别不均衡,Emnist 里的字母样本数和数字样本数差别不小,可以在model.fit里传class_weight:
class_weight = {i: 1.0 / count for i, count in enumerate(class_counts)} model.fit(train_dataset, epochs=80, class_weight=class_weight)这样样本少的类别能获得更大的损失权重,对提升字母类准确率有效。不过要注意,class_weight 只在训练时生效,验证集和 demo 的预测逻辑不会变。
3.3 验证集评估:test.py 与分类报告
运行python test.py --checkpoint checkpoints/pro1-10.ckpt,输出大概是:
Test accuracy: 0.9713, loss: 0.0987这个 0.97 是总体准确率。只报总体数字很容易掩盖问题,比如字母类 O 和数字 0 的混淆。我建议把验证集的预测结果拆开,用 sklearn 生成分类报告:
from sklearn.metrics import classification_report pred = model.predict(test_images) pred_ids = np.argmax(pred, axis=1) print(classification_report(test_labels, pred_ids, target_names=characters))逻辑说明:model.predict返回每个样本在所有类别上的概率分布,np.argmax取概率最大的索引,再交给classification_report计算每一类的精确率和召回率。这样你可以直接看到哪些字符最容易混。
参数说明:test_labels必须是原始整数标签,不能是 one-hot。如果你在训练时用了to_categorical,这里要先用np.argmax(test_labels, axis=1)转回去,不然 sklearn 会报TypeError: unknown label type。
3.4 checkpoint 恢复:训练中断后如何继续
训练脚本生成的checkpoints/pro1-10.ckpt是分片格式,本质上是变量名到数组的映射。加载最直接的方式是:
model = build_resnet(num_classes=len(characters)) model.load_weights('checkpoints/pro1-10.ckpt')逻辑说明:load_weights会根据分片 checkpoint 里的名字匹配权重。它要求执行加载的模型结构和保存时完全一致,包括卷积层的name、padding、use_bias等属性。所以如果改过model.py里某一层名字,加载就会报 Unexpected key。
参数说明:checkpoint 只是权重,不包含优化器状态。如果你想从中间结果继续训练,需要重新构建优化器并设置好学习率。下面这种方式能把优化器状态一起存下来:
ckpt = tf.train.Checkpoint(step=tf.Variable(1), optimizer=optimizer, model=model) manager = tf.train.CheckpointManager(ckpt, './checkpoints', max_to_keep=3) ckpt.restore(manager.latest_checkpoint)这样当你训练到一半停掉,下次用相同代码启动时,优化器的迭代步数和学习率调度状态都会恢复,不会出现"模型没训练完但 loss 像重新开始"的错觉。
4. 避坑指南:tf2.1 下跑字母数字识别,我把踩过的坑按现象写清楚
就算按照 README 来,第一次跑这个项目我也踩了不少坑。这一章每一条都是"现象 → 原因 → 解决",你照着对号入座可以省下很多时间。
4.1 import tensorflow 直接崩掉:CUDA 动态库版本不对
现象:装完 TF 后执行python -c "import tensorflow as tf",报Could not load dynamic library 'cudart64_101.dll'。或者只装了tensorflow==2.1.0,一进解释器就退出。
原因:TF 2.1 在 Windows 上默认安装的包是带 GPU 支持的,import 时会尝试加载 CUDA 10.1 的cudart64_101.dll。如果系统没装 CUDA,或者装了 CUDA 11/12,这个文件根本不存在。
解决:不要硬刚驱动,直接卸载重装 CPU 版:pip uninstall tensorflow,然后pip install tensorflow-cpu==2.1.0。这个项目的数据量不大,CPU 跑 80 epoch 最多半小时。如果你确实想用 GPU,用conda install cudatoolkit=10.1 cudnn=7.6在 conda 环境内装,不要动系统级 CUDA。
4.2 demo.py 读不进彩色图:通道和尺寸不匹配
现象:用自己的彩色手写图喂给 demo.py,报ValueError: Error when checking input: expected conv2d_input to have 4 dimensions, but got array with shape (28, 28, 3);或者不报错但预测结果全乱。
原因:模型输入是四维张量(batch, height, width, channels),单张图是(1, 28, 28, 1)。彩色 JPG 是 3 通道,而且通常不是 28x28。如果脚本内部没有正确的转换,shape 就会错。
解决:在送入模型前,用 Pillow 做一次统一转换:
from PIL import Image import numpy as np img = Image.open('my_handwriting.png').convert('L').resize((28, 28), Image.LANCZOS) img_array = np.array(img, dtype=np.float32) / 255.0 img_array = img_array.reshape(1, 28, 28, 1)这里convert('L')把 RGB 变灰度,resize((28, 28))匹配 EMNIST 分辨率,/255.0归一化,.reshape(1, 28, 28, 1)是补 batch 维度。
4.3 load_weights 报 Unexpected key
现象:加载 checkpoint 时报错Unexpected keys: ['layer_with_weights-0/conv2d-3/kernel']。
原因:模型结构和保存时不一致。比如你加了新的卷积层、改了层顺序,或者改了 ResNet 里某个层的name,checkpoint 里保存的变量名对不上。load_weights默认按位置匹配,一旦结构变动就会报错。
解决:第一,加载权重的模型必须和训练时的model.py完全一致,不要轻易改层名;第二,如果只是想做迁移学习,想保留主干权重、替换最后的全连接层,用model.load_weights('checkpoints/pro1-10.ckpt', by_name=True)并按名字匹配,但前提是前面层的name没改。
4.4 训练 loss 不降,准确率卡在 95% 到 97% 之间
现象:训练到第 80 个 epoch,loss 一直在 0.1 附近震荡,准确率偶尔跳到 97%,又掉回 95%。
原因:两个常见原因。一是学习率没有衰减,后期步长太大,在最优点附近绕圈;二是数据没有归一化,像素值 0-255 输入会让初始 loss 很大,梯度方向不稳定。
解决:把优化器换成 Adam,初始学习率 0.001,加上ExponentialDecay,decay_steps 按 batch 数计算。检查训练代码里有没有images / 255.0,没有就补上。如果你已经用了这些还是卡,那大概率是类别混淆导致的,看下一条。
4.5 O 和 0、Z 和 2 总是分不清
现象:测试集整体准确率还行,但按类别看,数字 0 的 precision 很高,字母 O 召回率很低;classification_report里 O 和 Z 这类字符大量被认成对应数字。
原因:EMNIST 数据本身是手写体,O和0在很多人的写法里几乎没有区别。加上数据集标签也只是按分类标准定的,人类都分不清的样本,模型自然学不好。这是数据本身的歧义,不是网络结构问题。
解决:训练端可以改用 EMNIST Balanced 划分,它合并了部分相似符号;推理端可以做规则后处理,比如当某个输出概率低于 0.9 时,强制按字符混淆矩阵做替换。在课程设计里,我一般会额外统计混淆矩阵,把最常见的混淆对打印出来,然后给 demo 加一个黑白名单逻辑:
if confidence < 0.9 and predicted_char == 'O': predicted_char = '0'这样的规则在报告中很容易写清楚,也算相对合理的补救手段。
5. 用 demo.py 验证真实图片:输入输出规范与进阶用法
5.1 先看输入输出格式
demo.py设计得比较直接:接受一张图片路径,输出预测字符和置信度。压缩包assets/里有0.png、5.png这些来自 EMNIST 测试集的示例图,imgs/demo.png是一张完整的手写字符图,适合第一次验证。我自己验证习惯写成一行:
python demo.py --image assets/3.png如果输出是Predicted: '3',说明 checkpoint 加载成功。注意流程里要确保--checkpoint参数正确指向checkpoints/pro1-10.ckpt,否则 demo 会随机初始化权重,预测结果自然不可信。
5.2 验证模型自己写的字
想验证模型能不能识别自己的字迹,需要走一遍预处理。这里再次强调三点:转灰度、resize 到 28x28、归一化。手写笔画要尽量粗、居中,EMNIST 里的字形本来就是居中裁剪过的。如果上传的图片背景是白纸,但字体很细,模型很可能预测错,这是因为训练样本笔画相对饱满。
5.3 进阶:导出 SavedModel 和记录混淆矩阵
如果你想把模型用到其他项目,而不仅仅是这个 demo,建议导出 SavedModel:
model.save('saved_model/')导出后,tf.keras.models.load_model('saved_model/')可以直接加载。这个格式会被 TensorFlow Serving 接受,后续做部署就方便了。
最后一个习惯:我会在test.py里把预测结果和真实标签全部保存下来,生成混淆矩阵并看每个类别的准确率。因为课程设计答辩时老师经常会问"你的模型在哪类容易错?你针对这个问题怎么改进",光有一个总准确率是不够的。用sklearn.metrics.confusion_matrix两行代码就能画出来:
import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix cm = confusion_matrix(test_labels, pred_ids) plt.imshow(cm, cmap='Blues')建议把图保存到imgs/confusion_matrix.png,以后写报告直接引用。
这个项目我跑通之后,最大的收获就是记住了 checkpoint 只是权重、优化器状态要单独保存这件事。从那以后我每次动手改网络结构都会强制走一遍训练、测试、demo 的闭环,先确认 baseline 没掉再做优化。希望帮到你。
本文还有配套的精品资源,点击获取