标签不足场景下的BNM预测增强:邻域一致性与自举置信
标签只有几十条剩下的几万条数据难道只能干看着这是近两年我被同行问得最多的问题之一。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, dim128, use_pcaTrue): # 关键scaler/PCA 只在无标数据上拟合避免有标数据泄漏 X_all np.vstack([X_lab, X_unlab]) if use_pca and X_all.shape[1] dim: pca PCA(n_componentsdim, random_state0).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, k20): nn NearestNeighbors(n_neighborsk1, metriccosine).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, keepdimsTrue) 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, B30, keep0.7, seed0): 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每折的样本数虽然少但把所有折的结果合并起来算总精度会稳一些。要判断“涨的这点到底是不是真的”可以粗略算一下置信区间如果测试规模 n100、准确率 p0.85标准误是 sqrt(0.85×0.15/100)≈0.03695% 区间就是 ±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}每格记录伪标签数量和探针精度通常能看到一块明显的“甜区”比如 k15、阈值 0.85 附近伪标签量在种子数的 8 到 12 倍、探针精度最高。找到甜区之后再微调间隔阈值间隔阈值的影响比置信阈值小得多一般调两档就够了。还有一个反直觉的发现k 偏小的伤害比 k 偏大更大。k10 时结果对随机种子很敏感换一次划分能差 5 个点k30 时虽然精度不最高但稳定得多。所以如果只能选一个我宁愿选偏大一点的 k 加严格的筛子而不是小 k 加宽松的筛子。另外提醒一句任何在探针集上做的参数选择都会让最终报告的数字偏乐观。如果这个结果要拿去写报告或者做决策最好留一小份标签从头到尾一次都不碰最后只在那上面验一次。标签本来就少还要挤出一份做最终验证听着浪费但比起拿一个虚高的数字去汇报然后上线翻车这点浪费很值。5.4 几个容易被忽略的工程细节内存和耗时上图是最大的开销。二十万样本、k20idx 和 dist 各占 3200 万个数float32 下约 128MB还能接受到百万级就得换近似近邻HNSW 之类别硬扛。传播那段用 Python 循环写会慢几十倍务必向量化y_full[idx]这种花式索引是性能关键。另一个细节是孤立点处理。无标样本的 k 个邻居如果全都没有标签投票全零这些样本永远进不了伪标签集。在类别分布稀疏的区域这很常见正常现象不用管但如果这类样本占比超过 60%说明你的带标签样本覆盖太窄得先做一轮主动学习式的补标而不是继续加迭代轮数。最后一个我自己觉得挺管用的技巧给伪标签做加权而不是等权。伪标签样本在训练学生模型时的损失权重可以按它通过筛子时的综合评分置信 × 间隔 × 稳定来设而不是一律设成 1。这个改动在标签极少的场景下提升明显因为它让模型在“很确定的伪样本”和“勉强及格的伪样本”之间做了区分。实现上就是给 CrossEntropyLoss 传一个 per-sample weight一行代码的事但效果比再多跑两轮迭代好。我在三个不同量级的项目上把 BNM 这套流程跑下来最深的体会是这类工具的价值不在于它用了多新的方法而在于它把“什么条件下才能相信一个预测”这件事拆得足够细逼着你去看伪标签的类别分布、稳定度分布、探针精度曲线。这些检查项本身比方法更有用——哪怕你最后不用它的代码把这几道筛子和监控点照搬到自己手写的伪标签脚本里也能立刻感受到差别。至于参数别指望一套通吃k 和置信阈值这两个数每换一个数据集都得重新看一遍花二十分钟扫一张表比在线上盲调两天强得多。