I trained a 113M-parameter earthquake LLM from absolute scratch A developer trained a 113M-parameter language model for earthquake science from scratch using open-access papers, Wikipedia, and FineWeb-Edu on two NVIDIA A30 GPUs. The model achieved a 35% reduction in bits-per-byte on general prose by mixing domain and general text, demonstrating the full pretraining lifecycle from data crawling to inference. Train a small GPT for earthquake science — the entire LLM lifecycle, from a blank folder to a talking model, explained block by block. Crawl → Clean → Tokenize → Model → Train → Infer, on 2× NVIDIA A30 48 GB . Six free data sources → crawl → clean/dedup → 16k BPE → 113M GQA+RoPE decoder → 2-GPU DDP training → streaming inference. Each stage is a section below. nanoGPT-Seis is a teaching repository. It is not trying to be a great earthquake model — it is trying to make every stage of pretraining a language model legible: where the data comes from, how it is cleaned and deduplicated, how a tokenizer is built, why the Transformer looks the way it does, how it is trained across two GPUs, and how it is served. Every design decision is explained, and every number perplexity, VRAM, tokens is one we actually measured on this hardware. The corpus mixes earthquake / seismology text open-access papers via Crossref+Unpaywall, arXiv/EarthArXiv preprints, the "Earthquake Insights" Substack with general text Wikipedia + FineWeb-Edu for plain-language fluency — about 24% domain / 76% general. A focused corpus lets a ~100M-param model become genuinely fluent on a single node, so you can see the whole loop close in a day, not a month. Why the general mix? See §1 1-results-at-a-glance . Status:the pretraining lifecycle is complete — crawl through inference. Results at a glance 1-results-at-a-glance Quick start 2-quick-start Stage 1 — Data crawling 3-stage-1--data-crawling Stage 2 — Processing & dedup 4-stage-2--processing--dedup Stage 3 — The BPE tokenizer 5-stage-3--the-bpe-tokenizer Stage 4 — The model RoPE, GQA, … 6-stage-4--the-model Stage 5 — Training DDP, VRAM, LR 7-stage-5--training Stage 6 — Inference 8-stage-6--inference Repository layout 9-repository-layout Scaling-law experiments 10-scaling-law-experiments | value | | |---|---| | Corpus | 533,248 docs · 485.7M words · 822.7M training tokens ≈2.4:1 general:domain | | Model | 113M params — decoder-only, GQA + RoPE + RMSNorm + SwiGLU | | Hardware | 2× NVIDIA A30 24 GB each , bf16, DDP — also runs on a single RTX 3090/4090, or 12–16 GB with a smaller batch | 4096 tokens 0.997 — vs 1.527 for a domain-only base −35% Three findings worth pausing on: Longer context helped. A controlled A/B data held fixed — retraining at 4096 vs 1024 dropped perplexity ~11% 9.74 → 10.93 domain-only for only ~26% more compute per step — papers have long-range structure a 1024-token window can't see across. The model uses that context. In a 4096-token window, loss on tokens at positions 2048–4096 is 25% lower than at 0–64 — it conditions on thousands of preceding tokens see §8 8-stage-6--inference . A general-text mix restores fluency. Adding Wikipedia + FineWeb-Edu ~2.4:1 general:domain cut bits/byte on general prose by 35% vs a paper-only base — see the data-mix comparison below. A paper-only base is fluent in paper-register but repetitive/incoherent in plain prose. Adding ~540M tokens of Wikipedia + FineWeb-Edu → ~823M train tokens, ~2.4:1 general:domain, ~3.8 epochs — within the ~4-epoch repeat budget shown to be near-lossless by Muennighoff et al., 2023 https://arxiv.org/abs/2305.16264 gives v2. Measured with bits-per-byte tokenizer-independent, so v1↔v2 is fair; src/compare models.py : v2 is far more fluent on general prose −35% bits/byte and generates coherent non-earthquake text where v1 emits gibberish, at the cost of some domain sharpness +22% — the classic fluency↔specialization trade-off. This fluent base is the right starting point for SFT; base pretraining alone never yields chat. Domain-only weights are kept as checkpoints/ckpt v1 domain.pt . The pretrained 113M checkpoint is hosted on the Hugging Face Hub: jiazhe868/nanogpt seis https://huggingface.co/jiazhe868/nanogpt seis . environment a working CUDA-12.4 PyTorch; see the note below conda activate nanogpt seis pip install -r requirements.txt download the checkpoint, tokenizer, and model config into the paths expected by src/inference.py huggingface-cli download jiazhe868/nanogpt seis \ checkpoints/ckpt.pt \ data/tokenized/tokenizer.json \ data/tokenized/meta.json \ configs/gpt120m ctx4k.yaml \ --local-dir . python -m src.inference --prompt "The 2011 Tohoku earthquake" environment a working CUDA-12.4 PyTorch; see the note below conda activate nanogpt seis pip install -r requirements.txt --- run the whole pipeline --- earthquake-domain sources python -m src.crawl.wikipedia --max-pages 500 earthquake-titled pages python -m src.crawl.fulltext --per-journal 3000 --broad 30000 --workers 64 python -m src.crawl.preprints --arxiv 3000 --eartharxiv 2000 python -m src.crawl.substack --max 500 general-text mix for fluency ~540M tokens: Wikipedia + FineWeb-Edu python -m src.crawl.general --wiki-tokens 300000000 --fineweb-tokens 240000000 python -m src.process.build corpus --val-frac 0.005 clean · dedup · split python -m src.tokenizer.train bpe --vocab-size 16384 train the tokenizer python -m src.tokenizer.encode → uint16 shards torchrun --standalone --nproc per node=2 \ -m src.train --config configs/gpt120m ctx4k.yaml train on 2 GPUs python -m src.inference --prompt "The 2011 Tohoku earthquake" streams live A PyTorch built for a newer CUDA than your driver will silently report⚠️ Environment gotcha. cuda.is available == False and fall back to CPU. Verify with python -c "import torch; print torch.cuda.is available " — it must print True . This project uses torch 2.6.0+cu124 to match a CUDA-12.5 driver. Goal: assemble a large, legal, full-text earthquake corpus from free sources. Code: src/crawl/ . | source | module | what we pull | mechanism | |---|---|---|---| | Research papers | fulltext.py | OA full-text PDFs of earthquake papers | Crossref DOIs → Unpaywall OA PDF → download → extract | | Preprints | preprints.py | arXiv + EarthArXiv full text | arXiv API + OSF/DOI → PDF | | Wikipedia | wikipedia.py | pages titled "earthquake" | MediaWiki API plaintext extracts | | Substack | substack.py | "Earthquake Insights" articles | archive API + HTML body parse | | General text | general.py | Wikipedia + FineWeb-Edu fluency mix | HF datasets streaming to a token budget | Why a general-text mix?A ~113M model trained only on research papers becomes fluent in paper-register but repetitive and incoherent in plain prose, and 240M tokens is far below compute-optimal Hoffmann et al., 2022 . So we add ~240M tokens of Wikipedia encyclopedic + FineWeb-Edu Penedo et al., 2024 ; quality-filtered educational web for a ~1:1 general:domain mix — a fluent base that the planned SFT stage can then make conversational. Base pretraining alone never yields chat; that's SFT. Every document is normalized to one schema src/crawl/common.py : @dataclass class Doc: source: str "fulltext" | "arxiv" | "wikipedia" | ... id: str stable per-source id used for dedup title: str text: str the cleaned body we will tokenize url: str = "" date: str = "" extra: dict = field default factory=dict venue, cited by, full text, ... Finding the papers Crossref . Crossref indexes ~150M scholarly works with a free, generous API. We page through earthquake journal-articles with a deep cursor no offset limit , filtered by journal ISSN: src/crawl/fulltext.py — iter crossref params = {"rows": 1000, "cursor": cursor, "filter": "type:journal-article,issn:0094-8276", e.g. GRL "query.bibliographic": "earthquake", "mailto": EMAIL} Finding the open PDF Unpaywall . A DOI is not a PDF. Unpaywall free, ~100k/day maps a DOI to its legal open-access copies. We try repository green OA locations first — publisher links are frequently bot-blocked stubs: repository copies download far more reliably than publisher links prio = 0 if loc.get "host type" == "repository" else 1 Downloading in parallel, politely. PDFs come from many hosts, so we use a thread pool but throttle per host — different servers download concurrently while any single server stays rate-limited: python class HostThrottle: src/crawl/fulltext.py def wait self, host : with self. guard: get/create this host's lock host lock = self. locks.setdefault host, threading.Lock self. last.setdefault host, 0.0 with host lock: serialize only this host delta = self.min - time.monotonic - self. last host if delta 0: time.sleep delta self. last host = time.monotonic Validating every download. Not every "OA PDF" is real — many are 5 KB anti-bot landing pages. We accept a download only if it is a real PDF with enough text: if not pdf bytes.startswith b"%PDF" : return None HTML / stub if doc.page count < 2: return None cover page if len text < min chars: return None too little extracted Not wasting the budget. Some journals Science, Nature are almost entirely paywalled — scanning thousands of their DOIs would burn the Unpaywall budget for zero full text. A low-yield abort gate skips a journal once its hit-rate stays under a threshold: if scanned = abort after and got ft / max 1, scanned < min hit: break this venue isn't worth more API calls Resumable. Output is appended line-by-line and already-fetched ids are skipped on restart, so a multi-hour crawl survives interruption: done = load done ids out ids already in the JSONL ... if iid in done: continue skip work we already have War story why Crossref + Unpaywall .This project originally enumerated papers via OpenAlex, which changed to a paid credit model mid-build — the free daily budget ran out after ~100 requests. Crossref + Unpaywall is the free, robust replacement, and it is what the code ships with. The lesson — pin your data source assumptions and make the crawler resumable — is baked into the design. Full-text yield varies a lot by source: arXiv ~99% open by design , the broad OA pool ~15%, paywalled journals ~0% abstract fallback . Net corpus: ~20k full-text papers + ~26k abstracts. The crawler above is API-driven, but the shape underneath is the classic one: a breadth-first traversal of a link graph, run across many threads. It is worth writing that general pattern out in full, because getting the concurrency right is the hard part. The crux is shared state that every worker touches at once — a frontier of URLs still to visit, and a set of URLs already seen. Three rules make it correct: - The frontier is a queue.Queue , which is internally synchronised: get and put are atomic, and get blocks until work exists, so workers never busy-wait. - The seen-set gets its own lock. "Is this URL new? if so, add it" is a read-modify-write; without the lock two threads can both judge the same URL new and enqueue it twice. - Termination uses Queue.join / task done . The hard question in a concurrent BFS is knowing when you are done: a worker finding the frontier momentarily empty does not mean the crawl is finished, because another worker may be one instruction away from enqueuing more links. join counts outstanding tasks and only releases once every enqueued URL has been fully processed. python import re, threading, queue, requests from urllib.parse import urljoin, urldefrag, urlparse class BFSCrawler: """Breadth-first web crawler: one shared frontier, N worker threads.""" def init self, seeds, max pages=5000, n workers=16, min interval=1.0 : self.frontier = queue.Queue thread-safe BFS queue of url, depth self.seen = set seeds every URL ever enqueued self.seen lock = threading.Lock guards seen check-then-add races self.pages = kept url, html pairs self.pages lock = threading.Lock guards pages + the page counter self.max pages = max pages self.n workers = n workers self.throttle = HostThrottle min interval §3.2 — politeness, per host for s in seeds: self.frontier.put s, 0 seed the frontier at depth 0 def links self, base, html : extract absolute, de-fragmented links for m in re.finditer r'href= "\' . ? "\' ', html : url, = urldefrag urljoin base, m.group 1 relative → absolute, drop frag if url.startswith "http://", "https://" : yield url def enqueue self, url, depth : with self.seen lock: the check AND the add are ONE critical section, if url in self.seen: else two threads both see url as new and return enqueue it twice self.seen.add url self.frontier.put url, depth def worker self : while True: url, depth = self.frontier.get blocks; None, is the stop sentinel if url is None: self.frontier.task done return try: with self.pages lock: if len self.pages = self.max pages: continue budget spent — drain the rest quietly self.throttle.wait urlparse url .netloc rate-limit this host html = requests.get url, timeout=10 .text with self.pages lock: if len self.pages = self.max pages: continue self.pages.append url, html for link in self. links url, html : BFS: expand the frontier self. enqueue link, depth + 1 except Exception: pass one dead link must not kill a worker finally: self.frontier.task done pairs with the get above def run self : workers = threading.Thread target=self. worker, daemon=True for in range self.n workers for t in workers: t.start self.frontier.join wait until the frontier is truly drained for in workers: then release the idle, blocked workers self.frontier.put None, 0 for t in workers: t.join return self.pages ThreadPoolExecutor reaches the same result with less bookkeeping — submit worker n workers times and let the pool own the threads — but spelling out the Queue.join handshake is what makes the termination logic visible. This repo's fulltext.py is a one-hop specialisation of exactly this skeleton: the frontier is the list of DOIs Crossref returns, each is "expanded" not into page out-links but into its Unpaywall PDF locations, and the shared guarded state is the resume-set of already-fetched ids rather than a seen set of URLs. The thread pool and the per-host throttle are identical. A full multi-hop BFS would instead follow