有段时间我接到一个挺折磨人的活:手里攒了接近八百条名人语录,形态五花八门,有某位企业家采访中的即兴总结,有某位作家的访谈段落,也有某位导演在发布会上的临场发挥,短的十几个字,长的也就一两百字,散落在不同渠道的摘抄里。需求很明确:按主题归档,搞清楚这群人反复在讲什么。我第一反应是老规矩上 LDA,结果跑完看主题词表,几乎每个主题都是“生活/快乐/努力/坚持”这种级别的空话,跟没分类差不多。后来我换成 BERTopic,花了大概两周把流程打磨顺,才终于能稳定输出“谁在反复讲什么、每个主题下面哪几句最典型”的结果。这篇就把完整的批量主题建模流程写下来,包括数据清洗、流水线设计、调参思路,以及我踩掉的那些坑。
1. 语录这种短文本,凭什么值得换一条建模路线
1.1 短文本天生让传统主题模型抓瞎
LDA这类传统主题模型依赖词袋统计,核心假设是“同一主题下的词会反复共现”。这句话放在长文档里成立,因为一篇文章几百上千字,一个主题底下的关键词怎么也能重复出现几次;但名人语录的平均长度可能只有三五十个字,共现矩阵不但稀疏,而且稍微换一个说法就断了联系。
举个例子,有人习惯说“自律”,有人会讲“自控力”,还有人会描述成“克制住即时满足的冲动”。在 LDA眼里,这三者之间没有任何交集,因为字面完全不重叠。可人类一眼就能看出这是同一类话题。语录恰恰又是语义浓缩型文本,说话人为了表达精炼,反而会刻意避开重复词汇,这等于把传统主题模型最依赖的线索亲手掐掉了。
所以我不建议在小样本、短句子场景里继续折腾 LDA 降维那套优化,调参调到头也只是从一个很孬的结果变成不那么孬的结果。真正需要换的是底层建模方式:先理解语义,再谈主题聚合。
1.2 BERTopic 并不是一个词袋模型,而是一条可组合的流水线
BERTopic 这个方案,严格说是“句子嵌入 + 降维 + 聚类 + 关键词提炼”这套流水线的打包实现。它整个流程分四步:
- 把每条语录通过预训练模型转成语义向量,意思相近的句子在向量空间里距离也接近;
- 用 UMAP 把高维向量压到低维空间,同时尽量保留句子之间的局部邻接关系;
- 用 HDBSCAN 做密度聚类,自动发现簇结构,不需要预先指定主题数量;
- 把每个簇里面的句子合并成一段“伪文档”,再用 c-TF-IDF 提取这个簇的代表词,最后拼成一个主题名。
这套流程对短文本特别友好,因为聚类依据是句子语义而不是字面共现。”自律“和”自控力“如果放在同一个语境里,向量距离很近,自然会被分进同一个主题。对语录这种一句话一个意思的文本,这种处理方式比传统词袋模型靠谱太多。
2. 数据准备:把几百条名人语录整理成可建模的语料
2.1 最小可用Schema:先给每条语录一个身份
很多人拿到语录文本就开始调模型,结果主题怎么都分不顺,回头一看,连“这条是谁说的、出自哪个场合”都没记录。做批量建模不是跑一次 toy demo,我建议任何项目都把语料结构化成表格,至少包含四列:id、person、source、text。
| 字段 | 含义 | 示例 |
|---|---|---|
| id | 语录唯一编号 | 1001 |
| person | 说话人 | 某企业家 |
| source | 出处场景 | 某次访谈 |
| text | 语录正文 | 能力不是一蹴而就的,重复和复盘才是长期杠杆 |
person字段尤其重要。批量建模如果只关心“全局有哪些主题”,可能觉得这个人名列可有可无;但一旦你想回答“某位名人的观点集中在哪几个主题上”,没有这个字段就得返工。source字段也别删,它会在后续抽样本核查时帮你快速定位原文语境。
2.2 清洗细节,少做一步都可能分崩离析
语录这种语料,清洗重点跟新闻稿、产品评论不一样。它最常见的污染来自三块:复制的网页残留、重复转述、以及被截断后留下的残缺字符。下面这段清洗函数基本覆盖了我的日常需求:
import re import pandas as pd def clean_quote(text): # 去掉多余空格、换行 text = str(text).replace('\u3000', ' ').replace('\n', ' ').strip() # 去掉网址、@用户、#话题# 这类平台标记 text = re.sub(r'https?://\S+', '', text) text = re.sub(r'#.*?#', '', text) # 合并连续空白 text = re.sub(r'\s+', ' ', text) return text.strip() df['text'] = df['text'].apply(clean_quote)清洗完之后,至少还要做三件事:去重、筛短、限长。
# 完全重复的语录只留一条 df = df.drop_duplicates(subset=['text']) # 过滤太短的噪声 df = df[df['text'].str.len() >= 6] # 过滤大段文章,避免把整篇内容塞进模型 df = df[df['text'].str.len() <= 200]太短的内容比如“共勉”“加油”,它们本身不具备可独立的主题信息,放进语料只会被 HDBSCAN 当成一堆离群点;太长的内容本质上已经不是“语录”,模型如果按默认长度截断,语义向量可能会被截歪。注意清洗和去重必须在分词之前完成,尤其是去重,否则同一句话在网上流传几十遍,模型会误认为这是一个信息量巨大的独立主题,把聚类带偏。
2.3 这一阶段先别纠结中文分词
中文做 NLP 大家总习惯先问一句:“要不要用 jieba 分词?”我的建议是:这一步先不用。真正驱动 BERTopic 聚类的是句子嵌入,它吃的是完整的原始句子,而不是切碎后的词序列。分词只影响后面 c-TF-IDF 提取主题词时的展示效果,不影响簇的划分。先跑通流程,等主题词乱成单字再说。
3. 从句子到主题:一条流水线拆开看
3.1 嵌入模型选择:中文语料别硬套英文模型
BERTopic 里对主题质量影响最大的,不是聚类参数,而是嵌入模型选没选对。一旦句子向量本身分不开“自律”和“自控力”,后面怎么调 UMAP、HDBSCAN 都没用。
我常用的是下面几个,按场景选:
| 模型 | 特点 | 适用场景 |
|---|---|---|
all-MiniLM-L6-v2 | 英文效果好、轻量快速 | 英文语录或英文访谈 |
paraphrase-multilingual-MiniLM-L12-v2 | 多语言、体积适中 | 中英混排、需要先跑通的验证环境 |
BAAI/bge-small-zh-v1.5 | 中文效果好、384维向量、资源占用低 | 中文名人语录首选 |
shibing624/text2vec-base-chinese | 中文短文本匹配表现稳定 | 中文语录、句子相似度要求较高的场景 |
如果你的语料全中文,我最推荐BAAI/bge-small-zh-v1.5,短句语义区分度够用,速度也快。遇到中英混杂语料,最好别直接混在一起,先把英文语录单独拆出来用多语言模型,或者干脆按语言分两个模型,否则最后会得到一堆语言混杂、难以解释的簇。
3.2 先降维再聚类,为什么不是直接KMeans
句子嵌入的维度通常从 384 到 1024 不等。直接在这上面做聚类,不仅慢,而且高维空间里距离度量很容易被稀释。UMAP 在这里的作用是把高维向量压缩到几维到十几维的低维空间,并且优先保持“相近的句子仍然相近”。
UMAP 有两个参数要格外留意:n_neighbors和min_dist。
n_neighbors控制降维时考虑多大范围的邻居,值越大越看全局结构,值越小越看重局部抱团;min_dist控制压缩后点与点的最小距离,设成 0.0 时会让簇更加紧实,簇间边界更清晰。
聚类用 HDBSCAN 而不是 KMeans 的原因也很直白:KMeans 要求你提前指定主题数量,又默认每个点必须属于一个簇。名人语录里总有几句是孤例,硬塞进某个簇只会污染主题。HDBSCAN 是密度聚类,它主动判断哪些点所在区域密度不够,直接标成 -1 也就是离群主题。
3.3 BERTopic 的最小生产代码
把组件拼起来以后,代码量其实不大。下面这段是我跑中文语录时会用的最小骨架:
from bertopic import BERTopic from sentence_transformers import SentenceTransformer from umap import UMAP from hdbscan import HDBSCAN docs = df['text'].tolist() embedding_model = SentenceTransformer('BAAI/bge-small-zh-v1.5') umap_model = UMAP( n_neighbors=15, n_components=5, min_dist=0.0, random_state=42 ) hdbscan_model = HDBSCAN( min_cluster_size=5, min_samples=1, metric='euclidean', prediction_data=True ) topic_model = BERTopic( embedding_model=embedding_model, umap_model=umap_model, hdbscan_model=hdbscan_model, calculate_probabilities=False, verbose=True ) topics, probs = topic_model.fit_transform(docs)calculate_probabilities=False是有意为之。只有在需要给每条语录输出“它属于每个主题的完整概率分布”时才要开 True;否则它会占用大量内存,尤其语料上千条以后,容易直接把进程撑爆。
跑完以后,topics是一个与docs等长的列表,每个元素对应该语录被分到的主题编号。-1 表示 HDBSCAN 认为是离群点。想看每个主题长什么样,直接调用:
topic_info = topic_model.get_topic_info() print(topic_info.head(20))3.4 中文主题词乱码的补救:给 c-TF-IDF 配一个分词器
跑完上面的代码,你会发现主题分布能出来,但主题词可能长这样:“努”“力”“坚”“持”——全是单字。这是因为 c-TF-IDF 默认用的是按空格分词的 CountVectorizer,对中文这种没有天然分隔的文本不友好。
解决办法是在 BERTopic 的 vectorizer 参数里注入 jieba 分词器:
import jieba from sklearn.feature_extraction.text import CountVectorizer from bertopic import BERTopic vectorizer_model = CountVectorizer( tokenizer=lambda text: jieba.lcut(str(text)), ngram_range=(1, 2), token_pattern=None ) topic_model = BERTopic( embedding_model=embedding_model, umap_model=umap_model, hdbscan_model=hdbscan_model, vectorizer_model=vectorizer_model, calculate_probabilities=False ) topics, probs = topic_model.fit_transform(docs)改完之后,主题词会变成“努力/坚持/重复”这种可读的关键词组合。注意 BERTopic 不同版本的参数名可能不一样,新版本叫vectorizer_model,老版本文档里经常写成ctfidf_model,碰到报参数错误,先查一下安装的版本 API。
提示:分词只影响主题词的展示和后续基于 c-TF-IDF 的离群点回收,不会改变聚类结果本身。
4. 批量建模实操:要同一个主题空间,还是各建各的
4.1 全量一把梭:可跨人物比较主题热度
如果你的分析目标不是“给某个人单独做档案”,而是想比较多位名人之间的话题分布,那就别按人物分模型。把所有语录扔进同一个 BERTopic 模型,得到一个统一主题空间,再按人物统计主题占比。
典型做法是先构建一个结果表,把人物和主题编号放在一起:
import pandas as pd result = pd.DataFrame({ 'person': df['person'], 'source': df['source'], 'text': df['text'], 'topic': topics }) print(result.head())然后按人聚合,看看每个人最集中的前几个主题:
for person, sub in result.groupby('person'): counts = sub['topic'].value_counts().head(3) print(f'\n===== {person} =====') for topic_id, cnt in counts.items(): words = [w for w, _ in topic_model.get_topic(topic_id)][:8] sample = sub.loc[sub['topic'] == topic_id, 'text'].iloc[0] print(f'主题{topic_id} ({cnt}条): {", ".join(words)}') print(f'示例: {sample}')这样出来的结果可以直接进汇报:某企业家反复讲的主题集中在“组织成长”“复盘机制”,某作家的主题集中在“自我审视”“写作训练”。同一主题空间让比较成为可能。
4.2 按人物分别建模:适合做个人专题档案
另一种更细的做法是按人物分组,每位名人单独拟合一个主题模型。优点是主题粒度往往更细腻,因为模型只见过这个人的语录,不会为了照顾所有人而把簇拉得很大。缺点是各人的主题编号没有可比性,某企业家的主题 0 和某作家的主题 0 说的完全是两回事。
如果目标就是“给每个人生成一份独有的话题档案”,可以循环处理:
models = {} for person, sub in df.groupby('person'): sub_docs = sub['text'].tolist() model = BERTopic( embedding_model=embedding_model, umap_model=umap_model, hdbscan_model=hdbscan_model, vectorizer_model=vectorizer_model ) sub_topics, _ = model.fit_transform(sub_docs) models[person] = model info = model.get_topic_info() print(f'\n===== {person} =====') print(info[['Topic', 'Count', 'Name']].head(10))这种写法听着简单,但要留意每个子集的大小。如果某位名人的语录就 20 条,HDBSCAN 很难在这点规模上形成有意义的簇,大概率全变成 -1。我一般会设置一个最小数量门槛,低于 30 条的要么并入全量模型分析,要么手动整理,而不是硬跑一个模型。
4.3 新语录如何继续进入已有模型
做名人语录往往是持续更新的,每隔几周就有一批新采访发布。这时候把全量语料重新 fit 一遍,不仅耗时,还会让旧主题编号反复变动,核对报告会非常痛苦。
更推荐的做法是用已经拟合好的模型对新句子做 transform:
new_topics, new_probs = topic_model.transform(new_docs)新语录会被分配到已有主题编号里,主题名称保持稳定。缺点是如果新语录里出现一个以往从未见过的全新话题,它有可能也会被硬塞进某个旧主题,或者全部变成 -1。所以每次更新完后,我习惯检查一遍 -1 占比和新主题采样,超过阈值再考虑是否重新全量建模。
5. 调参与迭代:把主题模型从玄学变成可解释
5.1 参数对照表:先动哪些,后动哪些
BERTopic 可调参数不少,但绝大多数项目只需要关注下面这几组。我给每组都标了“名人语录场景下的常见取值范围”:
| 参数 | 作用 | 语录场景常用值 |
|---|---|---|
n_neighbors | 控制 UMAP 保留局部还是全局结构 | 10~20,越小越紧凑 |
n_components | 降维后的维度 | 5~10,不要只用 2 |
min_dist | 降维后点的最小间距 | 0.0~0.1 |
min_cluster_size | 一个主题最少多少条语录 | 5~10 |
min_samples | 控制离群点的严格程度 | 1~2,越小离群点越少 |
calculate_probabilities | 是否输出完整概率分布 | 大语料设 False |
调参优先级我推荐这样:先选对嵌入模型,再调n_neighbors和min_dist,然后看min_cluster_size,最后才碰min_samples。很多人一开始就动min_cluster_size,结果发现主题数量忽多忽少,其实根因是降维阶段把局部结构揉乱了。
5.2 主题合并与离群点回收
如果模型跑完后发现有 2 个主题明显是同一种话题,比如“时间管理”和“高效工作”,可以用主题合并把它们归成一个:
# 假设主题2和主题5都想并入主题1 mapping = {2: 1, 5: 1} new_topics = topic_model.merge_topics(docs, topics, mapping) topic_model.get_topic_info()合并操作对语录型语料非常常见,因为同一句话可能从不同侧面切入,聚类会把它切碎成相邻主题。合并之后记得重新核对主题样本,别出现一个大杂烩。
离群点回收则是另一个常用操作。HDBSCAN 会把一些孤例全部标成 -1,可这些句子未必没主题,只是密度不够。reduce_outliers有两种策略:
# 基于主题关键词去回收 new_topics = topic_model.reduce_outliers(docs, topics, strategy="c-tf-idf") # 基于嵌入相似度去回收 new_topics = topic_model.reduce_outliers(docs, topics, strategy="embeddings", threshold=0.1)我的经验是:embeddings策略在短文本上效果更好,因为它利用句子向量找最近邻主题;c-tf-idf策略更快,但偶尔会把句子塞进只靠几个共词硬凑的主题里。
5.3 怎么判断主题质量?别只看主题词
主题词表只是个速览入口,真正判断主题好不好,必须抽原文看。我自己会写一个简单的采样函数,每个主题抽出 3~5 条原始语录,读一遍。
如果一个主题下的 5 条语录读起来讲的是同一件事,这个主题才算成立。如果 5 条语录读完感觉彼此毫无关联,那不管主题词打印得多顺眼,这个簇都是伪主题,需要回到底层重新调参。这个检查步骤不能省,尤其名人语录这种很多句子带有潜台词和隐喻的文本,光看关键词反而会被误导。
6. 我踩过的七个坑,按症状和修复方式都记在这里
6.1 主题词全是单字乱码:中文 c-TF-IDF 没喂分词器
症状是聚类没问题,主题分布看着也正常,但打印出来的主题词全是单字。原因就是前面说的 CountVectorizer 默认按空格分词,中文被切成一个个单字。修复方式是注入 jieba 分词器,并把ngram_range开到(1, 2),让主题词保留“自我/认知”这类双字组合。
6.2 跑出一大堆 -1 离群主题:先别急着怪模型
HDBSCAN 对短文本本来就偏保守,语录没有足够多的上下文支撑密度,-1 占比 20% 是很常见的事。处理思路不是把min_cluster_size降到 2 强行删掉离群点,而是先用低一点的min_samples缓解,再对实在回收不了的句子用reduce_outliers拉回最近主题。硬降min_cluster_size的最大副作用是产生大量 2-3 条语录的小碎簇,反而不好解释。
6.3 重复语录被模型当成一个“主题”
我遇到过全场最大主题不是“创业”也不是“成长”,而是一句网上被反复转发的名人语录,一模一样出现了几十次。模型不傻,重复文本在向量空间里完全重合成一个高密度区,自然会被聚成一个超大的单一主题。修复方式就是在数据清洗阶段做drop_duplicates,顺带把只改标点的近似重复也处理掉。
6.4 换台机器重跑,结果大变样
BERTopic 里有随机性,尤其 UMAP 本身不是确定性算法。同一份语料如果不设random_state,三次跑三次主题编号可能都对不上。固定随机种子是最基本的操作,另外尽量固定嵌入模型和HDBSCAN(min_samples=1)这类关键参数,否则排查问题时会分不清结果差异到底来自数据还是来自随机过程。
6.5 长文本被截断导致语义漂移
有段时间我把名言和整段演讲混在一起建模,出现一个诡异主题,里面既有“战略”,又有“天气”。原因是一些长段落超过句子嵌入模型的最大输入长度,后半段被截掉,语义向量里混入了奇怪的片段。解决方式是设置长度过滤,把所有超过 200 字的段落切分成更小的句子,或者干脆从语料里剔除,让模型只处理纯语录。
6.6 模型语言和语料语言不匹配
英文模型跑中文语料是最典型的选择性翻车。现象是聚类不那么糟糕,但主题词全是拉丁字母或者翻译腔。修复方式也简单:中文语料用中文预训练模型,混合语料用多语言模型,别指望英文模型能理解“复盘”和“反思”的语义关系。
6.7 概率矩阵把内存撑爆
开启calculate_probabilities=True后,每条语录都要算它和每个主题的概率,语料 2000 条、主题 20 个,概率矩阵规模已经不小;主题数继续增大时,内存占用会指数级提升。如果不需要概率分布做后续分析,始终建议关掉;确实要用概率,也先在小规模子集上验证内存消耗。
最后再说个我的个人习惯:跑正式全量之前,先抽 50 到 100 条语录做小样,把小样上的主题词表和原文采样打印出来,人工扫一遍。扫完再决定要不要调参数。我见过太多人一上来就把几千条语料全量扔进模型,跑出一个主题列表就开始发愁结果不可解释。主题模型永远是个需要来回对照原文的工具,调参顺序对了,结果就不会太离谱。