562. Predict Student Performance from Game Play | predict-student-performance-from-game-play
非常感谢比赛主办方和我的队友(@kingychiu、@tangtunyu 和 @yyykrk)。我很高兴 @kingychiu 和我将成为 Grandmaster,@tangtunyu 离成为 Master 更近一步,@yyykrk 也将在本次比赛后获得他的第二枚金牌!
这里我们将解释我们的整体解决方案,@yyykrk 也提供了他所负责部分的额外说明:https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/420274
在本次比赛中,我们需要为每个会话预测18个值。每个会话包含3个级别组。有多种建模方式:
对于梯度提升树模型,方法2b > 方法3b > 方法1。方法2a和3a被忽略,因为使用梯度提升树模型训练多标签任务要慢得多。
对于神经网络模型,我们专注于方法2a和3a,因为:
我们从原始数据创建了额外的数据集,包含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"))
我们尝试使用out-of-folds进行特征选择,但公开分数往往会下降,因此我们最终提交中未对此模型进行特征选择。
标志事件是游戏过程中必须通过的事件。我们参考jo_wilder的源代码、游戏过程以及获得满分用户的日志数据来提取这些事件。
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的理念)。步骤如下:
最终,每个级别组分别有233、647、693个特征。
Catboost 5折CV out of fold F1: 0.7019
Xgboost 5折CV out of fold F1: 0.7021
为了训练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

我们选择了LB分数最高的提交、CV分数最高的提交,以及目标为合理高CV和高方法/模型多样性的提交。
我们最佳选择的提交是以下模型的集成:
最佳选择集成:
我们有三个私密分数为0.705的提交未被选中。我们最佳选择的提交在所有提交中私密分数排名第13位。
在这些0.705分的提交中: