bgc-setnet / source /src /bgc_retrieval /campaign_analysis.py
whiteh4t's picture
Release final BGC retrieval checkpoints and model card
c87881a verified
Raw
History Blame Contribute Delete
12.4 kB
"""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