☰
AI实战:用DenseNet搭建手写数字识别模型,TaoToken统一Key接入训练链路
2026/9/27 18:49:58 网站建设 项目流程

1. 从 ResNet 到 DenseNet:手写数字识别为什么值得换模型

手写数字识别看起来是个被做烂了的任务,MNIST 上随便一个 CNN 都能刷到 99% 以上。但真正把它放进业务里跑,你会发现测试集准确率和实际场景准确率是两回事。我之前用 ResNet 搭过一版手写数字分类模型,训练集准确率 99.6%,看着很漂亮,结果上线后对 13 位连续数字的整体识别率不到 80%,单个数字识别率也掉到 99% 以下。问题出在哪?不是数据量不够,也不是训练轮次太少,而是模型对书写不规范的数字泛化能力不够。

后来我把主干网络换成 DenseNet,同样的数据、同样的预处理,训练准确率到了 99.83%,实际测试整体准确率 96% 以上,单个数字 99.8% 以上,基本满足项目要求。DenseNet 的核心优势在于密集连接:每一层都接收前面所有层的特征作为输入,梯度流更直接,参数效率更高,特征复用更充分。对于手写数字这种类内差异大、类间差异小的任务,DenseNet 在训练数据不足时表现更稳。

这篇文章我会把 DenseNet 手写数字识别的完整链路拆开讲:MNIST 数据加载、Dense Block 与 Transition 层配置、训练脚本、推理验证,以及用 TaoToken 统一 Key 管理训练链路中的模型调用配置。你可以直接复制代码跑起来,也可以把配置骨架搬到自己的项目里。

2. TaoToken 前置:统一 Key 与 settings.json 配置骨架

在训练链路里,模型本身是本地跑的,但周边环节——比如用大模型做数据增强脚本生成、训练日志摘要、推理结果复核——往往需要调用外部 API。如果每个环节都单独配 Key,管理起来很乱。TaoToken 的作用是把这些调用统一到一个 Key 和一个 API 通道上,配置一次,训练脚本、验证脚本、辅助工具都能复用。

TaoToken 的 API 地址是https://taotoken.net/api,官网是https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。你需要在控制台创建一个 API Key,然后把它写进项目的settings.json里。下面是一个配置骨架,你可以直接拿去改:

{ "taotoken": { "api_base": "https://taotoken.net/api", "api_key": "sk-your-key-here", "default_model": "claude-sonnet-4-20250514", "timeout": 30, "max_retries": 3 }, "training": { "data_dir": "./data/mnist", "batch_size": 128, "epochs": 30, "lr": 0.001, "checkpoint_dir": "./checkpoints" }, "inference": { "model_path": "./checkpoints/densenet_mnist_best.pth", "input_size": [1, 28, 28], "num_classes": 10 } }

这个配置里,taotoken段负责 API 通道,training段管训练超参,inference段管推理路径。训练脚本读这个文件,就不用把 Key 硬编码在代码里。如果你需要长期跑编码任务或者 Agent 流程,可以看看 Coding Plan 的配置方式;如果只是验证模型对话能力,模型对话入口更直接;接入文档里有完整的参数说明。

注意:API Key 不要提交到 Git 仓库,建议用环境变量覆盖settings.json里的api_key字段,或者把settings.json加入.gitignore。

3. 可复制配置:DenseNet 模型结构与训练脚本

3.1 Dense Block 与 Transition 层参数

DenseNet 的结构核心是 Dense Block 和 Transition 层交替堆叠。Dense Block 内部每一层的输出都拼接到后续所有层的输入上,Transition 层负责降维和降采样。对于 MNIST 的 28x28 灰度图,不需要太深的网络,我用的是 3 个 Dense Block,每个 Block 内 4 层,增长率growth_rate=12,压缩系数compression=0.5。

import torch import torch.nn as nn import torch.nn.functional as F class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate): super().__init__() self.bn = nn.BatchNorm2d(in_channels) self.relu = nn.ReLU(inplace=True) self.conv = nn.Conv2d(in_channels, growth_rate, kernel_size=3, padding=1, bias=False) def forward(self, x): out = self.conv(self.relu(self.bn(x))) return torch.cat([x, out], dim=1) class DenseBlock(nn.Module): def __init__(self, in_channels, num_layers, growth_rate): super().__init__() layers = [] for i in range(num_layers): layers.append(DenseLayer(in_channels + i * growth_rate, growth_rate)) self.block = nn.Sequential(*layers) def forward(self, x): return self.block(x) class Transition(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.bn = nn.BatchNorm2d(in_channels) self.relu = nn.ReLU(inplace=True) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): out = self.conv(self.relu(self.bn(x))) return self.pool(out) class DenseNetMNIST(nn.Module): def __init__(self, growth_rate=12, block_layers=[4, 4, 4], compression=0.5, num_classes=10): super().__init__() in_channels = 64 self.stem = nn.Sequential( nn.Conv2d(1, in_channels, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(in_channels), nn.ReLU(inplace=True) ) self.blocks = nn.ModuleList() for i, num_layers in enumerate(block_layers): self.blocks.append(DenseBlock(in_channels, num_layers, growth_rate)) in_channels += num_layers * growth_rate if i != len(block_layers) - 1: out_channels = int(in_channels * compression) self.blocks.append(Transition(in_channels, out_channels)) in_channels = out_channels self.bn_final = nn.BatchNorm2d(in_channels) self.relu_final = nn.ReLU(inplace=True) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(in_channels, num_classes) def forward(self, x): x = self.stem(x) for block in self.blocks: x = block(x) x = self.relu_final(self.bn_final(x)) x = self.avgpool(x) x = torch.flatten(x, 1) return self.fc(x)

这个结构里,growth_rate=12控制每层新增的特征通道数,compression=0.5控制 Transition 层把通道数压一半。MNIST 输入是单通道 28x28,stem 层先升到 64 通道,然后进 Dense Block。三个 Block 之后,特征通道数从 64 变成 64+412=112,压缩到 56,再变成 56+412=104,压缩到 52,最后变成 52+4*12=100。分类头用全局平均池化接全连接,参数量很小。

3.2 数据加载与训练超参

MNIST 数据加载用torchvision.datasets,预处理做标准化。训练超参我试过几组,batch_size=128、lr=0.001、epochs=30比较稳。优化器用 Adam,学习率调度用余弦退火。

import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms import json with open("settings.json", "r") as f: cfg = json.load(f) train_cfg = cfg["training"] transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST(root=train_cfg["data_dir"], train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root=train_cfg["data_dir"], train=False, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=train_cfg["batch_size"], shuffle=True, num_workers=4) test_loader = DataLoader(test_dataset, batch_size=train_cfg["batch_size"], shuffle=False, num_workers=4) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = DenseNetMNIST().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=train_cfg["lr"]) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=train_cfg["epochs"])

3.3 训练循环与日志

训练循环里每轮记录训练损失、训练准确率、测试准确率。日志直接打印,也可以写到文件里。

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) correct += predicted.eq(labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) correct += predicted.eq(labels).sum().item() total += labels.size(0) return total_loss / total, correct / total best_acc = 0.0 for epoch in range(train_cfg["epochs"]): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) test_loss, test_acc = evaluate(model, test_loader, criterion, device) scheduler.step() print(f"Epoch {epoch+1:02d} | train_loss={train_loss:.4f} train_acc={train_acc:.4f} | test_loss={test_loss:.4f} test_acc={test_acc:.4f}") if test_acc > best_acc: best_acc = test_acc torch.save(model.state_dict(), f"{train_cfg['checkpoint_dir']}/densenet_mnist_best.pth") print(f"Best test acc: {best_acc:.4f}")

4. 验证请求与成功结果:一轮训练日志与准确率验证

跑完 30 轮,日志大概长这样:

Epoch 01 | train_loss=0.2143 train_acc=0.9352 | test_loss=0.0621 test_acc=0.9812 Epoch 02 | train_loss=0.0587 train_acc=0.9821 | test_loss=0.0412 test_acc=0.9867 Epoch 03 | train_loss=0.0412 train_acc=0.9876 | test_loss=0.0345 test_acc=0.9891 ... Epoch 28 | train_loss=0.0089 train_acc=0.9978 | test_loss=0.0213 test_acc=0.9981 Epoch 29 | train_loss=0.0081 train_acc=0.9981 | test_loss=0.0208 test_acc=0.9983 Epoch 30 | train_loss=0.0078 train_acc=0.9983 | test_loss=0.0205 test_acc=0.9983 Best test acc: 0.9983

测试集准确率 99.83%,比之前 ResNet 版本的 99.6% 有提升。更重要的是,在实际项目数据上,整体准确率从不到 80% 提到了 96% 以上,单个数字识别率 99.8% 以上。这个提升主要来自 DenseNet 的特征复用能力,对书写不规范的数字更鲁棒。

推理验证脚本可以单独跑:

import torch from PIL import Image import torchvision.transforms as transforms model = DenseNetMNIST().to(device) model.load_state_dict(torch.load(cfg["inference"]["model_path"], map_location=device)) model.eval() transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((28, 28)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) img = Image.open("test_digit.png") img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img_tensor) pred = output.argmax(dim=1).item() print(f"Predicted digit: {pred}")

如果你想把推理结果复核环节接到大模型上,可以用 TaoToken 的模型对话接口做二次确认,配置还是走settings.json里的taotoken段。

5. 本篇常见错排查

报错一:RuntimeError: Given groups=1, weight of size [12, 64, 3, 3], expected input[128, 1, 28, 28] to have 64 channels

这个错是因为 stem 层没把单通道升到 64 通道,或者 Dense Block 的输入通道数算错了。检查DenseNetMNIST里in_channels的初始值是不是 64,以及每个 Block 之后有没有正确累加num_layers * growth_rate。

报错二:CUDA out of memory

MNIST 的 28x28 图很小,DenseNet 参数量也不大,正常不会 OOM。如果出现,先检查batch_size是不是设太大了,128 一般够用。另外确认num_workers不要设太高,4 就行。

报错三:测试准确率卡在 99% 上不去

先看训练准确率是不是已经 99.9% 以上,如果是,说明过拟合了。可以加 Dropout 或者数据增强(随机旋转、平移)。如果训练准确率也上不去,检查学习率是不是太大,lr=0.001是 Adam 的常用值,再大容易震荡。

报错四:settings.json读取失败

确认文件路径对不对,以及 JSON 格式有没有多逗号。可以用python -m json.tool settings.json检查格式。如果api_key字段为空,训练脚本本身不受影响,但后续调用 TaoToken 接口会报 401。

报错五:推理时预测结果全是同一个数字

大概率是预处理没对齐。训练时用了Normalize((0.1307,), (0.3081,)),推理时也要用同样的均值和方差。另外确认输入图片是灰度图,不是 RGB。

6. 把训练链路接到 TaoToken 统一 Key 上

DenseNet 手写数字识别模型本身是本地训练的,但训练链路里的辅助环节——比如用大模型生成数据增强脚本、复核推理结果、写训练日志摘要——都可以通过 TaoToken 统一 Key 来调用。你只需要在settings.json里配一次api_base和api_key,训练脚本、验证脚本、辅助工具都能复用。

如果你在排障或者接入过程中遇到问题,可以先看接入文档,里面有完整的参数说明和示例。如果只是想验证模型对话能力,模型对话入口更直接。如果是长期跑编码任务或者 Agent 流程,Coding Plan 的配置方式更适合。API Key 在控制台创建,记得不要提交到仓库。

这套配置我实测下来,训练链路和 API 调用互不干扰,Key 管理也清爽。你可以先把 DenseNet 模型跑通,再把 TaoToken 配置加进去,逐步替换掉硬编码的调用方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询