683. NFL Big Data Bowl 2026 - Prediction | nfl-big-data-bowl-2026-prediction
这是我参加过的最有趣的比赛之一。我在比赛过半时才加入,只剩下一个月的时间来解决这个问题,而且我对 ST-transformers 不是很熟悉。这是一个真正的挑战,但也令人无比兴奋。多亏了社区,我学到了很多。
我就直入主题,总结一下我的解决方案。
(我和大多数人一样,从公开 notebook 开始,所以我只会强调我做的不同之处以及我发现有帮助的地方。)
我应用了从论文 A Spatio-temporal Transformer for 3D Human Motion Prediction https://arxiv.org/abs/2004.08692 中学到的类似结构,并稍作调整以适应本次比赛的数据结构。
关键模型参数如下:
HIDDEN_DIM = 64
NUM_HEADS_TEMPORAL = 8
NUM_HEADS_SPATIAL = 8
NUM_LAYERS = 4
DROPOUT = 0.05
DIM_FEEDFORWARD = HIDDEN_DIM*4
我还在空间注意力输出之上添加了一些边缘偏置 (edge biases) (sameteam_ij, distance_ij, closing_speed_ij),这似乎有帮助,但作用不大。
'x', 'y', 'o', 'dir',
'player_height_feet', 'player_weight',
's', 'velocity_x', 'velocity_y', 'accel_x', 'accel_y',
'ball_land_x', 'ball_land_y',
'dist_from_right', 'distance_to_goal', 'play_direction_right',
'ball_dx', 'ball_dy', 'distance_to_ball', 'ball_dir_x', 'ball_dir_y', 'ball_closing_speed',
'speed_change', 'dir_change', 'vel_x_change', 'vel_y_change',
'passer_dx', 'passer_dy', 'passer_distance', 'vel_to_passer_alignment',
'receiver_dx', 'receiver_dy', 'receiver_distance', 'vel_to_receiver_alignment',
'post_pass', # 0=传球前,1=传球后
'frame_to_pass', # 传球前为负值 (倒计时到 0) - 用于虚拟传球前后切割增强
'input_frame_count', 'output_frame_count', # 总帧数
我注意到,超过某一点后,添加更多特征不再能提高性能。最终,我移除了大多数花哨的特征,主要保留了与基本相对距离和速度相关的特征。我还保留了几个与帧计数及其与传球点关系相关的特征。由于我正在做类似于滑动窗口的数据增强(如下所述),我希望模型能够理解时间帧的整体上下文。
此外,player_role 和 player_position 被嵌入为 8 维特征,并添加到上述特征中。
我只使用了本次比赛数据部分中提供的 2023 年数据,排除不良 play 后仅包含 14108 个 plays。需要生成更多数据来帮助模型泛化。
原始数据:
增强方法 1(将传球切割点移至传球前):
增强方法 2(将传球切割点移至传球后):
增强方法 1 和 2 一起总共为我提供了大约 200k 个额外样本。
增强方法 1 更重要,因为它将所有角色的球员都加入到了输出目标中,这对于使模型更稳健非常有帮助。
增强后的数据随后与原始数据一起用于训练,但损失权重较低,为 0.5。
我从社区学到了 TemporalHuber 损失函数,这非常有帮助。非常感谢!我稍微调整了一下,使训练更加稳定:
err = pred - target
abs_err = torch.abs(err)
huber = torch.where(
abs_err <= self.delta,
0.5 * err * err,
self.delta * (abs_err - 0.5 * self.delta)
)
if self.time_decay > 0:
L = pred.size(-1)
t = torch.arange(L, device=pred.device, dtype=pred.dtype)
w = torch.exp(-self.time_decay * t).view(1, 1, L)
else:
w = 1.0
masked_weighted_huber = huber * mask * w # ◀️
mask_sum = mask.sum(dim=(1, 2)) + 1e-8 # ◀️
main_loss = masked_weighted_huber.sum(dim=(1, 2)) / mask_sum # ◀️
我按周分割数据。共有 18 周的数据,在此基础上我创建了 6 折:
FOLDS = [(3,7,18),(1,10,17),(4,5,8),(2,11,14),(6,13,16),(9,12,15)]
每折我使用两个随机种子,所以每次训练运行给我的是 12 个模型的平均 RMSE 分数。尽管如此,我的 CV 和 LB 并不完全一致。在 0.001 的水平上,CV 经常下降而 LB 上升。只有当至少有 0.01 的明显改善时,我才信任 CV。
我的直觉是更信任 LB,因为最好的 CV 分数通常出现在几十个 epoch 之后,这感觉更像是过拟合或随机的幸运命中。
最终提交包含 across 4 次训练试验 的 48 个模型,每次试验包含来自不同折和种子的 12 个模型,使用简单加权平均组合。4 次试验主要在输出 horizon 上有所不同。