handson-ml 机器学习项目实战检查清单:从问题定义到上线监控的完整工作流

原创2026-09-29 10:27:341,672 阅读
文章标签:示例工程机器学习深度学习教程数据科学

handson-ml 机器学习项目实战检查清单:从问题定义到上线监控的完整工作流

本篇技术指南以 handson-ml 仓库中的 ml-project-checklist.md 为骨架,完整展开机器学习项目的八个核心阶段:问题定义与大局观、数据获取、数据探索、数据准备、模型筛选、超参数调优、成果汇报与上线监控。全文结合仓库内 02_end_to_end_machine_learning_project.ipynb 的真实实现(加州房价预测项目),给出每个步骤可复制的代码与可验证的工程依据,帮助你建立一套从 0 到 1、可落地、可复用的机器学习项目方法论。

说明:本仓库为《Hands-on Machine Learning with Scikit-Learn and TensorFlow》第一版配套代码(2017 年出版,作者已在仓库 README 中标注项目处于弃用状态),文中涉及的具体 API 与版本以仓库内 environment.yml 与 notebook 实际内容为准;第三版与 PyTorch 版请参见仓库 README 中作者提供的其他仓库地址。

一、八个主步骤总览

清单将机器学习项目划分为八个主步骤,几乎适用于任何项目类型:

  1. Frame the problem and look at the big picture:界定问题并审视大局;
  2. Get the data:获取数据;
  3. Explore the data to gain insights:探索数据以获得洞见;
  4. Prepare the data:准备数据,更好地向算法暴露底层数据模式;
  5. Short-list promising models:尝试多种模型并筛选出最有可能成功的候选;
  6. Fine-tune the system:微调模型并将它们组合成优质解决方案;
  7. Present your solution:汇报你的解决方案;
  8. Launch, monitor, and maintain the system:上线、监控并维护系统。

清单同时强调:你可以(也应该)根据自身项目需求自由调整这份清单,它是一份方法论参考而非僵化流程。

二、第 1 步:界定问题并审视大局

这一阶段的核心目标是在动手写任何代码之前,用业务语言把问题问清楚。清单给出的 12 个问题包括:

  1. 用业务术语定义目标;
  2. 解决方案将如何被使用;
  3. 当前有哪些解决方案/变通做法;
  4. 如何界定问题类型(监督/非监督、在线/离线等);
  5. 如何度量性能;
  6. 性能度量是否与业务目标对齐;
  7. 达到业务目标所需的最低性能是多少;
  8. 有哪些类似问题可以借鉴,能否复用经验或工具;
  9. 是否可获得领域专家经验;
  10. 你如何手工解决该问题;
  11. 列出截至目前你或他人做出的所有假设;
  12. 尽可能验证这些假设。

仓库落地实例:在 02_end_to_end_machine_learning_project.ipynb 中,场景被设定为 "Machine Learning Housing Corp." 的加州房价预测任务——给定各街区(block group)的经纬度、房龄、房间数、人口、收入等特征,预测房价中位数 median_house_value。

  • 问题类型:这是一个典型的监督学习 + 回归任务(预测连续数值),属于批量(batch)学习而非在线学习;
  • 性能度量:notebook 中采用 RMSE(均方根误差) 作为主度量,计算方式为 np.sqrt(mean_squared_error(y_true, y_pred))(见 notebook 中线性回归与随机森林的评估代码);
  • 特征与目标:数据集的 9 个数值特征与 1 个分类特征 ocean_proximity 详见 datasets/housing/README.md。

在定义问题阶段就应该确定"用什么指标衡量成功",因为后续所有模型的比较、超参数搜索的 scoring 参数都围绕它展开(见下文第 6 步)。

三、第 2 步:获取数据

清单强调:尽可能自动化数据获取流程,以便后续能轻松拿到新鲜数据。具体检查项包括:列出需要的数据及数量、定位并记录数据来源、评估存储空间、核对法律义务并取得授权、创建带足够存储空间的 workspace、把数据转换为易于操作的格式(不改变数据本身)、确保敏感信息被删除或脱敏、检查数据的规模与类型(时间序列、样本、地理数据等),以及——切出一个测试集放在一边,永远不去看它(避免数据窥探 data snooping)。

仓库中的自动化获取实现

notebook 通过 fetch_housing_data() 与 load_housing_data() 两个函数实现数据获取与加载的自动化:

import os, tarfile, urllib.request

DOWNLOAD_ROOT = "https://raw.githubusercontent.com/ageron/handson-ml/master/"
HOUSING_PATH = os.path.join("datasets", "housing")
HOUSING_URL = DOWNLOAD_ROOT + "datasets/housing/housing.tgz"

def fetch_housing_data(housing_url=HOUSING_URL, housing_path=HOUSING_PATH):
    os.makedirs(housing_path, exist_ok=True)
    tgz_path = os.path.join(housing_path, "housing.tgz")
    urllib.request.urlretrieve(housing_url, tgz_path)
    housing_tgz = tarfile.open(tgz_path)
    housing_tgz.extractall(path=housing_path)
    housing_tgz.close()

def load_housing_data(housing_path=HOUSING_PATH):
    csv_path = os.path.join(housing_path, "housing.csv")
    return pd.read_csv(csv_path)

仓库根目录下的 datasets/housing/housing.csv 即为解压后的数据文件,datasets/housing/housing.tgz 为原始压缩包。数据集的背景信息(源自 1990 年加州人口普查、每行对应一个 census block group 等)记录在 datasets/housing/README.md 中,该文件还明确了两处人为修改:total_bedrooms 列随机删除了 207 个值(用于讨论缺失值处理)、新增了 ocean_proximity 分类属性(用于讨论分类数据处理),这正好对应清单第 3、4 步要面对的真实脏数据场景。

测试集划分与数据窥探防范

清单强调测试集必须"切出来放一边,永远不看"。notebook 给出了层层递进的三版实现:

版本一:随机洗牌划分(仅演示,不可复用)

def split_train_test(data, test_ratio):
    shuffled_indices = np.random.permutation(len(data))
    test_set_size = int(len(data) * test_ratio)
    test_indices = shuffled_indices[:test_set_size]
    train_indices = shuffled_indices[test_set_size:]
    return data.iloc[train_indices], data.iloc[test_indices]

train_set, test_set = split_train_test(housing, 0.2)  # 16512 train + 4128 test

版本二:基于行标识的稳定哈希划分(推荐思路)

随机洗牌每次运行结果不同,且数据更新后测试集会"泄漏"进训练集。更稳的做法是基于稳定的行 ID 做哈希判定:

from zlib import crc32

def test_set_check(identifier, test_ratio):
    return crc32(np.int64(identifier)) & 0xffffffff < test_ratio * 2**32

def split_train_test_by_id(data, test_ratio, id_column):
    ids = data[id_column]
    in_test_set = ids.apply(lambda id_: test_set_check(id_, test_ratio))
    return data.loc[~in_test_set], data.loc[in_test_set]

由于原始数据没有天然 ID,notebook 用 longitude * 1000 + latitude 合成复合 ID(地理坐标在数据生命周期内稳定不变):

housing_with_id["id"] = housing["longitude"] * 1000 + housing["latitude"]
train_set, test_set = split_train_test_by_id(housing_with_id, 0.2, "id")

版本三:分层采样(StratifiedShuffleSplit)

仅仅随机划分还不够——如果测试集与训练集的收入分布不一致,模型评估会有偏差。notebook 先构造收入类别列,再按类别分层划分:

housing["income_cat"] = np.ceil(housing["median_income"] / 1.5)
housing["income_cat"].where(housing["income_cat"] < 5, 5.0, inplace=True)

from sklearn.model_selection import StratifiedShuffleSplit
split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
for train_index, test_index in split.split(housing, housing["income_cat"]):
    strat_train_set = housing.loc[train_index]
    strat_test_set = housing.loc[test_index]

之后 notebook 用 income_cat_proportions() 对比了整体、分层测试集与随机测试集的收入类别占比,验证分层采样让测试集分布与整体更一致,这正是清单第 2 步"防止数据窥探"的工程化实现。

四、第 3 步:探索数据以获得洞见

清单建议:尽可能让领域专家参与探索,并在 Jupyter notebook 中完整记录探索过程(notebook 本身就是最佳载体)。具体检查项:

  • 为探索创建数据副本(必要时抽样到可处理的大小);
  • 研究每个属性的特征:名称、类型(分类/数值、有界/无界、文本、结构化等)、缺失值百分比、噪声类型(随机噪声/离群点/舍入误差等)、对任务是否有用、分布类型(高斯/均匀/对数等);
  • 对监督学习任务,识别目标属性;
  • 可视化数据;
  • 研究属性间的相关性;
  • 研究如何手工解决问题;
  • 识别可能要应用的有前景的变换;
  • 识别可能有用但缺失的额外数据(必要时回到第 2 步补充数据);
  • 记录所学内容。

仓库落地实例:notebook 对 housing 数据做了 housing.info()(发现 total_bedrooms 有 207 个缺失值)、housing.describe()、ocean_proximity.value_counts() 等探索,并用散点图可视化地理分布与房价。相关性分析揭示了关键洞见——median_income 与房价中位数相关性最强(相关系数约 0.69,notebook 中输出为 0.688075208 量级),而新增的三个组合特征(rooms_per_household、bedrooms_per_room、population_per_household)中 bedrooms_per_room 与目标呈负相关(约 -0.26),比原始属性更能暴露数据模式。这正是清单中"识别有前景的变换"在实操中的体现:领域直觉(每户房间数比总房间数更有意义)应当先于建模被验证。

五、第 4 步:准备数据

清单给出两条硬性要求与四个子步骤:

工作于数据副本上(保留原始数据集不变);为所有数据变换编写函数——理由有五:下次拿到新数据可轻松复用;可在未来项目中复用;可用于清洗和准备测试集;可用于清洗新数据实例;便于把数据准备的选择当作超参数来调优。

四个子步骤:

  1. 数据清洗:修复或移除离群点(可选);填充缺失值(如用 0、均值、中位数等)或删除对应行/列;
  2. 特征选择(可选):丢弃对任务无用的属性;
  3. 特征工程:离散化连续特征;分解特征(分类、日期/时间等);添加有前景的变换(如 log(x)、sqrt(x)、x² 等);聚合出有前景的新特征;
  4. 特征缩放:标准化(standardize)或归一化(normalize)特征。

仓库中的完整数据准备流水线

① 缺失值填充:notebook 使用 SimpleImputer 以中位数策略填充缺失值(Scikit-Learn 0.20 起替代旧的 Imputer,见 notebook 中的兼容性注释):

from sklearn.impute import SimpleImputer  # Scikit-Learn 0.20+
imputer = SimpleImputer(strategy="median")

② 特征工程:先以 CombinedAttributesAdder(继承 BaseEstimator, TransformerMixin 的自定义变换器)实现组合特征,再改用 FunctionTransformer 以函数形式完成同一件事——生成 rooms_per_household、population_per_household 与可选的 bedrooms_per_room:

from sklearn.preprocessing import FunctionTransformer

def add_extra_features(X, add_bedrooms_per_room=True):
    rooms_per_household = X[:, rooms_ix] / X[:, household_ix]
    population_per_household = X[:, population_ix] / X[:, household_ix]
    if add_bedrooms_per_room:
        bedrooms_per_room = X[:, bedrooms_ix] / X[:, rooms_ix]
        return np.c_[X, rooms_per_household, population_per_household, bedrooms_per_room]
    else:
        return np.c_[X, rooms_per_household, population_per_household]

attr_adder = FunctionTransformer(add_extra_features, validate=False,
                                 kw_args={"add_bedrooms_per_room": False})

注意 add_bedrooms_per_room 被设计为可开关参数——这正是清单"把数据准备的选择当作超参数"的直接体现,后面可以用 GridSearchCV 自动探索是否添加该特征。

③ 分类编码:ocean_proximity 有 <1H OCEAN、INLAND、ISLAND、NEAR BAY、NEAR OCEAN 五种类别(见 datasets/housing/README.md 的 value_counts 输出),用 OneHotEncoder 转为独热向量;默认返回稀疏矩阵,可用 toarray() 转稠密,或创建时指定 sparse=False。notebook 还提供了 Scikit-Learn < 0.20 时从 future_encoders.py 导入兼容实现的备选路径。

④ 特征缩放与流水线组装:数值属性用 StandardScaler 标准化,并通过 Pipeline 与 ColumnTransformer 组装成端到端的完整预处理流程:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

num_pipeline = Pipeline([
        ('imputer', SimpleImputer(strategy="median")),
        ('attribs_adder', FunctionTransformer(add_extra_features, validate=False)),
        ('std_scaler', StandardScaler()),
    ])

from sklearn.compose import ColumnTransformer

num_attribs = list(housing_num)
cat_attribs = ["ocean_proximity"]

full_pipeline = ColumnTransformer([
        ("num", num_pipeline, num_attribs),
        ("cat", OneHotEncoder(), cat_attribs),
    ])

housing_prepared = full_pipeline.fit_transform(housing)  # 结果形状 (16512, 16)

预处理后训练矩阵变为 16512 行 × 16 列(9 个数值属性 + 3 个组合特征 + 5 个独热编码列减 1 个原始分类列,具体列数依 notebook 实际输出为 16)。这套 full_pipeline 的关键价值在于:对测试集只需调用 full_pipeline.transform(X_test),对线上新数据也只需同样调用,完全满足清单"工作于副本、将变换写成可复用函数"的要求。

六、第 5 步:筛选有前景的模型

清单要点:

  • 如果数据巨大,可先采样较小的训练集以便在合理时间内训练多个模型(注意这会惩罚大型神经网络、随机森林等复杂模型);
  • 尽量自动化该步骤。
  1. 用标准参数训练多个不同类别的快速粗糙模型(线性、朴素贝叶斯、SVM、随机森林、神经网络等);
  2. 度量并比较性能——对每个模型使用 N 折交叉验证,计算性能的均值与标准差;
  3. 分析每个算法最重要的变量;
  4. 分析模型犯的错误类型——人类会用什么数据来避免这些错误;
  5. 快速做一轮特征选择与工程;
  6. 再快速迭代上述步骤一到两次;
  7. 筛出前 3~5 个最有前景的模型,优先选择犯错类型不同的模型(便于后续集成互补)。

仓库落地实例:notebook 依次训练了 LinearRegression、DecisionTreeRegressor、RandomForestRegressor(n_estimators=10) 与 SVR(kernel="linear"),并用 10 折交叉验证比较:

from sklearn.model_selection import cross_val_score

scores = cross_val_score(tree_reg, housing_prepared, housing_labels,
                         scoring="neg_mean_squared_error", cv=10)
tree_rmse_scores = np.sqrt(-scores)

def display_scores(scores):
    print("Scores:", scores)
    print("Mean:", scores.mean())
    print("Standard deviation:", scores.std())

三个代表性模型在训练集与交叉验证下的 RMSE 对比(notebook 实际输出,量级约):

模型 训练集 RMSE(约) 10 折 CV RMSE 均值(约) 结论
LinearRegression 68,600 69,052(标准差约 2,731) 严重欠拟合,训练误差远高于预期
DecisionTreeRegressor 0(近乎完全拟合) 71,408(标准差约 2,439) 严重过拟合,训练误差与 CV 误差差距巨大
RandomForestRegressor(n_estimators=10) 21,933 52,584(标准差约 2,298) 明显优于前两者,进入候选名单

交叉验证的"均值 + 标准差"组合(notebook 中 display_scores 同时打印两者)正是清单第 5 步要求的标准做法——均值反映模型水平,标准差反映稳定性。另外 note:notebook 明确注明 n_estimators=10 是为了规避 Scikit-Learn 0.22 中默认值变更产生的警告。

七、第 6 步:微调系统

清单要点:

  • 此阶段应使用尽可能多的数据,尤其越接近调优尾声越要用全量数据;
  • 尽量自动化。
  1. 用交叉验证微调超参数——把数据变换的选择也当作超参数(例如:缺失值填 0 还是中位数?还是直接删行?);
  2. 除非待搜索的超参数组合极少,否则优先随机搜索(random search)而非网格搜索(grid search);若训练时间很长,可考虑贝叶斯优化(如基于高斯过程先验的方法);
  3. 尝试集成方法——组合最优模型往往好于单独运行;
  4. 对最终模型有把握后,在测试集上度量其性能以估算泛化误差。

关键警告(清单原文):测量泛化误差后不要再调整模型——否则你只是开始对测试集过拟合。

仓库落地实例:

网格搜索——对随机森林搜索 n_estimators 与 max_features(以及一组关闭 bootstrap 的组合),5 折交叉验证,共 (12+6)×5=90 轮训练:

from sklearn.model_selection import GridSearchCV

param_grid = [
    # 尝试 12(3×4)种超参数组合
    {'n_estimators': [3, 10, 30], 'max_features': [2, 4, 6, 8]},
    # 再尝试 6(2×3)种 bootstrap=False 的组合
    {'bootstrap': [False], 'n_estimators': [3, 10], 'max_features': [2, 3, 4]},
]

forest_reg = RandomForestRegressor(random_state=42)
grid_search = GridSearchCV(forest_reg, param_grid, cv=5,
                           scoring='neg_mean_squared_error', return_train_score=True)
grid_search.fit(housing_prepared, housing_labels)

搜索结果(notebook 实际输出):最佳组合为 {'max_features': 8, 'n_estimators': 30},对应 CV RMSE 约 49,682;通过 grid_search.cv_results_ 可查看每一组参数的平均得分。注意 scoring 传的是 neg_mean_squared_error(sklearn 的负均方误差,越大越好),因此打印时需取 np.sqrt(-mean_score) 还原为 RMSE 语义。

随机搜索——notebook 也用 RandomizedSearchCV 对 SVR 等模型进行了随机搜索(并给出了使用 SciPy 分布定义 param_distributions 的示例),与清单"优先随机搜索"的建议一致。

集成:notebook 中多次出现"用 GridSearchCV/RandomizedSearchCV 找到最好模型后,再以它为最终估计器"的流程;课程练习还引导读者尝试 cross_val_predict 与模型融合思路,对应清单"尝试 Ensemble 方法"。

特征重要性分析:微调后可用 grid_search.best_estimator_.feature_importances_ 输出特征重要性排序(notebook 实际输出显示 median_income 约 0.366 居首,INLAND 约 0.165 次之),这既是第 5 步"分析最重要变量"的延续,也是第 7 步向业务方汇报"哪些因素驱动预测"的证据来源。

测试集最终评估:对最终模型在保留的测试集上一次性评估:

final_model = grid_search.best_estimator_

X_test = strat_test_set.drop("median_house_value", axis=1)
y_test = strat_test_set["median_house_value"].copy()

X_test_prepared = full_pipeline.transform(X_test)   # 只 transform,不 fit
final_predictions = final_model.predict(X_test_prepared)

final_mse = mean_squared_error(y_test, final_predictions)
final_rmse = np.sqrt(final_mse)   # notebook 输出约 47730

notebook 还用 scipy.stats 的 t 分布与正态分布分别计算了测试 RMSE 的 95% 置信区间(约 [45,685, 49,691]),这是向利益相关方汇报"预测误差到底有多可信"的实用技巧。

八、第 7 步:汇报解决方案

清单检查项:

  1. 记录你已完成的工作;
  2. 制作一份漂亮的演示文稿——先突出大局;
  3. 解释为什么你的方案达成了业务目标;
  4. 别忘了汇报沿途发现的有趣点:哪些有效、哪些无效;列出假设与系统局限;
  5. 通过漂亮的可视化或易记的陈述传达关键发现(如"中位数收入是房价的头号预测因子"——这一表述正是由上文特征重要性分析支撑的)。

仓库落地实例:notebook 中的可视化(地理散点图、income_cat 直方图、相关性矩阵等)即为"用可视化传达发现"的样板;而特征重要性排序则提供了"median income 是房价首要预测因子"这类可写进演示文稿的一行式结论。汇报时应像清单要求的那样,把"业务目标 → 解决方案 → 指标达成情况"的链路讲清楚,而非只罗列技术细节。

九、第 8 步:上线、监控与维护

清单检查项:

  1. 为生产准备解决方案:接入生产数据输入、编写单元测试等;
  2. 编写监控代码:按固定间隔检查系统线上性能,性能下降时触发告警。注意缓慢退化:随着数据演化,模型会"腐化"(data rot);度量性能可能需要人工流水线(如众包标注);同时要监控输入质量(如传感器故障输出随机值、其他团队的输出变得陈旧),这对在线学习系统尤其重要;
  3. 定期用新鲜数据重训模型(尽可能自动化)。

仓库落地实例:notebook 在 "Extra material" 部分给出了两项与上线直接相关的工程化实践:

① 端到端流水线(预处理 + 预测一体)——把 full_pipeline 与回归器串成一个 Pipeline,做到"原始 DataFrame 进、预测值出",这正是清单"接入生产数据输入"的最小实现:

full_pipeline_with_predictor = Pipeline([
        ("preparation", full_pipeline),
        ("linear", LinearRegression())
    ])

full_pipeline_with_predictor.fit(housing, housing_labels)
full_pipeline_with_predictor.predict(some_data)  # 直接喂原始数据

② 模型持久化——使用 joblib 保存与加载完整模型(notebook 明确注明 sklearn.externals.joblib 已弃用,应直接 import joblib;environment.yml 中也列入了 joblib=0.14):

import joblib

joblib.dump(my_model, "my_model.pkl")
my_model_loaded = joblib.load("my_model.pkl")

把"预处理流水线 + 模型"整体序列化,是重训部署、灰度切换与回滚的基础设施前提,也对应清单"定期重训"的能力要求。

十、结语:把清单变成你的项目习惯

回顾整份清单与仓库实践,可以提炼出贯穿始终的三条方法论主线:

  1. 自动化优先:数据获取(fetch_housing_data)、数据准备(Pipeline)、超参数搜索(GridSearchCV/RandomizedSearchCV)、重训部署都应尽可能自动化,这样"拿到新数据"才不是灾难而是日常;
  2. 防数据窥探:分层切分测试集、只在测试集上评估一次、把数据变换当超参数——所有约束都是为了得到一个诚实的泛化误差估计;
  3. 业务闭环:从"用业务术语定义目标"到"解释方案为何达成业务目标",再到上线后监控业务指标,机器学习项目成败的关键往往不在算法而在流程纪律。

后续可对照学习仓库中的其他 notebook(如 03_classification.ipynb 的分类流程、07_ensemble_learning_and_random_forests.ipynb 的集成深化),将本清单的方法论迁移到更多任务类型上。

登录后查看全文
handson-ml