ArcFace 相似度阈值别再抄 0.8 了

ArcFace 相似度阈值别再抄 0.8 了:我用一次实测把它改成 0.45

做人脸去重 / 人脸检索时,你是不是也写过 if similarity > 0.8: 判为同一个人? 我一开始就是这么写的,结果上线后一个重复都没查出来。 这篇文章记录我从"抄阈值"到"自己标定阈值"的完整过程,以及怎么给这个功能做一次可信的评测。


一、出事:审计上线,查出 0 个重复

我在做一个学生刷脸考勤系统,其中一个功能是脸库审计: 定期检查库里有没有「同一个人被录成了多个学号」。

实现思路很常规:把所有 512 维人脸特征取出来,两两算相似度, 跨学号却相似度很高的,就判定为重复注册。

阈值我按网上常见资料写的是 0.8 。代码跑通了,接口返回 200, duplicate_pairs: 0 ------ 看起来一切正常。

问题在于:我提前往库里塞了 3 张同一个人的照片,分别挂在 3 个不同学号下。 也就是说,正确答案应该是「至少 3 对重复」,而它告诉我 0 对。

更隐蔽的是:它不报错。 接口正常、流程正常、日志正常,只是永远查不出东西。 这种"静默失效"是最难发现的 bug。


二、排查:别猜,把分布打出来

我没有继续调参试运气,而是加了一个接口,把库里所有向量两两算一遍余弦相似度, 直接把结果打印出来看:

python 复制代码
# 关键就三步:取全部向量 → L2 归一化 → 矩阵相乘
embeddings = np.asarray([v["embedding"] for v in vectors], dtype=np.float64)
normalized = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-8)
sim = normalized @ normalized.T          # 归一化了,点积就是余弦相似度

结果(我真实库里的数字):

关系 实测余弦相似度
同一个人的不同照片 0.547 / 0.559 / 0.568
不同人 最高 0.207,多数在 0.0 上下

同一人最高才 0.568,而我设的门槛是 0.8。 这不是阈值调得"有点松",是门槛根本不可能被触发------永远返回 0 个。


三、为什么是 0.45~0.65,而不是 0.8+

三个原因,缺一不可:

1. 我用的是已经 L2 归一化过的特征

insightface 的 normed_embedding 是 512 维且已经归一化 的向量。 两个单位向量做点积,结果就是余弦相似度,取值区间是 [-1, 1]。 在这种口径下,同一人的不同照片通常落在 0.45~0.65。

2. 人脸不是指纹

同一个人的两张照片,会受光照、角度、表情、清晰度影响。 余弦相似度本身就到不了 0.9 ------ 那是"同一张图"才有的分数。

3. 0.8 这个数字来自别的口径

常见资料里的 0.8+,通常对应:

  • 未归一化的向量做内积(数值范围完全不同),或
  • 用欧氏距离 / 其他度量,或
  • 换了一个模型(不同模型的特征分布差异很大)

把 A 口径的阈值套到 B 口径上,就是这个 bug 的全部原因。


四、改法:把"标定"变成一个可复现的流程

我没有把 0.45 直接写死,而是做了两件事:

4.1 先给一个看分布的接口

python 复制代码
GET /api/audit/similarity-matrix     # 返回 N×N 相似度矩阵 + top 配对

任何人在自己的数据上,都能先看真实分布,再决定阈值。

4.2 阈值做成可调参数 + 明确写清口径

python 复制代码
SAME_STUDENT_THRESHOLD   = 0.40   # 同一学号内,低于此值 → 脏数据(可能录错人)
CROSS_STUDENT_THRESHOLD  = 0.50   # 不同学号间,高于此值 → 重复注册
CLUSTER_DISTANCE_THRESHOLD = 0.60 # 余弦距离阈值(1-相似度),≈ 相似度 0.40 聚成一簇

调参不用改代码:

ini 复制代码
GET /api/audit/duplicates?cross_student=0.45

改完之后,那 3 张同人照片立刻被查出来了。


五、顺手做了个聚类:比两两比对更有说服力

两两比对只能告诉你"A 和 B 像",层次聚类能告诉你"这一撮人是同一个人":

python 复制代码
condensed = 1.0 - sim_matrix                       # 余弦距离
np.fill_diagonal(condensed, 0.0)
from scipy.spatial.distance import squareform
z = linkage(squareform(condensed, checks=False), method="average")
labels = fcluster(z, t=CLUSTER_DISTANCE_THRESHOLD, criterion="distance")

为什么用层次聚类而不是 KMeans? 因为 KMeans 要你先告诉它有几个簇,而"库里到底有几个人"恰恰是未知数。 层次聚类不需要预设 K,按距离阈值切就行。

用它可以识别三类异常:

  1. 同一学号内相似度 < 0.40 → 脏数据(这张脸可能录错了人)
  2. 不同学号间相似度 > 0.50 → 疑似一人多号
  3. 某个簇里混进了多个学号 → 强证据的重复注册

六、怎么证明它真的有效:一次踩坑后的评测

"能查重"这种话谁都会说,所以我补了一个评测脚本。 第一次跑出来是「召回 100%,但有 4 个误报」------我没有改判定把它变绿,而是去查为什么。

结果发现问题出在我的测试数据标错了 : 合成组用了跟负样本同一个人 的照片,审计把他们判为重复其实是判对了, 却被我算成了误报。是 ground truth 错了,不是审计错了。

于是改成三条硬约束:

  1. 独立评测库 :用环境变量把数据库和向量库指到临时目录, 不碰真实数据,也不和后端抢 Milvus Lite 的单进程文件锁

    python 复制代码
    os.environ["SQLITE_URL"]     = "sqlite:///" + str(EVAL_DIR / "attendance.db")
    os.environ["MILVUS_DB_PATH"] = str(EVAL_DIR / "milvus_lite.db")
  2. 身份唯一:先用特征把素材聚成身份(>0.45 视为同一人), 强制「一个身份只进一个组」

  3. 负样本独立性校验:入库前验证两两相似度 < 0.40,确认彼此真的不是同一个人

最终结果:

指标 结果
构造异常组 4(1 组真实同人照片 + 3 组翻转/提亮增广)
命中 4
召回率 100%
误报的跨组配对 0
负样本 3 个,被误报 0 个

规模限制必须说清楚 :我手上能检出人脸的素材只有 9 张, 这是小样本的正确性验证,不是大规模基准测试。


七、三条能带走的经验

  1. 任何阈值都是「模型 + 度量 + 预处理」三者的函数,换任何一个都要重新标定。 看到别人写 0.8,先问一句:他的向量归一化了吗?用的什么距离?
  2. 先打分布,再定阈值。 加一个能打印相似度矩阵的接口, 比你在那儿试 0.6 / 0.7 / 0.8 有效一百倍。
  3. "不报错但结果不对"的 bug 最危险。 静默失效不会报警, 唯一的防线是构造你已知答案的测试数据。

附:一个自查清单

上线人脸去重/检索前,问自己四个问题:

  • 我的向量做了 L2 归一化吗?(决定相似度落在哪个区间)
  • 我用的度量是余弦、内积还是欧氏距离?(0.8 只对其中一种成立)
  • 我在自己的数据上打过相似度分布吗?
  • 我有已知答案的测试数据吗?(不然你不知道它是"真没查出"还是"压根没工作")

项目代码已开源:gitee.com/flowerjan10... (含端到端测试脚本与本文提到的评测脚本 scripts/audit_recall_test.py) :我用一次实测把它改成 0.45 做人脸去重 / 人脸检索时,你是不是也写过 if similarity > 0.8: 判为同一个人? 我一开始就是这么写的,结果上线后一个重复都没查出来。 这篇文章记录我从"抄阈值"到"自己标定阈值"的完整过程,以及怎么给这个功能做一次可信的评测。 一、出事:审计上线,查出 0 个重复 我在做一个学生刷脸考勤系统,其中一个功能是脸库审计: 定期检查库里有没有「同一个人被录成了多个学号」。 实现思路很常规:把所有 512 维人脸特征取出来,两两算相似度, 跨学号却相似度很高的,就判定为重复注册。 阈值我按网上常见资料写的是 0.8。代码跑通了,接口返回 200, duplicate_pairs: 0 ------ 看起来一切正常。 问题在于:我提前往库里塞了 3 张同一个人的照片,分别挂在 3 个不同学号下。 也就是说,正确答案应该是「至少 3 对重复」,而它告诉我 0 对。 更隐蔽的是:它不报错。 接口正常、流程正常、日志正常,只是永远查不出东西。 这种"静默失效"是最难发现的 bug。 二、排查:别猜,把分布打出来 我没有继续调参试运气,而是加了一个接口,把库里所有向量两两算一遍余弦相似度, 直接把结果打印出来看: # 关键就三步:取全部向量 → L2 归一化 → 矩阵相乘 embeddings = np.asarray(v\["embedding" for v in vectors], dtype=np.float64) normalized = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-8) sim = normalized @ normalized.T # 归一化了,点积就是余弦相似度 结果(我真实库里的数字): 关系 实测余弦相似度 同一个人的不同照片 0.547 / 0.559 / 0.568 不同人 最高 0.207,多数在 0.0 上下 同一人最高才 0.568,而我设的门槛是 0.8。 这不是阈值调得"有点松",是门槛根本不可能被触发------永远返回 0 个。 三、为什么是 0.450.65,而不是 0.8+ 三个原因,缺一不可: 1. 我用的是已经 L2 归一化过的特征 insightface 的 normed_embedding 是 512 维且已经归一化的向量。 两个单位向量做点积,结果就是余弦相似度,取值区间是 -1, 1。 在这种口径下,同一人的不同照片通常落在 0.45 0.65。 2. 人脸不是指纹 同一个人的两张照片,会受光照、角度、表情、清晰度影响。 余弦相似度本身就到不了 0.9 ------ 那是"同一张图"才有的分数。 3. 0.8 这个数字来自别的口径 常见资料里的 0.8+,通常对应: 未归一化的向量做内积(数值范围完全不同),或 用欧氏距离 / 其他度量,或 换了一个模型(不同模型的特征分布差异很大) 把 A 口径的阈值套到 B 口径上,就是这个 bug 的全部原因。 四、改法:把"标定"变成一个可复现的流程 我没有把 0.45 直接写死,而是做了两件事: 4.1 先给一个看分布的接口 GET /api/audit/similarity-matrix # 返回 N×N 相似度矩阵 + top 配对 任何人在自己的数据上,都能先看真实分布,再决定阈值。 4.2 阈值做成可调参数 + 明确写清口径 SAME_STUDENT_THRESHOLD = 0.40 # 同一学号内,低于此值 → 脏数据(可能录错人) CROSS_STUDENT_THRESHOLD = 0.50 # 不同学号间,高于此值 → 重复注册 CLUSTER_DISTANCE_THRESHOLD = 0.60 # 余弦距离阈值(1-相似度),≈ 相似度 0.40 聚成一簇 调参不用改代码: GET /api/audit/duplicates?cross_student=0.45 改完之后,那 3 张同人照片立刻被查出来了。 五、顺手做了个聚类:比两两比对更有说服力 两两比对只能告诉你"A 和 B 像",层次聚类能告诉你"这一撮人是同一个人": condensed = 1.0 - sim_matrix # 余弦距离 np.fill_diagonal(condensed, 0.0) from scipy.spatial.distance import squareform z = linkage(squareform(condensed, checks=False), method="average") labels = fcluster(z, t=CLUSTER_DISTANCE_THRESHOLD, criterion="distance") 为什么用层次聚类而不是 KMeans? 因为 KMeans 要你先告诉它有几个簇,而"库里到底有几个人"恰恰是未知数。 层次聚类不需要预设 K,按距离阈值切就行。 用它可以识别三类异常: 1. 同一学号内相似度 < 0.40 → 脏数据(这张脸可能录错了人) 2. 不同学号间相似度 > 0.50 → 疑似一人多号 3. 某个簇里混进了多个学号 → 强证据的重复注册 六、怎么证明它真的有效:一次踩坑后的评测 "能查重"这种话谁都会说,所以我补了一个评测脚本。 第一次跑出来是「召回 100%,但有 4 个误报」------我没有改判定把它变绿,而是去查为什么。 结果发现问题出在我的测试数据标错了: 合成组用了跟负样本同一个人的照片,审计把他们判为重复其实是判对了, 却被我算成了误报。是 ground truth 错了,不是审计错了。 于是改成三条硬约束: 1. 独立评测库:用环境变量把数据库和向量库指到临时目录, 不碰真实数据,也不和后端抢 Milvus Lite 的单进程文件锁 os.environ"SQLITE_URL" = "sqlite:///" + str(EVAL_DIR / "attendance.db") os.environ"MILVUS_DB_PATH" = str(EVAL_DIR / "milvus_lite.db") 2. 身份唯一:先用特征把素材聚成身份(>0.45 视为同一人), 强制「一个身份只进一个组」 3. 负样本独立性校验:入库前验证两两相似度 < 0.40,确认彼此真的不是同一个人 最终结果: 指标 结果 构造异常组 4(1 组真实同人照片 + 3 组翻转/提亮增广) 命中 4 召回率 100% 误报的跨组配对 0 负样本 3 个,被误报 0 个 规模限制必须说清楚:我手上能检出人脸的素材只有 9 张, 这是小样本的正确性验证,不是大规模基准测试。 七、三条能带走的经验 1. 任何阈值都是「模型 + 度量 + 预处理」三者的函数,换任何一个都要重新标定。 看到别人写 0.8,先问一句:他的向量归一化了吗?用的什么距离? 2. 先打分布,再定阈值。 加一个能打印相似度矩阵的接口, 比你在那儿试 0.6 / 0.7 / 0.8 有效一百倍。 3. "不报错但结果不对"的 bug 最危险。 静默失效不会报警, 唯一的防线是构造你已知答案的测试数据。 附:一个自查清单 上线人脸去重/检索前,问自己四个问题: 我的向量做了 L2 归一化吗?(决定相似度落在哪个区间) 我用的度量是余弦、内积还是欧氏距离?(0.8 只对其中一种成立) 我在自己的数据上打过相似度分布吗? 我有已知答案的测试数据吗?(不然你不知道它是"真没查出"还是"压根没工作") 项目代码已开源:gitee.com/flowerjan10... (含端到端测试脚本与本文提到的评测脚本 scripts/audit_recall_test.py)

相关推荐
yunhuibin1 分钟前
AI分类任务种类
人工智能·自然语言处理·分类
东坡肘子2 分钟前
参与学生移动应用大赛有感 -- 肘子的 Swift 周报 #161
人工智能·swiftui·swift
天天被压力4 分钟前
【A股分钟级高频数据完全手册 #11】券商实时行情数据接口:实时交易(券商源)接入与字段
java·人工智能·python
唐老板8 分钟前
agent 撑爆 Git 之后,两家改的不是同一层
人工智能·ai编程
天天被压力17 分钟前
【A股分钟级高频数据完全手册 #12】股票五档盘口数据接口:买卖五档实时盘口解析
java·人工智能·python
爱敲代码的小黄8 小时前
神经网络入门指南:从 CNN、RNN 到 Attention
人工智能·面试·程序员
一只爱撸猫的程序猿9 小时前
我看着一个 AI 引擎在 14 天里,从"记忆"走进了"搜索"
人工智能·后端·架构
我登哥MVP10 小时前
DeepSeek-Harness-Super-Agent-学习笔记
人工智能·深度学习·神经网络·机器学习·自然语言处理·agent
LucianaiB10 小时前
【元宝搜索开发者征文大赛】AI时代,学得慢就不用学?用元宝搜索打造一个教程保质期,确保每一个教程符合你的现状
前端·人工智能·ai编程
冬奇Lab11 小时前
一天一个开源项目(第256篇):Anatomy —— 让 Claude 为技术概念「发明一台机器」,画成可交互的等距插画
人工智能·开源