尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于LSTM的encoder-decoder实现UCF101视频分类实战

基于LSTM的encoder-decoder实现UCF101视频分类实战 简介这份资源围绕结合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(pretrainedTrue) 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 特征维度如 2048hidden_size一般取 256 或 512num_layers从 1 层开始试2 层以上容易过拟合。UCF101 视频长度差异大短的几十帧长的上千帧直接送进 LSTM 会导致显存和计算量不可控。常见做法是均匀采样固定帧数比如每个视频采 16 帧或 32 帧。# LSTM 分类模型定义 import torch.nn as nn class VideoLSTMClassifier(nn.Module): def __init__(self, input_size2048, hidden_size512, num_layers2, num_classes101, dropout0.5): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, # 输入形状 (B, T, D) dropoutdropout if num_layers 1 else 0, bidirectionalFalse # 单向即可双向对分类有提升但更慢 ) 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_firstTrue让输入维度变成(batch, time, feature)更符合直觉。取lstm_out[:, -1, :]是常见做法也可以用所有时间步输出的平均池化。dropout在 LSTM 内部只在多层之间生效分类头前再加一个 Dropout 层防止过拟合。如果显存够可以试bidirectionalTrue但推理时需要完整序列不适合在线场景。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_frames16): 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(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5, verboseTrue) # 训练循环骨架 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_norm5.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(fEpoch {epoch}: val_loss{val_loss:.4f}, acc{acc:.4f})weight_decay1e-4是 L2 正则抑制权重过大。clip_grad_norm_对 LSTM 特别重要因为反向传播穿过时间步时梯度容易爆炸表现为 loss 突然变成 nan。max_norm5.0是经验值太小会限制学习太大等于没裁。3.2 学习率与 batch size 的配合学习率和 batch size 是联动的。batch size 越大梯度估计越稳可以用更大的学习率。单卡 11GB 显存下LSTM 输入 16 帧 × 2048 维batch size 设 32 或 64 比较合适。如果 OOM优先降 batch size 而不是帧数因为帧数影响时序信息量。参数推荐范围说明学习率1e-3 ~ 1e-4Adam 从 1e-3 开始不降就手动降batch size32 ~ 64受显存限制太小梯度噪声大hidden_size256 ~ 512太小欠拟合太大过拟合num_layers1 ~ 2超过 2 层收益递减dropout0.3 ~ 0.5分类头前必加帧数16 ~ 3232 帧更准但更慢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, dim1) # (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], annotTrue, fmtd) plt.savefig(confusion_top10.png)我自己的习惯是每跑完一轮完整训练先看混淆矩阵再决定下一步调什么。如果某两个类别互相误判超过 30%单独把这两个类别的视频抽出来看帧往往能发现标注问题或采样问题。这套流程跑下来UCF101 从 70% 推到 85% 是可行的但每一步都要做消融实验确认涨点来自哪里而不是碰运气。希望帮到你。本文还有配套的精品资源点击获取
返回列表