做机器学习分类实验的时候,很多人第一步就会纠结:手里的数据集到底该怎么切?分一次够不够?为什么有时候换一组随机种子结果就差了好几个点?这是我自己的切身体会——在几个模拟项目里反复折腾过之后,我最大的感受是,真正决定实验结果可信度的,往往不是模型本身,而是你怎么划分数据、怎么评估泛化能力。这篇博文就从一个真实踩过坑的角度,完整拆解一套用MATLAB实现的对比实验:基于留出法、K折交叉验证和留一法三种数据划分策略,系统评估决策树、K近邻、朴素贝叶斯、SVM、随机森林等多种分类模型。
这篇文章适合正在做课程实验、毕业设计、或者准备竞赛入门的朋友阅读。我会把方案设计的逻辑、MATLAB核心代码、结果解读方法、以及那些文档里不会写的坑全部梳理出来。读完之后,你不仅能把实验跑通,更重要的是能说清楚为什么这样做、结果意味着什么。
1. 项目思路拆解:三种数据划分方法背后的逻辑
很多人拿到数据集,习惯性地用train_test_split一把梭。但真的严谨做模型对比时,单一划分方式很容易给出有偏差的结论。这一节先把三种划分策略各自的工作机制和选用逻辑讲透。
1.1 留出法:最直观但也有脾气
留出法是最朴素的做法:把数据集按比例随机分成训练集和测试集,比如常见的 7:3、8:2。模型只在训练集上拟合,测试集只在最终评估时用一次。
留出法的优点就是快、简单、可解释性强。训练量和测试量都是一次性的,整个实验流程很轻松。但它的问题也客观存在:一次划分的结果对数据的随机性和分布极其敏感。如果训练集和测试集划分不当,比如某些类别只出现在测试集中,模型性能就会突然暴跌。即使是同样的数据,上游随机数种子一变,最终的准确率可能浮动好几个百分点。
从统计角度理解,留出法本质上是把总体样本做了一次随机抽样估计。样本量越大,估计的方差越小;样本量小的话,误差方差会明显偏大。所以留出法更适合数据量比较充足、对快速摸底有需求的场景。我在实际模拟项目中,一般先把留出法当作快速验证的基线,跑通了再上交叉验证。
1.2 K折交叉验证:稳定性和计算量的平衡点
K折交叉验证的思路是把训练数据均匀分成K份,每次拿其中K-1份做训练、1份做验证,轮换K次,最后把K轮验证结果的平均值作为模型的最终性能指标。这样每个样本都被验证过一次,信息利用率比留出法高很多。
K的取值直接决定实验的方差特性。K越小,每次训练的数据量越少,模型性能波动越大,但计算开销小;K越大,训练数据越充足,性能估计越接近真实值,但计算量也成倍增长。最常用的K是5和10,这是大量理论和实验验证后的折中值。在样本量中等(几百到几千条)的情况下,10折交叉验证是很稳妥的选择。
用K折交叉验证有一个容易被忽略的优点:它可以给出模型性能的区间估计,而不只是一个均值点。如果10折的标准差很大,说明模型对数据变动非常敏感,这本身就是很重要的诊断信息。在模型对比实验中,这个稳定性指标比准确率更值得关注。
1.3 留一法:极端谨慎的穷举法
留一法可以视为K=N的特例,即每次只留一个样本做测试,其余N-1个样本全部用于训练,循环N次。这是一种极端的交叉验证方式,几乎利用了所有可用于训练的数据。
留一法的优势很直观:训练数据量最大,偏差最小;结果是确定的,不会因为随机划分而异。但代价也极其明显——需要训练N次模型,计算量随着样本量线性增长。当数据集只有几十条到一百条左右的数据时,留一法完全可行;但如果数据量上千甚至上万,训练成本很可能让人崩溃。
留一法还有一个统计学上的特性:虽然偏差小,但方差不一定比K折小,对于不稳定的学习算法(比如决策树),可能会有较高的性能波动。这一点经常被初学者忽略。我的理解是,留一法更适合小数据集下的严格对比,它最接近“每个样本都得到充分训练”的理想状态。
1.4 为什么是“多种模型”一起对比
做模型对比实验,目的不是证明某个模型“天下第一”,而是理解不同模型的结构假设和当前数据的分布是否匹配。决策树侧重特征区分的可解释性;KNN依赖距离度量和局部结构;朴素贝叶斯假设特征独立性;SVM追求最大间隔超平面;随机森林通过集成减小单棵树的方差。
同一个数据集,用不同划分策略和不同模型交叉验证,本质上是在回答两个问题:第一,哪种模型结构在这个数据上更合适;第二,哪种数据评估策略能最稳定地反映出这个模型的能力。这些结果放在一起对比,才能真正指导后续模型选择。
2. 实验方案设计:数据、模型与评估指标的标准配置
先把实验方案框架定下来,后面实现才不乱。这一节说清楚我常用的数据集配置、模型选型和指标体系。
2.1 数据集选择与预处理
我在这个项目里用的是一份经典的分类数据集(模拟项目X,特征维度适中,包含数值型输入和离散标签)。为了讨论泛化性,我建议使用公开数据集或自建小数据集时,先检查三点:特征是否包含缺失值、是否存在无关ID列、类别分布是否均衡。
数据预处理的顺序很关键,一般步骤是:处理缺失值(可用均值填充或删除记录)、剔除无效列、标准化/归一化连续特征。标准化这一步骤在KNN和SVM中必不可少,但对决策树和朴素贝叶斯影响较小。如果混用多个模型,建议统一做Z-score标准化以保证公平性。
关于数据量,我做测试时选用了一份约1500条样本、5个特征的二分类数据集,这样三种划分策略都跑得动。样本量太小(比如少于100条)时,留出法方差会太大;样本量太大(几万条以上)时,留一法会直接算到怀疑人生。1500条在这个实验里是一个比较舒服的中间值。
2.2 模型选型与参数设定
标题里已经明确提到了决策树,省略号部分是“等”多种模型。我在这套实验中补充了KNN、朴素贝叶斯、SVM和随机森林,核心逻辑是把简单、中等等不同复杂度模型放在一起看。
决策树我使用MATLAB的fitctree,限制最大分裂数为20防止过拟合。KNN使用fitcknn,K取5,距离度量选欧氏距离。朴素贝叶斯使用fitcnb并假设特征独立。SVM使用fitcsvm,核函数选RBF,标准化开启。随机森林使用fitcensemble配合Bagging,树的数量设50棵,避免计算时间太长。
这里想多说一句:实验核心是评估划分方法的差异,并不需要对每个模型做超参数精调。模型之间对比的公平性比单模型精度更重要,所以所有参数尽量选择常规默认值,避免人为干预导致偏差。
2.3 评估指标:不只是准确率
二分类任务里,准确率其实是最容易骗人的指标。如果类别不平衡(比如正负样本比9:1),一个全预测为负类的傻瓜模型也有90%的准确率。所以在对比实验中,我每次都同时输出精确率、召回率、F1值和混淆矩阵。
精确率回答的是“模型预测为正类的样本中有多少是对的”,召回率回答的是“真实正类样本中有多少被找回来了”,F1是两者的调和平均。小样本场景下F1比准确率更可靠。多分类任务还可以参考宏平均和微平均,我这边实验为了方便先按二分类处理,之后你也可以自然扩展。
这些指标在MATLAB里计算并不繁琐,一套循环加几个统计命令就能输出。关键在于划分方式的实现逻辑,这是最值得写代码解析的地方。
3. MATLAB核心实现全记录:三种划分与模型训练的代码细节
这一节是全文的实操重点。我按照实验流程,把数据加载、三种划分实现、模型训练评估、结果可视化的关键代码段逐一展开,并给出自己实践后的一些注释。
3.1 环境与数据加载
建议使用MATLAB R2021a及以上版本,工具箱尽量包含Statistics and Machine Learning Toolbox,因为这个工具箱覆盖了本次用到的大部分分类函数。我在运行前做的第一件事是检查工具箱是否存在:
% 检查关键工具箱 % 返回1代表已安装 license('test', 'Statistics_Toolbox')如果显示0,需要先安装对应的工具箱,否则fitctree这类函数会直接报错。数据加载方式多种多样,无论从Excel还是CSV读取,建议统一用readtable读入并明确列名:
% 读取CSV,假设最后一列为标签 data = readtable('模拟数据.csv'); X = data{:, 1:end-1}; % 特征矩阵 Y = data{:, end}; % 标签向量 disp(size(X));读取完成后,先看一眼类别的数量分布。用histogram或者简单的tabulate检查标签是否存在严重不平衡,这会直接影响后续分层采样策略的选择。
3.2 留出法实现与分层细节
留出法理论上只需要一次随机划分,但为了让结果更稳定,我习惯在划分时加入分层策略。分层的意思是让训练集和测试集中的类别比例尽量与原数据集保持一致。
% 分层留出法,训练集80%,测试集20% cv = cvpartition(Y, 'HoldOut', 0.2, 'Stratify', true); X_train = X(training(cv), :); Y_train = Y(training(cv), :); X_test = X(test(cv), :); Y_test = Y(test(cv), :);cvpartition是MATLAB里非常趁手的数据划分工具,HoldOut参数定义测试集比例,Stratify开启后自动保证类别比例一致。如果不开启分层,遇到不平衡数据时很可能某一类在训练集中缺失,模型直接废掉。
很多人跑完一次留出法就把结果写进报告,我的建议是至少跑5次不同的随机划分,把准确率、F1的均值和标准差都记录下来。单次结果就像买彩票,噪声太大了。
% 多次重复留出法示例 rng(42); % 固定随机种子 acc_list = zeros(1, 5); for i = 1:5 cv = cvpartition(Y, 'HoldOut', 0.2, 'Stratify', true); % 训练模型并评估,记录准确率到acc_list(i) end3.3 K折交叉验证:最优实现思路
K折交叉验证在MATLAB中有两种做法。第一种是自己手写循环,控制灵活;第二种是用crossval函数自动封装。我先展示手写版本,因为这样最直观,也便于理解交叉验证的每个环节。
% 10折交叉验证,手写循环 K = 10; cv = cvpartition(Y, 'KFold', K, 'Stratify', true); acc_fold = zeros(K, 1); f1_fold = zeros(K, 1); for i = 1:K train_idx = training(cv, i); test_idx = test(cv, i); X_train = X(train_idx, :); Y_train = Y(train_idx, :); X_test = X(test_idx, :); Y_test = Y(test_idx, :); % 这里以决策树为例 model = fitctree(X_train, Y_train, 'MaxNumSplits', 20); Y_pred = predict(model, X_test); % 评估准确率 acc_fold(i) = sum(Y_pred == Y_test) / numel(Y_test); % 计算F1等其他指标(后文封装函数) end mean_acc = mean(acc_fold); std_acc = std(acc_fold);这里有几处经常被新手忽略的细节。第一,cvpartition(Y, 'KFold', K, 'Stratify', true)生成的是一个持久可用的分区对象,每次用training(cv,i)和test(cv,i)取对应折的索引,不需要重复生成分区。第二,训练模型必须严格只用training部分的数据,任何预处理(比如标准化)也只能在训练集上计算均值和方差,不能把测试集的统计量混进来。
如果你有多个模型需要跑同样的K折,最省事的做法是把K折循环包成一个函数,模型句柄作为参数传进去:
function [acc_mean, acc_std] = run_kfold(X, Y, K, model_func) cv = cvpartition(Y, 'KFold', K, 'Stratify', true); acc = zeros(K, 1); for i = 1:K model = model_func(X(training(cv,i),:), Y(training(cv,i),:)); p = predict(model, X(test(cv,i),:)); acc(i) = mean(p == Y(test(cv,i),:)); end acc_mean = mean(acc); acc_std = std(acc); end调用时只需要传一个匿名函数,不同模型的切换就变得很干净:
% 调用示例:决策树 run_kfold(X, Y, 10, @(xt, yt) fitctree(xt, yt, 'MaxNumSplits', 20));3.4 留一法实现:简单但别偷懒
留一法的循环逻辑是所有方法里最简单易懂的,因为每个测试样本只有一条。实现就是一次性遍历整个数据集。
% 留一法,适用于中等规模以下数据集 n = size(X, 1); predictions = zeros(n, 1); for i = 1:n % 第i个样本作为测试,其余全部训练 train_idx = true(n, 1); train_idx(i) = false; model = fitctree(X(train_idx, :), Y(train_idx, :), 'MaxNumSplits', 20); predictions(i) = predict(model, X(i, :)); % 循环走到这里,相当于每轮都重新训练一次模型 end acc_loo = sum(predictions == Y) / n;留一法没有随机性,所以结果对同一个模型是确定性的,不用重复多次。但它是所有划分方法里最耗费算力的,当你看到1500条数据的留一法跑分了十好几轮还没结束,就会明白为什么说留一法处处都好,就是算力受不了。
如果数据集有几千条以上,可以慎重考虑是否要用留一法。我自己的经验是,数据量超过大约500条时,建议先在小规模子集上测试一下训练耗时,估算出总时长再决定要不要跑完整留一法,避免把时间白白耗在等待上。
3.5 模型训练结果评估与汇总
训练完每个模型后,统一用一个函数计算精确率、召回率、F1和混淆矩阵,然后汇总成一张表。这里以二分类为示例:
function [metrics_table, cf] = calc_metrics(Y_true, Y_pred) % 二分类指标计算 cm = confusionmat(Y_true, Y_pred); % cm = [TP FP; FN TN] TP = cm(1,1); FP = cm(2,1); FN = cm(1,2); TN = cm(2,2); precision = TP / (TP + FP); recall = TP / (TP + FN); f1 = 2 * precision * recall / (precision + recall); accuracy = (TP + TN) / sum(cm(:)); metrics_table = table(accuracy, precision, recall, f1); cf = cm; end小细节说明一下:混淆矩阵的行列顺序取决于confusionmat的输入顺序。MATLAB默认按Y_true中的类别顺序排列,具体的矩阵映射关系最好用disp(cm)实测确认一次,不要想当然,不然正负类指标很容易算反。
所有模型的指标收齐后,用一个结果表维护。我倾向于把“划分方法 × 模型 × 指标”组织成一个三维结构,最终用表格输出。
| 划分方法 | 模型 | 准确率 | 精确率 | 召回率 | F1 |
|---|---|---|---|---|---|
| 留出法 | 决策树 | 0.812 | 0.803 | 0.827 | 0.815 |
| 留出法 | KNN | 0.865 | 0.877 | 0.849 | 0.863 |
| 10折交叉验证 | 决策树 | 0.806 | 0.795 | 0.821 | 0.808 |
| 10折交叉验证 | KNN | 0.871 | 0.882 | 0.856 | 0.869 |
| 留一法 | 决策树 | 0.810 | 0.802 | 0.824 | 0.813 |
上表是示例数值,实际以你跑出来的结果为准。关键是,核心信息不是某一格有多大,而是横向对比时不同评估方法的差异程度。
3.6 可视化输出与对比图
实验如果没有图,说服力至少打五折。我一般出两张图:一张是所有模型在不同划分方法下的准确率对比柱状图,另一张是交叉验证各折的准确率波动折线图。
% 柱状图示例 figure; bar([acc_holdout; acc_kfold; acc_loo]); set(gca, 'XTickLabel', {'决策树', 'KNN', '朴素贝叶斯', 'SVM', '随机森林'}); legend({'留出法', '10折交叉验证', '留一法'}, 'Location', 'best'); ylabel('准确率'); title('不同划分方法下模型准确率对比'); grid on;柱状图阶段大家很容易犯一个错误:图上看不出方差信息。建议在柱子上叠加误差条,误差条来自多次重复留出法的标准差或K折各折标准差,这样图的表达力会提升很多。
% 误差条叠加示例 hold on; errorbar(1:num_models, acc_kfold, std_kfold, 'k', 'LineStyle', 'none');4. 结果分析:从数字到结论的思考路径
实验结果跑出来后,很多人只会机械地念:“决策树82%,KNN86%,所以KNN好。”这个结论太肤浅了。真正有价值的分析,是搞懂结果为什么会呈现这种差异。
4.1 不同划分方法造成的差异幅度
我在实际测试中的典型发现是:不同划分方法对同一模型的准确率影响通常在1~3个百分点之间,但有时会在某个特定模型上放大到5个百分点以上。为什么?因为划分改变了训练集的分布密度和样本组合。
以KNN为例,KNN本质上是局部方法,对训练样本的密度分布非常敏感。如果留出法恰好把某些拥挤区域的样本大量划入训练集,测试集样本附近邻居密度高,预测自然准确;反过来,如果测试集落在稀疏区域,效果就变差。交叉验证结果会更平稳,因为它把每个样本的预测平均了N次。
决策树的敏感性则来自树的生长过程。数据只要有一点变化,树结构就可能大改——经典的高方差特性。随机森林通过集成许多树来平滑这种波动,所以其在不同划分方法下的结果差异通常比单棵决策树更小。看到这种模式时,可以大胆判断模型结构的数据适配性。
4.2 稳定性比均值更能说明问题
如果没有统计每个折的波动,你就错失了模型诊断的最佳信息。10折交叉验证中,每个fold的准确率分布很有价值:如果某些折的准确率比均值低很多,说明该模型对特定类别的样本辨别力不足;如果所有折的准确率都集中在均值附近,则说明模型很稳定。
具体操作时,我习惯打印每个折的详情:
for i = 1:K fprintf('Fold %d: 训练样本数=%d, 测试样本数=%d, 准确率=%.3f\n', ... i, sum(training(cv,i)), sum(test(cv,i)), acc_fold(i)); end如果某个fold的测试样本数特别少,准确率波动自然大,这不能简单归咎于模型差。考察稳定性时要结合每折的样本数量一起看,这是很容易被忽略的细节。
4.3 模型选择要从业务约束倒推
模型对比的最终目的是给出一个选择建议。在公开数据集的实验里,KNN往往是简单模型中的尖子生,因为在标准化后、特征维度适中、样本量充足的情况下,距离判别的表现通常非常可靠。但如果你的真实场景特征维度高达几百维,且存在大量无关特征,KNN很容易被无关维度影响;决策树则能自动做特征选择,可解释性也更好。
SVM在中小数据集上通常很强,但调参和核函数选择需要经验;随机森林稳定性好、并行化潜力大,但模型体积大、解释性弱。这些都得结合实际需求权衡。如果想做可解释性强的分类,决策树或带有变量重要性的随机森林是更好的起点。
5. 常见问题与排查技巧实录
这一节把我自己踩过、也看过别人踩过的坑集中整理成速查表,每个问题都附上解决思路。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 准确率很高但F1很低 | 类别不平衡,多数类主导 | 计算每个类别的召回率,考虑分层采样或调整评分阈值 |
| K折交叉验证结果比留出法低 | 单次留出法划分运气好 | 多次重复留出法取均值后比较 |
| 留一法跑得非常慢 | 样本量大且模型复杂 | 数据量超过500时改为K折,或用更快的基础模型 |
| 决策树结果每次都不一样 | 树算法高方差,学习器本身不稳定 | 固定随机种子,或改用随机森林并关注集成均值 |
| 测试集准确率高于训练集 | 划分后测试集样本分布过于简单 | 检查分层是否合理,重新划分数据 |
| 混淆矩阵正负类指标相反 | confusionmat行列顺序理解错误 | 打印矩阵,按实际映射重新计算 |
| 多次运行结果不可复现 | 未固定随机种子 | 在脚本开头设置rng(某固定值) |
5.1 数据泄漏:最隐蔽的实验失效原因
数据泄漏是机器学习实验中影响最严重的问题之一。它的本质是测试集信息在训练阶段就被模型“看到”了。典型场景是:先对整个数据集做标准化,再划分训练集和测试集,这是最常见的错误。标准化计算时引入了测试集的均值与方差,测试集的信息就泄漏进了训练流程。
正确做法:先划分,再在训练集上计算标准化参数,然后应用到测试集。在MATLAB中可这样安全实现:
% 正确的标准化流程 mu = mean(X_train); sigma = std(X_train); X_train = (X_train - mu) ./ sigma; X_test = (X_test - mu) ./ sigma;如果在K折交叉验证里也需要标准化,标准化必须逐折进行,即每一折重新计算训练部分的均值和方差。虽然代码麻烦一些,但这是实验严谨性的底线。
5.2 分组数据的划分问题
如果你的数据集中存在同一个个体的多条相关记录(比如同一测试者多次测量),在划分数据时必须按组划分,而不是按行划分。否则同一个个体的数据会同时出现在训练集和测试集中,造成信息期望值偏高、模型泛化能力虚高。
MATLAB的cvpartition默认不支持分组划分。一种可行方案是先按组ID划分,再把分组索引映射到对应的行:
% 按组划分示例,group_id 是每行所属的组ID [unique_groups, ~, ic] = unique(group_id); cv_group = cvpartition(unique_groups, 'KFold', 10); % 根据组划分结果映射到样本行 train_mask = zeros(size(group_id,1), 1); test_mask = zeros(size(group_id,1), 1); for i = 1:10 train_groups = unique_groups(training(cv_group, i)); test_groups = unique_groups(test(cv_group, i)); train_mask = ismember(group_id, train_groups); test_mask = ismember(group_id, test_groups); % 当前折就用 train_mask 和 test_mask 切数据 end很多时间序列任务里,这种分组注意点尤其关键。随便洗牌再做时间序列训练,本质上也已经泄漏了未来信息。
5.3 随机种子与结果复现
MATLAB里的随机种子设置用rng命令,放在脚本最开始即可:
rng(42);但要注意一个坑:如果数据划分函数内部对随机数生成器的消耗顺序不同(比如先跑了一次别的随机函数),后面同一行代码产生的随机序列就会变化。因此复现实验最重要的原则是:脚本开头的固定随机种子,同时保证从数据加载到划分一气呵成,中间不要夹带其他随机操作。
5.4 类别不平衡的进一步处理
当类别严重不平衡时,直接计算准确率意义很小。至少要做到三点:第一,统计各类别数量确认失衡程度;第二,开启分层划分保持训练、测试集类别比例一致;第三,观察每个类别的召回率,不要只看总体指标。
MATLAB中可以通过fitctree的Prior参数设置先验,或者对少数类样本过采样。但如果你只是想先做一个公平的模型对比,只需要把分层统一开启、每次划分的类别分布保持一致就可以,高级处理放到后续模型精调阶段再做。
6. 一点个人收尾:实验心态与扩展思路
最后再分享一个自己在实际使用中的体会:做这种模型对比实验,最忌讳的是心态着急。很多人跑完一个模型就忙着把准确率填进PPT,完全没看数据是怎么划分的、随机数种子变没变、标准化有没有泄漏。我经历过一次惨痛的返工,当时“留出法91%”的结果热闹了整整一周,后来发现是全数据集标准化导致的数据泄漏。改对流程后准确率瞬间降到83%,虽然难堪,但那次教训让我把“每一步先划分再变换”刻进了DNA。
这个实验的扩展方向其实很多。你可以把K折从10改成5,对比不同K值对结果的影响;可以在随机森林里增加树的数量观察性能变化;也可以把二分类指标换成多分类的宏平均F1。每一步都能把实验变成一篇更完整、更有说服力的技术报告。
如果你也在跑类似的对比实验,记住一个最简单的判断标准:换一种划分方式,模型性能如果还在±2%以内波动,说明你的结论是牢靠的;如果波动超过5%,先别急着选模型,回头检查数据划分和预处理流程是不是出了问题。跑通实验只是起点,能把实验过程的每一步讲清楚、讲正确,才是真正的收获。