Notebooks and analysis scripts for "The emotional arcs of stories are dominated by six basic shapes" — clustering of Project Gutenberg book sentiment timeseries.
Which repo is this? This is the artifact repo: the reproducible pipeline that produces the paper's figures. The companion repo
andyreagan/core-stories-privateis the original 2015–2016 research scratch repo (paper LaTeX sources, exploratory notebooks, dev diary). It's private because it includes work on copyrighted texts; nothing in it is needed to run this pipeline. Start here.
uv sync # creates .venv with pinned deps
uv run python src/build_csvs.py --input-dir <gutenberg-txt-dir> # raw .txt(.gz) → per-book labMT matrices
uv run python src/build_matrix.py # per-book matrices → (N x 200) timeseries matrix
make # runs SOM, hierarchical clustering, PCA/SVDOutputs land in output/figures/{SOM,clustering,SVD}/<version>/. The only bootstrap input you need is a directory of Project Gutenberg .txt files — see below.
The book-metadata table is bundled as data/library_book.csv.gz (51,250 rows, ~3 MB gzipped). Everything else needed to run is one input:
For each Gutenberg book id you want included, the pipeline reads a file at
data/per-book-labmt-counts/{gutenberg_id}.csv.gz with this exact format:
- gzipped CSV
- shape
(10222, 200)of integers - rows: word counts in
labMTsimple/data/LabMT/labMT1.txtorder (10222 words; the header line is skipped). Rowiis the count of wordlabMT1[i+1]. - columns: 200 sliding-window positions across the book, each window 10000 tokens wide. Step size is
floor((n_tokens - 10000) / 199); the final window extends to the end of the text. - one row per line, comma-separated.
-
Download the books from Project Gutenberg (gutenberg.org or any of its mirrors). The pipeline expects plain-text UTF-8 (with ISO-8859-1 fallback). Name each file
{gutenberg_id}.txtor{gutenberg_id}.txt.gzso the script can recover the book id from the filename.The canonical filter requires ≥40 downloads, 20K–100K words, English, locc
P(literature) — about 1385 books in the snapshot used for the published paper. The exact id list lives indata/library_book.csv.gz;build_matrix.pyapplies the filter at load time. -
Convert raw text → labMT count matrices:
uv run python src/build_csvs.py --input-dir /path/to/gutenberg/txt --output-dir data/per-book-labmt-counts
The script strips Project Gutenberg headers/footers via
*** START/END OF ... PROJECT GUTENBERG EBOOK ***markers, tokenizes with[A-Za-z][A-Za-z']*(see "Tokenizer choice" below for the rationale), slides a 10000-token window 200 times, and lowercase-matches againstlabMT1.txtto produce the integer matrix described above. See the docstring insrc/build_csvs.pyfor full algorithm details.
The pipeline scores text by counting occurrences of each labMT vocabulary entry. The labMT word list is the fixed side of the join — the tokenizer's job is to produce tokens shaped like labMT entries, not to mimic some reference tokenizer. This framing matters because the labMT vocabulary was intentionally not aggressively tokenized: an entry like mother's is rated as a single affective unit (a possessive context that means something different from mother alone). Splitting it destroys the rater's intent.
Composition of the 10222-word labMT1 vocabulary:
| feature | count | examples |
|---|---|---|
| apostrophe-containing | 131 | mother's, don't, I'm, they're, friend's |
| hyphen-containing | 51 | father-in-law, great-grandfather, e-mail, t-shirt |
| digit-containing | 82 | 1980s, 1st, mp3, 80s |
| pure alphabetic | ~9960 | typical English words |
Tokenizers tested (per-book Pearson r vs. cached spaCy-1.x CSVs on an 80-book stratified sample; mean valence drift is on the timeseries level):
| tokenizer | median r | r ≥ 0.90 | r ≥ 0.95 | min r | median |drift| |
|---|---|---|---|---|---|
\b\w+\b (Python word regex) |
0.843 | 22.5% | 5.0% | −0.12 | 0.046 |
[A-Za-z][A-Za-z']* (chosen) |
0.844 | 27.5% | 8.8% | +0.07 | 0.007 |
[A-Za-z][A-Za-z'\-]* (+ hyphens) |
0.838 | 28.7% | 7.5% | +0.10 | 0.005 |
[A-Za-z0-9][A-Za-z0-9'\-]* (+ hyphens + digits) |
0.840 | 27.5% | 7.5% | +0.10 | 0.005 |
NLTK word_tokenize (Penn Treebank) |
0.853 | 27.5% | 1.2% | −0.07 | 0.027 |
NLTK TreebankWordTokenizer |
0.852 | 26.2% | 1.2% | −0.07 | 0.028 |
spaCy 3.8 English() (whole text) |
0.841 | 22.5% | 3.8% | −0.17 | 0.042 |
spaCy 3.8 English() + original paragraph chunking |
0.840 | 23.8% | 3.8% | −0.17 | 0.042 |
The "median r" column is similar across all tokenizers because the per-book correlation is dominated by the same population of typical English alphabetic words. The columns that move are:
- Mean-valence drift: the apostrophe-preserving regex is ~7× better than
\b\w+\bor spaCy. This is the direct payoff of treating labMT's intent correctly — counts of frequent possessives likemother'snow match labMT, instead of being split intomother+s(wheresis junk andmotherhas a different valence). - Min r: only the apostrophe-preserving regexes eliminate anticorrelating outlier books.
- r ≥ 0.95: the apostrophe regex roughly doubles the fraction of books that closely match the spaCy CSVs at the upper end (8.8% vs 5.0%); ironically, NLTK's Penn-Treebank-style splitting (which we'd expect to be the closest match to spaCy 1.x's habits) is the worst here.
Why not also keep hyphens? Tested — they hurt. Only 51 hyphen-words are in labMT, but the common case of well-known → joined token well-known (not in labMT) destroys two labMT matches (well and known) to capture nothing. Net negative.
Why not modern spaCy? Tokenizer rules have evolved meaningfully between spaCy 1.x (2016) and 3.x (2026). The 3.x English tokenizer does not closely match 1.x output, and inherits the same labMT-blind contraction-splitting behavior anyway.
Per-book sentiment timeseries from this script will not be byte-identical to the values used in the original Reagan et al. 2016 paper, for two reasons:
- Project Gutenberg edits its texts. Books get re-OCR'd, re-formatted, re-edited continuously. The
.txtfile you download today is generally not the same as the one downloaded in 2016. Per-book correlations vs. the 2016 snapshot range from ~0.55 to ~0.95 depending on how much the text has changed. - Tokenizer. This script uses an apostrophe-preserving regex (see above); the original used spaCy 1.x.
What this means for the analyses (tested empirically — regex pipeline vs. cached spaCy-1.x CSVs on the full 1385-book corpus):
- PCA / SVD modes (the paper's "basic shapes" as eigen-arcs): robust. The top 6 right singular vectors correlate ≥ 0.96 between the two pipelines. If you only care about identifying the dominant arc shapes, this pipeline reproduces them faithfully.
- Hierarchical clustering at k=6 (the paper's "six basic shapes" cluster assignment): not fully robust. Adjusted Rand Index between cluster labels is ≈ 0.35. Four of six clusters match centroids at r = 0.90–0.96 — the four non-flat arc shapes survive — but the original pipeline's specific way of splitting low-variance "flat" books into two sub-clusters does not replicate.
So if you want the eigen-shapes, this pipeline is fine. If you want to reproduce the exact published cluster membership at k=6, you need the original spaCy 1.x runtime (and the 2016 Gutenberg snapshot). The k=6 number itself appears to be partly an artifact of how spaCy 1.x splits the flat-book mass.
- Reads
data/library_book.csv.gzand applies the filter (default:P-20K-100K-40dl-200pt= loccPprefix, 20K–100K words, ≥40 downloads, English, 200 windows). - For each match, reads the
(10222, 200)matrix fromdata/per-book-labmt-counts/{id}.csv.gz. - Applies the labMT stopper:
mask = |score - 5| >= 1.0(drops neutral words; 3731 of 10222 retained at default threshold). - Per column, computes
valence = scores[mask] @ counts[mask, :] / counts[mask, :].sum(axis=0). - Stacks into an
(N, 200)matrix and pickles todata/gutenberg/timeseries-matrix-cache-<version>.pplus a parallelbooks-<version>.pmetadata list.
With the full corpus this yields N = 1385 books at the default filter.
make # all three
# or individually:
uv run python src/SOM/SOM-002.py 40 false
uv run python src/hierarchical_clustering/hierarchical-clusting-004.py 40 false
uv run python src/PCA_SVD/PCA-SVD-006.py 40 falseArgs: <min_downloads> <salad_flag> [exclude_pattern]. The Makefile's control target sweeps exclude patterns -1 through -10.
The first run also produces three pairwise-distance caches under data/gutenberg/ (≈seconds via scipy.spatial.distance.cdist); subsequent runs reuse them.
uv run python tests/smoke_e2e.pyDownloads 5 books from gutenberg.org, runs them through build_csvs.py + build_matrix.py in a temp dir (so it doesn't clobber any working full-corpus pickles), validates the output shape and mean-valence range, exits 0 on success. Takes ~30 seconds.
src/build_csvs.py— converts raw Gutenberg.txtfiles → per-book(10222, 200)labMT count CSVssrc/build_matrix.py— collapses the per-book CSVs into the final(N, 200)timeseries matrix picklesrc/bookclass_standalone.py— pickle loader (replaces the old Django-backedbookclass.py)src/{SOM,hierarchical_clustering,PCA_SVD}/— the three analysessrc/kitchentable/— vendored plotting helperspyproject.toml/uv.lock— pinned environment
- The original code targeted Python 2 + Django + spacy. This version is Python ≥3.11, no Django, no spacy — the per-book word counts are already pre-computed in the CSVs, so re-tokenization isn't needed.
pdftile.pl(used by SOM at the end to combine PDFs) is missing; thesubprocess.callline fails silently and the individual PDFs are otherwise complete.- Questions, bugs, or suggestions: please open an issue on this repo.