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:
co-authored by
Claude Opus 5
parent
4e6c8aa2ea
commit
e4bd794025
@@ -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:
|
||||
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user