
最近我把一个回归实验里的模型自由度参数从2一路按线性步长加到100整套扫描跑完过去很多“差不多得了”的判断都变成了具体数字。这儿的自由度指模型里可以独立调节的参数个数一元线性回归是2个自由度径向基回归里每加一个高斯基函数就多一个自由度。自由度从2涨到100模型的表达能力会从“画一条直线”一路变成“能把数据里每个局部抖动都描出来”。这种扫描在建模选型、基函数数量确定、过拟合边界判断时都会用到这篇文章相当于把这个实验完整复盘一遍从协议设计、代码到结果解读适合所有需要在建模时决定模型复杂度的人参考。1. 自由度从2到100这个实验到底在做什么1.1 一个具体的实验设定我采用的模型是径向基函数回归也就是RBF回归目标是从数据中学出一个一维函数。模型的数学形式很简单f_hat(x) b Σ w_j · exp(-γ · (x - c_j)²)其中j从1到mm是高斯基函数的个数c_j是第j个中心的位置γ是核宽参数线性回归要估计的变量包括截距b和所有系数w_j。模型里可调参数的数量就是m1也就是本文反复说的“自由度”。实验里我刻意做了几个固定选择样本量600输入x在[-3,3]上等间隔采样真实函数为f(x)sin(x)0.3x叠加标准差为0.2的高斯噪声。中心点均匀散布在x的取值范围上γ按中心间距的平方倒数设置目的是让相邻基函数之间既有重叠又不过度平滑。这样每增加一个自由度模型就多一个局部刻画能力扫出来的曲线才有层次感。1.2 这个实验要回答的三个问题第一个问题训练误差随自由度增加到底下降多快很多人以为自由度翻倍、误差就均匀减少实际曲线往往在某个点前猛降之后就开始钝化。第二个问题测试误差的谷底在哪里这才是选自由度的关键因为训练误差下降不代表泛化变好。第三个问题自由度增加的代价是什么如果只是多几个参数倒无所谓但高自由度往往伴随数值病态和计算成本上涨。这三个问题在2到100的完整扫描过程中都能看到非常清晰的答案。尤其是第三个问题不真正跑到100你很难想象一个看起来不怎么复杂的RBF模型在80个自由度以上时设计矩阵的条件数会膨胀到需要求解器告警的程度。1.3 我为什么在意自由度的绝对值单独说“自由度等于14”没什么含义它必须结合样本量来看。600个样本配2个自由度相当于样本量远大于参数个数模型必然欠拟合600个样本配100个自由度参数占比已经达到1/6过拟合风险明显增加。自由度从2加到100本质上是把参数与样本的比例从0.3%推到16.7%覆盖了从“模型太简单”到“模型太复杂”的完整区间。这也是我坚持从2开始的原因。自由度为1时只能拟合常数几乎没有建模意义自由度为2时至少能拟合一条带斜率的直线是“最小可用模型”。终点选100也参考了同样的逻辑如果样本量只有60我可能扫到15就停样本量是600扫到100依然能得到稳定的最小二乘解继续加下去只会加剧病态对观察过拟合形态反而不够干净。2. 扫描协议的四个关键设计2.1 为什么起点是2、终点是100起点为2是数学模型决定的终点为100则是由样本量倒推的。在实际项目里自由度上限可以粗略按样本量的15%到20%来设这样最小二乘还能稳定求解曲线也足够覆盖合理容量区间。有人会问既然样本量大、数据复杂为什么只到100不多加到300我的判断是当自由度超过100以后训练误差的下降已经非常缓慢而测试误差的反弹趋势已经明确扫描曲线后半段几乎是一条斜率固定的上升线继续加多的信息增量不大。如果项目里样本量达到几千我会把上限同步提高到几百但扫描思路完全一样。换句话讲上限选择与数据量挂钩而不是拍脑袋定一个好看的数字。2.2 线性增长而不是对数增长自由度扫描最常见的问题是网格选择不当。若按对数网格取点2到100之间会变成大约这样的序列2、3、4、6、10、17、28、47、78、100看起来覆盖了范围但在2到10这段最关键的转折区域只有3个点。而测试误差往往就在这个区间形成谷底网格太粗会把最优点彻底漏掉。所以我选线性步长1从2一直取到100共99个点。这样做的代价是扫描次数多、耗时略高但对于600个样本、自由度不超过100的规模来说完整跑一遍也只有几秒钟。先做一轮粗线性扫描确定谷底的大致位置再在这一段加密步长是我在更大项目里的习惯。2.3 评价指标选什么才不误导主指标是训练RMSE和测试RMSE辅助看R²和单次拟合耗时。RMSE对误差的量级敏感容易看出模型是否在数量级上靠谱R²则用来判断模型解释了多少方差两者配合不会出现单一指标带来的偏差。BIC和AIC也是可选项但在有固定测试集划分时我更看重真实测试误差信息准则适合没有专门测试集或需要惩罚复杂度的场景。这篇实验里比较重要的是重复测试。单次划分的测试RMSE很容易被随机性干扰自由度20可能某次表现得比15好换一次划分结论就反过来。所以我在每个自由度上重复5次划分取均值和标准差这样“谷底位置”才有统计意义。2.4 数据生成与基函数设置的注意事项数据生成固定随机种子确保任何人复现时拿到同样的曲线。RBF中心按等间距铺开gamma随中心间距变化。有一个细节容易被忽略gamma必须和中心数联动否则中心数增加后基函数之间的重叠程度会失控。固定gamma扫中心数看到的主要是“高频细节能不能表达”的变化gamma跟着间距走看到的才是“复杂度线性增长下模型行为的完整变化”。我的gamma规则是γ1/间距²。中心少时间距大、基函数宽模型整体平滑中心多时间距小、基函数窄模型可以贴合局部细节也更容易过拟合。如果不说明这个规则别人复现出的结果和我的结论可能完全不同。实验性文章最怕变量失控协议里每一项选择都要有理由。3. 从2到100实测结果与四个阶段把整条扫描曲线按行为分成四个阶段最直观的方式是看表格。下面是基于我的随机种子、重复5次后得到的大致区间你按同样的代码跑一遍会有小幅差异但趋势不会变。自由度区间训练RMSE测试RMSE典型表现判断2-50.32-0.400.38-0.45预测曲线过于平滑连sin的主峰都描不准欠拟合区增加自由度收益很大6-200.22-0.280.19-0.22曲线形状基本正确开始出现轻微局部波动甜点区区间内存在测试误差最低点21-500.16-0.220.20-0.25训练误差继续下降测试误差反复缓慢上升过拟合初期必须同时看两条曲线51-1000.10-0.180.25-0.38预测曲线高频抖动设计矩阵接近病态明显过拟合增加自由度得不偿失3.1 阶段一0到5的欠拟合区自由度在2到5之间时模型连真实函数的基本形状都无法刻画。单个RBF基函数叠加出来的曲线太宽拟合出的结果近似于一条略带上翘的直线sin的波峰波谷都被平均掉了。这个阶段的好处是每增加一个自由度测试RMSE都会肉眼可见地下降。我在自由度4左右能看到测试误差突然加速收敛说明模型终于开始具备“形成曲线”的能力。如果你做扫描时发现前几个点测试误差下降非常快不用犹豫模型正处于欠拟合区加容量是确定性收益。这个阶段如果训练误差也偏高那说明问题不在噪声而在模型本身。3.2 阶段二6到20的甜点区间自由度大约从6开始测试RMSE进入低位。我这次重复运行的最佳点多数落在14到16之间对应的训练RMSE并不低还有0.22左右但测试RMSE已经压到0.19附近。这说明模型在这个区间刚好能表达真实函数的形状却没有强大到把样本噪声一起记住。这个区间的一大特征是训练误差继续缓慢下降而测试误差几乎不再变化或者只在小范围内波动。很多人在这个阶段容易犯一个错误看到测试误差没有继续掉就误以为“再加容量没用了”其实这个平台期恰恰说明当前容量处于均衡状态。如果噪声再小一点甜点区会向右移动如果噪声加大甜点区会明显左移。3.3 阶段三21到50的过拟合初现自由度超过20后训练RMSE还在下降但每一单位自由度换来的下降幅度已经很小。与此同时测试RMSE开始出现反复上升虽然单次波动看起来不大但均值曲线已经能看出明显的抬头。这里最危险的地方在于如果你只盯训练RMSE会认为模型还在进步只有把测试RMSE并排放在一起才能看到过拟合的到来。我在这个阶段还观察到一个细节自由度25到35时预测曲线在个别样本点附近出现小幅“抖动”也就是曲线为了贴住某个噪声点而出现了局部扭曲。这种扭曲在整体RMSE上只造成0.02到0.04的差异但外推时会被放大。3.4 阶段四51到100的数值病态与成本上涨自由度超过50以后情况从“统计上的过拟合”进一步恶化为“数值上的病态”。中心间距缩小到样本间距量级后距离较近的高斯核高度相似设计矩阵的列与列之间几乎线性相关。自由度80时XTX的条件数在我这次实验里已经达到1e12量级普通最小二乘解虽然还能算出来但数值噪声明显增加预测曲线高频抖动非常严重。训练RMSE在这个阶段依然可以降到0.1以下因为它有足够参数去记忆每一个训练点测试RMSE却反弹到0.25甚至0.38。自由度超过50到100这一段每加一个中心几乎都是负收益。如果你在实际项目里扫到类似形态可以放心得出结论模型容量已经过头计算成本和数值风险超过了任何可能的拟合收益。4. 扫描过程中踩到的坑与判断方法4.1 训练误差是最会骗人的指标我在做这次扫描时自由度30前后差点被训练RMSE带偏。那条曲线一路向下看起来非常舒服好像模型越强越好。直到我把测试RMSE叠在同一张图上才看到原来从20以后测试误差已经开始抬升。这个教训让我形成了一个习惯在做任何容量扫描时只接受“训练误差和测试误差成对出现”的曲线图单看任何一条都等于盲人摸象。判断过拟合的实用标准是经验性的如果训练RMSE连续下降而测试RMSE已经连续5个点没有下降或持续上涨就回退到测试误差最低的那个点不要被后续训练曲线的“进步”诱惑。4.2 自由度不完全等于模型容量同样是100个自由度把gamma调大可以让整个模型平滑得像线性回归把所有中心塞到数据密集区也能改变实际表达力。自由度只统计参数个数真正的有效容量由自由度、基函数宽度、中心位置三个因素共同决定。RBF实验里gamma随间距变化等于把容量这个概念和自由度绑定在一起如果你固定gamma那么扫描出来的曲线代表的又是另一种含义。我建议在协议中把gamma规则固定并写清楚否则后续对比不同实验时你无法判断性能变化是来自自由度还是来自基函数尺度。这也解释了为什么我反复强调协议设计失控的变量比错误的结论更难排查。4.3 高自由度下设计矩阵接近病态自由度60以上时RBF设计矩阵的列之间已经出现明显相关性。我在代码里打印过设计矩阵条件数低自由度时只有几十到80以上直接跳到1e12。求解器没有报错但在数学上这种程度的病态意味着系数估计对极小数值扰动都非常敏感测试误差的波动因此变得难以解释。处理方式有三种改用岭回归加一个小正则项比如alpha1e-8使用伪逆求解或者换用正交性更好的基函数比如切比雪夫多项式。扫到60以上时我通常直接改用岭回归不仅稳定而且能保留RBF原有的曲线表达能力。4.4 单次划分的随机波动能骗过最优点我第一次扫描时只用一次8比2划分结果发现自由度24的表现比15还好一度以为最优点应该在20附近。换成5次重复后再看24只是运气好均值曲线的最优点仍然稳定在14到16之间。随机波动在测试集只有150个样本时是真实存在的样本越少波动越大。所以凡是结论“最优自由度落在某处”必须加置信基础否则换个划分就会被打脸。4.5 高自由度下耗时统计不靠谱在600个样本、100个自由度的规模下单次线性回归拟合只有微秒到毫秒级别。直接比单次耗时随机噪声会盖过真实差异。我最后选择重复拟合多次再累计总时间而且固定同一台机器、同一套numpy版本才得到一个相对可信的耗时曲线。这里得到的规律是在低自由度时耗时缓慢增长自由度超过50后增速明显加快整体近似超线性。如果你的样本量更大单次耗时曲线会更明显但仍然要用累计时间而不是单次时间下结论。5. 可直接运行的扫描代码5.1 完整实现下面这段代码可以直接复制运行依赖numpy、pandas和scikit-learn。我故意把重复次数设为5方便快速复现想更稳可以改成10。import numpy as np import pandas as pd import time from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, r2_score rng np.random.default_rng(42) n 600 x np.linspace(-3.0, 3.0, n) y_true np.sin(x) 0.3 * x y y_true rng.normal(0.0, 0.2, n) def rbf_matrix(x, centers, gamma): dx x[:, None] - centers[None, :] return np.exp(-gamma * dx ** 2) def scan_dof(repeats5): records [] for dof in range(2, 101): m dof - 1 centers np.linspace(x.min(), x.max(), m) spacing centers[1] - centers[0] if m 1 else x.max() - x.min() gamma 1.0 / (spacing ** 2) train_rmses, test_rmses, times [], [], [] for seed in range(repeats): xtr, xte, ytr, yte train_test_split( x, y, test_size0.25, random_stateseed ) Xtr rbf_matrix(xtr, centers, gamma) Xte rbf_matrix(xte, centers, gamma) t0 time.perf_counter() model LinearRegression().fit(Xtr, ytr) times.append(time.perf_counter() - t0) train_rmses.append( mean_squared_error(ytr, model.predict(Xtr)) ** 0.5 ) test_rmses.append( mean_squared_error(yte, model.predict(Xte)) ** 0.5 ) records.append({ dof: dof, n_centers: m, train_rmse: np.mean(train_rmses), train_rmse_std: np.std(train_rmses), test_rmse: np.mean(test_rmses), test_rmse_std: np.std(test_rmses), fit_time_ms: np.mean(times) * 1e3, }) return pd.DataFrame(records) df scan_dof(repeats5) print(df.round(4).tail(15)) print(best dof:, int(df.loc[df[test_rmse].idxmin(), dof]))5.2 结果输出与解读运行后最后一条dof为100的记录大约会显示训练RMSE在0.1附近测试RMSE在0.25到0.35之间fit_time_ms也达到整个扫描区间里的最高值。best dof那行会打印出十几与我的实验结果接近。我强烈建议把训练RMSE和测试RMSE画在同一张图上横轴为dof而不是只看表格。曲线形态比数字更容易建立直觉训练曲线一直向下测试曲线先降后升两条线在自由度为十几的地方离得最近之后越拉越开。这个“喇叭口”就是过拟合的图形化定义。5.3 换场景怎么改代码框架不限于RBF回归。如果你在做机器人运动学自由度为机械臂的关节数扫描范围换成2到100评价指标换成可操作度或奇异值条件数就能看到关节数增加对灵巧性的边际收益如果你做有限元网格收敛性分析自由度换成节点数指标换成L2误差与求解时长可以复现“误差先降后平、耗时持续上涨”的经典曲线甚至神经网络宽度扫描也可以照搬这套循环把中心数换成隐藏单元数把LinearRegression换成MLPRegressor。关键改动只有三处自由度的定义、评价指标、数据生成方式。扫描协议和过拟合判断逻辑完全一致。6. 做完这次线性扫描后我养成的几个检查习惯第一任何模型选型问题都先做容量扫描而不是直接调正则化参数。正则化参数调得再准也只是在一个固定的容量范围内找平衡先扫出自由度和测试误差的完整曲线你知道自己该站在哪个容量区间后面调参才有方向。第二自由度上限按样本量比例来定不凭感觉。我这次的600个样本配100自由度是因为参数量达到样本量的六分之一换到6万个样本同样的自由度扫描可能完全不过拟合结论不能跨数据规模套用。第三高自由度区出现奇怪结果时先怀疑数值稳定性再怀疑算法逻辑。条件数超过1e10后的任何指标变化都要先做去相关或加正则处理否则你分析的是浮点误差不是真实模型行为。第四扫描曲线只信重复后的均值。单次划分的波动在自由度20到40之间特别明显不重复多次、不画误差带几乎不可能定位真实的甜蜜点。这次从2到100的线性扫描让我最直观的感受是模型复杂度的收益是递减的代价却是递增的。训练误差给你虚假的乐观测试误差告诉你何时该停。以后凡是有人问我某个模型该配多少自由度我都会建议他先花几分钟跑一遍这样的扫描眼见为实。