
简介基于LDA模型的豆瓣长评论主题分词与可视化项目面向具备一定Python基础的本科生适用于课程设计、期末大作业及自然语言处理入门实践。项目以豆瓣《庆余年》长评数据为对象完成分词、停用词过滤、LDA主题建模、困惑度评估与主题词输出并配有词云图、主题热度图、困惑度曲线等可视化结果。压缩包共39个文件包含7个Python源码lda_model_gensim.py、perplexity.py、word_cloud.py等、4个csv数据文件、8个txt词表/停用词、1个xlsx及运行所需的ttf字体另附16张png图表与topic_word、doc_top_topic等中间结果整体大小12.51MB目录结构清晰下载后按说明可直接运行。输出文件涵盖词频表、主题词表、文档-主题分布与多种可视化图片便于从数据到结论完整复盘。已有211人学习代码经过导师指导并获评97分逻辑完整、注释清楚既能直接作为高分作业提交也适合作为LDA主题模型实战的参考模板。1. 拿到 LDA 豆瓣长评论作业包第一步该做什么如果你是因为“高分期末大作业”这个标签搜到这个标题先别急着解压就跑 train.py。基于 LDA 模型对豆瓣长评论进行主题分词表面是一道中文自然语言处理入门题实际上一大半人交上去的源码能运行但主题结果完全读不了问题全出在数据和预处理上。这个 zip 把 python 源码、评论数据和输出结果打包在一起用意就是让你顺着 数据清洗 → jieba 分词 → 词典构建 → LDA 训练 → 主题输出 这条链路跑通一次。真正拉开差距的从来不是模型选得多花哨而是你愿不愿意把停用词表、主题数等细节抠干净。这篇笔记按这套顺序把每一步拆开代码在哪、参数怎么改、坑在什么地方都讲清楚适合已经装好 python 环境、准备拿这份源码交差的读者。2. 先把 LDA 和主题分词的关系理清楚再打开源码包2.1 主题分词的本质LDA 是“词共现聚类工具”不是情感打分器很多人在期末报告里把 LDA 写成“自动分析电影评论好坏”的工具这个理解从第一页就偏了。LDA 模型的核心假设是每篇文档由若干个隐藏主题混合生成每个主题又是词典上的一个概率分布。放到豆瓣长评论的场景里它根本不看“喜欢、讨厌、烂、好看”这类情感词只看“哪些词经常在同一篇评论里扎堆出现”。当“导演、镜头、叙事、节奏”总是一起出现时它们会聚到主题 A当“演员、演技、角色、塑造”频繁共现时聚到主题 B。LDA 通过吉布斯采样或变分推断估计出文档-主题分布和主题-词分布我们最后看到的结果就是“每个主题下概率最高的那批词”。所以“主题分词”这四个字要拆开看分词是拿 jieba 做的前置步骤负责把中文切成一个个词主题是拿 LDA 做的后置聚类负责把这些词归到不同主题编号下。LDA 输出某个词排在某个主题前面不代表这篇评论在夸什么只代表这个词在这个主题下的出现概率更高。写答辩的时候最好把这个区别话讲清楚否则老师一问“你这个主题是怎么得出来的”很容易露馅。2.2 为什么选豆瓣“长评论”短评的词袋太稀疏主题推不出来豆瓣的短评限制在 140 字以内绝大多数人只写一句话“全场最佳值回票价”“烂片浪费时间”。一句话里能用的实词可能只有五六个LDA 做的是概率共现统计词越少统计就越不稳定。一篇只有十几个词的短评在词袋模型里就是一条稀疏得不能再稀疏的向量丢进 LDA 里大概率被随机分配到某个主题没有可解释性。长评论就不一样。豆瓣长评少则两三百字多则上千字会涉及剧情、人物、摄影、配乐、节奏、价值观等多个维度。一篇评论展开聊得越细不同维度的词就越容易各自扎堆LDA 的主题结构也就越清晰。这也是为什么作业包里的数据列大多是“短评表”和“长评表”分开存训练时只取长评表。你要是在报告里强调这一层选择理由比单纯写“我们选用豆瓣长评论”要有说服力得多。2.3 中文做 LDA 前必须分词分词质量直接决定主题能不能读英文文档天然有空格分词中文没有所以中文 LDA 的前置分词是绕不开的。jieba 是这一类大作业里最常见的工具支持精确模式、全模式和搜索引擎模式但我们要的是精确模式也就是按最合适的粒度切分一句话。比如“这部电影的配乐非常出色”切完应该是“电影/配乐/非常/出色”而不是把整句当成一个词。问题在于 jieba 默认词典对影视类专有名词覆盖不全面。“流浪地球”可能被切成“流浪/地球”“哈利·波特”可能被切成“哈利/波特”这种切碎现象会让同一个实体被拆成两个无用特征散落到不同主题里主题报告读起来非常难受。解决办法是先加载一个自定义词典把片名、导演名、演员名提前加进去后面避坑章节我会给具体写法。2.4 为什么作业包里常见 gensim 的 LdaModel而不是 sklearn同样做 LDAsklearn 的 LatentDirichletAllocation 也能跑但这类作业包几乎清一色选 gensim原因不是代码短而是接口更贴合论文叙述。gensim 的 LdaModel 直接接受词袋语料和 id2word 映射不需要手动转成稠密矩阵训练完可以一行调用show_topic()看主题词一行调用log_perplexity()看困惑度配合 CoherenceModel 算一致性最后还能save()把模型完整落盘。这些动作在 sklearn 里都要自己拼凑。另外 gensim 的语料可以是迭代器不用把所有评论一次性载入内存数据量稍大的时候优势明显。期末大作业虽然用不到分布式那套但理解“语料不驻留内存、按批次读取”这个设计回答答辩时又能多拿一分。我一般把 gensim、jieba、pandas 三个库作为最小依赖其它库是补充缺了再装。3. 数据准备从豆瓣长评论原始数据到 jieba 分词语料3.1 打开 zip 后先检查数据字段和数据量别假设 raw 数据一定能用解压后先不急着看训练代码先看数据。我把 zip 里的数据文件统一按这个顺序检查文件格式是 csv 还是 json、字段名有哪些、评论条数有多少、每篇评论平均多少字。常见做法是写几行 pandas 把结构摸清楚再决定后面的清洗策略。import pandas as pd # 作业包里的长评论数据字段一般包含 movie_id、comment、rating、time 这几列 df pd.read_csv(data/raw/douban_long_comments.csv, encodingutf-8) print(df.shape) print(df.columns.tolist()) print(df.head(3)) print(df[comment].str.len().describe())这段代码输出三块信息数据规模、字段名、每条评论的字数分布。看到min值如果只有个位数说明数据里混进了短评或垃圾爬取样本看到count明显少于文件行数说明有空值存在。这个检查动作看似没用实际能帮你少踩一半的坑。如果评论总数少于 200 条LDA 的主题结构会非常稀碎这时候宁愿自己用 python 爬虫再补一批长评论也不要硬拿小样本去训。字段名在不同作业包里可能叫comment_content、content或text不要写死列名直接打印columns看。代码里我写的是comment你按实际字段改名即可。3.2 数据清洗去空、去重、过滤短评和“无意义长评论”拿到原始表后按“空值 → 重复 → 长度 → 噪声字符”四步清洗。豆瓣长评论里最常见的三种脏数据是爬虫没抓到正文的空行、同一篇评论被多次抓取导致的重复行、只有几十个字却被归到长评表的凑数样本。还有一种更隐蔽是评论区复制的“水军模板”内容完全一致去重之后基本能清掉。# 四步清洗空值、重复、长度过滤、噪声字符 df df.dropna(subset[comment]) df df.drop_duplicates(subset[comment]) df[comment] df[comment].str.strip() # 短于 50 个字的过滤掉长评表里低于这个长度的大多是半截数据 df df[df[comment].str.len() 50] df df.reset_index(dropTrue) print(f清洗后剩余评论{len(df)} 条)这里有两个参数值得说明。drop_duplicates是按整条评论文本去重不是按用户去重因为同一用户写不同电影的评论是有价值的。长度阈值我习惯定在 50你根据自己数据的字数分布调整不要在清洗阶段就把有效长评误杀。清洗完建议把df存一份副本后面调参失败要回头重新清洗时不用从头再来。3.3 jieba 分词加词性过滤只留名词、动词、形容词和专有名词清洗完文本后进入分词阶段这是整个流水线里最容易出问题的一步。直接jieba.lcut(text)会把助词、连词、代词、标点全部切出来得配合停用词表和词性过滤一起用。我常用的做法是jieba.posseg做带词性的分词只保留名词、动词、形容词开头和专有名词开头的词因为这些词是 LDA 主题建模的信息载体。import jieba import jieba.posseg as pseg import re # 加载停用词表常见做法是拿到“哈工大停用词表”再手动补充电影评论词 STOPWORDS set() with open(data/clean/stopwords.txt, r, encodingutf-8) as f: for line in f: STOPWORDS.add(line.strip()) # 自定义词典把片名、导演、演员等专有名词提前注册避免被切碎 jieba.load_userdict(data/clean/userdict.txt) def clean_noise(text): # 去掉链接、用户、话题符号和多余空白 text re.sub(rhttp\S|\w|#\w#, , text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return .join(text.split()) def tokenize(text): words [] for word, flag in pseg.cut(text): # 保留名词、动词、形容词、专有名词去掉副词、介词、语气词 if flag.startswith((n, v, a, nr, ns)): if len(word) 1 and word not in STOPWORDS: words.append(word) return words # 把清洗后的评论转成词列表 df[tokens] df[comment].apply(clean_noise).apply(tokenize) print(df[tokens].head(3))词性过滤这个动作是有代价的动词和形容词如果过滤太狠会把“好看”“惊艳”这类情感词也滤掉最后主题只剩一堆名词。我这里保留动词和形容词的开头字母v、a就是避免主题词列表太干瘪。另一个细节是len(word) 1单个字的词绝大多数是“的、了、是、我、你”这类噪声直接丢弃能显著提升主题词可读性。3.4 构建 gensim 词典和词袋语料落盘保存中间产物分词完成后下一步是把每篇评论的 token 列表转成 gensim 能读的格式。gensim 的 Dictionary 会为每个词分配整数 IDdoc2bow把一条评论转成(词ID, 出现次数)的稀疏列表。这一步需要注意的是过滤低频词和高频词低频词出现次数太少对主题没有区分度高频词如“电影”如果出现率超过 80%会污染所有主题。from gensim import corpora import pickle # 用分词结果构建词典 dictionary corpora.Dictionary(df[tokens]) # no_below2 表示词频低于 2 的词丢弃no_above0.8 表示出现在 80% 以上评论里的词丢弃 dictionary.filter_extremes(no_below2, no_above0.8) dictionary.compactify() # 每条评论转成词袋向量 corpus [dictionary.doc2bow(tokens) for tokens in df[tokens]] # 把中间结果落盘调参时不用重新跑预处理 with open(data/clean/processed.pkl, wb) as f: pickle.dump({ dictionary: dictionary, corpus: corpus, tokens: df[tokens].tolist() }, f) print(f词典规模{len(dictionary)} 个词) print(f语料文档数{len(corpus)} 篇)filter_extremes的两个阈值是 LDA 预处理最值得调的地方。no_below2如果数据量小建议降到 1否则大量词会被误删no_above0.8在电影评论场景里往往不够因为“电影、一部、故事”这类词出现在 90% 的评论里建议提高到 0.9 或直接手动把它们加进停用词表。compactify()是让词典重新映射 ID去掉被过滤词留下的空洞否则后续模型里会出现大量空位。4. 训练 LDA 模型主题数怎么选、参数怎么设、结果怎么导出4.1 主题数 K 不要拍脑袋用困惑度和一致性得分一起选主题数 K 是 LDA 训练里最关键的超参数。K 太小主题过于笼统所有词混成一团K 太大主题变得碎片化前后两个主题内容重复。常规做法是在一个范围内枚举 K用困惑度和主题一致性两个指标综合判断而不是只看单个指标。困惑度衡量模型对语料的拟合程度越低代表重建能力越好一致性代表主题内词的语义相关程度越高代表主题越可解释。两者经常打架需要放在一起权衡。from gensim.models import LdaModel from gensim.models import CoherenceModel import pandas as pd def evaluate_k(dictionary, corpus, texts, k): lda LdaModel( corpuscorpus, id2worddictionary, num_topicsk, random_state42, passes10, iterations200, alphaauto, etaauto ) perplexity lda.log_perplexity(corpus) coherence CoherenceModel( modellda, textstexts, dictionarydictionary, coherencec_v ).get_coherence() return perplexity, coherence results [] texts df[tokens].tolist() for k in range(3, 13): perp, coh evaluate_k(dictionary, corpus, texts, k) results.append({k: k, log_perplexity: perp, coherence: coh}) result_df pd.DataFrame(results) print(result_df.sort_values(coherence, ascendingFalse))这段代码里要用三次注意。一是texts是原始分词列表不是corpusCoherenceModel 需要原始词序列来计算共现输入错误会直接报类型错。二是log_perplexity返回值通常是负数不要看到负值就以为模型坏了困惑度等于负对数似然除以总词数负数正常。三是枚举区间200 条评论以下建议 3~10500 条以上可以放宽到 3~15不要一上来就尝试 30 个主题。4.2 确定主题数后正式训练alpha、eta、passes、iterations 该设多少K 定了之后进入正式训练。这一步不要照抄默认参数gensim 的默认值在电影短文本场景里表现一般需要按语料规模调。我的习惯是把alpha设成asymmetric这个词是让文档-主题分布更符合“评论内容偏向少数几个主题”的直觉eta用auto让模型自动学习主题-词分布的稀疏度效果通常比固定值好。final_lda LdaModel( corpuscorpus, id2worddictionary, num_topics8, # 上面枚举出的最优 K alphaasymmetric, # 文档主题分布不对称允许少数主题主导 etaauto, # 主题词分布自动估计 passes30, # 遍历整个语料的轮数30 轮足够小数据集收敛 iterations500, # 每轮吉布斯采样的迭代次数 chunksize200, # 每批处理的文档数 eval_every1, # 每 1 批计算一次困惑度方便看训练日志 random_state42, # 固定随机种子保证结果可复现 minimum_probability0.01 # 文档主题分布中小于 0.01 的概率直接置零 ) final_lda.save(output/lda_model.model)这几个参数按重要度排序说明random_state是优先级第一的不固定的话每次运行主题都变期末答辩被问到会很难看passes在小数据集上不是越多越好30 轮已经把收敛问题解决了调到 100 只增加耗时chunksize影响内存和日志频率200 条一 批比较合理minimum_probability0.01让输出更稀疏不设的话每篇评论的 8 个主题概率都是小数点后六七位没法读。4.3 导出主题分词结果和每篇评论的主主题训练完要交的东西不只是模型文件还要有主题词表和文档主题分布。主题词表是终稿里直接贴在论文附录的素材文档主题分布是后续做可视化、统计各主题评论占比的基础。这里我通常会一次性导出成 CSV避免答辩时现场重新跑训练。import pandas as pd # 导出每个主题的 top 15 词 topics_data [] for topic_id in range(final_lda.num_topics): word_probs final_lda.show_topic(topic_id, topn15) words .join([word for word, prob in word_probs]) topics_data.append({topic_id: topic_id, top_words: words}) topics_df pd.DataFrame(topics_data) topics_df.to_csv(output/topics.csv, indexFalse, encodingutf-8-sig) # 导出每篇评论的主主题概率最大的那个 doc_topics [final_lda.get_document_topics(bow, minimum_probability0.1) for bow in corpus] df[dominant_topic] [max(topics, keylambda x: x[1])[0] for topics in doc_topics] df[dominant_topic_prob] [max(topics, keylambda x: x[1])[1] for topics in doc_topics] df[[movie_id, comment, dominant_topic, dominant_topic_prob]].to_csv( output/doc_topics.csv, indexFalse, encodingutf-8-sig )导出文件用utf-8-sig而不是utf-8是给 Excel 留的后路直接utf-8在 Windows 上用 Excel 打开会乱码。get_document_topics里的minimum_probability0.1只保留概率超过 10% 的 主题避免每篇评论都输出 8 个接近 0 的概率值。至于show_topic的topn15如果某个主题下前 15 个词里已经出现明显的噪声词说明 K 或停用词表还有问题见下一章的排查清单。5. 避坑豆瓣评论 LDA 的五个翻车点和对应解法5.1 主题词打出来全是“电影、觉得、一部、真的”停用词表太薄现象show_topic返回的前 10 个词里有一半是“电影、一部、这个、觉得、真的、还是”所有主题长得几乎一样谁也无法区分。原因停用词表只有网上下载的通用表没有补充电影评论场景里的高频虚词。通用停用词表里有“的、了、是、我”但没有“电影、觉得、一部、真的”这类在短评里高频出现的口语词。解决把语料里出现率超过 60% 的词全部拉出来人工过一遍有意义的留下没意义的塞进停用词表。我个人的做法是加一步“高频词粗筛”from collections import Counter freq Counter(word for tokens in df[tokens] for word in tokens) total len(df[tokens]) suspects [word for word, cnt in freq.items() if cnt / total 0.6] print(suspects)把打印出的词逐个看像“电影、一部、觉得、真的”直接加入stopwords.txt再重新分词。注意阈值 0.6 在长评论数据上效果不错但如果你语料里恰好有一半评论都在讨论同一部影片片名也会出现在这个列表里要手动保留。5.2 同一个语料两次训练结果完全不一样随机种子没固定现象第一遍训练主主题编号是 3 和 5 的词不同第二遍训练同一批词的输出完全不同交报告时截图和实际输出对不上。原因LDA 依赖随机初始化gensim 里没传random_state时每次初始化都不同。解决训练代码里传random_state42并在报告里注明。这不只是“代码洁癖”是保证你在答辩现场重跑一遍时PPT 上的主题词和现场输出一致。此外passes30这类参数也有影响随机种子固定后passes越大结果越稳定但没必要为了稳定把轮数拉满。唯一要记住的组合是random_state固定 语料顺序固定语料顺序变化也会导致结果轻微波动。如果你把df.sample(frac1)洗了数据记得先洗再建词典不要在训练前临时洗。5.3 专有名词被切碎“流浪地球”变成“流浪/地球”现象输出主题词里出现“流浪”“地球”两个词而不是“流浪地球”导致原本讲科幻片的主题和讲地理的评论混在一起。原因jieba 默认词典不含当前语料的新词长评里“狂飙、满江红、封神第一部”这类词容易切碎。解决在userdict.txt里手动加词。每行一个词格式是“词 词频 词性”例如流浪地球 100 nt 满江红 100 n 哈利·波特 100 n加完后在预处理代码里调用jieba.load_userdict(data/clean/userdict.txt)并重新跑分词。这个文件放在data/clean/下连同代码一起打包答辩时老师问“这些主题词为什么这么整齐”这就是证据。另一个细节是如果你想更省事可以在tokenize里加一个检查如果word且它出现在你维护的实体名单里就保留原始词不要按词性过滤。但求稳还是用户字典优先。5.4 困惑度随主题数增大一直变好但主题却越来越不可读只看单一指标现象4.1 节的枚举结果里log_perplexity随着 K 从 5 到 15 单调下降新手选了困惑度最低的 K15结果每两个主题之间词重叠严重根本没法贴论文。原因困惑度衡量的是“模型重建词袋的能力”K 越大模型容量越大拟合越好但拟合不等于可解释。主题数目超过真实语义结构后LDA 会把一个完整主题拆成两半表面困惑度下降实际主题冗余。解决拿困惑度和一致性一起判断优先看一致性曲线的“肘部”也就是一致性增幅明显变小的点。如果 K5、6、7 一致性接近优先选更小的 K少而清晰的主题更适合写期末报告。5.5 Windows 上跑源码报 UnicodeDecodeError编码问题扎堆现象双击运行.py文件时预处理那一行open(stopwords.txt)直接报UnicodeDecodeError: gbk codec cant decode byte或者训练完保存的 CSV 在 Excel 打开全变乱码。原因Windows 默认编码是 GBK所有open()不指定encoding时按系统编码读读 utf-8 保存的停用词表就炸了导出 CSV 时反过来utf-8 写入的文件用 Excel 打开是乱码。解决所有文件读写显式指定编码。# 读停用词表、用户词典时一律显式指定 utf-8 with open(data/clean/stopwords.txt, r, encodingutf-8) as f: STOPWORDS set(line.strip() for line in f) # 写任何中间文件和结果文件时用 utf-8-sig 而不是 utf-8 with open(data/clean/processed.pkl, wb) as f: pickle.dump(...)另外如果你用的是 Windows 自带记事本编辑停用词表保存时注意选 UTF-8不要选 ANSI。这个坑几乎每个交作业的人都会踩提前在代码里把编码写死能少一晚上折腾。6. 交付前做两件事单篇长评论验证和可复现输出6.1 用一篇没进过训练集的豆瓣长评论走一遍推理验证训练完成不等于大功告成。我一般会从原始数据里刻意留出 10 篇评论不参与训练专门在交付前做验证。拿一篇新长评论走一遍“清洗 → 分词 → 词袋 → 主题分布”流程看看模型能不能把它归到合理主题这是检查整个流水线的最后一道闸。def predict_topic(text): tokens tokenize(clean_noise(text)) bow dictionary.doc2bow(tokens) doc_topics final_lda.get_document_topics(bow, minimum_probability0.05) dominant max(doc_topics, keylambda x: x[1]) return dominant, doc_topics # 新评论示例也可以是 sample_text 变量 sample_text 摄影和配乐都很克制但剧本节奏太慢人物成长弧光不够清晰 dominant, all_probs predict_topic(sample_text) print(主主题编号:, dominant[0]) print(主主题概率:, round(dominant[1], 3)) print(主题代表词:, final_lda.show_topic(dominant[0], topn10))如果这条评论被分到“摄影、配乐、画面”相关主题那模型行为符合预期如果被分到“剧情、人物、剧本”主题要检查一下是不是 K 太大导致主题边界模糊。这个动作看似简单但能在交付前发现数据预处理管线的隐性 bug比临时去改模型参数有用得多。手动验证两三条后再整理成论文的示例展示。6.2 确认三样东西能复现随机种子、词表、中间语料答辩现场最尴尬的事是截图和实际输出对不上。为了不翻车我在交付前会固定三样东西代码里的random_state、停用词表和用户词典、落盘的词典和语料。具体到文件至少保证data/clean/processed.pkl、data/clean/stopwords.txt、data/clean/userdict.txt都打在同一个 zip 里重新解压后按 README 跑一遍输出结果与报告截图保持一致。这也是我习惯把中间语料落盘而不是每次重跑的原因。预处理有随机性尤其是 jieba 分词在自定义词典加载顺序变化时结果可能不同把processed.pkl固定下来训练阶段每次加载统一语料模型结果就能稳定复现。若老师现场让你重训一遍也只需要从第 4 章的代码开始跑不涉及分词波动。这套流程下来LDA 不再是一个“跑完截图就交”的黑匣子而是一条能讲清楚每一步为什么这么做、参数为什么这么设的完整链路。按照这个顺序走期末大作业的源码分和报告分基本都能拿稳希望帮到你。本文还有配套的精品资源点击获取