취향 판별기 만들기 — 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. 미학 신호 분해
모델이 학습할 가능성이 가장 높은 건 취향이 아니라 사진 잘 찍는 능력이다. 측정해서 분리한다.
- 범용 미학 모델로 각 이미지에
aesthetic_score부여 (추론만, 학습 없음) - 미학 점수 하나만 입력으로 쓰는 BT 모델 학습 → baseline accuracy
- 전체 임베딩을 쓰는 모델 → full accuracy
- 두 값의 차이 = 대상 고유 신호의 크기
| 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 하고 있다는 신호일 가능성이 높다.