三类机器学习任务落地:视频描述、DCGAN与DQN实战拆解 简介这份东南大学软件学院研究生机器学习期末大作业PDF面向机器学习、深度学习方向的研究生以及需要完成视频描述video caption课题的读者。内容围绕视频描述生成展开梳理基于Image Caption的CNNLSTM框架、S2VT序列到序列模型与光流时序建模并给出央视新闻视频数据集的整理方式、训练与验证及测试划分1900/400/100条、英文描述分词与词频阈值处理、ResNet152逐帧抽取2048维特征的实现细节。资源包内含1个PDF文件约1.51MB为课程考核封面与实验报告合订便于对照章节组织与论述深度。目前已有1242人学习下载可用于选题参考、框架梳理与写作范式借鉴。1. 从一份课程大作业看三类机器学习任务的落地差异东南大学软件学院这份研究生机器学习期末大作业本身就是一份很典型的多任务合集视频描述生成video caption、DCGAN 生成动漫头像、DQN 玩 Flappy Bird。三份作业放在一起看很有意思——它们的输入模态、训练目标和评估方式完全不同但共享同一套 PyTorch/TensorFlow 训练骨架。很多人做机器学习入门项目时习惯一股脑堆模型结构真正卡住进度的往往是数据格式设计、词表构建、状态预处理这类脏活。这篇就按任务拆解 → 数据准备 → 模型搭建 → 训练调参 → 排错验证的顺序把这三份作业里能复现的部分全部拆开讲涉及视频帧特征提取、词向量与序列化、DCGAN 的生成器判别器约定、DQN 的经验回放与 Q-target 计算。适合已经在跟吴恩达、周志华课程或李宏毅机器学习公开课准备把理论落到一个能跑起来的项目上的人。2. 视频描述任务的数据组织与词表构建2.1 JSON 标注格式与训练/验证/测试划分数据集是全班同学一起整理出来的央视新闻片段每条视频几秒到几十秒配 10 句中英文描述。存储上分两个 json一个是视频元信息一个是标注文本。元信息里最关键的字段是 video_id、Category0 时政到 9 文化共十类、url、起止时间、split 标记。字段含义典型取值video_id视频唯一标识整数如 02399Category新闻类别标签09start / end片段在原始视频中的时间浮点秒split数据集归属train / val / testsentence描述集合每条 video_id 对应 10 条划分上可用视频 2400 条英文描述 23860 条。按 0-1900 训练、1901-2300 验证、2301-2400 测试切分。这里有个容易被忽略的点切分必须按 video_id 整体切不能按视频-描述对随机切。否则同一条视频的 10 条描述可能一半进训练一半进测试模型见过画面就能背出描述验证指标会虚高得离谱。import json, random with open(video_info.json, r, encodingutf-8) as f: vids json.load(f) # 按 video_id 排序后整体切分避免同一视频跨集合泄漏 ids sorted(int(k) for k in vids.keys()) random.seed(42) random.shuffle(ids) train_ids ids[:1900] val_ids ids[1900:2300] test_ids ids[2300:2400] print(len(train_ids), len(val_ids), len(test_ids))上面这段的关键是random.shuffle(ids)打乱后一次性切而不是对描述条目打乱。参数seed固定后每次划分一致便于复现。如果发现 validation loss 明显低于 train loss八成就是切分泄漏了。2.2 分词、词频阈值与 index 双向映射英文描述先做分词句子头尾各加sos和eos。然后统计词频低于阈值的词全部替换成UNK。这份作业里阈值取 1即出现一次的词也保留最终可用单词 5085 个坏词占全部单词的 24.21%——原因很直白数据集覆盖视频太少多数复杂词只出现一次常见词反复出现。from collections import Counter counter Counter() for vid in train_ids: for sent in vids[str(vid)][sentences]: tokens [sos] sent.lower().split() [eos] counter.update(tokens) THRESH 1 vocab [w for w, c in counter.items() if c THRESH] word_to_ix {w: i 1 for i, w in enumerate(vocab)} word_to_ix[pad] 0 # 补齐用 word_to_ix[unk] len(word_to_ix) ix_to_word {i: w for w, i in word_to_ix.items()}pad固定占 0 号位因为后续 S2VT 里要按 batch 内最长序列补齐unk放在词表末尾。word_to_ix和ix_to_word这一对双向映射是词向量提取的前置步骤word_to_ix负责文本到数字序列的编码ix_to_word负责推理阶段把模型输出的索引还原成单词。训练用的词表只能用训练集统计出来的验证和测试阶段遇到新词统一走unk不然评估结果不可信。提示词频阈值不要一上来就设大。阈值 1 保留全部词表能保证召回坏词率高的问题应该在训练时用 embedding 的unk向量吸收而不是靠砍词表解决。3. ResNet152 视频帧特征提取与 S2VT 模型搭建3.1 用预训练 ResNet152 抽取 [40, 2048] 帧特征视频描述的核心难点是一段视频里有时序信息、有声音信息信息量远超单张图。这份作业的做法是把视频拆帧随机挑 40 帧每帧过一遍预训练的 ResNet152152 层ImageNet 上训练每张图得到一个 2048 维特征向量最终一个视频就是[40, 2048]的 numpy 数组。import torch, numpy as np from torchvision import models, transforms from PIL import Image resnet models.resnet152(weightsmodels.ResNet152_Weights.IMAGENET1K_V1) resnet.fc torch.nn.Identity() # 去掉最后分类层输出 2048 维 resnet.eval().cuda() prep transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) def extract(video_path, n_frames40): frames sample_frames(video_path, n_frames) # 均匀或随机采样 40 帧 batch torch.stack([prep(Image.open(p).convert(RGB)) for p in frames]).cuda() with torch.no_grad(): feats resnet(batch) # [40, 2048] return feats.cpu().numpy() np.save(feat/vid_0001.npy, extract(raw/vid_0001.mp4))resnet.fc Identity()是把最后那层 1000 类分类头换成空操作直接暴露全局池化后的 2048 维。no_grad()很关键特征提取只前向不反向能省掉一大半显存。Normalize的三个均值和方差是 ImageNet 的统计值不替换成自定义值会让预训练权重失效。特征以numpy.ndarray存盘而不是在线抽帧是因为训练阶段要遍历几十上百轮重复解码视频太慢离线抽特征一次即可。3.2 S2VT 双层 LSTM 的 encoder-decoder 结构S2VTSequence to Sequence Video to Text出自 2015 年 CVPR用两个 LSTM 叠加做端到端视频-文本建模。模型使用两层 RNN每层 512 个隐藏单元。第一层 LSTM 逐个读入帧特征做编码把隐藏状态 h 传给第二层第二层在编码阶段把 h 与零填充符拼接后编码此时不计算损失。所有帧读完第二层送入BOS开始解码逐个词生成句子。import torch.nn as nn class S2VT(nn.Module): def __init__(self, vocab_size, dim512, feat_dim2048): super().__init__() self.enc nn.LSTM(feat_dim, dim, batch_firstTrue) self.dec nn.LSTM(dim 300, dim, batch_firstTrue) # 300 为词向量维度 self.proj nn.Linear(dim, vocab_size) def forward(self, feats, captions): # feats: [B, 40, 2048]captions: [B, T] enc_out, _ self.enc(feats) # 编码 40 帧 pad torch.zeros(feats.size(0), enc_out.size(1), self.dec.input_size - enc_out.size(2), devicefeats.device) enc_in torch.cat([enc_out, pad], dim2) _, _ self.dec(enc_in) # 训练阶段不产生输出 # 解码输入 BOS 已生成词 dec_out, _ self.dec(caption_embed(captions)) return self.proj(dec_out) # [B, T, vocab_size]enc的输入维度 2048 对应 ResNet152 输出dec的输入把 512 维隐藏状态和词向量拼起来这就是论文里隐藏状态与零填充符相连的实现。训练时用的 log-likelihood 目标就是上面proj输出的 logits 经过 softmax 后对真实词的负对数似然求和随机梯度下降去最大化似然。推理阶段没有 teacher forcing必须自己回读上一步输出所以生成质量对早期错误很敏感。3.3 训练损失与推理的差异训练目标是在已知帧序列隐藏状态和之前输出单词的条件下求预测句子的对数似然整个数据集上跑随机梯度下降。第二层 LSTM 的输出 z 通过在词汇库 V 里找最大可能性的词 y 作为预测。阶段解码输入是否计算 loss是否回读自身输出训练真实上一词是否teacher forcing推理上一时刻预测词否是编码帧特征 零填充否不适用这张表解释了为什么训练 loss 降得不错推理结果却偏含糊作业里模型描述画面与描述基本相符但在细节上缺乏描述就是这个原因造成的。缓解做法是训练后期逐步降低 teacher forcing 比例让模型逐渐适应自己的输出分布。4. DCGAN 生成动漫头像的网络约定4.1 生成器与判别器的结构约束GAN 的本质是二人零和博弈生成器 G 接收随机噪声 z 生成假样本判别器 D 判断输入是真是假输出一个 0 到 1 的概率。D 来自真实样本置 1来自 G 置 0。理论上收敛时 D(G(z))0.5D 已经分不清真假。DCGAN 把 G 和 D 都换成卷积网络并做了五条约定去掉所有 pooling 层、D 和 G 都用 batch normalization、去掉全连接层变全卷积、G 用 ReLU 末层用 tanh、D 用 LeakyReLU。生成器输入是 100 维均匀分布噪声区间 [-1,1]class Generator(nn.Module): def __init__(self, z_dim100, base64): super().__init__() self.net nn.Sequential( nn.ConvTranspose2d(z_dim, base * 8, 4, 1, 0), # 100 - 4x4 nn.BatchNorm2d(base * 8), nn.ReLU(True), nn.ConvTranspose2d(base * 8, base * 4, 4, 2, 1), # - 8x8 nn.BatchNorm2d(base * 4), nn.ReLU(True), nn.ConvTranspose2d(base * 4, base * 2, 4, 2, 1), # - 16x16 nn.BatchNorm2d(base * 2), nn.ReLU(True), nn.ConvTranspose2d(base * 2, base, 4, 2, 1), # - 32x32 nn.BatchNorm2d(base), nn.ReLU(True), nn.ConvTranspose2d(base, 3, 4, 2, 1), # - 64x64 nn.Tanh(), # 输出 [-1, 1] ) def forward(self, z): return self.net(z.view(z.size(0), -1, 1, 1))ConvTranspose2d就是转置卷积用来做上采样。kernel4、stride2、padding1 这个组合能把特征图边长精确翻倍4→8→16→32→64。第一层全连接 100→1024 后 reshape 成 4×4×1024 的特征图后续通道数逐层减半、尺寸翻倍这是 DCGAN 论文里的标准配比。末层Tanh输出范围 [-1,1]与噪声输入区间对应。判别器则是无 pooling 的全卷积网络用带 stride 的普通卷积代替池化class Discriminator(nn.Module): def __init__(self, base64): super().__init__() self.net nn.Sequential( nn.Conv2d(3, base, 4, 2, 1), nn.LeakyReLU(0.2, True), nn.Conv2d(base, base * 2, 4, 2, 1), nn.BatchNorm2d(base * 2), nn.LeakyReLU(0.2, True), nn.Conv2d(base * 2, base * 4, 4, 2, 1), nn.BatchNorm2d(base * 4), nn.LeakyReLU(0.2, True), nn.Conv2d(base * 4, 1, 4, 1, 0), nn.Sigmoid(), # 输出标量概率 ) def forward(self, x): return self.net(x).view(-1)LeakyReLU(0.2)的负斜率让梯度在负区间也能传导避免 ReLU 死区导致 D 完全没梯度。最后一层卷积把 4×4 特征压成 1×1再过 Sigmoid 得到 0 到 1 的概率值。D 的输入维度和 G 的输出保持一致都是 64×64×3。4.2 对抗训练循环与损失平衡数据集是老师给的 faces 文件夹5 万多张动漫头像约 275M。训练里 G 想尽量骗过 DD 想尽量区分真假两者交替更新。for epoch in range(200): for real_imgs, _ in dataloader: real_imgs real_imgs.cuda() bs real_imgs.size(0) # ---- 更新 D ---- z torch.rand(bs, 100, 1, 1, devicecuda) * 2 - 1 fake G(z).detach() loss_d (criterion(D(real_imgs), torch.ones(bs, devicecuda)) criterion(D(fake), torch.zeros(bs, devicecuda))) / 2 opt_d.zero_grad(); loss_d.backward(); opt_d.step() # ---- 更新 G ---- out D(G(z)) loss_g criterion(out, torch.ones(bs, devicecuda)) opt_g.zero_grad(); loss_g.backward(); opt_g.step()G(z).detach()在更新 D 时截断梯度防止判别器的梯度污染生成器。更新 G 时标签故意设成 1是让 G 朝骗过 D的方向优化。*2 - 1把 [0,1] 的随机数映射到 [-1,1]和生成器 Tanh 输出范围对齐。作业里 1 个 epoch 时结果模糊20 个 epoch 慢慢清晰200 个 epoch 后才以假乱真这条曲线说明 DCGAN 对训练轮次和超参很敏感学习率通常设 2e-4Adam 的 beta1 设 0.5 会比默认值稳。注意如果 loss_d 迅速趋近 0说明 D 太强、G 梯度消失可以把 D 的学习率减半或者给 D 的标签做单侧平滑真实样本标签用 0.9 而不是 1。5. DQN 玩 Flappy Bird 的状态预处理与训练5.1 84×84×4 状态图像的处理流水线Flappy Bird 直接输出的像素是 284×512为了省内存缩到 84×84。更关键的是去背景——原图背景是蓝天白云纯噪声对决策没用用纯黑替换掉。然后灰度化、调亮度再把连续 4 帧叠成 80×80×4 作为一次输入。import cv2, numpy as np def preprocess(frame): frame cv2.resize(frame, (84, 84)) frame cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 用颜色阈值分离背景天空/白云置黑 mask frame 200 frame[mask] 0 return frame.astype(np.float32) / 255.0 class StateStacker: def __init__(self, k4): self.k, self.buf k, [] def push(self, f): self.buf.append(f) if len(self.buf) self.k: self.buf.pop(0) return np.stack(self.buf, axis0) if len(self.buf) self.k else None叠 4 帧的意义在于给出小鸟的运动轨迹信息单帧无法判断小鸟在上升还是下降。叠帧时越旧的帧灰度略微降低远离最新帧强度降低这样卷积网络能自然区分时间顺序。preprocess里的阈值 200 是经验值天空和云通常都在这个亮度区间具体数值要看游戏渲染颜色可以先 dump 几帧看看直方图再定。5.2 经验回放与 Q-target 计算DQN 的本质是把 Q-Learning 的 Q-table 换成神经网络来拟合 Q-function。网络输入 80×80×4 的图像输出每个动作对应的 Q 值。作业里的智能体是 6 层网络卷积部分从 80×80×4 到 20×20×32、5×5×64、3×3×64再 flatten 成 256最后输出 2 维向上飞和下落。def _trainQNetwork(self): # Step1: 从 replay memory 里随机取一个 minibatch minibatch random.sample(self.replayMemory, BATCH_SIZE) state_batch [d[0] for d in minibatch] action_batch [d[1] for d in minibatch] reward_batch [d[2] for d in minibatch] next_state_batch [d[3] for d in minibatch] # Step2: 计算 q_target q_target [] QValue_batch self.QValue.eval(feed_dict{self.stateInput: next_state_batch}) for i in range(BATCH_SIZE): terminal minibatch[i][4] if terminal: q_target.append(reward_batch[i]) # 终止态无未来收益 else: q_target.append(reward_batch[i] GAMMA * np.max(QValue_batch[i])) self.sess.run(self.trainStep, feed_dict{ self.q_target: q_target, self.actionInput: action_batch, self.stateInput: state_batch, })这里QValue.eval传的是next_state_batch拿到下一个状态下各动作的 Q 值。terminal为 True 时不加折扣未来收益因为游戏已经结束后续状态不存在。GAMMA是折扣因子常取 0.99表示对远期奖励的权重。q_target相当于监督学习里的标签训练任务就是让网络预测的 Q 值不断逼近它。如果漏掉 terminal 判断crash 之后网络还会往未来能得分的方向优化小鸟会习惯性撞管道。游戏返回的 reward 有三种crash 时 -1越过障碍 1其他状态 0.1。这个 0.1 的持续小奖励很关键它让智能体在没得分也不会撞的中间状态有正反馈避免它学到干脆撞死。5.3 训练收敛的观察与排错训练过程中打印 reward 和 Q 值变化能快速判断状态。常见问题有三种现象可能原因处理方式reward 长期为负探索率过高或奖励设计失衡随训练衰减 epsilon检查 0.1 是否生效Q 值爆炸折扣因子过大或学习率过高GAMMA 降到 0.95学习率减半小鸟原地抖动状态叠帧不足或背景未去除确认 4 帧叠加重新校准背景阈值经验回放池replayMemory是 DQN 稳定的关键它打破样本间的时间相关性。池子太小时随机采样的 batch 里数据高度相似训练会震荡池子太大则早本文还有配套的精品资源点击获取