{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "3019ad7d",
   "metadata": {},
   "source": [
    "# 실습 2 · 텍스트 — 임베딩·제로샷·군집\n",
    "한국외국어대학교 GBT · 대학원 딥러닝 세미나. 데이터: KLUE-YNAT(연합뉴스 제목 7토픽).\n",
    "**한 줄 목표** — 한국어 텍스트를 튜닝 없이 (a) API 임베딩 → 선형 분류기, (b) LLM 제로샷 라벨, (c) 라벨 없는 군집으로 숫자 변수로 바꾸고, 세 결과를 같은 표에 놓는다.\n",
    "Colab T4 기준 전체 15분 이내. Cloudflare 무료 한도(하루 10,000 neurons) 중 이 노트북은 약 2,500 neurons 를 쓴다(임베딩 190 · 제로샷 200건 720 · 퓨샷 A/B 120건 1,450 · 군집 이름 20)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0c427e64",
   "metadata": {},
   "source": [
    "## ① 설치·토큰\n",
    "**관찰 포인트** — 토큰은 코드에 적지 않고 `getpass` 로 넣는다. 계정 ID 는 대시보드 Workers & Pages 화면 오른쪽에 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fe954def",
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip -q install umap-learn hdbscan pyarrow requests scikit-learn matplotlib\n",
    "import os, getpass\n",
    "os.environ['CLOUDFLARE_ACCOUNT_ID'] = input('Cloudflare Account ID: ').strip()\n",
    "os.environ['CLOUDFLARE_API_TOKEN']  = getpass.getpass('Cloudflare API Token (Workers AI 권한): ')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11cf35c3",
   "metadata": {},
   "source": [
    "### Cloudflare Workers AI 호출 함수\n",
    "강의 페이지 §3 과 같은 코드다. `USED` 에 neurons 가 누적된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "689a6596",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json, time, requests\n",
    "ACC = os.environ['CLOUDFLARE_ACCOUNT_ID']; TOK = os.environ['CLOUDFLARE_API_TOKEN']\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, retries=4):\n",
    "    for k in range(retries):\n",
    "        r = requests.post(BASE + model, headers={\"Authorization\": f\"Bearer {TOK}\"},\n",
    "                          data=json.dumps(body, ensure_ascii=False).encode('utf-8'), timeout=120).json()\n",
    "        if r.get('success'):\n",
    "            USED['calls'] += 1                                   # LLM 은 usage.neurons, 임베딩은 meta.neurons\n",
    "            USED['neurons'] += float(((r['result'].get('usage') or r['result'].get('meta') or {}).get('neurons') or 0))\n",
    "            return r['result']\n",
    "        time.sleep(3 * (k + 1))          # 속도 제한·용량 부족이면 잠시 기다렸다 재시도\n",
    "    raise RuntimeError(r.get('errors'))\n",
    "\n",
    "def report(): return f\"API 호출 {USED['calls']}회, 사용 neurons {USED['neurons']:.1f} (무료 한도 10,000/일)\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b71047d4",
   "metadata": {},
   "outputs": [],
   "source": [
    "def embed(texts, model=\"@cf/baai/bge-m3\", batch=100):\n",
    "    \"\"\"문장 리스트 → 1024차원 벡터 리스트. 한 번에 100문장.\"\"\"\n",
    "    out = []\n",
    "    for i in range(0, len(texts), batch):\n",
    "        out.extend(_post(model, {\"text\": list(texts[i:i+batch])})['data'])\n",
    "    return out\n",
    "\n",
    "def zeroshot(text, labels, system, examples=None, model=\"@cf/meta/llama-3.3-70b-instruct-fp8-fast\"):\n",
    "    \"\"\"라벨 목록 중 하나를 답하게 한다. examples=[(text,label),...] 를 주면 퓨샷.\"\"\"\n",
    "    msgs = [{\"role\": \"system\", \"content\": system}]\n",
    "    for t, l in (examples or []): msgs += [{\"role\": \"user\", \"content\": t}, {\"role\": \"assistant\", \"content\": l}]\n",
    "    msgs.append({\"role\": \"user\", \"content\": text})\n",
    "    ans = (_post(model, {\"messages\": msgs, \"max_tokens\": 12, \"temperature\": 0}).get('response') or '').strip()\n",
    "    hit = [(ans.find(l), l) for l in labels if l in ans]      # 답 안에 가장 먼저 나오는 라벨\n",
    "    return (min(hit)[1] if hit else None), ans"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "935badfe",
   "metadata": {},
   "source": [
    "## ② 데이터 로드\n",
    "Hugging Face 의 parquet 을 직접 읽는다. 라벨 순서는 `klue/klue` ynat 의 ClassLabel 순서다(0 IT과학 … 6 정치).\n",
    "**관찰 포인트** — train 과 val 의 라벨 분포가 다르다(val 은 사회 기사가 40%). macro-F1 을 쓰는 이유다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1cc26176",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd, numpy as np\n",
    "U = \"https://huggingface.co/api/datasets/klue/klue/parquet/ynat/{}/0.parquet\"\n",
    "tr = pd.read_parquet(U.format('train'));  va = pd.read_parquet(U.format('validation'))\n",
    "LABELS = ['IT과학','경제','사회','생활문화','세계','스포츠','정치']          # ClassLabel.names 순서\n",
    "print(tr.shape, va.shape); print(tr[['title','label']].head(3).to_string())\n",
    "print(pd.DataFrame({'train': tr.label.value_counts(normalize=True).sort_index().round(3),\n",
    "                    'val':   va.label.value_counts(normalize=True).sort_index().round(3)}).set_axis(LABELS))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "457a2a35",
   "metadata": {},
   "source": [
    "## ③ 분할·샘플\n",
    "YNAT 은 train/val 이 이미 나뉘어 있다. API 비용을 위해 train 3,000 · val 3,000 을 **층화 추출**한다. 자기 데이터에서는 `train_test_split(..., stratify=y)` 한 줄이 이 자리에 온다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "da56760b",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import train_test_split\n",
    "def strat(df, n, seed=0):\n",
    "    idx, _ = train_test_split(np.arange(len(df)), train_size=n, stratify=df.label.values, random_state=seed); return df.iloc[np.sort(idx)]\n",
    "tr_s = strat(tr, 3000); va_s = strat(va, 3000)\n",
    "Xtr_txt, ytr = tr_s.title.tolist(), tr_s.label.values\n",
    "Xva_txt, yva = va_s.title.tolist(), va_s.label.values\n",
    "print(len(Xtr_txt), len(Xva_txt))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f9f2e697",
   "metadata": {},
   "source": [
    "## ④ 베이스라인 — TF-IDF + 로지스틱 회귀\n",
    "**한 줄 목표** — 딥러닝 없이 도달하는 점수를 먼저 적는다. 한국어 제목은 띄어쓰기가 불규칙해 **문자 n-gram(1~3)** 이 단어 n-gram 보다 낫다(둘 다 돌려 확인).\n",
    "**관찰 포인트** — train 3,000 건으로 macro-F1 이 얼마인가. 이 숫자를 아래 임베딩 결과와 비교한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "efa5ec79",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.metrics import f1_score, accuracy_score, confusion_matrix\n",
    "def tfidf_lr(xtr, ytr, xte, analyzer='char_wb', ngram=(1,3), C=10):\n",
    "    vec = TfidfVectorizer(analyzer=analyzer, ngram_range=ngram, min_df=2, sublinear_tf=True, token_pattern=r'(?u)\\b\\w+\\b')\n",
    "    clf = LogisticRegression(C=C, max_iter=3000).fit(vec.fit_transform(xtr), ytr)\n",
    "    return clf.predict(vec.transform(xte))\n",
    "for name, kw in [('문자 1~3', dict(analyzer='char_wb', ngram=(1,3))), ('단어 1~2', dict(analyzer='word', ngram=(1,2)))]:\n",
    "    p = tfidf_lr(Xtr_txt, ytr, Xva_txt, **kw)\n",
    "    print(f\"TF-IDF {name}: macro-F1 {f1_score(yva, p, average='macro'):.3f}  acc {accuracy_score(yva, p):.3f}\")\n",
    "# → 문자 n-gram 이 단어 n-gram 보다 몇 점 높다 (train 3,000 기준 0.7대 초반)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7948185c",
   "metadata": {},
   "source": [
    "## ⑤ 딥러닝 특징 — bge-m3 임베딩 + 로지스틱 회귀\n",
    "**한 줄 목표** — 6,000 문장을 1024차원 벡터로 바꾸는 데 neurons 가 얼마나 드는지, 그 벡터 위의 선형 분류기가 TF-IDF 를 이기는지 본다.\n",
    "**관찰 포인트** — `report()` 의 neurons. 약 190 이면 정상이다(제목 1건 ≈ 29 토큰, 6,000건 ≈ 17만 토큰 × 1,075/M)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "278103ae",
   "metadata": {},
   "outputs": [],
   "source": [
    "t0 = time.time()\n",
    "Etr = np.array(embed(Xtr_txt), dtype=np.float32)     # (3000, 1024)\n",
    "Eva = np.array(embed(Xva_txt), dtype=np.float32)     # (3000, 1024)\n",
    "print(Etr.shape, Eva.shape, f\"{time.time()-t0:.0f}s\", report())\n",
    "np.save('emb_train.npy', Etr); np.save('emb_val.npy', Eva)          # 캐시: 다시 부르지 않는다"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e187042d",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import StratifiedKFold, cross_val_score\n",
    "best = max(((cross_val_score(LogisticRegression(C=C, max_iter=3000), Etr, ytr, cv=StratifiedKFold(5, shuffle=True, random_state=0),\n",
    "                              scoring='f1_macro').mean(), C) for C in [0.3, 1, 3, 10, 30]))\n",
    "C_best = best[1]; print('C(5-fold) =', C_best, f'cv macro-F1 {best[0]:.3f}')\n",
    "clf = LogisticRegression(C=C_best, max_iter=3000).fit(Etr, ytr); p_emb = clf.predict(Eva)\n",
    "print(f\"임베딩+LR: macro-F1 {f1_score(yva, p_emb, average='macro'):.3f}  acc {accuracy_score(yva, p_emb):.3f}\")\n",
    "# → 같은 train 3,000 의 TF-IDF 보다 macro-F1 이 약 0.09 높다 (본 실험 0.779 vs 0.691)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ce58f341",
   "metadata": {},
   "source": [
    "### 제로샷 — llama-3.3-70b 에 라벨만 답하게 한다\n",
    "**한 줄 목표** — 학습 데이터 0건으로 얻는 점수와 **라벨 미매칭 비율**을 본다. 200건 ≈ 600 neurons.\n",
    "**관찰 포인트** — 모델이 라벨 외의 말을 붙이는 비율(`None`). 그리고 퓨샷 예시 7개를 다른 7개로 바꾸면 예측이 몇 %나 바뀌는가(아래 셀)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8fde018d",
   "metadata": {},
   "outputs": [],
   "source": [
    "SYS = \"다음은 한국어 뉴스 제목이다. 주제를 \" + \"/\".join(LABELS) + \" 중 정확히 하나로 분류하고, 그 라벨만 한 단어로 답하라. 설명은 쓰지 않는다.\"\n",
    "zs_df = strat(va_s, 200)                                  # val 샘플 안에서 다시 층화 200건\n",
    "zs = [zeroshot(t, LABELS, SYS) for t in zs_df.title]\n",
    "pred = np.array([LABELS.index(l) if l else -1 for l, _ in zs]); y = zs_df.label.values\n",
    "ok = pred >= 0\n",
    "print(f\"제로샷 200건: macro-F1 {f1_score(y[ok], pred[ok], average='macro'):.3f}  acc {accuracy_score(y, pred):.3f}  미매칭 {(~ok).mean():.1%}\", report())\n",
    "print([a for (_, a), o in zip(zs, ok) if not o][:5])     # 매칭 실패한 원문 답"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "272cb199",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 퓨샷 A/B: 클래스당 1건씩 7개 예시를 두 세트 만들어 같은 60건에 적용 → 두 세트 간 예측 불일치 비율\n",
    "def pick_examples(seed):\n",
    "    rng = np.random.default_rng(seed)\n",
    "    return [(tr.title.iloc[rng.choice(np.where(tr.label.values == c)[0])], LABELS[c]) for c in range(7)]\n",
    "sub = zs_df.head(60)\n",
    "pa = [zeroshot(t, LABELS, SYS, pick_examples(11))[0] for t in sub.title]\n",
    "pb = [zeroshot(t, LABELS, SYS, pick_examples(22))[0] for t in sub.title]\n",
    "print(f\"퓨샷 A acc {np.mean([LABELS[l]==a for l,a in zip(sub.label, pa)]):.3f}  B acc {np.mean([LABELS[l]==b for l,b in zip(sub.label, pb)]):.3f}  A≠B {np.mean([a!=b for a,b in zip(pa,pb)]):.1%}\", report())\n",
    "# → 예시만 바꿔도 예측의 수 % 가 뒤집힌다. 제로샷 라벨을 변수로 쓸 때 이 값을 측정오차로 보고한다"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c8960981",
   "metadata": {},
   "source": [
    "## ⑥ 평가 — seed 5회, 혼동행렬\n",
    "학습 샘플을 바꿔(3,000 중 2,500 재추출) 5회 반복해 평균±표준편차를 만든다. 표에 쓰는 값은 이것이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "34e81d2c",
   "metadata": {},
   "outputs": [],
   "source": [
    "rows = []\n",
    "for s in range(5):\n",
    "    sub, _ = train_test_split(np.arange(3000), train_size=2500, stratify=ytr, random_state=s)\n",
    "    pe = LogisticRegression(C=C_best, max_iter=3000).fit(Etr[sub], ytr[sub]).predict(Eva)\n",
    "    pt = tfidf_lr([Xtr_txt[i] for i in sub], ytr[sub], Xva_txt)\n",
    "    rows.append({'seed': s, 'TF-IDF+LR': f1_score(yva, pt, average='macro'), '임베딩+LR': f1_score(yva, pe, average='macro')})\n",
    "res = pd.DataFrame(rows).set_index('seed'); print(res.agg(['mean', 'std']).round(3))\n",
    "cm = confusion_matrix(yva, p_emb); pairs = sorted(((cm[i,j]+cm[j,i], LABELS[i], LABELS[j]) for i in range(7) for j in range(i+1,7)), reverse=True)[:3]\n",
    "print('가장 헷갈리는 쌍:', pairs)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1826a152",
   "metadata": {},
   "source": [
    "## ⑦ 군집·시각화 — UMAP 지도 + K-means/HDBSCAN\n",
    "**한 줄 목표** — 라벨을 쓰지 않고 임베딩만으로 7개 덩어리가 나오는지, 정답과 ARI 로 얼마나 맞는지 본다.\n",
    "**관찰 포인트** — 군집은 **1024차원 원 임베딩**에서 하고 UMAP 2차원은 그림에만 쓴다. UMAP 좌표 위 거리는 해석하지 않는다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "597262a8",
   "metadata": {},
   "outputs": [],
   "source": [
    "import umap, hdbscan, matplotlib.pyplot as plt\n",
    "from sklearn.cluster import KMeans\n",
    "from sklearn.metrics import adjusted_rand_score as ARI, normalized_mutual_info_score as NMI\n",
    "U = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(Eva)\n",
    "km = [KMeans(7, n_init=10, random_state=s).fit_predict(Eva) for s in range(5)]\n",
    "ari = [ARI(yva, k) for k in km]; print(f\"K-means k=7: ARI {np.mean(ari):.3f}±{np.std(ari, ddof=1):.3f}  NMI {NMI(yva, km[0]):.3f}\")\n",
    "hd = hdbscan.HDBSCAN(min_cluster_size=50).fit_predict(Eva.astype(np.float64)); m = hd >= 0\n",
    "print(f\"HDBSCAN: 군집 {hd.max()+1}개, 잡음 {(~m).mean():.1%}, ARI(잡음 제외) {ARI(yva[m], hd[m]):.3f}\")\n",
    "fig, ax = plt.subplots(1, 2, figsize=(11, 5))\n",
    "for a, c, t in [(ax[0], yva, '색 = 정답'), (ax[1], km[0], '색 = K-means')]:\n",
    "    a.scatter(U[:,0], U[:,1], c=c, cmap='tab10', s=4, alpha=.6); a.set_title(t); a.set_xticks([]); a.set_yticks([])\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e5420b29",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 군집 해석: 중심 최근접 제목 5개 → LLM 한 줄 이름 (7회 호출, ~20 neurons)\n",
    "ct = pd.crosstab(km[0], yva); ct.columns = LABELS\n",
    "centers = KMeans(7, n_init=10, random_state=0).fit(Eva).cluster_centers_\n",
    "for c in range(7):\n",
    "    d = np.linalg.norm(Eva - centers[c], axis=1); d[km[0] != c] = np.inf; reps = [Xva_txt[i] for i in np.argsort(d)[:5]]\n",
    "    name = _post(\"@cf/meta/llama-3.3-70b-instruct-fp8-fast\", {\"messages\": [\n",
    "        {\"role\": \"system\", \"content\": \"아래 뉴스 제목 묶음에 공통되는 주제를 한국어 명사구 10자 이내로 한 줄만 답하라.\"},\n",
    "        {\"role\": \"user\", \"content\": \"\\n\".join('- ' + r for r in reps)}], \"max_tokens\": 20, \"temperature\": 0})['response'].strip()\n",
    "    print(f\"군집 {c} ({(km[0]==c).sum()}건) → {name} | 최다 정답 {ct.columns[ct.values[c].argmax()]} {ct.values[c].max()/ct.values[c].sum():.0%}\")\n",
    "print(report())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "173eb858",
   "metadata": {},
   "source": [
    "## ⑧ 보고표 생성\n",
    "논문 본문 표(성능)와 부록 표(재현성)를 바로 붙일 수 있는 형태로 만든다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "59172407",
   "metadata": {},
   "outputs": [],
   "source": [
    "import datetime\n",
    "main_tbl = pd.DataFrame({\n",
    "  '방법': ['TF-IDF(문자 1~3)+LR', 'bge-m3 임베딩+LR', 'llama-3.3-70b 제로샷'],\n",
    "  '학습 표본': ['2,500 (seed 5회)', '2,500 (seed 5회)', '0'],\n",
    "  'macro-F1': [f\"{res['TF-IDF+LR'].mean():.3f} ± {res['TF-IDF+LR'].std():.3f}\", f\"{res['임베딩+LR'].mean():.3f} ± {res['임베딩+LR'].std():.3f}\",\n",
    "               f\"{f1_score(y[ok], pred[ok], average='macro'):.3f} (200건, 미매칭 {(~ok).mean():.1%})\"]})\n",
    "appendix = pd.DataFrame([\n",
    "  ['임베딩 모델', '@cf/baai/bge-m3 (Cloudflare Workers AI), 1024차원, 정규화 없음'],\n",
    "  ['제로샷 모델', '@cf/meta/llama-3.3-70b-instruct-fp8-fast, temperature 0, max_tokens 12'],\n",
    "  ['분류기', f'LogisticRegression(C={C_best}, lbfgs, max_iter 3000), C 는 train 5-fold 로 선택'],\n",
    "  ['TF-IDF', 'char_wb (1,3), min_df 2, sublinear_tf; LogisticRegression C=10'],\n",
    "  ['군집', 'UMAP(n_neighbors 15, min_dist 0.1, seed 0) 그림용 / K-means k=7, HDBSCAN min_cluster_size 50 는 1024차원'],\n",
    "  ['seed', '0~4 (학습 재추출)'], ['실행일', datetime.date.today().isoformat()], ['API 사용량', report()]], columns=['항목', '값'])\n",
    "print(main_tbl.to_markdown(index=False)); print(); print(appendix.to_markdown(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b9ee949",
   "metadata": {},
   "source": [
    "## ⑨ 자기 데이터로 바꾸기\n",
    "바꿀 것은 **② 데이터 로드 셀 하나**다. `text` 와 `label` 열을 가진 CSV 를 만들고 아래 셀로 ②를 대체하면 ③~⑧ 이 그대로 돌아간다.\n",
    "- 라벨이 있으면: 분류·제로샷·군집 전부 실행.\n",
    "- 라벨이 없으면(공시·의사록·리뷰): `label` 열을 비워 두고 ⑤ 임베딩 → ⑦ 군집 → 군집 이름까지만 실행. 군집 비율·제로샷 라벨이 곧 변수다.\n",
    "- 긴 문서(수천 자)는 bge-m3 문맥 한도(8,192 토큰) 안에서 문단 단위로 잘라 평균하거나 첫 문단만 쓴다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "46af40cb",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ② 대체 셀 — text / label 열 이름만 맞추면 된다\n",
    "# from google.colab import files; up = files.upload(); path = next(iter(up))\n",
    "path = 'my_data.csv'\n",
    "df = pd.read_csv(path)\n",
    "df = df.rename(columns={'text': 'title'}).dropna(subset=['title'])\n",
    "LABELS = sorted(df.label.dropna().unique().tolist()) if 'label' in df else []   # 라벨 이름 그대로 쓴다(제로샷 프롬프트에도 들어간다)\n",
    "if LABELS: df['label'] = df.label.map({l: i for i, l in enumerate(LABELS)})\n",
    "tr, va = train_test_split(df, test_size=0.3, stratify=df.label if LABELS else None, random_state=0)\n",
    "print(tr.shape, va.shape, LABELS)"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
