简介包含四个独立完整的国科大深度学习课程实践项目手写数字识别、猫狗分类、自动写诗、情感分析均附带可运行的Python源代码与实验报告。各项目按模块组织涵盖数据加载、模型构建、训练测试等核心流程便于快速定位和二次修改适合计算机、人工智能等相关专业学生作为课程作业或入门实操参考。资源为zip压缩包共76个文件以py/pyc源码与运行文件、PDF实验报告、PNG结果图、md/txt说明文档为主便于阅读与复用整体大小约16.73MB结构清晰且轻量。目前已有466人学习下载代码均测试通过答辩评审平均分达96分可在其基础上扩展功能或直接运行体验完整流程。对于希望系统理解图像分类、文本生成与情感分析链路的初学者这套项目示例能提供从数据预处理到模型评估的一站式参考。1. 国科大深度学习课程作业四个任务其实是两套最小闭环手写数字体识别、猫狗分类、自动写诗、情感分析这四个任务经常被国科大深度学习课程打包成一次大作业。表面上是四个独立实验实际上它们只覆盖了两条主线路图像分类与文本序列建模。手写数字识别让你理解卷积怎样逐层提取局部特征猫狗分类则把问题推到真实图片的尺度逼迫你处理过拟合和数据增强自动写诗引入隐状态和时间步让模型学会生成序列情感分析又回到分类只是输入变成了变长文本。对初学者来说把这一组作业完整跑通等于动手实践了 CNN、迁移学习、RNN/LSTM、词向量四大块内容也正是深度学习入门最常见的四条路径。这篇文章不假设你有现成源码我会按自己习惯的组织方式从数据加载、模型定义、训练循环到文档说明把每个任务的最小可复现方案写清楚让你拿到的不仅是一份能跑的代码还能看懂每个参数为什么这样设。2. 手写数字体识别用 MNIST 打通数据管线与卷积网络2.1 数据加载与 LeNet 变体的最小实现手写数字识别最经典的基准是 MNIST28×28 灰度图10 类数字。这个任务的困难程度恰好适合验证数据加载、模型定义、训练循环这套基本流程。我一般会用 PyTorch 完成因为它的 Dataset 和 DataLoader 抽象对后续猫狗分类、文本任务同样适用。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集随机裁剪和水平翻转在数字识别上反而有害这里只用ToTensor和归一化 transform_mnist transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform_mnist) test_data datasets.MNIST(root./data, trainFalse, transformtransform_mnist) train_loader DataLoader(train_data, batch_size128, shuffleTrue, num_workers2) test_loader DataLoader(test_data, batch_size256, shuffleFalse) class LeNetLike(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(16*5*5, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这段代码里的 transform 只做了归一化没有加随机裁剪或翻转。原因是数字的方向和位置对语义有决定性影响旋转 90 度的 6 可能是 9水平翻转的 7 变成另一个符号。对比后面的猫狗分类你会看到数据增强是否需要完全由任务语义决定。卷积部分用了两个卷积层加两个池化kernel_size 在第二层没有再补 padding所以第二层输出的特征图尺寸会略小于输入。计算全连接层输入维度时需要根据原始 28×28 和两次池化的下采样人工推一遍这里 1655 对应第二次池化后的 5×5 特征图。如果后续换成更大输入这个维度要跟着改建议在网络里打印中间张量形状来确认。训练循环是一个标准的三件套前向计算、损失反向传播、优化器更新。损失函数用交叉熵优化器用 Adam学习率从 1e-3 起步。model LeNetLike() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(5): running_loss 0.0 for x, y in train_loader: optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() running_loss loss.item() acc compute_accuracy(model, test_loader) print(fepoch {epoch1}: loss{running_loss/len(train_loader):.4f}, acc{acc:.2%})注意zero_grad()必须放在 forward 之前否则梯度会跨 batch 累积。PyTorch 的.backward()会把计算图的梯度写入各张量的.grad不清零的话每个 batch 的梯度会叠加导致优化方向完全错误。num_workers2表示用两个子进程预取数据在 Windows 上如果遇到死锁可以改成 0。这个任务的训练时间在 CPU 上也很短5 轮就足以达到 98% 以上准确率这正是它适合做第一个作业的原因不依赖 GPU就能把调参、断点、日志等工程习惯养好。2.2 输出概率与错误样本的检查方法计算准确率时常见错误是把torch.max(logits, 1)的索引直接与标签比较这没问题但不能只盯着准确率。课程作业通常要求分析模型在哪些样本上出错。一个简单做法是把测试集里预测错误的前 10 张图连同预测值、真实值导出成文件。import matplotlib.pyplot as plt def collect_misclassified(model, loader, limit10): model.eval() kept [] with torch.no_grad(): for x, y in loader: pred model(x).argmax(dim1) mask pred ! y mis_x, mis_y, mis_pred x[mask], y[mask], pred[mask] for i in range(min(limit - len(kept), len(mis_x))): kept.append((mis_x[i], mis_y[i].item(), mis_pred[i].item())) if len(kept) limit: break return keptmodel.eval()是关键它关闭 dropout 和 batch norm 的训练行为。如果在评估时不加这一行同一批样本每次预测结果会不一样因为 dropout 在推理时仍会随机丢弃部分神经元。收集错误样本用于文档说明时可以画成网格图每张子图的标题写成“真实-预测”这样能直观看到数字 4 被识别成 9 之类的问题。这个错误的分布通常会集中在笔画较窄、手写风格潦草的样本上后续改进可以从数据增强或增加卷积层数入手。3. 猫狗分类用数据增强和迁移学习解决小样本过拟合3.1 为什么不建议从头训练大网络猫狗分类的数据集通常只有两万张左右每类一万张虽然比 MNIST 大得多但相对 ResNet 这类深层网络来说仍然容易过拟合。如果从头训练一个 18 层以上的网络训练集准确率可以快速逼近 100%验证集却卡在 85% 上下这就是典型的训练集和验证集分布差异被模型记住。注意猫狗分类任务里图像尺寸、颜色、拍摄角度差异很大直接用原图训练会让网络频繁调整对毛发纹理的敏感度。数据增强不是可有可无的锦上添花而是让模型学习“猫/狗”这两个概念而不是某张照片的像素组合。常见的做法是迁移学习使用在 ImageNet 上预训练的 ResNet18替换最后全连接层然后分两阶段微调。ImageNet 上的彩色图像与猫狗照片的底层特征边缘、纹理、颜色块高度通用预训练权重已经学会这些基础特征后续只需要让高阶语义特征适应猫狗分类。这样做有三点好处收敛更快训练轮次可以直接减半需要的数据量更少以及最后得到的模型泛化效果更好也就是验证集准确率更高。3.2 数据加载与增强torchvision transforms 的典型配置PyTorch 处理图片数据最常用datasets.ImageFolder它要求目录按类别组织。对应课程作业文件夹结构应该是train/dog/*.jpg和train/cat/*.jpg验证集同理。数据增强要写在数据集的 transform 里而不是模型里。这样每个 epoch 加载图片时都会做一次新的随机变换相当于训练数据在不停变化。from torchvision import models, transforms from torchvision.datasets import ImageFolder train_transform transforms.Compose([ transforms.RandomResizedCrop(224), # 随机裁剪并缩放至224x224 transforms.RandomHorizontalFlip(), # 有50%的概率水平翻转 transforms.ColorJitter(0.2, 0.2, 0.2), # 亮度、对比度、饱和度扰动 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(256), # 先放大到256 transforms.CenterCrop(224), # 中心裁剪到224 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_set ImageFolder(data/catdog/train, transformtrain_transform) val_set ImageFolder(data/catdog/val, transformval_transform)RandomResizedCrop(224)随机选取图片的一部分并缩放到正方形相当于模拟物体在画面中不同位置和远近的尺度。水平翻转对猫狗分类是安全的因为猫的左右镜像仍然是猫不会改变类别语义。ColorJitter 的三个颜色数值分别表示亮度、对比度、饱和度的随机扰动幅度0.2 是一个比较克制的取值如果太大图片颜色会不自然。验证集不应该加入随机增强只用 Resize 和 CenterCrop 保证每张图最终大小一致这样评估指标在不同实验之间才可比较。3.3 微调 ResNet18冻结参数与分层学习率加载预训练模型后有两种微调策略。一是把前面的层全部冻结只训练新加的全连接层二是先训练全连接层若干轮再解冻浅层所有参数以更小的学习率继续训练。第一种策略适合每类图片只有几百张的情况第二种策略在猫狗这份作业上更常用因为数据量相对充足解冻全部参数后模型能学到更贴合猫狗数据集的分布。model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 2) # 解冻全部参数但把学习率区分开 optimizer torch.optim.Adam([ {params: model.conv1.parameters(), lr: 1e-5}, {params: model.bn1.parameters(), lr: 1e-5}, {params: model.layer1.parameters(), lr: 1e-5}, {params: model.layer2.parameters(), lr: 1e-5}, {params: model.layer3.parameters(), lr: 1e-4}, {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], lr1e-4)这里给不同层配了不同学习率靠近输入的层负责通用边缘纹理特征保持很小的更新幅度靠近输出的层负责抽象语义特征用更大的学习率快速适应新任务。layer3和layer4的参数更新速度处在中间。如果全部层都用相同的 1e-4 学习率浅层容易破坏预训练学到的低频纹理信息导致收敛变慢。实际训练时可以用早停法验证集准确率连续 3 轮不上升就保存当前模型并停止防止后期过拟合。通常 5~10 个 epoch 就能把验证集准确率做到 95% 以上这比从头训练一个大网络省下至少一半的时间。3.4 训练过程中的监控指标分类任务不能只看准确率。样本不均衡时准确率会有欺骗性比如 90% 是狗时全部预测成狗也能拿到 90% 准确率。猫狗分类两类数量接近但实际数据集里可能不完全一样所以要在训练时同时记录 loss、accuracy、precision 和 recall。推荐用 sklearn 的classification_report快速打印每个类别的指标。from sklearn.metrics import classification_report # preds_all 和 labels_all 是验证集所有预测和真实标签的列表 print(classification_report(labels_all, preds_all, target_names[cat, dog]))每轮训练结束输出这个报告可以看到猫被误判成狗和狗被误判成猫的比例是否对称。如果猫的召回率明显低说明模型倾向于把不明确的图片判为狗这时可以增加猫类的图片数量或调整类别权重。表格里记录每轮的 train loss、val loss、val acc是实验报告最直观的数据轮次train lossval lossval acc备注10.5120.40283.2%fc 层训练30.2730.21192.4%全部层微调50.2180.18294.8%早停前最优4. 自动写诗与情感分析从字符到语义的文本建模4.1 自动写诗字符级 LSTM 与生成策略自动写诗任务的核心是给模型一段前文让它预测下一个字符。诗词文本数据通常只有几万字到几十万字直接学词级别的语言模型容易面临数据稀疏问题。所以最稳妥的做法是字符级建模把一首诗拆成单个汉字加上标点作为序列。比如“床前明月光”对应每个字符依次输入模型输出为下一个字符的概率分布。# 文本预处理建立字符到id的映射 text open(poems.txt, encodingutf-8).read() chars sorted(set(text)) stoi {ch: i for i, ch in enumerate(chars)} itos {i: ch for i, ch in enumerate(chars)} def text_to_sequence(text, seq_len32): ids [stoi[ch] for ch in text] X, y [], [] for i in range(len(ids) - seq_len): X.append(ids[i:iseq_len]) y.append(ids[iseq_len]) # 预测下一个字符 return torch.tensor(X), torch.tensor(y)seq_len是时间步长度示例取 32 个字符。这里有个容易忽略的点y不是序列的最后一个位置而是每个输入序列后真实出现的那个字符所以在循环里让i从 0 走到len(ids)-seq_len-1才能保证每个样本都有对应的标签。字符级模型的好处是词汇表最多几千个不会遇到未登录词的问题。代价是序列长度比较长训练时梯度需要跨 32 个时间步回传容易出现梯度消失。LSTM 的核心设计就是对抗梯度消失。它的记忆单元允许信息在多个时间步间保持所以诗词这类短文本用单层 LSTM 就够没必要堆多层否则参数增多且训练变慢。定义一个最精简的字符级 LSTMimport torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size256, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # [batch, seq_len, embed_size] outputs, hidden self.lstm(emb, hidden) # 每个时间步的隐状态 return self.fc(outputs), hidden这里batch_firstTrue让输入张量形状是[batch, seq_len, embed_size]与阅读习惯一致。如果不设置默认第一维是 seq_len后续切分时间步容易弄混。训练时标签的形状要与输出对齐。由于输出是每个时间步对全词表的预测损失函数用交叉熵并且把序列维度拼进 batch 维度一起计算criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for x, y in train_loader: optimizer.zero_grad() logits, _ model(x) # [batch, seq_len, vocab_size] logits logits.reshape(-1, logits.size(-1)) loss criterion(logits, y.reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()clip_grad_norm_是文本生成任务的必修课。梯度范数超过 5 时会被缩放防止单个异常样本导致参数剧烈震荡。这个值不是越大越好太小会让收敛变慢5.0 是常见起点。训练完成的模型可以生成诗句生成时每次把当前字符输入网络取 softmax 概率用带温度的随机采样来增加多样性def sample_char(model, stoi, itos, seed春, length32, temperature0.8): model.eval() chars [seed] hidden None x torch.tensor([[stoi[seed]]]) with torch.no_grad(): for _ in range(length): logits, hidden model(x, hidden) logits logits[0, -1, :] / temperature probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, 1).item() chars.append(itos[next_id]) x torch.tensor([[next_id]]) return .join(chars)temperature控制分布的平滑程度。大于 1 时分布变均匀生成更随机小于 1 时趋向贪心生成内容更保守但可能重复。torch.multinomial从概率分布中采样比直接argmax更有变化同时也避免每次都输出最高频的“的”“是”之类。自动写诗作业的文档说明里最好把不同 temperature 的生成结果各贴几首展示这个参数对生成质量的影响。4.2 情感分析词向量加 LSTM 或 TextCNN 的分类实践情感分析的本质是文本分类。输入是一段评论或句子输出是正面/负面二分类或更细粒度的多分类。与自动写诗不同这里不需要逐字生成而是把整个句子编码成一个定长向量。最常用的基线是词嵌入加双向 LSTM再取最后时间步的隐状态过全连接层。import jieba def tokenize_and_encode(sentences, vocab, max_len50): X [] for s in sentences: words list(jieba.cut(s))[:max_len] ids [vocab.get(w, 1) for w in words] # 1 保留给UNK ids [0] * (max_len - len(ids)) # 0 是PAD X.append(ids) return torch.tensor(X)max_len是句子截断或填充的目标长度。情感评论长短差异很大50 个词足以覆盖大多数电商和电影评论的核心信息。填充时把 PAD token 放在句子后面使用pack_padded_sequence可以让 LSTM 忽略填充部分。如果不做这一步模型会对无意义的 PAD 也计算隐状态不仅浪费计算量还会稀释真实文本的特征。参数取值作用max_len50控制句子的有效长度词向量维度100预训练 word2vec 常用 100 维隐藏层尺寸128双向 LSTM 拼接后为 256 维dropout0.5全连接层前丢弃减少过拟合学习率1e-3Adam 初始值一个容易被忽略的细节是词表构建。用jieba分词后词表大小通常在 5 万以上但很多词只在训练集出现一次。建议把出现次数小于 2 的词全部替换成 UNK压缩词表规模让模型不需要记忆稀疏词的出现位置。还可以从gensim加载预训练的词向量把 Embedding 层初始化为 word2vec 权重这样模型对“不错”“优秀”这类词的语义关系已经有了先验训练起来更容易收敛。TextCNN 是另一个经典选择它在文本分类上简单且高效。原理是使用多个不同宽度的卷积核比如 2、3、4在词向量序列上滑动捕捉相邻词的局部组合特征然后做全局池化。class TextCNN(nn.Module): def __init__(self, vocab_size, embed_size, num_classes, filter_sizes[2,3,4], num_filters100): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_size)) for k in filter_sizes ]) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): emb self.embedding(x).unsqueeze(1) # [batch, 1, seq_len, embed_size] conved [torch.relu(conv(emb)).squeeze(3) for conv in self.convs] pooled [torch.max_pool1d(c_s, c_s.size(2)).squeeze(2) for c_s in conved] return self.fc(torch.cat(pooled, dim1))卷积核的高度等于embed_size所以卷积只在序列长度方向滑动。squeeze(3)去掉最后一个维度是因为卷积输出形状是[batch, num_filters, seq_len, 1]。全局最大池化提取每个特征图最显著的部分这是 TextCNN 对局部关键词敏感的保证。情感分类中“太差”“不错”这类短语会被不同宽度的卷积核捕获最大池化确保只要能匹配到的语块就会产生强信号。4.3 两者的训练共性早停与模型保存自动写诗和情感分析有一个共同点它们都会在训练后期出现过拟合。情感分析在验证集 loss 不再下降时就该停止自动写诗则需要同时观察生成质量因为 loss 下降不代表生成的诗句连贯。保存模型时别只存state_dict把词表、超参数也存成 json方便之后加载做二次实验。checkpoint { model_state: model.state_dict(), vocab: stoi, itos: itos, config: {embed_size: 128, hidden_size: 256, num_layers: 1} } torch.save(checkpoint, poem_lstm.pt)加载时先根据config重建模型再load_state_dict最后把stoi和itos传进生成函数。很多同学只保存权重结果换了环境之后词表对不上加载时报错这就是文档说明里需要重点强调的部分。5. 源代码组织与文档说明课设从“能跑”到“能展示”课程作业打分时代码质量和文档说明的重要性不亚于模型准确率。我见过不少实验准确率很高但结构混乱的提交老师翻代码找不到数据预处理在哪最后只给基础分。一个可维护的作业源码结构值得单独拿出来写。5.1 工程目录的推荐组织方式四个任务共用一个虚拟环境但每个任务保持独立入口。我的做法是deep_learning_hw/ ├── requirements.txt ├── README.md ├── mnist/ │ ├── train.py │ ├── model.py │ └── results/ ├── catdog/ │ ├── train.py │ ├── data_loader.py │ └── results/ ├── poem/ │ ├── train.py │ ├── generate.py │ └── checkpoints/ └── sentiment/ ├── train.py ├── preprocess.py └── checkpoints/requirements.txt固定主要依赖版本至少注明torch、torchvision、jieba、numpy、matplotlib。5.2 用 argparse 统一实验参数每个任务的入口脚本都应该支持命令行传参而不是靠改代码里的常量。这对老师复现实验尤其重要因为不同人的机器显存和 CPU 核心数不同batch_size 和 num_workers 需要能在命令行调整。import argparse parser argparse.ArgumentParser(descriptionMNIST training) parser.add_argument(--epochs, typeint, default5) parser.add_argument(--batch_size, typeint, default128) parser.add_argument(--lr, typefloat, default1e-3) parser.add_argument(--device, typestr, defaultcuda) args parser.parse_args()最后在训练入口打印全部参数这样实验记录里能追溯每轮结果对应的配置。同时把每条实验记录追加到一个log.md文件格式是日期 | 任务 | 参数摘要 | 验证集准确率比事后回忆强得多。5.3 文档说明的文档说明要写什么文档说明不是把代码粘贴一遍而是要回答四个问题任务是什么、怎么运行、为什么要这样设计、最终结果如何。在 README 里给出一段可以直接执行的命令cd catdog python train.py --epochs 10 --batch_size 32 --lr_fc 1e-3紧接着说明运行后会在results/下生成什么文件比如训练曲线loss_curve.png和模型文件best_model.pth。我见过最有用的文档说明会附上一张数据增强前后对比图同一张猫图经过随机裁剪、翻转、颜色抖动后的效果这比写一百字解释 RandomResizedCrop 都有说服力。对应的代码只需要在数据集里单独取一张图用 matplotlib 的subplot展示 8 个增强结果并保存。最后再用一张表格总结四个任务在验证集上的表现包括准确率、参数量、训练时间这份作业的完整度和专业度就会明显高于平均水平。本文还有配套的精品资源点击获取