返回列表

1st place solution

586. Child Mind Institute - Detect Sleep States | child-mind-institute-detect-sleep-states

开始: 2023-09-05 结束: 2023-12-05 健康管理与公共卫生 数据算法赛

第一名解决方案

作者:sakami | 发布时间:2023-12-06 | 最后更新:2024-01-06

首先,我要向所有参与者和比赛主办方表示感谢。这是一场充满挑战的比赛,但我对积极的结果感到欣慰和放松。

以下是我们的解决方案简要总结。您可以在此处查看我们的代码。

单一模型

以下是摘要后提升CV分数的记录。最终分数为:CV: 0.8206,公开榜: 0.768,私有榜: 0.829(相当于第9名)。

摘要

模型结构

模型结构主要基于这个优秀的笔记本,结构包括:
CNN(下采样) → 残差GRU → CNN(上采样)

模型结构
  • SEScale

对于输入缩放,使用了SEModule。(https://arxiv.org/abs/1709.01507

class SEScale(nn.Module):
    def __init__(self, ch: int, r: int) -> None:
        super().__init__()
        self.fc1 = nn.Linear(ch, r)
        self.fc2 = nn.Linear(r, ch)

    def forward(self, x: torch.FloatTensor) -> torch.FloatTensor:
        h = self.fc1(x)
        h = F.relu(h)
        h = self.fc2(h).sigmoid()
        return h * x
  • 分钟连接

如多个讨论和笔记本中所述,地面实况事件发生的分钟存在偏差。为此,我们在最终层单独连接了与分钟相关的特征。

def forward(self, num_x: torch.FloatTensor, cat_x: torch.LongTensor) -> torch.FloatTensor:
    cat_embeddings = [embedding(cat_x[:, :, i]) for i, embedding in enumerate(self.category_embeddings)]
    num_x = self.numerical_linear(num_x)

    x = torch.cat([num_x] + cat_embeddings, dim=2)
    x = self.input_linear(x)
    x = self.conv(x.transpose(-1, -2)).transpose(-1, -2)

    for gru in self.gru_layers:
        x, _ = gru(x)

    x = self.dconv(x.transpose(-1, -2)).transpose(-1, -2)
    minute_embedding = self.minute_embedding(cat_x[:, :, 0])
    x = self.output_linear(torch.cat([x, minute_embedding], dim=2))
    return x

数据准备

每个数据序列被划分为每日块,偏移0.35天。

train_df = train_df.with_columns(pl.arange(0, pl.count()).alias("row_id"))
series_row_ids = dict(train_df.group_by("series_id").agg("row_id").rows())

series_chunk_ids = []  # list[str]
series_chunk_row_ids = []  # list[list[int]]
for series_id, row_ids in tqdm(series_row_ids.items(), desc="split into chunks"):
    for start_idx in range(0, len(row_ids), int(config.stride_size / config.epoch_sample_rate)):
        if start_idx + config.chunk_size <= len(row_ids):
            chunk_row_ids = row_ids[start_idx : start_idx + config.chunk_size]
            series_chunk_ids.append(series_id)
            series_chunk_row_ids.append(np.array(chunk_row_ids))
        else:
            chunk_row_ids = row_ids[-config.chunk_size :]
            series_chunk_ids.append(series_id)
            series_chunk_row_ids.append(np.array(chunk_row_ids))
            break

训练期间,每个块的一半在每轮迭代中使用。

sampled_train_idx = train_idx[epoch % config.epoch_sample_rate :: config.epoch_sample_rate]

评估时,重叠部分取平均值,并且每个块的边缘被修剪30分钟。

目标

基于与地面实况事件的距离创建衰减目标,距离增加时值递减。

目标
tolerance_steps = [12, 36, 60, 90, 120, 150, 180, 240, 300, 360]
target_columns = ["event_onset", "event_wakeup"]

train_df = (
    train_df.join(train_events_df.select(["series_id", "step", "event"]), on=["series_id", "step"], how="left")
    .to_dummies(columns=["event"])
    .with_columns(
        pl.max_horizontal(
            pl.col(target_columns)
            .rolling_max(window_size * 2 - 1, min_periods=1, center=True)
            .over("series_id")
            * (1 - i / len(tolerance_steps))
            for i, window_size in enumerate(tolerance_steps)
        )
    )
)

每轮迭代更新目标以进一步衰减。

targets = np.where(targets == 1.0, 1.0, (targets - (1.0 / config.n_epochs)).clip(min=0.0))

通过衰减目标,预测值的范围变窄,从而能够检测更精细的峰值。

衰减示例

周期性滤波器

此处所述,当测量设备被移除时,数据存在每日周期性。我们利用这一特性,基于规则预测这些时段,并将其用作输入和预测的滤波器。

周期性滤波器

输入特征

  • 分类特征
    • 小时
    • 分钟
    • 工作日
    • 周期性标志
  • 数值特征
    • anglez / 45
    • enmo.log1p().clip_max(1.0) / 0.1
    • anglez, enmo 的12步滚动均值、滚动标准差、滚动最大值
    • anglez_diff_abs 的5分钟滚动中位数

变更日志

  • 基线模型 (cv: 0.7510) - 公开榜: 0.728
  • 添加每轮迭代衰减目标的过程 (cv: 0.7699, +19分)
  • 将周期性滤波器添加到输出 (cv: 0.7807, +11分)
  • 也将周期性标志添加到输入 (cv: 0.7870, +6分) - 公开榜: 0.739
  • batch_size: 16 → 4, hidden_size: 128 → 64, num_layers: 2 → 8 (cv: 0.7985, +11分) - 公开榜: 0.755
  • 按每日分数总和归一化提交文件中的分数 (cv: 0.8044, +6分)
  • 从输入中移除月份和日期 (cv: 0.8117, +7分)
  • 将块的边缘两侧各修剪30分钟 (cv: 0.8142, +4分) - 公开榜: 0.765
  • 修改为将分钟特征连接到最终层 (cv: 0.8206, +6分) - 公开榜: 0.768

后处理

此后处理创建提交DataFrame以优化评估指标。通过此后处理方法,我们显著提升了分数(公开榜: 0.768 → 0.790,私有榜: 0.829 → 0.852 !!!)。

这是一个复杂的过程,我将逐步解释。

  1. 数据特性

    首先,让我们讨论数据特性。如多个讨论和笔记本中所述,目标事件的秒数始终设为零。
    比赛的评估指标不会区分与地面实况事件在30秒范围内的预测。因此,无论提交的时间戳秒数是5, 10, 15, 20, … 25,都会返回相同的分数。

  2. 二级模型的创建

    一级模型的预测被训练为在距离地面实况事件一定范围内识别为正例。然而,二级模型将这些转换为每分钟存在地面实况事件的概率。
    一级模型的输出在秒数0, 5, 10, …,但二级模型将这些聚合为始终在秒数0。具体来说,它聚合hh:mm:00周围的特征,并学习仅在事件的确切时间预测1,否则为0。二级模型的细节将在后面描述。

  3. 每个点的分数计算

    如前所述,提交同一分钟内的任意秒数都会获得相同的分数。因此,我们估计每分钟15秒和45秒点的分数,并提交值最高的那个,从而有效地提交所有点的最高分数。分数估计方法如下:

    例如,让我们估计10:00:15的分数。

    首先,我们从目标点创建12步的窗口,并汇总二级模型在该窗口内的预测值以计算`tolerance_12_score`。

    类似地,我们计算`tolerance_36_score`、`tolerance_60_score`、…,对应评估中使用的各个容差,这些分数的总和被视为目标点的分数。

    我们对所有点执行此计算,并对每个序列采用分数最高的点,将其添加到提交DataFrame中。

  4. 分数重新计算

    接下来,我们重新计算分数以确定下一个要采用的点。例如,假设选择了09:59:15点。

    首先,考虑更新`tolerance_12_score`。在采用点容差12范围内的事件不能与下一个要提交的点重叠匹配。

    因此,在计算下一个要采用的点的`tolerance_12_score`时,需要折扣当前采用点12步范围内的预测值。

    同样,对于`tolerance_36_score`、`tolerance_60_score`、…,我们通过折扣采用点36、60、…步范围内的预测值来重新计算。

    使用更新后的分数计算后,我们再次为每个序列采用分数最高的点,并将其添加到提交DataFrame中。

  5. 创建提交

    我们重复上述步骤4以提取足够数量的提交点,然后将这些点编译成DataFrame以创建提交文件。

附加技术

为使后处理有效工作,我们采用了若干其他技术:

  • 每日归一化二级模型的预测
  • 重新计算分数时,计算与之前分数的差值以减少计算量
  • 使用JIT编译加速上述计算

二级模型的细节

  • 二级模型首先将一级模型的预测按分钟平均,然后使用`find_peaks`检测这些平均值的峰值,高度为0.001,距离为8。
  • 基于检测到的峰值,从原始时间序列创建块,捕获每个峰值前后8分钟。(召回率: 0.9845)
    • 该步长至关重要,因为正负样本的比例取决于包含多少步,这会影响后续阶段的准确性。因此,我们调整步数以达到最佳性能。
    • 如果块相连,则将其视为单个块。
块创建
  • 对于每个块,我们从一级模型的预测以及anglez和enmo等其他特征聚合特征。这些聚合特征用于训练LightGBM和CatBoost等模型。
  • 此外,我们将每个块视为序列,用于训练CNN-RNN、CNN和Transformer模型。结果,我们开发了一个可以解释一级模型未完全解决的分钟偏差的模型。
模型训练
  • 二级模型的预测已充分校准,因此无需进一步转换。
校准结果
同比赛其他方案