长格式视频表示学习,听起来像是做视频理解的人才需要碰的东西,其实这两年已经踩到了几乎所有做多模态、视频检索、内容审核、赛事分析的人脚后跟上。短视频那种三五秒的片段,用现成的预训练模型抽个特征、池化一下、接个分类头,基本就能出活;但一旦视频变成十几分钟、几十分钟,甚至一个小时的监控录像、课程回放、比赛集锦,原来的套路立刻就不灵了——信息太稀疏、事件跨度过长、帧与帧之间大量冗余。我这两年被长视频反复教育过之后,最受益的一个思路就是彭博的标题里写的:视频作为图。这一篇是这个系列的第一部分,我把“怎么把一段长视频真正构造成一张可以训练的图”这件事,从动机到实操,完整拆开讲清楚。
适合看这篇内容的人,我默认是这么几类:已经在做视频理解、想从短视频切到长视频场景的;做图神经网络但想找个真实落地方向的;以及做多模态内容平台、需要对小时级视频做结构化理解的工程团队。后面所有的方法、代码思路和参数,都是我在自己项目里跑过、踩过坑之后沉淀下来的,不是教科书式的推导。
1. 为什么长格式视频需要用图建模
1.1 长视频真正的难点在哪里
先说清楚一个前提:长格式视频和短视频不是“同一个任务换个更长的输入”,而是问题性质变了。短视频的特征是事件密度高、语义集中,一个三五秒的镜头里通常就是完整的动作或者完整的场景;长视频则恰好相反,有效事件可能只占全片几分钟,剩下的都是过渡、空镜、对话铺垫和重复画面。时间跨度拉长之后,依赖关系也不再是相邻帧之间的那种连续变化,而是跨场景、跨镜头的语义呼应。
举个我实际做过的事:一段两小时的某课程录像,要自动切出“老师板书”“学生提问”“实验演示”这几类事件。最初的方案是用现成的视频分类模型,把视频按1秒抽帧、滑窗切片段,然后逐段分类。结果发现两个问题。第一,滑窗片段之间大量重叠,相邻片段特征高度相似,模型输出的标签在时间轴上抖动得非常厉害;第二,像“实验演示”这种事件,往往前面有很长的铺垫——老师在讲原理、调试设备、布置器材——这些片段单拎出来根本不像“实验演示”,只有当它们组合在一起,事件才成立。这个问题滑动窗口解决不了,因为窗口长度有限,把窗口加宽又会混入大量噪声。
这就是长视频区别于短视频的本质难点:事件是组合出来的,不是单点判别的。依赖不是局部的,而是长程的、有条件的;信息分布极度不均匀,大部分计算量都浪费在无效内容上。所以问题不再只是“怎么抽特征”,而是“怎么在保留事件结构的前提下做高层次的表示”。
1.2 序列模型的困境与图的破局
自然能想到的方案是把视频当序列建模,用RNN/LSTM,或者Transformer。RNN类模型在实际长视频上基本是放弃状态——时序太长,梯度回传早就衰减没了,而且LSTM的递归结构没法并行,算力根本扛不住。Transformer能并行,但自注意力是平方复杂度,上万帧输进去,注意力矩阵的显存消耗是不可接受的;更麻烦的是,哪怕硬算完,模型也很难从几千个token里学到真正重要的那几个跨片段依赖,大部分注意力权重会平均地撒在所有帧上,等于被长尾的冗余信息稀释了。
图模型在数学上天然就是为这种问题准备的。图结构允许节点之间跳过中间人直接相连,一条边就可以表达“第23分钟的动作和第58分钟的动作存在语义关系”,这种长程连接不需要经过中间所有节点的传递,计算开销和整个序列的长度没有强耦合关系。更重要的是,图是稀疏结构,我们只保留有意义的边,其他无效连接直接不建;这在语义上等价于给模型做了一次先验的注意力剪枝——不是让模型自己决定哪两帧相关,而是我们从特征和时序结构上先告诉它谁跟谁相关。
另一个容易被忽略的优势是,图结构可以天然处理异质关系。序列模型只有一种关系:谁在谁后面。但视频中两个片段之间的关系至少有两种:时间上的先后关系,和语义上的相似关系。这两种关系对表征学习的意义是完全不同的,序列模型只能混在一起,图可以区分对待。
1.3 视频作为图的基本定义
把视频建模成图,落到数据层面就是四个组件:节点、特征、边、全局属性。节点对应视频里一个有语义的片段,不一定等长,但每个节点必须能映射回原始视频的具体时间区间。特征是这个片段的内容向量,通常用预训练模型抽出来,维度参考视觉语言模型的输出对齐空间,一般768到1024维。边定义节点之间的关系,包括时间邻接边、语义相似边、以及可能的全局虚拟节点连接。全局属性是整段视频级别的信息,例如时长、场景类型、文本标题的嵌入,可以在最后的池化阶段拼进特征。
这个定义看起来简单,真正决定效果的是三个选择:节点怎么切,边怎么建,以及图怎么稀疏化。下面这几节,我把每一个选择背后的逻辑和实操中都需要注意的细节拆开说。
2. 从原始视频到图表征的核心拆解
2.1 节点划分:均匀切还是语义切
节点划分是第一道坎,也是很多人最开始忽视的地方。我见过不少方案直接按固定长度把视频切成等长片段,比如每5秒一个节点,如果一个小时视频就是720个节点。这个做法能跑,但效果通常一般,原因是固定长度切分不管语义边界——一个动作可能横跨三个片段,或者一个片段里混杂了两个动作,节点包含的语义就不纯,后面的图卷积学出来的节点表示也会被噪声带偏。
更合理的做法是场景级别切分。视频天然存在镜头边界,可以用场景检测算法先把视频切成镜头,再把相邻镜头中内容相似的合并成一个语义单元。没有现成检测模型的话,也可以用视觉特征聚类的办法:把每0.5秒抽出的帧特征做时序聚类,聚类中心切换的位置大致就是语义切换点。
粒度控制上有两个方向的经验。做全局分类、检索这类粗粒度任务,节点要稍微大一点,一个节点能包含完整的事件上下文,通常建议15秒到1分钟;做时序定位、细粒度动作识别这类任务,节点要小,控制在2到5秒,因为边界定位需要比较高的时间分辨率。我自己的默认做法是:先用镜头边界合并出一个粗分割,再用2到5秒的固定窗口在粗分割内部做细分,这样既保持语义边界,又不至于让节点数量失控。
2.2 节点特征:用什么模型抽,抽完怎么处理
节点特征决定整张图的信息上限。这里我强烈建议直接用预训练的视觉语言模型来抽特征,而不是用传统的动作识别模型。原因很简单:长视频的语义往往链接文本概念,比如“老师在板书”“机器在运转”,视觉语言模型的对比学习目标天然把视觉内容对齐到了语义空间,抽出来的特征对后续语义相似度计算更友好,建出来的边也更接近人类理解的“相关”。
具体操作时,一个节点内会有多帧特征。不要把帧特征简单做mean pooling,这个我反复试过,均值和池化会把关键的判别信息磨平。建议先对节点内帧特征做轻量聚类,取最靠近聚类中心的帧代表节点内容;或者用注意力池化,让模型自己学哪个帧更重要。在实际落地中,做一次简单的按通道最大值加权池化,效果也比均值好不少。
特征抽完之后记得做L2归一化。个别视频光线暗、画面模糊,原始特征范数差异很大;不归一化的话,后续计算余弦相似度时,高范数特征会占据主导,语义边的建立会失衡。
2.3 边的定义:时间边、语义边和全局边
边是图比序列多出来的自由度,也是效果差异最大的地方。我把边分成三类,每类负责一种关系。
时间邻接边保证图的基本时序结构。两个在时间上直接相邻的节点之间建一条边,无论内容相不相似。这是防止图完全退化成“相似但不连续”的语义聚类的关键——很多事件的成立依赖先后顺序,没有时间边,模型分不清因果方向。
语义相似边是长格式视频真正的杀手锏。节点之间的特征向量做余弦相似度计算,高于阈值就建边。这种边把逻辑上相关但时间上距离远的片段连接起来,让信息可以在图里直接跨时段传播。比如我在某实验演示视频里,开头设备调试的片段和后面出结果的片段,时间相隔四十分钟,但语义高度相似,经过语义边直接把上下文接上,模型最后的定位和分类准确率提升非常明显。
全局边是可选的,通常是加一个虚拟全局节点,所有节点都连向它。这个节点在消息传递过程中可以聚合全局信息,再分发回所有节点,相当于给了每一层一个视频全局上下文。不是所有任务都需要,但做长视频分类时往往有帮助。
2.4 边稀疏化:不控制边数,模型必炸
建边规则如果只设阈值,实战中代价极大。长视频的节点特征经过预训练模型之后,相似度分布通常非常集中,很多不相关片段的余弦相似度也超过0.8。如果阈值设低了,边爆炸式增长,图卷积里消息传递的计算量成倍上升;设高了,真正的相关边又被切掉。所以必须组合两招:阈值过滤加顶k筛选。
第一招只用阈值,但阈值可以放宽,比如0.7;第二招按每个节点只保留相似度最高的k条边,k一般取5到15。这两招合起来,既能剔除完全不相关的连接,又能避免中心节点形成超级枢纽。另一个细节是要去掉自环再取top-k,节点特征和自身相似度永远是1,不删除的话,top-1永远是自环,会把语义边名额浪费掉。
时间边不参与稀疏化,相邻节点必须保留,否则可能出现图不连通的情况,这在多层GNN里会直接导致部分节点学不到邻居信息。
3. 图构建实操:结构与代码一步步过
3.1 图层面的数据结构
我用的是Python项目里非常通用的图数据结构:节点特征矩阵X,边的起点和终点索引数组,边的权重向量。这种定义方式在主流图学习框架里都能直接喂进去,不用额外依赖复杂图数据库。
X的形状是num_nodes乘feature_dim,每个节点对应一个特征向量。边的索引以COO格式存储:两个一维数组source和target,长度都是num_edges,描述了每条边从哪个节点指向哪个节点。边权重是配套的一维数组,可以理解为消息传递的放大系数。
实践上,我会把语义边和时间边分开存储、分开权重,不会一股脑混成一个图。可以在构图阶段分别算好,再在模型输入时拼到一起,后续如果某类边效果差,可以直接调对应权重而不用重构图。
3.2 相似度计算与稀疏化的具体实现
下面这段是构图阶段最核心的代码逻辑,我在不同项目中改过很多版本,这个版本是稳定且可解释的。
import torch import torch.nn.functional as F # node_feat: [num_nodes, dim],已经做过L2归一化 # sim = node_feat @ node_feat.T # 余弦相似度,已经归一化则直接点积 def build_sparse_graph(node_feat, top_k=8, sim_threshold=0.7): num_nodes = node_feat.size(0) # 去掉自环的相似度,对角线置为一个极小值 sim = node_feat @ node_feat.t() sim.fill_diagonal_(-1.0) # 第一步:阈值过滤 mask = sim > sim_threshold # 第二步:top-k 稀疏化,对每一行只看最大的 k 个相似度 topk_vals, topk_idx = sim.topk(top_k, dim=1) row = torch.arange(num_nodes).repeat_interleave(top_k) col = topk_idx.reshape(-1) # 阈值和 top-k 取交集 keep = topk_vals.reshape(-1) > sim_threshold row = row[keep] col = col[keep] # 去掉 self-loop,防止重复建边 no_self = row != col row = row[no_self] col = col[no_self] # 去重:无向图需要合并双向边,或者直接保留单向按有向图处理 edge_index = torch.stack([row, col], dim=0) edge_weight = sim[row, col] return edge_index, edge_weight # 使用时,把时间邻接边单独构建后和语义边拼接 # time_edge_index 由相邻节点索引生成 # all_edge_index = torch.cat([semantic_edge_index, time_edge_index], dim=1) # all_edge_weight = torch.cat([semantic_edge_weight, time_edge_weight])这段代码看着简单,有两个细节值得解释。去重这一步,如果不处理,两个相邻节点相互指向会形成两条有向边,图卷积层会在这两个节点之间做两次消息传递,等效于双倍权重了;我一般保留单向即可,或者用无向图的标准化方法处理。另一个是阈值和top-k的顺序,先top-k再阈值比反过来更快,因为topk操作返回的是稠密小矩阵,后续过滤的运算量小很多。
如果视频特别长、节点数上万,用稠密相似度矩阵计算会吃几十GB内存。这时候不要直接构造完整矩阵,应该分块计算,或者用随机采样的方式估算近邻,更符合工程落地。
3.3 图神经网络编码器的选择
图建好了,下一个问题是用什么编码器做消息传递。我先给个结论:朴素GCN、GraphSAGE这一类轻量级卷积就够了,不必上来就上GAT、GTN这类复杂度高的模型。理由也很简单:视频图表征的重点在“图建得准不准”,而不是编码器有多强;我们构建的边已经把大prior注进去了,剩下只要在节点域内做局部聚合,层数又只有两三层,轻量卷积完全够用。
我常用的图卷积层设计是:线性变换加邻域均值聚合,加残差连接,加L2归一化。两层到三层卷积,隐藏维度512,就已经能在大多数长视频任务上稳定收敛。用GAT这类注意力图卷积也能做,但注意长视频的语义边本身就来自相似度,注意力机制再次度量相似度,信息增益不明显,还会增加训练时间。
消息传递之后,每个节点会携带自己片段的信息和来自全图相关片段的信息,此时就需要通过池化把节点表示汇总成视频级表示。我用注意力池化,让模型对每个节点学一个重要性分数,然后加权求和;这个比分数的直接平均有可感知的提升,尤其是在事件稀疏的视频里,注意力能自动聚焦到真正的关键事件片段上。
3.4 超参推荐与选择依据
这里列一张我实际跑不同场景积累的表,可以直接抄作业。节点规模不同,参数必须跟着调整:
| 场景 | 节点数 | top_k | 相似度阈值 | GNN层数 | 隐藏维度 |
|---|---|---|---|---|---|
| 课程录像(约1小时) | 300-800 | 8-12 | 0.7-0.8 | 2 | 512 |
| 赛事集锦(约20分钟) | 100-300 | 5-8 | 0.75 | 2 | 256 |
| 监控视频(约几小时) | 1000-3000 | 10-15 | 0.6-0.7 | 3 | 512 |
| 纪录片(约1小时) | 500-1500 | 8-10 | 0.65-0.75 | 2 | 512 |
为什么监控节点数量大时要加大top_k?因为长监控视频里事件密度特别低,语义相似的片段相对分散,留出更多边可以让信息跨越更大的范围传播。相反,集锦视频本身信息密度高、场景切换快,top_k调大反而会让主流事件干扰稀有事件,要收敛一些。
阈值这边的经验是:如果后续模型频繁过拟合在少量极端节点上,阈值往0.8以上调;如果模型欠拟合、分类准确率上不去,阈值先降一档再看构图结果,不要只动学习率。
4. 下游任务怎么适配这张图
4.1 长视频分类
分类是最直接的任务。整条pipeline是:视频抽帧、切节点、抽特征、建图、图卷积、注意力池化、分类头。唯一需要注意的是,分类头的输入不只是池化后的节点聚合,最好把节点级预测和视频级预测结合起来一起监督,给模型一个多任务信号。比如课程录像分类,在编码器后接一个小分类头预测每一段属于什么场景,另一个分类头预测整个视频属于什么课型。两个头共享编码器参数,节点级监督能让encoder更关注局部事件,视频级监督保证最终输出的全局一致性,这个组合我测试下来比单独视频级监督高出好几个点。
4.2 视频问答与事件检索
问答任务里,图结构可以作为跨模态交互的中间表征。具体做法:视频图编码完的节点特征,和文本问题的编码表示做跨注意力交互。这里不要直接把问题文本和所有节点一起做自注意力,而是用图聚合过的节点特征和问题向量做双线性交互,计算量小,效果也不差。检索场景更简单,整个视频表示出来之后,拿这个向量和文本向量做点积排序。
做事件检索时有个技巧:训练阶段不要只用视频级表示做对比学习,要引入图里的时间边做时序感知的负样本采样。比如把节点表示打乱顺序生成负样本,因为时间边被打乱后,模型就无法利用时序依赖来区分真假视频,这个监督信号能把图的时序结构逼出来,检索召回率会有明显进步。
4.3 时序动作定位的图思路
时序动作定位可以说和图结构最契合,因为建图时节点本身就带时间戳。模型输出不用直接回归绝对时间,而是让每个节点输出一组偏移量和分类概率:偏移量指“该节点内动作起止点相对节点边界的偏移”,分类概率指节点所属的事件类别。解码时把节点和事件预测映射回原始视频时间轴,再用时序非极大值抑制合并重叠片段。
这里的一个普遍难点是事件边界恰好落在节点中间。我的规避方案是构建训练标签时做软标签:如果一个事件覆盖节点的一半以上,就给这个节点打事件标签;当事件落在两个节点之间时,让这两个节点都产生一定的概率,最后在解码阶段靠时序合并解决。这样不会因为切分粒度问题产生误杀。
4.4 层次化图和跨场景建模
视频特别长、包含多个明显不同的场景时,一层图往往不够。我建议构建两层图:底层是片段节点组成的图,顶层是场景节点组成的图。先把低层图编码完,按场景归属做池化得到场景节点特征,再在场景层建一张语义图,之后再反向把场景表示分发回每个片段节点。这一上一下的迭代过程,可以让每个片段同时感知自己所在场景的其他事件,信息交互非常充分。
跨场景建模是一个容易被忽视的价值点。比如体育赛事里,上半场某一方的一次进攻策略,可能和下半场某个得分相关;不跨场景,这两段永远没有消息传递,模型就只能把它们当作孤立事件。加了层次化图之后,事件之间的关联就有机会被挖掘出来,这是纯序列模型做不到的。
5. 实战中的坑与排查实录
5.1 图太密,显存爆炸
我发现很多第一次上手的人,建完图之后就卡在训练这一步,显存直接拉满。最常见的直接原因是语义相似度阈值设太低,高相似度节点间形成巨大连接团,边数量级从几千变成几十万。
排查方法很简单:打印边的数量、平均节点度、最大节点度。如果平均每个节点度超过50,或者最大节点度超过200,图已经太密了,训练速度和显存都会出问题。先把top_k降到5到8,再观察平均节点度是否降到可接受范围。还有一个骚操作,是给边权重做一次softmax归一化,让每个节点收到的消息总和为1,这能显著缓解消息堆积引发的数值不稳定,不是结构问题但往往表现为训练不收敛。
5.2 节点粒度拿不准
节点切太粗,事件边界不清晰,分类准确性差;切太细,节点数量大,图构建和训练都会变慢,而且单节点特征没有足够的上下文,语义边里的相似度也容易虚高。我的判断标准是从下游任务出发倒推:如果任务要求时间定位到秒级,那么2秒节点已经是上限;如果只是判断视频大类和事件类型,节点可以放宽到30秒甚至1分钟。
一个低成本实验可以在正式训练前做:把节点粒度按2秒、5秒、15秒三档各建一次图,用同一套特征和分类头跑少量epoch,看验证集曲线的区分度。这一步花费小、收益明确,基本能确定这个任务适合的刻度。
5.3 边权不均衡导致消息淹没
时间边和语义边的量级经常天然不均衡。如果语义边权重按余弦相似度取值在0.7附近,时间边权重取1,那么消息传递时,语义信息会被时间信息压制,长程依赖又等于没建模。另一种相反情况,语义相似度过高导致时间序被打散,事件因果顺序丢失,模型对“A之后才出现B”这种依赖学不进去。
解决方式是对两类边分别归一化,再乘一个可学习的权重系数。初始值可以让时间边和语义边各占一半权重,训练中模型自己调。在消融实验里,这个简单的可学习系数带来的收益比换个更花哨的GNN层要大得多。
5.4 图过平滑与层数失控
图神经网络层数叠加到4层以上,节点特征会倾向于收敛到同一个值,这种“过平滑”现象在视频图上出现得尤其快,因为语义图本来就是高连通度。长视频图的直径通常很短,两层GNN已经能让任何两个节点通过几步相互影响,再加层数几乎必然过平滑。
我建议默认两层,最多三层。如果确实需要更深的感受野,与其加深不如加残差连接,或者用JK-Net式的跳跃结构。过平滑还有个不易察觉的症状:训练集准确率很高但验证集基本不变,这个症状很容易被误判为过拟合,其实更可能是图太深导致特征坍缩了。
5.5 正样本稀疏,模型学习倾向倒退
长视频事件密度低,节点级监督里正样本可能只占十分之一甚至更低。直接训练,模型很快就变成全预测负样本,准确率虚高但没意义。把节点级和视频级损失分开权重解决不了本质问题,真正有效是重采样加对比学习:正样本节点过采样,负样本节点降采样,再额外加一个节点级对比损失,让同一事件的节点向量互相靠拢、不同事件的互相推开。
我在一个赛事分析项目里这么处理之后,事件定位的准确率提升了超过10个点。对比学习还有一个额外的好处:它让图卷积输出更规则的节点分布,对后续的注意力池化来说,可学空间也更大。
一个偏实操的收尾
我自己的经验里,最影响最终效果的不是GNN选型,也不是训练技巧,而是建图时边的质量。花一晚上调相似度阈值和top_k,往往比花一周换一个花哨的编码器划算得多。另外,第一次做长视频图表征的时候,别急着端到端训练,先用抽好的特征和简单的线性分类器跑一版baseline,再逐步叠加图结构。这个baseline能帮你快速排除特征抽取、节点划分这些前置环节的干扰,否则图模型出了问题,你根本不知道是图建错了,还是数据预处理就崩了。
这部分我重点聊的是视频如何构造成图、图如何驱动表征;第二部分我准备展开讲一讲,图的表征学出来之后,怎么在长视频检索、结构化描述和跨模态对齐这些场景里真正把它用起来。