"""External product-structure benchmark with overlap quarantine and block bootstrap.""" from __future__ import annotations from collections.abc import Mapping import numpy as np import pandas as pd import torch from scipy import stats from torch.nn import functional as F from .metrics import expected_tie_aware_metrics def load_structure_matrix(path: str) -> pd.DataFrame: matrix = pd.read_csv(path, index_col=0) matrix.index = matrix.index.astype(str) matrix.columns = matrix.columns.astype(str) if matrix.shape[0] != matrix.shape[1] or set(matrix.index) != set(matrix.columns): raise ValueError("Product-structure similarity matrix must be square") matrix = matrix.loc[matrix.index, matrix.index] values = matrix.to_numpy(dtype=float) if not np.allclose(values, values.T, equal_nan=False): raise ValueError("Product-structure similarity matrix must be symmetric") if np.nanmin(values) < 0 or np.nanmax(values) > 1: raise ValueError("Product-structure similarities must be in [0, 1]") return matrix def eligible_external_ids( structure_matrix: pd.DataFrame, embedding_ids: set[str], training_split: pd.DataFrame, ) -> list[str]: blocked = set( training_split.loc[ training_split["split"].isin(["train", "validation"]), "group_id" ].astype(str) ) identifiers = set(structure_matrix.index).intersection(embedding_ids).difference(blocked) if identifiers.intersection(blocked): raise AssertionError("Training/validation MIBiG references leaked into external evaluation") return sorted(identifiers) def all_pair_scores( embeddings: Mapping[str, torch.Tensor], identifiers: list[str] ) -> pd.DataFrame: values = torch.stack([F.normalize(embeddings[identifier].float(), dim=0) for identifier in identifiers]) similarities = (values @ values.T).cpu().numpy() left, right = np.triu_indices(len(identifiers), k=1) return pd.DataFrame( { "record_a": [identifiers[index] for index in left], "record_b": [identifiers[index] for index in right], "score": similarities[left, right], } ) def score_requested_pairs( embeddings: Mapping[str, torch.Tensor], pairs: pd.DataFrame ) -> pd.DataFrame: required = {"record_a", "record_b"} if missing := required.difference(pairs.columns): raise ValueError(f"Requested pairs are missing columns: {sorted(missing)}") result = pairs[["record_a", "record_b"]].copy() result["score"] = [ float(F.cosine_similarity(embeddings[left], embeddings[right], dim=0)) for left, right in zip(result["record_a"], result["record_b"]) ] return result def attach_structural_truth(edges: pd.DataFrame, matrix: pd.DataFrame) -> pd.DataFrame: required = {"record_a", "record_b", "score"} if missing := required.difference(edges.columns): raise ValueError(f"Pair scores are missing columns: {sorted(missing)}") valid = edges["record_a"].isin(matrix.index) & edges["record_b"].isin(matrix.index) result = edges[valid].copy() result["structural_similarity"] = [ float(matrix.loc[left, right]) for left, right in zip(result["record_a"], result["record_b"]) ] return result def spearman_summary(edges: pd.DataFrame) -> dict[str, float | int]: if len(edges) < 3: raise ValueError("At least three scored pairs are required") correlation, p_value = stats.spearmanr(edges["score"], edges["structural_similarity"]) return {"pairs": len(edges), "spearman_r": float(correlation), "p_value": float(p_value)} def anchor_block_bootstrap( edges: pd.DataFrame, samples: int, confidence: float, seed: int, ) -> tuple[float, float]: """Two-endpoint BGC cluster bootstrap on fixed full-sample ranks. Spearman correlation is Pearson correlation of ranks. Ranking once and resampling endpoint-level sufficient statistics avoids materializing a million-row pair table for every replicate. Each dyad contributes half of its weight to each endpoint, so every BGC is represented as a dependence block instead of assigning pairs to the lexicographically smaller ID. """ if samples < 1: raise ValueError("Bootstrap samples must be positive") if not 0.0 < confidence < 1.0: raise ValueError("Bootstrap confidence must be between zero and one") if len(edges) < 3: raise ValueError("At least three scored pairs are required") score_rank = stats.rankdata(edges["score"].to_numpy(dtype=float), method="average") truth_rank = stats.rankdata( edges["structural_similarity"].to_numpy(dtype=float), method="average" ) endpoint_frame = pd.DataFrame( { "anchor": np.concatenate( [ edges["record_a"].astype(str).to_numpy(), edges["record_b"].astype(str).to_numpy(), ] ), "weight": 0.5, "x": np.tile(score_rank, 2), "y": np.tile(truth_rank, 2), } ) endpoint_frame["x2"] = endpoint_frame["x"] ** 2 endpoint_frame["y2"] = endpoint_frame["y"] ** 2 endpoint_frame["xy"] = endpoint_frame["x"] * endpoint_frame["y"] for column in ("x", "y", "x2", "y2", "xy"): endpoint_frame[column] *= endpoint_frame["weight"] blocks = ( endpoint_frame.groupby("anchor", sort=True)[ ["weight", "x", "y", "x2", "y2", "xy"] ] .sum() .to_numpy(dtype=float) ) if len(blocks) < 2: raise ValueError("At least two BGC endpoint blocks are required") random_state = np.random.default_rng(seed) correlations: list[float] = [] for _ in range(samples): selected = random_state.integers(0, len(blocks), size=len(blocks)) weight, sum_x, sum_y, sum_x2, sum_y2, sum_xy = blocks[selected].sum(axis=0) covariance = sum_xy - (sum_x * sum_y / weight) variance_x = sum_x2 - (sum_x * sum_x / weight) variance_y = sum_y2 - (sum_y * sum_y / weight) denominator = np.sqrt(max(variance_x, 0.0) * max(variance_y, 0.0)) if denominator > 0.0: correlations.append(float(covariance / denominator)) if not correlations: raise ValueError("No finite block-bootstrap correlations could be calculated") tail = (1.0 - confidence) / 2.0 return tuple(float(value) for value in np.quantile(correlations, [tail, 1.0 - tail])) def mark_cross_genus(edges: pd.DataFrame, metadata: pd.DataFrame) -> pd.DataFrame: genus = metadata.loc[metadata["genus_count"] == 1].set_index("bgc_id")["genera"].to_dict() result = edges.copy() result["cross_genus"] = [ left in genus and right in genus and genus[left].lower() != genus[right].lower() for left, right in zip(result.record_a, result.record_b) ] return result def exact_product_retrieval( embeddings: Mapping[str, torch.Tensor], gold_mapping: pd.DataFrame, eligible_ids: set[str], cutoff: int = 50, ) -> pd.DataFrame: mapping = gold_mapping[gold_mapping["bgc_id"].isin(eligible_ids)].copy() sizes = mapping.groupby("product_group_id")["bgc_id"].nunique() mapping = mapping[mapping["product_group_id"].isin(sizes[sizes >= 2].index)] universe = sorted(mapping["bgc_id"].unique()) group_by_bgc = mapping.set_index("bgc_id")["product_group_id"].to_dict() genus_by_bgc = mapping.set_index("bgc_id")["genus"].astype(str).to_dict() rows = [] for reference in universe: candidates = [identifier for identifier in universe if identifier != reference] relevant = { identifier for identifier in candidates if group_by_bgc[identifier] == group_by_bgc[reference] } if not relevant: continue scores = { identifier: float(F.cosine_similarity(embeddings[reference], embeddings[identifier], dim=0)) for identifier in candidates } metrics = expected_tie_aware_metrics(scores, relevant, recall_at=(cutoff,), ndcg_at=(cutoff,)) cross_genus_relevant = { identifier for identifier in relevant if genus_by_bgc[identifier].lower() != genus_by_bgc[reference].lower() } rows.append( { "reference_id": reference, "product_group_id": group_by_bgc[reference], "reference_genus": genus_by_bgc[reference], "cross_genus_positive_count": len(cross_genus_relevant), **metrics, } ) return pd.DataFrame(rows) def evaluate_similarity_method( name: str, edges: pd.DataFrame, structure_matrix: pd.DataFrame, metadata: pd.DataFrame, bootstrap_samples: int, confidence: float, seed: int, ) -> tuple[pd.DataFrame, list[dict[str, object]]]: scored = mark_cross_genus(attach_structural_truth(edges, structure_matrix), metadata) summaries: list[dict[str, object]] = [] for subset_name, subset in (("all", scored), ("cross_genus", scored[scored["cross_genus"]])): if len(subset) < 3: continue summary = spearman_summary(subset) lower, upper = anchor_block_bootstrap( subset, bootstrap_samples, confidence, seed ) summaries.append( {"method": name, "subset": subset_name, **summary, "ci_lower": lower, "ci_upper": upper, "bootstrap_unit": "two_endpoint_bgc_cluster_fixed_ranks"} ) scored["method"] = name return scored, summaries