rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
# 1.4. 第一个端到端模型

## 1.4.1. 完整程序

```rust
use ndarray::{Array1, Array2};
use rustyml::machine_learning::LogisticRegression;
use rustyml::metrics::{ConfusionMatrix, accuracy};
use rustyml::set_global_seed;
use rustyml::utils::StandardScaler;
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    // 设置全局种子,保证可复现性
    set_global_seed(42);

    // 造一个合成的二分类数据集
    let n_per_class = 30usize;
    let mut rows: Vec<f64> = Vec::with_capacity(2 * n_per_class * 2);
    let mut labels: Vec<f64> = Vec::with_capacity(2 * n_per_class);
    for i in 0..n_per_class {
        let a = (i % 6) as f64;
        let b = (i / 6) as f64;
        // 类别0中心在-0.6附近
        // 类别1在+0.6附近
        rows.push(-0.6 + 0.3 * a);
        rows.push((-0.6 + 0.3 * b) * 100.0);
        labels.push(0.0);
        rows.push(0.6 + 0.3 * a);
        rows.push((0.6 + 0.3 * b) * 100.0);
        labels.push(1.0);
    }
    let n = labels.len();
    let x: Array2<f64> = Array2::from_shape_vec((n, 2), rows).unwrap();
    let y: Array1<f64> = Array1::from(labels);

    // 划分训练和测试集
    let (x_train, x_test, y_train, y_test) =
        train_test_split(x, y, Some(0.25), Some(42)).unwrap();

    // 标准化:统计量只在训练集上拟合,然后应用到两个划分
    let mut scaler = StandardScaler::new();
    let x_train_std = scaler.fit_transform(&x_train).unwrap();
    let x_test_std = scaler.transform(&x_test).unwrap();

    // 训练
    let mut model = LogisticRegression::new(true, 0.5, 1000, 1e-6).unwrap();
    model.fit(&x_train_std, &y_train).unwrap();

    // 在测试集上预测
    let preds: Array1<f64> = model.predict(&x_test_std).unwrap();

    // 评估
    let acc = accuracy(&y_test, &preds);
    let cm = ConfusionMatrix::new(&y_test, &preds);
    let (tp, fp, tn, fn_) = cm.get_counts();
    println!("test accuracy = {:.3}", acc);
    println!("TP={tp} FP={fp} TN={tn} FN={fn_}");
    println!("{}", cm.summary());
    println!("iterations run: {:?}", model.get_actual_iterations());

    // 保存模型与加载模型
    let path = "logreg_model.bin";
    model.save_to_path(path).unwrap();
    let loaded = LogisticRegression::load_from_path(path).unwrap();
    let preds_loaded = loaded.predict(&x_test_std).unwrap();
    println!("round-trip predictions identical: {}", preds == preds_loaded);
}
```

这些 `println!` 会打印一份小报告,格式类似于:

```text
test accuracy = 0.XXX
TP=.. FP=.. TN=.. FN=..
Confusion Matrix:
+-----------------+--------------------+--------------------+
|                 | Predicted Positive | Predicted Negative |
+-----------------+--------------------+--------------------+
| Actual Positive | TP: ..             | FN: ..             |
| Actual Negative | FP: ..             | TN: ..             |
+-----------------+--------------------+--------------------+
... derived metrics ...
iterations run: Some(..)
round-trip predictions identical: true
```

## 1.4.2. 设置种子以确保可复现性

`rustyml::set_global_seed`接收一个`u64`。它设置一个线程局部的种子,同一线程上此后构造的每个没设种子(`random_state == None`)的组件,都会以它为源派生自己的RNG,模仿Keras风格的全局种子模型,详见[7.1. 可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md)。

在这条流水线里,它对数字没有任何影响。`LogisticRegression::fit` 就是普通的全批量梯度下降,毫无随机性(对同一份数据拟合两次,得到的权重逐位相同),`standardize` 同样是确定的。唯一带随机性的步骤是`train_test_split`内部的洗牌,而我给它显式传了`Some(42)`。按局部种子(如果设置了)大于全局的原则,这部分改这个局部种子。建议养成在代码开头设置`set_global_seed`的习惯。

## 1.4.3. 数据集与标签规范

特征是形状为`(n_samples, n_features)`的`Array2<f64>`,标签是 `Array1<f64>`。标签的元素类型不是随手定的,`LogisticRegression::fit`要求`y`是一个`f64`数组,取值必须**恰好**是`0.0`或`1.0`(你可以在[它的文档](https://docs.rs/rustyml/0.15.0/rustyml/machine_learning/linear_model/logistic_regression/struct.LogisticRegression.html#method.fit)注释中看到这条规定),其他任何值都会返回`Error::InvalidInput`。`fit`里没有内置的`LabelEncoder`步骤,如果你的标签是字符串或任意整数,得自己转换,详见[4.3. 标签编码](../Chapter-04/4.3._标签编码.md)。

## 1.4.4. 划分数据

```rust,ignore
let (x_train, x_test, y_train, y_test) = train_test_split(x, y, Some(0.25), Some(42)).unwrap();
```

`train_test_split`会消耗`x`和`y`,如果你在这之后还需要原始数据就需要提前克隆。`test_size`参数传`None`表示`0.3`(70/30 划分),`random_state`传`None`表示从系统熵取种子(若设了全局种子,则从全局种子取)。

空数据集是会返回`Error::EmptyInput`,`x`/`y`长度不一致会返回`Error::DimensionMismatch`,`test_size`值在`(0, 1)`之外是`Error::InvalidParameter`,数据集小到无法划分会返回`Error::InvalidInput`。函数会在两侧各保留至少一个样本,比如对十行数据取 `test_size = 0.99`,训练侧仍会剩1行而不是0行。

类别不平衡时,普通的随机划分可能把某个类别全部划分到测试集或是全部划分到训练集。这时候最好使用`train_test_split_stratified`,它对每个类别独立划分。

划分的具体机制和分层变体详见[4.1. 训练集与测试集划分](../Chapter-04/4.1._训练集与测试集划分.md)。

## 1.4.5. 标准化时不泄露测试信息

逻辑回归靠梯度下降拟合,而梯度下降在意特征的尺度。上面的代码构造出的特征2比特征1大一百倍,这会导致梯度被特征2主导,需要把每个特征标准化到零均值、单位方差。

有两种方法做标准化:
- 调用函数`standardize(data, axis)`:它是**无状态、一次性**的变换,它从*你传进去的那个数组*算出均值和标准差,返回一个新的标准化数组,什么都不保留。
- 使用`StandardScaler`结构体以及其方法:它能够存储状态,调用`fit`会计算统计量并把它们存下来,`transform`把存储好的统计量施加到之后交给它的任何数组上。

用函数时,常见的错误的做法是对`x_train`调一次`standardize`,再对`x_test`调一次,这样会导致训练集和测试集落到两套不同的尺度上。在划分*之前*就标准化整个矩阵会把测试集的均值和方差揉进模型训练所用的数字里,导致准确率虚高。

正确的方法是使用`StandardScaler`结构体以及其方法,在训练集上拟合一次,然后变换所有东西:

```rust,ignore
use rustyml::utils::StandardScaler;

let mut scaler = StandardScaler::new();
let x_train_std = scaler.fit_transform(&x_train).unwrap(); // 在这里从训练集计算到均值/标准差
let x_test_std = scaler.transform(&x_test).unwrap();       // 对测试集套用相同的统计量
```

只在训练集上使用`fit`/`fit_transform`,其余用`transform`。统计量是可以获取的(`scaler.get_mean()`、`get_scale()`)。 除数是总体标准差(`ddof = 0`,与scikit-learn的`StandardScaler`一致)。

当你不需要保持统计量一致,或者你需要结构体不覆盖的`Row`/`Global`轴时就可以用函数:

```rust
use ndarray::array;
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    let x = array![[1.0, 200.0], [3.0, 400.0], [5.0, 600.0]];
    let z = standardize(&x, StandardizationAxis::Column).unwrap();
    println!("standardized shape: {:?}", z.dim());
    println!("{:?}", z);
}
```

输出:

```
standardized shape: (3, 2)
[[-1.224744871391589, -1.224744871391589],
 [0.0, 0.0],
 [1.224744871391589, 1.224744871391589]], shape=[3, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2
```

更完整的教程见[4.2. 标准化与归一化](../Chapter-04/4.2._标准化与归一化.md)。

## 1.4.6. 训练模型

```rust,ignore
let mut model = LogisticRegression::new(true, 0.5, 1000, 1e-6).unwrap();
model.fit(&x_train_std, &y_train).unwrap();
```

`LogisticRegression::new`返回`Result<Self, Error>`,因为它会先校验超参数,如果不符合要求就会返回错误`Error::InvalidParameter`。你也可以使用`LogisticRegression::default()`来使用设定了默认值的模型(`fit_intercept = true`、`learning_rate = 0.01`、`max_iter = 100`、`tol = 1e-4`)。相比于默认值`new`里传的学习率和迭代预算都调大了,因为标准化后的特征容得下更大的步长。正则化默认关闭,如果需要可以用`.with_regularization(RegularizationType::L2(alpha))`打开,这个方法的返回值同样被`Result`包裹(负的或非有限的`alpha`会返回错误)。RustyML的惩罚强度计算与scikit-learn的`SGDClassifier`/`SGDRegressor`保持一致。`LogisticRegression(C=c)`则对应`alpha = 1 / (c * n)`,`n`是训练样本数。完整的换算表就写在`RegularizationType`自己的文档注释里。

`fit`接受特征矩阵和目标向量的引用。它的返回值被`Result`包裹,用于防范潜在的错误:
- `0.0`/`1.0`标签检查
- 空输入(`Error::EmptyInput`)
- `x`/`y` 长度不一致(`Error::DimensionMismatch`)
- 非有限的特征值(`Error::NonFinite`)。如果权重或损失溢出,它也会在循环中途触发 `Error::NonFinite`。

当相邻迭代间的损失变化在`tol`以下,或者迭代次数等于`max_iter`之后就会停止迭代。你可以通过`model.get_actual_iterations()`知道是哪种情况停止的迭代:如果它等于`max_iter`,说明模型到了迭代次数上限而非收敛,你该调高`max_iter`或重新设置学习率。更多关于逻辑回归的内容详见[2.2. 逻辑回归](../Chapter-02/2.2._逻辑回归.md)。

## 1.4.7. 预测

```rust,ignore
let preds: Array1<f64> = model.predict(&x_test_std).unwrap();
```

`predict`返回`Result<Array1<f64>, Error>`,每个元素是硬类别标签`0.0`或`1.0`(sigmoid概率在`0.5`处阈值化)。如果你要的是底层概率而非标签就调用`predict_proba`。

`predict`的返回值同样被`Result`包裹,用于防范潜在的错误:
- 在`fit`之前调用它是`Error::NotFitted`
- 传入的特征数与模型训练时的不同是`Error::DimensionMismatch`

## 1.4.8. 准确率与混淆矩阵

```rust,ignore
let acc = accuracy(&y_test, &preds);
let cm = ConfusionMatrix::new(&y_test, &preds);
let (tp, fp, tn, fn_) = cm.get_counts();
println!("{}", cm.summary());
```

`accuracy(y_true, y_pred) -> f64`计算准确率,也就是标签相符的比例。通过`ConfusionMatrix::new(y_true, y_pred)`构建混淆矩阵,`get_counts()`返回`(tp, fp, tn, fn)`。混淆矩阵只收**硬**标签,两个数组的每个元素都必须恰好是 `0.0` 或 `1.0`,其余一律panic。若标签用的是另一对取值——比如间隔分类器给出的`-1.0`/`+1.0`——就用`ConfusionMatrix::new_with_labels(y_true, y_pred, negative_label, positive_label)`,对应scikit-learn的`confusion_matrix(..., labels=[neg, pos])`。它的方法有`accuracy()`、`precision()`、`recall()`、`specificity()`、`f1_score()`、`balanced_accuracy()`和`mcc()`,也可以打印 `summary()` 得到前面那张格式化表格。数据不平衡时,别用原始准确率,应该使用`balanced_accuracy()`或`mcc()`。

更多的分类指标详见[5.2. 分类指标](../Chapter-05/5.2._分类指标.md)。

## 1.4.9. 保存与加载模型

```rust,ignore
model.save_to_path("logreg_model.bin").unwrap();
let loaded = LogisticRegression::load_from_path("logreg_model.bin").unwrap();
```

`save_to_path(&self, path: &str) -> Result<(), Error>`用[postcard](https://docs.rs/postcard)把整个模型(权重、截距开关、学习率、迭代次数、正则化设置)序列化成二进制。通过`load_from_path(path: &str) -> Result<Self, Error>`可以读取。详细内容见[3.9. 权重保存与加载](../Chapter-03/3.9._权重保存与加载.md)与[7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。