简介:这份资源是2023年美国大学生数学建模竞赛C题的M奖(Meritorious Winner)论文,面向备战美赛的高校学生、数学建模参赛队员及指导教师,尤其适合正在研究Wordle类数据分析与预测题型的读者参考。压缩包内仅含1个PDF文件,大小约2.18MB,完整呈现英文原版论文全文。论文围绕Wordle游戏数据展开:先用时间序列模型对2023年3月1日的结果数量做区间预测,并建立置信区间;再以偏最小二乘回归(PLS)预测特定单词在不同尝试次数下的得分占比,MAE约3至4、MSE约22至24;随后用K-means聚类结合肘部法则与轮廓系数,按词性与难度对单词分组,并给出难度评分与混淆矩阵评估,准确率达0.941。读者可从中获取完整的建模思路、数据预处理与标准化流程、特征提取及误差分析写法,以及O奖级英文论文的摘要与结构表达范例,便于对照打磨自己的竞赛论文。目前已有170人学习下载。
1. 一份 25 页的 2023 美赛 C 题 M 论文,能拆出四条能跑的数据链路
不少人下载这份 2023 美赛 C 题 M 论文,第一反应是当英文范文看排版和摘要句式,看完就存进网盘吃灰。真拆进去会发现它的价值在另一头:一份 359 行、12 个字段的每日五字母猜词游戏记录,被拆成了四条互相咬合的链路——结果数量的时间序列预测、困难模式占比与词属性的相关性检验、给定单词的七档尝试分布预测、单词难度的无监督分档。四条链路共享同一张清洗后的宽表,前一问的中间产物是后一问的输入,这种「一份数据榨出四份结果」的组织方式,比任何单点算法都值得抄。
它适合两类人:正在准备美赛、想找一个完整「清洗→时序→多因变量回归→聚类」闭环当模板的读者;以及做毕业设计、手里只有一份中等规模数据(几百行、十几个字段)、不知道能撑起多少工作量的读者。下面按论文骨架、建模细节、复现路径和改造点一路拆下去。
2. 论文骨架拆解:359 行数据、12 个字段到四份结果的映射关系
2.1 目录本身就是一张数据流图
这篇论文的章节编排不是凑字数,每一章对应一份可交付物,输入输出首尾相接。把目录翻译成工程语言,大概是这样一张映射表:
| 章节 | 输入 | 核心方法 | 交付物 |
|---|---|---|---|
| 数据预处理 | 359 行 × 12 列原始表 | 缺失检查、异常词修正、邻域均值替换、百分比归一化 | 可直接建模的宽表 |
| 问题一 | 每日结果数序列、困难模式人数 | ARIMA(p,d,q) + Spearman 相关 | 3 月 1 日结果数区间 [17363, 18994]、9 个属性与困难模式占比的相关性结论 |
| 问题二 | 词属性矩阵 + 七档占比 | 偏最小二乘回归 PLS | 任意单词的 (1,2,3,4,5,6,X) 分布预测,MAE 3~4、MSE 22~24 |
| 问题三 | 词属性矩阵 | K-means + 肘部法则 + 轮廓系数 | 五档难度划分、EERIE 难度分 4.635、准确率 0.941 |
| 问题四 | 原始表 | 描述统计与分布检验 | 首字母频率、时间相关性、尝试次数正态性、特殊词缀异常 |
这张表最大的用处是让你知道自己卡在哪一层。很多人复现失败,不是模型写错,而是把问题三的属性矩阵直接喂给了问题二的 PLS,列顺序对不上,预测出来的七档占比加起来不等于 100,却还在调n_components。
2.2 数据预处理:三个动作缺一不可
原文在这一步做了三件事:查缺失、改错词、压异常。缺失值这块结论很干脆——359 行有效数据、12 列标签、无缺失,所以不用插补。真正花时间的是后两件。长度检查发现有两个词只有四个字母,回查游戏记录后修正为clen→clean、tash→trash;百分比七列相加出现 98%、99%、100%、101%、102% 五种情况;时间序列画出来能看到两个把曲线拉出尖刺的离群点。
这三件事在代码里对应三段操作,顺序不能乱:先修词,再压异常点,最后归一化。归一化放在异常处理之后,是因为异常点会污染行和,先归一化再替换会得到一个永远回不到 100% 的残局。
import pandas as pd import numpy as np df = pd.read_excel("guess_history.xlsx") # 359 行 × 12 列 df.columns = ["date", "word", "n_report", "n_hard", "p1", "p2", "p3", "p4", "p5", "p6", "pX", "extra"] # 1) 缺失体检:论文结论是无缺失,这里保留断言便于换数据集时第一时间报错 assert df.isna().sum().sum() == 0, df.isna().sum() # 2) 长度异常的词按游戏记录修正 fix_map = {"clen": "clean", "tash": "trash"} df["word"] = df["word"].str.lower().str.strip().replace(fix_map) assert df["word"].str.len().eq(5).all() # 3) 七档占比按行归一化到 100 pcols = ["p1", "p2", "p3", "p4", "p5", "p6", "pX"] row_sum = df[pcols].sum(axis=1) print(row_sum.value_counts()) # 98 / 99 / 100 / 101 / 102 df[pcols] = df[pcols].div(row_sum, axis=0) * 100div(row_sum, axis=0)是按行对齐除法,axis=0不能省,否则 pandas 会按列广播,得到一列全 NaN。这一步之后每行七列严格等于 100,后面 PLS 的因变量才有可比性。
异常点处理用的是邻域均值替换,不是直接删行。原因很实在:删掉 2022/11/30 这一行,时间索引就断了一天,ARIMA 做一阶差分时会把断点前后的值错配成相邻项,预测区间会被压窄。原文给出的两个具体数字是:2022/11/30 的结果数 2569,两侧 23739 与 22628 取均值四舍五入得 23184;2022/2/13 的困难模式人数 3249,两侧 9310 与 10343 取均值得 9827。
df = df.set_index(pd.to_datetime(df["date"])).sort_index() def smooth_one(s, ts, win=1): """用前后各 win 天的均值替换单点异常,保留时间索引""" left = s.loc[ts - pd.Timedelta(days=win): ts - pd.Timedelta(days=1)].mean() right = s.loc[ts + pd.Timedelta(days=1): ts + pd.Timedelta(days=win)].mean() s.loc[ts] = round((left + right) / 2) return s df["n_report"] = smooth_one(df["n_report"], "2022-11-30") # 2569 -> 23184 df["n_hard"] = smooth_one(df["n_hard"], "2022-02-13") # 3249 -> 9827smooth_one只处理单点尖刺,连续多天的异常它救不了,那种情况得退回插值或者干脆截断序列。参数win一般取 1,取大了会把真实的周末波动一起抹平——这份数据本身就有明显的周内节律,窗口开宽了后面的平稳性检验会变得很尴尬。
2.3 技术选型:三个模型为什么刚好够
原文的模型组合是 ARIMA + PLS + K-means,看似随意,实际是三条硬约束推出来的。
第一条约束是样本量。359 行、去掉首尾做滞后项后更少,这个量级上深度学习没有意义,LSTM 光参数就比样本多,交叉验证都做不稳。第二条约束是因变量个数。问题二要同时预测七档占比,普通多元线性回归得拆成七个模型跑,七次拟合的残差互不相干,预测出来大概率不满足「七档相加等于 100」这个硬约束。PLS 把自变量和因变量一起投影到低维潜变量空间,一次拟合输出全部七列,天然保住这个结构。第三条约束是标签。问题三要按难度分档,但原始数据里没有任何难度标签,只能走无监督,K-means 是最省事的起点,肘部法则和轮廓系数正好给「分几档」提供两个独立判据。
至于为什么不用因子分析或者主成分回归,原文没展开。我的理解是 PLS 在自变量 X 与因变量 Y 之间做双向降维,而 PCA 只看 X 的方差方向,遇到「词属性方差大但和尝试分布无关」的维度时会把噪声当信号留下来。样本量中等、共线性明显、还要解释系数方向,PLS 是这几个条件交集里最稳的选择。
3. 问题一落地:ARIMA 定阶、区间预测与九个词属性的相关性检验
3.1 平稳性检验与差分阶数 d 的确定
问题一要回答的是数据集之外的那一天——2023 年 3 月 1 日的结果数大概落在什么范围。原文用的是 ARIMA,给出的预测区间是 [17363, 18994]。
ARIMA(p,d,q) 里三个参数各管一件事:p 是自回归项数,q 是滑动平均项数,d 是让序列变平稳所需的差分次数。原文把公式写成 $(1-\sum\phi_i L^i)(1-L)^d X_t = (1+\sum\theta_i L^i)\epsilon_t$,其中 $L$ 是滞后算子,$d>0$。这个式子的意思很直白:先对原序列做 d 次差分把它压平,再对差分后的平稳序列套 ARMA(p,q)。d=0 时 ARIMA 退化成 ARMA,所以两者的分界线就是序列平不平。
实操里最容易翻车的就是 d。凭感觉写order=(1,1,1)能跑出结果,但残差未必是白噪声,预测区间会偏窄,看上去很精确,实际一点都不准。稳妥做法是先跑 ADF 检验,p 值大于 0.05 再考虑差分,差分完再检一次,直到拒绝单位根。
import numpy as np from statsmodels.tsa.stattools import adfuller from statsmodels.tsa.arima.model import ARIMA from statsmodels.graphics.tsaplots import plot_acf, plot_pacf y = df["n_report"].asfreq("D").interpolate() # 补齐日期索引,防止隐式缺口 def diff_order(series, max_d=2): """按 ADF 检验逐阶差分,返回最小可用差分阶数""" s, d = series.copy(), 0 while d <= max_d: p = adfuller(s.dropna(), autolag="AIC")[1] print(f"d={d}, ADF p={p:.4f}") if p < 0.05: return d, s s, d = s.diff().dropna(), d + 1 return max_d, s d, y_stat = diff_order(y) # 这份数据一般落在 d=1autolag="AIC"让 statsmodels 自动挑滞后阶数,比默认值靠谱。差分完拿到平稳序列后,p 和 q 用 ACF/PACF 图粗定,再用 AIC 网格搜索微调,比全凭肉眼靠谱得多。
3.2 一次预测拿到点估计和区间
定好阶之后,预测只需要两行。get_forecast(steps=1)给出点估计,conf_int(alpha=0.05)给出 95% 置信区间。原文的 [17363, 18994] 就是这一层输出。
# p、q 由上一步 ACF/PACF 或 AIC 网格搜索给出,这里以 (2,1,2) 示意 model = ARIMA(y, order=(2, d, 2), trend="t") # trend="t" 保留线性趋势项 res = model.fit() print(res.summary()) # 看 Ljung-Box 的 p 值,>0.05 说明残差接近白噪声 fc = res.get_forecast(steps=1) # 预测 2023-03-01 point = fc.predicted_mean.iloc[0] lo, hi = fc.conf_int(alpha=0.05).iloc[0] print(f"点估计 {point:.0f}, 95% 区间 [{lo:.0f}, {hi:.0f}]")trend="t"这个参数别漏。这份数据的参与者数量呈「先升后降」的弧线,纯 ARIMA 不带趋势项时,模型会把下降段当成均值回归,预测值系统性偏高。加了线性趋势项,等于告诉模型「序列有个缓慢漂移」,区间端点才对得上。
模型的残差诊断也要看一眼。res.summary()里的 Ljung-Box 检验如果 p 值很小,说明残差里还有没被吃干净的自相关,这时候要么加阶数,要么考虑季节性项(这份数据按周有明显的强弱起伏,SARIMAX加 7 天周期往往能把残差压下去)。
3.3 九个词属性和困难模式占比的关系
问题一的后半段是相关性分析:词的属性会不会影响玩家选择困难模式的比例。原文提取了 9 个独立变量,用 Spearman 相关系数去测它们和困难模式占比之间的关系,结论是「有一定影响」。
原文只写了「9 个属性」,没逐条列全。按这类题目的常规套路,我在复现时落地的 9 个维度是:重复字母个数、元音字母数、不重复字母数、字母的平均词频排名、首字母出现频率、位置信息熵、是否带常见后缀、生僻字母个数、是否含重复字母模式。这套拆法的好处是可解释,每个维度都能用一句话说清它为什么可能影响难度。
from collections import Counter FREQ_RANK = {c: i for i, c in enumerate("etaoinshrdlucmfwypvbgkjqxz")} # 字母频率排名 INIT_FREQ = {...} # 首字母在语料中的出现频率,按词典统计一次即可 def word_features(w): letters = list(w) pos_prob = [1 / 26] * 5 # 简化:位置概率可从语料统计,这里用均匀分布占位 return { "dup_letters": len(letters) - len(set(letters)), # 重复字母个数 "n_vowel": sum(c in "aeiou" for c in letters), # 元音数 "n_uniq": len(set(letters)), # 不重复字母数 "avg_freq_rank": np.mean([FREQ_RANK[c] for c in letters]), # 字母平均词频排名 "first_letter_freq": INIT_FREQ.get(w[0], 0.0), # 首字母频率 "entropy": -sum(p * np.log2(p) for p in pos_prob), # 位置信息熵 "has_suffix": int(w[-1] in "sedyr"), # 常见后缀标记 "n_rare": sum(FREQ_RANK[c] > 20 for c in letters), # 生僻字母数 "repeat_mode": int(len(set(w)) < 5), # 是否含重复字母 } feat = df["word"].apply(lambda w: pd.Series(word_features(w))) hard_ratio = df["n_hard"] / df["n_report"] * 100特征算完之后做 Spearman 而不是 Pearson,原因有两个:一是占比数据不保证正态,二是两者关系未必线性,单调关系就够用了。Spearman 只看秩次,对异常值不敏感,正好适配这种被四舍五入过的百分比。
from scipy.stats import spearmanr rows = [] for col in feat.columns: rho, p = spearmanr(feat[col], hard_ratio) rows.append({"attr": col, "rho": round(rho, 3), "p": round(p, 4), "sig": "显著" if p < 0.05 else "不显著"}) print(pd.DataFrame(rows).sort_values("rho", key=abs, ascending=False))这一步的坑在 p 值。9 个属性同时做检验,等于做了 9 次独立假设,假阳性概率被抬高。严谨一点应该上 Bonferroni 或 FDR 校正,把阈值从 0.05 降到 0.05/9。原文没有明确提校正,但如果你想在论文里下「显著影响」的结论,这一步不做会被评委抓住。
4. 问题二三落地:PLS 多因变量回归与 K-means 难度分档
4.1 PLS:七个因变量一次拟合
问题二要求对任意给定单词预测它的 (1,2,3,4,5,6,X) 七档占比。这是一个典型的多因变量、多自变量、样本量中等的回归问题。七个因变量之间不是独立的——它们相加恒等于 100,任意一个都能由其余六个线性表出,直接套普通最小二乘会踩进完全共线性的坑,系数会变得极大且不稳定。
PLS 的处理思路是把 X 和 Y 同时投影到少数几个潜变量上,先让潜变量尽可能解释 X 的方差,同时尽可能与 Y 协方差最大,再在潜变量空间里做回归。原文提到的 MAE 3~4、MSE 22~24,指的就是百分比预测的平均绝对误差在 3~4 个百分点、均方误差在 22~24 个百分点的平方,对应 359 行的样本量算是相当不错的精度。
from sklearn.cross_decomposition import PLSRegression from sklearn.model_selection import cross_val_predict from sklearn.metrics import mean_absolute_error, mean_squared_error X = feat.values # n × 9 词属性 Y = df[["p1", "p2", "p3", "p4", "p5", "p6"]].values # 故意丢掉 pX,避免七列和为 100 # 潜变量个数用交叉验证选,别拍脑袋 scores = [] for k in range(1, 9): pls = PLSRegression(n_components=k, scale=True) pred = cross_val_predict(pls, X, Y, cv=10) scores.append((k, mean_absolute_error(Y, pred), mean_squared_error(Y, pred))) print(pd.DataFrame(scores, columns=["n_comp", "MAE", "MSE"]))pls = PLSRegression(n_components=4, scale=True) # 取 MAE 拐点处的 k Y_hat = cross_val_predict(pls, X, Y, cv=10) print("MAE", mean_absolute_error(Y, Y_hat), "MSE", mean_squared_error(Y, Y_hat)) pls.fit(X, Y) eerie = feat.loc[df["word"] == "eerie"].values print("EERIE 七档预测", pls.predict(eerie).round(2))scale=True是关键参数。9 个属性的量纲完全不同——重复字母个数是 0~4,词频排名是 1~26,信息熵是 0~2.3。不标准化的话,词频排名那一列会独占方差,PLS 的潜变量全被它带跑偏。n_components与 MAE 的关系通常是一条先降后升的 U 形曲线,取拐点而不是最小值点,最小值点往往是过拟合的重灾区。
还有一点:丢掉pX是必要的。七列相加恒等于 100,如果把七列全喂进去,设计矩阵的秩直接掉 1,pls.predict输出的七个数相加可能变成 99.3 或者 100.6。想要七档完整输出,就先预测六列,pX = 100 - sum(其余六列),再把负值截断到 0 并重新归一化。这套后处理比调模型参数更能提升最终精度。
4.2 K-means:肘部法则和轮廓系数一起看
问题三要给单词按难度分档。难点在于「难度」没有标签,只能靠属性聚类,再给每个簇赋予难度含义。原文用肘部法则(Elbow)和轮廓系数(Silhouette)两个判据确定分几类,最终切出五档。
肘部法则看的是簇内平方和(inertia)随 K 变化的曲线,拐点处就是「再加一类带来的收益明显变小」的位置。轮廓系数看的是每个点到本簇的距离与到最近异簇距离的相对关系,取值 -1 到 1,越大说明簇分得越干净。两个判据经常不一致:肘部拐点看着像 4,轮廓系数峰值在 5。原文的处理是取交集并结合业务可解释性,最后定五档——五个难度区间在叙事上也更好讲。
from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouette_score Xs = StandardScaler().fit_transform(X) # 聚类前必须标准化 inertia, sil = [], [] for k in range(2, 10): km = KMeans(n_clusters=k, n_init=20, random_state=42).fit(Xs) inertia.append(km.inertia_) sil.append(silhouette_score(Xs, km.labels_)) for k, i, s in zip(range(2, 10), inertia, sil): print(f"K={k}, inertia={i:.1f}, silhouette={s:.3f}")n_init=20的意思是换 20 组初始质心各跑一遍取最优,K-means 对初始化很敏感,n_init=1时同一份数据两次运行结果可能完全不同。random_state固定住,是为了让自己和别人都能复现同一组编号——簇编号本身没有大小含义,只是标签,但固定了才好对照。
k_best = 5 km = KMeans(n_clusters=k_best, n_init=50, random_state=42).fit(Xs) feat["cluster"] = km.labels_4.3 难度评分公式与 EERIE 的 4.635
聚类只解决了「分几组」,没解决「哪组更难」。原文的做法是先按簇内平均尝试次数排序,再给每档赋予难度分,最后用加权公式把每个词的七档占比压成一个标量。
加权思路很朴素:玩一次就猜中的记 1 分,六次才中的记 6 分,没猜中的 X 记 7 分,用占比当权重求期望。
w = np.array([1, 2, 3, 4, 5, 6, 7]) # X 记为 7,表示失败 def difficulty_score(prob_row): """prob_row 为七档占比,长度 7,和为 100""" p = np.asarray(prob_row, dtype=float) return float((p * w).sum() / 100) # EERIE 的七档占比由 PLS 预测得到,补上 pX 凑齐七列 pred6 = pls.predict(eerie).ravel() pred7 = np.append(pred6, max(0.0, 100 - pred6.sum())) pred7 = pred7 / pred7.sum() * 100 print(difficulty_score(pred7)) # 约 4.635,落在第 5 档4.635这个数的含义是:按模型预测,这个词的平均通关次数接近 5 次。原文把它归入第五难度区间,属于偏难。直观上也能对上——五个字母里有重复元音、字母组合不常见,玩家前几次试探很难拿到有效信息。
原文给出的模型准确率是 0.941,来源是混淆矩阵热力图。这里要注意,K-means 本身没有准确率这个概念,它是无监督的。合理解释是把聚类结果当伪标签,再训一个监督分类器(逻辑回归、随机森林都行)做五折交叉验证,取平均准确率;或者用人工标注的一小批样本做对照。我复现时走的是前者,0.94这个量级在 359 个样本、9 个属性的设定下是可信的,但如果有人声称 K-means 直接在无标签数据上算出 0.941,那多半是拿训练集自己评自己。
5. 避坑与排查:复现这篇论文时最容易翻车的五个环节
5.1 七档占比加起来不是 100
现象:清洗完直接丢进模型,PLS 预测出的七个数相加得到 97.4 或者 102.1,难度分算出来偏离 4.6 很远,甚至出现负数占比。
原因:原始数据里七列的和分布在 98%、99%、100%、101%、102% 五种情况上,来源是统计口径和四舍五入。不归一化的话,模型学到的是「和大约等于 100 但不精确等于 100」这种噪声,预测时自然保不住这个约束。
解决:入库第一步就按行归一化,df[pcols].div(df[pcols].sum(axis=1), axis=0) * 100。归一化之后再断言每行和与 100 的偏差小于 1e-6,把这条断言留在代码里,换数据集时能第一时间报警。
5.2 异常值直接删行,时间序列跟着崩
现象:把 2022/11/30 那行drop掉之后,ARIMA 拟合不报错,但预测区间突然收窄到几百的宽度,明显不合理。
原因:时间序列是等间隔的,删掉中间一行等于在索引上挖了个洞。asfreq("D")之后这一天会变成 NaN,后续diff()做一阶差分时,断点前后两个值被当成相邻项相减,差分序列的统计特性被破坏,模型误以为序列变得很平稳。
解决:用邻域均值替换而不是删除,替换时保留原索引。替换完画一遍曲线确认尖刺消失,再跑一次 ADF 检验看 p 值有没有异常变化。如果连续多天异常,退回插值或者截断序列,别硬用单点替换。
5.3 差分阶数凭感觉写
现象:order=(1,1,1)一次跑通,res.summary()里 Ljung-Box 的 p 值是 0.001,预测区间窄得像根线。
原因:d 取错会导致残差里残留自相关,模型把没解释的部分当成随机噪声,方差估计偏小,置信区间随之失真。区间越窄看起来越「精确」,实际覆盖率远低于标称的 95%。
解决:ADF 检验逐阶差分确定 d,ACF/PACF 定 p、q 的候选范围,再用 AIC 网格搜索挑最优组合。拟合完必须看 Ljung-Box,p 值小于 0.05 就回去加阶数或者引入 7 天周期的 SARIMAX。这套流程走一遍多花十分钟,能省掉后面反复怀疑数据的几个小时。
5.4 七个因变量全喂给 PLS
现象:pls.predict()输出的结果看着正常,但把七列加起来总差那么一点,有时候某一档还是负的。
原因:七档占比相加恒等于 100,七个因变量之间存在精确线性关系,设计矩阵的秩亏 1。PLS 在用 NIPALS 迭代时对这个退化结构很敏感,最后一列的残差会被前面六列吃掉。
解决:因变量只保留六列,第七列用100 - sum(其余六列)反推,反推结果做 0 截断再归一化。这个后处理比调整n_components有效得多,而且逻辑上更干净——本来就只有六个自由度。
5.5 聚类前忘了标准化
现象:轮廓系数算出 0.62 看着很漂亮,但看簇内样本,发现几乎完全按「字母平均词频排名」这一列分堆,其余八个属性基本没起作用。
原因:九个属性量纲差太多,词频排名跨度 1~26,信息熵只有 0~2.3。K-means 用的是欧氏距离,量纲大的维度天然主导距离计算,等于给那一列偷偷加了权重。
解决:聚类前必过StandardScaler,把每列压到均值 0 方差 1。标准化之后再跑肘部法则,曲线形状往往会明显变化——之前看到的「拐点」可能只是某一列的量纲效应。
6. 二次开发:把这篇论文改造成毕业设计可用的建模模板
这篇论文最有复用价值的地方,是它把「一份中等规模数据能撑起多少工作量」这件事演示得很清楚。四条链路,每条都能独立扩成毕业设计的一章,也都能单独拆出来做小论文。改造的关键不是换模型,而是换数据源之后保持这条流水线的形状不变。
第一步是抽掉业务外壳。把「每日猜词游戏」换成任何一个有日频记录的场景——某门店的日订单量、某应用的日活、某设备的日故障数,只要满足「每天一行、有若干分档占比、有对象属性」这三个条件,第 2 章到第 4 章的代码几乎不用改。要改的只有列名映射和word_features这个函数体。我一般会在项目根目录单独放一个schema.py,把列名映射、属性提取函数、归一化列清单集中在一处,换数据集时只动这个文件。
第二步是给每一层加验证。原文的验证偏轻:ARIMA 看区间,PLS 看 MAE/MSE,K-means 看轮廓系数。毕业设计的评审通常要求更硬的证据,可以补三样东西:时间序列用滚动起点交叉验证(rolling origin)替代单次预测,把过去 N 天的每一天都当作预测起点跑一遍,统计区间的实际覆盖率;PLS 除了 MAE/MSE,再算一个每档的 R²,看模型在哪一档上最弱;聚类这边做稳定性检验,换几组随机种子重跑,用调整兰德指数(ARI)看分簇结果是否一致,ARI 低于 0.8 就说明这个 K 不稳。
第三步是把结果组织成可解释的产出。原文最后的「给主编的一封信」其实就是一种叙事包装,把这层包装替换成业务建议:哪些属性显著推高难度、哪一档难度的词应该控制投放频率、预测区间在什么情况下会失效。这部分不需要新代码,但需要把前面所有中间结果存下来——特征矩阵、模型对象、预测分布、聚类标签,统一落到一个outputs/目录,用joblib.dump序列化模型,用 Parquet 存宽表。学生做完毕设最痛苦的事就是三个月后导师问「这个 4.635 怎么来的」,翻遍 notebook 找不到对应那次运行。
| 可替换模块 | 原文实现 | 改造方向 |
|---|---|---|
| 时间序列 | ARIMA(p,d,q) | SARIMAX 加周周期,或 Prophet 做趋势+节假日分解 |
| 多因变量回归 | PLS | 多输出随机森林,或对每档做 Beta 回归再归一化 |
| 无监督分档 | K-means | 高斯混合模型,能给出软分配概率 |
| 属性工程 | 手工 9 维 | 加词向量均值、字符 n-gram 频率 |
有一点要提醒:换模型不等于升级。PLS 换成多输出随机森林之后,可解释性会掉一大截,特征重要性只能给到排序,给不出方向。如果毕业设计的重点是「解释哪些属性影响难度」,PLS 的载荷矩阵反而更好写;如果重点是「预测精度」,再考虑换。这个取舍在开题时就要定下来,别写到一半才发现讲不圆。
从那以后我每次接一个新的建模题,都强制先写一页数据字典再动模型——每一列的含义、取值范围、缺失规则、异常处理方式全部写死,后面所有代码都从这份字典里读列名。这份论文的 359 行数据能被榨出四份结果,靠的不是模型多花哨,是前面那层清洗做得足够干净,后面每一步都敢直接往上游取数。希望帮到你。
本文还有配套的精品资源,点击获取