English
OneScience
protein structure generation
La-Proteina / models /datasets /gen_dataset.py
anzhi2710gmailcom's picture
Upload folder using huggingface_hub
8e04e6f verified
Raw
History Blame Contribute Delete
20.1 kB
import random
from collections import defaultdict
from typing import Dict, List, Optional, Tuple
import numpy as np
import torch
from loguru import logger
from torch.utils.data import Dataset
from models.utils.align_utils import mean_w_mask
from models.utils.fold_utils import mask_cath_code_by_level
from models.utils.motif_utils import parse_motif, save_motif_csv
from models.utils.coors_utils import ang_to_nm
class GenDataset(Dataset):
"""
This class provides length-centric and fold-centric sampling for unconditional
and conditional protein structure generation. Each returned item is a dictionary
with key information for generation, which contains the length of proteins, the
number of proteins and cath codes if conditional sampling is used.
If length distribution is specified, sample `nsamples` proteins for each length,
cath codes are randomly sampled based on empirical distribution.
Otherwise, if cath code set is specified, sample `nsamples` proteins for each cath code,
lengths are randomly sampled based on empirical distribution.
Each sample returned by this dataset is a 2-tuple (L, nsamples) or 3-tuple (L, nsamples, cath_code) where
- nres (int) is the number of residues in the proteins to be samples
- nsamples (int) is the number of proteins to generate (happens in parallel),
so if nsamples=10 it means that it will produce 10 proteins of length L (all sampled in parallel)
- cath_code (List[str]) is the cath code for the nsamples if conditional generation is used
"""
def __init__(
self,
nlens_cfg: Optional[Dict] = None,
cath_codes: Optional[List[str]] = None,
empirical_distribution_cfg: Optional[Dict] = None,
motif_dict_cfg: Optional[Dict] = None,
motif_task_name: Optional[str] = None,
motif_csv_path: Optional[str] = None,
target_as_features: bool = False,
nsamples: Optional[int] = 1,
max_nsamples_per_batch: Optional[int] = 1,
n_replicas: int = 1,
):
"""
Args:
nlens_cfg (Optional[Dict]): Config dict for specifying length distribution. If not None, use length-centric sampling.
Defaults to None.
cath_codes (Optional[List[str]]): List of cath codes to sample.. If not None and nlens_cfg is None, use fold-centric sampling.
Defaults to None.
empirical_distribution_cfg (Optional[Dict]): Config dict for specifying (length, cath code) distribution.
Defaults to None.
motif_dict_cfg (Optional[Dict]): Config dict for all motif configs.
Defaults to None.
motif_task_name (Optional[str]): Name of the motif task to sample.
Defaults to None.
motif_csv_path (Optional[str]): Path to the motif csv file.
Defaults to None.
nsamples (Optional[int]): Number of samples to generate for each length or each cath code.
Defaults to 1.
max_nsamples_per_batch (Optional[int]): Maximum number of samples for each batch.
Defaults to 1.
n_replicas (Optional[int]): Number of devices. Used for validation on multiple devices.
Defaults to 1.
"""
super(GenDataset, self).__init__()
##################################################################################
################### 1. Parse length and cath codes ###############################
##################################################################################
nres = self.parse_nlens_cfg(nlens_cfg)
self.target_as_features = target_as_features
self.motif_task_name = motif_task_name
if nres is not None:
logger.info("Use length-centric sampling.")
nsamples = [nsamples] * len(nres)
elif motif_task_name:
logger.info("Use motif-conditioned sampling.")
if motif_task_name in motif_dict_cfg:
motif_cfg = motif_dict_cfg[motif_task_name]
else:
raise ValueError(
f"Motif task name {motif_task_name} not found in motif_dict_cfg"
)
nsamples = [nsamples]
else:
raise ValueError("Error in GenDataset init.")
##################################################################################
################### 2. Parse and bucketize empirical distribution ################
##################################################################################
if empirical_distribution_cfg:
self.parse_empirical_distribution_cfg(empirical_distribution_cfg)
self.bucketize()
##################################################################################
################### 3. Generate data points ######################################
##################################################################################
self.motif_masks = [None] * len(nsamples)
self.x_motifs = [None] * len(nsamples)
self.masks = [None] * len(nsamples)
if nres is not None:
# Length-centric generation
self.nres, self.cath_codes, self.nsamples = (
self.generate_cath_code_given_len(nres, nsamples)
)
elif cath_codes:
# Fold-centric generation
self.nres, self.cath_codes, self.nsamples = (
self.generate_len_given_cath_code(cath_codes, nsamples)
)
else:
self.nsamples = nsamples
self.cath_codes = [None] * len(nsamples)
self.motif_masks, self.x_motifs, self.residue_types = (
self.generate_motif_info(motif_cfg, nsamples[0], motif_csv_path)
)
##################################################################################
# 4. Make sure the nsamples for each data point is not greater than max_nsamples #
##################################################################################
if max_nsamples_per_batch:
if nres is not None or cath_codes:
self.nres, self.cath_codes, self.nsamples = self.flatten(
max_nsamples_per_batch
)
else:
(
self.nres,
self.cath_codes,
self.nsamples,
self.masks,
self.motif_masks,
self.x_motifs,
self.residue_types,
) = self.flatten_motif(max_nsamples_per_batch)
##################################################################################
# 5. Make sure this won't cause an error during validation on multiple devices ###
##################################################################################
if n_replicas > 1:
self.pad_nlens(n_replicas)
assert all(
[n <= max_nsamples_per_batch for n in self.nsamples]
), f"The nsamples for each len shouldn't be greater than {max_nsamples_per_batch}"
assert (
len(self.nsamples) % n_replicas == 0
), f"Should be evenly splitable over {n_replicas} devices"
logger.info(
f"Adding generation dataset to sample {self.nsamples} sequences of length {self.nres}."
)
def bucketize(self):
"""Build length buckets for cath_codes. Record the cath_code distribution given length bucket and the reverse"""
if self.len_cath_codes is None:
self.cath_codes_given_len_bucket = None
self.len_bucket_given_cath_codes = None
return
bucket = list(
range(self.bucket_min_len, self.bucket_max_len, self.bucket_step_size)
)
cath_codes_given_len_bucket = [[] for _ in range(len(bucket))]
len_bucket_given_cath_codes = defaultdict(set)
for _len, codes in self.len_cath_codes:
if len(codes) == 0:
continue
bucket_idx = (_len - self.bucket_min_len) // self.bucket_step_size
bucket_idx = min(bucket_idx, self.bucket_size - 1) # Boundary cutoff
bucket_idx = max(bucket_idx, 0)
# Record all possible cath codes for each bucket
cath_codes_given_len_bucket[bucket_idx].append(codes)
# Record all possible len bucket for each cath code
for code in codes:
for level in ["C", "A", "T"]:
ns = {"C": 3, "A": 2, "T": 1}
level_code = code.rsplit(".", ns[level])[0] + ".x" * ns[level]
len_bucket_given_cath_codes[level_code].add(bucket_idx)
for k, v in len_bucket_given_cath_codes.items():
len_bucket_given_cath_codes[k] = tuple(v)
self.cath_codes_given_len_bucket = cath_codes_given_len_bucket
self.len_bucket_given_cath_codes = len_bucket_given_cath_codes
def generate_cath_code_given_len(self, nres: List[int], nsamples: List[int]):
"""Pre-generate corresponding cath codes for each length"""
cath_codes = []
for i in range(len(nres)):
if self.cath_codes_given_len_bucket is None:
cath_code = None
else:
if nres[i] <= self.bucket_max_len:
bucket_idx = (
nres[i] - self.bucket_min_len
) // self.bucket_step_size
else:
bucket_idx = -1
cath_code = random.choices(
self.cath_codes_given_len_bucket[bucket_idx], k=nsamples[i]
)
cath_codes.append(cath_code)
return nres, cath_codes, nsamples
def generate_len_given_cath_code(self, cath_codes: List[str], nsamples: List[int]):
"""Pre-generate corresponding lengths for each cath code, then gather proteins of the same length as one batch"""
assert (
self.len_bucket_given_cath_codes is not None
), "Need len_cath_code distribution for fold-centric generation"
tmp_nres = []
tmp_cath_codes = []
for i in range(len(cath_codes)):
for _ in range(nsamples[i]):
if cath_codes[i] not in self.len_bucket_given_cath_codes:
raise ValueError(
f"CATH code {cath_codes[i]} not in the empirical distribution"
)
bucket_idx = random.choices(
self.len_bucket_given_cath_codes[cath_codes[i]], k=1
)[0]
_len = self.bucket_min_len + bucket_idx * self.bucket_step_size
tmp_nres.append(_len)
tmp_cath_codes.append([cath_codes[i]])
# Gather the same lengths, as we need to generate proteins of the same length together
len_bucket = defaultdict(list)
out_nres, out_cath_codes, out_nsamples = [], [], []
for n, code in zip(tmp_nres, tmp_cath_codes):
len_bucket[n].append(code)
for n, code in len_bucket.items():
out_nres.append(n)
out_cath_codes.append(code)
out_nsamples.append(len(code))
return out_nres, out_cath_codes, out_nsamples
def generate_motif_info(self, motif_cfg, nsamples, motif_csv_path):
# Always return motif_masks, x_motifs, residue_types as lists of tensors, regardless of input type
lengths, motif_masks, x_motifs, residue_types, outstrs = parse_motif(
nsamples=nsamples, **motif_cfg
)
idx = np.argsort(lengths)
motif_masks = [motif_masks[i] for i in idx]
x_motifs = [x_motifs[i] for i in idx]
residue_types = [residue_types[i] for i in idx]
# center motifs to origin
for i in range(len(x_motifs)):
motif_center = mean_w_mask(
x_motifs[i].flatten(0, 1), motif_masks[i].flatten(0, 1)
).unsqueeze(0)
x_motifs[i] = x_motifs[i] - motif_center
x_motifs[i] = x_motifs[i] * motif_masks[i][..., None]
# Only save CSV for contig_string (residue/range) case
if "motif_atom_spec" not in motif_cfg or motif_cfg["motif_atom_spec"] is None:
outstrs = [outstrs[i] for i in idx]
save_motif_csv(
motif_cfg["motif_pdb_path"],
self.motif_task_name,
outstrs,
outpath=motif_csv_path,
segment_order=motif_cfg["segment_order"],
)
return motif_masks, x_motifs, residue_types
def flatten(self, max_nsamples: int):
"""Flatten the list to make sure each data point have no more than max_nsamples"""
nres, cath_codes, nsamples = [], [], []
for i in range(len(self.nsamples)):
for j in range(0, self.nsamples[i], max_nsamples):
nres.append(self.nres[i])
if self.cath_codes[i] is not None:
cath_codes.append(self.cath_codes[i][j : j + max_nsamples])
else:
cath_codes.append(None)
if j + max_nsamples <= self.nsamples[i]:
nsamples.append(max_nsamples)
else:
nsamples.append(self.nsamples[i] - j)
return nres, cath_codes, nsamples
def flatten_motif(self, max_nsamples: int):
"""Flatten the list to make sure each data point have no more than max_nsamples"""
nres, cath_codes, nsamples = [], [], []
masks, motif_masks = [], []
x_motifs, residue_types = [], []
for i in range(len(self.nsamples)):
for j in range(0, self.nsamples[i], max_nsamples):
if self.cath_codes[i] is not None:
cath_codes.append(self.cath_codes[i][j : j + max_nsamples])
else:
cath_codes.append(None)
if j + max_nsamples <= self.nsamples[i]:
nsamples.append(max_nsamples)
motif_mask = self.motif_masks[j : j + max_nsamples]
x_motif = self.x_motifs[j : j + max_nsamples]
residue_type = self.residue_types[j : j + max_nsamples]
else:
nsamples.append(self.nsamples[i] - j)
motif_mask = self.motif_masks[j : self.nsamples[i]]
x_motif = self.x_motifs[j : self.nsamples[i]]
residue_type = self.residue_types[j : self.nsamples[i]]
mask = [torch.Tensor([True] * x.shape[0]) for x in motif_mask]
padded_mask = torch.nn.utils.rnn.pad_sequence(
mask, batch_first=True, padding_value=False
)
padded_motif_mask = torch.nn.utils.rnn.pad_sequence(
motif_mask, batch_first=True, padding_value=False
)
padded_x_motif = torch.nn.utils.rnn.pad_sequence(
x_motif, batch_first=True, padding_value=0
)
padded_residue_type = torch.nn.utils.rnn.pad_sequence(
residue_type, batch_first=True, padding_value=0
)
masks.append(padded_mask)
motif_masks.append(padded_motif_mask)
x_motifs.append(padded_x_motif)
residue_types.append(padded_residue_type)
nres.append(padded_mask.shape[1])
return nres, cath_codes, nsamples, masks, motif_masks, x_motifs, residue_types
def pad_nlens(self, n_replicas: int):
"""Split nlens into data points (len, nsample) as val dataset and guarantee that
1. len(val_dataset) should be a multiple of n_replica, to ensure that we don't introduce additional samples for multi-gpu validation
2. nsample should be the same for all data points if n_replica > 1 (multi-gpu)
"""
# Add samples to the small bins
max_nsamples = max(self.nsamples)
for i in range(len(self.nsamples)):
while self.cath_codes[i] != None and len(self.cath_codes[i]) < max_nsamples:
self.cath_codes[i] += self.cath_codes[i][
: (max_nsamples - len(self.cath_codes[i]))
]
self.nsamples[i] += max_nsamples - self.nsamples[i]
# Keep adding lengths in the dataset to make it a multiple of n_replica
while len(self.nres) % n_replicas != 0:
self.nres.append(self.nres[-1])
self.nsamples.append(max_nsamples)
self.cath_codes.append(self.cath_codes[-1])
self.cath_codes.append(self.cath_codes[-1])
if hasattr(self, "chain_masks"):
self.chain_masks.append(self.chain_masks[-1])
if hasattr(self, "general_masks"):
self.general_masks.append(self.general_masks[-1])
if hasattr(self, "structures"):
self.structures.append(self.structures[-1])
def parse_empirical_distribution_cfg(self, cfg: Dict):
"""Load empirical (len, cath_codes) joint distribution. Apply mask according to the guidance cath code level"""
if cfg.len_cath_code_path is not None:
logger.info(
f"Loading empirical (length, cath_code) distribution from {cfg.len_cath_code_path}"
)
raw_len_cath_codes = torch.load(cfg.len_cath_code_path)
# By applying mask to the cath code distribution, we can control the level we want to sample
level = cfg.cath_code_level
self.len_cath_codes = []
for i in range(len(raw_len_cath_codes)):
_len, code = raw_len_cath_codes[i]
code = mask_cath_code_by_level(code, level="H")
if level == "A" or level == "C":
code = mask_cath_code_by_level(code, level="T")
if level == "C":
code = mask_cath_code_by_level(code, level="A")
self.len_cath_codes.append((_len, code))
self.bucket_min_len = cfg.bucket_min_len
self.bucket_max_len = cfg.bucket_max_len
self.bucket_step_size = cfg.bucket_step_size
self.bucket_size = (
self.bucket_max_len - self.bucket_min_len
) // self.bucket_step_size + 1
else:
logger.info(
"No empirical (length, cath_code) distribution provided. Use unconditional training."
)
self.len_cath_codes = None
def parse_nlens_cfg(self, cfg: Dict):
"""Load nlens config."""
if cfg is None:
return None
if cfg.nres_lens:
nres = [int(n) for n in cfg.nres_lens]
elif cfg.min_len:
nres = np.arange(cfg.min_len, cfg.max_len + 1, cfg.step_len).tolist()
else:
nres = None
return nres
def __len__(self):
return len(self.nres)
def __getitem__(self, index: int):
result = {
"nres": self.nres[index],
"nsamples": self.nsamples[index],
}
# Add CATH codes if available
if self.cath_codes[index] is not None:
result["cath_code"] = self.cath_codes[index]
# Motif-conditioned
if self.motif_task_name is not None:
# Assume motif_mask, x_motif, seq_motif_mask, seq_motif are available
result["motif_mask"] = self.motif_masks[index].bool() # [bs, num_res, 37]
result["x_motif"] = self.x_motifs[index] # [bs, num_res, 37, 3]
result["seq_motif_mask"] = (
self.motif_masks[index].sum(dim=-1).bool()
) # [bs, num_res]
result["seq_motif"] = self.residue_types[index] # [bs, num_res]
result["mask"] = self.masks[index].bool() # [bs, num_res]
return result
# Fallback: unconditional
return result