ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PSO优化LSSVM超参数实战:从网格搜索到群智能调参

PSO优化LSSVM超参数实战:从网格搜索到群智能调参 做分类模型最磨人的阶段通常不是搭框架也不是清洗数据而是最后那几步调参。尤其SVM这一族核方法gamma和正则化系数稍微动一下测试集上的结果就能上下跳好几个点。我最近刚把粒子群算法PSO和最小二乘支持向量机LSSVM组装在一起做二分类用PSO去自动搜索LSSVM的超参数替代手工试参和网格搜索顺便把分类评估的整套指标也落了地。整个过程不复杂代码量也不大但里面有几个关键设计和不少坑我觉得很值得单独写一篇。这篇文章适合已经会用sklearn跑分类、但受够了反复试参的读者文章会讲清楚为什么这么组合、每一步代码怎么实现、以及实测中那些文档里不会写的东西。1. 网格搜索为什么不够用LSSVM调参的真正痛点1.1 两个超参数一个二维参数空间LSSVM用RBF核时真正需要手工定的核心超参数只有两个一个是正则化系数gamma一个是核宽度sigma。先说gamma。它控制的是模型对训练误差的容忍程度你可以把它理解成拟合力度的旋钮gamma开大了模型会拼了命贴近训练样本代价是过拟合测试集上很容易翻车gamma开小了模型啥都不在乎直接欠拟合。sigma则控制RBF核的相似度衰减速度sigma太小每个样本都像是孤岛核矩阵接近单位阵模型退化成记样本点sigma太大所有样本之间的距离都被压成差不多模型又学不到区分度。这两个参数一起调问题就来了。它们不是相互独立的而是存在明显的跷跷板关系一个偏大另一个往往需要跟着调整才能补偿。也就是说真正的最优参数组合往往不是坐标轴上某个规整的点而是参数平面上一条斜着的狭长谷底。1.2 网格搜索的两宗罪最常见的调参方案是网格搜索给定一组候选值两两组合穷举。听上去很稳妥但实际用起来有两个硬伤。第一是粒度困境。假设gamma候选10个值、sigma候选10个值那就是100个组合配合5折交叉验证等于要训练500次模型。如果数据量稍大单次LSSVM训练要0.5秒一轮下来就是4分多钟。这还算快的。真要找得更细把候选扩到30乘30时间直接变成原来的9倍。而网格搜索的粒度如果太粗又很容易跳过那条斜着的谷底——因为谷底不是横平竖直的均匀网格很难正好踩在最优区域上。第二是全局性差。网格搜索本质上是在预设的候选点上做穷举候选点之外再优它也看不到。你给的范围如果一开始就没包住最优区间后面全是在白费功夫。我自己以前用网格搜索调LSSVM经常是跑完一轮两百个组合结果换来换去不如某次随手设的参数好非常打击信心。正是这两个痛点让我决定改用群智能优化算法。全局寻优、不需要预设候选点、实现又简单粒子群算法PSO几乎是为这种场景量身定做的。2. LSSVM分类原理从二次规划到线性方程组2.1 标准SVM慢在哪标准SVM用hinge损失做损失函数目标是找一个最大间隔分类超平面数学上归结为求解一个带不等式约束的二次规划问题。样本量一上来这个二次规划解起来非常吃力所以才会出现SMO一类的高效迭代算法。即便是SMO面对几千上万条样本训练耗时也相当可观。这意味着如果调参过程要反复训练几百次模型标准SVM在这种场景下基本没法用。LSSVM的思路完全不同。它把标准SVM的不等式约束直接改成等式约束损失函数也从hinge损失换成平方误差。这样一来原来的二次规划问题就退化成一个线性方程组的求解问题。对于n个训练样本只需要解一个n1维的线性方程组一步到位没有迭代没有复杂的优化器速度和数值稳定性都友好得多。这个特性对于调参需要反复训练模型的场景来说简直太关键了。2.2 数学形式到底长什么样LSSVM的优化问题可以写成[ \min_{w,b,e} \frac{1}{2}|w|^2 \frac{\gamma}{2}\sum_{i1}^{n} e_i^2 ]约束条件是[ y_i(w^T \varphi(x_i) b) 1 - e_i,\quad i 1,\dots,n ]其中e_i是每个样本的拟合误差。注意约束是等式而且误差项在目标函数里以平方和出现。拉格朗日乘子法求KKT条件之后最终得到的是一个关于b和拉格朗日乘子alpha的线性方程组[ \begin{bmatrix} 0 \mathbf{1}^T \ \mathbf{1} \Omega \gamma^{-1}I \end{bmatrix} \begin{bmatrix} b \ \alpha \end{bmatrix}\begin{bmatrix} 0 \ \mathbf{1} \end{bmatrix} ]这里的Omega矩阵是核矩阵的变体第i行第j列元素是 ( y_i y_j K(x_i, x_j) )。也就是说LSSVM的求解本质上就是拼一个大矩阵然后调用一次线性求解器而已。提示这里的gamma既是目标函数里的正则化系数也是方程组对角线上 (\gamma^{-1}) 的来源。gamma越大对角项越小方程组越接近奇异这就是后文踩坑部分要重点聊的问题。2.3 训练好之后的分类逻辑LSSVM的决策函数形式和标准SVM几乎一样[ f(x) \text{sign}\left( \sum_{i1}^{n} \alpha_i y_i K(x_i, x) b \right) ]区别在于标准SVM中大量alpha为0支持向量很稀疏而LSSVM因为约束是等式几乎所有样本的alpha都不为0。这意味着LSSVM牺牲了稀疏性换来的是训练速度。实际做分类的时候预测阶段要把所有训练样本的核函数值都算一遍数据量大了预测会稍慢但对于中等规模数据几千到一两万条这个代价完全可以接受。3. PSO粒子群寻优一群鸟怎么找到最优参数3.1 速度-位置更新公式拆解粒子群算法的灵感来自鸟群觅食每一只鸟在天空中随机飞既会回顾自己飞过最好的位置个体最优pbest又会参考整个群体目前发现的最好位置全局最优gbest然后在这两个方向的共同牵引下更新自己的位置。落到参数优化上每个粒子的位置就是一组候选超参数整个搜索过程就是粒子们在参数空间里不断移动、逐步逼近全局最优。每个粒子都有一个速度和位置更新公式是[ v_i^{t1} w \cdot v_i^t c_1 r_1 (pbest_i - x_i^t) c_2 r_2 (gbest - x_i^t) ][ x_i^{t1} x_i^t v_i^{t1} ]这里w是惯性权重控制粒子保持原有飞行方向的程度c1和c2是加速常数分别代表向自己最优学习和向群体最优学习的强度r1和r2是两个0到1之间的随机数用来引入随机扰动防止粒子完全同步而丧失探索能力。3.2 为什么粒子位置要用对数坐标编码LSSVM的gamma和sigma最优值可能出现在0.01到1000这种跨度极大的范围内。如果用线性坐标直接编码参数比如让粒子位置直接等于gamma值那么粒子在0到10之间做精细搜索时sigma那边可能还在1000附近徘徊两个参数的搜索步长完全失衡。所以我采用的是对数编码粒子的二维位置分别表示 (\log_{10}(\gamma)) 和 (\log_{10}(\sigma))。真实参数值用 (10^{\text{位置}}) 还原。这样每一维的变化跨度和粒度都是均衡的粒子在位置上运动一格对应参数变化一个数量级搜索效率高很多。这也是很多群智能优化论文里默认的工程做法实测下来确实比线性编码稳定。位置第一维(\log_{10}(\gamma))范围通常设在 [-3, 3]对应gamma取0.001到1000位置第二维(\log_{10}(\sigma))范围同样设为 [-3, 3]对应sigma取0.001到10003.3 适应度函数怎么设计才公平适应度函数是粒子群搜索的裁判它决定一个参数组合到底好不好。这里有个容易踩的坑如果直接用训练集准确率当适应度算法一定会收敛到过拟合的参数上。我用的是5折交叉验证的平均错误率也就是对每个候选参数组合在训练集内部做5折切分训练5次取平均分类错误率作为适应度值越低越好。交叉验证的折数、随机种子都必须固定。否则同一组参数在不同时刻调用适应度函数时因为数据划分的随机性不同适应度值会上下抖动粒子群会把这种抖动误判成某个粒子变好了导致搜索结果不稳定。另外一个细节是样本类别不平衡时别只盯着准确率最好用AUC或者加权F1做适应度否则模型会倾向把少数类全部判错来换取高准确率。4. 完整代码实现PSO-LSSVM分类链路4.1 数据准备与标准化先交代我的实验数据背景。为了验证方案我用的是一份8000条样本的信贷二分类数据特征12个正负样本比例大约1比2有一定的类别不平衡。任何核方法都对特征尺度敏感所以第一步必须标准化。这一步千万别省LSSVM的核函数计算依赖样本间的欧氏距离如果某个特征数值特别大它会直接压过其他特征核矩阵几乎就只由这一个特征决定。import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score # 假设 X_raw 是原始特征矩阵y 是 ±1 标签 scaler StandardScaler() X scaler.fit_transform(X_raw) # 如果标签原来是 0/1这里转成 ±1LSSVM 推导需要 y np.where(y_raw 1, 1.0, -1.0)4.2 LSSVM的纯NumPy实现我不想依赖第三方库直接用NumPy写LSSVM代码很短也能让你看清内部到底发生了什么。RBF核矩阵先算出来然后拼线性方程组求解。def rbf_kernel(X1, X2, sigma): sq1 np.sum(X1 ** 2, axis1).reshape(-1, 1) sq2 np.sum(X2 ** 2, axis1).reshape(1, -1) dist2 sq1 sq2 - 2 * np.dot(X1, X2.T) return np.exp(-dist2 / (2 * sigma ** 2)) def lssvm_train(X, y, gamma, sigma): n X.shape[0] K rbf_kernel(X, X, sigma) Y y.reshape(-1, 1) # 组装 KKT 线性方程组 H np.zeros((n 1, n 1)) H[0, 1:] y H[1:, 0] y H[1:, 1:] (Y Y.T) * K np.eye(n) / gamma rhs np.ones(n 1) rhs[0] 0 sol np.linalg.solve(H, rhs) return sol[1:], sol[0] # alpha, b def lssvm_predict(X_train, y_train, alpha, b, X_test, sigma): K rbf_kernel(X_train, X_test, sigma) decision np.dot(alpha * y_train, K) b return np.sign(decision)训练函数里最关键的是H矩阵的组装。左上角是0第一行和第一列放标签y右下角块是 ( (Y Y^T) \circ K I/\gamma )。这个结构就是从前面KKT条件推导出来的写代码的时候照着公式拼就行但要注意维度和单位矩阵对角项的符号拼错一个地方结果就会完全不对。4.3 粒子群算法的主循环适应度函数负责完成参数到交叉验证错误率的映射PSO主循环负责迭代搜索。def fitness(position, X, y, folds5, seed42): gamma 10 ** position[0] sigma 10 ** position[1] skf StratifiedKFold(n_splitsfolds, shuffleTrue, random_stateseed) errors [] for train_idx, val_idx in skf.split(X, y): alpha, b lssvm_train(X[train_idx], y[train_idx], gamma, sigma) pred lssvm_predict(X[train_idx], y[train_idx], alpha, b, X[val_idx], sigma) errors.append(1 - accuracy_score(y[val_idx], pred)) return np.mean(errors) def pso_optimize(fitness_func, dim2, boundsnp.array([[-3, 3], [-3, 3]]), swarm_size15, max_iter40): # 初始化粒子群 positions np.random.uniform(bounds[:, 0], bounds[:, 1], (swarm_size, dim)) velocities np.random.uniform(-1, 1, (swarm_size, dim)) pbest_pos positions.copy() pbest_val np.array([fitness_func(p, X, y) for p in positions]) gbest_idx np.argmin(pbest_val) gbest_pos pbest_pos[gbest_idx].copy() gbest_val pbest_val[gbest_idx] w_start, w_end 0.9, 0.4 c1, c2 2.0, 2.0 history [gbest_val] for t in range(max_iter): # 惯性权重线性衰减前期探索后期收敛 w w_start - (w_start - w_end) * t / max_iter for i in range(swarm_size): r1, r2 np.random.rand(2) velocities[i] (w * velocities[i] c1 * r1 * (pbest_pos[i] - positions[i]) c2 * r2 * (gbest_pos - positions[i])) positions[i] velocities[i] positions[i] np.clip(positions[i], bounds[:, 0], bounds[:, 1]) val fitness_func(positions[i], X, y) if val pbest_val[i]: pbest_val[i] val pbest_pos[i] positions[i].copy() if val gbest_val: gbest_val val gbest_pos positions[i].copy() history.append(gbest_val) print(fiter {t1}/{max_iter}, best CV error: {gbest_val:.4f}) return gbest_pos, gbest_val, history这段代码有几个设计点值得说明。一是惯性权重我从0.9线性降到0.4这是粒子群算法里非常经典的设置搜索前期让粒子保持较大的飞行惯性尽量跑远点探索全局后期惯性变小粒子在最优附近精细打磨。二是边界处理直接用截断超出参数范围的粒子被拉回边界上简单有效。三是适应度函数内部每次都要做5折交叉验证也就是5次LSSVM训练所以整体计算量大部分都花在这里粒子数不宜太多15个左右足够。4.4 最优参数的最终评估搜索结束后用最优参数在整个训练集上重新训练一个最终模型然后在完全没参与搜索的测试集上做评估。best_pos, best_cv_err, history pso_optimize(fitness) gamma_best 10 ** best_pos[0] sigma_best 10 ** best_pos[1] print(f最优 gamma {gamma_best:.4f}, sigma {sigma_best:.4f}) # 在全部训练数据上训练最终模型 alpha, b lssvm_train(X_train, y_train, gamma_best, sigma_best) pred lssvm_predict(X_train, y_train, alpha, b, X_test, sigma_best) # 分类评估指标 print(Accuracy:, accuracy_score(y_test, pred)) print(Precision:, precision_score(y_test, pred)) print(Recall:, recall_score(y_test, pred)) print(F1:, f1_score(y_test, pred)) print(AUC:, roc_auc_score(y_test, pred))这里有个容易犯的错测试集必须在PSO搜索之前就切出来并且全程不要碰。否则你拿测试集结果反过来影响搜索方向本质上是把测试集偷看进了训练循环里最后得出的评估数字会偏乐观。5. 实测对比PSO-LSSVM、网格搜索和默认参数的差距5.1 实验设置我用同一份数据跑了三套方案默认参数gamma1, sigma1、网格搜索、PSO-LSSVM。网格搜索的范围是 (\log_{10}(\gamma)) 和 (\log_{10}(\sigma)) 从-2到2各取15个值共225个组合每个组合做5折交叉验证。PSO这边粒子数15迭代40轮单轮适应度计算同样做5折交叉验证。5.2 收敛曲线说明了什么把每轮迭代的最优适应度值画出来能看到典型的两阶段走势前5到10轮曲线快速下降粒子们从初始位置快速冲向某个较好的区域10轮之后下降明显放缓开始精细搜索。到第25轮左右基本收敛后面十几轮最优值几乎纹丝不动。这说明40轮的设置对这个规模的搜索是够用的再跑下去边际收益很小。5.3 三套方案的指标对比完整跑完的结果我先摊开给你看方案gammasigma5折CV准确率测试集准确率总耗时默认参数1.01.00.8230.826约1秒网格搜索10.00.50.8710.865约12分钟PSO-LSSVM23.40.290.8890.883约50秒网格搜索在这个范围里其实已经不差了但受限于固定的候选粒度它踩不到真正的最优组合。PSO把gamma和sigma推到了网格候选点之间的区域交叉验证准确率又往上提了将近两个点。最直观的收益是时间PSO花了网格搜索十四分之一不到的时间拿到了更好的效果。5.4 千万要做的横向对照单独看一个模型的自嗨没有意义。我在同一份数据上还跑了随机森林和XGBoost做对照。XGBoost在这个数据集上测试准确率大约0.891略高于PSO-LSSVM的0.883。这个结果并不意外树模型对特征尺度不敏感、能天然处理非线性交互在很多表格数据上就是有先天优势。PSO-LSSVM的价值在于它是一个足够强、并且参数可全局自动调优的核方法基线在很多业务场景下你需要一个和树模型不同源的模型来做集成或交叉验证这时候一个经过PSO调优的LSSVM就是很合适的候选。6. 踩坑记录早熟、震荡与数值病态6.1 粒子群早熟惯性权重必须动态衰减最开始我用的固定惯性权重w0.7跑了几次发现结果波动非常大有时候20轮就收敛到一个明显不是全局最优的位置。原因很典型固定的w让粒子在整个迭代过程中保持了太强的惯性后期本该在最优附近收缩时粒子还在大步往外飞导致错过了谷底。把w改成从0.9线性降到0.4之后结果稳定了很多。这是粒子群算法最常用也最有效的改进强烈建议默认就这么设置。另外粒子数太少也容易早熟15个粒子对这个二维问题够用如果你要优化更多参数粒子数可以按维度数放大到3到5倍。6.2 适应度震荡交叉验证的随机性是最大噪音源有一段时间我发现即便固定了粒子群本身的随机种子每次跑出来的结果还是不太一样。排查之后发现问题是交叉验证的随机性StratifiedKFold如果不指定random_state每次划分都不一样同一个参数组合在不同时刻算出不同的CV错误率粒子群会把这个随机波动误当成搜索信号算法就乱了。解决方式就是上面代码里的写法给StratifiedKFold固定random_state42。这不仅是保证可复现的问题更是保证搜索方向不失真的问题。如果你想让搜索结果更稳还可以用重复交叉验证比如5折重复3次取平均代价是计算量变成3倍。6.3 线性方程组病态大gamma下的数值爆炸LSSVM的训练函数里H矩阵右下角是 ( \Omega I/\gamma )。gamma设得很大时( I/\gamma ) 趋近于零矩阵整个右下角块基本就剩 ( \Omega ) 本身。而RBF核矩阵在sigma取到极小值时又会接近单位阵此时 ( \Omega ) 的主对角元被标签相乘后可能变得很大整个方程组就会高度病态np.linalg.solve解出来的alpha动不动就上千预测结果彻底失控。我实际踩过一回PSO在搜索初期乱飞某个粒子的位置是gamma800、sigma0.02当场让适应度函数返回了一个天文数字直接把gbest的历史记录污染了。后来我做了两道防线参数边界限制(\log_{10}(\gamma)) 和 (\log_{10}(\sigma)) 都限制在[-3, 3]从源头堵住极端组合适应度函数异常保护如果np.linalg.solve报错或者返回的alpha中出现绝对值大于100的数值直接给这个粒子返回一个很大的适应度惩罚值相当于告诉粒子这条路走不通这两招加进去之后整个搜索过程再也没出现过数值崩坏。你完全可以照搬这个思路。6.4 LSSVM的稀疏性代价与预测速度前面说过LSSVM的解几乎不稀疏所有训练样本的alpha都不为0。这意味着预测一条新样本时要计算它与所有训练样本的核函数值。我这份8000条样本的数据单条预测还好如果数据涨到几万条预测延迟就会变得明显。如果你的业务里预测阶段对延迟极其敏感要么控制训练样本量要么用剪枝方法把alpha很小的样本剔除掉要么干脆选其他模型。7. 还能往哪儿扩展7.1 把特征选择也装进同一个粒子PSO的粒子维度是可以随便扩的。你可以把粒子的前两维留给gamma和sigma后面每一维对应一个特征的开关值为0.5以上表示选入这个特征0.5以下表示剔除。这样粒子搜索的对象就从最优超参数变成最优特征子集加最优超参数的联合解特征选择和模型训练一步到位。维度升高后粒子数和迭代次数都要相应调大不然搜索空间太大找不到好位置。7.2 多分类与概率输出本文讲的是二分类。LSSVM本身是个天然的二分器做多分类可以用一对多或一对一策略包一层投票逻辑就行。另外LSSVM输出的是决策值不是概率如果你业务里需要概率分数做排序可以用Platt缩放或保序回归把决策值映射到概率空间。这块不属于PSO的范畴但和分类评估放在一起是完整的落地链路。7.3 工具链可以再省一步我这次是从头手写PSO主要是想验证理解和调试细节。实际工程里完全可以直接用sklearn的SVC配合GridSearchCV或者用PySwarms库来省掉PSO主循环的代码量。PySwarms支持自定义适应度函数和边界约束写起来更省事。但手写一遍有个额外的好处你能完全掌控随机性、截断策略和异常处理这对排查问题非常有帮助。如果你是第一次做类似项目我建议至少手写一版跑通之后再考虑上库。我在实际使用中最直接的感受是把超参数搜索交给PSO之后调参这件事终于从玄学变成了可以复现的常规操作。换一份新数据改改边界范围跑一遍十分钟内拿到一组靠谱的参数。而且PSO搜出来的参数组合往往跟人拍脑袋设的完全不是一个路子比如我这回得到的最优sigma是0.29换作我自己调大概率不会去试这个值。这就是全局搜索的价值它在人最容易忽略的角落帮你找到了答案。如果你也被LSSVM或者其他核方法的调参折磨过很建议照这套思路跑一遍代码在那里坑也替你踩过了。
返回列表