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+
2335Key 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+
136266def run_retrospective_benchmark (
137267 amp_csv : str | Path ,
138268 decoy_csv : str | Path ,
0 commit comments