- +1
文本挖掘从小白到精通(二十二)短文本主题建模的利器 - BERTopic
原创 Scottish Fold Cats Social Listening与文本挖掘 收录于话题#主题模型6#聚类6#文本挖掘19
特别推荐|【文本挖掘系列教程】:
文本挖掘从小白到精通(十六)--- 像使用scikit-learn一样玩转BERT
-----------------------------正---------文----------------------------------------
近期,经常有小伙伴会在公众号后台或者直接通过微信向我咨询以下问题:
如何对大量的短文本数据进行高效建模?
在LDA建模时,如何确定主题数?
主题模型得到的结果解释性程度不高、看不懂咋办?
在没有任何类别或标签的情况下,利用无监督技术来提取文档主题是一个自然而然的想法,虽然LDA和NMF等主题模型已经被广泛采用,而且在大多时候效果还不错(主要是长文本),但是,笔者总觉得通过超参数调优来发掘有意义的话题需要花费相当大的精力,而且很多时候吃力不讨好---出来的结果奇差无比,上面的几个问题也印证了这一点。
鉴于此,笔者想结合时下SOA的BERT---因为它在近两年的各种NLP任务中表现优异,而且使用预训练模型不需要有标注的数据,更重要的是BERT可以产生出高质量的、带有上下文语境信息的词嵌入和句嵌入。
接下来,笔者将以一个汽车行业的用户评论语料作为示例,展示基于bert模型的主题模型的强大威力。
1 载入需要的python库
import numpy as npimport pandas as pdimport jiebaimport umapimport hdbscanfrom sentence_transformers import SentenceTransformerfrom sklearn.feature_extraction.text import CountVectorizerfrom sklearn.metrics.pairwise import cosine_similarityfrom tqdm import tqdmimport matplotlib.pyplot as plt
2 载入数据,并作必要的数据预处理
笔者在这里使用的是汽车之家的口碑评论数据,有20000+,大部分是长度不超过70的短文本数据。
from pyltp import SentenceSplitterdata = pd.read_excel('car_reviews.xlsx')data[['review']]= data[['review']].values.astype(str)splited_sentences = SentenceSplitter.split(' '.join(data['review'].tolist()))data = pd.DataFrame(list(splited_sentences), columns=["review"])
过滤掉句长较短的语句:
data['text_length'] = data["review"].apply(lambda x:len(x))data = data[data['text_length']>5]
检视数据:
data.head()
review text_length
0 也没什么不满意的,要说的话就是油耗吧,对于这个车型还有车重来说其实也不算很高了,但如果能再低... 74
1 发动机启停并没有什么用,轴距只有2.9多,后备箱太大,启停不如换成后视镜电动折叠 很多人觉得... 73
2 非要说一点那就是走烂路的时候天窗有异响。 20
3 换挡一到二还是有顿挫感,地板油也要给他思考一秒左右,希望后期加装拨片盘能稍微好一点。 42
4 非常不错的一款车,颜值、动力、驾乘感觉都不错,不过要说定位是一款四门五座的轿跑,我倒更倾向是... 91
对文本数据进行分词处理,同时排除语句中的停用词。
注意,这个操作是作获取主题词用,生成语句表示,也就是句嵌入则是基于BERT模型。
data['review_seg'] = data['review'].apply(lambda x : ' '.join([j.strip() for j in jieba.lcut(x) if j not in my_stopwords]))
再次检视数据:
data.head()
review text_length review_seg
0 也没什么不满意的,要说的话就是油耗吧,对于这个车型还有车重来说其实也不算很高了,但如果能再低... 74 没什么 满意 要说 油耗 车型 车重 来说 其实 不算 高 再 低点 当然 更好 毕竟 买 ...
1 发动机启停并没有什么用,轴距只有2.9多,后备箱太大,启停不如换成后视镜电动折叠 很多人觉得... 73 发动机 启停 没有 轴距 2.9 后备箱 太 启停 换成 后视镜 电动 折叠 人 觉得 油...
2 非要说一点那就是走烂路的时候天窗有异响。 20 要说 一点 走烂路 天窗 异响
3 换挡一到二还是有顿挫感,地板油也要给他思考一秒左右,希望后期加装拨片盘能稍微好一点。 42 换挡 一到 二 顿挫 感 地板 油 思考 一秒 左右 希望 后期 加装 拨 片盘 稍微 好 一点
4 非常不错的一款车,颜值、动力、驾乘感觉都不错,不过要说定位是一款四门五座的轿跑,我倒更倾向是... 91 非常 不错 一款 车 颜值 动力 驾乘 感觉 不错 要说 定位 一款 四门 五座 轿 跑 倒...
3 创建高质量的语句嵌入
第一步是将文档向量化,且要尽量减少数据转换过程中带来的语义损失。有很多方法可以使用,比如doc2vec、skip-thought、elmo等,但考虑到 BERT 的优异特性,笔者此次用它来提取语句嵌入。
我们首先使用SentenceTransformer从一组文档中创建文档嵌入。如果针对目标领域进行了专有化的预训练,比如汽车领域的主题建模就用大量汽车领域的无标注语料finetune模型,那么语句嵌入的效果将会很好,能够将语义相近但表达不同的文本聚在一起,尤其是短文本这类难以处理的文档类型。
如果你有很长的文档,笔者则建议你利用工程手段把文档分成小段落或句子,因为SentenceTransformer是基于BERT模型,通常有语句长度限制,一般是512个字符。
model = SentenceTransformer(r'gao_dir/my_pretrained_chinese_embeddings')embeddings = model.encode(data['review'].tolist(), show_progress_bar=True)
检视文档嵌入的形状,一般是(语句数量*嵌入维度数量)。
embeddings.shape
(9445, 512)
在正式进入聚类环节之前,我们首先需要降低其维度,因为HDBCAN聚类算法容易遭受“维度诅咒”。
4 句嵌入降维处理
笔者使用UMAP对上一环节创建的语句嵌入进行降维,一来可以减少计算复杂度,减少计算量和内存使用量,二来便于喜好低维数据的HDBSCAN进行更好的聚类,三来在流形降维的过程中,还能发现更多的局部语义特征,可谓是“一举三得”!
笔者个人认为,UMAP是目前文本向量降维效果最好的一个方法,其中重要的参数有3个---n_neighbors、n_components和metric,大家有余力可以研究下这里的调参。不过,一般情况下,用默认的参数就可以了。
%%timeimport syssys.setrecursionlimit(1000000)umap_embeddings = umap.UMAP( n_neighbors=25, n_components=10, min_dist=0.00, metric='cosine', random_state=2020).fit_transform(embeddings)
Wall time: 53.5 s
5 利用HDBSCAN进行文档聚类
由于UMAP保留了一些原始的高嵌入结构,所以使用HDBSCAN来寻找高密簇(即热门话题)是很有意义的。
该聚类方法有如下2个重要参数:
metric。此次笔者使用的度量方式是是euclidean(欧氏度量),因为它不会受到高维度的影响
min_cluster_size。min_cluster_size(最小聚类大小)可以让调节主题数量,该数值越大,则发掘出的主题数量就越少,反之越多。
%%timecluster = hdbscan.HDBSCAN( min_cluster_size=30, metric='euclidean', cluster_selection_method='eom', prediction_data=True).fit(umap_embeddings)
Wall time: 571 ms
我们可以通过使用UMAP将数据嵌入到二维空间,并使用matplotlib对聚类进行着色,从而将产生的聚类可视化。有些聚类很难被发现,因为可能会有>50个主题生成,但一些主题下的语句数(在图中就是小点数)过少。
# Prepare dataumap_data = umap.UMAP(n_neighbors=15, n_components=2, min_dist=0.0, metric='cosine').fit_transform(embeddings)result = pd.DataFrame(umap_data, columns=['x', 'y'])result['labels'] = cluster.labels_
# Visualize clustersfig, ax = plt.subplots(figsize=(25, 15))outliers = result.loc[result.labels == -1, :]clustered = result.loc[result.labels != -1, :]plt.scatter(outliers.x, outliers.y, color='#BDBDBD', s=0.05)plt.scatter(clustered.x, clustered.y, c=clustered.labels, s=0.05, cmap='hsv_r')plt.colorbar()# plt.savefig("result1.png", dpi = 300)

在正式进行主题分析前,我们还需要编写辅助函数,以便对聚类结果进行分析,将其转换为主题模型的分析范式。
6.1 c-TF-IDF
我们想从我们生成的聚类中知道的是 --- 是什么让一个聚类在内容上(基于语义)与另一个聚类有所不同?为了解决这个问题,我们可以“魔改”一下TF-IDF,使其可以发掘每个主题而不是每个文档中的重要词汇(主题词)。
当你像往常一样在一组文档上应用TF-IDF时,你所做的基本上是比较文档之间词的重要性。现在,如果,我们把一个类别中的所有文档(例如,一个聚类簇群)视为一个单一文档,然后应用TF-IDF呢?结果将是一个簇内的词的重要性得分(TF-IDF值)。一个聚类簇群内的词汇越重要,那么它就越能代表该主题。换句话说,如果我们提取每个簇中最重要的词,我们就会得到主题的描述,进而理解该主题到底在说什么!
具体说来,该方法在做这样一件事:
每一个簇都被转换为一个文档,而不是一组文档。然后,提取每个类i的词的频率t,然后除以总词数w,这个动作现在可以看做是类中频繁词的正则化形式。接下来,将总的、未连接的文档数m除以所有类n的词t的总频率。
借助sklearn中的CountVectorizer类,该方法可以轻而易举的被实现:
def c_tf_idf(documents, m, ngram_range=(1, 1)): my_stopwords = [i.strip() for i in open('stop_words_zh.txt',encoding='utf-8').readlines()] count = CountVectorizer( ngram_range=ngram_range, stop_words= my_stopwords).fit(documents) t = count.transform(documents).toarray() w = t.sum(axis=1) tf = np.divide(t.T, w) sum_t = t.sum(axis=0) idf = np.log(np.divide(m, sum_t)).reshape(-1, 1) tf_idf = np.multiply(tf, idf) return tf_idf, count
抽取主题中的关键词(主题词),以及计算每个主题的大小(主题下包含多少文档):
def extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=20): words = count.get_feature_names() labels = list(docs_per_topic.Topic) tf_idf_transposed = tf_idf.T indices = tf_idf_transposed.argsort()[:, -n:] top_n_words = {label: [(words[j], tf_idf_transposed[i][j]) for j in indices[i]][::-1] for i, label in enumerate(labels)} return top_n_words
def extract_topic_sizes(df): topic_sizes = (df.groupby(['Topic']) .Doc .count() .reset_index() .rename({"Topic": "Topic", "Doc": "Size"}, axis='columns') .sort_values("Size", ascending=False)) return topic_sizes
6.2 计算每个主题下的TOP主题词
为了方便选择,我们把结果放在pandas数据框架中。然后,docs_per_label被创建,在其中,一个聚类簇群中的所有文档都被加入。
docs_df = pd.DataFrame(data['review_seg'].tolist(), columns=["Doc"])docs_df['Topic'] = cluster.labels_docs_df['Doc_ID'] = range(len(docs_df))docs_per_topic = docs_df.groupby(['Topic'], as_index = False).agg({'Doc': ' '.join})
此次产生的主题数为:
len(docs_per_topic.Doc.tolist())
34
在应用TF-IDF方法之前,先将一个主题中的所有文档“捏合”为一个文档,从而计算一个主题中的词汇与其他所有主题的重要性。然后,我们只需提取每个簇中TF-IDF值最高的词作为该主题下的主题词。
tf_idf, count = c_tf_idf(docs_per_topic.Doc.values, m = len(data)) top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=20) topic_sizes = extract_topic_sizes(docs_df); topic_sizes.head(10)
看看主题索引为18的主题下的主题词是哪些:
top_n_words[18]
[('问题', 0.1025568295028655),
('没有', 0.09461670245949808),
('追评', 0.08719721082551075),
('目前', 0.07247799984688022),
('发现', 0.06975059757614206),
('时间', 0.06712798723943217),
('再来', 0.05536254179522642),
('味道', 0.05231485606815352),
('满意', 0.04991319860040351),
('感觉', 0.04842748742196347),
('出现', 0.04813242930859443),
('一个月', 0.046830037344296443),
('月份', 0.04624729052000483),
('新车', 0.04403148698556992),
('异味', 0.04383294309480369),
('暂时', 0.04334722860887084),
('车开', 0.04318017756299482),
('现在', 0.04022216122961403),
('质量', 0.038859284264954115),
('提车', 0.035243555499171186)]
然而,类别索引为-1的聚类簇群应该被排除,因为这部分聚类被模型视为“噪声”,杂糅了很多未被识别的主题,很难被人解读。
top_n_words[-1]
[('外观', 0.03397788540335169),
('喜欢', 0.028063470565283518),
('性价比', 0.024877802099763188),
('觉得', 0.02017027908469892),
('感觉', 0.019562629459711357),
('没有', 0.01852212424611342),
('大灯', 0.018513381594025758),
('配置', 0.018280959393705866),
('比较', 0.017890116980130752),
('价格', 0.017679624613747016),
('非常', 0.017142161266788858),
('品牌', 0.017058422370475335),
('满意', 0.016970659727685928),
('豪华', 0.016424003887498418),
('优惠', 0.01609247609255133),
('xts', 0.01579185209861865),
('设计', 0.015732793408522044),
('动力', 0.01541712071670732),
('大气', 0.014732855459186593),
('有点', 0.014718071299553026)]
罗列目前所有主题及其对应的主题词列表:
from pprint import pprintfor i in list(range(len(top_n_words) - 1)): print('Most 20 Important words in TOPIC {} :
'.format(i)) pprint(top_n_words[i]) pprint('***'*20)
Most 20 Important words in TOPIC 0 :
[('马儿', 0.24457507362737524),
('马儿跑', 0.2084888573356569),
('不吃', 0.09709590737397493),
('油耗', 0.06709136386307156),
('目前', 0.059650379616285276),
('不让', 0.05319169690659243),
('想要', 0.04764441180247841),
('左右', 0.046580524081679016),
('跑得快', 0.045400507911056986),
('哪有', 0.044559365280351336),
('公里', 0.041230968367632854),
('高速', 0.039234425817170064),
('行驶', 0.03890482349013843),
('10', 0.037022144019066686),
('个油', 0.03682216481709768),
('动力', 0.03616975159734934),
('正常', 0.03520558703001095),
('市区', 0.034599821025087185),
('毕竟', 0.03458202416009574),
('道理', 0.031503940772350914)]
'************************************************************'
Most 20 Important words in TOPIC 1 :
[('油耗', 0.09524385306084004),
('高速', 0.05653143388720487),
('左右', 0.05463694726066372),
('市区', 0.04736812727722961),
('公里', 0.04426042823825784),
('个油', 0.0437019462752025),
('10', 0.04124126267133629),
('目前', 0.04106957747526032),
('接受', 0.03392843290427474),
('11', 0.03258066460138708),
('平均', 0.03254166004110595),
('百公里', 0.026974405367215754),
('12', 0.02667734417832382),
('现在', 0.026547861579869568),
('省油', 0.024521146178990254),
('比较', 0.023967370074638887),
('行驶', 0.02337617146923143),
('平时', 0.02231213384456322),
('开车', 0.02225259142975045),
('磨合期', 0.019891589132560176)]
'************************************************************'
Most 20 Important words in TOPIC 2 :
[('老虎', 0.1972807028214997),
('油耗', 0.08030819950496665),
('美系车', 0.051452721555236586),
('现在', 0.04511691339526969),
('10', 0.04164581302410513),
('个油', 0.041420858563077104),
('美国', 0.04121728175026878),
('左右', 0.03493195487672415),
('平均', 0.03288881578728298),
('目前', 0.029076698183196633),
('12', 0.028824764053369055),
('高速', 0.028687350320703176),
('11', 0.0263147428710808),
('基本', 0.025791405022289656),
('百公里', 0.025566436389413978),
('驾驶', 0.02511085197343242),
('郊区', 0.023879719505057788),
('多公里', 0.023290821021098026),
('习惯', 0.023170932368572476),
('朋友', 0.022668297504425915)]
'************************************************************'
Most 20 Important words in TOPIC 3 :
[('油耗', 0.09774756730680972),
('凯迪拉克', 0.08150929317053307),
('左右', 0.03704063760365755),
('个油', 0.03393914525278086),
('节油', 0.033147790968701116),
('目前', 0.029322670672030947),
('耗油', 0.028607158460688595),
('市区', 0.028138942560105483),
('11', 0.027057690984927343),
('接受', 0.027035026157737122),
('毕竟', 0.025713800165879153),
('现在', 0.025636969123009515),
('美系车', 0.025507957831906663),
('平均', 0.02536302802175033),
('之前', 0.024645241362404695),
('动力', 0.023532574041308225),
('比较', 0.02351138127209341),
('降低', 0.021912206107234797),
('正常', 0.02137825605852441),
('可能', 0.02017083805610775)]
'************************************************************'
...
Most 20 Important words in TOPIC 31 :
[('满意', 0.4749794864152499),
('地方', 0.3926757136985932),
('没有', 0.21437689162047083),
('发现', 0.17910831839903818),
('目前', 0.11420499815982257),
('暂时', 0.09540746799339411),
('挖掘', 0.08502606632538356),
('不好', 0.06606868576085345),
('满满', 0.06546918040522966),
('挑剔', 0.06351786367717983),
('后续', 0.05924768082325757),
('其实', 0.05517858296374464),
('没什么', 0.0467681518553301),
('真的', 0.04629681210390699),
('癫得', 0.04599618482379703),
('我太多', 0.04599618482379703),
('定为', 0.04599618482379703),
('3w', 0.04599618482379703),
('能吐槽', 0.04599618482379703),
('相对', 0.045510230820616476)]
'************************************************************'
Most 20 Important words in TOPIC 32 :
[('外观', 0.19202697740762065),
('喜欢', 0.09742663275691509),
('好看', 0.06539925997592003),
('吸引', 0.051963718413741596),
('时尚', 0.04628469650846298),
('大气', 0.045441921472445655),
('个性', 0.0447603686071089),
('个人', 0.03601467530065024),
('反正', 0.03586746904278288),
('霸气', 0.03438681357345092),
('不用', 0.03315500048740606),
('漂亮', 0.03302680521368137),
('外观设计', 0.032328941456855734),
('非常', 0.032326600304463396),
('外形', 0.03215438082478295),
('觉得', 0.03126961228563091),
('不错', 0.029505153223353325),
('看起来', 0.02949619921569243),
('顺眼', 0.026753843592622728),
('帅气', 0.026252936525869065)]
'************************************************************'
到了这里,主题发现的工作其实已经完结,但有时候,我们会觉得:
主题数过多,想要再少点,最好是按指定的数量进行相似主题合并;
发现主题中的层次结构会更有意义。
此时,就轮到主题归并出场了~
7 主题归并
根据数据集的不同,你有可能会得到成百上千个主题!你可以调整HDBSCAN的参数,通过它的min_cluster_size参数来减少主题的数量,但是这个得不到指定的、确定的聚类/主题数量。一个较为自然的做法是通过合并彼此最相似的主题向量来减少主题的数量。我们可以使用类似的技巧,通过比较主题之间的c-TF-IDF向量,合并最相似的向量,最后重新计算c-TF-IDF向量来更新主题的表示。
for i in tqdm(range(20)): # Calculate cosine similarity similarities = cosine_similarity(tf_idf.T) np.fill_diagonal(similarities, 0)
# Extract label to merge into and from where topic_sizes = docs_df.groupby(['Topic']).count().sort_values("Doc", ascending=False).reset_index() topic_to_merge = topic_sizes.iloc[-1].Topic topic_to_merge_into = np.argmax(similarities[topic_to_merge + 1]) - 1
# Adjust topics docs_df.loc[docs_df.Topic == topic_to_merge, "Topic"] = topic_to_merge_into old_topics = docs_df.sort_values("Topic").Topic.unique() map_topics = {old_topic: index - 1 for index, old_topic in enumerate(old_topics)} docs_df.Topic = docs_df.Topic.map(map_topics) docs_per_topic = docs_df.groupby(['Topic'], as_index = False).agg({'Doc': ' '.join})
# Calculate new topic words m = len(data) tf_idf, count = c_tf_idf(docs_per_topic.Doc.values, m) top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=20)
topic_sizes = extract_topic_sizes(docs_df); topic_sizes.head(10)
结语
BERTopic是一种话题建模技术,它利用BERT嵌入和c-TF-IDF来创建密集的集群,使话题易于解释,同时在话题描述中保留重要词汇。其核心步骤主要是做三件事:
用基于BERT的Sentence Transformers提取语句嵌入
通过UMAP和HDBSCAN,将文档嵌入进行聚类,语义相近的语句将聚集成簇群
用c-TF-IDF提取主题词
另外,如果你不想在整个文档上应用主题建模,而是想在段落层面上应用,笔者则建议您在创建语句嵌入之前将数据进行一定程度的拆分。
最后,笔者想说的是,决定聚类效果好坏的关键因素在于Sentence Transformers提取语句嵌入这一环节,要想达到较好的效果,需要自行针对任务训练预训练模型,这是另一个大的话题,笔者有过这样的实践,后续可以单独写一篇文章来说明。
参考资料:
https://github.com/MaartenGr/BERTopic
原标题:《文本挖掘从小白到精通(二十二)短文本主题建模的利器 - BERTopic》
本文为澎湃号作者或机构在澎湃新闻上传并发布,仅代表该作者或机构观点,不代表澎湃新闻的观点或立场,澎湃新闻仅提供信息发布平台。申请澎湃号请用电脑访问http://renzheng.thepaper.cn。





- 报料热线: 021-962866
- 报料邮箱: news@thepaper.cn
互联网新闻信息服务许可证:31120170006
增值电信业务经营许可证:沪B2-2017116
© 2014-2026 上海东方报业有限公司




