"""ANI-1 dataset builder (pyanitools HDF5 from figshare).
The ANI-1 data set holds ~20 million off-equilibrium conformations and DFT
(wB97X/6-31G(d)) total energies for 57,462 small organic molecules (H, C, N, O)
built from GDB-11, generated by Normal Mode Sampling. It is distributed as eight
HDF5 files ``ani_gdb_s0X.h5`` (``X`` = number of heavy atoms, 1-8) bundled in a
single ~4.8 GB ``ANI-1_release.tar.gz`` archive.
Reference
---------
Smith, Isayev & Roitberg, "ANI-1, A data set of 20 million calculated
off-equilibrium conformations for organic molecules", *Sci. Data* **4**, 170193
(2017). Data: https://doi.org/10.6084/m9.figshare.c.3846712
Format / reader spec: https://github.com/isayev/ANI1_dataset
Notes
-----
* Upstream energies are in **Hartree** and positions in **angstrom**. By default
energies are converted to eV (the convention used elsewhere in xnn);
``units="hartree"`` keeps the raw values. The molecule contains no forces.
* The whole 4.8 GB archive is downloaded once (cached and MD5-verified), even to
read a single heavy-atom subset. Use ``heavy_atoms`` to select subsets and
``max_molecules`` / ``max_conformations`` to cap the amount materialised --
the full set is ~20 M conformations and will not fit in memory at once.
* ANI-1 ships no official split. ``split`` in ``{"train", "val", "test"}``
applies the paper's per-molecule 80/10/10 partition with a fixed seed, so
splits are disjoint and reproducible; ``split=None`` returns ``{"all": ...}``.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional, Union
import numpy as np
from tqdm.auto import tqdm
from ...config.coerce import SYMBOL_TO_Z
from ._download import download_file, extract_archive
from .base import DatasetBuilder, register_dataset
# figshare: ANI-1_release.tar.gz
_URL = "https://ndownloader.figshare.com/files/9057631"
_MD5 = "455e883a47ffd165f545c868ebaf95f9"
_ARCHIVE = "ANI-1_release.tar.gz"
# 1 Hartree in eV (CODATA 2018), matching ase.units.Hartree.
_HARTREE_TO_EV = 27.211386245988
def _iter_molecule_groups(group):
"""Yield every HDF5 group that holds a ``coordinates`` dataset.
pyanitools stores each molecule as a (possibly nested) group containing
``coordinates`` / ``energies`` / ``species`` datasets. This walks the tree
natively (no upstream reader needed) and yields the leaf molecule groups.
"""
import h5py
if "coordinates" in group and isinstance(group["coordinates"], h5py.Dataset):
yield group
return
for key in group:
item = group[key]
if isinstance(item, h5py.Group):
yield from _iter_molecule_groups(item)
def _decode_species(raw) -> np.ndarray:
"""Convert a pyanitools ``species`` dataset to an atomic-number array."""
out = []
for s in raw:
sym = s.decode() if isinstance(s, (bytes, bytearray)) else str(s)
out.append(SYMBOL_TO_Z[sym])
return np.asarray(out, dtype=np.int64)
[docs]
class ANI1Builder(DatasetBuilder):
"""Builder for the ANI-1 data set (20M DFT energies, GDB-11 subsets).
See the module docstring for the dataset description and citation. The one
4.8 GB archive is downloaded and extracted once; the requested heavy-atom
subsets are then parsed from their HDF5 files into xnn structure dicts.
"""
name = "ani1"
description = ("ANI-1: ~20M off-equilibrium conformations & wB97X energies "
"for H/C/N/O organic molecules (GDB-11).")
[docs]
def load(self, *, split: Optional[str] = None, cache_dir: Path,
heavy_atoms: Union[int, list, tuple, None] = None,
units: str = "eV", max_molecules: Optional[int] = None,
max_conformations: Optional[int] = None, seed: int = 1234,
quiet: bool = False) -> Union[dict[str, list[dict]], list[dict]]:
"""Download and preprocess the ANI-1 data set.
Parameters
----------
split : str or None
``None`` returns ``{"all": ...}``; ``"train"`` / ``"val"`` /
``"test"`` returns the paper's per-molecule 80/10/10 partition
(fixed ``seed``, disjoint splits).
cache_dir : pathlib.Path
Base cache directory; files live under ``cache_dir/"ani1"``.
heavy_atoms : int or sequence of int, optional
Which heavy-atom subset(s) ``ani_gdb_s0X.h5`` to load (``X`` in
1-8). Defaults to all eight. Fewer subsets = far less data.
units : str, optional
``"eV"`` (default) converts energies to eV; ``"hartree"`` keeps the
raw upstream values.
max_molecules : int, optional
Cap the number of molecules read per subset (useful for demos).
max_conformations : int, optional
Cap the number of conformations kept per molecule.
seed : int, optional
Seed for the reproducible 80/10/10 split. Defaults to ``1234``.
quiet : bool, optional
Suppress progress output. Defaults to ``False``.
Returns
-------
dict of {str: list of dict} or list of dict
Structure dicts with keys ``pos`` ``(N, 3)``, ``atomic_numbers``
``(N,)``, ``energy`` (scalar) and ``smiles`` (str). ANI-1 is
molecular and force-free, so no ``cell`` / ``pbc`` / ``forces``.
Raises
------
ValueError
If ``units`` or ``split`` is unrecognized, or ``heavy_atoms`` is out
of the 1-8 range.
ImportError
If ``h5py`` is not installed.
"""
if units not in ("eV", "hartree", "Hartree"):
raise ValueError(f"units must be 'eV' or 'hartree', got {units!r}")
subsets = self._resolve_subsets(heavy_atoms)
scale = 1.0 if units.lower() == "hartree" else _HARTREE_TO_EV
root = Path(cache_dir) / self.name
h5_dir = self._ensure_extracted(root, quiet)
structures: list[dict] = []
for x in subsets:
h5_path = h5_dir / f"ani_gdb_s{x:02d}.h5"
if not h5_path.exists():
raise FileNotFoundError(
f"expected {h5_path.name} in the extracted archive; "
f"found: {[p.name for p in h5_dir.glob('*.h5')]}")
structures.extend(self._read_h5(h5_path, scale, max_molecules,
max_conformations, quiet))
if split is None:
return {"all": structures}
if split not in ("train", "val", "test"):
raise ValueError(
f"unknown split {split!r}; use 'train', 'val', 'test', or None")
return self._partition(structures, seed)[split]
@staticmethod
def _resolve_subsets(heavy_atoms) -> list[int]:
"""Validate and normalize the ``heavy_atoms`` selection to a list."""
if heavy_atoms is None:
return list(range(1, 9))
vals = [heavy_atoms] if isinstance(heavy_atoms, int) else list(heavy_atoms)
for x in vals:
if not (1 <= int(x) <= 8):
raise ValueError(f"heavy_atoms entries must be 1-8, got {x!r}")
return [int(x) for x in vals]
def _ensure_extracted(self, root: Path, quiet: bool) -> Path:
"""Download (once) and extract the archive; return the h5 directory."""
raw = root / "raw"
# Locate the directory holding the ani_gdb_s0X.h5 files, if already there.
for cand in (raw / "ANI-1_release", raw):
if cand.is_dir() and any(cand.glob("ani_gdb_s0*.h5")):
return cand
archive = download_file(_URL, raw / _ARCHIVE, _MD5, quiet=quiet)
extract_archive(archive, raw)
for cand in (raw / "ANI-1_release", raw):
if cand.is_dir() and any(cand.glob("ani_gdb_s0*.h5")):
return cand
raise FileNotFoundError(
f"could not find ani_gdb_s0*.h5 after extracting {archive}")
@staticmethod
def _read_h5(h5_path: Path, scale: float, max_molecules: Optional[int],
max_conformations: Optional[int], quiet: bool) -> list[dict]:
"""Parse one ``ani_gdb_s0X.h5`` into structure dicts."""
import h5py
out: list[dict] = []
with h5py.File(h5_path, "r") as f:
groups = list(_iter_molecule_groups(f))
if max_molecules is not None:
groups = groups[:max_molecules]
for g in tqdm(groups, desc=f"ani1:{h5_path.stem}", unit=" mol",
disable=quiet, leave=False):
z = _decode_species(g["species"][()])
coords = np.asarray(g["coordinates"][()], dtype=np.float64)
energies = np.asarray(g["energies"][()], dtype=np.float64)
smiles = g["smiles"][()] if "smiles" in g else b""
if isinstance(smiles, np.ndarray):
smiles = b"".join(
s if isinstance(s, bytes) else str(s).encode()
for s in smiles.ravel())
smiles = smiles.decode() if isinstance(smiles, bytes) else str(smiles)
n = len(energies)
if max_conformations is not None:
n = min(n, max_conformations)
for i in range(n):
out.append({
"pos": coords[i],
"atomic_numbers": z,
"energy": float(energies[i]) * scale,
"smiles": smiles,
})
return out
@staticmethod
def _partition(structures: list[dict],
seed: int) -> dict[str, list[dict]]:
"""Deterministic 80/10/10 train/val/test partition (paper ratios)."""
idx = np.arange(len(structures))
np.random.default_rng(seed).shuffle(idx)
n_train = int(0.8 * len(idx))
n_val = int(0.1 * len(idx))
parts = {"train": idx[:n_train],
"val": idx[n_train:n_train + n_val],
"test": idx[n_train + n_val:]}
return {k: [structures[i] for i in v] for k, v in parts.items()}
register_dataset(ANI1Builder())