import itertools
from collections import defaultdict
from collections.abc import Sequence
from enum import StrEnum
from functools import partial
from typing import ClassVar, Literal
import biotite.structure as struc
import numpy as np
from atomworks.enums import ChainType, ChainTypeInfo
from atomworks.io.utils.annotator import ensure_annotations
from atomworks.io.utils.atom_array import apply_and_spread
from atomworks.io.utils.atom_array_plus import as_atom_array_plus
from atomworks.io.utils.scatter import (
apply_segment_wise,
spread_segment_wise,
)
from atomworks.io.utils.standard_annotations import S_ATM
from atomworks.ml.conditions import (
C_CRD,
C_IDX,
C_SEQ,
)
from atomworks.ml.transforms import mask_generator as mg
from atomworks.ml.transforms.crop import resize_crop_info_if_too_many_atoms
from atomworks.ml.transforms.design_task import DesignTaskABC
from atomworks.ml.transforms.sasa import calculate_atomwise_rasa
from atomworks.ml.utils.numpy import unique_by_first_occurrence
from atomworks.ml.utils.token import apply_and_spread_token_wise, get_token_count, get_token_starts
from biotite.structure import AtomArray
from scipy.spatial import ConvexHull
from scipy.special import softmax
from rfproteina.conditions import C_CRDGRP, C_HOT, C_IDXGRP
from rfproteina.transforms.design_task_modifiers import (
add_cath_condition,
add_hbond_condition,
add_hydrophobic_condition,
add_metal_condition,
add_pication_condition,
add_pistack_condition,
add_rasa_condition,
add_salt_condition,
add_secondary_structure_condition,
diversify_sequence_conditioning,
)
from rfproteina.transforms.na_geom import apply_training_na_ss_condition
from rfproteina.transforms.utils.conditioning_utils import (
convert_to_internal_coords,
sample_unindexed_groups,
)
from rfproteina.transforms.utils.ppi_utils import (
crop_spatial_keep_full_binder,
sample_hotspots,
)
from rfproteina.utils.common import true_with_prob
from rfproteina.utils.ddp import RankedLogger
# Default potential functions for TipAtomPartialLigandTask (module-level for picklability)
def _default_ligand_potential(x: np.ndarray, scaling_factor: float = 5.0) -> np.ndarray:
"""Default ligand sampling potential: inverse distance with offset."""
return 1.0 / (scaling_factor + x)
def _default_tip_atom_potential(x: np.ndarray, scaling_factor: float = 8.0) -> np.ndarray:
"""Default tip atom sampling potential: exponential decay."""
return np.exp(-x / scaling_factor)
ranked_logger = RankedLogger(__name__, rank_zero_only=True)
def _get_pn_unit_chain_type(atom_array: AtomArray, pn_unit_iid: str) -> ChainType | None:
"""Return the pn_unit chain's :py:class:`ChainType`, or ``None`` if absent or mixed."""
mask = atom_array.pn_unit_iid == pn_unit_iid
if not mask.any():
return None
chain_types = np.unique(atom_array.chain_type[mask])
if len(chain_types) != 1:
return None
return ChainType(int(chain_types[0]))
DESIGN_TASK_DEFAULTS = {
"rasa_kwargs": {
"p_add_rasa_condition": 0.0,
"probe_radius": 1.4,
"atom_radii": "element",
"point_number": 100,
"p_include_non_ligand": 0.1,
"rasa_max": 0.1,
},
"hbond_kwargs": {
"p_add_hbond_condition": 0.0,
"p_hbond_subsample": 0.5,
"hbond_subsample_frac": 0.5,
},
"pistack_kwargs": {
"p_add_pistack_condition": 0.0,
"p_pistack_subsample": 0.5,
"pistack_subsample_frac": 0.5,
},
"pication_kwargs": {
"p_add_pication_condition": 0.0,
"p_pication_subsample": 0.5,
"pication_subsample_frac": 0.5,
},
"salt_kwargs": {
"p_add_salt_condition": 0.0,
"p_salt_subsample": 0.5,
"salt_subsample_frac": 0.5,
},
"hydrophobic_kwargs": {
"p_add_hydrophobic_condition": 0.0,
"p_hydrophobic_subsample": 0.5,
"hydrophobic_subsample_frac": 0.5,
},
"metal_kwargs": {
"p_add_metal_condition": 0.0,
"p_metal_subsample": 0.5,
"metal_subsample_frac": 0.5,
},
"secondary_structure_kwargs": {
"p_add_secondary_structure_condition": 0.0,
"max_secondary_structure_frac_to_provide": 0.4,
"min_ss_island_len": 1,
# Might want to expand later, this is only average. Done for now to avoid over-conditioning.
"max_ss_island_len": 10,
"n_islands_max": 3,
},
"cath_kwargs": {
"p_add_cath_c_condition": 0.0,
"p_add_cath_ca_condition": 0.0,
"p_add_cath_cat_condition": 0.0,
},
"sequence_probability_kwargs": {
"p_exact": 1.0,
"p_zero_only": 0.0,
"p_positive_only": 0.0,
"p_zero_and_positive": 0.0,
"dirichlet_concentration": 3.0,
"exclusion_count_alpha": 2.0,
"p_full_probability_vector": 0.75,
"p_augment_unconditioned": 0.0,
"min_augmentation_fraction": 0.05,
"max_augmentation_fraction": 0.2,
"p_zero_only_whole_chain": 0.0,
},
}
[docs]
class ConditionedDesignTask(DesignTaskABC):
"""Base for tasks with shared conditioning modifiers.
Each modifier has a flat parameter dictionary. Constructor parameters take
precedence over global parameters and shared defaults, in that order.
Task-specific conditioning rates are configured in the design-task presets.
"""
def __init__(self, global_design_task_kwargs: dict | None = None, **kwargs):
super().__init__()
global_design_task_kwargs = global_design_task_kwargs or {}
for key, defaults in DESIGN_TASK_DEFAULTS.items():
params = {
**defaults,
**global_design_task_kwargs.get(key, {}),
**kwargs.get(key, {}),
}
setattr(self, key, params)
[docs]
def apply_design_task_modifiers(self, data: dict, *, cath_kwargs: dict | None = None) -> dict:
"""Apply configured modifiers with optional per-sample CATH parameters."""
# Set defaults for task dictionary
data["task"] |= {
"added_rasa_condition": False,
"added_hbond_condition": False,
"added_pistack_condition": False,
"added_pication_condition": False,
"added_salt_condition": False,
"added_hydrophobic_condition": False,
"added_metal_condition": False,
"added_secondary_structure_condition": False,
"added_cath_c_condition": False,
"added_cath_ca_condition": False,
"added_cath_cat_condition": False,
}
# Possibly add Conditions corresponding to design task modifiers
data = add_rasa_condition(data, **self.rasa_kwargs)
data = add_secondary_structure_condition(data, **self.secondary_structure_kwargs)
data = add_hbond_condition(data, **self.hbond_kwargs)
data = add_pistack_condition(data, **self.pistack_kwargs)
data = add_pication_condition(data, **self.pication_kwargs)
data = add_salt_condition(data, **self.salt_kwargs)
data = add_hydrophobic_condition(data, **self.hydrophobic_kwargs)
data = add_metal_condition(data, **self.metal_kwargs)
# Stochasticity and task annotations handled internally for CATH conditioning
data = add_cath_condition(data, **(self.cath_kwargs | (cath_kwargs or {})))
data = diversify_sequence_conditioning(data, **self.sequence_probability_kwargs)
return data
[docs]
def ensure_atomized_tokens_are_sequence_conditioned(self, data: dict) -> dict:
atom_array = data["atom_array"]
if "atomize" in atom_array.get_annotation_categories():
seq_cond_mask = C_SEQ.mask(atom_array, default="generate")
seq_cond_mask |= atom_array.atomize
C_SEQ.set_annotation_from_ground_truth(atom_array, seq_cond_mask)
data["atom_array"] = atom_array
return data
[docs]
class NoMotifABC(ConditionedDesignTask):
[docs]
def can_apply(self, data: dict) -> bool:
return True
[docs]
def set_task_info(self, data: dict) -> dict:
# ... NOTE: `total_mask` is all zeros, because no atom/token will be allowed to get a condition
atom_array = as_atom_array_plus(data["atom_array"])
# ... set the conditions for the task
n_atoms = atom_array.array_length()
# ... update the now condition-annotated atom array & task info
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
# required fields
"class": self.__class__.__name__,
"mask": np.zeros(n_atoms, dtype=bool),
}
return data
[docs]
class UnconditionalTask(NoMotifABC):
"""Sample a protein backbone & sequence unconditionally."""
[docs]
def forward(self, data: dict) -> dict:
data = super().set_task_info(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
data = self.apply_design_task_modifiers(data)
return data
[docs]
class StructurePredictionTask(NoMotifABC):
"""Sample a protein backbone, given the sequence information (= structure prediction)."""
[docs]
def forward(self, data: dict) -> dict:
data = super().set_task_info(data)
# ... show sequence to the model
atom_array = data["atom_array"]
C_SEQ.set_annotation(atom_array, C_SEQ.ground_truth_value(atom_array))
data = self.apply_design_task_modifiers(data)
return data
[docs]
class InverseFoldingTask(NoMotifABC):
"""Design sequence and sidechains/bases from selected polymers' backbone coordinates.
Other molecules stay fully conditioned; at least one designable atom is required.
Args:
polymer_types: Polymer types to design: "protein" (default), "rna", or "dna".
"""
# polymer type -> (per-residue membership annotation, backbone-atom annotation)
_POLYMER_MASK_ANNOT: ClassVar[dict[str, tuple[str, str]]] = {
"protein": ("is_standard_aa", "is_protein_backbone"),
"rna": ("is_rna", "is_rna_backbone"),
"dna": ("is_dna", "is_dna_backbone"),
}
def __init__(self, polymer_types: list[str] | None = None, **kwargs):
super().__init__(**kwargs)
polymer_types = polymer_types or ["protein"]
unknown = set(polymer_types) - set(self._POLYMER_MASK_ANNOT)
assert not unknown, f"unknown polymer_types {unknown}; choose from {list(self._POLYMER_MASK_ANNOT)}"
self.polymer_types = list(polymer_types)
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
res_annots = [self._POLYMER_MASK_ANNOT[p][0] for p in self.polymer_types]
ensure_annotations(atom_array, *res_annots)
return any(getattr(atom_array, annotation).any() for annotation in res_annots)
[docs]
def forward(self, data: dict) -> dict:
data = super().set_task_info(data)
atom_array = data["atom_array"]
res_annots = [self._POLYMER_MASK_ANNOT[p][0] for p in self.polymer_types]
bb_annots = [self._POLYMER_MASK_ANNOT[p][1] for p in self.polymer_types]
ensure_annotations(atom_array, *res_annots, *bb_annots)
# A token is "designed" if it belongs to one of the requested polymer types;
# its backbone atoms are still shown as coordinate context.
designed = np.zeros(atom_array.array_length(), dtype=bool)
backbone = np.zeros(atom_array.array_length(), dtype=bool)
for res_annot, bb_annot in zip(res_annots, bb_annots, strict=True):
designed |= getattr(atom_array, res_annot).astype(bool)
backbone |= getattr(atom_array, bb_annot).astype(bool)
# provide coordinate info for every atom except designed non-backbone atoms
hidden_coordinates = designed & ~backbone
C_CRD.set_annotation_from_ground_truth(atom_array, ~hidden_coordinates)
# provide sequence info for every atom that is not part of a designed polymer
C_SEQ.set_annotation_from_ground_truth(atom_array, ~designed)
data["atom_array"] = atom_array
data = self.apply_design_task_modifiers(data)
return data
[docs]
class ProteinFromDNAContextABC(ConditionedDesignTask):
"""Base for DNA-binder design tasks: the whole protein is generated from
noise while a DNA chain is provided as context.
The DNA is supplied through the ``C_CRD`` / ``C_SEQ`` condition annotations
rather than as a protein motif, so there is no motif region — hence the
all-zeros motif mask stamped by :py:meth:`set_task_info`. This mirrors the
no-motif tasks, but subclassing :py:class:`ConditionedDesignTask` directly keeps
this a distinct setting. Subclasses decide whether the DNA *structure* is
given as context or co-generated.
Design-task modifiers (RASA / H-bond / SSE / CATH conditioning) are left at
the experiment's global rates (``datasets.global_design_task_kwargs``); the
DNA tasks intentionally participate in the same conditioning as the protein
tasks.
"""
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_dna")
return bool(atom_array.is_dna.any())
[docs]
def set_task_info(self, data: dict) -> dict:
"""Stamp task bookkeeping with an all-zeros motif mask (no protein motif)."""
atom_array = as_atom_array_plus(data["atom_array"])
n_atoms = atom_array.array_length()
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": np.zeros(n_atoms, dtype=bool),
}
return data
[docs]
class ProteinFromDNAStructureContextTask(ProteinFromDNAContextABC):
"""Generate the entire protein from noise; DNA structure and sequence are
both provided as context.
"""
[docs]
def forward(self, data: dict) -> dict:
data = super().set_task_info(data)
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_dna")
is_dna = atom_array.is_dna.astype(bool)
# Provide DNA coordinates (skipping NaN coords) and DNA sequence.
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = is_dna & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
C_SEQ.set_annotation_from_ground_truth(atom_array, is_dna)
data["atom_array"] = atom_array
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
data = self.apply_design_task_modifiers(data)
return data
[docs]
class ProteinFromDNASequenceContextTask(ProteinFromDNAContextABC):
"""Generate the entire protein from noise; only the DNA *sequence* is
provided as context — the DNA structure is co-generated.
"""
[docs]
def forward(self, data: dict) -> dict:
data = super().set_task_info(data)
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_dna")
is_dna = atom_array.is_dna.astype(bool)
# Provide DNA sequence only; no coordinate conditioning.
C_SEQ.set_annotation_from_ground_truth(atom_array, is_dna)
data["atom_array"] = atom_array
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
data = self.apply_design_task_modifiers(data)
return data
[docs]
class PartialLigandTask(ConditionedDesignTask):
"""
Intended only for OMOL examples where we have ligands and maybe some tip atoms.
In this case, we always coordinate condition the non-ligands and sequence condition everything.
"""
def __init__(
self,
*,
min_coverage_fraction: float = 0.0,
max_coverage_fraction: float = 1.0,
p_fix_ligand_surroundings: float = 1.0, # on omol we always fix things that aren't ligands
**kwargs,
) -> None:
super().__init__(**kwargs)
self.min_coverage_fraction = min_coverage_fraction
self.max_coverage_fraction = max_coverage_fraction
self.p_fix_ligand_surroundings = p_fix_ligand_surroundings
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_ligand", "is_res_start", "res_min_occupancy", "pn_unit_iid")
n_eligible_ligands = np.sum(atom_array.mask("is_ligand & is_res_start & (res_min_occupancy > 0)"))
return n_eligible_ligands >= 1
[docs]
def sample_ligand_mask(self, atom_array: AtomArray, ligand_mask: np.ndarray) -> np.ndarray:
if not ligand_mask.any():
return np.zeros(atom_array.array_length(), dtype=bool)
sample_seed = mg.SampleUniformly(is_eligible=ligand_mask)
grow_seed = mg.GrowByHoppingAlongBondGraph(
n_hops_expected=1, # Hop one at a time, and rely on check budget to stop correctly
require_same_annotation=("pn_unit_iid",),
atom_array=atom_array,
)
# Check budget based on sampled coverage fraction
n_ligand_atoms = ligand_mask.sum()
coverage_fraction = np.random.uniform(self.min_coverage_fraction, self.max_coverage_fraction)
check_budget = mg.CheckAtomBudget(
n_min_atoms=int(n_ligand_atoms * coverage_fraction), # rounds down
n_max_atoms=n_ligand_atoms,
)
sampled_ligand_mask, _ = mg.sample_mask_via_seed_grow_merge(
atom_array,
fn_sample_seed=sample_seed,
fn_grow_seed=grow_seed,
fn_check_budget=check_budget,
)
return sampled_ligand_mask
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_ligand", "pn_unit_iid", "res_min_occupancy", "is_res_start")
# Loop through available ligands and sample a mask for each
coord_conditioned_mask = np.zeros(atom_array.array_length(), dtype=bool)
ligand_pn_unit_iids = np.unique(atom_array.pn_unit_iid[atom_array.is_ligand])
for ligand_pn_unit_iid in ligand_pn_unit_iids:
ligand_mask = atom_array.pn_unit_iid == ligand_pn_unit_iid
sampled_ligand_mask = self.sample_ligand_mask(atom_array, ligand_mask)
coord_conditioned_mask |= sampled_ligand_mask
sequence_condition_mask = atom_array.is_ligand | atom_array.atomize
# Coord condition everything that is not a ligand
if true_with_prob(self.p_fix_ligand_surroundings):
coord_conditioned_mask |= ~atom_array.is_ligand
sequence_condition_mask |= ~atom_array.is_ligand
# Do not coord condition nan coordinates
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = coord_conditioned_mask & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
C_SEQ.set_annotation_from_ground_truth(atom_array, sequence_condition_mask)
_n_atoms = atom_array.array_length()
C_IDX.set_annotation(atom_array, np.ones(atom_array.array_length(), dtype=bool))
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": coord_conditioned_mask,
}
data = self.apply_design_task_modifiers(data)
return data
[docs]
class TipAtomPartialLigandTask(ConditionedDesignTask):
def __init__(
self,
*,
n_max_ligands: int = 3,
n_hops_expected_ligand: int = 5,
n_max_residues: int = 10,
n_hops_expected_tip_atom: int = 1,
ligand_scaling_factor: float = 5.0,
tip_atom_scaling_factor: float = 1.5,
p_seq_conditioned: float = 1.0,
p_knockout_seq: float = 0.0,
p_unindex_all: float = 0.9,
p_merge_unindexed_groups: float = 0.1,
p_split_unindexed_groups: float = 0.01,
p_increase_group_id_gap: float = 0.1,
p_internal_coords: float = 0.0,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.n_max_ligands = n_max_ligands
self.n_hops_expected_ligand = n_hops_expected_ligand
self.ligand_scaling_factor = ligand_scaling_factor
self.n_max_residues = n_max_residues
self.n_hops_expected_tip_atom = n_hops_expected_tip_atom
self.tip_atom_scaling_factor = tip_atom_scaling_factor
self.p_seq_conditioned = p_seq_conditioned
self.p_knockout_seq = p_knockout_seq
self.p_unindex_all = p_unindex_all
self.p_merge_unindexed_groups = p_merge_unindexed_groups
self.p_split_unindexed_groups = p_split_unindexed_groups
self.p_increase_group_id_gap = p_increase_group_id_gap
self.p_internal_coords = p_internal_coords
if p_seq_conditioned < 1.0 or p_knockout_seq > 0.0:
raise ValueError(
"p_seq_conditioned < 1.0 or p_knockout_seq > 0.0 not currently supported. "
"Model must see sequence conditioning if given a coordinate condition"
)
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
ensure_annotations(
atom_array,
"is_protein",
"is_standard_aa",
"is_res_start",
"res_min_occupancy",
"is_ligand",
"res_has_tip_atom",
)
n_eligible_residues = np.sum(
atom_array.mask("is_standard_aa & is_protein & is_res_start & (res_min_occupancy > 0)")
)
n_eligible_ligands = np.sum(atom_array.mask("is_ligand & is_res_start & (res_min_occupancy > 0)"))
return n_eligible_residues >= 1 and n_eligible_ligands >= 1
[docs]
def sample_mask(self, atom_array: AtomArray) -> tuple[np.ndarray, list[np.ndarray]]:
n_eligible_ligands = atom_array.mask("is_ligand & is_res_start & (res_min_occupancy > 0)").sum()
n_max_ligands = min(n_eligible_ligands, self.n_max_ligands)
n_ligands_to_sample = np.random.randint(1, n_max_ligands + 1)
n_eligible_res = atom_array.mask("res_has_tip_atom & is_res_start & (res_min_occupancy > 0)").sum()
n_max_res = min(n_eligible_res, self.n_max_residues)
n_res_to_sample = np.random.randint(1, n_max_res + 1)
_same_ligand = ("pn_unit_iid",)
fn_sample_seed = mg.SampleWithPotential(
is_eligible="is_ligand & (res_min_occupancy > 0)",
avoid_same=_same_ligand,
potential=partial(_default_ligand_potential, scaling_factor=self.ligand_scaling_factor),
)
fn_grow_seed = mg.GrowByHoppingAlongBondGraph(
n_hops_expected=self.n_hops_expected_ligand,
require_same_annotation=_same_ligand,
atom_array=atom_array,
)
fn_check_budget = mg.CheckResidueBudget(
n_min_residues=n_ligands_to_sample,
n_max_residues=n_ligands_to_sample,
reduce=np.any,
)
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
fn_sample_seed=fn_sample_seed,
fn_grow_seed=fn_grow_seed,
fn_check_budget=fn_check_budget,
)
_same_residue = ("chain_iid", "res_name", "res_id", "transformation_id")
fn_sample_seed = mg.SampleTipAtomWithPotential(
is_eligible="is_tip_atom & (res_min_occupancy > 0)",
avoid_same=_same_residue,
potential=partial(_default_tip_atom_potential, scaling_factor=self.tip_atom_scaling_factor),
)
fn_grow_seed = mg.GrowByHoppingAlongBondGraph(
n_hops_expected=self.n_hops_expected_tip_atom,
require_same_annotation=_same_residue,
atom_array=atom_array,
)
fn_check_budget = mg.CheckResidueBudget(
n_min_residues=n_ligands_to_sample + n_res_to_sample,
n_max_residues=n_ligands_to_sample + n_res_to_sample + 1,
reduce=np.any,
)
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
all_masks=all_masks,
fn_sample_seed=fn_sample_seed,
fn_grow_seed=fn_grow_seed,
fn_check_budget=fn_check_budget,
)
return total_mask, all_masks
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_protein_backbone", "is_nucleic_acid_backbone")
total_mask, all_masks = self.sample_mask(atom_array)
n_atoms = atom_array.array_length()
_true = np.ones(n_atoms, dtype=bool)
# Mark the protein-side conditioning region (tip-atom seed + bond-hop growth).
atom_array.set_annotation("is_sampled_tip_atom", total_mask & atom_array.is_protein)
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = total_mask & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
# Possibly convert some absolute coordinate conditioning to internal coordinate conditioning.
# Sample the number of masks geometrically based on p_internal_coords.
if self.p_internal_coords > 0 and len(all_masks) > 0:
if self.p_internal_coords >= 1.0:
n_to_convert = len(all_masks)
else:
n_to_convert = min(
np.random.geometric(1 - self.p_internal_coords),
len(all_masks),
)
selected = np.random.choice(len(all_masks), size=n_to_convert, replace=False)
for idx in selected:
atom_array = convert_to_internal_coords(atom_array, all_masks[idx])
is_seq_conditioned = C_SEQ.mask(atom_array, default="generate")
if true_with_prob(self.p_seq_conditioned):
res_segments = struc.get_residue_starts(atom_array, add_exclusive_stop=True)
is_res_seq_conditioned = apply_segment_wise(res_segments, total_mask, np.any)
n_eligible_res = is_res_seq_conditioned.sum()
show_sequence = true_with_prob([1.0 - self.p_knockout_seq] * n_eligible_res)
is_res_seq_conditioned[is_res_seq_conditioned] = show_sequence
is_seq_conditioned = spread_segment_wise(res_segments, is_res_seq_conditioned)
is_seq_conditioned |= atom_array.mask("~is_standard_aa")
C_SEQ.set_annotation_from_ground_truth(atom_array, is_seq_conditioned)
# Sample the regions to be unindexed
show_idx = _true
if true_with_prob(self.p_unindex_all):
show_idx[total_mask & atom_array.is_protein & ~atom_array.atomize] = False
is_unindexed = ~show_idx
is_unindexed = apply_and_spread_token_wise(atom_array, is_unindexed, np.any)
show_idx = ~is_unindexed
show_idx[S_ATM.mask(atom_array, default="generate")] = True # Never unindex atomized regions
C_IDX.set_annotation(atom_array, show_idx)
# Sample the unindexed groups
all_index_group_ids = sample_unindexed_groups(
atom_array,
p_merge_groups=self.p_merge_unindexed_groups,
p_split_group=self.p_split_unindexed_groups,
p_increase_group_id_gap=self.p_increase_group_id_gap,
)
C_IDXGRP.set_annotation(atom_array, all_index_group_ids)
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": total_mask,
"all_masks": all_masks,
}
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data
[docs]
class TipAtomActiveSiteTask(ConditionedDesignTask):
"""
Designed to sample tip atoms around a protein pocket when no ligand is present.
This is done by calculating the SASA of all atoms, and calculating the distance they lie from
the convex hull of the protein chain. Atoms above a SASA threshold are sampled at rates
proportional to their distance from the convex hull. In effect this samples tip atoms around protein
pocket.
Args:
tip_atom_rasa_threshold: rASA threshold to consider tip atoms for sampling
min_tip_atoms: minimum number of tip atoms to sample
max_tip_atoms: maximum number of tip atoms to sample
p_seq_conditioned: probability of providing sequence conditioning to atoms with tip-atoms sampled
NOTE: In proteina, providing tip atoms inherently gives away sequence information because of Atom37!
min_protein_length: minimum length of protein chain to sample
p_unindex_all: probability of unindexing all atoms in the protein chain
p_unindex_any: probability of unindexing individual sampled atom. Will only do this if
p_index_all was sampled to False
p_merge_unindexed_groups: probability of merging unindexed groups
p_split_unindexed_groups: probability of splitting unindexed groups
p_increase_group_id_gap: probability of increasing the gap between unindexed group ids
"""
def __init__(
self,
tip_atom_rasa_threshold: float = 0.2,
min_tip_atoms: int = 2,
max_tip_atoms: int = 15,
p_seq_conditioned: float = 1.0,
min_protein_length: int = 50, # Avoid peptides
# Unindexing
p_unindex_all: float = 0.5,
p_unindex_any: float = 0.05,
p_merge_unindexed_groups: float = 0.1,
p_split_unindexed_groups: float = 0.01, # Should be rare
p_increase_group_id_gap: float = 0.1,
p_internal_coords: float = 0.0,
**kwargs,
):
super().__init__(**kwargs)
self.tip_atom_rasa_threshold = tip_atom_rasa_threshold
self.min_tip_atoms = min_tip_atoms
self.max_tip_atoms = max_tip_atoms
self.p_seq_conditioned = p_seq_conditioned
self.min_protein_length = min_protein_length
# Unindexing
self.p_unindex_all = p_unindex_all
self.p_unindex_any = p_unindex_any
self.p_merge_unindexed_groups = p_merge_unindexed_groups
self.p_split_unindexed_groups = p_split_unindexed_groups
self.p_increase_group_id_gap = p_increase_group_id_gap
# Internal coords
self.p_internal_coords = p_internal_coords
self.sampled_chain = None
self.sampled_tip_atom_ind = None
[docs]
def annotate_for_task_selection(self, data: dict) -> dict:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_tip_atom", "is_protein", "token_min_occupancy")
data["atom_array"] = atom_array
return data
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
eligible_mask = atom_array.mask("is_protein & (token_min_occupancy > 0)")
if "at_crop_boundary" in atom_array.get_annotation_categories():
eligible_mask &= ~atom_array.mask("at_crop_boundary")
# randomly choose one of the protein chains that is long enough to be our sampled protein chain
possible_chains = np.unique(atom_array[eligible_mask].chain_id)
chain_lengths = {
str(chain_id): len(np.unique(atom_array[atom_array.chain_id == chain_id].res_id))
for chain_id in possible_chains
}
eligible_chains = [chain_id for chain_id, length in chain_lengths.items() if length >= self.min_protein_length]
if len(eligible_chains) == 0:
return False
sampled_chain = np.random.choice(eligible_chains)
self.sampled_chain = sampled_chain
# calculate rasa for all atoms
rasa = calculate_atomwise_rasa(
atom_array,
probe_radius=1.4,
atom_radii="auto",
point_number=100,
sasa_mask=eligible_mask & (atom_array.chain_id == self.sampled_chain) & atom_array.is_tip_atom,
)
atom_array.set_annotation("tip_atom_rasa", rasa)
return not (rasa > self.tip_atom_rasa_threshold).sum() < self.min_tip_atoms
[docs]
def sample_protein_pocket(self, atom_array: AtomArray, eligible_tip_atom_mask: np.ndarray) -> int:
"""
Calculates convex hull of the protein chain (self.sampled_chain) and samples a tip atom
based on distance from the convex hull boundary and the rasa (stored in tip_atom_rasa
annotation). Returns the index of the sampled tip atom.
"""
# Get chain coordinates, filtering out NaN/Inf values and unresolved atoms
chain_mask = atom_array.chain_id == self.sampled_chain
# Also filter by occupancy if available to exclude unresolved atoms
if "token_min_occupancy" in atom_array.get_annotation_categories():
chain_mask = chain_mask & (atom_array.token_min_occupancy > 0)
chain_coords = atom_array[chain_mask].coord
# Use np.isfinite to catch both NaN and Inf values
valid_coord_mask = np.isfinite(chain_coords).all(axis=1)
if valid_coord_mask.sum() < 4:
raise ValueError(f"Not enough valid coordinates for convex hull (need >= 4, got {valid_coord_mask.sum()})")
# Make explicit copy to avoid any view issues
valid_chain_coords = chain_coords[valid_coord_mask].copy()
convex_hull = ConvexHull(valid_chain_coords)
eligible_tip_atom_inds = np.where(eligible_tip_atom_mask)[0]
tip_atom_coords = atom_array.coord[eligible_tip_atom_inds]
# Filter out tip atoms with NaN/Inf coordinates
valid_tip_mask = np.isfinite(tip_atom_coords).all(axis=1)
if valid_tip_mask.sum() == 0:
raise ValueError("No valid tip atom coordinates (all contain NaN/Inf)")
eligible_tip_atom_inds = eligible_tip_atom_inds[valid_tip_mask]
tip_atom_coords = tip_atom_coords[valid_tip_mask].copy()
distances_to_facets = (
tip_atom_coords @ convex_hull.equations[:, :-1].T + convex_hull.equations[:, -1]
) # (n_tip_atoms, n_facets)
distances_to_hull_boundary = np.min(distances_to_facets, axis=1)
probabilities = softmax(distances_to_hull_boundary)
sampled_tip_atom_ind = np.random.choice(eligible_tip_atom_inds, p=probabilities)
return sampled_tip_atom_ind
[docs]
def sample_mask(self, atom_array: AtomArray) -> np.ndarray:
is_eligible_query = (
f"is_tip_atom & (chain_id == '{self.sampled_chain}') & (token_min_occupancy > 0)"
f" & (tip_atom_rasa > {self.tip_atom_rasa_threshold})"
)
if "at_crop_boundary" in atom_array.get_annotation_categories():
is_eligible_query += " & (at_crop_boundary == False)"
eligible_tip_atom_mask = atom_array.mask(is_eligible_query)
sampled_tip_atom_ind = self.sample_protein_pocket(atom_array, eligible_tip_atom_mask)
sampled_tip_atom_mask = np.zeros(atom_array.array_length(), dtype=bool)
sampled_tip_atom_mask[sampled_tip_atom_ind] = True
target_num_tip_atoms = np.random.randint(
self.min_tip_atoms, min(self.max_tip_atoms, eligible_tip_atom_mask.sum()) + 1
)
fn_sample_seed = mg.SampleWithPotential(
is_eligible=is_eligible_query,
avoid_same=("atom_id"), # Effectively no token collision avoidance
potential=partial(_default_tip_atom_potential, scaling_factor=2.0),
)
# remember with seed grow merge, start with the sampled tip atom already as the first
# all_masks! Then it will sample from there
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
all_masks=[
sampled_tip_atom_mask
], # start with sampled tip atom as the first mask. Potential will grow around it
fn_sample_seed=fn_sample_seed,
fn_check_budget=mg.CheckAtomBudget(
n_min_atoms=target_num_tip_atoms,
n_max_atoms=target_num_tip_atoms,
),
)
return total_mask, all_masks
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_tip_atom", "tip_atom_rasa")
assert self.sampled_chain is not None, "sampled_chain was not set"
total_mask, all_masks = self.sample_mask(atom_array)
# Mark the conditioning region
atom_array.set_annotation("is_sampled_tip_atom", total_mask & atom_array.is_protein)
# set coordinate conditions for sampled atoms
C_CRD.set_annotation_from_ground_truth(atom_array, total_mask)
# Possibly convert some absolute coordinate conditioning to internal coordinate conditioning.
if self.p_internal_coords > 0 and len(all_masks) > 0:
# In this DesignTask, the individual masks are single atoms, but we will set those
# in the same residue to have the same internal coordinate group.
_same_residue = ("chain_iid", "res_name", "res_id", "transformation_id")
all_masks_residue_level = []
atom_masks_per_residue = defaultdict(list)
for mask in all_masks:
masked_atom_array = atom_array[mask]
if masked_atom_array.array_length() > 1:
all_masks_residue_level.append(mask)
else:
residue_data = tuple(masked_atom_array.get_annotation(category)[0] for category in _same_residue)
atom_masks_per_residue[residue_data].append(mask)
for _, atom_masks in atom_masks_per_residue.items():
combined_mask = np.zeros(atom_array.array_length(), dtype=bool)
for m in atom_masks:
combined_mask |= m
all_masks_residue_level.append(combined_mask)
# Sample the number of masks geometrically based on p_internal_coords.
if self.p_internal_coords >= 1.0:
n_to_convert = len(all_masks_residue_level)
else:
n_to_convert = min(
np.random.geometric(1 - self.p_internal_coords),
len(all_masks_residue_level),
)
selected = np.random.choice(len(all_masks_residue_level), size=n_to_convert, replace=False)
for idx in selected:
atom_array = convert_to_internal_coords(atom_array, all_masks_residue_level[idx])
# also sequence condition coordinates with sequence conditioning probability
if true_with_prob(self.p_seq_conditioned):
seq_cond_mask = apply_and_spread_token_wise(atom_array, total_mask, np.any)
C_SEQ.set_annotation_from_ground_truth(atom_array, seq_cond_mask)
# ... unindex atoms appropriately
show_idx = np.ones(atom_array.array_length(), dtype=bool)
unindexed_any = False
if true_with_prob(self.p_unindex_all):
show_idx[total_mask] = False
unindexed_any = True
elif true_with_prob(self.p_unindex_any):
show_idx[total_mask] = np.random.rand(total_mask.sum()) > self.p_unindex_any
unindexed_any = True
if unindexed_any:
# no partially unindexed tokens!
is_unindexed = ~show_idx
is_unindexed = apply_and_spread_token_wise(atom_array, is_unindexed, np.any)
show_idx = ~is_unindexed
show_idx[S_ATM.mask(atom_array, default="generate")] = True # Never unindex atomized regions
C_IDX.set_annotation(atom_array, show_idx)
# ... sample unindexed groups
all_index_group_ids = sample_unindexed_groups(
atom_array,
p_merge_groups=self.p_merge_unindexed_groups,
p_split_group=self.p_split_unindexed_groups,
p_increase_group_id_gap=self.p_increase_group_id_gap,
)
C_IDXGRP.set_annotation(atom_array, all_index_group_ids)
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": total_mask,
"total_mask": total_mask,
"target_num_tip_atoms": total_mask.sum(),
}
data["atom_array"] = atom_array
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data
[docs]
class TipAtomTask(ConditionedDesignTask):
def __init__(
self,
min_residues: int = 2,
max_residues: int = 25,
p_tip_atom: float = 0.8,
n_hops_expected: int = 3,
p_unindex_all: float = 0.9,
p_merge_unindexed_groups: float = 0.1,
p_split_unindexed_groups: float = 0.01,
p_increase_group_id_gap: float = 0.1,
p_internal_coords: float = 0.0,
**kwargs,
):
super().__init__(**kwargs)
self.min_residues = min_residues
self.max_residues = max_residues
self.n_hops_expected = n_hops_expected
self.p_tip_atom = p_tip_atom
self.p_unindex_all = p_unindex_all
self.p_merge_unindexed_groups = p_merge_unindexed_groups
self.p_split_unindexed_groups = p_split_unindexed_groups
self.p_increase_group_id_gap = p_increase_group_id_gap
self.p_internal_coords = p_internal_coords
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
ensure_annotations(
atom_array,
"is_protein",
"is_standard_aa",
"is_res_start",
"res_min_occupancy",
"is_tip_atom",
"res_has_tip_atom",
)
n_eligible_residues = np.sum(
atom_array.mask("is_standard_aa & is_protein & is_res_start & (res_min_occupancy > 0)")
)
return n_eligible_residues >= self.min_residues
[docs]
def sample_mask(self, atom_array: AtomArray) -> tuple[np.ndarray, list[np.ndarray], int]:
n_eligible_residues = np.sum(
atom_array.mask("is_standard_aa & is_protein & is_res_start & (res_min_occupancy > 0)")
)
_same_residue = ("chain_iid", "res_name", "res_id", "transformation_id")
def fn_sample_seed(
atom_array: AtomArray,
total_mask: np.ndarray,
all_masks: list,
) -> np.ndarray:
if true_with_prob(self.p_tip_atom):
return mg.SampleTipAtomUniformly(
is_eligible="is_tip_atom & (res_min_occupancy > 0)",
avoid_same=_same_residue,
)(atom_array, total_mask, all_masks)
return mg.SampleUniformly(
is_eligible="is_standard_aa & is_protein & (res_min_occupancy > 0)",
avoid_same=_same_residue,
)(atom_array, total_mask, all_masks)
fn_grow_seed = mg.GrowByHoppingAlongBondGraph(
n_hops_expected=self.n_hops_expected,
require_same_annotation=_same_residue,
atom_array=atom_array,
)
max_residues = min(n_eligible_residues, self.max_residues)
target_n_residues = np.random.randint(self.min_residues, max_residues + 1)
fn_check_budget = mg.CheckResidueBudget(
n_min_residues=target_n_residues,
n_max_residues=target_n_residues,
reduce=np.any,
)
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
fn_sample_seed=fn_sample_seed,
fn_grow_seed=fn_grow_seed,
fn_check_budget=fn_check_budget,
)
return total_mask, all_masks, target_n_residues
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
total_mask, all_masks, target_n_residues = self.sample_mask(atom_array)
n_atoms = atom_array.array_length()
_true = np.ones(n_atoms, dtype=bool)
# Mark the conditioning region
atom_array.set_annotation("is_sampled_tip_atom", total_mask & atom_array.is_protein)
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = total_mask & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
# Possibly convert some absolute coordinate conditioning to internal coordinate conditioning.
# Sample the number of masks geometrically based on p_internal_coords.
if self.p_internal_coords > 0 and len(all_masks) > 0:
if self.p_internal_coords >= 1.0:
n_to_convert = len(all_masks)
else:
n_to_convert = min(
np.random.geometric(1 - self.p_internal_coords),
len(all_masks),
)
selected = np.random.choice(len(all_masks), size=n_to_convert, replace=False)
for idx in selected:
atom_array = convert_to_internal_coords(atom_array, all_masks[idx])
res_segments = struc.get_residue_starts(atom_array, add_exclusive_stop=True)
is_seq_conditioned = apply_and_spread(res_segments, total_mask, np.any)
is_seq_conditioned |= atom_array.mask("~is_standard_aa")
C_SEQ.set_annotation_from_ground_truth(atom_array, is_seq_conditioned)
# Sample the regions to be unindexed
show_idx = _true
if true_with_prob(self.p_unindex_all):
show_idx[total_mask & atom_array.is_protein & ~atom_array.atomize] = False
is_unindexed = ~show_idx
is_unindexed = apply_and_spread_token_wise(atom_array, is_unindexed, np.any)
show_idx = ~is_unindexed
show_idx[S_ATM.mask(atom_array, default="generate")] = True # Never unindex atomized regions
C_IDX.set_annotation(atom_array, show_idx)
# Sample the unindexed groups
all_index_group_ids = sample_unindexed_groups(
atom_array,
p_merge_groups=self.p_merge_unindexed_groups,
p_split_group=self.p_split_unindexed_groups,
p_increase_group_id_gap=self.p_increase_group_id_gap,
)
C_IDXGRP.set_annotation(atom_array, all_index_group_ids)
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": total_mask,
"target_n_residues": target_n_residues,
"all_masks": all_masks,
}
data = self.apply_design_task_modifiers(data)
return data
[docs]
class MotifScaffoldTask(ConditionedDesignTask):
def __init__(
self,
min_tokens_per_motif: int = 1,
max_tokens_per_motif: int = 50,
max_unindexed_tokens: int = 50,
min_coverage_fraction: float = 0.05,
max_coverage_fraction: float = 1.0,
p_unindex_any: float = 0.5,
p_merge_unindexed_groups: float = 0.1,
p_split_unindexed_groups: float = 0.01,
p_increase_group_id_gap: float = 0.1,
p_internal_coords: float = 0.0,
p_backbone_only: float = 0.0,
p_bb_only_seq_conditioned: float = 0.3,
**kwargs,
):
super().__init__(**kwargs)
self.min_tokens_per_motif = min_tokens_per_motif
self.max_tokens_per_motif = max_tokens_per_motif
self.max_unindexed_tokens = max_unindexed_tokens
self.min_coverage_fraction = min_coverage_fraction
self.max_coverage_fraction = max_coverage_fraction
self.p_unindex_any = p_unindex_any
self.p_merge_unindexed_groups = p_merge_unindexed_groups
self.p_split_unindexed_groups = p_split_unindexed_groups
self.p_increase_group_id_gap = p_increase_group_id_gap
self.p_internal_coords = p_internal_coords
self.p_backbone_only = p_backbone_only
self.p_bb_only_seq_conditioned = p_bb_only_seq_conditioned
[docs]
def annotate_for_task_selection(self, data: dict) -> dict:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_token_start", "token_min_occupancy", "is_polymer", "token_id", "is_protein")
return data
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
polymer_chain_iids = np.unique(atom_array.chain_iid[atom_array.is_polymer])
for chain_iid in polymer_chain_iids:
chain_length = atom_array.mask(f"is_token_start & (chain_iid == '{chain_iid}')").sum()
if chain_length >= self.min_tokens_per_motif:
return True
return False
[docs]
def sample_mask(self, atom_array: AtomArray) -> tuple[np.ndarray, list[np.ndarray]]:
# Sample coverage fraction per-example (not per-instance) for reproducibility across restarts
self.coverage_fraction_goal = np.random.uniform(self.min_coverage_fraction, self.max_coverage_fraction)
fn_sample_seed = mg.SampleUniformly(is_eligible="is_polymer & (token_min_occupancy > 0) & ~has_nan_coord()")
fn_grow_seed = mg.GrowToTokenIsland(
island_min_size=self.min_tokens_per_motif,
island_max_size=self.max_tokens_per_motif,
)
total_eligible_tokens = atom_array.mask("is_polymer & is_token_start").sum()
min_total_tokens = int((self.coverage_fraction_goal - 0.05) * total_eligible_tokens)
max_total_tokens = int((self.coverage_fraction_goal + 0.05) * total_eligible_tokens)
fn_check_budget = mg.CheckTokenBudget(
n_min_tokens=min_total_tokens,
n_max_tokens=max_total_tokens,
)
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
fn_sample_seed=fn_sample_seed,
fn_grow_seed=fn_grow_seed,
fn_check_budget=fn_check_budget,
)
return total_mask, all_masks
[docs]
def sample_unindexed_mask(
self,
all_masks: list[np.ndarray],
token_starts: np.ndarray,
) -> np.ndarray:
"""Samples a mask of tokens to be unindexed, ensuring only full coordinate-conditioned
islands are unindexed.
NOTE: This function assumes that the coordinate-conditioned islands do not contain partial
tokens, which is always true for the MotifScaffoldTask.
Args:
all_masks: List of boolean masks for each coordinate-conditioned island (shape of each mask is (n_atoms,))
token_starts: Boolean array indicating the start of each token (shape (n_atoms,))
Returns:
unindexed_mask: Boolean array of shape (n_atoms,) where True indicates the token is unindexed
"""
unindexed_mask = np.zeros_like(all_masks[0], dtype=bool)
unindexed_mask_indices = []
target_num_islands_to_unindex = np.random.randint(1, len(all_masks) + 1)
for _ in range(target_num_islands_to_unindex):
valid_mask_indices = [
i
for i, mask in enumerate(all_masks)
if i not in unindexed_mask_indices
and (mask | unindexed_mask)[token_starts].sum() <= self.max_unindexed_tokens
]
if len(valid_mask_indices) == 0:
break
mask_index_to_add = np.random.choice(valid_mask_indices)
unindexed_mask_indices.append(mask_index_to_add)
unindexed_mask |= all_masks[mask_index_to_add]
return unindexed_mask
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
total_mask, all_masks = self.sample_mask(atom_array)
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = total_mask & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
res_segments = struc.get_residue_starts(atom_array, add_exclusive_stop=True)
is_seq_conditioned = apply_and_spread(res_segments, total_mask, np.any)
C_SEQ.set_annotation_from_ground_truth(atom_array, is_seq_conditioned)
# Per-residue probability of reducing coord conditioning to backbone atoms only.
# We do not give sequence conditioning in such cases
if self.p_backbone_only > 0.0:
ensure_annotations(atom_array, "is_protein_backbone", "is_rna_backbone", "is_dna_backbone")
is_backbone = atom_array.is_protein_backbone | atom_array.is_rna_backbone | atom_array.is_dna_backbone
for start, stop in itertools.pairwise(res_segments):
if coord_condition_mask[start:stop].any() and true_with_prob(self.p_backbone_only):
coord_condition_mask[start:stop] &= is_backbone[start:stop]
if true_with_prob(self.p_bb_only_seq_conditioned):
is_seq_conditioned[start:stop] = True
else:
is_seq_conditioned[start:stop] = False
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
C_SEQ.set_annotation_from_ground_truth(atom_array, is_seq_conditioned)
# Sample the regions to be unindexed using an island mask
# NOTE: Since absolute coords aren't converted to internal coords until later, this may
# also unindex regions with internal coord conditioning. This is fine for training but the
# unindexed mapping is not supported at inference
if true_with_prob(self.p_unindex_any):
unindexed_mask = self.sample_unindexed_mask(all_masks, get_token_starts(atom_array))
if "atomize" in atom_array.get_annotation_categories():
unindexed_mask &= ~atom_array.atomize
C_IDX.set_annotation(atom_array, ~unindexed_mask)
else:
C_IDX.set_annotation(atom_array, C_IDX.default_annotation(atom_array))
# Sample the unindexed groups
all_index_group_ids = sample_unindexed_groups(
atom_array,
p_merge_groups=self.p_merge_unindexed_groups,
p_split_group=self.p_split_unindexed_groups,
p_increase_group_id_gap=self.p_increase_group_id_gap,
)
C_IDXGRP.set_annotation(atom_array, all_index_group_ids)
# By default, coord-conditioned regions all have absolute coords
C_CRDGRP.set_annotation(atom_array, C_CRDGRP.default_annotation(atom_array))
# Sample the internal coordinate groups, keeping full coord-conditioned islands together
if true_with_prob(self.p_internal_coords):
# permute indices to avoid biases
mask_indices = np.random.permutation(len(all_masks))
# We have already decided to give the first group internal coords
atom_array = convert_to_internal_coords(atom_array, all_masks[mask_indices[0]])
# Additional internal coord groups are iteratively added with geometrically-decreasing probability
current_prob_geom = self.p_internal_coords
masks_added = 1
while True:
current_prob_geom *= self.p_internal_coords
if true_with_prob(current_prob_geom) and masks_added < len(all_masks):
atom_array = convert_to_internal_coords(atom_array, all_masks[mask_indices[masks_added]])
masks_added += 1
else:
break
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": total_mask,
"coverage_fraction_goal": self.coverage_fraction_goal,
"all_masks": all_masks,
}
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data
[docs]
class BinderHotspotWith3DTarget(ConditionedDesignTask):
def __init__(
self,
hotspot_count_distribution: Literal["uniform", "geometric"] = "uniform",
hotspot_geometric_p: float | None = None,
max_hotspots_frac_to_provide: float = 0.1,
hotspot_max_distance: float = 7.0,
p_no_hotspots: float = 0.05,
p_sample_ppi_crop: float = 0.75,
ppi_crop_binder_fraction: float = 0.667,
p_bb_only_coord_conditioning: float = 0.0,
target_chain_types: Sequence[ChainType] = (ChainType.POLYPEPTIDE_L, ChainType.POLYPEPTIDE_D),
**kwargs,
):
"""
This task samples a set of atom-level hotspots and provides them as 1D conditioning to the model.
The logic is as follows:
1. Decide which chain is the binder. We do this by randomly choosing one of the
query_pn_unit_iids since we assume these chains form a valid interface.
2. Find all possible hotspots based on the target chain based on whether the atom is within
hotspot_max_distance to any atom on the target chain.
3. Sample a set of hotspots from the possible hotspots.
Args:
hotspot_count_distribution: Distribution for sampling the number of hotspots. Must be
``"geometric"`` or ``"uniform"``.
hotspot_geometric_p: Success probability for the geometric distribution; mean hotspots
= 1 / hotspot_geometric_p. Required when ``hotspot_count_distribution`` is
``"geometric"``; must be ``None`` when it is ``"uniform"``.
max_hotspots_frac_to_provide: Hard cap on the fraction of available hotspot atoms to
provide. The sampled count is capped at this fraction of the total hotspot pool.
hotspot_max_distance: Maximum distance between a binder atom and a target atom for
the binder atom to be considered a hotspot candidate.
p_no_hotspots: Probability of providing no hotspot conditioning at all.
p_sample_ppi_crop: probability of using the PPI crop, which keeps the entirety of the
chain designated as the binder if it's smaller than
``int(crop_size * ppi_crop_binder_fraction)`` residues. Only consulted when an
upstream crop has run; otherwise the PPI re-crop is skipped entirely.
ppi_crop_binder_fraction: Max fraction of the upstream crop budget that can be spent
on the binder; the rest is reserved for target context. Defaults to ``0.667``.
p_bb_only_coord_conditioning: Probability of applying backbone-only target conditioning:
only the backbone atoms of the target chain receive coordinate conditioning (sidechain
atoms can still be hotspots, but their precise coordinates are not given). Set to 1.0
for always-on, 0.0 to disable.
target_chain_types: :py:class:`~atomworks.enums.ChainType` values allowed for the 3D
target chain. The designed binder is always a protein. Defaults to protein-only
(classic protein-protein interfaces); add ``ChainType.DNA`` / ``ChainType.RNA`` to
design binders against nucleic-acid targets.
Note:
Crop parameters (``crop_size``, ``crop_center_cutoff_distance``, ``max_atoms_in_crop``)
are read from ``data["crop_info"]`` at forward() time — populated by the upstream
crop transform (:py:class:`CropSpatialLikeAF3` / :py:class:`CropContiguousLikeAF3`).
If no upstream crop ran, the PPI re-crop is skipped and the task operates on
``data["atom_array"]`` as-is.
"""
super().__init__(**kwargs)
self.hotspot_count_distribution = hotspot_count_distribution
self.hotspot_geometric_p = hotspot_geometric_p
self.max_hotspots_frac_to_provide = max_hotspots_frac_to_provide
self.hotspot_max_distance = hotspot_max_distance
self.p_no_hotspots = p_no_hotspots
self.p_sample_ppi_crop = p_sample_ppi_crop
self.ppi_crop_binder_fraction = ppi_crop_binder_fraction
self.p_bb_only_coord_conditioning = p_bb_only_coord_conditioning
# Accept ChainType members directly; coerce str names (e.g. from YAML configs) for convenience.
self.target_chain_types = tuple(
ChainType[ct.upper()] if isinstance(ct, str) else ChainType(ct) for ct in target_chain_types
)
[docs]
def annotate_for_task_selection(self, data: dict) -> dict:
if "crop_info" in data:
if "atom_array" not in data["crop_info"]:
raise ValueError("Must preserve uncropped AtomArray in crop_info before applying this design task.")
atom_array = data["crop_info"]["atom_array"]
else:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "chain_type")
return data
def _valid_binder_pn_units(self, atom_array: AtomArray, query_pn_unit_iids: Sequence[str]) -> list[str]:
"""Return the query pn_units that can serve as the (protein) binder.
A pn_unit is a valid binder if it is a protein chain and every *other* query chain's
type is in :py:attr:`target_chain_types`. The remaining query chains form the 3D target (
which may span multiple chains -- e.g. a double-stranded DNA target).
For a protein-protein interface both chains are valid binders; for a protein-nucleic
interface only the protein is a valid binder.
"""
chain_types = {pn: _get_pn_unit_chain_type(atom_array, pn) for pn in query_pn_unit_iids}
valid_binders: list[str] = []
for binder in query_pn_unit_iids:
if chain_types[binder] not in ChainTypeInfo.PROTEINS:
continue
other_types = [chain_types[pn] for pn in query_pn_unit_iids if pn != binder]
if all(ct in self.target_chain_types for ct in other_types):
valid_binders.append(binder)
return valid_binders
[docs]
def can_apply(self, data: dict) -> bool:
"""
Can be applied if query_pn_unit_iids has at least two distinct chains that form a valid
binder/target interface: a protein binder and one or more target chains whose types are in
:py:attr:`target_chain_types` (protein-only by default; add DNA/RNA for nucleic-acid targets).
The target may span multiple chains, e.g. a double-stranded DNA target (two nucleic chains).
"""
query_pn_unit_iids = data.get("query_pn_unit_iids")
if "crop_info" in data and "atom_array" in data["crop_info"]:
atom_array = data["crop_info"]["atom_array"]
else:
atom_array = data["atom_array"]
if query_pn_unit_iids is None or len(query_pn_unit_iids) < 2:
return False
if len(np.unique(query_pn_unit_iids)) != len(query_pn_unit_iids):
return False
for pn_unit_iid in query_pn_unit_iids:
# make sure pn_unit_iid is actually in the atom array
if atom_array.mask(f"pn_unit_iid == '{pn_unit_iid}'").sum() == 0:
return False
return len(self._valid_binder_pn_units(atom_array, query_pn_unit_iids)) > 0
[docs]
def forward(self, data: dict) -> dict:
query_pn_unit_iids = data["query_pn_unit_iids"] # existence guaranteed via can_apply()
# Select the binder chain (always protein); every other query chain is the 3D target.
# The target may span multiple chains (e.g. double-stranded DNA). Uses the uncropped
# array (if present) so chain types match can_apply().
selection_atom_array = (
data["crop_info"]["atom_array"]
if "crop_info" in data and "atom_array" in data["crop_info"]
else data["atom_array"]
)
valid_binders = self._valid_binder_pn_units(selection_atom_array, query_pn_unit_iids)
if len(query_pn_unit_iids) == 2 and len(valid_binders) == 2:
# Legacy protein-protein path: keep the exact RNG draw (permutation of the two
# chains) so training trajectories and regression baselines are unchanged.
binder_pn_unit_iid = np.random.permutation(query_pn_unit_iids)[0]
else:
binder_pn_unit_iid = valid_binders[np.random.randint(len(valid_binders))]
target_pn_unit_iids = [pn for pn in query_pn_unit_iids if pn != binder_pn_unit_iid]
# Possibly apply the PPI crop instead of the previously-sampled crop
pre_crop_info = data.get("crop_info")
perform_ppi_crop = pre_crop_info is not None and true_with_prob(self.p_sample_ppi_crop)
if perform_ppi_crop:
crop_size = pre_crop_info["crop_size"]
max_atoms_in_crop = pre_crop_info["max_atoms_in_crop"]
crop_center_cutoff_distance = pre_crop_info.get("crop_center_cutoff_distance", 15.0)
ppi_crop_max_binder_length = int(crop_size * self.ppi_crop_binder_fraction)
pre_crop_atom_array = pre_crop_info["atom_array"]
pre_crop_atom_array.set_annotation(
"is_binder_pn_unit",
pre_crop_atom_array.pn_unit_iid == binder_pn_unit_iid,
)
if (
get_token_count(pre_crop_atom_array[pre_crop_atom_array.is_binder_pn_unit])
<= ppi_crop_max_binder_length
):
# Apply the PPI crop
crop_info = crop_spatial_keep_full_binder(
atom_array=pre_crop_atom_array,
query_pn_unit_iids=query_pn_unit_iids,
binder_pn_unit_iid=binder_pn_unit_iid,
crop_size=crop_size,
crop_center_cutoff_distance=crop_center_cutoff_distance,
force_crop=False,
)
# Resize if there are too many atoms
crop_info = resize_crop_info_if_too_many_atoms(
crop_info=crop_info,
atom_array=pre_crop_atom_array,
max_atoms=max_atoms_in_crop,
)
# Overwrite the old crop info with the info for the newly-applied PPI crop.
# Carry forward crop_size/max_atoms_in_crop/crop_center_cutoff_distance from
# pre_crop_info -- crop_spatial_keep_full_binder()'s return dict doesn't include
# them, but EnforceCropBudget (and a subsequent re-crop) still needs them.
data["crop_info"] = {
"type": "PPIFullBinderCropSpatial",
"crop_size": crop_size,
"max_atoms_in_crop": max_atoms_in_crop,
"crop_center_cutoff_distance": crop_center_cutoff_distance,
} | crop_info
data["crop_info"]["atom_array"] = pre_crop_atom_array
# Update data with the newly-cropped atom array
data["atom_array"] = pre_crop_atom_array[crop_info["crop_atom_idxs"]]
atom_array = data["atom_array"]
# sample_hotspot
if true_with_prob(self.p_no_hotspots):
hotspot_atom_mask = np.zeros(atom_array.array_length(), dtype=bool)
else:
hotspot_atom_mask = sample_hotspots(
atom_array,
binder_pn_unit_iid,
hotspot_max_distance=self.hotspot_max_distance,
max_hotspots_frac_to_provide=self.max_hotspots_frac_to_provide,
hotspot_count_distribution=self.hotspot_count_distribution,
hotspot_geometric_p=self.hotspot_geometric_p,
)
if hotspot_atom_mask.sum() == 0:
ranked_logger.warning(
f"No hotspots found for binder chain {binder_pn_unit_iid} on example_id {data['example_id']}"
)
# ... set the conditions for the task
n_atoms = atom_array.array_length()
_true = np.ones(n_atoms, dtype=bool)
_false = np.zeros(n_atoms, dtype=bool)
is_target_chain = np.isin(atom_array.pn_unit_iid, target_pn_unit_iids)
nan_coord_mask = atom_array.mask("has_nan_coord()")
C_HOT.set_annotation(atom_array, hotspot_atom_mask)
if true_with_prob(self.p_bb_only_coord_conditioning):
ensure_annotations(atom_array, "is_protein_backbone", "is_nucleic_acid_backbone")
is_backbone = atom_array.is_protein_backbone | atom_array.is_nucleic_acid_backbone
coord_condition_mask = is_target_chain & ~nan_coord_mask & is_backbone
else:
coord_condition_mask = is_target_chain & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
C_SEQ.set_annotation_from_ground_truth(atom_array, is_target_chain)
# TODO: enable `unindexed` condition
C_IDX.set_annotation(atom_array, _true)
# ... update the now condition-annotated atom array
data["atom_array"] = atom_array
# ... add the task mask
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
# required fields
"class": self.__class__.__name__,
"mask": hotspot_atom_mask,
# optional fields
"binder_pn_unit_iid": binder_pn_unit_iid,
"num_hotspots_to_sample": hotspot_atom_mask.sum(),
}
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data
[docs]
class NucleicSecondaryStructureTask(ConditionedDesignTask):
"""Apply NA secondary-structure conditioning, with optional small motif scaffolds.
When this task is selected, it computes ``bp_partners`` from the current
structure and either reveals the full annotation or a partial island mask.
It can also optionally add a motif-scaffold overlay with coordinate/sequence
conditioning on a small fraction of polymer tokens.
"""
def __init__(
self,
min_nucleic_tokens: int = 2,
p_show_partial_feats: float = 0.7,
p_add_motif_scaffold_condition: float = 0.0,
min_tokens_per_motif: int = 1,
max_tokens_per_motif: int = 12,
min_motif_coverage_fraction: float = 0.02,
max_motif_coverage_fraction: float = 0.10,
max_unindexed_tokens: int = 0,
p_unindex_any: float = 0.0,
p_merge_unindexed_groups: float = 0.0,
p_split_unindexed_groups: float = 0.0,
p_increase_group_id_gap: float = 0.0,
p_internal_coords: float = 0.0,
nucleic_ss_min_shown: float = 0.2,
nucleic_ss_max_shown: float = 1.0,
n_islands_min: int = 1,
n_islands_max: int = 6,
p_canonical_bp_filter: float = 0.0,
na_only: bool = False,
planar_only: bool = True,
**kwargs,
):
"""Initialize NA-SS reveal, optional motif overlay, and annotation filters.
Control knobs for conditioning control are:
- ``p_show_partial_feats`` controls full vs. partial ``bp_partners`` reveal.
- ``nucleic_ss_min_shown`` / ``nucleic_ss_max_shown`` and
``n_islands_min`` / ``n_islands_max`` shape the partial reveal.
- ``p_add_motif_scaffold_condition`` controls whether a motif scaffold is
additionally sampled.
- motif sizing/unindexing is controlled by the motif-specific arguments.
- ``p_canonical_bp_filter``, ``na_only``, and ``planar_only`` control
which interactions are eligible for annotation.
Control knobs for motif scaffold sampling are:
- ``p_add_motif_scaffold_condition`` controls whether a motif scaffold
is sampled in addition to the NA-SS conditioning.
- ``min_tokens_per_motif`` and ``max_tokens_per_motif`` control motif size.
- ``min_motif_coverage_fraction`` and ``max_motif_coverage_fraction``
control motif scaffold coverage as a fraction of eligible tokens.
Control knobs for unindexing behavior are:
- ``max_unindexed_tokens`` maximum number of tokens that can be unindexed in total.
- ``p_unindex_any`` probability of unindexing any motif at all.
- ``p_merge_unindexed_groups`` probability of merging two unindexed motif groups.
- ``p_split_unindexed_groups`` probability of splitting any unindexed motif group.
- ``p_increase_group_id_gap`` prob of increasing the gap in group IDs between indexed and unindexed groups
"""
super().__init__(**kwargs)
self.min_nucleic_tokens = min_nucleic_tokens
self.p_show_partial_feats = p_show_partial_feats
self.p_add_motif_scaffold_condition = p_add_motif_scaffold_condition
self.min_tokens_per_motif = min_tokens_per_motif
self.max_tokens_per_motif = max_tokens_per_motif
self.min_motif_coverage_fraction = min_motif_coverage_fraction
self.max_motif_coverage_fraction = max_motif_coverage_fraction
self.max_unindexed_tokens = max_unindexed_tokens
self.p_unindex_any = p_unindex_any
self.p_merge_unindexed_groups = p_merge_unindexed_groups
self.p_split_unindexed_groups = p_split_unindexed_groups
self.p_increase_group_id_gap = p_increase_group_id_gap
self.p_internal_coords = p_internal_coords
self.nucleic_ss_min_shown = nucleic_ss_min_shown
self.nucleic_ss_max_shown = nucleic_ss_max_shown
self.n_islands_min = n_islands_min
self.n_islands_max = n_islands_max
self.p_canonical_bp_filter = p_canonical_bp_filter
self.na_only = na_only
self.planar_only = planar_only
[docs]
def can_apply(self, data: dict) -> bool:
atom_array = data["atom_array"]
ensure_annotations(atom_array, "is_nucleic_acid", "is_polymer")
token_starts = get_token_starts(atom_array)
if len(token_starts) == 0:
return False
if self.na_only:
n_nucleic_tokens = int(atom_array.is_nucleic_acid[token_starts].sum())
return n_nucleic_tokens >= self.min_nucleic_tokens
n_polymer_tokens = int(atom_array.is_polymer[token_starts].sum())
return n_polymer_tokens >= 2
[docs]
def sample_motif_mask(self, atom_array: AtomArray) -> tuple[np.ndarray, list[np.ndarray]]:
"""Sample small polymer token islands to retain as motif scaffolds."""
coverage_fraction_goal = np.random.uniform(
self.min_motif_coverage_fraction,
self.max_motif_coverage_fraction,
)
fn_sample_seed = mg.SampleUniformly(is_eligible="is_polymer & (token_min_occupancy > 0) & ~has_nan_coord()")
fn_grow_seed = mg.GrowToTokenIsland(
island_min_size=self.min_tokens_per_motif,
island_max_size=self.max_tokens_per_motif,
)
total_eligible_tokens = atom_array.mask("is_polymer & is_token_start").sum()
min_total_tokens = int(max(1, (coverage_fraction_goal - 0.02) * total_eligible_tokens))
max_total_tokens = int(max(1, (coverage_fraction_goal + 0.02) * total_eligible_tokens))
fn_check_budget = mg.CheckTokenBudget(
n_min_tokens=min_total_tokens,
n_max_tokens=max_total_tokens,
)
total_mask, all_masks = mg.sample_mask_via_seed_grow_merge(
atom_array,
fn_sample_seed=fn_sample_seed,
fn_grow_seed=fn_grow_seed,
fn_check_budget=fn_check_budget,
)
self.motif_coverage_fraction_goal = coverage_fraction_goal
return total_mask, all_masks
[docs]
def sample_unindexed_mask(
self,
all_masks: list[np.ndarray],
token_starts: np.ndarray,
) -> np.ndarray:
"""Sample motif islands to unindex while preserving whole conditioned islands."""
unindexed_mask = np.zeros_like(all_masks[0], dtype=bool)
unindexed_mask_indices = []
target_num_islands_to_unindex = np.random.randint(1, len(all_masks) + 1)
for _ in range(target_num_islands_to_unindex):
valid_mask_indices = [
i
for i, mask in enumerate(all_masks)
if i not in unindexed_mask_indices
and (mask | unindexed_mask)[token_starts].sum() <= self.max_unindexed_tokens
]
if len(valid_mask_indices) == 0:
break
mask_index_to_add = np.random.choice(valid_mask_indices)
unindexed_mask_indices.append(mask_index_to_add)
unindexed_mask |= all_masks[mask_index_to_add]
return unindexed_mask
[docs]
def forward(self, data: dict) -> dict:
atom_array = as_atom_array_plus(data["atom_array"])
ensure_annotations(atom_array, "is_nucleic_acid", "is_polymer", "is_token_start", "token_min_occupancy")
nucleic_mask = atom_array.is_nucleic_acid.astype(bool)
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": nucleic_mask,
}
atom_array = apply_training_na_ss_condition(
atom_array,
p_show_partial_feats=self.p_show_partial_feats,
nucleic_ss_min_shown=self.nucleic_ss_min_shown,
nucleic_ss_max_shown=self.nucleic_ss_max_shown,
n_islands_min=self.n_islands_min,
n_islands_max=self.n_islands_max,
p_canonical_bp_filter=self.p_canonical_bp_filter,
NA_only=self.na_only,
planar_only=self.planar_only,
)
if true_with_prob(self.p_add_motif_scaffold_condition):
total_mask, all_masks = self.sample_motif_mask(atom_array)
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
coord_condition_mask = total_mask & ~nan_coord_mask
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
res_segments = struc.get_residue_starts(atom_array, add_exclusive_stop=True)
is_seq_conditioned = apply_and_spread(res_segments, total_mask, np.any)
C_SEQ.set_annotation_from_ground_truth(atom_array, is_seq_conditioned)
if true_with_prob(self.p_unindex_any):
unindexed_mask = self.sample_unindexed_mask(all_masks, get_token_starts(atom_array))
if "atomize" in atom_array.get_annotation_categories():
unindexed_mask &= ~atom_array.atomize
C_IDX.set_annotation(atom_array, ~unindexed_mask)
else:
C_IDX.set_annotation(atom_array, C_IDX.default_annotation(atom_array))
all_index_group_ids = sample_unindexed_groups(
atom_array,
p_merge_groups=self.p_merge_unindexed_groups,
p_split_group=self.p_split_unindexed_groups,
p_increase_group_id_gap=self.p_increase_group_id_gap,
)
C_IDXGRP.set_annotation(atom_array, all_index_group_ids)
C_CRDGRP.set_annotation(atom_array, C_CRDGRP.default_annotation(atom_array))
if true_with_prob(self.p_internal_coords) and len(all_masks) > 0:
mask_indices = np.random.permutation(len(all_masks))
atom_array = convert_to_internal_coords(atom_array, all_masks[mask_indices[0]])
current_prob_geom = self.p_internal_coords
masks_added = 1
while True:
current_prob_geom *= self.p_internal_coords
if true_with_prob(current_prob_geom) and masks_added < len(all_masks):
atom_array = convert_to_internal_coords(atom_array, all_masks[mask_indices[masks_added]])
masks_added += 1
else:
break
data["task"] |= {
"motif_mask": total_mask,
"motif_all_masks": all_masks,
"motif_coverage_fraction_goal": getattr(self, "motif_coverage_fraction_goal", None),
}
data["atom_array"] = atom_array
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data
# ---------------------------------------------------------------------------
# Helpers for IgFrameworkConditioningTask
# ---------------------------------------------------------------------------
[docs]
class IgRegion(StrEnum):
"""Allowed values of the per-atom ``ig_region`` CIF annotation."""
NOT_IG = ""
FRAMEWORK = "framework"
CDR1 = "cdr1"
CDR2 = "cdr2"
CDR3 = "cdr3"
CDR4 = "cdr4"
IG_CDR_REGIONS = (IgRegion.CDR1, IgRegion.CDR2, IgRegion.CDR3, IgRegion.CDR4)
def _expand_cdr_mask(
atom_array: AtomArray,
cdr_mask: np.ndarray,
boundary_mask: np.ndarray,
expand_n_term: int,
expand_c_term: int,
) -> np.ndarray:
"""Expand (>0) or contract (<0) a CDR atom mask independently at each terminus.
Each terminus is grown/shrunk on its own, so e.g. ``expand_n_term=+1`` with
``expand_c_term=-1`` shifts the CDR mask by one residue toward the C-terminus
rather than resizing it. Expansion is clamped to boundary_mask (the Ig chain);
contraction erodes boundary residues from the chosen terminus.
Args:
atom_array: The full atom array.
cdr_mask: Bool atom mask of the CDR region to expand/contract.
boundary_mask: Bool atom mask bounding the expansion (Ig chain).
expand_n_term: Residues to add (>0) or remove (<0) at the N-terminus
(lower residue indices).
expand_c_term: Residues to add (>0) or remove (<0) at the C-terminus
(higher residue indices).
Returns:
New bool atom mask after expansion/contraction.
"""
if expand_n_term == 0 and expand_c_term == 0:
return cdr_mask
res_starts = struc.get_residue_starts(atom_array, add_exclusive_stop=True)
n_res = len(res_starts) - 1
n_atoms = atom_array.array_length()
# Build residue-level masks
res_is_cdr = np.array([cdr_mask[res_starts[i] : res_starts[i + 1]].any() for i in range(n_res)])
res_in_boundary = np.array([boundary_mask[res_starts[i] : res_starts[i + 1]].any() for i in range(n_res)])
new_res = res_is_cdr.copy()
# N-terminus = lower residue indices ("left"), C-terminus = higher ("right").
for amount, toward_n_term in ((expand_n_term, True), (expand_c_term, False)):
for _ in range(amount): # grow toward this terminus
grown = np.zeros(n_res, dtype=bool)
if toward_n_term:
grown[:-1] = new_res[1:]
else:
grown[1:] = new_res[:-1]
new_res = (new_res | grown) & res_in_boundary
for _ in range(-amount): # shrink from this terminus
neighbor = np.zeros(n_res, dtype=bool)
if toward_n_term:
neighbor[1:] = new_res[:-1] # keep only residues with an N-side neighbor
else:
neighbor[:-1] = new_res[1:] # keep only residues with a C-side neighbor
new_res = new_res & neighbor
new_atom_mask = np.zeros(n_atoms, dtype=bool)
for i in range(n_res):
if new_res[i]:
new_atom_mask[res_starts[i] : res_starts[i + 1]] = True
return new_atom_mask
[docs]
class IgFrameworkConditioningTask(ConditionedDesignTask):
"""Framework-conditioning task for antibodies and TCRs.
Conditions on the Ig framework and (optionally) the target/antigen while
leaving CDR loops free for design. It:
- reads the native ``ig_region`` structure annotation,
- conditions framework geometry with internal coordinates,
- supports variable CDR boundary expansion/contraction,
- supports per-CDR fixation (internal coords + sequence conditioning), and
- supports separate rigid-body coordinate groups for multi-chain Ig molecules
(e.g. scFv heavy + light).
Works for both antibodies and TCRs.
Args:
cdr_expand_min: Minimum residue expansion per CDR side (negative = shrink).
cdr_expand_max: Maximum residue expansion per CDR side.
p_fix_all_but_cdr3: Probability of fixing every CDR **except** CDR3,
forcing CDR3 to be designed. When False the per-CDR probability
``p_cdr_fixed`` is used instead. CDR4 is excluded from this logic;
see ``p_design_cdr4``.
p_cdr_fixed: Per-CDR probability of fixing (internal coords + seq
conditioning). Applied independently to each CDR (except CDR4)
when ``p_fix_all_but_cdr3`` is not triggered.
p_design_cdr4: Probability of including CDR4 in the designed (free)
region. Sampled once per example and applied to CDR4 on every Ig
chain; otherwise CDR4 follows the framework conditioning policy.
p_framework_seq_cond: Probability of providing sequence conditioning
on the framework in addition to internal-coord conditioning.
p_framework_bb_only: Probability of restricting framework internal-coord
conditioning to backbone atoms only (CA/N/C/O) rather than all atoms.
p_flexible_target: Probability of giving the target chain **no**
coordinate conditioning at all (free antigen).
p_target_intcoord: Probability of using internal-coord conditioning for
the target (given it is conditioned). Otherwise absolute coords.
p_target_bb_only: Probability of restricting target conditioning to
backbone atoms only.
p_separate_ig_coord_groups: When multiple Ig chains are present
(e.g. heavy + light), probability of assigning each its own rigid-body
coordinate group. Otherwise all Ig chains are in one group.
p_no_hotspots: Probability of providing no hotspot conditioning.
hotspot_max_distance: Å threshold for hotspot eligibility.
max_hotspots_frac_to_provide: Hard cap on the fraction of interface
atoms to label as hotspots.
hotspot_count_distribution: ``"uniform"`` or ``"geometric"``.
hotspot_geometric_p: Geometric distribution parameter (required when
``hotspot_count_distribution="geometric"``).
"""
def __init__(
self,
cdr_expand_min: int = -2,
cdr_expand_max: int = 5,
p_fix_all_but_cdr3: float = 0.3,
p_cdr_fixed: float = 0.2,
p_design_cdr4: float = 0.1,
p_framework_seq_cond: float = 0.8,
p_framework_bb_only: float = 0.3,
p_flexible_target: float = 0.1,
p_target_intcoord: float = 0.5,
p_target_bb_only: float = 0.3,
p_separate_ig_coord_groups: float = 0.5,
p_no_hotspots: float = 0.05,
hotspot_max_distance: float = 7.0,
max_hotspots_frac_to_provide: float = 0.1,
hotspot_count_distribution: Literal["uniform", "geometric"] = "uniform",
hotspot_geometric_p: float | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.cdr_expand_min = cdr_expand_min
self.cdr_expand_max = cdr_expand_max
self.p_fix_all_but_cdr3 = p_fix_all_but_cdr3
self.p_cdr_fixed = p_cdr_fixed
self.p_design_cdr4 = p_design_cdr4
self.p_framework_seq_cond = p_framework_seq_cond
self.p_framework_bb_only = p_framework_bb_only
self.p_flexible_target = p_flexible_target
self.p_target_intcoord = p_target_intcoord
self.p_target_bb_only = p_target_bb_only
self.p_separate_ig_coord_groups = p_separate_ig_coord_groups
self.p_no_hotspots = p_no_hotspots
self.hotspot_max_distance = hotspot_max_distance
self.max_hotspots_frac_to_provide = max_hotspots_frac_to_provide
self.hotspot_count_distribution = hotspot_count_distribution
self.hotspot_geometric_p = hotspot_geometric_p
# ------------------------------------------------------------------
# Pipeline hooks
# ------------------------------------------------------------------
[docs]
def annotate_for_task_selection(self, data: dict) -> dict:
atom_array = data["atom_array"]
if "ig_region" not in atom_array.get_annotation_categories():
raise ValueError(
"IgFrameworkConditioningTask requires per-atom 'ig_region' annotations. "
"Annotate the source CIF and load it with parser_args.extra_fields=[ig_region]."
)
ensure_annotations(atom_array, "is_protein")
regions = atom_array.get_annotation("ig_region")
invalid_regions = sorted(
str(region) for region in set(np.unique(regions)) - {region.value for region in IgRegion}
)
if invalid_regions:
raise ValueError(f"Invalid ig_region value(s): {invalid_regions}")
per_chain_cdr_masks: dict[str, dict[IgRegion, np.ndarray]] = {}
per_chain_ig_masks: dict[str, np.ndarray] = {}
ig_mask = regions != IgRegion.NOT_IG.value
if not ig_mask.any():
raise ValueError(
"IgFrameworkConditioningTask requires nonempty 'ig_region' labels; all atoms are unlabeled."
)
for pn_unit_iid in unique_by_first_occurrence(atom_array.pn_unit_iid[ig_mask]):
pn_unit_mask = atom_array.pn_unit_iid == pn_unit_iid
cdr_masks = {
region: pn_unit_mask & (regions == region.value)
for region in IG_CDR_REGIONS
if np.any(pn_unit_mask & (regions == region.value))
}
per_chain_cdr_masks[pn_unit_iid] = cdr_masks
per_chain_ig_masks[pn_unit_iid] = pn_unit_mask & ig_mask
data["_ig_masks"] = {
"per_chain_cdr_masks": per_chain_cdr_masks,
"per_chain_ig_masks": per_chain_ig_masks,
}
return data
[docs]
def can_apply(self, data: dict) -> bool:
ig_masks = data.get("_ig_masks")
if not ig_masks:
return False
per_chain_cdr_masks = ig_masks["per_chain_cdr_masks"]
per_chain_ig_masks = ig_masks["per_chain_ig_masks"]
if not per_chain_ig_masks:
return False
# Need at least one valid CDR on at least one Ig chain
any_cdr = any(any(m.any() for m in chain_cdrs.values()) for chain_cdrs in per_chain_cdr_masks.values())
if not any_cdr:
return False
# All Ig chains must have at least one CDR
for pn_unit_iid in per_chain_ig_masks:
chain_cdrs = per_chain_cdr_masks.get(pn_unit_iid, {})
if not any(m.any() for m in chain_cdrs.values()):
return False
# There must be target atoms (not Ig chain)
atom_array = data["atom_array"]
combined_ig_mask = np.zeros(atom_array.array_length(), dtype=bool)
for ig_mask in per_chain_ig_masks.values():
combined_ig_mask |= ig_mask
target_mask = atom_array.is_protein & ~combined_ig_mask
return target_mask.any()
[docs]
def forward(self, data: dict) -> dict:
atom_array = data["atom_array"]
ig_masks = data["_ig_masks"]
per_chain_cdr_masks = ig_masks["per_chain_cdr_masks"]
per_chain_ig_masks = ig_masks["per_chain_ig_masks"]
# Combined Ig mask across all Ig chains
combined_ig_mask = np.zeros(atom_array.array_length(), dtype=bool)
for ig_mask in per_chain_ig_masks.values():
combined_ig_mask |= ig_mask
# Target mask: everything that isn't an Ig PN unit
target_mask = ~np.isin(atom_array.pn_unit_iid, np.unique(atom_array.pn_unit_iid[combined_ig_mask]))
# ------------------------------------------------------------------
# Step 1: CDR expansion — sample each terminus independently, apply to all CDRs.
# Sampling the two ends separately lets the mask shift (e.g. +1 / -1) rather
# than only grow or shrink symmetrically about the CDR center.
# ------------------------------------------------------------------
expand_n_term = int(np.random.randint(self.cdr_expand_min, self.cdr_expand_max + 1))
expand_c_term = int(np.random.randint(self.cdr_expand_min, self.cdr_expand_max + 1))
expanded_cdr_masks: dict[str, dict[IgRegion, np.ndarray]] = {}
for pn_unit_iid, chain_cdrs in per_chain_cdr_masks.items():
ig_mask = per_chain_ig_masks[pn_unit_iid]
expanded_cdr_masks[pn_unit_iid] = {}
for cdr_region, cdr_mask in chain_cdrs.items():
expanded_cdr_masks[pn_unit_iid][cdr_region] = _expand_cdr_mask(
atom_array, cdr_mask, ig_mask, expand_n_term, expand_c_term
)
# ------------------------------------------------------------------
# Step 2: Decide which CDRs are fixed (internal coord + seq cond)
# ------------------------------------------------------------------
fixed_cdr_mask = np.zeros(atom_array.array_length(), dtype=bool)
free_cdr_mask = np.zeros(atom_array.array_length(), dtype=bool)
if true_with_prob(self.p_fix_all_but_cdr3):
# Fix all CDRs except CDR3; leave CDR3 free for design. CDR4 is
# excluded here and resolved separately below via p_design_cdr4.
for chain_cdrs in expanded_cdr_masks.values():
for cdr_region, cdr_mask in chain_cdrs.items():
if cdr_region is IgRegion.CDR4:
continue
if cdr_region is IgRegion.CDR3:
free_cdr_mask |= cdr_mask
else:
fixed_cdr_mask |= cdr_mask
else:
# Per-CDR independent fixation probability (CDR4 excluded, see below).
for chain_cdrs in expanded_cdr_masks.values():
for cdr_region, cdr_mask in chain_cdrs.items():
if cdr_region is IgRegion.CDR4:
continue
if true_with_prob(self.p_cdr_fixed):
fixed_cdr_mask |= cdr_mask
else:
free_cdr_mask |= cdr_mask
# CDR4 is designed with one draw shared across all Ig chains; otherwise
# it follows the framework conditioning policy.
design_cdr4 = true_with_prob(self.p_design_cdr4)
for chain_cdrs in expanded_cdr_masks.values():
cdr4_mask = chain_cdrs.get(IgRegion.CDR4)
if cdr4_mask is None:
continue
if design_cdr4: # otherwise treated like a framework region
free_cdr_mask |= cdr4_mask
# Framework = Ig chain minus ALL CDRs (both fixed and free)
all_cdr_mask = fixed_cdr_mask | free_cdr_mask
framework_mask = combined_ig_mask & ~all_cdr_mask
# ------------------------------------------------------------------
# Step 3: Framework internal-coord conditioning (mark to be conditioned)
# ------------------------------------------------------------------
ensure_annotations(atom_array, "is_protein_backbone")
fw_conditioning_mask = (
framework_mask & atom_array.is_protein_backbone
if true_with_prob(self.p_framework_bb_only)
else framework_mask
)
nan_coord_mask = np.any(np.isnan(atom_array.coord), axis=1)
fw_coordinate_conditioning_mask = fw_conditioning_mask & ~nan_coord_mask
# ------------------------------------------------------------------
# Step 4: Fixed CDR internal-coord conditioning + seq conditioning
# ------------------------------------------------------------------
fixed_cdr_conditioning_mask = fixed_cdr_mask & ~nan_coord_mask
if fixed_cdr_mask.any():
seq_condition_mask = C_SEQ.mask(atom_array, default="generate") | fixed_cdr_mask
C_SEQ.set_annotation_from_ground_truth(atom_array, seq_condition_mask)
# ------------------------------------------------------------------
# Step 5: Framework sequence conditioning (optional)
# ------------------------------------------------------------------
if true_with_prob(self.p_framework_seq_cond):
seq_condition_mask = C_SEQ.mask(atom_array, default="generate") | framework_mask
C_SEQ.set_annotation_from_ground_truth(atom_array, seq_condition_mask)
else: # need to remove any type of coordinate conditioning from framework sidechains (keep backbone coords)
fw_coordinate_conditioning_mask = fw_coordinate_conditioning_mask & atom_array.is_protein_backbone
# ------------------------------------------------------------------
# Step 5.5 Apply internal coord conditioning for Ig chains
# ------------------------------------------------------------------
atom_array = convert_to_internal_coords(
atom_array, fw_coordinate_conditioning_mask | fixed_cdr_conditioning_mask
)
# ------------------------------------------------------------------
# Step 6: Multiple Ig chains — coord groups
# ------------------------------------------------------------------
ig_chain_ids = list(per_chain_ig_masks.keys())
if len(ig_chain_ids) > 1 and true_with_prob(self.p_separate_ig_coord_groups):
# Each Ig chain gets its own coord group (can translate/rotate independently)
for pn_unit_iid in ig_chain_ids:
ig_mask = per_chain_ig_masks[pn_unit_iid]
# Only re-group atoms that already received internal coord conditioning
intcrd_mask = C_CRDGRP.is_internal(atom_array)
chain_intcrd = ig_mask & intcrd_mask
if chain_intcrd.any():
# Assign each chain a fresh group before targets are materialized.
coord_groups = C_CRDGRP.annotation(atom_array, default="generate").copy()
coord_groups[chain_intcrd] = coord_groups.max() + 1
C_CRDGRP.set_annotation(atom_array, coord_groups)
# (else: all Ig chains are already in the same group from convert_to_internal_coords calls)
# ------------------------------------------------------------------
# Step 7: Target conditioning
# ------------------------------------------------------------------
target_nan_mask = np.any(np.isnan(atom_array.coord), axis=1)
if true_with_prob(self.p_flexible_target):
# No coordinate conditioning for target
coord_groups = C_CRDGRP.annotation(atom_array, default="generate").copy()
coord_groups[target_mask] = C_CRDGRP.UNKNOWN_COORDS
C_CRDGRP.set_annotation(atom_array, coord_groups)
elif true_with_prob(self.p_target_intcoord):
# Internal-coord conditioning for target
target_cond_mask = (
target_mask & atom_array.is_protein_backbone if true_with_prob(self.p_target_bb_only) else target_mask
)
atom_array = convert_to_internal_coords(atom_array, target_cond_mask & ~target_nan_mask)
else:
# Absolute-coord conditioning for target
target_cond_mask = (
target_mask & atom_array.is_protein_backbone if true_with_prob(self.p_target_bb_only) else target_mask
)
coord_condition_mask = C_CRD.mask(atom_array, default="generate") | (target_cond_mask & ~target_nan_mask)
C_CRD.set_annotation_from_ground_truth(atom_array, coord_condition_mask)
coord_groups = C_CRDGRP.annotation(atom_array, default="generate").copy()
coord_groups[coord_condition_mask] = C_CRDGRP.KNOWN_ABSOLUTE_COORDS
C_CRDGRP.set_annotation(atom_array, coord_groups)
# Target always gets sequence conditioning
seq_condition_mask = C_SEQ.mask(atom_array, default="generate") | target_mask
C_SEQ.set_annotation_from_ground_truth(atom_array, seq_condition_mask)
# ------------------------------------------------------------------
# Step 8: Hotspots
# ------------------------------------------------------------------
if true_with_prob(self.p_no_hotspots):
hotspot_mask = np.zeros(atom_array.array_length(), dtype=bool)
else:
hotspot_mask = sample_hotspots(
atom_array,
ig_chain_ids,
hotspot_max_distance=self.hotspot_max_distance,
max_hotspots_frac_to_provide=self.max_hotspots_frac_to_provide,
hotspot_count_distribution=self.hotspot_count_distribution,
hotspot_geometric_p=self.hotspot_geometric_p,
)
C_HOT.set_annotation(atom_array, hotspot_mask)
# ------------------------------------------------------------------
# Step 9: Index everything
# ------------------------------------------------------------------
C_IDX.set_annotation(atom_array, np.ones(atom_array.array_length(), dtype=bool))
data["atom_array"] = atom_array
if "task" not in data:
data["task"] = {"name": self.__class__.__name__}
data["task"] |= {
"class": self.__class__.__name__,
"mask": hotspot_mask,
"ig_chain_ids": ig_chain_ids,
"target_mask": target_mask,
"num_hotspots": hotspot_mask.sum(),
"cdr_expand_n_term": expand_n_term,
"cdr_expand_c_term": expand_c_term,
"design_cdr4": design_cdr4,
}
data = self.apply_design_task_modifiers(data)
data = self.ensure_atomized_tokens_are_sequence_conditioned(data)
return data