취향 판별기 만들기 — frozen backbone + pairwise head

언제 — 사진을 보고 “맛있어 보이는 정도” 같은 주관적 취향을 매기는 모델을 만들 때. 워크드 예제는 간짜장 사진.

전제 셋.

  • 목표는 실제 맛이 아니라 사진에서 지각되는 맛있어 보임이다. 라벨이 입력 픽셀 안에 완전히 들어있어야 문제가 성립한다.
  • backbone 은 학습하지 않는다. 남이 만든 범용 시각 표현을 빌리고 그 위에 취향 head 만 얹는다.
  • 컴퓨트 비용은 0원에 수렴한다. 병목은 GPU 가 아니라 라벨이다.

R1. 라벨링 툴을 먼저 만든다

전체 프로젝트에서 이걸 먼저 만드는 게 속도를 좌우한다. 두 장 띄우고 ←/→ 로 고르는 게 전부고, 정적 페이지 + 무료 티어 DB 면 충분하다.

요구사항은 넷 — 키보드만으로 진행(마우스를 쓰면 3배 느려진다), 다음 쌍 프리로드(대기 시간이 라벨링 리듬을 깬다), 되돌리기 1단계, 진행률 표시(이게 있어야 지인들이 끝까지 한다).

{"a": "img_0042", "b": "img_0117", "winner": "a", "labeler": "kim", "ms": 1840}

ms(응답 시간)를 남기면 나중에 200ms 미만 같은 대충 클릭을 걸러낼 수 있다.

왜 pairwise 인가 — 10점 척도는 평가자 간 분산 + 같은 사람의 시점 간 분산이 신호를 잡아먹는다. “둘 중 뭐가 더 나아 보이나”는 훨씬 안정적이고 빠르다.

확인 — 쌍 2,000개를 장당 2.5초면 약 1.4시간. 20명이 100쌍씩 하면 한 사람당 4분이다.


R2. 수집 — source_id 가 절반이다

직접 촬영은 산술적으로 불가능하다(한 끼 1만원 × 300 = 300만원 + 300끼). 본인 점심에서 자연 축적 + 지인 크라우드소싱이 현실적이고, 한 명이 100장보다 20명이 5장씩이 훨씬 낫다(R5 split 때문). 리뷰 이미지 스크래핑은 ToS 소지가 있으므로 개인 실험용과 공개 배포용 데이터를 처음부터 분리해서 관리한다.

수집 시점에 같이 기록한다. 나중에 추가할 수 없다.

{"id": "img_0042", "source_id": "friend_kim", "container": "delivery|dine_in",
 "device": "phone|dslr", "lighting": "daylight|fluorescent|dark", "sauce_separated": true}

source_id 가 없으면 group split 을 못 하고, split 이 새면 성능 숫자가 전부 거짓말이 된다.

규모 목표 — 스모크 100장/100쌍(신호가 존재하는지), MVP 400장/1,500쌍, 안정화 1,000장/5,000쌍. 400장이면 가능한 쌍이 8만인데 1,500쌍은 2%도 안 된다. 라벨이 이미지보다 훨씬 싸다.


R3. 임베딩 추출 — 한 번만 돌리고 캐시

이후 모든 실험이 이 캐시 위에서 이뤄진다.

proc = AutoImageProcessor.from_pretrained("facebook/dinov2-small")
model = AutoModel.from_pretrained("facebook/dinov2-small").to(device).eval()

@torch.no_grad()
def embed(paths, bs=16):
    out = []
    for i in range(0, len(paths), bs):
        imgs = [Image.open(p).convert("RGB") for p in paths[i:i+bs]]
        x = proc(images=imgs, return_tensors="pt").to(device)
        out.append(model(**x).pooler_output.float().cpu().numpy())
    return np.concatenate(out)

E = embed(paths)                                    # (N, 384)
E = E / np.linalg.norm(E, axis=1, keepdims=True)    # L2 정규화
np.savez("cache/embeddings.npz", ids=np.array(ids), E=E)

1,000장 기준 M 시리즈 맥에서 2분 내외.

함정 — 여기서 쓴 전처리(resize/crop/normalize)를 R8 브라우저 배포에서 정확히 똑같이 재현해야 한다. 가장 흔한 버그 지점이다.


R4. head 학습 — Bradley-Terry

각 이미지에 잠재 점수 s 를 주고 P(a 승) = sigmoid(s_a − s_b). 구현은 그냥 점수 차이에 BCE 를 씌우는 것이다.

class Head(nn.Module):
    def __init__(self, d=384, h=128, p=0.3):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(d, h), nn.GELU(), nn.Dropout(p), nn.Linear(h, 1))
    def forward(self, x): return self.net(x).squeeze(-1)

logits = head(E[ia]) - head(E[ib])          # ← Bradley-Terry
loss = F.binary_cross_entropy_with_logits(logits, y)

CPU 에서 수십 초. 하이퍼파라미터를 50번 스윕해도 커피 한 잔 시간이다.


R5. split — 여기가 제일 중요하다

절대 이미지 단위로 나누지 말 것. 같은 촬영자/가게의 사진이 train 과 val 에 흩어지면 모델은 “이 사람 사진 스타일”을 외우고 검증 점수가 실제보다 훨씬 좋게 나온다.

groups = np.array([m["source_id"] for m in meta])
tr_img, va_img = next(GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=0)
                      .split(np.arange(len(meta)), groups=groups))
# 라벨 쌍은 "양쪽 이미지가 모두 같은 split" 일 때만 사용 — 넘나드는 쌍은 버린다

아까워 보이지만 여기서 새면 이후 모든 숫자가 무의미해진다.


R6. 미학 신호 분해

모델이 학습할 가능성이 가장 높은 건 취향이 아니라 사진 잘 찍는 능력이다. 측정해서 분리한다.

  1. 범용 미학 모델로 각 이미지에 aesthetic_score 부여 (추론만, 학습 없음)
  2. 미학 점수 하나만 입력으로 쓰는 BT 모델 학습 → baseline accuracy
  3. 전체 임베딩을 쓰는 모델 → full accuracy
  4. 두 값의 차이 = 대상 고유 신호의 크기
baseline / full 의미
0.72 / 0.74 라벨의 대부분이 사진빨. 고유 신호가 거의 없다
0.62 / 0.78 대상 특유의 시각 신호가 실재한다. 프로젝트 성립
0.50 / 0.55 라벨 자체가 노이즈. R7 인간 천장부터 재확인

어느 쪽이 나와도 결과다. 이 분해 자체가 가장 재밌는 산출물이고, UI 에서 “사진빨 점수 / 대상 점수” 두 축으로 보여줄 수 있다.


R7. 인간 천장을 먼저 재라

이 단계를 건너뛰면 나머지가 전부 헛수고가 된다. 같은 쌍 200개를 두 사람(또는 같은 사람이 2주 간격으로)에게 라벨링시켜 일치율을 잰다.

인간 일치율 0.75 → 모델 0.72 는 사실상 천장
인간 일치율 0.75 → 모델 0.55 는 개선 여지 큼

이걸 모르고 “0.72밖에 안 나오네” 하며 몇 주를 태우는 게 가장 흔한 실패 패턴이다.

주 지표는 pairwise accuracy, 보조로 Spearman/Kendall tau. 분류 정확도는 쓰지 않는다 — 애초에 분류 문제가 아니다.

교란변수 감사 — 임베딩으로 메타데이터를 맞혀본다. LogisticRegression().fit(E_tr, container_tr).score(E_va, container_va) 가 0.95면 임베딩에 촬영 조건이 잔뜩 들어있고 점수도 그걸 타고 있을 가능성이 높다.


R8. 라벨 없이 돌리는 진단

라벨이 픽셀 안에 있으므로 augmentation 이 곧 검증 도구가 된다.

변형 기대 방향 어긋나면
색온도 +따뜻하게 점수 ↑ 색을 안 보고 있음
밝기 −40% 점수 ↓ 노출 신호 무시
좌우 반전 변화 없음 위치에 과적합
배경만 블러 거의 변화 없음 배경을 보고 있음

GradCAM 으로 어디를 보는지도 띄운다. 소스 표면이 아니라 그릇 테두리·배경 테이블·젓가락을 보고 있는 경우가 초기에 흔하고, 그러면 크롭 전략을 다시 봐야 한다는 신호다.


R9. 배포 — ONNX + 브라우저

.pt/.safetensors 는 파라미터만 담기고 모델 구조는 파이썬 코드에 있어야 한다. .onnx 는 구조와 파라미터를 함께 담아 파이썬 없이 실행된다. 개발은 전자, 배포는 후자.

class Full(nn.Module):
    def forward(self, pixel_values): return self.h(self.b(pixel_values).pooler_output)

torch.onnx.export(full, torch.randn(1, 3, 224, 224), "models/model.onnx",
    input_names=["pixel_values"], output_names=["score"],
    dynamic_axes={"pixel_values": {0: "batch"}}, opset_version=17)

onnxruntime-web 으로 정적 호스팅하면 서버가 없고 사진이 업로드되지 않아 프라이버시 이슈도 없다. int8 양자화하면 backbone 22MB + head 200KB 로 프론트엔드 번들에 감당 가능하다.

“판별기”의 실체는 200KB 다. 나머지는 빌려 쓴 범용 눈이다.

확인 — 전처리 불일치가 가장 자주 나오는 버그다. 파이썬과 JS 에 같은 이미지 하나를 넣고 임베딩 벡터를 비교한다.


R10. 아티팩트 관리

대체 불가능성 순서 — ① labels.jsonl(재생성 불가, 다시 클릭해야 한다) ② 원본 이미지 + meta.jsonl(재수집 비용이 크고 source_id 는 사후 복구 불가) ③ embeddings.npz(2분이면 재생성) ④ 가중치(사실상 빌드 산출물). 레포에는 라벨과 스크립트만 커밋한다.

같은 데이터로 다시 학습해도 가중치 파일은 매번 달라진다 — 초기값·셔플 순서·부동소수점 연산 순서 때문이고, 시드를 고정해도 하드웨어가 바뀌면 달라진다. 그래서 ML 에서 “재현”의 기준은 파일 해시가 아니라 평가 지표가 비슷한 범위에 들어오는가다. 학습마다 남긴다.

{"run": "2026-09-01T14:20", "n_images": 412, "n_pairs": 1503, "split_seed": 0,
 "val_pairwise_acc": 0.731, "baseline_aesthetic_acc": 0.648, "human_ceiling": 0.762, "commit": "a3f1c9d"}

실행 순서

[1] 라벨링 웹툴 ─── 이게 먼저다. 나머지 전체 속도를 좌우한다
[2] 이미지 100장 + 본인 라벨 100쌍
[3] CLIP zero-shot 베이스라인과 상관 측정
      ├─ tau ≈ 0    → 문제 설정 재검토
      └─ tau > 0.25 → 계속
[4] 400장 → 임베딩 캐시 (R3)
[5] 라벨 링크 배포, 1,500쌍 (모델이 생기면 active sampling — |s_a − s_b| 가 작은 쌍부터)
[6] head 학습 (R4) + 인간 천장 측정 (R7)   ← 병행
[7] 미학 분해 (R6) + 진단 (R8)
[8] 속성 head (multi-task) — 근거 표시용
[9] ONNX → 브라우저 (R9)

[3] zero-shot 베이스라인은 라벨 0장으로 만드는 최소 검증이다. sim(img, "잘 볶인 윤기 있는 …") − sim(img, "눅눅하고 물기 많은 …") 을 점수로 쓰고 본인이 매긴 100쌍과 Spearman 을 잰다. 0.3만 나와도 출발점이고, 0에 가까우면 문제 설정부터 다시 본다.

[8] 속성 head — 점수만 뱉으면 아무도 안 믿는다. 같은 임베딩에서 head 를 분기시키고 최종 점수를 속성의 선형 결합으로 두면 기름 분리 낮음 −1.2 / 양파 볶음 좋음 +0.8 같은 근거를 낼 수 있다. 속성 라벨은 VLM 으로 자동화해도 된다(장당 30초 손 라벨 = 1,000장에 8시간).

최종 점수 라벨을 VLM 으로 만들면 안 된다. 그건 내 취향이 아니라 그 모델의 취향을 증류하는 것이고 프로젝트의 전제가 무너진다. VLM 은 객관적으로 기술 가능한 속성 태깅에만 쓴다.

함정 — “GPU 가 부족하다”는 느낌이 들면 장비 문제가 아니라, 데이터가 100장뿐인데 backbone 을 fine-tuning 하고 있다는 신호일 가능성이 높다.

#586raw