1. 白话拆解:欠拟合与过拟合到底在闹哪样
先别急着上公式,我们用一个特别生活化的场景来理解这两个让无数人头疼的概念。假设你要教一个小朋友认识猫,方式很简单:给他看图片。第一轮,你只给小朋友看了三张猫的照片,一张白猫、一张黑猫、一张橘猫。结果学习结束,小朋友信心满满出门,遇到一只狸花猫,愣住了,说这不是猫。这就是典型的欠拟合——模型学得不够,连基本的规律都没抓到,训练集上表现就不好,测试集上更差。
第二轮,你吸取教训,给小朋友看了整整一万张猫的照片,每张都反复讲。这次更糟糕,小朋友记住了所有照片里的细节:某只猫耳朵上有个缺口,某只猫背景里有沙发,某只猫脖子上有铃铛。结果出门后,他看到一只没有铃铛、没有缺口、背景是草地的纯色猫,大声说这是狗。这就是过拟合——模型把训练数据里的噪声和无关细节当成了规律,训练集上完美,遇到新数据直接翻车。
在机器学习里,我们追求的从来不是“记住所有答案”,而是“学会举一反三”。欠拟合是模型表达能力不够,或者训练不到位;过拟合是模型表达能力太强,加上数据量不足或训练时间过长,导致它把“死记硬背”发挥到了极致。一个合格的数据科学从业者,日常至少有三分之一的时间在跟这两个东西做斗争,剩下的三分之二在准备数据。
这篇文章要解决的核心问题很简单:怎么一眼识别出当前模型是欠拟合还是过拟合?以及当发现过拟合时,L1/L2正则化到底做了什么,代码层面怎么落地?我会用Python和sklearn把整个流程走一遍,代码可以直接复制运行,公式尽量口语化解释,保证你不仅看懂,还能在下次训练模型时真正用起来。
无论是为了应付机器学习期末考试、课程设计,还是在实际项目中调试模型,这篇文章都能给你一套可复用的诊断思路和工具方法。如果你是刚开始接触机器学习,建议先跑通文中的代码,再回头理解原理,效果会好很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从训练误差和验证误差看透模型状态
2.1 误差曲线的三个经典形态
判断模型状态,最直观的手段就是画出训练集和验证集的误差曲线。这里的误差可以是损失函数值,也可以是准确率、均方误差等指标。为了方便说明,我以回归任务为例,用均方误差(MSE)来衡量。
假设我们有一份带噪声的模拟数据,真实函数是二次函数,但我们用不同复杂度的模型去拟合,会看到三种典型的曲线形态:
- 模型过于简单(比如用线性函数拟合二次关系):训练误差和验证误差都维持在高位,两条曲线几乎贴在一起,误差值大得离谱。这是欠拟合。
- 模型复杂度适中(比如用二次多项式拟合):训练误差和验证误差都降到了较低水平,两条曲线间距不大,验证误差没有明显反弹。这是健康状态。
- 模型过于复杂(比如用十次多项式拟合):训练误差一路狂跌,逼近零,但验证误差先降后升,两条曲线之间出现一个巨大的“喇叭口”。这是过拟合。
用一个形象的比喻:欠拟合是学生上课没听懂,期末考试和平时作业都不会;过拟合是学生把课后习题答案背得滚瓜烂熟,结果考试题目稍微变个数字就完全懵逼。正则化要做的,就是在“背答案”和“理解规律”之间找到平衡点。
2.2 学习曲线的正确打开方式
只看训练结束时的两个误差点还不够,更严谨的做法是画学习曲线——横轴是训练集大小,纵轴是误差。随着训练样本增多,训练误差通常逐渐上升(因为数据多了更难完美拟合),验证误差逐渐下降(因为模型能看到更多规律)。两条曲线最终会趋于平稳并靠近。
如果两条曲线已经靠得很近但误差都很高,说明模型是欠拟合,这时候加再多的数据也没用,应该增加模型复杂度或更换更强的算法。如果两条曲线之间始终存在明显差距,并且验证误差不再下降,说明模型过拟合,这时候增加训练样本量往往能立竿见影,或者用正则化压缩模型复杂度。
我见过很多同学一看到验证误差高就急着加正则化,其实得先搞清楚是哪种问题。欠拟合状态下强行加L2正则化,只会让模型更“傻”,误差更高。所以第一步永远是识别,第二步才是对症下药。
2.3 用验证集做“考场模拟”
另一个实用技巧是划分训练集、验证集和测试集。训练集负责调整参数,验证集用来选择超参数(比如正则化强度、多项式阶数),测试集只在最终评估时碰一次。很多人图省事只划分训练集和测试集,然后不停拿测试集试超参数,这等于把考试的答案提前偷看了,最后的测试成绩毫无参考价值。
正确流程是:训练集上训练,验证集上选超参数,选好之后再在测试集上做最终评估。如果验证集不够,也可以用交叉验证,把训练数据分成K份轮流做验证,取平均误差作为评估标准。sklearn里直接有validation_curve和learning_curve两个函数可以用,后面实战部分会展示。
3. 正则化的核心逻辑:给损失函数加一点“约束”
3.1 正则化项到底是什么
当我们训练模型时,目标是最小化损失函数,比如线性回归的均方误差:
[
J(\theta) = \frac{1}{m}\sum_{i=1}^{m}(h(x^{(i)}) - y^{(i)})^2
]
加上正则化项之后,目标函数变成:
[
J_{reg}(\theta) = J(\theta) + \lambda \cdot R(\theta)
]
其中 ( R(\theta) ) 是惩罚项,( \lambda ) 是正则化强度。L1正则化对应 ( R(\theta) = \sum_{j=1}^{n}|\theta_j| ),L2正则化对应 ( R(\theta) = \sum_{j=1}^{n}\theta_j^2 )。
我把它翻译成人话:原来的目标只是让预测误差尽量小,现在加了一个“约束”,不允许模型的参数(权重)随便放飞自我。如果某个参数值特别大,即使能让训练误差降一点,但惩罚项也会变大,模型就不敢轻易采用这个参数了。
3.2 L1和L2的直观区别
L1正则化对每个参数的绝对值进行惩罚,它的特点是会把某些参数直接压缩成0。这意味着它不仅能防止过拟合,还能顺便做特征选择——把不重要的特征对应的权重变成0,模型精简了,可解释性也更强。想象一下,你在收拾行李箱,L1就是狠心扔掉那些“可能用得上”但八成用不上的东西,最终只保留必需品。
L2正则化对参数的平方进行惩罚,它不会把参数变成0,而是让所有参数都尽量保持在一个较小的值附近。它的特点是让模型的权重平均分散,不会对某一个特征特别敏感。还是收拾行李箱的例子,L2就是不允许带特别大的物品,但小物件可以都带一点,整体重量受限。
从图形角度看,L1约束区域是菱形,L2约束区域是圆形。在二维平面上,如果损失函数的等高线和约束区域相切,L1的切点更容易落在坐标轴上,所以会产生稀疏解;L2的切点通常不会落在坐标轴上,所以参数保持非零但被缩小。
3.3 λ怎么选
λ控制惩罚的强度。λ太小,正则化几乎没有作用,过拟合照旧;λ太大,模型被“压”得太狠,所有参数接近0,模型退化成简单模型,出现欠拟合。实际工作中一般选一组λ值(比如0.001、0.01、0.1、1、10……),在验证集上挨个试,选择验证误差最小的那个。这个过程也叫超参数调优,sklearn里有GridSearchCV专门干这个活。
4. 实战:用多项式回归演示欠拟合与过拟合
4.1 生成模拟数据和基础模型
下面进入代码环节。我们用Python生成一组带噪声的模拟数据,真实关系是二次函数,然后用不同阶数的多项式回归来观察欠拟合和过拟合现象。为了省事,我直接用sklearn.preprocessing.PolynomialFeatures生成多项式特征,配合LinearRegression使用。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split, learning_curve, validation_curve
from sklearn.metrics import mean_squared_error
# 固定随机种子,保证结果可复现
np.random.seed(42)
# 生成模拟数据:真实函数为 y = 4x^2 - 3x + 2 + 噪声
X = np.linspace(-3, 3, 200).reshape(-1, 1)
y = 4 * X.ravel()**2 - 3 * X.ravel() + 2 + np.random.normal(0, 3, X.shape[0])
# 划分训练集和验证集(测试集先放一边,等最后评估)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
这里X是200个均匀分布在-3到3之间的点,y是二次函数值加上标准差为3的高斯噪声。这样的模拟数据能清楚看到不同模型复杂度的表现差异。
接下来定义一个函数,输入多项式阶数,返回训练好的模型和预测结果:
python复制def fit_polynomial(degree, X_train, y_train, X_test, y_test):
# 构造流水线:先扩展多项式特征,再线性回归
model = Pipeline([
('poly', PolynomialFeatures(degree=degree)),
('linear', LinearRegression())
])
model.fit(X_train, y_train)
train_pred = model.predict(X_train)
test_pred = model.predict(X_test)
train_mse = mean_squared_error(y_train, train_pred)
test_mse = mean_squared_error(y_test, test_pred)
return model, train_mse, test_mse
4.2 训练不同阶数,直观对比
我们用1阶(线性)、2阶、10阶来分别训练,并打印对应的MSE:
python复制for degree in [1, 2, 10]:
model, train_mse, test_mse = fit_polynomial(degree, X_train, y_train, X_test, y_test)
print(f"degree={degree:2d} train_mse={train_mse:.4f} test_mse={test_mse:.4f}")
输出结果大概是这样的(每次运行由于随机噪声略有差异,但趋势一致):
code复制degree= 1 train_mse=46.5321 test_mse=52.1843
degree= 2 train_mse= 8.1024 test_mse= 9.2157
degree=10 train_mse= 5.1321 test_mse=18.6742
看到没,1阶模型的训练误差和测试误差都很高,这是欠拟合。2阶模型的训练误差和测试误差都比较低,差距不大,这是理想状态。10阶模型的训练误差最低,但测试误差猛涨,这就是过拟合的典型特征——训练集上把噪声也拟合进去了,到了新数据上原形毕露。
把三个模型拟合的曲线画出来,你会看到:1阶是一条直线,完全没贴合数据形状;2阶曲线平滑且贴合整体趋势;10阶曲线则疯狂波动,试图穿过每一个点。
4.3 绘制学习曲线辅助诊断
画学习曲线可以帮助我们确认模型状态。比如对线性模型(1阶)画学习曲线:
python复制def plot_learning_curve(model, X_train, y_train):
train_sizes, train_scores, val_scores = learning_curve(
model, X_train, y_train, cv=5,
train_sizes=np.linspace(0.1, 1.0, 10),
scoring='neg_mean_squared_error'
)
train_mean = -train_scores.mean(axis=1)
val_mean = -val_scores.mean(axis=1)
plt.plot(train_sizes, train_mean, label='training error')
plt.plot(train_sizes, val_mean, label='validation error')
plt.xlabel('Training set size')
plt.ylabel('MSE')
plt.legend()
plt.show()
linear_model = Pipeline([
('poly', PolynomialFeatures(degree=1)),
('linear', LinearRegression())
])
plot_learning_curve(linear_model, X_train, y_train)
从曲线上能看到,当训练集大小增加时,训练误差和验证误差都保持在高位,而且两者靠得很近。这是欠拟合的另一个标志——无论怎么增加数据,误差都降不下来。这时候正确的做法不是加数据,而是增加模型复杂度,或者用更复杂的算法。
对于10阶模型,画学习曲线则会发现训练误差始终很低,验证误差却一直很高,两条曲线之间存在很大的“缝隙”。这说明模型方差太大,数据一多或一少,表现波动剧烈。这种状态靠增加数据量有一定缓解作用,但更直接的办法是引入正则化。
5. L1/L2正则化代码实现与参数调节
5.1 在Pipeline中加入正则化
sklearn的Ridge和Lasso就是带L2和L1正则化的线性模型。我们要在多项式回归中使用它们,直接替换掉LinearRegression即可。下面实现一个能指定多项式阶数和正则化类型的函数:
python复制from sklearn.linear_model import Ridge, Lasso
def fit_regularized(degree, reg_type, alpha, X_train, y_train, X_test, y_test):
if reg_type == 'l2':
reg_model = Ridge(alpha=alpha)
elif reg_type == 'l1':
reg_model = Lasso(alpha=alpha, max_iter=100000) # L1需要更多迭代
else:
reg_model = LinearRegression()
model = Pipeline([
('poly', PolynomialFeatures(degree=degree)),
('reg', reg_model)
])
model.fit(X_train, y_train)
train_pred = model.predict(X_train)
test_pred = model.predict(X_test)
train_mse = mean_squared_error(y_train, train_pred)
test_mse = mean_squared_error(y_test, test_pred)
return model, train_mse, test_mse
注意,Ridge的参数叫alpha,也就是我们前面公式里的λ。Lasso同样用alpha。当多项式阶数较高(比如10阶)时,如果不加正则化,测试误差爆炸;加上正则化之后,即使多项式阶数高,模型也会因为权重被约束而表现得稳定。
5.2 对比不同alpha的效果
我们固定多项式阶数为10,依次尝试不同的alpha值:
python复制for alpha in [0, 0.01, 0.1, 1, 10, 100]:
model, train_mse, test_mse = fit_regularized(10, 'l2', alpha, X_train, y_train, X_test, y_test)
print(f"alpha={alpha:8.2f} train_mse={train_mse:.4f} test_mse={test_mse:.4f}")
输出趋势大致为:
code复制alpha= 0.00 train_mse= 5.1321 test_mse=18.6742
alpha= 0.01 train_mse= 5.4213 test_mse=12.0314
alpha= 0.10 train_mse= 6.0102 test_mse= 9.8576
alpha= 1.00 train_mse= 8.3456 test_mse= 8.9231
alpha= 10.00 train_mse=15.6782 test_mse=13.2456
alpha= 100.00 train_mse=42.1234 test_mse=38.9876
看到规律了没有?alpha等于0时,就是纯10阶多项式回归,过拟合严重。alpha逐渐增大,测试误差先是下降,因为正则化抑制了过拟合;但alpha过大后,训练误差和测试误差都开始上升,模型被“压扁”成近似直线了,转入欠拟合。所以对alpha的选择,就是一个在偏差和方差之间走钢丝的过程。
对于L1正则化,同样的流程,只是当alpha增大到一定程度时,Lasso会把许多特征系数直接变成0,表现在多项式回归里,就是高次项的系数被清零,模型自动降低复杂度。
5.3 用验证曲线选择最佳alpha
手工试alpha虽然直观,但效率低。推荐用validation_curve函数自动跑一系列alpha并绘图:
python复制from sklearn.model_selection import validation_curve
alpha_range = np.logspace(-3, 3, 30)
train_scores, val_scores = validation_curve(
Pipeline([('poly', PolynomialFeatures(degree=10)),
('reg', Ridge())]),
X_train, y_train,
param_name='reg__alpha',
param_range=alpha_range,
cv=5,
scoring='neg_mean_squared_error'
)
train_mean = -train_scores.mean(axis=1)
val_mean = -val_scores.mean(axis=1)
# 找到验证误差最小的alpha
best_idx = np.argmin(val_mean)
best_alpha = alpha_range[best_idx]
print(f"Best alpha = {best_alpha:.4f}, validation MSE = {val_mean[best_idx]:.4f}")
这个方法的意义在于,它把模型选择和超参数调节自动化了。你可以把Ridge换成Lasso,PolynomialFeatures换成其他特征变换,就变成一套通用的调参工具。很多实际项目中,正则化强度就是用类似方式定下来的,而不是靠感觉。
6. 实战中还踩过的那些坑
6.1 数据标准化必须做
在使用L1/L2正则化之前,标准化特征是极其重要的一步。因为正则化惩罚的是参数的大小,如果某个特征的取值范围是0到100,另一个是0到0.01,那量纲大的特征对应的系数会被惩罚得更狠,导致模型选出来的特征不客观。
在sklearn里,可以在Pipeline中加入StandardScaler,确保每个特征都在同一尺度下:
python复制from sklearn.preprocessing import StandardScaler
model = Pipeline([
('scaler', StandardScaler()),
('poly', PolynomialFeatures(degree=10)),
('reg', Ridge(alpha=best_alpha))
])
这里有个细节:StandardScaler放在PolynomialFeatures之前还是之后?取决于你的需求。如果先把原始特征标准化,再生成多项式特征,那么多项式特征也能保持合理范围。如果先多项式再标准化,对每个多项式特征也做标准化,同样可以。一般而言,先标准化再多项式更常见,也能避免生成的高次项数值爆炸。
6.2 过拟合不等同于“测试误差高”
我见过有人把“测试集MSE高”直接等同于过拟合,这是不对的。测试误差高,也可能是欠拟合导致的。要区分二者,必须同时看训练误差和测试误差。如果训练误差高、测试误差也高,这是欠拟合;如果训练误差低、测试误差高,这才是过拟合。很多初学者只看一个指标,结果用错误的方向去调参,白白浪费时间。
6.3 L1正则化的非稀疏性陷阱
L1正则化虽然理论上会得到稀疏解,但在sklearn的Lasso实现中,如果alpha设置过小,或者迭代次数不够,模型可能不会收敛到真正的稀疏解。需要适当调大max_iter,并在训练后检查一下系数,看是否明显有很多0。另外,Lasso在处理特征数量多于样本数量的高维数据时,最多只能选出和样本数量一样多的非零特征,这一点要特别注意。
6.4 多项式特征不要盲目追求高次
有些同学看到正则化可以抑制过拟合,就放心大胆地把多项式阶数设到20、30,想着反正有正则化兜底。实际上,过高的多项式阶数会生成大量特征,而且这些特征之间高度相关,即使有正则化,数值稳定性和计算效率也会变差。我的建议是,在没有特殊理由的情况下,多项式阶数尽量控制在10次以内,再结合正则化调优,性价比最高。
6.5 交叉验证的折数选择
交叉验证的cv参数,常用的有5或10。如果训练数据只有一百条左右,cv=5可能每折只有二十条,验证噪声会很大;如果数据上万条,cv=10可能训练时间较长。实际中先试试cv=5,如果验证曲线抖动得很厉害,可以改用更大的cv值或者重复多次交叉验证取平均。validation_curve函数本身没有重复机制,必要时可以用RepeatedKFold配合GridSearchCV做更稳健的评估。
7. 这两种正则化在实际业务里怎么选
7.1 选L1还是L2,核心看你要不要特征选择
如果你手里有几十上百个特征,而且怀疑大部分特征都是噪声,希望模型自动挑出真正有效的特征,那优先选择L1正则化(Lasso)。它会直接把无用特征的权重压缩成0,训练完之后你直接看系数,非零系数对应的就是模型认为有意义的特征。这在特征工程阶段尤其有用,可以快速做一轮粗选。
如果特征是精心整理过的,每个特征都有业务含义,不希望模型“一棍子打死”某个特征,只想抑制权重过大导致的过拟合,那用L2正则化(Ridge)更合适。L2会温柔地把所有权重压缩,但不会彻底清零,模型依然会综合考虑所有特征。
当然,也有二者结合的ElasticNet,同时使用L1和L2惩罚,适合特征数量较大且存在相关性情况下的稳健建模。需要调整两个超参数l1_ratio和alpha,在sklearn中调用起来也很简单。
7.2 从模型的可解释性角度
在一些需要向老板、客户解释的行业建模场景里,L1正则化带来的稀疏模型具有天然优势。你只需要说“系统最终只挑选了5个关键变量做出决策”,要比拿着一堆权重解释容易太多。但稀疏模型的稳定性可能略差,因为特征选择结果对数据微小变化比较敏感,如果业务数据经常更新,可能需要频繁重建模型。
L2正则化不会改变特征数量,适合那些特征本身就有各自独立价值的场景,比如电商模型中,价格、评分、销量、点击率都是重要变量,我们不想舍弃任何一个,只是不希望单个变量的影响被放得过大。
7.3 正则化只能治标,治本还是要靠数据
最后说点掏心窝的话。正则化是应对过拟合最常用的手段,但绝不是唯一手段,也不是最优手段。如果过拟合特别严重,最根本的解决方案是增加训练数据量。数据多了,模型自然会发现“背答案”是背不完的,只能去总结规律。但是在工作中,新增高质量数据往往成本很高,这时候正则化就是一个低成本、见效快的好帮手。
另外,早停法(early stopping)、Dropout、数据增强等技术,也分别在不同领域发挥着抑制过拟合的作用。对于线性模型和多项式回归,正则化是主角;对于深度学习,Dropout和早停可能更常用。理解每一种技术的适用场景,才能在遇到问题时选择最优解。
8. 一段可以直接用的诊断与调参模板
这里分享一个我平时做回归问题时的标准流程,你可以直接当作模板套用:
- 把数据划分为训练集、验证集、测试集。
- 定义一组候选模型(不同复杂度,比如多项式的不同阶数)。
- 在训练集上训练,同时画出训练误差和交叉验证误差的曲线。
- 根据学习曲线判断是欠拟合、过拟合还是健康。
- 若是欠拟合,增加模型复杂度或特征;若是过拟合,先尝试增加数据,数据不足则加正则化。
- 用验证曲线选择正则化参数alpha。
- 最终在测试集上评估一次,记录最终指标。
这段流程既适合课程作业,也可以用在真实项目的第一版基线模型上。我强烈建议你把它固化成自己的习惯,因为很多问题模型的表现不佳,根源不在算法,而在这套诊断流程没有走完整。
python复制# 统一模板示例
from sklearn.model_selection import GridSearchCV
param_grid = {
'poly__degree': [2, 3, 4, 5],
'reg__alpha': np.logspace(-2, 2, 10)
}
grid_model = GridSearchCV(
Pipeline([('poly', PolynomialFeatures()),
('reg', Ridge())]),
param_grid=param_grid,
cv=5,
scoring='neg_mean_squared_error'
)
grid_model.fit(X_train, y_train)
print("Best params:", grid_model.best_params_)
print("Best CV MSE:", -grid_model.best_score_)
print("Test MSE:", mean_squared_error(y_test, grid_model.predict(X_test)))
这段代码一次性搜索了多项式阶数和正则化强度的最佳组合,比手动试高效得多。GridSearchCV的本质就是穷举参数组合,然后选出交叉验证分数最高的那一组,非常适合参数不多的情况。
9. 两个容易忽略的细节补充
9.1 分类问题中的正则化
上面全程用回归举例,但L1/L2正则化在分类问题中同样适用。LogisticRegression的参数penalty可以设置为l1或l2,solver也要做相应调整(l1需要用liblinear或saga)。支持向量机SVC中也有C参数,C是正则化的倒数,C越小正则化越强。理解了回归中的原理,分类问题换个框架也是一样的事。
9.2 不要把正则化用在特征变换上
还有一些新手会把正则化直接用在PolynomialFeatures生成的原始特征上,以为这样就行。其实正则化是针对模型权重(系数)的约束,不是针对特征的约束。特征的标准差如果很大,应该用StandardScaler去缩放,正则化管不了这个。该做的事各司其职,Pipeline里顺序一定要放对。
10. 写在最后的实操心得
我在训练模型时经常提醒自己一句话:“误差高不一定意味着模型差,要看高在哪里。” 一个人如果只看训练集MSE,很容易被过拟合骗得沾沾自喜;只看测试集MSE,又容易陷入盲目调参的泥潭。真正的经验是把学习曲线、验证曲线、交叉验证这些工具用熟,形成一套自己的诊断套路。
另一个心得是,正则化强度的选择一定要和数据量挂钩。如果数据特别少,哪怕很小的alpha也会产生明显效果;如果数据量大到一定程度,正则化可能彻底多余。我在一个项目里,数据从两万条增加到五十万条之后,原来选好的最佳alpha直接可以降到几乎为零,因为数据量已经把过拟合压下去了。所以别把调好的一组超参数当成永久答案,数据更新后要重新验证。
最后再分享一个小技巧:训练完之后,把模型系数画出来看分布。L1正则化会得到很多零,L2正则化会把系数压缩成类似正态分布的小值。用plt.bar(range(len(coef)), coef)随手一画,比盯着误差数值更能直观感受正则化在做什么。
欠拟合和过拟合是机器学习入门的第一个坎,也是职业道路上会反复遇到的常客。把本文中的概念和代码真正跑通一遍,之后再遇到类似问题,你会有一种“见怪不怪”的从容。愿你在模型调试的路上少踩坑,多出好结果。
