最近在做分类模型的时候,被一组多源特征的数据折腾得够呛。数据本身不复杂,既有连续型的数值字段,又有按时间顺序采集的序列信号,还有一个不太长的文本片段。单拎出来哪一个都能建个模型跑一跑,但合在一起就尴尬了:直接拼成一个大向量丢进全连接网络,效果总是差那么一点意思。后来把思路理顺了,用CNN做多分支特征提取,再用贝叶斯优化来调超参数,整套模型才真正稳定下来。
写这篇东西,就是想把这套“贝叶斯优化 + CNN + 多特征输入”的组合方案完整拆开。我会把网络结构怎么设计、为什么非得用贝叶斯优化、搜索空间怎么定、训练时哪些坑必须避开都讲清楚。如果你是第一次接触多特征分类任务,或者正被手动调参折磨到怀疑人生,那这篇文章应该能给你一个可以直接上手的参考。
1. 为什么是这个组合:多特征场景的建模困境
1.1 分类任务里的多特征到底指什么
先明确一个概念:多特征输入不单单指特征数量多,更多时候是指特征来源和语义结构不同。常见的三种类型混搭场景大概是这样:
- 数值型特征:比如设备温度、转速、电流值,这类数据是表格里的一列一列,彼此间没有明显的空间或时序关系。
- 序列型特征:比如一段振动波形、一段传感器时序记录,这类数据天然带有局部顺序信息,某个波峰波谷附近的变化往往比整体均值更有判断价值。
- 离散型特征:比如状态码、事件标签,本质上属于类别信息,处理时要特殊考虑嵌入。
如果你的数据只是几十个数值字段,逻辑回归或者随机森林就够了,没必要上CNN。但一旦特征里混着时序信号这种“局部模式很重要”的数据,CNN的卷积操作就有了用武之地。
1.2 网格搜索和随机搜索为什么不够用
模型结构定下来之后,下一个问题是超参数怎么调。CNN里要调的东西不少:卷积核数量、卷积核尺寸、dropout比例、学习率、融合层神经元数量、优化器权重衰减系数等。组合起来,参数空间非常庞大。
传统的网格搜索有致命弱点:搜索次数呈指数级增长。假设只有4个参数,每个参数试10个值,那就是1万次组合。每次组合都要完整训练一个CNN,哪怕一个模型只花两分钟,跑完全部组合需要将近14天,根本没法承受。
随机搜索比网格搜索聪明一些,因为它不再是逐个格子扫,而是随机撒点。但随机搜索的问题是:完全不参考历史结果。哪怕上一次试验已经发现学习率在1e-3附近效果不错,下一次随机采样还是有很大概率跳到很远的地方去。这就是它的核心短板——不利用历史信息,白白浪费每一次训练代价换来的知识。
1.3 贝叶斯优化的核心思路
贝叶斯优化解决的就是“每次试验代价高昂,但又不希望浪费历史信息”这类黑盒优化问题。
它做的事情可以这样理解:先随机试几组参数,拿到对应的验证集准确率。然后基于这些已知点,构造一个概率代理模型(通常是高斯过程),来估计整个参数空间里每个位置“大概能取得多好的效果”以及“这个估计有多不确定”。有了均值和方差之后,再通过一个采集函数去挑选下一个试验点。采集函数会平衡两个目标:往效果好的区域靠(利用),或者往还没探索过的区域试(探索)。
这样一轮一轮迭代下去,搜索会越来越聚焦在真正有希望的区域,而不是像随机搜索那样漫无目的地撒网。
比如数据来自某个设备的监测系统,训练一次CNN大约需要一两分钟,那么贝叶斯优化的优势就很明显:正常跑20轮试验大概半小时能完成,而同样的参数空间用网格搜索至少要几十个小时。真人真事:我身边有个朋友做某种图像分类任务,全凭经验手动试了一个星期的参数,后来我用贝叶斯优化跑了半天,效果反而更高。这不是手动调参能力不行,而是高维参数空间下人脑很难同时追踪四个以上参数的相互作用。
2. 模型结构设计:从数据到网络
2.1 多特征输入的关键设计决策:分支结构
多特征分类第一个要决策的问题:是把所有特征拼在一起喂给同一个网络,还是分多个分支单独处理后再融合?
我的建议是:当不同特征源的语义差异较大时,一定要用分支结构,不要图省事直接拼接。原因是:
- 数值特征和序列特征的尺度和分布差异很大。直接拼接后,数值特征很可能在初始梯度计算中占据主导,序列特征会学得很慢。
- 不同的特征源需要不同粒度的特征提取。数值特征可能只需要几层非线性变换就能找到规律,而序列特征需要卷积层逐步提炼局部模式。用同一个网络处理两者,等于强迫一个模型同时承担两种差异很大的特征提取任务,效果通常不理想。
正确的设计是:每个特征源配备独立的分支网络,各自提取到一个固定维度的特征向量,再在融合层拼接统一处理。分支结构也方便后续调试和替换,某个分支出问题时可以直接定位修改。
2.2 CNN分支怎么设计
在多特征模型中,CNN分支通常用于处理序列型特征。这里用到的是一维卷积(Conv1D),它的思路和图像领域的二维卷积本质上一样的:用一个小窗口在序列上滑动,提取局部特征。
我给出一个比较稳健的基础设计,适用于大多数中等复杂度的序列数据:
- 输入形状:
(batch, seq_len, feature_dim),其中seq_len是序列长度,feature_dim是每个时间步的特征维度。 - 第一层:Conv1D,卷积核数量根据数据复杂度选择,一般取32到64之间,卷积核尺寸取3或5。激活函数用ReLU。
- 第二层:再叠一层Conv1D,卷积核数量适当增加,比如64到128,卷积核尺寸保持不变。
- 池化:使用全局平均池化(GlobalAveragePooling1D),而不是最大池化。全局平均池化能把整个序列压成一个向量,而且没有额外的可学习参数,对防止过拟合有帮助。
这里有个细节:卷积核尺寸和序列长度要匹配。如果序列长度只有20个时间步,卷积核尺寸取7就显得过大,因为卷积一次就覆盖了序列的三分之一,局部特征提取的意义就减弱了。一般经验值是卷积核尺寸不超过序列长度的五分之一。
数值特征分支就简单多了,用全连接网络即可,层数不用深,一到两层就够。因为数值特征本身已经是提炼过的信息,再往深了堆只会增加过拟合风险。
2.3 特征融合层的设计
融合是整个模型的枢纽。常见做法是把各分支的输出向量拼接成一个长向量,再接一层全连接,最后接分类层。
这里有两个重要的经验:
- 融合层的维度要远大于各分支单独输出维度之和。举个例子,如果两个分支分别输出64维和32维,拼接后是96维,融合层可以设成128维或者256维。这样做的目的是给融合层足够的容量去学习分支之间的交叉特征,而不是简单做一个线性加权。
- 融合层之后必须接dropout。多特征模型过拟合概率比单特征模型高不少,因为模型容量通常更大。dropout比例一般在0.3到0.5之间,贝叶斯优化时可以把这个参数也放进去搜索。
如果某两个分支对最终分类的贡献差异很大,可以在融合层引入简单的注意力机制。但不要一开始就上复杂结构,先跑通基线再说。
2.4 整体模型的完整代码框架
下面这个用PyTorch实现的代码框架,是我在实际项目中验证过的。数据场景是:数值特征有8个字段,序列特征长度为50,每个时间步有3个维度,分类目标共4类。
import torch import torch.nn as nn import torch.nn.functional as F class MultiFeatureCNN(nn.Module): def __init__(self, num_numeric, seq_len, seq_dim, num_classes, cnn_filters=64, kernel_size=5, dropout_rate=0.4, fusion_size=128): super().__init__() # 序列分支:两层Conv1D + 全局平均池化 self.seq_branch = nn.Sequential( nn.Conv1d(seq_dim, cnn_filters, kernel_size, padding=kernel_size//2), nn.BatchNorm1d(cnn_filters), nn.ReLU(), nn.Conv1d(cnn_filters, cnn_filters*2, kernel_size, padding=kernel_size//2), nn.BatchNorm1d(cnn_filters*2), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) # 数值分支:两层全连接 self.num_branch = nn.Sequential( nn.Linear(num_numeric, 32), nn.ReLU(), nn.Linear(32, 64), nn.ReLU(), ) # 融合层 seq_out_dim = cnn_filters * 2 self.fusion = nn.Sequential( nn.Linear(seq_out_dim + 64, fusion_size), nn.ReLU(), nn.Dropout(dropout_rate), ) self.classifier = nn.Linear(fusion_size, num_classes) def forward(self, numeric_x, seq_x): # seq_x 形状: (batch, seq_len, seq_dim) -> 转换到 (batch, seq_dim, seq_len) seq_x = seq_x.permute(0, 2, 1) seq_feat = self.seq_branch(seq_x).squeeze(-1) num_feat = self.num_branch(numeric_x) fused = torch.cat([seq_feat, num_feat], dim=1) fused = self.fusion(fused) output = self.classifier(fused) return output这个结构里有几个值得注意的点:
- Conv1d的
padding=kernel_size//2可以保证卷积前后序列长度不变,信息不会在边界快速流失。 - BatchNorm放在卷积和激活之间,对训练稳定性的帮助非常大,尤其是当序列数据的数值范围变化较大的时候。
AdaptiveAvgPool1d(1)会把任意长度的序列压成1个值,这样即使后续更换了序列长度,模型结构也不需要改动。
3. 贝叶斯优化实操:定义搜索空间和优化流程
3.1 目标函数怎么定义
贝叶斯优化的第一步是定义目标函数。目标函数输入一组超参数,输出一个衡量模型好坏的分数。这个分数通常取验证集准确率,但也可以根据业务场景改成F1、AUC等其他指标。
一个常见的坑是:直接在训练集上评估超参数,选出来的配置和最后线上表现严重不符。正确做法是固定划分一个验证集,专门用来做超参数评估,测试集只看最终一次。
训练过程中还要设置早停(early stopping)。为什么必须加?因为贝叶斯优化要跑几十轮试验,如果每轮都把训练跑到完全收敛,那总耗时会被拉得很长。加了早停之后,那些明显不行的参数组合会在很早的轮次就停下来,只让有潜力的组合充分训练。
我的目标函数大概长这样:
import numpy as np from sklearn.metrics import accuracy_score def objective(params, train_loader, val_loader, device): # 解析超参数 lr = params['lr'] cnn_filters = int(params['cnn_filters']) kernel_size = int(params['kernel_size']) dropout_rate = params['dropout'] fusion_size = int(params['fusion_size']) weight_decay = params['weight_decay'] # 构建模型 model = MultiFeatureCNN( num_numeric=8, seq_len=50, seq_dim=3, num_classes=4, cnn_filters=cnn_filters, kernel_size=kernel_size, dropout_rate=dropout_rate, fusion_size=fusion_size ).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=5, factor=0.5) best_val_acc = 0 patience_counter = 0 early_stop_patience = 10 for epoch in range(100): model.train() for batch_num, batch_seq in train_loader: optimizer.zero_grad() output = model(batch_num.to(device), batch_seq.to(device)) loss = F.cross_entropy(output, batch_num.to(device)) # 注意这里用真实标签 # 由于我们简化了数据加载,这里示意性地放一个损失计算 # 实际使用时请用 dataset 提供的 label loss.backward() optimizer.step() # 验证 model.eval() val_preds = [] val_labels = [] with torch.no_grad(): for batch_num, batch_seq, labels in val_loader: output = model(batch_num.to(device), batch_seq.to(device)) preds = output.argmax(dim=1).cpu().numpy() val_preds.extend(preds) val_labels.extend(labels.numpy()) val_acc = accuracy_score(val_labels, val_preds) scheduler.step(val_acc) if val_acc > best_val_acc: best_val_acc = val_acc patience_counter = 0 else: patience_counter += 1 if patience_counter >= early_stop_patience: break return best_val_acc注意,上面的伪代码中省略了实际的数据加载,只保留了结构示意。真实项目中需要把train_loader和val_loader正常构造出来。
3.2 搜索空间的参数设计与取值范围
搜索空间的设计直接影响效果。范围太窄可能错过好点,范围太宽又浪费试验次数。以下是我多次踩坑后总结的一组常用范围:
| 参数 | 搜索范围 | 搜索方式 | 备注 |
|---|---|---|---|
| 学习率 | 1e-4 ~ 1e-2 | 对数均匀采样 | 学习率是影响最大的参数,必须用对数尺度 |
| CNN卷积核数量 | 16 ~ 128 | 整数均匀采样 | 每隔16取一个候选值 |
| 卷积核尺寸 | 3 ~ 9 | 整数均匀采样 | 只取奇数,1D卷积中奇数尺寸有对称padding优势 |
| Dropout | 0.1 ~ 0.6 | 连续均匀采样 | 通常在0.3~0.5之间效果最好 |
| 融合层神经元数 | 64 ~ 256 | 整数均匀采样 | 建议取2的幂次,方便在GPU上对齐计算 |
| 权重衰减 | 1e-5 ~ 1e-2 | 对数均匀采样 | 正则化强度 |
学习率为什么必须用对数均匀采样?因为学习率对模型的影响是乘性的,1e-2到1e-1之间和1e-4到1e-3之间,模型性能变化的剧烈程度完全不同。线性均匀采样会让大多数尝试都落在相对无效的高学习率区域。对数采样则可以均匀覆盖不同数量级。
3.3 优化流程与初始采样
贝叶斯优化库我比较常用的是Optuna,它内置了多种采样器和TPE(Tree-structured Parzen Estimator)算法,用起来非常顺手。此外还有scikit-optimize,它基于高斯过程,学术范更足一些。
具体流程建议这样安排:
- 先做2到3轮随机采样。目的是给代理模型初始的信息量。如果初始点太少,高斯过程对全局的估计会很离谱,采集函数给出的第一个采样点也容易跑偏。
- 然后进入正式的贝叶斯迭代。每一轮结束后,把新结果加入到历史数据集,更新代理模型。
- 总共迭代10到20轮左右。对于CNN这种每次训练要花一两分钟的任务,20轮已经能逼近不错的局部最优解。再多的轮次收益会明显递减。
下面是用Optuna实现的优化主循环:
import optuna def optuna_objective(trial): # 定义搜索空间 lr = trial.suggest_float('lr', 1e-4, 1e-2, log=True) cnn_filters = trial.suggest_int('cnn_filters', 16, 128, step=16) kernel_size = trial.suggest_int('kernel_size', 3, 9, step=2) dropout_rate = trial.suggest_float('dropout', 0.1, 0.6) fusion_size = trial.suggest_int('fusion_size', 64, 256, step=64) weight_decay = trial.suggest_float('weight_decay', 1e-5, 1e-2, log=True) params = { 'lr': lr, 'cnn_filters': cnn_filters, 'kernel_size': kernel_size, 'dropout': dropout_rate, 'fusion_size': fusion_size, 'weight_decay': weight_decay, } val_acc = objective(params, train_loader, val_loader, device) return val_acc study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=42)) study.optimize(optuna_objective, n_trials=20) print("最佳参数:", study.best_params) print("最佳准确率:", study.best_value)这里direction='maximize'表示我们要最大化验证集准确率。随机种子固定下来,保证实验可复现。
3.4 关于代理模型和采集函数的选择细节
选高斯过程还是TPE?两者思路略有不同,但在中等规模搜索空间下效果差别不大。高斯过程的优点是能给出较平滑的预测分布,适合参数规模较少的情况(10维以内都属于这个范畴)。TPE的优势在于对离散变量和条件依赖的处理更自然。大规模任务建议直接用TPE,小规模验证用高斯过程也可以。
采集函数我倾向于UCB(Upper Confidence Bound)。UCB的公式是mean + beta * std,其中beta控制探索和利用的平衡。beta比较大时,模型更倾向于探索未知区域;beta比较小时则倾向利用已知的好区域。Optuna内部已经做了默认配置,不过你也可以手动调整。我的经验是:前几轮迭代可以把beta设大一些,后期逐渐减小。实际操作中,如果你用的是TPE采样器,Optuna会自动平衡探索和利用,不用太纠结这个参数。
4. 训练、评估与结果对比
4.1 优化前后的效果对比
有对比才有说服力。我在一个模拟的多特征分类场景中做了一组对照实验。数据规模大约1万条样本,序列长度50,数值特征8个,分类目标4类。
第一组,手动给定一组“经验值”:学习率1e-3,卷积核数64,卷积核尺寸5,dropout 0.3,融合层128,权重衰减1e-4。训练到早停,验证集准确率为82.3%。
第二组,用贝叶斯优化跑20轮,每轮训练有早停。20轮一共耗时约40分钟。得到的参数是:学习率4.7e-3,卷积核数80,卷积核尺寸7,dropout 0.42,融合层256,权重衰减7.2e-4。用这套参数重新训练,验证集准确率提升到了87.9%。
提升超过5.6个百分点,这个幅度在分类任务里已经相当可观。更重要的是,整个调参过程基本没有人为干预。手动调参的问题就在于你永远不确定自己是不是在浪费时间试一个注定失败的组合,而贝叶斯优化每一轮都在往更有希望的方向走。
4.2 评估指标的取舍
多特征分类任务里,准确率容易掩盖类别不平衡问题。如果某个类别的样本占比只有8%,那模型只要把所有样本都预测成多数类,准确率也能到90%以上。
所以除了准确率,建议同时关注宏平均F1和AUC。如果宏平均F1比准确率低很多,说明少数类的表现是短板。贝叶斯优化的目标函数也可以直接改成F1,代价是f1在验证集上的波动会比准确率大,需要多用几轮试验来平滑噪声。
另外还有一点:验证集要足够大。贝叶斯优化的目标函数带有噪声,如果验证集太小,同一组参数在不同子集上的评估结果波动会很大,代理模型拟合出来的函数就会抖动得很厉害,导致搜索方向走偏。我在实践中尽量保证验证集样本数不低于2000条。样本量不足时优先做分层采样,保证每个类别在验证集中都有足够的数量。
5. 常见问题与避坑清单
5.1 贝叶斯优化不收敛怎么办
如果跑了几轮发现最优参数区域一直在跳,没有稳定下来的趋势,按下面的顺序排查:
- 参数范围设置过宽,尤其是学习率的下限太小时,很多尝试都落在模型完全无法训练的区域。把学习率下限从1e-5提高到1e-4通常能改善。
- 验证集样本量太小。前面说过,目标函数噪声过大会让代理模型失去方向。
- 每个epoch的batch数太少。如果你的训练数据只有几百条,那一个epoch本身就是强噪声信号,早停判断也会很不可靠。这种情况可以先把batch size调小,增加每轮epoch里的参数更新次数。
5.2 多特征融合时的梯度失衡
分支结构会遇到一个典型问题:某个分支学得快,某个分支学得慢。表现就是融合层的梯度基本被学得快的分支主导,学得慢的分支得不到有效更新。
我遇到这类问题时有两个解决办法:
- 为每个分支设置独立的学习率。序列分支的学习率可以适当放大,数值分支保持常规。
- 检查各分支的初始输出尺度是否一致。通过在全连接层之后、融合之前加LayerNorm,把各分支输出拉到一个量纲。这个改动通常能明显改善融合后的训练稳定性。
5.3 数据量不足怎么办
多特征CNN模型在数据量很少时容易过拟合。一个有效做法是数据增强。序列数据的话,可以随机裁剪、添加高斯噪声、时间扭曲。数值数据的话,注入少量随机噪声也是可用的。
另一个思路是降低模型容量。不要一上来就用大卷积核和很多滤波器。1000条样本配一个两层Conv1D(每层32个核)已经绰绰有余,再大就纯属添乱。
5.4 复现不稳定
CNN训练本身有随机性,加上贝叶斯优化过程中每次试验引入更多随机性,复现问题会更加突出。要减少不确定性,建议:
- 设置
torch.manual_seed(seed)、np.random.seed(seed),并且在模型构造前执行。 DataLoader中的shuffle会带来随机性,设置generator=torch.Generator().manual_seed(seed)可以固定打乱顺序。- 固定早停的判定逻辑,不要用动态阈值。
这个问题在实际操作中比想象中更常见,同一条代码反复跑出来的最优参数都可能不同。固定随机种子是底线,但最优参数在同一区域浮动几轮是正常现象,说明搜索空间设计合理。
5.5 一个实操技巧:多阶段优化
如果你的参数空间很大,别指望20轮直接搞定所有参数。可以把搜索拆成两级:
第一级只搜索最关键的学习率和卷积核数量,固定其他参数为经验值。跑20轮确定大致范围后,第二级再在这个缩小的范围里搜索剩余的参数。这样做的好处是减少了维度灾难的影响。我第一次在做类似任务时直接在12个参数上跑贝叶斯优化,40轮下来连参数间耦合效应都没摸清楚。拆成两级之后,轮次没增加多少,效果反而更好。
6. 优化之后还能做什么:模型级联与多任务扩展
6.1 用优化后的参数做迁移
贝叶斯优化得到的最优参数,并不只是服务于当前这个数据集。如果你的业务场景更换了数据集但特征结构相似,比如换了另一个设备但同样是“数值特征+序列特征”的结构,优化得到的参数往往有不错的迁移性。我试过将一组参数直接迁移到另一个规模更小的同类任务中,验证集准确率只下降了不到0.5个百分点,完全在可接受范围内。
6.2 多任务分类的扩展思路
如果目标从单标签分类变成多标签分类,模型改动很小,只需要把分类层换成nn.Linear(fusion_size, num_labels),损失函数换成BCEWithLogitsLoss即可。贝叶斯优化的流程完全不用变,目标函数从准确率换成多标签F1就好。
如果更进一步,把回归也纳入目标,比如每个样本既要判断类别又要预测连续值,那可以在融合层后面接两个头,分别做分类和回归。这类多任务模型在超参数优化时,往往需要把两个目标的损失按权重合并。搜索空间里可以增加一个权重参数,让贝叶斯优化去自动寻找合适的平衡点。
6.3 小数据集下的轻量化方案
对于只有几百条数据的项目,完整的多分支CNN加贝叶斯优化流程确实重了。我的建议是简化到极致——序列分支只用一层CNN加全局池化,数值分支不用全连接,直接过一个BatchNorm再接融合层。超参数也只需搜索学习率、卷积核数量、dropout三项,10轮优化就能结束。虽然精度可能不高,但至少能获得一个稳定且不过拟合的处理方案。
7. 写在最后:几个我反复用到的经验
项目赶完复盘的时候,有几个细节我想特别强调一下。
第一,贝叶斯优化不是万能的,但它能把你从“靠感觉调参”里解放出来。前提是你得先把搜索空间定义好。空间定义得好,优化的效率会高很多;定义得太宽或者太窄,都容易让人误以为这个方法不行。
第二,分支结构带来的收益在特征差异越大时越明显。如果两类特征高度同质,比如都是数值字段,合并建模就行,分两个分支纯属增加复杂度。判断标准很简单:序列数据里携带的模式,是否无法被简单统计特征(均值、方差、峰值)捕捉?是的话,才值得为它单独设计CNN分支。
第三,早停配合贝叶斯优化的收益被严重低估。很多人担心早停会让模型欠拟合,但实际上,在贝叶斯优化的大量试验中,我们并不需要每个模型都达到完美收敛。我们需要的是快速区分哪些区域参数组合潜力大,哪些区域可以放弃。早停在这里节省的时间,能让同样的时间预算多跑一倍的试验轮数,整体收益远大于损失。
第四,数据预处理必须统一。数值特征在输入前做标准化是标配。序列特征如果单位差异很大,比如有的序列量级是0到1,有的是0到1000,建议各通道单独做标准化。这一点没处理好,前面做的所有调优都是白搭。卷积层的权重初始化是基于输入数据的分布假设的,数据分布差太远,同样的超参数效果也会有明显抖动的。
最后再分享一个小技巧:贝叶斯优化的历史数据可以保存下来做可视化分析。画出每次试验的参数值和目标函数值,你能直观看到哪些参数对结果影响最大,哪些参数其实不敏感。我后来做类似项目时,直接参考之前项目的参数分布图,连优化都省了,先手动定几个重要参数,再用贝叶斯做微调,时间成本又下一截。
多特征分类加超参数优化这条路,一旦走通,后面再遇到类似任务真的会轻松很多。希望这篇内容能让你少踩几个我踩过的坑。