1. 先把问题摆到桌面上:LDA 到底"不稳"在哪
做主题建模的人大概都遇到过这种场面:同一份语料、同一个主题数、同一个工具包,只是把随机种子换了个数字,跑出来的主题就换了一副面孔。上一轮还是"用户投诉、退款、客服"凑在一起,下一轮这几个词被拆到两个主题里,其中一个还混进了"物流、发货"。你拿着两组结果去汇报,别人问一句"哪个是对的",很难回答。这就是 LDA 的稳定性问题,也是我最初被"使用聚类评估 LDA 的稳定性并提高其可靠性"这个题目吸引的原因——它讨论的不是怎么把模型跑起来,而是怎么判断跑出来的东西能不能信。
先把术语拆开。**稳定性(stability)**指的是模型在输入或初始化发生微小变化时,输出是否保持一致;**可靠性(reliability)**范围更大一点,包含稳定性,还包括结果是否可解释、是否经得起人工抽检、换一批同分布数据是否还能复现同样的主题结构。LDA 用的是变分推断或者吉布斯采样,目标函数是非凸的,落在哪个局部最优取决于初始化和采样路径,所以"每次跑都不一样"不是 bug,是算法本身的性格。
这篇工作的思路很直白:既然不同次训练得到的是若干组主题,那就把每个主题当成一个对象,用聚类把不同次运行中"说的是一件事"的主题归到一堆,再看这些堆得有多整齐。整齐度高,说明不同次运行反复收敛到同一套结构,模型稳定;整齐度低,说明每次都在胡说,主题数或者预处理多半有问题。这个转化很关键——它把"我觉得不稳定"这种主观印象,变成了一个可以算出来、可以画曲线、可以拿来选参数的数值。
适合谁看?如果你做过或准备做文本主题分析,被"主题数选几个""跑出来的结果能不能用"卡住过,这篇内容就是给你的。我会按读这类工作的顺序来讲:它为什么这么设计、指标怎么算、代码怎么写、参数怎么定、哪些地方会翻车。前置知识只需要了解 LDA 的基本生成过程,不要求推导变分下界。
2. 读这篇工作之前,先想明白三个"为什么"
2.1 为什么困惑度靠不住
LDA 的教科书指标是困惑度(perplexity),衡量模型对未见文档的预测能力,数值越低越好。问题在于,困惑度对主题可解释性几乎不敏感。我做过一组对照:K 从 5 调到 40,困惑度在 K=25 附近到了一个平台,但 K=25 的那组主题里已经出现大量"万能主题"——高频词全是"产品、问题、服务、时间"这类在语料里到处出现的词,人看起来完全分不清主题在讲什么。困惑度对此毫无反应,因为它只关心词的概率质量分布得准不准,不关心这堆概率是不是聚成了一个有意义的概念。
第二个毛病是,困惑度本身也受初始化影响。同一个 K 跑十次,困惑度能有 2%~5% 的波动,你在 K=18 和 K=19 之间比困惑度,差异很可能小于同参数下的随机波动,等于在噪声里挑最优。所以做主题模型的人后来普遍转向主题一致性(topic coherence),比如 C_v、U_Mass,用滑动窗口共现来打分。但一致性也有自己的问题:它对分词粒度、停用词表、语料规模都很敏感,跨语料不能直接比大小。
这篇工作的价值在于提供了第三把尺子——跨运行的稳定性。它不评价"这组主题好不好",评价的是"这组主题是不是稳定复现"。三把尺子一起看,选参数才有依据:一致性高但稳定性差,说明结果碰运气;稳定性高但一致性差,说明语料本身没什么结构,或者预处理把信息洗掉了。
提示:不要单独拿任何一个指标拍板。我现在的习惯是固定一套预处理,同时算一致性均值、跨运行 ARI、人工抽检 3~5 个主题,三个都过关才认为 K 可以定。
2.2 为什么用聚类,而不是直接一一匹配
最朴素的对齐方式是:两次运行各 K 个主题,两两算相似度,用匈牙利算法(scipy.optimize.linear_sum_assignment)做最优一一匹配,然后取匹配上的相似度均值。这个方法能用,但有两个硬伤。第一,它假设两次运行的主题数完全一样,而现实中我们经常在不同 K 之间比较,或者某次运行因为收敛差异出现"合并主题"。第二,它强迫一一对应,如果第一次运行把"A、B"合成一个主题,第二次拆成两个,匈牙利算法只能硬配一个,另一个变成孤儿,把整体分数拉低,但这个低分反映的是粒度差异,不是你想要的"不稳定"。
聚类绕开了这个限制。做法是把 R 次运行的所有主题(共 R×K 个)堆成一个池子,每个主题表示成一个向量(top 词的概率分布,或者全体词上的概率向量),然后对这个池子做聚类。理想情况下,同一个"真实主题"在不同运行里的 R 个副本会落进同一簇,簇的成员分布越均匀(每簇正好 R 个、每个运行各贡献一个),说明一致性越好。
再用外部指标去衡量"簇标签"和"运行编号"这两个划分的一致性——注意这里有个反直觉的点:我们要的不是簇分得好,而是簇与运行编号无关。如果聚类结果里每个簇都恰好包含来自不同运行的相似主题,说明不同运行说的是同一件事;如果簇和运行编号高度相关(比如某个簇全是第 3 次运行的主题),说明第 3 次运行跑飞了。ARI(调整兰德指数)和 NMI(归一化互信息)正好干这个,它们衡量两个划分的吻合程度,并且都做了随机水平的校正。
2.3 为什么选 ARI 而不是纯度和 F1
纯度(purity)的问题是不校正随机。R×K 个主题、K 个簇、R 次运行,即使完全随机分配,纯度也不会低。ARI 的公式里减掉了期望值:
ARI = (RI - E[RI]) / (max(RI) - E[RI])RI 是兰德指数,统计"两个对象在划分 A 中同簇且在划分 B 中也同簇"加上"都不同簇"的比例;减去期望再归一化,随机划分的 ARI 期望为 0,完全一致为 1,负值表示比随机还差。这个性质让它能跨 K、跨运行次数比较,非常适合画"稳定
性随 K 变化"的曲线——K 变了,簇数和对象数都变,纯度没法比,ARI 可以。
NMI 也做了归一化,取值 [0,1],但它对簇数量偏多有系统性偏好,K 大的时候容易虚高。我一般两个都算,以 ARI 为主、NMI 为辅,两者趋势一致才敢下结论。如果两个指标打架,八成是簇的粒度分布不均,得回头看看聚类的 linkage 和距离阈值。
3. 把"稳定性"变成可计算的数:方法拆解
3.1 主题的向量化表示,决定了后面一切
聚类之前得先把主题变成向量,这一步的选择直接影响结论。三种常见表示:
| 表示方式 | 构造方法 | 优点 | 坑 |
|---|---|---|---|
| Top-N 词集合 | 取概率最高的 10~20 个词 | 简单、直观 | 丢弃权重,同义词/近义词无法对齐 |
| Top-N 词概率向量 | 只保留 top-N 词及其概率,其余置零 | 保留权重,维度可控 | 不同运行 top 词集合不同,需固定词表做并集 |
| 全词表概率向量 | 用完整词表上的 φ 向量 | 信息最全 | 维度高(几万),稀疏,距离被大量近零项稀释 |
我实际用下来最稳的是第二种,但有一个必须做对的前提:所有运行必须共用同一个词表和同一个词-列索引。很多人栽在这里——每次运行都重新fit一遍CountVectorizer,min_df一卡,两次的词表就不同了,主题向量维度对不上,硬拼会错位。正确做法是先对全语料 fit 一次向量器,transform之后把矩阵存下来,R 次 LDA 训练全部复用这个矩阵。这样即便换随机种子,词维度也完全一致。
距离度量上,JS 散度(Jensen-Shannon divergence)比欧氏距离更合适,因为主题-词分布是概率单纯形上的点,欧氏距离对"概率质量集中在少数词"和"概率平摊"这两种形态不敏感。JS 散度对称、有界(0 到 log2),取平方根后满足三角不等式,可以直接喂给层次聚类。余弦距离也可以,实测在 top-50 截断后差别不大,但 JS 更符合概率分布的几何。
3.2 聚类算法的选型:层次聚类为什么更合适
KMeans 快,但要预先指定簇数。而我们恰恰不知道"真实主题"有几个——不同运行的 K 可能不同,真实簇数也可能小于 K(某些主题被拆开了)。层次聚类可以只给一个距离阈值,让簇自己长出来,这对探索性分析更友好。
具体配置我用的是:linkage='average',距离用 JS 散度的平方根,阈值按经验设在 0.5~0.7 之间(对应两个主题共享大约一半的概率质量)。average比single抗噪——single会把两个只共享一个高频词的主题串成一条链,最后整个池子连成一簇。complete又太严,主题稍微有点粒度差异就分不到一起。
一致性阈值还有个小技巧:不要只看聚类结果,把簇内成员数打出来。一个簇如果只有 1 个成员,说明这个主题只在某一次运行里出现过,是"孤例主题",通常是不稳定的信号;如果一个簇有 2R 个成员(R 次运行各贡献 2 个),说明两次运行都把某个主题拆成了两半,同样值得警惕。
3.3 另一条路:文档级稳定性
主题级稳定性回答"主题是否复现",文档级稳定性回答"文档分群是否复现"。做法是每次运行取doc_topic_matrix.argmax(axis=1),得到每条文档的主主题标签,然后跨运行算 ARI。这个指标对业务更友好——如果你最终目的是给文档打标签做分类,文档级稳定性比主题级更贴近使用场景。
两条路的结论有时会背离:主题级 ARI 高但文档级低,说明主题本身复现了,但文档在边界上摇摆,通常是 α 太大(文档-主题分布太分散)或者文档太短。我一般在报告里两个都给,让读者自己判断。
3.4 实验设计:扰动什么,才算测稳定性
只换随机种子是最弱的检验,它只测了"优化路径差异"。真正有说服力的稳定性评估要分三层:
第一层,初始化扰动:固定语料和参数,只换random_state,跑 R=10 次。这层测的是算法本身的收敛稳定性。
第二层,语料扰动:bootstrap 重采样文档(有放回抽 80%~90%),或者按时间切前后两段,各跑若干次。这层测的是"换一批同分布数据,主题结构会不会散架"。业务上这层最有价值,因为线上数据永远在变。
第三层,参数扰动:α、β 在合理范围内微调(比如 α 从 0.05 到 0.2),看主题排序和内容是否剧烈变化。这层测的是超参敏感性,决定你能不能在没调参的情况下交付。
三层都过关,我才会说这个模型"可靠"。只做第一层就宣称稳定,是有水分的。
4. 从语料到稳定性报告:完整实操流程
4.1 环境与依赖
pip install scikit-learn numpy pandas scipy matplotlib jieba版本上,scikit-learn1.0 以上对 LDA 的perplexity计算有调整,跨版本比较困惑度绝对值没有意义,比较相对趋势没问题。中文语料还需要分词工具,英文直接空格切分加词形还原。下面以中文为例,英文把分词那步换掉即可。
4.2 语料预处理:几个决定成败的参数
import jieba import re import pandas as pd def clean(text): text = re.sub(r"http\S+", "", text) text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z]", " ", text) return text.strip() df = pd.read_csv("corpus.csv") df["clean"] = df["raw"].map(clean) df["tokens"] = df["clean"].map(lambda s: " ".join(jieba.cut(s))) df = df[df["tokens"].str.len() > 10] # 丢掉过短文档短文档要果断删。LDA 对文档长度极度敏感,一条只有 5 个词的文档,其文档-主题分布基本是噪声,会严重干扰主题-词分布的估计。经验阈值是去掉最短的 5%~10%。
向量化这一步的参数我固定成一套模板:
from sklearn.feature_extraction.text import CountVectorizer vec = CountVectorizer( max_df=0.5, # 出现在半数以上文档的词直接丢 min_df=5, # 低频词丢掉,减少噪声维度 max_features=20000, token_pattern=r"(?u)\b\w+\b", ) X = vec.fit_transform(df["tokens"])max_df=0.5是我踩坑后加上的。之前用默认 1.0,结果主题里塞满了"公司""时间""问题"这类词,它们在所有文档里都出现,概率高但没有区分度。min_df=5是防止只出现一两次的错别字、人名、型号占据主题 top 位。max_features限制在 2 万左右,既控制内存,也避免维度太高导致 JS 散度计算被长尾稀释。
注意:向量器只 fit 一次,矩阵 X 存成变量或 npz 文件,后面所有 LDA 运行共用。这是后面主题向量能对齐的前提,别省略。
4.3 多轮训练与主题矩阵导出
import numpy as np from sklearn.decomposition import LatentDirichletAllocation def run_lda(X, K, seed, alpha=0.1, beta=0.01, n_iter=100): lda = LatentDirichletAllocation( n_components=K, doc_topic_prior=alpha, topic_word_prior=beta, max_iter=n_iter, learning_method="batch", random_state=seed, n_jobs=1, ) lda.fit(X) return lda R, K = 10, 15 models = [run_lda(X, K, seed=s) for s in range(R)]learning_method="batch"比online稳定得多,online的批大小会引入额外随机性,做稳定性实验时不要用。n_jobs=1是为了保证单次运行内部可复现,并行会打乱部分计算的顺序。max_iter=100是为了让绝大多数运行能收敛,观察lda.n_iter_,如果有运行跑满还没收敛(打印出来等于 max_iter),说明迭代次数不够,结果本身就不可信。
导出主题向量时,只保留全局 top-N 词做截断:
def topic_vectors(model, vec, topn=50): vocab = np.array(vec.get_feature_names_out()) W = model.components_ # shape: (K, V) W = W / W.sum(axis=1, keepdims=True) # 归一成概率 idx = np.argsort(-W, axis=1)[:, :topn] vecs, words = [], [] for k in range(W.shape[0]): v = np.zeros_like(W[k]) v[idx[k]] = W[k, idx[k]] v = v / v.sum() # 截断后重新归一 vecs.append(v) words.append(vocab[idx[k]]) return np.array(vecs), words截断后重新归一这一步不能省。不归一的话,不同主题被截掉的概率质量不同,距离计算会有系统偏差。
4.4 聚类对齐与 ARI 计算
import numpy as np from scipy.spatial.distance import pdist, squareform from scipy.cluster.hierarchy import linkage, fcluster from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score def js_divergence(p, q): m = 0.5 * (p + q) def kl(a, b): mask = a > 0 return np.sum(a[mask] * np.log2(a[mask] / b[mask])) return 0.5 * kl(p, m) + 0.5 * kl(q, m) all_vecs = np.vstack([topic_vectors(m, vec)[0] for m in models]) run_ids = np.repeat(np.arange(R), K) # 成对 JS 距离 n = all_vecs.shape[0] D = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): D[i, j] = D[j, i] = np.sqrt(js_divergence(all_vecs[i], all_vecs[j])) Z = linkage(squareform(D, checks=False), method="average") cluster_labels = fcluster(Z, t=0.6, criterion="distance") ari = adjusted_rand_score(run_ids, cluster_labels) nmi = normalized_mutual_info_score(run_ids, cluster_labels) print(f"ARI={ari:.3f} NMI={nmi:.3f} clusters={len(set(cluster_labels))}")两两距离是 O((RK)²),R=10、K=15 时只有 22500 对,很快。如果 K 到 50、R 到 20,就是 100 万对,得换用向量化写法或者先做 PCA 降维。我一般会在算距离前对all_vecs做一次 SVD 降到 100 维,速度能快十倍,对 ARI 的影响小于 0.02。
t=0.6这个阈值需要根据实际分布调。我的方法是画一张距离分布直方图,看双峰之间的谷底在哪。如果距离分布是单峰且集中在 0.8 以上,说明主题之间普遍不相似,可能是 K 设大了,主题被切得太碎。
4.5 解读结果:阈值怎么定
ARI 的绝对数值没有普适标准,但我积累了一组经验区间,适用于词汇表 5000 以上、文档数 1000 以上、K 在 10~30 之间的场景:
| ARI 区间 | 含义 | 建议动作 |
|---|---|---|
| > 0.80 | 高度稳定,主题跨运行几乎完全复现 | 可以直接交付 |
| 0.65 ~ 0.80 | 基本稳定,个别主题有合并/拆分 | 检查孤例主题,人工抽检 |
| 0.50 ~ 0.65 | 勉强可用,主题边界模糊 | 调 α、减 K、加预处理 |
| < 0.50 | 不稳定,结果不可信 | 别用了,回头查数据和 K |
这套区间只是参考。语料特别同质(比如全是同一种客服工单)时,ARI 天然高;语料跨领域混杂时,ARI 天生低。关键看相对变化:调 K 的时候 ARI 怎么动,做语料扰动的时候掉多少,这比绝对值更有信息量。
5. 用稳定性反哺可靠性:四个真正管用的动作
5.1 用稳定性曲线选 K,比一致性曲线更抗噪
把 K 从 5 扫到 40,每个 K 跑 R=10 次算 ARI,画出来通常是一条单调下降的曲线:K 越大,主题越细,边界越模糊,稳定性越差。这条曲线不能单独用,因为 K=5 时 ARI 肯定最高,但 5 个主题大概率过于笼统。
我现在的做法是两条曲线叠在一起看:一致性(C_v)随 K 上升后下降,稳定性(ARI)随 K 单调下降。取"一致性接近峰值、ARI 还在 0.7 以上"的那个 K。实操中这个交叉点往往在一致性峰值左侧一到两个位置,比我早期只看一致性选出来的 K 小 3~5,主题明显更干净、更好解释。
5.2 主题集成:把多次运行的稳定主题"投票"出来
既然能聚类对齐,就可以顺势做集成。思路是:R 次运行的所有主题聚类后,每个簇代表一个"候选真实主题",簇内成员数越多、来自的运行越分散,这个主题越可信。保留成员数 ≥ 阈值的簇(我一般用 R 的 60%,即 10 次里至少 6 次出现),每个簇取成员向量的均值作为最终主题表示,再取 top 词。
from collections import Counter final_topics = [] for c in set(cluster_labels): members = np.where(cluster_labels == c)[0] runs_in = len(set(run_ids[members])) if runs_in < int(0.6 * R): continue centroid = all_vecs[members].mean(axis=0) top_idx = np.argsort(-centroid)[:15] final_topics.append((runs_in, vec.get_feature_names_out()[top_idx])) for r, words in sorted(final_topics, reverse=True): print(r, " ".join(words))这套流程跑下来,输出的是"跨运行反复出现的主题",孤例主题被自动过滤。代价是主题数会比 K 少——K=15 跑出来可能只剩 10~12 个稳定主题,剩下的位置是那些本来就飘忽的内容。我认为这是好事,与其硬凑 15 个让人挑刺,不如交 12 个站得住的。
5.3 稳定性也能反过来诊断预处理
ARI 低的时候,先别急着调模型,八成是数据的问题。我遇到过的典型情况:
- 分词把词切碎了:某个领域术语被切成两半,导致 top 词全是碎片,聚类时对不上。加自定义词典解决。
- 停用词表不够:一堆语气词、模板句占据高位,主题之间相似度被这些词抬得很高,全被聚成一簇,ARI 反而虚高但簇数极少。看
len(set(cluster_labels))是否远小于 K,是重要信号。 - 文档长度差异过大:长文档主导了主题-词分布,短文档的主题全靠先验,跨运行摇摆。按长度分层重采样能显著提升 ARI。
这条经验很值钱:稳定性指标同时也是一个数据质量的探针。ARI 上不去,别在模型里打转。
5.4 人工抽检不能省
指标再好看,也得抽 3~5 个主题看词表。我的抽检清单是三条:top 10 词能不能用一句话概括;有没有明显不属于这个主题的词混进来;两个主题的 top 10 词重叠是否超过一半。第三条尤其重要,重叠高说明主题冗余,宁可减 K。ARI 高但主题冗余的情况很常见——模型很稳定地输出了两组几乎一样的主题,稳定但没用。
6. 踩坑记录与排查速查表
6.1 典型报错与异常处理
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 主题向量维度不一致,无法 vstack | 每次运行重新 fit 了向量器 | 向量器只 fit 一次,矩阵复用 |
| ARI 恒为 1.0 | 聚类簇数等于样本数,或 t 设得极小 | 检查fcluster的 t,打印簇数分布 |
| ARI 恒为 0 附近 | 簇数远少于 K,全被聚成一两簇 | 调小 t,检查停用词,检查距离是否被近零项稀释 |
某次运行n_iter_等于 max_iter | 未收敛 | 提到 200,或检查 α 是否过大 |
| 所有主题 top 词高度相似 | K 过大 / max_df 过高 / 语料太同质 | 降 K,收紧 max_df |
| 聚类耗时爆炸 | RK 太大,O(n²) 距离矩阵 | 先 SVD 降维,或改 MiniBatchKMeans |
6.2 几个必须记住的参数经验值
LDA 的两个先验,doc_topic_prior(α)和topic_word_prior(β),默认都是 1/K 和 1/K 之类的自适应值,但实测在短文本上偏"糊"。我常用的组合是 α=0.1、β=0.01,理由是这样:α 小让文档-主题分布稀疏,每条文档集中在少数主题上,符合大多数业务场景(一条工单通常只讲一件事);β 小让主题-词分布稀疏,主题 top 词更锐利。两者都调小之后,稳定性通常会提升,因为后验更集中,采样路径的随机性影响被压缩。
反过来,如果你要的是"每条文档可能涉及多个主题"的软分配,α 就得放大到 1 左右,这时候文档级稳定性会上升但主题级稳定性下降,得权衡。
6.3 一个容易被忽略的坑:随机种子的作用范围
random_state在不同实现里控制的环节不一样。sklearn的 LDA 控制的是初始化(init="k-means++"或"random")和变分推断的随机数。如果你用init="k-means++"(默认),初始化本身就做了聚类,稳定性会明显好于init="random",但代价是跑得慢。做稳定性实验时,两个 init 方式要分开测,混在一起比较没有意义。我一般正式训练用k-means++,稳定性基线测试用random,因为后者能暴露更真实的脆弱性。
还有n_jobs,设成 -1 时底层 BLAS 的并行归约顺序不固定,同一 seed 两次运行结果可能有 1e-6 级差异。对 ARI 没影响,但如果你要复现精确数值,就设成 1。
7. 我在这套流程里沉淀下来的几个判断
跑过十几份不同语料之后,我对"稳定性"这件事的看法变了几次。最初我以为它只是一个诊断工具,后来发现它其实是整个建模流程的目标函数——选 K 是为了稳定,调预处理是为了稳定,做集成也是为了稳定,稳定性从"检验指标"变成了"设计原则"。
一个具体的体会:不要在主题数上贪心。我早期总想把 K 调大,觉得主题多、粒度细,信息量大。实际上 K 每加 3,ARI 大概掉 0.05~0.1,到了一定位置,多出来的主题基本是"把一个大主题拆成两个小主题",而且这两个小主题在不同运行里反复交换边界,你根本没法用。宁可少几个主题,每个都站得稳,交付的时候也好解释。业务方问"为什么只有 12 个主题",你答"这 12 个在十次独立训练里都出现了,另外 3 个每次都不一样",这个回答比凑满 15 个有说服力得多。
另一个体会跟报告写法有关。别只报一个 ARI 数字,把簇成员分布也贴出来——每个簇来自几次运行、成员数多少。我跟人讨论结果时,一张"主题-出现次数"的表格比任何指标都直观,一眼就能看出哪些主题是真结构,哪些是噪声。稳定性评估这件事,算得准不如看得清。