marathon-train 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marathon_train-0.2.0/LICENSE +21 -0
- marathon_train-0.2.0/PKG-INFO +37 -0
- marathon_train-0.2.0/marathon/README.md +209 -0
- marathon_train-0.2.0/marathon/__init__.py +3 -0
- marathon_train-0.2.0/marathon/data/README.md +27 -0
- marathon_train-0.2.0/marathon/data/__init__.py +27 -0
- marathon_train-0.2.0/marathon/data/batching.py +236 -0
- marathon_train-0.2.0/marathon/data/properties.py +54 -0
- marathon_train-0.2.0/marathon/data/sample.py +199 -0
- marathon_train-0.2.0/marathon/data/sizes.py +19 -0
- marathon_train-0.2.0/marathon/data/splits.py +23 -0
- marathon_train-0.2.0/marathon/elemental.py +50 -0
- marathon_train-0.2.0/marathon/emit/README.md +47 -0
- marathon_train-0.2.0/marathon/emit/__init__.py +15 -0
- marathon_train-0.2.0/marathon/emit/checkpoint.py +199 -0
- marathon_train-0.2.0/marathon/emit/log.py +229 -0
- marathon_train-0.2.0/marathon/emit/plot.py +191 -0
- marathon_train-0.2.0/marathon/emit/pretty.py +110 -0
- marathon_train-0.2.0/marathon/emit/properties.py +85 -0
- marathon_train-0.2.0/marathon/evaluate/README.md +71 -0
- marathon_train-0.2.0/marathon/evaluate/__init__.py +5 -0
- marathon_train-0.2.0/marathon/evaluate/loss.py +123 -0
- marathon_train-0.2.0/marathon/evaluate/metrics.py +592 -0
- marathon_train-0.2.0/marathon/evaluate/predict.py +63 -0
- marathon_train-0.2.0/marathon/evaluate/properties.py +8 -0
- marathon_train-0.2.0/marathon/extra/__init__.py +0 -0
- marathon_train-0.2.0/marathon/extra/edge_to_edge/README.md +35 -0
- marathon_train-0.2.0/marathon/extra/edge_to_edge/__init__.py +4 -0
- marathon_train-0.2.0/marathon/extra/edge_to_edge/batching.py +100 -0
- marathon_train-0.2.0/marathon/extra/edge_to_edge/neighborlist.py +396 -0
- marathon_train-0.2.0/marathon/extra/hermes/__init__.py +10 -0
- marathon_train-0.2.0/marathon/extra/hermes/data_source.py +4 -0
- marathon_train-0.2.0/marathon/extra/hermes/pain.py +22 -0
- marathon_train-0.2.0/marathon/grain/README.md +44 -0
- marathon_train-0.2.0/marathon/grain/__init__.py +83 -0
- marathon_train-0.2.0/marathon/grain/data_source/README.md +9 -0
- marathon_train-0.2.0/marathon/grain/data_source/__init__.py +4 -0
- marathon_train-0.2.0/marathon/grain/data_source/data_source.py +107 -0
- marathon_train-0.2.0/marathon/grain/data_source/flatten_atoms.py +123 -0
- marathon_train-0.2.0/marathon/grain/data_source/prepare.py +87 -0
- marathon_train-0.2.0/marathon/grain/data_source/properties.py +232 -0
- marathon_train-0.2.0/marathon/grain/transforms/README.md +3 -0
- marathon_train-0.2.0/marathon/grain/transforms/__init__.py +25 -0
- marathon_train-0.2.0/marathon/grain/transforms/transforms.py +352 -0
- marathon_train-0.2.0/marathon/io/README.md +13 -0
- marathon_train-0.2.0/marathon/io/__init__.py +12 -0
- marathon_train-0.2.0/marathon/io/dicts.py +82 -0
- marathon_train-0.2.0/marathon/io/msgpack.py +33 -0
- marathon_train-0.2.0/marathon/io/yaml.py +43 -0
- marathon_train-0.2.0/marathon/utils.py +217 -0
- marathon_train-0.2.0/pyproject.toml +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marcel Langer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marathon-train
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: experimental tooling for training machine learning interatomic potentials in jax
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Marcel Langer
|
|
8
|
+
Author-email: dev@marcel.science
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Provides-Extra: grain
|
|
23
|
+
Provides-Extra: plot
|
|
24
|
+
Provides-Extra: wandb
|
|
25
|
+
Requires-Dist: PyYAML
|
|
26
|
+
Requires-Dist: ase
|
|
27
|
+
Requires-Dist: flax
|
|
28
|
+
Requires-Dist: grain ; extra == "all" or extra == "grain"
|
|
29
|
+
Requires-Dist: matplotlib ; extra == "all" or extra == "plot"
|
|
30
|
+
Requires-Dist: mmap_ninja ; extra == "all" or extra == "grain"
|
|
31
|
+
Requires-Dist: numba ; extra == "all" or extra == "grain"
|
|
32
|
+
Requires-Dist: opsis
|
|
33
|
+
Requires-Dist: pytest ; extra == "all" or extra == "dev"
|
|
34
|
+
Requires-Dist: ruff ; extra == "all" or extra == "dev"
|
|
35
|
+
Requires-Dist: scipy ; extra == "all" or extra == "grain"
|
|
36
|
+
Requires-Dist: vesin (>=0.3.2)
|
|
37
|
+
Requires-Dist: wandb ; extra == "all" or extra == "wandb"
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Technical readme
|
|
2
|
+
|
|
3
|
+
Welcome to the technical `marathon` readme! This is where we keep high-level conventions and so on. While the idea is that the subpackages are pretty independent, there are some implicit contracts that bind them together which we explain here. Individual modules make more targeted assumptions where needed; those are documented in their own `README.md` files.
|
|
4
|
+
|
|
5
|
+
## Data model
|
|
6
|
+
|
|
7
|
+
The most basic internal data object is a `Sample`, which is a `namedtuple` of `(structure, labels)`. The `structure` dict describes the atomic geometry and its neighbor graph:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
structure = {
|
|
11
|
+
"positions": ..., # (num_atoms, 3)
|
|
12
|
+
"atomic_numbers": ..., # (num_atoms,) -- Z values
|
|
13
|
+
"cell": ..., # (3, 3)
|
|
14
|
+
"charges": ..., # (num_atoms,)
|
|
15
|
+
"centers": ..., # (num_pairs,) -- i indices of neighbor pairs
|
|
16
|
+
"others": ..., # (num_pairs,) -- j indices of neighbor pairs
|
|
17
|
+
"displacements": ..., # (num_pairs, 3) -- R_ij vectors
|
|
18
|
+
"cell_shifts": ..., # (num_pairs, 3) -- periodic image shifts
|
|
19
|
+
"pbc": ..., # (3,) -- periodic boundary conditions
|
|
20
|
+
"max_neighbors": ..., # int -- max neighbors per atom
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The `labels` dict contains target properties:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
labels = {
|
|
28
|
+
"energy": ..., # scalar -- total energy (eV)
|
|
29
|
+
"forces": ..., # (num_atoms, 3) -- eV/Å
|
|
30
|
+
"stress": ..., # (3, 3) -- dU/dε (eV, see below)
|
|
31
|
+
"num_atoms": ..., # int
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The neighbor list is computed by [`vesin`](https://github.com/Luthaf/vesin) from positions and a cutoff radius. The `displacements` array contains the actual $R_{ij} = R_j - R_i + S \cdot \text{cell}$ vectors. In the typical case, these are the quantities that the model operates on and that we differentiate through to obtain forces (see `evaluate/README.md`).
|
|
36
|
+
|
|
37
|
+
The next step is a `Batch`, which is another `namedtuple` that collates one or more `Sample`s, padded and index-offset so that an MPNN can process them as one big disconnected graph:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
Batch(
|
|
41
|
+
atomic_numbers, # (num_atoms,) -- Z_i
|
|
42
|
+
displacements, # (num_pairs, 3) -- R_ij
|
|
43
|
+
centers, # (num_pairs,) -- i indices, offset per structure
|
|
44
|
+
others, # (num_pairs,) -- j indices, offset per structure
|
|
45
|
+
atom_to_structure, # (num_atoms,) -- which structure each atom belongs to
|
|
46
|
+
pair_to_structure, # (num_pairs,) -- which structure each pair belongs to
|
|
47
|
+
structure_mask, # (num_structures,) -- False for padding
|
|
48
|
+
atom_mask, # (num_atoms,) -- False for padding
|
|
49
|
+
pair_mask, # (num_pairs,) -- False for padding
|
|
50
|
+
labels, # dict of batched labels + masks
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It is expected that models that require something less off-the-shelf implement their own `Batch` class and related infrastructure. We try, as much as possible, to be agnostic to the internals of the batch. Only parts of the code that *must* explicitly interact with it care about internals, for example some parts of `marathon.evaluate`.
|
|
55
|
+
|
|
56
|
+
## Padding and masking
|
|
57
|
+
|
|
58
|
+
Padding adds a single extra "padding structure" at the end of the batch. Its atoms fill the remaining allocated slots; its pairs point both endpoints to a single padding atom (creating self-loops that don't participate in message passing). Displacements for padding pairs are zero.
|
|
59
|
+
|
|
60
|
+
**Downstream code must handle zero displacements** ($R_{ij} = 0$). Division by $|R_{ij}|$ will blow up if you don't mask.
|
|
61
|
+
|
|
62
|
+
Three boolean masks distinguish real from padding: `structure_mask`, `atom_mask`, `pair_mask`. Additionally, each label `key` in `batch.labels` has a corresponding `key + "_mask"` (e.g. `energy_mask`, `forces_mask`) that is `True` where the label is valid and `False` where it's missing or padding. This handles the case where some structures have, say, energy labels but not stress labels. NaN values in input labels are treated as missing data and get mask `False`.
|
|
63
|
+
|
|
64
|
+
Depending on the model architecture, it may be better to always have `Batch`es with the exact same shape (so they can be stacked and processed with `vmap`/`scan`), or it may be better to batch dynamically to varying shapes. The former avoids recompilation; the latter reduces wasted computation. `marathon.grain` provides both strategies (`ToFixedShapeBatch` and `ToFixedLengthBatch`).
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
## Label conventions
|
|
68
|
+
|
|
69
|
+
All *per-structure* properties are stored as genuinely per-structure quantities -- not per atom and not per volume:
|
|
70
|
+
|
|
71
|
+
- **Energy** is total energy (eV), not energy/atom.
|
|
72
|
+
- **Stress** is the ASE stress multiplied by volume, i.e. $dU/d\varepsilon$ in energy units (eV), not pressure. If you need stress in force/area units, divide by volume.
|
|
73
|
+
- **Forces** are per-atom by nature (eV/Å).
|
|
74
|
+
|
|
75
|
+
The properties/normalisation system (see below) can be used to change display units and normalise by atom count for loss computation, but it implicitly relies on inputs following this convention. The pipelines process `ase.Atoms` as inputs, and expect the energy there to be total energy and the stress the standard `ase` stress definition; we handle the conversion.
|
|
76
|
+
|
|
77
|
+
Everything up to generating a `Batch` is done in pure `numpy` in configurable precision (default: float64/int64). It is up to downstream code to convert to `jax` arrays and potentially cast into other `dtypes`. Some of the infrastructure is therefore also compatible with `pytorch`.
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
## Properties System
|
|
81
|
+
|
|
82
|
+
Marathon supports custom properties beyond the standard `energy`, `forces`, `stress`. The key idea is that users define **one** `PROPERTIES` dict and **one** `NORMALIZATION` dict, then pass them to all relevant infrastructure--each module extracts what it needs.
|
|
83
|
+
|
|
84
|
+
### Property definition
|
|
85
|
+
|
|
86
|
+
A property has these fields (not all are required everywhere):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
PROPERTIES = {
|
|
90
|
+
"energy": {
|
|
91
|
+
"shape": (1,), # for data loading/batching
|
|
92
|
+
"storage": "atoms.calc", # for DataSource: where in ase.Atoms
|
|
93
|
+
"report_unit": (1000, "meV"), # for emit: (scale, unit)
|
|
94
|
+
"symbol": "E", # for emit: short symbol
|
|
95
|
+
},
|
|
96
|
+
"forces": {
|
|
97
|
+
"shape": ("atom", 3),
|
|
98
|
+
"storage": "atoms.calc",
|
|
99
|
+
"report_unit": (1000, "meV/Å"),
|
|
100
|
+
"symbol": "F",
|
|
101
|
+
},
|
|
102
|
+
# ...
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- `shape`: tuple where `"atom"` placeholder gets replaced with num_atoms. **Only leading `"atom"` dimension is supported** (e.g., `("atom", 3)` works, but `(3, "atom")` does not).
|
|
107
|
+
- `storage`: where to read/write from ase.Atoms: `"atoms.calc"`, `"atoms.arrays"`, `"atoms.info"`
|
|
108
|
+
- `report_unit`: `(scale_factor, base_unit)` for converting internal units (eV) to display units (meV)
|
|
109
|
+
- `symbol`: short symbol for console/log output
|
|
110
|
+
|
|
111
|
+
**Note on storage:** `atoms.calc` is reserved for `energy`, `forces`, and `stress` (these are what ASE calculators provide). Custom properties should use `atoms.arrays` (per-atom data) or `atoms.info` (per-structure data). We don't guarantee that other properties will work correctly through calculators.
|
|
112
|
+
|
|
113
|
+
### Normalization
|
|
114
|
+
|
|
115
|
+
The normalization dict specifies which properties are divided by atom count:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
NORMALIZATION = {
|
|
119
|
+
"energy": "atom",
|
|
120
|
+
"stress": "atom",
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- `"atom"` means divide by num_atoms during loss/metrics and append `/atom` to units
|
|
125
|
+
- Properties not listed (like forces) are not modified. Setting normalization to a quantity that's of dimension `[atom,...]` will yield undefined behaviour or crash.
|
|
126
|
+
|
|
127
|
+
### Where configs are used
|
|
128
|
+
|
|
129
|
+
| Module | Uses from PROPERTIES | Uses NORMALIZATION |
|
|
130
|
+
|--------|---------------------|-------------------|
|
|
131
|
+
| `data/` batching | `shape` | - |
|
|
132
|
+
| `grain/DataSource` | `shape`, `storage` | - |
|
|
133
|
+
| `evaluate/` loss | - | yes |
|
|
134
|
+
| `evaluate/` metrics | - | yes |
|
|
135
|
+
| `emit/` logging | `report_unit`, `symbol` | yes (for unit suffix) |
|
|
136
|
+
| `emit/` plotting | `report_unit` | yes (for unit suffix) |
|
|
137
|
+
|
|
138
|
+
### Custom properties example
|
|
139
|
+
|
|
140
|
+
To add dipole moment as a custom property:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# One unified PROPERTIES dict
|
|
144
|
+
PROPERTIES = {
|
|
145
|
+
"energy": {
|
|
146
|
+
"shape": (1,), "storage": "atoms.calc",
|
|
147
|
+
"report_unit": (1000, "meV"), "symbol": "E",
|
|
148
|
+
},
|
|
149
|
+
"forces": {
|
|
150
|
+
"shape": ("atom", 3), "storage": "atoms.calc",
|
|
151
|
+
"report_unit": (1000, "meV/Å"), "symbol": "F",
|
|
152
|
+
},
|
|
153
|
+
"dipole": {
|
|
154
|
+
"shape": (3,), "storage": "atoms.info", # per-structure, not from calc
|
|
155
|
+
"report_unit": (1, "Debye"), "symbol": "μ",
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# One unified NORMALIZATION dict
|
|
160
|
+
NORMALIZATION = {
|
|
161
|
+
"energy": "atom",
|
|
162
|
+
# dipole not listed -> no /atom suffix, no per-atom normalization
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Pass to all relevant places:
|
|
166
|
+
from marathon.emit import Txt
|
|
167
|
+
from marathon.emit.pretty import format_metrics
|
|
168
|
+
from marathon.evaluate import get_loss_fn
|
|
169
|
+
|
|
170
|
+
logger = Txt(keys=["energy", "dipole"], properties=PROPERTIES, normalization=NORMALIZATION)
|
|
171
|
+
loss_fn = get_loss_fn(predict_fn, weights={"energy": 1.0, "dipole": 1.0}, normalization=NORMALIZATION)
|
|
172
|
+
msg = format_metrics(metrics, properties=PROPERTIES, normalization=NORMALIZATION)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The defaults are split across modules for convenience (`data/properties.py`, `evaluate/properties.py`, `emit/properties.py`), but users can combine them into a single dict.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## Serialization conventions
|
|
179
|
+
|
|
180
|
+
Model architectures are stored as YAML "spec dicts" -- a single-entry dict mapping a fully-qualified class path to its `__init__` kwargs:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
my_package.models.MACE:
|
|
184
|
+
num_features: 128
|
|
185
|
+
num_layers: 3
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`marathon.io.to_dict(module)` serialises any dataclass (including `flax.nn.Module`); `marathon.io.from_dict(spec)` reconstructs it by dynamically importing the class. `parent` and `name` fields are excluded (these are Flax-internal).
|
|
189
|
+
|
|
190
|
+
Numerical data (weights, optimizer state) uses `msgpack` via Flax's serialization. Checkpoints combine both: the architecture lives in `model.yaml`, the weights in `model.msgpack`, training state in `state.msgpack`, and metrics in `metrics.yaml`. See `emit/README.md` for details.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
## Code style
|
|
194
|
+
|
|
195
|
+
Formatting is handled by `ruff` with a line length of 92. The line length is enforced by the formatter only, not the linter — so lines that can't be shortened automatically are left alone. We suppress rules that get in the way of research code: short variable names (`E741`), lambdas (`E731`), non-top-level imports (`E402`), and line length warnings (`E501`).
|
|
196
|
+
|
|
197
|
+
Import ordering groups `numpy` and `jax` before other third-party packages (configured via `isort` sections in `pyproject.toml`). Lazy imports are used for optional dependencies (`grain`, `matplotlib`, `wandb`, `numba`) and occasionally to avoid circular imports or heavy startup costs.
|
|
198
|
+
|
|
199
|
+
The code is deliberately kept **copy-and-pasteable** — individual files should be easy to pull into a script or notebook for quick hacking. This is part of the reason for inline testing, avoiding deep relative import chains, and keeping modules self-contained where possible.
|
|
200
|
+
|
|
201
|
+
More specifically:
|
|
202
|
+
|
|
203
|
+
- **Descriptive names, minimal docstrings.** Docstrings only where behaviour isn't obvious from the signature and context. Comments explain *why*, not *what*.
|
|
204
|
+
- **Lambdas and comprehensions** are used freely. Short closures are preferred over named helper functions when the logic is local and throwaway.
|
|
205
|
+
- **`namedtuple`** for data objects (`Sample`, `Batch`). Plain dicts for labels and properties configs. Dataclasses (via `flax.nn.Module` or the `frozen` helper in `utils.py`) for model components.
|
|
206
|
+
- **Factory functions** follow the `get_*_fn` pattern (e.g. `get_loss_fn`, `get_predict_fn`, `get_metrics_fn`). They return closures that capture configuration, keeping the hot path clean.
|
|
207
|
+
- **Inline tests** at the bottom of many modules, separated from production code by a `# -- test --` comment. Always use exactly this marker. These tests run on import and serve as both documentation and a basic smoke test. They're not a substitute for `pytest` but catch regressions immediately.
|
|
208
|
+
- **`__all__`** in `__init__.py` files defines the public API of each subpackage.
|
|
209
|
+
- **Private functions** are prefixed with `_` (e.g. `_huber`). No `__double_underscore` mangling.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# `marathon.data`: Samples, batching, and data splits
|
|
2
|
+
|
|
3
|
+
Converts `ase.Atoms` objects into the internal `Sample` and `Batch` representations described in the top-level README. This module is pure numpy and does not depend on JAX (except `splits.py`, which uses `jax.random` for reproducible shuffling).
|
|
4
|
+
|
|
5
|
+
## `sample`: `ase.Atoms` → `Sample`
|
|
6
|
+
|
|
7
|
+
`to_structure(atoms, cutoff)` computes the neighbor list (via `vesin`) and packs geometry into a structure dict. `to_labels(atoms, ...)` extracts target properties using the `storage` field from the properties config. `to_sample` combines both.
|
|
8
|
+
|
|
9
|
+
For standard properties (`energy`, `forces`, `stress`), extraction from the ASE calculator handles the stress convention (multiply by volume to get $dU/d\varepsilon$). Custom properties are read from `atoms.info` or `atoms.arrays` as configured.
|
|
10
|
+
|
|
11
|
+
## `batching`: `[Sample]` → `Batch`
|
|
12
|
+
|
|
13
|
+
`batch_samples` collates a list of `Sample`s into a single `Batch` namedtuple with padding. Index arrays (`centers`, `others`) are offset per structure so the batch looks like one disconnected graph. Labels are batched separately by `batch_labels`, which also creates per-key masks (NaN → `False`).
|
|
14
|
+
|
|
15
|
+
## `sizes`: Buffer allocation
|
|
16
|
+
|
|
17
|
+
`determine_max_sizes` scans samples to find worst-case atom and pair counts for a given batch size, then rounds up via `next_size` to a JIT-friendly number.
|
|
18
|
+
|
|
19
|
+
Typically not needed and superseded by more complex `grain` machinery -- but this is nice for very small all-GPU training runs.
|
|
20
|
+
|
|
21
|
+
## `splits`: Train/valid/test splits
|
|
22
|
+
|
|
23
|
+
`get_splits` draws non-overlapping index sets using `jax.random` for reproducibility.
|
|
24
|
+
|
|
25
|
+
## `properties`: Shape and storage specs
|
|
26
|
+
|
|
27
|
+
`DEFAULT_PROPERTIES` defines `shape` and `storage` for the standard properties. `deduce_shape` resolves the `"atom"` placeholder at batch time.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .batching import Batch, batch_labels, batch_samples
|
|
5
|
+
from .sample import Sample, to_sample, to_structure
|
|
6
|
+
from .sizes import determine_max_sizes
|
|
7
|
+
from .splits import get_splits
|
|
8
|
+
|
|
9
|
+
dataset_folder = os.environ.get("DATASETS")
|
|
10
|
+
|
|
11
|
+
if dataset_folder is not None:
|
|
12
|
+
datasets = Path(dataset_folder)
|
|
13
|
+
else:
|
|
14
|
+
datasets = Path.cwd()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Sample",
|
|
19
|
+
"Batch",
|
|
20
|
+
"batch_samples",
|
|
21
|
+
"batch_labels",
|
|
22
|
+
"determine_max_sizes",
|
|
23
|
+
"to_sample",
|
|
24
|
+
"to_structure",
|
|
25
|
+
"get_splits",
|
|
26
|
+
"datasets",
|
|
27
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
|
|
5
|
+
from .properties import DEFAULT_PROPERTIES, deduce_shape, is_per_atom
|
|
6
|
+
from .sample import Sample
|
|
7
|
+
|
|
8
|
+
# Padded, index-offset collation of multiple Samples into one disconnected graph.
|
|
9
|
+
Batch = namedtuple(
|
|
10
|
+
"Batch",
|
|
11
|
+
(
|
|
12
|
+
"atomic_numbers", # Z_i
|
|
13
|
+
"displacements", # R_ij
|
|
14
|
+
"centers", # i
|
|
15
|
+
"others", # j
|
|
16
|
+
"atom_to_structure", # map atomic_numbers to original structures
|
|
17
|
+
"pair_to_structure", # map displacements to original structures
|
|
18
|
+
"structure_mask", # False for padding
|
|
19
|
+
"atom_mask", # False for padding
|
|
20
|
+
"pair_mask", # False for padding
|
|
21
|
+
"labels",
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def batch_samples(
|
|
27
|
+
samples,
|
|
28
|
+
num_atoms,
|
|
29
|
+
num_pairs,
|
|
30
|
+
keys,
|
|
31
|
+
num_structures=None,
|
|
32
|
+
float_dtype=None,
|
|
33
|
+
int_dtype=None,
|
|
34
|
+
properties=DEFAULT_PROPERTIES,
|
|
35
|
+
):
|
|
36
|
+
"""Collate samples into a Batch, padding to fixed num_atoms/num_pairs with masks.
|
|
37
|
+
|
|
38
|
+
num_atoms/num_pairs must exceed the real totals (padding needs at least one extra slot).
|
|
39
|
+
"""
|
|
40
|
+
if float_dtype is None:
|
|
41
|
+
float_dtype = samples[0].structure["displacements"].dtype
|
|
42
|
+
if int_dtype is None:
|
|
43
|
+
int_dtype = samples[0].structure["centers"].dtype
|
|
44
|
+
|
|
45
|
+
if num_structures is None:
|
|
46
|
+
num_structures = len(samples) + 1
|
|
47
|
+
else:
|
|
48
|
+
num_input_structures = len(samples)
|
|
49
|
+
assert num_input_structures + 1 <= num_structures
|
|
50
|
+
|
|
51
|
+
atomic_numbers = np.zeros(num_atoms, dtype=int_dtype)
|
|
52
|
+
displacements = np.zeros((num_pairs, 3), dtype=float_dtype)
|
|
53
|
+
centers = np.zeros(num_pairs, dtype=int_dtype)
|
|
54
|
+
others = np.zeros(num_pairs, dtype=int_dtype)
|
|
55
|
+
atom_to_structure = np.zeros(num_atoms, dtype=int_dtype)
|
|
56
|
+
pair_to_structure = np.zeros(num_pairs, dtype=int_dtype)
|
|
57
|
+
structure_mask = np.zeros(num_structures, dtype=bool)
|
|
58
|
+
atom_mask = np.zeros(num_atoms, dtype=bool)
|
|
59
|
+
pair_mask = np.zeros(num_pairs, dtype=bool)
|
|
60
|
+
|
|
61
|
+
labels = batch_labels(
|
|
62
|
+
[sample.labels for sample in samples],
|
|
63
|
+
num_structures,
|
|
64
|
+
num_atoms,
|
|
65
|
+
keys,
|
|
66
|
+
float_dtype=float_dtype,
|
|
67
|
+
int_dtype=int_dtype,
|
|
68
|
+
properties=properties,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
atom_offset = 0
|
|
72
|
+
pair_offset = 0
|
|
73
|
+
for i, sample in enumerate(samples):
|
|
74
|
+
s = sample.structure
|
|
75
|
+
|
|
76
|
+
_n_atoms = s["atomic_numbers"].shape[0]
|
|
77
|
+
_n_pairs = s["displacements"].shape[0]
|
|
78
|
+
|
|
79
|
+
atom_slice = slice(atom_offset, atom_offset + _n_atoms)
|
|
80
|
+
pair_slice = slice(pair_offset, pair_offset + _n_pairs)
|
|
81
|
+
|
|
82
|
+
atomic_numbers[atom_slice] = s["atomic_numbers"]
|
|
83
|
+
displacements[pair_slice] = s["displacements"]
|
|
84
|
+
centers[pair_slice] = s["centers"] + atom_offset
|
|
85
|
+
others[pair_slice] = s["others"] + atom_offset
|
|
86
|
+
|
|
87
|
+
atom_to_structure[atom_slice] = i
|
|
88
|
+
pair_to_structure[pair_slice] = i
|
|
89
|
+
|
|
90
|
+
structure_mask[i] = True
|
|
91
|
+
atom_mask[atom_slice] = True
|
|
92
|
+
pair_mask[pair_slice] = True
|
|
93
|
+
|
|
94
|
+
atom_offset += _n_atoms
|
|
95
|
+
pair_offset += _n_pairs
|
|
96
|
+
|
|
97
|
+
assert atom_offset < len(atom_mask), "no room for padding!"
|
|
98
|
+
|
|
99
|
+
# now we add the padding
|
|
100
|
+
|
|
101
|
+
# skip atomic_numbers -- there is no element 0
|
|
102
|
+
# skip displacements -- already zero
|
|
103
|
+
centers[pair_offset:] = atom_offset
|
|
104
|
+
others[pair_offset:] = atom_offset
|
|
105
|
+
|
|
106
|
+
atom_to_structure[atom_offset:] = num_structures - 1
|
|
107
|
+
pair_to_structure[pair_offset:] = num_structures - 1
|
|
108
|
+
|
|
109
|
+
# skip masks -- already False
|
|
110
|
+
|
|
111
|
+
# skip labels -- already zero
|
|
112
|
+
|
|
113
|
+
return Batch(
|
|
114
|
+
atomic_numbers,
|
|
115
|
+
displacements,
|
|
116
|
+
centers,
|
|
117
|
+
others,
|
|
118
|
+
atom_to_structure,
|
|
119
|
+
pair_to_structure,
|
|
120
|
+
structure_mask,
|
|
121
|
+
atom_mask,
|
|
122
|
+
pair_mask,
|
|
123
|
+
labels,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def batch_labels(
|
|
128
|
+
list_of_labels,
|
|
129
|
+
num_structures,
|
|
130
|
+
num_atoms,
|
|
131
|
+
keys,
|
|
132
|
+
float_dtype=np.float64,
|
|
133
|
+
int_dtype=np.int64,
|
|
134
|
+
properties=DEFAULT_PROPERTIES,
|
|
135
|
+
):
|
|
136
|
+
"""Stack label dicts into padded arrays with per-key NaN-aware masks."""
|
|
137
|
+
labels = {}
|
|
138
|
+
|
|
139
|
+
for key in keys:
|
|
140
|
+
if key not in properties:
|
|
141
|
+
raise KeyError(f"unknown key: {key}")
|
|
142
|
+
|
|
143
|
+
shape = deduce_shape(num_structures, num_atoms, properties[key]["shape"])
|
|
144
|
+
labels[key] = np.zeros(shape, dtype=float_dtype)
|
|
145
|
+
labels[key + "_mask"] = labels[key].astype(bool)
|
|
146
|
+
|
|
147
|
+
labels["num_atoms"] = np.ones(num_structures, dtype=int_dtype)
|
|
148
|
+
|
|
149
|
+
atom_offset = 0
|
|
150
|
+
for i, l in enumerate(list_of_labels):
|
|
151
|
+
num_atoms = l["num_atoms"]
|
|
152
|
+
atom_slice = slice(atom_offset, atom_offset + num_atoms)
|
|
153
|
+
|
|
154
|
+
labels["num_atoms"][i] = num_atoms
|
|
155
|
+
|
|
156
|
+
for key in keys:
|
|
157
|
+
per_atom = is_per_atom(properties[key]["shape"])
|
|
158
|
+
values = l[key]
|
|
159
|
+
if not np.isnan(values).any():
|
|
160
|
+
if per_atom:
|
|
161
|
+
labels[key][atom_slice] = values
|
|
162
|
+
labels[key + "_mask"][atom_slice] = True
|
|
163
|
+
else:
|
|
164
|
+
# scalar per-structure: squeeze to avoid deprecation warning
|
|
165
|
+
labels[key][i] = np.squeeze(values)
|
|
166
|
+
labels[key + "_mask"][i] = True
|
|
167
|
+
# else: stays zero, mask False
|
|
168
|
+
|
|
169
|
+
atom_offset += num_atoms
|
|
170
|
+
|
|
171
|
+
return labels
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# -- test --
|
|
175
|
+
|
|
176
|
+
test_samples = [
|
|
177
|
+
Sample(
|
|
178
|
+
dict(
|
|
179
|
+
displacements=np.ones((6, 3)),
|
|
180
|
+
atomic_numbers=np.array([0, 0, 0]),
|
|
181
|
+
centers=np.array([0, 0, 1, 1, 2, 2]),
|
|
182
|
+
others=np.array([1, 2, 0, 2, 0, 1]),
|
|
183
|
+
),
|
|
184
|
+
{
|
|
185
|
+
"energy": 0.1,
|
|
186
|
+
"forces": np.ones((3, 3)),
|
|
187
|
+
"num_atoms": 3,
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
Sample(
|
|
191
|
+
dict(
|
|
192
|
+
displacements=np.ones((2, 3)),
|
|
193
|
+
atomic_numbers=np.array([0, 0]),
|
|
194
|
+
centers=np.array([0, 1]),
|
|
195
|
+
others=np.array([1, 0]),
|
|
196
|
+
),
|
|
197
|
+
{
|
|
198
|
+
"energy": 0.2,
|
|
199
|
+
"forces": np.ones((2, 3)),
|
|
200
|
+
"num_atoms": 2,
|
|
201
|
+
},
|
|
202
|
+
),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
num_pairs = 10
|
|
206
|
+
num_atoms = 8
|
|
207
|
+
|
|
208
|
+
test_batch = batch_samples(test_samples, num_atoms, num_pairs, ["energy", "forces"])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
assert test_batch.displacements.shape[0] == num_pairs
|
|
212
|
+
assert test_batch.centers.shape[0] == num_pairs
|
|
213
|
+
assert test_batch.others.shape[0] == num_pairs
|
|
214
|
+
assert test_batch.atomic_numbers.shape[0] == num_atoms
|
|
215
|
+
np.testing.assert_equal(
|
|
216
|
+
test_batch.displacements, np.concatenate((np.ones((8, 3)), np.zeros((2, 3))))
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
np.testing.assert_equal(test_batch.centers, np.array([0, 0, 1, 1, 2, 2, 3, 4, 5, 5]))
|
|
220
|
+
np.testing.assert_equal(test_batch.others, np.array([1, 2, 0, 2, 0, 1, 4, 3, 5, 5]))
|
|
221
|
+
np.testing.assert_equal(test_batch.atomic_numbers, np.array([0, 0, 0, 0, 0, 0, 0, 0]))
|
|
222
|
+
np.testing.assert_equal(test_batch.atom_to_structure, np.array([0, 0, 0, 1, 1, 2, 2, 2]))
|
|
223
|
+
np.testing.assert_equal(
|
|
224
|
+
test_batch.pair_to_structure, np.array([0, 0, 0, 0, 0, 0, 1, 1, 2, 2])
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
np.testing.assert_equal(
|
|
228
|
+
test_batch.atom_mask, np.array([True, True, True, True, True, False, False, False])
|
|
229
|
+
)
|
|
230
|
+
np.testing.assert_equal(test_batch.structure_mask, np.array([True, True, False]))
|
|
231
|
+
|
|
232
|
+
np.testing.assert_equal(test_batch.labels["energy"], np.array([0.1, 0.2, 0.0]))
|
|
233
|
+
np.testing.assert_equal(test_batch.labels["energy_mask"], np.array([True, True, False]))
|
|
234
|
+
assert test_batch.labels["forces"].shape == (8, 3)
|
|
235
|
+
|
|
236
|
+
np.testing.assert_array_equal(test_batch.labels["num_atoms"], np.array([3, 2, 1]))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
DEFAULT_PROPERTIES = {
|
|
2
|
+
"energy": {"shape": (1,), "storage": "atoms.calc"},
|
|
3
|
+
"forces": {"shape": ("atom", 3), "storage": "atoms.calc"},
|
|
4
|
+
"stress": {"shape": (3, 3), "storage": "atoms.calc"},
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def deduce_shape(num_structures, num_atoms, shape):
|
|
9
|
+
# batch context: resolves shape spec, prepends num_structures
|
|
10
|
+
# for non-atom properties.
|
|
11
|
+
# see also: marathon.grain.data_source.properties.deduce_shape
|
|
12
|
+
|
|
13
|
+
# scalar per structure: no dummy dimension
|
|
14
|
+
if shape == (1,):
|
|
15
|
+
shape = ()
|
|
16
|
+
|
|
17
|
+
# replace "atom" -> num_atoms, keep (optional) trailing shape
|
|
18
|
+
if (len(shape) > 0) and (shape[0] == "atom"):
|
|
19
|
+
shape = (num_atoms,) + shape[1:]
|
|
20
|
+
else:
|
|
21
|
+
shape = (num_structures,) + shape
|
|
22
|
+
|
|
23
|
+
return shape
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_per_atom(shape):
|
|
27
|
+
return (len(shape) > 0) and (shape[0] == "atom")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# -- test --
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_properties():
|
|
34
|
+
# deduce_shape: scalar per structure -> shape (num_structures,)
|
|
35
|
+
# (1,) removes dummy dim, then prepends num_structures
|
|
36
|
+
assert deduce_shape(5, 10, (1,)) == (5,)
|
|
37
|
+
|
|
38
|
+
# deduce_shape: per-atom property
|
|
39
|
+
assert deduce_shape(5, 10, ("atom", 3)) == (10, 3)
|
|
40
|
+
assert deduce_shape(5, 10, ("atom",)) == (10,)
|
|
41
|
+
|
|
42
|
+
# deduce_shape: per-structure tensor property
|
|
43
|
+
assert deduce_shape(5, 10, (3, 3)) == (5, 3, 3)
|
|
44
|
+
assert deduce_shape(5, 10, (6,)) == (5, 6)
|
|
45
|
+
|
|
46
|
+
# is_per_atom
|
|
47
|
+
assert is_per_atom(("atom", 3)) is True
|
|
48
|
+
assert is_per_atom(("atom",)) is True
|
|
49
|
+
assert is_per_atom((3, 3)) is False
|
|
50
|
+
assert is_per_atom((1,)) is False
|
|
51
|
+
assert is_per_atom(()) is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
test_properties()
|