← Alle berichten

Een classifier is vooral loodgieterswerk

Op deze pagina

Bij Flowstate zitten we te malen over een probleem dat triviaal lijkt tot je het probeert: stuur elk binnenkomend verzoek naar het goedkoopste model dat het echt aankan. Om een prompt te routeren moet je eerst uitzoeken wat hij wil, en prompts komen binnen als pure menselijke chaos. Typfouten. Een dump van 400 regels code met de echte vraag verstopt op de laatste regel. “hey kun je hier ff naar kijken”. Daar moet je een schone intentie uit halen, en dat moet in ruim onder een milliseconde, in-process, op een CPU, voor niks. Een semantisch oordeel in minder geheugen dan een jpeg, terwijl elke blogpost over dit onderwerp zweert dat je een rek vol H100’s nodig hebt.

Voor precies dit bestaat een standaard draaiboek. Stel een gouden dataset samen van 200–500 door mensen gelabelde prompts. Valideer een teacher-LLM ertegen en ga niet verder onder een F1 van 0,90. Destilleer de teacher in een klein student-model: SetFit, een BERT-variant, iets met embeddings. Plot confusion matrices. Micro-benchmark de p99-latency. Draai 48 uur een shadow-deployment voordat je het ook maar één echt verzoek laat aanraken. Het is een mooi draaiboek, geschreven door onderzoekers met oneindig rekenvermogen en zonder productiepager. Het is ook een uitstekende manier om jezelf in een hoek te overengineeren.

De eigenlijke klus was smal: tag elke binnenkomende prompt met een taaktype (parent → child, zoals engineering → fix_bug of data → spreadsheet_edit), zodat de proxy hem ergens zinnig heen stuurt in plaats van alles naar het duurste model op de kaart. Alleen CPU, geen GPU in het hete pad, heel veel verzoeken. Wat een eigen classifier trainen verstandig maakt in plaats van gek, is dat Flowstate al op een berg echte verzoeken zit. Ze zijn alleen ongelabeld. Dat is het ene goede gebruik voor een goedkope teacher-LLM. Niet als portier die een deel van elk verzoek int. Je richt hem één keer op de berg, laat hem alles labelen en gooit hem weg. Gebruik het dure ding precies één keer.

De tolpoortparadox

De voor de hand liggende tegenwerping, waar ik wakker van lag: sla de lokale classifier helemaal over. Roep een goedkoop, snel model aan (Flash, Haiku, wat er onderaan de prijslijst staat), laat dat de prompt taggen en wees voor de lunch klaar. Het zou ook nauwkeuriger zijn dan alles wat ik zelf kan trainen.

Maar dat is precies de val waar de hele architectuur aan moet ontsnappen. Het punt van een prompt taggen voordat hij een model raakt, is minder uitgeven: stuur het makkelijke werk naar iets goedkoops en betaal alleen topprijzen als het echt moet. Als de router zelf bij elk verzoek een betaalde API-call doet, heb je een tolhokje gebouwd voor je tolhokje. Je koopt een kaartje voor de bus om de chauffeur te vragen of het wel de goede bus is. Je hebt de besparing verbrand voordat je hem hebt verdiend, bij elk verzoek een netwerkrondje erbovenop gebouwd en van elke prompt een kopie in andermans logs gezet. Een classifier die in-process draait op CPU die je al hebt, is per verzoek gratis. Niet goedkoop, gratis, voor altijd nadat hij getraind is. Dus een klungelig model van 92% dat niks kost, kan meer waard zijn dan een model van 99% dat je per token factureert. Je wilt hier een snelle, klungelige uitsmijter, geen trage, dure filosoof.

De adder onder het gras is het woord “getraind”. Zo’n model is hongerig. Het wil veel meer gelabelde voorbeelden dan ik ooit voor de lol met de hand zou labelen. Dus de teacher verdient zijn plek precies één keer: label de berg offline, en de classifier draait daarna voor niks.

Voordat ik hierover ging discussiëren, deed ik wat ik meestal doe: ik bouwde een kleine testopstelling en mat.

Eerst een bekentenis

Ik heb de teacher nog niet op de echte berg losgelaten, dus voor deze eerste ronde heb ik een stand-in gegenereerd. Een paar duizend synthetische prompts over achttien taaktypes, met bewust ingebouwde dubbelzinnigheid zodat de classifier iets had om echt fout te doen. Prompts als “kijk naar het kapotte endpoint”, wat eerlijk gezegd fix_bug of debug is en dat zie je niet aan de tekst. Ongeveer 2.800 om op te trainen, 600 om te testen.

Dit is belangrijk, dus ik zeg het hardop: synthetische data, gelabeld door het ding dat ze genereerde, is een doorgestoken kaart. Het vertelt je of de pipeline werkt en hoe de aanpakken zich tot elkaar verhouden. Het vertelt je niet hoe nauwkeurig het in de echte wereld is. En het flatteert eenvoudige modellen, want sjabloontekst laat lexicale vingerafdrukken achter die een bag of words moeiteloos opzuigt. Hou dat in je achterhoofd. Ik kom erop terug.

Met dat op de deur gespijkerd, nu de cijfers.

Het model is de saaie keuze

Vijf aanpakken, van goedkoopst tot fanciest. Een gewone TF-IDF bag of words naar logistische regressie. Hetzelfde met character n-grams en een SVM. Een hashing vectorizer naar een SGD-classifier. En degene die het draaiboek echt van je wil: sentence embeddings (een gekwantiseerde BGE-transformer) naar een lineaire kop.

AanpakNauwkeurigheid leafNauwkeurigheid parentp95 (1 verzoek)p95 (onder load)Grootte
TF-IDF + logreg0,9231,0000,18 ms0,17 ms336 KB
TF-IDF + char + SVM0,9261,0001,4 ms29,6 ms3,2 MB
Hashing (2²⁰) + SGD0,9261,00020,7 ms42,4 ms147 MB
Hashing (2¹⁸) + SGD0,9241,0004,1 ms6,8 ms37 MB
Embeddings + logreg0,9180,9976,0 ms20,8 ms131 MB

De nauwkeurigheidskolom is de saaie. Elke aanpak zit binnen een procentpunt van de rest, allemaal rond de 92%. De transformer van 131MB, degene waarvoor je een designdoc zou schrijven om hem te verantwoorden, kwam als laatste binnen en was de enige die het grove parent-label niet perfect kreeg. De bag of words van 336KB, een idee dat ouder is dan de meeste frameworks in je package.json, evenaarde hem en leverde in een derde van een milliseconde.

Latency is de kolom die niet vlak is. Twee ordes van grootte tussen boven en onder. De enige as waarop deze aanpakken echt verschillen is de operationele, en daar wint de domste optie op alle fronten.

De parent-kolom verbergt een stiller resultaat: een vlakke 1,000 voor bijna iedereen. Alle verwarring zit binnen een domein: viz aangezien voor data_analysis, market_research voor web_research. Niets verwart ooit een spreadsheet met een bugmelding. Als de proxy alleen het grove domein nodig heeft, had het goedkoopste ding op de lijst het al opgelost en kon ik naar huis.

Dus als het model nauwelijks uitmaakt, wat dan wel?

Het loodgieterswerk

Drie dingen kostten me veel meer aandacht dan het model ooit deed, en geen ervan staat in een gids.

Footgun één: de hashing vectorizer vreet ongemerkt 147 megabyte

Het hashingmodel met 2²⁰ features haalde 20,7ms en 147MB resident geheugen, voor een taak met achttien klassen. Dat is een kwart seconde latency en een klein videobestand aan RAM om te bepalen of iemand “fix this” of “why is this broken” typte.

De oorzaak is saai en volledig zelf aangericht: een featureruimte van 2²⁰ keer achttien klassen is een dichte coëfficiëntmatrix zo groot als een vakantiefotoalbum, en elke voorspelling sleept zijn hand over het geheel. Zet de hash terug naar 2¹⁸ en het wordt vijf keer sneller en vier keer kleiner bij dezelfde nauwkeurigheid. De standaardwaarde is de footgun, en niemand waarschuwt je dat hij geladen is.

Footgun twee: de “op mijn machine werkt het”-luchtspiegeling

De character-n-gram-SVM zag er in isolatie prachtig uit: 1,4ms per verzoek, op een haar na de beste nauwkeurigheid van het bord. Ik had hem bijna gecommit en was koffie gaan halen. Toen richtte ik acht gelijktijdige workers erop en zijn p95 stortte in naar 29,6 milliseconden. Een twintigvoudige instorting zodra hij gezelschap kreeg.

Twee redenen, beide onzichtbaar voor een benchmark met één verzoek. Om kansen uit een SVM te halen kalibreer je hem, en dat traint en draait ongemerkt drie submodellen per voorspelling. En al dat extra CPU-werk per verzoek stapelt zich recht op Pythons globale lock, dus verzoeken sluiten netjes aan in de rij in het brandende gebouw in plaats van er voorbij te vliegen. De bag of words, die per verzoek bijna niets doet, merkte de load helemaal niet. 0,17ms onder acht workers, hetzelfde als alleen draaien. De mediaan winnen is leuk. Je hoofd koel houden als alles in brand staat is waar je om 3 uur ‘s nachts om geeft.

De lijm: truncatie is het verschil tussen 17% en 83%

Hier voelde ik me een idioot bij. Ik gooide een stresstest naar het winnende model: een Java-klont van 400 regels met één cruciale instructie op de laatste regel geplakt, “now fix the bug that makes the total wrong”. De echte vorm van een prompt in een codetool.

Het scoorde 16,7%. Het verloor zijn verstand en labelde bijna alles refactor, want voor een bag of words is een muur van code een refactor, en de ene menselijke instructie onderaan verdronk in de ruis.

De oplossing was geen fancier model of embeddingmatrix. Het waren vier regels die alles weggooien behalve de laatste 200 tekens en alleen die classificeren.

Van zestien procent naar drieëntachtig, alleen door te veranderen wat het model mocht zien in plaats van welk model het was. (Alleen het einde wint als de instructie achteraan staat. Begin plus einde is de veiliger algemene gok, voor het geval de vraag bovenaan staat.) Het model was al die tijd prima. Het loodgieterswerk niet.

De andere lijm: leren zeggen “ik weet het niet”

Een tagger die zelfverzekerd fout labelt is erger dan een die zich onthoudt. Gelukkig wist het model zelf al wanneer het gokte. Zijn foute antwoorden op dubbelzinnige prompts van één woord (“debug”, ”?”) kwamen met bodemlage zekerheid. Dus ik liet een zekerheidsdrempel lopen: eronder routeer je naar een vangnet in plaats van te gokken.

Het is een nette afweging. Hou de drempel rond 0,7 en je beantwoordt nog steeds 90% van de prompts, de nauwkeurigheid op de beantwoorde stijgt naar 95,5%, en je vangt 90% van de echt buiten-scope-rommel. Waar je hem zet is een productkeuze (hoe vaak je bereid bent te schouderophalen tegenover hoe vaak je bereid bent fout te zitten) en, nogmaals, niets met het model te maken.

Waar dit is doorgestoken, en waar niet

Terug naar de bekentenis. Een scherpe lezer zit al te typen: je data is synthetisch en lexicaal opgeruimd, dus natuurlijk won de bag of words. Embeddings verdienen hun geld op rommelige echte prompts, die je nooit hebt getest. Die lezer heeft gelijk, en ik zou hun weddenschap aannemen. Op echt verkeer, met typfouten, halve gedachten, drie talen in één zin, dezelfde intentie op honderd manieren geformuleerd, verwacht ik volledig dat embeddings vooruit komen. De modelvergelijking in het bijzonder is het deel van dit verhaal dat je het minst moet vertrouwen.

De eerlijke test ligt daar voor het grijpen: richt de teacher op de Flowstate-berg, label hem één keer en draai de vergelijking opnieuw op echte prompts. Dan weet je of die 92% de data was die aardig deed of de aanpak die degelijk is. Als ik eraan toekom, is dat een eigen post.

Het loodgieterswerk geeft niet om welke data je erin stopt. De hashing vectorizer vreet sowieso 147MB. De gekalibreerde SVM stort in onder gelijktijdigheid bij elke input. Truncatie bepaalt of het model de instructie ooit ziet. De zekerheidsdrempel is een eigenschap van de deployment, niet van de dataset. Die bevindingen overleven de kanttekening intact, en ze waren het meeste werk.

Wat ik echt heb geleerd

  • Meet voordat je architectuur bedenkt. Ik had een week kunnen besteden aan ruziën over SetFit tegenover BERT. Een middag met een script vertelde me dat het model de minst interessante variabele in het systeem was.
  • Benchmarks met één verzoek liegen. De SVM zag er alleen het best uit en onder load het slechtst. Als je proxy gelijktijdig verkeer bedient, benchmark dan gelijktijdig verkeer.
  • Defaults zijn footguns. Een hashruimte van 2²⁰ voor achttien klassen is 147MB aan niets. Weet wat de knop doet voordat je hem laat staan waar je hem vond.
  • Truncatie is een model. Kiezen wat je de classifier laat zien verschoof de nauwkeurigheid meer dan welke architectuurkeuze ook: van 17% naar 83% met vier regels.
  • Laat hem zich onthouden. Een zekerheidsdrempel maakt van “soms zelfverzekerd fout” iets als “meestal goed, af en toe eerlijk”. Dat is een knop die het waard is om te hebben.
  • De saaie baseline is het ding om te verslaan, niet om over te slaan. Begin met 336KB en een stalen gezicht. Grijp naar de transformer van 131MB als je echte data hebt die bewijst dat het nodig is, en geen commit eerder.

Het fancy draaiboek had niet echt ongelijk. Het optimaliseerde alleen de ene beslissing die er niet toe deed, en zweeg over de vier die dat wel deden. De hele branche verkoopt je een H100-cluster om een prompt te lezen. Wat echt verschil maakte waren vier regels string slicing. Het grootste deel van een classifier is loodgieterswerk, en loodgieterswerk staat slecht op de foto, en daarom schrijft waarschijnlijk niemand er een gids voor.

De hele opstelling is zo’n 600 regels Python. Het is werkcode, dus die zet ik niet online, maar alles wat ertoe doet staat in deze post: de vijf aanpakken, de concurrency-test, de truncatiefix, de drempelsweep. Gezien de kanttekening zijn gaten precies wat ik zoek, dus richt op de methode.