返回列表

10th Place Solution - ~350 oofs => 9 hillclimbing versions => Final Autogluon ensemble

657. Playground Series - Season 5, Episode 6 | playground-series-s5e6

开始: 2025-06-01 结束: 2025-06-30 作物智能识别 数据算法赛
第 10 名解决方案 - ~350 个 OOF => 9 个 hillclimbing 版本 => 最终 Autogluon 集成
作者: Ole-Jakob
发布日期: 2025 年 7 月 2 日
竞赛排名: 第 10 名

第 10 名解决方案 - ~350 个 OOF => 9 个 hillclimbing 版本 => 最终 Autogluon 集成

介绍

首先,我要向 Kaggle 举办此次竞赛以及社区分享知识和创造有趣讨论表示巨大的感谢。这是我第一次使用 map@3 指标,我发现排名预测为解决方案提供了更多的创意空间。

祝贺大师本人 @cdeotte 再次赢得另一个 playground 竞赛,同时也祝贺 @masayakawamata, @mahoganybuttstrings, @hahahaj@optimistix 与他展开了激烈的竞争。

摘要 (TL;DR)

  • 使用了标准模型:XGBoost, CatBoost, LightGBM, 神经网络 (NN) 和 逻辑回归,特征工程极少。
  • 生成了约 350 个折叠外 (OOF) 预测。最好的单个模型是 XGBoost,CV=0.37799,使用 Optuna 调优,6 个固定折叠和样本权重提升。
  • 创建了 20 个版本的爬山集成 (hill climbing ensembles),最好的达到了 CV=0.383830
  • 使用 AutoGluon 对 9 个最好的爬山结果进行集成,最终 CV 达到 0.383918,私有 LB 得分为 0.38472

解决方案概述

解决方案概述图

每个新版本的爬山法都有更大集的 OOF,最后一个版本约有 350 个。

基础模型

XGBoost:

XGBoost 表现最好,顶级单个模型达到了 CV=0.37799。感谢 @ravi20076 在竞赛早期阶段 在此处 指出。找到的最佳参数如下:

Params = {
'max_depth': 17, 
'min_child_weight' : 4, 
'subsample' : 0.8932774942420912, 
'colsample_bytree' : 0.40035993059700825, 
'gamma' : 0.44089845615519774, 
'reg_alpha' : 3.0189326628791378, 
'reg_lambda' : 1.0463133005441632, 
'max_delta_step': 5}

这是通过使用如下所述的针对第 2、3 和 4 名的样本权重提升 (sample weight bumping) 实现的。令人惊讶的是深度 17 是最优的,我将其归因于数据的合成性质。

LightGBM/CatBoost:

两者都包含以增加多样性,但它们的表现通常不如 XGBoost。

神经网络 (NN):

我使用了 @paddykb 优秀 notebook 的修改版本:No Keras, No Loan。我能达到的最好结果是 CV=0.35097
我尝试了将数值特征作为数值和类别两种情况,发现后者表现最好。

逻辑回归:

这是改编自 @siukeitinWhat if you can only use logistic regression...。通过样本权重提升,我达到了 CV=0.37468

集成策略

爬山法 (Hill Climbing):

我使用 @cdeotte 的 GPU 爬山法创建了 20 个不同版本。我最好的爬山集成包含以下 14 个 OOF 预测:

- oof_model-1-15-w11-xgboost_trial_3_map3_0.37799.npy
- oof_model-13-3-w10-logisticregression_trial_0_map3_0.37530.npy
- oof_model-4-3-w10-NN_trial_0_map3_0.35016.npy
- oof_model-5-5-w10-catboost_trial_1_map3_0.34875.npy
- oof_model-12-1-w10-xgboost_index_0_map3_0.36555.npy
- oof_model-1-15-w11-xgboost_trial_0_map3_0.37737.npy
- oof_model-4-2-w10-NN_trial_3_map3_0.34908.npy
- oof_model-5-5-w10-catboost_trial_2_map3_0.35196.npy
- oof_model-12-1-w10-xgboost_index_67_map3_0.36579.npy
- oof_model-1-7-w10-xgboost_trial_0_map3_0.37640.npy
- oof_model-13-1-w10-logisticsregression_trial_0_map3_0.37411.npy
- oof_model-12-1-w10-xgboost_index_39_map3_0.36198.npy
- oof_model-3-2-w10-lightgbm_trial_2_map3_0.35041.npy
- oof_model-5-3-w10-catboost_trial_2_map3_0.34606.npy

这导致了 CV=0.383830Public LB=0.38268。正如 @cdeotte 多次指出的那样,关键在于模型的多样性,而不是高性能的单个模型。

旁注,我认为爬山法还有更多值得研究的地方(想法,未在此次竞赛中使用):

  • 分别对每个折叠进行爬山,应用于测试集,然后平均结果。
  • 尝试从不同的模型开始爬山,而不仅仅是表现最好的模型。
  • 实现基于树的搜索(例如,使用 Alpha-beta 剪枝)以找到最优模型集,而不是依赖纯粹的贪心方法。

在此次竞赛期间,我注意到有时向爬山添加新的 OOF 会显著降低分数,这让我对该方法的潜在改进感到好奇。

使用 AutoGluon 进行最终集成:

使用 AutoGluon 对 9 个最好的爬山集成进行进一步集成,生成最终提交,CV=0.383918Private LB=0.38472
感谢 @ravaghi这里 指出如何在 AutoGluon 中使用 map@3。

其他技巧

1. 对原始数据使用 Sample_Weight x4

遵循公开 notebook 中的常见技术,我将原始数据的 sample_weight 设置为 4 倍,这提高了分数。

2. 使用 Sample_Weight 提升第 2、3 和 4 名

我分析了 OOF 预测,以确定正确类别被预测为第 2、3 或 4 名的实例。然后我使用增加的 sample_weight 重新训练模型,以鼓励模型将它们排名更高。提升一个位置的潜在收益为:

  • 第 2 名 => 第 1 名 : 0.5 (从 0.5 到 1.0)
  • 第 3 名 => 第 2 名 : 0.17 (从 0.33 到 0.5)
  • 第 4 名 => 第 3 名 : 0.33 (从 0.0 到 0.33)

我尝试了对两个位置之间的概率差低于阈值(例如 0.02)的预测提升 sample_weight。这项技术为许多模型带来了约 0.001 的 CV 改进。我尝试了仅提升第 2 名预测,以及提升所有三个排名。我考虑过用 Optuna 自动化这个过程,但这将是未来 playground 竞赛的项目。

3. 对所有内容使用相同的 CV 折叠

我对所有实验使用了固定的 6 折分层 K 折 (Stratified-K-Fold) 策略。为了确保一致性并防止阶段间的数据泄露,我在脚本中添加了一个验证步骤:

def verify_first_fold(val_idx):
    # 硬编码的第一个验证折叠的前 10 个索引
    val_idx_expected = np.array([1, 2, 5, 8, 9, 14, 16, 23, 27, 30])  
    if np.array_equal(val_idx[:10], val_idx_expected):
        return
    
    print("第一个折叠验证索引与预期值不匹配。")
    print("预期:", val_idx_expected)
    print("实际:", val_idx[:10])
    raise ValueError("检测到折叠不匹配。停止执行。")

# 在训练循环中
for fold, (train_idx, val_idx) in enumerate(kf.split(X=train_df, y=...)):
    if fold == 0:
        verify_first_fold(val_idx)

这不是一个万无一失的方法,但它多次救我免于污染我的 OOF 预测。

4. Optuna - 技巧

  • 我将所有超参数调优运行合并在 Optuna Dashboard 中以便更好地跟踪。所有结果都存储在每个 notebook 的本地 sqlite3 数据库中,我使用一个单独的合并脚本将它们合并到一个 db 中:
def merge_optuna_studies(
    source_storages: List[str], 
    output_storage: str, 
    min_trials: int = 10,
    name_prefix: str = ""
) -> int:
    """将多个 Optuna 存储中的研究合并到一个中。"""
    copied_studies = 0
    
    for source_storage in source_storages:
        try:
            # 获取源存储中的所有研究摘要
            study_summaries = optuna.study.get_all_study_summaries(storage=source_storage)
            
            if len(study_summaries) == 0:
                print(f"在 {source_storage} 中未找到研究。跳过。")
                continue
                
            print(f"在 {source_storage} 中找到 {len(study_summaries)} 个研究")
            
            # 处理每个研究
            for summary in study_summaries:
                study_name = summary.study_name
                n_trials = summary.n_trials
                
                if n_trials < min_trials:
                    print(f"跳过研究 '{study_name}',只有 {n_trials} 次试验 (最少要求:{min_trials})")
                    continue
                    
                print(f"复制研究 '{study_name}',共 {n_trials} 次试验")
                
                optuna.study.copy_study(from_study_name=study_name, 
                                        from_storage=source_storage, 
                                        to_storage=output_storage, 
                                        to_study_name=study_name)

                copied_studies += 1
                
        except Exception as e:
            print(f"处理 {source_storage} 时出错:{str(e)}")
    
    return copied_studies
  • 我使用了 WilcoxonPruner 来减少计算工作量,尽管这导致生成的 OOF 较少。设置如下:
# 剪枝器设置
p_threshold = 0.08  # 剪枝的 p 值阈值
n_startup_steps = 2  # 开始剪枝前完成的试验次数
pruner = optuna.pruners.WilcoxonPruner(
    p_threshold=p_threshold,
    n_startup_steps=n_startup_steps
)

# 创建 Optuna 研究
study = optuna.create_study(
    direction="maximize", 
    pruner=pruner
)

def objective(trial):
    # ...
    # 在 CV 循环内,向 Optuna 报告中间分数
        # ... 在折叠上训练和评估 ...
        trial.report(map3_score, step=fold)

        # 如果试验没有希望则剪枝,除非在最后一个折叠。
        if trial.should_prune() and fold < total_folds - 1:
            raise optuna.TrialPruned()
    
    return final_cv_score

此设置还将 CV 分数(中间值)填充到 Optuna Dashboard 中:
Optuna Dashboard

  • 我总是将默认参数或其他强基线配置作为第一次试验入队。

5. 特征工程搜索

竞赛中期,我固定了最好的 XGBoost 参数,并系统地测试了约 200 个工程特征,计算每个特征的 map@3。一些示例:

{
  "tested_features": {
    "Potassium_binned": 0.36687777777820074,
    "Humidity_x_Moisture_x_Potassium": 0.36101355555603537,
    "Humidity_x_Potassium": 0.36245444444491326,
    "Humidity_x_Moisture": 0.36123777777825505,
    "log_Temparature": 0.3660744444448719,
    "Temparature_x_Nitrogen_div_Humidity": 0.36036377777826734,
    "sqrt_Phosphorous": 0.36733622222264173,
    "Temparature_x_Humidity_div_Nitrogen": 0.3603313333338274
  }
}

没有一个特征能改善无特征工程基线的结果。 这与其他竞争对手的发现一致。然而,由于增加的多样性,其中一些 OOF 仍被爬山算法选中。我知道 @cdeotte 建议添加所有特征然后逐个删除,但我缺乏计算资源来进行那种方法。由于我电脑上可用于爬山的 OOF 数量的内存限制,我不得不删除一些 OOF。

未成功的方法 / 未来改进

TabTransformer:

我花了一些时间尝试让基于 TabTransformer 库的网络工作,但只能达到 CV=0.31。我后来发现了 @omidbaghchehsaraei 的优秀 notebook TabTransformer,但没有时间整合他的工作。

在单独模型中识别 DAP/尿素:

分析几个 OOF 的每个类别 map@3 分数,我发现 DAP 和尿素与其他类别相比结果较差:

各类别 MAP@3 贡献:(最终 oof)
Class 14-25-14: MAP@3=0.432626, 贡献=0.066011 (114,436 样本)
Class 10-26-26: MAP@3=0.408642, 贡献=0.062052 (113,887 样本)
Class 17-17-17: MAP@3=0.426334, 贡献=0.063923 (112,453 样本)
Class 28-28: MAP@3=0.372506, 贡献=0.055209 (111,158 样本)
Class 20-20: MAP@3=0.369162, 贡献=0.054581 (110,889 样本)
Class DAP: MAP@3=0.341869, 贡献=0.043240 (94,860 样本)
Class Urea: MAP@3=0.315330, 贡献=0.038814 (92,317 样本)

我尝试训练一个单独的二分类模型来识别 DAP/尿素,并将其预测作为主模型的特征,但这并没有提高我的分数。我还尝试使用 sample_weight 专门提升 DAP/尿素,但这只导致 CV 变得更差。

经验教训

  • 相信你的本地 CV。
  • 优先考虑集成的模型多样性。
  • 从讨论和其他 notebook 中学习。
  • 我对所有模型使用了相同的种子。然而,正如 @cdeotte 在他的 第 1 名解决方案 中提到的,map@3 对训练中的随机性可能很敏感,所以也许其中涉及了一些运气成分。:)

这是我第一次尝试写解决方案总结。感谢 Kaggle 社区提供的所有经验教训,我期待下一个 playground 竞赛。Happy Kaggling!

同比赛其他方案