# 2.5. 支持向量机
支持向量机寻找的决策边界,离每个类别最近的点都尽可能远,这就是最大间隔超平面。RustyML 提供两套实现,从相反的方向逼近这个目标。
`SVC` 是核化版本。它把数据通过核函数抬升到更高维的空间,在那里找一条线性边界。回到原始空间后,这条边界就变弯了。`LinearSVC` 不用核。它直接用随机梯度下降最小化 hinge 损失,拟合出一条笔直的超平面。
两者都是严格的二分类器,没有内置的 one-vs-rest 封装。两者的标签取值域同为 `{0.0, 1.0}`,所以互换时不需要改写目标数组。除了这个共同目标,两个模型在求解器、正则化和代价上都不一样。这一页接下来帮你判断该用哪一个。
## 2.5.1. 该用哪一个
这个选择归结为两个数字:样本数(`n_samples`)和特征数(`n_features`)。此外还要看一条直线能不能把两类分开。
`SVC` 在训练开始前,先构建一个 `n_samples` x `n_samples` 的核矩阵,也叫 Gram 矩阵,之后 Sequential Minimal Optimization(SMO)求解器就在这个矩阵上工作。这个矩阵定死了一个硬限制:内存开销 `O(n_samples^2)`,构建开销 `O(n_samples^2 * n_features)`。样本数到 10,000 时,光 Gram 矩阵按 `f64` 算就要约 800 MB;到 260,000 时,得要几百 GB。
所以 `SVC` 适合边界确实非线性、且 `n_samples` 不大的场景,几百到几千行。它无法扩展到大数据集,这是算法本身的性质,不是这份实现的问题。
`LinearSVC` 从不构建核矩阵。每个 epoch 只是几趟矩阵-向量运算扫过数据,时间开销 `O(n_samples * n_features)`,权重向量只占 `O(n_features)` 内存。它在两个维度上都线性扩展,所以面对宽、高维或大样本的问题,它是首选,上万稀疏特征的文本分类就是经典场景。
代价是它只能拟合线性边界。如果两类线性不可分,再怎么训练也没用。这时候改用带核的 `SVC`。
| | `SVC` | `LinearSVC` |
|---|---|---|
| 边界 | 线性或非线性(通过核) | 仅线性 |
| 求解器 | Sequential Minimal Optimization(对偶) | 小批量 SGD(原问题) |
| 标签取值 | `0.0` / `1.0` | `0.0` / `1.0` |
| 内存 | `O(n_samples^2)` Gram 矩阵 | `O(n_features)` 权重 |
| 随 `n_samples` 扩展 | 差,`n^2` 内存墙 | 线性 |
| 正则化旋钮 | `C`(约束对偶变量) | L1 / L2 惩罚,强度为 `lambda` |
| 损失 | hinge(在对偶里求解) | `Hinge` 或 `SquaredHinge` |
留意一个坑:如果你只是要在大数据集上求一条线性边界,别用带 `KernelType::Linear` 的 `SVC`。这样做仍然要付出完整的 `O(n_samples^2)` Gram 矩阵开销,而 `LinearSVC` 用线性内存就能解决同一个问题。带线性核的 `SVC` 只有在小数据、且你要精确的最大间隔对偶解时才值得用。
两个估计器都对特征尺度敏感。RBF 核衡量的是欧氏距离的平方,`LinearSVC` 里的 SGD 在特征空间里迈的是固定大小的步子。除非各列本来就在同一量级,否则先做标准化(见[标准化与归一化](../Chapter-04/4.2._标准化与归一化.md))。
它们对 `y` 还有一条共同的硬性规则:每个元素都必须恰好是 `0.0` 或 `1.0`。其他任何值——从教科书推导里搬来的 `-1.0`、一个 `2.0`、一个漏网的 `0.5`——都会在 `fit` 处触发 `Error::InvalidInput`。两个估计器都不会悄悄帮你重映射。如果你的标签是字符串或任意整数,先过一遍[标签编码](../Chapter-04/4.3._标签编码.md)。
## 2.5.2. SVC:核函数与 SMO 求解器
用 `SVC::new(kernel, regularization_param, tol, max_iter)` 构造一个 `SVC`,这次调用会校验参数并返回 `Result`。用于可复现训练的种子要单独用 builder 方法 `with_random_state` 来设置。
| 参数 | 类型 | 含义 | 校验 |
|---|---|---|---|
| `kernel` | `KernelType` | 核函数(见下文) | — |
| `regularization_param`(C) | `f64` | 在间隔宽度与训练误差之间权衡 | 必须为正且有限 |
| `tol` | `f64` | SMO 的 KKT 停止容差 | 必须为正且有限 |
| `max_iter` | `usize` | SMO 外层循环的迭代上限 | 必须非零 |
`C` 是正则化旋钮,它是每个对偶系数的上界,`0 <= alpha <= C`。`C` 大,求解器就能把 alpha 顶得很高,用窄间隔拟合每一个训练点,容忍的违例也少;`C` 小则让 alpha 保持低位,间隔变宽、接受更多松弛。这和线性模型里 `lambda` 的直觉正好相反:`C` 越大,正则化反而越弱。任何非正或非有限的值都会在构造时被 `Error::InvalidParameter` 拒绝。
`SVC::default()` 给出一个 RBF 核,`gamma = 0.1`、`C = 1.0`、`tol = 0.001`、`max_iter = 1000`。在没有任何先验信息时,这是个合理的起点,不过 `gamma` 几乎总是需要调。
训练走的是 Sequential Minimal Optimization(SMO):它反复挑出一对违反 Karush-Kuhn-Tucker(KKT)条件的对偶变量,固定其余变量,对这一对做解析优化。如此反复,直到整组变量都在 `tol` 范围内满足 KKT 条件,或者迭代次数触顶。
这个对偶问题是按 `+1`/`-1` 目标写的,因为目标函数里的 `y_i * y_j` 乘积只有带符号才说得通。`fit` 在入口处把你那列 `{0.0, 1.0}` 一次性转成 `+1`/`-1`,并把这套带符号的形式锁在内部。`predict` 再把符号映射回 `{0.0, 1.0}`。你不必传 `+1`/`-1` 标签进去,除了下面提到的那一处例外,也拿不到它们出来。
Gram 矩阵和初始误差缓存可以并行构建。SMO 内层循环则是刻意保持串行,这样在给定种子下,优化轨迹才能复现。
下面是完整的构造-拟合-预测流程,用线性核处理一份直线就能干净分开的数据:
```rust
use rustyml::machine_learning::{KernelType, SVC};
use ndarray::array;
fn main() {
// 类别 1 在右上,类别 0 在左下,一条直线就能分开
let x = array![
[2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
[-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
];
let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
let mut svc = SVC::new(KernelType::Linear, 10.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).unwrap();
// predict 输出的标签仍在 {0.0, 1.0} 中,与 fit 收到的取值域一致
let preds = svc.predict(&x).unwrap();
println!("predictions: {:?}", preds);
// decision_function 返回到超平面的带符号原始距离
let scores = svc.decision_function(&x).unwrap();
println!("scores: {:?}", scores);
}
```
`predict` 以零为阈值:把 `>= 0.0` 映射到 `1.0`,其余全部映射到 `0.0`。`decision_function` 交回的是带符号的原始分数:正值代表类别 `1.0` 那一侧,绝对值大小表示这个点离边界有多远。想要类似置信度的排序,而不是硬标签时,就用 `decision_function`。另外还有 `fit_predict`,一次调用里先拟合、再对同一个矩阵预测。
### 核函数一览与 gamma 系数
`KernelType` 和[核主成分分析](./2.11._核主成分分析.md)用的是同一个枚举,所以在这里学会它能一举两得。它一共有 5 个变体。
| 变体 | `K(x, y)` | 字段 |
|---|---|---|
| `Linear` | `x*y` | 无 |
| `Poly { degree, gamma, coef0 }` | `(gamma*x*y + coef0)^degree` | `degree: u32`、`gamma: Gamma`、`coef0: f64` |
| `RBF { gamma }` | `exp(-gamma*\|\|x-y\|\|^2)` | `gamma: Gamma` |
| `Sigmoid { gamma, coef0 }` | `tanh(gamma*x*y + coef0)` | `gamma: Gamma`、`coef0: f64` |
| `Cosine` | `(x*y) / (\|\|x\|\| * \|\|y\|\|)` | 无 |
`RBF` 是默认核,也是非线性数据首先要试的核:它是一种平滑的局部相似度,只需要调 `gamma`。`Poly` 显式加入直到 `degree` 阶的交互项,但阶数一高就很快溢出:在稍大的输入上,400 阶的多项式会把决策值推到无穷,预测时就表现为 `Error::NonFinite`。
`Sigmoid` 模仿一个两层网络,但并非对所有参数都正定,因此可能表现得不稳定。`Cosine` 度量的是夹角而非距离,它对零向量做了防护,遇到零向量就返回 `0.0`。
`gamma` 字段不是裸的 `f64`,它是一个有 3 个变体的 `Gamma` 枚举,对应 scikit-learn 的 `'scale'`、`'auto'` 和显式取值三种选择:
- `Gamma::Value(v)`:你自己提供的显式系数。
- `Gamma::Scale`:在拟合时解析为 `1 / (n_features * X.var())`,其中 `X.var()` 是训练矩阵全部元素的总体方差。
- `Gamma::Auto`:在拟合时解析为 `1 / n_features`。
`Scale` 和 `Auto` 依赖数据,所以在 `fit` 见到数据之前,它们只是占位符。`fit` 会一次性把它们解析出来并存下具体值,所以事后调用 `get_kernel()`,拿到的 `KernelType` 里 `gamma` 已经变成了 `Gamma::Value`。如果数据方差为零,比如所有特征都是常数,`Scale` 会因为公式要除以零而报 `Error::InvalidInput`。
`gamma` 控制单个训练点的影响能传多远。`gamma` 大,RBF 的凸起就收得很窄,边界也跟着扭曲,是通往过拟合的捷径;`gamma` 小,凸起就宽,边界也平滑。它是你会调得最多的参数。
### 用 RBF 解决直线分不开的问题
核函数存在的意义,就是分开超平面分不开的数据。两个同心圆环是最经典的例子:内圈是一类,外圈是另一类。没有任何直线能把它们劈开,线性核在这里束手无策。按邻近程度打分的 RBF 核,则能把每一个点都分对。
```rust
use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;
fn main() {
// 内圈(半径 1)是类别 1,外圈(半径 5)是类别 0
let x = array![
[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0],
[5.0, 0.0], [-5.0, 0.0], [0.0, 5.0], [0.0, -5.0],
];
let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
let mut svc = SVC::new(
KernelType::RBF { gamma: Gamma::Value(0.5) },
10.0, // C
1e-3, // tol
5000, // max_iter——非线性问题需要更多 SMO 迭代
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).unwrap();
let preds = svc.predict(&x).unwrap();
let correct = preds.iter().zip(y.iter()).filter(|&(p, t)| p == t).count();
println!("RBF accuracy on rings: {}/{}", correct, y.len());
}
```
把这份数据里的 `KernelType::RBF { .. }` 换成 `KernelType::Linear`,分类器就没法把每个点都摆到正确的一侧了。圆环线性不可分,这正是核函数存在的意义所在。非线性问题通常也需要比线性问题更大的 `max_iter`,因为 SMO 求解器要安顿的支持向量更多。
### 查看训练好的模型
`fit` 之后,`SVC` 通过一组 getter 把训练好的模型的各个部件都暴露出来,这比这份指南里大多数估计器提供的都多。支持向量是唯一对预测有意义的训练行,也就是那些对偶系数最终不为零的行。你可以直接把它们读回来:
```rust
use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;
fn main() {
let x = array![
[2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
[-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
];
let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
let mut svc = SVC::new(
KernelType::RBF { gamma: Gamma::Value(0.5) },
5.0, 1e-3, 1000,
)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).unwrap();
// 只有 alpha 不为零的行才会作为支持向量留下来
let support_vectors = svc.get_support_vectors().unwrap();
let alphas = svc.get_alphas().unwrap();
let labels = svc.get_support_vector_labels().unwrap();
println!(
"kept {} of {} rows as support vectors",
support_vectors.nrows(),
x.nrows()
);
println!("dual coefficients (alphas): {:?}", alphas);
println!("support-vector labels (SMO's internal +/-1): {:?}", labels);
println!("bias: {:?}", svc.get_bias());
println!("SMO iterations actually run: {:?}", svc.get_actual_iterations());
println!("resolved kernel: {:?}", svc.get_kernel());
}
```
`get_support_vectors` 返回 `Option<&Array2<f64>>`,`get_alphas` 和 `get_support_vector_labels` 返回 `Option<&Array1<f64>>`,`get_bias` 返回 `Option<f64>`。这 4 个方法在 `fit` 之前都是 `None`,之后是 `Some`。
`get_support_vector_labels` 是包住对偶的那堵墙上唯一的裂缝:它交回的是内部的 `+1`/`-1` 编码,而不是你传给 `fit` 的 `{0.0, 1.0}`,所以那个数组里的 `-1.0` 代表的是类别 `0.0`。之所以按带符号的形式存,是因为 `predict` 要把它直接乘进 `alpha_i * y_i` 系数里。想要调用方那套取值域,自己按符号切一刀即可。
`get_actual_iterations` 报告 SMO 外层循环实际跑了多少趟,取值始终落在 `[1, max_iter]` 之间,由此可知求解器是收敛了还是撞上了上限。次数等于 `max_iter` 是个信号,提示你该抬高上限或放宽 `tol`。`get_kernel` 返回的是已解析的核,想读回 `Gamma::Scale` 或 `Gamma::Auto` 算出的具体 `gamma`,靠的就是它。
训练有可能一个支持向量都找不到,比如只有单一类别、压根没什么可分的数据。这种情况下,`fit` 会返回 `Error::NotConverged`,而不是甩给你一个退化的全零模型。
## 2.5.3. LinearSVC:原问题里的 hinge 损失
`LinearSVC` 直接用小批量随机梯度下降求解原问题,最小化 hinge 损失加上一个正则化惩罚项。用 `LinearSVC::new(max_iter, learning_rate, penalty, fit_intercept, tol)` 构造它。
| 参数 | 类型 | 含义 | 校验 |
|---|---|---|---|
| `max_iter` | `usize` | 最大 epoch 数 | 必须非零 |
| `learning_rate` | `f64` | SGD 步长 | 必须为正且有限 |
| `penalty` | `RegularizationType` | `L1(lambda)` 或 `L2(lambda)` | `lambda` 必须非负且有限 |
| `fit_intercept` | `bool` | 是否学习偏置项 | — |
| `tol` | `f64` | 参数变化的收敛容差 | 必须为正且有限 |
它接收的标签和 `SVC` 一样,是 `0.0`/`1.0`。底下的故事也一样:hinge 损失需要带符号的目标,所以 `fit` 在入口处把它们重映射成 `-1`/`+1`。区别在于,`LinearSVC` 的公开接口没有任何地方会把这套编码漏出来:它拟合出的状态是一个权重向量和一个偏置,两者都活在特征空间里,而不是标签空间。
```rust
use rustyml::machine_learning::{LinearSVC, RegularizationType};
use ndarray::array;
fn main() {
// 和 SVC 收的是同一套 0.0 / 1.0 标签——两者之间不需要重映射
let x = array![
[-5.0, 0.0], [-6.0, 0.0], [-7.0, 0.0], [-4.0, 0.0],
[5.0, 0.0], [6.0, 0.0], [7.0, 0.0], [4.0, 0.0],
];
let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let mut model = LinearSVC::new(
5000, // max_iter
0.01, // learning_rate
RegularizationType::L2(0.1), // penalty
true, // fit_intercept
1e-5, // tol
)
.unwrap()
.with_random_state(42);
model.fit(&x, &y).unwrap();
let preds = model.predict(&x).unwrap();
let scores = model.decision_function(&x).unwrap();
println!("labels: {:?}", preds);
println!("scores: {:?}", scores);
println!("weights: {:?}", model.get_weights().unwrap());
println!("bias: {:?}", model.get_bias().unwrap());
println!("stopped after {:?} epochs", model.get_actual_iterations());
}
```
`fit` 的训练过程由两个细节决定。小批量大小是自动选定的,按 `clamp(n_samples / 10, 32, 512)` 计算,你不需要设置。每个 epoch 在切成小批量之前都会先打乱样本顺序,这也是随机种子有讲究的原因(下面会讲)。
`fit` 的收敛判据是权重和偏置在相邻 epoch 之间变化的均方根:一旦降到 `tol` 以下,训练就提前停止。默认的 `Loss::Hinge` 在间隔附近的梯度大小是恒定的,所以固定学习率常常让权重来回振荡、而不是真正稳定下来。上面这个例子就是这样:它跑满了整整 5000 个 epoch 的上限,并没有提前停止。`Loss::SquaredHinge` 的梯度在间隔附近会收缩到零,所以用这个损失训练,通常远没到 `max_iter` 就会停下来,就像下面这个例子展示的那样。
如果放任不管,失控的 `learning_rate` 可能把权重推到非有限值。`fit` 会在训练中途捕获这种情况,报出 `Error::NonFinite`,而不是返回一堆垃圾。补救办法是调小 `learning_rate`,或者加强正则化。
留意它和 `SVC` 之间一个阈值上的差别:`LinearSVC::predict` 把决策值 `> 0.0` 映射到类别 `1.0`,其余(包括正好等于 `0.0`)映射到类别 `0.0`;`SVC` 则把 `>= 0.0` 映射到 `1.0`。既然两者共用同一套标签取值域,这就是货真价实的行为差异,而不是记账口径的差别:分数为零这个平局,两个模型倒向的是相反的类别。
这里的 `decision_function` 返回 `x * weights + bias`;当 `fit_intercept = false` 时,偏置恒为 `0.0`,分数就是一个纯粹的点积。
### 惩罚项与损失
`RegularizationType` 有 `L1(lambda)` 和 `L2(lambda)` 两种。L2(岭)每一步都施加一次 `weights *= (1 - learning_rate * lambda)` 的收缩,把所有权重压小但不清零。L1(lasso)施加一个基于符号的常数次梯度拉力,把无关特征的权重往零推。和 `LinearRegression`、`LogisticRegression` 不同,`LinearSVC` 用的是纯粹的次梯度步,而不是 proximal(软阈值)步,所以权重很少真正落在 `0.0` 上,只是无限接近它。
如果某个特征不含信号,强 L1 惩罚会把它的权重压得比有信息量的特征更接近零。当你怀疑很多列都是噪声时,这很有用。`lambda = 0.0` 是合法的,会彻底关掉惩罚项。
两个 builder 方法把估计器扩展到 `new` 设定之外。`with_loss` 在默认的 `Loss::Hinge`(`max(0, 1 - y*f(x))`)和 `Loss::SquaredHinge`(`max(0, 1 - y*f(x))^2`)之间切换。`Loss::SquaredHinge` 对间隔违例做二次惩罚,且处处可导,这个更平滑的目标能让一些数据集收敛得更干净。
`with_learning_rate_decay` 启用一种反比衰减调度,epoch `t` 时的有效步长是 `learning_rate / (1 + decay * t)`。这让 SGD 能稳定到更靠近最优点的位置,而不是始终在离它一个固定步长的地方来回打转。它返回 `Result`,因为 decay 必须非负且有限。
```rust
use rustyml::machine_learning::{LinearSVC, Loss, RegularizationType};
use ndarray::array;
fn main() {
let x = array![
[-5.0, 0.0], [-6.0, 0.0], [-7.0, 0.0], [-4.0, 0.0],
[5.0, 0.0], [6.0, 0.0], [7.0, 0.0], [4.0, 0.0],
];
let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let mut model = LinearSVC::new(10_000, 0.01, RegularizationType::L1(0.5), true, 1e-6)
.unwrap()
.with_loss(Loss::SquaredHinge)
.with_learning_rate_decay(0.001) // 返回 Result——decay 会被校验
.unwrap()
.with_random_state(0);
model.fit(&x, &y).unwrap();
println!("penalty: {:?}", model.get_penalty());
println!("loss: {:?}", model.get_loss());
println!("preds: {:?}", model.predict(&x).unwrap());
}
```
这些 getter 和 `SVC` 的相互呼应:`get_weights`(`Option<&Array1<f64>>`)、`get_bias`、`get_penalty`、`get_loss`、`get_learning_rate`、`get_learning_rate_decay`、`get_tolerance`、`get_max_iterations`、`get_actual_iterations`,以及 `get_random_state`。权重向量的长度始终等于 `n_features`,所以只要各列在同一量级上,`get_weights` 也能当作特征重要性来看。`LinearSVC` 是[逻辑回归](./2.2._逻辑回归.md)在线性分类器上的表亲,区别在损失:hinge 而不是 log-loss。hinge 只在意间隔附近或越过间隔的点,所以往往给出一条完全无视那些高置信度正确点的边界。
## 2.5.4. 可复现性、持久化与错误
两个估计器默认都是非确定性的,都通过 `with_random_state(u64)` 接收一个固定种子。对 `SVC` 而言,种子驱动的是 SMO 工作集的回退策略,也就是扫描第二个待优化 alpha 时用到的随机偏移;同一个种子能让支持向量、偏置和预测逐比特复现。
对 `LinearSVC` 而言,种子驱动的是每个 epoch 的小批量打乱,这就固定了梯度步的确切序列,进而固定了最终权重。不同种子可能落到不同的解上,`LinearSVC` 在打乱顺序会影响结果的数据上尤其明显。只要需要多次运行可比,就设种子;这如何嵌入 crate 全局的种子体系,见[可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md)。
```rust
use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;
fn main() {
let x = array![
[2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
[-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
];
let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
let train = || {
let mut svc = SVC::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 5.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).unwrap();
svc.predict(&x).unwrap()
};
// 相同种子、相同数据 -> 完全一致的预测
assert_eq!(train(), train());
println!("same seed reproduces the model exactly");
}
```
两个模型都实现了 `save_to_path` 和 `load_from_path`,这两个方法把整个已拟合状态序列化成一坨紧凑的 postcard 二进制。这份状态对 `SVC` 是支持向量和 alpha,对 `LinearSVC` 是权重和偏置,再加上每一个超参数。
文件扩展名随你取,无论叫什么名字,格式都是二进制。往返一圈能精确复现预测值和决策分数。这算是[深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)的浅水区。
```rust
use rustyml::machine_learning::{Gamma, KernelType, SVC};
use ndarray::array;
use std::fs::remove_file;
fn main() {
let x = array![
[2.0, 2.0], [3.0, 2.0], [2.0, 3.0], [3.0, 3.0],
[-2.0, -2.0], [-3.0, -2.0], [-2.0, -3.0], [-3.0, -3.0],
];
let y = array![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
let mut svc = SVC::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 5.0, 1e-3, 1000)
.unwrap()
.with_random_state(42);
svc.fit(&x, &y).unwrap();
svc.save_to_path("svc_model.bin").unwrap(); // postcard 二进制
let loaded = SVC::load_from_path("svc_model.bin").unwrap();
assert_eq!(svc.predict(&x).unwrap(), loaded.predict(&x).unwrap());
println!("loaded model reproduces the original's predictions");
remove_file("svc_model.bin").unwrap();
}
```
每个可能失败的入口都返回 crate 的 `Error`(见[错误处理](../Chapter-01/1.6._错误处理.md))。你实际会遇到的变体:
| 时机 | 错误 | 触发条件 |
|---|---|---|
| `new` | `Error::InvalidParameter` | `C`/`learning_rate`/`tol` 非正、`max_iter` 为零、`lambda` 为负、任何非有限值 |
| `fit` | `Error::InvalidInput` | 标签不在 `{0.0, 1.0}` 内(两个模型规则相同),或在零方差数据上用 `Gamma::Scale` |
| `fit` | `Error::EmptyInput` / `Error::DimensionMismatch` | 矩阵为空,或 `y.len()` != `x.nrows()` |
| `fit` | `Error::NotConverged` | `SVC` 没找到任何支持向量(例如单一类别数据) |
| `fit` | `Error::NonFinite` | 训练中核矩阵或权重变成非有限值 |
| `predict` / `decision_function` | `Error::NotFitted` | 在 `fit` 之前调用 |
| `predict` / `decision_function` | `Error::DimensionMismatch` | 输入特征数 != 训练特征数 |
| `predict` / `decision_function` | `Error::NonFinite` | 决策值溢出(例如高阶 `Poly` 核) |
| `load_from_path` | `Error::Io` | 文件缺失,或字节损坏/不兼容 |
标签取值域仍然是容易绊人的地方,只是绊的方向和 SMO 文献里的 `+1`/`-1` 暗示的相反:两个模型要的都是 `0.0`/`1.0`,所以从教科书推导或别的库的带符号约定里照抄过来的 `y`,在 `fit` 处就是 `InvalidInput`。两个模型都不会悄悄替你转换。训练前先把标签编码到 `{0.0, 1.0}`,此后你唯一还会碰上的 `+1`/`-1`,就是 `get_support_vector_labels` 交回来的那一份。