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:
- a model that recognizes profanity and abuse even when obfuscated (this lesson + lesson 2),
- a model that knows what real names and real job titles look like (the companion
title-guardproject, 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:
- Class imbalance. Only ~10% of comments are toxic. A model that answers "not toxic" every single time scores 90% accuracy while catching zero toxic comments. So accuracy is meaningless here; we track precision (of the things we flagged, how many were really toxic?), recall (of all toxic things, how many did we catch?), and their harmonic mean F1 β computed on the toxic class only.
- Three splits, three jobs. We train on train, tune choices on validation, and touch test only at the end, once, as an honest exam.
Step 1 β Turn text into numbers (TF-IDF, demystified)
Machine-learning models eat numbers, not words. The 60-year-old trick that still works:
- Bag of words: count how often each word appears in a comment. "you are an idiot" β {you:1, are:1, an:1, idiot:1}.
- TF-IDF ("term frequency Γ inverse document frequency"): the same counts, but words that appear in every comment ("you", "the") get scaled down, and rare, distinctive words ("idiot") get scaled up. Each comment becomes a long, mostly-zero vector of scores.
- n-grams: instead of single words, also count pairs ("kill yourself" carries more signal than "kill" and "yourself" separately).
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?
- It trains in seconds on a CPU.
- It's interpretable: you can print the weights and read what it learned.
- It sets the number to beat. If a fancy model can't beat it, the fancy model loses. This discipline is Rule #1 of applied ML.
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:
max_featurescaps the vocabulary (100k word + 150k char features = 250k weights β a quarter of our 1M budget).class_weight="balanced"tells the model that missing a toxic comment costs more than a false alarm, compensating the 10:1 imbalance.C=4.0is regularization strength (higher = trust the training data more). Tuned on the validation split, never on test.
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:
- Suspect #1 β different class balance? Checked: all three splits are ~10% toxic. Not it.
- 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.
- 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-labrepo on the VPS βlab/01_baseline/train_baseline.py.