返回列表

11th Place Solution

378. University of Liverpool - Ion Switching | liverpool-ion-switching

开始: 2020-02-24 结束: 2020-05-25 药物研发 数据算法赛
第11名解决方案

第11名解决方案

作者: Rob Mulla | 排名: 第11名

恭喜所有参加这次比赛的人。我要感谢利物浦和Kaggle举办这次比赛。没有比赛是完美的,但我认为尽管存在一些缺陷,这次比赛是成功的(至少对我来说),因为它挑战我努力思考并应用机器学习解决方案来解决一个棘手的问题。大约一年半前,我参加了我的第一次严肃的Kaggle比赛。我绝不会相信最终我能获得单人金牌。这需要大量的工作,外加一点运气。

背景(我的比赛经历)

比赛发布的那天就引起了我的兴趣。它似乎勾选了我感兴趣的所有选项,即:非传统数据,数据量不大也不小,基于科学的问题陈述,非同步因此我不必依赖内核进行训练或提交,以及一个扩展我构建神经网络技能的机会。在Bengali比赛结束后,我开始像往常一样在晚上和周末处理这个问题,并在脑海中构思想法。

然后美国开始出现COVID-19病例,我的日常工作发生了迅速变化,我被暂时解雇了,突然间我的日程安排中有了更多的时间陪伴家人和参加Kaggle :)

我注意到许多知名的Kagglers选择了退出这场比赛,这通常不是一个好迹象。有人早些时候推测我们已经达到了0.938的最佳解决方案——我表示怀疑。是的,数据是半合成的,但知道数据通过了物理放大器,我认为信号处理至少可以提供超过0.938的提升。

我开始像往常一样使用LGBM进行建模,类似于我在公共内核中分享的内容(链接)。受The Zoo过去解决方案的启发,我想保持我的模型特征轻量化。在阅读了@cdeotte解释漂移的优秀笔记本后,很明显去漂移是当务之急。我去除漂移的第一次尝试只是在每个批次的滚动中值上拟合线性和抛物线方程,并从信号中减去它。这给了我一个相对干净的信号,但仍然存在一个问题,即每个通道的峰值没有对齐——特别是10通道信号与其他组中相关通道的峰值没有正确对齐。为了解决这个问题,我手动添加了一个偏移量来对齐这些峰值。这种“峰值对齐”结合LGBM模型的干净数据实现了0.941的公共LB成绩——在当时足以获得第二名。我很兴奋,但我知道比赛还处于早期阶段。没过多久,“无漂移”数据的第一版被分享出来,许多队伍跃升至0.941。

0.941

我尝试了不同的特征、不同的CV设置、模型设置,看到了微小的收益。在此期间,我还专注于更好地理解macroF1指标以及如何优化它。在阅读了一些论文和过去的Kaggle帖子后,我了解到可以通过优先考虑出现频率较低的类别来改进MacroF1。我为此开发了一些代码,发现后处理在我的CV和LB分数上带来了约0.0001到0.0005的提升,但这也相当有风险,因为总是有可能过度后处理而弊大于利。

那时我决定采用我最好的LGBM特征并在神经网络中使用它们。多亏了那些精彩的笔记本,我决定使用TF2/keras而不是pytorch编码:

  • Unet: 链接 感谢 @kmat2019
  • Wavenet: 链接 感谢 @siavrez

Wavenet表现出了最大的希望,所以我开始尝试不同的特征并稍微修改结构,这足以将我推向0.942的公共LB(当时第3名)。

0.942

我尝试了伪标签、目标编码和许多其他东西,但最终排名开始下降,变得非常气馁。我甚至联系了一位朋友/Kaggler,看他是否有兴趣组队——幸运的是(对我而言),在他回复之前我取得了下一个突破。

在我的一个实验中,我尝试在Wavenet之后添加一个LSTM,并结合对信号四舍五入到小数点后2位的目标编码。这在CV上有了很大的改进,但我持怀疑态度。当我提交时,我很惊讶它站得住脚,我是第一个突破0.945的人!我花了接下来的几天试图理解为什么这行得通,并以一种更CV稳定的方式重现它。我发现当目标编码未缩放时,模型表现更好:所以当目标编码在0-10范围内时,信号和其他特征被缩放到0-1。我还意识到将信号四舍五入到2位数本质上与信号分箱是一样的——所以我转而使用KBinsDiscretizer以允许我尝试不同的分箱大小。

0.945

经过多次实验,我能够将分数提高到0.946(当时第1名)。我还发现了这个很棒的内核,给了我添加50Hz和几个额外频率特征的想法,这给了我的CV/LB提升:链接 - 感谢 @johnoliverjones

0.946

到此时,许多拥有多位GM和强力Kaggler的强队正在组建。我准备好被超越了——但我继续实验!我试图让我的GPU一直运行实验(while ps -p 12345; do sleep 60; python myscript.py 是一个巧妙的小技巧,用于在前一个脚本完成后启动脚本)。我还使用google colab进行额外的实验。几乎我所有的实验都失败了或没有定论,但有两个最终将我的分数提高到了0.947(公共第2名):

  1. 改进我的“峰值偏移”代码,以找到每个批次峰值的完美偏移。
  2. 从信号中移除包含噪声的特定频率(即50Hz)。

0.947

现在进入细节:

预处理

移除不良训练数据
像大多数人一样,我从无漂移数据开始。我还可以直观地识别出由于极端噪声而存在一些异常值,这些异常值与测试集中的任何内容都不相似,因此我完全移除了那部分训练数据:

FILTER_TRAIN = '(time <= 47.6 or time > 48) and (time <= 364 or time > 382.4)'
train = train.query(FILTER_TRAIN)

移除噪声频率
我知道50Hz噪声是由于放大器中的交流电流产生的嗡嗡声。以前研究和工作在电力工程领域,我知道50Hz(美国为60Hz)只是交流电源的近似频率。实际上,由于许多不同的因素,它可能会超前或滞后50Hz。我写了一些代码来识别每个批次的峰值究竟出现在哪里——有些批次是50.01Hz,有些是49.99Hz或50Hz。然后代码自动调整了一个陷波滤波器,其Q值通过scipy调整,以最小化约50Hz频率与周围频率幅度的方差。这使其能够“平滑地”滤除50Hz频率,而对信号其余部分的干扰最小。

这个笔记本在我最初研究这个想法时很有帮助:链接

以及这个“自动50Hz滤波器”的代码:

def fix_50hz(tt, batch, Q = 250.0,
             f0 = 50, fs=10000, plot=True,
             write=True, domax=True,
             use_cleaned=False):
    """
    Q: Quality factor
    f0: Frequency to be removed from signal (Hz)
    """
    if use_cleaned:
        sig_sample = tt.loc[tt['sbatch'] == batch]['signal_cleaned_10s'].values
    else:
        sig_sample = tt.loc[tt['sbatch'] == batch]['signal'].values
    
    # ... (code omitted for brevity in translation, refers to FFT and filtering logic) ...

这在去除49-51Hz噪声方面效果相当不错。我尝试在每个批次单独运行此操作,按10秒间隔和1秒间隔。我还尝试移除其他“嘈杂”频率。图表用于验证变化(蓝色区域显示频率被移除的位置)

滤波器示例

峰值对齐
预处理的最后一步是对齐每个批次的分布峰值。为了实现这一点,我写了一些代码,通过open_channel最小化批次之间信号中值的差异。在做一些研究时,我发现这些类型的“峰值对齐”问题在各个领域都很常见,我浏览了几篇关于它的论文。例如,这篇论文讨论了色谱法的峰值对齐

以及代码:

def apply_shift(tt, shift_group, base_groups):
    # ... (code logic for shifting and aligning peaks) ...

以下是此代码输出的两个示例:

峰值1

峰值2

峰值对齐过程的一个问题是,对于训练集,我知道真实的开放通道。对于测试集,我不知道它们,所以我使用了之前模型的预测。我尝试在训练集上使用包外值——但效果不如预期。我的想法是,最终我的测试对齐会达到一个“最佳”偏移。

尽管如此,我知道这让我有可能过度拟合我自己的预测。我尝试了许多其他方法来对齐峰值——但决定尽管有风险,这似乎效果最好。

最终结果是一个峰值对齐非常干净的数据集。

干净数据1 干净数据2

特征工程

正如我上面提到的,我发现最重要的特征是目标编码。我使用KBinsDiscretizer首先将信号划分为不同的分箱,然后对其运行目标编码。我没有缩放目标编码,但确实缩放了所有其他特征。其他特征是滞后+/- 5个样本,signal^2和50Hz特征。

以下是我用于创建目标编码的函数。它使用了category-encoders包 链接

def add_target_encoding(tr_df, val_df, test_df,
                        features, n_bins=500,
                        strategy='uniform', feature='signal',
                        smoothing=1):
    # ... (code logic for binning and target encoding) ...
    return tr_df, val_df, test_df, features

我会时不时对我的模型进行置换重要性测试,看看是否可以丢弃任何特征。

我最终模型之一中使用的确切特征示例是:

['signal', 'f_DC', 'f_50Hz', 'f_n100Hz',
'f_2xn100', 'signal_mm_scaled', 'signal_round2',
'signal_shift_pos_1', 'signal_shift_neg_1',
'signal_shift_pos_2', 'signal_shift_neg_2',
'signal_shift_pos_3', 'signal_shift_neg_3',
'signal_shift_pos_4', 'signal_shift_neg_4',
'signal_shift_pos_5', 'signal_shift_neg_5',
'signal_2',
'target_encode_signal_round2_500bins_10',
'target_encode_signal_300bins',
'target_encode_signal_600bins',
'target_encode_signal_100bins',
'target_encode_signal_1000bins']

模型:Wavenet+LSTM

我的模型最终基于wavenet-keras内核中的模型 链接(感谢 @siavrez)。我在比赛期间非常想给他的笔记本投票,但我认为这可能会给人们提供我正在使用什么的线索。从那以后我给它投了票,真希望能投10次,它太有用了。

在该笔记本的Wavenet基础上,我添加了:

  • 在Wavenet之后直接添加LSTM进入softmax输出
  • 添加了GaussianNoise似乎有帮助
  • 调整了学习率调度器

无效的尝试:

  • GRU不如LSTM好
  • GRU + LSTM并连接结果
  • 更改激活函数
  • 不同级别的GaussianNoise
  • 32, 64, 92, 128大小的LSTM(92似乎效果最好)
  • 对Wavenet进行了许多不同的设置,但没有明确的改进。
  • 许多不同的学习率设置
  • 单独建模类型
  • 移除训练数据以试图更接近公共或私有测试数据。

我尝试了许多不同的想法,我的最终模型之一示例如下:

    inp = Input(shape = (shape_))
    x = wave_block(inp, 16, 3, 12)
    x = GaussianNoise(0.01)(x)
    x = wave_block(x, 32, 3, 8)
    x = GaussianNoise(0.01)(x)
    x = wave_block(x, 64, 3, 4)
    x = GaussianNoise(0.01)(x)
    x = wave_block(x, 128, 3, 1)
    x = LSTM(96, return_sequences=True)(x)
    out = Dense(11, activation = 'softmax', dtype='float32', name='predictions')(x)
    model = models.Model(inputs = inp, outputs = out)

数据增强

早期我发现在训练期间翻转信号以使数据量加倍是有帮助的。我也在TTA中使用了翻转(在预测时对测试数据进行翻转并取平均值)。我尝试向原始信号添加噪声,但这只会让CV和LB变得更糟。

最后一周——训练与融合

独自参加比赛有很多困难——但这部分可能是最难的。没有人可以商量如何制作最终的集成和融合,只能依靠我的直觉,最终结果还不错——但我也有一点运气。

因为担心我的峰值对齐已经有点过拟合,我最终选取了最好的40个左右的提交,并在进行后处理之前融合原始概率。对于我的最终提交之一,我只是取了40个模型的平均值。第二次提交我根据公共LB上的位置权衡每个预测。我还非常仔细地检查了每个提交的相关性,比较公共与私有LB,并按模型类型比较。我的主要关注点是私有测试集中的10通道部分。我注意到那是提交之间差异最大的部分,并且由于macroF1指标对每个类别的权重相等,它会对最终分数产生巨大影响。

按公共LB分数排序的提交文件相关性示例图: 相关性

后处理

对于后处理,我融合了其他Kagglers的两个想法。第一个来自Chris在Bengali比赛中关于“黑客宏召回”的解决方案文章。第二个是笔记本中使用的f1优化类。这段代码的独特之处在于它使用每个类别的原始概率进行优化,而不是类别预测本身。

结果代码是这样的:

class ArgmaxF1Optimizer(object):
    """
    Class for optimizing predictions using argmax
    """
    def __init__(self, initial_exp=-1.2, method='nelder-mead',
                verbose=True):
        # ... (initialization code) ...

    def apply_pp(self, X, EXP):
        # ... (code logic for applying post-processing) ...
        return np.argmax(X.dot(mat1), axis=1)

    # ... (other methods for fit, predict, etc.) ...

我想在应用后处理时非常小心。我知道它在公共排行榜上有帮助,但我永远无法100%确信应该对私有数据应用多少。为了帮助我决定应用多少后处理,我编写了代码:

  • 使用了融合中所有模型的OOF预测。
  • 模拟了与公共和私有测试集完全相同的分布(同时匹配模型类型和开放通道)
  • 使用不同的种子重复此过程100次。我这样做的原因是F1分数会根据选择的样本波动+/- 0.001。
  • 在每个模拟上运行我的F1优化代码。
  • 取100个合成案例的平均EXP,并用它来后处理我的最终提交。
  • 注意这是分别在公共和私有测试集上完成的,因为它们是分开评分的。

这个模拟的输出看起来像这样:

0 Raw public:  0.94290 : -0.14906 : 0.94304 	 Raw private: 0.94278 : -0.14000 : 0.94289
1 Raw public:  0.94391 : -0.01950 : 0.94390 	 Raw private: 0.94377 : -0.02000 : 0.94374
...

分布看起来像这样,我在对提交应用后处理时使用了平均值:

exp选择

我仍然无法解释的事情

我还有一些遗留的问题。

  • 这些锚点是什么? [-2.5002, -1.2502, -0.0002, 1.2498, 2.4998, 3.7498]。我注意到在信号中,每个开放通道的信号分布右侧有一个唯一值计数的峰值。你可以在这张图中清楚地看到它们。我称它们为“锚点”,因为它们几乎总是被标记为open_channel,即使它们位于平均值的右侧。

峰值

我可能花了2或3天时间研究这个问题,认为这可能有助于对齐峰值。我仍然没有很好的解释它们为什么存在。我甚至写了一个优化函数,调整漂移批次,以便在四舍五入到小数点后4位时最大化这些值。结果LB分数更差。

  • 新批次从哪里开始?

批次大小是10秒或50秒..我想。我认为0-50秒是一个批次,直到仔细检查滚动中值。你可以看到中值在10秒处有明显的偏移。我在私有测试数据中发现了同样的情况。因为我不确定,我决定在这些点拆分并分别进行“峰值对齐”。

偏移

结论

最终我在私有排行榜上下降了9位,获得第11名。我最好的单模型得分为0.94547(私有),有潜力获得第7名——但我绝不会选择它。另一方面,我最好的公共LB分数模型的私有LB得分为0.94211!我也从未考虑选择它。

选定的提交

我确信我可以做一些事情在震荡中表现得更好,但我很高兴它足以保持在金牌范围内。

根据时间和兴趣——我可能会创建一个端到端运行我的代码的笔记本。

就是这样。希望这篇总结对你有帮助!

同比赛其他方案