{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7d4d3e2c",
   "metadata": {},
   "source": [
    "# 실습 3 · 이미지·멀티모달 — 학습 없이 이미지를 변수로\n",
    "한국외국어대학교 GBT · 대학원 딥러닝 세미나. 강의 페이지: `03_image_multimodal.html`\n",
    "\n",
    "**한 줄 목표** 사전학습 모델(CLIP · llava · bge-m3)을 호출만 해서 위성영상과 상품 이미지를 숫자 변수로 바꾸고, 라벨 0장(제로샷) → 라벨 조금(선형 프로브) → 두 모달 결합의 성능 변화를 같은 저울에 올린다.\n",
    "\n",
    "런타임: **T4 GPU** (런타임 → 런타임 유형 변경). 전체 실행 약 10~15분. Cloudflare API 토큰이 필요하다(Workers AI 읽기·실행 권한, 하루 10,000 neurons 무료).\n",
    "\n",
    "| 셀 | 내용 | 관찰 포인트 |\n",
    "|---|---|---|\n",
    "| ① | 설치·토큰·Cloudflare 헬퍼 | `report()` 로 neurons 누적 확인 |\n",
    "| ② | 데이터 로드(HF parquet 직접) | 라벨↔파일명 검증이 통과하는가 |\n",
    "| ③ | 분할 | 층화 후 클래스 비율 |\n",
    "| ④ | 베이스라인(픽셀·TF-IDF) | 제목만으로 이미 98% — 천장이 어디인가 |\n",
    "| ⑤ | CLIP 임베딩·제로샷·llava | 프롬프트 문구 하나에 클래스별 정확도가 뒤집힌다 |\n",
    "| ⑥ | 평가(seed 5회) | 결합이 단독 최강을 이기는가, 표준편차 안인가 |\n",
    "| ⑦ | 군집·시각화 | 같은 클래스가 이미지 지도와 텍스트 지도에서 같은 덩어리인가 |\n",
    "| ⑧ | 보고표 | 본문 표 + 부록 재현성 항목 |\n",
    "| ⑨ | 자기 데이터로 바꾸기 | ① 블록만 교체 |"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "29c9b824",
   "metadata": {},
   "source": [
    "## ① 설치 · 토큰 · Cloudflare Workers AI 헬퍼\n",
    "토큰은 화면에 남지 않도록 `getpass` 로 입력한다. 모든 API 호출은 `USED` 에 neurons 를 누적한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "925d4d1d",
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip -q install transformers umap-learn pyarrow requests tabulate\n",
    "import os, io, json, time, requests, numpy as np, pandas as pd\n",
    "from getpass import getpass\n",
    "TOK = os.environ.get('CLOUDFLARE_API_TOKEN') or getpass('Cloudflare API 토큰: ')\n",
    "ACC = os.environ.get('CLOUDFLARE_ACCOUNT_ID') or input('Cloudflare Account ID: ')\n",
    "BASE = f\"https://api.cloudflare.com/client/v4/accounts/{ACC}/ai/run/\"\n",
    "USED = {'neurons': 0.0, 'calls': 0}                                   # 누적 사용량\n",
    "\n",
    "def _post(model, body=None, raw=None, retries=4):\n",
    "    h = {\"Authorization\": f\"Bearer {TOK}\"}\n",
    "    for k in range(retries):\n",
    "        try:\n",
    "            if raw is not None: r = requests.post(BASE + model, headers={**h, \"Content-Type\": \"application/octet-stream\"}, data=raw, timeout=120)\n",
    "            else: r = requests.post(BASE + model, headers={**h, \"Content-Type\": \"application/json\"}, data=json.dumps(body).encode(), timeout=120)\n",
    "            d = r.json()\n",
    "            if d.get('success'):\n",
    "                USED['calls'] += 1; res = d['result']\n",
    "                for k in ('usage', 'meta'):                                    # LLM: result.usage.neurons / bge-m3: result.meta.neurons\n",
    "                    if isinstance(res, dict) and isinstance(res.get(k), dict) and res[k].get('neurons') is not None: USED['neurons'] += float(res[k]['neurons']); break\n",
    "                return res\n",
    "            if any(e.get('code') in (3040, 429) for e in d.get('errors') or []): time.sleep(3 * (k + 1)); continue   # 용량·속도 제한 → 대기\n",
    "            raise RuntimeError(f\"{model}: {d.get('errors')}\")\n",
    "        except (requests.RequestException, ValueError):\n",
    "            if k == retries - 1: raise\n",
    "            time.sleep(3 * (k + 1))\n",
    "\n",
    "def embed(texts, model=\"@cf/baai/bge-m3\", batch=100):                  # 문장 → 1024차원\n",
    "    return [v for i in range(0, len(texts), batch) for v in _post(model, {\"text\": list(texts[i:i + batch])})['data']]\n",
    "def ask_image(img_bytes, prompt, model=\"@cf/llava-hf/llava-1.5-7b-hf\", max_tokens=30):   # 이미지+질문 → 문자열\n",
    "    return (_post(model, {\"image\": list(img_bytes), \"prompt\": prompt, \"max_tokens\": max_tokens}).get('description') or '').strip()\n",
    "def resnet50(img_bytes): return _post(\"@cf/microsoft/resnet-50\", raw=img_bytes)      # ImageNet 상위 5\n",
    "def report(): return f\"API 호출 {USED['calls']}회, 사용 neurons {USED['neurons']:.1f} (무료 한도 10,000/일)\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aae9138d",
   "metadata": {},
   "source": [
    "## ② 데이터 로드 — Hugging Face parquet 을 URL 로 직접\n",
    "EuroSAT RGB validation 5,400장(64×64, 10클래스), Fashion Product Images small 0.parquet 22,036건(약 60×80 썸네일 + 제목·카테고리). 라벨 번호와 클래스 이름의 대응은 파일명으로 검증한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "502969e4",
   "metadata": {},
   "outputs": [],
   "source": [
    "def load_parquet(url):\n",
    "    r = requests.get(url, timeout=600); r.raise_for_status(); return pd.read_parquet(io.BytesIO(r.content))\n",
    "HF = 'https://huggingface.co/api/datasets/'\n",
    "es = load_parquet(HF + 'blanchon/EuroSAT_RGB/parquet/default/validation/0.parquet')        # 5,400장 · 35MB\n",
    "CLASSES = ['AnnualCrop', 'Forest', 'HerbaceousVegetation', 'Highway', 'Industrial',\n",
    "           'Pasture', 'PermanentCrop', 'Residential', 'River', 'SeaLake']\n",
    "assert (es.filename.str.rsplit('_', n=1).str[0] == np.array(CLASSES)[es.label]).all()      # 라벨↔파일명 검증\n",
    "fa = load_parquet(HF + 'ashraq/fashion-product-images-small/parquet/default/train/0.parquet')   # 22,036건 · 136MB\n",
    "from PIL import Image\n",
    "def decode(d): return Image.open(io.BytesIO(d['bytes'])).convert('RGB')\n",
    "print(es.shape, fa.shape, decode(es.image[0]).size, decode(fa.image[0]).size)\n",
    "print(fa[['subCategory', 'articleType', 'baseColour', 'productDisplayName']].head(3).to_string())\n",
    "# → (5400, 3) (22036, 11) (64, 64) (60, 80)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "783805b6",
   "metadata": {},
   "source": [
    "## ③ 분할\n",
    "EuroSAT 은 validation 5,400장을 절반씩(층화) 나눠 프로브 학습/평가에 쓴다(제로샷은 학습이 없으므로 5,400장 전체에 평가). Fashion 은 subCategory 상위 10개 클래스에서 클래스당 400건 = 4,000건 → train 3,000 / test 1,000(층화)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9d4b8c0a",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import train_test_split\n",
    "y_es = es.label.values\n",
    "es_tr, es_te = train_test_split(np.arange(len(es)), test_size=0.5, stratify=y_es, random_state=0)\n",
    "fa = fa[fa.productDisplayName.notna()]\n",
    "TOP = fa.subCategory.value_counts().index[:10].tolist()                                    # 상위 10개 클래스\n",
    "fs = pd.concat([fa[fa.subCategory == c].sample(400, random_state=0) for c in TOP]).reset_index(drop=True)\n",
    "fs['y'] = fs.subCategory.map({c: i for i, c in enumerate(TOP)}); y_fs = fs.y.values\n",
    "fs_tr, fs_te = train_test_split(np.arange(len(fs)), test_size=1000, stratify=y_fs, random_state=0)\n",
    "print(len(es_tr), len(es_te), '|', TOP, '|', len(fs_tr), len(fs_te))\n",
    "# → 2700 2700 | ['Topwear', 'Shoes', 'Bags', 'Bottomwear', 'Watches', 'Innerwear', 'Eyewear', 'Jewellery', 'Fragrance', 'Sandal'] | 3000 1000"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "db2db7a8",
   "metadata": {},
   "source": [
    "## ④ 베이스라인 — 딥러닝 없이\n",
    "EuroSAT: 8×8 로 줄인 RGB 픽셀(192) + 채널 평균·표준편차(6) → 로지스틱. Fashion: 제목 TF-IDF → 로지스틱. 이 숫자를 못 이기면 그대로 보고한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a7efd250",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.metrics import f1_score\n",
    "def pixel_feats(imgs):                                       # 8×8 RGB(192) + 채널 평균·표준편차(6) = 198차원\n",
    "    out = []\n",
    "    for im in imgs:\n",
    "        a = np.asarray(im.resize((8, 8)), np.float32) / 255; f = np.asarray(im, np.float32) / 255\n",
    "        out.append(np.r_[a.ravel(), f.mean((0, 1)), f.std((0, 1))])\n",
    "    return np.array(out)\n",
    "es_img = [decode(d) for d in es.image]\n",
    "P = pixel_feats(es_img); sc = StandardScaler().fit(P[es_tr])\n",
    "acc_pix = LogisticRegression(max_iter=3000).fit(sc.transform(P[es_tr]), y_es[es_tr]).score(sc.transform(P[es_te]), y_es[es_te])\n",
    "tf = TfidfVectorizer(ngram_range=(1, 2), min_df=2).fit(fs.productDisplayName[fs_tr])\n",
    "pr = LogisticRegression(max_iter=3000, C=5).fit(tf.transform(fs.productDisplayName[fs_tr]), y_fs[fs_tr]).predict(tf.transform(fs.productDisplayName[fs_te]))\n",
    "f1_tfidf = f1_score(y_fs[fs_te], pr, average='macro')\n",
    "print(f'EuroSAT 픽셀+로지스틱 acc = {acc_pix:.3f}   |   Fashion TF-IDF 제목 macro-F1 = {f1_tfidf:.3f}')\n",
    "# → EuroSAT 약 0.60 (train 2,700 기준; 16,200 으로 학습하면 0.678)   |   Fashion 약 0.99"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "22cbaa28",
   "metadata": {},
   "source": [
    "## ⑤-1 CLIP — 이미지와 문장을 같은 512차원 공간에\n",
    "`openai/clip-vit-base-patch32` 를 transformers 로 불러 **추론만** 한다. 이미지 5,400장 임베딩은 T4 에서 1분 안쪽이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ec406bb5",
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "from transformers import CLIPModel, CLIPProcessor\n",
    "dev = 'cuda' if torch.cuda.is_available() else 'cpu'\n",
    "clip = CLIPModel.from_pretrained('openai/clip-vit-base-patch32').to(dev).eval()\n",
    "proc = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')\n",
    "def _vec(o): return o if torch.is_tensor(o) else o.pooler_output          # transformers 4.x(텐서) / 5.x(출력 객체) 호환\n",
    "@torch.no_grad()\n",
    "def clip_image(imgs, bs=256):                                             # PIL 이미지 → (n, 512), L2 정규화\n",
    "    out = []\n",
    "    for i in range(0, len(imgs), bs):\n",
    "        px = proc(images=imgs[i:i + bs], return_tensors='pt')['pixel_values'].to(dev)\n",
    "        out.append(torch.nn.functional.normalize(_vec(clip.get_image_features(pixel_values=px)), dim=-1).cpu().numpy())\n",
    "    return np.concatenate(out)\n",
    "@torch.no_grad()\n",
    "def clip_text(texts):                                                     # 문장 → (n, 512), 같은 공간\n",
    "    e = _vec(clip.get_text_features(**proc(text=texts, return_tensors='pt', padding=True).to(dev)))\n",
    "    return torch.nn.functional.normalize(e, dim=-1).cpu().numpy()\n",
    "E_es = clip_image(es_img); print(E_es.shape)\n",
    "# → (5400, 512)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f62ce19",
   "metadata": {},
   "source": [
    "## ⑤-2 CLIP 제로샷 — 라벨 0장\n",
    "클래스 이름을 문장으로 만들어 텍스트 임베딩을 얻고, 이미지 임베딩과 코사인이 가장 큰 클래스를 예측으로 삼는다. 프롬프트 문구 두 가지를 비교한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c24e24d7",
   "metadata": {},
   "outputs": [],
   "source": [
    "PHRASE = {'AnnualCrop': 'annual crop land', 'Forest': 'forest', 'HerbaceousVegetation': 'herbaceous vegetation',\n",
    "          'Highway': 'a highway', 'Industrial': 'industrial buildings', 'Pasture': 'pasture', 'PermanentCrop': 'permanent crop land',\n",
    "          'Residential': 'residential buildings', 'River': 'a river', 'SeaLake': 'sea or lake'}\n",
    "zs = {}\n",
    "for name, tpl in {'photo': 'a satellite photo of {}', 'name': '{}'}.items():\n",
    "    T = clip_text([tpl.format(PHRASE[c]) for c in CLASSES])            # (10, 512)\n",
    "    zs[name] = (E_es @ T.T).argmax(1)                                   # 코사인 최대 = 예측 (학습 없음)\n",
    "    print(f'{tpl:26s} acc={(zs[name] == y_es).mean():.3f}', np.round([(zs[name][y_es == i] == i).mean() for i in range(10)], 2))\n",
    "# → a satellite photo of {}   acc=0.387 [0.38 0.43 0.   0.43 0.78 0.39 0.18 0.86 0.09 0.33]\n",
    "# → {}                        acc=0.436 [0.01 0.25 0.01 0.62 0.46 0.01 0.84 0.94 0.31 0.94]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5746bf0",
   "metadata": {},
   "source": [
    "## ⑤-3 llava 제로샷 — 비전 언어모델에 라벨을 묻는다 (60장)\n",
    "Cloudflare `@cf/llava-hf/llava-1.5-7b-hf`. 답은 문자열이므로 라벨 목록에서 첫 등장 라벨을 찾아 정수로 바꾼다. 같은 60장에서 CLIP 제로샷과 비교한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ec5ee97c",
   "metadata": {},
   "outputs": [],
   "source": [
    "PROMPT = 'This is a satellite image. Classify it into exactly one of: ' + ', '.join(CLASSES) + '. Answer with the label only.'\n",
    "def parse(ans):                                                          # 답 문자열 → 클래스 번호 (없으면 -1)\n",
    "    a = ans.replace(' ', '').lower(); hit = [(a.find(c.lower()), c) for c in CLASSES if c.lower() in a]\n",
    "    return CLASSES.index(min(hit)[1]) if hit else -1\n",
    "ll_idx = np.concatenate([np.where(y_es == c)[0][:6] for c in range(10)])        # 클래스당 6장 = 60장\n",
    "ll = np.array([parse(ask_image(es.image[i]['bytes'], PROMPT, max_tokens=12)) for i in ll_idx])\n",
    "print(f\"llava acc={(ll == y_es[ll_idx]).mean():.3f} | CLIP(photo) 같은 60장 acc={(zs['photo'][ll_idx] == y_es[ll_idx]).mean():.3f} | {report()}\")\n",
    "print('llava 답 분포:', pd.Series(ll).map(lambda i: CLASSES[i] if i >= 0 else 'None').value_counts().to_dict())\n",
    "# → 300장 실험에서 llava 0.367, CLIP(photo) 0.403. llava 답의 절반이 목록 첫 라벨 AnnualCrop 이었다"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "74b18e57",
   "metadata": {},
   "source": [
    "## ⑤-4 Fashion — 이미지 임베딩(CLIP) ‖ 제목 임베딩(bge-m3)\n",
    "두 벡터를 옆으로 이어붙이면(1536차원) 그것이 '결합'이다. bge-m3 는 4,000건 × 약 20토큰 → 약 80 neurons."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "38b6fa16",
   "metadata": {},
   "outputs": [],
   "source": [
    "fs_img = [decode(d) for d in fs.image]\n",
    "E_img = clip_image(fs_img)                                                # (4000, 512)\n",
    "E_txt = np.array(embed(fs.productDisplayName.tolist()), np.float32)      # bge-m3 (4000, 1024)\n",
    "E_txt /= np.linalg.norm(E_txt, axis=1, keepdims=True)\n",
    "feats = {'text': E_txt, 'image': E_img, 'concat': np.hstack([E_img, E_txt])}   # 결합 = 이어붙이기\n",
    "print({k: v.shape for k, v in feats.items()}, '|', report())\n",
    "# → {'text': (4000, 1024), 'image': (4000, 512), 'concat': (4000, 1536)}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b18f30f8",
   "metadata": {},
   "source": [
    "## ⑤-5 이미지 → 텍스트 → 벡터 경로 (llava 캡션 20장)\n",
    "Cloudflare 에 CLIP 이 없을 때의 우회로. llava 로 한 문장 캡션을 만들고 bge-m3 로 임베딩하면 제목 임베딩과 **같은 공간**에 놓인다. 제목으로 학습한 분류기가 캡션에도 먹히는지 본다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "95ca58b1",
   "metadata": {},
   "outputs": [],
   "source": [
    "cap_idx = fs_te[:20]\n",
    "caps = [ask_image(fs.image[i]['bytes'], 'Describe this product in one sentence.', max_tokens=40) for i in cap_idx]\n",
    "E_cap = np.array(embed(caps), np.float32); E_cap /= np.linalg.norm(E_cap, axis=1, keepdims=True)\n",
    "clf_txt = LogisticRegression(max_iter=3000, C=5).fit(E_txt[fs_tr], y_fs[fs_tr])        # 제목 임베딩으로 학습\n",
    "print('제목으로 학습한 분류기 → 캡션 임베딩 acc =', (clf_txt.predict(E_cap) == y_fs[cap_idx]).mean(), '|', report())\n",
    "for t, c in list(zip(fs.productDisplayName[cap_idx], caps))[:5]: print(f'  {t}  →  {c}')\n",
    "# → 150장 실험: 캡션 acc 0.86 (제목 자체는 0.99). 캡션은 \"A man wearing a plaid shirt.\" 처럼 브랜드·품목명이 빠진다"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f9c0e699",
   "metadata": {},
   "source": [
    "## ⑥ 평가 — seed 5회 (train 3,000 에서 2,500 재추출)\n",
    "왼쪽: EuroSAT 데이터 효율 곡선(클래스당 10 / 100 / 전체). 오른쪽: Fashion 텍스트 단독 / 이미지 단독 / 결합 macro-F1."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4222416e",
   "metadata": {},
   "outputs": [],
   "source": [
    "rows = []\n",
    "for n_per in ['10', '100', 'all']:\n",
    "    for seed in range(5):\n",
    "        rng = np.random.default_rng(seed)\n",
    "        idx = es_tr if n_per == 'all' else np.concatenate([rng.choice(es_tr[y_es[es_tr] == c], int(n_per), replace=False) for c in range(10)])\n",
    "        acc = LogisticRegression(max_iter=2000).fit(E_es[idx], y_es[idx]).score(E_es[es_te], y_es[es_te])\n",
    "        rows.append({'labels/class': n_per, 'seed': seed, 'acc': acc})\n",
    "        if n_per == 'all': break                                          # 전체는 재추출이 없어 1회\n",
    "probe = pd.DataFrame(rows).groupby('labels/class').acc.agg(['mean', 'std']).reindex(['10', '100', 'all']); print(probe.round(3))\n",
    "rows, per = [], {}\n",
    "for seed in range(5):\n",
    "    sub = np.random.default_rng(seed).choice(fs_tr, 2500, replace=False)\n",
    "    for k, E in feats.items():\n",
    "        pr = LogisticRegression(max_iter=3000, C=5).fit(E[sub], y_fs[sub]).predict(E[fs_te])\n",
    "        rows.append({'feat': k, 'seed': seed, 'macro_f1': f1_score(y_fs[fs_te], pr, average='macro')})\n",
    "        per.setdefault(k, []).append(f1_score(y_fs[fs_te], pr, average=None))\n",
    "res = pd.DataFrame(rows).groupby('feat').macro_f1.agg(['mean', 'std']); print(res.round(4))\n",
    "print(pd.DataFrame({k: np.mean(v, 0) for k, v in per.items()}, index=TOP).round(3))\n",
    "# → 페이지 실험(train 16,200): 프로브 10/클래스 0.705±0.015 · 100/클래스 0.850±0.002 · 전체 0.921 | Fashion text .989 image .974 concat .991"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9efd27ed",
   "metadata": {},
   "source": [
    "## ⑦ 군집·시각화 — 군집은 원 임베딩에서, 2차원은 그림에만\n",
    "K-means k=10 을 원 임베딩(512 / 1024차원)에서 돌려 정답과의 ARI 를 seed 5회로 잰다. UMAP 좌표 위의 거리는 해석하지 않는다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e42f4893",
   "metadata": {},
   "outputs": [],
   "source": [
    "import umap, matplotlib.pyplot as plt\n",
    "from sklearn.cluster import KMeans\n",
    "from sklearn.metrics import adjusted_rand_score as ARI\n",
    "def umap2d(E, seed=0): return umap.UMAP(n_neighbors=15, min_dist=0.1, metric='cosine', random_state=seed).fit_transform(E)\n",
    "sets = [('EuroSAT · CLIP', E_es, y_es), ('Fashion · CLIP 이미지', E_img, y_fs), ('Fashion · bge-m3 제목', E_txt, y_fs)]\n",
    "for name, E, y in sets:\n",
    "    aris = [ARI(y, KMeans(10, n_init=10, random_state=s).fit_predict(E)) for s in range(5)]      # 군집은 원 임베딩에서\n",
    "    print(f'{name:22s} K-means k=10 ARI = {np.mean(aris):.3f} ± {np.std(aris, ddof=1):.3f}')\n",
    "fig, ax = plt.subplots(1, 3, figsize=(16, 5))\n",
    "for a, (name, E, y) in zip(ax, sets):\n",
    "    U = umap2d(E); a.scatter(U[:, 0], U[:, 1], c=y, cmap='tab10', s=3); a.set_title(name); a.set_xticks([]); a.set_yticks([])\n",
    "plt.show()\n",
    "# → 페이지 실험: EuroSAT ARI 0.457±0.026 · Fashion 이미지 0.803±0.066 · Fashion 제목 0.545±0.033 (Fashion 은 표본이 달라 ±0.05 차이)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13d8dea7",
   "metadata": {},
   "source": [
    "## ⑧ 보고표 — 본문 성능표 + 부록 재현성 항목\n",
    "논문에는 '무엇을 호출했는가'가 전부 적혀야 한다. 모델 ID, 전처리, 프롬프트 문구, seed, 군집 설정."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "316e8417",
   "metadata": {},
   "outputs": [],
   "source": [
    "tbl = pd.DataFrame({'특징': ['픽셀 8×8 + 로지스틱', 'CLIP 제로샷 · \"a satellite photo of {}\"', 'CLIP 제로샷 · \"{}\"', 'llava-1.5 제로샷 (60장)',\n",
    "                             'CLIP + 프로브 (10/클래스)', 'CLIP + 프로브 (100/클래스)', 'CLIP + 프로브 (전체)'],\n",
    "                    '정확도': [acc_pix, (zs['photo'] == y_es).mean(), (zs['name'] == y_es).mean(), (ll == y_es[ll_idx]).mean(), *probe['mean']]})\n",
    "print(tbl.round(3).to_markdown(index=False)); print(); print(res.round(4).to_markdown())\n",
    "repro = {'clip': 'openai/clip-vit-base-patch32 (transformers, 추론만)', 'text_embed': '@cf/baai/bge-m3', 'vlm': '@cf/llava-hf/llava-1.5-7b-hf',\n",
    "         'prompts': {'clip': ['a satellite photo of {}', '{}'], 'llava_label': PROMPT, 'llava_caption': 'Describe this product in one sentence.'},\n",
    "         'preprocess': 'CLIPProcessor 기본(224 리사이즈·중심 크롭·정규화), 임베딩 L2 정규화', 'classifier': 'LogisticRegression lbfgs (C=1 EuroSAT, C=5 Fashion)',\n",
    "         'seeds': [0, 1, 2, 3, 4], 'umap': 'n_neighbors=15, min_dist=0.1, cosine, seed 0', 'kmeans': 'k=10, n_init=10', 'usage': report()}\n",
    "print(json.dumps(repro, ensure_ascii=False, indent=1))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e2ffb5f1",
   "metadata": {},
   "source": [
    "## ⑨ 자기 데이터로 바꾸기 — ① 블록만 교체\n",
    "이미지 폴더 + CSV(`file, text, label`) 형식이면 아래 함수로 `es`/`fs` 자리를 채우고 ④~⑧ 을 그대로 돌린다. 라벨이 없으면 제로샷(⑤-2)과 군집(⑦)만 쓴다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "14934ea9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def load_my_data(csv_path, img_dir):\n",
    "    \"\"\"CSV 열: file(이미지 파일명), text(제목·설명, 없으면 빈칸), label(범주, 없으면 빈칸)\"\"\"\n",
    "    my = pd.read_csv(csv_path)\n",
    "    imgs = [Image.open(os.path.join(img_dir, f)).convert('RGB') for f in my.file]\n",
    "    E_i = clip_image(imgs)                                                            # 이미지 → 512\n",
    "    E_t = None\n",
    "    if my.text.notna().any():\n",
    "        E_t = np.array(embed(my.text.fillna('').tolist()), np.float32); E_t /= np.linalg.norm(E_t, axis=1, keepdims=True)\n",
    "    y = my.label.astype('category').cat.codes.values if 'label' in my else None\n",
    "    names = list(my.label.astype('category').cat.categories) if 'label' in my else None\n",
    "    return my, imgs, E_i, E_t, y, names\n",
    "# my, my_img, E_img, E_txt, y_fs, TOP = load_my_data('my_data.csv', 'images/')    # ← 이 한 줄이 ① 블록 교체\n",
    "# 제로샷: PHRASE 를 내 라벨 이름으로 바꾸고 ⑤-2 실행 · 회귀에 넣을 스칼라: (E_img @ clip_text(['a photo of a metal roof']).T) 처럼 개념 점수 1개로 축약"
   ]
  }
 ],
 "metadata": {
  "accelerator": "GPU",
  "colab": {
   "name": "03_image_multimodal.ipynb",
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
