rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# 2.1. 线性回归

`LinearRegression` 用最小二乘拟合一个线性映射 `y_hat = X * w + b`。和 scikit-learn 的 `LinearRegression` 一样,RustyML 的默认路径是精确的闭式解。系数与 Python 那边的结果吻合到 `1e-15` 量级。批量梯度下降按需提供,用于闭式解应付不了的场景。

这个分工决定了本页的一切。默认估计器没有学习率、没有迭代预算,也没有收敛容差要设置。确实存在的那 3 个设置属于迭代求解器,并跟着它一起走。本页内容取自 `src/machine_learning/linear_model/linear_regression.rs` 及其集成测试。

## 2.1.1. 模型是如何训练的

两种策略最小化的是同一个目标,由 [`LeastSquaresSolver`] 在它们之间做选择。

**闭式解([`LeastSquaresSolver::Normal`],默认)** 一步到位地解出岭回归方程组。没有惩罚项时,这就是普通 OLS。它在增广设计矩阵 `[Xc; sqrt(lambda) * I]` 上做 SVD 最小二乘求解。即便 `X^T * X` 奇异(比如列完全共线,或者特征数多于样本数),也能给出最小范数解。设置了 `fit_intercept` 时,求解器会先对特征和目标做去均值中心化,好让截距置身惩罚项之外。然后再把截距还原为 `mean(y) - mean(X) * w`。闭式解没有任何东西可迭代。用正规方程求解器拟合之后,`get_actual_iterations()` 返回 `Some(0)`。这个 `0` 表明拟合没有走任何梯度步。

**梯度下降([`LeastSquaresSolver::GradientDescent`])** 把权重向量 `w` 初始化为零、截距 `b` 初始化为零,然后反复做全批量更新。每一轮迭代都会在整个训练集上算出预测值和残差、标量代价、梯度,以及参数的更新步。

代价是均方误差取一半,再加上一个可选的惩罚项。记 `n` 为样本数,`e = X * w + b - y` 为残差向量。一轮迭代算出 `cost = dot(e, e) / (2 * n) + penalty`。无正则化时 `penalty` 项是 `0`。L1 下是 `alpha * sum(|w_j|)`。L2 下是 `(alpha / 2) * dot(w, w)`。只有设置了 `fit_intercept` 时,预测才会加上截距 `b`。正则化永远不会惩罚截距,因为它收缩的是斜率,不是偏置。

梯度可以直接写出。权重梯度是 `grad_w = (X^T * e) / n`,L2 下再加上 `alpha * w`。截距梯度是 `grad_b = sum(e) / n`,`fit_intercept` 为 false 时则是 `0`。更新步就是学习率固定为 `learning_rate` 的朴素梯度下降:把 `w` 设为 `w - learning_rate * grad_w`,把 `b` 设为 `b - learning_rate * grad_b`。L1 不出现在梯度里。求解器是在更新步之后,通过一个邻近算子来施加它的(见 [2.1.6](#216-正则化l1-与-l2))。这里的梯度下降没有动量,没有自适应学习率,也没有线搜索。你在求解器上设置的步长,就是每一轮实际使用的步长。这正是特征缩放如此关键的原因(见 [2.1.5](#215-先把特征标准化))。

循环内部跑着 3 道数值防线。一旦代价、任意一个梯度分量,或者任意一个更新后的参数变成 NaN 或无穷,`fit` 就会立刻以 [`Error::NonFinite`] 中止,而不是返回一个垃圾模型。学习率一旦发散,这道防线会在寥寥几轮迭代内触发,不会悄无声息地产出 `inf` 系数。闭式解也对自己算出的解做了同一类检查。

求解器用确定性的分块折叠来计算残差平方和以及截距梯度的求和。矩阵-向量乘积 `X * w` 和 `X^T * e` 会在超过一个内部尺寸阈值后并行执行。两个求解器都不带任何随机性。在同一台机器上用同一份数据跑两遍,得到的系数完全相同。确定性测试断言了两个独立构造的模型给出逐比特一致的预测。这背后的并行归约机制,见 [7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)。

## 2.1.2. 构造模型

构造函数只收一个参数,而且不会失败:

```rust,ignore
pub fn new(fit_intercept: bool) -> Self
```

| 参数 | 类型 | 含义 |
| --- | --- | --- |
| `fit_intercept` | `bool` | 拟合一个偏置项。为 `false` 时,拟合出的直线过原点,存下来的截距恰好是 `0.0`。 |

这里没有别的东西需要校验,这就是为什么 `new` 返回的是 `Self` 而不是 `Result`。过去挂在估计器身上的那些迭代设置,如今住进了用到它们的求解器变体里。改由 `with_solver` 来校验它们。

`LinearRegression::default()` 就等同于 `LinearRegression::new(true)`。它设置 `fit_intercept = true`,选用 [`LeastSquaresSolver::Normal`],且不应用正则化。现在这两个构造函数总是构建出同一个算法。`default()` 是 Python 中 `LinearRegression()` 的对应物,而且是精确的。

两个 builder 方法用于细化已构造的模型。两者都通过消费并返回 `self` 来实现,因此可以链式调用。两者都返回 `Result`,因为两者都会校验收到的东西:

```rust,ignore
pub fn with_solver(self, solver: LeastSquaresSolver) -> Result<Self, Error>
pub fn with_regularization(self, regularization: RegularizationType) -> Result<Self, Error>
```

`with_solver` 会检查所选变体的载荷。`learning_rate` 或 `tol` 非正或非有限,或者 `max_iter` 为 `0`,都会得到 [`Error::InvalidParameter`],并带上出问题的那个字段名。`LeastSquaresSolver::Normal` 没有载荷,所以选它永远不会失败。`with_regularization` 用同样的方式校验惩罚系数 `alpha`。它会当场拒绝负值或非有限的 `alpha`。正则化枚举在 `rustyml::machine_learning::RegularizationType` 处重新导出:

```rust,ignore
pub enum RegularizationType {
    L1(f64), // Lasso:惩罚项 alpha * sum(|w_j|)
    L2(f64), // Ridge:惩罚项 (alpha / 2) * dot(w, w)
}
```

每个超参数和拟合出的量都能通过 getter 读到。拟合类的量返回 `Option`,在 `fit` 跑过之前都是 `None`:

| Getter | 返回 | 含义 |
| --- | --- | --- |
| `get_fit_intercept()` | `bool` | 是否拟合了截距。 |
| `get_solver()` | `LeastSquaresSolver` | `Normal`,或 `GradientDescent { learning_rate, max_iter, tol }`。 |
| `get_regularization_type()` | `Option<RegularizationType>` | `None`、`L1(alpha)` 或 `L2(alpha)`。 |
| `get_coefficients()` | `Option<&Array1<f64>>` | 拟合出的权重向量(按引用)。 |
| `get_intercept()` | `Option<f64>` | 拟合出的截距。 |
| `get_actual_iterations()` | `Option<usize>` | 上一次 `fit` 实际跑的迭代次数。闭式解为 `Some(0)`。 |

没有 `get_learning_rate`、`get_max_iterations` 或 `get_tolerance` 这些访问器。那些数字是求解器变体的字段。对 `get_solver()` 做模式匹配就能读回来:

```rust,ignore
if let LeastSquaresSolver::GradientDescent { learning_rate, max_iter, tol } = model.get_solver() {
    println!("learning_rate = {learning_rate}, cap = {max_iter}, tol = {tol}"); // cap 即迭代次数上限
}
```

这里也是故意不提供代价历史访问器的。模型只存最终的迭代次数,不存逐轮的代价曲线。如果你想实时看着代价下降,就用 `show_progress` feature 来编译。这样 `fit` 就会渲染一个进度条,显示实时代价和收敛计数器。否则,`get_actual_iterations()` 就是你能看到优化器做了什么的唯一窗口(见 [2.1.7](#217-收敛与诊断不收敛))。

## 2.1.3. 拟合与预测

这四个面向数据的方法都对 ndarray 的存储类型 `S: Data<Elem = f64>` 做了泛型化,因此它们能一视同仁地接收拥有所有权的数组、视图和切片,且不发生拷贝:

```rust,ignore
pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<&mut Self, Error>;
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>;
pub fn fit_predict<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<Array1<f64>, Error>;
pub fn score<S1, S2>(&self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<f64, Error>;
```

`x` 是特征矩阵,一行一个样本、一列一个特征。`y` 是目标向量。`fit` 返回 `&mut Self` 以便链式调用。`predict` 返回预测向量。`fit_predict` 就是先在同一份数据上跑 `fit` 再跑 `predict`。`score` 返回决定系数 R^2(`1 - SS_res / SS_tot`)。`1.0` 是满分。`0.0` 对应一个总是预测均值的模型。负值则比预测均值还差。泛型的 [`Fit`] 和 [`Predict`] trait 把同样的操作暴露给需要跨估计器类型工作的代码。它们的 trait 方法只是转发到这些固有方法。

`fit` 会用 [`Error::EmptyInput`] 拒绝空的 `x`。它会用 [`Error::DimensionMismatch`] 拒绝长度与 `x` 行数不符的 `y`。它会用 [`Error::NonFinite`] 拒绝 `x` 里任何 NaN 或无穷的元素。在训练之前调用 `predict` 和 `score`,也会得到 [`Error::NotFitted`]。`predict` 还会检查列数是否与训练数据一致,同样会得到 [`Error::DimensionMismatch`]。这些全都来自 crate 统一的 [`Error`] 类型。见 [1.6. 错误处理](../Chapter-01/1.6._错误处理.md)。

在一个小的多元问题 `y = 2 * x1 + 3 * x2 + 1` 上完整跑一遍。除了截距之外什么都没配置,所以这就是精确的 OLS:

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

fn main() {
    // 六个样本,铺开整个特征空间。
    let x = array![
        [1.0, 1.0],
        [2.0, 1.0],
        [1.0, 2.0],
        [3.0, 2.0],
        [2.0, 3.0],
        [4.0, 1.0],
    ];
    let y = array![6.0, 8.0, 9.0, 13.0, 14.0, 12.0];

    // fit_intercept = true,闭式求解器,无正则化。
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    // 系数落在 [2.0, 3.0]、截距落在 1.0,精确到机器精度。
    println!("coefficients = {:?}", model.get_coefficients().unwrap());
    println!("intercept    = {}", model.get_intercept().unwrap());
    println!("iterations   = {}", model.get_actual_iterations().unwrap()); // 0

    // 在没见过的行上预测:(1,1) -> 6.0,(2,3) -> 14.0。
    let preds = model.predict(&array![[1.0, 1.0], [2.0, 3.0]]).unwrap();
    println!("predictions  = {:?}", preds);

    // 训练集上的 R^2,对严格线性的数据就是 1.0。
    println!("R^2          = {}", model.score(&x, &y).unwrap());
}
```

闭式解是直接落在最小二乘最优点上,而不是逼近它。恢复出的斜率精确到机器精度。相比之下,梯度下降只是朝那个解收敛。测试断言它的斜率与真实值的误差在 `3e-3` 以内。

## 2.1.4. 选择求解器:正规方程还是梯度下降

`LeastSquaresSolver` 是一个带载荷的枚举。每个变体恰好持有它自己用到的那些设置。你没法把学习率传给闭式解,也没法让迭代路径漏掉一个设置。

```rust,ignore
pub enum LeastSquaresSolver {
    Normal, // 默认
    GradientDescent { learning_rate: f64, max_iter: usize, tol: f64 },
}
```

对中小规模的稠密问题,`LeastSquaresSolver::Normal` 是合适的选择。这也是它成为默认值的原因。它精确、不用缩放、也没有需要调的超参数。

有 3 种情况更适合用梯度下降。第一,数据集大到闭式解的分解开销(大致是 `O(n * p^2)`)变得过高时。第二,你想用 L1 时,因为闭式解没有 L1 的解法。第三,你要对齐一套同样用迭代优化的 scikit-learn 或 Keras 流程时。

`Normal` 求解器只支持无正则化或 L2。给它配一个 L1 惩罚项会让 `fit` 返回 [`Error::InvalidInput`],因为 Lasso 没有闭式解。L1 请用梯度下降。

选择梯度下降和配置梯度下降是同一个表达式,其结果是 `Result`,因为载荷会被校验:

```rust
use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;

fn main() {
    // y = 3*x0 - 2*x1 + 5,严格成立。
    let x = array![
        [1.0, 1.0],
        [2.0, 0.0],
        [0.0, 3.0],
        [4.0, 2.0],
        [3.0, 1.0],
        [1.0, 4.0],
    ];
    let y = array![6.0, 11.0, -1.0, 13.0, 12.0, 0.0];

    // 精确解,一步到位。
    let mut exact = LinearRegression::new(true);
    exact.fit(&x, &y).unwrap();

    // 迭代解:设置跟着用到它们的那个变体一起走。
    let mut iterative = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.01,
            max_iter: 10_000,
            tol: 1e-9,
        })
        .unwrap();
    iterative.fit(&x, &y).unwrap();

    println!("exact     = {:?}", exact.get_coefficients().unwrap());     // ~ [3.0, -2.0]
    println!("iterative = {:?}", iterative.get_coefficients().unwrap()); // 朝同一个解逼近
    println!("iterations: exact = {:?}, iterative = {:?}",
        exact.get_actual_iterations(), iterative.get_actual_iterations());
}
```

两个求解器在求解同一个目标时,数值上是一致的。梯度下降的代价把数据项除以 `2 * n`,却只按 `alpha / 2` 缩放 L2 惩罚项。这使得原始平方和上等价的岭惩罚等于 `lambda = n * alpha`,这正是正规方程求解器所用的值。集成测试证实了:对同一个 `alpha`,闭式的 L2 解与梯度下降收敛到的结果相符。

## 2.1.5. 先把特征标准化

本节讲的是梯度下降。闭式解完全绕开了这整件事,这也是它成为默认值的又一个理由。

用单一全局学习率的梯度下降,效果好不好取决于你的特征尺度是否匹配。设想一列跨在千的量级,另一列却在单位量级附近。损失曲面就会变成一条又陡又窄的山谷。学习率小到能避免在陡峭轴上过冲,就会在平缓轴上爬得极慢,收敛要多花很多轮迭代。学习率大到能在平缓轴上有所进展,又会在陡峭轴上发散,触发有限性防线。把每一列标准化到零均值、单位方差,曲率就大致各向同性。这样一个学习率就能照顾到每个方向,你也能放心用更大的值。

`StandardScaler` 做的正是这件事。它会记住训练集的均值和标准差,因此它是用训练统计量去变换测试集,而不是用测试集自己的统计量。完整的训练/测试流程,以及无状态的 `standardize` 自由函数,见 [4.2. 标准化与归一化](../Chapter-04/4.2._标准化与归一化.md)。这时你读回来的系数是标准化单位下的,不是原始特征单位下的。

```rust
use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;
use rustyml::utils::StandardScaler;

fn main() {
    // 第 0 列在千的量级。第 1 列接近单位量级。
    let x_raw = array![
        [1000.0, 1.0],
        [2000.0, 3.0],
        [3000.0, 2.0],
        [4000.0, 5.0],
        [5000.0, 4.0],
    ];
    let y = array![10.0, 23.0, 32.0, 45.0, 54.0];

    // 每列零均值、单位方差。scaler 会把统计量留给后续的批次用。
    let mut scaler = StandardScaler::new();
    let x = scaler.fit_transform(&x_raw).unwrap();

    // 各向同性的特征能容忍比原始数据大得多的步长。
    let mut model = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.1,
            max_iter: 5_000,
            tol: 1e-9,
        })
        .unwrap();
    model.fit(&x, &y).unwrap();

    println!("iterations   = {}", model.get_actual_iterations().unwrap());
    println!("R^2          = {}", model.score(&x, &y).unwrap());
    // 这些斜率是标准化单位下的。
    println!("coefficients = {:?}", model.get_coefficients().unwrap());
}
```

关于步长的经验法则:在标准化后的数据上,从 `0.1` 附近起步。如果 `fit` 返回 [`Error::NonFinite`],或者迭代次数顶到了 `max_iter`,就把它降低为原来的十分之一。在原始的、量级较小的整数特征上,`0.01` 左右的学习率通常是安全的。

## 2.1.6. 正则化:L1 与 L2

L2(`RegularizationType::L2(alpha)`,岭回归)给代价加上 `(alpha / 2) * dot(w, w)`,给权重梯度加上 `alpha * w`。它把每个系数平滑地往零收缩,但不会强迫任何一个系数恰好归零。这适合特征相关、你在意稳定、低方差拟合的场景。岭回归会把权重摊到共线的各列上,而不是让某一个长得过大。两个求解器都支持 L2。

L1(`RegularizationType::L1(alpha)`,lasso)给代价加上 `alpha * sum(|w_j|)`。求解器是通过一个**邻近步**来施加它的。每次梯度更新之后,它会把每个权重按 `learning_rate * alpha` 做软阈值。数据撑不起来的权重会恰好落到 `0.0` 并停在那儿。这个方法叫 ISTA,也正是它让 L1 成为一个特征选择器。早先的一种次梯度写法是把 `alpha * sign(w)` 加进梯度里,但那种写法只能随时间逐渐逼近零,从来产生不了真正的稀疏性。无论哪种写法,截距都不受惩罚。L1 需要梯度下降求解器,因为它没有闭式解。

把 `alpha` 设置对,在你从 Python 迁移模型时最要紧。每一个接收 `RegularizationType` 的估计器,最小化的都是一个均值数据项加上一个不作除法的惩罚项:

```text
L1:  (1 / n) * sum(loss) + alpha * ||w||_1
L2:  (1 / n) * sum(loss) + alpha * 0.5 * ||w||^2
```

这恰好就是 scikit-learn 的 `SGDRegressor` 和 `SGDClassifier` 的目标函数,所以 `alpha` 可以从这两者中的任意一个 1:1 直接搬过来。闭式的估计器各有各的约定,而且这些约定彼此还不一样:

| scikit-learn | RustyML |
| --- | --- |
| `Lasso(alpha=a)` | `L1(a)`,目标函数完全相同。双方都把数据项按 `1 / (2 * n)` 缩放 |
| `Ridge(alpha=a)` | `L2(a / n)`。scikit-learn 的 `Ridge` 不会把数据项除以 `n` |
| `SGDRegressor(alpha=a)` / `SGDClassifier(alpha=a)` | `L1(a)` 或 `L2(a)`,无需换算 |
| `LogisticRegression(C=c)` | `L1(1 / (c * n))` 或 `L2(1 / (c * n))` |

这里 `n` 是训练样本数。`Ridge` 那一行表述的就是 [2.1.4](#214-选择求解器正规方程还是梯度下降) 里 `lambda = n * alpha` 这个关系,只是反过来读。

下面这段用精确求解器,好让收缩效果一目了然。在近乎共线的数据上,岭回归给出的系数范数严格小于 OLS:

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

fn main() {
    // 两个近乎共线的特征,会让普通 OLS 的系数又大又不稳定。
    let x = array![
        [1.0, 0.9],
        [2.0, 2.1],
        [3.0, 2.9],
        [4.0, 4.2],
        [5.0, 5.1],
    ];
    let y = array![1.0, 2.0, 3.0, 4.0, 5.0];

    // 两者都用默认的闭式求解器。
    let mut ols = LinearRegression::new(true);
    ols.fit(&x, &y).unwrap();

    let mut ridge = LinearRegression::new(true)
        .with_regularization(RegularizationType::L2(1.0))
        .unwrap();
    ridge.fit(&x, &y).unwrap();

    let sq_norm =
        |m: &LinearRegression| m.get_coefficients().unwrap().iter().map(|c| c * c).sum::<f64>();
    println!("OLS   ||w||^2 = {}", sq_norm(&ols));
    println!("ridge ||w||^2 = {}", sq_norm(&ridge)); // 严格更小
}
```

用 L1 时要显式选择梯度下降。`Normal` 求解器会用 [`Error::InvalidInput`] 拒绝 L1。crate 自己的测试用了 200 个特征,其中第 0 列携带信号,其余 199 列都是纯噪声。一个小的 L1 惩罚会让那个有信息量的系数保持主导,同时把每个噪声系数都压到字面上的 `0.0`。用 `iter().filter(|c| **c == 0.0).count()` 就能数出这些零。测试断言的正是这个行为。

## 2.1.7. 收敛与诊断不收敛

本节适用于 `LeastSquaresSolver::GradientDescent`。闭式解没有收敛这个概念。它要么给出一个解,要么返回一个错误。

收敛不会因为单独一小步就被宣告。每轮迭代之后,优化器都会把代价的绝对变化量与 `tol` 相比。变化量小于 `tol` 就给计数器加一。只有**连续 3 轮**都低于 `tol`,训练才会停下。只要有一轮代价变化超过阈值,计数器就会清零。这条规则是为了防止在临时的平台期上过早停止。此外,循环也会在 `max_iter` 处无条件停止。

`fit` 之后,把 `get_actual_iterations()` 和你配置的 `max_iter` 做比较。对 `get_solver()` 做模式匹配就能读回 `max_iter`。如果实际次数严格小于上限,说明连续 3 轮的规则触发了,模型收敛了。如果它等于上限,说明优化器用光了预算,拟合可能还没收敛好。

常见的补救办法,按顺序:

1. 把特征标准化(见 [2.1.5](#215-先把特征标准化))。
2. 调高 `max_iter`。
3. 调高学习率以加快下降速度,但要留意别发散。
4. 如果你不需要最后那一位精度,就放宽 `tol`。
5. 干脆去掉 `with_solver` 调用,直接用精确的闭式解。

发散是另一种失败模式。学习率过大,会把代价、继而把参数推向无穷。循环内的有限性防线会在几轮之内把它转成 [`Error::NonFinite`],而不是返回一堆填满 `inf` 的系数。在干净、有限的数据上,`fit` 报出 `NonFinite`,几乎总是意味着学习率太高,或者特征需要缩放。模型不保留逐轮的代价历史。想直接盯着下降过程看,就用 `show_progress` feature 编译。这样 `fit` 就会在每一轮都渲染出实时代价和 `k/3` 收敛计数器。

## 2.1.8. 查看拟合后的参数

这些 getter 让你不用重新跑一遍推断,就能读出拟合好的模型。`get_coefficients()` 返回 `Option<&Array1<f64>>`,一个借用,因为权重归模型所有。`get_intercept()` 和 `get_actual_iterations()` 返回的则是拥有所有权的 `Option` 值。求解器的那些设置是包在枚举里回来的。用 `match` 或者 `if let` 来读它们。

```rust
use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::machine_learning::linear_model::LeastSquaresSolver;

fn main() {
    let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
    let y = array![3.0, 5.0, 7.0, 9.0, 11.0]; // y = 2x + 1

    let mut model = LinearRegression::new(true)
        .with_solver(LeastSquaresSolver::GradientDescent {
            learning_rate: 0.01,
            max_iter: 10_000,
            tol: 1e-10,
        })
        .unwrap();
    model.fit(&x, &y).unwrap();

    // 权重按引用返回,遍历时无需将其移出模型。
    let coefs = model.get_coefficients().unwrap();
    for (j, w) in coefs.iter().enumerate() {
        println!("w[{j}] = {w:.6}");
    }

    // 截距和迭代次数是拥有所有权的值。
    println!("intercept       = {:.6}", model.get_intercept().unwrap());
    let ran = model.get_actual_iterations().unwrap();
    println!("iterations run  = {ran}");

    // 迭代设置住在求解器变体里。
    match model.get_solver() {
        LeastSquaresSolver::Normal => println!("closed form, nothing to tune"), // 闭式解,没什么可调的
        LeastSquaresSolver::GradientDescent { learning_rate, max_iter, tol } => {
            println!("learning_rate   = {learning_rate}");
            println!("tolerance       = {tol}");
            println!("max_iter        = {max_iter}");
            println!("converged early = {}", ran < max_iter);
        }
    }

    // 无论模型是否已拟合,正则化设置都可读。
    println!("regularization  = {:?}", model.get_regularization_type());
}
```

当 `fit_intercept` 为 `false` 时,按契约,拟合之后 `get_intercept()` 返回 `Some(0.0)`。截距被固定为零,而不只是很小。读回来恰好是 `0.0`,就证实了没有学到任何偏置项。

## 2.1.9. 保存与加载模型

拟合好的模型会序列化成一个紧凑的 [postcard](https://docs.rs/postcard) 二进制块。这个块里包含系数、截距、超参数和训练元数据。`save_to_path(&self, path: &str)` 负责写入。`load_from_path(path: &str) -> Result<Self, Error>` 负责读回。两个方法都会把 I/O 和序列化失败以 [`Error::Io`] 的形式报告出来。一次往返能精确重现预测结果,因为原始的 `f64` 系数是逐比特保留的。

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

fn main() {
    let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
    let y = array![3.0, 5.0, 7.0, 9.0, 11.0];

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();
    let before = model.predict(&array![[6.0]]).unwrap();

    // 落盘,再加载进一个全新的实例。
    model.save_to_path("lr_model.bin").unwrap();
    let loaded = LinearRegression::load_from_path("lr_model.bin").unwrap();
    let after = loaded.predict(&array![[6.0]]).unwrap();

    println!("before = {:?}, after = {:?}", before, after); // 完全一致

    std::fs::remove_file("lr_model.bin").unwrap();
}
```

迭代设置搬进求解器变体之后,序列化布局也变了。过去分散的 3 个字段,现在合并成了 1 个载荷。旧版本写出的二进制块将无法加载。已持久化的模型请重新拟合、重新保存。

文件扩展名没有任何含义。不管你把文件命名为 `.bin`、`.dat` 还是别的什么,格式永远是 postcard 二进制。`LinearRegression` 还派生了 `Clone` 和 `Debug`。进程内复制一份就是简单的 `model.clone()`,`{:?}` 会打印出整个结构体。跨模型持久化的完整细节,包括神经网络权重,见 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。

想看建立在同一套梯度下降机制上的分类对应物,请接着读 [2.2. 逻辑回归](./2.2._逻辑回归.md)。想在拟合好的模型的预测上再算些别的回归评分,见 [5.1. 回归指标](../Chapter-05/5.1._回归指标.md)。

[`LeastSquaresSolver`]: https://docs.rs/rustyml
[`LeastSquaresSolver::GradientDescent`]: https://docs.rs/rustyml
[`LeastSquaresSolver::Normal`]: https://docs.rs/rustyml
[`Error`]: https://docs.rs/rustyml
[`Error::NonFinite`]: https://docs.rs/rustyml
[`Error::EmptyInput`]: https://docs.rs/rustyml
[`Error::DimensionMismatch`]: https://docs.rs/rustyml
[`Error::NotFitted`]: https://docs.rs/rustyml
[`Error::InvalidParameter`]: https://docs.rs/rustyml
[`Error::InvalidInput`]: https://docs.rs/rustyml
[`Error::Io`]: https://docs.rs/rustyml
[`Fit`]: https://docs.rs/rustyml
[`Predict`]: https://docs.rs/rustyml