메뉴
HN
Hacker News • 8일 전

Gemini에게 자기 대체 모델을 9달러에 학습시켰다

IMP
5/10
핵심 요약

한 개발자가 Gemini 3.1 Pro로 Reddit 요리칼 댓글 4,290개에 개체명(NER) 레이블을 한 번만 붙여 오픈소스 모델 GLiNER를 파인튜닝한 뒤, 이후 처리는 자체 GPU로 무료화했습니다. 결과적으로 9달러의 레이블 비용과 약 2.5달러의 GPU 비용으로 0.83 F1을 달성해 진행 중인 API 비용을 사실상 제거했습니다. 거대 모델의 출력을 증류(distillation)해 소형 모델로 대체하는 실용적이고 저렴한 접근법의 좋은 사례입니다.

번역된 본문

저는 요리를 좋아하는데, 어느새 고급 요리사 나이프에 대한 집착으로 발전했습니다. 그래서 사람들이 나이프에 대해 토론하는 Reddit 스레드를 스크래핑해서 언급되는 모든 브랜드, 모델, 강종(스틸)을 추출해 어떤 제품이 구매되고 논쟁되는지 확인합니다. 텍스트에서 제품명을 뽑아내는 작업은 개체명 인식(named-entity recognition, NER)이라고 불리며, 소형 모델이 10년째 해오던 일입니다. 저는 이를 Gemini 3.1 Pro로 처리했는데, 댓글 하나당 유료 API 호출이 한 번씩 발생했습니다. 과한 일이었지만 효과는 있었습니다. "화이트 2호강 마자키를 샀는데, 예전 피브록스보다 훨씬 낫다"라는 댓글에서 마자키를 브랜드로, 피브록스를 모델로, 화이트 2호를 강종으로 반환하고 그 외에는 아무것도 반환하지 않았습니다. 하지만 스크래퍼는 모든 새 댓글을 수집하므로, 사람들이 많이 쓸수록 청구액이 늘었고 비용을 제한하려면 댓글을 건너뛰는 수밖에 없었습니다. 당연한 대체제인 GLiNER라는 오픈소스 NER 모델을 제로샷으로 돌리니 비용은 0이 되었지만 정확도는 Gemini의 답 대비 약 0.65 F1으로 떨어졌습니다. 그 격차가 이 글의 나머지 주제입니다. Gemini가 4,290개 댓글에 한 번 레이블을 붙여 GLiNER에게 그 격차를 좁히도록 가르칠 수 있을까요?

무엇을: Reddit 댓글에서 브랜드, 모델, 재질을 태깅하도록 GLiNER large v2.5(459M)를 파인튜닝했으며, 레이블은 Gemini 3.1 Pro가 한 번 생성한 것을 사용했습니다. 왜: 제로샷 GLiNER는 약 0.65 F1(추정치)에 그쳤고, Gemini는 정확하지만 스크래퍼가 돌아가는 한 댓글마다 계속 과금됐습니다. 접근법: 모델에게 오프셋이 아닌 문자열을 요청하고 오프셋은 코드로 계산했습니다. 제품이 없는 댓글을 네거티브 샘플로 추가했습니다. 두 번째 실행 전에 225개 댓글 검증 세트를 고정했습니다. 문제점: 10번의 실행 중 5번이 사용 가능한 모델을 산출하지 못했습니다. 3번은 설정 오류로 실패했고, 2번은 어텐션 마스크 채우듯 채웠던 words_mask라는 텐서에서 실패했습니다. 결과: Tesla T4에서 24분 만에 Gemini 레이블 대비 0.83 F1을 달성했습니다. 레이블 비용 9달러, GPU 비용 약 2.5달러, 그리고 며칠간의 디버깅이 들었습니다.

목표했던 것 계획은 세 단계였습니다. Gemini가 수천 개의 Reddit 댓글에 한 번 레이블을 붙여 모든 브랜드, 모델, 강종을 표시하게 합니다. 그 레이블로 GLiNER를 학습시킵니다. 그 다음부터는 모든 댓글을 제 컴퓨터에서 GLiNER로 처리하고 Gemini 호출을 중단합니다. Gemini는 4,290개 댓글에 9달러, 즉 댓글당 0.0021달러를 청구했습니다. 이는 이후 댓글들이 비슷한 길이이고 이미 소유한 GPU에서 돌린다는 조건하에, 대략 4,291번째 댓글부터 학습된 모델이 본전을 뽑는다는 뜻입니다. 성공 판정 기준은 단순했습니다. 모델이 본 적 없는 225개 댓글에서 Gemini가 태그한 단어와 얼마나 자주 일치하는가입니다. 이 글의 모든 점수에 대해 유의할 점이 하나 있습니다. Gemini의 레이블을 사람이 직접 검수하지 않았으므로, 모델은 진실이 아닌 Gemini를 기준으로 채점됩니다. Gemini가 틀린 곳에서는 모델이 그 실수를 복사하면 정답으로, 고치면 오답으로 표시됩니다.

접근 방법 Gemini는 OpenRouter를 통해 temperature 0으로 25분 만에 댓글에 레이블을 붙였습니다. 가장 중요했던 프롬프트 설계 결정은 모델에게 문자 오프셋을 절대 요청하지 않는 것이었습니다. 모델은 문자 수를 잘못 세고 2~3칸 벗어난 스팬을 반환하기 때문입니다. 프롬프트는 정확한 부분 문자열과 레이블을 요청하고, TypeScript가 오프셋을 찾습니다. 문자열이 댓글에 없으면 해당 개체는 버려지고 로그에 기록됩니다.

// 모델은 문자열을 반환하고, 코드가 오프셋을 계산합니다. { "entities": [ { "text": "Benchmade", "label": "knife brand" }, { "text": "940", "label": "knife model" }, { "text": "S30V", "label": "knife steel" } ] }

제품명에는 일반 토크나이저가 분해해버리는 문장부호가 많아서, 정규식으로 VG-10, CPM-154, 1.4116 같은 것들은 하나로 유지하고 나머지 비공백 문자는 각각 개별 토큰으로 내보냈습니다. 여전히 토큰 경계를 놓치는 스팬은 추측하지 않고 버렸습니다. 훈련 세트의 약 30%는 알려진 오탐 유발 단어(gyuto, carbon, handle, patina)를 포함하지만 제품이 없는, 빈 레이블로 표시된 댓글입니다. 두 번째 실행 전에 225개 댓글을 검증 세트로 따로 빼놓고 다시는 건드리지 않았습니다. 학습은 Modal의 Tesla T4에서 HF Trainer로 진행했습니다.

per_device_train_batch_size = 2 gradient_accumulation_steps = 8 learning_rate = 1e-5 threshold = 0.45

무엇이 잘못되었나 10번 중 5번의 실행이 (본문이 여기서 잘립니다)

원문 보기
원문 보기 (영어)
I like to cook, and somewhere along the way that turned into an obsession with high-end chef's knives. So I scrape the Reddit threads where people argue about them and pull out every brand, model and steel they mention, to see what is getting bought and argued about. Picking product names out of text is a job called named-entity recognition, and small models have done it for a decade. I was doing it with Gemini 3.1 Pro, one paid API call per comment. Overkill, but it worked: from "picked up a Mazaki in white #2, way better than my old Fibrox" it returned Mazaki as a brand, Fibrox as a model and white #2 as a steel, and nothing else. But the scraper pulls every new comment, so the bill grew with how much people posted, and the only way to cap it was to skip comments. The obvious replacement, an open NER model called GLiNER run zero-shot, cut the cost to nothing and the accuracy to about 0.65 F1 against Gemini's answers. That gap is what the rest of this is about: could Gemini label 4,290 comments once and teach GLiNER to close it? What : Fine-tuned GLiNER large v2.5 (459M) to tag brands, models and materials in Reddit comments, on labels Gemini 3.1 Pro wrote once. Why : Zero-shot GLiNER scored about 0.65 F1 (est.). Gemini scored well and billed every comment for as long as the scraper ran. Approach : Ask Gemini for strings, not offsets. Compute offsets in code. Add comments with no products in them as negatives. Lock a 225-comment validation set before the second run. Problems : Five of ten runs produced no usable model. Three failed on configuration. Two failed on a tensor called words_mask that I filled the way you fill an attention mask. Result : 0.83 F1 against Gemini's labels after 24 minutes on a Tesla T4. $9 of labels, about $2.50 of GPU time, and days of debugging. What I set out to do The plan had three steps. Have Gemini label a few thousand Reddit comments once, marking every brand, model and steel. Train GLiNER on those labels. Then run GLiNER on my own machine for every comment after that, and stop calling Gemini. Gemini labeled 4,290 comments for $9, or $0.0021 a comment. That means the trained model pays for itself at roughly comment 4,291, as long as later comments are about the same length and it runs on a GPU I already own. The test of success was simple: on 225 comments the model had never seen, how often does it tag the same words Gemini tagged? One catch to keep in mind for every score in this article. Nobody checked Gemini's labels by hand, so the model is graded against Gemini, not against the truth. Where Gemini was wrong, the model gets marked right for copying the mistake and wrong for fixing it. The approach Gemini labeled the comments through OpenRouter at temperature 0 in 25 minutes. The prompt decision that mattered most was to never ask the model for character offsets. It counts characters badly and returns spans off by two or three positions. The prompt asks for the exact substring and a label, and TypeScript finds the offsets. If the string is not in the comment, the entity is dropped and logged. // The model returns strings. Code computes the offsets. { "entities": [ { "text": "Benchmade", "label": "knife brand" }, { "text": "940", "label": "knife model" }, { "text": "S30V", "label": "knife steel" } ] } Product names are full of punctuation a generic tokenizer splits, so a regex keeps VG-10, CPM-154 and 1.4116 whole and emits every other non-space character as its own token. Spans that still miss a token boundary are dropped rather than guessed. About 30% of the training set is comments that contain a known false-positive trigger (gyuto, carbon, handle, patina) and no product, labeled as empty. Before the second run I set aside 225 comments as a validation set and never touched them again. Training ran on a Tesla T4 on Modal with the HF Trainer. per_device_train_batch_size = 2 gradient_accumulation_steps = 8 learning_rate = 1e-5 threshold = 0.45 What went wrong For five runs the model learned nothing. The first three failed on configuration, and anyone using the HF Trainer with GLiNER will hit them in an afternoon. Run What went wrong Fix 1 GLiNER's default max_steps=10000 overrode num_train_epochs=3; trained 39 epochs Set max_steps explicitly 2 load_best_model_at_end without eval_strategy throws Set eval_strategy="steps" 3 Trainer saved state-dict keys without the "model." prefix GLiNER's loader expects Put the prefix back on save 4 ner_labels missing on negative examples Set the label list on every example 4–5 words_mask built as binary; loss flat at 70–130 Emit incremental word indices Runs 4 and 5 were the expensive ones. GLiNER's tokenize_inputs crashed on broken Reddit emoji, so I had patched it, and the patch has to fill a tensor called words_mask. It sits next to attention_mask, has the same shape, and every attention mask I have ever built is ones for real tokens and zeros for padding. I built it that way. Nothing about the name or the shape says it is anything other than an attention mask. Training ran to completion. Loss started around 130, drifted to about 70 and stayed there. No crash, no warning, no NaN, gradients of ordinary size, checkpoints saved on schedule, eval F1 near zero. I blamed the label list first, because run 4 also had negatives with no labels set. Fixing that and rerunning gave the same flat loss. The only thing wrong with run 5 was words_mask, a tensor I had never looked at. How I fixed it I read GLiNER's training loop instead of its docstrings. words_mask is not a mask. It is a word index: 0 for special, prompt and padding tokens, then 1, 2, 3 for the first sub-token of each real word. The span-scoring head uses it to pool sub-tokens back into words. Filled with ones it says the whole comment is a single word, so the model is asked to find brand and material spans inside one enormous token. It cannot, so the loss stays flat, and a flat loss does not show which input is wrong. # what I wrote # what GLiNER expects words_mask = [1,1,1,1,1] words_mask = [0,1,2,2,3] # [CLS] Mazaki wh ##ite #2 With the index fixed, run 6 learned on the first try. The rest was tuning against the locked set. The 209M medium model reached 0.800; the 459M large model, which fits a T4 only with gradient accumulation, reached 0.83. Ten times more adversarial negatives (510 instead of 51) dropped F1 to 0.799, so run 10 went back to 51. One threshold per class instead of a global cutoff took material recall from 0.787 to 0.911, because steel names like MagnaCut, S35VN and HAP40 score lower confidence than brands and a single cutoff dropped them. Every large run bottoms out at epoch 2 and overfits after; with 2,000 examples that is a dataset-size problem, and early stopping is the fix. What I learned It worked. The model runs locally, matches Gemini's labels at 0.83 F1 on comments it never saw, and knows that "carbon steel" is a category rather than a steel and that PM2 sometimes means the Spyderco Paramilitary 2 and sometimes is just letters. One earlier run scored 0.879 on a random split. I do not count it as the result: on random splits, two drops in F1 that I had blamed on my changes turned out to come from which comments landed in the validation set. On paper the project cost less than lunch: $9 of labels, $2.50 of GPU. What it cost me was the days spent on a tensor that passed every check the code had and was still wrong. I think the lost days are the normal case for small fine-tuning jobs. The model and the data are rarely the problem; the code between them fails, and a flat loss from a wrong input tensor looks the same as a flat loss from hard data. If I had to choose between a better label set and an assertion on every tensor I hand-build, I would take the assertion. This model powers New Knife Day, which tracks what knife people on Reddit are buying and arguing about. The knife-side write-up there has the full run log. Both are linked below. At a glance Problem