资讯动态

Matlab朴素贝叶斯分类器代码实现与精度调优

发布时间:2026/10/25 10:04:41 来源:尧图企业网站定制
简介这份面向机器学习初学者与Matlab实践者的朴素贝叶斯分类器代码包聚焦用简洁脚本复现基于贝叶斯定理与特征条件独立假设的分类流程帮助读者从概率计算层面理解先验概率、条件概率与分类决策的实现方式适合课程实验、算法入门和代码复现等场景。压缩包共3个文件均为.m格式的Matlab源码整体约961B体量轻巧包含训练、预测与测试三类模块便于串联“建模—预测—验证”的完整学习链路也方便按需拆解和改写。已有3441人学习说明其在入门实践中具有一定参考价值。读者可据此梳理核心公式如何落地为代码观察训练与预测函数的分工并借助测试脚本检查分类结果为后续扩展到文本分类、垃圾邮件识别等任务打基础。1. 从一个跑出 33% 精度的分类器说起朴素贝叶斯在 Matlab 里的真实定位三分类、四个特征、两百来行 Matlab 朴素贝叶斯代码第一次跑出来的精度是 33%——正好等于乱猜。原因不一定在算法本身某类样本的某个特征方差被算成 0高斯似然整列变成 NaNargmax 永远挑第一个类。这份朴素贝叶斯分类器 Matlab 代码要解决的正是这类问题它把先验估计、类条件参数、拉普拉斯平滑、对数域后验、分层交叉验证拆成独立的 .m 文件而不是全塞进一个脚本。适合两类人要在课程设计或小论文里跑通完整流程、并且能解释每个参数含义的学生以及手上有一批几千到几万行表格数据、想先拿一个不依赖深度学习工具箱的基线模型探底的工程开发者。2. 项目骨架与数据流一份 Matlab 分类器由哪几块拼起来多数人拿到 Matlab 代码的第一反应是双击主文件按 F5能出图就算跑通。可一旦要换数据、换特征、换评价口径就会发现自己在 workspace 里翻十几个临时变量根本分不清哪个是训练产物、哪个是中间缓存。这个项目的组织思路很朴素入口脚本只负责串流程算法函数只吃矩阵吐结构体读写和切分放到 utils图和结果落到 results。四层职责分开改任何一层都不用动其他三层的代码这也是后面能顺利做二次开发的前提。2.1 目录结构入口、算法、工具、结果四层分开naive-bayes-matlab/ ├── main_demo.m % 唯一入口串联训练、预测、评估、出图 ├── data/ │ ├── flower_measure.csv % 数值特征示例4 特征 3 分类 │ └── text_counts.mat % 离散计数示例词频矩阵 标签 ├── src/ │ ├── nb_train.m % 高斯朴素贝叶斯训练 │ ├── nb_predict.m % 高斯朴素贝叶斯预测对数域 │ ├── nb_train_discrete.m % 多项式版本训练带平滑系数 │ ├── nb_predict_discrete.m │ └── nb_evaluate.m % 精度、混淆矩阵、每类 P/R/F1 ├── utils/ │ ├── load_dataset.m % 统一读入 类型校验 │ └── split_holdout.m % 分层切分避免小类别在测试集里消失 └── results/ └── .gitkeep % 混淆矩阵图、参数扫描曲线落盘位置src 下面按「连续特征」和「离散计数特征」分成两套训练/预测函数是这套代码里比较关键的一个决定。高斯版本假设每个特征在类内服从正态分布只存均值向量和标准差向量参数量是 K×2d多项式版本假设特征是词频计数存一张 K×V 的对数概率表。两种假设对应的数据结构不同硬塞进一个函数里靠 if 分支判断可读性会迅速崩掉。分开写主脚本里换个函数名就切换了模型代价几乎为零。utils 里的两个文件看着不起眼但它们是复现性的保证。load_dataset 负责把 csv、mat、甚至手工构造的矩阵统一成「n×d 数值矩阵 n×1 标签向量」这一种形态split_holdout 负责固定随机种子并做分层抽样否则换个机器跑出来的精度能差三个点你会怀疑是算法写错了。提示把所有函数放在 src 和 utils 里主脚本开头用addpath(genpath(...))一次性加进来。genpath 会递归包含子目录后面新增文件不用改路径配置。2.2 数据组织约定X 是 n×d 数值矩阵y 是 n×1 标签向量整个项目只认一种数据形态特征矩阵 X 是 double 类型的 n 行 d 列第 i 行第 j 列就是第 i 个样本的第 j 个特征标签 y 是 n 行 1 列的向量可以是数值、字符串数组或 categorical。这个约定听起来理所当然实际踩坑最多的是两件事一是从 table 里取列时忘了转 double得到一个 cell 数组后面 mean 直接报错二是标签用了字符串但顺序没固定训练时 classes 是[A,B,C]预测时变成[C,A,B]混淆矩阵行列全错位。function [X, y, classes] load_dataset(filepath) %LOAD_DATASET 读入数据并统一成 n×d 数值矩阵 n×1 标签向量 % filepath 支持 .csv最后一列为标签和 .mat需含 X / y 两个变量 [~, ~, ext] fileparts(filepath); switch lower(ext) case .csv T readtable(filepath); y T{:, end}; % 最后一列当标签 X table2array(T(:, 1:end-1)); case .mat S load(filepath); X S.X; y S.y; otherwise error(load_dataset:ext, 不支持的文件类型%s, ext); end % 类型兜底cell 或 table 混进来时统一转 double避免后续 mean/std 报错 if iscell(X), X cell2mat(X); end if ~isa(X, double), X double(X); end y y(:); % 强制列向量防止行向量导致索引错乱 classes unique(y); % 类别顺序只在这里定一次后续全部沿用 classes classes(:); % 缺测值直接报错比悄悄传播成 NaN 后精度异常更容易排查 assert(~any(isnan(X(:))), 特征矩阵含 NaN请先做缺失值处理); end这段代码里真正有价值的不是读文件那几行而是最后三句。y y(:)把行向量掰成列向量是因为y classes(c)这种比较在行/列方向不一致时会广播成矩阵得到一堆逻辑矩阵而不是索引向量报错信息还特别难懂。classes unique(y)只执行一次并把结果存进模型结构体是为了训练和预测阶段共用同一套类别顺序混淆矩阵的行列才不会错位。最后的 assert 是血泪经验特征里混进 NaN 时mean 会返回 NaN方差变 NaN高斯似然整列 NaN最后的精度表现就是「全部预测成某一类」你从结果完全看不出根因在数据。2.3 模型结构体把训练产物收进一个 struct训练函数不返回一堆散装变量而是返回一个结构体。字段不多但每个字段的下游用途都很明确。字段维度含义谁在用classesK×1类别列表顺序固定预测、评估、画图muK×d每类每特征的均值高斯似然sigmaK×d每类每特征的标准差已加下界高斯似然priorK×1类先验概率和为 1后验的加法项logPriorK×1先验取对数防下溢预测阶段直接用nTrain标量训练样本数排查用日志输出结构体的好处在二次开发时特别明显想加一个特征标准化步骤只要在训练前处理 X模型字段一个不用改想换成离散版本mu/sigma换成logTheta预测函数跟着换主脚本里的调用形式完全一样。这种「同样的调用形状、不同的内部实现」是 Matlab 里做模型对比最省事的方式。2.4 一次完整调用链路从 load 到 evaluate主脚本就是四行核心调用加几行打印和出图。链路短但每一环的输入输出契约必须清楚load 出 X/y/classessplit 出四份切分train 吃训练集吐结构体predict 吃结构体和测试集吐标签加得分evaluate 吃两组标签吐统计量。% main_demo.m —— 最小可运行流程 addpath(genpath(fullfile(pwd, src))); addpath(genpath(fullfile(pwd, utils))); [X, y, classes] load_dataset(fullfile(data, flower_measure.csv)); [Xtr, ytr, Xte, yte] split_holdout(X, y, 0.3, 42); % 30% 测试种子固定 model nb_train(Xtr, ytr); % 训练只依赖训练集 [yHat, score] nb_predict(model, Xte); % 预测score 是 n×K 的对数后验 stats nb_evaluate(yte, yHat, model.classes); fprintf(Hold-out 精度: %.4f训练样本 %d测试样本 %d\n, ... stats.acc, numel(ytr), numel(yte)); disp(array2table(stats.conf, RowNames, string(model.classes), ... VariableNames, string(model.classes)));split_holdout的第四个参数是随机种子。很多人写切分函数时直接调 randperm跑一次 0.92再跑一次 0.88来回几次就开始怀疑人生。固定种子之后精度波动只可能来自代码改动排查范围立刻收窄一半。这个函数还做了分层每个类别内部单独打乱再按比例抽测试样本小类别至少保留 1 个进测试集避免出现「测试集里根本没有第三类」这种让宏平均 F1 直接变成 NaN 的情况。3. 核心算法拆解先验概率、条件概率与拉普拉斯平滑怎么落到代码里朴素贝叶斯的公式在纸上一行就写完了落到代码里真正需要做决定的是三件事似然用哪种分布、概率连乘怎么防下溢、某个特征在某个类下从没出现过时怎么处理零概率。这三件事决定了代码在真实数据上能不能跑出合理结果也决定了别人读你代码时能不能一眼看出你做的是哪个变种。3.1 训练阶段按类别切分后统计均值与标准差训练做的事情只有一件把数据按标签分组每组内对每个特征算均值和标准差再统计各类的样本占比作为先验。看起来简单但有两个细节会决定成败方差为 0 的处理以及先验为 0 的处理。function model nb_train(X, y, varFloor) %NB_TRAIN 高斯朴素贝叶斯训练 % X: n×d 数值特征矩阵y: n×1 标签向量 % varFloor: 标准差下界默认 1e-6防止常量特征把似然打成 Inf if nargin 3, varFloor 1e-6; end classes unique(y); classes classes(:); K numel(classes); d size(X, 2); model.classes classes; model.mu zeros(K, d); model.sigma zeros(K, d); model.prior zeros(K, 1); model.nTrain numel(y); for c 1:K idx (y classes(c)); % 逻辑索引行数必须与 y 一致 Xc X(idx, :); model.mu(c, :) mean(Xc, 1); % 逐列求均值 model.sigma(c, :) max(std(Xc, 0, 1), varFloor);% 标准差下界 model.prior(c) sum(idx) / numel(y); end model.prior model.prior / sum(model.prior); % 归一化防止数值漂移 model.logPrior log(model.prior); endstd(Xc, 0, 1)三个参数分别是权重标志位 0 表示按 n-1 归一化样本标准差维度 1 表示沿着行方向压缩、对每列求标准差。这两个参数写反是新手最常见的错误之一写成std(Xc, 1, 2)会得到 n×1 的每样本标准差维度直接对不上mu后面做减法时要么报错要么静默广播出一个 n×n 矩阵内存瞬间吃满。max(std(...), varFloor)这一层下界是我强烈建议保留的。某个特征在某一类里全取同一个值比如某个传感器在该类样本里恒为 0样本标准差就是 0高斯密度里的1/(sigma*sqrt(2π))直接变成 Inf(x-mu)^2/sigma^2变成 0/0 得 NaN。有了下界这个特征在该类下的贡献退化成一个极窄的尖峰样本值等于 mu 时给一个很大的正对数似然不等时给一个很大的负数。语义上是合理的至少不会污染整个后验矩阵。model.prior / sum(model.prior)这行看着多余——占比之和本来就该是 1。但浮点数累加会有 1e-16 级别的漂移某些极端类别不平衡的数据上log 之后这点漂移会被放大成可见偏差。归一化一行代码省掉一类玄学问题。3.2 预测阶段对数域后验别直接连乘预测阶段要算的是每个样本在每个类下的后验得分取最大者对应的类别。数学上是先验乘以各特征条件概率的连乘d 个特征、每个概率在 0 到 1 之间d 稍大一点比如 2000 维词频连乘结果直接下溢成 0所有类的得分都是 0argmax 退化成一个固定答案。取对数把连乘变连加是必须做的一步。function [yPred, logPost] nb_predict(model, X) %NB_PREDICT 对数域后验计算输出预测标签和 n×K 的得分矩阵 n size(X, 1); K numel(model.classes); logPost zeros(n, K); log2pi log(2 * pi); % 常量提出来循环内不重复算 for c 1:K mu model.mu(c, :); sg model.sigma(c, :); % 高斯对数似然-0.5*sum(log(2πσ²)) - 0.5*sum((x-μ)²/σ²) term1 -0.5 * sum(log(2 * pi * sg.^2)); % 1×1 标量 term2 -0.5 * sum(((X - mu).^2) ./ (sg.^2), 2); % n×1 向量 logPost(:, c) term1 term2 model.logPrior(c); end [~, ix] max(logPost, [], 2); % 沿第 2 维取最大得到 n×1 索引 yPred model.classes(ix); end这里用了 Matlab 的隐式扩展R2016b 之后支持X - mu中 X 是 n×d、mu 是 1×d结果自动广播成 n×d不需要bsxfun也不需要 repmat。老版本上跑会报维度不匹配换成bsxfun(minus, X, mu)即可语义完全一样。sum(..., 2)的第二参数 2 表示沿列方向压缩、对每行求和得到 n×1 的每样本对数似然这个维度必须和logPost(:, c)对上写成sum(..., 1)会得到一个 1×d 的行向量赋值时被静默广播结果全错但不报错——这类 bug 靠肉眼看代码极难发现只能靠小数据手算对拍。term1是高斯归一化常数项。很多人为了省事直接省略理由是它对所有类别是同一个数量级、不影响 argmax。严格说不成立sigma 在不同类之间不一样sum(log(sg.^2))的差异在做不平衡分类时会实打实影响判别边界。代价只是每个类算一次标量没有任何性能压力没有理由省。3.3 拉普拉斯平滑离散特征的零概率补丁连续特征走高斯文本、词袋、离散编码这类计数特征就得走多项式版本。多项式版本的核心风险是零概率某个词在训练集的某一类里一次都没出现该类的条件概率就是 0连乘之后整个后验变成 0无论这个词在测试样本里出现多少次都救不回来。平滑就是给每个计数加一个正数把零概率抬起来。function model nb_train_discrete(counts, y, alpha) %NB_TRAIN_DISCRETE 多项式朴素贝叶斯训练 % counts: n×V 非负计数矩阵词频、one-hot 累加等 % alpha : 平滑系数1 为经典拉普拉斯0alpha1 为 Lidstone 平滑 if nargin 3, alpha 1; end classes unique(y); classes classes(:); K numel(classes); V size(counts, 2); % 词表大小平滑的分母要用 model.classes classes; model.alpha alpha; model.logTheta zeros(K, V); model.logPrior zeros(K, 1); for c 1:K idx (y classes(c)); fc sum(counts(idx, :), 1); % 该类下每个词的总计数 % 分子加 alpha分母加 alpha*V保证概率和为 1 model.logTheta(c, :) log(fc alpha) - log(sum(fc) alpha * V); model.logPrior(c) log(sum(idx) / numel(y)); end end平滑的分母是sum(fc) alpha*V而不是sum(fc) alpha这一点经常被写错。加 alpha 是在 V 个位置上各加了一次分母必须对应加alpha*V否则整行概率之和不再是 1虽然 argmax 未必立刻出错但当不同类别的词表总计数差异较大时得分尺度不一致会系统性地偏向某一类。预测阶段可以利用矩阵乘法一次性算完所有样本function [yPred, score] nb_predict_discrete(model, counts) %NB_PREDICT_DISCRETE 用矩阵乘法替代逐样本循环 % counts 是 n×VlogTheta 是 K×V转置后相乘得到 n×K score counts * model.logTheta model.logPrior; [~, ix] max(score, [], 2); yPred model.classes(ix); endcounts * model.logTheta这一句等价于对每个样本、每个类做一次「词频向量与对数概率表的内积」也就是把测试样本里每个词出现次数乘以该词在该类下的对数概率再求和正好是多项式分布的对数似然。用矩阵乘法而不是双重循环在 V 上万、n 上千的场景下差距是几十倍。Matlab 的 BLAS 底层对这个形状n×V 乘 V×K优化得很好写成循环反而是在跟编译器抢活干。3.4 数值细节三个容易被忽略的常量第一个是方差下界。前面提过varFloor默认 1e-6 是针对量纲在 0 到 100 之间的特征。如果你的特征是像素值0 到 255或者金额上万这个下界相对量纲就太小了等于没加。稳妥的做法是按特征尺度调整或者干脆在训练前统一做标准化把每个特征拉到均值 0、标准差 1这样下界用固定值就有意义。我一般会在nb_train之前插一步X normalize(X, 1)代价是模型可解释性略降换来的是参数不用逐特征调。第二个是先验为 0。如果某个类别在训练集里一个样本都没有log(0)得到-Inf该类的后验永远是-Inf预测结果里永远不会出现这个类。这在分层切分没做好的小数据集上很常见——某个类只有 3 个样本切分时全被划进测试集。解决办法不是加平滑类别层面加平滑意义不大而是切分阶段强制每类至少留 1 个进训练集。第三个是得分矩阵的数值范围。对数后验的绝对值可能到 -1e5 量级直接 exp 会全部下溢成 0。如果下游要做概率输出比如画 ROC、算 log loss不能对logPost直接 exp要先做行方向的 max 减法再 exp 再归一化也就是 softmax 的标准写法function P logpost_to_prob(logPost) %LOGPOST_TO_PROB 稳定版 softmax避免 exp 直接下溢 m max(logPost, [], 2); % 每行最大值n×1 E exp(logPost - m); % 减掉最大值后最大项为 exp(0)1 P E ./ sum(E, 2); % 行归一化 end减掉行最大值不改变 softmax 的结果分子分母同乘一个常数但把指数项的输入挪到了 0 附近数值上安全。这个技巧在任何需要把对数得分转概率的地方都能用不只是朴素贝叶斯。4. 把代码跑起来从数据准备到精度评估的命令行实操理论讲完落到实操。这一章按「装环境 → 备数据 → 跑训练 → 看结果 → 做验证」的顺序走一遍每一步都给出可以直接粘进命令窗口的代码以及跑不通时该看哪里。4.1 环境与数据准备Matlab 版本建议 R2018b 及以上主要原因是隐式扩展和cvpartition的分层接口在老版本上行为有差异。不需要额外工具箱也能跑通全流程混淆矩阵是自己手写的nb_evaluate里的双重循环cvpartition属于 Statistics and Machine Learning Toolbox如果没有这个工具箱可以换成一个手写的分层切分循环逻辑和split_holdout一样只是多套一层 for。数据方面数值特征版本要求 csv 最后一列是标签前面各列是数值特征第一行是表头。离散版本要求一个 .mat 文件里面有两个变量X是 n×V 的计数矩阵可以稀疏y是 n×1 标签。自己造测试数据也很方便% 造一份三类、四特征、每类 120 个样本的合成数据用来验证流程是否跑通 rng(7); nPer 120; d 4; K 3; mu [0 0 0 0; 3 3 0 0; 0 3 3 0]; % 三个类的中心故意让前两类在特征1上重叠 X zeros(nPer*K, d); y zeros(nPer*K, 1); for c 1:K rows (c-1)*nPer (1:nPer); X(rows, :) mu(c, :) randn(nPer, d) * 1.0; % 单位方差噪声 y(rows) c; end这份合成数据的特点是第二类和第三类在特征 3 上有重叠、第一类和第二类在特征 1 上有重叠精度不会一上来就是 100%能真实反映模型在边界样本上的表现。如果拿完全可分的数据去测任何模型都能到 99%测了等于没测。4.2 训练、预测与评估的完整命令序列按顺序粘下面几段每段都能独立看到中间结果出问题能立刻定位到是哪一步。% 步骤 1把路径加进来确认函数能被找到 addpath(genpath(fullfile(pwd, src))); addpath(genpath(fullfile(pwd, utils))); which nb_train % 应输出 src 下的完整路径输出 not found 说明路径没加对 % 步骤 2读数据先看形状再看内容 [X, y, classes] load_dataset(fullfile(data, flower_measure.csv)); fprintf(X: %d×%d, y: %d×1, 类别数 %d\n, size(X,1), size(X,2), numel(y), numel(classes)); % 步骤 3切分固定种子保证可复现 [Xtr, ytr, Xte, yte] split_holdout(X, y, 0.3, 42); % 步骤 4训练 预测 评估 model nb_train(Xtr, ytr); [yHat, score] nb_predict(model, Xte); stats nb_evaluate(yte, yHat, model.classes); fprintf(精度 %.4f | 宏平均 F1 %.4f\n, stats.acc, mean(stats.f1));which nb_train这一句看着啰嗦但它是排查「Undefined function」最快的手段。Matlab 的搜索路径有优先级同名函数可能被别的目录里一个旧版本抢先命中你以为改的是 src 里的文件实际跑的是两年前留下的副本。路径问题在 Matlab 里是高频事故加一句 which 成本几乎为零。评估函数的核心是手工算混淆矩阵不依赖工具箱也方便你后面改成加权版本function stats nb_evaluate(yTrue, yPred, classes) %NB_EVALUATE 精度、混淆矩阵、每类精确率/召回率/F1 % 混淆矩阵行真实类列预测类行列顺序由 classes 决定 C numel(classes); conf zeros(C, C); for i 1:numel(yTrue) r find(classes yTrue(i), 1); c find(classes yPred(i), 1); if isempty(r) || isempty(c) error(nb_evaluate:label, 出现 classes 里没有的标签%s, ... string(yTrue(i))); end conf(r, c) conf(r, c) 1; end stats.conf conf; stats.acc sum(diag(conf)) / sum(conf(:)); tp diag(conf); fp sum(conf, 1) - tp; % 预测为该类但真实不是 fn sum(conf, 2) - tp; % 真实为该类但被分错 stats.precision tp ./ max(tp fp, eps); stats.recall tp ./ max(tp fn, eps); stats.f1 2 * stats.precision .* stats.recall ./ ... max(stats.precision stats.recall, eps); endmax(x, eps)是为了防 0/0。当某一类从来没被预测到时tp fp 0除法得 NaN后续求宏平均时整个指标被污染成 NaN你会以为是模型崩了实际只是这个类没被预测到。给它一个 eps 下限精确率变成 0语义上是「这一类一个都没预测对」比 NaN 好排查得多。4.3 K 折交叉验证与平滑系数扫描Hold-out 切分的精度受切分随机性影响很大小数据集上换个种子能差 5 个点。做模型对比时更稳的方式是分层 K 折每个样本都被测试一次取均值和标准差。k 5; cvp cvpartition(y, KFold, k); % 分层 K 折各类比例保持一致 acc zeros(k, 1); for f 1:k tr training(cvp, f); % 逻辑索引 te test(cvp, f); m nb_train(X(tr, :), y(tr)); yh nb_predict(m, X(te, :)); acc(f) mean(yh y(te)); % 标签向量逐元素比较 end fprintf(CV 精度 %.4f ± %.4f\n, mean(acc), std(acc));cvpartition的第一个参数传标签而不是样本数才会做分层传numel(y)得到的是普通随机 K 折小类别可能在某一折里全军覆没。这个差别在类别均衡的数据上几乎看不出来一旦某类占比低于 5%不分层的结果会剧烈抖动。平滑系数 alpha 的扫描建议在离散版本上做因为高斯版本的对应参数是方差下界语义没那么直观。扫描的写法很简单但要注意每次都重新训练、重新评估不能复用同一个模型alphas [0.01 0.1 0.5 1 2 5]; accA zeros(size(alphas)); for i 1:numel(alphas) m nb_train_discrete(Xtr, ytr, alphas(i)); yh nb_predict_discrete(m, Xte); accA(i) mean(yh yte); end table(alphas, accA, VariableNames, {alpha, accuracy})经验上 alpha 在 0.1 到 1 之间结果最稳alpha 越大越接近「忽略词频差异、只看词是否出现」的退化的模型alpha 太小则平滑不起作用、零概率问题复发。扫描曲线如果出现「某个 alpha 突然掉一大截」多半是那个值下某个类的分母计算溢出了值得单独看一眼。4.4 结果怎么读混淆矩阵比单个精度数字信息量大得多精度 0.87 这个数字本身说明不了什么。真正要看的是混淆矩阵错分集中在哪两类之间、是单向错分还是双向、各类的召回率是否均衡。三分类问题里如果两类互相错分严重通常意味着这两个类在特征空间里本来就重叠加特征比调模型有用如果某一类的召回率显著低于其他类多半是该类样本太少、先验过低可以考虑给先验加个指数或者做过采样。% 把混淆矩阵画成热图行列标签用类别名 figure(Color, w); imagesc(stats.conf); colormap(flipud(gray)); colorbar; set(gca, XTick, 1:numel(model.classes), XTickLabel, string(model.classes), ... YTick, 1:numel(model.classes), YTickLabel, string(model.classes)); xlabel(预测类别); ylabel(真实类别); title(sprintf(混淆矩阵精度 %.2f%%, stats.acc * 100)); for r 1:size(stats.conf, 1) for c 1:size(stats.conf, 2) text(c, r, num2str(stats.conf(r, c)), HorizontalAlignment, center, ... Color, r, FontWeight, bold); end end saveas(gcf, fullfile(results, confusion.png));除了混淆矩阵score矩阵也值得看一眼。它是 n×K 的对数后验行内做 softmax 后就是每个样本的类别概率。如果某个样本的最大得分和第二大得分只差 0.01说明模型在这个样本上几乎是在抛硬币这类样本挑出来人工复核往往能发现标注错误或者特征缺失——这是朴素贝叶斯在小数据集上做数据清洗的一个实用副产品。5. 常见问题排查那些让精度掉到 33% 的坑这一章列的是我在跑这套代码、以及帮别人看类似实现时反复遇到的五类问题按「现象 → 原因 → 解决」写。每一条都是真实会发生的且前三条都会表现为「精度异常低」这一种症状排查时按顺序过一遍能省很多时间。5.1 精度始终等于最大类别占比现象三分类数据每次跑出来的精度都稳定在 0.33 到 0.40 之间混淆矩阵里所有样本都被预测成同一个类预测标签的 unique 只有一个值。换随机种子、换切分比例结果不变。原因后验得分矩阵的某一列恒为最大值其他列被完全压制。最常见的是某一类的 sigma 里有 0没加下界log(0)得-Inf(x-mu).^2/0得Inf或NaN该类的得分变成NaNmax遇到 NaN 的行为是把 NaN 当成最大值返回索引于是所有样本都指向第一个出现 NaN 的类。次常见的原因是特征没做标准化某一列量纲是其他列的一万倍(x-mu).^2/sigma^2里这一列主导了整个求和模型实际上只用了这一个特征而这个特征恰好没什么区分度。解决先检查model.sigma里有没有 0 或极小值any(model.sigma(:) 1e-8)一行就能确认再看logPost里有没有 NaN 或 Infany(~isfinite(logPost(:)))。有就加方差下界。如果没有数值问题就看各特征的量级std(X)输出一眼就能看出哪一列大得离谱加一步X normalize(X, 1)标准化后重跑。注意normalize默认按列做 z-score但必须用训练集的均值和标准差去处理测试集。如果训练和测试各自 normalize两边尺度不一致精度会比不标准化还差。正确做法是把训练集的 mean/std 存进 model预测时用同一套参数。5.2 训练能跑通但预测报维度不匹配现象nb_train顺利返回nb_predict报错提示类似「矩阵维度必须一致」或者「submatrix assignment 尺寸不符」错误定位在logPost(:, c) term1 term2 model.logPrior(c)这一行。原因term2的维度不对。sum(((X - mu).^2) ./ (sg.^2), 2)里如果 X 是行向量只有一个测试样本且传成了 1×dX - mu得到 1×dsum(..., 2)得到 1×1 标量赋给 n×1 的列时在 n1 的情况下直接报错反过来如果 X 传成了 d×n 的转置形态sum(..., 2)得到 d×1赋值时报尺寸不符。另一种情况是预测时只传了一个样本的 cell 而不是矩阵。解决在nb_predict开头强制校验形状assert(size(X,2) size(model.mu,2), ...)加一句报错信息直接告诉你「期望 d 个特征收到 x 个」比 Matlab 原生报错好懂得多。同时统一约定 X 永远是 n×d单个样本也要包成 1×d。5.3 交叉验证精度远高于 Hold-out两者差十个点以上现象同一份数据Hold-out 切分跑出 0.725 折交叉验证跑出 0.85反复确认代码没改怀疑是不是cvpartition用错了。原因Hold-out 用固定的 42 号种子切了 30%如果这个切分恰好把一批难分的边界样本全划进了测试集精度就会显著偏低反过来也可能偏高。30% 的测试集在三分类、每类一百多的样本量下只有三四十个样本单个样本的翻转就是 2 到 3 个百分点波动本来就大。另外常见的隐性原因是 Hold-out 切分没做分层某一类在测试集里只剩两三个样本这几个样本分对了精度虚高、分错了精度暴跌。解决小数据集上以交叉验证结果为准Hold-out 的结果只用来快速验证代码跑通。要做模型对比就统一用同一个cvpartition对象保证每一折的样本划分完全一样这样比的是模型差异而不是切分噪声。同时确认切分是否分层cvpartition(y, KFold, 5)传标签才是分层。5.4 离散版本精度正常但训练极慢内存吃满现象词频矩阵 V 到五万n 只有两千nb_train_discrete跑了几分钟还没出结果任务管理器里 Matlab 内存一路涨。代码里明明只有一个 K 次的 for 循环。原因counts传进来的时候是稀疏矩阵但sum(counts(idx, :), 1)在某些写法下会把稀疏矩阵转成稠密。更常见的是fc alpha这一步fc是稀疏行向量加上一个标量后 Matlab 依然保持稀疏但紧接着log()对稀疏矩阵求对数会尝试保持稀疏结构而log(0)的位置变成-Inf稀疏结构被破坏结果被静默转成稠密。K 行 V 列的稠密表乘上几十倍的内存开销五万列直接上百 MB。解决训练阶段接受稠密化但要显式控制。fc full(sum(counts(idx, :), 1))主动转成稠密行向量V 五万时单行只有 400KBK 个类也才几 MB完全可接受。真正危险的是counts本身被稠密化n×V 两千万个 double 就是 160MB所以在load_dataset里加一句if issparse(X), keep as sparse的判断并且在nb_predict_discrete里保持counts * model.logTheta的稀疏×稠密乘法形式Matlab 对这个组合有专门优化不需要先转稠密。5.5 换了一份新数据所有函数都报 Undefined现象代码在自己的目录里跑得好好的把数据换到另一个路径、或者从别人那里拷了一份项目过来一运行就是「Undefined function nb_train for input arguments of type double」。原因Matlab 的搜索路径是会话级的addpath加进去的路径关掉重开就没了。放到别的目录下运行时当前工作目录不对fullfile(pwd, src)指向了一个不存在的路径genpath对不存在的目录不报错、直接返回空addpath加了空字符串也不报错于是函数压根没被找到。解决主脚本开头加路径存在性校验assert(isfolder(fullfile(pwd,src)), 请在项目根目录下运行 main_demo.m)报错信息直说该怎么做。更彻底的办法是把 src 和 utils 的相对路径写死成相对于脚本自身位置root fileparts(mfilename(fullpath)); addpath(genpath(fullfile(root, src)));这样不管从哪个目录调用run main_demo.m都能正确找到函数。6. 二次开发换似然、换特征、封成类的三条改造路线这套代码结构上留了三个明确的扩展口按改动成本从低到高排换似然假设、换特征处理、封装成类。换似然是最省事的。想从高斯换成伯努利特征只有 0/1比如 one-hot 编码的离散属性只要新写一对nb_train_bernoulli/nb_predict_bernoulli训练阶段统计每类下每个特征取 1 的频率 p预测阶段用sum(x.*log(p) (1-x).*log(1-p))主脚本里换个函数名就行。伯努利和高斯的差别在文本场景下特别明显——伯努利只看词是否出现、不看出现次数短文本上经常比多项式版本更稳。换特征处理是在nb_train之前插一步。最常见的需求是特征选择七百维特征里有一半是噪声直接扔进去会稀释真实信号。朴素贝叶斯天然适合做这一步因为它对每个特征是独立建模的可以单独算出每个特征在类间的区分度比如各类均值之差的绝对值除以类内标准差之和排序后取前 k 个。实现上只要在训练前多算一个得分向量、存进 model预测前按同样的索引裁剪 X 即可。function keep select_features(model, k) %SELECT_FEATURES 按类间可分性挑前 k 个特征 % score(j) 各类均值极差 / 各类标准差均值值越大区分度越强 muRange max(model.mu, [], 1) - min(model.mu, [], 1); % 1×d sgMean mean(model.sigma, 1) 1e-9; % 1×d score muRange ./ sgMean; [~, order] sort(score, descend); keep sort(order(1:min(k, numel(order)))); end这段代码有一个前提model.mu和model.sigma是在全量特征上训练出来的选完特征后必须用裁剪后的 X 重新训练一次否则 model 里的参数维度和你喂进去的数据对不上。我第一次做这个改造时就漏了重新训练这一步预测阶段直接维度报错回头查了半小时才发现是在用 700 维的模型吃 200 维的数据。封装成类适合要把模型嵌进更大流程的场景。Matlab 的classdef写一个 handle 类把 train/predict/交叉验证都做成方法属性里存 mu/sigma/prior/classes好处是可以直接传给别的函数、可以存成 mat 文件复用。代价是 Matlab 的类在性能上比函数略慢方法调用有开销在大数据上循环调用时能感觉到。我的做法是核心计算仍然走函数类只做一层薄封装。验证改造是否成功有一套固定动作改完之后必须跑一次和基线完全相同的切分同一个种子、同一个 cvpartition 对象对比精度、混淆矩阵、以及那几类的召回率。只看总精度容易被掩盖——特征选择后总精度涨了 2 个点但小类别的召回率掉了 15 个点这种改动在上线时是要出事的。从那以后我每次动算法核心都强制把改造前后的混淆矩阵并排打印出来对一遍再决定要不要保留这次改动。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑