news 2026/10/25 10:04:34

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Matlab朴素贝叶斯分类器代码实现与精度调优

简介:这份面向机器学习初学者与Matlab实践者的朴素贝叶斯分类器代码包,聚焦用简洁脚本复现基于贝叶斯定理与特征条件独立假设的分类流程,帮助读者从概率计算层面理解先验概率、条件概率与分类决策的实现方式,适合课程实验、算法入门和代码复现等场景。压缩包共3个文件,均为.m格式的Matlab源码,整体约961B,体量轻巧,包含训练、预测与测试三类模块,便于串联“建模—预测—验证”的完整学习链路,也方便按需拆解和改写。已有3441人学习,说明其在入门实践中具有一定参考价值。读者可据此梳理核心公式如何落地为代码,观察训练与预测函数的分工,并借助测试脚本检查分类结果,为后续扩展到文本分类、垃圾邮件识别等任务打基础。

1. 从一个跑出 33% 精度的分类器说起:朴素贝叶斯在 Matlab 里的真实定位

三分类、四个特征、两百来行 Matlab 朴素贝叶斯代码,第一次跑出来的精度是 33%——正好等于乱猜。原因不一定在算法本身:某类样本的某个特征方差被算成 0,高斯似然整列变成 NaN,argmax 永远挑第一个类。这份朴素贝叶斯分类器 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/classes,split 出四份切分,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); end

std(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,所有类的得分都是 0,argmax 退化成一个固定答案。取对数把连乘变连加,是必须做的一步。

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 为经典拉普拉斯,0<alpha<1 为 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×V,logTheta 是 K×V,转置后相乘得到 n×K score = counts * model.logTheta' + model.logPrior'; [~, ix] = max(score, [], 2); yPred = model.classes(ix); end

counts * 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); end

max(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,该类的得分变成NaN,max遇到 NaN 的行为是把 NaN 当成最大值返回索引,于是所有样本都指向第一个出现 NaN 的类。次常见的原因是特征没做标准化,某一列量纲是其他列的一万倍,(x-mu).^2/sigma^2里这一列主导了整个求和,模型实际上只用了这一个特征,而这个特征恰好没什么区分度。

解决:先检查model.sigma里有没有 0 或极小值,any(model.sigma(:) < 1e-8)一行就能确认;再看logPost里有没有 NaN 或 Inf,any(~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×d),X - mu得到 1×d,sum(..., 2)得到 1×1 标量,赋给 n×1 的列时在 n>1 的情况下直接报错;反过来如果 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.72,5 折交叉验证跑出 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 五万时单行只有 400KB,K 个类也才几 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 个点,这种改动在上线时是要出事的。从那以后我每次动算法核心,都强制把改造前后的混淆矩阵并排打印出来对一遍,再决定要不要保留这次改动。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 10:04:31

Qt俄罗斯方块源码解析:从编译到运行,掌握QTimer与paintEvent

简介&#xff1a;这是一份面向QT初学者与游戏开发爱好者的俄罗斯方块完整源码&#xff0c;基于QT框架与C实现&#xff0c;适合作为GUI编程、事件驱动与游戏逻辑设计的练手项目。压缩包为rar格式&#xff0c;共11个文件&#xff0c;约15.18MB&#xff0c;包含5个cpp源文件、1个头…

作者头像 李华
网站建设 2026/10/25 10:02:02

AI-For-Beginners 中文版实战:Jupyter环境、练习改造与排错

简介&#xff1a;微软AI-For-Beginners 中文版课程资料包面向零基础到入门阶段的AI学习者&#xff0c;对应一套12周、24课的系统课程&#xff0c;可用于自学、课堂教学或读书会共读。内容围绕符号人工智能与知识表示推理、神经网络与深度学习、计算机视觉和自然语言处理展开&am…

作者头像 李华
网站建设 2026/10/25 9:48:52

神经网络与模型预测控制在无人机与无人车中的应用

1. 项目背景与核心价值去年在参与某工业级四旋翼飞控系统升级时&#xff0c;我遇到了传统PID控制在复杂气流环境下的性能瓶颈。经过多轮测试发现&#xff0c;当无人机在建筑群间穿行时&#xff0c;突发的侧向风扰会导致明显的轨迹偏移。这促使我开始研究神经网络与模型预测控制…

作者头像 李华
网站建设 2026/10/25 9:47:30

STM32F030 RTC低功耗周期唤醒实战:5分钟定时采集与避坑指南

简介&#xff1a;这份资源面向嵌入式开发初学者与低功耗应用工程师&#xff0c;聚焦STM32F030C8T芯片在待机模式下借助RTC闹钟实现定时5分钟唤醒的完整工程实践&#xff0c;可用于电池供电设备、传感器节点等场景的功耗优化参考。压缩包共548个文件&#xff0c;约3.83MB&#x…

作者头像 李华
网站建设 2026/10/25 9:43:09

构建智能体评估与异常检测可视化平台:从数据链路到工程实践

1. 训练再久不如看得清&#xff1a;为什么要单独做一套智能体评估与可视化平台先聊个真实场景。我接手过一个多智能体路径规划项目&#xff0c;训练阶段跑了四十多天&#xff0c;reward曲线一路"很好看"&#xff0c;从-30稳步爬到接近0&#xff0c;理论上是收敛了。可…

作者头像 李华
网站建设 2026/10/25 9:41:43

Windows 上 Nexus 3.50 私服搭建全流程:从 JDK 选型到仓库验证

简介&#xff1a;Nexus Repository Manager 3.50.0-01 是面向 Windows 64 位系统的仓库管理软件&#xff0c;主要服务于需要搭建 Maven 私服、统一管理构建产物的软件开发团队与运维人员。它支持 Maven、npm、Docker 等多种构建工具&#xff0c;可集中存储与发布软件包&#xf…

作者头像 李华