我把毕设的阈值"拍脑袋"问题,做成了会被数据打脸的实验

54 阅读5分钟

我把毕设的阈值"拍脑袋"问题,做成了会被数据打脸的实验

先交代背景

毕设是一个人脸签到系统:SCRFD 检测 + ArcFace 出 512 维特征 + Milvus-Lite 存向量,FastAPI + Vue3 前后端。功能上除了签到本身,还有两块和"人脸库数据质量"较劲的东西:

  1. 质量门控:照片入库前过 7 条可计算规则(尺寸、倾角、姿态偏移、亮度、清晰度……),不合格直接拦下;
  1. 查重审计:对全库做人脸重复注册检测——"一人多号"靠两两余弦相似度抓,"跨账号异常簇"靠层次聚类抓。

问题是,这两条通道各有一个阈值(相似度阈值 cross_student、聚类距离 cluster_distance)。查了一圈开源实现,阈值基本都写在配置文件里,配一句"根据经验调整"。

我不想在论文里写"根据经验调整"。于是有了这篇文章。

第一步:把"选一个点"变成"选一片解集"

变量隔离的网格扫描:same_student(同人识别阈值)固定 0.40,cross_student 扫 14 档 × cluster_distance 扫 6 档,共 84 档,每档跑完整审计。

结果挺意外——81/84 档召回都是 100% ,其中 34 档存在误报。也就是说:

  • 一级判据(召回 100% 且零误报)筛出 47 档同时满足;
  • 这 47 档在一级指标上完全无法分辨——阈值根本不可唯一辨识。

这里有个容易踩的坑:既然 47 档都好,随手选一个不就行了?我用各向同性的 maximin 距离选过一次,结果它在这片"失败区偏置的网格"上直接退化到最激进的角落档位——被数据否决的第一次。

第二次被否决的是"用单一阈值上下界框定安全区"的论断——两条通道的失败边界根本不重合,单一阈值不成立,换成了双阈值等效面。这轮迭代之后,选点规则长成了四步:

一级判据 → 产出解集 → 冗余可行域收缩 → 单侧余量排序(误报侧余量优先,因为误报会让两个不同的人被判为同一人,代价更高)

按这个规则,评测集选出了 0.55 / 0.50,漏报侧余量 0.015——全表最脆的一维。

剧情反转:验证集把它否了

我单独构造了一套验证集:把同一个人的 6 张照片注册到 6 个学号("一人多号"脏库,两两组合共 C(6,2)=15 对重复),外加 7 个正常人的干净库。然后在两个独立进程里扫阈值(这个坑后面说),每档记两个数:脏库命中、正常库误报。

结果:

cross / dist脏库命中正常库误报
≤0.40 / 0.6015/150
0.45 / 0.6014/150
0.50 / 0.60(代码默认值)14/150
0.55 / 0.50(标定选点)11/150

三个干净的观察:

  1. dist 两档逐对完全同值——验证集上聚类通道无差异,单变量归因很干净;
  1. 召回只随 cross 变,而标定选点 0.55 恰好是脆的那一档;
  1. 有个 0.43/0.60 也能 15/15,但它不在评测网格上、不属于一级解集——按规则必须排除,这正是"规则写死"的意义,不能看见好数字就伸手。

翻车原因也清楚:评测集最弱组内相似度 0.565,演示库最弱配对只有 0.5091——标定选点过拟合到了评测集的宽余量。

把规则写死,让机器替我做决定

这时候最容易干的事是"哦那我们取 0.50 吧"。但这就又回到拍脑袋了,只是这次拍得小声一点。

我做的事是把验证集选点规则预先写死:

在一级解集与冗余可行域内,以"验证集正常库零误报"为硬约束,取验证集召回最高者;并列时沿用二级判据的"误报主序、漏报次序"逐维排序。

规则执行的结果:

  • cross ≤ 0.40 四档并列 → 取 cross 尽大 → 0.40
  • dist 两档无差异 → 取漏报侧余量更大的 → 0.60

最终部署值 0.40/0.60——和代码默认值 0.50/0.60 不一样。这次它是被规则和数据选出来的,不再是默认值的沿用。

顺手踩出来的坑

同进程切库会拿到假数据。 第一版扫描脚本在同一个 Python 进程里先扫脏库再扫正常库,结果"正常库"输出和脏库一模一样——milvus/db 模块第一次 import 时就把路径绑死在脏库上,改环境变量无效。修复:每个场景单开进程。第一次跑出的假 JSON 已删,坑写进了脚本注释。

上传接口原来是裸的。 await file.read() 没有大小上限、没有类型白名单,传个 1GB 文件直接吃满内存。补了个守卫:Content-Type 白名单(415)+ 5MB 上限(413),内容真伪交给图像解码兜底。e2e 还顺手抓出一个测试自身的 bug——multipart 构造函数把 Content-Type 硬编码成 image/jpeg,导致 415 用例永远测不到假类型。

最后的数字

  • pytest 单测 13 例(审计判定逻辑用合成向量精确控制余弦相似度,不碰 Milvus 不跑模型);
  • e2e 68 项全过(含签到闭环:首签落库 → 重复签到幂等同 id → 守卫负例 → 级联清理);

  • 部署值在验证集上 15/15 命中、0 误报。

写在最后

这套东西技术上没有任何高精尖:网格扫描、解集、验证集复核,都是机器学习里最基础的功课。真正值钱的可能只有一个习惯的转变——

"调了个参数"和"阈值由判据决定",中间隔着的不是数学,是愿不愿意让数据有机会对你说不。

论文里最难写的一句是"标定选点被验证集否决"。写完之后反而轻松了:答辩时评委问"你的阈值为什么是这个数",我有一整章可以翻给他看。

项目地址:gitee.com/flowerjan10/face-attendance