From e1b37ecf28f7e9e6386041bd175d7b3d81026e78 Mon Sep 17 00:00:00 2001 From: Kieran Didi <58345129+kierandidi@users.noreply.github.com> Date: Wed, 10 Sep 2025 12:15:44 -0700 Subject: [PATCH 1/8] fix: improve parsing speed for building template atom array and make codebase consistent with dev (#17) * fix: prevent chain_ids in outer scope from being overridden by loop (#13) * feat: performance improvements for parsing * chore: ruff formatting * chore: ruff formatting * fix: address python version and docstring changes --------- Co-authored-by: Richard Shuai Co-authored-by: Nathaniel Corley --- pyproject.toml | 2 +- src/atomworks/io/template.py | 51 ++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22181121..927107a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "atomworks" version = "1.0.0" description = "A research-oriented data toolkit for training biomolecular deep-learning foundation models" readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.11" authors = [ { name = "Institute for Protein Design", email = "contact@ipd.uw.edu" } ] diff --git a/src/atomworks/io/template.py b/src/atomworks/io/template.py index e0465033..cc62ed54 100644 --- a/src/atomworks/io/template.py +++ b/src/atomworks/io/template.py @@ -150,6 +150,42 @@ def match_residue_to_template( return template +def _find_residue_mask_fast( + residue_keys: np.ndarray, + sorted_keys: np.ndarray, + sort_idx: np.ndarray, + chain_id: str, + res_name: str, + res_id: int, +) -> np.ndarray: + """ + Efficient method of getting a residue mask from a sorted list of residue keys. + + Args: + - residue_keys: Structured np array of residue keys to search through + - sorted_keys: Sorted list of residue keys + - sort_idx: Index of the sorted list (get from doing np.argsort(residue_keys)) + - chain_id: Chain ID of the residue + - res_name: Residue name of the residue + - res_id: Residue ID of the residue + + Returns: + - mask: Boolean mask of the residue keys + """ + key = np.array([(chain_id, res_name, res_id)], dtype=residue_keys.dtype) + + # Find start and end indices using binary search + start_idx = np.searchsorted(sorted_keys, key)[0] + end_idx = np.searchsorted(sorted_keys, key, side="right")[0] + + # Create mask + mask = np.zeros(len(residue_keys), dtype=bool) + if start_idx < end_idx: + mask[sort_idx[start_idx:end_idx]] = True + + return mask + + def build_template_atom_array( chain_info_dict: dict[str, dict[str, Any]], atom_array: AtomArray | None = None, @@ -252,6 +288,15 @@ def build_template_atom_array( # ... create a list of atoms based on the reference CCD entries template_residues = [] chain_identifiers = chain_iids if use_chain_iids else chain_ids + + # ... get the sorted list of residue keys. This will make the residue mask lookup much faster. + residue_keys = np.array( + list(zip(chain_identifiers, res_names, res_ids, strict=True)), + dtype=np.dtype([("chain_id", "object"), ("res_name", "object"), ("res_id", " Date: Thu, 11 Sep 2025 17:58:27 -0700 Subject: [PATCH 2/8] refactor: datasets revised (#25) * fix: prevent chain_ids in outer scope from being overridden by loop (#13) * refactor: datasets initial commit with backwards compatibility * docs: first pass at dataset docs * chore: update test metadata parquet files * fix: passing tests * docs: updated docs for datasets * fix: tests * fix: dataset exploration formatting * fix: caching test * chore: PR comments * fix: CI * fix: CI * fix: CI * fix: CI --------- Co-authored-by: Richard Shuai --- .github/workflows/lint_and_test.yaml | 4 +- .../examples/dataset_exploration_01.png | Bin 0 -> 75012 bytes .../examples/simple_transform_example.pdf | Bin 0 -> 51978 bytes docs/examples/dataset_exploration.py | 240 +++++ .../examples/load_and_visualize_structures.py | 13 + src/atomworks/common.py | 104 +- src/atomworks/constants.py | 2 +- src/atomworks/io/utils/ccd.py | 61 +- src/atomworks/ml/datasets/datasets.py | 892 +++++++++++------- src/atomworks/ml/datasets/loaders.py | 262 +++++ src/atomworks/ml/datasets/parsers/base.py | 11 +- .../parsers/default_metadata_row_parsers.py | 4 +- .../ml/transforms/af3_reference_molecule.py | 18 +- src/atomworks/ml/transforms/base.py | 253 +++-- src/atomworks/ml/transforms/filters.py | 6 +- src/atomworks/ml/utils/debug.py | 28 +- src/atomworks/ml/utils/io.py | 341 +++---- src/atomworks_cli/setup.py | 2 +- tests/data/ml/af3_model_outs_protein_dna.pkl | Bin 94251 -> 0 bytes .../data/ml/af3_model_outs_protein_ligand.pkl | Bin 109735 -> 0 bytes tests/data/ml/pdb_interfaces/metadata.parquet | Bin 12819070 -> 12819150 bytes tests/data/ml/pdb_pn_units/metadata.parquet | Bin 14901729 -> 14899574 bytes tests/io/components/test_caching.py | 6 +- tests/ml/conftest.py | 142 +-- tests/ml/datasets/test_datasets.py | 18 +- .../ml/datasets/test_datasets_with_filters.py | 2 + .../pipelines/test_data_loading_pipelines.py | 24 +- .../ml/pipelines/test_pipeline_regression.py | 4 +- tests/ml/transforms/msa/test_load_msas.py | 43 - tests/ml/utils/test_io.py | 48 - 30 files changed, 1637 insertions(+), 891 deletions(-) create mode 100644 docs/_static/examples/dataset_exploration_01.png create mode 100644 docs/_static/examples/simple_transform_example.pdf create mode 100644 docs/examples/dataset_exploration.py create mode 100644 src/atomworks/ml/datasets/loaders.py delete mode 100644 tests/data/ml/af3_model_outs_protein_dna.pkl delete mode 100644 tests/data/ml/af3_model_outs_protein_ligand.pkl delete mode 100644 tests/ml/utils/test_io.py diff --git a/.github/workflows/lint_and_test.yaml b/.github/workflows/lint_and_test.yaml index 4fa9adc3..e3a149ad 100644 --- a/.github/workflows/lint_and_test.yaml +++ b/.github/workflows/lint_and_test.yaml @@ -55,10 +55,9 @@ jobs: test_digs: name: pytest (jojo) - runs-on: [jojo] + runs-on: [self-hosted] timeout-minutes: 30 needs: lint - if: github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - name: Run tests @@ -76,6 +75,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 needs: lint + if: github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/docs/_static/examples/dataset_exploration_01.png b/docs/_static/examples/dataset_exploration_01.png new file mode 100644 index 0000000000000000000000000000000000000000..a0316e52c001a4cbfd2a9102dd918c5cb9af6203 GIT binary patch literal 75012 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

IRi*w*L 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 G{C@yNkk}{y literal 0 HcmV?d00001 diff --git a/docs/_static/examples/simple_transform_example.pdf b/docs/_static/examples/simple_transform_example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..491675ecabc77f899e3a61a517e349924f2bd59e GIT binary patch literal 51978 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 literal 0 HcmV?d00001 diff --git a/docs/examples/dataset_exploration.py b/docs/examples/dataset_exploration.py new file mode 100644 index 00000000..61b35da5 --- /dev/null +++ b/docs/examples/dataset_exploration.py @@ -0,0 +1,240 @@ +""" +Dataset Exploration and Management in AtomWorks +============================================== + +This example demonstrates how to work with datasets in AtomWorks, from simple file-based datasets to complex tabular datasets with custom loaders and transform pipelines. + +**Prerequisites**: Familiarity with :doc:`load_and_visualize_structures` for basic structure loading and :doc:`pocket_conditioning_transform` for understanding transform pipelines. + +.. figure:: /_static/examples/dataset_exploration_01.png + :alt: Cropped structure visualization + :width: 400px + + Visualization of a cropped structure after applying transform pipelines to a dataset. +""" + +######################################################################## +# Overview +# ========= +# +# ``Transform`` pipelines can be used with any data loader and any dataset. They are simply functions that take as input an ``AtomArray`` (which is often the output of ``AtomWorks.io``) and output ``PyTorch`` tensors ready for ingestion by a model. +# +# However, most users will not want to build datasets from scratch. For convenience, we provide pre-built datasets and dataloaders that play well with ``Transform`` pipelines as well, roughly adhering to `Torchvision `_ conventions. +# +# We demonstrate below a couple of different ways to connect a ``Transform`` pipeline with arbitrary datasets and connect them with trivial ``Transform`` pipelines. + +######################################################################## +# Datasets in AtomWorks +# ====================== + +######################################################################## +# Using a Folder of CIF/PDB Files as a Dataset +# --------------------------------------------- +# +# The simplest way to use AtomWorks with a Dataset is to create a ``Dataset`` and ``Sampler`` pointed to a directory of structural files (e.g., PDB, CIF). +# +# .. note:: +# All AtomWorks Datasets require a ``name`` attribute to support many of the logging/debugging features that are supplied out-of-the-box. + +from atomworks.ml.datasets.datasets import FileDataset + +# To setup the test pack, if not already, run ``atomworks setup tests`` +dataset = FileDataset.from_directory( + directory="../../tests/data/ml/af2_distillation/cif", name="example_directory_dataset" +) + +######################################################################## +# Let's explore the dataset a tiny bit. + +# Count the number of examples in the dataset +print(f"Dataset has {len(dataset)} examples.") + +# Print the raw data of the first 5 examples +for i, example in enumerate(dataset): + if i >= 5: + break + print(f"Example {i + 1}: {example}") + +######################################################################## +# Understanding Dataset Requirements +# ---------------------------------- +# +# At a high level, to train models with AtomWorks, we need typically need a Dataset that: +# +# (1) Takes as input an item index and returns the corresponding example information; typically includes: +# a. Path to a structural file saved on disk (``/path/to/dataset/my_dataset_0.cif``) +# b. Additional item-specific metadata (e.g., class labels) +# +# (2) Pre-loads structural information from the returned example into an ``AtomArray`` and assembles inputs for the Transform pipeline +# +# (3) Feed the input dictionary through a Transform pipeline and returns the result +# +# So far, the ``FileDataset`` we initialized only accomplishes (1) from above - returning the raw data. +# +# To accomplish (2), we can additionally pass a loading function at dataset initialization that takes the raw example data as input and returns a pre-processed ready for a Transform pipeline. +# +# In most cases, this will involve using ``parse`` or ``load_any`` from ``AtomWorks.io`` to build an ``AtomArray``, which is the common language of our ``Transform`` library. + +from atomworks.io import parse + + +def simple_loading_fn(raw_data) -> dict: + """Simple loading function that parses structural data and returns an AtomArray.""" + parse_output = parse(raw_data) + return {"atom_array": parse_output["assemblies"]["1"][0]} + + +dataset_with_loading_fn = FileDataset.from_directory( + directory="../../tests/data/pdb", name="example_pdb_dataset", loader=simple_loading_fn +) +output = dataset_with_loading_fn[1] +print(f"Output AtomArray has {len(output['atom_array'])} atoms!") + +######################################################################## +# Adding Transform Pipelines +# --------------------------- +# +# Next up is adding in a pipeline. Let's create a simple one with a dramatic crop. + +from atomworks.ml.transforms.base import Compose +from atomworks.ml.transforms.crop import ( + CropSpatialLikeAF3, +) +from atomworks.ml.transforms.atom_array import ( + AddGlobalAtomIdAnnotation, +) +from atomworks.ml.transforms.atomize import AtomizeByCCDName +from atomworks.constants import STANDARD_AA + +pipe = Compose( + [ + # (We need to add these transforms before we can crop) + AddGlobalAtomIdAnnotation(), + AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=STANDARD_AA), + # Crop to 20 tokens (which in this case is number amino acids/nucleic acid bases + number of small molecule atoms) + CropSpatialLikeAF3(crop_size=20), + ], + track_rng_state=False, +) + +######################################################################## +# Just like with the loading function, we can also pass a composed ``Transform`` pipeline to our datasets. + +dataset_with_loading_fn_and_transforms = FileDataset.from_directory( + directory="../../tests/data/pdb", name="example_pdb_dataset", loader=simple_loading_fn, transform=pipe +) + +######################################################################## +# Visualizing the Results +# ------------------------ +# +# Let's visualize the result of our transform pipeline: + +from atomworks.io.utils.visualize import view + +pipeline_output = dataset_with_loading_fn_and_transforms[ + 0 +] # This will trigger the loading function and print the row information + +view(pipeline_output["atom_array"]) + +######################################################################## +# .. figure:: /_static/examples/dataset_exploration_01.png +# :alt: Cropped structure visualization + +######################################################################## +# And indeed, we have a cropped example! +# +# We will then sample uniformly (with or without replacement) from this dataset during training. Such a simple application may be appropriate for many fine-tuning cases such as distillation. +# +# The only "gotcha" outside of normal PyTorch sampling is that you'll need to implement a default collate function (which could simply be the identity) so long as your output dictionary contains an ``AtomArray``. + +from torch.utils.data import RandomSampler, DataLoader + +sampler = RandomSampler(dataset_with_loading_fn_and_transforms) +loader = DataLoader( + dataset=dataset_with_loading_fn_and_transforms, + sampler=sampler, + collate_fn=lambda x: x, # Identity collate: returns the batch as-is +) + +for i, example in enumerate(loader): + # (Since we now have a batch dimension, we need the extra indexing dimension) + print(f"Example: {i}, Length of AtomArray: {len(example[0]['atom_array'])}") + if i > 2: + break + +######################################################################## +# For more complicated sampling strategies, including distributed sampling for multi-GPU training, see the API documentation for ``samplers.py``, and the tests in ``test_samplers.py`` + +######################################################################## +# Tabular Datasets +# ================= +# +# So far, we have seen how to make and use simple datasets with just paths. In many applications, however, we may want more nuanced dataset schemes. For example, when training on the PDB, we typically want to sample at the chain or interface-level rather than the entry-level (since we are cropping, the two are distinct). We may also want to provide additional information other than the raw CIF file (e.g., class labels) to be used by the model during training. +# +# We thus support instantiating datasets from tabular sources stored on disk. +# +# We have implemented a ``PandasDataset`` class for this purpose; however, any tabular format (e.g., ``PolarsDataset``) could be similarly implemented without difficulty should the need arise (PR's welcome!) + +######################################################################## +# PandasDataset +# -------------- +# +# The ``PandasDataset`` class requires a couple of arguments: +# - ``data``: Either a pandas DataFrame or path to a CSV/Parquet file containing the tabular data. Each row represents one example. +# - ``name``: Descriptive name for this dataset, just as in ``FileDataset`` and all AtomWorks ``Dataset`` classes. Used for debugging and some downstream functions when using nested datasets. +# +# Again, we can also pass a `transform` pipeline and `loader`: +# - ``transform``: Transform pipeline to apply to loaded data. +# - ``loader``: Optional function to process raw DataFrame rows into Transform-ready format. +# +# There's also a few other `PandasDataset`-specific arguments to note: +# - ``filters``: Optional list of pandas query strings to filter the data. Applied in order during initialization. +# - ``columns_to_load``: Optional list of column names to load when reading from a file. If None, all columns are loaded. Can dramatically reduce memory usage and load time if loading from a columnar format like Parquet. + +######################################################################## +# We will start by exploring an example metadata dataframe, then load it into a ``PandasDataset``. + +from atomworks.ml.utils.io import read_parquet_with_metadata + +interfaces_metadata_parquet_path = "../../tests/data/ml/pdb_interfaces/metadata.parquet" +interfaces_df = read_parquet_with_metadata(interfaces_metadata_parquet_path) +print("DataFrame shape:", interfaces_df.shape) +print("Columns:", list(interfaces_df.columns)) +print("\nFirst few rows:") +print(interfaces_df.head()) + +######################################################################## +# Understanding the Metadata +# --------------------------- +# +# This dataframe includes a row for every interface between two ``pn_units`` (essentially, chains) in the Protein Data Bank. For illustration purposes, however, we're loading the test dataframe, which only includes information for a small subset of the full PDB. +# +# The complete dataframes can be downloaded with ``atomworks setup metadata`` and will be described in greater detail elsewhere in the documentation. +# +# For our purposes, note that we have a ``path`` column that points to a ``.cif`` file stored on disk, an ``example_id`` column which is unique across every row in the dataset, and two columns ``pn_unit_1_iid`` and ``pn_unit_2_iid`` that specify the interface of interest for this particular row. +# +# .. note:: +# Because a given PDB ID may contain many interfaces and thus may appear multiple times in our dataset, we must also incorporate the ``assembly_id`` and the ``pn_unit_iids`` of the two interacting chains within the ``example_id``. + +from atomworks.ml.datasets.datasets import PandasDataset +from atomworks.ml.datasets.loaders import loader_with_query_pn_units + +dataset = PandasDataset( + data=interfaces_df, + name="interfaces_dataset", + # We use a pre-built loader that takes in a list of column names and returns a loader function + loader=loader_with_query_pn_units(pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"]), + transform=pipe, +) + +print(f"Created PandasDataset with {len(dataset)} examples") + +######################################################################## +# Related Examples +# ---------- +# +# - :doc:`load_and_visualize_structures` - Learn how to load and explore protein structures +# - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation +# - :doc:`annotate_and_save_structures` - Learn how to add custom annotations to structures and save them for later use diff --git a/docs/examples/load_and_visualize_structures.py b/docs/examples/load_and_visualize_structures.py index 09aa358e..1d7c1923 100644 --- a/docs/examples/load_and_visualize_structures.py +++ b/docs/examples/load_and_visualize_structures.py @@ -155,6 +155,19 @@ else: print(f" {key}: {value}") +######################################################################## +# Accessing the Original mmCIF Data +# ----------------------------------- +# +# If there is information contained in the mmCIF file that is *not* extracted by `parse`, we can still gain access +# to the original Biotite CIF block using the ``keep_cif_block=True`` argument to `parse`. +# We can then use the Biotite API to explore any additional data we might need. +# (E.g., we could write a simple `Transform` that extracts the necessary information) + +# Load with original CIF block retained +parse_output_with_cif = parse(pdb_path, keep_cif_block=True) +cif_block = parse_output_with_cif.get("cif_block", None) + ######################################################################## # Related Examples # ---------- diff --git a/src/atomworks/common.py b/src/atomworks/common.py index 89a8d94b..ff72da25 100644 --- a/src/atomworks/common.py +++ b/src/atomworks/common.py @@ -11,41 +11,94 @@ def exists(obj: Any) -> bool: - """Check that `obj` is not `None`.""" + """Check that obj is not None. + + Args: + obj: The object to check. + + Returns: + True if obj is not None, False otherwise. + """ return obj is not None def default(obj: Any, default: Any) -> Any: - """Return `obj` if not `None`, otherwise return `default`.""" + """Return obj if not None, otherwise return default. + + Args: + obj: The primary object to return. + default: The fallback value if obj is None. + + Returns: + obj if it is not None, otherwise default. + """ return obj if exists(obj) else default def to_hashable(element: Any) -> Any: - """Convert an element to a hashable type.""" + """Convert an element to a hashable type. + + Args: + element: The element to convert. + + Returns: + The element if already hashable, otherwise converted to a tuple. + """ return element if isinstance(element, int | str | np.integer | np.str_) else tuple(element) def string_to_md5_hash(s: str, truncate: int = 32) -> str: - """Generate an MD5 hash of a string and return the first `truncate` characters.""" + """Generate an MD5 hash of a string and return the first truncate characters. + + Args: + s: The string to hash. + truncate: Number of characters to return from the hash. + + Returns: + The truncated MD5 hash as a string. + """ full_hash = hashlib.md5(s.encode("utf-8")).hexdigest() return full_hash[:truncate] def sum_string_arrays(*objs: np.ndarray | str) -> np.ndarray: - """ - Sum a list of string arrays or strings into a single string array by concatenating them and - determining the shortest string length to set as dtype. + """Sum a list of string arrays or strings into a single string array. + + Concatenates the arrays and determines the shortest string length to set as dtype. + + Args: + *objs: Variable number of string arrays or strings to sum. + + Returns: + A single concatenated string array. """ return reduce(np.char.add, objs).astype(object).astype(str) def not_isin(element: np.ndarray, array: np.ndarray, **isin_kwargs) -> np.ndarray: - """Like `~np.isin`, but more efficient.""" + """Like ~np.isin, but more efficient. + + Args: + element: The array to test. + array: The array of values to test against. + **isin_kwargs: Additional keyword arguments for np.isin. + + Returns: + Boolean array indicating which elements are not in the array. + """ return np.isin(element, array, invert=True, **isin_kwargs) def listmap(func: Callable, *iterables) -> list: - """Like `map`, but returns a list instead of an iterator.""" + """Like map, but returns a list instead of an iterator. + + Args: + func: The function to apply. + *iterables: Variable number of iterables to map over. + + Returns: + A list containing the results of applying func to the iterables. + """ return compose(list, map)(func, *iterables) @@ -55,6 +108,12 @@ def as_list(value: Any) -> list: Handles various types using duck typing: - Iterable objects (lists, tuples, strings, etc.): converted to list - Single values: wrapped in a list + + Args: + value: The value to convert to a list. + + Returns: + A list containing the value(s). """ try: # Try to iterate over the value (duck typing approach) @@ -68,7 +127,16 @@ def as_list(value: Any) -> list: def immutable_lru_cache(maxsize: int = 128, typed: bool = False, deepcopy: bool = True) -> Callable: - """An immutable version of `lru_cache` for caching functions that return mutable objects.""" + """An immutable version of lru_cache for caching functions that return mutable objects. + + Args: + maxsize: Maximum number of items to cache. + typed: Whether to treat different types as separate cache entries. + deepcopy: Whether to use deep copy for immutable caching. + + Returns: + A decorator that provides immutable caching functionality. + """ copy_func = copy.deepcopy if deepcopy else copy.copy def decorator(func: Callable) -> Callable: @@ -84,27 +152,33 @@ def wrapper(*args, **kwargs) -> Any: class KeyToIntMapper: - """ - Maps keys to unique integers based on the order of the first appearance of the key. + """Maps keys to unique integers based on the order of the first appearance of the key. - This is useful for mapping id's such as `chain_id`, `chain_entity`, `molecule_iid`, etc. + This is useful for mapping id's such as chain_id, chain_entity, molecule_iid, etc. to integers. Example: - ```python chain_id_to_int = KeyToIntMapper() chain_id_to_int("A") # 0 chain_id_to_int("C") # 1 chain_id_to_int("A") # 0 chain_id_to_int("B") # 2 - ``` """ def __init__(self): + """Initialize KeyToIntMapper with empty mapping.""" self.key_to_id = {} self.next_id = 0 def __call__(self, value: Any) -> int: + """Map a key to a unique integer. + + Args: + value: The key to map. + + Returns: + The unique integer assigned to the key. + """ if value not in self.key_to_id: self.key_to_id[value] = self.next_id self.next_id += 1 diff --git a/src/atomworks/constants.py b/src/atomworks/constants.py index 06f907a5..c10ca5cf 100644 --- a/src/atomworks/constants.py +++ b/src/atomworks/constants.py @@ -1,4 +1,4 @@ -"""Constants used in the `atomworks.io` package.""" +"""Constants used in the AtomWorks library.""" import logging import os diff --git a/src/atomworks/io/utils/ccd.py b/src/atomworks/io/utils/ccd.py index c6b2cee9..4c8c6ca1 100644 --- a/src/atomworks/io/utils/ccd.py +++ b/src/atomworks/io/utils/ccd.py @@ -3,6 +3,7 @@ import os from collections import defaultdict from collections.abc import Iterable +from pathlib import Path from typing import Literal import biotite.structure as struc @@ -82,28 +83,54 @@ def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_ Only counts codes when they adhere to the CCD mirror layout (e.g. .../H/HEM/HEM.cif) """ root = os.fspath(ccd_mirror_path) + + # Check if we have a pre-computed cache file + cache_file = os.path.join(root, ".ccd_codes_cache") + if os.path.exists(cache_file): + try: + # Check if cache is newer than the directory + cache_mtime = os.path.getmtime(cache_file) + dir_mtime = os.path.getmtime(root) + if cache_mtime > dir_mtime: + with open(cache_file) as f: + codes = {line.strip() for line in f if line.strip()} + return frozenset(codes) + except OSError: + # If cache is corrupted, fall back to scanning + pass + + # Fall back to filesystem scan codes: set[str] = set() - # NOTE: The below is an optimized file-system scan since this is run at every - with os.scandir(root) as level1: - for l1 in level1: - if not l1.is_dir(follow_symlinks=False): + root_path = Path(root) + + for level1_dir in root_path.iterdir(): + if not level1_dir.is_dir(): + continue + first_letter = level1_dir.name + if len(first_letter) != 1: + continue + + for level2_dir in level1_dir.iterdir(): + if not level2_dir.is_dir(): continue - first_letter = l1.name - if len(first_letter) != 1: + code = level2_dir.name + if not code or code[0] != first_letter: continue - with os.scandir(l1.path) as level2: - for l2 in level2: - if not l2.is_dir(follow_symlinks=False): - continue - code = l2.name - if not code or code[0] != first_letter: - continue - - expected = os.path.join(l2.path, f"{code}.cif") - if os.path.isfile(expected): - codes.add(code) + expected_file = level2_dir / f"{code}.cif" + if expected_file.is_file(): + codes.add(code) + + # Cache the results for next time + try: + with open(cache_file, "w") as f: + for code in sorted(codes): + f.write(f"{code}\n") + except OSError: + # If we can't write cache, that's okay + pass + return frozenset(codes) diff --git a/src/atomworks/ml/datasets/datasets.py b/src/atomworks/ml/datasets/datasets.py index 4bfd2493..d6acba0a 100644 --- a/src/atomworks/ml/datasets/datasets.py +++ b/src/atomworks/ml/datasets/datasets.py @@ -1,8 +1,22 @@ +"""AtomWorks Dataset classes and common APIs. + +Provides composable dataset classes for molecular data with Transform pipeline support. + +Key Components: + * :class:`MolecularDataset`: Base class with Transform pipeline and error handling + * :class:`PandasDataset`: Tabular data stored as pandas DataFrames + * :class:`FileDataset`: Individual files as examples + +For custom use cases, users may implement their own Dataset classes. Downstream code +makes no assumptions about the specific implementation. +""" + import copy import os import socket import time -from abc import abstractmethod +import warnings +from abc import ABC, abstractmethod from collections.abc import Callable from functools import cached_property from os import PathLike @@ -13,408 +27,452 @@ import pandas as pd from torch.utils.data import ConcatDataset, Dataset -from atomworks.common import default, exists from atomworks.ml.datasets import logger -from atomworks.ml.datasets.parsers import MetadataRowParser, load_example_from_metadata_row from atomworks.ml.preprocessing.constants import NA_VALUES -from atomworks.ml.transforms.base import Compose, Transform, TransformedDict +from atomworks.ml.transforms.base import TransformedDict from atomworks.ml.utils.debug import save_failed_example_to_disk -from atomworks.ml.utils.io import read_parquet_with_metadata +from atomworks.ml.utils.io import read_parquet_with_metadata, scan_directory from atomworks.ml.utils.rng import capture_rng_states -_USER = default(os.getenv("USER"), "") +class ExampleIDMixin(ABC): + """Mixin providing example ID functionality to a Dataset. -class BaseDataset(Dataset): - """ - Abstract base class for datasets. All dataset types (e.g., Pandas, Polars) should inherit from this class - and implement its methods. - - In addition to the standard PyTorch Dataset methods (`__getitem__`, `__len__`), this class requires - implementations for converting between example IDs and indices, which is necessary for our nested dataset structure. + Provides methods for converting between example IDs and indices, and checking + if an example ID exists in the dataset. """ @abstractmethod - def __getitem__(self, idx: int) -> Any: - pass + def __contains__(self, example_id: str) -> bool: + """Check if the dataset contains the example ID. - @abstractmethod - def __len__(self) -> int: - pass + Args: + example_id: The ID to check for. - @abstractmethod - def __contains__(self, example_id: str) -> bool: - """Check if the dataset contains the example ID.""" + Returns: + True if the ID exists in the dataset. + """ pass @abstractmethod def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: - """Convert an example ID or list of example IDs to the corresponding index or indices.""" + """Convert example ID(s) to index(es). + + Args: + example_id: Single ID or list of IDs to convert. + + Returns: + Corresponding index or list of indices. + """ pass @abstractmethod def idx_to_id(self, idx: int | list[int]) -> str | list[str]: - """Convert an index or list of indices to the corresponding example ID or IDs.""" + """Convert index(es) to example ID(s). + + Args: + idx: Single index or list of indices to convert. + + Returns: + Corresponding ID or list of IDs. + """ pass -class FileDataset(BaseDataset): +class MolecularDataset(Dataset): + """Base class for AtomWorks molecular datasets. + + Handles Transform pipelines and loader functionality for molecular data. + Subclasses implement :meth:`__getitem__` with their own data access patterns. + """ + def __init__( self, - source: PathLike | list[str | PathLike], - filter_fn: Callable[[PathLike], bool] | None = None, - max_depth: int = 3, + *, + name: str, + transform: Callable | None = None, + loader: Callable | None = None, + save_failed_examples_to_dir: str | Path | None = None, ): - """Initialize a FileDataset that loads files from a directory or uses a pre-provided list. + """Initialize MolecularDataset. Args: - source: Either a directory path to scan for files, or a pre-built list of file paths - filter_fn: Optional function that takes a file path and returns True if the file should be included - max_depth: Maximum directory depth to scan (only used when source is a directory path) + name: Descriptive name for this dataset. Used for debugging and some + downstream functions when using nested datasets. + transform: Transform function or pipeline to apply to loaded data. + Should accept the output of the loader and return featurized data. + loader: Optional function to process raw dataset output into Transform-ready + format. For example, parsing structural files or gathering columns + into structured data. + save_failed_examples_to_dir: Optional directory path where failed examples + will be saved for debugging. Includes RNG state and error information. """ - if isinstance(source, str | Path): - # Directory scanning mode - self.dir_path = Path(source) - assert self.dir_path.is_dir(), f"Directory {source} does not exist." - - # Default filter accepts all files - self.filter_fn = filter_fn if filter_fn is not None else lambda x: True + self.loader = loader - # Scan directory for any files below - file_paths = self._scan_directory(max_depth=max_depth) + self.transform = transform + self.name = name + self.save_failed_examples_to_dir = Path(save_failed_examples_to_dir) if save_failed_examples_to_dir else None - elif isinstance(source, list): - # Pre-provided file list mode - self.dir_path = None + def _apply_loader(self, raw_data: Any) -> Any: + """Apply the loader function to raw data with timing and debugging. - # Convert to strings and apply filter if provided - file_paths = [str(path) for path in source] - if filter_fn is not None: - file_paths = [path for path in file_paths if filter_fn(path)] - self.filter_fn = filter_fn + Args: + raw_data: The raw data to process. - else: - raise ValueError("source must be either a directory path (str/Path) or a list of file paths") + Returns: + Processed data ready for transforms. + """ + if self.loader is None: + return raw_data + + # Apply loader function with timing + _start_load_time = time.time() + data = self.loader(raw_data) + _stop_load_time = time.time() + + # Add timing information if data supports it (preserving TransformDataset behavior) + if isinstance(data, dict): + data = TransformedDict(data) + data.__transform_history__.append( + { + "name": "apply loader", + "instance": hex(id(self.loader)), + "start_time": _start_load_time, + "end_time": _stop_load_time, + "processing_time": _stop_load_time - _start_load_time, + } + ) - # Sort paths alphabetically for id<>idx consistency - file_paths.sort() + return data - self.file_paths = file_paths - self.path_to_idx = {path: i for i, path in enumerate(file_paths)} + def _apply_transform(self, data: Any, example_id: str | None = None, idx: int | None = None) -> Any: + """Apply the Transform pipeline with error handling and debugging support. - def _scan_directory(self, max_depth: int) -> list[str]: - """Fast directory scan without worrying about order.""" - file_paths = [] + Args: + data: The loaded data ready for transforms. + example_id: Optional example ID for debugging purposes. If not provided, + will generate one using dataset name and index. + idx: Optional dataset index for error reporting. - for root, dirs, files in os.walk(self.dir_path): - current_depth = len(Path(root).relative_to(self.dir_path).parts) + Returns: + Transformed data. - if current_depth >= max_depth: - dirs.clear() - continue + Raises: + KeyboardInterrupt: Always re-raised if encountered. + Exception: Any exception from the transform pipeline is re-raised. + """ + if self.transform is None: + return data - for file in files: - file_path = os.path.join(root, file) - if self.filter_fn(file_path): - file_paths.append(file_path) + # Generate default example_id from idx and dataset name if not provided + if example_id is None and idx is not None: + example_id = f"{self.name}_{idx}" - return file_paths + # Get process id and hostname for debugging + if example_id: + logger.debug(f"({socket.gethostname()}:{os.getpid()}) Processing example: {example_id}") - def __len__(self) -> int: - return len(self.file_paths) + try: + # Capture RNG state for reproducibility before applying Transforms + rng_state_dict = capture_rng_states(include_cuda=False) + data = self.transform(data) + return data - def __contains__(self, example_id: str) -> bool: - return example_id in self.path_to_idx + except KeyboardInterrupt: + # Always re-raise keyboard interrupts + raise + except Exception as e: + logger.error(e) + + if self.save_failed_examples_to_dir and example_id: + save_failed_example_to_disk( + example_id=example_id, + error_msg=e, + rng_state_dict=rng_state_dict, + data={}, # We do not save the data by default, since it may be large + fail_dir=self.save_failed_examples_to_dir, + ) + + # Re-raise the original exception + raise + + def __getitem__(self, index: int) -> Any: + """Return a fully-featurized data example given an index. + + Subclasses should implement this method to: + 1. Query the underlying data source for raw data at the given index + 2. Optionally pre-process data to prepare for the Transform pipeline + 3. Feed the input dictionary through a Transform pipeline + + Typical output for an activity prediction network: + Step 1: ``{"path": "/path/to/dataset", "class_label": 5}`` + Step 2: ``{"atom_array": AtomArray, "extra_info": {"class_label": 5}}`` + Step 3: ``{"features": torch.Tensor, "class_label": torch.Tensor}`` - def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: - if isinstance(example_id, list): - return [self.path_to_idx[id] for id in example_id] - return self.path_to_idx[example_id] + Args: + index: The index of the example to retrieve. - def idx_to_id(self, idx: int | list[int]) -> str | list[str]: - if isinstance(idx, list): - return [self.file_paths[i] for i in idx] - return self.file_paths[idx] + Returns: + Fully-featurized data example. + """ + raise - def __getitem__(self, idx: int) -> Any: - """Return the file path at the given index. + def __len__(self) -> int: + """Return the number of examples in the dataset. - Subclasses can override this to load and process the file content instead. + Returns: + The dataset length. """ - return self.file_paths[idx] - + pass -class StructuralFileDataset(FileDataset): - """FileDataset with StructuralDatasetWrapper compatibility. - Inherits all functionality from FileDataset but adds: - - .data property that returns a pandas DataFrame for compatibility - - __getitem__ returns pandas Series instead of just file paths - - Optional name attribute for logging/debugging +class FileDataset(MolecularDataset, ExampleIDMixin): + """Dataset that loads molecular data from individual files. - Allows integration with StructuralDatasetWrapper, samplers, and weight calculation. + Each file represents one example in the dataset. If creating a dataset from a + directory, use the :meth:`from_directory` class method instead of the default + constructor. """ def __init__( self, - source: PathLike | list[str | PathLike], + *, + file_paths: list[str | PathLike], + name: str, filter_fn: Callable[[PathLike], bool] | None = None, - max_depth: int = 3, - name: str | None = None, + **kwargs: Any, ): - """ + """Initialize FileDataset. + Args: - source: Either a directory path to scan for files, or a pre-built list of file paths - filter_fn: Optional function that takes a file path and returns True if the file should be included - max_depth: Maximum directory depth to scan (only used when source is a directory path) - name: Optional name for the dataset (useful for logging and debugging) + file_paths: List of file paths for the dataset. Each file represents + one example. + name: Descriptive name for this dataset. Used for debugging and some + downstream functions when using nested datasets. + filter_fn: Optional function to filter file paths. Should return True + for files to include. + **kwargs: Additional arguments passed to :class:`MolecularDataset`. + + Examples: + Create from explicit file list: + >>> files = ["/path/to/file1.cif", "/path/to/file2.cif"] + >>> dataset = FileDataset(file_paths=files, name="my_dataset") """ - super().__init__(source, filter_fn, max_depth) - self.name = name if name is not None else f"StructuralFileDataset({source})" + super().__init__(name=name, **kwargs) - assert len(self.file_paths) == len(set(self.file_paths)), "File paths must be unique." + self.filter_fn = filter_fn or (lambda x: True) - @cached_property - def data(self) -> pd.DataFrame: - """Return a pandas DataFrame with file paths and generated example IDs. + # Convert to Path objects and filter + file_paths = [Path(path) for path in file_paths if self.filter_fn(path)] + if not file_paths: + raise ValueError("No files found after applying filters") + if len(file_paths) != len(set(file_paths)): + raise ValueError("File paths must be unique") - This property makes StructuralFileDataset compatible with StructuralDatasetWrapper - and other components that expect a .data attribute. - """ - # Generate example IDs from file paths (use filename without extension) - example_ids = [] - for file_path in self.file_paths: - filename = Path(file_path).stem # filename without extension - # If filename has multiple extensions (e.g., .cif.gz), remove them all - while "." in filename: - filename = Path(filename).stem - example_ids.append(filename) - - # Create DataFrame with path and example_id columns - df = pd.DataFrame( - { - "path": self.file_paths, - "example_id": example_ids, - } - ) + # Sort for consistent id<>idx mapping + file_paths.sort() + self.file_paths = file_paths - # Set example_id as index for fast lookups - df.set_index("example_id", inplace=True, drop=False, verify_integrity=True) # No duplicates allowed + # Create ID mapping + self.id_to_idx_map = {self._get_example_id(i): i for i, _ in enumerate(file_paths)} - return df + @classmethod + def from_directory( + cls, + *, + directory: PathLike, + name: str, + max_depth: int = 3, + **kwargs: Any, + ) -> "FileDataset": + """Create a FileDataset by scanning a directory for files. - def __getitem__(self, idx: int) -> Any: - return self.data.iloc[idx] + Args: + directory: Path to directory to scan for files. + name: Descriptive name for this dataset. + max_depth: Maximum depth to scan for files in subdirectories. + **kwargs: Additional arguments passed to :class:`FileDataset`. + Returns: + FileDataset instance with files discovered from the directory. -class StructuralDatasetWrapper(BaseDataset): - def __init__( - self, - dataset: Dataset, - dataset_parser: MetadataRowParser, - cif_parser_args: dict | None = None, - transform: Transform | Compose | None = None, - return_key: str | None = None, - save_failed_examples_to_dir: PathLike | str | None = None, - ): + Example: + Create from directory: + >>> dataset = FileDataset.from_directory(directory="/path/to/files", name="my_dataset", max_depth=2) """ - Decorator (wrapper) for an arbitrary Dataset (e.g., PandasDataset, PolarsDataset, etc.) to handle loading of structural data from PDB or CIF files, - parsing, and applying a Transformation pipeline to the data. - - Designed to be used with a Transforms pipeline to process the data and a MetadataRowParser to convert the dataset rows into a common dictionary format. + dir_path = Path(directory) + if not dir_path.exists(): + raise FileNotFoundError(f"Directory {directory} does not exist.") + if not dir_path.is_dir(): + raise ValueError(f"Path {directory} is not a directory.") + + file_paths = scan_directory(dir_path=dir_path, max_depth=max_depth) + return cls(file_paths=file_paths, name=name, **kwargs) + + @classmethod + def from_file_list( + cls, + *, + file_paths: list[str | PathLike], + name: str, + **kwargs: Any, + ) -> "FileDataset": + """Create a FileDataset from an explicit list of file paths. - For more detail, see the README in the `datasets` directory. + This is an alias for the main constructor for clarity and consistency + with :meth:`from_directory`. Args: - dataset (Dataset): The dataset to wrap. For example, a PandasDataset, PolarsDataset, or standard PyTorch Dataset. - dataset_parser (MetadataRowParser): Parser to convert dataset metadata rows into a common dictionary format. See `atomworks.ml.datasets.dataframe_parsers`. - cif_parser_args (dict, optional): Arguments to pass to `atomworks.io.parse` (will override the defaults). Defaults to None. - transform (Transform | Compose, optional): Transformation pipeline to apply to the data. See `atomworks.ml.transforms.base`. - return_key (str, optional): Key to return from the data dictionary instead of the entire dictionary. - save_failed_examples_to_dir (PathLike | str | None, optional): Directory to save failed examples. - - Example usage: - ```python - dataset = StructuralDatasetDecorator(dataset=PandasDataset(data="path/to/data.csv"), ...) - dataset[0] # Returns the processed data for the first example. - ``` + file_paths: List of file paths for the dataset. Each file represents one example. + name: Descriptive name for this dataset. + **kwargs: Additional arguments passed to :class:`FileDataset`. + + Returns: + FileDataset instance with the provided file paths. """ - # ...basic assignments - self.transform = transform - self.return_key = return_key - self.save_failed_examples_to_dir = ( - Path(save_failed_examples_to_dir) if exists(save_failed_examples_to_dir) else None - ) - self.cif_parser_args = cif_parser_args - self.dataset_parser = dataset_parser - self.dataset = dataset + return cls(file_paths=file_paths, name=name, **kwargs) + + def __len__(self) -> int: + """Return the number of files in the dataset.""" + return len(self.file_paths) - # ...carry forward the data - self.data = self.dataset.data + def __contains__(self, example_id: str) -> bool: + """Check if the dataset contains the example ID.""" + return example_id in self.id_to_idx_map - # ...carry forward the name - self.name = self.dataset.name if hasattr(self.dataset, "name") else repr(self.dataset) + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: + """Convert example ID(s) to index(es).""" + if isinstance(example_id, list): + return [self.id_to_idx_map[id] for id in example_id] + return self.id_to_idx_map[example_id] + + def idx_to_id(self, idx: int | list[int]) -> str | list[str]: + """Convert index(es) to example ID(s).""" + if isinstance(idx, list): + return [self._get_example_id(i) for i in idx] + return self._get_example_id(idx) def __getitem__(self, idx: int) -> Any: - """ - Performs the following steps: - (1) Retrieve the row at the specified index from the dataset using the __getitem__ method. - (2) Parse the row into a common dictionary format using the dataset parser. - (3) Load the CIF file from the information in the common dictionary format (i.e., the "path" key). - (4) Apply the transformation pipeline to the data which, at a minimum, contains the output of `atomworks.io` parsing. + """Load and transform an example by file index. Args: - idx (int): The index of the item to retrieve. + idx: The index of the file to load. Returns: - Any: The processed item. + Transformed data from the file. """ + file_path = str(self.file_paths[idx]) + example_id = self._get_example_id(idx) + data = self._apply_loader(file_path) + return self._apply_transform(data, example_id=example_id, idx=idx) - # Capture example ID & current rng state (for reproducibility & debugging) - if hasattr(self, "idx_to_id"): - # ...if the dataset has a custom idx_to_id method, use it (e.g., for a PandasDataset) - example_id = self.idx_to_id(idx) - else: - # ...otherwise, fallback to a the `id_column` or a string representation of the index - example_id = self.dataset[idx][self.id_column] if self.id_column else f"row_{idx}" - - # Get process id and hostname (for debugging) - logger.debug(f"({socket.gethostname()}:{os.getpid()}) Processing example ID: {example_id}") - - # Load the row, using the __getitem__ method of the dataset - row = self.dataset[idx] - - # Process the row into a transform-ready dictionary with the given CIF and dataset parsers - # We require the "data" dictionary output from `load_example_from_metadata_row` to contain, at a minimum: - # (a) An "id" key, which uniquely identifies the example within the dataframe; and, - # (b) The "path" key, which is the path to the CIF file - _start_parse_time = time.time() - data = load_example_from_metadata_row(row, self.dataset_parser, cif_parser_args=self.cif_parser_args) - _stop_parse_time = time.time() - - # Manually add timing for cif-parsing - data = TransformedDict(data) - data.__transform_history__.append( - { - "name": "load_example_from_metadata_row", - "instance": hex(id(load_example_from_metadata_row)), - "start_time": _start_parse_time, - "end_time": _stop_parse_time, - "processing_time": _stop_parse_time - _start_parse_time, - } - ) + def _get_example_id(self, idx: int) -> str: + """Get example ID from index - returns filename stem without extensions. - # Apply the transformation pipeline to the data - if exists(self.transform): - try: - rng_state_dict = capture_rng_states(include_cuda=False) - data = self.transform(data) - except KeyboardInterrupt as e: - raise e - except Exception as e: - # Log the error and save the failed example to disk (optional) - logger.info(f"Error processing row {idx} ({example_id}): {e}") - - if exists(self.save_failed_examples_to_dir): - save_failed_example_to_disk( - example_id=example_id, - error_msg=e, - rng_state_dict=rng_state_dict, - data={}, # We do not save the data, since it may be large. - fail_dir=self.save_failed_examples_to_dir, - ) - raise e - - # Return the specified key or the entire data dict (i.e., only "feats" key from the Transform dictionary) - if exists(self.return_key): - return data[self.return_key] - else: - return data - - def __len__(self) -> int: - """Pass through the length of the wrapped dataset.""" - return len(self.dataset) - - def __contains__(self, example_id: str) -> bool: - """Pass through the contains method of the wrapped dataset.""" - return example_id in self.dataset - - def id_to_idx(self, example_id: str) -> int: - """Pass through the id_to_idx method of the wrapped dataset.""" - return self.dataset.id_to_idx(example_id) - - def idx_to_id(self, idx: int) -> str: - """Pass through the idx_to_id method of the wrapped dataset.""" - return self.dataset.idx_to_id(idx) - - def __getattr__(self, name: str) -> Any: - """Delegate attribute access to the wrapped dataset.""" - try: - # `object.__getattribute__(self, "dataset")` bypasses the custom `__getattr__` and safely retrieves the attribute, - # avoiding infinite recursion. - dataset = object.__getattribute__(self, "dataset") - return getattr(dataset, name) - except AttributeError: - raise AttributeError(f"'{type(self).__name__}' object (or its wrapped dataset) has no attribute '{name}'") # noqa: B904 + Args: + idx: The index of the file. + Returns: + Filename stem without any extensions. + """ + file_path = self.file_paths[idx] + filename = Path(file_path).stem + # If filename has multiple extensions (e.g., .cif.gz), remove them all + while "." in filename: + filename = Path(filename).stem + return filename -class PandasDataset(BaseDataset): - """ - A wrapper around PyTorch's Dataset class that allows for easy loading, filtering, and indexing of datasets stored as Pandas DataFrames. - The underlying DataFrame can be accessed via the `data` property. - For example usage, see the tests in `tests/datasets/test_datasets.py`. +class PandasDataset(MolecularDataset, ExampleIDMixin): + """Dataset for tabular data stored as pandas DataFrames. - Args: - data (pd.DataFrame | PathLike): The dataset, either as a Pandas DataFrame or a path to a file. - id_column (str | None, optional): The column to use as the index; must be unique within the DataFrame. Defaults to None. - For example, we use the `example_id` column as the index in the `PDBDataset`. By setting the dataframe index to the `example_id` - column, we can retrieve the row corresponding to a specific example ID by calling `dataset.data.loc[example_id]` in O(1) time. - filters (list[str] | None, optional): A list of query strings to filter the data. Defaults to None. For examples on how to specify filters, - see the docstring for `_apply_filters`. - name (str | None, optional): The name of the dataset. Defaults to None. Useful for debugging and logging. - columns_to_load (list[str] | None, optional): Specific columns to load if data is provided as a file path. Defaults to None. Helpful for - large datasets where only a subset of columns is needed (if using `parquet` or other columnar storage formats). - **load_kwargs (Any): Additional keyword arguments for loading the data. - - Attributes: - data (pd.DataFrame): The underlying DataFrame, accessible via the `data` property. + Inherits all functionality from :class:`MolecularDataset` with additional + DataFrame-specific features for filtering and ID-based access. """ def __init__( self, *, data: pd.DataFrame | PathLike, + name: str, id_column: str | None = None, filters: list[str] | None = None, - name: str | None = None, columns_to_load: list[str] | None = None, - **load_kwargs: Any, + # MolecularDataset parameters + transform: Callable | None = None, + loader: Callable | None = None, + save_failed_examples_to_dir: str | Path | None = None, + load_kwargs: dict | tuple | None = None, ): - if name is not None: - self.name = name - else: - self.name = repr(self) + """Initialize PandasDataset. - # Load the data from the path, if provided (and load only the specified columns) + Args: + data: Either a pandas DataFrame or path to a CSV/Parquet file containing + the tabular data. Each row represents one example. + name: Descriptive name for this dataset. Used for debugging and some + downstream functions when using nested datasets. + id_column: Optional column name to use as the DataFrame index for + example ID lookups. If provided, this column will be set as the index. + filters: Optional list of pandas query strings to filter the data. + Applied in order during initialization. + columns_to_load: Optional list of column names to load when reading + from a file. If None, all columns are loaded. Can dramatically reduce + memory usage and load time if loading from a columnar format like Parquet. + transform: Transform pipeline to apply to loaded data. + loader: Optional function to process raw DataFrame rows into Transform-ready format. + save_failed_examples_to_dir: Optional directory path where failed examples + will be saved for debugging. Includes RNG state and error information. + load_kwargs: Additional keyword arguments passed to pandas' read functions + (read_csv, read_parquet) when loading from file. + + Examples: + Load from DataFrame: + >>> df = pd.DataFrame({"path": [...], "label": [...]}) + >>> dataset = PandasDataset(data=df, name="my_dataset") + + Load from file with filtering: + >>> dataset = PandasDataset(data="data.csv", name="filtered_dataset", filters=["label > 0", "path.str.contains('.pdb')"]) + """ + super().__init__( + name=name, + transform=transform, + loader=loader, + save_failed_examples_to_dir=save_failed_examples_to_dir, + ) + + # Load data from path if needed if isinstance(data, PathLike | str): - data = self._load_from_path(data, columns_to_load, **load_kwargs) - self._data = data + data = self._load_from_path(data, columns_to_load, **(load_kwargs or {})) + self.data = data - # Apply filters, if provided + # Apply filters self.filters = filters self._already_filtered = False - if exists(filters): + if filters: self._apply_filters(filters) self._already_filtered = True + # Set index column if specified if id_column is not None: - assert id_column in self._data.columns, f"Column {id_column} not found in dataset." - self._data.set_index(id_column, inplace=True, drop=False, verify_integrity=True) + assert id_column in self.data.columns, f"Column {id_column} not found in dataset." + self.data.set_index(id_column, inplace=True, drop=False, verify_integrity=True) def _load_from_path( self, path: PathLike | str, columns_to_load: list[str] | None = None, **load_kwargs: Any ) -> pd.DataFrame: + """Load data from file path. + + Args: + path: Path to the file to load. + columns_to_load: Optional list of column names to load. + **load_kwargs: Additional arguments for pandas read functions. + + Returns: + Loaded DataFrame. + + Raises: + ValueError: If file type is unsupported. + """ path = Path(path) if path.suffix == ".csv": data = pd.read_csv(path, usecols=columns_to_load, keep_default_na=False, na_values=NA_VALUES, **load_kwargs) @@ -424,27 +482,36 @@ def _load_from_path( raise ValueError(f"Unsupported file type: {path.suffix}") return data - @property - def data(self) -> pd.DataFrame: - """Expose underlying dataframe as property to discourage changing it (can lead to unexpected behavior with torch ConcatDatasets).""" - return self._data - def __getitem__(self, idx: int) -> Any: - return self._data.iloc[idx] + """Get an example by index, applying specified loader and Transforms. + + Args: + idx: The index of the example to retrieve. + + Returns: + Transformed data from the row. + """ + raw_data = self.data.iloc[idx] + example_id = self._get_example_id(idx) + data = self._apply_loader(raw_data) + return self._apply_transform(data, example_id=example_id, idx=idx) def __len__(self) -> int: - return len(self._data) + """Return the number of rows in the dataset.""" + return len(self.data) def __contains__(self, example_id: str) -> bool: """Check if the dataset contains the example ID.""" - return example_id in self._data.index + return example_id in self.data.index def _id_to_index_single(self, example_id: str) -> int: - return self._data.index.get_loc(example_id) + """Convert single example ID to index.""" + return self.data.index.get_loc(example_id) def _id_to_index_multiple(self, example_ids: list[str]) -> list[int]: - idxs = np.arange(len(self._data)) - return [idxs[self._data.index.get_loc(example_id)] for example_id in example_ids] + """Convert multiple example IDs to indices.""" + idxs = np.arange(len(self.data)) + return [idxs[self.data.index.get_loc(example_id)] for example_id in example_ids] def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: """Convert an example ID to the corresponding local index.""" @@ -462,25 +529,22 @@ def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: _return_single = True idx = idx.item() if isinstance(idx, np.ndarray) else idx idx = slice(idx, idx + 1) - ids = self._data.iloc[idx].index.values + ids = self.data.iloc[idx].index.values return ids[0] if _return_single else ids def _apply_filters(self, filters: list[str]) -> pd.DataFrame: - """ - Apply filters to the data based on the provided list of query strings. + """Apply filters to the data based on the provided list of query strings. + For documentation on pandas query syntax, see: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.query.html Args: - filters (List[str]): List of query strings to apply to the data. + filters: List of query strings to apply to the data. Raises: ValueError: If the data is not initialized or if a query removes all rows. Warning: If a query does not remove any rows. - Exampleelse: - logger.info( - f"Query '{query}' filtered dataset from {original_num_rows:,} to {filtered_num_rows:,} rows (dropped {original_num_rows - filtered_num_rows:,} rows)" - ): + Examples: queries = [ "deposition_date < '2020-01-01'", "resolution < 2.5 and ~method.str.contains('NMR')", @@ -495,30 +559,27 @@ def _apply_filters(self, filters: list[str]) -> pd.DataFrame: self._apply_query(query) def _apply_query(self, query: str) -> None: - """ - Apply a single query to the data. + """Apply a single query to the data. Args: - query (str): A query string to apply to the data. + query: The pandas query string to apply. """ # Filter using query and validate impact - original_num_rows = len(self._data) - self._data = self._data.query(query) - filtered_num_rows = len(self._data) + original_num_rows = len(self.data) + self.data = self.data.query(query) + filtered_num_rows = len(self.data) self._validate_filter_impact(query, original_num_rows, filtered_num_rows) def _validate_filter_impact(self, query: str, original_num_rows: int, filtered_num_rows: int) -> None: - """ - Validate the impact of the filter. + """Validate the impact of the filter. Args: - query (str): The query string that was applied. - original_num_rows (int): The number of rows before applying the filter. - filtered_num_rows (int): The number of rows after applying the filter. + query: The query that was applied. + original_num_rows: Number of rows before filtering. + filtered_num_rows: Number of rows after filtering. Raises: - Warning: If the filter did not remove any rows. - ValueError: If the filter removed all rows. + ValueError: If the query removes all rows. """ rows_removed = original_num_rows - filtered_num_rows percent_removed = (rows_removed / original_num_rows) * 100 @@ -538,50 +599,97 @@ def _validate_filter_impact(self, query: str, original_num_rows: int, filtered_n f"+-------------------------------------------+\n" ) + def _get_example_id(self, idx: int) -> str: + """Get example ID from index - returns the index value from the DataFrame. + + Args: + idx: The index of the row. + + Returns: + The index value as a string. + """ + return str(self.data.iloc[idx].name) # .name gets the index value + class ConcatDatasetWithID(ConcatDataset): - """Equivalent to `torch.utils.data.ConcatDataset` but allows accessing examples by ID.""" + """Equivalent to :class:`torch.utils.data.ConcatDataset` but allows accessing examples by ID. + + Provides ID-based access across multiple datasets that implement :class:`ExampleIDMixin`. + """ + + # TODO: Do I need all of these _raise_if etc. etc. here? Can I just check that the wrapped datasets inherit somehow from ExampleIDMixin? + + datasets: list[ExampleIDMixin] - datasets: list[Dataset] + def __init__(self, datasets: list[ExampleIDMixin]): + """Initialize ConcatDatasetWithID. - def __init__(self, datasets: list[Dataset]): + Args: + datasets: List of datasets that implement ExampleIDMixin. + """ super().__init__(datasets) - # Print the length of each dataset + # Log the length of each dataset for i, dataset in enumerate(datasets): logger.info(f"Dataset {i} ({type(dataset)}): {len(dataset):,} examples") @cached_property def _can_convert_ids_and_idx(self) -> bool: + """Check if all sub-datasets can convert between IDs and indices.""" has_id_to_idx = all(hasattr(sub_dataset, "id_to_idx") for sub_dataset in self.datasets) has_idx_to_id = all(hasattr(sub_dataset, "idx_to_id") for sub_dataset in self.datasets) return has_id_to_idx and has_idx_to_id and self._can_check_contains @cached_property def _can_check_contains(self) -> bool: + """Check if all sub-datasets support contains operations.""" return all(hasattr(sub_dataset, "__contains__") for sub_dataset in self.datasets) def _raise_if_cannot_check_contains(self) -> None: + """Raise error if dataset cannot check contains.""" if not self._can_check_contains: raise ValueError("This dataset cannot check if it contains an example ID.") def _raise_if_cannot_convert_ids_and_idx(self) -> None: + """Raise error if dataset cannot convert IDs and indices.""" if not self._can_convert_ids_and_idx: raise ValueError("This dataset cannot convert example IDs to indices.") def _raise_if_idx_out_of_bounds(self, idx: int) -> None: + """Raise error if index is out of bounds. + + Args: + idx: The index to check. + """ if idx < 0 or idx >= len(self): raise ValueError(f"Index {idx} out of bounds for dataset of length {len(self)}.") def __contains__(self, example_id: str) -> bool: - """Check if the dataset contains the example ID.""" + """Check if the dataset contains the example ID. + + Args: + example_id: The ID to check for. + + Returns: + True if the ID exists in any sub-dataset. + """ self._raise_if_cannot_check_contains() return any(example_id in sub_dataset for sub_dataset in self.datasets) def id_to_idx(self, example_id: str) -> int: """Retrieves the index corresponding to the example ID. - WARNING: Assumes that the example ID is unique within the dataset. If not, + Args: + example_id: The ID to convert. + + Returns: + The corresponding index. + + Raises: + ValueError: If the example ID is not found. + + Warning: + Assumes that the example ID is unique within the dataset. If not, the first occurrence of the example ID is returned. """ # TODO: Generalize to list[str] @@ -594,7 +702,17 @@ def id_to_idx(self, example_id: str) -> int: raise ValueError(f"Example ID {example_id} not found in any sub-dataset.") def idx_to_id(self, idx: int) -> str: - """Retrieves the example ID corresponding to the index.""" + """Retrieves the example ID corresponding to the index. + + Args: + idx: The index to convert. + + Returns: + The corresponding example ID. + + Raises: + ValueError: If the index is out of bounds. + """ # TODO: Generalize to list[int] self._raise_if_cannot_convert_ids_and_idx() self._raise_if_idx_out_of_bounds(idx) @@ -606,7 +724,17 @@ def idx_to_id(self, idx: int) -> str: raise ValueError(f"Index {idx} out of bounds for any sub-dataset.") def get_dataset_by_idx(self, idx: int) -> Dataset: - """Retrieves the dataset containing the index.""" + """Retrieves the dataset containing the index. + + Args: + idx: The index to find. + + Returns: + The sub-dataset containing the index. + + Raises: + ValueError: If the index is out of bounds. + """ self._raise_if_idx_out_of_bounds(idx) for sub_dataset in self.datasets: if idx < len(sub_dataset): @@ -618,24 +746,30 @@ def get_dataset_by_idx(self, idx: int) -> Dataset: def get_dataset_by_id(self, example_id: str) -> Dataset: """Retrieves the dataset containing the example ID. - WARNING: Assumes that the example ID is unique within the dataset. If not, + Args: + example_id: The ID to find. + + Returns: + The sub-dataset containing the ID. + + Warning: + Assumes that the example ID is unique within the dataset. If not, the first occurrence of the example ID is returned. """ idx = self.id_to_idx(example_id) return self.get_dataset_by_idx(idx) -def get_row_and_index_by_example_id(dataset: ConcatDatasetWithID, example_id: str) -> dict: - """ - Retrieve a row and its index from a nested dataset structure by its example ID. +def get_row_and_index_by_example_id(dataset: ExampleIDMixin, example_id: str) -> dict: + """Retrieve a row and its index from a nested dataset structure by its example ID. - Parameters: - dataset (PandasDataset | ConcatDataset): The dataset or concatenated dataset to search. + Args: + dataset: The dataset or concatenated dataset to search. Must have the `id_to_idx` method. - example_id (str): The example ID to search for. + example_id: The example ID to search for. Returns: - tuple: A tuple containing the row (pd.Series) and the (global)index (int) corresponding to the example ID. + Dictionary containing the row and global index corresponding to the example ID. """ assert hasattr(dataset, "id_to_idx"), "Dataset must have the `id_to_idx` method." idx = dataset.id_to_idx(example_id) @@ -650,28 +784,25 @@ def get_row_and_index_by_example_id(dataset: ConcatDatasetWithID, example_id: st class FallbackDatasetWrapper(Dataset): - """ - A wrapper around a dataset that allows for a fallback dataset to be used when an error occurs. + """A wrapper around a dataset that allows for a fallback dataset to be used when an error occurs. Meant to be used with a FallbackSamplerWrapper. """ def __init__(self, dataset: Dataset, fallback_dataset: Dataset): - """ - FallbackDatasetWrapper is a wrapper around a dataset that provides a fallback mechanism - to another dataset in case of errors during data retrieval. + """Initialize FallbackDatasetWrapper. - Attributes: - - dataset (Dataset): The primary dataset to retrieve data from. - - fallback_dataset (Dataset): The fallback dataset to use when an error occurs. This + Args: + dataset: The primary dataset to retrieve data from. + fallback_dataset: The fallback dataset to use when an error occurs. This may be the same as the primary dataset, or a different one. """ self.dataset = dataset self.fallback_dataset = fallback_dataset def __getitem__(self, idxs: tuple[int, ...]) -> Any: - """ - Attempt to retrieve an item from the primary dataset, falling back to additional indices if errors occur. + """Attempt to retrieve an item from the primary dataset, falling back to additional indices if errors occur. + If all attempts fail, raises a RuntimeError containing all encountered exceptions. Args: @@ -717,4 +848,61 @@ def __getitem__(self, idxs: tuple[int, ...]) -> Any: ) def __len__(self): + """Return the length of the primary dataset.""" return len(self.dataset) + + +# Backwards Compatibility +# TODO: Deprecate +def StructuralDatasetWrapper( # noqa: N802 + dataset_parser: Callable, + transform: Callable | None = None, + dataset: PandasDataset | None = None, + cif_parser_args: dict | None = None, + save_failed_examples_to_dir: str | Path | None = None, + **kwargs, +) -> PandasDataset: + """Backwards-compatible wrapper for the deprecated StructuralDatasetWrapper. + + This function is deprecated and will be removed in a future version. + Use :class:`PandasDataset` with the appropriate loader function instead. + + Args: + dataset_parser: The dataset parser to use (e.g., PNUnitsDFParser, InterfacesDFParser). + transform: Transform pipeline to apply to loaded data. + dataset: The underlying PandasDataset containing the tabular data. + cif_parser_args: Arguments to pass to the CIF parser. + save_failed_examples_to_dir: Directory to save failed examples for debugging. + **kwargs: Additional arguments passed to PandasDataset. + + Returns: + PandasDataset instance configured with the deprecated parameters. + + Raises: + ValueError: If dataset parameter is required but not provided. + """ + from atomworks.ml.datasets.parsers import load_example_from_metadata_row + + warnings.warn( + "StructuralDatasetWrapper is deprecated. Use PandasDataset with a loader function instead. " + "See atomworks.ml.datasets.loaders for functional alternatives to dataset parsers.", + DeprecationWarning, + stacklevel=2, + ) + + if dataset is None: + raise ValueError("dataset parameter is required for StructuralDatasetWrapper") + + # Create loader from deprecated parameters + def loader(row: pd.Series) -> dict[str, Any]: + return load_example_from_metadata_row(row, dataset_parser, cif_parser_args=cif_parser_args or {}) + + # Create a new PandasDataset with the loader + return PandasDataset( + data=dataset.data, + name=dataset.name if hasattr(dataset, "name") else "structural_dataset", + transform=transform, + loader=loader, + save_failed_examples_to_dir=save_failed_examples_to_dir, + **kwargs, + ) diff --git a/src/atomworks/ml/datasets/loaders.py b/src/atomworks/ml/datasets/loaders.py new file mode 100644 index 00000000..7d1aa10c --- /dev/null +++ b/src/atomworks/ml/datasets/loaders.py @@ -0,0 +1,262 @@ +"""Functional loader implementations for AtomWorks datasets. + +Loaders are functions that process raw dataset output (e.g., pandas Series) into a Transform-ready format. +E.g., converts what may be dataset-specific metadata into a standard format for use in AtomWorks Transform pipelines. +""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pandas as pd +from toolz import keyfilter + +from atomworks.io.parser import parse +from atomworks.ml.utils.io import apply_sharding_pattern + + +def _build_metadata_hierarchy(row: pd.Series, attrs: dict | None = None) -> dict[str, Any]: + """Build up metadata dictionary with precedence hierarchy. + + Assembles metadata from multiple sources with the following precedence (lowest to highest priority): + 1. DataFrame-level attributes (row.attrs) + 2. Row-level data (row.to_dict()) + 3. Loader-specific attributes (attrs parameter) + + Args: + row: pandas Series representing one dataset example + attrs: Optional loader-specific attributes to merge with highest precedence + + Returns: + Dictionary containing merged metadata with proper hierarchy + """ + # Start with DataFrame-level attributes (lowest precedence) + extra_info = row.attrs.copy() if hasattr(row, "attrs") else {} + + # Add row-level data (middle precedence) + extra_info.update(row.to_dict()) + + # Add loader-specific attributes (highest precedence) + extra_info.update(attrs or {}) + + return extra_info + + +def _build_structure_path( + path: str, base_path: str | None, extension: str | None, sharding_pattern: str | None = None +) -> Path: + """Construct file path with optional base_path, extension, and sharding pattern. + + Args: + path: The base path or identifier (e.g., PDB ID) + base_path: Base directory to prepend + extension: File extension to add/replace + sharding_pattern: Pattern for organizing files in subdirectories + - "/1:2/": Use characters 1-2 for first directory level + - "/1:2/0:2/": Use chars 1-2 for first dir, then chars 0-2 for second dir + - None: No sharding (default) + """ + sharded_path = apply_sharding_pattern(path, sharding_pattern) + + if base_path: + final_path = Path(base_path) / sharded_path + else: + final_path = sharded_path + + if extension: + final_path = final_path.with_suffix(extension) + + return final_path + + +def _load_structure_from_path(path: Path, assembly_id: str, parser_args: dict | None = None) -> dict[str, Any]: + """Load structure from file path using the CIF parser.""" + result_dict = parse( + filename=path, + build_assembly=(assembly_id,), + **(parser_args or {}), + ) + return result_dict + + +def loader_base( + example_id_colname: str = "example_id", + path_colname: str = "path", + assembly_id_colname: str | None = "assembly_id", + attrs: dict | None = None, + base_path: str = "", + extension: str = "", + sharding_pattern: str | None = None, + parser_args: dict | None = None, +) -> Callable[[pd.Series], dict[str, Any]]: + """Base loader with common logic for many AtomWorks datasets. + + Args: + example_id_colname: Name of column containing unique example identifiers + path_colname: Name of column containing paths to structure files + assembly_id_colname: Optional column name containing assembly IDs. + If None, assembly_id defaults to "1" for all examples. + attrs: Additional attributes to merge with highest precedence into the metadata hierarchy + (and ultimately included in the output dictionary's "extra_info" key). + base_path: Base path to prepend to file paths if not included in path column + extension: File extension to add/replace if not included in path column + sharding_pattern: Pattern for how files are organized in subdirectories, if not specified in the path + - "/1:2/": Use characters 1-2 for first directory level + - "/1:2/0:2/": Use chars 1-2 for first dir, then chars 0-2 for second dir + - None: No sharding (default) + parser_args: Optional dictionary of arguments to pass to the CIF parser when loading the structure file. + """ + + def loader_function(row: pd.Series) -> dict[str, Any]: + # Prepare loader-specific attributes + loader_attrs = attrs.copy() if attrs else {} + if base_path and "base_path" not in loader_attrs: + loader_attrs["base_path"] = base_path + if extension and "extension" not in loader_attrs: + loader_attrs["extension"] = extension + + extra_info = _build_metadata_hierarchy(row, loader_attrs) + + assembly_id = ( + row[assembly_id_colname] if assembly_id_colname is not None and assembly_id_colname in row else "1" + ) + path = _build_structure_path( + row[path_colname], extra_info.get("base_path"), extra_info.get("extension"), sharding_pattern + ) + result_dict = _load_structure_from_path(path, assembly_id, parser_args) + + # Remove used columns from extra_info to avoid duplication in the output dictionary + exclude_cols = ( + [example_id_colname, path_colname] + + ([assembly_id_colname] if assembly_id_colname else []) + + ["base_path", "extension"] + ) + extra_info = keyfilter(lambda k: k not in exclude_cols, extra_info) + + return { + # ... from the row and metadata hierarchy + "example_id": row[example_id_colname], + "path": path, + "assembly_id": assembly_id, + "extra_info": extra_info, + # ... from the CIF parser + "atom_array": result_dict["assemblies"][assembly_id][0], # First model + "atom_array_stack": result_dict["assemblies"][assembly_id], # All models + "chain_info": result_dict["chain_info"], + "ligand_info": result_dict["ligand_info"], + "metadata": result_dict["metadata"], + } + + return loader_function + + +def loader_with_query_pn_units( + example_id_colname: str = "example_id", + path_colname: str = "path", + pn_unit_iid_colnames: str | list[str] | None = None, + assembly_id_colname: str | None = "assembly_id", + base_path: str = "", + extension: str = "", + sharding_pattern: str | None = None, + attrs: dict | None = None, + parser_args: dict | None = None, +) -> Callable[[pd.Series], dict[str, Any]]: + """Factory function that creates a generic loader for pipelines with query pn_units (chains). + + For instance, in the interfaces dataset, each sampled row contains two pn_unit instance IDs + that should be included in the cropped structure. + + Examples: + Interfaces dataset: + >>> loader = loader_with_query_pn_units( + ... pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], assembly_id_colname="assembly_id" + ... ) + + Chains dataset: + >>> loader = loader_with_query_pn_units( + ... pn_unit_iid_colnames="pn_unit_iid", base_path="/data/structures", extension=".cif.gz" + ... ) + """ + # Normalize pn_unit_iid_colnames to list format + if isinstance(pn_unit_iid_colnames, str): + pn_unit_iid_colnames = [pn_unit_iid_colnames] + pn_unit_iid_colnames = pn_unit_iid_colnames or [] + + # Create base loader with common parameters + base_loader = loader_base( + example_id_colname=example_id_colname, + path_colname=path_colname, + assembly_id_colname=assembly_id_colname, + attrs=attrs, + base_path=base_path, + extension=extension, + sharding_pattern=sharding_pattern, + parser_args=parser_args, + ) + + def loader_function(row: pd.Series) -> dict[str, Any]: + # Get base loader dictionary with common functionality + result = base_loader(row) + result["extra_info"] = keyfilter(lambda k: k not in pn_unit_iid_colnames, result["extra_info"]) + + # Add query-specific fields + query_pn_unit_iids = [row[colname] for colname in pn_unit_iid_colnames] + result["query_pn_unit_iids"] = query_pn_unit_iids + + return result + + return loader_function + + +def loader_with_interfaces_and_pn_units_to_score( + example_id_colname: str = "example_id", + path_colname: str = "path", + assembly_id_colname: str | None = "assembly_id", + interfaces_to_score_colname: str | None = "interfaces_to_score", + pn_units_to_score_colname: str | None = "pn_units_to_score", + base_path: str = "", + extension: str = "", + sharding_pattern: str | None = None, + attrs: dict | None = None, + parser_args: dict | None = None, +) -> Callable[[pd.Series], dict[str, Any]]: + """Loader that additionally adds interfaces and pn_units to score for validation datasets to the ground truth key. + + Example: + >>> loader = loader_with_interfaces_and_pn_units_to_score( + ... interfaces_to_score_colname="interfaces_to_score", pn_units_to_score_colname="pn_units_to_score" + ... ) + """ + # Create base loader with common parameters + base_loader = loader_base( + example_id_colname=example_id_colname, + path_colname=path_colname, + assembly_id_colname=assembly_id_colname, + attrs=attrs, + base_path=base_path, + extension=extension, + sharding_pattern=sharding_pattern, + parser_args=parser_args, + ) + + def loader_function(row: pd.Series) -> dict[str, Any]: + # Get base loader dictionary with common functionality + result = base_loader(row) + result["extra_info"] = keyfilter( + lambda k: k not in [interfaces_to_score_colname, pn_units_to_score_colname], result["extra_info"] + ) + + # Add validation-specific fields + interfaces_to_score = row[interfaces_to_score_colname] if interfaces_to_score_colname is not None else None + pn_units_to_score = row[pn_units_to_score_colname] if pn_units_to_score_colname is not None else None + + result.update( + { + "interfaces_to_score": interfaces_to_score, + "pn_units_to_score": pn_units_to_score, + } + ) + + return result + + return loader_function diff --git a/src/atomworks/ml/datasets/parsers/base.py b/src/atomworks/ml/datasets/parsers/base.py index 2125463f..613bc32d 100644 --- a/src/atomworks/ml/datasets/parsers/base.py +++ b/src/atomworks/ml/datasets/parsers/base.py @@ -7,16 +7,18 @@ from atomworks.constants import CRYSTALLIZATION_AIDS from atomworks.io import parse -DEFAULT_CIF_PARSER_ARGS = { +DEFAULT_PARSER_ARGS = { "add_missing_atoms": True, "add_id_and_entity_annotations": True, "add_bond_types_from_struct_conn": ["covale"], "remove_ccds": CRYSTALLIZATION_AIDS, "remove_waters": True, - "hydrogen_policy": "remove", "fix_ligands_at_symmetry_centers": True, - "convert_mse_to_met": True, "fix_arginines": True, + "fix_formal_charges": True, + "fix_bond_types": True, + "convert_mse_to_met": True, + "hydrogen_policy": "remove", "model": None, # all models } """Default cif parser arguments for `atomworks.io.parse`. @@ -115,12 +117,13 @@ def load_example_from_metadata_row( cif_parser_args = {} # Convenience utilities to default to loading from and saving to cache if a cache_dir is provided, unless explicitly overridden + # TODO: Move to DEFAULT_CIF_PARSER_ARGS, but set to False by default not True if cif_parser_args.get("cache_dir"): cif_parser_args.setdefault("load_from_cache", True) cif_parser_args.setdefault("save_to_cache", True) # Merge DEFAULT_CIF_PARSER_ARGS with cif_parser_args, overriding with the keys present in cif_parser_args - merged_cif_parser_args = {**DEFAULT_CIF_PARSER_ARGS, **cif_parser_args} + merged_cif_parser_args = {**DEFAULT_PARSER_ARGS, **cif_parser_args} # Use the parse function with the merged CIF parser arguments result_dict = parse( diff --git a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py index 1718538e..7ad1d5c7 100644 --- a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py +++ b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py @@ -96,7 +96,7 @@ class PNUnitsDFParser(MetadataRowParser): def __init__( self, - base_dir: Path | str | list[Path | str] | tuple[Path | str, ...] = Path(PDB_MIRROR_PATH), + base_dir: Path | str | list[Path | str] | tuple[Path | str, ...] = PDB_MIRROR_PATH, file_extension: str | list[str] | tuple[str, ...] = ".cif.gz", path_template: str | list[str] | tuple[str, ...] = "{base_dir}/{pdb_id[1:3]}/{pdb_id}{file_extension}", ): @@ -148,7 +148,7 @@ class InterfacesDFParser(MetadataRowParser): def __init__( self, - base_dir: Path | str | list[Path | str] | tuple[Path | str, ...] = Path(PDB_MIRROR_PATH), + base_dir: Path | str | list[Path | str] | tuple[Path | str, ...] = PDB_MIRROR_PATH, file_extension: str | list[str] | tuple[str, ...] = ".cif.gz", path_template: str | list[str] | tuple[str, ...] = "{base_dir}/{pdb_id[1:3]}/{pdb_id}{file_extension}", ): diff --git a/src/atomworks/ml/transforms/af3_reference_molecule.py b/src/atomworks/ml/transforms/af3_reference_molecule.py index 10acaeef..6246066c 100644 --- a/src/atomworks/ml/transforms/af3_reference_molecule.py +++ b/src/atomworks/ml/transforms/af3_reference_molecule.py @@ -1,5 +1,6 @@ import logging from collections import defaultdict +from functools import lru_cache from typing import Any, ClassVar, Literal import biotite.structure as struc @@ -24,8 +25,13 @@ logger = logging.getLogger("atomworks.ml") -# UNL is a special CCD code for unknown ligands; we do not consider it "known" as it has no structure -KNOWN_CCD_CODES = get_available_ccd_codes(CCD_MIRROR_PATH) - {UNKNOWN_LIGAND} + +# (Lazy-load this expensive computation to avoid slow imports) +@lru_cache(maxsize=1) +def get_known_ccd_codes() -> frozenset[str]: + """Get the set of known CCD codes, computing it lazily on first access.""" + # UNL is a special CCD code for unknown ligands; we do not consider it "known" as it has no structure + return get_available_ccd_codes(CCD_MIRROR_PATH) - {UNKNOWN_LIGAND} def _extract_cached_conformers( @@ -86,7 +92,7 @@ def _get_rdkit_mols_with_conformers( """ ref_mols = {} for res_name, count in res_stochiometry.items(): - if res_name not in KNOWN_CCD_CODES: + if res_name not in get_known_ccd_codes(): ref_mols[res_name] = None # placeholder so that the unknown CCD codes are still counted later on continue @@ -263,8 +269,8 @@ def get_af3_reference_molecule_features( # ... generate conformers for CCD codes that are unknown (including UNL) unknown_ccd_conformers = defaultdict(list) - if not all(res_name in KNOWN_CCD_CODES for res_name in res_stochiometry): - res_indices_with_unknown = np.where(~np.isin(_res_names, list(KNOWN_CCD_CODES)))[0] + if not all(res_name in get_known_ccd_codes() for res_name in res_stochiometry): + res_indices_with_unknown = np.where(~np.isin(_res_names, list(get_known_ccd_codes())))[0] for res_index in res_indices_with_unknown: res_name = _res_names[res_index] @@ -307,7 +313,7 @@ def get_af3_reference_molecule_features( conf_idx = _next_conf_idx[res_name] # ... turn conformer into an atom array - if res_name not in KNOWN_CCD_CODES: + if res_name not in get_known_ccd_codes(): # (conformers for unknown CCD codes are already atom arrays, since we generated them directly) conformer = unknown_ccd_conformers[res_name][conf_idx % len(unknown_ccd_conformers[res_name])] else: diff --git a/src/atomworks/ml/transforms/base.py b/src/atomworks/ml/transforms/base.py index 3aa1f9fb..9b881938 100644 --- a/src/atomworks/ml/transforms/base.py +++ b/src/atomworks/ml/transforms/base.py @@ -31,31 +31,42 @@ class TransformPipelineError(Exception): - """A custom error class for Transform pipelines (via `Compose`).""" + """A custom error class for Transform pipelines (via :class:`Compose`). + + Attributes: + rng_state_dict: Optional RNG state dictionary for debugging purposes. + """ def __init__(self, message: str, rng_state_dict: dict[str, Any] | None = None): + """Initialize TransformPipelineError. + + Args: + message: The error message. + rng_state_dict: Optional RNG state dictionary for debugging purposes. + """ super().__init__(message) # expose RNG state dict for debugging self.rng_state_dict = rng_state_dict class TransformedDict(dict): - """A thin wrapper around a dictionary that can be used to track the transform history.""" + """A thin wrapper around a dictionary that can be used to track the transform history. + + Behaves just like a regular dictionary but includes a ``__transform_history__`` attribute + that tracks the sequence of transforms applied to the data. + """ def __new__(cls, __existing_dict_to_wrap: dict[str, Any] | None = None, **kwargs): """Create a new instance or return the existing TransformedDict instance. - NOTE: To get a pure dictionary, simply use `dict(transformed_dict)` on a TransformedDict instance. - TransformedDict's behave just like dicts for all intents and purposes, so you can use them just like - a regular dictionary. + Note: + To get a pure dictionary, simply use ``dict(transformed_dict)`` on a TransformedDict instance. + TransformedDict's behave just like dicts for all intents and purposes. Args: - __existing_dict_to_wrap (dict, optional): This is useful for wrapping an existing dictionary. - The odd name `__existing_dict_to_wrap` is used as an unlikely name to avoid conflicts - with the `dict` class. - **kwargs: Additional keyword arguments to pass to the dictionary constructor. This ensures - that a TransformedDict can be initialized just like a regular dictionary if no existing - dictionary to wrap is provided. + __existing_dict_to_wrap: This is useful for wrapping an existing dictionary. + The odd name is used as an unlikely name to avoid conflicts with the dict class. + **kwargs: Additional keyword arguments to pass to the dictionary constructor. """ # if the argument is already a TransformedDict, return it if isinstance(__existing_dict_to_wrap, TransformedDict): @@ -79,21 +90,18 @@ def __new__(cls, __existing_dict_to_wrap: dict[str, Any] | None = None, **kwargs class Transform(ABC): - """ - Abstract base class for transformations on dictionary objects. - - Class level attributes: - - validate_input (bool): Whether to validate the input. - - raise_if_invalid_input (bool): Whether to raise an error if the input is invalid. - - requires_previous_transforms (list[str]): Transforms that must have been applied before this transform. - - incompatible_previous_transforms (list[str]): Transforms that cannot have preceeded this transform. - - previous_transforms_order_matters (bool): Whether the order of the transforms is important. - - _track_transform_history (bool): Whether to track the transform history. - - To write a subclass, you need to implement the following methods: - - check_input(data: dict): Validates the input data. Should raise an error if the input is invalid. - The returned value is not used. - - forward(data: dict): Applies the transformation to the input data and returns the transformed data. + """Abstract base class for transformations on dictionary objects. + + To write a subclass, you need to implement the :meth:`forward` method. + Optionally, you can override :meth:`check_input` for input validation. + + Attributes: + validate_input: Whether to validate the input. + raise_if_invalid_input: Whether to raise an error if the input is invalid. + requires_previous_transforms: Transforms that must have been applied before this transform. + incompatible_previous_transforms: Transforms that cannot have preceded this transform. + previous_transforms_order_matters: Whether the order of the transforms is important. + _track_transform_history: Whether to track the transform history. """ validate_input: bool = True @@ -105,28 +113,39 @@ class Transform(ABC): # To be implemented by subclasses (optional) def check_input(self, data: dict[str, Any]) -> None: # noqa: B027 - """ - Check if the input dictionary is valid for the transform. Raises an error if the input is invalid. + """Check if the input dictionary is valid for the transform. + + Args: + data: The input dictionary to validate. + + Raises: + Exception: If the input is invalid. """ pass @abstractmethod def forward(self, data: dict[str, Any], *args, **kwargs) -> dict[str, Any]: - """ - Apply a transformation to the input dictionary and return the transformed dictionary. + """Apply a transformation to the input dictionary and return the transformed dictionary. - Parameters: - data (dict): The input dictionary to transform. + Args: + data: The input dictionary to transform. + *args: Additional positional arguments. + **kwargs: Additional keyword arguments. Returns: - dict: The transformed dictionary. + The transformed dictionary. """ pass # Internal logic for formatting error messages, debugging, logging and transform history tracking def _format_error_msg(self, e: Exception) -> str: - """ - Formats the error message with optional traceback when in DEBUG mode. + """Format the error message with optional traceback when in DEBUG mode. + + Args: + e: The exception that occurred. + + Returns: + Formatted error message. """ msg = f"Invalid input for {self.__class__.__name__}: {e}" if DEBUG: @@ -134,8 +153,13 @@ def _format_error_msg(self, e: Exception) -> str: return msg def _transform_to_str(self, t: str | Transform | ABCMeta) -> str: - """ - Convert a transform to a string. + """Convert a transform to a string. + + Args: + t: The transform to convert (string, Transform instance, or Transform class). + + Returns: + String representation of the transform. """ if isinstance(t, str): # case: transform was provided as string, e.g. as `"RemoveKeys"` @@ -150,19 +174,36 @@ def _transform_to_str(self, t: str | Transform | ABCMeta) -> str: raise ValueError(f"Transform `{t}` cannot be converted to a string form for comparison of history.") def _ensure_has_transform_history(self, data: dict[str, Any] | TransformedDict) -> TransformedDict: - """Ensure that the data dictionary has a transform history by wrapping it in a `TransformedDict`.""" + """Ensure that the data dictionary has a transform history by wrapping it in a TransformedDict. + + Args: + data: The data dictionary to wrap. + + Returns: + TransformedDict instance with transform history. + """ data = TransformedDict(data) return data def _get_transform_history(self, data: TransformedDict) -> list[str]: - """ - Get the transform history from the data. + """Get the transform history from the data. + + Args: + data: The TransformedDict containing the history. + + Returns: + List of transform names in the history. """ return data.__transform_history__ def _maybe_update_transform_history(self, data: TransformedDict) -> dict[str, Any]: - """ - Update the transform history by appending the current transform to the transform history. + """Update the transform history by appending the current transform to the transform history. + + Args: + data: The TransformedDict to update. + + Returns: + The updated data dictionary. """ if self._track_transform_history: this_transform_record = { @@ -178,8 +219,14 @@ def _maybe_update_transform_history(self, data: TransformedDict) -> dict[str, An return data def _maybe_restore_transform_history(self, data: TransformedDict, transform_history: list[str]) -> dict[str, Any]: - """ - Restore the transform history, in case the data was copied. + """Restore the transform history, in case the data was copied. + + Args: + data: The TransformedDict to restore history for. + transform_history: The history to restore. + + Returns: + The data with restored history. """ if not hasattr(data, "__transform_history__") or len(data.__transform_history__) == 0: # restore previous transform history if it is not present (e.g. if the data was copied) @@ -187,8 +234,13 @@ def _maybe_restore_transform_history(self, data: TransformedDict, transform_hist return data def _maybe_record_processing_time(self, data: TransformedDict) -> dict[str, Any]: - """ - Record the processing time for the transform. + """Record the processing time for the transform. + + Args: + data: The TransformedDict to record timing for. + + Returns: + The data with updated timing information. """ if self._track_transform_history and len(data.__transform_history__) > 0: for reverse_idx in range(len(data.__transform_history__) - 1, -1, -1): @@ -202,9 +254,13 @@ def _maybe_record_processing_time(self, data: TransformedDict) -> dict[str, Any] return data def _check_transform_history(self, data: TransformedDict) -> None: - """ - Check if the previous transforms are valid for the transform. - Raises an error if the input is invalid. + """Check if the previous transforms are valid for the transform. + + Args: + data: The TransformedDict to check. + + Raises: + TransformPipelineError: If the transform history is invalid. """ # extract the transform history history = [record["name"] for record in data.__transform_history__] @@ -243,11 +299,18 @@ def _check_transform_history(self, data: TransformedDict) -> None: ) def __call__(self, data: dict[str, Any], *args, **kwargs) -> dict[str, Any]: - """ - Validate and apply the transformation to the given dictionary. + """Validate and apply the transformation to the given dictionary. + + Args: + data: The input dictionary to transform. + *args: Additional positional arguments. + **kwargs: Additional keyword arguments. + + Returns: + The transformed dictionary. Raises: - ValueError: If the input is invalid and raise_if_invalid_input is True. + TransformPipelineError: If the input is invalid and raise_if_invalid_input is True. """ # enable history tracking if it is not already enabled data = self._ensure_has_transform_history(data) @@ -291,7 +354,11 @@ def __call__(self, data: dict[str, Any], *args, **kwargs) -> dict[str, Any]: return data def __repr__(self) -> str: - """String representation of the transform for debugging, notebooks and logging.""" + """String representation of the transform for debugging, notebooks and logging. + + Returns: + String representation of the transform. + """ # Get all the attributes of the class repr_str = f"{self.__class__.__name__} at {hex(id(self))}" @@ -304,6 +371,17 @@ def __repr__(self) -> str: return repr_str def __add__(self, other: Transform) -> Compose: + """Add two transforms together to create a Compose instance. + + Args: + other: Another Transform or Compose instance. + + Returns: + A new Compose instance containing both transforms. + + Raises: + ValueError: If other is not a Transform or Compose instance. + """ # Case 1: self & other are `Compose` instances # ... overridden in `Compose` class # Case 2: self is a `Compose` instance and other is a `Transform` instance @@ -321,38 +399,36 @@ def __add__(self, other: Transform) -> Compose: class Compose(Transform): - """ - Compose multiple transformations together. + """Compose multiple transformations together. This class allows you to chain multiple transformations and apply them sequentially to a data dictionary. It is particularly useful for preprocessing pipelines where multiple steps need to be applied in a specific order. Attributes: - - transforms (list[Transform]): A list of transformations to be applied. - - track_rng_state (bool): Whether to track and serialize the random number generator (RNG) state. This is + transforms: A list of transformations to be applied. + track_rng_state: Whether to track and serialize the random number generator (RNG) state. This is useful for debugging when dealing with probabilistic transformations. The RNG state is returned with the error message if the transform pipeline fails, allowing you to instantiate the same RNG state - with `eval` for debugging. + with ``eval`` for debugging. """ _track_transform_history: bool = False # Compose does not show up in the transform history def __init__(self, transforms: list[Transform], track_rng_state: bool = True, print_rng_state: bool = False): - """ - Initialize the Compose transformation pipeline. + """Initialize the Compose transformation pipeline. Args: - - transforms (list[Transform]): A list of transformations to be applied sequentially. - - track_rng_state (bool): Whether to track and serialize the random number generator (RNG) state. + transforms: A list of transformations to be applied sequentially. + track_rng_state: Whether to track and serialize the random number generator (RNG) state. This is useful for debugging when dealing with probabilistic transformations. The RNG state is returned with the error message if the transform pipeline fails, allowing you to instantiate - the same RNG state with `eval` for debugging. - - print_rng_state (bool): Whether to print the RNG state upon failure. This can be useful + the same RNG state with ``eval`` for debugging. + print_rng_state: Whether to print the RNG state upon failure. This can be useful for debugging and reproducing specific states for transforms with stochasticity. Raises: - ValueError: If `transforms` is not a list or tuple, if it is empty, or if it contains elements that - are not instances of `Transform`. + ValueError: If transforms is not a list or tuple, if it is empty, or if it contains elements that + are not instances of Transform. """ if not isinstance(transforms, list | tuple): raise ValueError(f"Expected a list or tuple of Transforms, but got a {type(transforms)}") @@ -370,6 +446,17 @@ def __init__(self, transforms: list[Transform], track_rng_state: bool = True, pr self.print_rng_state = print_rng_state def __add__(self, other: Transform | list[Transform] | Compose) -> Compose: + """Add another transform or compose to this compose. + + Args: + other: Another Transform, list of Transforms, or Compose instance. + + Returns: + A new Compose instance containing all transforms. + + Raises: + ValueError: If other is not a valid type. + """ if isinstance(other, Compose): return Compose( self.transforms + other.transforms, track_rng_state=self.track_rng_state or other.track_rng_state @@ -382,6 +469,13 @@ def __add__(self, other: Transform | list[Transform] | Compose) -> Compose: raise ValueError(f"Expected a Transform or list of Transforms or Compose, but got a {type(other)}") def check_input(self, data: dict) -> None: + """Check if the input is valid for the compose. + + Compose is always valid, so this method does nothing. + + Args: + data: The input data to check. + """ # Compose is always valid pass @@ -391,6 +485,19 @@ def _stop_transforms( next_transform_idx: int, stop_before: Transform | int | str | None = None, ) -> bool: + """Check if transforms should stop before the next transform. + + Args: + next_transform: The next transform to apply. + next_transform_idx: The index of the next transform. + stop_before: The transform, name, or index to stop before. + + Returns: + True if transforms should stop before the next transform. + + Raises: + ValueError: If stop_before is not a valid type. + """ if stop_before is None: return False elif isinstance(stop_before, int): @@ -408,19 +515,17 @@ def forward( rng_state_dict: dict[str, Any] | None = None, _stop_before: Transform | str | int | None = None, ) -> dict: - """ - Apply a series of transformations to the input data. + """Apply a series of transformations to the input data. Args: - data (dict): The input data to be transformed. - rng_state_dict (dict[str, Any] | None, optional): Random number generator state dictionary. - If provided, sets the RNG state before applying transforms. Defaults to None. - _stop_before (Transform | str | int | None, optional): Specifies a point to stop the transformation + data: The input data to be transformed. + rng_state_dict: Random number generator state dictionary. + If provided, sets the RNG state before applying transforms. + _stop_before: Specifies a point to stop the transformation process. Can be a Transform instance, a string (transform class name), or an integer (index). - Defaults to None. Returns: - dict: The transformed data. + The transformed data. Raises: Exception: If any transform in the pipeline fails, with details about the failure point and RNG state. diff --git a/src/atomworks/ml/transforms/filters.py b/src/atomworks/ml/transforms/filters.py index 5f431d65..16cd8906 100644 --- a/src/atomworks/ml/transforms/filters.py +++ b/src/atomworks/ml/transforms/filters.py @@ -30,6 +30,7 @@ def remove_unresolved_pn_units(atom_array: AtomArray) -> AtomArray: """ Filters PN units that have all unresolved atoms (i.e., atoms with occupancy 0) from the AtomArray. + Can be applied before or after croppping, since cropping may lead to PN units with all unresolved atoms that were previously not entirely unresolved. At training time, these unresolved PN units provide minimal value and can lead to errors in the model. """ @@ -72,6 +73,7 @@ def remove_unresolved_tokens(atom_array: AtomArray) -> AtomArray: class RemoveUnresolvedPNUnits(Transform): """ Filters PN units that have all unresolved atoms (i.e., atoms with occupancy 0) from the AtomArray. + Can be applied before or after croppping, since cropping may lead to PN units with all unresolved atoms that were previously not entirely unresolved. At training time, these unresolved PN units provide minimal value and can lead to errors in the model. """ @@ -222,10 +224,10 @@ def check_input(self, data: dict) -> None: def forward(self, data: dict) -> dict: if ("extra_info" not in data) or (self.pn_unit_iid_key not in data["extra_info"]): - # ...short-circuit if the key does not exist in the `extra_info` dictionary + # ... short-circuit if the key does not exist in the `extra_info` dictionary return data else: - # ...otherwise, filter the atom array + # ... otherwise, filter the atom array data["atom_array"] = filter_to_specified_pn_units( data["atom_array"], eval(data["extra_info"][self.pn_unit_iid_key]) ) diff --git a/src/atomworks/ml/utils/debug.py b/src/atomworks/ml/utils/debug.py index 73ff5672..28225f74 100644 --- a/src/atomworks/ml/utils/debug.py +++ b/src/atomworks/ml/utils/debug.py @@ -1,3 +1,8 @@ +"""Debug utilities for ML components. + +Provides functions for saving failed examples and debugging ML pipelines. +""" + import logging import os import pickle @@ -16,6 +21,14 @@ def _remove_special_characters(s: str) -> str: + """Remove special characters from a string. + + Args: + s: The string to clean. + + Returns: + The cleaned string with only alphanumeric characters and underscores. + """ assert isinstance(s, str) # Remove unwanted characters using regex clean_s = re.sub(r"[^a-zA-Z0-9_]", "", s) @@ -30,17 +43,14 @@ def save_failed_example_to_disk( rng_state_dict: dict = {}, error_msg: str = "", ) -> None: - """ - Attempts to save a failed example to disk as a pickle file. + """Attempts to save a failed example to disk as a pickle file. Args: - - example_id (str): The ID of the example. - - fail_dir (str): The directory where the failed example should be saved. Defaults to a specific path. - - rng_state_dict (dict): The random number generator state dictionary. - - error_msg (str): The error message associated with the failure. - - Returns: - None + example_id: The ID of the example. + fail_dir: The directory where the failed example should be saved. + data: Optional data dictionary to save. + rng_state_dict: The random number generator state dictionary. + error_msg: The error message associated with the failure. """ try: # Get wandb run ID if currently in a wandb run diff --git a/src/atomworks/ml/utils/io.py b/src/atomworks/ml/utils/io.py index eb38cc0a..3e69fa2c 100644 --- a/src/atomworks/ml/utils/io.py +++ b/src/atomworks/ml/utils/io.py @@ -1,39 +1,40 @@ +"""I/O utilities for ML components. + +Provides functions for file operations, directory scanning, and data loading. +""" + import gzip import hashlib +import os import pickle -import warnings +import re from collections.abc import Callable from functools import wraps from os import PathLike from pathlib import Path from typing import Any, TextIO -import biotite.structure as struc -import numpy as np import pandas as pd import pyarrow as pa import pyarrow.parquet as pq -from atomworks.constants import ( - AA_LIKE_CHEM_TYPES, - ATOMIC_NUMBER_TO_ELEMENT, - DNA_LIKE_CHEM_TYPES, - HYDROGEN_LIKE_SYMBOLS, - POLYPEPTIDE_D_CHEM_TYPES, - POLYPEPTIDE_L_CHEM_TYPES, - RNA_LIKE_CHEM_TYPES, - UNKNOWN_LIGAND, -) -from atomworks.io.utils.ccd import get_chem_comp_type from atomworks.ml.utils.misc import ( - convert_pn_unit_iids_to_pn_unit_ids, - extract_transformation_id_from_pn_unit_iid, logger, ) def open_file(filename: PathLike) -> TextIO: - """Open a file, handling gzipped files if necessary.""" + """Open a file, handling gzipped files if necessary. + + Args: + filename: The path to the file to open. + + Returns: + A file-like object for reading. + + Raises: + AssertionError: If the file does not exist. + """ filename = Path(filename) # ...assert that the file exists assert filename.exists(), f"File {filename} does not exist" @@ -43,24 +44,49 @@ def open_file(filename: PathLike) -> TextIO: return filename.open("r") -def cache_based_on_subset_of_args(cache_keys: list[str], maxsize: int | None = None) -> Callable: +def scan_directory(dir_path: PathLike, max_depth: int) -> list[str]: + """Fast, order-independent directory scan for files up to max_depth levels deep. + + Args: + dir_path: The root directory to scan. + max_depth: The maximum depth to scan. A max_depth of 1 means only the top-level directory. + + Returns: + A list of file paths found within the specified directory and depth. """ - Decorator to cache function results based on a subset of its keyword arguments. - Most helpful when some arguments may be unhashable types (e.g., dictionaries, AtomArray). + file_paths = [] + + for root, dirs, files in os.walk(dir_path): + current_depth = len(Path(root).relative_to(dir_path).parts) + + if current_depth >= max_depth: + dirs.clear() + continue + + for file in files: + file_path = os.path.join(root, file) + file_paths.append(file_path) + + return file_paths + +def cache_based_on_subset_of_args(cache_keys: list[str], maxsize: int | None = None) -> Callable: + """Decorator to cache function results based on a subset of its keyword arguments. + + Most helpful when some arguments may be unhashable types (e.g., dictionaries, AtomArray). If the value of any of the cache keys is None, the function is executed and the result is not cached. Note: - The wrapped function must use keyword arguments for those specified in `cache_keys`. + The wrapped function must use keyword arguments for those specified in cache_keys. Positional arguments are not supported for cache key extraction. Args: - cache_keys (List[str]): The names of the keyword arguments to use as the cache key. - maxsize (Optional[int]): The maximum number of entries to store in the cache. + cache_keys: The names of the keyword arguments to use as the cache key. + maxsize: The maximum number of entries to store in the cache. If None, the cache size is unlimited. Returns: - Callable: A decorator that caches the function results based on the specified keyword arguments. + A decorator that caches the function results based on the specified keyword arguments. Example: @cache_based_on_subset_of_args(['arg1'], maxsize=2) @@ -212,213 +238,6 @@ def get_sharded_file_path( return (nested_path / file_hash).with_suffix(extension) -def convert_af3_model_output_to_atom_array_stack( - atom_to_token_map: np.ndarray[int], - pn_unit_iids: np.ndarray[str], - decoded_restypes: np.ndarray[str], - xyz: np.ndarray, - elements: np.ndarray[int | str], - token_is_atomized: np.ndarray[bool] = None, -) -> struc.AtomArrayStack: - """ - Create an AtomArrayStack from AlphaFold-3-type model outputs. - Specific to AF-3; may not work with other formats. - - Parameters: - - atom_to_token_map (np.ndarray): Mapping from atoms to tokens [n_atom] - - pn_unit_iids (np.ndarray): PN unit IID's for each token [n_token] - - decoded_restype (np.ndarray): Decoded residue types for each token [n_token] - - xyz (np.ndarray): Coordinates of atoms [n_atom, 3] or [batch, n_atom, 3], where batch is the number of structures - - elements (np.ndarray): Element types for each atom [n_atom] - - token_is_atomized (np.ndarray, optional): Flags indicating if tokens are atomized [n_token]. If not provided - or None, residues with a single atom are considered atomized. - - Returns: - - AtomArrayStack: Constructed AtomArrayStack. - """ - # Issue a deprecation warning - warnings.warn( - "`convert_af3_model_output_to_atom_array_stack` is deprecated in favor of overwriting the AtomArray coordinates directly and will be removed in future versions.", - DeprecationWarning, - stacklevel=2, - ) - - atom_array = None - chain_iid_residue_counts = {} - - # If dimensions are [n_atom, 3], add a batch dimension - if len(xyz.shape) == 2: - xyz = np.expand_dims(xyz, axis=0) - - # If elements are integers, convert them to strings (since that's what we get from the CCD, and it better matches what CIF files expect) - if np.issubdtype(type(elements[0]), np.integer): - elements = np.array([ATOMIC_NUMBER_TO_ELEMENT[element] for element in elements]) - - ####################################################################################################### - # Iterate over the residues, and create the appropriate atoms for each residue with empty coordinates - # We add the atom type, residue ID, chain ID, and transformation ID to the AtomArray - ####################################################################################################### - - for global_res_idx, res_name in enumerate(decoded_restypes): - # Get atoms corresponding to the residue - atom_indices_in_token = np.where(atom_to_token_map == global_res_idx)[0] - - # ...check if we're dealing with an atomized token - if token_is_atomized is not None: - # If we have the token_is_atomized array, we can use it to determine if the residue is atomized - is_atom = token_is_atomized[global_res_idx] - else: - # Otherwise, we assume that a residue with a single atom is atomized - is_atom = len(atom_indices_in_token) == 1 - - # ...compute the residue ID - pn_unit_iid = pn_unit_iids[global_res_idx] - if pn_unit_iid not in chain_iid_residue_counts: - chain_iid_residue_counts[pn_unit_iid] = 1 - elif not is_atom: - # Only increment the residue count if we're not dealing with an atomized token (we put all atomized tokens in the same residue, like the PDB) - chain_iid_residue_counts[pn_unit_iid] += 1 - res_id = chain_iid_residue_counts[pn_unit_iid] - - if is_atom: - # UNL is "Unknown Ligand" in the CCD - element = elements[atom_indices_in_token].item() - - # ruff: noqa: B023 - def atom_name_exists(atom_name: str) -> bool: - return ( - atom_array[ - (atom_array.pn_unit_iid == pn_unit_iid) - & (atom_array.res_id == res_id) - & (atom_array.atom_name == atom_name) - ].array_length() - > 0 - ) - - # Create the atom name and ensure it's unique within the residue (so that we can give all the atoms the same ID) - atom_name = element - if atom_name_exists(atom_name): - atom_name = next( - f"{element}{atom_count}" - for atom_count in range(2, len(atom_array) + 1) - if not atom_name_exists(f"{element}{atom_count}") - ) - - atom = struc.Atom(np.full((3,), np.nan), res_name=UNKNOWN_LIGAND, element=element, atom_name=atom_name) - residue_atom_array = struc.array([atom]) - else: - chem_type = get_chem_comp_type(res_name) - - # Get the atom array of the residue from the CCD - residue_atom_array = struc.info.residue(res_name) - - # Set the elements to uppercase for consistency - residue_atom_array.element = np.array([x.upper() for x in residue_atom_array.element]) - - # If needed, remove type-specific atoms (e.g., OXT in polypeptides, O3' in RNA or DNA) for residues participating in inter-residue bonds - # If we are at a terminal residue, we don't want to remove these leaving groups - residue_atom_array = filter_residue_atoms( - residue_atom_array=residue_atom_array, chem_type=chem_type, elements=elements[atom_indices_in_token] - ) - - # Empty coordinates to avoid unexpected behavior - residue_atom_array.coord = np.full((residue_atom_array.array_length(), 3), np.nan) - - # Wipe the bond information (we are better off letting PyMOL infer the bonds) - residue_atom_array.bonds = None - - # Get the chain_iid, chain_id, and transformation_id - pn_unit_id = convert_pn_unit_iids_to_pn_unit_ids([pn_unit_iid])[0] - transformation_id = extract_transformation_id_from_pn_unit_iid(pn_unit_iid) - - # Set the annotations (for our purposes, chains and pn_units are the same) - residue_atom_array.set_annotation("chain_id", np.full(residue_atom_array.array_length(), pn_unit_id)) - residue_atom_array.set_annotation("pn_unit_id", np.full(residue_atom_array.array_length(), pn_unit_id)) - residue_atom_array.set_annotation("chain_iid", np.full(residue_atom_array.array_length(), pn_unit_iid)) - residue_atom_array.set_annotation("pn_unit_iid", np.full(residue_atom_array.array_length(), pn_unit_iid)) - residue_atom_array.set_annotation( - "transformation_id", np.full(residue_atom_array.array_length(), transformation_id) - ) - - # Everything is full occupancy - residue_atom_array.set_annotation("occupancy", np.full(residue_atom_array.array_length(), 1.0)) - - # Set the residue ID - residue_atom_array.set_annotation("res_id", np.full(residue_atom_array.array_length(), res_id)) - - if atom_array is None: - atom_array = residue_atom_array - else: - atom_array += residue_atom_array - - ####################################################################################################### - # Iterate over the batches of coordinates, and create a new AtomArray for each batch - ####################################################################################################### - atom_arrays = [] - for coords in xyz: - # ...create a new AtomArray for each batch, with new coordinates - batch_atom_array = atom_array.copy() - batch_atom_array.coord = coords - atom_arrays.append(batch_atom_array) - - # Convert to a stack - atom_array_stack = struc.stack(atom_arrays) - - return atom_array_stack - - -def filter_residue_atoms( - residue_atom_array: struc.AtomArray, chem_type: str, elements: np.ndarray[str] -) -> struc.AtomArray: - """ - Filter out unwanted atoms from a residue (e.g.., hydrogens, leaving groups) - - Parameters: - - residue_atom_array (struc.AtomArray): The AtomArray to filter. - - chem_type (str): Type of the chemical chain. - - elements (np.array): Element types (as strings, e.g., "C") for each atom in the residue. - - Returns: - - struc.AtomArray: Filtered AtomArray. - """ - # ...capitalize the chemical type - chem_type = chem_type.upper() - - # ...remove hydrogens and deuteriums - residue_atom_array = residue_atom_array[~np.isin(residue_atom_array.element, HYDROGEN_LIKE_SYMBOLS)] - - # If the arrays match, we return the residue as-is - if len(residue_atom_array) == len(elements) and all(elements == residue_atom_array.element): - return residue_atom_array - - # ...otherwise, we will try to remove specific atoms until the arrays match - if ( - chem_type in AA_LIKE_CHEM_TYPES - or chem_type in POLYPEPTIDE_L_CHEM_TYPES - or chem_type in POLYPEPTIDE_D_CHEM_TYPES - ): - # ...try removing OXT in non-terminal polypeptides - candidate_residue_atom_array = residue_atom_array[residue_atom_array.atom_name != "OXT"] - if len(candidate_residue_atom_array) == len(elements) and all(elements == candidate_residue_atom_array.element): - return candidate_residue_atom_array - - elif chem_type in RNA_LIKE_CHEM_TYPES or chem_type in DNA_LIKE_CHEM_TYPES: - # ...try removing OP3 in RNA or DNA - candidate_residue_atom_array = residue_atom_array[residue_atom_array.atom_name != "OP3"] - if len(candidate_residue_atom_array) == len(elements) and all(elements == candidate_residue_atom_array.element): - return candidate_residue_atom_array - - # ...as a last resort, try and match the elements by sliding a window over the residue - for start in range(len(residue_atom_array) - len(elements) + 1): - current_slice = residue_atom_array[start : start + len(elements)] - if all(elements == current_slice.element): - return current_slice - - raise ValueError( - f"Could not find a matching AtomArray for residue {residue_atom_array.res_name[0]} with elements {elements}" - ) - - def to_parquet_with_metadata(df: pd.DataFrame, filepath: PathLike, **kwargs: Any) -> None: """Convenience wrapper around df.to_parquet that saves table-wide metadata (df.attrs) to the parquet file. @@ -473,3 +292,63 @@ def read_parquet_with_metadata(filepath: PathLike, **kwargs: Any) -> pd.DataFram df.attrs = metadata_dict return df + + +def parse_sharding_pattern(sharding_pattern: str) -> list[tuple[int, int]]: + """Parse a sharding pattern string into directory levels. + + Args: + sharding_pattern: String like "/1:2/0:2/" where each /start:end/ defines a directory level + - start:end defines the character range to use for that directory level + - Example: "/1:2/0:2/" means use chars 1-2 for first dir, then chars 0-2 for second dir + + Returns: + List of (start, end) tuples for each directory level + """ + # Find all patterns like /start:end/ using a non-consuming lookahead + pattern = r"/(\d+):(\d+)(?=/)" + matches = [] + for match in re.finditer(pattern, sharding_pattern): + matches.append((int(match.group(1)), int(match.group(2)))) + + if not matches: + raise ValueError(f"Invalid sharding pattern format: {sharding_pattern}. Expected format like '/1:2/0:2/'") + + return matches + + +def apply_sharding_pattern(path: str, sharding_pattern: str | None = None) -> Path: + """Apply a sharding pattern to construct a file path. + + Args: + path: The base path or identifier (e.g., PDB ID) + sharding_pattern: Pattern for organizing files in subdirectories + - "/1:2/": Use characters 1-2 for first directory level + - "/1:2/0:2/": Use chars 1-2 for first dir, then chars 0-2 for second dir + - None: No sharding (default) + + Returns: + Path: The constructed file path with sharding applied + """ + if sharding_pattern and sharding_pattern.startswith("/"): + # General sharding pattern: /start:end/start:end/... + try: + shard_levels = parse_sharding_pattern(sharding_pattern) + except ValueError as e: + raise ValueError(f"Invalid sharding pattern: {e}") from e + + # Build the sharded path + current_path = Path() + + for start, end in shard_levels: + if end > len(path): + raise ValueError(f"Sharding range {start}:{end} exceeds path length {len(path)} for path '{path}'") + shard_dir = path[start:end] + current_path = current_path / shard_dir + + final_path = current_path / path + else: + # Default behavior: no sharding + final_path = Path(path) + + return final_path diff --git a/src/atomworks_cli/setup.py b/src/atomworks_cli/setup.py index cdb616f1..a7550181 100644 --- a/src/atomworks_cli/setup.py +++ b/src/atomworks_cli/setup.py @@ -21,7 +21,7 @@ """The URL for the latest AtomWorks test pack. Should be untared in `tests/data/shared`.""" METADATA_URL = f"{IPD_DOWNLOAD_URL}/pdb_metadata_latest.tar.gz" -"""The URL for the latest AtomWorks PDB metadata. Should be untared at the specifided location.""" +"""The URL for the latest AtomWorks PDB metadata. Should be untared at the specified location.""" app = typer.Typer(help="Setup utilities for AtomWorks.") diff --git a/tests/data/ml/af3_model_outs_protein_dna.pkl b/tests/data/ml/af3_model_outs_protein_dna.pkl deleted file mode 100644 index af0f1bbc626a13135613962800d1f8b2dbece073..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94251 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 diff --git a/tests/data/ml/pdb_interfaces/metadata.parquet b/tests/data/ml/pdb_interfaces/metadata.parquet index 03f06f79ed41ac3dfd6e300a28637177d750a48e..70358777d947115c8faad936c9e7787bfb8148c4 100644 GIT binary patch delta 28635 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

(QL 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 Hk-+~4Pmjr= diff --git a/tests/data/ml/pdb_pn_units/metadata.parquet b/tests/data/ml/pdb_pn_units/metadata.parquet index 4f438f7a7fe53449e450d91277604d2a10238ba9..f4dbc5b32c5b89d89c8096446683e5954b2de158 100644 GIT binary patch delta 28012 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 diff --git a/tests/io/components/test_caching.py b/tests/io/components/test_caching.py index b0f759e8..ef42386a 100644 --- a/tests/io/components/test_caching.py +++ b/tests/io/components/test_caching.py @@ -7,7 +7,7 @@ from tests.io.conftest import get_pdb_path TEST_CASES = [ - "1A7J", # Contains an unusual operation expression for assembly building + "4NDZ", # 29K atoms, large enough to test caching without too much variance ] @@ -93,8 +93,8 @@ def different_args_parse(): normal_result["assemblies"][assembly_id], cached_result["assemblies"][assembly_id], annotations_to_compare ) - # Assert that the cached result is at least 1.5x faster than the normal result - assert cached_elapsed_time < normal_elapsed_time / 1.5 + # Assert that the cached result is at least 3x faster than the normal result + assert cached_elapsed_time < normal_elapsed_time / 3 # Assert that the result with different arguments is similar to the normal elapsed time assert abs(different_args_elapsed_time - normal_elapsed_time) < normal_elapsed_time * 0.5 diff --git a/tests/ml/conftest.py b/tests/ml/conftest.py index 7005cbe9..c4c13623 100644 --- a/tests/ml/conftest.py +++ b/tests/ml/conftest.py @@ -7,16 +7,15 @@ import pytest from dotenv import load_dotenv -from atomworks.constants import AF3_EXCLUDED_LIGANDS_REGEX, _load_env_var +from atomworks.constants import AF3_EXCLUDED_LIGANDS_REGEX, PDB_MIRROR_PATH, _load_env_var from atomworks.io.tools.inference import SequenceComponent -from atomworks.ml.datasets.datasets import ConcatDatasetWithID, PandasDataset, StructuralDatasetWrapper -from atomworks.ml.datasets.parsers import ( - GenericDFParser, - InterfacesDFParser, - PNUnitsDFParser, - ValidationDFParserLikeAF3, +from atomworks.ml.datasets.datasets import ConcatDatasetWithID, PandasDataset +from atomworks.ml.datasets.loaders import ( + loader_base, + loader_with_interfaces_and_pn_units_to_score, + loader_with_query_pn_units, ) -from atomworks.ml.datasets.parsers.base import DEFAULT_CIF_PARSER_ARGS +from atomworks.ml.datasets.parsers.base import DEFAULT_PARSER_ARGS from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline from atomworks.ml.pipelines.rf2aa import build_rf2aa_transform_pipeline from atomworks.ml.preprocessing.constants import TRAINING_SUPPORTED_CHAIN_TYPES_INTS @@ -37,14 +36,20 @@ def pytest_configure(config): dotenv_path = os.path.join(current_dir, "../..", ".env") # Load the environment variables - load_dotenv(dotenv_path) - - -if not os.environ.get("PDB_MIRROR_PATH") or not os.path.exists(os.environ.get("PDB_MIRROR_PATH")): - raise pytest.UsageError( - "ERROR: Required PDB_MIRROR_PATH environment variable not set. " - "Please set this in the .env file or in your shell environment." - ) + load_dotenv(dotenv_path, override=True) + + # We require a PDB mirror (of at least a subset of the PDB) for the AtomWorks.ml tests + pdb_mirror_path = os.environ.get("PDB_MIRROR_PATH") + if not pdb_mirror_path: + raise pytest.UsageError( + "ERROR: Required PDB_MIRROR_PATH environment variable not set. " + "Please set this in the .env file or in your shell environment." + ) + if not os.path.exists(pdb_mirror_path): + raise pytest.UsageError( + f"ERROR: PDB_MIRROR_PATH is set to '{pdb_mirror_path}', but this path does not exist. " + "Please check your .env file or shell environment." + ) ########################################################################################## @@ -115,13 +120,13 @@ def interfaces_df(): # AF2 Distillation Facebook, with and without table-wide metadata (to test metadata handling) @pytest.fixture(scope="session") -def af2_distillation_facebook_df_no_metadata(): +def af2_distillation_df_no_metadata(): path = TEST_DATA_ML / "af2_distillation" / "metadata.parquet" return pd.read_parquet(path) @pytest.fixture(scope="session") -def af2_distillation_facebook_df_with_metadata(): +def af2_distillation_df_with_metadata(): df = read_parquet_with_metadata(TEST_DATA_ML / "af2_distillation" / "metadata.parquet") df.attrs["base_path"] = str(TEST_DATA_ML / "af2_distillation" / "cif") return df @@ -167,9 +172,9 @@ def af3_validation_df(): @pytest.fixture(scope="session") def pn_units_pandas_dataset(pn_units_df): return PandasDataset( + data=pn_units_df, name="pn_units", id_column="example_id", - data=pn_units_df, filters=SHARED_TEST_FILTERS + TEST_PN_UNITS_FILTERS, columns_to_load=None, # Load all columns ) @@ -178,9 +183,9 @@ def pn_units_pandas_dataset(pn_units_df): @pytest.fixture(scope="session") def interfaces_pandas_dataset(interfaces_df): return PandasDataset( + data=interfaces_df, name="interfaces", id_column="example_id", - data=interfaces_df, filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, columns_to_load=None, # Load all columns ) @@ -189,17 +194,17 @@ def interfaces_pandas_dataset(interfaces_df): @pytest.fixture(scope="session") def validation_pandas_dataset(af3_validation_df): return PandasDataset( - name="validation", data=af3_validation_df, + name="validation", id_column="example_id", columns_to_load=None, # Load all columns ) @pytest.fixture(scope="session") -def distillation_pandas_dataset_no_metadata(af2_distillation_facebook_df_no_metadata): +def af2_distillation_dataset_no_metadata(af2_distillation_df_no_metadata): return PandasDataset( - data=af2_distillation_facebook_df_no_metadata, + data=af2_distillation_df_no_metadata, id_column="example_id", name="af2fb_distillation", columns_to_load=["example_id", "sequence_hash", "path"], @@ -207,9 +212,9 @@ def distillation_pandas_dataset_no_metadata(af2_distillation_facebook_df_no_meta @pytest.fixture(scope="session") -def distillation_pandas_dataset_with_metadata(af2_distillation_facebook_df_with_metadata): +def af2_distillation_dataset_with_metadata(af2_distillation_df_with_metadata): return PandasDataset( - data=af2_distillation_facebook_df_with_metadata, + data=af2_distillation_df_with_metadata, id_column="example_id", name="af2fb_distillation", columns_to_load=["example_id", "sequence_hash", "path"], @@ -223,8 +228,10 @@ def distillation_pandas_dataset_with_metadata(af2_distillation_facebook_df_with_ @pytest.fixture(scope="session") def rf2aa_pn_units_dataset(pn_units_pandas_dataset): - return StructuralDatasetWrapper( - dataset_parser=PNUnitsDFParser(), + return PandasDataset( + data=pn_units_pandas_dataset.data, + name="rf2aa_pn_units", + loader=loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -237,16 +244,18 @@ def rf2aa_pn_units_dataset(pn_units_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=pn_units_pandas_dataset, - cif_parser_args={"cache_dir": None}, save_failed_examples_to_dir=None, ) @pytest.fixture(scope="session") def rf2aa_interfaces_dataset(interfaces_pandas_dataset): - return StructuralDatasetWrapper( - dataset_parser=InterfacesDFParser(), + return PandasDataset( + data=interfaces_pandas_dataset.data, + name="rf2aa_interfaces", + loader=loader_with_query_pn_units( + pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH + ), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -259,8 +268,6 @@ def rf2aa_interfaces_dataset(interfaces_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=interfaces_pandas_dataset, - cif_parser_args={"cache_dir": None}, save_failed_examples_to_dir=None, ) @@ -272,9 +279,16 @@ def rf2aa_pdb_dataset(rf2aa_pn_units_dataset, rf2aa_interfaces_dataset): @pytest.fixture(scope="session") def rf2aa_validation_dataset(validation_pandas_dataset): - """Create a StructuralDatasetWrapper for RF2AA validation.""" - return StructuralDatasetWrapper( - dataset_parser=ValidationDFParserLikeAF3(), + """Create a PandasDataset for RF2AA validation.""" + return PandasDataset( + data=validation_pandas_dataset.data, + name="rf2aa_validation", + loader=loader_with_interfaces_and_pn_units_to_score( + path_colname="pdb_id", + base_path=str(PDB_MIRROR_PATH), + extension=".cif.gz", + sharding_pattern="/1:3/", + ), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -287,7 +301,6 @@ def rf2aa_validation_dataset(validation_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=validation_pandas_dataset, save_failed_examples_to_dir=None, ) @@ -299,8 +312,10 @@ def rf2aa_validation_dataset(validation_pandas_dataset): @pytest.fixture(scope="session") def af3_pn_units_dataset(pn_units_pandas_dataset): - return StructuralDatasetWrapper( - dataset_parser=PNUnitsDFParser(), + return PandasDataset( + data=pn_units_pandas_dataset.data, + name="af3_pn_units", + loader=loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -313,15 +328,18 @@ def af3_pn_units_dataset(pn_units_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=pn_units_pandas_dataset, save_failed_examples_to_dir=None, ) @pytest.fixture(scope="session") def af3_interfaces_dataset(interfaces_pandas_dataset): - return StructuralDatasetWrapper( - dataset_parser=InterfacesDFParser(), + return PandasDataset( + data=interfaces_pandas_dataset.data, + name="af3_interfaces", + loader=loader_with_query_pn_units( + pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH + ), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -334,8 +352,6 @@ def af3_interfaces_dataset(interfaces_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=interfaces_pandas_dataset, - cif_parser_args={"cache_dir": None}, save_failed_examples_to_dir=None, ) @@ -347,8 +363,15 @@ def af3_pdb_dataset(af3_pn_units_dataset, af3_interfaces_dataset): @pytest.fixture(scope="session") def af3_validation_dataset(validation_pandas_dataset): - return StructuralDatasetWrapper( - dataset_parser=ValidationDFParserLikeAF3(), + return PandasDataset( + data=validation_pandas_dataset.data, + name="af3_validation", + loader=loader_with_interfaces_and_pn_units_to_score( + path_colname="pdb_id", + base_path=PDB_MIRROR_PATH, + extension=".cif.gz", + sharding_pattern="/1:3/", + ), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -360,20 +383,19 @@ def af3_validation_dataset(validation_pandas_dataset): template_lookup_path=TEMPLATE_LOOKUP, template_base_dir=TEMPLATE_DIR, ), - dataset=validation_pandas_dataset, save_failed_examples_to_dir=None, ) @pytest.fixture(scope="session") def af3_af2fb_distillation_dataset_no_metadata(distillation_pandas_dataset_no_metadata): - return StructuralDatasetWrapper( - dataset=distillation_pandas_dataset_no_metadata, - dataset_parser=GenericDFParser( + return PandasDataset( + data=distillation_pandas_dataset_no_metadata.data, + name="af3_af2fb_distillation_no_metadata", + loader=loader_base( base_path=str(TEST_DATA_ML / "af2_distillation" / "cif"), extension=".cif", ), - cif_parser_args={}, transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=[], @@ -388,10 +410,10 @@ def af3_af2fb_distillation_dataset_no_metadata(distillation_pandas_dataset_no_me @pytest.fixture(scope="session") def af3_af2fb_distillation_dataset_with_metadata(distillation_pandas_dataset_with_metadata): - return StructuralDatasetWrapper( - dataset=distillation_pandas_dataset_with_metadata, - dataset_parser=GenericDFParser(), - cif_parser_args={}, + return PandasDataset( + data=distillation_pandas_dataset_with_metadata.data, + name="af3_af2fb_distillation_with_metadata", + loader=loader_base(), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=[], @@ -419,16 +441,16 @@ def atom_array(): """ Load a CIF file from somewhere local and return the atom_array """ - merged_cif_parser_args = { - **DEFAULT_CIF_PARSER_ARGS, + parser_args = { + **DEFAULT_PARSER_ARGS, **{ "fix_arginines": False, "add_missing_atoms": False, # this is crucial otherwise the annotations are deleted }, } - merged_cif_parser_args.pop("add_bond_types_from_struct_conn") - merged_cif_parser_args.pop("remove_ccds") - data = cached_parse("6lyz", **merged_cif_parser_args) + parser_args.pop("add_bond_types_from_struct_conn") + parser_args.pop("remove_ccds") + data = cached_parse("6lyz", **parser_args) atom_array = data["atom_array"] return atom_array diff --git a/tests/ml/datasets/test_datasets.py b/tests/ml/datasets/test_datasets.py index 295756b2..50a230b0 100644 --- a/tests/ml/datasets/test_datasets.py +++ b/tests/ml/datasets/test_datasets.py @@ -4,7 +4,11 @@ import torch from torch.utils.data import SequentialSampler, WeightedRandomSampler -from atomworks.ml.datasets.datasets import ConcatDatasetWithID, PandasDataset, get_row_and_index_by_example_id +from atomworks.ml.datasets.datasets import ( + ConcatDatasetWithID, + PandasDataset, + get_row_and_index_by_example_id, +) from atomworks.ml.samplers import ( MixedSampler, calculate_weights_for_pdb_dataset_df, @@ -20,7 +24,7 @@ def create_dummy_dataset(length: int, name: str, dataset_class: PandasDataset = } ) data.attrs = {"base_path": "/example/base/path"} - return dataset_class(data=data, id_column="example_id", name=name) + return dataset_class(data=data, name=name, id_column="example_id") def test_nested_dummy_datasets(): @@ -53,10 +57,10 @@ def test_nested_dummy_datasets(): def test_structural_datasets(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, rf2aa_pdb_dataset): - # +------------------ Structural Dataset (PandasDataset wrapped with a StructuralDatasetWrapper) ------------------+ + # +------------------ Structural Dataset ------------------+ num_examples_per_epoch = 100 - # ...calculate the weights based on the AF-3 weighting methodology + # ... calculate the weights based on the AF-3 weighting methodology b_pn_unit = 0.5 # β_chain b_interface = 0.5 # β_interface alphas = { @@ -73,7 +77,7 @@ def test_structural_datasets(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, r ) pdb_dataset_weights = torch.cat([pn_units_dataset_weights, interfaces_dataset_weights]) # NOTE: Order matters! - # ...and initialize one sampler for all PDB datasets, using the unified weights + # ... and initialize one sampler for all PDB datasets, using the unified weights pdb_sampler = WeightedRandomSampler( weights=pdb_dataset_weights, num_samples=num_examples_per_epoch, # We later override with proportional number of examples @@ -107,7 +111,7 @@ def test_structural_datasets(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, r n_examples_per_epoch=100, ) - # ...create a dataset including both datasets + # ... create a dataset including both datasets concat_dataset = ConcatDatasetWithID(datasets=datasets) # +---------------------------- Tests and assertions ----------------------------+ @@ -133,7 +137,7 @@ def test_structural_datasets(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, r assert len(indices) == 100 # Check that 80% of the indices are from the (second copy of) the pn_units dataset - # ...all idxs >= len(pdb_dataset) should be from pn_units dataset + # (all idxs >= len(pdb_dataset) should be from pn_units dataset) pn_unit_indices = [idx for idx in indices if idx >= len(rf2aa_pdb_dataset)] assert len(pn_unit_indices) == 80 diff --git a/tests/ml/datasets/test_datasets_with_filters.py b/tests/ml/datasets/test_datasets_with_filters.py index eef30a2e..67079d18 100644 --- a/tests/ml/datasets/test_datasets_with_filters.py +++ b/tests/ml/datasets/test_datasets_with_filters.py @@ -64,6 +64,7 @@ def test_filter_no_impact(caplog, pn_units_df): PandasDataset( data=pn_units_df.copy(), filters=filters, + name="no_impact_test_dataset", ) assert "did not remove any rows" in caplog.text, "Warning for no impact filter not raised" @@ -75,6 +76,7 @@ def test_filter_remove_all_rows(pn_units_df): PandasDataset( data=pn_units_df.copy(), filters=filters, + name="remove_all_rows_test_dataset", ) diff --git a/tests/ml/pipelines/test_data_loading_pipelines.py b/tests/ml/pipelines/test_data_loading_pipelines.py index 148199d6..46ea2656 100644 --- a/tests/ml/pipelines/test_data_loading_pipelines.py +++ b/tests/ml/pipelines/test_data_loading_pipelines.py @@ -14,28 +14,33 @@ @pytest.fixture def datasets_to_test( - af3_af2fb_distillation_dataset_with_metadata, - af3_af2fb_distillation_dataset_no_metadata, + af3_pdb_dataset, af3_validation_dataset, + af2_distillation_dataset_with_metadata, + af2_distillation_dataset_no_metadata, rf2aa_validation_dataset, rf2aa_pdb_dataset, - af3_pdb_dataset, ): """Create the list of datasets to test with actual dataset objects.""" return [ { - "dataset": af3_af2fb_distillation_dataset_with_metadata, + "dataset": af3_pdb_dataset, "type": "train", + "num_examples": 5, + }, + { + "dataset": af3_validation_dataset, + "type": "validation", "num_examples": 1, }, { - "dataset": af3_af2fb_distillation_dataset_no_metadata, + "dataset": af2_distillation_dataset_with_metadata, "type": "train", "num_examples": 1, }, { - "dataset": af3_validation_dataset, - "type": "validation", + "dataset": af2_distillation_dataset_no_metadata, + "type": "train", "num_examples": 1, }, { @@ -48,11 +53,6 @@ def datasets_to_test( "type": "train", "num_examples": 1, }, - { - "dataset": af3_pdb_dataset, - "type": "train", - "num_examples": 5, - }, ] diff --git a/tests/ml/pipelines/test_pipeline_regression.py b/tests/ml/pipelines/test_pipeline_regression.py index b2242091..247429f4 100644 --- a/tests/ml/pipelines/test_pipeline_regression.py +++ b/tests/ml/pipelines/test_pipeline_regression.py @@ -14,7 +14,7 @@ from atomworks.enums import ChainType from atomworks.io import parse from atomworks.io.utils.testing import assert_same_atom_array -from atomworks.ml.datasets.parsers.base import DEFAULT_CIF_PARSER_ARGS +from atomworks.ml.datasets.parsers.base import DEFAULT_PARSER_ARGS from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline from atomworks.ml.utils.rng import create_rng_state_from_seeds, rng_state @@ -76,7 +76,7 @@ def instantiate_example(example_name: str): result_dict = parse( filename=file, build_assembly=("1",), - **DEFAULT_CIF_PARSER_ARGS, + **DEFAULT_PARSER_ARGS, ) for chain_id in result_dict["chain_info"]: result_dict["chain_info"][chain_id]["msa_path"] = test_data_dir / example_name / f"{example_name}.a3m" diff --git a/tests/ml/transforms/msa/test_load_msas.py b/tests/ml/transforms/msa/test_load_msas.py index 76673543..7ebbf04b 100644 --- a/tests/ml/transforms/msa/test_load_msas.py +++ b/tests/ml/transforms/msa/test_load_msas.py @@ -4,7 +4,6 @@ from typing import Any import numpy as np -import pandas as pd import pytest from atomworks.enums import ChainType @@ -15,7 +14,6 @@ from atomworks.ml.transforms.msa._msa_loading_utils import get_msa_path from atomworks.ml.transforms.msa.msa import LoadPolymerMSAs from atomworks.ml.utils.testing import cached_parse -from tests.conftest import skip_if_not_on_digs from tests.ml.conftest import PROTEIN_MSA_DIRS, RNA_MSA_DIRS logging.basicConfig(level=logging.INFO) @@ -202,47 +200,6 @@ def test_msas_with_mse(): ), "All proteins should have MSAs after MSE conversion" -@pytest.mark.slow -@pytest.mark.requires_digs -@skip_if_not_on_digs -def test_msa_coverage(pn_units_df): - """Ensure the MSA coverage for the test data set surpasses a certain threshold.""" - - protein_coverage_threshold = 0.95 - rna_coverage_threshold = 0.40 - - result = _evaluate_coverage_for_df(pn_units_df, PROTEIN_MSA_DIRS, RNA_MSA_DIRS) - - assert ( - result["protein_coverage"] >= protein_coverage_threshold - ), f"Protein MSA coverage of {result['protein_coverage']} is below the threshold of {protein_coverage_threshold}" - assert ( - result["rna_coverage"] >= rna_coverage_threshold - ), f"RNA MSA coverage of {result['rna_coverage']} is below the threshold of {rna_coverage_threshold}" - - -def _evaluate_coverage_for_df(df: pd.DataFrame, protein_msa_dirs: list[str], rna_msa_dirs: list[str]): - """Utility function to evaluate the MSA coverage for a DataFrame path.""" - num_proteins = num_proteins_with_msas = num_rna = num_rna_with_msa = 0 - - for row in df.itertuples(): - chain_type = ChainType(row.q_pn_unit_type) - if chain_type.is_protein(): - num_proteins += 1 - if get_msa_path(row.q_pn_unit_processed_entity_non_canonical_sequence, protein_msa_dirs) is not None: - num_proteins_with_msas += 1 - elif chain_type == ChainType.RNA: - num_rna += 1 - # HACK: Replace U with T to match the RNA MSA file names (legacy issue) - sequence = row.q_pn_unit_processed_entity_non_canonical_sequence.replace("U", "T") - if get_msa_path(sequence, rna_msa_dirs) is not None: - num_rna_with_msa += 1 - return { - "protein_coverage": num_proteins_with_msas / num_proteins, - "rna_coverage": num_rna_with_msa / num_rna, - } - - @pytest.mark.parametrize("test_case", MSA_TEST_CASES) def test_inference_msa_transform(test_case): """Test the LoadPolymerMSAsInference transformation pipeline, where we provide MSAs through the `chain_info` field""" diff --git a/tests/ml/utils/test_io.py b/tests/ml/utils/test_io.py deleted file mode 100644 index c7a31add..00000000 --- a/tests/ml/utils/test_io.py +++ /dev/null @@ -1,48 +0,0 @@ -import pickle - -import numpy as np -import pytest -from biotite.structure import AtomArrayStack - -from atomworks.constants import ATOMIC_NUMBER_TO_ELEMENT -from atomworks.ml.utils.io import convert_af3_model_output_to_atom_array_stack -from tests.ml.conftest import TEST_DATA_ML - -# NOTE: Not the "true" model outputs; slightly pre-processed for storage efficiency -TEST_PICKLED_AF3_MODEL_OUTPUTS = ["af3_model_outs_protein_dna.pkl", "af3_model_outs_protein_ligand.pkl"] - - -@pytest.mark.parametrize("file_path", TEST_PICKLED_AF3_MODEL_OUTPUTS) -def test_convert_af3_model_output_to_atom_array_stack(file_path: str): - full_path = TEST_DATA_ML / file_path - - # Load the model outputs - with open(full_path, "rb") as f: - model_outputs = pickle.load(f) - - # Convert the model outputs to an AtomArrayStack - atom_array_stack = convert_af3_model_output_to_atom_array_stack( - atom_to_token_map=model_outputs["atom_to_token_map"], - pn_unit_iids=model_outputs["chain_iids"], - decoded_restypes=model_outputs["decoded_restypes"], - xyz=model_outputs["xyz"], - elements=model_outputs["elements"], - token_is_atomized=model_outputs["token_is_atomized"], - ) - - # Smoke tests - assert isinstance(atom_array_stack, AtomArrayStack) - assert len(atom_array_stack[0]) == len(model_outputs["xyz"]) - - # Assert that the AtomArray has the correct elements - uppercase_elements = np.array( - [ATOMIC_NUMBER_TO_ELEMENT[atomic_number] for atomic_number in model_outputs["elements"]] - ) - assert np.array_equal(atom_array_stack.element, uppercase_elements) - - # Assert that the AtomArray has the correct coordinates for the first (and only) model - assert np.array_equal(atom_array_stack.coord[0], model_outputs["xyz"]) - - -if __name__ == "__main__": - pytest.main(["-s", "-v", "-m not very_slow", __file__]) From 024bcb5cfd4ae41576d668ddf7da21f872f2845d Mon Sep 17 00:00:00 2001 From: Nathaniel Corley Date: Thu, 11 Sep 2025 22:52:49 -0700 Subject: [PATCH 3/8] docs: fix docstrings and api docs (#22) * refactor: datasets initial commit with backwards compatibility * docs: first pass at dataset docs * fix: passing tests * docs: updated docs for datasets * fix: tests * fix: dataset exploration formatting * fix: caching test * docs: fix docstrings, update API * refactor: datasets revised (#25) * fix: prevent chain_ids in outer scope from being overridden by loop (#13) * refactor: datasets initial commit with backwards compatibility * docs: first pass at dataset docs * chore: update test metadata parquet files * fix: passing tests * docs: updated docs for datasets * fix: tests * fix: dataset exploration formatting * fix: caching test * chore: PR comments * fix: CI * fix: CI * fix: CI * fix: CI --------- Co-authored-by: Richard Shuai * refactor: datasets initial commit with backwards compatibility * docs: first pass at dataset docs * fix: passing tests * docs: updated docs for datasets * fix: tests * fix: caching test * docs: fix docstrings, update API * refactor: datasets revised (#25) * fix: prevent chain_ids in outer scope from being overridden by loop (#13) * refactor: datasets initial commit with backwards compatibility * docs: first pass at dataset docs * chore: update test metadata parquet files * fix: passing tests * docs: updated docs for datasets * fix: tests * fix: dataset exploration formatting * fix: caching test * chore: PR comments * fix: CI * fix: CI * fix: CI * fix: CI --------- Co-authored-by: Richard Shuai * fix: tests * docs: update readme --------- Co-authored-by: Richard Shuai --- README.md | 23 ++- docs/api_reference.rst | 1 + docs/core.rst | 12 ++ docs/core/common.rst | 8 + docs/core/constants.rst | 8 + docs/core/enums.rst | 8 + docs/examples/annotate_and_save_structures.py | 2 +- docs/examples/dataset_exploration.py | 62 +++--- .../examples/load_and_visualize_structures.py | 6 +- .../examples/pocket_conditioning_transform.py | 10 +- docs/io.rst | 3 - docs/io/common.rst | 7 - docs/io/constants.rst | 7 - docs/io/enums.rst | 7 - docs/io/parser.rst | 2 +- docs/io/tools/fasta.rst | 2 +- docs/io/tools/inference.rst | 2 +- docs/io/transforms/atom_array.rst | 2 +- docs/io/transforms/categories.rst | 2 +- docs/io/utils/ccd.rst | 2 +- docs/io/utils/io_utils.rst | 2 +- docs/io/utils/sequence.rst | 2 +- docs/ml.rst | 6 - docs/ml/common.rst | 9 - docs/ml/datasets.rst | 19 +- docs/ml/datasets/datasets.rst | 187 ++++++++++++++++++ docs/ml/datasets/parsers.rst | 16 +- docs/ml/encoding_definitions.rst | 2 +- docs/ml/enums.rst | 9 - docs/ml/preprocessing.rst | 2 +- docs/ml/preprocessing/utils.rst | 28 --- docs/ml/transforms.rst | 24 +-- docs/ml/transforms/diffusion.rst | 2 +- docs/ml/transforms/dna.rst | 2 +- docs/ml/transforms/esm.rst | 9 - docs/ml/utils.rst | 6 +- src/atomworks/__init__.py | 8 +- src/atomworks/biotite_patch.py | 13 +- src/atomworks/common.py | 12 +- src/atomworks/constants.py | 75 +++++-- src/atomworks/enums.py | 162 +++++++++++---- src/atomworks/io/__init__.py | 3 +- src/atomworks/io/parser.py | 39 ++-- src/atomworks/io/tools/rdkit.py | 17 +- src/atomworks/io/transforms/atom_array.py | 25 ++- src/atomworks/io/transforms/categories.py | 25 ++- src/atomworks/io/utils/bonds.py | 4 +- src/atomworks/io/utils/ccd.py | 89 ++++++--- src/atomworks/io/utils/io_utils.py | 43 ++-- src/atomworks/io/utils/selection.py | 4 +- src/atomworks/io/utils/sequence.py | 18 +- src/atomworks/ml/datasets/datasets.py | 42 ++-- src/atomworks/ml/datasets/loaders.py | 33 ++-- .../parsers/default_metadata_row_parsers.py | 56 +++--- src/atomworks/ml/encoding_definitions.py | 55 +++--- src/atomworks/ml/pipelines/af3.py | 5 +- .../ml/preprocessing/utils/clustering.py | 6 +- .../ml/preprocessing/utils/structure_utils.py | 4 +- src/atomworks/ml/samplers.py | 4 +- .../ml/transforms/af3_reference_molecule.py | 10 +- src/atomworks/ml/transforms/atom_array.py | 7 +- src/atomworks/ml/transforms/atom_frames.py | 18 +- src/atomworks/ml/transforms/bonds.py | 62 +++--- src/atomworks/ml/transforms/chirals.py | 97 ++++----- .../ml/transforms/covalent_modifications.py | 3 +- src/atomworks/ml/transforms/crop.py | 10 +- src/atomworks/ml/transforms/encoding.py | 88 +++++---- .../ml/transforms/msa/_msa_constants.py | 8 +- .../transforms/msa/_msa_featurizing_utils.py | 8 +- .../ml/transforms/msa/_msa_loading_utils.py | 8 +- src/atomworks/ml/transforms/msa/msa.py | 4 +- .../ml/transforms/openbabel_utils.py | 175 ++++++++-------- src/atomworks/ml/transforms/rdkit_utils.py | 92 ++++----- src/atomworks/ml/transforms/sasa.py | 12 +- src/atomworks/ml/transforms/symmetry.py | 83 ++++---- src/atomworks/ml/transforms/template.py | 45 ++--- src/atomworks/ml/utils/geometry.py | 19 +- src/atomworks/ml/utils/io.py | 13 +- src/atomworks/ml/utils/misc.py | 2 +- src/atomworks/ml/utils/rng.py | 72 ++++--- src/atomworks/ml/utils/timer.py | 19 +- src/atomworks/ml/utils/token.py | 4 +- src/atomworks_cli/pdb.py | 28 ++- tests/conftest.py | 20 ++ tests/ml/conftest.py | 129 ++++-------- tests/ml/datasets/test_datasets.py | 4 +- 86 files changed, 1338 insertions(+), 945 deletions(-) create mode 100644 docs/core.rst create mode 100644 docs/core/common.rst create mode 100644 docs/core/constants.rst create mode 100644 docs/core/enums.rst delete mode 100644 docs/io/common.rst delete mode 100644 docs/io/constants.rst delete mode 100644 docs/io/enums.rst delete mode 100644 docs/ml/common.rst create mode 100644 docs/ml/datasets/datasets.rst delete mode 100644 docs/ml/enums.rst delete mode 100644 docs/ml/preprocessing/utils.rst delete mode 100644 docs/ml/transforms/esm.rst diff --git a/README.md b/README.md index 49824bfe..2fb26f7f 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,22 @@ [![Documentation Status](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://baker-laboratory.github.io/atomworks-dev/latest/index.html) [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -atomworks logo +

-**atomworks** is an open-source platform that maximizes research velocity for biomolecular modeling tasks. Much like how [Torchdata](https://docs.pytorch.org/data/beta/index.html) enables rapid prototyping within the vision and language domains, AtomWorks aims to accelerate development and experimentation within biomolecular modeling. +**atomworks** is an open-source platform that maximizes research velocity for biomolecular modeling tasks. Much like how [Torchvision](https://docs.pytorch.org/vision/stable/index.html) enables rapid prototyping within the vision domain, and [Torchaudio](https://docs.pytorch.org/audio/main/) within the audio domain, AtomWorks aims to accelerate development and experimentation within biomolecular modeling. -> **⚠️ Notice:** We are currently finalizing some cleanup work within our repositories. Please expect the APIs (e.g., function and class names, inputs and outputs) to stabilize within the next two weeks. Thank you for your patience! +> **⚠️ Notice:** We are currently finalizing some cleanup work within our repositories. Please expect the APIs (e.g., function and class names, inputs and outputs) to stabilize within the next one week. Thank you for your patience! If you're looking for the models themselves (e.g., RF3, MPNN) that integrate with AtomWorks rather than the underlying framework, check out [ModelForge](https://github.com/RosettaCommons/modelforge) +> **💡 Note:** Not sure where to start? We've made some [examples in the AtomWorks documentation](https://baker-laboratory.github.io/atomworks-dev/latest/auto_examples/index.html) that work through several helpful scenarios; a full tutorial is under construction! + AtomWorks is composed of two symbiotic libraries: -- **atomworks.io:** A universal Python toolkit for parsing, cleaning, manipulating, and converting biological data (structures, sequences, small molecules). Built on the [biotite](https://www.biotite-python.org/) API, it seamlessly loads and exports between standard formats like mmCIF, PDB, FASTA, SMILES, MOL, and more. -- **atomworks.ml:** Advanced dataset featurization and sampling for deep learning workflows that uses `atomworks.io` as its structural backbone. We provide a comprehensive, pre-built and well-tested set of `Transforms` for common tasks that can be easily composed into full deep-learning pipelines; users may also create their own `Transforms` for custom operations. +- `atomworks.io`: A universal Python toolkit for parsing, cleaning, manipulating, and converting biological data (structures, sequences, small molecules). Built on the [biotite](https://www.biotite-python.org/) API, it seamlessly loads and exports between standard formats like mmCIF, PDB, FASTA, SMILES, MOL, and more. Broadly useful for anyone who works with structural data for biomolecules. +- `atomworks.ml`: Advanced dataset featurization and sampling for deep learning workflows that uses `atomworks.io` as its structural backbone. We provide a comprehensive, pre-built and well-tested set of `Transforms` for common tasks that can be easily composed into full deep-learning pipelines; users may also create their own `Transforms` for custom operations. For more detail on the motivation for and applications of AtomWorks, please see the [preprint](https://doi.org/10.1101/2025.08.14.670328). @@ -25,7 +29,7 @@ AtomWorks is built atop [biotite](https://www.biotite-python.org/): We are grate ## atomworks.io -> *A general-purpose Python toolkit for cleaning up, standardizing, and working with biomolecular files - based on biotite* +> *A general-purpose Python toolkit for cleaning, standardizing, and manipulating with biomolecular structure files - built atop [biotite](https://www.biotite-python.org/): **atomworks.io** lets you: @@ -33,7 +37,7 @@ AtomWorks is built atop [biotite](https://www.biotite-python.org/): We are grate - Transform all data to a consistent `AtomArray` representation for further analysis or machine learning applications, regardless of initial source - Model missing atoms (those implied by the sequence but not represented in the coordinates) and initialize entity- and instance-level annotations (see the [glossary]() for more detail on our composable naming conventions) -We have found `atomworks.io` to be useful to a general bioinformatics and protein design audience; in many cases, `atomworks.io` can replace bespoke scripts and manual curation, enabling researchers to spend more time testing hypothesis and less time juggling dozens of tools and dependencies. +We have found `atomworks.io` to be generally useful to a broad bioinformatics and protein design audience; in many cases, `atomworks.io` can replace bespoke scripts and manual curation, enabling researchers to spend more time testing hypothesis and less time juggling dozens of tools and dependencies. --- @@ -45,11 +49,12 @@ We have found `atomworks.io` to be useful to a general bioinformatics and protei - A library of pre-built, well-tested `Transforms` that can be slotted into novel pipelines - An extensible framework, integrated with `atomworks.io`, to write `Transforms` for arbitrary use cases -- Scripts to pre-process the PDB or other databases into dataframes appropriate for network training -- Efficient sampling and batching utilities for training machine learning models +- Pre-built datasets and samplers suitable for most model training scenarios Within the AtomWorks paradigm, the output of each `Transform` is not an opaque dictionary with model-specific tensors but instead an updated version of our atom-level structural representation (Biotite's `AtomArray`). Operations within – and between – pipelines thus maintain a common vocabulary of inputs and outputs. +We have found that `atomworks.ml` **dramatically** reduces the overhead of starting, and completing, many ML projects; research topics that once took months now achieve signal within weeks if not days, accelerating the pace of innovation. + --- ## Installation diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 20ea14c7..44f008b4 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -5,5 +5,6 @@ API :maxdepth: 2 :caption: API Modules + core io ml \ No newline at end of file diff --git a/docs/core.rst b/docs/core.rst new file mode 100644 index 00000000..72d1d2f4 --- /dev/null +++ b/docs/core.rst @@ -0,0 +1,12 @@ +Core Modules +============ + +The core modules provide fundamental utilities, constants, and enumerations used throughout the atomworks library. + +.. toctree:: + :maxdepth: 2 + + core/common + core/constants + core/enums + diff --git a/docs/core/common.rst b/docs/core/common.rst new file mode 100644 index 00000000..797187ab --- /dev/null +++ b/docs/core/common.rst @@ -0,0 +1,8 @@ +Common Utilities +================ + +.. automodule:: atomworks.common + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/core/constants.rst b/docs/core/constants.rst new file mode 100644 index 00000000..0afca45b --- /dev/null +++ b/docs/core/constants.rst @@ -0,0 +1,8 @@ +Constants +========= + +.. automodule:: atomworks.constants + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/core/enums.rst b/docs/core/enums.rst new file mode 100644 index 00000000..64b59f57 --- /dev/null +++ b/docs/core/enums.rst @@ -0,0 +1,8 @@ +Enumerations +============ + +.. automodule:: atomworks.enums + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/examples/annotate_and_save_structures.py b/docs/examples/annotate_and_save_structures.py index 4ebc9a5e..7fb13648 100644 --- a/docs/examples/annotate_and_save_structures.py +++ b/docs/examples/annotate_and_save_structures.py @@ -220,6 +220,6 @@ def fix_boolean_annotation(atom_array: struc.AtomArray, annotation_name: str) -> ######################################################################## # Related Examples -# ---------- +# --------------- # # - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation diff --git a/docs/examples/dataset_exploration.py b/docs/examples/dataset_exploration.py index 61b35da5..28102cc3 100644 --- a/docs/examples/dataset_exploration.py +++ b/docs/examples/dataset_exploration.py @@ -1,6 +1,6 @@ """ Dataset Exploration and Management in AtomWorks -============================================== +=============================================== This example demonstrates how to work with datasets in AtomWorks, from simple file-based datasets to complex tabular datasets with custom loaders and transform pipelines. @@ -17,11 +17,11 @@ # Overview # ========= # -# ``Transform`` pipelines can be used with any data loader and any dataset. They are simply functions that take as input an ``AtomArray`` (which is often the output of ``AtomWorks.io``) and output ``PyTorch`` tensors ready for ingestion by a model. +# `Transform` pipelines can be used with any data loader and any dataset. They are simply functions that take as input an `AtomArray` (which is often the output of `AtomWorks.io`) and output `PyTorch` tensors ready for ingestion by a model. # -# However, most users will not want to build datasets from scratch. For convenience, we provide pre-built datasets and dataloaders that play well with ``Transform`` pipelines as well, roughly adhering to `Torchvision `_ conventions. +# However, most users will not want to build datasets from scratch. For convenience, we provide pre-built datasets and dataloaders that play well with `Transform` pipelines as well, roughly adhering to `Torchvision `_ conventions. # -# We demonstrate below a couple of different ways to connect a ``Transform`` pipeline with arbitrary datasets and connect them with trivial ``Transform`` pipelines. +# We demonstrate below a couple of different ways to connect a `Transform` pipeline with arbitrary datasets and connect them with trivial `Transform` pipelines. ######################################################################## # Datasets in AtomWorks @@ -31,14 +31,13 @@ # Using a Folder of CIF/PDB Files as a Dataset # --------------------------------------------- # -# The simplest way to use AtomWorks with a Dataset is to create a ``Dataset`` and ``Sampler`` pointed to a directory of structural files (e.g., PDB, CIF). +# The simplest way to use AtomWorks with a Dataset is to create a `Dataset` and `Sampler` pointed to a directory of structural files (e.g., PDB, CIF). # -# .. note:: -# All AtomWorks Datasets require a ``name`` attribute to support many of the logging/debugging features that are supplied out-of-the-box. +# **NOTE**: All AtomWorks Datasets require a `name` attribute to support many of the logging/debugging features that are supplied out-of-the-box. from atomworks.ml.datasets.datasets import FileDataset -# To setup the test pack, if not already, run ``atomworks setup tests`` +# To setup the test pack, if not already, run `atomworks setup tests` dataset = FileDataset.from_directory( directory="../../tests/data/ml/af2_distillation/cif", name="example_directory_dataset" ) @@ -62,18 +61,18 @@ # At a high level, to train models with AtomWorks, we need typically need a Dataset that: # # (1) Takes as input an item index and returns the corresponding example information; typically includes: -# a. Path to a structural file saved on disk (``/path/to/dataset/my_dataset_0.cif``) +# a. Path to a structural file saved on disk (`/path/to/dataset/my_dataset_0.cif`) # b. Additional item-specific metadata (e.g., class labels) # -# (2) Pre-loads structural information from the returned example into an ``AtomArray`` and assembles inputs for the Transform pipeline +# (2) Pre-loads structural information from the returned example into an `AtomArray` and assembles inputs for the Transform pipeline # # (3) Feed the input dictionary through a Transform pipeline and returns the result # -# So far, the ``FileDataset`` we initialized only accomplishes (1) from above - returning the raw data. +# So far, the `FileDataset` we initialized only accomplishes (1) from above - returning the raw data. # # To accomplish (2), we can additionally pass a loading function at dataset initialization that takes the raw example data as input and returns a pre-processed ready for a Transform pipeline. # -# In most cases, this will involve using ``parse`` or ``load_any`` from ``AtomWorks.io`` to build an ``AtomArray``, which is the common language of our ``Transform`` library. +# In most cases, this will involve using `parse` or `load_any` from `AtomWorks.io` to build an `AtomArray`, which is the common language of our `Transform` library. from atomworks.io import parse @@ -118,7 +117,7 @@ def simple_loading_fn(raw_data) -> dict: ) ######################################################################## -# Just like with the loading function, we can also pass a composed ``Transform`` pipeline to our datasets. +# Just like with the loading function, we can also pass a composed `Transform` pipeline to our datasets. dataset_with_loading_fn_and_transforms = FileDataset.from_directory( directory="../../tests/data/pdb", name="example_pdb_dataset", loader=simple_loading_fn, transform=pipe @@ -147,7 +146,7 @@ def simple_loading_fn(raw_data) -> dict: # # We will then sample uniformly (with or without replacement) from this dataset during training. Such a simple application may be appropriate for many fine-tuning cases such as distillation. # -# The only "gotcha" outside of normal PyTorch sampling is that you'll need to implement a default collate function (which could simply be the identity) so long as your output dictionary contains an ``AtomArray``. +# The only "gotcha" outside of normal PyTorch sampling is that you'll need to implement a default collate function (which could simply be the identity) so long as your output dictionary contains an `AtomArray`. from torch.utils.data import RandomSampler, DataLoader @@ -165,7 +164,7 @@ def simple_loading_fn(raw_data) -> dict: break ######################################################################## -# For more complicated sampling strategies, including distributed sampling for multi-GPU training, see the API documentation for ``samplers.py``, and the tests in ``test_samplers.py`` +# For more complicated sampling strategies, including distributed sampling for multi-GPU training, see the API documentation for `samplers.py`, and the tests in `test_samplers.py` ######################################################################## # Tabular Datasets @@ -175,26 +174,26 @@ def simple_loading_fn(raw_data) -> dict: # # We thus support instantiating datasets from tabular sources stored on disk. # -# We have implemented a ``PandasDataset`` class for this purpose; however, any tabular format (e.g., ``PolarsDataset``) could be similarly implemented without difficulty should the need arise (PR's welcome!) +# We have implemented a `PandasDataset` class for this purpose; however, any tabular format (e.g., `PolarsDataset`) could be similarly implemented without difficulty should the need arise (PR's welcome!) ######################################################################## # PandasDataset # -------------- # -# The ``PandasDataset`` class requires a couple of arguments: -# - ``data``: Either a pandas DataFrame or path to a CSV/Parquet file containing the tabular data. Each row represents one example. -# - ``name``: Descriptive name for this dataset, just as in ``FileDataset`` and all AtomWorks ``Dataset`` classes. Used for debugging and some downstream functions when using nested datasets. +# The `PandasDataset` class requires a couple of arguments: +# - `data`: Either a pandas DataFrame or path to a CSV/Parquet file containing the tabular data. Each row represents one example. +# - `name`: Descriptive name for this dataset, just as in `FileDataset` and all AtomWorks `Dataset` classes. Used for debugging and some downstream functions when using nested datasets. # # Again, we can also pass a `transform` pipeline and `loader`: -# - ``transform``: Transform pipeline to apply to loaded data. -# - ``loader``: Optional function to process raw DataFrame rows into Transform-ready format. +# - `transform`: Transform pipeline to apply to loaded data. +# - `loader`: Optional function to process raw DataFrame rows into Transform-ready format. # # There's also a few other `PandasDataset`-specific arguments to note: -# - ``filters``: Optional list of pandas query strings to filter the data. Applied in order during initialization. -# - ``columns_to_load``: Optional list of column names to load when reading from a file. If None, all columns are loaded. Can dramatically reduce memory usage and load time if loading from a columnar format like Parquet. +# - `filters`: Optional list of pandas query strings to filter the data. Applied in order during initialization. +# - `columns_to_load`: Optional list of column names to load when reading from a file. If None, all columns are loaded. Can dramatically reduce memory usage and load time if loading from a columnar format like Parquet. ######################################################################## -# We will start by exploring an example metadata dataframe, then load it into a ``PandasDataset``. +# We will start by exploring an example metadata dataframe, then load it into a `PandasDataset`. from atomworks.ml.utils.io import read_parquet_with_metadata @@ -209,23 +208,22 @@ def simple_loading_fn(raw_data) -> dict: # Understanding the Metadata # --------------------------- # -# This dataframe includes a row for every interface between two ``pn_units`` (essentially, chains) in the Protein Data Bank. For illustration purposes, however, we're loading the test dataframe, which only includes information for a small subset of the full PDB. +# This dataframe includes a row for every interface between two `pn_units` (essentially, chains) in the Protein Data Bank. For illustration purposes, however, we're loading the test dataframe, which only includes information for a small subset of the full PDB. # -# The complete dataframes can be downloaded with ``atomworks setup metadata`` and will be described in greater detail elsewhere in the documentation. +# The complete dataframes can be downloaded with `atomworks setup metadata` and will be described in greater detail elsewhere in the documentation. # -# For our purposes, note that we have a ``path`` column that points to a ``.cif`` file stored on disk, an ``example_id`` column which is unique across every row in the dataset, and two columns ``pn_unit_1_iid`` and ``pn_unit_2_iid`` that specify the interface of interest for this particular row. +# For our purposes, note that we have a `path` column that points to a `.cif` file stored on disk, an `example_id` column which is unique across every row in the dataset, and two columns `pn_unit_1_iid` and `pn_unit_2_iid` that specify the interface of interest for this particular row. # -# .. note:: -# Because a given PDB ID may contain many interfaces and thus may appear multiple times in our dataset, we must also incorporate the ``assembly_id`` and the ``pn_unit_iids`` of the two interacting chains within the ``example_id``. +# **NOTE**: Because a given PDB ID may contain many interfaces and thus may appear multiple times in our dataset, we must also incorporate the `assembly_id` and the `pn_unit_iids` of the two interacting chains within the `example_id`. from atomworks.ml.datasets.datasets import PandasDataset -from atomworks.ml.datasets.loaders import loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units dataset = PandasDataset( data=interfaces_df, name="interfaces_dataset", # We use a pre-built loader that takes in a list of column names and returns a loader function - loader=loader_with_query_pn_units(pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"]), + loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"]), transform=pipe, ) @@ -233,7 +231,7 @@ def simple_loading_fn(raw_data) -> dict: ######################################################################## # Related Examples -# ---------- +# --------------- # # - :doc:`load_and_visualize_structures` - Learn how to load and explore protein structures # - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation diff --git a/docs/examples/load_and_visualize_structures.py b/docs/examples/load_and_visualize_structures.py index 1d7c1923..5841e48a 100644 --- a/docs/examples/load_and_visualize_structures.py +++ b/docs/examples/load_and_visualize_structures.py @@ -37,7 +37,7 @@ ######################################################################## # Using ``parse()`` for Full Processing -# ------------------------------------ +# ------------------------------------- # # For RCSB structures, we typically load structures with ``parse()`` to get clean data suitable for most downstream tasks. # @@ -58,7 +58,7 @@ ######################################################################## # Using ``load_any()`` for Lightweight Loading -# ------------------------------------------- +# -------------------------------------------- # For comparison: load_any() for lightweight loading (no extensive processing) # Useful when you have clean data (e.g., from distillation) and/or want to preserve all annotations @@ -170,7 +170,7 @@ ######################################################################## # Related Examples -# ---------- +# --------------- # # - :doc:`annotate_and_save_structures` - Learn how to add custom annotations to structures and save them for later use # - :doc:`pocket_conditioning_transform` - Create custom transforms for ligand pocket identification and ML feature generation diff --git a/docs/examples/pocket_conditioning_transform.py b/docs/examples/pocket_conditioning_transform.py index 668edcf7..01b683cf 100644 --- a/docs/examples/pocket_conditioning_transform.py +++ b/docs/examples/pocket_conditioning_transform.py @@ -29,7 +29,7 @@ # Conventions # ----------- # **A.** Store information in ``AtomArray`` annotations, not in the state dictionary. -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # This ensures robustness when atoms are added/removed downstream. # @@ -39,7 +39,7 @@ # - ❌ Store ``pocket_atom_indices`` in dictionary (which creates significant dependencies with operations that delete or re-order atoms) # # **B.** Within ``forward()``, call a stand-alone function with the same name as the transform class. -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # We thus maintain an object-oriented and a functional API, making our core logic re-usable and testable outside of the ``Transform`` framework. # @@ -51,7 +51,7 @@ # Additionally, this function should preserve the input (e.g., not modify the underlying ``AtomArray``) and take as arguments any necessary parameters. # # **C.** Each ``Transform`` should follow the single-responsibility-principle; in particular separate Annotation from Featurization ``Transforms`` -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # To ensure our ``Transform`` code is maximally forward-compatible and re-usable across disparate pipelines, we adhere to the single responsibility principle - that is, each transform should do *exactly one* action. # @@ -88,7 +88,7 @@ ######################################################################## # Building ``AnnotateLigandPockets`` -# =============================== +# ================================== # # Let's create a ``Transform`` that identifies atoms near ligands (non-polymer molecules) of sufficient size. # @@ -209,7 +209,7 @@ def forward(self, data: dict) -> dict: ######################################################################## # Building ``FeaturizePocketAtoms`` -# ============================== +# ================================= # # Now let's create a model-specific transform that converts derived pocket annotations into numeric features. # diff --git a/docs/io.rst b/docs/io.rst index 9f94cdec..322558e5 100644 --- a/docs/io.rst +++ b/docs/io.rst @@ -4,9 +4,6 @@ IO .. toctree:: :maxdepth: 2 - io/common - io/constants - io/enums io/parser io/tools io/transforms diff --git a/docs/io/common.rst b/docs/io/common.rst deleted file mode 100644 index 9b963d94..00000000 --- a/docs/io/common.rst +++ /dev/null @@ -1,7 +0,0 @@ -common -====== - -.. automodule:: atomworks.io.common - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/io/constants.rst b/docs/io/constants.rst deleted file mode 100644 index bb3abb5e..00000000 --- a/docs/io/constants.rst +++ /dev/null @@ -1,7 +0,0 @@ -constants -====== - -.. automodule:: atomworks.io.constants - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/io/enums.rst b/docs/io/enums.rst deleted file mode 100644 index 35e8c960..00000000 --- a/docs/io/enums.rst +++ /dev/null @@ -1,7 +0,0 @@ -enums -===== - -.. automodule:: atomworks.io.enums - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/io/parser.rst b/docs/io/parser.rst index adcb68a8..901ffde1 100644 --- a/docs/io/parser.rst +++ b/docs/io/parser.rst @@ -1,5 +1,5 @@ parser -==== +====== .. automodule:: atomworks.io.parser :members: diff --git a/docs/io/tools/fasta.rst b/docs/io/tools/fasta.rst index 1ccf7145..b106de84 100644 --- a/docs/io/tools/fasta.rst +++ b/docs/io/tools/fasta.rst @@ -1,5 +1,5 @@ FASTA Tools -========== +=========== .. automodule:: atomworks.io.tools.fasta :members: diff --git a/docs/io/tools/inference.rst b/docs/io/tools/inference.rst index bca36db2..4b7ec663 100644 --- a/docs/io/tools/inference.rst +++ b/docs/io/tools/inference.rst @@ -1,5 +1,5 @@ Inference Tools -============== +=============== .. automodule:: atomworks.io.tools.inference :members: diff --git a/docs/io/transforms/atom_array.rst b/docs/io/transforms/atom_array.rst index 28045f71..e39345fd 100644 --- a/docs/io/transforms/atom_array.rst +++ b/docs/io/transforms/atom_array.rst @@ -1,5 +1,5 @@ Atom Array Transforms -=================== +==================== .. automodule:: atomworks.io.transforms.atom_array :members: diff --git a/docs/io/transforms/categories.rst b/docs/io/transforms/categories.rst index f069af86..65789f8d 100644 --- a/docs/io/transforms/categories.rst +++ b/docs/io/transforms/categories.rst @@ -1,5 +1,5 @@ Category Transforms -================= +================== .. automodule:: atomworks.io.transforms.categories :members: diff --git a/docs/io/utils/ccd.rst b/docs/io/utils/ccd.rst index a3758a4a..947dcd4b 100644 --- a/docs/io/utils/ccd.rst +++ b/docs/io/utils/ccd.rst @@ -1,5 +1,5 @@ CCD Utilities -============ +============= .. automodule:: atomworks.io.utils.ccd :members: diff --git a/docs/io/utils/io_utils.rst b/docs/io/utils/io_utils.rst index 576dd2f0..1d453546 100644 --- a/docs/io/utils/io_utils.rst +++ b/docs/io/utils/io_utils.rst @@ -1,5 +1,5 @@ I/O Utilities -============ +============= .. automodule:: atomworks.io.utils.io_utils :members: diff --git a/docs/io/utils/sequence.rst b/docs/io/utils/sequence.rst index aae48e65..f8782b63 100644 --- a/docs/io/utils/sequence.rst +++ b/docs/io/utils/sequence.rst @@ -1,5 +1,5 @@ Sequence Utilities -================ +================== .. automodule:: atomworks.io.utils.sequence :members: diff --git a/docs/ml.rst b/docs/ml.rst index d4476fc2..4618eb95 100644 --- a/docs/ml.rst +++ b/docs/ml.rst @@ -7,8 +7,6 @@ Core Modules .. toctree:: :maxdepth: 2 - ml/common - ml/enums ml/encoding_definitions ml/samplers @@ -19,14 +17,10 @@ Data Processing Modules :maxdepth: 2 ml/datasets - ml/datasets/parsers - ml/preprocessing - ml/preprocessing/utils ml/pipelines ml/transforms ml/transforms/diffusion ml/transforms/dna - ml/transforms/esm ml/transforms/feature_aggregation ml/transforms/msa ml/utils \ No newline at end of file diff --git a/docs/ml/common.rst b/docs/ml/common.rst deleted file mode 100644 index ebfb0df2..00000000 --- a/docs/ml/common.rst +++ /dev/null @@ -1,9 +0,0 @@ -Common Utilities -=============== - -This module contains common utilities and functions used throughout the atomworks.ml package. - -.. automodule:: atomworks.ml.common - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/ml/datasets.rst b/docs/ml/datasets.rst index 910d96a6..0dc8424e 100644 --- a/docs/ml/datasets.rst +++ b/docs/ml/datasets.rst @@ -1,7 +1,7 @@ Datasets ======== -This module contains dataset classes and utilities for loading and processing molecular data. +This module contains dataset classes and utilities for loading and processing molecular data using a modern, composable architecture. Core Dataset Classes -------------------- @@ -11,10 +11,19 @@ Core Dataset Classes :undoc-members: :show-inheritance: -Parsers -------- +Functional Loaders +------------------ -.. automodule:: atomworks.ml.datasets.parsers +.. automodule:: atomworks.ml.datasets.loaders :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: + +Dataset Architecture and Migration Guide +---------------------------------------- + +.. toctree:: + :maxdepth: 2 + + datasets/datasets + datasets/parsers \ No newline at end of file diff --git a/docs/ml/datasets/datasets.rst b/docs/ml/datasets/datasets.rst new file mode 100644 index 00000000..e12134ec --- /dev/null +++ b/docs/ml/datasets/datasets.rst @@ -0,0 +1,187 @@ +Dataset Architecture +==================== + +AtomWorks provides a modern, composable dataset architecture that separates data loading, processing, and transformation concerns. This approach replaces the legacy parser-based system with functional loaders and transform pipelines. + +.. warning:: + The metadata parser system (``atomworks.ml.datasets.parsers``) is **deprecated** and will be removed in a future version. + Use the new loader-based approach with ``FileDataset`` and ``PandasDataset`` instead. + +Modern Dataset Architecture +--------------------------- + +The current AtomWorks dataset system consists of three main components: + +1. **Datasets**: Container classes that manage data access and indexing +2. **Loaders**: Functions that process raw data into transform-ready format +3. **Transforms**: Pipelines that convert loaded data into model inputs + +Dataset Classes +--------------- + +.. automodule:: atomworks.ml.datasets.datasets + :members: + :undoc-members: + :show-inheritance: + +Functional Loaders +------------------ + +Loaders are functions that process raw dataset output (e.g., pandas Series) into a Transform-ready format. +They replace the legacy parser classes with a more flexible, functional approach. + +.. automodule:: atomworks.ml.datasets.loaders + :members: + :undoc-members: + :show-inheritance: + +Basic Usage Examples +~~~~~~~~~~~~~~~~~~~~ + +**File-based datasets** (replacing simple file parsers): + +.. code-block:: python + + from atomworks.ml.datasets.datasets import FileDataset + from atomworks.io import parse + + def simple_loading_fn(raw_data) -> dict: + """Simple loading function that parses structural data.""" + parse_output = parse(raw_data) + return {"atom_array": parse_output["assemblies"]["1"][0]} + + dataset = FileDataset.from_directory( + directory="/path/to/structures", + name="my_dataset", + loader=simple_loading_fn + ) + +**Tabular datasets** (replacing metadata parsers): + +.. code-block:: python + + from atomworks.ml.datasets.datasets import PandasDataset + from atomworks.ml.datasets.loaders import loader_with_query_pn_units + + dataset = PandasDataset( + data="metadata.parquet", + name="interfaces_dataset", + loader=loader_with_query_pn_units( + pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + ) + ) + +**Custom loaders** for specialized use cases: + +.. code-block:: python + + def custom_loader(row: pd.Series) -> dict: + """Custom loader with specific processing logic.""" + # Load structure + structure_path = Path(row["path"]) + parse_output = parse(structure_path) + + # Extract specific metadata + metadata = { + "resolution": row.get("resolution", None), + "method": row.get("method", "unknown"), + "custom_field": row.get("custom_field", "default_value") + } + + return { + "atom_array": parse_output["assemblies"]["1"][0], + "extra_info": metadata, + "example_id": row["example_id"] + } + + dataset = PandasDataset( + data=my_dataframe, + name="custom_dataset", + loader=custom_loader + ) + +Common Loader Patterns +~~~~~~~~~~~~~~~~~~~~~~ + +**Base loader** for standard structure loading: + +.. code-block:: python + + from atomworks.ml.datasets.loaders import loader_base + + loader = loader_base( + example_id_colname="example_id", + path_colname="path", + assembly_id_colname="assembly_id", + base_path="/data/structures", + extension=".cif" + ) + +**Interface loader** for protein-protein interfaces: + +.. code-block:: python + + from atomworks.ml.datasets.loaders import loader_with_query_pn_units + + loader = loader_with_query_pn_units( + pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], + base_path="/data/pdb", + extension=".cif.gz" + ) + +**Validation loader** with scoring targets: + +.. code-block:: python + + from atomworks.ml.datasets.loaders import loader_with_interfaces_and_pn_units_to_score + + loader = loader_with_interfaces_and_pn_units_to_score( + interfaces_to_score_colname="interfaces_to_score", + pn_units_to_score_colname="pn_units_to_score" + ) + +Integration with Transform Pipelines +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Loaders work seamlessly with AtomWorks transform pipelines. The loader output becomes the input to the transform pipeline: + +.. code-block:: python + + from atomworks.ml.transforms.base import Compose + from atomworks.ml.transforms.crop import CropSpatialLikeAF3 + from atomworks.ml.transforms.atom_array import AddGlobalAtomIdAnnotation + + # Create a transform pipeline + transform_pipeline = Compose([ + AddGlobalAtomIdAnnotation(), + CropSpatialLikeAF3(crop_size=256), + ]) + + # Create dataset with both loader and transforms + dataset = PandasDataset( + data="metadata.parquet", + name="my_dataset", + loader=loader_with_query_pn_units( + pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + ), + transform=transform_pipeline + ) + + # Access processed data + example = dataset[0] # Returns transformed data ready for model input + +Data Flow +~~~~~~~~~ + +The complete data flow in the new architecture is: + +1. **Raw Data**: File paths or DataFrame rows +2. **Loader**: Processes raw data into standardized format with ``AtomArray`` +3. **Transform Pipeline**: Converts loaded data into model-ready tensors +4. **Model Input**: Final processed data ready for training/inference + +This separation allows for: +- **Reusable loaders** across different datasets +- **Composable transforms** that can be mixed and matched +- **Easy testing** of individual components +- **Clear debugging** when issues arise diff --git a/docs/ml/datasets/parsers.rst b/docs/ml/datasets/parsers.rst index 4556ffc5..53a1edf0 100644 --- a/docs/ml/datasets/parsers.rst +++ b/docs/ml/datasets/parsers.rst @@ -1,28 +1,22 @@ Dataset Parsers =============== -This module contains parsers for different types of dataset metadata and structures. - -Base Parser ------------ +.. automodule:: atomworks.ml.datasets.parsers + :members: + :undoc-members: + :show-inheritance: .. automodule:: atomworks.ml.datasets.parsers.base :members: :undoc-members: :show-inheritance: -Custom Metadata Parsers ----------------------- - .. automodule:: atomworks.ml.datasets.parsers.custom_metadata_row_parsers :members: :undoc-members: :show-inheritance: -Default Metadata Parsers ------------------------ - .. automodule:: atomworks.ml.datasets.parsers.default_metadata_row_parsers :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/ml/encoding_definitions.rst b/docs/ml/encoding_definitions.rst index 334c6cd8..0e74107e 100644 --- a/docs/ml/encoding_definitions.rst +++ b/docs/ml/encoding_definitions.rst @@ -1,5 +1,5 @@ Encoding Definitions -=================== +==================== This module contains definitions for various encoding schemes used in the atomworks.ml package. diff --git a/docs/ml/enums.rst b/docs/ml/enums.rst deleted file mode 100644 index 83e5d8e4..00000000 --- a/docs/ml/enums.rst +++ /dev/null @@ -1,9 +0,0 @@ -Enums -===== - -This module contains enumeration classes used throughout the atomworks.ml package. - -.. automodule:: atomworks.ml.enums - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/ml/preprocessing.rst b/docs/ml/preprocessing.rst index a51043d1..1fe05690 100644 --- a/docs/ml/preprocessing.rst +++ b/docs/ml/preprocessing.rst @@ -4,7 +4,7 @@ Preprocessing This module contains utilities for preprocessing molecular structures and data. Core Preprocessing Functions ---------------------------- +---------------------------- .. automodule:: atomworks.ml.preprocessing.get_pn_unit_data_from_structure :members: diff --git a/docs/ml/preprocessing/utils.rst b/docs/ml/preprocessing/utils.rst deleted file mode 100644 index 4a794a2a..00000000 --- a/docs/ml/preprocessing/utils.rst +++ /dev/null @@ -1,28 +0,0 @@ -Preprocessing Utilities -====================== - -This module contains utility functions for preprocessing tasks. - -Clustering Utilities -------------------- - -.. automodule:: atomworks.ml.preprocessing.utils.clustering - :members: - :undoc-members: - :show-inheritance: - -FASTA Utilities ---------------- - -.. automodule:: atomworks.ml.preprocessing.utils.fasta - :members: - :undoc-members: - :show-inheritance: - -Structure Utilities ------------------- - -.. automodule:: atomworks.ml.preprocessing.utils.structure_utils - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/ml/transforms.rst b/docs/ml/transforms.rst index c13d5804..74ccc7ad 100644 --- a/docs/ml/transforms.rst +++ b/docs/ml/transforms.rst @@ -1,10 +1,10 @@ Transforms -========= +========== This module contains various transformation classes and utilities for processing molecular data. Core Transform Classes --------------------- +----------------------- .. automodule:: atomworks.ml.transforms.base :members: @@ -87,7 +87,7 @@ Core Transform Classes :show-inheritance: Utility Modules -------------- +--------------- .. automodule:: atomworks.ml.transforms._checks :members: @@ -115,10 +115,10 @@ Utility Modules :show-inheritance: Submodules ---------- +----------- DNA Transforms -~~~~~~~~~~~~ +~~~~~~~~~~~~~~ .. automodule:: atomworks.ml.transforms.dna :members: @@ -126,23 +126,15 @@ DNA Transforms :show-inheritance: Diffusion Transforms -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ .. automodule:: atomworks.ml.transforms.diffusion :members: :undoc-members: :show-inheritance: -ESM Transforms -~~~~~~~~~~~~ - -.. automodule:: atomworks.ml.transforms.esm - :members: - :undoc-members: - :show-inheritance: - Feature Aggregation Transforms -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: atomworks.ml.transforms.feature_aggregation :members: @@ -150,7 +142,7 @@ Feature Aggregation Transforms :show-inheritance: MSA Transforms -~~~~~~~~~~~~ +~~~~~~~~~~~~~~ .. automodule:: atomworks.ml.transforms.msa :members: diff --git a/docs/ml/transforms/diffusion.rst b/docs/ml/transforms/diffusion.rst index a7b5322b..d58e0ff5 100644 --- a/docs/ml/transforms/diffusion.rst +++ b/docs/ml/transforms/diffusion.rst @@ -1,5 +1,5 @@ Diffusion Transforms -================== +==================== This module contains transformations for diffusion-based structure processing. diff --git a/docs/ml/transforms/dna.rst b/docs/ml/transforms/dna.rst index 754f3b05..08565a6f 100644 --- a/docs/ml/transforms/dna.rst +++ b/docs/ml/transforms/dna.rst @@ -1,5 +1,5 @@ DNA Transforms -============= +============== This module contains transformations specific to DNA processing. diff --git a/docs/ml/transforms/esm.rst b/docs/ml/transforms/esm.rst deleted file mode 100644 index 1bf44857..00000000 --- a/docs/ml/transforms/esm.rst +++ /dev/null @@ -1,9 +0,0 @@ -ESM Transforms -============= - -This module contains transformations specific to ESM models and features. - -.. automodule:: atomworks.ml.transforms.esm - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/ml/utils.rst b/docs/ml/utils.rst index ebd4970a..2d5db923 100644 --- a/docs/ml/utils.rst +++ b/docs/ml/utils.rst @@ -36,7 +36,7 @@ I/O Utilities :show-inheritance: Miscellaneous Utilities ----------------------- +----------------------- .. automodule:: atomworks.ml.utils.misc :members: @@ -44,7 +44,7 @@ Miscellaneous Utilities :show-inheritance: Nested Dictionary Utilities --------------------------- +--------------------------- .. automodule:: atomworks.ml.utils.nested_dict :members: @@ -60,7 +60,7 @@ NumPy Utilities :show-inheritance: Random Number Generation ------------------------ +------------------------ .. automodule:: atomworks.ml.utils.rng :members: diff --git a/src/atomworks/__init__.py b/src/atomworks/__init__.py index bb4e2848..25956688 100644 --- a/src/atomworks/__init__.py +++ b/src/atomworks/__init__.py @@ -1,8 +1,8 @@ -""" -atomworks - Unified package for biological data I/O and machine learning. +"""Unified package for biological data I/O and machine learning. -This package combines functionality from atomworks.io (I/O operations) and atomworks.ml (ML utilities) -into a unified interface for biological data processing and machine learning. +This package combines functionality from :mod:`atomworks.io` (I/O operations) and +:mod:`atomworks.ml` (ML utilities) into a unified interface for biological data +processing and machine learning. """ import importlib diff --git a/src/atomworks/biotite_patch.py b/src/atomworks/biotite_patch.py index a1af4cc6..66743ff2 100644 --- a/src/atomworks/biotite_patch.py +++ b/src/atomworks/biotite_patch.py @@ -1,4 +1,12 @@ -"""Collection of monkey patches for biotite.""" +"""Collection of monkey patches for biotite. + +This module provides patches and extensions to the Biotite library to enhance +functionality and fix version-specific issues. + +References: + `Biotite Documentation `_ + `Biotite Structure Module `_ +""" from typing import Callable @@ -16,8 +24,7 @@ def apply_if_version_lt(version: str, min_version: str) -> Callable: - """ - Decorator to apply a function only if the given version is less than the given minimal version. + """Decorator to apply a function only if the given version is less than the given minimal version. Args: version: Version to check. diff --git a/src/atomworks/common.py b/src/atomworks/common.py index ff72da25..6559b88e 100644 --- a/src/atomworks/common.py +++ b/src/atomworks/common.py @@ -1,4 +1,4 @@ -"""Common functions used throughout the project.""" +"""Common utility functions used throughout the project.""" import copy import hashlib @@ -158,11 +158,11 @@ class KeyToIntMapper: to integers. Example: - chain_id_to_int = KeyToIntMapper() - chain_id_to_int("A") # 0 - chain_id_to_int("C") # 1 - chain_id_to_int("A") # 0 - chain_id_to_int("B") # 2 + >>> chain_id_to_int = KeyToIntMapper() + >>> chain_id_to_int("A") # 0 + >>> chain_id_to_int("C") # 1 + >>> chain_id_to_int("A") # 0 + >>> chain_id_to_int("B") # 2 """ def __init__(self): diff --git a/src/atomworks/constants.py b/src/atomworks/constants.py index c10ca5cf..100d71e2 100644 --- a/src/atomworks/constants.py +++ b/src/atomworks/constants.py @@ -16,7 +16,14 @@ def _load_env_var(var_name: str) -> str | None: - """Load an environment variable, returning None if it is not set.""" + """Load an environment variable, returning None if it is not set. + + Args: + var_name: The name of the environment variable to load. + + Returns: + The value of the environment variable, or None if not set. + """ try: return os.environ[var_name] except KeyError: @@ -32,10 +39,18 @@ def _load_env_var(var_name: str) -> str | None: CCD_MIRROR_PATH: Final[str] = _load_env_var("CCD_MIRROR_PATH") -"""A path to a carbon-copy mirror of the CCD ligands in the RCSB CCD.""" +"""A path to a carbon-copy mirror of the CCD ligands in the RCSB CCD. + +Reference: + `RCSB Chemical Component Dictionary `_ +""" PDB_MIRROR_PATH: Final[str] = _load_env_var("PDB_MIRROR_PATH") -"""A path to a mirror of the PDB.""" +"""A path to a mirror of the PDB. + +Reference: + `Protein Data Bank `_ +""" UNKNOWN_ELEMENT: Final[str] = "X" """The element name for an unknown element.""" @@ -59,13 +74,27 @@ def _load_env_var(var_name: str) -> str | None: "Rg": 111, "Cn": 112, "Nh": 113, "Fl": 114, "Mc": 115, "Lv": 116, "Ts": 117, "Og": 118, UNKNOWN_ELEMENT: UNKNOWN_ATOMIC_NUMBER })) -"""Map canonical *UPPERCASE* 2 letter element names to their atomic numbers. WARNING: Case-sensitive.""" +"""Map canonical *UPPERCASE* 2 letter element names to their atomic numbers. + +Warning: + Case-sensitive. + +Reference: + `IUPAC Periodic Table `_ +""" ATOMIC_NUMBER_TO_ELEMENT: Final[MappingProxyType[int | str, str]] = MappingProxyType( {v: k for k, v in ELEMENT_NAME_TO_ATOMIC_NUMBER.items()} | {str(v): k for k, v in ELEMENT_NAME_TO_ATOMIC_NUMBER.items()} ) -"""Map atomic numbers (int/str) to their canonical *UPPERCASE* 2 letter element names. WARNING: Case-sensitive.""" +"""Map atomic numbers (int/str) to their canonical *UPPERCASE* 2 letter element names. + +Warning: + Case-sensitive. + +Reference: + `IUPAC Periodic Table `_ +""" METAL_ELEMENTS: Final[frozenset[str]] = frozenset(map(str.upper, [ "Li", "Na", "K", "Rb", "Cs", "Be", "Mg", "Ca", "Sr", "Ba", @@ -74,7 +103,14 @@ def _load_env_var(var_name: str) -> str | None: "La", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Al", "Ga", "In", "Sn", "Tl", "Pb", "Bi", ])) -"""A set of all metal elements, all *UPPERCASE*. WARNING: Case-sensitive.""" +"""A set of all metal elements, all *UPPERCASE*. + +Warning: + Case-sensitive. + +Reference: + `IUPAC Periodic Table - Metals `_ +""" # fmt: on CHEM_COMP_TYPES: Final[tuple[str, ...]] = tuple( @@ -114,10 +150,11 @@ def _load_env_var(var_name: str) -> str | None: ] ) """Allowed Chemical Component Types for residues in the PDB + `mask`. + All uppercase. Reference: - - http://mmcif.rcsb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_chem_comp.type.html + `RCSB mmCIF Dictionary - chem_comp.type `_ """ AA_LIKE_CHEM_TYPES: Final[frozenset[str]] = frozenset( @@ -273,7 +310,7 @@ def _load_env_var(var_name: str) -> str | None: """A set of bond types that are considered when adding bonds to the atom array. Reference: - - https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_struct_conn.conn_type_id.html + `struct_conn.conn_type_id `_ """ STRUCT_CONN_BOND_ORDER_TO_INT: Final[MappingProxyType[str, int]] = MappingProxyType( @@ -287,8 +324,8 @@ def _load_env_var(var_name: str) -> str | None: """ Mapping from `struct_conn.pdbx_value_order` to integer bond orders. -References: - - https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_struct_conn.pdbx_value_order.html +Reference: + `struct_conn.pdbx_value_order `_ """ BIOTITE_BOND_TYPE_TO_BOND_ORDER: Final[MappingProxyType[BondType, int]] = MappingProxyType( @@ -320,7 +357,7 @@ def _load_env_var(var_name: str) -> str | None: Only elements that have unambiguous valences are included. Reference: - - https://www.rdkit.org/docs/RDKit_Book.html#valence-calculation-and-allowed-valences + `RDKit Book - Valence Calculation `_ """ CRYSTALLIZATION_AIDS: Final[list[str]] = [ @@ -344,7 +381,7 @@ def _load_env_var(var_name: str) -> str | None: """A list of CCD codes of common crystallization aids used in the crystallization of proteins. Reference: - - AF3 (Supp. Table 9) https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 (Supp. Table 9) `_ """ AF3_EXCLUDED_LIGANDS: Final[list[str]] = [ @@ -483,7 +520,7 @@ def _load_env_var(var_name: str) -> str | None: """A list of CCD codes of ligands that were excluded in AF3. Reference: - - AF3 (Supp. Table 10) https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 (Supp. Table 10) `_ """ AF3_EXCLUDED_LIGANDS_REGEX: Final[str] = r"(?:^|,)\s*(?:" + "|".join(AF3_EXCLUDED_LIGANDS) + r")\s*(?:,|$)" @@ -519,21 +556,21 @@ def _load_env_var(var_name: str) -> str | None: """A dictionary that maps three-letter amino acid codes to one-letter codes. Reference: - - Biotite: https://github.com/biotite-dev/biotite/blob/v0.41.0/src/biotite/sequence/seqtypes.py#L348-L556 + `Biotite seqtypes.py `_ """ UNKNOWN_LIGAND: Final[str] = sys.intern("UNL") """The CCD code for unknown ligands (`UNL`). Reference: - - https://www.wwpdb.org/documentation/procedure + `wwPDB Documentation `_ """ UNKNOWN_AA: Final[str] = sys.intern("UNK") """The CCD code for unknown amino acids (`UNK`). Reference: - - https://www.wwpdb.org/documentation/procedure + `wwPDB Documentation `_ """ # TODO: Change these to something unique. @@ -541,21 +578,21 @@ def _load_env_var(var_name: str) -> str | None: """The CCD code for unknown RNA nucleotides (`N`). Reference: - - https://www.wwpdb.org/documentation/procedure + `wwPDB Documentation `_ """ UNKNOWN_DNA: Final[str] = sys.intern("DN") """The CCD code for unknown DNA nucleotides (`DN`). Reference: - - https://www.wwpdb.org/documentation/procedure + `wwPDB Documentation `_ """ UNKNOWN_ATOM: Final[str] = sys.intern("UNX") """The CCD code for unknown atoms (`UNX`). Reference: - - https://www.wwpdb.org/documentation/procedure + `wwPDB Documentation `_ """ GAP: Final[str] = sys.intern("") diff --git a/src/atomworks/enums.py b/src/atomworks/enums.py index 11ba5bc9..b7d6a1a8 100644 --- a/src/atomworks/enums.py +++ b/src/atomworks/enums.py @@ -1,4 +1,4 @@ -"""Enums used accross `atomworks`.""" +"""Enums used across atomworks.""" from enum import IntEnum, StrEnum, auto from types import MappingProxyType @@ -19,13 +19,15 @@ class ChainType(IntEnum): """IntEnum representing the type of chain in a RCSB mmCIF file from the Protein Data Bank (PDB). - Useful constants relating to ChainType are defined in ChainTypeInfo. + Useful constants relating to ChainType are defined in :class:`ChainTypeInfo`. - Sources: - - https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_entity.type.html - - https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_entity_poly.type.html + Note: + The chain type fields in the PDB are not stable; note the specific versions + of the dictionaries used (updated November, 2024) - NOTE: The chain type fields in the PDB are not stable; note the specific versions of the dictionaries used (updated November, 2024) + References: + `RCSB mmCIF Dictionary - entity.type `_ + `RCSB mmCIF Dictionary - entity_poly.type `_ """ # Polymers @@ -46,7 +48,17 @@ class ChainType(IntEnum): @classmethod def from_string(cls, str_value: str) -> "ChainType": - """Convert a string to a ChainType enum.""" + """Convert a string to a ChainType enum. + + Args: + str_value: The string value to convert. + + Returns: + The corresponding ChainType enum. + + Raises: + ValueError: If the string value is not a valid chain type. + """ try: return ChainTypeInfo.STRING_TO_ENUM[str_value.upper()] except KeyError: @@ -56,36 +68,67 @@ def from_string(cls, str_value: str) -> "ChainType": @staticmethod def get_chain_type_strings() -> list[str]: - """Get a list of all chain type strings.""" + """Get a list of all chain type strings. + + Returns: + List of all valid chain type strings. + """ return list(ChainTypeInfo.STRING_TO_ENUM.keys()) @staticmethod def get_polymers() -> list["ChainType"]: - """Get a list of all polymer chain types.""" + """Get a list of all polymer chain types. + + Returns: + List of polymer chain types. + """ return ChainTypeInfo.POLYMERS @staticmethod def get_non_polymers() -> list["ChainType"]: - """Get a list of all non-polymer chain types.""" + """Get a list of all non-polymer chain types. + + Returns: + List of non-polymer chain types. + """ return ChainTypeInfo.NON_POLYMERS @staticmethod def get_proteins() -> list["ChainType"]: - """Get a list of all protein chain types.""" + """Get a list of all protein chain types. + + Returns: + List of protein chain types. + """ return ChainTypeInfo.PROTEINS @staticmethod def get_nucleic_acids() -> list["ChainType"]: - """Get a list of all nucleic acid chain types.""" + """Get a list of all nucleic acid chain types. + + Returns: + List of nucleic acid chain types. + """ return ChainTypeInfo.NUCLEIC_ACIDS @staticmethod def get_all_types() -> list["ChainType"]: - """Get a list of all chain types.""" + """Get a list of all chain types. + + Returns: + List of all chain types. + """ return list(ChainType) def __eq__(self, other: Union["ChainType", int, str]) -> bool: - """Check if two ChainType enums are equal.""" + """Check if two ChainType enums are equal. + + Args: + other: Another ChainType, int, or string to compare with. + + Returns: + True if the chain types are equal, False otherwise. + """ if isinstance(other, ChainType): return self.value == other.value elif isinstance(other, int): @@ -101,44 +144,85 @@ def __eq__(self, other: Union["ChainType", int, str]) -> bool: return NotImplemented def __hash__(self): - """Hash a ChainType enum.""" + """Hash a ChainType enum. + + Returns: + Hash value of the enum. + """ return hash(self.value) def __str__(self) -> str: - """Convert a ChainType enum to a string.""" + """Convert a ChainType enum to a string. + + Returns: + String representation of the chain type. + """ return self.to_string() def get_valid_chem_comp_types(self) -> set[str]: - """Get the set of valid chemical component types for a ChainType.""" + """Get the set of valid chemical component types for a ChainType. + + Returns: + Set of valid chemical component types for this chain type. + """ return ChainTypeInfo.VALID_CHEM_COMP_TYPES[self] def is_protein(self) -> bool: - """Check if a ChainType is a protein.""" + """Check if a ChainType is a protein. + + Returns: + True if this chain type represents a protein, False otherwise. + """ return self in ChainTypeInfo.PROTEINS def is_nucleic_acid(self) -> bool: - """Check if a ChainType is a nucleic acid.""" + """Check if a ChainType is a nucleic acid. + + Returns: + True if this chain type represents a nucleic acid, False otherwise. + """ return self in ChainTypeInfo.NUCLEIC_ACIDS def is_polymer(self) -> bool: - """Check if a ChainType is a polymer.""" + """Check if a ChainType is a polymer. + + Returns: + True if this chain type represents a polymer, False otherwise. + """ return self in ChainTypeInfo.POLYMERS def is_non_polymer(self) -> bool: - """Check if a ChainType is a non-polymer.""" + """Check if a ChainType is a non-polymer. + + Returns: + True if this chain type represents a non-polymer, False otherwise. + """ return self in ChainTypeInfo.NON_POLYMERS def to_string(self) -> str: - """ - Convert a ChainType enum to a string. + """Convert a ChainType enum to a string. + + Note: + Returns UPPERCASE string (e.g., "POLYPEPTIDE(D)" instead of "polypeptide(D)") - NOTE: Returns UPPERCASE string (e.g., "POLYPEPTIDE(D)" instead of "polypeptide(D)") + Returns: + Uppercase string representation of the chain type. """ return ChainTypeInfo.ENUM_TO_STRING[self] @staticmethod def as_enum(value: Union[str, int, "ChainType"]) -> "ChainType": - """Convert a string, int, or ChainType to a ChainType enum.""" + """Convert a string, int, or ChainType to a ChainType enum. + + Args: + value: The value to convert to a ChainType enum. + + Returns: + The corresponding ChainType enum. + + Raises: + ValueError: If the value cannot be converted to a ChainType. + """ if isinstance(value, ChainType): return value elif isinstance(value, str): @@ -150,10 +234,10 @@ def as_enum(value: Union[str, int, "ChainType"]) -> "ChainType": class ChainTypeInfo: - """ - Companion class containing metadata and helper methods for ChainType enum. + """Companion class containing metadata and helper methods for ChainType enum. - This class should not be instantiated - it serves as a namespace for ChainType-related constants and utilities. + This class should not be instantiated - it serves as a namespace for + ChainType-related constants and utilities. """ POLYMERS: Final[tuple[ChainType, ...]] = ( @@ -253,11 +337,17 @@ class GroundTruthConformerPolicy(IntEnum): """Enum for ground truth conformer policy. Possible values are: - - REPLACE: Use the ground-truth coordinates as the reference conformer, replacing the coordinated generated by RDKit in-place (and add a flag to indicate that the coordinates were replaced) - - ADD: Return an additional feature (with the same shape as `ref_pos`) containing the ground-truth coordinates - - FALLBACK: Use the ground-truth coordinates only if our standard conformer generation pipeline fails (e.g., we cannot generate a conformer with RDKit, - and the molecule is either not in the CCD or the CCD entry is invalid) - - IGNORE: Do not use the ground-truth coordinates as the reference conformer under any circumstances + - REPLACE: Use the ground-truth coordinates as the reference conformer, + replacing the coordinates generated by RDKit in-place (and add a flag + to indicate that the coordinates were replaced) + - ADD: Return an additional feature (with the same shape as ref_pos) + containing the ground-truth coordinates + - FALLBACK: Use the ground-truth coordinates only if our standard + conformer generation pipeline fails (e.g., we cannot generate a + conformer with RDKit, and the molecule is either not in the CCD or + the CCD entry is invalid) + - IGNORE: Do not use the ground-truth coordinates as the reference + conformer under any circumstances """ REPLACE = 1 @@ -270,9 +360,9 @@ class HydrogenPolicy(StrEnum): """Enum for hydrogen policy. Possible values are: - - KEEP: Keep the hydrogens as they are - - REMOVE: Remove the hydrogens - - INFER: Infer the hydrogens from the atom array + - KEEP: Keep the hydrogens as they are + - REMOVE: Remove the hydrogens + - INFER: Infer the hydrogens from the atom array """ KEEP = auto() diff --git a/src/atomworks/io/__init__.py b/src/atomworks/io/__init__.py index d545e9ca..504d52bb 100644 --- a/src/atomworks/io/__init__.py +++ b/src/atomworks/io/__init__.py @@ -1,5 +1,4 @@ -""" -atomworks.io - Input/Output operations for biological data structures. +"""Input/Output operations for biological data structures. This subpackage provides functionality for parsing, converting, and manipulating biological data formats, originally from the atomworks.io package. diff --git a/src/atomworks/io/parser.py b/src/atomworks/io/parser.py index 7d8dc3f5..cb0956d1 100644 --- a/src/atomworks/io/parser.py +++ b/src/atomworks/io/parser.py @@ -1,4 +1,12 @@ -"""Entrypoint for parsing atomic-level structure files (e.g., PDB, CIF) into Biotite-compatible data structures.""" +"""Entrypoint for parsing atomic-level structure files into Biotite-compatible data structures. + +This module provides functionality for parsing PDB, CIF, and other structure files +into Biotite-compatible data structures with various processing options. + +References: + `Biotite Structure I/O `_ + `mmCIF Format Specification `_ +""" from __future__ import annotations @@ -102,22 +110,22 @@ def parse( - Perform analogous cleaning/processing steps on an existing AtomArray or AtomArrayStack. We categorize arguments into two groups: - - Wrapper arguments: Arguments that are used within the wrapping `parse` method (e.g., caching) + - Wrapper arguments: Arguments that are used within the wrapping parse method (e.g., caching) - CIF parsing arguments: Arguments that control structure parsing and are ultimately are passed - to the `_parse_from_atom_array` method (regardless of file type, we convert to an AtomArray before parsing) + to the _parse_from_atom_array method (regardless of file type, we convert to an AtomArray before parsing) Args: filename (PathLike | io.StringIO | io.BytesIO): Either a Path or buffer to the file. This may be any format of - atomic-level structure (e.g. .cif, .bcif, .cif.gz, .pdb), although .cif files are *strongly* recommended. + atomic-level structure (e.g. .cif, .bcif, .cif.gz, .pdb), although .cif files are strongly recommended. - *** Wrapper arguments *** + **Wrapper arguments:** file_type (Literal["cif", "pdb"] | None, optional): The file type of the structure file. If not provided, the file type will be inferred automatically. load_from_cache (bool, optional): Whether to load pre-compiled results from cache. Defaults to False. cache_dir (PathLike, optional): Directory path to save pre-compiled results. Defaults to None. save_to_cache (bool, optional): Whether to save the results to cache when building the structure. Defaults to False. - *** Parsing arguments *** + **Parsing arguments:** ccd_mirror_path (str, optional): Path to the local mirror of the Chemical Component Dictionary (recommended). If not provided, Biotite's built-in CCD will be used. add_missing_atoms (bool, optional): Whether to add missing atoms to the @@ -155,14 +163,21 @@ def parse( Returns: dict: A dictionary containing the following keys: - chain_info: A dictionary mapping chain ID to sequence, type (as an IntEnum), RCSB entity, + + chain_info + A dictionary mapping chain ID to sequence, type (as an IntEnum), RCSB entity, EC number, and other information. - ligand_info: A dictionary containing ligand of interest information. - asym_unit: An AtomArrayStack instance representing the asymmetric unit. - assemblies: A dictionary mapping assembly IDs to AtomArrayStack instances. - metadata: A dictionary containing metadata about the structure + ligand_info + A dictionary containing ligand of interest information. + asym_unit + An AtomArrayStack instance representing the asymmetric unit. + assemblies + A dictionary mapping assembly IDs to AtomArrayStack instances. + metadata + A dictionary containing metadata about the structure (e.g., resolution, deposition date, etc.). - extra_info: A dictionary with information for cross-compatibility and caching. + extra_info + A dictionary with information for cross-compatibility and caching. Should typically not be used directly. """ diff --git a/src/atomworks/io/tools/rdkit.py b/src/atomworks/io/tools/rdkit.py index c7b25d3d..05c9b575 100644 --- a/src/atomworks/io/tools/rdkit.py +++ b/src/atomworks/io/tools/rdkit.py @@ -54,7 +54,8 @@ """ Mapping from RDKit hybridization types to integers. -Reference: https://www.rdkit.org/docs/cppapi/classRDKit_1_1Atom.html#a58e40e30db6b42826243163175cac976 +Reference: + `RDKit Atom Documentation `_ """ RDKIT_BOND_TYPE_TO_BIOTITE: Final[dict[tuple[Chem.BondType, bool], struc.bonds.BondType]] = { @@ -108,8 +109,8 @@ class ChEMBLNormalizer: This is useful for `rescuing` molecules that failed to be sanitized by RDKit alone. - References: - - https://github.com/chembl/ChEMBL_Structure_Pipeline/blob/master/chembl_structure_pipeline/standardizer.py#L33C1-L73C15 + Reference: + `ChEMBL Structure Pipeline `_ """ def __init__(self): @@ -290,9 +291,9 @@ def fix_mol( References: - - https://www.rdkit.org/docs/RDKit_Book.html#molecular-sanitization - - https://github.com/chembl/ChEMBL_Structure_Pipeline/blob/master/chembl_structure_pipeline/standardizer.py - - https://github.com/datamol-io/datamol/blob/0312388b956e2b4eeb72d791167cfdb873c7beab/datamol/mol.py + `RDKit Molecular Sanitization `_ + `ChEMBL Structure Pipeline `_ + `datamol mol.py `_ """ if not in_place: @@ -379,8 +380,8 @@ def get_morgan_fingerprint_from_rdkit_mol(mol: Chem.Mol, *, radius: int = 2, n_b - ExplicitBitVect: The Morgan fingerprint for the input molecule. References: - - AF-3 Supplement - - https://greglandrum.github.io/rdkit-blog/posts/2023-01-18-fingerprint-generator-tutorial.html + AF-3 Supplement + `RDKit Fingerprint Generator Tutorial `_ """ morgan_fingerprint_generator = rdFingerprintGenerator.GetMorganGenerator(radius=radius, fpSize=n_bits) fingerprint = morgan_fingerprint_generator.GetFingerprint(mol) diff --git a/src/atomworks/io/transforms/atom_array.py b/src/atomworks/io/transforms/atom_array.py index 7f9c2e5c..d788e4f1 100644 --- a/src/atomworks/io/transforms/atom_array.py +++ b/src/atomworks/io/transforms/atom_array.py @@ -1,6 +1,6 @@ -""" -Transforms operating predominantly on Biotite's `AtomArray` objects. -These operations should take as input, and return, `AtomArray` objects. +"""Transforms operating predominantly on Biotite's AtomArray objects. + +These operations should take as input, and return, AtomArray objects. """ import logging @@ -32,7 +32,15 @@ def subset_atom_array(atom_array: AtomArray | AtomArrayStack, keep: np.ndarray) -> AtomArray | AtomArrayStack: - """Subsets an AtomArray or AtomArrayStack by a boolean mask.""" + """Subsets an AtomArray or AtomArrayStack by a boolean mask. + + Args: + atom_array: The AtomArray or AtomArrayStack to subset. + keep: Boolean mask indicating which atoms to keep. + + Returns: + The subsetted AtomArray or AtomArrayStack. + """ if isinstance(atom_array, AtomArrayStack): return atom_array[:, keep] else: @@ -40,7 +48,14 @@ def subset_atom_array(atom_array: AtomArray | AtomArrayStack, keep: np.ndarray) def is_any_coord_nan(atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Returns a boolean mask of shape [n_atoms] indicating whether any coordinate is NaN for each atom in the AtomArray or AtomArrayStack.""" + """Returns a boolean mask indicating whether any coordinate is NaN for each atom. + + Args: + atom_array: The AtomArray or AtomArrayStack to check. + + Returns: + Boolean mask of shape [n_atoms] indicating NaN coordinates. + """ if isinstance(atom_array, AtomArrayStack): return np.isnan(atom_array.coord).any(axis=(0, -1)) else: diff --git a/src/atomworks/io/transforms/categories.py b/src/atomworks/io/transforms/categories.py index f8002088..eabea63a 100644 --- a/src/atomworks/io/transforms/categories.py +++ b/src/atomworks/io/transforms/categories.py @@ -1,5 +1,4 @@ -""" -Transforms operating on Biotite's CIFBlock and CIFCategory objects. +"""Transforms operating on Biotite's CIFBlock and CIFCategory objects. These transforms are used to extract information from the CIFBlock and return a dictionary containing processed information. """ @@ -27,12 +26,28 @@ def category_to_df(cif_block: CIFBlock, category: str) -> pd.DataFrame | None: - """Convert a CIF block to a pandas DataFrame.""" + """Convert a CIF block to a pandas DataFrame. + + Args: + cif_block: The CIF block to convert. + category: The category name to extract. + + Returns: + DataFrame containing the category data, or None if category doesn't exist. + """ return pd.DataFrame(category_to_dict(cif_block, category)) if category in cif_block else None def category_to_dict(cif_block: CIFBlock, category: str) -> dict[str, np.ndarray]: - """Convert a CIF block to a dictionary.""" + """Convert a CIF block to a dictionary. + + Args: + cif_block: The CIF block to convert. + category: The category name to extract. + + Returns: + Dictionary containing the category data as numpy arrays. + """ if exists(cif_block.get(category)): return toolz.valmap(lambda x: x.as_array(), dict(cif_block[category])) else: @@ -303,7 +318,7 @@ def get_ligand_of_interest_info(cif_block: CIFBlock) -> dict: """Extract ligand of interest information from a CIF block. Reference: - - https://pdb101.rcsb.org/learn/guide-to-understanding-pdb-data/small-molecule-ligands + `PDB101 Small Molecule Ligands Guide `_ """ # Extract binary flag for whether the ligand of interest is specified # NOTE: This is being used in addition to the below as it has slightly higher coverage across the PDB diff --git a/src/atomworks/io/utils/bonds.py b/src/atomworks/io/utils/bonds.py index ff499eda..5da28f2f 100644 --- a/src/atomworks/io/utils/bonds.py +++ b/src/atomworks/io/utils/bonds.py @@ -333,8 +333,8 @@ def get_struct_conn_bonds( bonds (np.array[[int, int, struc.BondType]]): A List of bonds to be added to the atom array. leaving (np.ndarray): An array of indices of atoms that are leaving groups for bookkeeping. - References: - - https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_struct_conn.conn_type_id.html + Reference: + `struct_conn.conn_type_id `_ """ # ... validate input invalid_bond_types = set(add_bond_types) - STRUCT_CONN_BOND_TYPES diff --git a/src/atomworks/io/utils/ccd.py b/src/atomworks/io/utils/ccd.py index 4c8c6ca1..3530bab5 100644 --- a/src/atomworks/io/utils/ccd.py +++ b/src/atomworks/io/utils/ccd.py @@ -32,25 +32,41 @@ @functools.cache def aa_chem_comps() -> frozenset[str]: - """Set of amino acid chemical components. E.g. {'ALA', 'ARG', ...}""" + """Set of amino acid chemical components. + + Returns: + Set of amino acid chemical components (e.g., {'ALA', 'ARG', ...}). + """ return frozenset(struc.info.groups._get_group_members(list(AA_LIKE_CHEM_TYPES))) @functools.cache def na_chem_comps() -> frozenset[str]: - """Set of nucleic acid chemical components. E.g. {'DA', 'DC', ...}""" + """Set of nucleic acid chemical components. + + Returns: + Set of nucleic acid chemical components (e.g., {'DA', 'DC', ...}). + """ return frozenset(struc.info.groups._get_group_members(list(NA_LIKE_CHEM_TYPES))) @functools.cache def rna_chem_comps() -> frozenset[str]: - """Set of RNA chemical components. E.g. {'A', 'C', ...}""" + """Set of RNA chemical components. + + Returns: + Set of RNA chemical components (e.g., {'A', 'C', ...}). + """ return frozenset(struc.info.groups._get_group_members(list(RNA_LIKE_CHEM_TYPES))) @functools.cache def dna_chem_comps() -> frozenset[str]: - """Set of DNA chemical components. E.g. {'DA', 'DC', ...}""" + """Set of DNA chemical components. + + Returns: + Set of DNA chemical components (e.g., {'DA', 'DC', ...}). + """ return frozenset(struc.info.groups._get_group_members(list(DNA_LIKE_CHEM_TYPES))) @@ -58,8 +74,16 @@ def dna_chem_comps() -> frozenset[str]: def chem_comp_to_one_letter() -> dict[str, str]: """Dictionary mapping the chemical components to their 1-letter code. - NOTE: Chemical components historically used to be 3-letter codes, - but nowadays longer codes exist. + Note: + Chemical components historically used to be 3-letter codes, + but nowadays longer codes exist. + + Returns: + Dictionary mapping chemical component names to their 1-letter codes. + + References: + `RCSB Chemical Component Dictionary `_ + `Biotite CCD Module `_ """ ccd = struc.info.ccd.get_ccd() three_letter_code = ccd["chem_comp"]["three_letter_code"].as_array() @@ -81,6 +105,16 @@ def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_ """Set of all CCD codes available in the local mirror. Only counts codes when they adhere to the CCD mirror layout (e.g. .../H/HEM/HEM.cif) + + Args: + ccd_mirror_path: Path to the CCD mirror directory. + + Returns: + Set of all available CCD codes in the mirror. + + References: + `RCSB Chemical Component Dictionary `_ + `CCD Mirror Layout `_ """ root = os.fspath(ccd_mirror_path) @@ -237,25 +271,23 @@ def parse_ccd_cif( add_properties: bool = False, add_mapping: bool = False, ) -> struc.AtomArray: - """ - Parses a Chemical Component Dictionary CIF file into a Biotite AtomArray structure. + """Parses a Chemical Component Dictionary CIF file into a Biotite AtomArray structure. Args: - - cif (CIFFile): The CIF file containing the component data. - - coords (Literal["model", "ideal_pdbx", "ideal_rdkit"] | None | tuple[str, ...]): - Type of coordinates to use. Defaults to ("ideal_pdbx", "model", "ideal_rdkit"). + cif: The CIF file containing the component data. + coords: Type of coordinates to use. Defaults to ("ideal_pdbx", "model", "ideal_rdkit"). Can be a single coordinate type or a tuple of fallback preferences (e.g., ("ideal_pdbx", "model", "ideal_rdkit")). - - "model": Use the coordinates that are found in a random (but fixed) pdb file. - - "ideal_pdbx": Use the idealized coordinates computed by the RCSB PDB (sometimes not available). - - "ideal_rdkit": Use the idealized coordinates computed by RDKit (sometimes unrealistic). - - add_properties (bool): Whether to include RDKit-computed properties. Defaults to False. - Properties are available under the `properties` attribute of the returned `AtomArray`. - - add_mapping (bool): Whether to include external resource mappings, such as e.g. the ChEMBL ID. + - "model": Use the coordinates that are found in a random (but fixed) pdb file. + - "ideal_pdbx": Use the idealized coordinates computed by the RCSB PDB (sometimes not available). + - "ideal_rdkit": Use the idealized coordinates computed by RDKit (sometimes unrealistic). + add_properties: Whether to include RDKit-computed properties. Defaults to False. + Properties are available under the ``properties`` attribute of the returned ``AtomArray``. + add_mapping: Whether to include external resource mappings, such as e.g. the ChEMBL ID. Defaults to False. - Mappings are available under the `mapping` attribute of the returned `AtomArray`. + Mappings are available under the ``mapping`` attribute of the returned ``AtomArray``. Returns: - - AtomArray: The parsed atomic structure with requested annotations and properties. + AtomArray: The parsed atomic structure with requested annotations and properties. Example: >>> cif = pdbx.CIFFile.read("path/to/ALA.cif") @@ -398,21 +430,20 @@ def parse_ccd_cif( def get_ccd_component_from_mirror( ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, **parse_ccd_cif_kwargs ) -> struc.AtomArray: - """ - Retrieves and parses a component from a local mirror of the Chemical Component Dictionary. + """Retrieves and parses a component from a local mirror of the Chemical Component Dictionary. Args: - - ccd_code (str): The three-letter code of the chemical component. - - ccd_mirror_path (os.PathLike): Path to the root of the CCD mirror directory. - - **parse_ccd_cif_kwargs: Additional keyword arguments passed to parse_ccd_cif(): - - coords (Literal["model", "ideal_pdbx", "ideal_rdkit"] | None): - Type of coordinates to use. Defaults to "ideal_pdbx". - - add_properties (bool): Whether to include RDKit-computed properties. Defaults to True. - - add_mapping (bool): Whether to include external resource mappings, such as e.g. the ChEMBL ID. + ccd_code: The three-letter code of the chemical component. + ccd_mirror_path: Path to the root of the CCD mirror directory. + **parse_ccd_cif_kwargs: Additional keyword arguments passed to parse_ccd_cif(): + coords: Type of coordinates to use ("model", "ideal_pdbx", "ideal_rdkit", or None). + Defaults to "ideal_pdbx". + add_properties: Whether to include RDKit-computed properties. Defaults to True. + add_mapping: Whether to include external resource mappings, such as e.g. the ChEMBL ID. Defaults to False. Returns: - - AtomArray: The parsed atomic structure of the requested component. + AtomArray: The parsed atomic structure of the requested component. Example: >>> atom_array = get_ccd_component_from_mirror("ALA", coords="ideal_pdbx") diff --git a/src/atomworks/io/utils/io_utils.py b/src/atomworks/io/utils/io_utils.py index 5a0c43c3..1661a2b2 100644 --- a/src/atomworks/io/utils/io_utils.py +++ b/src/atomworks/io/utils/io_utils.py @@ -1,6 +1,4 @@ -""" -General utility functions for working with CIF files in Biotite. -""" +"""General utility functions for working with CIF files in Biotite.""" __all__ = ["get_structure", "load_any", "read_any", "to_cif_buffer", "to_cif_file", "to_cif_string"] @@ -36,8 +34,10 @@ def _get_logged_in_user() -> str: - """ - Get the logged in user. + """Get the logged in user. + + Returns: + The username of the logged in user, or "unknown_user" if unavailable. """ try: return os.getlogin() @@ -57,19 +57,20 @@ def load_any( """Convenience function for loading a structure from a file or buffer. Args: - - file_or_buffer: Path to the file or buffer to load the structure from. - - file_type: Type of the file to load. If None, it will be inferred. - - extra_fields: List of extra fields to include as AtomArray annotations. + file_or_buffer: Path to the file or buffer to load the structure from. + file_type: Type of the file to load. If None, it will be inferred. + extra_fields: List of extra fields to include as AtomArray annotations. If "all", all fields in the 'atom_site' category of the file will be included. - - include_bonds: Whether to include bonds in the structure. - - model: The model number to use for loading the structure. If None, all models will be loaded. - - altloc: The altloc ID to use for loading the structure. + include_bonds: Whether to include bonds in the structure. + model: The model number to use for loading the structure. If None, all models will be loaded. + altloc: The altloc ID to use for loading the structure. Returns: - AtomArrayStack: The loaded structure with the specified fields and assumptions. + The loaded structure with the specified fields and assumptions. - Reference: - Biotite documentation (https://www.biotite-python.org/apidoc/biotite.structure.io.pdbx.get_structure.html#biotite.structure.io.pdbx.get_structure) + References: + `Biotite Structure I/O `_ + `mmCIF Format Specification `_ """ file_obj = read_any(file_or_buffer, file_type=file_type) return get_structure( @@ -87,17 +88,17 @@ def _add_bonds( add_bond_types_from_struct_conn: list[str] = ["covale"], fix_bond_types: bool = True, ) -> AtomArray | AtomArrayStack: - """ - Add bonds to the AtomArray and filter by a given altloc strategy. + """Add bonds to the AtomArray and filter by a given altloc strategy. + Avoids the issue where spurious bonds are added due to uninformative label_seq_ids. Args: - - atom_array: The AtomArray to add bonds to. Must contain `auth_seq_id` annotation. - - cif_block: The CIFBlock containing the structure data. - - add_bond_types_from_struct_conn (list, optional): A list of bond types to add to the structure + atom_array: The AtomArray to add bonds to. Must contain `auth_seq_id` annotation. + cif_block: The CIFBlock containing the structure data. + add_bond_types_from_struct_conn: A list of bond types to add to the structure from the `struct_conn` category. Defaults to `["covale"]`. This means that we will only add covalent bonds to the structure (excluding metal coordination and disulfide bonds). - - fix_bond_types (bool, optional): Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. + fix_bond_types: Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. Returns: AtomArray | AtomArrayStack: The AtomArray or AtomArrayStack with bonds and filtered by altloc. @@ -209,7 +210,7 @@ def get_structure( AtomArray | AtomArrayStack: The loaded structure with the specified fields and assumptions. Reference: - Biotite documentation (https://www.biotite-python.org/apidoc/biotite.structure.io.pdbx.get_structure.html#biotite.structure.io.pdbx.get_structure) + `Biotite documentation `_ """ tmp_altloc = altloc if altloc in {"first", "occupancy", "all"} else "all" diff --git a/src/atomworks/io/utils/selection.py b/src/atomworks/io/utils/selection.py index b9bcd7c9..bca87ff4 100644 --- a/src/atomworks/io/utils/selection.py +++ b/src/atomworks/io/utils/selection.py @@ -56,8 +56,8 @@ def get_residue_starts(atom_array: AtomArray | AtomArrayStack, add_exclusive_sto Backwards compatible with `biotite.structure.residues.get_residue_starts` if the `transformation_id` annotation is not present. - References: - - https://github.com/biotite-dev/biotite/blob/231eefed334e1d3509c1b7cb3f2bfd71d4b0eeb0/src/biotite/structure/residues.py#L35 + Reference: + `Biotite residues.py `_ """ _annots_to_check = ["chain_id", "res_name", "res_id", "ins_code", "transformation_id"] existing_annots = atom_array.get_annotation_categories() diff --git a/src/atomworks/io/utils/sequence.py b/src/atomworks/io/utils/sequence.py index 1c1052a8..869ff045 100644 --- a/src/atomworks/io/utils/sequence.py +++ b/src/atomworks/io/utils/sequence.py @@ -36,8 +36,13 @@ @functools.cache def aa_chem_comp_3to1(standard_only: bool = False) -> dict[str, str]: - """ - Returns a dictionary mapping 3-letter amino acid codes to 1-letter codes. + """Returns a dictionary mapping 3-letter amino acid codes to 1-letter codes. + + Args: + standard_only: If True, only include standard amino acids. + + Returns: + Dictionary mapping 3-letter to 1-letter amino acid codes. """ aa_3to1 = toolz.keyfilter(lambda x: x in aa_chem_comps(), chem_comp_to_one_letter()) if standard_only: @@ -47,8 +52,13 @@ def aa_chem_comp_3to1(standard_only: bool = False) -> dict[str, str]: @functools.cache def na_chem_comp_3to1(standard_only: bool = False) -> dict[str, str]: - """ - Returns a dictionary mapping 3-letter DNA codes to 1-letter codes. + """Returns a dictionary mapping 3-letter DNA codes to 1-letter codes. + + Args: + standard_only: If True, only include standard nucleic acids. + + Returns: + Dictionary mapping 3-letter to 1-letter nucleic acid codes. """ na_3to1 = toolz.keyfilter(lambda x: x in na_chem_comps(), chem_comp_to_one_letter()) if standard_only: diff --git a/src/atomworks/ml/datasets/datasets.py b/src/atomworks/ml/datasets/datasets.py index d6acba0a..4d4e743f 100644 --- a/src/atomworks/ml/datasets/datasets.py +++ b/src/atomworks/ml/datasets/datasets.py @@ -1,14 +1,21 @@ """AtomWorks Dataset classes and common APIs. -Provides composable dataset classes for molecular data with Transform pipeline support. - -Key Components: - * :class:`MolecularDataset`: Base class with Transform pipeline and error handling - * :class:`PandasDataset`: Tabular data stored as pandas DataFrames - * :class:`FileDataset`: Individual files as examples - -For custom use cases, users may implement their own Dataset classes. Downstream code -makes no assumptions about the specific implementation. +At a high level, to train models with AtomWorks, we need a Dataset class that: + (1) Takes as input an item index and returns the corresponding example information; typically includes: + a. Path to a structural file saved on disk (`/path/to/dataset/my_dataset_0.cif`) + b. Additional item-specific metadata (e.g., class labels) + (2) Pre-loads structural information from the returned example into an `AtomArray` and assembles inputs for the Transform pipeline + (3) Feed the input dictionary through a Transform pipeline and return the result + +Due to the heterogeneity of biomolecular data, in many cases, we may also want: + (4) In the event of a failure during the Transform pipeline, fall back to a different example + +For bespoke use cases, users may choose to write a custom Dataset that accomplish these steps; downstream code makes no assumptions. + +To accelerate development, we also provide an off-the-shelf, composable approach following common patterns: + - :class:`MolecularDataset`: Base class that handles pre-loading structural data and executing the Transform pipeline with error handling and debugging utilities + - :class:`PandasDataset`: A subclass of MolecularDataset for tabular data stored as pandas DataFrames + - :class:`FileDataset`: A subclass of MolecularDataset where each file is one example """ import copy @@ -395,7 +402,7 @@ def __init__( *, data: pd.DataFrame | PathLike, name: str, - id_column: str | None = None, + id_column: str | None = "example_id", filters: list[str] | None = None, columns_to_load: list[str] | None = None, # MolecularDataset parameters @@ -544,13 +551,14 @@ def _apply_filters(self, filters: list[str]) -> pd.DataFrame: ValueError: If the data is not initialized or if a query removes all rows. Warning: If a query does not remove any rows. - Examples: - queries = [ - "deposition_date < '2020-01-01'", - "resolution < 2.5 and ~method.str.contains('NMR')", - "cluster.notnull()", - "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" - ] + Example: + >>> queries = [ + >>> "deposition_date < '2020-01-01'", + >>> "resolution < 2.5 and ~method.str.contains('NMR')", + >>> "cluster.notnull()", + >>> "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" + >>> ] + >>> dataset = PandasDataset(data="data.csv", name="filtered_dataset", filters=queries) """ assert not self._already_filtered, "Filters cannot be applied after initialization." diff --git a/src/atomworks/ml/datasets/loaders.py b/src/atomworks/ml/datasets/loaders.py index 7d1aa10c..43d8e968 100644 --- a/src/atomworks/ml/datasets/loaders.py +++ b/src/atomworks/ml/datasets/loaders.py @@ -15,8 +15,8 @@ from atomworks.ml.utils.io import apply_sharding_pattern -def _build_metadata_hierarchy(row: pd.Series, attrs: dict | None = None) -> dict[str, Any]: - """Build up metadata dictionary with precedence hierarchy. +def _construct_metadata_hierarchy(row: pd.Series, attrs: dict | None = None) -> dict[str, Any]: + """Construct metadata dictionary with precedence hierarchy. Assembles metadata from multiple sources with the following precedence (lowest to highest priority): 1. DataFrame-level attributes (row.attrs) @@ -42,7 +42,7 @@ def _build_metadata_hierarchy(row: pd.Series, attrs: dict | None = None) -> dict return extra_info -def _build_structure_path( +def _construct_structure_path( path: str, base_path: str | None, extension: str | None, sharding_pattern: str | None = None ) -> Path: """Construct file path with optional base_path, extension, and sharding pattern. @@ -79,7 +79,7 @@ def _load_structure_from_path(path: Path, assembly_id: str, parser_args: dict | return result_dict -def loader_base( +def create_base_loader( example_id_colname: str = "example_id", path_colname: str = "path", assembly_id_colname: str | None = "assembly_id", @@ -89,7 +89,7 @@ def loader_base( sharding_pattern: str | None = None, parser_args: dict | None = None, ) -> Callable[[pd.Series], dict[str, Any]]: - """Base loader with common logic for many AtomWorks datasets. + """Factory function that creates a base loader with common logic for many AtomWorks datasets. Args: example_id_colname: Name of column containing unique example identifiers @@ -105,6 +105,9 @@ def loader_base( - "/1:2/0:2/": Use chars 1-2 for first dir, then chars 0-2 for second dir - None: No sharding (default) parser_args: Optional dictionary of arguments to pass to the CIF parser when loading the structure file. + + Returns: + A function that takes a pandas Series and returns a dictionary of the loaded structure. """ def loader_function(row: pd.Series) -> dict[str, Any]: @@ -115,12 +118,12 @@ def loader_function(row: pd.Series) -> dict[str, Any]: if extension and "extension" not in loader_attrs: loader_attrs["extension"] = extension - extra_info = _build_metadata_hierarchy(row, loader_attrs) + extra_info = _construct_metadata_hierarchy(row, loader_attrs) assembly_id = ( row[assembly_id_colname] if assembly_id_colname is not None and assembly_id_colname in row else "1" ) - path = _build_structure_path( + path = _construct_structure_path( row[path_colname], extra_info.get("base_path"), extra_info.get("extension"), sharding_pattern ) result_dict = _load_structure_from_path(path, assembly_id, parser_args) @@ -150,7 +153,7 @@ def loader_function(row: pd.Series) -> dict[str, Any]: return loader_function -def loader_with_query_pn_units( +def create_loader_with_query_pn_units( example_id_colname: str = "example_id", path_colname: str = "path", pn_unit_iid_colnames: str | list[str] | None = None, @@ -168,12 +171,12 @@ def loader_with_query_pn_units( Examples: Interfaces dataset: - >>> loader = loader_with_query_pn_units( + >>> loader = create_loader_with_query_pn_units( ... pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], assembly_id_colname="assembly_id" ... ) Chains dataset: - >>> loader = loader_with_query_pn_units( + >>> loader = create_loader_with_query_pn_units( ... pn_unit_iid_colnames="pn_unit_iid", base_path="/data/structures", extension=".cif.gz" ... ) """ @@ -183,7 +186,7 @@ def loader_with_query_pn_units( pn_unit_iid_colnames = pn_unit_iid_colnames or [] # Create base loader with common parameters - base_loader = loader_base( + base_loader = create_base_loader( example_id_colname=example_id_colname, path_colname=path_colname, assembly_id_colname=assembly_id_colname, @@ -208,7 +211,7 @@ def loader_function(row: pd.Series) -> dict[str, Any]: return loader_function -def loader_with_interfaces_and_pn_units_to_score( +def create_loader_with_interfaces_and_pn_units_to_score( example_id_colname: str = "example_id", path_colname: str = "path", assembly_id_colname: str | None = "assembly_id", @@ -220,15 +223,15 @@ def loader_with_interfaces_and_pn_units_to_score( attrs: dict | None = None, parser_args: dict | None = None, ) -> Callable[[pd.Series], dict[str, Any]]: - """Loader that additionally adds interfaces and pn_units to score for validation datasets to the ground truth key. + """Factory function that creates a loader that adds interfaces and pn_units to score for validation datasets. Example: - >>> loader = loader_with_interfaces_and_pn_units_to_score( + >>> loader = create_loader_with_interfaces_and_pn_units_to_score( ... interfaces_to_score_colname="interfaces_to_score", pn_units_to_score_colname="pn_units_to_score" ... ) """ # Create base loader with common parameters - base_loader = loader_base( + base_loader = create_base_loader( example_id_colname=example_id_colname, path_colname=path_colname, assembly_id_colname=assembly_id_colname, diff --git a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py index 7ad1d5c7..20fa2727 100644 --- a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py +++ b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py @@ -193,38 +193,48 @@ class GenericDFParser(MetadataRowParser): We parse an input row (e.g., a Pandas Series) and return a dictionary containing pertinent information for the Transform pipeline. Args: - example_id_colname (str): Name of the column containing a unique identifier for each example (across ALL datasets, not just this dataset). - By convention, the columns values should be generated with `atomworks.ml.common.generate_example_id`. Default: "example_id" - path_colname (str): Name of the column containing paths (relative or absolute) to the relevant structure files. Default: "path" - pn_unit_iid_colnames (str | List[str]): The name(s) of the column(s) containing the CIFUtils pn_unit_iid(s); used for cropping. + example_id_colname: Name of the column containing a unique identifier for each example (across ALL datasets, not just this dataset). + By convention, the columns values should be generated with ``atomworks.ml.common.generate_example_id``. Default: "example_id" + path_colname: Name of the column containing paths (relative or absolute) to the relevant structure files. Default: "path" + pn_unit_iid_colnames: The name(s) of the column(s) containing the CIFUtils pn_unit_iid(s); used for cropping. If given as a list, should contain one element for a monomers dataset and two for an interfaces dataset. Default: None (crop randomly) - assembly_id_colname (str | None): Optional parameter giving the name of the column containing the assembly ID. + assembly_id_colname: Optional parameter giving the name of the column containing the assembly ID. If None, the assembly ID will be set to "1" for all examples. Default: None - base_path (str): The base path to the files, if not included in the path. - extension (str): The file extension of the structure files, if not included in the path. - attrs (dict): Additional attributes to be merged with the dataframe-level attributes stored in the DF (if present). Attributes + base_path: The base path to the files, if not included in the path. + extension: The file extension of the structure files, if not included in the path. + attrs: Additional attributes to be merged with the dataframe-level attributes stored in the DF (if present). Attributes in this dictionary will take precedence over those in the dataset-level attributes and will be returned in the "extra_info" key. Returns: - - example_id: The unique identifier for the example. Must be unique across all datasets. - - path: The composed path to the structure file, including the base path and extension if specified. - - query_pn_unit_iids: The pn_unit_iid(s) that inform where to crop the structure. - During TRAINING, we typically want to specify the chain(s) or interface at which to center our crop. If not given (i.e., None), + dict: A dictionary containing: + + example_id + The unique identifier for the example. Must be unique across all datasets. + path + The composed path to the structure file, including the base path and extension if specified. + query_pn_unit_iids + The pn_unit_iid(s) that inform where to crop the structure. + During TRAINING, we typically want to specify the chain(s) or interface at which to center our crop. If not given (i.e., None), then we will crop the structure at a random location, if a crop is required. - During VALIDATION, then we do not crop, and query_pn_unit_iids should be None. - - assembly_id: The assembly ID. Used to load the correct assembly from the CIF file. If not given, the assembly ID will be set to "1". - - extra_info: A dictionary containing all additional information that should be passed to the Transform pipeline. Contains, in order of precedence: - - Any additional key-value pairs specified by the `attrs` parameter - - All unused dataframe columns (i.e., those not used for example_id, path, query_pn_unit_iids, or assembly_id) - - Dataset-level attributes (if present), found in the `attrs` attribute of the Dataframe (or Series) - For example, the "extra_info" key could contain information about which chain(s) to score during validation, metadata for specific metrics, etc. - - NOTE: We must avoid duplication of interfaces due to order inversion. If not using the preprocessing - scripts in `atomworks.ml`, ensure that the interfaces dataframe has been checked for duplicates. + During VALIDATION, then we do not crop, and query_pn_unit_iids should be None. + assembly_id + The assembly ID. Used to load the correct assembly from the CIF file. If not given, the assembly ID will be set to "1". + extra_info + A dictionary containing all additional information that should be passed to the Transform pipeline. Contains, in order of precedence: + + - Any additional key-value pairs specified by the ``attrs`` parameter + - All unused dataframe columns (i.e., those not used for example_id, path, query_pn_unit_iids, or assembly_id) + - Dataset-level attributes (if present), found in the ``attrs`` attribute of the Dataframe (or Series) + For example, the "extra_info" key could contain information about which chain(s) to score during validation, metadata for specific metrics, etc. + + Note: + We must avoid duplication of interfaces due to order inversion. If not using the preprocessing + scripts in ``atomworks.ml``, ensure that the interfaces dataframe has been checked for duplicates. For example, [A, B] and [B, A] should be considered the same interface. - Example dataframe: + Example: + Example dataframe: example_id path pn_unit_1_iid pn_unit_2_iid {['my-dataset']}{ex_1}{1}{[A_1,B_1]} /path/to/structure_1.cif A_1 B_1 {['my-dataset']}{ex_2}{2}{[C_1,B_1]} /path/to/structure_2.cif C_1 B_1 diff --git a/src/atomworks/ml/encoding_definitions.py b/src/atomworks/ml/encoding_definitions.py index 7621d223..41b6fbea 100644 --- a/src/atomworks/ml/encoding_definitions.py +++ b/src/atomworks/ml/encoding_definitions.py @@ -35,34 +35,36 @@ @dataclass class TokenEncoding: - """A class to represent an fixed length token encoding. + """A class to represent a fixed length token encoding. Args: - token_atoms (dict[str, np.ndarray]): A dictionary mapping token names to atom names. + token_atoms: A dictionary mapping token names to atom names. The order of the tokens in the sequence determines the integer encoding of the token. The order of the atom names in the tuple determines the integer encoding of the atom name within the token. - chemcomp_type_to_unknown (dict[str, str]): A dictionary mapping chemical component types + chemcomp_type_to_unknown: A dictionary mapping chemical component types to unknown token names. This is used to map unknown residues to the respective unknown token. Different chemical component types may map to different unknown token names. - Defaults to `{}`, meaning that no unknown tokens are defined, leading to a `KeyError` + Defaults to ``{}``, meaning that no unknown tokens are defined, leading to a ``KeyError`` if an unknown residue is encountered. - NOTE: We follow these conventions for tokens to make them compatible with the CCD for - robust and easy tokenization. If you want to use the Transforms written for automatically - tokenizing and encoding, you need to follow these conventions. + Note: + We follow these conventions for tokens to make them compatible with the CCD for + robust and easy tokenization. If you want to use the Transforms written for automatically + tokenizing and encoding, you need to follow these conventions: + - When encoding a residue, we use the standardized (up to) 3-letter residue name from the CCD, - e.g. 'ALA' for Alanine, or `DA` for Deoxyadenosine, or `U` for Uracil. + e.g. ``'ALA'`` for Alanine, or ``'DA'`` for Deoxyadenosine, or ``'U'`` for Uracil. - When encoding unknown tokens, we may define different unknown tokens for different chemical components (e.g. a different unknown for proteins, vs. dna, ...). The - unkown tokens can take on any arbitrary 3-letter code that we want to map to, but + unknown tokens can take on any arbitrary 3-letter code that we want to map to, but they should not clash with existing residue names in the CCD. - When encoding an atom, we use the atomic number of the element as a string as the - token name. E.g. '1' for Hydrogen, '6' for Carbon, '9' for Fluorine, ... - For unknown atoms, we use '0' as the token name. - # TODO: Deal with ligand names such as `100` which is also an atomic number - - To denote masked tokens, we use a '<...>' syntax. E.g. '' for a generic mask token, - or '' for a mask token for proteins. The ... can be any arbitrary string. We + token name. E.g. ``'1'`` for Hydrogen, ``'6'`` for Carbon, ``'9'`` for Fluorine, ... + For unknown atoms, we use ``'0'`` as the token name. + # TODO: Deal with ligand names such as ``'100'`` which is also an atomic number + - To denote masked tokens, we use a ``'<...>'`` syntax. E.g. ``''`` for a generic mask token, + or ``''`` for a mask token for proteins. The ... can be any arbitrary string. We use the angle brackets to avoid clashes with existing residue names in the CCD. """ @@ -282,7 +284,7 @@ def __repr__(self): """AF2's atom14 encoding. Reference: - - https://github.com/google-deepmind/alphafold/blob/f251de6613cb478207c732bf9627b1e853c99c2f/alphafold/common/residue_constants.py#L505 + `AlphaFold residue_constants.py `_ """ AF2_ATOM37_ENCODING = TokenEncoding( @@ -315,7 +317,7 @@ def __repr__(self): """AF2's atom37 encoding Reference: - - https://github.com/google-deepmind/alphafold/blob/f251de6613cb478207c732bf9627b1e853c99c2f/alphafold/common/residue_constants.py#L492-L544 + `AlphaFold residue_constants.py `_ (extracted via: ```python atom37 = {} @@ -447,8 +449,10 @@ def __repr__(self): chemcomp_type_to_unknown={chem_type: "UNK" for chem_type in AA_LIKE_CHEM_TYPES}, ) """RF2 atom14 encoding for proteins. - - Encodes only the heavy atoms (max 14, for `TRP`) - - Includes 1 unknown tokens: `UNK` + +- Encodes only the heavy atoms (max 14, for ``TRP``) +- Includes 1 unknown tokens: ``UNK`` + Print it out to see a visual representation of the encoding. """ @@ -494,8 +498,10 @@ def __repr__(self): ), ) """RF2 atom23 encoding for proteins and nucleic acids. - - Encodes only the heavy atoms (max 22, for `RG`) - - Includes 3 unknown tokens: `UNK` for proteins, `DN` for dna, `N` for RNA + +- Encodes only the heavy atoms (max 22, for ``RG``) +- Includes 3 unknown tokens: ``UNK`` for proteins, ``DN`` for dna, ``N`` for RNA + Print it out to see a visual representation of the encoding. """ @@ -687,18 +693,11 @@ def __repr__(self): class AF3SequenceEncoding: - """ - Encodes and decodes sequence tokens for AlphaFold 3. + """Encodes and decodes sequence tokens for AlphaFold 3. This class provides functionality to convert between residue names and their corresponding integer encodings as used in AlphaFold 3. It handles standard amino acids, RNA, DNA, and unknown residues. - - Methods: - encode(res_names): Encode residue names to integer indices. - decode(res_indices): Decode integer indices to residue names. - tokens: Property that returns the list of AF3 tokens. - n_tokens: Property that returns the number of AF3 tokens. """ def __init__(self): diff --git a/src/atomworks/ml/pipelines/af3.py b/src/atomworks/ml/pipelines/af3.py index 511f2e7c..df28b348 100644 --- a/src/atomworks/ml/pipelines/af3.py +++ b/src/atomworks/ml/pipelines/af3.py @@ -161,9 +161,8 @@ def build_af3_transform_pipeline( The pipeline includes steps for processing the structure, adding annotations, and generating features required for AF3-like predictions. - References: - - AlphaFold 3 Supplementary Information. - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + Reference: + `AlphaFold 3 Supplementary Information `_ """ if (crop_contiguous_probability > 0 or crop_spatial_probability > 0) and not is_inference: diff --git a/src/atomworks/ml/preprocessing/utils/clustering.py b/src/atomworks/ml/preprocessing/utils/clustering.py index a8d4cd12..f7a2d408 100644 --- a/src/atomworks/ml/preprocessing/utils/clustering.py +++ b/src/atomworks/ml/preprocessing/utils/clustering.py @@ -77,9 +77,9 @@ def run_mmseqs2_clustering( afe56282ba3, 19f7ce1eed1 References: - - PDB clustering approach: https://www.rcsb.org/docs/grouping-structures/sequence-based-clustering - - MMseqs2 documentation: https://github.com/soedinglab/mmseqs2/wiki - - CLI documentation for the `easy-cluster` command: `mmseqs easy-cluster -h` + `PDB clustering approach `_ + `MMseqs2 documentation `_ + CLI documentation for the `easy-cluster` command: `mmseqs easy-cluster -h` """ # If input is a Path object, convert it to a string if isinstance(input_fasta, Path): diff --git a/src/atomworks/ml/preprocessing/utils/structure_utils.py b/src/atomworks/ml/preprocessing/utils/structure_utils.py index 43f21f87..c833c370 100644 --- a/src/atomworks/ml/preprocessing/utils/structure_utils.py +++ b/src/atomworks/ml/preprocessing/utils/structure_utils.py @@ -395,8 +395,8 @@ def get_ligand_validity_scores_from_pdb_id(pdb_id: str) -> list[dict[str, str | residue name, chain ID, and entity ID. Can easily be converted to a pandas DataFrame for easier handling via `pd.DataFrame(records)`. - References: - - https://www.rcsb.org/docs/general-help/ligand-structure-quality-in-pdb-structures + Reference: + `RCSB Ligand Structure Quality Guide `_ """ pdb_graphql_url: Final[str] = "https://data.rcsb.org/graphql" diff --git a/src/atomworks/ml/samplers.py b/src/atomworks/ml/samplers.py index 20ef3e7f..76535fe5 100644 --- a/src/atomworks/ml/samplers.py +++ b/src/atomworks/ml/samplers.py @@ -238,8 +238,8 @@ class DistributedMixedSampler(Sampler): Returns: iter: An iterator over indices of the dataset for the current process (of length n_samples, not n_examples_per_epoch) - References: - - PyTorch DistributedSampler (https://github.com/pytorch/pytorch/blob/main/torch/utils/data/distributed.py#L68) + Reference: + `PyTorch DistributedSampler `_ """ def __init__( diff --git a/src/atomworks/ml/transforms/af3_reference_molecule.py b/src/atomworks/ml/transforms/af3_reference_molecule.py index 6246066c..b98aea63 100644 --- a/src/atomworks/ml/transforms/af3_reference_molecule.py +++ b/src/atomworks/ml/transforms/af3_reference_molecule.py @@ -88,7 +88,7 @@ def _get_rdkit_mols_with_conformers( to using the idealized conformer from the CCD entry if available. Reference: - - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 Supplementary Information `_ """ ref_mols = {} for res_name, count in res_stochiometry.items(): @@ -120,7 +120,7 @@ def _encode_atom_names_like_af3(atom_names: np.ndarray) -> np.ndarray: length 4. Reference: - - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 Supplementary Information `_ """ # Ensure uppercase atom_names = np.char.upper(atom_names) @@ -230,8 +230,7 @@ def get_af3_reference_molecule_features( - is_atomized_atom_level: [N_atoms] Whether the atom is atomized (atom-level version of "is_ligand") Reference: - - Section 2.8 of the AF3 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `Section 2.8 of the AF3 supplementary information `_ """ _has_ground_truth_conformer_policy = "ground_truth_conformer_policy" in atom_array.get_annotation_categories() _has_global_res_id = "res_id_global" in atom_array.get_annotation_categories() @@ -463,8 +462,7 @@ class GetAF3ReferenceMoleculeFeatures(Transform): Note: This transform should be applied after cropping. Reference: - - Section 2.8 of the AF3 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `Section 2.8 of the AF3 supplementary information `_ """ def __init__( diff --git a/src/atomworks/ml/transforms/atom_array.py b/src/atomworks/ml/transforms/atom_array.py index 29c06f06..e226b498 100644 --- a/src/atomworks/ml/transforms/atom_array.py +++ b/src/atomworks/ml/transforms/atom_array.py @@ -660,9 +660,10 @@ def sort_like_rf2aa(atom_array: AtomArray) -> AtomArray: class SortLikeRF2AA(Transform): """Sort the atom array in 3 groups (in this order). Within each group the atoms are ordered by their pn_unit_iid (and within a pn_unit their order is preserved). - - (1) polymer atoms - - (2) non-poly atoms of a pn-unit bonded to a polymer (covalent modifications) - - (3) non-poly atoms of a free-floating pn-unit (free-floating ligands) + + - (1) polymer atoms + - (2) non-poly atoms of a pn-unit bonded to a polymer (covalent modifications) + - (3) non-poly atoms of a free-floating pn-unit (free-floating ligands) """ requires_previous_transforms: ClassVar[list[str | Transform]] = ["AtomizeByCCDName"] diff --git a/src/atomworks/ml/transforms/atom_frames.py b/src/atomworks/ml/transforms/atom_frames.py index 29d41ac8..f21b2b31 100644 --- a/src/atomworks/ml/transforms/atom_frames.py +++ b/src/atomworks/ml/transforms/atom_frames.py @@ -159,21 +159,19 @@ def find_all_paths_of_length_n( graph: nx.Graph, n: int, order_independent_atom_frame_prioritization: bool = True ) -> list: - """ - Find all paths of a given length n in a NetworkX graph. + """Find all paths of a given length n in a NetworkX graph. - Parameters: - G (nx.Graph): The input graph. - n (int): The length of the paths to find. - order_independent_frame_prioritization (bool, optional): - If True, considers paths with the same nodes but in different orders as equivalent. - Defaults to True. + Args: + graph: The input graph. + n: The length of the paths to find. + order_independent_atom_frame_prioritization: If True, considers paths with the same nodes but in different orders as equivalent. + Defaults to True. Returns: - np.ndarray: A tensor containing all unique paths of length n. + A tensor containing all unique paths of length n. Reference: - https://stackoverflow.com/questions/28095646/finding-all-paths-walks-of-given-length-in-a-networkx-graph''' + `StackOverflow: Finding all paths of given length `_ """ def find_paths(graph: nx.Graph, u: Any, n: int) -> list[list[Any]]: diff --git a/src/atomworks/ml/transforms/bonds.py b/src/atomworks/ml/transforms/bonds.py index 84499ba1..5aef7bff 100644 --- a/src/atomworks/ml/transforms/bonds.py +++ b/src/atomworks/ml/transforms/bonds.py @@ -135,41 +135,42 @@ def forward(self, data: dict) -> dict: class AddRF2AABondFeaturesMatrix(Transform): - """ - Adds a matrix indicating the RF2AA bond type between two nodes to the data. + """Adds a matrix indicating the RF2AA bond type between two nodes to the data. + This transform builds from the Biotite bond type, modifying as needed for residue-residue and residue-atom mappings. - We then add the matrix to the data dictionary under the key `rf2aa_bond_features_matrix`. + We then add the matrix to the data dictionary under the key "rf2aa_bond_features_matrix". From the RF2AA supplement, Supplementary Methods Table 8: Inputs to RFAA: - ------------------------------------------------------------------------------------------------ - bond_feats | (L, L, 7) Pairwise bond adjacency matrix. Pairs of residues are either - single, double, triple, aromatic, residue-residue, residue-atom, or other. - ------------------------------------------------------------------------------------------------ + + bond_feats | (L, L, 7) Pairwise bond adjacency matrix. Pairs of residues are either + | single, double, triple, aromatic, residue-residue, residue-atom, or other. Specifically, we map to the following enum, as described in ChemData: - - 0 = No bonds - - 1 = Single bond - - 2 = Double bond - - 3 = Triple bond - - 4 = Aromatic - - 5 = Residue-residue - - 6 = Residue-atom - - 7 = Other + - 0 = No bonds + - 1 = Single bond + - 2 = Double bond + - 3 = Triple bond + - 4 = Aromatic + - 5 = Residue-residue + - 6 = Residue-atom + - 7 = Other We build the matrix from the Biotite bond types. - The Biotite `BondType` enum contains the following mapping: - - ANY = 0 - - SINGLE = 1 - - DOUBLE = 2 - - TRIPLE = 3 - - QUADRUPLE = 4 - - AROMATIC_SINGLE = 5 - - AROMATIC_DOUBLE = 6 - - AROMATIC_TRIPLE = 7 + The Biotite BondType enum contains the following mapping: + + - ANY = 0 + - SINGLE = 1 + - DOUBLE = 2 + - TRIPLE = 3 + - QUADRUPLE = 4 + - AROMATIC_SINGLE = 5 + - AROMATIC_DOUBLE = 6 + - AROMATIC_TRIPLE = 7 + The the index -1 is used for non-bonded interactions. Reference: - - Biotite documentation (https://www.biotite-python.org/apidoc/biotite.structure.BondType.html#biotite.structure.BondType) + `Biotite BondType Documentation `_ """ requires_previous_transforms: ClassVar[list[str | Transform]] = [AtomizeByCCDName, AddTokenBondAdjacency] @@ -202,16 +203,15 @@ def forward(self, data: dict) -> dict: class AddRF2AATraversalDistanceMatrix(Transform): - """ - Generates a matrix indicating the minimum amount of bonds to traverse between two nodes. + """Generates a matrix indicating the minimum amount of bonds to traverse between two nodes. + We define the traversal distance between two protein nodes as zero. Sets the "traversal_distance_matrix" key in the data dictionary. From the RF2AA supplement, Supplementary Methods Table 8: Inputs to RFAA: - ------------------------------------------------------------------------------------------------ - dist_matrix | (L, L) Minimum amount of bonds to traverse between two nodes. - This is 0 between all protein nodes. - ------------------------------------------------------------------------------------------------ + + dist_matrix | (L, L) Minimum amount of bonds to traverse between two nodes. + | This is 0 between all protein nodes. """ def check_input(self, data: dict) -> None: diff --git a/src/atomworks/ml/transforms/chirals.py b/src/atomworks/ml/transforms/chirals.py index 4769b94d..0dccada0 100644 --- a/src/atomworks/ml/transforms/chirals.py +++ b/src/atomworks/ml/transforms/chirals.py @@ -90,7 +90,7 @@ def _get_plane_pair_keys_for_planes_between_chiral_center_and_tetrahedral_side( AssertionError: If the length of `bonded_atoms` is not 4. Reference: - - RF2AA supplementary notes figure S1 (https://www.science.org/doi/10.1126/science.adl2528#supplementary-materials) + `RF2AA supplementary notes figure S1 `_ Example: >>> chiral_center = 1 @@ -159,11 +159,13 @@ def get_rf2aa_chiral_features( NOTE: Each row of output features contains the indices of the plane pairs and the signed ideal dihedral angle for each chiral center. For example, the entry: - `[c, i, j, k, angle]` - means that the atom at index `c` is a chiral center with atoms at indices `(i, j, k)` bonded - to it. The signed dihedral angle `angle` is the signed angle between the planes `(cij)` and - `(ijk)`. The sign of the angle determines the chirality of the chiral center. + [c, i, j, k, angle] + means that the atom at index c is a chiral center with atoms at indices (i, j, k) bonded + to it. The signed dihedral angle angle is the signed angle between the planes (cij) and + (ijk). The sign of the angle determines the chirality of the chiral center. + NOTE: Each chiral center will result in more than one feature. In particular: + - 3 features if one of the 4 atoms bonded to the chiral center is an implicit hydrogen (as we do not look at any pair of planes where one plane contains an implicit hydrogen). - 12 features if all 4 atoms bonded to the chiral center are explicit atoms. @@ -176,19 +178,19 @@ def get_rf2aa_chiral_features( network to iteratively refine predictions to match ideal tetrahedral geometry. Args: - chiral_centers (list[dict]): A list of dictionaries, of the form: + chiral_centers: A list of dictionaries, of the form: {"chiral_center_idx": int, "bonded_explicit_atom_idxs": list[int]} - where `chiral_center_idx` is the index of the chiral center atom, and `bonded_explicit_atom_idxs` + where chiral_center_idx is the index of the chiral center atom, and bonded_explicit_atom_idxs is a list of the indices of the atoms bonded to the chiral center (excluding implicit hydrogens). - coords (np.ndarray): A numpy array of atomic coordinates. - take_first_chiral_subordering (bool): If True, only the first subordering is considered (when four + coords: A numpy array of atomic coordinates. + take_first_chiral_subordering: If True, only the first subordering is considered (when four bonded non-hydrogen atoms are present). If False, all orderings are considered (leading to 12 unique plane pairs in the case of 4 bonded atoms, or 3 unique plane pairs in the case of 3 bonded atoms). Returns: - torch.Tensor: A tensor of shape [n_chirals, 5] where each row contains the indices of the plane pairs - and the *signed* ideal dihedral angle for each chiral center. The sign of the dihedral + A tensor of shape [n_chirals, 5] where each row contains the indices of the plane pairs + and the signed ideal dihedral angle for each chiral center. The sign of the dihedral angle determines the chirality of the chiral center (+1 for clockwise, -1 for counterclockwise). If no stereocenters are found, returns an empty tensor of shape [0, 5]. """ @@ -233,46 +235,43 @@ def get_rf2aa_chiral_features( class AddRF2AAChiralFeatures(Transform): - """ - AddRF2AAChiralFeatures adds chiral features to the atom array data under the `"chiral_feats"` key. - Chiral centers are taken from `data["chiral_centers"]`, which is a list of dictionaries, of the form: - {"chiral_center_atom_id": int, "bonded_explicit_atom_ids": list[int]} - This metadata can be added by running e.g. the `AddOpenBabelMoleculesForAtomizedMolecules` and - `GetChiralCentersFromOpenBabel` transforms.This transform also requires the `AtomizeByCCDName` transform + """AddRF2AAChiralFeatures adds chiral features to the atom array data under the "chiral_feats" key. + + Chiral centers are taken from data["chiral_centers"], which is a list of dictionaries, of the form: + {"chiral_center_atom_id": int, "bonded_explicit_atom_ids": list[int]} + + This metadata can be added by running e.g. the AddOpenBabelMoleculesForAtomizedMolecules and + GetChiralCentersFromOpenBabel transforms. This transform also requires the AtomizeByCCDName transform to be applied previously to ensure the atom array is properly atomized. Args: - data (dict[str, Any]): A dictionary containing the input data, including the atom array and chiral centers. + data: A dictionary containing the input data, including the atom array and chiral centers. Returns: - dict[str, Any]: The updated `data` dictionary with the added chiral features under the `"chiral_feats"` key. + The updated data dictionary with the added chiral features under the "chiral_feats" key. Example: - data = { - "atom_array": atom_array, - "chiral_centers": [ - { - "chiral_center_atom_id": 5, - "bonded_explicit_atom_ids": [1, 2, 3, 4] - }, - { - "chiral_center_atom_id": 10, - "bonded_explicit_atom_ids": [6, 7, 8, 9] - } - ] - } - - transform = AddRF2AAChiralFeatures() - result = transform.forward(data) - - print(result["chiral_feats"]) - # Output might look like: - # (assuming the atom_id s above also correspond to the indices in the atom array, - # otherwise the first 4 columns look different as they are the indices in the atom array) - # tensor([[ 5., 1., 2., 3., 0.61546...], - # [ 5., 2., 3., 4., -0.61546...], - # ... - # [10., 7., 8., 9., -0.61546...]]) + .. code-block:: python + + data = { + "atom_array": atom_array, + "chiral_centers": [ + {"chiral_center_atom_id": 5, "bonded_explicit_atom_ids": [1, 2, 3, 4]}, + {"chiral_center_atom_id": 10, "bonded_explicit_atom_ids": [6, 7, 8, 9]}, + ], + } + + transform = AddRF2AAChiralFeatures() + result = transform.forward(data) + + print(result["chiral_feats"]) + # Output might look like: + # (assuming the atom_id s above also correspond to the indices in the atom array, + # otherwise the first 4 columns look different as they are the indices in the atom array) + # tensor([[ 5., 1., 2., 3., 0.61546...], + # [ 5., 2., 3., 4., -0.61546...], + # ... + # [10., 7., 8., 9., -0.61546...]]) """ requires_previous_transforms: ClassVar[list[str | Transform]] = ["AtomizeByCCDName"] @@ -401,12 +400,14 @@ def add_af3_chiral_features( class AddAF3ChiralFeatures(Transform): - """Adds chiral features into the `feats` dictionary. + """Adds chiral features into the feats dictionary. Adds the following features to the data dictionary under the 'feats' key: - - chiral_feats: [N_chiral_centers, 5] A listing of chiral centers of the format: - tensor([[ 5., 1., 2., 3., 0.61546...],...]) - Here, the first 4 columns define atom indices of chiral center; the 5th is target dihedral + + chiral_feats + [N_chiral_centers, 5] A listing of chiral centers of the format: + tensor([[ 5., 1., 2., 3., 0.61546...],...]) + Here, the first 4 columns define atom indices of chiral center; the 5th is target dihedral Metadata from GetRDKitChiralCenters, held in the "chiral_centers" key, is needed for this transform. """ diff --git a/src/atomworks/ml/transforms/covalent_modifications.py b/src/atomworks/ml/transforms/covalent_modifications.py index cfbc568e..99a09187 100644 --- a/src/atomworks/ml/transforms/covalent_modifications.py +++ b/src/atomworks/ml/transforms/covalent_modifications.py @@ -91,13 +91,12 @@ class FlagAndReassignCovalentModifications(Transform): """Handles covalent modifications within the AtomArray. Covalent modifications, e.g., glycosylation, are handled by the following algorithm: - ------------------------------------------------------------------------------------------------ + for polymer residues with atoms covalently bound to a NON-POLYMER: for ALL atoms in the polymer residue: set the pn_unit_iid and pn_unit_id identifying annotations to that of the NON-POLYMER polymer/non-polymer unit set atomize = true (thus, this transform must be run before the Atomize transform) set is_covalent_modification = true (for the entire pn_unit) - ------------------------------------------------------------------------------------------------ TODO: Break into two Transforms - one that flags, one that reassigns. Atomizing covalent modifications is a design choice that may not be desired in all pipelines. Annotating covalent modifications, however, is broadly useful. diff --git a/src/atomworks/ml/transforms/crop.py b/src/atomworks/ml/transforms/crop.py index c691530a..6c3e9521 100644 --- a/src/atomworks/ml/transforms/crop.py +++ b/src/atomworks/ml/transforms/crop.py @@ -99,8 +99,8 @@ def crop_contiguous_af2_multimer(iids: list[int | str], instance_lens: list[int] (iids) to crop masks (i.e. boolean arrays) indicating which tokens to keep. References: - - AF2 Multimer https://www.biorxiv.org/content/10.1101/2021.10.04.463034v2.full.pdf - - AF3 https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF2 Multimer `_ + `AF3 `_ Example: >>> iids = [1, 2, 3] @@ -185,7 +185,6 @@ def get_spatial_crop_center( Sample a crop center from a spatial region of the atom array. Implements the selection of a crop center as described in AF3. - ``` In this procedure, polymer residues and ligand atoms are selected that are within close spatial distance of an interface atom. The interface atom is selected at random from the set of token centre atoms (defined @@ -195,7 +194,6 @@ def get_spatial_crop_center( provided (subsection 2.5), the reference atom is selected at random from interfacial token centre atoms that exist within this chain or interface. - ``` Args: atom_array (AtomArray): The array containing atom information. @@ -289,8 +287,8 @@ def get_spatial_crop_mask( crop_mask (np.ndarray): A boolean mask of shape (N,) where True indicates that the token is within the crop. References: - - AF2 Multimer https://www.biorxiv.org/content/10.1101/2021.10.04.463034v2.full.pdf - - AF3 https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF2 Multimer `_ + `AF3 `_ Example: >>> coord = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]]) diff --git a/src/atomworks/ml/transforms/encoding.py b/src/atomworks/ml/transforms/encoding.py index 19de7da4..4cdd65c1 100644 --- a/src/atomworks/ml/transforms/encoding.py +++ b/src/atomworks/ml/transforms/encoding.py @@ -197,8 +197,7 @@ def atom_array_from_encoding( **other_annotations: np.ndarray | None, # TODO: Allow passing a res_id ) -> AtomArray: - """ - Create an AtomArray from encoded coordinates, mask, and sequence. + """Create an AtomArray from encoded coordinates, mask, and sequence. This function takes encoded data and reconstructs an AtomArray, which is a structured representation of atomic information. The encoded coordinates, @@ -206,23 +205,24 @@ def atom_array_from_encoding( relevant annotations are included. Args: - - encoded_coord (torch.Tensor | np.ndarray): Encoded coordinates tensor. - - encoded_mask (torch.Tensor | np.ndarray): Encoded mask tensor. - - encoded_seq (torch.Tensor | np.ndarray): Encoded sequence tensor. - - encoding (TokenEncoding): The encoding to use for encoding the atom array. - - chain_id (str | np.ndarray, optional): Chain ID. Can be a single string (e.g., "A") + encoded_coord: Encoded coordinates tensor. + encoded_mask: Encoded mask tensor. + encoded_seq: Encoded sequence tensor. + encoding: The encoding to use for encoding the atom array. + chain_id: Chain ID. Can be a single string (e.g., "A") or a numpy array of shape (n_res,) corresponding to each residue. Defaults to "A". - - token_is_atom (torch.Tensor | np.ndarray | None, optional): Boolean mask indicating + token_is_atom: Boolean mask indicating whether each token corresponds to an atom. - - **other_annotations (np.ndarray | None): Additional annotations to include in the + **other_annotations: Additional annotations to include in the AtomArray. The shape must match one of the following: + - scalar, for global annotations - (n_atom,) for per-atom annotations, - (n_res,) for per-residue annotations, - (n_chain,) for per-chain annotations. Returns: - - atom_array (AtomArray): The created AtomArray containing the encoded atomic information. + The created AtomArray containing the encoded atomic information. """ # Turn tensors into numpy arrays if necessary _from_tensor = lambda x: x.cpu().numpy() if isinstance(x, torch.Tensor) else x # noqa E731 @@ -401,39 +401,53 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: class EncodeAF3TokenLevelFeatures(Transform): - """ - A transform that encodes token-level features like AF3. The token-level features are returned as: + """A transform that encodes token-level features like AF3. The token-level features are returned as: - - feats: - # (Standard AF3 token-level features) - - `residue_index`: Residue number in the token's original input chain (pre-crop) - - `token_index`: Token number. Increases monotonically; does not restart at 1 for new + feats: + (Standard AF3 token-level features) + + residue_index + Residue number in the token's original input chain (pre-crop) + token_index + Token number. Increases monotonically; does not restart at 1 for new chains. (Runs from 0 to N_tokens) - - `asym_id`: Unique integer for each distinct chain (pn_unit_iid) - NOTE: We use `pn_unit_iid` rather than `chain_iid` to be more consistent + asym_id + Unique integer for each distinct chain (pn_unit_iid) + NOTE: We use pn_unit_iid rather than chain_iid to be more consistent with handling of multi-residue/multi-chain ligands (especially sugars) - - `entity_id`: Unique integer for each distinct sequence (pn_unit entity) - - `sym_id`: Unique integer within chains of this sequence. E.g. if pn_units A, B and C - share a sequence but D does not, their `sym_id`s would be [0, 1, 2, 0]. - - `restype`: Integer encoding of the sequence. 32 possible values: 20 AA + unknown, + entity_id + Unique integer for each distinct sequence (pn_unit entity) + sym_id + Unique integer within chains of this sequence. E.g. if pn_units A, B and C + share a sequence but D does not, their sym_ids would be [0, 1, 2, 0]. + restype + Integer encoding of the sequence. 32 possible values: 20 AA + unknown, 4 RNA nucleotides + unknown, 4 DNA nucleotides + unknown, and gap. Ligands are - represented as unknown amino acid (`UNK`) - - `is_protein`: whether a token is of protein type - - `is_rna`: whether a token is of RNA type - - `is_dna`: whether a token is of DNA type - - `is_ligand`: whether a token is a ligand residue - - # (Custom token-level features) - - `is_atomized`: whether a token is an atomized token - - - feat_metadata: - - `asym_name`: The asymmetric unit name for each id in `asym_id`. Acts as a legend. - - `entity_name`: The entity name for each id in `entity_id`. Acts as a legend. - - `sym_name`: The symmetric unit name for each id in `sym_id`. Acts as a legend. + represented as unknown amino acid (UNK) + is_protein + whether a token is of protein type + is_rna + whether a token is of RNA type + is_dna + whether a token is of DNA type + is_ligand + whether a token is a ligand residue + + (Custom token-level features) + + is_atomized + whether a token is an atomized token + + feat_metadata: + asym_name + The asymmetric unit name for each id in asym_id. Acts as a legend. + entity_name + The entity name for each id in entity_id. Acts as a legend. + sym_name + The symmetric unit name for each id in sym_id. Acts as a legend. Reference: - - Section 2.8 of the AF3 supplementary (Table 5) - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `Section 2.8 of the AF3 supplementary (Table 5) `_ """ def __init__(self, sequence_encoding: AF3SequenceEncoding): diff --git a/src/atomworks/ml/transforms/msa/_msa_constants.py b/src/atomworks/ml/transforms/msa/_msa_constants.py index c958308f..4b48a16c 100644 --- a/src/atomworks/ml/transforms/msa/_msa_constants.py +++ b/src/atomworks/ml/transforms/msa/_msa_constants.py @@ -78,8 +78,8 @@ def create_lookup_table(one_letter_to_int: dict, fallback_letter: str) -> np.nda Ordered list of protein amino acid one-letter codes, including gaps, ambiguous, and rare amino acids. References: - - https://iupac.qmul.ac.uk/AminoAcid/A2021.html#AA21 (for IUPAC amino acid codes) - - https://www.cup.uni-muenchen.de/ch/compchem/tink/as.html (for Pyrollisine) + `IUPAC Amino Acid Codes `_ + `Pyrollisine `_ """ RNA_NUCLEOTIDE_ONE_LETTER_TO_INT = { @@ -109,8 +109,8 @@ def create_lookup_table(one_letter_to_int: dict, fallback_letter: str) -> np.nda """ Ordered list of RNA nucleotide one-letter codes, including gaps, ambiguous, and rare residues. -References: - - https://www.promega.com/resources/guides/nucleic-acid-analysis/restriction-enzyme-resource/restriction-enzyme-resource-tables/iupac-ambiguity-codes-for-nucleotide-degeneracy/ +Reference: + `IUPAC Ambiguity Codes for Nucleotide Degeneracy `_ """ # Create lookup tables from MSA one letter codes to integers, based on the above mappings diff --git a/src/atomworks/ml/transforms/msa/_msa_featurizing_utils.py b/src/atomworks/ml/transforms/msa/_msa_featurizing_utils.py index b682ea17..afab7fcd 100644 --- a/src/atomworks/ml/transforms/msa/_msa_featurizing_utils.py +++ b/src/atomworks/ml/transforms/msa/_msa_featurizing_utils.py @@ -151,8 +151,8 @@ def mask_msa_like_bert( - masked_msa (torch.Tensor): Tensor [n_rows, n_tokens_across_chains] representing the masked MSA, with the mask only applied to indices where `index_can_be_masked` is True. - mask_position (torch.Tensor): Boolean tensor [n_rows, n_tokens_across_chains] indicating positions where a mask was applied (i.e., one of the outcomes of the mask behavior) - References: - - AF2 Supplement https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-021-03819-2/MediaObjects/41586_2021_3819_MOESM1_ESM.pdf + Reference: + `AF2 Supplement `_ """ # We start by defining the probabilities for each masking behavior: @@ -318,8 +318,8 @@ def summarize_clusters( Examples: See the test cases in `test_featurize_msa`. - References: - - AlphaFold2 (https://github.com/google-deepmind/alphafold/blob/f251de6613cb478207c732bf9627b1e853c99c2f/alphafold/model/tf/data_transforms.py#L292) + Reference: + `AlphaFold2 data_transforms.py `_ """ n_clust = selected_indices.shape[0] n_rows, n_seq = encoded_msa.shape diff --git a/src/atomworks/ml/transforms/msa/_msa_loading_utils.py b/src/atomworks/ml/transforms/msa/_msa_loading_utils.py index e186de38..abafd84c 100644 --- a/src/atomworks/ml/transforms/msa/_msa_loading_utils.py +++ b/src/atomworks/ml/transforms/msa/_msa_loading_utils.py @@ -70,8 +70,8 @@ def parse_fasta(filename: PathLike, maxseq: int = 10000, query_tax_id: str = "qu ins (np.ndarray): Array of shape (N, L) where N is the number of sequences and L is the length of sequences. tax_ids (np.ndarray): Array of shape (N,) containing the taxonomy IDs for each sequence in the MSA. - References: - - UniProt FASTA Header Documentation (https://www.uniprot.org/help/fasta-headers) + Reference: + `UniProt FASTA Header Documentation `_ """ msa = [] ins = [] @@ -149,8 +149,8 @@ def parse_a3m( tax_ids (np.ndarray): Array of shape (N,) containing the taxonomy IDs for each sequence in the MSA. - References: - - A3M Format Documentation (https://yanglab.qd.sdu.edu.cn/trRosetta/msa_format.html#a3m) + Reference: + `A3M Format Documentation `_ """ msa = [] ins = [] diff --git a/src/atomworks/ml/transforms/msa/msa.py b/src/atomworks/ml/transforms/msa/msa.py index 97f03cbb..78b3158f 100644 --- a/src/atomworks/ml/transforms/msa/msa.py +++ b/src/atomworks/ml/transforms/msa/msa.py @@ -992,8 +992,8 @@ class FeaturizeMSALikeAF3(Transform): - "profile": Shape [n_tokens_across_chains, n_tokens]. Distribution across restypes in the main MSA. Computed before MSA truncation. - "insertion_mean": Shape [n_tokens_across_chains]. Mean number of insertions to the left of each position in the main MSA. Computed before MSA truncation. - References: - - AF3 Supplement, Table 5: https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + Reference: + `AF3 Supplement, Table 5 `_ """ requires_previous_transforms: ClassVar[list[str | Transform]] = [ diff --git a/src/atomworks/ml/transforms/openbabel_utils.py b/src/atomworks/ml/transforms/openbabel_utils.py index 78d89e53..9fa54758 100644 --- a/src/atomworks/ml/transforms/openbabel_utils.py +++ b/src/atomworks/ml/transforms/openbabel_utils.py @@ -6,8 +6,8 @@ allows using OpenBabel for identifying e.g. stereochemistry, automorphisms, etc. References: -- OpenBabel documentation: https://open-babel.readthedocs.io/ -- Biotite documentation: https://www.biotite-python.org/ + `OpenBabel documentation `_ + `Biotite documentation `_ """ import logging @@ -74,7 +74,7 @@ or lone pairs. Reference: - - https://open-babel.readthedocs.io/en/latest/Stereochemistry/stereo.html#accessing-stereochemistry-information + `OpenBabel Stereochemistry Documentation `_ """ @@ -86,66 +86,69 @@ def atom_array_to_openbabel( annotations_to_keep: list[str] = _BIOTITE_DEFAULT_ANNOTATIONS, ph_for_inferred_hydrogens: float = 7.4, ) -> openbabel.OBMol: - """ - Convert a Biotite AtomArray to an OpenBabel OBMol with the option of keeping custom AtomArray annotations. + """Convert a Biotite AtomArray to an OpenBabel OBMol with the option of keeping custom AtomArray annotations. + + For easier interfacing with the OBMol object, you can wrap it into a pybel.Molecule object. - For easier interfacing with the `OBMol` object, you can wrap it into a `pybel.Molecule` object. - - https://open-babel.readthedocs.io/en/latest/UseTheLibrary/Python_PybelAPI.html - - https://github.com/openbabel/documentation/blob/master/pybel.py + - https://open-babel.readthedocs.io/en/latest/UseTheLibrary/Python_PybelAPI.html + - https://github.com/openbabel/documentation/blob/master/pybel.py Args: - atom_array (AtomArray): The Biotite AtomArray to convert. - set_coords (bool, optional): If True, set the atomic coordinates from the AtomArray in the OBMol. Defaults to True. - infer_aromaticity (bool, optional): If True, infer aromaticity in the OBMol or take the aromaticity annotations from the AtomArray. Defaults to False. - infer_hydrogens (bool, optional): If True, infer hydrogens in the OBMol or take the hydrogens annotations from the AtomArray. Defaults to False. - annotations_to_keep (list[str], optional): List of annotation categories to keep from the AtomArray. Defaults to _BIOTITE_DEFAULT_ANNOTATIONS. - ph_for_inferred_hydrogens (float, optional): The pH value to use for inferred hydrogens. Defaults to pH 7.4 which is the openbabel default. + atom_array: The Biotite AtomArray to convert. + set_coords: If True, set the atomic coordinates from the AtomArray in the OBMol. Defaults to True. + infer_aromaticity: If True, infer aromaticity in the OBMol or take the aromaticity annotations from the AtomArray. Defaults to False. + infer_hydrogens: If True, infer hydrogens in the OBMol or take the hydrogens annotations from the AtomArray. Defaults to False. + annotations_to_keep: List of annotation categories to keep from the AtomArray. Defaults to _BIOTITE_DEFAULT_ANNOTATIONS. + ph_for_inferred_hydrogens: The pH value to use for inferred hydrogens. Defaults to pH 7.4 which is the openbabel default. The pH value is exposed here explicitly, but we recommend using the default value and only changing it if you have a good reason, as this will likely make it out of sync with other parts of the codebase which use the default pH value. Returns: - openbabel.OBMol: The converted OpenBabel OBMol. The custom annotations are stored in the `_annotations` attribute. + The converted OpenBabel OBMol. The custom annotations are stored in the _annotations attribute. Example: - >>> from biotite.structure import AtomArray, BondType - >>> import numpy as np - >>> from atomworks.ml.transforms.openbabel_utils import atom_array_to_openbabel - >>> # Create AtomArray - >>> atom_array = AtomArray(5) - >>> atom_array.element = np.array(["C", "C", "O", "N", "H"]) - >>> atom_array.coord = np.array( - ... [ - ... [0.0, 0.0, 0.0], - ... [1.5, 0.0, 0.0], - ... [1.5, 1.5, 0.0], - ... [0.0, 1.5, 0.0], - ... [0.0, 0.0, 1.5], - ... ] - ... ) - >>> # Add bonds - >>> atom_array.bonds = struc.BondList(len(atom_array)) - >>> atom_array.bonds.add_bond(0, 1, BondType.SINGLE) - >>> atom_array.bonds.add_bond(1, 2, BondType.DOUBLE) - >>> atom_array.bonds.add_bond(1, 3, BondType.SINGLE) - >>> atom_array.bonds.add_bond(0, 4, BondType.SINGLE) - >>> # Convert to OpenBabel molecule - >>> obmol = atom_array_to_openbabel(atom_array) - >>> # Print number of atoms - >>> print(f"Number of atoms: {obmol.NumAtoms()}") - Number of atoms: 5 - >>> # Print atom information - >>> print("\nAtom information:") - >>> for atom in openbabel.OBMolAtomIter(obmol): - ... print( - ... f"Atomic number: {atom.GetAtomicNum()}, Coordinates: ({atom.GetX():.1f}, {atom.GetY():.1f}, {atom.GetZ():.1f})" - ... ) - - Atom information: - Atomic number: 6, Coordinates: (0.0, 0.0, 0.0) - Atomic number: 6, Coordinates: (1.5, 0.0, 0.0) - Atomic number: 8, Coordinates: (1.5, 1.5, 0.0) - Atomic number: 7, Coordinates: (0.0, 1.5, 0.0) - Atomic number: 1, Coordinates: (0.0, 0.0, 1.5) + .. code-block:: python + + from biotite.structure import AtomArray, BondType + import numpy as np + from atomworks.ml.transforms.openbabel_utils import atom_array_to_openbabel + + # Create AtomArray + atom_array = AtomArray(5) + atom_array.element = np.array(["C", "C", "O", "N", "H"]) + atom_array.coord = np.array( + [ + [0.0, 0.0, 0.0], + [1.5, 0.0, 0.0], + [1.5, 1.5, 0.0], + [0.0, 1.5, 0.0], + [0.0, 0.0, 1.5], + ] + ) + # Add bonds + atom_array.bonds = struc.BondList(len(atom_array)) + atom_array.bonds.add_bond(0, 1, BondType.SINGLE) + atom_array.bonds.add_bond(1, 2, BondType.DOUBLE) + atom_array.bonds.add_bond(1, 3, BondType.SINGLE) + atom_array.bonds.add_bond(0, 4, BondType.SINGLE) + # Convert to OpenBabel molecule + obmol = atom_array_to_openbabel(atom_array) + # Print number of atoms + print(f"Number of atoms: {obmol.NumAtoms()}") + # Number of atoms: 5 + # Print atom information + print("\nAtom information:") + for atom in openbabel.OBMolAtomIter(obmol): + print( + f"Atomic number: {atom.GetAtomicNum()}, Coordinates: ({atom.GetX():.1f}, {atom.GetY():.1f}, {atom.GetZ():.1f})" + ) + + # Atom information: + # Atomic number: 6, Coordinates: (0.0, 0.0, 0.0) + # Atomic number: 6, Coordinates: (1.5, 0.0, 0.0) + # Atomic number: 8, Coordinates: (1.5, 1.5, 0.0) + # Atomic number: 7, Coordinates: (0.0, 1.5, 0.0) + # Atomic number: 1, Coordinates: (0.0, 0.0, 1.5) """ # Initialize empty OpenBabel molecule obmol = openbabel.OBMol() @@ -410,8 +413,8 @@ def find_automorphisms(obmol: openbabel.OBMol, max_automorphs: int = 1000, max_m a single automorphism representing the identity (no swaps). References: - - https://openbabel.org/api/3.0/group__substructure.shtml#ga16841a730cf92c8e51a804ad8d746307 - - https://baoilleach.blogspot.com/2010/11/automorphisms-isomorphisms-symmetry.html + `OpenBabel Substructure API `_ + `Automorphisms and Symmetry Blog `_ Example: >>> from openbabel import pybel @@ -546,48 +549,50 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: class GetChiralCentersFromOpenBabel(Transform): - """ - Identify chiral centers in the OpenBabel molecules stored in the `data["openbabel"]` dictionary. - These molecules typically correspond to the atomized molecules in the `data["atom_array"]` (c.f. - `AddOpenBabelMoleculesForAtomizedMolecules`). + """Identify chiral centers in the OpenBabel molecules stored in the data["openbabel"] dictionary. + + These molecules typically correspond to the atomized molecules in the data["atom_array"] (c.f. + AddOpenBabelMoleculesForAtomizedMolecules). Chiral centers are mapped to the global atom IDs in the atom array to enable tracking chiral centers regardless of cropping or reshuffling operations that may modify the atom_array. Args: - data (dict[str, Any]): A dictionary containing the input data, including the atom array and - OpenBabel molecules under the `data["openbabel"]` key. + data: A dictionary containing the input data, including the atom array and + OpenBabel molecules under the data["openbabel"] key. Returns: - dict[str, Any]: The updated `data` dictionary with the identified chiral centers under the - `"chiral_centers"` key. The chiral centers are stored as a list of dictionaries, where each + The updated data dictionary with the identified chiral centers under the + "chiral_centers" key. The chiral centers are stored as a list of dictionaries, where each dictionary contains the chiral center global atom ID and the atom IDs of the (3 to 4) atoms bonded to it. Example: - data = { - "atom_array": atom_array, - "openbabel": { - 1: obmol1, - 2: obmol2, + .. code-block:: python + + data = { + "atom_array": atom_array, + "openbabel": { + 1: obmol1, + 2: obmol2, + }, } - } - transform = GetChiralCentersFromOpenBabel() - result = transform.forward(data) - - print(result["chiral_centers"]) - # Output might look like: - # [ - # { - # "chiral_center_atom_id": 5, - # "bonded_explicit_atom_ids": [1, 2, 3, 4] - # }, - # { - # "chiral_center_atom_id": 10, - # "bonded_explicit_atom_ids": [6, 7, 8, 9] - # } - # ] + transform = GetChiralCentersFromOpenBabel() + result = transform.forward(data) + + print(result["chiral_centers"]) + # Output might look like: + # [ + # { + # "chiral_center_atom_id": 5, + # "bonded_explicit_atom_ids": [1, 2, 3, 4] + # }, + # { + # "chiral_center_atom_id": 10, + # "bonded_explicit_atom_ids": [6, 7, 8, 9] + # } + # ] """ requires_previous_transforms: ClassVar[list[str | Transform]] = [ diff --git a/src/atomworks/ml/transforms/rdkit_utils.py b/src/atomworks/ml/transforms/rdkit_utils.py index ab7c7ab0..fe9c55b8 100644 --- a/src/atomworks/ml/transforms/rdkit_utils.py +++ b/src/atomworks/ml/transforms/rdkit_utils.py @@ -60,35 +60,34 @@ def generate_conformers( attempts_with_random_coordinates: int = 10_000, **uff_optimize_kwargs: dict, ) -> Mol: - """ - Generate conformations for the given molecule. + """Generate conformations for the given molecule. Args: - - mol (rdkit.Chem.Mol): The RDKit molecule to generate conformations for. - - seed (int | None): Random seed for reproducibility. If None, a random seed is used. - - n_conformers (int): Number of conformations to generate. - - method (str): The method to use for conformer generation. Default is "ETKDGv3". + mol: The RDKit molecule to generate conformations for. + seed: Random seed for reproducibility. If None, a random seed is used. + n_conformers: Number of conformations to generate. + method: The method to use for conformer generation. Default is "ETKDGv3". Allowed methods are: "ETDG", "ETKDG", "ETKDGv2", "ETKDGv3", "srETKDGv3" See https://rdkit.org/docs/RDKit_Book.html#conformer-generation for details. - - num_threads (int): Number of threads to use for parallel computation. Default is 1. - - hydrogen_policy (Literal["infer", "remove", "keep", "auto"]): Whether to add explicit + num_threads: Number of threads to use for parallel computation. Default is 1. + hydrogen_policy: Whether to add explicit hydrogens to the molecule. If "remove", hydrogens are temporarily added for conformer generation, but removed again before returning the molecule. If "keep" the molecule is used as-is (without adding or removing hydrogens). If "auto", the policy is set to "keep" if the molecule already has explicit hydrogens, otherwise it is set to "remove". If "infer", we follow the same behavior as "remove," but do not remove added hydrogens prior to returning the molecule. - - optimize (bool): Whether to optimize the generated conformers using UFF. + optimize: Whether to optimize the generated conformers using UFF. Default is True. - - **uff_optimize_kwargs (dict): Additional keyword arguments for UFF optimization: - - maxIters (int): Maximum number of iterations (default 200). - - vdwThresh (float): Used to exclude long-range van der Waals interactions + **uff_optimize_kwargs: Additional keyword arguments for UFF optimization: + - maxIters: Maximum number of iterations (default 200). + - vdwThresh: Used to exclude long-range van der Waals interactions (default 10.0). - - ignoreInterfragInteractions (bool): If True, nonbonded terms between + - ignoreInterfragInteractions: If True, nonbonded terms between fragments will not be added to the forcefield (default True). Returns: - rdkit.Chem.Mol: The molecule with generated conformations. + The molecule with generated conformations. Note: - Optimizing conformers (optimize_conformers=True) is recommended for obtaining @@ -114,11 +113,10 @@ def generate_conformers( maxIterations or use more advanced sampling techniques. References: - 1. Conformer tutorial: https://rdkit.org/docs/RDKit_Book.html#conformer-generation - 1. RDKit Cookbook: https://www.rdkit.org/docs/Cookbook.html - 2. Riniker and Landrum, "Better Informed Distance Geometry: Using What We Know To - Improve Conformation Generation", JCIM, 2015. - + `Conformer tutorial `_ + `RDKit Cookbook `_ + Riniker and Landrum, "Better Informed Distance Geometry: Using What We Know To + Improve Conformation Generation", JCIM, 2015. """ # Ensure that all properties are being pickled (needed when we use timeout) assert ( @@ -326,8 +324,8 @@ def find_automorphisms_with_rdkit( If the search fails (e.g. due to running out of memory), returns an array with a single automorphism representing the identity (no swaps). - References: - - https://sourceforge.net/p/rdkit/mailman/message/27897393/ + Reference: + `RDKit Mailman Discussion `_ Example: >>> from openbabel import pybel @@ -397,21 +395,21 @@ def sample_rdkit_conformer_for_atom_array( """Sample a conformer for a Biotite AtomArray using RDKit. Args: - - atom_array: The Biotite AtomArray to sample a conformer for. - - n_conformers: The number of conformers to sample. - - timeout: The timeout for conformer generation. If None, + atom_array: The Biotite AtomArray to sample a conformer for. + n_conformers: The number of conformers to sample. + timeout: The timeout for conformer generation. If None, no timeout is applied. If a tuple, the first element is the offset and the second element is the slope. - - seed: The seed for conformer generation. If None, a random seed + seed: The seed for conformer generation. If None, a random seed is generated using the global numpy RNG. - - timeout_strategy: The strategy to use for the timeout. + timeout_strategy: The strategy to use for the timeout. Defaults to "subprocess". - - **generate_conformers_kwargs: Additional keyword arguments to pass to the + **generate_conformers_kwargs: Additional keyword arguments to pass to the generate_conformers function. Returns: - - AtomArray: The AtomArray with updated coordinates from the sampled conformer. - - Chem.Mol: The RDKit molecule with the generated conformer. + The AtomArray with updated coordinates from the sampled conformer. + The RDKit molecule with the generated conformer. Note: This function preserves the original atom order and properties of the input AtomArray. @@ -464,13 +462,13 @@ def ccd_code_to_rdkit_with_conformers( skip_rdkit_conformer_generation: bool = False, **generate_conformers_kwargs, ) -> Chem.Mol: - """ - Generate an RDKit molecule with conformers for a given residue name. + """Generate an RDKit molecule with conformers for a given residue name. This function attempts to generate the specified number of conformers for the given CCD code using RDKit's conformer generation (based on ETKDGv3 per default). If conformer generation fails or times out, it falls back to using the idealized conformer from the CCD entry if one is available. + Args: ccd_code: The CCD code to generate conformers for. E.g. 'ALA' or 'GLY', '9RH' etc. n_conformers: The number of conformers to generate for the given CCD code. @@ -485,7 +483,7 @@ def ccd_code_to_rdkit_with_conformers( generate_conformers function. Returns: - Chem.Mol: An RDKit molecule with the specified number of conformers. + An RDKit molecule with the specified number of conformers. """ # ... get molecule from CCD with its idealized conformer (default conformer 0) mol = ccd_code_to_rdkit(ccd_code, hydrogen_policy="remove") @@ -699,28 +697,32 @@ def get_rdkit_chiral_centers(rdkit_mols: dict[str, Mol]) -> dict: class GetRDKitChiralCenters(Transform): - """ - Identify chiral centers in the RDKit molecules stored in the `data["rdkit"]` dictionary. + """Identify chiral centers in the RDKit molecules stored in the data["rdkit"] dictionary. + Returns a dictionary mapping each residue name to a list of chiral centers, e.g: - data["chiral_centers"] = { - ... - "ILE": [ - {'chiral_center_idx': 1, 'bonded_explicit_atom_idxs': [0, 2, 4], 'chirality': 'S'}, - {'chiral_center_idx': 4, 'bonded_explicit_atom_idxs': [1, 5, 6], 'chirality': 'S'} - ], - ... - } + + .. code-block:: python + + data["chiral_centers"] = { + ... + "ILE": [ + {'chiral_center_idx': 1, 'bonded_explicit_atom_idxs': [0, 2, 4], 'chirality': 'S'}, + {'chiral_center_idx': 4, 'bonded_explicit_atom_idxs': [1, 5, 6], 'chirality': 'S'} + ], + ... + } + Each chiral center is a dict with a center atom index, 3 or 4 bonded atom indices, and the RDKit-determined chirality. Uses RDKit molecules first computed in GetAF3ReferenceMoleculeFeatures. Args: - data (dict[str, Any]): A dictionary containing the input data, including RDKit molecules - under the `"rdkit"` key. + data: A dictionary containing the input data, including RDKit molecules + under the "rdkit" key. Returns: - dict[str, Any]: The updated `data` dictionary with `chiral_centers` containing chiral + The updated data dictionary with chiral_centers containing chiral centers for each molecule. """ diff --git a/src/atomworks/ml/transforms/sasa.py b/src/atomworks/ml/transforms/sasa.py index 4b6226eb..bb9b63ce 100644 --- a/src/atomworks/ml/transforms/sasa.py +++ b/src/atomworks/ml/transforms/sasa.py @@ -133,16 +133,14 @@ def check_input(self, data: dict[str, Any]) -> None: check_atom_array_annotation(data, ["res_name"]) def forward(self, data: dict, key_to_add_sasa_to: str = "atom_array") -> dict: - """ - Calculates SASA and adds it to the data dictionary under the key `atom_array`. + """Calculates SASA and adds it to the data dictionary under the key "atom_array". + Args: - data: dict - A dictionary containing the input data atomarray. - key_to_add_sasa_to: str - The key in the data dictionary to add the SASA values to. + data: A dictionary containing the input data atomarray. + key_to_add_sasa_to: The key in the data dictionary to add the SASA values to. Returns: - dict: The data dictionary with SASA values added. + The data dictionary with SASA values added. """ atom_array: AtomArray = data[key_to_add_sasa_to] sasa = calculate_atomwise_sasa( diff --git a/src/atomworks/ml/transforms/symmetry.py b/src/atomworks/ml/transforms/symmetry.py index 8b245483..d9dd1477 100644 --- a/src/atomworks/ml/transforms/symmetry.py +++ b/src/atomworks/ml/transforms/symmetry.py @@ -34,44 +34,45 @@ def find_automorphisms(atom_array: AtomArray) -> np.ndarray: def apply_automorphs(data: torch.Tensor, automorphs: np.ndarray | torch.Tensor) -> torch.Tensor: - """ - Create data permutations of the input data for each of the automorphs. + """Create data permutations of the input data for each of the automorphs. - This function generates permutations of the input tensor `data` based on the provided automorphisms. + This function generates permutations of the input tensor data based on the provided automorphisms. Each permutation corresponds to a different automorphism, effectively reordering the data according to the automorphisms. Args: - - data (torch.Tensor): The input tensor to be permuted. The first dimension has to correspond to + data: The input tensor to be permuted. The first dimension has to correspond to the number of atoms. - - automorphs (np.ndarray | torch.Tensor): A tensor or numpy array of shape [n_automorphs, n_atoms, 2] + automorphs: A tensor or numpy array of shape [n_automorphs, n_atoms, 2] representing the automorphisms. Each automorphism is a list of paired atom indices - (from_idx, to_idx). The `from_idx` column is essentially just a repetition of np.arange(n_atoms). + (from_idx, to_idx). The from_idx column is essentially just a repetition of np.arange(n_atoms). Returns: - - data_automorphs (torch.Tensor): A tensor of shape [n_automorphs, *data.shape] containing the permuted + A tensor of shape [n_automorphs, ``*data.shape``] containing the permuted data for each automorphism. Example: - >>> data = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) - >>> # Example automorphisms (2 automorphisms for 3 atoms) - >>> automorphs = np.array([ + .. code-block:: python + + data = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + # Example automorphisms (2 automorphisms for 3 atoms) + automorphs = np.array([ [[0, 0], [1, 1], [2, 2]], [[0, 2], [1, 0], [2, 1]] - ... ]) - >>> permuted_data = create_automorph_permutations(data, automorphs) - >>> print(permuted_data) - tensor([[[1.0, 2.0], - [3.0, 4.0], - [5.0, 6.0]], - - [[5.0, 6.0], - [1.0, 2.0], - [3.0, 4.0]]]) + ... ]) + permuted_data = create_automorph_permutations(data, automorphs) + print(permuted_data) + # tensor([[[1.0, 2.0], + # [3.0, 4.0], + # [5.0, 6.0]], + # + # [[5.0, 6.0], + # [1.0, 2.0], + # [3.0, 4.0]]]) """ automorphs = torch.as_tensor(automorphs) n_automorphs, n_atoms, _ = automorphs.shape @@ -518,33 +519,33 @@ def handle_polymer_isomorphisms( post_poly_array: AtomArray, crop_tmask: np.ndarray, ) -> tuple[torch.Tensor, torch.Tensor]: - """ - Handles polymer symmetries by computing all swaps between isomorphic (i.e., equivalent) polymers + """Handles polymer symmetries by computing all swaps between isomorphic (i.e., equivalent) polymers that are at least partially in the crop. NOTE: This function only swaps full chains. Swaps within atoms of a polymer (e.g., residue naming ambiguities) are not considered and are handled elsewhere. The process involves the following steps: + 1. Subset the crop mask and pre-cropped atom array to only include polymers that are at least partially in the crop. 2. Among these, identify polymers that are equal to each other (i.e., symmetry groups). 3. Generate all possible combinations of in-group permutations (isomorphisms). - 3. Apply these isomorphisms to the coordinates and masks of the pre-cropped, encoded polymers. - 4. Crop to the relevant bits that appear in the crop. - 5. De-duplicate the isomorphisms to remove any redundancies. + 4. Apply these isomorphisms to the coordinates and masks of the pre-cropped, encoded polymers. + 5. Crop to the relevant bits that appear in the crop. + 6. De-duplicate the isomorphisms to remove any redundancies. Args: - - pre_poly_array (AtomArray): The atom array representing the state before cropping, + pre_poly_array: The atom array representing the state before cropping, containing polymer tokens. - - post_poly_array (AtomArray): The atom array representing the state after cropping, + post_poly_array: The atom array representing the state after cropping, containing polymer tokens. - - crop_tmask (np.ndarray): A boolean mask indicating which tokens are included in the crop. + crop_tmask: A boolean mask indicating which tokens are included in the crop. Returns: - - poly_xyz (torch.Tensor): The xyz coordinates of the polymers after applying the isomorphisms. + poly_xyz: The xyz coordinates of the polymers after applying the isomorphisms. It has shape [n_permutations, n_crop_tokens, n_atoms_per_token, 3]. - - poly_mask (torch.Tensor): The mask of the polymers after applying the isomorphisms. + poly_mask: The mask of the polymers after applying the isomorphisms. It has shape [n_permutations, n_crop_tokens, n_atoms_per_token]. """ # NOTATION: a = atom-level, t = token-level, tidx = token-level index, tmask = token-level mask @@ -626,21 +627,21 @@ def handle_nonpoly_automorphisms( crop_tmask: np.ndarray, openbabel_data: dict[int, Any], ) -> tuple[torch.Tensor, torch.Tensor]: - """ - Handles non-polymer symmetries by computing automorphs within each non-polymer that + """Handles non-polymer symmetries by computing automorphs within each non-polymer that is at least partially in the crop. This function calculates the swapped coordinate and mask values for each molecule and - concatenates all automorphs together, padding the `n_permutations` dimension to the + concatenates all automorphs together, padding the n_permutations dimension to the maximum number of automorphs for any molecule. WARNING: Unlike polymer symmetries, inter-molecule symmetries are not considered here, as they are managed by the RF2AA loss function through a greedy search. For non-polymers, the following steps are performed: + 1. Subset the pre-cropped non-poly array to only include the non-poly molecules that are at least partially in the crop. - 2. Compute the automorphs for each identified full molecule (i.e. *BEFORE* cropping). + 2. Compute the automorphs for each identified full molecule (i.e. BEFORE cropping). 3. Apply the computed automorphs to the coordinates and masks of the encoded full molecules. 4. Crop to the relevant sections of the molecules that appear in the crop. 5. Concatenate all automorphs together, padding to the maximum number of automorphs @@ -649,15 +650,15 @@ def handle_nonpoly_automorphisms( two automorphs, but the atom swaps are in a part that is not within the crop) Args: - - pre_nonpoly_array (AtomArray): The pre-cropped non-polymer array to process. - - post_nonpoly_array (AtomArray): The post-cropped non-polymer array to process. - - crop_tmask (np.ndarray): A boolean mask indicating which tokens are in the crop. - - openbabel_data (dict[int, Any]): A dictionary containing Open Babel data for molecules. + pre_nonpoly_array: The pre-cropped non-polymer array to process. + post_nonpoly_array: The post-cropped non-polymer array to process. + crop_tmask: A boolean mask indicating which tokens are in the crop. + openbabel_data: A dictionary containing Open Babel data for molecules. Returns: - - nonpoly_xyzs (torch.Tensor): A tensor containing the coordinates of the non-polymer automorphs. - - nonpoly_masks (torch.Tensor): A tensor containing the masks of the non-polymer automorphs. - - symmetry_info (dict[tuple[int, str], int]): A dictionary containing the symmetry information. + nonpoly_xyzs: A tensor containing the coordinates of the non-polymer automorphs. + nonpoly_masks: A tensor containing the masks of the non-polymer automorphs. + symmetry_info: A dictionary containing the symmetry information. """ n_nonpoly_token_in_crop = get_token_count(post_nonpoly_array) diff --git a/src/atomworks/ml/transforms/template.py b/src/atomworks/ml/transforms/template.py index 73970be5..d1b362e0 100644 --- a/src/atomworks/ml/transforms/template.py +++ b/src/atomworks/ml/transforms/template.py @@ -41,27 +41,26 @@ @dataclass class RF2AATemplate: - """ - Data class for holding template information in the RF, RF2 & RF2AA format. + """Data class for holding template information in the RF, RF2 & RF2AA format. NOTE: - - RF templates only exist for proteins - - This is a helper class to cast the templates into a more `readable` format and also - to provide an interface layer that allows us to deal with templates as atom_arrays, if - we ever re-create templates or add templates for non-proteins - - RF-style templates already come encoded in atom14 representation (RFAtom14, not AF2Atom14) + - RF templates only exist for proteins + - This is a helper class to cast the templates into a more readable format and also + to provide an interface layer that allows us to deal with templates as atom_arrays, if + we ever re-create templates or add templates for non-proteins + - RF-style templates already come encoded in atom14 representation (RFAtom14, not AF2Atom14) Keys: - - xyz: Tensor([1, n_templates x n_atoms_per_template, 14, 3]), raw coordinates of all templates - - mask: Tensor([1, n_templates x n_atom_per_template, 14]), mask of all templates - - qmap: Tensor([1, n_templates x n_atom_per_template, 2]), alignment mapping of all templates - - index 0: which index in the query protein this template index matches to - - index 1: which template index this matches to - - f0d: Tensor([1, n_templates, 8?]), [0,:,4] holds sequence identity info - - f1d: Tensor([1, n_templates x n_atoms_per_template, 3]), something in there may be related to template confidence, gaps? - - seq: Tensor([1, 100677]) (tensor, encoded with Chemdata.aa2num encoding) - - ids: list[tuple[str]] # Holds the f"{pdb_id}_{chain_id}" of the template - - label: list[str] # holds the lookup_id for this template + - xyz: Tensor([1, n_templates x n_atoms_per_template, 14, 3]), raw coordinates of all templates + - mask: Tensor([1, n_templates x n_atom_per_template, 14]), mask of all templates + - qmap: Tensor([1, n_templates x n_atom_per_template, 2]), alignment mapping of all templates + - index 0: which index in the query protein this template index matches to + - index 1: which template index this matches to + - f0d: Tensor([1, n_templates, 8?]), [0,:,4] holds sequence identity info + - f1d: Tensor([1, n_templates x n_atoms_per_template, 3]), something in there may be related to template confidence, gaps? + - seq: Tensor([1, 100677]) (tensor, encoded with Chemdata.aa2num encoding) + - ids: list[tuple[str]] # Holds the f"{pdb_id}_{chain_id}" of the template + - label: list[str] # holds the lookup_id for this template """ xyz: torch.Tensor # [1, n_templates x n_atoms_per_template, 14, 3] @@ -735,10 +734,8 @@ def featurize_templates_like_af3( dict: A dictionary containing the template features. References: - - Section 2.8 of the AF3 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf - - AF2 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-021-03819-2/MediaObjects/41586_2021_3819_MOESM1_ESM.pdf + `Section 2.8 of the AF3 supplementary information `_ + `AF2 supplementary information `_ NOTE: For templates a token is always a residue since we never align ligands, non-canonicals, PTMs, etc. """ @@ -907,10 +904,8 @@ class FeaturizeTemplatesLikeAF3(Transform): of the CA atom of all residues within the local frame of each residue. References: - - Section 2.8 of the AF3 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf - - AF2 supplementary information - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-021-03819-2/MediaObjects/41586_2021_3819_MOESM1_ESM.pdf + `Section 2.8 of the AF3 supplementary information `_ + `AF2 supplementary information `_ """ requires_previous_transforms: ClassVar[list[str | Transform]] = [ diff --git a/src/atomworks/ml/utils/geometry.py b/src/atomworks/ml/utils/geometry.py index 12a7a13d..b52159c7 100644 --- a/src/atomworks/ml/utils/geometry.py +++ b/src/atomworks/ml/utils/geometry.py @@ -38,8 +38,7 @@ def rigid_from_3_points( t: torch.Tensor of shape [..., 3], translation vector Reference: - - AF2 supplementary, Algorithm 21 - https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-021-03819-2/MediaObjects/41586_2021_3819_MOESM1_ESM.pdf + `AF2 supplementary, Algorithm 21 `_ Example: >>> x1 = torch.tensor([0.0, 0.0, 1.0]) @@ -239,18 +238,18 @@ def get_random_rots(batch_size: int, **tensor_kwargs) -> torch.Tensor: def get_random_rigid(batch_size: int, scale: float = 1.0, **tensor_kwargs) -> tuple[torch.Tensor, torch.Tensor]: - """ - Generate random rigid body transformations (R, t). + """Generate random rigid body transformations (R, t). Args: - - batch_size (int): Number of rigid transformations to generate. - - scale (float, optional): Scale factor for the translation vectors. Defaults to 1.0. - - **tensor_kwargs: Additional keyword arguments to pass to tensor creation functions. + batch_size: Number of rigid transformations to generate. + scale: Scale factor for the translation vectors. Defaults to 1.0. + **tensor_kwargs: Additional keyword arguments to pass to tensor creation functions. Returns: - - tuple[torch.Tensor, torch.Tensor]: A `rigid`tuple containing: - - rots (torch.Tensor): Batch of random rotation matrices with shape (batch_size, 3, 3). - - trans (torch.Tensor): Batch of random translation vectors with shape (batch_size, 3). + A rigid tuple containing: + + - rots: Batch of random rotation matrices with shape (batch_size, 3, 3). + - trans: Batch of random translation vectors with shape (batch_size, 3). Note: If batch_size is 1, the output tensors are squeezed to remove the batch dimension. diff --git a/src/atomworks/ml/utils/io.py b/src/atomworks/ml/utils/io.py index 3e69fa2c..a7e91e49 100644 --- a/src/atomworks/ml/utils/io.py +++ b/src/atomworks/ml/utils/io.py @@ -89,12 +89,15 @@ def cache_based_on_subset_of_args(cache_keys: list[str], maxsize: int | None = N A decorator that caches the function results based on the specified keyword arguments. Example: - @cache_based_on_subset_of_args(['arg1'], maxsize=2) - def function(*, arg1, arg2): - return arg1 + arg2 + .. code-block:: python - result1 = function(arg1=1, arg2=2) # Caches with key 1 - result2 = function(arg1=1, arg2=3) # Retrieves from cache + @cache_based_on_subset_of_args(["arg1"], maxsize=2) + def function(*, arg1, arg2): + return arg1 + arg2 + + + result1 = function(arg1=1, arg2=2) # Caches with key 1 + result2 = function(arg1=1, arg2=3) # Retrieves from cache """ def decorator(func: Callable) -> Callable: diff --git a/src/atomworks/ml/utils/misc.py b/src/atomworks/ml/utils/misc.py index bddabcf8..0ce3203e 100644 --- a/src/atomworks/ml/utils/misc.py +++ b/src/atomworks/ml/utils/misc.py @@ -218,7 +218,7 @@ def masked_mean( tensor([3., 5.]) # float32 Reference: - - AF2 Multimer Code (https://github.com/google-deepmind/alphafold/blob/f251de6613cb478207c732bf9627b1e853c99c2f/alphafold/model/utils.py#L79) + `AF2 Multimer Code `_ """ # Drop the last channel of the mask if specified diff --git a/src/atomworks/ml/utils/rng.py b/src/atomworks/ml/utils/rng.py index 8f4d1d2a..7bd00701 100644 --- a/src/atomworks/ml/utils/rng.py +++ b/src/atomworks/ml/utils/rng.py @@ -55,52 +55,62 @@ def rng_state( rng_state_dict: dict[str, Any] | None = None, include_cuda: bool = True ) -> Generator[dict[str, Any], None, None]: """A context manager that resets the global random state on exit to what it was before entering. + Within the context manager, the RNG states are set to the provided rng state in the dictionary. It supports isolating the states for PyTorch, Numpy, and Python built-in random number generators. Args: - - rng_state_dict (dict[str, Any] | None): A dictionary of RNG states to set. It can have the following keys: + rng_state_dict: A dictionary of RNG states to set. It can have the following keys: + - "torch": The state of the PyTorch RNG. - "torch.cuda": The state of the PyTorch CUDA RNG. - "numpy": The state of the Numpy RNG. - "python": The state of the Python built-in RNG. + If no rng_state_dict is provided, the RNG states are set to the current state of the RNGs. If the rng_state_dict only contains a subset of the RNG states, the other RNG states are set to the current state of the RNGs. - - include_cuda (bool): Whether to allow this function to also control the `torch.cuda` random number generator. - Set this to ``False`` when using the function in a forked process where CUDA re-initialization is + include_cuda: Whether to allow this function to also control the torch.cuda random number generator. + Set this to False when using the function in a forked process where CUDA re-initialization is prohibited. Defaults to True. Example: - - ``` - # Outside the context manager - print("NumPy:", np.random.random(3)) # [0.04810046 0.99270597 0.70612995] - print("PyTorch:", torch.rand(3)) # tensor([0.1405, 0.4602, 0.4284]) - print("Python random:", [random.random() for _ in range(3)]) # [0.7406435863188185, 0.5632059276194807, 0.8537007637060476] - - # Inside the context manager with fixed seeds - with rng_state(create_rng_state_from_seeds(np_seed=42, torch_seed=42, py_seed=42)) as rng_state_dict: - my_state = serialize_rng_state_dict(rng_state_dict) - print("\nWithin context manager:") - print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] - print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) - print("Python random:", [random.random() for _ in range(3)]) # [0.6394267984578837, 0.025010755222666936, 0.27502931836911926] - - # Back to the original state outside the context manager - print("\nBack outside the context manager:") - print("NumPy:", np.random.random(3)) # [0.75479377 0.99594641 0.70411424] - print("PyTorch:", torch.rand(3)) # tensor([0.2757, 0.5345, 0.1754]) - print("Python random:", [random.random() for _ in range(3)]) # [0.2194923914916147, 0.8731837332486028, 0.47700011905124995] - - # Inside the context manager with fixed seeds - with rng_state(eval(my_state)): - print("\nWithin context manager:") - print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] - print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) - print("Python random:", [random.random() for _ in range(3)]) # [0.6394267984578837, 0.025010755222666936, 0.27502931836911926] - ``` + .. code-block:: python + + # Outside the context manager + print("NumPy:", np.random.random(3)) # [0.04810046 0.99270597 0.70612995] + print("PyTorch:", torch.rand(3)) # tensor([0.1405, 0.4602, 0.4284]) + print( + "Python random:", [random.random() for _ in range(3)] + ) # [0.7406435863188185, 0.5632059276194807, 0.8537007637060476] + + # Inside the context manager with fixed seeds + with rng_state(create_rng_state_from_seeds(np_seed=42, torch_seed=42, py_seed=42)) as rng_state_dict: + my_state = serialize_rng_state_dict(rng_state_dict) + print("\nWithin context manager:") + print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] + print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) + print( + "Python random:", [random.random() for _ in range(3)] + ) # [0.6394267984578837, 0.025010755222666936, 0.27502931836911926] + + # Back to the original state outside the context manager + print("\nBack outside the context manager:") + print("NumPy:", np.random.random(3)) # [0.75479377 0.99594641 0.70411424] + print("PyTorch:", torch.rand(3)) # tensor([0.2757, 0.5345, 0.1754]) + print( + "Python random:", [random.random() for _ in range(3)] + ) # [0.2194923914916147, 0.8731837332486028, 0.47700011905124995] + + # Inside the context manager with fixed seeds + with rng_state(eval(my_state)): + print("\nWithin context manager:") + print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] + print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) + print( + "Python random:", [random.random() for _ in range(3)] + ) # [0.6394267984578837, 0.025010755222666936, 0.27502931836911926] """ # Collect previous states prev_states = capture_rng_states(include_cuda) diff --git a/src/atomworks/ml/utils/timer.py b/src/atomworks/ml/utils/timer.py index fb3c9d2f..66eaae01 100644 --- a/src/atomworks/ml/utils/timer.py +++ b/src/atomworks/ml/utils/timer.py @@ -38,25 +38,24 @@ def timeout(timeout: float | int | None = None, strategy: Literal["signal", "sub def do_nothing(*args, **kwargs) -> Callable: - """ - A decorator that does nothing and simply returns the original function. + """A decorator that does nothing and simply returns the original function. This decorator can be used as a placeholder or for testing purposes when you want to conditionally apply decorators without changing the code structure. Returns: - Callable: A decorator function that returns the original function unchanged. + A decorator function that returns the original function unchanged. Example: - ```python - @do_nothing_decorator() - def my_function(): - return "Hello, World!" + .. code-block:: python + + @do_nothing_decorator() + def my_function(): + return "Hello, World!" - # or: - do_nothing(bla=123, blub=456)(my_function) - ``` + # or: + do_nothing(bla=123, blub=456)(my_function) """ def decorator(func: Callable) -> Callable: diff --git a/src/atomworks/ml/utils/token.py b/src/atomworks/ml/utils/token.py index 606924ff..7ca6ecbf 100644 --- a/src/atomworks/ml/utils/token.py +++ b/src/atomworks/ml/utils/token.py @@ -329,7 +329,7 @@ def get_af3_token_center_masks(atom_array: AtomArray) -> np.ndarray: np.ndarray: A boolean mask indicating the center atoms of the tokens in the atom array. Reference: - - AF3: https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 Supplementary Information `_ """ assert ( @@ -378,7 +378,7 @@ def get_af3_token_center_coords(atom_array: AtomArray) -> np.ndarray: np.ndarray: The center coordinates of the tokens in the atom array. Reference: - - AF3: https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf + `AF3 Supplementary Information `_ Example: >>> # Contrived example showing only a few tokens and annotations per residue for illustration diff --git a/src/atomworks_cli/pdb.py b/src/atomworks_cli/pdb.py index 5e8489f0..8a5908f9 100644 --- a/src/atomworks_cli/pdb.py +++ b/src/atomworks_cli/pdb.py @@ -20,7 +20,17 @@ def _normalize_pdb_id(pdb_id: str) -> str: - """Return a normalized, lower-case 4-char PDB id or raise ValueError.""" + """Return a normalized, lower-case 4-char PDB id or raise ValueError. + + Args: + pdb_id: The PDB ID to normalize. + + Returns: + Normalized lowercase PDB ID. + + Raises: + ValueError: If the PDB ID is invalid. + """ pdb_id = pdb_id.strip().lower() if not PDB_ID_REGEX.match(pdb_id): raise ValueError(f"Invalid PDB id: {pdb_id}") @@ -31,6 +41,12 @@ def _pdb_id_to_relpath(pdb_id: str) -> Path: """Map a PDB id to its relative mmCIF path under the divided layout. Example: '1a0i' -> 'a0/1a0i.cif.gz' + + Args: + pdb_id: The PDB ID to map. + + Returns: + The relative path to the mmCIF file. """ pid = _normalize_pdb_id(pdb_id) subdir = pid[1:3] @@ -38,7 +54,15 @@ def _pdb_id_to_relpath(pdb_id: str) -> Path: def _run_rsync_list(remote_path: str, port: int | None) -> tuple[bool, str]: - """Try to list a remote rsync path and return success and output/error.""" + """Try to list a remote rsync path and return success and output/error. + + Args: + remote_path: The remote rsync path to list. + port: The port to use for rsync connection. + + Returns: + Tuple of (success, output) where success is a boolean and output is the stdout/stderr. + """ cmd = ["rsync", "--list-only"] if port is not None: cmd.extend(["--port", str(port)]) diff --git a/tests/conftest.py b/tests/conftest.py index 24bc0832..bfaf97e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,11 @@ # Conditional skip markers ---------------------------------------------------------- def _is_on_digs() -> bool: + """Check if running on DIGS infrastructure. + + Returns: + True if running on DIGS infrastructure, False otherwise. + """ return os.path.exists("/software/containers/versions/rf_diffusion_aa/ipd.txt") @@ -22,6 +27,11 @@ def _is_on_digs() -> bool: def _is_on_github_runner() -> bool: + """Check if running on GitHub Actions runner. + + Returns: + True if running on GitHub Actions runner, False otherwise. + """ return os.environ.get("GITHUB_ACTIONS", "false") == "true" @@ -32,6 +42,11 @@ def _is_on_github_runner() -> bool: def _has_internet_connection() -> bool: + """Check if internet connection is available. + + Returns: + True if internet connection is available, False otherwise. + """ try: # Try to connect to a well-known DNS server (Google's) socket.create_connection(("8.8.8.8", 53), timeout=2) @@ -44,6 +59,11 @@ def _has_internet_connection() -> bool: def _has_gpu() -> bool: + """Check if GPU is available. + + Returns: + True if GPU is available, False otherwise. + """ import torch return torch.cuda.is_available() diff --git a/tests/ml/conftest.py b/tests/ml/conftest.py index c4c13623..6cfbee33 100644 --- a/tests/ml/conftest.py +++ b/tests/ml/conftest.py @@ -11,9 +11,9 @@ from atomworks.io.tools.inference import SequenceComponent from atomworks.ml.datasets.datasets import ConcatDatasetWithID, PandasDataset from atomworks.ml.datasets.loaders import ( - loader_base, - loader_with_interfaces_and_pn_units_to_score, - loader_with_query_pn_units, + create_base_loader, + create_loader_with_interfaces_and_pn_units_to_score, + create_loader_with_query_pn_units, ) from atomworks.ml.datasets.parsers.base import DEFAULT_PARSER_ARGS from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline @@ -139,7 +139,7 @@ def af3_validation_df(): ########################################################################################## -# + ------------------------------------ Datasets -------------------------------------- + +# + ------------------------------------ Filters -------------------------------------- + ########################################################################################## SHARED_TEST_FILTERS = [ @@ -164,74 +164,19 @@ def af3_validation_df(): TEST_DIFFUSION_BATCH_SIZE = 32 # Set to a value other than default (48) for testing -# +--------------------------------------------------------------------------+ -# Base PandasDataset fixtures -# +--------------------------------------------------------------------------+ - -@pytest.fixture(scope="session") -def pn_units_pandas_dataset(pn_units_df): - return PandasDataset( - data=pn_units_df, - name="pn_units", - id_column="example_id", - filters=SHARED_TEST_FILTERS + TEST_PN_UNITS_FILTERS, - columns_to_load=None, # Load all columns - ) - - -@pytest.fixture(scope="session") -def interfaces_pandas_dataset(interfaces_df): - return PandasDataset( - data=interfaces_df, - name="interfaces", - id_column="example_id", - filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, - columns_to_load=None, # Load all columns - ) - - -@pytest.fixture(scope="session") -def validation_pandas_dataset(af3_validation_df): - return PandasDataset( - data=af3_validation_df, - name="validation", - id_column="example_id", - columns_to_load=None, # Load all columns - ) - - -@pytest.fixture(scope="session") -def af2_distillation_dataset_no_metadata(af2_distillation_df_no_metadata): - return PandasDataset( - data=af2_distillation_df_no_metadata, - id_column="example_id", - name="af2fb_distillation", - columns_to_load=["example_id", "sequence_hash", "path"], - ) - - -@pytest.fixture(scope="session") -def af2_distillation_dataset_with_metadata(af2_distillation_df_with_metadata): - return PandasDataset( - data=af2_distillation_df_with_metadata, - id_column="example_id", - name="af2fb_distillation", - columns_to_load=["example_id", "sequence_hash", "path"], - ) - - -# +--------------------------------------------------------------------------+ -# RF2AA Dataset fixtures -# +--------------------------------------------------------------------------+ +########################################################################################## +# + ------------------------------------ Datasets -------------------------------------- + +########################################################################################## @pytest.fixture(scope="session") -def rf2aa_pn_units_dataset(pn_units_pandas_dataset): +def rf2aa_pn_units_dataset(pn_units_df): return PandasDataset( - data=pn_units_pandas_dataset.data, + data=pn_units_df, name="rf2aa_pn_units", - loader=loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), + id_column="example_id", + loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -245,15 +190,17 @@ def rf2aa_pn_units_dataset(pn_units_pandas_dataset): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, + filters=SHARED_TEST_FILTERS + TEST_PN_UNITS_FILTERS, ) @pytest.fixture(scope="session") -def rf2aa_interfaces_dataset(interfaces_pandas_dataset): +def rf2aa_interfaces_dataset(interfaces_df): return PandasDataset( - data=interfaces_pandas_dataset.data, + data=interfaces_df, name="rf2aa_interfaces", - loader=loader_with_query_pn_units( + id_column="example_id", + loader=create_loader_with_query_pn_units( pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH ), transform=build_rf2aa_transform_pipeline( @@ -269,6 +216,7 @@ def rf2aa_interfaces_dataset(interfaces_pandas_dataset): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, + filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, ) @@ -278,12 +226,12 @@ def rf2aa_pdb_dataset(rf2aa_pn_units_dataset, rf2aa_interfaces_dataset): @pytest.fixture(scope="session") -def rf2aa_validation_dataset(validation_pandas_dataset): +def rf2aa_validation_dataset(af3_validation_df): """Create a PandasDataset for RF2AA validation.""" return PandasDataset( - data=validation_pandas_dataset.data, + data=af3_validation_df, name="rf2aa_validation", - loader=loader_with_interfaces_and_pn_units_to_score( + loader=create_loader_with_interfaces_and_pn_units_to_score( path_colname="pdb_id", base_path=str(PDB_MIRROR_PATH), extension=".cif.gz", @@ -302,6 +250,7 @@ def rf2aa_validation_dataset(validation_pandas_dataset): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, + filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, ) @@ -311,11 +260,11 @@ def rf2aa_validation_dataset(validation_pandas_dataset): @pytest.fixture(scope="session") -def af3_pn_units_dataset(pn_units_pandas_dataset): +def af3_pn_units_dataset(pn_units_df): return PandasDataset( - data=pn_units_pandas_dataset.data, + data=pn_units_df, name="af3_pn_units", - loader=loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), + loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -329,15 +278,16 @@ def af3_pn_units_dataset(pn_units_pandas_dataset): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, + filters=SHARED_TEST_FILTERS + TEST_PN_UNITS_FILTERS, ) @pytest.fixture(scope="session") -def af3_interfaces_dataset(interfaces_pandas_dataset): +def af3_interfaces_dataset(interfaces_df): return PandasDataset( - data=interfaces_pandas_dataset.data, + data=interfaces_df, name="af3_interfaces", - loader=loader_with_query_pn_units( + loader=create_loader_with_query_pn_units( pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH ), transform=build_af3_transform_pipeline( @@ -353,6 +303,7 @@ def af3_interfaces_dataset(interfaces_pandas_dataset): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, + filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, ) @@ -362,11 +313,11 @@ def af3_pdb_dataset(af3_pn_units_dataset, af3_interfaces_dataset): @pytest.fixture(scope="session") -def af3_validation_dataset(validation_pandas_dataset): +def af3_validation_dataset(af3_validation_df): return PandasDataset( - data=validation_pandas_dataset.data, + data=af3_validation_df, name="af3_validation", - loader=loader_with_interfaces_and_pn_units_to_score( + loader=create_loader_with_interfaces_and_pn_units_to_score( path_colname="pdb_id", base_path=PDB_MIRROR_PATH, extension=".cif.gz", @@ -388,11 +339,11 @@ def af3_validation_dataset(validation_pandas_dataset): @pytest.fixture(scope="session") -def af3_af2fb_distillation_dataset_no_metadata(distillation_pandas_dataset_no_metadata): +def af2_distillation_dataset_no_metadata(af2_distillation_df_no_metadata): return PandasDataset( - data=distillation_pandas_dataset_no_metadata.data, + data=af2_distillation_df_no_metadata, name="af3_af2fb_distillation_no_metadata", - loader=loader_base( + loader=create_base_loader( base_path=str(TEST_DATA_ML / "af2_distillation" / "cif"), extension=".cif", ), @@ -409,11 +360,11 @@ def af3_af2fb_distillation_dataset_no_metadata(distillation_pandas_dataset_no_me @pytest.fixture(scope="session") -def af3_af2fb_distillation_dataset_with_metadata(distillation_pandas_dataset_with_metadata): +def af2_distillation_dataset_with_metadata(af2_distillation_df_with_metadata): return PandasDataset( - data=distillation_pandas_dataset_with_metadata.data, + data=af2_distillation_df_with_metadata, name="af3_af2fb_distillation_with_metadata", - loader=loader_base(), + loader=create_base_loader(), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=[], @@ -427,8 +378,8 @@ def af3_af2fb_distillation_dataset_with_metadata(distillation_pandas_dataset_wit @pytest.fixture(scope="session") -def af3_af2fb_distillation_concat_dataset(af3_af2fb_distillation_dataset_no_metadata): - return ConcatDatasetWithID(datasets=[af3_af2fb_distillation_dataset_no_metadata]) +def af3_af2fb_distillation_concat_dataset(af2_distillation_dataset_no_metadata): + return ConcatDatasetWithID(datasets=[af2_distillation_dataset_no_metadata]) ########################################################################################## diff --git a/tests/ml/datasets/test_datasets.py b/tests/ml/datasets/test_datasets.py index 50a230b0..ef3693d0 100644 --- a/tests/ml/datasets/test_datasets.py +++ b/tests/ml/datasets/test_datasets.py @@ -56,8 +56,8 @@ def test_nested_dummy_datasets(): assert row.attrs["base_path"] is not None -def test_structural_datasets(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, rf2aa_pdb_dataset): - # +------------------ Structural Dataset ------------------+ +def test_nested_datasets_with_weighted_samplers(rf2aa_interfaces_dataset, rf2aa_pn_units_dataset, rf2aa_pdb_dataset): + # +------------------ Sampler ------------------+ num_examples_per_epoch = 100 # ... calculate the weights based on the AF-3 weighting methodology From ea9afda4f0e1e9ba6892880b8e8ede8af1491cbb Mon Sep 17 00:00:00 2001 From: Nathaniel Corley Date: Wed, 17 Sep 2025 14:19:57 -0700 Subject: [PATCH 4/8] fix: inference APIs for RF3 (#28) * fix: inference APIs for RF3 * fix: tests * fix: local tests --- src/atomworks/io/parser.py | 6 +- src/atomworks/io/tools/inference.py | 75 +++- src/atomworks/io/tools/rdkit.py | 2 + src/atomworks/io/utils/bonds.py | 2 + src/atomworks/io/utils/io_utils.py | 6 + src/atomworks/io/utils/non_rcsb.py | 8 +- src/atomworks/io/utils/selection.py | 377 +++++++++++++++----- src/atomworks/io/utils/testing.py | 12 + tests/io/tools/test_inference_processing.py | 1 - tests/io/utils/test_io.py | 1 - tests/io/utils/test_selection_utils.py | 36 +- tests/ml/conftest.py | 1 - 12 files changed, 405 insertions(+), 122 deletions(-) diff --git a/src/atomworks/io/parser.py b/src/atomworks/io/parser.py index cb0956d1..888e9ba0 100644 --- a/src/atomworks/io/parser.py +++ b/src/atomworks/io/parser.py @@ -158,7 +158,7 @@ def parse( build_assembly (string, list, or tuple, optional): Specifies which assembly to build, if any. Options are None (e.g., asymmetric unit), "first", "all", or a list or tuple of assembly IDs. Defaults to "all". extra_fields (list, optional): A list of extra fields to include in the AtomArrayStack. Defaults to None. "all" includes all fields. - Only support mmCIF files. + Only supports mmCIF files. keep_cif_block (bool, optional): Whether to keep the CIF block in the result. Defaults to False. Returns: @@ -551,7 +551,7 @@ def parse_atom_array( if msa_path != "": data_dict["chain_info"][chain]["msa_path"] = Path(msa_path) - # ... optionally, build assemblies and add assembly-specifc annotation (instance IDs) + # ... optionally, build assemblies and add assembly-specifc annotation (instance IDs like `chain_iid`, `pn_unit_iid`, `molecule_iid`) if exists(build_assembly): assert ( build_assembly in ["first", "all", "_spoof"] or isinstance(build_assembly, list | tuple) @@ -735,7 +735,7 @@ def _parse_from_pdb(filename: os.PathLike, **parse_from_cif_kwargs) -> dict[str, updated_chain_hetero_annotations = atom_array_stack.hetero[atom_array_stack.chain_id == chain_id] assert np.all(updated_chain_hetero_annotations) or np.all(~updated_chain_hetero_annotations) - # ...parse the CIF block into a dictionary + # ... parse the CIF block into a dictionary parse_from_cif_kwargs["file_type"] = "pdb" parse_from_cif_kwargs["extra_fields"] = None parse_from_cif_kwargs["build_assembly"] = "_spoof" diff --git a/src/atomworks/io/tools/inference.py b/src/atomworks/io/tools/inference.py index 4da19c82..d829921c 100644 --- a/src/atomworks/io/tools/inference.py +++ b/src/atomworks/io/tools/inference.py @@ -40,9 +40,10 @@ check_ccd_codes_are_available, get_chain_type_from_ccd_code, get_chem_comp_type, + parse_ccd_cif, ) from atomworks.io.utils.chain import create_chain_id_generator -from atomworks.io.utils.io_utils import CIF_LIKE_EXTENSIONS +from atomworks.io.utils.io_utils import CIF_LIKE_EXTENSIONS, read_any logger = logging.getLogger("atomworks.io") @@ -206,56 +207,83 @@ class SDFComponent(LigandComponent): class CIFOrPDBFileComponent(ChemicalComponent): path: os.PathLike | io.StringIO msa_paths: dict[str, os.PathLike] | None = None - chain_type: ChainType | str | None = None custom_parse_kwargs: dict[str, Any] | None = None def __post_init__(self): - if self.chain_type: - self.chain_type = ChainType.as_enum(self.chain_type) + """Initialize the component by parsing the structure file.""" + if self._is_ccd_cif_file(): + self._parse_ccd_style_cif() + else: + self._parse_standard_pdb_or_cif() + + def _is_ccd_cif_file(self) -> bool: + """Check if we are given a CCD CIF file, which by convention includes the _chem_comp_atom field but not the atom_site field""" + cif = read_any(self.path) + keys = list(cif.block.keys()) + + has_atom_site = "atom_site" in keys + has_chem_comp_atom = "chem_comp_atom" in keys + + return has_chem_comp_atom and not has_atom_site + + def _parse_ccd_style_cif(self) -> None: + """Parse a CCD-style CIF file.""" + + if self.custom_parse_kwargs is not None: + raise ValueError("Custom parse kwargs are not supported for CCD CIF files.") + + logger.warning( + f"CCD CIF file detected: {self.path}. " + "This file will be parsed as a CCD CIF file rather than a regular CIF file " + "(e.g., with an `atom_site` category)." + ) + + self.atom_array = parse_ccd_cif(read_any(self.path)) + self.atom_array.set_annotation("is_polymer", np.full(len(self.atom_array), False)) + self.chain_ids = np.unique(self.atom_array.chain_id) + + # Set occupancy to all 1s since we presumably want to predict everything + self.atom_array.occupancy = np.full(len(self.atom_array), 1.0) + + def _parse_standard_pdb_or_cif(self) -> None: + """Parse a standard PDB or CIF structure file.""" if self.custom_parse_kwargs is None: self.custom_parse_kwargs = {} + # We add missing atoms later to the fully-concatenated inference AtomArray parse_kwargs = {**DEFAULT_PARSE_KWARGS, "add_missing_atoms": False} | self.custom_parse_kwargs if parse_kwargs["add_missing_atoms"]: logger.warning( "Missing atoms will be added later to the fully-concatenated inference AtomArray. " - "It is recommended to set this argument to False in initial CIFOrPDBFileComponent parsing. " + "It is recommended to set this argument to False in initial CIFOrPDBFileComponent parsing." ) - # Parse using atomworks.io parsing_results = parse(self.path, **parse_kwargs) if "assemblies" in parsing_results: assemblies = parsing_results["assemblies"] - # We will keep only the first assembly that was parsed first_assembly_id = next(iter(assemblies.keys())) - # Give warning if multiple assemblies were parsed if len(assemblies) > 1: logger.warning( f"Multiple biological assemblies found in {self.path} and none were specified. " f"Only the first assembly (assembly_id={first_assembly_id}) will be used for inference. " - f"If you would like to use a different assembly, please specify this in the `parse_kwargs`." + "If you would like to use a different assembly, please specify this in the `parse_kwargs`." ) - # Get the atom array stack corresponding to this assembly atom_array_stack = assemblies[first_assembly_id] - - # Use the asymmetric unit if no assemblies were returned else: atom_array_stack = parsing_results["asym_unit"] - # We will keep only the first model of the parsed structure if atom_array_stack.stack_depth() > 1: logger.warning( f"Multiple models found in {self.path}. Only the first model will be used for inference. " - f"If you would like to use a different model, please specify this in the `parse_kwargs`." + "If you would like to use a different model, please specify this in the `parse_kwargs`." ) - structure_file_atom_array = atom_array_stack[0] - # Record chain ids and AtomArray + structure_file_atom_array = atom_array_stack[0] self.chain_ids = np.unique(structure_file_atom_array.chain_id) self.atom_array = structure_file_atom_array @@ -663,6 +691,13 @@ def components_to_atom_array( for component in components: # CIFOrPDBFileComponents already have parsed AtomArrays if isinstance(component, CIFOrPDBFileComponent): + atom_array = component.atom_array + if np.any(atom_array.chain_id == ""): + atom_array.chain_id = np.full(atom_array.array_length(), next(chain_id_generator)) + logger.warning( + f"Chain ID was not set for {component.path}. " + f"The next available chain ID was assigned, assuming that this is a single-chain structure: {atom_array.chain_id[0]}" + ) atom_arrays.append(component.atom_array) continue @@ -672,12 +707,16 @@ def components_to_atom_array( atom_arrays.append(sequence_to_annotated_atom_array(**component.as_dict(), custom_residues=custom_residues)) elif isinstance(component, SmilesComponent): ligand_array = smiles_to_annotated_atom_array(**component.as_dict()) - atom_arrays.append(assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id)) + if component.res_name == UNKNOWN_LIGAND: + ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id) + atom_arrays.append(ligand_array) elif isinstance(component, CCDComponent): atom_arrays.append(ccd_code_to_annotated_atom_array(**component.as_dict())) elif isinstance(component, SDFComponent): ligand_array = sdf_to_annotated_atom_array(**component.as_dict()) - atom_arrays.append(assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id)) + if component.res_name == UNKNOWN_LIGAND: + ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id) + atom_arrays.append(ligand_array) else: raise ValueError(f"Unknown chemical component type: {type(component)}") diff --git a/src/atomworks/io/tools/rdkit.py b/src/atomworks/io/tools/rdkit.py index 05c9b575..c260b96a 100644 --- a/src/atomworks/io/tools/rdkit.py +++ b/src/atomworks/io/tools/rdkit.py @@ -65,6 +65,7 @@ (Chem.BondType.DOUBLE, False): struc.bonds.BondType.DOUBLE, (Chem.BondType.TRIPLE, False): struc.bonds.BondType.TRIPLE, (Chem.BondType.QUADRUPLE, False): struc.bonds.BondType.QUADRUPLE, + (Chem.BondType.DATIVE, False): struc.bonds.BondType.COORDINATION, (Chem.BondType.SINGLE, True): struc.bonds.BondType.AROMATIC_SINGLE, (Chem.BondType.DOUBLE, True): struc.bonds.BondType.AROMATIC_DOUBLE, (Chem.BondType.TRIPLE, True): struc.bonds.BondType.AROMATIC_TRIPLE, @@ -83,6 +84,7 @@ struc.bonds.BondType.DOUBLE: (Chem.BondType.DOUBLE, False), struc.bonds.BondType.TRIPLE: (Chem.BondType.TRIPLE, False), struc.bonds.BondType.QUADRUPLE: (Chem.BondType.QUADRUPLE, False), + struc.bonds.BondType.COORDINATION: (Chem.BondType.DATIVE, False), # NOTE: We map aromatics to single/double/triple instead of Chem.BondType.AROMATIC # because the PDB specified bond-order (from a kekulized form of the molecule) # is lost when we map to aromatic, which can lead to incorrect bond-order diff --git a/src/atomworks/io/utils/bonds.py b/src/atomworks/io/utils/bonds.py index 5da28f2f..33e5c145 100644 --- a/src/atomworks/io/utils/bonds.py +++ b/src/atomworks/io/utils/bonds.py @@ -812,6 +812,8 @@ def spoof_struct_conn_dict_from_string(bonds: list[tuple[str, str]]) -> dict[str NOTE: We only support covalent bonds. + TODO: Use AtomSelection to parse the bond strings + Args: bonds (list[tuple[str, str]]): A list of bond strings. Each bond string should be in the format: diff --git a/src/atomworks/io/utils/io_utils.py b/src/atomworks/io/utils/io_utils.py index 1661a2b2..0ba58753 100644 --- a/src/atomworks/io/utils/io_utils.py +++ b/src/atomworks/io/utils/io_utils.py @@ -570,6 +570,12 @@ def _to_cif_or_bcif( if not include_nan_coords: structure = ta.remove_nan_coords(structure) + if include_bonds and structure.bonds is not None: + # TODO: Switch to using the `convert_bond_type` method once we upgrade to Biotite v1.4.0 + # structure.bonds.convert_bond_type(struc.bonds.BondType.COORDINATION, struc.bonds.BondType.SINGLE) + mask = structure.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION + structure.bonds._bonds[mask, 2] = struc.bonds.BondType.SINGLE + pdbx.set_structure(cif_file, structure, data_block=id, include_bonds=include_bonds, extra_fields=extra_fields) # Add extra categories if provided diff --git a/src/atomworks/io/utils/non_rcsb.py b/src/atomworks/io/utils/non_rcsb.py index 7a44a668..54c1781f 100644 --- a/src/atomworks/io/utils/non_rcsb.py +++ b/src/atomworks/io/utils/non_rcsb.py @@ -120,7 +120,7 @@ def initialize_chain_info_from_atom_array( In particular, this function adds the following information to the chain_info_dict: - The RCSB entity ID for each chain (e.g., 1, 2, 3, etc.), if present in the AtomArray (under the entity_id atom site label) - The unprocessed one-letter entity canonical and non-canonical sequences. - - (OptionallyA boolean flag indicating whether the chain is a polymer. + - (Optionally) A boolean flag indicating whether the chain is a polymer. - (Optionally) The chain type as an IntEnum (e.g., polypeptide(L), non-polymer, etc.) - (Optionally) The residue IDs and residue names, inferred from the AtomArray. @@ -147,8 +147,12 @@ def initialize_chain_info_from_atom_array( res_names = atom_array.res_name[_res_starts] hetero = atom_array.hetero[_res_starts] - # Loop through chains for chain_identifier in np.unique(chain_identifiers): + if not chain_identifier: + raise ValueError( + 'Chain identifier is empty! Please ensure that in your input file, each chain has a unique identifier (e.g., `label_asym_id` in a CIF file cannot be "").' + ) + is_in_chain = chain_identifiers == chain_identifier seq = res_names[is_in_chain] diff --git a/src/atomworks/io/utils/selection.py b/src/atomworks/io/utils/selection.py index bca87ff4..c8575861 100644 --- a/src/atomworks/io/utils/selection.py +++ b/src/atomworks/io/utils/selection.py @@ -1,10 +1,21 @@ -"""Utility functions for selecting segments of an AtomArray""" +"""Tools for atom and segment selection on ``AtomArray`` and ``AtomArrayStack``. -__all__ = ["annot_start_stop_idxs", "get_annotation", "get_residue_starts"] +Provides helpers to compute segment boundaries and apply expressive selection syntax to structures. + +Key public objects: +- :py:class:`~atomworks.io.utils.selection.AtomSelection` +- :py:class:`~atomworks.io.utils.selection.AtomSelectionStack` +- :py:class:`~atomworks.io.utils.selection.SegmentSlice` + +See individual docstrings for usage and examples. +""" + +__all__ = ["AtomSelection", "AtomSelectionStack", "annot_start_stop_idxs", "get_annotation", "get_residue_starts"] import re from abc import ABC, abstractmethod from functools import reduce +from itertools import product from typing import Any, Literal import biotite.structure as struc @@ -18,16 +29,15 @@ def annot_start_stop_idxs( atom_array: AtomArray | AtomArrayStack, annots: str | list[str], add_exclusive_stop: bool = False ) -> np.ndarray: - """ - Computes the start and stop indices for segments in an AtomArray where any of the specified annotation(s) change. + """Computes the start and stop indices for segments in an AtomArray where any of the specified annotation(s) change. Args: - - atom_array (AtomArray): The AtomArray to process. - - annots (str | list[str]): The annotation(s) to consider for determining segment boundaries. - - add_exclusive_stop (bool): If True, an exclusive stop index (the length of the AtomArray) is added to the result. + atom_array: The AtomArray to process. + annots: Annotation name or names to define segments. + add_exclusive_stop: Append an exclusive stop index at the end. Defaults to ``False``. Returns: - - np.ndarray: An array of start and stop indices for segments where the annotations change. + 1D array of start/stop indices that bound segments. Example: >>> atom_array = AtomArray(...) @@ -50,14 +60,21 @@ def annot_start_stop_idxs( def get_residue_starts(atom_array: AtomArray | AtomArrayStack, add_exclusive_stop: bool = False) -> np.ndarray: """Get the start (and optionally stop) indices of residues in an AtomArray. - More robust version of `biotite.structure.residues.get_residue_starts` that also - differentiates between residues resulting from different transformation ids. + This is a more robust version of :py:func:`biotite.structure.residues.get_residue_starts` + that additionally differentiates residues across different ``transformation_id`` values + when present. It is backwards compatible if the annotation is absent. + + Args: + atom_array: Structure to analyze. + add_exclusive_stop: Append an exclusive stop index at the end. Defaults to ``False``. + + Returns: + 1D array of residue boundary indices. - Backwards compatible with `biotite.structure.residues.get_residue_starts` if the - `transformation_id` annotation is not present. + References: + * `Biotite get_residue_starts`_ - Reference: - `Biotite residues.py `_ + .. _Biotite get_residue_starts: https://github.com/biotite-dev/biotite/blob/231eefed334e1d3509c1b7cb3f2bfd71d4b0eeb0/src/biotite/structure/residues.py#L35 """ _annots_to_check = ["chain_id", "res_name", "res_id", "ins_code", "transformation_id"] existing_annots = atom_array.get_annotation_categories() @@ -66,7 +83,17 @@ def get_residue_starts(atom_array: AtomArray | AtomArrayStack, add_exclusive_sto def _validate_n_body_and_type(atom_array: AtomArray | AtomArrayStack, n_body: int, operation: str) -> None: - """Validate n_body parameter and atom_array type compatibility.""" + """Validate ``n_body`` value and structure type. + + Args: + atom_array: Structure to validate. + n_body: Annotation dimensionality (1 or 2). + operation: Description used in error messages. + + Raises: + ValueError: If ``n_body > 1`` but ``atom_array`` is not ``AtomArrayPlus`` or ``AtomArrayStack``. + NotImplementedError: If ``n_body`` is not 1 or 2. + """ if n_body > 1 and not isinstance(atom_array, (AtomArrayPlus | AtomArrayStack)): raise ValueError(f"Cannot {operation} with n_body={n_body} on non-AtomArrayPlus!") @@ -77,7 +104,19 @@ def _validate_n_body_and_type(atom_array: AtomArray | AtomArrayStack, n_body: in def get_annotation( atom_array: AtomArray | AtomArrayStack, annot: str, n_body: int | None = None, default: Any = None ) -> np.ndarray: - """Get the annotation for an AtomArray or AtomArrayStack if it exists, otherwise return the default value.""" + """Return an annotation array if present, otherwise ``default``. + + If ``n_body`` is ``None``, the dimensionality is auto-detected by probing 1D then 2D annotation categories. + + Args: + atom_array: Structure to query. + annot: Annotation category name. + n_body: 1 for 1D annotations, 2 for 2D annotations; auto-detected if ``None``. + default: Value to return if the annotation is missing. Defaults to ``None``. + + Returns: + The requested annotation array or ``default`` if missing. + """ if n_body is not None: _validate_n_body_and_type(atom_array, n_body, f"get annotation for {annot}") else: @@ -98,11 +137,11 @@ def get_annotation_categories(atom_array: AtomArray | AtomArrayStack, n_body: in """Get annotation categories for the specified n_body. Args: - atom_array: The AtomArray or AtomArrayStack to query. - n_body: 1 for 1D annotations, 2 for 2D annotations, or "all" for all available n_body. + atom_array: Structure to query. + n_body: ``1`` for 1D, ``2`` for 2D, or ``"all"`` for both. Returns: - categories: list[str] List of annotation category names. + Names of available annotation categories for the requested dimensionality. """ # Map n_body to the corresponding method name n_body_to_method = { @@ -127,8 +166,7 @@ def get_annotation_categories(atom_array: AtomArray | AtomArrayStack, n_body: in class SegmentSlice(ABC): - """ - Abstract base class for slicing segments of an AtomArray or AtomArrayStack. + """Abstract base class for slicing segments of an AtomArray or AtomArrayStack. Provides functionality analogous to Python's built-in slice object but operates on structural segments (e.g., residues or chains indices) rather than individual atom indices. To subclass, implement the @@ -140,8 +178,8 @@ class SegmentSlice(ABC): - to slice to the last two residues: `atom_array[ResIdxSlice(-2, None)]` Args: - - start (int | None): The starting segment index. If None, starts from the beginning. - - stop (int | None): The ending segment index (exclusive). If None, continues to the end. + start: Starting segment index. Defaults to ``None``. + stop: Exclusive ending segment index. Defaults to ``None``. """ def __init__(self, start: int | None = None, stop: int | None = None): @@ -153,14 +191,13 @@ def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndar pass def __call__(self, atom_array: AtomArray | AtomArrayStack) -> slice: - """ - Creates a slice object for the specified segment range in the atom array. + """Creates a slice object for the specified segment range in the atom array. Args: - - atom_array (AtomArray | AtomArrayStack): The structure to slice. + atom_array: Structure to slice. Returns: - - slice: A slice object that can be used to index the atom array. + A Python ``slice`` that can be used to index ``atom_array``. """ seg_bounds = self._get_segment_bounds(atom_array) n_segments = len(seg_bounds) - 1 @@ -175,11 +212,10 @@ def __call__(self, atom_array: AtomArray | AtomArrayStack) -> slice: class ResIdxSlice(SegmentSlice): - """ - Slice atoms by residue indices. + """Slice atoms by residue indices. - Allows for selecting ranges of residues using Python slice-like syntax. Each residue is considered - as a segment, defined by changes in chain_id, res_name, res_id, ins_code, or transformation_id. + Residues are segmented by changes in ``chain_id``, ``res_name``, ``res_id``, + ``ins_code``, or ``transformation_id``. Example: >>> atom_array = AtomArray(...) @@ -192,8 +228,7 @@ def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndar class ChainIdxSlice(SegmentSlice): - """ - Slice atoms by chain indices. + """Slice atoms by chain indices. Allows for selecting ranges of chains using Python slice-like syntax. Each chain is considered as a segment, defined by changes in the chain_id annotation. @@ -209,15 +244,7 @@ def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndar class AtomSelection: - """Class that represents a selection of atoms in a molecular structure. - - We can specify a selection by chain_id, res_name, res_id, atom_name, and (optionally) transformation_id. - - For example: - - If we specify only chain_id, we will select all atoms in that chain (across all transformations) - - If we specify chain_id and res_name, we will select all atoms in that chain and residue - - If we specify only atom_name, we will select all atoms with that name, regardless of chain or residue - """ + """Represent a selection of atoms in a molecular structure.""" def __init__( self, @@ -227,6 +254,15 @@ def __init__( atom_name: str = "*", transformation_id: int | str = "*", ): + """Initialize a selection. + + Args: + chain_id: Chain identifier or ``"*"`` for any. Defaults to ``"*"``. + res_name: Residue name or ``"*"`` for any. Defaults to ``"*"``. + res_id: Residue index (integer) or ``"*"`` for any. Defaults to ``"*"``. + atom_name: Atom name or ``"*"`` for any. Defaults to ``"*"``. + transformation_id: Transformation id or ``"*"`` for any. Defaults to ``"*"``. + """ self.chain_id = chain_id self.res_name = res_name self.atom_name = atom_name @@ -248,7 +284,7 @@ def __repr__(self) -> str: def __eq__(self, other: Any) -> bool: if isinstance(other, str): # Convert the string to an AtomSelection for comparison - other = self.from_str(other) + other = self.from_selection_str(other) if not isinstance(other, AtomSelection): return False @@ -262,11 +298,24 @@ def __eq__(self, other: Any) -> bool: ) @classmethod - def from_str(cls, selection_string: str) -> "AtomSelection": - """Create a new AtomSelection from a selection string. + def from_selection_str(cls, selection_string: str) -> "AtomSelection": + """Create a selection from ``CHAIN/RES/RESID/ATOM/TRANSFORM`` syntax. + + ``"*"`` acts as a wildcard for any field. Trailing fields may be omitted + and default to ``"*"``. - Selection strings are of the form: `CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME/TRANSFORMATION_ID` - We use "*" as a wildcard to select all atoms in a given granularity. + Examples: + >>> # Selects the CA atom of the ALA residue at chain A, residue index 1 + >>> AtomSelection.from_selection_str("A/ALA/1/CA") + + >>> # Selects the CB atom of the ALA residue in any chain at any residue index + >>> AtomSelection.from_selection_str("*/ALA/*/CB") + + >>> # Selects all atoms of the ALA residue at chain A + >>> AtomSelection.from_selection_str("A/ALA/") + + >>> # Selects the CA atom of the ALA residue at chain A, residue index 1, transformation index 1 + >>> AtomSelection.from_selection_str("A/ALA/1/CA/1") """ selection = parse_selection_string(selection_string) @@ -280,12 +329,18 @@ def from_str(cls, selection_string: str) -> "AtomSelection": @classmethod def from_pymol_str(cls, pymol_string: str) -> "AtomSelection": - """Create a new AtomSelection from a PyMOL string. + """Create a selection from a PyMOL atom label string. + + PyMOL strings are of the form ``CHAIN/RES`RESID/ATOM`` and do not support + ``transformation_id``. ``"*"`` may be used as a wildcard. + + PyMOL strings do not support transformation_id. - PyMOL strings, found by clicking on an atom or residue, are of the form: CHAIN_ID/RES_NAME`RES_ID/ATOM_NAME - For example: "A/ASP`37/OD2" + We introduce to default PyMOL syntax the "*" operator as a wildcard to select all atoms in a given granularity. - We introduce "*" as a wildcard to select all atoms in a given granularity. + Example: + >>> # Selects the OD2 atom of the ASP residue at chain A, residue index 37 + >>> AtomSelection.from_pymol_str("A/ASP`37/OD2") """ selection = parse_pymol_string(pymol_string) return cls( @@ -305,21 +360,13 @@ def get_idxs(self, atom_array: AtomArray) -> np.ndarray: def parse_selection_string(selection_string: str) -> AtomSelection: - """Convert a selection string into a AtomSelection dataclass. + """Parse ``CHAIN/RES/RESID/ATOM/TRANSFORM`` into an :py:class:`AtomSelection`. - Selection strings are of the form: `CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME/TRANSFORMATION_ID` + ``"*"`` acts as a wildcard for any field. Trailing fields may be omitted + and default to ``"*"``. - We use "*" as a wildcard to select all atoms in a given granularity. - - Example: - >>> parse_selection_string("A/ALA/1/CA") - AtomSelection(chain_id='A', res_name='ALA', res_id=1, atom_name='CA') - >>> parse_selection_string("*/ALA/*/CB") # (select all CB atoms in ALA residues) - AtomSelection(chain_id='*', res_name='ALA', res_id='*', atom_name='CB') - >>> parse_selection_string("A/ALA/") - AtomSelection(chain_id='A', res_name='ALA') - >>> parse_selection_string("A/*/*/*/1") - AtomSelection(chain_id='A', res_name='*', res_id='*', atom_name='*', transformation_id=1) + See Also: + :py:meth:`~atomworks.io.utils.selection.AtomSelection.from_selection_str` """ granularity_tiers = ["chain_id", "res_name", "res_id", "atom_name", "transformation_id"] values = selection_string.split("/") @@ -331,20 +378,14 @@ def parse_selection_string(selection_string: str) -> AtomSelection: def parse_pymol_string(pymol_string: str) -> AtomSelection: - """Convert a PyMOL selection string into an AtomSelection instance. + """Parse a PyMOL string ``CHAIN/RES`RESID/ATOM`` into an :py:class:`AtomSelection`. PyMOL selection strings are of the form: CHAIN_ID/RES_NAME`RES_ID/ATOM_NAME - Wildcards can be used with "*". PyMOL selection strings do not support transformation_id. - Examples: - >>> parse_pymol_string("A/ASP`37/OD2") - AtomSelection(chain_id='A', res_name='ASP', res_id=37, atom_name='OD2') - >>> parse_pymol_string("A/ASP") - AtomSelection(chain_id='A', res_name='ASP', res_id='*', atom_name='*') - >>> parse_pymol_string("*/ASP`*/OD2") - AtomSelection(chain_id='*', res_name='ASP', res_id='*', atom_name='OD2') + See Also: + :py:meth:`~atomworks.io.utils.selection.AtomSelection.from_pymol_str` """ # Replace backtick with slash to standardize the format standardized_string = pymol_string.replace("`", "/") @@ -354,20 +395,27 @@ def parse_pymol_string(pymol_string: str) -> AtomSelection: def get_mask_from_selection_string(atom_array: AtomArray, selection_string: str) -> np.ndarray: """Create a boolean mask from an AtomArray sequence selection string. - Selection strings are of the form: `CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME/TRANSFORMATION_ID` - - We use "*" as a wildcard to select all atoms in a given granularity. + Selection strings follow ``CHAIN/RES/RESID/ATOM/TRANSFORM`` with ``"*"`` as a + wildcard for any field. Trailing fields may be omitted. Example: >>> atom_array = AtomArray(...) >>> mask = get_mask_from_selection_string(atom_array, "A/ALA/1/CA") [False, True, False, False, ...] + + See Also: + :py:func:`~atomworks.io.utils.selection.parse_selection_string` """ return get_mask_from_atom_selection(atom_array, parse_selection_string(selection_string)) def get_mask_from_atom_selection(atom_array: AtomArray, atom_selection: AtomSelection) -> np.ndarray: - """Create a boolean mask from a AtomSelection dataclass.""" + """Create a boolean mask from an :py:class:`AtomSelection`. + + See Also: + :py:func:`~atomworks.io.utils.selection.parse_selection_string` + """ + # TODO: Refactor using AtomArray query syntax mask = np.ones(atom_array.array_length(), dtype=bool) # ... add the masks @@ -393,21 +441,47 @@ def get_mask_from_atom_selection(atom_array: AtomArray, atom_selection: AtomSele class AtomSelectionStack: - """Class that represents a stack of AtomSelections. + """Manage multiple :py:class:`AtomSelection` objects as a unioned query. + + Supports ranges and comma-separated tokens via :py:meth:`from_query` and + contiguous ranges via :py:meth:`from_contig`. - Useful for managing multiple selections and applying them to an AtomArrayStack. - Notably, enables the use of a single selection string to select multiple segments. + See Also: + :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_query`, + :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_contig` """ def __init__(self, selections: list[AtomSelection]): + """Initialize a stack of selections. + + Args: + selections: Sequence of selections to be unioned. + """ self.selections = selections @classmethod - def from_contig_string(cls, contig_string: str) -> "AtomSelectionStack": + def from_contig(cls, contig: str) -> "AtomSelectionStack": + """Create a stack from contiguous residue ranges. + + Contig strings specify inclusive residue index ranges, e.g. ``"A1-2"`` + or ``"A1-2, B3-10"``. + + Args: + contig: Contiguous residue selection string like ``"A1-2, B3-10"``. + + Examples: + >>> # Selects residues 1..2 in chain A + >>> AtomSelectionStack.from_contig("A1-2") + >>> # Selects residues 1..2 in chain A and 3..10 in chain B + >>> AtomSelectionStack.from_contig("A1-2, B3-10") + + See Also: + :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_query` + """ # First define a regex that matches the elements of the contig string CONTIG_REGEX = re.compile(r"([A-Za-z]+)(\d+)-(\d+)") # noqa selections = [] - for selection in contig_string.replace(" ", "").split(","): + for selection in contig.replace(" ", "").split(","): match = CONTIG_REGEX.match(selection) if not match: raise ValueError(f"Invalid contig string: {selection}") @@ -419,12 +493,137 @@ def from_contig_string(cls, contig_string: str) -> "AtomSelectionStack": selections.append(atom_selection) return cls(selections) + @classmethod + def from_query(cls, query: str | list[str]) -> "AtomSelectionStack": + """Create a stack from extended query syntax with ranges. + + Extended syntax overview: + - **Chains**: ``A`` (all atoms in chain A), ``A/ALA`` (all ALA in chain A) + - **Ranges (``res_id`` only)**: ``A/*/5-10`` selects residues 5..10 in chain A + + Grammar per field (``CHAIN/RES/RESID/ATOM/TRANSFORM``): + - ``"*"`` wildcard + - Exact value, e.g. ``"A"``, ``"ALA"``, ``"CA"`` + - Range (``res_id`` only): ``"5-10"`` (inclusive) + + Notes: + - Fields are in order: CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME/TRANSFORMATION_ID + - Wildcard is "*". Missing trailing fields default to "*". + - Multiple comma-separated tokens are combined by union. + + Multiple tokens may be provided as a comma-separated string or ``list[str]``. + + Examples: + >>> # Selects residues 5..10 in chain A + >>> AtomSelectionStack.from_query("A/*/5-10") + >>> # Selects residues 5..10 in chain A and 3..10 in chain B + >>> AtomSelectionStack.from_query("A/*/5-10, B/*/3-10") + >>> # Selects residues 5..10 in chain A and 3..10 in chain B + >>> AtomSelectionStack.from_query(["A/*/5-10", "B/*/3-10"]) + """ + tokens = cls._parse_query_tokens(query) + selections: list[AtomSelection] = [] + + for token in tokens: + field_values = cls._parse_token_fields(token) + token_selections = cls._build_selections_from_fields(field_values) + selections.extend(token_selections) + + return cls(selections) + + @classmethod + def _parse_query_tokens(cls, query: str | list[str]) -> list[str]: + """Parse query input into individual tokens.""" + if isinstance(query, str): + return [tok.strip() for tok in query.split(",") if tok.strip()] + else: + return [tok.strip() for tok in query if tok and tok.strip()] + + @classmethod + def _parse_token_fields(cls, token: str) -> dict[str, list[Any]]: + """Parse a single token into field values.""" + parts = token.split("/") + + # Ensure five fields with '*' defaults + while len(parts) < 5: + parts.append("*") + chain_val, res_name_val, res_id_val, atom_name_val, trans_id_val = parts[:5] + + return { + "chain_id": cls._parse_field_value(chain_val, is_res_id=False), + "res_name": cls._parse_field_value(res_name_val, is_res_id=False), + "res_id": cls._parse_field_value(res_id_val, is_res_id=True), + "atom_name": cls._parse_field_value(atom_name_val, is_res_id=False), + "transformation_id": cls._parse_field_value(trans_id_val, is_res_id=False), + } + + @classmethod + def _parse_field_value(cls, value: str, *, is_res_id: bool = False) -> list[Any]: + """Parse a field value into a list of options. + + For ``res_id``, values are integers; for others, strings. + """ + v = value.strip() + if v == "*" or v == "": + return ["*"] + + return cls._extract_field_options(v, is_res_id=is_res_id) + + @classmethod + def _extract_field_options(cls, value: str, *, is_res_id: bool = False) -> list[Any]: + """Extract options from a field value (ranges or scalars).""" + # Range syntax: 5-10 (res_id only) + if is_res_id and re.fullmatch(r"-?\d+-?\d+", value): + start_s, stop_s = value.split("-", 1) + start_i, stop_i = int(start_s), int(stop_s) + step = 1 if start_i <= stop_i else -1 + return list(range(start_i, stop_i + step, step)) + + # Scalar value + return [int(value)] if is_res_id and value not in ("*", "") else [value] + + @classmethod + def _build_selections_from_fields(cls, field_values: dict[str, list[Any]]) -> list[AtomSelection]: + """Build selections from parsed field values, expanding sets and ranges.""" + # Extract field values directly as lists + chain_vals = field_values["chain_id"] + resn_vals = field_values["res_name"] + resi_vals = field_values["res_id"] + atom_vals = field_values["atom_name"] + tran_vals = field_values["transformation_id"] + + # Build selections via Cartesian product + selections = [ + AtomSelection( + chain_id=c if c != "*" else "*", + res_name=r if r != "*" else "*", + res_id=i if i != "*" else "*", + atom_name=a if a != "*" else "*", + transformation_id=t if t != "*" else "*", + ) + for c, r, i, a, t in product(chain_vals, resn_vals, resi_vals, atom_vals, tran_vals) + ] + + return selections + def get_mask(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Create a boolean mask using this AtomSelection on an AtomArray.""" - return reduce(np.logical_or, [selection.get_mask(atom_array) for selection in self.selections]) + """Create a boolean mask by unioning all selections.""" + if not self.selections: + return np.zeros(atom_array.array_length(), dtype=bool) + + masks = [selection.get_mask(atom_array) for selection in self.selections] + return reduce(np.logical_or, masks) def get_center_of_mass(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Get the center of mass of the selected atoms in the AtomArray.""" + """Return the center of mass of the selected atoms. + + Returns: + For :py:class:`~biotite.structure.AtomArray`: ``(3,)`` array. + For :py:class:`~biotite.structure.AtomArrayStack`: ``(n_models,)`` array of means. + + Raises: + ValueError: If no atoms are selected. + """ mask = self.get_mask(atom_array) if not np.any(mask): raise ValueError("No atoms selected by the AtomSelectionStack.") @@ -437,10 +636,14 @@ def get_center_of_mass(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarr raise ValueError(f"Cannot get center of mass for {type(atom_array)}!") def get_principal_components(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Get the principal components of the selected atoms in the AtomArray. + """Return principal axes (eigenvectors) of the selected atoms via SVD. Returns: - - np.ndarray: Principal axes (eigenvectors). For AtomArray: (3, 3). For AtomArrayStack: (n_models, 3, 3). + ``(3, 3)`` array for :py:class:`~biotite.structure.AtomArray`. + ``(n_models, 3, 3)`` array for :py:class:`~biotite.structure.AtomArrayStack`. + + Raises: + ValueError: If no atoms are selected. """ mask = self.get_mask(atom_array) if not np.any(mask): diff --git a/src/atomworks/io/utils/testing.py b/src/atomworks/io/utils/testing.py index 937f6aec..582abd3d 100644 --- a/src/atomworks/io/utils/testing.py +++ b/src/atomworks/io/utils/testing.py @@ -196,6 +196,10 @@ def assert_same_atom_array( arr2, AtomArray | AtomArrayStack ), f"arr2 is not an AtomArray or AtomArrayStack but has type {type(arr2)}" + # Copy both arrays to avoid modifying the original arrays + arr1 = arr1.copy() + arr2 = arr2.copy() + # If the input is a stack, only compare the first array if isinstance(arr1, AtomArrayStack): arr1 = arr1[0] @@ -310,6 +314,14 @@ def convert_atom_array_to_sorted_tuples(arr: AtomArray, annotations: list[str]) assert arr1.bonds is not None, "arr1.bonds is None" assert arr2.bonds is not None, "arr2.bonds is None" + # TODO: Switch to using the `convert_bond_type` method once we upgrade to Biotite v1.4.0 + # structure.bonds.convert_bond_type(struc.bonds.BondType.COORDINATION, struc.bonds.BondType.SINGLE) + mask_1 = arr1.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION + arr1.bonds._bonds[mask_1, 2] = struc.bonds.BondType.SINGLE + + mask_2 = arr2.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION + arr2.bonds._bonds[mask_2, 2] = struc.bonds.BondType.SINGLE + if enforce_order: # Compare bond arrays directly bonds1 = arr1.bonds.as_array() diff --git a/tests/io/tools/test_inference_processing.py b/tests/io/tools/test_inference_processing.py index 68eb714b..f80de814 100644 --- a/tests/io/tools/test_inference_processing.py +++ b/tests/io/tools/test_inference_processing.py @@ -133,7 +133,6 @@ def custom_residues(): return { "C:0": { "path": f"{TEST_DATA_IO}/example_ncaa.cif", - "chain_type": "polypeptide(l)", } } diff --git a/tests/io/utils/test_io.py b/tests/io/utils/test_io.py index 78ef52ab..cf34f6aa 100644 --- a/tests/io/utils/test_io.py +++ b/tests/io/utils/test_io.py @@ -511,7 +511,6 @@ def custom_residues(): return { "C:0": { "path": f"{TEST_DATA_IO}/example_ncaa.cif", - "chain_type": "polypeptide(l)", } } diff --git a/tests/io/utils/test_selection_utils.py b/tests/io/utils/test_selection_utils.py index 4f7fa94a..888f2685 100644 --- a/tests/io/utils/test_selection_utils.py +++ b/tests/io/utils/test_selection_utils.py @@ -164,7 +164,7 @@ def test_parse_selection_string(selection_string, pymol_string, expected_selecti from_pymol_string = parse_pymol_string(pymol_string) assert from_selection_string == expected_selection assert from_pymol_string == expected_selection - assert from_selection_string == AtomSelection.from_str(selection_string) + assert from_selection_string == AtomSelection.from_selection_str(selection_string) def test_get_mask_from_selection_string(basic_atom_array: struc.AtomArray): @@ -172,13 +172,13 @@ def test_get_mask_from_selection_string(basic_atom_array: struc.AtomArray): mask = get_mask_from_selection_string(basic_atom_array, "A/ALA/1/CA") expected_mask = np.array([False, True, False, False, False, False], dtype=bool) assert np.array_equal(mask, expected_mask) - assert np.array_equal(mask, AtomSelection.from_str("A/ALA/1/CA").get_mask(basic_atom_array)) + assert np.array_equal(mask, AtomSelection.from_selection_str("A/ALA/1/CA").get_mask(basic_atom_array)) # Test partial match mask = get_mask_from_selection_string(basic_atom_array, "A/ALA") expected_mask = np.array([True, True, False, False, False, False], dtype=bool) assert np.array_equal(mask, expected_mask) - assert np.array_equal(mask, AtomSelection.from_str("A/ALA").get_mask(basic_atom_array)) + assert np.array_equal(mask, AtomSelection.from_selection_str("A/ALA").get_mask(basic_atom_array)) # Test no match raises ValueError with pytest.raises(ValueError, match="No atoms found for selection: A/VAL/1/CB"): @@ -192,18 +192,18 @@ def test_get_mask_from_selection_string(basic_atom_array: struc.AtomArray): @pytest.mark.parametrize("contig_test_case", CONTIG_TEST_CASES) -def test_get_mask_from_contig_string(contig_test_case: str): +def test_get_mask_from_contig(contig_test_case: str): contig_string, expected_length = contig_test_case - selection_stack = AtomSelectionStack.from_contig_string(contig_string) + selection_stack = AtomSelectionStack.from_contig(contig_string) assert isinstance(selection_stack, AtomSelectionStack) assert len(selection_stack.selections) == expected_length @pytest.mark.parametrize("contig_test_case", CONTIG_TEST_CASES) -def test_get_mask_from_contig_string_with_atom_array(basic_atom_array: struc.AtomArray, contig_test_case: str): +def test_get_mask_from_contig_with_atom_array(basic_atom_array: struc.AtomArray, contig_test_case: str): contig_string, expected_length = contig_test_case - selection_stack = AtomSelectionStack.from_contig_string(contig_string) + selection_stack = AtomSelectionStack.from_contig(contig_string) residue_starts = get_residue_starts(basic_atom_array) mask = selection_stack.get_mask(basic_atom_array) @@ -214,7 +214,7 @@ def test_get_mask_from_contig_string_with_atom_array(basic_atom_array: struc.Ato def test_atom_selection_stack_get_center_of_mass(basic_atom_array: struc.AtomArray): """Test that get_center_of_mass returns the correct center for selected atoms.""" - selection_stack = AtomSelectionStack.from_contig_string("A1-2, B3-3") + selection_stack = AtomSelectionStack.from_contig("A1-2, B3-3") center_of_mass = selection_stack.get_center_of_mass(basic_atom_array) expected_center = np.mean(basic_atom_array[selection_stack.get_mask(basic_atom_array)].coord, axis=0) assert np.allclose(center_of_mass, expected_center) @@ -229,7 +229,7 @@ def test_atom_selection_stack_get_center_of_mass(basic_atom_array: struc.AtomArr def test_atom_selection_stack_get_principle_components(basic_atom_array: struc.AtomArray): """Test that get_principle_components returns correct principal axes for selected atoms.""" - selection_stack = AtomSelectionStack.from_contig_string("A1-2, B3-3") + selection_stack = AtomSelectionStack.from_contig("A1-2, B3-3") # AtomArray case pcs = selection_stack.get_principal_components(basic_atom_array) coords = basic_atom_array[selection_stack.get_mask(basic_atom_array)].coord @@ -252,5 +252,23 @@ def test_atom_selection_stack_get_principle_components(basic_atom_array: struc.A assert np.allclose(np.abs(pcs_stack[i]), np.abs(expected_pcs)) +def test_atom_selection_stack_from_query_ranges(basic_atom_array: struc.AtomArray) -> None: + """Select a range of residue IDs within a chain using extended syntax.""" + selection_stack = AtomSelectionStack.from_query("A/*/1-2") + mask = selection_stack.get_mask(basic_atom_array) + # Expect residues 1 and 2 in chain A (first four atoms) + expected_mask = np.array([True, True, True, True, False, False], dtype=bool) + assert np.array_equal(mask, expected_mask) + + +def test_atom_selection_stack_from_query_multiple_tokens(basic_atom_array: struc.AtomArray) -> None: + """Union of multiple selection tokens.""" + selection_stack = AtomSelectionStack.from_query(["A/ALA", "B/VAL"]) # include ALA in chain A and VAL in chain B + mask = selection_stack.get_mask(basic_atom_array) + # Expect ALA in chain A (first two atoms) and VAL in chain B (last two atoms) + expected_mask = np.array([True, True, False, False, True, True], dtype=bool) + assert np.array_equal(mask, expected_mask) + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/ml/conftest.py b/tests/ml/conftest.py index 6cfbee33..48e55947 100644 --- a/tests/ml/conftest.py +++ b/tests/ml/conftest.py @@ -250,7 +250,6 @@ def rf2aa_validation_dataset(af3_validation_df): template_base_dir=TEMPLATE_DIR, ), save_failed_examples_to_dir=None, - filters=SHARED_TEST_FILTERS + TEST_INTERFACES_FILTERS, ) From 252f2ed5f719ea32ef92d2459595cc1b86cf592e Mon Sep 17 00:00:00 2001 From: Simon Mathis Date: Wed, 17 Sep 2025 23:23:51 +0100 Subject: [PATCH 5/8] fix: re-enable github runner --- .github/workflows/lint_and_test.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/lint_and_test.yaml b/.github/workflows/lint_and_test.yaml index e3a149ad..bd52a90c 100644 --- a/.github/workflows/lint_and_test.yaml +++ b/.github/workflows/lint_and_test.yaml @@ -75,7 +75,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 needs: lint - if: github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From 60f9a5f390a87cc025e823b8f58b4745dde65efd Mon Sep 17 00:00:00 2001 From: Rachel Clune Date: Wed, 17 Sep 2025 18:04:42 -0700 Subject: [PATCH 6/8] fix: doc building from RosettaCommons (#16) * Change publish directory to build/html in workflow Changed from gh-pages, which is the branch name. As of making this change the GitHub pages site at the rosettacommons domain was throwing a 404 error, it could not locate an index.html file. This is an attempt to fix that error. * Change publish directory for GitHub Pages Missed the underscore in front of build - apologies! * fix: doc deployment * fix: revert change to release and docs to deploy only when merging to production --------- Co-authored-by: ncorley Co-authored-by: Nathaniel Corley --- .github/workflows/release_and_docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_and_docs.yaml b/.github/workflows/release_and_docs.yaml index ceccfec2..ba67708e 100644 --- a/.github/workflows/release_and_docs.yaml +++ b/.github/workflows/release_and_docs.yaml @@ -156,4 +156,4 @@ jobs: keep_files: true # Keep existing versions force_orphan: false # Don't force orphan, preserve history user_name: 'github-actions[bot]' - user_email: 'github-actions[bot]@users.noreply.github.com' \ No newline at end of file + user_email: 'github-actions[bot]@users.noreply.github.com' From 622a1a1ba10a3329e0b25aa36a3790b5c77dcfde Mon Sep 17 00:00:00 2001 From: Nathaniel Corley Date: Wed, 17 Sep 2025 18:10:16 -0700 Subject: [PATCH 7/8] chore: mr comment (#31) --- src/atomworks/ml/datasets/datasets.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/atomworks/ml/datasets/datasets.py b/src/atomworks/ml/datasets/datasets.py index 4d4e743f..c42ca6cf 100644 --- a/src/atomworks/ml/datasets/datasets.py +++ b/src/atomworks/ml/datasets/datasets.py @@ -281,7 +281,11 @@ def __init__( self.file_paths = file_paths # Create ID mapping - self.id_to_idx_map = {self._get_example_id(i): i for i, _ in enumerate(file_paths)} + self.id_to_idx_map = {self._get_example_id(i): i for i, _ in enumerate(self.file_paths)} + + # Verify that all example IDs are unique + if len(self.id_to_idx_map) != len(self.file_paths): + raise ValueError("Example IDs must be unique. Found duplicate example IDs.") @classmethod def from_directory( From 63b0d533bc506861f71ac29a4df9b2ea9d65ccdc Mon Sep 17 00:00:00 2001 From: ncorley Date: Wed, 17 Sep 2025 18:46:43 -0700 Subject: [PATCH 8/8] chore: doc release --- .github/workflows/release_and_docs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release_and_docs.yaml b/.github/workflows/release_and_docs.yaml index ba67708e..1aaeba3c 100644 --- a/.github/workflows/release_and_docs.yaml +++ b/.github/workflows/release_and_docs.yaml @@ -4,6 +4,7 @@ on: push: branches: - production + - doc_release jobs: release_and_docs: