Skip to content

接地气可视化讲解聚类算法(一):K-Medoids

· 32 min

前言#

我们常说聚类(Clustering)是迈入无监督学习大门的钥匙,但大多数教材只讲 K-Means,轻轻一笔带过 K-Medoids。

但其实:

如果你已经学过 K-Means,本篇会让你:

  1. 更好地理解“代表点”的意义;

  2. 看懂 K-Medoids 每一步怎么优化的;

  3. 彻底搞清楚为什么它更“稳”,但也更“慢”。


目录(可点击跳转)#


概念解释#

什么是 K-medoids?#

K-Medoids 是一种聚类算法,和 K-Means 很像,但是更“稳”。

想象你有一堆朋友(数据点),你想把他们分成几个小团体(簇),每个团体有个“带头大哥”(英文叫做medoid)。这个“带头大哥”不是随便选的,他必须是朋友中的一个,而且要让团体里每个人到他的“距离”总和尽量小。K-medoids 的目标就是找到这些“带头大哥”,让所有团体都尽量紧凑。

和 K-means 的区别?#

K-means 的“中心”是算出来的平均值,可能不是真实的数据点;而 k-medoids 的“中心”(medoid)必须是数据集里的一个真实点。所以 k-medoids 对离群点(奇怪的朋友)更不敏感,更符合适合现实中的情况。

举个例子:

假设你发了一个问卷,收集了很多人在「幸福感」上的评分,维度包括:

你想把人群分成几种幸福感类型。

K-Means 得到的中心是所有维度的“平均值”,可能是一个看起来“中等满意、一般社交、轻度焦虑”的组合,但现实中可能没人刚好同时具备这些特点。

而 K-Medoids 会告诉你:“第 27 号填表者”就是最能代表第 1 组的人。 你可以直接点开他的完整问卷,看他是个什么样的人、怎么活的、怎么想的——真实且可解读。

这就非常适合社会科学里的一些特点,因为我们经常需要找出“代表人物”“典型样本”,比如社会群体中的“典型用户”“典型行为者”,或者在心理访谈中选出“最能代表某类受访者”的那一位。 我们不想拿一个“平均人”来代表某个群体,我们想找一个真实存在的人,他就在这个群体里。

K-medoids是怎么做的呢?#

K-Medoids 聚类算法步骤:

  1. 随便挑 k 个带头大哥:从数据里随机选 k 个点作为初始 medoids。
  2. 分组:每个点去找离自己最近的 medoid,加入那个组。
  3. 检查谁更适合当大哥:对每个组,试试组里其他点当 medoid,看看总距离能不能变小。
  4. 换大哥:如果找到更合适的 medoid,就换掉原来的,继续分组。
  5. 停下来:如果换来换去没啥变化了(总距离不减了),就结束。

简单举个例子#

假设有 5 个点:1, 3, 10, 12, 20,我们想分成 2 组(k=2)。

再来一个例子:你想把一群人按身高聚成 3 类。

K-Means 会按平均身高来算中心点,但那可能是一个“虚拟值”,现实中没人身高正好等于它。

K-Medoids 则是选这堆人里面“最能代表这个群体的人”来当中心点。

K-Medoids 适用场景#

1. 数据中存在离群值或异常点#

K-Medoids 不容易被极端值“拉偏”,因为它选的中心点是数据集中实际存在的点,而不是被少数极端值“拉”出来的平均值。

举个例子:

在一组用户收入的数据中,大部分人月收入 3k–8k,但有个超级富豪月入 100w。

适合场景:

2. 希望中心点是某个真实存在的样本点#

K-Medoids 的中心(medoid)必须是某一个真实存在的数据点,所以我们能:

这对于商业场景、社会科学、用户研究特别重要,因为我们不想要一个虚构的代表,我们要一个能看见、能理解、能联系的真实人。

3. 距离计算不一定是欧氏距离#

K-Means 默认使用欧氏距离(空间上的直线距离),这要求数据必须是数值型、可以进行“平均”。

K-Medoids 更灵活——只要你能定义“相似度”或“距离”,它都能用。

举例:

适合场景:

4. 聚类结果需要可解释性、可操作性#

有些行业不只是要聚完类,还要:

K-Medoids 给出的“中心”点是实际样本,天然具有可解释性

适合场景:

总结:K-Medoids 更适合这些情况#

场景类型描述
存在极端值或异常点数据中包含离群样本(不一定是“错误”),希望聚类结果对它们更稳健
要找代表样本希望选出真实的代表用户 / 病人 / 问卷对象
距离无法平均文本、符号序列、结构化数据不能求均值
解释性要求高聚类结果需要被“看懂、讲明白、执行”
商业场景/社会经济/人文不只是分类,更关注真实案例,便于决策落地

关键概念的中英文对照:#

概念中文解释英文术语
Medoid中心点(真实数据点)Medoid
Clustering聚类Clustering
Inertia / Cost聚类代价Total Cost
Distance距离(如曼哈顿/欧几里得)Distance Function

K-Medoids 的算法流程#

还记得我们上面写的“带头大哥”案例吗?我们再给大家讲解一下严肃版本的算法流程:

K-Medoids 聚类算法步骤:

  1. 选择 k 个数据点作为初始中心(称为 medoids,也就是中心点/中位点)
  2. 将每个样本分配给最近的中心点
  3. 尝试将某个中心点和某个非中心点交换,如果交换后总距离变小,就保留
  4. 重复 2-3,直到没有更优的交换为止

咱们用可视化的方式帮助你来理解:

Terminal window
pip install scikit-learn matplotlib scikit-learn-extra
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans
from sklearn_extra.cluster import KMedoids
from sklearn.metrics import pairwise_distances_argmin_min
# 生成主簇 + 多个离群点
X, _ = make_blobs(n_samples=300, centers=3, cluster_std=0.9, random_state=42)
outliers = np.random.uniform(low=15, high=25, size=(30, 2))
X_with_outliers = np.vstack([X, outliers])
# KMeans
kmeans = KMeans(n_clusters=3, random_state=42).fit(X_with_outliers)
kmeans_labels = kmeans.labels_
kmeans_centers = kmeans.cluster_centers_
# KMedoids
kmedoids = KMedoids(n_clusters=3, method='alternate', random_state=42).fit(X_with_outliers)
kmedoids_labels = kmedoids.labels_
kmedoids_centers = kmedoids.cluster_centers_
# 标签颜色对齐
mapping = pairwise_distances_argmin_min(kmedoids_centers, kmeans_centers)[0]
aligned_kmedoids_labels = np.array([mapping[label] for label in kmedoids_labels])
# 可视化(美观版)
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
titles = ['K-Means 聚类(受异常点影响)', 'K-Medoids 聚类(更稳健)']
colors = ['#1f77b4', '#ff7f0e', '#2ca02c']
for ax, labels, centers, title, marker, center_color in zip(
axes,
[kmeans_labels, aligned_kmedoids_labels],
[kmeans_centers, kmedoids_centers],
titles,
['X', 'D'],
['red', 'blue']
):
for i in range(3):
cluster_points = X_with_outliers[labels == i]
ax.scatter(cluster_points[:, 0], cluster_points[:, 1], s=30, color=colors[i], alpha=0.7)
ax.scatter(centers[:, 0], centers[:, 1], c=center_color, marker=marker, s=250, edgecolor='white', linewidth=2, label='中心点')
ax.set_title(title, fontsize=14)
ax.set_xlim(-10, 30)
ax.set_ylim(-10, 30)
ax.grid(True, linestyle='--', alpha=0.5)
ax.legend()
plt.tight_layout()
plt.show()

上面代码得出来的这张图非常适合用来讲解 K-Means 和 K-Medoids 在面对“疑似新簇”时的差异与策略选择

KMeans vs KMedoids

左图:K-Means 聚类(受异常点影响?还是发现新簇?)#

右图:K-Medoids 聚类(稳健保守,不轻易接纳新簇)#


但你可能会问:

“但是这些离群点感觉也像是一个簇啊?”

这体现出一个很重要的思想:

这也意味着,K-Means 和 K-Medoids 的“差异”不是孰优孰劣,而是取舍不同;简单来说,两个算法里面,一个比较激进,一个比较保守。

小结:图中的观察#

对比维度K-MeansK-Medoids
对右上角点的处理成为新簇被归到其他簇
中心点位置可能被异常点拉偏更贴近主簇核心
是否认为“右上角是一个新簇”是(发现结构)否(认为它不够代表性)
更适合何种需求结构探索、发现潜在模式稳健聚类、抗噪音任务

下面是4个聚类的结果,可以看到这里就没有3个聚类的时候有争议性了,因为 K-Means 和 K-Medoids 都有了一致的聚类结果:

KMeans vs KMedoids


K-Medoids 聚类演化过程图#

下面是我做的一个 gif 动态图,帮助大家理解这个算法一步一步到底怎么做的。图的右下角有这个 gif 在你阅读的当前到底经历到了哪一步,如果还没有到第一步,就可以等一等,然后从第一步开始看。

K-Medoids 动画

我们接下来就来一张张详细解释这6个步骤,用非常通俗的方式帮助你理解这个算法的核心思想。

Step 1:初始随机选出 3 个样本点作为中心,并将每个样本分配到最近的 medoid#

step_1.png


Step 2:尝试替换某个 medoid,如果可以降低总距离则替换#

step_2.png


Step 3:再次尝试替换某个 medoid,如果可以降低总距离则替换#

step_3.png


Step 4:橙色簇中心发生更新,分类重新分配#

step_4.png


Step 5:所有点都尝试过替换后,发现未能进一步降低成本#

step_5.png


Step 6:达到收敛,不再更新,聚类完成,得到最终稳定的分簇结构#

step_6.png


那我们再来小小地总结一下,加深理解:#

特点K-MeansK-Medoids
中心点类型均值(可能不是实际数据点)原始数据点中的某一个
抗离群点能力弱,容易被拉偏强,不易被极端点影响
计算速度较快慢一些(要试很多组合)

对比分析 Python / C++ / Rust 三种语言中 K-Medoids 的实现#

我为什么要给大家展示这三种语言的算法版本?

Python 实现(用的是常规写法,不依赖特殊库)#

import random
import numpy as np
def manhattan_distance(a, b):
return np.sum(np.abs(a - b)) # 你也可以改成欧氏距离
def total_cost(data, medoids, clusters):
cost = 0
for i, cluster in enumerate(clusters):
for point in cluster:
cost += manhattan_distance(data[point], data[medoids[i]])
return cost
def assign_points(data, medoids):
clusters = [[] for _ in medoids]
for idx, point in enumerate(data):
distances = [manhattan_distance(point, data[medoid]) for medoid in medoids]
min_index = np.argmin(distances)
clusters[min_index].append(idx)
return clusters
def k_medoids(data, k, max_iter=100):
n = len(data)
medoids = random.sample(range(n), k)
for _ in range(max_iter):
clusters = assign_points(data, medoids)
best_cost = total_cost(data, medoids, clusters)
changed = False
for i in range(k):
for j in range(n):
if j in medoids:
continue
new_medoids = medoids[:]
new_medoids[i] = j
new_clusters = assign_points(data, new_medoids)
new_cost = total_cost(data, new_medoids, new_clusters)
if new_cost < best_cost:
medoids = new_medoids
best_cost = new_cost
changed = True
if not changed:
break
return medoids, assign_points(data, medoids)

C++ 实现(简化版,目前的版本适合小数据)#

注意:当前的版本更适合刚开始学习使用,当同学熟悉之后,可以看看 R 包 WeightedCluster 对 K-Medoids 的版本对照

#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cmath>
#include <limits>
using namespace std;
double distance(const vector<double>& a, const vector<double>& b) {
double sum = 0;
for (size_t i = 0; i < a.size(); i++)
sum += abs(a[i] - b[i]);
return sum;
}
double total_cost(const vector<vector<double>>& data, const vector<int>& medoids, const vector<vector<int>>& clusters) {
double cost = 0;
for (size_t i = 0; i < clusters.size(); i++)
for (int idx : clusters[i])
cost += distance(data[idx], data[medoids[i]]);
return cost;
}
vector<vector<int>> assign(const vector<vector<double>>& data, const vector<int>& medoids) {
vector<vector<int>> clusters(medoids.size());
for (int i = 0; i < data.size(); i++) {
double min_dist = numeric_limits<double>::max();
int best = 0;
for (int j = 0; j < medoids.size(); j++) {
double d = distance(data[i], data[medoids[j]]);
if (d < min_dist) {
min_dist = d;
best = j;
}
}
clusters[best].push_back(i);
}
return clusters;
}
pair<vector<int>, vector<vector<int>>> k_medoids(const vector<vector<double>>& data, int k) {
srand(time(0));
int n = data.size();
vector<int> medoids;
while (medoids.size() < k) {
int r = rand() % n;
if (find(medoids.begin(), medoids.end(), r) == medoids.end())
medoids.push_back(r);
}
for (int iter = 0; iter < 100; iter++) {
auto clusters = assign(data, medoids);
double best_cost = total_cost(data, medoids, clusters);
bool changed = false;
for (int i = 0; i < k; i++) {
for (int j = 0; j < n; j++) {
if (find(medoids.begin(), medoids.end(), j) != medoids.end())
continue;
vector<int> new_medoids = medoids;
new_medoids[i] = j;
auto new_clusters = assign(data, new_medoids);
double new_cost = total_cost(data, new_medoids, new_clusters);
if (new_cost < best_cost) {
medoids = new_medoids;
best_cost = new_cost;
changed = true;
}
}
}
if (!changed) break;
}
return {medoids, assign(data, medoids)};
}

Rust 实现(简洁版)#

use rand::seq::SliceRandom;
use rand::thread_rng;
fn manhattan(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| (x - y).abs()).sum()
}
fn assign(data: &[Vec<f64>], medoids: &[usize]) -> Vec<Vec<usize>> {
let mut clusters = vec![vec![]; medoids.len()];
for (i, point) in data.iter().enumerate() {
let (min_idx, _) = medoids.iter()
.enumerate()
.map(|(j, &m)| (j, manhattan(point, &data[m])))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.unwrap();
clusters[min_idx].push(i);
}
clusters
}
fn total_cost(data: &[Vec<f64>], medoids: &[usize], clusters: &[Vec<usize>]) -> f64 {
clusters.iter().enumerate().map(|(i, cluster)| {
cluster.iter().map(|&j| manhattan(&data[j], &data[medoids[i]])).sum::<f64>()
}).sum()
}
fn k_medoids(data: &[Vec<f64>], k: usize) -> (Vec<usize>, Vec<Vec<usize>>) {
let mut rng = thread_rng();
let mut medoids: Vec<usize> = (0..data.len()).collect();
medoids.shuffle(&mut rng);
medoids.truncate(k);
for _ in 0..100 {
let clusters = assign(data, &medoids);
let mut best_cost = total_cost(data, &medoids, &clusters);
let mut updated = false;
for i in 0..k {
for j in 0..data.len() {
if medoids.contains(&j) { continue; }
let mut new_medoids = medoids.clone();
new_medoids[i] = j;
let new_clusters = assign(data, &new_medoids);
let new_cost = total_cost(data, &new_medoids, &new_clusters);
if new_cost < best_cost {
medoids = new_medoids;
best_cost = new_cost;
updated = true;
}
}
}
if !updated { break; }
}
(medoids, assign(data, &medoids))
}

具体理解#

我们要实现的都是同一个目标:

给定一堆数据点,找出 k 个代表(medoids),把所有点分进离它们最近的“带头大哥”。

核心步骤回顾(所有语言都一样):

  1. 初始化:随机选出 k 个点作为初始 medoids。
  2. 分配点:每个点分到离它最近的 medoid 所在的簇。
  3. 优化 medoid:尝试替换 medoid,若总距离变小,就更新。
  4. 重复:直到收敛(不再更新)。

Python 版本的要点解析:最容易读懂,结构清晰,语法简洁#

def manhattan_distance(a, b):
return np.sum(np.abs(a - b))

使用曼哈顿距离(当然也可以改成欧氏距离),这样计算两个向量之间的距离。在社会序列分析中,一般使用的是其他dissimilarity measures,比如 optimal matching(最优匹配)。

def assign_points(data, medoids):
clusters = [[] for _ in medoids]
...

assign_points() 函数会把每个点分配给最近的 medoid,并组成簇。

for i in range(k):
for j in range(n):
if j in medoids: continue

尝试将一个新的点替换当前的 medoid,判断是否更优。


C++ 版本的要点解析:需要注意更底层的东西#

double distance(const vector<double>& a, const vector<double>& b)

使用 abs 实现曼哈顿距离。这里用的是 double,没有模板泛型,简单粗暴。

vector<vector<int>> assign(const vector<vector<double>>& data, const vector<int>& medoids)

和 Python 一样,遍历每个数据点,把它放进最近的 medoid 所属的簇。

for (int i = 0; i < k; i++) {
for (int j = 0; j < n; j++) {
...

嵌套遍历:尝试替换某个 medoid,看看有没有更好的聚类结构(距离更小)。


Rust 版本的要点解析:安全强大,但稍微晦涩难懂(刚开始学习的曲线比较陡峭)#

fn manhattan(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| (x - y).abs()).sum()
}

Rust 没有 numpy 之类的工具包,得用迭代器组合:zip → map → sum。

fn assign(data: &[Vec<f64>], medoids: &[usize]) -> Vec<Vec<usize>> { ... }

Vec<Vec<usize>> 表示所有簇,每个簇中是原始数据点的索引(和 Python 一致)。

medoids.shuffle(&mut rng);
medoids.truncate(k);

shuffle + truncate 是 Rust 的随机初始化方式(类似 Python 的 random.sample)。

总结:三语言结构对照总结表#

功能PythonC++Rust
数据结构list, numpyvectorVec, slice
随机初始化random.sample()rand() % nshuffle + truncate
距离函数np.abs(a - b).sum()abs(a[i] - b[i])zip().map().sum()
聚类表示List[List[int]]vector<vector<int>>Vec<Vec<usize>>
替换逻辑for i in range(k)for (int i = 0; i < k)for i in 0..k
是否收敛if not changed: breakif (!changed) break;if !updated { break; }
总体写法风格简洁、解释性强稍长但结构清晰类型安全但语法偏函数式

思考题(也很重要,加深理解)#

  1. 如果我们用欧氏距离(Euclidean),各语言代码应该怎么改?
  2. 如何避免暴力搜索(加速 medoid 替换)?
  3. Rust 中如果不想复制 Vec,有没有更高效写法?

思考题解析#

1. 如果我们用欧氏距离 (Euclidean),各语言代码应该怎么改?#

欧氏距离公式:

distance(a, b) = √((a1 - b1)² + (a2 - b2)² + ...)

但 K-Medoids 只求 距离大小比较,不需要真的开方,所以 可省略 sqrt 提升性能

2. 如何避免暴力搜索?(加速 medoid 替换)#
K-Medoids 主要性能瓶颈:

    每轮都要尝试将每个 medoid 替换成所有非 medoid 的点,并重新计算总距离,以寻找更优的组合,这种“枚举所有替换方案”的方式计算量很大。

具体什么意思呢?K-Medoids 算法每一步都在问: “有没有比当前这个 medoid 更合适的人选?”

于是它要做的事就是:

这就相当于:

所以,这是一种暴力搜索(brute-force search),尝试了所有可能的替代方案。这会导致上千次“全量重算”,代价非常大。

那么,我们该如何解决大数据 K-Medoids 性能问题?

3. Rust 如果不想 clone Vec,有比较高效的写法吗?#

有的,Rust 重视安全和性能,但 clone 有性能损耗,我们可以进行优化。

方法 1: swap 替换 + 恢复

let old = medoids[i];
medoids[i] = j; // 替换
...
medoids[i] = old; // 还原,避免 clone

方法 2: 先记录成本,最后一次性替换

用临时变量记录最佳方案,减少内部 mut 操作。

方法 3: 小型 arrayvec 替代 Vec

use arrayvec::ArrayVec; // 限定容量,避免 heap 分配
let mut medoids: ArrayVec<usize, 10> = (0..n).collect();

总结对比

问题解决方案
欧氏距离改成平方和求和,必要时加开方
替换性能预算距离 + FastPAM / CLARA
Rust cloneswap + 还原 / 先算后替 / 小型 ArrayVec