test: 单元测试与端到端冒烟

88 个测试覆盖配置继承、注册表、GPU 守卫、YOLO 格式解析、数据集、模型形状、
标签模式、聚类原语与 SimCLR 损失,以及 train.py / eval.py / 聚类脚本的入口接线。

含两条回归测试:
- PCA 白化放大噪声方向导致簇塌陷(默认 whiten=False 的依据)
- classes 写成 data.yaml 路径时 num_classes 必须是类别数而非路径字符串长度

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-02 17:55:47 +08:00
co-authored by Claude Opus 5
parent f433e5cf59
commit d6adea5b05
15 changed files with 1310 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"""无监督聚类的原语。"""
import pytest
torch = pytest.importorskip("torch")
from engine.cluster import ( # noqa: E402
ClusterResult,
cluster_features,
cluster_stability,
kmeans,
normalized_mutual_info,
pca_reduce,
random_fit_index,
silhouette_score,
)
@pytest.fixture
def blobs():
"""4 团分得很开的高斯,外加 200 个纯噪声维度。"""
torch.manual_seed(0)
centers = torch.tensor([[6.0, 0, 0], [0, 6, 0], [0, 0, 6], [-6, -6, -6]])
signal = torch.cat([center + 0.4 * torch.randn(50, 3) for center in centers])
noise = 0.01 * torch.randn(200, 200)
return signal, torch.cat([signal, noise], dim=1)
def test_kmeans_recovers_well_separated_blobs(blobs):
signal, _ = blobs
labels, centroids, inertia = kmeans(signal, 4, n_init=5, seed=0)
assert sorted(torch.bincount(labels).tolist()) == [50, 50, 50, 50]
assert centroids.shape == (4, 3)
assert inertia > 0
assert silhouette_score(signal, labels) > 0.8
def test_kmeans_rejects_bad_arguments(blobs):
signal, _ = blobs
with pytest.raises(ValueError, match="至少为 2"):
kmeans(signal, 1)
with pytest.raises(ValueError, match="少于簇数"):
kmeans(signal[:3], 4)
def test_nmi_is_invariant_to_label_renaming():
labels = [0, 0, 1, 1, 2, 2]
assert normalized_mutual_info(labels, labels) == pytest.approx(1.0)
assert normalized_mutual_info(labels, [2, 2, 0, 0, 1, 1]) == pytest.approx(1.0)
torch.manual_seed(0)
unrelated = torch.randint(0, 2, (200,)).tolist()
assert normalized_mutual_info([0] * 100 + [1] * 100, unrelated) < 0.1
def test_whitening_amplifies_noise_directions(blobs):
"""回归测试:pca 白化 + 较大的 dim 会把噪声方向抬到与信号同权,簇随即塌掉。
这正是默认 `whiten=False` 的原因——早期版本默认开白化,真实数据上
最大的簇吃掉了 96% 的样本。
"""
_, noisy = blobs
clean = cluster_features(noisy, 4, pca_dim=32, whiten=False)
whitened = cluster_features(noisy, 4, pca_dim=32, whiten=True)
assert clean.silhouette > 0.8
assert sorted(clean.sizes) == [50, 50, 50, 50]
assert whitened.silhouette < clean.silhouette
assert max(whitened.sizes) > max(clean.sizes)
def test_cluster_features_is_insensitive_to_pca_dim_without_whitening(blobs):
_, noisy = blobs
scores = [cluster_features(noisy, 4, pca_dim=dim).silhouette for dim in (8, 32, 64)]
assert all(score > 0.8 for score in scores)
def test_pca_reduce_shapes_and_whitening_effect(blobs):
_, noisy = blobs
projected = pca_reduce(noisy, 16)
assert projected.shape == (200, 16)
# 不白化时各主成分方差递减;白化后大致拉平
variances = projected.var(dim=0)
assert variances[0] > variances[-1]
whitened = pca_reduce(noisy, 16, whiten=True).var(dim=0)
assert float(whitened.max() / whitened.min()) < float(variances.max() / variances.min())
def test_stability_is_high_on_real_structure_and_low_on_noise(blobs):
signal, _ = blobs
torch.manual_seed(1)
assert cluster_stability(signal, 4, seeds=(0, 7), pca_dim=0) > 0.9
assert cluster_stability(torch.randn(300, 16), 4, seeds=(0, 7), pca_dim=0) < 0.5
def test_cluster_features_returns_full_result(blobs):
signal, _ = blobs
result = cluster_features(signal, 4, pca_dim=0)
assert isinstance(result, ClusterResult)
assert result.labels.shape == (200,)
assert result.centroids.shape[0] == 4
assert result.distances.shape == (200,)
assert sum(result.sizes) == 200
def test_fit_index_only_fits_a_subset_but_assigns_everything(blobs):
signal, _ = blobs
index = random_fit_index(200, 60, seed=0)
assert index is not None and len(index) == 60
assert random_fit_index(200, 500) is None # 样本本来就少于上限
assert random_fit_index(200, 0) is None # 0 表示不采样
result = cluster_features(signal, 4, pca_dim=0, fit_index=index)
assert result.labels.shape == (200,) # 拟合用 60 个,分配仍是全部
assert sorted(result.sizes) == [50, 50, 50, 50]
def test_silhouette_subsamples_large_inputs(blobs):
signal, _ = blobs
labels = kmeans(signal, 4, n_init=3, seed=0)[0]
assert silhouette_score(signal, labels, max_samples=50) > 0.5
assert torch.isnan(torch.tensor(silhouette_score(signal, torch.zeros_like(labels))))