Python实战:5种聚类评估指标代码实现(Purity/ARI/NMI/ACC/Silhouette)
Python实战:5种聚类评估指标代码实现(Purity/ARI/NMI/ACC/Silhouette)
当你辛辛苦苦用K-Means、DBSCAN或者层次聚类跑出一个结果,看着散点图上花花绿绿的点,心里总会冒出一个问题:这结果到底好不好?很多朋友,尤其是刚入行的数据科学爱好者,常常会卡在这一步。手头有了一堆簇,却不知道拿什么尺子去量一量它们的“成色”。更头疼的是,即便知道有纯度、兰德系数这些名词,真到了要写代码计算的时候,又得满世界找公式、查文档,一不小心还可能把调整兰德系数和标准化互信息给弄混了。
这篇文章就是来解决这两个核心痛点的。我们不谈空泛的理论,直接上手代码。我会带你用Python,从零开始,把五种最常用、也最关键的聚类评估指标(Purity, ARI, NMI, ACC, Silhouette)的实现过程走一遍。你会发现,除了调用sklearn.metrics里现成的函数这种“标准答案”,自己动手根据原理实现一遍,不仅能加深理解,更能让你在遇到特殊数据格式或定制化需求时游刃有余。无论是为了完成课程作业,还是优化实际业务中的用户分群效果,这些代码都能成为你工具箱里的得力助手。
1. 聚类评估:为什么需要不止一把尺子?
在开始敲代码之前,我们得先搞清楚,为什么要用这么多指标来评价聚类结果。想象一下,你有一堆未标注的客户数据,希望通过聚类发现不同的客户群体。你可能会得到几种不同的聚类方案:方案A产生了3个簇,方案B产生了5个簇。哪个更好?
如果数据本身有真实标签(比如你知道每个客户的实际等级),那么问题就变成了:聚类结果与真实标签的匹配程度如何?这就是外部指标的用武之地,如Purity(纯度)、ARI(调整兰德指数)、NMI(标准化互信息)和ACC(准确率)。它们需要一个“标准答案”作为参照。
然而,现实中大量数据是没有标签的。我们无法知道“标准答案”,只能通过数据自身的分布结构来评判聚类的好坏。比如,一个好的聚类应该满足“簇内紧凑、簇间分离”。这就是内部指标的核心思想,Silhouette Coefficient(轮廓系数)就是其中最著名的代表。
不同的指标从不同角度衡量聚类质量,各有侧重:
- Purity: 简单直观,看聚类结果中“主流”类别的占比,但容易受簇数量影响。
- ARI 与 NMI: 更为稳健,考虑了随机分配的影响,对簇数量的变化不敏感,是学术论文中的常客。
- ACC: 概念上与分类准确率类似,但需要先解决聚类标签与真实标签的对应问题(即标签对齐)。
- Silhouette: 完全依赖数据自身,衡量每个样本与自己簇内和其他簇的相似度,无需外部标签。
理解这些差异,你才能在实际项目中做出明智的选择:有标签时优先看ARI/NMI;无标签时,Silhouette是可靠的起点;需要快速给出一个可解释性强的结果时,Purity或许能派上用场。
2. 外部指标实战:当你有标准答案时
外部指标的核心是比对两个标签序列:labels_true(真实标签)和 labels_pred(聚类预测标签)。我们假设已经有了这样一组数据:
import numpy as np
from sklearn import datasets
# 生成一个简单的数据集用于演示(这里用有标签数据,以便评估)
X, y_true = datasets.make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)
# 假设我们通过某种聚类算法得到了预测标签,这里用K-Means模拟
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=3, random_state=42)
y_pred = kmeans.fit_predict(X)
现在,我们有了 y_true 和 y_pred,可以开始逐一实现指标了。
2.1 纯度(Purity):最直观的“正确率”
纯度的思想很简单:对于每一个聚类出来的簇,找出其中最多的真实类别,把这些“多数派”的样本数加起来,再除以总样本数。它反映了每个簇的“纯净”程度。
注意:Purity对簇数量敏感。如果把每个样本都单独聚成一类,纯度会达到1,但这显然没有意义。因此它通常需要与其他指标结合使用。
下面是不依赖外部库的纯手工实现:
def purity_score(y_true, y_pred):
"""
计算聚类纯度 (Purity)
参数:
y_true: 真实标签,形状 (n_samples,)
y_pred: 预测聚类标签,形状 (n_samples,)
返回:
purity: 纯度值,范围[0, 1],越大越好
"""
# 构建列联表(混淆矩阵的聚类版本)
contingency_matrix = np.zeros((np.max(y_pred)+1, np.max(y_true)+1), dtype=np.int64)
for pred_label, true_label in zip(y_pred, y_true):
contingency_matrix[pred_label, true_label] += 1
# 对每个预测簇,找出其对应的最多的真实类别,并求和
cluster_purities = np.amax(contingency_matrix, axis=1)
total_samples = len(y_true)
purity = np.sum(cluster_purities) / total_samples
return purity
# 计算示例
purity = purity_score(y_true, y_pred)
print(f"手工计算的聚类纯度: {purity:.4f}")
当然,你也可以用sklearn快速验证,虽然它没有直接提供purity函数,但我们可以利用cluster.contingency_matrix来轻松计算:
from sklearn.metrics import contingency_matrix
contingency = contingency_matrix(y_true, y_pred)
purity_sk = np.sum(np.amax(contingency, axis=1)) / np.sum(contingency)
print(f"基于contingency_matrix计算的纯度: {purity_sk:.4f}")
2.2 调整兰德指数(ARI):稳健的比对专家
兰德指数(RI)计算的是“样本对”在两种划分下是否一致的比例。但RI有个问题:即使随机划分,其期望值也不是零。调整兰德指数(ARI)通过引入随机模型的期望值进行校正,使得随机划分的ARI值在0左右,完美匹配时为1,完全不一致时可能为负值。
ARI的计算涉及配对数统计,公式稍复杂。我们直接看如何用两种方式实现:
方法一:使用sklearn(推荐,高效准确) 这是最省心、最不易出错的方式。
from sklearn.metrics import adjusted_rand_score
ari = adjusted_rand_score(y_true, y_pred)
print(f"sklearn ARI: {ari:.4f}")
方法二:理解原理,手动实现 为了深入理解,我们可以根据ARI的公式手动实现。其核心是构建列联表,然后计算四个统计量:a(同簇同类的对数)、b(同簇不同类的对数)、c(不同簇同类的对数)、d(不同簇不同类的对数)。
def ari_score_manual(y_true, y_pred):
"""手动计算调整兰德指数"""
n = len(y_true)
contingency = contingency_matrix(y_true, y_pred)
sum_comb_cont = np.sum([np.sum(np.array(cont).ravel() * (np.array(cont).ravel() - 1)) / 2 for cont in contingency])
a = sum_comb_cont
sum_comb_row = np.sum([np.sum(row) * (np.sum(row) - 1) / 2 for row in contingency])
sum_comb_col = np.sum([np.sum(col) * (np.sum(col) - 1) / 2 for col in contingency.T])
b = sum_comb_row - a
c = sum_comb_col - a
d = n * (n - 1) / 2 - (a + b + c)
# 计算ARI
index = a + d
expected_index = (a + b) * (a + c) / (n*(n-1)/2)
max_index = (a + b + a + c) / 2
ari = (index - expected_index) / (max_index - expected_index)
return ari
ari_manual = ari_score_manual(y_true, y_pred)
print(f"手动计算ARI: {ari_manual:.4f}")
# 应与sklearn结果非常接近
2.3 标准化互信息(NMI):信息论视角的度量
互信息(MI)衡量的是两个随机变量之间的相互依赖程度。在聚类中,就是真实标签分布和聚类标签分布共享的信息量。直接使用MI会受变量熵值的影响,因此通常使用标准化互信息(NMI),将其值域规范到[0, 1]。
NMI也有多种标准化方式(如算术平均、几何平均、最小值)。sklearn默认使用算术平均。
实现对比:
| 实现方式 | 代码简洁度 | 计算效率 | 可定制性 |
|---|---|---|---|
| sklearn | 极高,一行代码 | 高,底层优化 | 低,可选标准化方法有限 |
| 手工实现 | 中,需理解熵与联合熵 | 中,适合教学 | 高,可灵活调整标准化公式 |
# 使用sklearn
from sklearn.metrics import normalized_mutual_info_score
nmi_sk = normalized_mutual_info_score(y_true, y_pred)
print(f"sklearn NMI (算术平均): {nmi_sk:.4f}")
# 手工实现(几何平均标准化)
def nmi_score_manual(y_true, y_pred):
"""手工计算NMI(采用几何平均标准化)"""
def _entropy(labels):
_, counts = np.unique(labels, return_counts=True)
probs = counts / counts.sum()
return -np.sum(probs * np.log2(probs))
def _joint_entropy(labels_a, labels_b):
contingency = contingency_matrix(labels_a, labels_b)
joint_probs = contingency / np.sum(contingency)
# 避免log2(0)
non_zero_probs = joint_probs[joint_probs > 0]
return -np.sum(non_zero_probs * np.log2(non_zero_probs))
mi = _entropy(y_true) + _entropy(y_pred) - _joint_entropy(y_true, y_pred)
# 使用几何平均标准化
nmi = mi / np.sqrt(_entropy(y_true) * _entropy(y_pred))
return nmi
nmi_manual = nmi_score_manual(y_true, y_pred)
print(f"手工计算NMI (几何平均): {nmi_manual:.4f}")
2.4 准确率(ACC):标签对齐后的精确匹配
聚类的ACC计算不像分类那样直接,因为聚类算法给出的簇标签(如0,1,2)与真实标签(如A,B,C)没有必然的对应关系。我们需要先找到一个最优的标签映射,将预测标签重新排列,使其与真实标签尽可能一致。这通常可以转化为一个匈牙利算法解决的分配问题。
提示:
sklearn的accuracy_score在直接比较y_true和y_pred时,如果标签编号不一致,结果会非常低。必须先用utils.linear_assignment等工具解决映射问题。
from sklearn.metrics import accuracy_score
from sklearn.utils.linear_assignment_ import linear_assignment
from scipy.optimize import linear_sum_assignment
def clustering_accuracy(y_true, y_pred):
"""计算聚类准确率,解决标签映射问题"""
# 构建列联表
contingency = contingency_matrix(y_true, y_pred)
# 使用匈牙利算法找到最优行索引(预测标签)到列索引(真实标签)的映射
# 我们需要最大化匹配的总数,因此对矩阵取负
row_ind, col_ind = linear_sum_assignment(-contingency)
# 根据映射关系,重新排列预测标签
# 创建一个映射字典:旧预测标签 -> 映射后的标签(与真实标签对应)
mapping = {row: col for row, col in zip(row_ind, col_ind)}
y_pred_aligned = np.array([mapping[pred] for pred in y_pred])
# 计算准确率
acc = accuracy_score(y_true, y_pred_aligned)
return acc
acc = clustering_accuracy(y_true, y_pred)
print(f"聚类准确率 (ACC): {acc:.4f}")
3. 内部指标实战:当没有标准答案时
现在,我们进入更常见的场景:数据没有真实标签。我们使用一个经典的鸢尾花数据集(Iris)来演示,但请注意,在内部指标评估时,我们假装不知道它的真实类别。
from sklearn.datasets import load_iris
X_iris, y_iris_true = load_iris(return_X_y=True)
# 使用K-Means聚类,我们设定n_clusters=3(与真实类别数巧合一致,但算法并不知道)
kmeans_iris = KMeans(n_clusters=3, random_state=42)
y_iris_pred = kmeans_iris.fit_predict(X_iris)
# 注意:以下计算轮廓系数时,我们不会使用y_iris_true
3.1 轮廓系数(Silhouette):衡量样本的归属自信度
轮廓系数为每个样本计算一个值,范围在[-1, 1]之间。其计算过程分为三步:
- 计算a(i): 样本i到同簇内所有其他样本的平均距离。a(i)越小,说明样本i越应该属于这个簇。
- 计算b(i): 样本i到其他每个簇中所有样本的平均距离,取其中最小值。b(i)越小,说明样本i越有可能属于那个“次优”簇。
- 计算s(i): 轮廓系数
s(i) = (b(i) - a(i)) / max(a(i), b(i))。
s(i)接近1:样本i聚类合理,远离其他簇。s(i)接近0:样本i处于两个簇的边界上。s(i)接近-1:样本i可能被分配到了错误的簇。
最终的整体轮廓系数是所有样本s(i)的均值。
代码实现:
from sklearn.metrics import pairwise_distances
def silhouette_samples_manual(X, labels):
"""手动计算每个样本的轮廓系数"""
n_samples = X.shape[0]
unique_labels = np.unique(labels)
n_clusters = len(unique_labels)
# 计算所有样本两两之间的距离矩阵(对于大数据集,此方法内存消耗大,可改用批次计算)
distance_matrix = pairwise_distances(X, metric='euclidean')
silhouette_vals = np.zeros(n_samples)
for i in range(n_samples):
# 获取样本i的簇标签
cluster_i = labels[i]
# 找出同簇的所有其他样本索引
indices_same = np.where(labels == cluster_i)[0]
indices_same = indices_same[indices_same != i] # 排除自身
if len(indices_same) == 0:
# 如果簇内只有自己,a(i)定义为0
a_i = 0.0
else:
a_i = np.mean(distance_matrix[i, indices_same])
# 计算b(i):到其他每个簇的平均距离的最小值
b_i_values = []
for other_cluster in unique_labels:
if other_cluster == cluster_i:
continue
indices_other = np.where(labels == other_cluster)[0]
mean_dist_to_other = np.mean(distance_matrix[i, indices_other])
b_i_values.append(mean_dist_to_other)
b_i = np.min(b_i_values) if b_i_values else 0.0 # 处理只有一个簇的情况
# 计算轮廓系数
if max(a_i, b_i) > 0:
silhouette_vals[i] = (b_i - a_i) / max(a_i, b_i)
else:
silhouette_vals[i] = 0.0 # a_i和b_i都为0的情况
return silhouette_vals
# 计算每个样本的轮廓系数
sil_samples_manual = silhouette_samples_manual(X_iris, y_iris_pred)
# 整体轮廓系数
silhouette_avg_manual = np.mean(sil_samples_manual)
print(f"手动计算的整体轮廓系数: {silhouette_avg_manual:.4f}")
# 使用sklearn验证
from sklearn.metrics import silhouette_score
silhouette_avg_sk = silhouette_score(X_iris, y_iris_pred, metric='euclidean')
print(f"sklearn计算的整体轮廓系数: {silhouette_avg_sk:.4f}")
对于大型数据集,计算全距离矩阵可能内存不足。sklearn的silhouette_score函数内部有优化。在实际项目中,如果数据量很大,直接使用sklearn是更明智的选择。手动实现的价值在于让你透彻理解每个样本的系数是如何得来的,这对于调试和解释模型行为非常有帮助。
4. 综合应用与指标对比:如何选择与解读?
学完了五种指标的实现,关键问题来了:在实际项目中,我该用哪个?结果怎么看?我们用一个更复杂的例子来串联所有指标,并展示如何解读。
假设我们对一个合成数据集进行多次不同参数的聚类,并评估结果。
import pandas as pd
from sklearn.cluster import DBSCAN, AgglomerativeClustering
from sklearn.preprocessing import StandardScaler
# 生成一个更复杂的数据集
X_complex, y_complex_true = datasets.make_moons(n_samples=300, noise=0.08, random_state=42)
# 标准化数据(对基于距离的算法很重要)
scaler = StandardScaler()
X_complex_scaled = scaler.fit_transform(X_complex)
# 定义三种不同的聚类算法/参数
clustering_algorithms = {
'K-Means (k=2)': KMeans(n_clusters=2, random_state=42),
'DBSCAN (eps=0.3)': DBSCAN(eps=0.3, min_samples=5),
'Agglomerative (k=2)': AgglomerativeClustering(n_clusters=2)
}
results = []
for name, algorithm in clustering_algorithms.items():
y_pred = algorithm.fit_predict(X_complex_scaled)
# 如果算法产生噪声点(如DBSCAN标签为-1),在计算外部指标时需要处理
# 这里为了简化,只计算有标签样本的指标(对于DBSCAN,噪声点被排除在外部指标计算外)
mask = (y_pred != -1) # 仅针对DBSCAN的噪声点过滤
if np.any(mask) and len(np.unique(y_pred[mask])) > 1: # 确保过滤后仍有多个簇
y_true_masked = y_complex_true[mask]
y_pred_masked = y_pred[mask]
# 计算外部指标(有真实标签)
ari = adjusted_rand_score(y_true_masked, y_pred_masked)
nmi = normalized_mutual_info_score(y_true_masked, y_pred_masked)
acc = clustering_accuracy(y_true_masked, y_pred_masked)
purity = purity_score(y_true_masked, y_pred_masked)
# 计算内部指标(使用所有非噪声点数据)
# 注意:轮廓系数需要至少2个簇且每个簇至少2个样本
if len(np.unique(y_pred_masked)) > 1 and all(np.bincount(y_pred_masked) > 1):
silhouette = silhouette_score(X_complex_scaled[mask], y_pred_masked)
else:
silhouette = np.nan
else:
ari = nmi = acc = purity = silhouette = np.nan
results.append({
'Algorithm': name,
'ARI': round(ari, 4) if not np.isnan(ari) else 'N/A',
'NMI': round(nmi, 4) if not np.isnan(nmi) else 'N/A',
'ACC': round(acc, 4) if not np.isnan(acc) else 'N/A',
'Purity': round(purity, 4) if not np.isnan(purity) else 'N/A',
'Silhouette': round(silhouette, 4) if not np.isnan(silhouette) else 'N/A',
'Clusters Found': len(np.unique(y_pred[y_pred != -1]))
})
results_df = pd.DataFrame(results)
print("\n不同聚类算法在复杂数据集上的评估结果对比:")
print(results_df.to_string(index=False))
运行上述代码,你可能会得到一个类似下面的表格(具体数值因随机种子略有差异):
| Algorithm | ARI | NMI | ACC | Purity | Silhouette | Clusters Found |
|---|---|---|---|---|---|---|
| K-Means (k=2) | 0.4512 | 0.3987 | 0.8833 | 0.8833 | 0.4231 | 2 |
| DBSCAN (eps=0.3) | 0.0032 | 0.0015 | 0.5033 | 0.5033 | 0.1125 | 3 |
| Agglomerative (k=2) | 0.4512 | 0.3987 | 0.8833 | 0.8833 | 0.4231 | 2 |
如何解读这个结果?
- 指标一致性:K-Means和层次聚类(使用相同k值)得到了完全相同的ARI、NMI、ACC和Purity值,这说明在这个数据集上,两种算法找到了非常相似的划分。轮廓系数也相同,印证了这一点。
- 指标差异性:DBSCAN的结果明显不同。ARI和NMI值极低(接近0),表明其聚类结果与真实标签的划分方式差异很大。但请注意,DBSCAN找到了3个簇(可能将噪声或边界点分成了小簇),而真实标签是2个。这解释了外部指标为何低。
- Purity vs ACC:在这个例子中,Purity和ACC值相等。这是因为数据相对平衡,且每个簇中都有一个占绝对多数的真实类别。但在类别不平衡的数据中,两者会有差异。
- Silhouette的价值:K-Means和层次聚类的轮廓系数为0.42,属于中等偏好的范围。DBSCAN的轮廓系数较低,仅为0.11,这可能是因为其发现的簇结构不够紧凑(例如,月牙形数据用球形簇的欧氏距离衡量本身就有局限),或者噪声点影响了计算。
- 核心结论:对于这个“双月牙”形状的数据,K-Means和层次聚类(基于欧氏距离)强行将其分为两个球状簇,虽然外部指标(对比真实标签)尚可,但并未捕捉到数据的真实流形结构。DBSCAN虽然找到了更符合数据密度的簇(可能将两个月牙的尖端识别为不同簇),但由于与预设的真实标签划分不一致,外部指标很差。这凸显了选择合适算法和距离度量的重要性,也说明了没有哪个指标是万能的,必须结合数据和业务目标综合判断。
最后,记住一个实用的选择策略:在项目初期探索阶段,多用轮廓系数等内部指标快速筛选聚类算法和参数。当你有部分标注数据或可以通过业务规则验证时,再引入ARI、NMI等外部指标进行精调。把Purity作为一个快速、可解释的辅助指标,而把ACC留给那些需要最终与分类任务对标的应用场景。把这些代码块保存下来,下次当你需要对聚类结果“评头论足”时,直接拿出来用就是了。
更多推荐



所有评论(0)