임계값 하나로는 '확신'을 표현할 수 없다 (feat. 1등과 2등이 붙어 있을 때)

사진 정리 도구를 만들다가 자동 라벨링 기능을 붙였습니다. 얼굴을 찾고, 임베딩을 뽑고, 비슷한 것끼리 묶고, "이 사진은 A네" 하고 자동으로 딱지를 붙여주는 그거요.
처음엔 당연히 이렇게 짰습니다.
if score >= threshold:
auto_label(photo, top_candidate)
점수가 기준을 넘으면 자동 확정. 안 넘으면 사람이 봐라. 깔끔하죠. 슬라이더 하나 만들어서 대시보드에 꽂아놓고 뿌듯해했습니다.
이게 왜 부족한지 깨닫는 데 시간이 좀 걸렸습니다.
0.85와 0.84가 나란히 있을 때
문제 상황을 그림으로 그려볼게요. 임계값을 0.8로 잡아뒀습니다.
사진 #1 1등: A(0.93) 2등: B(0.41) → 통과
사진 #2 1등: A(0.85) 2등: B(0.84) → 통과
둘 다 통과합니다. 규칙대로면 아무 문제 없어요. 근데 이 둘이 정말 같은 급의 판단인가요?
1번은 "A가 압도적"입니다. 2번은... "A랑 B 중에 굳이 고르자면 A" 예요. 0.01 차이는 확신이 아닙니다. 조명이 조금만 달라도, 각도가 살짝만 틀어져도 뒤집힐 수 있는 숫자죠. 얼굴 매칭에서 1등 후보는 오차 범위 안에서 얼마든지 자리를 바꿉니다.
임계값은 이걸 구분하지 못합니다. 왜냐면 임계값은 "이 후보가 충분히 좋은가" 만 묻거든요. 그런데 제가 진짜 묻고 싶었던 건 하나 더 있었습니다. "이 후보가 다른 후보들보다 충분히 앞서는가."
이건 다른 질문이에요. 그리고 다른 질문은 다른 축이 필요합니다. 스칼라 하나로는 표현이 안 됩니다. (여기서 한참 헤맸습니다. 임계값을 올렸다 내렸다 하면서요.)
슬라이더를 하나 더 만들었습니다
그래서 대시보드에 슬라이더가 두 개가 됐습니다.
자동 라벨 임계치(0~1)— 후보 점수의 절대 하한자동 라벨 최소 점수 간격(0~1)— 1등과 2등의 점수 차 하한
규칙은 AND입니다. 점수가 임계값을 넘고, 동시에 1등–2등 격차가 하한을 넘어야 자동 확정. 격차가 부족하면? 자동 확정 안 하고 사람 검토로 넘깁니다.
아까 그 예시를 다시 보면 (격차 하한을 0.15로 잡았다고 칩시다):
사진 #1 1등 0.93 2등 0.41 격차 0.52 → 임계값 OK, 격차 OK → 자동 확정
사진 #2 1등 0.85 2등 0.84 격차 0.01 → 임계값 OK, 격차 X → 사람이 봐주세요
2번이 걸러집니다. 버려지는 게 아니라 검토 대기줄로 갑니다. 이게 중요해요.
왜 이렇게까지 하냐면
"애매한 건 그냥 자동으로 붙이고 나중에 고치면 되지 않나?" 저도 그렇게 생각했었습니다. 근데 이 프로젝트에는 프로필 파일이라는 게 있습니다.
같은 아이가 여러 행사에 계속 나오거든요. 그래서 행사마다 처음부터 라벨링하지 않으려고, 사람별 벡터를 파일에 따로 모아둡니다. 다음 행사 때 그 파일을 참조해서 "저번에 봤던 그 아이" 추천을 띄우고요.
여기서 문제가 생깁니다. 잘못 확정한 라벨이 프로필 벡터에 그대로 쌓입니다. 그 오염된 프로필이 다음 행사의 추천을 만들고, 그 추천이 또 자동 확정되고... 네. 하셨죠? 그 그림.
한 번의 애매한 판단이 그 자리에서 끝나지 않고 다음 행사로 넘어갑니다. 그래서 "애매하면 넘어가"가 아니라 "애매하면 멈춰"가 맞았던 겁니다.
(참고로 이 프로필 파일은 이벤트 산출물의 민감정보 만료 정책에서 빠져 있습니다. 코드를 직접 확인했는데 purge_sensitive_outputs는 이벤트 출력 폴더의 임베딩 행과 face_crops/ 트리만 지웁니다. 프로필 파일 정리 경로는 없어요. 그러니까 이 파일 백업·버전·삭제는 직접 관리하셔야 합니다. 자동으로 안 지워져요.)
튜닝은 둘을 같이 만져야 합니다
한쪽만 올리면 망합니다. 이건 직접 해보시면 바로 압니다.
- 임계값만 올리면 → 재현율이 떨어집니다. 확실한 것도 안 잡혀요.
- 격차만 올리면 → 후보 점수가 고만고만한 풀에서는 자동 라벨이 거의 안 나옵니다. 슬라이더를 켠 게 아니라 기능을 끈 셈이 됩니다.
두 축은 서로를 보완하라고 있는 거지, 하나로 다른 하나를 대신하라고 있는 게 아닙니다. 같이 만지세요.
덤: 실패를 한 덩어리로 묶지 마세요
이 얘기를 하는 김에 하나만 더. 자동 분류에 실패한 사진들을 저는 처음에 _unmatched 폴더 하나에 다 넣었습니다. "실패했음." 끝.
그러다 나중에 셋으로 쪼갰어요.
no_face— 얼굴 검출기가 아무것도 못 찾음. 임베딩 단계까지 가지도 못함noise— 얼굴은 찾았고 임베딩도 만들었는데, 클러스터링이 노이즈(-1)로 분류low_quality— 검출은 됐는데 품질 필터(크기·블러·신뢰도)에서 탈락
왜 쪼갰냐면 셋의 처리 방법이 완전히 다르기 때문입니다. no_face는 애초에 사람이 없는 풍경 사진일 가능성이 높아서 한꺼번에 치우면 됩니다. noise는 진짜 아이가 찍혀 있을 확률이 높으니 클러스터 병합 흐름에서 다시 봐야 하고요. low_quality는 임계값을 손보거나 수동으로 받아들이는 게 맞습니다.
하나로 묶으면 이 정보가 정확히 필요한 순간에 사라집니다. 검토 화면을 열었는데 "실패 342장"만 뜨는 거죠. 뭘 어쩌라고요.
실패 이유는 자유 텍스트 상태값 말고 타입 있는 enum으로 기록하세요. 나중에 라우팅할 때 고맙습니다.
정리하면
한 문장으로 줄이면 이겁니다. "충분히 좋은가"와 "충분히 앞서는가"는 다른 질문이고, 다른 질문은 다른 축이 필요합니다.
정성적인 요구사항("압도적인 후보만 자동 확정해줘")이 있는데 튜닝 손잡이가 하나뿐이면, 그건 손잡이가 부족한 겁니다. 스칼라 하나를 아무리 잘 굴려도 표현이 안 되는 조건이 있어요.
솔직히 말하면 이걸 정량화된 개선 수치로 보여드리고 싶은데, 저는 before/after 정밀도를 재보지 않았습니다. (측정해야 하는데 미루고 있습니다.) 그래서 이 글은 "이렇게 하면 몇 % 좋아진다"가 아니라 "왜 축이 두 개여야 하는지"에 대한 이야기입니다.
혹시 자동 확정 로직에 슬라이더가 하나뿐인 시스템 만지고 계시다면, 1등과 2등이 얼마나 붙어 있는지 한번 찍어보세요. 생각보다 아슬아슬한 게 많습니다. 저는 그랬어요.