GID-Flow / PDGrapher /tests /test_sparse_planner.py
Boom5426's picture
Upload GID-Flow project snapshot (deduped: code + key artifacts)
07fcdfe verified
Raw
History Blame Contribute Delete
10.7 kB
"""Tests for P3: SparsePlanner, PopulationEncoder, GapEncoder, PopulationGIDModel,
target metrics, and perturbation eval metrics."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import pytest
import torch
from gidflow.models import (
PopulationEncoder,
GapEncoder,
SparsePlanner,
PopulationGIDModel,
)
from gidflow.metrics import (
recall_at_k, precision_at_k, ndcg_at_k, mrr, jaccard_topk,
compute_all_target_metrics,
pearson_r_mean, pearson_r_topk_de, de_direction_agreement,
compute_all_perturbation_metrics,
mmd_rbf_projected,
)
from gidflow.data import SyntheticPopulationPerturbationDataset, population_collate_fn
from torch.utils.data import DataLoader
# ---------------------------------------------------------------------------
# PopulationEncoder
# ---------------------------------------------------------------------------
class TestPopulationEncoder:
def test_output_shape_with_var(self):
enc = PopulationEncoder(num_genes=32, hidden_dim=64, output_dim=16, use_var=True)
cells = torch.randn(3, 10, 32)
z = enc(cells)
assert z.shape == (3, 16)
def test_output_shape_no_var(self):
enc = PopulationEncoder(num_genes=32, hidden_dim=64, output_dim=16, use_var=False)
z = enc(torch.randn(2, 8, 32))
assert z.shape == (2, 16)
def test_mask_accepted(self):
enc = PopulationEncoder(32, 64, 16, use_var=True)
cells = torch.randn(2, 8, 32)
mask = torch.ones(2, 8, dtype=torch.bool)
mask[0, 6:] = False
z = enc(cells, mask)
assert z.shape == (2, 16)
assert not z.isnan().any()
def test_gradient_flows(self):
enc = PopulationEncoder(16, 32, 8)
x = torch.randn(2, 5, 16, requires_grad=True)
z = enc(x)
z.mean().backward()
assert x.grad is not None
# ---------------------------------------------------------------------------
# GapEncoder
# ---------------------------------------------------------------------------
class TestGapEncoder:
def test_output_shape(self):
gap = GapEncoder(input_dim=16, hidden_dim=32, output_dim=24)
z_s = torch.randn(3, 16)
z_t = torch.randn(3, 16)
z_g = gap(z_s, z_t)
assert z_g.shape == (3, 24)
def test_gradient_flows(self):
gap = GapEncoder(16, 32, 24)
z_s = torch.randn(2, 16, requires_grad=True)
z_t = torch.randn(2, 16, requires_grad=True)
gap(z_s, z_t).mean().backward()
assert z_s.grad is not None
# ---------------------------------------------------------------------------
# SparsePlanner
# ---------------------------------------------------------------------------
class TestSparsePlanner:
@pytest.fixture
def planner(self):
return SparsePlanner(gap_dim=32, num_genes=64, hidden_dim=64)
def test_score_shape(self, planner):
z_gap = torch.randn(4, 32)
scores = planner(z_gap)
assert scores.shape == (4, 64)
def test_soft_mask_range(self, planner):
z_gap = torch.randn(2, 32)
mask = planner.get_soft_mask(z_gap)
assert (mask >= 0).all() and (mask <= 1).all()
def test_topk_mask_binary(self, planner):
z_gap = torch.randn(2, 32)
mask = planner.get_topk_mask(z_gap, k=5)
assert ((mask == 0) | (mask == 1)).all()
assert mask.sum(dim=-1).eq(5).all()
def test_ste_topk_gradient(self, planner):
z_gap = torch.randn(2, 32, requires_grad=True)
mask = planner.get_ste_topk_mask(z_gap, k=5)
mask.sum().backward()
assert z_gap.grad is not None
# Hard mask but gradient is not zero
assert z_gap.grad.abs().sum() > 0
def test_ste_mask_is_binary(self, planner):
z_gap = torch.randn(2, 32)
mask = planner.get_ste_topk_mask(z_gap, k=3)
assert ((mask == 0) | (mask == 1)).all()
# ---------------------------------------------------------------------------
# PopulationGIDModel (integration)
# ---------------------------------------------------------------------------
class TestPopulationGIDModel:
@pytest.fixture
def model(self):
return PopulationGIDModel(
num_genes=32,
encoder_hidden=64, encoder_output=16,
gap_hidden=32, gap_output=32,
planner_hidden=32,
response_hidden=64, response_pert_dim=32,
n_layers=1,
planner_topk=3,
)
def test_forward_keys(self, model):
B, N, G = 2, 6, 32
src = torch.randn(B, N, G)
tgt = torch.randn(B, N, G)
out = model(src, tgt)
assert "target_scores" in out
assert "target_mask" in out
assert "pred_cells" in out
def test_shapes(self, model):
B, Ns, Nt, G = 2, 8, 6, 32
src = torch.randn(B, Ns, G)
tgt = torch.randn(B, Nt, G)
out = model(src, tgt)
assert out["target_scores"].shape == (B, G)
assert out["pred_cells"].shape == (B, Ns, G)
def test_teacher_forcing(self, model):
B, N, G = 2, 5, 32
src = torch.randn(B, N, G)
tgt = torch.randn(B, N, G)
pert = torch.zeros(B, G); pert[:, :3] = 1.0
out = model(src, tgt, true_perturbation=pert)
assert out["pred_cells"].shape == (B, N, G)
def test_no_nan(self, model):
src = torch.randn(2, 6, 32)
tgt = torch.randn(2, 6, 32)
out = model(src, tgt)
assert not out["target_scores"].isnan().any()
assert not out["pred_cells"].isnan().any()
def test_end_to_end_gradient(self, model):
src = torch.randn(2, 5, 32, requires_grad=False)
tgt = torch.randn(2, 5, 32)
out = model(src, tgt)
out["pred_cells"].mean().backward()
# Check at least one param has gradient
grads = [p.grad for p in model.parameters() if p.grad is not None]
assert len(grads) > 0
def test_predict_targets_shape(self, model):
model.eval()
src = torch.randn(2, 5, 32)
tgt = torch.randn(2, 5, 32)
mask = model.predict_targets(src, tgt, topk=3)
assert mask.shape == (2, 32)
assert mask.sum(dim=-1).eq(3).all()
# ---------------------------------------------------------------------------
# Target metrics
# ---------------------------------------------------------------------------
class TestTargetMetrics:
def _make_scores_targets(self, B=4, G=64, k_true=3):
targets = torch.zeros(B, G)
for b in range(B):
idx = torch.randperm(G)[:k_true]
targets[b, idx] = 1.0
# Perfect scores: true targets get high score
scores = torch.rand(B, G)
scores[targets.bool()] += 10.0
return scores, targets
def test_recall_at_k_perfect(self):
scores, targets = self._make_scores_targets(k_true=3)
r = recall_at_k(scores, targets, k=3)
assert r.item() > 0.9
def test_recall_at_k_random(self):
torch.manual_seed(7)
targets = torch.zeros(100, 1000); targets[:, :3] = 1.0
scores = torch.rand(100, 1000)
r = recall_at_k(scores, targets, k=3)
# Expected ~3/1000 * 3 ≈ 0.009 → very low
assert r.item() < 0.1
def test_ndcg_perfect_vs_random(self):
scores, targets = self._make_scores_targets()
ndcg_good = ndcg_at_k(scores, targets, k=5).item()
scores_rand = torch.rand_like(scores)
ndcg_rand = ndcg_at_k(scores_rand, targets, k=5).item()
assert ndcg_good > ndcg_rand
def test_mrr_perfect(self):
B, G = 3, 32
targets = torch.zeros(B, G); targets[:, 0] = 1.0
scores = torch.zeros(B, G); scores[:, 0] = 10.0
r = mrr(scores, targets)
assert abs(r.item() - 1.0) < 0.01
def test_compute_all_returns_keys(self):
scores, targets = self._make_scores_targets()
out = compute_all_target_metrics(scores, targets, ks=(1, 5))
for k in ["recall@1", "recall@5", "ndcg@1", "ndcg@5", "mrr"]:
assert k in out
# ---------------------------------------------------------------------------
# Perturbation eval metrics
# ---------------------------------------------------------------------------
class TestPerturbationEval:
def _make_populations(self, B=2, N=20, G=16):
src = torch.randn(B, N, G)
tgt = src + torch.randn(B, 1, G) * 0.5 # shifted
pred = tgt + torch.randn(B, N, G) * 0.1 # close to true
rand = torch.randn(B, N, G) + 5.0 # far from true
return src, tgt, pred, rand
def test_pearson_r_perfect(self):
B, N, G = 2, 10, 16
cells = torch.randn(B, N, G)
r = pearson_r_mean(cells, cells)
assert r.item() > 0.99
def test_pearson_r_good_better_than_random(self):
src, tgt, pred, rand = self._make_populations()
r_good = pearson_r_mean(pred, tgt).item()
r_rand = pearson_r_mean(rand, tgt).item()
assert r_good > r_rand
def test_pearson_r_topk_de(self):
src, tgt, pred, _ = self._make_populations(G=32)
r = pearson_r_topk_de(pred, tgt, src, topk=8)
assert -1.0 <= r.item() <= 1.0
def test_de_direction_agreement_perfect(self):
src = torch.randn(2, 10, 16)
tgt = src + 1.0 # all positive shift
pred = src + 1.0 # identical shift
agr = de_direction_agreement(pred, tgt, src)
assert agr.item() > 0.95
def test_compute_all_returns_keys(self):
src, tgt, pred, _ = self._make_populations()
out = compute_all_perturbation_metrics(pred, tgt, src)
assert "pearson_r_mean" in out
assert "de_direction_agreement" in out
assert "nmse" in out
# ---------------------------------------------------------------------------
# mmd_rbf_projected
# ---------------------------------------------------------------------------
class TestMMDProjected:
def test_same_dist_low_mmd(self):
torch.manual_seed(0)
x = torch.randn(2, 20, 128)
mmd = mmd_rbf_projected(x, x, n_components=16)
assert mmd.item() < 0.5
def test_different_dist_higher_mmd(self):
torch.manual_seed(1)
x = torch.randn(2, 20, 128)
y = torch.randn(2, 20, 128) + 5.0
mmd_proj = mmd_rbf_projected(x, y, n_components=16)
mmd_zero = mmd_rbf_projected(x, x, n_components=16)
assert mmd_proj.item() > mmd_zero.item()
def test_shape(self):
x = torch.randn(3, 15, 64)
y = torch.randn(3, 12, 64)
mmd = mmd_rbf_projected(x, y, n_components=8)
assert mmd.shape == torch.Size([])