50 minHistorical text snapshotExecuted reading edition

Read keyword frequencies in context

Retrieve every exact-token match with context, inspect chapter-normalized counts and return to the source passages.

Humanities & social sciences · Lesson 03🔋 Offline core analysis⏱ 50 minHistorical text snapshot📦 numpy · pandas · matplotlib · IPython

Goal and how to run

This lesson assumes basic Python knowledge. Read each method and caption before changing parameters. AI exercises are optional; no AI account is needed for the core workflow. Data lives in notebook metadata, so there is no companion data download. Keep the notebook saved in the active working folder.

Your goal is to understand the research question, execute transparent calculations, check the results, and state the limits of the conclusion.

Setup and parameters

In [1] · Python
from pathlib import Path
import base64, gzip, hashlib, json, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, HTML
import re
from collections import Counter
from IPython.display import display as _display
def display(value):
    if isinstance(value,pd.DataFrame):
        table=value.to_html(max_rows=12,escape=True)
        style="<style>.vibeit-readable-table{max-width:100%;overflow-x:auto}.vibeit-readable-table table{width:max-content;max-width:none;border-collapse:collapse}.vibeit-readable-table td,.vibeit-readable-table th{white-space:nowrap!important;word-break:normal!important;overflow-wrap:normal!important}</style>"
        return _display(HTML(style+'<div class="vibeit-readable-table">'+table+'</div>'))
    return _display(value)
RECIPE_ID='hu03-keyword-context'
OUTPUT_DIR=Path.cwd()/"results"/RECIPE_ID
plt.rcParams.update({"figure.dpi":120,"font.size":10,"axes.spines.top":False,"axes.spines.right":False,
                      "axes.prop_cycle":plt.cycler(color=["#18785f","#416aa6","#bc6541","#9974a5"])})
print("Python:",platform.python_version(),"; NumPy:",np.__version__,"; Pandas:",pd.__version__)
print("Working folder:",Path.cwd())
print("Core data mode: embedded snapshot; no network requests")
Python: 3.13.13 ; NumPy: 2.5.3 ; Pandas: 3.0.6
Working folder: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads
Core data mode: embedded snapshot; no network requests

Load the embedded snapshot

In [2] · Python
def load_snapshot(recipe_id, expected_sha256, snapshot_key):
    """Read embedded data from this notebook, including a renamed copy."""
    for path in sorted(Path.cwd().glob("*.ipynb")):
        try:
            notebook = json.loads(path.read_text(encoding="utf-8"))
            metadata = notebook.get("metadata", {}).get("vibeit_cookbook", {})
            blob = metadata.get("snapshots", {}).get(snapshot_key)
            if metadata.get("recipe_id") != recipe_id or not blob:
                continue
            if blob.get("sha256") != expected_sha256:
                continue
            raw = gzip.decompress(base64.b64decode(blob["gzip_base64"], validate=True))
            if hashlib.sha256(raw).hexdigest() != expected_sha256:
                raise ValueError("Embedded snapshot checksum mismatch: " + snapshot_key)
            return json.loads(raw)
        except (OSError, json.JSONDecodeError):
            continue
    raise FileNotFoundError(
        "Save/open the complete .ipynb in the active working folder. "
        "The embedded snapshot could not be located for " + recipe_id)

SNAPSHOT_HASHES={'novels': 'c03763abb21b2380d2934b088f1c22a6d5a4675b17e23534e3f7343dc90084ec'}
def snapshot(key):
    return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['novels']

Read the method functions

In [3] · Python
def strip_ebook_wrapper(raw_text):
    """Return the analysis body while the unmodified source/complete license remain in metadata."""
    text=raw_text.replace('\r\n','\n').replace('\r','\n')
    start=re.search(r'\*\*\* START[^\n]*\*\*\*',text,re.I)
    end=re.search(r'\*\*\* END[^\n]*\*\*\*',text,re.I)
    if not start or not end or end.start()<=start.end():raise ValueError('Ebook start/end markers not found')
    return text[start.end():end.start()]

print("Method ready: strip_ebook_wrapper")
Method ready: strip_ebook_wrapper
In [4] · Python
def split_novel_chapters(body):
    """Declared two-ebook rule: repeated labels resolve to last heading, excluding their TOCs."""
    headings=list(re.finditer(r'(?im)^\s*chapter\s+([ivxlcdm]+|\d+)[.\]]*\s*$',body))
    if not headings:raise ValueError('No supported chapter headings')
    last={match.group(1).upper():match for match in headings}
    chosen=sorted(last.values(),key=lambda match:match.start());chapters=[]
    for j,match in enumerate(chosen):
        text=body[match.end():chosen[j+1].start() if j+1<len(chosen) else len(body)]
        text=re.sub(r'\[Illustration\b[^\]]*\]',' ',text,flags=re.I|re.S)
        text=text.replace('_','').replace('’',"'").strip()
        if len(text)<300:raise ValueError('Unexpected short chapter after declared TOC rule')
        chapters.append(dict(label=match.group(1).upper(),order=j+1,text=text))
    return chapters

print("Method ready: split_novel_chapters")
Method ready: split_novel_chapters
In [5] · Python
def tokenize_words(text):
    """Transparent English-letter tokenizer; punctuation/case decisions are part of the method."""
    return re.findall(r"[a-z]+(?:'[a-z]+)?",text.lower().replace('’',"'"))

print("Method ready: tokenize_words")
Method ready: tokenize_words
In [6] · Python
def keyword_contexts(tokens,query,window=7):
    query=query.lower()
    if not re.fullmatch(r"[a-z]+(?:'[a-z]+)?",query) or window<0:raise ValueError('Expected one normalized word and nonnegative window')
    rows=[]
    for index,word in enumerate(tokens):
        if word==query:
            rows.append(dict(token_index=index,before=' '.join(tokens[max(0,index-window):index]),keyword=word,
                after=' '.join(tokens[index+1:index+1+window])))
    return rows

print("Method ready: keyword_contexts")
Method ready: keyword_contexts

Data and model boundary

The snapshot retains complete unmodified source ebooks, including their original licenses. Chapter-based analysis uses 61 Pride and Prejudice chapters and 24 Frankenstein chapters; front matter and Frankenstein framing letters are outside this declared scope. English-letter tokenization is a method choice, not a language-neutral definition. Word frequencies, surface-pattern cooccurrence and SVD axes do not independently establish authorial intent, character relationships or sentiment.

Steps

1. Declare exact-token queries and context width

Query pride in Pride and Prejudice and creature in Frankenstein. These are predeclared examples, not automatic thematic labels. Contexts use seven normalized tokens on each side and retain source chapter/order identifiers.

In [7] · Python
novels=snapshot("novels");corpus={};chapter_rows=[]
for book in novels["books"]:
 chapters=split_novel_chapters(strip_ebook_wrapper(book["raw_text"]))
 tokens=tokenize_words(" ".join(chapter["text"] for chapter in chapters))
 corpus[book["id"]]={"title":book["title"],"author":book["author"],"chapters":chapters,"tokens":tokens}
 for chapter in chapters:chapter_rows.append({"book_id":book["id"],"book":book["title"],"chapter":chapter["order"],"source_label":chapter["label"],"characters":len(chapter["text"]),"tokens":len(tokenize_words(chapter["text"])),"text":chapter["text"]})
chapter_table=pd.DataFrame(chapter_rows)
assert len(corpus[1342]["chapters"])==61 and len(corpus[84]["chapters"])==24
print(novels["source_notice"])
print("Declared chapter counts:",{b:len(corpus[b]["chapters"]) for b in corpus})
display(chapter_table.drop(columns="text").head(8))
queries={1342:"pride",84:"creature"};hits=[];counts=[]
for book_id,entry in corpus.items():
 for chapter in entry["chapters"]:
  tokens=tokenize_words(chapter["text"]);contexts=keyword_contexts(tokens,queries[book_id],7)
  for row in contexts:hits.append({"book_id":book_id,"book":entry["title"],"chapter":chapter["order"],**row})
  counts.append({"book_id":book_id,"book":entry["title"],"chapter":chapter["order"],"query":queries[book_id],"hits":len(contexts),"tokens":len(tokens),"per_1000_tokens":1000*len(contexts)/len(tokens)})
kwic=pd.DataFrame(hits);chapter_counts=pd.DataFrame(counts)
display(kwic[["book","chapter","before","keyword","after"]].head(10))
These source ebooks are available free of charge. Complete unmodified source texts and their licenses are included in this snapshot. Project Gutenberg is acknowledged as the transcription source; its name is not used as a course title or endorsement.
Declared chapter counts: {1342: 61, 84: 24}
book_id book chapter source_label characters tokens
0 1342 Pride and Prejudice 1 I 4490 853
1 1342 Pride and Prejudice 2 II 4293 799
2 1342 Pride and Prejudice 3 III 9560 1704
3 1342 Pride and Prejudice 4 IV 5931 1064
4 1342 Pride and Prejudice 5 V 5246 962
5 1342 Pride and Prejudice 6 VI 13023 2353
6 1342 Pride and Prejudice 7 VII 11263 2000
7 1342 Pride and Prejudice 8 VIII 10993 1936
book chapter before keyword after
0 Pride and Prejudice 5 says that he is eat up with pride and i dare say he had heard
1 Pride and Prejudice 5 you never to dance with him his pride said miss lucas does not offend me
2 Pride and Prejudice 5 does not offend me so much as pride often does because there is an excuse
3 Pride and Prejudice 5 elizabeth and i could easily forgive his pride if he had not mortified mine pride
4 Pride and Prejudice 5 pride if he had not mortified mine pride observed mary who piqued herself upon the
5 Pride and Prejudice 5 or other real or imaginary vanity and pride are different things though the words are
6 Pride and Prejudice 5 person may be proud without being vain pride relates more to our opinion of ourselves
7 Pride and Prejudice 8 be very bad indeed a mixture of pride and impertinence she had no conversation no
8 Pride and Prejudice 11 understanding to ridicule such as vanity and pride yes vanity is a weakness indeed but
9 Pride and Prejudice 11 yes vanity is a weakness indeed but pride where there is a real superiority of

2. Normalize chapter frequencies

The vertical axis is query occurrences per 1000 chapter tokens, not raw count or sentiment intensity. Zero means no exact query match under this tokenizer, not no relevant theme in the passage.

In [8] · Python
fig,ax=plt.subplots(figsize=(10,4))
for book_id,part in chapter_counts.groupby("book_id"):ax.plot(part.chapter,part.per_1000_tokens,"o-",ms=3,label=part.book.iloc[0]+": "+queries[book_id])
ax.set(xlabel="Declared chapter order",ylabel="Query hits per 1000 tokens",title="Length-normalized exact-token occurrences");ax.legend(fontsize=8);plt.tight_layout();plt.show()
Computed figure from this code cell

3. Inspect corpus-position dispersion

Locations are relative to the processed numbered-chapter token sequence. The display ignores excluded front matter and framing letters. Dispersion shows where the query occurs; it does not determine how the narrator uses the word.

In [9] · Python
fig,axes=plt.subplots(2,1,figsize=(10,4))
for ax,(book_id,entry) in zip(axes,corpus.items()):
 indices=[i for i,w in enumerate(entry["tokens"]) if w==queries[book_id]]
 ax.scatter(np.asarray(indices)/len(entry["tokens"]),np.zeros(len(indices)),marker="|",s=100)
 ax.set(xlim=(0,1),yticks=[],xlabel="Relative processed-corpus position",title=entry["title"]+": "+queries[book_id])
plt.tight_layout();plt.show()
Computed figure from this code cell

4. Read context neighbors rather than automatic sentiment

Count words in retrieved context windows after a declared short stop list. Overlapping windows may count a neighbor more than once. The bars motivate close reading, not a sentiment classifier or an author-intent conclusion.

In [10] · Python
stop=set("the a an and to of in i he she it was is that with for".split())
fig,axes=plt.subplots(1,2,figsize=(11,4))
for ax,book_id in zip(axes,corpus):
 subset=kwic[kwic.book_id.eq(book_id)];neighbor=Counter()
 for row in subset.itertuples():neighbor.update(w for w in (row.before+" "+row.after).split() if w not in stop and w!=queries[book_id])
 top=neighbor.most_common(10);ax.barh([w for w,n in top][::-1],[n for w,n in top][::-1]);ax.set(xlabel="Counts in retrieved windows",title=corpus[book_id]["title"])
plt.tight_layout();plt.show()
Computed figure from this code cell

5. Reconcile exact matches and export all contexts

Compare per-chapter totals with direct query counts. Export all matches in source order so students can examine counterexamples and return to the full original ebook, not only a selected excerpt.

In [11] · Python
assert chapter_counts.hits.sum()==len(kwic)
for book_id,entry in corpus.items():assert kwic.book_id.eq(book_id).sum()==entry["tokens"].count(queries[book_id])
OUTPUT_DIR.mkdir(parents=True,exist_ok=True);kwic.to_csv(OUTPUT_DIR/"all-keyword-contexts.csv",index=False)
chapter_counts.to_csv(OUTPUT_DIR/"normalized-chapter-query-counts.csv",index=False)
print("Exact match totals reconciled; all contexts exported:",len(kwic))
Exact match totals reconciled; all contexts exported: 86

Exercises and reference explanation

  1. Can query frequency determine sentiment?
  2. What do overlapping context windows imply?

Reference explanation. Meaning needs close reading. Overlap can count neighbor words repeatedly, so neighbor bars are window counts rather than independent utterances.

Keep a copy before changing parameters. Compare old and new figures and record which inputs, calculations and conclusions changed.

Optional AI coding exercises

Prompt 1

Using re/Counter/NumPy/Pandas/Matplotlib, add a declared exact-word query and export every match.

Prompt 2

With the same packages, compare context widths 5 and 10 without retokenizing the source silently.

Human acceptance. Direct/query totals agree, zero hits are handled and every displayed context has source chapter/order.

Review the edited source, then manually run affected cells and subsequent checks. Ask the assistant to explain the purpose, packages and data provenance of its changes, and reconcile that explanation with actual output.

Optional online extension

In [12] · Python
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
    import requests
    try:
        response=requests.get('https://www.gutenberg.org/ebooks/1342',timeout=20)
        response.raise_for_status()
        print("Source:",response.url)
        print(response.text[:700])
    except requests.RequestException as error:
        print("Online extension unavailable:",type(error).__name__,str(error)[:160])
else:
    print("Online extension skipped; embedded core data unchanged")
Online extension skipped; embedded core data unchanged

Sources, snapshots and next steps

  • Historical novel 1342 — Original novel public domain in USA; complete source ebook and its Project Gutenberg license retained; free distribution and acknowledgment links; retrieved 2026-10-09. SHA-256 3f6bb9d6f78e0293b56acd4714dd68cb7d6d1d293402031ce9d5a216bcaf9d75. Preserve unmodified UTF-8 source including ebook headers and complete license; preprocessing occurs visibly in the notebook.

  • Historical novel 84 — Original novel public domain in USA; complete source ebook and its Project Gutenberg license retained; free distribution and acknowledgment links; retrieved 2026-10-09. SHA-256 7810cd483cffcf2cc8a1d8f0d5807931e69d4f48cd14149b8c76f88af82fead3. Preserve unmodified UTF-8 source including ebook headers and complete license; preprocessing occurs visibly in the notebook.

Next steps. Transfer these methods to your own public or authorized data while retaining input checks, recorded parameters and result validation. Document the new experimental design and method limits; this example does not validate a new experiment. Full data licenses and generation instructions accompany the cookbook.