# 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