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)

相关推荐
snakeshe10101 小时前
Linux 下 Ollama 完整实操指南:安装、部署大模型与开启远程访问
人工智能
阿里云大数据AI技术1 小时前
StarRocks 与 Fluss 用一条 SQL 查实时与历史
人工智能·sql
AICoder码农王1 小时前
如何写 Skill 第6课,分诊五连问:skill 看病指南
人工智能
绿意新酿1 小时前
长线学习-4
人工智能
大明二代1 小时前
Mastra RAG 进阶:使用 Drizzle ORM 和 pgvector 实现文档版本管理与 Version-aware Retrieval
人工智能
stormzhangV1 小时前
AI 平权后,最该重视的 8 种能力
人工智能·aigc·产品
RockChinQ1 小时前
QQ 好友申请与被拉进群:两道审批交给同一个 Agent
人工智能
江润舟1 小时前
万物|炼器:从零手搓工业级旋转目标检测网络·卷5 —— YAML蓝图与parse_model动态引擎(一)
人工智能·深度学习
用户1917291270831 小时前
企业 Agent 接不进现有系统?跨厂商协议网关选型与接入自查
人工智能