简介:这份PDF教程聚焦深度学习中的回归问题,以波士顿房价预测为完整案例,面向希望用Keras在Python中开展项目实战的机器学习初学者与进阶开发者。内容从任务描述、14项特征含义讲起,逐步演示如何加载数据、使用StandardScaler做尺度调整、构建基于ReLU隐藏层的基准神经网络模型,并结合KerasRegressor、交叉验证与网格搜索进行改进,帮助读者理解回归建模与调优流程。资源为1个PDF文件,大小约366KB,图文配合代码与执行结果,适合边读边练。目前已有1349人学习浏览,是一份兼顾概念讲解与落地操作的案例型教程。学完后可掌握房价预测场景下的数据预处理、模型封装、K折评估和超参数搜索等技能,也能迁移至其他连续值预测任务。
1. 回归问题不玄学:拿 Keras 首先把波士顿房价预测跑通
做深度学习入门,最怕一上来就碰图像分类、目标检测那些重工程化的案例。显卡、CUDA、预训练权重、数据增强这一串名词还没消化完,人已经学不下去了。回归问题反而是最亲民的入口:数据量小、单机 CPU 就能跑、评价指标直白。这本《深度学习-基于Keras的Python项目开发实战》里关于波士顿房价预测的章节,就是这样一个典型案例:用 506 条房屋数据、13 个连续特征,把 Keras 建模、10 折交叉验证、数据标准化、网格搜索调参整个过程串成一条可复现的流水线。适合刚装好 Python 和 Keras、想找个练手项目把回归问题完整跑通的人,也适合想回头看看早期深度学习教材怎么处理小数据集的熟手。读完你至少能回答三个问题:回归网络为什么输出层不加激活函数、预处理为什么必须塞进交叉验证、网格搜索结果为什么不能只看一个数。
2. 波士顿房价数据集与基准模型:13 个输入特征先看清楚
2.1 数据集结构:每行是一条城镇房价画像
波士顿房价数据集统计于 1978 年,506 条样本每条代表波士顿周边某个城镇或区域的房价信息。输入特征共 13 个,目标值是 MEDV,也就是自住房屋房价中位数。数据规模不大,单层全连接网络在 CPU 上几十秒就能训练一轮,这也是它适合当深度学习入门案例的原因——调参损耗小,反馈快。
| 字段 | 说明 | 字段 | 说明 |
|---|---|---|---|
| CRIM | 城镇人均犯罪率 | DIS | 距 5 个就业中心的加权距离 |
| ZN | 住宅用地比例 | RAD | 距高速公路便利指数 |
| INDUS | 非住宅用地比例 | TAX | 每 1 万美元不动产税率 |
| CHAS | 是否临河,虚拟变量 | PTRATIO | 城镇师生比例 |
| NOX | 环保指数,氮氧化物浓度 | B | 城镇黑人比例 |
| RM | 每栋住宅平均房间数 | LSTAT | 低收入人群占比 |
| AGE | 1940 年前建成自住单位比例 | MEDV | 房价中位数,目标值 |
拿到手先注意量纲差异:CRIM 是比例值,RM 是房间数,TAX 是税率绝对值,B 这类比例特征数值会更大。不同量纲会直接干扰神经网络的梯度更新,这就是本书 8.3 节专门做数据预处理的原因。加载数据用 scikit-learn 自带接口,一行代码就能拿到 Bunch 对象,但随机数种子要事先固定,否则后续 K 折划分每次都不同,实验就没有可比性。
from sklearn import datasets import numpy as np seed = 7 np.random.seed(seed) dataset = datasets.load_boston() x = dataset.data Y = dataset.target print(x.shape, Y.shape) # (506, 13) (506,)dataset.data 是 13 列特征矩阵,dataset.target 是房价中位数序列。设定 seed=7 是为了让 KFold 的 shuffle 结果固定,模型每次训练前数据划分一致,后面调参得到的差异才真正来自模型结构,而不是数据顺序。这里给的 np.random.seed 只锁住了 NumPy 的随机流,Keras 后端还有自己的随机源,后面避坑部分会单独说。
什么样的任务算回归问题?价格预测、乘客人数预测、温度预测都是。和多分类任务相比,回归网络的输出层不需要 softmax 把结果压成概率,而是直接输出连续数值;损失函数也从 categorical_crossentropy 换成 mean_squared_error。理解这个区别,再看下面的基准模型代码就不会觉得“少了一层”是笔误。
2.2 基准模型:单隐藏层 MLP 与 KerasRegressor 包装
原书思路是先建一个尽可能简单的模型当基准,之后每次改动都拿它对比。这里的 create_model 把 units_list 设计成列表参数,[13] 表示单隐藏层 13 个神经元,[13, 6] 就表示两层。为什么基准模型偏偏用 13 个神经元?说白了就是取巧的默认值——让每个输入维度都有一个神经元承接,没有理论保证,纯粹是从简起点,网格搜索后面会重新验证。
from keras.models import Sequential from keras.layers import Dense from keras.wrappers.scikit_learn import KerasRegressor from sklearn.model_selection import cross_val_score, KFold from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV def create_model(units_list=[13], optimizer='adam', init='normal'): model = Sequential() units = units_list[0] model.add(Dense(units=units, activation='relu', input_dim=13, kernel_initializer=init)) for units in units_list[1:]: model.add(Dense(units=units, activation='relu', kernel_initializer=init)) model.add(Dense(units=1, kernel_initializer=init)) model.compile(loss='mean_squared_error', optimizer=optimizer) return model第一层 Dense 同时充当输入层,input_dim=13 和特征数对齐;循环体负责追加更多隐藏层;最后 Dense(units=1) 就是输出层,不设激活函数,回归任务直接输出数值。kernel_initializer='normal' 表示用正态分布初始化权重。loss 用 mean_squared_error,优化器默认 adam。参数 init、optimizer、units_list 全部可配置,这是为了后面网格搜索能直接搜索这些维度。
model = KerasRegressor(build_fn=create_model, epochs=200, batch_size=5, verbose=0) kfold = KFold(n_splits=10, shuffle=True, random_state=seed) results = cross_val_score(model, x, Y, cv=kfold) print('Baseline: %.2f (%.2f) MSE' % (results.mean(), results.std())) # Baseline: 22.77 (11.57) MSEKerasRegressor 是 Keras 给 scikit-learn 做的适配器,接收 build_fn 回调函数,这个函数返回编译好的模型。适配之后,模型就能像普通 sklearn 估计器一样被 cross_val_score 和 GridSearchCV 调度,这是整个案例能少写大量样板代码的关键。KFold 把数据打乱切成 10 份,每份轮流做验证集,其余 9 份训练,最终输出 10 次 MSE 的均值和标准差。Baseline 均值 22.77、标准差 11.57,说明不仅误差偏高,折与折之间波动也大,这给后续优化留出了空间,也说明模型连稳定的预测都谈不上。
一个实用细节:verbose=0 让训练不打印进度条,否则 10 折交叉验证会刷掉大量终端输出,几乎没法看结果。epochs=200、batch_size=5 这种配置在单机 CPU 上每折也就几十秒量级,总耗时还在可接受范围内。基准模型的意义就是立住一块“地板砖”,后面标准化、调参、换结构,每一次改动都要拿它做对比锚点。
3. 数据预处理:StandardScaler 与 Pipeline 的正确打开方式
3.1 不同量纲是回归模型的隐形障碍
看数据集字段就能发现,CRIM 是比例值,RM 是房间数,TAX 是税率绝对值,B 这类比例值可能上千。如果不做处理,神经网络反向传播时会被数值大的特征带偏——输入从 0.01 变化到 1,和从 100 变化到 200,对加权求和的贡献完全不同。损失函数在原始尺度下是个狭长的碗,梯度方向始终被大尺度特征主导,收敛速度和最终精度都会受影响。
StandardScaler 做的事情很简单,对每一列减去均值、除以标准差,使每个特征均值 0、方差 1,公式就是 z = (x - μ) / σ。标准化之后所有特征都在同一尺度,权重初始化不会因为某一维度过大而失衡。对波士顿这个案例,效果立竿见影,原书 8.3 节给出的结果从 22.77 降到了 12.33,接近一半的提升。这个收益甚至比后面调网络结构还大,说明预处理在这个数据集上是优先级最高的动作。
顺带说一句,StandardScaler 适合特征分布接近正态的场景,如果数据有严重长尾,可以先做 log 变换再标准化。波士顿房价数据没有特别离谱的离群特征,所以标准做法就够用。MinMaxScaler 也能把数据压到 0~1 区间,但对量纲差异极大的场景,StandardScaler 对均值偏移和尺度波动的适应力更好,这也是原书选它的原因。
3.2 把标准化放进每一折交叉验证,而不是一次性 fit
新手最容易犯的错,是先把全部数据标准化,再切训练集和验证集。这样验证集的信息已经参与计算均值和方差,严格说就是数据泄漏,评估结果会偏乐观。正确的姿势是每一折里只用训练折的数据去 fit StandardScaler,再 transform 训练折和验证折。Pipeline 正是为此设计的。
steps = [] steps.append(('standardize', StandardScaler())) steps.append(('mlp', model)) pipeline = Pipeline(steps) kfold = KFold(n_splits=10, shuffle=True, random_state=seed) results = cross_val_score(pipeline, x, Y, cv=kfold) print('Standardize: %.2f (%.2f) MSE' % (results.mean(), results.std())) # Standardize: 12.33 (6.96) MSEPipeline 把两个步骤串成一条流水线:standardize 在当前折的训练集上 fit,再对当前折做变换;mlp 拿到变换后的数据继续训练。cross_val_score 每折调用 pipeline.fit 时,StandardScaler 只看见训练折的数据,验证折的统计量不会渗进训练过程。这段代码看起来只是把模型包了一层,实际堵住了泄漏漏洞。
对比两种结果更直观:
| 方案 | 平均 MSE | 标准差 |
|---|---|---|
| 原始尺度基准模型 | 22.77 | 11.57 |
| StandardScaler + Pipeline | 12.33 | 6.96 |
误差均值和波动双双下降。需要提醒的是,Pipeline 里 steps 的名字不能乱起,后面网格搜索如果想对特定步骤调参,会用到标准化的步骤名__参数名这种带双下划线的写法,例如standardize__with_mean。虽然原书第三步的网格搜索直接作用在 model 上,但实际项目里把整条 Pipeline 传给 GridSearchCV 才是更通用的做法。
预处理做到位之后,模型评估从 22.77 降到 12.33,接下来才轮到网络结构这个真正属于深度学习的调参环节。
4. GridSearchCV 调参:从网络拓扑到超参数的一轮完整搜索
4.1 把模型函数写成可配置的,搜索才有意义
第 2 章的 create_model 中 units_list 是列表参数,这意味着能同时控制“层数”和“每层神经元数”。想试两层结构 [13, 6],模型会先加 13 神经元层,循环再追加 6 神经元层,最后接 1 单元输出层。optimizer 和 init 也做了参数化,网格搜索本质上就是把 5 个维度的候选值做笛卡尔积,在每组组合上跑交叉验证,看哪组表现最好。
param_grid = {} param_grid['units_list'] = [[20], [13, 6]] param_grid['optimizer'] = ['rmsprop', 'adam'] param_grid['init'] = ['glorot_uniform', 'normal'] param_grid['epochs'] = [100, 200] param_grid['batch_size'] = [5, 20] scaler = StandardScaler() scaler_x = scaler.fit_transform(x) grid = GridSearchCV(estimator=model, param_grid=param_grid) results = grid.fit(scaler_x, Y) print('Best: %f using %s' % (results.best_score_, results.best_params_))先说两个代码层面的细节。estimator=model 这里的 model 是我们前面定义好的 KerasRegressor 实例,务必保证它是未拟合的新实例,否则会在搜索过程中反复 fit 同一个对象,行为不可控。scaler_x 在网格搜索前就对全量数据做了 fit_transform,这其实会带来轻微数据泄漏,原书这里写得比较随意,我在 5.4 节单独说。
param_grid 遍历的是 5 个键的笛卡尔积,2×2×2×2×2,共 32 组超参数组合。每组内部再做 K 折交叉验证,每个模型还要跑 100 或 200 epoch。算一下总训练量就知道为什么原作者说用了云主机 CPU 跑 30 分钟。我自己的习惯是,第一轮先把网格缩到 2 个维度,比如只调 units_list 和 optimizer,确认方向后再放开 batch_size、epochs。一上来就把 5 个维度全部拉满,在本地笔记本上很容易出现“看起来像死机”的长等待。
4.2 最优结果与输出解读:单层 20 神经元胜出
原书跑完后的最优参数组合如下:
# Best: 99.163545 using {'batch_size': 20, 'epochs': 100, # 'init': 'normal', 'optimizer': 'rmsprop', # 'units_list': [20]}这里必须说清楚一个容易误读的点:99.16 这个数不是 MSE。KerasRegressor 继承的是 sklearn 的 RegressorMixin,默认 score 方法返回的是 R² 决定系数,原书打印的 99.16 可以理解为 R² 做了百分比显示,对应约 0.9916,意思是模型解释了约 99% 的方差。而模型编译时的 loss 是 MSE,存储在训练历史里。两个指标口径完全不同,不能拿 99.16 去和 Baseline 的 22.77 直接对比,否则会得出“模型提升了 5 倍”的错误结论。这个坑在第 5 章还会展开。
原书完整输出有三十多行,我摘几组有代表性的组合看一下规律:
| 参数组合(units, optimizer, init, epochs, batch) | mean_test_score | 说明 |
|---|---|---|
| [20], rmsprop, glorot_uniform, 100, 5 | 37.22 | batch 小,明显不稳定 |
| [13,6], rmsprop, glorot_uniform, 100, 5 | 24.52 | 两层结构在小样本上没占便宜 |
| [20], rmsprop, normal, 100, 20 | 99.16 | 最优组,方差控制得好 |
| [13,6], rmsprop, normal, 200, 5 | 31.38 | 增加到 200 epochs 也没救回来 |
从完整结果看,[20] 单隐层普遍比 [13,6] 双层稳定,batch_size=5 的组合标准差动辄 40~93,而 batch_size=20 的组合普遍平稳。506 条样本这种小数据集,两层网络参数量变大,反而更容易过拟合,训练噪声也更大。这个结论符合“小数据优先简单模型”的经验,别急着堆深度。
另一个值得注意的现象是,epochs 从 100 加到 200 在很多组合上并没有明显改善,甚至标准差更大。这说明 100 epoch 对这个规模的数据已经够用,继续训练只是在小样本上来回震荡。拿到最优参数后,可以用 create_model(units_list=[20], optimizer='rmsprop', init='normal') 重建模型,后面第 6 章我会给出完整的终版验证流程。
5. 避坑与排查:波士顿房价案例里最常见的五个翻车点
网格搜索结果看似接近完美,但只有自己动手复现一遍,才会撞上教材没写的环境问题和指标口径问题。下面这几条都是实际踩过或高频出现的问题,按“现象、原因、解决”梳理清楚。
5.1 load_boston 找不到:sklearn 新版已经移除该数据集
现象:很多人按教材敲datasets.load_boston(),环境直接抛 AttributeError,或者只有一行 DeprecationWarning 后数据返回空。原因:波士顿数据集包含 B 字段这类按种族统计的特征,且统计口径存在争议,scikit-learn 在 1.2 版本正式删除了 load_boston。解决:临时降级到scikit-learn==1.0.2可以原样复现教材;更推荐的做法是从 StatLib 下载原始 csv,用 pandas 读进来,以 MEDV 作为 y,其余 13 列作为 x。想换个同风格的数据集练手,可以用fetch_california_housing,同样是回归问题,特征数量相近,API 也是 Bunch 风格,改两行就能跑通。
# sklearn 1.2+ 的替代方案 from sklearn.datasets import fetch_california_housing housing = fetch_california_housing() x = housing.data Y = housing.target5.2 from keras 与 from tensorflow.keras:装对导入路径
现象:from keras.models import Sequential在部分环境直接 ImportError,或者在 pip 装了 Keras 后又和 TensorFlow 内置版本冲突,出现类型不匹配的诡异报错。原因:教材写的是独立 Keras 时代,TensorFlow 2.x 起 Keras 已经内置为 tf.keras,两者 API 大体相同,但模型对象不是同一套类体系。解决:按环境二选一,现代环境统一用 tf.keras:
# 推荐:TensorFlow 2.x from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense from tensorflow.keras.wrappers.scikit_learn import KerasRegressor # 旧版教材原样 from keras.models import Sequential from keras.layers import Dense from keras.wrappers.scikit_learn import KerasRegressor混着导入最常见的报错是 “Model was constructed using shape ... but was called with ...”,本质是两类模型在内存里的类型标识不一致。建议做项目时统一走 tf.keras,环境里不要额外pip install keras,避免把内置版本覆盖掉。
5.3 best_score_ 高达 99.16 却不是 MSE:指标口径必须对齐
现象:第 4 章打印出Best: 99.163545,有人直接拿它和 Baseline 的 22.77 MSE 做比较,觉得模型提升了近 5 倍,兴奋半天后发现对不上账。原因:KerasRegressor 默认走 sklearn 的 RegressorMixin,score 返回的是 R² 决定系数,原书打印的 99.16 更像 R² 做了百分化,而模型 compile 时的 loss 是 MSE。GridSearchCV 的 best_score_ 用的是估计器的 score 方法,两个数本身就不是一个物理量。解决:在 GridSearchCV 里显式指定 scoring,让搜索结果与训练损失口径统一:
grid = GridSearchCV(estimator=model, param_grid=param_grid, scoring='neg_mean_squared_error')sklearn 习惯把损失取负数,“越大越好”,所以搜索结果的负 MSE 越大,真实 MSE 越小。指定 scoring 之后,best_score_ 和模型 history 里的 loss 才能放在同一张图里看趋势。
5.4 在全量数据上先标准化再搜索,同样存在数据泄漏
现象:原书第 4 章先scaler_x = scaler.fit_transform(x)再做 GridSearchCV,按严格标准评估结果会偏乐观。原因:GridSearchCV 内部每一折都会重新切训练集和验证集,而 scaler 已经在全量数据上算过均值和方差,验证折的统计信息渗透进了训练过程。Pipeline 的意义恰恰是在每折内重新 fit scaler。解决:把标准化放进 Pipeline,让 GridSearchCV 作用于整个 Pipeline:
pipeline = Pipeline([('standardize', StandardScaler()), ('mlp', model)]) grid = GridSearchCV(estimator=pipeline, param_grid=param_grid)注意此时 param_grid 里的参数名要写成mlp__units_list、mlp__optimizer这种带步骤名前缀的格式。对小数据集影响不一定大,但要把评估结果当作可信指标来用,这条流程不能省。
5.5 batch_size=5 结果忽好忽坏:随机性与批大小都要管住
现象:原书结果里 batch_size=5 的组合标准差动辄 40~93,同一个配置多次跑差异也大;batch_size=20 则稳定很多。原因:batch 越小,梯度噪声越大,训练过程震荡明显;加上只设了 np.random.seed,Keras 和 TensorFlow 后端的随机源没有被锁定,数据打乱和权重初始化的随机性都没被管住。解决:锁随机种子要锁全套:
import os import random import numpy as np import tensorflow as tf os.environ['PYTHONHASHSEED'] = '0' random.seed(7) np.random.seed(7) tf.random.set_seed(7)在完全可复现的实验里,还要考虑算子级确定性配置。实践上,小数据集优先把 batch_size 提到 20,梯度更稳定,比追求小 batch 的“快速更新”实用得多。
6. 验证与进阶:用留出集和误差分布给最优模型一个交代
网格搜索给出最优参数,但 best_score_ 是在交叉验证折上得到的,最终交付前还得单独切一个留出集,用肉眼可见的方式检查预测质量。我的习惯是固定 20% 样本做测试集,在剩余 80% 上训练,然后同时看 R² 和平均绝对误差 MAE,再对比预测值和真实值。
from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score X_train, X_test, y_train, y_test = train_test_split( scaler_x, Y, test_size=0.2, random_state=seed) best_model = create_model(units_list=[20], optimizer='rmsprop', init='normal') best_model.fit(X_train, y_train, epochs=100, batch_size=20, verbose=0) y_pred = best_model.predict(X_test) print('MSE: %.2f' % mean_squared_error(y_test, y_pred)) print('MAE: %.2f' % mean_absolute_error(y_test, y_pred)) print('R2 : %.4f' % r2_score(y_test, y_pred))MSE 放大了大误差的惩罚,适合评估上限风险;MAE 直接解释为“平均预测偏差多少千美元”,给业务方讲的时候更直观。R² 接近 1 说明模型解释了大部分房价方差。我还会把前 10 条预测值和真实值并排打印,用肉眼扫有没有系统性高估或低估,再画一个残差直方图看拖尾。残差集中在零附近、两侧大致对称,模型才算真正过关。
大致可以预期,经过交叉验证选出的最优参数在留出集上成绩会略差于交叉验证均值,这是正常的,因为网格搜索已经见过这些折的梯度方向。如果你在留出集上成绩反而更好,先检查是不是随机种子固定后测试集恰好简单了,或者数据泄漏的某一步又被带了进来。
拿到结果后我一般还会做两个方向的延伸:第一,把标准化的 Pipeline 和网格搜索合并成一个整体,将来换数据集时只改数据加载部分;第二,把评估指标换成负 MSE 之后重跑一遍搜索,确认最优参数没有因指标口径而漂移。这份 PDF 里整套回归问题流程——从基准模型、预处理到网格调参——都可以直接照搬复现。从那以后,我每次拿到一个回归数据集,都强制自己先跑完 baseline 加标准化,再谈网络结构;任何一个网格搜索结果,也先问一句“这个 score 到底是什么口径”,而不是看到数字漂亮就欢呼。这些习惯都是翻车换来的,希望帮到你。
本文还有配套的精品资源,点击获取