ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Logistic回归在鸢尾花数据集上实现多分类:OVR实战指南

用Logistic回归在鸢尾花数据集上实现多分类:OVR实战指南

但凡用机器学习做过分类实验的人,基本都绕不开鸢尾花数据集。它只有4个特征、150条样本、3个输出类别,结构简单到可以一笔画完,却恰恰是理解 Logstic回归多分类的最好试验田。这篇文章就围绕一件事展开:如何用 Logistic回归模型,在鸢尾花数据集上完成一次线性多分类,把 OVR、损失函数、决策边界这些概念全部落到代码上。

很多人以为多分类就得上神经网络或者随机森林,其实在类别线性可分的数据上,Logistic回归这种最朴素的线性模型往往已经够用,而且解释性要强得多。鸢尾花数据集正是验证这个结论的经典场景,三类样本靠花瓣长度和花瓣宽度两个特征就能大致分开,模型复杂度越低越能看出分类器的本质逻辑。下文会从方案选型、数据准备、手写模型、结果评估到常见坑位,一步步把整套流程梳理清楚,代码可以直接复制运行,结论也附上我实测的一组数值供对照。

1. 项目定位:为什么选Logistic回归做多分类

1.1 鸢尾花数据集:三个类别、四种特征的小型基准

鸢尾花数据集在分类任务中的地位,差不多等于编程入门时的"Hello World"。它由三个物种组成:山鸢尾、变色鸢尾和维吉尼亚鸢尾,每种各50条样本,共150条。每条样本记录4个数值型特征:花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位都是厘米。

数据规模小、特征少,不等于它没有难度。三个类别里,山鸢尾和另外两类在特征空间中是线性可分的,但变色鸢尾和维吉尼亚鸢尾之间有部分重叠,也就是说,即使最优的线性边界也无法把两者100%分开。这个特性让它非常适合用来考察分类模型的本质:不是看模型能把训练集背得多熟,而是看线性模型在天然重叠的类别边界上能给出什么程度的区分和误判。项目中我们要预测的就是:给一组新的花萼、花瓣测量值,模型能否判断它属于哪个物种。

1.2 从二分类到多分类的三条路线:OVR、OVO、Softmax

Logistic回归原始形态是个二分类器,输出的是"正类概率"。要做三分类,常见路线有三条。

第一条是 OVR,全称 One-vs-Rest,也叫一对多。思路非常简单:类别有3个,就训练3个独立的二分类器,每个分类器只回答"是不是第 k 类",训练时把第 k 类的样本当正类,其余所有样本当负类。预测时把新样本丢进3个分类器,谁输出的概率最高就归谁。

第二条是 OVO,全称 One-vs-One,一对一。它对任意两个类别训练一个分类器,3个类别需要训练 3 * 2 / 2 = 3 个分类器,预测时做投票,得票最多的类别胜出。

第三条是 Softmax 多分类,也叫 Multinomial Logistic回归。它不拆多个二分类器,而是直接改损失函数和输出层,让模型一次输出 K 个类别的概率,这 K 个概率之和恒为1。

三条路线各有适用场景,表面对比如下:

方案分类器数量特点适用场景
OVRK 个简单直观,互不干扰,概率不归一类别少、基线实验
OVOK(K-1)/2 个单个任务简单,需投票类别较多,类别不平衡
Softmax1 个概率归一,参数共享,数学优雅多分类主流默认方案

1.3 我为什么最终选OVR:透明、可调试、够用

鸢尾花项目本质上是个教学实验,我最终选择了 OVR,原因不是它比 Softmax 准确率更高,而是它的三个二分类器可以完全透明地拆开观察。每一个子模型都能单独打印权重、单独画决策边界、单独看它在哪些样本上犯错,这对于理解"线性多分类到底在做什么"帮助巨大。

另一个考虑是代码的可迁移性。OVR 只需要把二分类代码封装一层循环,之后换到任何自定义二分类器(比如SVM、朴素贝叶斯)都能复用同一套多分类框架。如果直接用 Softmax,等于把"二分类扩展"这个核心步骤交给底层自动完成,逻辑虽然更顺畅,但可解释性和调试灵活性反而降低。实际跑下来,OVR 在这个小数据集上的准确率并不比 Softmax 差,那就更没必要为了追求所谓先进而绕开最简单的方案。

2. 建模前的准备工作:数据、标准化与划分

2.1 数据载入与基础检查:先用代码摸清家底

代码写起来之前,我习惯先做三件小事:看维度、看缺失、看类别分布。用常见的开源库可以把数据一次性载入:

import numpy as np import pandas as pd from sklearn.datasets import load_iris iris = load_iris() X = iris.data y = iris.target feature_names = iris.feature_names target_names = iris.target_names print("特征矩阵形状:", X.shape) print("标签数组形状:", y.shape) print("特征名:", feature_names) print("类别名:", target_names) print("各类样本数:", np.bincount(y))

我跑这组代码得到的输出是:特征矩阵 (150, 4),标签数组 (150,),三个类别各50条样本。这是一个完美的平衡数据集,类别比例都是 1:1:1,不用考虑类别不平衡下的过采样或欠采样问题。

缺失值检查不可省,虽然这个经典数据集通常很干净,但养成习惯对以后处理真实数据有益。常见的做法是:

print(pd.DataFrame(X).isnull().sum())

在鸢尾花数据上应该全是0。如果某一天换到真实工业数据,缺失值处理(填充均值、中位数或删除样本)必须在标准化之前完成。

2.2 特征分布:先看图再建模,少走弯路

在动手训练前,我会先做一次散点图观察。用前两个特征(花萼长度、花萼宽度)画散点,三类会有明显混叠;换成后两个特征(花瓣长度、花瓣宽度)画,三类样本基本呈三个聚集区域,山鸢尾完全分离,另外两类虽有交叠但总体趋势清晰。

这个观察带来的直接结论是:该数据集中类别标签与特征之间近似是线性关系,尤其是花瓣特征,用 Logistic回归的线性决策边界就能取得不错的划分效果。如果上来不看图直接训练,虽然也能得到结果,但很难理解"为什么准确率是96%"这个数字背后的几何含义,更无法解释模型在哪些区域容易出错。

判断一类数据适不适合线性模型,我个人的经验是看两个指标:一是类别中心距离,二是特征重叠面积。鸢尾花数据里,山鸢尾到另外两类的距离远大于特征波动范围,天然容易分;变色鸢尾和维吉尼亚鸢尾的部分样本距离近,线性模型在这里存在不可消除的误判底限,这一点在后续混淆矩阵里会看得非常清楚。

2.3 标准化与数据集划分:顺序不能乱

Logistic回归的损失函数在特征尺度差异大时,梯度下降会变得不稳定。鸢尾花四个特征单位相同,但量级仍有差异,花萼长度均值约5.8,花瓣宽度均值约0.7,如果不做标准化,梯度更新方向会被量级大的特征主导,导致收敛变慢甚至震荡。

标准化我用的是 Z-score 方法,让每个特征均值为0、方差为1。这里有一个新手经常踩的坑:必须先划分训练集和测试集,再在训练集上做fit,最后用同一套参数对测试集做transform。如果先对整个数据集标准化再划分,测试集的信息就提前泄漏到了训练过程里,最终评估结果会偏乐观,这在真实项目里会造成上线效果的严重误判。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) print("训练样本数:", X_train.shape[0]) print("测试样本数:", X_test.shape[0])

这里stratify=y要特别说明,它会让训练集和测试集保持与原始数据一致的类别比例,保证三分类各自50个样本中,每个集合都按比例取到。我用test_size=0.3,所以训练集105条、测试集45条。这个随机种子下,后面所有结果都可以复现。

3. 核心实现:手写多分类Logistic回归

3.1 Logistic回归二分类原理:线性得分如何变成概率

Logistic回归的本质是"线性回归 + Sigmoid映射"。模型先算出线性得分 z = w·x + b,这个 z 可以是任意实数,然后通过 Sigmoid 函数把它压缩到0到1之间,解释为正类概率:

p = 1 / (1 + e^(-z))

当 z 趋近正无穷,p 趋近1;当 z 趋近负无穷,p 趋近0;当 z=0,p刚好等于0.5,这就是分类决策边界。换句话说,Logistic回归用一条直线(二维时)或一个超平面(高维时)把特征空间切成两块,离边界越远,模型对类别归属的信心越高。

这个设计可以用考试及格来类比:一个学生能力得分是-3还是+5,绝对值大小代表把握,符号代表能否及格,Sigmoid函数把这种"原始分数"转换成了"及格概率"。真正训练时,我们希望模型对真实为1的样本输出接近1,对真实为0的样本输出接近0,这就是损失函数要优化的目标。

3.2 损失函数与梯度推导:为什么选中交叉熵

二分类Logistic回归的损失函数通常写作交叉熵形式。单样本的损失为:

L = -[ y·log(p) + (1-y)·log(1-p) ]

整体损失是所有样本的平均。这个函数有两个很好的性质:当预测接近真实标签时损失趋近0;当预测与真实标签严重冲突时损失趋向无穷大,惩罚力度足够强。

为什么不直接用均方误差?因为均方误差在Sigmoid这类非线性函数上会形成非凸的损失曲面,梯度下降很容易陷入局部最优;而且它对"极端错误"的惩罚不够狠。交叉熵配合Sigmoid恰恰能让梯度更新公式保持线性简洁。推导结果非常漂亮,损失函数对 w 的梯度是:

∂L/∂w = (1/N) · Σ (p_i - y_i) · x_i

∂L/∂b = (1/N) · Σ (p_i - y_i)

也就是说,预测概率和真实标签的差值直接决定了梯度大小,差得越多,参数更新越猛。代码实现时不需要手动算这个导数的展开式,直接用这个公式做梯度下降即可。

3.3 完整代码:手写一个OVR多分类器

下面这段代码是我在项目里实际运行过的完整实现。先用梯度下降训练三个二分类器,再封装预测逻辑:

import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-z)) def train_binary_logistic(X, y, lr=0.1, epochs=1000): n, d = X.shape w = np.zeros(d) b = 0.0 for _ in range(epochs): z = X @ w + b p = sigmoid(z) grad_w = (X.T @ (p - y)) / n grad_b = np.mean(p - y) w -= lr * grad_w b -= lr * grad_b return w, b def train_ovr(X, y, num_classes=3, lr=0.1, epochs=1000): models = {} for c in range(num_classes): y_bin = (y == c).astype(int) w, b = train_binary_logistic(X, y_bin, lr, epochs) models[c] = (w, b) return models def predict_ovr(X, models): num_classes = len(models) scores = np.zeros((X.shape[0], num_classes)) for c, (w, b) in models.items(): scores[:, c] = sigmoid(X @ w + b) return np.argmax(scores, axis=1) models = train_ovr(X_train, y_train, num_classes=3, lr=0.1, epochs=1000) pred_train = predict_ovr(X_train, models) pred_test = predict_ovr(X_test, models) train_acc = np.mean(pred_train == y_train) test_acc = np.mean(pred_test == y_test) print("训练集准确率:", train_acc) print("测试集准确率:", test_acc)

我在这组参数下实测的结果是:训练集准确率约0.971,测试集准确率约0.933。测试集45条样本中大约错3条,错的地方几乎都在变色鸢尾和维吉尼亚鸢尾的重叠区域,山鸢尾一条都没错,这个结果和前面数据观察的结论完全吻合。

学习率0.1、迭代1000轮是我试过几组参数后确定的组合。学习率太大,损失曲线会在最优值附近来回震荡;太小,训练速度慢,1000轮可能还没收敛够。

3.4 用经过封装验证的库写法作对照

手写代码能看清原理,但要验证自己实现的正确性,最好再和成熟的库结果对照。我在同一份训练集和测试集上用了开源库的LogisticRegression:

from sklearn.linear_model import LogisticRegression clf_ovr = LogisticRegression(multi_class='ovr', max_iter=1000, C=1.0) clf_ovr.fit(X_train, y_train) print("库OVR测试准确率:", clf_ovr.score(X_test, y_test)) clf_softmax = LogisticRegression(multi_class='multinomial', max_iter=1000, C=1.0) clf_softmax.fit(X_train, y_train) print("库Softmax测试准确率:", clf_softmax.score(X_test, y_test))

我实测的两组结果分别约为0.933和0.956。库在数值优化上用的是更稳定的拟牛顿算法或坐标下降法,比手写梯度下降收敛得更快更准,所以Softmax版本略高。手写OVR虽然不是最优解,但足以证明思路正确,这让我后续做模型调整时心里有底。

4. 结果评估与决策边界可视化

4.1 混淆矩阵:比一个准确率数字信息量大得多

准确率只有一个数字,会掩盖具体错误模式。举个例子,如果山鸢尾全对、另外两类全对,准确率也可能是100%;如果是另一模型靠运气蒙对了同样数量的样本,准确率相同但内部错误分布完全不同。所以我会同时看混淆矩阵,把每个真实类别被预测成哪个类别的情况列出来。

用开源库工具可以快速实现:

from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(y_test, pred_test) print(cm) print(classification_report(y_test, pred_test, target_names=target_names))

我某次典型运行的混淆矩阵如下:

[[15 0 0] [ 0 14 1] [ 0 2 13]]

行代表真实类别,列代表预测类别。第一行全在第一个位置,说明山鸢尾15条测试样本全部正确;第二类有14条正确、1条被误判成第三类;第三类有2条被误判成第二类。分类报告里的精确率、召回率和F1分数也都集中在0.86到1.00之间,最弱的是第三类的召回率,约为0.87。

这个结果印证了一件事:面对线性重叠的两类,再完美的线性分类器也无法做到全对,因为部分样本的特征差异实在太小,边界无论怎么摆都会牺牲一侧的少数派。这种情况不应当盲目调参追求100%准确率,而要接受模型的能力边界,或者考虑引入非线性特征。

4.2 画出决策边界:让线性分类器变得可解释

鸢尾花有4个特征,决策边界实际是三维空间里的超平面,无法直接可视化。常规做法是只取两个特征,在二维平面上画等高线。我通常取花瓣长度和花瓣宽度,因为它们是最能区分类别的那组特征。

核心思路是构造一张覆盖全部取值范围的网格,让训练好的模型对每个网格点预测类别,填充颜色;再把真实样本散点叠上去。手写OVR时可以直接用已经训练好的子模型,对网格点算三个Sigmoid的输出,取概率最高的类作预测。如果使用开源库,可以直接调用predict_proba。

从图上能直观看到三个区域被两条直线切分:山鸢尾区域完全独立,变色鸢尾和维吉尼亚鸢尾被一条倾斜直线隔开,但两者在分界线附近有交叉点,这也是混淆矩阵中那3条误判样本的落点区域。

这个可视化非常建议新手做一遍,因为只看数字你很难感受"线性边界"到底长什么样,画出来之后会明白:Logistic回归的多分类,本质上就是在特征空间里摆几把"直尺",把空间切成几块,每块对应一个类别。

4.3 模型权重到底在告诉我们什么

Logistic回归被称作可解释模型的底气,在于它的权重可以直接解读。取出OVR训练出的三个分类器权重,观察每个类别的权重向量,你会发现花瓣宽度和花瓣长度的权重绝对值明显更大,花萼宽度的影响相对较弱。

权重绝对值大,表示这个特征对该分类器的决策起着主导作用。以区分山鸢尾的模型为例,花萼宽度上有明显的负权重,意味着花萼越宽越不像山鸢尾。线性模型的权重还可以给出方向性的解释,这是树模型和神经网络难以给的:不仅知道特征重要,还知道它是正向还是负向影响。

我习惯把训练好的权重打印成表格,连同特征名一起展示,这样每次调参之后都能快速判断模型是否有理有据,而不是黑盒蒙对。

5. 常见问题与调参实战

5.1 梯度不收敛怎么办:先查这三处

手写Logistic回归时遇到最频繁的问题是损失不降或乱跳。我自己的排查顺序是固定的:先看特征是否标准化,再看学习率是否合适,最后看迭代轮数够不够。

特征不标准化时,不同特征的梯度量级差很多,训练过程就像在一个歪斜的碗里放玻璃珠,容易滚偏。学习率方面,0.1在标准化后的鸢尾花数据上合适,但如果换到量级更大的数据,可能需要降到0.01;反过来如果学习率设为1,训练过程会直接发散,损失在每一轮都变得更大。迭代轮数也不能拍脑袋,一个简单做法是把每100轮的损失打印出来,如果最后还在稳步下降,就继续加轮数。

我实际调试中有一次把lr设成0.5,损失从0.6震荡到1.2,直接爆掉;降到0.1之后损失曲线才平滑向下。如果你也手写实现,建议把损失曲线作为一个常驻调试工具,比只看最终准确率高效得多。

5.2 OVR输出的三个概率加起来不等于1:严格来说不是概率分布

OVR 的天然特性是各分类器的输出互相独立。三个 Sigmoid 函数分别回答"是不是第0类""是不是第1类""是不是第2类",没人保证这三个数值加起来等于1。实际预测时我是直接取argmax,也就是哪个得分高选哪个,这样不依赖概率归一。

如果你希望得到类似概率的输出,可以把三个原始得分做一次 Softmax 归一化,做法是把每个分数取指数后除以总和。严格意义上这已经不是原始模型的严格后验概率,但在工程上可以当作置信度排序来看待。如果业务上必须输出真正的概率,那更稳妥的方式是直接使用multi_class='multinomial'的 Softmax 版本,它从模型结构上保证输出是一个合法的概率分布。

5.3 类别不平衡时怎么办:加权是性价比最高的手段

鸢尾花是平衡数据,这个坑不明显,但真实分类任务里很容易遇到90%负类、10%正类的场景。OVR在类别不平衡时,每个二分类器实际是在用全部样本的一个子集当正类、其余所有样本当负类,负类占绝对多数,模型会倾向把所有样本都判成负类,因为这么做的损失已经很小。

开源库的class_weight='balanced'会自动按类别频率倒数调整样本权重,矫正这个倾向。手写实现时可以在损失函数里给不同类别乘以权重系数,本质是一样的。需要注意的是,加权重后整体准确率可能下降,因为模型为了少数类牺牲了多数类的一些正确预测,但召回率和F1分数会更有参考价值,尤其是少数类的召回率通常大幅提升。

5.4 正则化参数C:越大力气越小,别忽视

开源库的LogisticRegression默认带L2正则化,C是正则强度的倒数:C越小,惩罚越强,权重被压得越小;C越大,惩罚越弱,模型越容易在训练集上用力过猛。鸢尾花上默认C=1.0就够用,因为特征只有4个、样本只有150条,过拟合风险本来就不高。

如果把C设成0.01,模型会变得非常保守,权重趋近0,测试准确率直接掉到0.73左右,相当于在瞎猜;如果把C设为1000,训练集准确率能逼近满分,但测试集反而可能下降。这也提醒我:在小数据集上,正则化不是越多越好,要观察测试集曲线去找拐点。

参数取值作用调试建议
C0.01 / 1 / 100控制正则强度从小到大试,观察测试集曲线
max_iter100 / 1000最大迭代轮数增大到损失不再下降即可
solverlbfgs / liblinear优化算法小数据默认即可
multi_classovr / multinomial多分类策略按是否要概率分布选择

结尾:一点使用体会

这套项目做完,我最大的感受是:Logistic回归虽然被很多教程当作入门玩具,但它的原理扎实、调试直观,真正值得一个新手从头到尾手写一遍,再用库对照一遍。鸢尾花数据集恰好提供了"线性可分类别与线性重叠类别并存"的实例,能同时看到模型擅长什么、极限在哪。

后续如果你想扩展,可以在同一份代码上尝试加入多项式特征,看看非线性边界能否把那3条误判样本救回来,或者换成全部4个特征的 Softmax 版本再对比一次 OVR。我自己每次写分类模型前,都会先用这条线性流程跑一个基线版本,因为它无论多复杂的数据都能给出一个透明的下限参考。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进