在语境中解读关键词频率
为每个完整词匹配提取语境,检查章节归一化数量,并回到来源段落。
目标与运行方法
本课适合具备 Python 基础的本科生。先阅读每一步的方法与图注,再修改参数。AI 练习为可选部分,不需要 AI 账号即可完成核心课程。数据写在 notebook metadata 中,不需要另下载数据文件。请保持 notebook 已保存到当前工作文件夹。
学习任务是理解本课的研究问题、执行透明的计算、校验结果,并说明结论的边界。
环境与参数
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
读取内嵌快照
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']
方法函数
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 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
数据与模型边界
快照保留完整未修改来源电子书及原许可。章节分析使用《傲慢与偏见》61 章及《弗兰肯斯坦》24 章;前言和后者书信框架不在本次声明范围。英文字母分词是方法选择,不是语言无关定义。词频、表面模式共现和 SVD 轴不能独立证明作者意图、人物关系或情感。
分步分析
1. 声明完整词查询和上下文宽度
对两书预先声明查询 pride 和 creature,不是自动主题标签。两侧各七个归一化词,保留来源章节及位置标识。
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. 归一化章节频率
纵轴为每千章节词的查询次数,不是原数量或情感强度。零仅表示此分词下无完整查询匹配,不代表段落没有相关主题。
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()
3. 检查语料位置分散
位置相对处理后的编号章节词序列,不包括已排除前言/框架书信。分散图说明查询出现位置,不确定叙述者如何使用该词。
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()
4. 阅读语境邻词而非自动情感
按声明短停词表统计提取窗口邻词。窗口重叠可能重复计邻词。柱图用于促成细读,不是情感分类器或作者意图结论。
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()
5. 核对完整匹配并导出所有语境
将章节总数与直接查询计数对照,按来源顺序导出全部匹配,方便检视反例并回到完整原书,而非只看挑选片段。
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
自主练习与参考答案
- 查询频率能确定情感吗?
- 窗口重叠意味着什么?
参考解释。 意义需细读,重叠可重复邻词,柱为窗口计数非独立语句。
先保留当前 notebook 副本,再修改参数。把新图与原图一起比较,并记录改变了哪些输入、哪些计算和哪些结论。
可选的 AI 编程练习
提示词 1
仅用 re、Counter、NumPy、Pandas、Matplotlib 加一完整词查询并导出全部。
提示词 2
同包比较两侧五/十词,不暗改分词。
人工验收。 总数一致,处理零匹配,展示语境有来源章节/顺序。
检查修改后的源代码,再手动运行受影响单元及后续校验。要求助手解释修改的目的、使用的包和数据来源,并用实际输出核对解释。
可选联网扩展
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
来源、快照与下一步
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.
下一步。 在保留本课的数据检查、参数记录和结果校验之后,将相同方法用于自己的公开或已获授权数据。记录研究设计与方法限制,不把示例结果当成新实验的验证。完整数据许可与生成说明见 cookbook 网页。