简介:本资源为2021年高教社杯全国大学生数学建模竞赛B题「乙醇偶合制备C4烯烃」的二等奖完整论文,面向备战数模国赛的本科生与建模爱好者,尤其适合需要参考优秀获奖论文结构与建模思路的参赛者。压缩包内仅含1个PDF文件,大小约3.93MB,即论文全文,涵盖摘要、问题重述、模型建立与求解及附录代码,便于直接阅读与对照学习。目前已有4262人学习下载,热度较高。论文围绕乙醇高效制备C4烯烃的工艺条件展开,针对四个子问题分别采用Newton插值刻画温度与乙醇转化率、C4烯烃选择性的关系,建立多元线性回归模型分析催化剂成分与温度的影响,并引入BP神经网络结合粒子群算法(PSO)优化C4烯烃收率,最终给出最优催化剂组合与温度区间,并设计补充实验方案。读者可从中获取完整的建模框架、算法选型依据、检验方法与论文写作范式,是数模国赛备赛的实用参考资料。
1. 从 2021 国赛 B 题说起:乙醇偶合制备 C4 烯烃到底在算什么
2021 年全国大学生数学建模竞赛 B 题给了一批乙醇偶合制备 C4 烯烃的实验数据,核心诉求就一句话:在不同催化剂组合和温度下,C4 烯烃收率怎么变,怎么找到最优工艺条件。做过这道题的人都知道,它表面是化工题,骨子里是数据建模题——温度、乙醇浓度、催化剂配比这些自变量,和乙烯、C4 烯烃收率这些因变量之间,既不是简单线性关系,也不是纯黑箱能糊弄过去的。
这道题当年拿二等奖的队伍,通常不是靠堆模型复杂度赢的,而是把数据清洗、插值补点、回归拟合、非线性寻优这条链路走扎实了。我后来复盘过很多次,发现真正拉开差距的是三件事:温度区间内缺失点的处理方式、回归模型对非线性段的拟合能力、以及寻优时参数边界设得合不合理。这篇笔记就按这条链路拆开讲,从数据预处理到 BP 神经网络拟合,再到粒子群算法找最优条件,每一步都给可复现的代码和参数说明。适合正在做化工数据建模、或者想拿这道题练手的同学,也适合想搞清楚 BP 神经网络和粒子群算法怎么在真实数据上落地的人。
2. 数据预处理与 Newton 插值:把散点补成能用的曲线
2.1 先搞清楚原始数据长什么样
题目给的附件数据一般是按催化剂组合分组的,每组里有温度、乙醇转化率、C4 烯烃选择性、C4 烯烃收率这几列。收率等于转化率乘选择性,这个关系要先验证一遍,如果对不上,说明数据里有需要处理的异常值。我一般会先做三件事:检查缺失值、看每个催化剂组合下的温度覆盖范围、画散点图看趋势。
import pandas as pd import numpy as np import matplotlib.pyplot as plt # 读取附件数据,假设文件名为 data.xlsx df = pd.read_excel('data.xlsx') # 检查缺失值和基本统计 print(df.isnull().sum()) print(df.describe()) # 验证收率 = 转化率 * 选择性 df['check'] = df['乙醇转化率'] * df['C4烯烃选择性'] / 100 print((df['check'] - df['C4烯烃收率']).abs().max()) # 按催化剂组合分组画散点 for name, group in df.groupby('催化剂组合'): plt.scatter(group['温度'], group['C4烯烃收率'], label=name) plt.xlabel('温度') plt.ylabel('C4烯烃收率') plt.legend() plt.show()这段代码的关键在第三步:如果check和实际收率差得超过 1 个百分点,说明数据录入或计算口径有问题,得回去核对。分组画散点是为了看每个组合的温度点是不是均匀分布,很多队伍在这一步就发现某些组合只有三四个温度点,直接拟合会严重过拟合。
2.2 Newton 插值补点的适用边界
温度点稀疏的时候,常见做法是用插值把曲线补密。Newton 插值适合等距节点,计算量小,但有个坑:高次插值会出现龙格现象,两端震荡得厉害。我一般把插值阶数控制在 3 到 5 之间,超过 5 就改用分段三次样条。下面是一个 Newton 插值的实现,输入是温度数组和对应的收率数组,输出是加密后的温度-收率对。
def newton_interpolation(x, y, x_new): """ x, y: 原始节点 x_new: 待插值点 """ n = len(x) # 计算差商表 diff = np.zeros((n, n)) diff[:, 0] = y for j in range(1, n): for i in range(n - j): diff[i][j] = (diff[i+1][j-1] - diff[i][j-1]) / (x[i+j] - x[i]) # 计算插值结果 result = np.zeros_like(x_new, dtype=float) for k, xv in enumerate(x_new): val = diff[0][0] term = 1.0 for i in range(1, n): term *= (xv - x[i-1]) val += diff[0][i] * term result[k] = val return result # 对某一组数据插值 group = df[df['催化剂组合'] == 'A1'].sort_values('温度') x = group['温度'].values y = group['C4烯烃收率'].values x_new = np.linspace(x.min(), x.max(), 50) y_new = newton_interpolation(x, y, x_new)差商表是 Newton 插值的核心,diff[0][i]就是第 i 阶差商。参数上要注意:x必须严格递增,否则差商计算会除零;x_new的范围不能超出原始温度区间,外推段没有物理意义。插值完一定要画图对比原始点和插值曲线,如果曲线在两端翘得离谱,就说明阶数太高了,降到 3 阶再试。
2.3 插值之后还要做的一件事
插值补出来的点不能直接拿去训练模型,因为插值点之间是强相关的,会让回归模型高估自己的拟合能力。我的习惯是把插值点只用于画趋势图和确定温度区间,真正训练 BP 神经网络时还是用原始实验点,或者用插值点做交叉验证的补充。另外,如果某个催化剂组合的温度范围和其他组合差太多,建议单独建模,不要混在一起训练,否则温度这个变量会被稀释掉。
3. 多元线性回归打底:先知道线性部分能解释多少
3.1 回归模型怎么设
在上一章把数据补密、趋势看清楚之后,下一步不是直接上神经网络,而是先用多元线性回归探底。原因很简单:如果线性模型就能解释 80% 以上的方差,那非线性模型的提升空间有限,没必要把问题搞复杂。我一般会把温度、乙醇浓度、催化剂配比作为自变量,C4 烯烃收率作为因变量,先跑一个基准回归。
import statsmodels.api as sm # 构造自变量矩阵,温度做中心化处理 X = df[['温度', '乙醇浓度', '催化剂配比']].copy() X['温度'] = X['温度'] - X['温度'].mean() X = sm.add_constant(X) y = df['C4烯烃收率'] model = sm.OLS(y, X).fit() print(model.summary())中心化处理是为了让截距项有物理意义,不然截距就是温度为零时的收率,没有参考价值。summary()里重点看三个数:R-squared、各变量的 p 值、以及残差的正态性检验。如果某个变量 p 值大于 0.05,说明它对收率的影响不显著,可以考虑去掉或者换成非线性项。
3.2 什么时候该加交互项和平方项
温度对收率的影响通常不是线性的,低温段收率随温度上升快,高温段可能反而下降。这时候要在回归里加温度的平方项,甚至温度和其他变量的交互项。我一般会先画收率对温度的散点,如果明显是个倒 U 型,就加温度^2;如果不同催化剂组合的曲线斜率不一样,就加温度 * 催化剂配比交互项。
# 加入平方项和交互项 X2 = df[['温度', '乙醇浓度', '催化剂配比']].copy() X2['温度'] = X2['温度'] - X2['温度'].mean() X2['温度平方'] = X2['温度'] ** 2 X2['温度_催化剂'] = X2['温度'] * X2['催化剂配比'] X2 = sm.add_constant(X2) model2 = sm.OLS(y, X2).fit() print(model2.summary())加完平方项后 R-squared 一般会涨,但如果涨得太多而样本量又小,就要警惕过拟合。判断标准是调整后的 R-squared 有没有同步提升,如果调整 R-squared 反而降了,说明加的项不值得。
3.3 回归残差告诉你的信息
回归跑完不要只看 R-squared,残差图才是最有信息量的。把预测值做横轴、残差做纵轴,如果残差呈现喇叭口形状,说明存在异方差,这时候要么对因变量做变换,要么改用加权最小二乘。如果残差在某个温度区间系统性偏正或偏负,说明线性模型在这个区间失效了,这正是后面 BP 神经网络要补的地方。我一般会把残差绝对值大于两倍标准差的点标出来,回去核对原始数据是不是记录错了。
4. BP 神经网络拟合:结构、参数和训练技巧
4.1 网络结构怎么定
BP 神经网络做函数拟合,结构不用太深。这道题的自变量一般不超过 5 个,我通常用一层隐藏层,神经元个数在 8 到 15 之间试。隐藏层激活函数用 tanh 或 relu,输出层用线性激活,因为收率是连续值。下面是一个用 PyTorch 搭的最小可用网络。
import torch import torch.nn as nn import torch.optim as optim class BPNet(nn.Module): def __init__(self, input_dim, hidden_dim): super(BPNet, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, 1) def forward(self, x): x = self.relu(self.fc1(x)) x = self.fc2(x) return x # 假设输入是温度、乙醇浓度、催化剂配比三个特征 input_dim = 3 hidden_dim = 12 net = BPNet(input_dim, hidden_dim) criterion = nn.MSELoss() optimizer = optim.Adam(net.parameters(), lr=0.01)隐藏层神经元个数不是越多越好,12 个是我在这类化工数据上比较常用的起点。如果训练集 loss 降不下去,先加神经元;如果训练集 loss 很低但验证集 loss 高,说明过拟合,要减神经元或者加正则化。
4.2 训练循环和早停
训练的时候要把数据分成训练集和验证集,比例大概 8:2。每轮记录训练 loss 和验证 loss,验证 loss 连续 20 轮不下降就早停,防止过拟合。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 准备数据 X = df[['温度', '乙醇浓度', '催化剂配比']].values y = df['C4烯烃收率'].values.reshape(-1, 1) scaler = StandardScaler() X = scaler.fit_transform(X) X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42) X_train = torch.FloatTensor(X_train) y_train = torch.FloatTensor(y_train) X_val = torch.FloatTensor(X_val) y_val = torch.FloatTensor(y_val) best_val_loss = float('inf') patience = 20 counter = 0 for epoch in range(1000): net.train() optimizer.zero_grad() output = net(X_train) loss = criterion(output, y_train) loss.backward() optimizer.step() net.eval() with torch.no_grad(): val_output = net(X_val) val_loss = criterion(val_output, y_val) if val_loss < best_val_loss: best_val_loss = val_loss counter = 0 torch.save(net.state_dict(), 'best_model.pth') else: counter += 1 if counter >= patience: print(f'Early stop at epoch {epoch}') break标准化是必须的,温度数值在几百,乙醇浓度在几十,不标准化的话梯度下降会非常慢。早停的 patience 设 20 是我试出来的经验值,太小容易停在局部最优,太大浪费时间。
4.3 学习率调度和批量大小的选择
学习率一开始设 0.01,如果 loss 震荡就降到 0.001。批量大小在这类小数据集上直接用全批量就行,没必要搞 mini-batch。如果数据量超过一千条,可以用 32 或 64 的批量。另外,Adam 优化器对学习率不敏感,但如果你换成 SGD,学习率要调到 0.001 以下,不然很容易发散。
5. 粒子群算法寻优:在拟合面上找最优工艺条件
5.1 粒子群算法的参数怎么设
BP 神经网络训练好之后,它就是一个可调用的函数:输入温度、乙醇浓度、催化剂配比,输出预测收率。粒子群算法的任务是在这些自变量的取值范围内,找到让预测收率最大的组合。粒子群的核心参数有三个:粒子数、惯性权重、学习因子。我一般设粒子数 30,惯性权重从 0.9 线性降到 0.4,学习因子都设 2.0。
import numpy as np def pso_optimize(model, scaler, bounds, num_particles=30, max_iter=100): """ model: 训练好的 BP 网络 scaler: 标准化器 bounds: 每个自变量的取值范围,列表形式 [(min, max), ...] """ dim = len(bounds) # 初始化粒子位置和速度 positions = np.random.uniform( low=[b[0] for b in bounds], high=[b[1] for b in bounds], size=(num_particles, dim) ) velocities = np.random.uniform(-1, 1, size=(num_particles, dim)) # 个体最优和全局最优 pbest = positions.copy() pbest_score = np.full(num_particles, -np.inf) gbest = positions[0].copy() gbest_score = -np.inf for iteration in range(max_iter): # 惯性权重线性递减 w = 0.9 - 0.5 * iteration / max_iter for i in range(num_particles): # 预测收率 x_scaled = scaler.transform(positions[i].reshape(1, -1)) x_tensor = torch.FloatTensor(x_scaled) model.eval() with torch.no_grad(): score = model(x_tensor).item() if score > pbest_score[i]: pbest_score[i] = score pbest[i] = positions[i].copy() if score > gbest_score: gbest_score = score gbest = positions[i].copy() # 更新速度和位置 r1 = np.random.rand(num_particles, dim) r2 = np.random.rand(num_particles, dim) velocities = (w * velocities + 2.0 * r1 * (pbest - positions) + 2.0 * r2 * (gbest - positions)) positions = positions + velocities # 边界处理 for d in range(dim): positions[:, d] = np.clip(positions[:, d], bounds[d][0], bounds[d][1]) return gbest, gbest_score惯性权重从 0.9 降到 0.4 是为了前期探索、后期收敛。学习因子 2.0 是经典取值,调大容易早熟,调小收敛慢。边界处理用np.clip直接截断,比反射边界简单,效果也够用。
5.2 寻优结果怎么验证
粒子群找到的最优组合不能直接信,要做两件事验证。第一,把这个组合代回原始实验数据附近,看有没有实际实验点支持,如果最优温度落在两个实验点中间,要说明这是插值预测的结果。第二,换一组随机种子重新跑粒子群,如果两次结果差很多,说明拟合面不平滑,需要回去检查 BP 网络的训练质量。
# 假设 bounds 是 [(200, 400), (0.5, 2.0), (1, 5)] bounds = [(200, 400), (0.5, 2.0), (1, 5)] best_pos, best_score = pso_optimize(net, scaler, bounds) print(f'最优条件: 温度={best_pos[0]:.1f}, 乙醇浓度={best_pos[1]:.2f}, 催化剂配比={best_pos[2]:.2f}') print(f'预测收率: {best_score:.2f}')如果最优温度贴着边界,比如正好是 400,说明真实最优可能在边界外,需要扩大搜索范围重新跑。如果最优收率比所有实验点的收率都高很多,要警惕过拟合导致的虚高。
6. 避坑与排查:这道题最容易翻车的五个地方
6.1 插值点混入训练集导致 R-squared 虚高
现象:回归模型 R-squared 跑到 0.99,但预测新数据时误差很大。原因:把 Newton 插值补出来的点当成真实实验点放进训练集,插值点之间强相关,模型相当于在背答案。解决:训练集只用原始实验点,插值点只用于画图和确定温度区间,验证集从原始点里划。
6.2 BP 网络不标准化直接训练
现象:loss 一直不降,或者降得很慢。原因:温度在 200 到 400 之间,乙醇浓度在 0.5 到 2 之间,量纲差两个数量级,梯度下降被大数值特征主导。解决:训练前用StandardScaler对自变量做标准化,预测时记得用同一个 scaler 做逆变换。
6.3 粒子群早熟收敛到局部最优
现象:粒子群跑了几十轮就不动了,找到的最优解明显不是全局最优。原因:惯性权重降得太快,或者粒子数太少。解决:惯性权重从 0.9 降到 0.4,粒子数加到 30 以上,如果还不行就加变异操作,每轮随机重置 10% 的粒子位置。
6.4 温度边界设得太窄
现象:最优温度贴着搜索边界。原因:初始 bounds 是根据实验数据的最小最大值设的,但真实最优可能在实验范围之外。解决:把温度边界往外扩 10% 到 20%,重新跑粒子群,如果最优还在边界上,继续扩,直到最优落在区间内部。
6.5 忽略催化剂组合的类别差异
现象:所有催化剂组合混在一起建模,预测误差大。原因:不同催化剂组合的反应机理不一样,温度-收率曲线形状不同,混在一起模型学不到统一规律。解决:按催化剂组合分组建模,或者把催化剂组合做 one-hot 编码作为额外输入特征。
7. 进阶技巧:用交叉验证选隐藏层神经元个数
BP 神经网络的隐藏层神经元个数是这道题里最玄学的参数。我一开始靠试,后来改成用 5 折交叉验证来选。具体做法是:对每个候选神经元个数(8、10、12、15、20),跑 5 折交叉验证,取验证集 MSE 的平均值,选最小的那个。下面是一个可复用的代码框架。
from sklearn.model_selection import KFold def cv_select_hidden(X, y, hidden_list, epochs=500): kf = KFold(n_splits=5, shuffle=True, random_state=42) results = {} for hidden_dim in hidden_list: mse_list = [] for train_idx, val_idx in kf.split(X): X_tr, X_val = X[train_idx], X[val_idx] y_tr, y_val = y[train_idx], y[val_idx] net = BPNet(X.shape[1], hidden_dim) optimizer = optim.Adam(net.parameters(), lr=0.01) criterion = nn.MSELoss() X_tr_t = torch.FloatTensor(X_tr) y_tr_t = torch.FloatTensor(y_tr) X_val_t = torch.FloatTensor(X_val) y_val_t = torch.FloatTensor(y_val) for epoch in range(epochs): net.train() optimizer.zero_grad() loss = criterion(net(X_tr_t), y_tr_t) loss.backward() optimizer.step() net.eval() with torch.no_grad(): val_pred = net(X_val_t) mse = criterion(val_pred, y_val_t).item() mse_list.append(mse) results[hidden_dim] = np.mean(mse_list) return results # 使用 hidden_list = [8, 10, 12, 15, 20] results = cv_select_hidden(X, y, hidden_list) best_hidden = min(results, key=results.get) print(f'最佳隐藏层神经元个数: {best_hidden}')这个框架的关键是每折都重新初始化网络,不能用同一个网络跑五折,否则验证集的信息会泄漏到训练里。另外,epochs设 500 是折中值,如果数据量大可以降到 200,数据量小可以加到 1000。跑完交叉验证后,用最佳神经元个数在全量数据上重新训练一次,作为最终模型。
我自己的习惯是:交叉验证选出来的神经元个数,再手动加 2 到 3 个作为最终值,因为交叉验证是在子集上跑的,全量数据上稍微大一点更稳。这个技巧帮我在这道题上把验证集 MSE 降了大概 15%,比盲目试参数靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取