Audit a name-mention cooccurrence graph
Declare surface-pattern aliases, count paragraph cooccurrences and inspect threshold-sensitive graph summaries.
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
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
import networkx as nx
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='hu04-mention-network'
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
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
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
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
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
def mention_cooccurrence(paragraphs,patterns,minimum_count=3):
"""Paragraph-level surface-pattern cooccurrence; preserve all declared entity nodes."""
if minimum_count<1:raise ValueError('Minimum cooccurrence count must be positive')
labels=list(patterns);compiled={name:re.compile(pattern,re.I) for name,pattern in patterns.items()}
matrix=np.zeros((len(labels),len(labels)),dtype=int);presence=np.zeros(len(labels),dtype=int)
for paragraph in paragraphs:
found=np.array([bool(compiled[name].search(paragraph)) for name in labels]);presence+=found
matrix+=np.outer(found,found).astype(int)
np.fill_diagonal(matrix,0);graph=nx.Graph();graph.add_nodes_from(labels)
for i in range(len(labels)):
for j in range(i+1,len(labels)):
if matrix[i,j]>=minimum_count:graph.add_edge(labels[i],labels[j],paragraph_count=int(matrix[i,j]))
return dict(graph=graph,labels=labels,matrix=matrix,paragraph_presence=presence)
print("Method ready: mention_cooccurrence")
Method ready: mention_cooccurrence
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 alias patterns and paragraph unit
Use surface patterns in Pride and Prejudice. Darcy is labelled as a surname rule and may refer to discussion or family mentions; rules do not establish unique character identity. Cooccurrence means both patterns occur in the same processed paragraph.
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))
patterns={"Elizabeth / Lizzy":r"\b(?:elizabeth|lizzy|eliza)\b","Jane":r"\bjane\b","Darcy surname":r"\bdarcy\b","Bingley surname":r"\bbingley\b","Mr Bennet":r"\bmr\.?\s+bennet\b","Mrs Bennet":r"\bmrs\.?\s+bennet\b","Lydia":r"\blydia\b","Wickham":r"\bwickham\b"}
paragraphs=[part for chapter in corpus[1342]["chapters"] for part in re.split(r"\n\s*\n",chapter["text"]) if part.strip()]
MINIMUM=3;network=mention_cooccurrence(paragraphs,patterns,MINIMUM);graph=network["graph"]
print("Processed paragraphs:",len(paragraphs),"; declared entities:",len(patterns),"; minimum shared paragraphs:",MINIMUM)
display(pd.DataFrame({"pattern":list(patterns),"regex":list(patterns.values()),"paragraph_presence":network["paragraph_presence"]}))
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 |
Processed paragraphs: 2096 ; declared entities: 8 ; minimum shared paragraphs: 3
| pattern | regex | paragraph_presence | |
|---|---|---|---|
| 0 | Elizabeth / Lizzy | \b(?:elizabeth|lizzy|eliza)\b | 636 |
| 1 | Jane | \bjane\b | 229 |
| 2 | Darcy surname | \bdarcy\b | 344 |
| 3 | Bingley surname | \bbingley\b | 249 |
| 4 | Mr Bennet | \bmr\.?\s+bennet\b | 82 |
| 5 | Mrs Bennet | \bmrs\.?\s+bennet\b | 136 |
| 6 | Lydia | \blydia\b | 135 |
| 7 | Wickham | \bwickham\b | 141 |
2. Read a thresholded cooccurrence graph
Edges count shared paragraphs under the declared rules. Layout is seeded; edge width encodes count. This graph is not a verified social relationship network or proof that two characters met. All declared nodes survive even with no edges.
position=nx.spring_layout(graph,seed=42,weight="paragraph_count")
fig,ax=plt.subplots(figsize=(9,6));width=[1+np.log1p(graph[a][b]["paragraph_count"]) for a,b in graph.edges()]
nx.draw_networkx(graph,pos=position,ax=ax,node_size=1500,font_size=8,width=width,node_color="#b4d4ed")
ax.set_title("Surface-pattern cooccurrence; threshold = 3 shared paragraphs");ax.axis("off");plt.tight_layout();plt.show()
3. Inspect the underlying count matrix
Matrix entries are shared-paragraph counts with an intentionally zero diagonal. The figure separates counted evidence from a drawn graph. A high count can reflect frequent mentions, not interpersonal importance.
fig,ax=plt.subplots(figsize=(8,6));im=ax.imshow(network["matrix"],cmap="Blues")
ax.set_xticks(range(8),network["labels"],rotation=55,ha="right",fontsize=8);ax.set_yticks(range(8),network["labels"],fontsize=8)
fig.colorbar(im,ax=ax,label="Shared processed paragraphs");ax.set_title("Declared alias-rule counts");plt.tight_layout();plt.show()
4. Compare thresholds without dropping declared nodes
Rebuild at 1, 3, 5 and 10 shared paragraphs. Thresholds change retained edges; the declared node set stays fixed. Centrality is a graph summary conditional on these choices, not a narrative truth.
sensitivity=[]
for minimum in [1,3,5,10]:
trial=mention_cooccurrence(paragraphs,patterns,minimum)["graph"]
sensitivity.append({"minimum_shared_paragraphs":minimum,"nodes":trial.number_of_nodes(),"edges":trial.number_of_edges(),"isolates":len(list(nx.isolates(trial)))})
sensitivity=pd.DataFrame(sensitivity)
fig,ax=plt.subplots(figsize=(7,4));ax.plot(sensitivity.minimum_shared_paragraphs,sensitivity.edges,"o-")
ax.set(xlabel="Minimum shared paragraphs",ylabel="Retained edges",title="Threshold-sensitive cooccurrence representation");plt.tight_layout();plt.show()
5. Reconcile counts and export inspectable rules
Check symmetric counts, all nodes and monotonic retained edges. Export patterns, counts and GraphML. Preserve isolated nodes and explain that surname ambiguity must be examined in context.
assert graph.number_of_edges()>0 and network["paragraph_presence"].sum()>0
assert np.array_equal(network["matrix"],network["matrix"].T) and np.all(np.diag(network["matrix"])==0)
assert set(graph)==set(patterns) and sensitivity.nodes.eq(8).all() and np.all(np.diff(sensitivity.edges)<=0)
OUTPUT_DIR.mkdir(parents=True,exist_ok=True);nx.write_graphml(graph,OUTPUT_DIR/"surface-mention-cooccurrence.graphml")
pd.DataFrame(network["matrix"],index=network["labels"],columns=network["labels"]).to_csv(OUTPUT_DIR/"shared-paragraph-counts.csv")
sensitivity.to_csv(OUTPUT_DIR/"threshold-sensitivity.csv",index=False)
(OUTPUT_DIR/"alias-rules.json").write_text(json.dumps(patterns,indent=2))
print("Count/node checks passed; interpretation remains conditional on alias rules")
Count/node checks passed; interpretation remains conditional on alias rules
Exercises and reference explanation
- Does surname cooccurrence identify a social relationship?
- Why preserve isolated declared nodes?
Reference explanation. Surface aliases can conflate family/discussion mentions. Isolates document declared scope; centrality depends on pattern, paragraph and threshold choices.
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/NumPy/Pandas/Matplotlib/NetworkX, compare thresholds 1, 3 and 10 with fixed nodes/layout.
Prompt 2
With the same packages, inspect ambiguous Darcy/Bingley contexts and export revised rules separately from the original.
Human acceptance. Counts stay symmetric, nodes remain eight, self edges are excluded and relationship/intent is not inferred automatically.
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
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.