简介:面向视频描述与多模态理解研究者,MSR-VTT 10K数据集划分工具包提供标准化的训练/验证/测试集切分及配套Python处理代码,可免去自行清洗原始JSON的繁琐工作,同时适用于学术研究与工程实践。压缩包体积仅3.09MB,共包含2个文件:一个为整理后的MSR-VTT标注JSON,另一个是负责读取、写入并按范围划分数据的Python脚本,脚本逻辑清晰,支持灵活调整划分边界。训练集覆盖video0至video6512共6513条,验证集为video6513至video7009共497条,测试集为video7010至video9999共2990条,划分比例与多数公开实验设置一致。拿到后可直接用于视频描述模型训练与验证,脚本接口简单,可快速加载划分好的标注或按需生成字幕词典,也支持自定义划分比例进行消融实验,方便算法研究者二次开发,能有效提升实验复现效率。目前已有2249人学习下载,其划分与代码在同类任务中具备较好的参考价值。
1. 从一句字幕到整个榜单:MSR-VTT 10K v2.0 到底是什么水平
做视频理解的人,十有八九都经历过这样的对话:我模型在 MSR-VTT 上 CIDEr 到 0.6 了,对方先是一愣,然后追问你是 10K 还是 10000 那个版本,测试集用的是官方 split 还是自己切的。MSR-VTT 10K v2.0 就是视频到文本任务里那张绕不开的考卷,10 个类别的网络短视频配了大约 20 万条人工字幕,模型要从画面里学出“人在干什么、场景是什么、先后顺序怎么样”,然后生成一句话描述,或者反过来,拿一句话去把对应视频从库里捞出来。这个数据集解决的是评测标准不统一的问题:别人发在论文里的分数,只有在同一份数据、同一套划分、同一个评测脚本下才有意义。它适合谁?适合做视频字幕生成、文本视频检索、多模态特征融合的研究人员,以及被老板要求“跑个 baseline 发论文”的工程团队。
2. 拆开 MSR-VTT 10K 的压缩包:目录布局和标注 JSON 的解析
2.1 先分清“数据版本”和“划分版本”
很多人在网上找到 MSR-VTT 10K v2.0.rar 之后,第一件事是急着解压,然后发现里面全是 .mp4,字幕标注文件却不在。这不是压缩包坏了,而是 MSR-VTT 的历史遗留问题:最初的 v1.0 只有一万个视频下载链接和一份标注 JSON,视频内容靠 URL 去网上拉,后来才有整理好的完整压缩包。v2.0 通常指的不是重新标注了一遍,而是把视频文件、标注文件、训练验证测试划分整理得更规整了一版,并且修掉了 v1.0 里划分不干净的问题。
所以打开压缩包之前,先想清楚一件事:你拿到的是“原始视频包”,还是“特征包”,还是“标注包”?常见的做法是视频包里每个视频单独一个目录,或者全部平铺在根目录,命名形如 video0.mp4、video1.mp4,而标注文件 msrvtt_data.json 大概率是单独带的。如果这个 rar 只有视频没有 json,那就需要去 MSR-VTT 的原始发布页确认标注文件的下载方式,别急着在训练脚本里写死路径。
2.2 把标注 JSON 读进内存:最小可运行脚本
假设你已经有一个完整的解压目录,结构大概是这样的:
- MSRVTT/ 根目录
- msrvtt_data.json(字幕和划分信息)
- train_list.txt / val_list.txt / test_list.txt(可选)
- video0.mp4、video1.mp4……
我拿标注文件做第一件事永远是先看 keys,而不是直接塞进 DataLoader。用这份代码先摸清格式:
import json from pathlib import Path ann_path = Path("MSRVTT/msrvtt_data.json") with open(ann_path, "r", encoding="utf-8") as f: data = json.load(f) print(data.keys())这段代码干了什么不用多解释,它只是把 JSON 载入内存。关键在于后面的输出往往和直觉不一样。我见过好几个团队在这里踩坑,以为 data[“annos”] 就是一个大列表,结果这个 JSON 里可能分成了 videos 和 sentences 两部分,video 字段记录视频 ID、时长、类别,sentence 字段每一条带着对应视频 ID 和一句字幕。更有甚者,划分标记不在每一条句子上,而在单独的 train/val/test 集合文件里。
如果你看到的是这种分离结构,下一步就这么拆:
videos = data["videos"] # 视频级元数据 sentences = data["sentences"] # 每条字幕和所属视频ID # 建立 video_id -> 视频信息的映射 video_info = {v["id"]: v for v in videos} print(f"total videos: {len(video_info)}") print(f"total captions: {len(sentences)}") print(sentences[0])注意:这里我按常见的键名来写,你的文件里键名可能叫 video_id 也可能叫 id,甚至可能是 filename。先 print 一条样本再决定字段名,这比任何假设都可靠。这个小动作能帮你省下三个小时去调对不上的索引。
2.3 按官方划分切分训练验证测试:脚本里不要再自己随机切
MSR-VTT 10K v2.0 最值得重视的是它的划分方式。v1.0 时期有人图省事,直接把 10000 个视频按比例随机切成训练和测试,这种做法很快被证明会造成严重的数据泄漏——同一个视频的不同片段,或者同一个事件的不同机位,会同时出现在训练和测试里。v2.0 在划分上做了修正,所以拿到压缩包后先确认有没有 train_list.txt、val_list.txt、test_list.txt 这类文件,如果有,直接读它们,不要自己按比例切。
我一般会把三个 list 文件和标注 JSON 一起检查一遍,确保每个视频 ID 只出现在一个集合里:
train_ids = [line.strip() for line in open("MSRVTT/train_list.txt")] val_ids = [line.strip() for line in open("MSRVTT/val_list.txt")] test_ids = [line.strip() for line in open("MSRVTT/test_list.txt")] all_ids = set(train_ids) | set(val_ids) | set(test_ids) assert len(all_ids) == len(train_ids) + len(val_ids) + len(test_ids), "split overlap!"这段代码的意义在于:如果三个集合有交集,assert 会立刻崩给你看。我建议把 10000 这个数字也核对一遍,比如打印 len(train_ids) + len(val_ids) + len(test_ids)。不同版本的 v2.0 对测试集数量定义不一样,有的是 2990,有的是 1000,这直接影响你满大街找的论文对比结果。跑 baseline 之前,先用这段脚本把视频总数、字幕总数、划分重合度三件事打印出来,存成一份日志,后面写论文的时候直接引用这份日志,省得为了“我用了哪个 split”跟审稿人来回拉扯。
3. 把视频转成可训练样本:抽帧、时长对齐和缓存策略
3.1 抽帧工具选型:为什么我用 ffmpeg 而不是 OpenCV 直接抽
拿到一万个视频之后,第一个实际问题就是“视频不能直接进模型”。无论是做视频字幕还是做检索,标准流程都是先抽帧,再送进视觉编码器提特征。抽帧工具有两个流派:一个是 OpenCV 的 VideoCapture,一个是 ffmpeg 命令行。OpenCV 看起来方便,但它在处理网络下载的视频时经常遇到编码器支持不全、PTS 时间戳不准、读到最后几帧卡死的问题。ffmpeg 是独立进程,挂了能重启,参数可复现,采样准确,我很少在它身上翻车。
抽帧的参数不是随意定的。最常见的做法是用 2 帧/秒的均匀采样,再把短边缩放到 224 或者 256,这样每个视频平均产生 20 到 30 帧。不要贪心抽得太密——帧数多一倍,训练速度慢一倍,但对视频字幕任务的效果提升很小,因为视频本身信息冗余很大。反过来,抽太少也不行,很多动作类别的视频一两秒内容就变了,如果帧率低于 1,模型学到的几乎是静态图。
3.2 一个带缓存和并发控制的抽帧脚本
先把单个视频的抽帧逻辑写好,再并发跑。这样排查问题的时候不用对着一个复杂的批处理脚本发愁。
import subprocess from pathlib import Path def extract_frames(video_path: Path, out_dir: Path, fps: float = 2.0, size: int = 256): out_dir.mkdir(parents=True, exist_ok=True) cmd = [ "ffmpeg", "-y", "-loglevel", "error", "-i", str(video_path), "-vf", f"fps={fps},scale={size}:-2", "-an", # 不需要音频 "-q:v", "2", # JPEG质量2,接近无损 str(out_dir / "frame_%04d.jpg"), ] subprocess.run(cmd, check=True)这个函数接受视频文件路径和输出目录,ffmpeg 会按 fps 做均匀采样,scale={size}:-2 表示短边缩放为 256,长边等比缩放并保持偶数。输出帧命名为 frame_0001.jpg、frame_0002.jpg 这种格式。q:v 设为 2 是为了把 JPEG 质量保得高一点,因为后面提特征时压缩噪声会传进模型。
有了单视频函数,再用进程池并发处理。这里有个容易忽略的点:不要盲目开几百个并发,ffmpeg 是 CPU 密集任务,并发数超过 CPU 核心数反而会因为进程切换变慢。我一般按 CPU 核心数减一设置:
import os from concurrent.futures import ProcessPoolExecutor source_root = Path("MSRVTT/videos") frame_root = Path("MSRVTT/frames") video_ids = sorted([p.stem for p in source_root.glob("*.mp4")]) def extract_one(vid: str): extract_frames( source_root / f"{vid}.mp4", frame_root / vid, fps=2.0, size=256, ) # macOS/Linux 用 ProcessPoolExecutor,Windows 上注意 __main__ 保护 with ProcessPoolExecutor(max_workers=max(1, os.cpu_count() - 1)) as pool: pool.map(extract_one, video_ids)这段代码把所有视频的抽帧任务扔进进程池。关键设计是输出目录以视频 ID 命名,这样后续加载样本时可以直接由视频 ID 拼接帧目录路径,不需要额外维护一份映射表。执行完可以抽查一两个视频的帧数是否符合预期,比如 30 秒的视频应该有约 60 帧,误差一两帧正常,但如果少了三分之一,那就要回头检查是不是原视频时长标注和实际时长不一致。
3.3 从视频 ID 到样本 ID:字幕和画面的配对逻辑
抽完帧,下一步是把字幕文本和视频画面配对。MSR-VTT 的标注设计是每个视频约 20 条字幕,训练时候不是把整个视频当成一个样本,而是“一段视频 + 一条字幕”构成一个训练样本。这里有一个许多人第一次接触时绕不过去的弯:视频是同一个,字幕有 20 条,模型输入应该用全部字幕还是随机选一条?答案是随机或循环取样,每一条字幕都有机会参与训练,但同一个 batch 里不要用同一个视频的多条不同字幕,否则很容易让模型学会背字幕而不是看画面。
我习惯的做法是构造一个 pyspark 风格的过滤,这里用纯 Python 演示:
train_samples = [] for sent in sentences: if sent["video_id"] not in all_ids: # 或换成你确认过的划分集合 continue if sent["video_id"] in train_id_set: train_samples.append({ "video_id": sent["video_id"], "caption": sent["caption"], # 或 sent["sentence"] "split": "train", })注意这段代码里我故意用了不固定的键名,就是因为不同版本的标注 JSON 键名不统一。配对完成后,一个典型的训练样本字典就三个字段:视频 ID、字幕文本、集合划分。抽帧路径和文本都齐了,DataLoader 只需要靠 video_id 去 frames 目录下找图片序列,和 caption 拼一起就是一个 batch。
4. 避坑:在 MSR-VTT 上训练的第一周最容易翻车的五个地方
4.1 训练集和测试集出现了同视频不同片段
现象:训练到第五轮 loss 掉得飞快,验证集指标也在涨,但一到测试集就崩,甚至比随机好不了多少。检查发现 train_list 和 test_list 里有相同的视频 ID 前缀,比如 “video123” 和 “video123_1”。原因:v1.0 的老划分里,同一个视频的不同片段被分到了不同集合,v2.0 修正了一部分,但如果你用的是网上某个第三方整理包,划分可能还是旧的。解决:用 2.3 节的 assert 检查三个集合是否重合,把所有交叉的 ID 统一归到训练集或者全部踢出测试集,宁缺毋滥。
4.2 验证集指标高,全靠字幕里的“废话”
现象:模型生成的句子是“一个人在上面做事情”,CIDEr 分还挺高。原因:MSR-VTT 的标注质量是人工的,但有一大批字幕是高度模板化的,比如 “a person is talking” 这种句子在人群场景里对什么都沾边。解决:别只盯单一指标,把 BLEU、METEOR、ROUGE-L、CIDEr 四个都算出来一起看。如果 CIDEr 高但 BLEU 低,基本是模型偷懒走模板,不是真的理解视频内容。
4.3 解压出来的文件名大小写不一致
现象:标注文件里写的是 video0.mp4,磁盘上实际是 Video0.MP4,Windows 上没事,Linux 上一打开就是 FileNotFoundError。原因:压缩包制作时的原始文件名就不统一,解压工具在某些系统上又改了大小写。解决:解压后先做一个文件名规范化脚本,把所有视频统一转成小写 .mp4,再生成一份 video_id 到实际文件路径的映射表,后续代码只通过映射表访问文件。
4.4 视频特征和抽帧混用,指标对不上账
现象:复现某篇论文时,发现自己用抽帧帧提的 I3D 特征,作者用的是网上公开的视频级特征文件,两边指标差了 0.05 还多。原因:MSR-VTT 有很多第三方发布的高层特征,比如在完整视频上提的 CLIP 特征或 SlowFast 特征,和“自己抽帧再提特征”根本就不是同一种输入。解决:记录两大要素——视觉特征来自哪个模型、在什么帧率上提取。写 README 时明确写清楚“本仓库使用自抽 2fps 帧 + CLIP ViT-B/32 特征”,别让复现的人猜。
4.5 一个 batch 里视频长度差太大,训练不稳
现象:训练 loss 波动大,甚至时不时冒出 nan。原因:视频从 3 秒到 40 秒都有,一个 batch 里短视频 pad 到超长视频的长度,注意力矩阵大部分是 padding,梯度被 padding 区污染。解决:按视频长度分桶采样,或者干脆裁剪视频长度上限。常见做法是把帧序列限制在 48 帧以内,超过的尾部直接截断,不足的用随机起始位置采样。
5. 在 MSR-VTT 10K 上验证你的模型:字幕和检索的两套指标脚本
5.1 视频字幕打分的四件套:BLEU、METEOR、ROUGE-L、CIDEr
跑通训练之后,最需要的是能打论文图表的评测代码。字幕生成任务标准做法是把模型生成的句子和参考字幕集合比较。BLEU 用 nltk 的官方实现就可以,但注意分数极低很正常,因为 BLEU 对生成式任务并不友好:
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction # 模型输出一个句子,参考字幕有约20条,这里取前三条演示 hyp = ["a", "man", "is", "playing", "a", "guitar"] ref1 = ["a", "man", "plays", "guitar"] ref2 = ["someone", "is", "playing", "the", "guitar"] ref3 = ["a", "person", "is", "performing", "music"] smooth = SmoothingFunction().method1 bleu4 = sentence_bleu([ref1, ref2, ref3], hyp, weights=(0, 0, 0, 1), smoothing_function=smooth) print(f"BLEU-4: {bleu4:.4f}")这里的 weights=(0, 0, 0, 1) 表示只看 4-gram,返回的就是 BLEU-4。SmoothingFunction 是必需的,因为生成句和参考句的 4 元组大概率没有完全重合,不平滑会得 0 分,看不出任何差异。
CIDEr 的计算核心是 n-gram 共现加权,实现不复杂:
from collections import Counter def extract_ngrams(tokens, n): return Counter(zip(*[tokens[i:] for i in range(n)])) def cider(hyp_tokens, refs_tokens, n=4): total = 0.0 hyp_grams = [extract_ngrams(hyp_tokens, i) for i in range(1, n+1)] ref_grams_list = [[extract_ngrams(r, i) for i in range(1, n+1)] for r in refs_tokens] for i in range(n): hyp_counter = hyp_grams[i] ref_counter = Counter() for rg in ref_grams_list: ref_counter.update(rg[i]) # 只需要简单的重合率作为demo,完整版要加IDF和长度惩罚 overlap = sum((hyp_counter & ref_counter).values()) total += overlap / max(1, sum(hyp_counter.values())) return total / n这段代码作演示可以,真正写进论文之前换成官方评价脚本更稳妥,因为 MSR-VTT 竞赛用的 CIDEr 实现有 IDF 权重和复杂度惩罚,纯重合率会高估。我的经验是,把每一条生成字幕对应的参考字幕集合全部装进一个 JSON,运行完评测再把 20 条参考字幕的都打印出来,人工看一遍,确认不是模板句在刷分。
5.2 文本到视频检索:R@1、R@5、R@10 和 Median Rank 的计算
检索任务的评测和字幕生成不同:给定一句查询字幕,模型给所有视频打相似度分,然后看正确视频排在第几位。跑通这个流程后,最直观的指标是 Recall@K。假设你有一个相似度矩阵,行是文本,列是视频,矩阵元素是余弦相似度:
import numpy as np # sims[i][j] 表示第i句字幕和第j个视频的相似度 # 对角线表示正确配对 sims = np.random.rand(1000, 1000) # 示意数据,实际是你的模型输出 def recall_at_k(sims, k): ranks = [] for i in range(sims.shape[0]): # 当前字幕在所有视频中的排序 rank = (sims[i] >= sims[i, i]).sum() ranks.append(rank) return np.mean([1 for r in ranks if r <= k]), ranks recall_1, ranks = recall_at_k(sims, 1) median_rank = np.median(ranks) print(f"R@1: {recall_1:.3f}, MedR: {median_rank}")解释一下逻辑:sims[i][i] 是正确视频得分,rank 等于相似度大于等于正确配对分数的视频个数。如果正确视频最高分,rank 是 1,落在 R@1 里。Median Rank 是所有这些 rank 的中位数,越低越好。注意这里用的是 >= 而不是 >,为了处理打分并列的情况,否则一个常见的并列分数会把正确视频挤到更靠后,指标会被低估。
5.3 验证脚本前先做一轮“自检”:一份最小 demo 表格
跑完整套评测后,我总会做一次人工自检:挑出训练集里三个视频和测试集里三个视频,把它们抽帧的缩略图拼成一张图,然后把模型生成的字幕和人工字幕打印在边上。这个工作不写进论文,但它能快速暴露指标骗人的情况。比如模型对一个“正在炒菜”的视频生成“一个人在厨房”,人工字幕是“厨师把蔬菜放进锅里翻炒”,BLEU 可能只有 0.2,但语义上完全正确,说明模型学到了,但你如果只看 BLEU 可能会误判模型没训练好。
我的习惯是多存一张“失败样本表”:列分别是 video_id、时长、GT 字幕、模型输出、模型置信度。这一招帮我抓出过不少问题,比如某个类别的视频总是生成失败,视频里同时出现两个人时模型只描述其中一个,黑暗场景下视觉编码器输出全是噪声。这些细节在自己的模型上出现一次,后面就能在设计时留一条退路:未来要不要加针对性的数据增强,是否需要人物交互检测分支,全从这张表里找答案。
关于这套流程,最后补一句我一直坚持的操作:所有实验结果,除了打印指标,还要把评测脚本的版本、相似度矩阵的 numpy 文件、生成的字幕 JSON 一起归档,这样三个月后回看模型迭代时,还能复现当时的每一个分数。血的教训告诉我,慌慌张张把评分跑完就删中间文件,迟早有一天要翻车重跑,希望这些步骤能帮你少踩几个我踩过的坑。
希望这些步骤对你有用,愿你在 MSR-VTT 10K 上跑出的每一个数字都经得起复现。
本文还有配套的精品资源,点击获取