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
+28 -1
View File
@@ -186,7 +186,34 @@ uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --
簇数由配置的 `num_clusters` 决定,也可以 `--num-clusters 6` 临时覆盖(实测 4 类的换种子稳定性最高,见文档 §10.4)。产出在 `work_dir`: 簇数由配置的 `num_clusters` 决定,也可以 `--num-clusters 6` 临时覆盖(实测 4 类的换种子稳定性最高,见文档 §10.4)。产出在 `work_dir`:
`assignments.csv`(每张图的簇号与到质心的距离)、`summary.json`、 `assignments.csv`(每张图的簇号与到质心的距离)、`summary.json`、
`clusters/cluster_XX/`(按簇建的软链目录,直接用看图软件翻)、 `clusters/cluster_XX/`(按簇建的软链目录,直接用看图软件翻)、
`cluster_XX_preview.png`(每簇最靠近质心的代表样本拼图)。 `cluster_XX_preview.png`(每簇最靠近质心的代表样本拼图)、
`cluster_scatter.png`(**簇分布图**,见下)。
### 簇分布图
`cluster_scatter.png` 和 preview 拼图同级,一张图回答"各簇离多远":
- **左**:聚类空间的 PCA 二维投影,散点按簇着色,`X` 是质心。标题给出两个主成分的
方差占比——占比低说明高维结构被压掉得多,图上的远近只能参考。
- **右**:质心两两距离矩阵(在**聚类空间**里算的,不是投影后),带数值标注。
这个是准的,看重叠严不严重以它为准。
投影用 PCA 而不是 t-SNE/UMAP:后两者为了把簇拉开会**扭曲簇间距离**,而这张图的目的
恰恰是看距离。相关配置:
```yaml
scatter:
enabled: true
max_points: 5000 # 点太多会糊成一团且文件很大,超过就随机抽样
point_size: 6
alpha: 0.35
figsize: [14, 6]
dpi: 150
```
关掉用 `--set scatter.enabled=false`。图上的中文需要系统里有 PingFang / Noto Sans CJK
等字体,找不到会自动退回英文标签(不会画成豆腐块)。质心距离矩阵同时写进
`summary.json` 的 `centroid_distances`,方便脚本读取。
翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型), 翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型),
末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。 末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。
+2
View File
@@ -738,6 +738,8 @@ $k=4$ 稳定性略低于 ImageNet(0.83 vs 0.98),符合"数据的主结构
| 换种子稳定性 | `engine/cluster.py::cluster_stability` | | 换种子稳定性 | `engine/cluster.py::cluster_stability` |
| 随机子集拟合 | `engine/cluster.py::random_fit_index` | | 随机子集拟合 | `engine/cluster.py::random_fit_index` |
| 端到端聚类 | `engine/cluster.py::cluster_features` | | 端到端聚类 | `engine/cluster.py::cluster_features` |
| 质心两两距离 | `engine/cluster.py::centroid_distances` |
| 簇分布图的二维投影 | `engine/cluster.py::project_2d` |
| NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` | | NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` |
| SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` | | SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` |
| 断点续训(完整状态存取) | `engine/simclr.py::save_state` / `load_state` | | 断点续训(完整状态存取) | `engine/simclr.py::save_state` / `load_state` |
+26
View File
@@ -27,6 +27,7 @@ class ClusterResult:
inertia: float # 簇内平方距离和,越小越紧 inertia: float # 簇内平方距离和,越小越紧
silhouette: float = float("nan") # 轮廓系数,[-1, 1],越大越好 silhouette: float = float("nan") # 轮廓系数,[-1, 1],越大越好
sizes: list[int] = field(default_factory=list) 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: 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) nearest = distances.gather(1, labels[:, None]).squeeze(1)
return ClusterResult( return ClusterResult(
points=x,
labels=labels, labels=labels,
centroids=centroids, centroids=centroids,
distances=nearest, 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: def random_fit_index(num_samples: int, max_samples: int, seed: int = 42) -> torch.Tensor | None:
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。 """随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"pillow>=10.3", "pillow>=10.3",
"pyyaml>=6.0", "pyyaml>=6.0",
"swanlab[dashboard]>=0.10.0", "swanlab[dashboard]>=0.10.0",
"matplotlib>=3.8",
] ]
[dependency-groups] [dependency-groups]
+154
View File
@@ -34,9 +34,11 @@ from datasets.build import build_dataset # noqa: E402
from datasets.unlabeled import unlabeled_collate # noqa: E402 from datasets.unlabeled import unlabeled_collate # noqa: E402
from engine.cluster import ( # noqa: E402 from engine.cluster import ( # noqa: E402
StageFeatureExtractor, StageFeatureExtractor,
centroid_distances,
cluster_features, cluster_features,
cluster_stability, cluster_stability,
extract_features, extract_features,
project_2d,
random_fit_index, random_fit_index,
) )
from models.registry import BACKBONES # noqa: E402 from models.registry import BACKBONES # noqa: E402
@@ -133,6 +135,7 @@ def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
float(result.distances[result.labels == index].mean()) if size else None float(result.distances[result.labels == index].mean()) if size else None
for index, size in enumerate(result.sizes) for index, size in enumerate(result.sizes)
], ],
"centroid_distances": centroid_distances(result.centroids).tolist(),
} }
(work_dir / "summary.json").write_text( (work_dir / "summary.json").write_text(
json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8" json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8"
@@ -161,9 +164,160 @@ def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
link.symlink_to(source) link.symlink_to(source)
save_preview(work_dir / f"cluster_{cluster:02d}_preview.png", metas, indices[:preview_count]) save_preview(work_dir / f"cluster_{cluster:02d}_preview.png", metas, indices[:preview_count])
if cfg.get("scatter", {}).get("enabled", True) and result.points is not None:
scatter_path = work_dir / "cluster_scatter.png"
save_scatter(scatter_path, result, cfg)
logger.info("簇分布图已保存: %s", scatter_path)
return summary return summary
#: 按优先级尝试的中文字体(macOS / Linux 常见)。都找不到就退回英文标签,避免画出豆腐块。
CJK_FONTS = (
"PingFang SC",
"Hiragino Sans GB",
"Heiti SC",
"Noto Sans CJK SC",
"Source Han Sans SC",
"WenQuanYi Zen Hei",
"Microsoft YaHei",
"SimHei",
"Arial Unicode MS",
)
def setup_cjk_font() -> bool:
"""给 matplotlib 配一个能显示中文的字体,返回是否配上了。"""
import matplotlib
from matplotlib import font_manager
available = {f.name for f in font_manager.fontManager.ttflist}
for name in CJK_FONTS:
if name in available:
matplotlib.rcParams["font.sans-serif"] = [name, *matplotlib.rcParams["font.sans-serif"]]
matplotlib.rcParams["axes.unicode_minus"] = False # 中文字体里没有 U+2212
return True
logger.warning("未找到中文字体(尝试过 %s),图上改用英文标签", "/".join(CJK_FONTS[:3]))
return False
def save_scatter(path: Path, result, cfg) -> None:
"""把聚类结果画成二维散点 + 质心距离矩阵,放在 preview 拼图的同级目录。
投影用 PCA 而不是 t-SNE/UMAP:后两者为了把簇拉开会扭曲簇间距离,
而这张图的目的就是看簇之间离多远。图上标注了两个主成分的方差占比——
占比低说明高维结构被压掉得多,图上的远近只能参考,以右边的距离矩阵为准。
"""
import matplotlib
matplotlib.use("Agg") # 无显示环境
import matplotlib.pyplot as plt
cjk = setup_cjk_font()
scatter_cfg = dict(cfg.get("scatter", {}))
max_points = int(scatter_cfg.get("max_points", 5000))
points, centroids, ratio = project_2d(result.points, result.centroids)
labels = result.labels
# 点太多时随机抽样:既避免糊成一团,也控制文件大小
if max_points and points.shape[0] > max_points:
generator = torch.Generator().manual_seed(int(cfg.get("seed", 42)))
index = torch.randperm(points.shape[0], generator=generator)[:max_points]
points, labels = points[index], labels[index]
subtitle = f"随机抽样 {max_points} / {result.labels.shape[0]} 点"
subtitle_en = f"sampled {max_points} / {result.labels.shape[0]}"
else:
subtitle = f"全部 {points.shape[0]} 点"
subtitle_en = f"all {points.shape[0]} points"
points = points.cpu().numpy()
centroids_2d = centroids.cpu().numpy()
labels = labels.cpu().numpy()
num_clusters = result.centroids.shape[0]
colors = plt.get_cmap("tab10")(range(num_clusters))
figsize = scatter_cfg.get("figsize", [14, 6])
fig, (ax, ax_matrix) = plt.subplots(
1, 2, figsize=tuple(figsize), gridspec_kw={"width_ratios": [1.6, 1]}
)
for cluster in range(num_clusters):
mask = labels == cluster
ax.scatter(
points[mask, 0],
points[mask, 1],
s=float(scatter_cfg.get("point_size", 6)),
alpha=float(scatter_cfg.get("alpha", 0.35)),
color=colors[cluster],
linewidths=0,
label=f"cluster_{cluster:02d} n={int(result.sizes[cluster])}",
)
ax.scatter(
centroids_2d[:, 0],
centroids_2d[:, 1],
s=220,
marker="X",
c=colors,
edgecolors="black",
linewidths=1.5,
zorder=5,
)
for cluster in range(num_clusters):
ax.annotate(
f"{cluster:02d}",
centroids_2d[cluster],
textcoords="offset points",
xytext=(0, 12),
fontsize=11,
fontweight="bold",
ha="center",
zorder=6,
color="black",
bbox={"boxstyle": "round,pad=0.2", "fc": "white", "ec": "none", "alpha": 0.8},
)
if cjk:
scatter_title = (
f"PCA 二维投影({subtitle})\n"
f"两个主成分方差占比 {ratio[0]:.1%} + {ratio[1]:.1%} = {sum(ratio):.1%}"
)
matrix_title = "质心两两距离(聚类空间,非投影后)"
else:
scatter_title = (
f"PCA 2D projection ({subtitle_en})\n"
f"explained variance {ratio[0]:.1%} + {ratio[1]:.1%} = {sum(ratio):.1%}"
)
matrix_title = "pairwise centroid distance (in cluster space)"
ax.set_title(scatter_title)
ax.set_xlabel("PC1")
ax.set_ylabel("PC2")
ax.legend(loc="best", fontsize=8, framealpha=0.9)
ax.grid(alpha=0.2)
ax.set_aspect("equal", adjustable="datalim")
distances = centroid_distances(result.centroids).cpu().numpy()
image = ax_matrix.imshow(distances, cmap="viridis")
ax_matrix.set_title(matrix_title)
ax_matrix.set_xticks(range(num_clusters))
ax_matrix.set_yticks(range(num_clusters))
ax_matrix.set_xticklabels([f"{i:02d}" for i in range(num_clusters)])
ax_matrix.set_yticklabels([f"{i:02d}" for i in range(num_clusters)])
for i in range(num_clusters):
for j in range(num_clusters):
ax_matrix.text(
j, i, f"{distances[i, j]:.2f}",
ha="center", va="center",
color="white" if distances[i, j] < distances.max() * 0.6 else "black",
fontsize=9,
)
fig.colorbar(image, ax=ax_matrix, fraction=0.046)
fig.tight_layout()
fig.savefig(path, dpi=int(scatter_cfg.get("dpi", 150)))
plt.close(fig)
def save_preview(path: Path, metas: list[dict], indices: list[int], cell: int = 96, columns: int = 5): def save_preview(path: Path, metas: list[dict], indices: list[int], cell: int = 96, columns: int = 5):
"""把代表样本拼成一张图,方便一眼看出这个簇是什么缺陷。""" """把代表样本拼成一张图,方便一眼看出这个簇是什么缺陷。"""
if not indices: if not indices:
+40
View File
@@ -6,11 +6,13 @@ torch = pytest.importorskip("torch")
from engine.cluster import ( # noqa: E402 from engine.cluster import ( # noqa: E402
ClusterResult, ClusterResult,
centroid_distances,
cluster_features, cluster_features,
cluster_stability, cluster_stability,
kmeans, kmeans,
normalized_mutual_info, normalized_mutual_info,
pca_reduce, pca_reduce,
project_2d,
random_fit_index, random_fit_index,
silhouette_score, silhouette_score,
) )
@@ -124,3 +126,41 @@ def test_silhouette_subsamples_large_inputs(blobs):
labels = kmeans(signal, 4, n_init=3, seed=0)[0] labels = kmeans(signal, 4, n_init=3, seed=0)[0]
assert silhouette_score(signal, labels, max_samples=50) > 0.5 assert silhouette_score(signal, labels, max_samples=50) > 0.5
assert torch.isnan(torch.tensor(silhouette_score(signal, torch.zeros_like(labels)))) 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)
+44
View File
@@ -337,6 +337,50 @@ def test_cluster_script_simclr_branch_end_to_end(tiny_image_dir, tmp_path, monke
assert (work_dir / "clusters" / "cluster_00").is_dir() assert (work_dir / "clusters" / "cluster_00").is_dir()
def test_scatter_plot_is_written_next_to_previews(tiny_image_dir, tmp_path, monkeypatch):
"""cluster_scatter.png 要和 preview 拼图落在同一级目录。"""
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"device=cpu", "loader.num_workers=0", "loader.batch_size=4",
"num_clusters=2", "n_init=2"],
)
assert cu.main() == 0
scatter = work_dir / "cluster_scatter.png"
assert scatter.exists() and scatter.stat().st_size > 5000
assert (work_dir / "cluster_00_preview.png").parent == scatter.parent
import json
summary = json.loads((work_dir / "summary.json").read_text(encoding="utf-8"))
matrix = summary["centroid_distances"]
assert len(matrix) == 2 and matrix[0][0] == pytest.approx(0.0)
assert matrix[0][1] == pytest.approx(matrix[1][0])
def test_scatter_can_be_disabled(tiny_image_dir, tmp_path, monkeypatch):
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run_off"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"device=cpu", "loader.num_workers=0", "loader.batch_size=4",
"num_clusters=2", "n_init=2", "scatter.enabled=False"],
)
assert cu.main() == 0
assert not (work_dir / "cluster_scatter.png").exists()
assert (work_dir / "cluster_00_preview.png").exists()
def test_simclr_runs_on_the_resolved_device(tiny_image_dir, tmp_path, monkeypatch): def test_simclr_runs_on_the_resolved_device(tiny_image_dir, tmp_path, monkeypatch):
"""method=simclr 在本机自动选中的后端上(MPS / CUDA / CPU)都应能跑通。""" """method=simclr 在本机自动选中的后端上(MPS / CUDA / CPU)都应能跑通。"""
sys.path.insert(0, "scripts") sys.path.insert(0, "scripts")
Generated
+1398 -1070
View File
File diff suppressed because it is too large Load Diff