LDA主题模型实战:用金庸语料复现文本分类全流程

📅 发布时间:2026/10/11 22:16:44
LDA主题模型实战:用金庸语料复现文本分类全流程
简介这是一份以LDALatent Dirichlet Allocation模型为核心的文本分析实验文档面向自然语言处理、文本挖掘方向的学生与研究者。文档以金庸小说语料为例完整呈现了从200个段落中均匀抽取样本、将同一小说段落合并为16篇训练文档并划分测试集到进行语料预处理删除无用信息、停用词、标点及空白按“词”或“字”单元进行分词再到LDA主题建模、参数迭代收敛、计算段落主题分布并用欧氏距离完成分类的整个流程。内容还系统比较了主题个数从10到100变化时分类正确率的波动趋势以及以“词”和“字”为基本单元对分类结果造成的影响并提供实验表格作为参考。资源为单个docx文件大小644KB结构包含引言、方法论、程序实现、实验研究与结论既可作为课程设计或论文写作的模板也方便读者按步骤复现实验。目前已有204人学习下载适合需要快速掌握LDA建模流程或撰写文本分析报告的学习者。1. LDA模型实战复盘一个能直接跑通的金庸语料文本分析闭环做文本分析实验最烦的不是算法推导而是代码能跑但结果对不上。这份 LDA 模型实战文档是我最近拆过的比较扎实的一份 NLP 实验资源。它从金庸小说语料里均匀抽了 200 个段落把同一小说的段落合成 16 篇训练文档再用 LDA 训练主题分布最后拿 200 个段落当测试集做分类验证。整套流程覆盖语料预处理、段落抽取、LDA 训练、主题数量对比、词/字粒度对比适合正在做文本挖掘课程设计、NLP 入门实验、或者想快速复现一篇 LDA 分类论文结果的人。文档里没有花哨的框架就是原生 Python 循环把 LDA 的迭代逻辑一步步写出来数据表格和代码结构都能直接当模板用。2. 原理与选型为什么主题数 K 能明显改变分类正确率2.1 三个分布与一次迭代LDA 到底在算什么LDA 把每篇文档看成多个主题的混合每个主题又看成一组词的混合。文档里某个词 w 出现的概率不是直接统计词频而是通过主题这一中间层拆成p(w | d) Σ p(t | d) × p(w | t)其中 p(t | d) 是文档 d 对主题 t 的分布p(w | t) 是主题 t 对词 w 的分布。实际训练时维护两类统计量每篇文档里属于各主题的词数、每个主题里各词出现的次数。迭代时针对文档 d 中第 i 个词 w要枚举所有主题计算它属于每个主题的概率选最大的作为这个词的新主题。词的主题一变文档-主题分布和主题-词分布都要跟着更新。这样反复迭代下去两个分布收敛稳定后LDA 的输出就是最终结果。这个流程最核心的公式是p(w_i | d_s) p(w_i | t_j) × p(t_j | d_s)即式 (0.1)。训练时枚举 j 从 1 到 K找出让这个概率最大的主题 t_j然后把词 w_i 重新分配给这个主题。实际代码里 p(w_i | t_j) 来自主题-词频矩阵p(t_j | d_s) 来自文档-主题频数两者都是当前迭代步的统计结果。我建议把这两个矩阵单独维护好因为后续调主题数或者改预处理方式时改这两个矩阵比改整个流程要快得多。2.2 主题数量 K从「等于小说数」到可调原始参考代码有个明显缺陷主题数量 K 被写死为语料库的小说数量。这个项目用的是 16 部小说所以 K 固定为 16没法做主题数量对分类性能的敏感性实验。文档作者把 K 改成可配置参数后实验一下子能展开。从结果来看以词为基本单元时K15 的正确率在 77%~79% 左右K16 能到 82%~83%K50 大约在 92%~93%K100 可以到 96% 以上。看到这个趋势直觉会以为 K 越大越好。实际上 K 增长时每个主题包含的词越来越少主题之间区分度变大段落更容易被分到正确的文档类别里。但 K 继续增大会出现过拟合每个主题退化成几个词的组合泛化能力下降。从文档数据看K 从 50 到 100 正确率提升幅度已经很小说明在 16 篇文档、每篇约 12~13 个段落的规模下K50 左右已经接近收益拐点。2.3 词粒度与字粒度特异性决定了分类上限同一套代码、同一个 K把基本单元从「词」换成「字」分类效果会掉一截。文档里的实验数据很直接K16 时词单元正确率 82%~83%字单元只有 53%~54%K100 时词单元 96% 以上字单元 85% 左右。原因在于字的特异性远低于词。「江湖」「武功」「掌门」这类词在特定主题下有很强的辨识度而单字「江」「武」「掌」在多个主题里都会出现主题分布被稀释。用字做基本单元时不同段落之间的主题向量差异更小欧氏距离分类时边界更模糊。这个差异提示我们处理短文本或者歌词这类字数受限的语料时尽量保留词粒度如果分词效果不理想再考虑字粒度兜底。3. 语料预处理与段落抽取金庸语料怎么切成训练集和测试集3.1 预处理去掉小说开头信息和停用词金庸小说原始文本里有大量非正文内容包括出版说明、章节标题、版权页信息等。直接拿来训练这些噪音会被当成正常词汇进入主题-词分布主题质量会明显变差。我处理这类小说语料的习惯是先按章节标题定位正文起点丢弃正文之前的全部内容然后逐行清洗。import re import jieba def clean_text(raw_text): # 去掉小说开头无用信息常见做法是定位第一章标题 patterns [ r^第[一二三四五六七八九十百零0-9][章节回卷].*, # 章节标题 r^.*$, # 括号注释 r^《.*》$, # 书名号内容 ] lines raw_text.split(\n) start 0 for idx, line in enumerate(lines): stripped line.strip() if re.match(r^第[一二三四五六七八九十百0-9][章节回], stripped): start idx break body \n.join(lines[start:]) return body这段代码先定位第一章标题所在行把之前的内容全部丢弃。参数按语料微调如果开头有「楔子」「引子」需要把匹配模式改成同时识别这些词。处理完后正文里还会残留大量标点和空格下一步集中清理。stopwords set() with open(cn_stopwords.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def tokenize(body, unitword): # 删除标点符号与空白字符 body re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , body) # unitword 用 jieba 分词unitchar 直接用单字 if unit word: words jieba.lcut(body) else: words list(body.replace( , )) return [w for w in words if w.strip() and w not in stopwords]停用词表用cn_stopwords.txt这是中文 NLP 里常用的公开停用词资源。预处理时先删标点再过滤停用词。注意 jieba 默认词典对武侠小说里的「玄铁重剑」「降龙十八掌」这类词切分可能不准确我一般会把小说里的专有名词追加进用户词典防止分词把完整词切开。3.2 按语料比例抽取段落而非随机抽题目要求从语料库中「均匀」抽取 200 个段落。直接随机抽有两个问题一是某些小说文本量大但抽得少二是段落长度严重不均。文档的做法是统计每部小说总词数按词数占总语料词数的比例分配抽取名额。例如某部小说占全部语料的 12%就抽取 200 × 12% 24 个段落。import random from collections import Counter random.seed(42) def split_paragraphs(tokenized_by_novel): novel_word_counts { name: len(tokens) for name, tokens in tokenized_by_novel.items() } total_words sum(novel_word_counts.values()) assign_counts {} remaining 200 for name, cnt in novel_word_counts.items(): # 按比例分配向下取整 assign_counts[name] int(200 * cnt / total_words) remaining - assign_counts[name] # 剩余名额按小数部分从大到小分配 decimals sorted( novel_word_counts.items(), keylambda x: 200 * x[1] / total_words - int(200 * x[1] / total_words), reverseTrue ) idx 0 while remaining 0: assign_counts[decimals[idx % len(decimals)][0]] 1 remaining - 1 idx 1 return assign_counts比例分配后每部小说的段落可能不是整数代码把剩余名额按小数部分从大到小补足。random.seed(42)固定随机种子保证每次抽取结果一致这对后续对比实验尤其重要。段落抽取时我建议以句号、感叹号、问号为分隔符把正文切成段落再过滤掉少于 500 词的短段落避免把人物对话或章节过渡段混进来干扰主题分布。3.3 词/字两种基本单元同时准备实验需要对比「词」和「字」两种基本单元。我在代码里做了一个分支unitword走 jieba 分词unitchar直接把文本按单字拆分。两种单元各生成一套训练集和测试集文件for unit in [word, char]: train_data [] test_data [] for novel_name, counts in assign_counts.items(): paragraphs tokenized_by_novel[novel_name] # 抽取该小说应抽的段落数这部分作为该小说的训练文档 random.shuffle(paragraphs) train_paras paragraphs[:counts[novel_name]] # 余下段落里再抽固定数量的测试段落 test_paras paragraphs[counts[novel_name]:counts[novel_name] 40] train_data.append({novel: novel_name, tokens: train_paras}) test_data.extend([{novel: novel_name, tokens: p} for p in test_paras]) # 保存文件格式是段落内容按空格分隔 with open(ftrain_{unit}.txt, w, encodingutf-8) as f1: for item in train_data: for p in item[tokens]: f1.write( .join(p) \n)代码中测试段落数我临时设了每部 40 个实际操作时应该从总量 200 里乘法分配。数据的核心原则是训练集和测试集必须来自不同段落不能有重叠否则分类正确率会虚高。4. 训练与测试全流程K 可调的 LDA 迭代实现4.1 初始化修复原代码固定 topic 统计 Bug文档里专门提到原参考代码的一个 Bug初始化统计词频时统计的是固定 topic即默认第一篇小说的词全部来自 topic_0。这意味着初始状态下文档 0 的词全部堆积在第一个主题里其他文档的主题分布全为空后续迭代要花很多轮才能纠正这个偏差而且结果容易陷入局部最优。正确做法是给每篇文档的每个词随机分配一个 topic再统计频数。import numpy as np class LDA: def __init__(self, docs, vocab, K, alpha0.1, beta0.01): self.docs docs # 文档列表每篇是 token 列表 self.vocab vocab # 词表 self.K K # 主题数量 self.alpha alpha # 文档-主题 Dirichlet 先验 self.beta beta # 主题-词 Dirichlet 先验 self.nd len(docs) self.nv len(vocab) self.V {w: i for i, w in enumerate(vocab)} # 频数统计矩阵 self.doc_topic np.zeros((self.nd, self.K)) # 文档-主题频数 self.topic_word np.zeros((self.K, self.nv)) # 主题-词频数 self.topic_total np.zeros(self.K) # 每个主题的总词数 self.doc_total np.zeros(self.nd) # 每篇文档的总词数 self.z [] # 每个词的主题编号 rng np.random.default_rng(42) for d in range(self.nd): doc_topics [] for w in self.docs[d]: t rng.integers(0, self.K) # 随机分配主题 doc_topics.append(t) self.doc_topic[d][t] 1 self.topic_word[t][self.V[w]] 1 self.topic_total[t] 1 self.doc_total[d] 1 self.z.append(doc_topics)初始化部分的关键参数alpha和beta是 Dirichlet 先验参数影响主题分布和词分布的平滑程度。alpha0.1让文档-主题分布偏稀疏beta0.01让主题-词分布偏集中这是我处理分类任务时的常用起点。np.random.default_rng(42)固定随机种子保证每次初始化可复现。z记录每个词所属主题迭代时要反复访问。4.2 迭代训练用 p(w|d) 重选 topic 并更新统计训练的核心是根据当前统计量计算每个词属于每个主题的概率重新分配主题。def train(self, max_iter200): for _ in range(max_iter): changed 0 for d in range(self.nd): for i, w in enumerate(self.docs[d]): t_old self.z[d][i] wid self.V[w] # 先从统计量中减去当前词的贡献 self.doc_topic[d][t_old] - 1 self.topic_word[t_old][wid] - 1 self.topic_total[t_old] - 1 self.doc_total[d] - 1 # 计算每个主题对当前词的概率 probs [] for t in range(self.K): p_t_d (self.doc_topic[d][t] self.alpha) / \ (self.doc_total[d] self.K * self.alpha) p_w_t (self.topic_word[t][wid] self.beta) / \ (self.topic_total[t] self.nv * self.beta) probs.append(p_t_d * p_w_t) # 选概率最大的主题 t_new int(np.argmax(probs)) if t_new ! t_old: changed 1 self.z[d][i] t_new # 重新统计 self.doc_topic[d][t_new] 1 self.topic_word[t_new][wid] 1 self.topic_total[t_new] 1 self.doc_total[d] 1 # 当重新分配的词很少时提前停止 if changed 5: break每处理一个词先把该词从旧主题的统计里去掉计算它属于每个主题的概率然后挑概率最大的主题。减掉当前词再计算是为了防止该词自身的贡献干扰判断这是 Gibbs 采样思想里常见的手法。probs数组里每个元素是p(t|d) × p(w|t)对应式 (0.1)。迭代停止条件用了changed 5同时max_iter200兜底。实际运行中主题数 K 越大需要迭代轮数越多K100 时我会把 max_iter 调到 300。4.3 测试分类用欧氏距离匹配 16 篇文档训练完成后模型得到的是主题-词分布topic_word。测试阶段的做法是把 200 个测试段落也用这个分布计算出主题向量然后与 16 篇训练文档的主题向量算欧氏距离距离最小的那篇文档的类别就是预测结果。def inference(self, test_docs): results [] # 每个测试段落主题分布向量 for para in test_docs: theta np.zeros(self.K) total 0 for w in para: if w not in self.V: continue # 跳过词表外的词 wid self.V[w] # 用训练好的 topic_word 推断段落的主题分布 probs self.topic_word[:, wid] / self.topic_word.sum(axis1) t int(np.argmax(probs)) theta[t] 1 total 1 if total 0: theta / total results.append(theta) return results def classify(self, test_theta, train_theta): preds [] for theta in test_theta: dists np.linalg.norm(train_theta - theta, axis1) preds.append(int(np.argmin(dists))) return predsnp.linalg.norm(..., axis1)一次性计算当前测试段落与 16 篇训练文档的欧氏距离。注意probs self.topic_word[:, wid] / self.topic_word.sum(axis1)这里存在一个隐患当某个主题里该词从未出现时topic_word[t][wid]为 0但分母不会为 0所以概率为 0不会报错极端情况下某主题的总词数为 0 才会除零不过初始化时每个主题至少分配过一个词程序能跑通。如果要更稳就在分母上加一个极小的1e-10。测试集里的词如果没在训练词表里出现过self.V查不到直接跳过。这说明测试段落里大量新词会被丢弃所以预处理时训练集和测试集的词表最好合并构建或者用同一个停用词表过滤否则测试段落的主题向量会比实际稀疏不少。5. LDA 复现避坑这些坑不避开正确率会凭空掉一截5.1 词频统计初始化 Bug第一篇文档全被归到 topic_0现象训练完成后第一个主题的词频异常高文档 0 的分类结果接近 100% 正确其他文档乱成一团。 原因原参考代码初始化时把第一篇文档的所有词都分配给了固定 topic_0后面文档即使随机分配初始统计也严重失衡。 解决初始化时遍历每篇文档的每个词用rng.integers(0, K)独立分配主题。改完这句之后各主题初始词频基本均匀收敛速度也快很多。5.2 段落抽取不考虑长度分类结果波动大现象同一组参数下每次重新抽取段落正确率能差 5~8 个百分点。 原因抽取的段落长短不齐有的段落只有几十个词主题分布统计量太少计算 p(t|d) 时噪声大。 解决抽取段落前先按标点切分过滤掉词数少于 500 的段落。切分正则以句号、感叹号、问号为准不能无脑按换行符。金庸小说文本里换行比较随意按换行切容易把一大段对话和正文混在一起。5.3 主题数量写死没法做横向对比现象K 恒等于 16无法复现实验结果里 K50、K100 的正确率变化。 原因原始代码用len(docs)作为主题数量训练和测试都只支持固定规模。 解决把 K 改成LDA类的构造参数传入训练、推理、分类都从self.K取。视角放宽一点K 可调之后还能顺手画「主题数量-正确率」曲线这是论文里很实用的一张图。5.4 随机种子不固定结果不可复现现象同一份语料、同一组参数两次跑出来的正确率对不上。 原因LDA 的初始化随机分配 topic迭代过程中 argmax 的并列情况也可能不同。 解决初始化、段落抽取都固定random.seed和np.random.default_rng的种子。正式实验时还要按文档做法对每个 K 跑多次取平均单次结果只能当参考。5.5 收敛条件只看词频变化可能提前终止现象max_iter 设小了模型还没稳定就停止主题分布混乱。 原因changed 5判断的是「本轮重新分配的词数」。当 K 很大时词量少changed 数值本身就不大容易误判为收敛。 解决同时卡两个条件——changed小于阈值且大于一定迭代次数或者直接固定迭代轮数等训练完再看主题词表是否合理。我实际跑 K100 时max_iter 至少 300 轮。6. 让结果更能说明问题的三个检查习惯模型跑完、分类正确率也统计出来了但实验报告如果只有数字答辩时很容易被追问细节。我复盘这套流程后养成了三个检查习惯每个都能在几分钟内定位潜在问题。第一个习惯是检查主题-词表。训练完后从每个主题里取出概率最高的 10 个词打印出来看一眼。K16 时主题里如果出现相对集中的「丐帮」「帮主」「英雄帖」「乔峰」这类词说明主题是有语义的如果某个主题的前 10 词全是「的」「了」「是」这类停用词残留说明预处理没做干净先回第 3 章重新清语料。这个检查不花时间但能避免「正确率很高但主题没意义」的尴尬。第二个习惯是画训练集的文档-主题分布热图。16 篇文档对 16 个主题理想情况是每篇文档只在个别主题上有高概率矩阵接近稀疏。如果热图一团糊所有文档在所有主题上都均匀分布说明 alpha 设得太大或者语料本身主题差异小。这时把 alpha 从 0.1 降到 0.01再跑一轮通常矩阵会清晰很多。K50 和 K100 时热图更要看因为 K 超过文档数后必然存在某些主题没有文档靠近这属于正常现象但报告里要解释清楚。第三个习惯是多次运行取平均。LDA 的随机初始化决定了每次结果不会完全一致文档里的试验表格也体现了这一点——每个 K 值下列出了两行数据。我一般对每个 K 跑 5 次取平均值标准差偏大的 K 值要警惕。判断标准同一 K 下5 次正确率的最大最小值差超过 5 个百分点说明语料预处理或初始化有隐患优先排查段落抽取是否均衡。这三个检查做完再写结论就有底气了。也是从那以后我每次复现 LDA 实验都会强制走一遍「看主题词、看热图、看方差」的流程几乎每次都能揪出一两个预处理层面的小问题。希望帮到你。这份文档里预处理、训练、测试三段代码和完整的实验结果表格都已经整理成了可以直接改用的形式主题数量可调这个改进点也被单独拆出来了下载后照着第 4 章的结构往自己的语料上套就行。本文还有配套的精品资源点击获取