1. 先搞清楚模型在“闹脾气”:欠拟合与过拟合的本质
如果你已经跑通了几个经典的机器学习入门项目,比如用sklearn做线性回归、用逻辑回归做分类,大概率会遇到一种让人很头疼的情况:训练集上表现很好,一到测试集上就拉胯;或者反过来,训练集上误差就很大,怎么调都降不下去。这两类问题,前者叫过拟合(Overfitting),后者叫欠拟合(Underfitting)。很多新手会把它们当成“模型不行”或者“数据不行”,但实际上,这俩是机器学习里最核心、也最值得花时间搞懂的诊断问题。
先说欠拟合。它本质上就是模型能力不够,连训练集里的规律都没学明白。就好比你让一个实习生去处理一份他完全没接触过的行业报表,他连表头都看不懂,自然给不出靠谱的结果。模型也是一样,当模型复杂度太低、特征太少、或者训练轮次不够时,它在训练集和测试集上的误差都会偏高,这是它的“能力天花板”太低导致的。
过拟合则完全相反,是模型“太聪明”了,聪明到把训练集里的噪音、异常点、个别样本的偶然规律都当成了一般的规律去学习。你可以把它理解成一个考试前把参考答案逐字逐句背下来的学生——遇到原题他能考满分,但只要题目稍微变一下,他就懵了。在机器学习里,这个“变一下”就是测试集和训练集的分布差异。过拟合的典型表现是:训练集误差极低甚至为0,测试集误差却高得离谱,模型的泛化能力很差。
我见过太多初学者在这上面走弯路。有人一看训练集效果好就兴冲冲上线,结果线上效果被打回原形;有人一看到测试集效果差,就开始疯狂加数据、加特征、调参,折腾了一个月才发现问题出在模型根本没有学到有效信息上。所以,先把欠拟合和过拟合的识别搞清楚,是比调参更优先的事情。
好在,识别这两种问题并不需要什么高深的手段,最实用的方法就是画学习曲线,用训练误差和验证误差随数据量或训练轮次的变化趋势来判断。这也是这一节想带你先掌握的核心能力:不是急着优化,而是先看明白模型到底处于什么状态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一招诊断模型状态:学习曲线的原理与画法
2.1 学习曲线到底在看什么
学习曲线(Learning Curve)是最直观、也最不容易出错的模型状态诊断工具。它的横轴通常是训练样本数量(也有用训练轮次的),纵轴是误差或者准确率,然后分别画出模型在训练集和验证集上的表现随数据量变化的两条曲线。
正常情况下,随着训练样本增加,训练集误差会逐渐上升(因为数据变多,模型更难把每个样本都背下来),验证集误差会逐渐下降(因为模型学到了更多真实的规律,泛化能力变强),最后两条曲线会趋近于一个相同或者接近的值,那个值就代表了这个模型在当前特征组合下的“真实水平上限”。
如果两条曲线之间一直隔着一道鸿沟——训练集误差很低、验证集误差很高——那就要警惕过拟合了。如果两条曲线都高位徘徊、怎么都不往下走,那就是欠拟合。
我在实际项目里一般会用sklearn的learning_curve函数来画,它可以自动做K折交叉验证,帮你算不同训练集大小下模型在训练集和验证集上的平均得分,比自己手动切数据要靠谱得多,因为用了交叉验证,结果更稳、更不容易被某一次随机的数据切分带偏。
2.2 三类典型状态的长什么样
把模型状态分成三类来看,最清晰:
第一类,欠拟合。 训练集和验证集的误差都很高,两条曲线在图表上方贴得很近。这种时候你加再多的训练数据也没用,因为模型根本没那个容量去学更多东西。正确的解法是提升模型复杂度,比如用多项式特征替代线性特征、增加神经网络的层数或神经元数量、减少正则化强度,或者换一个更强的模型。
第二类,过拟合。 训练集误差一路走低、低到令人发指,验证集误差却在某个点之后开始反弹或者居高不下。两条曲线之间有一个很大的“gap”。这种时候加数据是有效果的,但很多时候数据不好搞,更快的办法是正则化、Dropout、早停(Early Stopping)或者特征选择。
第三类,正常。 两条曲线最终收敛在一个较低的位置,而且差值不大。这个状态下的模型基本上就是“训练到位了”。
画学习曲线这件事,花不了十分钟,但它能帮你少走好几天的弯路。我自己的习惯是,每跑一个新模型,先画一条学习曲线看看状态,再决定下一步是加数据、加特征、加复杂度,还是上正则化。下面给出一段可以跑的代码,直接换成你自己的数据和模型就能用。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LinearRegression
# 这里用波士顿房价数据做演示,实际使用时换成自己的数据集
from sklearn.datasets import load_diabetes
data = load_diabetes()
X, y = data.data, data.target
train_sizes, train_scores, valid_scores = learning_curve(
LinearRegression(), X, y,
train_sizes=np.linspace(0.1, 1.0, 10),
cv=5, scoring='neg_mean_squared_error'
)
train_errors = -train_scores.mean(axis=1)
valid_errors = -valid_scores.mean(axis=1)
plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_errors, 'o-', label='Train Error')
plt.plot(train_sizes, valid_errors, 's-', label='Validation Error')
plt.xlabel('Training Set Size')
plt.ylabel('MSE')
plt.legend()
plt.title('Learning Curve')
plt.show()
提示:
learning_curve在sklearn 1.2之前可以用scoring='neg_mean_squared_error',之后的版本记得检查sklearn的版本兼容性。另外,如果你的样本量很大,比如几十万条,学习曲线的计算会比较慢,可以适当减少train_sizes的取值个数。
如果你发现画出来的曲线趋势不明确,也有一个可能:数据划分或预处理出了问题。比如你在做多项式特征时,直接用fit_transform处理了全部数据,而不是在训练集上fit完再对测试集transform,这会导致数据泄漏,让验证集结果虚高,学习曲线的参考价值就会大打折扣。这个问题在后面的实战部分还会再次遇到,务必留意。
3. L1/L2正则化:往损失函数里加一项,为什么能治过拟合
3.1 正则项的数学直觉
在学习曲线确认了模型过拟合之后,最常用的手段之一就是正则化(Regularization)。正则化的思路非常朴素:既然模型太复杂导致它把噪音也学进去了,那我们就给它加点“惩罚”,让它不敢轻易把参数变得很大。
神经科学的类比可能不太好懂,换个生活化的说法。你请了一个非常健谈的顾问,他能把你公司过去三年的每条OKR细节、每个人的考勤异常全部复述出来,显得很专业,但真正遇到新问题时,他给出的建议全是从旧数据里翻出来的,没有一句话能落地。这时候你要在他出报告时加一条规矩:每条建议必须足够简洁、通用,用词不能太偏激,如果太“个性化”就扣钱。顾问为了不被扣钱,就不敢把话说得太满、太细,只能挑真正稳定的规律来讲。正则化干的就是这件事——它在损失函数里加了一个“扣钱项”,模型为了总体损失最小,就只能收敛到参数更“温和”的状态。
写成公式的话,不带正则化的损失函数是:
[
J(\theta) = \frac{1}{n}\sum_{i=1}^{n} L(y_i, \hat{y}_i)
]
加上L2正则化后就变成了:
[
J(\theta) = \frac{1}{n}\sum_{i=1}^{n} L(y_i, \hat{y}i) + \lambda \sum^{p} \theta_j^2
]
加L1正则化则是:
[
J(\theta) = \frac{1}{n}\sum_{i=1}^{n} L(y_i, \hat{y}i) + \lambda \sum^{p} |\theta_j|
]
这里的(\lambda)就是正则化强度的系数,代表你在训练集误差和模型复杂度之间取的平衡。(\lambda)越大,模型越倾向于变成“不犯大错但也学不了太多东西”的状态;(\lambda=0)就是没有正则化,完全放任模型去拟合。
3.2 L1和L2有什么区别
L1和L2的数学形式差别看上去很小,但实际效果有本质不同。
L2正则化(也叫岭回归Ridge Regression里的那个正则项)惩罚的是参数的平方和。它的特点是:参数会被压得比较小,但不会直接被压成0。也就是说,L2会保留所有特征,只是让每个特征的权重都变小,降低模型对单个特征的敏感度,从而减小波动。
L1正则化(也叫Lasso回归的正则项)惩罚的是参数的绝对值之和。它的特点是:当(\lambda)足够大时,一些参数会被精确地压缩成0。换句话说,L1可以做自动特征选择——它会把那些不重要特征的权重清零,留下真正有用的特征。
怎么理解这件事呢?有一个很常见的几何解释。L2正则化对应的约束区域是一个圆形,而L1对应的是一个菱形。在菱形上,目标函数的等高线更容易接触顶点,而顶点处某些坐标就是0;在圆形上,等高线接触的点几乎不可能正好落在坐标轴上,所以L2不会把参数压到0。
如果特征维度很高、且你怀疑很多特征是没有意义的,L1比L2更好用。如果只是想让整体参数别太大、模型更平滑,L2是更稳妥的默认选择。
还有一个重要的点是:L1的正则化路径是不平滑的,在0点处不可导,所以传统梯度下降处理起来比较麻烦。常见的做法是用近端梯度下降(Proximal Gradient Descent),或者在sklearn里直接用Lasso自带的坐标下降法。自己用纯梯度下降实现L1时要小心,不能用普通的梯度直接更新。
我把两者的差异整理成一张表,方便你对比参考:
| 对比维度 | L2正则化(Ridge) | L1正则化(Lasso) |
|---|---|---|
| 惩罚项 | 参数的平方和 | 参数的绝对值之和 |
| 参数倾向 | 压缩到接近0,但一般不为0 | 可以精确压缩到0 |
| 是否具备特征选择能力 | 否 | 是 |
| 适合场景 | 特征间存在关联,希望整体平滑 | 高维稀疏数据,希望自动筛选特征 |
| 几何特征 | 圆形约束 | 菱形约束 |
| 主要问题 | 参数稠密,解释性略差 | 当特征强相关时,选择不稳定 |
3.3 从数学到工程:正则化什么时候该上
是不是所有模型都要加正则化?也不是。模型如果本身处在欠拟合状态,加正则化只会雪上加霜。正则化是用来解决“模型方差过大、泛化能力差”这类问题的,如果你的问题明明是偏差过大(欠拟合),正确动作是加复杂度而不是加惩罚。
工程上我总结了一个简单的判断流程:先用学习曲线诊断状态;如果是欠拟合,增加模型复杂度、增加特征、减少正则化;如果是过拟合,优先考虑加数据,数据加不了就上正则化、Dropout、早停。对线性模型,L1/L2正则化几乎是标配;对树模型(比如随机森林、XGBoost),正则化参数(如min_samples_split、max_depth、XGBoost里的reg_alpha和reg_lambda)也承担着类似的作用。
这里有一个很多人会忽略的细节:加了正则化之后,特征必须做标准化。因为正则化惩罚的是参数的绝对值或平方,如果某个特征的取值范围是0~100,另一个特征的取值范围是0~0.1,那么前者的参数天然会被压得更狠,后者则几乎不受影响。这不是我们想要的,因为惩罚应该跟特征本身的重要性相关,而不是跟尺度相关。所以,无论用Ridge还是Lasso,先做StandardScaler是必须的。
4. 实战:用Python对比欠拟合、过拟合和正则化效果
4.1 环境准备与数据构造
为了让对比效果足够直观,我这次用人工构造的回归数据集来做演示。这样我们可以精确控制噪音和真实规律,方便观察不同模型的行为。
我用numpy生成一份包含二次函数关系的样本,自变量在-3到3之间均匀取值,目标值为真实规律加上随机高斯噪音。同时我会故意生成一个多项式特征扩展到很高维度的模型,人为制造过拟合,在中途穿插对比不同正则化系数的效果。
需要提前安装好的库是numpy、matplotlib和sklearn,这三件套是机器学习入门最常用的组合。如果你没有装,直接用pip install numpy matplotlib scikit-learn就行。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.pipeline import make_pipeline
np.random.seed(42)
X = np.linspace(-3, 3, 80).reshape(-1, 1)
y = 0.8 * X[:, 0]**2 + 0.5 * X[:, 0] + 2 + np.random.normal(0, 2, size=X.shape[0])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
这里为什么要把standard scaling放在pipeline里?因为对于多项式特征,多项式的输出天然就带着不同次方的量级差异(比如x是3到3,x的5次方就是243到243,两者量级相差悬殊),如果不标准化,正则化惩罚就会不公平地集中在低阶项上,后面你想对比“到底哪个λ效果最好”就会得出错误的结论。
4.2 欠拟合模型演示
为了展示欠拟合,我先用一条直线去拟合这个明显有弯曲的数据分布。一个简单的线性模型在训练集和测试集上的表现都会很差,学习曲线也会一直卡在高位。
python复制linear_model = LinearRegression()
linear_model.fit(X_train, y_train)
train_pred = linear_model.predict(X_train)
test_pred = linear_model.predict(X_test)
train_mse = np.mean((y_train - train_pred)**2)
test_mse = np.mean((y_test - test_pred)**2)
print(f"Linear Model -> Train MSE: {train_mse:.3f}, Test MSE: {test_mse:.3f}")
跑完你会发现,训练集MSE和测试集MSE差距不大,但绝对值很高,都在十几这个量级。这就是典型的欠拟合:模型结构太简单,无法表达数据里的非线性趋势。此时如果强行上L2正则化,情况只会更糟,因为模型本来就学不够,你还限制它的能力,这不是方向错了嘛。
4.3 过拟合模型演示
接下来我换成15阶多项式特征。15阶多项式几乎可以完美穿过训练集的每个样本点,但它在测试集上的表现会很惨淡。这就是“死记硬背式学习”的典型代表。
python复制overfit_model = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
LinearRegression()
)
overfit_model.fit(X_train, y_train)
train_pred = overfit_model.predict(X_train)
test_pred = overfit_model.predict(X_test)
train_mse = np.mean((y_train - train_pred)**2)
test_mse = np.mean((y_test - test_pred)**2)
print(f"15-degree Polynomial Model -> Train MSE: {train_mse:.3f}, Test MSE: {test_mse:.3f}")
运行结果通常会是训练集MSE非常低(比如0.2以下),而测试集MSE反而比线性模型还要高(甚至飙到几十上百)。这说明模型在训练集上“背”得太好了,完全没抓住数据背后的稳定规律。
为了能直观看到曲线有多“扭曲”,还需要在连续的x网格上画出这个模型的预测结果。你想,训练集相邻样本点之间的区间里,15次多项式可以被某些极端的参数拉出剧烈的波浪形,这就是过拟合最典型的样子。
python复制x_grid = np.linspace(-3, 3, 300).reshape(-1, 1)
y_grid = overfit_model.predict(x_grid)
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.scatter(X_train, y_train, alpha=0.6, label='Train')
plt.scatter(X_test, y_test, alpha=0.4, label='Test')
plt.plot(x_grid, y_grid, 'r-', label='Overfit Model')
plt.legend()
plt.title('Overfitting with Degree 15')
4.4 L2和L1正则化登场
现在用同样的15阶多项式,但在全流程里分别加入Ridge和Lasso。重点观察两个角度:一是训练/测试MSE的变化趋势,二是拟合曲线的形态是否变得更“平滑”。
python复制ridge_model = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
Ridge(alpha=1.0)
)
ridge_model.fit(X_train, y_train)
test_pred_ridge = ridge_model.predict(X_test)
train_pred_ridge = ridge_model.predict(X_train)
print(f"L2 Regularized Model -> Train MSE: {np.mean((y_train - train_pred_ridge)**2):.3f}, \
Test MSE: {np.mean((y_test - test_pred_ridge)**2):.3f}")
Lasso的写法几乎一样,只要把Ridge(alpha=1.0)换成Lasso(alpha=0.1)即可。注意Lasso对alpha的敏感度比Ridge高很多,因为绝对值惩罚比平方惩罚更硬,alpha稍微大一点就可能把所有系数全部压成0,你需要根据自己的数据量尝试几个数量级的值。
我在实际调参时,一般会把alpha从np.logspace(-3, 3, 7)这个范围里试,先画出测试误差随alpha变化的曲线,选择测试误差最低点附近的值,而不是凭感觉拍一个数。后面会详细讲这个。
画图部分把三个模型放进同一张图里,能明显看到过拟合模型的曲线剧烈波动,Ridge和Lasso的曲线则平滑很多,和真实数据趋势吻合度更高。
python复制ridge_y_grid = ridge_model.predict(x_grid)
lasso_model = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
Lasso(alpha=0.05, max_iter=100000)
)
lasso_model.fit(X_train, y_train)
lasso_y_grid = lasso_model.predict(x_grid)
plt.subplot(1, 2, 2)
plt.scatter(X_train, y_train, alpha=0.6, label='Train')
plt.plot(x_grid, ridge_y_grid, 'g--', label='Ridge (L2)')
plt.plot(x_grid, lasso_y_grid, 'm-.', label='Lasso (L1)')
plt.legend()
plt.title('Regularization on Degree 15 Polynomial')
plt.show()
注意:Lasso默认迭代次数有时不够,尤其是特征维度高、样本量大的时候,会出现警告信息
ConvergenceWarning。遇到这个情况,把max_iter调大,比如100000,同时可以考虑把tol适当调大一点,加快收敛。我在很长一段时间里忽略了这个警告,后来才发现Lasso的结果其实是没收敛的,导致模型效果忽好忽坏,白踩了不少坑。
4.5 正则化强度的“甜点区”怎么找
正则化系数alpha从0.001到1000,横跨6个数量级。怎么选?最稳妥的办法就是把alpha跑一个网格,画出测试集误差随alpha变化的曲线,找到“甜点区”。
python复制alphas = np.logspace(-3, 3, 13)
ridge_test_mses = []
lasso_test_mses = []
for a in alphas:
ridge_tmp = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
Ridge(alpha=a)
)
ridge_tmp.fit(X_train, y_train)
ridge_test_mses.append(np.mean((y_test - ridge_tmp.predict(X_test))**2))
lasso_tmp = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
Lasso(alpha=a, max_iter=200000)
)
lasso_tmp.fit(X_train, y_train)
lasso_test_mses.append(np.mean((y_test - lasso_tmp.predict(X_test))**2))
plt.figure(figsize=(8, 4))
plt.semilogx(alphas, ridge_test_mses, 'o-', label='Ridge Test MSE')
plt.semilogx(alphas, lasso_test_mses, 's-', label='Lasso Test MSE')
plt.xlabel('alpha (log scale)')
plt.ylabel('Test MSE')
plt.legend()
plt.show()
你观察曲线时会发现:alpha太小时,正则化作用微弱,测试误差居高不下(甚至很大);alpha合适时,测试误差降到最低点;alpha太大时,测试误差重新上升——因为模型的参数被压得太狠,很多真实信号也没学到,相当于“欠拟合”了。这就是正则化本质上在偏差和方差之间做平衡的证据。
4.6 深度学习里的权重衰减(Weight Decay)与L2
很多用神经网络的人会疑惑:我在PyTorch里没听说过“L2正则化”,只听说过weight_decay,这两者是同一个东西吗?答案是的。在梯度下降优化器(如SGD、Adam)里传weight_decay参数,本质上就是在更新参数时额外多乘一个小于1的系数,效果等价于给损失函数加了一个L2惩罚项。
PyTorch里代码大致是这样用的:
python复制import torch
import torch.nn as nn
import torch.optim as optim
model = nn.Sequential(
nn.Linear(10, 64),
nn.ReLU(),
nn.Linear(64, 1)
)
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
这个weight_decay一般取1e-4到1e-2之间。注意,你最好只在权重(weight)上做weight decay,不要对偏置(bias)和BatchNorm的缩放参数做,因为这些参数是否过大并不影响模型平滑度,强行惩罚反而会影响表达能力。PyTorch里如果直接用optimizer = optim.Adam(model.parameters(), lr=..., weight_decay=...),偏置也会被一起惩罚,严格来说是不够精细的。如果你想精细控制,可以把参数按名字分成两组,分别传不同的weight_decay值。我在工程中一般会这样做,虽然代码会复杂一点,但效果确实更稳。
再有就是L1在深度学习里的应用不如L2广泛,因为L1的梯度在0点附近不稳定,需要特殊处理。PyTorch没有直接提供L1正则化选项,需要你自己往loss里加一项,比如:
python复制l1_lambda = 1e-5
l1_norm = sum(p.abs().sum() for p in model.parameters())
loss = loss_fn(output, target) + l1_lambda * l1_norm
通常我只在需要模型稀疏化、或者希望网络结构被剪枝的时候才会用L1,日常做图像分类、文本分类这类任务,L2(也就是weight decay)是默认选择。
5. 从入门到落地:模型调优的完整工作流
5.1 拿到一个任务后的标准动作
单看欠拟合、过拟合和正则化的知识点,很多人都能看明白,但一上了实战就手忙脚乱。我自己比较推荐的工作流程是这样的,也算是我踩了无数坑之后沉淀下来的习惯:
第一步,先跑一个最简单的基线模型。线性回归、逻辑回归或者一个很小的决策树都行,不求效果好,只求全流程跑通。
第二步,用学习曲线诊断当前模型的状态。《》。如果训练集误差和验证集误差都高,先加模型复杂度;如果训练集低、验证集高,再考虑正则化或其他防过拟合手段。
第三步,特征工程在模型调优之前做。很多人一上来就忙着调正则化参数,但如果你连特征都没处理好,正则化的效果也非常有限。
第四步,网格搜索调超参数。对线性模型主要调alpha,对树模型主要调max_depth、min_samples_split、min_samples_leaf,对神经网络主要调学习率和weight_decay。用GridSearchCV或RandomizedSearchCV都可以,数据少时用GridSearch,数据多时优先RandomizedSearch。
第五步,用交叉验证的结果来决定是否上线。千万不要只看一次测试集的分数就拍板,因为单次划分的测试集结果方差很大,有时候只是运气好。用K折交叉验证的平均分才更可靠。
5.2 交叉验证和正则化参数搜索结合起来
把交叉验证和正则化参数搜索结合,是工程上最标准的姿势。sklearn的GridSearchCV可以帮你自动做这件事,你只需要传一个参数网格,它会遍历所有组合,每一组都在K折交叉验证里评估,最后输出最优参数。
python复制from sklearn.model_selection import GridSearchCV
pipeline = make_pipeline(
PolynomialFeatures(degree=15, include_bias=False),
StandardScaler(),
Ridge()
)
param_grid = {'ridge__alpha': np.logspace(-3, 3, 13)}
grid = GridSearchCV(pipeline, param_grid, cv=5, scoring='neg_mean_squared_error')
grid.fit(X_train, y_train)
print("Best alpha:", grid.best_params_)
如果你不了解管道机制,这段代码可能看起来有点奇怪。PolynomialFeatures、StandardScaler、Ridge被封装在一个make_pipeline里,GridSearchCV会针对Ridge这个步骤的alpha参数做搜索,而参数名ridge__alpha中的双下划线就表示“管道中名为ridge的那一步的alpha参数”。这就是scikit-learn管道的标准用法,好处是:搜索过程中每个参数组合都会在pipeline内部依次做特征扩展、标准化、训练,完全避免了数据泄漏问题,不需要你手动管理训练集/测试集上的特征转换状态。
5.3 案例复盘:一个实际项目的调参记录
有一次我在做一个房屋价格预测的练手项目,特征有二十几个,其中有好几个是明显的高度相关特征,比如房屋面积、客厅面积、房间数量。用普通线性回归时,训练集R2能到0.88,测试集R2却只有0.76,明显是过拟合了。
当时我先尝试了Ridge,用GridSearchCV搜出来alpha大约在0.5左右,测试集R2涨到了0.81。后来又试了Lasso,Lasso把二十几个特征里七八个不重要特征的系数全部压成了0,测试集R2差不多也在0.81左右,但模型解释性好了很多——至少我知道哪些特征是真正有用的。
有意思的是,我再往前做了一步:把Lasso选出来的有效特征(大概剩下14个)拿出来,重新用普通线性回归训练,测试集R2跟直接用Lasso差不多。这说明什么?说明Lasso在这里的作用主要是特征选择,当你筛选完了特征之后,再用不加正则化的模型去拟合,效果也不会差太多。
这个案例想表达的核心观点是:正则化不只是“把一个模型变好”,它还可以被当作一个特征工程工具来使用。很多入门材料不会告诉你这一点,但你在实际项目里会发现,L1正则化做初筛、再配合L2做精细训练,是一个性价比极高的组合拳。
5.4 训练集上的“假完美”怎么破
还有一种情况在深度学习里特别常见:训练集损失已经降到很低了,但验证集损失却还在高位徘徊。这时有新手会试图继续调大模型、加更多训练轮次,期待验证损失能跟着降下来,但结果往往是训练损失继续降、验证损失进一步提升。这就是过拟合最典型的“假完美”表现。
处理办法有几个方向。第一,增加数据增强。图片任务可以旋转、裁剪、加噪声;文本任务可以换一种方式打乱语序。第二,加Dropout,让网络在训练时随机丢弃一部分神经元,强迫网络学得更鲁棒。第三,早停——监控验证集损失,连续几个epoch不下降就停止训练。第四,降低模型容量或加正则化。
早停的实现很简单,PyTorch里大概就是在每个epoch结束后看验证损失,如果连续patience轮没有改善,就break:
python复制best_loss = float('inf')
patience = 5
wait = 0
for epoch in range(num_epochs):
train_one_epoch(model, dataloader, optimizer)
val_loss = evaluate(model, val_dataloader)
if val_loss < best_loss:
best_loss = val_loss
wait = 0
torch.save(model.state_dict(), 'best_model.pt')
else:
wait += 1
if wait >= patience:
print(f"Early stopping after {epoch} epochs")
break
这个早停技巧,加上weight decay,可以说是深度学习项目里最常用也最实用的防过拟合组合。前者可以帮你省掉大量没必要的训练时间,后者可以有效降低最终模型的泛化误差,两者都不难实现,但收益非常大。
6. 常见问题与排查技巧实录
6.1 训练误差一直很高,无法下降
这种情况不一定是欠拟合,也可能是数据预处理出了问题。比如特征没有标准化、学习率设置不对(对神经网络来说学习率太大或太小都会学不动)、损失函数选择错误、或者数据标签本身有严重噪音。我的排查顺序是:先看几个训练样本的预测值和真值的差距,再看特征的数值范围是否异常,最后才怀疑模型容量。
如果是线性模型,用多项式特征是提升拟合能力的首选,但不要一上来就用degree=20这种极端值,它会带来非常严重的过拟合。一般从degree=2或3开始,多试几个级别,配合学习曲线来看。
6.2 Lasso把系数压成0了,是不是代码写错了
很常见。Lasso对alpha非常敏感,一个看起来不算大的alpha都可能导致所有系数都变成0。这不能完全说是代码问题,更适合通过搜索alpha来解决。你可以在np.logspace(-3, 1, 20)这个范围里搜索,观察系数变为0的个数随alpha的变化,选择一个“系数非零数量适中、测试误差较低”的点。
另外,如果特征间存在高度多重共线性,Lasso的系数选择会很不稳定,可能你在交叉验证的每一折上选出来的特征都不一样。这种情况下可以先用随机森林的特征重要性做一次粗筛,再用Lasso精细筛,会稳很多。
6.3 Ridge、Lasso、ElasticNet到底选哪个
三者选的排序我一般这样判断:如果特征量不大、且模型明显过拟合,直接用Ridge;如果特征量很大且希望获得稀疏解,用Lasso;如果特征高度相关,Lasso可能不稳定,这时用ElasticNet(也就是L1和L2的加权组合)往往效果更好。
ElasticNet里有两个重要参数:alpha控制整体正则化强度,l1_ratio控制L1和L2的比例。l1_ratio=1时退化为Lasso,l1_ratio=0时退化为Ridge。如果你不确定用哪个,可以先跑一个ElasticNetCV,它会自己把这两个参数都搜索出来,省的你自己折腾。
python复制from sklearn.linear_model import ElasticNetCV
elastic = ElasticNetCV(l1_ratio=[0.1, 0.5, 0.7, 0.9, 1.0], cv=5, max_iter=200000)
elastic.fit(X_train, y_train)
print("Best l1_ratio:", elastic.l1_ratio_)
print("Best alpha:", elastic.alpha_)
6.4 加了正则化反而更差了,哪里出了问题
出现这种情况,最常见的三个原因:
第一,模型处于欠拟合状态,加正则化只会雪上加霜。正确动作是先确认学习曲线,如果两条曲线都在高位且靠得近,那就得先加模型复杂度。
第二,特征没有标准化或者标准化没有放进pipeline里。正则化惩罚的是参数的大小,如果特征的量级差异很大,惩罚就会变得严重不公平。这个前面讲过了,这是最容易踩的坑之一。
第三,alpha的值选得太大了。很多人习惯性地把alpha设成1.0,但不同数据集、不同特征维度下,最优alpha可能差好多个数量级。不要拍脑袋,用交叉验证搜一搜最稳妥。
6.5 学习曲线慢到跑不动怎么办
几万样本、几百个特征的情况下,learning_curve跑起来确实很慢,尤其是还叠加了K折交叉验证。解决办法是降低样本抽样的档位数量和交叉验证折数。比如train_sizes=np.linspace(0.05, 0.3, 5),cv=3,先快速看趋势,等模型基本定型了再跑精细的交叉验证。
如果数据规模真的非常大,甚至可以不用learning_curve,直接在固定测试集上,用不同训练数据量手动训练几次,画出误差曲线。虽然不那么规范,但作为快速诊断已经足够了。
7. 写在最后的个人经验
机器学习项目里,欠拟合、过拟合和正则化这三个概念是绕不开的基础题,也是面试的高频考点。但比背概念更重要的是,你得知道它们在实际代码里是怎么体现的、用什么手段去识别、用什么手段去调整。我见过太多例子:有人把书翻烂了,却连学习曲线都没画过;有人模型效果不好就只会加数据,加不了就摆烂。其实,只要会诊断、会抓主要矛盾,调模型的难度会低很多。
我自己的习惯是:每个新任务都先建立模型状态诊断的习惯,用学习曲线看模型卡在哪,再决定下一步动作。如果欠拟合,想尽办法让模型更有能力;如果过拟合,优先加数据、其次上正则化、再配合早停。L1和L2不是密码学,它们就是工具箱里最常用的两把钳子,一个负责拧紧螺丝(压小权重),一个负责剪掉多余的线头(特征选择)。
最后再分享一个小经验:正则化系数不要让模型自己“裸奔”,也不要盲目迷信默认值。多跑几组交叉验证、把测试误差随alpha变化的曲线画出来,你大概率能找到那个“甜点区”。这个甜点区的存在,恰恰就是偏差和方差平衡的最好证明——往左一步是拟合不足,往右一步是过拟合,站对了位置,模型就稳稳的。
