CNN-RNN 混合回归预测及 Python 实现:从 13 个特征到一个房价(含 RNN、LSTM、GRU、BiLSTM、BiGRU)
如果给你一套房子的 13 个具体特征,比如房间数、周边环境、税率、离市中心的距离等,你能估算出这套房子值多少钱吗?在这个任务中,输入是 13 个不同维度的信息,而输出只有一个连续变化的数值。因为我们要预测的是一个具体数值,而不是“高价/低价”这样的类别标签,所以这是一个标准的回归预测问题。
为了完成这个映射,我们将使用神经网络。但有趣的是,一提到 CNN(卷积神经网络),大家通常会想到图像处理;一提到 RNN(循环神经网络),大家往往会想到文本或时间序列。
那么,把它们俩拼在一起,为什么也能用来预测房价呢?
其实,回归任务只规定了最终要输出一个连续数值,并没有限制中间必须使用哪种网络结构。CNN-RNN 的分工很清楚:CNN 负责“找局部”,RNN 负责“看前后”,最后的回归层负责“给估价”。
具体来说,CNN 可以先用一个小窗口,从相邻位置中提取局部组合信息;池化层再把这些局部响应做一次压缩;接着,RNN 把压缩后的局部特征按顺序读一遍,整理成一份对这套房子的整体认知;最后,全连接层将这份整体认知压缩成一个具体的房价数值。
这篇文章不堆砌复杂公式。我们就跟随一套真实的房屋样本,一步步看看这 13 个输入特征,是如何在 Python 程序中层层转换,最终变成一个预测房价的。
一、认识数据:506 套房子的全貌
在搭建网络之前,我们得先看看数据长什么样。
本项目使用的是经典的波士顿房价数据集。Python 程序从 housing.txt 文件中读取了 506 条数据。每一行代表一套房子,前 13 列是房屋的特征参数,第 14 列是它真实的房价目标值:
data = np.loadtxt('housing.txt')X = data[:, :13]Y = data[:, 13]此时,输入数据 X 的形状是 506×13,意思是 506 套房屋,每套房屋有 13 个输入特征。目标数据 Y 的形状是 506,也就是每套房屋对应一个真实房价。
为了让模型能够输出我们想要的单一连续数值,我们在网络末端设置一个单值输出节点。这就确立了本任务的核心身份:多输入、单输出回归。
这里顺手把三个容易混在一起的概念拆开:
回归说的是任务类型:最后输出一个连续数值。
有序一维输入说的是数据结构:一行数据里的 13 个位置会被程序当作从左到右排列的一段序列。
时间序列预测说的是应用场景:如果这 13 个位置代表过去 13 个时刻,用它们预测下一时刻,那才是时间序列预测。
本文的房价数据本质上是静态表格数据,不是严格的时间序列。它适合演示完整代码流程,但在真实项目中,CNN-RNN 更适合那些列顺序确实有意义的数据,比如滑动时间窗口、光谱波段、传感器排列位置等。这一点后面还会再展开。
二、数据准备:划分与归一化
在 13 个原始特征真正送入网络之前,我们还需要做两次关键的数据整理。
第一次整理:划分数据集。 拿到 506 条数据后,程序首先固定随机种子 42,然后将样本拆分为训练集、验证集和测试集。在当前参数下,数据被稳定地划分成:
- 验证集(60 条):在训练过程中充当“模拟考”,检查模型有没有走偏或过拟合。
- 测试集(102 条):绝对保密,直到模型训练完毕后,才用来做最终评估。
第二次整理:归一化处理。 13 个特征的单位和数值范围差异很大,有的特征可能只有零点几,有的可能达到几百。如果直接放在一起,数值大的列容易在训练中抢走过多注意力。Python 版使用 MinMaxScaler 将输入和输出都缩放到相近范围:
scalerX = MinMaxScaler((0, 1)).fit(X_train_raw)X_train = scalerX.transform(X_train_raw)X_val = scalerX.transform(X_val_raw)X_test = scalerX.transform(X_test_raw)这里最重要的细节是:缩放规则只能用训练集计算。验证集和测试集只能套用训练集已经确定的规则。否则,测试集的信息会提前泄露进训练流程,最后的成绩就不再公正。
处理完成后,我们取出其中一套房屋来看。此刻它仍然可以理解成 1×13:1 个输入通道,沿水平方向排着 13 个位置的特征值。
准备工作就绪,网络的第一道工序——CNN 开始介入。
三、CNN 出场:从 13 个特征中寻找局部组合
先看这套混合网络的整体数据旅程:
数据从左侧进入。CNN 首先登场,它的任务是“局部阅读”。
关于 CNN 的基本原理,我在之前的文章中有过详细讲解,大家可以在专栏里找一找。这里我们只抓住和本项目最相关的一点:CNN 并不是只能处理图片,它也可以处理一维序列。只要一行数据中的相邻位置有意义,卷积窗口就可以在这行数据上滑动。
在 Python 代码中,我们设置卷积核宽度为 3。你可以把卷积核想象成一把短尺,一次只能盖住 3 个相邻位置。它先看第 1、2、3 列,计算出一个局部响应;然后向右移动一格,看第 2、3、4 列,依此类推,直到滑动到末尾。
代码里,这部分由 Conv1d 完成:
self.conv = nn.Conv1d( in_channels=1, out_channels=16, kernel_size=3, padding=1)in_channels=1 对应单条输入只有一行数。kernel_size=3 表示每次观察 3 个相邻位置。out_channels=16 表示同时训练 16 把不同的短尺。为什么要用 16 把?因为不同通道在训练后,会负责捕捉不同类型的局部组合模式。
通过 padding=1,程序会在左右两端各补一个位置,让卷积前后的长度保持一致。所以,当一行 13 个原始特征穿过 CNN 后,长度仍然是 13,但通道数从 1 变成 16。
此时,单条样本的数据形状从 1×13 变成了 16×13。
注意,CNN 到这里还没有预测房价。它只是把 13 个原始数字,翻译成了 16 组更丰富的局部响应。接下来,程序要对这些局部响应做一次筛选。
四、池化筛选:为什么 Python 版会从 13 压到 6
卷积后面紧跟着 ReLU 和最大池化。
ReLU 的动作很简单:负数响应直接归零,只保留正向响应。你可以把它理解成一次“有用信号筛选”:某个局部组合如果给出了负向响应,暂时就不让它继续影响后面。
最大池化负责进一步压缩。Python 版当前代码中的池化窗口为 2:
self.pool = nn.MaxPool1d(2)它会把相邻两个响应分成一组,只留下较大的那个。第 1、2 位选一个,第 3、4 位再选一个,依次向后。13 个位置可以组成 6 个完整小组,最后落单的第 13 位不会进入输出,因此长度从 13 变成 6。
这一步是 Python 版和 MATLAB 版最容易混淆的地方。本文按照 Python 源码来讲:MaxPool1d(2) 默认步长也是 2,所以池化后长度从 13 变成 6。经过 16 个通道分别池化后,单条样本的形状从 16×13 变成 16×6。
池化窗口调大,序列会压得更短,计算更省,但也可能丢掉细节;窗口小一些,会保留更多位置,同时把更多工作留给后面的循环网络。当原始序列本来就很短时,池化过猛尤其要谨慎。
现在数据已经压缩,却还不能直接交给 LSTM。此刻的含义是“16 个通道,每个通道有 6 个位置”;而 LSTM 更希望看到的是“6 个连续位置,每个位置有 16 项局部摘要”。数据本身没有变,只是阅读方向要换一下:
于是,单条样本从 16×6 整理成 6×16。这一步不是学出了新知识,而是把同一叠资料重新摆放,让 LSTM 知道应该沿哪一维往后读。
局部特征提取和筛选完毕,接下来就该 RNN 上场汇总了。
五、LSTM 接力:将 6 份局部摘要整理成全局记忆
前面我们一直说 RNN 负责汇总前后信息。在本文的默认演示中,Python 程序使用的是 RNN 家族中很常用的一种变体:LSTM(长短期记忆网络)。
CNN 和池化交给 LSTM 的是 6×16 的数据:6 个位置,每个位置有 16 个局部摘要。LSTM 的工作方式是“按顺序阅读”。
如图所示,LSTM 从第 1 个位置开始读,一直读到第 6 个位置。在读取每一个位置时,它都会做两件事:
结合这两者,LSTM 会决定哪些信息该保留,哪些信息可以淡化,然后把更新后的记忆继续向后传。
代码中,这部分设置如下:
self.rnn = nn.LSTM( input_size=16, hidden_size=32, num_layers=1, batch_first=True)input_size=16 表示每个位置都有 16 项局部摘要。hidden_size=32 表示内部记忆用 32 个数字来表达。程序只取 LSTM 读完最后一个位置后的输出,再交给全连接层:
returnself.fc(out[:, -1, :])这 32 个数字,就是网络对这套房子提取出的高度浓缩的整体表示。
六、回归输出:从 32 个内部记忆到一个房价
现在,网络手里捏着 32 个浓缩特征,但我们真正需要的是一个具体的房价。全连接层负责最后这一跃:
self.fc = nn.Linear(32, 1)nn.Linear(32, 1) 会把 32 个记忆值加权组合成唯一的一个数值。由于目标房价在训练前也做过归一化,此时网络吐出的也是一个归一化后的值。程序最后会使用训练集上保存的 scalerY 进行反归一化,把预测结果还原回真实房价标尺。
回顾一下,在 Python 默认 LSTM 设置下,一套房屋的数据尺寸是如何一步步演变的:
1×13 个归一化特征 ↓ (CNN 局部提取)16×13 个卷积响应 ↓ (最大池化筛选)16×6 个筛选响应 ↓ (维度调整)6×16:6 个位置,每个位置 16 项摘要 ↓ (LSTM 顺序阅读并汇总)32 个全局记忆状态 ↓ (全连接层压缩)1 个预测房价这就是 Python 版 CNN-RNN 混合结构能够完成回归预测的底层逻辑。
七、严谨的考官:为什么测试集必须保密?
模型第一次给出的房价通常不准。程序会把预测值和真实值比较,用均方误差衡量差距。误差越大,惩罚越重。
接着,误差信息会从输出端反向传回去:全连接层调整怎样组合 32 个记忆值,LSTM 调整怎样保存前后信息,CNN 调整 16 把局部短尺的参数。Adam 优化器每次只修改一点,然后用下一批数据继续练习。
但是,模型训练得越久,不一定越好。它可能越来越熟悉训练数据,甚至把一些偶然噪声也记下来,这就是过拟合。
为了防止这种情况,验证集起到了监督作用。项目设置了 earlyStoppingPatience=25。如果验证集损失连续 25 轮没有改善,训练就会提前停止,并恢复验证集表现最好的那一版模型。
而测试集全程都被锁住。它不参与日常训练,不参与调参,也不参与早停判断。只有当模型完全定型后,测试集才解锁,作为最后的闭卷考试来检验模型的真正实力。只有这样测出来的成绩,才具备说服力。
本次 Python 完整版实跑在第 93 轮提前停止。下面的损失曲线显示,前期训练和验证误差都快速下降,后期进入波动平台。图中横轴虽然写着“迭代次数”,但当前 Python 代码每轮只记录一个点,所以这里应按“训练轮次”理解。
八、Python 实战:运行结果与指标分析
在 Python 完整版中(以本次实跑结果为例,随机种子 42,计算设备为 CPU),模型最终在训练集、验证集和测试集上得到了如下指标:
先看看训练集的拟合效果:
左图中,真实房价与预测房价的整体走势比较贴合,说明模型确实从训练数据中学到了一些规律。右图显示误差大多集中在 0 附近,但并不是所有样本都能精确命中。
再看从未参与训练的测试集表现:
测试集曲线总体能跟随真实值变化。测试集 MAE 为 2.5230,表示平均每套房预测偏离约 2.5 左右;RMSE 为 3.7823,对少数偏差较大的样本惩罚更重;R² 为 0.8219,说明模型解释了房价中相当一部分变化。
但这并不意味着模型已经完美。右侧误差分布仍然存在少数长尾误差,说明某些样本预测偏差较大。
散点图更直观。点越靠近黑色对角线,预测越接近真实值。可以看到,低、中价位的点整体更集中,而高房价区域仍有一些点偏离对角线,出现低估或波动偏大的现象。R² 指标能概括总体表现,但散点图能把这些局部瑕疵摊开给我们看。
最后把多个指标放在一起:
训练成绩优于验证成绩并不奇怪,但验证集和测试集没有完全崩掉,说明当前训练过程没有出现明显失控。更重要的是,这些结果只能说明当前代码在这个数据和这组参数下完成了有效拟合,不能单凭一个漂亮的 R² 就断言 CNN-RNN 一定适合所有表格回归问题。
九、这套结构适合什么数据?
CNN-RNN 会认真对待“谁挨着谁、谁先谁后”。这既是它的能力,也是它的限制。
如果输入是连续时间窗口、光谱波段、按位置排列的传感器等数据,相邻关系稳定,前后顺序有含义。CNN 可以寻找局部形状,RNN 可以继续阅读前后变化,这类任务更符合模型假设。
但如果输入只是年龄、税率、房间数、距离等普通表格字段,它们虽然也能排成 13 列,却未必存在天然顺序。换一下列的位置,业务含义可能没有改变,但 CNN 和 RNN 看到的局部关系就变了。因此,房价案例适合演示完整代码链路,不足以证明 CNN-RNN 是普通表格回归的首选。
选择模型前,可以先问三个问题:
第一,最终目标是不是连续数值?第二,每条输入内部有没有不能随意交换的顺序?第三,预测是否同时依赖局部模式和前后联系?
三个答案都为“是”,CNN-RNN 才值得进入候选名单。
十、“一行代码”完成 CNN-RNN 回归预测
原理讲明白之后,真正自己动手时,麻烦才刚刚开始。
你需要划分训练集、验证集和测试集,需要避免归一化时偷看测试集,还要逐层核对 CNN、池化和 LSTM 的输入尺寸。模型训练完,还得反归一化、计算指标、画预测曲线和误差图。任何一个环节漏掉,程序都可能报错;即使程序不报错,结果也可能不可信。
为了让大家把精力放在数据和模型本身,而不是反复拼接这些固定流程,我把整套 CNN-RNN 回归过程封装成了 FunRegCNNRNN 函数。设置好数据和参数后,真正启动模型只需要一行:
foreData, foreDataTrain, info = FunRegCNNRNN(X, Y, options)这一行代码背后,会依次完成数据划分、训练集归一化、CNN-RNN 网络搭建、模型训练、提前停止、最佳权重恢复、测试集预测、指标计算和结果绘图。
函数的三个输入也很直接:
- X:输入数据,尺寸为“样本数 × 有序序列长度”。本文中就是 506×13。
- options:数据划分、网络结构、训练过程、正则化、归一化和绘图输出的设置。
三个输出分别是:
- info:模型、归一化器、训练/验证/测试索引、评价指标、损失曲线等运行信息。
以本文的房价数据为例,完整调用代码如下:
import numpy as npfrom FunRegCNNRNN import FunRegCNNRNNdata = np.loadtxt('housing.txt')X = data[:, :13] # 506 套房,每套 13 个输入位置Y = data[:, 13] # 每套房对应 1 个真实房价options = {'rTrain': 0.8, # 80% 作为训练+验证数据,其余作为最终测试集'validationRatio': 0.15, # 从训练+验证数据中再划出 15% 做验证'networkType': 'LSTM', # Python 版支持 RNN、LSTM、GRU、BiLSTM、BiGRU'convChannels': 16, # 卷积通道数,越大能学更多局部模式,也更容易过拟合'kernelSize': 3, # CNN 每次观察 3 个相邻位置,必须为正奇数'poolSize': 2, # 池化窗口,设为 1 基本等价于不池化'rnnHidden': 32, # RNN/LSTM/GRU 隐藏单元数,越大记忆容量越强'epochs': 150, # 最大训练轮数,可能因为早停提前结束'lr': 0.01, # Adam 学习率,太大易震荡,太小收敛慢'batchSize': 32, # 批尺寸,大一些更稳定但更占内存/显存'weightDecay': 1e-4, # L2 正则系数,适当增大可抑制过拟合'earlyStoppingPatience': 25, # 验证损失连续不改善 25 轮就停止'mapflag': True, # 自动归一化与反归一化,只用训练集拟合归一化器'figflag': True, # 自动绘制并保存结果图,批量调参可设为 False'shuffle': True, # 独立样本可打乱;严格时间序列通常不要打乱'seed': 42# 固定随机种子,保证结果尽量可复现}foreData, foreDataTrain, info = FunRegCNNRNN(X, Y, options)看起来参数不少,但它们各自只回答一个很具体的问题。
rTrain 和 validationRatio 管的是数据怎么分。这里 rTrain=0.8 不是说 80% 全部拿去训练,而是先把 80% 样本划成“训练+验证数据”,剩下 20% 留作最终测试集;然后再从这 80% 里拿出 15% 做验证集。训练集负责更新网络参数,验证集负责观察模型是不是开始过拟合,测试集要等训练结束后才第一次参与评估。
networkType、convChannels、kernelSize、poolSize 和 rnnHidden 管的是模型容量。convChannels 越大,CNN 能提取的局部模式越多;kernelSize 越大,CNN 一次看的相邻位置越宽;poolSize 越大,中间特征压缩越强;rnnHidden 越大,RNN/LSTM/GRU 的记忆容量越强。它们不是越大越好,数据量不够时,容量太大反而可能让训练集很好、测试集变差。
epochs、lr、batchSize、weightDecay 和 earlyStoppingPatience 管的是训练节奏。学习率太大,损失可能上下震荡;学习率太小,训练会很慢。weightDecay 是一种抑制过拟合的约束,太小约束不足,太大又可能欠拟合。最大训练轮数只是上限,真正是否提前停下来,要看验证损失是否连续多轮不改善。mapflag=True 会自动完成归一化和反归一化,但归一化器只从训练集里学,避免模型提前“偷看”验证集和测试集。
Python 版可以使用哪几种 RNN?
本项目 Python 版的循环网络模块一共支持 RNN、LSTM、BiLSTM、GRU 和 BiGRU 五种结构,通过 networkType 参数即可切换:
options['networkType'] = 'RNN'# 普通循环网络options['networkType'] = 'LSTM'# 本文默认设置options['networkType'] = 'GRU'# 门控结构相对精简options['networkType'] = 'BiLSTM'# 双向 LSTMoptions['networkType'] = 'BiGRU'# 双向 GRU实际使用时只保留其中一行即可。LSTM 是本文演示采用的默认结构;GRU 的门控结构更精简;双向结构会从两个方向阅读整段输入,但参数量和计算量也会增加。哪一种更好不能只看名字,需要在相同的数据划分下比较验证集和测试集表现。
函数运行结束后,你不必再手工整理一堆零散结果。程序会自动给出训练集、验证集和测试集上的 MAE、RMSE、MAPE、R² 等指标,同时生成训练集预测、测试集预测、真实值与预测值散点图、指标对比图以及训练损失曲线。
至此,13 个原始特征已经走完了完整旅程:先由 CNN 捕捉局部组合,再由池化层做压缩筛选,然后由 LSTM 汇总前后信息,最后由全连接层输出一个连续房价。前面讲的是这条流水线为什么成立,这一节解决的则是如何把它真正跑起来。
如何获取完整代码
本文使用的完整 Python 代码(包括核心函数、演示脚本、数据集和依赖说明)可以在以下网址获取:
https://www.khsci.com/docs/index.php/2026/07/05/cnn-rnn-regression-python/
代码包含完整的 CNN-RNN 混合回归核心函数、详细的参数说明和使用示例、波士顿房价数据集、自动生成的可视化图表,以及 MAE、RMSE、MAPE、R² 等完善的性能评估指标。