Source code for tmol.optimization._sfxn_modules

import warnings
from copy import deepcopy

import attrs
import numpy
import torch

from tmol.pose import PoseStack
from tmol.score import ScoreFunction
from tmol.kinematics import (
    FoldForest,
    MoveMap,
    NodeType,
    BondDOFTypes,
    JumpDOFTypes,
    PoseStackKinematicsModule,
)
from tmol.kinematics.compiled import inverse_kin


[docs] class CartesianSfxnNetwork(torch.nn.Module): """Differentiable score network over selected Cartesian coordinates.""" def __init__( self, score_function: ScoreFunction, pose_stack: PoseStack, coord_mask=None, cuda_graph: bool | str = False, ): super(CartesianSfxnNetwork, self).__init__() wpsm = score_function.render_whole_pose_scoring_module( pose_stack, cuda_graph=cuda_graph ) self.whole_pose_scoring_module = wpsm self._score_function = score_function self._score_function_versions = ( score_function._terms_version, score_function._options_version, ) self.pose_stack = pose_stack # clone: forward() writes into full_coords in place, which would # otherwise overwrite the caller's coordinates self.full_coords = pose_stack.coords.clone().detach() self._uses_default_coord_mask = coord_mask is None if coord_mask is None: # Padding coordinates never contribute to a pose's score. Excluding # them keeps heterogeneous batches from allocating and updating # meaningless optimizer degrees of freedom. coord_mask = pose_stack.real_atoms self.coord_mask = coord_mask # Precompute flat integer indices for the boolean mask # Flat integer is faster than bool mask # (since torch does nonzero each time under the hood) self._coord_flat_idx = ( self.coord_mask.reshape(-1).nonzero(as_tuple=False).squeeze(-1) ) self._all_coords_movable = ( self._coord_flat_idx.numel() == self.coord_mask.numel() ) self.masked_coords = torch.nn.Parameter( self.full_coords.view(-1, self.full_coords.shape[-1])[self._coord_flat_idx] ) if self._all_coords_movable: self.full_coords = self.masked_coords.view_as(self.full_coords) # pose each element of masked_coords belongs to, for per-pose minimization pose_for_atom = torch.div( self._coord_flat_idx, self.full_coords.shape[1], rounding_mode="floor", ) self.segment_ids = pose_for_atom.repeat_interleave(self.full_coords.shape[-1]) def _reusable_for( self, score_function: ScoreFunction, pose_stack: PoseStack, coord_mask=None, ) -> bool: """Return whether this network can score another pose without rendering.""" # Check cheap Python identity and shape predicates before any tensor # comparison. The normal repeated-topology path shares these tensors, # so it avoids both rebuilding ``real_atoms`` and synchronizing CUDA. if not ( score_function is self._score_function and self._score_function_versions == ( self._score_function._terms_version, self._score_function._options_version, ) and pose_stack.packed_block_types is self.pose_stack.packed_block_types and pose_stack.constraint_set is self.pose_stack.constraint_set and pose_stack.coords.shape == self.pose_stack.coords.shape and pose_stack.inter_residue_connections is self.pose_stack.inter_residue_connections and pose_stack.inter_block_bondsep is self.pose_stack.inter_block_bondsep ): return False if coord_mask is None: if not self._uses_default_coord_mask: return False elif coord_mask is not self.coord_mask and not torch.equal( coord_mask, self.coord_mask ): return False return ( pose_stack.block_type_ind is self.pose_stack.block_type_ind or torch.equal(pose_stack.block_type_ind, self.pose_stack.block_type_ind) ) and ( pose_stack.block_coord_offset is self.pose_stack.block_coord_offset or torch.equal( pose_stack.block_coord_offset, self.pose_stack.block_coord_offset ) ) def _reset( self, score_function: ScoreFunction, pose_stack: PoseStack, coord_mask=None ) -> bool: """Reset for a compatible pose topology; report whether it was reused.""" if not self._reusable_for(score_function, pose_stack, coord_mask): return False self.pose_stack = pose_stack with torch.no_grad(): self.whole_pose_scoring_module.weights.copy_( score_function.weights_tensor().unsqueeze(1) ) if self._all_coords_movable: self.masked_coords.copy_(pose_stack.coords.reshape(-1, 3)) else: self.full_coords.copy_(pose_stack.coords) self.masked_coords.copy_( pose_stack.coords.reshape(-1, 3)[self._coord_flat_idx] ) self.masked_coords.grad = None return True
[docs] def forward(self) -> torch.Tensor: if not self._all_coords_movable: self.full_coords = self.full_coords.detach() self.full_coords.view(-1, self.full_coords.shape[-1])[ self._coord_flat_idx ] = self.masked_coords return self.whole_pose_scoring_module(self.full_coords)
def pose_stack_from_dofs(self) -> PoseStack: if self._all_coords_movable: full_coords = self.masked_coords.detach().view_as(self.full_coords).clone() return attrs.evolve(self.pose_stack, coords=full_coords) full_coords = self.full_coords.detach().clone() full_coords.view(-1, full_coords.shape[-1])[ self._coord_flat_idx ] = self.masked_coords.detach() return attrs.evolve(self.pose_stack, coords=full_coords)
def _same_fold_forest(left: FoldForest, right: FoldForest) -> bool: return ( left.max_n_edges == right.max_n_edges and numpy.array_equal(left.n_edges, right.n_edges) and numpy.array_equal(left.edges, right.edges) ) def _same_move_map(left: MoveMap, right: MoveMap) -> bool: for field in attrs.fields(MoveMap): left_value = getattr(left, field.name) right_value = getattr(right, field.name) if isinstance(left_value, torch.Tensor): if not ( left_value.shape == right_value.shape and left_value.device == right_value.device and torch.equal(left_value, right_value) ): return False elif left_value != right_value: return False return True class _KinematicScoringModule(torch.nn.Module): """Map movable kinematic DOFs through coordinates to weighted pose scores. Every step is out of place so the same module can run eagerly or be captured as a CUDA graph. A graph reads ``base_dofs`` and ``base_coords`` by address on replay, so update them in place rather than reassigning. """ def __init__( self, kin_module: PoseStackKinematicsModule, scoring_module, base_dofs: torch.Tensor, base_coords: torch.Tensor, dof_flat_idx: torch.Tensor, atom_idx: torch.Tensor, ): super().__init__() self.kin_module = kin_module self.scoring_module = scoring_module self.base_dofs = base_dofs self.base_coords = base_coords self.dof_flat_idx = dof_flat_idx self.atom_idx = atom_idx def dofs(self, masked_dofs: torch.Tensor) -> torch.Tensor: return ( self.base_dofs.view(-1) .index_put((self.dof_flat_idx,), masked_dofs) .view_as(self.base_dofs) ) def coords(self, masked_dofs: torch.Tensor) -> torch.Tensor: # Node 0 is the kinematic root; it has no pose-stack atom. kin_coords = self.kin_module(self.dofs(masked_dofs)) flat_coords = self.base_coords.view(-1, 3).index_put( (self.atom_idx,), kin_coords[1:].to(self.base_coords.dtype) ) return flat_coords.view_as(self.base_coords) def forward(self, masked_dofs: torch.Tensor) -> torch.Tensor: return self.scoring_module(self.coords(masked_dofs))
[docs] class KinForestSfxnNetwork(torch.nn.Module): """Differentiable score network over selected kinematic degrees of freedom. Set ``cuda_graph`` to ``"forward_backward"`` (or ``True``) to capture the complete DOF-to-score evaluation and its gradient as CUDA graphs, ``"forward"`` to capture gradient-free evaluation, or ``"both"``. Capture requires CUDA and a fixed topology; :meth:`_reset` reuses the captured graphs for new coordinates. Pass ``fold_forest`` and ``move_map`` to allow that reuse. """ def __init__( self, score_function: ScoreFunction, pose_stack: PoseStack, kin_module: PoseStackKinematicsModule, dof_mask=None, kin_dtype=torch.float32, *, cuda_graph: bool | str = False, fold_forest: FoldForest | None = None, move_map: MoveMap | None = None, ): super(KinForestSfxnNetwork, self).__init__() torch_device = pose_stack.device self.pose_stack = pose_stack # The kinematic graph captures scoring itself; a separate # coordinate-input scoring graph would only retain more memory. wpsm = score_function.render_whole_pose_scoring_module(pose_stack) kmd = kin_module.kmd self.ff_copy = None if fold_forest is None else deepcopy(fold_forest) self.mm_copy = None if move_map is None else deepcopy(move_map) self.kin_module = kin_module self.whole_pose_scoring_module = wpsm self.kin_dtype = kin_dtype self._score_function = score_function self._score_function_versions = ( score_function._terms_version, score_function._options_version, ) self.orig_coords_shape = pose_stack.coords.shape self.id = kmd.forest.id self._atom_idx = self.id[1:].to(torch.int64) base_coords = pose_stack.coords.clone().detach() raw_dofs = self._dofs_from_coords(base_coords) if dof_mask is None: # Default behavior: # Enable minimization of phi_c dofs for bonded atoms # Enable minimization of 6 dofs for jump atoms # - RBx, y, z, and # - RBdel_alpha, beta, gamma dof_mask = torch.zeros( raw_dofs.shape, dtype=torch.bool, device=torch_device ) dof_mask[kmd.forest.doftype == NodeType.bond, BondDOFTypes.phi_c] = True dof_mask[ kmd.forest.doftype == NodeType.jump, : JumpDOFTypes.RBdel_gamma ] = True self.dof_mask = dof_mask # Precompute flat integer indices for the boolean mask # Flat integer is faster than bool mask self._dof_flat_idx = ( self.dof_mask.reshape(-1).nonzero(as_tuple=False).squeeze(-1) ) self.masked_dofs = torch.nn.Parameter(raw_dofs[self.dof_mask]) # pose each element of masked_dofs belongs to, for per-pose minimization. # kmd.forest.id is the index into the flattened (pose, atom) coords; the # root node's id of -1 never survives the dof mask. n_atoms_per_pose = pose_stack.coords.shape[1] pose_for_node = torch.div(self.id, n_atoms_per_pose, rounding_mode="floor") pose_for_dof = pose_for_node.unsqueeze(1).expand(raw_dofs.shape).reshape(-1) self.segment_ids = pose_for_dof[self._dof_flat_idx].to(torch.int64) assert bool((self.segment_ids >= 0).all()), "root node dofs cannot be minimized" self._kinematic_scoring = _KinematicScoringModule( kin_module, wpsm, raw_dofs, base_coords, self._dof_flat_idx, self._atom_idx, ) # A plain dict keeps the captured modules, and the score weights they # hold, out of parameters(): optimizers see only masked_dofs. self._cuda_graphs: dict[str, object] = {} if cuda_graph: mode = "forward_backward" if cuda_graph is True else cuda_graph self.enable_cuda_graphs(mode) def _dofs_from_coords(self, coords: torch.Tensor) -> torch.Tensor: kmd = self.kin_module.kmd kincoords = torch.zeros( (kmd.forest.id.shape[0], 3), dtype=self.kin_dtype, device=coords.device ) kincoords[1:] = coords.view(-1, 3)[self._atom_idx].to(self.kin_dtype) return inverse_kin( kincoords, kmd.forest.parent, kmd.forest.frame_x, kmd.forest.frame_y, kmd.forest.frame_z, kmd.forest.doftype, )
[docs] def enable_cuda_graphs( self, mode: str = "forward_backward" ) -> "KinForestSfxnNetwork": """Capture DOF-to-score evaluation for this fixed topology. Captures forward kinematics, the coordinate scatter, and weighted scoring as one graph and, for gradients, their backward pass as a second graph sharing its memory pool. ``mode`` may be ``"forward"``, ``"forward_backward"``, or ``"both"``; paths already captured are kept. """ if not self.masked_dofs.is_cuda: raise ValueError("CUDA graphs require CUDA degrees of freedom") if mode not in ("forward", "forward_backward", "both"): raise ValueError(f"unsupported CUDA graph mode: {mode!r}") from tmol.score._score_function import _InferenceCUDAGraph from tmol.score.common._cuda_graph import CapturedScoringGraph graph_module = _KinematicScoringModule( self.kin_module, self.whole_pose_scoring_module._graph_capturable_module(), self._kinematic_scoring.base_dofs, self._kinematic_scoring.base_coords, self._dof_flat_idx, self._atom_idx, ) if mode in ("forward", "both") and "forward" not in self._cuda_graphs: self._cuda_graphs["forward"] = _InferenceCUDAGraph( graph_module, self.masked_dofs ) if mode in ("forward_backward", "both") and "autograd" not in self._cuda_graphs: sample = self.masked_dofs.detach().clone().requires_grad_(True) with ( torch.cuda.device(sample.device), torch.enable_grad(), warnings.catch_warnings(), ): # Scoring parameters may retain an AccumulateGrad node on # another stream. Suppress only that known warmup warning. warnings.filterwarnings( "ignore", message="The AccumulateGrad node's stream does not match", ) self._cuda_graphs["autograd"] = CapturedScoringGraph( graph_module, sample ) return self
@property def full_dofs(self) -> torch.Tensor: """All kinematic DOFs at the current movable-DOF values.""" return self._kinematic_scoring.dofs(self.masked_dofs.detach()) @property def full_coords(self) -> torch.Tensor: """Pose-stack coordinates at the current movable-DOF values.""" with torch.no_grad(): return self._kinematic_scoring.coords(self.masked_dofs) @property def flat_coords(self) -> torch.Tensor: return self.full_coords.view(-1, 3) def _reusable_for( self, score_function: ScoreFunction, pose_stack: PoseStack, fold_forest: FoldForest, move_map: MoveMap, ) -> bool: """Return whether this network can score another pose without rendering.""" # Check cheap Python identity and shape predicates before any tensor # comparison. The normal repeated-topology path shares these tensors, # so it avoids both rebuilding ``real_atoms`` and synchronizing CUDA. if not ( score_function is self._score_function and self._score_function_versions == ( self._score_function._terms_version, self._score_function._options_version, ) and pose_stack.packed_block_types is self.pose_stack.packed_block_types and pose_stack.constraint_set is self.pose_stack.constraint_set and pose_stack.coords.shape == self.pose_stack.coords.shape and pose_stack.inter_residue_connections is self.pose_stack.inter_residue_connections and pose_stack.inter_block_bondsep is self.pose_stack.inter_block_bondsep and self.ff_copy is not None and self.mm_copy is not None and _same_fold_forest(fold_forest, self.ff_copy) ): return False # The fold forest and move map are mutable, so compare their contents # against the copies taken at construction rather than identity. if not _same_move_map(move_map, self.mm_copy): return False return ( pose_stack.block_type_ind is self.pose_stack.block_type_ind or torch.equal(pose_stack.block_type_ind, self.pose_stack.block_type_ind) ) and ( pose_stack.block_coord_offset is self.pose_stack.block_coord_offset or torch.equal( pose_stack.block_coord_offset, self.pose_stack.block_coord_offset ) ) def _reset( self, score_function: ScoreFunction, pose_stack: PoseStack, fold_forest: FoldForest, move_map: MoveMap, ) -> bool: """Reset for a compatible pose topology; report whether it was reused.""" if not self._reusable_for(score_function, pose_stack, fold_forest, move_map): return False self.pose_stack = pose_stack # Copy in place: captured graphs read these buffers by address. scoring = self._kinematic_scoring with torch.no_grad(): self.whole_pose_scoring_module.weights.copy_( score_function.weights_tensor().unsqueeze(1) ) scoring.base_coords.copy_(pose_stack.coords) scoring.base_dofs.copy_(self._dofs_from_coords(pose_stack.coords)) self.masked_dofs.copy_(scoring.base_dofs.view(-1)[self._dof_flat_idx]) self.masked_dofs.grad = None return True
[docs] def forward(self) -> torch.Tensor: if torch.is_grad_enabled(): graph = self._cuda_graphs.get("autograd") if graph is not None: # Graph replay reuses its output storage. Optimizers such as # LBFGS retain earlier losses, so return owned storage. return graph(self.masked_dofs).clone() else: graph = self._cuda_graphs.get("forward") if graph is not None: return graph(self.masked_dofs) return self._kinematic_scoring(self.masked_dofs)
def pose_stack_from_dofs(self) -> PoseStack: return attrs.evolve(self.pose_stack, coords=self.full_coords)