无监督谱回归(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,那么测试样本必须使用同一个均值做中心化,再乘 W:
Z_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 保存下来,测试时只做 transform:
Z_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, dim=2, alpha=1e-4, n_neighbors=5, n_clusters=None): self.dim = dim self.alpha = alpha self.n_neighbors = n_neighbors self.n_clusters = n_clusters def fit(self, X, y=None): X = np.asarray(X, dtype=np.float64) if self.n_clusters is None and y is not None: self.n_clusters = len(np.unique(y)) self.mean_ = X.mean(axis=0) Xc = X - self.mean_ # k近邻图,并对称化 A = kneighbors_graph(Xc, n_neighbors=self.n_neighbors, mode="connectivity", include_self=False) A = (A + A.T) / 2.0 A = np.asarray(A.todense()) D = np.diag(A.sum(axis=1)) 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_clusters=self.n_clusters, n_init=10, random_state=0) 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_), axis=1) self.threshold_ = np.percentile(self.train_dist_, 95) return self def transform(self, X): X = np.asarray(X, dtype=np.float64) return (X - self.mean_) @ self.W_ def predict(self, X, return_dist=False): Z = self.transform(X) dist = euclidean_distances(Z, self.centroids_) idx = np.argmin(dist, axis=1) labels = self.centroid_labels_[idx] min_dist = np.min(dist, axis=1) 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(dim=8, alpha=1e-3, n_neighbors=10, n_clusters=6) 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_dist=True) 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(columns=feature_names_, fill_value=0)对齐,这样既保证顺序,也自动补上缺失列。
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 的测试阶段,本质上不是复杂的算法题,而是一道工程纪律题:只要训练时用的每一项状态都能在测试阶段准确定位并复用,模型上线就是水到渠成的事。