Skip to content

Analytics Module

Analysis and visualization tools.

analytics

Typed interaction fingerprints

reference_ifp

Typed extraction and comparison of protein--ligand interaction fingerprints.

AtomIdentity dataclass

Stable atom identity reported for one typed interaction occurrence.

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True)
class AtomIdentity:
    """Stable atom identity reported for one typed interaction occurrence."""

    chain_id: str
    residue_number: int
    insertion_code: str
    residue_name: str | None
    atom_name: str
    element: str | None = None
    atom_serial: int | None = None
    source_index: int | None = None

    def to_dict(self) -> dict[str, object]:
        return {
            "chain_id": self.chain_id,
            "residue_number": self.residue_number,
            "insertion_code": self.insertion_code,
            "residue_name": self.residue_name,
            "atom_name": self.atom_name,
            "element": self.element,
            "atom_serial": self.atom_serial,
            "source_index": self.source_index,
        }

InteractionGeometry dataclass

Named ProLIF geometry value with an explicit physical unit.

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True)
class InteractionGeometry:
    """Named ProLIF geometry value with an explicit physical unit."""

    name: str
    value: float
    unit: str

    def __post_init__(self) -> None:
        if not self.name or not math.isfinite(self.value):
            raise ReferenceIFPInputError(
                "Interaction geometry must be named and finite."
            )

    def to_dict(self) -> dict[str, object]:
        return {"name": self.name, "value": self.value, "unit": self.unit}

InteractionEvent dataclass

One atom-level occurrence contributing to a deduplicated interaction key.

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True)
class InteractionEvent:
    """One atom-level occurrence contributing to a deduplicated interaction key."""

    interaction: InteractionKey
    ligand_atoms: tuple[AtomIdentity, ...]
    protein_atoms: tuple[AtomIdentity, ...]
    ligand_role: str
    protein_role: str
    geometry: tuple[InteractionGeometry, ...] = ()

    def to_dict(self) -> dict[str, object]:
        return {
            "interaction_key": str(self.interaction),
            "interaction_type": self.interaction.interaction_type,
            "ligand_role": self.ligand_role,
            "protein_role": self.protein_role,
            "ligand_atoms": [atom.to_dict() for atom in self.ligand_atoms],
            "protein_atoms": [atom.to_dict() for atom in self.protein_atoms],
            "geometry": [measurement.to_dict() for measurement in self.geometry],
        }

InteractionFingerprint dataclass

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True)
class InteractionFingerprint:
    taxonomy: IFPTaxonomy
    ligand: LigandIdentity
    receptor_chains: tuple[str, ...]
    interactions: frozenset[InteractionKey]
    source_path: Path
    events: tuple[InteractionEvent, ...] = ()

    def serialized_interactions(self) -> list[str]:
        return [str(item) for item in sorted(self.interactions)]

    def serialized_events(self) -> list[dict[str, object]]:
        """Return occurrence-level events in a deterministic JSON-safe form."""

        return [event.to_dict() for event in self.events]
serialized_events
serialized_events() -> list[dict[str, object]]

Return occurrence-level events in a deterministic JSON-safe form.

Source code in src/cofolder/modules/analytics/reference_ifp.py
def serialized_events(self) -> list[dict[str, object]]:
    """Return occurrence-level events in a deterministic JSON-safe form."""

    return [event.to_dict() for event in self.events]

InteractionKey dataclass

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True, order=True)
class InteractionKey:
    receptor: ResidueIdentity
    interaction_type: str

    def __str__(self) -> str:
        suffix = f"{self.receptor.residue_number}{self.receptor.insertion_code}"
        return f"{self.receptor.chain_id}:{suffix}:{self.interaction_type}"

    @classmethod
    def parse(cls, value: str) -> InteractionKey:
        try:
            chain_id, residue_token, interaction_type = str(value).strip().split(":", 2)
        except ValueError as exc:
            raise ReferenceIFPInputError(
                f"Invalid interaction key {value!r}; expected CHAIN:RESNUM[ICODE]:TYPE."
            ) from exc
        index = 0
        if residue_token.startswith("-"):
            index = 1
        while index < len(residue_token) and residue_token[index].isdigit():
            index += 1
        number_token = residue_token[:index]
        insertion_code = residue_token[index:]
        if (
            not chain_id
            or not number_token
            or len(insertion_code) > 1
            or not interaction_type
        ):
            raise ReferenceIFPInputError(
                f"Invalid interaction key {value!r}; expected CHAIN:RESNUM[ICODE]:TYPE."
            )
        return cls(
            receptor=ResidueIdentity(chain_id, int(number_token), insertion_code),
            interaction_type=_normalize_interaction_type(interaction_type),
        )

IFPExtractionConfig dataclass

Source code in src/cofolder/modules/analytics/reference_ifp.py
@dataclass(frozen=True, slots=True)
class IFPExtractionConfig:
    taxonomy: IFPTaxonomy = IFPTaxonomy.DISTANCE
    distance_cutoff_angstrom: float = 5.0
    prolif_interactions: tuple[str, ...] = DEFAULT_PROLIF_INTERACTIONS
    prolif_timeout_seconds: float = 120.0

    def __post_init__(self) -> None:
        object.__setattr__(self, "taxonomy", IFPTaxonomy(self.taxonomy))
        if (
            not math.isfinite(self.distance_cutoff_angstrom)
            or self.distance_cutoff_angstrom <= 0
        ):
            raise ReferenceIFPInputError(
                "distance_cutoff_angstrom must be positive and finite."
            )
        if (
            not math.isfinite(self.prolif_timeout_seconds)
            or self.prolif_timeout_seconds <= 0
        ):
            raise ReferenceIFPInputError(
                "prolif_timeout_seconds must be positive and finite."
            )
        unknown = set(self.prolif_interactions) - set(DEFAULT_PROLIF_INTERACTIONS)
        if unknown:
            raise ReferenceIFPInputError(
                f"Unsupported ProLIF interactions: {sorted(unknown)}"
            )

extract_interaction_fingerprint

extract_interaction_fingerprint(
    structure_path: Path,
    *,
    ligand: LigandSelector | None = None,
    receptor_chains: Sequence[str] | None = None,
    config: IFPExtractionConfig = DEFAULT_IFP_EXTRACTION_CONFIG
) -> InteractionFingerprint

Extract a normalized fingerprint from a PDB or mmCIF complex.

Source code in src/cofolder/modules/analytics/reference_ifp.py
def extract_interaction_fingerprint(
    structure_path: Path,
    *,
    ligand: LigandSelector | None = None,
    receptor_chains: Sequence[str] | None = None,
    config: IFPExtractionConfig = DEFAULT_IFP_EXTRACTION_CONFIG,
) -> InteractionFingerprint:
    """Extract a normalized fingerprint from a PDB or mmCIF complex."""

    path = Path(structure_path)
    if config.taxonomy is IFPTaxonomy.PROLIF:
        return _extract_prolif(path, ligand, receptor_chains, config)

    structure = _load_structure(path)
    model = next(iter(structure), None)
    if model is None:
        raise ReferenceIFPExtractionError(f"Structure contains no model: {path}")
    ligand_chain, ligand_residue = _select_ligand(model, ligand)
    proteins = _protein_residues(model, receptor_chains, str(ligand_chain.id))
    ligand_atoms = [
        atom
        for atom in ligand_residue
        if str(getattr(atom, "element", "")).strip().upper() != "H"
    ]
    if not ligand_atoms:
        raise ReferenceIFPExtractionError("selected_ligand_has_no_heavy_atoms")
    cutoff_squared = config.distance_cutoff_angstrom**2
    interactions: set[InteractionKey] = set()
    for chain_id, residues in proteins.items():
        for residue in residues:
            contact = any(
                float(((protein_atom.coord - ligand_atom.coord) ** 2).sum())
                <= cutoff_squared
                for protein_atom in residue
                if str(getattr(protein_atom, "element", "")).strip().upper() != "H"
                for ligand_atom in ligand_atoms
            )
            if contact:
                interactions.add(
                    InteractionKey(
                        _residue_identity(chain_id, residue), "distance_contact"
                    )
                )
    return InteractionFingerprint(
        taxonomy=IFPTaxonomy.DISTANCE,
        ligand=LigandIdentity(
            str(ligand_chain.id),
            int(ligand_residue.id[1]),
            str(ligand_residue.id[2]).strip(),
            str(ligand_residue.get_resname()).strip().upper() or None,
        ),
        receptor_chains=tuple(sorted(proteins)),
        interactions=frozenset(interactions),
        source_path=path,
    )

InteractionFingerprint.interactions is the deduplicated residue/type feature set. InteractionFingerprint.events contains every atom-level ProLIF occurrence. Event geometry uses Å for distances and degrees for angles. The extractor accepts PDB and mmCIF paths; mmCIF conversion remains contained in the isolated ProLIF worker.

from pathlib import Path

from cofolder.modules.analytics.reference_ifp import (
    IFPExtractionConfig,
    IFPTaxonomy,
    LigandSelector,
    extract_interaction_fingerprint,
)

fingerprint = extract_interaction_fingerprint(
    Path("prediction.cif"),
    ligand=LigandSelector(chain_id="B"),
    receptor_chains=("A",),
    config=IFPExtractionConfig(taxonomy=IFPTaxonomy.PROLIF),
)
asp168_backbone_n = [
    event
    for event in fingerprint.events
    if any(
        atom.residue_name == "ASP"
        and atom.residue_number == 168
        and atom.atom_name == "N"
        for atom in event.protein_atoms
    )
]

Interaction-fingerprint clustering

ifp_clustering

Deterministic clustering and summaries for binary interaction fingerprints.

IFPClusteringResult dataclass

Stable cluster assignments and their audit-ready summary.

Source code in src/cofolder/modules/analytics/ifp_clustering.py
@dataclass(frozen=True)
class IFPClusteringResult:
    """Stable cluster assignments and their audit-ready summary."""

    cluster_ids: list[str]
    summary: pd.DataFrame
    member_ids: tuple[str, ...]
    linkage_matrix: tuple[tuple[float, float, float, float], ...]
    leaf_indices: tuple[int, ...]
    leaf_member_ids: tuple[str, ...]

cluster_binary_ifps

cluster_binary_ifps(
    fingerprints: Sequence[Sequence[int]],
    member_ids: Sequence[str],
    *,
    similarity_threshold: float
) -> IFPClusteringResult

Cluster compatible binary IFPs using average-linkage Jaccard distance.

Cluster IDs are normalized by the earliest input position, so callers never observe the implementation-specific integer labels returned by SciPy.

Source code in src/cofolder/modules/analytics/ifp_clustering.py
def cluster_binary_ifps(
    fingerprints: Sequence[Sequence[int]],
    member_ids: Sequence[str],
    *,
    similarity_threshold: float,
) -> IFPClusteringResult:
    """Cluster compatible binary IFPs using average-linkage Jaccard distance.

    Cluster IDs are normalized by the earliest input position, so callers never
    observe the implementation-specific integer labels returned by SciPy.
    """

    if len(fingerprints) != len(member_ids):
        raise ValueError("fingerprints and member_ids must have equal length")
    member_ids_as_text = tuple(str(value) for value in member_ids)
    if any(not value.strip() for value in member_ids_as_text):
        raise ValueError("member_ids must be non-empty")
    if len(set(member_ids_as_text)) != len(member_ids_as_text):
        raise ValueError("member_ids must be unique")
    if not fingerprints:
        return IFPClusteringResult(
            [], pd.DataFrame(columns=SUMMARY_COLUMNS), (), (), (), ()
        )

    width = len(fingerprints[0])
    if width == 0 or any(len(value) != width for value in fingerprints):
        raise ValueError("fingerprints must be non-empty and have equal lengths")
    features = np.asarray(fingerprints, dtype=np.bool_)
    if features.ndim != 2 or features.shape != (len(fingerprints), width):
        raise ValueError("fingerprints must form a two-dimensional binary matrix")

    hierarchy = require_analysis_dependency(
        "scipy.cluster.hierarchy",
        feature="Interaction-fingerprint clustering",
        dependency_name="scipy",
    )
    distance = require_analysis_dependency(
        "scipy.spatial.distance",
        feature="Interaction-fingerprint clustering",
        dependency_name="scipy",
    )

    if len(features) == 1:
        raw_labels = np.array([1], dtype=int)
        tree = np.empty((0, 4), dtype=float)
        leaf_indices = (0,)
    else:
        distances = distance.pdist(features, metric="jaccard")
        # Explicitly define the empty-set Jaccard distance as zero. This keeps
        # all-zero fingerprints as one reproducible "no contacts" pattern.
        distances = np.nan_to_num(distances, nan=0.0)
        tree = hierarchy.linkage(distances, method="average", optimal_ordering=False)
        leaf_indices = tuple(int(value) for value in hierarchy.leaves_list(tree))
        raw_labels = hierarchy.fcluster(
            tree,
            t=1.0 - float(similarity_threshold),
            criterion="distance",
        ).astype(int)

    ordered_raw_labels = sorted(
        np.unique(raw_labels),
        key=lambda label: int(np.flatnonzero(raw_labels == label)[0]),
    )
    stable_names = {
        label: f"IFP{position:03d}"
        for position, label in enumerate(ordered_raw_labels, start=1)
    }
    cluster_ids = [stable_names[label] for label in raw_labels]

    rows: list[dict[str, object]] = []
    for raw_label in ordered_raw_labels:
        positions = np.flatnonzero(raw_labels == raw_label)
        cluster_features = features[positions]
        cluster_member_ids = [member_ids_as_text[position] for position in positions]
        medoid_offset, mean_similarity = _cluster_medoid_and_mean_similarity(
            cluster_features
        )
        consensus = (cluster_features.mean(axis=0) >= 0.5).astype(int).tolist()
        rows.append(
            {
                "ifp_cluster_id": stable_names[raw_label],
                "size": len(positions),
                "member_ids": json.dumps(cluster_member_ids),
                "medoid_compound_id": cluster_member_ids[medoid_offset],
                "consensus_ifp": json.dumps(consensus),
                "mean_within_cluster_jaccard_similarity": mean_similarity,
            }
        )

    return IFPClusteringResult(
        cluster_ids=cluster_ids,
        summary=pd.DataFrame(rows, columns=SUMMARY_COLUMNS),
        member_ids=member_ids_as_text,
        linkage_matrix=tuple(
            tuple(float(value) for value in row) for row in tree.tolist()
        ),
        leaf_indices=leaf_indices,
        leaf_member_ids=tuple(member_ids_as_text[index] for index in leaf_indices),
    )

cluster_interaction_fingerprints

cluster_interaction_fingerprints(
    fingerprints: Sequence[InteractionFingerprint],
    member_ids: Sequence[str],
    *,
    similarity_threshold: float,
    feature_universe: Sequence[InteractionKey] | None = None
) -> IFPClusteringResult

Cluster typed fingerprints through their deterministic binary matrix.

Source code in src/cofolder/modules/analytics/ifp_clustering.py
def cluster_interaction_fingerprints(
    fingerprints: Sequence[InteractionFingerprint],
    member_ids: Sequence[str],
    *,
    similarity_threshold: float,
    feature_universe: Sequence[InteractionKey] | None = None,
) -> IFPClusteringResult:
    """Cluster typed fingerprints through their deterministic binary matrix."""

    vectorized = vectorize_interaction_fingerprints(
        fingerprints, feature_universe=feature_universe
    )
    return cluster_binary_ifps(
        vectorized.vectors, member_ids, similarity_threshold=similarity_threshold
    )