Spaces:
Runtime error
Runtime error
File size: 9,545 Bytes
be1ce5d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | # 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.
|