深度学习作业实战:PyTorch实现MNIST、猫狗分类、写诗与情感分析
2026/9/10 2:27:22 网站建设 项目流程

简介:包含四个独立完整的国科大深度学习课程实践项目:手写数字识别、猫狗分类、自动写诗、情感分析,均附带可运行的Python源代码与实验报告。各项目按模块组织,涵盖数据加载、模型构建、训练测试等核心流程,便于快速定位和二次修改,适合计算机、人工智能等相关专业学生作为课程作业或入门实操参考。资源为zip压缩包,共76个文件,以py/pyc源码与运行文件、PDF实验报告、PNG结果图、md/txt说明文档为主,便于阅读与复用,整体大小约16.73MB,结构清晰且轻量。目前已有466人学习下载,代码均测试通过,答辩评审平均分达96分,可在其基础上扩展功能,或直接运行体验完整流程。对于希望系统理解图像分类、文本生成与情感分析链路的初学者,这套项目示例能提供从数据预处理到模型评估的一站式参考。

1. 国科大深度学习课程作业:四个任务其实是两套最小闭环

手写数字体识别、猫狗分类、自动写诗、情感分析,这四个任务经常被国科大深度学习课程打包成一次大作业。表面上是四个独立实验,实际上它们只覆盖了两条主线路:图像分类与文本序列建模。手写数字识别让你理解卷积怎样逐层提取局部特征;猫狗分类则把问题推到真实图片的尺度,逼迫你处理过拟合和数据增强;自动写诗引入隐状态和时间步,让模型学会生成序列;情感分析又回到分类,只是输入变成了变长文本。对初学者来说,把这一组作业完整跑通,等于动手实践了 CNN、迁移学习、RNN/LSTM、词向量四大块内容,也正是深度学习入门最常见的四条路径。这篇文章不假设你有现成源码,我会按自己习惯的组织方式,从数据加载、模型定义、训练循环到文档说明,把每个任务的最小可复现方案写清楚,让你拿到的不仅是一份能跑的代码,还能看懂每个参数为什么这样设。

2. 手写数字体识别:用 MNIST 打通数据管线与卷积网络

2.1 数据加载与 LeNet 变体的最小实现

手写数字识别最经典的基准是 MNIST,28×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', train=True, download=True, transform=transform_mnist) test_data = datasets.MNIST(root='./data', train=False, transform=transform_mnist) train_loader = DataLoader(train_data, batch_size=128, shuffle=True, num_workers=2) test_loader = DataLoader(test_data, batch_size=256, shuffle=False) class LeNetLike(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size=5), 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(), lr=1e-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(f"epoch {epoch+1}: loss={running_loss/len(train_loader):.4f}, acc={acc:.2%}")

注意zero_grad()必须放在 forward 之前,否则梯度会跨 batch 累积。PyTorch 的.backward()会把计算图的梯度写入各张量的.grad,不清零的话每个 batch 的梯度会叠加,导致优化方向完全错误。num_workers=2表示用两个子进程预取数据,在 Windows 上如果遇到死锁,可以改成 0。这个任务的训练时间在 CPU 上也很短,5 轮就足以达到 98% 以上准确率,这正是它适合做第一个作业的原因:不依赖 GPU,就能把调参、断点、日志等工程习惯养好。

2.2 输出概率与错误样本的检查方法

计算准确率时,常见错误是把torch.max(logits, 1)的索引直接与标签比较,这没问题,但不能只盯着准确率。课程作业通常要求分析模型在哪些样本上出错。一个简单做法是把测试集里预测错误的前 10 张图连同预测值、真实值导出成文件。

import matplotlib.pyplot as plt def collect_misclassified(model, loader, limit=10): model.eval() kept = [] with torch.no_grad(): for x, y in loader: pred = model(x).argmax(dim=1) 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 kept

model.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/*.jpgtrain/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', transform=train_transform) val_set = ImageFolder('data/catdog/val', transform=val_transform)

RandomResizedCrop(224)随机选取图片的一部分并缩放到正方形,相当于模拟物体在画面中不同位置和远近的尺度。水平翻转对猫狗分类是安全的,因为猫的左右镜像仍然是猫,不会改变类别语义。ColorJitter 的三个颜色数值分别表示亮度、对比度、饱和度的随机扰动幅度,0.2 是一个比较克制的取值,如果太大图片颜色会不自然。验证集不应该加入随机增强,只用 Resize 和 CenterCrop 保证每张图最终大小一致,这样评估指标在不同实验之间才可比较。

3.3 微调 ResNet18:冻结参数与分层学习率

加载预训练模型后,有两种微调策略。一是把前面的层全部冻结,只训练新加的全连接层;二是先训练全连接层若干轮,再解冻浅层所有参数以更小的学习率继续训练。第一种策略适合每类图片只有几百张的情况,第二种策略在猫狗这份作业上更常用,因为数据量相对充足,解冻全部参数后模型能学到更贴合猫狗数据集的分布。

model = models.resnet18(weights=models.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}, ], lr=1e-4)

这里给不同层配了不同学习率:靠近输入的层负责通用边缘纹理特征,保持很小的更新幅度;靠近输出的层负责抽象语义特征,用更大的学习率快速适应新任务。layer3layer4的参数更新速度处在中间。如果全部层都用相同的 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', encoding='utf-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_len=32): ids = [stoi[ch] for ch in text] X, y = [], [] for i in range(len(ids) - seq_len): X.append(ids[i:i+seq_len]) y.append(ids[i+seq_len]) # 预测下一个字符 return torch.tensor(X), torch.tensor(y)

seq_len是时间步长度,示例取 32 个字符。这里有个容易忽略的点:y不是序列的最后一个位置,而是每个输入序列后真实出现的那个字符,所以在循环里让i从 0 走到len(ids)-seq_len-1才能保证每个样本都有对应的标签。字符级模型的好处是词汇表最多几千个,不会遇到未登录词的问题。代价是序列长度比较长,训练时梯度需要跨 32 个时间步回传,容易出现梯度消失。

LSTM 的核心设计就是对抗梯度消失。它的记忆单元允许信息在多个时间步间保持,所以诗词这类短文本用单层 LSTM 就够,没必要堆多层,否则参数增多且训练变慢。定义一个最精简的字符级 LSTM:

import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_size=128, hidden_size=256, num_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden=None): emb = self.embedding(x) # [batch, seq_len, embed_size] outputs, hidden = self.lstm(emb, hidden) # 每个时间步的隐状态 return self.fc(outputs), hidden

这里batch_first=True让输入张量形状是[batch, seq_len, embed_size],与阅读习惯一致。如果不设置,默认第一维是 seq_len,后续切分时间步容易弄混。训练时标签的形状要与输出对齐。由于输出是每个时间步对全词表的预测,损失函数用交叉熵,并且把序列维度拼进 batch 维度一起计算:

criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-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='春', length=32, temperature=0.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_len=50): 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_filters=100): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) 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, dim=1))

卷积核的高度等于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,最后把stoiitos传进生成函数。很多同学只保存权重,结果换了环境之后词表对不上,加载时报错,这就是文档说明里需要重点强调的部分。

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固定主要依赖版本,至少注明torchtorchvisionjiebanumpymatplotlib

5.2 用 argparse 统一实验参数

每个任务的入口脚本都应该支持命令行传参,而不是靠改代码里的常量。这对老师复现实验尤其重要,因为不同人的机器显存和 CPU 核心数不同,batch_size 和 num_workers 需要能在命令行调整。

import argparse parser = argparse.ArgumentParser(description='MNIST training') parser.add_argument('--epochs', type=int, default=5) parser.add_argument('--batch_size', type=int, default=128) parser.add_argument('--lr', type=float, default=1e-3) parser.add_argument('--device', type=str, default='cuda') 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 个增强结果并保存。最后再用一张表格总结四个任务在验证集上的表现,包括准确率、参数量、训练时间,这份作业的完整度和专业度就会明显高于平均水平。

本文还有配套的精品资源,点击获取

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

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

立即咨询