Spaces:
Sleeping
Sleeping
| """Synthetic-cohort tests for ``validate/transfer_gse65858.py``. | |
| No network. No engine import. A GSE65858-shaped fixture (symbols × | |
| samples matrix + `sample_id`/`hpv_status` clinical frame) is written | |
| into a tmp_path directory; the transfer function points at it via the | |
| `processed_dir` override. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| import pytest | |
| from data_pipeline import schema | |
| from validate.transfer_gse65858 import transfer_score | |
| N = 120 | |
| N_POS = 40 # HPV+ count | |
| RNG_SEED = 0 | |
| def _write_fixture(tmp_path, *, signal_gene: str, extra_genes: list[str], | |
| signal_strength: float = 3.0): | |
| """Write a synthetic (symbols x samples) parquet + clinical parquet | |
| into `tmp_path`. The signal gene is drawn so HPV+ patients score | |
| reliably higher; the extra genes are pure noise.""" | |
| rng = np.random.default_rng(RNG_SEED) | |
| sample_ids = [f"GSMsynth{i:04d}" for i in range(N)] | |
| hpv = [schema.GSE65858_HPV_POS_LABEL] * N_POS + [ | |
| schema.GSE65858_HPV_NEG_LABEL | |
| ] * (N - N_POS) | |
| all_genes = [signal_gene, *extra_genes] | |
| mat = rng.normal(loc=0.0, scale=1.0, size=(len(all_genes), N)) | |
| # Boost the signal gene on HPV+ samples. | |
| signal_ix = all_genes.index(signal_gene) | |
| mat[signal_ix, :N_POS] += signal_strength | |
| expr = pd.DataFrame(mat, index=all_genes, columns=sample_ids) | |
| expr.index.name = "symbol" | |
| clin = pd.DataFrame({ | |
| "sample_id": sample_ids, | |
| "hpv_status": hpv, | |
| "has_expression": True, | |
| }) | |
| tmp_path.mkdir(parents=True, exist_ok=True) | |
| expr.to_parquet(tmp_path / "expression.parquet") | |
| clin.to_parquet(tmp_path / "clinical.parquet", index=False) | |
| def test_signal_gene_produces_high_auroc_and_small_p(tmp_path): | |
| """A gene that carries the HPV+ signal must yield an AUROC well | |
| above 0.5 and a small permutation p.""" | |
| _write_fixture( | |
| tmp_path, | |
| signal_gene="SIG1", | |
| extra_genes=["NOISE1", "NOISE2"], | |
| signal_strength=3.0, | |
| ) | |
| out = transfer_score( | |
| ["SIG1"], n_permutations=200, seed=0, processed_dir=tmp_path, | |
| ) | |
| assert out["auroc"] is not None | |
| assert out["auroc"] > 0.9 | |
| assert out["p"] is not None | |
| assert out["p"] < 0.05 | |
| assert out["n"] == N | |
| assert out["n_pos"] == N_POS | |
| assert out["n_neg"] == N - N_POS | |
| assert out["n_found"] == 1 | |
| assert out["n_missing"] == 0 | |
| assert out["found_symbols"] == ["SIG1"] | |
| assert out["missing_symbols"] == [] | |
| def test_noise_only_symbols_are_around_chance(tmp_path): | |
| """A set of noise-only symbols must sit near AUROC 0.5 with a | |
| non-significant p — the transfer test should not fool itself.""" | |
| _write_fixture( | |
| tmp_path, | |
| signal_gene="SIG1", | |
| extra_genes=["NOISE1", "NOISE2", "NOISE3"], | |
| signal_strength=3.0, | |
| ) | |
| out = transfer_score( | |
| ["NOISE1", "NOISE2", "NOISE3"], | |
| n_permutations=200, seed=0, processed_dir=tmp_path, | |
| ) | |
| assert out["auroc"] is not None | |
| # Orientation-agnostic AUROC is bounded [0.5, 1.0]. Noise should | |
| # land near 0.5 (+ a small stochastic bump from the ceiling). | |
| assert 0.5 <= out["auroc"] < 0.70 | |
| # A non-significant result (p should generally be large; give a | |
| # forgiving bound since 200 permutations is small). | |
| assert out["p"] is not None | |
| assert out["p"] > 0.10 | |
| assert out["n_found"] == 3 | |
| assert out["missing_symbols"] == [] | |
| def test_missing_symbols_are_reported_without_crashing(tmp_path): | |
| """Symbols not present in the cohort get reported cleanly.""" | |
| _write_fixture( | |
| tmp_path, | |
| signal_gene="SIG1", | |
| extra_genes=["NOISE1"], | |
| ) | |
| out = transfer_score( | |
| ["SIG1", "NOTPRESENT_A", "NOTPRESENT_B"], | |
| n_permutations=100, seed=0, processed_dir=tmp_path, | |
| ) | |
| assert out["found_symbols"] == ["SIG1"] | |
| assert set(out["missing_symbols"]) == {"NOTPRESENT_A", "NOTPRESENT_B"} | |
| assert out["n_found"] == 1 | |
| assert out["n_missing"] == 2 | |
| assert out["auroc"] is not None | |
| def test_no_found_symbols_returns_graceful_none(tmp_path): | |
| """When none of the winner's genes appear in the cohort, the | |
| function returns a graceful payload (auroc/p None) instead of | |
| crashing or inventing a number.""" | |
| _write_fixture( | |
| tmp_path, | |
| signal_gene="SIG1", | |
| extra_genes=["NOISE1"], | |
| ) | |
| out = transfer_score( | |
| ["FOO_MISSING", "BAR_MISSING"], | |
| n_permutations=50, seed=0, processed_dir=tmp_path, | |
| ) | |
| assert out["auroc"] is None | |
| assert out["p"] is None | |
| assert out["n_found"] == 0 | |
| assert out["n_missing"] == 2 | |
| assert out["found_symbols"] == [] | |
| def test_returned_payload_contains_only_supplied_symbols(tmp_path): | |
| """AIRGAP invariant: the payload's gene NAMES are exactly the ones | |
| the caller passed in. No full gene list from the cohort leaks.""" | |
| _write_fixture( | |
| tmp_path, | |
| signal_gene="SIG1", | |
| extra_genes=["NOISE_SECRET_A", "NOISE_SECRET_B"], | |
| ) | |
| out = transfer_score( | |
| ["SIG1"], n_permutations=100, seed=0, processed_dir=tmp_path, | |
| ) | |
| all_names = list(out["found_symbols"]) + list(out["missing_symbols"]) | |
| assert all_names == ["SIG1"] | |
| # NOISE_SECRET_A / _B never appear in the payload — they were in | |
| # the cohort but never requested by the caller. | |
| payload_text = repr(out) | |
| assert "NOISE_SECRET_A" not in payload_text | |
| assert "NOISE_SECRET_B" not in payload_text | |