二次训练这件事,听起来比第一次平淡,实际上才是真正磨数据、磨配置、磨耐心的阶段。第一次训练我在自建数据集上跑完 PoseC3D,验证集 Top-1 只有 61.3%,训练集却冲到 98.7%——典型过拟合,而且不是一般的过拟合,是那种数据、划分、采样策略全都有问题的过拟合。第六篇就专门讲讲第二次训练,怎么从 61% 这种“能跑但没法用”的状态,一步步调回到 82% 左右的稳定水平。
这篇内容适合正在自建动作数据集、用 PoseC3D 或者类似骨架模型做训练的人。如果你已经跑通了第一遍全流程,但对结果不满意,这篇就是给这种“半成品训练”擦屁股的实战记录。
1. 复盘第一次训练:成绩单与错误点
1.1 第一次训练的配置与结果
先把第一次训练的配置摆出来,后面对比起来才清楚。
我当时的数据情况是:自己采集并标注了 8 类动作,每类样本数量很不均匀。少的类别只有 60 多段,多的类别接近 700 段,总共 1500 段训练片段、200 段验证片段。骨架数据用的是 17 个关键点的 COCO 格式,输入时序长度固定为 30 帧,主干是 PoseC3D 默认的 3D 卷积网络。
第一次训练我图省事,直接套了类似图像分类的套路:
- 优化器:Adam,初始学习率 1e-3
- 损失函数:普通交叉熵
- 训练轮数:40 epoch
- 批大小:64
- 数据增强:只做了轻微的关键点坐标归一化,没有任何扰动
- 训练集和验证集随机按“片段”划分,没有严格隔离人员
这个配置跑完,训练集 Top-1 是 98.7%,验证集 Top-1 是 61.3%。我第一反应是模型太简单、数据太少,后来一项项排查才发现,问题根本不在模型,而在好几个环节都有隐藏坑。
1.2 为什么第一次训练会过拟合
很多人一看到训练集 98%、验证集 61%,第一反应就是“加数据增强”“加 Dropout”“换小模型”。但 PoseC3D 这种基于骨架的模型,和图像分类的过拟合来源并不完全一样。
我复盘后认为,第一次训练的过拟合主要有三个来源。
第一个来源是数据划分泄漏。我当时是按“片段”随机分训练和验证,但同一个人的同一组动作往往被采集了好几段,这些片段之间只是拍摄角度、位移略微不同,骨架序列高度相似。于是验证集里混进了大量训练集的“近亲样本”。这种情况下,模型即使没学到真正的动作泛化特征,只要死记了这些相似序列的倾向,训练集指标也会很好,验证集上那些“稍微新一点”的同类样本反而识别不准。
第二个来源是长尾类别问题。数量最少的类别“跌倒”只有 60 多段,在 40 个 epoch 的训练里被反复看过很多遍,模型很容易把这几个样本彻底背下来。普通交叉熵对每个样本一视同仁,头部类别梯度很大,尾部类别过拟合到几个具体样本上,验证集遇到新样本就崩。
第三个来源是时序采样没做好。我第一版是直接把每段视频均匀抽成 30 帧。但自建数据采集的视频长短不一,动作速度也不同,均匀采样会把一个 2 秒的动作压成 30 帧,也可能把一个 5 秒的动作硬截断成 30 帧,导致同一类动作的关键点时序模式差异极大,模型只能记住那些“采得巧”的训练样本。
1.3 必须纠正的三个关键错误
复盘之后,我把问题收敛成三个必须纠正的关键错误:
第一,训练集和验证集必须按照“人员”或者“场景”划分,不能简单按片段随机切。
第二,类别不平衡必须显式处理,不能指望模型自己从交叉熵里悟出来。
第三,帧采样策略不能一刀切,要根据动作时长分布,设计合理的采样方式。
这三个问题都和数据有关,而不是模型结构。这也给了第二次训练一个很明确的调优方向:先动数据,再动训练策略,模型结构本身不需要大改。
2. 数据层面的修正:比调参更难察觉的细节
2.1 骨架数据的再清洗策略
第一版骨架数据是直接跑的 2D 姿态估计模型输出的关键点坐标序列,并没有做太多后处理。我第二次训练前专门写了一个清洗脚本,重点检查三件事:
一是单帧关键点置信度。正常情况下每个关键点有一个置信度分数,自建数据里经常出现背面、遮挡、快速运动模糊导致某几个关键点置信度极低。如果直接把这类帧灌进训练,相当于给模型喂了错误的位置信息。我的处理方式是:置信度低于阈值的帧,如果连续少于 3 帧,用前后帧线性插值补上;如果连续超过 10 帧,直接把这段从样本里剔除。
二是整个人体的尺度变化。同一个动作,离镜头近的人和远的人,关键点坐标绝对数值差异很大。第一次训练只做了简单归一化,相当于把所有关键点减均值除标准差,但没有按人体中心跨度和肩宽做尺度归一。第二次训练改为:以髋部中心为原点,把所有关键点坐标减去中心点,再除以人体左右肩宽的平均值。这样处理后,距离、身高、体型的影响被大幅压低。
三是关键点的“抖动”噪声。姿态估计模型输出的逐帧关键点天然存在抖动,尤其手部、脚腕这些末端点,每帧跳动几个像素是常态。我对关键点坐标做了滑动窗口平滑,窗口大小 3 帧,用简单均值滤波。这一步不能做太狠,窗口大了会把动作细节磨没,3 帧是我试下来比较稳的值。
2.2 帧采样策略:从单一均匀抽帧到分段随机采样
第一版均匀抽帧的问题前面说过了。第二次训练我改成 PoseC3D 更常用的 TSN 式分段采样思路:
对每段视频,先计算有效动作帧区间。因为自建视频一般开头和结尾都有多余的站立、走动、准备帧,全部纳入训练只会让模型更难学。我用一个简单规则截取有效区间:双手或双脚关键点位移速度连续超过阈值的帧段,取最大连续区间的起点和终点。
有效区间确定后,把它分成 30 个等长段,训练时每段随机抽一帧,验证时每段取中间帧。这样做的好处是,即使动作快慢不同,模型也能看到动作不同阶段的关键帧,而不是被固定的均匀抽帧节奏带偏。
对于明显太短的视频片段,比如只有 20 帧以内的动作,我的处理是周期性重复补齐到 30 帧。这里注意不要简单复制最后一帧,否则会引入一段静止状态,让模型误以为所有动作最后都是“停顿”。
2.3 训练/验证划分重新设计
数据划分是我这次改得最狠的部分。我把全部采集人员分成 5 组,第 1、2、3 组作为训练集,第 4 组作为验证集,第 5 组作为测试集,确保验证集里出现的人的骨架序列,训练集完全没见过。
这种按人划分的方式会让验证集指标比原来“难看”一些,因为没有了近亲样本,泛化难度真实暴露出来了。但它才是自建数据集训练的正确姿势。
划分完之后我重新统计了类别的分布,发现验证集中某些类别的样本数量更少,于是后续引入了类别权重。
3. 第二次训练的模型与超参数配置
3.1 主干与输入规格
第二次训练我没有换主干结构,仍然是 PoseC3D 默认的 3D 卷积。输入规格上,我只做了一个重要调整:把时序长度从固定 30 帧放宽到“输入 30 帧,但采样时允许随机偏移”。简单说,训练时每段样本可以从有效动作区间的多个起点开始采样,而不是死板地从头开始。这相当于给时序维度加了一层免费的平移增强。
关键点通道数保持 17,每个关键点坐标是 (x, y) 两维,所以模型输入形状是 (B, 34, 30, 17, 1)。如果你用官方 mmaction2 的 Skeleton 数据格式,还需要把坐标重新组织成 (B, M, T, V, C) 的格式,其中 M 表示人物数量,单人的时候设为 1 即可。
3.2 优化器、调度器与损失配置
第一次用的 Adam 在小数据集上收敛很快,但也容易过拟合。第二次我换成了 SGD,动量为 0.9,初始学习率 1e-2,权重衰减 5e-4。这里说下原因:PoseC3D 这种骨架模型输入的维度并不高,Adam 的自适应学习率在训练后期容易让权重在小梯度方向反复横跳,泛化性反而不如 SGD 配合退火来得稳。
学习率调度我用了余弦退火,并且加了 5 个 epoch 的线性热身。具体是这样设置的:
- 前 5 个 epoch,学习率从 0 线性升到 1e-2
- 第 5 到第 40 个 epoch,学习率按余弦曲线从 1e-2 降到 0
- 总训练轮数 40 epoch
损失函数改成了带类别权重的交叉熵。权重计算方式我采用了类频率倒数的平方根,而不是直接倒数。直接倒数的权重会把“跌倒”这样的少样本类别抬得太高,导致模型为了少样本类别牺牲大部分类别的精度。平方根形式是一种折中,既提高了少样本类别的梯度贡献,又不至于喧宾夺主。
3.3 配置修改前后对比表
我把两次训练的核心配置放在一起,方便直接对照:
| 配置项 | 第一次训练 | 第二次训练 |
|---|---|---|
| 优化器 | Adam | SGD(momentum 0.9) |
| 初始学习率 | 1e-3 | 1e-2 |
| 学习率调度 | 固定/无 | 5 epoch 热身 + 余弦退火 |
| 训练轮数 | 40 | 40 |
| 批大小 | 64 | 32 |
| 损失函数 | 普通交叉熵 | 类别权重交叉熵(频率倒数平方根) |
| 数据划分 | 片段随机 | 按人员划分 |
| 帧采样 | 均匀抽帧 | 分段随机采样 + 随机偏移 |
| 数据增强 | 无 | 关键点抖动、尺度、平移、遮挡模拟 |
| 骨架归一化 | 简单减均值除标准差 | 髋部中心化 + 肩宽尺度归一 + 平滑滤波 |
这张表基本就是第二次训练的“处方”。我没有改任何模型结构参数,所有的提升都来自数据和训练策略的修正,这一点也侧面说明,自建数据集训练时,数据管线的坑往往比模型本身更大。
4. 完整训练流程:从数据处理到训练脚本运行
4.1 准备训练与验证列表
第二次训练前,我用脚本把数据整理成了 mmaction2 需要的格式。每个样本是一行,包含:
相对路径/xxx.pkl 30 1其中第一个数字表示有效帧长度,第二个数字是类别标签。训练列表 train_list.txt 和验证列表 val_list.txt 分开生成。实际运行前一定要检查两个列表的人员是否有交叉,我写了一个简单脚本统计每段视频对应的采集人员 ID,一旦发现验证集出现训练集的同人样本就立刻剔除。
骨架数据结构方面,PoseC3D 官方要求每个样本是包含 keypoint、keypoint_score 的 numpy 数组。我建议直接效仿官方动作识别工具包的数据处理方式,用如下结构保存:
{ "keypoint": np.zeros((M, T, V, C)), "keypoint_score": np.zeros((M, T, V)) }我用的单人数据集,M 就是 1。如果视频里有两人同框,需要额外处理人员匹配问题,这个我在第五篇里已经提过,这里不再展开。
4.2 实际训练脚本的关键配置
如果你用的是基于 mmaction2 二次开发的训练脚本,第二次训练我直接在配置里改了这几个字段:
# optimizer optimizer = dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=5e-4) optimizer_config = dict(grad_clip=None) # lr scheduler lr_config = dict( policy='CosineAnnealing', min_lr=0, warmup='linear', warmup_iters=5, warmup_ratio=0.1 ) # loss loss_cls = dict( type='CrossEntropyLoss', loss_weight=1.0, class_weight=class_weight_array ) # data augmentation train_pipeline = [ dict(type='UniformSampleFrames', clip_len=30, num_clips=1), dict(type='PoseDecode'), dict(type='PoseCompact', hw_ratio=1.0, allow_imgpad=True), dict(type='Resize', scale=(-1, 64)), dict(type='RandomResizedCrop', area_range=(0.8, 1.0)), dict(type='Resize', scale=(56, 56), keep_ratio=False), dict(type='Flip', flip_ratio=0.5), dict(type='GeneratePoseTarget', with_kp=True, with_limb=False) ]这里我特别说明两点。第一,UniformSampleFrames配合num_clips=1就是分段随机采样;第二,翻转增强对于左右对称的动作类别很有效,但对“左右手分别做动作”的类别会造成标签歧义,需要根据自己的类别体系决定要不要开。
4.3 训练过程中的检查点与验证策略
我在训练脚本里设置了每个 epoch 结束都跑一次验证,并保存验证集 Top-1 最高的检查点。之所以每轮都验证,是因为小数据集训练时最优模型往往不是最后一个 epoch 的模型,而是中间某个时刻。
训练过程中我观察到了几个现象。前 5 个 epoch 由于学习率从 0 热身,loss 下降看起来比较慢,但这时模型权重还在稳定收敛,不用慌张。大约第 10 个 epoch,验证集 Top-1 涨到 70% 以上。到第 20 个 epoch 左右,验证集 Top-1 开始稳定在 78% 到 81% 之间波动。
到了第 32 个 epoch,验证集 Top-1 达到了最终的 82.4%,之后没有明显提升。
4.4 中间被忽略的评估细节
同类模型在验证时有“多线程平均”的说法。我第一次只用单片段验证,第二次训练最终评分采用了多片段投票:把每段视频分段随机采样 10 次,分别推理并取平均概率作为最终预测。这个操作通常能再提升 1 到 2 个点的 Top-1。我的测试集最终成绩比验证集高一点,也和这个机制有关。
5. 试验对比:第二次训练收获了什么
5.1 准确率指标对比
我直接用表格呈现两次训练的结果差异:
| 指标 | 第一次训练 | 第二次训练 |
|---|---|---|
| 训练集 Top-1 | 98.7% | 93.2% |
| 验证集 Top-1 | 61.3% | 82.4% |
| 测试集 Top-1 | 未测 | 83.7% |
| 验证集损失 | 持续震荡 | 稳步下降后收敛 |
| 最优模型出现 epoch | 第 39 轮 | 第 32 轮 |
第二次训练训练集 Top-1 反而比第一次低,这一点很容易被误解。我自己的理解是,第一次那种 98.7% 是“记住样本”,第二次的 93.2% 才是“学会动作”。验证集和测试集的大幅提升说明泛化能力是真的变强了。
5.2 单类别准确率的改善与剩余问题
我重点看了混淆矩阵里几个过去容易错的动作。第一次训练里,“弯腰”和“蹲下”经常互相误判,因为两者的腿部关键点角度变化很接近。第二次训练修正数据划分和尺度归一化后,这两类的混淆率明显下降。原因在于,尺度归一化让腿部坐标的肩膀宽度比例更一致,模型不再被“离镜头远近不同造成的腿部绝对位移差异”干扰。
“挥手”和“拍手”仍然有少量交叉,这是合理的,因为二者手部路径有重叠,单靠骨架序列区分本来就困难。我没有强行追求百分百分清,后续如果真的要区分这类近似动作,只能再采集更多包含肩肘角度差异的样本来补强。
5.3 这套配置的可复现性说明
如果你也想复现第二次训练的结果,我的建议是直接照搬这一套配置:
- 骨架数据必须经过清洗、平滑、尺度归一
- 训练/验证必须按人员或场景划分
- 类别权重采用频率倒数平方根
- SGD + 5 epoch 热身 + 余弦退火
- 每轮验证并保存最优检查点
- 验证阶段使用 10 次多段投票
这套配置我在类似规模的另一组数据上也跑过一次,结论是相似的。它不一定是最优解,但作为自建小数据集的起点,远比上来就用复杂随机搜参更可靠。
6. 常见问题与排查清单
每次训练都会遇到一些奇怪的错,第二次训练也不例外。我把踩过的坑整理成速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 验证集 Top-1 一直卡在 60% 左右 | 数据划分泄漏或帧采样方式错误 | 按人员划分、改用分段随机采样 |
| 训练集快速到 99%,验证集 50% | 无数据增强 + 学习率过大 | 加入关键点扰动增强、降低初始学习率 |
| 少样本类别完全不识别 | 类别权重缺失或权重过大 | 使用频率倒数平方根做类权重 |
| 验证集曲线剧烈震荡 | 学习率调度没有热身 | 加 5 epoch 线性热身 |
| 骨架坐标数值跨度极大 | 只减均值没做尺度归一 | 按肩宽归一化关键点坐标 |
| 输入形状报错 | M、T、V、C 顺序错误 | 检查是否转成 (B, M, T, V, C) |
| 中断后无法恢复训练 | 没有保存最近 epoch 检查点 | 设置每 epoch 保存,并记录优化器状态 |
排查顺序我个人的习惯是:先看数据划分有没有泄漏,再看帧采样和输入格式对不对,最后才是学习率这些训练参数。因为数据层面的错误往往会让模型陷入“怎么调都无效”的怪圈。
根据我的经验,如果你第一次训练结果很差,先别急着换模型结构,把这篇里提到的数据清洗、数据划分、采样策略和优化器配置都过一遍,大概率能涨回 15 到 20 个点。自建数据集的项目,真正值钱的往往不是网络改得多花哨,而是把数据管线里的细节打磨到什么程度。
最后再分享一个实操体会:二次训练时,我会额外存一份“最后一次 epoch 的模型”和“验证集最优模型”,不要只留最优模型。因为有一些后续推理场景需要复现训练曲线,只保留最优模型会让你丢失中间过程的调试信息。这个小习惯在后续继续数据扩增时特别有用。