개인 개발로 타나고(タナゴ) 어획 기록 앱 'KIRIBAKO'를 만들고 있습니다. 사진에서 어종을 추정합니다.
분류기(Classifier)를 탑재하여 이번에 하제(ハゼ) 대응을 시켰습니다.
출하 전에 '새로운 모델이 정말 좋아졌는지'를 측정하고, 수치로 판단하여 출시 여부를 결정했습니다.
그 수치가 틀렸었습니다. 모델도 데이터도 아닌, 측정하는 쪽에 문제가 있었습니다.
결론을 먼저 말씀드립니다.
평가는 출하할 결과물 자체에 적용해야 합니다. 변환 과정의 중간 생성물을 가리키고 있다면,
모델을 교체했을 때 평가 시스템만 오래된 상태로 남아 있게 됩니다. 게다가 그것은 '오류'가 아니라 '그럴듯한 수치'로 나타납니다.
측정했더니 좋은 수치가 나왔다
앱의 분류기는 2단계 구조입니다.
- 객체 탐지(Object Detection) (YOLOX-Nano)를 사용해 사진에서 물고기를 찾아 잘라냅니다.
- 그 잘라낸 이미지를 분류기에 넣어 어종을 추정합니다.
평가도 같은 절차를 Python으로 재현했습니다. 실제로 잡은 사진을 YOLOX로 잘라내어,
그 크롭(crop)된 이미지를 신구 모델에 통과시켜 비교했습니다.
하제 22장을 준비하여 테스트한 결과, 다음과 같이 나왔습니다.
4장은 YOLOX가 물고기를 찾지 못해 잘라낼 수 없었습니다. 여기서 이런 논리가 성립하게 됩니다.
앱도 같은 임계값(score 0.30)으로 작동하므로,
이 4장은 실제 기기에서도 분류까지 진행될 것입니다. 손으로 잘라내어 구제하는 것은 실태와 다릅니다. 제외해도 괜찮습니다.
도달하지 못한다.
그럴듯한 논리입니다. 남은 18장으로 측정하니, 신모델이 구모델보다 명확히 좋습니다.
고신뢰도 오답률이 14 → 2로 감소했습니다. 출하를 결정했습니다.
실제 기기에 넣으니 수치가 맞지 않는다
빌드하여 실제 기기에서 테스트했을 때, 하제 사진으로 다음과 같이 반환되었습니다.
candidates = [コイ 0.478, クチボソ 0.311, フナ 0.179]
candidates = [コイ 0.858, ハゼ 0.061, スゴモロコ 0.058]
candidates = [ハゼ 0.669, スゴモロコ 0.183, クチボソ 0.006]
8장 중 3장만 맞았습니다. 비율만 보면, 오프라인의 50%와 통계적으로 모순되지 않는 범위입니다 (3/8은 참 확률 50%에서도 36%의 확률로 발생 가능).
하지만 오답의 방향이 다릅니다. 오프라인에서는 18장 중 단 한 번도 코이(コイ)라고 답하지 않았는데,
실기에서는 코이가 3번 나오고 있습니다. 이는 우연으로는 설명하기 어렵습니다.
벗어난 가설 4가지
여기서 원인을 찾아봅니다. 결과만 나열하자, 모두 틀렸었습니다.
① 탐지기가 공회전하여 배경까지 잘라내고 있는 것은 아닐까
이전에 YOLOX가 물고기를 찾지 못하면 Saliency (OS의 주목 영역 추정)로 떨어져서,
물고기 전체를 감싸는 버그가 있었습니다. 큰 영역을 자르면 '큰 물고기'에 기우므로, 코이는 논리적으로 맞습니다.
로그에서 탐지 출처를 확인:
[fish-bbox] result: yolox 1330.3 x 499.2
→ ハゼ 0.522
yolox였습니다. bbox도 가늘고 길며 물고기 형태입니다. 아닙니다.
② EXIF의 Orientation이 빠진 것은 아닐까
평가용 사진을 복사할 때, 프라이버시를 위해 exiftool로 GPS를 제거했습니다.
그때 Orientation까지 사라졌다면, 잘라내는 방향이 바뀝니다.
잘라내기 스크립트는 ImageOps.exif_transpose를 사용하므로, 영향을 받습니다.
원본 265장 중 Orientation을 가진: 265
복사 44장 중 Orientation을 가진: 44 (값은 모두 6)
남아 있었습니다. 아닙니다.
③ 실제 기기에 들어간 모델이 다른 것은 아닐까
빌드한 .app 내부와 리포지토리의 모델을 sha256으로 비교:
model.mlmodel 일치
weight.bin 일치
같습니다. 아닙니다.
④ 실제 기기와 오프라인에서 보고 있는 픽셀이 다른 것은 아닐까
이것은 유망해 보였습니다. 앱은 카메라롤에서 선택할 때 ph://로 PhotoKit의
어셋을 읽습니다. 반면, 평가에 사용했던 것은 앱이 저장한
Documents/catches/*.jpg입니다. 다른 파일입니다.
사진 라이브러리에서 원본을 가져와 sha256을 비교했습니다.
원문 (사진 라이브러리) → 앱이 저장한 JPEG sha256 일치
원문 (사진 라이브러리) → 앱이 저장한 JPEG sha256 일치
똑같았습니다. '저장된 JPEG'와 '카메라 롤의 원본'은 이 앱에서는
동일 파일이었습니다.아닙니다.
남은 것은, 자신의 스크립트
4개를 제거하고 남은 것은 '측정하는 쪽'입니다.
실기에서 분리한 사진 중 2장을 다시 오프라인 경로로 통과시켜 보았습니다.
[skip] no detection: haze-01.jpg
[skip] no detection: haze-02.jpg
오프라인에서는 탐지할 수 없다. 하지만 실기는 이 2장을 탐지하고 있다.
같은 사진, 같아야 할 탐지기로서.
추출 스크립트가 읽고 있는 모델을 보았습니다.
YOLOX_MLPKG = Path(".../tools/convert/yolox_nano_fish.mlpackage")
파일의 날짜를 비교합니다.
오프라인 : tools/convert/yolox_nano_fish.mlpackage 2026-06-07
앱 : modules/.../yolox_nano_fish.mlmodelc 2026-09-27
sha256 : 불일치
다른 모델이었습니다.
9월에 탐지기를 118장으로 재학습하여 교체했을 때, 앱에 넣는 .mlmodelc는
업데이트했지만, 변환 작업장에 놓여 있던 .mlpackage는 6월 그대로였습니다.
그리고 평가 스크립트는 그쪽을 가리키고 있었습니다.
무슨 일이 일어나고 있었나
평가용 추출은, 4개월 전의, 실지에서 공중을 치던 탐지기로 만들어져 있었습니다.
- 새로운 탐지기라면 찾았을 사진을, 오래된 탐지기가
no detection으로 떨어뜨림 - 떨어진 4장은 '실기에서도 도달하지 않는다'는 설명과 함께 제외됨
- 남은 18장은, 오래된 탐지기가 잘하는 사진들만이 남은 서브셋
올바른 탐지기로 다시 측정해 보니, 이렇게 되었습니다.
| 하제 22장 | 구 모델 | 신 모델 |
|---|
| 정답 | 0 | 12 |
| ... | 고확신으로 오답 | |
| 13 | 2 | |
22장 모두 탐지할 수 있었습니다 (18/22 → 22/22). 결론은 변하지 않습니다. 오히려 개선 폭은
커졌습니다.하지만 그것은 우연입니다. 반대로 나왔다면, 잘못된 판단을 하고 있었던 것입니다.
가장 나빴던 것은, 제외에 이유가 붙었다는 것
기술적인 원인은 경로의 하드코딩입니다. 하지만 정말 위험했던 건 그게 아닙니다.
4장이 떨어졌을 때, 거기에 이유가 생겨버린 것.
앱도 같은 임계값으로 작동하니까, 이 4장은 실기에서도 분류까지 도달하지 않는다.
이 설명은 그럴듯하고, 언뜻 데이터에 근거해 보이며, 그리고 거짓이었습니다.
샘플이 떨어진 것 자체는, 그냥 사실입니다. 거기에 '떨어질 수밖에 없었다'라는
이야기를 덧씌우는 순간, 선택 편향이 정당화로 변합니다. 게다가 자신의 손에서
나온 논리라 의심할 동기가 없습니다.
숫자가 맞지 않을 때, 의심한 것은 모델이었고, 사진이었고, 단말기였습니다.
끝까지 의심하지 않은 것이, 자신의 측정기였습니다.
넣은 가드
같은 일이 일어나지 않도록, 평가 스크립트를 2곳 수정했습니다.
① 변환의 중간물이 아니라, 출하하는 결과물을 직접 읽기
# 앱이 실제로 탑재하고 있는 컴파일된 탐지기를 그대로 읽음
YOLOX_MODEL = Path(".../modules/coin-detector/ios/Models/yolox_nano_fish.mlmodelc")
model = ct.models.CompiledMLModel(str(YOLOX_MODEL))
.mlmodelc는 get_spec()을 가지고 있지 않으므로 입출력 이름은 상수로 유지합니다. 이름이 바뀌면
KeyError로 떨어지기 때문에, 조용히 망가지는 일은 없습니다.
**② 그것을 테스트에서 고정하기
def test_検出器はアプリが出荷している成果物を指す(self):
literal = ... # YOLOX_MODEL 의 문자열 리터럴
self.assertTrue(literal.endswith("yolox_nano_fish.mlmodelc")
...
작성한 후, 일부러 고장 내서 다운되는 것을 확인했습니다. 경로를 원래의
tools/convert/...mlpackage
로 되돌리는 변이와, 존재 체크를 없애는 변이의 두 가지입니다.
둘 다 다운되었습니다. 다운되지 않는 테스트는, 안 쓴 것과 똑같으니까요.
다음에 같은 일을 하지 않기 위해
평가는 출하물 자체에 적용한다. 중간 생성물을 가리키면, 교체 시 평가 시스템만 오래되게 된다.
이것은 '고장 난' 것이 아니라 '그럴듯한 숫자를 계속 내놓는 것'이라서 알아차리기 어렵다 -
제외된 샘플을 센다. n
이 예상과 다르면 멈춘다.
18/22
를 보고 '뭐 그 정도겠지' 하고 넘긴 것이 분기점이었다 -
다운된 샘플에 이야기를 붙이지 않는다. '다운될 수밖에 없었다'는,
대부분 확인하지 않고 말하는 것이다 -
오프라인과 실기가 다르면, 먼저 측정기를 의심한다. 모델・데이터・단말을
먼저 의심했기 때문에, 돌아갔다.
덧붙여 두 가지를 배웠습니다. 평가 스크립트는 출하 중인 모델의 metrics는 보호하고
덮어쓰지 않는 사양이었기 때문에, 승격 후에 다시 측정하려면 명시 플래그가 필요했습니다.
반대로
비출하 모델은 덮어쓴다기 때문에, 평가 세트 구성이 바뀌면 과거 기록이
다른 모집단의 숫자로 무너진다.
저장된 metrics끼리 세대 비교를 해서는 안 된다.
KIRIBAKO는 iPhone의 LiDAR로 작은 물고기를 측정하고 기록하는 앱입니다.