简介:这份资源围绕结合LSTM的encoder-decoder模型实现UCF101视频动作分类展开,面向具备一定深度学习基础、希望复现视频分类实验的研究者与开发者。内容涵盖CNN、3DCNN与预训练ResNet三种encoder方案,以及LSTM作为decoder整合时序特征、完成101类动作预测的完整流程,并涉及数据预处理、特征提取、序列编码、分类与模型评估等关键环节。压缩包共90个文件,以py与ipynb代码脚本、npy与pkl数据文件、png训练曲线与预测可视化图为主,另含少量xml、md说明及配置文件,整体约8.93MB,目录按ResNetCRNN、CRNN、Conv3D等模块组织,便于对照实验。已有272人学习下载。读者可据此复现多组对比实验,查看损失曲线与错误预测样例,理解不同encoder对时序建模的影响,并借助预训练模型与日志快速搭建自己的视频分类基线。
1. 当视频分类遇上 LSTM:为什么 encoder-decoder 不是万能钥匙
UCF101 是视频动作识别领域最常被拿来练手的基准数据集之一,13320 段短视频、101 个动作类别,涵盖打球、弹琴、化妆、爬绳等日常动作。很多人第一次做视频分类,直觉反应是「CNN 提特征 + LSTM 做时序」——这个组合确实经典,但真正动手跑起来,你会发现准确率卡在 60% 上下死活上不去,训练 loss 震荡得像心电图。问题往往不在 LSTM 本身,而在于 encoder-decoder 结构里 encoder 到底编码了什么、decoder 又解码出了什么。这篇笔记围绕「结合 LSTM 的 encoder-decoder 模型实现 UCF101 分类」这个方向,把特征提取、时序建模、训练调参、踩坑排查整条链路拆开讲清楚。适合已经跑通过图像分类、想往视频时序方向走一步的工程师,也适合正在做毕设或课程项目、需要一套能复现方案的同学。
2. 拆解 encoder-decoder:视频分类里到底谁在编码、谁在解码
2.1 视频分类为什么不能直接套图像分类
图像分类把一张 RGB 图送进 CNN,输出类别概率,整个流程是静态的。视频多了一个时间维度,同一动作在不同帧里的空间形态可能完全不同——比如「开合跳」,起始帧和最高点帧的肢体位置差异巨大,单看任何一帧都容易误判。常见做法有两种:一是把多帧堆成 3D 体素送进 3D CNN,二是用 2D CNN 逐帧提特征再用 RNN 串起来。前者参数量大、训练成本高,后者更轻量,也是 encoder-decoder 思路的出发点。
在 encoder-decoder 框架下,encoder 负责把变长视频帧序列压缩成固定维度的上下文表示,decoder 负责从这个表示里恢复出分类结果或重建序列。对于分类任务,decoder 通常就是一个全连接分类头;对于更复杂的任务(如视频描述生成),decoder 才是真正的 LSTM 解码器。这里要区分清楚:分类任务里 LSTM 更多扮演的是时序聚合器的角色,而不是生成式解码器。
2.2 encoder 选型:CNN 提特征还是端到端训练
实际工程里,encoder 有两种主流做法。第一种是离线提特征:用预训练好的 CNN(如 ResNet、Inception)对每帧提一个 2048 维或 1024 维向量,存成 npy 文件,训练时直接加载。好处是训练快、显存占用低,缺点是 CNN 权重不更新,特征可能不适应具体动作类别。第二种是端到端:CNN 和 LSTM 一起训练,梯度回传到底层。好处是特征可以微调,缺点是显存爆炸、训练慢,且容易过拟合。
我一般会先用离线特征跑通 baseline,确认 LSTM 部分没问题,再考虑是否解冻 CNN 最后几个 block 做微调。对于 UCF101 这种规模(约 1.3 万视频),离线特征 + LSTM 通常能到 70% 左右,端到端微调可以再涨 5 到 8 个点,但训练时间可能翻三倍。
# 离线特征提取示例:用预训练 ResNet 对每帧提特征 import torch import torchvision.models as models import torchvision.transforms as transforms from PIL import Image import numpy as np import os # 加载预训练 ResNet50,去掉最后的全连接层 resnet = models.resnet50(pretrained=True) resnet = torch.nn.Sequential(*list(resnet.children())[:-1]) # 输出 2048 维 resnet.eval().cuda() # 图像预处理:统一尺寸和归一化 preprocess = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def extract_features(frame_dir, output_path): """对单个视频的所有帧提特征,保存为 npy""" frames = sorted(os.listdir(frame_dir)) feats = [] with torch.no_grad(): for fname in frames: img = Image.open(os.path.join(frame_dir, fname)).convert('RGB') tensor = preprocess(img).unsqueeze(0).cuda() feat = resnet(tensor).squeeze().cpu().numpy() # (2048,) feats.append(feat) np.save(output_path, np.array(feats)) # 形状 (T, 2048)这段代码的关键点:resnet.children()[:-1]去掉了原始的全连接分类层,保留全局平均池化后的 2048 维输出。torch.no_grad()关闭梯度计算,节省显存。每个视频保存成一个(T, 2048)的数组,T 是帧数,后续 LSTM 输入就是按这个格式组织。注意帧的排序要用sorted,否则时序会乱。
2.3 LSTM 时序聚合:hidden state 怎么传、序列怎么截断
LSTM 处理变长序列时,核心参数是input_size、hidden_size、num_layers。input_size必须等于 CNN 特征维度(如 2048),hidden_size一般取 256 或 512,num_layers从 1 层开始试,2 层以上容易过拟合。UCF101 视频长度差异大,短的几十帧,长的上千帧,直接送进 LSTM 会导致显存和计算量不可控。常见做法是均匀采样固定帧数,比如每个视频采 16 帧或 32 帧。
# LSTM 分类模型定义 import torch.nn as nn class VideoLSTMClassifier(nn.Module): def __init__(self, input_size=2048, hidden_size=512, num_layers=2, num_classes=101, dropout=0.5): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, # 输入形状 (B, T, D) dropout=dropout if num_layers > 1 else 0, bidirectional=False # 单向即可,双向对分类有提升但更慢 ) self.classifier = nn.Sequential( nn.Dropout(dropout), nn.Linear(hidden_size, num_classes) ) def forward(self, x): # x: (B, T, 2048) lstm_out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的输出做分类 last_out = lstm_out[:, -1, :] # (B, hidden_size) return self.classifier(last_out)这里batch_first=True让输入维度变成(batch, time, feature),更符合直觉。取lstm_out[:, -1, :]是常见做法,也可以用所有时间步输出的平均池化。dropout在 LSTM 内部只在多层之间生效,分类头前再加一个 Dropout 层防止过拟合。如果显存够,可以试bidirectional=True,但推理时需要完整序列,不适合在线场景。
2.4 数据管道:帧采样策略与 batch 组织
UCF101 原始数据是 avi 视频,需要先解帧。常见做法是每个视频均匀采 16 帧,存成图像文件夹,再用上面的脚本提特征。训练时,把所有视频的特征数组和标签做成 Dataset,每个 batch 内对齐到相同帧数(不足的重复最后一帧或补零)。
from torch.utils.data import Dataset, DataLoader import numpy as np class UCF101FeatureDataset(Dataset): def __init__(self, feat_dir, label_map, num_frames=16): self.samples = [] # (feat_path, label) self.num_frames = num_frames for cls_name, label in label_map.items(): cls_dir = os.path.join(feat_dir, cls_name) for fname in os.listdir(cls_dir): self.samples.append((os.path.join(cls_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] feats = np.load(path) # (T, 2048) T = feats.shape[0] if T >= self.num_frames: # 均匀采样 indices = np.linspace(0, T - 1, self.num_frames).astype(int) else: # 不足则重复最后一帧 indices = list(range(T)) + [T - 1] * (self.num_frames - T) feats = feats[indices] # (num_frames, 2048) return torch.FloatTensor(feats), label采样策略直接影响模型看到的信息量。均匀采样能覆盖整个动作过程,但如果动作集中在某几帧,可能会漏掉关键信息。另一种做法是密集采样后随机裁剪连续 16 帧,增加数据多样性。num_frames设 16 还是 32 需要实验,32 帧通常更准但训练更慢。
3. 训练流程与参数配置:从 loss 曲线判断模型状态
3.1 损失函数与优化器选择
UCF101 是单标签多分类任务,标准做法是交叉熵损失。优化器首选 Adam,学习率从 1e-3 开始,配合ReduceLROnPlateau在验证集 loss 不降时衰减。如果过拟合严重,可以换 SGD + momentum,泛化有时更好但收敛慢。
import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model = VideoLSTMClassifier().cuda() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5, verbose=True) # 训练循环骨架 for epoch in range(50): model.train() for feats, labels in train_loader: feats, labels = feats.cuda(), labels.cuda() optimizer.zero_grad() logits = model(feats) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() # 验证 model.eval() val_loss = 0.0 correct = 0 with torch.no_grad(): for feats, labels in val_loader: feats, labels = feats.cuda(), labels.cuda() logits = model(feats) val_loss += criterion(logits, labels).item() correct += (logits.argmax(1) == labels).sum().item() val_loss /= len(val_loader) acc = correct / len(val_dataset) scheduler.step(val_loss) print(f"Epoch {epoch}: val_loss={val_loss:.4f}, acc={acc:.4f}")weight_decay=1e-4是 L2 正则,抑制权重过大。clip_grad_norm_对 LSTM 特别重要,因为反向传播穿过时间步时梯度容易爆炸,表现为 loss 突然变成 nan。max_norm=5.0是经验值,太小会限制学习,太大等于没裁。
3.2 学习率与 batch size 的配合
学习率和 batch size 是联动的。batch size 越大,梯度估计越稳,可以用更大的学习率。单卡 11GB 显存下,LSTM 输入 16 帧 × 2048 维,batch size 设 32 或 64 比较合适。如果 OOM,优先降 batch size 而不是帧数,因为帧数影响时序信息量。
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 学习率 | 1e-3 ~ 1e-4 | Adam 从 1e-3 开始,不降就手动降 |
| batch size | 32 ~ 64 | 受显存限制,太小梯度噪声大 |
| hidden_size | 256 ~ 512 | 太小欠拟合,太大过拟合 |
| num_layers | 1 ~ 2 | 超过 2 层收益递减 |
| dropout | 0.3 ~ 0.5 | 分类头前必加 |
| 帧数 | 16 ~ 32 | 32 帧更准但更慢 |
3.3 从 loss 曲线诊断问题
训练时重点看两条曲线:训练 loss 和验证 loss。如果训练 loss 持续下降但验证 loss 先降后升,是过拟合,加 dropout 或减模型容量。如果两条都降不下去,是欠拟合,检查特征质量或增大 hidden_size。如果 loss 震荡剧烈,降学习率或增大 batch size。如果 loss 变成 nan,检查梯度裁剪和输入是否有异常值。
4. 避坑与排查:UCF101 训练中最容易翻车的五个地方
4.1 准确率卡在 1% 不动
现象:训练几个 epoch 后,验证准确率始终在 1% 左右,相当于随机猜。
原因:最常见的是标签映射错乱。UCF101 的类别文件夹名和标签索引没对上,或者 DataLoader 里label_map的构建顺序和特征文件夹不一致。
解决:打印一个 batch 的标签和对应文件夹名,人工核对。确保label_map是用sorted(os.listdir())生成的,训练和验证用同一份映射。
4.2 loss 突然变成 nan
现象:训练到一半 loss 突然变成 nan,之后所有输出都是 nan。
原因:LSTM 梯度爆炸,或者输入特征里有 inf/nan。离线提特征时如果某帧读取失败,可能产生异常值。
解决:加梯度裁剪clip_grad_norm_(model.parameters(), 5.0);提特征后加一步np.nan_to_num(feats)清洗;学习率降一个数量级重跑。
4.3 验证集准确率远高于训练集
现象:训练集准确率 60%,验证集 75%,反常。
原因:训练时忘了加model.train(),dropout 没生效;或者验证集太小,统计波动大。
解决:确认训练循环里有model.train(),验证时有model.eval()。检查验证集划分是否均匀,每个类别都要有样本。
4.4 显存不够用
现象:batch size 降到 8 还是 OOM。
原因:LSTM 的num_layers太多,或者帧数设太大,或者 CNN 端到端训练没冻结底层。
解决:先降num_layers到 1,再降帧数到 8,最后考虑用梯度累积模拟大 batch。端到端训练时冻结 CNN 前几层。
4.5 推理时结果和验证不一致
现象:验证集准确率 70%,但单独拿一个视频推理,结果乱跳。
原因:推理时忘了model.eval(),dropout 还在随机丢弃;或者帧采样方式和训练不一致。
解决:推理前加model.eval()和torch.no_grad();确保推理时的帧采样逻辑和 Dataset 里完全一致,最好抽成同一个函数。
5. 进阶技巧:把 UCF101 准确率从 70% 推到 85% 的实操路径
5.1 光流双流:给 LSTM 补上运动信息
RGB 帧提供外观信息,光流提供运动信息,两者互补。常见做法是训练两个 encoder,一个吃 RGB 特征,一个吃光流特征,在 LSTM 前或 LSTM 后融合。光流可以用 TV-L1 或 FlowNet 预计算,存成和 RGB 特征对应的 npy。融合方式有早期融合(特征拼接)和晚期融合(分数平均),晚期融合实现简单且稳定,适合先跑通。
# 双流晚期融合推理示例 rgb_logits = rgb_model(rgb_feats) # (B, 101) flow_logits = flow_model(flow_feats) # (B, 101) # 加权平均,权重根据验证集调 final_logits = 0.6 * rgb_logits + 0.4 * flow_logits preds = final_logits.argmax(1)权重 0.6/0.4 是经验起点,实际用验证集网格搜索。光流特征维度通常也是 2048,但计算成本高,建议离线算好存盘。
5.2 注意力池化替代 last hidden state
取 LSTM 最后一个时间步的输出,隐含假设是「最后一步包含所有信息」。但视频动作的关键帧可能在中段,注意力池化能让模型自己决定关注哪些时间步。
class AttentionPooling(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn = nn.Linear(hidden_size, 1) def forward(self, lstm_out): # lstm_out: (B, T, H) scores = self.attn(lstm_out).squeeze(-1) # (B, T) weights = torch.softmax(scores, dim=1) # (B, T) pooled = torch.bmm(weights.unsqueeze(1), lstm_out).squeeze(1) return pooled # (B, H)把last_out换成AttentionPooling的输出,通常能涨 2 到 4 个点。注意力权重还可以可视化,看模型关注哪些帧,辅助排查。
5.3 数据增强:帧级和时序级都要做
帧级增强包括随机裁剪、颜色抖动、水平翻转,直接加在提特征前的预处理里。时序级增强包括随机采样起始点、随机丢帧、时间反转。时间反转对对称动作(如开合跳)有效,但对方向敏感动作(如投篮)可能有害,需要按类别筛选。
# 时序增强:随机起始点采样 def random_sample(feats, num_frames): T = feats.shape[0] if T <= num_frames: indices = list(range(T)) + [T-1] * (num_frames - T) else: start = np.random.randint(0, T - num_frames) indices = list(range(start, start + num_frames)) return feats[indices]训练时用随机采样,验证和推理时用均匀采样,保证可复现。
5.4 用混淆矩阵定位难分类别
准确率是全局指标,但哪些类别容易混,要看混淆矩阵。UCF101 里「刷牙」和「涂口红」都涉及手部靠近面部,「跳绳」和「开合跳」都有跳跃动作,容易互相误判。定位到具体类别后,可以针对性补充数据或调整采样帧数。
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for feats, labels in val_loader: preds = model(feats.cuda()).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) # 只看错误最多的前 10 个类别 top_errors = np.argsort(cm.sum(1) - cm.diagonal())[-10:] sns.heatmap(cm[top_errors][:, top_errors], annot=True, fmt='d') plt.savefig('confusion_top10.png')我自己的习惯是每跑完一轮完整训练,先看混淆矩阵再决定下一步调什么。如果某两个类别互相误判超过 30%,单独把这两个类别的视频抽出来看帧,往往能发现标注问题或采样问题。这套流程跑下来,UCF101 从 70% 推到 85% 是可行的,但每一步都要做消融实验,确认涨点来自哪里,而不是碰运气。希望帮到你。
本文还有配套的精品资源,点击获取