# 勤奋的凯尔森同学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
)
1
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}")
1
2
3
4
5
6
7
8
9
10
准确率: 0.9508 ± 0.0152
1

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]
    # 训练 + 验证
1
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]
    # 训练集的时间必须早于验证集
1
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()
1
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]
1
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()
1
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
1
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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
训练集: (16512, 8) 测试集: (4128, 8)
1

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}")
1
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
1
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}")
1
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
1
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()
1
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
1
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()
1
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])
1
2
3
4
5
6
7
8
9
10
11
12
真实房价: 0.477
预测房价: 0.5010119
1
2

真实0.477(约4.77万美元),预测0.501(约5.01万美元),误差0.024——单点误差远小于整体RMSE=0.4441(RMSE是所有测试点误差平方的均值开根,单点误差时大时小属正常波动)。joblib比pickle更适合存sklearn/xgboost模型,因为它能高效序列化numpy数组。

保存加载只是最后一环,这里把一个完整机器学习项目从头到尾复盘成一张十步清单。

# 1.5 机器学习项目完整流程总结

  1. 业务理解 → 明确目标(分类/回归/聚类)、评估指标
  2. 数据获取 → SQL/API/文件
  3. 数据探索(EDA) → 分布、缺失、异常、相关性
  4. 数据清洗 → 缺失值、异常值、重复值
  5. 特征工程 → 编码、缩放、构造、选择
  6. 模型选择 → 多个模型对比,选基线
  7. 模型调优 → 交叉验证 + 超参数搜索
  8. 模型评估 → 测试集最终评估
  9. 模型解释 → 特征重要性、SHAP
  10. 部署上线 → 保存模型、API封装、监控

# 1.6 小结

# 评估速查

场景 选择
数据量大,切分不敏感 留出法(80/20)
数据量小,要稳定评估 K折交叉验证(K=5或10)
分类任务,类别不平衡 分层K折
时间序列数据 TimeSeriesSplit(不能随机)
超小数据集 自助法(Bootstrap)
训练误差低、验证误差高 高方差 → 加数据、加正则化、早停
训练验证都低 高偏差 → 换复杂模型、加特征
调参用哪个集 验证集,测试集只看一次
最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing