从几何直觉到数学推导,从参数拟合到嵌入式部署的全流程笔记。
目录
- 问题设定与符号约定
- 几何直觉:最大间隔分类器
- [原始优化问题(Primal Problem)](#原始优化问题(Primal Problem))
- 拉格朗日乘子法
- 对偶问题的推导
- [SMO 算法求解](#SMO 算法求解)
- [从二分类到多分类:OneVsRest 策略](#从二分类到多分类:OneVsRest 策略)
- [Python 端参数拟合](#Python 端参数拟合)
- [C/C++ 端部署](#C/C++ 端部署)
一、问题设定与符号约定
考虑一个二分类问题。给定训练集:
D = { ( x 1 , y 1 ) , ( x 2 , y 2 ) , ... , ( x n , y n ) } D = \{(\mathbf{x}_1, y_1), (\mathbf{x}_2, y_2), \ldots, (\mathbf{x}_n, y_n)\} D={(x1,y1),(x2,y2),...,(xn,yn)}
其中:
- x i ∈ R d \mathbf{x}_i \in \mathbb{R}^d xi∈Rd 是 d d d 维特征向量
- y i ∈ { + 1 , − 1 } y_i \in \{+1, -1\} yi∈{+1,−1} 是类别标签
- n n n 是样本数量
目标:找到一个超平面将两类样本分开,并且分得"尽可能好"。
超平面方程:
w T x + b = 0 \mathbf{w}^T \mathbf{x} + b = 0 wTx+b=0
其中 w ∈ R d \mathbf{w} \in \mathbb{R}^d w∈Rd 是法向量(决定超平面方向), b ∈ R b \in \mathbb{R} b∈R 是偏置(决定超平面位置)。
二、几何直觉:最大间隔分类器
2.1 为什么要"最大间隔"?
能将两类样本分开的超平面有无数条。SVM 的判据是:选那条让"缓冲带"最宽的。缓冲带越宽,模型对噪声、对新样本的容忍度就越高,泛化能力就越强。
2.2 为什么可以"硬性规定 ∣ w T x i + b ∣ = 1 |\mathbf{w}^T\mathbf{x}_i+b|=1 ∣wTxi+b∣=1"?
等式缩放原理,考虑两条直线方程:
x 1 + x 2 − 2 = 0 与 10 x 1 + 10 x 2 − 20 = 0 x_1 + x_2 - 2 = 0 \quad \text{与} \quad 10x_1 + 10x_2 - 20 = 0 x1+x2−2=0与10x1+10x2−20=0
它们在几何上完全是同一条直线 。也就是说,把 w \mathbf{w} w 和 b b b 同时乘以任意非零常数,超平面位置根本不会动。
既然有这个自由度,那么我们可以利用这个自由度,找到最近的支持向量后,对 w , b \mathbf{w}, b w,b 整体缩放,使得这些最近点恰好满足:
∣ w T x i + b ∣ = 1 |\mathbf{w}^T \mathbf{x}_i + b| = 1 ∣wTxi+b∣=1
目的是统一标尺------就像规定"分割线到最近点的距离 = 1 个单位"。
2.3 间隔宽度的推导
经过缩放约定后:
- 正类支持向量满足 w T x + + b = + 1 \mathbf{w}^T \mathbf{x}_+ + b = +1 wTx++b=+1
- 负类支持向量满足 w T x − + b = − 1 \mathbf{w}^T \mathbf{x}_- + b = -1 wTx−+b=−1
现在我们求这两条平行超平面之间的垂直距离(即缓冲带宽度 Margin)。可以用几何中的向量投影来推导:
- 在两条边界线上各挑一个点,正边界上挑 x + \mathbf{x}+ x+,负边界上挑 x − \mathbf{x}- x−
- 连接这两个点,得到一个向量 ( x + − x − ) (\mathbf{x}+ - \mathbf{x}-) (x+−x−)。这个向量是斜跨在缓冲带两边的
- 怎么求垂直宽度呢?只需要把这个斜向的向量,投影到垂直于边界线的方向上即可
- 超平面的法向量(垂直方向的向量)就是 w \mathbf{w} w。投影到这个方向,就需要用到法向量的单位向量 w / ∥ w ∥ \mathbf{w}/\|\mathbf{w}\| w/∥w∥
将向量 ( x + − x − ) (\mathbf{x}+ - \mathbf{x}-) (x+−x−) 与单位法向量做内积(点乘),就是投影的长度,也就是间隔宽度 Margin:
Margin = ( x + − x − ) ⋅ w ∥ w ∥ = w T x + − w T x − ∥ w ∥ \text{Margin} = (\mathbf{x}+ - \mathbf{x}-) \cdot \frac{\mathbf{w}}{\|\mathbf{w}\|} = \frac{\mathbf{w}^T \mathbf{x}+ - \mathbf{w}^T \mathbf{x}-}{\|\mathbf{w}\|} Margin=(x+−x−)⋅∥w∥w=∥w∥wTx+−wTx−
根据前面的硬性约定:
- 因为 w T x + + b = 1 \mathbf{w}^T \mathbf{x}+ + b = 1 wTx++b=1,所以 w T x + = 1 − b \mathbf{w}^T \mathbf{x}+ = 1 - b wTx+=1−b
- 因为 w T x − + b = − 1 \mathbf{w}^T \mathbf{x}- + b = -1 wTx−+b=−1,所以 w T x − = − 1 − b \mathbf{w}^T \mathbf{x}- = -1 - b wTx−=−1−b
代入上面的公式:
Margin = ( 1 − b ) − ( − 1 − b ) ∥ w ∥ = 1 − b + 1 + b ∥ w ∥ = 2 ∥ w ∥ \text{Margin} = \frac{(1-b) - (-1-b)}{\|\mathbf{w}\|} = \frac{1 - b + 1 + b}{\|\mathbf{w}\|} = \boxed{\frac{2}{\|\mathbf{w}\|}} Margin=∥w∥(1−b)−(−1−b)=∥w∥1−b+1+b=∥w∥2
这就是为什么经过神奇的缩放后,间隔宽度不仅不复杂,反而变成了一个极其纯粹的式子 2 ∥ w ∥ \frac{2}{\|\mathbf{w}\|} ∥w∥2。
关键结论 :要让间隔最大,就要让 ∥ w ∥ \|\mathbf{w}\| ∥w∥ 最小(因为分母越小,间隔 Margin 就越大)!这就是为什么在训练 SVM 时,我们要去最小化 ∥ w ∥ \|\mathbf{w}\| ∥w∥。
三、原始优化问题(Primal Problem)
把上述几何直觉翻译成优化语言:
min w , b 1 2 ∥ w ∥ 2 s.t. y i ( w T x i + b ) ≥ 1 , i = 1 , 2 , ... , n \boxed{ \begin{aligned} \min_{\mathbf{w}, b} \quad & \frac{1}{2}\|\mathbf{w}\|^2 \\ \text{s.t.} \quad & y_i(\mathbf{w}^T \mathbf{x}_i + b) \geq 1, \quad i=1, 2, \ldots, n \end{aligned} } w,bmins.t.21∥w∥2yi(wTxi+b)≥1,i=1,2,...,n
几点说明:
-
为什么是 1 2 ∥ w ∥ 2 \frac{1}{2}\|\mathbf{w}\|^2 21∥w∥2 而不是 ∥ w ∥ \|\mathbf{w}\| ∥w∥?
- 平方让目标函数变得可微( ∥ w ∥ \|\mathbf{w}\| ∥w∥ 在原点不可导)
- 1 2 \frac{1}{2} 21 是为了求导时让平方项的 2 抵消掉,不影响最优解
-
约束 y i ( w T x i + b ) ≥ 1 y_i(\mathbf{w}^T\mathbf{x}_i + b) \geq 1 yi(wTxi+b)≥1 的含义:
- 对正样本( y i = + 1 y_i = +1 yi=+1):要求 w T x i + b ≥ + 1 \mathbf{w}^T\mathbf{x}_i + b \geq +1 wTxi+b≥+1
- 对负样本( y i = − 1 y_i = -1 yi=−1):要求 w T x i + b ≤ − 1 \mathbf{w}^T\mathbf{x}_i + b \leq -1 wTxi+b≤−1
- 综合起来就是"所有点都被正确分类,并且在缓冲带之外"
-
凸二次规划:目标函数是凸的(二次型)、约束是线性的,理论上保证全局最优解唯一存在
要直接在带有不等式约束(必须把每个点都分对)的情况下求最小值是非常困难的。因而采用一套的"三步走"策略,将这个问题转化为了一个更容易用计算机求解的形式。
四、拉格朗日乘子法
4.1 核心思想:把约束融合进目标函数
拉格朗日乘子法的核心思想是"把带约束的问题变成无约束的问题" 。做法是:给每一个约束条件分配一个"罚款系数" α i \alpha_i αi(这就是拉格朗日乘子,且要求 α i ≥ 0 \alpha_i \geq 0 αi≥0),如果约束被打破,就给目标函数加上一笔重罚。
我们将目标和约束融合,构造出一个新的函数,叫做拉格朗日函数 L L L:
L ( w , b , α ) = 1 2 ∥ w ∥ 2 − ∑ i = 1 n α i [ y i ( w T x i + b ) − 1 ] L(\mathbf{w}, b, \boldsymbol{\alpha}) = \frac{1}{2}\|\mathbf{w}\|^2 - \sum_{i=1}^{n} \alpha_i [y_i(\mathbf{w}^T \mathbf{x}_i + b) - 1] L(w,b,α)=21∥w∥2−i=1∑nαi[yi(wTxi+b)−1]
现在的目标变成寻找一个"鞍点":我们要对 w \mathbf{w} w 和 b b b 求极小值 ,同时对 α \boldsymbol{\alpha} α 求极大值(这被称为主问题,Primal Problem)。
4.2 关于 α i ≥ 0 \alpha_i \geq 0 αi≥0 的来源
这是一个纯数学规定。在拉格朗日乘子法中:
- 如果原问题是等式约束 (例如 h ( x ) = 0 h(x) = 0 h(x)=0),那么对应的拉格朗日乘子 α \alpha α 可以是任意实数
- 但如果我们面对的是不等式约束 (SVM 要求的是所有点都在间隔外,即 y i ( w T x i + b ) − 1 ≥ 0 y_i(\mathbf{w}^T\mathbf{x}_i + b) - 1 \geq 0 yi(wTxi+b)−1≥0),著名的 KKT 条件 在数学上严格证明了:为了保证罚函数的有效性,对应于不等式约束的乘子必须是非负的 。因此就有了 α i ≥ 0 \alpha_i \geq 0 αi≥0
直觉解释是"罚款只能朝一个方向罚"------如果允许 α i < 0 \alpha_i < 0 αi<0,那么违反约束反而能"奖励"目标函数下降,整个最优化就失去意义了。
五、对偶问题的推导
5.1 强对偶性:交换求极值的顺序
直接求解依然困难,但好在这个问题满足斯拉特条件(Slater's condition) ,因此,可以交换求极值的顺序。
原本是先对 α \boldsymbol{\alpha} α 求最大,再对 w , b \mathbf{w}, b w,b 求最小;现在我们变成先对 w , b \mathbf{w}, b w,b 求最小,再对 α \boldsymbol{\alpha} α 求最大(这被称为对偶问题,Dual Problem)。
5.2 第一步:把 α \boldsymbol{\alpha} α 当作常数,对 w \mathbf{w} w 和 b b b 求偏导
对 w \mathbf{w} w 求导等于 0:
∂ L ∂ w = w − ∑ i = 1 n α i y i x i = 0 ⟹ w = ∑ i = 1 n α i y i x i \frac{\partial L}{\partial \mathbf{w}} = \mathbf{w} - \sum_{i=1}^{n} \alpha_i y_i \mathbf{x}i = 0 \quad\Longrightarrow\quad \boxed{\mathbf{w} = \sum{i=1}^{n} \alpha_i y_i \mathbf{x}_i} ∂w∂L=w−i=1∑nαiyixi=0⟹w=i=1∑nαiyixi
这是一个极为重要的结论:那条决定性的法向量 w \mathbf{w} w,实际上仅仅是样本数据点 x i \mathbf{x}_i xi 的线性组合!
对 b b b 求导等于 0:
我们把拉格朗日函数 L L L 展开,只盯着含有 b b b 的项看。 L L L 中含有 b b b 的项为:
− ∑ i = 1 n α i y i b -\sum_{i=1}^{n} \alpha_i y_i b −i=1∑nαiyib
现在,我们对 b b b 求偏导:
∂ L ∂ b = − ∑ i = 1 n α i y i \frac{\partial L}{\partial b} = -\sum_{i=1}^{n} \alpha_i y_i ∂b∂L=−i=1∑nαiyi
为了求极值,我们必须令导数为 0,这就直接得到了第一个等式约束:
∑ i = 1 n α i y i = 0 \boxed{\sum_{i=1}^{n} \alpha_i y_i = 0} i=1∑nαiyi=0
物理意义 :它保证了拉格朗日函数在消去 b b b 之后,问题是有下界的(否则如果这个和不为 0, b b b 趋于无穷大或无穷小时,整个函数值就会直接崩溃到负无穷,就找不到极小值了)。
5.3 第二步:化简为"纯 α \boldsymbol{\alpha} α"的对偶问题
现在,我们把上面得到的两个结论( w \mathbf{w} w 的表达式,以及 ∑ α i y i = 0 \sum \alpha_i y_i = 0 ∑αiyi=0)代回到原先极其复杂的拉格朗日函数 L L L 中。
代数抵消使所有含有 w \mathbf{w} w 和 b b b 的项都消失,目标函数变成了一个只包含 α \boldsymbol{\alpha} α 和样本数据的式子:
max α ∑ i = 1 n α i − 1 2 ∑ i = 1 n ∑ j = 1 n α i α j y i y j ( x i T x j ) s.t. ∑ i = 1 n α i y i = 0 , α i ≥ 0 \boxed{ \begin{aligned} \max_{\boldsymbol{\alpha}} \quad & \sum_{i=1}^{n} \alpha_i - \frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n} \alpha_i \alpha_j y_i y_j (\mathbf{x}_i^T \mathbf{x}j) \\ \text{s.t.} \quad & \sum{i=1}^{n} \alpha_i y_i = 0, \quad \alpha_i \geq 0 \end{aligned} } αmaxs.t.i=1∑nαi−21i=1∑nj=1∑nαiαjyiyj(xiTxj)i=1∑nαiyi=0,αi≥0
注意公式最后括号里的 ( x i T x j ) (\mathbf{x}_i^T \mathbf{x}_j) (xiTxj)!这意味着:计算机在求解模型时,根本不需要知道数据长什么样,它只需要计算两个数据点之间的"内积"(相似度)即可 !这直接为后面引入"核函数"处理非线性数据铺平了道路。
六、SMO 算法求解
6.1 计算机如何真正求解?
上面那个只剩下 α \boldsymbol{\alpha} α 的方程,依然是一个二次规划问题。虽然用标准的数学软件(如单纯形法、内点法)可以解,但当数据量成千上万时,计算极其缓慢。
1998 年,微软研究院的 John Platt 发明了 SMO(序列最小最优化,Sequential Minimal Optimization)算法 。这个算法是 Python 代码中 SVC() 底层调用的 libsvm 库的核心。
SMO 的思路非常朴素:
- 每次只挑出两个 α \alpha α(比如 α 1 \alpha_1 α1 和 α 2 \alpha_2 α2),固定其他的 α \alpha α 不变
- 这样问题就变成了一个简单的一元二次方程求极值,可以直接用公式算出解析解
- 不断重复这个过程,直到所有的 α \alpha α 都收敛,不再变化
6.2 为什么不能只挑 1 个 α \alpha α 进行优化?
这正是因为我们上面刚刚推导出的那个约束条件: ∑ i = 1 n α i y i = 0 \sum_{i=1}^{n} \alpha_i y_i = 0 ∑i=1nαiyi=0。
假设我们把其他的 α \alpha α 都固定,只改变 α 1 \alpha_1 α1 。由于 y i y_i yi 都是 + 1 +1 +1 或 − 1 -1 −1 的常数标签,此时等式变成了:
α 1 y 1 + 常数 = 0 \alpha_1 y_1 + \text{常数} = 0 α1y1+常数=0
这就意味着, α 1 \alpha_1 α1 的值是被唯一"锁死"的,你根本无法对它进行任何修改和优化!只要动了 α 1 \alpha_1 α1,等式就不成立了。
所以,为了让等式继续成立,我们至少需要同时改变两个变量 ,比如 α 1 \alpha_1 α1 和 α 2 \alpha_2 α2。此时等式变成:
α 1 y 1 + α 2 y 2 + 常数 = 0 ⟹ α 1 y 1 + α 2 y 2 = C \alpha_1 y_1 + \alpha_2 y_2 + \text{常数} = 0 \quad\Longrightarrow\quad \alpha_1 y_1 + \alpha_2 y_2 = C α1y1+α2y2+常数=0⟹α1y1+α2y2=C
这就形成了一个巧妙的"跷跷板 "效应:如果你增加了 α 1 \alpha_1 α1,你就可以通过相应地调整 α 2 \alpha_2 α2 来保持整个等式恒为 0。
6.3 挑出两个后,为什么好用?
因为根据 α 1 y 1 + α 2 y 2 = C \alpha_1 y_1 + \alpha_2 y_2 = C α1y1+α2y2=C,我们可以把 α 1 \alpha_1 α1 用 α 2 \alpha_2 α2 表达出来:
α 1 = C − α 2 y 2 y 1 \alpha_1 = \frac{C - \alpha_2 y_2}{y_1} α1=y1C−α2y2
当我们把这个关系代入回 SVM 复杂的多元目标函数时,原本成千上万维的复杂优化问题,瞬间坍缩成了一个只有一个变量( α 2 \alpha_2 α2)的一元二次方程!
高中数学告诉我们,求一元二次方程(开口向上的抛物线)的极小值,只需要一个公式(顶点坐标公式)就能瞬间求出精确解,根本不需要迭代。这就是 SMO 速度极快的原因。
6.4 为什么这样不断挑两个能收敛到全局最优解?
这得益于 SVM 数学模型的优美性质:它是一个严格的凸二次规划问题。
- 凸函数(Convexity):想象一个表面平滑的碗,这个碗的底部只有一个(没有其他坑坑洼洼的局部陷阱)。这就是凸函数。全局唯一最小值就在碗底
- 单调递减 :SMO 算法在挑选哪两个 α \alpha α 时是有策略的。它会专门挑那些当前最"违反 KKT 条件"的 α \alpha α 对。一旦对它们进行了一元二次优化,整个目标函数的值就一定会严格下降(相当于我们在碗壁上往下走了一步)
由于碗底是有界的(不会无限往下掉),且每走一步高度都在严格下降,数学上(Osuna 定理)已经严格证明:这种"每次只走一小步且绝不回头"的策略,必然会在有限步内收敛到碗底,即那个唯一的全局最优解。
6.5 算完所有 α \alpha α 后,会发生一件神奇的事情
大部分的 α i \alpha_i αi 会等于 0!
只有极少数的 α i > 0 \alpha_i > 0 αi>0。而这些 α i > 0 \alpha_i > 0 αi>0 对应的那个样本点 x i \mathbf{x}_i xi,就是支持向量(Support Vector)!
6.6 有了 α \boldsymbol{\alpha} α,怎么还原我们要的 w \mathbf{w} w 和 b b b 呢?
-
计算 w \mathbf{w} w :直接代入前面推导出的公式 w = ∑ i = 1 n α i y i x i \mathbf{w} = \sum_{i=1}^{n} \alpha_i y_i \mathbf{x}_i w=∑i=1nαiyixi。你会发现,因为大部分 α i \alpha_i αi 都是 0,所以 w \mathbf{w} w 真的只由那几个支持向量决定(这就是 SVM 在嵌入式设备上运行极快的原因)
-
计算 b b b :随便找一个支持向量(也就是 α s > 0 \alpha_s > 0 αs>0 的点 x s \mathbf{x}_s xs),把它代入边界公式 y s ( w T x s + b ) = 1 y_s(\mathbf{w}^T \mathbf{x}_s + b) = 1 ys(wTxs+b)=1,即可反解出:
b = y s − w T x s b = y_s - \mathbf{w}^T \mathbf{x}_s b=ys−wTxs
实践中通常对所有支持向量算一遍取平均,以减少数值误差。
七、从二分类到多分类:OneVsRest 策略
经典 SVM 是二分类器,但实际任务往往有多个类别。OneVsRestClassifier 的策略是:
训练 K 个独立的二分类器,每个负责"我这一类 vs 其他所有类"
假设有 K 个类别,则训练 K 个 SVM:
| 分类器 | 正类 (+1) | 负类 (-1) |
|---|---|---|
| 0 | 第 0 类 | 其余所有类 |
| 1 | 第 1 类 | 其余所有类 |
| ... | ... | ... |
| K-1 | 第 K-1 类 | 其余所有类 |
每个分类器独立地走完上面的对偶求解过程,得到自己的 ( w c , b c ) (\mathbf{w}_c, b_c) (wc,bc)。最终的权重矩阵形状为 ( K , d ) (K, d) (K,d),偏置向量形状为 ( K , ) (K,) (K,)。
预测时的决策规则 :把输入 x \mathbf{x} x 喂给所有 K 个分类器,得到 K 个决策分数 w c T x + b c \mathbf{w}_c^T\mathbf{x} + b_c wcTx+bc,取分数最大的那个对应的类别作为最终预测:
y ^ = arg max c ∈ { 0 , 1 , ... , K − 1 } ( w c T x + b c ) \hat{y} = \arg\max_{c \in \{0, 1, \ldots, K-1\}} \left( \mathbf{w}_c^T\mathbf{x} + b_c \right) y^=argc∈{0,1,...,K−1}max(wcTx+bc)
八、Python 端参数拟合
下面是一个通用的 Python 训练流程(基于 scikit-learn):
python
import numpy as np
from sklearn.svm import SVC
from sklearn.multiclass import OneVsRestClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import accuracy_score, classification_report
# ============================================================
# 1. 数据准备
# ============================================================
# X: (n_samples, n_features) 特征矩阵
# y: (n_samples,) 标签数组(字符串或整数)
# 编码标签为整数
label_encoder = LabelEncoder()
y_encoded = label_encoder.fit_transform(y)
classes = label_encoder.classes_
print(f"类别数: {len(classes)}, 类别: {classes}")
# 训练-测试集划分
X_train, X_test, y_train, y_test = train_test_split(
X, y_encoded, test_size=0.2, random_state=42, stratify=y_encoded
)
# ============================================================
# 2. 特征标准化(对线性 SVM 至关重要)
# ============================================================
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# ============================================================
# 3. 训练 OneVsRest 线性 SVM
# ============================================================
clf = OneVsRestClassifier(SVC(kernel='linear', C=1.0, random_state=42))
clf.fit(X_train_scaled, y_train)
# ============================================================
# 4. 提取权重和偏置(核心步骤)
# ============================================================
n_classes = len(clf.estimators_)
n_features = X_train_scaled.shape[1]
weight_matrix = np.zeros((n_classes, n_features), dtype=np.float32)
bias_vector = np.zeros(n_classes, dtype=np.float32)
for i, estimator in enumerate(clf.estimators_):
weight_matrix[i] = estimator.coef_[0] # 这就是 w_c
bias_vector[i] = estimator.intercept_[0] # 这就是 b_c
print(f"权重矩阵形状: {weight_matrix.shape}") # (n_classes, n_features)
print(f"偏置向量形状: {bias_vector.shape}") # (n_classes,)
# ============================================================
# 5. 评估
# ============================================================
y_pred = clf.predict(X_test_scaled)
acc = accuracy_score(y_test, y_pred)
print(f"准确率: {acc * 100:.2f}%")
print(classification_report(y_test, y_pred, target_names=classes))
# ============================================================
# 6. 手动实现预测,验证一致性(部署前的关键自检)
# ============================================================
def manual_predict(X, W, b, classes):
"""
手动实现 OneVsRest SVM 预测
decision_score[c] = W[c] · x + b[c]
prediction = argmax_c(decision_score)
"""
decision_scores = np.dot(X, W.T) + b # (n_samples, n_classes)
predicted_indices = np.argmax(decision_scores, axis=1)
return predicted_indices, decision_scores
manual_pred, manual_scores = manual_predict(
X_test_scaled, weight_matrix, bias_vector, classes
)
sklearn_scores = clf.decision_function(X_test_scaled)
# 验证:手动实现的决策分数应与 sklearn 完全一致
max_diff = np.max(np.abs(manual_scores - sklearn_scores))
print(f"决策分数最大差异: {max_diff:.2e}") # 应当 ≈ 1e-7 级别(浮点误差)
导出参数到 C 头文件:
python
def export_to_c(W, b, classes, output_path='svm_params.h'):
"""将权重和偏置导出为 C 头文件"""
n_classes, n_features = W.shape
with open(output_path, 'w') as f:
f.write('#ifndef SVM_PARAMS_H\n#define SVM_PARAMS_H\n\n')
f.write(f'#define SVM_N_CLASSES {n_classes}\n')
f.write(f'#define SVM_N_FEATURES {n_features}\n\n')
# 类别名
f.write('static const char* svm_class_names[SVM_N_CLASSES] = {\n')
for c in classes:
f.write(f' "{c}",\n')
f.write('};\n\n')
# 权重矩阵
f.write('static const float svm_weights[SVM_N_CLASSES][SVM_N_FEATURES] = {\n')
for i in range(n_classes):
f.write(' {')
f.write(', '.join(f'{w:.8f}f' for w in W[i]))
f.write('},\n')
f.write('};\n\n')
# 偏置向量
f.write('static const float svm_bias[SVM_N_CLASSES] = {\n ')
f.write(', '.join(f'{bv:.8f}f' for bv in b))
f.write('\n};\n\n')
f.write('#endif\n')
九、C/C++ 端部署
9.1 头文件 svm_params.h
c
/*
* SVM OneVsRest Classifier Parameters
*/
#ifndef SVM_PARAMS_H
#define SVM_PARAMS_H
#ifdef __cplusplus
extern "C" {
#endif
#define SVM_N_CLASSES 3 // 类别数(OneVsRest 分类器个数)
#define SVM_N_FEATURES 16 // 特征维度
// 类别名
extern const char* svm_class_names[SVM_N_CLASSES];
// 权重矩阵(每行是一个 OneVsRest 分类器的法向量 w_c)
extern const float svm_weights[SVM_N_CLASSES][SVM_N_FEATURES];
// 偏置向量
extern const float svm_bias[SVM_N_CLASSES];
/**
* @brief 使用 OneVsRest SVM 分类器进行预测
* @param x 输入特征向量(长度 SVM_N_FEATURES)
* @param decision_scores 输出决策分数(长度 SVM_N_CLASSES),可为 NULL
* @return 预测类别索引(0 到 SVM_N_CLASSES-1)
*/
int svm_predict(const float* x, float* decision_scores);
#ifdef __cplusplus
}
#endif
#endif // SVM_PARAMS_H
9.2 实现文件 svm_params.c
c
#include "svm_params.h"
// 类别名(从 Python 端 LabelEncoder.classes_ 导出)
const char* svm_class_names[SVM_N_CLASSES] = {
"ClassA",
"ClassB",
"ClassC"
};
// 权重矩阵:每一行是一个独立 SVM 分类器的法向量 w_c
// 来自 Python 端 estimator.coef_[0]
const float svm_weights[SVM_N_CLASSES][SVM_N_FEATURES] = {
{ // Class 0
0.35326248f, 0.02060862f, -0.44330732f, 0.77913608f,
0.38499219f, -0.05459209f, 0.85497581f, 0.70733096f,
-0.40738003f, -1.74369219f, -0.68552091f, 0.19778470f,
0.68258982f, 0.02136635f, 0.77613555f, 0.00340731f
},
{ // Class 1
0.20129736f, -0.45622374f, 0.22362229f, -0.30288867f,
-0.08454146f, 0.48482821f, 0.00950517f, -0.48167103f,
0.12221864f, 0.57686148f, -0.22570071f, 0.03157581f,
-0.24654829f, -0.02168464f, -0.20666883f, -0.00579798f
},
{ // Class 2
-0.21808064f, 0.30866630f, 0.13797665f, -0.27851619f,
-0.08130081f, -0.17178697f, -0.45223908f, -0.09143300f,
-0.05750307f, 0.59929866f, 0.11807443f, -0.11207356f,
-0.19420604f, 0.06068741f, -0.10932892f, 0.20890234f
}
};
// 偏置向量:来自 Python 端 estimator.intercept_[0]
const float svm_bias[SVM_N_CLASSES] = {
-3.57607321f,
-2.34429088f,
0.06130016f
};
/**
* 预测函数:核心就是 score = W·x + b,然后 argmax
*/
int svm_predict(const float* x, float* decision_scores) {
int predicted_class = 0;
float max_score = -1e30f;
float local_scores[SVM_N_CLASSES];
float* scores = (decision_scores != NULL) ? decision_scores : local_scores;
// 对每一个分类器,计算决策分数
for (int c = 0; c < SVM_N_CLASSES; c++) {
// score_c = b_c + Σ x[i] * w_c[i]
scores[c] = svm_bias[c];
for (int i = 0; i < SVM_N_FEATURES; i++) {
scores[c] += x[i] * svm_weights[c][i];
}
// 跟踪最大决策分数对应的类别
if (scores[c] > max_score) {
max_score = scores[c];
predicted_class = c;
}
}
return predicted_class;
}
9.3 C++ 实现
cpp
#include <array>
#include <algorithm>
#include <string>
extern "C" {
#include "svm_params.h"
}
class LinearSVMClassifier {
public:
struct Result {
int class_index;
std::string class_name;
std::array<float, SVM_N_CLASSES> scores;
float confidence; // 最高分与次高分之差
};
Result predict(const float* x) const {
Result r;
r.class_index = svm_predict(x, r.scores.data());
r.class_name = svm_class_names[r.class_index];
// 计算置信度(最高分 - 次高分)
auto sorted = r.scores;
std::nth_element(sorted.begin(), sorted.begin() + 1, sorted.end(),
std::greater<float>());
r.confidence = r.scores[r.class_index] - sorted[1];
return r;
}
};
// 使用示例
// LinearSVMClassifier clf;
// float features[SVM_N_FEATURES] = { /* ... */ };
// auto result = clf.predict(features);
// printf("预测类别: %s (置信度: %.3f)\n",
// result.class_name.c_str(), result.confidence);
9.4 CMSIS-DSP 加速版本
在 Cortex-M4F / M7 / M33 等带 FPU + DSP 扩展的内核上,可以用 CMSIS-DSP 把推理中的矩阵-向量乘法、加法、argmax 全部替换成 SIMD 优化实现。线性 SVM 的预测本质就是:
s = W x + b , W ∈ R C × d \mathbf{s} = \mathbf{W}\mathbf{x} + \mathbf{b}, \quad \mathbf{W} \in \mathbb{R}^{C \times d} s=Wx+b,W∈RC×d
正好对应 CMSIS-DSP 最擅长的 BLAS 类操作。把整个预测的 W·x + b 用 CMSIS-DSP 的矩阵接口算完。CMSIS-DSP 没有直接的 "Wx+b" 融合函数,但可以三步走:矩阵乘 → 加偏置 → argmax。
c
#include "arm_math.h"
#include "svm_params.h"
/*
* 把权重矩阵包装成 CMSIS-DSP 的矩阵实例(全局静态,一次性初始化)
*
* 注意:svm_weights 是 const,但 arm_matrix_instance_f32.pData 是 float32_t*,
* 所以这里需要 cast 去掉 const。实际硬件上权重在 Flash 里只读,
* CMSIS-DSP 内部只读不写,cast 是安全的。
*/
static const arm_matrix_instance_f32 W_mat = {
SVM_N_CLASSES, // 行数 = 类别数
SVM_N_FEATURES, // 列数 = 特征维度
(float32_t*)&svm_weights[0][0] // 数据指针
};
/**
* @brief CMSIS-DSP 加速版本的 SVM 预测
* @param x 输入特征向量(长度 SVM_N_FEATURES)
* @param decision_scores 输出决策分数(长度 SVM_N_CLASSES),可为 NULL
* @return 预测类别索引
*/
int svm_predict_dsp(const float* x, float* decision_scores) {
float local_scores[SVM_N_CLASSES];
float* scores = (decision_scores != NULL) ? decision_scores : local_scores;
// 把输入向量 x 包装成 d × 1 的矩阵
arm_matrix_instance_f32 x_mat = {
SVM_N_FEATURES, 1, (float32_t*)x
};
// 输出 scores 包装成 C × 1 的矩阵
arm_matrix_instance_f32 s_mat = {
SVM_N_CLASSES, 1, scores
};
// 步骤 1:scores = W · x (C×d · d×1 = C×1)
arm_mat_mult_f32(&W_mat, &x_mat, &s_mat);
// 步骤 2:scores += b (逐元素加偏置)
arm_add_f32(scores, (float32_t*)svm_bias, scores, SVM_N_CLASSES);
// 步骤 3:argmax (找最大分数对应的索引)
float32_t max_val;
uint32_t max_idx;
arm_max_f32(scores, SVM_N_CLASSES, &max_val, &max_idx);
return (int)max_idx;
}
工程上的几个坑
对齐 :CMSIS-DSP 的优化路径要求数据 4 字节对齐。const float svm_weights[C][d] 自然就是 4 字节对齐的;输入 x 如果来自 packed struct 或 DMA 缓冲区,要确认对齐性。
const 转换 :arm_* 接口大多吃 float32_t*(非 const),把 const float* cast 进去是惯用法。权重在 Flash 里只读,CMSIS-DSP 内部只读不写,安全。
编译宏 :使用前确认 arm_math.h 能找到,并且工程里定义了正确的核心宏(如 ARM_MATH_CM4、ARM_MATH_CM7、__FPU_PRESENT=1、__FPU_USED=1),否则会退化到通用 C 实现,加速就没了。STM32CubeIDE 里勾选 "Use CMSIS-DSP" 会帮你处理大部分宏配置。