← petervijeh.com

Fine-tuned NER model for brand detection on Reddit content

Named-entity recognition for brands and products in community forum text, where the language is misspelled, abbreviated, sarcastic, and nothing like the news articles the off-the-shelf models were trained on.

Problem
Brand / product / spec extraction from user-generated forum text
Approach
LLM-generated silver labels + fine-tuned GLiNER (DeBERTa-v3-large encoder)
Result
~0.65 → 0.879 F1; production checkpoint 0.832 on a locked validation set
Cost
$9 in labeling, ~$0.25 in GPU time per training run
Stack
TypeScript, MongoDB, Python, PyTorch, Modal, FastAPI

Why this is hard

I run a pipeline that reads a large volume of forum comments in a consumer-gear niche and turns them into structured aggregates: which products people actually recommend, which materials get defended, how sentiment moves over time. Every downstream number depends on one upstream step — pulling brand, product-model, and spec mentions out of the raw text. If extraction is noisy, every chart above it is decorative.

Community text breaks the assumptions that general-purpose NER models are built on. There is no capitalization discipline. Product names are abbreviated to in-group shorthand a model has never seen. Material and spec strings look like part numbers, not words. Category nouns get discussed with exactly the same syntax as brand names, so surface form tells you nothing. And the same string can be a brand in one sentence and a generic descriptor in the next.

Off-the-shelf zero-shot extraction handled the easy half. Prompted with plain label names, a general zero-shot NER model landed around 0.65 F1 on our data. Its errors were the expensive kind: it confidently tagged generic category words as brands, and it silently missed the smaller makers that are exactly what a recommendation engine needs to surface. Precision failures inflate rankings; recall failures make the long tail invisible.

Labels without a labeling team

Fine-tuning needs annotated spans, and hand-annotating a few thousand comments is a week of work I was never going to do. So a frontier LLM did the annotation and I spent the effort on making its output trustworthy instead. Total labeling bill: about $9 for ~5,000 comments.

The one design decision that made this work: never ask the model for character offsets. LLMs are bad at counting characters and will hand you spans that are off by a few positions, which is worse than no label at all because it teaches the model wrong boundaries. Instead the LLM returns the verbatim substring it found, plus the label, and deterministic code locates every occurrence and computes exact offsets. The LLM does the part it's good at — semantics — and the type system does arithmetic.

// LLM returns strings, not indices.
{ "entities": [
    { "text": "Acme Works", "label": "brand" },
    { "text": "Model 42", "label": "product model" },
    { "text": "AB-12", "label": "material spec" }
] }

// Code computes spans, validates with a schema, and drops
// anything it cannot locate verbatim in the source text.
What the model trained on What the model trained on 4,290 forum comments annotated by an LLM for $9 1,575 positive (69.6%) 675 negative 2,250 training examples 2,029 train 225 val split (validation set locked from run 10 on) 3,907 entity spans brand 1,720 product model 1,345 material spec 842 Roughly 30% of examples deliberately contain no entities at all.

Everything about the annotation stage is idempotent and resumable, keyed on the source document, so a rate limit or a bad batch costs cents rather than a rerun. Responses are schema-validated before they're allowed into the dataset; anything the code can't align to the original text is skipped rather than guessed at.

The tokenization problem nobody warns you about

Character offsets aren't what the model trains on — it wants token indices. That conversion is where a surprising amount of quality lives, because domain entities are full of punctuation that generic tokenizers happily shred. A spec string like AB-12 or 1.4116 has to survive as one unit; a comma glued to a brand name has to be separated from it.

/[A-Za-z0-9]+(?:[-./][A-Za-z0-9]+)*|[^\s]/g

That single punctuation-aware pattern — keep alphanumerics joined across hyphens, dots, and slashes; emit every other non-space character on its own — fixed a whole class of boundary errors. When a span still can't be aligned to token boundaries cleanly, the example is dropped. A small clean dataset beats a large approximately-correct one, and there is no way to notice a slowly-corrupted training set from the loss curve.

Real user text also brings its own hazards. Lone Unicode surrogates from broken emoji sequences crashed the tokenizer's Rust extension outright, and batched pre-tokenized encoding hit a library bug that required falling back to per-example calls. Neither had anything to do with machine learning, and both cost real hours.

Negatives are the actual product

The thing that moved precision most wasn't more positive examples — it was buying negatives on purpose. Roughly 30% of the training set is comments with no entities at all, sampled from the model's own failure vocabulary: the exact generic terms it kept mislabeling as brands. Teaching a model what isn't an entity is teaching it the boundary of the class.

Dataset (production run)Count
Source comments annotated4,290
Training examples2,029
Locked validation examples225
Positive / negative split1,575 / 675
Mapped entity spans3,907

More is not better, though. An aggressive expansion to ten times the adversarial negatives made the model measurably worse — it learned to be timid, and recall fell further than precision rose. The useful dose turned out to be small and targeted.

Ten runs, and the bug that ate five of them

Getting to a good model took ten training runs, and most of the failures were plumbing rather than modeling. The one worth writing down: the training pipeline feeds the encoder a per-token mask that looks exactly like a binary is-this-a-real-token flag. It isn't. It carries incremental word indices — zero for special, prompt, continuation, and padding tokens, and an increasing counter for the first sub-token of each real word.

Building it as ones and zeros produces no error, no warning, and a perfectly plausible loss curve. The model simply reads the entire comment as a single word and learns nothing about spans. Five runs died before I stopped trusting the shape of the data and read the base library's training loop.

Failure modeSymptomFix
Word mask built as binaryTrains fine, learns nothingEmit incremental word indices
Library default step capEpoch config silently ignoredPin the step count explicitly
Trainer rewrote state-dict keysCheckpoint won't loadRestore the key prefix on save
Missing label list on negativesCrash mid-epochAttach the label set to every example
Malformed Unicode in user textNative tokenizer crashSanitize surrogates on ingest
Ten training runs Ten training runs The first five produced no usable model — all plumbing, no modeling 0.0 0.2 0.4 0.6 0.8 1.0 failed scored best F1 in production R1 step cap R2 config R3 checkpoint R4 word mask R5 word mask not scored R6 first success 0.800 R7 tuned 209M 0.879 R8 459M 0.799 R9 too many negs 0.832 R10 locked val Runs 1–5 failed outright. The dashed line tracks overall F1 after the word-mask fix.

The lesson I actually took from this: in ML plumbing, silent success is the dangerous failure mode. Assert on the shape and semantics of your batches before you spend a GPU hour on them.

Results

Training runs on a single mid-tier cloud GPU in about 24 minutes, which makes iteration essentially free — the expensive resources here were labeling and debugging time, not compute.

F1 by entity class F1 by entity class Zero-shot baseline vs. fine-tuned checkpoints, same held-out data Zero-shot (est.) Fine-tuned 209M Fine-tuned 459M 0.0 0.2 0.4 0.6 0.8 1.0 ~0.65 0.800 0.879 Overall n/a 0.858 0.904 Brand n/a 0.775 0.877 Product n/a 0.712 0.829 Spec Bigger encoder helps most where the vocabulary is purely domain-specific.

Scaling the encoder helped least where the model already had general knowledge (brands, +5.4%) and most where the vocabulary is purely domain-specific (product models +13.2%, spec strings +16.4%). That gradient is a useful signal in itself: it tells you which classes are bottlenecked on data rather than on capacity.

Two operational details mattered more than the headline number. First, per-class confidence thresholds instead of one global cutoff — the classes have genuinely different score distributions, and tuning them separately beat any single value.

Per-class inference thresholds Per-class inference thresholds Production checkpoint, locked 225-example validation set precision recall brand threshold 0.35 0.801 0.876 product model threshold 0.30 0.855 0.770 material spec threshold 0.20 0.800 0.911 Lowering the spec threshold to 0.20 lifted recall on that class from 0.787 to 0.911.

Second, the large model consistently reached its best validation loss around epoch two and overfit after. With a dataset this small, the honest fix is early stopping and more data, not more training — and knowing that is worth more than another hyperparameter sweep.

Validation loss per epoch (459M model) Validation loss per epoch (459M model) Every large-encoder run bottoms out at epoch 2 6.0 7.0 8.0 9.0 eval loss overfitting 7.07 6.49 7.44 7.39 9.26 best checkpoint epoch 1 epoch 2 epoch 3 epoch 4 epoch 5 ~2K examples is not enough to train a 459M-parameter encoder for five epochs.

Caveat I'd want a reviewer to hold me to: these scores are agreement with LLM-generated labels on a locked 225-example validation set, not with human-verified ground truth. It's the right metric for tracking iteration, and the wrong one to quote as absolute accuracy.

What generalizes

The long-form version of this write-up — with the full run log, the domain specifics, and the ugly parts left in — is published on the site the pipeline feeds.