从 max 到质心,阈值从手感到数据:室友 bug 的最后一块拼图
误认事故的完整修复:把'逐样本取最高分'翻译成统计语言,推出质心是球面上的严格最优解;再用注册库自己当冒充者数据源,让阈值从数据里长出来而不是从手感里。结尾留三个还没想完的问题。
从 max 到质心,阈值从手感到数据:室友 bug 的最后一块拼图
室友被认成我的那篇,结尾挖了个坑:导师说相似度阈值不该拍脑袋,该从数据里算出来。清晰度那篇填了小坑——糊样本进不了门了。这篇填大的:匹配逻辑本身怎么改,以及阈值怎么自动标定。
写完这篇,这个 bug 才算真正修完。
第 0 步:一切的地基——嵌入空间的几何
SFace 这类模型做的事情是:把任何一张对齐的人脸图变成一个 128 维向量,训练目标保证同一个人的不同照片落在一小簇,不同人离得远。归一化之后,所有向量都落在一个 128 维单位球面上,“相似度”就是两个向量的夹角——余弦相似度,也就是单位向量的点积。
这是后面所有决定的前提。我实测的数字直观印证了这个几何:我自己的样本互相之间 0.84–0.91(同簇,夹角小),我和测试者之间约等于 0(近乎正交)。在高维空间里,“正交”就是”毫无关系”——两个随机的高维向量几乎必然近似垂直,这叫高维集中现象。模型说”这两张脸没关系”的方式,是让它们的向量互相垂直。
第 1 步:从事故推出质心
原始 bug 的机制是”一张模糊的注册样本 + 逐样本取最大分”。关键的一步是把它翻译成统计语言:
逐样本取 max,意味着任何一个样本单独就能触发匹配。 max 是对离群点最敏感的统计量——10 个样本里 9 个好的救不了 1 个坏的。
那自然的问题就是:有没有一个统计量,能让坏样本被好样本”投票压制”?答案几乎是脱口而出的:平均。10 个样本平均,单个坏样本的影响被稀释到十分之一。
但”平均”在球面上有一个数学上很漂亮的正当性,不只是直觉。问:球面上哪个点,和我所有注册样本的平均余弦相似度最大?点积对求和是线性的,所以”最大化平均相似度”等价于”找和样本均值向量夹角最小的单位向量”——答案就是均值方向本身,即归一化后的均值。代码里就两行:
centroid = feats.mean(axis=0)
centroid /= np.linalg.norm(centroid)
所以质心不是工程上的取巧,它是”和你所有注册样本整体最像的那个点”的严格数学解。
两个实现细节现在也能自己推出来了:
- 为什么先逐样本归一化再平均:不归一化的话,模长大的样本在求和里权重就大。我要的是每个样本一票,不是”嗓门大的多算”。
- 为什么平均之后还要再归一化:单位向量的平均值一定落在球面内部(模长小于 1),要把它拉回球面,后续的点积才仍然是标准的余弦相似度。
这套东西在文献里早有名字:经典机器学习里叫 Nearest Class Mean 分类器,小样本学习里叫 Prototypical Networks(Snell et al., 2017),人脸评测里叫 template averaging。我”想到”它,本质上是认出了我的 bug 和这些已解问题是同一个问题。
第 2 步:从”阈值该是多少”推出标定
固定阈值 0.363 是 OpenCV 给的默认值,它是在某个公开数据集上调出来的——凭什么对我客厅里的这两个人也合适?
这就引出生物识别的经典框架:任何验证系统里只存在两种比对——genuine(本人 vs 本人的库)和 impostor(别人 vs 你的库),各自形成一个分数分布。阈值就是你在两个分布之间画的一条线:往左移,冒充者更容易混进来(FAR 升高);往右移,本人更容易被拒(FRR 升高)。阈值没有”正确值”,只有你愿意接受的权衡点。
我的场景特殊在:误认(把室友叫成我)比认不出难堪得多。所以宁可 FRR 高一点,也要 FAR 低——阈值应该定在冒充者分布的右尾之外。
没有大规模冒充者数据集怎么办?注册库自己就是一个小型冒充者数据源:A 的每个样本打 B 的质心,就是一次真实的 impostor 比对。取所有跨人比对的最高分(经验上的冒充者天花板),加 0.05 的余量,就是 _calibrate_threshold 的全部逻辑。标定只升不降——两个人的小库估出来的天花板可能偏低,默认值兜底更稳妥。
实测下来:冒充者天花板 -0.017,本人地板 0.78,中间隔着 0.8 的鸿沟。0.363 站在沟里很安全,所以标定跑完保持了默认值。但意义变了:以前它安全是因为运气,现在它安全是因为量过。
这个”画出两个分布、看间隔”的动作,就是 NIST 人脸评测和 ISO/IEC 19795 标准里的标准姿势。我只是在两个人的客厅尺度上,把同一件事做了一遍。
第 3 步:所谓”能推出来”,其实是三个可学的技能
复盘整条链路,没有一步靠灵感,每一步都是可以练的:
- 把 bug 翻译成统计语言。“室友被认错”翻译成”max 对离群点敏感”。这一步最值钱,也最可练——翻译完,解就自己浮出来了。
- 认出同构问题。“多个样本代表一个类”= prototype;“定阈值”= genuine/impostor 权衡。读文献的意义不是背方案,是攒下这本”问题词典”,下次事故发生时能查得到。
- **在自己的数据上验证。**前两步给的只是候选方案,那张相似度表——0.84 到 0.91 的簇、约等于 0 的正交、隔着 0.8 的鸿沟——才是它在我的场景里成立的证据。
留给自己的三个问题
写到这里,又收到三个追问。都有提示,但我还没想到能落笔的程度,先记下来:
一、为什么用均值不用中位数?中位数不是对离群点更鲁棒吗? 提示:想想 128 维里”逐维取中位数”得到的向量,还在不在簇中心方向上。我目前的直觉是:中位数只能一个维度一个维度地取,128 个坐标各自独立选出来的数拼成的向量,未必还指向簇的中心——它不像均值那样有球面上的最优性证明,而且坐标系一旋转答案就变了。再说离群点的活,门口的清晰度门卫已经干了。但这个直觉还没被我算过。
二、注册样本从 10 张涨到 100 张,质心会更好吗?什么情况下反而会变差? 提示:平均压的是随机噪声,压不了系统性偏差。100 张全是同一侧光照的照片,平均掉的是抖动,平均不掉的是光照本身——它会被原封不动地烧进质心里。如果真是这样,那样本要的是多样性,不是数量。
三、阈值标定在什么情况下会失效? 提示:库里只有 2 个人时,“冒充者天花板”估的是谁的天花板?答案是只是我们俩之间的。来一个长得像我的第三人,他的 impostor 分数可能远高于这个天花板。这大概就是”只升不降 + 默认值兜底”存在的理由,也意味着每进一个新人,标定都得重跑。
想清楚哪个,就回来填哪个。
后记
室友 bug 系列到这里算完结了:门口有质检,匹配看质心,阈值从数据里长出来。
一场误认,最后修出来的其实不是三段代码,是三次”把直觉换成可以被检验的东西”。