Files
Shaun ZhouandClaude Opus 5 e4bd794025 feat(cluster): 输出簇分布图,直观展示簇间距离
在 preview 拼图的同级目录生成 cluster_scatter.png:
- 左:聚类空间的 PCA 二维投影,散点按簇着色 + 质心标记,标题标注两个主成分的方差占比
- 右:质心两两距离矩阵(在聚类空间里算,不是投影后),带数值标注

投影用 PCA 而不是 t-SNE/UMAP——后两者为了把簇拉开会扭曲簇间距离,
而这张图的目的就是看距离。

- ClusterResult 新增 points 字段,保留 KMeans 实际使用的空间供可视化复用,
  质心与散点用同一组主成分变换,避免质心飘到簇外
- summary.json 新增 centroid_distances
- 新增 scatter 配置块(enabled / max_points / point_size / alpha / figsize / dpi)
- 中文字体按 PingFang / Noto Sans CJK 等顺序探测,找不到自动退回英文标签,
  不会画成豆腐块
- 新增依赖 matplotlib

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:09:35 +08:00

167 lines
5.8 KiB
Python

"""无监督聚类的原语。"""
import pytest
torch = pytest.importorskip("torch")
from engine.cluster import ( # noqa: E402
ClusterResult,
centroid_distances,
cluster_features,
cluster_stability,
kmeans,
normalized_mutual_info,
pca_reduce,
project_2d,
random_fit_index,
silhouette_score,
)
@pytest.fixture
def blobs():
"""4 团分得很开的高斯,外加 200 个纯噪声维度。"""
torch.manual_seed(0)
centers = torch.tensor([[6.0, 0, 0], [0, 6, 0], [0, 0, 6], [-6, -6, -6]])
signal = torch.cat([center + 0.4 * torch.randn(50, 3) for center in centers])
noise = 0.01 * torch.randn(200, 200)
return signal, torch.cat([signal, noise], dim=1)
def test_kmeans_recovers_well_separated_blobs(blobs):
signal, _ = blobs
labels, centroids, inertia = kmeans(signal, 4, n_init=5, seed=0)
assert sorted(torch.bincount(labels).tolist()) == [50, 50, 50, 50]
assert centroids.shape == (4, 3)
assert inertia > 0
assert silhouette_score(signal, labels) > 0.8
def test_kmeans_rejects_bad_arguments(blobs):
signal, _ = blobs
with pytest.raises(ValueError, match="至少为 2"):
kmeans(signal, 1)
with pytest.raises(ValueError, match="少于簇数"):
kmeans(signal[:3], 4)
def test_nmi_is_invariant_to_label_renaming():
labels = [0, 0, 1, 1, 2, 2]
assert normalized_mutual_info(labels, labels) == pytest.approx(1.0)
assert normalized_mutual_info(labels, [2, 2, 0, 0, 1, 1]) == pytest.approx(1.0)
torch.manual_seed(0)
unrelated = torch.randint(0, 2, (200,)).tolist()
assert normalized_mutual_info([0] * 100 + [1] * 100, unrelated) < 0.1
def test_whitening_amplifies_noise_directions(blobs):
"""回归测试:pca 白化 + 较大的 dim 会把噪声方向抬到与信号同权,簇随即塌掉。
这正是默认 `whiten=False` 的原因——早期版本默认开白化,真实数据上
最大的簇吃掉了 96% 的样本。
"""
_, noisy = blobs
clean = cluster_features(noisy, 4, pca_dim=32, whiten=False)
whitened = cluster_features(noisy, 4, pca_dim=32, whiten=True)
assert clean.silhouette > 0.8
assert sorted(clean.sizes) == [50, 50, 50, 50]
assert whitened.silhouette < clean.silhouette
assert max(whitened.sizes) > max(clean.sizes)
def test_cluster_features_is_insensitive_to_pca_dim_without_whitening(blobs):
_, noisy = blobs
scores = [cluster_features(noisy, 4, pca_dim=dim).silhouette for dim in (8, 32, 64)]
assert all(score > 0.8 for score in scores)
def test_pca_reduce_shapes_and_whitening_effect(blobs):
_, noisy = blobs
projected = pca_reduce(noisy, 16)
assert projected.shape == (200, 16)
# 不白化时各主成分方差递减;白化后大致拉平
variances = projected.var(dim=0)
assert variances[0] > variances[-1]
whitened = pca_reduce(noisy, 16, whiten=True).var(dim=0)
assert float(whitened.max() / whitened.min()) < float(variances.max() / variances.min())
def test_stability_is_high_on_real_structure_and_low_on_noise(blobs):
signal, _ = blobs
torch.manual_seed(1)
assert cluster_stability(signal, 4, seeds=(0, 7), pca_dim=0) > 0.9
assert cluster_stability(torch.randn(300, 16), 4, seeds=(0, 7), pca_dim=0) < 0.5
def test_cluster_features_returns_full_result(blobs):
signal, _ = blobs
result = cluster_features(signal, 4, pca_dim=0)
assert isinstance(result, ClusterResult)
assert result.labels.shape == (200,)
assert result.centroids.shape[0] == 4
assert result.distances.shape == (200,)
assert sum(result.sizes) == 200
def test_fit_index_only_fits_a_subset_but_assigns_everything(blobs):
signal, _ = blobs
index = random_fit_index(200, 60, seed=0)
assert index is not None and len(index) == 60
assert random_fit_index(200, 500) is None # 样本本来就少于上限
assert random_fit_index(200, 0) is None # 0 表示不采样
result = cluster_features(signal, 4, pca_dim=0, fit_index=index)
assert result.labels.shape == (200,) # 拟合用 60 个,分配仍是全部
assert sorted(result.sizes) == [50, 50, 50, 50]
def test_silhouette_subsamples_large_inputs(blobs):
signal, _ = blobs
labels = kmeans(signal, 4, n_init=3, seed=0)[0]
assert silhouette_score(signal, labels, max_samples=50) > 0.5
assert torch.isnan(torch.tensor(silhouette_score(signal, torch.zeros_like(labels))))
def test_cluster_result_keeps_the_space_kmeans_ran_in(blobs):
"""可视化要复用 KMeans 实际使用的空间,否则画出来的距离和聚类依据对不上。"""
_, noisy = blobs
result = cluster_features(noisy, 4, pca_dim=16)
assert result.points is not None
assert result.points.shape == (200, 16)
assert result.centroids.shape[1] == result.points.shape[1]
def test_centroid_distances_is_a_proper_metric_matrix(blobs):
signal, _ = blobs
result = cluster_features(signal, 4, pca_dim=0)
matrix = centroid_distances(result.centroids)
assert matrix.shape == (4, 4)
assert torch.allclose(matrix, matrix.T) # 对称
assert torch.allclose(matrix.diagonal(), torch.zeros(4), atol=1e-5) # 对角为 0
assert matrix[matrix > 0].min() > 0
def test_project_2d_shapes_and_shared_basis(blobs):
signal, _ = blobs
result = cluster_features(signal, 4, pca_dim=0)
points, centroids, ratio = project_2d(result.points, result.centroids)
assert points.shape == (200, 2)
assert centroids.shape == (4, 2)
assert len(ratio) == 2 and 0 <= sum(ratio) <= 1.0001
# 分离良好的团:前两个主成分应当解释大部分方差
assert sum(ratio) > 0.5
# 质心必须用与散点相同的变换,否则图上质心会飘到簇外
for cluster in range(4):
members = points[result.labels == cluster]
assert torch.allclose(members.mean(dim=0), centroids[cluster], atol=1e-4)