标签只有几十条,剩下的几万条数据难道只能干看着?这是近两年我被同行问得最多的问题之一。BNM就是冲着这件事来的一个开源项目——一个面向标签不足场景的预测增强工具。它的定位很明确:不训练基础模型、不重做特征工程,而是在已有模型和已有特征之上,用邻域一致性加自举置信的方式,把大量无标签样本的预测结果“扶正”一遍,让原本只有 70 多分的模型在标注量极少的条件下往上抬一截。第一次看到这个项目时我并没有太在意,因为“伪标签 + 自训练”这套东西早就被讲烂了;真正让我把它拉下来跑了一遍的原因,是它把置信度估计这件事单独拎出来做了一层,而不是简单拿 softmax 的最大值当阈值——这个细节决定了它在类别不平衡和小样本下到底能不能用。这篇内容适合三类人看:手上标注预算被卡死、只能标几百条甚至几十条的算法同学;做业务侧冷启动、拿不到标注资源的工程同学;以及想搞清楚半监督那套方法在真实数据上为什么会翻车、翻在哪里的人。下面我按“它想解决什么—为什么这么设计—核心细节怎么抠—完整跑一遍—出问题怎么查”的顺序,把我自己复现和调参的过程摊开讲。
1. 先把问题说清楚:标签不足到底卡在哪
1.1 标签不足不是“数据少”,而是“单位标签成本高”
很多人把标签不足理解成数据量小,这两件事完全不同。数据量小是样本只有几百条,模型容量上不去;标签不足是样本有几万条、几十万条,但能拿到人工标注的只有几十到几百条。后者的麻烦在于:你有充足的特征信息,却没有足够的监督信号去约束模型往哪个方向收敛。举个生活化的类比,这就像你带一个新人做质检,样品堆满仓库,但你只能抽空给他指认二十个合格品和二十个次品,剩下的要他自己判断。他一开始的判断依据必然是偏的,但仓库里那些样品的共性(颜色、纹理、尺寸分布)他是能看见的——半监督方法做的,就是让他先按自己的理解分一遍,再把分得特别有把握的那部分当新的示范,反过来修正自己的判断标准。
这件事在业务上极其常见。电商的类目纠错、工业质检的缺陷分型、内容平台的违规识别、医疗影像的病灶标注,全都属于“标注一条几十块、几万条标不起”的形态。我见过最极端的例子是某公司的一个缺陷检测项目,正样本只有 43 条,负样本堆了六万多条,最后是靠一轮一轮的伪标签把可用训练集滚到了八千条量级才把模型跑起来的。所以这个场景不是学术玩具,是真实生产里绕不开的坎。
1.2 BNM 的定位:站在已有模型之上做“预测增强”
需要先把预期摆正。BNM 不是一个新的网络结构,也不是一个从零训练的大模型框架,它更像一个后处理层 + 迭代器:输入是你已有的特征(或者已有模型输出的 embedding)、一小撮带标签样本、一大堆无标签样本;输出是增强后的预测结果,以及一批经过筛选、可以直接并入训练集的伪标签样本。这个定位带来的好处是它跟模型解耦——你今天用逻辑回归、明天换梯度提升树、后天换成预训练模型的线性探针,BNM 那一层基本不用改,只要能把样本映射到一个有度量意义的向量空间里就行。
它明确不管的事情也得说清楚:不做预训练权重、不做数据清洗、不做标注质量校验。如果你的那几十条标签本身就是错的、或者采样严重偏离真实分布,BNM 不会救你,反而会把错误放大——因为邻域传播的本质是“相信相似样本应该同标签”,标签本身脏了,这个假设就是毒的。我在第二个项目上就吃过这个亏,后面第 5 节会细讲。
1.3 什么样的场景适合上手,什么样的别碰
判断标准其实只有一条:你的特征空间里,同类样本是否真的聚得起来。这个可以很便宜地验证——拿那几十条标签,画个二维降维图,或者直接算一下同类样本的平均余弦相似度和异类样本的平均余弦相似度,如果两者差距明显(比如 0.82 对 0.41),那邻域一致性假设成立,BNM 大概率有效;如果两者都在 0.7 上下纠缠,说明特征本身没有区分度,这时候上任何半监督方法都是浪费时间,该做的是换特征或者换预训练模型。
我一般会先跑一个“一近邻”基线:对每个带标签样本,看它在特征空间里最近的邻居是不是同类。这个准确率如果低于 75%,就别急着上复杂流程了。这个检查只花五分钟,能省掉后面两天的瞎折腾。另外,类别极度不平衡(比如正负比 1:200)且少数类样本只有个位数时也要谨慎,那种情况下邻域投票几乎一定会把少数类淹掉,得先做重采样或者改用按类分别设阈值的策略。
2. 整体设计思路拆解:为什么是“邻域一致性 + 自举置信”
2.1 三条常见路线的取舍对比
面对标签不足,业内主流就三条路,我把它们的性格特点列一下,这决定了 BNM 为什么选了一条折中的路。
| 路线 | 代表做法 | 优点 | 硬伤 |
|---|---|---|---|
| 一致性正则 | 对同一样本做两种增强,要求预测一致 | 不需要伪标签,稳定 | 依赖强增强策略,图像领域好用,表格和文本上增强很难设计 |
| 自训练 / 伪标签 | 模型预测高置信样本,加入训练集 | 实现简单,通用性强 | 置信度不可靠时会自我强化错误,几轮就崩 |
| 图传播 / 标签传播 | 在样本相似度图上扩散标签 | 不需要训练,直接利用结构 | 对图质量和 k 值敏感,样本量大时内存吃紧 |
BNM 选的是后两条的杂交:用图上的邻域投票产生初始伪标签,用自举重采样估计每个伪标签的稳定度,再用稳定度而不是原始概率去筛样本。这个组合的逻辑很直白——自训练崩掉的根本原因是“模型对自己的错误过于自信”,而单一模型的 softmax 输出恰恰是最不可靠的置信来源。换成一个样本周围二十个邻居的投票分布,再加上对邻居做多次随机子采样看结果抖不抖,这个置信度就立体多了:它不仅回答“有多确定”,还回答“这个确定是不是靠运气”。
2.2 四段式流程:从建图到早停
拆开看,整个流程是四段。第一段建图:把全部样本(有标 + 无标)映射到同一个特征空间,做归一化,用余弦或欧氏距离找每个样本的 k 个最近邻,形成一张有向图(实践中我会对称化一下,让 i 是 j 的邻居时 j 也是 i 的邻居,减少孤立点)。第二段传播:以带标签样本为种子,在图上做加权投票,得到每个无标签样本的类别分布。第三段筛选:对每个候选伪标签算三个指标——投票置信度、最高类与次高类的间隔、自举重采样下的稳定度,三条同时达标才留下。第四段迭代:把筛出来的伪标签并入训练集,重训学生模型,用学生模型重新算 embedding、重建图、再传播,一般跑三到五轮,靠一个探针集监控精度,掉了就回退。
这个顺序不能乱。我见过有人把“重训模型”放在第一轮之前,也就是先拿几十条标签硬训一个模型再产出 embedding——这在标签极少时非常危险,模型过拟合到那几十条上,产出的 embedding 空间会把同类样本推得更散,后面的图传播就全废了。稳妥的做法是第一轮直接用预训练特征或者无监督降维结果建图,等伪标签攒到一定量(比如超过带标签样本的五倍)再引入重训。这也是我在实际项目里调整最大的一处,原流程默认每轮都重训,我改成了前两轮不重训。
2.3 阈值不是拍脑袋定的:从分位数到类别配平
新手最容易犯的错是把阈值设成 0.9 然后跑不通就骂工具不行。0.9 这个数字在二分类均衡数据上勉强能用,在十类问题上等于把 90% 的样本全砍掉,在多分类长尾数据上更是灾难——头部类随便就能过 0.9,尾部类可能一个都过不了。合理的做法是动态分位数 + 类别配平双管齐下:先按置信度排序,取前 P 分位(我常用的起点是 30%),这是“总量控制”;再对每个类别单独取分位,并且限制每个类别的伪标签数量不超过该类带标签数量的某个倍数(cap_ratio,常用 1.5 到 3),这是“结构控制”。两个约束叠加,能同时避免头部类垄断和伪标签总量失控。
至于稳定度阈值怎么定,我一般直接看自举标准差的分布:取所有候选样本自举标准差的 70 分位作为上限,意思是“抖动最大的那三成不要”。这个方法比拍一个绝对值靠谱,因为它自动适配了你的数据规模和类别数。后面 3.3 节我会给具体的计算方式和代码。
3. 核心细节解析:图、筛子、校准这三件事
3.1 相似度图怎么建才不会一建就废
建图这一步的坑比想象中多。第一个坑是维度诅咒:如果你的 embedding 是 768 维甚至更高,直接算欧氏距离会发现所有样本的距离都差不多,近邻失去意义。解决办法是先做降维,PCA 到 64 到 128 维,或者干脆用余弦相似度(余弦在高维下比欧氏稳健得多)。我自己在文本任务上基本固定用归一化后的余弦,图像特征上会先 PCA 到 128 维再算余弦,实测下来这两种组合在几十个样本的尺度上最不容易出问题。
第二个坑是k 值。k 太小(比如 3),投票完全由两三个邻居决定,噪声极大;k 太大(比如 200),邻域会跨类,投票被稀释。经验公式是 k 取带标签样本数的平方根再乘 1.5 左右,但下限不低于 10、上限不超过 50。比如你有 60 条标签,sqrt(60)≈7.7,乘 1.5 约 11.6,取 15 比较合适。如果无标签样本非常多(十万级),k 可以适当放大到 30,因为大样本下邻域的统计意义更稳。
第三个坑是距离到权重的映射。直接等权投票是最省事但最差的,邻域里第 1 个邻居和第 20 个邻居的分量天差地别。我一般用高斯核w = exp(-d² / (2σ²)),其中 σ 取该样本到第 k 个邻居距离的中位数,这样权重会自动归一化到 [0,1] 且不会因为特征尺度不同而失真。这个细节对结果的影响比很多人以为的大——我在一个两千条的文本分类集上做过对比,等权投票和 σ 自适应高斯核的差距有 4 个点以上。
3.2 伪标签的三道筛子:置信、间隔、稳定
筛伪标签是整个流程里最需要克制的地方。我把它拆成三个独立维度,任何一个不达标就不要,宁可少留。
- 置信度:邻域加权投票后最高类的概率。它回答“多数邻居说是这一类”。
- 间隔(margin):最高类概率减次高类概率。它回答“这个判断是不是含糊”。很多样本置信度 0.85,但第二名 0.14,间隔只有 0.71,实际上仍然清晰;而有些样本置信度 0.55、第二名 0.45,这就是典型的骑墙样本,绝对不能要。间隔阈值我常用 0.6 起步。
- 稳定度:对邻居做 B 次随机子采样(每次保留 70% 的邻居),看 B 次投票结果的一致率。它回答“这个判断是不是靠运气”。这个指标是 BNM 这套思路里我认为最有价值的一环,因为它抓的是模型自己意识不到的那种“脆弱的确定”。
这三个指标的组合还有个隐性好处:能自动识别出边界样本。处在两类交界处的样本,往往置信度中等、间隔小、稳定度低,三个指标同时报警,被剔除得干干净净。而这些样本恰恰是把模型带偏的主要来源。
3.3 置信校准与阈值换算的一个具体算法
如果有人想把这套东西用到神经网络输出上,还有一个额外步骤:温度校准。原始 softmax 在小样本训练的模型上通常过度自信,最高类动辄 0.99,直接拿来当阈值等于全收。做法是在带标签集上(或它的交叉验证折上)拟合一个温度系数 T,使得softmax(z / T)的负对数似然最小。T 大于 1 会把分布压平,T 小于 1 会变得更尖锐。实操中我见过 T 落在 1.8 到 3.5 之间的情况居多,校准后原来 0.99 的样本会降到 0.8 左右,这时候 0.85 的阈值才真正有筛选意义。
温度校准有个前提:必须有独立的校准集,不能拿训练集自己校准,否则 T 会趋近于 1,等于没做。标签本来就只有几十条,怎么挤出一个校准集?我的做法是分层抽出 20% 的带标签样本做校准和探针,剩下 80% 做种子。这个比例不能再高了,种子少于 40 条时传播会不稳。如果标签总数不到 50 条,那就放弃温度校准,改用分位数阈值硬顶,损失一点精度换取流程能跑通。
4. 实操过程:完整跑通一次增强流程
4.1 环境与数据准备
依赖很轻,numpy、scikit-learn、torch(只在需要重训学生模型时用)。数据准备上,我要求三份东西:X_labeled、y_labeled、X_unlabeled。特征来源可以是预训练模型的倒数第二层输出,也可以是 TF-IDF 加 SVD,甚至是业务侧已有的手工特征拼接。关键点只有一个:有标和无标必须用同一套特征提取流程,中间不能有任何只对有标数据做的变换(比如用有标数据拟合的标准化器),否则两个空间的尺度对不上,图会建歪。这是个非常隐蔽的坑,我第二次复现时就栽在这里,图上所有无标样本都聚成一坨。
import numpy as np from sklearn.preprocessing import normalize, StandardScaler from sklearn.decomposition import PCA def prepare_features(X_lab, X_unlab, dim=128, use_pca=True): # 关键:scaler/PCA 只在无标数据上拟合,避免有标数据泄漏 X_all = np.vstack([X_lab, X_unlab]) if use_pca and X_all.shape[1] > dim: pca = PCA(n_components=dim, random_state=0).fit(X_unlab) X_all = pca.transform(X_all) X_all = normalize(X_all) # 余弦相似度必须归一化 return X_all[:len(X_lab)], X_all[len(X_lab):]注意:PCA 只在无标数据上拟合这一步很多人会跳过,理由是“无监督变换不算泄漏”。严格说不算标签泄漏,但它会让有标样本的分布被无标样本主导,如果两者分布本来就有偏移,反而更糟。稳妥做法是两边各拟合一次看结果差多少,差距大就说明分布偏移严重,得先处理偏移问题。
4.2 建图与邻域传播
建图用 sklearn 的 NearestNeighbors 就够,十万级样本内内存扛得住。传播我写成一个纯 numpy 的加权投票函数,可以向量化,二十万样本跑一次大概十几秒。
from sklearn.neighbors import NearestNeighbors def build_graph(X_all, k=20): nn = NearestNeighbors(n_neighbors=k+1, metric="cosine").fit(X_all) dist, idx = nn.kneighbors(X_all) return dist[:, 1:], idx[:, 1:] # 去掉自己 def neighbor_vote(y_full, idx, w, n_classes): # y_full: 全量标签,-1 表示无标 n = idx.shape[0] P = np.zeros((n, n_classes)) marks = (y_full[idx] >= 0) # (n, k) for c in range(n_classes): P[:, c] = (((y_full[idx] == c) * marks) * w).sum(1) s = P.sum(1, keepdims=True) s[s == 0] = 1.0 return P / s权重用 σ 自适应的高斯核,σ 取每个样本到第 k 个邻居距离的 0.6 倍左右(这个系数我用 0.5 到 0.8 扫过,差别不大,0.6 是个稳的值)。传播完你会拿到一个(n_samples, n_classes)的概率矩阵,注意无标样本的邻域里可能一个带标签样本都没有,这些行的概率全零,直接标记为“不可用”,不要送给后面的筛子。
4.3 自举稳定度与伪标签筛选
稳定度这段是整个流程里计算量最大的部分,B 取 30 就够,再大收益很小。实现上就是重复对邻居 mask 采样。
def bootstrap_stats(y_full, idx, w, n_classes, B=30, keep=0.7, seed=0): rng = np.random.default_rng(seed) n = idx.shape[0] votes = np.zeros((B, n, n_classes)) for b in range(B): m = (rng.random(idx.shape) < keep).astype(float) votes[b] = neighbor_vote(y_full, idx, w * m, n_classes) mean_p = votes.mean(0) std_p = votes.std(0) top = mean_p.argmax(1) agree = (votes.argmax(-1) == top[None, :]).mean(0) return mean_p, std_p, agree筛选那一步我建议写成“三道闸 + 类别配平”,把逻辑显式写出来,方便后面调:
| 闸门 | 判据 | 常用起点 | 调松的后果 | 调紧的后果 |
|---|---|---|---|---|
| 置信 | 最高类概率 | ≥ 0.85 | 混入噪声 | 伪标签量不足 |
| 间隔 | 最高 − 次高 | ≥ 0.60 | 骑墙样本入库 | 长尾类被清空 |
| 稳定 | 自举一致率 | ≥ 0.85 | 偶发正确被当可靠 | 只留下极简单样本 |
| 配平 | 每类数量 ≤ cap × 该类种子数 | cap = 2.0 | 头部类垄断 | 尾部类欠采样 |
跑完一轮,我会把留下的伪标签数量、每类的分布、以及它们在探针集上的实际精度都打出来。伪标签精度低于 90% 就不要进下一轮,这是我最硬的一条线。宁可只加两千条高纯度的,也不要加两万条混杂的——后者在你只有几十条真标签的情况下,会把模型的判断标准整个带跑。
4.4 标签极少时怎么评估才不骗自己
这一节我认为比前面所有内容都重要。带标签样本只有五十条的时候,你切出来的“测试集”可能只有十条,十条上算出来的准确率波动能到正负二十个点,任何对比都是噪音。我亲眼见过有人在 12 条测试样本上看到涨了 5 个点就宣布方法有效,换一批数据直接掉 8 个点。
靠谱的评估只有两种。第一种是多次随机划分:把那几十条标签做 20 次不同的分层划分,每次算一个分数,报均值和标准差。第二种是用全部标签做留一或分层 K 折,K 取 5,每折的样本数虽然少,但把所有折的结果合并起来算总精度会稳一些。要判断“涨的这点到底是不是真的”,可以粗略算一下置信区间:如果测试规模 n=100、准确率 p=0.85,标准误是 sqrt(0.85×0.15/100)≈0.036,95% 区间就是 ±7 个点。也就是说,n 只有一百的时候,涨 3 个点完全可能是运气。想把误差压到 ±3 个点以内,测试集至少得四百条。这个账不算清楚,后面所有的调参都是自欺欺人。
还有一个纪律:伪标签绝对不能进验证集和测试集。我习惯在流程开始就把验证集从候选池里物理移除,而不是靠一个布尔 mask 去屏蔽,因为迭代几轮之后 mask 很容易错位。这个教训来自一次事故,某次跑完发现验证精度 0.97,高兴了半天,最后发现是验证样本被当作无标数据传播进去了。
5. 常见问题与排查实录
5.1 效果不升反降的五个典型原因
第一轮跑完精度反而掉,这是最常见的反馈。按我踩过的顺序排,原因大概这么几类:
种子标签脏。前面提过,那几十条标签里只要有两三成是错的、或者采样偏了(比如正样本全来自同一个批次),邻域传播出来的东西就是系统性偏差。判断方法很简单:只看带标签样本之间的近邻一致性,如果同类样本互相都不挨着,说明标签本身有问题,先做清洗。
特征空间和任务不匹配。比如你做的是细粒度分类(手机型号),用的却是通用图像 embedding,那同类样本在空间里根本不聚,邻域投票等于随机。这种情况换更细粒度的特征比调任何参数都有效。
阈值一次性设太松。第一轮就放 80% 的样本进伪标签集,等于用一堆半信半疑的判断去覆盖原本就稀薄的监督信号。正确的节奏是第一轮严格(置信 0.9 以上、总量控制在前 15%),后面几轮看探针精度再逐步放宽。
类别先验被摧毁。伪标签筛完之后如果头部类占了 90%,而真实分布是 60/40,那学生模型学到的是扭曲的先验。这就是 cap_ratio 存在的原因,必须按类限制。
迭代次数过多。自训练的误差是累积的,第五轮之后精度通常开始缓降。我一般跑三轮,第四轮的收益已经是负的。监控手段就是那个探针集,每轮算一次,掉了直接停并回退到上一轮。
5.2 一份可以直接对照的排查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 第一轮精度就掉 | 种子标签脏 / 特征无区分度 | 算同类与异类平均相似度差 | 清洗标签或换特征,别调参 |
| 精度不动 | 伪标签量太少或太同质 | 看伪标签类别分布 | 放宽分位到 40%,降间隔阈值 |
| 前两轮涨,第三轮崩 | 噪声累积 | 看每轮伪标签在探针上的精度 | 减到两轮,收紧稳定度到 0.9 |
| 尾部类完全消失 | 置信阈值对长尾不友好 | 按类统计通过率 | 改按类分位数 + 提高 cap_ratio |
| 内存爆掉 | k 太大或全量距离矩阵 | 看 k 和样本量 | k 降到 20,用 NearestNeighbors 分块 |
| 结果每次不一样 | 自举随机性未固定 | 检查随机种子 | 固定 seed,报多次均值 |
5.3 参数扫描的实操心得
我想单独说说调参这件事,因为它最容易被做成体力活。参数其实只有四个:k、置信阈值、间隔阈值、cap_ratio。四个一起扫会爆炸,正确顺序是先固定后两个、只扫 k 和置信阈值,因为这两个决定了“进多少”。我的做法是画一张二维表,横轴 k 取 {10, 15, 20, 30},纵轴置信阈值取 {0.80, 0.85, 0.90, 0.95},每格记录伪标签数量和探针精度,通常能看到一块明显的“甜区”,比如 k=15、阈值 0.85 附近伪标签量在种子数的 8 到 12 倍、探针精度最高。找到甜区之后再微调间隔阈值,间隔阈值的影响比置信阈值小得多,一般调两档就够了。
还有一个反直觉的发现:k 偏小的伤害比 k 偏大更大。k=10 时结果对随机种子很敏感,换一次划分能差 5 个点;k=30 时虽然精度不最高,但稳定得多。所以如果只能选一个,我宁愿选偏大一点的 k 加严格的筛子,而不是小 k 加宽松的筛子。
另外提醒一句,任何在探针集上做的参数选择都会让最终报告的数字偏乐观。如果这个结果要拿去写报告或者做决策,最好留一小份标签从头到尾一次都不碰,最后只在那上面验一次。标签本来就少,还要挤出一份做最终验证,听着浪费,但比起拿一个虚高的数字去汇报然后上线翻车,这点浪费很值。
5.4 几个容易被忽略的工程细节
内存和耗时上,图是最大的开销。二十万样本、k=20,idx 和 dist 各占 3200 万个数,float32 下约 128MB,还能接受;到百万级就得换近似近邻(HNSW 之类),别硬扛。传播那段用 Python 循环写会慢几十倍,务必向量化,y_full[idx]这种花式索引是性能关键。
另一个细节是孤立点处理。无标样本的 k 个邻居如果全都没有标签,投票全零,这些样本永远进不了伪标签集。在类别分布稀疏的区域这很常见,正常现象,不用管;但如果这类样本占比超过 60%,说明你的带标签样本覆盖太窄,得先做一轮主动学习式的补标,而不是继续加迭代轮数。
最后一个我自己觉得挺管用的技巧:给伪标签做加权而不是等权。伪标签样本在训练学生模型时的损失权重,可以按它通过筛子时的综合评分(置信 × 间隔 × 稳定)来设,而不是一律设成 1。这个改动在标签极少的场景下提升明显,因为它让模型在“很确定的伪样本”和“勉强及格的伪样本”之间做了区分。实现上就是给 CrossEntropyLoss 传一个 per-sample weight,一行代码的事,但效果比再多跑两轮迭代好。
我在三个不同量级的项目上把 BNM 这套流程跑下来,最深的体会是:这类工具的价值不在于它用了多新的方法,而在于它把“什么条件下才能相信一个预测”这件事拆得足够细,逼着你去看伪标签的类别分布、稳定度分布、探针精度曲线。这些检查项本身比方法更有用——哪怕你最后不用它的代码,把这几道筛子和监控点照搬到自己手写的伪标签脚本里,也能立刻感受到差别。至于参数,别指望一套通吃,k 和置信阈值这两个数每换一个数据集都得重新看一遍,花二十分钟扫一张表,比在线上盲调两天强得多。