zeref.
/ LiitLLM
01Case study Trained from scratch, ₱0

Taglish language model

LiitLLM

32.8M parameters · 2 × 2 runs

Filipinos online don't write pure Tagalog or pure English. They switch between them mid-sentence. The tools that assemble training data for large language models treat that mixing as garbage and delete it. I built the opposite: a filter that keeps only the mixed writing, trained a small model on it, and then trained an identical model on unfiltered text to prove the filter is what made the difference.

Web-scale corpora are assembled with document-level fastText language ID, which scores heavily code-switched Filipino as low-confidence and drops it or misroutes it to en. Standard pipelines filter code-switching out; this one filters for it. A 32.8M-parameter transformer trained from scratch on a Taglish-filtered corpus, ablated against an identical model trained on the unfiltered corpus at exactly equal token budget, two seeds per arm.

More English than control
2.8×
Tokens per arm
995M
Training
4 × 60k steps
GPU time
~44 h, free T4
Download model (131 MB)

Weights only, no login. Runs on a laptop CPU.

What this project shows

Every big corpus throws Taglish away. This one keeps only Taglish.

  1. 01FilterKeep only web text that mixes Tagalog and English.
  2. 02TrainTwo identical models, one on the filtered text, one on everything.
  3. 03MeasureSame prompts, same scorer. Count the English that comes out.

The filter, running live type in the box

431 Tagalog and 647 English words

Tagalog0%
English0%
01The result

The filtered model borrows about three times as much English

en_mean 0.185 vs 0.065, with no overlap between arms

2.8×

more English in the filtered model's writing than in the control's, averaged over two training runs each.

Both models were given the same eight Taglish prompts and asked to continue them, 32 continuations each. Then the same scorer from the top of this page measured how much English came out.

Fixed committed prompt set, 32 generations per checkpoint, scored with taglish.score. Reported as a range across two seeds per arm, because a single run per arm cannot separate a real gap from seed noise.

English word fraction in each model's generations. Each dot is one training run. The gap between arms is far larger than the spread within either.
ArmSeed 1Seed 2Spread
Filtered: English fraction 0.2060.1640.042
Unfiltered: English fraction 0.0610.0700.009

The arms do not overlap. The gap between them is 0.120. The filtered models put roughly three times as much English into their output, and that gap is 2.8× the widest within-arm spread. Filtering for code-switching produced a model that code-switches.

Table of all four training runs: English share, Tagalog share, Taglish pass rate and validation loss per seed
Drawn from the committed run results by docs/make_figures.py.

The headline metric is noisier than that, and I am reporting both

My pre-registered metric was taglish share: the fraction of generations passing the full is_taglish test. It gives a gap of 0.328 against a within-arm spread of 0.219: the same direction, the same conclusion, but a much thinner margin. That metric is a threshold count over 32 samples, so it throws away magnitude and inherits the noise of a small binomial. The continuous measure separates cleanly; the thresholded one barely clears. Both are on this page because reporting only the flattering one is how you fool yourself.

What the difference actually sounds like

Same prompt, both models, at 60,000 steps.

FILTERED: tl 0.46 · en 0.27

Kahapon nag-iinom ako ng buko juice. Alam mo naman kasi yung feeling na gusto mo ng pakulo before ka mag-hard reservation. Napagdesisyunan ko na lang na mag-care ako, ang dami ko nang gustong terminate. […] Maybe this time I'm going to be better off. I'm not worried about it. Gets ko na lang, and I know I'm just hoping for that.

UNFILTERED: tl 0.73 · en 0.01

Kahapon nag-usap sila ni Jimmy sa telepono. Nang tanungin kung anong oras na ang kanilang komunikasyon para sa Skype, tinanong sila ni Jimmy kung ano ang sagot. Sabi ni Jimmy, "Puwede na ulit nating sabihin na hindi natin naayos ang problema."

The difference is register, not just vocabulary. The filtered model writes the way someone texts: "Gets ko na lang", "Alam mo naman kasi yung feeling", switching to English for a whole clause and back. The unfiltered model writes clean reported speech in a news register. Both are fluent Filipino. Only one of them is Taglish.

02The premise

A filter that calls the real language "noise"

Language ID is a document-level classifier, and Taglish is a sentence-level phenomenon

To build a training set, you crawl the web and sort pages by language. A tool looks at each page and says "this is Tagalog" or "this is English." Pages it can't confidently label get thrown out.

Now consider how a Filipino actually writes: "Kahapon nag-commute ako papuntang office kasi grabe ang traffic." The grammar is Tagalog. Half the nouns are English. The tool sees a page full of that, can't commit to either label, and deletes it.

So the register that millions of people actually write in is systematically absent from the data that language models learn from. Not by anyone's decision, but as a side effect of a sorting step nobody thinks about.

CC-100, OSCAR, FineWeb-2 and friends all route documents through a fastText langid classifier with a confidence floor. Intra-sentential code-switching (English content words inserted into Tagalog morphosyntax) produces exactly the low-confidence, split-posterior documents that floor is designed to remove.

The consequence is a systematic register gap. The Filipino text that survives collection skews formal and monolingual: news copy, encyclopedic prose, official statements. The conversational register, the one with the code-switching in it, is thinned out at the point of collection, before any model sees it.

Tagalog Wikipedia is excluded here for the same reason, deliberately. It is clean, well-formed, and almost entirely free of the phenomenon under study.

03The scorer

Two word lists and a rule about prefixes

Lexicon-ratio scoring, and why the two lists are built differently

The filter is about thirty lines. It splits text into words and asks, for each one, "is this in my Tagalog list?" and "is this in my English list?" Then it keeps documents where both answers come back often enough.

Two details make it work, and both took a rewrite to find.

score(text) returns the fraction of word tokens matching each lexicon; is_taglish keeps a document when both fractions clear a floor. No new dependencies, no model, and explainable in a paragraph, which matters, because this same function decides the training corpus, the ablation, and the headline metric.

The two lists are not symmetric

The Tagalog list is almost all function words: ang, ng, sa, kasi, naman. Those are a closed class: you cannot write Tagalog syntax without them, and English text will not accidentally produce them.

The English list deliberately includes content words: office, traffic, meeting. That asymmetry is forced by the phenomenon itself: in "nag-commute ako sa office" there is not a single English function word. Score English by function words and the most characteristically Taglish sentences read as pure Tagalog.

Hyphens get split, or the most Taglish word scores nothing

nag-commute matches neither list as a single token, and neither does nagcommute, because the affix stripper only finds a stem that is itself in the Tagalog list, and commute is English. So the single most characteristically Taglish word in the sentence would contribute zero to either side.

Splitting on the hyphen fixes half of that: commute lands in the English list. The nag- half still scores nothing, since bare affixes are not lexicon entries. That is a real limitation, not a design choice, but it only ever under-counts, and it under-counts the Tagalog side of a word already surrounded by Tagalog function words, so the document is not at risk of being misread as English.

Morphology

Tagalog is agglutinative: trabaho becomes nagtrabaho, trabahuhan, pinagtrabahuhan. A bare root list misses all of those and under-counts the Tagalog side. The scorer peels one prefix and one suffix, and handles reduplication (nagcocommute → commute). It stops at one layer on purpose: two layers reduces nasa to sa and starts inventing matches.

04The finding that dented my own premise

The web text was already 40% Taglish

Measured keep-rate: 38.0% and 39.5%, not the ~10% the thesis assumed

My whole pitch was that code-switching gets filtered out of web corpora. So I measured how much survives, expecting very little. Across 50,000 documents from each of two sources, 38% and 39.5% already passed the Taglish test, untouched, straight out of the corpus.

Bake-off across FineWeb-2 fil_Latn and HPLT 2.0 tgl_Latn, 50,000 documents sampled from each: keep-rates of 38.0% and 39.5%. The langid filter thins code-switched text but does not come close to removing it.

38.0%

FineWeb-2 fil_Latn already passes the Taglish filter

39.5%

HPLT 2.0 tgl_Latn, same measurement

This weakens the control arm, and it stays in

The "unfiltered" model is supposed to be the contrast. But if its corpus is already ~40% code-switched, it is not a clean negative. It is a weaker dose, not a zero dose. The honest framing is not "filtering creates code-switching from nothing." It is: Filipino web text is already heavily code-switched, and filtering the rest of the way still measurably changes the model. That is a smaller claim than I set out to make, and it is the one the data supports.

An earlier version of this page would have said something false

I first ran that bake-off on 2,000 documents and got 46.8% versus 34.4%, a clear win for FineWeb-2, and I wrote it down as one. At 50,000 documents it is 38.0% versus 39.5%: the two sources are equivalent and the ranking had inverted. Small samples do not give you a quiet, proportional error. They give you a confident answer that is backwards.

05The model

What it is, and what "from scratch" means

32.8M-parameter decoder-only transformer, no pretrained weights

No downloaded model, no fine-tuning. The tokenizer, the transformer, the training loop are code in this repository. The only things brought in from outside are the raw text and PyTorch itself.

Architecture is inherited unchanged from Munti, a prior from-scratch model proven on TinyStories, deliberately, so that any difference in output is attributable to data rather than code. What changed is scale and context.

 MuntiLiitLLM
Layers / heads / width6 / 6 / 3849 / 8 / 512
Context window256512
Vocabulary4,0968,192
Parameters12.3M32.8M
Training tokens~330M1.97B
GPU-hours~6~44

The 512-token context is a deliberate choice for this subject. Code-switching is a discourse-level behaviour: a writer flips register across clauses and sentences, not within a three-word window. At 256 tokens the model would rarely see a full switch cycle.

One tokenizer, trained on the unfiltered corpus

Both arms share a byte-level BPE tokenizer, and it is trained on the unfiltered text on purpose. Training it on the filtered corpus would tune the vocabulary to the filtered model's own distribution and hand that arm an advantage that has nothing to do with the experiment. The two data-prep runs ran on two different accounts a day apart and produced byte-identical tokenizers, which is the check that this was actually deterministic rather than merely intended to be.

06Surviving the clock

Four 11-hour runs on a service that kills you at 12

Crash-safe checkpointing, a wall-clock guard, and a chained runner across two accounts

The free GPU I used stops every session at 12 hours, whether or not you are done. Each run needs about 11. There is no room for a crash, and a crash at hour 10 that loses everything is not a setback, it is the end of the project.

Kaggle: ~30 GPU-h per week per account, hard 12-hour session wall. Four runs of ~10.9h each across two accounts. Every failure mode below actually fired at least once during this project.

What makes an 11-hour run survivable

  • Atomic checkpoints. Write to a temp file, then os.replace onto the target, keeping the previous one as a fallback. A crash mid-write otherwise truncates the only copy you have.
  • Random state is saved too. Without it, a resumed run replays the same batches it already learned from and quietly trains on a shorter corpus than you think.
  • A wall-clock guard. The loop stops itself at 11 hours and checkpoints cleanly, so I choose when the session ends rather than the platform choosing mid-write.
  • A runner that assumes it will die. Re-running the entrypoint skips finished work and resumes partial work. It also detects the specific case where a run finished on the server but its output was never downloaded, which is what happens every time the laptop sleeps.
  • A lockfile. Two runners once raced and pushed the same job 18 seconds apart, burning double the GPU on identical work.

The last run made the case for all of it: it stopped at step 55,000 instead of 60,000. The runner noticed, pushed a continuation from the last checkpoint, finished the remaining 5,000 steps, and moved on to scoring, with nobody awake.

Filtered (Taglish) Unfiltered (control) two seeds each
Validation loss, 60,000 steps. Both seeds of each arm sit on top of each other, so the runs are reproducible. The two arms cannot be compared on this chart, and section 07 explains why.
07The number that looks like an answer and isn't

The "worse" model is the one that worked

Cross-arm validation loss is not comparable, by construction

The unfiltered models reach a validation loss of 2.635. The filtered models sit at 2.745. Lower is better, so the control arm looks like the better model, and that reading is meaningless.

Each model is evaluated on a held-out slice of its own corpus. They are sitting different exams. Filtered Taglish is intrinsically harder to predict: two languages' worth of vocabulary, and a switch point that can fall anywhere. A lower score on an easier exam says nothing about which model is better at the thing I care about.

The only apples-to-apples measurement is the one in section 01: identical prompts, identical scorer, comparing outputs rather than losses. It would have been very easy to put "2.635 vs 2.745" in a results table and let a reader draw the wrong conclusion.

08What it cannot do

Fluent, confident, and frequently wrong

Failure probes, deliberately out of domain

A 32.8M-parameter model trained on ~2B tokens of web text has grammar and register. It does not have knowledge, arithmetic, or code, and it will not tell you so.

PROMPT · Ang kabuuang populasyon ng Pilipinas noong 2020 ay

…umabot sa 3.5 milyon.

The real figure is about 109 million. The sentence is grammatical, plausibly shaped, and off by a factor of thirty.

PROMPT · 1 + 1 =

0.04 – 5.05 = 0.48 – 0.8 – 0.8 – 0.2 – 0.8 – 0.05 = 0.8 – 0.8 – 0.10 – 0.3…

PROMPT · def fibonacci(n):

Southern California, Mexico, CA 9003, Mexico, Mexico, Mexico, Ltd. (Nobel/Five: 1)…
  • It repeats itself. Longer generations fall into loops: the same hashtag or phrase over and over.
  • Degenerate repetition at longer horizons; no repetition penalty is applied at sampling time.
  • It invents facts fluently. Names, dates and figures are shaped correctly and almost always wrong.
  • It has no instruction following. There is no fine-tuning stage. It continues text; it does not answer questions.
  • Its idea of Taglish is the internet's. The corpus is web text, so the register skews toward blogs, news comments and entertainment copy.
  • It is under-trained, and the curve says so. Validation loss was still descending at step 60,000 in all four runs. Nothing had flattened or turned up. More compute would have produced better models. The runs stopped because the weekly GPU quota did, not because training had converged. Both arms stopped at the same step, so the comparison is unaffected; the absolute quality is not.
09What broke

Five ways this reported success while handing me the wrong thing

Failures that produced plausible output instead of an error

None of these crashed. Every one of them ran to completion and gave me something that looked like a result.

A deduplication step that deleted the corpus

Documents were deduplicated by hashing their first 200 characters. Web pages share navigation boilerplate, so thousands of distinct articles hashed identically. A 20,000-token target came out as 109 tokens. Hashing the whole document fixed it. A unit test caught this, not a person.

A config I edited but never uploaded

I changed the training length, then launched two 11-hour runs from the copy that was still sitting on the server. Both runs used the old schedule. They are still valid, because both arms were equally affected, but the config file in the repo no longer described what ran. Editing a file is not deploying it.

The fix was to set the configs to what actually ran rather than what I meant, and write a comment explaining why the obvious-looking value must not be "corrected". Seed 2 then had to reproduce the same schedule exactly, or the seed-to-seed spread would have absorbed a config change and stopped measuring noise.

…and then a second bug caused by fixing the first

That config field was read by two different things: the learning-rate schedule and the runner's "is this run finished?" test. Pinning it for the schedule made a completed 60,000-step run look 12,000 steps short, and the runner launched a pointless continuation. An assert at the top of the training notebook caught it in 55 seconds instead of after hours of compute.

A file pattern that matched two files

Every saved run contains a full copy of the repository, so a search for pyproject.toml found both the repo and the previous run's embedded copy. The fix was to scope each lookup to where the thing legitimately lives, making the ambiguity impossible rather than merely detected.

A wrong diagnosis, stated confidently

When mounted files first went missing I blamed a race condition and said so. It was a wrong path. Running one command that listed the directory would have shown that immediately. A confident wrong diagnosis costs more than saying "checking."

10Reproduce it

Run it yourself

Download the model

No login needed. Both files are weights only, so they load with PyTorch's safe weights_only=True mode and run on a laptop CPU.

git clone https://github.com/Zeref538/liitllm && cd liitllm
pip install -e .
python -m liitllm.sample --ckpt liitllm-taglish.pt --tokenizer tokenizer.json --prompt "Kahapon nag-"

Checksums and release notes: GitHub release v1.0. All four runs (both seeds per arm) are on Kaggle.

Train it from zero

The correctness gate runs on a CPU in about a minute and must pass before any GPU time is spent. It is the reason a bad generation can be blamed on data rather than on a broken attention mask.

git clone https://github.com/Zeref538/liitllm && cd liitllm
pip install -e .

python test_liitllm.py          # transformer + training loop correctness
python test_data.py             # corpus parity and dedup
python test_resume.py           # a killed session can resume

python -m liitllm.data prepare  # stream, filter, tokenize (CPU, hours)
python -m liitllm.train --config configs/liit-33m.yaml
python -m liitllm.evaluate --baseline out/ckpt.pt --ablation out-unfiltered/ckpt.pt

Stack: Python, PyTorch, HuggingFace tokenizers and datasets (streaming only), Kaggle free T4s. No pretrained weights, no paid services, no API keys.

Built from an empty file. Made for how Filipinos actually write.