| """Tests for GeneGraphEncoder, graph_utils, and GIDModel with gene graph.""" |
| import sys |
| import os |
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) |
|
|
| import pytest |
| import torch |
|
|
| from gidflow.data import ( |
| load_pdgrapher_edge_index, |
| extract_subgraph, |
| build_normalized_adjacency, |
| ) |
| from gidflow.models import GeneGraphEncoder, PopulationGIDModel |
|
|
| PDG_EI_PATH = ( |
| "/data/boom/Protein/PDGrapher/data/processed/" |
| "torch_data/real_lognorm/edge_index_A375.pt" |
| ) |
| real_data_available = pytest.mark.skipif( |
| not os.path.exists(PDG_EI_PATH), reason="PDGrapher edge_index not found" |
| ) |
|
|
|
|
| |
| |
| |
| class TestGraphUtils: |
|
|
| def _small_ei(self, G=20, E=60): |
| src = torch.randint(0, G, (E,)) |
| dst = torch.randint(0, G, (E,)) |
| return torch.stack([src, dst]) |
|
|
| def test_build_normalized_adjacency_shape(self): |
| ei = self._small_ei(G=10, E=30) |
| A = build_normalized_adjacency(ei, num_nodes=10) |
| assert A.shape == (10, 10) |
|
|
| def test_adjacency_nonneg_and_diagonal_positive(self): |
| ei = self._small_ei(G=10, E=40) |
| A = build_normalized_adjacency(ei, num_nodes=10) |
| |
| assert (A >= 0).all() |
| |
| assert (A.diag() > 0).all() |
|
|
| def test_adjacency_undirected_is_symmetric(self): |
| G = 10 |
| src = torch.randint(0, G, (30,)); dst = torch.randint(0, G, (30,)) |
| |
| ei = torch.stack([torch.cat([src, dst]), torch.cat([dst, src])]) |
| A = build_normalized_adjacency(ei, num_nodes=G) |
| assert torch.allclose(A, A.T, atol=1e-5) |
|
|
| def test_extract_subgraph_reduces_edges(self): |
| G = 10 |
| full_genes = [f"G{i}" for i in range(G)] |
| target_genes = ["G0", "G1", "G2", "G5"] |
| |
| ei = torch.tensor([[0,1,2,5,7,8],[1,2,5,0,8,9]]) |
| sub_ei, mask = extract_subgraph(ei, full_genes, target_genes) |
| |
| assert sub_ei.shape[0] == 2 |
| assert sub_ei.max() < len(target_genes) |
|
|
| def test_extract_subgraph_correct_remapping(self): |
| full_genes = ["A", "B", "C", "D"] |
| target_genes = ["B", "D"] |
| |
| ei = torch.tensor([[0, 1, 2, 1, 3], |
| [1, 0, 3, 3, 1]]) |
| sub_ei, _ = extract_subgraph(ei, full_genes, target_genes) |
| |
| assert sub_ei.shape[1] > 0 |
| assert sub_ei.max().item() < len(target_genes) |
|
|
| @real_data_available |
| def test_load_pdgrapher_edge_index(self): |
| ei, _ = load_pdgrapher_edge_index(PDG_EI_PATH) |
| assert ei.shape[0] == 2 |
| assert ei.shape[1] == 303678 |
| assert ei.max().item() == 10715 |
|
|
|
|
| |
| |
| |
| class TestGeneGraphEncoder: |
|
|
| def _make_encoder(self, G=50, out_dim=16): |
| enc = GeneGraphEncoder( |
| num_genes=G, node_feature_dim=16, hidden_dim=32, |
| output_dim=out_dim, n_layers=2 |
| ) |
| ei = torch.randint(0, G, (2, 200)) |
| enc.set_graph(ei) |
| return enc |
|
|
| def test_output_shape(self): |
| enc = self._make_encoder(G=50, out_dim=16) |
| emb = enc() |
| assert emb.shape == (50, 16) |
|
|
| def test_no_nan(self): |
| enc = self._make_encoder(G=40, out_dim=8) |
| emb = enc() |
| assert not emb.isnan().any() |
| assert not emb.isinf().any() |
|
|
| def test_gradient_through_embedding(self): |
| enc = self._make_encoder(G=30, out_dim=8) |
| emb = enc() |
| emb.sum().backward() |
| |
| assert enc.gene_embedding.weight.grad is not None |
|
|
| def test_with_protein_features(self): |
| G, F_p = 40, 4 |
| enc = GeneGraphEncoder( |
| num_genes=G, node_feature_dim=16, hidden_dim=32, |
| output_dim=16, n_layers=2, protein_feature_dim=F_p |
| ) |
| ei = torch.randint(0, G, (2, 150)) |
| enc.set_graph(ei) |
| prot = torch.rand(G, F_p) |
| emb = enc(prot) |
| assert emb.shape == (G, 16) |
|
|
| def test_different_graphs_give_different_embeddings(self): |
| G = 30 |
| enc = GeneGraphEncoder(G, 16, 32, 16, n_layers=1) |
| ei1 = torch.randint(0, G, (2, 100)) |
| ei2 = torch.randint(0, G, (2, 100)) |
| enc.set_graph(ei1); emb1 = enc().detach() |
| enc.set_graph(ei2); emb2 = enc().detach() |
| |
| assert not torch.allclose(emb1, emb2) |
|
|
| def test_set_graph_raises_before_forward(self): |
| enc = GeneGraphEncoder(20, 8, 16, 8, n_layers=1) |
| with pytest.raises(RuntimeError): |
| enc() |
|
|
| @real_data_available |
| def test_real_pdgrapher_graph(self): |
| ei, _ = load_pdgrapher_edge_index(PDG_EI_PATH) |
| enc = GeneGraphEncoder(10716, node_feature_dim=16, hidden_dim=32, output_dim=16, n_layers=1) |
| enc.set_graph(ei) |
| emb = enc() |
| assert emb.shape == (10716, 16) |
| assert not emb.isnan().any() |
|
|
|
|
| |
| |
| |
| class TestGIDModelWithGeneGraph: |
|
|
| @pytest.fixture |
| def model_and_ei(self): |
| G = 60 |
| ei = torch.randint(0, G, (2, 250)) |
| model = PopulationGIDModel( |
| num_genes=G, encoder_hidden=32, encoder_output=16, |
| gap_hidden=32, gap_output=32, planner_hidden=32, |
| response_hidden=32, response_pert_dim=16, n_layers=1, |
| planner_topk=2, encoder_use_var=False, |
| use_gene_graph=True, |
| gene_graph_node_dim=16, gene_graph_hidden_dim=32, |
| gene_graph_output_dim=16, gene_graph_n_layers=2, |
| ) |
| model.set_gene_graph(ei) |
| return model, ei |
|
|
| def test_forward_shapes(self, model_and_ei): |
| model, _ = model_and_ei |
| B, G = 2, 60 |
| src = torch.randn(B, 1, G) |
| tgt = torch.randn(B, 1, G) |
| out = model(src, tgt) |
| assert out["target_scores"].shape == (B, G) |
| assert out["pred_cells"].shape == (B, 1, G) |
|
|
| def test_no_nan(self, model_and_ei): |
| model, _ = model_and_ei |
| src = torch.randn(2, 1, 60); tgt = torch.randn(2, 1, 60) |
| out = model(src, tgt) |
| assert not out["target_scores"].isnan().any() |
| assert not out["pred_cells"].isnan().any() |
|
|
| def test_gradient_flows_through_gcn(self, model_and_ei): |
| model, _ = model_and_ei |
| src = torch.randn(2, 1, 60); tgt = torch.randn(2, 1, 60) |
| out = model(src, tgt) |
| out["target_scores"].sum().backward() |
| |
| gcn_grad = model.gene_graph_encoder.gene_embedding.weight.grad |
| assert gcn_grad is not None |
| assert gcn_grad.abs().sum() > 0 |
|
|
| def test_gcn_off_matches_no_graph(self): |
| """Without graph, model should still forward correctly.""" |
| G = 60 |
| model_no_graph = PopulationGIDModel( |
| num_genes=G, encoder_hidden=32, encoder_output=16, |
| gap_hidden=32, gap_output=32, planner_hidden=32, |
| response_hidden=32, response_pert_dim=16, n_layers=1, |
| planner_topk=2, encoder_use_var=False, use_gene_graph=False, |
| ) |
| src = torch.randn(2, 1, G); tgt = torch.randn(2, 1, G) |
| out = model_no_graph(src, tgt) |
| assert out["target_scores"].shape == (2, G) |
|
|
| def test_predict_targets_shape(self, model_and_ei): |
| model, _ = model_and_ei |
| model.eval() |
| src = torch.randn(2, 1, 60); tgt = torch.randn(2, 1, 60) |
| mask = model.predict_targets(src, tgt, topk=3) |
| assert mask.shape == (2, 60) |
| assert mask.sum(dim=-1).eq(3).all() |
|
|
| def test_with_protein_features_and_graph(self): |
| G, F_p = 50, 4 |
| ei = torch.randint(0, G, (2, 200)) |
| model = PopulationGIDModel( |
| num_genes=G, encoder_hidden=32, encoder_output=16, |
| gap_hidden=32, gap_output=32, planner_hidden=32, |
| response_hidden=32, response_pert_dim=16, n_layers=1, |
| planner_topk=1, encoder_use_var=False, |
| protein_input_dim=F_p, protein_hidden_dim=16, protein_output_dim=8, |
| use_gene_graph=True, |
| gene_graph_node_dim=16, gene_graph_hidden_dim=32, |
| gene_graph_output_dim=16, gene_graph_n_layers=1, |
| ) |
| model.set_gene_graph(ei) |
| model.set_protein_features(torch.rand(G, F_p)) |
| src = torch.randn(2, 1, G); tgt = torch.randn(2, 1, G) |
| out = model(src, tgt) |
| assert out["target_scores"].shape == (2, G) |
| assert not out["target_scores"].isnan().any() |
|
|