机器学习流水线:从 notebook 到可复现的产物 本节摘要:模型不是产品,流水线(Pipeline)才是。流水线是从原始数据到部署预测的一切步骤,每一步都必须可复现。你有一个 notebook:加载数据、用中位数填缺失、缩放特征、训练模型、打印准确率。一个月后有人重训,得到不同结果——因为中位数是在含测试数据的全集上算的(数据泄漏);因为缩放参数没存,推理时用了不同统计量;因为特征工程代码在训练和服务两处被复制粘贴,两份拷贝已经发散;因为某个类别列在生产里出现了编码器从未见过的新值。这些不是假想,是 ML 系统在生产里翻车最常见的原因。
本节摘要:模型不是产品,流水线(Pipeline)才是。流水线是从原始数据到部署预测的一切步骤,每一步都必须可复现。你有一个 notebook:加载数据、用中位数填缺失、缩放特征、训练模型、打印准确率。一个月后有人重训,得到不同结果——因为中位数是在含测试数据的全集上算的(数据泄漏);因为缩放参数没存,推理时用了不同统计量;因为特征工程代码在训练和服务两处被复制粘贴,两份拷贝已经发散;因为某个类别列在生产里出现了编码器从未见过的新值。这些不是假想,是 ML 系统在生产里翻车最常见的原因。流水线把每个变换步骤打包成一个有序、可复现的单一对象,一举解决这些问题:变换只在训练数据上拟合(无泄漏)、推理时套用同样的变换、整个对象可序列化部署、交叉验证按折应用流水线。本节将从零实现一个自定义变换器和流水线类,讲透 ColumnTransformer 如何对数值和类别列分别预处理,并引入 MLflow/DVC 做实验追踪与数据版本管理。
阅读完本节,你应当能够:
ColumnTransformer,对数值和类别特征应用不同预处理。你有一个 notebook,加载数据、用中位数填缺失、缩放特征、训练模型、打印准确率。它能跑。你把它上线了。
一个月后,有人重训模型,得到不同结果。中位数是在含测试数据的全集上算的(数据泄漏)。缩放参数没存,推理时用了不同统计量。特征工程代码在训练和服务之间被复制粘贴,两份拷贝已经发散。某个类别列在生产里出现了编码器从未见过的新值。
这些不是假想,是 ML 系统在生产里翻车最常见的原因。流水线通过把每个变换步骤打包成一个有序、可复现的单一对象,把它们全部解决。
流水线是一连串有序的数据变换,后跟一个模型。每一步把上一步的输出当输入。整条流水线在训练数据上一次性拟合好。推理时,同一个已拟合的流水线变换新数据并产出预测。
流水线保证:
数据泄漏(Data Leakage)发生在测试集或未来数据的信息污染了训练。流水线防止最常见的几种形式。
有泄漏(错误):
X = df.drop("target", axis=1) y = df["target"] scaler = StandardScaler() X_scaled = scaler.fit_transform(X) X_train, X_test = X_scaled[:800], X_scaled[800:] y_train, y_test = y[:800], y[800:]
scaler 看到了测试数据。均值和标准差里混入了测试样本。这会虚高准确率估计。
正确:
X_train, X_test = X[:800], X[800:] scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)
有了流水线,你不必操心这个——流水线自动处理。
sklearn 的 Pipeline 把变换器和一个估计器链接起来。它暴露 .fit()、.predict()、.score(),按顺序应用所有步骤。
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline([ ("scaler", StandardScaler()), ("model", LogisticRegression()), ]) pipe.fit(X_train, y_train) predictions = pipe.predict(X_test)
调用 pipe.fit(X_train, y_train) 时:
fit_transform。fit。调用 pipe.predict(X_test) 时:
transform(不是 fit_transform)。predict。scaler 在拟合阶段从不看测试数据。这就是全部要点。
真实数据有数值列和类别列,需要不同预处理。ColumnTransformer 处理这个。
from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer numeric_pipe = Pipeline([ ("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler()), ]) categorical_pipe = Pipeline([ ("impute", SimpleImputer(strategy="most_frequent")), ("encode", OneHotEncoder(handle_unknown="ignore")), ]) preprocessor = ColumnTransformer([ ("num", numeric_pipe, ["age", "income", "score"]), ("cat", categorical_pipe, ["city", "gender", "plan"]), ]) full_pipeline = Pipeline([ ("preprocess", preprocessor), ("model", GradientBoostingClassifier()), ])
OneHotEncoder 里的 handle_unknown="ignore" 对生产至关重要。当出现新类别(模型从未见过的城市),它产出一个零向量而非崩溃。
流水线让训练可复现,但你还需要追踪跨实验发生了什么:用了哪些超参数、哪个数据集版本、指标多少、跑的哪份代码。
MLflow 是最常见的开源方案:
import mlflow with mlflow.start_run(): mlflow.log_param("max_depth", 5) mlflow.log_param("n_estimators", 100) mlflow.log_param("learning_rate", 0.1) pipe.fit(X_train, y_train) accuracy = pipe.score(X_test, y_test) mlflow.log_metric("accuracy", accuracy) mlflow.sklearn.log_model(pipe, "model")
每次运行都记录了参数、指标、产物和完整模型。你可以对比运行、复现任何实验、部署任何模型版本。
Weights & Biases(wandb) 提供同样的功能,配一个托管仪表盘:
import wandb wandb.init(project="my-pipeline") wandb.config.update({"max_depth": 5, "n_estimators": 100}) pipe.fit(X_train, y_train) accuracy = pipe.score(X_test, y_test) wandb.log({"accuracy": accuracy})
实验追踪之外,你还需要管理模型版本。哪个模型在生产?哪个是预发布?上周的是哪个?
MLflow 的模型注册表(Model Registry)提供:
代码用 git 版本管理。数据也该如此,但 git 处理不了大文件。DVC(Data Version Control)解决这个问题。
dvc init dvc add data/training.csv git add data/training.csv.dvc data/.gitignore git commit -m "Track training data" dvc push
DVC 把实际数据存在远程存储(S3、GCS、Azure),在 git 里保留一个小的 .dvc 文件记录哈希。当你 checkout 一个 git commit,dvc checkout 还原当时用的精确数据。
这意味着每个 git commit 同时钉住了代码和数据。完全可复现。
一个可复现实验需要四样东西:
import numpy as np import random def set_seed(seed=42): random.seed(seed) np.random.seed(seed) try: import torch torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True except ImportError: pass
典型的演进路径:
| 错误 | 为什么糟 | 修复 |
|---|---|---|
| 切分前在全数据上拟合 | 数据泄漏 | 用 Pipeline 配 cross_val_score |
| 流水线外的特征工程 | 训练与服务变换不同 | 把所有变换放进 Pipeline |
| 不处理未知类别 | 生产里新值导致崩溃 | OneHotEncoder(handle_unknown="ignore") |
| 硬编码列名 | schema 变就崩 | 用配置里的列名列表 |
| 无数据校验 | 坏数据上沉默给错预测 | 预测前加 schema 检查 |
| 训练/服务偏差 | 生产里模型看到不同特征 | 训练和服务共用一个 Pipeline 对象 |
code/pipeline.py 从零构建一条完整的机器学习流水线。
class CustomTransformer: def __init__(self): self.means = None self.stds = None def fit(self, X): self.means = np.mean(X, axis=0) self.stds = np.std(X, axis=0) self.stds[self.stds == 0] = 1.0 return self def transform(self, X): return (X - self.means) / self.stds def fit_transform(self, X): return self.fit(X).transform(X)
class PipelineFromScratch: def __init__(self, steps): self.steps = steps def fit(self, X, y=None): X_current = X.copy() for name, step in self.steps[:-1]: X_current = step.fit_transform(X_current) name, model = self.steps[-1] model.fit(X_current, y) return self def predict(self, X): X_current = X.copy() for name, step in self.steps[:-1]: X_current = step.transform(X_current) name, model = self.steps[-1] return model.predict(X_current)
注意 fit 时变换器调 fit_transform,而 predict 时只调 transform——这正是防泄漏的关键。
代码演示带流水线的交叉验证如何防数据泄漏:scaler 在每一折的训练数据上单独拟合。
用 ColumnTransformer、多条预处理路径和一个模型搭一条完整流水线,配恰当的交叉验证和实验日志。
from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.ensemble import GradientBoostingClassifier numeric_features = ["age", "income", "score"] categorical_features = ["city", "gender", "plan"] preprocessor = ColumnTransformer( transformers=[ ("num", Pipeline([ ("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler()), ]), numeric_features), ("cat", Pipeline([ ("impute", SimpleImputer(strategy="most_frequent")), ("encode", OneHotEncoder(handle_unknown="ignore")), ]), categorical_features), ] ) pipe = Pipeline([ ("preprocess", preprocessor), ("model", GradientBoostingClassifier()), ]) pipe.fit(X_train, y_train)
from sklearn.model_selection import cross_val_score scores = cross_val_score(pipe, X, y, cv=5, scoring="accuracy") print(f"CV 准确率: {scores.mean():.4f} +/- {scores.std():.4f}")
cross_val_score 在每一折内部对训练部分调 fit(从而 fit_transform)、对验证部分只调 transform,彻底杜绝泄漏。
import joblib # 训练并保存 pipe.fit(X_train, y_train) joblib.dump(pipe, "model_pipeline.joblib") # 在另一个脚本里加载并推理 loaded_pipe = joblib.load("model_pipeline.joblib") predictions = loaded_pipe.predict(X_new)
序列化后整个对象(填补器、缩放器、编码器、模型)作为一个产物存在。推理时无需重建任何变换逻辑,训练/服务偏差无从产生。
from sklearn.model_selection import GridSearchCV, cross_val_score param_grid = { "model__max_depth": [3, 5, 7], "model__n_estimators": [50, 100, 200], "model__learning_rate": [0.01, 0.1], } search = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy") search.fit(X_train, y_train) print(f"最佳参数: {search.best_params_}")
注意参数名用 步骤名__参数名 的双下划线语法,这让你能调流水线任意步骤的超参数。
| 维度 | 手写 PipelineFromScratch | sklearn Pipeline |
|---|---|---|
| 完整性 | 仅链式 fit/transform | 支持 ColumnTransformer、网格调参、序列化 |
| 与生态集成 | 无 | cross_val_score、GridSearchCV、joblib 都原生支持 |
| 适用 | 理解 fit/transform 区别 | 生产 |
💡 凡是能在 Pipeline 里做的事就别在 Pipeline 外做。任何游离于流水线之外的变换都是训练/服务偏差的潜在来源。
本节产出:
outputs/prompt-ml-pipeline.md——一个帮你搭建和调试机器学习流水线的技能提示词,涵盖防泄漏、列变换、序列化、实验追踪。code/pipeline.py——从零到 sklearn 的完整流水线实现。多列类型流水线:搭一条处理 3 个数值列、2 个类别列的流水线。用 ColumnTransformer 对数值列做中位数填补 + 缩放,对类别列做众数填补 + one-hot 编码,用 5 折交叉验证训练。
故意引入泄漏:在切分前对全集拟合 scaler,对比其交叉验证分数(有泄漏)与流水线交叉验证分数(干净)。差距多大?
序列化与一致性:用 joblib.dump 保存流水线,在另一个脚本加载并预测,验证预测完全一致。
加多项式特征变换器:给流水线加一个自定义变换器,为两个最重要的数值列生成二次多项式特征。它该放在流水线的哪个位置?
配 MLflow:给流水线配 MLflow 追踪,跑 5 个不同超参数的实验,用 MLflow UI(mlflow ui)对比运行并挑出最佳模型。
fit_transform(学统计量+变换),推理/验证时只 transform(用学到的统计量)。model__max_depth 让你能调任意步骤的参数。下一节,我们讲朴素贝叶斯——为何一个数学上「错」的独立性假设,反而能在文本分类上击败更「聪明」的模型。