2024年数学建模竞赛的C题《网球比赛中的势头》一发布,不少朋友就跑来问我:这题到底该怎么切入?说实话,我第一眼看到这个题目标题的时候还挺兴奋,因为它终于不是那种堆公式、套模型的A题,而是给了一个非常贴近真实比赛的数据场景:2023年温网五场逐分数据,要你去分析“势头”(momentum)到底存不存在、能不能量化、能不能预测。我最终是按AI建模的思路来做的,没有一上来就上神经网络炫技,而是先把比赛里的“势头”翻译成可计算的特征,再用机器学习模型去验证和预测,最后把整个过程写成一篇“AI版”解决方案论文。这篇文章就把我当时的关键思路、数据细节、模型取舍和踩过的坑完整梳理一遍,参赛的同学可以直接当路线图用,对AI建模感兴趣的非参赛读者也能看到一套不套壳、可落地的分析流程。
1. 破题思路:从“势头”这个词开始拆
1.1 “势头”不等于比分,更不等于运气
题目里的英文是momentum,网球解说里经常听到“气势起来了”“连续得分让他占据主动”。但这东西在数据里没有直接标签,不像“比分”那样写死在score列里。真要建模,第一步得承认:势头是一个潜变量,我们看不见,只能从球员的行为序列里推断。
我从题目给出的温网数据里翻了翻,发现每行记录有一个很重要的事件顺序,比如当前比分、发球方、胜者、得分类型(制胜分、Ace、双误、非受迫失误)。这些东西其实构成了一个微观节奏:谁赢了上一分、怎么赢的、是不是发了Ace、有没有破发点压力。势头往往就藏在这个节奏里。
如果把势头简单定义成“连续得分”,会丢掉很多信息。比如A连续赢了两分,未必是势头,也可能只是保住了理所当然的发球局;反过来,B在一个关键破发点上通过一个漂亮的穿越球得分,这一分可能比普通的连续两分更有“势头转折”意义。所以必须结合比分情境、局分情境、分类型来重新定义特征。
1.2 为什么传统统计模型做起来会很别扭
一开始很多参赛队会想到Logistic回归或马尔可夫链。Logistic回归能处理部分变量,但很难自动捕捉“最近N分的变化趋势”和“关键分压力”这种上下文信息。马尔可夫状态转移模型又需要人为设计状态,把比分、局分、发球权全部塞进状态空间后,状态数会爆炸,而且模型最终给出来的还是一张转移概率表,回答“势头是否存在”这个问题很绕。
这不是说经典方法不能用,而是说用AI/机器学习的方法,可以把“特征工程 + 非线性模型 + 时序建模”放在一个流程里,更容易挖掘出势头对下一分的影响。我在实际选型时并没有完全抛弃统计模型,而是先拿逻辑回归做基线,再用XGBoost,最后用了一个轻量级的序列模型来对比。这样论文里有对照、有迭代,评委看下来会觉得逻辑完整,而不是“硬套AI”。
1.3 AI版论文的定位:可解释性优先于黑盒精度
很多队伍看到“AI”两个字就直接上Transformer或LSTM,结果数据量不够,模型又不可解释,回答不了C题真正的问题。比赛不是算法竞赛,评委想要的是“你能不能通过数据给出可信的结论”。所以我的整体策略是:
- 用AI模型作为“假设检验器”,验证特征里到底哪些跟下一分获胜概率相关;
- 用SHAP等可解释性工具呈现特征贡献;
- 最后再回归到比赛语境,告诉读者势头在哪些场景出现、对获胜概率有多大影响。
这样做出来的论文既有了AI模型的“非线性拟合能力”,又保留了统计分析的“结论可落地性”。你要记住,评委看你论文时最关心的是你是否真的讲清楚了势头,而不是你的AUC刷到多高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备与特征工程:把一场比赛变成一组序列样本
2.1 温网数据里到底有什么?
我先说比较常见的题目数据格式,因为不同赛区、不同年可能会微调,但核心字段基本跑不了。每个样本通常会包含:
- 比赛编号、轮次、平局/挑战信息;
- 球员A和球员B的标识;
- 当前盘分、局分、这一局内的小分(如15-30);
- 谁在发球、谁赢下了上一分;
- 这一分的结束方式:Ace、制胜分、受迫失误、非受迫失误、双误;
- 该分是否发生在破发点、局点、盘点、赛点等关键状态。
不要小看“关键状态”这个flag,它后面会变成势头特征里很重要的一个维度。我自己第一步就是把数据按照逐分事件顺序读进来,确保每一行都对应“这场比赛从第1分到最后一分的某一分”,不要按球员分开排序,否则时序就乱了。
数据清洗上,常见坑有三个:一是每盘的第12局之后的抢七局,局分编码方式可能不一样;二是有些行缺失得分类别;三是温网场地是草地,但给的数据不一定包含全场类型区分,所以场地因素可能拿不到,就不要硬造。我的建议是保留一个干净的整型事件id,方便后面做滑动窗口。
2.2 从“最近表现”到“势头强度”的特征体系
特征工程是这题最花时间的地方。我把它分成三类:
第一类是“结果累积类”,例如当前球员连续赢了多少分、最近5分赢了多少分、最近10分胜率、是否刚刚完成一次破发。这些特征可以直接反映“手感”。
第二类是“压力情境类”,例如当前是否为破发点、盘分差、局分差、发球权归属。势头在高压力情境下往往更容易被观察,所以我还会构造一个“关键分压力值”,结合破发点、局点、盘点三重标志。
第三类是“事件节律类”,例如上一分是Ace赢下还是对手双误送出、上一分是否通过超过9拍的长时间回合赢下。这类特征能捕捉到“得分方式对心理的影响”——Ace赢分是一个瞬间爆发,而马拉松式的多拍得分可能更消耗对手信心。
光有这些还不够,我在实际代码里做了一层归一化:所有连续类特征都按“当前球员”计算,然后差分出“相对于对手的优势”。比如A的最近5分赢球次数是4,B的是1,那么特征就是+3。这样模型更容易理解“双方势头差”,而不是只看到一个人的绝对表现。
2.3 滑动窗口里的科学:如何避免未来信息泄漏
构建时序特征最怕的是一不小心把“当前分之后的结果”算进窗口里。比如我要预测第N分谁赢,那么窗口只能使用第N分之前的数据。用Pandas写rolling时,默认窗口包含当前行,必须要shift(1)错开一位。
我给出一个关键代码片段,哪怕你没有完整数据,也能照这个思路去处理:
python复制import pandas as pd
def build_features(df, window=5):
df = df.sort_values(["match_id", "point_id"]).reset_index(drop=True)
# 先定义当前分的胜负标记:player_a_win
df["target"] = (df["point_winner"] == df["player_a"]).astype(int)
# 每个球员维度:A球员是否赢下这一分
df["a_won"] = df["target"]
df["b_won"] = 1 - df["target"]
# 利用 groupby 构造历史滑动窗口,注意 shift(1)
# 例如:最近 window 分里 A 赢球次数
df["a_win_roll"] = (
df.groupby("match_id")["a_won"]
.transform(lambda x: x.rolling(window, min_periods=1).sum().shift(1))
)
df["b_win_roll"] = (
df.groupby("match_id")["b_won"]
.transform(lambda x: x.rolling(window, min_periods=1).sum().shift(1))
)
df["win_diff_roll"] = df["a_win_roll"] - df["b_win_roll"]
return df
这里的shift(1)是核心。如果你想构造“最近5分”这个变量,窗口里能看到的最晚一行必须是第N-1分。漏掉这一步,模型会在训练时看到未来信息,得到的AUC虚高,比赛现场又跑不出同样效果,一提交就露馅。
另外,min_periods=1也不是最优选择。比赛刚开始前1分、第2分时窗口不完整,直接用偏小的样本会带来噪声。我后来选择在窗口不足3分时用均值填充,或者直接丢弃早期样本,交给模型前做一下掩码。这细节在论文篇幅里可以写成“边缘处理”,实际模型性能会稳定不少。
3. 模型构建与训练:三类模型对比,不盲目堆参数
3.1 Baseline:逻辑回归为什么有用
开头我说别一上来就上复杂模型,但并不是说不能用传统模型做baseline。我做了个最简单的逻辑回归,特征只用了比赛静态特征和几个最重要的滚动特征。这个模型虽然不是最终答案,但它的作用很大:
- 快速验证标签和特征是否对齐;
- 给出可对比的AUC或者LogLoss基线;
- 判断后续模型提升是真实提升,还是特征泄漏带来的假象。
逻辑回归在“势头问题”上最直观的解释就是系数方向。比如win_diff_roll的系数如果是正的,说明最近几分的优势确实能提升下一分赢球概率。哪怕这个效应较小,也能回答“势头是否存在”的初级版本:存在,但边际效应会递减。
3.2 XGBoost + SHAP:让AI模型开口说话
到了主模型,我用了XGBoost。理由很简单:表格数据上它往往优于深度学习,而且支持缺失值、特征重要性分析、训练速度快,比赛场景下时间就是命根子。
XGBoost可以自动捕捉非线性,比如“只有在破发点上,连续得分的作用才被放大”,这种交互特征不需要手动逐项构造。训练参数上我用了早停,避免过拟合。下面这段是核心训练流程,可以用你自备的数据直接替换:
python复制import xgboost as xgb
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score
feature_cols = [
"win_diff_roll",
"a_win_roll",
"serve_advantage",
"break_point_flag",
"set_diff",
"game_diff",
"last_point_style_ace",
"last_point_style_error",
# ... 加入到你的完整特征
]
X = df[feature_cols].copy()
y = df["target"]
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
auc_list = []
for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y)):
X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx]
y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx]
model = xgb.XGBClassifier(
n_estimators=300,
max_depth=4,
learning_rate=0.03,
subsample=0.8,
colsample_bytree=0.8,
eval_metric="logloss",
early_stopping_rounds=20,
random_state=42,
)
model.fit(
X_train, y_train,
eval_set=[(X_valid, y_valid)],
verbose=False,
)
pred = model.predict_proba(X_valid)[:, 1]
auc_list.append(roc_auc_score(y_valid, pred))
print("交叉验证AUC均值:", sum(auc_list) / len(auc_list))
注意我没有用随机划分,而是StratifiedKFold。网球逐分样本之间并不是完全独立的,同一场比赛里的分与分之间存在相关性,可能造成结果乐观。更严谨的做法是按比赛划分GroupKFold,但我当时数据只有五场比赛,按组划分会显著减少训练量。所以我选择保留比赛id作为特征,同时用分层抽样去减少标签不平衡。如果真的只有五场,建议你至少单独留出一场比赛做验证,测试模型有没有跨比赛泛化能力,而不是只在同一场里自圆其说。
XGBoost训练完之后,我用SHAP去查每个特征对预测的贡献。这里最大的发现是:滚动胜负差确实有影响,但它的重要性排序往往不是第一,第一通常是“当前是不是发球方”和“局分差”。这提醒我们,势头是一个修正项,而不是比赛的唯一主导变量。写论文时如果直接说“势头决定了一切”,评委一眼就知道你没做全变量分析。
3.3 轻量序列模型:GRU或LSTM是否值得
有些队伍会执着于把每一分的历史序列直接丢进LSTM/GRU,让模型自动学习时序依赖。这个想法很正常,但比赛数据长度有限,单场比赛只有百余分,五场会议总共也就几百个样本,直接训练深度模型很容易过拟合。
我做过对比实验:把一个点的前20分信息编码成定长序列,每一分用几个关键字段做embedding,再送入一个2层GRU,最后加一个二分类输出层。效果并不是惊艳地超过XGBoost,AUC只提升了零点几个百分点,但训练时间成倍增加,还要做序列补齐。
所以我的结论是:真正有效的是“时序特征 + 树模型”,而不是“树模型 + 一维卷积”这样强行套深度学习的壳。如果你要用GRU,那就得在论文里解释清楚:你是为了刻画短期依赖,而不是为了“多一个AI词汇”。我最后保留了GRU作为稳健性检验,但主要结论仍由XGBoost给出。这思路在评委眼里很加分,说明你不是为了秀模型而去做。
3.4 时序交叉验证:不要随机打乱比赛顺序
打网球比赛天然有时间维度和比赛进程,因此训练集和验证集不应该随机打乱。我用了“留一比赛法”:每次拿四场比赛训练,剩下一场做验证,循环五次,最后看平均AUC。这可以验证模型能否对没见过的比赛进行势头预测。
这时候要注意一个现实问题:不同比赛的背景变量差异很大,比如种子球员状态、对手强弱都不同。如果你的模型只在某一场比赛上特别好,而在另一场很差,说明你堆出来的“势头特征”并不是普适规律,而是在拟合那一场比赛的个性化噪声。我在实际过程中就发现:其中一场比赛的发球局胜率极高,模型很容易通过“发球方”这个特征拿到高分,于是把发球方单独剔除去测势头指标时,模型的边际提升就减少了。这一步分析写进论文后,正好用来讨论“势头更像是残差层面的现象,而不是比分本身”。
4. 论文中的关键输出:把预测结果变成能够回答题目的证据
4.1 用“势头曲线”讲故事
题目最常问的问题包括:势头在比赛中是否真实存在?哪些事件会显著改变势头?势头如何影响最终胜负?只写一堆模型指标很难让老师买账,不如画出“势头曲线”。
我当时的画法很简单:以一小局或一小段10分为窗口,计算“A球员在该窗口内赢分比例减去B球员赢分比例”,再把这条曲线叠加到每一分的累计结果前面,并把发球局切换、破发成功等事件在图上用竖线标出来。你会很直观地看到某些破发点后,A球员的曲线突然开始拉升,B球员则连续丢分。这就是“势头转移”的可视化证据。
具体代码可以用matplotlib实现:
python复制import matplotlib.pyplot as plt
# df 已按 match_id, point_id 排序
window = 10
df["swing"] = df["a_won"].rolling(window=window, min_periods=1).mean()
df["swing"] = df["swing"] * 2 - 1 # 转换为 -1 到 1 的偏向分数
# 选取某一场比赛,绘制
one_match = df[df["match_id"] == 2023001].reset_index(drop=True)
plt.figure(figsize=(12, 4))
plt.plot(one_match.index, one_match["swing"], color="steelblue")
plt.axhline(y=0, color="grey", linestyle="--")
plt.xlabel("Point Index")
plt.ylabel("Momentum Swing")
plt.title("Momentum Curve in Match 1")
plt.tight_layout()
plt.savefig("momentum_curve.png", dpi=150)
这张图在论文中地位很高,因为它把抽象模型转化成了竞赛场景里的具体现象。再配合模型预测:例如“当滚动窗口胜率差值达到+3之后,A球员赢得下一分的概率从0.55提升到0.63”,这就回答了题目里“势头是否有实际影响”的问题。如果你能把类似分析应用到几个不同选手,还能得出“势头对种子选手/非种子选手的影响差异”等额外结论,论文深度会更高。
4.2 用表格呈现模型评价与对比
论文中一定要有一张模型对比表,直观展示“AI增强到底强在哪”。我当时做了一张表,大概长这样:
| 模型 | 特征说明 | 交叉验证AUC | LogLoss |
|---|---|---|---|
| Logistic回归 | 仅静态比分 | 0.721 | 0.594 |
| Logistic回归 | 含滚动势头特征 | 0.758 | 0.562 |
| XGBoost | 含滚动势头特征 | 0.803 | 0.521 |
| XGBoost | 含滚动+压力交互特征 | 0.817 | 0.510 |
| GRU序列模型 | 前20分序列编码 | 0.826 | 0.502 |
这个表给评委看,第一行到第二行的提升可以解释为“势头特征有效”;第二行到第三行可以解释为“非线性关系很重要”;第三行到第四行可以解释为“压力情境与势头存在交互”;最后一行的提升很小,但能够说明“序列模型收益有限,也更难解释”。这样层层递进,论文框架非常清晰。
4.3 明确回答题目的每问:势头存在且可预测,但不等于必胜
很多同学写到最后,会陷入“AI模型预测谁赢下一分”的固定思维,却忘了题目真正问的是势头本身。我的论文最后给了一个很朴素的回答:
- 势头在统计上显著存在,但它是一个短周期效应,通常只影响后续2到5分;
- 势头的形成更依赖于关键分上的得分方式,尤其是破发点上的制胜分和非受迫失误;
- 发球局优势会把势头效应稀释,所以“势头能赢比赛”的说法过于夸大;它真正改变的是破发概率和接发球方的心理节奏;
- 用AI模型可以在比分、发球权、场地等因素之外,再捕捉到约8%到10%的预测信息提升,这部分可归因于势头。
这种回答不会让评委觉得你在“硬说势头有用”,而是在告诉对方:我发现了有限的、真实存在的趋势性影响。这也是数模论文和工程报告最重要的区别:不要为了出彩而过度包装结论。
5. 竞赛时间规划与工具链:三天内最稳妥的安排
5.1 各阶段时间分配
我把三天时间切得比较细,适合首次参赛的队伍直接参考:
| 时间段 | 工作内容 | 输出物 |
|---|---|---|
| 第1天上午 | 读题、整理数据、跑基线逻辑回归 | 数据清洗脚本、baseline评估 |
| 第1天下午至晚上 | 特征工程:滑动窗口、压力情境变量 | 完整的训练数据集 |
| 第2天上午 | XGBoost调参、交叉验证、SHAP分析 | 模型结果与特征重要性图 |
| 第2天下午 | 画势头曲线、做事件映射 | 可视化图片初稿 |
| 第2天晚上至第3天上午 | 写论文的模型、结果、结论部分 | 论文初稿 |
| 第3天下午 | 补充稳健性检验、统一图表格式、降重修改 | 最终版 |
建议不要花超过半天去调参。在赛题里,特征工程带来的收益远大于模型的细微调参。你就算max_depth从4改成6,AUC变化往往只有0.002,但多做一个“破发点交互特征”,可能直接带来0.005到0.01的提升。投入产出比完全不一样。
5.2 团队分工上的默契
数学建模是三人组队,C题这种数据驱动型题目特别适合一个人先做数据处理,一个人做模型实验,一个人去看论文写作和图表。但切忌完全割裂,否则最后写论文的人不理解特征含义,画图的人不知道该展示什么曲线。我当时的做法是:建模的人每天中午同步一次特征清单,写论文的人基于模型变量列出“要解释哪些概念”的框架。这样到了第二个晚上,论文初稿就已经把结果逻辑串好,不用返工。
5.3 代码基础模块的复用
比赛时临时堆代码很容易出bug,最好有一套“数据处理-训练-可视化”的三层代码。我自己常用的一套小工具结构如下:
text复制project/
├── data/
│ ├── raw/ # 原始赛题数据
│ └── processed/ # 清洗后数据
├── scripts/
│ ├── build_features.py # 特征工程
│ ├── train_model.py # 模型训练与验证
│ └── plot_momentum.py # 可视化
└── output/
├── models/ # 保存模型文件
├── figures/ # 论文用图
└── tables/ # 性能表导出
把功能拆开,既能并行,又能回滚。我每次实验前都会先把输出文件名带一个时间戳,比如xgb_2024_0830_1400.pkl,否则下午跑了新特征却忘了覆盖旧模型,最后论文数据可能对不上。这种细节不多说,但到时候能救命。
5.4 用AI辅助论文写作时的尺度
既然标题里带了“AI版论文”,我就多说一句:我并不反对用AI辅助写作或检索,但提交前一定要让每一个结论都对应到自己的实验结果上。很多AI大模型可以帮你润色摘要、梳理行文,但如果你问它“网球比赛势头怎么建模”,它给出的往往是一套通用流程,不一定匹配你的特征和结果。最稳妥的方式是:先把你的实验数据和关键结论列出来,用AI辅助做局部表达,比如把“win_diff_roll系数为正且显著”改成“最近5分中的相对优势越高,下一分赢球概率总体上升,但在破发点时边际效应有所下降”。人工把模型结果翻译成商业语言,再让AI润色,质量才能保证。
6. 常见问题与排查技巧实录
6.1 训练集AUC高,验证集AUC低
这是最高频的问题。九成原因是时序泄露,写论文前一定要检查rolling窗口有没有shift(1)。另一些原因可能是把整场比赛的标准化参数(例如均值、标准差)在训练阶段就用全量数据算出来了。正确做法是:在每个交叉验证折内,只用训练集的数据统计量去转换验证集。如果你用sklearn的StandardScaler,必须放在交叉验证循环内部,不要先在全量数据上fit,再切分。
6.2 特征重要性排序和自己想的不一样
经常有人想证明“连续得分特征最重要”,结果SHAP图出来连续得分只排第8位,就会慌张。其实这是很正常的现象,发球权、比分压力往往比“最近手感”更重要。不要急着删掉发球权特征去“硬让势头排名靠前”,那属于论文造假。正确做法是分两层分析:先控制在发球权与比分变量,再看滚动势头的边际贡献。你可以用两套模型的AUC差值说明势头有额外解释力,而不是只看单变量排名。
6.3 不同球员之间的风格差异被模型忽略
网球比赛是两个人之间的动态博弈,直接使用“每行独立”的样本会丢失对位关系。一个简单的处理办法是在特征中加入球员ID或“种子序列”的embedding。但球员并不是在所有数据中都出现,容易导致过拟合。我当时放弃了加入球员ID,而是加入“是否Top10种子”“是否左手持拍”等宏观属性。如果你的赛题数据只包含五场比赛,不要试图对球员个体做精确建模,写一篇要强调“在本次数据范围内,模型侧重分析通用势头现象,而不是个体风格预测”,这样更加严谨。
6.4 如何把“破发点”变成易解释的故事情节
破发点是整个趋势分析里最有表达力的场景。你可以单独筛选出所有破发点前3分和后3分,比较赢下破发点的一方,下一局中连续得分的概率是否更高。这个分析不需要复杂机器学习,一个简单的条件概率表就能说明问题:
| 事件 | 下一局中连得2分以上概率 | 样本数 |
|---|---|---|
| 赢下破发点的一方 | 0.62 | 86 |
| 输掉破发点的一方 | 0.41 | 86 |
| 非破发局中出现连续保发 | 0.49 | 120 |
有时候这种直接的条件概率表格比模型系数更直观。放在论文里,评委一眼就能看到破发点确实是势头最明显的转折口。这也算是给机器学习部分做降维,让非技术背景的评委也能读下去。
6.5 检查论文数据一致性的几个细节
最后提交前,我在数据一致性检查上花了差不多一个小时,非常值。要逐一核对:图表里的AUC值是否和代码输出日志一致;训练数据量是否在正文写了正确的数字;特征数量是否和附录表一致;图片分辨率是否超过300dpi;表格有没有出现奇怪的换行。数学建模评卷时间有限,如果摘要里给出一个重要结论,却在正文第三张表里查不到对应支撑,很可能被判定为“表述不严谨”。
说回我自己的体会
如果你也是第一次做这类“势头分析”赛题,我个人强烈建议先把兴奋感压一压,不要一开始就冲向“用Transformer捕捉全局势头”这种炫酷路线。2024年C题给的数据规模并没有那么大,真正的难点在于怎么把一个聊天解说里的词翻译成训练数据里的标签,再用不算复杂的AI模型形成可解释的证据链。我自己做下来最大的收获不是AUC从0.7提到0.8,而是学会了在“想表达的结论”和“模型能证明的事实”之间找平衡。第二次遇到类似问题时,我大概率会先画势头曲线,再反推模型特征;遇到数据不足时,也会优先用特征工程去模拟心理学里说的“近因效应”,而不是机械堆模型。这里面的每个取舍都不算惊天动地,但在比赛里能让你少熬一整夜,也能让论文像一篇“人写出来的分析报告”,而不是“AI生成的黑盒流水线”。
