多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于ConvLSTM的旷场实验小鼠行为自动识别与实现

基于ConvLSTM的旷场实验小鼠行为自动识别与实现 简介一份聚焦ConvLSTM网络开展小鼠旷场实验行为自动识别的技术文档面向动物行为学、计算机视觉及深度学习交叉领域的研究者与工程师解决人工观察耗时、主观偏差大等问题。文档完整呈现了从关键点检测、特征图序列构建到ConvLSTM行为分类模型设计再到众值滤波修正与时序行为参数统计的整套分析流程包含鼻尖、左耳、右耳、尾根等关键点标注方式直走、转身、修饰、静止、直立五类行为的识别分类以及行为发生次数、持续时间、转变模式的计算规则便于按步骤复现或借鉴模型优化思路。资源为1个docx文档压缩包大小约18KB内容结构紧凑适合作为方法设计、论文写作或实验框架搭建的参考。已有138人学习对希望快速了解行为识别技术方案的研究者具有实用价值。1. 旷场实验的时长问题ConvLSTM为什么值得试一段旷场实验视频通常持续 10 到 30 分钟如果做慢性应激或药物干预实验样本量动辄几十只。过去靠人工观察秒级标注工作量大不说行为边界的判定还高度依赖观察经验——同一段视频不同标注者的 kappa 一致性可能只有 0.7 左右。更麻烦的是旷场实验里小鼠的走动站立和修饰常常在几秒内交替发生逐帧分类对时序敏感度要求极高。传统方法里光流法勉强能抓住运动方向却忽略了在哪动3D CNN 能同时编码时间空间但需要极大数据量才能训稳。基于 ConvLSTM 的做法是把视频切成一帧一帧的序列交给卷积循环网络处理卷积部分负责提取小鼠在画面哪里、姿态如何的空间特征LSTM 结构负责把连续帧的信息串起来让模型自己学到先快走再急停这类时空模式。这样既不用手工设计光流特征也不需要把整段视频暴塞进显存。这个思路适合正在做动物行为自动识别、又不想依赖商业软件的研究者也适合想尝试时空序列建模的工程师。本文会从行为标签定义开始一直到训练和指标计算给出一套能落地运行的流程。2. ConvLSTM网络结构与旷场行为分类的理论基础要建立一套可复现的旷场行为分类系统先要把两个问题说清楚一是你要模型预测什么二是 ConvLSTM 凭什么能预测出这个东西。这两件事没对齐后面所有参数都白调。2.1 旷场实验中的行为学指标与标签体系旷场实验的核心观测指标包括运动总距离、中央区域停留时间、站立次数和修饰行为频率但指标是算出来的要先有行为类别作为中间层。实践中最常用的标签体系有五类静止、走动、站立rear、修饰groom、转圈circle。实际标注时建议不要超过六类因为行为类别越多标注一致性和模型收敛难度都会剧烈上升。每类行为都需要明确的起止定义比如站立指小鼠前爪离地、躯干直立持续超过 0.5 秒。行为标签标注的粒度直接决定模型输入序列的长度。推荐先把视频按 10 帧每秒抽帧标注员在序列层标注行为区间区间再拆成固定长度的样本。下表是一套我常用的标签定义和参考时长可以作为标注手册的起点行为类别行为定义最小持续时长常见误判情况静止身体位置无位移仅头部微动0.5s与呼吸导致的躯干起伏混淆走动四肢交替移动位移 2 cm0.3s与静止后首次迈步混淆站立前爪离地躯干直立0.5s与攀爬箱壁动作混淆修饰舔舐前爪、面部或梳理皮毛1.0s与短促抓挠混淆转圈原地旋转角度累计超过 180°0.8s与绕小半径行走混淆标注完成后再把序列切割成固定长度比如每个样本 16 帧。这 16 帧对应的是一段时间窗口窗口里可能包含一个完整行为也可能跨越两种行为。更合理的做法是取 24 帧标签取窗口中间帧的行为类别让模型在训练时同时看到行为前、中、后和尾部变化。这样做的好处是减少边界样本对训练的影响。切割后的样本要做类别统计如果某类比其他类少 40% 以上优先考虑过采样而不是换损失函数。2.2 ConvLSTM为什么能同时看看哪里和怎么动ConvLSTM 是卷积LSTM的缩写它在普通 LSTM 的基础上把输入到状态、状态到状态的变换从全连接矩阵乘法换成卷积操作。记忆单元、输入门、遗忘门、输出门这些结构依然存在但每个门控的输入现在是一张特征图而不是一维向量。这让模型在时间维度上传递信息时空间局部性也被保留下来——上一步学到的小鼠轮廓和这一步的轮廓按空间位置对应着流动。数学上ConvLSTM 的核心公式比普通 LSTM 直观得多。以时间步为 t 输入 X_t上一时刻状态 H_(t-1) 和记忆 C_(t-1)遗忘门 F_t 由当前输入和上一隐藏状态经过卷积后接 sigmoid 得到输入门 I_t 和候选记忆 Ct 类似更新后的记忆 C_t F_t * C(t-1) I_t * C_t输出 H_t O_t * tanh(C_t)。这里的 * 表示卷积不是逐元素乘法。卷积核大小通常取 3×3padding 保持特征图尺寸不变。相比单独使用 3D CNNConvLSTM 的参数规模更可控也能处理任意长度的时间序列测试时不一定非要和训练序列等长。为什么在旷场行为分析里选 ConvLSTM 而不是普通 LSTM 或 Transformer普通 LSTM 需要把每一帧展平成一维向量这对视频而言空间信息被强行打散小鼠在左上角还是右下角对模型来说只是不同位置的像素组合Transformer 的注意力能捕捉长程依赖但在小样本的动物行为数据集上预训练权重也不好找。ConvLSTM 的归纳偏置正合适——卷积保证平移等变性循环结构保证时间连续性这正好符合小鼠的运动轨迹和姿态是连续时空变化这一事实。2.3 用PyTorch定义ConvLSTM单元实际建模时我不会直接手写完整的 ConvLSTM 循环而是先实现一个 ConvLSTMCell 组件再在序列上循环展开。这里给出一个简洁的 PyTorch 实现可以放在模型文件底部复用import torch import torch.nn as nn class ConvLSTMCell(nn.Module): def __init__(self, in_channels, hidden_channels, kernel_size3): super().__init__() self.hidden_channels hidden_channels padding kernel_size // 2 self.conv nn.Conv2d(in_channels hidden_channels, 4 * hidden_channels, kernel_sizekernel_size, paddingpadding) def forward(self, x, prev_state): # prev_state 是 (H, C) 的二元组必须是初始化过的状态 prev_h, prev_c prev_state combined torch.cat([x, prev_h], dim1) gates self.conv(combined) i, f, o, g torch.split(gates, self.hidden_channels, dim1) i torch.sigmoid(i) f torch.sigmoid(f) o torch.sigmoid(o) g torch.tanh(g) c f * prev_c i * g h o * torch.tanh(c) return h, c这个ConvLSTMCell的输入是四维张量(batch, channels, height, width)。in_channels是输入图像或特征图的通道数hidden_channels是内部状态的特征图数量。conv层把当前输入和上次隐藏状态拼接后一次性算出四个门控值然后按通道维度拆开。要注意的是kernel_size设为奇数padding 用kernel_size // 2目的是让特征图在卷积后保持原尺寸否则循环几十帧后空间维度会缩得无法对齐。prev_state需要外部传入初始化时通常用全零张量。这个单元实现了 ConvLSTM 的基本前向过程训练时梯度会自动沿着时间步反传不需要额外处理。3. 数据预处理与序列构建从原始视频到特征张量模型的输入决定模型的上限。旷场实验视频不像自然图像那样有丰富的纹理信息小鼠在画面里通常只占很小一块区域大量背景像素对分类不提供有效梯度。直接拿整帧训练除了浪费显存还容易让模型把笼子角落的污渍学成重要特征。所以在进入网络之前要完成帧抽取、前景区域裁剪和序列切割这三步。3.1 帧抽取与运动区域自动裁剪第一步先把视频转成图片序列。我习惯用 OpenCV 的VideoCapture读取视频按目标帧率抽帧。旷场实验里 10 fps 已经足够捕捉行为变化过高的帧率只会让相邻帧高度重复训练效率反而下降。抽帧完成后用背景减除或者帧间差分得到激活区域然后找出所有激活像素的外接矩形适当外扩像素作为序列输入区域。下面的代码展示了一个最小实现抽帧、差分、找轮廓、裁剪并缩放到固定尺寸。import cv2 import numpy as np def extract_roi_frames(video_path, out_size128, fps_target10): cap cv2.VideoCapture(video_path) src_fps cap.get(cv2.CAP_PROP_FPS) frame_interval max(1, int(src_fps // fps_target)) frames [] idx 0 prev_gray None while True: ret, frame cap.read() if not ret: break if idx % frame_interval ! 0: idx 1 continue gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) if prev_gray is not None: diff cv2.absdiff(gray, prev_gray) _, thresh cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY) contours, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: x, y, w, h cv2.boundingRect(np.vstack(contours)) # 外扩 20% 保证金鼠四肢和尾巴在框内 pad_x, pad_y int(w * 0.2), int(h * 0.2) x max(0, x - pad_x) y max(0, y - pad_y) w min(frame.shape[1] - x, w 2 * pad_x) h min(frame.shape[0] - y, h 2 * pad_y) roi frame[y:yh, x:xw] else: roi frame roi cv2.resize(roi, (out_size, out_size)) frames.append(roi) prev_gray gray idx 1 cap.release() return np.stack(frames, axis0)这段代码的关键点是frame_interval的计算它把原始视频帧率统一到目标帧率。findContours之前用背景差分得到二值图差分的灵敏度由阈值 25 控制——这个值在光照均匀的旷场箱里基本够用如果实验环境有反光建议换成基于均值背景模型的cv2.createBackgroundSubtractorMOG2。ROI 外扩 20% 是保证裁剪框不会卡住小鼠的尾巴和胡须因为行为分类里尾巴摆动的信息并不是关键但频繁截断边缘会让模型不稳定。如果检测不到轮廓代码保留了整帧作为输入这是一个兜底逻辑防止空序列导致训练断裂。3.2 序列滑窗切割与数据增强裁剪出来的帧序列长度可能上千需要滑窗切割成固定长度的样本。窗口长度通常取 16 或 24 帧对应 1.6 秒到 2.4 秒的行为片段。步长设为窗口长度的四分之一也就是 4 或 6 帧让相邻样本之间有大面积重叠相当于做了时序上的数据增强。每个样本的标签取窗口中心帧的标注类别这样模型被迫从上下文推断中心时刻的行为而不是偷懒只看第一帧。数据增强方面旷场视频的样本量通常只有几千条需要做一些轻度的空间增强。我常用的组合是随机水平翻转、小角度旋转±10°和色彩抖动这三项都不会改变行为本身的语义——水平翻转对旷场实验成立因为箱体是对称的旋转角度控制在 10° 以内避免把正常的直立甩出感受野。时间维度上不要做随机裁剪因为前后帧的因果顺序对行为分类很重要裁剪时间轴会破坏先走再停这类时间模式。增强之后的样本会暂时存放在内存列表中如果数据量超过 2 万条建议改用一个在__getitem__里实时做增强的 PyTorch Dataset避免吃光内存。3.3 归一化与张量维度设计统一后的输入张量形状是(batch, seq_len, channels, height, width)。PyTorch 里nn.Conv3d接受(N, C, D, H, W)但 ConvLSTM 需要的是(N, D, C, H, W)所以批次维度在循环里要挪到第 0 维。归一化时按照 ImageNet 的均值方差用(0.5, 0.5, 0.5)简单归一到[-1, 1]即可不要用 ImageNet 统计量因为旷场图像的灰度分布差异很大。更好的做法是在训练集上计算每个通道的均值和标准差存成 JSON 文件推理时加载同一个统计量。对于一个批次内长度不一的样本标准做法是 pad 到相同长度再训练但行为分类场景下所有样本固定长度反而省事。我建议在数据生成阶段就把长度固定为seq_len16输入高度为 64 或 128 时通道数取 3。模型结构上第一层 ConvLSTM 的输入通道数就是 3。如果显存不够可以把图像缩小到 64×64再用两层 ConvLSTM 逐层提取特征。4. 模型实现与训练流程全流程可直接套用模型结构这部分强烈建议在数据集上先跑通一个最小实验单只小鼠、十段视频、只分两类。千万不要一上来就做五分类和复杂网络先把数据管线验证好再加参数量。下面的结构是最常用的三层组合ConvLSTM ConvLSTM 全连接分类头。4.1 构建ConvLSTM行为分类模型先实现一个多层 ConvLSTM 封装再叠加分类头。这里的逻辑是第一层 ConvLSTM 输出 32 张特征图第二层输出 64 张最后把所有时间步的特征图做全局平均池化得到一个 64 维向量再过全连接层。全局池化比取最后时间步更稳因为旷场行为的关键信息可能出现在窗口中间而非末尾。import torch import torch.nn as nn class ConvLSTMClassifier(nn.Module): def __init__(self, in_channels3, hidden_channels32, num_layers2, num_classes5): super().__init__() self.num_layers num_layers self.hidden_channels hidden_channels cells [] for i in range(num_layers): input_ch in_channels if i 0 else hidden_channels cells.append(ConvLSTMCell(input_ch, hidden_channels)) self.cells nn.ModuleList(cells) self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Dropout(0.4), nn.Linear(hidden_channels, num_classes) ) def forward(self, x): # x 形状: (batch, seq_len, in_channels, H, W) batch, seq_len, _, H, W x.shape hidden_states [] cell_states [] # 初始化每一层的隐藏状态和记忆状态 for _ in range(self.num_layers): hidden_states.append( torch.zeros(batch, self.hidden_channels, H, W, devicex.device) ) cell_states.append( torch.zeros(batch, self.hidden_channels, H, W, devicex.device) ) for t in range(seq_len): x_t x[:, t, :, :, :] for layer in range(self.num_layers): h, c self.cells[layer](x_t, (hidden_states[layer], cell_states[layer])) hidden_states[layer] h cell_states[layer] c x_t h # 下一层的输入是当前层的输出 # 取最后一层所有时间步的输出做池化 out self.avg_pool(hidden_states[-1]).flatten(1) return self.fc(out)在forward里每一层的隐藏状态都初始化成全零张量并保持和输入图像相同的高度宽度。循环时x_t从x[:, t]切片得到然后在多层之间逐层传递。这里有个容易被忽略的细节AdaptiveAvgPool2d(1)是对最后一层隐藏状态做空间池化而不是对每次时间步都做池化否则会丢掉时间上下文。Dropout放在全连接之前只在训练时生效。如果你希望模型输出行为概率可以在self.fc后再接nn.Softmax(dim1)但训练阶段一般用CrossEntropyLoss自带 LogSoftmax不要手动加。4.2 训练超参数与收敛检查旷场数据集规模决定了模型容量和正则化强度。数据量在 5000 到 20000 个样本时推荐用下面这组参数作为起点超参数推荐值调整建议seq_len16 帧扩大窗口到 24 可提升站立检测精度hidden_channels32数据量大时调到 64批次大小16显存小降到 8初始学习率0.001AdamW 下建议配合 warmup权重衰减0.01对减少过拟合有帮助训练轮数30用早停法patience5训练时我用 AdamW 优化器学习率采用线性 warmup 加余弦退火。具体到旷场数据类别不平衡会直接影响收敛。如果你统计出静止占 60%站立只占 8%可以给CrossEntropyLoss传入一个逆类别频率的权重向量。这个做法比简单的过采样更平稳因为过采样会让相邻样本几乎相同而类别权重保留了数据的原始分布。训练循环中每 5 步打印一次 loss等到验证集准确率不再提升时保存最好模型。这里有一个容易踩的坑把时序样本随机切分成训练集和验证集时来自同一只小鼠、同一段视频的相邻样本会同时出现在两边导致验证集虚高。正确做法是按视频维度分组用GroupKFold或者手动把每只小鼠的视频整体划到验证集。4.3 显存优化与推理加速如果输入分辨率是 128 或序列长度超过 24显存占用会明显上升。常见做法是把输入降采样到 64×64或者让 ConvLSTM 的 kernel size 从 3 改成 5 不做下采样。另一种更有效的方案是在第一个 ConvLSTM 之前插入一个简单的卷积下采样层用nn.Conv2dMaxPool2d把分辨率缩到 32×32后面循环的计算量骤降。推理端我一般把模型转成 ONNX 格式然后用 TensorRT 在 GPU 上跑半精度单段 30 分钟视频的推理时间能从分钟级压缩到秒级。训练脚本最后保存两类文件模型权重和归一化统计量。归一化统计量在预处理脚本里计算存储为一个字典推理脚本加载时直接使用。否则训练时归一化到 [-1, 1]推理时却按 [0, 1]准确率会掉得莫名其妙。5. 行为序列解码与实验指标计算技巧模型输出的是一帧级概率向量不是实验指标。要把概率变成可用的行为分析结论还需要做两件事把逐帧预测平滑成行为单元再从行为单元累加出旷场实验的核心指标。5.1 用滑窗投票消除抖动预测旷场行为天然具有连续性模型偶尔会把一个长站立预测成站立-走动-站立这种单帧抖动在最终指标里会被放大。常见做法是窗口平滑取前后各 3 帧做一个多数投票把孤立异常值纠正过来。下面这个函数直接操作 NumPy 数组。def smooth_predictions(pred_ids, window7): pad window // 2 pred_ids np.pad(pred_ids, (pad, pad), modeedge) smoothed [] for i in range(pad, len(pred_ids) - pad): seg pred_ids[i - pad:i pad 1] counts np.bincount(seg) smoothed.append(np.argmax(counts)) return np.array(smoothed)平滑窗口取 7 帧意味着 0.7 秒内的一次闪烁会被吸收。注意窗口不能设太大否则 0.5 秒的短促站立会被前一个走动完全吞掉。如果实验关注短时行为建议把预测概率存下来而不是只存类别在概率层面做指数加权平均这比投票更精细。5.2 从轨迹计算距离和中央区域时间对于已经得到逐帧行为的样本运动总距离和中央区域停留时间必须结合原始坐标计算。坐标可以用预处理阶段得到的外接矩形中心点替代小鼠位置但更可靠的是用前景轮廓的质心。计算距离时要先做坐标平滑否则相邻帧的像素抖动会被当成实际位移。def calc_distance(centers, fps10): # centers: (N, 2) ndarray按时间排序 deltas np.diff(centers, axis0) step np.linalg.norm(deltas, axis1) # 过滤小于 0.5 cm 的抖动位移 step step[step 5] return float(step.sum()) / fps这里的距离单位是像素实验中需要先用标尺把像素换算成厘米。中央区域时间计算则简单很多只要知道旷场箱的内边界和中央区域比例把每一帧坐标落在中央区域的帧数除以总帧数再乘以视频时长即可。如果你关注焦虑样行为通常报告中央区域停留时间占比和进入中央区次数这两者都比总距离更敏感。最后再提醒一个验证细节计算完指标后建议随机抽 10 段 30 秒的预测结果把每一帧的预测类别按颜色叠加到原视频上人工回放一遍。这个动作能快速暴露平滑参数是否过度、标签定义是否清晰、模型是否在特定光照下系统性出错。跑一次这个可视化流程比看十次损失曲线都更有用。本文还有配套的精品资源点击获取
返回列表