Ik liet Gemini zijn eigen vervanger trainen voor $9
Ik hou van koken, en ergens onderweg is dat uitgegroeid tot een obsessie voor high-end koksmessen. Daarom scrape ik Reddit-threads waarin mensen hierover discussiëren; ik haal elk merk, model en type staal eruit om te zien wat er wordt gekocht en waarover wordt gedebatteerd.
Het herkennen van productnamen in tekst is een taak die named-entity recognition (NER) wordt genoemd, iets wat kleine modellen al tien jaar doen. Ik deed dit aanvankelijk met Gemini 3.1 Pro, met één betaalde API-call per reactie. Dat was overkill, maar het werkte: van een zin als "picked up a Mazaki in white #2, way better than my old Fibrox" retourneerde het Mazaki als merk, Fibrox als model en white #2 als staal, en niets anders. Maar omdat de scraper elke nieuwe reactie ophaalt, groeide de rekening mee met de hoeveelheid posts. De enige manier om dit te beperken was door reacties over te slaan.
De voor de hand liggende vervanger was GLiNER, een open NER-model dat zero-shot wordt uitgevoerd. Dit bracht de kosten naar nul, maar de nauwkeurigheid zakte naar een F1-score van ongeveer 0,65 ten opzichte van de antwoorden van Gemini. Het gat tussen die twee is waar dit artikel over gaat: kon Gemini 4.290 reacties eenmalig labelen en GLiNER zo leren om dat gat te dichten?
Samenvatting van het project
- Wat: Het fine-tunen van GLiNER large v2.5 (459M) om merken, modellen en materialen in Reddit-reacties te markeren, op basis van labels die eenmalig door Gemini 3.1 Pro zijn geschreven.
- Waarom: Zero-shot GLiNER scoorde ongeveer 0,65 F1 (geschat). Gemini scoorde goed, maar factureerde elke reactie zolang de scraper draaide.
- Aanpak: Gemini vragen om strings in plaats van offsets. Offsets berekenen in de code. Reacties zonder producten toevoegen als negatieve voorbeelden. Een validatieset van 225 reacties vastleggen vóór de tweede run.
- Problemen: Vijf van de tien runs leverden geen bruikbaar model op. Drie faalden door configuratie. Twee faalden door een tensor genaamd
words_mask, die ik invulde zoals men een attention mask invult. - Resultaat: 0,83 F1 ten opzichte van de labels van Gemini na 24 minuten op een Tesla T4. $9 aan labels, ongeveer $2,50 aan GPU-tijd, en dagen aan debugging.
Wat ik wilde bereiken
Het plan bestond uit drie stappen. Ten eerste Gemini een paar duizend Reddit-reacties laten labelen, waarbij elk merk, model en staal werd gemarkeerd. Daarna GLiNER trainen op deze labels. Ten slotte GLiNER op mijn eigen machine draaien voor elke volgende reactie, om zo te stoppen met het aanroepen van Gemini.
Gemini labelde 4.290 reacties voor $9, oftewel $0,0021 per reactie. Dit betekent dat het getrainde model zichzelf terugverdient bij ongeveer reactie 4.291, mits latere reacties ongeveer dezelfde lengte hebben en het model draait op een GPU die ik al bezit. De test voor succes was simpel: hoe vaak markeert het model dezelfde woorden als Gemini in 225 reacties die het model nog nooit had gezien?
Een belangrijke kanttekening bij elke score in dit artikel: niemand heeft de labels van Gemini handmatig gecontroleerd. Het model wordt dus beoordeeld ten opzichte van Gemini, niet ten opzichte van de absolute waarheid. Waar Gemini een fout maakte, wordt het model als "correct" gemarkeerd als het de fout kopieert, en als "fout" als het de fout herstelt.
De aanpak
Gemini labelde de reacties via OpenRouter met een temperatuur van 0 in 25 minuten. De belangrijkste beslissing in de prompt was om het model nooit te vragen naar karakter-offsets. LLM's zijn slecht in het tellen van karakters en retourneren vaak spans die twee of drie posities verschoven zijn. De prompt vraagt om de exacte substring en een label, waarna TypeScript de offsets berekent. Als de string niet in de reactie voorkomt, wordt de entiteit verwijderd en gelogd.
De model retourneert strings. De code berekent de offsets:
{
"entities": [
{ "text": "Benchmade", "label": "knife brand" },
{ "text": "940", "label": "knife model" },
{ "text": "S30V", "label": "knife steel" }
]
}
Productnamen bevatten vaak leestekens waar een generieke tokenizer op splitst. Daarom houdt een regex combinaties als VG-10, CPM-154 en 1.4116 heel, terwijl elk ander niet-spatiekarakter als een eigen token wordt uitgezonden. Spans die nog steeds een token-boundary missen, worden weggegooid in plaats van gegist. Ongeveer 30% van de trainingsset bestaat uit reacties die een bekende false-positive trigger bevatten (zoals gyuto, carbon, handle, patina) maar geen product, en zijn als leeg gelabeld.
Vóór de tweede run heb ik 225 reacties apart gezet als validatieset en deze daarna nooit meer aangeraakt. De training draaide op een Tesla T4 via Modal met de HF Trainer:
perdevicetrainbatchsize = 2gradientaccumulationsteps = 8learning_rate = 1e-5threshold = 0.45
Waarop het model is getraind
- Totaal: 4.290 forumreacties geannoteerd door een LLM voor $9.
- 1.575 positief (69,6%)
- 675 negatief
- Trainingsvoorbeelden: 2.250
- 2.029 train
- 225 val (validatieset vastgelegd vanaf run 10)
- Entiteit-spans: 3.907
- Merk (brand): 1.720
- Productmodel (product model): 1.345
- Materiaalspecificatie (material spec): 842
Ongeveer 30% van de voorbeelden bevatten bewust helemaal geen entiteiten.
Wat er misging
In vijf runs leerde het model niets. De eerste drie faalden door configuratiefouten, waar iedereen die de HF Trainer met GLiNER gebruikt waarschijnlijk tegenaan loopt.
| Run | Wat ging er mis | Oplossing |
|---|---|---|
| 1 | maxsteps=10000 van GLiNER overschreef numtrain_epochs=3; trainde 39 epochs | max_steps expliciet instellen |
| 2 | loadbestmodelatend zonder eval_strategy veroorzaakt crash | eval_strategy="steps" instellen |
| 3 | Trainer bewaarde state-dict keys zonder het "model." prefix dat GLiNER verwacht | Prefix terugzetten bij opslaan |
| 4 | ner_labels ontbraken bij negatieve voorbeelden | Label-lijst instellen op elk voorbeeld |
| 4–5 | words_mask gebouwd als binair; loss bleef vlak tussen 70–130 | Incrementele woordindices verzenden |
Runs 4 en 5 waren de kostbare runs. GLiNER's tokenizeinputs crashte op kapotte Reddit-emoji's, dus ik had een patch toegepast. Deze patch moet een tensor invullen genaamd wordsmask. Deze staat naast de attention_mask en heeft dezelfde vorm. Elke attention mask die ik ooit heb gebouwd, bestond uit enen voor echte tokens en nullen voor padding. Ik heb het op die manier gebouwd. Niets aan de naam of de vorm suggereerde dat het iets anders was dan een attention mask.
De training liep tot voltooiing. De loss begon rond de 130, zakte naar ongeveer 70 en bleef daar. Geen crash, geen waarschuwing, geen NaN, gradiënten van normale grootte, checkpoints werden volgens schema opgeslagen, maar de eval F1 was bijna nul. Ik gaf eerst de label-lijst de schuld, omdat run 4 ook negatieve voorbeelden had zonder labels. Dat oplossen en opnieuw draaien gaf dezelfde vlakke loss. Het enige dat fout was in run 5 was de words_mask, een tensor waar ik nooit naar had gekeken.
De tien trainingsruns
De eerste vijf runs produceerden geen bruikbaar model — alleen loodgieterswerk, geen modellering.
- R1: Gefaald (step cap)
- R2: Gefaald (config)
- R3: Gefaald (checkpoint)
- R4: Gefaald (word mask)
- R5: Gefaald (word mask)
- R6: Eerste succes (0.800 F1)
- R7: Getuned 209M model (0.879 F1)
- R8: 459M model (0.799 F1)
- R9: Te veel negatieve voorbeelden (0.832 F1)
- R10: Vastgelegde validatieset (In productie)
De stippellijn in de resultaten volgt de algemene F1-score na de fix van de word-mask.
Hoe ik het oploste
Ik las de trainingsloop van GLiNER in plaats van de docstrings. words_mask is geen mask. Het is een woordindex: 0 voor speciale tokens, prompts en padding-tokens, en vervolgens 1, 2, 3 voor het eerste sub-token van elk echt woord. De span-scoring head gebruikt dit om sub-tokens weer samen te voegen tot woorden. Wanneer dit gevuld is met enen, zegt het dat de hele reactie één enkel woord is. Het model wordt dus gevraagd om merk- en materiaalspans te vinden binnen één enorm token. Dat kan het niet, waardoor de loss vlak blijft. Een vlakke loss laat echter niet zien welke input fout is.
Wat ik schreef vs. wat GLiNER verwacht: wordsmask = [1,1,1,1,1] → wordsmask = [0,1,2,2,3] [CLS] Mazaki wh ##ite #2
Met de index gefixt, leerde run 6 direct. De rest was tuning tegen de vastgelegde set. Het 209M medium model bereikte 0,800; het 459M large model, dat alleen op een T4 past met gradient accumulation, bereikte 0,83. Tien keer zoveel adversariële negatieve voorbeelden (510 in plaats van 51) verlaagde de F1 naar 0,799, dus run 10 ging terug naar 51.
Het gebruik van een drempelwaarde (threshold) per klasse in plaats van één globale cutoff verhoogde de recall voor materialen van 0,787 naar 0,911. Dit kwam omdat staalnamen zoals MagnaCut, S35VN en HAP40 een lagere betrouwbaarheidsscore hebben dan merken; een enkele cutoff filterde ze weg. Elke large run bereikt een dieptepunt bij epoch 2 en overfit daarna; bij 2.000 voorbeelden is dat een probleem van datasetgrootte, en early stopping is de oplossing.
F1-score per entiteitsklasse
Zero-shot baseline vs. fine-tuned checkpoints, op dezelfde hold-out data.
| Klasse | Zero-shot (est.) | Fine-tuned 209M | Fine-tuned 459M |
|---|---|---|---|
| Totaal | ~0.65 | 0.800 | 0.879 |
| Merk | n/a | 0.775 | 0.858 |
| Product | n/a | 0.712 | 0.829 |
| Spec | n/a | 0.800 | 0.904 |
Een grotere encoder helpt het meest daar waar de woordenschat puur domeinspecifiek is.
Wat ik heb geleerd
Het heeft gewerkt. Het model draait lokaal, matcht de labels van Gemini met een 0,83 F1 op reacties die het nooit heeft gezien, en weet dat "carbon steel" een categorie is en geen specifiek staal, en dat "PM2" soms naar de Spyderco Paramilitary 2 verwijst en soms gewoon letters zijn. Een eerdere run scoorde 0,879 op een willekeurige split, maar ik reken daar niet mee: bij willekeurige splits bleek dat dalingen in F1 die ik toeschreef aan mijn wijzigingen, eigenlijk kwamen door welke reacties in de validatieset terechtkwamen.
Op papier kostte het project minder dan een lunch: $9 aan labels, $2,50 aan GPU. Wat het me echt kostte, waren de dagen besteed aan een tensor die elke code-check passeerde maar toch fout was. Ik denk dat verloren dagen de norm zijn voor kleine fine-tuning jobs. Het model en de data zijn zelden het probleem; de code daartussenin faalt. Een vlakke loss door een foutieve input-tensor ziet er hetzelfde uit als een vlakke loss door moeilijke data. Als ik moest kiezen tussen een betere labelset en een assertion op elke tensor die ik handmatig bouw, zou ik voor de assertion kiezen.
Dit model drijft New Knife Day aan, een site die bijhoudt welke messen Reddit-gebruikers kopen en waarover discussiëren.
In één oogopslag
- Probleem: Merk-, model- en materiaalnamen vinden in Reddit-reacties, zonder per reactie aan een LLM te betalen.
- Aanpak: Gemini eenmalig de reacties laten labelen, vervolgens GLiNER large v2.5 (een DeBERTa-v3-large encoder) fine-tunen op die labels en dit lokaal draaien.
- Resultaat: 0,83 F1 op een vooraf vastgelegde validatieset van 225 reacties; materiaal-recall van 0,911 met een threshold per klasse.
- Kosten: $9 aan Gemini-labels plus ongeveer $2,50 aan T4-tijd over tien runs.
- Stack: TypeScript en MongoDB voor de scraper en labels, Python en PyTorch voor training op Modal, FastAPI om het model te serveren.
Groetjes,