简介:面向毕业设计学生的 Python 机器学习实践资源,聚焦基于基本算法实现正则化多项式拟合,兼顾主成分分析、EM 算法与逻辑斯蒂回归等拓展内容。压缩包共 31 个文件,以 py 源码、py~ 备份文件、docx 实验报告为主,并含少量 m 脚本与临时文件,整体仅 943KB,轻量易用。已有 37 人学习下载。资源内包含多项式函数拟合实验、正则化变体(L1/L2)实现、主成分分析及 EM 算法求解高斯混合模型等代码与文档,配套实验报告和可视化分析,可帮助读者理解最小二乘拟合、损失函数惩罚项、优化求解及模型评估的完整流程,兼顾理论讲解与手写实现。通过实际数据集演示数据预处理、模型训练与参数调优,适合想用 Python 从零实现经典算法、完成毕设实验或巩固机器学习基础的学生。
1. 从“看着拟合得挺好”到“一上测试集就现原形”:这包代码到底解决什么
多项式拟合大概是很多人在Python里碰到的第一个“效果看得见”的算法:点一把数据点,配置好次数,numpy的polyfit一拉,曲线丝滑穿过每一个点,谁看了都觉得自己会了。可一旦把模型用到新数据上,曲线开始剧烈震荡,边界处直接飞起,拟合结果连物理规律都对不上——这就是过拟合,在多项式拟合里几乎是100%会出现的现象,次数越高越严重。
这个zip工程做的不是调包,而是用Python基本算法从零实现一遍:手写正规方程、手写梯度下降、自己构造设计矩阵,然后在此基础上把L2正则化(岭回归)、L1正则化(Lasso)和弹性网整合进拟合流程。适合两类人:一类是刚学完NumPy、想搞明白“机器学习里那些黑匣子底层到底在算什么”的初学者,另一类是会用sklearn但总说不清正则化系数怎么调、为什么结果忽好忽坏的从业者。看完你会发现,正则化不是玄学,它就是给目标函数加了一个约束惩罚项,让模型记住数据的主要趋势而不是细节噪声。
2. 先搞懂要拟合的数学对象:目标函数、设计矩阵与求解方式的选择
2.1 从一组散点到一个多项式:用基函数构造设计矩阵
多项式拟合的输入是一组数据点,输出是一个形如y = w0 + w1*x + w2*x^2 + ... + wn*x^n的系数向量。这个形式看起来简单,但落到代码里最关键的一步是把原始的x值转换成设计矩阵X:每一行对应一个样本,每一列对应一个幂次。只有先完成这个变换,后面的一切求解才能用矩阵运算一把梭。
import numpy as np def build_polynomial_features(x, degree): """把原始 x 转成多项式特征矩阵,返回形状为 (len(x), degree+1) 的数组""" x = np.asarray(x, dtype=np.float64).reshape(-1, 1) powers = np.arange(degree + 1) # 用广播机制一次性生成所有幂次,避免写循环 return np.power(x, powers) # 示例:5 个样本点,拟合 3 次多项式 x = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) X = build_polynomial_features(x, degree=3) print(X)这里的逻辑是:对每个原始x值,分别计算它的0次幂、1次幂、2次幂、3次幂,把它们横着拼在一起。第一列全为1,对应常数项,也就是偏置。reshape(-1, 1)的目的是把x从一维数组转成列向量,这样广播运算的方向才正确,否则在维度不匹配时容易得到意外的形状。degree是你要拟合的最高次数,它直接决定模型的复杂度,也是过拟合的开关。
使用numpy的np.power替代循环,代码简洁且底层走向量化计算,在样本量大或多项式的次数较高时性能差异明显。实际工程中,这种“构造特征矩阵”的思路不仅用于多项式,任何一个基于基函数展开的模型——比如样条回归、傅里叶特征、周期性信号的建模——都是同一个套路,把原始特征映射到高维空间,再用线性模型拟合,这点务必记牢。
2.2 目标函数里加一项惩罚:L1、L2、弹性网到底差在哪
不带正则化的多项式拟合,目标函数是最小化残差平方和,也就是loss = ||y - Xw||^2。加正则化后,目标函数变成两部分:原来的损失项加上参数惩罚项。L2正则化在高斯先验假设下对应岭回归,惩罚权重为alpha * sum(w_i^2),对每个维度施加均匀缩放的惩罚但不产生稀疏性;L1正则化采用alpha * sum(|w_i|),对应拉普拉斯先验,可以把不重要的系数直接压成0,适用于特征筛选;弹性网则是乘以一个比例系数rho把L1和L2组合在一起,缓解纯L1在特征高度相关时选择不稳定的问题。
import numpy as np def ridge_loss(w, X, y, alpha): """L2 正则化的目标函数:残差平方和 + alpha * 权重平方和""" residual = y - X @ w return residual @ residual + alpha * np.sum(w * w) def lasso_loss(w, X, y, alpha): """L1 正则化的目标函数:残差平方和 + alpha * 权重绝对值之和""" residual = y - X @ w return residual @ residual + alpha * np.sum(np.abs(w)) def elastic_net_loss(w, X, y, alpha, rho=0.5): """弹性网:alpha 控制整体正则强度,rho 控制 L1 与 L2 的混合比例""" residual = y - X @ w l1_term = alpha * rho * np.sum(np.abs(w)) l2_term = alpha * (1 - rho) * np.sum(w * w) return residual @ residual + l1_term + l2_term参数说明:alpha是正则化系数,取值越大惩罚力度越强,模型越趋向简单,但过大会导致欠拟合;在Lasso的目标函数中,w的更新方式不再是一个简单的收缩,而是涉及软阈值算子——soft_threshold(z, lambda_) = sign(z) * max(|z| - lambda_, 0),这直接源于L1正则项的次梯度条件,也是“为什么L1能把系数更新为精确的0”的根本原因。弹性网的rho一般取0.5左右起步,往1方向偏则更接近Lasso,往0方向偏则更接近岭回归。
| 正则化类型 | 惩罚项形式 | 稀疏性 | 适用场景 |
|---|---|---|---|
| L2(岭回归) | alpha * sum(w^2) | 无,压缩但不为零 | 特征间相关性高、需要保留全部特征 |
| L1(Lasso) | alpha * sum( | w | ) |
| 弹性网 | alpha * (rho*L1 + (1-rho)*L2) | 有,但组合调节 | 特征强相关时替代Lasso |
2.3 求解方式选哪种:闭式解、坐标下降法与梯度下降的取舍
带L2正则化的目标函数是严格凸的二次函数,有闭式解:w = (X^T X + alpha * I)^(-1) X^T y。 这里的I是单位矩阵,注意要对偏置项单独决定是否惩罚,实践中通常不惩罚偏置,但为了简化实现可以一并惩罚,数据做过中心化影响不大。带L1正则化的目标函数不可导,无法直接求导数为零的闭式解,普遍做法是采用坐标下降法:每次固定其他权重,只对当前这一个维度做软阈值更新,依次循环直到收敛。
import numpy as np def fit_ridge_closed_form(X, y, alpha): """ L2 正则化的闭式解:正规方程 + 对角惩罚 """ n_features = X.shape[1] A = X.T @ X + alpha * np.eye(n_features) b = X.T @ y return np.linalg.solve(A, b) def soft_threshold(z, threshold): """软阈值算子:L1 正则坐标下降的核心步骤""" return np.sign(z) * np.maximum(np.abs(z) - threshold, 0.0) def fit_lasso_coordinate_descent(X, y, alpha, n_iters=1000, tol=1e-6): """用坐标下降法拟合 Lasso,逐维度更新权重""" n_samples, n_features = X.shape w = np.zeros(n_features) # 缓存 X 每列的平方和,避免循环里反复计算 col_sq = np.sum(X ** 2, axis=0) for _ in range(n_iters): w_old = w.copy() for j in range(n_features): # 去掉第 j 维的贡献,计算当前残差 residual = y - X @ w + X[:, j] * w[j] rho_j = X[:, j] @ residual # 除以列平方和得到当前维度的最小二乘解,再做软阈值 if col_sq[j] < 1e-12: continue z_j = rho_j / col_sq[j] w[j] = soft_threshold(z_j, alpha / col_sq[j]) if np.max(np.abs(w - w_old)) < tol: break return w闭式解适合L2,高效直接;坐标下降法适合L1,核心就是那个软阈值步骤——把当前维度的最小二乘解向零点压缩,压缩量超过alpha / col_sq[j]就直接置零。注意代码里residual的计算:先算全量预测,再加回当前维度被减掉的贡献,这是坐标下降的高效实现写法,避免每一维都重建残差向量,在特征数多时能省不少时间。梯度下降理论上也能用于L1,但需要处理不可导点的次梯度,收敛速度也不如坐标下降直接,工程上做Lasso几乎都用坐标下降。
3. 把完整工程跑起来:数据生成、训练、评估与三种正则化对比
3.1 环境准备与压缩包目录结构:先确认里面是什么再动手
拿到zip后,第一步不是急着解压然后双击运行,而是先看一眼目录。常见做法是先解压到一个独立文件夹,确认依赖只有numpy(工程如果用了matplotlib画图则需要一并确认),然后用命令行进入目录执行训练脚本。标准的工程目录里应当包含:数据生成脚本或处理逻辑、模型求解的函数库、主训练入口、结果输出或可视化部分。
# 在项目根目录下操作 mkdir -p poly_regularization && cd poly_regularization mv ~/Downloads/基于python基本算法实现正则化的多项式拟合.zip . unzip 基于python基本算法实现正则化的多项式拟合.zip # 查看解压后的目录结构 find . -type f | sort如果解压后看到的是.py源文件直接暴露出所有函数,那很好;如果只有算法代码而缺少安装脚本,也无需担心,只要本机有Python 3.8以上的环境即可运行。关于Linux下解压命令zip的细节,常见问题在于中文文件名在部分解压环境下会出现乱码,解决方式是使用unzip -O gbk指定文件名的编码字符集,这是老生常谈但总有人踩的坑。
依赖确认完成后,按顺序检查一下当前Python环境中是否已有numpy。如果没有,直接安装;如果有,建议确认版本不低于1.20,较老的版本在某些广播规则上行为略有差异,虽然大概率不影响这里的功能,但避免无谓的翻车。
python -c "import numpy; print(numpy.__version__)" python -c "from sklearn.linear_model import Ridge, Lasso, ElasticNet"第二行命令在项目不依赖sklearn时可以不执行,但如果工程里带有与sklearn对比验证的部分(我做的方案里通常有,用来侧面验证手写结果的正确性),就需要把scikit-learn也装上。逻辑上自研代码和sklearn算同一道题,两组结果放在一起对比,能快速暴露手写实现的错误。
3.2 造一份“既好看又难过拟合”的数据:正弦加噪声的设计
多项式拟合的实验数据一般用一条固定函数曲线加上高斯噪声来生成。函数选正弦比较典型:正弦不是多项式,却能很好地展示低次数拟合的欠拟合和平滑效果、高次数拟合的过拟合振荡,是这一课题最常用的基准函数。
import numpy as np def generate_toy_data(n_train=30, n_test=100, noise_std=0.1, seed=42): rng = np.random.default_rng(seed) x_train = np.sort(rng.uniform(-3, 3, size=n_train)) x_test = np.sort(rng.uniform(-3, 3, size=n_test)) y_train_true = np.sin(x_train) y_test_true = np.sin(x_test) y_train = y_train_true + rng.normal(0, noise_std, size=n_train) y_test = y_test_true + rng.normal(0, noise_std, size=n_test) return x_train, y_train, x_test, y_test_true, y_test参数说明:n_train是训练样本数,30个点对于多项式拟合来说是典型的中小样本场景,样本越少越容易过拟合,也越能体现正则化的作用;noise_std是高斯噪声的标准差,0.1表示噪声幅度约为信号幅度的百分之十上下,噪声太小了拟合曲线几乎完全贴合正弦,正则化效果不明显;噪声太大了模型难以学到有效趋势。seed固定随机种子保证实验可复现,这一点在调正则化系数时太重要了——如果不固定种子,每次跑出来的过拟合现象可能时有时无,你根本无法判断某个系数的好坏到底是真的还是运气。
训练集和测试集都用np.sort排序,是为了后续画图时曲线绘制方便。如果要用代码的方式比较测试误差,不排序也没有关系,因为误差计算只依赖预测值和真实值,与排列顺序无关。工程上建议数据分布保持一致,都从[-3, 3]区间均匀采样,这样训练集和测试集的分布范围相同,能干净地反映泛化性能,而不是测试集使用了训练集之外的数据区间导致误差被区间外推问题主导。
3.3 手写正规方程的完整训练流程:闭式解下跑通Ridge
训练流程的组织方式,是把特征构造、求解、评估三件事分开写成函数,主入口按顺序调用。这样的组织逻辑在工程上是最常见做法,也便于后面依次实验不同正则化类型。
import numpy as np from sklearn.metrics import mean_squared_error def evaluate_model(w, x, y_true): """根据权重系数和输入数据构造特征、预测并返回均方误差""" X = build_polynomial_features(x, w.shape[0] - 1) y_pred = X @ w return mean_squared_error(y_true, y_pred) def train_and_evaluate(x_train, y_train, x_test, y_test, degree, alpha): X_train = build_polynomial_features(x_train, degree) X_test = build_polynomial_features(x_test, degree) w = fit_ridge_closed_form(X_train, y_train, alpha) train_loss = evaluate_model(w, x_train, y_train) test_loss = evaluate_model(w, x_test, y_test) return w, train_loss, test_loss # 实验 1:无正则化 vs L2 正则化 x_train, y_train, x_test, y_test_true, y_test = generate_toy_data() w_raw, train_loss_raw, test_loss_raw = train_and_evaluate( x_train, y_train, x_test, y_test, degree=15, alpha=0.0 ) w_reg, train_loss_reg, test_loss_reg = train_and_evaluate( x_train, y_train, x_test, y_test, degree=15, alpha=1e-3 ) print(f"无正则化: train_loss={train_loss_raw:.6f}, test_loss={test_loss_raw:.6f}") print(f"L2 正则化: train_loss={train_loss_reg:.6f}, test_loss={test_loss_reg:.6f}")执行这段代码,大概率会看到无正则化的训练误差极小(说明在训练集上拟合得非常好),但测试误差比带正则化的版本高出几个数量级。这就是过拟合的有力证据:模型把噪声当成信号去拟合了。注意degree=15配合30个训练样本,已经是非常极端的过拟合配置——15次多项式有16个参数,而样本只有30个,模型有足够的自由度去精确穿过每一个点,但代价是测试集上完全失控。
alpha=1e-3是我针对这个数据规模常用的起点值,它很小但足以把权重压下来。为什么用这个数量级?因为设计矩阵的数值范围和目标函数的残差规模决定了惩罚项的相对大小,如果alpha取1或更大,大概率会直接把曲线压成一条水平线,那是欠拟合的方向。正则化系数的量级必须跟着数据和特征矩阵的尺度走,不存在一个通用的万能值。
3.4 梯度下降版拟合:学习率、迭代次数与收敛判断
闭式解虽然简洁,但当特征维度变高或矩阵求逆代价升高时,迭代求解更通用。用梯度下降拟合带L2正则化的多项式,目标函数对权重的梯度是grad = 2*X^T(Xw - y) + 2*alpha*w。
import numpy as np def fit_ridge_gradient_descent(X, y, alpha=1e-3, lr=0.01, n_iters=5000, tol=1e-8): n_samples, n_features = X.shape w = np.zeros(n_features) for i in range(n_iters): grad = 2 * X.T @ (X @ w - y) / n_samples + 2 * alpha * w grad[0] -= 2 * alpha * w[0] # 不惩罚偏置项(第一个特征为全 1 列) w_new = w - lr * grad if np.linalg.norm(w_new - w, ord=2) < tol: break w = w_new return w这段代码里有两个细节值得留意。第一,梯度里的2*X^T(Xw-y)除以了n_samples,目的和L2惩罚项保持量纲一致,避免样本量改变时梯度幅度大范围波动;闭式解里除不除没有影响,但梯度下降里归一化能显著缓解学习率调参的痛苦。第二,grad[0] -= 2 * alpha * w[0]这一行的意思是偏置项单独拎出来不参与正则化惩罚。技术上讲L2的正则化梯度是2*alpha*w,但对偏置项做惩罚会降低模型对数据均值的拟合能力,需要手动去掉这个梯度分量。
学习率lr=0.01在标准化后的数据上是合适的起点;如果不做特征标准化,多项式的幂次会让高维列数值极大,梯度量级差异悬殊,学习率就很难选——这就是为什么工程上做多项式拟合前几乎必然要归一化。tol=1e-8是收敛阈值,用权重变化的欧几里得范数衡量;实际运行中,即使不满足这个阈值,达到n_iters=5000的上限也会结束迭代,这时要观察loss曲线来判断训练是否真的收敛。
4. 关键预处理:特征归一化能救活你的系数和收敛速度
4.1 为什么要做归一化:x的幂次让设计矩阵数值跨了十几个数量级
在一个[-3, 3]区间采样的数据上,构造15次多项式特征后,x=3这一行的特征值是[1, 3, 9, 27, ..., 3^15],最后一个值是14348907,而第一列是1,两列之间差了七到八个数量级。如果你看X^T X,矩阵的奇异值会横跨更宽的范围,闭式解求逆时会高度病态,小胖一点点的噪声扰动都会导致权重解翻天覆地。更直观地说,高次项系数会被压得极小,低次项系数又可能巨大,模型根本没有稳定性可言。
归一化常见的做法是对每一列做z-score标准化:减均值除以标准差,或者缩放到[-1, 1]区间。多项式多项式特征用z-score更稳妥,因为不同幂次的列数值分布形态完全不同,用min-max缩放容易被极端值带偏。
import numpy as np class StandardScaler: """简单的列标准化器,用来对特征矩阵做 z-score 标准化""" def fit(self, X): self.mean_ = np.mean(X, axis=0) self.std_ = np.std(X, axis=0) self.std_[self.std_ < 1e-12] = 1.0 # 防止常数项除零 return self def transform(self, X): return (X - self.mean_) / self.std_ def fit_transform(self, X): self.fit(X) return self.transform(X)标准化的关键是fit和transform的分离:只能用训练集的均值和标准差去标准化训练集,再用同一个均值和标准差去标准化测试集和未来预测数据。如果你用X_test重新计算均值和方差,测试数据的分布就被泄露到了预处理环节,算出来的测试误差会虚低,这是新手最容易犯的错误。代码里std_[std_ < 1e-12] = 1.0是为了处理全为1的常数项列,它的标准差天然为0,不处理就会产生除零。
4.2 归一化对解的效果影响:从闭式解到L1收缩路径
做归一化后,闭式解中的X^T X变成了标准化特征的协方差矩阵,对角线更加接近同一量级,求逆的数值稳定性显著提升。这一点在用np.linalg.solve求解时体现为结果不随数据的小扰动而剧烈变化。坐标系的原点移动也直接改变了权重的含义:未归一化时,w0对应的是x=0处的函数值,偏移后w0的意义也随之变化,这个偏差让正则化惩罚作用在被偏移扭曲了的参数上,公平性和解释性都不好。
再看L1正则化坐标下降的更新公式:w[j] = soft_threshold(z_j, alpha / col_sq[j])。如果第j列没有标准化,col_sq[j]可能极大(比如15次幂列),那么alpha / col_sq[j]极小,软阈值几乎不压缩;而常数项列col_sq只有30,同样的alpha产生了相对更大的压缩量。这导致正则化对不同项的惩罚强度严重失衡——相当于说,惩罚力度是不公平的。标准化的意义不仅在于数值稳定性,更重要的是让所有参数被惩罚的尺度一致,L1的稀疏化效果才不会被浪费在错误的维度上。
实践中,我在做带正则化的多项式拟合时,归一化永远在构造特征矩阵之后、求解之前。这个顺序不能反过来——如果先对原始x做标准化再构造多项式特征,等价于对基底做仿射变换,同样能改善数值,但特征矩阵各列的相关性结构会变得更复杂,而且换算回原始坐标系时需要额外处理,工程上不推荐,除非你有明确的理论需求。
4.3 偏置项的处理策略:罚还是不罚,影响没那么大也没那么小
在第3节的fit_ridge_gradient_descent中,偏置项被单独从惩罚项中剔除。这是有理论考量的:L2正则化的贝叶斯解释里,每个权重的先验通常是零均值高斯,但数据的均值或常数偏移往往不对应于零先验,强制收缩偏置会引入系统性偏差。在特征标准化过的情况下,目标变量的均值通常会偏移到一个非零数值,如果偏置也被强压缩到接近0,相当于强制模型从原点开始拟合数据,多半会失败。
import numpy as np def fit_elastic_net_cd(X, y, alpha=1e-2, rho=0.5, n_iters=1000, tol=1e-6): """弹性网坐标下降:L1 与 L2 的联合惩罚通过缩放更新实现""" n_samples, n_features = X.shape w = np.zeros(n_features) col_sq = np.sum(X ** 2, axis=0) lambda1 = alpha * rho lambda2 = alpha * (1 - rho) for _ in range(n_iters): w_old = w.copy() for j in range(n_features): residual = y - X @ w + X[:, j] * w[j] rho_j = X[:, j] @ residual if col_sq[j] < 1e-12: continue z_j = rho_j / (col_sq[j] + lambda2) w[j] = soft_threshold(z_j, lambda1 / (col_sq[j] + lambda2)) if np.max(np.abs(w - w_old)) < tol: break return w上述弹性网的更新公式可以直观理解:L2项在分母上把col_sq[j]增加了一个常量,效果是整体收缩更强烈;L1项通过软阈值把系数向零压缩。两者合在一起,同一个坐标下降框架不用大改就实现了三种正则化,这也是为什么我建议工程上直接写弹性网的代码——alpha取0是普通最小二乘,rho取0是Ridge,rho取1是Lasso,一套代码三种行为。
弹性网里正则化系数与求解步长的关系值得注意:lambda2出现在分母上,增大了有效分母,会让每一步更新的步长变小,所以相同迭代次数下需要设置更高的n_iters或者根据实际loss曲线判断是否收敛。这个细节在Lasso代码中不存在,因为分母只有col_sq。
5. 正则化系数与验证方法:alpha怎么设、过拟合怎么判断
5.1 系数网格搜索法:手动扫描与自动化选参
正则化系数不能靠拍脑袋,工程上最通用的方法是网格搜索:准备一组候选值,比如[0, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1, 10],遍历每个alpha训练模型,在独立验证集上评估性能,选出误差最小或模型恰好精简的那个值。这个过程可以手动循环,也可以用GridSearchCV自动化,但理解了循环代码的原理对网格搜索的行为会更有把握。
import numpy as np def grid_search_alpha(x_train, y_train, x_test, y_test, degree, alpha_candidates): """遍历候选 alpha 列表,在测试集上记录误差,返回最优值及对应权重""" best_alpha = None best_model = None best_test_loss = float("inf") results = [] for alpha in alpha_candidates: w, train_loss, test_loss = train_and_evaluate( x_train, y_train, x_test, y_test, degree, alpha ) results.append((alpha, train_loss, test_loss)) if test_loss < best_test_loss: best_test_loss = test_loss best_alpha = alpha best_model = w return best_alpha, best_model, results alpha_list = [0, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1.0] best_alpha, best_w, grid_results = grid_search_alpha( x_train, y_train, x_test, y_test_true, degree=15, alpha_candidates=alpha_list ) for a, tr, te in grid_results: print(f"alpha={a:.0e}, train_loss={tr:.6f}, test_loss={te:.6f}") print(f"最优 alpha: {best_alpha:.0e}")这里有一个关键认知:网格搜索里如果直接用测试集选alpha,实际上把测试集信息泄漏到了模型选择过程中,最终报告出来的测试误差会偏乐观。正确的做法是预留三个集合:训练集、验证集、测试集。在训练集上训练,在验证集上选alpha,全部确定后用测试集做最终评估。在数据量较少时,常用k折交叉验证替代单个验证集,把训练集切成k份轮流做验证,最后取平均误差。网格搜索得出的alpha对不同的数据规模变化非常大,30个样本时0.1到0.01范围效果较好,300个样本时可能0.001更合适,不存在一个固定的配方。
| alpha 量级 | 30 样本效果 | 300 样本效果 |
|---|---|---|
| 0 | 过拟合严重,测试误差爆炸 | 仍然过拟合,但程度稍轻 |
| 1e-3 | 有效压缩权重,测试误差明显下降 | 压制效果有限,也许需要再增大 |
| 1e-1 | 权重被大幅压缩,接近欠拟合 | 适合中等噪声时使用 |
| 1e1 | 几乎退化为常数预测 | 严重欠拟合,测试误差回升 |
5.2 学习曲线判别法:训练误差和验证误差的距离说明什么
学习曲线是判断过拟合、欠拟合和当前数据量是否足够的图形化工具。横轴是训练样本数量,纵轴是误差,画两条曲线:训练集误差和验证集误差。如果训练误差很低、验证误差很高且两者之间的间隔大,说明过拟合——模型记住了数据但没有泛化规律;如果训练误差和验证误差都高且接近,说明欠拟合——模型复杂度不够或正则化过强;如果训练误差和验证误差收敛到同一水平且数值较小,说明拟合状态健康。
在项目里实现学习曲线,需要按数据量的递增比例多次调用训练和评估函数:
import numpy as np def learning_curve(x_train, y_train, x_test, y_test, degree, alpha, sample_sizes): train_errors, val_errors = [], [] for n in sample_sizes: x_sub = x_train[:n] y_sub = y_train[:n] w, tr_loss, te_loss = train_and_evaluate( x_sub, y_sub, x_test, y_test, degree, alpha ) train_errors.append(tr_loss) val_errors.append(te_loss) return sample_sizes, train_errors, val_errors sample_sizes = [8, 12, 16, 20, 24, 30] sizes, tr_curve, val_curve = learning_curve( x_train, y_train, x_test, y_test_true, degree=15, alpha=1e-3, sample_sizes=sample_sizes )解释这段工程代码:直接把固定数据集按顺序取出前n个样本做训练,用完整测试集做验证,是一种快速近似。严格的交叉验证学习曲线需要对每个样本量做多次不同切分,然后取平均。工程上先快速跑这个近似版本判断趋势,再在关键节点用交叉验证精确验证,能节省大量时间。学习曲线的一个优点是它和正则化系数无关的部分不受影响:无论alpha取多少,样本量增加时训练误差总会走高,验证误差总会走低,两条线逼近说明模型不再从更多数据中受益,反之说明数据量不足,加正则化只能压制症状,无法根治病因。
6. 避坑指南:多项式拟合里几个最容易翻车的细节
6.1 现象:alpha很小但结果还是过拟合
有学员在我以前带项目时遇到过这种情况:alpha已经小到1e-8了,测试误差依旧巨大,曲线震荡得跟心电图一样。原因不是正则化没用,而是他忘了做特征归一化,导致惩罚项在高次项上几乎不起作用。高次幂的列数值巨大,col_sq[j]很大,实际惩罚被分母稀释掉了。
解决方法是先对特征矩阵做标准化,再重新设置alpha量级。标准化的实施顺序和细节在前文讲过了,只要标准化的均值和标准差全部来自训练集一条守住,就不会有大问题。注意:标准化后重新搜索alpha,最优点会比未标准化时移动几个数量级,这是完全正常的。
6.2 现象:训练误差降不下去
另一种情况:加上正则化后发现训练误差很大,曲线拟合得也不好,总觉得模型能力不够。看是否alpha定得过大——尤其是初学者为了压制过拟合会把alpha调得非常大,这等于直接说“所有系数都快归零吧”,模型退化为一条水平直线。这种情况的经典信号是训练误差和测试误差一起走高,而且数值接近。
调整思路是从0(无正则化)开始逐步增大alpha,观察测试误差先降后升的那个拐点,它就是最优区间附近。如果你用了交叉验证网格搜索,拐点会自动找到。如果一个项目里多项式次数从1到20都试过了,alpha也扫过很宽的网格,训练误差就是下不来,那大概率是数据生成或预处理环节出了问题——比如x和y对不上号,或者特征矩阵的列顺序错了,先画图看看散点和预测曲线是否在同一个坐标系里,别急于优化算法。
6.3 现象:测试误差比训练误差低很多
这看起来是好事,但实际是bug——很多时候是因为数据泄露:你使用了包含测试集统计量的预处理流程。最常见的情节是,标准化时用fit_transform处理了整个x拼接后的数组,然后把其中一部分当作训练集另一部分当作测试集。均值和标准差包含了测试样本的信息,等效于模型在训练时偷偷看了测试数据的分布。测试误差自然好看。
另一种情节是网格搜索选择了在测试集上表现最好的alpha,然后把该alpha的测试误差当作最终泛化误差。这是典型的用测试集调参,结论虚低。解决办法:严格三集分离,测试集只在最后用一次。如果数据总量很少,至少用嵌套交叉验证来报告无偏的泛化误差估计。
6.4 现象:L1正则化结果不稳定,两次运行权重差异大
L1正则化对训练集的微小变化非常敏感,尤其是在特征高度相关的场景——因为多个特征可以互相替换来表达同样的预测,L1选择哪个特征是随机的。比如在多项式特征中,x^2和x^3在[-3,3]区间内高度相关,随机留下哪个都有道理。
工程上的应对是使用弹性网替代纯Lasso,或者对数据做不同的重采样(bootstrap)运行若干次,统计哪些特征被稳定地选择为重要特征。不要在未做特征工程的情况下直接迷信L1的“特征筛选”结果。如果你关心的是预测精度而不是特征解释性,Ridge长期来看比Lasso更稳定。
6.5 现象:闭式解报“Singular matrix”或权重巨大
设计矩阵接近奇异的原因是特征列高度共线,或者样本数小于特征数。多项式拟合中常见的触发条件是多项式的次数达到样本数甚至超过样本数。没有正则化时X^T X求逆或np.linalg.solve会直接失败;加了正则化后X^T X + alpha*I在理论上改善了条件数,但如果alpha太小或需要求逆的矩阵是零矩阵填充的更高维度,依然可能数值不稳。
解决的办法有两个层面:数值上,把alpha提高到1e-6以上并做特征标准化,通常可以稳定求解;另一个思路是用SVD分解替代直接求逆——np.linalg.pinv能稳定输出最小范数解,但要注意这不等同于正则化,它没有压缩所有方向上的权重强度。正规方程加一个适度大小的alpha理论上已经做了类似的收缩操作,如果依旧报警,检查是不是矩阵维度构造错了或某列为常数零。
7. 一个有用的落地技巧:用交叉验证曲线自动锁定alpha——我的习惯做法
项目跑通后,最值得花时间打磨的就是选择alpha的自动化流程。手工看网格搜索的打印结果固然直观,但alpha候选少了容易漏过最优点,候选多了又看得眼花。我习惯的做法是输出一条“交叉验证误差随alpha变化”的曲线数据,从曲线最低点右侧一个刻度处选alpha,而不是机械地选最低点——因为曲线最低点往往伴随过拟合风险,右侧一点点的alpha虽然验证误差略高,但测试集上往往更稳定。
import numpy as np def cross_validate_for_alpha(X, y, alpha_list, n_folds=5): """k 折交叉验证选择最优 alpha,返回每个 alpha 的平均验证误差与标准差""" n = len(y) fold_ids = np.arange(n) % n_folds rng = np.random.default_rng(0) rng.shuffle(fold_ids) mean_scores = [] std_scores = [] for alpha in alpha_list: fold_errors = [] for f in range(n_folds): X_tr = X[fold_ids != f] y_tr = y[fold_ids != f] X_va = X[fold_ids == f] y_va = y[fold_ids == f] w = fit_ridge_closed_form(X_tr, y_tr, alpha) pred = X_va @ w fold_errors.append(np.mean((pred - y_va) ** 2)) mean_scores.append(np.mean(fold_errors)) std_scores.append(np.std(fold_errors)) return alpha_list, mean_scores, std_scores X_all = build_polynomial_features(np.concatenate([x_train, x_test]), degree=15) y_all = np.concatenate([y_train, y_test]) alphas = [1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1.0] _, cv_mean, cv_std = cross_validate_for_alpha(X_all, y_all, alphas, n_folds=5) for a, m, s in zip(alphas, cv_mean, cv_std): print(f"alpha={a:.0e}, cv_error={m:.6f}±{s:.6f}")这段代码的关键在于:X_all和y_all同时包含训练测试的全部数据,但交叉验证过程中每个fold轮流充当验证集,全部样本都参与训练也参与验证,不浪费任何数据,比固定划分的训练/测试对比更可靠。输出带标准差的意义是,不仅看平均误差,还要看稳定性——如果某个alpha的平均误差低但标准差很大,说明它对数据划分方式敏感,不建议选它。
我在做这类工程时还有个习惯:把归一化也放进交叉验证的每一折内部重新拟合。也就是说,在每折的训练子集上计算均值和标准差,用它们标准化各自的训练子集和验证子集,而不是先在全局数据上计算好再切分。这虽然在代码上稍显繁琐,但更严格,得到的误差估计对真实环境的预测更有参考价值。如果你手头的工具箱已经用了sklearn的Pipeline,它天然就按这个语义工作,但自研代码里容易不自觉走过这一步,值得专门检查。
关于弹性网和Lasso在交叉验证中的行为,还有一点值得注意:Lasso在alpha很小时稀疏性很弱,坐标下降法可能需要非常多的迭代才能完全收敛,检查收敛的标志是看权重是否稳定不变;如果看到的输出结果在两次不同seed下差异显著,先排除收敛问题,再考虑特征相关性的影响。压缩包工程里如果带了测试脚本,你完全可以改改alpha列表和fold数量,观察误差曲线如何移动,这个动手过程会比任何文章都更能建立直觉。
最后说一个我早年踩过的坑:以前我总习惯先看网格搜索里哪个alpha的测试误差最低,就直接用那个模型上线,结果下个月换了新数据后效果崩得一塌糊涂。后来我把习惯改成交叉验证选参、独立测试集只做一次确认,翻车率一下子降了下来。希望这篇笔记里的思路——把特征构造、标准化、求解器和系数搜索全部落在独立函数里,每个环节都能被单独验证——也能帮到你。
本文还有配套的精品资源,点击获取