RSS
菜单
全部文章快讯开发科技深度热点

第 36 章 pandas 与 sklearn 建模完整流程

内容摘要

本篇把 pandas 从"数据处理"延伸到"机器学习建模",给出一条"DataFrame 进、模型出"的完整可运行流水线:数据清洗 → 特征工程 → 划分训练/测试集 → 用 `ColumnTransformer + Pipeline` 把编码、缩放、模型串起来 → 训练预测 → 分类/回归评估 → 交叉验证 → 特征重要性 → 模型解释要点。前置知识:前面章节的 DataFrame 操作、[第 35 章](35-特征工程与数据预处理.md)的特征工程。学完后你能把任意一份表格数据喂给 sklearn,并避开"数据泄漏""列名丢失""流程割裂"这三个最常见的坑。

📖 本篇简介:本篇把 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 和召回率带你做一份能上线的反欺诈模型。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 36 章 pandas 与 sklearn 建模完整流程》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。