| """Quantify cross-seed stability of learned laboratory embeddings. | |
| The comparison uses rank correlations between vectors of pairwise laboratory | |
| distances. Pairwise distances are invariant to translation, rotation, and | |
| reflection of an embedding, while Spearman correlation is also invariant to a | |
| positive global rescaling. This avoids comparing arbitrary embedding axes. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import itertools | |
| import json | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| STRATEGIES = ("canonical_grouped", "scaffold_aware") | |
| SEEDS = (123456, 123457, 123458) | |
| MODEL_SPECS = { | |
| "gat": ("graph_model.lab_embedding.weight", "gat_fold_*.pt"), | |
| "gcn": ("graph_model.lab_embedding.weight", "gcn_fold_*.pt"), | |
| "fpnn": ("lab_embedding.weight", "fp_nn_fold_*.pt"), | |
| } | |
| def _prepare_output_dir(path: Path) -> Path: | |
| if path.exists() and any(path.iterdir()): | |
| raise FileExistsError(f"Refusing nonempty output directory: {path}") | |
| path.mkdir(parents=True, exist_ok=True) | |
| return path | |
| def _load_laboratory_order(neural_dir: Path) -> list[str]: | |
| encoder_path = neural_dir / "fold_preprocessing" / "lab_encoder.json" | |
| if not encoder_path.is_file(): | |
| raise FileNotFoundError(f"Incomplete checkpoint matrix: {encoder_path}") | |
| payload = json.loads(encoder_path.read_text(encoding="utf-8")) | |
| laboratories = payload.get("classes_in_index_order") | |
| if not isinstance(laboratories, list) or len(laboratories) < 3: | |
| raise ValueError(f"Invalid laboratory encoder: {encoder_path}") | |
| if len(laboratories) != len(set(laboratories)): | |
| raise ValueError(f"Duplicate laboratory labels: {encoder_path}") | |
| return [str(value) for value in laboratories] | |
| def _load_embedding(checkpoint_path: Path, key: str) -> np.ndarray: | |
| payload: dict[str, Any] = torch.load( | |
| checkpoint_path, | |
| map_location="cpu", | |
| weights_only=True, | |
| ) | |
| model_state = payload.get("model_state") | |
| if not isinstance(model_state, dict) or key not in model_state: | |
| raise KeyError(f"Missing {key} in {checkpoint_path}") | |
| embedding = model_state[key].detach().cpu().numpy().astype(float, copy=False) | |
| if embedding.ndim != 2: | |
| raise ValueError(f"Expected a two-dimensional embedding in {checkpoint_path}") | |
| return embedding | |
| def _mean_pairwise_distance_vector( | |
| neural_dir: Path, | |
| model: str, | |
| expected_folds: int, | |
| ) -> tuple[tuple[str, ...], np.ndarray]: | |
| key, pattern = MODEL_SPECS[model] | |
| checkpoint_paths = sorted((neural_dir / "checkpoints" / model).glob(pattern)) | |
| if len(checkpoint_paths) != expected_folds: | |
| raise FileNotFoundError( | |
| "Incomplete checkpoint matrix: " | |
| f"expected {expected_folds} {model} checkpoints in {neural_dir}, " | |
| f"found {len(checkpoint_paths)}" | |
| ) | |
| encoder_order = _load_laboratory_order(neural_dir) | |
| common_order = tuple(sorted(encoder_order)) | |
| reorder = np.asarray([encoder_order.index(label) for label in common_order]) | |
| upper = np.triu_indices(len(common_order), k=1) | |
| fold_distances: list[np.ndarray] = [] | |
| for checkpoint_path in checkpoint_paths: | |
| embedding = _load_embedding(checkpoint_path, key) | |
| if embedding.shape[0] != len(encoder_order): | |
| raise ValueError( | |
| f"Laboratory count mismatch in {checkpoint_path}: " | |
| f"{embedding.shape[0]} versus {len(encoder_order)}" | |
| ) | |
| aligned = embedding[reorder] | |
| difference = aligned[:, None, :] - aligned[None, :, :] | |
| distances = np.sqrt(np.sum(difference * difference, axis=2)) | |
| fold_distances.append(distances[upper]) | |
| return common_order, np.mean(np.vstack(fold_distances), axis=0) | |
| def _spearman_correlation(vector_a: np.ndarray, vector_b: np.ndarray) -> float: | |
| """Compute Spearman rho without importing SciPy's additional OpenMP runtime.""" | |
| ranks_a = pd.Series(vector_a).rank(method="average").to_numpy(dtype=float) | |
| ranks_b = pd.Series(vector_b).rank(method="average").to_numpy(dtype=float) | |
| centered_a = ranks_a - ranks_a.mean() | |
| centered_b = ranks_b - ranks_b.mean() | |
| denominator = np.sqrt( | |
| np.sum(centered_a * centered_a) * np.sum(centered_b * centered_b) | |
| ) | |
| if denominator == 0: | |
| raise ValueError("Cannot compute embedding stability from constant distances.") | |
| return float(np.sum(centered_a * centered_b) / denominator) | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description="Compare learned laboratory geometry across outer seeds." | |
| ) | |
| parser.add_argument("--artifacts-root", required=True) | |
| parser.add_argument("--output-dir", required=True) | |
| parser.add_argument("--expected-folds", type=int, default=6) | |
| arguments = parser.parse_args() | |
| artifacts_root = Path(arguments.artifacts_root).resolve() | |
| output_dir = _prepare_output_dir(Path(arguments.output_dir).resolve()) | |
| distance_vectors: dict[tuple[str, str, int], np.ndarray] = {} | |
| laboratory_orders: dict[tuple[str, str, int], tuple[str, ...]] = {} | |
| for strategy in STRATEGIES: | |
| for seed in SEEDS: | |
| neural_dir = ( | |
| artifacts_root / strategy / f"seed_{seed}" / "neural_stack" | |
| ) | |
| for model in MODEL_SPECS: | |
| order, vector = _mean_pairwise_distance_vector( | |
| neural_dir, | |
| model, | |
| arguments.expected_folds, | |
| ) | |
| laboratory_orders[(strategy, model, seed)] = order | |
| distance_vectors[(strategy, model, seed)] = vector | |
| rows: list[dict[str, Any]] = [] | |
| for strategy in STRATEGIES: | |
| for model in MODEL_SPECS: | |
| for seed_a, seed_b in itertools.combinations(SEEDS, 2): | |
| order_a = laboratory_orders[(strategy, model, seed_a)] | |
| order_b = laboratory_orders[(strategy, model, seed_b)] | |
| if order_a != order_b: | |
| raise ValueError( | |
| f"Laboratory labels differ for {strategy}/{model}: " | |
| f"seed {seed_a} versus seed {seed_b}" | |
| ) | |
| vector_a = distance_vectors[(strategy, model, seed_a)] | |
| vector_b = distance_vectors[(strategy, model, seed_b)] | |
| rho = _spearman_correlation(vector_a, vector_b) | |
| rows.append( | |
| { | |
| "strategy": strategy, | |
| "model": model, | |
| "seed_a": seed_a, | |
| "seed_b": seed_b, | |
| "n_laboratories": len(order_a), | |
| "n_laboratory_pairs": len(vector_a), | |
| "spearman_rho": rho, | |
| } | |
| ) | |
| correlations = pd.DataFrame(rows) | |
| correlations.to_csv(output_dir / "embedding_stability.csv", index=False) | |
| aggregate = ( | |
| correlations.groupby(["strategy", "model"])["spearman_rho"] | |
| .agg(["mean", "min", "max"]) | |
| .reset_index() | |
| ) | |
| aggregate.to_csv(output_dir / "embedding_stability_aggregate.csv", index=False) | |
| summary = { | |
| "expected_folds": arguments.expected_folds, | |
| "distance_definition": "mean foldwise Euclidean distance", | |
| "comparison": "Spearman correlation of laboratory-pair distance vectors", | |
| "invariances": [ | |
| "translation", | |
| "rotation", | |
| "reflection", | |
| "positive global scaling", | |
| ], | |
| "strategies": list(STRATEGIES), | |
| "seeds": list(SEEDS), | |
| "models": list(MODEL_SPECS), | |
| } | |
| (output_dir / "embedding_stability_summary.json").write_text( | |
| json.dumps(summary, indent=2) + "\n", | |
| encoding="utf-8", | |
| ) | |
| print(f"Wrote embedding-stability analysis to: {output_dir}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |