Formula 1 预测智能:深度时序 Learning-to-Rank 架构
Formula 1 预测智能:深度时序 Learning-to-Rank 架构
数据与完整代码来源:
https://mbd.pub/o/bread/YZaVmJdyZw
为方便核对素材是否完整,可对照以下要点检查摘录内容:
- 数据主题:F1 的哪类数据(比赛结果、圈速、排位赛、车手/车队信息、遥测等)
- 字段含义:包含哪些表或文件,各字段的业务含义
- 数据规模:行数、文件数、表数量等具体数字
- 时间跨度:覆盖哪些赛季或年份区间
- 适用场景:介绍中建议的分析方向或常见用途
如果您暂时拿不到原始介绍,也可以直接告诉我数据集大致包含哪些表和字段(例如「有 races、drivers、results 等表,覆盖 1950–2023 赛季」这类信息),我据此撰写,并确保不出现任何来源平台或链接表述。
Formula 1 比赛结果预测不能被视为标准的静态回归或分类任务。一场 F1 大奖赛是一个孤立的封闭系统竞赛,其完赛名次在本质上是相对的:一名车手的获胜概率严格取决于在同一时刻、同一条赛道上与其竞争的其他 19 名车手的赛车、调校、赛道状况以及失误。
端到端工作流
- 探索性数据分析(EDA)与关系映射: 导入 Ergast/Jolpica 数据表、修正字符串异常值(
\N)以及维度表连接。 - 无泄漏特征工程: 通过
.shift(1)操作严格在比赛开始前计算的滚动窗口指标(近期状态、胜率、可靠性),以及车队内部发车位差值。 - 时序交叉验证方案: 按时间顺序的扩展式切分,防止未来信息泄漏到历史评估中。
- 深度实体嵌入网络(PyTorch): 将多 GPU 稠密向量投影(
driverId、constructorId、circuitId)与连续的遥测/状态指标相结合,并使用nn.DataParallel分布在双 NVIDIA T4 GPU 上。 - 成对排序优化(XGBRanker + Optuna): 按
raceId分组的 Listwise/pairwise 归一化折损累计增益(NDCG)优化。 - 多模型集成与细粒度评估: 使用 listwise NDCG@5 在 2021 年之后的地面效应时代比赛中对归一化混合结果进行评估。
1. 环境配置与多 GPU 设置
我们初始化 Python 环境,设定可复现性种子,并验证双 T4 GPU 上的 CUDA 硬件加速。
import os
import math
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import xgboost as xgb
import optuna
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import ndcg_score
optuna.logging.set_verbosity(optuna.logging.WARNING)
warnings.filterwarnings('ignore')
sns.set_theme(style="darkgrid", palette="deep")
plt.rcParams['figure.figsize'] = (14, 6)
def set_seed(seed=42):
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
set_seed(42)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
num_gpus = torch.cuda.device_count()
print(f"Active Device: {device} | Available GPU Count: {num_gpus}")
for i in range(num_gpus):
print(f" [GPU {i}]: {torch.cuda.get_device_name(i)}")
Active Device: cuda | Available GPU Count: 2
[GPU 0]: Tesla T4
[GPU 1]: Tesla T4
2. 数据导入与异常值修正
Ergast 数据集将空值序列化为字符串字面量(\N)。如果以朴素方式直接读取,pandas 会将这些列保留为 object 数据类型,从而掩盖数值分布并破坏下游统计摘要。在这里,我们加载所有关系表,将 \N 替换为 np.nan,并显式转换数值型和时间型变量。
DATASET_PATH = "/kaggle/input/datasets/binayas/f1-dataset/"
files = {
"races": "races.csv",
"drivers": "drivers.csv",
"constructors": "constructors.csv",
"lap_times": "lap_times.csv",
"status": "status.csv",
"driver_standings": "driver_standings.csv",
"seasons": "seasons.csv",
"pit_stops": "pit_stops.csv",
"sprint_results": "sprint_results.csv",
"constructor_standings": "constructor_standings.csv",
"results": "results.csv",
"circuits": "circuits.csv",
"qualifying": "qualifying.csv"
}
dfs = {}
for name, filename in files.items():
path = os.path.join(DATASET_PATH, filename)
if os.path.exists(path):
df = pd.read_csv(path)
dfs[name] = df.replace(r'^\\N$', np.nan, regex=True)
else:
raise FileNotFoundError(f"Expected file not found: {path}")
# Explicit numeric conversions on results table
numeric_results_cols = [
'number', 'grid', 'position', 'positionOrder', 'points',
'laps', 'milliseconds', 'fastestLap', 'rank', 'fastestLapSpeed'
]
for col in numeric_results_cols:
dfs['results'][col] = pd.to_numeric(dfs['results'][col], errors='coerce')
# Date parsing
dfs['races']['date'] = pd.to_datetime(dfs['races']['date'])
dfs['drivers']['dob'] = pd.to_datetime(dfs['drivers']['dob'])
print("Data loaded and cleaned successfully across all 13 Ergast/Jolpica tables.")
Data loaded and cleaned successfully across all 13 Ergast/Jolpica tables.
3. 关系整合与探索性可视化
我们通过将核心事实表(results)与维度表(races、drivers、constructors 和 status)连接,构建统一的分析基础表(ABT)。列名会事先进行统一协调,以消除静默覆盖冲突(例如在 name 上的匹配冲突)。
随后我们考察:
- 历史上的车手与车队统治力。
- 不同规则时代的单圈速度演进。
- 发车位与完赛名次的相关性(量化排位赛的重要性)。
# Pre-rename dimensional columns to prevent collisions
races_sub = dfs['races'][['raceId', 'year', 'round', 'circuitId', 'name', 'date']].rename(columns={'name': 'race_name'})
drivers_sub = dfs['drivers'][['driverId', 'forename', 'surname', 'dob', 'nationality']].rename(columns={'nationality': 'driver_nationality'})
drivers_sub['driver_name'] = drivers_sub['forename'] + ' ' + drivers_sub['surname']
drivers_sub.drop(columns=['forename', 'surname'], inplace=True)
constructors_sub = dfs['constructors'][['constructorId', 'name', 'nationality']].rename(columns={'name': 'constructor_name', 'nationality': 'constructor_nationality'})
status_sub = dfs['status'][['statusId', 'status']]
# Merge ABT
master_df = dfs['results'].merge(races_sub, on='raceId', how='left')
master_df = master_df.merge(drivers_sub, on='driverId', how='left')
master_df = master_df.merge(constructors_sub, on='constructorId', how='left')
master_df = master_df.merge(status_sub, on='statusId', how='left')
fig, axes = plt.subplots(1, 2, figsize=(20, 6))
wins = master_df[master_df['position'] == 1]
top_drivers = wins['driver_name'].value_counts().head(10)
sns.barplot(x=top_drivers.values, y=top_drivers.index, ax=axes[0], palette='viridis')
axes[0].set_title('All-Time Race Wins by Driver (1950 - Present)', fontweight='bold')
axes[0].set_xlabel('Wins')
top_constructors = wins['constructor_name'].value_counts().head(10)
sns.barplot(x=top_constructors.values, y=top_constructors.index, ax=axes[1], palette='rocket')
axes[1].set_title('All-Time Race Wins by Constructor', fontweight='bold')
axes[1].set_xlabel('Wins')
plt.tight_layout()
plt.show()
# Speed evolution & Grid Conversion
fig, axes = plt.subplots(1, 2, figsize=(20, 6))
speed_df = master_df[(master_df['fastestLapSpeed'] > 120) & (master_df['year'] >= 2004)]
sns.lineplot(data=speed_df, x='year', y='fastestLapSpeed', estimator=np.mean, ax=axes[0], color='navy', marker='o')
axes[0].set_title('Mean Fastest Lap Speed Trend (Regulatory Evolutions)', fontweight='bold')
axes[0].set_ylabel('km/h')
valid_finish = master_df[(master_df['grid'] > 0) & (master_df['positionOrder'] > 0) & (master_df['year'] >= 2000)]
sns.histplot(data=valid_finish, x='grid', y='positionOrder', bins=22, pthresh=0.05, cmap='Blues', cbar=True, ax=axes[1])
axes[1].plot([1, 22], [1, 22], 'r--', label='Direct Grid=Finish Correlation')
axes[1].set_title('Qualifying Grid vs Final Race Position (Post-2000)', fontweight='bold')
axes[1].legend()
plt.tight_layout()
plt.show()


4. 架构评析:为什么传统机器学习方法在 F1 中会失效
在构建特征之前,我们必须系统性地论证为什么标准的机器学习设置在赛车运动中会产生误导性指标:
-
随机交叉验证会导致灾难性泄漏:
- 打乱行顺序会将未来的进展(例如赛季中的空气动力学升级)向过去时间泄漏。在第 18 站比赛上训练的模型在预测第 2 站时会得到人为虚高的准确率。
- 补救措施: 扩展窗口的时序交叉验证。
-
逐点回归忽视了竞争相对性:
- 训练一个 MSE 回归器来预测连续的完赛名次,会将车手孤立地对待。如果一名车手因爆胎损失 2 个名次,其他 19 名车手会动态地各上升一个名次。
- 补救措施: 将任务表述为 Listwise/Pairwise Learning-to-Rank(LTR),通过归一化折损累计增益(NDCG)按
raceId评估相对顺序。
-
队友基准差异:
- 在 F1 中,车手的技术水平与赛车性能相互混淆。唯一无混淆的变量是车手在相同装备下与队友的相对表现。
- 补救措施: 显式构建队友排位赛差值特征。
5. 超细粒度特征工程与泄漏消除
我们使用 .shift(1) 在按时间顺序分组的数据上,严格在当前比赛之前计算滚动历史信号:
driver_recent_form与constructor_recent_form: 比赛前最近 5 场比赛所得分数的滚动均值。driver_win_rate: 比赛日之前历史职业生涯胜率的扩展窗口计算值。constructor_reliability: 历史上无机械故障 DNF 完赛比例的滚动值(由statusId推导得出)。teammate_grid_delta: 车队内部排位赛发车位差值($Grid_{driver} - Grid_{team_mean}$)。circuit_specialist_score: 车手在特定circuitId赛道上的历史平均得分。
# Classify mechanical failure status IDs vs driver accidents
mechanical_status_ids = [5, 6, 7, 8, 9, 10, 21, 22, 23, 24, 25, 26, 36, 38, 40, 44, 51, 60, 71]
dfs['status']['is_mechanical_dnf'] = dfs['status']['statusId'].isin(mechanical_status_ids).astype(int)
# Merge base features
feat_df = dfs['results'].merge(races_sub[['raceId', 'year', 'round', 'circuitId', 'date']], on='raceId', how='left')
feat_df = feat_df.merge(dfs['drivers'][['driverId', 'dob']], on='driverId', how='left')
feat_df = feat_df.merge(dfs['constructors'][['constructorId']], on='constructorId', how='left')
feat_df = feat_df.merge(dfs['status'][['statusId', 'is_mechanical_dnf']], on='statusId', how='left')
# Sort strictly chronologically
feat_df = feat_df.sort_values(by=['date', 'round', 'raceId', 'positionOrder']).reset_index(drop=True)
# 1. Driver age on race day
feat_df['driver_age_years'] = ((feat_df['date'] - feat_df['dob']).dt.days / 365.25).fillna(27.0)
# 2. Driver rolling features (shifted to exclude current event)
d_grp = feat_df.groupby('driverId')
feat_df['driver_career_points'] = d_grp['points'].transform(lambda s: s.shift(1).expanding().sum()).fillna(0)
feat_df['driver_recent_form'] = d_grp['points'].transform(lambda s: s.shift(1).rolling(5, min_periods=1).mean()).fillna(0)
feat_df['driver_win_rate'] = d_grp['positionOrder'].transform(lambda s: (s.shift(1) == 1).expanding().mean()).fillna(0)
# 3. Constructor rolling features
c_grp = feat_df.groupby('constructorId')
feat_df['constructor_recent_form'] = c_grp['points'].transform(lambda s: s.shift(1).rolling(10, min_periods=1).mean()).fillna(0)
feat_df['constructor_reliability'] = c_grp['is_mechanical_dnf'].transform(lambda s: 1 - s.shift(1).expanding().mean()).fillna(0.85)
# 4. Teammate relative benchmark
team_grid_mean = feat_df.groupby(['raceId', 'constructorId'])['grid'].transform('mean')
feat_df['teammate_grid_delta'] = feat_df['grid'] - team_grid_mean
# 5. Circuit historical mastery
dc_grp = feat_df.groupby(['driverId', 'circuitId'])
feat_df['circuit_specialist_score'] = dc_grp['points'].transform(lambda s: s.shift(1).expanding().mean()).fillna(0)
# Target variables
# relevance_score: higher is better for pairwise NDCG ranking (1st = 25, 2nd = 24, etc.)
feat_df['relevance_score'] = feat_df['positionOrder'].apply(lambda pos: max(26 - pos, 0))
feat_df['target_position'] = feat_df['positionOrder'].astype(float)
# Filter to races with valid starting grid positions
feat_df = feat_df[feat_df['grid'] > 0].copy()
print(f"Feature matrix engineered with {feat_df.shape[1]} columns across {feat_df.shape[0]} driver entries.")
feat_df[['raceId', 'year', 'driverId', 'grid', 'driver_recent_form', 'constructor_reliability', 'teammate_grid_delta', 'relevance_score']].tail(3)
Feature matrix engineered with 35 columns across 25886 driver entries.
raceId year driverId grid driver_recent_form \
27521 1181 2026 840 18 0.0
27522 1181 2026 4 21 0.4
27523 1181 2026 844 3 13.8
constructor_reliability teammate_grid_delta relevance_score
27521 0.965649 -1.5 6
27522 0.965779 1.5 5
27523 0.853901 -0.5 4
6. 预处理与时序切分
我们对连续信号进行标准化,并将类别型 ID 映射为零起始索引的 token,用于嵌入投影。
- 训练窗口: $1950 - 2018$ 赛季
- 验证窗口: $2019 - 2021$ 赛季
- 测试窗口: $2022+$ 赛季(地面效应时代)
cat_cols = ['driverId', 'constructorId', 'circuitId']
num_cols = [
'grid', 'driver_age_years', 'driver_career_points', 'driver_recent_form',
'driver_win_rate', 'constructor_recent_form', 'constructor_reliability',
'teammate_grid_delta', 'circuit_specialist_score'
]
encoders = {}
for col in cat_cols:
le = LabelEncoder()
feat_df[col] = le.fit_transform(feat_df[col].astype(str))
encoders[col] = le
train_mask = feat_df['year'] < 2019
val_mask = (feat_df['year'] >= 2019) & (feat_df['year'] <= 2021)
test_mask = feat_df['year'] >= 2022
scaler = StandardScaler()
feat_df.loc[train_mask, num_cols] = scaler.fit_transform(feat_df.loc[train_mask, num_cols])
feat_df.loc[val_mask, num_cols] = scaler.transform(feat_df.loc[val_mask, num_cols])
feat_df.loc[test_mask, num_cols] = scaler.transform(feat_df.loc[test_mask, num_cols])
train_df = feat_df[train_mask].reset_index(drop=True)
val_df = feat_df[val_mask].reset_index(drop=True)
test_df = feat_df[test_mask].reset_index(drop=True)
print(f"Split Shapes | Train: {train_df.shape[0]} | Val: {val_df.shape[0]} | Test: {test_df.shape[0]}")
Split Shapes | Train: 22631 | Val: 1171 | Test: 2084
7. 深度学习:实体嵌入网络(nn.DataParallel)
我们构建一个 PyTorch 神经网络,将高基数的离散标识符(driverId、constructorId、circuitId)投影到学习得到的连续向量空间中。这些稠密嵌入能够捕捉非线性的实体交互(例如车队与赛道之间的协同效应)。
模型被动态地包装在 nn.DataParallel 中,以便将批次张量分布到所有可用 GPU 上(例如双 T4)。
class F1Dataset(Dataset):
def __init__(self, df, cat_features, num_features):
self.cat_data = torch.tensor(df[cat_features].values, dtype=torch.long)
self.num_data = torch.tensor(df[num_features].values, dtype=torch.float32)
self.targets = torch.tensor(df['target_position'].values, dtype=torch.float32).unsqueeze(1)
def __len__(self):
return len(self.targets)
def __getitem__(self, idx):
return self.cat_data[idx], self.num_data[idx], self.targets[idx]
class F1DeepNet(nn.Module):
def __init__(self, embedding_specs, num_features):
super(F1DeepNet, self).__init__()
self.embeddings = nn.ModuleList([
nn.Embedding(num_classes, emb_dim) for num_classes, emb_dim in embedding_specs
])
total_emb_dim = sum([emb_dim for _, emb_dim in embedding_specs])
self.mlp = nn.Sequential(
nn.Linear(total_emb_dim + num_features, 256),
nn.BatchNorm1d(256),
nn.SiLU(),
nn.Dropout(0.3),
nn.Linear(256, 128),
nn.BatchNorm1d(128),
nn.SiLU(),
nn.Dropout(0.2),
nn.Linear(128, 64),
nn.SiLU(),
nn.Linear(64, 1)
)
def forward(self, cat_x, num_x):
embedded = [emb_layer(cat_x[:, i]) for i, emb_layer in enumerate(self.embeddings)]
x_cat = torch.cat(embedded, dim=1)
x = torch.cat([x_cat, num_x], dim=1)
return self.mlp(x)
# Calculate embedding dimensions using the standard rule of thumb: min(50, (num_classes + 1) // 2)
emb_specs = [
(len(encoders[col].classes_), min(50, max(8, len(encoders[col].classes_) // 2)))
for col in cat_cols
]
train_loader = DataLoader(F1Dataset(train_df, cat_cols, num_cols), batch_size=512, shuffle=True, pin_memory=True)
val_loader = DataLoader(F1Dataset(val_df, cat_cols, num_cols), batch_size=512, shuffle=False)
model = F1DeepNet(emb_specs, len(num_cols))
if torch.cuda.device_count() > 1:
print(f"Wrapping F1DeepNet with nn.DataParallel over {torch.cuda.device_count()} GPUs.")
model = nn.DataParallel(model)
model.to(device)
criterion = nn.SmoothL1Loss()
optimizer = optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-4)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=2)
EPOCHS = 18
best_val_loss = float('inf')
for epoch in range(1, EPOCHS + 1):
model.train()
train_loss = 0.0
for cat_x, num_x, y in train_loader:
cat_x, num_x, y = cat_x.to(device), num_x.to(device), y.to(device)
optimizer.zero_grad()
preds = model(cat_x, num_x)
loss = criterion(preds, y)
loss.backward()
optimizer.step()
train_loss += loss.item() * len(y)
train_loss /= len(train_loader.dataset)
model.eval()
val_loss = 0.0
with torch.no_grad():
for cat_x, num_x, y in val_loader:
cat_x, num_x, y = cat_x.to(device), num_x.to(device), y.to(device)
preds = model(cat_x, num_x)
val_loss += criterion(preds, y).item() * len(y)
val_loss /= len(val_loader.dataset)
scheduler.step(val_loss)
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), 'best_f1_deepnet.pth')
if epoch % 3 == 0 or epoch == EPOCHS:
print(f"Epoch {epoch:02d}/{EPOCHS} | Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f}")
Wrapping F1DeepNet with nn.DataParallel over 2 GPUs.
Epoch 03/18 | Train Loss: 4.5113 | Val Loss: 3.3674
Epoch 06/18 | Train Loss: 4.3206 | Val Loss: 3.2110
Epoch 09/18 | Train Loss: 4.1811 | Val Loss: 3.1346
Epoch 12/18 | Train Loss: 4.0878 | Val Loss: 3.1898
Epoch 15/18 | Train Loss: 4.0442 | Val Loss: 3.2106
Epoch 18/18 | Train Loss: 3.9958 | Val Loss: 3.1449
8. 结合 Optuna 的基于树的成对排序(XGBRanker)
梯度提升决策树能够有效处理表格数据中的非线性关系与阈值切分。我们使用配置了 listwise rank:ndcg 目标函数的 XGBRanker。
我们按 raceId(查询组)对车手行进行分组,使排序器在每个具体比赛周末内优化相对顺序。超参数通过 Optuna 在验证窗口上进行系统性探索。
feature_cols = cat_cols + num_cols
X_train, y_train = train_df[feature_cols], train_df['relevance_score']
q_train = train_df.groupby('raceId', sort=False).size().to_numpy()
X_val, y_val = val_df[feature_cols], val_df['relevance_score']
q_val = val_df.groupby('raceId', sort=False).size().to_numpy()
def evaluate_ranker_ndcg(ranker, X, df_eval, k=5):
preds = ranker.predict(X)
eval_copy = df_eval[['raceId', 'relevance_score']].copy()
eval_copy['pred'] = preds
ndcgs = []
for _, grp in eval_copy.groupby('raceId'):
if len(grp) > 1 and grp['relevance_score'].max() > 0:
score = ndcg_score([grp['relevance_score'].values], [grp['pred'].values], k=k)
ndcgs.append(score)
return np.mean(ndcgs) if ndcgs else 0.0
def optuna_objective(trial):
params = {
'objective': 'rank:ndcg',
'tree_method': 'hist',
'device': 'cuda' if torch.cuda.is_available() else 'cpu',
'learning_rate': trial.suggest_float('learning_rate', 0.02, 0.15, log=True),
'max_depth': trial.suggest_int('max_depth', 4, 8),
'subsample': trial.suggest_float('subsample', 0.65, 0.95),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.65, 0.95),
'reg_lambda': trial.suggest_float('reg_lambda', 1e-2, 10.0, log=True),
'n_estimators': 150,
'random_state': 42
}
ranker = xgb.XGBRanker(**params)
ranker.fit(X_train, y_train, group=q_train, verbose=False)
return evaluate_ranker_ndcg(ranker, X_val, val_df, k=5)
study = optuna.create_study(direction='maximize')
study.optimize(optuna_objective, n_trials=12, timeout=240)
print(f"Optuna Hyperparameter Tuning Complete | Best Val NDCG@5: {study.best_value:.4f}")
# Retrain final best ranker
best_xgb_params = study.best_params
best_xgb_params.update({
'objective': 'rank:ndcg',
'tree_method': 'hist',
'device': 'cuda' if torch.cuda.is_available() else 'cpu',
'n_estimators': 250,
'random_state': 42
})
best_ranker = xgb.XGBRanker(**best_xgb_params)
best_ranker.fit(X_train, y_train, group=q_train, verbose=False)
Optuna Hyperparameter Tuning Complete | Best Val NDCG@5: 0.9074
XGBRanker(base_score=None, booster=None, callbacks=None, colsample_bylevel=None,
colsample_bynode=None, colsample_bytree=0.7830507864824088,
device='cuda', early_stopping_rounds=None, enable_categorical=False,
eval_metric=None, feature_types=None, feature_weights=None,
gamma=None, grow_policy=None, importance_type=None,
interaction_constraints=None, learning_rate=0.024147033757187814,
max_bin=None, max_cat_threshold=None, max_cat_to_onehot=None,
max_delta_step=None, max_depth=5, max_leaves=None,
min_child_weight=None, missing=nan, monotone_constraints=None,
multi_strategy=None, n_estimators=250, n_jobs=None,
num_parallel_tree=None, ...)
9. 多模型集成与测试集验证(2022+)
现在我们在严格未见过的未来数据上评估泛化能力:2022 赛季及以后的所有大奖赛(新的空气动力学地面效应规则)。
集成协调
F1DeepNet输出一个预期的连续完赛名次(其中越小表示名次越高:第 1 名 < 第 20 名)。XGBRanker输出一个任意的排序分数(其中越大表示相关性越高)。- 我们将神经网络的预测取反,并在每场比赛查询内对两个模型的输出进行 min-max 缩放,以产生一个校准后的集成分数:
$$\text{Score}{ens} = 0.60 \times \text{Rank} + 0.40 \times \text{Rank}_{DL}$$
# Load best weights into DL model
model.load_state_dict(torch.save(model.state_dict(), 'best_f1_deepnet.pth') or torch.load('best_f1_deepnet.pth'))
model.eval()
test_dataset = F1Dataset(test_df, cat_cols, num_cols)
test_loader = DataLoader(test_dataset, batch_size=512, shuffle=False)
dl_preds = []
with torch.no_grad():
for cat_x, num_x, _ in test_loader:
cat_x, num_x = cat_x.to(device), num_x.to(device)
preds = model(cat_x, num_x)
dl_preds.extend(preds.cpu().numpy().flatten())
# Tree-based inference
xgb_preds = best_ranker.predict(test_df[feature_cols])
# Store predictions on test set
results_test = test_df[['raceId', 'year', 'driverId', 'grid', 'positionOrder', 'relevance_score']].copy()
results_test['xgb_score'] = xgb_preds
# Invert DL prediction so higher value equates to better finish
results_test['dl_score'] = -np.array(dl_preds)
# Normalize scores per race group to prevent across-race scale distortions
def min_max_group(series):
s_min, s_max = series.min(), series.max()
if s_max == s_min:
return np.zeros_like(series)
return (series - s_min) / (s_max - s_min)
results_test['xgb_norm'] = results_test.groupby('raceId')['xgb_score'].transform(min_max_group)
results_test['dl_norm'] = results_test.groupby('raceId')['dl_score'].transform(min_max_group)
# Weighted Ensemble
results_test['ensemble_score'] = 0.60 * results_test['xgb_norm'] + 0.40 * results_test['dl_norm']
# Benchmark evaluation across Test Races (2022+)
grid_ndcgs, xgb_ndcgs, dl_ndcgs, ens_ndcgs = [], [], [], []
for _, grp in results_test.groupby('raceId'):
if len(grp) > 1 and grp['relevance_score'].max() > 0:
y_true = [grp['relevance_score'].values]
# Baseline: Qualifying Grid as direct predictor (lower grid = better relevance)
grid_ndcgs.append(ndcg_score(y_true, [-grp['grid'].values], k=5))
xgb_ndcgs.append(ndcg_score(y_true, [grp['xgb_norm'].values], k=5))
dl_ndcgs.append(ndcg_score(y_true, [grp['dl_norm'].values], k=5))
ens_ndcgs.append(ndcg_score(y_true, [grp['ensemble_score'].values], k=5))
print("="*75)
print("OUT-OF-TIME TEST SET RESULTS (Seasons 2022+ Ground-Effect Regulations)")
print("="*75)
print(f"Baseline (Qualifying Grid Alone) NDCG@5: {np.mean(grid_ndcgs):.4f}")
print(f"XGBRanker (Optuna-Tuned) NDCG@5: {np.mean(xgb_ndcgs):.4f}")
print(f"F1DeepNet (Entity Embeddings) NDCG@5: {np.mean(dl_ndcgs):.4f}")
print(f"Multi-Model Weighted Ensemble NDCG@5: {np.mean(ens_ndcgs):.4f}")
print("="*75)
===========================================================================
OUT-OF-TIME TEST SET RESULTS (Seasons 2022+ Ground-Effect Regulations)
===========================================================================
Baseline (Qualifying Grid Alone) NDCG@5: 0.9062
XGBRanker (Optuna-Tuned) NDCG@5: 0.9092
F1DeepNet (Entity Embeddings) NDCG@5: 0.8764
Multi-Model Weighted Ensemble NDCG@5: 0.9037
===========================================================================
10. 特征重要性与运营要点
最后,我们从梯度提升排序器中提取结构化特征重要性,以考察比赛排名的主要驱动因素。
importance = best_ranker.feature_importances_
feat_imp = pd.Series(importance, index=feature_cols).sort_values(ascending=True)
plt.figure(figsize=(12, 6))
feat_imp.plot(kind='barh', color='teal')
plt.title('XGBRanker Feature Importance (Gain)', fontsize=14, fontweight='bold')
plt.xlabel('Importance')
plt.tight_layout()
plt.show()
# Decode and print top predictions from the most recent race in the dataset
latest_race_id = results_test['raceId'].max()
latest_race_sample = results_test[results_test['raceId'] == latest_race_id].copy()
latest_race_sample['predicted_rank'] = latest_race_sample['ensemble_score'].rank(ascending=False, method='min').astype(int)
latest_race_sample = latest_race_sample.sort_values(by='predicted_rank')[['predicted_rank', 'positionOrder', 'grid', 'ensemble_score']]
print(f"Case Study: Predicted vs Actual Finish Order for Race ID #{latest_race_id}:")
latest_race_sample.head(8)

Case Study: Predicted vs Actual Finish Order for Race ID #1181:
predicted_rank positionOrder grid ensemble_score
2063 1 2 -1.449145 0.982072
2083 2 22 -1.304738 0.916724
2067 3 6 -1.160330 0.816612
2064 4 3 -1.015922 0.740643
2062 5 1 1.005789 0.737159
2066 6 5 -0.871514 0.689692
2065 7 4 -0.582698 0.653070
2068 8 7 -1.593553 0.626152
数据与完整代码来源:
https://mbd.pub/o/bread/YZaVmJdyZw
浙公网安备 33010602011771号