📌 背景: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.650 | 23 | 0.142 |
| 0.671 | 21 | 0.156 |
| 0.693 | 19 | 0.171 |
| 0.714 | 16 | 0.189 |
| 0.736 | 15 | 0.195 |
| 0.757 | 13 | 0.203 |
| 0.779 | 11 | 0.187 |
| 0.800 | 9 | 0.176 |
| 0.821 | 8 | 0.158 |
| 0.843 | 6 | 0.132 |
最优阈值 0.757,最终形成 13 个主题簇。
📊 聚类结果展示
| 簇 ID | 要素数 | 代表要素 | 初步解读 |
|---|---|---|---|
| 1 | 152 | 空间宽敞、环境舒适、运行平稳 | 乘车舒适体验(正面) |
| 2 | 161 | 工作人员帮助、服务贴心、失物找回 | 工作人员服务(正面) |
| 3 | 183 | 速度快、效率高、出行便利 | 出行效率(正面) |
| 4 | 438 | 文化展示、节日氛围、创意设计 | 文化活动与氛围(正面) |
| 5 | 546 | 祝福暖心、毕业祝福、情感共鸣 | 暖心祝福活动(正面) |
| 6 | 584 | 温度不适、空调过冷、闷热 | 空调温度问题(负面) |
| 7 | 390 | 拥挤、人多、空间不足 | 车厢拥挤问题(负面) |
| 8 | 295 | 电梯缺失、设施不足、设计缺陷 | 设施不足问题(负面) |
| 9 | 179 | 列车故障、延误、安全隐患 | 运营安全问题(负面) |
| 10 | 232 | 票价高、速度慢、换乘不便 | 性价比问题(负面) |
| 11 | 285 | 管理混乱、服务态度差、标识不清 | 管理服务问题(负面) |
| 12 | 229 | 刹车不稳、灯光昏暗、颠簸 | 乘坐舒适度问题(负面) |
| 13 | 182 | 噪音大、异味、卫生差 | 环境卫生问题(负面) |
💡 三条核心经验
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 自动给每个簇起一个标准化的名字。
本文是"南京地铁乘客需求分析"系列的第三篇