☰
BP神经网络多输入多输出回归:Python实现与数据集实战
2026/10/13 1:35:04 网站建设 项目流程

简介:BP神经网络多输入多输出回归模型的Python实验包,面向机器学习初学者和需要快速实现回归功能的开发者,内容涵盖网络初始化、前向传播、反向传播、训练迭代与结果可视化等完整流程。压缩包内共三个文件,包括一个Python脚本和两个Excel数据文件;脚本实现从权重初始化到损失曲线绘制的全部算法步骤,数据文件分别提供输入特征与目标输出,整体仅20KB,结构紧凑易于上手。该资源已有960人学习下载,适合作为课程设计、期末项目或小型回归任务的基础参考。通过运行脚本并载入数据,读者可以直观观察多输入多输出场景下的模型训练过程,同时可自由调节隐藏层节点数、学习率与训练轮数以优化性能;两个Excel文件提供了可直接测试的样本,便于快速验证算法效果,是理解BP回归原理并落地实践的实用资料。

1. BP神经网络多输入多输出回归:带Python工程和数据集,拿来就能改

在多输入多输出回归这个方向上,很多人的第一个障碍不是网络结构多难懂,而是“手里有数据集,代码却拼不完整”。这份BP神经网络回归资源把数据、预处理、建模、训练、评价整套串好了,直接拿Python跑就能看到结果。它解决的是真实工程里最常见的需求:N个输入特征预测M个输出目标,比如根据建筑参数同时预测供暖负荷和制冷负荷。适合刚学Keras、想用真实数据集做回归的入门者,也适合已经在做回归但被数据泄漏、量纲、过拟合反复折磨的从业者。读完你至少能分清哪些地方是模型能力问题,哪些是自己的预处理锅。

2. 数据先行:多输入多输出回归的数据集结构与预处理细节

2.1 数据集结构与业务含义:先搞清楚要预测什么

我拆任何带数据的项目,第一步永远是pd.read_csv,而不是直接进入模型代码。这份资源里的data.csv是UCI经典的Energy Efficiency数据集,768条样本,8个输入特征对应建筑结构的物理参数,2个输出目标分别是对应的供暖负荷和制冷负荷。说白了,这就是一个“房子怎么盖,能耗大概是多少”的直接映射,天然是多输入多输出回归。

import pandas as pd df = pd.read_csv("data.csv") print(df.shape) # 期望输出 (768, 10) print(df.head()) # 看一眼列名和数据样子 features = ["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"] targets = ["Y1", "Y2"]

先打印shape是确认有没有读错文件、数据量够不够;head则是确认列名是否和代码里features、targets写的一致。我把两个列表单独抽出来,后面所有切片、切分、评估都用这两个列表,避免在十几个地方硬编码列名,一旦改项目就得全局搜索替换。

具体字段含义用下面表格说明:

列名含义
X1相对紧凑度,建筑体积与外表面比例的度量
X2建筑表面积
X3墙面面积
X4屋顶面积
X5建筑总高度
X6朝向,离散整数取值
X7玻璃面积占比
X8玻璃面积分布
Y1供暖负荷
Y2制冷负荷

读进来之后还要顺手做一轮数据体检:

print(df.info()) print(df.isna().sum())

df.info()可以看每一列的数据类型和内存占用,df.isna().sum()输出每列缺失值数量。这份数据是完整矩阵,没有缺失,但自己接手新数据时这两个检查不能省。一旦有NaN,BP网络训练时loss会直接变成NaN,后面所有分析全白做。

对Y1、Y2的取值分布也要心里有数。供暖负荷和制冷负荷数值范围都比较接近,在十几到四十多之间,谁也没比谁大一个量级,这是后面能安心用默认MSE的前提。如果你自己的数据一个目标范围是0~1,另一个是0~1000,就得按第5章的办法给不同输出配不同的loss权重。

2.2 为什么回归任务的多输出用BP神经网络,而不是线性回归

如果只是两列输出,你完全可以架两个线性回归模型,每个模型一个目标,也能出结果。但实际效果通常不理想。原因在于X和Y之间不只是线性叠加:供暖负荷同时受高度、面积、玻璃占比影响,而且这些因素之间还有交互效应。线性回归把每个特征的影响固定成一条直线,无法表达“高度小但玻璃占比大时,能耗先降后升”这种复杂关系。

BP神经网络的隐藏层用ReLU这类非线性激活函数,本质上是在帮原始特征不断做非线性组合。理论上已经证明,只要隐藏层节点足够多,BP网络能以任意精度逼近连续函数。碰到768条这样的小规模数据集,一两个隐藏层足够,不需要往深处堆网络。这也是后面模型参数设置的核心原则:小数据量配小模型,先跑通再扩。

你可能会问,那直接用随机森林、XGBoost不也行吗?确实能,但BP网络在这里的优势是输出层天然支持多目标联合建模,隐藏层学到的特征表示是共享的。而像XGBoost做多输出要分别建树或者用一些多目标扩展,代码反而绕。BP就是这个场景下最直白、最好改的方案。

2.3 归一化不是可选项:MinMaxScaler的使用与顺序

多输入多输出回归里,归一化是强制的,而且顺序比用什么scaler更重要。我见过太多人在全量数据上直接scaler.fit_transform(X),之后再train_test_split,那就是典型的把测试集信息提前泄露给训练过程,验证指标虚高。正确做法是先划分,再在训练集上fit,再transform测试集。

import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler X = df[features].values.astype(np.float32) Y = df[targets].values.astype(np.float32) X_train, X_test, Y_train, Y_test = train_test_split( X, Y, test_size=0.2, random_state=42 ) scaler_X = MinMaxScaler(feature_range=(0, 1)) scaler_Y = MinMaxScaler(feature_range=(0, 1)) X_train_scaled = scaler_X.fit_transform(X_train) X_test_scaled = scaler_X.transform(X_test) Y_train_scaled = scaler_Y.fit_transform(Y_train) Y_test_scaled = scaler_Y.transform(Y_test)

这里每个变量的作用都值得说清楚。X是特征矩阵,Y是两列目标。test_size=0.2表示留出20%作为完全没参与过训练的测试集,random_state=42是随机种子,固定它才能保证每次复现同一份划分。scaler_X只在X_train上fit,拿到训练集的最小值和最大值,然后对测试集transform;如果测试集某列最大值比训练集更大,会被压缩到1以上,这是正常现象,说明测试集分布略微超出训练范围。

scaler_Y同样先fitY_train再transformY_test。你可能会问目标变量为什么要归一化,直接回归不行吗?不是不行,而是目标不归一化时,MSE数值会很大,梯度数值也大,训练初期学习率很难定。把目标缩放到0~1,损失和梯度都更温和。注意,后续预测出的结果一定记得用scaler_Y.inverse_transform还原成真实量纲再评估,否则你对比的尺度和原始Y都不一样。

提示:顺序永远先split后scale。测试集只用transform,绝不fit_transform;对Y和X都做同样的处理。

为什么不用StandardScaler?MinMaxScaler把数据映射到0~1区间,对ReLU组合友好,而且这组数据的特征没有太夸张的离群点。如果你自己的数据有极端大值,StandardScaler的均值和方差会更稳定,但那已经是另一个场景,这份资源里的数据集用MinMax完全够。

3. 网络搭建:输入层、隐藏层、输出层到底怎么定

3.1 输入输出节点数的确定

继续用刚才的features和targets。输入层节点数不需要任何调参,直接等于特征数8;输出层节点数等于目标数2。这是多输出回归和单输出回归唯一的“硬性”差别,其余结构完全一样。隐藏层怎么定才是真正的玄学。常见做法是第一个隐藏层取输入维度的2~4倍,后面逐层减半,比如8入→16→8→2出。如果数据量更小,压到8→4也行;数据量上千,可以试32→16。

节点数没有解析解,但可以遵循一个原则:让模型参数总量远小于样本数。768条样本,参数总量几万是上限,再多必然过拟合。我一般会先按(16, 8)搭一版,跑通再决定要不要加宽或加深。别一上来就上(128, 64, 32),那是在赌自己的样本量撑得住。

3.2 激活函数和损失函数:回归场景的默认组合

回归任务里,隐藏层用ReLU是默认操作。ReLU计算快、对大于0的区间梯度恒为1,不容易梯度消失,比tanh和sigmoid都更适合多层网络。输出层最容易翻车:回归的输出层绝不加激活函数,保持线性输出。因为输出层承担的是把最后一层特征映射到任意数值范围的任务,如果加了sigmoid,输出被压在0和1之间,而你的Y1真实最小值是6点多,模型根本不可能用sigmoid结构输出到40以上,loss自然降不下去。

损失函数用均方误差MSE。多输出时Keras的MSE会默认对所有输出位求平均,相当于把所有目标拼成一个向量算MSE。这对Y1和Y2量纲接近的场景没问题;如果两个目标量纲差很多,就需要给不同输出单独加权,这个坑在第5章详细讲。

3.3 Keras模型搭建与参数说明

import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout from tensorflow.keras.optimizers import Adam model = Sequential([ Dense(16, activation="relu", input_shape=(8,)), Dropout(0.1), Dense(8, activation="relu"), Dropout(0.1), Dense(2, name="output") ]) model.compile( optimizer=Adam(learning_rate=0.001), loss="mse", metrics=["mae"] ) model.summary()

这段代码几乎可以直接套到其他多输入多输出数据上,唯一要改的是input_shape里的8和最后一层的2。Dense(16, activation="relu", input_shape=(8,)):16是第一个隐藏层的神经元数量,input_shape告诉Keras每条样本特征维度是8。Dropout(0.1)表示每次训练迭代随机丢弃10%的神经元,是给768条小样本上的过拟合上保险。Dropout不应该放在输出层,所以你看到两个Dropout都夹在Dense之间。

第二次Dense(8)是第二个隐藏层,进一步组合特征。Dense(2, name="output")没有activation,输出两个连续数值。compile里的Adam(learning_rate=0.001)是优化器,0.001是基准学习率,小数据集上通常不用改,除非loss震荡严重;loss="mse"是损失函数,metrics=["mae"]让训练过程额外多输出一个平均绝对误差,作为肉眼观察训练是否正常的指标。

跑一下model.summary(),能看到每一层输出形状和参数量。第一个Dense层参数量是8×16+16=144,第二个是16×8+8=136,输出层是8×2+2=18,加起来不到400个参数。这个复杂度在768条数据上是安全的。如果你的模型summary打印出来是几万参数,先砍一层或者减半神经元再跑。

4. 训练、评价与可视化:从fit到R2的全流程

4.1 训练参数与过拟合防线

模型定义好,直接fit也可以,但我不建议裸跑。fit里要带validation_split和callbacks,让训练过程能看见验证集上的实时表现,以及自动停下来的能力。

early_stop = tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=30, restore_best_weights=True ) history = model.fit( X_train_scaled, Y_train_scaled, validation_split=0.1, batch_size=32, epochs=200, callbacks=[early_stop] )

validation_split=0.1表示从X_train_scaled里再切10%作为验证集,这部分不参与拟合,只用来评估每轮结束后的损失。batch_size=32代表每32条样本计算一次梯度更新,大数据量下批太小训练震荡,批太大收敛慢,32在768条数据上是比较平衡的默认值。epochs=200不是必须跑满,EarlyStopping会在val_loss连续30轮不下降时停止,而且restore_best_weights=True会把模型权重恢复到验证集表现最好的那个epoch。

为什么要这个机制?因为BP神经网络在训练集上会越训越好,但验证集指标通常先降后升,那个最低点才是你真正想要的模型。没有早停的话,跑满200轮可能已经严重过拟合,自己还浑然不觉。

训练结束后,打印前10轮的loss看下学习率是否正常:

print(history.history["loss"][:10]) print(history.history["val_loss"][:10])

如果前几轮loss在快速下降,说明学习率合适;如果loss几乎不动或者直接NaN,优先检查学习率太大、数据里有没有NaN、有没有忘记归一化。

4.2 回归任务该看哪些指标:R2、RMSE、MAE

训练结束后,用测试集做最终评估。测试集是整个流程中被隔离得最彻底的那20%的数据,模型一次都没见过,只有它能反映真实泛化能力。

from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error import numpy as np Y_pred_scaled = model.predict(X_test_scaled) Y_pred = scaler_Y.inverse_transform(Y_pred_scaled) for i, name in enumerate(targets): r2 = r2_score(Y_test[:, i], Y_pred[:, i]) rmse = np.sqrt(mean_squared_error(Y_test[:, i], Y_pred[:, i])) mae = mean_absolute_error(Y_test[:, i], Y_pred[:, i]) print(f"{name}: R2={r2:.3f}, RMSE={rmse:.3f}, MAE={mae:.3f}")

model.predict拿到的是经过归一化的预测值,必须先inverse_transform还原回原始单位,再和Y_test比较。如果忘了这一步,RMSE会小得离谱,因为两头都是0~1的数据。循环里enumerate(targets)把第0列Y1和第1列Y2逐一拿出来评估。R2衡量的是预测值解释真实值方差的比例,RMSE对大误差敏感,MAE对每个点的误差平均。

在多输出任务里,只看loss是不够的。loss是Y1和Y2误差的混合平均,可能一个指标已经很好,另一个还在拖后腿。分开打印每个输出的R2、RMSE、MAE,一眼就能看出是哪边出了问题。对这套能源效率数据,一个训练到位的BP网络通常能拿到测试集R2在0.90~0.95左右。如果你的结果明显低于这个,大概率回到了第5章的某一种坑里。

4.3 预测结果可视化:别只看loss曲线

训练曲线和预测散点图是两回事。loss曲线只能告诉你训练过程是否正常,散点图告诉你的才是预测质量。我通常把两个目标画成并排两列散点,x轴是真实值,y轴是预测值。

import matplotlib.pyplot as plt plt.figure(figsize=(8, 4)) for i, name in enumerate(targets): plt.subplot(1, 2, i + 1) plt.scatter(Y_test[:, i], Y_pred[:, i], alpha=0.5) plt.plot([Y_test[:, i].min(), Y_test[:, i].max()], [Y_test[:, i].min(), Y_test[:, i].max()], "r--") plt.xlabel("真实值") plt.ylabel("预测值") plt.title(f"{name} 预测对比") plt.tight_layout() plt.savefig("predict_compare.png", dpi=200)

alpha=0.5把散点透明度调低,重叠多的区域颜色更深,更容易看出密度。红色虚线是对角线,散点贴着虚线就是理想预测。plt.savefig保存成图片而不是plt.show,是因为命令行环境可能没有图形界面,存文件再打开是稳定的做法。

如果看到散点在一端聚成一团,远离对角线,优先怀疑归一化还原出了问题;如果散点沿着对角线但中间有一大段分散,说明模型对该区间特征的拟合不够,先加隐藏层节点数试试,而不是盲目加epoch。

5. 避坑手册:多输入多输出回归的五个常见问题

5.1 归一化顺序错了,验证集R2虚高

现象:测试集R2高达0.97,看起来非常漂亮,但把模型拿到另一批实采数据上预测时误差大到离谱。

原因:在划分数据之前,对整个X做了scaler.fit_transform,整个Y也被提前归一化过,测试集的最小值、最大值乃至排序信息在训练阶段已经可见,属于数据泄漏。模型相当于考试前见过试卷,考分没有参考价值。

解决:严格按先train_test_split,再fit_transform训练集、transform测试集的顺序来做。资源里的代码就是这么排的,不要自己把scaler回调掉顺序。完成后可以做一个简单自检:随机挑一条测试样本,用scaler_X.transform处理一下,确认缩放后的数值和手动按公式(x - min) / (max - min)算出来一致。

5.2 输出层用了Sigmoid,预测值卡在0~1

现象:loss下降特别慢,训到最后预测值全部落在0到1之间,inverse_transform后也只是一堆零点几的数字。

原因:sigmoid输出有上界1,但这个任务的供暖负荷真实范围是6到43,模型就算想输出20,sigmoid的数学结构也做不到。更糟的是sigmoid在两端梯度接近0,误差越大时梯度反而越小,训练变得极其缓慢。

解决:输出层不要写activation="sigmoid",保持Dense(2, name="output")不带激活函数。如果你已经在目标归一化到0~1的前提下用了sigmoid,那相当于对归一化后的数据又做了一次非线性压缩,也是错的。正确的输出是线性神经元,配合MinMaxScaler,预测后inverse_transform还原。

5.3 隐藏层神经元太多,训练集R2=0.99,测试集=0.5

现象:训练集R2接近1,测试集R2只有0.5左右,中间差距巨大,典型的过拟合形状。

原因:768条样本配了(128, 64, 32)这种规模的网络,参数量轻松过万,模型有足够能力“背下”训练集。BP网络在小数据上就是这样,越复杂越容易把噪声也学进去。

解决:回到(16, 8)这种规模,并且加Dropout和EarlyStopping。一个可复现的判断方法:把隐藏层神经元翻倍,观察测试集R2是否明显上升;如果上升超过0.02,说明容量不够,可以继续加;如果测试集反而降了,说明已经过拟合,别再增大。这个方法的原理就是控制模型容量,让容量和样本量匹配。

5.4 多输出量纲不一致,MSE被大数值输出主导

现象:两个目标的loss曲线看起来正常,但打印出每个输出指标时发现,量纲大的那个R2比量纲小的高得多。

原因:Sequential模型编译时如果不特殊设置,MSE是对两个输出位置直接求平均值。假设Y1范围0~100,Y2范围0~1,误差10和误差0.1在MSE里差100倍,量纲大的输出垄断了梯度,另一个目标成了陪跑。

解决:先看Y1、Y2的取值范围。这份数据两个目标量纲很接近,不需要处理。如果换到其他数据量纲差10倍以上,就改用函数式API,分别给两个输出设置loss_weights:

inputs = tf.keras.Input(shape=(8,)) x = Dense(16, activation="relu")(inputs) x = Dropout(0.1)(x) x = Dense(8, activation="relu")(x) out1 = Dense(1, name="Y1")(x) out2 = Dense(1, name="Y2")(x) model = tf.keras.Model(inputs, [out1, out2]) model.compile( optimizer="adam", loss={"Y1": "mse", "Y2": "mse"}, loss_weights={"Y1": 0.5, "Y2": 0.5} )

这个写法把输出拆成两个独立的Dense(1)分支,每个分支有名字,编译时通过key指定各自的loss和权重。loss_weights可以按业务重要性配,比如供暖负荷更重要就0.7/0.3,最稳妥的起点是让每个输出权重的量级接近,例如0.5/0.5。

5.5 只在最后一个epoch手动取模型,错过最佳权重

现象:训练日志里val_loss在120轮时到最低点,后面又涨了回去。最终拿到的模型测试指标比训练中途看到的验证指标差。

原因:没有早停,也没有保存中间权重,只用跑完200轮后的最后一批权重。BP训练后期在验证集上常见的先降后升,权重被后续过拟合的迭代污染。

解决:在第4章的fit代码里挂上EarlyStopping(restore_best_weights=True),或者用ModelCheckpoint显式保存最佳模型:

checkpoint = tf.keras.callbacks.ModelCheckpoint( "best_model.keras", monitor="val_loss", save_best_only=True, verbose=0 ) history = model.fit( X_train_scaled, Y_train_scaled, validation_split=0.1, batch_size=32, epochs=200, callbacks=[early_stop, checkpoint] )

训练后在获取测试指标前,先加载best_model.keras再predict,确保你评估的不是最后一轮权重,而是val_loss最低那一轮的冠军权重。这个习惯可以从根本上避免“训练完直接保存模型”这条路径带来的后悔药问题。

6. 进阶技巧:先用MLPRegressor做基线,再进Keras精调超参

6.1 基线模型:sklearn的MLPRegressor怎么快速验证数据

不要一上来就在Keras里调参。同样的数据,先用scikit-learn的MLPRegressor跑一遍,一秒钟就能告诉你数据可不可学、特征够不够。MLPRegressor内部就是BP网络,但封装更严密,不需要你手动管反向传播和归一化,还会自动处理多输出:

from sklearn.neural_network import MLPRegressor mlp = MLPRegressor( hidden_layer_sizes=(16, 8), alpha=0.01, max_iter=500, random_state=42 ) mlp.fit(X_train_scaled, Y_train_scaled) print("基线R2:", mlp.score(X_test_scaled, Y_test_scaled))

hidden_layer_sizes=(16, 8)和Keras模型的隐藏层结构保持一致,alpha=0.01是L2正则强度,max_iter=500限制最大迭代次数,random_state固定种子。score()返回的是多输出回归的R2,用法和单输出完全一样。如果基线R2低于0.8,先别急着上复杂网络,回到数据清洗、特征工程。如果基线R2已经接近0.9,说明数据本身学得动,Keras的工作就是把这0.9往上再拱一拱。

6.2 早停与模型保存:把最强weights留住

Keras和sklearn有个很大的差别:Keras给了你每个epoch的实时状态,你可以用这个状态做很多精细操作。除了第5章讲的EarlyStopping和ModelCheckpoint,我还有个人习惯是训练结束后立刻对比history.history里的val_loss最低值和最后一个val_loss。如果两者差超过0.02,说明后几十个epoch几乎全在过拟合,我会把epoch上限再收紧,比如从200改到100。

实际训练时我经常把batch_size、learning_rate、早停patience这3个参数单独拿出来试。batch_size越小,梯度波动越大,同学习率下越容易跳过局部极小点;learning_rate从0.001起步,如果loss在前10轮不降,降一半重来。

6.3 超参数搜索习惯:从字典开始,别手动一个个试

手动试参最大的问题是只能得到一个局部结果,没有对照。我习惯建一个参数字典,用GridSearchCV批量验证隐藏层和alpha的几个组合:

from sklearn.model_selection import GridSearchCV param_grid = { "hidden_layer_sizes": [(16, 8), (32, 16), (16, 16)], "alpha": [0.001, 0.01, 0.1] } grid = GridSearchCV( MLPRegressor(max_iter=500, random_state=42), param_grid, cv=3, scoring="neg_mean_squared_error" ) grid.fit(X_train_scaled, Y_train_scaled) print("best params:", grid.best_params_)

scoring="neg_mean_squared_error"对多输出默认是多输出平均,网格搜索会把所有组合的交叉验证结果列出来。这一步筛出来的隐藏层规模和alpha,可以直接迁移到Keras模型里作为初始值,比在茫茫超参里碰运气快得多。尤其alpha对应Keras里Dense的kernel_regularizer,选到合适正则项比单纯加Dropout理论性更强。

从那以后,我每个回归项目都强制走一遍基线、网格搜索、早停、保存最佳模型这条流程;Keras模型只是这个流程里最后收尾的那一步。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询