做过多输入单输出拟合预测的朋友应该都有感触:数据预处理花半天,模型结构搭一小时,最后卡在调参环节,能让人怀疑人生。学习率给大了,loss曲线直接震荡发散;给小了,训了一两百轮还在原地打转。全连接层的神经元个数更是门玄学,拍脑袋定的数字,效果全看缘分。我最近在某设备监测项目里,试着把粒子群优化算法(PSO)和卷积神经网络(CNN)搞到一起,用PSO自动去搜CNN的学习率和隐含层神经元个数,实测下来省了不少事。这篇就把我的做法、代码、以及踩过的坑完整记录下来,给正在做回归预测、又被超参数折磨的同学一些参考。
1. 为什么多输入单输出拟合会选CNN,又为什么非上PSO不可
1.1 这类任务到底长什么样
多输入单输出的拟合预测,在工程和业务里太常见了。比如设备健康监测:采集温度、振动幅值、电流、流量、压力等一堆传感器信号,去预测设备的剩余寿命百分比;再比如生产工艺控制:用原料配比、炉温、反应时间等多个工艺参数,预测最终产品的质量指标。这些任务的共同点是,输入特征之间往往存在耦合关系,不是简单线性叠加就能解释的。
传统MLP理论上能拟合任意函数,但输入特征维度高、样本量大的时候,全连接层参数冗余非常严重。所有特征之间都用独立的权重去联系,训练效率低不说,还特别容易过拟合。面对这类多输入单输出的问题,我更倾向于把特征当作一个“一维信号”去做局部提取,而不是一上来就铺全连接层。
1.2 CNN不是图像专属工具
很多人一听CNN就想到图像分类,其实卷积操作的本质,是在一个固定的滑动窗口里提取局部模式。这个思路用到结构化表格数据上同样成立。把每个样本的多个特征看成一段一维信号,用一维卷积(Conv1d)沿着特征维度扫一遍,就能用较少的参数捕捉特征之间的局部耦合关系。
举个例子,设备监测数据里,同一时刻的温度脉动和振动幅值往往会联合反映一些异常征兆,卷积核恰好能捕捉这种组合模式。相比从零开始学所有特征的两两关系,卷积的方式相当于给模型注入了一个“先验”:只看局部、共享权重。所以在特征量在几十到几百这种规模时,一维CNN的拟合效果通常优于同等参数量的MLP,训练速度还更快。这个结论在我后来做的几组对比实验里都得到验证。
1.3 学习率和隐含层神经元个数为何值得专门优化
学习率直接决定梯度下降的步伐。步子太大,loss会在最优解附近来回震荡甚至直接发散;步子太小,训练时间成倍拉长,还容易困在不好的局部区域。实际项目里,学习率差一个数量级,最终精度差几个百分点都很正常。
隐含层神经元个数则决定模型容量。这里说的“隐含层神经元个数”,在我这套方案里指的是卷积层之后、输出层之前那个全连接层的隐藏单元数量。少了,模型学不动数据里的复杂关系;多了,过拟合风险变大,训练时间也跟着涨。麻烦的是这两个参数不是独立的:神经元数量变多,模型容量变大,它偏好合适的学习率也会变化。手动去调,相当于在一个二维空间里瞎摸索。网格搜索可以覆盖,但算一下就知道:学习率试10个值、神经元数试10个值,就是100组CNN训练,每组还要跑几十个epoch,根本不现实。
启发式搜索的价值就在这:它不穷举所有组合,而是靠群体协作,很快锁定最有可能出效果的区域。我第一次跑PSO,大概十几轮就找到了一组远好于人工手调的参数,印象深刻。
2. PSO优化CNN的原理与整体方案设计
2.1 PSO算法的通俗解读
粒子群优化算法模拟的是鸟群觅食。每个“粒子”就是一组候选解,它在搜索空间里飞,同时记住两件事:自己路过时最好的位置(个体历史最优,pbest),以及整个群体目前发现的最好位置(全局最优,gbest)。
每次迭代,粒子通过三个信息更新自己的位置:上一次的运动惯性、飞向pbest的“个体认知”、飞向gbest的“社会认知”。最经典的速度更新公式长这样:
v_new = w * v + c1 * r1 * (pbest - x) + c2 * r2 * (gbest - x) x_new = x + v_neww是惯性权重,控制对上个速度的保留程度;c1、c2是学习因子;r1、r2是0到1的随机数,给搜索留一点随机性。把这个公式拆开了理解,就是三个力拉一个球往前走:旧速度让它别乱停,pbest让它别忘了自己的发现,gbest让它跟随群体的进展。
2.2 编码方式、搜索空间与适应度函数
把CNN的两个关键超参数编码成一个二维粒子,x = [log10(lr), hidden]。学习率这种跨越数量级的参数,直接在一维空间里搜很吃亏。0.001和0.002的差距,跟0.0001和0.001的差距根本没可比性。所以我先对学习率取log10,在[-4, -2]这个区间里搜,也就是实际学习率在0.0001到0.01之间,这样每个数量级都能被均匀覆盖。
隐含层神经元个数在粒子内部用连续值表示,但真正交给网络的时候取整。范围我放在[16, 256],因为再小模型容量不够,再大训练时间猛增、过拟合风险也高。速度更新时对边界做了钳位,防止粒子飞出这个范围。适应度函数用的是验证集上的MSE(均方误差),MSE越小,说明这组参数训练出来的模型泛化能力越好。这里一定要用验证集而不是训练集,不然会选出一组把训练数据背下来的过拟合参数。
2.3 CNN结构哪些固定、哪些可变
为了让搜索问题可解,我把CNN的大部分结构固定了:卷积核数量16个、卷积核大小3、padding为1、自适应平均池化、全连接层一层。可变的只有隐含层神经元个数。这么设计有两方面考虑:一是搜索维度越少,PSO收敛越快;二是经过前期实验,卷积核数量、池化方式这类结构参数在这个任务里并不敏感,与其一起搜,不如固定成可靠的经验值。
完整网络结构是:输入一维特征序列后,接一个Conv1d + ReLU,再过AdaptiveAvgPool1d压缩到固定长度,展平后接全连接层,最后输出一个神经元做回归。整个结构简单干净,PSO每次评估一个粒子,就是完整训练一次这样的网络,然后返回验证MSE作为适应度。
这种“固定大部分、只搜少数关键参数”的做法,其实更符合工程调参的直觉:如果一个网卡在瓶颈上,通常不是通道数差一两个,而是学习率或者容量设置出了问题。
3. 完整实操:代码实现与参数配置
3.1 数据准备与预处理细节
多输入数据喂给CNN之前,标准化是必须的。我习惯按训练集计算均值和标准差,再用同一组统计量去变换验证集和测试集,坚决避免数据泄漏。顺序也很重要:先划分类数据集,再fit标准化器,然后transform验证集和测试集,不能在整个数据集上先标准化再划分。
数据划分比例我用7:1.5:1.5,先把数据分成训练集和临时集,再从临时集里对半切出验证集和测试集。对回归任务,随机打乱时固定随机种子,保证结果可复现。样本量至少要几千条,如果只有几百条,CNN很容易过拟合,这时要么减少搜索的神经元数量上限,要么在网络里加上Dropout和权重衰减。
3.2 PSO主循环代码
下面是最核心的部分,用PyTorch实现。数据准备部分先略过,假设X、y已经读入:
import numpy as np import torch import torch.nn as nn import torch.optim as optim from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 1. 数据划分(X、y为已经读入的多输入特征和单输出目标) X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, random_state=42) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42) # 2. 标准化,只从训练集学习统计量 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_val = scaler.transform(X_val) X_test = scaler.transform(X_test) n_features = X_train.shape[1]CNN回归模型结构如下:
class CNNRegressor(nn.Module): def __init__(self, hidden_dim): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.head = nn.Sequential( nn.Linear(16, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) def forward(self, x): x = x.unsqueeze(1) # (batch, 1, n_features) x = self.conv(x).flatten(1) # (batch, 16) return self.head(x)评价一组参数(学习率取对数后的值 + 隐含层神经元数)的适应度:
def evaluate_params(log_lr, hidden_dim): torch.manual_seed(0) lr = 10 ** log_lr hidden_dim = int(hidden_dim) model = CNNRegressor(hidden_dim) optimizer = optim.Adam(model.parameters(), lr=lr) criterion = nn.MSELoss() X_tr = torch.tensor(X_train, dtype=torch.float32) y_tr = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1) X_va = torch.tensor(X_val, dtype=torch.float32) y_va = torch.tensor(y_val, dtype=torch.float32).unsqueeze(1) train_set = torch.utils.data.TensorDataset(X_tr, y_tr) loader = torch.utils.data.DataLoader(train_set, batch_size=32, shuffle=True) for epoch in range(50): model.train() for xb, yb in loader: optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_pred = model(X_va) val_mse = criterion(val_pred, y_va).item() return val_msePSO主循环:
n_particles = 15 n_iters = 20 w_max, w_min = 0.9, 0.4 c1, c2 = 1.5, 1.5 # 粒子位置:第一维是 log10(lr),第二维是连续化的隐含层神经元数 particles = np.column_stack([ np.random.uniform(-4, -2, n_particles), np.random.uniform(16, 256, n_particles) ]) velocities = np.random.uniform(-0.5, 0.5, (n_particles, 2)) pbest = particles.copy() pbest_fitness = np.full(n_particles, float('inf')) gbest = particles[0].copy() gbest_fitness = float('inf') for it in range(n_iters): w = w_max - (w_max - w_min) * it / (n_iters - 1) for i in range(n_particles): fitness = evaluate_params(particles[i, 0], particles[i, 1]) if fitness < pbest_fitness[i]: pbest_fitness[i] = fitness pbest[i] = particles[i].copy() if fitness < gbest_fitness: gbest_fitness = fitness gbest = particles[i].copy() print(f"Iter {it+1:02d}, best val MSE: {gbest_fitness:.6f}") for i in range(n_particles): r1 = np.random.rand(2) r2 = np.random.rand(2) velocities[i] = ( w * velocities[i] + c1 * r1 * (pbest[i] - particles[i]) + c2 * r2 * (gbest - particles[i]) ) velocities[i] = np.clip(velocities[i], -1, 1) particles[i] += velocities[i] # 边界处理 particles[i, 0] = np.clip(particles[i, 0], -4, -2) particles[i, 1] = np.clip(particles[i, 1], 16, 256) best_lr = 10 ** gbest[0] best_hidden = int(gbest[1]) print(f"best lr: {best_lr:.4f}, best hidden: {best_hidden}")有几个细节需要特别说明。速度更新向量化之后,每个粒子的两个维度各自用一组随机数r1、r2,这样搜索轨迹会更多样。速度钳位在[-1, 1]是为了防止粒子一步跨得太远、直接飞出有效区域。边界处理用的不是反弹而是钳制,在实际测试里更稳。
3.3 适应度评估的公平性问题
评价一组参数时,我固定了torch.manual_seed(0)。这个细节很多人会漏掉。CNN训练本身带随机性,同样的学习率和神经元个数,换一次初始化,验证MSE就可能差不少。如果不固定种子,PSO搜到的“最优粒子”可能只是运气好,换个种子就不行了。固定种子能让同一个参数组合在不同迭代里得到尽量一致的评估,搜索过程才靠谱。
但对于真正要拿到生产环境用的最终模型,我建议最后再用最优参数多跑几个随机种子取平均,避免因为单一初始化导致模型表现不稳定。搜索阶段追求一致性,上线阶段追求稳健性,两者目标不一样。
3.4 PSO参数配置速查表
| 参数 | 取值 | 说明 |
|---|---|---|
| 粒子数 | 15 | 太少探索不足,太多评估成本高 |
| 迭代次数 | 20 | 与粒子数配合,共300次CNN训练 |
| 惯性权重w | 0.9 → 0.4线性衰减 | 前期探索大范围,后期局部细搜 |
| 学习因子c1/c2 | 1.5 / 1.5 | 个人经验和群体经验平衡 |
| 速度上限 | [-1, 1] | 防止粒子飞离有效区间 |
| 学习率搜索范围 | (0.0001, 0.01) | 在对数空间搜索 |
| 神经元个数搜索范围 | (16, 256) | 评估时取整 |
| CNN训练epoch | 50 | 平衡单次评估耗时与收敛程度 |
实际项目中,300次完整CNN训练大概要跑多久,取决于数据量和硬件。我在一个大约5万样本、40个特征的任务上,单卡GPU跑完整个PSO大概两小时,完全在可接受范围内。如果是CPU训练,建议把粒子数降到10、迭代次数降到15,或者先抽30%样本做粗搜。
4. 寻优效果与实验结果对比
4.1 PSO收敛过程
我用某产线传感器数据的实测记录来展示收敛过程。验证集MSE的最优值随迭代变化如下:
| 迭代次数 | 当前最优验证集MSE |
|---|---|
| 第1代 | 0.0123 |
| 第5代 | 0.0048 |
| 第10代 | 0.0027 |
| 第15代 | 0.0020 |
| 第20代 | 0.0018 |
可以看到,前面几代收敛特别快,因为粒子一开始分布在搜索空间各个角落,很快就把有希望的区域找到了。后面几代曲线变平,进入精细搜索阶段,整体符合PSO先探索后开发的特征。如果20代之后MSE还在明显下降,说明搜索空间可能定得偏大或者粒子数不够,可以适当增加迭代次数。
4.2 与人工经验配置的基准对比
为了验证优化的价值,我拿一组人工经验配置做对比:学习率0.001、隐含层神经元64。这个是很多教程默认推荐的点。又加了一组网格搜索里表现较好的配置:学习率0.003、隐含层神经元128。最后看测试集上的表现:
| 模型配置 | 测试MSE | 测试MAE | R² |
|---|---|---|---|
| 人工基准(lr=0.001, hidden=64) | 0.0035 | 0.045 | 0.93 |
| 网格较优(lr=0.003, hidden=128) | 0.0023 | 0.036 | 0.95 |
| PSO最优(lr=0.0032, hidden=136) | 0.0021 | 0.031 | 0.96 |
PSO搜出的最优参数是学习率约0.0032,神经元个数约136。相比人工基准,测试集MSE降低了约40%,MAE降低约31%,R²从0.93提升到0.96。而且这个结果比网格搜索里表现最好的点还要好一些,说明PSO不只在效率上有优势,最终精度上也能找到更细的位置。
4.3 稳定性验证
我还用三个不同随机种子重复跑了PSO,观察搜索结果是否稳定。三次得到的最优学习率在0.0028到0.0036之间,神经元个数在120到144之间,说明这套方案收敛到的区域是比较一致的。
如果重复多次的结果差异很大,就要警惕评估噪声太大或搜索域设计不合理。这时候先不要急着增加迭代次数,而应该把单个粒子评估时的epoch数加大,或者同一个参数组合多训练几次取平均,先把适应度面的信号变干净,再谈优化。信号和搜索效率的平衡,是这类任务里最核心的调参节奏。
5. 常见问题与避坑排查实录
5.1 高频问题速查表
| 问题表现 | 可能原因 | 解决办法 |
|---|---|---|
| 最优学习率总在搜索边界 | 搜索范围设置不合理 | 观察收敛曲线,把边界扩到更大范围重跑 |
| 神经元个数收敛到256上限 | 数据信息量大,模型容量不够 | 扩大搜索上限,同时注意过拟合 |
| 神经元个数收敛到16下限 | 模型容量过剩或数据太少 | 减小特征数或加深网络结构,而不是减小神经元 |
| PSO很早就停止进步 | 惯性权重衰减太快,粒子早熟 | w从0.95开始缓慢递减,降低c2或提高c1 |
| 验证集MSE很高但训练集很低 | 过拟合严重 | 减少epoch、加Dropout、增大神经元个数惩罚 |
| 同样参数两次评估差异很大 | CNN初始化随机性导致适应度噪声 | 固定随机种子或多次评估取平均 |
| PSO总耗时太长 | 粒子多、迭代多、单次训练epoch多 | 先小规模粗搜,再全量细搜,或增大batch size |
5.2 我的排错思路与实操习惯
踩过太多次坑之后,我现在做这类调参任务,会刻意遵循一套快捷流程。正式跑PSO之前,先用小号数据(比如抽30%样本)快速跑一遍,确认搜索方向和参数范围大致合理,再开全量数据。这一步看似多余,实际上能省出不少无效计算时间。我最早就是直接全量开跑,跑到一半发现学习率搜索范围定得太高,一堆粒子在无效区域浪费了十几轮迭代。
另一个习惯是CNN训练epoch不要贪多。我一般先设50个epoch做搜索,如果验证集loss还在明显下降,再把最优参数附近的模型拉出来单独训200个epoch做精调。搜索阶段和时间预算做对抗,精调阶段再追求指标上限,这个分工很实用。网格搜索和贝叶斯优化也能做类似的事,但PSO胜在实现简单、不需要额外维护代理模型,参数组合不多时真的是够用又顺手。