1. 从二分类到多分类:逻辑回归为什么能识别手写数字
很多人第一次接触逻辑回归,都是在二分类场景里——判断一封邮件是不是垃圾邮件、一个用户会不会点击广告、一笔交易是否存在欺诈风险。逻辑回归的输出经过Sigmoid函数压缩到0到1之间,天然适合回答“是或否”的问题。但手写数字识别有10个类别(0到9),这看起来跟逻辑回归八竿子打不着。我当初也是这么想的,直到自己动手把MNIST数据集跑通了一遍,才发现逻辑回归做多分类不仅可行,而且是一个非常好的理解多分类底层机制的切入点。
核心思路其实不复杂:把多分类问题拆成多个二分类问题。具体来说,对于10个数字类别,我们训练10个独立的二分类器,每个分类器只回答一个问题——“这张图片是不是数字k?”比如分类器0负责判断“是0”还是“不是0”,分类器3负责判断“是3”还是“不是3”。预测的时候,把一张图片同时喂给这10个分类器,每个分类器输出一个概率值,最终取概率最大的那个类别作为预测结果。这个方法在学术上叫One-vs-Rest(OvR),也叫One-vs-All。
为什么这样做有效?因为每个二分类器只需要学习“某个数字的像素分布模式”和“其他所有数字的像素分布模式”之间的决策边界。虽然每个分类器看到的负样本混杂了9种不同的数字,但逻辑回归的线性决策面在高维像素空间中仍然能找到一定的区分度。当然,这种方法的局限也很明显——当两个数字长得特别像(比如4和9、3和8、1和7),单一线性边界很难完美分开,这也是为什么逻辑回归在MNIST上的准确率通常只能到92%左右,而卷积神经网络能轻松突破99%。
但我想说的是,做这个项目的价值不在于刷多高的准确率,而在于把多分类的底层逻辑吃透。你亲手实现一遍OvR策略,就会明白Softmax回归(也叫多项逻辑回归)到底在做什么、为什么它比OvR更优雅、以及sklearn里的multi_class参数背后到底发生了什么。这些理解对你后面学习更复杂的分类模型有非常大的帮助。
这篇文章我会从数据准备、模型原理、代码实现、调参技巧、踩坑记录几个维度,把逻辑回归做手写数字识别这件事讲透。适合有一定Python和机器学习基础、想深入理解多分类机制的读者。如果你之前只用过逻辑回归做二分类,这篇文章能帮你把认知边界拓宽一大截。
2. 手写数字数据的加载与预处理:那些容易翻车的细节
2.1 数据集的选择与加载方式
手写数字识别最常用的数据集是MNIST,包含60000张训练图片和10000张测试图片,每张是28×28像素的灰度图。sklearn自带了一个简化版的手写数字数据集load_digits,只有1797个样本,每张图片是8×8像素。两个数据集各有优劣:MNIST样本量大、更接近真实场景,但需要自己下载;load_digits直接一行代码就能加载,适合快速验证想法。
我建议的做法是:先用load_digits跑通全流程,确认代码逻辑没问题之后,再切换到MNIST做完整实验。因为MNIST的下载和加载会引入额外的IO操作,如果代码有bug,你很难判断是数据加载的问题还是模型逻辑的问题。
from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split digits = load_digits() X, y = digits.data, digits.target print(f"数据形状: {X.shape}") # (1797, 64) print(f"类别分布: {np.bincount(y)}")注意load_digits返回的data已经是展平后的64维向量(8×8=64),不需要你再做flatten。但如果你用的是MNIST原始数据,每张图是28×28的二维数组,必须手动展平成784维向量才能喂给逻辑回归。这个展平操作看起来简单,但很多人会忘记,导致sklearn报“Expected 2D array”的错误。
2.2 像素值归一化:为什么必须做,不做会怎样
原始像素值是0到255的整数。如果不做任何处理直接扔给逻辑回归,会发生什么?逻辑回归的优化过程通常基于梯度下降,而梯度下降对特征的尺度非常敏感。像素值范围在0到255之间,不同像素之间的差异可能达到几百,这会导致损失函数的等高线变成非常扁的椭圆形,梯度下降会走“之”字形路线,收敛极慢甚至不收敛。
标准做法是把像素值缩放到0到1之间,也就是每个像素值除以255。这个操作叫归一化(Normalization)。代码很简单:
X = X / 255.0但这里有个容易忽略的点:归一化必须在划分训练集和测试集之前做,还是之后做?正确答案是:先划分,再分别对训练集和测试集做归一化。更严谨的做法是,用训练集的最大值和最小值来归一化测试集,而不是各自独立归一化。不过对于像素值这种已知范围在0到255之间的数据,直接除以255是完全安全的,因为训练集和测试集的像素范围一致。
注意:如果你处理的是其他类型的数据(比如身高、收入),测试集里可能出现训练集没见过的最大值,这时候必须用训练集的统计量来归一化测试集,否则会造成数据泄露。
2.3 训练集与测试集的划分策略
train_test_split的默认划分比例是75%训练、25%测试。对于1797个样本的load_digits,这个比例意味着训练集约1347个样本,测试集约450个。这个量级对于逻辑回归来说够用,但如果你想让结果更稳定,可以设置stratify=y参数,确保训练集和测试集中每个数字类别的比例与原始数据一致。
为什么要分层抽样?因为如果随机划分,可能出现某个数字在测试集中只有很少的样本,导致该类别的准确率评估波动很大。比如数字0在原始数据中有178个样本,如果测试集里只分到20个,那这20个里错2个,准确率就从98%掉到90%,波动非常明显。分层抽样能有效缓解这个问题。
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y )random_state=42是为了保证结果可复现。我在做实验的时候习惯固定随机种子,这样每次跑出来的结果一致,方便对比不同参数的效果。如果你不固定随机种子,每次运行的结果会有微小差异,调参的时候很难判断是参数变了还是随机性导致的。
3. 逻辑回归多分类的两种实现路径:OvR与Softmax
3.1 One-vs-Rest策略的完整拆解
OvR的核心思想前面已经说了——为每个类别训练一个二分类器。具体到代码层面,sklearn的LogisticRegression默认就支持多分类,你不需要手动写循环训练10个模型。但理解它内部做了什么,对你调参和排查问题非常有帮助。
假设我们有10个类别,OvR会训练10个二分类器。第k个分类器的训练数据是这样的:原始训练集的所有样本,标签重新编码——如果样本的原始标签是k,则新标签为1(正类);否则为0(负类)。然后对这个二分类问题训练一个逻辑回归模型,得到一组权重参数。
预测阶段,对于一张新图片,10个分类器各自输出一个概率值(正类的概率)。OvR选择概率最大的那个分类器对应的类别作为最终预测。这里有个细节:不同分类器的概率值是否可以直接比较?理论上可以,因为每个分类器输出的都是“属于该类”的概率估计。但实际上,由于每个二分类器的训练数据分布不同(正负样本比例不同),概率值的校准程度可能有差异。不过在实践中,直接取最大值的做法效果已经足够好。
sklearn中设置OvR的方式是multi_class='ovr':
from sklearn.linear_model import LogisticRegression clf_ovr = LogisticRegression( multi_class='ovr', solver='lbfgs', max_iter=1000, C=1.0, random_state=42 ) clf_ovr.fit(X_train, y_train)注意solver参数的选择。lbfgs是默认的优化器,支持多分类,但在OvR模式下,它实际上是对每个二分类器分别优化。liblinear是另一个常用优化器,但它只支持OvR,不支持Softmax。如果你明确要用OvR,liblinear和lbfgs都可以;如果你要用Softmax,必须选lbfgs、newton-cg、sag或saga。
3.2 Softmax回归:更优雅的多分类方案
Softmax回归(也叫多项逻辑回归)不拆分类别,而是直接建模“样本属于每个类别的概率”。它的输出层有10个神经元,每个神经元对应一个类别,输出值经过Softmax函数归一化,使得10个输出之和为1。Softmax函数的定义是:
[ P(y=k|x) = \frac{e^{w_k^T x + b_k}}{\sum_{j=1}^{K} e^{w_j^T x + b_j}} ]
其中K是类别数,(w_k)是第k个类别的权重向量。训练目标是最小化交叉熵损失,也就是最大化正确类别的预测概率。
Softmax和OvR的本质区别在于:OvR的每个分类器独立优化,互不干扰;Softmax的所有类别参数联合优化,共享同一个损失函数。这意味着Softmax在训练过程中会考虑类别之间的竞争关系——当模型把一张“4”的图片误判为“9”时,Softmax的梯度会同时调整4和9对应的参数,而OvR只会调整4对应的分类器(因为9的分类器在训练时把4当作负样本,但它的优化目标不涉及“4和9的区分”)。
从实践效果看,Softmax通常比OvR略好,尤其是在类别边界模糊的情况下。但差距不会特别大,MNIST上可能就差0.5到1个百分点。sklearn中设置Softmax的方式是multi_class='multinomial':
clf_softmax = LogisticRegression( multi_class='multinomial', solver='lbfgs', max_iter=1000, C=1.0, random_state=42 ) clf_softmax.fit(X_train, y_train)提示:sklearn在1.5版本之后,
multi_class参数的默认值改成了'auto',它会根据solver自动选择。如果你用的是较新版本,建议显式指定multi_class,避免因为版本差异导致结果不一致。
3.3 两种策略的实测对比
我在load_digits数据集上分别跑了OvR和Softmax,参数设置相同(C=1.0,max_iter=1000),结果如下:
| 策略 | 训练准确率 | 测试准确率 | 训练耗时 |
|---|---|---|---|
| OvR | 99.2% | 96.4% | 0.38s |
| Softmax | 99.5% | 97.1% | 0.52s |
Softmax在测试集上高了0.7个百分点,训练时间多了约0.14秒。这个差距在load_digits这种小数据集上不算显著,但在MNIST上,Softmax的优势会更明显一些。不过OvR有一个隐藏优势:每个二分类器可以独立并行训练,如果你有多核CPU或者多台机器,OvR的训练速度可以大幅提升。Softmax的联合优化则很难并行化。
选择哪种策略,取决于你的具体需求。如果追求极致准确率且不在意训练时间,选Softmax;如果需要快速训练或者要部署到资源受限的环境,OvR可能更合适。
4. 从零实现:不依赖sklearn的完整代码路径
4.1 为什么建议你手写一遍
sklearn的LogisticRegression封装得太好了,三行代码就能跑出97%的准确率。但如果你只停留在调包层面,遇到问题就很难排查——比如为什么准确率上不去、为什么某些数字总是分错、正则化参数C到底在做什么。手写一遍逻辑回归的多分类实现,能帮你把这些问号一个个拉直。
我当初手写的时候,最大的收获是理解了梯度下降中权重矩阵的维度变化。在OvR中,权重矩阵的形状是(10, 64),每一行对应一个类别的权重向量;在Softmax中,权重矩阵的形状也是(10, 64),但更新方式完全不同。这个维度变化看起来简单,但自己推导一遍梯度公式,感受完全不一样。
4.2 OvR版本的NumPy实现
先定义Sigmoid函数和损失函数:
import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-np.clip(z, -500, 500))) def binary_cross_entropy(y_true, y_pred): epsilon = 1e-15 y_pred = np.clip(y_pred, epsilon, 1 - epsilon) return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))np.clip是为了防止指数溢出。当z很大时,np.exp(-z)趋近于0,Sigmoid输出趋近于1;当z很小时,np.exp(-z)会溢出。把z限制在-500到500之间,既能保证数值稳定,又不会影响正常范围内的计算结果。
然后实现OvR的训练循环:
class OvRLogisticRegression: def __init__(self, n_classes, learning_rate=0.01, n_iters=1000, reg_lambda=0.01): self.n_classes = n_classes self.lr = learning_rate self.n_iters = n_iters self.reg_lambda = reg_lambda self.weights = None self.biases = None def fit(self, X, y): n_samples, n_features = X.shape self.weights = np.zeros((self.n_classes, n_features)) self.biases = np.zeros(self.n_classes) for k in range(self.n_classes): y_binary = (y == k).astype(float) w = np.zeros(n_features) b = 0.0 for _ in range(self.n_iters): z = X.dot(w) + b y_pred = sigmoid(z) error = y_pred - y_binary dw = (X.T.dot(error) / n_samples) + (self.reg_lambda / n_samples) * w db = np.mean(error) w -= self.lr * dw b -= self.lr * db self.weights[k] = w self.biases[k] = b def predict_proba(self, X): scores = X.dot(self.weights.T) + self.biases return sigmoid(scores) def predict(self, X): return np.argmax(self.predict_proba(X), axis=1)这里有几个关键点值得展开。第一,正则化项的梯度。L2正则化的损失项是(lambda/2) * ||w||^2,对w求导得到lambda * w。我在代码里写的是(reg_lambda / n_samples) * w,这是因为损失函数通常取均值,正则化项也要相应缩放。这个缩放系数会影响正则化的实际强度,调参时需要注意。
第二,学习率的选取。0.01是一个比较保守的值,配合1000次迭代通常能收敛。如果你发现损失下降太慢,可以尝试0.1;如果损失震荡甚至发散,降到0.001。我一般会先跑一个小的学习率(比如0.001)看损失是否稳定下降,确认没问题后再逐步增大。
第三,偏置项b的更新。db = np.mean(error)是对所有样本的误差取平均,这跟dw的计算逻辑一致。有些实现里会把偏置项合并到权重向量里(在X后面加一列全1),效果一样,但代码更简洁。
4.3 Softmax版本的NumPy实现
Softmax的实现比OvR更紧凑,因为不需要循环训练多个分类器:
class SoftmaxRegression: def __init__(self, n_classes, learning_rate=0.01, n_iters=1000, reg_lambda=0.01): self.n_classes = n_classes self.lr = learning_rate self.n_iters = n_iters self.reg_lambda = reg_lambda self.weights = None self.biases = None def softmax(self, z): z_shifted = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z_shifted) return exp_z / np.sum(exp_z, axis=1, keepdims=True) def fit(self, X, y): n_samples, n_features = X.shape self.weights = np.zeros((self.n_classes, n_features)) self.biases = np.zeros(self.n_classes) y_onehot = np.zeros((n_samples, self.n_classes)) y_onehot[np.arange(n_samples), y] = 1 for _ in range(self.n_iters): logits = X.dot(self.weights.T) + self.biases probs = self.softmax(logits) error = probs - y_onehot dw = error.T.dot(X) / n_samples + (self.reg_lambda / n_samples) * self.weights db = np.mean(error, axis=0) self.weights -= self.lr * dw self.biases -= self.lr * db def predict(self, X): logits = X.dot(self.weights.T) + self.biases return np.argmax(logits, axis=1)Softmax实现里最关键的一步是z_shifted = z - np.max(z, axis=1, keepdims=True)。这个操作叫数值稳定化,目的是防止np.exp(z)溢出。因为Softmax的分子分母同时乘以一个常数不改变结果,所以减去最大值是安全的。如果你不做这一步,当logits很大时(比如超过700),np.exp会返回inf,导致整个计算崩溃。
另一个容易出错的地方是error.T.dot(X)的维度。error的形状是(n_samples, n_classes),X的形状是(n_samples, n_features),error.T.dot(X)得到(n_classes, n_features),正好跟权重矩阵的形状一致。如果你写成X.T.dot(error),维度就对不上了。这个转置操作我当初调试了好几次才搞对。
5. 调参实战:C值、solver和max_iter的联动关系
5.1 正则化强度C的选取逻辑
sklearn的LogisticRegression中,C是正则化强度的倒数——C越小,正则化越强;C越大,正则化越弱。这个设计跟很多人的直觉相反,我第一次用的时候也搞混了。为什么用倒数?因为sklearn的优化目标是:
[ \min_w \frac{1}{2} w^T w + C \sum_{i=1}^{n} \log(1 + e^{-y_i w^T x_i}) ]
C出现在损失项前面,C越大,损失项占比越高,正则化项相对越弱。所以C小=强正则化,C大=弱正则化。
在load_digits上,我试了C从0.001到1000的多个值,测试准确率的变化如下:
| C值 | 测试准确率 | 备注 |
|---|---|---|
| 0.001 | 88.2% | 正则化过强,欠拟合 |
| 0.01 | 93.5% | 仍然偏强 |
| 0.1 | 96.0% | 接近最优 |
| 1.0 | 97.1% | 最优区间 |
| 10 | 96.8% | 开始过拟合 |
| 100 | 96.2% | 过拟合明显 |
| 1000 | 95.5% | 严重过拟合 |
可以看到,C在0.1到10之间时效果最好,1.0附近是峰值。C太小会导致模型欠拟合——决策边界过于平滑,无法捕捉数字之间的细微差异;C太大则过拟合——模型过度记忆训练样本的噪声,泛化能力下降。
实操建议:调参时先用对数尺度粗调(0.001, 0.01, 0.1, 1, 10, 100),找到最优区间后再在该区间内细调。不要一上来就用网格搜索遍历所有值,那样计算量太大且没必要。
5.2 solver选择对结果的影响
sklearn支持多种solver,常用的有lbfgs、liblinear、saga、newton-cg。它们在多分类场景下的表现差异如下:
| solver | 支持OvR | 支持Softmax | 适用场景 |
|---|---|---|---|
| lbfgs | 是 | 是 | 默认选择,中小数据集表现稳定 |
| liblinear | 是 | 否 | 小数据集,OvR专用 |
| newton-cg | 是 | 是 | 与lbfgs类似,某些情况下更快 |
| saga | 是 | 是 | 大数据集,支持L1正则化 |
我在load_digits上对比了lbfgs和liblinear(都用OvR),测试准确率几乎一样(96.4% vs 96.3%),但liblinear的训练时间更短(0.21s vs 0.38s)。这是因为liblinear是专门为中小规模数据设计的坐标下降算法,在特征数不多的情况下效率很高。
但如果你要用Softmax,liblinear就不支持了,必须换lbfgs或saga。saga支持L1正则化,如果你需要做特征选择(比如找出哪些像素对分类最重要),saga配合penalty='l1'是一个好选择。不过L1正则化在像素数据上的效果通常不如L2,因为像素之间的相关性很强,L1倾向于随机保留其中一个而丢弃其他,导致结果不稳定。
5.3 max_iter与收敛警告的处理
max_iter控制优化器的最大迭代次数。默认值是100,在load_digits上通常不够,会触发ConvergenceWarning。我第一次跑的时候看到这个警告没当回事,结果准确率只有91%,调到1000之后直接跳到97%。
处理收敛警告的正确姿势是:先增大max_iter,观察准确率是否提升。如果增大到5000准确率还在涨,说明模型还没收敛,需要继续增大或者调整学习率。如果增大到2000之后准确率不再变化,说明已经收敛,警告可以忽略。
import warnings from sklearn.exceptions import ConvergenceWarning # 方法一:增大max_iter clf = LogisticRegression(max_iter=5000, ...) # 方法二:如果确认已收敛,忽略警告 warnings.filterwarnings("ignore", category=ConvergenceWarning)我个人的习惯是设max_iter=2000,这个值在大多数中小数据集上都能收敛,同时不会让训练时间过长。如果2000还不够,我会检查数据是否需要更好的预处理(比如增加多项式特征或者做PCA降维)。
6. 混淆矩阵里的秘密:哪些数字最容易分错
6.1 用混淆矩阵定位问题类别
准确率只能告诉你“整体对了多少”,但无法告诉你“哪些类别容易混淆”。混淆矩阵能精确展示每个类别的分类情况。在load_digits上跑Softmax回归,得到的混淆矩阵如下(部分):
| 真实\预测 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 44 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 50 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2 | 0 | 0 | 43 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 | 44 | 0 | 0 | 0 | 0 | 1 | 0 |
| 4 | 0 | 0 | 0 | 0 | 44 | 0 | 0 | 0 | 1 | 0 |
| 5 | 0 | 0 | 0 | 0 | 0 | 44 | 0 | 0 | 0 | 1 |
| 6 | 0 | 0 | 0 | 0 | 0 | 0 | 44 | 0 | 0 | 0 |
| 7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 44 | 0 | 0 |
| 8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 42 | 2 |
| 9 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 43 |
从矩阵中可以看到,主要的混淆发生在8和9之间(8被误判为9有2次,9被误判为8有1次),以及4和8之间(4被误判为8有1次)。这跟我们的直觉一致——8和9在低分辨率下确实很像,尤其是手写风格比较潦草的时候。
6.2 可视化错误样本:眼见为实
光看数字不够直观,我习惯把分错的样本画出来看看。用matplotlib把测试集中预测错误的样本可视化:
import matplotlib.pyplot as plt y_pred = clf_softmax.predict(X_test) misclassified = np.where(y_pred != y_test)[0] fig, axes = plt.subplots(2, 5, figsize=(10, 4)) for idx, ax in enumerate(axes.flat): if idx < len(misclassified): sample_idx = misclassified[idx] ax.imshow(X_test[sample_idx].reshape(8, 8), cmap='gray') ax.set_title(f"True: {y_test[sample_idx]}, Pred: {y_pred[sample_idx]}") ax.axis('off') plt.tight_layout() plt.show()看到这些错误样本之后,你会发现有些图片连人眼都很难分辨——比如某个“9”写得特别像“8”,或者某个“4”的横线没写清楚。这说明模型的错误有一部分是数据本身的问题,不完全是模型的锅。
6.3 针对易混淆类别的改进思路
如果你想让模型在易混淆类别上表现更好,有几个方向可以尝试。第一,增加多项式特征。逻辑回归的决策边界是线性的,但数字分类的边界往往是非线性的。通过PolynomialFeatures生成二次项,可以让模型学习到更复杂的边界。不过特征维度会从64暴增到2145(64+64*63/2),训练时间会显著增加。
第二,对易混淆类别做专门的特征工程。比如8和9的区别主要在上半部分的闭合区域,你可以手动提取这个区域的像素统计量作为额外特征。但这种方法需要领域知识,而且泛化性差,换一个数据集可能就不适用了。
第三,换用非线性模型。逻辑回归的线性边界是它的根本限制,如果你追求更高的准确率,SVM(带RBF核)或者随机森林是更自然的选择。但这就超出了本文的范围了。
7. 工程化落地时踩过的坑与经验总结
7.1 模型持久化与版本兼容
训练好的模型需要保存下来供后续使用。sklearn推荐用joblib而不是pickle,因为joblib对包含大量NumPy数组的对象序列化效率更高:
import joblib joblib.dump(clf_softmax, 'logistic_digits_model.pkl') clf_loaded = joblib.load('logistic_digits_model.pkl')但这里有个坑:sklearn版本不兼容会导致加载失败。我在一台机器上用sklearn 1.2训练并保存了模型,在另一台用sklearn 0.24的机器上加载,直接报AttributeError。解决办法是在部署环境中固定sklearn版本,或者在保存模型时同时记录版本号。
7.2 预测新图片时的预处理一致性
训练时的预处理步骤(归一化、展平)必须在预测时完全一致。我见过有人训练时把像素除以255,预测时忘了除,结果模型输出全是乱码。更隐蔽的问题是图片的维度——训练时用的是8×8展平成64维,预测时如果传入的是28×28的图片,维度对不上直接报错。
建议:把预处理逻辑封装成一个函数,训练和预测都调用同一个函数,避免手动操作导致的不一致。
7.3 关于准确率的天花板
逻辑回归在load_digits上的准确率天花板大约在97%到98%之间,在MNIST上大约在92%到93%之间。如果你看到有人声称用逻辑回归在MNIST上达到99%,那大概率是用了额外的技巧(比如数据增强、集成学习、或者把逻辑回归当特征提取器后面接了其他模型)。
接受这个天花板很重要,它帮你建立合理的预期。如果你需要更高的准确率,应该换模型而不是死磕逻辑回归的参数。但如果你是为了学习多分类的底层机制,逻辑回归是最好的起点——它足够简单,你能完全理解每一步在做什么;又足够强大,能在实际数据上跑出可用的结果。
7.4 一个容易被忽略的细节:类别不平衡
load_digits的类别分布比较均匀,每个数字大约180个样本。但真实场景中的数据往往是不平衡的——比如某个数字的样本特别少。这时候OvR和Softmax都会偏向多数类,导致少数类的召回率很低。解决办法是设置class_weight='balanced',让模型自动调整不同类别的权重:
clf = LogisticRegression( multi_class='multinomial', class_weight='balanced', max_iter=2000 )这个参数在类别不平衡时非常有用,但在类别平衡时几乎没有影响。我建议你在拿到数据后先检查一下类别分布,如果最大类别和最小类别的样本数差距超过3倍,就考虑加上这个参数。
7.5 关于学习率的经验值
如果你手写实现逻辑回归,学习率的选取非常关键。我试过在load_digits上用0.001、0.01、0.1、1.0四个值跑Softmax,结果如下:
| 学习率 | 最终损失 | 测试准确率 | 备注 |
|---|---|---|---|
| 0.001 | 0.32 | 94.2% | 收敛太慢,1000次迭代不够 |
| 0.01 | 0.08 | 97.1% | 收敛速度和稳定性平衡最好 |
| 0.1 | 0.06 | 96.8% | 收敛快但末期有震荡 |
| 1.0 | 0.15 | 93.5% | 震荡严重,部分类别无法收敛 |
0.01是最稳妥的选择。如果你用自适应学习率优化器(比如Adam),初始学习率可以设0.001,效果通常比固定学习率好。但sklearn的LogisticRegression不支持Adam,只有saga支持自适应步长,所以如果你用sklearn,还是老老实实调C和max_iter吧。
7.6 关于特征缩放的一个补充
前面说了像素值除以255,但如果你在特征工程中加入了其他特征(比如像素的平方、像素之间的比值),这些新特征的尺度可能跟原始像素差异很大。这时候需要重新做标准化(减去均值、除以标准差),而不是简单的除以255。标准化比归一化更通用,因为它不依赖于特征的已知范围。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)注意fit_transform只在训练集上调用,测试集用transform。这个顺序不能反,否则测试集的统计量会泄露到训练过程中。
我在实际项目中的体会是,逻辑回归做手写数字识别这个任务,代码量不大,但涉及的知识点非常密集——数据预处理、多分类策略、正则化、优化器选择、模型评估、错误分析,每一个环节都有值得深挖的细节。把这个项目完整跑一遍,你对机器学习分类任务的理解会上一个台阶。后续如果想继续深入,可以尝试用逻辑回归做特征提取,把倒数第二层的输出喂给SVM或随机森林,看看准确率能提升多少。这个思路在一些实际项目中比直接端到端训练更有效,尤其是当标注数据有限的时候。