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
+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()
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):
"""method=simclr 在本机自动选中的后端上(MPS / CUDA / CPU)都应能跑通。"""
sys.path.insert(0, "scripts")