Upload folder using huggingface_hub
Browse files- .gitignore +6 -0
- LICENSE +21 -0
- README.md +255 -0
- demo/_parse_config.py +113 -0
- demo/configs/oc20_scratch_8dcu.yaml +103 -0
- demo/configs/oc20_scratch_8dcu_smoke.yaml +91 -0
- demo/reference_data/oc20_subset_energy_element_references.npz +3 -0
- demo/run.sh +160 -0
- elastic.py +104 -0
- evaluate.py +262 -0
- finetune.py +392 -0
- fit_element_references.py +91 -0
- formation_energy.py +132 -0
- model/__init__.py +14 -0
- model/equiformer_v3.py +771 -0
- model/equiformer_v3_dens.py +502 -0
- phonons.py +103 -0
- prepare_mptrj_dataset.py +456 -0
- scripts/create_finetune_dataset.py +186 -0
- scripts/create_uma_finetune_dataset.py +140 -0
- single_point.py +80 -0
- train.py +1585 -0
- weight/Jd.pt +3 -0
- weight/mptrj_gradient.pt +3 -0
- weight/omat24-mptrj-salex_gradient.pt +3 -0
- weight/omat24_direct.pt +3 -0
- weight/omat24_gradient.pt +3 -0
.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
|