ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

美赛MCM C题实战:时序预测与特征工程完整方案

美赛MCM C题实战:时序预测与特征工程完整方案 简介面向数学建模美赛C题备考者的一套系统性资源包聚焦历年赛题的结构化解析与编程实现涵盖连续型、离散型及大数据分析等常见赛题方向对非传统交叉领域题目也有专门方法梳理适合从入门到冲刺的参赛学生系统学习。整套资源共二百零九个文件压缩包约六十四兆字节包内包括四十九份PDF格式讲解文档、三十五份电子表格数据、三十二张图表图片、二十份脚本说明文本以及若干Word笔记、Sav统计数据和Visio图形文件类型覆盖理论解析、数据样例、代码注释与辅助图表便于对照查阅。内容按“基础理论—案例精讲—模拟训练”三阶段组织并配有对应习题库与参考答案从数据平滑预处理、回归拟合计算到K-Means聚类分析等环节均有可运行的代码与配套数据可帮助理解完整建模流程。目前已吸引七十七人学习适合希望快速掌握建模思路、完善备赛笔记并节省资料整理时间的选手。1. 2023美赛MCM C题完整解题方案先看清数据形态再决定建模路线2023年MCM C题以Wordle每日对局数据为背景原始数据集只有几百行字段却包含了对局总数、逐次猜中比例、困难模式参与人数以及一份每日单词的答案列表。很多队伍拿到这份数据后第一反应是直接套LSTM做时序预测但样本量不超过1000条深度模型在这么小的数据上根本学不出稳定的规律更别说论文里还要解释模型的每一步决策。真正能打动评阅人的路线是把问题拆成数据探索、时序预测、单词特征建模、结果可视化四个模块用统计模型做主力、用特征工程补解释性、用交叉验证保证可信度再把每一步的代码整理成可复现的资源。这篇文章就按这条路线把关键代码、参数设置和最常见的坑完整过一遍。2. 数据读取与探索性分析从原始CSV到可建模的时间序列2.1 数据文件的字段结构与读取方式竞赛提供的数据集是CSV格式每一行对应一天的对局记录。字段数量不多但类型差异很大有日期、整数计数、浮点占比三种类型混在一起。里面真正需要关注的字段如下字段名类型说明Date日期YYYY-MM-DD按天递增Contest Number整数Wordle 游戏编号从 1 开始递增Number of reported results整数当日提交对局结果的总人数Number in hard mode整数当日困难模式的参与人数1-Word 到 6-Word浮点猜中 1 次到 6 次的人数占比读取数据的代码比较简单但有两个地方容易出问题。一是日期列的解析二是百分比列的数值类型。import pandas as pd import numpy as np df pd.read_csv(wordle_data.csv, parse_dates[Date]) df df.sort_values(Date).reset_index(dropTrue) # 检查字段类型和缺失值 print(df.info()) print(df.isna().sum()) # 猜中次数分布列转浮点脏数据统一变成NaN for col in [f{i}-Word for i in range(1, 7)]: df[col] pd.to_numeric(df[col], errorscoerce) # 各列占比之和应接近1用于快速诊断数据质量 df[total_share] df[[f{i}-Word for i in range(1, 7)]].sum(axis1) print(df[total_share].describe())parse_dates会在读入阶段把日期字符串转成datetime64类型后续做时间切片和重采样时效率会高很多。to_numeric加上errorscoerce是为了防止某些行里出现空字符串或文本描述转换失败会变成NaN不会直接抛异常。最后算total_share是一步非常值得保留的诊断逻辑如果某一天六类占比之和不是 1说明原始数据存在舍入误差或字段缺失后续建模前必须先处理。2.2 缺失值填充与异常点识别Wordle 数据最常见的缺失形式是整行缺失也就是某一天完全没有记录。除此之外还可能存在个别字段为空的半行缺失。处理方案是先生成完整的日期范围再对缺失列做填充。# 以最小和最大日期为边界生成连续日序列 full_dates pd.date_range(df[Date].min(), df[Date].max(), freqD) df df.set_index(Date).reindex(full_dates).rename_axis(Date).reset_index() # 数值列先做前向填充再用线性插值兜底 num_cols [Contest Number, Number of reported results, Number in hard mode] \ [f{i}-Word for i in range(1, 7)] df[num_cols] df[num_cols].ffill().interpolate(limit_directionboth) # 用3倍标准差标记参与人数的异常值 from scipy import stats z_scores np.abs(stats.zscore(df[Number of reported results].dropna())) outlier_idx np.where(z_scores 3)[0] print(Outlier dates:, df.iloc[outlier_idx][Date])填充策略选择前向填充加线性插值而不是用均值填充原因是时间序列的局部连续性比全局统计量更重要。前一天的数据与后一天的数据在统计特性上高度相似用前面一天的值去填充短期缺失不会引入过大的偏差。interpolate只在连续缺失超过一天时发挥作用limit_directionboth确保序列首尾也有值可用。异常点识别这里要特别小心。Wordle 在传播高峰期出现过的参与人数跃升属于真实的业务趋势不应该被当作脏数据删除。3倍标准差只是辅助定位的工具具体要不要处理需要参考前后几天的数据形态。先标记再人工判断比直接自动过滤更稳妥。2.3 EDA阶段必做的三张图和两个结论探索性分析阶段我一般会先画三张图参与人数的时间趋势、困难模式的参与占比、六档猜中次数的分布演化。这三张图分别回答三个问题整体走势是什么样的、用户结构是否稳定、玩家能力是否在变化。import matplotlib.pyplot as plt fig, axes plt.subplots(3, 1, figsize(12, 10)) # 第一张图每日报告人数的时间趋势 axes[0].plot(df[Date], df[Number of reported results], lw0.8) axes[0].set_title(Daily Reported Results Trend) # 第二张图困难模式参与占比 df[hard_ratio] df[Number in hard mode] / df[Number of reported results] axes[1].plot(df[Date], df[hard_ratio], colororange, lw0.8) axes[1].set_title(Hard Mode Participation Ratio) # 第三张图按月聚合的猜中次数分布 monthly df.set_index(Date).resample(M)[[f{i}-Word for i in range(1, 7)]].mean() monthly.plot(axaxes[2], markero, ms3) axes[2].set_title(Monthly Average Guess Distribution) plt.tight_layout() plt.show()三张图背后有三个可以直接写进论文的结论。第一张图能看出参与人数经历了快速增长、峰值和回落三个阶段说明时间序列存在明显的趋势变化点静态线性回归无法覆盖这种非平稳性。第二张图如果整体波动很小说明困难模式参与比例相对稳定适合单独建模做回归分析。第三张图如果2-Word和3-Word的占比持续抬升说明玩家整体水平在逐步提高这时模型需要引入时间相关的特征或者把分布演化本身作为变量。把观察到的规律、选择的模型、模型回答的问题串成链条这比堆砌10 张图表更有说服力。3. 时序预测的核心操作Prophet 与 ARIMA 的选型、定参和交叉验证3.1 为什么短序列上不推荐直接上 LSTM这里必须先说清楚模型选择的边界Wordle 数据只有几百个时间点LSTM 的训练集通常只有几百条测试集几十条随机种子不同结果波动就非常剧烈。这不是调参能解决的问题是统计功效不足。深度学习在短序列上缺乏足够样本去拟合时序依赖强行使用反而会在交叉验证里暴露不稳定性。适合这种规模的时序模型行业内比较成熟的选型如下模型优点缺点在本题中的定位指数平滑实现简单稳定性好无法处理复杂趋势变化基线模型ARIMA统计检验成熟解释性强定阶需要人工判断单变量预测对照Prophet自动处理缺失和节假日对突变趋势反应滞后主力模型GAM/线性回归可加入外生特征依赖特征工程质量难度分布建模我的做法是以 Prophet 为主模型ARIMA 做交叉验证中的对照模型。Prophet 对缺失值和突变趋势的容忍度更高ARIMA 则在验证稳定性上作用明显。两个模型同时跑论文里可以多一张对比表。3.2 Prophet 建模流程与三个必调参数Prophet 把时间序列分解为趋势项、季节项和节假日项对 Wordle 这样有周内周期和节假日波动的数据适用性很好。最小可运行的代码如下from prophet import Prophet # 列名必须重命名为 ds 和 y prophet_df df[[Date, Number of reported results]].rename( columns{Date: ds, Number of reported results: y} ) model Prophet( yearly_seasonalityTrue, weekly_seasonalityTrue, daily_seasonalityFalse, changepoint_prior_scale0.8, seasonality_prior_scale10.0, n_changepoints25 ) model.add_country_holidays(country_nameUS) model.fit(prophet_df) future model.make_future_dataframe(periods60) forecast model.predict(future) # 检查趋势项和置信区间 print(forecast[[ds, trend, yhat_lower, yhat_upper]].tail())三个最值得花时间调的参数是changepoint_prior_scale、seasonality_prior_scale和n_changepoints。changepoint_prior_scale控制趋势变化点的响应速度值越大模型越容易跟着数据的局部波动改变趋势方向。对 Wordle 这种有明显爆发期和后衰减期的数据0.5 到 1.0 之间通常比较合适。seasonality_prior_scale控制季节性成分的平滑程度调得太大容易把噪声当成周期性规律。n_changepoints是趋势变化点的数量上限数据只有几百条时25 个点已经足够覆盖主要的趋势切换。注意这三个参数不要凭感觉定后面 3.4 节会给一套基于交叉验证的搜索方法。3.3 ARIMA 定阶平稳性检验与网格搜索ARIMA 的难点在于确定 p、d、q 三个阶数。常规流程是先做 ADF 平稳性检验然后用网格搜索找最优阶数。from statsmodels.tsa.stattools import adfuller from statsmodels.tsa.arima.model import ARIMA import itertools y df[Number of reported results].dropna() # 平稳性检验p值小于0.05则序列可认为是平稳的 adf_result adfuller(y) print(fADF p-value: {adf_result[1]:.4f}) # 网格搜索最优 p 和 qd 固定为1 best_aic float(inf) best_order None for p in range(0, 5): for q in range(0, 5): try: order (p, 1, q) res ARIMA(y, orderorder).fit() if res.aic best_aic: best_aic res.aic best_order order except Exception: continue print(fBest ARIMA order: {best_order}, AIC{best_aic:.2f})ADF 检验的 p 值如果大于 0.05说明序列非平稳需要先做一阶差分。Wordle 数据在绝大多数情况下一阶差分后就能通过平稳性检验。网格搜索时 p 和 q 的范围设在 0 到 4 就足够了样本量有限时高阶模型反而容易过拟合。AIC 值用于在拟合优度和模型复杂度之间做权衡比直接看残差的 R 方更可靠。3.4 时间序列交叉验证与误差评估时序预测里最忌讳的是随机切分训练集和测试集。随机打乱会破坏时间顺序让模型在训练时看到来自未来的信息。标准做法是扩展窗口交叉验证每次训练集向后扩展一个窗口测试集保持固定长度。from sklearn.metrics import mean_absolute_percentage_error def ts_cv_prophet(data, h30, n_splits3): 扩展窗口交叉验证训练集逐步扩大测试集往后滚动 mape_list [] for i in range(n_splits): train_end len(data) - h * (i 1) train data.iloc[:train_end] test data.iloc[train_end:train_end h] model Prophet(weekly_seasonalityTrue, daily_seasonalityFalse) model.fit(train.rename(columns{Date: ds, y: y})) future model.make_future_dataframe(periodsh) pred model.predict(future).tail(h)[yhat].values true test[y].values mape_list.append(mean_absolute_percentage_error(true, pred)) return np.mean(mape_list), np.std(mape_list)扩展窗口比滚动窗口更合适因为每次训练都能用上截至当前的全部历史数据在样本量紧张的场景下稳定性更好。评价指标选 MAPE 而非 RMSE是因为参与人数的量级从几百到几万都有相对误差可以直接横向对比不同时间段的表现论文里写“预测误差在 7% 左右”也更容易被评阅人理解。4. 单词特征工程把 Wordle 答案转化为可解释的模型输入4.1 从答案列表中提取字母级特征题目附带了一份每日单词的答案列表这是时间序列之外非常重要的信息源。单词本身的特征比如元音数量、字母重复程度、词频高低会直接影响玩家猜中的难易程度从而影响当天的分布数据。常见做法是提取下面这些特征def letter_features(word): 从单词中提取字母相关的特征用于建模 word word.lower() vowels sum(1 for ch in word if ch in aeiou) unique_letters len(set(word)) # common_score 用字母频率加权近似表示单词常见度 common_score sum(common_letters.get(ch, 0) for ch in word) return { length: len(word), vowel_count: vowels, unique_letters: unique_letters, repeat_count: len(word) - unique_letters, common_score: common_score, has_repeat: 1 if len(word) - unique_letters 0 else 0 }common_letters是字母在英语中出现的频率表可以根据公开的词频统计预先定义好一个字典。把repeat_count单独拆出来很有必要因为含重复字母的单词玩家的猜测策略会失效典型的表现就是猜中分布向 4-Word 和 5-Word 偏移。这一特征在比赛中被很多队伍忽视但它恰好是解释“为什么某些天玩家表现特别差”的关键变量。4.2 单词特征与玩家分布的关联建模提取完特征后需要把单词特征和当天的时间序列数据对齐做一轮监督学习。目的是验证单词特征对玩家表现确实有统计意义上的影响而不是停留在描述层面。from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split # feature_df 是上面函数对全部答案词运行后的结果 X pd.concat([feature_df, df[[hard_ratio]]], axis1).dropna() y df[3-Word] # 按时间顺序切分shuffle 必须关闭 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, shuffleFalse ) rf RandomForestRegressor(n_estimators300, max_depth6, random_state42) rf.fit(X_train, y_train) importance_df pd.DataFrame({ feature: X.columns, importance: rf.feature_importances_ }).sort_values(importance, ascendingFalse) print(importance_df)shuffleFalse是这里最重要的参数。随机森林本身不关心样本顺序但如果你打乱了时间顺序模型就能在训练阶段看到未来的信息得到的特征重要性完全不可信。输出结果一般情况下会显示前一天的历史分布特征重要性最高字母特征其次这本身就是一个值得写进论文的结论短期惯性主导玩家行为单词难度起修正作用。4.3 分层建模普通模式与困难模式分开处理困难模式的参与人数远小于普通模式两者的行为逻辑也不同放在同一个模型里训练会互相干扰。更好的做法是分层建模预测目标建模方式核心特征每日总参与人数Prophet / ARIMA日期、趋势、节假日普通模式各猜中次数占比随机森林 / GAM历史占比、单词特征、困难模式占比困难模式参与人数随机森林 / 线性回归历史人数、单词特征、趋势项分层建模还有一个附带的好处论文里可以分模块展示误差分别讨论三个模型各自的优缺点这样内容更充实评阅人也能感受到作者对问题有拆解能力。三个模型混在一起做一张误差表什么问题都说明不了。5. 从模型到论文预测图、敏感性分析与代码资源的组织方式5.1 预测图必须画置信区间比赛论文里最常见的浪费是预测图只画一条线。评阅人看到单条预测曲线完全无法判断模型的不确定性。Prophet 输出的yhat_lower和yhat_upper列可以直接用来画区间带这一步视觉提升非常明显。fig, ax plt.subplots(figsize(12, 5)) ax.plot(df[Date], df[Number of reported results], labelactual, lw1) ax.plot(forecast[ds], forecast[yhat], labelpredicted, lw1, colorred) ax.fill_between(forecast[ds], forecast[yhat_lower], forecast[yhat_upper], colorred, alpha0.2, label95% interval) ax.legend() plt.show()置信区间的存在本质上承认了预测天然有不确定性这个表达在数模论文里非常加分。很多队伍只汇报点估计评阅人反而会怀疑模型的稳定性。5.2 敏感性分析的具体操作评阅人最常见的提问方式是你的关键参数是试出来的还是拍脑袋定的所以论文里最好专门留一段做敏感性分析。最简单的做法是改变changepoint_prior_scale观察预测结果是否剧烈波动。def sensitivity_prophet(train_df, changepoint_scale): model Prophet(changepoint_prior_scalechangepoint_scale) model.fit(train_df) future model.make_future_dataframe(periods30) forecast model.predict(future) return forecast[yhat].iloc[-1] params [0.1, 0.3, 0.5, 0.8, 1.0] results [sensitivity_prophet(train_df, p) for p in params] print(pd.DataFrame({changepoint: params, last_pred_value: results}))如果 30 天后预测值的波动幅度小于 5%就可以在论文里明确写“模型对超参数变化不敏感”。如果波动很大则应当说明选择某个参数的理由并附上交叉验证的误差对比这样评阅人不会觉得参数是随意设定的。5.3 代码资源的组织方式最后说代码提交的规范。竞赛提交的不只是论文代码的可复现性会直接影响团队的整体评价。建议按模块拆分脚本数据预处理、EDA 可视化、Prophet 建模、ARIMA 建模、特征工程、敏感性分析各一个文件每个文件入口都放在if __name__ __main__块里。脚本开头固定随机种子数据路径统一从同一起始点读取。README 里写清楚 Python 版本和四个关键依赖pandas、numpy、prophet、statsmodels。评阅人拿着代码按 README 跑一遍如果能在五分钟内获得完整结果这个印象分比论文里任何一张图都更值钱。本文还有配套的精品资源点击获取
返回列表