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:
co-authored by
Claude Opus 5
parent
f433e5cf59
commit
d6adea5b05
@@ -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))))
|
||||
Reference in New Issue
Block a user