Source code for atomworks.ml.samplers

import itertools
import logging
import math
from collections.abc import Iterator, Sequence
from operator import add

import numpy as np
import pandas as pd
import pyarrow as pa
import torch
from toolz import accumulate
from torch.utils.data import Dataset, DistributedSampler, Sampler, WeightedRandomSampler

logger = logging.getLogger(__name__)


def _calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], beta: float) -> pd.Series:
    """Determines the weight of each example in the DataFrame using a methodology inspired by AF-3.

    In AF-3, the weight of a given example is a function of:
        (1) The size of the cluster to which the example belongs (specific for interfaces vs. chains)
        (2) The number of proteins / nucleic acids / ligands in the example
        (3) Whether the example is an interface or a chain

    Specifically, AF3 gives the following formula (Section 2.5.1 from the AF-3 Supplementary Information):
        w ∝ (β_r / N_clust) * (a_prot * n_prot + a_nuc * n_nuc + a_ligand * n_ligand)

    Where:
        - w is the weight of the example
        - β_r is a weighting hyperparameter that is distinct for interfaces and chains
        - N_clust is the number of examples in the cluster
        - a_prot, a_nuc, and a_ligand are the interface weight hyperparameters for proteins, nucleic acids, and ligands, respectively
        - n_prot, n_nuc, and n_ligand are the number of proteins, nucleic acids, and ligands in the example

    We make the following modifications to the original AF-3 formula:
        - We introduce n_peptide and a_peptide to better control the sampling over peptides (which were being over-sampled). We define peptides
        as proteins with fewer than PEPTIDE_MAX_RESIDUES residues (see `atomworks.constants`).
        - We introduce an incremental a_loi weight to control the sampling of ligands of interests (LOI), also described as Subject of Investigation.

    Thus, our full formula is:
        w ∝ (β_r / N_clust) * (a_prot * n_prot + a_peptide * n_peptide + a_nuc * n_nuc + a_ligand * n_ligand + a_loi * is_loi)

    Args:
        df (pd.DataFrame): DataFrame containing the PN unit or interface data
        alphas (dict): Dictionary containing the weight hyperparameters for proteins, nucleic acids, ligands, and possibly peptides (common across interfaces and chains)
        beta (float): Weighting hyperparameter (distinct for interfaces and chains)

    Returns:
        pd.Series: A Series containing the calculated weights for each row in the DataFrame
    """
    # Extract relevant columns with default handling
    n_prot = df["n_prot"]
    n_nuc = df["n_nuc"]
    n_ligand = df["n_ligand"]
    n_peptide = df["n_peptide"]
    cluster_size = df["cluster_size"]

    is_loi = (df["involves_loi"] if "involves_loi" in df.columns else df["q_pn_unit_is_loi"]).astype(int)

    # Assert that all cluster sizes are greater than 0
    assert all(cluster_size > 0), "All cluster sizes must be greater than 0"

    # Warn if not all cluster sizes are less than the dataframe length
    if not all(cluster_size < len(df)):
        logger.warning(
            "Some cluster sizes are greater than the DataFrame length. "
            "This is unexpected, unless you are running with a very "
            "restricted dataframe for debugging. If you aren't, please check!"
        )

    # If we're missing any of the alphas, or any of the counts, log a warning
    missing_alphas = set(alphas.keys()) - {"a_prot", "a_peptide", "a_nuc", "a_ligand", "a_loi"}
    missing_counts = {"n_prot", "n_peptide", "n_nuc", "n_ligand"} - set(df.columns)

    if missing_alphas:
        logger.warning(f"Missing alphas from configuration file: {missing_alphas}; defaulting to 0")
    if missing_counts:
        logger.warning(f"Missing chain within dataframe counts: {missing_counts}; defaulting to 0")
        logger.warning(f"Columns in dataframe: {df.columns}")

    logger.info(f"Calculating weights for AF-3 examples using alphas={alphas}, beta={beta}")

    # Vectorized calculation of the weights
    weights = (beta / cluster_size) * (
        alphas.get("a_prot", 0) * n_prot
        + alphas.get("a_peptide", 0) * n_peptide
        + alphas.get("a_nuc", 0) * n_nuc
        + alphas.get("a_ligand", 0) * n_ligand
        + alphas.get("a_loi", 0) * is_loi
    )

    return weights


def _col_to_series(table: pa.Table | pd.DataFrame, column: str) -> pd.Series:
    """Extract a single column from a pandas DataFrame or PyArrow Table as a pandas Series.

    Only the requested column is materialised, keeping memory usage low when the
    backing table is a PyArrow Table held in columnar format.
    """
    col = table[column]
    return col.to_pandas() if hasattr(col, "to_pandas") else col


def _get_effective_cluster_sizes(
    dataset_df: pa.Table | pd.DataFrame,
    cluster_column: str,
    altloc_weights: pd.Series | None = None,
) -> pd.Series:
    """Return a per-row Series of effective cluster sizes.

    Without altloc weights, effective size is simply the row count per cluster.
    With altloc weights, effective size is the sum of altloc weights per cluster,
    which equals the number of unique examples (e.g. pdb_ids / assembly id / pn_unit)
    since each unique example's altloc weights sum to exactly 1.0.

    Args:
        dataset_df: DataFrame or PyArrow Table containing the data.
        cluster_column: Column identifying sequence clusters.
        altloc_weights: If provided, summed per cluster to get effective size.
            Defaults to ``None`` (use raw row counts).

    Returns:
        Per-row Series of effective cluster sizes.
    """
    cluster_col = _col_to_series(dataset_df, cluster_column)
    if altloc_weights is None:
        cluster_to_size = cluster_col.value_counts().to_dict()
    else:
        tmp = pd.DataFrame({"cluster": cluster_col, "altloc_weight": altloc_weights})
        cluster_to_size = tmp.groupby("cluster")["altloc_weight"].sum().to_dict()
    return cluster_col.map(cluster_to_size)


def _get_altloc_weights(
    dataset_df: pa.Table | pd.DataFrame,
    altloc_seed_column: str,
    pdb_id_column: str,
) -> pd.Series:
    """Return a per-row Series of ``1 / num_altlocs`` for altloc-aware sampling.

    ``num_altlocs`` is the number of unique values in ``altloc_seed_column`` per
    ``pdb_id``.  ``NaN`` counts as one unique value (structures with no altlocs
    have a single row with ``altloc_seed=None``, so ``num_altlocs=1``).
    """
    pdb_id_col = _col_to_series(dataset_df, pdb_id_column)
    altloc_col = _col_to_series(dataset_df, altloc_seed_column)
    tmp = pd.DataFrame({pdb_id_column: pdb_id_col, altloc_seed_column: altloc_col})
    pdb_id_to_num_altlocs = tmp.groupby(pdb_id_column)[altloc_seed_column].nunique(dropna=False).to_dict()
    return 1.0 / pdb_id_col.map(pdb_id_to_num_altlocs)


[docs] def calculate_weights_for_pdb_dataset_df( dataset_df: pa.Table | pd.DataFrame, alphas: dict[str, float], beta: float, cluster_column: str = "cluster", altloc_seed_column: str | None = None, pdb_id_column: str = "pdb_id", ) -> torch.Tensor: """Calculate weights based on the AF-3 methodology, optionally adjusted for altlocs. Base weight per row: ``(beta / cluster_size) * (a_prot * n_prot + ...)``. If ``altloc_seed_column`` is provided, weights are additionally multiplied by ``1 / num_altlocs`` so that each altloc variant of a structure is sampled uniformly. Args: dataset_df: DataFrame or PyArrow Table containing the PN unit or interface data. alphas: Alpha hyperparameters for the AF-3 weighting formula. beta: Beta hyperparameter (distinct for interfaces vs. chains). cluster_column: Column identifying sequence clusters. Defaults to ``"cluster"``. altloc_seed_column: If provided, weights are divided by the number of unique altloc seeds per ``pdb_id``. Defaults to ``None`` (no altloc adjustment). pdb_id_column: Column identifying structures, used only when ``altloc_seed_column`` is set. Defaults to ``"pdb_id"``. Returns: Tensor of per-row weights with shape ``(len(dataset_df),)``. """ col_names = dataset_df.schema.names if isinstance(dataset_df, pa.Table) else list(dataset_df.columns) required_columns = [cluster_column, "n_prot", "n_nuc", "n_ligand", "n_peptide"] assert all(col in col_names for col in required_columns), ( "Missing required columns in the (loaded) table. " f"Please ensure the table contains the following columns: {required_columns}. " "Also ensure that the columns to include are specified in the Hydra configuration file." ) assert "involves_loi" in col_names or "q_pn_unit_is_loi" in col_names, ( "Missing column for 'involves_loi' or 'q_pn_unit_is_loi'. " f"Please check the columns in the table: {col_names}, " "and the columns to include specified in the Hydra configuration file." ) loi_col = "involves_loi" if "involves_loi" in col_names else "q_pn_unit_is_loi" needed_cols = [cluster_column, "n_prot", "n_nuc", "n_ligand", "n_peptide", loi_col] df = pd.DataFrame({col: _col_to_series(dataset_df, col) for col in needed_cols}) altloc_weights = ( _get_altloc_weights(dataset_df, altloc_seed_column, pdb_id_column) if altloc_seed_column is not None else None ) df["cluster_size"] = _get_effective_cluster_sizes(dataset_df, cluster_column, altloc_weights) assert not df["cluster_size"].isnull().any(), "Cluster sizes must not be NaN" weights = _calculate_af3_example_weights(df, alphas, beta).values if altloc_weights is not None: weights = weights * altloc_weights.values return torch.tensor(weights)
[docs] def calculate_weights_by_inverse_cluster_size( dataset_df: pa.Table | pd.DataFrame, cluster_column: str = "cluster", altloc_seed_column: str | None = None, pdb_id_column: str = "pdb_id", ) -> torch.Tensor: """Calculate weights as the inverse of cluster size, optionally adjusted for altlocs. Base weight per row: ``1 / cluster_size``. If ``altloc_seed_column`` is provided, weights are additionally multiplied by ``1 / num_altlocs`` for uniform three-level sampling: cluster → example → altloc. Args: dataset_df: DataFrame or PyArrow Table containing the PN unit or interface data. cluster_column: Column identifying sequence clusters. Defaults to ``"cluster"``. altloc_seed_column: If provided, weights are divided by the number of unique altloc seeds per ``pdb_id``. Defaults to ``None`` (no altloc adjustment). pdb_id_column: Column identifying structures, used only when ``altloc_seed_column`` is set. Defaults to ``"pdb_id"``. Returns: Tensor of per-row weights with shape ``(len(dataset_df),)``. """ altloc_weights = ( _get_altloc_weights(dataset_df, altloc_seed_column, pdb_id_column) if altloc_seed_column is not None else None ) effective_cluster_sizes = _get_effective_cluster_sizes(dataset_df, cluster_column, altloc_weights) weights = 1.0 / effective_cluster_sizes if altloc_weights is not None: weights = weights * altloc_weights return torch.tensor(weights.values, dtype=torch.float64)
[docs] def set_sampler_epoch(sampler: Sampler, epoch: int, add_random_offset: bool = False) -> None: """Control the random seed for a sampler.""" if add_random_offset: epoch += torch.randint(-int(1e12), int(1e12), (1,)).item() logger.info(f"Setting epoch for sampler {sampler} to {epoch}") if hasattr(sampler, "set_epoch"): sampler.set_epoch(epoch) elif hasattr(sampler, "generator"): if sampler.generator is None: sampler.generator = torch.Generator() sampler.generator.manual_seed(epoch) else: logger.warning( f"Sampler {sampler} does not have a set_epoch method or generator attribute, so epoch cannot be set." )
[docs] class DistributedMixedSampler(Sampler): """Custom DistributedSampler implementation that samples from an arbitrary list of samplers with specified probabilities. Child samplers can be any type of non-distributed sampler, including a MixedSampler. After gathering all indices, shards the samples across nodes, ensuring each node receives a unique slice of the dataset. Example: Imagine we have the following sampling tree: DistributedMixedSampler | ------------------------- | | 0.8 0.2 Sampler1 MixedSampler / \ 0.9 0.1 Sampler2 Sampler3 If we initialized DistributedMixedSampler with `n_examples_per_epoch=100` and `num_replicas=2`, it would collect 80 samples from Sampler1 and 20 samples from the MixedSampler. The MixedSampler would in turn collect 18 samples from Sampler2 and 2 samples from Sampler3. After collecting those 100 samples, the DistributedMixedSampler would shard the samples across the two nodes, ensuring each node receives a unique slice of 50 examples. If any of the child samplers were distributed samples, then the DistributedMixedSampler would not receive n_examples_per_epoch indices, and we would raise an error. NOTE: The order of the datasets in datasets_info MUST match the order of the datasets in the ConcatDataset associated with this MixedSampler. Args: datasets_info: List of dictionaries, where each dictionary must contain at a minimum: - "sampler": Sampler object for the dataset - "dataset": Dataset object associated with the sampler - "probability": Probability of sampling from this dataset num_replicas: Number of replicas (nodes) in the distributed setting rank: Rank of the current node n_examples_per_epoch: Number of examples in an epoch. Effectively, the "length" of the sampler (since we often sample with replacement). May be None, in which case the number of examples per epoch must be set dynamically by a parent sampler. shuffle: Whether to shuffle the indices. If False, the iterator will return all sampled indices from the first dataset, then the second, etc. drop_last: Whether to drop the last incomplete batch if the dataset size is not divisible by the batch size Returns: iter: An iterator over indices of the dataset for the current process (of length n_samples, not n_examples_per_epoch) Reference: `PyTorch DistributedSampler <https://github.com/pytorch/pytorch/blob/main/torch/utils/data/distributed.py#L68>`_ """ def __init__( self, datasets_info: list[dict[str, any]], num_replicas: int, rank: int, n_examples_per_epoch: int | None, shuffle: bool = True, drop_last: bool = True, ): self.datasets_info = datasets_info self.num_replicas = num_replicas self.rank = rank self.shuffle = shuffle self.drop_last = drop_last self.epoch = 0 # Initialize epoch to 0 self.samplers = [info["sampler"] for info in datasets_info] # ordered self.probabilities = [info["probability"] for info in datasets_info] # ordered self.dataset_lengths = [len(info["dataset"]) for info in datasets_info] # ordered # Calculate cumulative lengths of datasets (so we can map local dataset indices to ConcatDataset indices) self.cumulative_lengths = [0, *list(accumulate(add, self.dataset_lengths))] # ordered # Remove the last element to match the other list shapes self.cumulative_lengths = self.cumulative_lengths[:-1] # Assert that: # ... the number of samplers, probabilities, and datasets match assert len(self.samplers) == len(self.probabilities) == len(self.dataset_lengths) # ... the probabilities sum to 1 assert abs(sum(self.probabilities) - 1.0) < 1e-6, "Probabilities must sum to 1" # ... the datasets_info contains keys for "sampler", "probability", and "dataset" assert "sampler" in datasets_info[0] and "probability" in datasets_info[0] and "dataset" in datasets_info[0] if n_examples_per_epoch is not None: self._set_num_examples_per_epoch(n_examples_per_epoch) def _set_num_examples_per_epoch(self, n_examples_per_epoch: int) -> None: """Set the number of examples per epoch, and update the number of examples per epoch for each sampler. Allows for dynamic setting and propagation of the number of examples per epoch. Args: n_examples_per_epoch: Number of examples in an epoch. Effectively, the "length" of the sampler """ self.n_examples_per_epoch = n_examples_per_epoch # If the number of examples per epoch is not evenly divisible by the number of replicas, there # is no need to drop any data, since the examples will be split equally. if self.drop_last and self.n_examples_per_epoch % self.num_replicas != 0: # Split to nearest available length that is evenly divisible. # This is to ensure each rank receives the same amount of data when using this Sampler. self.n_samples = math.ceil((self.n_examples_per_epoch - self.num_replicas) / self.num_replicas) else: self.n_samples = math.ceil(self.n_examples_per_epoch / self.num_replicas) self.epoch = 0 # Initialize epoch to 0 self.total_size = ( self.n_samples * self.num_replicas ) # May be greater than n_examples_per_epoch, which we will handle in __iter__ # Create a list representing the number of items to sample from each dataset (sampler) self.n_examples_per_dataset = [math.ceil(prob * self.total_size) for prob in self.probabilities] # ordered for sampler, n_examples in zip(self.samplers, self.n_examples_per_dataset, strict=False): # Set the `n_examples_per_epoch` for each sampler, if they allow it... # NOTE: Required for MixedSamplers, which must continue propagating the number of examples per epoch if hasattr(sampler, "_set_num_examples_per_epoch"): sampler._set_num_examples_per_epoch(n_examples) # ... override the `num_samples` attribute if it exists (e.g., for WeightedRandomSampler) if hasattr(sampler, "num_samples"): sampler.num_samples = n_examples # ... and assert that either we have more than n_examples_per_epoch examples or we are sampling with replacement sampler_has_enough_data = len(sampler) >= n_examples sampler_is_replacement = getattr(sampler, "replacement", False) assert ( sampler_has_enough_data or sampler_is_replacement ), "Must either have enough data or be sampling with replacement" def __iter__(self): # Trigger the __iter__ of each sampler upfront (generates a list of local indices based on the sampling scheme) sampler_iters = [iter(sampler) for sampler in self.samplers] # Take the first n_examples_per_dataset indices from each sampler indices = [ list(itertools.islice(sampler_iter, n)) for sampler_iter, n in zip(sampler_iters, self.n_examples_per_dataset, strict=False) ] # Convert to global indices for i in range(1, len(indices)): indices[i] = [index + self.cumulative_lengths[i] for index in indices[i]] # Flatten the list of local indices indices = [index for sublist in indices for index in sublist] padding_size = self.total_size - len(indices) if not self.drop_last and padding_size > 0: # Add extra samples to make it evenly divisible if padding_size <= len(indices): indices += indices[:padding_size] else: indices += (indices * math.ceil(padding_size / len(indices)))[:padding_size] else: # Remove tail of data to make it evenly divisible. indices = indices[: self.total_size] assert len(indices) == self.total_size, f"Expected {self.total_size} indices, got {len(indices)}" # Randomly permute the global indices (otherwise, we will sample one dataset first, then the next, etc.) if self.shuffle: # Set the seed based on the epoch indices = torch.tensor(indices) g = torch.Generator() g.manual_seed(self.epoch) # Randomly permute the global indices permuted_indices = torch.randperm(len(indices), generator=g) indices = indices[permuted_indices] # Back to list indices = indices.tolist() # Subsample # This samples [0, num_replicas, 2*num_replicas, ...] for node 0, # [1, num_replicas+1, 2*num_replicas+1...] for node 1, and so on indices = indices[self.rank : self.total_size : self.num_replicas] assert len(indices) == self.n_samples return iter(indices) def __len__(self): return self.n_samples
[docs] def set_epoch(self, epoch: int) -> None: self.epoch = epoch for sampler in self.samplers: set_sampler_epoch(sampler, epoch)
[docs] class MixedSampler(DistributedMixedSampler): """A non-distributed sampler that samples from an arbitrary list of samplers with specified probabilities. This class acts like a DistributedMixedSampler with `rank=0` and `num_replicas=1`. Args: datasets_info: List of dictionaries, where each dictionary must contain at a minimum: - "sampler": Sampler object for the dataset - "dataset": Dataset object associated with the sampler - "probability": Probability of sampling from this dataset n_examples_per_epoch: Number of examples in an epoch. Effectively, the "length" of the sampler. shuffle: Whether to shuffle the indices. If False, the iterator will return all sampled indices from the first dataset, then the second, etc. """ def __init__( self, datasets_info: list[dict[str, any]], n_examples_per_epoch: int | None = None, shuffle: bool = True, ): super().__init__( datasets_info=datasets_info, num_replicas=1, rank=0, n_examples_per_epoch=n_examples_per_epoch, shuffle=shuffle, )
[docs] class FallbackSamplerWrapper(Sampler): """A wrapper around a sampler that allows for a fallback sampler to be used when an error occurs. Meant to be used with a FallbackDatasetWrapper. """ def __init__(self, sampler: Sampler, fallback_sampler: Sampler, n_fallback_retries: int = 2): self.sampler = sampler self.fallback_sampler = fallback_sampler self.n_fallback_retries = n_fallback_retries def __iter__(self): # Create a list of iterators, each of which will yield the next n_fallback_retries indices from the fallback sampler fallback_iterators = [itertools.cycle(iter(self.fallback_sampler)) for _ in range(self.n_fallback_retries)] iterators = [iter(self.sampler), *fallback_iterators] return zip(*iterators, strict=False) def __len__(self): return len(self.sampler)
[docs] def set_epoch(self, epoch: int) -> None: set_sampler_epoch(self.sampler, epoch) set_sampler_epoch(self.fallback_sampler, epoch, add_random_offset=True)
[docs] class LazyWeightedRandomSampler(WeightedRandomSampler): def __init__( self, weights: Sequence[float], num_samples: int, replacement: bool = True, generator: torch.Generator | None = None, prefetch_buffer_size: int = 1, ) -> None: assert replacement, "LazyWeightedRandomSampler only supports replacement=True" super().__init__(weights, num_samples, replacement, generator) self.prefetch_buffer_size = prefetch_buffer_size # We cannot use torch.multinomial with > 2^24 categories (and MGnify validation has more than this) # precompute sampling probabilities weights_np = self.weights.cpu().numpy() if self.weights.is_cuda else self.weights.numpy() self.cumsum = np.cumsum(weights_np, dtype=np.float64) self.cumsum = self.cumsum / self.cumsum[-1] # Normalize to [0, 1] def __iter__(self): prefetch_buffer = [] for _ in range(self.num_samples): if not prefetch_buffer: # Pull another buffer of length `prefetch_buffer_size` # Use inverse transform sampling with precomputed CDF random_values = torch.rand(self.prefetch_buffer_size, generator=self.generator).cpu().numpy() prefetch_buffer = np.searchsorted(self.cumsum, random_values).tolist() yield prefetch_buffer.pop(0)
[docs] class LoadBalancedDistributedSampler(DistributedSampler): """DistributedSampler that balances large examples across replicas. Helpful for validation, where we don't want GPUs to be idle while waiting for the slowest replica to finish. For example, we may want to avoid the scenario where one GPU receives many large examples that are slow to process, while another GPU receives many small examples that are quick to process. NOTE: Only useful for validation, as the order of the examples is deterministic. Args: dataset: Dataset used for sampling. key_to_balance: Key in the dataset data dataframe that contains the length (size) of each example. The dataset must have a data attribute that can be accessed like a dataframe. For example, if the dataset has a data attribute that is a pandas DataFrame, the key_to_balance should be a column in that DataFrame (i.e., "n_tokens"). num_replicas (int, optional): Number of processes participating in distributed training. By default, :attr:`world_size` is retrieved from the current distributed group. rank (int, optional): Rank of the current process within :attr:`num_replicas`. By default, :attr:`rank` is retrieved from the current distributed group. drop_last (bool, optional): if ``True``, then the sampler will drop the tail of the data to make it evenly divisible across the number of replicas. If ``False``, the sampler will add extra indices to make the data evenly divisible across the replicas. Default: ``False``. """ def __init__( self, dataset: Dataset, key_to_balance: str, num_replicas: int | None = None, rank: int | None = None, drop_last: bool = False, ): super().__init__( dataset=dataset, num_replicas=num_replicas, rank=rank, shuffle=False, # No shuffling when we try and balance across replicas drop_last=drop_last, ) self.length_key = key_to_balance def __iter__(self) -> Iterator[int]: # Extract sizes from the dataset sizes = self.dataset.data[self.length_key] indices = list(range(len(sizes))) # Sort indices by example size indices.sort(key=lambda x: sizes[x], reverse=True) if not self.drop_last: # Add extra samples to make it evenly divisible padding_size = self.total_size - len(indices) if padding_size > 0: if padding_size <= len(indices): indices += indices[-padding_size:] # Add from the end of the list, which are the smallest examples else: indices += indices[-1:] * padding_size else: # Remove tail of data to make it evenly divisible. indices = indices[: self.total_size] assert len(indices) == self.total_size # Subsample indices = indices[self.rank : self.total_size : self.num_replicas] assert len(indices) == self.num_samples return iter(indices)