AI 算法工程师的成长路径里,有一组项目出现频率非常高:VGG/ResNet 做图像分类,图像风格迁移做特征分布控制,TextCNN 做文本分类,图像字幕生成把视觉和语言拼到一起。这四个深度学习核心项目,恰好覆盖了 CNN 基础、特征提取、损失设计、序列建模和多模态入口,不是面试前背结构就完事的东西。我建议每个想转算法方向的人,都亲手跑通一遍。适合谁?刚学完 Python 和 PyTorch 基础的学生、做 CV 但没碰过 NLP 的工程师,以及准备把课程 Demo 整理成可提交项目的开发者。最值得关注的不是某一个模型有多深,而是你能通过这套组合学会一件事:如何从数据、训练、验证、日志、报错里判断一个深度学习项目是不是真的可控。
1. 先搞清楚这四个项目各自在练什么
很多人把这四个项目当成“四个模型”来学,这是最大的误区。它们真正的作用是一条能力链路:先用 VGG/ResNet 看懂图像特征,再用风格迁移理解特征分布,然后用 TextCNN 把视野拉到文本,最后用图像字幕生成把图像和文本串起来。按这个顺序学,比单独刷十个模型更稳。
1.1 VGG/ResNet:不是背结构,是理解特征提取的底座
VGG 的核心思路很朴素:连续用 3x3 卷积堆叠,配合 max pooling 逐步降低分辨率,最后接全连接层做分类。VGG16、VGG19 的区别只是层数不同,结构上没有太多花哨的东西。正因为结构简单,VGG 非常适合用来理解“卷积层提取到什么特征”这个问题。
ResNet 解决的是更深网络难训练的问题。层数加深后,训练误差反而可能变大,这被称为退化问题。ResNet 通过残差连接让每一层可以学习“相对于输入的增量”,也就是把输出从 F(x) 变成 F(x) + x。这个变化看起来小,实际影响很大。它让网络可以训练得更深,也成了很多后续模型的通用骨架。
在工程实践里,我不建议一上来就死记 VGG 和 ResNet 每一层输出尺寸。你需要记住的是几件更核心的事:
- 预训练模型通常使用 ImageNet 的均值和标准差做归一化,换数据集时不要丢掉这一步。
- 分类头要按自己的类别数替换,ResNet 是 model.fc,VGG 是 model.classifier。
- 数据量小的时候,优先用预训练模型做微调,而不是从头训练。
这四个项目里,VGG 和 ResNet 不只是分类模型。图像风格迁移会用到 VGG 的中间层特征,图像字幕生成的编码器可以直接用 ResNet。所以这一关过了,后面会省很多事。
1.2 图像风格迁移:把“内容”和“风格”拆开
风格迁移通常不是训练一个新网络,而是用预训练好的 VGG 网络提取特征,然后对一个输入图片的像素做优化。
内容特征很好理解,就是图片经过某层卷积后的 feature map。风格特征更抽象一些,通常用 Gram 矩阵表示。Gram 矩阵计算的是不同特征通道之间的相关性,可以把它粗略理解为“某种风格纹理的统计指纹”。把内容图片的某层特征和风格图片的 Gram 矩阵同时约束住,就能让生成图既保留内容结构,又呈现风格纹理。
总损失一般长这样:
loss = content_weight * content_loss + style_weight * style_loss + tv_weight * tv_loss其中 tv_loss 是 total variation loss,用来让生成结果更平滑,减少噪点。
学习风格迁移时,最值得体会的不是代码,而是“损失函数不一定需要标签”这件事。分类任务需要真实标签,风格迁移只需要定义一张图应该像内容图、应该具备风格图的特征分布。这种用特征去定义损失的方式,在后面很多生成模型里都会被反复用到。
1.3 TextCNN:把文本当成图像来处理
TextCNN 的核心思路是:文本经过 embedding 之后,变成形状为 (batch_size, seq_len, embed_size) 的张量。转置一下,就可以把它当成一个“一维图像”去做卷积。不同的卷积核大小相当于不同长度的 n-gram 窗口,比如 kernel_size=2 看相邻两个词,kernel_size=3 看相邻三个词。
TextCNN 的逻辑并不复杂,但它是一个特别适合入门 NLP 分类任务的项目。相比 RNN,它训练更快,结构更直观,调参也更简单。对于短文本分类、情感分析、意图识别这类任务,TextCNN 依然是很强的基线。
做中文文本分类时,要先决定用字还是用词。用字的好处是不需要分词,简单直接;用词能带上更多语义,但要先做分词,还要处理词表大小。第一次跑通时,我建议先按字切分,减少变量。
1.4 图像字幕生成:第一道多模态门槛
图像字幕生成,英文叫 image captioning,任务是输入一张图片,输出一句描述性文本。和前面的项目都不一样,这个任务的输入是图像,输出是序列。你需要把图像特征编码成一个向量,然后用 LSTM 或者 GRU 一个词一个词地生成句子。
这就构成了一个经典的 encoder-decoder 结构:
- 编码器:CNN 提取图像特征。
- 解码器:RNN/LSTM 根据图像特征和已经生成的词,预测下一个词。
- 训练目标:交叉熵损失,让模型预测词的概率尽量接近真实标注。
第一次做这个项目时,很多人会被数据集和数据加载搞晕。因为图片和文本是两种不同格式,需要建立 image_id 和 caption 的对应关系,还要处理词表、未知词、填充、句子长度不一致等问题。这些正是多模态项目最常出现的工程坑。
这四个项目串起来,就是从“看懂图像”到“看懂文本”,再到“图像和文本互相转换”的完整过程。
2. 环境准备和数据集怎么选
环境问题看起来是最基础的,但实际项目中,报错最多的往往不是模型,而是版本不匹配、数据路径错误、图片打开失败、文本编码乱码。先把环境固定住,后面四个项目才不会互相干扰。
2.1 硬件和软件版本:先跑通再升级
如果你是第一次跑这套项目,不要一上来就配最强的设备。更稳妥的做法是先用 CPU 跑通一个小样例,确认模型前向、损失计算、验证流程都没问题,再用 GPU 跑完整训练。
常见配置可以参考下面这张表:
| 资源 | 入门起步 | 相对舒服的配置 |
|---|---|---|
| CPU | 能跑,训练慢 | 多核 CPU 会好一些 |
| 内存 | 8GB | 16GB 以上 |
| GPU | 可选 | NVIDIA GPU,显存 6GB 以上 |
| 磁盘 | 10GB 可用空间 | 30GB 以上 |
依赖方面,以 PyTorch 为例,我建议先确认 Python、CUDA、PyTorch 三者兼容。不要随手复制最新版本,因为最新版不一定适配你本机的 CUDA。
pip install torch torchvision numpy pillow matplotlib pandas如果你的环境是内网,装不了在线包,就要提前准备离线 wheel 包,或者让维护环境的同事统一安装。这个环节看起来和算法无关,但很多人恰恰卡在这里。
2.2 数据集和目录组织:别把时间浪费在数据加载上
这四个项目的数据集可以这样选:
- VGG/ResNet 图像分类:CIFAR-10、猫狗分类,或者你自己整理的小型图片集。
- 图像风格迁移:准备一张内容图、一张风格图即可,比如一张街景照片和一张名画。
- TextCNN 文本分类:THUCNews 的子集、购物评论情感分类,或者自定义的中文短文本。
- 图像字幕生成:Flickr8k 或 COCO 的少量子集,不要一开始就处理整个 COCO。
目录结构建议按项目分开:
deep-learning-practice/ ├── 01_classification/ ├── 02_style_transfer/ ├── 03_textcnn/ └── 04_captioning/每个项目目录里再拆 data、models、checkpoints、outputs。这样做的好处是,出问题时你能很快定位是数据问题、模型保存问题,还是运行日志问题。否则所有文件堆在一个目录里,后期会很痛苦。
3. 按顺序跑通四个项目
下面按实际操作顺序拆一遍。每个项目我只给出关键代码片段和要注意的点,完整训练逻辑需要你自己补全。
3.1 项目一:VGG/ResNet 图像分类
先用 ResNet18 做分类,因为它比 ResNet50 轻,跑得也快。第一次运行时,建议用小数据集、小 batch、少 epoch,确认流程能通,再逐步加大。
import torch import torch.nn as nn from torchvision import models, transforms model = models.resnet18(pretrained=True) num_classes = 10 model.fc = nn.Linear(model.fc.in_features, num_classes) transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里最容易忽略的是 Normalize。如果你使用 ImageNet 预训练权重,输入图片就必须按 ImageNet 的均值和标准差归一化。如果不做,模型的输出会非常差,而且你很难判断是模型问题还是数据问题。
训练时至少分训练集和验证集。不要用同一份数据既训练又验证,否则看到的准确率是虚高的。常见做法是把数据按 8:2 或 7:3 划分,每个 epoch 结束在验证集上跑一次,保存验证集上表现最好的模型。
如果 CIFAR-10 数据量较小,ResNet18 微调十几个 epoch 就能看到明显效果。如果你的任务不是图片分类,而是检测或分割,这一块的重点仍然是把它当特征提取器用,后面的项目会依赖这个能力。
3.2 项目二:基于 VGG 的图像风格迁移
风格迁移不需要训练模型,需要定义损失并优化图片本身。建议用 VGG19 的中间层特征,通常内容层取靠后的conv4_1或conv5_1,风格层取多层。
语法上,Gram 矩阵可以这样实现:
def gram_matrix(x): b, c, h, w = x.shape features = x.view(b, c, h * w) return features.bmm(features.transpose(1, 2)) / (c * h * w)内容损失用内容特征和生成图特征之间的 MSE,风格损失用 Gram 矩阵之间的 MSE。每次迭代时,把生成图片交给 VGG 提取特征,算损失,再对生成图片执行 backward。优化器更新的是图片张量,不是网络参数。
这里有几个实践建议:
- 内容图和风格图最好先缩放到相同尺寸,否则 VGG 前向之后特征大小不一致,处理起来很绕。
- 风格权重通常比内容权重大很多,因为 Gram 矩阵的数值范围比较大,需要平衡。
- 每 50 或 100 次迭代保存一张生成图,肉眼看效果比看 loss 更直观。
风格迁移的 loss 数值没有绝对标准,不同风格图、不同内容图差异很大。判断标准是:内容结构是否清晰、风格纹理是否明显、图像是否出现大量噪点。如果出现花屏,可以增加 tv_weight。
3.3 项目三:TextCNN 文本分类
TextCNN 的网络结构可以写得很短:
import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_size, num_filters, num_classes, kernel_sizes=(2, 3, 4)): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_size, num_filters, k) for k in kernel_sizes ]) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): emb = self.embedding(x).transpose(1, 2) pooled = [] for conv in self.convs: c = torch.relu(conv(emb)) c = torch.max_pool1d(c, c.size(2)).squeeze(2) pooled.append(c) return self.fc(torch.cat(pooled, dim=1))这份代码里,Embedding 用 padding_idx=0,意思是填充位置的向量不会参与更新。数据加载时,文本序列长度不一致,需要 pad 成一个 batch 内的固定长度。最简单的方式是用torch.nn.utils.rnn.pad_sequence,把每条样本 padding 到当前 batch 的最大长度。
做文本分类,数据清洗比网络结构更影响结果。首先要检查文本编码,Windows 下常见 gbk,用 utf-8 打开会乱码。其次要确定标签类别数量与 model 输出维度一致。最后要确认输入是整数索引,不是字符串。很多人把中文文本直接传进模型,报错后还以为是卷积层写错了。
TextCNN 的典型训练状态是:前几个 epoch loss 快速下降,之后逐渐平稳。如果验证集准确率一直上不去,优先检查数据标签是否均衡、词表是否过大、学习率是否太高。
3.4 项目四:图像字幕生成小模型
第一次做图像字幕生成,不要追求完整 COCO 效果。可以只取少量图片和一个固定词表,先用小模型跑通生成流程。
简单做法是:
- 编码器用 ResNet18,去掉最后的全连接层,输出一个图像特征向量。
- 解码器用单层 LSTM,输入是词向量。
- 训练时使用 teacher forcing,也就是每一步都把真实词作为输入。
- 测试时使用 greedy decoding,把上一步预测的词作为下一步输入。
关键代码结构参考:
class CaptionDecoder(nn.Module): def __init__(self, feature_dim, embed_size, hidden_size, vocab_size): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.lstm = nn.LSTM(embed_size, hidden_size, batch_first=True) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, image_feature, captions): emb = self.embedding(captions) # 简化处理:把 image_feature 拼在序列最前面 # 也可以用 image_feature 初始化 LSTM 的 hidden state outputs, _ = self.lstm(emb) return self.fc(outputs)实际项目中,你会遇到一个很常见的问题:batch 里每条 caption 长度不一样。直接塞进 DataLoader,要么 padding,要么使用 pack_padded_sequence。我建议先使用 padding 跑通,再去处理更复杂的性能优化。
评估字幕生成质量时,不要只看交叉熵。应该实际保存几条生成的句子,和参考字幕放在一起对比。指标可以看 BLEU,但 BLEU 也不是万能的,它更偏向 n-gram 重叠,不一定能完全反映语义质量。
4. 关键参数、训练策略和结果判断
跑通四个项目之后,下一步是学会调参和判断训练状态。这一阶段才是真正拉开差距的地方。
4.1 图像任务里要盯的参数
| 参数 | 影响 | 我的调整习惯 |
|---|---|---|
| image_size | 越大信息越多,但显存和时间上升 | 先小后大,不一开始就 448 |
| batch_size | 影响梯度和显存 | 显存不够就减半,同时观察收敛 |
| learning_rate | 过大会震荡,过小收敛慢 | 预训练微调用 0.0001 到 0.001 |
| epochs | 训练轮次 | 用小数据集先试,看验证集是否还能涨 |
| 数据增强 | 防止过拟合 | ResNet 分类建议加随机翻转、裁剪 |
做风格迁移时,重点不是学习率多大,而是 content_weight 和 style_weight 的比例。风格权重高,生成图更“像”风格图,但内容结构可能丢;内容权重高,结构清晰但风格不够明显。实际调参时,我习惯固定一张内容图和一张风格图,把权重做成变量,保存多组结果对比。
4.2 文本任务里要盯的参数
TextCNN 的常用参数包括 embed_size、num_filters、kernel_sizes、dropout。embed_size 越小训练越快,但表示能力弱;num_filters 越多,模型越容易记住训练数据,也更容易过拟合。建议先保持默认 128 左右,跑通后再调。
文本分类里,类别不均衡是很容易被忽略的问题。如果某个类别只有几十条,另一些类别有一万条,模型会倾向于预测多数类。处理方式可以是重采样、调整类别权重,或者收集更均衡的数据。先看混淆矩阵,再决定怎么处理。
图像字幕生成里,max_caption_length 也要提前设置。不要等训练时才发现句子长度差异巨大。词表需要保留<pad>、<bos>、<eos>、<unk>四种特殊 token。如果你训练时没加未知词处理,测试时碰到新词就会直接崩。
4.3 怎么判断训练是否正常,而不是只看 loss
loss 下降不一定代表结果好,loss 不降也不一定代表模型坏了。最有效的判断是同时看训练集和验证集:
- 训练 loss 降,验证 loss 也降:正常。
- 训练 loss 降,验证 loss 不降或上升:过拟合,需要增强数据、加 dropout、降低模型容量。
- 训练 loss 和验证 loss 都不降:可能学习率太小、数据有问题、模型设计有 bug。
- 训练一开始 loss 就是 NaN:优先检查输入数据有没有异常值、学习率是否过大、标签是否越界。
对风格迁移,我强烈建议每轮迭代都保存图片。不要只看终端里的 loss,因为风格迁移的 loss 和人类主观感受不是严格对应的。画面是否干净、物体轮廓是否可辨认,这些只能靠肉眼判断。
5. 常见报错和排查顺序
项目跑不通时,很多人第一反应是改模型,但大多数问题其实不在模型。遇到问题时,按这个顺序查,效率最高。
5.1 先看现象,再看输入,最后动参数
我常用的排查顺序是:
- 看报错发生在哪个阶段:数据加载、模型前向、loss 计算、反向传播,还是验证过程。
- 看输入数据的形状、类型、取值范围。先 print 一个 batch,确认 shape 和标签对不对。
- 看依赖版本和路径。路径不存在、目录没权限、权重没下载成功,这些比模型 bug 常见得多。
- 看参数。batch_size 太大、学习率太大、序列 padding 错误,都会引发千奇百怪的问题。
- 最后才怀疑模型结构。不要一上来就重构网络。
5.2 四个项目各自的典型坑
| 项目 | 常见现象 | 优先检查 |
|---|---|---|
| VGG/ResNet | 准确率很低 | 是否做 Normalize、是否改对了分类头、训练集和验证集是否混在一起 |
| 风格迁移 | 生成图花屏 | 图片尺寸是否一致、tv_loss 是否太小、风格权重是否过高 |
| TextCNN | shape 不匹配 | 文本序列是否 padding、词表索引是否从 0 开始、padding_idx 是否设置 |
| 图像字幕生成 | 生成的句子全是重复词 | 是否漏掉结束符、训练是否过拟合、词表是否太小 |
GPU 显存不足时,不要马上靠增加模型并行解决。先把 batch_size 减半,把 image_size 调小,把 DataLoader 的 num_workers 调低,通常都能缓解。如果数据太大,优先做采样,不要一次性把所有图片读进内存。
还有一个常见问题是权重下载失败。内网环境尤其容易遇到。解决办法是提前把预训练权重下载到本地,然后用 torch.hub 指定缓存目录,或者在代码里直接加载本地路径。这不是模型问题,是网络和路径问题。
6. 从 Demo 到工程化的几个建议
四个项目都能跑通后,你手里就已经有了四个可演示的“小项目”。但如果只是想跑通,价值还不够。要把它们变成真正可复用的工程资产,还需要补几件看似琐碎但很关键的事。
6.1 模型保存、日志和复现
我建议每个项目都做 checkpoint,而不是只保存最后一步的权重。一个完整的 checkpoint 至少包含模型参数、优化器参数、当前 epoch、最优验证指标。这样断点续跑时不会丢进度,也能回溯哪个版本效果最好。
torch.save({ 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'epoch': epoch, 'best_acc': best_acc, }, 'checkpoint.pt')复现同样重要。固定随机种子、固定数据划分文件、固定数据增强顺序,以后才能解释某一次实验为什么效果变好或变差。否则实验结果不可复现,等于没做实验。
6.2 把推理过程封装起来
训练完后,不要只写训练脚本,还要有一个干净的推理脚本或推理函数。推理时要处理几个和训练不一样的问题:
- 输入图片要经过和训练时相同的预处理。
- 文本输入要做相同的 tokenize、padding。
- 模型要切换到 eval 模式,并用 torch.no_grad() 包住前向过程。
- 输出类别要映射回原始标签名称,而不是输出数字编号。
把这些封装成一个函数,比如 predict_image(image_path) 或 predict_text(text),后续接业务接口会非常省事。如果只是做一个课程项目,这段封装也会让你的代码可读性高很多。
6.3 下一步怎么扩展
跑完这四个项目后,你可以往几个不同的方向继续深入:
- 如果想继续做 CV,可以把 ResNet 换成 ViT、Swin Transformer,或者加入注意力机制。
- 如果想继续做 NLP,可以把 TextCNN 换成 BERT、Ernie 这类预训练语言模型。
- 如果想继续做多模态,可以把图像字幕生成里的 LSTM 换成 Transformer,甚至尝试 CLIP 的思路。
但我不建议你为了“追热点”而直接跳到太新的模型。先把这四个基础项目吃透,把数据加载、训练、验证、推理、排查这一套动作练熟,再上新模型,你会发现自己踩的坑会少很多。
把这四个项目完整跑通之后,你会慢慢形成一种判断力:看到一个新模型时,会先问输入输出是什么,数据要怎么做预处理,训练时应该如何验证,线上推理时哪些环节会变慢或出问题。这套判断力,比多背十个模型的结构更有用。