做内容分析的朋友大概都遇到过类似场景:想认真讨论一个在粉丝圈里流传很久的剧情解读,比如“托尼·斯塔克早就预见了自己的结局”这一类说法,翻遍帖子发现全是主观感受,谁也说服不了谁。这类讨论如果想做得有点说服力,其实可以换成工程思路来处理——把剧情拆成结构化的事件和证据,交给代码去排序、打分、画图。下面这套流程从数据结构设计一直讲到可视化输出,包含完整可运行代码和踩坑记录,适合有一定 Python 基础、想做文本或剧情数据分析的同学参考,也适合把它当作 pandas 与 networkx 的练手项目。
1. 把“隐藏剧情理论”翻译成可计算的数据模型
1.1 为什么剧情理论天生难以验证
粉丝理论最大的问题不在于观点本身,而在于它的论证方式是“印象式”的。支持者会记住几个印象深刻的镜头,反对者会记住另外几个,双方都没有覆盖全集,讨论就变成了各自举例子。这种做法在技术上等价于“抽样偏差 + 无基线”,结论当然不可靠。
如果换一个角度,把整条故事线看成一个数据集,把每一次关键行为看成一条记录,把每一条“证据”看成带权重的标注,那么“这个理论成立吗”就转化成了几个可以动手算的问题:相关事件在时间线上是否集中在某个阶段?关键角色的关系强度是否在某个节点后发生跃迁?被引用的证据条目是否真的存在,权重是否被夸大?这些问题都有确定的答案,而不是靠嗓门大小决定。
需要提前说清楚的是,本文不会去论证这个理论本身是真是假,也不引用任何官方设定。所有的事件条目都是演示用的简化示意数据,目的只有一个:把一套“从零散叙述到可查询结构”的方法跑通。你把它换成别的剧、换成产品需求分析、换成事故复盘时间线,代码几乎不用改。
1.2 三层数据结构:事件、角色、证据
要让人工叙述变成机器可读的东西,最直接的办法是拆成三张表,每张表负责一件事。
第一层是事件表,它是整个模型的主干。每一行代表剧情里一个可以定位的时间点上的行为或状态变化,必须带一个单调递增的序号作为时间线索引,这样才能排序、比较、切片。事件表里还要记录参与角色、发生地点和一段简短描述,描述是后面做关键词匹配的输入。
第二层是证据表,它记录“有人拿这条材料来支持某个观点”。证据表的关键字段是权重,用来表示这条材料对假设的支持强度。权重不是客观真理,而是标注者的主观判断,这一点必须在代码和文档里写明白,否则后面很容易把“打分结果”误当成“客观结论”。
第三层是角色维度。角色不必单独建一张表,但必须能被拆开、能被聚合。最省事的做法是在事件表里用分隔符存一个角色列表,加载时再拆成 Python 列表,需要统计时用 explode 展开。这样做的好处是数据录入简单,坏处是角色名必须保持一致,后面会专门讲怎么防这个坑。
三层结构听起来简单,但它带来的变化是实质性的。原来只能定性地说“这条线埋得很早”,现在能算出具体在第几个事件位、属于哪个阶段、有多少条证据支撑。可计算,才可讨论。
1.3 整体技术方案与影响范围
整条链路可以概括成五个步骤,按顺序是:读取并校验 CSV、按时间线排序、按关键词和证据权重计算相关度得分、构建角色共现关系图、输出表格与图片。串行执行,没有外部服务依赖,也没有网络请求。
这里有一个容易被忽略的点:每一步的产物都是一个明确的数据结构,而不是直接往控制台打印。加载器返回 DataFrame,打分器返回排好序的 DataFrame,关系图模块返回一个 networkx.Graph 对象加一张指标表。这样做的影响范围是可控的——如果你想换一种打分公式,只需要改打分层,加载和可视化完全不用动;如果想换一种绘图方式,也不会牵连到数据校验逻辑。
反过来说,如果一开始就把读文件、算分、画图全塞进一个两百行的脚本里,后面想加一个字段就得通读全文,任何改动都可能是全局风险。这也是我在讲实战之前,先花篇幅讲分层的原因:结构决定了后续所有改动的成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目结构
2.1 运行环境与依赖说明
这套代码只依赖三个第三方库:pandas 负责表格处理,networkx 负责图结构,matplotlib 负责出图。Python 版本建议 3.8 及以上,主要是为了用上类型注解和 pathlib 的现代写法。具体的小版本号本文不锁死,因为这三个库的 API 在近几年的版本里都保持得比较稳定,但如果你在极老的版本上运行,DataFrame.explode 的行为可能略有差异,遇到问题优先升级 pandas。
需要提前说明的是,本文不写死任何版本号,也不保证某个特定版本组合一定可用。你在实际项目里应该用 requirements.txt 或 pyproject.toml 把版本固定下来,尤其是在团队协作或要跑很久的分析任务里。版本漂移导致的结果不一致,是数据类项目里最隐蔽的问题之一。
关于运行环境还有一个现实问题:如果你在服务器上跑,没有图形界面,matplotlib 直接用默认后端可能会报错或者卡住。解决办法是在导入 pyplot 之前显式设置 Agg 后端,这个细节在 4.5 节会给出代码。这个坑我见过太多次,早设早省事。
2.2 项目目录结构
目录结构不必复杂,但要保证“数据、代码、输出”三者分离。数据是输入,不应该被代码修改;输出是产物,应该能被整体删除后重新生成。按照这个原则,我习惯这样组织:
text复制story-analysis/
├── data/
│ ├── events.csv
│ └── evidence.csv
├── src/
│ ├── config.py
│ ├── loader.py
│ ├── analysis.py
│ ├── graph.py
│ └── main.py
├── output/
└── README.md
data 目录只放原始输入,任何时候都不要让脚本往里写东西。output 目录存放生成的 CSV 和 PNG,可以随时清空。src 里的每个文件负责一个明确职责,main.py 只做流程编排,不写业务逻辑。这个约定的价值在于,当你半年后回来看这个项目,能一眼看出数据从哪来、往哪去。
README.md 不要偷懒。至少写清楚三件事:数据表各字段的含义、运行命令、以及“示例数据仅为演示”这句声明。第三点尤其重要,避免别人把你的示意数据当成事实引用。
2.3 依赖安装与最小验证
安装命令很常规,用虚拟环境隔离:
bash复制python -m venv .venv
source .venv/bin/activate # Windows 下用 .venv\Scripts\activate
pip install pandas networkx matplotlib
安装完之后不要急着写业务代码,先做一次最小验证,确认三个库都能正常导入、版本信息能打印出来:
python复制# 文件路径:src/check_env.py
import pandas as pd
import networkx as nx
import matplotlib
print("pandas:", pd.__version__)
print("networkx:", nx.__version__)
print("matplotlib:", matplotlib.__version__)
g = nx.Graph()
g.add_edge("A", "B", weight=2)
print("nodes:", list(g.nodes))
print("edge weight:", g["A"]["B"]["weight"])
预期输出会打印出三个库的版本号,以及 nodes: ['A', 'B'] 和 edge weight: 2。如果这一步就报错,说明环境没装好,别往下走。很多“后面莫名其妙失败”的问题,根子都在这一步没验证。
3. 数据建模:把剧情拆成三张表
3.1 事件表设计
事件表是整个模型的骨架,字段设计要遵循一个原则:能被程序直接用来排序和过滤的字段,不要藏在描述文本里。基于这个原则,我设计了七个字段。
event_id 是主键,建议用有意义的短编号,比如 E01、E02,比纯数字更容易在日志里定位。timeline_index 是整数,表示在时间线上的位置,必须唯一且单调递增,后续所有排序都依赖它。phase 是阶段标签,用来做分组统计,取值要控制在一个封闭集合内,不要随手写“前期”“早期”“开始阶段”这种同义不同形的值。
title 是一句话标题,方便人看。characters 存参与角色,用英文逗号分隔。location 存地点,主要用于后续扩展。description 是一段自由文本,关键词匹配就发生在这个字段上,所以写的时候要把关键动作词带上,不要只写“发生了一件事”。
这里有个设计取舍值得说明:为什么阶段不用时间戳而用枚举?因为剧情时间线往往不是真实时间,可能是跳跃的、倒叙的,用真实日期反而会误导。用有序枚举既表达了先后关系,又不会让人误以为那是日历时间。如果你的场景确实有真实时间戳,那就换成 datetime 字段,但排序逻辑要相应调整。
3.2 证据表设计
证据表回答的是“哪些材料被用来支持这个假设”。字段上我保留五个:evidence_id 主键、event_id 外键指向事件、keyword 记录这条证据对应的关键词、weight 是浮点权重、note 写一句人话说明。
权重的取值范围建议统一在 0 到 2 之间,并且约定一个基准:1.0 表示“中等强度支持”,大于 1 表示“强支持”,小于 1 表示“弱支持”。统一量纲非常重要,否则不同标注者按自己的习惯打 10 分、100 分,聚合出来的结果就没法比较了。这也是团队标注任务里最常见的分歧来源。
note 字段看起来可有可无,其实很关键。当一条证据的权重被质疑时,note 是唯一能还原判断依据的东西。我在实际项目里的经验是,宁可少录几条证据,也要保证每条都写了 note。没有说明的权重,过两周连自己都不知道当时为什么这么打。
3.3 字段约定与常见坑
角色列表用逗号分隔,这个方案最大的风险是分隔符不统一。有人用中文逗号,有人用英文逗号,还有人用顿号。解决办法是在加载阶段做一次归一化,把所有可能的分隔符统一替换成英文逗号再切分,代码在 4.2 节。
另一个坑是角色名不一致。同一个角色可能写成全名、写成代号、写成简称。这个在数据量小的时候可以靠人工统一,数据量大了就得建一张别名映射表。本文的示例数据量很小,所以采用最直接的做法:录入时统一用同一个名字,并在 README 里写清楚命名规范。
还有一个隐蔽的坑是空值。characters 字段如果为空,split 之后会得到空字符串而不是空列表,后面 explode 会产生一行角色名为空字符串的记录,污染统计结果。所以加载时要做 fillna(""),切分时过滤掉空串。这类问题不会报错,只会让结果悄悄偏掉,属于最难查的一类。
4. 核心代码拆解
4.1 配置与常量集中管理
把所有路径、关键词、阶段顺序这些常量集中到一个文件里,是我在数据类项目里坚持的第一条规矩。原因很实际:这些东西散落在各处时,改一个关键词要全局搜索,很容易漏掉某处硬编码。
python复制# 文件路径:src/config.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
OUTPUT_DIR = BASE_DIR / "output"
EVENT_FILE = DATA_DIR / "events.csv"
EVIDENCE_FILE = DATA_DIR / "evidence.csv"
# 假设关键词及其基础权重,权重仅表示"与假设的相关程度",不代表客观事实
HYPOTHESIS_KEYWORDS = {
"预言": 1.0,
"备份": 1.2,
"后手": 0.8,
"遗产": 0.6,
"安排": 1.0,
"保险": 0.9,
}
# 阶段顺序,使用有序枚举避免字符串排序错乱
PHASE_ORDER = ["早期", "转折", "危机", "终局"]
这里用 Path(__file__).resolve().parent.parent 定位项目根目录,而不是用相对路径 ./data。区别在于,相对路径依赖你从哪个目录执行脚本,从 src 里跑和从项目根目录跑结果不一样;用 __file__ 推算出来的绝对路径则不受工作目录影响。这个细节在定时任务和 CI 环境里尤其重要。
关键词字典的注释必须写清楚权重含义。我在评审别人的代码时见过太多次把“权重”当成“重要性”或“置信度”的情况,命名相同但语义完全不同,最后指标算出来没人敢用。
4.2 数据加载与字段校验
加载层的职责只有两个:读进来,检查字段是否齐全。不要在加载层做业务计算,也不要在加载层吞掉异常。
python复制# 文件路径:src/loader.py
import pandas as pd
from config import EVENT_FILE, EVIDENCE_FILE, PHASE_ORDER
REQUIRED_EVENT_COLUMNS = [
"event_id", "timeline_index", "phase",
"title", "characters", "location", "description",
]
REQUIRED_EVIDENCE_COLUMNS = [
"evidence_id", "event_id", "keyword", "weight", "note",
]
def _check_columns(df, required, table_name):
missing = [c for c in required if c not in df.columns]
if missing:
raise ValueError(f"{table_name} 缺少必需字段:{missing}")
def split_characters(value):
if not isinstance(value, str) or not value.strip():
return []
normalized = (
value.replace(",", ",")
.replace("、", ",")
.replace(";", ",")
)
return [item.strip() for item in normalized.split(",") if item.strip()]
def load_events(path=EVENT_FILE):
df = pd.read_csv(path, encoding="utf-8")
_check_columns(df, REQUIRED_EVENT_COLUMNS, "events")
df["timeline_index"] = df["timeline_index"].astype(int)
df["phase"] = pd.Categorical(
df["phase"], categories=PHASE_ORDER, ordered=True
)
df["characters"] = df["characters"].fillna("").astype(str)
df["description"] = df["description"].fillna("").astype(str)
df = df.sort_values("timeline_index").reset_index(drop=True)
duplicated = df["event_id"].duplicated()
if duplicated.any():
bad_ids = df.loc[duplicated, "event_id"].tolist()
raise ValueError(f"存在重复的 event_id:{bad_ids}")
return df
def load_evidence(path=EVIDENCE_FILE):
df = pd.read_csv(path, encoding="utf-8")
_check_columns(df, REQUIRED_EVIDENCE_COLUMNS, "evidence")
df["weight"] = df["weight"].astype(float)
return df
split_characters 里的归一化处理,看起来只是三行 replace,但它解决的是真实痛点。中文输入法下打逗号,默认多半是中文逗号,而 CSV 里为了通用性又常常用英文逗号,不统一就会出现“角色列表只有一项”的假象,而且不会报错。
pd.Categorical 这一步也值得说。把 phase 转成有序分类后,做 groupby 时会按 PHASE_ORDER 的顺序输出,而不是按拼音或字符编码排序。如果不做这一步,“早期”“转折”“危机”“终局”按字符串排序出来的顺序可能是乱的,图表横轴就会看起来毫无逻辑。
4.3 关键词打分与证据加权
打分层把两种信号加在一起:描述文本里的关键词命中,和证据表里人工标注的权重。前者是自动的,后者是手工的,两者相加得到一个总分。
python复制# 文件路径:src/analysis.py
import pandas as pd
from config import HYPOTHESIS_KEYWORDS
from loader import split_characters
def attach_character_list(events: pd.DataFrame) -> pd.DataFrame:
df = events.copy()
df["character_list"] = df["characters"].apply(split_characters)
return df
def keyword_score(text: str) -> float:
if not isinstance(text, str):
return 0.0
score = 0.0
for word, weight in HYPOTHESIS_KEYWORDS.items():
if word in text:
score += weight
return score
def score_events(events: pd.DataFrame, evidence: pd.DataFrame) -> pd.DataFrame:
df = events.copy()
df["text_score"] = df["description"].apply(keyword_score)
evidence_sum = (
evidence.groupby("event_id")["weight"]
.sum()
.rename("evidence_score")
)
df = df.merge(evidence_sum, left_on="event_id", right_index=True, how="left")
df["evidence_score"] = df["evidence_score"].fillna(0.0)
df["total_score"] = df["text_score"] + df["evidence_score"]
return df.sort_values(
["total_score", "timeline_index"], ascending=[False, True]
).reset_index(drop=True)
merge 那一步用了 left_on="event_id", right_index=True。这个写法的语义是“左表按列匹配,右表按索引匹配”,因为 groupby 之后 event_id 变成了索引。如果误写成 right_on,pandas 会因为找不到列而报错;如果两边都写 on,也会报错。这是初学者最容易卡住的地方之一,记住“groupby 的结果,分组键在索引上”就通了。
fillna(0.0) 也不能省。有些事件在证据表里完全没有对应记录,merge 之后 evidence_score 是 NaN,直接相加会得到 NaN,排序时 NaN 会被排到最后甚至影响结果。显式填 0 是最稳妥的做法。
阶段汇总函数用来回答“各阶段的角色出场分布”,它依赖 explode:
python复制def build_phase_summary(events: pd.DataFrame) -> pd.DataFrame:
df = attach_character_list(events)
exploded = df.explode("character_list")
exploded = exploded[exploded["character_list"].notna()]
summary = (
exploded.groupby(["phase", "character_list"], observed=True)
.size()
.reset_index(name="event_count")
.sort_values(["phase", "event_count"], ascending=[True, False])
)
return summary
observed=True 这个参数是配合前面的 Categorical 用的。不加的话,即使某个阶段数据里根本没有出现,groupby 也会把它列出来,计数为 0,图上就会多出空荡荡的一栏。加上之后只统计实际出现的分类。
4.4 共现关系图与中心度指标
关系图回答的是另一个问题:哪些角色经常一起出现?经常一起出现,往往意味着剧情上强绑定。这个信息用表格看不直观,用图一眼就能看出来。
python复制# 文件路径:src/graph.py
import itertools
import networkx as nx
import pandas as pd
from loader import split_characters
def build_cooccurrence_graph(events: pd.DataFrame) -> nx.Graph:
g = nx.Graph()
for _, row in events.iterrows():
names = split_characters(row.get("characters", ""))
for name in names:
if name not in g:
g.add_node(name, event_count=0)
g.nodes[name]["event_count"] += 1
for a, b in itertools.combinations(sorted(names), 2):
if g.has_edge(a, b):
g[a][b]["weight"] += 1
else:
g.add_edge(a, b, weight=1)
return g
def graph_metrics(g: nx.Graph) -> pd.DataFrame:
weighted_degree = dict(g.degree(weight="weight"))
closeness = nx.closeness_centrality(g)
betweenness = nx.betweenness_centrality(g)
rows = []
for node in g.nodes:
rows.append({
"character": node,
"event_count": g.nodes[node].get("event_count", 0),
"weighted_degree": weighted_degree.get(node, 0),
"closeness": round(closeness.get(node, 0.0), 4),
"betweenness": round(betweenness.get(node, 0.0), 4),
})
return pd.DataFrame(rows).sort_values(
"weighted_degree", ascending=False
).reset_index(drop=True)
itertools.combinations(sorted(names), 2) 里的 sorted 不是多余的。如果两个事件里同一个角色对出现顺序不同(一次是“甲,乙”,一次是“乙,甲”),不排序就会生成两条方向不同的边,在无向图里虽然会被合并,但 add_edge 时的属性覆盖逻辑容易出问题。排序之后可以保证同一对角色永远生成同一个键。
weighted_degree 用的是加权度,比单纯数邻居数量更能反映“绑定强度”。betweenness 衡量的是“桥梁作用”,值高的角色往往是把不同小圈子连起来的关键人物。这两个指标结合看,比只看一个更有信息量。
4.5 图表输出与中文字体
绘图部分最容易在服务器上翻车,所以要在导入 pyplot 之前切后端。
python复制def draw_graph(g: nx.Graph, output_path) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = [
"SimHei", "Microsoft YaHei", "Noto Sans CJK SC", "DejaVu Sans"
]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(10, 8))
pos = nx.spring_layout(g, seed=42, k=0.6, weight="weight")
edge_widths = [0.8 + 0.6 * g[u][v]["weight"] for u, v in g.edges]
node_sizes = [300 + 120 * g.nodes[n].get("event_count", 1) for n in g.nodes]
nx.draw_networkx_edges(
g, pos, width=edge_widths, alpha=0.5, ax=ax
)
nx.draw_networkx_nodes(
g, pos, node_size=node_sizes, node_color="#4C8BF5",
alpha=0.85, ax=ax
)
nx.draw_networkx_labels(g, pos, font_size=9, ax=ax)
ax.set_title("角色共现关系图")
ax.axis("off")
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
中文字体那一段是必须的。font.sans-serif 里列了四个候选,按顺序尝试,第一个找不到就用第二个。Linux 服务器上通常只有 Noto Sans CJK SC,Windows 上有 SimHei 和 Microsoft YaHei,macOS 上可能需要换成 PingFang SC。全部找不到时,中文会渲染成方块,图还是能出来,但一看就知道字体没配好。
matplotlib.use("Agg") 必须在 import matplotlib.pyplot 之前调用,顺序反了就没效果。plt.close(fig) 也别漏,批量生成图片时不关闭 figure 会持续占用内存,跑几十张之后可能直接 OOM。
5. 完整实战:跑通一次理论验证
5.1 准备示例数据
先准备事件表。这里再次强调,下面每一条都是为了演示流程而构造的简化条目,不追求与任何作品的实际设定一致,也不应被当作事实引用。
csv复制event_id,timeline_index,phase,title,characters,location,description
E01,1,早期,首次公开表态,托尼·斯塔克,发布会,"主角在公开场合做出高调承诺,语气轻松"
E02,3,转折,第一次危机应对,托尼·斯塔克,实验室,"设备失效后临时改造方案,事后留下一份备份记录"
E03,5,转折,建立长期预案,托尼·斯塔克,基地,"开始系统化记录风险点,并安排自动化响应流程"
E04,7,危机,关键装备交接,托尼·斯塔克,基地,"把部分控制权限交给助手,同时设置保险机制"
E05,9,危机,团队分歧,托尼·斯塔克,会议室,"与同伴就风险优先级产生分歧,坚持保留后手"
E06,11,危机,预案被触发,托尼·斯塔克,城市,"此前布置的自动化方案在无人工干预下启动"
E07,13,终局,遗留信息被发现,托尼·斯塔克,基地,"一段提前录制的信息被检索出来,内容涉及安排"
E08,15,终局,结果确认,托尼·斯塔克,基地,"最终结果与此前记录的多项预案存在时间上的对应关系"
证据表相对精简,只保留被明确引用过的几条:
csv复制evidence_id,event_id,keyword,weight,note
V01,E02,备份,1.2,描述了故障后保留副本的行为
V02,E03,安排,1.0,提到系统化记录与自动响应
V03,E04,保险,0.9,权限交接时设置保护机制
V04,E05,后手,0.8,分歧中坚持保留后备方案
V05,E07,预言,1.1,提前录制信息,时间上早于事件发生
这两份数据加起来不到二十行,但已经足够跑通从加载、排序、打分到出图的完整链路。实测下来,用真实数据替换时,最容易出问题的不是代码,而是字段填得不规范,所以先用小数据把流程跑顺,再逐步扩充。
5.2 编写入口脚本
入口脚本只负责编排,不写计算逻辑。这样做的好处是,将来如果要把流程搬到 notebook 里,直接把函数一个个调出来就行。
python复制# 文件路径:src/main.py
from pathlib import Path
from config import OUTPUT_DIR
from graph import build_cooccurrence_graph, draw_graph, graph_metrics
from loader import load_events, load_evidence
from analysis import build_phase_summary, score_events
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
events = load_events()
evidence = load_evidence()
print(f"[1/4] 载入事件 {len(events)} 条,证据 {len(evidence)} 条")
scored = score_events(events, evidence)
scored_path = OUTPUT_DIR / "scored_events.csv"
scored.to_csv(scored_path, index=False, encoding="utf-8-sig")
print(f"[2/4] 打分完成,结果写入 {scored_path}")
phase_summary = build_phase_summary(events)
summary_path = OUTPUT_DIR / "phase_summary.csv"
phase_summary.to_csv(summary_path, index=False, encoding="utf-8-sig")
print(f"[3/4] 阶段汇总完成,结果写入 {summary_path}")
graph = build_cooccurrence_graph(events)
metrics = graph_metrics(graph)
metrics_path = OUTPUT_DIR / "graph_metrics.csv"
metrics.to_csv(metrics_path, index=False, encoding="utf-8-sig")
image_path = OUTPUT_DIR / "character_graph.png"
draw_graph(graph, image_path)
print(f"[4/4] 关系图完成,结果写入 {image_path}")
top3 = scored.head(3)[["event_id", "title", "total_score"]]
print("\n相关度最高的三个事件:")
print(top3.to_string(index=False))
if __name__ == "__main__":
main()
这里用 utf-8-sig 而不是 utf-8 写 CSV,是有意为之。用 Excel 直接打开纯 UTF-8 编码的 CSV,中文经常显示成乱码,加上 BOM 之后 Excel 能正确识别编码。代价是有些程序读文件时会看到开头多一个不可见字符,如果你的下游是 pandas,read_csv 默认能处理,但如果是自己写的解析器就要注意。这个取舍按你的使用场景决定。
5.3 运行与预期输出
从项目根目录执行:
bash复制cd story-analysis
python src/main.py
如果你的代码结构和本文一致,会看到类似下面的输出(数值取决于你的示例数据):
text复制[1/4] 载入事件 8 条,证据 5 条
[2/4] 打分完成,结果写入 /path/to/story-analysis/output/scored_events.csv
[3/4] 阶段汇总完成,结果写入 /path/to/story-analysis/output/phase_summary.csv
[4/4] 关系图完成,结果写入 /path/to/story-analysis/output/character_graph.png
相关度最高的三个事件:
event_id title total_score
E03 建立长期预案 2.0
E02 第一次危机应对 1.2
E04 关键装备交接 0.9
同时 output 目录下会多出四个文件:scored_events.csv、phase_summary.csv、graph_metrics.csv 和 character_graph.png。如果只生成了前三个,说明绘图那一步出问题了,优先去看报错信息里的字体和 backend 相关提示。
5.4 结果怎么解读
跑出结果只是第一步,更重要的是知道结果能说明什么、不能说明什么。以下几点是我在实际使用中总结的判读原则。
第一,total_score 排序反映的是“事件与预设关键词及人工标注的匹配程度”,不是“事件在剧情中的重要性”。关键词是人为选的,权重是人为打的,换一组关键词排序就会变。看到排序结果的第一反应应该是“我的关键词选得合理吗”,而不是“这就是结论”。
第二,phase_summary 用来看分布。如果高分事件集中在“危机”和“终局”阶段,那只能说明“标注者倾向于在后期找证据”,这是一个关于标注行为的结论,不是关于剧情的结论。这个区分很重要,混淆了就会得出似是而非的推断。
第三,graph_metrics 里 weighted_degree 最高的角色,通常是叙事中心,但这在很大程度上取决于你在 characters 字段里填了谁。如果某个配角在数据里被遗漏,整个图的中心度分布就会偏。所以这张图的正确用法是“发现数据录入的疏漏”,而不是“证明某个角色最重要”。
把这三点想清楚,这套工具才是有用的。它帮你把零散材料组织成可检查的结构,把主观印象暴露成可讨论的参数,但不替你下判断。
6. 常见问题与排查思路
6.1 编码与字段类问题
UnicodeDecodeError: 'utf-8' codec can't decode byte 是最常见的报错。原因是 CSV 文件实际编码不是 UTF-8,多半是 Windows 下用 Excel 另存的 GBK 文件。排查步骤很直接:用文本编辑器打开文件,看能不能正常显示中文,或者用 Python 试探性读取几个字节。解决方案是在 read_csv 里显式指定 encoding="gbk",或者更彻底一点,把源文件另存为 UTF-8。
另一个高频问题是 KeyError,提示某列不存在。这时候不要急着改代码,先打印 df.columns 看看实际列名。常见原因是表头里有空格、有不可见字符,或者列名大小写不一致。我遇到过表头写了 Event_ID 而代码里写 event_id 的情况,肉眼几乎看不出来。
还有一种情况是数值列被读成了字符串。比如 weight 列里混进了空值或者写了“高”这样的中文,pandas 会把整列推断为 object 类型,astype(float) 直接抛 ValueError。解决办法是用 pd.to_numeric(df["weight"], errors="coerce") 先转换,再把 NaN 的行打印出来人工修正。errors="coerce" 会把无法转换的值变成 NaN 而不是报错,方便定位问题行。
6.2 依赖与运行环境问题
ModuleNotFoundError: No module named 'config' 这类报错,八成是因为执行目录不对或者没有把 src 加入模块搜索路径。本文的写法是从项目根目录执行 python src/main.py,此时 Python 会把 src 目录本身加入 sys.path,from config import ... 才能生效。如果你改成 python -m src.main,导入路径就要相应调整成 from src.config import ...,否则会找不到模块。
ImportError: cannot import name 'draw' from 'networkx' 这类问题属于版本不匹配。networkx 在不同大版本之间调整过部分 API 的位置,遇到这种情况先查你安装的版本,再看对应版本的文档,不要照抄网上随便一篇文章的写法。
绘图相关的问题集中在两点:一是中文显示成方块,二是保存的图片是空白。前者是字体配置问题,参考 4.5 节的候选列表;后者通常是 savefig 之后忘记 plt.close,或者用了交互式后端但没显示窗口。在无界面环境里,务必在导入 pyplot 前设置 Agg 后端。
6.3 排查清单
把上面的问题整理成一张表,出问题时从上往下逐条对照,能覆盖绝大多数场景。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 读取 CSV 报编码错误 | 文件实际是 GBK 编码 | 显式指定 encoding,或另存为 UTF-8 |
| 提示某列不存在 | 表头有空格、大小写不一致、含不可见字符 | 打印 df.columns 逐个核对 |
| 数值列转换失败 | 列中混入非数字内容或空串 | 用 pd.to_numeric(..., errors="coerce") 定位 |
| 找不到自定义模块 | 执行目录错误或 sys.path 未包含 src |
从项目根目录执行,或统一改成包内相对导入 |
| 角色统计多出空记录 | characters 为空时切分出空串 |
fillna("") 并在切分时过滤空值 |
| 阶段分组顺序错乱 | 未使用有序分类 | 用 pd.Categorical 指定 categories 和 ordered=True |
| 图片中文变方块 | 系统缺少中文字体 | 在 font.sans-serif 中补充本机可用字体 |
| 生成的图片空白 | 后端选择错误或未正确关闭 figure | 导入前设置 Agg,保存后 plt.close(fig) |
排序结果出现 NaN |
证据合并后未填充 | 合并后用 fillna(0.0) |
event_id 重复导致合并膨胀 |
主键未做唯一性检查 | 加载阶段主动检测重复并抛错 |
7. 最佳实践与工程建议
7.1 数据可复现的三条底线
数据类项目最怕“跑一次一个结果”。要保证可复现,我建议守住三条底线。
第一条,原始数据只读。任何时候都不要让分析脚本修改 data 目录下的文件,所有中间结果写进 output。这样即使代码写错了,重跑一次就能回到原始状态,不需要去找备份。
第二条,随机性必须固定种子。本文的 spring_layout(g, seed=42) 就是为此,不固定种子的话每次画的图布局都不一样,截图放进文档里对不上。如果你的流程里还有采样、打乱等操作,同样要设种子。
第三条,输出文件名带版本或时间戳。当你要对比两次分析结果时,scored_events_v2.csv 比覆盖写 scored_events.csv 有用得多。小项目里手动改名字就够了,大项目里可以加一个时间戳后缀。
这三条单独看都很朴素,但真正坚持下来的项目,出问题时排查成本会低一个数量级。
7.2 代码分层与扩展方向
本文的分层是“加载 → 分析 → 图 → 入口”四层。这个划分不是唯一解,但它的边界比较清晰,扩展时不容易打结。想加一种新的打分方式,只需要在 analysis.py 里加一个函数,在 main.py 里多调一次;想换一种可视化,只需要在 graph.py 里加一个绘图函数。
有几个方向值得继续做。一是把 HYPOTHESIS_KEYWORDS 从硬编码改成配置文件,这样不用改代码就能换关键词,适合需要频繁调参的场景。二是给证据表加一列 source,记录证据来源,方便追溯。三是把打分函数抽象成可插拔的接口,允许同时跑多套打分规则并对比结果。
需要提醒的是,扩展之前先想清楚“这个扩展解决什么问题”。我见过太多项目在只有几十条数据的时候就开始搭插件框架,结果框架本身的复杂度远超业务逻辑,最后没人愿意维护。数据量小的时候,最简单直接的写法就是最好的写法。
7.3 方法迁移与影响范围
这套方法真正的价值不在剧情分析上,而在它的可迁移性。任何“需要把零散叙述整理成可讨论结构”的场景,都可以套用这个模式。
比如线上故障复盘:事件表就是时间线,角色表就是参与方或系统模块,证据表就是日志片段和监控截图。跑完之后你会得到一份按相关度排序的关键节点列表,以及一张模块共现关系图,能快速看出哪些模块总是同时出问题。
比如需求变更分析:事件表是变更记录,角色是需求方和开发方,证据是会议纪要摘录。打分之后能看出变更集中在哪个阶段爆发,关系图能看出沟通瓶颈在哪。
迁移的时候要注意影响范围。数据模型的字段名可以沿用,但语义会变,这时候注释和文档必须同步更新,否则后来的人会按字面意思误解。另外,关键词权重这类主观参数在新场景下需要重新标定,不能直接把旧值搬过去。这一点我在实际项目里吃过亏,改场景不改权重,算出来的排序完全没有参考价值。
7.4 不要过度解读结果
最后说一个非技术但很重要的问题。这类工具的输出很容易被当成“证据”,尤其是在讨论气氛热烈的时候,一张排序表看起来比一堆截图有说服力得多。但它的本质是“把你输入的东西按你定的规则重新排列”,输入里没有的信息,输出里也不会有。
所以在使用时,最好同时公开三样东西:原始数据表、关键词和权重设置、打分公式。这三样都公开,别人才能复现你的结果,也才能指出你的参数设置有问题。只给一张结论图,无论图多漂亮,都不构成有效论证。
另外,total_score 这种合成分数尽量少用绝对化表述。说“在这套关键词设置下,E03 的相关度最高”是准确的;说“E03 证明了某个结论”就越界了。工具能帮你把讨论拉回到可检验的层面,但不能替你做判断。
8. 下一步可以怎么练
如果你想把这套东西真正练熟,建议按三个层次推进。第一层是把本文代码完整抄一遍跑通,一个字符都不要省,包括那些看起来多余的 fillna 和 observed=True,先跑通再谈优化。第二层是替换数据,找一段你熟悉的材料,按字段规范录入二十条左右事件和十条证据,看看结果是否符合你的直觉,不符合就回头调关键词权重。第三层是改结构,比如把关键词匹配换成按阶段加权,或者给关系图加上按阶段分层的能力。
第三层最容易卡住,卡住的时候不要硬改,先写个最小验证脚本,只测那一个新功能,确认能跑通再合并回主流程。这个习惯能帮你把改动的影响范围控制在最小,也是我在多次返工之后养成的。
如果你在跑的过程中遇到报错,优先按第 6 节那张表逐条对照,大部分问题都在里面。代码不用追求一次写对,能快速定位问题、能稳定复现结果,就已经比大多数人做得好了。
