☰
MATLAB实现模型评估对比:留出法、K折与留一法在五类分类器上的实践
2026/10/21 6:31:41 网站建设 项目流程

先抛个观点:模型之间的好坏,很多时候不是模型本身决定的,而是你用什么方式去评价它。同样是决策树,用固定划分的数据测出来可能90%准确率,换成留一法一测可能直接掉到70%多,很多刚做机器学习实验的同学第一次遇到这种情况会怀疑代码写错了,其实不是代码问题,而是评估方式放大了数据划分的偶然性。

这个项目要做的,就是在一套统一的框架下,把留出法、K折交叉验证和留一法三种数据划分策略各跑一遍,用决策树、SVM、KNN、逻辑回归和朴素贝叶斯这些常见分类器做横向对比,最后用准确率、召回率、F1分数这些指标把结果摆出来。它本质上是一个模型评估与选择的标准流程,适合正在做课程设计、毕业设计或者自己练手机器学习的人参考。我写这篇文章会把完整思路、可复现的MATLAB代码和踩过的坑都讲清楚。

1. 内容整体设计与思路拆解

1.1 为什么不能只看单一评估方式

很多初学者习惯把数据集随手切一块出来当测试集,模型跑完直接报一个准确率就交差。这种做法的风险在于,如果划分时某类样本恰好集中在了训练集或者测试集里,你得到的准确率可能比真实水平高很多,也可能低很多。比如一个二分类数据集正负样本比例是8:2,你随机划分时测试集里碰巧全是正样本,模型即使全猜正也能拿到100%准确率,这完全没有参考价值。

所以项目中同时引入三种评估策略,不是为了炫技,而是为了从不同角度回答同一个问题:这个模型在新数据上到底行不行?留出法速度快,适合大数据集快速摸底;K折交叉验证均衡稳定,是工程上最常用的方案;留一法是在样本极少时的极限方案,相当于把K折做到极致。

这三种方法本质上是同一个逻辑——用部分数据训练,用另一部分数据测试,区别只在于划分的粒度和重复次数。明白了这一点,你再看代码就不会觉得它们是三套独立的东西,而只是cvpartition函数的不同参数配置。

1.2 三种评估方式的适用场景

留出法(Hold-Out)是最直觉的划分方式,把数据按比例切成训练集和测试集,比如70%训练、30%测试。它的问题在于结果对划分比例和划分种子很敏感,同一份数据换一个随机种子,准确率可能上下浮动几个百分点。对于样本量几万几十万而且类别均衡的数据,留出法完全够用,因为样本足够多,随机划分的波动会被摊平。

K折交叉验证把数据分成K份,轮流拿其中一份做验证、其余K-1份做训练,最后把K次结果平均。默认的K是5或者10。这种方式的好处是每个样本都恰好被当作验证数据一次,训练数据也有重叠,所以评估结果方差小、更可靠。代价是训练K次,时间成本是K倍,如果模型已经很复杂,这个开销要提前算清楚。

留一法是K折的一种极端形式,K等于样本数,每次只留一个样本做验证。这是确定性的评估,不需要随机种子,结果完全可复现。但它只能用于样本量很小的场景,几百个样本还能跑,几千个样本配合复杂模型就会让人等得崩溃。它给出的误差估计几乎是无偏的,但方差偏大,而且极端耗时,实验笔记里要注明这一点。

1.3 模型选型与对比实验的核心原则

做模型对比最忌讳的一件事就是控制变量没做好。我在项目里把数据集划分策略当作唯一自变量,所有分类器都用默认或者经过简单调优的参数,保证它们在同一条起跑线上。如果你在SVM上叠加了精细的核函数调优,却让决策树裸奔,那对比结果只能说明“调参力度不同”,不能说明“模型本身谁更好”。

另外,数据预处理必须在划分之前完成,而且要放在交叉验证的每一折里重新做。最典型的错误是把全部数据标准化之后再做留出法,这样测试集的信息已经渗进了训练过程,属于典型的数据泄露,会让评估结果偏乐观。真正严谨的做法是只在训练折上计算均值和标准差,再用同一组参数去变换测试折。

还有一个容易被忽略的点:随机种子。MATLAB里rng(42)这种写法要放在整个实验最前面,之后所有涉及随机划分的操作都会基于这个种子,结果才能复现。我见过不少同学的代码每次跑出来数字都不一样,就是因为没有固定种子,或者训练过程中某些自带随机性的函数没有设置全局随机流。

2. 核心细节解析与实操要点

2.1 数据准备阶段的三个细节

数据格式方面,MATLAB的分类器接口基本都接受两个参数:特征矩阵X和标签向量y。X的每一行是一个样本,每一列是一个特征;y是categorical类型或者数值向量。我最开始做实验时直接用字符串数组当标签,结果部分分类器报错,后来统一用categorical(y)转换,各个模型都太平了。

缺失值和异常值必须在进模型之前处理掉。fitctree和fitcknn这类函数遇到NaN还能勉强运行,但SVM的逻辑回归这类对输入敏感的模型会直接出错。最省事的做法是在加载数据后先做一步清洗,比如用rmmissing函数删掉含缺失值的行,或者用中位数填充。这个步骤看上去不起眼,但能避免很多莫名其妙的报错。

数值特征的范围差异也要注意。决策树和KNN对特征尺度不敏感,逻辑回归和SVM对特征尺度敏感。如果特征里既有0到1的数值又有上千的大数值,那逻辑回归的梯度下降会很难收敛,SVM的间隔计算也会被大数值特征主导。标准化用zscore就行,按列减均值除标准差。标准化参数必须从训练集计算,再套到测试集上,直接在全部数据上zscore是不对的。

2.2 评估指标怎么选、怎么算

准确率是最直观的指标,但光看它远远不够。二分类场景里正负样本不平衡时,准确率会骗人:99%负样本的数据集,全预测负类就能拿99%准确率,模型其实什么都没学到。所以项目里要同时算精确率(Precision)、召回率(Recall)和F1分数。

多分类场景下,每一类都可以单独算精确率和召回率,但为了整体对比模型,通常用宏平均,也就是把每一类的指标算出来之后求算术平均。这个做法让每个类对最终结果有相同的权重,类别不平衡时更公平。还有一种微平均是先把所有类的混淆矩阵元素相加再计算,更偏向样本多的类。项目对比里统一用宏平均,表格里要注明这点,否则别人看你数字会一头雾水。

混淆矩阵是评判分类模型最好的诊断工具,MATLAB里直接调用confusionmat(y_true, y_pred)就能拿到。它不仅能让你看到总准确率,还能一眼看出模型把哪两个类搞混了,这对后续特征工程和模型改进非常有帮助。我在项目里会把混淆矩阵一并输出到图片文件里,保存下来做分析素材。

2.3 分类器配置与参数设置的要点

决策树用fitctree,默认参数对大多数中等规模数据都够用。如果出现过拟合迹象,可以手动限制最大分裂层数MaxNumSplits,或者设置MinParentSize来控制叶子节点最小样本数。剪枝不是必须的,但加一个限制能明显提升泛化效果。

SVM在多分类情况下直接用fitcecoc,内部会做一对一分解,把多分类问题拆成若干个二分类子问题。核函数的选择很关键,线性核适合特征较多或高维稀疏的场景,RBF核适合特征维度适中但类别边界复杂的场景。RBF的KernelScale参数如果不知道怎么调,先用默认值,对比实验里不一致调是没问题的,但至少要保证它能收敛。

KNN用fitcknn时需要设置NumNeighbors,也就是K值。这个K值有讲究:K太小容易过拟合,K太大决策边界变得过于平滑。从经验来看,二分类时K取奇数可以避免平票问题,多分类时K尽量避开类数的倍数。距离度量一般用欧氏距离就够了,但特征标准化没做的时候,欧氏距离会被量纲大的特征主导。

逻辑回归可以用fitclinear,它支持正则化,适合高维特征。但fitclinear默认是为二分类设计的,做多分类时需要用它作为基分类器包在fitcecoc里。朴素贝叶斯用fitcnb,它对特征独立性的假设很强,特征之间相关性高时效果会打折,但它训练极快,而且对缺失值和小样本比较友好,当作对比的基线是很合适的。

3. 实操过程与核心环节实现

3.1 项目总框架设计

我的实验代码分成四层:数据准备层、划分策略层、模型训练评估层、结果汇总层。数据准备层负责读取、清洗、categorical转换和标准化;划分策略层用一个switch语句控制三种策略;模型训练评估层是一个通用函数,接收训练数据和测试数据,返回预测标签和各项评估指标;结果汇总层把三种策略、五个模型的指标汇总成一张表,顺便打印混淆矩阵。

这样做的好处是,后续想再加一个新模型,只需要在模型列表里加一行函数句柄,不用动其他代码。划分策略和模型组合成15组实验,每组实验都自动记录指标,最后一次性输出对比表。整个流程对以后做其他数据集也完全通用,换个文件路径就能跑新的实验。

3.2 留出法的实现细节

留出法直接用cvpartition的HoldOut类型,代码非常简洁:

rng(42); % 固定随机种子,保证实验可复现 cv = cvpartition(y, 'Holdout', 0.3); X_train = X(cv.training, :); y_train = y(cv.training, :); X_test = X(cv.test, :); y_test = y(cv.test, :);

这段代码的意思是拿出30%的样本作为测试集,剩下70%做训练集。实际项目里我习惯传一个结构化参数holdoutRatio进来,方便以后改成0.2或者0.4。有一点要提醒:cvpartition是按类别比例做分层划分的,也就是说即使数据集里两类样本数量不一样,划分后的训练集和测试集里类别比例也会保持和原始数据一致,这是一个很有用的特性,能避免分层偏斜。

拿到划分结果之后,标准化参数一定要用训练集计算:

mu = mean(X_train); sigma = std(X_train); X_train_norm = (X_train - mu) ./ sigma; X_test_norm = (X_test - mu) ./ sigma;

测试集用的是训练集的mu和sigma,不是自己的均值和标准差。测试集本质上是要模拟“未来到达的新数据”,它不能参与任何参数估计,否则就是作弊。

3.3 K折交叉验证与留一法的实现写法

K折交叉验证的实现有两种写法。一种是自己写循环,另一种是用cvpartition和cviter。cvpartition返回的对象里有训练和测试的索引,循环K次即可。K=10通常是比较稳妥的选择,官方文档也是这么推荐的。

cv = cvpartition(y, 'KFold', 10); accuracies = zeros(cv.NumTestSets, 1); for i = 1:cv.NumTestSets X_train = X(cv.training(i), :); y_train = y(cv.training(i), :); X_test = X(cv.test(i), :); y_test = y(cv.test(i), :); % 每折都重新做标准化 mu = mean(X_train); sigma = std(X_train); X_train_norm = (X_train - mu) ./ sigma; X_test_norm = (X_test - mu) ./ sigma; model = fitctree(X_train_norm, y_train); y_pred = predict(model, X_test_norm); accuracies(i) = sum(y_pred == y_test) / numel(y_test); end mean_acc = mean(accuracies);

留一法在cvpartition里的写法其实更简单,因为它本质上是K折的极端情况,直接设置LeaveOut为true:

cv = cvpartition(y, 'LeaveOut', true); LOO_accuracy = zeros(cv.NumTestSets, 1); for i = 1:cv.NumTestSets X_train = X(cv.training(i), :); y_train = y(cv.training(i), :); X_test = X(cv.test(i), :); y_test = y(cv.test(i), :); % 其余逻辑与K折相同 end

注意这里的NumTestSets就等于样本总量N,也就是说如果有1000个样本,循环要跑1000次。配合SVM的话,训练时间会让人崩溃。我自己的经验是留一法只适合样本量在200以内的小数据集,超过这个规模,换成10折交叉验证完全够用。

3.4 模型评估通用函数与结果汇总

为了让代码不重复,我把训练和评估封装成一个函数:

function results = evaluateModel(X_train, y_train, X_test, y_test, modelFunc) model = modelFunc(X_train, y_train); y_pred = predict(model, X_test); acc = sum(y_pred == y_test) / numel(y_test); C = confusionmat(y_test, y_pred); numClasses = size(C, 1); precision = zeros(numClasses, 1); recall = zeros(numClasses, 1); for c = 1:numClasses tp = C(c, c); fp = sum(C(:, c)) - tp; fn = sum(C(c, :)) - tp; precision(c) = tp / (tp + fp); recall(c) = tp / (tp + fn); end f1 = 2 * precision .* recall ./ (precision + recall); macroF1 = mean(f1); results.accuracy = acc; results.macroF1 = macroF1; results.precision = precision; results.recall = recall; results.confusionMatrix = C; results.predicted = y_pred; end

这里需要注意precision和recall的公式里tp+fp可能为0,MATLAB里会出现NaN。稳妥的做法是在分母加一个极小量,或者用一个判断兜底。我写代码时用了一个简单处理,分母为零直接把这类的precision设为0,因为这一类根本没有被预测出来,谈不上精确率。

模型列表用函数句柄数组统一管理:

models = { @(Xtr, ytr) fitctree(Xtr, ytr), ... @(Xtr, ytr) fitcecoc(Xtr, ytr, 'Learners', templateSVM('KernelFunction', 'rbf')), ... @(Xtr, ytr) fitcknn(Xtr, ytr, 'NumNeighbors', 5), ... @(Xtr, ytr) fitcecoc(Xtr, ytr, 'Learners', templateLinear()), ... @(Xtr, ytr) fitcnb(Xtr, ytr) };

这样写的好处是模型参数一目了然,而且新增模型只加一行。这里的逻辑回归用fitcecoc包了一层,才能兼容多分类任务。

4. 常见问题与排查技巧实录

4.1 结果每次跑都不一样,无法复现

这个问题90%出在随机种子没有固定。MATLAB里很多函数内部自带随机性,比如fitctree的分裂特征选择、KNN的最近邻计算、SVM的求解器初始化。在脚本最开始加上rng(42),所有随机过程会基于同一个种子流,结果就能复现。注意rng要放在cvpartition之前,因为cvpartition本身也有随机划分逻辑,如果rng放在它之后,划分结果还是会变。

还有一个小概率问题是系统并行计算导致的浮动误差,比如fitcecoc内部用了并行求解器时,不同次运行的数值可能在小数点后好几位有微小差别。解决办法是把并行池先关掉,或者明确设置'Options'里的UseParallel为false,保证实验串行执行。

4.2 KNN报错或者预测结果全是第一类

KNN的K值如果是偶数,在多分类中很容易出现两类票数相同的情况,这时候MATLAB默认取字典序靠前的类别,所以结果会偏向某个类。把NumNeighbors改成奇数,比如3或5,这种现象基本消失。

结果全是第一类还有一个常见原因,是数据没有标准化。KNN用距离度量,如果某个特征的数值范围特别大,它会主导整个距离计算,导致样本之间的差异主要由这个特征决定,模型退化成按单一特征排序,预测结果就会很离谱。这个问题的排查方式很简单,打印出每个特征的标准差,如果规模差异巨大,标准化肯定没做对。

4.3 fitcnb报错,说训练数据里存在负值

朴素贝叶斯有两种分布假设:默认情况下对连续特征拟合高斯分布,这时候数据允许有负值。但如果你用了fitcnb的Distribution参数指定了' mn'或者我没写对位置,并且数据里有负值,它就会报错。更常见的是你手动把分布设成了'multinomial'或者'binomial',这两种是专门处理计数数据的。我的建议是连续特征就用默认高斯分布,标称特征提前转成categorical,不要混用。

4.4 留一法跑得极其慢,等得心焦

留一法的训练次数等于样本数,这是绕不开的。如果数据集超千条样本,而且模型是SVM或者随机森林,建议放弃留一法,改用10折交叉验证。留一法最大的价值在于小样本场景下每个样本都能被单独验证,大样本场景下10折的结果在统计意义上已经足够接近留一法,时间成本却降低一个数量级。

如果想在代码里自动处理这个问题,可以按样本量做个判断:

if size(X, 1) <= 200 cv = cvpartition(y, 'LeaveOut', true); else cv = cvpartition(y, 'KFold', 10); end

4.5 常见问题速查表

问题现象可能原因排查与解决办法
每次运行结果不同未固定随机种子脚本开头加rng(42),并确保在cvpartition之前执行
测试集准确率非常高但泛化差数据泄露或过拟合检查标准化参数是否全部来自训练集,决策树限制MaxNumSplits
KNN预测结果偏向单一类别K值为偶数,出现平票改用奇数K值,比如3或5
fitcnb报数据含负值分布假设与数据不匹配连续特征用默认高斯分布,不要手动指定计数分布
SVM训练时间过长核函数或数据规模问题尝试线性核,或改用10折交叉验证替代留一法
模型评估指标出现NaN某类样本未被预测到分母为0时手动赋值为0
混淆矩阵对不上分类结果categorical标签顺序不一致统一使用categorical类型并检查类顺序

5. 几个值得再深挖的扩展点

项目做到这里,15组对比实验跑完,结果表也生成出来了,但实验的价值其实还可以再延伸一点。我建议把每一次划分时训练集和测试集上的指标分开记录,训练集准确率和测试集准确率的差距就是过拟合程度的量化指标。如果某个模型训练集98%、测试集只有70%,说明它过拟合明显,这个信息比单一的测试集准确率更有参考价值。

还可以做统计显著性分析。模型A的10折平均准确率比模型B高2个百分点,这个差异到底是真实差异还是随机波动,用配对t检验就能看出个大概。MATLAB里直接用ttest函数对比两个模型每折的准确率序列,p值小于0.05基本可以认为差异是显著的。

另外一个容易忽略的方向是特征重要性分析。决策树训练完,sum(predictorImportance)可以查看每个特征的重要性排序;SVM这类模型可以用交叉验证的平均损失来辅助筛选特征。很多人在模型对比做完之后就结束了,但加上特征重要性分析,你的实验报告会更有说服力,也能直接为后续特征工程指明方向。

6. 最后分享一点实际体会

我在实际跑这类实验的过程中,耗时最多的往往不是训练模型,而是排查那些让人一头雾水的警告和诡异结果。一次做K折交叉验证时,我发现某几折的精确率直接是0,查了半天发现那几折的测试数据里根本不包含某几个类别,原因是小样本数据分层划分仍然带来类别缺失。这个情况在样本量极小的时候很普遍,解决办法是确保数据集类别数量都够多,或者设置cvpartition时仔细检查每一折的类别分布。

还有一次,我在标准化时失误了。写代码时脑子一热用整个X_train和X_test拼接起来做了zscore,导致测试集信息泄露,实验结果全面偏高。后来重新仔细检查前置处理流程,才明白这件事有多容易被忽略。这种小坑,犯过一次之后再也不会犯,但新手往往会被这种问题卡住好几天。

从经验上讲,一个扎实的模型对比实验,最核心的是流程纪律:固定种子、分层划分、先训练后测试、指标多样、记录完整。把这五点做到位,哪怕用的全是默认模型,结论也站得住脚。反过来,这些细节如果没做好,再高级的模型也经不起推敲。

这个项目后续扩展其实空间很大。可以换不同的数据集来测试这五个模型在不同数据特性下的表现,也可以加一个网格搜索模块对关键参数自动调优,甚至可以写一个一键生成实验报告的函数,把表格、混淆矩阵、学习曲线全部导出成HTML或者PDF。做机器学习实验,代码能跑通只是第一步,能够清晰呈现实验设计、实验过程和结论分析,才算真正完成了整个研究闭环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询