Text Detective

Words by the Numbers

Chapter 10 · Capstone: A Text Detective

A stranger hands you a page of text with no title and no name. Can you say what language it is in, who probably wrote it, and what it is about? This last chapter puts the course to work on exactly that. Each question gets one tool you already own: letter counts from Words by the Numbers 3, the author comparison from Words by the Numbers 6, and the keyword ranking from Words by the Numbers 8. The new skill is not another formula. It is knowing how far to trust each verdict, which is why the chapter spends as much time on the failures as on the successes.

What was run, and what to trust
Every Python snippet in this chapter and in the three exercise solutions was executed (Python 3.14.0), and the output in the comments is what Python printed. The texts are plain-text files from Project Gutenberg, which the code downloads once and saves next to the script, so you need an internet connection the first time. The books are identified by their eBook numbers: for language, English 1342 and 11 and 12 and 36, French 17489 and 2650, German 2229 and 22367 and 2407, Spanish 2000 and 49836, Italian 1012 and 45334; for authors, 1342 and 158 (Austen), 11 and 12 (Carroll), 35 and 36 (Wells), 1661 and 2852 (Doyle), 98 and 730 (Dickens), with 1260 as a stranger to the system. Every test passage comes from a different book than the one the profile was trained on, so the accuracy figures are honest, but they use only these few books and one random seed, and other books or seeds would give other numbers. The methods are simplified: my language test uses letter-pair profiles with cosine similarity, and my author test is a simple version of the Delta measure with my own choices (2,000-word reference chunks, the commonest words of the five training books). The history comes from two web searches that returned summaries of web pages, which are often truncated or model-summarised, so treat the wording as a paraphrase. The searches supported the citations given below and the general descriptions of the two methods; they did not let me check the original papers. Statements marked (check) were not confirmed against a source.

Case 1: What Language Is This?

Language identification is the easiest case, and it is old. A 1994 paper by William Cavnar and John Trenkle, "N-Gram-Based Text Categorization" (presented at the SDAIR-94 symposium in Las Vegas, pages 161 to 175, per the search), built a profile of the most frequent short letter sequences for each language and gave a new text to the language whose profile it matched best. Secondary summaries report accuracy of about 99.8% on postings of at least 300 bytes, using the 400 most frequent sequences, and 99.3% on shorter ones; I did not check those figures in the paper, and the search did not say which sequence lengths they used (check). The idea is the one from Words by the Numbers 3, that every language has a letter profile, plus the idea from Words by the Numbers 4, that pairs of neighbours say more than single items.

The first snippet trains one profile per language on one book, then tests passages of 20 to 300 characters taken at random from different books. It does this twice: once with single letters as the features and once with letter pairs. The score is cosine similarity, which measures how closely two lists of frequencies point the same way (1 means identical shape, 0 means nothing in common). Each result below is out of 500 passages, 100 per language.

import math, pathlib, random, re, urllib.request from collections import Counter TRAIN = {"English": 1342, "French": 17489, "German": 2229, "Spanish": 2000, "Italian": 1012} TEST = {"English": [12, 36], "French": [2650], "German": [22367, 2407], "Spanish": [49836], "Italian": [45334]} # different books from the training ones def load(number): path = pathlib.Path(f"pg{number}.txt") if not path.exists(): # download once, then reuse the saved copy url = f"https://www.gutenberg.org/cache/epub/{number}/pg{number}.txt" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) path.write_bytes(urllib.request.urlopen(req).read()) raw = path.read_text(encoding="utf-8-sig").replace("\r", "") start = raw.index("\n", raw.index("*** START OF")) + 1 return raw[start:raw.index("*** END OF")] def clean(text): # lower-case letters (accents kept) and single spaces return re.sub(r"[\W\d_]+", " ", text.lower()).strip() def profile(text, n): # relative frequency of each n-letter sequence grams = Counter(text[i:i + n] for i in range(len(text) - n + 1) if " " not in text[i:i + n]) total = sum(grams.values()) return {g: c / total for g, c in grams.items()} def cosine(a, b): dot = sum(v * b.get(g, 0) for g, v in a.items()) return dot / (math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))) def guess(passage, profiles, n): p = profile(passage, n) return max(profiles, key=lambda lang: cosine(p, profiles[lang])) random.seed(1) trained = {lang: clean(load(n)) for lang, n in TRAIN.items()} pools = {lang: [clean(load(n)) for n in nums] for lang, nums in TEST.items()} print("training text (characters):", {lang: f"{len(t):,}" for lang, t in trained.items()}) for n in (1, 2): profiles = {lang: profile(t, n) for lang, t in trained.items()} print(f"profile of {n}-letter sequences; accuracy on 100 random passages per language:") for length in (20, 50, 100, 300): right = total = 0 for lang, books in pools.items(): for _ in range(100): book = random.choice(books) start = random.randrange(len(book) - length) right += guess(book[start:start + length], profiles, n) == lang total += 1 print(f" {length:4} characters: {right / total:.1%}") profiles = {lang: profile(t, 1) for lang, t in trained.items()} for letter in "eaznwkyçñ": print(f" {letter}: " + " ".join(f"{lang[:2]} {profiles[lang].get(letter, 0):.2%}" for lang in profiles)) # training text (characters): {'English': '692,498', 'French': '632,388', 'German': '178,904', 'Spanish': '2,034,952', 'Italian': '506,884'} # profile of 1-letter sequences; accuracy on 100 random passages per language: # 20 characters: 57.0% # 50 characters: 75.6% # 100 characters: 86.4% # 300 characters: 96.0% # profile of 2-letter sequences; accuracy on 100 random passages per language: # 20 characters: 75.4% # 50 characters: 91.0% # 100 characters: 97.4% # 300 characters: 99.0% # e: En 12.87% Fr 14.76% Ge 15.48% Sp 13.53% It 11.55% # a: En 7.77% Fr 8.17% Ge 4.90% Sp 11.79% It 10.54% # z: En 0.17% Fr 0.19% Ge 1.03% Sp 0.40% It 0.46% # n: En 7.03% Fr 6.76% Ge 9.53% Sp 6.61% It 6.59% # w: En 2.26% Fr 0.00% Ge 1.83% Sp 0.00% It 0.00% # k: En 0.60% Fr 0.00% Ge 1.10% Sp 0.00% It 0.00% # y: En 2.35% Fr 0.34% Ge 0.03% Sp 1.53% It 0.00% # ç: En 0.00% Fr 0.07% Ge 0.00% Sp 0.00% It 0.00% # ñ: En 0.00% Fr 0.00% Ge 0.00% Sp 0.26% It 0.00%
Characters in the passageSingle lettersLetter pairs
2057.0%75.4%
5075.6%91.0%
10086.4%97.4%
30096.0%99.0%

Three things stand out. Pairs beat single letters at every length, because a pair like th or qu carries more of a language's habits than a letter count does. Both improve with more text, as Words by the Numbers 1 would lead you to expect: more data, less luck. And the letter table at the end of the output shows why a single-letter profile works at all, and where it is weak. The letters w and k are absent from the French, Spanish and Italian profiles, z is six times commoner in German than in English (1.03% against 0.17%), and ñ and ç identify Spanish and French almost alone. But e and n are everywhere, and the pairs add the rest. The training books are modest (the German one is only 179 thousand characters), and the test is five languages that all use the Latin alphabet; languages written in other scripts would be easier to tell apart, and closely related languages harder.

LanguageWhat a letter profile picks upCaution
Englishw and y, and pairs such as thaccuracy may depend on how old the test text is (check)
Frenchc with a cedilla, accented e, no w or kan English quotation inside a French novel confuses it
Germanz, k, w, and clusters such as schsmall training book here, so the profile is noisier
Spanishn with a tilde, a and o endingsmost often confused with Italian in Exercise 1
Italiana, i and o endings, few k, w or ymost often confused with Spanish in Exercise 1

Case 2: Who Wrote This?

Authorship is harder, because every author writes the same language. Words by the Numbers 6 used the habit that is hardest to fake: how often a writer uses small function words such as the, of and but. Here is the measure in its general form. Standardise each word's rate against a reference collection (subtract the mean, divide by the standard deviation) so that all words count equally, then take the average absolute difference between the passage's standardised rates and each author's. This is Burrows' Delta, from John Burrows, "'Delta': a Measure of Stylistic Difference and a Guide to Likely Authorship", Literary and Linguistic Computing, volume 17, number 3, 2002, pages 267 to 287, per the search. The search also reported a reviewer's note that it works least well on texts of a genre unlike the rest of an author's work, and a 2004 test of the measure by David Hoover in the same journal (volume 19, pages 453 to 475). My version standardises against 2,000-word chunks of the five training books, which is a simplification of Burrows' reference set.

The second snippet trains on one book by each of five authors and tests on a different book by each. It varies two knobs: how many of the commonest words are used, and how long the passage is. It ends with a deliberate trap, a sixth author the system has never heard of, to see whether Delta can recognise a stranger.

import pathlib, random, re, statistics, urllib.request from collections import Counter TRAIN = {"Austen": 1342, "Carroll": 11, "Wells": 35, "Doyle": 1661, "Dickens": 98} TEST = {"Austen": 158, "Carroll": 12, "Wells": 36, "Doyle": 2852, "Dickens": 730} OUTSIDER = 1260 # a different author, not among the candidates def load(number): path = pathlib.Path(f"pg{number}.txt") if not path.exists(): # download once, then reuse the saved copy url = f"https://www.gutenberg.org/cache/epub/{number}/pg{number}.txt" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) path.write_bytes(urllib.request.urlopen(req).read()) raw = path.read_text(encoding="utf-8-sig").replace("\r", "") start = raw.index("\n", raw.index("*** START OF")) + 1 return raw[start:raw.index("*** END OF")] def words(text): return re.findall(r"[a-z']+", text.lower()) def rates(tokens, vocab): # occurrences per 1,000 words for each word in vocab c = Counter(tokens) return [1000 * c[w] / len(tokens) for w in vocab] random.seed(7) train = {a: words(load(n)) for a, n in TRAIN.items()} test = {a: words(load(n)) for a, n in TEST.items()} outsider = words(load(OUTSIDER)) def build(top_n): vocab = [w for w, _ in Counter(sum(train.values(), [])).most_common(top_n)] chunks = [rates(t[i:i + 2000], vocab) for t in train.values() for i in range(0, len(t) - 2000, 2000)] mean = [statistics.fmean(col) for col in zip(*chunks)] sd = [statistics.pstdev(col) or 1 for col in zip(*chunks)] z = lambda r: [(x - m) / s for x, m, s in zip(r, mean, sd)] return vocab, {a: z(rates(t, vocab)) for a, t in train.items()}, z def delta(za, zb): return sum(abs(x - y) for x, y in zip(za, zb)) / len(za) def attribute(passage, vocab, authors, z): zp = z(rates(passage, vocab)) scores = {a: delta(zp, za) for a, za in authors.items()} best = min(scores, key=scores.get) return best, scores[best] print("accuracy, 50 random passages from a different book by each of 5 authors (chance 20%):") for top_n in (10, 30, 100, 300): vocab, authors, z = build(top_n) row = [] for length in (200, 500, 1000, 2000): right = total = 0 for a, t in test.items(): for _ in range(50): s = random.randrange(len(t) - length) right += attribute(t[s:s + length], vocab, authors, z)[0] == a total += 1 row.append(f"{length} words {right / total:.0%}") print(f" {top_n:3} commonest words: " + " ".join(row)) vocab, authors, z = build(100) known, strangers = [], [] for a, t in test.items(): for _ in range(50): s = random.randrange(len(t) - 1000) known.append(attribute(t[s:s + 1000], vocab, authors, z)[1]) for _ in range(50): s = random.randrange(len(outsider) - 1000) strangers.append(attribute(outsider[s:s + 1000], vocab, authors, z)) cut = sorted(known)[int(0.95 * len(known))] print(f"best Delta for known authors: median {statistics.median(known):.2f}, 95th percentile {cut:.2f}") print(f"best Delta for the outsider: median {statistics.median(d for _, d in strangers):.2f};", f"{sum(d > cut for _, d in strangers)} of 50 passages beyond the cut-off;", "blamed on:", dict(Counter(a for a, _ in strangers))) # accuracy, 50 random passages from a different book by each of 5 authors (chance 20%): # 10 commonest words: 200 words 46% 500 words 48% 1000 words 64% 2000 words 68% # 30 commonest words: 200 words 66% 500 words 71% 1000 words 84% 2000 words 84% # 100 commonest words: 200 words 66% 500 words 81% 1000 words 88% 2000 words 94% # 300 commonest words: 200 words 68% 500 words 81% 1000 words 94% 2000 words 99% # best Delta for known authors: median 0.93, 95th percentile 1.21 # best Delta for the outsider: median 0.95; 2 of 50 passages beyond the cut-off; blamed on: {'Wells': 12, 'Dickens': 20, 'Doyle': 16, 'Austen': 2}
Commonest words used200 words500 words1000 words2000 words
1046%48%64%68%
3066%71%84%84%
10066%81%88%94%
30068%81%94%99%

Chance is 20%, so even ten words and 200 words of text do far better than guessing, and both more words and more text help. With 300 words and a 2,000-word passage the score is 99%. Each cell is only 250 trials with one seed, so differences of a few points between cells are not reliable. The last two lines are the sobering part. For passages by the five known authors the best Delta has a median of 0.93 and a 95th percentile of 1.21. For passages by the stranger the median is 0.95, practically the same, and only 2 of 50 pass the 1.21 cut-off, about the 5% you would expect from known authors by construction. The stranger was blamed on Dickens 20 times, Doyle 16, Wells 12 and Austen 2. Delta always names someone. It measures which candidate is nearest, and nearest is not the same as right.

Question you can answerQuestion you cannot answer from the numbers
Which of these five authors is this passage closest to?Is the real author one of the five?
Does more text or more words make it more accurate?How sure is the system about this one passage (the best distance alone does not say)?
Which pair of authors does the system mix up most (Exercise 2)?Why: topic, period and genre all leave traces in function-word rates

Case 3: What Is It About?

For topic, Words by the Numbers 8 gave the tool: TF-IDF, which scores a word highly when it is frequent in the passage but rare across the collection. The third snippet is the full detective. It takes six mystery passages of 1,000 words, from books the profiles were not trained on (one is by an author outside the candidate list). For each it works out the language from the first 300 characters, names the nearest author if the language is English (flagging a Delta above the 1.21 cut-off from the previous snippet), and lists the five top TF-IDF keywords, measured against 500-word pieces of the books on the shelf for that language. The true answers are printed last.

import math, pathlib, random, re, statistics, urllib.request from collections import Counter LANG_TRAIN = {"English": 1342, "French": 17489, "German": 2229, "Spanish": 2000, "Italian": 1012} AUTHORS = {"Austen": 1342, "Carroll": 11, "Wells": 35, "Doyle": 1661, "Dickens": 98} MYSTERIES = {"A": (158, "Emma, Austen"), "B": (2852, "The Hound of the Baskervilles, Doyle"), "C": (2650, "Du cote de chez Swann, Proust"), "D": (22367, "Die Verwandlung, Kafka"), "E": (36, "The War of the Worlds, Wells"), "F": (1260, "Jane Eyre, Bronte (not a candidate)")} SHELF = {"English": [1342, 11, 35, 1661, 98, 158, 12, 36, 2852, 730, 1260], "French": [17489, 2650], "German": [2229, 22367, 2407]} # books used to measure how rare a word is def load(number): path = pathlib.Path(f"pg{number}.txt") if not path.exists(): # download once, then reuse the saved copy url = f"https://www.gutenberg.org/cache/epub/{number}/pg{number}.txt" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) path.write_bytes(urllib.request.urlopen(req).read()) raw = path.read_text(encoding="utf-8-sig").replace("\r", "") start = raw.index("\n", raw.index("*** START OF")) + 1 return raw[start:raw.index("*** END OF")] words = lambda text: re.findall(r"[^\W\d_]+(?:'[^\W\d_]+)?", text.lower()) # 1. language: cosine similarity of letter-pair profiles (as in the language snippet) def pairs(text): t = " ".join(words(text)) c = Counter(t[i:i + 2] for i in range(len(t) - 1) if " " not in t[i:i + 2]) return {g: v / sum(c.values()) for g, v in c.items()} def cosine(a, b): return sum(v * b.get(g, 0) for g, v in a.items()) / (math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))) lang_profiles = {lang: pairs(load(n)) for lang, n in LANG_TRAIN.items()} def language(passage): p = pairs(passage) return max(lang_profiles, key=lambda lang: cosine(p, lang_profiles[lang])) # 2. author: Delta over the 100 commonest words (as in the author snippet) rates = lambda tokens, vocab: [1000 * Counter(tokens)[w] / len(tokens) for w in vocab] train = {a: words(load(n)) for a, n in AUTHORS.items()} vocab = [w for w, _ in Counter(sum(train.values(), [])).most_common(100)] chunks = [rates(t[i:i + 2000], vocab) for t in train.values() for i in range(0, len(t) - 2000, 2000)] mean = [statistics.fmean(col) for col in zip(*chunks)] sd = [statistics.pstdev(col) or 1 for col in zip(*chunks)] z = lambda r: [(x - m) / s for x, m, s in zip(r, mean, sd)] author_z = {a: z(rates(t, vocab)) for a, t in train.items()} def author(tokens): zp = z(rates(tokens, vocab)) scores = {a: sum(abs(x - y) for x, y in zip(zp, za)) / len(zp) for a, za in author_z.items()} best = min(scores, key=scores.get) return best, scores[best] # 3. topic: TF-IDF keywords, with rarity measured over 500-word pieces of the shelf for that language pieces = {lang: [set(t[i:i + 500]) for t in map(lambda n: words(load(n)), books) for i in range(0, len(t) - 500, 500)] for lang, books in SHELF.items()} def keywords(tokens, lang, k=5): tf, docs = Counter(tokens), pieces[lang] score = {w: tf[w] * math.log(len(docs) / (1 + sum(w in d for d in docs))) for w in tf if len(w) > 2} return sorted(score, key=score.get, reverse=True)[:k] random.seed(3) cut = 1.21 # 95th percentile of best Delta for known authors (author snippet) for label, (number, truth) in MYSTERIES.items(): tokens = words(load(number)) s = random.randrange(len(tokens) - 1000) passage = tokens[s:s + 1000] lang = language(" ".join(passage)[:300]) line = f"{label}: language {lang}" if lang == "English": who, d = author(passage) line += f"; author {who} (Delta {d:.2f}{', above the cut-off' if d > cut else ''})" print(line + f"; keywords {', '.join(keywords(passage, lang))}") print() for label, (number, truth) in MYSTERIES.items(): print(f" {label} was really: {truth}") # A: language English; author Austen (Delta 0.91); keywords weston, praise, taylor, balls, highbury # B: language English; author Doyle (Delta 0.86); keywords charles, sir, tracey, coombe, correspond # C: language French; keywords princesse, basin, cambremer, général, euphonique # D: language German; keywords prokurist, gregor, vorzimmer, geschäft, prokuristen # E: language English; author Wells (Delta 1.05); keywords strand, brother, wellington, woking, cylinders # F: language English; author Doyle (Delta 0.83); keywords briggs, rochester, governess, eyre, advertisements # # A was really: Emma, Austen # B was really: The Hound of the Baskervilles, Doyle # C was really: Du cote de chez Swann, Proust # D was really: Die Verwandlung, Kafka # E was really: The War of the Worlds, Wells # F was really: Jane Eyre, Bronte (not a candidate)
PassageLanguageAuthorKeywords (top three)Verdict
AEnglishAusten (Delta 0.91)weston, praise, taylorcorrect
BEnglishDoyle (0.86)charles, sir, traceycorrect
CFrenchnot attemptedprincesse, basin, cambremerlanguage correct
DGermannot attemptedprokurist, gregor, vorzimmerlanguage correct
EEnglishWells (1.05)strand, brother, wellingtoncorrect
FEnglishDoyle (0.83)briggs, rochester, governesswrong: a candidate was blamed for a stranger

Every language verdict is right, and so are the three author verdicts on passages by candidate authors; the stranger is the lesson. Passage F is by an author who is not on the list, and the detective named Doyle with a Delta of 0.83, which is below the median of 0.93 for passages by the real candidates, so no cut-off could have caught it. Yet the keywords for F (rochester, governess, eyre) are the strongest clue in the whole report, since a character name is a fingerprint of one book. A good detective uses the tools against each other. A keyword list full of proper names says the topic evidence is really about the story, not the style; a Delta that is merely adequate says to hold the verdict loosely; and language, the most reliable of the three, comes first because the other two depend on it. Notice also why the keywords are mostly names: with a shelf this small, a character who appears in one book is rare everywhere else, so TF-IDF loves characters. A bigger shelf with many books on the same subject would favour subject words instead (my expectation, not tested here).

The last table lines the three tools up so you can choose between them. The reliability column summarises what this chapter measured, not a general rule.

ToolNeedsReliable whenFails when
letter-pair profile (language)one book per language, 50 to 300 charactersthe language is among those trainedrelated languages, very short text, mixed-language text
Delta on common words (author)a book or two per candidate, 500 or more wordsthe true author is among the candidatesthe author is a stranger, the genre differs, the passage is short
TF-IDF keywords (topic)a shelf of other texts in the same languagethe text has distinctive vocabularythe shelf is small (names dominate) or the text is generic
Common mistakes
Testing on the same book you trained on (the numbers look wonderful and mean nothing); reading a nearest-author answer as proof when the real author might not be on the list; quoting one accuracy figure without the passage length and the number of words used; trusting a single random seed for small differences between cells; leaving accented letters in some profiles and stripping them in others; training on a text with a lot of foreign quotation or editorial notes in it; feeding the detective a passage too short for the method (Exercise 3 shows what 30 words does); treating TF-IDF keywords that are character names as the topic of a whole genre; and forgetting that a cut-off taken from known data will always flag about the percentage you chose.
Exercise 1

How short can a language test be? Using letter-pair profiles, test 100 random passages per language at lengths of 10, 20, 50, 100 and 200 characters, print a table of accuracy by language and length, and report the most common mistake at 20 characters. Which two languages are confused most, and why might that be?

📄 View solution
Exercise 2

Who gets confused with whom? Build the Delta profiles from the 100 commonest words, test 100 random 1,000-word passages from a different book by each of the five authors, and print a confusion table with the true author in the rows and the named author in the columns. Name the most frequent mistake and suggest, with a reason, why that pair is close.

📄 View solution
Exercise 3

Make the detective safe to hand to someone else: it must not crash when the language has no shelf for keywords. Run it on three new passages: 1,000 words of a poem, 1,000 words of a Spanish novel, and just 30 words of a book the author profiles do know. Which parts of each verdict would you trust, and which would you mark as uncertain?

📄 View solution

Quick Reference

  • Language: a letter-pair profile per language plus cosine similarity reached 75.4% at 20 characters, 91.0% at 50 and 99.0% at 300 here; single letters reached 57.0%, 75.6% and 96.0%
  • Author: a simple Delta (mean absolute difference of standardised word rates) reached 88% with the 100 commonest words and 1,000-word passages, and 99% with 300 words and 2,000-word passages, on five authors (chance 20%)
  • Topic: TF-IDF keywords against a shelf of other texts; on a small shelf they are mostly character names
  • The stranger problem: Delta always names a candidate; the outsider's best Delta (median 0.95) looked like the known authors' (median 0.93)
  • Order of work: language first (the others depend on it), then author, then topic, then check them against each other
  • Honest testing: train and test on different books, report the passage length, the number of features and the seed, and keep small samples in mind
  • History: Cavnar and Trenkle, SDAIR-94 (1994), for n-gram language profiles; Burrows, Literary and Linguistic Computing 17(3), 2002, for Delta (both as summarised by the search; check)
Where to go from here
You have now built, in plain Python, most of the classic toolkit for treating text as numbers: counting (Words by the Numbers 1), the rank-frequency curve (Words by the Numbers 2), letter profiles (Words by the Numbers 3), word pairs (Words by the Numbers 4), generators (Words by the Numbers 5), style (Words by the Numbers 6), readability (Words by the Numbers 7), keywords (Words by the Numbers 8) and information (Words by the Numbers 9). Three suggestions for what to try next, all my own suggestions rather than anything checked against a source. First, scale up: repeat the detective with ten books per author and ten languages, and see which weak spots shrink and which do not. Second, borrow a library: packages for natural-language processing and machine learning offer ready-made versions of these ideas (their names and features change often, so read their current documentation), and having built the simple versions yourself you can now judge what they do. Third, pick a mystery of your own: a diary, a set of old letters, your own emails over ten years, a set of speeches. The tools do not change, but every new collection teaches you something about their limits that no book can, and the habit this course has tried to build, testing on held-out text and being honest about the failures, is the part that carries over to everything else.