ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python的东野圭吾小说集文本挖掘课设全流程解析

基于Python的东野圭吾小说集文本挖掘课设全流程解析 简介一份面向高校Python数据挖掘课程设计、期末大作业与毕业设计的完整项目资源项目以日本作家东野圭吾的小说集为语料围绕文本挖掘展开包含源码、文档说明及详实代码注释结构清晰部署门槛低适合作为入门级到进阶级的数据挖掘实践参考。压缩包大小约25.78MB内容覆盖数据读取、文本预处理、分词、词频统计、可视化等常见分析环节并配有说明文档便于读者快速理解项目逻辑与复现实验。目前已有519人学习/下载项目经严格调试可稳定运行系统功能完整、界面直观特别适合需要完成相似课程任务或希望借鉴高分项目框架的学习者。下载后可直接部署使用也可依照注释修改语料与参数迁移到其他文本数据挖掘场景实用价值较高。1. 这份东野圭吾文本挖掘课设到底值不值得复刻先看清题目长什么样答辩现场最常被问到的问题不是“你的分类准确率是多少”而是“你从东野圭吾的小说里挖出了什么别人没看到的东西”。所谓“基于 python 数据挖掘大作业-东野圭吾小说集文本挖掘”本质上就是这样一个完整闭环拿到一批小说集文本用 python 数据挖掘常用手段把分词、词频、TF-IDF、主题模型、人物关系、情感变化全部跑一遍最后交出一份源码加文档说明的课设。它解决的是“数据挖掘学了 but 不会用”的典型困境适合还在上数据挖掘导论、需要课程设计或毕业设计入手项目的同学也适合想拿现成小说语料练手文本挖掘 pipeline 的 python 初学者。复刻这个项目的价值不在算法多新颖而在于从原始 txt 到可视化图表再到报告每一步都不依赖黑匣子。2. 语料清洗与分句东野圭吾小说集的预处理该做到哪一步2.1 为什么小说集文本挖掘第一步不是分词而是建一个干净的语料目录很多人拿到东野圭吾小说集的第一反应是装 jieba、跑词频结果跑出来的词云里全是“他”“她”“了”“的”人名还被切开连“白夜行”都可能被拆成“白夜”和“行”。原因不是分词库不行而是原始语料压根没清洗。小说集的 txt 里通常混着目录、出版社版权页、章节标题、空行、全角空格甚至同一本书在不同文件里编码都不一样Windows 下用 GBK 保存的文本用 UTF-8 去读第一行就会报UnicodeDecodeError。我一般会先建一个分层的目录data/raw放原始抓下来的书data/cleaned放清洗后的文本data/output放所有中间产物和图表。这样后面跑 TF-IDF、LDA、人物共现时随时可以回溯是哪一步出了问题不至于全都堆在一个脚本里拿到答辩现场被问“这个文件哪来的”时只能支支吾吾。清洗脚本是整条 pipeline 的地基先把编码统一成 UTF-8再把空行和无意义的装饰线去掉。下面这个脚本就是对原始目录做第一轮清洗。from pathlib import Path import re raw_dir Path(data/raw) clean_dir Path(data/cleaned) clean_dir.mkdir(parentsTrue, exist_okTrue) # 匹配书本目录、版权、序言等页面常见的无意义行 noise_pattern re.compile(r^(目录|版权|序言|前言|后记|责任编辑|出版社|定价|ISBN)[:]?.*$) def clean_raw_text(text: str) - str: lines [] for line in text.splitlines(): line line.strip() if not line: continue if noise_pattern.match(line): continue # 去掉纯装饰性的下划线、星号、波浪线 if re.fullmatch(r[_*#~\-]{3,}, line): continue lines.append(line) return \n.join(lines) for path in raw_dir.glob(*.txt): try: text path.read_text(encodingutf-8) except UnicodeDecodeError: text path.read_text(encodinggbk) cleaned clean_raw_text(text) out clean_dir / f{path.stem}.clean.txt out.write_text(cleaned, encodingutf-8) print(f{path.name} - {out.name})这段脚本的逻辑很直白用Path.read_text(encodingutf-8)先尝试读取一旦遇到UnicodeDecodeError说明文件大概率是 GBK 编码就回退成gbk再读。noise_pattern匹配的是小说集开头的版权目录行fullmatch匹配整行都是横线或星号的装饰符这类噪音对后续分词和 LDA 一点帮助都没有必须尽早清掉。清洗后的文本统一用 UTF-8 写出这一步能避免后续所有脚本在 Windows 上反复出现乱码。注意不要在这一步做过度清洗比如把“第1章”这种章节标记也删了后面切分章节还要靠它。2.2 用正则做章节切分和句段拆分你真正需要的文本清洗细节东野圭吾的小说集往往是一个文件里包含好几个中篇像《天黑以后》《十一字杀人》这类作品合集直接把整个文件丢给 TF-IDF等于把好几本书混成一个文档主题模型会认为所有主题都来自同一篇。我一般会在清洗后按章节标记把文本拆成独立的“逻辑文档”。中文网文的章节标题通常是“第1章”“第一章”“第X节”用一条正则re.split就能搞定但注意要保留标题本身否则后面分析报告目录时会找不到章节名。import re chapter_pat re.compile(r(第[0-9一二三四五六七八九十百零]{1,6}[章节回部])) def split_chapters(text: str) - list: parts chapter_pat.split(text) chapters [] for idx in range(1, len(parts), 2): title parts[idx] body parts[idx 1] if idx 1 len(parts) else if len(body.strip()) 100: chapters.append((title, body.strip())) return chapters # 用法示例 text Path(data/cleaned/放学后.clean.txt).read_text(encodingutf-8) chapters split_chapters(text) for title, body in chapters[:3]: print(title, len(body))re.split把文本按匹配到的章节标题切成“标题 正文”交替的列表因此parts的奇数位是标题偶数位是对应的正文。这里的关键参数是len(body.strip()) 100小于 100 字的段落多半是目录页残留或者章节过渡页直接丢弃比保留更安全。正则里的{1,6}限制了章节编号最多 6 个字覆盖“第一百二十三回”这种长编号同时避免匹配到正文里出现的“第一次”这类词。如果你手里的文本用的是“Episode 1”这类西式标题把正则改成r(E[pP]isode\s*\d)即可。分句是容易被忽视的一步。LDA 和情感分析通常以句子为基本单位句子切得不好“他站起来说‘我们走吧。’”这种对话会被劈成三截情感模型会把引号里的“我们走吧”误读成叙述者的情绪。我一般先用(?[。…])做常规断句再检查引号闭合状态把未闭合引号内的句子重新合并。import re SENT_SPLIT re.compile(r(?[。…])\s*) def split_sentences(paragraph: str) - list: parts SENT_SPLIT.split(paragraph.strip()) merged [] buf in_quote False for part in parts: buf part if part.count(“) % 2 1: in_quote True if part.count(”) % 2 1: in_quote False if not in_quote: merged.append(buf.strip()) buf if buf.strip(): merged.append(buf.strip()) return merged sample 加贺说“如果这样案件就解开了。凶手其实就在现场。”他说完笑了笑。 for s in split_sentences(sample): print(s)(?[。…])是零宽断言只在句末标点之后切分不会吃掉标点本身。引号计数部分用奇偶判断左引号是否闭合“和”都是中文全角字符直接按出现次数模 2 判断足够。这个方案不是百分百稳健比如遇到连续引号或者省略号写法不规范时偶尔会合并过度但对于小说集这种体量已经够用。分句后的结果我建议保存成data/output/sentences/下的 JSON 文件每个文档对应一个句子列表后续做共现矩阵时可以直接读。2.3 分句之后先做一次人工抽检参数从哪来边界在哪处理完章节和句子后不要急着进分词。我会从每本书里随机抽 30 个句子打印出来用肉眼检查三样东西章节标题有没有混进正文、引号是否闭合、有没有出现乱码。这步看起来“土”但能提前暴露 80% 的编码和清洗问题比事后看词云猜翻车原因省几个小时。清洗阶段的常见误区是把预处理做得过重比如把所有数字、英文、标点全部删掉。对于小说文本破折号和省略号其实是情感表达的一部分保留标点对后续做情绪曲线有实际价值。人物对话里还会出现“嗯”“啊”“哦”这类语气词停用词表可以筛掉一部分但不要在这里统一过滤因为后面做情感分析时语气词恰恰能反映角色情绪。也就是说data/cleaned应该尽量保留原文原貌只做结构性清洗内容层面的过滤放到分词阶段按用途分别处理。参数设计的边界也在这里。清洗脚本里的noise_pattern是我按中文出版物常见版权页写的换成语料后一定要自己加几条特征行。比如你的小说集里如果包含“献给××”这种题词页就需要补充到噪音规则里。宁可清洗脚本多几行正则也不要靠手工删文件因为答辩时老师问你“数据清洗怎么做的”你总不能说是手工删的。3. 分词、停用词与特征表示让 TF-IDF 和 LDA 看到句子里的小说3.1 jieba 分词的自定义词典与停用词表决定“白夜行”和“加贺恭一郎”不被拆散预处理做完下一步才是真正进入文本挖掘的入口分词。中文所有下游任务几乎都依赖分词的稳定性而 jieba 的能力边界就在人名和作品名上。“白夜行”这个词默认词库里没有直接jieba.lcut会得到[白夜, 行]“加贺恭一郎”会被切成一长串碎片。解决办法是维护一份userdict.txt格式为“词 词频 词性”词频给一个比较大的值比如 5000Jieba 就会优先把整段作为词保留。import jieba jieba.load_userdict(data/userdict.txt) with open(data/stopwords.txt, encodingutf-8) as f: stopwords set(f.read().split()) def tokenize(text: str): for word in jieba.cut(text): w word.strip() if not w: continue if w in stopwords: continue if len(w) 1: continue yield w userdict_demo 白夜行 5000 nz 加贺恭一郎 5000 nr 东野圭吾 5000 nr 汤川学 5000 nr 嫌疑犯X 5000 nz Path(data/userdict.txt).write_text(userdict_demo.strip(), encodingutf-8)jieba.load_userdict需要在第一次分词前调用如果你用的是jieba.analyse里的关键词接口它内部可能启动了另一套词典所以最稳妥的做法是脚本启动时先调用一次jieba.initialize()再load_userdict。停用词表可以从公开的中文停用词表直接拿来用但一定要自己追加“他说”“她想”“不是”“一个”这类高频叙述词。len(w) 1过滤掉单字会让“雪”“白”“夜”这些有信息量的字也丢掉但我一般会保留这个规则因为单字在短文本里噪音远大于信号。注意分词阶段不要做词性过滤比如只保留名词。东野圭吾小说的叙述里动词承载了大量情节转折全过滤成名词后做情感分析会失去时态变化。如果后面要专门提取人物关系可以单独用jieba.posseg做一次人物名识别但那种情况下通常依赖显式词表比依赖词性更准。3.2 用 TF-IDF 挑出每本书的关键词从词频到权重的转换逻辑分词之后最常见也最容易被答辩追问的是 TF-IDF。“词频”告诉你哪句话反复出现但它会给“的”“了”“他说”这种功能词打高分。TF-IDF 的作用是给每个词加权词在一篇文档里出现得越多越重要词在整个语料里出现得越普通越不重要。放到东野圭吾小说集这个场景出现频率会突出“尸体”“案件”“密室”“不在场证明”而文档频率会压掉所有书里都出现的“小说”“作者”“出版社”。直接调 scikit-learn 的TfidfVectorizer时一个重要前提是我们已经把每本书预先分词并用空格拼接因为 vectorizer 默认的词粒度是空格分隔的 token不是中文连续字符串。from sklearn.feature_extraction.text import TfidfVectorizer from pathlib import Path import pandas as pd import json docs [] doc_names [] for path in sorted(Path(data/cleaned).glob(*.clean.txt)): text path.read_text(encodingutf-8) words list(tokenize(text)) docs.append( .join(words)) doc_names.append(path.stem) vectorizer TfidfVectorizer(min_df2, max_df0.6, max_features5000, token_patternr(?u)\b\w\b) tfidf vectorizer.fit_transform(docs) feature_names vectorizer.get_feature_names_out() rows [] for doc_idx, name in enumerate(doc_names): scores tfidf[doc_idx].toarray().flatten() top_idx scores.argsort()[::-1][:20] top_words [(feature_names[i], round(scores[i], 4)) for i in top_idx] rows.append({书名: name, 关键词: json.dumps(top_words, ensure_asciiFalse)}) pd.DataFrame(rows).to_csv(data/output/tfidf_keywords.csv, indexFalse, encodingutf-8-sig)这里三个参数值得解释。min_df2表示词至少要在 2 个文档里出现否则当作一次笔误丢弃max_df0.6表示在超过 60% 文档里出现的词过于泛化直接排除这能把所有书共用的“推理”“日本”这类背景词压掉max_features5000是给向量空间的维数封顶防止内存被几百本书的词汇表撑爆。token_pattern维持正则分词的方式即可因为我们传入的是空格分词后的文本。输出时用encodingutf-8-sig保存 CSVExcel 打开不会乱码这一点在交报告时很加分。TF-IDF 的坑在于它把每本书当成一个独立文档如果一本《白夜行》被切成了 50 个章节那每个章节都变成了文档计算出来的 TF-IDF 就不再是一本书的关键词而是章节关键词。这就是为什么第 2 章要先决定好“一个文档到底对应一整本书还是一个章节”做书间对比用整本书做情节起伏用章节两者不能混在一个 TF-IDF 矩阵里。3.3 LDA 主题建模的三个必调参数与困惑度验证TF-IDF 给出了高权重的词但没告诉我们“这些词为什么会凑在一起”。LDA 是更接近数据挖掘导论里“主题模型”概念的工具它假设每本书由若干主题混合生成每个主题由一组词的概率分布构成。放在东野圭吾小说集上跑出来的主题可能是“调查与现场线索”“家庭关系与秘密”“童年阴影与复仇”“不在场证明与时间线”。这些主题的聚类质量直接决定了答辩时能不能讲出有说服力的故事。我常用 Gensim 跑 LDA因为它的passes参数直观且能在同一次运行里返回困惑度。核心参数有三个num_topics主题数、passes迭代轮数、alpha主题稠密度。这几个参数不调好LDA 输出的词要么全部重叠要么每个主题都是噪音。from gensim.corpora.dictionary import Dictionary from gensim.models.ldamodel import LdaModel from gensim.models.coherencemodel import CoherenceModel from pathlib import Path import json # 加载每本书的分词结果doc_tokens 是一个二维 list doc_tokens [] for path in sorted(Path(data/cleaned).glob(*.clean.txt)): text path.read_text(encodingutf-8) doc_tokens.append(list(tokenize(text))) dictionary Dictionary(doc_tokens) dictionary.filter_extremes(no_below5, no_above0.5) corpus [dictionary.doc2bow(doc) for doc in doc_tokens] lda_model LdaModel( corpuscorpus, id2worddictionary, num_topics8, passes20, alphaauto, random_state42, ) for topic_id in range(lda_model.num_topics): words lda_model.show_topic(topic_id, topn15) print(topic_id, [w for w, _ in words])no_below5把出现少于 5 次的词从字典里删掉压制只在一句话里出现的地名人名。no_above0.5把在超过一半文档里出现的词当作停用词处理效果类似 TF-IDF 的max_df。num_topics8是我针对小说集常用经验值如果一共有 12 本书主题数 8 到 12 之间比较合理你不需要一次定死可以跑 6、8、10、12 对比。passes20表示语料被重复采样 20 轮太少主题不稳定太多时间翻倍。random_state42必须设置否则 LDA 每次跑出来的主题顺序都不一样答辩被问“为什么这次结果和我那次不一样”就很尴尬。判断主题数是否合适的标准不是主题越多越好而是“每个主题能否用一句人话概括”。Gensim 的CoherenceModel可以算一致性分数但那个分数本身也有玄学成分我一般会跑 8 和 10 两组打印主题词挑出一组没有明显重叠词的。主题重叠指的是“推理”“案件”同时出现在四五个主题的 top 10 里说明主题数设高了或者停用词表还没到位。4. 人物共现与图谱用 NetworkX 把关系挖出来4.1 人物词典的构造把称谓和代词归并到角色实体文本挖掘光有词频和主题还不够课程设计想要拿高分通常要有一个“关系型”结果人物关系图谱是最直观的交付物。东野圭吾的小说集里人物称呼极其混乱加贺恭一郎有时候叫“加贺”有时候叫“恭一郎”偶尔还会被称作“警部”汤川学被叫“汤川老师”“刑警先生”多次。直接拿分词结果里的“加贺”“汤川”做共现等于把同一个角色拆成了两个实体图谱必然失真。更麻烦的是代词“他说”“她哭”“那个男人”都指向某个人但规则无法百分之百解析指代。常见做法是不解析代词而是维护一份人物别名映射表把出现在正文里的人物称谓全部归一化到角色主名。你可以手工建一张表也可以用命名实体识别先抽候选词再人工确认。我一般用显式映射简单可控答辩时也能讲清楚每一条别名对应哪个角色。import re from pathlib import Path character_alias { 加贺: 加贺恭一郎, 恭一郎: 加贺恭一郎, 加贺刑警: 加贺恭一郎, 汤川: 汤川学, 汤川老师: 汤川学, 物理教授: 汤川学, 石神: 石神哲哉, 花冈: 花冈靖子, } def normalize_character(text: str) - list: items set() for alias, name in character_alias.items(): if alias in text: items.add(name) return list(items) sample 汤川老师对加贺说石神就是那个人。 print(normalize_character(sample))这段逻辑很简单但参数上要留意两点。第一别名表里的词不能太短比如单字“雪”就会命中所有包含“雪”的句子产生大量误报。第二character_alias的匹配是子串级别的所以“加贺刑警”必须排在“加贺”前面否则先命中“加贺”后虽然最终归一化结果一样但后面的“加贺刑警”不会对计数产生额外影响。更严谨的做法是先按别名长度降序排列再匹配保证最长别名优先。实际项目中人物词典可以单独存成一个characters.json结构为{ 加贺恭一郎: [加贺, 恭一郎, ...] }。这样在做共现矩阵前可以随时调整词典而不用改主代码。4.2 共现矩阵的计算逻辑窗口大小为什么选 15人物关系图谱的核心假设是两个角色在一段较短的文本窗口里同时出现说明他们存在某种交互。窗口太小比如 5 个字符只统计“加贺对汤川说”这类紧邻情况会把大量隔空对话漏掉窗口太大比如整本章节任何角色都会和主角发生共现图谱会变成一个完全连通图看不出核心结构。我常用的窗口单位是“句子”而不是字符数。先把分句得到的每句话做人物归属匹配然后在一个window15的句子窗口内统计所有人物两两共现。15 句在小说里大约覆盖一次完整的对话交锋或场景转换既能捕捉到消息通过中间人传递的间接关系又不会把两个互不相干的角色拉成强关系。from pathlib import Path from collections import defaultdict window 15 def character_seqs(sentences): seqs [] for sent in sentences: seqs.append(normalize_character(sent)) return seqs def build_cooccurrence(sentences): co defaultdict(lambda: defaultdict(int)) seqs [s for s in character_seqs(sentences) if s] for i, chars in enumerate(seqs): end min(i window 1, len(seqs)) for j in range(i 1, end): other_chars seqs[j] if not other_chars: continue for c1 in chars: for c2 in other_chars: if c1 ! c2: co[c1][c2] 1 return co # 把 data/output/sentences 下的句子列表读进来 all_sentences [] for path in sorted(Path(data/cleaned).glob(*.clean.txt)): text path.read_text(encodingutf-8) all_sentences.extend(split_sentences(text)) co build_cooccurrence(all_sentences)这里是按照无向共现来计数的co[c1][c2]和co[c2][c1]都会增加因为关系不分方向。图谱里边的权重就是两个人物共同出现在同一窗口的次数。window15是我自己的经验值如果你的语料是短篇集人物出场密度低可以放到 30如果是长篇15 更合理。参数调完后一定要看输出矩阵的对角线对角线上应该是 0否则说明同一角色自环被计入了通常是别名表里有两个名字指向同一角色且同时出现在一句话里。共现矩阵输出之后通常会存在“加贺恭一郎”和“汤川学”这种全局中心人物权重虚高的问题。为了在图上展示一般要做阈值过滤只保留权重排在前 20 到 30 的边。这不算篡改数据只是可视化裁剪但在报告里一定要写明阈值选了多少否则老师看到图谱上只有四条边会怀疑你数据有 bug。4.3 用 NetworkX 输出人物关系图与社区发现有了共现矩阵画图选 NetworkX 是最快路径。NetworkX 本身不带布局但可以用spring_layout做力导引图再用python-louvain也叫community包做社区发现把东野圭吾各本书里的不同关系派别自动分开。这个输出可以直接在报告里作为“人物关系网络”插图。import networkx as nx import community as community_louvain min_weight 3 G nx.Graph() for person, neighbors in co.items(): for other, weight in neighbors.items(): if weight min_weight: G.add_edge(person, other, weightweight) partition community_louvain.best_partition(G, random_state42) print(人物节点数:, G.number_of_nodes()) print(关系边数:, G.number_of_edges()) print(社区数:, len(set(partition.values()))) nx.write_gexf(G, data/output/character_graph.gexf)min_weight3表示只有至少共同出现 3 次的关系才进入图把这些噪声边剪掉之后图会明显清爽。random_state42同样用于社区发现的随机初始化保证结果可复现。write_gexf输出的是 Gephi 可以直接打开的格式如果你不会调 NetworkX 的绘图样式把 GEXF 拖进 Gephi 里用 ForceAtlas2 布局更省时间。这里有一个和主题模型一样的坑community_louvain.best_partition的社区划分每一次都有微小差异所以必须在调用时固定random_state否则图里角色分组的颜色每次跑都不一样你在报告里写的“东野圭吾作品中存在三大关系派系”就站不住脚。我一般会把 GEXF 和 PNG 同时输出PNG 用matplotlib直接渲染保留节点 size 映射为度中心性这样不用依赖 Gephi 也能看到人物权重分布。人物共现矩阵的另一个进阶用法是计算每个角色的度中心性和介数中心性。度中心性高的人通常就是主角比如加贺恭一郎介数中心性高的人可能是串联两条故事线的“桥梁角色”比如某本小说里的中间人。把这两个指标放到报告里比单纯贴一张图更有数据挖掘的味道。NetworkX 提供了现成的nx.degree_centrality(G)和nx.betweenness_centrality(G)可以一行求出建议放到输出脚本里一起保存成 CSV。5. 文档说明与避坑源码之外决定答辩高分的 4 个细节5.1 文档说明该怎么写从目录结构到流程图的完整骨架课设源码如果只有代码答辩时老师根本不知道从哪下手。题目里的“文档说明”才是项目能被称为“高分项目”的核心护城河。文档不需要华丽但结构要清晰能让人照着走完一遍你的 pipeline。我会把文档说明分成六个部分项目概述、依赖环境、目录结构、数据清洗与特征构建过程、结果分析和复现步骤。其中目录结构直接用文本树表达比大段文字更直观。data/ ├── raw/ # 原始小说集txt ├── cleaned/ # 清洗后统一编码的txt ├── output/ # 关键词、主题、共现矩阵、图谱图片 └── userdict.txt # jieba自定义词典 scripts/ ├── clean_data.py # 第2章清洗与分句 ├── tokenize_tfidf.py # 第3章分词与TF-IDF ├── lda_topic.py # 第3章LDA主题模型 ├── co_occurrence.py # 第4章人物共现矩阵 └── draw_graph.py # 第4章人物关系图 README.md # 快速启动说明 report/ └── 数据挖掘课设报告.md文档里必须写清楚每个脚本的入口参数。比如clean_data.py默认读取data/raw下所有 txt输出到data/cleaned如果你漏掉了encodingutf-8-sigExcel 打开关键词 CSV 就会乱码。README 的快速启动命令也要给全pip install jieba scikit-learn gensim networkx python-louvain matplotlib python scripts/clean_data.py python scripts/tokenize_tfidf.py python scripts/lda_topic.py python scripts/co_occurrence.py python scripts/draw_graph.py文档里最该花心思的不是报告开头而是“结果分析”。我见过不少课设报告堆了十几张词云图却没有一句解释“加贺恭一郎出现在高频词里说明什么”。正确做法是每一张图都配一小段“可复述的分析”比如“从共现矩阵看加贺恭一郎与汤川学的边权最高说明两人在同一案件中的交互最频繁”。这种话不需要多专业但能让老师在十分钟内理解你的挖掘结论。5.2 避坑点 1Windows 下文件编码导致的翻车现象所有脚本在 Linux 上跑得好好的一到 Windows PowerShell 里运行就报UnicodeDecodeError: gbk codec cant decode byte或者打开 CSV 全是乱码。原因Python 在 Windows 默认使用 GBK 读取文件而我们的数据统一保存为 UTF-8。解决所有open统一显式指定encodingutf-8写 CSV 时使用encodingutf-8-sig后者会在文件头部加 BOMExcel 才能正确识别。同样的道理适用于json.dump(ensure_asciiFalse)否则 JSON 里中文全部变成\u转义报告贴图时没法直接看。5.3 避坑点 2jieba 自定义词典不生效现象已经写了jieba.load_userdict(data/userdict.txt)但分词结果里“加贺恭一郎”还是被切成“加贺”和“恭一郎”。原因load_userdict的路径是相对路径脚本在项目根目录运行时没问题从scripts/目录直接执行就找不到文件另外如果用户在调用jieba.cut之前先调用了jieba.analyse内部初始化可能已经加载默认词典再load_userdict就会部分失效。解决用Path(__file__).resolve().parent.parent / data/userdict.txt拼绝对路径并在加载后调用jieba.initialize()强制生效。还有一个小坑是用户词典文件编码必须是 UTF-8否则同样会静默失败。5.4 避坑点 3LDA 结果不可复现现象同一个脚本跑了两次两次输出的主题 top 词完全不一样甚至主题数都看起来不同。原因LDA 初始化和 Gibbs 采样带有随机性没有设置随机种子。解决random_state42写在LdaModel的构造参数里同时把dictionary.filter_extremes的参数固定住。如果仍然不稳定检查passes是否太小少于 10 轮时采样没有收敛结果自然漂移。答辩时如果想要完全一致的结果建议把 LDA 模型用lda_model.save(data/output/lda_model)保存一次之后直接用LdaModel.load加载报告里的词表永远不会再变。5.5 避坑点 4人物图谱变成一团乱麻现象共现图打印出来几百个节点互相连接完全看不出人物结构。原因没有做边的权重过滤min_weight1会把所有偶然出现在同一窗口的角色都连起来。解决先看权重分布再定阈值。我一般会输出去重后的边权排序取中位数或前 20% 作为阈值而不是拍脑袋定min_weight3。处理之后如果图仍然混乱回到window参数把它从 15 降到 8强关系会立刻浮出水面。这张图需要保存成矢量 PDF 或高清 PNG分辨率至少 300 DPI否则打印进报告里字全糊了老师看着会直接扣印象分。6. 进阶技巧给每本小说画一条情绪起伏曲线让答辩真正“有观点”当词频、主题、人物共现都跑完后大部分课设就停在了“摆结果”。但如果你想让项目在班里脱颖而出可以加一条情绪起伏曲线把整本小说切成连续片段对每个片段算一个情感极性得分然后把得分连成一条线。东野圭吾很多作品的叙事节奏就是“平静开局 → 案件爆发 → 压抑侦查 → 结尾反转”情绪曲线能把这种节奏直接可视化答辩时指着曲线说“这里就是《嫌疑人X的献身》结尾真相大白时的情感最低点”比任何词云都有说服力。实现上不需要复杂的大模型用现成的情感分析库做一句聚合即可。我用 SnowNLP 做演示因为它简单不需要训练。每个片段可以按章节切分也可以按固定字符数滑动我个人习惯按 2000 字一个窗口因为章节长短差异太大固定字符窗口在报告中更好解释。from pathlib import Path from snownlp import SnowNLP def emotion_curve(text: str, window_chars: int 2000): curve [] for start in range(0, len(text), window_chars): seg text[start:start window_chars] score SnowNLP(seg).sentiments curve.append(round(score, 4)) return curve text Path(data/cleaned/嫌疑人X的献身.clean.txt).read_text(encodingutf-8) curve emotion_curve(text) print(curve[:10])这个脚本唯一要调的是window_chars窗口越小曲线越毛躁越大越平滑。SnowNLP 的情感得分是 0 到 1 的连续值得分低于 0.4 可以解释为压抑段落高于 0.7 是明朗段落。由于它是通用情感模型对东野圭吾这种推理文本会有一定偏差比如“尸体”这个词不会被判为强负面但整体趋势仍然可用。想更严谨一点就换成基于词典的 DUTIR 情感词典统计每段积极词和消极词频次计算比例作为得分只是代码量会多两倍。最后把曲线和章节标题放到同一张图上横轴标注“第1章”“第2章”转成图片存进报告。我在课设里吃过亏第一次只提交了高频词和主题词表答辩老师一句“所以你想说明什么”就把我问住了。后来养成的习惯是每次跑完数据都强制自己写两句话“这个结果对应的原文情节是哪一段它意味着什么”。学位论文也好课设也好评委真正想验证的不是你会不会调库而是你有没有把数据挖掘结果重新放回到文学作品里去解释。做到这一步项目的高分价值才真正落地。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进