拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Keras+Scikit-Learn回归实战:波士顿房价MSE从22.77降到12.33

Keras+Scikit-Learn回归实战:波士顿房价MSE从22.77降到12.33

简介:基于 Keras 的 Python 项目实战教程,聚焦波士顿房价预测这一经典回归问题,适合希望掌握深度学习回归建模的开发者或数据分析初学者。PDF 完整呈现了从波士顿房价数据集(含 14 个特征,目标为房屋中位价)读取、特征标准化到构建单层全连接神经网络的完整代码流程,模型采用线性整流激活函数、自适应矩估计优化器与均方误差损失函数;同时演示了 Keras 的回归器封装配合 K 折交叉验证与网格搜索的超参数调优方法,并讨论了数据尺度调整的必要性与降低误差的优化思路。资源为单个 PDF 文件,大小约 366KB,内容紧凑、步骤清晰,包含问题描述、基准模型、数据预处理、模型调优等完整实践链,适合作为回归问题的入门级实战案例;读者可参照代码流程搭建自己的回归模型,并将交叉验证与网格搜索方法迁移到其他数据集。该资源已有 1348 人学习浏览,实用价值较高。

1. 波士顿房价回归实战:用 Keras 把 MSE 从 22.77 压到 12.33

波士顿房价(Boston House Price)数据集是深度学习入门者最适合拿来练回归任务的经典样本:506 条数据、13 个输入特征,用 Keras 在 CPU 上就能迭代完。这份基于 Python 的实战 PDF 按“搭基准模型 → 数据标准化 → 网格搜索调参”的顺序推进,每一步都有量化收益:基准模型 10 折交叉验证的 MSE 是 22.77,标准化后立刻降到 12.33。它适合刚学会分类、想搞明白回归问题输出层与损失函数设计的读者,也适合想把 Keras 和 Scikit-Learn 串成完整评测管线的工程师。你会在同一个项目里看到 KerasRegressor、KFold、Pipeline、GridSearchCV 的协作,以及几个不看代码很难发现的坑。

2. 环境与建模框架:把 Keras 模型封装进 Scikit-Learn 评估管线

2.1 为什么让 Keras 让渡控制权给 Scikit-Learn

Keras 的优势是快速搭网络:定义层、编译、fit、evaluate,几乎一轮对话就能跑通。但交叉验证、超参数搜索这类“实验流程管理”不是它的强项。Scikit-Learn 的 cross_val_score、KFold、GridSearchCV 已经非常成熟,缺的只是一个能把 Keras 模型包装成标准估计器接口的适配层。KerasRegressor 就是这个适配层。

实际项目里,我把模型构建逻辑放进一个返回编译后模型的函数,KerasRegressor 接收这个函数,然后模型就拥有了 fit、predict、score 方法,可以直接丢给 Scikit-Learn。好处是:10 折交叉验证时,每一折都会调用一次模型构建函数,保证每折都在独立权重上训练,而不是把上一折的模型参数接着训练。对回归问题来说,避免数据在折与折之间“串味”尤其重要。

# 原 PDF 的导入方式(Keras 2.x 时代) # from keras.wrappers.scikit_learn import KerasRegressor # 当前环境推荐:TensorFlow 同样是 Keras 后端,包装器走 scikeras from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense from scikeras.wrappers import KerasRegressor

这里说明一下导入差异。PDF 写作时 Keras 还是独立安装的包,keras.wrappers.scikit_learn是官方提供的兼容层。现在 Keras 已经深度集成进 TensorFlow,而且 Keras 3 把 wrappers 模块移走了,所以直接用scikeras.wrappers.KerasRegressor是更稳的选择。调用方式基本不变:一个 KerasRegressor 实例包住模型构建函数,epochs、batch_size 这些训练参数可以直接在实例化时传入。

2.2 依赖版本怎么锁:一版能跑通原 PDF 项目代码的环境

很多照着 PDF 复现卡住的人,问题不在模型代码,而在环境。load_boston() 在较新的 Scikit-Learn 里被移除,keras.wrappers 在新版里被删,numpy 版本和 TensorFlow 版本互相踩。我一般会这样锁环境:

pip install numpy==1.23.5 scikit-learn==1.1.3 pip install tensorflow==2.11.0 pip install scikeras==0.11.0

参数说明:numpy 锁 1.23.5 是考虑到旧模型代码里 np.random.seed 的行为和 TensorFlow 对 numpy 接口的兼容;scikit-learn 锁 1.1.3 是因为这是最后一个还能直接调 datasets.load_boston() 的版本;scikeras 0.11.0 适配 TensorFlow 2.11 这一代 Keras。如果你想用更新的 TensorFlow,就要把 load_boston 换成从本地 CSV 读取,并在 KerasRegressor 的参数名上做迁移,后面避坑章节会细说。

建议用 venv 或 conda 单独建一个环境,别和日常项目的全局环境混在一起。TensorFlow 对依赖版本非常敏感,同一个项目半个月后回来跑,也可能因为某个传递依赖升级而崩。环境锁定文件就是你的后悔药。

验证环境是否就绪,可以先跑一段最小导入:

import tensorflow as tf from scikeras.wrappers import KerasRegressor print(tf.__version__) # 确认 TensorFlow 版本 print('KerasRegressor ready') # 导入成功说明包装器可用

如果输出正常,再继续后面的模型构建。这一步能提前暴露 90% 的环境问题,不用等模型代码跑崩了才回头查。

2.3 KerasRegressor 运行时行为:交叉验证里每一折发生了什么

用 KerasRegressor 配合 cross_val_score,执行顺序和手写训练循环不同。Scikit-Learn 首先按 KFold 把数据切成训练折和验证折,然后把训练折交给 KerasRegressor 的 fit()。KerasRegressor 内部调用 build_fn 指向的函数重新建一个模型,编译后用你传入的 epochs 和 batch_size 训练。验证折交给 score() 或 predict() 来评估。

这里有个关键细节:如果你在模型构建函数里没有显式重置随机种子,模型权重初始化仍然有随机性。PDF 里在建模型前设置了 np.random.seed(7),这种做法在纯 NumPy 时代有效,但在 TensorFlow 后端下只能影响一部分路径,这部分多少有点玄学。更稳妥的做法是在构建函数内部也固定种子,或者接受结果的一定波动。后面网格搜索输出里那些很大的标准差,一部分就来自这个随机性。

另一个要留意的点是 verbose 参数。训练多组超参数组合时,如果保持默认 verbose=1,终端会被训练进度条刷屏,严重影响调试体验。实际调参时我习惯传 verbose=0,只看最终评估结果;单独调试单模型时再开 verbose=1 观察训练过程。

3. 基准模型搭建:单隐藏层 13 神经元与 10 折交叉验证的 22.77 起点

3.1 波士顿数据集里 13 个输入特征与目标值的关系

PDF 里说的 14 个特征,是把目标列 MEDV 也算进去了。实际给到模型的输入是 13 个特征,MEDV 是我们要预测的自住房屋房价中位数。这 13 个特征混合了比例、指数、距离、税率,量纲差异非常大。

特征含义量纲特点
CRIM城镇人均犯罪率取值从 0.006 到 88.97,偏度极大
ZN住宅用地占比0 到 100
INDUS非住宅用地占比0.46 到 27.74
CHAS是否临河0/1 虚拟变量
NOX一氧化氮浓度0.385 到 0.871
RM平均房间数3.56 到 8.78
AGE1940 年前建成自住单位占比2.9 到 100
DIS到就业中心加权距离1.13 到 12.13
RAD到高速公路便利指数1 到 24
TAX每 1 万美元不动产税率187 到 711
PTRATIO师生比12.6 到 22
B城镇中黑人比例(1978 年统计口径,仅教学参考)0.32 到 396.9
LSTAT低收入人群占比1.73 到 37.97

放在同一行看,TAX 和 B 的范围是 RM、NOX 的几十倍。如果一个模型的权重初始化时对每一列一视同仁,大数值列会天然在梯度里占据更大比重。这就是后面非做标准化不可的直接原因。

还要补充一句:这个数据集是 1978 年统计的,特征口径带有明显时代背景,比如 B 的统计方式在今天看来并不合适。教学演示它非常有价值,但别直接把这份数据用于当前社会语境下的真实房价预测产品。

3.2 构建 create_model 函数:为什么输出层不加激活函数

按 PDF 的做法,模型构建函数被设计成可配置的。units_list 接收一个列表,列表长度决定隐藏层层数,每个元素决定该层神经元数量。这样做的好处是,后面做网格搜索时,只需在参数网格里换列表内容,就能在“单层 20 个神经元”和“双层 13-6 结构”之间切换。

import numpy as np from sklearn import datasets from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense def create_model(units_list=[13], optimizer='adam', init='normal'): # 第一个隐藏层:input_dim=13,对应波士顿数据的 13 个输入特征 model = Sequential() units = units_list[0] model.add(Dense(units=units, activation='relu', input_dim=13, kernel_initializer=init)) # units_list 里如果还有更多元素,继续追加隐藏层 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

几个参数值得展开。input_dim=13 明确告诉模型输入维度,这是第一层 Dense 必需的;kernel_initializer 默认值是 'normal',控制权重初始分布,后面网格搜索会把它和 glorot_uniform 做对比。输出层 units=1 且不写 activation,这是分类和回归任务最关键的区别:分类输出层用 softmax 得到概率分布,回归输出层需要的是原始数值,任何激活函数都会把预测值压缩到某个区间,反而限制表达能力。

损失函数用 mean_squared_error,优化器是 adam。MSE 对预测值和真实值的平方差求平均,值越小越好。传统机器学习在波士顿房价上的 MSE 一般能到 14 左右,PDF 给神经网络定的目标线是 10 左右。这个数字后面会反复出现,作为改进的参照系。

3.3 用 KerasRegressor 和 KFold 拿到基准结果

模型函数定义好后,封装成 KerasRegressor,再交给 cross_val_score。固定随机种子是为了让 KFold 切分可复现,至少数据划分这一层不会每次跑都不一样。

# 导入数据并固定随机种子 dataset = datasets.load_boston() x = dataset.data Y = dataset.target seed = 7 np.random.seed(seed) # 封装 Keras 模型,epochs=200,batch_size=5 model = KerasRegressor(model=create_model, epochs=200, batch_size=5, verbose=0) # 10 折交叉验证评估 from sklearn.model_selection import KFold, cross_val_score 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()))

说明:这里用 KerasRegressor(model=create_model) 是当前 scikeras 的写法;原 PDF 里是 build_fn=create_model,语义一样。KFold 的 n_splits=10 把 506 条数据切成 10 份,每份大约 50 条样本作为验证集,其余 9 份做训练。shuffle=True 打散顺序,random_state=seed 保证打散方式可复现。

执行结果 Baseline: 22.77 (11.57)。这个 11.57 的标准差非常大,意味着模型在不同数据折上的表现差异明显。主要原因有两个:样本量本身只有 506 条,单折验证集太小;更重要的是输入特征未标准化,网络训练不稳定。22.77 就是我后续所有改进的基准线,接下来的每一步都要和它比。

4. 数据标准化与 Pipeline:为什么 22.77 能先降到 12.33

4.1 量纲悬殊如何拖累神经网络的梯度更新

回到 3.1 那张表格。TAX 的取值范围是 187 到 711,RM 只有 3.56 到 8.78。Dense 层的计算是输入乘权重加偏置,反向传播时梯度大小和输入值正相关。输入数值大的特征列,梯度贡献也大,权重更新会被这些列主导,RM、NOX 这类小数值特征的信息被压在背景里。

还有一个容易忽略的点:ReLU 激活函数对输入尺度没有天然的归一化能力。输入范围不统一时,ReLU 在某些神经元上一旦进入负区间就会一直输出 0,梯度也跟着断开,这就是神经元死亡的前兆。标准化之后,数据集中在 0 附近且方差一致,ReLU 的梯度能更稳定地回流。

标准化就是把每一列变成均值为 0、方差为 1 的分布。处理后的特征不再有“谁数值大谁说了算”的问题,每个维度在梯度里的地位平等。这也是为什么 PDF 在构建基准模型之后,第二步就是数据预处理。

4.2 StandardScaler 的作用与 Pipeline 的封装方式

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() x_scaled = scaler.fit_transform(x) print('标准化后各列均值(应接近 0):', x_scaled.mean(axis=0).round(3)) print('标准化后各列标准差(应接近 1):', x_scaled.std(axis=0).round(3))

fit_transform 先计算每一列的均值和标准差,再把数据转换成 (x - mean) / std。这样处理后,数值不再带原始单位。注意 fit_transform 只能用一次,后续对验证集的转换必须沿用训练集算出的均值和标准差,不能重新计算,否则会把验证集信息泄漏进模型评估过程。

PDF 的做法是把它放进 Pipeline,让交叉验证每一折内部自动完成“先标准化、再训练”:

from sklearn.pipeline import 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()))

Pipeline 的核心价值在于顺序封装。cross_val_score 切好训练折和验证折后,pipeline 先只对训练折执行 fit_transform,再用同一组标准化的参数去 transform 验证折。这样验证折完全没参与 scaler 的参数计算,评分才可信。如果手动先对整个 x 做 fit_transform 再做交叉验证,scaler 偷看了所有数据,测试分数会偏乐观,这也是后面避坑章节要展开的点。

这里还能带出一个使用习惯:steps 列表里每个元素是(名字,估计器对象)的元组,名字可以随便起,但建议起有意义的。后面要检查 pipeline 中间结果时,pipeline.named_steps['standardize'] 一目了然。

4.3 标准化前后的对比:预处理比调参更容易见效

代码跑出来的结果非常直观。

方案10 折交叉验证 MSE标准差
数据未标准化22.7711.57
数据标准化后12.336.96

两组数字放在一起,结论很明显:只加了一个 StandardScaler,MSE 均值从 22.77 降到 12.33,标准差从 11.57 收敛到 6.96。这个提升幅度比后面折腾网络结构和超参数带来的变化还大。所以遇到回归模型结果不理想,先检查特征尺度,再谈调参,顺序不能反。

标准差从 11.57 收敛到 6.96,这个信号比均值变化更值得注意。它说明模型的性能从“某些折很差、某些折还行”变得更加稳定,对样本划分不那么敏感。回归模型在测试阶段真正怕的不是均值稍微偏高,而是方差大导致的不可控。所以标准化这一步带来的不只是精度提升,还有稳定性的提升。

标准化后的 12.33 已经接近传统机器学习 14 左右的水平,原计划里的“10 左右”目标线,接下来要靠调参逼近。

5. 网格搜索调参与避坑:四组超参数、30 分钟训练与四个坑

5.1 参数网格设计:从单层 20 到双层 13-6

代码中 create_model 的可配置性在这里发挥作用。units_list 决定网络拓扑,optimizer 决定优化器,init 决定权重初始化方式。再加上训练过程的 epochs 和 batch_size,一共五个超参数。参数网格如下:

from sklearn.model_selection import GridSearchCV 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, verbose=0) results = grid.fit(scaler_x, Y) print('Best: %f using %s' % (results.best_score_, results.best_params_))

组合数算一下:拓扑 2 种乘优化器 2 种乘初始化 2 种乘 epochs 2 种乘 batch_size 2 种,总共 32 组。GridSearchCV 默认做 5 折交叉验证,也就是 32 乘 5 等于 160 次模型训练。每次训练最长 200 个 epoch,这在 CPU 上就是半小时起步的量级,PDF 里提到用阿里云 CPU 跑了约 30 分钟,是完全正常的时间开销。

init 的两个候选值得单独说。glorot_uniform 按每层输入输出维度计算一个均匀分布区间,初始化权重时从这个区间里采样,是 ReLU 时代的默认选择之一;normal 则是从高斯分布里采权值。在网络较深时,两者差异会被放大,但对波士顿这种 13 输入的小网络,影响有限。网格搜索把它放进来,更多是为了验证“初始化方式是否值得在意”这个命题。

这组代码有一个数据泄漏隐患:scaler_x 是在网格搜索前对全量数据做的 fit_transform。更严格的写法是把 StandardScaler 也放进 Pipeline,让每一折独立计算标准化参数。原 PDF 为了简洁直接缩放在前,暴露的问题是评分会略偏乐观。

5.2 结果怎么读:Best 分数不是 MSE,而是估计器自带的 score

执行完网格搜索,终端输出的内容很容易让人误读。例如:

Best: 99.163545 using {'batch_size': 20, 'epochs': 100, 'init': 'normal', 'optimizer': 'rmsprop', 'units_list': [20]} 37.223335 (21.225069) with: {'batch_size': 5, 'epochs': 100, 'init': 'glorot_uniform', 'optimizer': 'rmsprop', 'units_list': [20]} 24.520587 (16.924204) with: {'batch_size': 5, 'epochs': 100, 'init': 'glorot_uniform', 'optimizer': 'rmsprop', 'units_list': [13, 6]}

如果直接把 99.163545 当成 MSE,会以为模型错到离谱。实际情况是,GridSearchCV 在不传 scoring 参数时,默认使用估计器自带的 score 方法。KerasRegressor 的 score 在多数版本下返回的是决定系数,也就是 R²。99.16 这个值应该理解成约 0.9916 的 R²,而不是 MSE。前面的 37.22、24.52 同理,它们是 R² 偏低甚至为负的试验组合。要统一评分口径,显式指定 scoring:

grid = GridSearchCV(estimator=model, param_grid=param_grid, scoring='neg_mean_squared_error') results = grid.fit(scaler_x, Y)

scikit-learn 的评分规则是“越大越好”,所以 MSE 要被取负号,叫 neg_mean_squared_error。取回结果时再取负,得到的就是正常意义的 MSE。这个细节不处理好,网格搜索结果和前面基准模型的 MSE 完全没法对比。

结果速览能直观看到参数组合的影响:

参数组合(节选)mean_test_score说明
units=[20], rmsprop, glorot_uniform, epochs=100, batch_size=537.22R² 偏低,等效 MSE 较差
units=[13,6], adam, normal, epochs=100, batch_size=535.45双层结构没有带来优势
units=[20], rmsprop, normal, epochs=100, batch_size=2099.16最优组合,R² 接近 0.99

同一组 units 和优化器下,batch_size 和 init 的变化直接拉开差距。这也是为什么网格搜索虽然耗时,但在小数据集上值得跑一次。

5.3【避坑】复现这个项目我踩过的四个坑

坑一:把 best_score_ 当成 MSE,整份结果全读歪

现象:看到 Best: 99.163545,第一反应是“这损失怎么这么大”,对照标准化的 12.33 完全对不上。 原因:GridSearchCV 默认用的是 KerasRegressor.score,即决定系数 R²,不是训练时的 MSE 损失。 解决:在 GridSearchCV 里显式传 scoring='neg_mean_squared_error',让搜索结果和基准模型用同一个指标。

坑二:新版 scikit-learn 直接报 load_boston 不存在

现象:datasets.load_boston() 抛 AttributeError,找不到这个属性。 原因:scikit-learn 1.2 起移除了波士顿房价数据集,官方不再维护该数据。 解决:环境锁定 scikit-learn==1.1.3 复现原 PDF;想用新版本就从本地 CSV 读数据,自行拆分特征列和目标列。

坑三:Keras 导入和包装类的 API 迁移

现象:按 PDF 写 from keras.wrappers.scikit_learn import KerasRegressor,直接 ModuleNotFoundError。 原因:Keras 3 移除了这个模块,TensorFlow 2.16 之后的 wrappers 也逐步废弃。 解决:改用 pip install scikeras 安装,导入路径换成 from scikeras.wrappers import KerasRegressor,并把构造函数的 build_fn 参数名改成 model。create_model 的内部代码基本不用动;如果参数透传不生效,改用 model__ 前缀传参。

坑四:训练时长失控,不知道要等多久

现象:参数一多就开跑,结果半小时只跑了一小半,进度信息还被 verbose=1 刷屏。 原因:32 组超参数乘 5 折交叉验证,每组最多 200 epochs,batch_size=5 时单 epoch 轮次虽快但总轮次多。 解决:先建一个 5 epochs 的迷你版参数网格试跑,估算单次训练时间再乘总次数;正式跑时把 verbose=0,终端只保留最后一行结果。想让训练稳定收敛,也可以在模型构建函数里加入 EarlyStopping 回调,按验证损失自动提前终止。

四个坑基本覆盖了从环境安装到结果解读的整个流程。接下来用最优参数重建模型就是水到渠成的事。

6. 用最优参数重建最终模型:一份可直接复现的 Pipeline 脚本

网格搜索锁定的最优参数是单隐藏层 20 个神经元、rmsprop 优化器、normal 初始化、epochs=100、batch_size=20。这一步要做的是把参数写回模型,用 Pipeline 完整地串起标准化和训练,并验证它的 10 折交叉验证表现。

# 最终模型:参数来自 GridSearchCV 的 best_params_ final_model = KerasRegressor(model=create_model, units_list=[20], optimizer='rmsprop', init='normal', epochs=100, batch_size=20, verbose=0) # 标准化 + 模型,封装成一条完整流水线 steps = [('standardize', StandardScaler()), ('mlp', final_model)] pipeline = Pipeline(steps) # 用同一套 KFold 设置做 10 折评估 kfold = KFold(n_splits=10, shuffle=True, random_state=seed) scores = cross_val_score(pipeline, x, Y, cv=kfold, scoring='neg_mean_squared_error') print('最终 Pipeline MSE: %.2f (%.2f)' % (-scores.mean(), scores.std()))

注意第 5 章提过的评分口径,这里 cross_val_score 显式传了 scoring='neg_mean_squared_error',取负号得到的就是和基准模型同口径的 MSE。这样最终成绩才能和 22.77、12.33 直接对比。我自己的习惯是再把模型拟合并抽查前几条预测,确认输出量级在合理范围:

# 全量数据拟合一次,用于查看预测结果形态 pipeline.fit(x, Y) preds = pipeline.predict(x[:5]) for i, (p, t) in enumerate(zip(preds, Y[:5])): print('样本 %d:预测值 %.2f,真实值 %.2f' % (i, p, t))

这段只是演示模型能输出连续房价数值,真正的泛化能力以 10 折交叉验证为准。如果你要部署这个模型,记得把 pipeline 整体保存下来,而不是只保存模型权重。因为推理时还得用同一个 StandardScaler 的均值和标准差去转换输入,Pipeline 恰好把这件事和模型绑在一起。

这套流程沉淀下来的方法论很朴素:先固定随机种子和评估方案拿到基准,再做数据标准化,最后才用网格搜索调网络结构。从那以后,我每次跑回归任务都会强制走一遍固定流程:先检查特征值域,再确认 StandardScaler 是否放进 Pipeline,最后在 GridSearchCV 里显式写明 scoring。光是最后一条,就避免过不止一次把 R² 当成 MSE 的笑话。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表