Download model/boltzgen/task/analyze/analyze_utils.py from OneScience-Group/BoltzGen: direct link, hf CLI and curl.
- Browser
- Download file 47.6 kB
-
https://huggingface.co/OneScience-Group/BoltzGen/resolve/main/model/boltzgen/task/analyze/analyze_utils.py
- Command line
-
hf download hf://OneScience-Group/BoltzGen/model/boltzgen/task/analyze/analyze_utils.py
-
curl -L -o analyze_utils.py https://huggingface.co/OneScience-Group/BoltzGen/resolve/main/model/boltzgen/task/analyze/analyze_utils.py
47.6 kB
| import io | |
| import warnings | |
| from pathlib import Path | |
| import random | |
| from typing import List | |
| import subprocess | |
| import re | |
| import biotite | |
| import hydride | |
| from sklearn.cluster import DBSCAN | |
| from Bio import PDB | |
| from biotite import structure | |
| from Bio.Seq import Seq | |
| from matplotlib import pyplot as plt | |
| import matplotlib as mpl | |
| mpl.rcParams["figure.max_open_warning"] = 100 | |
| from Bio import Align | |
| from boltzgen.data.rmsd_computation import get_true_coordinates | |
| from boltzgen.model.loss.diffusion import weighted_rigid_align | |
| from boltzgen.task.predict.data_from_generated import collate | |
| import torch | |
| import numpy as np | |
| import pandas as pd | |
| from tqdm import tqdm | |
| from boltzgen.data import const | |
| from boltzgen.data.data import Structure, biotite_array_from_feat | |
| from boltzgen.data.write.mmcif import to_mmcif | |
| from boltzgen.model.loss.validation import factored_lddt_loss, compute_subset_rmsd | |
| from biotite.structure.sasa import sasa | |
| from biotite.structure.info import vdw_radius_single, vdw_radius_protor | |
| import biotite.structure.io.pdbx as pdbx | |
| import biotite.structure.io.pdb as pdbio | |
| TARGET_ID_RE = re.compile( | |
| r"^(?:(?:sample\d+_|batch\d+_|rank\d+_)+)?([^_]+)(?:_[^_]+)*?(?:_(?:gen))*$" | |
| ) | |
| def _load_stack(path): | |
| path = Path(path) | |
| suffix = path.suffix.lower() | |
| if suffix in {".cif", ".mmcif"}: | |
| cif_file = pdbx.CIFFile.read(str(path)) | |
| stack = pdbx.get_structure(cif_file, use_author_fields=False) | |
| elif suffix in {".pdb", ".ent"}: | |
| pdb_file = pdbio.PDBFile.read(str(path)) | |
| stack = pdbio.get_structure(pdb_file, model=None) | |
| else: | |
| raise ValueError(f"Unsupported structure file extension: {suffix}") | |
| return stack | |
| def compute_rmsd(atom_coords: torch.Tensor, pred_atom_coords: torch.Tensor): | |
| rmsd, _ = compute_subset_rmsd( | |
| atom_coords, | |
| pred_atom_coords, | |
| atom_mask=torch.ones_like(atom_coords[..., 0]), | |
| align_weights=torch.ones_like(atom_coords[..., 0]), | |
| subset_mask=torch.ones_like(atom_coords[..., 0]), | |
| multiplicity=1, | |
| ) | |
| return rmsd | |
| def make_histogram( | |
| df, | |
| column_name: str, | |
| ): | |
| data = df[column_name].dropna() | |
| fig, ax = plt.subplots(figsize=(6, 4)) | |
| ax.hist(data, bins=50, alpha=0.7, edgecolor="black", linewidth=0.5) | |
| ax.axvline(data.mean(), color="red", linestyle="dashed", linewidth=1) | |
| ax.set_title( | |
| f"{column_name.replace('_', ' ').capitalize()} Distribution", fontsize=12 | |
| ) | |
| ax.set_xlabel(column_name.replace("_", " "), fontsize=10) | |
| ax.set_ylabel("Count", fontsize=10) | |
| ax.grid(True, axis="y", linestyle="--", linewidth=0.5, alpha=0.6) | |
| ax.tick_params(axis="both", which="major", labelsize=8) | |
| plt.tight_layout() | |
| return fig | |
| def get_best_folding_sample(folded): | |
| confidence = 0.8 * folded["design_to_target_iptm"] + 0.2 * folded["design_ptm"] | |
| best_idx = np.argmax(confidence) | |
| # TODO: remove the "if k in folded" | |
| best_sample = { | |
| k: folded[k][best_idx] for k in const.eval_keys_confidence if k in folded | |
| } | |
| best_sample["coords"] = folded["coords"][best_idx] | |
| return best_sample | |
| def get_fold_metrics( | |
| feat, | |
| folded, | |
| compute_lddts=True, | |
| prefix="", | |
| ): | |
| batch = collate([feat]) | |
| diffusion_samples = batch["coords"].shape[0] | |
| best_sample = get_best_folding_sample(folded) | |
| # Compute RMSDs | |
| rmsd_out = get_true_coordinates( | |
| batch=batch, | |
| out={"sample_atom_coords": torch.from_numpy(best_sample["coords"])}, | |
| diffusion_samples=1, | |
| symmetry_correction=False, | |
| protein_lig_rmsd=True, | |
| ) | |
| true_coords_resolved_mask = rmsd_out["true_coords_resolved_mask"] | |
| # Add to metrics dictionary | |
| metrics = {} | |
| metrics["rmsd"] = rmsd_out.get("rmsd").item() | |
| metrics["rmsd_design"] = rmsd_out.get("rmsd_design").item() | |
| metrics["rmsd_target"] = rmsd_out.get("rmsd_target").item() | |
| metrics["rmsd_design_target"] = rmsd_out.get("rmsd_design_target").item() | |
| metrics["target_aligned_rmsd_design"] = rmsd_out.get( | |
| "target_aligned_rmsd_design" | |
| ).item() | |
| metrics["rmsd<2.5"] = bool(metrics["rmsd"] <= 2.5) | |
| metrics["target_aligned<2.5"] = bool(metrics["target_aligned_rmsd_design"] <= 2.5) | |
| metrics["designability_rmsd_2"] = bool(metrics["rmsd_design"] <= 2.0) | |
| metrics["designability_rmsd_4"] = bool(metrics["rmsd_design"] <= 4.0) | |
| # Comput LDDTs | |
| if compute_lddts: | |
| all_lddt_dict, _ = factored_lddt_loss( | |
| feats=batch, | |
| atom_mask=true_coords_resolved_mask, | |
| true_atom_coords=batch["coords"], | |
| pred_atom_coords=torch.from_numpy(best_sample["coords"]), | |
| multiplicity=diffusion_samples, | |
| exclude_ions=False, | |
| ) | |
| metrics.update({f"lddt_{k}": v.max().item() for k, v in all_lddt_dict.items()}) | |
| metrics["designability_lddt_60"] = bool(metrics["lddt_intra_design"] >= 0.6) | |
| metrics["designability_lddt_65"] = bool(metrics["lddt_intra_design"] >= 0.65) | |
| metrics["designability_lddt_70"] = bool(metrics["lddt_intra_design"] >= 0.7) | |
| metrics["designability_lddt_75"] = bool(metrics["lddt_intra_design"] >= 0.75) | |
| metrics["designability_lddt_80"] = bool(metrics["lddt_intra_design"] >= 0.8) | |
| metrics["designability_lddt_85"] = bool(metrics["lddt_intra_design"] >= 0.85) | |
| metrics["designability_lddt_90"] = bool(metrics["lddt_intra_design"] >= 0.9) | |
| # metrics without prefix (backbone only is the same as all atom) | |
| # TODO: remove the "if k in best_sample" | |
| confs = {k: best_sample[k] for k in const.eval_keys_confidence if k in best_sample} | |
| confs["min_interaction_pae<1.5"] = bool(confs["min_interaction_pae"] <= 1.5) | |
| confs["min_interaction_pae<2"] = bool(confs["min_interaction_pae"] <= 2.0) | |
| confs["min_interaction_pae<2.5"] = bool(confs["min_interaction_pae"] <= 2.5) | |
| confs["min_interaction_pae<3"] = bool(confs["min_interaction_pae"] <= 3) | |
| confs["min_interaction_pae<4"] = bool(confs["min_interaction_pae"] <= 4) | |
| confs["min_interaction_pae<5"] = bool(confs["min_interaction_pae"] <= 5) | |
| confs["design_ptm>80"] = bool(confs["design_ptm"] >= 0.8) | |
| confs["design_ptm>75"] = bool(confs["design_ptm"] >= 0.75) | |
| confs["design_iptm>80"] = bool(confs["design_iptm"] >= 0.8) | |
| confs["design_iptm>70"] = bool(confs["design_iptm"] >= 0.7) | |
| confs["design_iptm>60"] = bool(confs["design_iptm"] >= 0.6) | |
| confs["design_iptm>50"] = bool(confs["design_iptm"] >= 0.5) | |
| prefixed_metrics = {f"{prefix}{k}": v for k, v in metrics.items()} | |
| prefixed_metrics.update(confs) | |
| return prefixed_metrics | |
| def count_noncovalents(feat): | |
| metrics = {} | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| biotite_array = biotite_array_from_feat(feat) | |
| biotite_array, _ = hydride.add_hydrogen(biotite_array) | |
| hbond = biotite.structure.hbond(biotite_array) | |
| donor_idxs, acceptor_idxs = hbond[:, 0], hbond[:, 2] | |
| donor_design_hbonds = int( | |
| ( | |
| biotite_array.is_design[donor_idxs] | |
| & ~biotite_array.is_chain_design[acceptor_idxs] | |
| ).sum() | |
| ) | |
| acceptor_design_hbonds = int( | |
| ( | |
| ~biotite_array.is_chain_design[donor_idxs] | |
| & biotite_array.is_design[acceptor_idxs] | |
| ).sum() | |
| ) | |
| metrics["plip_hbonds"] = donor_design_hbonds + acceptor_design_hbonds | |
| # saltbridges | |
| pos_atoms = biotite_array[biotite_array.charge > 0] | |
| neg_atoms = biotite_array[biotite_array.charge < 0] | |
| if len(neg_atoms) > 0 and len(pos_atoms) > 0: | |
| pos_neg_distances = torch.cdist( | |
| torch.as_tensor(pos_atoms.coord), torch.as_tensor(neg_atoms.coord) | |
| ) | |
| pos_idxs, neg_idxs = torch.where( | |
| (pos_neg_distances > 0.5) & (pos_neg_distances < 5.5) | |
| ) | |
| # only keep the ones between design and non design | |
| pos_design_sb = int( | |
| (pos_atoms.is_design[pos_idxs] & ~neg_atoms.is_chain_design[neg_idxs]).sum() | |
| ) | |
| neg_design_sb = int( | |
| (~pos_atoms.is_chain_design[pos_idxs] & neg_atoms.is_design[neg_idxs]).sum() | |
| ) | |
| metrics["plip_saltbridge"] = pos_design_sb + neg_design_sb | |
| else: | |
| metrics["plip_saltbridge"] = 0 | |
| return metrics | |
| def tm_score(coords1, coords2): | |
| num_atoms1 = coords1.shape[0] | |
| num_atoms2 = coords2.shape[0] | |
| atom_array1 = structure.AtomArray(num_atoms1) | |
| atom_array1.coord = coords1.numpy() | |
| atom_array1.element = np.array(["C"] * num_atoms1) | |
| atom_array1.atom_name = np.array(["CA"] * num_atoms1) | |
| atom_array1.res_name = np.array(["ALA"] * num_atoms1) | |
| atom_array1.chain_id = np.array(["A"] * num_atoms1) | |
| atom_array1.res_id = np.arange(1, num_atoms1 + 1) | |
| atom_array2 = structure.AtomArray(num_atoms2) | |
| atom_array2.coord = coords2.numpy() | |
| atom_array2.element = np.array(["C"] * num_atoms2) | |
| atom_array2.atom_name = np.array(["CA"] * num_atoms2) | |
| atom_array2.res_name = np.array(["ALA"] * num_atoms2) | |
| atom_array2.chain_id = np.array(["A"] * num_atoms2) | |
| atom_array2.res_id = np.arange(1, num_atoms2 + 1) | |
| try: | |
| # This fails with a value error if the structures are too dissimilar. In that event, we return 0 as the TM-Score | |
| aligned, transform, fixed_indices, mobile_indices = ( | |
| structure.superimpose_structural_homologs( | |
| atom_array1, atom_array2, max_iterations=25 | |
| ) | |
| ) | |
| tm_align_fixed = structure.tm_score( | |
| atom_array1, | |
| aligned, | |
| fixed_indices, | |
| mobile_indices, | |
| ) | |
| except: | |
| tm_align_fixed = 0 | |
| tm_score_rmsd_aligned = 0 | |
| if num_atoms1 == num_atoms2: | |
| coords1 = weighted_rigid_align( | |
| coords1.float()[None], | |
| coords2.float()[None], | |
| weights=torch.ones(len(coords1)).float()[None], | |
| mask=torch.ones(len(coords2))[None], | |
| ).squeeze() | |
| atom_array1 = structure.AtomArray(num_atoms1) | |
| atom_array1.coord = coords1.numpy() | |
| atom_array1.element = np.array(["C"] * num_atoms1) | |
| atom_array1.atom_name = np.array(["CA"] * num_atoms1) | |
| atom_array1.res_name = np.array(["ALA"] * num_atoms1) | |
| atom_array1.chain_id = np.array(["A"] * num_atoms1) | |
| atom_array1.res_id = np.arange(1, num_atoms1 + 1) | |
| atom_array2 = structure.AtomArray(num_atoms2) | |
| atom_array2.coord = coords2.numpy() | |
| atom_array2.element = np.array(["C"] * num_atoms2) | |
| atom_array2.atom_name = np.array(["CA"] * num_atoms2) | |
| atom_array2.res_name = np.array(["ALA"] * num_atoms2) | |
| atom_array2.chain_id = np.array(["A"] * num_atoms2) | |
| atom_array2.res_id = np.arange(1, num_atoms2 + 1) | |
| try: | |
| _, _, fixed_indices, mobile_indices = ( | |
| structure.superimpose_structural_homologs( | |
| atom_array1, atom_array2, max_iterations=25 | |
| ) | |
| ) | |
| tm_score_rmsd_aligned = structure.tm_score( | |
| atom_array1, | |
| atom_array2, | |
| fixed_indices, | |
| mobile_indices, | |
| ) | |
| except: | |
| pass | |
| return tm_score_rmsd_aligned, tm_align_fixed | |
| def vendi_from_sim(mat): | |
| mat = mat + mat.T | |
| np.fill_diagonal(mat, 1.0) | |
| eigvals, _ = np.linalg.eigh(mat / len(mat)) | |
| eigvals = np.clip(eigvals, 0.0, None) | |
| return np.exp(np.nansum(-(eigvals * np.log(eigvals)))) | |
| def vendi_scores( | |
| all_ca_coords: List[np.ndarray], | |
| all_metrics: list = None, | |
| fold_metrics: bool = False, | |
| diversity_subset: int = None, | |
| compute_lddts: bool = True, | |
| compute_iptms: bool = True, | |
| compute_min_int_paes: bool = True, | |
| backbone_fold_metrics: bool = False, | |
| allatom_fold_metrics: bool = True, | |
| ) -> float: | |
| if fold_metrics or diversity_subset is not None: | |
| assert all_metrics is not None | |
| if all_metrics is not None: | |
| assert len(all_ca_coords) == len(all_metrics) | |
| if diversity_subset is not None and diversity_subset < len(all_ca_coords): | |
| indices = random.sample(range(len(all_ca_coords)), diversity_subset) | |
| all_metrics = [all_metrics[i] for i in indices] | |
| all_ca_coords = [all_ca_coords[i] for i in indices] | |
| N = len(all_ca_coords) | |
| tm = np.zeros((N, N), dtype=np.float32) | |
| tm_fixed = np.zeros((N, N), dtype=np.float32) | |
| for i in tqdm(range(N), desc="Computing structure diversity."): | |
| for j in range(i + 1, N): | |
| tm_score_rmsd_aligned, tm_fixeds = tm_score( | |
| all_ca_coords[i], all_ca_coords[j] | |
| ) | |
| tm[i, j] = tm_score_rmsd_aligned | |
| tm_fixed[i, j] = tm_fixeds | |
| scores = { | |
| "vendi_tm_fixed": vendi_from_sim(tm_fixed), | |
| "vendi_tm_align": vendi_from_sim(tm), | |
| } | |
| prefixes = [] | |
| if allatom_fold_metrics: | |
| prefixes.append("") | |
| if backbone_fold_metrics: | |
| prefixes.append("bb_") | |
| for prefix in prefixes: | |
| mask_2 = np.array([m[f"{prefix}designability_rmsd_2"] for m in all_metrics]) | |
| mask_4 = np.array([m[f"{prefix}designability_rmsd_4"] for m in all_metrics]) | |
| mask_25 = np.array([m[f"{prefix}rmsd<2.5"] for m in all_metrics]) | |
| mask_target_25 = np.array( | |
| [m[f"{prefix}target_aligned<2.5"] for m in all_metrics] | |
| ) | |
| scores.update( | |
| { | |
| f"vendi_tm_{prefix}rmsd<2.5": vendi_from_sim( | |
| tm_fixed[mask_25][:, mask_25] | |
| ) | |
| if np.sum(mask_25) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}rmsd_2": vendi_from_sim(tm_fixed[mask_2][:, mask_2]) | |
| if np.sum(mask_2) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}rmsd_4": vendi_from_sim(tm_fixed[mask_4][:, mask_4]) | |
| if np.sum(mask_4) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}target_aligned_rmsd<2.5": vendi_from_sim( | |
| tm_fixed[mask_target_25][:, mask_target_25] | |
| ) | |
| if np.sum(mask_target_25) > 0 | |
| else 0.0, | |
| } | |
| ) | |
| if compute_lddts: | |
| mask_60 = np.array( | |
| [m[f"{prefix}designability_lddt_60"] for m in all_metrics] | |
| ) | |
| mask_65 = np.array( | |
| [m[f"{prefix}designability_lddt_65"] for m in all_metrics] | |
| ) | |
| mask_70 = np.array( | |
| [m[f"{prefix}designability_lddt_70"] for m in all_metrics] | |
| ) | |
| mask_75 = np.array( | |
| [m[f"{prefix}designability_lddt_75"] for m in all_metrics] | |
| ) | |
| mask_80 = np.array( | |
| [m[f"{prefix}designability_lddt_80"] for m in all_metrics] | |
| ) | |
| mask_85 = np.array( | |
| [m[f"{prefix}designability_lddt_85"] for m in all_metrics] | |
| ) | |
| mask_90 = np.array( | |
| [m[f"{prefix}designability_lddt_90"] for m in all_metrics] | |
| ) | |
| scores.update( | |
| { | |
| f"vendi_tm_{prefix}lddt_60": vendi_from_sim( | |
| tm_fixed[mask_60][:, mask_60] | |
| ) | |
| if np.sum(mask_60) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_65": vendi_from_sim( | |
| tm_fixed[mask_65][:, mask_65] | |
| ) | |
| if np.sum(mask_65) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_70": vendi_from_sim( | |
| tm_fixed[mask_70][:, mask_70] | |
| ) | |
| if np.sum(mask_70) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_75": vendi_from_sim( | |
| tm_fixed[mask_75][:, mask_75] | |
| ) | |
| if np.sum(mask_75) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_80": vendi_from_sim( | |
| tm_fixed[mask_80][:, mask_80] | |
| ) | |
| if np.sum(mask_80) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_85": vendi_from_sim( | |
| tm_fixed[mask_85][:, mask_85] | |
| ) | |
| if np.sum(mask_85) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}lddt_90": vendi_from_sim( | |
| tm_fixed[mask_90][:, mask_90] | |
| ) | |
| if np.sum(mask_90) > 0 | |
| else 0.0, | |
| } | |
| ) | |
| if compute_iptms: | |
| mask_80 = np.array([m[f"design_iptm>80"] for m in all_metrics]) | |
| mask_70 = np.array([m[f"design_iptm>70"] for m in all_metrics]) | |
| mask_60 = np.array([m[f"design_iptm>60"] for m in all_metrics]) | |
| mask_50 = np.array([m[f"design_iptm>50"] for m in all_metrics]) | |
| scores.update( | |
| { | |
| f"vendi_tm_{prefix}design_iptm_80": vendi_from_sim( | |
| tm_fixed[mask_80][:, mask_80] | |
| ) | |
| if np.sum(mask_80) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}design_iptm_70": vendi_from_sim( | |
| tm_fixed[mask_70][:, mask_70] | |
| ) | |
| if np.sum(mask_70) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}design_iptm_60": vendi_from_sim( | |
| tm_fixed[mask_60][:, mask_60] | |
| ) | |
| if np.sum(mask_60) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}design_iptm_50": vendi_from_sim( | |
| tm_fixed[mask_50][:, mask_50] | |
| ) | |
| if np.sum(mask_50) > 0 | |
| else 0.0, | |
| } | |
| ) | |
| if compute_min_int_paes: | |
| mask_15 = np.array([m[f"min_interaction_pae<1.5"] for m in all_metrics]) | |
| mask_2 = np.array([m[f"min_interaction_pae<2"] for m in all_metrics]) | |
| mask_25 = np.array([m[f"min_interaction_pae<2.5"] for m in all_metrics]) | |
| mask_3 = np.array([m[f"min_interaction_pae<3"] for m in all_metrics]) | |
| mask_4 = np.array([m[f"min_interaction_pae<4"] for m in all_metrics]) | |
| mask_5 = np.array([m[f"min_interaction_pae<5"] for m in all_metrics]) | |
| scores.update( | |
| { | |
| f"vendi_tm_{prefix}min_interaction_pae_1.5": vendi_from_sim( | |
| tm_fixed[mask_15][:, mask_15] | |
| ) | |
| if np.sum(mask_15) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}min_interaction_pae_2": vendi_from_sim( | |
| tm_fixed[mask_2][:, mask_2] | |
| ) | |
| if np.sum(mask_2) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}min_interaction_pae_2.5": vendi_from_sim( | |
| tm_fixed[mask_25][:, mask_25] | |
| ) | |
| if np.sum(mask_25) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}min_interaction_pae_3": vendi_from_sim( | |
| tm_fixed[mask_3][:, mask_3] | |
| ) | |
| if np.sum(mask_3) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}min_interaction_pae_4": vendi_from_sim( | |
| tm_fixed[mask_4][:, mask_4] | |
| ) | |
| if np.sum(mask_4) > 0 | |
| else 0.0, | |
| f"vendi_tm_{prefix}min_interaction_pae_5": vendi_from_sim( | |
| tm_fixed[mask_5][:, mask_5] | |
| ) | |
| if np.sum(mask_5) > 0 | |
| else 0.0, | |
| } | |
| ) | |
| return scores | |
| def vendi_sequences(all_seqs: List[np.ndarray], diversity_subset: int = None) -> float: | |
| if diversity_subset is not None and diversity_subset < len(all_seqs): | |
| all_seqs = random.sample(all_seqs, diversity_subset) | |
| N = len(all_seqs) | |
| sims = np.zeros((N, N), dtype=np.float32) | |
| aligner = Align.PairwiseAligner() | |
| for i in tqdm(range(N), desc="Computing sequence diversity."): | |
| for j in range(i + 1, N): | |
| seq1 = Seq(all_seqs[i]) | |
| seq2 = Seq(all_seqs[j]) | |
| alignments = aligner.align(seq1, seq2) | |
| similarity = alignments[0].score / max(len(seq1), len(seq2)) | |
| sims[i, j] = similarity | |
| return { | |
| "vendi_seq_sim": vendi_from_sim(sims), | |
| } | |
| def compute_novelty_foldseek( | |
| indir: Path, | |
| outdir: Path, | |
| reference_db: Path, | |
| files: List[str], | |
| foldseek_binary: str = "/data/rbg/users/hstark/foldseek/bin/foldseek", | |
| ) -> pd.DataFrame: | |
| if len(files) == 0: | |
| return np.nan | |
| aln_tsv = outdir / "aln.tsv" | |
| tmp_dir = outdir / "tmp" | |
| cmd = [ | |
| foldseek_binary, | |
| "easy-search", | |
| str(indir), | |
| str(reference_db), | |
| str(aln_tsv), | |
| str(tmp_dir), | |
| "--format-output", | |
| "query,target,alntmscore,qtmscore,ttmscore", | |
| "--alignment-type", | |
| "1", | |
| "--exhaustive-search", | |
| "1", | |
| ] | |
| subprocess.run(cmd, check=True) | |
| df = pd.read_csv( | |
| aln_tsv, | |
| sep="\t", | |
| names=["query", "target", "alntmscore", "qtmscore", "ttmscore"], | |
| ) | |
| df["tmscore"] = (df["qtmscore"] + df["ttmscore"]) / 2 | |
| df = df.groupby("query").max().reset_index() | |
| queries = [Path(f).stem for f in files] | |
| df = df.set_index("query").reindex(queries, fill_value=0.0).reset_index() | |
| df_novelty = df[["query", "tmscore"]].rename(columns={"tmscore": "novelty"}) | |
| return df_novelty | |
| def _radius(res_name: str, atom_name: str, element: str) -> float: | |
| """ | |
| ProtOr radius with element fallback. | |
| """ | |
| try: | |
| r = vdw_radius_protor(res_name, atom_name) | |
| if r is not None: | |
| return r | |
| except KeyError: | |
| pass | |
| r = vdw_radius_single(element) | |
| return r if r is not None else 1.8 | |
| def compute_sasa(structure_path): | |
| HYDROPHOBIC_RESIDUES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "PRO", "TRP"} | |
| atoms = _load_stack(structure_path)[0] | |
| res_names = [ | |
| bytes(r).decode() if isinstance(r, bytes) else str(r) for r in atoms.res_name | |
| ] | |
| atom_names = [ | |
| bytes(a).decode() if isinstance(a, bytes) else str(a) for a in atoms.atom_name | |
| ] | |
| elements = [ | |
| bytes(e).decode() if isinstance(e, bytes) else str(e) for e in atoms.element | |
| ] | |
| radii = np.array( | |
| [ | |
| _radius(rn.strip(), an.strip(), el) | |
| for rn, an, el in zip(res_names, atom_names, elements) | |
| ], | |
| dtype=float, | |
| ) | |
| atom_sasa = sasa(atoms, probe_radius=1.4, point_number=960, vdw_radii=radii) | |
| mask = ( | |
| np.array([rn.strip() in HYDROPHOBIC_RESIDUES for rn in res_names]) | |
| & np.char.startswith(atom_names, "C") | |
| & (atom_sasa > 0) | |
| ) | |
| return atoms.coord[mask], atom_sasa[mask] | |
| def largest_hydrophobic_patch_area(cif_path, distance_cutoff=6.0): | |
| result = compute_sasa(cif_path) | |
| if result is None or result[0].size == 0: | |
| return np.nan | |
| coords, sasa_vals = result | |
| if len(coords) == 0: | |
| return 0.0 | |
| clustering = DBSCAN(eps=distance_cutoff, min_samples=1).fit(coords) | |
| labels = clustering.labels_ | |
| max_patch_area = 0.0 | |
| for label in np.unique(labels): | |
| area = sasa_vals[labels == label].sum() | |
| max_patch_area = max(max_patch_area, area) | |
| return max_patch_area | |
| def get_delta_sasa( | |
| path, | |
| atom_target_mask, | |
| atom_design_mask, | |
| ): | |
| stack = _load_stack(path) | |
| atoms = stack[0] | |
| res = [ | |
| r.decode().strip() if isinstance(r, bytes) else str(r).strip() | |
| for r in atoms.res_name | |
| ] | |
| atm = [ | |
| a.decode().strip() if isinstance(a, bytes) else str(a).strip() | |
| for a in atoms.atom_name | |
| ] | |
| elem = [e.decode() if isinstance(e, bytes) else str(e) for e in atoms.element] | |
| radii = np.array( | |
| [_radius(rn, an, el) for rn, an, el in zip(res, atm, elem)], dtype=float | |
| ) | |
| bound_mask = atom_design_mask | atom_target_mask | |
| atoms_bound = atoms[bound_mask] | |
| radii_bound = radii[bound_mask] | |
| area_bound = sasa( | |
| atoms_bound, | |
| probe_radius=1.4, | |
| point_number=960, | |
| vdw_radii=radii_bound, | |
| ) | |
| target_in_bound = atom_target_mask[bound_mask] | |
| target_bound = area_bound[target_in_bound].sum() | |
| target_atoms = atoms[atom_target_mask] | |
| target_res = [r for r, m in zip(res, atom_target_mask) if m] | |
| target_atm = [a for a, m in zip(atm, atom_target_mask) if m] | |
| target_elem = [e for e, m in zip(elem, atom_target_mask) if m] | |
| radii_lig = np.array( | |
| [_radius(rn, an, el) for rn, an, el in zip(target_res, target_atm, target_elem)], | |
| dtype=float, | |
| ) | |
| target_area = sasa( | |
| target_atoms, | |
| probe_radius=1.4, | |
| point_number=960, | |
| vdw_radii=radii_lig, | |
| ) | |
| delta = target_area.sum() - target_bound | |
| return delta, target_area.sum(), target_bound | |
| def compute_ss_metrics(dssp_pred, ss_conditioning_metricsed): | |
| ss_metrics = {} | |
| conditioned_mask = ss_conditioning_metricsed != 0 | |
| if conditioned_mask.sum() == 0: | |
| return { | |
| "precision_loop": float("nan"), | |
| "recall_loop": float("nan"), | |
| "accuracy_loop": float("nan"), | |
| "precision_helix": float("nan"), | |
| "recall_helix": float("nan"), | |
| "accuracy_helix": float("nan"), | |
| "precision_sheet": float("nan"), | |
| "recall_sheet": float("nan"), | |
| "accuracy_sheet": float("nan"), | |
| "accuracy_overall": float("nan"), | |
| } | |
| types = {1: "loop", 2: "helix", 3: "sheet"} | |
| TP_total, total_conditioned = 0, conditioned_mask.sum().item() | |
| for i, name in types.items(): | |
| TP = ((dssp_pred == i) & (ss_conditioning_metricsed == i)).sum().item() | |
| FP = ( | |
| ((dssp_pred == i) & (ss_conditioning_metricsed != i) & conditioned_mask) | |
| .sum() | |
| .item() | |
| ) | |
| FN = ((dssp_pred != i) & (ss_conditioning_metricsed == i)).sum().item() | |
| precision = TP / (TP + FP) if (TP + FP) > 0 else float("nan") | |
| recall = TP / (TP + FN) if (TP + FN) > 0 else float("nan") | |
| accuracy = TP / (TP + FP + FN) if (TP + FP + FN) > 0 else float("nan") | |
| ss_metrics[f"precision_{name}"] = precision | |
| ss_metrics[f"recall_{name}"] = recall | |
| ss_metrics[f"accuracy_{name}"] = accuracy | |
| TP_total += TP | |
| accuracy_overall = ( | |
| TP_total / total_conditioned if total_conditioned > 0 else float("nan") | |
| ) | |
| ss_metrics["accuracy_overall"] = accuracy_overall | |
| return ss_metrics | |
| def filter_resolved_atoms(structure: Structure) -> Structure: | |
| resolved_atom_indices = np.where(structure.atoms["is_present"])[0] | |
| return Structure.extract_atoms(structure, resolved_atom_indices) | |
| def save_design_only_structure_to_cif(atom_design_mask, structure, output_path: Path): | |
| design_atom_indices = torch.where(atom_design_mask)[0].cpu().numpy() | |
| design_only_str = Structure.extract_atoms( | |
| structure, design_atom_indices, res_reindex=True | |
| ) | |
| cif_text = to_mmcif(design_only_str) | |
| output_path.write_text(cif_text) | |
| return cif_text | |
| def save_design_only_structure_to_pdb(atom_design_mask, structure, output_path: Path): | |
| cif_path = output_path.with_suffix(".cif") | |
| cif_text = save_design_only_structure_to_cif(atom_design_mask, structure, cif_path) | |
| cif_io = io.StringIO(cif_text) | |
| mmcif_parser = PDB.MMCIFParser() | |
| pdb_writer = PDB.PDBIO() | |
| parsed_structure = mmcif_parser.get_structure("des_only", cif_io) | |
| pdb_writer.set_structure(parsed_structure) | |
| pdb_writer.save(str(output_path)) | |
| ######################################################################################################## | |
| # Hydrophobycity computation functions. From Jeremie Alexander. | |
| ######################################################################################################## | |
| def calc_base_h(seq: str) -> float: | |
| s = seq.upper() | |
| n = len(s) | |
| if n == 0: | |
| return 0.0 | |
| H = 0.0 | |
| # position-specific coefficients | |
| for i, aa in enumerate(s): | |
| if aa not in const.hydrophobicity_info: | |
| raise ValueError(f"Unknown residue '{aa}' in '{seq}'") | |
| if i == 0: | |
| key = "Rc1" | |
| elif i == 1: | |
| key = "Rc2" | |
| elif i == n - 1: | |
| key = "Rn" | |
| elif i == n - 2: | |
| key = "Rn1" | |
| else: | |
| key = "Rc" | |
| H += const.hydrophobicity_info[aa][key] | |
| # nearest-neighbor penalties around H/R/K | |
| for i, aa in enumerate(s): | |
| if aa in ("H", "R", "K"): | |
| for j in (i - 1, i + 1): | |
| if 0 <= j < n and s[j] in const.nn_penalty: | |
| H -= const.nn_penalty[s[j]] | |
| # proline run penalties | |
| i = 0 | |
| while i < n: | |
| if s[i] == "P": | |
| j = i | |
| while j < n and s[j] == "P": | |
| j += 1 | |
| run = j - i | |
| if run >= 4: | |
| H -= 5.0 | |
| elif run == 3: | |
| H -= 3.5 | |
| elif run == 2: | |
| H -= 1.2 | |
| i = j | |
| else: | |
| i += 1 | |
| return H | |
| def apply_length_weight(H: float, n: int) -> float: | |
| if n < 8: | |
| KL = 1.0 - 0.055 * (8 - n) | |
| elif n > 20: | |
| KL = 1.0 / (1.0 + 0.027 * (n - 20)) | |
| else: | |
| KL = 1.0 | |
| return H * KL | |
| def overall_penalty(H: float) -> float: | |
| if H <= 20: | |
| return H | |
| if H <= 30: | |
| return H - 0.27 * (H - 18.0) | |
| if H <= 40: | |
| return H - 0.33 * (H - 18.0) | |
| if H <= 50: | |
| return H - 0.38 * (H - 18.0) | |
| return H - 0.447 * (H - 18.0) | |
| def calc_hydrophobicity(seq: str) -> float: | |
| s = (seq or "").strip().upper() | |
| if not s or "X" in s: | |
| return float("nan") | |
| base = calc_base_h(s) | |
| base = apply_length_weight(base, len(s)) | |
| return round(overall_penalty(base), 4) | |
| ######################################################################################################## | |
| # Liability computation functions | |
| ######################################################################################################## | |
| def get_motif_set(modality="antibody", peptide_type="linear"): | |
| """ | |
| Returns a dict of motif_name -> compiled regex for the given modality. | |
| modality: 'antibody' or 'peptide' | |
| peptide_type: 'linear' or 'cyclic' (only for peptide modality) | |
| """ | |
| if modality == "antibody": | |
| return { | |
| "DeAmdH": re.compile(r"N[GS]"), # High-risk deamidation | |
| "DeAmdM": re.compile(r"N[AHNT]"), # Medium-risk deamidation | |
| "DeAmdL": re.compile(r"[STK]N"), # Low-risk deamidation | |
| "Ngly": re.compile(r"N[^P][ST]"), # N-glycosylation sites | |
| "Isom": re.compile(r"D[DG HST]".replace(" ", "")), # Isomerization | |
| "Isomer": re.compile(r"DG|DS|DD"), # Isomerization variants | |
| "FragH": re.compile(r"DP"), # High fragmentation risk | |
| "FragM": re.compile(r"TS"), # Medium fragmentation risk | |
| "TrpOx": re.compile(r"W"), # Tryptophan oxidation | |
| "MetOx": re.compile(r"M"), # Methionine oxidation | |
| "Hydro": re.compile(r"NP"), # Hydrolysis prone | |
| "IntBind": re.compile(r"GPR|RGD|RYD|LDV|DGE|KGD|NGR"), # Integrin binding | |
| "Polyreactive": re.compile( | |
| r"GGG|GG|RR|VG|VVV|WWW|YY|WxW" | |
| ), # Polyreactivity | |
| "AggPatch": re.compile(r"FHW"), # Aggregation patches | |
| "ViscPatch": re.compile(r"HYF|HWH"), # Viscosity patches | |
| "DeAmdH": re.compile(r"N[GS]"), | |
| "DeAmdM": re.compile(r"N[AHNT]"), | |
| "DeAmdL": re.compile(r"[STK]N"), | |
| "Ngly": re.compile(r"N[^P][ST]"), | |
| "Isom": re.compile(r"D[DG HST]".replace(" ", "")), | |
| "Isomer": re.compile(r"DG|DS|DD"), | |
| "FragH": re.compile(r"DP"), | |
| "FragM": re.compile(r"TS"), | |
| "TrpOx": re.compile(r"W"), | |
| "MetOx": re.compile(r"M"), | |
| "Hydro": re.compile(r"NP"), | |
| "IntBind": re.compile(r"GPR|RGD|RYD|LDV|DGE|KGD|NGR"), | |
| "Polyreactive": re.compile(r"GGG|GG|RR|VG|VVV|WWW|YY|WxW"), | |
| "AggPatch": re.compile(r"FHW"), | |
| "ViscPatch": re.compile(r"HYF|HWH"), | |
| } | |
| elif modality == "peptide": | |
| motifs = { | |
| "AspBridge": re.compile(r"N[GSQA]"), # Deamidation hotspots | |
| "AspCleave": re.compile(r"D[PGS]"), # Acidic cleavage sites | |
| "NTCycl": re.compile(r"^[QN]"), # N-terminal cyclization | |
| "ProtTryp": re.compile(r"[KR](?=.)"), # Trypsin cleavage sites | |
| "DPP4": re.compile(r"^[PX]?[AP]"), # DPP4 cleavage sites | |
| "MetOx": re.compile(r"M"), # Methionine oxidation | |
| "TrpOx": re.compile(r"W"), # Tryptophan oxidation | |
| "HydroPatch": re.compile(r"[FILVWY]{3,}"), # Hydrophobic patches | |
| } | |
| if peptide_type == "cyclic": | |
| # remove N-term liabilities for cyclic peptides | |
| motifs.pop("NTCycl", None) | |
| motifs.pop("DPP4", None) | |
| elif peptide_type == "linear": | |
| # For linear peptides, we handle cysteine pairing separately | |
| # so we don't include CysOx in the motif set | |
| pass | |
| return motifs | |
| else: | |
| raise ValueError(f"Unknown modality: {modality}") | |
| def severity_score(name): | |
| return const.liability_severity.get(name, const.default_severity) | |
| def compute_liability_scores(sequences, modality="antibody", peptide_type="linear"): | |
| """ | |
| Compute liability scores for given sequences. | |
| modality: 'antibody' or 'peptide'; peptide_type: 'linear' or 'cyclic'. | |
| For cyclic peptides, terminal CysOx flags are skipped. | |
| Returns: | |
| dict: sequence -> {'score': int, 'violations': list of dicts} | |
| """ | |
| motifs = get_motif_set(modality, peptide_type) | |
| results = {} | |
| for seq in sequences: | |
| violations = [] | |
| total_score = 0 | |
| length = len(seq) | |
| # motif scanning | |
| for name, pat in motifs.items(): | |
| for m in pat.finditer(seq): | |
| pos = m.start() + 1 | |
| # skip terminal cysteines for cyclic peptides | |
| if ( | |
| modality == "peptide" | |
| and peptide_type == "cyclic" | |
| and name == "CysOx" | |
| and pos in (1, length) | |
| ): | |
| continue | |
| sev = severity_score(name) | |
| violations.append( | |
| {"motif": name, "pos": pos, "len": len(m.group()), "severity": sev} | |
| ) | |
| total_score += sev | |
| # antibody-specific extras | |
| if modality == "antibody": | |
| # unpaired cysteines | |
| cpos = [i for i, aa in enumerate(seq) if aa == "C"] | |
| paired = set() | |
| for i in range(len(cpos) - 1): | |
| if abs(cpos[i + 1] - cpos[i]) in (1, 2): | |
| paired.update({cpos[i], cpos[i + 1]}) | |
| for i in cpos: | |
| if i not in paired: | |
| sev = severity_score("UnpairedCys") | |
| violations.append( | |
| { | |
| "motif": "UnpairedCys", | |
| "pos": i + 1, | |
| "len": 1, | |
| "severity": sev, | |
| } | |
| ) | |
| total_score += sev | |
| # net charge | |
| charge = seq.count("K") + seq.count("R") - seq.count("D") - seq.count("E") | |
| if charge > 1: | |
| sev = const.default_severity | |
| violations.append( | |
| {"motif": "HighNetCharge", "pos": None, "len": 0, "severity": sev} | |
| ) | |
| total_score += sev | |
| # peptide-specific extras | |
| elif modality == "peptide": | |
| # For linear peptides, only flag unpaired cysteines (odd number of cysteines) | |
| # For cyclic peptides, terminal cysteines are expected, so only flag internal unpaired cysteines | |
| cpos = [i for i, aa in enumerate(seq) if aa == "C"] | |
| if peptide_type == "linear": | |
| # For linear peptides, if there's an odd number of cysteines, flag all cysteines as potential liabilities | |
| # since we don't know which one is unpaired | |
| if len(cpos) % 2 == 1: | |
| sev = severity_score("UnpairedCys") | |
| for cys_pos in cpos: | |
| violations.append( | |
| { | |
| "motif": "UnpairedCys", | |
| "pos": cys_pos + 1, | |
| "len": 1, | |
| "severity": sev, | |
| } | |
| ) | |
| total_score += sev | |
| elif peptide_type == "cyclic": | |
| # For cyclic peptides, terminal cysteines are expected for cyclization | |
| # Only flag internal unpaired cysteines | |
| internal_cpos = [ | |
| i for i in cpos if i != 0 and i != len(seq) - 1 | |
| ] # exclude terminal positions | |
| if len(internal_cpos) % 2 == 1: | |
| # Flag all internal cysteines as potential liabilities | |
| sev = severity_score("UnpairedCys") | |
| for cys_pos in internal_cpos: | |
| violations.append( | |
| { | |
| "motif": "UnpairedCys", | |
| "pos": cys_pos + 1, | |
| "len": 1, | |
| "severity": sev, | |
| } | |
| ) | |
| total_score += sev | |
| # Additional liability checks for cyclic peptides | |
| # 1. Check for low hydrophilic content (< 40%) | |
| hydrophilic_residues = ( | |
| seq.count("D") | |
| + seq.count("E") | |
| + seq.count("K") | |
| + seq.count("R") | |
| + seq.count("H") | |
| + seq.count("N") | |
| + seq.count("Q") | |
| + seq.count("S") | |
| + seq.count("T") | |
| ) | |
| hydrophilic_percentage = (hydrophilic_residues / len(seq)) * 100 | |
| if hydrophilic_percentage < 40: | |
| sev = severity_score("LowHydrophilic") | |
| violations.append( | |
| { | |
| "motif": "LowHydrophilic", | |
| "pos": None, | |
| "len": 0, | |
| "severity": sev, | |
| "details": f"{hydrophilic_percentage:.1f}% hydrophilic", | |
| } | |
| ) | |
| total_score += sev | |
| # 2. Check for consecutive identical residues | |
| max_consec_identical = 1 | |
| current_consec = 1 | |
| for i in range(1, len(seq)): | |
| if seq[i] == seq[i - 1]: | |
| current_consec += 1 | |
| max_consec_identical = max(max_consec_identical, current_consec) | |
| else: | |
| current_consec = 1 | |
| if max_consec_identical > 1: | |
| sev = severity_score("ConsecIdentical") | |
| violations.append( | |
| { | |
| "motif": "ConsecIdentical", | |
| "pos": None, | |
| "len": 0, | |
| "severity": sev, | |
| "details": f"{max_consec_identical} consecutive identical", | |
| } | |
| ) | |
| total_score += sev | |
| # 3. Check for more than 4 consecutive hydrophobic residues | |
| max_consec_hydrophobic = 0 | |
| current_consec = 0 | |
| for aa in seq: | |
| if aa in "FILVWY": | |
| current_consec += 1 | |
| max_consec_hydrophobic = max( | |
| max_consec_hydrophobic, current_consec | |
| ) | |
| else: | |
| current_consec = 0 | |
| if max_consec_hydrophobic > 4: | |
| sev = severity_score("LongHydrophobic") | |
| violations.append( | |
| { | |
| "motif": "LongHydrophobic", | |
| "pos": None, | |
| "len": 0, | |
| "severity": sev, | |
| "details": f"{max_consec_hydrophobic} consecutive hydrophobic", | |
| } | |
| ) | |
| total_score += sev | |
| results[seq] = {"score": total_score, "violations": violations} | |
| return results | |
| def compute_liability_metrics(sequence, liability_modality, liability_peptide_type): | |
| metrics = {} | |
| # check if sequence is valid | |
| if not sequence or len(sequence) == 0: | |
| raise ValueError(f"Sequence is empty: '{sequence}'") | |
| liability_results = compute_liability_scores( | |
| [sequence], | |
| modality=liability_modality, | |
| peptide_type=liability_peptide_type, | |
| ) | |
| liability_data = liability_results[sequence] | |
| # Store liability metrics | |
| metrics["liability_score"] = liability_data["score"] | |
| metrics["liability_num_violations"] = len(liability_data["violations"]) | |
| # Count violations by severity | |
| high_severity_violations = [ | |
| v for v in liability_data["violations"] if v["severity"] >= 10 | |
| ] | |
| medium_severity_violations = [ | |
| v for v in liability_data["violations"] if 5 <= v["severity"] < 10 | |
| ] | |
| low_severity_violations = [ | |
| v for v in liability_data["violations"] if v["severity"] < 5 | |
| ] | |
| metrics["liability_high_severity_violations"] = len(high_severity_violations) | |
| metrics["liability_medium_severity_violations"] = len(medium_severity_violations) | |
| metrics["liability_low_severity_violations"] = len(low_severity_violations) | |
| # Count violations by type | |
| violation_counts = {} | |
| for v in liability_data["violations"]: | |
| motif = v["motif"] | |
| violation_counts[motif] = violation_counts.get(motif, 0) + 1 | |
| # Store individual violation type counts as metrics | |
| for motif, count in violation_counts.items(): | |
| metrics[f"liability_{motif}_count"] = count | |
| # Add detailed violation information | |
| # Group violations by type for intelligent reporting | |
| violations_by_type = {} | |
| for v in liability_data["violations"]: | |
| motif = v["motif"] | |
| if motif not in violations_by_type: | |
| violations_by_type[motif] = [] | |
| violations_by_type[motif].append(v) | |
| # Initialize default values for all motifs to ensure consistent dataframe columns | |
| # Use the full motif set for the configured modality/peptide_type so columns are consistent | |
| all_motifs = set( | |
| get_motif_set( | |
| modality=liability_modality, | |
| peptide_type=liability_peptide_type, | |
| ).keys() | |
| ) | |
| for motif in all_motifs: | |
| # Initialize all possible fields with default values | |
| metrics[f"liability_{motif}_count"] = 0 | |
| metrics[ | |
| f"liability_{motif}_position" | |
| ] = -1 # use -1 for no position (keeps int dtype) | |
| metrics[f"liability_{motif}_length"] = 0 | |
| metrics[f"liability_{motif}_severity"] = 0 | |
| metrics[f"liability_{motif}_details"] = "" | |
| metrics[f"liability_{motif}_positions"] = "" | |
| metrics[f"liability_{motif}_num_positions"] = 0 | |
| metrics[f"liability_{motif}_global_details"] = "" | |
| metrics[f"liability_{motif}_avg_severity"] = 0.0 | |
| # Store detailed violation information | |
| for motif, motif_violations in violations_by_type.items(): | |
| if len(motif_violations) == 1: | |
| # Single violation - store all details | |
| v = motif_violations[0] | |
| # Ensure position is an integer; use -1 for non-positional violations | |
| metrics[f"liability_{motif}_position"] = ( | |
| int(v["pos"]) if v["pos"] is not None else -1 | |
| ) | |
| metrics[f"liability_{motif}_length"] = v["len"] | |
| metrics[f"liability_{motif}_severity"] = v["severity"] | |
| if "details" in v: | |
| metrics[f"liability_{motif}_details"] = v["details"] | |
| else: | |
| metrics[f"liability_{motif}_details"] = "" | |
| else: | |
| # Multiple violations - store summary information | |
| positions = [v["pos"] for v in motif_violations if v["pos"] is not None] | |
| global_violations = [v for v in motif_violations if v["pos"] is None] | |
| if positions: | |
| # Store position range for positional violations | |
| metrics[f"liability_{motif}_positions"] = ( | |
| f"{min(positions)}-{max(positions)}" | |
| ) | |
| metrics[f"liability_{motif}_num_positions"] = len(positions) | |
| else: | |
| metrics[f"liability_{motif}_positions"] = "" | |
| metrics[f"liability_{motif}_num_positions"] = 0 | |
| if global_violations: | |
| # Store details for global violations | |
| details = [ | |
| v.get("details", "") for v in global_violations if v.get("details") | |
| ] | |
| if details: | |
| metrics[f"liability_{motif}_global_details"] = "; ".join(details) | |
| else: | |
| metrics[f"liability_{motif}_global_details"] = "" | |
| else: | |
| metrics[f"liability_{motif}_global_details"] = "" | |
| # Store average severity | |
| avg_severity = sum(v["severity"] for v in motif_violations) / len( | |
| motif_violations | |
| ) | |
| metrics[f"liability_{motif}_avg_severity"] = round(avg_severity, 1) | |
| # Add a comprehensive violation summary for easy interpretation | |
| violation_summary = [] | |
| for motif, motif_violations in violations_by_type.items(): | |
| count = len(motif_violations) | |
| if count == 1: | |
| v = motif_violations[0] | |
| if v["pos"] is not None: | |
| violation_summary.append(f"{motif}(pos{v['pos']},sev{v['severity']})") | |
| else: | |
| details = v.get("details", "") | |
| violation_summary.append( | |
| f"{motif}({details},sev{v['severity']})" | |
| if details | |
| else f"{motif}(sev{v['severity']})" | |
| ) | |
| else: | |
| positions = [v["pos"] for v in motif_violations if v["pos"] is not None] | |
| if positions: | |
| violation_summary.append( | |
| f"{motif}x{count}(pos{min(positions)}-{max(positions)},sev{motif_violations[0]['severity']})" | |
| ) | |
| else: | |
| violation_summary.append( | |
| f"{motif}x{count}(sev{motif_violations[0]['severity']})" | |
| ) | |
| metrics["liability_violations_summary"] = ( | |
| "; ".join(violation_summary) if violation_summary else "" | |
| ) | |
| return metrics | |