handson-ml 机器学习项目实战检查清单:从问题定义到上线监控的完整工作流
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 中作者提供的其他仓库地址。
一、八个主步骤总览
清单将机器学习项目划分为八个主步骤,几乎适用于任何项目类型:
- Frame the problem and look at the big picture:界定问题并审视大局;
- Get the data:获取数据;
- Explore the data to gain insights:探索数据以获得洞见;
- Prepare the data:准备数据,更好地向算法暴露底层数据模式;
- Short-list promising models:尝试多种模型并筛选出最有可能成功的候选;
- Fine-tune the system:微调模型并将它们组合成优质解决方案;
- Present your solution:汇报你的解决方案;
- Launch, monitor, and maintain the system:上线、监控并维护系统。
清单同时强调:你可以(也应该)根据自身项目需求自由调整这份清单,它是一份方法论参考而非僵化流程。
二、第 1 步:界定问题并审视大局
这一阶段的核心目标是在动手写任何代码之前,用业务语言把问题问清楚。清单给出的 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 步:准备数据
清单给出两条硬性要求与四个子步骤:
工作于数据副本上(保留原始数据集不变);为所有数据变换编写函数——理由有五:下次拿到新数据可轻松复用;可在未来项目中复用;可用于清洗和准备测试集;可用于清洗新数据实例;便于把数据准备的选择当作超参数来调优。
四个子步骤:
- 数据清洗:修复或移除离群点(可选);填充缺失值(如用 0、均值、中位数等)或删除对应行/列;
- 特征选择(可选):丢弃对任务无用的属性;
- 特征工程:离散化连续特征;分解特征(分类、日期/时间等);添加有前景的变换(如 log(x)、sqrt(x)、x² 等);聚合出有前景的新特征;
- 特征缩放:标准化(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 步:筛选有前景的模型
清单要点:
- 如果数据巨大,可先采样较小的训练集以便在合理时间内训练多个模型(注意这会惩罚大型神经网络、随机森林等复杂模型);
- 尽量自动化该步骤。
- 用标准参数训练多个不同类别的快速粗糙模型(线性、朴素贝叶斯、SVM、随机森林、神经网络等);
- 度量并比较性能——对每个模型使用 N 折交叉验证,计算性能的均值与标准差;
- 分析每个算法最重要的变量;
- 分析模型犯的错误类型——人类会用什么数据来避免这些错误;
- 快速做一轮特征选择与工程;
- 再快速迭代上述步骤一到两次;
- 筛出前 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 步:微调系统
清单要点:
- 此阶段应使用尽可能多的数据,尤其越接近调优尾声越要用全量数据;
- 尽量自动化。
- 用交叉验证微调超参数——把数据变换的选择也当作超参数(例如:缺失值填 0 还是中位数?还是直接删行?);
- 除非待搜索的超参数组合极少,否则优先随机搜索(random search)而非网格搜索(grid search);若训练时间很长,可考虑贝叶斯优化(如基于高斯过程先验的方法);
- 尝试集成方法——组合最优模型往往好于单独运行;
- 对最终模型有把握后,在测试集上度量其性能以估算泛化误差。
关键警告(清单原文):测量泛化误差后不要再调整模型——否则你只是开始对测试集过拟合。
仓库落地实例:
网格搜索——对随机森林搜索 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 步:汇报解决方案
清单检查项:
- 记录你已完成的工作;
- 制作一份漂亮的演示文稿——先突出大局;
- 解释为什么你的方案达成了业务目标;
- 别忘了汇报沿途发现的有趣点:哪些有效、哪些无效;列出假设与系统局限;
- 通过漂亮的可视化或易记的陈述传达关键发现(如"中位数收入是房价的头号预测因子"——这一表述正是由上文特征重要性分析支撑的)。
仓库落地实例:notebook 中的可视化(地理散点图、income_cat 直方图、相关性矩阵等)即为"用可视化传达发现"的样板;而特征重要性排序则提供了"median income 是房价首要预测因子"这类可写进演示文稿的一行式结论。汇报时应像清单要求的那样,把"业务目标 → 解决方案 → 指标达成情况"的链路讲清楚,而非只罗列技术细节。
九、第 8 步:上线、监控与维护
清单检查项:
- 为生产准备解决方案:接入生产数据输入、编写单元测试等;
- 编写监控代码:按固定间隔检查系统线上性能,性能下降时触发告警。注意缓慢退化:随着数据演化,模型会"腐化"(data rot);度量性能可能需要人工流水线(如众包标注);同时要监控输入质量(如传感器故障输出随机值、其他团队的输出变得陈旧),这对在线学习系统尤其重要;
- 定期用新鲜数据重训模型(尽可能自动化)。
仓库落地实例: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")
把"预处理流水线 + 模型"整体序列化,是重训部署、灰度切换与回滚的基础设施前提,也对应清单"定期重训"的能力要求。
十、结语:把清单变成你的项目习惯
回顾整份清单与仓库实践,可以提炼出贯穿始终的三条方法论主线:
- 自动化优先:数据获取(
fetch_housing_data)、数据准备(Pipeline)、超参数搜索(GridSearchCV/RandomizedSearchCV)、重训部署都应尽可能自动化,这样"拿到新数据"才不是灾难而是日常; - 防数据窥探:分层切分测试集、只在测试集上评估一次、把数据变换当超参数——所有约束都是为了得到一个诚实的泛化误差估计;
- 业务闭环:从"用业务术语定义目标"到"解释方案为何达成业务目标",再到上线后监控业务指标,机器学习项目成败的关键往往不在算法而在流程纪律。
后续可对照学习仓库中的其他 notebook(如 03_classification.ipynb 的分类流程、07_ensemble_learning_and_random_forests.ipynb 的集成深化),将本清单的方法论迁移到更多任务类型上。