텍스트 —
임베딩·제로샷·군집
한국어 뉴스 제목 5만 건을 튜닝 없이 숫자 변수로 바꾼다. TF-IDF 기준선에서 출발해 API 임베딩, LLM 제로샷, 라벨 없는 군집까지 같은 표에 올리고, 그 숫자가 무엇을 담고 있는지 검증하는 절차를 익힌다.
텍스트가 변수가 되기까지
텍스트를 숫자로 바꾸는 비용은 거의 0이 되었다. 남은 비용은 그 숫자가 무엇을 재는지 확인하는 일이다.
의사록 200쪽에 형광펜을 긋던 방
2019년, 경제학과 대학원의 한 연구실. 박사과정 학생 두 명이 금융통화위원회 의사록을 문단 단위로 잘라 놓고 "긴축 / 완화 / 중립" 셋 중 하나를 손으로 붙이고 있었다. 한 사람이 한 회차를 코딩하는 데 이틀이 걸렸고, 둘의 일치율을 계산해 부록에 적어야 했다. 뉴스 제목으로 정책 관심도를 재려던 옆 팀은 키워드 사전을 만들다 학기가 끝났다.
지금은 같은 일이 API 호출 한 번으로 끝난다. 이 회차에서 실제로 돌린 실험에서는 뉴스 제목 15,107건을 1,024차원 벡터로 바꾸는 데 약 8분과 무료 한도의 5%가 들었고, 대형 언어모델에 "이 제목의 주제는?"이라고 물어 라벨을 받는 데는 건당 1초가 걸렸다. 수만 건의 텍스트가 회귀식에 들어갈 수 있는 숫자 열이 되는 데 하루가 걸리지 않는다.
반전은 그 다음에 온다. 그 벡터가 무엇을 담고 있는지는 API 가 알려 주지 않는다. 같은 제목에 라벨을 붙이라고 하면 예시 일곱 개를 바꾸는 것만으로 답이 바뀌고, 라벨 없이 군집을 만들면 우리가 기대한 "7개 토픽"이 아니라 "등교 수업 · 거리두기 · 인사말" 같은 덩어리가 나온다. 텍스트를 변수로 만드는 비용은 0이 됐지만, 그 변수의 측정오차를 보고하는 일은 여전히 연구자의 몫이다. 이 회차는 그 검증 절차를 손에 익히는 시간이다.
텍스트를 변수로 쓰는 경제·경영 연구는 크게 세 갈래로 발전해 왔다. 첫째는 사전(dictionary) 방식이다. 긍정·부정 단어 목록을 만들어 문서마다 비율을 센다. 투명하지만 사전이 분야마다 다르고, "금리 인상 우려 완화"처럼 부정어와 긍정어가 한 문장에 있으면 무너진다. 둘째는 단어 빈도 기반 기계학습이다. TF-IDF 로 문서를 수만 차원의 희소 벡터로 만들고 선형 모델을 학습한다. 라벨이 충분하면 강하고, 이 회차의 기준선이다. 셋째가 사전학습 표현이다. 대규모 말뭉치로 미리 학습된 모델이 문장을 조밀한 벡터로 바꿔 주므로, 라벨이 적어도 의미가 비슷한 문장이 가까이 놓인다.
세 번째 갈래가 실무에 들어온 결정적 변화는 모델을 직접 돌릴 필요가 없어졌다는 점이다. 이 회차는 GPU 학습을 하지 않는다. Cloudflare Workers AI 의 무료 한도 안에서 임베딩과 제로샷을 호출하고, 나머지는 노트북의 scikit-learn 으로 처리한다. 학생 각자의 계정에서 같은 코드가 그대로 돈다.
생각해 볼 질문
내 논문의 텍스트 자료(공시, 리뷰, 회의록, 기사)에 라벨이 있는가. 있다면 그 라벨은 누가 어떤 기준으로 붙였고, 없다면 이 회차의 세 축 중 어느 것이 변수를 만들어 주는가.
출처 Gentzkow, Kelly & Taddy, "Text as Data", Journal of Economic Literature 57(3), 2019, aeaweb.org (doi 10.1257/jel.20181020) · Cloudflare Workers AI 요금·모델 문서, developers.cloudflare.com/workers-ai/platform/pricing
데이터
train 과 val 은 라벨 분포도 시기도 다르다. 이 차이가 이후 모든 숫자에 실려 있다.
제목 한 줄과 토픽 하나
KLUE 벤치마크의 YNAT(Yonhap News Agency Topic Classification)은 연합뉴스 제목에 7개 토픽 중 하나를 붙인 공개 데이터다. 제목은 평균 27자, 라벨은 IT과학 · 경제 · 사회 · 생활문화 · 세계 · 스포츠 · 정치 순서(0~6)다.
| 항목 | train | validation |
|---|---|---|
| 건수 | 45,678 | 9,107 |
| 라벨 분포(최다 → 최소) | 세계 18% · 스포츠 17% · 정치 16% · 경제 13% · 생활문화 13% · IT과학 11% · 사회 11% | 사회 41% · 생활문화 15% · 경제 15% · 세계 9% · 정치 8% · 스포츠 6% · IT과학 6% |
| 기사 연도 | 2016~2019 (2020 없음) | 2020 이 47%, 2016~2019 나머지 |
| 제목 길이 | 평균 27.4자, 4~44자 | |
| 접근 | Hugging Face klue/klue, config ynat. parquet URL 로 직접 로드, 로그인 불필요 | |
| 전처리 | 없음. 제목 원문 그대로 사용(특수문자·한자 유지) | |
두 가지를 미리 적어 둔다. 첫째, val 은 사회 기사가 41%라 정확도(accuracy)는 사회 클래스 하나에 끌려간다. 그래서 이 회차의 주 지표는 클래스별 F1 의 단순 평균인 macro-F1이다. 둘째, val 의 절반이 2020년 기사인데 train 에는 2020년이 없다. 코로나 시기의 어휘("거리두기", "등교")는 학습 데이터에 없는 셈이고, 이 시기 차이는 §5 군집에서 그대로 드러난다.
데이터 로드
노트북 ② 셀과 같다. 라벨 이름 순서는 데이터셋의 ClassLabel.names 로 확인한 값이다. 이 순서가 틀리면 이후 모든 표가 어긋난다.
import pandas as pd, numpy as np
U = "https://huggingface.co/api/datasets/klue/klue/parquet/ynat/{}/0.parquet"
tr = pd.read_parquet(U.format('train')); va = pd.read_parquet(U.format('validation'))
LABELS = ['IT과학','경제','사회','생활문화','세계','스포츠','정치'] # ClassLabel.names 순서
print(tr.shape, va.shape); print(tr[['title','label']].head(3).to_string())
print(pd.DataFrame({'train': tr.label.value_counts(normalize=True).sort_index().round(3),
'val': va.label.value_counts(normalize=True).sort_index().round(3)}).set_axis(LABELS))
# → (45678, 5) (9107, 5) … 사회: train 0.112 / val 0.406
조작 안내 — 셀을 실행하고 train 과 val 의 분포 표를 나란히 본다.
관찰 포인트 — 사회 클래스의 비율이 두 분할에서 4배 차이 난다. 이 한 줄이 "왜 macro-F1 인가"의 답이다.
라벨 순서는 공식 데이터셋 카드의 features.label.names 에서 읽었고, 기사 URL 의 네이버 섹션 코드(sid1: 100 정치, 101 경제, 102 사회, 103 생활문화, 104 세계, 105 IT과학)와 교차표를 만들어 대각선이 맞는지 확인했다. 스포츠 기사는 네이버에서 별도 도메인이라 sid1 이 없어 교차표에 거의 나타나지 않는다. YNAT 의 라벨은 URL 섹션이 아니라 사람이 다시 붙인 것이므로 둘이 완전히 같지는 않다(예: 과학기술 정책 기사가 IT 섹션에 있어도 라벨은 사회).
YNAT 은 KLUE 논문(Park et al. 2021)에서 정의됐고, 학습·검증 분할이 공개돼 있다. 시험(test) 분할은 라벨이 비공개이므로 이 회차는 validation 을 최종 평가에 쓴다. 논문에서라면 validation 을 다시 "튜닝용 / 최종 평가용"으로 나누거나, 튜닝은 train 내부 교차검증으로만 하고 validation 은 한 번만 열어야 한다. 이 회차는 후자를 따른다(로지스틱 회귀의 C 는 train 5-fold 로 정한다).
출처
Park et al., "KLUE: Korean Language Understanding Evaluation", NeurIPS 2021 Datasets and Benchmarks, arxiv.org/abs/2105.09680 ·
데이터셋 카드 huggingface.co/datasets/klue/klue (features 확인: datasets-server /info?dataset=klue/klue&config=ynat)
베이스라인 — TF-IDF + 로지스틱 회귀
라벨 4만 5천 건이 있으면 문자 n-gram 하나로 macro-F1 0.815 에 닿는다. 이 숫자를 이기지 못하는 방법은 쓸 이유가 없다.
문자 n-gram 이 단어 n-gram 을 크게 이긴다
한 줄 목표: 딥러닝 없이 도달하는 점수를 먼저 적어 두고, 한국어 제목에서는 단어보다 문자 단위 특징이 낫다는 것을 확인한다.
| 특징 | 차원 | C (3-fold) | val macro-F1 | val 정확도 |
|---|---|---|---|---|
| TF-IDF 문자 1~3-gram (char_wb) | 119,450 | 3 | 0.815 | 0.816 |
| TF-IDF 단어 1~2-gram | 54,608 | 3 | 0.726 | 0.723 |
실행 환경: train 45,678건 전체 학습, val 9,107건 평가. LogisticRegression(lbfgs, max_iter 3000), C 는 train 3-fold 교차검증으로 선택. 단일 실행(결정적). 2026-09-12, 로컬 CPU.
TF-IDF + 로지스틱 회귀
노트북 ④ 셀. 두 특징을 같은 함수로 돌린다. 노트북은 비용 때문에 train 3,000건 층화 샘플로 돌리므로 숫자는 위 표(전체 학습)보다 낮게 나온다.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, accuracy_score, confusion_matrix
def tfidf_lr(xtr, ytr, xte, analyzer='char_wb', ngram=(1,3), C=10):
vec = TfidfVectorizer(analyzer=analyzer, ngram_range=ngram, min_df=2, sublinear_tf=True, token_pattern=r'(?u)\b\w+\b')
clf = LogisticRegression(C=C, max_iter=3000).fit(vec.fit_transform(xtr), ytr)
return clf.predict(vec.transform(xte))
for name, kw in [('문자 1~3', dict(analyzer='char_wb', ngram=(1,3))), ('단어 1~2', dict(analyzer='word', ngram=(1,2)))]:
p = tfidf_lr(Xtr_txt, ytr, Xva_txt, **kw)
print(f"TF-IDF {name}: macro-F1 {f1_score(yva, p, average='macro'):.3f} acc {accuracy_score(yva, p):.3f}")
# → 문자 n-gram 이 단어 n-gram 보다 몇 점 높다 (train 3,000 기준 0.7대 초반)
조작 안내 — ngram=(1,3) 을 (2,4) 로, min_df 를 1 로 바꿔 다시 돌려 본다.
관찰 포인트 — 단어 기준이 문자 기준보다 항상 낮으면 관찰 성공. 한국어 제목은 조사가 붙고("정부는/정부가") 띄어쓰기가 불규칙해, 단어 단위 어휘는 같은 뜻을 여러 토큰으로 쪼갠다. 열린 과제: 형태소 분석기(kiwi 등)를 끼우면 단어 기준이 문자 기준을 따라잡는지 확인해 보라.
기준선을 먼저 세우는 이유는 두 가지다. 하나는 심사자의 첫 질문이 "간단한 방법으로는 안 되는가"이기 때문이고, 다른 하나는 이 숫자가 라벨의 상한을 알려 주기 때문이다. 문자 n-gram 은 제목의 표면 형태만 보므로, 이 방법으로 0.82 에 닿는다는 것은 라벨의 상당 부분이 어휘만으로 결정된다는 뜻이다. 남은 0.18 에는 라벨 자체의 애매함(사회 vs 생활문화)이 포함돼 있고, 어떤 모델도 그 경계 전체를 넘지는 못한다.
TF-IDF 의 약점은 라벨이 적을 때다. §4 의 데이터 효율 곡선에서 학습 표본이 100~1,000건일 때 두 방법의 차이가 가장 크다. 논문에서 라벨을 직접 붙여야 하는 상황이라면(수백 건이 현실적 한계다) 기준선의 진짜 값은 위 표가 아니라 그 곡선의 왼쪽 끝이다.
출처
scikit-learn TfidfVectorizer 문서, scikit-learn.org ·
Park et al. 2021 (KLUE, YNAT 기준 모델 표).
임베딩과 제로샷 — API 두 번으로 변수 만들기
임베딩은 라벨이 적을 때 강하고, 제로샷은 라벨이 없을 때 유일한 선택지다. 둘 다 비용은 무료 한도 안이다.
호출 함수 둘과 비용표 하나
한 줄 목표: 임베딩과 제로샷 호출을 각각 함수 하나로 감싸고, 응답에 실려 오는 neurons 를 누적해 하루 한도(10,000) 안에서 몇 건을 처리할 수 있는지 미리 계산한다.
공통 호출부 — 노트북 ① 셀
토큰은 getpass 로 넣고 코드에 적지 않는다. 응답의 usage.neurons(LLM) 또는 meta.neurons(임베딩)를 더해 둔다.
import json, time, requests
ACC = os.environ['CLOUDFLARE_ACCOUNT_ID']; TOK = os.environ['CLOUDFLARE_API_TOKEN']
BASE = f"https://api.cloudflare.com/client/v4/accounts/{ACC}/ai/run/"
USED = {'neurons': 0.0, 'calls': 0}
def _post(model, body, retries=4):
for k in range(retries):
r = requests.post(BASE + model, headers={"Authorization": f"Bearer {TOK}"},
data=json.dumps(body, ensure_ascii=False).encode('utf-8'), timeout=120).json()
if r.get('success'):
USED['calls'] += 1 # LLM 은 usage.neurons, 임베딩은 meta.neurons
USED['neurons'] += float(((r['result'].get('usage') or r['result'].get('meta') or {}).get('neurons') or 0))
return r['result']
time.sleep(3 * (k + 1)) # 속도 제한·용량 부족이면 잠시 기다렸다 재시도
raise RuntimeError(r.get('errors'))
def report(): return f"API 호출 {USED['calls']}회, 사용 neurons {USED['neurons']:.1f} (무료 한도 10,000/일)"
임베딩 · 제로샷 — 노트북 ① 셀(둘째)
embed 는 100문장씩 묶어 보내고, zeroshot 은 답 문자열에서 라벨을 찾아 돌려준다(못 찾으면 None).
def embed(texts, model="@cf/baai/bge-m3", batch=100):
"""문장 리스트 → 1024차원 벡터 리스트. 한 번에 100문장."""
out = []
for i in range(0, len(texts), batch):
out.extend(_post(model, {"text": list(texts[i:i+batch])})['data'])
return out
def zeroshot(text, labels, system, examples=None, model="@cf/meta/llama-3.3-70b-instruct-fp8-fast"):
"""라벨 목록 중 하나를 답하게 한다. examples=[(text,label),...] 를 주면 퓨샷."""
msgs = [{"role": "system", "content": system}]
for t, l in (examples or []): msgs += [{"role": "user", "content": t}, {"role": "assistant", "content": l}]
msgs.append({"role": "user", "content": text})
ans = (_post(model, {"messages": msgs, "max_tokens": 12, "temperature": 0}).get('response') or '').strip()
hit = [(ans.find(l), l) for l in labels if l in ans] # 답 안에 가장 먼저 나오는 라벨
return (min(hit)[1] if hit else None), ans
조작 안내 — 제목 두 개로 embed 를 한 번 부르고 report() 를 찍어 본다.
관찰 포인트 — 벡터 길이 1024, neurons 0.02~0.03. 이 숫자에 건수를 곱한 것이 실험 예산이다.
| 모델 | 역할 | 공시 단가 | 실측 (이 실험) | 하루 무료 처리량 |
|---|---|---|---|---|
| @cf/baai/bge-m3 | 임베딩 1024차원 | 1,075 / 100만 입력 토큰 | 제목 1건 ≈ 29 토큰 → 0.031 | 약 32만 건 |
| @cf/meta/llama-3.3-70b-instruct-fp8-fast | 제로샷 (시스템 프롬프트 + 제목) | 26,668 / 100만 입력 · 204,805 / 100만 출력 | 건당 3.6 neurons | 약 2,700 건 |
| 같은 모델 | 퓨샷 (예시 7개 포함) | 동일 | 건당 9.8 neurons | 약 1,000 건 |
이 회차 실험의 실제 사용량: 총 3,125 neurons(임베딩 479 · 제로샷 300건 1076 · 퓨샷 80건 787 · 군집 이름 7회 34 · 중단·폐기한 실행 748). 계정 실측은 Cloudflare GraphQL aiInferenceAdaptiveGroups 로 조회했다.
비용 감각이 필요한 이유는 제로샷의 단가가 임베딩의 100배 이상이기 때문이다. 같은 제목 1건에 임베딩은 0.03, 제로샷은 3.6, 퓨샷은 10 neurons 가 든다. 무료 계정 하루 한도로 임베딩은 수십만 건이 가능하지만 제로샷은 3천 건이 채 안 된다. 논문 자료 전체에 LLM 라벨을 붙이고 싶다면 (1) 임베딩으로 전부 벡터화하고, (2) 제로샷은 수백 건만 돌려 라벨의 품질을 재고, (3) 그 수백 건을 학습 데이터 삼아 임베딩 위에 로지스틱 회귀를 얹어 전체에 확장하는 순서가 비용 효율적이다. 이 회차의 §4 가 정확히 그 셋을 나란히 놓는다.
모델 선택에서 실제로 걸린 문제 하나. gemma-4·qwen3 계열은 "추론(reasoning) 토큰"을 먼저 생성하느라 max_tokens 12 안에 답이 안 나와 빈 문자열을 돌려줬다. 라벨만 받으려는 분류 용도에는 추론 모드가 없는 llama-3.3-70b 가 맞다. 또 llama-3.2-vision 은 계정마다 라이선스 동의가 필요해 수업용에서 뺐다.
출처 Cloudflare Workers AI 요금, developers.cloudflare.com/workers-ai/platform/pricing · 모델 페이지 bge-m3 · llama-3.3-70b-instruct-fp8-fast · REST API workers-ai/get-started/rest-api
임베딩 위에 로지스틱 회귀만 얹는다
한 줄 목표: 1,024차원 벡터를 그대로 특징으로 쓰고, 정규화 상수 C 만 train 내부 5-fold 로 고른다. 튜닝할 것은 그것뿐이다.
임베딩 + LR — 노트북 ⑤ 셀
임베딩은 한 번 받으면 npy 로 저장해 두고 다시 부르지 않는다. 노트북은 train 3,000 + val 3,000 을 쓴다.
t0 = time.time() Etr = np.array(embed(Xtr_txt), dtype=np.float32) # (3000, 1024) Eva = np.array(embed(Xva_txt), dtype=np.float32) # (3000, 1024) print(Etr.shape, Eva.shape, f"{time.time()-t0:.0f}s", report()) np.save('emb_train.npy', Etr); np.save('emb_val.npy', Eva) # 캐시: 다시 부르지 않는다
from sklearn.model_selection import StratifiedKFold, cross_val_score
best = max(((cross_val_score(LogisticRegression(C=C, max_iter=3000), Etr, ytr, cv=StratifiedKFold(5, shuffle=True, random_state=0),
scoring='f1_macro').mean(), C) for C in [0.3, 1, 3, 10, 30]))
C_best = best[1]; print('C(5-fold) =', C_best, f'cv macro-F1 {best[0]:.3f}')
clf = LogisticRegression(C=C_best, max_iter=3000).fit(Etr, ytr); p_emb = clf.predict(Eva)
print(f"임베딩+LR: macro-F1 {f1_score(yva, p_emb, average='macro'):.3f} acc {accuracy_score(yva, p_emb):.3f}")
# → 같은 train 3,000 의 TF-IDF 보다 macro-F1 이 약 0.09 높다 (본 실험 0.779 vs 0.691)
조작 안내 — C 후보를 [0.01, 0.1] 까지 넓혀 보고, 벡터를 StandardScaler 로 표준화한 뒤 다시 돌려 본다.
관찰 포인트 — 이 실험에서 선택된 C 는 1 였다. C 를 크게 하면(규제 약화) 교차검증 점수가 내려가는 것을 확인하면 성공. 1,024차원에 표본 3,000 이면 규제가 필요하다.
왜 임베딩 위에 선형 모델인가. 사전학습 임베딩의 가치는 "의미가 비슷한 문장이 가까이 있다"는 기하학에 있고, 그 공간에서 토픽은 대체로 선형으로 분리된다. 로지스틱 회귀는 계수 해석이 가능하고, 표본이 수백 건이어도 과적합이 제한적이며, seed 에 따라 흔들리지 않는다(같은 데이터에 같은 답). 신경망 분류기를 얹어 얻는 이득은 이 데이터에서 크지 않고, 논문에서 "튜닝한 것이 없다"고 말할 수 있는 단순함이 더 가치 있다.
bge-m3 는 다국어 모델이라 한국어 문장을 별도 설정 없이 처리한다. 출력은 CLS 풀링 벡터이며 API 응답의 pooling: "cls" 로 확인된다. 문맥 창은 8,192 토큰이라 제목뿐 아니라 공시 본문 한 문단도 그대로 넣을 수 있다. 다만 긴 문서 전체를 벡터 하나로 만들면 정보가 평균에 묻히므로, 문단 단위로 임베딩한 뒤 평균하거나 문단별로 분류하는 편이 낫다.
출처 Chen et al., "BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation", 2024, arxiv.org/abs/2402.03216
제로샷 — 라벨만 답하게 하고, 예시를 바꿔 흔들림을 잰다
한 줄 목표: 학습 데이터 0건으로 얻는 점수, 라벨 밖의 답을 하는 비율, 그리고 퓨샷 예시 7개를 다른 7개로 바꿨을 때 예측이 바뀌는 비율을 한 번에 잰다.
제로샷과 퓨샷 A/B — 노트북 ⑤ 셀(셋째·넷째)
시스템 프롬프트는 라벨 이름을 그대로 나열한다. 퓨샷은 train 에서 클래스당 1건씩 무작위로 뽑은 예시를 대화 이력으로 넣는다.
SYS = "다음은 한국어 뉴스 제목이다. 주제를 " + "/".join(LABELS) + " 중 정확히 하나로 분류하고, 그 라벨만 한 단어로 답하라. 설명은 쓰지 않는다." zs_df = strat(va_s, 200) # val 샘플 안에서 다시 층화 200건 zs = [zeroshot(t, LABELS, SYS) for t in zs_df.title] pred = np.array([LABELS.index(l) if l else -1 for l, _ in zs]); y = zs_df.label.values ok = pred >= 0 print(f"제로샷 200건: macro-F1 {f1_score(y[ok], pred[ok], average='macro'):.3f} acc {accuracy_score(y, pred):.3f} 미매칭 {(~ok).mean():.1%}", report()) print([a for (_, a), o in zip(zs, ok) if not o][:5]) # 매칭 실패한 원문 답
# 퓨샷 A/B: 클래스당 1건씩 7개 예시를 두 세트 만들어 같은 60건에 적용 → 두 세트 간 예측 불일치 비율 def pick_examples(seed): rng = np.random.default_rng(seed) return [(tr.title.iloc[rng.choice(np.where(tr.label.values == c)[0])], LABELS[c]) for c in range(7)] sub = zs_df.head(60) pa = [zeroshot(t, LABELS, SYS, pick_examples(11))[0] for t in sub.title] pb = [zeroshot(t, LABELS, SYS, pick_examples(22))[0] for t in sub.title] 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()) # → 예시만 바꿔도 예측의 수 % 가 뒤집힌다. 제로샷 라벨을 변수로 쓸 때 이 값을 측정오차로 보고한다
조작 안내 — SYS 에서 라벨 순서를 바꾸거나 "한 단어로" 를 빼고 다시 돌려 본다. pick_examples 의 seed 를 세 번째 값으로 바꿔 C 세트를 만든다.
관찰 포인트 — 미매칭 답의 원문을 반드시 눈으로 본다. 이 실험에서는 미매칭 2.3% 전부가 "교육"이었다. 라벨 목록에 없는 단어를 모델이 만들어 낸 것이며, 이런 답을 버릴지 "사회"로 접을지가 곧 연구자의 코딩 결정이다.
제로샷 라벨은 "모델이 붙인 코딩"이다. 사람 코더 두 명의 일치율을 보고하듯, 모델 라벨에도 신뢰도를 보고해야 한다. 이 회차가 쓰는 지표는 셋이다. (1) 정답이 있는 표본에서의 macro-F1, (2) 라벨 밖 답의 비율, (3) 프롬프트(예시)를 바꿨을 때의 예측 불일치율. 셋째가 가장 자주 빠지는데, 정답이 없는 자기 데이터에서도 계산할 수 있는 유일한 지표다. temperature 0 으로 두면 같은 입력에는 같은 답이 나오므로, 불일치는 무작위성이 아니라 프롬프트 의존성이다.
퓨샷 예시는 이 실험에서 성능을 올리기도 내리기도 했다(A 세트 정확도 0.725, B 세트 0.775, 같은 40건의 제로샷 0.750). 예시 7개로 70B 모델의 판단 기준을 옮기기엔 부족하고, 어떤 예시를 뽑느냐가 결과를 좌우한다. 논문에서 퓨샷을 쓴다면 예시를 무작위로 여러 세트 만들어 결과의 분산을 부록에 적는 것이 정직한 보고다.
출처
Cloudflare Workers AI, llama-3.3-70b-instruct-fp8-fast 모델 페이지(입력 스키마 messages, max_tokens, temperature),
developers.cloudflare.com/workers-ai/models/llama-3.3-70b-instruct-fp8-fast
결과
라벨이 같은 양이면 임베딩이 TF-IDF 를 0.07~0.15 이긴다. 그러나 라벨 4만 5천 건을 다 쓴 TF-IDF 는 임베딩 6천 건을 이긴다.
세 방법을 한 표에
같은 val 9,107건(제로샷은 그중 층화 300건)에서 잰 값이다. 임베딩과 TF-IDF 의 "5,000건" 행은 학습 표본을 seed 마다 다시 뽑아 5회 돌린 평균±표준편차다.
| 방법 | 학습 라벨 수 | macro-F1 | 정확도 | seed |
|---|---|---|---|---|
| TF-IDF 문자 1~3 + LR | 45,678 (전체) | 0.815 | 0.816 | 1 (결정적) |
| TF-IDF 문자 1~3 + LR | 5,000 | 0.723 ± 0.004 | — | 5 |
| bge-m3 임베딩 + LR (C=1) | 5,000 | 0.791 ± 0.002 | 0.791 ± 0.002 | 5 |
| bge-m3 임베딩 + LR (C=1) | 6,000 | 0.795 | 0.796 | 1 |
| llama-3.3-70b 제로샷 | 0 | 0.723 (300건, 미매칭 2.3% 제외) | 0.683 | 1 (temp 0) |
| 임베딩 + LR, 같은 300건 | 6,000 | 0.784 | 0.787 | 1 |
실행 환경: 2026-09-12. 임베딩·제로샷은 Cloudflare Workers AI(총 3,125 neurons), 분류기는 로컬 CPU scikit-learn 1.7. seed 0~4 는 train 6,000 층화 샘플에서 5,000 을 재추출. 제로샷 300건은 val 층화 추출(seed 0). 표준편차는 표본 표준편차(ddof 1).
읽는 순서는 이렇다. 라벨을 같은 양(5,000건) 쓰면 임베딩이 TF-IDF 를 macro-F1 로 약 0.07 이긴다. 라벨을 전부(45,678건) 쓴 TF-IDF 는 임베딩 6,000건보다 0.02 높다. 제로샷은 라벨 0건으로 0.72 에 닿지만, 같은 300건에서 임베딩+LR 은 0.784 이므로 라벨 6,000건이 있다면 제로샷을 쓸 이유가 없다. 결론은 "무엇이 최고인가"가 아니라 라벨이 몇 건 있는가에 따라 답이 바뀐다는 것이고, 그것을 그린 것이 다음 그림이다.
임베딩이 TF-IDF 전체 학습을 못 이긴 것은 이 데이터에서 예상 가능한 결과다. YNAT 의 라벨은 어휘와 강하게 묶여 있고("靑", "文대통령" 이 보이면 정치), 4만 5천 건은 그 어휘 목록을 거의 완전히 학습하기에 충분하다. 임베딩은 문장의 의미를 압축하면서 "어느 매체 섹션에 실렸는가" 같은 표면 단서를 일부 버린다. 반대로 라벨이 수백 건이면 어휘 목록을 학습할 수 없으므로, 미리 학습된 의미 공간이 이긴다. 두 방법은 경쟁 관계가 아니라 라벨 예산에 따른 분업이다.
KLUE 논문이 보고한 YNAT 의 미세조정(fine-tuning) 성능은 macro-F1 0.85~0.87 수준(KLUE-RoBERTa 계열)이다. 이 회차는 학습을 하지 않는 조건이므로 그 숫자와 직접 비교하지 않는다. 다만 "학습 없이 0.80, 미세조정으로 0.86" 이라는 간격이 튜닝의 값어치이고, 논문에서 그 0.06 이 필요한지는 연구 질문이 결정한다.
출처
이 회차 실험 결과(실습_work/out/text_baseline.json, text_emblr.json, text_analysis.json) · Park et al. 2021, Table 6(YNAT 기준 모델).
라벨이 적을수록 임베딩의 이득이 크다
학습 표본을 100건에서 6,000건까지 늘리며 두 방법을 같은 조건으로 비교했다. 각 점은 seed 5회 평균, 막대는 표준편차다.
| 학습 표본 | bge-m3 임베딩 + LR | TF-IDF 문자 + LR | 차이 |
|---|---|---|---|
| 100 | 0.447 ± 0.012 | 0.300 ± 0.022 | +0.146 |
| 300 | 0.599 ± 0.017 | 0.441 ± 0.009 | +0.159 |
| 1,000 | 0.721 ± 0.012 | 0.590 ± 0.007 | +0.131 |
| 3,000 | 0.779 ± 0.005 | 0.691 ± 0.007 | +0.088 |
| 6,000 | 0.795 ± 0.000 | 0.735 ± 0.000 | +0.060 |
macro-F1, val 9,107건. seed 0~4 는 train 6,000 층화 샘플에서 재추출(6,000 행은 전체라 편차 0). TF-IDF 는 각 표본에서 다시 적합(어휘도 표본에서만 만든다). C 는 임베딩 1, TF-IDF 10 고정.
이 곡선이 논문 설계에 주는 함의는 구체적이다. 라벨을 직접 붙일 계획이라면 300건을 붙이고 임베딩+LR 을 쓰는 것이, 1,000건을 붙이고 TF-IDF 를 쓰는 것과 같은 성능을 낸다. 코딩 인건비가 라벨당 같다면 비용은 3분의 1이다. 반대로 이미 라벨이 수만 건 있는 공개 데이터(예: 평점이 달린 리뷰)라면 TF-IDF 로 충분하고 API 비용을 쓸 이유가 없다.
곡선의 오른쪽 끝이 만나지 않는 이유도 적어 둔다. 임베딩 곡선은 6,000건에서 아직 오르는 중이고(3,000→6,000 에서 +0.016), TF-IDF 는 45,678건에서 0.815 이다. 임베딩을 45,678건 전부 뽑으면(약 1,400 neurons) 어디서 포화하는지는 이 회차에서 확인하지 않았다. 열린 과제로 남긴다.
출처 이 회차 실험 결과(text_curve.csv).
두 모델이 헷갈리는 자리는 같다: 사회의 경계
혼동행렬을 정답 기준 비율(%)로 놓고 보면, 임베딩+LR 과 제로샷 모두 "사회" 행에서 흘러나간다. 다만 흘러가는 방향이 다르다.
| 모델 | 가장 혼동되는 쌍 1 | 쌍 2 | 쌍 3 |
|---|---|---|---|
| TF-IDF + LR (val 9,107) | 경제 ↔ 사회 (400건) | 사회 ↔ 생활문화 (327건) | 사회 ↔ 정치 (210건) |
| 임베딩 + LR (val 9,107) | 사회 ↔ 생활문화 (415건) | 경제 ↔ 사회 (362건) | 사회 ↔ 정치 (281건) |
| 제로샷 (val 300) | 사회 ↔ 정치 (25건) | 사회 ↔ 생활문화 (20건) | 세계 ↔ 정치 (8건) |
| 모델 | 제목 | 정답 | 예측 |
|---|---|---|---|
| 임베딩+LR | 세종 경로당·어린이집 2주간 휴원…도서관·박물관도 운영 중단 | 사회 | 생활문화 |
| 임베딩+LR | 낙동강 구포나루축제 부산시 최우수축제에 선정 | 사회 | 생활문화 |
| 임베딩+LR | 인천소식 북부교육청 인천문화재단과 예술 교육 협약 | 사회 | 생활문화 |
| 임베딩+LR | 5억원 무이자 융자는 되고 7천만원 이사비는 안된다 | 사회 | 경제 |
| 임베딩+LR | 두산밥캣 스캇성철박 단독 대표이사로 변경 | 사회 | 경제 |
| 임베딩+LR | 2021년 삼성디스플레이 정기 임원 인사 | 사회 | 경제 |
| 제로샷 | 광주소식 고삼석 전 방통위 상임위원 지역방송 신년 특강 | 사회 | 정치 |
| 제로샷 | 보훈문화상 공동개최 업무협약식 | 사회 | 정치 |
| 제로샷 | 제주도 유치·초등 1∼3년생 대상 긴급돌봄 무상급식비 지원 | 사회 | 생활문화 |
| 제로샷 | 등교 개학 대비한 시설물 점검 | 사회 | 생활문화 |
오분류 제목을 읽어 보면 오류의 성격이 보인다. "세종 경로당·어린이집 2주간 휴원"이 사회인지 생활문화인지, "삼성디스플레이 정기 임원 인사"가 사회인지 경제인지는 라벨 기준을 모르면 사람도 갈린다. 이런 쌍의 오류는 모델을 바꿔도 사라지지 않으며, 논문에서는 라벨 정의의 경계로 보고하는 것이 맞다. 반면 제로샷이 "보훈문화상 업무협약식"을 정치로 보낸 것은 모델의 사전 지식이 데이터의 기준과 다른 경우이고, 이것은 퓨샷 예시나 라벨 설명문으로 줄일 수 있는 오류다.
| 퓨샷 A/B (같은 40건) | 세트 A 정확도 | 세트 B 정확도 | 제로샷(같은 40건) | A≠B 불일치 |
|---|---|---|---|---|
| 클래스당 예시 1건, seed 11 / 22 | 0.725 | 0.775 | 0.750 | 5.0% |
예시 세트만 바꿨을 때 40건 중 2건의 예측이 달라졌다(「임금·퇴직금 제때 안 준 의료법인 이사장…」 A=경제 / B=사회; 「유은혜·정은경 정례회의…학교에 식중독 예…」 A=정치 / B=사회). 표본이 40건이라 불일치율의 표준오차는 약 3.5%p 다. 예산상 건수를 150에서 40으로 줄였다.
혼동행렬을 그리는 이유는 정확도 한 숫자가 숨기는 것을 드러내기 위해서다. 제로샷의 정확도 0.683 은 "사회" 41%가 절반쯤 틀린 결과이고, 나머지 클래스는 0.8~1.0 이다. 만약 자기 데이터가 스포츠·정치 같은 뚜렷한 범주만 있다면 제로샷은 충분히 정확하고, 사회·생활문화처럼 기준이 관행에 의존하는 범주가 있다면 제로샷은 그 관행을 모른다. 라벨 정의를 프롬프트에 문장으로 적어 주는 것이 첫 처방이고, 그래도 안 되면 소량의 라벨로 임베딩+LR 을 쓰는 것이 둘째 처방이다.
제로샷과 임베딩+LR 의 오류가 같은 자리에 있다는 사실은 앙상블의 이득이 작다는 뜻이기도 하다. 두 모델이 서로 다른 곳에서 틀려야 합쳐서 이득이 난다. 이 데이터에서는 둘 다 "사회의 경계"에서 틀리므로, 합쳐도 그 경계는 넘지 못한다.
출처 이 회차 실험 결과(text_analysis.json, text_fewshot.json).
군집·시각화 — 라벨 없이 구조 보기
임베딩만으로 만든 군집은 7개 토픽과 ARI 0.193 만큼만 겹친다. 군집이 찾은 것은 토픽이 아니라 2020년의 사건이었다.
UMAP 2차원 지도에 정답을 칠하면
val 9,107건의 1,024차원 임베딩을 UMAP(n_neighbors 15, min_dist 0.1)으로 2차원에 펼쳤다. 색은 정답 토픽이다. 스포츠·IT과학·경제는 덩어리를 이루고, 사회는 지도 한가운데를 넓게 덮는다.
UMAP 은 고차원의 이웃 구조를 2차원에서 최대한 보존하는 방법이다. 각 점의 k 개 이웃(여기서는 15)을 고차원에서 찾고, 2차원에서도 같은 점들이 이웃이 되도록 좌표를 최적화한다. 그래서 "가까운 점은 비슷하다"는 읽기는 대체로 맞고, "덩어리 A 와 B 가 멀리 떨어져 있으니 매우 다르다"는 읽기는 틀릴 수 있다. min_dist 를 줄이면 덩어리가 더 조밀해지고 n_neighbors 를 키우면 전체 구조가 더 살아난다. 논문에는 두 값과 seed 를 반드시 적는다.
이 지도는 그림에만 쓴다. 다음 카드의 군집은 2차원이 아니라 원래 1,024차원에서 만든다. 2차원에서 군집을 만들면 UMAP 이 인위적으로 만든 덩어리를 군집으로 착각하기 쉽기 때문이다. 실제로 이 데이터에서 2차원 위 K-means 는 ARI 0.244 로 1,024차원(0.193)보다 높게 나오는데, 이것은 정답 라벨과 더 맞는다는 뜻이지 더 올바른 군집이라는 뜻은 아니다. 두 결과의 일치도는 ARI 0.351 에 그친다.
출처 McInnes, Healy & Melville, "UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction", 2018, arxiv.org/abs/1802.03426 · umap-learn 문서 umap-learn.readthedocs.io
지도 위에서 색 기준을 바꿔 보기
한 줄 목표: 같은 점을 정답 / K-means 군집 / 제로샷 정답 여부로 번갈아 칠하며, 군집이 토픽과 어긋나는 자리와 제로샷이 틀리는 자리가 어디인지 눈으로 찾는다.
임베딩 지도 — val 2,000점
val 9,107건 중 2,000점(제로샷을 돌린 300건 전부 + 나머지에서 층화 1,700건)을 UMAP 좌표에 찍었다. 점 위에 마우스를 올리면 제목·정답·군집·제로샷 예측이 보인다.
조작 안내 — ① "정답 토픽"에서 사회(적갈색)가 어디까지 퍼져 있는지 본다. ② "K-means 군집"으로 바꿔 같은 자리가 몇 개 군집으로 갈리는지 본다. ③ "제로샷 정답 여부"로 바꿔 틀린 점(붉은색)이 어느 덩어리에 몰리는지 본다.
관찰 포인트 — 스포츠 섬은 세 기준 모두에서 한 색이다(군집도 맞고 제로샷도 맞는다). 지도 중앙의 사회 지역은 K-means 에서 서너 군집으로 쪼개지고 제로샷 오답도 거기 몰린다. 즉 "라벨이 어렵다"는 사실이 군집·제로샷·지도 세 곳에서 같은 모양으로 나타나면 관찰 성공.
위젯의 점은 2,000개로 줄였지만 색은 val 전체에서 계산한 결과다(K-means 군집 번호는 9,107건 전체에서 만든 seed 0 결과, 제로샷은 §4 의 300건). 2,000점 부분집합에서 다시 계산한 ARI 는 0.193 로 전체(0.193)와 같다. 7개 클래스를 구분하려면 이 강의자료의 5색 규칙을 넘는 7색이 필요해, 위젯과 지도 그림 안에서만 7색(남색·청록·적갈·민트·회색·황토·보라)을 쓴다.
K-means 가 찾은 것은 토픽이 아니라 2020년이었다
1,024차원에서 K-means(k=7)를 돌리고, 군집마다 중심에 가장 가까운 제목 5개를 llama-3.3-70b 에 주어 한 줄 이름을 받았다. 정답 토픽과의 일치는 ARI 0.194 ± 0.002, NMI 0.306 다.
| 군집 | 크기 | LLM 이름 | 최다 정답 | 순도 | 중심 최근접 제목 |
|---|---|---|---|---|---|
| 0 | 1,548 | 취업 | 사회 | 48% | 고용노동부 올해 인적자원개발 우수기관 인증 신청 접수 시작 노동부여가부 국민취업지원제도 관련 업무협약식 |
| 1 | 1,663 | 거리두기 | 세계 | 34% | 경찰로 압송되는 인질극 용의자 반가워도 서로 떨어지세요 |
| 2 | 1,366 | 노동자 | 사회 | 65% | 정부의 노조법 개정 반대 기자회견 불법파견 처벌하고 즉각 정규직화 하라 |
| 3 | 1,033 | 등교 수업 | 사회 | 93% | 등교 수업 확대 앞둔 교실 풍경 학생들 등교 준비에 분주한 학교 |
| 4 | 1,180 | 책 | 생활문화 | 81% | 어느샌가 벌써 봄이 와 있네 먼지 나는 고서에서 보물 같은 이야기 찾아냅니다 |
| 5 | 1,329 | 금융 | 경제 | 70% | 주식거래계좌 3천만개 눈앞…작년 증가폭 10년만에 최대 올해 중소형주 펀드 수익률 마이너스 전환 |
| 6 | 988 | 인사말 | 사회 | 46% | 축사하는 송기인 이사장 인사하는 홍남기 부총리 |
K-means(k=7, n_init 10, seed 0), 1,024차원 원 임베딩. 순도 = 군집 안에서 최다 정답 라벨의 비율. LLM 이름은 대표 제목 5개만 보고 붙인 것이라 군집 전체보다 좁게 나온다. 이름 붙이기 7회 호출 34 neurons.
ARI 0.19 는 낮은 값이다. 그러나 "군집이 실패했다"는 뜻은 아니다. 군집 3(등교 수업, 순도 93%)과 군집 1(거리두기·현장 사진)은 val 의 절반을 차지하는 2020년 기사가 만든 사건 군집이고, 이것은 라벨 체계에는 없지만 자료에는 실재하는 구조다. 라벨이 없는 자기 데이터에서 군집을 쓴다는 것은 정확히 이런 구조를 찾는 일이다. 다만 그 구조가 연구 질문과 맞는지는 대표 문서를 읽어 판단해야 하고, LLM 이름은 그 읽기를 대신하지 않는다. 이름은 5개 문서의 요약일 뿐이다.
k 를 7 로 둔 것은 정답 토픽 수에 맞춘 것이지 데이터가 고른 값이 아니다. 실루엣이나 엘보로 k 를 고르면 다른 값이 나올 수 있고, 논문에서는 k 선택 근거를 한 줄 적는다. 군집 결과를 회귀의 더미 변수로 쓸 계획이라면, k 를 바꿨을 때 회귀 계수가 얼마나 흔들리는지를 부록에 넣는다.
출처 이 회차 실험 결과(text_cluster.json, text_cluster_names.json).
seed 를 바꿔도 K-means 는 같은 답을 낸다. HDBSCAN 은 답을 내지 않는다
군집을 논문에 쓰려면 "다시 돌려도 같은가"를 보여야 한다. K-means 는 seed 5회에서 ARI 0.194 ± 0.002 로 안정적이고, seed 간 군집 일치도(ARI)도 0.91~0.98 다.
| 방법 | 설정 | 군집 수 | 잡음 비율 | ARI (정답 대비) | NMI |
|---|---|---|---|---|---|
| K-means | k=7, 1,024차원, seed 0~4 | 7 | 0 | 0.194 ± 0.002 | 0.306 |
| K-means | k=7, UMAP 2차원 위 (비교용) | 7 | 0 | 0.244 | 0.357 |
| HDBSCAN | min_cluster_size 50, 1,024차원 (기본 min_samples) | 0 | 100% | — | — |
| HDBSCAN | min_cluster_size 50, min_samples 5, 1,024차원 | 2 | 85.5% | 0.277 (잡음 제외) | 0.255 |
| HDBSCAN | min_cluster_size 50, UMAP 2차원 위 (보조) | 6 | 13.3% | 0.216 (잡음 제외) | 0.354 |
ARI·NMI 는 val 9,107건 정답 토픽 대비. K-means 행은 seed 5회 평균±표준편차. HDBSCAN 은 결정적(단일 실행). 2026-09-12, 로컬 CPU(UMAP 29초, HDBSCAN 4초).
HDBSCAN 이 1,024차원에서 군집을 하나도 만들지 못한 것은 이 회차의 negative result 다. 임베딩 공간에서는 점 사이의 거리가 고르게 분포해(고차원의 거리 집중), 밀도가 특별히 높은 영역을 찾는 HDBSCAN 이 모든 점을 잡음으로 분류한다. min_samples 를 낮추면 군집 2개가 나오지만 85% 이상이 잡음이다. 관행처럼 쓰이는 "UMAP 으로 줄인 뒤 HDBSCAN"은 군집 6개를 찾지만, 그것은 UMAP 이 만든 2차원 밀도를 본 것이지 원 공간의 밀도가 아니다. 논문에 쓴다면 "군집은 원 공간 K-means, HDBSCAN 은 축소 공간에서만 성립"이라고 조건을 밝혀야 한다.
K-means 가 안정적인 것과 옳은 것은 다르다. seed 를 바꿔도 같은 답이 나오는 것은 목적함수의 최적해가 뚜렷하다는 뜻이고, 그 최적해가 연구자가 원하는 구분과 같은지는 ARI 가 말해 준다(여기서는 0.19). 안정성은 필요조건이다. 부록에는 (1) seed 별 ARI, (2) k 를 ±2 바꿨을 때의 결과, (3) 축소 전·후 군집의 일치도(여기서는 0.351)를 적으면 심사에서 군집 분석에 대한 질문은 대부분 답이 된다.
HDBSCAN 의 negative result 를 그대로 적은 이유는, 자기 데이터에서 같은 일이 일어났을 때 "내가 잘못했나"가 아니라 "고차원 임베딩에서는 흔한 일"임을 알기 위해서다. 해결책은 (a) PCA 로 50차원 정도로 먼저 줄이거나, (b) 코사인 거리를 쓰거나, (c) 군집 대신 밀도 자체를 변수로 쓰지 않는 것이다. 어느 쪽이든 논문에 조건을 적는다.
출처 Campello, Moulavi & Sander, "Density-Based Clustering Based on Hierarchical Density Estimates", PAKDD 2013, doi.org/10.1007/978-3-642-37456-2_14 · McInnes, Healy & Astels, "hdbscan: Hierarchical density based clustering", JOSS 2(11), 2017, doi.org/10.21105/joss.00205 · Hubert & Arabie, "Comparing partitions", Journal of Classification 2, 1985 (ARI).
논문에 쓰기
텍스트 변수는 측정오차를 가진 변수다. 성능표·재현성 표·오차 보고, 이 셋이 붙어야 회귀에 넣을 수 있다.
성능표는 이렇게 쓴다
본문에는 방법·학습 라벨 수·지표·반복 횟수만 둔다. 하이퍼파라미터와 모델 ID 는 부록으로 보낸다. 아래는 이 회차 숫자로 만든 예시다.
| 표 1. 뉴스 제목 토픽 분류 성능 (KLUE-YNAT validation, n = 9,107) | 학습 라벨 | macro-F1 | 정확도 |
|---|---|---|---|
| TF-IDF(문자 1~3-gram) + 로지스틱 회귀 | 45,678 | 0.815 | 0.816 |
| TF-IDF(문자 1~3-gram) + 로지스틱 회귀 | 5,000 | 0.723 ± 0.004 | — |
| 문장 임베딩(bge-m3) + 로지스틱 회귀 | 5,000 | 0.791 ± 0.002 | 0.791 ± 0.002 |
| LLM 제로샷(Llama 3.3 70B)a | 0 | 0.723 | 0.683 |
주: 평균 ± 표준편차는 학습 표본 재추출 5회. a 층화 추출 300건, 라벨 밖 응답 2.3% 은 오답 처리 시 macro-F1 0.713. 퓨샷 예시 교체 시 예측 불일치 5.0% (n = 40).
표에서 빠지면 안 되는 것 셋. (1) 평가 표본의 크기와 분포: n 과 최다 클래스 비율(여기서는 사회 41%)이 없으면 정확도를 해석할 수 없다. (2) 반복 횟수와 무엇을 반복했는가: seed 가 학습 표본 재추출인지, 모델 초기화인지, 프롬프트 예시인지에 따라 표준편차의 뜻이 다르다. (3) 라벨 밖 응답의 처리: 제로샷은 항상 라벨을 답하지 않는다. 버렸는지 오답 처리했는지 각주로 적는다.
재현성 표: 남이 다시 돌릴 수 있게
API 모델은 조용히 바뀐다. 모델 ID·호출 날짜·차원·seed·튜닝 범위를 적어 두지 않으면 6개월 뒤 같은 숫자가 나오지 않는다.
| 항목 | 값 |
|---|---|
| 임베딩 모델 | @cf/baai/bge-m3 (Cloudflare Workers AI), 1,024차원, CLS 풀링, 정규화 없음, 배치 100 |
| 제로샷 모델 | @cf/meta/llama-3.3-70b-instruct-fp8-fast, temperature 0, max_tokens 12, 시스템 프롬프트 전문은 노트북 ⑤ 셀 |
| 호출 일자 · 사용량 | 2026-09-12 · 총 3,125 neurons (임베딩 15,107건, 제로샷 300건, 퓨샷 80건, 군집 이름 7회) |
| 토크나이저 · 전처리 | 임베딩·LLM 은 모델 내장 토크나이저(서버 측). TF-IDF 는 scikit-learn 1.7 char_wb (1,3) / word (1,2), min_df 2, sublinear_tf |
| 분류기 | LogisticRegression(lbfgs, max_iter 3000). C 후보 {0.3, 1, 3, 10, 30}, train 5-fold macro-F1 로 선택 → 임베딩 C=1, TF-IDF C=3(3-fold) |
| seed | 학습 표본 재추출 0~4 (train_test_split(random_state=seed, stratify=y)). UMAP·K-means random_state 0(안정성은 0~4) |
| 차원 축소 · 군집 | umap-learn 0.5.12 (n_neighbors 15, min_dist 0.1, 그림 전용) · K-means k=7 n_init 10 · HDBSCAN min_cluster_size 50, 1,024차원 |
| 데이터 | KLUE-YNAT v1.1 (Hugging Face klue/klue, config ynat), train 45,678 / validation 9,107. 라벨 순서 0 IT과학 … 6 정치 |
| 코드 | notebooks/02_text.ipynb — 내려받아 Colab 에 업로드해서 연다(파일 → 노트 업로드) |
API 모델의 재현성은 로컬 모델보다 약하다. 같은 모델 ID 라도 서버 쪽 양자화·버전이 바뀔 수 있고, 무료 계정에서는 이 실험에서도 응답의 model 필드가 요청한 ID 와 다른 변종(-sd)으로 표시됐다. 그래서 (1) 임베딩 벡터 자체를 파일로 보관하고, (2) 제로샷 원문 응답을 전부 저장하고, (3) 호출 날짜를 적는다. 벡터 파일이 있으면 API 가 사라져도 분류기·군집은 다시 돌릴 수 있다.
텍스트를 변수로 만들어 패널 회귀에 넣는 절차
공시·뉴스·리뷰를 "토픽 비율"이나 "감성 점수"로 바꿔 회귀에 넣는 연구의 표준 절차다. 핵심은 마지막 단계, 측정오차를 어떻게 다루는가다.
- 단위 정하기. 회귀의 관측 단위(기업×연도, 지역×월)를 먼저 정하고, 텍스트를 그 단위로 묶는다. 기사 1건이 아니라 "기업 i 의 t 년 기사 전체"가 한 관측이다.
- 문서 단위 라벨. 문서마다 (a) 임베딩+LR 의 예측 확률, (b) 제로샷 라벨, (c) 군집 번호 중 하나를 붙인다. 정답 표본 수백 건으로 (a)의 F1 을 재고, (b)는 예시 교체 불일치율을, (c)는 seed 간 ARI 를 기록한다.
- 집계. 단위별로 라벨의 비율(토픽 비율), 확률의 평균(감성 점수), 군집 더미의 비율을 만든다. 이것이 회귀의 설명변수 \(\hat{x}_{it}\) 다.
- 회귀. \(y_{it} = \beta\,\hat{x}_{it} + \gamma' z_{it} + \alpha_i + \lambda_t + \varepsilon_{it}\). 단위·시점 고정효과를 넣고, 표준오차는 단위에서 클러스터링한다.
- 측정오차 보고. \(\hat{x}\) 는 참값 \(x\) 를 오차 있게 잰 것이다. 고전적 측정오차라면 \(\hat\beta\) 는 0 쪽으로 축소된다(attenuation). 2단계에서 잰 F1·불일치율로 오차의 크기를 보고하고, 가능하면 (i) 정답 표본에서 오차 분산을 추정해 보정하거나, (ii) 다른 모델(TF-IDF vs 임베딩)로 만든 \(\hat{x}\) 를 도구변수처럼 써서 \(\beta\) 의 견고성을 보인다.
노트북에서 바꿀 곳은 ② 데이터 로드 셀 하나다. text, label 두 열을 가진 CSV 를 만들고 ⑨ 셀의 대체 코드로 ②를 갈아 끼우면 ③~⑧ 이 그대로 돈다. 라벨이 없으면 label 열을 비워 두고 ⑤ 임베딩 → ⑦ 군집 → 군집 이름까지만 실행한다. 그때는 군집 비율과 제로샷 라벨이 곧 변수다.
측정오차가 고전적(참값과 무관한 잡음)이라는 가정은 텍스트 변수에서 자주 깨진다. 제로샷은 "사회" 기사를 체계적으로 "정치"로 보내고, 그 오류는 정치인이 등장하는 기사에 몰린다. 즉 오차가 참값과 상관되고, 그때 \(\hat\beta\) 의 편향 방향은 정해져 있지 않다. 이 경우 가장 정직한 처방은 정답 표본에서 오류의 구조(혼동행렬)를 보고하고, 오류가 몰리는 하위집단을 빼거나 따로 추정해 결과가 유지되는지 보이는 것이다. 이것이 §4 의 오류 분석을 논문에 그대로 옮기는 이유다.
토픽 비율을 설명변수로 쓸 때 또 하나. 비율은 합이 1이므로 7개를 다 넣으면 완전 공선성이다. 하나를 기준으로 빼거나, 관심 토픽 하나의 비율만 넣는다. 군집 더미도 마찬가지다.
출처 Gentzkow, Kelly & Taddy 2019, §4 (텍스트 변수의 회귀 투입과 측정오차) · Wooldridge, Econometric Analysis of Cross Section and Panel Data, 2nd ed., MIT Press, 2010, 4.4절(측정오차).
분류용 표현 ≠ 교란 조정용 표현
이 회차의 임베딩은 토픽을 맞추도록 평가됐다. 인과추론에서 텍스트가 교란변수일 때 필요한 것은 다른 성질이다.
인과추론 9장 §2(텍스트가 교란일 때)의 논리를 이 회차의 언어로 옮기면 이렇다. 처치 \(D\)(예: 기업의 공시 어조)와 결과 \(Y\)(주가 반응) 사이에 텍스트 \(X\)(공시 내용)가 교란으로 놓여 있다면, 우리는 \(X\) 를 조정해야 한다. 그런데 1,024차원 벡터를 그대로 조정에 쓰면 차원이 너무 높고, 이 회차처럼 "토픽을 잘 맞추는" 저차원 표현으로 줄이면 처치와 결과에 동시에 영향을 주는 정보가 빠질 수 있다. 토픽 분류에 필요 없는 정보가 교란 조정에는 필요할 수 있기 때문이다. Veitch, Sridhar & Blei(2020)는 임베딩을 "처치 예측과 결과 예측을 동시에 잘하도록" 다시 학습해야 교란 조정에 충분한 표현이 된다는 것을 보였다. 이 회차의 파이프라인은 그 첫 단계(임베딩 확보)까지이고, 인과 질문에서는 표현을 무엇으로 평가할지가 바뀐다.
출처 Veitch, Sridhar & Blei, "Adapting Text Embeddings for Causal Inference", UAI 2020, arxiv.org/abs/1905.12741 · 이 시리즈 인과추론 9장 · 비정형 자료.
퀴즈와 핵심 정리
먼저 스스로 답한 뒤 펼쳐서 확인한다.
퀴즈
라벨이 400건뿐인 공시 자료를 분류하려 한다. TF-IDF+LR, 임베딩+LR, 제로샷 중 무엇을 먼저 시도해야 하고, 그 근거가 되는 이 회차의 결과는 무엇인가?
제로샷 라벨을 회귀의 설명변수로 쓰면서 "정확도 0.68"만 보고했다. 심사자가 요구할 두 가지 추가 정보는?
HDBSCAN 이 1,024차원 임베딩에서 군집을 하나도 찾지 못했다. UMAP 2차원에서 돌리니 6개가 나왔다. 후자를 논문에 "임베딩 공간의 군집"으로 보고해도 되는가?
하나. 텍스트 변수의 방법 선택은 라벨 예산이 결정한다. 라벨이 수백 건이면 임베딩+선형, 수만 건이면 TF-IDF 로 충분하고, 0건이면 제로샷이 유일하다. 셋을 한 표에 올리고 데이터 효율 곡선으로 근거를 댄다.
둘. API 가 만든 변수는 측정오차를 가진 변수다. 정답 표본의 혼동행렬, 라벨 밖 응답 비율, 프롬프트 교체 불일치율, 군집의 seed 안정성. 이 넷을 재지 않은 텍스트 변수는 회귀에 넣을 수 없다.
셋. 군집은 라벨 체계가 아니라 자료의 구조를 찾는다. 정답과의 ARI 가 낮아도 군집이 찾은 것(여기서는 2020년의 사건)이 연구 질문에 맞으면 변수가 된다. 판단은 대표 문서를 읽어서 하고, LLM 이름은 요약일 뿐이다.
참고문헌
연도는 출간 기준. 수치는 본문에 인용한 원문 또는 이 회차의 실행 결과다.
데이터
- Park, S., Moon, J., Kim, S., Cho, W. I., et al. (2021). KLUE: Korean Language Understanding Evaluation. NeurIPS 2021 Datasets and Benchmarks Track. arxiv.org/abs/2105.09680 — YNAT 정의와 기준 모델.
- KLUE 데이터셋 카드. Hugging Face
klue/klue, configynat. huggingface.co/datasets/klue/klue — 라벨 순서(ClassLabel.names)와 분할 크기 확인.
모델과 API
- Chen, J., Xiao, S., Zhang, P., Luo, K., Lian, D., & Liu, Z. (2024). BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation. arxiv.org/abs/2402.03216
- Cloudflare. Workers AI 문서 — 요금(platform/pricing), REST API(get-started/rest-api), 모델 페이지 bge-m3 · llama-3.3-70b-instruct-fp8-fast. 단가는 2026-09-12 기준.
차원 축소·군집·평가
- McInnes, L., Healy, J., & Melville, J. (2018). UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arxiv.org/abs/1802.03426
- Campello, R. J. G. B., Moulavi, D., & Sander, J. (2013). Density-Based Clustering Based on Hierarchical Density Estimates. PAKDD 2013, LNCS 7819, 160–172. doi.org/10.1007/978-3-642-37456-2_14
- McInnes, L., Healy, J., & Astels, S. (2017). hdbscan: Hierarchical density based clustering. Journal of Open Source Software, 2(11), 205. doi.org/10.21105/joss.00205
- Hubert, L., & Arabie, P. (1985). Comparing partitions. Journal of Classification, 2, 193–218. — ARI.
텍스트를 변수로 · 인과추론 연결
- Gentzkow, M., Kelly, B., & Taddy, M. (2019). Text as Data. Journal of Economic Literature, 57(3), 535–574. aeaweb.org (doi 10.1257/jel.20181020)
- Veitch, V., Sridhar, D., & Blei, D. (2020). Adapting Text Embeddings for Causal Inference. UAI 2020. arxiv.org/abs/1905.12741 — 인과추론 9장 §2 의 근거.
- Wooldridge, J. M. (2010). Econometric Analysis of Cross Section and Panel Data (2nd ed.). MIT Press. 4.4절 측정오차.
인용 원칙
성능 수치는 전부 이 회차에서 실제로 실행한 결과이며 스크립트(실습_work/20_text_experiment.py)와 캐시(out/)로 재현된다. API 단가는 문서의 게시 값, 사용량은 계정 실측이다. 도입부의 연구실 장면은 특정 인물·기관을 가리키지 않는 일반적 묘사다.
이 자료는 단일 HTML 파일로 배포되며, 수식은 MathJax, 위젯은 순수 JavaScript 로 구현되어 오프라인에서도 동작한다(수식 렌더링만 최초 1회 네트워크 필요). 색상 규약: 회색 = 베이스라인, 청록 = 임베딩·API 특징, 붉은색 = 오류·강조. 지도·위젯은 7클래스 구분을 위해 7색을 쓴다.