ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

无监督谱回归测试阶段实现详解:投影矩阵、均值对齐与阈值设定

无监督谱回归测试阶段实现详解:投影矩阵、均值对齐与阈值设定 无监督谱回归USR这名字听起来挺学术但其实它要解决的问题很朴素给一堆没有标签的样本建图、找低维结构再让新来的样本也能快速落到同一个低维空间里。我最近在帮业务团队落地一版 USR 模型整整两天时间都耗在测试阶段不是模型训练不收敛而是新样本上线后各种对不齐。这篇把测试阶段的实现细节完整过一遍包括投影矩阵怎么存、均值怎么对齐、阈值怎么定、报错怎么排查。如果你正在做流形学习、无监督降维或者手头有聚类模型想上线那这篇内容应该能帮你少踩几个坑。1. 先搞清楚 USR 建模流程测试才不会糊涂1.1 USR 到底在干什么无监督谱回归USR本质上是“谱嵌入 线性回归”的组合。它先从无标签样本出发构造一个反映样本间局部关系的近邻图然后求图拉普拉斯矩阵的特征向量得到一组描述样本流形结构的低维坐标。到这里它和谱聚类很像区别在于USR 不会停在这一步而是把得到的谱嵌入当作回归目标学习一个从原始特征空间到低维谱空间的线性投影矩阵。有了这个矩阵新样本就不需要重新建图、重新分解特征一次矩阵乘法就能映射进同一个低维空间。具体拆开来看整个建模过程大致是四步对训练样本 X 构造 k 近邻图得到邻接矩阵 A 和度矩阵 D。计算图拉普拉斯 L D - A做特征分解取前 k 个非平凡特征向量作为谱嵌入 Y。把 Y 当作监督信号在 X 上做岭回归求解投影矩阵 W使得 XW 尽量接近 Y。训练结束后保存 W、均值、特征顺序等状态测试阶段直接用 (X_test - mean) 乘以 W 得到投影坐标。这套方案在无监督场景里的价值很明显。传统的谱聚类只能处理已经参与建图的样本来了一个新样本你没法简单判断它应该归到哪个簇因为图结构是固定的新样本进来会改变整体图拓扑。USR 通过回归这一步把“图谱结构”压缩成了一个线性变换新样本只要做同样的矩阵乘法就能拿到和训练样本可比的低维坐标。用生活化的方式理解谱聚类是在一栋楼里靠认识邻居来定位USR 是先把楼里所有房间的位置画成一张平面图再教你看坐标这样你到了任意一个新房间只要对一下坐标就知道自己在哪儿。1.2 测试阶段为什么值得单独拿出来讲从训练到测试很多算法工程师习惯把“测试”理解成一行model.predict(X_test)。在 USR 模型里这样做会出问题。原因不在于预测函数本身而在于 USR 的测试依赖一系列训练阶段产生的中间状态均值、投影矩阵、特征列顺序、聚类中心、阈值。任何一项没有保存或者没有按训练时的规则复用测试结果就会出现系统性偏差。我在实际项目里见过一种特别典型的情况同事把训练代码里的“建图 特征分解 回归”整套逻辑复制到了测试脚本里每次来一批新数据就先拿这批数据和历史数据一起重新建图再跑一遍谱回归。这样做的结果是离线评估分数很高因为测试样本被“偷偷”参与到了图结构建模中相当于泄漏了测试集信息但真正上线后新样本是一次一条进来的根本没有完整图可以重建模型表现立刻崩掉。所以测试阶段需要单独拎出来讲核心是回答三个问题新样本怎么映射到谱空间映射完之后怎么做决策评估时怎么保证和训练阶段出发点一致后面所有内容都围绕这三件事展开。2. 测试阶段前必须确认的四个“工件”2.1 投影矩阵 W 与偏置项测试阶段最核心的工件就是投影矩阵 W。训练时如果采用中心化方式也就是先把 X 减去均值得到 Xc然后求解 Y Xc W那么测试样本必须使用同一个均值做中心化再乘 WZ_test (X_test - mean_train) W这里有两种容易写错的情况。第一种训练时把均值存下来了但测试时忘了减直接乘 W第二种训练时用的是“在 X 后加一列 1”的仿射形式求出来的是一个带 bias 的投影矩阵但测试时没有加对应的一列常数。两种写法本身没有绝对的对错但一旦训练和测试不一致低维坐标就会整体偏移后续聚类中心、最近邻判断全都会出错。为什么强调这个因为谱空间里的坐标不是原始特征它没有绝对零点。训练时所有样本都做了一次平移新样本如果不做完全相同的平移就会落在训练样本分布之外哪怕 W 本身很准也没有意义。保存模型时W 的 shape 也要检查清楚。如果原始特征有 d 维投影到 k 维谱空间W 的 shape 应该是 d 行 k 列。如果没加分号拿 (d, k) 的矩阵去乘 (n_test, d) 的输入结果不是报错就是语义反了。2.2 训练集均值与标准化参数除了中心化均值很多场景还会做标准化比如 z-score也就是减均值再除以标准差。USR 本身对特征的尺度敏感因为近邻图和拉普拉斯矩阵都建立在特征距离上。如果训练时用 StandardScaler 做了标准化那么测试阶段一定要复用训练集保存下来的 scaler不能重新对测试集做 fit。这里要特别警惕数据泄漏。标准的错误写法是# 错误对测试集单独 fit scaler_test StandardScaler() X_test_scaled scaler_test.fit_transform(X_test)原因很简单测试集的标准差、均值一旦被重新计算测试数据本身的信息就进入了投影过程。离线评估时这种情况会让指标虚高因为测试集整体参与调整线上单独一条样本进来时你不可能靠它自己算出有意义的均值和标准差于是同样的代码逻辑在线上就失效。正确的做法是把训练集 fit 好的 scaler 保存下来测试时只做 transformZ_test (X_test - scaler.mean_) / scaler.scale_ Z_test Z_test model.W_如果你像我一样手写 USR没有用 sklearn 的 scaler那至少要保证 mean_ 和 scale_ 是和模型一起持久化的不要临时从某个全局变量里取。2.3 谱子空间维数与特征向量顺序谱回归里用到的特征向量并不是越多越好。图拉普拉斯最小的特征值通常对应着常数方向或连通分量的标记直接拿来回归会造成投影退化。工程习惯是跳过第一个最小特征向量从第二个开始取。这里有一个必须保存的信息训练时到底取了哪几个特征向量对应原始特征分解中的哪几个索引。你可能觉得这不重要因为训练时已经把它们回归成 W 了测试阶段不需要再去算特征向量。但保存这个索引的意义在于复现和排查。比如你换了 LAPACK 版本或者从稠密特征分解换成稀疏特征分解特征值的排序可能变化如果只记得“取前 k 个”不同的库里含义可能不完全一样。保存eig_indices_后一旦需要重新生成 W或者对比两版模型你能立刻确认是不是选向量选错了。另外如果训练时用的是规范化拉普拉斯 L_sym那么“跳过第一列”这一条不一定永远成立需要以测试时的投影分布为准。我强烈建议在训练日志里输出选中的特征值并在测试阶段做一次投影后的形状检查确认 Z 的均值和方差分布和训练时一致避免向量选择出错还浑然不知。2.4 标签映射与决策规则USR 是无监督方法它本身不产出一个有语义的类别名称只产出低维坐标。实际使用中我们通常会在训练集的投影坐标上跑 KMeans得到若干个簇中心然后把每个簇当成一个类别。测试阶段的决策规则一般是新样本投影到谱空间后计算它到各个簇中心的距离距离最近的簇对应的标签就是预测结果。这就要求训练结束后把“簇中心”和“簇标签映射表”一起保存。如果业务上给每个簇起了名字比如“高活跃用户”“流失风险用户”那么簇中心数组的第 i 行对应的业务标签必须显式存下来而不是靠顺序硬猜。我之前就吃过这个亏训练代码里重新跑了一次 KMeans簇中心的顺序变了测试阶段拿旧的映射表去套新的簇中心所有标签全错位。最后改成把centroid_labels_和centroids_一起打包存进模型对象才彻底解决。3. 测试阶段实现全流程逐步拆解3.1 封装状态一个对象带走全部测试阶段最难的部分不是算法而是状态管理。USR 涉及的中间状态有 mean_、W_、centroids_、centroid_labels_、eig_indices_、训练集投影后的距离阈值。我强烈建议不要在各个脚本里散落保存而是封装成一个完整的模型对象用 joblib 或 pickle 整体持久化。有人可能会说把 W_ 单独存成一个 npz 文件不就行了短周期实验确实可以但项目一旦进入联调和维护期特征列顺序变更、样本量变化、版本迭代都会发生。散落的工件很难保证同时更新。封装成对象后加载一个文件就恢复了所有状态测试代码也只需要调用对象的transform和predict不会出现某个参数没更新的情况。3.2 标准化与新样本映射测试阶段的标准化流程可以拆成五步加载模型对象拿到保存的 mean_、scale_如果有、W_、centroids_。按训练时的特征顺序对齐测试数据缺失的候选用 0 填充类别列按照训练时编码后的列名扩展。使用训练集的 mean_ 和 scale_ 对 X_test 做中心化和标准化不能重新计算。把标准化后的矩阵乘 W_得到谱空间坐标 Z_test。对 Z_test 的形状做一次断言确保列数等于保存的投影维数。第五步看起来多余但在实际工程里非常有用。特征工程一旦改了列顺序或者有人在清洗逻辑里多删了一列错误不会在标准化时报出来因为矩阵乘法可能仍然形状匹配但列的顺序和含义已经错了。加上断言至少能在问题最早暴露时给你一个明确提示。3.3 投影后的三种预测方式拿到 Z_test 之后选择什么方式做类别判断直接决定测试阶段的复杂度和效果。我整理过三种常用方式最近簇中心训练阶段对投影后的数据做 KMeans保存 k 个簇中心。测试时计算 Z_test 到每个簇中心的欧氏距离取最近的簇作为预测结果。优点是快缺点是假设每个簇在谱空间里近似凸。KNN 投票训练阶段不只保存簇中心而是保存所有训练样本的投影坐标 Z_train。测试时找 Z_test 在 Z_train 里的 k 个最近邻由邻居投票决定类别。这种方法能适配复杂的簇形状但存储和计算成本更高。带阈值拒识不管用最近簇中心还是 KNN都额外设定一个距离阈值。如果新样本到最近簇中心/最近邻的距离超过阈值就判断为“未知样本”不强行归类。实际项目中如果簇的形状比较规范最近簇中心已经足够如果数据分布复杂可以把 KNN 作为候选方案如果业务本身对“误分类”敏感强烈建议加拒识。线上不会像离线那样整齐新样本很可能落在所有已知簇的边缘甚至外部强行归类不如让模型承认“我不知道”。3.4 阈值与异常检测场景阈值怎么定我常用的简单方法是训练阶段先得到所有训练样本到各自簇中心的距离取 95 百分位数作为默认阈值。不要只用均值加三倍标准差因为投影后的距离分布往往右偏均值和标准差受极端值影响大95 分位数更稳定。self.threshold_ np.percentile(self.train_dist_, 95)测试阶段计算新样本到最近簇中心的距离如果大于阈值就标记为 unknown。如果业务需要更高召回可以调低百分位如果更看重精度可以调高。这个阈值必须和模型一起保存不能上线后临时现算否则不同环境下同一个样本可能得到不同结论。对于异常检测场景还可以用马氏距离替代欧氏距离利用训练投影坐标 Z_train 估计协方差矩阵计算测试样本到簇中心的马氏距离。谱空间的维度通常不高比如 2 到 20 维协方差矩阵是可控的计算成本不会太高。马氏距离的好处是考虑了各维度的尺度和相关性比欧氏距离更健壮。4. Python 示例从训练保存到测试加载4.1 完整类实现为了把上面这些思路串起来这里给一个可用的精简版 USR 类。代码偏教学满足中小规模数据没问题千万级样本量在生产环境跑建议把特征分解部分换成稀疏版本的eigsh同时把图构建改成批量近似算法。import numpy as np from sklearn.cluster import KMeans from sklearn.neighbors import kneighbors_graph from sklearn.metrics.pairwise import euclidean_distances class USR: def __init__(self, dim2, alpha1e-4, n_neighbors5, n_clustersNone): self.dim dim self.alpha alpha self.n_neighbors n_neighbors self.n_clusters n_clusters def fit(self, X, yNone): X np.asarray(X, dtypenp.float64) if self.n_clusters is None and y is not None: self.n_clusters len(np.unique(y)) self.mean_ X.mean(axis0) Xc X - self.mean_ # k近邻图并对称化 A kneighbors_graph(Xc, n_neighborsself.n_neighbors, modeconnectivity, include_selfFalse) A (A A.T) / 2.0 A np.asarray(A.todense()) D np.diag(A.sum(axis1)) L D - A eig_vals, eig_vecs np.linalg.eigh(L) order np.argsort(eig_vals) # 跳过最小的常数方向取接下来的 dim 个向量 self.eig_indices_ order[1:self.dim 1] Y eig_vecs[:, self.eig_indices_] # 岭回归Y Xc W d Xc.shape[1] reg self.alpha * np.eye(d) self.W_ np.linalg.solve(Xc.T Xc reg, Xc.T Y) # 对投影坐标聚类得到簇中心和伪标签 Z_train self.transform(X) km KMeans(n_clustersself.n_clusters, n_init10, random_state0) self.pseudo_labels_ km.fit_predict(Z_train) self.centroids_ km.cluster_centers_ self.centroid_labels_ self.pseudo_labels_ # 记录训练样本到所属中心的距离用于阈值 self.train_dist_ np.min( euclidean_distances(Z_train, self.centroids_), axis1) self.threshold_ np.percentile(self.train_dist_, 95) return self def transform(self, X): X np.asarray(X, dtypenp.float64) return (X - self.mean_) self.W_ def predict(self, X, return_distFalse): Z self.transform(X) dist euclidean_distances(Z, self.centroids_) idx np.argmin(dist, axis1) labels self.centroid_labels_[idx] min_dist np.min(dist, axis1) if return_dist: return labels, min_dist return labels这段代码里有一个细节要注意self.centroid_labels_暂时只是 KMeans 给出的簇编号。如果业务上有自定义标签比如 0 号簇代表“高效客户”1 号簇代表“沉默客户”在保存之前需要把centroid_labels_替换成业务标签数组并且保证长度等于n_clusters。4.2 训练后保存关键工件训练完成后不要只保存 W_要用 joblib 把整个模型对象保存下来import joblib model USR(dim8, alpha1e-3, n_neighbors10, n_clusters6) model.fit(X_train, y_train) # y_train 不参与建图和回归只用于确定簇数/评估 joblib.dump(model, usr_model.joblib)这里y_train完全可以传空因为 USR 用不到标签。如果传了真实标签也只会在代码里被用于设置n_clusters或后续离线评估不会影响图构建和回归。有些同学会担心无监督模型加了标签是不是就不纯了放心真正使用的只是簇的数量。保存对象的好处是加载时不需要挨个检查 mean_ 和 W_ 是否齐全。加载代码简单成一行loaded joblib.load(usr_model.joblib)4.3 测试阶段调用与评估模拟线上调用代码风格如下loaded joblib.load(usr_model.joblib) # X_test 必须经过和训练时完全相同的特征清洗 X_test_aligned align_features_for_test(X_test, train_feature_names) labels, dists loaded.predict(X_test_aligned, return_distTrue) unknown_mask dists loaded.threshold_如果测试集有真实标签想算准确率或 NMI不能直接把labels和真实标签做 accuracy因为簇编号是任意选的0/1 的语义和真实标签不对齐。建议用归一化互信息 NMI它对标签映射不敏感如果业务上非要看 accuracy要先做一次匈牙利匹配把模型簇编号映射到真实类别编号上。from sklearn.metrics import normalized_mutual_info_score nmi normalized_mutual_info_score(y_test, labels)这一步经常被忽略但离线评估里如果直接算 accuracy通常会得到一个偏低甚至接近随机的结果因为簇编号只是容器不是类别语义。5. 常见问题与排查技巧实录5.1 测试报错维度不一致怎么查矩阵乘法报维度错误是 USR 测试阶段最常见的报错。看到ValueError: matmul: Input operand 1 has a mismatch不要先怀疑算法先检查三个东西特征列顺序是否和训练时一致。测试集是否漏了某些类别编码后的列或者多了新类别列。W_ 的存储 shape 和加载后的实际 shape 是否有人改动过。我建议在模型对象里额外存一份feature_names_测试加载后先打印出来再和当前数据的列名比对。如果是 Pnadas DataFrame最简单的方法是用reindex(columnsfeature_names_, fill_value0)对齐这样既保证顺序也自动补上缺失列。5.2 标准化参数泄漏测试阶段重新计算均值和标准差是离线指标虚高、线上失效的最大元凶。有一个判断标准如果一条新样本单独进来你用来做标准化的均值和标准差是不是和训练时一模一样如果不一样说明你的代码里存在泄漏。我习惯把标准化逻辑写进模型类而不是放在外部 pipeline 里。这样模型加载后标准化用的 mean_ 和 scale_ 天然跟随模型走不存在“训练时用 A 文件测试时用 B 文件”的情况。很多出问题的地方不是不知道要复用训练统计量而是不同脚本里埋了多个标准化的位置改漏了一个。5.3 投影结果符号翻转图拉普拉斯特征向量的符号是任意取的。同样的数据换一个 LAPACK 版本或者换一种特征分解算法某个维度的特征向量可能正好变号。这意味着训练出来的 W_ 某一列也可能整体变号投影坐标 Z 的某一列也相应变号。这不是 bug但如果拿两个不同环境训出的 W_ 做对比或者想可视化投影坐标可能会看到某个维度方向相反。解决方案不是去改 W_而是在对比模型时先做符号对齐对每一列判断训练样本投影坐标的正负分布如果负数数量超过一半就把这一列乘以 -1。对预测来说这个操作不影响簇中心和标签的判断因为距离是负号不敏感的。5.4 测试效果远不如训练集先查这几处如果 USR 离线测试分数还不错上线后效果下降我会按这个顺序排查先检查特征分布漂移。USR 强烈依赖训练时的近邻图结构如果新样本和训练样本分布差距太大线性投影很难适应。可以对比测试样本和训练样本每个特征的均值、方差如果差距明显考虑做数据版本管理。再检查距离阈值是否合理。训练时如果用了 99 分位数作阈值测试集里被判成 unknown 的比例可能很高需要按业务容错率重选。接着检查特征口径。模型的很多问题不在模型本身而在训练样本的清洗规则和线上样本的清洗规则不一致。最后检查是否误用了归一化还是标准化。USR 图构建对尺度敏感训练时用标准化、测试时误用归一化投影结果会完全跑偏。6. 对测试阶段实现的几点个人体会这套流程里最容易被低估的其实是“状态一致性”。算法公式再复杂写起来也就几十行代码真正让项目延期的是训练和测试对状态的假设不一致。我之前带的项目里USR 的投影矩阵 W_ 已经在模型文件里了但另一个同学在自己的脚本里重新定义了一个全零矩阵覆盖了加载结果导致线上全部预测成同一个簇。后来我统一把模型对象封装成完整的类不再允许外部随意改内部字段这类问题才基本消失。如果你要在自己的项目里落地 USR 测试阶段我建议从两个小切口开始第一把训练保存用的模型类写完整不接受“只保存 W_”的临时方案第二在测试脚本里加一个简单的断言确认输入特征顺序和训练日志对得上。这些小动作不会提升模型理论分但能避免绝大多数线上排障时间。最后再分享一个小技巧处理大批量测试样本时不要一条一条调用predict最好一次性传入整批数据先算出 Z再对 Z 做距离计算和阈值判断。矩阵乘法在批处理下比循环更高效而且更容易统一日志格式。把投影坐标缓存下来后面无论是做可视化还是调试阈值都不用重新跑一遍模型。USR 的测试阶段本质上不是复杂的算法题而是一道工程纪律题只要训练时用的每一项状态都能在测试阶段准确定位并复用模型上线就是水到渠成的事。
返回列表