586. Child Mind Institute - Detect Sleep States | child-mind-institute-detect-sleep-states
感谢CMI和Kaggle主办这场有趣的竞赛,也感谢其他选手的辛勤付出,共同提升了排行榜成绩,改进了事件检测方法的质量。我获得了我的第一枚奖牌 🦾,并从中学习了很多。
竞赛概述:https://www.kaggle.com/competitions/child-mind-institute-detect-sleep-states/overview
数据:https://www.kaggle.com/competitions/child-mind-institute-detect-sleep-states/data
我的解决方案的核心思想是尽可能减少预处理/后处理,尝试以端到端的方式检测睡眠/清醒事件。这是因为我观察到不同的后处理方法在不同折上的mAP表现不一致(有些提升,有些下降),这可能是由于标签的不一致性造成的。
我的流水线包含两种模型,一种用于检测事件的准确位置(称为“Regressor”),另一种用于检测事件在一天内发生的概率密度(称为“DensityNet”)。
这是一个简单的一维U-Net,仅使用局部信息和anglez特征来检测事件发生的位置。这一思路受Faster-RCNN以及后续如YOLO等边界框RPN回归方法的启发。由于我们处理的是1D数据且事件之间间隔明显,因此每步只需预测两个值(入睡、醒来)。
训练时选择一个固定的超参数“width”。数据加载器会随机打乱并加载训练series_id的入睡和醒来事件,以及时间序列区间 $ [\text{事件} - \text{width}, \text{事件} + \text{width}] $。模型优化的目标如下:

由于数据存在噪声,我发现Huber损失效果最佳,类似于Fast-RCNN中的平滑L1回归损失,因此对异常值不敏感。
推理时,Regressor网络将在整个时间序列上运行,预测每步的相对(入睡、醒来)值,从而得到感兴趣的位置。将以感兴趣位置为中心、标准差为12的高斯核进行累加。

将有两个累加得分,一个用于入睡,一个用于醒来。得分的峰值即为入睡和醒来事件的可能位置。我使用了最简单的峰值检测方法:
locations = np.argwhere((score[1:-1] > score[:-2]) & (score[1:-1] > score[2:])).flatten() + 1
额外的NMS后处理步骤,确保预测位置之间至少相隔6分钟。
Regressor是一个简单的一维U-Net,输入通道为1,输出通道为2,采用一维ResNet作为骨干网络。隐藏通道数依次为2、2、4、8、16、32、32,每个池化操作之间包含2个ResNet块。我没有使用SE模块,并采用BatchNorm1D而非InstanceNorm/GroupNorm/LayerNorm,以使网络对全局变化不敏感。
我在整个训练数据集上训练了3个模型,宽度分别为120、180、240(即10、15、20分钟)。在得到位置之前,将3个模型的得分进行平均。
为了验证该模型的性能,我计算了每个事件前后120步(10分钟)内得分最大值的累积分布函数(CDF)(5折交叉验证),结果如下:

约85%的argmax预测落在实际事件3分钟以内。由于在事件周围的240步区间内可能存在多个峰值,我们预期最小距离的误差会更小:

另一个网络(称为“DensityNet”)用于为每个事件分配得分。该网络必须判断哪一段清醒和睡眠阶段最有可能(选择一天中最长的一段),并遵守规定的30分钟长度和中断规则。因此需要更长的上下文信息。
为此,我在一维U-Net的最深层特征层中加入了Transformer编码器模块,以建模全局信息。由于每天最多只有一个事件,DensityNet将预测该时间窗口内入睡/醒来事件的概率密度。我使用了对称的ALiBi编码,使Transformer编码器块具有平移等变性。
我发现,在比推理更大的区间上训练模型有助于包含更多全局上下文。然而,我的模型并不预测每步的入睡/醒来概率。受信号处理的启发,未知的入睡/醒来信号是感兴趣固定区间内的随机变量。因此,DensityNet在2天区间内与真实的入睡+醒来位置进行拟合。
这相当于具有12 * 60 * 24 * 2 = 34560个类别的交叉熵损失。由于标签存在噪声(并被裁剪到最近的分钟),目标概率分布采用拉普拉斯分布进行平滑。伪代码如下:
# (N, T, 2), N = batch_size, T = 34560
target_distribution = get_distribution(interval_min, interval_min + 34560, series_onset_lbls, series_wakeup_lbls)
pred_logits = model(time_series) # (N, T, 2)
loss = cross_entropy_loss(pred_logits.permute(0, 2, 1), target_distribution) # torch cross entropy acts on 2nd axis
由于手表摘下时数据会填充虚假区间,DensityNet还预测两个概率——整个区间内是否存在入睡或醒来事件。
该模型现在仅在2天区间的中心1天子区间上进行推理。为了给Regressor预测的事件分配得分,我们使用条件概率
$$p(t|\text{实际事件出现在Regressor预测结果中}) = \frac{p(t)}{\sum_{t' \in \text{Regressor预测结果}} p(t')}$$
其中p(t)是DensityNet预测的概率密度。考虑到虚假区间,最终得分为
$$\text{score} = q * p(t|\text{实际事件出现在Regressor预测结果中})$$
其中q是区间包含某个事件(入睡或醒来)的预测概率。
整个序列中事件的得分通过在整个序列上滑动预测窗口计算,并在每个预测窗口重叠时进行平均。条件概率可以通过将softmax限制在Regressor建议的位置的logits上来轻松计算。
与其他团队类似,我将事件时间移至xx:xx:15、xx:xx:45,以提高mAP。注意,xx:xx:30也不理想,因为mAP评分中存在7.5分钟的窗口。
我很惊讶没有太多顶尖方案使用这个技巧来提高mAP。显然,事件无法被精确标记到3分钟/1分钟的精度,稍微移动鼠标就会使标签偏移1分钟。以下是本地5折交叉验证的mAP分数(已排除不良序列和缺失标签的部分):

(未使用增强)

(使用增强,mAP提升约0.002)
矩阵轮廓(Matrix profile)可以检测完全重复的模式。我将其作为额外的后处理步骤,以剔除位于虚假数据中的预测。
一些顶尖方案添加了二进制特征(1, 0)来指示该步是否位于虚假数据内,或仅在干净数据区间上训练。这相比我目前让模型预测窗口内是否存在事件的方法,应能提升模型性能。
许多顶尖方案利用了这种模式(每15分钟事件分布不均)。这表明将该模式作为模型输入或后处理步骤可能会提升性能。