ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【RustyML入门】2.2. 逻辑回归

【RustyML入门】2.2. 逻辑回归

2.2. 逻辑回归

2.2.1. 模型到底在优化什么

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

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

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

2.2.2. 构造模型

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

userustyml::machine_learning::LogisticRegression;fnmain(){// 默认值: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拒绝,而不是拖到 fit 时才失败。

参数类型默认值约束
fit_interceptbooltrue
learning_ratef640.01严格为正且有限
max_iterationsusize100至少为 1
tolerancef641e-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.01.0。RustyML 会拒绝其他任何值,例如0.52.0-1.0,抛出Error::InvalidInput。这与SVCLinearSVC的标签约定一致,它们的预测结果同样是0.01.0。如果你的标签是别的编码,先映射到{0, 1}。字符串或类别标签,先用 4.3. 标签编码 编码。刻意挑好哪一类是正类,也就是那个1,因为这个选择定义了精确率、召回率和概率输出的含义。

usendarray::array;userustyml::machine_learning::LogisticRegression;fnmain(){// 逻辑 AND,编码为 {0.0, 1.0}letx_train=array![[0.0,0.0],[0.0,1.0],[1.0,0.0],[1.0,1.0]];lety_train=array![0.0,0.0,0.0,1.0];letmutmodel=LogisticRegression::new(true,0.5,500,1e-7).unwrap();model.fit(&x_train,&y_train).unwrap();letpreds=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.01.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,自己定阈值。

usendarray::array;userustyml::machine_learning::LogisticRegression;fnmain(){letx_train=array![[-3.0],[-2.0],[-1.0],[1.0],[2.0],[3.0]];lety_train=array![0.0,0.0,0.0,1.0,1.0,1.0];letmutmodel=LogisticRegression::new(true,0.3,500,1e-7).unwrap();model.fit(&x_train,&y_train).unwrap();letx_test=array![[-0.5],[0.5]];letproba=model.predict_proba(&x_test).unwrap();// 正类概率letdefault=model.predict(&x_test).unwrap();// 0.5 阈值 -> {0.0, 1.0} 的 Array1<f64>// 更严格的工作点:只有 p >= 0.8 才判为正类letstrict:Vec<i32>=proba.iter().map(|&p|ifp>=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后面链式调用:

usendarray::array;userustyml::machine_learning::{LogisticRegression,RegularizationType};fnmain(){letx=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],];lety=array![0.0,0.0,0.0,1.0,1.0,1.0];letmutplain=LogisticRegression::new(true,0.1,2000,1e-8).unwrap();plain.fit(&x,&y).unwrap();letmutridge=LogisticRegression::new(true,0.1,2000,1e-8).unwrap().with_regularization(RegularizationType::L2(5.0)).unwrap();ridge.fit(&x,&y).unwrap();// 特征权重的 L2 范数,跳过下标 0 处那个不受惩罚的偏置letfeature_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)(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.2.6. 标准化、类别不平衡与可分数据

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

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

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

完全可分的数据会让无正则化的最大似然估计发散。当一个超平面把两类干净地分开时,似然靠把权重范数推向无穷来最大化,因为概率会饱和到 0 和 1。无正则化的梯度下降因而跑得越久、权重就越大。实践中max_iterationstolerance会把训练截断,分类结果保持正确。权重,以及由此得到的概率,仍然会变得数量级任意、校准很差。在幅度很大的可分输入上使用较大的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],不含常数列(截距已经提供了)。在展开后的数据上拟合,并在同样的展开上预测

usendarray::array;userustyml::machine_learning::{LogisticRegression,generate_polynomial_features};fnmain(){// 内圈 = 类别 0,外圈 = 类别 1:在 (x1, x2) 中不是线性可分的letx=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],];lety=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]letx_poly=generate_polynomial_features(&x,2);assert_eq!(x_poly.ncols(),5);letmutmodel=LogisticRegression::new(true,0.01,3000,1e-7).unwrap();model.fit(&x_poly,&y).unwrap();// x1^2 + x2^2 这一项让两个圆环变得线性可分letpreds=model.predict(&x_poly).unwrap();println!("{:?}",preds);}

列数随特征数和次数组合式增长,3 个特征、3 次就已经是 19 列。这个工具只适合少数几个特征、低次数的场合。当展开变大时,改用像 SVC 这样的核方法。

2.2.8. 带评估的完整示例

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

usendarray::{array,Array1};userustyml::machine_learning::LogisticRegression;userustyml::metrics::{ConfusionMatrix,accuracy,roc_auc};fnmain(){letx_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],];lety_train=array![0.0,0.0,0.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0];letmutmodel=LogisticRegression::new(true,0.5,500,1e-7).unwrap();model.fit(&x_train,&y_train).unwrap();// 已经是硬标签 {0.0, 1.0},正是 ConfusionMatrix::new 所要求的letpreds=model.predict(&x_train).unwrap();letcm=ConfusionMatrix::new(&y_train,&preds);println!("{}",cm.summary());println!("accuracy: {:.3}",accuracy(&y_train,&preds));// ROC AUC 对概率排序,所以它需要原始得分,而不是 0/1 标签lettruth:Array1<bool>=y_train.mapv(|v|v>0.5);letscores=model.predict_proba(&x_train).unwrap();println!("ROC AUC: {:.3}",roc_auc(&truth,&scores));}

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

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_pathload_from_path序列化为紧凑的 postcard 二进制,携带权重、超参数和迭代次数。往返是逐字节精确的,所以加载后模型的预测与原模型的预测分毫不差。

usendarray::array;userustyml::machine_learning::LogisticRegression;fnmain(){letx=array![[-2.0,1.0],[-1.0,-1.0],[1.0,1.0],[2.0,-1.0]];lety=array![0.0,0.0,1.0,1.0];letmutmodel=LogisticRegression::new(true,0.3,500,1e-7).unwrap();model.fit(&x,&y).unwrap();letpath="logreg_model.bin";model.save_to_path(path).unwrap();letloaded=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. 可复现性与随机种子;序列化格式及其跨版本的限制,见 7.2. 深入模型持久化。如果你用show_progressfeature 构建,fit还会渲染一个带实时损失的进度条,方便你盯着上面描述的不收敛和发散行为。

返回列表