Knowledge Base

Smol LLM Lab Β· Lesson 1 β€” Start with a baseline, not a transformer

πŸ§ͺ Build-in-public: we're building small, production-grade text models β€” under 1M parameters β€” that train and serve on a plain 4-core VPS with no GPU. Every lesson documents a real run: the commands, the numbers, and the surprises.

Where this is going (the real goal)

The end product is not a generic "bad words" filter. It's a data-quality guard for B2B contact records β€” the kind of thing ZoomInfo or Dun & Bradstreet needs: when someone signs a form as "Mickey Mouse, Chief Butthead, asdf@qwerty.com", the pipeline should catch that the name is fake, the job title is profane or garbage, and score the record's trustworthiness.

That problem needs the exact skills we build here:

  1. a model that recognizes profanity and abuse even when obfuscated (this lesson + lesson 2),
  2. a model that knows what real names and real job titles look like (the companion title-guard project, which validates titles against golden data like the O*NET taxonomy and SEC filings).

We start with general toxicity data because it's large, labeled, and free β€” the perfect gym. Then we transfer the technique to short strings like "VP of F***ing Nothing".

What "small" buys us

Constraint Value Why it matters
Hardware 4 CPU cores, 7.6 GB RAM, no GPU anyone can afford this
Model budget \< 1,000,000 parameters fits in RAM, no accelerator
Serving target \< 5 ms per record on CPU usable inside a data pipeline that processes millions of rows
Phases L1 baseline β†’ L2 tiny transformer β†’ L3 deployed API β†’ L4 theory notes each phase is one lesson

For contact-data cleaning you might score 50 million records in a batch job. At 1 ms per record, that's ~14 CPU-hours β€” fine. At 100 ms (a big transformer), it's 58 days. Small is a feature.

Step 0 β€” What we're learning on

wiki_toxic: 159,549 comments from Wikipedia talk pages, hand-labeled by the Jigsaw project as toxic (insults, threats, profanity, harassment) or not.

Split Rows % toxic
train 127,656 10.1%
validation 31,915 10.3%
test 63,978 9.8%

Two things to notice before training β€” this habit will save you weeks over your career:

Step 1 β€” Turn text into numbers (TF-IDF, demystified)

Machine-learning models eat numbers, not words. The 60-year-old trick that still works:

And the trick that makes this actually production-worthy β€” character n-grams. Slide a 3-to-5-letter window over the raw characters: "sh1t" β†’ sh1, h1t, sh1t… A word-level filter is blind to sh1t, f__k, st00pid; a char-level model sees that sh1 and h1t look suspiciously like their uncensored cousins, because it learned that from data. Nobody wrote a rule for it.

Step 2 β€” The simplest model that could work

Logistic regression: one weight per feature, multiply-and-add, squash to a 0–1 probability. It is literally a single neuron. Why start here?

The entire model, for real:

word_vec = TfidfVectorizer(ngram_range=(1, 2), max_features=100_000,
                           sublinear_tf=True, strip_accents="unicode")
char_vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5),
                           max_features=150_000, sublinear_tf=True)
X = hstack([word_vec.fit_transform(texts), char_vec.fit_transform(texts)])
clf = LogisticRegression(C=4.0, max_iter=2000, class_weight="balanced")
clf.fit(X, labels)

Notes on the three knobs that matter:

Step 3 β€” The numbers

Training took 25 seconds. Featurizing the text took 2 minutes. On our 4-core box.

Metric (toxic class) Validation Test
F1 0.812 0.627
Precision 0.768 0.476
Recall 0.862 0.916
ROC-AUC 0.982 0.966

Read the validation column first: we catch 86% of toxic comments, and 77% of our flags are correct. For 25 seconds of training, that's genuinely strong.

Step 4 β€” The plot twist (this is the real lesson)

Test F1 cratered to 0.63. A beginner's instinct is "the model is broken." Debug like an engineer instead:

  1. Suspect #1 β€” different class balance? Checked: all three splits are ~10% toxic. Not it.
  2. Look at the shape of the failure. Recall stayed high (0.92) but precision collapsed (0.48). The model finds toxic comments fine β€” but half its flags are "wrong" according to the test labels.
  3. Question the labels. The Jigsaw test set is documented to come from a different annotation batch with different labeling standards. Many comments our model flags were probably let through by the second batch of human labelers. The disagreement between annotators is baked into the benchmark.

Takeaway you'll reuse forever: validation is a promise the test set doesn't have to keep. In production there's always a second distribution β€” new users, new slang, a different labeling vendor. When a metric drops, decompose it (precision vs recall), check base rates, then question the labels before questioning the model.

Step 5 β€” Read the model's mind

Because it's linear, we can print the highest-weighted features: ass, uck, fuck, shit, crap, bitch, idiot, stupid, cunt…

Look at uck and fuc β€” those are character n-grams, and they're exactly the anti-obfuscation machinery: any creative respelling that keeps those letter patterns still fires. That's the property we'll want when someone types a "clever" profane job title into a form.

Step 6 β€” Production reality check

Model file (vectorizers + weights) 4.6 MB
Latency, batched on CPU 0.77 ms per comment
Parameters 250,001

Already inside every budget we set. This is the bar lesson 2's transformer has to clear: beat 0.81 validation F1 under 1M parameters β€” and if it can't, the boring baseline wins. That would also be a lesson.

Replicate it yourself (10 minutes)

mkdir smol-lab && cd smol-lab
python3 -m venv .venv
.venv/bin/pip install scikit-learn pandas scipy joblib

mkdir data && for f in train validation test; do
  curl -L "https://huggingface.co/datasets/OxAISH-AL-LLM/wiki_toxic/resolve/main/$f.csv" -o data/$f.csv
done

# grab the training script from the lab repo (lab/01_baseline/train_baseline.py)
.venv/bin/python lab/01_baseline/train_baseline.py

The script prints every number in this lesson and saves baseline.joblib (the deployable model) plus metrics.json. Change one thing, rerun, watch the metrics move β€” try ngram_range=(1,1) with no char features and watch obfuscated profanity slip through. That's the whole game of applied ML: change, measure, understand.

Next up

Lesson 2: a transformer built from scratch in readable PyTorch β€” byte-pair tokenizer, embeddings, attention blocks, ~700k parameters β€” trained on this same box, judged on this same table. And in parallel, title-guard: validating job titles against real golden data (the O*NET occupation taxonomy and officer titles pulled from SEC filings). Same lab, real B2B problem.

πŸ“Œ All code: smol-lab repo on the VPS β€” lab/01_baseline/train_baseline.py.