返回列表

3rd Place Solution

562. Predict Student Performance from Game Play | predict-student-performance-from-game-play

开始: 2023-02-06 结束: 2023-06-28 学习效果预测 数据算法赛

第三名解决方案

作者:Patrick Yam (Grandmaster),团队成员:Anthony Chiu (Grandmaster)、yu (Master)、yyykrk (Master)
发布日期:2023-06-29
比赛排名:第3名

非常感谢比赛主办方和我的队友(@kingychiu@tangtunyu@yyykrk)。我很高兴 @kingychiu 和我将成为 Grandmaster,@tangtunyu 离成为 Master 更近一步,@yyykrk 也将在本次比赛后获得他的第二枚金牌!

这里我们将解释我们的整体解决方案,@yyykrk 也提供了他所负责部分的额外说明:https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/420274

分类任务公式化

在本次比赛中,我们需要为每个会话预测18个值。每个会话包含3个级别组。有多种建模方式:

  1. 18个二元分类器
  2. 3个级别组分类器,每个可以是:
    a. 多标签分类器,预测级别组内的所有值
    b. 二元分类器,将"问题索引"作为级别组内的特征
  3. 1个分类器,可以是:
    a. 多标签分类器,预测会话内的18个值
    b. 二元分类器,将"问题索引"作为会话内的特征

对于梯度提升树模型,方法2b > 方法3b > 方法1。方法2a和3a被忽略,因为使用梯度提升树模型训练多标签任务要慢得多。

对于神经网络模型,我们专注于方法2a和3a,因为:

  • 树模型不太适合处理这两种方法
  • 多标签学习更有意义,因为本次比赛的评分标准是F1分数(一些讨论认为我们不应该针对单个问题进行优化)
  • 多标签神经网络模型训练和推理速度更快

从原始数据生成的额外数据集

我们从原始数据创建了额外的数据集,包含11343个完整会话。该数据集使梯度提升树模型的CV分数提升了约+0.001~2,但对公开和私密分数影响不大,且效果有好有坏。然而,它对神经网络模型非常有效,我们在CV和公开分数上都看到了+0.002的改进。

验证策略

我们使用基于session_id的5折GroupKFold,确保验证集中不会出现已见过的会话。同时,我们没有在验证集中包含额外数据。

梯度提升树

单问题分类器由@yyykrk负责,级别组分类器和All-in-1分类器由@tangtunyu@kingychiu负责。这就是为什么在数据预处理步骤中存在一些不一致,例如按索引排序与按时间排序。

单问题分类器

我们为每个级别组创建特征,并按索引排序。这些特征和排序方法与其他模型不同。

# Code in polars
df1 = df.filter(pl.col("level_group") == "0-4")
df2 = df.filter(pl.col("level_group") == "5-12")
df3 = df.filter(pl.col("level_group") == "13-22")

df1 = df1.sort(pl.col("session_id"), pl.col("index"))
df2 = df2.sort(pl.col("session_id"), pl.col("index"))
df3 = df3.sort(pl.col("session_id"), pl.col("index"))
特征数量:
  • 级别组 0-4:1,000个特征
  • 级别组 5-12:2,000个特征
  • 级别组 13-22:2,400个特征
特征选择

我们尝试使用out-of-folds进行特征选择,但公开分数往往会下降,因此我们最终提交中未对此模型进行特征选择。

典型特征
  • 前一个级别组与当前级别组之间的经过时间
  • 标志事件之间的经过时间和索引计数
  • 先前问题的预测概率
  • 最近M个(M=1,2,...)预测概率的总和

标志事件是游戏过程中必须通过的事件。我们参考jo_wilder的源代码、游戏过程以及获得满分用户的日志数据来提取这些事件。

单模型最佳成绩(5折XGBoost)

CV: 0.702,公开榜: 0.700,私密榜: 0.701

级别组分类器

为了使级别组模型能够利用先前级别组的信息,我们首先按以下方式分割训练数据:

# Code in polars
df1 = df.filter(pl.col("level_group") == "0-4")
df2 = df.filter((pl.col("level_group") == "0-4") | (pl.col("level_group") == "5-12"))
df3 = df

df1 = df1.sort(pl.col("session_id"), pl.col("elapsed_time"))
df2 = df2.sort(pl.col("session_id"), pl.col("elapsed_time"))
df3 = df3.sort(pl.col("session_id"), pl.col("elapsed_time"))

特征工程完成后进行特征选择。

特征工程
  • 房间距离和屏幕距离
(pl.col("room_coor_x") - pl.col("room_coor_x").shift(1)).over(["session_id"]).pow(2).alias("room_coor_x_dis"),
(pl.col("room_coor_y") - pl.col("room_coor_y").shift(1)).over(["session_id"]).pow(2).alias("room_coor_y_dis"),    
(pl.col("screen_coor_x") - pl.col("screen_coor_x").shift(1)).over(["session_id"]).pow(2).alias("screen_coor_x_dis"),
(pl.col("screen_coor_y") - pl.col("screen_coor_y").shift(1)).over(["session_id"]).pow(2).alias("screen_coor_y_dis")
  • 最终场景、检查点和答题时间

通过手动玩游戏,我们知道学生只在每个级别结束时参加测验。他们用于完成答题会话的时间越短,正确回答这些问题的概率就越高。通过以下特征捕获:

pl.col("index").filter((pl.col("fqid") == "chap2_finale_c") | (pl.col("event_name") == "checkpoint")).apply(lambda s: s.max() - s.min()).alias("chap2_answer_indexCount"),
(pl.col("elapsed_time").filter(pl.col("level_group") == "5-12").min() - pl.col("elapsed_time").filter(pl.col("level_group") == "0-4").max()).alias("chap1_answer_time")
  • 不必要的移动

根据游戏经验,我们认为很多人多次玩过游戏。如果能有特征来识别这些玩家就太好了。

unnecessary_data_values = {}
for q in range(23):
    unnecessary_data_values[q] = {}
    for feature_type in ['text', 'fqid', 'text_fqid']:
        unnecessary_data_values[q][feature_type] = []
        unique_values = list(df.filter((pl.col("level") == q))[feature_type].unique())
        
        for val in unique_values:
            if df.filter((pl.col("level") == q) & (pl.col(feature_type) == val))['session_id'].n_unique() < 23000:
                unused_data_values[q][feature_type].append(val)

如果是第一次玩游戏,他们可能会有很多不必要的移动。然后我们计算他们花费在这些移动上的时间/操作次数:

for col in ['elapsed_time_diff']:
    
    aggs.extend([
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("text").is_in(unused_data_values[level]["text"]))).count().alias(f"level_{level}_unused_text_{col}_counts") for level in level_feature],
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("fqid").is_in(unused_data_values[level]["fqid"]))).count().alias(f"level_{level}_unused_fqid_{col}_counts") for level in level_feature],
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("text_fqid").is_in(unused_data_values[level]["text_fqid"]))).count().alias(f"level_{level}_unused_text_fqid_{col}_counts") for level in level_feature],
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("text").is_in(unused_data_values[level]["text"]))).sum().alias(f"level_{level}_unused_text_{col}_sum") for level in level_feature],
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("fqid").is_in(unused_data_values[level]["fqid"]))).sum().alias(f"level_{level}_unused_fqid_{col}_sum") for level in level_feature],
         *[pl.col(col).filter((pl.col("level") == level) & (pl.col("text_fqid").is_in(unused_data_values[level]["text_fqid"]))).sum().alias(f"level_{level}_unused_text_fqid_{col}_sum") for level in level_feature],
    ])
  • 任务花费的时间/操作次数

另一类用于过滤经验丰富玩家的特征是测量他们在每个级别组的测验前完成任务的速度。例如游戏的首要任务是找到笔记本,我们的假设是:经验丰富的玩家会用更少的时间和操作完成它,并且他们有更高的概率正确回答测验问题。

第一章的两个例子:

pl.col("elapsed_time").filter((pl.col("text") == "Now where did I put my notebook?") | (pl.col("text") == "Found it!")).apply(lambda s: s.max() - s.min()).alias("find_notebook_duration"),
pl.col("index").filter((pl.col("text") == "Now where did I put my notebook?") | (pl.col("text") == "Found it!")).apply(lambda s: s.max() - s.min()).alias("find_notebook_indexCount"),
pl.col("elapsed_time").filter((pl.col("text") == "Found it!") | (pl.col("text") == "Let's get started. The Wisconsin Wonders exhibit opens tomorrow!")).apply(lambda s: s.max() - s.min()).alias("go_upstairs_duration"),
pl.col("index").filter((pl.col("text") == "Found it!") | (pl.col("text") == "Let's get started. The Wisconsin Wonders exhibit opens tomorrow!")).apply(lambda s: s.max() - s.min()).alias("go_upstairs_events")
特征选择

选择基于Catboost特征重要性除以标签打乱后的Catboost特征重要性(Null Importances的理念)。步骤如下:

  1. 使用整个训练数据计算Catboost特征重要性
  2. 打乱训练数据标签,重复N次获取随机重要性
  3. 通过基础重要性除以平均随机重要性计算最终重要性
  4. 使用`gp_minimize`基于5折交叉验证搜索最佳特征数量

最终,每个级别组分别有233、647、693个特征。

Catboost 5折CV out of fold F1: 0.7019
Xgboost 5折CV out of fold F1: 0.7021

18合1分类器

为了训练18个问题的合1分类器,我们将上述3个数据框连接成一个大型数据框:

# Code in pandas
all_df = pd.concat([
    df1[FEATURES1 + ["q"]],
    df2[FEATURES2 + ["q"]],
    df3[FEATURES3 + ["q"]],
], axis=0)

这种 mega 连接产生了许多空值,因为某些特征只存在于特定级别组。因此构建18合1分类器的特征时:首先重用级别组分类器的特征选择结果,然后在mega连接后重新运行特征选择。

Catboost 5折CV out of fold F1: 0.7002
Xgboost 5折CV out of fold F1: 0.7007

神经网络

模型:Transformer + LSTM

我们的神经网络管道基于此公开notebook:https://www.kaggle.com/code/abaojiang/lb-0-694-event-aware-tconv-with-only-4-features

数值输入:
  • np.log1p( elapsed_time_diff )
类别输入:
  • event_comb, room_fqid, page, text_fqid, level
Transformer部分(3个变体):

神经网络架构图

Transformer后LSTM:
  • 1个双向LSTM + 1个LSTM层
池化方法:
  • sum、std、max、last的拼接
训练方法:
  1. 如前所述,我们使用多标签训练模型,有两种变体:
    a. 每个级别组一个模型
    b. 所有级别组使用同一模型
  2. 我们发现结合不同设置训练的模型可以提升CV和公开榜成绩
  3. 使用额外数据训练,提升了神经网络的CV和公开榜成绩

仅神经网络最佳集成(5个不同设置的神经网络):

  • CV: 0.7028,公开榜: 0.701,私密榜: 0.704
  • 结果显示神经网络在公开榜上表现不佳,但在私密榜上表现良好

提交选择

我们选择了LB分数最高的提交、CV分数最高的提交,以及目标为合理高CV和高方法/模型多样性的提交。

我们最佳选择的提交是以下模型的集成:

  • 一个级别组Catboost、一个18合1 Catboost、两个18合1 Xgboost和三个神经网络
  • 我们选择的神经网络为:Type A按级别组、Type B按级别组、Type C所有级别组。这种组合为最终集成提供了良好的多样性
  • 我们使用两个独立的逻辑回归模型分别在out-of-fold数据上集成GBT模型和NN模型,然后按GBT:NN = 6:4的比例组合
  • 在组合GBT和NN结果时手动加权的原因是神经网络在公开榜上表现不佳,因此我们没有足够信心给予NN模型过高权重

最佳选择集成:

  • CV: 0.7046,公开榜: 0.706,私密榜: 0.704

我们未选择的0.705分提交

我们有三个私密分数为0.705的提交未被选中。我们最佳选择的提交在所有提交中私密分数排名第13位。

在这些0.705分的提交中:

  • 单问题GBT模型 + 级别组GBT模型给出了0.705的私密分数,但集成CV分数不高
  • 级别组GBT + 神经网络模型配合逻辑回归集成给出了0.705的私密分数,但公开分数不高

观察结果:

  1. 神经网络模型在CV和私密榜上表现良好,但在公开榜上表现非常差,而GBT模型却非常契合公开榜,这很奇怪...
  2. 单问题GBT模型的集成CV较低,但在公开和私密榜上表现尚可
同比赛其他方案