简介:这是2023年美赛获奖C类论文《通过数据分析揭示Wordle的秘密》的完整PDF文档,面向参加美赛的学生、数学建模爱好者和数据分析学习者。论文以《纽约时报》热门游戏Wordle为案例,完整展示了数据分析驱动建模的流程:先利用门控循环单元算法预测报告数量,再定义词频、字母频率之和、重复模式和主要词性四个属性,通过回归分析和箱形图探究它们与得分的关系,随后构建网格搜索随机森林模型预测单词的得分分布,并采用K均值聚类对难度进行分级。全文附有模型推导、误差分析、灵敏度分析和可视化图表,可直接用作美赛C题范文、备赛参考或数据科学项目案例。资源为单个PDF文件,压缩包大小约5.74MB,支持全文检索和打印阅读。已有195人学习下载,适合需要研读获奖论文结构、建模方法与写作规范的读者。
1. 美赛C题Wordle论文拆解:为什么值得复现
最近为准备美赛复现,从模型资源包里翻出一篇2023年C类获奖论文,团队编号2300348,题目是《Revitalizing a Classic Game: Uncovering the Secrets of Wordle through Data Analysis》。它把Wordle游戏数据拆成五个建模任务:预测每日报告数量、分析单词属性对得分的影响、预测特定单词EERIE的难度分布、用聚类给单词分难度等级,再额外挖两个数据特征。论文最值得抄的不是某个模型调得有多深,而是GRU、回归、箱线图、随机森林网格搜索、K-Means++被串成一条完整链路,每节都能改参数复现。适合找美赛C题全流程范式的人,也适合想系统走一遍数据清洗到模型评估的从业者。
2. 时间序列预测:GRU模型如何把报告数量误差压到2.16%
2.1 题目要你干什么:不是猜单词,是猜“报告数”
Wordle每天一个五字母单词,玩家最多猜六次,猜完可以把得分分布分享到社交平台。论文的第一个任务不是预测答案,而是预测未来某一天会有多少人把结果报告出来。这里的“Number of reported results”是单纯的时间序列,受星期几、节假日、玩家活跃度影响。论文拿2022年1月7日到12月31日的数据做样本,前80%训练、后20%测试,最终预测2023年3月1日的报告量。
这类单变量时序预测,传统做法用ARIMA、指数平滑,论文选了GRU(Gated Recurrent Unit)。它比LSTM少一个门,只有重置门和更新门,训练和推理速度更快;对样本量只有几百条的日度数据,GRU不容易像深层LSTM那样出现梯度消失。PyTorch的torch.nn.GRU封装很完整,几行代码就能搭一个可训练的预测网络,这也是论文选PyTorch的直接原因之一:动态计算图允许在运行时调整结构,对变长序列很友好。
动手复现时,我一般先把原始序列切成长度为14天的滑动窗口,代码这样写比较直接:
import numpy as np import torch def make_windows(series, window=14): X, y = [], [] for i in range(len(series) - window): X.append(series[i:i+window]) y.append(series[i+window]) return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) raw = np.loadtxt("reported_results.csv", delimiter=",") X, y = make_windows(raw, window=14) train_n = int(len(X) * 0.8) X_train, X_test = X[:train_n], X[train_n:] y_train, y_test = y[:train_n], y[train_n:] # GRU 输入维度为 (seq_len, batch, input_size) X_train = torch.from_numpy(X_train).unsqueeze(-1).permute(1, 0, 2) X_test = torch.from_numpy(X_test).unsqueeze(-1).permute(1, 0, 2)代码逻辑:make_windows把连续14天的报告量作为输入窗口,第15天作为标签。训练之后,模型看到最近14天数值,就能预测下一天。window=14相当于让模型学“近两周报告量的惯性”,窗口太大样本数减少,窗口太小抓不住周周期性。论文没有明确写窗口长度,但实际复现时14天是比较稳的选择;你也可以用交叉验证去试7、14、21。
注意GRU输入形状:PyTorch的nn.GRU默认输入张量是(seq_len, batch, input_size),而原始数组是(batch, seq_len),所以用permute(1,0,2)把维度换回来。第三维是1,因为每个时间步只有一个特征“报告量”。后面如果想把星期几、节假日虚拟变量加进来,把input_size改成2或3即可。
2.2 搭一个PyTorch GRU:网络结构、参数与训练
网络本身不复杂,我一般写成两层结构:GRU层接全连接层。论文也是这样,GRU最后一个隐状态接Linear,输出一个标量。
import torch.nn as nn class GRUPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=32): super().__init__() self.gru = nn.GRU(input_size, hidden_size, batch_first=False) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, _ = self.gru(x) # out shape: (seq_len, batch, hidden_size) last = out[-1] # 取最后时间步 return self.fc(last) model = GRUPredictor(input_size=1, hidden_size=32) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01)逻辑说明:GRU每个时间步都会输出隐状态,但预测第15天时只需要最后一步的hidden state,再接全连接层把维度压回1。hidden_size=32是复现时比较常见的默认值:日志序列本身短,隐藏维度过大(64或128)会明显过拟合;过小(8或16)又可能欠拟合。训练轮数建议控制在30到50轮并做早停,因为测试集只有20%,轮数多了模型会把节假日噪声一起背下来。
损失函数用MSELoss,优化器用Adam。Adam对学习率不敏感,但0.01算比较激进,0.005更稳;如果loss震荡,可以降到0.003。训练前建议把输入做标准化,常见做法是MinMaxScaler把报告量压缩到[0,1],否则GRU的tanh激活很容易饱和。
2.3 数据清洗:重算比例、修错词、统一口径
获奖论文真正值钱的是开篇的数据清洗部分。Wordle历史数据并不干净,论文明确指出三类错误:单词拼写错误,比如第314天把trash写成了tash;报告量录入错误,第529天把25569写成了2569;每日各猜中步数比例之和不等于100%。处理办法是修词、修数值、把每日比例重新归一化到100%。
复现这一步的清洗代码很简单:
import pandas as pd df = pd.read_csv("wordle_data.csv") # 拼写修复,改的是单词列 df["word"] = df["word"].replace({"tash": "trash"}) # 每日 1~6 次和 X 的比例归一化,确保每行合计 100% cols = ["p1", "p2", "p3", "p4", "p5", "p6", "pX"] df[cols] = df[cols].div(df[cols].sum(axis=1), axis=0) * 100逻辑:div().sum()按行求和,每个值除以当天总和再乘100,让七类比例严格等于100%。为什么必须这么干?因为后文的“得分百分比”是一个整体分布,某一天加起来只有95%,模型会误以为玩家活跃度下降,时序预测的窗口特征也跟着失真。这个归一化在论文里属于数据增强,不是可选项。
2.4 误差评估:相对误差率、RMSE和预测区间
训练完成后,论文给了两个关键指标:测试集相对误差率2.1569%,相对均方根误差(论文表2里写RESE,按公式看就是RMSE)6.4957%。RMSE除以原始数据均值得到一个相对值,统计上小于10%就算预测精度可接受。对日度活跃量预测来说,这个精度已经够用。
我复现时用下面这段代码重算:
import numpy as np y_pred = model(X_test).detach().numpy().ravel() y_true = y_test rel_error = np.mean(np.abs(y_pred - y_true) / y_true) rmse = np.sqrt(np.mean((y_pred - y_true) ** 2)) rel_rmse = rmse / np.mean(y_true) print(f"relative error: {rel_error:.4%}") print(f"relative RMSE: {rel_rmse:.4%}")这里有个细节必须提醒:论文提到12月25日圣诞节当天偏差接近50%,原因是当天报告量出现断崖式下降。如果把这个点留在测试集里,整体误差会非常难看。论文的处理是把它当作异常点剔除后再算误差,突出“正常日”的预测精度。复现时也要做同样的标注,否则很难对齐到2.1569%。更稳妥的做法是加入节假日虚拟变量,或者至少同时报告“含异常点”和“排除异常点”两种误差。
3. 单词属性分析:fword、fletter、rep、pos 到底谁在影响得分
3.1 三个连续属性,一个分类属性
得分百分比指的是玩家以1、2、3、4、5、6次尝试或失败X完成的分布。论文把“score”定义为该分布的一个综合指标,数值越大代表玩家总体猜中步数越多,也就是说这个单词越难。这个设计很聪明,把一堆百分比压缩成单个数值,才能做回归分析。
然后给每个单词算四个候选属性:
| 属性 | 含义 | 类型 | 与score的关系 | 论文结论 |
|---|---|---|---|---|
| fword | 单词整体词频 | 连续 | 皮尔逊相关系数 -0.3165 | 有影响 |
| fletter | 各字母常见频率之和 | 连续 | 皮尔逊相关系数 -0.4005 | 有影响 |
| rep | 重复字母模式(0/2/3) | 分类 | 箱线图中位数差 0.13004 | 有影响 |
| pos | 主要词性 | 分类 | 箱线图中位数差 0.05973 | 无影响 |
fword和fletter是连续变量,直接用回归分析;rep和pos是分类变量,用箱线图对比。数据里像ERIE这种连续两个E的单词,rep会提升,玩家看到重复字母后更容易在2、3次猜中,但也更容易在X(失败)上翻车。
关于词频,补充一个直觉:高频词如TRASH是日常词汇,玩家一眼就能看穿;低频词如EERIE则可能连拼写都陌生。fletter更直接,一个单词里有几个常见字母,玩家第一轮试错后的信息量完全不同。这两个变量被定义成连续值后,才有后面的皮尔逊相关性检验。
pos属性要复杂一点。单词的主要词性定义为名词、动词、形容词等。但玩家解题时并不真的关心词性,他们关心的是字母组合和位置,所以后续箱线图显示pos对得分的影响非常弱,也符合直觉。论文把它剔出模型是合理的。
3.2 回归分析:负相关系数怎么解读
fword和score的相关系数是-0.3165,fletter和score的相关系数是-0.4005。两者都是负相关。解释是:score越大的单词代表玩家越难猜,而词频越高或字母组合越常见的单词,玩家应对起来更轻松,得分应该更好。所以出现负相关符合逻辑。
回归分析在Python里用scipy一行就能算:
from scipy.stats import pearsonr rho_fword, p_fword = pearsonr(df["fword"], df["score"]) rho_fletter, p_fletter = pearsonr(df["fletter"], df["score"]) print(f"fword: r={rho_fword:.4f}, p={p_fword:.4g}") print(f"fletter: r={rho_fletter:.4f}, p={p_fletter:.4g}") alpha = 0.05 if p_fword < alpha and p_fletter < alpha: print("两个连续属性与得分相关显著")逻辑说明:皮尔逊相关系数衡量线性相关程度,负号表示方向,绝对值表示强度。-0.4属于中等偏弱相关,单独一个属性解释力有限,所以论文后续才用随机森林把fword、fletter、rep一起放入模型。p值在这里排除“纯靠随机碰出相关系数”的可能,论文没写具体p值,但按样本量推断,这个相关强度在MCM数据量级下通常是显著的。
常见误用是只看r值不看p值,更常见的是把负相关直接解释成“词频高导致得分低”。严格说法是“词频与得分存在负相关关系”,因果要留给后文的GSRF模型和敏感性分析。
3.3 箱线图对比:rep差出0.13,pos只有0.06,谁被踢出局
rep是分类变量,有重复字母的单词和没有重复字母的单词,得分分布用箱线图展示。论文报告中位数差异0.13004,说明有重复字母的单词会明显影响玩家表现。pos的中位数差异只有0.05973,差距太小,不足以说明不同词性之间存在稳定差异。结论清晰:fword、fletter、rep进入GSRF,pos被去掉。
复现这一步的代码:
import matplotlib.pyplot as plt import seaborn as sns # rep 分组箱线图 plt.figure(figsize=(6, 4)) sns.boxplot(x="rep", y="score", data=df) med_diff_rep = df.groupby("rep")["score"].median().max() - df.groupby("rep")["score"].median().min() print(f"rep 中位数差异: {med_diff_rep:.5f}") # pos 分组箱线图 plt.figure(figsize=(6, 4)) sns.boxplot(x="pos", y="score", data=df) med_diff_pos = df.groupby("pos")["score"].median().max() - df.groupby("pos")["score"].median().min() print(f"pos 中位数差异: {med_diff_pos:.5f}")参数说明:groupby("rep")["score"].median()得到每个组的中位数,max减去min就是论文里的“Median difference”。0.13004与0.05973的对比要建立在同一量纲上,这两个变量的得分尺度相同,直接比较没有问题。如果箱线图出现大量离群点,说明该分组里混入了特殊高难度单词,这时候中位数比均值更稳健,这也是论文选箱线图而不是柱状图的理由。
4. GSRF与K-Means++:从预测分布到难度分级
4.1 为什么选随机森林:表格数据的稳健默认答案
第三章筛选出三个有效特征:fword、fletter、rep。现在的任务是预测单词EERIE在一月一日当天的得分百分比分布,输出有七个数:1次、2次、3次、4次、5次、6次和X的概率。这是多输出回归问题。
为什么用随机森林而不是多层感知机?因为样本量不大,特征只有三个,树模型对量纲和离群点不敏感,不需要像神经网络那样精细调参;随机森林还能输出特征重要性,方便论文写解释。
“GSRF”全称是Grid-Search Random Forest,先让网格搜索找最优超参数组合,再在最优组合上训练随机森林。超参数一般包含n_estimators(树的数量)、max_depth(最大深度)、min_samples_split(最小分裂样本数)。论文用“best combination of hyperparameters”这个说法,本质上就是GridSearchCV干的事情。
4.2 网格搜索代码:一套可以直接跑的GSRF
下面这段代码按论文思路复刻:
from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GridSearchCV X = df[["fword", "fletter", "rep"]].values y = df[["p1", "p2", "p3", "p4", "p5", "p6", "pX"]].values param_grid = { "n_estimators": [100, 200, 400], "max_depth": [5, 10, 20, None], "min_samples_split": [2, 5, 10], } model = RandomForestRegressor(random_state=42) gsrf = GridSearchCV(model, param_grid, cv=5, scoring="neg_mean_squared_error") gsrf.fit(X, y) print("best params:", gsrf.best_params_) print("best MSE:", -gsrf.best_score_)逻辑说明:GridSearchCV对每组参数做5折交叉验证,评估指标用负MSE,因为sklearn的scoring接口里“损失越小越好”对应负数。论文报告的训练MSE为20.70641,MAE为3.24388,说明最优组合的预测误差大约在单个百分比点。多输出随机森林天然支持y为七列二维数组,不需要额外包装。
参数说明:cv=5意味着每棵组合都会被训练5次,如果n_estimators取到400棵,训练时间会成倍增加;但这份数据只有几百条,开销可接受。复现时如果发现best_params总是顶到搜索边界,比如n_estimators在400还是最优,应扩大边界而不是直接取边界值,否则可能错过真正的全局最优。
4.3 EERIE的预测分布:七个数字怎么读
模型对EERIE的预测结果是(1, 7, 23, 30, 23, 13, 3),单位是百分比。对应关系:1次猜中占1%,2次占7%,3次占23%,4次占30%,5次占23%,6次占13%,X(六次内没猜中)占3%。七个数加起来刚好100。
翻译成人话:EERIE在预测当天会有近七成玩家在3到5次内解决,最可能的单点是4次,占比30%。这个分布有几个值得注意的地方:第一,1%和X%都很少,说明对生僻词来说,极容易和极困难的玩家都占少数,大多数人卡在中间步数;第二,EERIE有两个E,属于rep=2的类别,第三章已经证明重复字母模式会推高难度;第三,这是单日预测值,不是平均效应,用于编辑决策时还要考虑当天是星期几。
4.4 K-Means++难度分级:δ定义、五级聚类与93.33%匹配率
论文还做了难度分级模型。先定义难度率δ,把每个单词的得分百分比分布压缩成单值,EERIE的δ算出为0.35916。接着用K-Means++把所有单词的δ聚成五个难度等级,EERIE落在第三级。为了验证分级不是拍脑袋,论文抽了一部分单词与人工评委评分对比,匹配率达到93.33%,说明聚类边界与人类直觉基本一致。
K-Means++在sklearn里一行就能调:
from sklearn.cluster import KMeans delta = df["difficulty_rate"].values.reshape(-1, 1) kmeans = KMeans(n_clusters=5, init="k-means++", random_state=42, n_init=10) labels = kmeans.fit_predict(delta) # 把聚类序号排序,让 1 最易、5 最难 order = np.argsort(kmeans.cluster_centers_.ravel()) label_map = {old: new + 1 for new, old in enumerate(order)} difficulty_levels = np.array([label_map[l] for l in labels]) # 与人工评分对比 manual = df["manual_level"].values match_rate = np.mean(difficulty_levels == manual) print(f"match rate: {match_rate:.2%}")逻辑说明:K-Means++是K-Means的改进版,初始化时让中心点彼此距离尽可能远,避免随机落到局部最优。δ只有一维,聚类轮廓很容易解释,但有一个很关键的点:K-Means输出的簇编号是无序的,必须按中心点大小重新映射,最小中心点对应等级1、最大中心点对应等级5。如果漏掉这步,直接把labels和人工评分比较,匹配率可能只有二成,这是我复现时踩得最深的坑之一。
n_init=10表示从10组不同初始中心选最优解。对一维数据,这个值可以小一点;但保留10也不是坏习惯。93.33%的匹配率在美赛论文里是相当漂亮的数字,因为人工评分本身就有主观性。
5. 避坑与常见问题:复现获奖论文时最容易翻车的四个位置
5.1 “tash”还是“trash”:脏数据直接改写预测结果
现象:原始数据里第314天的单词被写成tash,第529天的报告量写成2569。如果不过滤直接进入建模,fword、fletter都会算错,相关系数偏移,GRU窗口也被污染。
原因:题目提供的是真实抓取记录,有拼写错误、录入错误和口径错误。真实数据永远需要清洗,不是拿来就能训练的。
解决:复现第一件事就是数据体检。写一个校验脚本,检查每个单词长度是否为5、是否在合法词表内、每天七类比例之和是否接近100%。发现tash直接replace成trash,发现比例异常按第2.3节的div代码重新归一化。复现获奖论文不是从建模开始,是从数据清洗开始,顺序反了后面全崩。
5.2 圣诞节偏差50%:这个异常点删还是不删
现象:GRU预测12月25日当天的报告量,偏差接近50%。
原因:圣诞当天玩家社交行为明显变化,报告量断崖式下跌,这在平日数据里几乎没出现过。GRU学到的是“近期惯性”,遇到结构性断点,预测值自然落后。
解决:论文把12月25日作为异常点剔除后再算误差。复现时要保留数据做窗口特征,但评估要分两本账:报告“含异常点”和“排除异常点”两种误差。如果想做得更扎实,可以加节假日虚拟变量,让模型显式感知这一天和普通周日不同。如果你复现误差偏离2.1569%,先检查自己是否处理了这个点。
5.3 不调超参数就跑随机森林,模型成了黑匣子
现象:直接用默认参数训练随机森林,MSE比论文高出一截,甚至每次运行结果都不稳定。
原因:默认参数n_estimators=100、无深度限制,在只有几百条样本的小数据集上容易过拟合;同时随机种子不同会让结果波动很大,表现不可复现。
解决:严格按论文的GSRF流程做。至少把n_estimators、max_depth、min_samples_split三个参数做网格搜索,cv=5交叉验证,设置random_state=42。我一般还会加一个嵌套交叉验证:外层评估模型在未见数据上的性能,内层选超参数,避免在测试集上反复调参造成信息泄漏。
5.4 相关系数-0.4不是因果,别在论文里写“因为”
现象:有复现者把fletter与score的-0.4005直接写成“字母频率导致玩家得分变差”,被评委质疑结论过强。
原因:相关系数只能说明两个变量线性相关,不说明因果关系。低频词往往也更生僻,二者可能都被第三个因素驱动,比如词形复杂度、是否常见拼写模式。
解决:论文的做法是先回归,再用GSRF做多变量预测,最后用高斯噪声做敏感性分析,整个证据链是“相关+预测+稳定”而不是“因果”。复现时可以写“负相关表明字母频率与得分呈反向关系”,但结论要落回“该属性对预测模型有效”;如果想支撑因果,至少控制其他属性后看偏回归系数,或做一个线性回归报告系数和显著性。
6. 从复现到结论:验证关键数字,补上敏感性分析,向编辑讲清结论
6.1 对齐三组关键数字
复现是否成功不该拍脑袋,把论文的指标逐个对一遍:GRU相对误差率2.1569%、相对RMSE 6.4957%;GSRF的MSE 20.70641、MAE 3.24388;K-Means++匹配率93.33%。我复现时用一个脚本一次性输出这五个数值:
checks = { "gru_rel_err": rel_error, "gru_rel_rmse": rel_rmse, "gsrf_mse": best_mse, "gsrf_mae": best_mae, "kmeans_match_rate": match_rate, } for k, v in checks.items(): print(k, v)如果某个数值和论文差距超过一个量级,先回来查数据清洗,再看是否漏了异常点处理。对上了,再往下写结论。
6.2 敏感性分析:给输入加高斯噪声
GSRF模型要让人信服,论文还做了敏感性分析,分别给fword和fletter叠加高斯噪声,观察预测分布是否大幅波动。复现代码很轻量:
noise = np.random.normal(0, 0.05, size=X.shape[0]) X_noise = X.copy() X_noise[:, 0] += noise pred_clean = gsrf.predict(X) pred_noise = gsrf.predict(X_noise) delta = np.abs(pred_clean - pred_noise).mean() print("mean absolute shift:", delta)逻辑说明:噪声标准差0.05对应词频测量中很小的扰动;如果平均偏移只有小数点后几位,说明模型对输入波动不敏感。论文结论是模型低敏感、稳定,正是靠这个实验撑起来的。
6.3 把结论翻译成业务语言:一封给“填字游戏编辑”的信
论文最后写了一封给填字游戏编辑的信。很多同学觉得这是凑字数,其实这是美赛C题拿分的关键:把模型结论转成业务动作。“PARER为什么最难”“EERIE难度分级第三级”“硬模式数据和普通模式只差1%”这些发现不是炫技,而是告诉编辑哪些词能留住玩家、哪些词会打击信心。
从那以后我每次复现获奖论文,拿到资源包都会强制走一遍这个流程:先数据清洗,再对齐三项关键指标,最后把模型输出翻译成一句可以写给业务方的话。如果一句话都写不出来,说明复现还没到位。希望帮到你。
本文还有配套的精品资源,点击获取