| """Bundled inverse-folding inference utilities for Evo-IF. |
| |
| This module contains only the model, structure featurization, checkpoint |
| loading, sequence conversion, and iterative sampler used by ``infer.py``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from collections import OrderedDict |
| from pathlib import Path |
| from typing import Literal, Mapping, Optional |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import torch.utils |
| import torch.utils.checkpoint |
|
|
| from .structure_io import CIFParser, PDBParser |
|
|
| |
| |
| |
|
|
| def featurize(batch, polytype_to_int, restype_to_int, atom_dict, device): |
| |
| |
| valid_batch = [] |
| for b in batch: |
| |
| if isinstance(b[0], list): |
| continue |
| |
| if not isinstance(b[0], dict): |
| continue |
| |
| if not isinstance(b[1], (int, torch.Tensor)): |
| continue |
| valid_batch.append(b) |
| batch = valid_batch |
| B = len(batch) |
| if B > 0: |
| |
| L_list = [] |
| for b in batch: |
| L = b[1] |
| if isinstance(L, int): |
| L = torch.tensor(L) |
| L_list.append(L) |
| L_stack = torch.stack(L_list) |
| L_max = torch.max(L_stack) |
| X = torch.zeros([B, L_max, len(atom_dict), 3], dtype=torch.float32) |
| X_m = torch.zeros([B, L_max, len(atom_dict)], dtype=torch.int32) |
|
|
| mask = torch.zeros([B, L_max], dtype=torch.int32) |
| S = restype_to_int["PAD"] * torch.ones([B, L_max], dtype=torch.int64) |
| R_idx = -100*torch.ones([B, L_max], dtype=torch.int32) |
| chain_labels = -1*torch.ones([B, L_max], dtype=torch.int64) |
|
|
| protein_mask = torch.zeros([B, L_max], dtype=torch.int32) |
| dna_mask = torch.zeros([B, L_max], dtype=torch.int32) |
| rna_mask = torch.zeros([B, L_max], dtype=torch.int32) |
|
|
| R_polymer_type = polytype_to_int["PAD"] * torch.ones([B, L_max], dtype=torch.int64) |
|
|
| interface_mask = torch.zeros([B, L_max], dtype=torch.int32) |
| base_pair_mask = torch.zeros([B, L_max], dtype=torch.int32) |
| base_pair_index = torch.zeros([B, L_max], dtype=torch.int64) |
| canonical_base_pair_mask = torch.zeros([B, L_max], dtype=torch.int32) |
| canonical_base_pair_index = torch.zeros([B, L_max], dtype=torch.int64) |
|
|
| aligned_ppm = torch.zeros([B, L_max, len(restype_to_int)], dtype=torch.float64) |
| ppm_mask = torch.zeros([B, L_max], dtype=torch.int32) |
|
|
| structure_paths = [] |
| assembly_ids = [] |
|
|
| for i, b in enumerate(batch): |
| out_dict = b[0] |
| X[i,:L_stack[i]] = out_dict["X"][None,] |
| X_m[i,:L_stack[i]] = out_dict["X_m"][None,] |
| mask[i,:L_stack[i]] = torch.ones_like(out_dict["S"][None,], dtype=torch.int32) |
| S[i,:L_stack[i]] = out_dict["S"][None,] |
| R_idx[i,:L_stack[i]] = out_dict["R_idx"][None,] |
| chain_labels[i,:L_stack[i]] = out_dict["chain_labels"][None,] |
|
|
| protein_mask[i,:L_stack[i]] = out_dict["protein_mask"][None,] |
| dna_mask[i,:L_stack[i]] = out_dict["dna_mask"][None,] |
| rna_mask[i,:L_stack[i]] = out_dict["rna_mask"][None,] |
|
|
| R_polymer_type[i,:L_stack[i]] = out_dict["R_polymer_type"][None,] |
|
|
| interface_mask[i,:L_stack[i]] = out_dict["interface_mask"][None,] |
| base_pair_mask[i,:L_stack[i]] = out_dict["base_pair_mask"][None,] |
| base_pair_index[i,:L_stack[i]] = out_dict["base_pair_index"][None,] |
| canonical_base_pair_mask[i,:L_stack[i]] = out_dict["canonical_base_pair_mask"][None,] |
| canonical_base_pair_index[i,:L_stack[i]] = out_dict["canonical_base_pair_index"][None,] |
|
|
| aligned_ppm[i,:L_stack[i]] = out_dict["aligned_ppm"][None,] |
| ppm_mask[i,:L_stack[i]] = out_dict["ppm_mask"][None,] |
|
|
| structure_paths.append(out_dict["structure_path"]) |
| assembly_ids.append(out_dict["assembly_id"]) |
|
|
| out_dict = {} |
|
|
| out_dict["X"] = X.to(device) |
| out_dict["X_m"] = X_m.to(device) |
| out_dict["mask"] = mask.to(device) |
|
|
| out_dict["S"] = S.long().to(device) |
| out_dict["R_idx"] = R_idx.to(device) |
| out_dict["chain_labels"] = chain_labels.to(device) |
|
|
| out_dict["protein_mask"] = protein_mask.to(device) |
| out_dict["dna_mask"] = dna_mask.to(device) |
| out_dict["rna_mask"] = rna_mask.to(device) |
|
|
| out_dict["R_polymer_type"] = R_polymer_type.to(device) |
|
|
| out_dict["interface_mask"] = interface_mask.to(device) |
| out_dict["base_pair_mask"] = base_pair_mask.to(device) |
| out_dict["base_pair_index"] = base_pair_index.to(device) |
| out_dict["canonical_base_pair_mask"] = canonical_base_pair_mask.to(device) |
| out_dict["canonical_base_pair_index"] = canonical_base_pair_index.to(device) |
|
|
| out_dict["aligned_ppm"] = aligned_ppm.to(device) |
| out_dict["ppm_mask"] = ppm_mask.to(device) |
|
|
| out_dict["structure_path"] = structure_paths |
| out_dict["assembly_id"] = assembly_ids |
|
|
| return out_dict |
|
|
| else: |
| return "pass" |
|
|
| |
| def gather_edges(edges, neighbor_idx): |
| |
| neighbors = neighbor_idx.unsqueeze(-1).expand(-1, -1, -1, edges.size(-1)) |
| edge_features = torch.gather(edges, 2, neighbors) |
| return edge_features |
|
|
| def gather_nodes(nodes, neighbor_idx): |
| |
| |
| neighbors_flat = neighbor_idx.reshape((neighbor_idx.shape[0], -1)) |
| neighbors_flat = neighbors_flat.unsqueeze(-1).expand(-1, -1, nodes.size(2)) |
| |
| neighbor_features = torch.gather(nodes, 1, neighbors_flat) |
| neighbor_features = neighbor_features.view(list(neighbor_idx.shape)[:3] + [-1]) |
| return neighbor_features |
|
|
| def gather_nodes_t(nodes, neighbor_idx): |
| |
| idx_flat = neighbor_idx.unsqueeze(-1).expand(-1, -1, nodes.size(2)) |
| neighbor_features = torch.gather(nodes, 1, idx_flat) |
| return neighbor_features |
|
|
| def cat_neighbors_nodes(h_nodes, h_neighbors, E_idx): |
| h_nodes = gather_nodes(h_nodes, E_idx) |
| h_nn = torch.cat([h_neighbors, h_nodes], -1) |
| return h_nn |
|
|
|
|
| class EncLayer(nn.Module): |
| def __init__(self, num_hidden, num_in, dropout=0.1, num_heads=None, scale=30): |
| super(EncLayer, self).__init__() |
| self.num_hidden = num_hidden |
| self.num_in = num_in |
| self.scale = scale |
| self.dropout1 = nn.Dropout(dropout) |
| self.dropout2 = nn.Dropout(dropout) |
| self.dropout3 = nn.Dropout(dropout) |
| self.norm1 = nn.LayerNorm(num_hidden) |
| self.norm2 = nn.LayerNorm(num_hidden) |
| self.norm3 = nn.LayerNorm(num_hidden) |
|
|
| self.W1 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) |
| self.W2 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.W3 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.W11 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) |
| self.W12 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.W13 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.act = torch.nn.GELU() |
| self.dense = PositionWiseFeedForward(num_hidden, num_hidden * 4) |
|
|
| def forward(self, h_V, h_E, E_idx, mask_V=None, mask_attend=None): |
| """ Parallel computation of full transformer layer """ |
|
|
| h_EV = cat_neighbors_nodes(h_V, h_E, E_idx) |
| h_V_expand = h_V.unsqueeze(-2).expand(-1,-1,h_EV.size(-2),-1) |
| h_EV = torch.cat([h_V_expand, h_EV], -1) |
| h_message = self.W3(self.act(self.W2(self.act(self.W1(h_EV))))) |
| if mask_attend is not None: |
| h_message = mask_attend.unsqueeze(-1) * h_message |
| dh = torch.sum(h_message, -2) / self.scale |
| h_V = self.norm1(h_V + self.dropout1(dh)) |
|
|
| dh = self.dense(h_V) |
| h_V = self.norm2(h_V + self.dropout2(dh)) |
| if mask_V is not None: |
| mask_V = mask_V.unsqueeze(-1) |
| h_V = mask_V * h_V |
|
|
| h_EV = cat_neighbors_nodes(h_V, h_E, E_idx) |
| h_V_expand = h_V.unsqueeze(-2).expand(-1,-1,h_EV.size(-2),-1) |
| h_EV = torch.cat([h_V_expand, h_EV], -1) |
| h_message = self.W13(self.act(self.W12(self.act(self.W11(h_EV))))) |
| h_E = self.norm3(h_E + self.dropout3(h_message)) |
| return h_V, h_E |
|
|
|
|
|
|
| class DecLayer(nn.Module): |
| def __init__(self, num_hidden, num_in, dropout=0.1, num_heads=None, scale=30): |
| super(DecLayer, self).__init__() |
| self.num_hidden = num_hidden |
| self.num_in = num_in |
| self.scale = scale |
| self.dropout1 = nn.Dropout(dropout) |
| self.dropout2 = nn.Dropout(dropout) |
| self.norm1 = nn.LayerNorm(num_hidden) |
| self.norm2 = nn.LayerNorm(num_hidden) |
|
|
| self.W1 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) |
| self.W2 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.W3 = nn.Linear(num_hidden, num_hidden, bias=True) |
| self.act = torch.nn.GELU() |
| self.dense = PositionWiseFeedForward(num_hidden, num_hidden * 4) |
|
|
| def forward(self, h_V, h_E, mask_V=None, mask_attend=None): |
| """ Parallel computation of full transformer layer """ |
|
|
| |
| h_V_expand = h_V.unsqueeze(-2).expand(-1,-1,h_E.size(-2),-1) |
| h_EV = torch.cat([h_V_expand, h_E], -1) |
|
|
| h_message = self.W3(self.act(self.W2(self.act(self.W1(h_EV))))) |
| if mask_attend is not None: |
| h_message = mask_attend.unsqueeze(-1) * h_message |
| dh = torch.sum(h_message, -2) / self.scale |
|
|
| h_V = self.norm1(h_V + self.dropout1(dh)) |
|
|
| |
| dh = self.dense(h_V) |
| h_V = self.norm2(h_V + self.dropout2(dh)) |
|
|
| if mask_V is not None: |
| mask_V = mask_V.unsqueeze(-1) |
| h_V = mask_V * h_V |
| return h_V |
|
|
| class PositionWiseFeedForward(nn.Module): |
| def __init__(self, num_hidden, num_ff): |
| super(PositionWiseFeedForward, self).__init__() |
| self.W_in = nn.Linear(num_hidden, num_ff, bias=True) |
| self.W_out = nn.Linear(num_ff, num_hidden, bias=True) |
| self.act = torch.nn.GELU() |
| def forward(self, h_V): |
| h = self.act(self.W_in(h_V)) |
| h = self.W_out(h) |
| return h |
|
|
| class PositionalEncodings(nn.Module): |
| def __init__(self, num_embeddings, max_relative_feature=32): |
| super(PositionalEncodings, self).__init__() |
| self.num_embeddings = num_embeddings |
| self.max_relative_feature = max_relative_feature |
| self.linear = nn.Linear(2*max_relative_feature+1+1, num_embeddings) |
|
|
| def forward(self, offset, mask): |
| d = torch.clip(offset + self.max_relative_feature, 0, 2*self.max_relative_feature)*mask + (1-mask)*(2*self.max_relative_feature+1) |
| d_onehot = torch.nn.functional.one_hot(d, 2*self.max_relative_feature+1+1) |
| E = self.linear(d_onehot.float()) |
| return E |
|
|
| class ProteinFeatures(nn.Module): |
| def __init__(self, |
| edge_features, |
| node_features, |
| num_positional_embeddings=16, |
| num_rbf=16, |
| top_k=30, |
| atom_dict=None, |
| polytype_to_int=None, |
| protein_augment_eps=0., |
| dna_augment_eps=0., |
| rna_augment_eps=0., |
| na_ref_atom="C1'", |
| include_pred_na_N=1, |
| device=None): |
| """ Extract protein features """ |
| super(ProteinFeatures, self).__init__() |
|
|
| if atom_dict is None: |
| raise Exception("atom_dict is necessary for featurization!") |
| |
| if polytype_to_int is None: |
| raise Exception("polytype_to_int is necessary for featurization!") |
|
|
| self.N_idx = atom_dict["N"] |
| self.CA_idx = atom_dict["CA"] |
| self.C_idx = atom_dict["C"] |
|
|
| self.O4prime_idx = atom_dict["O4'"] |
| self.C1prime_idx = atom_dict["C1'"] |
| self.C2prime_idx = atom_dict["C2'"] |
|
|
| self.na_ref_atom_idx = atom_dict[na_ref_atom] |
|
|
| self.edge_features = edge_features |
| self.node_features = node_features |
| self.top_k = top_k |
|
|
| self.protein_augment_eps = protein_augment_eps |
| self.dna_augment_eps = dna_augment_eps |
| self.rna_augment_eps = rna_augment_eps |
|
|
| self.num_rbf = num_rbf |
| self.num_positional_embeddings = num_positional_embeddings |
|
|
| self.embeddings = PositionalEncodings(num_positional_embeddings) |
|
|
| self.num_polytypes = len(polytype_to_int) |
| self.node_in = len(polytype_to_int) |
|
|
| self.node_embedding = nn.Linear(self.node_in, node_features, bias=False) |
| self.norm_nodes = nn.LayerNorm(node_features) |
|
|
| total_atoms = len(atom_dict) + 1 |
|
|
| self.include_pred_na_N = include_pred_na_N |
| if self.include_pred_na_N: |
| total_atoms = total_atoms + 1 |
|
|
| self.edge_in = num_positional_embeddings + num_rbf*total_atoms*total_atoms |
| self.edge_embedding = nn.Linear(self.edge_in, edge_features, bias=False) |
| self.norm_edges = nn.LayerNorm(edge_features) |
|
|
|
|
| def _dist(self, X, mask, eps = 1E-6): |
| mask_2D = torch.unsqueeze(mask,1) * torch.unsqueeze(mask,2) |
| dX = torch.unsqueeze(X,1) - torch.unsqueeze(X,2) |
| D = mask_2D * torch.sqrt(torch.sum(dX**2, 3) + eps) |
| D_max, _ = torch.max(D, -1, keepdim=True) |
| D_adjust = D + (1. - mask_2D) * D_max |
| sampled_top_k = self.top_k |
| D_neighbors, E_idx = torch.topk(D_adjust, np.minimum(self.top_k, X.shape[1]), dim=-1, largest=False) |
| return D_neighbors, E_idx |
|
|
| def _rbf(self, D): |
| device = D.device |
| D_min, D_max, D_count = 2., 22., self.num_rbf |
| D_mu = torch.linspace(D_min, D_max, D_count, device=device) |
| D_mu = D_mu.view([1,1,1,1,1,-1]) |
| D_sigma = (D_max - D_min) / D_count |
| D_expand = torch.unsqueeze(D, -1) |
| RBF = torch.exp(-((D_expand - D_mu) / D_sigma)**2) |
| return RBF |
|
|
| def _get_all_rbf(self, X, E_idx, X_m): |
| |
| X_flat = X.reshape((X.shape[0], X.shape[1], -1)) |
| X_flat_g = gather_nodes(X_flat, E_idx) |
| X_g = X_flat_g.reshape(list(X_flat_g.shape)[:-1] + list(X.shape[-2:])) |
| D = torch.sqrt(torch.sum((X[:,:,None,:,None,:] - X_g[:,:,:,None,:,:])**2,-1) + 1e-6) |
| RBF_all = self._rbf(D) |
| X_m_gathered = gather_nodes(X_m, E_idx) |
| RBF_all = RBF_all*X_m[:,:,None,:,None,None]*X_m_gathered[:,:,:,None,:,None] |
| RBF_all = RBF_all.view([X.shape[0], X.shape[1], E_idx.shape[2],-1]) |
| return RBF_all |
| |
| def get_Cb(self, N, Ca, C, w_a, w_b, w_c): |
| b = Ca - N |
| c = C - Ca |
| a = torch.cross(b, c, dim=-1) |
| Cb = w_a * a + w_b * b + w_c * c + Ca |
| return Cb |
|
|
| def forward(self, feature_dict): |
| X = feature_dict["X"] |
| mask = feature_dict["mask"] |
| R_idx = feature_dict["R_idx"] |
| chain_labels = feature_dict["chain_labels"] |
| X_m = feature_dict["X_m"] |
| protein_mask = feature_dict["protein_mask"] |
| dna_mask = feature_dict["dna_mask"] |
| rna_mask = feature_dict["rna_mask"] |
| R_polymer_type = feature_dict["R_polymer_type"] |
|
|
| if self.training and (self.protein_augment_eps > 0 or \ |
| self.dna_augment_eps > 0 or \ |
| self.rna_augment_eps > 0): |
| augment_eps = protein_mask * self.protein_augment_eps + \ |
| dna_mask * self.dna_augment_eps + \ |
| rna_mask * self.rna_augment_eps |
| |
| X = X + X_m[:,:,:,None] * augment_eps[:,:,None,None] * torch.randn_like(X) |
| |
| Ca = X[:,:,self.CA_idx,:] |
| N = X[:,:,self.N_idx,:] |
| C = X[:,:,self.C_idx,:] |
|
|
| Cb = self.get_Cb(N, Ca, C, w_a = -0.58273431, w_b = 0.56802827, w_c = -0.54067466) |
|
|
| na_ref_atom = X[:,:,self.na_ref_atom_idx,:] |
| if self.include_pred_na_N: |
| O4prime = X[:,:,self.O4prime_idx,:] |
| C1prime = X[:,:,self.C1prime_idx,:] |
| C2prime = X[:,:,self.C2prime_idx,:] |
|
|
| N_na = self.get_Cb(O4prime, C1prime, C2prime, w_a = -0.56967352, w_b = 0.51055973, w_c = -0.53122153) |
|
|
| augmented_X = (X, Cb[:,:,None,:], N_na[:,:,None,:]) |
| augmented_X_m = (X_m, protein_mask[:,:,None], (rna_mask + dna_mask)[:,:,None]) |
| else: |
| augmented_X = (X, Cb[:,:,None,:]) |
| augmented_X_m = (X_m, protein_mask[:,:,None]) |
|
|
| augmented_X = torch.cat(augmented_X, -2) |
| augmented_X_m = torch.cat(augmented_X_m, -1) |
|
|
| |
| |
| D_neighbors, E_idx = self._dist(Ca + na_ref_atom, mask) |
|
|
| RBF_all = self._get_all_rbf(augmented_X, E_idx, augmented_X_m) |
|
|
| offset = R_idx[:,:,None]-R_idx[:,None,:] |
| offset = gather_edges(offset[:,:,:,None], E_idx)[:,:,:,0] |
|
|
| d_chains = ((chain_labels[:, :, None] - chain_labels[:,None,:])==0).long() |
| E_chains = gather_edges(d_chains[:,:,:,None], E_idx)[:,:,:,0] |
| E_positional = self.embeddings(offset.long(), E_chains) |
| E = torch.cat((E_positional, RBF_all), -1) |
| E = self.edge_embedding(E) |
| E = self.norm_edges(E) |
|
|
| R_polymer_type_one_hot = torch.nn.functional.one_hot(R_polymer_type, num_classes = self.num_polytypes).float() |
| V = R_polymer_type_one_hot |
|
|
| V = self.node_embedding(V) |
| V = self.norm_nodes(V) |
|
|
| return V, E, E_idx |
|
|
| class ProteinMPNNDiffusion(nn.Module): |
| """ |
| NA-MPNN with Absorbing State Diffusion support. |
| |
| Key differences from ProteinMPNN: |
| - Bidirectional attention in decoder (no causal masking) |
| - Accepts masked sequences as input |
| - Predicts all positions simultaneously (MLM-style) |
| |
| This enables iterative denoising for sequence generation, |
| inspired by ProRefiner and DPLM. |
| """ |
| def __init__(self, |
| node_features=128, |
| edge_features=128, |
| hidden_dim=128, |
| num_encoder_layers=3, |
| num_decoder_layers=3, |
| atom_dict=None, |
| restype_to_int=None, |
| polytype_to_int=None, |
| vocab=33, |
| num_letters=33, |
| k_neighbors=32, |
| protein_augment_eps=0.1, |
| dna_augment_eps=0.1, |
| rna_augment_eps=0.1, |
| dropout=0.1, |
| na_ref_atom="C1'", |
| include_pred_na_N=1, |
| use_sequence_context=True, |
| device=None): |
| """ |
| Args: |
| use_sequence_context: If True, use sequence embeddings in decoder |
| (allows model to see unmasked positions) |
| """ |
| super(ProteinMPNNDiffusion, self).__init__() |
|
|
| |
| self.node_features = node_features |
| self.edge_features = edge_features |
| self.vocab = vocab |
| self.hidden_dim = hidden_dim |
| self.use_sequence_context = use_sequence_context |
|
|
| if restype_to_int is None: |
| raise Exception("restype_to_int dictionary is necessary!") |
|
|
| self.mask_token = restype_to_int["MAS"] |
|
|
| self.features = ProteinFeatures(node_features, |
| edge_features, |
| top_k=k_neighbors, |
| atom_dict=atom_dict, |
| polytype_to_int=polytype_to_int, |
| protein_augment_eps=protein_augment_eps, |
| dna_augment_eps=dna_augment_eps, |
| rna_augment_eps=rna_augment_eps, |
| na_ref_atom=na_ref_atom, |
| include_pred_na_N=include_pred_na_N, |
| device=device) |
|
|
| self.W_e = nn.Linear(edge_features, hidden_dim, bias=True) |
| self.W_v = nn.Linear(node_features, hidden_dim, bias=True) |
| self.W_s = nn.Embedding(vocab, hidden_dim) |
|
|
| |
| self.encoder_layers = nn.ModuleList([ |
| EncLayer(hidden_dim, hidden_dim*2, dropout=dropout) |
| for _ in range(num_encoder_layers) |
| ]) |
|
|
| |
| self.decoder_layers = nn.ModuleList([ |
| DecLayer(hidden_dim, hidden_dim*3, dropout=dropout) |
| for _ in range(num_decoder_layers) |
| ]) |
|
|
| self.W_out = nn.Linear(hidden_dim, num_letters, bias=True) |
|
|
| for p in self.parameters(): |
| if p.dim() > 1: |
| nn.init.xavier_uniform_(p) |
|
|
| def forward(self, feature_dict, return_embeddings=False): |
| """ |
| Forward pass with bidirectional attention. |
| |
| Unlike autoregressive ProteinMPNN, this uses full bidirectional |
| attention in the decoder, allowing the model to see all context |
| (both masked and unmasked positions) when making predictions. |
| |
| Args: |
| feature_dict: Dictionary containing: |
| - X: [B, L, num_atoms, 3] Coordinates |
| - S: [B, L] Sequence (may contain MASK tokens) |
| - mask: [B, L] Valid position mask |
| - protein_mask, dna_mask, rna_mask: Polymer type masks |
| - R_idx, chain_labels: Residue indices and chain labels |
| - R_polymer_type: Polymer type for each position |
| return_embeddings: If True, also return hidden embeddings |
| |
| Returns: |
| log_probs: [B, L, V] Log probabilities for each position |
| probs: [B, L, V] Probabilities for each position |
| (optional) h_V: [B, L, H] Hidden embeddings |
| """ |
| X = feature_dict["X"] |
| S = feature_dict["S"] |
| mask = feature_dict["mask"] |
| |
| device = X.device |
| |
| |
| V, E, E_idx = self.features(feature_dict) |
| h_V = self.W_v(V) |
| h_E = self.W_e(E) |
|
|
| |
| mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) |
| mask_attend = mask.unsqueeze(-1) * mask_attend |
| |
| for layer in self.encoder_layers: |
| if self.training: |
| h_V, h_E = torch.utils.checkpoint.checkpoint( |
| layer, h_V, h_E, E_idx, mask, mask_attend, |
| use_reentrant=False |
| ) |
| else: |
| h_V, h_E = layer(h_V, h_E, E_idx, mask, mask_attend) |
| |
| |
| h_S = self.W_s(S) |
| |
| |
| |
| |
| |
| if self.use_sequence_context: |
| |
| h_ES = cat_neighbors_nodes(h_S, h_E, E_idx) |
| else: |
| |
| h_ES = cat_neighbors_nodes(torch.zeros_like(h_S), h_E, E_idx) |
| |
| |
| h_EXV_encoder = cat_neighbors_nodes(h_V, h_ES, E_idx) |
| |
| |
| |
| |
| mask_1D = mask.view([mask.size(0), mask.size(1), 1, 1]) |
| mask_attend_decoder = mask_1D * gather_nodes(mask.unsqueeze(-1), E_idx) |
| |
| for layer in self.decoder_layers: |
| h_ESV = h_EXV_encoder * mask_attend_decoder |
| if self.training: |
| h_V = torch.utils.checkpoint.checkpoint( |
| layer, h_V, h_ESV, mask, |
| use_reentrant=False |
| ) |
| else: |
| h_V = layer(h_V, h_ESV, mask) |
|
|
| logits = self.W_out(h_V) |
| log_probs = torch.nn.functional.log_softmax(logits, dim=-1) |
| probs = torch.nn.functional.softmax(logits, dim=-1) |
|
|
| if return_embeddings: |
| return log_probs, probs, h_V |
| return log_probs, probs |
|
|
| |
| |
| |
|
|
| class PDBDataset(torch.utils.data.Dataset): |
| """Parser metadata and structure assembly helpers needed at inference.""" |
|
|
| def __init__( |
| self, |
| cif_parser, |
| pdb_parser, |
| atom_list_to_save=None, |
| parse_protein=1, |
| parse_dna=1, |
| parse_rna=1, |
| parse_rna_as_dna=0, |
| na_shared_tokens=0, |
| protein_backbone_occ_cutoff=0.8, |
| protein_side_chain_occ_cutoff=0.5, |
| dna_backbone_occ_cutoff=0.8, |
| dna_side_chain_occ_cutoff=0.5, |
| rna_backbone_occ_cutoff=0.8, |
| rna_side_chain_occ_cutoff=0.5, |
| crop_large_structures=0, |
| batch_tokens=6000, |
| na_ref_atom="C1'", |
| parse_ppms=0, |
| min_overlap_length=5, |
| drop_protein_probability=0, |
| na_only_as_uniform_ppm=0, |
| protein_interface_residue_mutation_probability=0, |
| mutate_base_pair_together=0, |
| mutate_entire_side_chain_interface_probability=0, |
| na_non_interface_as_uniform_ppm=0, |
| ): |
| |
| |
| |
| self.crop_large_structures = bool(crop_large_structures) |
| del ( |
| parse_ppms, |
| min_overlap_length, |
| drop_protein_probability, |
| na_only_as_uniform_ppm, |
| protein_interface_residue_mutation_probability, |
| mutate_base_pair_together, |
| mutate_entire_side_chain_interface_probability, |
| na_non_interface_as_uniform_ppm, |
| ) |
|
|
| if atom_list_to_save is None: |
| atom_list_to_save = [ |
| "N", "CA", "C", "O", |
| "OP1", "OP2", "P", "O5'", "C5'", "C4'", "O4'", |
| "C3'", "O3'", "C2'", "O2'", "C1'", |
| ] |
|
|
| self.cif_parser = cif_parser |
| self.pdb_parser = pdb_parser |
| self.atom_list_to_save = atom_list_to_save |
| self.num_atoms_to_save = len(atom_list_to_save) |
| self.atom_dict = dict(zip(atom_list_to_save, range(self.num_atoms_to_save))) |
|
|
| self.parse_protein = parse_protein |
| self.parse_dna = parse_dna |
| self.parse_rna = parse_rna |
| self.parse_rna_as_dna = parse_rna_as_dna |
| self.na_shared_tokens = na_shared_tokens |
| self.batch_tokens = batch_tokens |
| self.na_ref_atom = na_ref_atom |
|
|
| self.protein_backbone_occ_cutoff = protein_backbone_occ_cutoff |
| self.protein_side_chain_occ_cutoff = protein_side_chain_occ_cutoff |
| self.dna_backbone_occ_cutoff = dna_backbone_occ_cutoff |
| self.dna_side_chain_occ_cutoff = dna_side_chain_occ_cutoff |
| self.rna_backbone_occ_cutoff = rna_backbone_occ_cutoff |
| self.rna_side_chain_occ_cutoff = rna_side_chain_occ_cutoff |
|
|
| self.polytypes = ["PP", "DNA", "RNA", "UNK", "MAS", "PAD"] |
| self.polytype_to_int = dict(zip(self.polytypes, range(len(self.polytypes)))) |
| if self.parse_rna_as_dna: |
| self.polytype_to_int["RNA"] = self.polytype_to_int["DNA"] |
|
|
| self.restypes = [ |
| "ALA", "ARG", "ASN", "ASP", "CYS", "GLN", "GLU", "GLY", |
| "HIS", "ILE", "LEU", "LYS", "MET", "PHE", "PRO", "SER", |
| "THR", "TRP", "TYR", "VAL", "UNK", |
| "DA", "DC", "DG", "DT", "DX", |
| "A", "C", "G", "U", "RX", "MAS", "PAD", |
| ] |
| self.protein_restypes = [ |
| "ALA", "ARG", "ASN", "ASP", "CYS", "GLN", "GLU", "GLY", |
| "HIS", "ILE", "LEU", "LYS", "MET", "PHE", "PRO", "SER", |
| "THR", "TRP", "TYR", "VAL", "UNK", |
| ] |
| self.dna_restypes = ["DA", "DC", "DG", "DT", "DX"] |
| self.rna_restypes = ["A", "C", "G", "U", "RX"] |
|
|
| self.restype_3_to_1 = { |
| "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", |
| "GLN": "Q", "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", |
| "LEU": "L", "LYS": "K", "MET": "M", "PHE": "F", "PRO": "P", |
| "SER": "S", "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V", |
| "UNK": "X", "DA": "a", "DC": "c", "DG": "g", "DT": "t", |
| "DX": "x", "A": "b", "C": "d", "G": "h", "U": "u", |
| "RX": "y", "MAS": "-", "PAD": "+", |
| } |
| self.restype_to_int = dict(zip(self.restypes, range(len(self.restypes)))) |
| self.int_to_restype = dict(zip(range(len(self.restypes)), self.restypes)) |
|
|
| if self.parse_rna_as_dna or self.na_shared_tokens: |
| self.restype_to_int["A"] = self.restype_to_int["DA"] |
| self.restype_to_int["C"] = self.restype_to_int["DC"] |
| self.restype_to_int["G"] = self.restype_to_int["DG"] |
| self.restype_to_int["U"] = self.restype_to_int["DT"] |
| self.restype_to_int["RX"] = self.restype_to_int["DX"] |
|
|
| self.protein_backbone_list = ["N", "CA", "C", "O"] |
| self.dna_backbone_list = [ |
| "OP1", "OP2", "P", "O5'", "C5'", "C4'", "O4'", "C3'", |
| "O3'", "C2'", "C1'", |
| ] |
| self.rna_backbone_list = [ |
| "OP1", "OP2", "P", "O5'", "C5'", "C4'", "O4'", "C3'", |
| "O3'", "C2'", "O2'", "C1'", |
| ] |
| self.protein_bb_idx_list = [ |
| self.atom_dict[atom] |
| for atom in self.atom_list_to_save |
| if atom in self.protein_backbone_list |
| ] |
| self.dna_bb_idx_list = [ |
| self.atom_dict[atom] |
| for atom in self.atom_list_to_save |
| if atom in self.dna_backbone_list |
| ] |
| self.rna_bb_idx_list = [ |
| self.atom_dict[atom] |
| for atom in self.atom_list_to_save |
| if atom in self.rna_backbone_list |
| ] |
|
|
| def load_chains(self, chains): |
| """Convert parser chain objects into arrays used by ``load_assembly``.""" |
| supported_types = { |
| "polypeptide(L)", |
| "polydeoxyribonucleotide", |
| "polyribonucleotide", |
| "polydeoxyribonucleotide/polyribonucleotide hybrid", |
| } |
| macromolecule_chain_dict = {} |
|
|
| for letter, chain in chains.items(): |
| if chain.type not in supported_types: |
| continue |
|
|
| residues = OrderedDict() |
| for atom_key in chain.atoms: |
| _, residue_id, residue_name, _ = atom_key |
| variants = residues.setdefault(residue_id, OrderedDict()) |
| variants.setdefault(residue_name, []).append(atom_key) |
| residue_count = len(residues) |
| xyz = np.zeros( |
| [residue_count, self.num_atoms_to_save, 3], dtype=np.float32 |
| ) |
| occ = np.zeros( |
| [residue_count, self.num_atoms_to_save], dtype=np.float32 |
| ) |
| residue_idx = -100 * np.ones([residue_count], dtype=np.int32) |
| raw_sequence = residue_count * ["UNK"] |
|
|
| for residue_offset, (residue_id, variants) in enumerate(residues.items()): |
| residue_name, residue_atoms = max( |
| variants.items(), |
| key=lambda item: sum( |
| float(chain.atoms[atom_key].occ) for atom_key in item[1] |
| ), |
| ) |
| for atom_key in residue_atoms: |
| _, _, _, atom_name = atom_key |
| if atom_name not in self.atom_dict: |
| continue |
| atom_index = self.atom_dict[atom_name] |
| xyz[residue_offset, atom_index] = np.asarray( |
| chain.atoms[atom_key].xyz |
| ) |
| occ[residue_offset, atom_index] = np.asarray( |
| chain.atoms[atom_key].occ |
| ) |
| raw_sequence[residue_offset] = residue_name |
| residue_idx[residue_offset] = int(residue_id) |
|
|
| macromolecule_chain_dict[letter] = { |
| "type": chain.type, |
| "xyz": xyz, |
| "occ": occ, |
| "seq": raw_sequence, |
| "residue_idx": residue_idx, |
| } |
|
|
| return macromolecule_chain_dict |
|
|
| def load_assembly(self, macromolecule_chain_dict, asmb, assembly_id, ppms=None): |
| """Apply biological-assembly transforms and select usable polymers. |
| |
| ``ppms`` is accepted for call compatibility. Evo-IF inference does not |
| consume PPM inputs, so the returned PPM features are zero-filled. |
| """ |
| del ppms |
| if assembly_id not in asmb: |
| raise ValueError(f"Assembly {assembly_id!r} is not present in the structure.") |
| assembly_transforms = asmb[assembly_id] |
| if not assembly_transforms: |
| raise ValueError(f"Assembly {assembly_id!r} contains no chain transforms.") |
|
|
| X_list = [] |
| X_occ_list = [] |
| S_list = [] |
| R_idx_list = [] |
| chain_labels_list = [] |
| protein_mask_list = [] |
| dna_mask_list = [] |
| rna_mask_list = [] |
|
|
| for letter, transform_matrix in assembly_transforms: |
| if letter not in macromolecule_chain_dict: |
| continue |
| chain = macromolecule_chain_dict[letter] |
| xyz = chain["xyz"] |
| transform_matrix = np.asarray(transform_matrix) |
| rotation_matrix = transform_matrix[:3, :3] |
| translation = transform_matrix[:3, 3] |
| xyz = np.einsum("ij,raj->rai", rotation_matrix, xyz) |
| xyz = xyz + translation[None, None, :] |
|
|
| chain_length = len(chain["residue_idx"]) |
| protein_mask = np.zeros(chain_length, dtype=np.int32) |
| dna_mask = np.zeros(chain_length, dtype=np.int32) |
| rna_mask = np.zeros(chain_length, dtype=np.int32) |
|
|
| if chain["type"] == "polypeptide(L)": |
| unknown_residue = "UNK" |
| protein_mask[:] = 1 |
| elif chain["type"] == "polydeoxyribonucleotide": |
| unknown_residue = "DX" |
| dna_mask[:] = 1 |
| elif chain["type"] == "polyribonucleotide": |
| unknown_residue = "RX" |
| rna_mask[:] = 1 |
| else: |
| |
| |
| unknown_residue = "DX" |
| for residue_offset, residue_name in enumerate(chain["seq"]): |
| if residue_name in self.dna_restypes: |
| dna_mask[residue_offset] = 1 |
| elif residue_name in self.rna_restypes: |
| rna_mask[residue_offset] = 1 |
|
|
| sequence = np.array( |
| [ |
| self.restype_to_int.get( |
| residue_name, self.restype_to_int[unknown_residue] |
| ) |
| for residue_name in chain["seq"] |
| ], |
| dtype=np.int32, |
| ) |
| chain_label = len(chain_labels_list) |
|
|
| X_list.append(xyz) |
| X_occ_list.append(chain["occ"]) |
| S_list.append(sequence) |
| R_idx_list.append(chain["residue_idx"]) |
| chain_labels_list.append( |
| np.full(chain_length, chain_label, dtype=np.int32) |
| ) |
| protein_mask_list.append(protein_mask) |
| dna_mask_list.append(dna_mask) |
| rna_mask_list.append(rna_mask) |
|
|
| if not X_list: |
| available = ", ".join(map(str, macromolecule_chain_dict)) or "none" |
| raise ValueError( |
| f"Assembly {assembly_id!r} contains no supported macromolecular " |
| f"chains (parsed chains: {available})." |
| ) |
|
|
| X = np.concatenate(X_list, axis=0) |
| X_occ = np.concatenate(X_occ_list, axis=0) |
| S = np.concatenate(S_list, axis=0) |
| R_idx = np.concatenate(R_idx_list, axis=0) |
| chain_labels = np.concatenate(chain_labels_list, axis=0) |
| protein_mask = np.concatenate(protein_mask_list, axis=0) |
| dna_mask = np.concatenate(dna_mask_list, axis=0) |
| rna_mask = np.concatenate(rna_mask_list, axis=0) |
|
|
| |
| |
| aligned_ppm = np.zeros( |
| (len(S), len(self.restype_to_int)), dtype=np.float64 |
| ) |
| ppm_mask = np.zeros(len(S), dtype=np.int32) |
|
|
| R_polymer_type = ( |
| protein_mask * self.polytype_to_int["PP"] |
| + dna_mask * self.polytype_to_int["DNA"] |
| + rna_mask * self.polytype_to_int["RNA"] |
| + (1 - protein_mask - dna_mask - rna_mask) |
| * self.polytype_to_int["UNK"] |
| ) |
| side_chain_occ_cutoff = ( |
| protein_mask * self.protein_side_chain_occ_cutoff |
| + dna_mask * self.dna_side_chain_occ_cutoff |
| + rna_mask * self.rna_side_chain_occ_cutoff |
| ) |
| X_m = (X_occ > side_chain_occ_cutoff[:, None]).astype(np.int32) |
|
|
| backbone_occ_cutoff = ( |
| protein_mask * self.protein_backbone_occ_cutoff |
| + dna_mask * self.dna_backbone_occ_cutoff |
| + rna_mask * self.rna_backbone_occ_cutoff |
| ) |
| X_occ_mask = (X_occ > backbone_occ_cutoff[:, None]).astype(np.int32) |
| protein_mask = protein_mask * np.prod( |
| X_occ_mask[:, self.protein_bb_idx_list], axis=-1 |
| ) |
| dna_mask = dna_mask * np.prod( |
| X_occ_mask[:, self.dna_bb_idx_list], axis=-1 |
| ) |
| rna_mask = rna_mask * np.prod( |
| X_occ_mask[:, self.rna_bb_idx_list], axis=-1 |
| ) |
|
|
| if self.parse_rna_as_dna: |
| dna_mask = np.bitwise_or(dna_mask, rna_mask) |
| rna_mask = np.zeros_like(dna_mask) |
|
|
| mask_for_output = np.zeros_like(protein_mask) |
| out_dict = {} |
| if self.parse_protein: |
| mask_for_output = np.bitwise_or(mask_for_output, protein_mask) |
| out_dict["protein_L"] = np.count_nonzero(protein_mask) |
| else: |
| out_dict["protein_L"] = 0 |
| if self.parse_dna: |
| mask_for_output = np.bitwise_or(mask_for_output, dna_mask) |
| out_dict["dna_L"] = np.count_nonzero(dna_mask) |
| else: |
| out_dict["dna_L"] = 0 |
| if self.parse_rna: |
| mask_for_output = np.bitwise_or(mask_for_output, rna_mask) |
| out_dict["rna_L"] = np.count_nonzero(rna_mask) |
| else: |
| out_dict["rna_L"] = 0 |
|
|
| out_dict["macromolecule_L"] = np.count_nonzero(mask_for_output) |
| mask_for_output = mask_for_output.astype(bool) |
| for key, value in { |
| "protein_mask": protein_mask, |
| "dna_mask": dna_mask, |
| "rna_mask": rna_mask, |
| "X": X, |
| "X_m": X_m, |
| "S": S, |
| "R_idx": R_idx, |
| "chain_labels": chain_labels, |
| "R_polymer_type": R_polymer_type, |
| "aligned_ppm": aligned_ppm, |
| "ppm_mask": ppm_mask, |
| }.items(): |
| out_dict[key] = value[mask_for_output] |
|
|
| return out_dict |
|
|
| def apply_crop_mask(self, out_dict, mask_to_keep): |
| """Crop per-residue arrays and remap precomputed index features.""" |
| mask_to_keep = np.asarray(mask_to_keep, dtype=np.bool_) |
| for key in out_dict: |
| if type(out_dict[key]) is np.ndarray: |
| out_dict[key] = out_dict[key][mask_to_keep] |
|
|
| mask_to_remove = np.logical_not(mask_to_keep) |
| index_of_removed = np.where(mask_to_remove)[0] |
| residues_removed_to_left = np.array( |
| [0] |
| + list(np.add.accumulate(mask_to_remove.astype(np.int32))[:-1]), |
| dtype=np.int64, |
| ) |
| index_and_mask_key_pairs = [ |
| ("base_pair_index", "base_pair_mask"), |
| ("canonical_base_pair_index", "canonical_base_pair_mask"), |
| ("nearest_protein_side_chain_index", "side_chain_interface_mask"), |
| ] |
| for index_key, mask_key in index_and_mask_key_pairs: |
| index_in_removed = np.isin(out_dict[index_key], index_of_removed) |
| out_dict[mask_key][index_in_removed] = 0 |
| out_dict[index_key] = ( |
| out_dict[index_key] |
| - residues_removed_to_left[out_dict[index_key]] |
| ) |
| out_dict[index_key] = out_dict[index_key] * out_dict[mask_key] |
|
|
| out_dict["protein_L"] = np.count_nonzero(out_dict["protein_mask"]) |
| out_dict["dna_L"] = np.count_nonzero(out_dict["dna_mask"]) |
| out_dict["rna_L"] = np.count_nonzero(out_dict["rna_mask"]) |
| out_dict["macromolecule_L"] = ( |
| out_dict["protein_L"] + out_dict["dna_L"] + out_dict["rna_L"] |
| ) |
|
|
| def random_crop_na(self, out_dict): |
| """Crop to the nearest ``batch_tokens`` residues around a random NA.""" |
| X = out_dict["X"] |
| na_mask = out_dict["dna_mask"] + out_dict["rna_mask"] |
| na_indices = np.where(na_mask == 1)[0] |
| if len(na_indices) == 0: |
| raise ValueError("NA-centred cropping requires at least one DNA/RNA residue.") |
| if self.batch_tokens <= 0: |
| raise ValueError("batch_tokens must be positive for NA-centred cropping.") |
|
|
| protein_ref_atom_index = self.atom_dict["CA"] |
| na_ref_atom_index = self.atom_dict[self.na_ref_atom] |
| ref_atom_X = ( |
| X[:, protein_ref_atom_index, :] + X[:, na_ref_atom_index, :] |
| ) |
| na_residue_index = np.random.choice(na_indices) |
| distances = np.sqrt( |
| np.sum((ref_atom_X - ref_atom_X[na_residue_index]) ** 2, axis=-1) |
| ) |
| indices_to_keep = np.argsort(distances)[: self.batch_tokens] |
| mask_to_keep = np.zeros_like(out_dict["S"], dtype=np.bool_) |
| mask_to_keep[indices_to_keep] = True |
| self.apply_crop_mask(out_dict, mask_to_keep) |
|
|
| |
| |
| |
|
|
| def get_denoising_schedule( |
| num_steps: int, |
| schedule_type: Literal["linear", "cosine", "sqrt"] = "cosine", |
| ) -> np.ndarray: |
| """Return mask ratios from fully masked to fully revealed.""" |
| t = np.linspace(0, 1, num_steps + 1) |
| if schedule_type == "linear": |
| return 1.0 - t |
| if schedule_type == "cosine": |
| return 0.5 * (1.0 + np.cos(t * np.pi)) |
| if schedule_type == "sqrt": |
| return 1.0 - np.sqrt(t) |
| raise ValueError(f"Unknown schedule type: {schedule_type}") |
|
|
|
|
| def select_positions_to_unmask( |
| current_seq: torch.Tensor, |
| logits: torch.Tensor, |
| mask_token_idx: int, |
| num_to_unmask: int, |
| strategy: Literal["confidence", "random", "entropy"] = "confidence", |
| temperature: float = 1.0, |
| use_argmax: bool = True, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Reveal the requested number of currently masked positions.""" |
| batch_size, length, _ = logits.shape |
| device = logits.device |
| is_masked = current_seq.eq(mask_token_idx) |
| probs = F.softmax(logits / temperature, dim=-1) |
|
|
| if strategy == "confidence": |
| confidence = probs.max(dim=-1).values |
| elif strategy == "entropy": |
| entropy = -(probs * (probs + 1e-10).log()).sum(dim=-1) |
| confidence = -entropy |
| elif strategy == "random": |
| confidence = torch.rand(batch_size, length, device=device) |
| else: |
| raise ValueError(f"Unknown strategy: {strategy}") |
|
|
| confidence = confidence.masked_fill(~is_masked, -float("inf")) |
| new_seq = current_seq.clone() |
| unmasked_positions = torch.zeros( |
| batch_size, length, dtype=torch.bool, device=device |
| ) |
|
|
| for batch_index in range(batch_size): |
| masked_indices = torch.where(is_masked[batch_index])[0] |
| n_unmask = min(num_to_unmask, masked_indices.numel()) |
| if n_unmask == 0: |
| continue |
| selected = confidence[batch_index, masked_indices].topk(n_unmask).indices |
| positions = masked_indices[selected] |
| for position in positions: |
| if use_argmax: |
| token = logits[batch_index, position].argmax() |
| else: |
| token = torch.multinomial( |
| probs[batch_index, position], 1 |
| ).squeeze() |
| new_seq[batch_index, position] = token |
| unmasked_positions[batch_index, position] = True |
|
|
| return new_seq, unmasked_positions |
|
|
|
|
| def rewalk_positions( |
| current_seq: torch.Tensor, |
| mask_token_idx: int, |
| probs: torch.Tensor, |
| rewalk_ratio: float = 0.1, |
| min_confidence_threshold: float = 0.3, |
| designable_mask: Optional[torch.Tensor] = None, |
| ) -> torch.Tensor: |
| """Re-mask a random subset of low-confidence designable positions.""" |
| confidence = probs.max(dim=-1).values |
| candidates = ( |
| current_seq.ne(mask_token_idx) |
| & confidence.lt(min_confidence_threshold) |
| ) |
| if designable_mask is not None: |
| candidates &= designable_mask.gt(0) |
| selected = torch.rand_like(confidence).lt(rewalk_ratio) & candidates |
| rewalked_seq = current_seq.clone() |
| rewalked_seq[selected] = mask_token_idx |
| return rewalked_seq |
|
|
|
|
| @torch.no_grad() |
| def iterative_denoise( |
| model: torch.nn.Module, |
| feature_dict: dict, |
| mask_token_idx: int, |
| num_steps: int = 50, |
| schedule_type: str = "cosine", |
| selection_strategy: str = "confidence", |
| temperature: float = 1.0, |
| use_argmax: bool = True, |
| rewalk_enabled: bool = True, |
| rewalk_ratio: float = 0.1, |
| rewalk_threshold: float = 0.3, |
| designable_mask: Optional[torch.Tensor] = None, |
| na_mask: Optional[torch.Tensor] = None, |
| verbose: bool = False, |
| ) -> tuple[torch.Tensor, list[torch.Tensor]]: |
| """Generate a sequence with MaskGIT-style iterative unmasking.""" |
| if designable_mask is None and na_mask is not None: |
| designable_mask = na_mask |
|
|
| model.eval() |
| device = next(model.parameters()).device |
| batch_size, length = feature_dict["S"].shape |
| current_seq = feature_dict["S"].clone() |
| if designable_mask is None: |
| current_seq = torch.full( |
| (batch_size, length), mask_token_idx, device=device, dtype=torch.long |
| ) |
| else: |
| current_seq[designable_mask > 0] = mask_token_idx |
|
|
| schedule = get_denoising_schedule(num_steps, schedule_type) |
| trajectory = [current_seq.clone()] |
|
|
| for step in range(num_steps): |
| step_features = feature_dict.copy() |
| step_features["S"] = current_seq |
| log_probs, probs = model(step_features) |
|
|
| if designable_mask is None: |
| total_maskable = torch.full( |
| (batch_size,), length, device=device, dtype=torch.float32 |
| ) |
| else: |
| total_maskable = designable_mask.gt(0).sum(dim=1).float() |
| masked_now = current_seq.eq(mask_token_idx).sum(dim=1).float() |
| masked_target = (total_maskable * schedule[step + 1]).long() |
| to_unmask = (masked_now - masked_target).clamp(min=0).long() |
| average_to_unmask = int(to_unmask.float().mean().item()) |
|
|
| if average_to_unmask > 0: |
| current_seq, _ = select_positions_to_unmask( |
| current_seq, |
| log_probs, |
| mask_token_idx, |
| average_to_unmask, |
| strategy=selection_strategy, |
| temperature=temperature, |
| use_argmax=use_argmax, |
| ) |
|
|
| if rewalk_enabled and step < num_steps - 1: |
| current_seq = rewalk_positions( |
| current_seq, |
| mask_token_idx, |
| probs, |
| rewalk_ratio=rewalk_ratio, |
| min_confidence_threshold=rewalk_threshold, |
| designable_mask=designable_mask, |
| ) |
|
|
| trajectory.append(current_seq.clone()) |
| if verbose and step % 10 == 0: |
| n_masked = current_seq.eq(mask_token_idx).sum().item() |
| print(f"Step {step}/{num_steps}: {n_masked} masked positions remaining") |
|
|
| final_features = feature_dict.copy() |
| final_features["S"] = current_seq |
| log_probs, _ = model(final_features) |
| remaining = current_seq.eq(mask_token_idx) |
| if designable_mask is not None: |
| remaining &= designable_mask.gt(0) |
| if remaining.any(): |
| predictions = log_probs.argmax(dim=-1) |
| current_seq[remaining] = predictions[remaining] |
|
|
| trajectory.append(current_seq.clone()) |
| return current_seq, trajectory |
|
|
| |
| |
| |
|
|
| DEFAULT_PARAMS = { |
| "ATOMS_TO_LOAD": "backbone", |
| "PARSE_PROTEIN": 1, |
| "PARSE_DNA": 1, |
| "PARSE_RNA": 1, |
| "PARSE_RNA_AS_DNA": 0, |
| "NA_SHARED_TOKENS": 1, |
| "PROTEIN_BACKBONE_OCC_CUTOFF": 0.8, |
| "PROTEIN_SIDE_CHAIN_OCC_CUTOFF": 0.5, |
| "DNA_BACKBONE_OCC_CUTOFF": 0.8, |
| "DNA_SIDE_CHAIN_OCC_CUTOFF": 0.5, |
| "RNA_BACKBONE_OCC_CUTOFF": 0.8, |
| "RNA_SIDE_CHAIN_OCC_CUTOFF": 0.5, |
| "CROP_LARGE_STRUCTURES": 1, |
| "BATCH_TOKENS": 6000, |
| "NA_REF_ATOM": "C1'", |
| "EXCLUDE_RES": ["HOH", "NA", "CL", "K", "BR"], |
| "RANDOMIZE_NMR_MODEL": 0, |
| "HIDDEN_DIM": 128, |
| "NUM_ENCODER_LAYERS": 3, |
| "NUM_DECODER_LAYERS": 3, |
| "NUM_NEIGHBORS": 32, |
| "INCLUDE_PRED_NA_N": 1, |
| "USE_SEQUENCE_CONTEXT": True, |
| "VOCAB_SIZE": 33, |
| "NUM_LETTERS": 33, |
| } |
|
|
|
|
| def _torch_load(path: str | Path, map_location): |
| try: |
| return torch.load(path, map_location=map_location, weights_only=True) |
| except TypeError: |
| return torch.load(path, map_location=map_location) |
|
|
|
|
| def load_model_and_dataset( |
| checkpoint_path: Optional[str] = None, |
| config_path: Optional[str] = None, |
| model_state_dict: Optional[Mapping[str, torch.Tensor]] = None, |
| device: Optional[torch.device] = None, |
| ): |
| """Create the diffusion model and dataset from a checkpoint or embedded state.""" |
|
|
| if device is None: |
| device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") |
| |
| if model_state_dict is not None: |
| checkpoint = {"model_state_dict": model_state_dict} |
| elif checkpoint_path: |
| checkpoint = _torch_load(checkpoint_path, map_location=device) |
| else: |
| raise ValueError("checkpoint_path or model_state_dict is required") |
| |
| params = DEFAULT_PARAMS.copy() |
| if config_path: |
| with Path(config_path).expanduser().open(encoding="utf-8") as handle: |
| params.update(json.load(handle)) |
| |
| |
| if params["ATOMS_TO_LOAD"] == "backbone": |
| atom_list_to_save = [ |
| 'N', 'CA', 'C', 'O', |
| 'OP1', 'OP2', 'P', "O5'", "C5'", "C4'", "O4'", "C3'", "O3'", |
| "C2'", "O2'", "C1'" |
| ] |
| else: |
| atom_list_to_save = [ |
| 'N', 'CA', 'C', 'CB', 'O', 'CG', 'CG1', 'CG2', 'OG', 'OG1', |
| 'SG', 'CD', 'CD1', 'CD2', 'ND1', 'ND2', 'OD1', 'OD2', 'SD', |
| 'CE', 'CE1', 'CE2', 'CE3', 'NE', 'NE1', 'NE2', 'OE1', 'OE2', |
| 'CH2', 'NH1', 'NH2', 'OH', 'CZ', 'CZ2', 'CZ3', 'NZ', 'OXT', |
| 'OP1', 'OP2', 'P', "O5'", "C5'", "C4'", "O4'", "C3'", "O3'", |
| "C2'", "O2'", "C1'", 'N9', 'C8', 'C7', 'N7', 'C6', 'N6', 'O6', |
| 'C5', 'C4', 'N4', 'O4', 'N3', 'C2', 'N2', 'O2', 'N1' |
| ] |
| |
| |
| cif_parser = CIFParser( |
| skip_res=params.get("EXCLUDE_RES", []), |
| randomize_nmr_model=params.get("RANDOMIZE_NMR_MODEL", 0) |
| ) |
| pdb_parser = PDBParser() |
| |
| pdb_dataset = PDBDataset( |
| cif_parser=cif_parser, |
| pdb_parser=pdb_parser, |
| atom_list_to_save=atom_list_to_save, |
| parse_protein=params["PARSE_PROTEIN"], |
| parse_dna=params["PARSE_DNA"], |
| parse_rna=params["PARSE_RNA"], |
| parse_rna_as_dna=params["PARSE_RNA_AS_DNA"], |
| na_shared_tokens=params["NA_SHARED_TOKENS"], |
| protein_backbone_occ_cutoff=params["PROTEIN_BACKBONE_OCC_CUTOFF"], |
| protein_side_chain_occ_cutoff=params["PROTEIN_SIDE_CHAIN_OCC_CUTOFF"], |
| dna_backbone_occ_cutoff=params["DNA_BACKBONE_OCC_CUTOFF"], |
| dna_side_chain_occ_cutoff=params["DNA_SIDE_CHAIN_OCC_CUTOFF"], |
| rna_backbone_occ_cutoff=params["RNA_BACKBONE_OCC_CUTOFF"], |
| rna_side_chain_occ_cutoff=params["RNA_SIDE_CHAIN_OCC_CUTOFF"], |
| crop_large_structures=params["CROP_LARGE_STRUCTURES"], |
| batch_tokens=params["BATCH_TOKENS"], |
| na_ref_atom=params["NA_REF_ATOM"], |
| ) |
| |
| |
| model = ProteinMPNNDiffusion( |
| node_features=params["HIDDEN_DIM"], |
| edge_features=params["HIDDEN_DIM"], |
| hidden_dim=params["HIDDEN_DIM"], |
| num_encoder_layers=params["NUM_ENCODER_LAYERS"], |
| num_decoder_layers=params["NUM_DECODER_LAYERS"], |
| k_neighbors=params["NUM_NEIGHBORS"], |
| dropout=0.0, |
| atom_dict=pdb_dataset.atom_dict, |
| restype_to_int=pdb_dataset.restype_to_int, |
| polytype_to_int=pdb_dataset.polytype_to_int, |
| protein_augment_eps=0.0, |
| dna_augment_eps=0.0, |
| rna_augment_eps=0.0, |
| na_ref_atom=params["NA_REF_ATOM"], |
| include_pred_na_N=params["INCLUDE_PRED_NA_N"], |
| use_sequence_context=params.get("USE_SEQUENCE_CONTEXT", True), |
| device=device, |
| vocab=params["VOCAB_SIZE"], |
| num_letters=params["NUM_LETTERS"] |
| ) |
| |
| |
| model.load_state_dict(checkpoint['model_state_dict']) |
| model.to(device) |
| model.eval() |
| |
| return model, pdb_dataset, params, device |
|
|
|
|
| def load_structure( |
| pdb_path: str, |
| pdb_dataset: PDBDataset, |
| device: torch.device, |
| max_tokens: Optional[int] = None, |
| ): |
| """Load and featurize a structure, optionally using NAIAD's NA-centered crop.""" |
| |
| lower_path = str(pdb_path).lower() |
| if lower_path.endswith((".pdb", ".pdb.gz")): |
| chains, asmb, _covalei, _meta = pdb_dataset.pdb_parser.parse(pdb_path) |
| elif lower_path.endswith((".cif", ".cif.gz", ".mmcif", ".mmcif.gz")): |
| chains, asmb, _covalei, _meta = pdb_dataset.cif_parser.parse(pdb_path) |
| else: |
| raise ValueError(f"Unsupported structure format: {pdb_path}") |
| |
| |
| macromolecule_chain_dict = pdb_dataset.load_chains(chains) |
| |
| |
| if not asmb: |
| raise ValueError(f"No usable assembly found in {pdb_path}") |
| assembly_id = next(iter(asmb)) |
| |
| |
| out_dict = pdb_dataset.load_assembly( |
| macromolecule_chain_dict, asmb, assembly_id, ppms=[] |
| ) |
| |
| |
| L = out_dict["macromolecule_L"] |
| out_dict["original_macromolecule_L"] = int(L) |
| out_dict["interface_mask"] = np.zeros(L, dtype=np.int32) |
| out_dict["side_chain_interface_mask"] = np.zeros(L, dtype=np.int32) |
| out_dict["nearest_protein_side_chain_index"] = np.zeros(L, dtype=np.int64) |
| out_dict["base_pair_mask"] = np.zeros(L, dtype=np.int32) |
| out_dict["base_pair_index"] = np.zeros(L, dtype=np.int64) |
| out_dict["canonical_base_pair_mask"] = np.zeros(L, dtype=np.int32) |
| out_dict["canonical_base_pair_index"] = np.zeros(L, dtype=np.int64) |
|
|
| if max_tokens is None and pdb_dataset.crop_large_structures: |
| max_tokens = pdb_dataset.batch_tokens |
| out_dict["crop_applied"] = False |
| if max_tokens and L > max_tokens and (out_dict["dna_L"] + out_dict["rna_L"]) > 0: |
| original_batch_tokens = pdb_dataset.batch_tokens |
| try: |
| pdb_dataset.batch_tokens = int(max_tokens) |
| pdb_dataset.random_crop_na(out_dict) |
| finally: |
| pdb_dataset.batch_tokens = original_batch_tokens |
| L = out_dict["macromolecule_L"] |
| out_dict["crop_applied"] = True |
| |
| out_dict["structure_path"] = pdb_path |
| out_dict["assembly_id"] = assembly_id |
| out_dict["ppm_paths"] = "[]" |
| out_dict["ppm_paths_chosen"] = [] |
|
|
| for key, value in list(out_dict.items()): |
| if isinstance(value, np.ndarray): |
| out_dict[key] = torch.from_numpy(value) |
| |
| |
| batch = [[(out_dict, torch.tensor(L, dtype=torch.long))]] |
| |
| |
| feature_dict = featurize( |
| batch[0], |
| pdb_dataset.polytype_to_int, |
| pdb_dataset.restype_to_int, |
| pdb_dataset.atom_dict, |
| device |
| ) |
| |
| return feature_dict, out_dict |
|
|
|
|
| def sequence_to_string( |
| seq_tensor: torch.Tensor, |
| pdb_dataset: PDBDataset, |
| mask: Optional[torch.Tensor] = None, |
| dna_mask: Optional[torch.Tensor] = None, |
| rna_mask: Optional[torch.Tensor] = None, |
| chain_labels: Optional[torch.Tensor] = None, |
| chain_break_character: str = "/", |
| ) -> str: |
| """Convert sequence tensor to string representation.""" |
| seq = seq_tensor.cpu().numpy() |
| if mask is not None: |
| mask = mask.cpu().numpy() |
| if dna_mask is not None: |
| dna_mask = dna_mask.cpu().numpy() |
| if rna_mask is not None: |
| rna_mask = rna_mask.cpu().numpy() |
| if chain_labels is not None: |
| chain_labels = chain_labels.cpu().numpy() |
|
|
| dna_chars_by_restype = { |
| "DA": "a", "A": "a", |
| "DC": "c", "C": "c", |
| "DG": "g", "G": "g", |
| "DT": "t", "U": "t", |
| "DX": "x", "RX": "x", |
| } |
| rna_chars_by_restype = { |
| "DA": "b", "A": "b", |
| "DC": "d", "C": "d", |
| "DG": "h", "G": "h", |
| "DT": "u", "U": "u", |
| "DX": "y", "RX": "y", |
| } |
| |
| result = [] |
| previous_chain_label = None |
| for i, token_idx in enumerate(seq): |
| if mask is not None and mask[i] == 0: |
| continue |
| restype = pdb_dataset.int_to_restype.get(token_idx, 'X') |
| if rna_mask is not None and rna_mask[i] > 0: |
| char = rna_chars_by_restype.get(restype, 'X') |
| elif dna_mask is not None and dna_mask[i] > 0: |
| char = dna_chars_by_restype.get(restype, 'X') |
| else: |
| char = pdb_dataset.restype_3_to_1.get(restype, 'X') |
|
|
| if chain_labels is not None: |
| chain_label = chain_labels[i] |
| if ( |
| previous_chain_label is not None and |
| chain_label != previous_chain_label |
| ): |
| result.append(chain_break_character) |
| previous_chain_label = chain_label |
|
|
| result.append(char) |
| |
| return ''.join(result) |
|
|
| __all__ = [ |
| "PDBDataset", |
| "ProteinMPNNDiffusion", |
| "featurize", |
| "iterative_denoise", |
| "load_model_and_dataset", |
| "load_structure", |
| "sequence_to_string", |
| ] |
|
|