如何通过类别理解文本的差异
现在,在产品分析中,我们面对着许多自由格式的文本:
- 用户在AppStore、Google Play或其他服务中留下评论;
- 客户通过自然语言向我们的客户支持寻求帮助并描述他们的问题;
- 我们自己启动调查以获得更多反馈,而在大多数情况下,都会有一些自由格式的问题以便更好地理解。
我们有成千上万的文本。要阅读并获得一些见解将需要数年时间。幸运的是,有许多数据科学工具可以帮助我们自动化这个过程。其中一个工具就是主题建模,今天我想讨论一下。
基本主题建模可以让您了解文本中的主要主题(例如评论)及其混合。但是基于一个观点来做决策是具有挑战性的。例如,14.2%的评论是关于您的应用中广告太多。这是好还是坏?我们应该调查吗?老实说,我不知道。
但是,如果我们尝试对客户进行分群,我们可能会发现这一比例在Android用户中为34.8%,而在iOS用户中为3.2%。那么,很明显我们需要调查一下是为什么在Android上显示太多广告,或者为什么Android用户对广告的容忍度较低。
这就是为什么我不仅想分享如何构建主题模型,还想分享如何在不同类别之间比较主题。最后,我们将为每个主题获得这样富有洞察力的图表。
数据
自由格式文本的最常见实际案例是某种评论。因此,让我们使用酒店评论的数据集来进行示例。
我已经筛选出与伦敦的几个连锁酒店有关的评论。
在开始文本分析之前,值得对我们的数据有一个概览。总共,我们有12,890条来自7个不同酒店连锁的评论。
BERTopic
现在我们有了数据,可以应用我们的新奇工具主题建模来从中获取见解。正如我在开头提到的,我们将使用主题建模和一个强大且易于使用的BERTopic
包(文档)进行这个文本分析。
您可能想知道什么是主题建模。它是一种与自然语言处理相关的无监督机器学习技术。它允许您在文本(通常称为文档)中找到隐藏的语义模式并为其分配“主题”。您不需要事先拥有主题列表。算法将自动定义它们,通常以最重要单词(标记)或N-gram的形式。
BERTopic
是一个使用HuggingFace transformers和基于类的TF-IDF的主题建模包。 BERTopic
是一个高度灵活的模块化包,以便您可以根据自己的需求进行定制。
如果您想更好地了解它的工作原理,我建议您观看这个来自该库作者的视频。
预处理
您可以在GitHub上找到完整的代码。
根据文档,除非存在许多噪声,例如HTML标记或其他不添加意义的标记,否则我们通常不需要预处理数据。这是BERTopic
的一个重要优势,因为对于许多自然语言处理方法,需要大量样板代码来预处理数据。如果您对其可能的样子感兴趣,请参阅这篇使用LDA进行主题建模的指南。
您可以使用BERTopic
处理多种语言的数据,通过指定BERTopic(language= "multilingual")
。然而,根据我的经验,将文本翻译为一种语言后,模型的效果会稍微好一些。所以,我将把所有的评论都翻译成英文。
为了进行翻译,我们将使用deep-translator
包(您可以从PyPI安装它)。
此外,看一下按语言分布也很有趣,我们可以使用langdetect
包。
import langdetectfrom deep_translator import GoogleTranslatordef get_language(text): try: return langdetect.detect(text) except KeyboardInterrupt as e: raise(e) except: return '<-- ERROR -->' def get_translation(text): try: return GoogleTranslator(source='auto', target='en')\ .translate(str(text)) except KeyboardInterrupt as e: raise(e) except: return '<-- ERROR -->'df['language'] = df.review.map(get_language)df['reviews_transl'] = df.review.map(get_translation)
在我们的案例中,95%以上的评论已经是英文的。
为了更好地了解我们的数据,让我们看一下评论长度的分布。图表显示,有很多非常短的评论(很可能不具有意义)——大约5%的评论少于20个字符。
我们可以查看一些最常见的例子,以确保这些评论中没有太多信息。
df.reviews_transl.map(lambda x: x.lower().strip()).value_counts().head(10)reviewsnone 74<-- error --> 37great hotel 12perfect 8excellent value for money 7good value for money 7very good hotel 6excellent hotel 6great location 6very nice hotel 5
因此,我们可以过滤掉所有长度小于20个字符的评论——其中有556个评论(占总评论数的4.3%)。然后,我们将仅分析具有更多上下文的长句。这是一个基于示例的任意阈值,您可以尝试几个级别,看看都过滤了哪些文本。
值得检查一下,这个过滤器是否对某些酒店产生了不成比例的影响。不同类别的短评论份额非常接近。所以,数据看起来没有问题。
最简单的主题模型
现在,是时候构建我们的第一个主题模型了。让我们从最基本的模型开始,以了解库是如何工作的,然后我们会进行改进。
我们可以在几行代码中训练一个主题模型,这些代码对于至少使用过一个机器学习包的任何人来说都很容易理解。
from bertopic import BERTopicdocs = list(df.reviews.values)topic_model = BERTopic()topics, probs = topic_model.fit_transform(docs)
默认模型返回了113个主题。我们可以查看最热门的主题。
topic_model.get_topic_info().head(7).set_index('Topic')[ ['Count', 'Name', 'Representation']]
最大的群组是Topic -1
,对应于异常值。默认情况下,BERTopic
使用HDBSCAN
进行聚类,并且不强制所有数据点都属于聚类。在我们的案例中,有6,356个评论是异常值(约占所有评论的49.3%)。这几乎是我们数据的一半,所以我们将稍后处理这个群组。
主题表示通常是特定于该主题而不是其他主题的一组最重要的单词。因此,了解一个主题的最佳方法是查看主要术语(在BERTopic
中,使用基于类的TF-IDF分数对单词进行排名)。
topic_model.visualize_barchart(top_n_topics = 16, n_words = 10)
BERTopic
甚至还有每个类别的主题表示,可以解决我们理解课程评价差异的任务。
topics_per_class = topic_model.topics_per_class(docs, classes=filt_df.hotel)topic_model.visualize_topics_per_class(topics_per_class, top_n_topics=10, normalize_frequency = True)
如果你想知道如何解释这个图表,你并不孤单 —— 我也无法猜测。然而,作者友好地支持这个包,GitHub上有很多答案。从讨论中,我了解到当前的归一化方法没有显示不同主题在类别中的份额。所以,它还没有完全解决我们最初的任务。
然而,我们只用不到10行代码完成了第一次迭代。这太棒了,但还有改进的空间。
处理离群值
正如我们之前所看到的,近50%的数据点被认为是离群值。这是相当多的,让我们看看我们可以对此做些什么。
文档提供了四种处理离群值的策略:
- 基于主题-文档概率,
- 基于主题分布,
- 基于c-TF-IFD表示,
- 基于文档和主题嵌入。
您可以尝试不同的策略,看看哪种策略最适合您的数据。
让我们来看看离群值的例子。尽管这些评价相对较短,它们涉及多个主题。
BERTopic
使用聚类来定义主题。这意味着每个文档只被分配一个主题。在大多数实际情况下,您的文本中可能有多个主题的混合。我们可能无法为文档分配一个主题,因为它们有多个主题。
幸运的是,这个问题有解决办法 —— 使用主题分布。采用这种方法,每个文档将被拆分成标记。然后,我们将形成子句(由滑动窗口和步幅定义),并为每个子句分配一个主题。
让我们尝试这个方法,看看是否能够减少没有主题的离群值的数量。
改进主题模型
然而,主题分布是基于拟合的主题模型的,所以让我们增强它。
首先,我们可以使用CountVectorizer。它定义了如何将文档拆分为标记。此外,它可以帮助我们摆脱无意义的单词,比如to
、not
或the
(我们的第一个模型中有很多这样的单词)。
此外,我们可以改进主题的表示,甚至尝试一些不同的模型。我使用了KeyBERTInspired
模型(更多细节),但您可以尝试其他选项(例如LLMs)。
from sklearn.feature_extraction.text import CountVectorizerfrom bertopic.representation import KeyBERTInspired, PartOfSpeech, MaximalMarginalRelevancemain_representation_model = KeyBERTInspired()aspect_representation_model1 = PartOfSpeech("en_core_web_sm")aspect_representation_model2 = [KeyBERTInspired(top_n_words=30), MaximalMarginalRelevance(diversity=.5)]representation_model = { "Main": main_representation_model, "Aspect1": aspect_representation_model1, "Aspect2": aspect_representation_model2 }vectorizer_model = CountVectorizer(min_df=5, stop_words = 'english')topic_model = BERTopic(nr_topics = 'auto', vectorizer_model = vectorizer_model, representation_model = representation_model)topics, ini_probs = topic_model.fit_transform(docs)
我指定了nr_topics = 'auto'
来减少主题的数量。然后,所有相似度超过阈值的主题将自动合并。通过这个功能,我们得到了99个主题。
我创建了一个函数来获取前几个主题及其份额,以便我们更容易进行分析。让我们来看看新的主题集。
def get_topic_stats(topic_model, extra_cols = []): topics_info_df = topic_model.get_topic_info().sort_values('Count', ascending = False) topics_info_df['Share'] = 100.*topics_info_df['Count']/topics_info_df['Count'].sum() topics_info_df['CumulativeShare'] = 100.*topics_info_df['Count'].cumsum()/topics_info_df['Count'].sum() return topics_info_df[['Topic', 'Count', 'Share', 'CumulativeShare', 'Name', 'Representation'] + extra_cols]get_topic_stats(topic_model, ['Aspect1', 'Aspect2']).head(10)\ .set_index('Topic')
我们还可以查看Interoptic距离图以更好地了解我们的聚类,例如,哪些主题彼此相近。您还可以使用它来定义一些父主题和子主题。这被称为层次主题建模,您可以使用其他工具进行操作。
topic_model.visualize_topics()
更好地理解您的主题的另一种有见地的方法是查看visualize_documents
图表(文档)。
我们可以看到主题的数量已经显著减少。此外,主题的表示中没有无意义的停用词。
减少主题数量
然而,结果中仍然存在相似的主题。我们可以进行手动调查和合并此类主题。
为此,我们可以绘制相似度矩阵。我指定了n_clusters
,我们的主题被聚类以更好地可视化。
topic_model.visualize_heatmap(n_clusters = 20)
有一些非常相似的主题。让我们计算一下成对距离并查看前几个主题。
from sklearn.metrics.pairwise import cosine_similaritydistance_matrix = cosine_similarity(np.array(topic_model.topic_embeddings_))dist_df = pd.DataFrame(distance_matrix, columns=topic_model.topic_labels_.values(), index=topic_model.topic_labels_.values())tmp = []for rec in dist_df.reset_index().to_dict('records'): t1 = rec['index'] for t2 in rec: if t2 == 'index': continue tmp.append( { 'topic1': t1, 'topic2': t2, 'distance': rec[t2] } )pair_dist_df = pd.DataFrame(tmp)pair_dist_df = pair_dist_df[(pair_dist_df.topic1.map( lambda x: not x.startswith('-1'))) & (pair_dist_df.topic2.map(lambda x: not x.startswith('-1')))]pair_dist_df = pair_dist_df[pair_dist_df.topic1 < pair_dist_df.topic2]pair_dist_df.sort_values('distance', ascending = False).head(20)
我在GitHub的讨论中找到了如何从距离矩阵中获取距离的指导。
现在,我们可以通过余弦相似度查看主题的前几个成对主题。有一些含义相近的主题可以合并。
topic_model.merge_topics(docs, [[26, 74], [43, 68, 62], [16, 50, 91]])df['merged_topic'] = topic_model.topics_
注意:合并后,所有主题的ID和表示都将重新计算,因此如果您使用它们,值得更新。
现在,我们改进了初始模型,准备进一步。
在实际任务中,值得花更多时间合并主题,并尝试不同的表示和聚类方法,以获得最佳结果。
另一个潜在的想法是将评论拆分成单独的句子,因为评论通常很长。
主题分布
让我们计算主题和标记的分布。我使用的窗口大小为4(作者建议使用4-8个标记),步幅为1。
topic_distr, topic_token_distr = topic_model.approximate_distribution( docs, window = 4, calculate_tokens=True)
例如,该评论将被拆分为子句(或四个标记的集合),然后将分配给现有主题中最接近的主题。然后,这些主题将被聚合以计算整个句子的概率。您可以在文档中找到更多详细信息。
使用这些数据,我们可以得到每个评论不同主题的概率。
topic_model.visualize_distribution(topic_distr[doc_id], min_probability=0.05)
我们甚至可以看到每个主题的术语分布,并理解为什么会得到这个结果。对于我们的句子来说,best very beautiful
是主题74
的主要术语,而location close to
定义了一系列与位置相关的主题。
vis_df = topic_model.visualize_approximate_distribution(docs[doc_id], topic_token_distr[doc_id])vis_df
这个例子还表明,我们可能应该花更多时间合并主题,因为仍然存在相似的主题。
现在,我们对每个主题和评论都有了概率。下一个任务是选择一个阈值,以过滤掉概率过低的无关主题。
我们可以像往常一样使用数据来完成这个任务。让我们计算不同阈值水平下每个评论中所选主题的分布。
tmp_dfs = []# 迭代不同的阈值水平for thr in tqdm.tqdm(np.arange(0, 0.35, 0.001)): # 计算每个文档中概率大于阈值的主题数量 tmp_df = pd.DataFrame(list(map(lambda x: len(list(filter(lambda y: y >= thr, x))), topic_distr))).rename( columns = {0: 'num_topics'} ) tmp_df['num_docs'] = 1 tmp_df['num_topics_group'] = tmp_df['num_topics']\ .map(lambda x: str(x) if x < 5 else '5+') # 汇总统计数据 tmp_df_aggr = tmp_df.groupby('num_topics_group', as_index = False).num_docs.sum() tmp_df_aggr['threshold'] = thr tmp_dfs.append(tmp_df_aggr)num_topics_stats_df = pd.concat(tmp_dfs).pivot(index = 'threshold', values = 'num_docs', columns = 'num_topics_group').fillna(0)num_topics_stats_df = num_topics_stats_df.apply(lambda x: 100.*x/num_topics_stats_df.sum(axis = 1))# 可视化colormap = px.colors.sequential.YlGnBupx.area(num_topics_stats_df, title = '主题数量分布', labels = {'num_topics_group': '主题数量', 'value': '评论比例, %'}, color_discrete_map = { '0': colormap[0], '1': colormap[3], '2': colormap[4], '3': colormap[5], '4': colormap[6], '5+': colormap[7] })
threshold = 0.05
似乎是一个不错的选择,因为在这个水平下,没有任何主题的评论的比例仍然很低(小于6%),而具有4个或更多主题的评论的百分比也不是很高。
这种方法已经帮助我们将异常值的数量从53.4%降低到了5.8%。因此,分配多个主题可能是处理异常值的有效方法。
让我们用这个阈值来计算每个文档的主题。
threshold = 0.13# 为每个文档定义概率大于等于0.13的主题df['multiple_topics'] = list(map( lambda doc_topic_distr: list(map( lambda y: y[0], filter(lambda x: x[1] >= threshold, (enumerate(doc_topic_distr))) )), topic_distr))# 创建具有文档id、主题的临时数据集tmp_data = []for rec in df.to_dict('records'): if len(rec['multiple_topics']) != 0: mult_topics = rec['multiple_topics'] else: mult_topics = [-1] for topic in mult_topics: tmp_data.append( { 'topic': topic, 'id': rec['id'], 'course_id': rec['course_id'], 'reviews_transl': rec['reviews_transl'] } ) mult_topics_df = pd.DataFrame(tmp_data)
按酒店比较分布
现在,我们为每个评论映射了多个主题,我们可以比较不同酒店连锁店的主题混合。
让我们找出某个酒店中某个主题的占比过高或过低的情况。为此,我们将计算每个主题+酒店对于该酒店与其他酒店的相关评论的占比。
tmp_data = []for hotel in mult_topics_df.hotel.unique(): for topic in mult_topics_df.topic.unique(): tmp_data.append({ 'hotel': hotel, 'topic_id': topic, 'total_hotel_reviews': mult_topics_df[mult_topics_df.hotel == hotel].id.nunique(), 'topic_hotel_reviews': mult_topics_df[(mult_topics_df.hotel == hotel) & (mult_topics_df.topic == topic)].id.nunique(), 'other_hotels_reviews': mult_topics_df[mult_topics_df.hotel != hotel].id.nunique(), 'topic_other_hotels_reviews': mult_topics_df[(mult_topics_df.hotel != hotel) & (mult_topics_df.topic == topic)].id.nunique() }) mult_topics_stats_df = pd.DataFrame(tmp_data)mult_topics_stats_df['topic_hotel_share'] = 100*mult_topics_stats_df.topic_hotel_reviews/mult_topics_stats_df.total_hotel_reviewsmult_topics_stats_df['topic_other_hotels_share'] = 100*mult_topics_stats_df.topic_other_hotels_reviews/mult_topics_stats_df.other_hotels_reviews
然而,并非所有的差异对我们来说都是显著的。我们可以说如果存在以下情况,主题分布的差异值得关注:
- 统计显著性:差异不仅仅是偶然的。
- 实际显著性:差异大于X%(我使用了1%)。
from statsmodels.stats.proportion import proportions_ztestmult_topics_stats_df['difference_pval'] = list(map( lambda x1, x2, n1, n2: proportions_ztest( count = [x1, x2], nobs = [n1, n2], alternative = 'two-sided' )[1], mult_topics_stats_df.topic_other_hotels_reviews, mult_topics_stats_df.topic_hotel_reviews, mult_topics_stats_df.other_hotels_reviews, mult_topics_stats_df.total_hotel_reviews))mult_topics_stats_df['sign_difference'] = mult_topics_stats_df.difference_pval.map( lambda x: 1 if x = -sign_percent) and (d <= sign_percent): return '无差异' if d sign_percent: return '增加' mult_topics_stats_df['diff_significance_total'] = list(map( get_significance, mult_topics_stats_df.topic_hotel_share - mult_topics_stats_df.topic_other_hotels_share, mult_topics_stats_df.sign_difference))
我们已经拥有所有主题和酒店的统计数据,最后一步是创建一个可视化工具,比较不同类别的主题份额。
import plotly# 根据差异显著性定义颜色def get_color_sign(rel): if rel == 'no diff': return plotly.colors.qualitative.Set2[7] if rel == 'lower': return plotly.colors.qualitative.Set2[1] if rel == 'higher': return plotly.colors.qualitative.Set2[0]# 将主题表示格式化为适合图表标题的形式def get_topic_representation_title(topic_model, topic): data = topic_model.get_topic(topic) data = list(map(lambda x: x[0], data)) return ', '.join(data[:5]) + ', <br> ' + ', '.join(data[5:])def get_graphs_for_topic(t): topic_stats_df = mult_topics_stats_df[mult_topics_stats_df.topic_id == t]\ .sort_values('total_hotel_reviews', ascending = False).set_index('hotel') colors = list(map( get_color_sign, topic_stats_df.diff_significance_total )) fig = px.bar(topic_stats_df.reset_index(), x = 'hotel', y = 'topic_hotel_share', title = '主题: %s' % get_topic_representation_title(topic_model, topic_stats_df.topic_id.min()), text_auto = '.1f', labels = {'topic_hotel_share': '评论份额, %'}, hover_data=['topic_id']) fig.update_layout(showlegend = False) fig.update_traces(marker_color=colors, marker_line_color=colors, marker_line_width=1.5, opacity=0.9) topic_total_share = 100.*((topic_stats_df.topic_hotel_reviews + topic_stats_df.topic_other_hotels_reviews)\ /(topic_stats_df.total_hotel_reviews + topic_stats_df.other_hotels_reviews)).min() print(topic_total_share) fig.add_shape(type="line", xref="paper", x0=0, y0=topic_total_share, x1=1, y1=topic_total_share, line=dict( color=colormap[8], width=3, dash="dot" ) ) fig.show()
然后,我们可以计算出热门主题列表,并为它们制作图表。
top_mult_topics_df = mult_topics_df.groupby('topic', as_index = False).id.nunique()top_mult_topics_df['share'] = 100.*top_mult_topics_df.id/top_mult_topics_df.id.sum()top_mult_topics_df['topic_repr'] = top_mult_topics_df.topic.map( lambda x: get_topic_representation(topic_model, x))for t in top_mult_topics_df.head(32).topic.values: get_graphs_for_topic(t)
以下是一些结果图表的示例。让我们根据这些数据尝试得出一些结论。
我们可以看到,与希尔顿或帕克广场相比,假日酒店、Travelodge和Park Inn的价格和性价比更好。
另一个发现是在Travelodge酒店中噪音可能是一个问题。
对于我来说,解释这个结果有点具有挑战性。我不确定这个主题是关于什么的。
对于这种情况,最好的做法是查看一些示例。
- 我们住在东塔,电梯正在翻新,只有一个在运行,但有指示牌显示到服务电梯的路线也可以使用。
- 然而,地毯和家具可能需要翻新。
- 它建在Queensway地铁站上。请注意,这个地铁站将被关闭翻新一年!因此您可能需要考虑噪音水平。
所以,这个主题是关于在酒店住宿期间出现临时问题或家具不在最佳状态的案例。
您可以在GitHub上找到完整的代码。
摘要
今天,我们进行了端到端的主题建模分析:
- 使用BERTopic库构建了一个基本的主题模型。
- 然后,我们处理了异常值,使得只有5.8%的评论没有被分配主题。
- 自动和手动减少了主题的数量,以得到一个简明的列表。
- 学会了如何为每个文档分配多个主题,因为在大多数情况下,您的文本会有多个主题的混合。
最后,我们能够比较不同课程的评论,创建令人鼓舞的图表并获得一些见解。
非常感谢您阅读本文。我希望对您有所启发。如果您有任何后续问题或评论,请在评论区留言。
数据集
Ganesan, Kavita和Zhai, ChengXiang。 (2011). OpinRank评论数据集。 UCI机器学习库。 https://doi.org/10.24432/C5QW4W
如果您想深入了解BERTopic
- Maarten Grootendorst(BERTopic作者)的文章“使用BERTopic进行交互式主题建模”
- Maarten Grootendorst的文章“使用BERT进行主题建模”
- Maarten Grootendorst的论文“BERTopic:基于类别的TF-IDF过程的神经主题建模”