简介:这套基于贝叶斯最小错误率的手写数字识别项目,面向机器学习初学者与模式识别课程设计者,解决手写数字高效分类问题。项目在贝叶斯决策框架下,不仅计算后验概率,还引入错误成本,使不同误判损失能得到合理权衡,避免将“1”误判为“7”与将“9”误判为“4”产生同等代价。压缩包共5个文件,大小仅52KB,包括两个M程序文件、一个FIG界面、一个DAT特征数据和一个MAT样本库:M主程序完成识别流程,特征提取函数负责预处理与特征编码,样本文件提供预训练数据,GUI便于用户绘图测试。已有551人学习下载。借助这套资源,读者能完整走通从特征提取、模型训练到界面测试的贝叶斯分类流程,也可将其作为课程设计或实验报告的参考方案,深入理解最小错误率决策的实际应用。
1. 贝叶斯最小错误率:手写数字识别里先立住决策规则
如果你已经用深度学习跑惯了 MNIST,可能会觉得“贝叶斯最小错误率手写数字识别”是个老古董。但真在手写数字场景里做过基线、做过拒识、做过小样本分类的人会有个反直觉的体会:贝叶斯最小错误率分类器的瓶颈从来不是那个公式,而是你给它的类条件概率密度靠不靠谱。换个说法,数字 4 和 9 的像素分布高度重叠时,再精确的后验概率也没法把错误率压到 0,但它至少能告诉你“这个样本不能信”。
这正是这个标题的价值:它解决的问题不是“能不能识别数字”,而是“在已知每个数字类别先验和像似然的前提下,如何做出错误率最小的决策”。它适合两类人,一类是想把模式识别里的生成式分类器真正落到代码上的工程师,另一类是手里只有几百张标注样本、但又不想一上来就上神经网络的团队。全文会从决策规则一路走到参数估计、混淆矩阵分析和调优技巧,把整个链路用最小可运行代码串起来。
2. 用贝叶斯最小错误率分类器判别数字:先验、似然与决策面
2.1 从贝叶斯公式到最小错误率决策规则
贝叶斯最小错误率分类器的起点是贝叶斯公式:
P(C_k|x) = P(x|C_k) * P(C_k) / P(x)
在手写数字识别里,C_k 是 0 到 9 这十个类别,x 是一张归一化后的特征向量。P(C_k) 是类别先验,P(x|C_k) 是该类别的类条件概率密度,分母 P(x) 对所有类别相同,所以决策时只需要比较分子大小。
最小错误率决策规则是什么呢?它不做任何花哨的投影或距离计算,只做一件事:选择后验概率最大的那个数字类别。这个规则在 0-1 损失下被证明是错误率最小的。0-1 损失的意思是,分类正确损失为 0,分类错误损失为 1,而且所有类别的错误损失完全相同。手写数字识别的大部分落地场景天然满足这个前提:把 4 认成 9 和把 9 认成 4,代价是一样的,所以最小错误率分类器是合理的起点。
2.2 最小错误率与最小风险:什么时候不能只看后验
如果业务场景里错误代价不对称,最小错误率就不是最优目标。举个例子,银行表单里的手写金额识别,把 3 认成 8 属于金额录入错误,把 8 认成 3 同样严重,这是对称的。但在“单据自动通过 + 低置信转人工”的场景里,漏过一张错票的成本远高于把好票转人工去核验,此时该用的是最小风险决策,而不是最小错误率。
最小错误率和最小风险的区别可以看下面的表格。
| 决策目标 | 损失矩阵设定 | 适用场景 | 决策方式 |
|---|---|---|---|
| 最小错误率 | 0-1 损失,所有错误代价相同 | 普通手写数字分类、MNIST 基准实验 | argmax 后验概率 |
| 最小风险 | 非对称损失,不同错误代价不同 | 票据审核、金额录入、拒识场景 | 按损失矩阵加权后比较 |
回归到手写数字识别这个标题,默认就采用 0-1 损失下的最小错误率分类器。只有当你发现误识和拒识的业务代价不同,才需要把决策规则从“argmax 后验”改成“argmin 期望损失”。
2.3 高斯假设下的决策面:为什么不是像素距离最近
很多人以为写数字分类就是拿距离度量,比如算一下测试样本和每类均值的欧氏距离,然后选最近的。这种做法其实隐含了一个很强的概率假设:每一类数字的像素分布都是各向同性的高斯分布,协方差矩阵是单位阵的倍数。手写数字的笔画变异根本不满足这个假设。
数字 1 的横向摆动通常不大,但纵向笔画的粗细变化明显;数字 0 的椭率在不同人笔下差异很大。若用全协方差矩阵建模,贝叶斯分类器会退化为马氏距离分类,样本到每类中心的距离要被该类协方差矩阵重新加权。这也是贝叶斯最小错误率分类器比“取均值然后算距离”更优的根本原因:它显式刻画了每一类数字的协方差结构。
3. 手写数字识别特征准备:从灰度图到可估计协方差的向量
3.1 灰度读取、归一化与反色处理
先用 MNIST 作为演示,但要注意很多真实手写数字扫描件是白底黑字,和 MNIST 的黑底白字相反。如果直接拿反色图像喂给模型,均值向量和协方差矩阵都会偏移,识别率往往掉 2 到 3 个百分点。
读取和预处理的常见做法是先把图像拉平成向量,再做标准化:
import numpy as np from tensorflow.keras.datasets import mnist (x_train_raw, y_train), (x_test_raw, y_test) = mnist.load_data() X_train_raw = x_train_raw.reshape(x_train_raw.shape[0], -1).astype(np.float32) X_test_raw = x_test_raw.reshape(x_test_raw.shape[0], -1).astype(np.float32) # 如果图像是白底黑字,先反色;MNIST 本身是黑底白字 # X_train_raw = 255.0 - X_train_raw mean = X_train_raw.mean(axis=0) std = X_train_raw.std(axis=0) + 1e-6 X_train = (X_train_raw - mean) / std X_test = (X_test_raw - mean) / std标准化参数只在训练集上计算,测试集复用同一个 mean 和 std。不要在测试集上重新估计均值和方差,这会让模型看到测试集的全局统计信息,实验结论会偏乐观。如果原始图像尺寸不是 28×28,需要先统一缩放,常见的插值参数建议用 INTER_AREA 做缩小、用 INTER_CUBIC 做放大,避免出现明显锯齿。
3.2 PCA 降维:维度是一个需要调的参数
原始 784 维向量直接用于估计协方差矩阵,理论上需要 784 维以上、数量充足且覆盖各类变异的样本。如果训练集只有几千张,全协方差矩阵几乎必然奇异。PCA 是最常见的降维手段,而且 PCA 的另一个好处是它做了去相关,让后续协方差矩阵估计更稳定。
from sklearn.decomposition import PCA pca = PCA(n_components=0.95) X_train_pca = pca.fit_transform(X_train) X_test_pca = pca.transform(X_test) print("保留维度:", pca.n_components_) print("累计解释方差比:", pca.explained_variance_ratio_.sum())n_components=0.95 的含义是保留 95% 的总方差,这是一个经验起点。手写数字这种低层视觉任务,多数情况下维度会落在 70 到 120 之间。也可以用固定维度,比如 n_components=64,配合后面的贝叶斯优化来调。PCA 的 fit 只能在训练集上,测试集只做 transform,原因是 PCA 的基向量属于训练集特征分布的一部分。
3.3 数据划分与信息泄漏问题
MNIST 自带训练集和测试集,但自有数据集时需要分层划分。手写数字通常类别不平衡程度不高,但为了避免某个数字恰好全落在验证集里,建议用分层采样:
from sklearn.model_selection import StratifiedShuffleSplit sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(sss.split(X_train_raw, y_train)) Xfold_train = X_train_raw[train_idx] yfold_train = y_train[train_idx] Xfold_val = X_train_raw[val_idx] yfold_val = y_train[val_idx]注意一个容易踩的坑:如果要做数据增强,比如旋转、平移、加噪声,增强操作必须发生在划分之后,而且只能对训练集增强。否则原始样本和它的增强版本可能同时出现在训练集和验证集里,验证集误差会被严重低估。
4. 高斯类条件概率密度估计与最小错误率分类器实现
4.1 每类独立估计均值与协方差
高斯判别分析的核心是假设每个数字类别的特征向量服从多维高斯分布。对第 k 类数字,需要估计均值向量 mu_k 和协方差矩阵 Sigma_k:
mu_k = 1/N_k * sum(x_i)
Sigma_k = 1/N_k * sum((x_i - mu_k)(x_i - mu_k)^T)
这里用的是最大似然估计。注意一个细节:样本协方差的分母用 N 而不是 N-1 在分类任务里影响很小,但如果训练样本数不大,更稳妥的做法是用除以 (N-1) 的无偏版本,即 numpy 中 np.cov 的默认行为。这个差异在高维小样本下会略微影响矩阵的条件数。
4.2 协方差矩阵的三种策略与正则化参数
在实际手写数字识别里,全协方差矩阵并不总是最优。特征维度高、样本少的时候,协方差矩阵的估计误差会直接放大到判别函数里。下表是三种常见策略。
| 协方差策略 | 参数数量 | 决策面类型 | 手写数字场景下的表现 |
|---|---|---|---|
| 每类独立全协方差 | 多,约 d + d(d+1)/2 每类 | 二次决策面 | 能捕捉笔画结构,但样本不足时易过拟合 |
| 各类共享一个协方差 | 中等,只估计一个全局 Sigma | 线性决策面 | 样本效率高,但忽略了不同数字的形态差异 |
| 对角协方差(朴素贝叶斯) | 少,每类 d 个方差 | 线性决策面,按维度独立加权 | 参数少、训练快,但像素相关性被丢弃 |
全协方差在高维下的推荐做法是加正则约束。常见实现是 Sigma_k = (1 - lambda) * Sigma_k_mle + lambda * diag(Sigma_k_mle),lambda 取值通常从 1e-4 到 1e-1。lambda 过大会把协方差拉成对角阵,退化成朴素贝叶斯;过小则矩阵可能不可逆。
4.3 用 log 域实现后验计算与预测
实现贝叶斯最小错误率分类器时,最怕的是高维高斯密度数值下溢。特征维度几十维时,概率密度值会小到超出浮点数范围。解决办法是全程在 log 域计算,只比较对数后验的大小。
import numpy as np class BayesMinErrorClassifier: def __init__(self, reg=1e-3): self.reg = reg def fit(self, X, y): self.classes = np.unique(y) self.means = {} self.covs = {} self.priors = {} n = len(X) for c in self.classes: Xc = X[y == c] self.priors[c] = len(Xc) / n self.means[c] = Xc.mean(axis=0) cov = np.cov(Xc, rowvar=False) # 正则化防止协方差奇异 cov += self.reg * np.eye(cov.shape[0]) self.covs[c] = cov return self def _log_likelihood(self, X, c): mu = self.means[c] cov = self.covs[c] d = X - mu # pinv 比 inv 更稳,奇异时不会直接报错 cov_inv = np.linalg.pinv(cov) logdet = np.linalg.slogdet(cov)[1] maha = np.sum((d @ cov_inv) * d, axis=1) return -0.5 * maha - 0.5 * logdet def predict_log_proba(self, X): log_posterior = np.column_stack([ self._log_likelihood(X, c) + np.log(self.priors[c]) for c in self.classes ]) # 减掉最大值,让数值稳定,不回传概率也成立 return log_posterior - log_posterior.max(axis=1, keepdims=True) def predict(self, X): idx = np.argmax(self.predict_log_proba(X), axis=1) return self.classes[idx]这里使用了 np.linalg.pinv 而不是 np.linalg.inv,因为即便加了正则,高维协方差矩阵仍可能出现接近奇异的特征值,pinv 会通过奇异值分解给出稳定的伪逆。predict_log_proba 中减去每行最大值不会改变 argmax 的结果,但可以防止后面对数概率做指数变换时溢出。这个类已经是一个可用的最小错误率贝叶斯分类器,训练后直接调 predict 就能得到数字标签。
如果你的特征是 PCA 降维后的向量,并且数据做了标准化,这套实现基本不会遇到数值问题。MATLAB 用户也不必拘泥于 Python,拟合分别估计 mu 和 sigma,分类时用 mvnpdf 计算每个类别的似然,再乘先验取最大值即可,逻辑完全一样。
5. 手写数字识别的评估:混淆矩阵与错误模式分析
5.1 用混淆矩阵而不是准确率定位问题
贝叶斯最小错误率分类器训练完成后,最常见的评估错误是只看总准确率。总准确率掩盖了所有结构性的错误关系。比如某个模型整体准确率 95%,但其中 4 被认成 9 的比例高达 20%,这在特定业务里是不可接受的。正确做法是打印逐类指标和混淆矩阵。
from sklearn.metrics import confusion_matrix, classification_report y_pred = clf.predict(X_test_pca) report = classification_report(y_test, y_pred, digits=3) print(report) cm_normalized = confusion_matrix(y_test, y_pred, normalize='true') print(cm_normalized.round(3))classification_report 给出每个数字类别的精确率、召回率和 F1。normalize='true' 会对每一行(真实类别)归一化,这样第 i 行第 j 列的含义是“真实为 i 的样本中有多少比例被预测成了 j”。它是排查类别混淆的主力工具。
5.2 常见混淆对与对应的修正方向
基于高斯判别分析的手写数字识别器,错误往往集中在这几个方向。
| 混淆对 | 典型原因 | 尝试的修正方向 |
|---|---|---|
| 4 和 9 | 上半部分结构相似,PCA 保留维度偏低 | 提高 PCA 维度,或加入局部结构特征 |
| 3 和 8 | 中间笔画闭合关系的差异被特征向量稀释 | 保留更多主成分,或对图像做细化 |
| 7 和 1 | 7 的短横缺失或过浅 | 做笔画宽度归一化,或增加相应训练样本 |
| 0 和 6 | 下半部闭合差异在低分辨率下不显著 | 上采样到更大尺寸,再重新降维 |
看到某个混淆对后,我一般会先回去检查该类别在 PCA 子空间下的分布图。直接取训练集里 500 个样本做二维可视化,观察 4 和 9 的重叠区域。如果重叠区域过大,就算改分类器结构也救不回来,问题出在特征表示或图像本身的分辨率上。
5.3 用 t-SNE 检查特征空间的重叠程度
from sklearn.manifold import TSNE sample_idx = np.random.default_rng(0).choice(len(X_train_pca), 2000, replace=False) X_sample = X_train_pca[sample_idx] y_sample = y_train[sample_idx] tsne = TSNE(n_components=2, perplexity=30, random_state=0) X_emb = tsne.fit_transform(X_sample) # 按 y_sample 分别散点绘制即可观察类别边界perplexity 一般取 5 到 50,样本量 2000 时 30 是常用起点。t-SNE 的结果不是分类器,它只是帮你判断特征是否可分。如果两个数字类在 t-SNE 图上完全纠缠,贝叶斯最小错误率分类器无论怎么调参,准确率天花板都摆在那里。反过来,如果类别边界清晰但分类器表现不佳,那问题更可能出在协方差矩阵估计或正则化强度上。
6. 贝叶斯最小错误率分类器调优的 3 个实用技巧
6.1 用贝叶斯优化调 PCA 维度和正则化系数
PCA 维度和协方差正则化参数 lambda 之间存在交互:维度越高,全协方差矩阵需要的正则化越强;维度越低,模型偏差越大。手动调这两个参数很折磨,常见做法是交给贝叶斯优化来搜。以 Optuna 为例,它使用 TPE 这类贝叶斯优化策略,相比网格搜索能更快找到较好的参数组合。
import optuna def objective(trial): n_comp = trial.suggest_int("n_comp", 40, 120) reg = trial.suggest_float("reg", 1e-5, 1e-1, log=True) pca_tmp = PCA(n_components=n_comp) X_tr = pca_tmp.fit_transform(X_train) X_te = pca_tmp.transform(X_val) clf_tmp = BayesMinErrorClassifier(reg=reg) clf_tmp.fit(X_tr, y_train) acc = np.mean(clf_tmp.predict(X_te) == y_val) return acc study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50) print(study.best_params)贝叶斯优化的意义在于,它能尝试那些人工调参容易忽略的参数组合,比如高维度配较高正则化。运行时建议把验证集固定,避免不同 trial 之间因数据划分不同而产生噪声。
6.2 用后验概率阈值做拒识,而不只是输出标签
最小错误率分类器的天然副产品是每个类别的后验概率。在真实业务里,很多错误可以通过“低置信拒识”转化掉。设置一个概率阈值,比如后验最大值低于 0.9 时拒绝分类,转到人工处理。
log_proba = clf.predict_log_proba(X_test_pca) max_prob = np.exp(log_proba.max(axis=1)) reject_mask = max_prob < 0.9 y_pred_final = clf.predict(X_test_pca) y_pred_final[reject_mask] = -1 # -1 表示拒识阈值的选择可以用“拒绝率对错误率曲线”来定。横轴是阈值从 0 到 0.99 变化,纵轴是剩余样本的错误率。挑一个业务能接受的人工处理量对应的阈值即可。这就是把最小错误率思想从分类层延伸到决策层,也是贝叶斯分类器相比纯判别模型的实用优势。
6.3 对比训练集、验证集和拒识集的分布差异
最后一步是判断模型是否真的在泛化。把贝叶斯分类器在训练集和验证集上各自的混淆矩阵做差,如果差异集中在几个特定类别上,说明这些类存在过拟合;如果差异均匀,说明整体都过拟合了,优先加大正则化或降低 PCA 维度。
拒识集也要单独看。那些后验概率低于阈值的样本,它们的真实标签如果集中在某几类,说明这些类在特征空间里本身叠得厉害,需要回到第 5 章的错误模式分析去补样本或改特征。校准曲线、混淆矩阵差异和拒绝率曲线这三样放在一起,基本就能判断分类器是在泛化还是在背训练集。
本文还有配套的精品资源,点击获取