562. Predict Student Performance from Game Play | predict-student-performance-from-game-play
难以置信能写下这篇解决方案!
按照惯例,我们首先感谢主办方和Kaggle。这是特别的感谢,因为在这个竞赛中,我们与您建立了一种特殊的联系——我们通过报告数据泄露问题给您增加了额外的工作量。毫无疑问,您已经尽力做到了最好。您有理由活跃在这个社区并信任它,因为您本身就是社区的一部分。请照顾好这个能够通过分享共同创造如此多价值的社区。和所有人一样,您也犯过错误,我们希望您能从中学到东西。
我们还想感谢所有的Kagglers。我们热爱并感激能够成为这个群体/社区的一员。感谢大家的分享和集体学习的经历。
我们的解决方案本质上是XGBoost和神经网络模型的融合。两者都严重依赖"持续时间"这一强大的杠杆。对于GBDT,时间以不同方式聚合并与计数结合;而对于神经网络,时间通过基于1D卷积的自定义TimeEmbedding块进行转换,产生与用户事件表示结合的表示。
鲁棒性和效率是我们工作的基础。XGBoost模型在10个5折交叉验证的bag上进行验证,只有当这些bag的CV平均值的提升大于我们量化的噪声水平时,才会加入新特征;而对于神经网络,我们采用了多数/共识策略,即只有当5个折中的4个都得到改进时才验证选择。通过轻量级神经网络和TF Lite加速,我们获得了效率榜第3名。
在发布这份报告后,我们决定开源我们的代码:https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/420332
它包含以下几个部分:如何训练XGBoost模型、如何预训练和训练神经网络模型,以及获胜所使用的推理代码。
查看首批发布的数据发现,会话数量并不多,因此序列也不多。而且这些序列都很长,这对于深度学习方法并不理想。
通过探索Field Day Lab的研究,我们了解到Jo Wilder应用是为帮助学习阅读而构建的,已有超过11,500名学习者玩过这款游戏。
这两个想法促使我们寻找更大的数据集。一次Google搜索和3次点击后,我们找到了开放数据门户(https://fielddaylab.wisc.edu/opengamedata/),其中包含大量会话数据。1小时和3个bash命令后,我们确认训练集部分来自开放数据。于是我们花了一周时间构建了一个能完美提取训练集98%会话的管道,最后2%只有微小错误。我们的数据甚至比竞赛数据更好,因为我们早于主办方确认就发现:对于玩了两局游戏的会话,目标存在偏差(如果在两局游戏中有一局错误则为0,而我们需要预测的是第一局游戏的回答)。修复这些目标似乎能带来显著提升,最多可达+0.002。
我们又花了一周时间构建了一个GBDT/XGBoost基线,考虑到CV分数,它本可以进入前10名,使用补充数据(约20,000个会话)在当时带来了+0.003/0.004的提升。由于我们在本地模拟了API(见下文),我们使用部分训练会话进行推理,发现得分是0.718。我们希望LB会话不属于开放数据门户,但我们的第一次提交,LB 0.708,立即向我们表明我们重建了大约一半的数据,特别是公共LB中的目标,因为0.708 = (0.698 + 0.718) / 2。主办方和Kaggle立即收到了我们的通知。你知道接下来发生了什么(https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/415820)。
在LB数据发布后,我们测量发现我们在~11,500个LB数据会话中完美重建了约7000个。
我们花了第一个月探索数据,直到我们相当了解它。例如,我们甚至重建了可能是学校的会话(一个IP会话上进行多次游戏),提取了每个至少有一个回答的会话,...
更新后,我们进行了第一次提交,得分0.72。这令人震惊,因为这意味着仍有泄露的数据存在。几天后,我们注意到开放数据与我们一个月前发现的状态不完全相似,缺少了一个文件。所以我们再次联系主办方和Kaggle,给他们更多工作。
这个过程/工作让我们完美理解了数据模型(自游戏首次发布以来已经改变)。这也让我们深入理解了数据本身。
注意,我们只使用了那些对前两个level groups的所有问题都有回答的会话。1)这与我们想要预测的会话(从游戏开始到结束)更加一致;2)这种方法在保持性能的同时减少了训练时间。
我们的数据集由37,323个完整会话(23,562个竞赛数据+日志)组成,总共66,376个会话。
补充数据(我们一个月前完全添加)持续为我们带来CV +0.002的提升。
我们的解决方案主要是GBDT + NN模型的集成。
我们认为我们解决方案鲁棒性的主要原因是我们在决策制定上只依赖于CV,没有在LB上做出任何选择。
探测表明,私有测试集由API提供的前1450/1500个会话组成,这是一个小集合。在我们的实验中,5,000个会话是保证CV/LB稳定对齐的最小数量,少于2,000个会话的集合非常嘈杂,因此鲁棒性是唯一出路。
我们只添加了那些能确定提升CV的特征。删除你相信的特征并不容易,但这是必要的,因为科学不是信仰问题。有几种方法可以做到这一点:例如监控CV中的所有折(只接受多数或共识),监控多个bag(验证集的组成以避免过拟合验证),...
对于GBDT方法,我们主要在10个bag的平均值上进行验证(我们将bag定义为折的组合)。由于我们估计噪声约为~0.0003,因此只考虑大于该噪声的提升。对于NN,由于需要更快迭代,我们只使用单个bag,并且只纳入总体提升大于0.0003且至少3或4个(超过5个)折有改进的情况。
我们做了很多实验来寻找按问题设置阈值,发现这种方法不如单一阈值鲁棒。尽管我们获得的最高LB分数是使用按问题阈值,但我们主要使用0.625作为全局阈值。
由于数据的结构化/表格性质,我们用XGBoost构建了基线。特征工程过程对于理解什么是可预测的以及因果关系(即特征和决策标准如何促成正确预测)非常有趣。
一般来说,我们通过3种方式构建特征:业务知识、我们玩游戏时的直觉以及对数据的细致探索。
业务知识指的是利用专家知识。阅读构建这款游戏的科研人员论文,可以让我们超越使用层面理解游戏。例如,Jo Wilder是为提高玩家阅读技能而构建的,这意味着文本持续时间应该是重要的,这些是杀手级特征。
由于CPU限制以及为了学习它,我们完全使用Polars。
我们的特征(每个level_group分别为663、1993、3734个)主要是不同聚合方式的持续时间和计数:在某个级别的时间、在某个房间的时间、阅读文本的时间、以某种方式交互的时间(事件类型)、每个level_group的事件数量、每种类型的事件数量、每个房间或级别的每种类型事件数量,...
我们还构建了一些笔记本专用特征:某个级别上笔记本不同类型事件的数量,...
尽管我们努力,但无法从坐标中提取有用信息,只有极少数此类特征是某些活动(例如日志交互)事件的均值和标准差。
我们认为,注入前一个level groups预测的目标会压缩信号,意味着信息丢失,因此对于每个会话,我们使用从游戏/会话开始的所有交互,这在该选择时带来了+0.002的提升。
在API需要对数据进行排序后,我们注意到在原始顺序和索引顺序上都训练的模型,但在索引顺序上验证(推理顺序)提升了我们的分数,这带来了更多样化的模型,有助于提高稳定性和鲁棒性。验证集的组成也是如此:使用基于竞赛数据和提取数据的多个bag改善了我们的分数。我们较晚才发现,将折数从5增加到10也可以被利用。
GBDT代码允许通过简单变量参数从XGBoost切换到LightGBM和CatBoost,尽管它们表现良好(比XGBoost低约0.001),但并未给集成带来提升,因此我们坚持只使用XGBoost。
我们在特征选择方面做了很多实验,但未能构建出稳定的策略。因此,我们采用自底向上的方法,仔细选择每组特征,而不是采用自上而下删除无用特征的方法。
我们的XGBoost模型CV得分约为0.7025 ±0.0003,融合5个模型(我们保留的得分正确的XGBoost)LB得分为0.704。
在用梯度提升取得良好成绩并充分理解数据后,我们将重点转向深度学习。
第一次尝试使用Transformer,结果令人失望:CV 0.685,每折2小时(据我们所记)。Transformer计算量非常大。相关资源:https://arxiv.org/pdf/1912.09363.pdf、https://arxiv.org/pdf/2001.08317.pdf、https://arxiv.org/pdf/1711.03905.pdf、https://arxiv.org/pdf/1907.00235.pdf,...
然后我们尝试了Conv1D。在一天内,我们得到了一个非常简单的模型,得分与Transformer相当,但速度快了10倍,允许更快迭代。所以我们推进了这一方法,并能无缝扩展到超出我们预期的程度。
很难分享实现最终解决方案所需的数十或数百次实验,该方案基于简单架构和稍微复杂的训练流程。
我们基于"如何在深度学习中对时间建模?"这一问题浏览了文献。
这项研究让我们想到了时间感知事件(即https://proceedings.mlr.press/v126/zhang20c/zhang20c.pdf)并回归到WaveNet(https://arxiv.org/pdf/1609.03499.pdf),因为它使用Conv1D对长序列进行建模并考虑因果关系。
其他论文也给了我们启发:https://arxiv.org/pdf/1703.04691.pdf在WaveNet论文基础上构建了时间序列,https://idus.us.es/bitstream/handle/11441/114701/Short-Term%20Load%20Forecasting%20Using%20Encoder-Decoder%20WaveNet.pdf?sequence=1&isAllowed=y也在WaveNet基础上构建了编码器-解码器。
我们还必须提到@abaojiang分享的出色工作(https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/398565和https://www.kaggle.com/code/abaojiang/lb-0-694-tconv-with-4-features-training-part),它启发了我们的研究,可能成功地影响了我们的方向。
让我们重点关注效率提名的模型,它也是我们最终集成的模型之一,其性能与具有更多特征的模型几乎相同。
5个特征作为输入:duration、text_fqid、room_fqid、fqid、event_name + name(据我们所记,这是原始数据模型中的事件类型)。这些信息各自被编码为向量表示(d_model = 24)然后合并。4个类别特征输入经典Embedding层,duration输入TimeEmbedding,这是一个自定义块。
开发GBDT解决方案表明,duration至关重要,因此我们投入了大量时间尝试很好地对其进行建模。TimeEmbedding层由4个ConvBlock组成,灵感来自Transformer主块:Conv1D → 跳跃连接 → 层归一化 → Dropout。
class TimeEmbedding(tf.keras.layers.Layer):
def __init__(self, n_blocks, d_model, dropout_rate):
super(TimeEmbedding, self).__init__()
self.conv_blocks = [ConvBlock(d_model, dropout_rate=dropout_rate) for _ in range(n_blocks)]
def call(self, inputs):
x = tf.expand_dims(inputs, axis=-1)
for conv_block in self.conv_blocks:
x = conv_block(x)
return x
class ConvBlock(tf.keras.layers.Layer):
def __init__(self, d_model, dropout_rate):
super(ConvBlock, self).__init__()
self.conv1d = tf.keras.layers.Conv1D(d_model, kernel_size=5, padding='same', activation='gelu')
self.layer_norm = tf.keras.layers.LayerNormalization()
self.dropout = tf.keras.layers.Dropout(rate=dropout_rate)
def call(self, inputs):
x = self.conv1d(inputs)
x = x + inputs
x = self.layer_norm(x)
outputs = self.dropout(x)
return outputs
如前所述,构建这些表示的目标是对时间感知事件进行建模。我们将类别特征视为事件,因为它们代表用户与游戏业务实体的交互。然后我们尝试加入duration使其具备时间感知能力。我们的主要直觉被证明是最好的:这是一种基于运算优先级的简单解决方案,表示duration应在事件关联之前与每个事件关联:duration * event_1 + duration * event_2 + ...,可因式分解为duration * (event_1 + event_2 + ...)。
class ConvNet(tf.keras.Model):
def __init__(self, input_dims, n_outputs, d_model, n_blocks=4, name=None):
super(ConvNet, self).__init__(name=name)
self.input_dims = input_dims
self.n_outputs = n_outputs
self.d_model = d_model
self.n_blocks = n_blocks
self.event_embedding = tf.keras.layers.Embedding(input_dims['event_name_name'], d_model, mask_zero=True)
self.room_embedding = tf.keras.layers.Embedding(input_dims['room_fqid'], d_model, mask_zero=True)
self.text_embedding = tf.keras.layers.Embedding(input_dims['text'], d_model, mask_zero=True)
self.fqid_embedding = tf.keras.layers.Embedding(input_dims['fqid'], d_model, mask_zero=True)
self.duration_embedding = TimeEmbedding(n_blocks=n_blocks, d_model=d_model, dropout_rate=0.2)
self.gap = tf.keras.layers.GlobalAveragePooling1D()
def call(self, inputs):
event = self.event_embedding(inputs['event_name_name'])
room = self.room_embedding(inputs['room_fqid'])
text = self.text_embedding(inputs['text'])
fqid = self.fqid_embedding(inputs['fqid'])
duration = self.duration_embedding(inputs['duration'])
x = duration * (event + room + text + fqid)
outputs = self.gap(x)
return outputs
def get_config(self):
config = super().get_config().copy()
config.update({
'input_dims': self.input_dims,
'n_outputs': self.n_outputs,
'd_model': self.d_model,
'n_blocks': self.n_blocks,
'name': self._name,
})
return config
@classmethod
def from_config(cls, config):
return cls(**config)
训练流程并不完全简单明了。
@dongyk发布了很好的示意图,有助于说明下面解释的内容:https://www.kaggle.com/competitions/predict-student-performance-from-game-play/discussion/420217#2332166。
对我们而言,最好的方法包括一种代表level_group事件的骨干网络。
该骨干网络在该level_group的所有可用数据上训练(即完整+不完整会话),并与一个临时的SimpleHead关联,优化BCE损失。
class SimpleHead(tf.keras.Model):
def __init__(self, n_units, n_outputs, name=None):
super(SimpleHead, self).__init__(name=name)
self.ffs = [tf.keras.layers.Dense(units, activation='gelu') for units in n_units]
self.out = tf.keras.layers.Dense(n_outputs, activation='sigmoid')
def call(self, inputs):
x = inputs
for ff in self.ffs:
x = ff(x)
outputs = self.out(x)
return outputs
这种方法可以得分CV 0.70025 ± 0.0005。
3个骨干网络(每个level_group一个)的权重在第二轮训练中被冻结,以加速训练,同时也因为这样更稳定高效。这些骨干网络可以被视为"嵌入器"。
在这第二步中,所有组成解决方案的子模型都在所有完整会话上以端到端方式训练。输入数据是5个特征的3个序列,每个level_group一个。每个"嵌入器"输出一个24维向量表示。这些输出是一个head的输入,其中level_group '0-4'的表示用于预测前3个问题,而level_group '5-12'和'13-22'则连接前一个和当前表示以利用所有信息。
这样做可以优化整体性能,并基于竞赛的F1分数进行监控。这意味着我们使用F1分数作为指标来优化BCE。
我们的获胜提交使用了一个简单的MLP head,但也有一个skip head(例如512 → 512 → 512)。MMoE并没有改进最简单的方法。
这种方法可以得分CV 0.70175 ± 0.0003,与GBDT解决方案相当。
在比赛早期,我们构建了API的模拟器。这样做使我们从未遇到过提交错误。也许保持想法和代码尽可能简单,也是轻松调试的关键。
我们在GBDT和NN上都投入了效率部分的挑战。
对XGBoost使用Treelite使我们能将执行时间减半。
我们的深度学习模型很轻量:端到端模型有400,000个权重,整合了所有部分/子模型。由于我们已经使用过TF Lite,我们知道它可能改变游戏规则。转换模型显著提升了推理时间,且没有任何性能损失(我们不记得确切数字,但在我们的本地推理模拟器上至少快了6倍)。
开始探索剪枝和硬量化表明,性能损失会很明显(生产环境可以接受,但比赛不行),所以我们坚持简单的TF Lite转换。
我们没有利用效率指标中似乎存在的问题。由于我们识别出私有测试会话是API提供的前1450/1500个,我们尝试只预测其他会话来检查使用了哪些时间(公共会话用公共时间,私有会话用私有时间),这样做我们获得了更好的排名,但选择不使用这个技巧。
我们的效率提交是一个NN,在不到5分钟内得分公共LB 0.702和私有LB 0.699。
我们尝试了很多集成方案。最后我们采用简单的50/50平均GBDT/NN:
由于我们的模型轻量,我们能够构建巨大的集成:2 x 4 x 10折XGBoost + 3 x 4 x 5折NN。我们的瓶颈是8GB RAM限制。
获胜提交得分CV 0.705,公共LB 0.705和私有LB 0.705。
我们工作的主要成就是,对于研究人员、学习者和孩子们来说,这是一个很好的解决方案,他们可以从中受益,我们希望它能为更好的学习体验的进步做出贡献。这取决于你们!
感谢你读到这里!
如果你有任何问题,请随时提问,我们将尽力回答。
我们已经录制了给主办方的视频演示,如有需要可提供。请通过私信询问。
以下是我们使用的主要来源,更多来源可在上面的提交详情部分找到。