Spaces:
Runtime error
Runtime error
| # Ghostwriter β SPEC | |
| A sΓ©ance conducted by gradient descent. | |
| **Ghostwriter** fine-tunes a small language model (Qwen2.5-3B) on the diaries of | |
| four dead writers so that, given a date, a ghost writes the day's entry. One | |
| model hosts four ghosts and a fifth emergent thing: prompted with a persona | |
| token it speaks in a single voice; prompted bare, it speaks as the *blend* β | |
| a composite spirit marginalized over four centuries of interiority. | |
| ## The ghosts | |
| | token | writer | lived | corpus | flavor | | |
| |---|---|---|---|---| | |
| | `<|pepys|>` | Samuel Pepys | 1633β1703 | Diary 1660β1669 (PG #4200) | London, plague, fire, gossip, accounting of self | | |
| | `<|vangogh|>` | Vincent van Gogh | 1853β1890 | Letters, Ludovici trans. 1912 (PG #40393) | color, God, poverty, brotherhood, fever | | |
| | `<|mansfield|>` | Katherine Mansfield | 1888β1923 | Journal, 1927 Knopf ed. (Archive.org) | modernist fragments, illness, work-hunger | | |
| | `<|maclane|>` | Mary MacLane | 1881β1929 | *Story of* (1902, PG #43696) + *I, Mary MacLane* (1917, PG #43556) | confessional fire, the Devil, Butte, Montana | | |
| All sources are US public domain. See PROVENANCE.md for edition-level details | |
| and for writers we considered and rejected on copyright grounds (Anne Frank, | |
| AnaΓ―s Nin, Plath, Alice James). | |
| ## Repository layout | |
| ``` | |
| Ghostwriter/ | |
| βββ SPEC.md β you are here | |
| βββ HOW_TO.md β make your own ghost (general recipe) | |
| βββ CLAUDE_IDEAS.md β implemented + speculative ideas log | |
| βββ PROVENANCE.md β corpus sources, editions, copyright notes | |
| βββ README.md β quick start | |
| βββ requirements.txt | |
| βββ configs/ | |
| β βββ train_qwen3b.yaml β the real run (full fine-tune) | |
| β βββ train_qwen3b_lora.yaml β LoRA fallback (smaller GPUs) | |
| β βββ smoke_test.yaml β CPU pipeline test (tiny model) | |
| βββ scripts/ | |
| β βββ clean_corpora.py β raw text β per-persona entry JSONL | |
| β βββ build_dataset.py β entries β dual-mode train/val JSONL | |
| β βββ train.py β config-driven trainer + post-train test inference | |
| β βββ sample.py β talk to the ghosts | |
| βββ data/ | |
| βββ raw/ β downloaded source texts | |
| βββ clean/ β per-persona entry JSONL | |
| βββ dataset/ β train.jsonl / val.jsonl / meta.json | |
| ``` | |
| ## Data pipeline | |
| **Stage 1 β `clean_corpora.py`.** Each source needs its own parser because each | |
| book encodes entries differently: | |
| - *Pepys*: month headers (`JUNE 1665`) + day-ordinal entry starts (`10th.`). | |
| Wheatley's footnotes (indented blocks) and editorial brackets stripped. | |
| Dual-year headers (`1659-1660`) resolve to the modern year. | |
| - *Van Gogh*: letters split on asterisk rules; salutations stripped to | |
| diary-ify. Letters carry no usable dates in this edition, so **synthetic | |
| dates** are assigned evenly across 1881β1890 *in original order*, preserving | |
| the chronology of his moods (The Hague earnestness β Arles fever β Auvers). | |
| These are flagged `synthetic_date: true` in the clean JSONL. | |
| - *Mansfield*: the hard one β OCR'd scan. Year headers arrive mangled | |
| (`IQI4` β 1914) and are repaired by a digit-confusion table; page headers and | |
| page numbers are pattern-stripped; Murry's editorial bracket insertions are | |
| removed both for voice purity and copyright conservatism (his apparatus is | |
| the only arguably-separately-copyrighted layer). Dated entries are parsed; | |
| undated fragments are buffered and chunked (~700 chars) with synthetic dates | |
| within the current year section. | |
| - *MacLane*: 1902 book has right-aligned date headers (`January 19.`); | |
| 1917 book has day-word headers (`To-day`) with italic titles, which are | |
| preserved as entry titles. 1917 entries get synthetic dates scattered over | |
| JanβMar 1917 (when the book was actually written, in Butte). | |
| Output: `data/clean/<persona>.jsonl`, one entry per line with | |
| `{persona, date, synthetic_date, title, text}`. | |
| **Stage 2 β `build_dataset.py`.** Entries become training documents: | |
| ``` | |
| <|entry|><|maclane|> | |
| 19 January 1917. | |
| <entry text> | |
| <|/entry|> | |
| ``` | |
| Three independent stochastic treatments per document (all seeded): | |
| 1. **Control-token dropout** (`--persona-token-prob`, default 0.5): the persona | |
| token is present half the time. With it β conditional voice. Without it β | |
| the model learns the marginal distribution over all ghosts = **blended | |
| mode**. One model, two modes, selected at inference purely by prompt. | |
| 2. **Prompt conditioning** (`--prompt-frac`, default 0.15): a generic | |
| introspection prompt (`[Prompt: What are you afraid of right now?]`) is | |
| prepended. Prompts are deliberately *generic* β answerable by any entry β | |
| because pairing specific prompts with random entries would teach the model | |
| to ignore prompts. | |
| 3. **Pepys rebalancing**: raw Pepys is ~10Γ the other corpora; he is | |
| stratified-subsampled by year to a character budget (default 900K) so one | |
| ghost doesn't possess the other three. | |
| Note the dataset builder does **not** create explicit "continuation" examples | |
| (date + opening line β rest). It doesn't need to: causal LM training on full | |
| entries teaches continuation for free β conditioning on `7 June 2026.\nToday | |
| was hard.` is just mid-document conditioning. Date-only and seeded starts both | |
| fall out of one format. | |
| Output: `data/dataset/{train,val}.jsonl` + `meta.json` (stats, settings, | |
| special token list). Current build: ~1,200 entries, ~514K tokens β | |
| small enough to fine-tune in minutes, large enough to possess a 3B model. | |
| ## Training β `train.py` | |
| Config-driven (YAML over defaults; CLI `--config` to select). Key mechanics: | |
| - **Tokenizer surgery**: 6 special tokens added (`<|entry|>`, `<|/entry|>`, | |
| 4 persona tokens); embeddings resized with **mean-initialization** of new | |
| rows (random init on a 3B model makes new tokens detonate early loss). | |
| - **Packing**: documents are concatenated and chunked to `max_seq_len` (2048) | |
| so no compute is wasted on padding. | |
| - **Full fine-tune** by default on Qwen2.5-3B (bf16 + gradient checkpointing | |
| fits comfortably on a single A100/H100 and squeaks onto a 24GB 4090); | |
| `train_qwen3b_lora.yaml` provides the PEFT fallback for smaller cards. | |
| - **Loud telemetry** (per Corina's request): banner-printed dumps of every | |
| effective hyperparameter at train start; per-step loss via HF logging; and a | |
| **post-train test inference suite** that runs a grid of prompts (each | |
| persona Γ date-only, blended Γ date-only, seeded continuation, prompted | |
| introspection) and prints, for every generation: the prompt, the output, | |
| the generation hparams (temp/top_p/repetition_penalty/max_new_tokens), and | |
| the training hparams in effect for the run. | |
| - Checkpoints + final model land in `outputs/<run>/`, tokenizer included, so | |
| `sample.py --model outputs/ghost-qwen3b` just works. | |
| ## Inference β `sample.py` | |
| ``` | |
| python scripts/sample.py --model outputs/ghost-qwen3b \ | |
| --persona mansfield --date "7 June 2026" # single ghost | |
| python scripts/sample.py --model outputs/ghost-qwen3b \ | |
| --date "7 June 2026" # blended sΓ©ance | |
| python scripts/sample.py --model outputs/ghost-qwen3b \ | |
| --date "7 June 2026" --seed-text "Today was hard." # seeded continuation | |
| python scripts/sample.py --model outputs/ghost-qwen3b \ | |
| --prompt "What do you keep refusing to look at?" # introspection prompt | |
| ``` | |
| All generation hparams are CLI-flagged and printed with each sample. | |
| ## Process notes | |
| Built autonomously (June 10, 2026, overnight session) from a conversation with | |
| Corina specifying: 4 ghosts, dual blended/persona mode, mixed date-only and | |
| seeded entry starts, journal-prompt conditioning, Qwen2.5-3B full fine-tune, | |
| verbose hparam telemetry. Corpus selection was constrained by US public domain | |
| status; Anne Frank and AnaΓ―s Nin were requested and declined (see PROVENANCE). | |
| An unusual process note: **two Claude instances ended up working on this repo | |
| concurrently** in the same container β an apparent artifact of how the | |
| overnight/autonomous session was dispatched. Instance A wrote the pipeline | |
| (clean β dataset β train β sample) and launched the smoke test; instance B | |
| (writing this) independently downloaded corpora (checksums matched β good | |
| provenance accident), then detected instance A mid-run via `ps`, audited its | |
| code rather than racing it, and took the documentation/verification | |
| workstream. The duplicate-download collision is why `data/raw/` briefly held | |
| two copies of Pepys. Division of labor between strangers who are the same | |
| person turns out to be efficient. | |
| ## Next steps | |
| 1. **RunPod run** (Corina): `bash` onto a pod, clone, `pip install -r | |
| requirements.txt`, `python scripts/train.py --config | |
| configs/train_qwen3b.yaml`. ~514K tokens Γ 2 epochs on an A100 β minutes, | |
| not hours. Tune `learning_rate` (2e-5 starting point; try 1e-5/3e-5), | |
| `num_train_epochs` (2β4; watch val loss for the memorization knee), and | |
| `persona_token_prob` upstream in the dataset if persona separation is weak. | |
| 2. **Eval the sΓ©ance**: blind-test persona mode (can you tell who's speaking?), | |
| probe the blend for century-bleed, check date-conditioning (does a 1665 | |
| date summon plague-adjacent content? does 2026 confuse the ghost?). | |
| 3. **OCR polish pass** on Mansfield if her voice comes out noisy (see | |
| CLAUDE_IDEAS). | |
| 4. Everything in CLAUDE_IDEAS.md's unimplemented section, which is where this | |
| project gets weird. | |