GBT
한국외국어대학교Global Business & Technology
실습 3 · 이미지·멀티모달 — 학습 없이 이미지를 변수로
목차 강의 홈 ↗
대학원 딥러닝 · 실습 3

이미지·멀티모달:
학습 없이 이미지를 변수로

위성영상 5,400장과 상품 사진 4,000장을 사전학습 모델 호출만으로 숫자 벡터로 바꾼다. 라벨 0장(제로샷), 라벨 조금(선형 프로브), 두 모달 결합(융합)이 각각 어디까지 가는지를 같은 저울에 올리고, 그 벡터를 논문의 변수로 쓸 때 무엇을 적어야 하는지까지 간다. 결과는 전부 실제 실행값이다.

실데이터 · 실행 결과 수록Colab 노트북조작형 실습 1개API 사용 약 100 neurons
0
케냐 서부 시골 마을 상공, 수백 킬로미터 위

지붕이 바뀌는 것이 우주에서 보였다

그 연구는 라벨 수만 장과 전용 학습이 필요했다. 지금은 모델 한 번 호출로 사진이 숫자가 된다. 문제는 그 다음이다.

도입 · 한 장면

짚지붕에서 양철지붕으로

케냐 서부 시골 마을 수백 곳에 대규모 무조건 현금이전이 시행되었다. 가구가 부유해지면 가장 먼저 하는 일 중 하나가 짚지붕을 양철지붕으로 바꾸는 것이고, 양철지붕은 상공에서 선명하게 보인다. Huang, Hsiang & Gonzalez-Navarro(2021)는 설문 대신 위성영상에서 딥러닝으로 건물과 지붕 재질을 검출해 현금이전의 효과를 추정했고, 설문 기반 추정과 부합하는 값을 얻었다. 인과추론 9장의 도입이 바로 이 장면이다.

그 연구가 치른 비용을 다시 보자. 지붕을 검출하는 모형은 케냐 지역의 라벨링된 영상으로 따로 학습해야 했고, 검출 정확도를 지상 검증 자료로 확인해야 했다. 사진을 변수로 만드는 일 자체가 논문 한 편 분량의 공학이었다.

지금은 다르다. CLIP 같은 사전학습 모델은 이미지를 넣으면 512개 숫자를 돌려주고, 그 숫자는 "숲"이라는 단어의 숫자와 가까운지 먼지로 비교할 수 있다. 라벨이 한 장도 없어도 분류가 되고, 라벨 100장이면 선형 회귀 하나로 정확도 85%가 나온다. 이 실습에서 직접 확인한다. 그런데 바로 여기서 설계가 무너지기 쉽다. 그 512개 숫자에 무엇이 담겼는지 모른 채 회귀식에 꽂으면, 결과변수인지 교란요인인지, 처치가 그 숫자를 바꿨는지 아닌지를 말할 수 없다. 9장이 "자리"를 따진 이유가 실습에서는 "그 벡터에 무엇이 들어 있는가"라는 질문으로 돌아온다.

제로샷 — 라벨 없이클래스 이름만으로 위성영상을 분류한다. 어디까지 버티고 어디서 무너지는가. 프롬프트 한 단어가 정확도를 얼마나 흔드는가
프로브 — 라벨 조금같은 벡터에 로지스틱 회귀만 얹는다. 클래스당 10장·100장·전체에서 정확도 곡선이 어떻게 생겼는가
융합 — 두 모달상품 사진 벡터와 제목 벡터를 이어붙인다. 결합이 단독 최강을 이기는가, 이긴다면 어느 클래스에서인가
더 읽기

이 회차는 학습(가중치 갱신)을 한 번도 하지 않는다. 시계열 회차(실습 1)만 직접 학습했고, 텍스트 회차(실습 2)와 이 회차는 추론만 한다. 이미지 쪽에서 이것이 가능해진 결정적 계기가 CLIP(Radford 외 2021)이다. 인터넷에서 모은 4억 쌍의 (이미지, 캡션)으로 "이 사진과 이 문장이 짝인가"만 학습시켰더니, 이미지와 문장이 같은 좌표계에 놓였다. 분류기가 아니라 좌표계를 배운 셈이어서, 처음 보는 클래스 이름을 문장으로 써 넣기만 하면 분류가 된다.

Cloudflare Workers AI 에는 CLIP 류 이미지 임베딩 모델이 없다. 그래서 이 회차는 두 경로를 병행한다. 이미지 임베딩은 Colab GPU 에서 CLIP 을 추론만 하고(5,400장에 1분), 비전 언어모델 llava 와 텍스트 임베딩 bge-m3 는 API 로 부른다. llava 는 실측 결과 neurons 를 차감하지 않았고(§3 비용표), 이 회차 전체의 API 사용량은 약 100 neurons 로 하루 무료 한도(10,000)의 1%다.

생각해 볼 질문

내 연구에서 사진이 등장하는 자리는 어디인가. 사진이 결과변수라면 "처치가 사진을 바꿨는가"가 질문이고, 사진이 교란요인이라면 "사진이 처치 이전에 찍혔는가"가 질문이다. 이 실습에서 만드는 512차원 벡터는 두 자리 중 어느 쪽에 더 위험한가.

출처 Huang, Hsiang & Gonzalez-Navarro, "Using Satellite Imagery and Deep Learning to Evaluate the Impact of Anti-Poverty Programs", NBER Working Paper 29105, 2021, nber.org/papers/w29105 · Burke, Driscoll, Lobell & Ermon, "Using Satellite Imagery to Understand and Promote Sustainable Development", Science 371(6535), 2021 · Radford 외, "Learning Transferable Visual Models From Natural Language Supervision", 2021, arxiv.org/abs/2103.00020

1
EuroSAT RGB · Fashion Product Images

데이터: 위성 5,400장, 상품 4,000건

두 데이터 모두 Hugging Face parquet 하나를 URL 로 받으면 끝난다. 로그인도 API 키도 없다. 라벨 번호와 이름의 대응은 파일명으로 검증했다.

데이터 요약

위성 토지이용 10클래스, 상품 카테고리 10클래스

EuroSAT 은 유럽 Sentinel-2 위성영상을 64×64 픽셀로 잘라 토지이용 10종을 붙인 데이터다. Fashion Product Images 는 인도 전자상거래 상품 사진에 제목·성별·카테고리·색상이 붙어 있어 이미지와 텍스트가 한 행에 같이 있다. 이 회차에서는 두 번째 데이터의 subCategory 상위 10개 클래스만 쓴다.

데이터출처 · 접근크기이 회차에서 쓰는 것전처리
EuroSAT RGBHelber 외 2019 · HF blanchon/EuroSAT_RGB parquet27,000장 (train 16,200 / val 5,400 / test 5,400) · 64×64 · 10클래스val 5,400장 전체 (제로샷·프로브 평가·군집). 프로브 학습은 train 16,200PNG 바이트 → PIL RGB. CLIP 전처리기가 224 로 확대·정규화
Fashion Product Images (small)Kaggle (Param Aggarwal) · HF 미러 ashraq/fashion-product-images-small44,072건 (0.parquet 22,036) · 약 60×80 · subCategory 45종subCategory 상위 10종 × 400 = 4,000건 → train 3,000 / test 1,000 (층화)JPEG 바이트 → PIL RGB. 제목(productDisplayName)은 그대로
AnnualCropAnnualCrop
일년생 작물
ForestForest
HerbaceousVegetationHerbaceousVegetation
초지(목초 외)
HighwayHighway
고속도로
IndustrialIndustrial
공업지
PasturePasture
목초지
PermanentCropPermanentCrop
다년생 작물
ResidentialResidential
주거지
RiverRiver
SeaLakeSeaLake
해양·호수

EuroSAT 10클래스의 대표 영상(클래스별 CLIP 중심에 가장 가까운 1장, 64×64 원본). 일년생 작물·다년생 작물·목초지·초지는 사람 눈에도 갈리지 않는다. 이 넷이 §4 오류의 대부분이다.

LAB · 코드형

① 데이터 로드 — parquet 두 개

이미지가 바이트로 들어 있는 parquet 이므로 pandas 로 읽고 PIL 로 푼다. 라벨 번호와 클래스 이름의 대응을 파일명 접두사로 검증하는 assert 한 줄이 중요하다. HF 데이터셋의 라벨 순서는 문서에 없는 경우가 많다.

def load_parquet(url): r = requests.get(url, timeout=600); r.raise_for_status(); return pd.read_parquet(io.BytesIO(r.content)) HF = 'https://huggingface.co/api/datasets/' es = load_parquet(HF + 'blanchon/EuroSAT_RGB/parquet/default/validation/0.parquet') # 5,400장 · 35MB CLASSES = ['AnnualCrop', 'Forest', 'HerbaceousVegetation', 'Highway', 'Industrial', 'Pasture', 'PermanentCrop', 'Residential', 'River', 'SeaLake'] assert (es.filename.str.rsplit('_', n=1).str[0] == np.array(CLASSES)[es.label]).all() # 라벨↔파일명 검증 fa = load_parquet(HF + 'ashraq/fashion-product-images-small/parquet/default/train/0.parquet') # 22,036건 · 136MB from PIL import Image def decode(d): return Image.open(io.BytesIO(d['bytes'])).convert('RGB') print(es.shape, fa.shape, decode(es.image[0]).size, decode(fa.image[0]).size) print(fa[['subCategory', 'articleType', 'baseColour', 'productDisplayName']].head(3).to_string()) # → (5400, 3) (22036, 11) (64, 64) (60, 80)

관찰 포인트assert 가 통과하면 라벨 0~9 가 알파벳순 클래스 이름과 일치한다는 뜻이다. 통과하지 않으면 이후 모든 클래스별 수치가 엉뚱한 이름에 붙는다. Fashion 의 head(3) 에서 제목에 이미 "Shirt", "Watch" 같은 카테고리 단어가 들어 있음을 확인해 두면 §2 베이스라인 숫자가 왜 높은지 미리 보인다.

더 읽기

EuroSAT 원 논문은 Sentinel-2 의 13개 분광 밴드 전체를 담은 버전과 RGB 3밴드 버전을 함께 공개했다. 여기서는 RGB 만 쓴다. CLIP 은 자연 사진으로 학습되었으므로 적외선 밴드를 넣을 수 없고, 반대로 말하면 위성영상 고유의 정보(식생지수 등)를 버리고 시작하는 셈이다. 제로샷이 식생 클래스에서 무너지는 원인 하나가 여기 있다.

Fashion 데이터의 subCategory 상위 10종은 Topwear, Shoes, Bags, Bottomwear, Watches, Innerwear, Eyewear, Jewellery, Fragrance, Sandal 이다. 클래스당 400건으로 균형을 맞춘 이유는 macro-F1 을 읽기 쉽게 하기 위해서이고, 원 데이터는 Topwear 가 7,681건으로 압도적이다. 자기 데이터에 옮길 때 클래스 불균형이 크면 층화 분할과 macro 평균을 반드시 같이 쓴다.

출처 Helber, Bischke, Dengel & Borth, "EuroSAT: A Novel Dataset and Deep Learning Benchmark for Land Use and Land Cover Classification", IEEE JSTARS 12(7), 2019, arxiv.org/abs/1709.00029 · HF blanchon/EuroSAT_RGB · Kaggle paramaggarwal/fashion-product-images-small · HF 미러 ashraq/fashion-product-images-small

분할

먼저 나누고, 그 다음에 뭐든 한다

EuroSAT 은 노트북에서 val 5,400장을 절반씩 층화 분할해 프로브 학습·평가에 쓰고(페이지 결과표는 train 16,200 으로 학습한 값), 제로샷은 학습이 없으므로 5,400장 전체에 평가한다. Fashion 은 클래스당 400건을 뽑아 3,000 / 1,000 으로 나눈다.

LAB · 코드형

② 층화 분할

from sklearn.model_selection import train_test_split y_es = es.label.values es_tr, es_te = train_test_split(np.arange(len(es)), test_size=0.5, stratify=y_es, random_state=0) fa = fa[fa.productDisplayName.notna()] TOP = fa.subCategory.value_counts().index[:10].tolist() # 상위 10개 클래스 fs = pd.concat([fa[fa.subCategory == c].sample(400, random_state=0) for c in TOP]).reset_index(drop=True) fs['y'] = fs.subCategory.map({c: i for i, c in enumerate(TOP)}); y_fs = fs.y.values fs_tr, fs_te = train_test_split(np.arange(len(fs)), test_size=1000, stratify=y_fs, random_state=0) print(len(es_tr), len(es_te), '|', TOP, '|', len(fs_tr), len(fs_te)) # → 2700 2700 | ['Topwear', 'Shoes', 'Bags', 'Bottomwear', 'Watches', 'Innerwear', 'Eyewear', 'Jewellery', 'Fragrance', 'Sandal'] | 3000 1000

관찰 포인트 — 제로샷에는 분할이 필요 없다는 사실 자체가 관찰 대상이다. 학습이 없으니 "훈련 데이터 누출"이 원리적으로 없다. 대신 프롬프트를 고르는 과정에서 평가 데이터를 보고 문구를 바꾸면 그것이 누출이다. §4 에서 두 프롬프트의 차이를 볼 때 이 점을 기억해 두자.

2
딥러닝 없이

베이스라인: 픽셀과 제목

위성영상은 8×8 로 뭉갠 픽셀 198개로 68%까지 간다. 상품은 제목 TF-IDF 만으로 98.7%다. 두 번째 숫자는 이 회차의 천장을 미리 알려 준다.

기준 모델

먼저 가장 단순한 것으로

한 줄 목표: 이후의 모든 딥러닝 특징은 이 두 숫자를 이겨야 한다. EuroSAT 픽셀 베이스라인 67.8%(train 16,200), Fashion 제목 TF-IDF macro-F1 98.7 ± 0.2(seed 5회).

LAB · 코드형

③ 픽셀 특징 + 로지스틱 / TF-IDF + 로지스틱

이미지 베이스라인은 64×64 를 8×8 로 줄인 RGB 값 192개에 채널 평균·표준편차 6개를 붙인 198차원이다. 색과 거친 질감만 남는다.

from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics import f1_score def pixel_feats(imgs): # 8×8 RGB(192) + 채널 평균·표준편차(6) = 198차원 out = [] for im in imgs: a = np.asarray(im.resize((8, 8)), np.float32) / 255; f = np.asarray(im, np.float32) / 255 out.append(np.r_[a.ravel(), f.mean((0, 1)), f.std((0, 1))]) return np.array(out) es_img = [decode(d) for d in es.image] P = pixel_feats(es_img); sc = StandardScaler().fit(P[es_tr]) 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]) tf = TfidfVectorizer(ngram_range=(1, 2), min_df=2).fit(fs.productDisplayName[fs_tr]) 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])) f1_tfidf = f1_score(y_fs[fs_te], pr, average='macro') print(f'EuroSAT 픽셀+로지스틱 acc = {acc_pix:.3f} | Fashion TF-IDF 제목 macro-F1 = {f1_tfidf:.3f}') # → EuroSAT 약 0.60 (train 2,700 기준; 16,200 으로 학습하면 0.678) | Fashion 약 0.99

관찰 포인트 — Fashion 의 98% 를 보고 "이미지가 필요 없다"고 결론 내리는 것이 정확한 독해다. 제목에 "Sandals", "Watch", "Sunglasses" 가 적혀 있는 데이터에서 subCategory 는 텍스트 문제다. 그렇다면 §4 에서 결합이 이길 수 있는 폭은 처음부터 1~2%p 뿐이다. 이 회차는 그 좁은 폭에서 무엇이 일어나는지를 본다. 열린 과제: 8×8 을 4×4 와 16×16 으로 바꿔 정확도가 어떻게 움직이는지 확인해 보라.

더 읽기

픽셀 베이스라인이 68%나 되는 이유는 EuroSAT 의 클래스가 색으로 상당히 갈리기 때문이다. 바다·호수는 파랗고, 숲은 짙은 초록이고, 공업지는 밝은 회색이다. 이 베이스라인이 무너지는 곳은 Highway(35%)와 PermanentCrop(39%) — 색이 아니라 선의 배열과 격자 무늬로 구분되는 클래스다. §4 에서 CLIP 프로브가 이 둘을 각각 86%, 85%로 끌어올린다. 사전학습 모델이 추가하는 것은 색이 아니라 모양이다.

베이스라인은 "이길 대상"이기 전에 "천장을 재는 도구"다. 텍스트 회차에서 TF-IDF 가 그랬듯, 여기서도 TF-IDF 98.7% 는 남은 1.3% 가 어떤 표본인지 들여다보게 만든다. 그 1.3% 는 대부분 Shoes ↔ Sandal, Innerwear ↔ Topwear 경계에 있다.

3
CLIP · llava · bge-m3 — 추론만

학습 없이 특징 뽑기

세 가지 호출로 이미지가 세 종류의 변수가 된다. 벡터(CLIP), 라벨·문장(llava), 그리고 문장을 다시 벡터로(bge-m3). 각각 20행 안쪽이다.

경로 1 · 벡터

CLIP: 이미지와 문장을 같은 512차원에

한 줄 목표: transformers 로 CLIP 을 불러 이미지 5,400장을 512차원 벡터로 만들고, 클래스 이름 문장 10개를 같은 공간에 놓아 코사인이 가장 큰 클래스를 예측으로 삼는다. 학습은 없다.

LAB · 코드형

④ CLIP 로드와 임베딩 함수

이미지와 텍스트에 각각 인코더가 있고 출력은 둘 다 512차원이다. L2 정규화를 해 두면 내적이 곧 코사인 유사도다.

import torch from transformers import CLIPModel, CLIPProcessor dev = 'cuda' if torch.cuda.is_available() else 'cpu' clip = CLIPModel.from_pretrained('openai/clip-vit-base-patch32').to(dev).eval() proc = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32') def _vec(o): return o if torch.is_tensor(o) else o.pooler_output # transformers 4.x(텐서) / 5.x(출력 객체) 호환 @torch.no_grad() def clip_image(imgs, bs=256): # PIL 이미지 → (n, 512), L2 정규화 out = [] for i in range(0, len(imgs), bs): px = proc(images=imgs[i:i + bs], return_tensors='pt')['pixel_values'].to(dev) out.append(torch.nn.functional.normalize(_vec(clip.get_image_features(pixel_values=px)), dim=-1).cpu().numpy()) return np.concatenate(out) @torch.no_grad() def clip_text(texts): # 문장 → (n, 512), 같은 공간 e = _vec(clip.get_text_features(**proc(text=texts, return_tensors='pt', padding=True).to(dev))) return torch.nn.functional.normalize(e, dim=-1).cpu().numpy() E_es = clip_image(es_img); print(E_es.shape) # → (5400, 512)

관찰 포인트 — T4 에서 5,400장이 1분 안에 끝나는지 시간을 재 보라. 27,000장 전체도 5분이다. "이미지 딥러닝은 비싸다"는 감각은 학습 기준이고, 추론만 하면 CPU 로도 된다.

LAB · 코드형

⑤ 제로샷 — 프롬프트 두 가지

클래스 이름을 문장에 끼워 넣는 틀(프롬프트)을 두 개 비교한다. 이 한 줄이 §4 에서 클래스별 정확도를 뒤집는다.

PHRASE = {'AnnualCrop': 'annual crop land', 'Forest': 'forest', 'HerbaceousVegetation': 'herbaceous vegetation', 'Highway': 'a highway', 'Industrial': 'industrial buildings', 'Pasture': 'pasture', 'PermanentCrop': 'permanent crop land', 'Residential': 'residential buildings', 'River': 'a river', 'SeaLake': 'sea or lake'} zs = {} for name, tpl in {'photo': 'a satellite photo of {}', 'name': '{}'}.items(): T = clip_text([tpl.format(PHRASE[c]) for c in CLASSES]) # (10, 512) zs[name] = (E_es @ T.T).argmax(1) # 코사인 최대 = 예측 (학습 없음) 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)) # → 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] # → {} acc=0.436 [0.01 0.25 0.01 0.62 0.46 0.01 0.84 0.94 0.31 0.94]

조작 안내PHRASE 의 문구를 바꿔 보라(예: 'sea or lake' → 'water').
관찰 포인트 — 전체 정확도보다 클래스별 배열의 변화가 크다. "a satellite photo of" 를 붙이면 Industrial 이 78% 로 오르고 SeaLake 가 33% 로 떨어진다. 프롬프트는 하이퍼파라미터이며, 평가 데이터를 보며 고르면 그것이 곧 학습이다.

더 읽기

CLIP 의 제로샷은 마법이 아니라 검색이다. 이미지 벡터를 질의로, 클래스 문장 벡터 10개를 문서로 놓고 가장 가까운 문서를 고르는 것이다. 그래서 두 가지가 결정적이다. 첫째, 문서(클래스 문장)가 CLIP 이 학습한 캡션 분포와 닮았는가. "a satellite photo of a river" 는 인터넷 캡션에 있을 법한 문장이고 "river" 한 단어는 덜 그렇다. 둘째, 클래스들이 그 공간에서 서로 떨어져 있는가. "pasture" 와 "herbaceous vegetation" 은 CLIP 에게 거의 같은 뜻이고, 그래서 둘 중 하나는 반드시 무너진다.

모델 크기도 변수다. 여기서는 가장 작은 ViT-B/32 를 썼다. 원 논문은 EuroSAT 제로샷이 모델을 키워도 낮은 편에 속하는 과제라고 보고했다. 위성영상은 인터넷 캡션 데이터에 드물기 때문이다. 자기 데이터가 인터넷 사진과 멀수록(현미경, X선, 도면) 제로샷 성능은 떨어지고 프로브의 가치는 올라간다.

출처 Radford 외 2021, arxiv.org/abs/2103.00020 (§3.1.4 프롬프트 공학, 표 · EuroSAT 결과) · HF openai/clip-vit-base-patch32 모델 카드

경로 2 · 라벨과 문장

llava: 사진을 보고 말로 답하는 모델

한 줄 목표: Cloudflare 의 @cf/llava-hf/llava-1.5-7b-hf 에 이미지와 질문을 보내 라벨 문자열을 받는다. 답이 문자열이라 파싱이 필요하고, 그 파싱 규칙도 재현성 표에 적어야 한다.

LAB · 코드형

⑥ llava 제로샷 라벨 (노트북 60장 · 페이지 300장)

ask_image 는 셀 ① 의 헬퍼다. 이미지 바이트를 정수 배열로 보내고 result.description 을 받는다.

PROMPT = 'This is a satellite image. Classify it into exactly one of: ' + ', '.join(CLASSES) + '. Answer with the label only.' def parse(ans): # 답 문자열 → 클래스 번호 (없으면 -1) a = ans.replace(' ', '').lower(); hit = [(a.find(c.lower()), c) for c in CLASSES if c.lower() in a] return CLASSES.index(min(hit)[1]) if hit else -1 ll_idx = np.concatenate([np.where(y_es == c)[0][:6] for c in range(10)]) # 클래스당 6장 = 60장 ll = np.array([parse(ask_image(es.image[i]['bytes'], PROMPT, max_tokens=12)) for i in ll_idx]) 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()}") print('llava 답 분포:', pd.Series(ll).map(lambda i: CLASSES[i] if i >= 0 else 'None').value_counts().to_dict()) # → 300장 실험에서 llava 0.367, CLIP(photo) 0.403. llava 답의 절반이 목록 첫 라벨 AnnualCrop 이었다

관찰 포인트 — 답 분포를 보라. 300장 실험에서 llava 는 절반(150장)을 "AnnualCrop" 이라 답했다. 목록의 첫 라벨이다. 언어모델은 선택지의 순서에 반응한다. 열린 과제: CLASSES 순서를 뒤집어 다시 물었을 때 답 분포가 따라 움직이는지 확인해 보라.

이 회차의 API 비용

모델역할단가 (neurons)이 회차 사용
openai/clip-vit-base-patch32이미지·문장 임베딩 (Colab GPU, 추론만)0 (API 아님)25,600장 · GPU 1분
@cf/llava-hf/llava-1.5-7b-hf제로샷 라벨 · 캡션0.000 / 장 (실측)450회 · 0
@cf/baai/bge-m3제목·캡션 → 1024차원1,075 / 100만 토큰 ≈ 0.02 / 제목4,350건 · 89.6
@cf/microsoft/resnet-50ImageNet 1000 라벨0.228 / 장50장 · 11.4
@cf/meta/llama-3.3-70b-instruct-fp8-fast(실습 2) 텍스트 제로샷약 2.9 / 건이 회차 미사용

llava 단가는 가격표에 없어 실측했다. 20장 호출 뒤 응답의 usage.neurons 와 계정 분석 API(aiInferenceAdaptiveGroups)를 함께 확인한 결과 두 곳 모두 0 이었다(2026-09-12, 474회). 과금이 바뀔 수 있으므로 노트북의 report() 로 매번 확인한다. resnet-50 은 응답에 usage 가 없어 분석 API 값(0.228/장)을 썼다.

더 읽기

llava 같은 비전 언어모델(VLM)의 답은 자유 문장이다. 그래서 파싱 규칙이 결과의 일부가 된다. 여기서는 "답 문자열에 등장하는 첫 번째 클래스 이름"을 라벨로 삼았고, 300장 중 파싱 실패는 0건이었다. 그러나 "Pasture or AnnualCrop" 같은 답이 오면 이 규칙은 Pasture 를 고른다. 규칙을 바꾸면 정확도가 바뀐다. 논문에는 프롬프트 문구, max_tokens, 파싱 규칙, 실패 건수를 모두 적는다.

비용 감각도 적어 둔다. 이 회차의 API 사용량은 bge-m3 약 86, resnet-50 약 11, llava 0 으로 합계 약 100 neurons 다. 텍스트 회차에서 llama-3.3-70b 로 2,000건을 분류하는 데 약 3,000 neurons 가 든 것과 비교하면, 이미지 쪽 API 는 사실상 무료다. 단, llava-1.5 는 2023년 모델이고 64px 위성영상은 그 모델이 본 적 없는 분포다. 비용이 0 이라는 것과 쓸모가 있다는 것은 다른 문제이며, §4 가 그 답이다.

출처 Liu, Li, Wu & Lee, "Visual Instruction Tuning", NeurIPS 2023, arxiv.org/abs/2304.08485 · Cloudflare Workers AI 모델 카탈로그, developers.cloudflare.com/workers-ai/models · 가격, workers-ai/platform/pricing

경로 3 · 두 모달

이미지 벡터 ‖ 제목 벡터, 그리고 이미지 → 문장 → 벡터

한 줄 목표: Fashion 4,000건에 대해 CLIP 이미지 벡터(512)와 bge-m3 제목 벡터(1024)를 만들고 옆으로 이어붙인다(1536). 결합은 이것뿐이다. 이어서 llava 캡션을 bge-m3 로 임베딩해 "이미지를 텍스트 공간으로 보내는" 우회로도 확인한다.

LAB · 코드형

⑦ 특징 세 벌: text · image · concat

fs_img = [decode(d) for d in fs.image] E_img = clip_image(fs_img) # (4000, 512) E_txt = np.array(embed(fs.productDisplayName.tolist()), np.float32) # bge-m3 (4000, 1024) E_txt /= np.linalg.norm(E_txt, axis=1, keepdims=True) feats = {'text': E_txt, 'image': E_img, 'concat': np.hstack([E_img, E_txt])} # 결합 = 이어붙이기 print({k: v.shape for k, v in feats.items()}, '|', report()) # → {'text': (4000, 1024), 'image': (4000, 512), 'concat': (4000, 1536)}

관찰 포인트 — 이어붙이기 전에 두 벡터를 각각 L2 정규화해 두었다. 정규화 없이 붙이면 노름이 큰 쪽이 로지스틱 회귀를 지배한다. 열린 과제: 정규화를 빼고 §4 의 결합 F1 을 다시 재 보라.

LAB · 코드형

⑧ 이미지 → 캡션 → 텍스트 임베딩 (노트북 20장 · 페이지 150장)

Cloudflare 에 CLIP 이 없을 때의 대안이다. llava 캡션은 bge-m3 를 거치면 제목 임베딩과 같은 1024차원 공간에 놓이므로, 제목으로 학습한 분류기를 그대로 캡션에 적용할 수 있다.

cap_idx = fs_te[:20] caps = [ask_image(fs.image[i]['bytes'], 'Describe this product in one sentence.', max_tokens=40) for i in cap_idx] E_cap = np.array(embed(caps), np.float32); E_cap /= np.linalg.norm(E_cap, axis=1, keepdims=True) clf_txt = LogisticRegression(max_iter=3000, C=5).fit(E_txt[fs_tr], y_fs[fs_tr]) # 제목 임베딩으로 학습 print('제목으로 학습한 분류기 → 캡션 임베딩 acc =', (clf_txt.predict(E_cap) == y_fs[cap_idx]).mean(), '|', report()) for t, c in list(zip(fs.productDisplayName[cap_idx], caps))[:5]: print(f' {t} → {c}') # → 150장 실험: 캡션 acc 0.86 (제목 자체는 0.99). 캡션은 "A man wearing a plaid shirt." 처럼 브랜드·품목명이 빠진다

관찰 포인트 — 150장 실험에서 제목으로 학습한 분류기는 제목 임베딩에 99.3%, 캡션 임베딩에는 86.0% 를 냈다. 아래 표의 캡션을 보면 이유가 보인다. 브랜드와 품목명이 사라지고 "A man wearing a plaid shirt." 만 남는다. 캡션은 제목의 대체물이 아니라 다른 변수다.

제목 (productDisplayName)llava 캡션 ("Describe this product in one sentence.")
John Players Men Check Green ShirtA man wearing a plaid shirt.
Scullers Men Red ShirtA man wearing a plaid shirt.
Nike Men Printed Green T-shirtA green shirt with a circle on it.
S.Oliver Men Blue SweaterA man wearing a blue hoodie.
Myntra Women's I Know I Am Not Black T-shirtA woman wearing a black shirt with the words "perfectly imperfect" on it.
더 읽기

결합 방식은 이어붙이기(late fusion 의 가장 단순한 형태)만 썼다. 두 벡터를 곱하거나 어텐션으로 섞는 방법은 학습이 필요하고, 이 회차의 원칙(학습 없음)을 벗어난다. 실무에서 이어붙이기가 먼저인 이유는 해석 때문이기도 하다. 로지스틱 회귀의 계수가 이미지 쪽 512개와 텍스트 쪽 1024개로 나뉘어 있어, 어느 모달이 어느 클래스를 맡는지 계수 노름으로 볼 수 있다.

캡션 경로에는 장점이 하나 있다. 캡션은 사람이 읽을 수 있다. 512차원 벡터는 무엇이 담겼는지 볼 수 없지만, "plaid shirt" 라는 문장은 볼 수 있고 틀렸으면 틀린 이유를 말할 수 있다. 9장이 케냐 연구에서 "해석 가능한 스칼라"를 강조한 것과 같은 논리다. 벡터를 회귀에 꽂기 전에 캡션으로 한 번 읽어 보는 절차는 비용이 0 이면서 설계 오류를 잡아 준다.

출처 Chen 외, "BGE M3-Embedding", 2024, arxiv.org/abs/2402.03216 · Cloudflare @cf/baai/bge-m3

4
seed 5회 · 2026-09-12 실행

결과: 제로샷은 베이스라인 수준, 프로브가 판을 바꾸고, 융합은 표준편차 안

라벨 0장의 CLIP 은 픽셀 베이스라인이 라벨 100장으로 도달하는 39% 와 같다. 라벨 100장을 CLIP 에 얹으면 85%, 전체면 92%. Fashion 결합은 단독 최강을 0.2%p 이겼고 그 차이는 표준편차 한두 배다.

결과 A · EuroSAT

라벨 수에 따른 정확도

제로샷 두 프롬프트는 38.7% 와 43.6%. 같은 표본 300장에서 llava 는 36.7%, CLIP 은 40.3%. 반면 CLIP 벡터에 클래스당 10장의 라벨을 얹은 로지스틱 회귀는 70.5%, 100장이면 85.0%, 전체 16,200장이면 92.1% 다.

특징 → 분류기라벨 수val 정확도 (%)
픽셀 8×8 + 로지스틱 (베이스라인)16,20067.8
픽셀 8×8 + 로지스틱클래스당 10 (100)39.9 ± 1.9
픽셀 8×8 + 로지스틱클래스당 100 (1,000)56.2 ± 0.7
CLIP 제로샷 · "a satellite photo of {class}"038.7
CLIP 제로샷 · "{class}"043.6
llava-1.5 제로샷 (층화 300장 표본)036.7 (같은 300장 CLIP photo 40.3)
CLIP 임베딩 + 로지스틱 (선형 프로브)클래스당 10 (100)70.5 ± 1.5
CLIP 임베딩 + 로지스틱클래스당 100 (1,000)85.0 ± 0.2
CLIP 임베딩 + 로지스틱16,200 (전체)92.1

실행 환경: 2026-09-12 · RTX 4090 (CLIP 추론) · Cloudflare Workers AI (llava) · seed 0~4 (클래스당 10·100 은 표본 재추출 5회 평균 ± 표준편차, 전체는 1회) · LogisticRegression lbfgs C=1 · 평가 val 5,400장.

EuroSAT 데이터 효율 곡선
그림 1. 라벨 수에 따른 EuroSAT val 정확도(seed 5회 평균 ± 표준편차, 전체는 1회). CLIP 제로샷(점선 38.7%)은 픽셀 베이스라인이 클래스당 10장으로 도달하는 수준(39.9%)과 같다. 그러나 같은 CLIP 임베딩에 라벨을 클래스당 10장만 얹으면 70.5%, 100장이면 85.0%로 뛴다. 이 실습에서 "라벨 조금"이 가장 싼 투자다.
더 읽기

표에서 읽을 것은 두 개의 간격이다. 첫째, 제로샷(39~44%)과 픽셀 베이스라인 전체(68%)의 간격. 라벨이 16,200장 있으면 딥러닝 없이도 제로샷을 크게 이긴다. 제로샷은 "라벨이 정말 한 장도 없을 때"의 도구이지 라벨을 대체하는 도구가 아니다. 둘째, 같은 라벨 수에서 픽셀과 CLIP 의 간격 — 클래스당 10장에서 39.9% 대 70.5%, 100장에서 56.2% 대 85.0%. 사전학습 모델의 진짜 가치는 제로샷이 아니라 라벨 효율에 있다. 라벨 100장이면 픽셀 16,200장을 이긴다.

llava 가 CLIP 보다 낮은 것은 모델의 우열이라기보다 과제의 성격이다. 64×64 위성영상은 llava 가 학습한 자연 사진·문서 분포에서 멀고, 답을 "생성"하는 모델은 선택지 순서 같은 언어적 편향을 함께 가져온다. 반면 CLIP 은 10개 문장과의 거리만 재므로 그런 편향이 없다. 라벨을 묻는 일에는 검색형(CLIP)이, 설명을 얻는 일에는 생성형(llava)이 맞는다.

오류 분석 A

제로샷이 버티는 클래스와 무너지는 클래스

Residential 과 Industrial 은 제로샷으로도 78~94% 다. 사람이 만든 구조물은 인터넷 사진과 닮았다. HerbaceousVegetation 은 두 프롬프트 모두 0% 대이고, PermanentCrop 과 SeaLake 는 프롬프트에 따라 18% ↔ 84%, 33% ↔ 94% 로 뒤집힌다.

EuroSAT 클래스별 정확도
그림 2. 클래스별 정확도. 제로샷은 HerbaceousVegetation 을 두 프롬프트 모두 사실상 0%로 맞히지 못하고, SeaLake 는 문구에 따라 33.5% ↔ 94.3%, PermanentCrop 은 17.9% ↔ 84.4%로 뒤집힌다. 프롬프트는 하이퍼파라미터다. 선형 프로브(진한 청록)는 모든 클래스를 80% 위로 올린다.
  • 동의어 충돌. "a satellite photo of" 프롬프트에서 HerbaceousVegetation 540장 중 351장이 Pasture 로 갔다. CLIP 에게 herbaceous vegetation 과 pasture 는 같은 뜻이다. 클래스 이름이 서로 겹치면 제로샷은 그 경계를 원리적으로 그을 수 없다.
  • 한 클래스로 쏠림. 같은 프롬프트에서 Industrial 예측이 1,129건(정답은 540건)이다. River 166장, AnnualCrop 153장, Highway 146장이 Industrial 로 흡수됐다. 제로샷 결과를 변수로 쓰면 이 쏠림이 그대로 측정오차가 된다.
  • llava 의 순서 편향. 300장 중 답 분포는 AnnualCrop 150, Forest 72, Pasture 28, Residential 14. 정답이 30장씩인데 목록 첫 라벨에 절반이 몰렸다. AnnualCrop 과 Forest 는 93~97% 로 맞히고 HerbaceousVegetation·PermanentCrop 은 0% 다.
  • 프로브가 남긴 오류. 전체 학습 프로브(92.1%)의 오류는 River(82%)와 PermanentCrop(85%)에 남는다. 좁은 강은 64px 에서 도로와, 과수원 격자는 일년생 작물의 밭고랑과 닮았다.
더 읽기

이 오류 분석이 논문에 들어가야 하는 이유는 성능 보고 때문이 아니라 변수 정의 때문이다. 제로샷 라벨을 "토지이용" 변수로 쓴다면, 그 변수는 실제로는 "CLIP 이 Industrial 이라 부르는 것"이고 그 안에 강과 도로가 섞여 있다. 회귀에서 이 변수의 계수는 토지이용의 효과가 아니라 그 혼합물의 효과다. §6 에서 이 문제를 측정오차의 언어로 다시 쓴다.

결과 B · Fashion

텍스트 단독 · 이미지 단독 · 결합

결합(99.12 ± 0.08)이 텍스트 단독(98.90 ± 0.19)을 이겼다. 차이 0.22%p 는 표준편차의 한두 배이고, 베이스라인 TF-IDF(98.70)와의 차이도 0.4%p 다. 이미지 단독(97.37)이 가장 낮다. 이 데이터에서 subCategory 는 제목이 거의 다 말해 준다.

특징macro-F1 (%)정확도 (%)
TF-IDF 제목 + 로지스틱 (베이스라인)98.7 ± 0.298.7 ± 0.2
bge-m3 제목 임베딩 (1024)98.9 ± 0.298.9 ± 0.2
CLIP 이미지 임베딩 (512)97.4 ± 0.297.4 ± 0.2
결합 = 이미지 ‖ 제목 (1536)99.1 ± 0.199.1 ± 0.1

실행 환경: 2026-09-12 · RTX 4090 (CLIP) · Cloudflare bge-m3 · train 3,000 에서 2,500 재추출 × seed 0~4, test 1,000 고정 · LogisticRegression lbfgs C=5 · macro-F1 은 10클래스 단순 평균.

Fashion 클래스별 F1
그림 3. Fashion 클래스별 F1(seed 5회 평균, 축 85~100 확대). 이미지 단독이 무너지는 곳은 Shoes(91.4%)와 Sandal(92.3%) — 60×80 썸네일에서 신발과 샌들은 닮았고 제목에는 "Sandals"가 적혀 있다. 반대로 Bottomwear 는 이미지(98.9%)가 제목(97.6%)보다 낫고 결합에서 99.8%가 된다. 결합의 이득은 두 모달이 서로 다른 클래스에서 틀릴 때만 생긴다.
더 읽기

"결합이 이겼다"고 쓰기 전에 두 가지를 확인해야 한다. 첫째, 차이가 seed 간 변동 안인가. 0.22%p 대 표준편차 0.08~0.19 — 5회 평균 기준으로는 일관되게 결합이 위지만, 표본 1,000건에서 0.22%p 는 오답 2건 차이다. 둘째, 어디서 이겼는가. 그림 3 의 Bottomwear 와 Topwear 에서 결합이 두 단독을 모두 넘고, Shoes·Sandal 에서는 결합이 텍스트 수준으로 회복한 것이지 넘어선 것이 아니다. 즉 결합의 역할은 "이미지가 무너지는 클래스에서 텍스트가 받쳐 주고, 텍스트가 애매한 클래스(Bottomwear: "Track Pants" 와 "Trousers")에서 이미지가 받쳐 주는" 상호 보험이다.

정직한 결론은 이렇다. subCategory 예측에서 융합의 이득은 실재하지만 작고, 이 데이터에서 이미지를 추가하는 비용(CLIP 추론)에 비해 이득이 큰 과제는 아니다. 이득이 커지는 곳은 텍스트가 없거나 빈약한 행(제목 결측, 짧은 제목), 텍스트가 말하지 않는 속성(색상, 패턴, 착용 상태)이다. 자기 데이터에서 융합을 시도할 가치가 있는지는 "텍스트 단독의 오류가 이미지로 풀리는 종류인가"를 먼저 보고 결정한다.

5
UMAP + K-means · 라벨 없이

군집·시각화: 두 모달의 지도

위성영상 벡터는 라벨 없이 묶어도 정답과 ARI 0.46 으로 맞물린다. 상품은 이미지 지도(ARI 0.80)가 제목 지도(0.55)보다 깨끗하다 — 분류 정확도와 반대다.

실습 · 조작형

EuroSAT 임베딩 지도

한 줄 목표: 같은 2,000개 점을 정답 / 제로샷 정답 여부 / K-means 군집 세 가지 색으로 바꿔 보며, 제로샷이 틀리는 점들이 지도 위 어디에 모여 있는지 눈으로 찾는다.

LAB · 조작형

UMAP 2차원 지도 (val 5,400장 중 무작위 2,000점)

좌표는 CLIP 512차원 임베딩을 UMAP(n_neighbors 15, cosine)으로 내린 것이고, 군집은 원 512차원에서 K-means k=10 으로 구했다. 점 위에 마우스를 올리면 파일명·정답·제로샷 예측·군집이 보인다.

조작 안내 — ① "정답 클래스"에서 SeaLake(하늘색)·Residential(회색)·Industrial(보라)이 다른 덩어리와 떨어져 있는지 보라. ② "제로샷 정답 여부"로 바꿔 붉은 점(오답)이 어느 덩어리에 몰리는지 보라. ③ "K-means 군집"에서 한 군집이 두 정답 색에 걸쳐 있는 곳을 찾아라.
관찰 포인트 — 오답은 지도 전체에 흩어져 있지 않고 특정 덩어리(HerbaceousVegetation·Pasture·PermanentCrop·AnnualCrop 이 이어진 띠)에 몰려 있다. 즉 제로샷의 오류는 무작위 잡음이 아니라 구조적이며, 이 점이 §6 측정오차 논의의 출발점이다. 지도 위의 거리는 해석하지 않는다. 덩어리가 붙어 있다는 것만 읽는다.

더 읽기

위젯의 제로샷 예측은 "a satellite photo of {class}" 프롬프트의 것이고, 표시된 2,000점의 정답률은 39.6% 로 5,400장 전체(38.7%)와 0.9%p 차이다. 무작위 추출이므로 이 정도 차이는 정상이다. 군집 색은 K-means seed 0 의 결과이며, 군집 번호는 의미가 없고 정답 클래스와 일대일로 대응하지도 않는다. 아래 프로파일 표가 그 대응을 보여 준다.

군집 프로파일 · 안정성

라벨 없이 묶으면 무엇이 묶이는가

K-means k=10 은 정답과 ARI 0.457 ± 0.026, NMI 0.560 ± 0.010(seed 5회). 순도 90% 를 넘는 군집은 SeaLake·Residential·Industrial 셋뿐이고, 식생 네 클래스는 서로 섞인 군집을 만든다.

군집n다수 클래스 (비율)2위 클래스 (비율)
3271SeaLake (100%)Forest (0%)
2581Residential (92%)Industrial (4%)
4537Industrial (87%)Highway (4%)
8515HerbaceousVegetation (74%)PermanentCrop (10%)
6567AnnualCrop (67%)PermanentCrop (11%)
1502Highway (58%)River (39%)
7584Forest (54%)SeaLake (39%)
5606PermanentCrop (43%)Pasture (32%)
0581Forest (42%)HerbaceousVegetation (34%)
9656AnnualCrop (25%)Pasture (24%)

K-means seed 0, val 5,400장. 안정성: seed 0 대 seed 1~4 의 군집 일치도(ARI) 0.675 · 0.675 · 0.948 · 0.673 — 같은 데이터에서도 초기값에 따라 군집 경계가 30% 정도 움직인다.

LAB · 코드형

⑫ 군집은 원 임베딩에서, UMAP 은 그림에만

노트북 ⑦ 셀. 세 임베딩(EuroSAT CLIP · Fashion CLIP · Fashion bge-m3)에 같은 절차를 적용한다.

import umap, matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.metrics import adjusted_rand_score as ARI def umap2d(E, seed=0): return umap.UMAP(n_neighbors=15, min_dist=0.1, metric='cosine', random_state=seed).fit_transform(E) sets = [('EuroSAT · CLIP', E_es, y_es), ('Fashion · CLIP 이미지', E_img, y_fs), ('Fashion · bge-m3 제목', E_txt, y_fs)] for name, E, y in sets: aris = [ARI(y, KMeans(10, n_init=10, random_state=s).fit_predict(E)) for s in range(5)] # 군집은 원 임베딩에서 print(f'{name:22s} K-means k=10 ARI = {np.mean(aris):.3f} ± {np.std(aris, ddof=1):.3f}') fig, ax = plt.subplots(1, 3, figsize=(16, 5)) for a, (name, E, y) in zip(ax, sets): 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([]) plt.show() # → 페이지 실험: EuroSAT ARI 0.457±0.026 · Fashion 이미지 0.803±0.066 · Fashion 제목 0.545±0.033 (Fashion 은 표본이 달라 ±0.05 차이)

관찰 포인트 — ARI 는 EuroSAT 0.46, Fashion 이미지 0.80, Fashion 제목 0.55 근처가 나와야 한다. 열린 과제: KMeans(10)KMeans(7) 로 바꾸고 EuroSAT 의 식생 네 클래스를 하나로 합친 라벨과 ARI 를 다시 재 보라.

더 읽기

안정성 수치가 논문에 들어가야 하는 이유는, 군집 번호를 더미 변수로 회귀에 넣을 때 그 변수의 정의가 seed 에 따라 달라지기 때문이다. ARI 0.67 은 "다른 seed 였다면 표본의 상당수가 다른 군집 번호를 받았을 것"이라는 뜻이다. 대처는 셋이다. seed 여러 개의 합의(co-association)로 군집을 정하거나, 순도가 높은 군집만 변수로 쓰거나, 군집 대신 "특정 개념과의 코사인 점수"(§6) 같은 연속 변수를 쓴다.

NMI 가 ARI 보다 높은 것은 군집 수가 맞고 큰 덩어리는 잘 잡히지만 경계에서 재배정이 많다는 전형적 신호다. 식생 네 클래스를 하나로 합쳐 7클래스로 다시 재면 ARI 는 크게 오른다. 라벨 체계 자체가 벡터가 보는 세계보다 촘촘할 때 군집 평가는 이렇게 낮게 나온다.

출처 McInnes, Healy & Melville, "UMAP: Uniform Manifold Approximation and Projection", 2018, arxiv.org/abs/1802.03426 · Hubert & Arabie, "Comparing Partitions", Journal of Classification 2, 1985 (ARI)

두 모달의 지도

같은 상품, 이미지 지도와 제목 지도

Fashion 4,000건을 이미지 벡터와 제목 벡터로 각각 그리면, 이미지 지도가 훨씬 깨끗하다(K-means ARI 0.803 ± 0.066 대 0.545 ± 0.033). §4 에서 분류는 제목이 이겼는데 군집은 이미지가 이긴다.

Fashion 이미지·텍스트 UMAP 비교
그림 4. 같은 4,000건을 이미지 임베딩(왼쪽)과 제목 임베딩(오른쪽)으로 그린 UMAP. 이미지 지도는 열 덩어리가 또렷하고 K-means ARI 0.80, 제목 지도는 Topwear·Bottomwear·Shoes·Sandal 이 한 덩어리로 엉키고 ARI 0.55. 분류 정확도는 제목이 높은데 군집은 이미지가 깨끗하다 — 분류는 "구분선 하나"만 있으면 되고 군집은 "덩어리 전체"가 떨어져야 하기 때문이다.
더 읽기

분류와 군집이 반대로 나오는 것은 모순이 아니다. 분류기는 "Sandals" 라는 단어 하나로 경계를 그을 수 있으므로 제목 벡터에서 Shoes 와 Sandal 이 가까이 있어도 상관없다. 군집은 경계가 아니라 밀도를 본다. 제목 벡터 공간에서 "Puma Men Black Sandals" 와 "Puma Men Black Sports Shoes" 는 단어 하나 차이라 같은 덩어리에 놓이고, 이미지 공간에서는 발가락이 보이느냐 아니냐가 덩어리를 가른다. 라벨이 없는 탐색 단계에서는 이미지 벡터가, 라벨을 예측하는 단계에서는 제목이 유리한 이유다.

군집 해석

군집별 대표 상품 — 눈으로 확인하는 절차

군집 이름은 숫자로 붙일 수 없다. 중심에 가장 가까운 표본을 보고 붙여야 하고, 그 과정에서 "무엇을 파는가"가 아니라 "어떻게 찍었는가"로 묶인 군집이 드러난다.

Fashion 군집 대표 이미지 격자
그림 5. 이미지 임베딩 K-means(k=10, seed 0) 군집별 중심 최근접 6장. 군집 이름은 다수 subCategory. 군집 1·2 는 Topwear 가 41·45%에 그친다 — 모델 착용 사진(여성 상의·바지 / 남성 상의·바지)끼리 묶여 "무엇을 파는가"보다 "어떻게 찍었는가"가 군집을 갈랐다. 라벨 없이 군집을 쓸 때 반드시 대표 이미지를 눈으로 확인해야 하는 이유다.
더 읽기

군집 1 과 2 가 교훈이다. 둘 다 Topwear 가 다수지만 순도는 41·45% 이고, 대표 이미지는 각각 여성 모델 착용 사진과 남성 모델 착용 사진이다. CLIP 은 상의와 하의보다 "사람이 입고 찍은 전신 사진"이라는 촬영 방식을 더 강한 축으로 본 것이다. 이 군집을 "상의" 변수로 회귀에 넣으면 실제로는 "모델 착용 사진 여부"를 넣는 셈이 된다. 케냐 연구가 지붕 검출기의 출력을 지상 검증 자료로 확인한 것과 같은 절차가, 군집에서는 이 대표 이미지 격자다.

6
보고 규격 · 재현성 · 자기 데이터

논문에 쓰기: 이미지 → 스칼라 → 회귀

벡터를 회귀에 꽂는 순간 그 벡터의 오류가 계수의 오류가 된다. 표에는 성능을, 부록에는 호출 명세를, 본문에는 측정오차의 방향을 적는다.

본문 표 예시

성능표는 이렇게 생겼다

한 표에 베이스라인·제로샷·프로브·융합이 같은 평가셋·같은 seed 프로토콜로 놓여야 한다. 평균 ± 표준편차와 seed 수, 라벨 수를 열로 둔다.

과제특징 (모델 ID)라벨지표
EuroSAT 토지이용 10클래스 (val 5,400)픽셀 8×8 + 로지스틱16,200acc67.8
CLIP ViT-B/32 제로샷, "a satellite photo of {c}"0acc38.7
CLIP ViT-B/32 + 로지스틱클래스당 100acc85.0 ± 0.2 (5 seeds)
CLIP ViT-B/32 + 로지스틱16,200acc92.1
Fashion subCategory 10클래스 (test 1,000)bge-m3 제목2,500macro-F198.9 ± 0.2 (5 seeds)
CLIP 이미지2,500macro-F197.4 ± 0.2
CLIP ‖ bge-m3 결합2,500macro-F199.1 ± 0.1
LAB · 코드형

⑨ 평가 셀 — seed 5회 반복

노트북 ⑥ 셀. 표의 모든 행이 이 루프 하나에서 나온다.

rows = [] for n_per in ['10', '100', 'all']: for seed in range(5): rng = np.random.default_rng(seed) 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)]) acc = LogisticRegression(max_iter=2000).fit(E_es[idx], y_es[idx]).score(E_es[es_te], y_es[es_te]) rows.append({'labels/class': n_per, 'seed': seed, 'acc': acc}) if n_per == 'all': break # 전체는 재추출이 없어 1회 probe = pd.DataFrame(rows).groupby('labels/class').acc.agg(['mean', 'std']).reindex(['10', '100', 'all']); print(probe.round(3)) rows, per = [], {} for seed in range(5): sub = np.random.default_rng(seed).choice(fs_tr, 2500, replace=False) for k, E in feats.items(): pr = LogisticRegression(max_iter=3000, C=5).fit(E[sub], y_fs[sub]).predict(E[fs_te]) rows.append({'feat': k, 'seed': seed, 'macro_f1': f1_score(y_fs[fs_te], pr, average='macro')}) per.setdefault(k, []).append(f1_score(y_fs[fs_te], pr, average=None)) res = pd.DataFrame(rows).groupby('feat').macro_f1.agg(['mean', 'std']); print(res.round(4)) print(pd.DataFrame({k: np.mean(v, 0) for k, v in per.items()}, index=TOP).round(3)) # → 페이지 실험(train 16,200): 프로브 10/클래스 0.705±0.015 · 100/클래스 0.850±0.002 · 전체 0.921 | Fashion text .989 image .974 concat .991

관찰 포인트 — 클래스당 10장의 표준편차(1.5%p)가 100장(0.2%p)의 7배다. 라벨이 적을수록 "어느 10장을 뽑았는가"가 결과를 흔든다. 라벨 수가 적은 논문일수록 seed 반복은 선택이 아니라 필수다.

부록 · 재현성 표

무엇을 호출했는가를 전부 적는다

사전학습 모델은 버전이 바뀌면 벡터가 바뀐다. 모델 ID, 전처리, 프롬프트 문구, 파싱 규칙, seed, 군집 설정, API 호출 날짜까지가 재현성 표의 한 세트다.

항목이 회차의 값
이미지 임베딩openai/clip-vit-base-patch32, transformers 5.x, 추론만. 전처리: CLIPProcessor 기본(짧은 변 224 리사이즈 · 중심 크롭 · CLIP 평균/표준편차 정규화). 출력 512차원 L2 정규화
텍스트 임베딩@cf/baai/bge-m3 (Cloudflare Workers AI), 1024차원, 배치 100, L2 정규화. 호출일 2026-09-12
비전 언어모델@cf/llava-hf/llava-1.5-7b-hf. 라벨 프롬프트: "This is a satellite image. Classify it into exactly one of: AnnualCrop, …, SeaLake. Answer with the label only." max_tokens 12. 파싱: 답에 등장하는 첫 클래스 이름, 실패 0/300. 캡션 프롬프트: "Describe this product in one sentence." max_tokens 40
제로샷 프롬프트"a satellite photo of {phrase}" / "{phrase}". phrase: annual crop land, forest, herbaceous vegetation, a highway, industrial buildings, pasture, permanent crop land, residential buildings, a river, sea or lake
분류기scikit-learn LogisticRegression(lbfgs, max_iter 2000~3000, C=1 EuroSAT / C=5 Fashion). 베이스라인: 픽셀 198차원 StandardScaler 후 동일 분류기 · TF-IDF (1,2)-gram min_df 2
표본·seedEuroSAT 프로브 클래스당 10·100 표본 seed 0~4 재추출, 전체 1회. Fashion train 3,000 중 2,500 재추출 seed 0~4, test 1,000 고정. llava 층화 300장(클래스당 30, seed 0)
군집·시각화K-means k=10 n_init 10 seed 0~4 (원 임베딩), ARI·NMI. UMAP n_neighbors 15 · min_dist 0.1 · cosine · seed 0 (그림에만)
API 사용량bge-m3 약 86 · resnet-50 11.4 · llava 0 (474회) — 합계 약 100 neurons
LAB · 코드형

⑩ 보고표 생성 셀

tbl = pd.DataFrame({'특징': ['픽셀 8×8 + 로지스틱', 'CLIP 제로샷 · "a satellite photo of {}"', 'CLIP 제로샷 · "{}"', 'llava-1.5 제로샷 (60장)', 'CLIP + 프로브 (10/클래스)', 'CLIP + 프로브 (100/클래스)', 'CLIP + 프로브 (전체)'], '정확도': [acc_pix, (zs['photo'] == y_es).mean(), (zs['name'] == y_es).mean(), (ll == y_es[ll_idx]).mean(), *probe['mean']]}) print(tbl.round(3).to_markdown(index=False)); print(); print(res.round(4).to_markdown()) repro = {'clip': 'openai/clip-vit-base-patch32 (transformers, 추론만)', 'text_embed': '@cf/baai/bge-m3', 'vlm': '@cf/llava-hf/llava-1.5-7b-hf', 'prompts': {'clip': ['a satellite photo of {}', '{}'], 'llava_label': PROMPT, 'llava_caption': 'Describe this product in one sentence.'}, 'preprocess': 'CLIPProcessor 기본(224 리사이즈·중심 크롭·정규화), 임베딩 L2 정규화', 'classifier': 'LogisticRegression lbfgs (C=1 EuroSAT, C=5 Fashion)', '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()} print(json.dumps(repro, ensure_ascii=False, indent=1))
2단계 설계

이미지 → 스칼라 → 회귀, 그리고 측정오차의 전파

케냐 연구는 영상에서 "양철지붕 면적"이라는 스칼라를 먼저 만들고 그 스칼라를 회귀에 넣었다(9장 §3). 이 실습의 벡터도 같은 길을 가야 한다. 512개 숫자를 통째로 넣지 않고, 의미가 정해진 스칼라 하나로 줄여서 넣는다.

스칼라를 만드는 방법은 이 회차에 세 가지가 있다. 제로샷 라벨(예: 이 격자가 Residential 인가), 프로브 확률(라벨 100장으로 학습한 로지스틱의 예측 확률), 개념 점수(이미지 벡터와 "a photo of a metal roof" 문장 벡터의 코사인). 셋 다 오차가 있는 측정치다. 그 오차는 회귀에서 사라지지 않고 계수로 전파된다.

  • 스칼라가 설명변수일 때 — 고전적 측정오차는 계수를 0 쪽으로 끌어내린다(희석). 단, §4 의 오류는 고전적이지 않았다. Industrial 로의 쏠림처럼 오류가 특정 클래스에 체계적으로 몰리면 편향의 방향을 미리 알 수 없다. 오류 분석 표가 부록에 있어야 하는 이유다.
  • 스칼라가 결과변수일 때 — 측정오차가 처치와 무관하면 계수는 편향되지 않고 표준오차만 커진다. 그러나 처치가 촬영 조건이나 사진의 종류를 바꾸면(정책 이후 마을이 더 자주 촬영된다면) 오차가 처치와 상관되어 편향이 생긴다. 9장 §3 의 "처치가 측정 자체를 바꾸는가"가 이 질문이다.
  • 스칼라가 교란요인일 때 — 벡터에 처치 이후의 정보가 섞이면 나쁜 통제가 된다(9장 §4). 사진의 촬영 시점을 처치 이전으로 고정하는 것이 기술적 조치보다 먼저다.
  • 보고 규격 — 스칼라의 검증 정확도(라벨이 있는 소표본에서), 오류의 클래스별 분포, 그리고 오차를 반영한 표준오차(측정 단계의 불확실성을 부트스트랩으로 전파)를 적는다. 검증 소표본 없이 사전학습 모델의 출력을 그대로 변수로 쓴 논문은 "그 변수가 무엇인지 모른다"는 지적을 피할 수 없다.
더 읽기

왜 512차원 벡터를 통째로 회귀에 넣으면 안 되는가. 기술적으로는 넣을 수 있고, 예측이 목적이면 그렇게 한다. 인과 질문에서 문제가 되는 것은 해석이다. 벡터의 계수 512개는 어느 것도 "지붕"이나 "토지이용"에 대응하지 않고, 처치가 그중 무엇을 바꿨는지 말할 수 없으며, 벡터에 결과 정보가 섞여 있는지 검사할 수도 없다. 스칼라로 줄이는 것은 정보를 버리는 대가로 "이 변수가 무엇인가"를 진술 가능하게 만드는 절차다. 케냐 연구가 end-to-end 예측 대신 지붕 면적을 택한 이유와 같다.

이 회차의 숫자로 감을 잡아 보자. 제로샷 라벨을 스칼라로 쓰면 검증 정확도 39%, 클래스당 100장 프로브면 85%. 소표본 라벨 1,000장을 만드는 비용이 스칼라의 오차를 절반 이하로 줄인다. 위성영상 정책평가에서 "지상 검증 소표본"에 예산을 남겨 두는 것은 사치가 아니라 설계의 일부다.

출처 Huang, Hsiang & Gonzalez-Navarro 2021 (측정 → 연결 → 인과의 3단계) · Burke 외 2021, Science 371(6535) (원격 측정 지표의 검증 관행) · 인과추론 강의 9장 · 비정형 데이터와 인과추론

자기 데이터로 바꾸기

이미지 폴더 + CSV 한 장이면 ① 블록만 교체된다

CSV 는 세 열이면 된다. file(이미지 파일명), text(제목·설명, 없으면 빈칸), label(범주, 없으면 빈칸). 라벨이 없으면 제로샷과 군집만 쓰고, 라벨이 조금 있으면 프로브를 쓴다.

LAB · 코드형

⑪ 로더 함수 — 노트북 마지막 셀

def load_my_data(csv_path, img_dir): """CSV 열: file(이미지 파일명), text(제목·설명, 없으면 빈칸), label(범주, 없으면 빈칸)""" my = pd.read_csv(csv_path) imgs = [Image.open(os.path.join(img_dir, f)).convert('RGB') for f in my.file] E_i = clip_image(imgs) # 이미지 → 512 E_t = None if my.text.notna().any(): E_t = np.array(embed(my.text.fillna('').tolist()), np.float32); E_t /= np.linalg.norm(E_t, axis=1, keepdims=True) y = my.label.astype('category').cat.codes.values if 'label' in my else None names = list(my.label.astype('category').cat.categories) if 'label' in my else None return my, imgs, E_i, E_t, y, names # my, my_img, E_img, E_txt, y_fs, TOP = load_my_data('my_data.csv', 'images/') # ← 이 한 줄이 ① 블록 교체 # 제로샷: PHRASE 를 내 라벨 이름으로 바꾸고 ⑤-2 실행 · 회귀에 넣을 스칼라: (E_img @ clip_text(['a photo of a metal roof']).T) 처럼 개념 점수 1개로 축약

조작 안내 — 자기 이미지 20장과 CSV 를 Colab 에 올리고 load_my_data 한 줄로 ① 을 교체한 뒤 ⑤-2(제로샷)부터 돌려 보라. 라벨이 있으면 ⑥, 없으면 ⑦ 로 간다.
관찰 포인트 — 제로샷의 PHRASE 를 내 라벨 이름으로 바꿀 때, 인터넷 캡션에 있을 법한 문장으로 쓰는 것과 전문 용어로 쓰는 것의 정확도 차이가 이 실습의 SeaLake(33% ↔ 94%)만큼 날 수 있다.

노트북03_image_multimodal.ipynb 를 내려받아 Google Colab 에서 파일 → 노트 업로드. 런타임 유형은 T4 GPU. 전체 실행 약 10~15분, API 약 100 neurons.
인과추론 시리즈 연결9장 세 자리 프레임(결과·교란·처치)으로 내 사진의 자리를 먼저 정한다. 실습 4 에서는 이 회차의 스칼라를 DiD·합성통제의 결과변수로 쓰는 절차를 다룬다.

(선택) resnet-50 API — ImageNet 라벨은 상품 카테고리와 얼마나 맞는가

Cloudflare 의 @cf/microsoft/resnet-50 은 ImageNet 1,000 클래스 확률을 돌려준다(0.228 neurons/장). Fashion test 50장(클래스당 5)의 top-1 을 subCategory 와 대조했다.

subCategoryresnet-50 top-1 (5장, 빈도순)평균 확신도
TopwearJersey · Pajama · Windsor Tie0.82
ShoesRunning Shoe · Clog · Loafer0.82
BagsSweatshirt · Shopping Basket · Cleaver0.69
BottomwearVelvet · Windsor Tie · Suit0.47
WatchesMagnetic Compass · Analog Clock · Digital Watch0.76
InnerwearBrassiere · Maillot · Whistle0.84
EyewearSunglass1.00
JewelleryChain · Hair Slide · Nipple0.61
FragranceHair Spray · Nipple · Lotion0.92
SandalSandal0.76

선글라스·샌들·브래지어처럼 ImageNet 에 같은 클래스가 있으면 정확하고, 시계는 "magnetic compass"·"barometer", 향수는 "hair spray", 가방은 "mailbag"·"shopping basket" 으로 간다. 고정 라벨 집합의 분류기는 라벨 집합 밖의 세계를 가장 닮은 라벨로 강제한다. 제로샷(라벨을 내가 정함)과 고정 분류기(라벨이 정해져 있음)의 차이가 여기서 드러난다.

7
이해 점검

퀴즈와 핵심 정리

먼저 스스로 답한 뒤 펼쳐서 확인하라.

이해 점검

퀴즈

CLIP 제로샷 정확도가 38.7% 인데 픽셀 베이스라인은 67.8% 다. 그렇다면 CLIP 은 이 과제에서 쓸모가 없는가?
두 숫자의 라벨 수가 다르다. 베이스라인은 라벨 16,200장을 썼고 제로샷은 0장이다. 같은 라벨 수에서 비교하면 클래스당 10장에서 CLIP 프로브 70.5% 대 픽셀 39.9%, 100장에서 85.0% 대 56.2% 로 CLIP 이 크게 앞선다. 사전학습 모델의 가치는 제로샷 자체가 아니라 라벨 효율에 있다. 라벨 100장이면 픽셀 16,200장을 이긴다.
프롬프트를 "{class}" 에서 "a satellite photo of {class}" 로 바꾸자 SeaLake 정확도가 94% 에서 33% 로 떨어졌다. 평가셋을 보고 더 좋은 프롬프트를 고르면 무엇이 문제인가?
프롬프트는 하이퍼파라미터이고, 평가 데이터를 보며 고르는 순간 그 데이터가 학습에 쓰인 것이다. 보고된 "제로샷" 정확도는 더 이상 라벨 0장의 성능이 아니다. 프롬프트는 별도의 검증 표본(또는 도메인 지식)으로 정하고, 논문에는 시도한 프롬프트 전부와 선택 기준을 적는다. 클래스별 정확도가 이렇게 크게 뒤집힌다는 사실 자체가, 제로샷 라벨을 변수로 쓸 때 오차가 클래스에 따라 체계적으로 다르다는 경고다.
Fashion 에서 결합(99.12 ± 0.08)이 텍스트 단독(98.90 ± 0.19)을 이겼다. "멀티모달 융합이 성능을 높인다"고 논문에 쓸 수 있는가?
그대로 쓰기에는 근거가 약하다. 차이 0.22%p 는 test 1,000건에서 오답 2건 차이이고 표준편차의 한두 배다. 클래스별로 보면 결합의 이득은 Bottomwear·Topwear 에 국한되고 Shoes·Sandal 에서는 텍스트 수준으로 회복한 것뿐이다. 정직한 서술은 "제목이 카테고리를 거의 결정하는 이 과제에서 이미지 추가의 이득은 작고(0.2%p), 텍스트가 애매한 클래스에 국한된다"이다. 융합이 유리한 과제는 텍스트가 없거나 텍스트가 말하지 않는 속성(색·패턴)을 묻는 과제다.
핵심 정리

하나. 사전학습 모델의 가치는 제로샷이 아니라 라벨 효율이다. 라벨 0장의 CLIP 은 픽셀 베이스라인 수준이지만, 라벨 100장을 얹으면 픽셀 16,200장을 이긴다. 라벨 소표본에 예산을 남겨라.

둘. 제로샷의 오류는 잡음이 아니라 구조다. 동의어 클래스는 무너지고, 특정 클래스로 쏠리고, 언어모델은 선택지 순서에 반응한다. 그 오류 구조가 변수의 측정오차이며, 회귀 계수로 전파된다.

셋. 벡터를 통째로 꽂지 말고 의미가 정해진 스칼라로 줄여서 넣는다. 융합은 이어붙이기부터, 군집은 대표 이미지를 눈으로 확인한 뒤에, 그리고 모델 ID·프롬프트·파싱 규칙·seed 를 전부 적는다.

8
데이터 · 모델 · 방법

참고문헌

연도는 출간 기준. 수치는 본문에 적힌 날짜의 실제 실행 결과이며, 모델 성능의 일반 진술은 원 논문 기준이다.

데이터

  1. Helber, P., Bischke, B., Dengel, A., & Borth, D. (2019). EuroSAT: A Novel Dataset and Deep Learning Benchmark for Land Use and Land Cover Classification. IEEE Journal of Selected Topics in Applied Earth Observations and Remote Sensing, 12(7), 2217–2226. arxiv.org/abs/1709.00029 · HF blanchon/EuroSAT_RGB
  2. Aggarwal, P. Fashion Product Images (Small). Kaggle. kaggle.com/datasets/paramaggarwal/fashion-product-images-small · HF 미러 ashraq/fashion-product-images-small

모델

  1. Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., … Sutskever, I. (2021). Learning Transferable Visual Models From Natural Language Supervision. ICML. arxiv.org/abs/2103.00020
  2. Liu, H., Li, C., Wu, Q., & Lee, Y. J. (2023). Visual Instruction Tuning. NeurIPS. arxiv.org/abs/2304.08485 (LLaVA)
  3. 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
  4. Cloudflare. Workers AI 모델 카탈로그 · 가격. developers.cloudflare.com/workers-ai/models · platform/pricing
  5. Hugging Face. transformers 문서, CLIP. huggingface.co/docs/transformers/model_doc/clip

방법 · 응용

  1. McInnes, L., Healy, J., & Melville, J. (2018). UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arxiv.org/abs/1802.03426
  2. Hubert, L., & Arabie, P. (1985). Comparing Partitions. Journal of Classification, 2, 193–218. (Adjusted Rand Index)
  3. Huang, L. Y., Hsiang, S. M., & Gonzalez-Navarro, M. (2021). Using Satellite Imagery and Deep Learning to Evaluate the Impact of Anti-Poverty Programs. NBER Working Paper 29105. nber.org/papers/w29105
  4. Burke, M., Driscoll, A., Lobell, D. B., & Ermon, S. (2021). Using Satellite Imagery to Understand and Promote Sustainable Development. Science, 371(6535), eabe8628. science.org/doi/10.1126/science.abe8628

인용 원칙 성능 수치는 전부 이 자료의 실제 실행값(2026-09-12)이며 원 논문의 벤치마크 수치가 아니다. 모델의 일반적 성질(CLIP 의 EuroSAT 제로샷이 낮은 편이라는 서술 등)은 원 논문 기준이고, 1차 자료 확인이 어려운 서술은 "~로 알려져 있다"로 구분했다.

한국외국어대학교 Global Business & Technology · 대학원 딥러닝 세미나 · 실습 3.
이 자료는 단일 HTML 파일로 배포되며, 실습 위젯은 순수 JavaScript 로 구현되어 오프라인에서도 동작한다. 모든 수치는 본문에 적힌 날짜의 실제 실행 결과다. 색상 규약: 회색 = 베이스라인, 청록 계열 = 사전학습 모델 특징, 붉은색 = 오류·강조. 위젯 안의 10색은 클래스 구분 전용이다.