Part 2: Creating a Transform Pipeline#
Table of Contents#
Introduction#
This is the second tutorial in the How to Build a Model with AtomWorks series. In Part 1 you cleaned the PDB metadata and saved train/validation/test parquet splits.
In this installment, you will learn how to transform each parquet file into tensors that can be used in a machine learning model.
More specifically, you will learn how to:
create custom transforms
create a transform pipeline
utilize the
PandasDatasetclass to go from parquets to trainable tensors
By the end of this part of the tutorial series you will have two python files:
transforms.py: Definition of custom transformssmoke_test.py: Testing the pipeline by loading a single example
Important
This tutorial will walk you through the creation of these two scripts.
For those who want to use the tutorial text as structure and hints to write your own code, the solutions are hidden in collapsible cells. The new code in cells with repeated code is highlighted.
If you would like to see the full scripts, they are provided in the tutorial files.
Prerequisites#
Before starting this part it is assumed that you have:
Completed Part 1 and saved
splits/train.parquet,splits/validation.parquet, andsplits/test.parquet.A working installation of AtomWorks, including the
mlside.A (partial) PDB mirror set up so the loader can find the structure file for each row (see Data Mirrors).
Familiarity with
biotite.structure.AtomArray, NumPy, and SciPy.
Note
The transforms you write here operate on a biotite AtomArray. If you have not worked with AtomArray objects before, it helps to skim the Biotite structure documentation so the coordinate and annotation access patterns below feel familiar.
The Goal of the Pipeline#
Our task is pose generation: given a protein pocket and a small-molecule ligand (from the parquet files we generated in Part 1), predict plausible bound cartesian coordinates for every ligand atom. To get there, each raw structure needs to be reduced to just the binding pocket and converted into tensors.
We will create a transform pipeline that can take protein/ligand pairs from the parquet files we created in Part 1, reduce the information to just the binding pocket, and convert them into tensors that can be used in the training of our machine learning model.
We will apply four transforms in order, two that already exist in AtomWorks and two that we will write ourselves:
RemoveHydrogens(exists already): drops hydrogen atoms.RemoveUnresolvedAtoms(exists already): drops atoms with occupancy below the default threshold of 0.5.CropToPocket(new): spatially crops the structure to the atoms within a radius of the ligand.FeaturizeForDocking(new): converts theAtomArrayinto the tensors our model needs.
A fifth transform, ConvertToTorch, converts the NumPy features into torch tensors. We add it in Part 4 when we build the training loop; for now we work in NumPy so the outputs are easy to inspect.
Wiring Up the Dataset and Loader#
Before writing any transforms, let’s confirm we can load a single example. AtomWorks provides two pieces we need:
PandasDatasetwraps a parquet/DataFrame and applies a loader and transform to each row.create_structure_loader()returns a picklable loader that parses the CIF file for each row and maps metadata columns to example keys, so we know which PN units the interface of interest involves.
Read the documentation for both (linked above) before using them. You can search the API docs, or use help() at a Python prompt. There is also more detail on PandasDataset in the Dataset Exploration and Management in AtomWorks example.
Click to see how to inspect the documentation in a Python prompt
from atomworks.ml.datasets import PandasDataset
from atomworks.ml.datasets.loaders import create_structure_loader
help(PandasDataset)
help(create_structure_loader)
Let’s create our PandasDataset. We need to supply it with
data: let’s use train.parquetname: however you want to label this structure, in the tutorial we’ll call itdocking_trainid_column: The column that has the unique identifier for each of piece of data,example_idloader: we will usecreate_structure_loaderhere
As a quick check that everything fits together, write a script (smoke_test.py) that reads train.parquet, builds a PandasDataset from it, and uses create_structure_loader as the loader.
Click to see the code
import pandas as pd
from atomworks.ml.datasets import PandasDataset
from atomworks.ml.datasets.loaders import create_structure_loader
df_train = pd.read_parquet("splits/train.parquet")
dataset = PandasDataset(
data=df_train,
name="docking_train",
id_column="example_id",
loader=create_structure_loader(
altloc_seed_colname="altloc_seed",
column_mapping={
"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"],
"query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"],
},
),
)
The loader is told which columns hold the query PN-unit IIDs and polymer flags. It also receives altloc_seed to reproduce the alternate-location selection used by the metadata.
Now load one example and inspect it. This tells us what keys the loader attaches and confirms the parquet, the loader, and your PDB mirror are all talking to each other.
Click to see the code
print(f"Dataset size: {len(dataset)}")
# Load a single example
example = dataset[0]
print("\nLoaded one sample successfully.")
print("Sample type:", type(example))
if hasattr(example, "keys"):
print("Sample keys:", list(example.keys()))
for k, v in example.items():
print(f"{k}: {type(v)}")
else:
print(example)
You should see that the example type is <class 'atomworks.ml.transforms.base.TransformedDict'>.
Among the keys you should see atom_array, query_pn_unit_iids, and query_is_polymer. These are the pieces of information that AtomWorks has pulled from the parquet files that we will use in our transforms pipeline:
atom_arrayis theAtomArrayfor this structure. Every atom carries apn_unit_iidannotation (atom_array.pn_unit_iid), tagging it with the PN unit it belongs to, using the same string format asquery_pn_unit_iidsbelow.query_pn_unit_iidsis a list of PN unit IID strings, one per column mapped toquery_pn_unit_iids. Each IID has the form"{chain_id}_{transformation_id}"(e.g."A_1"), or a comma-joined list of such tokens for a covalently-linked, multi-chain unit (e.g."G_1,R_1"). Since we asked for two columns (pn_unit_1_iidandpn_unit_2_iid), this list has two entries: the two sides of the interface we are studying.query_is_polymeris a list of polymer flags, in the same order asquery_pn_unit_iids. Part 1 keeps exactly one polymer side, soCropToPocketcan use these flags to work out which side is the protein and which is the ligand.
Tip
The loader also attaches other keys to example, such as example_id, metadata, and ligand_info, however the three listed above are the ones this tutorial’s transforms rely on.
Building the Transform Pipeline#
We can start creating our pipeline by adding to the code in our smoke_test.py file. We will use the Compose class to store the transforms, in an object we’ll call pipe, in the order we want them to be applied and then add that object to our dataset.
Let’s start with the two transforms that are native to AtomWorks, RemoveHydrogens and RemoveUnresolvedAtoms:
Click to see the code
import pandas as pd
from atomworks.ml.datasets import PandasDataset
from atomworks.ml.datasets.loaders import create_structure_loader
from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms
from atomworks.ml.transforms.base import Compose
df_train = pd.read_parquet("splits/train.parquet")
pipe = Compose([
RemoveHydrogens(),
RemoveUnresolvedAtoms(),
])
dataset = PandasDataset(
data=df_train,
name="docking_train",
id_column="example_id",
loader=create_structure_loader(
altloc_seed_colname="altloc_seed",
column_mapping={
"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"],
"query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"],
},
),
transform=pipe,
)
Creating Custom Transforms#
Now we need the two transforms that do not yet exist: CropToPocket and FeaturizeForDocking. The Creating Custom Transforms: Ligand Pocket Conditioning example is a great place to start.
Tip
Write your transforms in a separate transforms.py file. Each transform is a class whose forward() method calls a standalone function of the same name in snake_case. Keeping the logic in a standalone function makes it easy to test and reuse outside the transform machinery.
CropToPocket#
The CropToPocket transform will take the protein-ligand interfaces from our dataset and crop them to just the pocket. We will build CropToPocket up incrementally, confirming it plugs into Compose at each stage.
Start with a Shell#
Begin with a class that has an __init__(radius=10.0) and a forward() that just returns the input data unchanged. Confirm it plugs into Compose().
Click to see the code
In transforms.py:
from typing import ClassVar
from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys
from atomworks.ml.transforms.base import Transform
class CropToPocket(Transform):
def __init__(self, radius: float = 10.0) -> None:
super().__init__()
self.radius = radius
def forward(self, data: dict) -> dict:
return data
Back in your smoke_test.py file, make sure to add this transform to your pipeline:
from transforms import CropToPocket
...
pipe = Compose([
RemoveHydrogens(),
RemoveUnresolvedAtoms(),
CropToPocket(radius=10.0),
])
...
Input Validation#
We need to add input validation to our class, add a check_input() method. In check_input(), check that data contains atom_array, query_pn_unit_iids, and query_is_polymer, and that the atom array has a pn_unit_iid annotation.
Click to see the code
In the CropToPocket class:
def check_input(self, data: dict) -> None:
check_contains_keys(data, ["atom_array", "query_pn_unit_iids", "query_is_polymer"])
check_atom_array_annotation(data, ["pn_unit_iid"])
Write the crop_to_pocket Function#
Add a standalone crop_to_pocket() function that forward() calls. It should take atom_array, query_pn_unit_iids, query_is_polymer, and radius as inputs. For now, just return the AtomArray.
Click to see the code
In transforms.py:
import numpy as np
from biotite.structure import AtomArray
def crop_to_pocket(
atom_array: AtomArray,
query_pn_unit_iids: list[str],
query_is_polymer: list[bool],
radius: float = 10.0,
) -> AtomArray:
return atom_array
class CropToPocket(Transform):
...
def forward(self, data: dict) -> dict:
data["atom_array"] = crop_to_pocket(
data["atom_array"],
query_pn_unit_iids=data["query_pn_unit_iids"],
query_is_polymer=data["query_is_polymer"],
radius=self.radius,
)
return data
Each interface has two query PN units, a ligand and a protein (polymer). Add to the crop_to_pocket function to use query_is_polymer to assign the IIDs to ligand_iid and protein_iid. Exactly one side must be a polymer; raise an error if the flags are ambiguous.
Click to see the code
def crop_to_pocket(
atom_array: AtomArray,
query_pn_unit_iids: list[str],
query_is_polymer: list[bool],
radius: float = 10.0,
) -> AtomArray:
"""Keep the complete ligand and protein atoms within ``radius`` angstroms of it."""
if radius <= 0:
raise ValueError(f"Pocket radius must be positive, got {radius}")
if len(query_pn_unit_iids) != 2 or len(query_is_polymer) != 2 or sum(query_is_polymer) != 1:
raise ValueError("Expected two query PN units with exactly one polymer")
protein_side = 0 if query_is_polymer[0] else 1
ligand_side = 1 - protein_side
ligand_iid = query_pn_unit_iids[ligand_side]
protein_iid = query_pn_unit_iids[protein_side]
print("ligand_iid:", ligand_iid)
print("protein_iid:", protein_iid)
return atom_array
Create boolean masks to make sure that the PN unit actually contains atoms whose identity match the ligand_iid or protein_iid. Raise a clear error if either side is empty. Failing early with a descriptive message makes debugging bad structures much easier later.
Click to see the code
ligand_mask = atom_array.pn_unit_iid == ligand_iid
protein_mask = atom_array.pn_unit_iid == protein_iid
print("ligand atoms:", int(ligand_mask.sum()))
print("protein atoms:", int(protein_mask.sum()))
if ligand_mask.sum() == 0:
raise ValueError(f"Ligand {ligand_iid} has no atoms")
if protein_mask.sum() == 0:
raise ValueError(f"Protein {protein_iid} has no atoms")
return atom_array
Add the Spatial Crop with a KD-tree#
Now for the actual crop. We keep every ligand atom plus every protein atom within a given radius (in angstroms) of any ligand atom.
Note
A KD-tree (k-dimensional tree) is a data structure that partitions points in space so you can answer “which points are within radius r of this query point?” efficiently, without comparing every pair of atoms. SciPy’s cKDTree.query_ball_point returns, for each ligand atom, the indices of all protein atoms within radius.
Click to see the code
from scipy.spatial import cKDTree
def crop_to_pocket(
atom_array: AtomArray,
query_pn_unit_iids: list[str],
query_is_polymer: list[bool],
radius: float = 10.0,
) -> AtomArray:
"""Keep the complete ligand and protein atoms within ``radius`` angstroms of it."""
if radius <= 0:
raise ValueError(f"Pocket radius must be positive, got {radius}")
if len(query_pn_unit_iids) != 2 or len(query_is_polymer) != 2 or sum(query_is_polymer) != 1:
raise ValueError("Expected two query PN units with exactly one polymer")
protein_side = 0 if query_is_polymer[0] else 1
ligand_side = 1 - protein_side
ligand_iid = query_pn_unit_iids[ligand_side]
protein_iid = query_pn_unit_iids[protein_side]
ligand_mask = atom_array.pn_unit_iid == ligand_iid
protein_mask = atom_array.pn_unit_iid == protein_iid
ligand_coords = atom_array.coord[ligand_mask]
protein_coords = atom_array.coord[protein_mask]
if len(ligand_coords) == 0:
raise ValueError(f"Ligand {ligand_iid} has no atoms")
if len(protein_coords) == 0:
raise ValueError(f"Protein {protein_iid} has no atoms")
neighbors = cKDTree(protein_coords).query_ball_point(ligand_coords, r=radius)
if not any(neighbors):
raise ValueError(f"No protein atoms found within {radius} Å of ligand {ligand_iid}")
pocket_local_indices = np.unique(np.concatenate(neighbors).astype(int))
protein_global_indices = np.flatnonzero(protein_mask)
ligand_global_indices = np.flatnonzero(ligand_mask)
keep = np.sort(np.concatenate([protein_global_indices[pocket_local_indices], ligand_global_indices]))
cropped = atom_array[keep]
return cropped
The cKDTree is built from the protein coordinates; query_ball_point returns indices local to protein_coords, so we map them back to global indices via protein_global_indices before slicing the AtomArray.
Confirm the crop runs in your test. The crop keeps individual nearby protein atoms; it does not expand the selection to whole residues:
Click to see the code
example = dataset[0]
cropped = example["atom_array"]
print("\nLoaded one sample successfully.")
print("Cropped atom count:", len(cropped))
Add the is_ligand annotation#
The featurizer (and later analysis) needs to know which cropped atoms are ligand atoms. Annotate the cropped AtomArray with a boolean is_ligand array.
Important
This annotation is easy to forget: it is computed from ligand_mask[keep], but you must actually attach it to the array with set_annotation. If you skip this line, FeaturizeForDocking will fail because atom_array.is_ligand will not exist.
Click to see the code
Add the annotation just before returning, so the end of crop_to_pocket reads:
keep = np.sort(np.concatenate([protein_global_indices[pocket_local_indices], ligand_global_indices]))
cropped = atom_array[keep]
cropped.set_annotation("is_ligand", ligand_mask[keep])
return cropped
Quick check in your test:
Click to see the code
example = dataset[0]
cropped = example["atom_array"]
print("\nLoaded one sample successfully.")
print("Cropped atom count:", len(cropped))
print("Has is_ligand annotation:", hasattr(cropped, "is_ligand"))
print("Ligand atoms kept:", int(cropped.is_ligand.sum()))
print("Pocket atoms kept:", int((~cropped.is_ligand).sum()))
assert hasattr(cropped, "is_ligand")
assert cropped.is_ligand.any(), "No ligand atoms were kept"
assert (~cropped.is_ligand).any(), "No pocket atoms were kept"
print("CropToPocket sanity checks passed.")
Declare which transforms must run first#
CropToPocket assumes hydrogens and unresolved atoms are already gone. Declare this ordering requirement so AtomWorks can enforce it.
Click to see the code
class CropToPocket(Transform):
requires_previous_transforms: ClassVar[list[str]] = ["RemoveHydrogens", "RemoveUnresolvedAtoms"]
...
At this point you can assemble a full smoke_test.py:
Click to see the full Python file:
import pandas as pd
from atomworks.ml.datasets import PandasDataset
from atomworks.ml.datasets.loaders import create_structure_loader
from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms
from atomworks.ml.transforms.base import Compose
from transforms import CropToPocket
df_train = pd.read_parquet("splits/train.parquet")
pipe = Compose([
RemoveHydrogens(),
RemoveUnresolvedAtoms(),
CropToPocket(radius=10.0),
])
dataset = PandasDataset(
data=df_train,
name="docking_train",
id_column="example_id",
loader=create_structure_loader(
altloc_seed_colname="altloc_seed",
column_mapping={
"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"],
"query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"],
},
),
transform=pipe,
)
print(f"Dataset size: {len(dataset)}")
example = dataset[0]
cropped = example["atom_array"]
print("\nLoaded one sample successfully.")
print("Sample keys:", list(example.keys()))
print("Cropped atom count:", len(cropped))
print("Has is_ligand annotation:", hasattr(cropped, "is_ligand"))
print("Ligand atoms kept:", int(cropped.is_ligand.sum()))
print("Pocket atoms kept:", int((~cropped.is_ligand).sum()))
assert hasattr(cropped, "is_ligand")
assert cropped.is_ligand.any(), "No ligand atoms were kept"
assert (~cropped.is_ligand).any(), "No pocket atoms were kept"
print("\nCropToPocket smoke test passed.")
FeaturizeForDocking#
We follow the same incremental procedure. FeaturizeForDocking reads the cropped AtomArray and returns a dictionary of tensors that we merge into the example.
Start with a Shell#
Click to see the code
In transforms.py:
class FeaturizeForDocking(Transform):
def forward(self, data: dict) -> dict:
return data
Add it to Compose() in smoke_test.py:
from transforms import CropToPocket, FeaturizeForDocking
pipe = Compose([
RemoveHydrogens(),
RemoveUnresolvedAtoms(),
CropToPocket(radius=10.0),
FeaturizeForDocking(),
])
Declare the dependency on CropToPocket#
FeaturizeForDocking depends on the is_ligand annotation created by CropToPocket, so declare it as a required previous transform and validate the annotation in check_input().
Click to see the code
from typing import ClassVar
from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys
class FeaturizeForDocking(Transform):
"""Add atom, bond, and coordinate arrays for the docking model."""
requires_previous_transforms: ClassVar[list[str]] = ["CropToPocket"]
def check_input(self, data: dict) -> None:
check_contains_keys(data, ["atom_array"])
check_atom_array_annotation(data, ["is_ligand"])
Write the featurize_for_docking Function#
Start the function by returning an empty dictionary.
Also update the forward() function. Have it update the data dictionary with the dictionary features the featurize_for_docking function will eventually create.
Click to see the code
def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]:
return {}
class FeaturizeForDocking(Transform):
...
def forward(self, data: dict) -> dict:
features = featurize_for_docking(data["atom_array"])
data.update(features)
return data
Add the is_ligand Mask and Target Coordinates#
target_coords are the ground-truth coordinates the model must reproduce.
Click to see the code
def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]:
is_ligand = atom_array.is_ligand.astype(bool)
target_coords = atom_array.coord.astype(np.float32)
return {
"is_ligand": is_ligand,
"target_coords": target_coords,
}
Check the new features in your smoke test:
example = dataset[0]
print("Sample keys:", list(example.keys()))
print("is_ligand shape:", example["is_ligand"].shape)
print("ligand atoms:", int(example["is_ligand"].sum()))
assert "is_ligand" in example
assert example["is_ligand"].dtype == bool
print("target_coords shape:", example["target_coords"].shape)
print("target_coords dtype:", example["target_coords"].dtype)
assert example["target_coords"].ndim == 2
assert example["target_coords"].shape[1] == 3
Add input_coords with the ligand zeroed out#
This is the heart of the task. We keep the real pocket coordinates but zero out the ligand coordinates: the model must learn to place the ligand.
Click to see the code
def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]:
is_ligand = atom_array.is_ligand.astype(bool)
target_coords = atom_array.coord.astype(np.float32)
input_coords = target_coords.copy()
input_coords[is_ligand] = 0.0
return {
"is_ligand": is_ligand,
"target_coords": target_coords,
"input_coords": input_coords,
}
Add atomic_numbers#
Encode each atom’s element as an integer atomic number using the AtomWorks lookup table. These integers will feed an embedding layer in the model.
Click to see the code
from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER
...
def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]:
is_ligand = atom_array.is_ligand.astype(bool)
target_coords = atom_array.coord.astype(np.float32)
input_coords = target_coords.copy()
input_coords[is_ligand] = 0.0
atomic_numbers = np.array(
[ELEMENT_NAME_TO_ATOMIC_NUMBER.get(e.upper(), 0) for e in atom_array.element],
dtype=np.int64,
)
return {
"atomic_numbers": atomic_numbers,
"is_ligand": is_ligand,
"target_coords": target_coords,
"input_coords": input_coords,
}
Elements not found in the lookup table map to 0, which acts as an “unknown atom” index in the embedding table.
Add Bonds Via an edge_index#
edge_index is the bond graph in COO (coordinate) format: a [2, <number of bonds>] integer array where each column is a bonded pair of atoms.
Click to see the code
def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]:
"""Build atom, bond, and coordinate arrays from a cropped pocket."""
is_ligand = atom_array.is_ligand.astype(bool)
target_coords = atom_array.coord.astype(np.float32)
input_coords = target_coords.copy()
input_coords[is_ligand] = 0.0
atomic_numbers = np.array(
[ELEMENT_NAME_TO_ATOMIC_NUMBER.get(element.upper(), 0) for element in atom_array.element],
dtype=np.int64,
)
if atom_array.bonds is None:
raise ValueError("Cropped atom array has no bond list")
edge_index = atom_array.bonds.as_array()[:, :2].T.astype(np.int64)
return {
"atomic_numbers": atomic_numbers,
"is_ligand": is_ligand,
"target_coords": target_coords,
"edge_index": edge_index,
"input_coords": input_coords,
}
atom_array.bonds.as_array() returns an array whose first two columns are the indices of the bonded atoms; we take those two columns and transpose to get the [2, E] shape.
An empty bond list produces an edge_index with shape [2, 0]. Check the edge graph in your smoke test:
Click to see the code
example = dataset[0]
print("edge_index shape:", example["edge_index"].shape)
print("edge_index dtype:", example["edge_index"].dtype)
assert example["edge_index"].ndim == 2
assert example["edge_index"].shape[0] == 2
assert not example["edge_index"].size or example["edge_index"].max() < example["atomic_numbers"].shape[0]
The final FeaturizeForDocking class#
Click to see the code
class FeaturizeForDocking(Transform):
"""Add atom, bond, and coordinate arrays for the docking model."""
requires_previous_transforms: ClassVar[list[str]] = ["CropToPocket"]
def check_input(self, data: dict) -> None:
check_contains_keys(data, ["atom_array"])
check_atom_array_annotation(data, ["is_ligand"])
def forward(self, data: dict) -> dict:
data.update(featurize_for_docking(data["atom_array"]))
return data
The Complete Smoke Test#
Your final smoke_test.py should build the full pipeline and validate every array. Run it from the repository root with python docs/how_to_build_a_model/scripts/smoke_test.py splits/train.parquet. Use --index to select a row whose structure exists in a partial mirror:
Click to see the complete file
"""Load one Part 1 example through the Part 2 transform pipeline."""
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
from transforms import CropToPocket, FeaturizeForDocking
from atomworks.ml.datasets import PandasDataset
from atomworks.ml.datasets.loaders import create_structure_loader
from atomworks.ml.transforms.base import Compose
from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms
def build_dataset(split_path: Path) -> PandasDataset:
"""Build the tutorial dataset from a Part 1 Parquet split."""
pipeline = Compose(
[
RemoveHydrogens(),
RemoveUnresolvedAtoms(),
CropToPocket(radius=10.0),
FeaturizeForDocking(),
]
)
return PandasDataset(
data=pd.read_parquet(split_path),
name="docking_train",
id_column="example_id",
loader=create_structure_loader(
altloc_seed_colname="altloc_seed",
column_mapping={
"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"],
"query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"],
},
),
transform=pipeline,
)
def main() -> None:
"""Print shapes and check the basic feature contract for one example."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("split", type=Path, nargs="?", default=Path("splits/train.parquet"))
parser.add_argument("--index", type=int, default=0, help="Row to load from the split.")
args = parser.parse_args()
dataset = build_dataset(args.split)
example = dataset[args.index]
atom_count = len(example["atomic_numbers"])
assert example["target_coords"].shape == (atom_count, 3)
assert example["input_coords"].shape == (atom_count, 3)
assert example["is_ligand"].shape == (atom_count,)
assert example["edge_index"].shape[0] == 2
assert not example["edge_index"].size or example["edge_index"].max() < atom_count
assert np.all(example["input_coords"][example["is_ligand"]] == 0)
assert np.array_equal(
example["input_coords"][~example["is_ligand"]],
example["target_coords"][~example["is_ligand"]],
)
assert example["is_ligand"].any() and (~example["is_ligand"]).any()
print(f"Loaded {example['example_id']} from {len(dataset)} examples")
for name in ("atomic_numbers", "input_coords", "target_coords", "edge_index", "is_ligand"):
value = example[name]
print(f"{name}: shape={value.shape}, dtype={value.dtype}")
if __name__ == "__main__":
main()
When this passes, every example your dataset yields carries five arrays: atomic_numbers, input_coords, target_coords, edge_index, and is_ligand.
What Next?#
With a working transform pipeline, you are ready to write the model that consumes these tensors. Continue to Part 3: Write a Model.
Glossary#
KD-tree: a space-partitioning data structure for fast nearest-neighbor and radius queries.
COO format: “coordinate” sparse format; here, a [2, E] array listing the endpoints of each bond.
Transform: a reusable step that reads and rewrites the example dictionary; chained together with Compose.