简介:面向机器学习初学者与需要快速复现实验的开发者,这份贝叶斯分类Matlab源码包给出从数据预处理、训练测试划分到朴素贝叶斯建模、预测及准确率等指标评估的完整流程,并配有图形操作界面,可在不写代码的情况下导入数据、设置参数、训练模型和查看分类结果。压缩包共28个文件,以17个m脚本为核心,辅以8个txt数据与说明文件、2个prj工程文件和1个fig界面文件,整体约37KB;内容按带变量与图形界面两类组织,示例数据覆盖不同列数与分隔符形式,结构紧凑,便于按模块阅读和二次修改。已有3686人学习下载,适合课程实验、算法入门和项目原型验证;通过研读脚本与界面实现,读者可理解先验概率、条件概率的计算方式,掌握Matlab中贝叶斯分类器的构建、评估与GUI集成思路,并据此完成自己的数据分类实验。
1. 带操作界面的贝叶斯分类:把命令行里的数字变成一张能看的图
某同学交课程设计,贝叶斯分类的代码早跑通了,命令行打印出准确率 0.93,可当被问到“决策边界长什么样、换一组先验会怎么变”,他只能对着屏幕比划。问题不在算法,在于结果没有出口。这份“贝叶斯分类 源码 Matlab 带操作界面”的资源,解决的正是这个出口问题:把数据加载、参数设置、训练、预测、决策面绘制串到一个窗口里,点几下鼠标就能看到分类区域怎么随先验和类内方差变化。
它适合三类人:刚学完概率论想验证公式的学生,要给非算法同事演示分类逻辑的工程人员,以及想拿一份能改的 GUI 骨架当模板的开发者。算法部分不依赖统计工具箱里的高层封装,核心的均值、方差、判别函数都是手写,拆起来没有黑匣子,改起来也不怕被版本兼容卡住。
2. 项目骨架:一个 .fig 加一组 .m,界面和算法是分开的
拿到一份 MATLAB 带界面的项目,最怕的是所有逻辑都堆在回调函数里,几百行挤在一起,改一行崩一片。这份源码在结构上做了分层,界面层只负责取值和展示,算法层是纯函数,输入矩阵、输出标签,脱离 GUI 也能单独跑。这个分法决定了后面二次开发好不好受。
2.1 目录结构与文件职责
我把源码拉下来之后先做了一件事:把每个文件打开看头部的注释,再对照调用关系画了一张表。文件数量不多,但职责边界划得比较清楚,不会出现“这个函数既画图又算概率”的情况。
| 文件 | 职责 | 改动频率 |
|---|---|---|
BayesGUI.fig | 界面布局,控件坐标与属性 | 低 |
BayesGUI.m | 回调集合,负责取值、校验、调用算法、回填结果 | 中 |
trainNB.m | 训练,输出 mu / sigma / prior 结构体 | 低 |
predictNB.m | 判别,返回预测标签和后验概率矩阵 | 低 |
plotDecisionBoundary.m | 网格化预测并绘制分类区域 | 中 |
parzenPdf.m | 核密度估计,非高斯分布时的替代方案 | 中 |
loadDataset.m | 数据加载与格式规整,统一成 N×D 矩阵 | 高 |
confusionMat.m | 混淆矩阵与召回率、精确率计算 | 低 |
BayesGUI.fig和BayesGUI.m是一对,MATLAB 会用同名规则把界面和回调绑起来,fig里每个控件的Callback属性写的就是.m里对应的函数名。算法文件全部是独立函数,不读handles、不碰gca,这一点很关键——意味着你可以在脚本里直接model = trainNB(X, y)做批量实验,不需要开窗口。
loadDataset.m是我改动最多的一个文件。原始版本里它写死了列号和分隔符,换了份数据就得改代码。我一般会把这里改成参数化的形式,把“哪几列是特征、哪一列是标签、有没有表头”做成三个输入参数,后面换数据集只动调用处,不动函数体。
2.2 为什么是 MATLAB + 手写算法这套组合
选 MATLAB 做这类演示工具,优势不在性能,在于矩阵操作写起来短、画图快、uicontrol那套控件足够应付一个课程级界面。换成 Python 加 PyQt,光是环境依赖和打包就能劝退一半人;换成网页端,又得处理前后端通信,对只想验证公式的人来说太重。
真正值得说的是“手写”这个选择。MATLAB 自带fitcnb、fitgmdist这些函数,一行就能训练一个朴素贝叶斯,但用它们做教学或二次开发有两个麻烦:一是内部细节看不到,你想改个方差平滑系数、想换成最小风险判决,得去翻文档找对应的 Name-Value 参数;二是工具箱版本差异,某些老版本机器上根本没装统计工具箱,一跑就报未定义函数。
手写的代价是代码量,收益是完全可控。比如类内方差为 0 的处理,官方函数内部怎么加平滑你不知道,而手写版本里就是一行+ 1e-6,改成+ 1e-3还是按维度自适应,一行的事。课程设计场景下,这种“看得见”比“跑得快”重要得多。
界面框架用的是传统 GUIDE 而不是 App Designer。原因也实际:GUIDE 生成的.fig是二进制布局文件,但回调结构简单,handles加guidata的老套路在网上资料多,学生照着改不容易卡住;App Designer 的app.对象体系更现代,可一旦涉及在多个回调间共享大数据,初学者容易在app.Data和app.UIFigure.UserData之间绕晕。这份源码选择的是一条更稳的路。
2.3 一次分类从点按钮到画出区域的完整链路
搞清数据怎么流动,比读十遍代码都有用。整个交互大致是这么一条链:
用户点“加载数据” →btnLoad_Callback调loadDataset→ 得到X(N×D)和y(N×1)→ 存进handles并用guidata写回 → 界面上的散点图预览画出来。
用户调好先验和方差平滑系数 → 点“训练” →btnTrain_Callback从handles取X、y→ 调trainNB得到model→ 存回handles。
用户点“分类”或“绘制决策面” →btnPredict_Callback取model和X→ 调predictNB得到yhat和post→ 调confusionMat算指标 → 刷新右侧的混淆矩阵表和准确率文本 → 调plotDecisionBoundary在坐标轴上画出彩色分类区域。
这里有个容易忽略的点:model和X、y都挂在同一个handles结构体上,靠guidata(hObject, handles)做持久化。MATLAB 的 GUIDE 回调里,handles是值传递进来的副本,你在函数里改了字段,如果不调guidata写回,函数一返回改动就丢了。这个机制是后面很多“数据莫名其妙变空”问题的根源,第 5 章会细说。
3. 算法内核:先验、类条件密度、后验判决三段式
界面再花哨,分类对不对还是看这三步。贝叶斯分类的骨架其实很短:训练阶段统计每个类的先验和类条件分布参数,判别阶段对每个样本算各类的后验、取最大。真正让代码变长的是数值稳定性处理、特征独立假设的取舍,以及决策面绘制时的向量化。
3.1 训练阶段:从样本矩阵到 mu / Sigma / prior
训练函数要干的事就三件:按类别切分样本、算每类的均值和方差、算每类的先验频率。输入是特征矩阵和标签向量,输出是一个结构体,把后续判别需要的所有参数打包带走。
function model = trainNB(X, y, varargin) % TRAINNB 训练朴素贝叶斯(高斯类条件密度) % X : N×D 特征矩阵,每行一个样本 % y : N×1 类别标签,取值可为任意离散值 % 可选参数:'Smooth' 方差平滑系数,默认 1e-6 p = inputParser; addParameter(p, 'Smooth', 1e-6, @(v) isnumeric(v) && v > 0); parse(p, varargin{:}); smooth = p.Results.Smooth; classes = unique(y); % 类别列表,顺序即后续索引 K = numel(classes); D = size(X, 2); mu = zeros(K, D); % 类均值,K×D sigma = zeros(K, D); % 类内方差,K×D(对角协方差) prior = zeros(K, 1); % 先验概率,K×1 for k = 1:K idx = (y == classes(k)); % 逻辑索引,取第 k 类样本 Xk = X(idx, :); prior(k) = sum(idx) / numel(y); % 频率估计先验 mu(k, :) = mean(Xk, 1); % 按列求均值 sigma(k,:) = var(Xk, 0, 1) + smooth; % 按列求方差并平滑 end model.mu = mu; model.sigma = sigma; model.prior = prior; model.classes = classes; model.D = D; end逐段看。unique(y)同时完成了两件事:拿到类别列表,以及给后续的矩阵行号建立映射关系——第 k 类就对应mu的第 k 行、prior的第 k 个元素。这个顺序在整个项目里必须保持一致,预测阶段靠model.classes反查,一旦有人在中途对标签做了重新编码而没同步,结果就会整体错位。
var(Xk, 0, 1)里的0是权重参数,表示按 N-1 归一化,1表示沿第一维(列方向)计算。这两个参数写错是新手的高频翻车点:var(Xk)默认沿列算,对二维矩阵恰好也对,但当某类只有一个样本时,返回的是 0 而不是 NaN,配合平滑系数勉强能跑,但方差已经失真了。
Smooth参数的作用是防止某个特征在某类内方差为 0 时,判别函数里出现除以零。默认1e-6对量纲在个位数的特征是够的;如果你的特征取值在万级别,这个平滑量相对方差就太小,起不到稳定作用,我一般会按特征尺度的平方来给,比如1e-3 * mean(var(X))。
还有个隐含假设值得点出来:这里用的是对角协方差,也就是朴素贝叶斯的“特征条件独立”假设。真实数据里特征往往相关,这时可以换成完整协方差矩阵加正则化,判别函数里的求和会变成二次型,代码结构不变,只是sigma从 K×D 变成 K×D×D。
3.2 判别阶段:把连乘改写成对数求和
按定义,后验正比于先验乘类条件密度。直接用密度连乘有两个问题:特征维度一高,几十个小概率相乘立刻下溢成 0;各类概率量级差异大时,浮点精度不够用。标准做法是全程取对数,把连乘变成求和,最后再用减最大值的技巧还原成概率。
function [yhat, post, score] = predictNB(model, X) % PREDICTNB 对 X 中每个样本做贝叶斯判别 % 返回 yhat: N×1 预测标签;post: N×K 后验概率;score: N×K 对数得分 N = size(X, 1); K = numel(model.classes); score = zeros(N, K); for k = 1:K mu = model.mu(k, :); s2 = model.sigma(k, :); % 高斯对数密度:-0.5*sum(log(2*pi*s2)) - 0.5*sum((x-mu)^2/s2) const = -0.5 * sum(log(2 * pi * s2)); quad = -0.5 * sum(((X - mu).^2) ./ s2, 2); % 按行求和 loglik = const + quad; % N×1 score(:, k) = loglik + log(model.prior(k) + eps); end [~, idx] = max(score, [], 2); % 每行取最大得分的列号 yhat = model.classes(idx); % 映射回原始标签 % softmax 还原后验,先减行最大值防指数溢出 s = score - max(score, [], 2); post = exp(s); post = post ./ sum(post, 2); endconst这一项只和方差有关,和样本无关,可以提到循环外预先算好,这里为了可读性留在循环内。quad里的(X - mu).^2 ./ s2是 N×D 矩阵,sum(..., 2)沿行求和,得到 N×1,正好对应每个样本的对数似然。
log(model.prior(k) + eps)加eps是为了兜底先验为 0 的情况。理论上某类在训练集里一个样本都没有,先验就是 0,取对数变成负无穷,这类就永远判不出来。加eps相当于给个极小先验,实际项目里更常见的做法是加一个拉普拉斯平滑,把(count + 1) / (N + K)当作先验,效果更可控。
返回的post才是界面上真正有展示价值的东西。很多人只关心预测标签,但后验概率能告诉你“这个样本判得有多勉强”,值接近 0.5 的样本往往是边界点,值得单独挑出来看。在二分类场景下,post(:,1) > 0.5和yhat完全等价,但展示成概率条之后,非专业用户一眼就能理解模型的置信度。
3.3 决策面绘制:网格化加向量化预测
分类区域那张图是整个界面的门面。原理不复杂:在特征空间铺一张网格,对每个格点当成一个样本做预测,把预测类别当作高度填色。坑在于如果对每个格点单独调一次预测函数,200×200 的网格就是四万次函数调用,界面直接卡死。
function plotDecisionBoundary(ax, model, X, y, gridN) % PLOTDECISIONBOUNDARY 在坐标轴 ax 上绘制分类区域与样本点 % gridN: 每个维度的网格密度,默认 150 if nargin < 5, gridN = 150; end x1 = linspace(min(X(:,1)) - 1, max(X(:,1)) + 1, gridN); x2 = linspace(min(X(:,2)) - 1, max(X(:,2)) + 1, gridN); [G1, G2] = meshgrid(x1, x2); gridX = [G1(:), G2(:)]; % 拉平成 (gridN^2)×2 yhat = predictNB(model, gridX); % 一次调用预测全部格点 Z = reshape(yhat, size(G1)); % 还原成网格形状 cla(ax); contourf(ax, G1, G2, Z, 'LineStyle', 'none'); colormap(ax, lines(numel(model.classes))); hold(ax, 'on'); for k = 1:numel(model.classes) sel = (y == model.classes(k)); scatter(ax, X(sel,1), X(sel,2), 40, 'filled', ... 'MarkerEdgeColor', 'k'); end hold(ax, 'off'); xlabel(ax, '特征 1'); ylabel(ax, '特征 2'); endmeshgrid加(:)拉平是 MATLAB 里处理网格预测的固定套路,把二维问题变成一次 N×D 的矩阵运算,内部循环交给底层,速度差几十倍。reshape(yhat, size(G1))把结果还原成网格形状,contourf才能正确着色。
cla(ax)这一句不能省。每次重绘决策面之前必须清空坐标轴,否则上次的填色层会叠在下面,颜色混成一片,看起来像是分类结果错了,其实是图层没清干净。
scatter用循环而不是gscatter,是为了能明确指定画在哪个坐标轴上。gscatter的轴参数在最后一位,且老版本不支持,循环写法虽然啰嗦几行,兼容性最好。样本点的填充色默认和对应类别的区域同色系,靠黑色边框区分,视觉上比较清楚。
网格范围比数据范围各外扩 1 个单位,是为了让边界处的样本不贴着图边。如果你的特征尺度差异很大(比如一维是 0 到 1,另一维是 0 到 1000),直接写死外扩量会让图很难看,我一般改成按各维标准差的 0.2 倍来扩。
3.4 两个扩展:最小风险判决与 Parzen 窗
朴素贝叶斯取后验最大,等价于“误判代价相同”的假设。实际场景里这个假设经常不成立,比如把正常样本判成异常和把异常判成正常,代价完全不同。源码里留了一个最小风险判决的分支,思路是把后验乘上代价矩阵再取最小。
function yhatRisk = predictRisk(model, X, C) % PREDICT RISK 最小风险贝叶斯判决 % C: K×K 代价矩阵,C(i,j) 表示真实为 i、判为 j 的代价 K = numel(model.classes); [~, post] = predictNB(model, X); risk = post * C'; % N×K:每个候选决策的期望风险 [~, idx] = min(risk, [], 2); yhatRisk = model.classes(idx); endpost * C'这一步是矩阵形式的期望风险计算:对每个样本,把所有可能的真实类别对应的后验乘上对应代价再求和。C的对角线一般填 0,非对角线按业务给。这份代码里默认C = ones(K) - eye(K),也就是退化成普通的最大后验。
另一个扩展是parzenPdf.m,用于类条件分布明显不是高斯的情况。核密度估计不对分布形状做假设,直接用训练样本和核函数拼出密度:
function p = parzenPdf(Xquery, Xtrain, h) % PARZENPDF 高斯核密度估计 % h: 带宽,控制平滑程度,经验值取 1.06*std*N^(-1/5) if nargin < 3 h = 1.06 * std(Xtrain(:)) * numel(Xtrain)^(-0.2); end p = zeros(size(Xquery, 1), 1); for i = 1:size(Xquery, 1) d = Xquery(i,:) - Xtrain; % M×D 差值 p(i) = mean(exp(-sum(d.^2, 2) / (2*h^2))); end p = p / ((2*pi)^(size(Xtrain,2)/2) * h^size(Xtrain,2)); end带宽h是最关键的参数,写死一个常数在数据尺度变化时会直接失效。代码里用了 Silverman 经验公式做默认值,实际调参时可以在界面上加个滑块,从0.5h到2h扫一遍看决策边界怎么变,这个交互比任何文字解释都直观。
Parzen 窗的计算是 O(N_query × N_train),数据量大时明显慢。真要上规模,得换成knnsearch或者分箱近似,但作为教学演示,几百个样本完全够用。
4. 界面回调:控件和算法函数怎么接上
算法函数是纯的,界面是脏的——控件取值可能是空、可能是字符串、可能是还没加载数据的初始状态。回调函数的职责就是把这些不确定性挡在算法外面,保证进到trainNB的永远是一个合法的数值矩阵。
4.1 控件命名与回调清单
GUIDE 里每个控件的Tag就是它在handles结构体里的字段名,命名规范直接影响代码可读性。这份源码用的是匈牙利式前缀:btn按钮、edt编辑框、pop下拉框、ax坐标轴、tbl表格、txt文本标签。看一眼回调函数名就知道对应哪个控件。
| Tag | 类型 | 回调 | 作用 |
|---|---|---|---|
btnLoad | 按钮 | btnLoad_Callback | 打开文件对话框,加载数据 |
popPrior | 下拉框 | popPrior_Callback | 选择先验估计方式 |
edtSmooth | 编辑框 | 无(读取时取值) | 方差平滑系数 |
btnTrain | 按钮 | btnTrain_Callback | 训练模型并存回 handles |
btnPredict | 按钮 | btnPredict_Callback | 预测并刷新结果面板 |
btnBoundary | 按钮 | btnBoundary_Callback | 绘制决策面 |
axScatter | 坐标轴 | 无 | 样本分布与决策区域 |
tblConfusion | 表格 | 无 | 混淆矩阵展示 |
txtAcc | 文本 | 无 | 准确率数值 |
edtSmooth没有回调,它的值在训练时读取。这种“只读不响应”的控件很常见,不需要为每个控件都写回调,否则代码会被一堆空函数淹没。
有一点要提醒:Tag改了之后必须同步改回调函数名,否则 GUIDE 找不到入口,点了按钮毫无反应。这个错误在手动复制控件时特别容易出现,报错信息还不明显,只是静默失败。
4.2 数据加载回调:把各种格式统一成 N×D 矩阵
真实的数据来源五花八门,.mat、.csv、.xlsx都有。加载函数要做的是把它们统一成“数值矩阵加标签向量”这一种形态,让后面的所有环节都不用关心原始格式。
function btnLoad_Callback(hObject, ~, handles) [fname, fpath] = uigetfile({'*.mat;*.csv;*.xlsx', '数据文件'}); if isequal(fname, 0) return; % 用户点了取消,直接返回 end fullpath = fullfile(fpath, fname); [~, ~, ext] = fileparts(fullpath); switch lower(ext) case '.mat' S = load(fullpath); fn = fieldnames(S); raw = S.(fn{1}); % 取第一个变量 case '.csv' raw = readmatrix(fullpath); case '.xlsx' raw = readmatrix(fullpath); otherwise errordlg('不支持的文件格式', '错误'); return; end if size(raw, 2) < 3 errordlg('至少需要 2 列特征和 1 列标签', '错误'); return; end X = raw(:, 1:end-1); % 前若干列为特征 y = raw(:, end); % 最后一列为标签 handles.X = X; handles.y = y; handles.model = []; % 换数据时清空旧模型 guidata(hObject, handles); showScatter(handles); set(handles.txtAcc, 'String', '数据已加载,待训练'); endisequal(fname, 0)是uigetfile的取消判定,注意不能用isempty,取消时返回的是数值 0 而不是空数组。这个细节翻过一次车之后我就记住了。
handles.model = []那一行容易被漏掉。换了数据集但没清空旧模型,用户不点训练直接点预测,程序会拿旧模型去预测新数据,维度对不上就报索引越界,维度恰好对上的话更糟——静默输出一堆错误结果,还查不出原因。
showScatter是独立的小函数,负责按标签给样本点上色,和决策面绘制共用一套配色逻辑,保证两张图颜色一致。
4.3 训练与预测回调:状态校验放在最前面
训练和预测两个回调的骨架几乎一样:从handles取状态,校验,调算法,写回,刷新界面。差别只在中途调的是哪个函数。
function btnTrain_Callback(hObject, ~, handles) if ~isfield(handles, 'X') || isempty(handles.X) errordlg('请先加载数据', '提示'); return; end smooth = str2double(get(handles.edtSmooth, 'String')); if isnan(smooth) || smooth <= 0 smooth = 1e-6; % 非法输入回退默认值 set(handles.edtSmooth, 'String', '1e-6'); end handles.model = trainNB(handles.X, handles.y, 'Smooth', smooth); guidata(hObject, handles); % 必须写回 set(handles.txtAcc, 'String', '训练完成'); end function btnPredict_Callback(hObject, ~, handles) if ~isfield(handles, 'model') || isempty(handles.model) errordlg('请先训练模型', '提示'); return; end [yhat, post] = predictNB(handles.model, handles.X); acc = mean(yhat == handles.y); % 训练集准确率 cm = confusionMat(handles.y, yhat, handles.model.classes); handles.yhat = yhat; handles.post = post; guidata(hObject, handles); set(handles.txtAcc, 'String', sprintf('准确率 %.2f%%', acc*100)); set(handles.tblConfusion, 'Data', cm); showScatter(handles, yhat); % 标出判错的点 endget(handles.edtSmooth, 'String')拿到的一定是字符,必须str2double转换。用户输入了空格、中文、或者干脆清空了编辑框,转换结果是NaN,不做判断直接传进trainNB,会在inputParser的参数校验那里报错,但报错信息对用户毫无意义。回退默认值是更友好的做法。
guidata(hObject, handles)的位置很讲究。放在算法调用之前行不行?不行,那时候handles.model还没算出来。放在所有 UI 更新之后行不行?可以,但万一后面的 UI 更新报错中断,状态就没写回去,下次点击又是空模型。我的习惯是算完就立刻写回,UI 刷新放在最后,即使刷新失败,数据状态是对的。
mean(yhat == handles.y)算的是训练集准确率,不是泛化准确率,这点必须在界面上写清楚。用同一批数据训练又预测,准确率虚高是必然的,尤其在样本少、特征多的时候,能刷到 100%。演示时如果拿这个数字当模型效果,很容易被问到哑口无言——正规做法是留出验证集或做 K 折交叉验证,第 6 章会给一个简单实现。
4.4 混淆矩阵与指标回填
光有一个准确率数字说服力有限,混淆矩阵能看出错在哪。多分类场景下,每个类别的召回率差异往往比总体准确率更有信息量。
function cm = confusionMat(yTrue, yPred, classes) % CONFUSIONMAT 按 classes 顺序构建混淆矩阵 % 行对应真实类别,列对应预测类别 K = numel(classes); cm = zeros(K, K); for i = 1:numel(yTrue) r = find(classes == yTrue(i), 1); c = find(classes == yPred(i), 1); if ~isempty(r) && ~isempty(c) cm(r, c) = cm(r, c) + 1; end end end用find(..., 1)而不是逻辑索引,是为了拿到类别在classes里的位置,进而确定填到矩阵的哪一格。isempty判断兜住“预测出了训练集里没有的标签”这种极端情况——正常不会发生,但一旦标签类型不一致(比如一边是int8一边是double),==比较可能失败,加个判断总比矩阵越界强。
表格控件tblConfusion的Data属性直接接受数值矩阵,列名可以另外设ColumnName,用字符串元胞数组填上类别名。行名在 GUIDE 的表格控件里没有直接属性,一般靠左侧加一列文本或者用RowName(新版才有),兼容做法是把类别名拼进第一列。
5. 避坑与排查:五个真实翻过车的地方
这套代码结构不算复杂,但 MATLAB 的 GUI 机制加上贝叶斯本身的数值特性,踩坑点比想象中密集。下面五条是我调试时实际遇到的,按现象、原因、解决三段写。
第一坑:点按钮没反应,也不报错。现象是界面正常显示、数据也能加载,点“训练”什么都不发生。原因通常是回调函数名和控件Tag对不上,或者.fig文件被移动过导致路径失效,MATLAB 在找不到回调时是静默跳过的。解决办法是右键控件看Tag,再去.m文件里搜对应函数名,确认拼写一致;同时在命令行敲which BayesGUI确认加载的是哪一个版本的文件。
第二坑:handles.X在回调里是空的。现象是加载完数据界面上散点图明明画出来了,点训练却提示“请先加载数据”。原因是handles是值传递,某个回调里改了字段但忘了guidata(hObject, handles),改动只存在于那个函数的副本里。排查方法是在每个回调开头加一句disp(fieldnames(handles)),看关键字段在不在;根治办法是养成“改完即写回”的习惯,或者统一用setappdata/getappdata挂到 figure 上。
第三坑:准确率 100%,换数据就崩。现象是训练集上完美分类,换个数据集准确率掉到随机水平。原因多半是特征没归一化,某个量纲大的特征主导了欧氏距离和方差计算,模型实际上只学了那一维。解决是在loadDataset末尾加一步标准化X = (X - mean(X)) ./ std(X),注意均值和标准差要用训练集算,验证集复用同一组参数,不能各算各的。
第四坑:方差为 0 导致判别函数返回 NaN。现象是某些样本的后验概率全是NaN,决策面整片空白。原因是某类在某特征上取值完全相同(离散特征或样本极少时常见),方差算出来是 0,除以 0 得到Inf,再取对数变NaN。代码里的+ smooth就是防这个的,但如果平滑系数远小于方差量级(比如特征取值上万,平滑还是1e-6),等于没加。解决是按特征尺度调整平滑量,或者干脆改用完整协方差加正则化。
第五坑:网格密度调高之后界面卡死。现象是把gridN从 150 提到 500,绘图要等十几秒,拖动窗口直接无响应。原因是 500×500 是 25 万个格点,虽然预测是向量化的,但contourf渲染这么多面片本身就慢。解决是把网格密度控制在 200 以内,视觉上已经足够平滑;如果确实需要高分辨率,就只画边界线不填色,用contour代替contourf,渲染量小一个数量级。
6. 二次开发:把这份骨架改成你自己的分类器
拿到这份源码,最省事的用法是当模板:换数据、换分布假设、加对比算法。下面三个方向是我实际改过的,按改动量从小到大排。
换数据集。只需要改loadDataset.m里的列映射和标签编码,其余文件一行不动。标签只要是离散值就行,不需要是 1、2、3 这样的整数,字符串标签也能跑,因为unique和==都支持。如果数据有表头,readmatrix会返回NaN行,记得在读取后加raw = raw(~isnan(raw(:,end)), :)过滤掉。改完之后我一般会先跑一遍“留出法”:随机抽 30% 当测试集,看准确率和训练集差多少,差距超过 15 个百分点就说明过拟合了,得回头检查特征。
加对比算法。界面上加一个下拉框选择分类器,回调里用switch分发到不同函数,是成本最低的扩展方式。最值得加的是 KNN 和线性判别,两者都能复用现有的决策面绘制函数——只要新分类器也提供predict(model, X)这个签名的接口,plotDecisionBoundary完全不用改。这种“统一预测接口”的设计是这套骨架最值钱的地方,加算法只是加文件,不动已有代码。
改进验证方式。把训练集准确率换成 K 折交叉验证,改动集中在预测回调里:
function acc = crossValidate(X, y, K) % CROSSVALIDATE K 折交叉验证准确率 N = size(X, 1); idx = randperm(N); % 打乱顺序,避免类别聚集 folds = mod(0:N-1, K) + 1; % 轮流分配折号 acc = zeros(K, 1); for k = 1:K te = (folds == k); tr = ~te; model = trainNB(X(tr,:), y(tr)); yhat = predictNB(model, X(te,:)); acc(k) = mean(yhat == y(te)); end acc = mean(acc); endrandperm那一步不能省,很多数据集是按类别排好序的,不打乱直接切折,某一折可能全是同一类,验证结果毫无意义。mod(0:N-1, K) + 1保证各折样本数最多差一个,比floor(N/K)的写法更稳。
改到这里我踩过一个教训:最早我给交叉验证加了个进度条,结果每次刷新界面都把handles里的模型覆盖掉,跑完五折之后界面上的决策面变成了最后一折的模型画的,和显示的准确率完全对不上。从那以后我每次在循环里碰共享状态,都强制先把中间结果存到局部变量,循环结束再统一写回handles,宁可多写两行赋值,也不让循环体去改全局状态。这套习惯后来在好几个项目里都救过我。希望这份拆解能帮你少走几个弯路。
本文还有配套的精品资源,点击获取