返回列表

The 6th place solution (CV 0.39)

631. UM - Game-Playing Strength of MCTS Variants | um-game-playing-strength-of-mcts-variants

开始: 2024-09-05 结束: 2024-12-02 游戏AI 数据算法赛
第 6 名解决方案 (CV 0.39)

第 6 名解决方案 (CV 0.39)

作者: Vadim Timakin
发布时间: 2024-12-03
竞赛排名: 第 6 名

引言

首先,我要感谢本次竞赛的组织者,这个任务非常有趣,我也学到了很多。同时,非常感谢和致敬所有在本次竞赛中分享想法的参与者。最后,祝贺所有的获奖者!

1. 总结

我的解决方案结合了零成本数据生成的力量、良好的建模、手动和自动特征生成、两阶段模型集成,同时也包含一些有风险的部分,我将在后面尝试证明其合理性。

总体流程如下所示:

MCTS  pipeline

2. 验证策略

像本次竞赛中的其他人一样,从简单的以 GameRulesetName 为组的 GroupKFold 开始是一个很好的基线,但不够稳定。我做的第一个修改是添加按目标分层,之后我又将 agent1 列添加到分层标准中。

即使使用这种 CV 方法,我也没有在 CV 和公共 LB 之间获得完全的相关性,或者填补 CV 和 LB 之间的差距(在我看来,在使用训练集和测试集之间进行分组类型分层的竞赛中,这不应该是一个 concern),但它仍然更稳定,并且在本次竞赛中使用不同方法时在本地显示了更预期的结果,它在 CV 和公共 LB 上也显示了不同种子的一致结果。

保持分割始终相同并且仅验证源数据也很重要,随着新数据生成,这变得最为实际。

使用超过 5 折会导致分数轻微提升,但稳定性较差。

3. 数据部分

3.1. 预处理

  1. 删除常量列。
  2. 删除 GameRulesetName, LudRules 和 EnglishRules。
  3. 内存优化。

我还尝试了对分类特征进行 one-hot / label / target 编码,以及对数值特征进行 scaling / binarizing / normalizing,以提高模型性能并减少过拟合,但在所有情况下都无效,除了训练 DNN 模型。

3.2. 零成本数据生成

这部分基于逻辑和一些经验假设。

最初,生成更多数据的想法来自于我们有一些依赖于游戏和依赖于玩家的特征,这些特征也依赖于顺序。首先,我尝试交换 agent 字符串(这里及以后我总是反转目标)和 AdvantageP1 列,认为它代表某个 agent 胜过另一个 agent 的概率,这已经给我的 CV 和 LB 带来了 solid 的提升(仅验证原始数据很重要)。很快,我意识到这一列实际上代表了在这个游戏中第一个玩家随机战胜第二个玩家的概率。所以这个特征逻辑上不应该被反转,我移除了对它的反转,但这实际上降低了分数!我的解释是,虽然逻辑上只交换 agent 和目标是对的,但这种变换并没有给数据带来任何新信号,因此导致结果更差。

在此之后,我的原始和反转 OOF 预测分数之间仍然存在差距,所以我试图通过反转其他特征来填补这一差距。其中一个特征是 Balance,它在 AdvantageP1 之后具有最高的特征重要性。

特征重要性

所以我尝试反转这个特征,这几乎完全填补了差距。除此之外,我还尝试反转更多特征,但其余特征的特征重要性较低,并没有给数据带来任何新信号。下面展示了不反转 Balance 列和反转 Balance 列时的差距演示。

Balance 差距

总体数据生成流程如下所示。

数据生成

除此之外,我还尝试了其他生成新数据的方法,比如 agent 与自己对战,但价值较低。

当我填补了原始数据和生成数据之间的误差差距时,这也给了我使用 TTA 的机会,可以将我的分数提高 0.001-0.002 分。然而,它并未用于我的最终提交。

受上述结果的启发,我还尝试通过训练另一个没有分组分割标准的模型然后用它生成新数据来进行伪标签,但这并没有提高分数。

3.3. 手动特征生成

我尝试手动生成很多特征,大多数都没起作用,但这里有一些成功的特征。

1) 第一组特征来自简单地拆分 agent 字符串。

df = df.with_columns(
    pl.col('agent1').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 1).alias('p1_selection'),
    pl.col('agent1').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 2).alias('p1_exploration'),
    pl.col('agent1').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 3).alias('p1_playout'),
    pl.col('agent1').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 4).alias('p1_bounds'),
    pl.col('agent2').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 1).alias('p2_selection'),
    pl.col('agent2').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 2).alias('p2_exploration'),
    pl.col('agent2').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 3).alias('p2_playout'),
    pl.col('agent2').str.extract(r'MCTS-(.*)-(.*)-(.*)-(.*)', 4).alias('p2_bounds'),
)

2) 第二组特征取自 这个 notebook by @litsea。

df = df.with_columns([
    (pl.col('PlayoutsPerSecond') / (pl.col('MovesPerSecond') + 1e-15)).alias('Playouts/Moves'),
    (pl.col('MovesPerSecond') / (pl.col('PlayoutsPerSecond') + 1e-15)).alias('EfficiencyPerPlayout'),
    (pl.col('DurationActions') / (pl.col('DurationTurnsStdDev') + 1e-15)).alias('TurnsDurationEfficiency'),
    (pl.col('DurationActions') / (pl.col('MovesPerSecond') + 1e-15)).alias('ActionTimeEfficiency'),
    (pl.col('DurationTurnsStdDev') / (pl.col('DurationActions') + 1e-15)).alias('StandardizedTurnsEfficiency'),
    (pl.col('DurationActions') / (pl.col('StateTreeComplexity') + 1e-15)).alias('DurationToComplexityRatio'),
    (pl.col('GameTreeComplexity') / (pl.col('StateTreeComplexity') + 1e-15)).alias('NormalizedGameTreeComplexity'),
    (pl.col('Balance') * pl.col('GameTreeComplexity')).alias('ComplexityBalanceInteraction'),
    (pl.col('StateTreeComplexity') + pl.col('GameTreeComplexity')).alias('OverallComplexity'),
    (pl.col('GameTreeComplexity') / (pl.col('PlayoutsPerSecond') + 1e-15)).alias('ComplexityPerPlayout'),
    (pl.col('DurationTurnsNotTimeouts') / (pl.col('MovesPerSecond') + 1e-15)).alias('TurnsNotTimeouts/Moves'),
    (pl.col('Timeouts') / (pl.col('DurationActions') + 1e-15)).alias('Timeouts/DurationActions'),
    (pl.col('StepDecisionToEnemy') + pl.col('SlideDecisionToEnemy') + pl.col('HopDecisionMoreThanOne')).alias('ComplexDecisionRatio'),
    (pl.col('StepDecisionToEnemy') + 
        pl.col('HopDecisionEnemyToEnemy') + 
        pl.col('HopDecisionFriendToEnemy') + 
        pl.col('SlideDecisionToEnemy')).alias('AggressiveActionsRatio'),
    (pl.col('AdvantageP1') / (pl.col('Balance') + 1e-15)).alias('AdvantageBalanceRatio'),
    (pl.col('AdvantageP1') / (pl.col('DurationActions') + 1e-15)).alias('AdvantageTimeImpact'),
    (pl.col('OutcomeUniformity') / (pl.col('AdvantageP1') + 1e-15)).alias('OutcomeUniformity/AdvantageP1'),
])

3) 第三组特征我称为跨 agent 特征,它捕获了不同子类型 agent 之间的交互。

# Cross-agent features.

df["p1_agent"] = df["agent1"]
df["p2_agent"] = df["agent2"]

cols = ["agent", "selection", "exploration", "playout", "bounds"]
for c1 in cols:
    for c2 in cols:
        df[f"{c1}_{c2}"] = df[f"p1_{c1}"].astype(str) + df[f"p1_{c2}"].astype(str)

谈到从规则列生成的特征。对我来说,它们都被证明是无效的。我尝试使用 TF-IDF,计算各种索引,但这并没有带来分数的增加。

3.4. 自动特征生成

我使用了 OpenFE 库进行自动特征生成。这是一个很好的算法,但它没有像我预期的那样开箱即用,所以我不得不列出一系列必要的更改才能使其在本次竞赛中工作。基本上,我经历了以下步骤:

  1. 修复与多处理相关的 bugs。
  2. 修复与数据类型相关的 bugs。
  3. 调整日志记录以便调试。
  4. 将 StratifiedKFold 替换为 StratifiedGroupKFold。
  5. 采样 10% 的原始数据(考虑分层和组标准)来运行算法。
  6. 运行算法(在 10% 子样本和 1 CPU 上大约 30 小时)。
  7. 选择前 500 个最佳特征。
  8. 删除按组聚合的特征和 freq 特征,因为它们在公共 LB 中产生不稳定的结果。
  9. 用 "-100" 值填充 NaN。
  10. 保存特征和元数据以便在推理时生成它们。

下图显示许多这些特征被证明是非常有信息的。

SHAP 值

3.5. 特征选择

在所有特征生成之后,我基于 CV 重要性只保留了大约 400 列。这对 CV 或公共 LB 没有任何影响,这样做只是为了节省内存和加快训练速度。

除此之外,我还尝试不仅基于特征重要性,还基于其他因素删除特征:相关性、方差、每组唯一值的数量等。但这没有起作用。

4. 建模

4.1. 方法

从竞赛开始到结束,Catboost 对我来说显示了最好的结果,而其他模型较差,所以我决定使用两阶段方法来构建集成。

我将这个问题视为回归任务,并训练所有模型以优化 RMSE 指标。我尝试将此任务作为分类任务解决并优化 QWK,但结果更差。无论如何,由于我们在训练数据中有离散标签,有可能找到一种舍入预测的方法从而提高性能。我做了一些后处理实验,寻找一些阈值来舍入我的预测,但结果不稳定,所以我决定不在最终解决方案中使用它。

我还尝试了不同的正则化以实现更平衡的特征重要性,但这也没有带来任何改进。

4.2. Catboost

最好的单模型。参数是手动调整的。

catboost_params = {
    'iterations': 30000,
    'learning_rate': 0.01,
    'depth': 10,
    'early_stopping_rounds': 200,
    
    'loss_function': 'RMSE',
    'eval_metric': 'RMSE',

    'task_type': 'GPU',
    'verbose': 1000,
    'thread_count': 14,
    
    'use_best_model': True,
    'random_seed': 0xFACED,
}

4.3. 两阶段模型

LGBM, XGB 和 DNN 模型在本次竞赛中 consistently 较差,所以我决定使用 catboost OOF 预测作为这些模型的另一个特征。

1) 首先,我需要正确地收集那些 OOF 预测。使用简单的 5 折模型分割输出不起作用并导致泄漏,所以我使用了嵌套 CV Catboost 模型 (5*5 折)。这样做的原因在 这个讨论这个 notebook by @martinapreusse 中描述得很好。
2) 然后我训练了Catboost OOF 和 LGBM OOF模型。Catboost 的参数与上面相同,LGBM 模型的参数如下所示,我没有太多调整它们,这里有很大的改进空间。

lgbm_params = {
    'objective': 'regression',
    'min_child_samples': 24,
    'num_iterations': 30000,
    'learning_rate': 0.01,
    'extra_trees': True,
    'reg_lambda': 0.8,
    'reg_alpha': 0.1,
    'num_leaves': 64,
    'metric': 'rmse',
    'device': 'cpu',
    'max_depth': 9,
    'max_bin': 128,
    'verbose': -1,
    'seed': 42
}

3) 接下来我训练了另一个Catboost模型,使用 OOF 预测,但这次不是作为特征,而是作为基线初始化。示例可以在 这里 找到。
4) 最后,我训练了一个DNN 模型。我尝试了很多不同的架构变体,但最终使用了一个带有修改的经典 MLP:

    1. 嵌入层 (128) 用于分类特征。
    2. 分位数变换器用于数值特征。
    3. OOF 特征原样使用。
    4. Concat -> MLP (Dropout=0.9, Hardswish, input -> 2048 -> 1024 -> 512 -> 256 -> 128 -> 1).
    5. 使用 MADGRAD 优化器和 RMSE loss 进行优化。

4.4. 其他模型

除此之外,我还尝试训练其他元模型来预测游戏次数、 corner cases 和平局,但这并没有带来任何显著改进。XGBoost 模型也无效。

4.5. 集成

在测试了各种集成方法后,我只是选择了加权集成,正权重由 scipy minimize 函数基于 CV 分数选择。

5. 匹配分布

从竞赛开始,我就注意到至少训练集和公共测试集有不同的分布。这被 LB 和 CV 分数之间的巨大差距以及其他参与者的 LB 探测间接证实,例如在 这个讨论 by @tomooinubushi。我也在公共 notebook 中看到很多数据 shifting 和 scaling。我决定在本次竞赛中专注于我的 CV,并在竞赛接近结束时再回到这个问题。

最后,我有一个具有良好 CV 分数的稳定集成,所以我决定将其作为我的稳定提交而不进行任何后处理,同时保留另一个用于更风险的方法。我拿了一个具有最佳公共分数的 notebook 并进行了一些探测。我尝试了不同的 clipping, shifting 和 scaling 技术,以最好地匹配公共分布。

我能够使用以下调整将我的最佳公共分数从 0.421 提高到 0.417:

predictions = np.clip(predictions * 1.175, -1, 1)

有趣的是,将相同的调整应用于我的最佳 CV 集成,公共分数仅从 0.424 提高到 0.423。

基本上,这背后的想法是私有测试数据集要么有完全新的游戏,要么有与公共测试数据集中的游戏相交(或相似)的游戏。我试图涵盖这两种情况。

6. 最终结果

与公共 LB 最对齐的提交在私有 LB 上也得分最高。

模型 CV 公共 LB 私有 LB
Catboost Classic 0.3933 0.426 0.432
Catboost Nested 0.3946 0.426 0.433
Catboost with OOF 0.3978 0.425 0.431
Catboost with OOF as baseline 0.3969 0.426 0.433
LGBM with OOF 0.3996 0.426 0.432
DNN with OOF 0.4017 0.426 0.432
最佳 CV 集成 0.3905 0.424 0.430
最佳公共 (Catboost + 分布匹配) 0.3995 0.417 0.422

7. 哪些可能有益,哪些无用

  • 有意义的 LB 探测 - 在这里可能有用,但我只是在竞赛接近结束时才开始做,但我看到很多人有数百次提交,期待找出他们的潜在方法。
  • 用于规则解析的 LLM - 似乎没有足够的唯一规则来训练语言模型,所以我没有做,但再次期待找出是否有人成功做到了。
  • 测试数据上的伪标签 - 如果测试数据集不是分批给出的,这里可能是可行且有价值的。
  • 更好地处理随机性 - 显然有确定性游戏和基于概率的游戏(例如带有骰子的),更好地处理第二种类型的游戏并更聪明地匹配可能的分布可能是有益的。
  • 经典数据生成 - 需要大量计算,但在这里必须是有价值的。

感谢阅读,祝下次竞赛好运,到时候见!

编辑:拼写错误

同比赛其他方案