LapSVM流形正则化:从拉普拉斯矩阵到半监督分类实战

发布时间:2026/9/26 13:29:35
LapSVM流形正则化:从拉普拉斯矩阵到半监督分类实战 简介拉普拉斯支持向量机LapSVM的完整MATLAB代码包面向机器学习研究者和需要处理非线性分类问题的开发者提供基于流形正则化的半监督分类实现该算法在标准SVM基础上引入图拉普拉斯正则项能够利用数据局部几何结构辅助分类决策。压缩包共含38个文件以.m脚本为主覆盖算法核心模块、核函数计算、拉普拉斯矩阵构造、训练预测与实验对比等另含C源码与头文件以及数据、图形界面、编译辅助文件整体仅155KB结构清晰。当前已有108人学习可作为算法入门的实用参考。通过运行实验脚本可直接观察二维样本上的分类效果并能与流形学习方法及工具对照加深对局部几何结构在分类中作用的理解。适合论文复现、课程设计或算法二次开发。1. LapSVM 这套流形正则化代码能让你少写三层封装第一次接触 LapSVMLaplacian 支持向量机时我手里是一批只有 3% 标签的传感器数据标准 SVM 怎么调都过拟合。LapSVM 的思路是把传统 SVM 的间隔最大化目标和数据的图拉普拉斯结构揉成一个优化问题让分类边界沿着数据流形走而不是被少量标签带偏。ManifoldLearn 这个 MATLAB 包把邻接矩阵、拉普拉斯矩阵、核矩阵、训练、预测和可视化全部摊开适合两类人想搞清楚半监督 SVM 原理的研究生和需要把 LapSVM 接进自己分类流程的工程师。它不是给你一个黑匣子而是把从数据到模型的每一环都摆在脚本里。2. 从拉普拉斯矩阵到 LapSVM 优化目标先看懂这套代码的组织方式2.1 邻接矩阵、相似度矩阵和拉普拉斯矩阵到底谁先谁后LapSVM 区别于普通 SVM 的地方不是核函数而是它额外引入了一个图结构。数据点被看成图的顶点两个点之间有没有边取决于它们离得够不够近。ManifoldLearn 里adjacency.m就是干这件事的它读入样本矩阵返回一个邻接矩阵 AA(i,j) 非零表示第 i 个样本和第 j 个样本在局部邻域内相关。这里有个选型问题用什么度量判断“相关”。包里同时给了euclidean.m和cosine.m对应欧氏距离和余弦相似度。我一般是这样选的二维或三维 toy 数据、图片像素特征用欧氏距离文本 TF-IDF 或方向特征用余弦相似度。欧氏距离对绝对尺度敏感特征没有做标准化的时候余弦相似度往往更稳。adjacency.m通常还会带一个 kNN 参数也就是每个点只看最近的 k 个邻居避免全连接把远处样本也拉进来。拿到 A 之后下一步是laplacian.m。这个脚本做的是把邻接矩阵变成拉普拉斯矩阵 L。常见做法是先算度矩阵 DD 的对角线是每行或每列的和然后有三种形式可选未归一化的 LD-A对称归一化的 LI-D^{-1/2}AD^{-1/2}以及随机游走归一化的 LI-D^{-1}A。包里的laplacian.m一般会留一个 normalize 参数让你选。第一次读代码时我建议你直接看懂未归一化版本再去看归一化版本否则容易被 I 矩阵和 D 的负半次幂绕晕。拉普拉斯矩阵真正的作用是度量一个函数在图上“变化得有多剧烈”。如果两个相邻样本的模型输出差很多f^T L f 就会很大反过来如果分类结果沿着数据流形平缓变化这一项就很小。LapSVM 要做的正是把这种“局部平滑性”写成惩罚项。你可以在 MATLAB 里做一次最直观的验证% 读入 2moons 数据构造最简拉普拉斯矩阵 load(2moons.mat); X double(X); A adjacency(X, 6, 1.0); % 6 近邻sigma1.0 L laplacian(A, 0); % 0 表示未归一化 f randn(size(X,1), 1); % 任意一个定义在样本上的函数 smoothness f * L * f;代码里adjacency(X, 6, 1.0)是最常见的调用姿势第二个参数是近邻数 k第三个参数是构造边的宽度参数。laplacian(A, 0)的第二个参数我习惯写成0明确告诉脚本不要归一化。f * L * f就是前面说的平滑度标量值越大说明函数 f 在图上震荡越厉害。这个量会在后面的目标函数里作为惩罚项出现。2.2 从标准 SVM 到 LapSVM一个优化目标里塞进了两类信息标准 SVM 只做一件事找一个超平面让有标签样本的间隔最大化。它的目标函数可以写成“结构风险 经验风险”结构风险对应权重的二范数经验风险对应 hinge 损失。LapSVM 在这里加了一个流形正则项目标函数变成了三块min f 的 hinge 损失 gamma_A 乘以 f 在再生核希尔伯特空间里的范数 归一化系数乘以 f^T L f。前两项大家很熟等价于 C-SVM。第三项就是拉普拉斯正则gamma_I 控制它占多大权重。gamma_A 越大模型越迁就有标签样本但容易过拟合gamma_I 越大模型越强调相邻样本输出一致标签样本少的时候尤其管用。两者不是同一个量纲所以不能只调一个而是要一起看。这套包里的lapsvm.m就是解这个优化问题的核心。它没有像常见库那样走 SMO 或者梯度下降而是用了一个更直接的思路根据表示定理最优解 f 可以写成所有样本点上核函数的线性组合于是原问题变成了求解一个组合系数向量 alpha。lapsvm.m内部会拼出一个大矩阵把核矩阵和拉普拉斯矩阵组合起来然后用线性系统来解。读lapsvm.m的时候我建议你先不要纠结每一行而是盯住四个变量核矩阵 K、有标签样本索引、拉普拉斯矩阵 L、两个超参数 gamma_A 和 gamma_I。只要这四个东西对上了其他代码都是在做矩阵运算。svm.m和tsvm.m也在包里是为了做对照实验用的svm.m是纯监督版本tsvm.m是另一类半监督方法laprlsc.m是拉普拉斯正则化最小二乘分类器。它们共用一套核矩阵和拉普拉斯矩阵所以非常适合横向比较。2.3 文件调用链入口是 demo.m真正干活的是 ml_train 和 ml_test拿到 rar 解压之后不要一上来就运行全部脚本而是先建立一个文件地图。这个包里文件虽然多但大致分成五类数据生成与加载、图与核构建、训练方法、评估与可视化、GUI 入口。文件作用使用阶段generate_toydata.m / 2moons.mat生成或加载双月牙数据数据准备adjacency.m / laplacian.m构建邻接矩阵和拉普拉斯矩阵预处理calckernel.m计算线性、RBF、多项式或预计算核矩阵训练前lapsvm.m / laprlsc.m / tsvm.m / rlsc.m不同分类器的训练核心训练ml_train.m / ml_test.m统一训练和预测封装调用入口plot2D.m / makefigure_moon.m画分类边界和数据点可视化acc.m / f1.m / breakeven.m / pre_rec_equal.m精度、F1、平衡点等指标评估mexGramSVMTrain.c / svmprecomputed.cppC/C 加速版本加速demo.m是入口它做的事一般是加载 2moons 数据调用ml_options.m设置参数然后走ml_train.m最后用plot2D.m画图。ml_options.m是一个配置中心所有超参数都会集中在这里包括 kernel 类型、gamma_A、gamma_I、近邻数等。我看代码的习惯是第一步打开demo.m第二步打开ml_options.m第三步打开ml_train.m这样能最快建立起“配置到训练”的映射关系。mexGramSVMTrain.c和svmprecomputed.cpp是给性能敏感场景留的加速口子。如果你只是跑通流程完全可以直接用 MATLAB 版本但数据量到了几千个样本以上纯 MATLAB 的线性代数解法会明显变慢这时候才需要去编译 mex。我后面第三章会讲怎么把 demo 跑起来第四章专门说 mex 文件带来的坑。3. 把 demo 跑起来数据生成、参数设置和分类边界可视化3.1 环境准备与数据入口这套代码是典型的 MATLAB 工程结构不依赖第三方工具箱但你需要一个能编译 mex 的 MATLAB 环境。我通常用的是 R2018b 之后的版本老版本也能跑只是mex命令和新版本有点差异。第一步先把包里的所有 .m、.mat、.c、.cpp 文件放进同一个目录然后把这个目录加入 MATLAB 路径。addpath(你的路径/LapSVM_ManifoldLearn); load(2moons.mat); whoswhos是用来查看数据变量名和维度的。不同来源的 2moons.mat 格式不完全一样有的存成 X 和 Y有的存成 data 和 labels有的行列是反的。我每次都会先跑whos确认 X 到底是“样本数乘特征数”还是“特征数乘样本数”。这个看起来很小的检查能省掉后面所有维数不匹配的报错。如果这个压缩包里没有现成的 2moons.mat可以用generate_toydata.m现场生成。双月牙数据是最经典的半监督 toy 数据两个类别各自是一条弧形弧与弧之间有重叠区域只用两三个标签很难把边界定准但无标签样本能把流形结构补出来。generate_toydata.m一般会输出一个结构体或两个变量我习惯生成后立刻保存成 mat方便多次实验复用。if ~exist(2moons.mat,file) [X, Y] generate_toydata(200, 100, 0.2); save(2moons.mat, X, Y); end这段代码的意思是如果当前目录没有 2moons.mat就调用生成函数创建 200 个样本其中两类各约 100 个0.2 是两类弧线之间的噪声宽度。保存之后后续实验每次加载的都是同一份数据避免随机噪声影响对比结论。做算法对比时固定数据比重新生成数据更重要否则你分不清是算法变好了还是数据换了一组。3.2 ml_options.m 里的核心参数怎么设ml_options.m是整个实验的配置中心。打开它你会看到一个大的 struct里面字段通常包括 method、kernel、gamma_A、gamma_I、neighbors、sigma 等。先别急着改所有字段只盯下面这几个参数含义我常用的初始值method训练方法lapsvm / laprlsc / tsvm / svmlapsvmkernel核类型linear / rbf / poly / precomputedrbfsigmaRBF 核宽度1.0neighbors邻接矩阵 kNN 近邻数6gamma_A有标签样本的损失权重1.0gamma_I流形正则强度0.1一个很容易犯的错误是把 gamma_I 设成 0。gamma_I0 时LapSVM 就退化成了普通 SVM你等于花了两倍时间跑了一个标准分类器。反过来gamma_I 设得太大比如 100模型会过度平滑有标签样本全被无标签结构带偏训练集精度都会掉。RBF 核的 sigma 我一般先按数据平均距离来估计计算所有样本两两欧氏距离的中位数sigma 取这个中位数附近的值。calckernel.m里如果你传了kernelrbf它会拿 X 和 X 算 Gram 矩阵对角线上是 1非对角线上是 exp(-distance^2 / sigma^2)。sigma 太小核矩阵接近单位阵模型学不到邻居信息sigma 太大所有点都长得一样分类器没有判别力。这个参数和 gamma_I 是联动的第一次不要追求最优跑通再调。3.3 完整跑一个 LapSVM 实验下面这段是简化后的实验脚本我改过很多次最后固定成这个结构% run_lapsvm_demo.m load(2moons.mat); [X, Y] deal(double(X), double(Y)); opt ml_options(method, lapsvm, ... kernel, rbf, ... sigma, 1.0, ... neighbors, 6, ... gamma_A, 1.0, ... gamma_I, 0.1); model ml_train(X, Y, opt); [pred, score] ml_test(model, X, opt); acc mean(pred(:) Y(:)); fprintf(LapSVM accuracy: %.4f\n, acc); figure; plot2D(X, pred, Y);ml_train是统一封装它会根据opt.method分发给lapsvm.m或laprlsc.m。返回的 model 里保存的是组合系数 alpha、偏置 b、核矩阵和拉普拉斯矩阵这些训练产物。ml_test用同一套核函数计算新样本与训练样本之间的核值再和 alpha 做内积得到预测标签。这里要注意Y的编码方式不同脚本可能用 0/1也可能用 -1/1。LapSVM 的推导通常基于 -1/1如果数据里是 0/1分类边界出来可能完全反掉我后面会专门讲这个坑。plot2D是这套包最有价值的部分之一。它会在二维平面画等高线把分类边界直观画出来。对于双月牙数据你会看到标准 SVM 画出的边界是一条直线或简单曲线而 LapSVM 的边界会沿着两个月牙之间的空隙去走这是因为无标签样本把流形结构“撑”起来了。如果你看不到这个效果先别怀疑算法去检查 gamma_I 是不是被设成了 0或者 y 标签编码是不是反了。4. LapSVM 避坑指南核矩阵不对称、标签编码与 mex 文件不匹配4.1 三类最容易让新手翻车的运行错误现象 1运行 demo.m 时报错Invalid MEX-file指向 mexGramSVMTrain.mexglx 或 mexGramSVMTrain.dll。这几乎是每个下载这套代码的人都会遇到的第一道坎。包里同时出现了 .mexglx、.dll、.cpp、.c 多种后缀说明原作者在不同平台上编译过但老版本 MATLAB 在 Linux 32 位下生成的 .mexglx在现代 Windows 或 64 位 MATLAB 里根本加载不了。原因很简单mex 文件是编译后的二进制和操作系统、MATLAB 版本、编译链强绑定。解决方法是不要用旧二进制直接在本机重编。打开 MATLABcd 到文件目录执行mex mexGramSVMTrain.cpp mex svmprecomputed.cpp如果编译报错找不到头文件检查svm.h是否在当前目录。这部分是标准 C/C新版 MATLAB 自带 MinGW 或 MSVC 都能编过。注意如果 MATLAB 里没装支持的编译器mex 会提示你没有可用的 C 编译器这不是代码的问题是环境配置的问题。现象 2训练完成后精度奇低plot2D 画出的边界乱成一片或者只把样本分成两个模糊的团。这通常是核矩阵和拉普拉斯矩阵的数据对齐出了问题。calckernel.m计算核矩阵时默认假设 X 的每一行是一个样本但2moons.mat有时候为了可视化方便会把 X 存成 2 行乘 N 列。一旦行列颠倒核矩阵就不是对称阵拉普拉斯矩阵的维度也对不上。解决方法是先whos看维度再在脚本开头统一加一行X X;或者X X;保证后续所有函数拿到的是同一套约定。我自己的血泪经验是任何半监督代码拿到手第一步永远是写一个断言检查size(K,1)size(K,2)size(L,1)。现象 3demo 跑通了换到自己数据集上lapsvm.m 直接报内存不足。LapSVM 需要构建一个 N 乘 N 的核矩阵其中 N 是所有样本数包括无标签样本。无标签样本越多矩阵越大。5000 个样本的核矩阵就是 5000 乘 5000 的 double200MB 起步这还只是核矩阵一个变量。加上拉普拉斯矩阵和中间临时矩阵很容易把内存吃满。解决方法是先降采样或者用包里提供的可扩展近似思路把无标签样本分块分别计算核矩阵而不是一次性全算。另一个实用办法是先用svmprecomputed.cpp这条预计算核路径把核矩阵算好后复用到多次训练里减少重复分配。4.2 调参层面的坑标签编码、半监督比例和评估指标现象 4LapSVM 精度比普通 SVM 还低尤其在标签数很少的时候。很多人会怀疑是算法坏了但更常见的原因是标签编码和优化目标不匹配。LapSVM 的 hinge 损失默认面向 /-1 标签如果你把正常样本标成 0模型会把 0 当成一个类别来学边界自然不对。解决方法是训练前把标签统一映射到 -1 和 1并把这个映射保存下来预测后再映射回去。还有一点容易被忽略有标签样本的索引在训练脚本里必须是已知的LapSVM 不负责猜测哪些样本有标签它靠一个 index 向量区分有标签和无标签部分。如果索引传错等于告诉模型所有样本都有标签那就退化成了普通 SVM。现象 5用 acc 评估不平衡数据明明 F1 很差acc 却很高。包里同时给了 acc.m、f1.m、breakeven.m、pre_rec_equal.m这不是堆文件而是给你不同评估视角。LapSVM 处理双月牙这种均衡数据时acc 够用但真实分类任务里正负样本往往 9:1acc 会被多数类主导。有个很典型的场景正样本召回率只有 30%但整体 acc 有 92%看起来很好实际模型完全不能用。解决方法是至少同时看 acc 和 f1breakeven.m在信息检索场景里对应查准率等于查全率的点。评估脚本不是装饰换数据前先想清楚你要优化哪个指标。现象 6gamma_A 和 gamma_I 同时调半天找不到规律最后随手设了一组。这个现象的本质是两个参数互相纠缠。gamma_A 决定有标签样本的影响力gamma_I 决定流形结构的影响力。我把两个参数分开调先固定 gamma_I0调 gamma_A 到普通 SVM 的最优范围然后把 gamma_A 固定在稍微偏小一点的值比如普通最优的 1/2再逐步加大 gamma_I观察无标签样本带来的增益。这样调虽然不能保证全局最优但能让你看清每个参数各自管什么。网格搜索也可以但要在同一份固定的数据划分上做不然搜索过程本身也在引入噪声。5. 把 LapSVM 改造成可复用分类器预计算核、laprlsc 与自定义核的接法5.1 预计算核矩阵与 svmprecomputed 的内在逻辑svmprecomputed.cpp存在的意义是很多实验里核矩阵只需要算一次但要在不同超参数下反复训练。每次训练都重算 N 乘 N 的核矩阵是巨大的浪费。预计算核的做法是先用calckernel.m把核矩阵算出来再把它直接喂给训练函数训练函数不再访问原始特征只消费核矩阵。% 预计算核的典型流程 load(2moons.mat); X double(X); K calckernel(rbf, X, X, 1.0); % 只算一次 opt ml_options(method, lapsvm, kernel, precomputed); opt.gamma_A 1.0; opt.gamma_I 0.1; model ml_train(K, Y, opt); % 训练时不再传原始 Xkernelprecomputed告诉后面的函数第一个参数已经是核矩阵不是特征矩阵。这里最容易犯的错误是训练时传了 K预测时却忘了用训练样本和新样本之间的交叉核而是又传了一个方阵。预测阶段的核矩阵应该是“新样本数乘训练样本数”的矩形不是方阵。你可以把svmprecomputed.cpp理解成一个只吃核矩阵的分类器它内部会做和lapsvm.m类似的线性代数求解但省掉了每轮重算核矩阵的开销。5.2 封装成 train / test 两个函数原始脚本是实验导向的不是工程导向。我自己会把核心流程封成两个函数这样在真实数据集上调用时心智负担小很多function model build_model(X, y, opt) X double(X); y double(y(:)); y(y ~ 1) -1; % 统一到 -1/1 K calckernel(opt.kernel, X, X, opt.sigma); A adjacency(X, opt.neighbors, opt.graph_sigma); L laplacian(A, opt.normalize); model.X X; model.K K; model.L L; model.y y; model.opt opt; model.alpha []; model.b 0; end这个build_model只负责把所有矩阵算好并保存是“训练前准备”。注意里面我强制把标签转成 -1/1这个动作放在封装层做比在每个实验脚本里写一遍稳得多。kernelrbf时opt.kernel和opt.sigma会被传给calckernel如果用自定义核可以在这里加一个分支直接把calckernel替换成你自己的核函数。function [pred, score] predict_model(model, Xtest) Ktest calckernel(model.opt.kernel, Xtest, model.X, model.opt.sigma); score Ktest * model.alpha model.b; pred sign(score); endpredict_model的关键是Ktest的形状行数是新样本数列数是训练样本数这样才能和 alpha 做矩阵乘法。score 大于 0 的判为正类小于 0 的判为负类。如果原始数据标签是 0/1最后还要做一次pred(pred1)1; pred(pred0)0的映射。封装的好处是你换数据集时不需要重新理解lapsvm.m内部结构只要把build_model的 X 和 y 换掉就行。5.3 laprlsc、tsvm、rlsc 什么时候换着用包里有这么多个训练方法不是因为作者无聊而是半监督算法各有适用边界。rlsc.m是正则化最小二乘分类器它把 SVM 的 hinge 损失换成平方损失求解变成一个纯线性系统速度比 SVM 快但在标签噪声大的时候不够稳。laprlsc.m是它的拉普拉斯正则化版本和 LapSVM 共享同一套图结构训练更快适合第一次粗筛。tsvm.m是直推式 SVM它会对无标签样本做硬假设把无标签样本当成待分类测试集来优化和 LapSVM 的软正则思路不同。我的选型经验是数据量在几千以内、需要和论文对照用lapsvm.m数据量上万、只求快速看趋势用laprlsc.m如果类别边界本身就不连续TSVM 的硬假设容易把无标签样本推到错误一侧这时候 LapSVM 的软正则更稳。你可以把这几个方法看成同一套核矩阵和拉普拉斯矩阵上的不同求解器换方法时只改opt.method一个字段其余代码不用动这是这套包设计得很方便的地方。6. 用 2moons 数据验证半监督增益一个最小可复现实验前面读代码、跑 demo、踩坑最后都要落到一个问题上LapSVM 到底比普通 SVM 好在哪。最干净的做法是在 2moons 上做标签数量扫描实验。固定所有超参数只改变有标签样本数量从 2 个到 20 个分别跑 SVM 和 LapSVM记录测试精度。load(2moons.mat); X double(X); Y double(Y); nList [2 4 6 10 20]; accSvm zeros(length(nList), 1); accLap zeros(length(nList), 1); for i 1:length(nList) nLabel nList(i); rng(1); % 固定随机种子 idx randperm(size(X,1), nLabel); % 随机挑有标签样本 Ylabel zeros(size(X,1), 1); Ylabel(idx) Y(idx); % 其余视为无标签 optSvm ml_options(method, svm, kernel, rbf, sigma, 1.0); modelSvm ml_train(X, Ylabel, optSvm); predSvm ml_test(modelSvm, X, optSvm); accSvm(i) mean(predSvm(:) Y(:)); optLap ml_options(method, lapsvm, kernel, rbf, ... sigma, 1.0, neighbors, 6, ... gamma_A, 1.0, gamma_I, 0.1); modelLap ml_train(X, Ylabel, optLap); predLap ml_test(modelLap, X, optLap); accLap(i) mean(predLap(:) Y(:)); end table(nList, accSvm, accLap, VariableNames, {nLabel,SVM_acc,LapSVM_acc})这段实验有一个微妙的地方Ylabel中未选中的样本标签被设成 0表示“不知道”而不是“负类”。如果包里的ml_train不是按这个约定识别无标签样本你需要改成传入一个 index 向量。这一点不同版本实现有差异跑之前先看ml_options.m或ml_train.m的开头注释。固定rng(1)特别重要否则每次随机挑选的标签不一样实验不可复现。数据量小的时候随机种子对结果影响很大换一个种子可能趋势就变了所以正式实验至少要跑 5 个种子取平均。正常情况下你会看到两种结果。nLabel 很小比如 2 或 4 时普通 SVM 只能学到一条非常粗糙的边界甚至直接把所有样本归到一边LapSVM 因为有无标签样本撑住流形结构边界会沿着两个月牙的间隙走精度通常高出几个点到十几个点。随着 nLabel 增加到 20两者差距逐渐缩小因为标签足够多时监督信息已经能确定边界无标签样本的贡献被稀释。这个趋势说明 LapSVM 的增益主要来自“标签极少”的场景而不是所有半监督数据上都能吊打 SVM。做完这个实验我通常会顺手把gamma_I再扫一遍比如从 0.01 到 10 做对数网格画出精度曲线。如果曲线在 gamma_I 较大时依然保持高位说明数据流形结构确实强如果 gamma_I 一加大精度就崩说明邻接矩阵构造有问题或者 sigma 选得不对。从那以后我每次拿到新的半监督数据都会强制先跑一遍 2moons 基线确认同一套代码、同一组参数在 toy 数据上能复现预期效果再上真实数据。这套流程帮我省掉了至少十次“算法有问题”的误判希望也能帮到你。本文还有配套的精品资源点击获取