dadadaxi commited on
Commit
096347b
·
verified ·
1 Parent(s): a9d903f

Upload folder using huggingface_hub

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ outputs/
2
+ checkpoints/
3
+ .trainer_work/
4
+ __pycache__/
5
+ demo/__pycache__/
6
+ *.log
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Atomic Architects
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.
README.md ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tasks:
4
+ - materials-simulation
5
+ - energy-prediction
6
+ - force-prediction
7
+ - molecular-dynamics
8
+ frameworks:
9
+ - pytorch
10
+ language:
11
+ - en
12
+ tags:
13
+ - OneScience
14
+ - EquiformerV3
15
+ - materials-science
16
+ - machine-learning-potential
17
+ - graph-neural-network
18
+ - equivariant-neural-network
19
+ - training
20
+ - inference
21
+ datasets:
22
+ - OneScience-Group/oc20
23
+ ---
24
+
25
+ <p align="center">
26
+ <strong>
27
+ <span style="font-size: 30px;">Equiformer V3</span>
28
+ </strong>
29
+ </p>
30
+
31
+ # Model Introduction
32
+
33
+ Equiformer V3 is an SE(3)-equivariant graph attention potential model for three-dimensional atomic systems. It predicts the energy, atomic forces, and stress of atomic structures.
34
+
35
+ # Model Description
36
+
37
+ Equiformer V3 is based on an equivariant graph neural network architecture. Building on Equiformer V2, it improves equivariant normalization, smooth cutoff attention, and SwiGLU-S² activation for materials potential energy surface modeling. This release includes OneScience-adapted model code, pretrained weights, single-point inference, and an OC20 training example.
38
+
39
+ # Use Cases
40
+
41
+ | Use case | Description |
42
+ | :---: | :---: |
43
+ | Single-point energy, forces, and stress | Predict energy, forces, and stress for ASE-readable structures |
44
+ | Structure relaxation and formation energy | Use the model and elemental reference energies for structure optimization and formation-energy calculations |
45
+ | Elastic tensor | Calculate the elastic properties of periodic materials |
46
+ | Phonons | Calculate the phonon properties of periodic materials |
47
+ | OC20 S2EF training | Train on energy and force labels using preprocessed data from `OneScience-Group/oc20` |
48
+ | Custom data migration | Convert custom data to the ASE-LMDB format required by the configuration, then update the training paths |
49
+
50
+ # Usage
51
+
52
+ ## 1. Using OneCode
53
+
54
+ Try intelligent, one-click AI4S programming in the OneCode online environment:
55
+
56
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
57
+
58
+ ## 2. Manual Installation and Usage
59
+
60
+ **Hardware requirements**
61
+
62
+ - An NVIDIA GPU or Hygon DCU is recommended for inference and training.
63
+ - A CPU can be used to inspect configurations and prepare data but is not recommended for production training.
64
+ - DCU users must load a DTK environment compatible with the current PyTorch build.
65
+
66
+ ### Download the Model Package
67
+
68
+ ```bash
69
+ hf download --model OneScience-Group/Equiformer_v3 --local-dir ./Equiformer_v3
70
+ cd Equiformer_v3
71
+ ```
72
+
73
+ ### Install the Runtime Environment
74
+
75
+ **DCU environment**
76
+
77
+ ```bash
78
+ # Activate DTK and Conda first
79
+ conda create -n onescience311 python=3.11 -y
80
+ conda activate onescience311
81
+ pip install onescience[matchem-dcu] \
82
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
83
+ --trusted-host mirrors.onescience.ai
84
+ ```
85
+
86
+ **GPU environment**
87
+
88
+ ```bash
89
+ # Activate Conda first
90
+ conda create -n onescience311 python=3.11 -y
91
+ conda activate onescience311
92
+ pip install onescience[matchem-gpu] \
93
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
94
+ --trusted-host mirrors.onescience.ai
95
+ ```
96
+
97
+ ### Training Dataset
98
+
99
+ Training data is not bundled with this model repository. The OC20 training data is available from the Hugging Face dataset **OneScience-Group/oc20** and corresponds to the Open Catalyst 2020 (OC20) S2EF task. See the [OC20 dataset paper](https://doi.org/10.1021/acscatal.0c04525) and the [FAIR Chemistry OC20 documentation](https://fair-chem.github.io/oc20/) for the original data, task definitions, and citation information. When using the data, follow the licenses and terms on the OneScience dataset page, from the Open Catalyst Project, and for the original data.
100
+
101
+ ```bash
102
+ hf download --dataset OneScience-Group/oc20 --local-dir ./data
103
+ ```
104
+
105
+ Convert the downloaded data to the ASE-LMDB format expected by the training configuration. The current configuration reads:
106
+
107
+ ```text
108
+ data/oc20_finetune/
109
+ ├── train/ # ASE-LMDB training data
110
+ └── val/ # ASE-LMDB validation data
111
+ ```
112
+
113
+ If the downloaded OC20 data consists of `.extxyz` files or other ASE-readable source files, convert it with the bundled UMA data-processing script:
114
+
115
+ ```bash
116
+ python scripts/create_uma_finetune_dataset.py \
117
+ --train-dir ./data/oc20/s2ef_200k_uncompressed \
118
+ --val-dir ./data/oc20/s2ef_val_id_uncompressed \
119
+ --uma-task oc20 \
120
+ --regression-tasks ef \
121
+ --output-dir ./data/oc20_finetune \
122
+ --num-workers 8
123
+ ```
124
+
125
+ The script generates `train/` and `val/` ASE-LMDB shards together with a UMA data configuration file. Input files must contain energy and force labels provided through an ASE calculator. Files that fail conversion are recorded in a `.failed` log corresponding to each output shard. `create_finetune_dataset.py` is the low-level ASE data conversion implementation, while `create_uma_finetune_dataset.py` is the complete OC20/UMA data-processing entry point. Both scripts come from the UMA repository and are provided in their original form.
126
+
127
+ Under the OneScience MatChem data root, the corresponding paths are:
128
+
129
+ ```text
130
+ ${ONESCIENCE_DATASETS_DIR}/matchem/oc20/uma_oc20_finetune/
131
+ ├── train/
132
+ └── val/
133
+ ```
134
+
135
+ If the data is stored elsewhere, update the `train` and `val` fields in `demo/configs/oc20_scratch_8dcu.yaml` and `demo/configs/oc20_scratch_8dcu_smoke.yaml`, or set `ONESCIENCE_DATASETS_DIR`. The Equiformer V3 training YAML must continue to use its own `train`, `val`, and `transforms.element_references.energy.file` fields; the UMA fine-tuning YAML generated by the UMA script does not directly replace the Equiformer V3 training configuration. After changing the training data, refit the elemental reference-energy coefficients:
136
+
137
+ ```bash
138
+ python fit_element_references.py \
139
+ --config demo/configs/oc20_scratch_8dcu.yaml \
140
+ --output demo/reference_data/oc20_subset_energy_element_references.npz
141
+ ```
142
+
143
+ ### Model Weights
144
+
145
+ This repository includes the following Equiformer V3 weights:
146
+
147
+ ```text
148
+ weight/
149
+ ├── Jd.pt
150
+ ├── mptrj_gradient.pt
151
+ ├── omat24_direct.pt
152
+ ├── omat24_gradient.pt
153
+ └── omat24-mptrj-salex_gradient.pt
154
+ ```
155
+
156
+ | Weight | Training task or domain | Intended use |
157
+ | --- | --- | --- |
158
+ | `mptrj_gradient.pt` | MPtrj gradient | Inference or evaluation on MPtrj material structures |
159
+ | `omat24_direct.pt` | OMat24 direct + DeNS | Inference or evaluation with the OMat24 direct model |
160
+ | `omat24_gradient.pt` | OMat24 gradient | Inference or evaluation with the OMat24 gradient model |
161
+ | `omat24-mptrj-salex_gradient.pt` | OMat24 + MPtrj + sAlex gradient | General materials-structure inference; used by the inference scripts by default |
162
+
163
+ `Jd.pt` contains the Wigner rotation basis and is loaded automatically from `weight/Jd.pt` by the entry-point scripts.
164
+
165
+ ### Fine-Tuning
166
+
167
+ Run the OC20 smoke-test configuration:
168
+
169
+ ```bash
170
+ export ONESCIENCE_DATASETS_DIR=/path/to/onescience_datasets
171
+ bash demo/run.sh --config configs/oc20_scratch_8dcu_smoke.yaml
172
+ ```
173
+
174
+ The smoke-test configuration uses eight training samples, eight validation samples, and a reduced one-layer model. It performs a single update to verify the data pipeline, distributed communication, forward and backward passes, optimizer, and checkpoint-saving workflow.
175
+
176
+ Run the full OC20 configuration:
177
+
178
+ ```bash
179
+ bash demo/run.sh --config configs/oc20_scratch_8dcu.yaml
180
+ ```
181
+
182
+ This configuration uses one node, eight DCUs, and 12 epochs. FP32 is the currently validated stable path. Controlled experiments with the same full model in FP16/BF16 encountered a DCU kernel VMFault, so the release configuration uses `amp: false`.
183
+
184
+ ### Inference
185
+
186
+ Single-point energy, forces, and stress:
187
+
188
+ ```bash
189
+ python single_point.py --device cuda --output outputs/single_point.json
190
+ ```
191
+
192
+ Formation energy:
193
+
194
+ ```bash
195
+ python formation_energy.py --device cuda --output outputs/formation_energy.json
196
+ ```
197
+
198
+ Elastic tensor:
199
+
200
+ ```bash
201
+ python elastic.py --relax --device cuda --output outputs/elastic.json
202
+ ```
203
+
204
+ Phonons:
205
+
206
+ ```bash
207
+ python phonons.py \
208
+ --supercell 3 3 3 \
209
+ --bandpath GXWKGL \
210
+ --device cuda \
211
+ --output-dir outputs/phonons
212
+ ```
213
+
214
+ Custom structures or weights:
215
+
216
+ ```bash
217
+ python single_point.py --input structure.cif
218
+ python elastic.py --input POSCAR --output outputs/elastic.json
219
+ python phonons.py --input structure.cif --supercell 2 2 2
220
+ python single_point.py --checkpoint weight/omat24_gradient.pt
221
+ ```
222
+
223
+ `--input` supports CIF, POSCAR, XYZ, trajectory, and other formats readable by ASE. All four inference scripts use `weight/omat24-mptrj-salex_gradient.pt` by default.
224
+
225
+ ## Official OneScience Resources
226
+
227
+ | Platform | OneScience Main Repository | Skills Repository |
228
+ | --- | --- | --- |
229
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
230
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
231
+
232
+ ---
233
+
234
+ ## Citation and License
235
+
236
+ - The Equiformer V3 architecture code is based on upstream software released under the MIT License. See `LICENSE` in the repository root.
237
+ - The OC20 dataset is licensed under CC BY 4.0 as described on its official page. The repository's MIT License does not cover the dataset license.
238
+ - If you use Equiformer V3 or OC20 in research, cite the Equiformer V3 paper, the OC20 dataset paper, and the source of the dataset actually used.
239
+
240
+ ```bibtex
241
+ @article{equiformer_v3,
242
+ title={EquiformerV3: Scaling Efficient, Expressive, and General SE(3)-Equivariant Graph Attention Transformers},
243
+ author={Yi-Lun Liao and Alexander J. Hoffman and Sabrina C. Shen and Alexandre Duval and Sam Walton Norwood and Tess Smidt},
244
+ journal={arXiv preprint arXiv:2604.09130},
245
+ year={2026}
246
+ }
247
+
248
+ @article{oc20,
249
+ title={Open Catalyst 2020 (OC20) Dataset and Community Challenges},
250
+ author={Lowik Chanussot and Abhishek Das and Siddharth Goyal and others},
251
+ journal={ACS Catalysis},
252
+ year={2021},
253
+ doi={10.1021/acscatal.0c04525}
254
+ }
255
+ ```
demo/_parse_config.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract Equiformer V3 demo metadata and its fine-tuning configuration."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shlex
7
+ import sys
8
+
9
+ import yaml
10
+
11
+
12
+ META_KEYS = {"name", "description", "launch", "slurm", "nccl"}
13
+
14
+
15
+ def _assignment(name: str, value) -> None:
16
+ print(f"{name}={shlex.quote(str(value))}")
17
+
18
+
19
+ def _positive_int(value, field: str) -> int:
20
+ try:
21
+ parsed = int(value)
22
+ except (TypeError, ValueError) as error:
23
+ raise ValueError(f"{field} must be a positive integer") from error
24
+ if parsed < 1:
25
+ raise ValueError(f"{field} must be a positive integer")
26
+ return parsed
27
+
28
+
29
+ def _config(path: str) -> dict:
30
+ with open(path, encoding="utf-8") as handle:
31
+ return yaml.safe_load(handle) or {}
32
+
33
+
34
+ def _launch(cfg: dict) -> dict:
35
+ return cfg.get("launch", {}) or {}
36
+
37
+
38
+ def _slurm(cfg: dict) -> dict:
39
+ return cfg.get("slurm", {}) or {}
40
+
41
+
42
+ def _print_launch(cfg: dict) -> None:
43
+ launch = _launch(cfg)
44
+ mode = launch.get("mode", "local")
45
+ if mode not in {"local", "submit"}:
46
+ raise ValueError("launch.mode must be 'local' or 'submit'")
47
+ _assignment("RUN_MODE", mode)
48
+ _assignment("NODES", _positive_int(launch.get("num_nodes", 1), "launch.num_nodes"))
49
+ _assignment(
50
+ "GPUS_PER_NODE",
51
+ _positive_int(launch.get("num_gpus", 1), "launch.num_gpus"),
52
+ )
53
+ _assignment(
54
+ "OMP_NUM_THREADS",
55
+ _positive_int(launch.get("omp_num_threads", 1), "launch.omp_num_threads"),
56
+ )
57
+
58
+
59
+ def _print_slurm(cfg: dict) -> None:
60
+ slurm = _slurm(cfg)
61
+ _assignment("PARTITION", slurm.get("partition", "hx1hdexclu12"))
62
+ _assignment("TIME", slurm.get("time", "24:00:00"))
63
+ _assignment(
64
+ "CPUS_PER_TASK",
65
+ _positive_int(slurm.get("cpus_per_task", 16), "slurm.cpus_per_task"),
66
+ )
67
+ _assignment("NODELIST", slurm.get("nodelist", ""))
68
+
69
+
70
+ def _print_env(cfg: dict) -> None:
71
+ nccl = cfg.get("nccl", {}) or {}
72
+ if nccl.get("socket_ifname"):
73
+ _assignment("export NCCL_SOCKET_IFNAME", nccl["socket_ifname"])
74
+ if nccl.get("ib_hca"):
75
+ _assignment("export NCCL_IB_HCA", nccl["ib_hca"])
76
+ if nccl.get("proto"):
77
+ _assignment("export NCCL_PROTO", nccl["proto"])
78
+ print("export HSA_FORCE_FINE_GRAIN_PCIE=1")
79
+
80
+
81
+ def _print_finetune_config(cfg: dict) -> None:
82
+ finetune = {key: value for key, value in cfg.items() if key not in META_KEYS}
83
+ yaml.safe_dump(
84
+ finetune,
85
+ sys.stdout,
86
+ sort_keys=False,
87
+ default_flow_style=False,
88
+ allow_unicode=True,
89
+ )
90
+
91
+
92
+ def main() -> None:
93
+ if len(sys.argv) != 3:
94
+ raise SystemExit(
95
+ "usage: _parse_config.py <config.yaml> <name|launch|slurm|env|finetune-config>"
96
+ )
97
+ cfg = _config(sys.argv[1])
98
+ action = sys.argv[2]
99
+ actions = {
100
+ "name": lambda: print(cfg.get("name", "equiformer_v3_finetune")),
101
+ "launch": lambda: _print_launch(cfg),
102
+ "slurm": lambda: _print_slurm(cfg),
103
+ "env": lambda: _print_env(cfg),
104
+ "finetune-config": lambda: _print_finetune_config(cfg),
105
+ }
106
+ try:
107
+ actions[action]()
108
+ except KeyError as error:
109
+ raise SystemExit(f"unknown action: {action}") from error
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
demo/configs/oc20_scratch_8dcu.yaml ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Complete FP32 OC20 scratch training on the bundled ASE-LMDB subset with eight DCUs.
2
+ # The official model scale and effective global batch of 64 are retained.
3
+ # Per-DCU batch 1 plus accumulation 8 retains global batch 64. Energy element
4
+ # references are fitted offline, following the official OC20 configuration.
5
+ # FP32 is intentional: controlled 20-step FP16/BF16 runs raised DCU kernel
6
+ # VMFaults, while the otherwise equivalent FP32 run completed and checkpointed.
7
+ name: equiformer_v3_oc20_scratch_8dcu
8
+ description: "Complete Equiformer V3 OC20 scratch training on eight DCUs in FP32"
9
+
10
+ launch: {mode: local, num_nodes: 1, num_gpus: 8, omp_num_threads: 8}
11
+ slurm: {partition: hx1hdnormal01, time: "24:00:00", cpus_per_task: 64}
12
+ nccl: {}
13
+
14
+ mode: train_from_scratch
15
+ train: "${ONESCIENCE_DATASETS_DIR}/matchem/oc20/uma_oc20_finetune/train"
16
+ val: "${ONESCIENCE_DATASETS_DIR}/matchem/oc20/uma_oc20_finetune/val"
17
+ output: "checkpoints/equiformer_v3_oc20_scratch_8dcu.pt"
18
+ device: cuda
19
+ backend: nccl
20
+ seed: 0
21
+ amp: false
22
+ load_balancing: atoms
23
+ load_balancing_on_error: raise
24
+
25
+ epochs: 12
26
+ batch_size: 1
27
+ eval_batch_size: 1
28
+ workers: 0
29
+ grad_accumulation_steps: 8
30
+ log_every_n_steps: 10
31
+ log_every_n_validation_batches: 100
32
+ clip_grad_norm: 100
33
+ ema_decay: 0.999
34
+
35
+ transforms:
36
+ element_references:
37
+ energy:
38
+ file: "${ONESCIENCE_EQUIFORMER_V3_DIR}/demo/reference_data/oc20_subset_energy_element_references.npz"
39
+ normalizers:
40
+ energy: {mean: 0.0, rmsd: 24.901469505465872}
41
+ forces: {mean: 0.0, rmsd: 0.5111534595489502}
42
+
43
+ losses:
44
+ energy: {fn: mae, coefficient: 4}
45
+ forces: {fn: l2mae, coefficient: 100, free_atoms_only: true}
46
+
47
+ optimizer:
48
+ name: AdamW
49
+ lr: 2.0e-4
50
+ weight_decay: 1.0e-3
51
+ betas: [0.9, 0.999]
52
+ eps: 1.0e-8
53
+
54
+ scheduler:
55
+ name: cosine
56
+ warmup_factor: 0.2
57
+ warmup_epochs: 0.1
58
+ lr_min_factor: 0.01
59
+
60
+ model:
61
+ name: equiformer_v3
62
+ use_pbc: true
63
+ use_pbc_single: false
64
+ otf_graph: true
65
+ regress_forces: true
66
+ regress_stress: false
67
+ direct_prediction: true
68
+ max_neighbors: 20
69
+ max_radius: 12.0
70
+ num_radial_basis: 128
71
+ max_num_elements: 128
72
+ num_layers: 8
73
+ num_channels: 128
74
+ attn_hidden_channels: 64
75
+ num_heads: 8
76
+ attn_alpha_channels: 64
77
+ attn_value_channels: 16
78
+ ffn_hidden_channels: 512
79
+ norm_type: merge_layer_norm
80
+ lmax: 6
81
+ mmax: 2
82
+ attn_grid_resolution_list: [20, 8]
83
+ ffn_grid_resolution_list: [20, 20]
84
+ edge_channels: 128
85
+ use_atom_edge_embedding: true
86
+ use_envelope: true
87
+ attn_activation: sep-merge_gates2_swiglu
88
+ use_attn_renorm: true
89
+ use_add_merge: false
90
+ use_rad_l_parametrization: true
91
+ softcap: null
92
+ ffn_activation: sep-merge_gates2_swiglu
93
+ use_grid_mlp: true
94
+ use_gate_force_head: true
95
+ alpha_drop: 0.0
96
+ attn_mask_rate: 0.0
97
+ attn_weights_drop: 0.1
98
+ value_drop: 0.0
99
+ drop_path_rate: 0.05
100
+ proj_drop: 0.0
101
+ ffn_drop: 0.0
102
+ gradient_checkpointing_block_list: [1, 1, 1, 1, 1, 1, 1, 1]
103
+ enforce_max_neighbors_strictly: true
demo/configs/oc20_scratch_8dcu_smoke.yaml ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Eight-DCU FP32 DDP smoke test. This tiny model is not a scientific run.
2
+ # FP32 matches the precision path that passed the controlled 20-step full-model test.
3
+ name: equiformer_v3_oc20_scratch_8dcu_smoke
4
+ description: "One-update OC20 scratch FP32 DDP smoke test"
5
+
6
+ launch: {mode: submit, num_nodes: 1, num_gpus: 8, omp_num_threads: 8}
7
+ slurm: {partition: hx1hdnormal01, time: "00:20:00", cpus_per_task: 64}
8
+ nccl: {}
9
+
10
+ mode: train_from_scratch
11
+ train: "${ONESCIENCE_DATASETS_DIR}/matchem/oc20/uma_oc20_finetune/train"
12
+ val: "${ONESCIENCE_DATASETS_DIR}/matchem/oc20/uma_oc20_finetune/val"
13
+ output: "checkpoints/equiformer_v3_oc20_scratch_8dcu_smoke.pt"
14
+ device: cuda
15
+ backend: nccl
16
+ seed: 17
17
+ amp: false
18
+ load_balancing: atoms
19
+ load_balancing_on_error: raise
20
+ epochs: 1
21
+ max_steps: 1
22
+ batch_size: 1
23
+ eval_batch_size: 1
24
+ workers: 0
25
+ max_train_samples: 8
26
+ max_val_samples: 8
27
+ grad_accumulation_steps: 1
28
+ log_every_n_steps: 10
29
+ log_every_n_validation_batches: 100
30
+ clip_grad_norm: 100
31
+ ema_decay: 0.9
32
+
33
+ transforms:
34
+ element_references:
35
+ energy:
36
+ file: "${ONESCIENCE_EQUIFORMER_V3_DIR}/demo/reference_data/oc20_subset_energy_element_references.npz"
37
+ normalizers:
38
+ energy: {mean: 0.0, rmsd: 24.901469505465872}
39
+ forces: {mean: 0.0, rmsd: 0.5111534595489502}
40
+
41
+ losses:
42
+ energy: {fn: mae, coefficient: 4}
43
+ forces: {fn: l2mae, coefficient: 100, free_atoms_only: true}
44
+
45
+ optimizer: {name: AdamW, lr: 2.0e-4, weight_decay: 1.0e-3, betas: [0.9, 0.999], eps: 1.0e-8}
46
+ scheduler: {name: cosine, warmup_factor: 0.2, warmup_epochs: 0.1, lr_min_factor: 0.01}
47
+
48
+ model:
49
+ name: equiformer_v3
50
+ use_pbc: true
51
+ use_pbc_single: false
52
+ otf_graph: true
53
+ regress_forces: true
54
+ regress_stress: false
55
+ direct_prediction: true
56
+ max_neighbors: 20
57
+ max_radius: 6.0
58
+ num_radial_basis: 16
59
+ max_num_elements: 128
60
+ num_layers: 1
61
+ num_channels: 16
62
+ attn_hidden_channels: 8
63
+ num_heads: 2
64
+ attn_alpha_channels: 8
65
+ attn_value_channels: 4
66
+ ffn_hidden_channels: 32
67
+ norm_type: merge_layer_norm
68
+ lmax: 2
69
+ mmax: 2
70
+ attn_grid_resolution_list: [8, 4]
71
+ ffn_grid_resolution_list: [8, 8]
72
+ edge_channels: 16
73
+ use_atom_edge_embedding: true
74
+ use_envelope: true
75
+ attn_activation: sep-merge_gates2_swiglu
76
+ use_attn_renorm: true
77
+ use_add_merge: false
78
+ use_rad_l_parametrization: true
79
+ softcap: null
80
+ ffn_activation: sep-merge_gates2_swiglu
81
+ use_grid_mlp: true
82
+ use_gate_force_head: true
83
+ alpha_drop: 0.0
84
+ attn_mask_rate: 0.0
85
+ attn_weights_drop: 0.0
86
+ value_drop: 0.0
87
+ drop_path_rate: 0.0
88
+ proj_drop: 0.0
89
+ ffn_drop: 0.0
90
+ gradient_checkpointing_block_list: [0]
91
+ enforce_max_neighbors_strictly: true
demo/reference_data/oc20_subset_energy_element_references.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f1ed53d158dfd54bf6f8a1f74c1e56d36e974651a9f87c7ca70fd9a5af94e03e
3
+ size 740
demo/run.sh ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run Equiformer V3 after the central MatChem environment has been loaded.
3
+ set -euo pipefail
4
+
5
+ DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ EQV3_DIR="$(cd "$DEMO_DIR/.." && pwd)"
7
+ REPO_ROOT="$EQV3_DIR"
8
+ export ONESCIENCE_EQUIFORMER_V3_DIR="$EQV3_DIR"
9
+ export ONESCIENCE_EQUIFORMER_V3_JD_PATH="${ONESCIENCE_EQUIFORMER_V3_JD_PATH:-$EQV3_DIR/weight/Jd.pt}"
10
+ PARSER="$DEMO_DIR/_parse_config.py"
11
+ CONFIG=""
12
+ SUBMIT=false
13
+
14
+ while [[ $# -gt 0 ]]; do
15
+ case "$1" in
16
+ --config) CONFIG="$2"; shift 2 ;;
17
+ --config=*) CONFIG="${1#*=}"; shift ;;
18
+ --submit) SUBMIT=true; shift ;;
19
+ -h|--help)
20
+ echo "Usage: bash demo/run.sh --config configs/<name>.yaml"
21
+ echo "Set launch.mode: submit in YAML to submit a Slurm job."
22
+ exit 0
23
+ ;;
24
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
25
+ esac
26
+ done
27
+
28
+ [[ -n "$CONFIG" ]] || { echo "Please specify --config configs/<name>.yaml" >&2; exit 2; }
29
+ [[ "$CONFIG" = /* ]] || CONFIG="$DEMO_DIR/$CONFIG"
30
+ [[ -f "$CONFIG" ]] || { echo "Config not found: $CONFIG" >&2; exit 2; }
31
+
32
+ missing_env=()
33
+ for variable in CONDA_PREFIX ONESCIENCE_DATASETS_DIR; do
34
+ [[ -n "${!variable:-}" ]] || missing_env+=("$variable")
35
+ done
36
+ if (( ${#missing_env[@]} )); then
37
+ echo "The OneScience MatChem environment is not loaded (missing: ${missing_env[*]})." >&2
38
+ echo "Activate the conda environment and set ONESCIENCE_DATASETS_DIR first." >&2
39
+ exit 2
40
+ fi
41
+ export MATCHEM_CONDA_NAME="${MATCHEM_CONDA_NAME:-$(basename "$CONDA_PREFIX")}"
42
+
43
+ NAME="$(python3 "$PARSER" "$CONFIG" name)"
44
+ eval "$(python3 "$PARSER" "$CONFIG" launch)"
45
+ eval "$(python3 "$PARSER" "$CONFIG" slurm)"
46
+ ENV_EXPORTS="$(python3 "$PARSER" "$CONFIG" env)"
47
+
48
+ if [[ "$RUN_MODE" == "submit" ]]; then
49
+ SUBMIT=true
50
+ fi
51
+
52
+ if ! $SUBMIT; then
53
+ AUTO_SUBMIT_REASON=""
54
+ if (( NODES > 1 )); then
55
+ AUTO_SUBMIT_REASON="the config requests $NODES nodes"
56
+ else
57
+ AVAILABLE_GPUS="$(python3 -c 'import torch; print(torch.cuda.device_count() if torch.cuda.is_available() else 0)' 2>/dev/null || echo 0)"
58
+ if (( AVAILABLE_GPUS < GPUS_PER_NODE )); then
59
+ AUTO_SUBMIT_REASON="the config requests $GPUS_PER_NODE DCUs but only $AVAILABLE_GPUS are visible"
60
+ fi
61
+ fi
62
+ if [[ -n "$AUTO_SUBMIT_REASON" ]]; then
63
+ if ! command -v sbatch >/dev/null 2>&1; then
64
+ echo "Cannot run locally: $AUTO_SUBMIT_REASON, and sbatch is unavailable." >&2
65
+ exit 2
66
+ fi
67
+ echo "Local resources are insufficient: $AUTO_SUBMIT_REASON. Submitting to Slurm."
68
+ SUBMIT=true
69
+ fi
70
+ fi
71
+
72
+ TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
73
+ OUTPUT_ROOT="${EQUIFORMER_V3_OUTPUT_ROOT:-$EQV3_DIR/outputs}"
74
+ OUTPUT_DIR="$OUTPUT_ROOT/${NAME}_${TIMESTAMP}"
75
+ mkdir -p "$OUTPUT_DIR/checkpoints"
76
+ cp "$CONFIG" "$OUTPUT_DIR/config.yaml"
77
+ FINETUNE_CONFIG="$OUTPUT_DIR/finetune.yaml"
78
+ python3 "$PARSER" "$CONFIG" finetune-config > "$FINETUNE_CONFIG"
79
+
80
+ if $SUBMIT; then
81
+ SLURM_SCRIPT="$OUTPUT_DIR/submit.sh"
82
+ cat > "$SLURM_SCRIPT" <<EOF
83
+ #!/bin/bash
84
+ #SBATCH --job-name=$NAME
85
+ #SBATCH --partition=$PARTITION
86
+ #SBATCH --nodes=$NODES
87
+ #SBATCH --ntasks-per-node=1
88
+ #SBATCH --cpus-per-task=$CPUS_PER_TASK
89
+ #SBATCH --gres=dcu:$GPUS_PER_NODE
90
+ #SBATCH --time=$TIME
91
+ #SBATCH --output=$OUTPUT_DIR/slurm_%j.out
92
+ #SBATCH --error=$OUTPUT_DIR/slurm_%j.err
93
+ EOF
94
+
95
+ if [[ -n "$NODELIST" ]]; then
96
+ printf '#SBATCH --nodelist=%s\n' "$NODELIST" >> "$SLURM_SCRIPT"
97
+ fi
98
+
99
+ cat >> "$SLURM_SCRIPT" <<EOF
100
+
101
+ set -euo pipefail
102
+ export PYTHONPATH="$REPO_ROOT:\${PYTHONPATH:-}"
103
+ export ONESCIENCE_EQUIFORMER_V3_DIR="$EQV3_DIR"
104
+ export ONESCIENCE_EQUIFORMER_V3_JD_PATH="$ONESCIENCE_EQUIFORMER_V3_JD_PATH"
105
+ # A Slurm allocation starts a new shell, so activate the same conda environment.
106
+ source "$(dirname "$CONDA_EXE")/../etc/profile.d/conda.sh"
107
+ conda activate "$MATCHEM_CONDA_NAME"
108
+ export OMP_NUM_THREADS="$OMP_NUM_THREADS"
109
+ # A login/compute shell may carry a single-device selection into sbatch.
110
+ # Let Slurm expose all devices requested by this YAML.
111
+ if (( $GPUS_PER_NODE > 1 )); then
112
+ unset HIP_VISIBLE_DEVICES
113
+ fi
114
+ $ENV_EXPORTS
115
+ cd "$OUTPUT_DIR"
116
+ EOF
117
+
118
+ if (( NODES > 1 )); then
119
+ cat >> "$SLURM_SCRIPT" <<EOF
120
+
121
+ nodes=(\$(scontrol show hostnames "\$SLURM_JOB_NODELIST"))
122
+ export MASTER_ADDR="\${nodes[0]}"
123
+ export MASTER_PORT=\$((20000 + SLURM_JOB_ID % 20000))
124
+ echo "Equiformer V3 multi-node DDP: nodes=\$SLURM_NNODES, devices/node=$GPUS_PER_NODE"
125
+ echo "MASTER_ADDR=\$MASTER_ADDR MASTER_PORT=\$MASTER_PORT"
126
+
127
+ srun --nodes="\$SLURM_NNODES" --ntasks="\$SLURM_NNODES" --ntasks-per-node=1 \\
128
+ bash -c 'exec torchrun \\
129
+ --nnodes=$NODES \\
130
+ --node_rank="\${SLURM_NODEID}" \\
131
+ --nproc_per_node=$GPUS_PER_NODE \\
132
+ --rdzv_id="\${SLURM_JOB_ID}" \\
133
+ --rdzv_backend=c10d \\
134
+ --rdzv_endpoint="\${MASTER_ADDR}:\${MASTER_PORT}" \\
135
+ "$EQV3_DIR/finetune.py" --config "$FINETUNE_CONFIG"'
136
+ EOF
137
+ elif (( GPUS_PER_NODE > 1 )); then
138
+ cat >> "$SLURM_SCRIPT" <<EOF
139
+
140
+ torchrun --standalone --nproc_per_node=$GPUS_PER_NODE \\
141
+ "$EQV3_DIR/finetune.py" --config "$FINETUNE_CONFIG"
142
+ EOF
143
+ else
144
+ echo "python \"$EQV3_DIR/finetune.py\" --config \"$FINETUNE_CONFIG\"" >> "$SLURM_SCRIPT"
145
+ fi
146
+
147
+ chmod u+x "$SLURM_SCRIPT"
148
+ echo "Submitting Equiformer V3 job: $SLURM_SCRIPT"
149
+ sbatch "$SLURM_SCRIPT"
150
+ exit 0
151
+ fi
152
+
153
+ eval "$ENV_EXPORTS"
154
+ export PYTHONPATH="$REPO_ROOT:${PYTHONPATH:-}"
155
+ cd "$OUTPUT_DIR"
156
+ if (( GPUS_PER_NODE > 1 )); then
157
+ exec torchrun --standalone --nproc_per_node="$GPUS_PER_NODE" \
158
+ "$EQV3_DIR/finetune.py" --config "$FINETUNE_CONFIG"
159
+ fi
160
+ exec python "$EQV3_DIR/finetune.py" --config "$FINETUNE_CONFIG"
elastic.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fit an elastic tensor from Equiformer V3 stress predictions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ os.environ.setdefault(
10
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
11
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
12
+ )
13
+
14
+ from ase.build import bulk
15
+ from ase.io import read, write
16
+
17
+ from onescience.utils.equiformer_v3 import (
18
+ EquiformerV3Calculator,
19
+ relax_structure,
20
+ run_elastic_workflow,
21
+ write_workflow_result,
22
+ )
23
+
24
+
25
+ def default_checkpoint() -> str:
26
+ return str(
27
+ Path(__file__).resolve().parent
28
+ / "weight"
29
+ / "omat24-mptrj-salex_gradient.pt"
30
+ )
31
+
32
+
33
+ def load_structure(path: str | None):
34
+ if path:
35
+ return read(path)
36
+ return bulk("Cu")
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser(description=__doc__)
41
+ parser.add_argument("--checkpoint")
42
+ parser.add_argument(
43
+ "--input",
44
+ help="periodic structure readable by ASE; defaults to built-in bulk Cu",
45
+ )
46
+ parser.add_argument(
47
+ "--relax",
48
+ action="store_true",
49
+ help="relax the cell and atomic positions before applying strains",
50
+ )
51
+ parser.add_argument(
52
+ "--normal-strains", type=float, nargs="+", default=(-0.01, 0.01)
53
+ )
54
+ parser.add_argument(
55
+ "--shear-strains", type=float, nargs="+", default=(-0.02, 0.02)
56
+ )
57
+ parser.add_argument(
58
+ "--relax-positions",
59
+ action="store_true",
60
+ help="relax atomic positions at fixed cell for every deformation",
61
+ )
62
+ parser.add_argument("--relax-fmax", type=float, default=0.02)
63
+ parser.add_argument("--relax-steps", type=int, default=100)
64
+ parser.add_argument("--device", default="cuda")
65
+ parser.add_argument("--output", default="outputs/elastic.json")
66
+ args = parser.parse_args()
67
+
68
+ checkpoint = args.checkpoint or default_checkpoint()
69
+ calculator = EquiformerV3Calculator.from_checkpoint(
70
+ checkpoint, device=args.device
71
+ )
72
+ structure = load_structure(args.input)
73
+ initial_relaxation = None
74
+ if args.relax:
75
+ structure, initial_relaxation = relax_structure(
76
+ structure,
77
+ calculator,
78
+ relax_cell=True,
79
+ fmax=args.relax_fmax,
80
+ steps=args.relax_steps,
81
+ )
82
+ relaxed_output = Path(args.output).with_name("elastic_relaxed.cif")
83
+ relaxed_output.parent.mkdir(parents=True, exist_ok=True)
84
+ write(relaxed_output, structure)
85
+ result = run_elastic_workflow(
86
+ structure,
87
+ calculator,
88
+ normal_strains=args.normal_strains,
89
+ shear_strains=args.shear_strains,
90
+ relax_positions=args.relax_positions,
91
+ relax_fmax=args.relax_fmax,
92
+ relax_steps=args.relax_steps,
93
+ )
94
+ result["checkpoint"] = str(checkpoint)
95
+ result["initial_relaxation"] = initial_relaxation
96
+ output = write_workflow_result(result, args.output)
97
+
98
+ print("formula:", result["formula"])
99
+ print("bulk modulus, Hill (GPa):", result["bulk_modulus_gpa"]["hill"])
100
+ print("result:", output)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
evaluate.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate an Equiformer V3 checkpoint on an independent ASE database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ os.environ.setdefault(
12
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
13
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
14
+ )
15
+
16
+ import torch
17
+
18
+ from onescience.utils.equiformer_v3 import (
19
+ EquiformerV3CheckpointTransforms,
20
+ load_equiformer_v3_checkpoint,
21
+ )
22
+
23
+ from finetune import _loader
24
+
25
+
26
+ @dataclass
27
+ class ErrorAccumulator:
28
+ """Accumulate MAE/RMSE inputs without retaining the full dataset."""
29
+
30
+ absolute_sum: float = 0.0
31
+ squared_sum: float = 0.0
32
+ count: int = 0
33
+
34
+ def update(self, error: torch.Tensor) -> None:
35
+ error = error.detach().reshape(-1).double()
36
+ self.absolute_sum += float(error.abs().sum())
37
+ self.squared_sum += float(error.square().sum())
38
+ self.count += error.numel()
39
+
40
+ def result(self) -> dict[str, float]:
41
+ if self.count == 0:
42
+ raise ValueError("cannot compute metrics for an empty tensor")
43
+ return {
44
+ "mae": self.absolute_sum / self.count,
45
+ "rmse": (self.squared_sum / self.count) ** 0.5,
46
+ }
47
+
48
+
49
+ @dataclass
50
+ class MeanAccumulator:
51
+ total: float = 0.0
52
+ count: int = 0
53
+
54
+ def update(self, values: torch.Tensor) -> None:
55
+ values = values.detach().reshape(-1).double()
56
+ self.total += float(values.sum())
57
+ self.count += values.numel()
58
+
59
+ def result(self) -> float:
60
+ if self.count == 0:
61
+ raise ValueError("cannot compute a mean for an empty tensor")
62
+ return self.total / self.count
63
+
64
+
65
+ def _force_cosine(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
66
+ prediction = prediction.detach().float().reshape(-1, 3)
67
+ target = target.detach().float().reshape(-1, 3)
68
+ if prediction.shape != target.shape or prediction.shape[0] == 0:
69
+ raise ValueError(
70
+ "cannot compute force cosine similarity for an empty/mismatched tensor"
71
+ )
72
+ return torch.cosine_similarity(prediction, target, dim=1).detach().cpu()
73
+
74
+
75
+ def _force_magnitude_error(
76
+ prediction: torch.Tensor, target: torch.Tensor
77
+ ) -> torch.Tensor:
78
+ prediction = prediction.detach().float().reshape(-1, 3)
79
+ target = target.detach().float().reshape(-1, 3)
80
+ if prediction.shape != target.shape or prediction.shape[0] == 0:
81
+ raise ValueError(
82
+ "cannot compute force magnitude error for an empty/mismatched tensor"
83
+ )
84
+ return (
85
+ torch.linalg.vector_norm(prediction, dim=1)
86
+ - torch.linalg.vector_norm(target, dim=1)
87
+ ).abs().cpu()
88
+
89
+
90
+ def _energy_force_success(
91
+ energy_error: torch.Tensor,
92
+ force_error: torch.Tensor,
93
+ selected_natoms: torch.Tensor,
94
+ ) -> torch.Tensor:
95
+ """Match FairChem's OC20 energy/force threshold metric per structure."""
96
+
97
+ energy_error = energy_error.detach().reshape(-1).abs()
98
+ force_error = force_error.detach().float().reshape(-1, 3).abs()
99
+ selected_natoms = selected_natoms.detach().reshape(-1).long()
100
+ if energy_error.numel() != selected_natoms.numel():
101
+ raise ValueError("energy count and per-structure atom counts differ")
102
+ if int(selected_natoms.sum()) != force_error.shape[0]:
103
+ raise ValueError("force count and per-structure atom counts differ")
104
+ if bool((selected_natoms == 0).any()):
105
+ raise ValueError("a structure contains no selected atoms for force evaluation")
106
+
107
+ successes = []
108
+ for structure_energy_error, structure_force_error in zip(
109
+ energy_error, torch.split(force_error, selected_natoms.tolist())
110
+ ):
111
+ successes.append(
112
+ (structure_energy_error < 0.02)
113
+ & (structure_force_error.max() < 0.03)
114
+ )
115
+ return torch.stack(successes).detach().cpu()
116
+
117
+
118
+ def main() -> None:
119
+ parser = argparse.ArgumentParser(description=__doc__)
120
+ parser.add_argument("--checkpoint", required=True)
121
+ parser.add_argument("--data", required=True, help="Independent ASE DB/ASE-LMDB")
122
+ parser.add_argument("--device", default="cuda")
123
+ parser.add_argument("--batch-size", type=int, default=2)
124
+ parser.add_argument("--workers", type=int, default=0)
125
+ parser.add_argument("--max-samples", type=int)
126
+ parser.add_argument("--seed", type=int, default=0)
127
+ parser.add_argument(
128
+ "--free-atoms-only",
129
+ action=argparse.BooleanOptionalAction,
130
+ default=True,
131
+ help="Evaluate forces only on atoms not constrained by FixAtoms (default: true).",
132
+ )
133
+ parser.add_argument(
134
+ "--include-stress",
135
+ action="store_true",
136
+ help="Also report stress when both checkpoint and dataset provide it.",
137
+ )
138
+ parser.add_argument(
139
+ "--include-oc20-threshold",
140
+ action="store_true",
141
+ help="Report FairChem's OC20 energy/force threshold success rate.",
142
+ )
143
+ parser.add_argument("--output")
144
+ args = parser.parse_args()
145
+
146
+ device = torch.device(args.device)
147
+ if device.type == "cuda" and not torch.cuda.is_available():
148
+ raise RuntimeError("CUDA/DCU was requested but is unavailable")
149
+
150
+ model = load_equiformer_v3_checkpoint(args.checkpoint).to(device)
151
+ model.eval()
152
+ transforms = EquiformerV3CheckpointTransforms.from_checkpoint(
153
+ args.checkpoint
154
+ ).to(device)
155
+ loader = _loader(
156
+ args.data,
157
+ args.batch_size,
158
+ args.workers,
159
+ max_samples=args.max_samples,
160
+ train=False,
161
+ seed=args.seed,
162
+ )
163
+
164
+ energy_errors = ErrorAccumulator()
165
+ energy_per_atom_errors = ErrorAccumulator()
166
+ force_errors = ErrorAccumulator()
167
+ force_cosines = MeanAccumulator()
168
+ force_magnitude_errors = MeanAccumulator()
169
+ energy_force_successes = MeanAccumulator()
170
+ stress_errors = ErrorAccumulator()
171
+ with torch.enable_grad():
172
+ for batch in loader:
173
+ batch = batch.to(device)
174
+ prediction = model(batch)
175
+ pred_energy = transforms.denormalize_prediction(
176
+ "energy", prediction["energy"], batch
177
+ )
178
+ target_energy = batch.energy.reshape_as(pred_energy)
179
+ energy_error = pred_energy - target_energy
180
+ energy_errors.update(energy_error)
181
+ natoms = batch.natoms.to(pred_energy).reshape(
182
+ (-1,) + (1,) * (pred_energy.ndim - 1)
183
+ )
184
+ energy_per_atom_errors.update(energy_error / natoms)
185
+
186
+ pred_forces = transforms.denormalize_prediction(
187
+ "forces", prediction["forces"], batch
188
+ )
189
+ target_forces = batch.forces.reshape_as(pred_forces)
190
+ selected_natoms = batch.natoms
191
+ if args.free_atoms_only and hasattr(batch, "fixed"):
192
+ free_mask = batch.fixed.reshape(-1) == 0
193
+ selected_natoms = torch.stack(
194
+ [
195
+ structure_mask.sum()
196
+ for structure_mask in torch.split(
197
+ free_mask, batch.natoms.tolist()
198
+ )
199
+ ]
200
+ )
201
+ pred_forces = pred_forces.reshape(-1, 3)[free_mask]
202
+ target_forces = target_forces.reshape(-1, 3)[free_mask]
203
+ if not pred_forces.numel():
204
+ selection = "free atoms" if args.free_atoms_only else "atoms"
205
+ raise ValueError(f"an evaluation batch contains no selected {selection}")
206
+ force_error = pred_forces - target_forces
207
+ force_errors.update(force_error)
208
+ force_cosines.update(_force_cosine(pred_forces, target_forces))
209
+ force_magnitude_errors.update(
210
+ _force_magnitude_error(pred_forces, target_forces)
211
+ )
212
+ if args.include_oc20_threshold:
213
+ energy_force_successes.update(
214
+ _energy_force_success(
215
+ energy_error, force_error, selected_natoms
216
+ )
217
+ )
218
+ if (
219
+ args.include_stress
220
+ and "stress" in prediction
221
+ and hasattr(batch, "stress")
222
+ ):
223
+ pred_stress = transforms.denormalize_prediction(
224
+ "stress", prediction["stress"], batch
225
+ )
226
+ target_stress = batch.stress.reshape_as(pred_stress)
227
+ stress_errors.update(pred_stress - target_stress)
228
+
229
+ result = {
230
+ "checkpoint": str(Path(args.checkpoint).expanduser()),
231
+ "data": str(Path(args.data).expanduser()),
232
+ "samples": len(loader.dataset),
233
+ "force_atoms": force_cosines.count,
234
+ "free_atoms_only": args.free_atoms_only,
235
+ "energy_total_eV": energy_errors.result(),
236
+ "energy_per_atom_eV": energy_per_atom_errors.result(),
237
+ }
238
+ if force_errors.count == 0:
239
+ selection = "free atoms" if args.free_atoms_only else "atoms"
240
+ raise ValueError(f"the evaluation dataset contains no selected {selection}")
241
+ result["forces_eV_per_A"] = force_errors.result()
242
+ result["forces_cosine_similarity"] = {"mean": force_cosines.result()}
243
+ result["forces_magnitude_error_eV_per_A"] = {
244
+ "mean": force_magnitude_errors.result()
245
+ }
246
+ if args.include_oc20_threshold:
247
+ result["energy_forces_within_threshold"] = {
248
+ "fraction": energy_force_successes.result(),
249
+ "energy_threshold_eV": 0.02,
250
+ "force_threshold_eV_per_A": 0.03,
251
+ }
252
+ if stress_errors.count:
253
+ result["stress_eV_per_A3"] = stress_errors.result()
254
+ print(json.dumps(result, indent=2, sort_keys=True))
255
+ if args.output:
256
+ output = Path(args.output).expanduser()
257
+ output.parent.mkdir(parents=True, exist_ok=True)
258
+ output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
finetune.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compatibility entrypoint for Equiformer V3 checkpoint fine-tuning.
2
+
3
+ New training configurations are handled by :mod:`train`, which supports
4
+ scratch training, checkpoint initialization, and full-state resume. The
5
+ loader remains here for compatibility with ``evaluate.py`` and external code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ os.environ.setdefault(
17
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
18
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
19
+ )
20
+
21
+ import torch
22
+ import yaml
23
+ from torch.nn.parallel import DistributedDataParallel
24
+ from torch.utils.data import DataLoader, Subset
25
+ from torch.utils.data.distributed import DistributedSampler
26
+
27
+ from onescience.datapipes.materials.custom_stack import data_list_collater
28
+ from onescience.datapipes.materials.custom_stack.storage.ase_datasets import AseDBDataset
29
+ from onescience.utils.equiformer_v3 import (
30
+ EquiformerV3CheckpointTransforms,
31
+ load_equiformer_v3_checkpoint,
32
+ )
33
+ from onescience.utils.uma.normalization.element_references import (
34
+ fit_linear_references,
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class DistributedContext:
40
+ """Runtime information for a normal Python process or a torchrun worker."""
41
+
42
+ rank: int = 0
43
+ world_size: int = 1
44
+ local_rank: int = 0
45
+
46
+ @property
47
+ def enabled(self) -> bool:
48
+ return self.world_size > 1
49
+
50
+ @property
51
+ def is_main(self) -> bool:
52
+ return self.rank == 0
53
+
54
+
55
+ def _init_distributed(device_name: str, backend: str) -> DistributedContext:
56
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
57
+ if world_size == 1:
58
+ if device_name.startswith("cuda") and torch.cuda.is_available():
59
+ torch.cuda.set_device(0)
60
+ return DistributedContext()
61
+
62
+ if not torch.distributed.is_available():
63
+ raise RuntimeError("torch.distributed is required for multi-device fine-tuning.")
64
+ rank = int(os.environ["RANK"])
65
+ local_rank = int(os.environ.get("LOCAL_RANK", rank))
66
+ if device_name.startswith("cuda"):
67
+ if not torch.cuda.is_available():
68
+ raise RuntimeError(
69
+ "torchrun requested multiple CUDA/DCU devices, but CUDA is unavailable."
70
+ )
71
+ torch.cuda.set_device(local_rank)
72
+ torch.distributed.init_process_group(backend=backend, rank=rank, world_size=world_size)
73
+ return DistributedContext(rank=rank, world_size=world_size, local_rank=local_rank)
74
+
75
+
76
+ def _close_distributed(context: DistributedContext) -> None:
77
+ if context.enabled and torch.distributed.is_initialized():
78
+ torch.distributed.barrier()
79
+ torch.distributed.destroy_process_group()
80
+
81
+
82
+ def _loader(
83
+ path: str | list[str],
84
+ batch_size: int,
85
+ workers: int,
86
+ max_samples: int | None = None,
87
+ context: DistributedContext | None = None,
88
+ train: bool = False,
89
+ seed: int = 0,
90
+ ) -> DataLoader:
91
+ dataset = AseDBDataset(
92
+ {
93
+ "src": path,
94
+ "a2g_args": {
95
+ "r_edges": False,
96
+ "r_energy": True,
97
+ "r_forces": True,
98
+ "r_stress": True,
99
+ },
100
+ }
101
+ )
102
+ if max_samples is not None:
103
+ sample_count = min(max_samples, len(dataset))
104
+ generator = torch.Generator().manual_seed(seed)
105
+ indices = torch.randperm(len(dataset), generator=generator)[:sample_count].tolist()
106
+ dataset = Subset(dataset, indices)
107
+ context = context or DistributedContext()
108
+ sampler = None
109
+ if context.enabled:
110
+ sampler = DistributedSampler(
111
+ dataset,
112
+ num_replicas=context.world_size,
113
+ rank=context.rank,
114
+ shuffle=train,
115
+ drop_last=False,
116
+ )
117
+ return DataLoader(
118
+ dataset,
119
+ batch_size=batch_size,
120
+ shuffle=sampler is None and train,
121
+ sampler=sampler,
122
+ num_workers=workers,
123
+ collate_fn=lambda items: data_list_collater(items, otf_graph=True),
124
+ )
125
+
126
+
127
+ def _loss(
128
+ pred,
129
+ batch,
130
+ energy_weight: float,
131
+ force_weight: float,
132
+ stress_weight: float,
133
+ transforms: EquiformerV3CheckpointTransforms,
134
+ ):
135
+ losses = {}
136
+ if energy_weight:
137
+ energy_target = transforms.normalize_target(
138
+ "energy", batch.energy, pred["energy"], batch
139
+ )
140
+ energy_error = pred["energy"] - energy_target
141
+ natoms_shape = (-1,) + (1,) * (energy_error.ndim - 1)
142
+ natoms = batch.natoms.to(energy_error).reshape(natoms_shape)
143
+ losses["energy"] = (energy_error / natoms).square().mean()
144
+ if force_weight:
145
+ force_target = transforms.normalize_target(
146
+ "forces", batch.forces, pred["forces"], batch
147
+ )
148
+ losses["forces"] = (pred["forces"] - force_target).square().mean()
149
+ if stress_weight:
150
+ if not hasattr(batch, "stress"):
151
+ raise ValueError("stress_weight is nonzero, but the batch has no stress labels")
152
+ if "stress" not in pred:
153
+ raise ValueError("stress_weight is nonzero, but the model returned no stress")
154
+ stress_target = transforms.normalize_target(
155
+ "stress", batch.stress, pred["stress"], batch
156
+ )
157
+ losses["stress"] = (pred["stress"] - stress_target).square().mean()
158
+ total = energy_weight * losses.get("energy", 0.0)
159
+ total = total + force_weight * losses.get("forces", 0.0)
160
+ total = total + stress_weight * losses.get("stress", 0.0)
161
+ return total, {key: float(value.detach()) for key, value in losses.items()}
162
+
163
+
164
+ def _run_epoch(
165
+ model,
166
+ loader,
167
+ device,
168
+ optimizer,
169
+ weights,
170
+ transforms: EquiformerV3CheckpointTransforms,
171
+ context: DistributedContext,
172
+ ):
173
+ training = optimizer is not None
174
+ model.train(training)
175
+ total = 0.0
176
+ batches = 0
177
+ metric_names = tuple(
178
+ name
179
+ for name, weight in zip(("energy", "forces", "stress"), weights)
180
+ if weight
181
+ )
182
+ metrics = {name: 0.0 for name in metric_names}
183
+ for batch in loader:
184
+ batch = batch.to(device)
185
+ if training:
186
+ optimizer.zero_grad(set_to_none=True)
187
+ prediction = model(batch)
188
+ loss, batch_metrics = _loss(prediction, batch, *weights, transforms)
189
+ if training:
190
+ loss.backward()
191
+ optimizer.step()
192
+ total += float(loss.detach())
193
+ batches += 1
194
+ for key, value in batch_metrics.items():
195
+ metrics[key] = metrics.get(key, 0.0) + value
196
+ if batches == 0:
197
+ raise RuntimeError("The dataset contains no samples.")
198
+ values = torch.tensor(
199
+ [total, *metrics.values(), float(batches)],
200
+ dtype=torch.float64,
201
+ device=device,
202
+ )
203
+ if context.enabled:
204
+ torch.distributed.all_reduce(values, op=torch.distributed.ReduceOp.SUM)
205
+ global_batches = values[-1].item()
206
+ return {
207
+ "loss": values[0].item() / global_batches,
208
+ **{
209
+ key: values[index].item() / global_batches
210
+ for index, key in enumerate(metrics, start=1)
211
+ },
212
+ }
213
+
214
+
215
+ def main() -> None:
216
+ parser = argparse.ArgumentParser(description=__doc__)
217
+ parser.add_argument("--config", help="YAML configuration path")
218
+ parser.add_argument("--checkpoint")
219
+ parser.add_argument("--train", help="ASE DB or ASE-LMDB training path")
220
+ parser.add_argument("--val", help="ASE DB or ASE-LMDB validation path")
221
+ parser.add_argument("--output")
222
+ parser.add_argument("--device")
223
+ parser.add_argument("--epochs", type=int)
224
+ parser.add_argument("--batch-size", type=int)
225
+ parser.add_argument("--workers", type=int)
226
+ parser.add_argument("--lr", type=float)
227
+ parser.add_argument("--energy-weight", type=float)
228
+ parser.add_argument("--force-weight", type=float)
229
+ parser.add_argument("--stress-weight", type=float)
230
+ parser.add_argument("--max-train-samples", type=int)
231
+ parser.add_argument("--max-val-samples", type=int)
232
+ parser.add_argument("--backend", help="torch.distributed backend for torchrun")
233
+ parser.add_argument("--seed", type=int)
234
+ parser.add_argument(
235
+ "--fit-element-references",
236
+ action=argparse.BooleanOptionalAction,
237
+ default=None,
238
+ help="fit energy element references on the training data",
239
+ )
240
+ args = parser.parse_args()
241
+
242
+ if not args.config:
243
+ parser.error("--config is required; use a YAML file from demo/configs")
244
+ config_path = args.config
245
+ with Path(config_path).expanduser().open() as handle:
246
+ config = yaml.safe_load(handle) or {}
247
+ for key, value in config.items():
248
+ if getattr(args, key.replace("-", "_"), None) is None:
249
+ setattr(args, key.replace("-", "_"), value)
250
+ for key in ("checkpoint", "train", "val", "output"):
251
+ value = getattr(args, key)
252
+ if value is not None:
253
+ if isinstance(value, list):
254
+ value = [
255
+ os.path.expandvars(os.path.expanduser(str(item)))
256
+ for item in value
257
+ ]
258
+ else:
259
+ value = os.path.expandvars(os.path.expanduser(str(value)))
260
+ setattr(args, key, value)
261
+ required = ("checkpoint", "train", "val", "output")
262
+ missing = [key for key in required if not getattr(args, key)]
263
+ if missing:
264
+ parser.error("missing required config fields: " + ", ".join(missing))
265
+ args.backend = args.backend or "nccl"
266
+ args.seed = 0 if args.seed is None else args.seed
267
+ args.fit_element_references = bool(args.fit_element_references)
268
+ if not any((args.energy_weight, args.force_weight, args.stress_weight)):
269
+ parser.error(
270
+ "at least one of energy_weight, force_weight, or stress_weight "
271
+ "must be nonzero"
272
+ )
273
+
274
+ if args.device.startswith("cuda") and not torch.cuda.is_available():
275
+ raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false.")
276
+ context = _init_distributed(args.device, args.backend)
277
+ try:
278
+ if args.device.startswith("cuda"):
279
+ device = torch.device(f"cuda:{context.local_rank}")
280
+ else:
281
+ device = torch.device(args.device)
282
+ torch.manual_seed(args.seed + context.rank)
283
+
284
+ model = load_equiformer_v3_checkpoint(args.checkpoint).to(device)
285
+ transforms = EquiformerV3CheckpointTransforms.from_checkpoint(
286
+ args.checkpoint
287
+ )
288
+ if args.fit_element_references:
289
+ reference_dataset = _loader(
290
+ args.train, args.batch_size, args.workers
291
+ ).dataset
292
+ fitted_references = fit_linear_references(
293
+ targets=["energy"],
294
+ dataset=reference_dataset,
295
+ batch_size=args.batch_size,
296
+ num_workers=args.workers,
297
+ log_metrics=False,
298
+ shuffle=False,
299
+ )
300
+ transforms.elementrefs["energy"] = fitted_references["energy"]
301
+ if context.is_main:
302
+ print("fitted energy element references from training data", flush=True)
303
+ transforms = transforms.to(device)
304
+ if context.enabled:
305
+ model = DistributedDataParallel(
306
+ model,
307
+ device_ids=[context.local_rank] if device.type == "cuda" else None,
308
+ output_device=context.local_rank if device.type == "cuda" else None,
309
+ )
310
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
311
+ train_loader = _loader(
312
+ args.train,
313
+ args.batch_size,
314
+ args.workers,
315
+ args.max_train_samples,
316
+ context=context,
317
+ train=True,
318
+ seed=args.seed,
319
+ )
320
+ val_loader = _loader(
321
+ args.val,
322
+ args.batch_size,
323
+ args.workers,
324
+ args.max_val_samples,
325
+ context=context,
326
+ train=False,
327
+ seed=args.seed + 1,
328
+ )
329
+ weights = (args.energy_weight, args.force_weight, args.stress_weight)
330
+
331
+ history = []
332
+ for epoch in range(args.epochs):
333
+ if isinstance(train_loader.sampler, DistributedSampler):
334
+ train_loader.sampler.set_epoch(epoch)
335
+ train_metrics = _run_epoch(
336
+ model, train_loader, device, optimizer, weights, transforms, context
337
+ )
338
+ # Force/stress outputs are gradients of the energy, so validation also
339
+ # needs autograd even though model parameters are not updated.
340
+ val_metrics = _run_epoch(
341
+ model, val_loader, device, None, weights, transforms, context
342
+ )
343
+ record = {"epoch": epoch, "train": train_metrics, "val": val_metrics}
344
+ if context.is_main:
345
+ history.append(record)
346
+ print(json.dumps(record, sort_keys=True), flush=True)
347
+
348
+ if context.is_main:
349
+ output = Path(args.output)
350
+ output.parent.mkdir(parents=True, exist_ok=True)
351
+ source = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
352
+ base_model = model.module if context.enabled else model
353
+ checkpoint = {
354
+ "config": source["config"],
355
+ "normalizers": source.get("normalizers", {}),
356
+ "state_dict": {
357
+ key: value.detach().cpu()
358
+ for key, value in base_model.state_dict().items()
359
+ },
360
+ "metadata": dict(source.get("metadata", {})),
361
+ }
362
+ checkpoint["elementrefs"] = {
363
+ name: {
364
+ key: value.detach().cpu()
365
+ for key, value in elementref.state_dict().items()
366
+ }
367
+ for name, elementref in transforms.elementrefs.items()
368
+ }
369
+ checkpoint["metadata"].update(
370
+ {
371
+ "onescience_equiformer_v3_history": history,
372
+ "source_checkpoint": args.checkpoint,
373
+ "world_size": context.world_size,
374
+ "loss_space": "checkpoint_normalized",
375
+ "element_references": (
376
+ "fitted_from_training_data"
377
+ if args.fit_element_references
378
+ else "source_checkpoint"
379
+ ),
380
+ }
381
+ )
382
+ del source
383
+ torch.save(checkpoint, output)
384
+ print(f"saved checkpoint: {output}", flush=True)
385
+ finally:
386
+ _close_distributed(context)
387
+
388
+
389
+ if __name__ == "__main__":
390
+ from train import main as training_main
391
+
392
+ training_main()
fit_element_references.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Fit OC20 energy element references before starting model training."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import yaml
11
+
12
+ from onescience.utils.uma.normalization.element_references import (
13
+ fit_linear_references,
14
+ )
15
+
16
+ from train import _expand_path, _loader
17
+
18
+
19
+ def fit_references(
20
+ config_path: str | Path,
21
+ output_path: str | Path,
22
+ batch_size: int = 32,
23
+ num_batches: int | None = None,
24
+ ) -> Path:
25
+ """Fit and save legacy ``coeff`` references consumed by the OC20 YAML."""
26
+
27
+ if batch_size < 2:
28
+ raise ValueError("reference fitting batch_size must be at least 2")
29
+ if num_batches is not None and num_batches < 1:
30
+ raise ValueError("num_batches must be positive")
31
+
32
+ config_path = Path(config_path).expanduser().resolve()
33
+ with config_path.open(encoding="utf-8") as handle:
34
+ config = yaml.safe_load(handle) or {}
35
+
36
+ train_path = _expand_path(config.get("train"))
37
+ if not train_path:
38
+ raise ValueError(f"training data is not configured in {config_path}")
39
+
40
+ loader = _loader(
41
+ train_path,
42
+ batch_size=batch_size,
43
+ workers=int(config.get("workers", 0)),
44
+ max_samples=config.get("max_train_samples"),
45
+ train=False,
46
+ seed=int(config.get("seed", 0)),
47
+ max_atoms=config.get("max_atoms"),
48
+ load_balancing=False,
49
+ )
50
+ references = fit_linear_references(
51
+ targets=["energy"],
52
+ dataset=loader.dataset,
53
+ batch_size=batch_size,
54
+ num_batches=num_batches,
55
+ num_workers=int(config.get("workers", 0)),
56
+ shuffle=False,
57
+ )
58
+
59
+ output_path = Path(output_path).expanduser().resolve()
60
+ if output_path.suffix != ".npz":
61
+ raise ValueError("output path must use the .npz extension")
62
+ output_path.parent.mkdir(parents=True, exist_ok=True)
63
+ coefficients = (
64
+ references["energy"].element_references.detach().cpu().numpy()
65
+ )
66
+ np.savez(output_path, coeff=coefficients)
67
+ print(f"Saved energy element references to {output_path}")
68
+ return output_path
69
+
70
+
71
+ def _parse_args() -> argparse.Namespace:
72
+ parser = argparse.ArgumentParser(description=__doc__)
73
+ parser.add_argument("--config", required=True, help="OC20 training YAML")
74
+ parser.add_argument("--output", required=True, help="output .npz path")
75
+ parser.add_argument("--batch-size", type=int, default=32)
76
+ parser.add_argument("--num-batches", type=int)
77
+ return parser.parse_args()
78
+
79
+
80
+ def main() -> None:
81
+ args = _parse_args()
82
+ fit_references(
83
+ args.config,
84
+ args.output,
85
+ batch_size=args.batch_size,
86
+ num_batches=args.num_batches,
87
+ )
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
formation_energy.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calculate an uncorrected formation energy with Equiformer V3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ os.environ.setdefault(
10
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
11
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
12
+ )
13
+
14
+ from ase.build import bulk, molecule
15
+ from ase.io import read
16
+
17
+ from onescience.utils.equiformer_v3 import (
18
+ EquiformerV3Calculator,
19
+ calculate_element_reference_energies,
20
+ calculate_formation_energy,
21
+ load_element_reference_energies,
22
+ relax_structure,
23
+ write_workflow_result,
24
+ )
25
+
26
+
27
+ def default_checkpoint() -> str:
28
+ return str(
29
+ Path(__file__).resolve().parent
30
+ / "weight"
31
+ / "omat24-mptrj-salex_gradient.pt"
32
+ )
33
+
34
+
35
+ def load_compound(path: str | None):
36
+ if path:
37
+ return read(path)
38
+ return bulk("MgO", "rocksalt", a=4.21)
39
+
40
+
41
+ def model_reference_structures():
42
+ oxygen = molecule("O2")
43
+ oxygen.center(vacuum=8.0)
44
+ oxygen.pbc = True
45
+ return {
46
+ "Mg": bulk("Mg", "hcp", a=3.21, c=5.21),
47
+ "O": oxygen,
48
+ }
49
+
50
+
51
+ def main() -> None:
52
+ parser = argparse.ArgumentParser(description=__doc__)
53
+ parser.add_argument("--checkpoint")
54
+ parser.add_argument(
55
+ "--input",
56
+ help="compound structure readable by ASE; defaults to built-in MgO",
57
+ )
58
+ parser.add_argument(
59
+ "--reference-energies",
60
+ help="JSON/YAML mapping of element to trusted reference energy in eV/atom",
61
+ )
62
+ parser.add_argument(
63
+ "--relax",
64
+ action="store_true",
65
+ help="relax the compound and model-evaluated reference phases",
66
+ )
67
+ parser.add_argument("--fmax", type=float, default=0.03)
68
+ parser.add_argument("--steps", type=int, default=100)
69
+ parser.add_argument("--device", default="cuda")
70
+ parser.add_argument("--output", default="outputs/formation_energy.json")
71
+ args = parser.parse_args()
72
+
73
+ checkpoint = args.checkpoint or default_checkpoint()
74
+ calculator = EquiformerV3Calculator.from_checkpoint(
75
+ checkpoint, device=args.device
76
+ )
77
+ compound = load_compound(args.input)
78
+ compound_relaxation = None
79
+ if args.relax:
80
+ compound, compound_relaxation = relax_structure(
81
+ compound,
82
+ calculator,
83
+ relax_cell=bool(compound.pbc.all()),
84
+ fmax=args.fmax,
85
+ steps=args.steps,
86
+ )
87
+
88
+ reference_relaxations = {}
89
+ if args.reference_energies:
90
+ reference_energies = load_element_reference_energies(
91
+ args.reference_energies
92
+ )
93
+ reference_source = str(Path(args.reference_energies))
94
+ else:
95
+ reference_structures = model_reference_structures()
96
+ if args.relax:
97
+ relaxed_references = {}
98
+ for element, atoms in reference_structures.items():
99
+ relaxed, metadata = relax_structure(
100
+ atoms,
101
+ calculator,
102
+ relax_cell=element != "O",
103
+ fmax=args.fmax,
104
+ steps=args.steps,
105
+ )
106
+ relaxed_references[element] = relaxed
107
+ reference_relaxations[element] = metadata
108
+ reference_structures = relaxed_references
109
+ reference_energies = calculate_element_reference_energies(
110
+ reference_structures, calculator
111
+ )
112
+ reference_source = "Equiformer V3 evaluation of Mg(hcp) and O2"
113
+
114
+ result = calculate_formation_energy(
115
+ compound,
116
+ calculator,
117
+ reference_energies,
118
+ )
119
+ result["reference_source"] = reference_source
120
+ result["checkpoint"] = str(checkpoint)
121
+ result["compound_relaxation"] = compound_relaxation
122
+ result["reference_relaxations"] = reference_relaxations
123
+ output = write_workflow_result(result, args.output)
124
+
125
+ print("formula:", result["formula"])
126
+ print("formation energy (eV/atom):", result["formation_energy_ev_per_atom"])
127
+ print("corrections applied:", result["corrections_applied"])
128
+ print("result:", output)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
model/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Equiformer V3 atomistic models adapted to OneScience."""
2
+
3
+ from .equiformer_v3 import EquiformerV3_OC
4
+ from .equiformer_v3_dens import EquiformerV3DeNS_OC
5
+
6
+ EquiformerV3 = EquiformerV3_OC
7
+ EquiformerV3DeNS = EquiformerV3DeNS_OC
8
+
9
+ __all__ = [
10
+ "EquiformerV3",
11
+ "EquiformerV3DeNS",
12
+ "EquiformerV3DeNS_OC",
13
+ "EquiformerV3_OC",
14
+ ]
model/equiformer_v3.py ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Equiformer V3 model adapted to the OneScience FairChem runtime."""
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch.utils.checkpoint import checkpoint
7
+
8
+ from onescience.modules.func_utils.uma_graph.mixin import GraphModelMixin
9
+ from onescience.modules.layer.equiformer_v3.edge_rot_mat import init_edge_rot_mat
10
+ from onescience.modules.layer.equiformer_v3.envelope import PolynomialEnvelope
11
+ from onescience.modules.layer.equiformer_v3.input_block import EdgeDegreeEmbedding
12
+ from onescience.modules.layer.equiformer_v3.layer_norm import (
13
+ EquivariantLayerNorm,
14
+ EquivariantMergeLayerNorm,
15
+ EquivariantSeparableLayerNorm,
16
+ RMSNorm,
17
+ get_normalization_layer,
18
+ )
19
+ from onescience.modules.layer.equiformer_v3.output_block import (
20
+ EquivariantGraphAttentionStressHead,
21
+ FeedForwardNetworkStressHead,
22
+ ScalarFeedForwardNetwork,
23
+ )
24
+ from onescience.modules.layer.equiformer_v3.radial_function import (
25
+ GaussianSmearing,
26
+ RadialFunction,
27
+ )
28
+ from onescience.modules.layer.equiformer_v3.so3 import (
29
+ SO3Rotation,
30
+ SO3Linear,
31
+ )
32
+ from onescience.modules.layer.equiformer_v3.transformer_block import (
33
+ EquivariantGraphAttention,
34
+ FeedForwardNetwork,
35
+ TransBlockV3,
36
+ )
37
+ from onescience.modules.layer.equiformer_v3.wigner import configure_jd_path
38
+ from onescience.utils.uma.common.registry import registry
39
+ from onescience.utils.uma.common.utils import conditional_grad
40
+
41
+
42
+ # Statistics of IS2RE 100K
43
+ _AVG_NUM_NODES = 77.81317
44
+ _AVG_DEGREE = 23.395238876342773 # IS2RE: 100k, max_radius = 5, max_neighbors = 100
45
+
46
+ _NORM_SCALE_NODES = math.sqrt(_AVG_NUM_NODES) # 8.82117735906041
47
+ _NORM_SCALE_DEGREE = math.sqrt(_AVG_DEGREE) # 4.836862503353054
48
+
49
+
50
+ @registry.register_model("equiformer_v3")
51
+ class EquiformerV3_OC(torch.nn.Module, GraphModelMixin):
52
+ """
53
+ Args:
54
+ use_pbc (bool): Use periodic boundary conditions
55
+ use_pbc_single (bool, optional): Process batch PBC graphs one at a time.
56
+ This argument is set to True when training on MPTraj.
57
+ otf_graph (bool): Compute graph On The Fly (OTF)
58
+
59
+ regress_forces (bool): Compute forces
60
+ regress_stress (bool): Compute stress
61
+ direct_prediction (bool): Whether to use direct methods to predict forces and stress
62
+
63
+ max_neighbors (int): Maximum number of neighbors per atom
64
+ max_radius (float): Maximum distance between nieghboring atoms in Angstroms
65
+ num_radial_basis (int): Number of radial basis functions
66
+ max_num_elements (int): Maximum atomic number
67
+
68
+ num_layers (int): Number of layers in the GNN
69
+ num_channels (int): Number of channels in node embeddings
70
+ attn_hidden_channels (int): Number of hidden channels in equivariant graph attention
71
+ num_heads (int): Number of attention heads
72
+ attn_alpha_channels (int): Number of channels for alpha vector in each attention head
73
+ attn_value_channels (int): Number of channels for value vector in each attention head
74
+ ffn_hidden_channels (int): Number of hidden channels in feedforward network
75
+ norm_type (str): Type of normalization layer
76
+ (['sep_layer_norm', 'merge_layer_norm',
77
+ 'merge_layer_norm_attn_rms_norm', 'merge_rms_norm'])
78
+
79
+ lmax (int): Maximum degrees (l)
80
+ mmax (int): Maximum order (m)
81
+ attn_grid_resolution_list (list:int):
82
+ Grid resolution list in class `SO3Grid` in attention
83
+ ffn_grid_resolution_list (list:int):
84
+ Grid resolution list in class `SO3Grid` in feedforward network
85
+
86
+ edge_channels (int): Number of channels for edge-wise invariant features
87
+ use_atom_edge_embedding (bool): Whether to use atomic embedding along with relative distance for edge scalar features
88
+ use_envelope (bool): Whether to apply an envelope function to attention
89
+
90
+ attn_activation (str): Type of activation function in equivariant graph attention
91
+ use_attn_renorm (bool): Whether to re-normalize attention weights
92
+ use_add_merge (bool): Default: False
93
+ If True, use addition to merge the source/target node features instead of concat,
94
+ which can save 2x compute when rotating with Wigner-D matrices.
95
+ use_rad_l_parametrization (bool):
96
+ Default: True
97
+ If True, all the m components within the same type-L vector will share the same
98
+ weight from the radial function.
99
+ softcap (float): Default: None
100
+ If not None, use soft capping to limit the range of attention logits to
101
+ [- `softcap`, + `softcap`].
102
+ attn_eps (float): Default: 1e-16
103
+ Epsilon value used in the softmax operation of attention
104
+ ffn_activation (str): Type of activation function for feedforward network
105
+ use_grid_mlp (bool): If `True`, use projecting to grids and performing MLPs for FFNs.
106
+
107
+ use_gate_force_head (bool): If `True`, use `GateActivation` in the equivariant attention of the force prediction head.
108
+
109
+ alpha_drop (float): Dropout rate for the hidden features in non-linear MLP attention
110
+ attn_mask_rate (float): Mask rate for neighbors considered in attention
111
+ attn_weights_drop (float): Dropout rate for attention weights
112
+ value_drop (float): Dropout rate for the hidden features in non-linear value vectors
113
+ drop_path_rate (float): Drop path rate
114
+ proj_drop (float): Dropout rate for outputs of attention and FFN in Transformer blocks
115
+ ffn_drop (float): Dropout rate for the hidden features in FFN
116
+ use_head_reg (bool): Whether to apply regularization to output head (dummy argument for backend compatibility)
117
+
118
+ gradient_checkpointing_block_list (list):
119
+ A list indicating which block we apply gradient/activation checkpointing to save memory.
120
+
121
+ avg_num_nodes (float): Normalization factor for sum aggregation over nodes
122
+ avg_degree (float): Normalization factor for sum aggregation over edges
123
+
124
+ enforce_max_neighbors_strictly (bool): When edges are subselected based on the `max_neighbors` arg, arbitrarily select amongst equidistant / degenerate edges to have exactly the correct number.
125
+ """
126
+ def __init__(
127
+ self,
128
+
129
+ use_pbc=True,
130
+ use_pbc_single=False,
131
+ otf_graph=True,
132
+
133
+ regress_forces=True,
134
+ regress_stress=False,
135
+ direct_prediction=True,
136
+
137
+ max_neighbors=20,
138
+ max_radius=12.0,
139
+ num_radial_basis=600,
140
+ max_num_elements=128,
141
+
142
+ num_layers=12,
143
+ num_channels=128,
144
+ attn_hidden_channels=64,
145
+ num_heads=8,
146
+ attn_alpha_channels=32,
147
+ attn_value_channels=16,
148
+ ffn_hidden_channels=128,
149
+ norm_type='merge_layer_norm',
150
+
151
+ lmax=6,
152
+ mmax=2,
153
+ attn_grid_resolution_list=[20, 8],
154
+ ffn_grid_resolution_list=[20, 20],
155
+
156
+ edge_channels=128,
157
+ use_atom_edge_embedding=True,
158
+ use_envelope=True,
159
+
160
+ attn_activation='sep-merge_gates2_swiglu',
161
+ use_attn_renorm=True,
162
+ use_add_merge=False,
163
+ use_rad_l_parametrization=True,
164
+ softcap=None,
165
+ attn_eps=1e-16,
166
+ ffn_activation='sep-merge_gates2_swiglu',
167
+ use_grid_mlp=True,
168
+
169
+ use_gate_force_head=True,
170
+
171
+ alpha_drop=0.0,
172
+ attn_mask_rate=0.0,
173
+ attn_weights_drop=0.1,
174
+ value_drop=0.0,
175
+ drop_path_rate=0.05,
176
+ proj_drop=0.0,
177
+ ffn_drop=0.0,
178
+ use_head_reg=False,
179
+
180
+ gradient_checkpointing_block_list=None,
181
+
182
+ avg_num_nodes=_AVG_NUM_NODES,
183
+ avg_degree=_AVG_DEGREE,
184
+
185
+ enforce_max_neighbors_strictly=True,
186
+ jd_path=None,
187
+ ):
188
+ super().__init__()
189
+
190
+ configure_jd_path(jd_path)
191
+
192
+ self.use_pbc = use_pbc
193
+ self.use_pbc_single = use_pbc_single
194
+ self.otf_graph = otf_graph
195
+
196
+ self.regress_forces = regress_forces
197
+ self.regress_stress = regress_stress
198
+ self.direct_prediction = direct_prediction
199
+ self.direct_forces = direct_prediction
200
+
201
+ self.max_neighbors = max_neighbors
202
+ self.max_radius = max_radius
203
+ self.cutoff = max_radius
204
+ self.num_radial_basis = num_radial_basis
205
+ self.max_num_elements = max_num_elements
206
+
207
+ self.num_layers = num_layers
208
+ self.num_channels = num_channels
209
+ self.attn_hidden_channels = attn_hidden_channels
210
+ self.num_heads = num_heads
211
+ self.attn_alpha_channels = attn_alpha_channels
212
+ self.attn_value_channels = attn_value_channels
213
+ self.ffn_hidden_channels = ffn_hidden_channels
214
+ self.norm_type = norm_type
215
+
216
+ self.lmax = lmax
217
+ self.mmax = mmax
218
+ self.attn_grid_resolution_list = attn_grid_resolution_list
219
+ self.ffn_grid_resolution_list = ffn_grid_resolution_list
220
+
221
+ self.edge_channels = edge_channels
222
+ self.use_atom_edge_embedding = use_atom_edge_embedding
223
+ self.use_envelope = use_envelope
224
+
225
+ self.attn_activation = attn_activation
226
+ self.use_attn_renorm = use_attn_renorm
227
+ self.use_add_merge = use_add_merge
228
+ self.use_rad_l_parametrization = use_rad_l_parametrization
229
+ self.softcap = softcap
230
+ self.attn_eps = attn_eps
231
+ self.ffn_activation = ffn_activation
232
+ self.use_grid_mlp = use_grid_mlp
233
+
234
+ self.use_gate_force_head = use_gate_force_head
235
+
236
+ self.alpha_drop = alpha_drop
237
+ self.attn_mask_rate = attn_mask_rate
238
+ self.attn_weights_drop = attn_weights_drop
239
+ self.value_drop = value_drop
240
+ self.drop_path_rate = drop_path_rate
241
+ self.proj_drop = proj_drop
242
+ self.ffn_drop = ffn_drop
243
+ self.use_head_reg = use_head_reg
244
+
245
+ self.gradient_checkpointing_block_list = gradient_checkpointing_block_list
246
+ if self.gradient_checkpointing_block_list is not None:
247
+ assert len(self.gradient_checkpointing_block_list) == self.num_layers
248
+ else:
249
+ self.gradient_checkpointing_block_list = [0] * self.num_layers
250
+
251
+ self.avg_num_nodes = avg_num_nodes
252
+ self.avg_degree = avg_degree
253
+
254
+ self.enforce_max_neighbors_strictly = enforce_max_neighbors_strictly
255
+
256
+ # Atom-type embedding
257
+ self.sphere_embedding = torch.nn.Embedding(self.max_num_elements, self.num_channels)
258
+
259
+ # Radial basis function
260
+ self.distance_expansion = GaussianSmearing(
261
+ 0.0,
262
+ self.cutoff,
263
+ self.num_radial_basis,
264
+ 2.0,
265
+ )
266
+ edge_input_channels = int(self.distance_expansion.num_output)
267
+
268
+ # The sizes of radial functions (input channels and 2 hidden channels)
269
+ self.edge_channels_list = [edge_input_channels] + [self.edge_channels] * 2
270
+
271
+ # Envelope function
272
+ self.envelope_func = PolynomialEnvelope(
273
+ cutoff=self.cutoff,
274
+ exponent=5
275
+ ) if self.use_envelope else None
276
+
277
+ # Computing Wigner-D matrices
278
+ self.so3_rotation = SO3Rotation(self.lmax, self.mmax, use_rotation_mask=(not self.direct_prediction))
279
+
280
+ # Edge-degree embedding
281
+ self.edge_degree_embedding = EdgeDegreeEmbedding(
282
+ num_channels=self.num_channels,
283
+ lmax=self.lmax,
284
+ mmax=self.mmax,
285
+ so3_rotation=self.so3_rotation,
286
+ max_num_elements=self.max_num_elements,
287
+ edge_channels_list=self.edge_channels_list,
288
+ use_atom_edge_embedding=self.use_atom_edge_embedding,
289
+ rescale_factor=self.avg_degree
290
+ )
291
+
292
+ # Transformer block
293
+ self.blocks = torch.nn.ModuleList()
294
+ for i in range(self.num_layers):
295
+ if self.gradient_checkpointing_block_list[i] == 1:
296
+ attn_activation = self.attn_activation.replace('_mem', '')
297
+ ffn_activation = self.ffn_activation.replace('_mem', '')
298
+ else:
299
+ attn_activation = self.attn_activation
300
+ ffn_activation = self.ffn_activation
301
+ block_config_dict = dict(
302
+ num_in_channels=self.num_channels,
303
+ attn_hidden_channels=self.attn_hidden_channels,
304
+ num_heads=self.num_heads,
305
+ attn_alpha_channels=self.attn_alpha_channels,
306
+ attn_value_channels=self.attn_value_channels,
307
+ ffn_hidden_channels=self.ffn_hidden_channels,
308
+ num_out_channels=self.num_channels,
309
+ lmax=self.lmax,
310
+ mmax=self.mmax,
311
+ so3_rotation=self.so3_rotation,
312
+ attn_grid_resolution_list=self.attn_grid_resolution_list,
313
+ ffn_grid_resolution_list=self.ffn_grid_resolution_list,
314
+ max_num_elements=self.max_num_elements,
315
+ edge_channels_list=self.edge_channels_list,
316
+ use_atom_edge_embedding=self.use_atom_edge_embedding,
317
+ attn_activation=attn_activation,
318
+ use_attn_renorm=self.use_attn_renorm,
319
+ use_add_merge=self.use_add_merge,
320
+ use_rad_l_parametrization=self.use_rad_l_parametrization,
321
+ softcap=self.softcap,
322
+ attn_eps=self.attn_eps,
323
+ ffn_activation=ffn_activation,
324
+ use_grid_mlp=self.use_grid_mlp,
325
+ norm_type=self.norm_type,
326
+ alpha_drop=self.alpha_drop,
327
+ attn_mask_rate=self.attn_mask_rate,
328
+ attn_weights_drop=attn_weights_drop,
329
+ value_drop=self.value_drop,
330
+ drop_path_rate=self.drop_path_rate,
331
+ proj_drop=self.proj_drop,
332
+ ffn_drop=self.ffn_drop
333
+ )
334
+ block_class = TransBlockV3
335
+ self.blocks.append(block_class(**block_config_dict))
336
+
337
+ # Output blocks for energy and forces (and optionally stress)
338
+ self.norm = get_normalization_layer(
339
+ self.norm_type,
340
+ lmax=self.lmax,
341
+ num_channels=self.num_channels
342
+ )
343
+ self.energy_block = ScalarFeedForwardNetwork(
344
+ num_in_channels=self.num_channels,
345
+ num_hidden_channels=self.ffn_hidden_channels,
346
+ num_out_channels=1,
347
+ dropout=0.0
348
+ )
349
+ if self.direct_prediction:
350
+ if self.regress_forces:
351
+ self.force_block = EquivariantGraphAttention(
352
+ num_in_channels=self.num_channels,
353
+ num_hidden_channels=self.attn_hidden_channels,
354
+ num_heads=self.num_heads,
355
+ attn_alpha_channels=self.attn_alpha_channels,
356
+ attn_value_channels=self.attn_value_channels,
357
+ num_out_channels=1,
358
+ lmax=self.lmax,
359
+ mmax=self.mmax,
360
+ so3_rotation=self.so3_rotation,
361
+ grid_resolution_list=self.attn_grid_resolution_list,
362
+ max_num_elements=self.max_num_elements,
363
+ edge_channels_list=self.edge_channels_list,
364
+ use_atom_edge_embedding=self.use_atom_edge_embedding,
365
+ activation=('sep_s2' if not self.use_gate_force_head else 'gate'),
366
+ use_attn_renorm=self.use_attn_renorm,
367
+ use_add_merge=self.use_add_merge,
368
+ use_rad_l_parametrization=self.use_rad_l_parametrization,
369
+ softcap=self.softcap,
370
+ eps=self.attn_eps,
371
+ alpha_drop=0.0,
372
+ attn_mask_rate=0.0,
373
+ attn_weights_drop=0.0,
374
+ value_drop=0.0
375
+ )
376
+ if 'rms_norm' in norm_type:
377
+ if self.force_block.alpha_norm is not None:
378
+ del self.force_block.alpha_norm
379
+ self.force_block.alpha_norm = RMSNorm(self.attn_alpha_channels)
380
+ if self.regress_stress:
381
+ self.stress_block = FeedForwardNetworkStressHead(
382
+ num_in_channels=self.num_channels,
383
+ num_hidden_channels=self.ffn_hidden_channels,
384
+ num_out_channels=1,
385
+ lmax=self.lmax,
386
+ mmax=self.mmax,
387
+ grid_resolution_list=self.ffn_grid_resolution_list,
388
+ activation='gate',
389
+ use_grid_mlp=False,
390
+ dropout=0.0,
391
+ )
392
+
393
+ self.apply(self._init_weights)
394
+
395
+
396
+ def _forward_edge(
397
+ self,
398
+ edge_distance,
399
+ edge_distance_vec
400
+ ):
401
+ # Compute 3x3 rotation matrix per edge
402
+ edge_rot_mat = self._init_edge_rot_mat(edge_distance_vec)
403
+
404
+ # Compute Wigner-D matrices
405
+ self.so3_rotation.set_wigner(edge_rot_mat)
406
+
407
+ # Envelope function
408
+ edge_envelope_weight = self.envelope_func(edge_distance) if self.envelope_func is not None else None
409
+
410
+ # Radial basis function
411
+ edge_distance = self.distance_expansion(edge_distance)
412
+
413
+ return edge_distance, edge_envelope_weight
414
+
415
+
416
+ def _forward_embedding(
417
+ self,
418
+ atomic_numbers,
419
+ edge_distance,
420
+ edge_index,
421
+ edge_envelope_weight
422
+ ):
423
+ num_atoms = len(atomic_numbers)
424
+
425
+ # Initialize node embedding
426
+ x = torch.zeros(
427
+ (
428
+ num_atoms,
429
+ ((self.lmax + 1) ** 2),
430
+ self.num_channels
431
+ ),
432
+ device=self.device,
433
+ dtype=self.dtype
434
+ )
435
+
436
+ # Atom-type embedding
437
+ atom_embedding = self.sphere_embedding(atomic_numbers)
438
+ x[:, 0, :] = atom_embedding
439
+
440
+ # Edge-degree embedding
441
+ edge_degree = self.edge_degree_embedding(
442
+ atomic_numbers,
443
+ edge_distance,
444
+ edge_index,
445
+ edge_envelope_weight
446
+ )
447
+ x = x + edge_degree
448
+
449
+ return x
450
+
451
+
452
+ def _forward_blocks(
453
+ self,
454
+ x,
455
+ source_atomic_numbers,
456
+ target_atomic_numbers,
457
+ edge_distance,
458
+ edge_index,
459
+ edge_envelope_weight,
460
+ batch
461
+ ):
462
+ # Transformer blocks
463
+ for i in range(self.num_layers):
464
+ if self.gradient_checkpointing_block_list[i] == 0:
465
+ x = self.blocks[i](
466
+ x,
467
+ source_atomic_numbers,
468
+ target_atomic_numbers,
469
+ edge_distance,
470
+ edge_index,
471
+ edge_envelope_weight,
472
+ batch, # for GraphDropPath
473
+ )
474
+ elif self.gradient_checkpointing_block_list[i] == 1:
475
+ x = checkpoint(
476
+ self.blocks[i],
477
+ x,
478
+ source_atomic_numbers,
479
+ target_atomic_numbers,
480
+ edge_distance,
481
+ edge_index,
482
+ edge_envelope_weight,
483
+ batch, # for GraphDropPath
484
+ use_reentrant=False
485
+ )
486
+ else:
487
+ raise ValueError
488
+
489
+ # Final layer norm
490
+ x = self.norm(x)
491
+ x_scalar = x.narrow(1, 0, 1)
492
+ x_scalar = x_scalar.view(x_scalar.shape[0], self.num_channels)
493
+ return x_scalar, x
494
+
495
+
496
+ def _forward_direct(self, data):
497
+ self.batch_size = len(data.natoms)
498
+ self.dtype = data.pos.dtype
499
+ self.device = data.pos.device
500
+
501
+ (
502
+ edge_index,
503
+ edge_distance,
504
+ edge_distance_vec,
505
+ cell_offsets,
506
+ _, # cell offset distances
507
+ neighbors,
508
+ ) = self.generate_graph(
509
+ data,
510
+ enforce_max_neighbors_strictly=self.enforce_max_neighbors_strictly,
511
+ use_pbc_single=self.use_pbc_single
512
+ )
513
+
514
+ atomic_numbers = data.atomic_numbers.long()
515
+ source_atomic_numbers = atomic_numbers[edge_index[0]]
516
+ target_atomic_numbers = atomic_numbers[edge_index[1]]
517
+
518
+ edge_distance, edge_envelope_weight = self._forward_edge(edge_distance, edge_distance_vec)
519
+ x = self._forward_embedding(atomic_numbers, edge_distance, edge_index, edge_envelope_weight)
520
+ x_scalar, x = self._forward_blocks(
521
+ x,
522
+ source_atomic_numbers,
523
+ target_atomic_numbers,
524
+ edge_distance,
525
+ edge_index,
526
+ edge_envelope_weight,
527
+ data.batch
528
+ )
529
+
530
+ outputs = {}
531
+
532
+ # Energy prediction
533
+ node_energy = self.energy_block(x_scalar)
534
+ energy = torch.zeros(self.batch_size, device=node_energy.device, dtype=node_energy.dtype)
535
+ energy.index_add_(0, data.batch, node_energy.view(-1))
536
+ energy = energy / self.avg_num_nodes
537
+ outputs['energy'] = energy
538
+
539
+ # Force prediction
540
+ if self.regress_forces:
541
+ forces = self.force_block(
542
+ x,
543
+ source_atomic_numbers,
544
+ target_atomic_numbers,
545
+ edge_distance,
546
+ edge_index,
547
+ edge_envelope_weight
548
+ )
549
+ forces = forces.narrow(1, 1, 3)
550
+ forces = forces.view(-1, 3)
551
+ outputs['forces'] = forces
552
+
553
+ # Stress Prediction
554
+ if self.regress_stress:
555
+ stress = self.stress_block(
556
+ x,
557
+ batch_size=self.batch_size,
558
+ batch=data.batch
559
+ )
560
+ outputs['stress'] = stress
561
+
562
+ return outputs
563
+
564
+
565
+ @conditional_grad(torch.enable_grad())
566
+ def _forward_gradient(self, data):
567
+ """
568
+ 1. We have additional `@conditional_grad` as the decorator since the decorator might not be compatible with
569
+ `torch.compile()` in direct methods.
570
+ """
571
+ self.batch_size = len(data.natoms)
572
+ self.dtype = data.pos.dtype
573
+ self.device = data.pos.device
574
+
575
+ """
576
+ For gradient methods
577
+ """
578
+ displacement = None
579
+ orig_cell = None
580
+ if self.regress_stress and self.regress_forces:
581
+ displacement = torch.zeros(
582
+ (3, 3),
583
+ dtype=self.dtype,
584
+ device=self.device,
585
+ )
586
+ displacement = displacement.view(-1, 3, 3).expand(self.batch_size, 3, 3)
587
+ displacement.requires_grad = True
588
+ symmetric_displacement = 0.5 * (
589
+ displacement + displacement.transpose(-1, -2)
590
+ )
591
+
592
+ data.pos.requires_grad = True
593
+ data.pos = data.pos + torch.bmm(
594
+ data.pos.unsqueeze(-2),
595
+ torch.index_select(symmetric_displacement, 0, data.batch)
596
+ ).squeeze(-2)
597
+
598
+ orig_cell = data.cell
599
+ data.cell = data.cell + torch.bmm(
600
+ data.cell, symmetric_displacement
601
+ )
602
+ elif not self.regress_stress and self.regress_forces:
603
+ data.pos.requires_grad = True
604
+
605
+ (
606
+ edge_index,
607
+ edge_distance,
608
+ edge_distance_vec,
609
+ cell_offsets,
610
+ _, # cell offset distances
611
+ neighbors,
612
+ ) = self.generate_graph(
613
+ data,
614
+ enforce_max_neighbors_strictly=self.enforce_max_neighbors_strictly,
615
+ use_pbc_single=self.use_pbc_single
616
+ )
617
+
618
+ atomic_numbers = data.atomic_numbers.long()
619
+ source_atomic_numbers = atomic_numbers[edge_index[0]]
620
+ target_atomic_numbers = atomic_numbers[edge_index[1]]
621
+
622
+ edge_distance, edge_envelope_weight = self._forward_edge(edge_distance, edge_distance_vec)
623
+ x = self._forward_embedding(atomic_numbers, edge_distance, edge_index, edge_envelope_weight)
624
+ x_scalar, x = self._forward_blocks(
625
+ x,
626
+ source_atomic_numbers,
627
+ target_atomic_numbers,
628
+ edge_distance,
629
+ edge_index,
630
+ edge_envelope_weight,
631
+ data.batch
632
+ )
633
+
634
+ outputs = {}
635
+
636
+ # Energy prediction
637
+ node_energy = self.energy_block(x_scalar)
638
+ energy = torch.zeros(self.batch_size, device=node_energy.device, dtype=node_energy.dtype)
639
+ energy.index_add_(0, data.batch, node_energy.view(-1))
640
+ energy = energy / self.avg_num_nodes
641
+ outputs['energy'] = energy
642
+
643
+ if self.regress_stress and self.regress_forces:
644
+ # Stress and forces prediction
645
+ grads = torch.autograd.grad(
646
+ [energy.sum()],
647
+ [data.pos, displacement],
648
+ create_graph=self.training,
649
+ )
650
+ forces = torch.neg(grads[0])
651
+ virial = grads[1].view(-1, 3, 3)
652
+ volume = torch.det(data.cell).abs().unsqueeze(-1)
653
+ stress = virial / volume.view(-1, 1, 1)
654
+ virial = torch.neg(virial)
655
+ outputs['forces'] = forces
656
+ outputs['stress'] = stress.view(-1, 9)
657
+ data.cell = orig_cell
658
+
659
+ elif not self.regress_stress and self.regress_forces:
660
+ forces = (
661
+ -1
662
+ * torch.autograd.grad(
663
+ energy.sum(), data.pos, create_graph=self.training
664
+ )[0]
665
+ )
666
+ outputs['forces'] = forces
667
+
668
+ return outputs
669
+
670
+
671
+ def forward(self, data):
672
+ if self.direct_prediction:
673
+ outputs = self._forward_direct(data)
674
+ else:
675
+ outputs = self._forward_gradient(data)
676
+ return outputs
677
+
678
+
679
+ # Initialize the edge rotation matrics
680
+ def _init_edge_rot_mat(self, edge_distance_vec):
681
+ return init_edge_rot_mat(edge_distance_vec, use_rotation_mask=(not self.direct_prediction))
682
+
683
+
684
+ @property
685
+ def num_params(self):
686
+ return sum(p.numel() for p in self.parameters())
687
+
688
+
689
+ def _init_weights(self, m):
690
+ if (isinstance(m, torch.nn.Linear)
691
+ or isinstance(m, SO3Linear)
692
+ ):
693
+ if m.bias is not None:
694
+ torch.nn.init.constant_(m.bias, 0)
695
+ elif isinstance(m, torch.nn.LayerNorm):
696
+ torch.nn.init.constant_(m.bias, 0)
697
+ torch.nn.init.constant_(m.weight, 1.0)
698
+ elif (isinstance(m, RadialFunction)):
699
+ m.apply(self._uniform_init_linear_weights)
700
+
701
+
702
+ def _uniform_init_linear_weights(self, m):
703
+ if isinstance(m, torch.nn.Linear):
704
+ if m.bias is not None:
705
+ torch.nn.init.constant_(m.bias, 0)
706
+ std = 1 / math.sqrt(m.in_features)
707
+ torch.nn.init.uniform_(m.weight, -std, std)
708
+
709
+
710
+ @torch.jit.ignore
711
+ def no_weight_decay(self):
712
+ no_wd_list = []
713
+ named_parameters_list = [name for name, _ in self.named_parameters()]
714
+ for module_name, module in self.named_modules():
715
+ if (isinstance(module, torch.nn.Embedding)
716
+ or isinstance(module, torch.nn.Linear)
717
+ or isinstance(module, SO3Linear)
718
+ or isinstance(module, torch.nn.LayerNorm)
719
+ or isinstance(module, RMSNorm)
720
+ or isinstance(module, EquivariantLayerNorm)
721
+ or isinstance(module, EquivariantSeparableLayerNorm)
722
+ or isinstance(module, EquivariantMergeLayerNorm)
723
+ ):
724
+ for parameter_name, _ in module.named_parameters():
725
+ if (isinstance(module, torch.nn.Linear)
726
+ or isinstance(module, SO3Linear)
727
+ ):
728
+ if 'weight' in parameter_name:
729
+ continue
730
+ global_parameter_name = module_name + '.' + parameter_name
731
+ assert global_parameter_name in named_parameters_list
732
+ no_wd_list.append(global_parameter_name)
733
+ return set(no_wd_list)
734
+
735
+
736
+ @torch._dynamo.disable
737
+ def generate_graph(
738
+ self,
739
+ data,
740
+ cutoff=None,
741
+ max_neighbors=None,
742
+ use_pbc=None,
743
+ otf_graph=None,
744
+ enforce_max_neighbors_strictly=None,
745
+ use_pbc_single=False,
746
+ ):
747
+ graph_data = super().generate_graph(
748
+ data,
749
+ cutoff=cutoff,
750
+ max_neighbors=max_neighbors,
751
+ use_pbc=use_pbc,
752
+ otf_graph=otf_graph,
753
+ enforce_max_neighbors_strictly=enforce_max_neighbors_strictly,
754
+ use_pbc_single=use_pbc_single,
755
+ )
756
+
757
+ edge_index = graph_data.edge_index
758
+ edge_dist = graph_data.edge_distance
759
+ distance_vec = graph_data.edge_distance_vec
760
+ cell_offsets = graph_data.cell_offsets
761
+ cell_offset_distances = graph_data.offset_distances
762
+ neighbors = graph_data.neighbors
763
+
764
+ return (
765
+ edge_index,
766
+ edge_dist,
767
+ distance_vec,
768
+ cell_offsets,
769
+ cell_offset_distances,
770
+ neighbors,
771
+ )
model/equiformer_v3_dens.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import e3nn
4
+ import torch
5
+
6
+ from onescience.modules.layer.equiformer_v3.layer_norm import (
7
+ RMSNorm,
8
+ get_normalization_layer,
9
+ )
10
+ from onescience.modules.layer.equiformer_v3.so3 import SO3Linear
11
+ from onescience.modules.layer.equiformer_v3.transformer_block import (
12
+ EquivariantGraphAttention,
13
+ FeedForwardNetwork,
14
+ )
15
+ from onescience.utils.uma.common.registry import registry
16
+ from onescience.utils.uma.common.utils import conditional_grad
17
+
18
+ from .equiformer_v3 import EquiformerV3_OC
19
+
20
+
21
+ # Statistics of IS2RE 100K
22
+ _AVG_NUM_NODES = 77.81317
23
+ _AVG_DEGREE = 23.395238876342773 # IS2RE: 100k, max_radius = 5, max_neighbors = 100
24
+
25
+
26
+ @registry.register_model("equiformer_v3_dens")
27
+ class EquiformerV3DeNS_OC(EquiformerV3_OC):
28
+ """
29
+ Args:
30
+ use_pbc (bool): Use periodic boundary conditions
31
+ use_pbc_single (bool, optional): Process batch PBC graphs one at a time.
32
+ This argument is set to True when training on MPTraj.
33
+ otf_graph (bool): Compute graph On The Fly (OTF)
34
+
35
+ regress_forces (bool): Compute forces
36
+ regress_stress (bool): Compute stress
37
+ direct_prediction (bool): Whether to use direct methods to predict forces and stress
38
+
39
+ max_neighbors (int): Maximum number of neighbors per atom
40
+ max_radius (float): Maximum distance between nieghboring atoms in Angstroms
41
+ num_radial_basis (int): Number of radial basis functions
42
+ max_num_elements (int): Maximum atomic number
43
+
44
+ num_layers (int): Number of layers in the GNN
45
+ num_channels (int): Number of channels in node embeddings
46
+ attn_hidden_channels (int): Number of hidden channels in equivariant graph attention
47
+ num_heads (int): Number of attention heads
48
+ attn_alpha_channels (int): Number of channels for alpha vector in each attention head
49
+ attn_value_channels (int): Number of channels for value vector in each attention head
50
+ ffn_hidden_channels (int): Number of hidden channels in feedforward network
51
+ norm_type (str): Type of normalization layer
52
+ (['sep_layer_norm', 'sep_layer_norm_liger', 'merge_layer_norm',
53
+ 'merge_layer_norm_attn_rms_norm', 'merge_rms_norm'])
54
+
55
+ lmax (int): Maximum degrees (l)
56
+ mmax (int): Maximum order (m)
57
+ attn_grid_resolution_list (list:int):
58
+ Grid resolution list in class `SO3Grid` in attention
59
+ ffn_grid_resolution_list (list:int):
60
+ Grid resolution list in class `SO3Grid` in feedforward network
61
+
62
+ edge_channels (int): Number of channels for edge-wise invariant features
63
+ use_atom_edge_embedding (bool): Whether to use atomic embedding along with relative distance for edge scalar features
64
+ use_envelope (bool): Whether to apply an envelope function to attention
65
+
66
+ attn_activation (str): Type of activation function in equivariant graph attention
67
+ use_attn_renorm (bool): Whether to re-normalize attention weights
68
+ use_add_merge (bool): Default: False
69
+ If True, use addition to merge the source/target node features instead of concat,
70
+ which can save 2x compute when rotating with Wigner-D matrices.
71
+ use_rad_l_parametrization (bool):
72
+ Default: True
73
+ If True, all the m components within the same type-L vector will share the same
74
+ weight from the radial function.
75
+ softcap (float): Default: None
76
+ If not None, use soft capping to limit the range of attention logits to
77
+ [- `softcap`, + `softcap`].
78
+ attn_eps (float): Default: 1e-16
79
+ Epsilon value used in the softmax operation of attention
80
+ ffn_activation (str): Type of activation function for feedforward network
81
+ use_grid_mlp (bool): If `True`, use projecting to grids and performing MLPs for FFNs.
82
+
83
+ use_gate_force_head (bool): If `True`, use `GateActivation` in the equivariant attention of the force prediction head.
84
+
85
+ alpha_drop (float): Dropout rate for the hidden features in non-linear MLP attention
86
+ attn_mask_rate (float): Mask rate for neighbors considered in attention
87
+ attn_weights_drop (float): Dropout rate for attention weights
88
+ value_drop (float): Dropout rate for the hidden features in non-linear value vectors
89
+ drop_path_rate (float): Drop path rate
90
+ proj_drop (float): Dropout rate for outputs of attention and FFN in Transformer blocks
91
+ ffn_drop (float): Dropout rate for the hidden features in FFN
92
+ use_head_reg (bool): Whether to apply regularization to output head (dummy argument for backend compatibility)
93
+
94
+ gradient_checkpointing_block_list (list):
95
+ A list indicating which block we apply gradient/activation checkpointing to save memory.
96
+
97
+ avg_num_nodes (float): Normalization factor for sum aggregation over nodes
98
+ avg_degree (float): Normalization factor for sum aggregation over edges
99
+
100
+ enforce_max_neighbors_strictly (bool): When edges are subselected based on the `max_neighbors` arg, arbitrarily select amongst equidistant / degenerate edges to have exactly the correct number.
101
+ """
102
+ def __init__(
103
+ self,
104
+
105
+ use_pbc=True,
106
+ use_pbc_single=False,
107
+ otf_graph=True,
108
+
109
+ regress_forces=True,
110
+ regress_stress=False,
111
+ direct_prediction=True,
112
+
113
+ max_neighbors=20,
114
+ max_radius=12.0,
115
+ num_radial_basis=600,
116
+ max_num_elements=128,
117
+
118
+ num_layers=12,
119
+ num_channels=128,
120
+ attn_hidden_channels=64,
121
+ num_heads=8,
122
+ attn_alpha_channels=32,
123
+ attn_value_channels=16,
124
+ ffn_hidden_channels=128,
125
+ norm_type='merge_layer_norm',
126
+
127
+ lmax=6,
128
+ mmax=2,
129
+ attn_grid_resolution_list=[20, 8],
130
+ ffn_grid_resolution_list=[20, 20],
131
+
132
+ edge_channels=128,
133
+ use_atom_edge_embedding=True,
134
+ use_envelope=True,
135
+
136
+ attn_activation='sep-merge_gates2_swiglu',
137
+ use_attn_renorm=True,
138
+ use_add_merge=False,
139
+ use_rad_l_parametrization=True,
140
+ softcap=None,
141
+ attn_eps=1e-16,
142
+ ffn_activation='sep-merge_gates2_swiglu',
143
+ use_grid_mlp=True,
144
+
145
+ use_gate_force_head=True,
146
+
147
+ alpha_drop=0.0,
148
+ attn_mask_rate=0.0,
149
+ attn_weights_drop=0.1,
150
+ value_drop=0.0,
151
+ drop_path_rate=0.05,
152
+ proj_drop=0.0,
153
+ ffn_drop=0.0,
154
+ use_head_reg=False,
155
+
156
+ gradient_checkpointing_block_list=None,
157
+
158
+ avg_num_nodes=_AVG_NUM_NODES,
159
+ avg_degree=_AVG_DEGREE,
160
+
161
+ enforce_max_neighbors_strictly=True,
162
+ jd_path=None,
163
+ ):
164
+ super().__init__(
165
+ use_pbc=use_pbc,
166
+ use_pbc_single=use_pbc_single,
167
+ otf_graph=otf_graph,
168
+ regress_forces=regress_forces,
169
+ regress_stress=regress_stress,
170
+ direct_prediction=direct_prediction,
171
+ max_neighbors=max_neighbors,
172
+ max_radius=max_radius,
173
+ num_radial_basis=num_radial_basis,
174
+ max_num_elements=max_num_elements,
175
+ num_layers=num_layers,
176
+ num_channels=num_channels,
177
+ attn_hidden_channels=attn_hidden_channels,
178
+ num_heads=num_heads,
179
+ attn_alpha_channels=attn_alpha_channels,
180
+ attn_value_channels=attn_value_channels,
181
+ ffn_hidden_channels=ffn_hidden_channels,
182
+ norm_type=norm_type,
183
+ lmax=lmax,
184
+ mmax=mmax,
185
+ attn_grid_resolution_list=attn_grid_resolution_list,
186
+ ffn_grid_resolution_list=ffn_grid_resolution_list,
187
+ edge_channels=edge_channels,
188
+ use_atom_edge_embedding=use_atom_edge_embedding,
189
+ use_envelope=use_envelope,
190
+ attn_activation=attn_activation,
191
+ use_attn_renorm=use_attn_renorm,
192
+ use_add_merge=use_add_merge,
193
+ use_rad_l_parametrization=use_rad_l_parametrization,
194
+ softcap=softcap,
195
+ attn_eps=attn_eps,
196
+ ffn_activation=ffn_activation,
197
+ use_grid_mlp=use_grid_mlp,
198
+ use_gate_force_head=use_gate_force_head,
199
+ alpha_drop=alpha_drop,
200
+ attn_mask_rate=attn_mask_rate,
201
+ attn_weights_drop=attn_weights_drop,
202
+ value_drop=value_drop,
203
+ drop_path_rate=drop_path_rate,
204
+ proj_drop=proj_drop,
205
+ ffn_drop=ffn_drop,
206
+ use_head_reg=use_head_reg,
207
+ gradient_checkpointing_block_list=gradient_checkpointing_block_list,
208
+ avg_num_nodes=avg_num_nodes,
209
+ avg_degree=avg_degree,
210
+ enforce_max_neighbors_strictly=enforce_max_neighbors_strictly,
211
+ jd_path=jd_path,
212
+ )
213
+
214
+ # Force encoding
215
+ self.irreps_sh = e3nn.o3.Irreps.spherical_harmonics(lmax=self.lmax, p=1)
216
+ self.force_embedding = SO3Linear(
217
+ in_features=1,
218
+ out_features=self.num_channels,
219
+ lmax=self.lmax,
220
+ bias=True
221
+ )
222
+
223
+ if self.regress_forces:
224
+ self.dens_block = EquivariantGraphAttention(
225
+ num_in_channels=self.num_channels,
226
+ num_hidden_channels=self.attn_hidden_channels,
227
+ num_heads=self.num_heads,
228
+ attn_alpha_channels=self.attn_alpha_channels,
229
+ attn_value_channels=self.attn_value_channels,
230
+ num_out_channels=1,
231
+ lmax=self.lmax,
232
+ mmax=self.mmax,
233
+ so3_rotation=self.so3_rotation,
234
+ grid_resolution_list=self.attn_grid_resolution_list,
235
+ max_num_elements=self.max_num_elements,
236
+ edge_channels_list=self.edge_channels_list,
237
+ use_atom_edge_embedding=self.use_atom_edge_embedding,
238
+ activation=('sep_s2' if not self.use_gate_force_head else 'gate'),
239
+ use_attn_renorm=self.use_attn_renorm,
240
+ use_add_merge=self.use_add_merge,
241
+ use_rad_l_parametrization=self.use_rad_l_parametrization,
242
+ softcap=self.softcap,
243
+ eps=self.attn_eps,
244
+ alpha_drop=0.0,
245
+ attn_mask_rate=0.0,
246
+ attn_weights_drop=0.0,
247
+ value_drop=0.0
248
+ )
249
+ if 'rms_norm' in norm_type:
250
+ if self.dens_block.alpha_norm is not None:
251
+ del self.dens_block.alpha_norm
252
+ self.dens_block.alpha_norm = RMSNorm(self.attn_alpha_channels)
253
+
254
+ self.apply(self._init_weights)
255
+
256
+
257
+ def _forward_direct(self, data):
258
+ self.batch_size = len(data.natoms)
259
+ self.dtype = data.pos.dtype
260
+ self.device = data.pos.device
261
+
262
+ (
263
+ edge_index,
264
+ edge_distance,
265
+ edge_distance_vec,
266
+ cell_offsets,
267
+ _, # cell offset distances
268
+ neighbors,
269
+ ) = self.generate_graph(
270
+ data,
271
+ enforce_max_neighbors_strictly=self.enforce_max_neighbors_strictly,
272
+ use_pbc_single=self.use_pbc_single
273
+ )
274
+
275
+ atomic_numbers = data.atomic_numbers.long()
276
+ source_atomic_numbers = atomic_numbers[edge_index[0]]
277
+ target_atomic_numbers = atomic_numbers[edge_index[1]]
278
+
279
+ edge_distance, edge_envelope_weight = self._forward_edge(edge_distance, edge_distance_vec)
280
+ x = self._forward_embedding(atomic_numbers, edge_distance, edge_index, edge_envelope_weight)
281
+ force_embedding, noise_mask_tensor, dens_batch_mask_tensor, dens_mask_tensor = self._forward_dens_force_encoding(data)
282
+ x = x + force_embedding
283
+ x_scalar, x = self._forward_blocks(
284
+ x,
285
+ source_atomic_numbers,
286
+ target_atomic_numbers,
287
+ edge_distance,
288
+ edge_index,
289
+ edge_envelope_weight,
290
+ data.batch
291
+ )
292
+
293
+ outputs = {}
294
+
295
+ # Energy prediction
296
+ node_energy = self.energy_block(x_scalar)
297
+ energy = torch.zeros(self.batch_size, device=node_energy.device, dtype=node_energy.dtype)
298
+ energy.index_add_(0, data.batch, node_energy.view(-1))
299
+ energy = energy / self.avg_num_nodes
300
+ outputs['energy'] = energy
301
+
302
+ # Force prediction
303
+ if self.regress_forces:
304
+ forces = self.force_block(
305
+ x,
306
+ source_atomic_numbers,
307
+ target_atomic_numbers,
308
+ edge_distance,
309
+ edge_index,
310
+ edge_envelope_weight
311
+ )
312
+ forces = forces.narrow(1, 1, 3)
313
+ forces = forces.view(-1, 3)
314
+
315
+ # for DeNS
316
+ denoising_pos_vec = self.dens_block(
317
+ x,
318
+ source_atomic_numbers,
319
+ target_atomic_numbers,
320
+ edge_distance,
321
+ edge_index,
322
+ edge_envelope_weight
323
+ )
324
+ denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
325
+ denoising_pos_vec = denoising_pos_vec.view(-1, 3)
326
+
327
+ outputs['forces'] = forces * (~noise_mask_tensor) + denoising_pos_vec * noise_mask_tensor
328
+
329
+ # Stress Prediction
330
+ if self.regress_stress:
331
+ stress = self.stress_block(
332
+ x,
333
+ batch_size=self.batch_size,
334
+ batch=data.batch
335
+ )
336
+ outputs['stress'] = stress * (~dens_batch_mask_tensor) # not predict stress during DeNS
337
+
338
+ return outputs
339
+
340
+
341
+ @conditional_grad(torch.enable_grad())
342
+ def _forward_gradient(self, data):
343
+ """
344
+ 1. We have additional `@conditional_grad` as the decorator since the decorator might not be compatible with
345
+ `torch.compile()` in direct methods.
346
+ """
347
+ self.batch_size = len(data.natoms)
348
+ self.dtype = data.pos.dtype
349
+ self.device = data.pos.device
350
+
351
+ """
352
+ For gradient methods
353
+ """
354
+ displacement = None
355
+ orig_cell = None
356
+ if self.regress_stress and self.regress_forces:
357
+ displacement = torch.zeros(
358
+ (3, 3),
359
+ dtype=self.dtype,
360
+ device=self.device,
361
+ )
362
+ displacement = displacement.view(-1, 3, 3).expand(self.batch_size, 3, 3)
363
+ displacement.requires_grad = True
364
+ symmetric_displacement = 0.5 * (
365
+ displacement + displacement.transpose(-1, -2)
366
+ )
367
+
368
+ data.pos.requires_grad = True
369
+ data.pos = data.pos + torch.bmm(
370
+ data.pos.unsqueeze(-2),
371
+ torch.index_select(symmetric_displacement, 0, data.batch)
372
+ ).squeeze(-2)
373
+
374
+ orig_cell = data.cell
375
+ data.cell = data.cell + torch.bmm(
376
+ data.cell, symmetric_displacement
377
+ )
378
+ elif not self.regress_stress and self.regress_forces:
379
+ data.pos.requires_grad = True
380
+
381
+ (
382
+ edge_index,
383
+ edge_distance,
384
+ edge_distance_vec,
385
+ cell_offsets,
386
+ _, # cell offset distances
387
+ neighbors,
388
+ ) = self.generate_graph(
389
+ data,
390
+ enforce_max_neighbors_strictly=self.enforce_max_neighbors_strictly,
391
+ use_pbc_single=self.use_pbc_single
392
+ )
393
+
394
+ atomic_numbers = data.atomic_numbers.long()
395
+ source_atomic_numbers = atomic_numbers[edge_index[0]]
396
+ target_atomic_numbers = atomic_numbers[edge_index[1]]
397
+
398
+ edge_distance, edge_envelope_weight = self._forward_edge(edge_distance, edge_distance_vec)
399
+ x = self._forward_embedding(atomic_numbers, edge_distance, edge_index, edge_envelope_weight)
400
+ force_embedding, noise_mask_tensor, dens_batch_mask_tensor, dens_mask_tensor = self._forward_dens_force_encoding(data)
401
+ x = x + force_embedding
402
+ x_scalar, x = self._forward_blocks(
403
+ x,
404
+ source_atomic_numbers,
405
+ target_atomic_numbers,
406
+ edge_distance,
407
+ edge_index,
408
+ edge_envelope_weight,
409
+ data.batch
410
+ )
411
+
412
+ outputs = {}
413
+
414
+ # Energy prediction
415
+ node_energy = self.energy_block(x_scalar)
416
+ energy = torch.zeros(self.batch_size, device=node_energy.device, dtype=node_energy.dtype)
417
+ energy.index_add_(0, data.batch, node_energy.view(-1))
418
+ energy = energy / self.avg_num_nodes
419
+ outputs['energy'] = energy
420
+
421
+ if self.regress_stress and self.regress_forces:
422
+ # Stress and forces prediction
423
+ grads = torch.autograd.grad(
424
+ [energy.sum()],
425
+ [data.pos, displacement],
426
+ create_graph=self.training,
427
+ )
428
+ forces = torch.neg(grads[0])
429
+ virial = grads[1].view(-1, 3, 3)
430
+ volume = torch.det(data.cell).abs().unsqueeze(-1)
431
+ stress = virial / volume.view(-1, 1, 1)
432
+ virial = torch.neg(virial)
433
+ outputs['forces'] = forces
434
+ outputs['stress'] = stress.view(-1, 9) * (~dens_batch_mask_tensor) # not predict stress during DeNS
435
+ data.cell = orig_cell
436
+ elif not self.regress_stress and self.regress_forces:
437
+ forces = (
438
+ -1
439
+ * torch.autograd.grad(
440
+ energy.sum(), data.pos, create_graph=self.training
441
+ )[0]
442
+ )
443
+ outputs['forces'] = forces
444
+
445
+ # for DeNS
446
+ if self.regress_forces:
447
+ denoising_pos_vec = self.dens_block(
448
+ x,
449
+ source_atomic_numbers,
450
+ target_atomic_numbers,
451
+ edge_distance,
452
+ edge_index,
453
+ edge_envelope_weight
454
+ )
455
+ denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
456
+ denoising_pos_vec = denoising_pos_vec.view(-1, 3)
457
+ outputs['forces'] = forces * (~noise_mask_tensor) + denoising_pos_vec * noise_mask_tensor # not predict forces during DeNS
458
+
459
+ return outputs
460
+
461
+
462
+ @torch._dynamo.disable
463
+ def _generate_dens_data(self, data):
464
+ num_atoms = len(data.atomic_numbers)
465
+ if hasattr(data, "denoising_pos_forward") and data.denoising_pos_forward:
466
+ force_data = data.forces
467
+ if hasattr(data, "noise_mask"):
468
+ noise_mask_tensor = data.noise_mask.view(-1, 1)
469
+ else:
470
+ noise_mask_tensor = torch.ones((num_atoms, 1), dtype=torch.bool, device=self.device)
471
+ if hasattr(data, "dens_batch_mask"):
472
+ dens_batch_mask_tensor = data.dens_batch_mask.view(-1, 1)
473
+ else:
474
+ dens_batch_mask_tensor = torch.ones((len(data.natoms), 1), dtype=torch.bool, device=self.device)
475
+ dens_mask_tensor = torch.ones((1, 1), dtype=torch.bool, device=self.device)
476
+ else:
477
+ force_data = torch.zeros((num_atoms, 3), dtype=self.dtype, device=self.device)
478
+ noise_mask_tensor = torch.zeros((num_atoms, 1), dtype=torch.bool, device=self.device)
479
+ dens_batch_mask_tensor = torch.zeros((len(data.natoms), 1), dtype=torch.bool, device=self.device)
480
+ dens_mask_tensor = torch.zeros((1, 1), dtype=torch.bool, device=self.device)
481
+ force_sh = e3nn.o3.spherical_harmonics(
482
+ l=self.irreps_sh,
483
+ x=force_data,
484
+ normalize=True,
485
+ normalization='component'
486
+ )
487
+
488
+ return force_data, force_sh, noise_mask_tensor, dens_batch_mask_tensor, dens_mask_tensor
489
+
490
+
491
+ def _forward_dens_force_encoding(self, data):
492
+ force_data, force_sh, noise_mask_tensor, dens_batch_mask_tensor, dens_mask_tensor = self._generate_dens_data(data)
493
+ force_norm = force_data.norm(dim=-1, keepdim=True)
494
+ force_norm = force_norm / math.sqrt(3.0)
495
+ force_embedding = force_sh * force_norm
496
+ force_embedding = force_embedding.view(force_embedding.shape[0], -1, 1)
497
+ force_embedding = self.force_embedding(force_embedding)
498
+ noise_mask_tensor = noise_mask_tensor.view(-1, 1, 1)
499
+ force_embedding = force_embedding * noise_mask_tensor
500
+ noise_mask_tensor = noise_mask_tensor.view(-1, 1)
501
+
502
+ return force_embedding, noise_mask_tensor, dens_batch_mask_tensor, dens_mask_tensor
phonons.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run finite-displacement phonons with Equiformer V3 and ASE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ os.environ.setdefault(
10
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
11
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
12
+ )
13
+
14
+ from ase.build import bulk
15
+ from ase.io import read, write
16
+
17
+ from onescience.utils.equiformer_v3 import (
18
+ EquiformerV3Calculator,
19
+ relax_structure,
20
+ run_phonon_workflow,
21
+ write_workflow_result,
22
+ )
23
+
24
+
25
+ def default_checkpoint() -> str:
26
+ return str(
27
+ Path(__file__).resolve().parent
28
+ / "weight"
29
+ / "omat24-mptrj-salex_gradient.pt"
30
+ )
31
+
32
+
33
+ def load_structure(path: str | None):
34
+ if path:
35
+ return read(path)
36
+ return bulk("Cu")
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser(description=__doc__)
41
+ parser.add_argument("--checkpoint")
42
+ parser.add_argument(
43
+ "--input",
44
+ help="periodic structure readable by ASE; defaults to built-in bulk Cu",
45
+ )
46
+ parser.add_argument(
47
+ "--relax",
48
+ action="store_true",
49
+ help="relax the cell and atomic positions before finite displacements",
50
+ )
51
+ parser.add_argument("--relax-fmax", type=float, default=0.01)
52
+ parser.add_argument("--relax-steps", type=int, default=200)
53
+ parser.add_argument("--supercell", type=int, nargs=3, default=(3, 3, 3))
54
+ parser.add_argument("--delta", type=float, default=0.01)
55
+ parser.add_argument("--bandpath", help="ASE band path, for example GXWKGL")
56
+ parser.add_argument("--band-points", type=int, default=100)
57
+ parser.add_argument("--dos-kpts", type=int, nargs=3, default=(10, 10, 10))
58
+ parser.add_argument("--dos-points", type=int, default=400)
59
+ parser.add_argument("--dos-width", type=float, default=0.001, help="eV")
60
+ parser.add_argument("--device", default="cuda")
61
+ parser.add_argument("--output-dir", default="outputs/phonons")
62
+ args = parser.parse_args()
63
+
64
+ checkpoint = args.checkpoint or default_checkpoint()
65
+ calculator = EquiformerV3Calculator.from_checkpoint(
66
+ checkpoint, device=args.device
67
+ )
68
+ output_dir = Path(args.output_dir)
69
+ structure = load_structure(args.input)
70
+ relaxation = None
71
+ if args.relax:
72
+ structure, relaxation = relax_structure(
73
+ structure,
74
+ calculator,
75
+ relax_cell=True,
76
+ fmax=args.relax_fmax,
77
+ steps=args.relax_steps,
78
+ )
79
+ output_dir.mkdir(parents=True, exist_ok=True)
80
+ write(output_dir / "relaxed.cif", structure)
81
+ result = run_phonon_workflow(
82
+ structure,
83
+ calculator,
84
+ output_dir,
85
+ supercell=args.supercell,
86
+ delta=args.delta,
87
+ bandpath=args.bandpath,
88
+ band_points=args.band_points,
89
+ dos_kpts=args.dos_kpts,
90
+ dos_points=args.dos_points,
91
+ dos_width_ev=args.dos_width,
92
+ )
93
+ result["checkpoint"] = str(checkpoint)
94
+ result["relaxation"] = relaxation
95
+ output = write_workflow_result(result, output_dir / "phonons.json")
96
+
97
+ print("formula:", result["formula"])
98
+ print("minimum phonon energy (eV):", result["minimum_band_energy_ev"])
99
+ print("result:", output)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
prepare_mptrj_dataset.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert an MPtrj JSON file into ASE-LMDB shards for Equiformer V3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import multiprocessing as mp
8
+ import os
9
+ import time
10
+ from collections import deque
11
+ from concurrent.futures import Future, ProcessPoolExecutor
12
+ from itertools import islice
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+ from typing import Iterable, Iterator
16
+
17
+ import ase
18
+ import numpy as np
19
+ import torch
20
+ from ase import Atoms
21
+ from ase.calculators.singlepoint import SinglePointCalculator
22
+ from ase.db import connect
23
+
24
+ from onescience.modules.func_utils.uma_graph.radius_graph_pbc import radius_graph_pbc
25
+
26
+
27
+ def _to_atoms(identifier: str, record: dict) -> Atoms:
28
+ """Convert one MPtrj record while preserving FairChem label conventions."""
29
+
30
+ structure = record["structure"]
31
+ sites = structure["sites"]
32
+ numbers = [
33
+ ase.data.atomic_numbers[site["species"][0]["element"]] for site in sites
34
+ ]
35
+ positions = [site["xyz"] for site in sites]
36
+ atoms = Atoms(
37
+ numbers,
38
+ positions,
39
+ cell=structure["lattice"]["matrix"],
40
+ pbc=True,
41
+ )
42
+
43
+ # MPtrj stress is reported in kbar; retain the upstream ASE sign and units.
44
+ stress = np.asarray(record["stress"], dtype=np.float32)
45
+ stress = stress * (-0.1 * ase.units.GPa)
46
+ energy = record["uncorrected_total_energy"]
47
+ atoms.calc = SinglePointCalculator(
48
+ atoms,
49
+ energy=energy,
50
+ free_energy=energy,
51
+ forces=record["force"],
52
+ stress=stress,
53
+ )
54
+ atoms.info["sid"] = identifier
55
+ return atoms
56
+
57
+
58
+ def _has_no_isolated_atoms(
59
+ atoms: Atoms, cutoff: float, max_neighbors: int
60
+ ) -> bool:
61
+ # Match the upstream AtomsToGraphs + radius_graph_pbc(..., True) path.
62
+ data = SimpleNamespace(
63
+ pos=torch.as_tensor(atoms.positions, dtype=torch.float32),
64
+ cell=torch.as_tensor(atoms.cell.array, dtype=torch.float32).view(1, 3, 3),
65
+ pbc=torch.as_tensor(atoms.pbc, dtype=torch.bool).view(1, 3),
66
+ natoms=torch.tensor([len(atoms)], dtype=torch.long),
67
+ )
68
+ edge_index, _, _ = radius_graph_pbc(
69
+ data,
70
+ cutoff,
71
+ max_neighbors,
72
+ True,
73
+ pbc=data.pbc[0],
74
+ )
75
+ counts = torch.bincount(edge_index[1], minlength=len(atoms))
76
+ return bool(torch.all(counts > 0))
77
+
78
+
79
+ def _init_worker() -> None:
80
+ """Keep each conversion worker single-threaded to avoid CPU oversubscription."""
81
+
82
+ os.environ["OMP_NUM_THREADS"] = "1"
83
+ os.environ["MKL_NUM_THREADS"] = "1"
84
+ torch.set_num_threads(1)
85
+
86
+
87
+ def _worker_ready() -> None:
88
+ """Start the process pool before the parent opens an ASE database."""
89
+
90
+
91
+ def _convert_batch(
92
+ batch: list[tuple[str, dict]], cutoff: float, max_neighbors: int
93
+ ) -> list[Atoms | None]:
94
+ converted: list[Atoms | None] = []
95
+ for identifier, record in batch:
96
+ atoms = _to_atoms(identifier, record)
97
+ converted.append(
98
+ atoms if _has_no_isolated_atoms(atoms, cutoff, max_neighbors) else None
99
+ )
100
+ return converted
101
+
102
+
103
+ def _iter_batches(
104
+ records: Iterable[tuple[str, dict]], batch_size: int
105
+ ) -> Iterator[list[tuple[str, dict]]]:
106
+ iterator = iter(records)
107
+ while batch := list(islice(iterator, batch_size)):
108
+ yield batch
109
+
110
+
111
+ def _convert_batches_ordered(
112
+ records: Iterable[tuple[str, dict]],
113
+ cutoff: float,
114
+ max_neighbors: int,
115
+ workers: int,
116
+ batch_size: int,
117
+ executor: ProcessPoolExecutor | None,
118
+ ) -> Iterator[list[Atoms | None]]:
119
+ batches = _iter_batches(records, batch_size)
120
+ if executor is None:
121
+ for batch in batches:
122
+ yield _convert_batch(batch, cutoff, max_neighbors)
123
+ return
124
+
125
+ pending: deque[Future[list[Atoms | None]]] = deque()
126
+ max_pending = workers * 2
127
+ for batch in batches:
128
+ pending.append(
129
+ executor.submit(_convert_batch, batch, cutoff, max_neighbors)
130
+ )
131
+ if len(pending) >= max_pending:
132
+ yield pending.popleft().result()
133
+ while pending:
134
+ yield pending.popleft().result()
135
+
136
+
137
+ class _JSONStream:
138
+ """Incrementally decode JSON values without loading the full MPtrj file."""
139
+
140
+ def __init__(self, path: Path, chunk_size: int = 1 << 20):
141
+ self.handle = path.open("r", encoding="utf-8")
142
+ self.chunk_size = chunk_size
143
+ self.buffer = ""
144
+ self.decoder = json.JSONDecoder()
145
+ self.eof = False
146
+
147
+ def close(self) -> None:
148
+ self.handle.close()
149
+
150
+ def _fill(self) -> None:
151
+ if not self.eof:
152
+ chunk = self.handle.read(self.chunk_size)
153
+ if chunk:
154
+ self.buffer += chunk
155
+ else:
156
+ self.eof = True
157
+
158
+ def _skip_whitespace(self) -> None:
159
+ while True:
160
+ stripped = self.buffer.lstrip()
161
+ if stripped:
162
+ self.buffer = stripped
163
+ return
164
+ self._fill()
165
+ if not self.buffer and self.eof:
166
+ raise EOFError("unexpected end of JSON input")
167
+
168
+ def _take(self, token: str) -> None:
169
+ self._skip_whitespace()
170
+ if not self.buffer.startswith(token):
171
+ raise ValueError(f"expected {token!r} in MPtrj JSON")
172
+ self.buffer = self.buffer[len(token) :]
173
+
174
+ def value(self):
175
+ """Read one complete JSON value, filling until the decoder succeeds."""
176
+
177
+ self._skip_whitespace()
178
+ while True:
179
+ try:
180
+ value, end = self.decoder.raw_decode(self.buffer)
181
+ except json.JSONDecodeError:
182
+ if self.eof:
183
+ raise
184
+ self._fill()
185
+ continue
186
+ self.buffer = self.buffer[end:]
187
+ return value
188
+
189
+ def iter_group_records(self) -> Iterator[tuple[str, dict]]:
190
+ """Yield ``(record_id, record)`` pairs from the current group object."""
191
+
192
+ self._take("{")
193
+ self._skip_whitespace()
194
+ if self.buffer.startswith("}"):
195
+ self.buffer = self.buffer[1:]
196
+ return
197
+ while True:
198
+ record_id = self.value()
199
+ if not isinstance(record_id, str):
200
+ raise ValueError("MPtrj record identifiers must be strings")
201
+ self._take(":")
202
+ record = self.value()
203
+ if not isinstance(record, dict):
204
+ raise ValueError("MPtrj records must be JSON objects")
205
+ yield record_id, record
206
+ self._skip_whitespace()
207
+ if self.buffer.startswith(","):
208
+ self.buffer = self.buffer[1:]
209
+ continue
210
+ self._take("}")
211
+ return
212
+
213
+ def iter_records(self) -> Iterator[tuple[str, dict]]:
214
+ """Yield all records from the two-level MPtrj root object."""
215
+
216
+ self._take("{")
217
+ self._skip_whitespace()
218
+ if self.buffer.startswith("}"):
219
+ self.buffer = self.buffer[1:]
220
+ return
221
+ while True:
222
+ group_id = self.value()
223
+ if not isinstance(group_id, str):
224
+ raise ValueError("MPtrj group identifiers must be strings")
225
+ self._take(":")
226
+ for record_id, record in self.iter_group_records():
227
+ # Upstream discards the outer group key and uses this ID as sid.
228
+ yield record_id, record
229
+ self._skip_whitespace()
230
+ if self.buffer.startswith(","):
231
+ self.buffer = self.buffer[1:]
232
+ continue
233
+ self._take("}")
234
+ while not self.eof:
235
+ self._fill()
236
+ if self.buffer.strip():
237
+ raise ValueError("trailing data after MPtrj JSON root object")
238
+ self.buffer = ""
239
+ return
240
+
241
+
242
+ def _count_records(
243
+ input_path: Path,
244
+ max_records: int | None,
245
+ progress_every: int,
246
+ ) -> int:
247
+ """Count records without retaining the full upstream JSON object in memory."""
248
+
249
+ stream = _JSONStream(input_path)
250
+ started = time.monotonic()
251
+ try:
252
+ records = stream.iter_records()
253
+ if max_records is not None:
254
+ records = islice(records, max_records)
255
+ count = 0
256
+ for _ in records:
257
+ count += 1
258
+ if progress_every and count % progress_every == 0:
259
+ elapsed = max(time.monotonic() - started, 1e-9)
260
+ print(
261
+ f"counted {count} input records; "
262
+ f"rate={count / elapsed:.1f} records/s",
263
+ flush=True,
264
+ )
265
+ print(
266
+ f"counted {count} input records; starting conversion",
267
+ flush=True,
268
+ )
269
+ return count
270
+ finally:
271
+ stream.close()
272
+
273
+
274
+ def _shard_sizes(total_records: int, shards: int) -> list[int]:
275
+ """Return the same contiguous, remainder-first split used upstream."""
276
+
277
+ chunk_size, remainder = divmod(total_records, shards)
278
+ return [
279
+ chunk_size + (1 if shard_index < remainder else 0)
280
+ for shard_index in range(shards)
281
+ ]
282
+
283
+
284
+ def convert(
285
+ input_path: Path,
286
+ output_dir: Path,
287
+ cutoff: float,
288
+ max_neighbors: int,
289
+ shards: int,
290
+ max_records: int | None = None,
291
+ progress_every: int = 10_000,
292
+ workers: int = 1,
293
+ batch_size: int = 16,
294
+ ) -> None:
295
+ if shards < 1:
296
+ raise ValueError("shards must be positive")
297
+ if max_records is not None and max_records < 1:
298
+ raise ValueError("max_records must be positive")
299
+ if progress_every < 0:
300
+ raise ValueError("progress_every cannot be negative")
301
+ if workers < 1:
302
+ raise ValueError("workers must be positive")
303
+ if batch_size < 1:
304
+ raise ValueError("batch_size must be positive")
305
+ output_dir.mkdir(parents=True, exist_ok=True)
306
+ existing = sorted(output_dir.iterdir())
307
+ if existing:
308
+ raise FileExistsError(
309
+ f"refusing to write into non-empty output directory {output_dir}; "
310
+ "choose a new directory or clear it explicitly"
311
+ )
312
+ total_records = _count_records(input_path, max_records, progress_every)
313
+ shard_sizes = _shard_sizes(total_records, shards)
314
+ atom_counts: list[int] = []
315
+ written = 0
316
+ examined = 0
317
+ next_progress = progress_every
318
+ conversion_started = time.monotonic()
319
+ executor: ProcessPoolExecutor | None = None
320
+ if workers > 1:
321
+ executor = ProcessPoolExecutor(
322
+ max_workers=workers,
323
+ mp_context=mp.get_context("fork"),
324
+ initializer=_init_worker,
325
+ )
326
+ # ProcessPoolExecutor launches all workers together on its first submit.
327
+ executor.submit(_worker_ready).result()
328
+ stream = _JSONStream(input_path)
329
+ try:
330
+ records = stream.iter_records()
331
+ if max_records is not None:
332
+ records = islice(records, max_records)
333
+
334
+ for shard_index, shard_size in enumerate(shard_sizes):
335
+ # Zero padding preserves numeric shard order in AseDBDataset, whose
336
+ # directory discovery sorts paths lexicographically.
337
+ database = connect(
338
+ str(output_dir / f"data_{shard_index:05d}.aselmdb")
339
+ )
340
+ shard_written = 0
341
+ try:
342
+ shard_records = islice(records, shard_size)
343
+ for converted in _convert_batches_ordered(
344
+ shard_records,
345
+ cutoff,
346
+ max_neighbors,
347
+ workers,
348
+ batch_size,
349
+ executor,
350
+ ):
351
+ for atoms in converted:
352
+ examined += 1
353
+ if atoms is not None:
354
+ database.write(atoms, data=atoms.info)
355
+ atom_counts.append(len(atoms))
356
+ shard_written += 1
357
+ written += 1
358
+ if progress_every and examined >= next_progress:
359
+ elapsed = max(
360
+ time.monotonic() - conversion_started, 1e-9
361
+ )
362
+ rate = examined / elapsed
363
+ remaining = total_records - examined
364
+ eta_seconds = remaining / rate if rate else float("inf")
365
+ print(
366
+ f"processed {examined}/{total_records} records; "
367
+ f"wrote {written}; filtered {examined - written}; "
368
+ f"rate={rate:.1f} records/s; "
369
+ f"eta={eta_seconds / 60:.1f} min",
370
+ flush=True,
371
+ )
372
+ next_progress += progress_every
373
+ finally:
374
+ database.close()
375
+ print(
376
+ f"finished shard {shard_index}: examined {shard_size}; "
377
+ f"wrote {shard_written}",
378
+ flush=True,
379
+ )
380
+ finally:
381
+ stream.close()
382
+ if executor is not None:
383
+ executor.shutdown(wait=True, cancel_futures=True)
384
+
385
+ if examined != total_records:
386
+ raise RuntimeError(
387
+ f"MPtrj input changed while converting: counted {total_records} "
388
+ f"records but read {examined}"
389
+ )
390
+
391
+ np.savez(
392
+ output_dir / "metadata.npz",
393
+ natoms=np.asarray(atom_counts, dtype=np.int64),
394
+ )
395
+ print(f"wrote {written} structures to {output_dir} ({shards} shards)")
396
+
397
+
398
+ def main() -> None:
399
+ parser = argparse.ArgumentParser(description=__doc__)
400
+ parser.add_argument("--input", type=Path, required=True, help="MPtrj JSON file")
401
+ parser.add_argument(
402
+ "--output", type=Path, required=True, help="ASE-LMDB output directory"
403
+ )
404
+ parser.add_argument("--cutoff", type=float, default=6.0)
405
+ parser.add_argument("--max-neighbors", type=int, default=1000)
406
+ parser.add_argument("--shards", type=int, default=15)
407
+ parser.add_argument(
408
+ "--max-records",
409
+ type=int,
410
+ default=None,
411
+ help="optional maximum input records to examine for bounded validation",
412
+ )
413
+ parser.add_argument(
414
+ "--progress-every",
415
+ type=int,
416
+ default=10_000,
417
+ help="print progress after this many input records; zero disables it",
418
+ )
419
+ parser.add_argument(
420
+ "--workers",
421
+ type=int,
422
+ default=1,
423
+ help="parallel CPU conversion workers; results remain ordered",
424
+ )
425
+ parser.add_argument(
426
+ "--batch-size",
427
+ type=int,
428
+ default=16,
429
+ help="records sent to each worker task",
430
+ )
431
+ args = parser.parse_args()
432
+ if args.shards < 1:
433
+ parser.error("--shards must be positive")
434
+ if args.max_records is not None and args.max_records < 1:
435
+ parser.error("--max-records must be positive")
436
+ if args.progress_every < 0:
437
+ parser.error("--progress-every cannot be negative")
438
+ if args.workers < 1:
439
+ parser.error("--workers must be positive")
440
+ if args.batch_size < 1:
441
+ parser.error("--batch-size must be positive")
442
+ convert(
443
+ args.input,
444
+ args.output,
445
+ args.cutoff,
446
+ args.max_neighbors,
447
+ args.shards,
448
+ args.max_records,
449
+ args.progress_every,
450
+ args.workers,
451
+ args.batch_size,
452
+ )
453
+
454
+
455
+ if __name__ == "__main__":
456
+ main()
scripts/create_finetune_dataset.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import glob
5
+ import logging
6
+ import multiprocessing as mp
7
+ import os
8
+ import random
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from ase.db import connect
13
+ from ase.io import read
14
+ from tqdm import tqdm
15
+
16
+ from onescience.datapipes.materials.custom_stack.storage import AseDBDataset
17
+ logging.basicConfig(level=logging.INFO)
18
+
19
+
20
+ def compute_normalizer_and_linear_reference(train_path, num_workers):
21
+ """
22
+ Given a path to an ASE database file, compute the normalizer value and linear
23
+ reference coefficients. These are used to normalize energies and forces during
24
+ training. For large datasets, compute this for only a subset of the data.
25
+ """
26
+ global dataset
27
+ dataset = AseDBDataset({"src": str(train_path)})
28
+
29
+ sample_indices = random.sample(range(len(dataset)), min(100000, len(dataset)))
30
+ with mp.Pool(num_workers) as pool:
31
+ outputs = list(
32
+ tqdm(
33
+ pool.imap(extract_energy_and_forces, sample_indices),
34
+ total=len(sample_indices),
35
+ desc="Computing normalizer values.",
36
+ )
37
+ )
38
+ atomic_numbers = [x[0] for x in outputs]
39
+ energies = [x[1] for x in outputs]
40
+ forces = np.array([force for x in outputs for force in x[2]])
41
+ force_rms = np.sqrt(np.mean(np.square(forces)))
42
+ assert (
43
+ np.isfinite(force_rms).all()
44
+ ), "We found non-finite values in the forces, please check your input data!"
45
+ coeff = compute_lin_ref(atomic_numbers, energies)
46
+ return force_rms, coeff
47
+
48
+
49
+ def extract_energy_and_forces(idx):
50
+ """
51
+ Extract energy and forces from an ASE atoms object at a given index in the dataset.
52
+ """
53
+ atoms = dataset.get_atoms(idx)
54
+ atomic_numbers = atoms.get_atomic_numbers()
55
+ energy = atoms.get_potential_energy()
56
+ forces = atoms.get_forces()
57
+ n_atoms = len(atoms)
58
+
59
+ fixed_idx = np.zeros(n_atoms)
60
+ if hasattr(atoms, "constraints"):
61
+ from ase.constraints import FixAtoms
62
+
63
+ for constraint in atoms.constraints:
64
+ if isinstance(constraint, FixAtoms):
65
+ fixed_idx[constraint.index] = 1
66
+
67
+ mask = fixed_idx == 0
68
+ forces = forces[mask]
69
+
70
+ return atomic_numbers, energy, forces
71
+
72
+
73
+ def compute_lin_ref(atomic_numbers, energies):
74
+ """
75
+ Compute linear reference coefficients given atomic numbers and energies.
76
+ """
77
+ features = [np.bincount(x, minlength=100).astype(int) for x in atomic_numbers]
78
+
79
+ X = np.vstack(features)
80
+ y = energies
81
+
82
+ coeff = np.linalg.lstsq(X, y, rcond=None)[0]
83
+ assert np.isfinite(
84
+ coeff
85
+ ).all(), "Found some non-finite values while computing element references, please check/sanitize your inputs before proceeding"
86
+ return coeff.tolist()
87
+
88
+
89
+ def write_ase_db(mp_arg):
90
+ """
91
+ Write ASE atoms objects to an ASE database file. This function is designed to be
92
+ run in parallel using multiprocessing.
93
+ """
94
+ db_file, file_list, worker_id = mp_arg
95
+
96
+ successful = []
97
+ failed = []
98
+ natoms = []
99
+ with connect(str(db_file)) as db:
100
+ for file in tqdm(file_list, position=worker_id):
101
+ atoms_list = read(file, ":")
102
+ for i, atoms in enumerate(atoms_list):
103
+ try:
104
+ assert (
105
+ atoms.calc is not None
106
+ ), "No calculator attached to atoms object."
107
+ assert "energy" in atoms.calc.results, "Missing energy result"
108
+ assert "forces" in atoms.calc.results, "Missing forces result"
109
+ db.write(atoms, data=atoms.info)
110
+ natoms.append(len(atoms))
111
+ successful.append(f"{file},{i}")
112
+ except AssertionError as e:
113
+ failed.append(f"{file},{i}: {e!s}")
114
+
115
+ return db_file, natoms, successful, failed
116
+
117
+
118
+ def launch_processing(data_dir, output_dir, num_workers):
119
+ """
120
+ Driver script to launch processing of ASE atoms files into an ASE database.
121
+ """
122
+ os.makedirs(output_dir, exist_ok=True)
123
+ input_files = [
124
+ f
125
+ for f in glob.glob(os.path.join(data_dir, "**/*"), recursive=True)
126
+ if os.path.isfile(f)
127
+ ]
128
+ chunked_files = np.array_split(input_files, num_workers)
129
+ db_files = [output_dir / f"data.{i:04d}.aselmdb" for i in range(num_workers)]
130
+ mp_args = [(db_files[i], chunked_files[i], i) for i in range(num_workers)]
131
+ with mp.Pool(num_workers) as pool:
132
+ outputs = pool.map(write_ase_db, mp_args)
133
+
134
+ # Log results
135
+ natoms = []
136
+ for output in outputs:
137
+ db_file, _natoms, successful, failed = output
138
+ natoms.extend(_natoms)
139
+ log_file = db_file.with_suffix(".log")
140
+ failed_file = db_file.with_suffix(".failed")
141
+
142
+ with open(log_file, "w") as log:
143
+ log.write("\n".join(successful))
144
+ with open(failed_file, "w") as failed_log:
145
+ failed_log.write("\n".join(failed))
146
+ np.savez_compressed(output_dir / "metadata.npz", natoms=natoms)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ parser = argparse.ArgumentParser()
151
+ parser.add_argument(
152
+ "--train-dir",
153
+ type=str,
154
+ required=True,
155
+ help="Directory of ASE atoms objects to convert for training.",
156
+ )
157
+ parser.add_argument(
158
+ "--val-dir",
159
+ type=str,
160
+ required=True,
161
+ help="Directory of ASE atoms objects to convert for validation.",
162
+ )
163
+ parser.add_argument(
164
+ "--output-dir",
165
+ type=Path,
166
+ required=True,
167
+ help="Output directory to save required finetuning artifacts.",
168
+ )
169
+ parser.add_argument(
170
+ "--num-workers",
171
+ type=int,
172
+ default=8,
173
+ help="Number of parallel workers for processing files.",
174
+ )
175
+ args = parser.parse_args()
176
+
177
+ # Launch processing for training data
178
+ train_path = args.output_dir / "train"
179
+ launch_processing(args.train_dir, train_path, args.num_workers)
180
+ force_rms, linref_coeff = compute_normalizer_and_linear_reference(
181
+ train_path, args.num_workers
182
+ )
183
+ val_path = args.output_dir / "val"
184
+ launch_processing(args.val_dir, val_path, args.num_workers)
185
+
186
+ logging.info(f"Generated dataset at {args.output_dir}")
scripts/create_uma_finetune_dataset.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from onescience.utils.uma.scripts.create_finetune_dataset import (
11
+ compute_normalizer_and_linear_reference,
12
+ launch_processing,
13
+ )
14
+ from onescience.utils.uma.units.mlip_unit.api.inference import UMATask
15
+
16
+
17
+ logging.basicConfig(level=logging.INFO)
18
+
19
+ TEMPLATE_DIR = Path("configs")
20
+ DATA_YAML_DIR = Path("data")
21
+ REGRESSION_LABEL_TO_TASK_YAML = {
22
+ "e": DATA_YAML_DIR / Path("uma_conserving_data_task_energy.yaml"),
23
+ "ef": DATA_YAML_DIR / Path("uma_conserving_data_task_energy_force.yaml"),
24
+ "efs": DATA_YAML_DIR / Path("uma_conserving_data_task_energy_force_stress.yaml"),
25
+ }
26
+ UMA_SM_FINETUNE_YAML = Path("uma_sm_finetune_template.yaml")
27
+
28
+
29
+ def create_yaml(
30
+ train_path: str,
31
+ val_path: str,
32
+ force_rms: float,
33
+ linref_coeff: list,
34
+ output_dir: str,
35
+ dataset_name: str,
36
+ regression_tasks: str,
37
+ # base_model_name: str,
38
+ ):
39
+ data_task_yaml = TEMPLATE_DIR / REGRESSION_LABEL_TO_TASK_YAML[regression_tasks]
40
+ with open(data_task_yaml) as file:
41
+ template = yaml.safe_load(file)
42
+ template["dataset_name"] = dataset_name
43
+ template["normalizer_rmsd"] = force_rms
44
+ template["elem_refs"] = linref_coeff
45
+ template["train_dataset"]["splits"]["train"]["src"] = train_path
46
+ template["val_dataset"]["splits"]["val"]["src"] = val_path
47
+ # add extra large vaccum box for molecules
48
+ # if dataset_name == str(UMATask.OMOL):
49
+ # template["train_dataset"]["a2g_args"]["molecule_cell_size"] = 1000.0
50
+ os.makedirs(output_dir / DATA_YAML_DIR, exist_ok=True)
51
+ with open(
52
+ output_dir / REGRESSION_LABEL_TO_TASK_YAML[regression_tasks], "w"
53
+ ) as yaml_file:
54
+ yaml.dump(template, yaml_file, default_flow_style=False, sort_keys=False)
55
+
56
+ uma_finetune_yaml = TEMPLATE_DIR / UMA_SM_FINETUNE_YAML
57
+ with open(uma_finetune_yaml) as file:
58
+ template_ft = yaml.safe_load(file)
59
+ #template_ft["base_model_name"] = base_model_name
60
+ template_ft["defaults"][0]["data"] = REGRESSION_LABEL_TO_TASK_YAML[
61
+ regression_tasks
62
+ ].stem
63
+ with open(output_dir / UMA_SM_FINETUNE_YAML, "w") as yaml_file:
64
+ yaml.dump(template_ft, yaml_file, default_flow_style=False, sort_keys=False)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ parser = argparse.ArgumentParser()
69
+ parser.add_argument(
70
+ "--train-dir",
71
+ type=str,
72
+ required=True,
73
+ help="Directory of ASE atoms objects to convert for training.",
74
+ )
75
+ parser.add_argument(
76
+ "--val-dir",
77
+ type=str,
78
+ required=True,
79
+ help="Directory of ASE atoms objects to convert for validation.",
80
+ )
81
+ parser.add_argument(
82
+ "--uma-task",
83
+ type=str,
84
+ required=True,
85
+ choices=[t.value for t in UMATask],
86
+ help="choose a uma task to finetune",
87
+ )
88
+ parser.add_argument(
89
+ "--regression-tasks",
90
+ type=str,
91
+ choices=["e", "ef", "efs"],
92
+ required=True,
93
+ help="Choose to finetune based on regression task set (you must have the corresponding labels in your dataset), can be energy (e), energy+force (ef) or energy+force+stress(efs)",
94
+ )
95
+ #parser.add_argument(
96
+ # "--base-model",
97
+ # type=str,
98
+ # default="uma-s-1",
99
+ # help="Name of base uma model",
100
+ #)
101
+ parser.add_argument(
102
+ "--output-dir",
103
+ type=Path,
104
+ required=True,
105
+ help="Output directory to save required finetuning artifacts.",
106
+ )
107
+ parser.add_argument(
108
+ "--num-workers",
109
+ type=int,
110
+ default=8,
111
+ help="Number of parallel workers for processing files.",
112
+ )
113
+ args = parser.parse_args()
114
+ assert not Path(
115
+ args.output_dir
116
+ ).exists(), f"{args.output_dir} can't already exist, please choose a different dir"
117
+
118
+ # Launch processing for training data
119
+ train_path = args.output_dir / "train"
120
+ launch_processing(args.train_dir, train_path, args.num_workers)
121
+ force_rms, linref_coeff = compute_normalizer_and_linear_reference(
122
+ train_path, args.num_workers
123
+ )
124
+ val_path = args.output_dir / "val"
125
+ launch_processing(args.val_dir, val_path, args.num_workers)
126
+
127
+ create_yaml(
128
+ train_path=str(train_path),
129
+ val_path=str(val_path),
130
+ force_rms=float(force_rms),
131
+ linref_coeff=linref_coeff,
132
+ output_dir=args.output_dir,
133
+ dataset_name=args.uma_task,
134
+ regression_tasks=args.regression_tasks,
135
+ # base_model_name=args.base_model,
136
+ )
137
+ logging.info(f"Generated dataset and data config yaml in {args.output_dir}")
138
+ logging.info(
139
+ f"To run finetuning, run fairchem -c {args.output_dir}/{UMA_SM_FINETUNE_YAML}"
140
+ )
single_point.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run one Equiformer V3 energy, force, and stress prediction through ASE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ os.environ.setdefault(
10
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
11
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
12
+ )
13
+
14
+ from ase.build import bulk
15
+ from ase.io import read
16
+
17
+ from onescience.utils.equiformer_v3 import (
18
+ EquiformerV3Calculator,
19
+ write_workflow_result,
20
+ )
21
+
22
+
23
+ def default_checkpoint() -> str:
24
+ return str(
25
+ Path(__file__).resolve().parent
26
+ / "weight"
27
+ / "omat24-mptrj-salex_gradient.pt"
28
+ )
29
+
30
+
31
+ def load_structure(path: str | None):
32
+ if path:
33
+ return read(path)
34
+ # Keep the example runnable without requiring a separate structure file.
35
+ return bulk("Cu")
36
+
37
+
38
+ def main() -> None:
39
+ parser = argparse.ArgumentParser(description=__doc__)
40
+ parser.add_argument("--checkpoint")
41
+ parser.add_argument(
42
+ "--input",
43
+ help=(
44
+ "CIF, POSCAR, XYZ, trajectory, or another ASE-readable structure; "
45
+ "defaults to the built-in periodic Cu example"
46
+ ),
47
+ )
48
+ parser.add_argument("--device", default="cuda")
49
+ parser.add_argument("--output", default="outputs/single_point.json")
50
+ args = parser.parse_args()
51
+
52
+ checkpoint = args.checkpoint or default_checkpoint()
53
+ atoms = load_structure(args.input)
54
+ atoms.calc = EquiformerV3Calculator.from_checkpoint(
55
+ checkpoint,
56
+ device=args.device,
57
+ )
58
+
59
+ result = {
60
+ "formula": atoms.get_chemical_formula(),
61
+ "natoms": len(atoms),
62
+ "input": str(Path(args.input).expanduser()) if args.input else None,
63
+ "input_source": args.input or "ASE bulk Cu default",
64
+ "checkpoint": str(Path(checkpoint).expanduser()),
65
+ "pbc": atoms.pbc.tolist(),
66
+ "cell_angstrom": atoms.cell.array.tolist(),
67
+ "energy_ev": float(atoms.get_potential_energy()),
68
+ "forces_ev_per_angstrom": atoms.get_forces().tolist(),
69
+ "stress_ev_per_angstrom_cubed_voigt": atoms.get_stress().tolist(),
70
+ }
71
+ output = write_workflow_result(result, args.output)
72
+
73
+ print("formula:", result["formula"])
74
+ print("atoms:", result["natoms"])
75
+ print("energy (eV):", result["energy_ev"])
76
+ print("result:", output)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
train.py ADDED
@@ -0,0 +1,1585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train, fine-tune, or resume an Equiformer V3 atomistic model.
2
+
3
+ The input splits must be ASE DB or ASE-LMDB datasets containing calculator
4
+ results. Checkpoints remain compatible with ``EquiformerV3Calculator`` while
5
+ also carrying optimizer, scheduler, EMA, and progress state for resuming.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import copy
12
+ import json
13
+ import os
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ os.environ.setdefault(
20
+ "ONESCIENCE_EQUIFORMER_V3_JD_PATH",
21
+ str(Path(__file__).resolve().parent / "weight" / "Jd.pt"),
22
+ )
23
+
24
+ import torch
25
+ import yaml
26
+ from torch.nn.parallel import DistributedDataParallel
27
+ from torch.utils.data import DataLoader
28
+ from torch.utils.data.distributed import DistributedSampler
29
+
30
+ from onescience.datapipes.materials.custom_stack import data_list_collater
31
+ from onescience.datapipes.materials.custom_stack.base_dataset import (
32
+ Subset as MetadataSubset,
33
+ )
34
+ from onescience.datapipes.materials.custom_stack.storage.ase_datasets import (
35
+ AseDBDataset,
36
+ )
37
+ from onescience.modules.loss.uma_loss import DDPLoss
38
+ from onescience.utils.equiformer_v3 import (
39
+ EquiformerV3CheckpointTransforms,
40
+ load_equiformer_v3_checkpoint,
41
+ )
42
+ from onescience.utils.uma.common.data_parallel import BalancedBatchSampler
43
+ from onescience.utils.uma.common.registry import registry
44
+ from onescience.utils.uma.normalization.element_references import (
45
+ LinearReferences,
46
+ create_element_references,
47
+ fit_linear_references,
48
+ )
49
+ from onescience.utils.uma.normalization.normalizer import (
50
+ create_normalizer,
51
+ fit_normalizers,
52
+ )
53
+ from onescience.utils.uma.scheduler import CosineLRLambda
54
+
55
+
56
+ MODE_ALIASES = {
57
+ "scratch": "train_from_scratch",
58
+ "train": "train_from_scratch",
59
+ "train_from_scratch": "train_from_scratch",
60
+ "finetune": "init_from_checkpoint",
61
+ "fine_tune": "init_from_checkpoint",
62
+ "init_from_checkpoint": "init_from_checkpoint",
63
+ "resume": "resume_training",
64
+ "resume_training": "resume_training",
65
+ }
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DistributedContext:
70
+ """Runtime information for a normal process or a torchrun worker."""
71
+
72
+ rank: int = 0
73
+ world_size: int = 1
74
+ local_rank: int = 0
75
+
76
+ @property
77
+ def enabled(self) -> bool:
78
+ return self.world_size > 1
79
+
80
+ @property
81
+ def is_main(self) -> bool:
82
+ return self.rank == 0
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class LossSpec:
87
+ """One target loss from the YAML contract."""
88
+
89
+ name: str
90
+ function: str
91
+ coefficient: float
92
+ free_atoms_only: bool = False
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class DenoisingPosParams:
97
+ """Official Equiformer V3 DeNS position-corruption contract."""
98
+
99
+ enabled: bool = False
100
+ prob: float = 0.0
101
+ fixed_noise_std: bool = True
102
+ std: float = 0.025
103
+ corrupt_ratio: float | None = None
104
+ all_atoms: bool = False
105
+ min_num_atoms: int | None = None
106
+ strict_max_ratio: float | None = None
107
+ max_force_norm: float | None = None
108
+ max_stress_norm: float | None = None
109
+ max_mean_force_norm: float | None = None
110
+ coefficient: float = 1.0
111
+
112
+
113
+ class ModelEMA:
114
+ """Exponential moving average of trainable model parameters."""
115
+
116
+ def __init__(self, model: torch.nn.Module, decay: float):
117
+ if not 0.0 < decay < 1.0:
118
+ raise ValueError("ema_decay must be between zero and one")
119
+ self.decay = float(decay)
120
+ self.shadow = {
121
+ name: parameter.detach().clone()
122
+ for name, parameter in model.named_parameters()
123
+ if parameter.requires_grad
124
+ }
125
+
126
+ @torch.no_grad()
127
+ def update(self, model: torch.nn.Module) -> None:
128
+ parameters = dict(model.named_parameters())
129
+ for name, value in self.shadow.items():
130
+ value.lerp_(parameters[name].detach(), 1.0 - self.decay)
131
+
132
+ @contextmanager
133
+ def apply(self, model: torch.nn.Module):
134
+ parameters = dict(model.named_parameters())
135
+ backup = {
136
+ name: parameters[name].detach().clone() for name in self.shadow
137
+ }
138
+ with torch.no_grad():
139
+ for name, value in self.shadow.items():
140
+ parameters[name].copy_(value)
141
+ try:
142
+ yield
143
+ finally:
144
+ with torch.no_grad():
145
+ for name, value in backup.items():
146
+ parameters[name].copy_(value)
147
+
148
+ def state_dict(self) -> dict[str, Any]:
149
+ return {
150
+ "decay": self.decay,
151
+ "shadow": {
152
+ name: value.detach().cpu() for name, value in self.shadow.items()
153
+ },
154
+ }
155
+
156
+ def load_state_dict(self, state: dict[str, Any], device: torch.device) -> None:
157
+ self.decay = float(state["decay"])
158
+ if set(state["shadow"]) != set(self.shadow):
159
+ raise ValueError("EMA parameters do not match the resumed model")
160
+ self.shadow = {
161
+ name: value.to(device=device) for name, value in state["shadow"].items()
162
+ }
163
+
164
+
165
+ def _init_distributed(device_name: str, backend: str) -> DistributedContext:
166
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
167
+ if world_size == 1:
168
+ if device_name.startswith("cuda") and torch.cuda.is_available():
169
+ torch.cuda.set_device(0)
170
+ return DistributedContext()
171
+ if not torch.distributed.is_available():
172
+ raise RuntimeError("torch.distributed is required for torchrun training")
173
+ rank = int(os.environ["RANK"])
174
+ local_rank = int(os.environ.get("LOCAL_RANK", rank))
175
+ if device_name.startswith("cuda"):
176
+ if not torch.cuda.is_available():
177
+ raise RuntimeError("torchrun requested CUDA/DCU, but it is unavailable")
178
+ torch.cuda.set_device(local_rank)
179
+ torch.distributed.init_process_group(
180
+ backend=backend, rank=rank, world_size=world_size
181
+ )
182
+ return DistributedContext(rank, world_size, local_rank)
183
+
184
+
185
+ def _close_distributed(context: DistributedContext) -> None:
186
+ if context.enabled and torch.distributed.is_initialized():
187
+ torch.distributed.barrier()
188
+ torch.distributed.destroy_process_group()
189
+
190
+
191
+ def _loader(
192
+ path: str | list[str],
193
+ batch_size: int,
194
+ workers: int,
195
+ max_samples: int | None = None,
196
+ context: DistributedContext | None = None,
197
+ train: bool = False,
198
+ seed: int = 0,
199
+ max_atoms: int | None = None,
200
+ load_balancing: str | bool | None = "atoms",
201
+ load_balancing_on_error: str = "raise",
202
+ device: torch.device | None = None,
203
+ ) -> DataLoader:
204
+ """Build the shared OneScience FairChem-style ASE data loader."""
205
+
206
+ dataset = AseDBDataset(
207
+ {
208
+ "src": path,
209
+ "a2g_args": {
210
+ "r_edges": False,
211
+ "r_energy": True,
212
+ "r_forces": True,
213
+ "r_stress": True,
214
+ },
215
+ }
216
+ )
217
+ indices = list(range(len(dataset)))
218
+ if max_atoms is not None:
219
+ if not dataset.metadata_hasattr("natoms"):
220
+ raise ValueError("max_atoms requires dataset metadata.npz with natoms")
221
+ natoms = dataset.get_metadata("natoms", indices)
222
+ indices = [
223
+ index for index, count in zip(indices, natoms) if int(count) <= max_atoms
224
+ ]
225
+ if not indices:
226
+ raise ValueError(f"max_atoms={max_atoms} filtered every sample")
227
+ if max_samples is not None:
228
+ sample_count = min(max_samples, len(indices))
229
+ generator = torch.Generator().manual_seed(seed)
230
+ order = torch.randperm(len(indices), generator=generator)[:sample_count]
231
+ indices = [indices[index] for index in order.tolist()]
232
+ if len(indices) != len(dataset):
233
+ dataset = MetadataSubset(dataset, indices, metadata={})
234
+
235
+ context = context or DistributedContext()
236
+ if load_balancing:
237
+ batch_sampler = BalancedBatchSampler(
238
+ dataset,
239
+ batch_size=batch_size,
240
+ num_replicas=context.world_size,
241
+ rank=context.rank,
242
+ device=device,
243
+ seed=seed,
244
+ mode=load_balancing,
245
+ shuffle=train,
246
+ on_error=load_balancing_on_error,
247
+ drop_last=False,
248
+ )
249
+ return DataLoader(
250
+ dataset,
251
+ batch_sampler=batch_sampler,
252
+ num_workers=workers,
253
+ collate_fn=lambda items: data_list_collater(items, otf_graph=True),
254
+ generator=torch.Generator().manual_seed(seed),
255
+ )
256
+
257
+ sampler = None
258
+ if context.enabled:
259
+ sampler = DistributedSampler(
260
+ dataset,
261
+ num_replicas=context.world_size,
262
+ rank=context.rank,
263
+ shuffle=train,
264
+ drop_last=False,
265
+ seed=seed,
266
+ )
267
+ return DataLoader(
268
+ dataset,
269
+ batch_size=batch_size,
270
+ shuffle=sampler is None and train,
271
+ sampler=sampler,
272
+ num_workers=workers,
273
+ collate_fn=lambda items: data_list_collater(items, otf_graph=True),
274
+ generator=torch.Generator().manual_seed(seed),
275
+ )
276
+
277
+
278
+ def _expand_path(value: str | list[str] | None) -> str | list[str] | None:
279
+ if value is None:
280
+ return None
281
+ if isinstance(value, list):
282
+ return [_expand_path(item) for item in value]
283
+ return os.path.expandvars(os.path.expanduser(str(value)))
284
+
285
+
286
+ def _normalize_config(raw: dict[str, Any]) -> dict[str, Any]:
287
+ config = copy.deepcopy(raw)
288
+ mode = config.get("mode")
289
+ if mode is None:
290
+ mode = "resume_training" if config.get("resume") else None
291
+ mode = mode or (
292
+ "init_from_checkpoint"
293
+ if config.get("initialization_checkpoint") or config.get("checkpoint")
294
+ else "train_from_scratch"
295
+ )
296
+ try:
297
+ config["mode"] = MODE_ALIASES[str(mode)]
298
+ except KeyError as error:
299
+ raise ValueError(f"unsupported training mode: {mode!r}") from error
300
+
301
+ if config.get("checkpoint") and not config.get("initialization_checkpoint"):
302
+ config["initialization_checkpoint"] = config["checkpoint"]
303
+ for key in ("initialization_checkpoint", "resume", "train", "val", "output"):
304
+ config[key] = _expand_path(config.get(key))
305
+ config["transforms_checkpoint"] = _expand_path(
306
+ config.get("transforms_checkpoint")
307
+ )
308
+
309
+ optimizer = config.setdefault("optimizer", {})
310
+ optimizer.setdefault("name", "AdamW")
311
+ optimizer.setdefault("lr", config.get("lr", 5.0e-5))
312
+ optimizer.setdefault("weight_decay", config.get("weight_decay", 1.0e-3))
313
+ optimizer.setdefault("betas", config.get("betas", [0.9, 0.98]))
314
+ optimizer.setdefault("eps", config.get("eps", 1.0e-6))
315
+
316
+ scheduler = config.setdefault("scheduler", {})
317
+ scheduler.setdefault("name", "cosine")
318
+ scheduler.setdefault("warmup_factor", 0.0)
319
+ scheduler.setdefault("warmup_epochs", 0.1)
320
+ scheduler.setdefault("lr_min_factor", 0.01)
321
+
322
+ config.setdefault("device", "cuda")
323
+ config.setdefault("backend", "nccl")
324
+ config.setdefault("seed", 0)
325
+ config.setdefault("epochs", 1)
326
+ config.setdefault("batch_size", 1)
327
+ config.setdefault("eval_batch_size", config["batch_size"])
328
+ config.setdefault("workers", 0)
329
+ config.setdefault("grad_accumulation_steps", 1)
330
+ config.setdefault("log_every_n_steps", 0)
331
+ config.setdefault("log_every_n_validation_batches", 0)
332
+ config.setdefault("clip_grad_norm", 100.0)
333
+ config.setdefault("ema_decay", 0.999)
334
+ config.setdefault("amp", False)
335
+ config.setdefault("amp_dtype", "float16")
336
+ config.setdefault("amp_init_scale", 65536.0)
337
+ config.setdefault("load_balancing", "atoms")
338
+ config.setdefault("load_balancing_on_error", "raise")
339
+ config.setdefault("ddp_find_unused_parameters", False)
340
+ return config
341
+
342
+
343
+ def _loss_specs(config: dict[str, Any]) -> list[LossSpec]:
344
+ raw = config.get("losses") or config.get("loss_functions")
345
+ if raw is None:
346
+ raw = {
347
+ "energy": {
348
+ "fn": "per_atom_mae",
349
+ "coefficient": config.get("energy_weight", 1.0),
350
+ },
351
+ "forces": {
352
+ "fn": "l2mae",
353
+ "coefficient": config.get("force_weight", 10.0),
354
+ },
355
+ "stress": {
356
+ "fn": "mae",
357
+ "coefficient": config.get("stress_weight", 0.0),
358
+ },
359
+ }
360
+ if isinstance(raw, list):
361
+ merged = {}
362
+ for item in raw:
363
+ merged.update(item)
364
+ raw = merged
365
+ specs = []
366
+ for name, values in raw.items():
367
+ coefficient = float(values.get("coefficient", values.get("weight", 1.0)))
368
+ if coefficient == 0.0:
369
+ continue
370
+ specs.append(
371
+ LossSpec(
372
+ name=name,
373
+ function=str(values.get("fn", values.get("function", "mae"))),
374
+ coefficient=coefficient,
375
+ free_atoms_only=bool(values.get("free_atoms_only", False)),
376
+ )
377
+ )
378
+ if not specs:
379
+ raise ValueError("at least one nonzero target loss is required")
380
+ unknown = {spec.name for spec in specs} - {"energy", "forces", "stress"}
381
+ if unknown:
382
+ raise ValueError(f"unsupported loss targets: {sorted(unknown)}")
383
+ return specs
384
+
385
+
386
+ def _optional_float(value: Any) -> float | None:
387
+ return None if value is None else float(value)
388
+
389
+
390
+ def _denoising_pos_params(config: dict[str, Any]) -> DenoisingPosParams:
391
+ values = config.get("denoising_pos_params") or {}
392
+ return DenoisingPosParams(
393
+ enabled=bool(config.get("use_denoising_pos", False)),
394
+ prob=float(values.get("prob", 0.0)),
395
+ fixed_noise_std=bool(values.get("fixed_noise_std", True)),
396
+ std=float(values.get("std", 0.025)),
397
+ corrupt_ratio=_optional_float(values.get("corrupt_ratio")),
398
+ all_atoms=bool(values.get("all_atoms", False)),
399
+ min_num_atoms=(
400
+ None
401
+ if values.get("min_num_atoms") is None
402
+ else int(values["min_num_atoms"])
403
+ ),
404
+ strict_max_ratio=_optional_float(values.get("strict_max_ratio")),
405
+ max_force_norm=_optional_float(values.get("max_force_norm")),
406
+ max_stress_norm=_optional_float(values.get("max_stress_norm")),
407
+ max_mean_force_norm=_optional_float(values.get("max_mean_force_norm")),
408
+ coefficient=float(config.get("denoising_pos_coefficient", 1.0)),
409
+ )
410
+
411
+
412
+ def _transforms_from_config(config: dict[str, Any]) -> EquiformerV3CheckpointTransforms:
413
+ transform_config = config.get("transforms", {})
414
+ normalizers = {}
415
+ for name, values in transform_config.get("normalizers", {}).items():
416
+ values = copy.deepcopy(values)
417
+ if "file" in values:
418
+ values["file"] = _expand_path(values["file"])
419
+ normalizers[name] = create_normalizer(**values)
420
+ elementrefs = {}
421
+ for name, values in transform_config.get("element_references", {}).items():
422
+ values = copy.deepcopy(values)
423
+ if "values" in values:
424
+ elementrefs[name] = LinearReferences(
425
+ torch.as_tensor(values["values"], dtype=torch.float32)
426
+ )
427
+ else:
428
+ if "file" in values:
429
+ values["file"] = _expand_path(values["file"])
430
+ elementrefs[name] = create_element_references(**values)
431
+ return EquiformerV3CheckpointTransforms(normalizers, elementrefs)
432
+
433
+
434
+ def _training_transforms(
435
+ config: dict[str, Any], checkpoint_path: str | Path | None
436
+ ) -> EquiformerV3CheckpointTransforms:
437
+ """Resolve target transforms independently from model initialization weights.
438
+
439
+ Initialization checkpoints carry the statistics used by their training
440
+ dataset. Fine-tuning may instead point at a target-domain checkpoint or
441
+ override individual entries in ``transforms``. Resume deliberately keeps
442
+ the source checkpoint transforms unchanged and is validated separately.
443
+ """
444
+
445
+ source = config.get("transforms_checkpoint") or checkpoint_path
446
+ if source:
447
+ if config.get("clear_checkpoint_transforms"):
448
+ transforms = EquiformerV3CheckpointTransforms()
449
+ else:
450
+ transforms = EquiformerV3CheckpointTransforms.from_checkpoint(source)
451
+ else:
452
+ transforms = EquiformerV3CheckpointTransforms()
453
+
454
+ overrides = _transforms_from_config(config)
455
+ for name, module in overrides.normalizers.items():
456
+ transforms.normalizers[name] = module
457
+ for name, module in overrides.elementrefs.items():
458
+ transforms.elementrefs[name] = module
459
+ return transforms
460
+
461
+
462
+ def _construct_model(model_config: dict[str, Any]) -> torch.nn.Module:
463
+ import onescience.models.equiformer_v3 # noqa: F401
464
+
465
+ kwargs = copy.deepcopy(model_config)
466
+ name = kwargs.pop("name", None)
467
+ if name not in {"equiformer_v3", "equiformer_v3_dens"}:
468
+ raise ValueError(f"unsupported Equiformer V3 model name: {name!r}")
469
+ return registry.get_model_class(name)(**kwargs)
470
+
471
+
472
+ def _checkpoint_document(path: str | Path) -> dict[str, Any]:
473
+ path = Path(path)
474
+ if not path.is_file():
475
+ raise FileNotFoundError(path)
476
+ document = torch.load(path, map_location="cpu", weights_only=False)
477
+ if "config" not in document or "state_dict" not in document:
478
+ raise ValueError(f"invalid Equiformer V3 checkpoint: {path}")
479
+ return document
480
+
481
+
482
+ def _reset_module(module: torch.nn.Module) -> None:
483
+ for child in module.modules():
484
+ if hasattr(child, "reset_parameters"):
485
+ child.reset_parameters()
486
+
487
+
488
+ def _initialize_model(
489
+ config: dict[str, Any],
490
+ ) -> tuple[
491
+ torch.nn.Module,
492
+ EquiformerV3CheckpointTransforms,
493
+ dict[str, Any],
494
+ dict[str, Any] | None,
495
+ ]:
496
+ mode = config["mode"]
497
+ if mode == "train_from_scratch":
498
+ if not config.get("model"):
499
+ raise ValueError("train_from_scratch requires a model mapping")
500
+ model_config = copy.deepcopy(config["model"])
501
+ return (
502
+ _construct_model(model_config),
503
+ _training_transforms(config, None),
504
+ model_config,
505
+ None,
506
+ )
507
+
508
+ path = config.get("resume") if mode == "resume_training" else config.get(
509
+ "initialization_checkpoint"
510
+ )
511
+ if not path:
512
+ required = "resume" if mode == "resume_training" else "initialization_checkpoint"
513
+ raise ValueError(f"{mode} requires {required}")
514
+ document = _checkpoint_document(path)
515
+ source_model_config = copy.deepcopy(document["config"]["model"])
516
+ model_config = source_model_config | copy.deepcopy(config.get("model", {}))
517
+
518
+ if mode == "resume_training":
519
+ model = _construct_model(model_config)
520
+ state = document.get("training_state_dict", document["state_dict"])
521
+ model.load_state_dict(state, strict=True)
522
+ elif model_config == source_model_config:
523
+ model = load_equiformer_v3_checkpoint(path)
524
+ else:
525
+ model = _construct_model(model_config)
526
+ source_state = document["state_dict"]
527
+ excluded = tuple(config.get("exclude_initialization_prefixes", []))
528
+ compatible = {
529
+ key.removeprefix("_orig_mod."): value
530
+ for key, value in source_state.items()
531
+ if not key.removeprefix("_orig_mod.").startswith(excluded)
532
+ and key.removeprefix("_orig_mod.") in model.state_dict()
533
+ and model.state_dict()[key.removeprefix("_orig_mod.")].shape == value.shape
534
+ }
535
+ model.load_state_dict(compatible, strict=False)
536
+ print(
537
+ f"initialized {len(compatible)}/{len(model.state_dict())} model tensors "
538
+ f"from {path}",
539
+ flush=True,
540
+ )
541
+ if config.get("reset_energy_head"):
542
+ _reset_module(model.energy_block)
543
+
544
+ if mode == "resume_training":
545
+ transforms = EquiformerV3CheckpointTransforms.from_checkpoint(path)
546
+ else:
547
+ transforms = _training_transforms(config, path)
548
+ return model, transforms, model_config, document
549
+
550
+
551
+ def _target_tensor(name: str, batch) -> torch.Tensor:
552
+ if not hasattr(batch, name):
553
+ raise ValueError(f"the batch does not contain required {name} labels")
554
+ return getattr(batch, name)
555
+
556
+
557
+ def _masked_tensors(
558
+ prediction: torch.Tensor,
559
+ target: torch.Tensor,
560
+ spec: LossSpec,
561
+ batch,
562
+ ) -> tuple[torch.Tensor, torch.Tensor]:
563
+ if spec.name == "forces" and spec.free_atoms_only:
564
+ if not hasattr(batch, "fixed"):
565
+ raise ValueError("free_atoms_only requires a fixed atom mask")
566
+ mask = batch.fixed.reshape(-1) == 0
567
+ prediction = prediction[mask]
568
+ target = target[mask]
569
+ return prediction, target
570
+
571
+
572
+ def _loss_functions(specs: list[LossSpec]) -> dict[str, DDPLoss]:
573
+ """Build the same DDP-aware reductions used by the official trainer."""
574
+
575
+ return {
576
+ spec.name: DDPLoss(spec.function, reduction="mean") for spec in specs
577
+ }
578
+
579
+
580
+ def _loss(
581
+ prediction: dict[str, torch.Tensor],
582
+ batch,
583
+ specs: list[LossSpec],
584
+ transforms: EquiformerV3CheckpointTransforms,
585
+ loss_functions: dict[str, DDPLoss] | None = None,
586
+ dens_params: DenoisingPosParams | None = None,
587
+ ) -> tuple[torch.Tensor, dict[str, float]]:
588
+ loss_functions = loss_functions or _loss_functions(specs)
589
+ total = next(iter(prediction.values())).new_zeros(())
590
+ components = {}
591
+ for spec in specs:
592
+ if spec.name not in prediction:
593
+ raise ValueError(f"the model did not return required {spec.name} output")
594
+ target = _target_tensor(spec.name, batch)
595
+ normalized = transforms.normalize_target(
596
+ spec.name, target, prediction[spec.name], batch
597
+ )
598
+ if spec.name == "forces" and _is_dens_batch(batch):
599
+ if dens_params is None:
600
+ raise RuntimeError("DeNS batch requires denoising parameters")
601
+ pred = prediction[spec.name]
602
+ noise_mask = batch.noise_mask.reshape(-1, 1).bool()
603
+ denoising_target = batch.noise_vec.to(pred) / dens_params.std
604
+ hybrid_target = torch.where(noise_mask, denoising_target, normalized)
605
+ selection = torch.ones(
606
+ pred.shape[0], dtype=torch.bool, device=pred.device
607
+ )
608
+ if spec.free_atoms_only:
609
+ if not hasattr(batch, "fixed"):
610
+ raise ValueError("free_atoms_only requires a fixed atom mask")
611
+ selection = batch.fixed.reshape(-1) == 0
612
+ if dens_params.all_atoms:
613
+ selection = selection | noise_mask.reshape(-1)
614
+ if not bool(selection.any()):
615
+ raise RuntimeError("DeNS batch has no atoms selected for force loss")
616
+
617
+ atomwise = torch.linalg.vector_norm(pred - hybrid_target, dim=-1)
618
+ coefficients = torch.where(
619
+ noise_mask.reshape(-1),
620
+ atomwise.new_full(atomwise.shape, dens_params.coefficient),
621
+ atomwise.new_full(atomwise.shape, spec.coefficient),
622
+ )
623
+ value = (atomwise[selection] * coefficients[selection]).mean()
624
+ total = total + value
625
+ force_mask = selection & ~noise_mask.reshape(-1)
626
+ dens_mask = selection & noise_mask.reshape(-1)
627
+ if bool(force_mask.any()):
628
+ components[f"{spec.name}_{spec.function}"] = float(
629
+ atomwise[force_mask].mean().detach()
630
+ )
631
+ if bool(dens_mask.any()):
632
+ components["denoising_pos_l2mae"] = float(
633
+ atomwise[dens_mask].mean().detach()
634
+ )
635
+ components["forces_dens_hybrid_l2mae"] = float(value.detach())
636
+ continue
637
+ pred, normalized = _masked_tensors(
638
+ prediction[spec.name], normalized, spec, batch
639
+ )
640
+ value = loss_functions[spec.name](pred, normalized, natoms=batch.natoms)
641
+ total = total + spec.coefficient * value
642
+ components[f"{spec.name}_{spec.function}"] = float(value.detach())
643
+ return total, components
644
+
645
+
646
+ @torch.no_grad()
647
+ def _physical_metrics(
648
+ prediction: dict[str, torch.Tensor],
649
+ batch,
650
+ specs: list[LossSpec],
651
+ transforms: EquiformerV3CheckpointTransforms,
652
+ dens_params: DenoisingPosParams | None = None,
653
+ ) -> dict[str, tuple[float, int]]:
654
+ metrics = {}
655
+ for spec in specs:
656
+ if spec.name == "forces" and _is_dens_batch(batch):
657
+ if dens_params is None:
658
+ raise RuntimeError("DeNS batch requires denoising parameters")
659
+ prediction_tensor = prediction[spec.name].detach()
660
+ noise_mask = batch.noise_mask.reshape(-1).bool()
661
+ selection = torch.ones_like(noise_mask)
662
+ if spec.free_atoms_only:
663
+ selection = batch.fixed.reshape(-1) == 0
664
+ if dens_params.all_atoms:
665
+ selection = selection | noise_mask
666
+ force_mask = selection & ~noise_mask
667
+ dens_mask = selection & noise_mask
668
+
669
+ physical_force = transforms.denormalize_prediction(
670
+ spec.name, prediction_tensor, batch
671
+ )
672
+ force_error = physical_force[force_mask] - batch.forces[force_mask]
673
+ dens_prediction = prediction_tensor[dens_mask] * dens_params.std
674
+ dens_error = dens_prediction - batch.noise_vec[dens_mask]
675
+ metrics["denoising_force_mae"] = (
676
+ float(force_error.abs().sum()),
677
+ force_error.numel(),
678
+ )
679
+ metrics["denoising_force_l2mae"] = (
680
+ float(torch.linalg.vector_norm(force_error, dim=-1).sum()),
681
+ force_error.shape[0],
682
+ )
683
+ metrics["denoising_pos_mae"] = (
684
+ float(dens_error.abs().sum()),
685
+ dens_error.numel(),
686
+ )
687
+ metrics["denoising_pos_l2mae"] = (
688
+ float(torch.linalg.vector_norm(dens_error, dim=-1).sum()),
689
+ dens_error.shape[0],
690
+ )
691
+ metrics["dens_corrupted_atom_fraction"] = (
692
+ float(dens_mask.sum()),
693
+ int(selection.sum()),
694
+ )
695
+ continue
696
+ physical = transforms.denormalize_prediction(
697
+ spec.name, prediction[spec.name].detach(), batch
698
+ )
699
+ target = _target_tensor(spec.name, batch).reshape_as(physical)
700
+ physical, target = _masked_tensors(physical, target, spec, batch)
701
+ error = physical - target
702
+ absolute_error = error.abs()
703
+ metrics[f"{spec.name}_mae"] = (
704
+ float(absolute_error.sum()),
705
+ absolute_error.numel(),
706
+ )
707
+ if spec.name == "energy":
708
+ shape = (-1,) + (1,) * (error.ndim - 1)
709
+ per_atom = error / batch.natoms.to(error).reshape(shape)
710
+ metrics["energy_per_atom_mae"] = (
711
+ float(per_atom.abs().sum()),
712
+ per_atom.numel(),
713
+ )
714
+ elif error.ndim >= 2:
715
+ vector_error = torch.linalg.vector_norm(error, dim=-1)
716
+ metrics[f"{spec.name}_l2mae"] = (
717
+ float(vector_error.sum()),
718
+ vector_error.numel(),
719
+ )
720
+ return metrics
721
+
722
+
723
+ def _reduce_metrics(
724
+ sums: dict[str, tuple[float, int]],
725
+ device: torch.device,
726
+ context: DistributedContext,
727
+ ) -> dict[str, float]:
728
+ if not sums:
729
+ raise RuntimeError("the dataset contains no samples")
730
+ names = sorted(sums)
731
+ if context.enabled:
732
+ rank_names: list[list[str] | None] = [None] * context.world_size
733
+ torch.distributed.all_gather_object(rank_names, names)
734
+ names = sorted(
735
+ {
736
+ name
737
+ for gathered_names in rank_names
738
+ if gathered_names is not None
739
+ for name in gathered_names
740
+ }
741
+ )
742
+ values = torch.tensor(
743
+ [
744
+ *(sums.get(name, (0.0, 0))[0] for name in names),
745
+ *(sums.get(name, (0.0, 0))[1] for name in names),
746
+ ],
747
+ device=device,
748
+ dtype=torch.float64,
749
+ )
750
+ if context.enabled:
751
+ torch.distributed.all_reduce(values, op=torch.distributed.ReduceOp.SUM)
752
+ split = len(names)
753
+ reduced = {}
754
+ for index, name in enumerate(names):
755
+ count = values[split + index].item()
756
+ if count <= 0:
757
+ continue
758
+ reduced[name] = values[index].item() / count
759
+ return reduced
760
+
761
+
762
+ def _collect_batch_metrics(
763
+ sums: dict[str, tuple[float, int]],
764
+ loss: torch.Tensor,
765
+ components: dict[str, float],
766
+ physical: dict[str, tuple[float, int]],
767
+ ) -> None:
768
+ values = {
769
+ "loss": (float(loss.detach()), 1),
770
+ **{
771
+ f"normalized_{name}": (value, 1) for name, value in components.items()
772
+ },
773
+ **physical,
774
+ }
775
+ for name, (total, count) in values.items():
776
+ previous_total, previous_count = sums.get(name, (0.0, 0))
777
+ sums[name] = previous_total + total, previous_count + count
778
+
779
+
780
+ def _unwrap(model: torch.nn.Module) -> torch.nn.Module:
781
+ return model.module if isinstance(model, DistributedDataParallel) else model
782
+
783
+
784
+ def _is_dens_batch(batch) -> bool:
785
+ value = getattr(batch, "denoising_pos_forward", False)
786
+ if torch.is_tensor(value):
787
+ return bool(value.reshape(-1)[0].item())
788
+ return bool(value)
789
+
790
+
791
+ def _graph_max(
792
+ values: torch.Tensor, batch_index: torch.Tensor, graph_count: int
793
+ ) -> torch.Tensor:
794
+ result = values.new_full((graph_count,), float("-inf"))
795
+ return result.scatter_reduce_(
796
+ 0, batch_index, values, reduce="amax", include_self=True
797
+ )
798
+
799
+
800
+ def _apply_graph_filter(
801
+ graph_mask: torch.Tensor,
802
+ dens_batch_mask: torch.Tensor,
803
+ noise_mask: torch.Tensor,
804
+ noise_vec: torch.Tensor,
805
+ batch_index: torch.Tensor,
806
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
807
+ dens_batch_mask = dens_batch_mask & graph_mask
808
+ atom_mask = graph_mask[batch_index]
809
+ noise_mask = noise_mask & atom_mask
810
+ noise_vec = noise_vec * atom_mask.reshape(-1, 1)
811
+ return dens_batch_mask, noise_mask, noise_vec
812
+
813
+
814
+ def _add_gaussian_noise_to_position(batch, params: DenoisingPosParams):
815
+ """Apply the official Equiformer V3 DeNS corruption to one collated batch."""
816
+
817
+ graph_count = int(batch.natoms.numel())
818
+ batch_index = batch.batch.long()
819
+ noise_vec = torch.empty_like(batch.pos).normal_(mean=0.0, std=params.std)
820
+ if params.corrupt_ratio is None:
821
+ noise_mask = torch.ones(
822
+ batch.pos.shape[0], dtype=torch.bool, device=batch.pos.device
823
+ )
824
+ else:
825
+ noise_mask = (
826
+ torch.rand(
827
+ batch.pos.shape[0], dtype=batch.pos.dtype, device=batch.pos.device
828
+ )
829
+ < params.corrupt_ratio
830
+ )
831
+ noise_vec = noise_vec * noise_mask.reshape(-1, 1)
832
+ dens_batch_mask = torch.ones(
833
+ graph_count, dtype=torch.bool, device=batch.pos.device
834
+ )
835
+
836
+ if hasattr(batch, "skip_dens"):
837
+ graph_mask = ~batch.skip_dens.reshape(-1).bool()
838
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
839
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
840
+ )
841
+ if params.min_num_atoms is not None:
842
+ graph_mask = batch.natoms >= params.min_num_atoms
843
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
844
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
845
+ )
846
+ if params.strict_max_ratio is not None:
847
+ corrupted = batch.pos.new_zeros(graph_count)
848
+ corrupted.index_add_(0, batch_index, noise_mask.to(batch.pos.dtype))
849
+ graph_mask = corrupted <= batch.natoms.to(corrupted) * params.strict_max_ratio
850
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
851
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
852
+ )
853
+ if params.max_force_norm is not None:
854
+ graph_mask = _graph_max(
855
+ torch.linalg.vector_norm(batch.forces, dim=-1),
856
+ batch_index,
857
+ graph_count,
858
+ ) <= params.max_force_norm
859
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
860
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
861
+ )
862
+ if params.max_stress_norm is not None:
863
+ graph_mask = (
864
+ torch.linalg.vector_norm(batch.stress.reshape(graph_count, -1), dim=-1)
865
+ <= params.max_stress_norm
866
+ )
867
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
868
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
869
+ )
870
+ if params.max_mean_force_norm is not None:
871
+ force_sum = batch.forces.new_zeros((graph_count, batch.forces.shape[-1]))
872
+ force_sum.index_add_(0, batch_index, batch.forces)
873
+ graph_mask = (
874
+ torch.linalg.vector_norm(force_sum, dim=-1)
875
+ <= params.max_mean_force_norm
876
+ )
877
+ dens_batch_mask, noise_mask, noise_vec = _apply_graph_filter(
878
+ graph_mask, dens_batch_mask, noise_mask, noise_vec, batch_index
879
+ )
880
+
881
+ if params.all_atoms:
882
+ position_mask = torch.ones_like(noise_mask)
883
+ else:
884
+ if not hasattr(batch, "fixed"):
885
+ raise ValueError("DeNS with all_atoms=false requires a fixed atom mask")
886
+ position_mask = batch.fixed.reshape(-1) == 0
887
+ batch.pos = batch.pos + noise_vec * position_mask.reshape(-1, 1)
888
+ batch.noise_vec = noise_vec
889
+ batch.noise_mask = noise_mask
890
+ batch.denoising_pos_forward = True
891
+ batch.dens_batch_mask = dens_batch_mask
892
+ return batch
893
+
894
+
895
+ def _should_apply_dens(
896
+ params: DenoisingPosParams,
897
+ device: torch.device,
898
+ context: DistributedContext,
899
+ ) -> bool:
900
+ if not params.enabled or params.prob <= 0.0:
901
+ return False
902
+ decision = torch.rand((), device=device) < params.prob
903
+ if context.enabled:
904
+ torch.distributed.broadcast(decision, src=0)
905
+ return bool(decision.item())
906
+
907
+
908
+ def _run_train_epoch(
909
+ model: torch.nn.Module,
910
+ loader: DataLoader,
911
+ device: torch.device,
912
+ optimizer: torch.optim.Optimizer,
913
+ scheduler: torch.optim.lr_scheduler.LambdaLR,
914
+ ema: ModelEMA | None,
915
+ specs: list[LossSpec],
916
+ loss_functions: dict[str, DDPLoss],
917
+ transforms: EquiformerV3CheckpointTransforms,
918
+ context: DistributedContext,
919
+ scaler: torch.GradScaler | None,
920
+ amp_dtype: torch.dtype,
921
+ grad_accumulation_steps: int,
922
+ clip_grad_norm: float | None,
923
+ global_step: int,
924
+ max_steps: int | None,
925
+ epoch: int,
926
+ log_every_n_steps: int,
927
+ dens_params: DenoisingPosParams,
928
+ ) -> tuple[dict[str, float], int]:
929
+ model.train()
930
+ optimizer.zero_grad(set_to_none=True)
931
+ sums: dict[str, tuple[float, int]] = {}
932
+ progress_sums: dict[str, tuple[float, int]] = {}
933
+ progress_start_step = global_step
934
+ pending = 0
935
+ completed_updates = 0
936
+ skipped_optimizer_steps = 0
937
+ updates_per_epoch = _updates_per_epoch(len(loader), grad_accumulation_steps)
938
+ batches_to_process = updates_per_epoch * grad_accumulation_steps
939
+ for index, batch in enumerate(loader):
940
+ if index >= batches_to_process:
941
+ break
942
+ batch = batch.to(device)
943
+ if _should_apply_dens(dens_params, device, context):
944
+ batch = _add_gaussian_noise_to_position(batch, dens_params)
945
+ synchronize_gradients = pending + 1 == grad_accumulation_steps
946
+ with _gradient_sync_context(model, synchronize_gradients):
947
+ with torch.autocast(
948
+ device_type=device.type,
949
+ enabled=scaler is not None,
950
+ dtype=amp_dtype,
951
+ ):
952
+ prediction = model(batch)
953
+ loss, components = _loss(
954
+ prediction,
955
+ batch,
956
+ specs,
957
+ transforms,
958
+ loss_functions,
959
+ dens_params,
960
+ )
961
+ physical = _physical_metrics(
962
+ prediction, batch, specs, transforms, dens_params
963
+ )
964
+ backward_loss = loss / grad_accumulation_steps
965
+ if scaler is None:
966
+ backward_loss.backward()
967
+ else:
968
+ scaler.scale(backward_loss).backward()
969
+ _collect_batch_metrics(sums, loss, components, physical)
970
+ _collect_batch_metrics(progress_sums, loss, components, physical)
971
+ pending += 1
972
+ if pending != grad_accumulation_steps:
973
+ continue
974
+ if clip_grad_norm:
975
+ if scaler is not None:
976
+ scaler.unscale_(optimizer)
977
+ torch.nn.utils.clip_grad_norm_(model.parameters(), clip_grad_norm)
978
+ if scaler is None:
979
+ optimizer.step()
980
+ optimizer_step_succeeded = True
981
+ else:
982
+ previous_scale = float(scaler.get_scale())
983
+ scaler.step(optimizer)
984
+ scaler.update()
985
+ optimizer_step_succeeded = _amp_step_succeeded(
986
+ previous_scale, float(scaler.get_scale())
987
+ )
988
+ optimizer.zero_grad(set_to_none=True)
989
+ pending = 0
990
+ if not optimizer_step_succeeded:
991
+ skipped_optimizer_steps += 1
992
+ continue
993
+ scheduler.step()
994
+ if ema is not None:
995
+ ema.update(_unwrap(model))
996
+ global_step += 1
997
+ completed_updates += 1
998
+ reached_max_steps = max_steps is not None and global_step >= max_steps
999
+ should_log = _should_log_progress(
1000
+ global_step,
1001
+ completed_updates,
1002
+ updates_per_epoch,
1003
+ log_every_n_steps,
1004
+ reached_max_steps,
1005
+ )
1006
+ if should_log:
1007
+ window_metrics = _reduce_metrics(progress_sums, device, context)
1008
+ if context.is_main:
1009
+ print(
1010
+ json.dumps(
1011
+ {
1012
+ "event": "train_progress",
1013
+ "epoch": epoch,
1014
+ "epoch_step": completed_updates,
1015
+ "epoch_steps": updates_per_epoch,
1016
+ "global_step": global_step,
1017
+ "lr": float(optimizer.param_groups[0]["lr"]),
1018
+ "window_steps": global_step - progress_start_step,
1019
+ "window_metrics": window_metrics,
1020
+ },
1021
+ sort_keys=True,
1022
+ ),
1023
+ flush=True,
1024
+ )
1025
+ progress_sums = {}
1026
+ progress_start_step = global_step
1027
+ if reached_max_steps:
1028
+ break
1029
+ metrics = _reduce_metrics(sums, device, context)
1030
+ metrics["lr"] = float(optimizer.param_groups[0]["lr"])
1031
+ metrics["skipped_optimizer_steps"] = float(skipped_optimizer_steps)
1032
+ return metrics, global_step
1033
+
1034
+
1035
+ def _amp_step_succeeded(previous_scale: float, current_scale: float) -> bool:
1036
+ """A decreasing GradScaler scale means optimizer.step was skipped."""
1037
+
1038
+ return current_scale >= previous_scale
1039
+
1040
+
1041
+ @contextmanager
1042
+ def _gradient_sync_context(
1043
+ model: torch.nn.Module, synchronize_gradients: bool
1044
+ ):
1045
+ """Delay DDP reduction until the final microbatch in an update."""
1046
+
1047
+ if synchronize_gradients or not hasattr(model, "no_sync"):
1048
+ yield
1049
+ return
1050
+ with model.no_sync():
1051
+ yield
1052
+
1053
+
1054
+ def _updates_per_epoch(loader_batches: int, grad_accumulation_steps: int) -> int:
1055
+ """Return the upstream trainer's number of complete optimizer updates."""
1056
+
1057
+ updates = loader_batches // grad_accumulation_steps
1058
+ if updates < 1:
1059
+ raise ValueError(
1060
+ "grad_accumulation_steps exceeds the number of training batches; "
1061
+ "reduce it or provide more training samples"
1062
+ )
1063
+ return updates
1064
+
1065
+
1066
+ def _should_log_progress(
1067
+ global_step: int,
1068
+ completed: int,
1069
+ total: int,
1070
+ interval: int,
1071
+ reached_limit: bool = False,
1072
+ ) -> bool:
1073
+ """Log periodic progress plus the final update or batch in a phase."""
1074
+
1075
+ if interval <= 0:
1076
+ return False
1077
+ return global_step % interval == 0 or completed == total or reached_limit
1078
+
1079
+
1080
+ def _run_validation(
1081
+ model: torch.nn.Module,
1082
+ loader: DataLoader,
1083
+ device: torch.device,
1084
+ specs: list[LossSpec],
1085
+ loss_functions: dict[str, DDPLoss],
1086
+ transforms: EquiformerV3CheckpointTransforms,
1087
+ context: DistributedContext,
1088
+ amp: bool,
1089
+ amp_dtype: torch.dtype,
1090
+ epoch: int,
1091
+ log_every_n_batches: int,
1092
+ ) -> dict[str, float]:
1093
+ # Gradient models derive forces/stress from energy, so validation must keep
1094
+ # autograd enabled even though no parameter update is performed.
1095
+ model.eval()
1096
+ sums: dict[str, tuple[float, int]] = {}
1097
+ total_batches = len(loader)
1098
+ for batch_index, batch in enumerate(loader, start=1):
1099
+ batch = batch.to(device)
1100
+ with torch.autocast(
1101
+ device_type=device.type,
1102
+ enabled=amp,
1103
+ dtype=amp_dtype,
1104
+ ):
1105
+ prediction = model(batch)
1106
+ loss, components = _loss(
1107
+ prediction, batch, specs, transforms, loss_functions
1108
+ )
1109
+ physical = _physical_metrics(prediction, batch, specs, transforms)
1110
+ _collect_batch_metrics(sums, loss, components, physical)
1111
+ if context.is_main and _should_log_progress(
1112
+ batch_index,
1113
+ batch_index,
1114
+ total_batches,
1115
+ log_every_n_batches,
1116
+ ):
1117
+ print(
1118
+ json.dumps(
1119
+ {
1120
+ "event": "validation_progress",
1121
+ "epoch": epoch,
1122
+ "batch": batch_index,
1123
+ "batches": total_batches,
1124
+ },
1125
+ sort_keys=True,
1126
+ ),
1127
+ flush=True,
1128
+ )
1129
+ return _reduce_metrics(sums, device, context)
1130
+
1131
+
1132
+ def _cosine_scheduler(
1133
+ optimizer: torch.optim.Optimizer,
1134
+ scheduler_config: dict[str, Any],
1135
+ steps_per_epoch: int,
1136
+ epochs: int,
1137
+ max_steps: int | None,
1138
+ ) -> torch.optim.lr_scheduler.LambdaLR:
1139
+ if scheduler_config.get("name", "cosine").lower() not in {
1140
+ "cosine",
1141
+ "lambdalr",
1142
+ }:
1143
+ raise ValueError("only the official cosine LambdaLR scheduler is supported")
1144
+ del max_steps
1145
+ total_steps = max(1, steps_per_epoch * epochs)
1146
+ warmup_steps = int(
1147
+ float(scheduler_config.get("warmup_epochs", 0.0)) * steps_per_epoch
1148
+ )
1149
+ # Official full runs always have at least one warmup update. Keep bounded
1150
+ # smoke configurations away from the upstream zero-step division edge case.
1151
+ warmup_steps = max(1, min(warmup_steps, total_steps))
1152
+ warmup_factor = float(scheduler_config.get("warmup_factor", 0.0))
1153
+ minimum = float(scheduler_config.get("lr_min_factor", 0.01))
1154
+ lr_lambda = CosineLRLambda(
1155
+ warmup_epochs=warmup_steps,
1156
+ warmup_factor=warmup_factor,
1157
+ epochs=total_steps,
1158
+ lr_min_factor=minimum,
1159
+ )
1160
+ return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
1161
+
1162
+
1163
+ def _state_dict_cpu(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
1164
+ return {name: value.detach().cpu() for name, value in state.items()}
1165
+
1166
+
1167
+ def _inference_state_dict(model: torch.nn.Module, ema: ModelEMA | None) -> dict:
1168
+ state = _state_dict_cpu(model.state_dict())
1169
+ if ema is not None:
1170
+ for name, value in ema.shadow.items():
1171
+ state[name] = value.detach().cpu()
1172
+ return state
1173
+
1174
+
1175
+ def _save_checkpoint(
1176
+ output: Path,
1177
+ model: torch.nn.Module,
1178
+ transforms: EquiformerV3CheckpointTransforms,
1179
+ model_config: dict[str, Any],
1180
+ training_config: dict[str, Any],
1181
+ optimizer: torch.optim.Optimizer,
1182
+ scheduler: torch.optim.lr_scheduler.LambdaLR,
1183
+ ema: ModelEMA | None,
1184
+ epoch: int,
1185
+ global_step: int,
1186
+ history: list[dict[str, Any]],
1187
+ source_document: dict[str, Any] | None,
1188
+ scaler: torch.GradScaler | None = None,
1189
+ ) -> None:
1190
+ output.parent.mkdir(parents=True, exist_ok=True)
1191
+ source_metadata = copy.deepcopy((source_document or {}).get("metadata", {}))
1192
+ source_metadata.update(
1193
+ {
1194
+ "onescience_equiformer_v3_history": history,
1195
+ "training_mode": training_config["mode"],
1196
+ "source_checkpoint": training_config.get("initialization_checkpoint"),
1197
+ "resume_checkpoint": training_config.get("resume"),
1198
+ "global_step": global_step,
1199
+ "ema_decay": ema.decay if ema is not None else None,
1200
+ "amp": scaler is not None,
1201
+ }
1202
+ )
1203
+ document = {
1204
+ "config": {"model": copy.deepcopy(model_config), "training": training_config},
1205
+ "normalizers": {
1206
+ name: _state_dict_cpu(module.state_dict())
1207
+ for name, module in transforms.normalizers.items()
1208
+ },
1209
+ "elementrefs": {
1210
+ name: _state_dict_cpu(module.state_dict())
1211
+ for name, module in transforms.elementrefs.items()
1212
+ },
1213
+ "state_dict": _inference_state_dict(model, ema),
1214
+ "training_state_dict": _state_dict_cpu(model.state_dict()),
1215
+ "optimizer_state_dict": optimizer.state_dict(),
1216
+ "scheduler_state_dict": scheduler.state_dict(),
1217
+ "ema_state_dict": ema.state_dict() if ema is not None else None,
1218
+ "amp_state_dict": scaler.state_dict() if scaler is not None else None,
1219
+ "training_state": {"epoch": epoch, "global_step": global_step},
1220
+ "metadata": source_metadata,
1221
+ }
1222
+ torch.save(document, output)
1223
+ history_path = output.with_name(output.name + ".history.json")
1224
+ history_path.write_text(json.dumps(history, indent=2, sort_keys=True) + "\n")
1225
+
1226
+
1227
+ def _fit_transforms(
1228
+ config: dict[str, Any],
1229
+ transforms: EquiformerV3CheckpointTransforms,
1230
+ dataset,
1231
+ ) -> None:
1232
+ if config.get("fit_element_references"):
1233
+ fitted = fit_linear_references(
1234
+ targets=["energy"],
1235
+ dataset=dataset,
1236
+ batch_size=config["batch_size"],
1237
+ num_batches=config.get("fit_statistics_batches"),
1238
+ num_workers=config["workers"],
1239
+ log_metrics=False,
1240
+ shuffle=False,
1241
+ )
1242
+ transforms.elementrefs["energy"] = fitted["energy"]
1243
+ requested = config.get("fit_normalizers")
1244
+ if requested:
1245
+ targets = (
1246
+ [spec.name for spec in _loss_specs(config)]
1247
+ if requested is True
1248
+ else list(requested)
1249
+ )
1250
+ fitted = fit_normalizers(
1251
+ targets=targets,
1252
+ dataset=dataset,
1253
+ batch_size=config["batch_size"],
1254
+ num_batches=config.get("fit_statistics_batches"),
1255
+ num_workers=config["workers"],
1256
+ shuffle=False,
1257
+ element_references=dict(transforms.elementrefs),
1258
+ )
1259
+ for name, normalizer in fitted.items():
1260
+ transforms.normalizers[name] = normalizer
1261
+
1262
+
1263
+ def _parse_args() -> argparse.Namespace:
1264
+ parser = argparse.ArgumentParser(description=__doc__)
1265
+ parser.add_argument("--config", required=True, help="training YAML path")
1266
+ parser.add_argument("--mode", choices=sorted(MODE_ALIASES))
1267
+ parser.add_argument("--checkpoint", dest="initialization_checkpoint")
1268
+ parser.add_argument("--transforms-checkpoint")
1269
+ parser.add_argument("--resume")
1270
+ parser.add_argument("--train")
1271
+ parser.add_argument("--val")
1272
+ parser.add_argument("--output")
1273
+ parser.add_argument("--device")
1274
+ parser.add_argument("--epochs", type=int)
1275
+ parser.add_argument("--max-steps", type=int)
1276
+ parser.add_argument("--batch-size", type=int)
1277
+ parser.add_argument("--eval-batch-size", type=int)
1278
+ parser.add_argument("--workers", type=int)
1279
+ parser.add_argument("--max-train-samples", type=int)
1280
+ parser.add_argument("--max-val-samples", type=int)
1281
+ parser.add_argument("--max-atoms", type=int)
1282
+ parser.add_argument("--log-every-n-steps", type=int)
1283
+ parser.add_argument("--log-every-n-validation-batches", type=int)
1284
+ parser.add_argument("--seed", type=int)
1285
+ parser.add_argument(
1286
+ "--amp", action=argparse.BooleanOptionalAction, default=None
1287
+ )
1288
+ parser.add_argument("--amp-dtype", choices=("float16", "bfloat16"))
1289
+ parser.add_argument(
1290
+ "--clear-checkpoint-transforms",
1291
+ action=argparse.BooleanOptionalAction,
1292
+ default=None,
1293
+ )
1294
+ return parser.parse_args()
1295
+
1296
+
1297
+ def _load_config(args: argparse.Namespace) -> dict[str, Any]:
1298
+ with Path(args.config).expanduser().open(encoding="utf-8") as handle:
1299
+ raw = yaml.safe_load(handle) or {}
1300
+ for key, value in vars(args).items():
1301
+ if key != "config" and value is not None:
1302
+ raw[key] = value
1303
+ return _normalize_config(raw)
1304
+
1305
+
1306
+ def _validate_config(config: dict[str, Any]) -> None:
1307
+ missing = [key for key in ("train", "val", "output") if not config.get(key)]
1308
+ if missing:
1309
+ raise ValueError("missing required config fields: " + ", ".join(missing))
1310
+ for name in ("epochs", "batch_size", "eval_batch_size", "grad_accumulation_steps"):
1311
+ if int(config[name]) < 1:
1312
+ raise ValueError(f"{name} must be positive")
1313
+ if config.get("max_steps") is not None and int(config["max_steps"]) < 1:
1314
+ raise ValueError("max_steps must be positive")
1315
+ if config.get("max_atoms") is not None and int(config["max_atoms"]) < 1:
1316
+ raise ValueError("max_atoms must be positive")
1317
+ for name in ("log_every_n_steps", "log_every_n_validation_batches"):
1318
+ if int(config.get(name, 0)) < 0:
1319
+ raise ValueError(f"{name} must be non-negative")
1320
+ if config["amp_dtype"] not in {"float16", "bfloat16"}:
1321
+ raise ValueError("amp_dtype must be float16 or bfloat16")
1322
+ if config["mode"] == "resume_training" and (
1323
+ config.get("transforms_checkpoint")
1324
+ or config.get("transforms")
1325
+ or config.get("clear_checkpoint_transforms")
1326
+ ):
1327
+ raise ValueError(
1328
+ "resume_training restores transforms from the resume checkpoint; "
1329
+ "remove transforms_checkpoint/transforms overrides"
1330
+ )
1331
+ specs = _loss_specs(config)
1332
+ dens_params = _denoising_pos_params(config)
1333
+ if dens_params.enabled:
1334
+ if not dens_params.fixed_noise_std:
1335
+ raise ValueError("the official DeNS trainer requires fixed_noise_std=true")
1336
+ if not 0.0 <= dens_params.prob <= 1.0:
1337
+ raise ValueError("denoising_pos_params.prob must be between zero and one")
1338
+ if dens_params.std <= 0.0:
1339
+ raise ValueError("denoising_pos_params.std must be positive")
1340
+ for name, value in (
1341
+ ("corrupt_ratio", dens_params.corrupt_ratio),
1342
+ ("strict_max_ratio", dens_params.strict_max_ratio),
1343
+ ):
1344
+ if value is not None and not 0.0 <= value <= 1.0:
1345
+ raise ValueError(
1346
+ f"denoising_pos_params.{name} must be between zero and one"
1347
+ )
1348
+ if dens_params.min_num_atoms is not None and dens_params.min_num_atoms < 1:
1349
+ raise ValueError("denoising_pos_params.min_num_atoms must be positive")
1350
+ if dens_params.coefficient <= 0.0:
1351
+ raise ValueError("denoising_pos_coefficient must be positive")
1352
+ force_specs = [spec for spec in specs if spec.name == "forces"]
1353
+ if len(force_specs) != 1 or force_specs[0].function != "l2mae":
1354
+ raise ValueError("DeNS requires one forces loss using l2mae")
1355
+ if config["mode"] == "train_from_scratch":
1356
+ model_config = config.get("model") or {}
1357
+ if model_config.get("name") != "equiformer_v3_dens":
1358
+ raise ValueError("DeNS requires model.name=equiformer_v3_dens")
1359
+ if not model_config.get("direct_prediction", False):
1360
+ raise ValueError("DeNS pre-training requires direct_prediction=true")
1361
+
1362
+
1363
+ def main() -> None:
1364
+ args = _parse_args()
1365
+ try:
1366
+ config = _load_config(args)
1367
+ _validate_config(config)
1368
+ except ValueError as error:
1369
+ raise SystemExit(str(error)) from error
1370
+ if config["device"].startswith("cuda") and not torch.cuda.is_available():
1371
+ raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false")
1372
+
1373
+ context = _init_distributed(config["device"], config["backend"])
1374
+ try:
1375
+ device = (
1376
+ torch.device(f"cuda:{context.local_rank}")
1377
+ if config["device"].startswith("cuda")
1378
+ else torch.device(config["device"])
1379
+ )
1380
+ torch.manual_seed(int(config["seed"]) + context.rank)
1381
+ dens_params = _denoising_pos_params(config)
1382
+ model, transforms, model_config, source_document = _initialize_model(config)
1383
+ model = model.to(device)
1384
+
1385
+ train_loader = _loader(
1386
+ config["train"],
1387
+ config["batch_size"],
1388
+ config["workers"],
1389
+ config.get("max_train_samples"),
1390
+ context,
1391
+ train=True,
1392
+ seed=config["seed"],
1393
+ max_atoms=config.get("max_atoms"),
1394
+ load_balancing=config.get("load_balancing"),
1395
+ load_balancing_on_error=config["load_balancing_on_error"],
1396
+ device=device,
1397
+ )
1398
+ val_loader = _loader(
1399
+ config["val"],
1400
+ config["eval_batch_size"],
1401
+ config["workers"],
1402
+ config.get("max_val_samples"),
1403
+ context,
1404
+ train=False,
1405
+ seed=config["seed"] + 1,
1406
+ max_atoms=config.get("eval_max_atoms"),
1407
+ load_balancing=config.get("load_balancing"),
1408
+ load_balancing_on_error=config["load_balancing_on_error"],
1409
+ device=device,
1410
+ )
1411
+ if config["mode"] != "resume_training":
1412
+ _fit_transforms(config, transforms, train_loader.dataset)
1413
+ transforms = transforms.to(device)
1414
+
1415
+ optimizer_config = config["optimizer"]
1416
+ if optimizer_config["name"].lower() != "adamw":
1417
+ raise ValueError("only the official AdamW optimizer is supported")
1418
+ optimizer = torch.optim.AdamW(
1419
+ model.parameters(),
1420
+ lr=float(optimizer_config["lr"]),
1421
+ weight_decay=float(optimizer_config["weight_decay"]),
1422
+ betas=tuple(float(value) for value in optimizer_config["betas"]),
1423
+ eps=float(optimizer_config["eps"]),
1424
+ )
1425
+ updates_per_epoch = _updates_per_epoch(
1426
+ len(train_loader), int(config["grad_accumulation_steps"])
1427
+ )
1428
+ scheduler = _cosine_scheduler(
1429
+ optimizer,
1430
+ config["scheduler"],
1431
+ updates_per_epoch,
1432
+ int(config["epochs"]),
1433
+ config.get("max_steps"),
1434
+ )
1435
+ if config["amp"] and device.type != "cuda":
1436
+ raise ValueError("amp requires a CUDA/DCU device")
1437
+ amp_dtype = getattr(torch, config["amp_dtype"])
1438
+ scaler = (
1439
+ torch.GradScaler(
1440
+ "cuda", init_scale=float(config["amp_init_scale"])
1441
+ )
1442
+ if config["amp"]
1443
+ else None
1444
+ )
1445
+ ema = (
1446
+ ModelEMA(model, float(config["ema_decay"]))
1447
+ if config.get("ema_decay")
1448
+ else None
1449
+ )
1450
+
1451
+ start_epoch = 0
1452
+ global_step = 0
1453
+ history: list[dict[str, Any]] = []
1454
+ if config["mode"] == "resume_training":
1455
+ state = source_document.get("training_state", {})
1456
+ start_epoch = int(state.get("epoch", -1)) + 1
1457
+ global_step = int(state.get("global_step", 0))
1458
+ optimizer.load_state_dict(source_document["optimizer_state_dict"])
1459
+ scheduler.load_state_dict(source_document["scheduler_state_dict"])
1460
+ if scaler is not None and source_document.get("amp_state_dict") is not None:
1461
+ scaler.load_state_dict(source_document["amp_state_dict"])
1462
+ if ema is not None and source_document.get("ema_state_dict") is not None:
1463
+ ema.load_state_dict(source_document["ema_state_dict"], device)
1464
+ history = list(
1465
+ source_document.get("metadata", {}).get(
1466
+ "onescience_equiformer_v3_history", []
1467
+ )
1468
+ )
1469
+
1470
+ if context.enabled:
1471
+ find_unused_parameters = bool(
1472
+ config["ddp_find_unused_parameters"]
1473
+ )
1474
+ if context.is_main:
1475
+ print(
1476
+ json.dumps(
1477
+ {
1478
+ "event": "ddp_setup",
1479
+ "find_unused_parameters": find_unused_parameters,
1480
+ },
1481
+ sort_keys=True,
1482
+ ),
1483
+ flush=True,
1484
+ )
1485
+ model = DistributedDataParallel(
1486
+ model,
1487
+ device_ids=[context.local_rank] if device.type == "cuda" else None,
1488
+ output_device=context.local_rank if device.type == "cuda" else None,
1489
+ find_unused_parameters=find_unused_parameters,
1490
+ )
1491
+ specs = _loss_specs(config)
1492
+ loss_functions = _loss_functions(specs)
1493
+ output = Path(config["output"])
1494
+
1495
+ for epoch in range(start_epoch, int(config["epochs"])):
1496
+ if hasattr(train_loader.batch_sampler, "set_epoch"):
1497
+ train_loader.batch_sampler.set_epoch(epoch)
1498
+ elif isinstance(train_loader.sampler, DistributedSampler):
1499
+ train_loader.sampler.set_epoch(epoch)
1500
+ train_metrics, global_step = _run_train_epoch(
1501
+ model,
1502
+ train_loader,
1503
+ device,
1504
+ optimizer,
1505
+ scheduler,
1506
+ ema,
1507
+ specs,
1508
+ loss_functions,
1509
+ transforms,
1510
+ context,
1511
+ scaler,
1512
+ amp_dtype,
1513
+ int(config["grad_accumulation_steps"]),
1514
+ config.get("clip_grad_norm"),
1515
+ global_step,
1516
+ config.get("max_steps"),
1517
+ epoch,
1518
+ int(config["log_every_n_steps"]),
1519
+ dens_params,
1520
+ )
1521
+ base_model = _unwrap(model)
1522
+ if ema is None:
1523
+ val_metrics = _run_validation(
1524
+ model,
1525
+ val_loader,
1526
+ device,
1527
+ specs,
1528
+ loss_functions,
1529
+ transforms,
1530
+ context,
1531
+ scaler is not None,
1532
+ amp_dtype,
1533
+ epoch,
1534
+ int(config["log_every_n_validation_batches"]),
1535
+ )
1536
+ else:
1537
+ with ema.apply(base_model):
1538
+ val_metrics = _run_validation(
1539
+ model,
1540
+ val_loader,
1541
+ device,
1542
+ specs,
1543
+ loss_functions,
1544
+ transforms,
1545
+ context,
1546
+ scaler is not None,
1547
+ amp_dtype,
1548
+ epoch,
1549
+ int(config["log_every_n_validation_batches"]),
1550
+ )
1551
+ record = {
1552
+ "epoch": epoch,
1553
+ "global_step": global_step,
1554
+ "train": train_metrics,
1555
+ "val": val_metrics,
1556
+ }
1557
+ if context.is_main:
1558
+ history.append(record)
1559
+ print(json.dumps(record, sort_keys=True), flush=True)
1560
+ _save_checkpoint(
1561
+ output,
1562
+ base_model,
1563
+ transforms,
1564
+ model_config,
1565
+ config,
1566
+ optimizer,
1567
+ scheduler,
1568
+ ema,
1569
+ epoch,
1570
+ global_step,
1571
+ history,
1572
+ source_document,
1573
+ scaler,
1574
+ )
1575
+ print(f"saved checkpoint: {output}", flush=True)
1576
+ if config.get("max_steps") is not None and global_step >= int(
1577
+ config["max_steps"]
1578
+ ):
1579
+ break
1580
+ finally:
1581
+ _close_distributed(context)
1582
+
1583
+
1584
+ if __name__ == "__main__":
1585
+ main()
weight/Jd.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4059c45be246dcb6c49c545670b65c56550eb0c2e7a9c92b4b50a92d370dbe2
3
+ size 21697
weight/mptrj_gradient.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:59c6c23573a3b05b347662f473209d1bb1ccb5b85f6624a8f87072e7c397addf
3
+ size 485354773
weight/omat24-mptrj-salex_gradient.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:429ccded98163122e7ba588d78e2441653f37f3e091e106c432807fe373c8f98
3
+ size 486243861
weight/omat24_direct.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cd874ab2698a391a03a5083b8729c286c676d76ca584437ff5e550696609a96d
3
+ size 559867247
weight/omat24_gradient.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c4b9139c1a14094ed724c3addd584ce07afaf0032e1ec77490d2c60ed8b8a2b
3
+ size 486243797