From 41a77429d50143e0c7b9b53327c84c373bd2def0 Mon Sep 17 00:00:00 2001 From: Amir Feizi Date: Mon, 1 Jun 2026 20:46:52 +0000 Subject: [PATCH 1/4] Add enrichment fixes, p-values, matched-pairs export, and CI Correctness fixes: - Return NaN for RR/OR when either comparison group is empty instead of misleading continuity-corrected effect sizes. - Match EFO similarity pairs in both orientations in matching and disease expansion (handles inline or one-sided lookup tables). New features: - Add two-sided Fisher's exact test p_value to enrichment results (statistics.fisher_exact_pvalue, exposed in API/CLI/MCP output). - Add create_matched_pairs_df() audit export with gene, ct_efo_id, ge_efo_id, similarity, and match_type columns. - Add validate(return_matched_pairs=True) and CLI --save-matched-pairs (optional output.save_matched_pairs in YAML config). - Improve CLI summary: show p-value column and render undefined RR/CI as N/A. CI: - Add GitHub Actions workflow (.github/workflows/ci.yml) running ruff and pytest on Python 3.11, 3.12, and 3.13. Tests: 130 passed. --- .github/workflows/ci.yml | 32 +++++++ src/ct_validation/__init__.py | 2 + src/ct_validation/api.py | 67 ++++++++++++--- src/ct_validation/cli.py | 79 ++++++++++++++--- src/ct_validation/config/loader.py | 2 + src/ct_validation/config/schema.py | 1 + src/ct_validation/mcp_server.py | 1 + src/ct_validation/validation/__init__.py | 14 +++- src/ct_validation/validation/enrichment.py | 25 ++++-- src/ct_validation/validation/matching.py | 98 +++++++++++++++++++++- src/ct_validation/validation/statistics.py | 34 ++++++++ tests/test_api.py | 35 ++++++++ tests/test_cli.py | 21 +++++ tests/test_config.py | 1 + tests/test_enrichment.py | 46 ++++++++++ tests/test_matching.py | 74 +++++++++++++++- tests/test_mcp_server.py | 1 + tests/test_statistics.py | 24 +++++- 18 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..41ba9dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev,mcp]" + + - name: Lint + run: ruff check src tests + + - name: Test + run: pytest tests/ -v diff --git a/src/ct_validation/__init__.py b/src/ct_validation/__init__.py index c26570b..e9e5a91 100644 --- a/src/ct_validation/__init__.py +++ b/src/ct_validation/__init__.py @@ -11,6 +11,7 @@ from ct_validation.validation import ( EnrichmentResult, calculate_enrichment, + create_matched_pairs_df, create_matched_pairs_set, get_expanded_disease_set, katz_ci_risk_ratio, @@ -24,6 +25,7 @@ "Config", "EnrichmentResult", "calculate_enrichment", + "create_matched_pairs_df", "create_matched_pairs_set", "forest_plot", "get_expanded_disease_set", diff --git a/src/ct_validation/api.py b/src/ct_validation/api.py index 5e5ed76..8bae433 100644 --- a/src/ct_validation/api.py +++ b/src/ct_validation/api.py @@ -10,7 +10,7 @@ from ct_validation.config.schema import DEFAULT_PHASE_TRANSITIONS, Config, Thresholds from ct_validation.data.schema import CLINICAL_TRIALS, GENETIC_EVIDENCE, SIMILARITY_LOOKUP from ct_validation.validation.enrichment import calculate_all_enrichments -from ct_validation.validation.matching import create_matched_pairs_set +from ct_validation.validation.matching import create_matched_pairs_df, create_matched_pairs_set _DataInput = pd.DataFrame | str | Path @@ -142,6 +142,25 @@ def _prepare_clinical_trials( return ct.drop(columns=["_has_baseline"]) +def _pack_validation_result( + enrichment: pd.DataFrame, + trials: pd.DataFrame | None, + matched_pairs: pd.DataFrame | None, + *, + return_trials: bool, + return_matched_pairs: bool, +) -> pd.DataFrame | tuple: + """Build validate() return value from optional extras.""" + if not return_trials and not return_matched_pairs: + return enrichment + parts: list[pd.DataFrame] = [enrichment] + if return_trials: + parts.append(trials) + if return_matched_pairs: + parts.append(matched_pairs) + return tuple(parts) + + def validate( config: Config | str | Path | None = None, *, @@ -153,7 +172,8 @@ def validate( similarity_threshold: float | None = None, phase_transitions: list[tuple[int, int]] | None = None, return_trials: bool = False, -) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame] | list: + return_matched_pairs: bool = False, +) -> pd.DataFrame | tuple | list: """ Run validation pipeline. @@ -191,9 +211,12 @@ def validate( rate_yes, rate_no: Progression rates. rr, rr_ci_lower, rr_ci_upper: Risk ratio with 95% CI (Katz log method). or, or_ci_lower, or_ci_upper: Odds ratio with 95% CI (Woolf logit method). + p_value: Two-sided Fisher's exact test p-value. Returns: - Single targets: DataFrame (or tuple with trials_df if return_trials=True). + Single targets: enrichment DataFrame, or tuple with optional extras in order: + (enrichment), (enrichment, trials), (enrichment, matched_pairs), + or (enrichment, trials, matched_pairs). List of targets: list of the above, one per target set. """ batch = isinstance(targets, list) @@ -238,14 +261,26 @@ def validate( results = [] for t in p.targets_list: - target_pairs = create_matched_pairs_set( - genetic_evidence=t, - clinical_trials=p.clinical_trials, - similarity_pairs=p.similarity_lookup, - similarity_threshold=p.similarity_threshold, - gene_universe=gu, - con=con, - ) + if return_matched_pairs: + matched_pairs_df = create_matched_pairs_df( + genetic_evidence=t, + clinical_trials=p.clinical_trials, + similarity_pairs=p.similarity_lookup, + similarity_threshold=p.similarity_threshold, + gene_universe=gu, + con=con, + ) + target_pairs = set(zip(matched_pairs_df["gene"], matched_pairs_df["ct_efo_id"])) + else: + matched_pairs_df = None + target_pairs = create_matched_pairs_set( + genetic_evidence=t, + clinical_trials=p.clinical_trials, + similarity_pairs=p.similarity_lookup, + similarity_threshold=p.similarity_threshold, + gene_universe=gu, + con=con, + ) ct = _prepare_clinical_trials( clinical_trials=p.clinical_trials, @@ -259,7 +294,15 @@ def validate( phase_transitions=p.phase_transitions, ) - results.append((enrichment, ct) if return_trials else enrichment) + results.append( + _pack_validation_result( + enrichment, + ct, + matched_pairs_df, + return_trials=return_trials, + return_matched_pairs=return_matched_pairs, + ), + ) con.close() diff --git a/src/ct_validation/cli.py b/src/ct_validation/cli.py index 6c0b024..cc98aa3 100644 --- a/src/ct_validation/cli.py +++ b/src/ct_validation/cli.py @@ -1,9 +1,11 @@ """Command-line interface for ct-validation.""" +import math from enum import Enum from pathlib import Path from typing import Annotated +import pandas as pd import typer from ct_validation import validate @@ -69,6 +71,13 @@ def main( help="Save annotated trials DataFrame alongside enrichment results.", ), ] = False, + save_matched_pairs: Annotated[ # noqa: FBT002 + bool, + typer.Option( + "--save-matched-pairs", + help="Save target match details (gene, ct_efo_id, ge_efo_id, similarity).", + ), + ] = False, output_format: Annotated[ OutputFormat, typer.Option("--format", help="Output format."), @@ -91,6 +100,7 @@ def main( rate_yes, rate_no Progression rates rr, rr_ci_lower/upper Risk ratio with 95% CI or, or_ci_lower/upper Odds ratio with 95% CI + p_value Two-sided Fisher's exact test p-value Examples: ct-validation --config config.yaml @@ -106,9 +116,11 @@ def main( if output_dir is None: output_dir = config.output.dir if config and config.output.dir else Path() - # Resolve save_trials from config if not set via flag + # Resolve save flags from config if not set via CLI if not save_trials and config: save_trials = config.output.save_trials + if not save_matched_pairs and config: + save_matched_pairs = config.output.save_matched_pairs output_dir.mkdir(parents=True, exist_ok=True) @@ -136,6 +148,7 @@ def main( similarity_threshold=similarity_threshold, phase_transitions=parsed_transitions, return_trials=save_trials, + return_matched_pairs=save_matched_pairs, ) # Normalize to list for uniform handling @@ -144,36 +157,78 @@ def main( for r, label in zip(results, labels): suffix = f"_{label}" if label else "" - - if save_trials: - enrichment_df, trials_df = r - else: - enrichment_df = r + enrichment_df, trials_df, matched_df = _unpack_validation_result( + r, + save_trials=save_trials, + save_matched_pairs=save_matched_pairs, + ) # Save results enrichment_path = output_dir / f"enrichment_results{suffix}.{output_format.value}" _save_df(enrichment_df, enrichment_path, output_format) print(f"Saved enrichment results to {enrichment_path}") - if save_trials: + if save_trials and trials_df is not None: trials_path = output_dir / f"annotated_trials{suffix}.{output_format.value}" _save_df(trials_df, trials_path, output_format) print(f"Saved annotated trials to {trials_path}") + if save_matched_pairs and matched_df is not None: + matched_path = output_dir / f"matched_pairs{suffix}.{output_format.value}" + _save_df(matched_df, matched_path, output_format) + print(f"Saved matched pairs to {matched_path}") + # Print summary header = f"\nEnrichment Results ({label}):" if label else "\nEnrichment Results:" print(header) - print("-" * 60) - print(f"{'Phase':<12} {'n_yes':>8} {'n_no':>10} {'RR':>8} {'95% CI':<20}") - print("-" * 60) + print("-" * 72) + print(f"{'Phase':<12} {'n_yes':>8} {'n_no':>10} {'RR':>8} {'95% CI':<20} {'p-value':>10}") + print("-" * 72) for _, row in enrichment_df.iterrows(): - ci = f"[{row['rr_ci_lower']:.3f}, {row['rr_ci_upper']:.3f}]" + ci = _format_ci(row["rr_ci_lower"], row["rr_ci_upper"]) print( f"{row['phase_label']:<12} {row['n_yes']:>8} {row['n_no']:>10} " - f"{row['rr']:>8.3f} {ci:<20}", + f"{_format_number(row['rr']):>8} {ci:<20} {_format_number(row['p_value'], width=10):>10}", ) +def _unpack_validation_result( + result: pd.DataFrame | tuple, + *, + save_trials: bool, + save_matched_pairs: bool, +) -> tuple[pd.DataFrame, pd.DataFrame | None, pd.DataFrame | None]: + """Split validate() output into enrichment and optional extras.""" + if not save_trials and not save_matched_pairs: + return result, None, None + + parts = result if isinstance(result, tuple) else (result,) + enrichment_df = parts[0] + offset = 1 + trials_df = parts[offset] if save_trials else None + if save_trials: + offset += 1 + matched_df = parts[offset] if save_matched_pairs else None + return enrichment_df, trials_df, matched_df + + +def _format_number(value: float, *, width: int = 8, precision: int = 3) -> str: + """Format numeric output, using N/A for undefined values.""" + if value is None or (isinstance(value, float) and math.isnan(value)): + return "N/A".rjust(width) + return f"{value:>{width}.{precision}f}" + + +def _format_ci(lower: float, upper: float) -> str: + """Format a confidence interval, handling undefined bounds.""" + if any( + v is None or (isinstance(v, float) and math.isnan(v)) + for v in (lower, upper) + ): + return "N/A" + return f"[{lower:.3f}, {upper:.3f}]" + + def _save_df(df, path: Path, fmt: OutputFormat) -> None: """Save DataFrame in specified format.""" if fmt == OutputFormat.parquet: diff --git a/src/ct_validation/config/loader.py b/src/ct_validation/config/loader.py index 539c304..633f524 100644 --- a/src/ct_validation/config/loader.py +++ b/src/ct_validation/config/loader.py @@ -24,6 +24,8 @@ def load_config(path: str | Path) -> Config: output_kwargs["dir"] = Path(output_raw["dir"]) if "save_trials" in output_raw: output_kwargs["save_trials"] = output_raw["save_trials"] + if "save_matched_pairs" in output_raw: + output_kwargs["save_matched_pairs"] = output_raw["save_matched_pairs"] output = Output(**output_kwargs) # Parse thresholds - dataclass handles defaults diff --git a/src/ct_validation/config/schema.py b/src/ct_validation/config/schema.py index 122c5eb..2c59969 100644 --- a/src/ct_validation/config/schema.py +++ b/src/ct_validation/config/schema.py @@ -24,6 +24,7 @@ class Output: dir: Path | None = None save_trials: bool = False + save_matched_pairs: bool = False @dataclass diff --git a/src/ct_validation/mcp_server.py b/src/ct_validation/mcp_server.py index 7afbee6..4fc8926 100644 --- a/src/ct_validation/mcp_server.py +++ b/src/ct_validation/mcp_server.py @@ -90,6 +90,7 @@ def ct_validate( - n_yes, n_no, x_yes, x_no, rate_yes, rate_no - rr, rr_ci_lower, rr_ci_upper (risk ratio with 95% CI) - or, or_ci_lower, or_ci_upper (odds ratio with 95% CI) + - p_value (two-sided Fisher's exact test) """ ct_input = _to_input(clinical_trials, "clinical_trials") targets_input = _to_input(targets, "targets") diff --git a/src/ct_validation/validation/__init__.py b/src/ct_validation/validation/__init__.py index bf9417c..3eaed6f 100644 --- a/src/ct_validation/validation/__init__.py +++ b/src/ct_validation/validation/__init__.py @@ -1,13 +1,23 @@ """Core validation logic for clinical trial enrichment.""" from ct_validation.validation.enrichment import EnrichmentResult, calculate_enrichment -from ct_validation.validation.matching import create_matched_pairs_set, get_expanded_disease_set -from ct_validation.validation.statistics import katz_ci_risk_ratio, woolf_ci_odds_ratio +from ct_validation.validation.matching import ( + create_matched_pairs_df, + create_matched_pairs_set, + get_expanded_disease_set, +) +from ct_validation.validation.statistics import ( + fisher_exact_pvalue, + katz_ci_risk_ratio, + woolf_ci_odds_ratio, +) __all__ = [ "EnrichmentResult", "calculate_enrichment", + "create_matched_pairs_df", "create_matched_pairs_set", + "fisher_exact_pvalue", "get_expanded_disease_set", "katz_ci_risk_ratio", "woolf_ci_odds_ratio", diff --git a/src/ct_validation/validation/enrichment.py b/src/ct_validation/validation/enrichment.py index 523a663..98aafc4 100644 --- a/src/ct_validation/validation/enrichment.py +++ b/src/ct_validation/validation/enrichment.py @@ -2,10 +2,15 @@ from dataclasses import dataclass +import numpy as np import pandas as pd from ct_validation.config.schema import phase_label -from ct_validation.validation.statistics import katz_ci_risk_ratio, woolf_ci_odds_ratio +from ct_validation.validation.statistics import ( + fisher_exact_pvalue, + katz_ci_risk_ratio, + woolf_ci_odds_ratio, +) @dataclass @@ -31,6 +36,8 @@ class EnrichmentResult: or_: float or_ci_lower: float or_ci_upper: float + # Fisher's exact test (two-sided) + p_value: float def to_dict(self) -> dict: """Convert to dictionary for DataFrame creation.""" @@ -50,6 +57,7 @@ def to_dict(self) -> dict: "or": self.or_, "or_ci_lower": self.or_ci_lower, "or_ci_upper": self.or_ci_upper, + "p_value": self.p_value, } @@ -103,11 +111,15 @@ def calculate_enrichment( rate_yes = x_yes / n_yes if n_yes > 0 else 0.0 rate_no = x_no / n_no if n_no > 0 else 0.0 - # Calculate risk ratio with CI - rr, rr_lower, rr_upper = katz_ci_risk_ratio(x_yes, n_yes, x_no, n_no) - - # Calculate odds ratio with CI - or_, or_lower, or_upper = woolf_ci_odds_ratio(x_yes, n_yes, x_no, n_no) + # RR/OR require both comparison groups; continuity correction is not meaningful here + if n_yes == 0 or n_no == 0: + rr = rr_lower = rr_upper = np.nan + or_ = or_lower = or_upper = np.nan + p_value = np.nan + else: + rr, rr_lower, rr_upper = katz_ci_risk_ratio(x_yes, n_yes, x_no, n_no) + or_, or_lower, or_upper = woolf_ci_odds_ratio(x_yes, n_yes, x_no, n_no) + p_value = fisher_exact_pvalue(x_yes, n_yes, x_no, n_no) # Generate label label = phase_label(phase_from, phase_to) @@ -128,6 +140,7 @@ def calculate_enrichment( or_=or_, or_ci_lower=or_lower, or_ci_upper=or_upper, + p_value=p_value, ) diff --git a/src/ct_validation/validation/matching.py b/src/ct_validation/validation/matching.py index 923cb4d..5dabc99 100644 --- a/src/ct_validation/validation/matching.py +++ b/src/ct_validation/validation/matching.py @@ -40,12 +40,17 @@ def get_expanded_disease_set( _setup_source(conn, "sim", similarity_pairs) conn.register("input_diseases", pd.DataFrame({"efo_id": list(direct_set)})) - # sql + # sql — search both directions (lookup tables may store pairs once) query = """ - SELECT DISTINCT sim.efo_id_2 + SELECT DISTINCT sim.efo_id_2 AS efo_id FROM input_diseases d INNER JOIN sim ON d.efo_id = sim.efo_id_1 AND sim.similarity >= $threshold WHERE sim.efo_id_2 IS NOT NULL + UNION + SELECT DISTINCT sim.efo_id_1 AS efo_id + FROM input_diseases d + INNER JOIN sim ON d.efo_id = sim.efo_id_2 AND sim.similarity >= $threshold + WHERE sim.efo_id_1 IS NOT NULL """ expanded = conn.execute(query, {"threshold": similarity_threshold}).fetchall() conn.close() @@ -83,7 +88,7 @@ def create_matched_pairs_set( if similarity_pairs is not None: _setup_source(conn, "sim", similarity_pairs) - # sql + # sql — match GE/CT on either orientation of the similarity pair query = f""" SELECT DISTINCT ct.gene, ct.efo_id FROM ct @@ -92,6 +97,14 @@ def create_matched_pairs_set( INNER JOIN ge ON ct.gene = ge.gene AND sim.efo_id_1 = ge.efo_id WHERE ct.gene IS NOT NULL AND ct.efo_id IS NOT NULL AND ge.gene IS NOT NULL AND ge.efo_id IS NOT NULL + UNION + SELECT DISTINCT ct.gene, ct.efo_id + FROM ct + {gu_join} + INNER JOIN sim ON ct.efo_id = sim.efo_id_1 AND sim.similarity >= $threshold + INNER JOIN ge ON ct.gene = ge.gene AND sim.efo_id_2 = ge.efo_id + WHERE ct.gene IS NOT NULL AND ct.efo_id IS NOT NULL + AND ge.gene IS NOT NULL AND ge.efo_id IS NOT NULL """ result = conn.execute(query, {"threshold": similarity_threshold}).fetchall() else: @@ -110,3 +123,82 @@ def create_matched_pairs_set( conn.close() return set(result) + + +def create_matched_pairs_df( + genetic_evidence: pd.DataFrame | str | Path, + clinical_trials: pd.DataFrame | str | Path, + similarity_pairs: pd.DataFrame | str | Path | None, + similarity_threshold: float, + gene_universe: set[str] | None = None, + *, + con: duckdb.DuckDBPyConnection | None = None, +) -> pd.DataFrame: + """ + Return detailed target matches for audit and debugging. + + Columns: gene, ct_efo_id, ge_efo_id, similarity, match_type + (match_type is ``exact`` when ct_efo_id equals ge_efo_id, else ``similarity``). + """ + owned = con is None + conn = con or duckdb.connect() + + _setup_source(conn, "ge", genetic_evidence) + _setup_source(conn, "ct", clinical_trials) + + if gene_universe: + conn.register("gu", pd.DataFrame({"gene": list(gene_universe)})) + gu_join = "INNER JOIN gu ON ct.gene = gu.gene" + else: + gu_join = "" + + if similarity_pairs is not None: + _setup_source(conn, "sim", similarity_pairs) + query = f""" + SELECT DISTINCT + ct.gene, + ct.efo_id AS ct_efo_id, + ge.efo_id AS ge_efo_id, + sim.similarity, + CASE WHEN ct.efo_id = ge.efo_id THEN 'exact' ELSE 'similarity' END AS match_type + FROM ct + {gu_join} + INNER JOIN sim ON ct.efo_id = sim.efo_id_2 AND sim.similarity >= $threshold + INNER JOIN ge ON ct.gene = ge.gene AND sim.efo_id_1 = ge.efo_id + WHERE ct.gene IS NOT NULL AND ct.efo_id IS NOT NULL + AND ge.gene IS NOT NULL AND ge.efo_id IS NOT NULL + UNION + SELECT DISTINCT + ct.gene, + ct.efo_id AS ct_efo_id, + ge.efo_id AS ge_efo_id, + sim.similarity, + CASE WHEN ct.efo_id = ge.efo_id THEN 'exact' ELSE 'similarity' END AS match_type + FROM ct + {gu_join} + INNER JOIN sim ON ct.efo_id = sim.efo_id_1 AND sim.similarity >= $threshold + INNER JOIN ge ON ct.gene = ge.gene AND sim.efo_id_2 = ge.efo_id + WHERE ct.gene IS NOT NULL AND ct.efo_id IS NOT NULL + AND ge.gene IS NOT NULL AND ge.efo_id IS NOT NULL + """ + df = conn.execute(query, {"threshold": similarity_threshold}).df() + else: + query = f""" + SELECT DISTINCT + ct.gene, + ct.efo_id AS ct_efo_id, + ge.efo_id AS ge_efo_id, + 1.0 AS similarity, + 'exact' AS match_type + FROM ct + {gu_join} + INNER JOIN ge ON ct.gene = ge.gene AND ct.efo_id = ge.efo_id + WHERE ct.gene IS NOT NULL AND ct.efo_id IS NOT NULL + AND ge.gene IS NOT NULL AND ge.efo_id IS NOT NULL + """ + df = conn.execute(query).df() + + if owned: + conn.close() + + return df.sort_values(["gene", "ct_efo_id", "ge_efo_id"]).reset_index(drop=True) diff --git a/src/ct_validation/validation/statistics.py b/src/ct_validation/validation/statistics.py index 097e334..a2a9447 100644 --- a/src/ct_validation/validation/statistics.py +++ b/src/ct_validation/validation/statistics.py @@ -4,6 +4,40 @@ from scipy import stats +def fisher_exact_pvalue( + a: int, + n1: int, + c: int, + n2: int, +) -> float: + """ + Two-sided Fisher's exact test p-value for a 2x2 contingency table. + + Parameters + ---------- + a : int + Successes in group 1 (with genetic evidence) + n1 : int + Total in group 1 + c : int + Successes in group 2 (without genetic evidence) + n2 : int + Total in group 2 + + Returns + ------- + float + Two-sided p-value, or NaN when either group is empty + """ + if n1 == 0 or n2 == 0: + return np.nan + + b = n1 - a + d = n2 - c + _, p_value = stats.fisher_exact([[a, b], [c, d]], alternative="two-sided") + return float(p_value) + + def katz_ci_risk_ratio( a: int, n1: int, diff --git a/tests/test_api.py b/tests/test_api.py index 5f924ad..c4035dd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -102,6 +102,41 @@ def test_validate_return_trials(clinical_trials_df, genetic_evidence_df, similar assert "has_genetic_evidence" in trials.columns +def test_validate_return_matched_pairs(clinical_trials_df, genetic_evidence_df, similarity_lookup_df): + """return_matched_pairs=True returns match audit DataFrame.""" + enrichment, matched = validate( + clinical_trials=clinical_trials_df, + targets=genetic_evidence_df, + similarity_lookup=similarity_lookup_df, + similarity_threshold=0.8, + return_matched_pairs=True, + ) + + assert isinstance(enrichment, pd.DataFrame) + assert isinstance(matched, pd.DataFrame) + assert "p_value" in enrichment.columns + assert {"gene", "ct_efo_id", "ge_efo_id", "similarity", "match_type"}.issubset(matched.columns) + assert len(matched) > 0 + + +def test_validate_return_trials_and_matched_pairs( + clinical_trials_df, genetic_evidence_df, similarity_lookup_df +): + """Both optional outputs are returned in order.""" + enrichment, trials, matched = validate( + clinical_trials=clinical_trials_df, + targets=genetic_evidence_df, + similarity_lookup=similarity_lookup_df, + similarity_threshold=0.8, + return_trials=True, + return_matched_pairs=True, + ) + + assert isinstance(enrichment, pd.DataFrame) + assert isinstance(trials, pd.DataFrame) + assert isinstance(matched, pd.DataFrame) + + def test_validate_missing_required_raises(): """Missing required args raises ValueError.""" with pytest.raises(ValueError, match="required"): diff --git a/tests/test_cli.py b/tests/test_cli.py index f71e580..40ca3bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -108,6 +108,26 @@ def test_cli_save_trials(runner, tmp_config_yaml, tmp_path): assert (output_dir / "annotated_trials.parquet").exists() +def test_cli_save_matched_pairs(runner, tmp_config_yaml, tmp_path): + """--save-matched-pairs flag produces matched_pairs file.""" + output_dir = tmp_path / "cli_output" + + result = runner.invoke( + cli, + [ + "--config", + str(tmp_config_yaml), + "--output-dir", + str(output_dir), + "--save-matched-pairs", + ], + ) + + assert result.exit_code == 0 + assert (output_dir / "enrichment_results.parquet").exists() + assert (output_dir / "matched_pairs.parquet").exists() + + def test_cli_prints_summary(runner, tmp_config_yaml, tmp_path): """CLI prints enrichment summary to stdout.""" result = runner.invoke( @@ -123,6 +143,7 @@ def test_cli_prints_summary(runner, tmp_config_yaml, tmp_path): assert result.exit_code == 0 assert "Enrichment Results" in result.output assert "Phase" in result.output + assert "p-value" in result.output def test_cli_phase_transitions(runner, tmp_config_yaml, tmp_path): diff --git a/tests/test_config.py b/tests/test_config.py index cbc1040..d60da2e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -44,6 +44,7 @@ def test_output_defaults(): assert output.dir is None assert output.save_trials is False + assert output.save_matched_pairs is False @pytest.mark.parametrize( diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py index 1e554c2..75acba4 100644 --- a/tests/test_enrichment.py +++ b/tests/test_enrichment.py @@ -66,6 +66,50 @@ def test_no_matching_rows_returns_zero_counts(): assert result.n_no == 0 +def test_empty_comparison_group_returns_nan_effect_sizes(): + """RR/OR are undefined when either comparison group is empty.""" + all_with_ge = pd.DataFrame( + { + "max_phase": [2, 2, 2], + "has_genetic_evidence": [True, True, True], + } + ) + all_without_ge = pd.DataFrame( + { + "max_phase": [2, 2, 2], + "has_genetic_evidence": [False, False, False], + } + ) + + with_ge_only = calculate_enrichment(all_with_ge, phase_from=1, phase_to=3) + without_ge_only = calculate_enrichment(all_without_ge, phase_from=1, phase_to=3) + no_eligible = calculate_enrichment(all_with_ge, phase_from=3, phase_to=4) + + assert pd.isna(with_ge_only.rr) + assert pd.isna(with_ge_only.or_) + assert pd.isna(with_ge_only.p_value) + assert pd.isna(without_ge_only.rr) + assert pd.isna(without_ge_only.or_) + assert pd.isna(without_ge_only.p_value) + assert pd.isna(no_eligible.rr) + assert pd.isna(no_eligible.or_) + assert pd.isna(no_eligible.p_value) + + +def test_enrichment_includes_p_value(enrichment_input_df): + """Enrichment results include a Fisher's exact test p-value.""" + result = calculate_enrichment(enrichment_input_df, phase_from=1, phase_to=2) + + assert 0.0 <= result.p_value <= 1.0 + + +def test_all_enrichments_includes_p_value_column(enrichment_input_df): + """Batch enrichment output includes p_value column.""" + result = calculate_all_enrichments(enrichment_input_df, [(1, 2)]) + + assert "p_value" in result.columns + + def test_result_to_dict(): """EnrichmentResult.to_dict() returns expected keys.""" result = EnrichmentResult( @@ -84,6 +128,7 @@ def test_result_to_dict(): or_=1.0, or_ci_lower=0.7, or_ci_upper=1.3, + p_value=0.42, ) d = result.to_dict() @@ -93,6 +138,7 @@ def test_result_to_dict(): assert d["rr"] == 1.0 assert d["or"] == 1.0 assert d["or_ci_lower"] == 0.7 + assert d["p_value"] == 0.42 def test_rates_between_zero_and_one(enrichment_input_df): diff --git a/tests/test_matching.py b/tests/test_matching.py index 3677310..fefed64 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -1,7 +1,12 @@ """Tests for similarity matching.""" import pandas as pd -from ct_validation.validation.matching import create_matched_pairs_set, get_expanded_disease_set +import pytest +from ct_validation.validation.matching import ( + create_matched_pairs_df, + create_matched_pairs_set, + get_expanded_disease_set, +) def test_exact_match_found(): @@ -26,6 +31,17 @@ def test_similar_match_found(): assert ("GENE1", "EFO:002") in result +def test_similar_match_found_reverse_pair(): + """Similarity pairs stored in reverse orientation still match.""" + ct = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:002"], "max_phase": [2]}) + ge = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:001"]}) + sim = pd.DataFrame({"efo_id_1": ["EFO:002"], "efo_id_2": ["EFO:001"], "similarity": [0.9]}) + + result = create_matched_pairs_set(ge, ct, sim, similarity_threshold=0.8) + + assert ("GENE1", "EFO:002") in result + + def test_below_threshold_excluded(): """Pairs below similarity threshold are not matched.""" ct = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:002"], "max_phase": [2]}) @@ -124,6 +140,21 @@ def test_expand_diseases_with_similarity(): assert result == {"EFO:001", "EFO:002", "EFO:003"} +def test_expand_diseases_reverse_pair(): + """Expansion works when the input disease appears as efo_id_2.""" + sim = pd.DataFrame( + { + "efo_id_1": ["EFO:002"], + "efo_id_2": ["EFO:001"], + "similarity": [0.9], + } + ) + + result = get_expanded_disease_set(["EFO:001"], sim, similarity_threshold=0.8) + + assert result == {"EFO:001", "EFO:002"} + + def test_expand_diseases_excludes_below_threshold(): """Diseases below threshold are excluded.""" sim = pd.DataFrame( @@ -259,3 +290,44 @@ def test_none_similarity_gene_universe_filter(): assert ("GENE1", "EFO:001") in result assert ("GENE2", "EFO:001") not in result + + +# Tests for create_matched_pairs_df + + +def test_matched_pairs_df_exact_match_columns(): + """Exact matching returns expected audit columns.""" + ct = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:001"], "max_phase": [2]}) + ge = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:001"]}) + + result = create_matched_pairs_df(ge, ct, similarity_pairs=None, similarity_threshold=0.8) + + assert list(result.columns) == ["gene", "ct_efo_id", "ge_efo_id", "similarity", "match_type"] + assert result.iloc[0]["match_type"] == "exact" + assert result.iloc[0]["similarity"] == pytest.approx(1.0) + + +def test_matched_pairs_df_similarity_match(): + """Similarity matching records the supporting GE disease ID.""" + ct = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:002"], "max_phase": [2]}) + ge = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:001"]}) + sim = pd.DataFrame({"efo_id_1": ["EFO:001"], "efo_id_2": ["EFO:002"], "similarity": [0.9]}) + + result = create_matched_pairs_df(ge, ct, sim, similarity_threshold=0.8) + + assert len(result) == 1 + assert result.iloc[0]["ct_efo_id"] == "EFO:002" + assert result.iloc[0]["ge_efo_id"] == "EFO:001" + assert result.iloc[0]["match_type"] == "similarity" + + +def test_matched_pairs_df_reverse_pair(): + """Reverse-oriented similarity rows are still exported.""" + ct = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:002"], "max_phase": [2]}) + ge = pd.DataFrame({"gene": ["GENE1"], "efo_id": ["EFO:001"]}) + sim = pd.DataFrame({"efo_id_1": ["EFO:002"], "efo_id_2": ["EFO:001"], "similarity": [0.9]}) + + result = create_matched_pairs_df(ge, ct, sim, similarity_threshold=0.8) + + assert len(result) == 1 + assert result.iloc[0]["ge_efo_id"] == "EFO:001" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 342dc9a..80c78bb 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -131,6 +131,7 @@ def test_ct_validate_inline_has_expected_keys(): "rr", "rr_ci_lower", "rr_ci_upper", + "p_value", } assert expected_keys.issubset(result[0].keys()) diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 42c78d6..f93dc33 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -3,7 +3,29 @@ import math import pytest -from ct_validation.validation.statistics import katz_ci_risk_ratio, woolf_ci_odds_ratio +from ct_validation.validation.statistics import ( + fisher_exact_pvalue, + katz_ci_risk_ratio, + woolf_ci_odds_ratio, +) + + +def test_fisher_exact_equal_rates(): + """p-value is high when both groups have the same success rate.""" + p_value = fisher_exact_pvalue(50, 100, 50, 100) + assert p_value == pytest.approx(1.0, abs=1e-9) + + +def test_fisher_exact_enrichment_signal(): + """p-value is low when group 1 has higher success rate.""" + p_value = fisher_exact_pvalue(80, 100, 40, 100) + assert p_value < 0.05 + + +def test_fisher_exact_empty_group_returns_nan(): + """p-value is undefined when either group is empty.""" + assert math.isnan(fisher_exact_pvalue(0, 0, 50, 100)) + assert math.isnan(fisher_exact_pvalue(10, 10, 0, 0)) def test_rr_equal_rates(): From 133adec3856d513115971b869c316fd01484a9fd Mon Sep 17 00:00:00 2001 From: Amir Feizi Date: Mon, 1 Jun 2026 20:50:07 +0000 Subject: [PATCH 2/4] Document p-values, matched-pairs export, and CI in README --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50678dd..c586d13 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,31 @@ results = ctv.validate( similarity_lookup="data/mappings/efo_similarity_lookup_0.5.parquet", ) print(results) -# phase_label n_yes n_no rr rr_ci_lower rr_ci_upper ... +# phase_label n_yes n_no rr rr_ci_lower rr_ci_upper p_value ... +``` + +Export annotated trials or matched-pair audit rows: + +```python +enrichment, trials = ctv.validate(..., return_trials=True) +enrichment, matched = ctv.validate(..., return_matched_pairs=True) +enrichment, trials, matched = ctv.validate( + ..., + return_trials=True, + return_matched_pairs=True, +) +``` + +Inspect how targets matched to clinical trials: + +```python +matched = ctv.create_matched_pairs_df( + genetic_evidence="data/genetic_evidence/genetic_evidence.parquet", + clinical_trials="data/clinical_trials/gene_indication_max_phase.parquet", + similarity_pairs="data/mappings/efo_similarity_lookup_0.5.parquet", + similarity_threshold=0.8, +) +# columns: gene, ct_efo_id, ge_efo_id, similarity, match_type ``` Batch mode — compare multiple evidence sources at once: @@ -95,6 +119,10 @@ ct-validation \ --clinical-trials ct.parquet \ --targets gwas.parquet --targets clinvar.parquet --targets omim.parquet \ -o results/ + +# Save annotated trials and/or matched-pair audit rows +ct-validation --config configs/default.yaml \ + --save-trials --save-matched-pairs -o results/ ``` ### MCP server @@ -105,7 +133,7 @@ ct-validation-mcp Exposes two tools for agent-based workflows: -- `ct_validate` — compute phase-transition enrichment +- `ct_validate` — compute phase-transition enrichment (includes `p_value`) - `expand_disease_set` — expand EFO IDs via semantic similarity ## Input schemas @@ -130,6 +158,21 @@ All inputs accept Parquet files or pandas DataFrames (except `gene_universe`, wh | `rate_yes`, `rate_no` | Progression rates | | `rr`, `rr_ci_lower`, `rr_ci_upper` | Risk ratio with 95% CI (Katz log method) | | `or`, `or_ci_lower`, `or_ci_upper` | Odds ratio with 95% CI (Woolf logit method) | +| `p_value` | Two-sided Fisher's exact test p-value | + +When either comparison group is empty (`n_yes=0` or `n_no=0`), `rr`, `or`, their confidence intervals, and `p_value` are undefined (`NaN`). + +### Matched-pairs export (optional) + +When `return_matched_pairs=True` (API) or `--save-matched-pairs` (CLI) is set, a separate audit table is returned/saved with: + +| Column | Description | +| ------------ | -------------------------------------------------------- | +| `gene` | Gene symbol | +| `ct_efo_id` | Disease on the clinical trial row | +| `ge_efo_id` | Supporting genetic-evidence disease | +| `similarity` | Match score (`1.0` for exact matches) | +| `match_type` | `exact` when `ct_efo_id == ge_efo_id`, else `similarity` | ## Enrichment logic @@ -139,7 +182,7 @@ For each phase transition, target-indication pairs that reached at least the sta RR = (x_yes / n_yes) / (x_no / n_no) ``` -A risk ratio greater than one indicates that genetically supported pairs are more likely to progress. When a similarity lookup is provided, a pair (gene, disease) is considered supported if there exists evidence (gene, disease') with similarity above the threshold (default 0.8). +A risk ratio greater than one indicates that genetically supported pairs are more likely to progress. When a similarity lookup is provided, a pair (gene, disease) is considered supported if there exists evidence (gene, disease') with similarity above the threshold (default 0.8). Similarity pairs may be stored in either orientation; matching searches both directions. ### Prioritized mode @@ -190,6 +233,25 @@ python scripts/parse/run_parsing.py See `configs/default.yaml` for validation settings and `configs/parsing.yaml` for data source paths. All config values can be overridden via CLI arguments. +Output options in `configs/default.yaml`: + +```yaml +output: + dir: "results/" + save_trials: false + save_matched_pairs: false +``` + +## Development + +CI runs on push/PR to `main` via GitHub Actions (Python 3.11–3.13, `ruff check`, `pytest`). + +```bash +pip install -e ".[dev,mcp]" +ruff check src tests +pytest tests/ -v +``` + ## License MIT From de1d118a35afae74cc938e93859d093c36cfad8d Mon Sep 17 00:00:00 2001 From: malbiruk <2601074@gmail.com> Date: Thu, 4 Jun 2026 13:27:22 +0400 Subject: [PATCH 3/4] Switch CI to uv with locked sync; refresh uv.lock --- .github/workflows/ci.yml | 14 ++++--- uv.lock | 90 ---------------------------------------- 2 files changed, 8 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ba9dc..989a0b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,20 +13,22 @@ jobs: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v6 with: - python-version: ${{ matrix.python-version }} + enable-cache: true - name: Install dependencies - run: pip install -e ".[dev,mcp]" + run: uv sync --locked --extra mcp - name: Lint - run: ruff check src tests + run: uv run ruff check src tests - name: Test - run: pytest tests/ -v + run: uv run pytest tests/ -v diff --git a/uv.lock b/uv.lock index bf899e1..63bc78b 100644 --- a/uv.lock +++ b/uv.lock @@ -325,19 +325,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/1f/94e1b020fd87b52f092fdd111d9800f2411dc113a0957d0d85b7d2c7f247/botocore-1.42.87-py3-none-any.whl", hash = "sha256:32832df27c039cc1a518289afe0f6006296d3df26603b5f66e911457e67f0146", size = 14871982, upload-time = "2026-04-09T19:39:32.847Z" }, ] -[[package]] -name = "cattrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, -] - [[package]] name = "certifi" version = "2026.1.4" @@ -490,21 +477,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "chembl-webresource-client" -version = "0.10.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "easydict" }, - { name = "requests" }, - { name = "requests-cache" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/f0/a392e80361645d0dea1a228ff131b43a69209715b0b583832d9bd2bdd4ac/chembl-webresource-client-0.10.9.tar.gz", hash = "sha256:d49c7db7d40d19289f0f950557d0cf133fddef56c78a5873ed922a3d5816cc20", size = 46390, upload-time = "2024-02-26T15:55:41.137Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/7a/81dd5eb6d6166f733c02aeff6eff207ee3cd077872e6a20da925044c3d59/chembl_webresource_client-0.10.9-py3-none-any.whl", hash = "sha256:99101cad1ad178c5a0d7d74d70923c7997e9e63ec0361c247c6252dc29e524b5", size = 55201, upload-time = "2024-02-26T15:55:39.426Z" }, -] - [[package]] name = "click" version = "8.3.1" @@ -796,10 +768,7 @@ dependencies = [ [package.optional-dependencies] fetch = [ - { name = "chembl-webresource-client" }, { name = "huggingface-hub" }, - { name = "joblib" }, - { name = "tqdm" }, ] genebass = [ { name = "hail" }, @@ -826,12 +795,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "chembl-webresource-client", marker = "extra == 'fetch'" }, { name = "cyvcf2", marker = "extra == 'parse'" }, { name = "duckdb", specifier = ">=1.4.3" }, { name = "hail", marker = "extra == 'genebass'" }, { name = "huggingface-hub", marker = "extra == 'fetch'" }, - { name = "joblib", marker = "extra == 'fetch'" }, { name = "matplotlib", marker = "extra == 'plot'" }, { name = "mcp", extras = ["cli"], marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "numpy", specifier = ">=2.3.3" }, @@ -841,7 +808,6 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", marker = "extra == 'parse'" }, { name = "scipy", specifier = ">=1.16.2" }, - { name = "tqdm", marker = "extra == 'fetch'" }, { name = "tqdm", marker = "extra == 'parse'" }, { name = "typer", specifier = ">=0.24.0" }, ] @@ -966,15 +932,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/2d/13e6024e613679d8a489dd922f199ef4b1d08a456a58eadd96dc2f05171f/duckdb-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:53cd6423136ab44383ec9955aefe7599b3fb3dd1fe006161e6396d8167e0e0d4", size = 13458633, upload-time = "2026-01-26T11:50:17.657Z" }, ] -[[package]] -name = "easydict" -version = "1.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/9f/d18d6b5e19244788a6d09c14a8406376b4f4bfcc008e6d17a4f4c15362e8/easydict-1.13.tar.gz", hash = "sha256:b1135dedbc41c8010e2bc1f77ec9744c7faa42bce1a1c87416791449d6c87780", size = 6809, upload-time = "2024-03-04T12:04:41.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/ec/fa6963f1198172c2b75c9ab6ecefb3045991f92f75f5eb41b6621b198123/easydict-1.13-py3-none-any.whl", hash = "sha256:6b787daf4dcaf6377b4ad9403a5cee5a86adbc0ca9a5bcf5410e9902002aeac2", size = 6804, upload-time = "2024-03-04T12:04:39.508Z" }, -] - [[package]] name = "filelock" version = "3.25.2" @@ -1396,15 +1353,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - [[package]] name = "jproperties" version = "2.1.2" @@ -2205,15 +2153,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - [[package]] name = "plotly" version = "5.24.1" @@ -2984,23 +2923,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "requests-cache" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cattrs" }, - { name = "platformdirs" }, - { name = "requests" }, - { name = "url-normalize" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209, upload-time = "2024-06-18T17:18:03.774Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425, upload-time = "2024-06-18T17:17:45Z" }, -] - [[package]] name = "requests-oauthlib" version = "2.0.0" @@ -3443,18 +3365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] -[[package]] -name = "url-normalize" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/31/febb777441e5fcdaacb4522316bf2a527c44551430a4873b052d545e3279/url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37", size = 18846, upload-time = "2025-04-26T20:37:58.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/d9/5ec15501b675f7bc07c5d16aa70d8d778b12375686b6efd47656efdc67cd/url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b", size = 14728, upload-time = "2025-04-26T20:37:57.217Z" }, -] - [[package]] name = "urllib3" version = "2.6.3" From 1b9a5556cbcd39c9985dec4eb359efbdf09c14de Mon Sep 17 00:00:00 2001 From: malbiruk <2601074@gmail.com> Date: Thu, 4 Jun 2026 13:47:41 +0400 Subject: [PATCH 4/4] Trim README export examples and switch dev setup to uv --- README.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c586d13..6d896ff 100644 --- a/README.md +++ b/README.md @@ -40,28 +40,21 @@ print(results) # phase_label n_yes n_no rr rr_ci_lower rr_ci_upper p_value ... ``` -Export annotated trials or matched-pair audit rows: +Export annotated trials and/or matched-pair audit rows (returned in order): ```python enrichment, trials = ctv.validate(..., return_trials=True) enrichment, matched = ctv.validate(..., return_matched_pairs=True) -enrichment, trials, matched = ctv.validate( - ..., - return_trials=True, - return_matched_pairs=True, -) +enrichment, trials, matched = ctv.validate(..., return_trials=True, return_matched_pairs=True) ``` -Inspect how targets matched to clinical trials: +Or build the match-audit table directly (columns: `gene`, `ct_efo_id`, `ge_efo_id`, `similarity`, `match_type`): ```python matched = ctv.create_matched_pairs_df( - genetic_evidence="data/genetic_evidence/genetic_evidence.parquet", - clinical_trials="data/clinical_trials/gene_indication_max_phase.parquet", - similarity_pairs="data/mappings/efo_similarity_lookup_0.5.parquet", + genetic_evidence=..., clinical_trials=..., similarity_pairs=..., similarity_threshold=0.8, ) -# columns: gene, ct_efo_id, ge_efo_id, similarity, match_type ``` Batch mode — compare multiple evidence sources at once: @@ -244,12 +237,12 @@ output: ## Development -CI runs on push/PR to `main` via GitHub Actions (Python 3.11–3.13, `ruff check`, `pytest`). +CI runs on push/PR to `main` (Python 3.11–3.13, `ruff`, `pytest`): ```bash -pip install -e ".[dev,mcp]" -ruff check src tests -pytest tests/ -v +uv sync --extra mcp +uv run ruff check src tests +uv run pytest tests/ -v ``` ## License