586. Child Mind Institute - Detect Sleep States | child-mind-institute-detect-sleep-states
首先,我要向所有参与者和比赛主办方表示感谢。这是一场充满挑战的比赛,但我对积极的结果感到欣慰和放松。
以下是我们的解决方案简要总结。您可以在此处查看我们的代码。
以下是摘要后提升CV分数的记录。最终分数为:CV: 0.8206,公开榜: 0.768,私有榜: 0.829(相当于第9名)。
模型结构主要基于这个优秀的笔记本,结构包括:
CNN(下采样) → 残差GRU → CNN(上采样)
对于输入缩放,使用了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))
通过衰减目标,预测值的范围变窄,从而能够检测更精细的峰值。
如此处所述,当测量设备被移除时,数据存在每日周期性。我们利用这一特性,基于规则预测这些时段,并将其用作输入和预测的滤波器。
此后处理创建提交DataFrame以优化评估指标。通过此后处理方法,我们显著提升了分数(公开榜: 0.768 → 0.790,私有榜: 0.829 → 0.852 !!!)。
这是一个复杂的过程,我将逐步解释。
数据特性
首先,让我们讨论数据特性。如多个讨论和笔记本中所述,目标事件的秒数始终设为零。
比赛的评估指标不会区分与地面实况事件在30秒范围内的预测。因此,无论提交的时间戳秒数是5, 10, 15, 20, … 25,都会返回相同的分数。
二级模型的创建
一级模型的预测被训练为在距离地面实况事件一定范围内识别为正例。然而,二级模型将这些转换为每分钟存在地面实况事件的概率。
一级模型的输出在秒数0, 5, 10, …,但二级模型将这些聚合为始终在秒数0。具体来说,它聚合hh:mm:00周围的特征,并学习仅在事件的确切时间预测1,否则为0。二级模型的细节将在后面描述。
每个点的分数计算
如前所述,提交同一分钟内的任意秒数都会获得相同的分数。因此,我们估计每分钟15秒和45秒点的分数,并提交值最高的那个,从而有效地提交所有点的最高分数。分数估计方法如下:
例如,让我们估计10:00:15的分数。
首先,我们从目标点创建12步的窗口,并汇总二级模型在该窗口内的预测值以计算`tolerance_12_score`。
类似地,我们计算`tolerance_36_score`、`tolerance_60_score`、…,对应评估中使用的各个容差,这些分数的总和被视为目标点的分数。
我们对所有点执行此计算,并对每个序列采用分数最高的点,将其添加到提交DataFrame中。
分数重新计算
接下来,我们重新计算分数以确定下一个要采用的点。例如,假设选择了09:59:15点。
首先,考虑更新`tolerance_12_score`。在采用点容差12范围内的事件不能与下一个要提交的点重叠匹配。
因此,在计算下一个要采用的点的`tolerance_12_score`时,需要折扣当前采用点12步范围内的预测值。
同样,对于`tolerance_36_score`、`tolerance_60_score`、…,我们通过折扣采用点36、60、…步范围内的预测值来重新计算。
使用更新后的分数计算后,我们再次为每个序列采用分数最高的点,并将其添加到提交DataFrame中。
创建提交
我们重复上述步骤4以提取足够数量的提交点,然后将这些点编译成DataFrame以创建提交文件。
为使后处理有效工作,我们采用了若干其他技术: