from collections.abc import Callable, Generator, Mapping, Sequence
from typing import Any, Optional, Protocol, Type
import jax
import jax.numpy as jnp
from ...types import KeyArray
from .base import (
ZMatrix,
ZMatrixEntry,
ZMatrixEntryTemplate,
ZMatrixLine,
ZMatrixLineTemplate,
ZMatrixTemplate,
)
from .concrete import ConcreteZMatrix, ConcreteZMatrixEntry, ConcreteZMatrixLine
def rng_iterator(rng: KeyArray) -> Generator[KeyArray, None, None]:
"""Infinite iterator over random number generators."""
yield rng
while True:
rng, rng_next = jax.random.split(rng)
yield rng_next
[docs]
class DistributionFactory(Protocol):
r"""Protocol for distribution factories.
A :class:`DistributionFactory` is called with the value of a bond length, angle
or dihedral found in a reference geometry, and returns a sampler function for a
(typically noisy) distribution over that coordinate. This is the extension
point used to implement custom noise distributions for entries of a
:class:`StochasticZMatrixTemplate`.
"""
def __call__(self, loc: jax.Array) -> Callable[[KeyArray], jax.Array]:
r"""Create a sampler function for a distribution located around ``loc``.
Args:
loc (~jax.Array): the value of the coordinate (bond length, angle or
dihedral) found in the reference geometry, used to center or
otherwise parametrize the returned distribution.
Returns:
~collections.abc.Callable[[~deepqmc.types.KeyArray], ~jax.Array]: a
function that samples a value from the distribution, given an rng key.
"""
...
class StochasticZMatrixEntry(ZMatrixEntry):
"""Stochastic Z matrix entry.
A stochastic Z matrix entry is made up of an atom index and a function that
samples a random value for the entry from a given distribution.
"""
atom_idx: int
value_fn: Callable[[KeyArray], jax.Array]
def __init__(self, atom_idx: int, value_fn: Callable[[KeyArray], jax.Array]):
self.atom_idx = atom_idx
self.value_fn = value_fn
def __call__(self, rng: KeyArray) -> ConcreteZMatrixEntry:
return ConcreteZMatrixEntry(atom_idx=self.atom_idx, value=self.value_fn(rng))
class StochasticZMatrixEntryTemplate(ZMatrixEntryTemplate):
"""Template for a stochastic Z matrix entry."""
atom_idx: int
distribution_factory: DistributionFactory
entry_constructor: Type[StochasticZMatrixEntry] = StochasticZMatrixEntry
def __init__(
self,
atom_idx: int,
distribution_factory: DistributionFactory,
):
self.atom_idx = atom_idx
self.distribution_factory = distribution_factory
def concretize(self, value: jax.Array):
return self.entry_constructor(
atom_idx=self.atom_idx, value_fn=self.distribution_factory(value)
)
@classmethod
def from_simplified_config(
cls,
atom_idx: int | None,
distribution_factory: DistributionFactory | None,
):
if atom_idx is None:
return None
assert distribution_factory is not None
return cls(atom_idx=atom_idx, distribution_factory=distribution_factory)
class StochasticZMatrixLine(ZMatrixLine):
"""Stochastic Z matrix line."""
bond: Optional[StochasticZMatrixEntry]
angle: Optional[StochasticZMatrixEntry]
dihedral: Optional[StochasticZMatrixEntry]
def __call__(self, rng: KeyArray):
rng_bond, rng_angle, rng_dihedral = jax.random.split(rng, 3)
bond = None if self.bond is None else self.bond(rng_bond)
angle = None if self.angle is None else self.angle(rng_angle)
dihedral = None if self.dihedral is None else self.dihedral(rng_dihedral)
return ConcreteZMatrixLine(
self.charge, bond=bond, angle=angle, dihedral=dihedral
)
class StochasticZMatrixLineTemplate(ZMatrixLineTemplate):
"""Template for a stochastic Z matrix line."""
bond: Optional[StochasticZMatrixEntryTemplate]
angle: Optional[StochasticZMatrixEntryTemplate]
dihedral: Optional[StochasticZMatrixEntryTemplate]
line_constructor = StochasticZMatrixLine
@classmethod
def from_simplified_config(
cls,
atom_idxs: Sequence[int | None],
distribution_factories: Sequence[Callable | None],
charge: Optional[int] = None,
):
return cls(
charge,
bond=StochasticZMatrixEntryTemplate.from_simplified_config(
atom_idx=atom_idxs[0], distribution_factory=distribution_factories[0]
),
angle=StochasticZMatrixEntryTemplate.from_simplified_config(
atom_idx=atom_idxs[1], distribution_factory=distribution_factories[1]
),
dihedral=StochasticZMatrixEntryTemplate.from_simplified_config(
atom_idx=atom_idxs[2], distribution_factory=distribution_factories[2]
),
)
[docs]
class StochasticZMatrix(ZMatrix):
"""Stochastic Z matrix representation.
A Z matrix where the values of the bond lengths, angles, and dihedrals are
defined by a distribution.
"""
lines: Sequence[StochasticZMatrixLine]
def __init__(self, lines: Sequence[StochasticZMatrixLine]):
self.lines = lines
def __call__(self, rng: KeyArray) -> ConcreteZMatrix:
r"""Sample a :class:`~deepqmc.geom.zmatrix.ConcreteZMatrix` from the
distributions of this Z matrix.
Args:
rng (~deepqmc.types.KeyArray): an rng key for sampling.
Returns:
~deepqmc.geom.zmatrix.ConcreteZMatrix: a Z matrix with concrete,
sampled values.
"""
lines = [line(rng) for rng, line in zip(rng_iterator(rng), self.lines)]
return ConcreteZMatrix(lines)
[docs]
def to_cartesian(self, rng: KeyArray) -> jax.Array:
r"""Sample this Z matrix and convert it to Cartesian nuclear coordinates.
Args:
rng (~deepqmc.types.KeyArray): an rng key for sampling.
Returns:
~jax.Array: the Cartesian nuclear coordinates, of shape ``(n_nuc, 3)``.
"""
return self(rng).to_cartesian()
[docs]
class StochasticZMatrixTemplate(ZMatrixTemplate):
"""Template for a stochastic Z matrix.
The template includes the "connectivity" of the Z matrix, i.e. which
atoms form bonds, angles, and dihedrals. Moreover, it includes a recipe
for generating distributions for the bond lengths, angles, and dihedrals.
"""
line_templates: Sequence[StochasticZMatrixLineTemplate]
zmatrix_constructor = StochasticZMatrix
concretize: Callable[..., StochasticZMatrix] # here only for type hinting
concretize_from_cartesian: Callable[
..., StochasticZMatrix
] # here only for type hinting
def __init__(self, line_templates: Sequence[StochasticZMatrixLineTemplate]):
self.line_templates = line_templates
[docs]
@classmethod
def from_simplified_config(cls, lines: Sequence[Mapping[str, Any]]):
r"""Construct a template from a simplified, config-friendly specification.
This is the constructor typically used from Hydra configs, e.g. to build
the ``z_matrix_template`` of a Z-matrix-based nuclei sampler
(``deepqmc.sampling.nuclei_samplers.ZMatrixSampler``).
Args:
lines
(~collections.abc.Sequence[~collections.abc.Mapping[str, typing.Any]]):
one entry per atom, in the same order as the nuclear charges. Each
entry is a mapping with the keys:
- ``atom_idxs``: a sequence ``(bond_atom_idx, angle_atom_idx,
dihedral_atom_idx)`` of (up to three) atom indices, using
``None`` for entries that don't apply (e.g. the first three
atoms, which don't need a full bond, angle and dihedral).
- ``distribution_factories``: a sequence of (up to three)
:class:`DistributionFactory` instances (or ``None``), one per
entry of ``atom_idxs``, used to sample the corresponding bond
length, angle or dihedral around the value found in the
reference geometry.
- ``charge`` (optional): the nuclear charge of the atom, only used
for bookkeeping.
Returns:
StochasticZMatrixTemplate: the resulting Z matrix template.
"""
line_templates = [
StochasticZMatrixLineTemplate.from_simplified_config(**line)
for line in lines
]
return cls(line_templates)
[docs]
class ClippedAsymmetricNormalDistributionFactory(DistributionFactory):
r"""Create normal distributions centered on ``loc``, with different standard
deviations on either side of ``loc``, clipped to an absolute range.
Args:
low_scale (float): the standard deviation used for samples below ``loc``.
high_scale (float): the standard deviation used for samples above ``loc``.
low (float | None): optional, an absolute lower bound the samples are
clipped to.
high (float | None): optional, an absolute upper bound the samples are
clipped to.
"""
def __init__(
self,
low_scale: float,
high_scale: float,
low: Optional[float] = None,
high: Optional[float] = None,
):
self.low_scale = low_scale
self.high_scale = high_scale
self.low = low
self.high = high
def __call__(self, loc: jax.Array):
def clipped_normal_distribution(rng: KeyArray):
x = jax.random.normal(rng)
scale = jnp.where(x > 0, self.high_scale, self.low_scale)
return jnp.clip(loc + scale * x, self.low, self.high)
return clipped_normal_distribution
[docs]
class ClippedNormalDistributionFactory(DistributionFactory):
r"""Create normal distributions centered on ``loc``, clipped to an absolute
range.
Args:
scale (float): the standard deviation of the normal distribution.
low (float | None): optional, an absolute lower bound the samples are
clipped to.
high (float | None): optional, an absolute upper bound the samples are
clipped to.
"""
def __init__(
self, scale: float, low: Optional[float] = None, high: Optional[float] = None
):
self.scale = scale
self.low = low
self.high = high
def __call__(self, loc: jax.Array):
def clipped_normal_distribution(rng: KeyArray) -> jax.Array:
return jnp.clip(
loc + self.scale * jax.random.normal(rng), self.low, self.high
)
return clipped_normal_distribution
[docs]
class DeltaDistributionFactory(DistributionFactory):
r"""Create a degenerate "distribution" that deterministically returns ``loc``.
Useful to keep a bond length, angle or dihedral fixed at its reference value
while other entries of the same Z matrix are sampled stochastically.
"""
def __call__(self, loc: jax.Array):
def delta_distribution(rng: KeyArray):
return loc
return delta_distribution