---
type: note
kind: Recipe
title: 취향 판별기 만들기 — frozen backbone + pairwise head
description: 사진에서 지각되는 선호를 점수화하는 모델을 라벨 중심으로 만드는 절차. Bradley-Terry head, group split, 미학 신호 분해.
tags: ['machine-learning', 'pytorch', 'dinov2', 'onnx', 'dataset']
status: release
ctime: 2026-09-01
mtime: 2026-09-01
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
---

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

**전제 셋.**

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

---

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

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

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

```json
{"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 소지가 있으므로 개인 실험용과 공개 배포용 데이터를 **처음부터 분리해서** 관리한다.

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

```json
{"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. 임베딩 추출 — 한 번만 돌리고 캐시

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

```python
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 를 씌우는 것**이다.

```python
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 에 흩어지면 모델은 "이 사람 사진 스타일"을 외우고 **검증 점수가 실제보다 훨씬 좋게 나온다.**

```python
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` 는 구조와 파라미터를 함께 담아 파이썬 없이 실행된다. **개발은 전자, 배포는 후자.**

```python
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 에서 "재현"의 기준은 파일 해시가 아니라 **평가 지표가 비슷한 범위에 들어오는가**다. 학습마다 남긴다.

```json
{"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시간).

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

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