2023美赛C题实战:从Wordle数据到时间序列预测建模
简介:2023年美国大学生数学建模竞赛C题资料包,围绕该赛题提供完整的数据、代码与论文写作参考资料,适合准备参加美赛或进行数据建模训练的学生和指导教师使用。压缩包内共包含208个文件,涵盖大量PDF论文与说明、Excel数据表、文档笔记、图片及备份文件,其中还包括可用于回归计算、聚类分析、数据平滑等任务的工作素材,整体约59MB。目前已有176人学习本资源。资料内不仅有第三问模型和拟合公式等关键成果,还整理了单词属性、数据拆分、多次尝试等分步分析记录,方便读者追踪建模全过程。通过查阅这些内容,能够了解赛题的数据预处理思路、模型构建方法以及报告撰写要点,有助于提高参赛者的建模实战能力和论文组织能力。
1. 2023美赛C题:把Wordle猜词游戏做成时序预测题
2023年美国大学生数学建模竞赛C题没考传统的最优化或微分方程,而是把《纽约时报》的Wordle猜词游戏数据摆到台面上,要求参赛者预测未来几天每天各轮次成功人数的分布。数学建模做到这一题,拼的就不再是公式推导,而是数据清洗、特征构造、时序建模和结果表达的完整闭环。这篇拆解围绕C题官方数据、预测目标和评分规则展开,按“读懂数据→特征工程→建模→避坑→验证→沉淀”的顺序梳理一整条能落地的路线,适合正在备赛的队伍,也适合想拿真实数据练手时间序列预测的从业者。
2. 读懂官方数据集:七个字段与两天的预测目标
先把题目的家底摸清楚。C题给定的数据是2022年1月3日到2023年1月27日每一天的Wordle玩家结果聚合,每天一条记录。注意这里不是玩家级别的明细数据,而是当天全体玩家的统计快照,所以每一行代表一个日期的全局状态。数据量看起来不大,只有三百多条记录,但如果把它当成普通回归问题拿K折交叉验证直接开跑,后面的预测基本会翻车——原因是它本质是一条时间序列,样本之间按日期强相关,星期效应、节假日效应、词难度波动都藏在日期里。
官方下发的数据一般是CSV格式,每条记录的核心字段如下:
| 字段(题目常见命名) | 含义 | 类型 |
|---|---|---|
| Date | 日期,每条记录对应一天 | 时间 |
| Contest number | 当天对应的比赛编号,随日期递增 | 整数 |
| Word | 当天《纽约时报》给出的目标单词 | 字符串 |
| Number of reported results | 当天提交结果的总玩家数 | 整数 |
| 1 try ~ 6 tries | 在对应尝试次数内成功的玩家数 | 整数 |
| 7 or more tries(X) | 超过6次仍未猜中的玩家数 | 整数 |
2.1 字段关系先验:人数口径决定建模方式
拿到数据第一件事不是急着画图,而是核对字段之间的数量关系。理论上“1 try”到“6 tries”以及“X”这几列的人数之和应该等于“Number of reported results”,但实际数据里往往会有偏差,原因是官方做聚合时可能把部分异常提交单独处理了,或者小数进位产生了截断误差。这个偏差虽然只有几百人,但在比赛评分时会直接影响你的预测误差,所以必须先确定口径。
我一般会把这几列先做一次加总校验,算出差值,然后决定建模目标是“各轮次人数”还是“各轮次占比”。如果是人数,最后要除以报告总数才能和评分基准对齐;如果是占比,就要在训练前把原始人数归一化。这里有个容易被忽略的点:如果目标是占比,那么各列之间是耦合的,某一列预测偏大,必然导致其他列被压缩,建模时最好做多输出联合预测,而不是七个列各自独立回归。
另一个值得提前想清楚的是“Number in hard mode”这类附加信息在三列中的角色。它代表当天用困难模式提交的结果数,和总报告数存在比例关系,但题目并没有要求预测它。我在做特征时会把这类列保留下来当辅助特征,因为它反映了当天玩家群体的“硬核程度”,对最终成功率分布有解释力。字段多不代表都要当预测目标,能帮到主目标的特征挖掘才是这部分的核心工作。
2.2 预测目标拆解:为什么是1月31日和2月1日
题目要求预测的是2023年1月31日和2月1日两天的结果分布,也就是数据集截止日期之后紧跟着的两个新数据点,没有真实值可供你在训练阶段直接比对。这种设置意味着你没法在测试集上反复试错,必须靠对模型的信任和历史验证推估计。两天的预测维度也比一天更难,因为要把趋势项和星期效应同时外推两步。
从分布预测的角度看,这不是七个独立的数值预测,而是整个分布形态的预测。不同轮次的比例之间存在明显的单调递减关系:大部分玩家在第3到第5次尝试之间成功,第1次成功的人数极少。模型如果只学平均趋势,容易把所有预测都压向均值附近,丢失尾部信息。所以我在处理目标值时没有对单列做标准化,而是保留了原始分布形态,让模型自己去学各列之间的联动关系。
这里还牵扯到一个比赛技巧:两天的预测结果要都写在摘要表里,而且评委能直接看到提交结果和真实值的偏差。比赛后会有排行榜性质的评测,误差小的队伍会占明显优势。因此两天都要给预测值,而不是只给一天;同时建议额外给置信区间,因为题目明确倾向带有不确定性度量的预测,区间预测做得好可以弥补点估计的失分。
2.3 交付物结构:摘要表、正文与代码附录
美赛的提交物是一篇PDF论文,C题作为数据题,对结果可复现性的要求比其他题更高。整体结构一般是:摘要表占第1页,正文从问题重述开始,包含数据说明、模型构建、结果验证、优缺点分析,最后是参考文献和附录。我当时是按“正文不超过20页,PDF总量尽量控制在25页以内”的尺度来做的,附录放代码和额外图表,不参与正文计数。
评委在看C题论文时,一个重要关注点是数据处理过程是否说得清楚。如果只写一句“用XGBoost建模”然后甩出预测结果,基本拿不到好分数。更稳的做法是用独立小节讲数据清洗、特征构造和验证策略,让每一步都有依据。很多队伍忽略了对预测结果的敏感性分析,比如训练集去掉最后两周模型会不会漂移、周几特征对预测的影响有多大,这些内容写进去,论文厚度会立刻不一样。
3. 特征工程与模型选型:从基线到XGBoost的完整路线
拿到干净数据后,不建议一上来就调模型参数。我习惯先做一个“傻基线”把预测下限钉死,后续所有模型都在这个基准上比较提升幅度。这道题最自然的基线是滞后均值:直接用过去7天各轮次占比的平均值作为未来两天的预测。它对趋势平稳的日期表现不错,但遇到假日或单词特别难的时候会明显失灵,而基线的失灵正好能说明特征工程的价值。
3.1 基线模型:滞后均值先把下限钉死
import pandas as pd df = pd.read_csv('wordle_data.csv', parse_dates=['date']) target_cols = ['1_try', '2_try', '3_try', '4_try', '5_try', '6_try', 'X'] # 用过去7天的均值做未来两天预测 recent = df[target_cols].tail(7).mean().values baseline_pred = recent / recent.sum() # 归一化成占比这段代码先把最后7天各轮次人数取均值,再归一化成占比。逻辑很简单,但它的作用是锚定后续模型的下限:一个XGBoost模型如果连滞后均值都打不过,说明特征构造或训练流程出了问题。参数上要注意“tail(7)”的窗口长度不是固定的,我曾对比过3天、7天、14天窗口,7天在大多数星期周期下效果最稳,因为它刚好覆盖一个完整星期,能吸收星期效应。
这个基线还有个用途:它天然是一个平滑器,后续模型预测结果如果和基线偏离太多,我会警惕是不是过拟合。比赛数据集只有三百多条记录,复杂模型很容易把噪声学进去,基线恰恰是噪声的上限参照。
3.2 特征构造:日期、滞后项和滚动统计一起上
# 日期衍生特征 df['day_num'] = (df['date'] - df['date'].min()).dt.days df['weekday'] = df['date'].dt.weekday df['month'] = df['date'].dt.month # 滞后特征:过去1/2/3/7/14天的目标值 for lag in [1, 2, 3, 7, 14]: for col in target_cols: df[f'{col}_lag{lag}'] = df[col].shift(lag) # 滚动统计:7天与14天的均值/标准差 for col in target_cols: df[f'{col}_mean7'] = df[col].rolling(7).mean() df[f'{col}_std7'] = df[col].rolling(7).std() df = df.dropna().reset_index(drop=True)这里做了三类特征。第一类是日期编码,day_num是连续天数,用来捕捉长期趋势;weekday是星期编号,用来捕捉周期性;month是月份,因为赛季或节日会影响玩家活跃度。第二类是滞后特征,把过去1天、2天、3天、7天、14天的目标值复制到当前行,让模型能直接看到近期历史。第三类是滚动统计,7天均值和标准差把窗口内的水平与波动压缩成两个数。
参数上滞后窗口的选择有讲究:1到3天捕捉短期波动,7天对齐星期周期,14天看半个月趋势。滚动窗口我固定用7和14,不额外做太多窗口组合,因为特征多了很容易在三百条样本上过拟合。有一个细节是dropna的位置必须在所有shift和rolling之后,否则前14行会被删掉,训练集长度和模型看到的窗口就不一致了。
特征构造完后,只保留真正有解释力的列。我在比赛里会根据特征重要性筛选,一般滞后1天、滞后7天和星期几这三类特征贡献最大,单词本身的难度特征反而作用有限,因为单词特征很难量化——字母重复率、词频这些指标在预测轮次分布时的相关系数并不高。
3.3 多输出模型:用XGBoost同时预测七个占比
from sklearn.model_selection import TimeSeriesSplit from xgboost import XGBRegressor import numpy as np feature_cols = [c for c in df.columns if c not in target_cols + ['date', 'word']] X = df[feature_cols].values y = df[target_cols].values # 时间序列交叉验证,保证训练集时间早于验证集 tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(X): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] model = XGBRegressor( n_estimators=300, learning_rate=0.05, max_depth=4, subsample=0.8, colsample_bytree=0.8, reg_lambda=2.0, random_state=2023 ) model.fit(X_train, y_train) # 预测未来两天 pred_raw = model.predict(X_val[-2:]) pred = np.maximum(pred_raw, 0) # 夹紧到非负 pred = pred / pred.sum(axis=1, keepdims=True) # 归一化成占比这段代码做了三件事。第一,用TimeSeriesSplit而不是普通K折,这是时序建模最容易踩的坑,后文会展开说。第二,XGBoost直接对7个目标列做多输出回归,XGBRegressor对多目标会为每个输出建一组树,相当于七个模型并联。第三,对原始预测做非负夹紧加行归一化,保证每个日期预测的占比之和为1。
参数选择上,n_estimators设300配合learning_rate=0.05是比较稳的组合,树太多容易在三百条样本上过拟合,太少则欠拟合。max_depth控制在4,因为特征维度不高,太深的树纯粹在记训练集。subsample和colsample_bytree都是0.8,相当于给模型加了随机性。reg_lambda=2.0是L2正则,对平滑预测有直接帮助。随机种子固定是必要的,否则每次跑结果都不一样,论文里的数值没法复现。
模型选型上我也对比过随机森林和线性回归。随机森林的优势是稳定、好解释,但外推能力弱,它的预测值不会超出训练集取值范围,这意味着面对从未出现过的极端情况容易失效。线性回归在这个任务里过于简单,因为各轮次占比和日期之间不是线性关系,单靠线性项很难建模星期效应。XGBoost算是个折中:支持外推、能捕获非线性、自带正则,是这道题最省心的选择。
4. 避坑指南:数据、建模与交卷环节的五个典型翻车现场
这道题看起来数据规整,实际处理起来坑不少。下面五条每条都是我或者周围队伍真实踩过的,按“现象→原因→解决”写清楚,能帮你省下至少两天排查时间。
4.1 翻车一:占比列加起来不等于报告总数
现象:读入数据后做加总校验,发现每行的“1 try”到“X”人数之和不等于“Number of reported results”,有的行差几十人,有的差几百人,没有固定规律。
原因:官方聚合时可能把超时未完成的记录部分归入了报告总数但未拆分到各轮次,也可能是小数进位、分母口径不一致导致。
解决:建模前先跑一次全量校验,计算每行的差值比例。如果差值比例稳定在可接受范围内,直接以占比为建模目标,把差值信息作为独立特征放进模型,不要丢掉它;如果差值不稳定,就只建模各轮次占比,最后用“报告总数”乘占比得到人数预测。
4.2 翻车二:预测值出现负数和大于1
现象:模型训练完,输出预测结果里出现-0.02、1.15这种不可能出现的值,提交测评时误差被大幅拉高。
原因:XGBoost作为回归模型不对输出做约束,它只是拟合数值,预测本身没有任何物理意义限制,出现超界值非常正常。
解决:预测后在后处理里加两步——先np.maximum把所有值夹到非负,再除以行和做归一化。这个后处理代码要放进你的论文主流程里,因为评委要看的是完整可复现的预测管线,不是手工在Excel里修数字。
4.3 翻车三:时间序列被随机打乱做验证
现象:用train_test_split默认的随机划分做训练验证,验证集误差很低,自我感觉良好,但一提交真实预测就被打回原形。
原因:数据是时间序列,随机打会把未来数据混进训练集,模型提前“看到”了验证期的分布形态,评估结果虚高。这是时序建模里最常见的自欺欺人。
解决:强制用TimeSeriesSplit,保证每次划分训练集都严格早于验证集。另一个更贴近比赛的做法是留出法:把最后两周数据当作验证集,训练集只用这两周之前的数据,模拟真实比赛场景。两种方法取一种,写进论文说明清楚即可。
4.4 翻车四:只给点估计不给置信区间
现象:论文里的预测表只有一行一个数字,评委意见栏写“缺乏不确定性度量”,分数被卡在中等档。
原因:C题本质是预测问题,评委希望看到你对预测结果有把握程度的概念。单点预测在真实数据面前或多或少会偏差,没有区间就代表着这不是一个完整的统计模型。
解决:用分位数回归给每个目标列算5%和95%分位线,组成置信区间。第5章会给出具体实现,这套东西在数据题里是显著加分项,能在点预测误差相近时拉开论文档次。
4.5 翻车五:报告页数超限,代码全部堆进正文
现象:写完草稿发现正文25页、附录10页,总PDF超过35页,离提交截止还有两小时发现稿子被退。
原因:把数据探索、特征代码、全部图表都塞进正文,忘记了美赛论文有页数结构要求,正文和附录分开计算。
解决:正文只放必要的图表和公式,所有代码块挪到附录,每个模型保留一张关键输出图即可。我的一般规格是:问题重述3页,数据处理2页,模型构建6页,验证结果4页,敏感性分析2页,总结和批评性分析2页,整体压在20页内。
5. 结果验证与置信区间:让模型输出经得起评委追问
模型建好只是完成了一半,C题论文的核心说服力来自验证环节。很多队伍训练完模型直接预测完事,不评价好坏就交卷,评委看到的是没有依据的数字,自然给不出高分。这一章把验证指标、置信区间和可视化三件事一次讲透。
5.1 验证指标:RMSE之外还要看JS散度
因为预测目标是占比分布,单一RMSE不够用。RMSE能衡量每个目标列的平均偏离程度,但它对列之间的相对关系不敏感——如果七个类别都整体偏移10%,RMSE可能不大,但分布形状已经不对了。我会同时看JS散度(Jensen-Shannon Divergence),它衡量的是两个概率分布之间的距离,值越小说明预测分布和真实分布越接近。
from scipy.spatial.distance import jensenshannon import numpy as np # pred_row和true_row都是归一化后的占比向量 jsd = jensenshannon(pred_row, true_row) print(f"JS散度: {jsd:.4f}")这段代码用在验证集上,计算每天预测分布与真实分布的JS散度。参数上jensenshannon默认以e为底,输出的是自然对数量纲,不同天之间的数值可以直接比较。我用它来筛选特征集:加一组新特征后如果验证集平均JS散度下降,说明特征有效;反之直接丢弃。
RMSE和JS散度要配合论文呈现。比如表格里列出每天每个指标的数值,再做一张散点图展示“预测值对真实值”的分布,离对角线越近越好。这两个指标共同构成你的模型评价体系,比单指标说服力强得多。
5.2 置信区间的构造:分位数回归替代正态假设
from sklearn.ensemble import GradientBoostingRegressor quantiles = [0.05, 0.5, 0.95] q_models = {} for q in quantiles: q_models[q] = GradientBoostingRegressor( loss='quantile', alpha=q, n_estimators=300, learning_rate=0.05, max_depth=4, random_state=2023 ) q_models[q].fit(X_train, y_train[:, 2]) # 以第3列为演示分位数回归的思路是对每个目标列训练三个模型,分别拟合5%分位、中位数和95%分位。loss='quantile'配上alpha参数让 GradientBoostingRegressor 不再拟合均值,而是拟合指定分位数。这样预测出来的三条曲线天然组成置信带,不需要假设预测误差服从正态分布,对尾部偏厚的数据更稳健。实际操作时,对7个目标列循环训练即可。
注意这里是以单列演示的,跑全量时把y_train[:, 2]换成所有目标列。如果用的是新版XGBoost,也可以把目标函数换成reg:quantileerror并传入quantile_alpha,效果一致但训练速度更快。这步最大的价值是给论文提供了一个“区间覆盖率”的评价点:可以声称在历史验证集上有90%的真实值落在预测区间内,这句话在评委那里比“预测很准确”有分量。
5.3 可视化组合:趋势线、残差和区间带
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(history_dates, history_values, label='historical') ax.plot(future_dates, pred_mid, label='prediction', marker='o') ax.fill_between(future_dates, pred_low, pred_high, alpha=0.3, label='90% interval') ax.legend()这张图放在结果章节开头,一眼能看出历史走势、预测值和区间宽度。残差图放到验证小节:横轴是日期,纵轴是真实值减预测值,如果残差随机分布在零线附近说明模型没有系统性偏差;如果残差在某个时间段整体偏正或偏负,说明特征里缺少对应时间段的驱动因素。
区间带的宽窄也要解读:如果预测区间太窄,说明模型低估了不确定性;太宽又显得预测没有意义。一个好的区间是覆盖真实值的同时保持合理宽度。我在论文里对区间宽度做了一句量化描述——“过去五周验证集上90%区间的平均宽度为0.03”,这句话比任何漂亮图都有说服力。
6. 赛后沉淀:把C题解法固化成可复用的时序预测流水线
比赛结束以后值得把整套流程固化成代码模板,下次遇到电商销量预测、平台日活跃用户预估、舆情热度外推这类问题,直接套骨架换数据就能跑。我习惯把特征工程、模型训练、区间预测和后处理四个环节封装成函数,配合一个配置文件切换参数,这样调参和复现都在一条命令内完成。
def build_features(df, target_cols, lags=(1,2,3,7,14)): df = df.copy() df['day_num'] = (df['date'] - df['date'].min()).dt.days df['weekday'] = df['date'].dt.weekday for lag in lags: for col in target_cols: df[f'{col}_lag{lag}'] = df[col].shift(lag) return df.dropna() def quantile_forecast(X_train, y_train, X_pred, q=0.5): model = GradientBoostingRegressor(loss='quantile', alpha=q) model.fit(X_train, y_train) return model.predict(X_pred)这个骨架的设计原则是:特征层和模型层完全解耦,换数据时只改target_cols和lags两个变量,其他不动。我第一次直接用这套代码去预测公司另一个业务的未来两周活跃用户数,只改了三行列名,预测结果就比原来的Excel外推法稳定许多。
从那以后我每次拿到时序预测题都强制走一遍这个流程:先核对字段加总关系,再构造滞后和滚动特征,跑基线钉下限,最后用时间序列交叉验证评估真实水平。这套习惯让我在各种项目抢时间的时候少翻了很多车,也让我意识到看题目先拆解法再动手,才是一个数学建模题目真正留给你的东西。希望帮到你。
本文还有配套的精品资源,点击获取