用CNN+LSTM在UCF101上做视频分类:完整实践与踩坑总结 简介这份资源提供了一套结合LSTM的encoder-decoder模型完成UCF101视频动作分类的完整工程适合有一定深度学习基础、希望从零复现视频识别项目的开发者与研究者。压缩包共90个文件约8.93MB涵盖Python脚本、Jupyter Notebook、预训练结果、配置文件、可视化图片与说明文档等其中12个py文件为核心代码12个ipynb便于分步调试14个pkl保存了标签与帧计数等中间数据16个npy为特征或输出结果。目录按ResNetCRNN、Conv3D、CRNN等模型组织并附有README与预测效果图。目前已有270人学习下载。通过这套资料读者可直观了解CNN/3DCNN/ResNet特征提取与LSTM序列建模的衔接方式掌握数据预处理、特征提取、序列编码、分类训练与评估的完整流程还能参考检查预测的辅助脚本和实验记录快速迁移到其他视频分类任务。 前阵子接了个动作识别的小项目要在短视频数据集上做多分类选来选去用了UCF101。这块数据一共101类动作、13320段视频看着体量不大真要上模型坑不少。我第一反应是直接套3D CNN但手里只有一块消费级GPU跑I3D这种模型多少有点勉强。后来换成了“预训练CNN提帧特征 LSTM做时序编码”的encoder-decoder方案在UCF101上把分类流程完整跑通了。这篇文章把整个项目的设计思路、核心实现和踩过的坑记录下来给想在视频分类上快速起步、又不想一上来就背大模型的朋友做个参考。内容不复杂但每一步都有取舍搞清楚了后面换数据集或者换任务都能复用这套骨架。1. 方案选型为什么是encoder-decoder LSTM1.1 UCF101任务拆解UCF101不是简单的图像分类它的核心难点在于动作信息分布在时间轴上。像“射门”和“传球”这类动作单看某一帧可能长得很像甚至完全一样真正区分它们的是帧与帧之间的运动趋势和姿态变化。所以这个任务的本质是给一段视频先抽帧再从帧序里提取时序特征最后映射到101个动作类别上。如果只做单帧CNN分类模型能学到静态场景信息比如篮球场、泳池但学不到“动作”本身。这也是为什么时序建模在UCF101这类任务里是刚需而不是可选项。顺着这个思路自然就落到两个问题一是用什么方式表示视频帧二是用什么结构建模帧之间的依赖关系。1.2 encoder-decoder如何在分类任务中落地一提到encoder-decoder很多人第一反应是机器翻译或者文本摘要属于sequence-to-sequence的典型玩法。但分类任务里同样能用思路是从“序列到序列”变成“序列到标签”。我把视频帧的特征序列喂给encoderencoder把整段信息压缩成一个语义向量这个向量包含了动作的关键时序信息。decoder在分类场景里的职责不再是逐个词生成而是拿到这个压缩向量后映射到类别空间输出101个类别上的概率分布。实际实现时decoder有两种做法。一种是严格意义上的LSTM decoder输入一个起始符逐步“生成”类别标签另一种是直接把encoder最后一个隐状态接一个MLP分类头。我实验下来单标签分类用后者就够了简单、收敛快效果也不差。LSTM decoder只有在做视频描述、密集标注这类生成式任务时才真正有优势。从选型角度当时我对比过三条路线方案参数量时序建模能力训练成本上手难度3D CNN大中等高较高CNN LSTM encoder-decoder中强低低Video Transformer很大强很高高在本地单卡训练的前提下3D CNN的数据增强和调参都比较折腾Video Transformer又太吃显存和训练技巧。LSTM这套方案反而最均衡预训练CNN负责空间特征LSTM负责时序依赖每一层职责清晰出了问题也好单独排查。如果你也面临类似约束这个选择大概率不会错。2. 数据准备从视频到帧级特征2.1 UCF101数据整理UCF101官方数据按101个动作类别分文件夹每个动作类有25组视频每组里还有多个片段总数13320段。官方同时提供了3种train/test划分方案我直接用split1训练集和测试集都有对应的txt文件里面每行是“视频路径 标签”。下载解压后建议先建一个简单的目录结构后续加载会方便很多。我的组织方式是这样的UCF101/ ├── videos/ │ ├── ApplyEyeMakeup/ │ ├── ApplyLipstick/ │ └── ... ├── ucfTrainTestlist/ │ ├── trainlist01.txt │ └── testlist01.txttxt里的视频路径自带类别前缀所以读标签时直接取第一层目录名再用一个字典映射到0到100的索引就行。这一步不复杂但路径和标签映射偶尔会串建议加载数据后先打印几条记录核对一下。2.2 视频采样与预处理视频分类首先要解决的是“一段视频怎么变成一批帧”。UCF101里的视频长度不统一最长的几分钟最短的只有几秒。最省事的做法是均匀采样固定帧数我选了16帧。为什么是16不是32或者64因为UCF101里绝大多数动作在1到2秒内就能完整呈现按25fps算16帧大约覆盖0.64秒对短时动作已经够用。加帧数会提升一点信息量但训练时间和显存消耗是线性增长的边际收益不高。采样代码用OpenCV实现核心是先把帧索引均匀铺开再逐帧读取import cv2 import numpy as np def sample_frames(video_path, num_frames16, target_size(224, 224)): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total 0: cap.release() return None indices np.linspace(0, total - 1, num_frames, dtypeint) frames [] for idx in indices: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame cap.read() if not ret: frame np.zeros((target_size[0], target_size[1], 3), dtypenp.uint8) frame cv2.resize(frame, target_size) frames.append(frame) cap.release() return np.stack(frames)这里有个注意点cv2.VideoCapture对某些视频的CAP_PROP_FRAME_COUNT不一定精确读帧时偶尔会失败所以代码里要做兜底否则一个坏视频就能让整个训练中断。预处理阶段我还会做随机水平翻转和轻微的颜色抖动用来增强模型对视角和光照变化的鲁棒性。2.3 用预训练CNN提取帧特征如果把原始视频帧直接喂给LSTM输入维度是[batch, 16, 224, 224, 3]LSTM吃这种高维数据非常吃力而且参数会膨胀到不可控。所以我在前面加了一个特征提取器用ImageNet预训练的ResNet去掉最后的全连接层把每一帧压成一个512维或2048维的特征向量。这一步可以离线做把整个数据集的特征提前算好存成npy文件训练LSTM时直接加载显存占用会小到几乎可以忽略。特征提取代码大致是这样的import torch from torchvision import models, transforms from PIL import Image model models.resnet18(pretrainedTrue) model.fc torch.nn.Identity() # 输出512维resnet50输出2048维 model.eval() transform 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_feature(video_path, frame_model, transform): frames sample_frames(video_path, 16) tensor_list [] for frame in frames: pil_img Image.fromarray(frame) tensor_list.append(transform(pil_img)) tensor torch.stack(tensor_list) # [16, 3, 224, 224] with torch.no_grad(): feats frame_model(tensor) # [16, 512] return feats.numpy()用ResNet18还是ResNet50需要权衡。ResNet50的特征更丰富但离线提取时间几乎翻倍ResNet18的特征稍弱但训练LSTM时收敛更快。我的建议是先用ResNet18把整条流程跑通确认代码和参数没问题再换ResNet50提升精度。这样能减少无谓的等待时间。3. 模型实现LSTM encoder-decoder实战3.1 模型结构定义特征准备好之后模型部分就非常清爽了。输入是[batch, seq_len, feature_dim]经过encoder LSTM得到最后的隐状态再接一个MLP解码器输出分类结果。import torch import torch.nn as nn class EncoderDecoderLSTM(nn.Module): def __init__(self, input_size512, hidden_size256, num_layers2, num_classes101, dropout0.3): super().__init__() self.encoder nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.decoder nn.Sequential( nn.Linear(hidden_size, hidden_size), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size, num_classes) ) def forward(self, x): # x: [batch, seq_len, feature_dim] _, (h, c) self.encoder(x) # 取最后一层的隐状态 out self.decoder(h[-1]) return out这个结构里encoder负责把所有帧信息压缩进最后一个隐状态decoder则把隐状态映射到101个类别。如果视频长度不统一数据加载时要做padding再用pack_padded_sequence处理更省心的做法是我前面那样统一采样成固定帧数绕开变长序列的坑。第一版实现强烈建议用固定帧数。decoder用MLP而不是LSTM的理由上面说过我再补充一点UCF101每个视频只有一个标签没有标签序列需要生成MLP足够表达从隐状态到类别的非线性映射。如果以后要做多标签分类或视频内容描述再考虑把decoder换成LSTM或者Transformer模块。3.2 训练流程与超参数训练部分我用PyTorch原生训练循环优化器选AdamW损失函数用CrossEntropyLoss加一个ReduceLROnPlateau调度器验证集指标不再改善时自动降学习率。核心训练代码import torch.optim as optim model EncoderDecoderLSTM(input_size512, hidden_size256, num_layers2, num_classes101, dropout0.3) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) for epoch in range(30): model.train() total_loss 0 for feats, labels in train_loader: feats feats.float().to(device) labels labels.to(device) optimizer.zero_grad() outputs model(feats) loss criterion(outputs, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() val_loss, val_acc evaluate(model, val_loader) scheduler.step(val_loss) print(fepoch {epoch1}, loss {total_loss/len(train_loader):.4f}, fval_acc {val_acc:.4f})有几个参数是试出来的hidden_size取256再往上加对精度帮助不大训练时间倒是明显变长num_layers取21层表达能力偏弱3层容易过拟合dropout设0.3配合weight_decay效果比较稳。还有一个容易忽略的细节是梯度裁剪LSTM训练时间长偶尔会出现梯度爆炸clip_grad_norm_能避免loss突然变成nan。序列长度、输入特征维度和隐藏层维度这几个主要参数的设定可以参考下表参数推荐值说明seq_len16覆盖短时动作加长收益有限input_size512 / 2048取决于ResNet18还是ResNet50hidden_size256够用再大容易过拟合num_layers2兼顾表达力和训练速度dropout0.3缓解过拟合batch_size32单卡内存充足即可learning_rate1e-3AdamW常用起点epochs20-30特征方案下收敛较快3.3 评估与指标UCF101官方主要看Top-1准确率但101分类任务中不少动作相似度高比如“弹吉他”和“弹尤克里里”Top-1会显得过于苛刻。所以我同时统计Top-5用来观察模型是否已经学到了接近正确类别的语义而不是完全跑偏。评估代码比较简单维护一个correct和total计数再加一个top5计算def evaluate(model, val_loader): model.eval() correct1 0 correct5 0 total 0 with torch.no_grad(): for feats, labels in val_loader: feats feats.float().to(device) labels labels.to(device) outputs model(feats) _, pred outputs.topk(5, dim1) for i in range(labels.size(0)): if labels[i] in pred[i]: correct5 1 if pred[i][0] labels[i]: correct1 1 total labels.size(0) return correct1 / total, correct5 / total从实用角度看我还会额外看混淆矩阵找出哪些类别经常互相误判。UCF101上最容易混淆的往往是动作方式相似但参与物体不同的类别比如“刷牙”和“梳头发”或者“弹吉他”和“弹钢琴”。知道模型在哪些类别上“犯糊涂”比单独盯一个准确率数字有价值得多。4. 踩坑记录训练中的常见问题与优化建议4.1 常见问题速查表整个项目跑下来遇到的问题不少我把最典型、最容易浪费时间的几个整理成表格方便对照排查。问题可能原因解决方法loss变成nan学习率太大或梯度爆炸调小学习率加梯度裁剪验证集准确率很低序列长度太短或采样位置不对改为均匀采样确保覆盖整个视频训练集acc高但测试集低过拟合增加dropout、加数据增强、减小hidden_size视频帧读取失败个别视频损坏或帧数不准采样代码里对空帧做兜底替换训练很慢实时抽帧实时CNN提取特征离线缓存特征训练时只加载npy视频长度不一致报错batch内序列长度不统一统一采样固定帧数或使用pack_padded_sequence有个坑特别值得说一开始我把视频前16帧直接作为输入结果模型在好几个类别上准确率接近0。原因是很多UCF101视频开头是黑屏或者淡入效果真正的动作在视频中段才开始。改成全视频均匀采样之后这个问题立刻消失。这也说明时序任务里“数据怎么取”往往比“模型怎么搭”更影响最终效果。4.2 性能优化与扩展方向当前这套encoder-decoder LSTM方案在UCF101 split1上能拿到一个还不错的baseline但如果你想继续压精度有几个方向值得尝试按性价比排序第一是双向LSTM。把encoder改成双向可以让每个时间步的隐状态同时包含前后文信息对动作起始和结束位置的建模更完整。代价是参数量翻倍训练时间增加。第二是注意力机制。在decoder之前对encoder所有时间步的输出做注意力加权而不是只用最后一个隐状态。对于持续时间长的动作全局平均或者注意力池化往往比最后状态保留更多信息。第三是可以考虑把LSTM替换成Transformer encoder或者直接用TimeSformer这类视频Transformer。但这需要更充裕的算力和更长的调参周期不建议在入门阶段尝试。也可以从特征侧优化把ResNet替换成更强的backbone比如ConvNeXt或EVA-02特征质量提升了LSTM部分几乎不用改就能涨点。这种做法很适合已有预训练特征库、只想升级精度的场景。我先用ResNet18跑通全流程再换ResNet50最后如果精度还不够就加一层注意力每一步都有明确的收益预期和回滚点。最后再分享一个个人经验如果你也想在自己的数据上复现这套方案建议不要一上来就追求最复杂的模型先把“抽帧 → 提特征 → 训练LSTM → 评估”这条管线完整跑通再逐步升级模块。我在这个项目里最深的体会是视频分类的大部分问题都不在模型结构而在数据管线和特征质量上。管线做干净了LSTM这种被很多人觉得“老掉牙”的结构照样能在UCF101上做出实用的分类效果而且调试起来远比Transformer顺手。本文还有配套的精品资源点击获取