检查 TF-IDF 与有符号章节投影
构造声明的章节词汇,计算归一化 TF-IDF,并解读未中心化 SVD,不将轴当作主题概率。
目标与运行方法
本课适合具备 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='hu05-text-projection'
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 tfidf_svd(documents,stopwords,min_df=3,max_df_fraction=.9,max_terms=500,components=3):
"""Exploratory TF-IDF + uncentered SVD; signed components are not topic probabilities."""
if len(documents)<3 or components<1 or max_terms<2:raise ValueError('Need documents and valid model sizes')
counts=[Counter(word for word in tokenize_words(text) if word not in stopwords) for text in documents]
frequency=Counter();totals=Counter()
for count in counts:frequency.update(count.keys());totals.update(count)
eligible=[word for word in frequency if min_df<=frequency[word]<=max_df_fraction*len(documents)]
terms=sorted(eligible,key=lambda word:(-totals[word],word))[:max_terms]
if len(terms)<components:raise ValueError('Vocabulary too small for the requested components')
matrix=np.array([[count.get(word,0) for word in terms] for count in counts],dtype=float)
idf=np.log((1+len(documents))/(1+np.count_nonzero(matrix,axis=0)))+1
matrix*=idf
norms=np.linalg.norm(matrix,axis=1)
if np.any(norms==0):raise ValueError('A document has no retained terms')
matrix/=norms[:,None]
left,singular,right=np.linalg.svd(matrix,full_matrices=False)
for j in range(min(components,len(singular))):
if right[j,np.argmax(abs(right[j]))]<0:right[j]*=-1;left[:,j]*=-1
return dict(terms=terms,tfidf=matrix,idf=idf,scores=left[:,:components]*singular[:components],components=right[:components],singular_values=singular)
print("Method ready: tfidf_svd")
Method ready: tfidf_svd
数据与模型边界
快照保留完整未修改来源电子书及原许可。章节分析使用《傲慢与偏见》61 章及《弗兰肯斯坦》24 章;前言和后者书信框架不在本次声明范围。英文字母分词是方法选择,不是语言无关定义。词频、表面模式共现和 SVD 轴不能独立证明作者意图、人物关系或情感。
分步分析
1. 定义章节及词汇规则
使用 85 编号章节作为探索文档。声明短停词表、文档频率范围及 500 词上限,定义表示方式。使用整部语料,不是测试分类器。
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))
stopwords=set("a an and are as at be been being but by can could did do does for from had has have he her hers him his i if in is it its me more most my no not of on one or our she so some than that the their them then there these they this those to too was we were what when where which who why will with would you your".split())
documents=chapter_table.text.tolist();model=tfidf_svd(documents,stopwords,min_df=3,max_df_fraction=.9,max_terms=500,components=3)
print("Documents:",len(documents),"; retained terms:",len(model["terms"]),"; TF-IDF row normalization: L2")
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 |
Documents: 85 ; retained terms: 500 ; TF-IDF row normalization: L2
2. 解读有符号词方向
TF-IDF 使用平滑 idf=log((1+n)/(1+df))+1 及文档 L2 归一化。SVD 未中心化,成分有符号系数且整体符号任意,不是主题概率分布。
component=1;loading=model["components"][component]
selected=np.argsort(abs(loading))[-15:];selected=selected[np.argsort(loading[selected])]
fig,ax=plt.subplots(figsize=(8,5));ax.barh([model["terms"][j] for j in selected],loading[selected])
ax.axvline(0,color="black",lw=1);ax.set(xlabel="Signed SVD coefficient",title="Second uncentered text direction; not topic probabilities");plt.tight_layout();plt.show()
3. 检查章节投影几何
点为章节文档,颜色为来源书籍。几何依赖词汇、归一化及截断,书籍分离不证明作者意图或有效体裁分类器。
fig,ax=plt.subplots(figsize=(8,5))
for book_id in corpus:
mask=chapter_table.book_id.eq(book_id).to_numpy();ax.scatter(model["scores"][mask,0],model["scores"][mask,1],label=corpus[book_id]["title"],s=25)
ax.set(xlabel="SVD1 score (uncentered)",ylabel="SVD2 score",title="Exploratory chapter geometry");ax.legend();plt.tight_layout();plt.show()
4. 解读原 TF-IDF 空间余弦相似度
行经 L2 归一化,内积给出原空间余弦相似度。矩阵仅描述声明词袋表示,不等于意义、事件或人物身份相同。
similarity=model["tfidf"]@model["tfidf"].T
fig,ax=plt.subplots(figsize=(7,6));im=ax.imshow(similarity,vmin=0,vmax=1,cmap="viridis")
ax.set(xlabel="Source chapter-document order",ylabel="Source chapter-document order",title="TF-IDF cosine similarity");fig.colorbar(im,ax=ax,label="Cosine similarity");plt.tight_layout();plt.show()
5. 核对归一化并导出表示
检查行单位范数、相似矩阵对称及保留方向正交。导出词汇与有符号权重,方便检视重要词并回到原段落。
assert np.allclose(np.linalg.norm(model["tfidf"],axis=1),1)
assert np.allclose(similarity,similarity.T) and np.allclose(np.diag(similarity),1)
assert np.allclose(model["components"]@model["components"].T,np.eye(3))
OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
pd.DataFrame(model["scores"],columns=["SVD1","SVD2","SVD3"]).assign(book=chapter_table.book,chapter=chapter_table.chapter).to_csv(OUTPUT_DIR/"chapter-projections.csv",index=False)
pd.DataFrame(model["components"].T,index=model["terms"],columns=["SVD1","SVD2","SVD3"]).to_csv(OUTPUT_DIR/"signed-term-directions.csv")
print("Normalization and orthogonality checked; exports:",OUTPUT_DIR)
Normalization and orthogonality checked; exports: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads/results/hu05-text-projection
自主练习与参考答案
- 为何有符号 SVD 不是主题概率?
- 词汇/归一化如何改几何?
参考解释。 SVD 为有符号线性方向且整体符号任意,选词及权重定义表示,不是普遍语义模型。
先保留当前 notebook 副本,再修改参数。把新图与原图一起比较,并记录改变了哪些输入、哪些计算和哪些结论。
可选的 AI 编程练习
提示词 1
仅用 re、Counter、NumPy、Pandas、Matplotlib 比较三百/五百词并保存参数。
提示词 2
同包导出正负重要词并细读来源章节。
人工验收。 行 L2 范数一,方向正交、相似对称,不称主题概率。
检查修改后的源代码,再手动运行受影响单元及后续校验。要求助手解释修改的目的、使用的包和数据来源,并用实际输出核对解释。
可选联网扩展
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 网页。