rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
# 2.2. 逻辑回归

`LogisticRegression` 是 RustyML 面向**二分类**问题的线性分类器。它的机制和 [`LinearRegression`](./2.1._线性回归.md) 几乎一样:一个权重向量、一个可选的截距、梯度下降、可选的 L1 或 L2 惩罚。它把平方误差目标换成了逻辑损失,还让线性得分经过 sigmoid,输出因而读作概率。它对应 scikit-learn 里精简到核心的 `sklearn.linear_model.LogisticRegression`:只支持一条二分类边界,用朴素的全批量梯度下降,不内置多分类,正则化强度也直接表示,而非用倒数 `C`。

## 2.2.1. 模型到底在优化什么

每个样本先得到一个线性得分 `z = w * x`(拟合截距时再加上偏置)。sigmoid `sigmoid(z) = 1 / (1 + e^-z)` 把这个得分映射到 `(0, 1)`,RustyML 把它读作正类的概率。训练最小化这些概率与标签之间的平均二元交叉熵,优化器就是朴素的**全批量梯度下降**:每次迭代在整个训练集上计算梯度 `(1/n) * X^T * (sigmoid(X * w) - y)`,然后沿反方向走一步,步长为 `learning_rate`。

这个选择在实践中有两个后果。其一,没有随机采样,也没有随机初始化:权重从精确的零开始,更新是确定的。相同数据和超参数上跑两次,得到的权重逐位相同(测试套件验证了这一点)。其二,全批量梯度下降是只有一个全局步长的一阶方法,对特征尺度和 `learning_rate` 的敏感程度,远高于 scikit-learn 默认的拟牛顿求解器(`lbfgs`、`liblinear`)。这是它和那些求解器之间最大的行为差异,也决定了 [2.2.6](#226-标准化类别不平衡与可分数据) 里的实用建议。

RustyML 用数值稳定的 log-sum-exp 形式 `max(z, 0) - z * y + ln(1 + e^-|z|)` 计算损失,而不是对 sigmoid 取对数,因此幅度很大的 logit 不会让损失计算本身溢出。权重仍可能溢出,2.2.6 节把它作为一种真实的故障模式来讨论。每次迭代的 logit、梯度和损失,只要规模越过尺寸门限,就交由 RustyML 的并行 GEMV 与确定性归约原语来算,因此在同一台机器上结果可复现。详见 [7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)。

## 2.2.2. 构造模型

有两个构造器。`LogisticRegression::default()` 给出一个合理的起点,`LogisticRegression::new(...)` 让你设定每一个超参数,并在一开始就逐个校验。

```rust
use rustyml::machine_learning::LogisticRegression;

fn main() {
    // 默认值:fit_intercept = true, lr = 0.01, max_iter = 100, tol = 1e-4, 无惩罚
    let _a = LogisticRegression::default();

    // 显式指定:new(fit_intercept, learning_rate, max_iterations, tolerance)
    let _b = LogisticRegression::new(true, 0.1, 1000, 1e-6).unwrap();
}
```

`new` 返回 `Result<Self, Error>`。遇到非法超参数会立刻用 [`Error::InvalidParameter`](../Chapter-01/1.6._错误处理.md) 拒绝,而不是拖到 fit 时才失败。

| 参数 | 类型 | 默认值 | 约束 |
| --- | --- | --- | --- |
| `fit_intercept` | `bool` | `true` | 无 |
| `learning_rate` | `f64` | `0.01` | 严格为正且有限 |
| `max_iterations` | `usize` | `100` | 至少为 1 |
| `tolerance` | `f64` | `1e-4` | 严格为正且有限 |

默认值刻意保守:`learning_rate = 0.01` 加上仅 `100` 次迭代,除了平凡、尺度良好的数据,几乎收敛不了。把 `default()` 当作冒烟测试,而不是生产配置。大多数真实拟合都需要更大的 `learning_rate`(先标准化数据)和数千量级的 `max_iterations`。

每个存储的值都有 getter。其中 2 个是模型拟合后的诊断量:

| Getter | 返回 |
| --- | --- |
| `get_fit_intercept()` | `bool` |
| `get_learning_rate()` | `f64` |
| `get_max_iterations()` | `usize` |
| `get_tolerance()` | `f64` |
| `get_regularization_type()` | `Option<RegularizationType>` |
| `get_actual_iterations()` | `Option<usize>`(实际跑过的迭代次数,fit 之前为 `None`) |
| `get_weights()` | `Option<&Array1<f64>>`(fit 之前为 `None`) |

`get_actual_iterations()` 就是收敛检查。相邻两次迭代的损失变化小于 `tolerance`,或者达到 `max_iterations` 时,训练停止。如果返回的次数等于 `max_iterations`,就认定模型**没有收敛**到该容差。这时应调大 `max_iterations`、调大 `learning_rate`,或标准化输入,而不是相信这个边界值。

## 2.2.3. 标签约定:严格的 0 与 1

`fit` 接收特征矩阵 `x`(行为样本,列为特征)和目标向量 `y`,两者都必须是同一存储类型的 `f64` 数组。标签的取值域是精确的:`y` 的每个元素必须是 `0.0` 或 `1.0`。RustyML 会拒绝其他任何值,例如 `0.5`、`2.0` 或 `-1.0`,抛出 [`Error::InvalidInput`](../Chapter-01/1.6._错误处理.md)。这与 [`SVC`](./2.5._支持向量机.md) 和 [`LinearSVC`](./2.5._支持向量机.md) 的标签约定一致,它们的预测结果同样是 `0.0` 和 `1.0`。如果你的标签是别的编码,先映射到 `{0, 1}`。字符串或类别标签,先用 [4.3. 标签编码](../Chapter-04/4.3._标签编码.md) 编码。刻意挑好哪一类是正类,也就是那个 `1`,因为这个选择定义了精确率、召回率和概率输出的含义。

```rust
use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    // 逻辑 AND,编码为 {0.0, 1.0}
    let x_train = array![[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]];
    let y_train = array![0.0, 0.0, 0.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.5, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    let preds = model.predict(&x_train).unwrap(); // Array1<f64>,取值在 {0.0, 1.0}
    println!("predictions: {:?}", preds);
    println!("iterations:  {:?}", model.get_actual_iterations());
}
```

`fit_intercept` 为 true 时,模型会把偏置作为权重的第 0 项前置,`get_weights()` 于是返回 `n_features + 1` 个值。`fit_intercept` 为 false 时,它正好返回 `n_features` 个值。查看权重时这个下标很要紧,因为正则化器对截距有特殊处理(见下文)。

## 2.2.4. 预测:硬标签还是概率

RustyML 有 3 个预测入口,硬标签与概率之间的区别正是最容易搞错的地方。

- `predict(&x) -> Result<Array1<f64>, Error>` 返回硬类别标签 `0.0` 或 `1.0`,做法是把正类概率以 **0.5** 为阈值二值化。
- `predict_proba(&x) -> Result<Array1<f64>, Error>` 返回 `(0, 1)` 之间的原始正类概率,每个样本一个值,即 sigmoid 的输出。
- `fit_predict(&mut self, x, y)` 先跑 `fit`,再在同一个 `x` 上跑 `predict`,方便快速核验训练集。

`predict` 完全等价于 `predict_proba` 再套一个固定的 `>= 0.5` 切分,模型上没有阈值参数。这个固定切分对均衡问题没问题,但 0.5 这条边界是建模选择,不是规则。当假阳性和假阴性的代价不同、或类别不均衡时,改调 `predict_proba`,自己定阈值。

```rust
use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    let x_train = array![[-3.0], [-2.0], [-1.0], [1.0], [2.0], [3.0]];
    let y_train = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.3, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    let x_test = array![[-0.5], [0.5]];
    let proba = model.predict_proba(&x_test).unwrap(); // 正类概率
    let default = model.predict(&x_test).unwrap();     // 0.5 阈值 -> {0.0, 1.0} 的 Array1<f64>

    // 更严格的工作点:只有 p >= 0.8 才判为正类
    let strict: Vec<i32> = proba.iter().map(|&p| if p >= 0.8 { 1 } else { 0 }).collect();

    println!("proba:   {:?}", proba);
    println!("default: {:?}", default);
    println!("strict:  {:?}", strict);
}
```

这 3 个方法都会用训练时的特征数(不含隐式的偏置列)校验输入。对未拟合的模型调用返回 `Error::NotFitted`。列数不对返回 `Error::DimensionMismatch`。出现 `NaN` 或无穷元素返回 `Error::NonFinite`。传入特征时不要手动加偏置列,模型内部会自行增删这一列,以匹配它训练时的方式。

## 2.2.5. 正则化

默认**不带惩罚**,这一点与 scikit-learn(默认 L2)不同。用 builder 方法 `with_regularization` 加惩罚,它会消费并返回模型,因此可以直接接在 `new` 后面链式调用:

```rust
use ndarray::array;
use rustyml::machine_learning::{LogisticRegression, RegularizationType};

fn main() {
    let x = array![
        [-4.0, -3.0], [-3.0, -4.0], [-2.0, -1.0],
        [ 2.0,  1.0], [ 3.0,  4.0], [ 4.0,  3.0],
    ];
    let y = array![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];

    let mut plain = LogisticRegression::new(true, 0.1, 2000, 1e-8).unwrap();
    plain.fit(&x, &y).unwrap();

    let mut ridge = LogisticRegression::new(true, 0.1, 2000, 1e-8)
        .unwrap()
        .with_regularization(RegularizationType::L2(5.0))
        .unwrap();
    ridge.fit(&x, &y).unwrap();

    // 特征权重的 L2 范数,跳过下标 0 处那个不受惩罚的偏置
    let feature_norm = |m: &LogisticRegression| {
        m.get_weights().unwrap().iter().skip(1).map(|w| w * w).sum::<f64>()
    };
    println!("no penalty: {:.4}", feature_norm(&plain));
    println!("L2(5.0):    {:.4}", feature_norm(&ridge));
}
```

每个变体里的 `f64` 是惩罚强度 `alpha`,必须非负且有限(`alpha = 0` 也接受,等价于无惩罚)。[`RegularizationType::L2(alpha)`](./2.1._线性回归.md#216-正则化l1-与-l2)(ridge)在损失上加 `alpha * 0.5 * ||w||^2`,把权重平滑地朝零收缩。`RegularizationType::L1(alpha)`(lasso)加 `alpha * ||w||_1`,会把个别权重压到精确的零,给出一个对特征选择很有用的稀疏模型。

“精确的零”是字面意思。RustyML **不是**把 L1 以 `alpha * sign(w)` 的形式折进梯度里的:次梯度步只能逼近零,那种写法不管跑多久都给不出稀疏性。取而代之的是,优化器先走完常规的梯度步,再施加一个**邻近步**。每个特征权重按 `learning_rate * alpha` 做软阈值,于是数据撑不起来的权重会落到 `0.0` 并停在那儿。这个方法叫 ISTA,正是它让 L1 成为一个真正的特征选择器。用 `w.iter().skip(1).filter(|v| **v == 0.0).count()` 就能数出这些零。

有 3 个实现细节会改变你挑 `alpha` 的方式:

- **截距从不受惩罚。** 模型拟合截距时,惩罚梯度从特征下标 1 算起,偏置因而可以自由移动。这是标准且正确的选择:正则化器不应妨碍模型平移决策边界的能力。比较权重范数时应跳过下标 0,就像示例里那样。
- **惩罚不除以样本数。** 数据项是平均对数损失,但 RustyML 把惩罚以绝对值 `alpha * R(w)` 加进去。所以无论数据有多少行,`alpha` 的含义都固定不变:把数据集复制一份,正则化后的最优解不变(测试套件核验了这个不变性)。在这里,`alpha` 越大意味着正则化越强,方向与 scikit-learn 的倒数 `C` 相反。
- **`alpha` 可以从 scikit-learn 的 SGD 估计器 1:1 搬过来,从别的估计器则需要换算。** 上面「均值数据项加不作除法的惩罚项」这个目标,恰好和 `SGDClassifier` 的一致,所以在那边调好的 `alpha` 原样搬过来即可。若来自 `LogisticRegression(C=c)`,就用 `alpha = 1 / (c * n)`,其中 `n` 是训练样本数。完整的换算表挂在 `RegularizationType` 上,与 [`LinearRegression`](./2.1._线性回归.md#216-正则化l1-与-l2) 共用。

## 2.2.6. 标准化、类别不平衡与可分数据

有 3 种故障模式常见到值得在这里点名。

**标准化你的特征。** 优化器是只有一个全局 `learning_rate` 的全批量梯度下降,尺度天差地别的特征因而收敛速率也天差地别。对取值在 `[0, 1]` 的特征恰到好处的那一步,对取值在 `[0, 10000]` 的特征就太小了。于是训练爬行,损失在 `max_iterations` 之内就停在了够不着 `tolerance` 的地方。先用 [4.2. 标准化与归一化](../Chapter-04/4.2._标准化与归一化.md) 里的工具做中心化和缩放。这一步对收敛速度和数值稳定性的收益最大。它还能让你用上真正管用的 `learning_rate`,比如 `0.1` 到 `1.0`,而不是那个保守的默认值。

**没有类别加权。** 模型没有 `class_weight` 参数,`predict` 内部阈值固定在 0.5。在不均衡数据上,它可能只学会预测多数类,还照样报出一个不低的准确率。用 2 道防线来应对:自己在从精确率-召回率或 ROC 分析选出的工作点上给 `predict_proba` 定阈值;再用不会被不均衡骗到的指标来评估,例如 [5.2. 分类指标](../Chapter-05/5.2._分类指标.md) 里的 `balanced_accuracy`、`mcc` 或 `roc_auc`,而不是原始准确率。

**完全可分的数据会让无正则化的最大似然估计发散。** 当一个超平面把两类干净地分开时,似然靠把权重范数推向无穷来最大化,因为概率会饱和到 0 和 1。无正则化的梯度下降因而跑得越久、权重就越大。实践中 `max_iterations` 和 `tolerance` 会把训练截断,分类结果保持正确。权重,以及由此得到的概率,仍然会变得数量级任意、校准很差。在幅度很大的可分输入上使用较大的 `learning_rate`,还可能让整个权重更新完全溢出。RustyML 会在循环内用 `Error::NonFinite` 守卫抓住它,而不是悄悄返回 `NaN`。哪怕只加一点 L2 惩罚,也能给最优解定界、让概率保持有意义,并消除溢出风险。这正是生产中默认保留惩罚的主要理由。

## 2.2.7. 用多项式特征拟合非线性边界

决策边界在你给它的特征空间里是线性的,非线性问题因而需要更丰富的空间。`generate_polynomial_features(&x, degree)` 把每一行展开成直到 `degree` 次的所有单项式。2 个特征、2 次时,这会给出 5 列 `[x1, x2, x1^2, x1*x2, x2^2]`,不含常数列(截距已经提供了)。在展开后的数据上拟合,并**在同样的展开上预测**。

```rust
use ndarray::array;
use rustyml::machine_learning::{LogisticRegression, generate_polynomial_features};

fn main() {
    // 内圈 = 类别 0,外圈 = 类别 1:在 (x1, x2) 中不是线性可分的
    let x = array![
        [ 1.0, 0.0], [0.0,  1.0], [-1.0, 0.0], [0.0, -1.0],
        [ 5.0, 0.0], [0.0,  5.0], [-5.0, 0.0], [0.0, -5.0],
    ];
    let y = array![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];

    // 2 个特征,2 次 -> [x1, x2, x1^2, x1*x2, x2^2]
    let x_poly = generate_polynomial_features(&x, 2);
    assert_eq!(x_poly.ncols(), 5);

    let mut model = LogisticRegression::new(true, 0.01, 3000, 1e-7).unwrap();
    model.fit(&x_poly, &y).unwrap();

    // x1^2 + x2^2 这一项让两个圆环变得线性可分
    let preds = model.predict(&x_poly).unwrap();
    println!("{:?}", preds);
}
```

列数随特征数和次数组合式增长,3 个特征、3 次就已经是 19 列。这个工具只适合少数几个特征、低次数的场合。当展开变大时,改用像 [SVC](./2.5._支持向量机.md) 这样的核方法。

## 2.2.8. 带评估的完整示例

这个示例在一个小的双特征数据集上拟合,再用[第 5 章](../Chapter-05/5.0._模型评估.md)的分类指标评估。`predict` 已经把硬标签 `{0.0, 1.0}` 以 `Array1<f64>` 的形式返回,这正是 [`ConfusionMatrix::new`](../Chapter-05/5.2._分类指标.md) 所要求的。它自己不做任何阈值化,所以会拒绝一个概率向量,而不是悄悄把它二值化。`roc_auc` 则反过来:它要的是一个布尔真值向量,配上连续的 `predict_proba` 得分,因为排序才是它的重点。

```rust
use ndarray::{array, Array1};
use rustyml::machine_learning::LogisticRegression;
use rustyml::metrics::{ConfusionMatrix, accuracy, roc_auc};

fn main() {
    let x_train = array![
        [-2.0, -1.5], [-1.5, -2.0], [-1.0, -0.5], [-2.5, -1.0], [-0.5, -1.0],
        [ 2.0,  1.5], [ 1.5,  2.0], [ 1.0,  0.5], [ 2.5,  1.0], [ 0.5,  1.0],
    ];
    let y_train = array![0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.5, 500, 1e-7).unwrap();
    model.fit(&x_train, &y_train).unwrap();

    // 已经是硬标签 {0.0, 1.0},正是 ConfusionMatrix::new 所要求的
    let preds = model.predict(&x_train).unwrap();

    let cm = ConfusionMatrix::new(&y_train, &preds);
    println!("{}", cm.summary());
    println!("accuracy:  {:.3}", accuracy(&y_train, &preds));

    // ROC AUC 对概率排序,所以它需要原始得分,而不是 0/1 标签
    let truth: Array1<bool> = y_train.mapv(|v| v > 0.5);
    let scores = model.predict_proba(&x_train).unwrap();
    println!("ROC AUC:   {:.3}", roc_auc(&truth, &scores));
}
```

`ConfusionMatrix::summary()` 会在一张表里打印计数,连同准确率、平衡准确率、精确率、召回率、特异度、F1 和 MCC。它给出了核验二分类器最快的办法。把 `predict_proba`(而非阈值化后的标签)喂给 `roc_auc`,正是这一点让 AUC 成为与阈值无关的排序质量度量。它回答的是模型把正样本排在负样本之上的能力有多好,与你之后把切分点定在哪里无关。上面的输出取决于数据,这里给出应当预期的形态:

```text
Confusion Matrix:
+-----------------+--------------------+--------------------+
| ...             | Predicted Positive | Predicted Negative |
...
Performance Metrics:
- Accuracy:          <0.0..1.0>
- ...
accuracy:  <0.0..1.0>
ROC AUC:   <0.0..1.0>
```

## 2.2.9. 持久化与可复现性

训练好的模型通过 `save_to_path` 和 `load_from_path` 序列化为紧凑的 postcard 二进制,携带权重、超参数和迭代次数。往返是逐字节精确的,所以加载后模型的预测与原模型的预测分毫不差。

```rust
use ndarray::array;
use rustyml::machine_learning::LogisticRegression;

fn main() {
    let x = array![[-2.0, 1.0], [-1.0, -1.0], [1.0, 1.0], [2.0, -1.0]];
    let y = array![0.0, 0.0, 1.0, 1.0];

    let mut model = LogisticRegression::new(true, 0.3, 500, 1e-7).unwrap();
    model.fit(&x, &y).unwrap();

    let path = "logreg_model.bin";
    model.save_to_path(path).unwrap();
    let loaded = LogisticRegression::load_from_path(path).unwrap();

    assert_eq!(model.predict(&x).unwrap(), loaded.predict(&x).unwrap());

    std::fs::remove_file(path).unwrap();
    println!("round-trip OK");
}
```

拟合过程里没有任何随机性,可复现性因而是白来的:不像本章那些基于采样的模型,这里没有种子要设。相同数据和超参数上的 2 次拟合会产生相同的权重,这正是持久化往返能做到精确的原因。哪里的种子**确实**要紧,见 [7.1. 可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md);序列化格式及其跨版本的限制,见 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。如果你用 `show_progress` feature 构建,`fit` 还会渲染一个带实时损失的进度条,方便你盯着上面描述的不收敛和发散行为。