Skip to content

Commit f05ca47

Browse files
cschanhniemOmX
andcommitted
feat(bench): cluster-split within-set leakage test
The 95-AMP benchmark contains 19 near-duplicates in 14 clusters at 0.70 Levenshtein similarity. The headline AUROC of 0.7832 drops to 0.7607 when near-duplicates are removed -- a 2.3-point inflation that passes the <0.05 gate but must be visible and auditable. AUPRC inflation is larger (0.8164 to 0.7569, 6.0 points), indicating near-duplicate AMPs were concentrated in the high-precision region. Flagged as a known limitation. Changes: - run_cluster_split_benchmark() in benchmark/retrospective.py - CLI: validate-scoring --benchmark-type cluster_split - Makefile: make validate-scoring-cluster-split - tests: 12 new tests for cluster-split benchmark - tests: 3 new doc consistency tests - Fix: cli/__main__.py missing (all Makefile -m targets were broken) - docs: METRICS_CURRENT, BENCHMARKING, ROADMAP updated Co-authored-by: OmX <noreply@omx.local>
1 parent 60011e6 commit f05ca47

10 files changed

Lines changed: 483 additions & 14 deletions

File tree

‎Makefile‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help demo test lint ci clean bench-leakage bench-baseline bench-hidden-active generate phase3 pilot validate-scoring validate-scoring-phase3 validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity synthesis-order novelty-broad external-consensus questionnaire gate-check ip-report benchmark-card wave0-5-gate-check wave0-5-novelty-audit wave0-5-novelty-audit-v2 wave0-5-panel wave0-5-evidence wave0-5-fill-external wave0-5b-generate wave0-5b-filter
1+
.PHONY: help demo test lint ci clean bench-leakage bench-baseline bench-hidden-active generate phase3 pilot validate-scoring validate-scoring-phase3 validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity synthesis-order novelty-broad external-consensus questionnaire gate-check ip-report benchmark-card wave0-5-gate-check wave0-5-novelty-audit wave0-5-novelty-audit-v2 wave0-5-panel wave0-5-evidence wave0-5-fill-external wave0-5b-generate wave0-5b-filter validate-scoring-cluster-split
22

33
PYTHON := $(shell [ -f .venv/bin/python ] && echo .venv/bin/python || echo python3)
44
PYTEST := $(shell [ -f .venv/bin/pytest ] && echo .venv/bin/pytest || echo pytest)
@@ -26,6 +26,7 @@ help:
2626
@echo " make validate-scoring AUROC on 95 AMP + 96 decoy (pipeline config)"
2727
@echo " make validate-scoring-phase3 AUROC with phase3.yaml config"
2828
@echo " make validate-scoring-strict AUROC with scrambled-decoy strict benchmark"
29+
@echo " make validate-scoring-cluster-split Within-set leakage test (dedup AMPs before AUROC)"
2930
@echo " make bench-leakage Check for near-duplicates between candidates and refs"
3031
@echo " make bench-baseline Hidden-positive recovery benchmark (demo set)"
3132
@echo " make bench-hidden-active Hidden-positive recovery on mixed benchmark set"
@@ -213,6 +214,11 @@ novelty-broad: pilot
213214
--references-csv examples/known_reference/amp_curated_references.csv \
214215
--out outputs/novelty_broad_report.md
215216

217+
validate-scoring-cluster-split:
218+
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli validate-scoring \
219+
--benchmark-type cluster_split \
220+
--out outputs/cluster_split_report.json
221+
216222
clean:
217223
rm -rf outputs/*.jsonl outputs/*.md outputs/*.json outputs/evidence outputs/phase3_evidence .pytest_cache .ruff_cache
218224

‎docs/BENCHMARKING.md‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,48 @@ Common artifacts:
1515

1616
## Required benchmarks
1717

18-
| Benchmark | Purpose |
19-
|---|---|
20-
| Random baseline | Prove the pipeline is not noise |
21-
| Simple physicochemical baseline | Prove models beat charge/hydrophobicity alone |
22-
| Cluster split | Prevent near-duplicate leakage |
23-
| Time split | Test future generalization where metadata allows |
24-
| Hidden-positive recovery | Check whether known actives are ranked high |
25-
| Toxicity down-ranking | Ensure predicted risky candidates are penalized |
26-
| Novelty stress test | Avoid near-clones of references |
18+
| Benchmark | Purpose | Status |
19+
|---|---|---|
20+
| Random baseline | Prove the pipeline is not noise | ✅ Implemented |
21+
| Simple physicochemical baseline | Prove models beat charge/hydrophobicity alone | ✅ Implemented |
22+
| Cluster split | Prevent near-duplicate leakage | ✅ Implemented (`validate-scoring --benchmark-type cluster_split`) |
23+
| Time split | Test future generalization where metadata allows | Deferred (metadata not available) |
24+
| Hidden-positive recovery | Check whether known actives are ranked high | ✅ Implemented |
25+
| Toxicity down-ranking | Ensure predicted risky candidates are penalized | ✅ Implemented |
26+
| Novelty stress test | Avoid near-clones of references | ✅ Implemented |
27+
28+
## Cluster-split benchmark
29+
30+
The cluster-split benchmark is the within-set leakage test. It runs automatically
31+
as part of `make validate-scoring-cluster-split`.
32+
33+
**What it does:**
34+
35+
1. Clusters all 95 benchmark AMPs at 0.70 Levenshtein similarity.
36+
2. Removes near-duplicates, keeping one representative per cluster (76 AMPs remain).
37+
3. Reruns the standard AUROC/AUPRC benchmark on the deduplicated set.
38+
4. Reports both standard and cluster-split metrics side-by-side.
39+
40+
**Current result (2026-07-01):**
41+
42+
| Metric | Standard (95 AMPs) | Cluster-split (76 AMPs) | Inflation |
43+
|--------|--------------------|-------------------------|-----------|
44+
| AUROC | 0.7832 | 0.7607 | 0.0225 |
45+
| AUPRC | 0.8164 | 0.7569 | 0.0595 |
46+
47+
**Gate:** AUROC inflation < 0.05 → PASS. The headline AUROC is not materially
48+
inflated by within-set redundancy.
49+
50+
**Interpretation:** The 2.3-point AUROC drop is modest, confirming that the
51+
pipeline's discriminative power does not primarily depend on scoring near-duplicate
52+
AMPs correctly. However, the 6.0-point AUPRC drop is larger, suggesting that
53+
near-duplicate AMPs were disproportionately concentrated in the high-precision
54+
region of the ranking. This is flagged as a known limitation.
55+
56+
**19 near-duplicate AMPs removed (14 clusters at 0.70):**
57+
3 magainins, 3 indolicidins, 3 protegrins, 3 tachyplesins, 2 buforins, 2 aureins,
58+
2 cecropins, 2 dermaseptins, 2 apidaecins, 2 RsAFPs, 2 plectasin/eurocin,
59+
2 piscidins, 2 BMAP/PMAP, 2 KWK template/WKL.
2760

2861
## Minimum report fields
2962

‎docs/METRICS_CURRENT.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Purpose:** One authoritative table of current pipeline metrics. If any doc disagrees
44
> with this file, this file wins. Updated whenever benchmark/benchmark config changes.
55
>
6-
> **Last updated:** 2026-06-29 (PR #110 expanded benchmark)
6+
> **Last updated:** 2026-07-01 (cluster-split leakage test added)
77
> **Pipeline version:** v0.5.x
88
> **Branch:** main
99
@@ -22,6 +22,10 @@
2222
| **Pipeline AUPRC** | **0.8164** | Random baseline: 0.4974 |
2323
| **Phase3 AUPRC** | **0.7933** | Random baseline: 0.4974 |
2424
| Strict AUROC (scrambled) | 0.4335 | 95 shuffled decoys; below random — expected for helic-centric scorer |
25+
| **Cluster-split AUROC** | **0.7607** | 76 deduped AMPs + 96 decoys; CI₉₅: 0.69–0.83 (n_bootstrap=2000) |
26+
| **AUROC inflation (standard → cluster-split)** | **0.0225** | < 0.05 gate: PASS — headline not materially inflated by near-duplicate AMPs |
27+
| **Cluster-split AUPRC** | **0.7569** | Random baseline: 0.4419; AUPRC inflation: 0.0595 |
28+
| Near-duplicate AMPs removed | 19 | 14 multi-member clusters at 0.70 Levenshtein similarity |
2529
| Recall@10 | 0.1053 | 10/95 positives in top 10 |
2630
| Recall@20 | 0.2105 | 20/95 positives in top 20 |
2731
| Recall@43 | 0.4211 | 40/95 positives in top 43 |
@@ -158,3 +162,4 @@ Machine-readable: `outputs/wave0_5_external_predict_results.csv`, `outputs/wave0
158162
| 2026-06-29 | External predictor results filled from wave05_combined_consensus.csv; all 7 gates PASS | OpenAMP Wave 0.5 |
159163
| 2026-06-29 | Wave 0.5 scaffold diversification — 24-candidate Wave 1 panel across 14 families | OpenAMP Wave 0.5 |
160164
| 2026-06-29 | Initial — expanded benchmark (PR #110) | OpenAMP CI |
165+
| 2026-07-01 | Cluster-split leakage test: 19 near-duplicate AMPs removed (76 reps); AUROC 0.7832→0.7607, inflation 0.0225 (< 0.05 gate: PASS) | OpenAMP loop |

‎docs/ROADMAP.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ Implemented during the pre-wet-lab improvement loop (PRs #31–#54):
111111
- Patent risk disclosed: DRAMP patent hits for SEED-010, SEED-019, SEED-012 families
112112
- Novelty audit v2: `scripts/run_wave0_5_novelty_audit_v2.py`; database: `data/novelty_db/`
113113

114+
## v0.5.8 — Cluster-Split Leakage Test ✓ (2026-07-01)
115+
116+
- Within-set leakage test implemented: `run_cluster_split_benchmark()` in `benchmark/retrospective.py`
117+
- 95 benchmark AMPs clustered at 0.70 Levenshtein similarity → 76 non-redundant representatives
118+
- 19 near-duplicate AMPs removed (14 multi-member clusters: magainin, indolicidin, protegrin, tachyplesin, buforin, aurein, cecropin, dermaseptin, apidaecin, RsAFP, plectasin, piscidin, BMAP/PMAP, KWK template)
119+
- Standard AUROC 0.7832 → cluster-split AUROC 0.7607; inflation 0.0225 (< 0.05 gate: PASS)
120+
- AUPRC 0.8164 → 0.7569; AUPRC inflation 0.0595 (larger drop — near-duplicate AMPs were concentrated in high-precision region)
121+
- CLI: `openamp-foundry validate-scoring --benchmark-type cluster_split [--cluster-threshold 0.70]`
122+
- Makefile: `make validate-scoring-cluster-split`
123+
- Tests: `tests/test_cluster_split_benchmark.py` (12 tests covering structure, risk classification, real-data regression, threshold control)
124+
- Documents: BENCHMARKING.md updated with cluster-split section; METRICS_CURRENT.md updated with cluster-split metrics row
125+
- Conclusion: headline AUROC is not materially inflated by within-set redundancy. AUPRC inflation is larger and flagged as a known limitation.
126+
114127
## v0.5.6 — Wave 0.5b Safety-Optimized Designs ✓ (2026-06-29)
115128

116129
- External predictor results integrated from `wave05_combined_consensus.csv` (`scripts/fill_wave0_5_external_results.py`)

‎src/openamp_foundry/benchmark/retrospective.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@
2020
since all composition features are identical per pair.
2121
NOT used as the synthesis gate; reported for scientific transparency.
2222
23+
CLUSTER_SPLIT (within-set leakage test, required):
24+
Positives — cluster representatives of the 95 AMPs, deduplicated at a
25+
similarity threshold (default 0.70 Levenshtein), leaving ~76
26+
non-redundant AMPs.
27+
Negatives — 96 length-matched random decoys (same as STANDARD).
28+
Tests: does the model's discriminative power survive removal of
29+
near-duplicate AMPs? If AUROC drops sharply, the headline
30+
number was inflated by within-set redundancy — the model was
31+
memorising family-specific composition rather than generalising.
32+
Gate: AUROC should not drop more than 0.05 from STANDARD.
33+
A drop > 0.05 flags within-set leakage as a material risk.
34+
2335
Key metric: AUROC (area under receiver operating characteristic curve)
2436
= P(random AMP scores higher than random decoy)
2537
"""
@@ -122,6 +134,13 @@ def _recall_at_k(labels: list[int], k: int) -> float:
122134
"distinguish AMP-like composition and structure from random protein sequences. "
123135
"This is the primary synthesis gate."
124136
),
137+
"cluster_split": (
138+
"Positives are cluster representatives of the 95 AMPs, deduplicated at "
139+
"0.70 Levenshtein similarity (~76 non-redundant AMPs). Negatives are the "
140+
"same 96 random background peptides. This tests whether the headline AUROC "
141+
"is inflated by near-duplicate AMPs within the benchmark set. "
142+
"A drop > 0.05 from standard AUROC flags within-set leakage."
143+
),
125144
"strict": (
126145
"Negatives are amino-acid-composition-matched shuffled decoys (RNG seed=42). "
127146
"AUROC reflects discrimination by ORDER-DEPENDENT features only "
@@ -133,6 +152,117 @@ def _recall_at_k(labels: list[int], k: int) -> float:
133152
}
134153

135154

155+
def run_cluster_split_benchmark(
156+
amp_csv: str | Path,
157+
decoy_csv: str | Path,
158+
config_path: str | Path = "configs/pipeline.yaml",
159+
threshold: float = 0.70,
160+
n_bootstrap: int = 2000,
161+
) -> dict:
162+
"""Run the retrospective benchmark after deduplicating AMPs by cluster.
163+
164+
This is the within-set leakage test. It:
165+
1. Clusters all AMPs at the given similarity threshold (Levenshtein-based).
166+
2. Keeps only one representative per cluster (the first encountered).
167+
3. Runs the standard AUROC/AUPRC benchmark on the deduplicated set.
168+
4. Reports both the original and deduplicated metrics so the inflation
169+
from near-duplicate AMPs is visible and auditable.
170+
171+
A large drop (AUROC_std - AUROC_dedup > 0.05) signals that the headline
172+
benchmark was inflated by within-set redundancy.
173+
174+
Returns a dict with both standard and cluster-split results.
175+
"""
176+
from openamp_foundry.benchmark.splits import cluster_by_similarity
177+
178+
# Load AMP sequences for clustering
179+
amp_seqs: list[str] = []
180+
amp_ids: list[str] = []
181+
with open(amp_csv, newline="", encoding="utf-8") as f:
182+
for row in csv.DictReader(f):
183+
amp_seqs.append(row["sequence"].strip().upper())
184+
amp_ids.append(row["id"])
185+
186+
clusters = cluster_by_similarity(amp_seqs, threshold=threshold)
187+
rep_indices = sorted(c[0] for c in clusters)
188+
removed_indices = [i for i in range(len(amp_seqs)) if i not in set(rep_indices)]
189+
190+
# Write deduplicated AMP CSV to a temp file
191+
import tempfile
192+
dedup_path = Path(tempfile.mktemp(suffix="_dedup_amps.csv"))
193+
with open(dedup_path, "w", newline="", encoding="utf-8") as f:
194+
writer = csv.writer(f)
195+
writer.writerow(["id", "sequence", "family", "reference", "label"])
196+
for i in rep_indices:
197+
writer.writerow([amp_ids[i], amp_seqs[i], "cluster_rep", "dedup", 1])
198+
199+
# Run standard benchmark on deduplicated set
200+
dedup_result = run_retrospective_benchmark(
201+
amp_csv=str(dedup_path),
202+
decoy_csv=decoy_csv,
203+
config_path=config_path,
204+
benchmark_type="cluster_split",
205+
n_bootstrap=n_bootstrap,
206+
)
207+
208+
# Clean up temp file
209+
dedup_path.unlink(missing_ok=True)
210+
211+
# Run standard benchmark on the full set for comparison
212+
std_result = run_retrospective_benchmark(
213+
amp_csv=amp_csv,
214+
decoy_csv=decoy_csv,
215+
config_path=config_path,
216+
benchmark_type="standard",
217+
n_bootstrap=n_bootstrap,
218+
)
219+
220+
auroc_inflation = round(std_result["auroc"] - dedup_result["auroc"], 4)
221+
auprc_inflation = round(std_result["auprc"] - dedup_result["auprc"], 4)
222+
223+
leakage_risk = (
224+
"HIGH — AUROC inflated by > 0.05 from near-duplicate AMPs; "
225+
"headline benchmark is not reliable without cluster split"
226+
if auroc_inflation > 0.05
227+
else "LOW — AUROC drop from deduplication is < 0.05; "
228+
"headline benchmark is not materially inflated by within-set redundancy"
229+
)
230+
231+
removed_details = [
232+
{"id": amp_ids[i], "sequence": amp_seqs[i]}
233+
for i in removed_indices
234+
]
235+
236+
return {
237+
"benchmark": "cluster_split_leakage_test",
238+
"threshold": threshold,
239+
"n_amps_original": len(amp_seqs),
240+
"n_amps_after_dedup": len(rep_indices),
241+
"n_near_duplicates_removed": len(removed_indices),
242+
"removed_amps": removed_details,
243+
"standard_auroc": std_result["auroc"],
244+
"standard_auroc_ci95": f'{std_result["auroc_ci95_lo"]}-{std_result["auroc_ci95_hi"]}',
245+
"standard_auprc": std_result["auprc"],
246+
"cluster_split_auroc": dedup_result["auroc"],
247+
"cluster_split_auroc_ci95": f'{dedup_result["auroc_ci95_lo"]}-{dedup_result["auroc_ci95_hi"]}',
248+
"cluster_split_auprc": dedup_result["auprc"],
249+
"auroc_inflation": auroc_inflation,
250+
"auprc_inflation": auprc_inflation,
251+
"leakage_risk": leakage_risk,
252+
"interpretation": (
253+
f"Cluster-split AUROC = {dedup_result['auroc']:.4f} "
254+
f"(vs standard {std_result['auroc']:.4f}, "
255+
f"inflation = {auroc_inflation:.4f}). {leakage_risk}."
256+
),
257+
"dedup_result": dedup_result,
258+
"disclaimer": (
259+
"Cluster-split AUROC does NOT imply the nominated candidates are antimicrobial. "
260+
"It tests whether the benchmark itself is inflated by near-duplicate positives. "
261+
"Wet-lab validation remains mandatory."
262+
),
263+
}
264+
265+
136266
def run_retrospective_benchmark(
137267
amp_csv: str | Path,
138268
decoy_csv: str | Path,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Allow `python -m openamp_foundry.cli` to invoke the CLI."""
2+
import sys
3+
4+
from openamp_foundry.cli.main import main
5+
6+
if __name__ == "__main__":
7+
sys.exit(main())

‎src/openamp_foundry/cli/commands/benchmark.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,44 @@ def _run_bench(args: argparse.Namespace) -> int:
5151

5252
def _run_validate_scoring(args: argparse.Namespace) -> int:
5353
import json as _json
54-
from openamp_foundry.benchmark.retrospective import run_retrospective_benchmark
54+
from openamp_foundry.benchmark.retrospective import (
55+
run_cluster_split_benchmark,
56+
run_retrospective_benchmark,
57+
)
5558
from openamp_foundry.utils.io import write_json
5659

5760
benchmark_type = getattr(args, "benchmark_type", "standard")
61+
62+
# Cluster-split mode runs the full leakage test with side-by-side comparison
63+
if benchmark_type == "cluster_split":
64+
threshold = getattr(args, "cluster_threshold", 0.70)
65+
result = run_cluster_split_benchmark(
66+
amp_csv=args.amp_csv,
67+
decoy_csv=args.decoy_csv,
68+
config_path=args.config,
69+
threshold=threshold,
70+
)
71+
if args.out:
72+
write_json(args.out, result)
73+
summary = {
74+
"status": "ok",
75+
"benchmark_type": "cluster_split",
76+
"n_amps_original": result["n_amps_original"],
77+
"n_amps_after_dedup": result["n_amps_after_dedup"],
78+
"n_near_duplicates_removed": result["n_near_duplicates_removed"],
79+
"standard_auroc": result["standard_auroc"],
80+
"cluster_split_auroc": result["cluster_split_auroc"],
81+
"auroc_inflation": result["auroc_inflation"],
82+
"standard_auprc": result["standard_auprc"],
83+
"cluster_split_auprc": result["cluster_split_auprc"],
84+
"auprc_inflation": result["auprc_inflation"],
85+
"leakage_risk": result["leakage_risk"],
86+
"interpretation": result["interpretation"],
87+
"out": args.out,
88+
}
89+
print(_json.dumps(summary, indent=2))
90+
return 0
91+
5892
result = run_retrospective_benchmark(
5993
amp_csv=args.amp_csv,
6094
decoy_csv=args.decoy_csv,

‎src/openamp_foundry/cli/main.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,21 @@ def build_parser() -> argparse.ArgumentParser:
177177
)
178178
validate_scoring.add_argument(
179179
"--benchmark-type",
180-
choices=["standard", "strict"],
180+
choices=["standard", "strict", "cluster_split"],
181181
default="standard",
182182
help=(
183183
"standard: AMPs vs background random peptides (primary synthesis gate). "
184-
"strict: AMPs vs composition-matched shuffled decoys (order-sensitivity test)."
184+
"strict: AMPs vs composition-matched shuffled decoys (order-sensitivity test). "
185+
"cluster_split: dedup near-duplicate AMPs before scoring (within-set leakage test)."
186+
),
187+
)
188+
validate_scoring.add_argument(
189+
"--cluster-threshold",
190+
type=float,
191+
default=0.70,
192+
help=(
193+
"Similarity threshold for cluster-split deduplication (default: 0.70). "
194+
"Only used when --benchmark-type cluster_split."
185195
),
186196
)
187197
validate_scoring.add_argument("--config", default="configs/pipeline.yaml")

0 commit comments

Comments
 (0)