简介:一份围绕ConvLSTM网络的小鼠旷场实验行为分析文档,面向动物行为学、神经科学及计算机视觉方向的研究者与学习者,用于解决旷场实验中人工观察费时、主观偏差大等问题。文档系统梳理了从视频采集、关键点检测(DeepLabCut提取鼻尖、耳、尾根)、相邻帧特征图序列构建,到ConvLSTM行为识别分类、众值滤波优化、行为时序图生成的完整流程,并明确定义了直走、转身、修饰、静止和直立五类行为的分类输出,以及行为发生次数、持续时间与行为转变模式的计算方式。内容精炼,属于技术方案类材料,压缩包内仅含1个docx文档,大小18KB,适合快速了解整体分析链路与算法模块设计。目前已有140人学习,可作为开展小鼠行为自动识别研究、设计旷场实验分析方案的参考。
1. 从旷场实验视频到行为指标:ConvLSTM 在自动化分析里到底做了什么
旷场实验是神经药理、毒理和基因行为表型筛选里最常见的范式之一,传统做法是靠肉眼盯视频或者用简单的质心轨迹算法去算移动距离和中心区时间。问题在于,像站立、理毛这类行为没有清晰的位置特征,只看单帧画面经常把静止误判成站立,中心区停留又高度依赖时间连续性。ConvLSTM 的作用,就是同时建模空间特征和帧间时序,把视频切成一帧一帧的行为类别序列,再自动汇总成行为学指标。这篇笔记面向需要用旷场实验做批量筛选的课题组,也面向想给行为分析上自动化但不想掉进手工特征坑里的工程师。接下来我会按数据准备、模型搭建、训练评估、指标导出和避坑排查的顺序,把整套流程拆开讲清楚。
2. 旷场实验的时空双维困境:为什么普通 CNN 和轨迹阈值都扛不住
2.1 旷场实验要统计的核心指标,以及它们对时间上下文的要求
旷场实验箱一般是 40cm 或 50cm 见方的敞箱,顶部俯视记录,单次实验时长 5 到 10 分钟。最常用的行为学指标有五个:总运动距离、中心区停留时间占比、进入中心区次数、站立次数、理毛时间占比。前三个指标本质上是位置轨迹的统计,后两个指标要求先判断动物当前处于什么行为状态。
总运动距离可以靠逐帧质心位移累加,进入中心区次数需要在轨迹跨过中心区边界时做事件计数。但中心区停留时间占比有一个隐藏的坑,就是小鼠经常在边界上反复进出,单帧判断会被闪烁分割成大量碎片,时间占比数据失真。站立和理毛更麻烦,行为学定义是“前爪离地”和“舔舐梳理”,只看静态帧很难区分站立和静止探头,必须有 0.5 到 2 秒的时间窗口做上下文。也就是说,旷场实验分析需要一个从一开始就带时序建模能力的方案,而不是先做单帧分类再事后平滑。
2.2 ConvLSTM 如何同时抓空间特征与时间依赖
ConvLSTM 是在 LSTM 的基础上把状态转移里的全连接运算替换成卷积运算。普通 LSTM 在处理一维序列时,输入门、遗忘门、输出门都靠矩阵乘法,但到了视频帧这种二维空间结构里,矩阵乘法会把像素之间的空间关系完全打散。ConvLSTM 的每个门仍然保留,只是把矩阵乘法换成卷积操作,让模型在更新记忆时能同时感知局部空间邻域和之前若干帧的动态变化,这是它适合旷场视频的两条核心原因。
第一,旷场里小鼠的大小、位置和轮廓在空间上是局部连续的,卷积天然具备平移等变性,小鼠在箱体左上角和右下角时,分类特征应当是等价的。第二,行为状态在时间上有很强的先验,移动结束之后往往紧跟着静止或站立,站立又可能过渡到理毛,ConvLSTM 的循环连接会把这种转换关系存进隐藏状态里。相比单帧 CNN,ConvLSTM 不再把每一帧当独立样本,而是把整段滑窗当作一个有先后顺序的事件序列来建模。
2.3 建模方案对比:光流阈值、3D-CNN、帧级 CNN 与 ConvLSTM 怎么选
我在实际项目里对比过四类方案,结论比较明确,按数据规模和可解释性来选。
| 方案 | 空间建模 | 时间上下文 | 数据需求 | 可解释性 | 工程成本 |
|---|---|---|---|---|---|
| 光流+手工阈值 | 靠前景分割和光流 | 无,逐帧判定 | 低,几十段视频即可调参 | 中,阈值可见 | 低 |
| 帧级 CNN 分类 | 卷积 | 无,单帧输入 | 中,容易抖动 | 高,特征图直观 | 低 |
| 3D-CNN | 三维卷积 | 有,但感受野固定 | 高,几百段视频起步 | 低,难以定位错误帧 | 高 |
| ConvLSTM | 卷积 | 有,循环状态累积 | 中,几十段即可训练 | 高,可按帧回溯 | 中 |
如果课题组的存量数据只有十几只小鼠的视频,光流阈值是最快出结果的,但它对光照和阴影极其敏感,换了房间就要重新调参,血泪经验确实不少。如果有几百段视频,3D-CNN 能拿到更好的精度,但训练成本和数据增强成本都会让课题组难以消化。ConvLSTM 的参数规模不大,十几只小鼠、每只 5 到 10 分钟的视频量就足够训出一个能用的模型,而且推理时可以按帧输出类别概率,方便排查错判,这是它最划算的地方。
3. 训练数据准备:切帧、标注、均衡这三步决定模型天花板
3.1 采集规格和预处理:先把视频做成干净的标准帧序列
采集条件看着简单,实际最影响后续效果。机位一定要在旷场正上方,镜头光轴尽量垂直于箱底,避免透视畸变。光照用漫反射光源,不要让箱体边缘产生明显阴影,否则小鼠走过阴影区时前景分割会把阴影一起算进去。分辨率建议不低于 640×480,帧率 15 到 30 帧每秒够用,旷场里的行为状态变化不快,再高的帧率只会增加存储和标注负担。
拿到原始视频后,第一步是切帧。我一般用 ffmpeg 把视频按固定帧率导出成 JPEG 序列,文件名直接用帧序号,方便之后和标注对齐:
ffmpeg -i mouse_01.mp4 -vf "fps=30,scale=640:480" -q:v 2 frames/mouse_01_%06d.jpg这条命令里fps=30把视频统一到 30 帧每秒,scale=640:480统一分辨率,-q:v 2保证压缩质量接近无损,避免 JPEG 压缩引入的边缘纹路干扰卷积特征。切帧之后还需要做一个固定区域的抠图,把旷场以外的实验台、笼架标签都裁掉,这样模型不需要花参数去学习环境背景。
预处理的顺序一般是这样:先把全部帧按旷场四个角做透视校正,然后裁剪出箱体内部区域,缩放到 224×224,最后做全局均值方差归一化。如果实验环境稳定,可以额外做一版背景差分,也就是拿一段空箱子视频做背景减除,得到只含小鼠的前景掩码。不过要注意,背景差分和 ConvLSTM 的分类不是互斥的,后者仍然可以继续学习行为状态,前者主要用来辅助提取质心轨迹。
3.2 帧级标注怎么做:四到六个行为类别,先从关键帧插值开始
标注行为类别前要定义类别体系。我见过不少项目一开始定义得非常细,站起来、走动、静止、理毛、抓挠、舔舐、攀爬等等,最后类别之间互相混淆,模型怎么调都分不开。合理的做法是控制在四到六类,常用的四类是移动、静止、站立、理毛,如果研究需要再加一个攀爬或嗅探。
标注不要逐帧画框,效率太低且前后标准容易漂移。常见做法是先按行为变化点把视频切成小片段,比如某一段小鼠连续移动 8 秒,那这 8 秒内所有帧都标为移动,只有在行为切换的那几帧才需要人工确认边界。旷场实验一个比较隐蔽的问题是,小鼠理毛时身体蜷缩,很容易被标成静止,而它移动间隙突然前爪离地的一瞬间又短又快,标成人者如果没回放慢放就会漏掉。所以标注时至少要两人背靠背标同一段视频,算一下 Cohen‘s Kappa,低于 0.8 就要重新统一标准。
标注结果我习惯存成 CSV,每行是帧序号和类别编号,方便后续滑窗采样直接读:
import pandas as pd df = pd.DataFrame({ "frame_id": [0, 1, 2, ...], "label": [1, 1, 1, ...] }) df.to_csv("mouse_01_labels.csv", index=False)把标注和帧序列分开存放,只存帧序号而不是存图片路径,好处是后续做数据划分时,可以直接按帧序号切滑窗,不用频繁移动图片文件。标注完成后要花十分钟统计一下每个类别的帧数占比,这个数字直接决定要不要做重采样。
3.3 样本不均衡与数据增强:让模型别只会输出“移动”
旷场视频里移动通常占 60% 到 70%,静止占 20% 左右,站立和理毛可能各占 5% 到 10%,而且理毛经常集中在实验后半段。如果直接拿原始分布训练,模型很快会收敛到“所有帧都输出移动”,因为这样损失已经很小了。处理这个问题的第一步是给损失函数加类别权重,权重取总帧数除以每个类别的帧数,再做归一化。第二步是在训练时按类别做样本采样,控制每个 batch 里各类别比例大致均衡。
数据增强要特别注意,不能对单帧做而必须对整段滑窗做。比如随机水平翻转一张帧,那这一帧的空间位置和前后帧从逻辑上就不连续了,ConvLSTM 会学到错误的时间跳跃特征。我常用的增强组合是:整段时间轴随机翻转、整体亮度抖动、小幅旋转和缩放,以及时间步随机丢帧。下面这段代码展示怎么从标注文件直接生成滑窗训练样本:
import numpy as np import torch from torch.utils.data import Dataset class ConvLSTMDataset(Dataset): def __init__(self, frames, labels, seq_len=24, stride=4): self.frames = frames # 形状 (N, C, H, W) self.labels = labels # 形状 (N,) self.seq_len = seq_len self.stride = stride def __len__(self): return max(0, (len(self.frames) - self.seq_len) // self.stride + 1) def __getitem__(self, idx): start = idx * self.stride end = start + self.seq_len seq = self.frames[start:end].astype(np.float32) / 255.0 lab = self.labels[start:end] return torch.from_numpy(seq), torch.from_numpy(lab)这里seq_len=24对应 0.8 秒的上下文,stride=4让相邻样本之间有 20 帧重叠,既增加训练样本量又不至于让序列之间完全独立。重叠设计是有意的,行为状态转换往往发生在连续样本的边缘,如果 stride 等于 seq_len,模型就永远只在片段内部做分类,学不到跨片段的连接关系。样本生成后建议再做一次类别统计,确认增强没有改变整体标签分布。
4. 搭建 ConvLSTM 模型:网络结构、训练参数和评估口径
4.1 核心网络结构:两层 ConvLSTM 加分类头的 PyTorch 实现
模型结构我一般按三段设计:第一段是一个小型的卷积主干,把 224×224 的输入图像降采样到 56×56 并提取基础边缘和纹理特征;第二段是两层 ConvLSTM,在时间和空间上同时建模;第三段把 ConvLSTM 输出的特征图做全局池化,接一个全连接分类头输出每个类别的概率。ConvLSTM 单元本身要自己实现,PyTorch 原生没有内置,但实现并不复杂,核心就是四组卷积门:
import torch import torch.nn as nn class ConvLSTMCell(nn.Module): def __init__(self, input_dim, hidden_dim, kernel_size): super().__init__() self.hidden_dim = hidden_dim padding = kernel_size // 2 self.conv = nn.Conv2d(input_dim + hidden_dim, 4 * hidden_dim, kernel_size=kernel_size, padding=padding) def forward(self, x, state): h, c = state combined = torch.cat([x, h], dim=1) gates = self.conv(combined) i, f, g, o = torch.chunk(gates, 4, dim=1) i = torch.sigmoid(i) f = torch.sigmoid(f) g = torch.tanh(g) o = torch.sigmoid(o) c_next = f * c + i * g h_next = o * torch.tanh(c_next) return h_next, c_next这里的conv一次输出四组特征图,分别对应输入门、遗忘门、候选记忆和输出门。把四个门合并在一次卷积里做,比写成四个独立卷积更容易保持输入输出维度一致,也减少显存占用。kernel_size我一般用 3,视野足够覆盖小鼠的身体局部,参数又比 5×5 少一半。值得注意的是 ConvLSTM 的隐藏状态形状和输入帧保持一致,都是 (batch, hidden_dim, H, W),所以第二层 ConvLSTM 可以直接接第一层的输出序列。
4.2 训练参数怎么设置:序列长度、学习率、类别权重都是经验值
| 参数 | 推荐值 | 说明 |
|---|---|---|
| seq_len | 16 到 24 | 对应 0.5 到 0.8 秒上下文,覆盖多数行为状态持续时间 |
| batch_size | 8 到 16 | 显存不够时优先调低,不要调小 seq_len |
| 初始学习率 | 3e-4 到 1e-3 | 高于 1e-2 必炸梯度,常见翻车现场 |
| 优化器 | AdamW | 配合 weight_decay 防止隐藏状态过拟合 |
| 类别权重 | 按频率倒数归一化 | 解决移动类占比过高的问题 |
| 梯度裁剪 | max_norm=1.0 | LSTM 训练必备,放在 backward 之后 step 之前 |
训练循环里要显式做梯度裁剪,这是 ConvLSTM 和普通 CNN 训练最大的区别。循环结构在时间步上反传,梯度很容易在长序列末端爆炸成 NaN,不裁剪的话前几个 epoch 就废了。下面这段是训练循环里最核心的部分:
optimizer.zero_grad() outputs = model(seq) # (batch, seq_len, num_classes) loss = criterion( outputs.reshape(-1, num_classes), lab.reshape(-1) ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()裁剪阈值max_norm=1.0是一个比较保守的值,如果训练初期 loss 出现剧烈抖动,可以降到 0.5,如果收敛太慢则适当放大到 2.0。另外,outputs.reshape(-1, num_classes)会把整段序列的所有帧都当作独立样本求损失,这在数学上是没问题的,因为帧间依赖已经在模型前向传播时建模过了,损失层面不需要再刻意区分边界。
4.3 评估口径:帧级准确率不够,要看行为边界和持续时间误差
旷场行为分析的评估不能只看帧级准确率,因为准确率会被头部的移动类带偏,一个全移动模型也能跑到 70%。我建议至少看三个维度的指标。
第一个是加权 F1,权重用类别频率倒数,这样站立和理毛的贡献不会被淹没。第二个是混淆矩阵,重点看移动和静止、静止和站立这两组相邻类别的混淆比例,它们是最常见的误判对。第三个是事件级评估,把模型的帧级预测按时间连续性切成行为事件,计算事件数量和平均持续时间的误差。比如模型把一次站立事件拆成了三段,帧级 F1 可能不低,但事件数量直接从 12 变成 31,这在行为学统计上意义完全不同。
事件级评估对阈值选择也有指导意义。输出层用的是 softmax,默认阈值是 0.5,但实际部署时可以按每个类别分别调阈值,原则是让行为事件的转换点时间和人工标注尽可能一致。调阈值时只看帧级准确率会越调越偏,应该看每个行为事件的起始时间差和结束时间差。
5. 避坑与排查:ConvLSTM 在旷场数据上最常见的五个翻车现场
5.1 标注错位:行为变化没有对齐到帧索引
现象是一个人标完视频,训练出来模型准确率很高,但逐帧回放发现行为事件整体偏移了十几帧。
原因最常出在标注工具上,用视频播放器逐帧标记时,播放器显示的时间戳和 ffmpeg 切帧的起始点之间往往有几百毫秒偏差,导致标签帧号和实际画面没有对齐。另外标注者看到行为变化到按下按键也有延迟,专门针对瞬间行为。
解决方式是统一从同一帧序列回放标注,不要直接在播放器上标时间点,而是让标注工具直接显示 JPEG 帧序号。训练前做一个标签对齐校验,随机抽 20 个行为转换点,人工检查模型输入的第 N 帧画面和标签是否一致。如果发现系统偏差,可以在生成标签时加一个固定偏移量,但一定要先算清楚偏移方向和帧数。
5.2 旷场边缘阴影被识别成小鼠
现象是模型在旷场边缘输出的移动和静止置信度都不稳,尤其在箱壁附近,静止时长明显偏短。
原因是箱体侧壁在俯视视角下形成暗带,亮度分布和小鼠深色皮毛接近,卷积主干提取的特征分不清阴影和小鼠。如果预处理阶段没有做背景差分,这个问题几乎必现。
解决方式有两层。第一层是在预处理阶段把箱体边缘往内收缩几个像素,或者在标注时把边缘阴影区域设为 ignore 类,不让它参与损失计算。第二层是给输入叠加一个背景距离图,也就是每个像素到最近箱壁的距离,作为一个额外通道拼到 RGB 上,这样 ConvLSTM 能直接感知位置信息。做背景差分时要确保旷场里没有水瓶、饲料颗粒等杂物,否则那些杂物会被当成背景里的固定前景,导致小鼠经过时出现伪影。
5.3 类别不均衡让模型学会“躺平”
现象是训练 loss 下降正常,但站立和理毛的 F1 低于零点二,预测结果里几乎没有这两个类别,模型把所有帧都输出为移动。
原因是忽略了对类别权重的设置,或者数据增强时没有考虑类别分布。移动帧占比太高,模型输出移动的期望损失已经很小,即使偶尔把站立分错也不会让总损失明显上升。
解决方式是在损失函数里按类别频率倒数设置权重,我的做法是先统计训练集标签分布,再计算每个类别的权重。要注意类别权重不能只看训练集,还要看验证集,如果标注时某一类别的时长本身就很短,比如只有几十秒,那即使加权也很难学到,这种情况下优先考虑降低标注粒度,把站立和理毛合并成“非移动”类,等数据多了再细分。
5.4 梯度爆炸:训练到一半 loss 变成 NaN
现象是模型在前几个 epoch 正常,突然某一步 loss 变成 NaN,之后无论怎么调都回不来。
原因是 Learning rate 偏高加上 ConvLSTM 的循环时间步导致梯度累计,尤其是序列长度超过 32 帧时,反向传播跨越的时间步数多,梯度范数会指数放大。即便用了 AdamW,这类模型的梯度曲线也比纯 CNN 不稳定得多。
解决方式是加梯度裁剪,并把初始学习率从常见的 1e-3 降到 3e-4。如果已经炸了,不要只调学习率继续训练,要重新加载最近一个正常 checkpoint,把学习率调低后冷启动。另一个超参数是 weight decay,调得太大会让隐藏状态趋于稀疏,调太小又解决不了过拟合,常规情况 1e-4 就够了。
5.5 推理序列与训练序列长度不一致
现象是训练时 seq_len 用 24,推理时觉得跑得慢把 seq_len 改成 8,结果行为事件碎片化严重。
原因是 ConvLSTM 的隐藏状态初始值在每次前向传播时清零,序列越短,模型能看到的上下文越有限。推理时不能随便改序列长度,特别是训练时学到的时序依赖可能长达十几帧。
解决方式是保持和训练完全一致的 seq_len,同时在推理时使用滑窗重叠。具体做法是每次滑窗推进 12 帧,输出时只取每个滑动窗口中间 12 帧的预测结果,窗口两端的预测舍弃掉,因为离窗口边界越近,循环状态越不充分。这样推理时间会变长,但换来的是行为事件边界稳定。
6. 进阶与验证:让行为序列更稳定,再把指标导出成实验报告
6.1 时间平滑与行为转换修正
模型直接输出的帧级标签总会有少量闪烁,表现为移动与静止之间在几十帧内来回跳。常见做法是加一个轻量后处理,用中值滤波,窗口大小取 7 到 15 帧。窗口不能盲目加大,小鼠站立事件最短可能只有 0.5 秒,对应 15 帧,如果窗口超过这个值会把短站立事件整个抹掉。进阶方案是用 HMM 对标签序列做状态修正,观测概率用模型输出的置信度,转移概率按行为学经验设置,比如移动转静止的概率远高于移动转理毛的概率,效果会好一截,但需要多写几十行状态机代码。
6.2 轨迹提取与指标导出的口径
旷场实验的轨迹不需要神经网络来做,背景灰度固定,用背景差分加质心计算就能拿到稳定坐标。拿到逐帧坐标后,按下面这个口径导出指标即可。
| 指标 | 计算方式 |
|---|---|
| 总运动距离 | 逐帧质心位移的累计和 |
| 中心区时间占比 | 质心落在中心区内的帧数除以总帧数 |
| 进入中心区次数 | 轨迹连续穿越中心区边界的事件数 |
| 站立次数 | 模型输出站立事件的数量 |
| 平均速度 | 总运动距离除以实验总时长 |
中心区的定义统一取敞箱中心 25% 面积区域,不要按边长一半去设,不同课题组之间结果差别会很大。导出报告要同时给帧级时间戳和事件列表,方便复查异常值。
6.3 可视化验证与人工评分对比
模型上线前,最有效的验证手段是把预测结果画在原始视频上,类别用颜色叠加在画面角落,行为转换时同步画出时间轴标记,然后倒放抽查 20 个行为事件的前后十帧。我自己的习惯是强制看转换点而不是抽查均匀帧,转换点是模型最容易翻车的地方。之后再把自动指标和两个人工评分员的统计结果做配对差检验,重点看总运动距离和中心区时间占比的两两差异是否在合理范围内。
坦白讲,我最早跑通这套流程时直接拿默认参数训练,结果中心区停留时间被高估了将近一倍,后来检查发现是平滑窗口把小鼠在边界上的快速往返全合并成了长时间停留。从那以后我把平滑窗、中心区边界定义和标签对齐三项写进了固定检查清单,每次适配新场地或新鼠株都先跑完这三项再谈模型调优。希望这些细节能帮你在自己的旷场数据上少走一段弯路。
本文还有配套的精品资源,点击获取