简介:基于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
同样做 LDA,sklearn 的 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', encoding='utf-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(drop=True) 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', encoding='utf-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(r'http\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 会为每个词分配整数 ID,doc2bow把一条评论转成(词ID, 出现次数)的稀疏列表。这一步需要注意的是过滤低频词和高频词,低频词出现次数太少,对主题没有区分度;高频词如“电影”如果出现率超过 80%,会污染所有主题。
from gensim import corpora import pickle # 用分词结果构建词典 dictionary = corpora.Dictionary(df['tokens']) # no_below=2 表示词频低于 2 的词丢弃;no_above=0.8 表示出现在 80% 以上评论里的词丢弃 dictionary.filter_extremes(no_below=2, no_above=0.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_below=2如果数据量小,建议降到 1,否则大量词会被误删;no_above=0.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( corpus=corpus, id2word=dictionary, num_topics=k, random_state=42, passes=10, iterations=200, alpha='auto', eta='auto' ) perplexity = lda.log_perplexity(corpus) coherence = CoherenceModel( model=lda, texts=texts, dictionary=dictionary, coherence='c_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', ascending=False))这段代码里要用三次注意。一是texts是原始分词列表,不是corpus,CoherenceModel 需要原始词序列来计算共现,输入错误会直接报类型错。二是log_perplexity返回值通常是负数,不要看到负值就以为模型坏了,困惑度等于负对数似然除以总词数,负数正常。三是枚举区间,200 条评论以下建议 3~10,500 条以上可以放宽到 3~15,不要一上来就尝试 30 个主题。
4.2 确定主题数后正式训练:alpha、eta、passes、iterations 该设多少
K 定了之后,进入正式训练。这一步不要照抄默认参数,gensim 的默认值在电影短文本场景里表现一般,需要按语料规模调。我的习惯是把alpha设成'asymmetric',这个词是让文档-主题分布更符合“评论内容偏向少数几个主题”的直觉;eta用'auto'让模型自动学习主题-词分布的稀疏度,效果通常比固定值好。
final_lda = LdaModel( corpus=corpus, id2word=dictionary, num_topics=8, # 上面枚举出的最优 K alpha='asymmetric', # 文档主题分布不对称,允许少数主题主导 eta='auto', # 主题词分布自动估计 passes=30, # 遍历整个语料的轮数,30 轮足够小数据集收敛 iterations=500, # 每轮吉布斯采样的迭代次数 chunksize=200, # 每批处理的文档数 eval_every=1, # 每 1 批计算一次困惑度,方便看训练日志 random_state=42, # 固定随机种子,保证结果可复现 minimum_probability=0.01 # 文档主题分布中小于 0.01 的概率直接置零 ) final_lda.save('output/lda_model.model')这几个参数按重要度排序说明:random_state是优先级第一的,不固定的话每次运行主题都变,期末答辩被问到会很难看;passes在小数据集上不是越多越好,30 轮已经把收敛问题解决了,调到 100 只增加耗时;chunksize影响内存和日志频率,200 条一 批比较合理;minimum_probability=0.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, topn=15) 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', index=False, encoding='utf-8-sig') # 导出每篇评论的主主题(概率最大的那个) doc_topics = [final_lda.get_document_topics(bow, minimum_probability=0.1) for bow in corpus] df['dominant_topic'] = [max(topics, key=lambda x: x[1])[0] for topics in doc_topics] df['dominant_topic_prob'] = [max(topics, key=lambda x: x[1])[1] for topics in doc_topics] df[['movie_id', 'comment', 'dominant_topic', 'dominant_topic_prob']].to_csv( 'output/doc_topics.csv', index=False, encoding='utf-8-sig' )导出文件用utf-8-sig而不是utf-8,是给 Excel 留的后路,直接utf-8在 Windows 上用 Excel 打开会乱码。get_document_topics里的minimum_probability=0.1只保留概率超过 10% 的 主题,避免每篇评论都输出 8 个接近 0 的概率值。至于show_topic的topn=15,如果某个主题下前 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_state=42,并在报告里注明。这不只是“代码洁癖”,是保证你在答辩现场重跑一遍时,PPT 上的主题词和现场输出一致。
此外passes=30这类参数也有影响,随机种子固定后,passes越大结果越稳定,但没必要为了稳定把轮数拉满。唯一要记住的组合是random_state固定 + 语料顺序固定,语料顺序变化也会导致结果轻微波动。如果你把df.sample(frac=1)洗了数据,记得先洗再建词典,不要在训练前临时洗。
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 单调下降,新手选了困惑度最低的 K=15,结果每两个主题之间词重叠严重,根本没法贴论文。原因:困惑度衡量的是“模型重建词袋的能力”,K 越大模型容量越大,拟合越好,但拟合不等于可解释。主题数目超过真实语义结构后,LDA 会把一个完整主题拆成两半,表面困惑度下降,实际主题冗余。解决:拿困惑度和一致性一起判断,优先看一致性曲线的“肘部”,也就是一致性增幅明显变小的点。如果 K=5、6、7 一致性接近,优先选更小的 K,少而清晰的主题更适合写期末报告。
5.5 Windows 上跑源码报 UnicodeDecodeError:编码问题扎堆
现象:双击运行.py文件时,预处理那一行open('stopwords.txt')直接报UnicodeDecodeError: 'gbk' codec can't decode byte;或者训练完保存的 CSV 在 Excel 打开全变乱码。原因:Windows 默认编码是 GBK,所有open()不指定encoding时按系统编码读,读 utf-8 保存的停用词表就炸了;导出 CSV 时反过来,utf-8 写入的文件用 Excel 打开是乱码。解决:所有文件读写显式指定编码。
# 读停用词表、用户词典时,一律显式指定 utf-8 with open('data/clean/stopwords.txt', 'r', encoding='utf-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_probability=0.05) dominant = max(doc_topics, key=lambda 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], topn=10))如果这条评论被分到“摄影、配乐、画面”相关主题,那模型行为符合预期;如果被分到“剧情、人物、剧本”主题,要检查一下是不是 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 不再是一个“跑完截图就交”的黑匣子,而是一条能讲清楚每一步为什么这么做、参数为什么这么设的完整链路。按照这个顺序走,期末大作业的源码分和报告分基本都能拿稳,希望帮到你。
本文还有配套的精品资源,点击获取