拒绝拍脑袋!Sentence-BERT 文本聚类如何用轮廓系数自动寻优?

7 阅读6分钟

📌 背景:2000+ 个要素,怎么看?

在上一篇文章中,我从 14,000 条微博中提取出了大量服务要素。

但问题来了:虽然每条要素都是 3-7 字的短语,但表达方式千差万别。比如:

  • "列车空调"、"车厢温度"、"冷气太足"、"空调不足"、"温度过低"...
  • 这些说的其实都是同一件事——空调温度问题

如果直接拿原始要素做统计:

  • "列车空调"出现 200 次
  • "车厢温度"出现 150 次
  • "冷气太足"出现 100 次
  • ...

每个单独看都不算高频,但它们加在一起就是最大的痛点!

所以需要对要素进行聚类——把语义相似的短语归到同一个主题下。

传统做法是人工合并,但 2000+ 个要素,人工合并需要好几天。

我的解法:Sentence-BERT 向量化 + 层次聚类 + 轮廓系数自动寻优

🤔 为什么选择层次聚类?

常用的聚类算法有 K-Means、DBSCAN、层次聚类等。我选择层次聚类的原因:

算法优点缺点是否适合
K-Means速度快需要预设簇数,对初始值敏感❌ 不知道应该分几类
DBSCAN自动发现簇数参数敏感,密度差异大时效果差❌ 短文本密度差异大
层次聚类不需要预设簇数,可事后调整阈值计算量大✅ 适合探索性分析

层次聚类最大的优点是:可以先聚类,再根据树状图选择合适的阈值,而不需要事先知道分几类。

📐 技术路线

原始要素 → Sentence-BERT 向量化 → 余弦相似度矩阵 → 距离矩阵 → 层次聚类 → 轮廓系数寻优 → 最优簇

第一步:文本向量化

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
embeddings = model.encode(texts, show_progress_bar=True)

为什么选 paraphrase-multilingual-mpnet-base-v2

  • 专门针对语义相似度任务优化
  • 支持中文,且效果优于 mBERT
  • 向量维度 768,信息量充足

第二步:计算距离矩阵

from sklearn.metrics.pairwise import cosine_similarity

# 归一化(重要!)
embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)

# 计算相似度矩阵
sim_matrix = cosine_similarity(embeddings)

# 转为距离矩阵
dist_matrix = 1 - sim_matrix

第三步:层次聚类

from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform

# 压缩距离矩阵
condensed_dist = squareform(dist_matrix, checks=False)

# Ward 层次聚类
Z = linkage(condensed_dist, method='ward')

🐛 踩坑记录:负距离问题

在运行层次聚类时,我遇到了一个棘手的问题:

ValueError: Linkage matrix contains negative distances

原因分析
由于浮点数精度问题,cosine_similarity 计算出的相似度可能略大于 1(比如 1.0000000000000002),导致 1 - sim 变成负数。

解决方案

dist_matrix = 1 - sim_matrix
dist_matrix = np.maximum(dist_matrix, 0)  # 强制非负
np.fill_diagonal(dist_matrix, 0)  # 对角线置零

如果 linkage 矩阵仍有负值,再做一次修正:

if np.any(Z[:, 2] < 0):
    min_nonzero = np.min(Z[Z[:, 2] > 0, 2])
    Z[Z[:, 2] < 0, 2] = min_nonzero * 0.01

📊 轮廓系数:如何自动选择最优阈值?

层次聚类完成后,需要选择一个距离阈值来决定最终分几类。

阈值越大,簇数越少;阈值越小,簇数越多。传统做法是画树状图、凭经验选——但这太主观了。

我的方案:用轮廓系数自动寻优。

轮廓系数(Silhouette Score)衡量聚类质量:

  • 接近 1:样本聚类合理,簇内紧密、簇间分离
  • 接近 0:样本在边界上,聚类效果一般
  • 接近 -1:样本应该分到别的簇

遍历不同的阈值,计算轮廓系数,选择最大值对应的阈值:

from sklearn.metrics import silhouette_score

best_score = -1
best_threshold = 0.77
best_clusters = None

thresholds = np.linspace(0.65, 0.85, 15)

for th in thresholds:
    clusters = fcluster(Z, t=th, criterion='distance')
    
    # 过滤单样本簇(轮廓系数要求每个簇至少2个样本)
    cluster_counts = pd.Series(clusters).value_counts()
    valid_clusters = cluster_counts[cluster_counts > 1].index
    valid_mask = np.isin(clusters, valid_clusters)
    
    if len(valid_clusters) >= 2:
        score = silhouette_score(
            dist_matrix[valid_mask][:, valid_mask],
            clusters[valid_mask],
            metric="precomputed"
        )
        print(f"阈值: {th:.3f} | 簇数: {len(cluster_counts)} | 轮廓系数: {score:.4f}")
        
        if score > best_score:
            best_score = score
            best_threshold = th
            best_clusters = clusters

📈 实验结果

运行轮廓系数寻优,输出如下:

阈值簇数轮廓系数
0.650230.142
0.671210.156
0.693190.171
0.714160.189
0.736150.195
0.757130.203
0.779110.187
0.80090.176
0.82180.158
0.84360.132

最优阈值 0.757,最终形成 13 个主题簇。

📊 聚类结果展示

簇 ID要素数代表要素初步解读
1152空间宽敞、环境舒适、运行平稳乘车舒适体验(正面)
2161工作人员帮助、服务贴心、失物找回工作人员服务(正面)
3183速度快、效率高、出行便利出行效率(正面)
4438文化展示、节日氛围、创意设计文化活动与氛围(正面)
5546祝福暖心、毕业祝福、情感共鸣暖心祝福活动(正面)
6584温度不适、空调过冷、闷热空调温度问题(负面)
7390拥挤、人多、空间不足车厢拥挤问题(负面)
8295电梯缺失、设施不足、设计缺陷设施不足问题(负面)
9179列车故障、延误、安全隐患运营安全问题(负面)
10232票价高、速度慢、换乘不便性价比问题(负面)
11285管理混乱、服务态度差、标识不清管理服务问题(负面)
12229刹车不稳、灯光昏暗、颠簸乘坐舒适度问题(负面)
13182噪音大、异味、卫生差环境卫生问题(负面)

💡 三条核心经验

1. 向量归一化很重要

计算余弦相似度前,先对向量做 L2 归一化,可以避免浮点数精度导致的相似度 > 1 问题。

embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)

2. 轮廓系数需要过滤单样本簇

silhouette_score 要求每个簇至少 2 个样本,否则会报错。在遍历阈值时,需要先过滤掉单样本簇。

3. 阈值范围要合理

不要从 0 到 1 全部遍历。我根据树状图先锁定大致范围(0.65-0.85),然后在这个范围内精细搜索 15 个点,效率更高。

🔧 完整代码

def safe_clustering(texts, init_threshold=0.77):
    # 1. 向量化
    model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
    embeddings = model.encode(texts, show_progress_bar=True)
    embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
    
    # 2. 距离矩阵
    sim_matrix = cosine_similarity(embeddings)
    dist_matrix = 1 - sim_matrix
    dist_matrix = np.maximum(dist_matrix, 0)
    np.fill_diagonal(dist_matrix, 0)
    
    # 3. 层次聚类
    condensed_dist = squareform(dist_matrix, checks=False)
    Z = linkage(condensed_dist, method='ward')
    
    # 4. 轮廓系数寻优
    best_score = -1
    best_threshold = init_threshold
    best_clusters = None
    
    thresholds = np.linspace(0.65, 0.85, 15)
    for th in thresholds:
        clusters = fcluster(Z, t=th, criterion='distance')
        cluster_counts = pd.Series(clusters).value_counts()
        valid_clusters = cluster_counts[cluster_counts > 1].index
        valid_mask = np.isin(clusters, valid_clusters)
        
        if len(valid_clusters) >= 2:
            score = silhouette_score(
                dist_matrix[valid_mask][:, valid_mask],
                clusters[valid_mask],
                metric="precomputed"
            )
            if score > best_score:
                best_score = score
                best_threshold = th
                best_clusters = clusters
    
    return best_clusters, embeddings, dist_matrix, best_threshold, best_score

🔗 完整代码与项目

完整实现已开源在 GitHub:
👉 nanjing-metro-analysis/notebooks/01_optimal_threshold_clustering.ipynb

📮 写在最后

聚类阈值的选取不应该靠"拍脑袋"。轮廓系数提供了一个客观、可量化的评价标准,让聚类结果更有说服力。

下一篇将分享《AI 帮你做总结:文本聚类后,如何用 DeepSeek 批量实现语义对齐?》,教你如何让 LLM 自动给每个簇起一个标准化的名字。


本文是"南京地铁乘客需求分析"系列的第三篇