BERTopic实战:从嵌入到聚类,轻松搞定语义主题建模
发布时间:2026/9/25 22:40:59来源:尧图网络
简介这套BERTopic模型教程代码包面向自然语言处理与主题建模入门者聚焦如何用BERT文本嵌入替换传统词袋表示再经UMAP降维、HDBSCAN聚类和类间词频逆文档频率生成主题解决传统LDA忽略语义关联的问题适合文本挖掘、舆情分析、文档归纳等场景。资源共14个文件906KB大小包含离线演示与交互式Python脚本、示例文本、CSV主题输出、主题分布与热力图等PNG可视化、依赖说明和README目录清晰方便边看边跑。已有151人学习下载。读者可借此完整走通BERTopic流程学会参数调节掌握层次主题与动态主题模型的扩展用法是一份能快速落地的入门代码包。1. 先跑通再理解BERTopic 模型为什么值得上手手里有一批没标好的文档想快速知道大家在聊什么主题。以前我用 LDA 跑结果里全是“价格”“商品”这种大词看不出语义边界。换成 BERTopic 模型之后文档先变成向量再聚类最后每个类自动生成主题词输出的是“语义相近的一群文档”不是“词频共现的一堆词”。这篇文章就是给你一份能直接抄的落地路径最小训练脚本、参数怎么设、模型怎么存以及最容易翻车的几个坑。适合做运营内容聚类、客服工单分主题、电商评论提炼的人。你只需要有一份文档列表最好还是没清洗过的原始文本因为清洗步骤也会一起给到。2. BERTopic 的原理骨架嵌入、UMAP、HDBSCAN、c-TF-IDF 是怎么串成一条流水线的要把 BERTopic 用明白不用啃完 transformer 的公式只需要理解它的四段接力先让嵌入模型把每篇文档变成向量再用 UMAP 把高维向量压到低维接着用 HDBSCAN 找稠密区域最后用 c-TF-IDF 给每个聚类生成主题词。每一段只做一件事但前后是强依赖关系。我见过不少人在聚类参数上反复调却忽略了嵌入层才是决定主题质量的上限这一章就把四个环节的选型逻辑讲清楚。2.1 四段式流水线为什么比 LDA 更接近人的理解LDA 和 NMF 这类传统主题模型本质是在做“文档-词共现”统计把经常一起出现的词绑成一个主题。这有一个硬伤同义词、转述句、反讽表达在字面上完全不像但在语义上是一回事。比如“充电慢”和“续航不行”在字面上没有共同词人一眼就知道都在说电池问题LDA 却很难把它们归到一类。BERTopic 的思路是先做语义嵌入。每个文档经过 sentence-transformer 编码成一个向量向量里包含的是“整句话的意思”不是单个词的词频。接下来用 UMAP 降维把几百维的向量压缩到五维左右同时保留局部相似结构。然后交给 HDBSCAN 聚类HDBSCAN 不需要预先指定主题个数能自动把稠密区域识别成一类把稀疏的点标成离群点。最后一步是 c-TF-IDF把每个聚类里的文档拼起来算“类专属词”得到人眼可读的主题词表。这就是 BERTopic 模型和 LDA 最本质的区别LDA 在词层面找共现BERTopic 在句层面找语义相近。实际操作中你会发现它对短文本、口语化文本尤其友好因为嵌入层已经把句子结构调整过了不需要像 LDA 那样拼命做同义词合并。2.2 嵌入层的选型embedding 模型怎么挑主题质量从这里决定嵌入层是整条流水线的天花板。UMAP 和 HDBSCAN 只能尽量不破坏嵌入结果没法把嵌入搞砸的语义拉回来。所以选嵌入模型比选聚类参数更值得花时间。我平时用的方案大概是这么几类嵌入模型输出维度适用语种使用场景all-MiniLM-L6-v2384英文为主先跑通流程、做快速验证单机 CPU 也能接受paraphrase-multilingual-MiniLM-L12-v2384中英混合语料里混着中文和英文时首选bge-base-zh-v1.5768中文中文评论、工单、公告类文本效果稳定text-embedding-3-small1536多语言云端批量处理不走本地显存如果语料以中文为主我一般直接试 bge 系列如果只是先验证流程all-MiniLM-L6-v2 就够跑起来快后面再换模型重训。需要特别提醒的是这些模型默认是处理句子的如果你的文档特别长比如几千字的文章最好先分段再平均否则嵌入向量会被大量无关信息稀释。还有一点容易被忽略嵌入模型加载后会占内存模型维度越高占得越多。很多人以为卡顿是聚类导致的其实换一个大维度嵌入模型后内存直接翻倍。先想清楚语料量级再决定嵌入层。2.3 UMAP 与 HDBSCAN 的联合效果两个参数就可以决定你的主题长什么样主题个数不是直接设置的而是由 HDBSCAN 的密度阈值决定的。这里最关键的两个参数一个是 UMAP 的n_neighbors一个是 HDBSCAN 的min_cluster_size。n_neighbors决定了降维时保留多大范围的局部结构。数值越小UMAP 越关注点与最近邻居的关系聚类边界会更细碎适合标签类语料数值越大结构越平滑小主题容易被吞掉。我一般从 15 开始如果你的文本主题之间差异很大可以降到 10 以下。min_cluster_size是 HDBSCAN 判断“多少篇文档才能算一个主题”的下限。它直接控制主题个数值越小主题越多越细值越大主题越少越粗。默认 10 适合几百到几千篇的语料如果是几万篇我一般直接提到 20 到 50否则会跑出一堆占比不到 1% 的碎片主题。还有一个组合习惯值得抄UMAP 的metric用cosine降维后 HDBSCAN 的metric用euclidean。原因是降维后的空间已经接近局部欧氏结构再在原始高维空间用余弦距离聚类反而费时。参数作用我的常用取值n_neighbors降维时保留的邻居范围15小语料可降到 5-10n_components降维目标维度5不要直接降到 2min_dist降维后点的紧凑程度0.0min_cluster_size主题最小文档数10大语料 20-50min_samples离群点判定敏感度1噪声大时提到 5random_state随机种子42n_components这里多说一句:很多人为了可视化直接降到 2但聚类需要更多信息量2 维会丢失太多结构。正确做法是训练时降到 5 维可视化时单独再降一次到 2 维。2.4 c-TF-IDF从“聚在一起”到“主题是什么词”的最后一步聚类完成后每个类拿到一批文档编号。问题是这批文档的共同点是什么c-TF-IDF 解决的就是这个。它的逻辑比经典 TF-IDF 更直观先把每个类里的所有文档拼成一整篇“类文档”统计每个词在这个类里出现的次数然后把“该类出现这个词的文档比例”作为惩罚项。一个词如果在所有类里都频繁出现说明它没有区分度权重被压低一个词只在当前类里高频率出现它就是这类的高权重主题词。所以 c-TF-IDF 输出的是“对区分这个类最有贡献的词”而不是“这个类里出现最多的词”。这也是为什么 BERTopic 主题词表经常看起来比 LDA 干净很多的原因。topic_model.get_topic(0)返回的结果就是按这个权重排出来的前几个词基本一眼能看出这一类在讲什么。需要留意的是c-TF-IDF 是在词表层面工作的它不感知语义。如果文本里有两个同义词“性价比”和“划算”它们的分数是分别计算的不会自动合并。想要让主题词表更紧凑后面用 MMR 做多样性控制是一种办法但如果你想合并同义词得在预处理阶段自己处理或者接受主题词表里同时出现这两个词。3. 用代码跑通 BERTopic从原始文本到主题图和模型保存这一章是全文核心我按自己平时在项目里的步骤来先列最小依赖再给数据清洗函数然后是完整训练脚本最后是模型保存和复用。你按顺序执行能得到一份可用的 BERTopic 模型而不是一个 demo。3.1 环境准备与最小依赖先少装别把环境搞得一塌糊涂BERTopic 的安装比想象中轻核心依赖就几个。新手最容易踩的坑是把umap-learn装错成umap两个包都能 import 成功但 API 完全不一样后面跑聚类会莫名报错。pip install bertopic pip install umap-learn hdbscan plotly说明bertopic安装时会把sentence-transformers、scikit-learn一起拉进来所以不用单独装太多。plotly是用来画主题图的想省事可以不装但visualize_topics()会失效。hdbscan在 Windows 上偶尔需要预编译包如果装不上检查一下 Python 版本是否在 3.9 以上我见过 3.7 老环境装 hdbscan 直接把安装过程卡死的情况。装完后可以快速验证一下import bertopic from bertopic import BERTopic print(bertopic.__version__)能看到版本号说明环境没问题。版本不要太老0.16 之前的版本 API 差异比较大很多参数名对不上直接装最新版就行。3.2 数据准备与清洗哪些预处理真的值得做BERTopic 对停用词不太敏感因为它有嵌入层不像 LDA 那样需要先去掉大量停用词才能看到主题。但有两类东西必须处理HTML 标签和超链接。它们会占据 token 数量干扰嵌入向量的语义。import re def clean_text(text: str) - str: text re.sub(r[^], , text) # HTML 标签 text re.sub(rhttp\S, , text) # 超链接 text re.sub(r\b\d{2,}\b, NUM , text) # 长数字替换成占位符 text re.sub(r[ \t], , text) # 连续空白压缩 return text.strip() docs [clean_text(d) for d in raw_docs]这段代码做了三件事。第一是去掉 HTML 标签标签本身没有语义还会造成不同页面模板产生伪主题。第二是去掉超链接链接字符串对主题判断没有帮助。第三是把连续两位以上的数字替换成NUM占位符防止“2021”“2023”这种年份数字被聚成一个主题。值得说明的是中文语料我不建议在这里做分词。很多人习惯了先分词再去停用词但 sentence-transformer 的 tokenizer 自带分词能力强行分词反而破坏句子结构。先跑一遍聚类看到主题词表有噪音再回头补清洗比一开始就做一堆预处理更高效。3.3 最小训练脚本训练、看主题、画图一条龙环境准备好、数据清洗完就能训练了。下面这个脚本是完整可跑的直接把模型、UMAP、HDBSCAN 都显式传进去方便后面调参。from bertopic import BERTopic from umap import UMAP from hdbscan import HDBSCAN from sentence_transformers import SentenceTransformer docs [...] # 清洗后的文档列表 embedding_model SentenceTransformer(sentence-transformers/all-MiniLM-L6-v2) umap_model UMAP( n_neighbors15, n_components5, min_dist0.0, metriccosine, random_state42, ) hdbscan_model HDBSCAN( min_cluster_size10, min_samples1, metriceuclidean, cluster_selection_methodeom, ) topic_model BERTopic( embedding_modelembedding_model, umap_modelumap_model, hdbscan_modelhdbscan_model, ) topics, probs topic_model.fit_transform(docs) print(topic_model.get_topic_info().head(10))这里每个组件都有明确分工。embedding_model负责把文档变成向量这是语义理解的源头。umap_model负责把高维向量降到 5 维n_neighbors15保留局部结构random_state42保证可复现。hdbscan_model负责聚类min_cluster_size10控制最小主题规模这个值在你语料变大后要跟着调大。fit_transform返回两个结果topics是每篇文档被分配的主题编号probs是模型对该分配的置信度。注意一点HDBSCAN 本质是硬聚类probs的数值参考意义有限不需要对它做太多解读。训练完先看主题总览topic_info topic_model.get_topic_info() print(topic_info[[Topic, Count, Name]])Topic列里的-1表示离群点也就是 HDBSCAN 认为不属于任何主题的文档。这个后面会专门讲怎么处理。接着看具体主题的词表topic_model.get_topic(0)返回的是(词, 权重)列表权重来自 c-TF-IDF。一眼扫过去就能判断这个主题是否合理比如全是数字或全是停用词就需要回炉清洗。画图看整体结构也很简单topic_model.visualize_topics()这个函数会生成一个交互式气泡图每个气泡代表一个主题气泡大小对应文档数量。图是用 plotly 渲染的输出为 HTML可以直接在浏览器里打开也可以存成文件发给同事看。3.4 保存与复用模型体积、加载和后处理的“后悔药”训练一次可能要几分钟到几十分钟如果每次调参都要重新训练效率太低了。BERTopic 提供了标准的保存和加载接口topic_model.save(bertopic_model_dir) loaded_topic_model BERTopic.load(bertopic_model_dir)save会创建一个目录里面包含当前模型参数和内部状态。加载后可以用同样的接口继续分析但要注意训练用的文档不会保存在模型里topics也不在模型中保留。如果你想保存每篇文档的主题分配结果直接存数组import pandas as pd result_df pd.DataFrame({doc_id: list(range(len(docs))), topic: topics}) result_df.to_csv(topic_result.csv, indexFalse)我一般会把三样东西一起存模型目录、主题分配 CSV、get_topic_info()的主题表。这样后面无论是重新画图、分析主题占比还是给新文档预测主题都有依据。给新文档预测主题时需要重新做嵌入new_topics, new_probs loaded_topic_model.transform(new_docs)注意transform依然会使用模型内部的 embedding 模型所以新文档也要先做同样的清洗。如果数据量大提前算好 embedding 传给transform会更稳妥。4. 避坑与排查真实语料上 BERTopic 最容易翻车的五个地方BERTopic 看起来代码少但真实项目里翻车点非常集中。以下五条是我在客服工单、电商评论、舆情文章三类语料上都遇到过的问题按“现象 → 原因 → 解决”写清楚方便你对号入座。4.1 主题词全是数字和符号清洗不到位HDBSCAN 把噪声也聚成了类现象get_topic(0)返回的前几个词是“2021”“12345”“500”完全看不出主题语义。原因原始文本里的数字和符号没有处理嵌入模型把它们当成正常 tokenUMAP 降维后这些数字因为格式相似被聚在一起。解决回到清洗函数把连续数字替换成占位符text re.sub(r\b\d{2,}\b, NUM , text)4.2 主题词全是“的、了、在”嵌入太在意语法词c-TF-IDF 压不住现象主题词表前十个词里有一半是停用词主题虽然能区分但词表没法直接展示给业务方。原因c-TF-IDF 计算的类是全部文档拼起来的通用词在类内出现次数高如果该类独有词不够强停用词就排上来了。解决不要先删停用词而是把停用词表传给 CountVectorizerfrom sklearn.feature_extraction.text import CountVectorizer vectorizer CountVectorizer(stop_wordsenglish, min_df2) topic_model BERTopic(vectorizer_modelvectorizer, ...)英文语料直接传stop_wordsenglish中文语料可以传一个自定义停用词 list。min_df2表示词至少在两类中出现过才进入词表能过滤掉只在一篇文章里出现的怪词。4.3 主题每次跑都不一样UMAP 随机性和模型没锁种子现象同一份语料两次训练得到不同数量的主题同一篇文档落入不同主题。原因UMAP 初始化带随机性HDBSCAN 的聚类顺序也会受影响。解决给 UMAP 固定random_state42同时保存模型作为基准umap_model UMAP(random_state42, ...)如果固定了种子还是结果不同说明语料里部分主题边界太弱模型本身不稳定。这种情况不要强行调参而是回到嵌入层换一个更强的模型比如从 MiniLM 换到 bge。顺手保存一次模型后面不管怎么重跑都有参照物。4.4 离群主题太多HDBSCAN 把所有点都赶出群了现象get_topic_info()里-1主题的文档数占比超过 30%大量文档没有被归类。原因min_cluster_size设置太大或者语料本身噪声高HDBSCAN 倾向于不聚类把很多稀疏点标为离群点。解决先调参数再看要不要用reduce_outliers回填。new_topics topic_model.reduce_outliers(docs, topics, strategyc-tf-idf)这个函数会把离群点按就近主题的 c-TF-IDF 相似度重新分配返回一个新的主题数组。strategy可以选c-tf-idf或embeddings前者快后者效果略好但需要重新计算嵌入。注意reduce_outliers不会修改原来的topics需要把返回值接住。4.5 几万篇文档内存爆掉embedding 全留在内存里现象文档量到两三万时训练脚本内存冲到十几 G甚至直接卡死。原因BERTopic 默认流程会把所有文档一次性编码成向量矩阵UMAP 和 HDBSCAN 还要在此基础上复制计算。解决把嵌入计算拆成批量提前算好再传给fit_transform。import numpy as np def batch_encode(docs, model, batch_size256): for i in range(0, len(docs), batch_size): yield model.encode(docs[i:i batch_size], show_progress_barFalse) embeddings np.vstack(list(batch_encode(docs, embedding_model))) topic_model.fit_transform(docs, embeddingsembeddings)这样fit_transform会自动跳过内部嵌入计算只做降维和聚类。我一般两万篇以内直接np.vstack超过五万就分批写入.npy文件再用np.load配合mmap_moder做映射读取内存压力会小很多。5. 主题太多怎么办降维、去冗余和可用性验收训练完成后最常遇到的情况是主题数量太多跑出来三四十个没法汇报也没法做后续标注。BERTopic 提供了专门的合并机制不用重新训练。5.1 一次把 40 个主题压到 10 个nr_topics 的正确打开方式直接调reduce_topics就能在主题层面做合并topic_model.reduce_topics(docs, topics, nr_topics10) topic_model.get_topic_info()它的原理是计算主题之间的 c-TF-IDF 相似度把相近主题合并。nr_topics可以传固定数字也可以传auto让模型自动决定。我更推荐传固定数字可控性强业务上说要 10 个就给 10 个。这个操作不会动文档嵌入所以速度很快。做之前先save一份原始模型方便对比合并前后效果。5.2 主题词去重MMR 让前 10 个词不全是同义词有些主题的词表全是同义词“价格”“价钱”“售价”反复出现。这不是模型错了而是 c-TF-IDF 只看权重不看词汇多样性。给模型加一个 MMR 就能压住from bertopic.representation import MaximalMarginalRelevance mmr_model MaximalMarginalRelevance(diversity0.4) topic_model BERTopic(representation_modelmmr_model, ...)diversity在 0.3 到 0.5 之间比较稳值越大主题词越多样。这步只影响主题词表的展示不影响聚类结构可以放心调。5.3 验收方法不要只看图抽样读文档比什么都管用主题模型的结果必须回到文档里验证。我通常的做法是选一个主题随机抽三到五篇原始文档通读一遍确认它们是不是真的在聊同一件事。import random topic_id 0 indices [i for i, t in enumerate(topics) if t topic_id] for idx in random.sample(indices, min(3, len(indices))): print(docs[idx][:100])这个方法看似简单但能拦住大部分“图好看、实际没法用”的模型。我的习惯是把语料版本、嵌入模型名、UMAP 参数、HDBSCAN 参数、随机种子写进一个meta.json放进模型目录三个月后回来跑不会对着一个不知道参数的模型干瞪眼。这个流程走完你手里的 BERTopic 模型就不再是黑匣子而是一份能落地、能复现、能交付的结果。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网