1. 为什么第二章值得单独写一篇笔记
很多人读技术书有个习惯,第一章翻得仔细,到了第二章就开始加速,觉得“基础的东西扫一眼就行”。我一开始也是这么想的,直到在实际项目里连续踩了几次坑,回头再翻第二章,才发现这一章的信息密度远比第一遍读的时候感受到的要大得多。
这本《机器学习实战:基于Scikit-Learn、Keras和TensorFlow》的第二章,主题是端到端的机器学习项目。它没有停留在“什么是训练集、什么是测试集”这种概念层面,而是用一个完整的项目案例,把从数据获取、探索性分析、特征工程、模型训练、评估调优到最终部署上线的全流程串了一遍。换句话说,这一章是整本书从“理论”跨到“实战”的那道门槛。
我写这篇笔记的目的很明确:把第二章里那些容易被快速略过、但在实际工作中反复出现的细节拆开来讲。比如分层抽样到底怎么选分层依据、数据探索阶段应该重点看什么、特征工程的组合方式为什么那样设计、交叉验证和网格搜索的配合逻辑是什么。这些内容在书里都有,但书是线性叙述的,而实际做项目时遇到的问题是非线性的,所以我会按照“实际做项目时会怎么想”这个顺序来重新组织。
这篇笔记适合两类人看:一类是正在读这本书、想找人一起把第二章吃透的读者;另一类是已经有一定机器学习基础、但想看看一个完整项目从头到尾应该怎么走的人。不管你是哪种,我都建议你打开一个Jupyter Notebook,跟着下面的内容把代码跑一遍,光看是看不出问题的。
2. 端到端项目的整体设计思路拆解
2.1 为什么选“端到端”而不是“分模块讲”
第二章最核心的设计理念就是“端到端”。书里没有先讲数据预处理、再讲模型选择、再讲评估,而是模拟了一个真实项目的完整流程。这个选择背后有很实际的考量。
在实际工作中,机器学习的各个环节是高度耦合的。你在数据探索阶段发现的问题,会直接影响特征工程的设计;特征工程的设计,又会决定你选什么模型、用什么评估指标。如果按模块拆开讲,很容易造成一种错觉:好像每个环节都是独立的,可以单独优化。但实际上,一个环节的改动往往会牵动全局。
我举个例子。书里用的数据集是加州房价数据,目标是预测某个区域的房价中位数。在数据探索阶段,你会发现有几个特征和房价的相关性特别高,比如收入中位数。这时候你可能会想:那我直接拿这几个特征做线性回归不就行了?但如果你真的这么做了,评估结果往往会让你失望。原因在于,房价和收入的关系不是简单线性的,而且不同区域的房价分布差异很大,单一模型很难同时拟合好所有区域。
这就是端到端思路的价值:它逼着你在每个环节都考虑上下游的影响,而不是孤立地优化某一个点。
2.2 项目流程的骨架与关键决策点
把第二章的流程抽象一下,大概是这么一条链路:
- 数据获取与初步观察
- 划分训练集和测试集
- 探索性数据分析
- 特征工程与数据预处理
- 模型训练与初步评估
- 交叉验证与模型调优
- 测试集评估与最终交付
这条链路看起来线性,但实际上有几个关键决策点会反复出现。第一个决策点是划分策略:用什么方式划分训练集和测试集?随机划分还是分层划分?第二个决策点是评估指标:用RMSE还是MAE?为什么?第三个决策点是特征处理方式:哪些特征需要缩放?哪些需要编码?第四个决策点是模型选择:从简单模型开始还是直接上集成模型?
书里在每个决策点都给出了明确的选择和理由,但我在实际项目中最大的体会是:这些选择不是绝对的,而是取决于你的数据特点和业务目标。比如分层抽样,书里是按收入中位数分层的,但如果你的数据里某个类别特别少,可能就需要按类别分层。理解“为什么这么选”比记住“选了什么”重要得多。
2.3 一个容易被忽略的前置问题:这个项目到底在解决什么
在动手写代码之前,书里花了一些篇幅讨论“框架问题”。这个部分很多人会跳过,但我觉得恰恰是最重要的。
框架问题的核心是:你的模型要用来做什么?是做一个独立的预测工具,还是嵌入到一个更大的系统里?这个问题的答案会直接影响你对模型性能的要求。
如果模型是独立使用的,那RMSE稍微高一点可能无所谓,用户看到的是一个预测值,误差在可接受范围内就行。但如果模型是嵌入到一个自动化决策系统里的,比如根据预测房价自动决定是否放贷,那误差的代价就完全不一样了。这时候你可能需要更严格的评估标准,甚至需要考虑模型的可解释性。
我在实际项目中见过太多这样的情况:模型在测试集上表现很好,但上线后效果大打折扣。原因往往不是模型本身有问题,而是从一开始就没有想清楚模型的使用场景。第二章在这一点上给了一个很好的示范:先想清楚要解决什么问题,再动手写代码。
3. 数据获取与探索阶段的核心细节
3.1 数据下载与目录结构的设计
书里用的是在线数据集,通过一个函数直接下载。这个操作看起来很简单,但实际项目中,数据获取这一步往往比想象中复杂。
首先是目录结构。书里建议创建一个独立的目录来存放数据,这个习惯非常重要。我自己的做法是每个项目一个根目录,下面分data、notebooks、src、models、reports几个子目录。data目录下再分raw和processed,原始数据只读,处理后的数据可以覆盖。这样做的好处是,当你需要回溯问题时,可以清楚地知道每一步的数据来源。
其次是数据版本管理。书里没有展开讲这一点,但实际项目中,数据是会变的。今天下载的数据和下周下载的数据可能不一样。我的做法是每次下载数据时记录一个哈希值或者时间戳,放在一个data_version.txt里。这样当模型效果出现波动时,可以快速排查是不是数据变了。
注意:原始数据永远不要直接修改。所有清洗和转换操作都应该生成新的文件,保留原始数据的完整性。这个习惯在出问题的时候能救命。
3.2 划分训练集和测试集的时机与策略
书里有一个很明确的观点:在真正查看数据之前,先划分训练集和测试集。这个顺序很多人会搞反,先做探索性分析,然后再划分。这样做的问题在于,你在探索阶段已经对全体数据有了印象,后续的模型选择会不自觉地受到这个印象的影响,导致数据泄露。
划分策略上,书里用的是分层抽样,分层依据是收入中位数。为什么选这个特征?因为收入中位数和房价的相关性最强,如果随机划分,可能会导致训练集和测试集的收入分布差异较大,从而影响评估的可靠性。
分层抽样的代码实现大致是这样的:
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]这里有一个细节:income_cat是需要先创建的。书里是把收入中位数分成了5个区间,用pd.cut实现的。分几个区间没有绝对标准,但一般5到10个比较合适。太少会导致分层效果不明显,太多会导致某些层样本太少。
我在实际项目中的经验是:如果数据量不大(比如几千条),分层抽样的优势很明显;如果数据量很大(几十万条以上),随机划分和分层抽样的差异会小很多。但不管数据量大小,先划分再探索这个顺序不能变。
3.3 探索性数据分析应该看什么
书里的探索性分析部分做了几件事:计算统计摘要、绘制直方图、查看地理分布、计算相关性。这些操作看起来常规,但每一项都有明确的意图。
统计摘要主要看的是数据的分布形态和异常值。比如total_rooms这个特征,最大值和均值差距很大,说明分布是右偏的。右偏分布对很多模型都不友好,后续可能需要做对数变换。
直方图的作用是直观地看到每个特征的分布。书里特别提到,有些特征是被截断的,比如housing_median_age和median_house_value都有上限。被截断的特征会影响模型的泛化能力,因为模型在训练时看不到超过上限的值,预测时遇到超过上限的输入就会出问题。这个问题在实际项目中很常见,处理方式可以是删除这些样本,或者对目标变量做变换。
地理分布的可视化是这一章比较有特色的部分。书里用散点图把房价按地理位置画出来,颜色深浅代表价格高低。这个图能直观地看出房价和地理位置的关系,也能看出数据采集的边界。我在实际项目中也会做类似的可视化,但通常会加上交互功能,方便查看具体区域的数值。
相关性分析是探索阶段的重点。书里用corr()方法计算了所有特征和房价的相关系数,然后按绝对值排序。这里有一个细节:相关系数只反映线性关系,如果两个特征是非线性相关的,相关系数可能很低。所以书里在看完相关系数后,还画了散点图来确认。比如median_income和房价的散点图显示,两者有明显的线性关系,但也有一些水平线,说明数据存在截断。
实操心得:探索性分析阶段不要急着下结论。我见过很多人看到相关系数低就直接删特征,结果删掉了重要的非线性特征。正确的做法是:相关系数低只是提示你需要进一步查看,而不是直接判死刑。
4. 特征工程与数据预处理的关键操作
4.1 特征组合的设计逻辑
书里在特征工程部分做了一个很有意思的操作:把total_rooms、total_bedrooms、population、households这几个特征组合成了几个新的特征,比如rooms_per_household、bedrooms_per_room、population_per_household。
这个操作的逻辑是:原始特征是总量,而总量往往不如比率有信息量。比如total_rooms多,可能是因为这个区域大,也可能是因为房子多,单独看这个数字说明不了什么。但rooms_per_household就能反映每户人家的房间数,这个信息量就大得多。
我在实际项目中也经常做类似的组合。组合的方式没有固定公式,但有一个原则:组合后的特征应该比原始特征更有解释性。比如在电商场景中,订单数和用户数单独看意义有限,但人均订单数就能反映用户的活跃度。
书里还提到,组合特征后需要重新计算相关性。结果显示,bedrooms_per_room和房价的负相关性很强,比单独的total_bedrooms和total_rooms都要强。这说明组合确实带来了信息增益。
4.2 数据清洗与缺失值处理
书里在数据清洗部分处理了缺失值。total_bedrooms这个特征有部分缺失,书里给了三个选项:删除缺失的样本、删除整个特征、填充缺失值。
书里选择了填充,用的是中位数。为什么用中位数而不是均值?因为中位数对异常值不敏感。如果数据中有极端值,均值会被拉偏,中位数则相对稳定。
填充的方式也有讲究。书里用的是SimpleImputer,这个类的好处是可以把填充逻辑封装起来,后续在测试集上可以直接复用。如果手动填充,很容易在训练集和测试集上用了不同的填充值,导致数据泄露。
我在实际项目中的经验是:缺失值处理没有万能方案,需要根据缺失的比例和原因来决定。如果缺失比例很低(比如小于5%),填充通常没问题;如果缺失比例很高(比如超过30%),可能需要考虑这个特征是否还有保留价值。另外,有时候缺失本身就是一个信息,比如用户没有填写某个字段,可能和用户的行为有关。这种情况下,可以加一个“是否缺失”的指示特征。
4.3 特征缩放与编码的实操要点
书里在预处理部分用了Pipeline和ColumnTransformer,把数值特征的缩放和类别特征的编码串在一起。这个设计在实际项目中非常实用。
数值特征的缩放,书里用的是StandardScaler。这个操作的必要性取决于模型类型。对于线性回归、SVM、神经网络这类对特征尺度敏感的模型,缩放是必须的;对于决策树、随机森林这类基于分裂的模型,缩放不是必须的,但也不会有坏处。
类别特征的编码,书里用的是OneHotEncoder。这个操作把类别特征转换成二进制向量,避免模型把类别之间的顺序关系误认为是数值关系。比如ocean_proximity这个特征有5个类别,如果直接用数字编码,模型可能会认为“NEAR BAY”比“INLAND”大,但实际上它们之间没有大小关系。
ColumnTransformer的好处是可以把不同的预处理逻辑应用到不同的列上,而且可以很方便地嵌入到Pipeline中。这样在交叉验证时,预处理逻辑会自动应用到每个折上,避免数据泄露。
from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer num_pipeline = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) cat_pipeline = Pipeline([ ('encoder', OneHotEncoder(handle_unknown='ignore')) ]) full_pipeline = ColumnTransformer([ ('num', num_pipeline, num_attribs), ('cat', cat_pipeline, cat_attribs) ])这里有一个细节:OneHotEncoder的handle_unknown参数。如果不设置这个参数,当测试集出现训练集中没有的类别时,会报错。设置为ignore后,未知类别会被编码成全零向量,虽然信息丢失了,但至少不会中断流程。
注意:
ColumnTransformer的列选择可以用列名或列索引。用列名更安全,因为列的顺序可能会变。但如果列名有重复,就需要用索引。我一般会在数据加载后先检查列名是否唯一。
5. 模型训练、评估与调优的完整流程
5.1 从简单模型开始的理由
书里在模型训练部分,先试了线性回归,然后试了决策树,最后试了随机森林。这个顺序不是随意的,而是从简单到复杂。
线性回归是最简单的模型,它的好处是训练快、可解释性强。如果线性回归的效果已经能满足需求,那就没必要上复杂模型。书里线性回归的RMSE大约是六万多,这个误差在实际场景中可能偏大,但它提供了一个基准线。
决策树的效果比线性回归好一些,但书里发现决策树有明显的过拟合。训练集上的RMSE很低,但验证集上的RMSE很高。这个现象在实际项目中很常见,决策树如果不加限制,会一直分裂到每个叶子节点只有一个样本,导致过拟合。
随机森林通过集成多个决策树,降低了过拟合的风险。书里用默认参数的随机森林就取得了比决策树好得多的效果。这说明集成方法在很多场景下确实有优势。
我在实际项目中的做法是:先用一个简单模型跑通全流程,确认数据管道没有问题,然后再逐步尝试更复杂的模型。这样做的好处是,如果后面模型效果不好,可以快速定位是数据问题还是模型问题。
5.2 交叉验证的正确使用方式
书里在评估模型时用了交叉验证,具体是cross_val_score,K=10。交叉验证的作用是减少评估的随机性。如果只用一次训练集和验证集的划分,评估结果可能会受到划分方式的影响。交叉验证通过多次划分取平均,得到更稳定的评估结果。
书里特别提到,交叉验证应该配合Pipeline使用。如果先对全体数据做预处理,再交叉验证,会导致数据泄露。因为预处理时用到了验证集的信息,比如缩放时用到了验证集的均值和方差。正确的做法是把预处理嵌入到Pipeline中,这样每次交叉验证时,预处理只在训练折上拟合,然后应用到验证折上。
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)这里有一个细节:scoring参数用的是neg_mean_squared_error,因为sklearn的交叉验证默认是“分数越高越好”,而MSE是越低越好,所以取负值。最后计算RMSE时需要再取负号。
我在实际项目中的经验是:交叉验证的K值一般选5或10。K越大,评估越稳定,但计算成本也越高。如果数据量很大,K=5就够了;如果数据量不大,K=10更合适。另外,如果数据有时间顺序,不能用普通的交叉验证,需要用时间序列交叉验证,否则会用未来数据预测过去。
5.3 网格搜索与随机搜索的取舍
书里在调优部分用了GridSearchCV,对随机森林的几个关键参数做了搜索。网格搜索的逻辑是:给定每个参数的候选值,穷举所有组合,用交叉验证评估每个组合的效果,选最好的。
网格搜索的优点是全面,缺点是计算成本高。如果参数多、候选值多,组合数会爆炸。书里搜索的参数包括n_estimators、max_features、bootstrap等,组合数已经不少了。
实际项目中,如果参数空间很大,我会先用RandomizedSearchCV做粗调,再用GridSearchCV做细调。随机搜索的好处是可以在有限的迭代次数内覆盖更大的参数空间。书里没有展开讲随机搜索,但这是实际工作中很常用的技巧。
from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_distribs = { 'n_estimators': randint(low=1, high=200), 'max_features': randint(low=1, high=8), } rnd_search = RandomizedSearchCV(rf_reg, param_distribs, n_iter=10, cv=5, scoring='neg_mean_squared_error', random_state=42) rnd_search.fit(housing_prepared, housing_labels)这里有一个细节:n_iter是随机搜索的迭代次数。书里没有给具体建议,但我的经验是至少50次,如果参数空间大,可以到100次以上。另外,随机搜索的random_state要固定,否则每次结果不一样,没法复现。
实操心得:调优时不要只盯着一个指标。我见过很多人为了降低RMSE,把模型调得很复杂,结果上线后发现推理速度太慢,或者内存占用太高。调优时应该同时考虑性能指标和工程指标,找到平衡点。
5.4 测试集评估与最终交付
书里在最后用测试集评估了最终模型。这一步看起来简单,但有几个细节需要注意。
首先,测试集只能用一次。如果在测试集上反复评估、调整模型,测试集就失去了“未见过的数据”这个意义。书里在调优阶段用的是验证集,测试集一直留到最后。
其次,测试集评估的结果应该和交叉验证的结果对比。如果差异很大,说明模型可能过拟合了验证集,或者测试集的分布和训练集不一样。书里最终模型的测试集RMSE和交叉验证的RMSE比较接近,说明模型泛化能力还可以。
最后,交付的模型应该是一个完整的Pipeline,包括预处理和模型本身。这样在新数据上预测时,可以直接调用predict(),不需要手动做预处理。
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) final_predictions = final_model.predict(X_test_prepared) final_rmse = np.sqrt(mean_squared_error(y_test, final_predictions))这里有一个细节:full_pipeline.transform()而不是fit_transform()。因为预处理已经在训练集上拟合过了,测试集只需要转换,不需要重新拟合。如果用fit_transform(),会用测试集的统计量重新拟合,导致数据泄露。
6. 常见问题与排查技巧实录
6.1 数据泄露的几种典型场景
数据泄露是机器学习项目中最常见的问题之一,而且往往很隐蔽。我在实际项目中遇到过几种典型场景。
第一种是预处理时用了全体数据。比如在划分训练集和测试集之前,先对全体数据做了标准化。这样测试集的均值和方差信息就泄露到了训练过程中。正确的做法是先划分,再在训练集上拟合预处理器,然后应用到测试集。
第二种是特征工程时用了目标变量。比如构造了一个特征,这个特征的计算用到了目标变量的信息。这种泄露在时间序列项目中特别常见,比如用未来的数据构造特征来预测过去。
第三种是交叉验证时没有用Pipeline。如果先对全体数据做预处理,再交叉验证,每次验证时验证集的信息已经通过预处理泄露到了训练过程中。
注意:数据泄露不一定马上表现出来。有时候模型在验证集上表现很好,但上线后效果很差,这时候就要排查是不是有数据泄露。
6.2 模型过拟合与欠拟合的判断与处理
过拟合和欠拟合是模型训练中的两个极端。判断的方法是看训练集和验证集的性能差异。
如果训练集性能很好,验证集性能很差,说明过拟合。处理方式包括:减少模型复杂度、增加正则化、增加训练数据、使用集成方法。
如果训练集和验证集性能都很差,说明欠拟合。处理方式包括:增加模型复杂度、增加特征、减少正则化。
书里在决策树部分遇到了过拟合,通过随机森林解决了。随机森林通过集成多个决策树,降低了方差,从而减轻了过拟合。
我在实际项目中的经验是:过拟合和欠拟合的界限不是绝对的。有时候模型在训练集上表现好、验证集上表现稍差,但差距在可接受范围内,这不算过拟合。关键是要看验证集的性能是否满足业务需求。
6.3 特征重要性分析的实用技巧
书里在随机森林部分提到了特征重要性。随机森林可以输出每个特征的重要性分数,这个分数反映的是特征在分裂节点时带来的信息增益。
特征重要性分析在实际项目中很有用。一方面可以验证特征工程的效果,如果构造的新特征重要性很高,说明构造得合理;另一方面可以用于特征选择,去掉重要性很低的特征,简化模型。
但特征重要性也有局限性。对于有相关性的特征,随机森林可能会把重要性分散到多个特征上,导致每个特征的重要性都不高。这时候需要结合领域知识来判断。
feature_importances = grid_search.best_estimator_.feature_importances_ sorted(zip(feature_importances, attributes), reverse=True)书里输出的结果显示,median_income的重要性最高,这和相关性分析的结果一致。ocean_proximity的某些类别也有较高的重要性,说明地理位置确实影响房价。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 验证集RMSE远高于训练集 | 过拟合 | 检查模型复杂度、训练数据量 | 简化模型、增加正则化、增加数据 |
| 训练集和验证集RMSE都很高 | 欠拟合 | 检查特征质量、模型复杂度 | 增加特征、增加模型复杂度 |
| 交叉验证结果波动很大 | 数据分布不均 | 检查数据划分方式 | 使用分层抽样、增加K值 |
| 测试集RMSE远高于验证集 | 数据泄露或分布差异 | 检查预处理流程、对比数据分布 | 修正预处理、重新划分数据 |
| 模型预测值超出合理范围 | 目标变量截断 | 检查目标变量分布 | 删除截断样本或做变换 |
| 新数据预测报错 | 类别特征出现新值 | 检查编码器参数 | 设置handle_unknown='ignore' |
6.5 几个我踩过的坑
第一个坑是忽略了random_state。书里的代码都设置了random_state=42,我一开始觉得无所谓,结果每次运行结果都不一样,没法复现。后来养成了习惯,所有涉及随机的地方都设置random_state。
第二个坑是直接修改了原始数据。有一次我在探索阶段对数据做了清洗,直接覆盖了原始文件。后来发现清洗逻辑有问题,想回退却回不去了。从那以后,原始数据永远只读,所有修改都生成新文件。
第三个坑是交叉验证时用了fit_transform。这个坑很隐蔽,因为代码能跑通,结果看起来也正常。但后来对比测试集结果时发现差异很大,排查了很久才发现是预处理时数据泄露了。
第四个坑是忽略了特征缩放的必要性。有一次用SVM做分类,没有做缩放,结果模型效果很差。后来加了StandardScaler,效果立刻提升。这个坑让我意识到,不同模型对数据的要求不一样,不能一套流程走到底。
7. 从第二章延伸出去的几个方向
第二章的内容已经覆盖了一个完整项目的主要环节,但实际工作中还有一些方向可以继续深入。
第一个方向是自动化机器学习。书里手动做了特征工程、模型选择、调优,这些步骤在AutoML工具中都可以自动化。如果项目周期紧,可以考虑用AutoML快速得到基准结果,然后再手动优化。
第二个方向是模型部署。书里没有展开讲部署,但这是实际项目中很重要的一环。模型训练好之后,怎么打包、怎么上线、怎么监控,都是需要解决的问题。我一般会用Flask或FastAPI把模型封装成API,然后用容器化部署。
第三个方向是特征存储。如果多个项目用到相同的特征,可以考虑建一个特征存储,统一管理特征的计算和版本。这样可以避免重复开发,也能保证特征的一致性。
第四个方向是模型监控。模型上线后,数据分布可能会变化,导致模型效果下降。需要定期监控模型的预测分布和实际结果的差异,及时发现和解决问题。
这些方向每一个都可以单独写一篇笔记,但第二章的价值在于,它提供了一个完整的起点。把第二章的流程跑通,后面的扩展就有了基础。我在实际项目中的体会是,很多问题不是出在模型本身,而是出在流程的某个环节。第二章的端到端思路,能帮你建立起对整体流程的感知,这在排查问题时特别有用。