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
@@ -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 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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:
|
||||||
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。
|
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user