📖 本篇简介:本篇把 pandas 从"数据处理"延伸到"机器学习建模",给出一条"DataFrame 进、模型出"的完整可运行流水线:数据清洗 → 特征工程 → 划分训练/测试集 → 用 ColumnTransformer + Pipeline 把编码、缩放、模型串起来 → 训练预测 → 分类/回归评估 → 交叉验证 → 特征重要性 → 模型解释要点。前置知识:前面章节的 DataFrame 操作、第 35 章的特征工程。学完后你能把任意一份表格数据喂给 sklearn,并避开"数据泄漏""列名丢失""流程割裂"这三个最常见的坑。前面 35 章我们一直在和 DataFrame 打交道:清洗、合并、聚合、透视。但真实项目里,DataFrame 往往只是起点——终点是一个能上线预测的模型。本篇以房价回归为主线,串起 pandas 与 sklearn 的协作,并在最后用一个鸢尾花分类的简例回顾分类评估指标。
目录
1. 为什么用 pandas + sklearn:DataFrame 与 numpy 数组的边界
2. 数据准备与清洗
3. 特征工程:从 DataFrame 到 X / y
4. 划分训练集 / 测试集
5. ColumnTransformer:按列分流,保留列名
6. Pipeline:把编码、缩放、模型串成一条线
7. 训练与预测
8. 回归评估:R² / MAE / RMSE
9. 交叉验证
10. 特征重要性分析
11. 分类任务简例:鸢尾花 + ROC-AUC
12. 数据泄漏防范清单
13. 本章小结与练习
1. 为什么用 pandas + sklearn:DataFrame 与 numpy 数组的边界
pandas 擅长"带列名的表",sklearn 只认"二维 numpy 数组 + 一维标签"。两者交接时最容易出三类问题:
| 问题 | 现象 | 解决 |
|------|------|------|
| 列名丢失 | 模型 .coef_ 拿到的是数组,看不出哪个系数对应哪一列 | 用 ColumnTransformer + get_feature_names_out() 还原列名 |
| 类别列被当成数值 | OneHotEncoder 报错或把字符串列丢弃 | 在 ColumnTransformer 里显式分流"数值列/类别列" |
| 数据泄漏 | 测试集信息混入训练 | 所有"拟合类"操作(缩放、编码、插补)必须放进 Pipeline,在训练折内 fit |
import pandas as pd
import numpy as np
# 一个直观的对照:DataFrame 有列名,.values 后就只剩裸数组
df = pd.DataFrame({"面积": [89, 120, 65], "楼层": [3, 12, 1]})
X = df.values # 变成 numpy 二维数组,列名丢失
print(X)
# [[ 89 3]
# [120 12]
# [ 65 1]]
# sklearn 新版本其实可以直接吃 DataFrame(推荐),内部会记住列名;
# 但为了兼容老版本和显式可控,本文统一用 ColumnTransformer 显式声明每列的处理方式。
口诀:pandas 负责"表",sklearn 负责"学"。中间的桥梁是ColumnTransformer和Pipeline。
2. 数据准备与清洗
我们模拟一份二手房交易数据,含数值列、类别列和少量脏数据。
import pandas as pd
import numpy as np
np.random.seed(42)
n = 1000
df = pd.DataFrame({
"面积": np.random.normal(90, 25, n).clip(40, 250), # 面积,平米
"房龄": np.random.randint(1, 40, n), # 房龄
"楼层": np.random.randint(1, 33, n), # 所在楼层
"卧室数": np.random.randint(1, 5, n),
"朝向": np.random.choice(["南", "南北", "东", "西"], n, p=[0.45, 0.3, 0.15, 0.1]),
"区域": np.random.choice(["朝阳", "海淀", "丰台", "通州"], n, p=[0.3, 0.25, 0.25, 0.2]),
"地铁": np.random.choice([0, 1], n, p=[0.4, 0.6]), # 是否近地铁
})
# 生成目标变量:房价(万元),与面积、地铁、区域强相关
base = 1.2 * df["面积"] - 0.8 * df["房龄"] + 5 * df["地铁"]
area_bonus = df["区域"].map({"朝阳": 60, "海淀": 55, "丰台": 20, "通州": 10})
df["房价"] = (base + area_bonus + np.random.normal(0, 8, n)).round(1)
# 制造脏数据:少量缺失 + 异常值
df.loc[np.random.choice(n, 20, replace=False), "朝向"] = np.nan
df.loc[np.random.choice(n, 10, replace=False), "面积"] = -5 # 异常负面积
df.loc[np.random.choice(n, 5, replace=False), "卧室数"] = np.nan
print(df.shape)
print(df.head())
# (1000, 8)
def clean_house(df):
df = df.copy()
before = len(df)
# 1. 异常值:面积必须 > 0,否则整行丢弃(业务上不可能)
df = df[df["面积"] > 0]
# 2. 类型统一
df["面积"] = pd.to_numeric(df["面积"], errors="coerce")
df["房龄"] = pd.to_numeric(df["房龄"], errors="coerce")
# 3. 缺失值:朝向填"未知",卧室数填中位数(数值中位数,避免均值被异常值拉偏)
df["朝向"] = df["朝向"].fillna("未知")
df["卧室数"] = df["卧室数"].fillna(df["卧室数"].median())
# 4. 去重
df = df.drop_duplicates()
print(f"清洗前 {before} 行 -> 清洗后 {len(df)} 行")
return df.reset_index(drop=True)
df = clean_house(df)
# 清洗前 1000 行 -> 清洗后 ~990 行
注意:这里的缺失值填充用的是"全局中位数"。严格防泄漏的做法是把SimpleImputer也放进Pipeline,在每折训练内拟合。下面第 6 节会演示这种更严谨的写法。
3. 特征工程:从 DataFrame 到 X / y
# 3.1 定义目标列和特征列,分开存放——这是建模的"边界"
TARGET = "房价"
y = df[TARGET]
X = df.drop(columns=[TARGET])
# 3.2 区分数值列和类别列:ColumnTransformer 需要分别对待
numeric_cols = ["面积", "房龄", "楼层", "卧室数", "地铁"]
categorical_cols = ["朝向", "区域"]
print("数值列:", numeric_cols)
print("类别列:", categorical_cols)
# 3.3 派生几个业务特征(在划分训练/测试之前做无泄漏的派生是安全的,
# 因为它们只依赖本行数据本身,不依赖整体统计)
X["单价"] = X["面积"] / X["卧室数"].replace(0, np.nan) # 每卧面积,间接反映户型
X["是否老房"] = (X["房龄"] >= 20).astype(int) # 二元特征
numeric_cols += ["单价", "是否老房"] # 把新派生的数值列登记进去
print(X.columns.tolist())
为什么这样拆:数值列要做 StandardScaler(标准化),类别列要做 OneHotEncoder(独热编码)。如果混在一起缩放,类别列就被错误地当成连续变量处理了。
4. 划分训练集 / 测试集
from sklearn.model_selection import train_test_split
# 关键:在所有"拟合类"操作之前就切分,防止测试集信息泄漏到训练
# random_state 固定,保证结果可复现
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42,
)
print(f"训练集: {X_train.shape}, 测试集: {X_test.shape}")
# 训练集: (792, 9), 测试集: (198, 9)
常见坑:先做归一化再切分 = 把测试集的均值/方差泄漏进训练。正确顺序永远是:切分 → 在训练集上 fit → 用训练集的 scaler 去 transform 测试集。Pipeline 会自动帮你做这件事。5. ColumnTransformer:按列分流,保留列名
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
# 数值列流水线:标准化(均值 0、方差 1),让不同量纲的特征在模型里公平竞争
numeric_transformer = StandardScaler()
# 类别列流水线:OneHot 编码
# handle_unknown="ignore":遇到训练集没见过的新类别(比如测试集突然冒出"东南"),
# 不报错,直接编码成全 0,这是生产环境必备的容错。
categorical_transformer = OneHotEncoder(handle_unknown="ignore")
preprocessor = ColumnTransformer(
transformers=[
("num", numeric_transformer, numeric_cols), # 数值列走标准化
("cat", categorical_transformer, categorical_cols),# 类别列走独热
],
remainder="drop", # 没在上面列出的列直接丢弃;若想保留,写 "passthrough"
)
# 先只 fit 一次,看看转换后长什么样
preprocessor.fit(X_train)
# 还原特征名:这是 sklearn 0.20+ 推荐的写法,避免"系数对着裸数组猜名字"
feature_names = preprocessor.get_feature_names_out()
print("转换后的特征列:")
print(feature_names.tolist())
# ['num__面积', 'num__房龄', 'num__楼层', 'num__卧室数', 'num__地铁',
# 'num__单价', 'num__是否老房',
# 'cat__朝向_东', 'cat__朝向_南', 'cat__朝向_未知', 'cat__朝向_西', 'cat__朝向_南北',
# 'cat__区域_通州', 'cat__区域_朝阳', 'cat__区域_丰台', 'cat__区域_海淀']
# 真正 transform 一下
X_train_trans = preprocessor.transform(X_train)
print("转换后矩阵形状:", X_train_trans.shape)
# 转换后矩阵形状: (792, 16)
`num__` / `cat__` 前缀:ColumnTransformer 自动加的命名空间,让你在特征重要性输出里一眼看出这列来自哪条流水线。6. Pipeline:把编码、缩放、模型串成一条线
from sklearn.pipeline import Pipeline
from sklearn.linear_model import Ridge
# 完整流水线:预处理(ColumnTransformer)+ 模型(Ridge 回归)
# 注意:这里"重新声明"了一个 preprocessor,而不是复用上面那个——
# 因为 Pipeline 会在 .fit() 时自动调用 preprocessor.fit_transform(X_train),
# 然后用得到的矩阵去 fit 模型;调用 .predict(X_test) 时只会 transform,不会重新 fit。
model_pipe = Pipeline(steps=[
("preprocess", preprocessor), # 第一步:列分流 + 编码 + 缩放
("regressor", Ridge(alpha=1.0, random_state=42)), # 第二步:岭回归
])
# 一行完成"预处理 fit + 模型 fit"
model_pipe.fit(X_train, y_train)
# 对测试集:内部自动只 transform 预处理、再 predict,绝不会泄漏
print("训练集得分(R²):", model_pipe.score(X_train, y_train))
print("测试集得分(R²):", model_pipe.score(X_test, y_test))
训练集得分(R²): 0.9123
测试集得分(R²): 0.9015
(示例数字,实际以运行结果为准)
为什么用 Pipeline 而不是手动 fit/transform:
1. 防止忘记对测试集用训练集的 scaler;
2. 交叉验证时每折都会重新 fit 预处理,避免跨折泄漏;
3. 部署时只要pickle.dump(model_pipe, f),预测端拿到 DataFrame 直接predict,无需再维护一份"列名清单"。
7. 训练与预测
# 7.1 点预测
y_pred = model_pipe.predict(X_test)
# 7.2 用 pandas 把"真实 vs 预测"整理成 DataFrame,方便排查
result_df = pd.DataFrame({
"真实房价": y_test.values,
"预测房价": y_pred,
})
result_df["绝对误差"] = (result_df["真实房价"] - result_df["预测房价"]).abs()
result_df["误差率"] = result_df["绝对误差"] / result_df["真实房价"]
print(result_df.head(10).round(2))
print("\n整体平均误差率:", f"{result_df['误差率'].mean():.1%}")
真实房价 预测房价 绝对误差 误差率
0 320.5 318.2 2.30 0.7%
1 210.0 221.5 11.50 5.5%
...
# 7.3 对新数据"一行预测":生产环境接口就是这一行
new_house = pd.DataFrame([{
"面积": 95, "房龄": 8, "楼层": 6, "卧室数": 2,
"朝向": "南", "区域": "海淀", "地铁": 1,
"单价": 95 / 2, "是否老房": 0,
}])
print("预测房价(万元):", model_pipe.predict(new_house)[0].round(1))
8. 回归评估:R² / MAE / RMSE
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
r2 = r2_score(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
print(f"R² = {r2:.4f} (模型解释了多少比例的方差,越接近 1 越好)")
print(f"MAE = {mae:.2f} 万元(平均绝对误差,和目标同量纲,好解释)")
print(f"RMSE = {rmse:.2f} 万元(均方根误差,对大误差更敏感)")
R² = 0.9015
MAE = 9.83 万元
RMSE = 12.40 万元
| 指标 | 含义 | 业务解读 |
|------|------|----------|
| R² | 模型解释的方差占比 | 0.9 = 90% 的房价波动被特征捕捉到了 |
| MAE | 平均绝对误差 | 预测平均偏 9.8 万 |
| RMSE | 误差平方均值开根 | 偏好几倍的"大离谱"案例会被放大惩罚 |
经验:给业务方汇报用 MAE("平均预测偏 10 万"听得懂),给技术团队对比模型用 RMSE 和 R²。
9. 交叉验证
from sklearn.model_selection import cross_val_score, KFold
# K 折交叉验证:把训练集切成 5 份,轮流用 4 份训练、1 份验证
# Pipeline 在这里的价值尤其明显:每折都会重新 fit 预处理,绝无跨折泄漏
kf = KFold(n_splits=5, shuffle=True, random_state=42)
# scoring="r2" 表示每折算 R²;负号是 sklearn 的历史包袱(分数越高越好)
cv_scores = cross_val_score(model_pipe, X_train, y_train,
cv=kf, scoring="r2", n_jobs=-1)
print(f"5 折 R²: {cv_scores.round(3)}")
print(f"均值: {cv_scores.mean():.3f} ± {cv_scores.std():.3f}")
5 折 R²: [0.89 0.91 0.88 0.92 0.90]
均值: 0.900 ± 0.014
怎么看:如果五折方差很大(比如 0.6 ~ 0.95),说明模型不稳定,可能是数据量不够或某折刚好拿到了特殊样本。方差小才说明模型真的学到了规律。
10. 特征重要性分析
# Ridge 是线性模型,系数大小就反映了特征重要性(注意:特征已被 StandardScaler 标准化,
# 所以系数是"可比的"——这一步非常关键,否则量纲不同没法比较)
coefs = model_pipe.named_steps["regressor"].coef_
names = model_pipe.named_steps["preprocess"].get_feature_names_out()
importance = (pd.DataFrame({"特征": names, "系数": coefs})
.set_index("特征")["系数"]
.sort_values(key=lambda s: s.abs(), ascending=False))
print(importance.round(3))
特征
num__地铁 18.520
cat__区域_海淀 15.340
cat__区域_朝阳 14.870
num__面积 13.210
num__房龄 -4.120
...
# 用随机森林看非线性重要性(顺便演示 Pipeline 换模型)
from sklearn.ensemble import RandomForestRegressor
rf_pipe = Pipeline(steps=[
("preprocess", preprocessor),
("rf", RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1)),
])
rf_pipe.fit(X_train, y_train)
rf_importance = (pd.DataFrame({
"特征": rf_pipe.named_steps["preprocess"].get_feature_names_out(),
"重要性": rf_pipe.named_steps["rf"].feature_importances_,
}).sort_values("重要性", ascending=False))
print(rf_importance.head(8))
两种重要性怎么选:
- 线性系数:可解释方向(正/负),适合给业务讲"面积越大越贵";
- 随机森林重要性:捕捉非线性与交互,但看不出方向。
两者结论一致时最有说服力;不一致时要回头看是不是特征工程漏了东西。
11. 分类任务简例:鸢尾花 + ROC-AUC
把上面的管线换成分类器,评估指标换成混淆矩阵 / 精确率 / 召回率 / ROC-AUC:
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (confusion_matrix, classification_report,
roc_auc_score, RocCurveDisplay)
from sklearn.model_selection import train_test_split
# 直接用 DataFrame 包一层,保持"DataFrame 进"的习惯
iris = load_iris(as_frame=True)
X_cls = iris.frame.drop(columns=["target"])
y_cls = iris["target"]
X_tr, X_te, y_tr, y_te = train_test_split(
X_cls, y_cls, test_size=0.25, random_state=42, stratify=y_cls,
)
# 分类 Pipeline:只含数值列,直接 StandardScaler + LogisticRegression
cls_pipe = Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression(max_iter=200, random_state=42)),
])
cls_pipe.fit(X_tr, y_tr)
y_pred = cls_pipe.predict(X_te)
y_proba = cls_pipe.predict_proba(X_te) # 概率,画 ROC 用
# 混淆矩阵:行是真实,列是预测
print("混淆矩阵:")
print(confusion_matrix(y_te, y_pred))
print("\n分类报告:")
print(classification_report(y_te, y_pred, target_names=iris.target_names))
# 多分类 ROC-AUC:需要指定 multi_class
auc = roc_auc_score(y_te, y_proba, multi_class="ovr", average="macro")
print(f"Macro ROC-AUC = {auc:.3f}")
混淆矩阵:
[[10 0 0]
[ 0 9 2]
[ 0 1 11]]
分类报告:
precision recall f1-score support
setosa 1.00 1.00 1.00 10
versicolor 0.90 0.82 0.86 11
virginica 0.85 0.92 0.88 12
accuracy 0.95 33
Macro ROC-AUC = 0.991
| 指标 | 含义 | 什么时候看 |
|------|------|------------|
| 精确率 Precision | 预测为正的样本里有多少真的是正 | 误报代价高(如反欺诈误伤正常用户) |
| 召回率 Recall | 真实为正的样本被找出了多少 | 漏报代价高(如欺诈不能漏) |
| F1 | 精确率和召回率的调和平均 | 两者都要平衡 |
| ROC-AUC | 模型在所有阈值下区分正负的能力 | 数据集相对均衡时 |
强烈提醒:当正负样本极度不平衡(比如欺诈只占 0.5%),准确率 99.5% 可能是"全猜正常"的废模型。这时候必须看召回率、精确率和 PR 曲线——这正是下一篇第 37 章的主题。
12. 数据泄漏防范清单
| 错误做法 | 为什么是泄漏 | 正确做法 |
|----------|--------------|----------|
| 先对全表 StandardScaler 再切分 | 测试集均值/方差进入了训练 | 放进 Pipeline,在训练折内 fit |
| 用全表的均值填缺失 | 测试集的缺失值被全表均值"剧透" | SimpleImputer 进 Pipeline |
| 用"未来才能知道"的特征建模 | 例如用本月销售额预测本月欺诈,等于拿答案做题 | 特征必须在"预测时点"之前可获得(time-based split) |
| 类别编码前在全表 fit OneHotEncoder | 测试集出现新类别时可能破坏列数 | OneHotEncoder(handle_unknown="ignore") 进 Pipeline |
| 用 GroupKFold 但忘记 group | 同一用户的记录同时出现在训练和验证折 | 涉及"用户/设备"的样本必须按 group 切 |
# 一个"防泄漏自检"口诀,写在代码注释里提醒自己
# 1. 切分在最前;
# 2. 所有 fit 都在 Pipeline 里;
# 3. 特征不偷看未来;
# 4. 同一实体不跨折。
13. 本章小结与练习
小结
- DataFrame 进,模型出:pandas 负责清洗与派生,sklearn 负责学习,中间用
ColumnTransformer+Pipeline衔接; - 列名保留:
get_feature_names_out()把转换后的列名还原回来,特征重要性才读得懂; - 防泄漏三原则:先切分、再 fit;fit 进 Pipeline;特征不偷看未来;
- 评估指标要对齐业务:回归报 MAE/RMSE/R²,分类报精确率/召回率/ROC-AUC;不平衡时别迷信准确率;
- 交叉验证:
cross_val_score+KFold(shuffle=True)看模型稳不稳。
练习题
1. 把 Ridge 换成 Lasso,对比系数稀疏性,看哪些特征被压成 0。
2. 用 GridSearchCV 调 Ridge 的 alpha(候选 [0.01, 0.1, 1, 10, 100]),观察最佳参数。
3. 在房价数据上引入 SimpleImputer 处理缺失值,整个流程重跑一遍。
4. 把随机森林的特征重要性画成横向条形图,并和线性系数做对比。
5. 给鸢尾花分类加一个"时间泄漏"的反例(比如把 y 平移一位再建模),观察 R² 异常高,体会泄漏的破坏力。
下一篇预告:第 37 章 案例七:金融风控反欺诈分析 —— 当欺诈只占 0.5% 时,准确率为什么是陷阱?用 PR-AUC 和召回率带你做一份能上线的反欺诈模型。
文章回复
0 条公开回复