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>
This commit is contained in:
Shaun Zhou
2026-09-04 15:09:35 +08:00
co-authored by Claude Opus 5
parent 4e6c8aa2ea
commit e4bd794025
8 changed files with 1693 additions and 1071 deletions
+26
View File
@@ -27,6 +27,7 @@ class ClusterResult:
inertia: float # 簇内平方距离和,越小越紧
silhouette: float = float("nan") # 轮廓系数,[-1, 1],越大越好
sizes: list[int] = field(default_factory=list)
points: torch.Tensor | None = None # KMeans 实际使用的空间(归一化 + PCA 之后),供可视化复用
def l2_normalize(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
@@ -258,6 +259,7 @@ def cluster_features(
nearest = distances.gather(1, labels[:, None]).squeeze(1)
return ClusterResult(
points=x,
labels=labels,
centroids=centroids,
distances=nearest,
@@ -267,6 +269,30 @@ def cluster_features(
)
def centroid_distances(centroids: torch.Tensor) -> torch.Tensor:
"""质心两两欧氏距离 (k, k)。数值上直接回答"簇之间离多远"。"""
return torch.cdist(centroids, centroids)
def project_2d(
points: torch.Tensor, centroids: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor, list[float]]:
"""把聚类空间投到 2 维用于画图,质心用同一组主成分变换。
用 PCA 而不是 t-SNE/UMAP:后两者为了把簇拉开会**扭曲簇间距离**,
而这里的目的恰恰是看簇之间离多远。代价是高维结构被压掉,
所以返回两个主成分的方差占比,图上会标出来供判断可信度。
"""
mean = points.mean(dim=0, keepdim=True)
centered = points - mean
_, singular, components = torch.pca_lowrank(centered, q=min(2, *points.shape), center=False)
basis = components[:, :2]
variance = singular[:2] ** 2 / (points.shape[0] - 1)
total = (centered**2).sum() / (points.shape[0] - 1)
ratio = (variance / total.clamp(min=1e-12)).tolist()
return centered @ basis, (centroids - mean) @ basis, ratio
def random_fit_index(num_samples: int, max_samples: int, seed: int = 42) -> torch.Tensor | None:
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。