ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实现CRNN在UCF101视频动作分类中的实战经验

PyTorch实现CRNN在UCF101视频动作分类中的实战经验 简介面向计算机视觉与深度学习学习者这份资源将UCF101视频动作识别数据集与CRNN模型结合完整覆盖了视频帧处理、时空特征提取、动作分类和结果预测等实验环节。UCF101包含101类动作、约1.3万个视频背景复杂且视角多变CRNN则通过卷积与循环网络分别建模空间和时间特征二者搭配适合作为动作识别入门及基线复现项目。包内共38个文件以.ipynb实验笔记和.py脚本为主辅以.npy保存的训练/测试损失与分数、.pkl输出的预测结果、.png可视化图片等压缩包大小仅2.57MB结构清晰且便于下载阅读。目前已有1146人学习使用适合需要复现动作识别基线、分析CRNN时空建模能力的初学者和研究者。资源中的训练过程数据和预测结果可以帮助你观察模型在120轮迭代下的收敛趋势结合错误预测样本来定位识别失败的动作类别进而调整数据增强、正则化或网络结构有效降低项目复现门槛。 视频动作分类这个领域UCF101基本是跑不掉的“标准考卷”。不过我发现很多人拿到这套数据集第一反应就是套个现成的3D卷积网络直接开训结果不是精度卡在60%上下不去就是训练过程各种报错。我自己用PyTorch搭CRNN模型在UCF101上做动作识别从数据预处理到最终调参花了差不多三周把整个过程踩过的坑、试错后的正确做法整理出来这篇东西应该能让准备动手的朋友少走不少弯路。这篇文章适合两类人一是刚接触视频分类、想用UCF101做第一个完整项目的学生或初级算法工程师二是已经在用纯CNN或者C3D跑UCF101、但觉得精度和训练效率都不够理想想尝试CRNN路线的人。文章不会贴完整工程代码但核心模块的写法逻辑和关键参数都会讲到照着思路走完全能自己复现。1. UCF101数据集先搞懂这张“考卷”的规则再做题1.1 数据集结构和容易被忽略的细节UCF101一共包含101个动作类别、13320个视频片段来源是YouTube上的真实场景视频。相比HMDB51那种小而杂的数据集UCF101的类别划分更清晰运动特征也更丰富所以学术界约定俗成拿它当基准测试集。第一次用这个数据集大多数人会直接下载压缩包解压然后开始写代码。但有几个细节我得先说明白它们会影响你后面的数据管道设计第一视频分辨率和帧率并不统一。虽然大部分视频是320×240左右但实际编码格式、码率差别很大有些视频甚至带有黑边。如果你不做统一处理直接用OpenCV逐帧读会遇到部分视频读不出来或读出来是花屏的情况。第二数据集的directory结构是按类别组织的也就是UCF101/ApplyEyeMakeup/v_ApplyEyeMakeup_g01_c01.avi这种路径。文件名里的g01和c01代表分组和摄像头角度这两个信息可以用来做更严格的数据划分防止同一个视频的不同片段泄漏到训练集和验证集。第三UCF101官方提供了三个train/test split文件split1、split2、split3每个split大约有9500个训练视频和3700个测试视频。这三个split的划分方式是官方固定的学术论文里报告结果也分别报告三个split的准确率再取平均。很多人图省事自己随机划分训练集这样做出来的结果没法和其他人的工作横向对比我不建议。1.2 帧采样策略决定训练效率和模型上限视频数据不能像图像一样整段扔进模型核心原因是显存和时间成本。业界通用的做法是稀疏采样从视频中均匀抽取N帧常见是8、16、32帧然后用这N帧代表整个视频。这里有一个关键权衡采样帧数越少训练越快但容易丢动作信息采样帧数越多准确率会上升但训练时间几乎线性增长。我在实际项目中测试UCF101上用CRNN模型8帧的准确率大概比16帧低3到5个百分点但训练时间节省一半以上。如果你算力有限先用8帧跑通流程再逐步加帧数这是最稳妥的路线。另一个经验是采样时刻的随机性。训练时不要每次都取固定位置的帧而是从视频中随机选一个起始点再均匀取N帧。这样相当于做了时间维度的数据增强能明显提升模型的泛化能力。测试时再固定取均匀采样即可。def sample_frames(video_path, num_frames16, trainTrue): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if train: # 随机偏移 均匀采样 max_start max(total - num_frames, 1) start random.randint(0, max_start) indices np.linspace(start, start num_frames - 1, num_frames, dtypeint) else: 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((240, 320, 3), dtypenp.uint8) frames.append(frame) cap.release() return frames这里有一个很隐蔽的坑cap.set(cv2.CAP_PROP_POS_FRAMES, idx)在OpenCV的某些版本对部分AVI编码的视频定位不准确会跳到错误帧。如果你的训练loss震荡异常可以考虑用decord或PyAV替代OpenCV做视频读取读取稳定性和性能都会有明显改善。2. 为什么视频动作分类选了CRNN而不是纯CNN或C3D2.1 纯CNN处理视频的根本缺陷很多人第一次尝试视频分类会想到把每一帧送到ResNet里提取特征然后把所有帧的特征做平均池化再过全连接层输出分类。这种做法其实是将视频当成一堆独立图片完全没有建模帧与帧之间的时序关系。问题在于许多动作类别的判别信息恰恰在“运动的模式”中而不是单帧的外观里。比如“挥拳”和“鼓掌”单看某一帧可能非常相似但放到时间轴上一个是手臂大幅度、低频率运动一个是手腕小幅度、高频率运动时序特征才是区分它们的关键。纯CNN平均池化相当于把所有帧的特征混在一起时序结构全丢失了准确率自然会受限。UCF101上用纯2D CNN 平均池化的基线通常只能到75%~80%左右就有这个原因。2.2 CRNN的思路用CNN提空间特征用RNN建模时序CRNN的结构很直观先用一个2D CNNResNet18/34或轻量级的VGG11逐帧提取空间特征然后把一个视频片段所有帧的特征序列送给RNN最后利用RNN的输出做分类。其中CNN部分相当于眼睛负责看懂每张画面里有什么RNN部分相当于短时记忆负责串联起这些画面之间发生了什么变化。两者分工明确训练也相对稳定不像C3D那样深层3D卷积对显存和算力的要求那么苛刻。我选择的是ResNet18 BiLSTM这种组合完整结构如下输入16帧连续或稀疏采样的图像size为[batch, 16, 112, 112, 3]CNN特征提取每帧单独经过ResNet18的卷积部分输出feature map空间池化对每帧的feature map做全局平均池化得到512维向量时序建模将这16个512维向量组成序列输入单层BiLSTMhidden size 256特征融合BiLSTM最后时间步的前向隐状态 初始时间步的反向隐状态拼起来分类层Dropout后接全连接层输出101个类别的得分2.3 单向LSTM还是双向LSTM这很关键双向LSTM在UCF101上的效果几乎总是优于单向LSTM原因在于动作的判别信息不一定只落在序列尾部。有些动作比如“跳远”关键判别信息可能在起跳的瞬间有些动作比如“拉小提琴”运动模式则贯穿始终。双向结构让模型能同时看到当前时刻前后的信息特征表达更完整。代价是计算量翻倍但LSTM部分在整个CRNN模型里的计算占比相对CNN要小得多所以这波“加量”非常划算。我用双向LSTM后在split1上提升了大概3个百分点。不过要注意BiLSTM在训练时的batch_first设置以及初始隐状态的处理很容易写错。建议在定义时固定batch_firstTrue并把LSTM的dropout层设为0.5仅在层数大于1时生效单层LSTM需要自己在输出后加Dropout否则容易加了个寂寞。3. PyTorch实现CRNN数据装载、模型定义、训练循环的核心写法3.1 Dataset和DataLoader性能瓶颈往往在这里视频数据的Dataset实现比图像复杂得多核心问题是I/O。UCF101视频是AVI压缩格式如果每次__getitem__都现读视频、逐帧解码CPU会被大量占用GPU经常处于饥饿等待状态。我建议做离线抽帧缓存训练前把每个视频的采样帧提取出来存成.npy或高效图片格式JPEG质量压缩到90即可。虽然UCF101全部抽帧会占不少磁盘空间但换来的是训练速度成倍提升。128GB可用磁盘是底线如果不够就只缓存当前split的训练集和测试集省着点用。DataLoader的num_workers建议设为CPU核心数的一半同时设置prefetch_factor2。我用8核CPU时num_workers4时数据加载速度已经能满足大部分GPU的训练需求盲目调大反而会因CPU频繁切换进程导致性能下降。标签映射是新手常踩的坑UCF101类别标签是从1到101不是从0到100。直接读文件夹名排序并enumerate时ApplyEyeMakeup会排在第0位但官方split文件里的标签是从1开始的。你需要在加载split文件时将标签列整体减一或者建立从文件名到0-index标签的映射字典否则验证集的准确率会突然掉到1%以下。3.2 模型各模块之间的维度对齐CRNN在代码实现上最麻烦的维度问题在于CNN特征序列进入LSTM前的形状变换。很多人在这儿写崩过我给出一个实测可用的小模型实现思路class CNNEncoder(nn.Module): def __init__(self, pretrainedTrue): super().__init__() resnet models.resnet18(pretrainedpretrained) self.features nn.Sequential(*list(resnet.children())[:-1]) # 去掉最后的全局池化和fc self.embed_dim 512 def forward(self, x): # x: [B, T, C, H, W] - [B*T, C, H, W] B, T x.size(0), x.size(1) x x.view(B * T, *x.size()[2:]) feat self.features(x) # [B*T, 512, 1, 1] feat feat.view(B, T, self.embed_dim) return feat # [B, T, 512] class CRNNClassifier(nn.Module): def __init__(self, num_classes101): super().__init__() self.encoder CNNEncoder() self.lstm nn.LSTM( input_size512, hidden_size256, num_layers1, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(0.5) self.fc nn.Linear(512, num_classes) def forward(self, x): feat_seq self.encoder(x) # [B, T, 512] lstm_out, _ self.lstm(feat_seq) # [B, T, 512] final torch.cat([lstm_out[:, -1, :256], lstm_out[:, 0, 256:]], dim1) return self.fc(self.dropout(final))BiLSTM的lstm_out[:, -1, :256]取的是前向层最后一个时间步的隐状态lstm_out[:, 0, 256:]取的是反向层第一个时间步也就是序列末尾的隐状态两者拼接才能完整利用双向信息。直接用lstm_out[:, -1, :]会把反向层最后推演到的时间步拿进来语义就乱了这一点务必注意。3.3 训练循环中的三个隐性要求BCEWithLogitsLoss还是CrossEntropyLossUCF101是单标签多分类问题用标准的nn.CrossEntropyLoss()就够了不需要Idea多标签那套。唯一要留意的类别不平衡问题并不严重UCF101绝大多数类别视频数量在100~150之间极个别类别稍少一点不经过加权也能正常收敛。Batch Size方面16帧输入、112×112分辨率ResNet18作为backbone时12GB显存可以放batch size 32。如果调整到224×224分辨率batch size要降到8左右。先用小分辨率跑通逻辑再升级大分辨率是性价比最高的路径。优化器选择AdamW初始学习率设为1e-4到3e-4之间权重衰减设1e-4。视频分类任务的数据复杂度远高于单帧图像分类学习率设置太高很容易出现训练loss下降很快但验证集精度不涨的情况。4. 提升UCF101准确率的实战调参经验4.1 预训练权重怎么用才有效使用在ImageNet上预训练的ResNet18作为CNN前端能让模型起步准确率远高于随机初始化。但怎么用预训练权重有讲究。我的做法是全网络训练的前5个epoch冻结ResNet的layer1和layer2只训练layer3、layer4和后面的LSTM、全连接层。这相当于先用偏底层的通用视觉特征去适配UCF101防止高层参数剧烈震荡。5个epoch后解冻所有层用较小学习率微调全部参数。这个策略在UCF101上约能带来2~3个百分点的提升理由在于UCF101的视频帧内容与ImageNet的自然图像分布差距不大底层特征迁移性很强强行从零学反而容易过拟合到特定动作的外观上。4.2 数据增强空间增强很重要时序增强别乱用空间增强方面训练时随机裁剪到112×112或选择的输入分辨率然后用RandomHorizontalFlip做水平翻转这两个组合几乎是白拿的提升。测试时用中心裁剪。有人问要不要做颜色抖动、随机灰度化我在UCF101上的实测增益很小反而可能让模型学到不够鲁棒的颜色特征。原因可能是UCF101背景相对复杂颜色增强物带来的扰动被背景噪声淹没了。时序增强方面随机采样起始点就够了不建议使用时间反序、时间裁剪这种操作。很多动作类别比如“跳高”和“跳远”对时间方向非常敏感反向会制造错误的学习信号。4.3 学习率调度和训练轮次的选择我推荐使用OneCycleLR或CosineAnnealingLR。UCF101视频数量不算大模型又比较重过长的训练时间容易出现先涨后跌的现象。实测下来30个epoch配合cosine退火初始学习率3e-4最小学习率降到1e-6split1准确率大约能到85%上下。继续训练到50个epoch提升可能只有1个百分点出头要根据自己的时间预算决定。还有一个很多人忽略的技巧监控验证集loss在验证集loss连续5个epoch不下降时保存当前权重作为最优模型而不是单纯保存最后一个epoch的权重。你会发现验证集准确率的峰值往往出现在训练中段而不是训练结束时。4.4 混合精度训练与梯度累积显存不够时torch.cuda.amp混合精度训练是最直接的解法。开启AMP后ResNet和LSTM的计算都会在FP16下进行显存占用几乎减半而精度损失在UCF101这个量级通常可以忽略。LSTM在FP16下有个小坑如果hidden size设置得比较大且训练初期存在梯度值极小的时刻可能触发数值下溢。建议对LSTM层的参数单独保持FP32只对CNN部分开启AMP代码上用torch.cuda.amp.autocast()配合model.parameters()区分处理。梯度累积更适合batch size不敏感的场景但CRNN内部的LSTM对梯度累积的延迟更新会有一定影响表现为训练loss波动变大。如果显卡能放下batch size 16以上就不用梯度累积了收益不大还徒增调参负担。5. 模型预测结果解读与排错记录5.1 先看懂预测结果里“错的”比“对的”更有价值训练完成后不要只盯着准确率数字。把预测错误的样本单独抽出来看是最能发现问题的方式。我第一次训练时split1准确率到了84%但看混淆矩阵发现“HorseRiding”和“Biking”互相混淆严重。原因是这两类动作的帧图像里都有大量相似的自然背景和快速移动的前景主体CNN提取的空间特征高度接近时序维度上速度也差不多模型很难区分。后来我试着把输入帧数从16加到32这类混淆显著减少因为更长时间跨度下“骑马”的上下颠簸规律和“骑行”的匀速前进规律更容易被LSTM捕捉。预测置信度方面可以用torch.topk(preds, k2)输出前两个类别的概率。如果很多样本的top1和top2概率非常接近差距小于0.05说明模型对某些类别语义把握不够可以考虑增大这部分类别的采样频率。5.2 训练集精度高、验证集精度低的典型原因这是避坑环节的重灾区。我总结过UCF101项目中最常见的过拟合信号第一显存不够时直接在视频帧上做全局平均池化模型只能靠背景等表面线索做判断。比如“HighJump”类别的很多训练样本都带横杆模型把“横杆”当成了判类依据测试集换场景就崩。解决方法是增加空间增强的比例并用测试时中心裁剪之外的多种裁剪做平均预测。第二BiLSTM隐层数量过大。我一开始把hidden size设为512参数量上涨很多在UCF101上过拟合速度极快。降到256后准确率不降反升。第三没有对BN层做正确的训练/评估模式切换。PyTorch的model.train()和model.eval()直接影响BN层统计量的使用方式。验证或测试时忘了切回eval()模式BN会用当前batch的统计量推理验证集准确率会无规律地上下波动。5.3 视频解码与帧对齐的坑三种库的取舍记录OpenCV读不了某些视频怎么办我遇到的情况是某些从YouTube下载的AVI文件的编码是MPEG-4 Part 2OpenCV的VideoCapture虽然能打开但CAP_PROP_FRAME_COUNT返回的帧数可能比实际多或少导致采样索引越界。PyAVFFmpeg的Python绑定能稳定处理UCF101里几乎所有视频解码速度也快但安装和依赖管理稍有门槛。decord则是轻量级选择对常见格式兼容性不错DeepMind也在用。我的最终方案是用decord读帧异常时回退到PyAV两种库都失败才用OpenCV兜底。这样可以保证13320个视频全部成功读取。这个兜底逻辑看起来笨但绝对是训练时最省心的做法。5.4 验证集评估时还有几个“低级但致命”的坑评估时不开torch.no_grad()显存暴涨导致OOM批次大小设置得太大导致BN统计量在最后一批时异常忘记对输入像素做归一化忘了把训练时做的随机裁剪替换成测试时的中心裁剪。这些错误我都犯过而且它们导致的直接后果是验证集准确率比正常值低10%到20%非常误导人。一个正确的测试评估流程应该是model.eval() correct 0 total 0 with torch.no_grad(): for frames, labels in test_loader: frames frames.cuda() labels labels.cuda() outputs model(frames) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fTest accuracy: {acc:.4f})这里还有一个细节split1的测试集有3783个视频不算多但如果你把每个视频的多次随机裁剪全部跑一遍取平均准确率可以再涨1个百分点左右。前提是时间充足否则单次中心裁剪的结果也够用了。最后分享一个我的习惯每次训练跑完我会把错误预测的视频片段连同预测得分、真实标签和前三名的预测类别一起存到一个CSV文件里并且额外生成一段拼接了预测类别文字的“视频配图网格图”。这样后续分析错误模式时完全不用重跑代码拿着CSV就能快速定位是数据问题还是模型结构问题还是类别本身的语义太接近。我建议你也把这一步固化到训练脚本里省下的时间足够你再跑好几个调参实验了。UCF101 CRNN这条路难的不是模型结构本身而是数据读取、序列对齐、训练策略这些看起来不起眼但处处埋雷的环节。希望上面的经验能帮你少踩几个坑早日把split1跑到85%以上。本文还有配套的精品资源点击获取
返回列表