"""Reproducible, explicitly post-hoc analysis of completed campaign artifacts.""" from __future__ import annotations import json from pathlib import Path from typing import Any import numpy as np import pandas as pd from scipy import stats from .artifacts import sha256_file, write_json_immutable from .statistics import holm_adjust, paired_family_test INTERNAL_METRICS = ["recall@50", "mrr", "map", "ndcg@50"] def _aligned_edges( first: pd.DataFrame, second: pd.DataFrame ) -> tuple[pd.DataFrame, pd.DataFrame]: required = {"record_a", "record_b", "score", "structural_similarity"} for name, frame in (("first", first), ("second", second)): if missing := required.difference(frame.columns): raise ValueError(f"{name} edge table is missing columns: {sorted(missing)}") keys = ["record_a", "record_b"] left = first.sort_values(keys).reset_index(drop=True) right = second.sort_values(keys).reset_index(drop=True) if not left[keys].equals(right[keys]): raise ValueError("Paired methods must contain identical edges") if not np.allclose(left["structural_similarity"], right["structural_similarity"]): raise ValueError("Paired methods disagree on structural truth") return left, right def paired_correlation_difference_bootstrap( first: pd.DataFrame, second: pd.DataFrame, samples: int = 10000, confidence: float = 0.95, seed: int = 0, ) -> dict[str, float | int]: """Paired two-endpoint BGC cluster bootstrap for a Spearman difference. Ranks are fixed on the complete paired edge set. Each dyad contributes half its sufficient-statistic weight to each endpoint block. Both methods use the same resampled endpoint multiplicities in every replicate. """ if samples < 1: raise ValueError("Bootstrap samples must be positive") if not 0.0 < confidence < 1.0: raise ValueError("Confidence must be between zero and one") left, right = _aligned_edges(first, second) if len(left) < 3: raise ValueError("At least three paired edges are required") score_first = stats.rankdata(left["score"].to_numpy(float), method="average") score_second = stats.rankdata(right["score"].to_numpy(float), method="average") truth = stats.rankdata(left["structural_similarity"].to_numpy(float), method="average") identifiers = pd.Index( sorted(set(left["record_a"].astype(str)) | set(left["record_b"].astype(str))) ) index_left = identifiers.get_indexer(left["record_a"].astype(str)) index_right = identifiers.get_indexer(left["record_b"].astype(str)) def block_sum(values: np.ndarray) -> np.ndarray: return 0.5 * ( np.bincount(index_left, weights=values, minlength=len(identifiers)) + np.bincount(index_right, weights=values, minlength=len(identifiers)) ) weights = 0.5 * ( np.bincount(index_left, minlength=len(identifiers)) + np.bincount(index_right, minlength=len(identifiers)) ) blocks = np.column_stack( [ weights, block_sum(score_first), block_sum(score_second), block_sum(truth), block_sum(score_first**2), block_sum(score_second**2), block_sum(truth**2), block_sum(score_first * truth), block_sum(score_second * truth), ] ) random_state = np.random.default_rng(seed) differences: list[float] = [] for _ in range(samples): selected = random_state.integers(0, len(blocks), size=len(blocks)) weight, sum_a, sum_b, sum_y, sum_aa, sum_bb, sum_yy, sum_ay, sum_by = ( blocks[selected].sum(axis=0) ) variance_y = sum_yy - sum_y * sum_y / weight def correlation(sum_x: float, sum_xx: float, sum_xy: float) -> float: covariance = sum_xy - sum_x * sum_y / weight variance_x = sum_xx - sum_x * sum_x / weight denominator = np.sqrt(max(variance_x, 0.0) * max(variance_y, 0.0)) return covariance / denominator if denominator > 0.0 else float("nan") difference = correlation(sum_a, sum_aa, sum_ay) - correlation( sum_b, sum_bb, sum_by ) if np.isfinite(difference): differences.append(float(difference)) if not differences: raise ValueError("No finite paired bootstrap differences were produced") values = np.asarray(differences) tail = (1.0 - confidence) / 2.0 lower, upper = np.quantile(values, [tail, 1.0 - tail]) first_rho = float(stats.spearmanr(left["score"], left["structural_similarity"]).statistic) second_rho = float(stats.spearmanr(right["score"], right["structural_similarity"]).statistic) sign_probability = 2.0 * min( (np.count_nonzero(values <= 0.0) + 1) / (len(values) + 1), (np.count_nonzero(values >= 0.0) + 1) / (len(values) + 1), ) return { "pairs": len(left), "bgcs": len(identifiers), "spearman_first": first_rho, "spearman_second": second_rho, "delta_spearman": first_rho - second_rho, "ci_lower": float(lower), "ci_upper": float(upper), "bootstrap_two_sided_sign_probability": float(min(sign_probability, 1.0)), "bootstrap_samples": len(values), } def internal_paired_comparisons(main: pd.DataFrame, no_phase1: pd.DataFrame) -> pd.DataFrame: comparisons: list[dict[str, Any]] = [] ensemble_methods = sorted( method for method in main["method"].astype(str).unique() if method.startswith("ensemble_validation_alpha_") ) if len(ensemble_methods) != 1: raise ValueError( "Expected exactly one validation-selected ensemble method; " f"found {ensemble_methods}" ) ensemble_method = ensemble_methods[0] families = [ ("setnet", "raw_esm_mean", "main_vs_raw", main), ("setnet", "pfam_jaccard_max", "main_vs_pfam", main), ( ensemble_method, "pfam_jaccard_max", "ensemble_vs_pfam", main, ), ] phase_ablation = pd.concat( [ main[main["method"] == "setnet"].assign(method="setnet_phase1"), no_phase1[no_phase1["method"] == "setnet"].assign( method="setnet_no_phase1" ), ], ignore_index=True, ) families.append( ("setnet_phase1", "setnet_no_phase1", "phase1_ablation", phase_ablation) ) for method, baseline, family, frame in families: rows = [paired_family_test(frame, method, baseline, metric) for metric in INTERNAL_METRICS] adjusted = holm_adjust(row["p_value"] for row in rows) for row, corrected in zip(rows, adjusted): row.update(family=family, p_value_holm=corrected, analysis_status="post_hoc") comparisons.append(row) return pd.DataFrame(comparisons) def _methods(path: Path) -> dict[str, pd.DataFrame]: frame = pd.read_csv(path) return { str(method): rows.drop(columns="method").reset_index(drop=True) for method, rows in frame.groupby("method", sort=False) } def _training_summary(path: Path, metric: str, maximize: bool) -> dict[str, Any]: with path.open("r", encoding="utf-8") as handle: history = json.load(handle) best = (max if maximize else min)(history, key=lambda row: row[metric]) return {"epochs": len(history), "best": best, "first": history[0], "last": history[-1]} def analyze_campaign( artifact_root: str | Path, campaign_tag: str, output_dir: str | Path, bootstrap_samples: int = 10000, confidence: float = 0.95, seed: int = 20260810, ) -> Path: root = Path(artifact_root) output = Path(output_dir) output.mkdir(parents=True, exist_ok=False) main_internal = root / f"{campaign_tag}-main-evaluation/group_results.csv" no_internal = root / f"{campaign_tag}-no-phase1-evaluation/group_results.csv" main_external = root / f"{campaign_tag}-main-external/external_pair_scores.csv" no_external = root / f"{campaign_tag}-no-phase1-external/external_pair_scores.csv" required = [main_internal, no_internal, main_external, no_external] if missing := [str(path) for path in required if not path.is_file()]: raise FileNotFoundError(f"Campaign artifacts are missing: {missing}") internal = internal_paired_comparisons( pd.read_csv(main_internal), pd.read_csv(no_internal) ) internal.to_csv(output / "internal_paired_comparisons.csv", index=False) main_methods = _methods(main_external) no_methods = _methods(no_external) external_rows: list[dict[str, Any]] = [] comparisons = [ ("main_setnet_vs_raw", main_methods["setnet"], main_methods["raw_esm_mean"]), ( "main_setnet_vs_raw_bigscape_edges", main_methods["setnet_on_bigscape_edges"], main_methods["raw_esm_mean_on_bigscape_edges"], ), ("phase1_ablation", main_methods["setnet"], no_methods["setnet"]), ( "phase1_ablation_bigscape_edges", main_methods["setnet_on_bigscape_edges"], no_methods["setnet_on_bigscape_edges"], ), ( "bigscape_vs_main_setnet", main_methods["bigscape"], main_methods["setnet_on_bigscape_edges"], ), ] for name, first, second in comparisons: for subset_name, subset in ( ("all", first), ("cross_genus", first[first["cross_genus"]]), ): keys = set(zip(subset["record_a"], subset["record_b"])) paired_second = second[ [pair in keys for pair in zip(second["record_a"], second["record_b"])] ] result = paired_correlation_difference_bootstrap( subset, paired_second, samples=bootstrap_samples, confidence=confidence, seed=seed, ) external_rows.append( { "comparison": name, "subset": subset_name, **result, "analysis_status": "post_hoc", } ) pd.DataFrame(external_rows).to_csv( output / "external_paired_comparisons.csv", index=False ) exact_rows: list[dict[str, Any]] = [] for run_name in (f"{campaign_tag}-main-external", f"{campaign_tag}-no-phase1-external"): for method in ("setnet", "raw_esm_mean"): path = root / run_name / f"{method}_exact_product_retrieval.csv" frame = pd.read_csv(path) exact_rows.append( { "run": run_name, "method": method, "references": len(frame), "cross_genus_references": int( (frame["cross_genus_positive_count"] > 0).sum() ), **{metric: float(frame[metric].mean()) for metric in INTERNAL_METRICS}, "precision@50": float(frame["precision@50"].mean()), } ) pd.DataFrame(exact_rows).to_csv(output / "exact_product_summary.csv", index=False) training = { "phase1": _training_summary( root / f"{campaign_tag}-phase1/phase1_history.json", "validation_loss", maximize=False, ), "phase2_main": _training_summary( root / f"{campaign_tag}-main/phase2_history.json", "validation_recall@50", maximize=True, ), "phase2_no_phase1": _training_summary( root / f"{campaign_tag}-no-phase1/phase2_history.json", "validation_recall@50", maximize=True, ), } write_json_immutable(output / "training_summary.json", training) metadata = { "schema_version": 1, "campaign_tag": campaign_tag, "analysis_status": "post_hoc_exploratory", "bootstrap_samples": bootstrap_samples, "confidence": confidence, "seed": seed, "pair_bootstrap": "paired_two_endpoint_bgc_cluster_fixed_ranks", "input_sha256": {str(path.relative_to(root)): sha256_file(path) for path in required}, } write_json_immutable(output / "analysis_metadata.json", metadata) return output