# 勤奋的凯尔森同学10000h刻意练习Day13:模型评估与端到端项目 —— 过拟合是模型的锅还是评估的锅?从偏差-方差到完整流水线
所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。
同一个模型,换个测试集,准确率凭什么差10个百分点?
模型训得再快,评估不对就是自欺欺人——模型评估与交叉验证是把模型从"能跑"变成"可靠"的关键,数据泄露、类别不平衡、过拟合这些坑都要靠正确的评估方法论来避。偏差-方差权衡则给你一副诊断眼镜:看到症状就知道该加数据、加正则化还是换模型。最后用加州房价预测项目把Day6~Day12学的内容串成一条完整流水线:数据加载 → EDA → 特征工程 → 模型选择 → 调参 → 评估 → 可解释性 → 保存部署。
# 1.1 模型评估方法论
# 1.1.1 为什么需要验证集
在训练集上评估模型会严重高估性能——模型可能只是记住了训练数据(过拟合)。正确的做法是把数据分成三份:
| 数据集 | 用途 | 比例 |
|---|---|---|
| 训练集 | 训练模型 | 60~70% |
| 验证集 | 调参、模型选择 | 15~20% |
| 测试集 | 最终评估(只看一次) | 15~20% |
铁律:测试集只能在最后用一次,用来报告最终性能。绝对不能用测试集调参,否则测试集就变成了验证集,性能评估会虚高。
# 1.1.2 留出法(Hold-out)
最简单的方法:随机切分。
from sklearn.model_selection import train_test_split
from sklearn.datasets import load_breast_cancer
X, y = load_breast_cancer(return_X_y=True)
# 分层抽样,保持类别比例
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
2
3
4
5
6
7
8
9
- 优点:简单、快
- 缺点:结果依赖切分方式,可能运气不好切出偏的数据集;数据利用率低(只有部分数据参与训练)
# 1.1.3 K折交叉验证(K-Fold CV)
把数据分成K份,每次用K-1份训练、1份验证,轮流K次,最后取平均。
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
X, y = load_breast_cancer(return_X_y=True)
scores = cross_val_score(
LogisticRegression(max_iter=10000),
X, y, cv=5, scoring='accuracy'
)
print(f"准确率: {scores.mean():.4f} ± {scores.std():.4f}")
2
3
4
5
6
7
8
9
10
准确率: 0.9508 ± 0.0152
K的选择:
- K大:每次训练数据多,偏差小;但计算量大,各折相关性高,方差大
- K小:计算快;但每次训练数据少,偏差大
- 经验值:K=5或K=10
分层K折(Stratified K-Fold):分类任务用,保持每折的类别比例。
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, val_idx in skf.split(X, y):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
# 训练 + 验证
2
3
4
5
6
# 1.1.4 自助法(Bootstrap)
有放回采样,约63.2%的样本被采样到,剩下的36.8%当验证集。适用于小数据集。
# 1.1.5 时间序列数据的切分
时间序列不能随机切分(会泄露未来信息),要用时间序列切分:
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, val_idx in tscv.split(X):
X_train, X_val = X[train_idx], X[val_idx]
# 训练集的时间必须早于验证集
2
3
4
5
6
# 1.1.6 手推小例子:混淆矩阵与评价指标手算
评估指标的公式不多,但容易背混。下面用8个样本把混淆矩阵和四个指标完整手算一遍。
# 1. 设定8个样本(二分类:预测是否为"正类")
| 样本 | 真实 | 预测 | 归类 |
|---|---|---|---|
| 1 | 正 | 正 | TP |
| 2 | 正 | 正 | TP |
| 3 | 正 | 正 | TP |
| 4 | 正 | 负 | FN |
| 5 | 正 | 负 | FN |
| 6 | 负 | 正 | FP |
| 7 | 负 | 负 | TN |
| 8 | 负 | 负 | TN |
# 2. 数出混淆矩阵
| 预测为正 | 预测为负 | |
|---|---|---|
| 实际为正 | TP = 3 | FN = 2 |
| 实际为负 | FP = 1 | TN = 2 |
检查:3 + 2 + 1 + 2 = 8,一个不多一个不少。
# 3. 算四个指标
# 4. 读数
- 准确率62.5%:整体8个里判断对了5个(3个TP + 2个TN)
- 精确率75%:模型喊"正"的4次里,3次真的是正——衡量"喊正"的含金量
- 召回率60%:真实为正的5个里只抓到了3个——漏报了2个(FN)
- F1 ≈ 0.667:精确率和召回率的调和平均,比算术平均(0.75+0.6)/2 = 0.675略小——谁低向谁偏,专门惩罚"一高一低"的偏科模型
四个数各不相同:同一个模型,换一个指标看,评价就不一样。这就是为什么类别不平衡时不能只看Accuracy——精确率、召回率、F1各自从混淆矩阵的一个角落看问题,得结合业务代价来选。
指标回答的是"模型好不好",下一节的偏差-方差权衡则回答"模型为什么不好、该往哪个方向救"。
# 1.2 偏差-方差权衡
# 1.2.1 偏差和方差的来源
- 偏差(Bias):模型预测的期望与真实值的差。偏差高 → 欠拟合(模型太简单)。
- 方差(Variance):模型预测的波动程度。方差高 → 过拟合(模型太复杂,对训练数据扰动敏感)。
- 噪声(Noise):数据本身的不可约误差。
这是平方损失下误差的严格分解(对0-1分类损失并不严格成立,但直觉相通):固定模型、换用不同训练集,预测误差的期望恰好拆成这三项——偏差平方是模型假设带来的系统性偏差,方差是训练集扰动造成的预测波动,噪声是数据生成过程固有的不可约误差。前两项此消彼长,是优化的对象;第三项无论用什么模型都消不掉。
直觉:把建模想成打靶——偏差是枪本身瞄歪了,弹着点整体偏向一边(系统性错误);方差是手抖,弹着点围着中心乱散(对训练数据的扰动敏感);噪声是风,再稳的枪也躲不掉(不可约误差)。模型能优化的只有前两项:简单模型偏差大方差小,复杂模型偏差小方差大,最优复杂度就在两者之和最小的地方。
# 1.2.2 怎么降偏差/方差
| 问题 | 表现 | 解决方法 |
|---|---|---|
| 高偏差(欠拟合) | 训练误差和验证误差都高 | 增加特征、用更复杂模型、减小正则化 |
| 高方差(过拟合) | 训练误差低,验证误差高 | 增加数据、加正则化、降维、早停、集成学习 |
# 1.2.3 学习曲线诊断
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LogisticRegression
X, y = load_breast_cancer(return_X_y=True)
model = LogisticRegression(max_iter=1000)
train_sizes, train_scores, val_scores = learning_curve(
model, X, y, cv=5, scoring='accuracy',
train_sizes=np.linspace(0.1, 1.0, 10)
)
print("训练样本数:", train_sizes)
print("训练集准确率:", np.round(train_scores.mean(axis=1), 4))
print("验证集准确率:", np.round(val_scores.mean(axis=1), 4))
plt.plot(train_sizes, train_scores.mean(axis=1), label='训练集')
plt.plot(train_sizes, val_scores.mean(axis=1), label='验证集')
plt.xlabel('训练样本数')
plt.ylabel('准确率')
plt.legend()
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
训练样本数: [ 45 91 136 182 227 273 318 364 409 455]
训练集准确率: [1. 0.9978 0.9691 0.9758 0.9665 0.9648 0.9667 0.9621 0.9633 0.9609]
验证集准确率: [0.8452 0.935 0.9226 0.9314 0.9385 0.9473 0.9473 0.9473 0.9508 0.9508]
2
3
上面这组数里,训练集从1.0降到0.96,验证集从0.85升到0.95,两条线逐渐靠近——这是正常拟合:数据少时训练集能记满(1.0)但验证集差(0.85,高方差),数据多了训练集记不满了(0.96)但验证集涨上来(0.95),间距收窄说明加数据有效。反过来诊断:
- 两条线间距大 → 高方差(过拟合)→ 加数据或正则化
- 两条线都低且间距小 → 高偏差(欠拟合)→ 换复杂模型
评估方法和诊断工具备齐后,接下来用一个完整项目,把它们和Day6~Day12学过的模型串成一条流水线。
# 1.3 端到端项目:房价预测
下面用加州房价数据集,走一遍完整流程。
# 1.3.1 第一步:加载数据与EDA
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import fetch_california_housing
# 加载数据
housing = fetch_california_housing()
df = pd.DataFrame(housing.data, columns=housing.feature_names)
df['MedHouseVal'] = housing.target # 目标:房价中位数(单位:10万美元)
print("数据形状:", df.shape)
print(df.describe())
print(df.isnull().sum()) # 检查缺失值
# 目标变量分布
# 画直方图,把房价取值范围切成50个区间(bins),统计每个区间内的样本数
plt.hist(df['MedHouseVal'], bins=50)
plt.xlabel('房价中位数(10万美元)')
plt.show()
# 相关性热力图
# 新建画布,宽 10 英寸、高 8 英寸
plt.figure(figsize=(10, 8))
# df.corr():计算9×9的皮尔逊相关系数矩阵,默认方法,取值范围[-1, 1]。1是完全正相关,-1是完全负相关,0是无线性相关
# annot=True:在每个格子里显示具体数值,否则只看颜色无法读出精确值
# cmap='coolwarm':配色方案,蓝色代表负相关、红色代表正相关,白色接近0。这个配色对正负相关区分度最好
# fmt='.2f':数字保留2位小数
sns.heatmap(df.corr(), annot=True, cmap='coolwarm', fmt='.2f')
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
数据形状: (20640, 9)
MedInc HouseAge ... Longitude MedHouseVal
count 20640.000000 20640.000000 ... 20640.000000 20640.000000
mean 3.870671 28.639486 ... -119.569704 2.068558
std 1.899822 12.585558 ... 2.003532 1.153956
min 0.499900 1.000000 ... -124.350000 0.149990
25% 2.563400 18.000000 ... -121.800000 1.196000
50% 3.534800 29.000000 ... -118.490000 1.797000
75% 4.743250 37.000000 ... -118.010000 2.647250
max 15.000100 52.000000 ... -114.310000 5.000010
[8 rows x 9 columns]
缺失值:
MedInc 0
HouseAge 0
AveRooms 0
AveBedrms 0
Population 0
AveOccup 0
Latitude 0
Longitude 0
MedHouseVal 0
dtype: int64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
20640行9列,8个特征无缺失值。目标值MedHouseVal范围0.15~5.0(单位:10万美元),均值2.07、中位数1.80——均值大于中位数,说明高价区样本把均值拉高,分布右偏。EDA要点:看数据分布、缺失值、异常值、特征与目标的相关性、特征之间的共线性。
# 1.3.2 第二步:特征工程
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X = df.drop('MedHouseVal', axis=1)
y = df['MedHouseVal']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
print("训练集:", X_train.shape, "测试集:", X_test.shape)
# 特征缩放(线性模型需要,树模型不需要)
scaler = StandardScaler()
# 先拟合(计算均值、方差),再转换训练集
X_train_scaled = scaler.fit_transform(X_train)
# 只用前面已经算好的均值方差,直接转换测试集
# 测试集是模拟 “未来没见过的数据”,真实场景你不可能拿到测试集的统计信息。一旦用测试集 fit,相当于**把测试集信息泄露给预处理**,评估出来的指标虚高,不能代表模型真实泛化能力。
X_test_scaled = scaler.transform(X_test)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
训练集: (16512, 8) 测试集: (4128, 8)
16512条训练、4128条测试。fit_transform只在训练集上调用,测试集用transform——这是防数据泄露的铁律:缩放参数只能从训练集学,不能用测试集的信息。
# 1.3.3 第三步:模型选择与基线
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor
import xgboost as xgb
from sklearn.metrics import mean_squared_error, r2_score
models = {
'LinearRegression': LinearRegression(),
'Ridge': Ridge(alpha=1.0),
'DecisionTree': DecisionTreeRegressor(random_state=42),
'RandomForest': RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1),
'XGBoost': xgb.XGBRegressor(random_state=42, n_jobs=-1)
}
results = {}
for name, model in models.items():
# 树模型不用缩放,线性模型用缩放后的数据
if name in ['LinearRegression', 'Ridge']:
model.fit(X_train_scaled, y_train)
y_pred = model.predict(X_test_scaled)
else:
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
r2 = r2_score(y_test, y_pred)
results[name] = {'RMSE': rmse, 'R2': r2}
print(f"{name:20s} RMSE={rmse:.4f} R²={r2:.4f}")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
LinearRegression RMSE=0.7456 R²=0.5758
Ridge RMSE=0.7456 R²=0.5758
DecisionTree RMSE=0.7093 R²=0.6161
RandomForest RMSE=0.5063 R²=0.8044
XGBoost RMSE=0.4626 R²=0.8367
2
3
4
5
XGBoost的RMSE最低(0.4626,单位10万美元,即约4.6万美元误差),R²=0.84解释了84%的方差。线性模型只有0.58——房价和特征之间是非线性关系,线性模型拟合不了。决策树比线性好但不如集成,随机森林和XGBoost把多棵树的结果平均/叠加,精度大幅提升。后续调参就拿XGBoost继续压。
# 1.3.4 第四步:调参
import optuna
from sklearn.model_selection import cross_val_score
def objective(trial):
params = {
'max_depth': trial.suggest_int('max_depth', 3, 10),
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
'n_estimators': trial.suggest_int('n_estimators', 100, 500),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
'reg_lambda': trial.suggest_float('reg_lambda', 1e-3, 10.0, log=True)
}
model = xgb.XGBRegressor(**params, random_state=42, n_jobs=-1)
scores = cross_val_score(model, X_train, y_train, cv=5, scoring='neg_root_mean_squared_error')
return -scores.mean()
# sampler设seed保证结果可复现
study = optuna.create_study(direction='minimize', sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=30)
print("最佳参数:", study.best_params)
print("最佳CV RMSE:", round(study.best_value, 4))
best_model = xgb.XGBRegressor(**study.best_params, random_state=42, n_jobs=-1)
best_model.fit(X_train, y_train)
y_pred = best_model.predict(X_test)
print(f"调参后RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.4f}")
print(f"调参后R²: {r2_score(y_test, y_pred):.4f}")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
最佳参数: {'max_depth': 9, 'learning_rate': 0.0345, 'n_estimators': 412, 'subsample': 0.7773, 'colsample_bytree': 0.7851, 'reg_lambda': 3.1844}
最佳CV RMSE: 0.4517
调参后RMSE: 0.4441
调参后R²: 0.8495
2
3
4
调参后RMSE从0.4626降到0.4441,R²从0.8367升到0.8495——提升不大但确认了默认参数没漏掉更优解。TPESampler(seed=42)保证每次跑同样结果,不设seed的话Optuna的搜索路径随机,每次best_params都会变。
# 1.3.5 第五步:特征重要性与可解释性
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# sklearn 模型解释工具,部分依赖图,可视化单个特征对模型预测结果的边际影响
from sklearn.inspection import PartialDependenceDisplay
# best_model.feature_importances_:树模型内置属性,输出特征重要性数组
importances = best_model.feature_importances_
# pd.Series(importances, index=X_train.columns):把数值数组+特征名称绑定,变成pandas序列。index就是特征名字
# .sort_values(ascending=False):降序排序,分数最高的特征排在最前面
feat_imp = pd.Series(
importances,
index=X_train.columns
).sort_values(ascending=False)
print(feat_imp)
# .plot(kind='barh'):绘制水平条形图 barh = horizontal bar
feat_imp.plot(kind='barh')
plt.xlabel('重要性')
plt.title('特征重要性')
# plt.tight_layout():自动调整子图边距,防止标签文字被截断
plt.tight_layout()
plt.show()
# PDP全称Partial Dependence Plot:固定其他所有特征,只改变当前这一个特征,看模型预测输出的平均变化趋势
# 作用:直观看到:特征增大时,预测值上升还是下降,以及非线性关系
PartialDependenceDisplay.from_estimator(
best_model,
# X_train:用来采样的背景数据集。PDP会基于X_train样本,遍历目标特征取值,其余特征保持样本原有值,最后取预测均值
X_train,
# ['MedInc', 'AveRooms']:指定要绘图的特征,这里是加州房价数据集:MedInc收入中位数,AveRooms平均房间数;列表里写几个特征,就输出几张子图
['MedInc', 'AveRooms'],
# grid_resolution=20:在该特征的取值区间均匀采样20个点,用来绘制曲线。数值越大曲线越顺滑,但计算耗时成倍增加
grid_resolution=20,
)
plt.tight_layout()
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
MedInc 0.3794
Longitude 0.1467
Latitude 0.1412
AveOccup 0.1281
AveRooms 0.0768
HouseAge 0.0594
AveBedrms 0.0370
Population 0.0314
dtype: float32
2
3
4
5
6
7
8
9
MedInc(街区收入中位数)占了38%的重要性,经纬度(Longitude + Latitude)合计29%——加州房价和地理位置强相关,收入越高、地段越好,房价越贵,和直觉完全吻合。
# 1.3.6 第六步:残差分析
residuals = y_test - y_pred
plt.figure(figsize=(12, 4))
# subplot(行数,列数,当前子图序号)
plt.subplot(1, 2, 1)
# 第一张图:残差vs预测值散点图
plt.scatter(y_pred, residuals, alpha=0.5)
# 画一条水平红色虚线y=0,这条线代表残差=0,预测完全准确。理想情况下点均匀随机分布在红线上下,没有明显趋势
plt.axhline(0, color='r', linestyle='--')
plt.xlabel('预测值')
plt.ylabel('残差')
plt.title('残差图')
plt.subplot(1, 2, 2)
# 第2张子图:残差直方图
# bins=50:分成50个柱子,柱子越多分布细节越清楚
plt.hist(residuals, bins=50)
plt.xlabel('残差')
plt.title('残差分布')
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
残差应该随机分布在0附近,没有明显模式。如果有模式(比如残差随预测值增大而增大),说明模型还有未捕捉到的规律。
残差结构确认无虞,模型才算验收通过;最后一步是把它保存下来、部署出去。
# 1.4 模型保存与部署
import joblib
# 保存模型
joblib.dump(best_model, 'california_housing_model.pkl')
# 加载模型
loaded_model = joblib.load('california_housing_model.pkl')
# 用加载的模型预测
sample = X_test.iloc[0:1]
print("真实房价:", y_test.iloc[0])
print("预测房价:", loaded_model.predict(sample)[0])
2
3
4
5
6
7
8
9
10
11
12
真实房价: 0.477
预测房价: 0.5010119
2
真实0.477(约4.77万美元),预测0.501(约5.01万美元),误差0.024——单点误差远小于整体RMSE=0.4441(RMSE是所有测试点误差平方的均值开根,单点误差时大时小属正常波动)。joblib比pickle更适合存sklearn/xgboost模型,因为它能高效序列化numpy数组。
保存加载只是最后一环,这里把一个完整机器学习项目从头到尾复盘成一张十步清单。
# 1.5 机器学习项目完整流程总结
- 业务理解 → 明确目标(分类/回归/聚类)、评估指标
- 数据获取 → SQL/API/文件
- 数据探索(EDA) → 分布、缺失、异常、相关性
- 数据清洗 → 缺失值、异常值、重复值
- 特征工程 → 编码、缩放、构造、选择
- 模型选择 → 多个模型对比,选基线
- 模型调优 → 交叉验证 + 超参数搜索
- 模型评估 → 测试集最终评估
- 模型解释 → 特征重要性、SHAP
- 部署上线 → 保存模型、API封装、监控
# 1.6 小结
# 评估速查
| 场景 | 选择 |
|---|---|
| 数据量大,切分不敏感 | 留出法(80/20) |
| 数据量小,要稳定评估 | K折交叉验证(K=5或10) |
| 分类任务,类别不平衡 | 分层K折 |
| 时间序列数据 | TimeSeriesSplit(不能随机) |
| 超小数据集 | 自助法(Bootstrap) |
| 训练误差低、验证误差高 | 高方差 → 加数据、加正则化、早停 |
| 训练验证都低 | 高偏差 → 换复杂模型、加特征 |
| 调参用哪个集 | 验证集,测试集只看一次 |