自建数据集跑PoseC3D,第一次训练往往只是“试跑”,第二次才真正决定项目上限。这个系列前几篇把数据采集、骨架提取、配置文件都讲完了,这篇就专门说第二次训练——不是把命令再跑一遍那么简单,而是带着第一轮暴露的问题去做针对性优化。我会把这次调整的完整逻辑、训练过程中的实时决策、以及踩过的几个坑全部摊开讲。
先说结论:第二次训练后,验证集准确率从63.2%提到86.7%,过拟合gap从33.5个百分点缩到5.7个百分点,最弱的“原地跳”类别从41%拉到82%。看到数字时挺高兴,但真正有价值的是这一路怎么排查、怎么改配置、怎么验证。
1. 为什么会有第二次:第一次训练暴露的三组问题
先回顾一下第一次训练的最终结果,方便对照后面所有改动。
| 指标 | 第一次训练 |
|---|---|
| 训练集准确率 | 96.7% |
| 验证集准确率 | 63.2% |
| 过拟合gap | 33.5% |
| “原地跳”类别验证准确率 | 41% |
| “挥左手” vs “挥右手” 混淆率 | 约27% |
这三个数字分别指向三类本质性问题,第二次训练所有改动都围绕它们展开。
表面看是“过拟合严重”,但往深挖,过拟合只是结果,根因有三。首先,类别样本量严重不平衡。我自建数据集时,基础动作类各有几百段视频,但“原地跳”这类偏冷门的动作只收集了72段有效样本。模型在训练集上见过太多次其它类别,对少量类别根本没有足够的模式支撑,自然学不好。这属于数据层面的硬伤,光靠调模型参数救不回来。
其次是正则化不足。第一版配置里我几乎没开任何数据增强,骨架数据送入模型前只有中心化和缩放,训练时也没做随机扰动。对PoseC3D这种3D卷积网络来说,骨架序列本身的自由度比图像低,模型很容易把训练集里的个体姿态特征当成通用规则记下来。训练集准确率冲到96.7%,验证集只有63.2%,gap就是这么拉开的。
第三个问题比较隐蔽但很致命:左右镜像动作混淆。“挥左手”和“挥右手”这类互为镜像的动作,在骨架坐标空间里本质上是一个负号的区别。第一次训练时有27%的验证样本被分错到相反方向。逐帧检查标注数据后,我发现了两个原因:一是采集时有一部分视频是站在志愿者侧面拍的,左右手判断因人而异;二是后期人工标注时把少量片段的左右手标签标反了。这属于标注一致性问题,不修数据的话,怎么调模型都白搭。
所以第二次训练的改进路线也就清晰了——先修数据和丰富数据,再针对性调整训练配置,最后做多维度的验证。这是整个系列里改动最大的一次训练。
2. 从数据下手:不追求加量,先补最弱的类别
第一次训练最容易犯的错误就是“闭眼堆数据”。我也差点这么做——看到验证集准确率低,第一反应是再录几百段视频丢进去。但冷静下来一统计,问题不在总量,而在分布。
2.1 先做类别数量体检
把训练集每个类别的样本量拉出来,看到最弱的那几个类别时,我明确了自己该干什么。
| 动作类别 | 第一轮样本量 | 验证集准确率 |
|---|---|---|
| 原地跳 | 72 | 41% |
| 上台阶 | 104 | 53% |
| 侧向跨步 | 118 | 57% |
| 双手上举 | 126 | 60% |
| 腰部拉伸 | 131 | 58% |
| 挥左手 / 挥右手 | 180 / 184 | 61% / 58% |
最少的“原地跳”只有72段,而“走路”有355段。模型用355段学“走路”,只用72段学“原地跳”,结果一目了然。
所以补充数据的策略很明确:不平均用力,把所有类别的样本量都拉到200段以上。尤其“原地跳”这类动作,直接想办法翻了3倍多——新增了166段有效视频。一个细节是,家里和办公室的落地窗位置不同,角度也完全不同——其中有些人会把落地窗作为一个背景对照,但我们做的短视频动作不涉及隐私问题,就是纯动作视频,这个可以放心。不过要提醒一句,采集时尽量背景变化大一点,别让模型学会用背景认动作,我们这次特意去补了一些户外和不同室内场景的数据。
2.2 标注一致性的可视化检查
这是第二次训练前我认为最值得做的一件事。用骨架可视化工具把训练集里所有“挥左手”“挥右手”样本逐帧看一遍,重点检查关节连接的左右是否和视频内容一致。
具体做法:把每段视频的骨架序列渲染成带关节点和骨骼线的帧图片,按批导出后快速翻看。因为骨架点是有顺序的,比如COCO格式里第5、6、7号点对应左肩、左肘、左腕,第2、3、4号点对应右肩、右肘、右腕,如果渲染图和原始视频左右反了,一眼就能看到。
这一轮检查下来,修复了9个标反左右手的样本,删除5个骨骼跟丢严重的样本。同时把一些被错误归为“双手上举”但实际只抬了一侧手的样本重新归类。
提示:自建数据集最容易在标注阶段积累错误。骨架可视化检查虽然费时间,但比模型迭代后发现错误再回头改数据高效得多。建议每一个类别都抽查,而不仅是表现差的类别。
2.3 合并数据并重新划分训练集与验证集
新增数据和修正标注之后,总有效样本量大约从1200段增加到1450段。这里有个很容易踩坑的地方:不能简单把新旧pkl拼接。
中间有个很惨痛的教训,后面会详细讲,这里先给正确流程。合并时要注意三点:一是老样本里被修正标注的那些视频,需要重新生成骨架pkl,不能只改label;二是新样本的keypoint数组维度要和旧的完全一致,包括关节数、最大人数、帧窗口长度;三是合并后要重新划分训练集和验证集,确保同一段视频的不同片段不会同时出现在训练集和验证集里。
第一次划分时没注意这个问题,有个动作的视频连续帧被拆进训练和验证两个集合,导致验证集虚高。第二轮彻底按“视频文件级”划分,同一条视频的所有clip只能进训练或验证其中之一,才算干净。
2.4 类别均衡采样
数据量补上来以后,光靠随机采样,每次epoch里“原地跳”样本出现次数仍然偏少。我在data pipeline里加了一个类别均衡采样器,让模型每轮都保证看到一定比例的困难类别样本。效果就是小类别不再被大类别淹没,每个epoch的有效学习信号更均匀。
这里有个小权衡:类别均衡采样会让模型在遇到极不均衡的真实环境时略微“偏科”,但对自建数据集这种中小规模项目,利远大于弊。如果后面采集了更多数据,再考虑切回自然分布也不迟。
3. 训练配置的针对性调整:从过拟合和学习率策略入手
第一次训练用的是比较保守的官方默认配置:SGD、初始学习率0.02、weight decay 0.0001、训练90个epoch、学习率在60和80个epoch时阶梯下降。这个配置在公开数据集上没问题,但在自建小数据集上明显不够。
3.1 为什么第一版会过拟合
一个重要原因是骨架数据增强全关着。我想最典型的是,第一次训练中除了中心化和尺度归一化,几乎没有任何随机扰动。模型看到的骨架形态永远一模一样,pose稍微变化一点就“认不出”。人在现实里做同一个动作,关节角度、动作幅度、时间节奏都有波动,模型却完全没见过这些变化,泛化自然不行。
另一个原因是weight decay设得太低。0.0001对图像模型是常见取值,但骨架数据输入维度相对低、特征本身结构化程度高,模型更容易记住训练集个体的具体姿态。适当加大权重衰减,能强迫网络学更本质的运动模式,而不是背下每个人的“动作签名”。
3.2 这次打开了哪些数据增强
第二次训练我在数据流水线上做了一轮增强组合,具体如下。
| 增强项 | 建议取值 | 作用 |
|---|---|---|
| 水平翻转 | 开启,概率0.5 | 打破左右偏向,缓解左右手动作混淆 |
| 骨架尺度扰动 | 0.9 ~ 1.1倍缩放关节坐标 | 模拟拍摄距离和人体高矮差异 |
| 时间方向随机平移 | 最多偏移4帧 | 模拟动作起止时间的不确定性 |
| 时间方向随机裁剪 | 48帧窗口内随机取44~48帧,再插值回48 | 防止固定窗口造成时间相位过拟合 |
| 关键点随机失活 | 5%概率把单关节坐标置零 | 提升对检测器漏检或遮挡的鲁棒性 |
第一次开增强的时候,训练很快暴露了问题——加入水平翻转后训练loss突然飙升(这个后面会细说)。增强不是直接打开就完事,参数要根据项目的骨架质量来调。如果检测器本身就有大量漏检,关键点失活概率不能开太高,否则模型看到的全是“残缺骨架”,反而学不到动作模式。
3.3 优化器与学习率策略调整
我保留了SGD加momentum的组合,PoseC3D的骨架输入信号相对容易收敛,不需要AdamW那种自适应步长带来的“额外加速”,SGD加正确的调度策略反而更稳定。
具体改动:初始学习率从0.02降到0.01,weight decay从0.0001提到0.0005,学习率调度从阶梯下降换成cosine退火,并开启5个epoch的warmup。
这里解释一下为什么换cosine。阶梯下降的问题是后期步长突变,模型在某个epoch突然从一个相对平缓的解空间跳到另一个,验证集精度跟着跳。第一次训练在60个epoch后验证集精度几乎不再上升,就是因为后期步长太大,模型在最优解附近来回摆动。cosine退火让学习率平滑下降,前半段快速探索,后半段精细收敛,最终精度通常比阶梯下降高一点,而且更稳定。
3.4 为什么这次没有换heatmap分支
PoseC3D有两类输入表示,一类用关键点坐标堆成张量,另一类先把关键点渲染成高斯热力图再送入网络。heatmap分支信息更丰富、精度上限更高,但训练和推理都要大很多,而且需要额外的渲染预处理。
这次项目规模不大、动作种类也不复杂,用关键点坐标分支已经够吃,没必要为了追时髦换输入。如果后期动作类别翻倍、或者要识别非常精细的手部动作,再考虑heatmap版本也不迟。这种选择直接关系到训练周期和显存成本,不是越复杂越好。
4. 第二次训练实录:配置、曲线、中间检查点
配置改完,直接跑第二次训练。这一轮我做了更细致的记录,不光是最后看一眼acc,而是每个epoch都盯loss曲线和验证趋势。
4.1 最终训练的配置文件要点
下面是训练配置里最关键的部分,用伪配置文件展示,读者可以对照自己的项目做替换。
# 模拟配置片段,完整配置需按实际框架填写 model = dict( type='PoseC3DRecognizer', backbone=dict( type='C3DNet', in_channels=2, # 关键点通道:x和y(也可以加score变成3) base_channels=32, depth=18, norm_cfg=dict(type='BN3d'), act_cfg=dict(type='ReLU') ), cls_head=dict( type='I3DHead', num_classes=10, # 动作类别数 in_channels=32, dropout_ratio=0.5 ) ) train_pipeline = [ dict(type='UniformSample', clip_len=48), dict(type='Flip', flip_p=0.5, flip_label=True), dict(type='ScaleSkeleton', scale_range=(0.9, 1.1)), dict(type='ShiftSkeleton', shift_range=4), dict(type='MaskSkeleton', mask_prob=0.05), dict(type='CollectSkeleton', keys=['keypoint', 'label']) ] optimizer = dict( type='SGD', lr=0.01, # 从0.02降下来 momentum=0.9, weight_decay=0.0005, # 从0.0001调大 nesterov=True ) lr_config = dict( policy='CosineAnnealing', # 从StepDecay换成cosine warmup='linear', warmup_iters=5, warmup_ratio=0.1, min_lr_ratio=0.01 ) train_dataloader = dict( batch_size=64, sampler=dict(type='ClassBalancedDataset') )关于batch size多说一句。之前用过128,后来降到64。PoseC3D在小数据集上不需要那么大的batch,batch太大反而容易收敛到尖锐极小值,泛化差一些。小batch相当于引入了额外噪声,配合SGD做“丢手”式的探索,在小数据集上往往效果更好。
4.2 训练曲线:比第一次顺,但也有插曲
第二次训练的loss曲线整体比第一次平稳得多。前5个epoch是warmup阶段,lr从0.001升到0.01,loss从3.2左右往下降。epoch 10之后,训练集acc过80%,验证集在65%~70%之间徘徊。
关键转折在epoch 30之后。cosine退火进入中段,学习率开始明显下降,验证集acc从72%跨到80%以上。这里有个现象我想单独说一下:验证集精度并不是一路平滑上升,而是在78%附近来回震荡了大约5个epoch。第一次训练遇到这类震荡,我可能会急着改参数,但这次想过之后判断这是增强带来的正常波动——骨架数据被加入了随机平移和失活,验证集又包含不同拍摄角度,模型需要一定时间在“接受噪声”和“保持稳定”之间找平衡。果然熬过这个平台期后,epoch 38开始验证集重新爬升。
epoch 60之后,训练acc大约92%,验证acc 84%,gap已经不到8%,比第一次的33.5%健康太多了。到最后一个epoch,训练集acc稳定在92.4%附近,验证集最终86.7%。
4.3 训练中途的难堪时刻:loss在epoch 7突然跳高
这条必须记下来。第二次训练大概在epoch 7的时候,训练loss从1.2左右突然跳到2.1,过了两个epoch才慢慢回来。我当时第一反应是“学习率炸了”,但检查后发现lr没有异常。结合那个epoch刚好是数据增强随机种子触发了特定的Flip组合,我怀疑问题和flip有关。
排查之后确认,问题出在“bone特征”的翻转逻辑上。PoseC3D里除了原始joint坐标,还可以额外计算bone向量(骨骼边方向),作为分支输入。我在配置里开启了flip增强,会让骨架在x方向镜像。但bone向量的计算如果基于原始坐标做差分,翻转后向量的正负方向会和原始bone相反。打个比方,挥右手时右上臂的bone方向在原始坐标系里指向右下方,水平翻转后坐标变成左下方,但模型训练时label也从“右手”翻成“左手”,bone方向却还是按原始方向解释,特征分布突然就混乱了。
解决办法是在flip增强内部同步处理bone向量:如果检测到水平翻转,bone向量应该基于翻转后的坐标重新计算,或者对原始bone的x分量取反。第二次训练修复后,最后没有保留bone分支,只用了joint分支——原因很简单,这个项目里joint分支已经够用,多一个分支就要多调一组翻转逻辑,增加不必要的复杂度。
4.4 中段检查点:不只是看acc,还要看损失结构
很多人在训练中只看一个validation accuracy,其实对自建数据集,我建议每个20个epoch打印一次验证集每个类别的acc。如果只看总分,很容易漏掉“某些类别始终不涨”的隐性问题。
epoch 40检查时,我发现“侧向跨步”类别的acc只有61%,明显低于其它类。回看数据,这类动作在采集时有一个典型的争议视频——快走到一半时略微停顿。骨架序列上表现为中段几帧位移很小。我把这类样本挑出来重新看,发现它们被错误标注成了“上台阶”,因为动作的前半段太像了。修了12个样本的标签后,这个类别在epoch 60的检查里就冲到了83%。这种阶梯式排查,只看总acc是发现不了的。
5. 验证:不只盯着总准确率,还要看类别和真实场景
训练结束后,验证不能只跑一遍测试集然后看acc数字。我按三类维度做了检查:整体指标、单类别细分、真实场景泛化能力。
5.1 整体指标对比
最终结果如下表,改进幅度一目了然。
| 指标 | 第一次训练 | 第二次训练 |
|---|---|---|
| 训练集准确率 | 96.7% | 92.4% |
| 验证集准确率 | 63.2% | 86.7% |
| 过拟合gap | 33.5% | 5.7% |
| 平均类别F1 | 0.58 | 0.85 |
刻意让训练集acc“降下来”,这听着反直觉,但实际是模型从“背答案”转向“理解规则”。训练集能到100%才有问题,小数据集上训练acc略低于验证acc有一部分是增强带来的正常现象,只要验证acc持续上升就没问题。
5.2 单类别验证结果
这里看三个典型类别。
| 类别 | 第一次验证 acc | 第二次验证 acc |
|---|---|---|
| 原地跳 | 41% | 82% |
| 挥左手 | 61% | 89% |
| 挥右手 | 58% | 91% |
| 上台阶 | 53% | 84% |
“原地跳”能从41%到82%,主要归功于新增样本和类别均衡采样。“挥左手/右手”的混淆率从27%降到4%左右,数据修标注和flip增强都起了作用。有个细节,flip增强在解决左右混淆时是把“左手”镜像成“右手”让模型学共存特征,但这里要小心模型学到的是“永远把某个动作统一预测为同一个类”,那只是把自己的混淆“藏起来”。我特意检查了混淆矩阵,确认左右手类别是分离的,不是被拍扁了。
5.3 真实场景测试:不在训练集里的视频
数字再漂亮,也要拿到现实中使用场景里看。我让同事用手机横屏对着我录了6段动作,时长、环境、角度都和训练集不太一样,然后跑模型预测。
结果:6段里5段完全正确,唯一错误的一段把“原地跳”识别成“上台阶”。回看那段视频,我跳起来时上半身有明显的下压再弹起动作,确实和“上台阶”的骨架时序有相似。这提醒我,动作识别的边界没法靠无限堆数据来解决,现实动作本来就是连续的、有交叉的。真实测试永远值得做,因为test集再干净,也和真实世界有分布差异。
5.4 仍然不理想的地方
诚实说,有些类别依旧短板。对“侧向跨步”最远视角的那几段测试视频,模型预测置信度只有0.62~0.68,虽然类别对了,但不够“笃定”。这类问题可能需要专门补充“小尺寸人体”的训练样本,或者在推理时做多帧平均。
以及“弯腰捡东西”这个类别有两条测试视频没通过,一次判成了“腰部拉伸”。看骨骼序列,弯腰和拉伸前段的髋关节角度变化接近,如果后腰弯曲发生在画面侧面,特征就更模糊。这个可以留到第三次训练时再优先优化。
6. 这次踩的坑与完整排查过程
这一节把最容易让后来人浪费两三天时间的三个坑完整写出来。
6.1 坑一:合并pkl后训练acc卡在50%附近
第二次训练我本来设想的第一个epoch acc应该直接起步在40%~50%,然后快速上升。结果跑了两轮,训练acc一直卡在49%~52%之间,验证acc也只有40%上下,loss下降极慢。那种“一眼就知道不对”的闷盒感,和正常训练完全两样。
排查过程我按四步走。
第一步,查loss和acc曲线形态。如果模型初始化有问题,loss会明显偏大且不下降,这回loss还在降但acc完全原地踏步,更像是“标签乱”而不是“模型蠢”。第二步,随机抽取一个batch检查输入输出。我发现dataloader返回的keypoint张量对应的动作类别,和pkl里标签完全对不上,像两个人在各说各话。第三步,检查新旧pkl的结构字段。旧pkl是自己在原始代码上直接保存的,新pkl经过新的可视化工具重新生成,两边对keypoint索引的组织方式不同——旧数据把通道放最前,新数据把通道放最后。合并时我只做了“拼接数组”,没有统一轴顺序。到这一步,dataloader按统一后的索引取数,自然全错。第四步,重写合并工具,强制把新旧数据的shape对齐为完全一样,再做一个校验函数——取前10个样本,对比数组和标签是否能重现训练日志里的数字。
这个坑的直接教训是:合并不同批次的数据时,先对齐字段和shape定义,再写合并逻辑。别假设两份pkl是“同构的”。
6.2 坑二:flip增强导致loss突然冲高
前文已经讲到它,这里展开一下排查逻辑。
当时我在yaml里开了一个简单的flip开关,预期是loss平稳下降,结果epoch 7训练loss突然从1.2跳到2.1。我第一时间怀疑是增强幅度太大,让模型看到太多“形态夸张”的样本。但随后测试,单独把flip关掉,loss又恢复正常。于是问题锁定在flip本身。
我的排查方式是:拿同一个clip,手动做一次flip,打出骨架数组和bone数组对比。结果发现,joint坐标正确翻转了,x轴取反;但bone向量还是基于原始坐标做差分的,坐标翻转后bone向量方向没有跟着变。放在数学上,就是bone向量在翻转前后符号不一致。模型训练时,同一类动作的bone特征一会儿正一会儿负,学个寂寞。
修复:flip增强必须同时翻转bone向量。具体来说,如果bone是关节i减关节j,那么在flip之后,bone的x分量也要取反,同时关节索引的顺序也要按翻转后的语义重新对应。修复后重跑,loss掉回正常范围。
这里也解释了一个为什么“官方pipeline的flip默认是关的”——不是因为flip不重要,而是它牵扯坐标轴、bone向量、label语义三个层面的联动,很多人没做对。
6.3 坑三:时间维度随机裁剪过头导致模型“看不到”动作起止
这个坑是在训练epoch 50前后发现的。我开了时间方向的随机裁剪,窗口在44~48帧之间随机取,然后统一插值回48帧。起初没多想,后来发现某几个类别的验证acc突然小幅下降,而且只在这几个“动作起止节点比较关键”的类别上发生。
排查后确认:裁剪48帧窗口时,如果正好把动作的起始帧或结束帧裁掉,而插值又会把剩余帧拉长,模型看到的动作节奏就变形了。比如“原地跳”要求完整看到“下蹲+起跳+腾空+落地”的周期,如果裁掉了最开始的预备下蹲,模型看到的就是一个“无预备起跳”,和“上台阶”就更难区分。
解决思路是:把裁剪范围调到“46~48帧之间取”,同时配合时间方向的小偏移而不是激进裁剪。更稳妥的办法是保证clip覆盖整个动作周期,而不是在时间轴上随机开窗。自建数据集里往往一段视频包含动作的不止一个周期,更需要先手工标注动作片段边界,再做裁剪。
注意:数据增强不是越多越好。每个增强项在解决一个问题的同时,也会扭曲某些关键运动信息。开增强后如果看到特定类别acc下降,优先排查是不是增强逻辑破坏了该动作的“关键相位”。
写在最后的个人体感
训完第二次,最大的感受是:模型改得好不好,其实只占一半;数据清洗和增强逻辑的配合,才是自建数据集训练的大头。我建议每个做自建数据集的人,在第一次训练结束后都列一个问题清单,然后一条一条去改数据和配置,而不是直接“再跑一次看看”。
第二次训练的最终config和工具脚本我已经整理好,放到之前的系列目录里了。如果你正要开始第二次训练,先把上一次的混淆矩阵和类别acc翻出来,对着它做这一步。在这个项目里,最花时间的不是训练过程,而是带着问题清单做定向优化的过程,但恰恰是这些时间最值得花。