Upload folder using huggingface_hub
Browse files- .gitattributes +22 -10
- .gitignore +11 -0
- README.md +234 -0
- configuration.json +1 -0
- demo/_parse_config.py +88 -0
- demo/configs/finetune_16dcu.yaml +36 -0
- demo/configs/finetune_16dcu_smoke.yaml +36 -0
- demo/configs/finetune_1dcu.yaml +35 -0
- demo/configs/finetune_2dcu.yaml +35 -0
- demo/run.sh +147 -0
- evaluate.py +100 -0
- finetune.py +372 -0
- md.py +81 -0
- model/__init__.py +18 -0
- model/esen.py +514 -0
- model/esen_dens.py +582 -0
- model/graph.py +43 -0
- prepare_oxide_dataset.py +114 -0
- relax.py +62 -0
- single_point.py +47 -0
- weight/Jd.pt +3 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,47 @@
|
|
| 1 |
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 4 |
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bin.* filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 6 |
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 11 |
*.model filter=lfs diff=lfs merge=lfs -text
|
| 12 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 13 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 14 |
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 15 |
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 17 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 18 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 19 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 20 |
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 22 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.tgz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 24 |
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
*.zstandard filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.db* filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.ark* filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
**/*ckpt*data* filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
**/*ckpt*.meta filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
**/*ckpt*.index filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*.gguf* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.ggml filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.llamafile* filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.pt2 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 46 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
outputs/
|
| 2 |
+
checkpoints/
|
| 3 |
+
datasets/
|
| 4 |
+
weight/esen_*.pt
|
| 5 |
+
weight/esen_*.pth
|
| 6 |
+
__pycache__/
|
| 7 |
+
demo/__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*.log
|
| 10 |
+
*.traj
|
| 11 |
+
relaxed.cif
|
README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
datasets:
|
| 3 |
+
- OneScience-Group/oxides
|
| 4 |
+
frameworks:
|
| 5 |
+
- ""
|
| 6 |
+
language:
|
| 7 |
+
- en
|
| 8 |
+
license: mit
|
| 9 |
+
tags:
|
| 10 |
+
- OneScience
|
| 11 |
+
- eSEN
|
| 12 |
+
- materials-science
|
| 13 |
+
- machine-learning-potential
|
| 14 |
+
- equivariant-graph-neural-network
|
| 15 |
+
- structure-relaxation
|
| 16 |
+
- molecular-dynamics
|
| 17 |
+
- fine-tuning
|
| 18 |
+
tasks: []
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
<p align="center">
|
| 22 |
+
<strong>
|
| 23 |
+
<span style="font-size: 30px;">eSEN</span>
|
| 24 |
+
</strong>
|
| 25 |
+
</p>
|
| 26 |
+
|
| 27 |
+
# Model Introduction
|
| 28 |
+
|
| 29 |
+
eSEN (equivariant Smooth Energy Network) is an equivariant graph neural network interatomic potential proposed by FAIR Chemistry. It predicts the energy, atomic forces, and stress of material structures. OneScience provides an ASE calculator and fine-tuning entry points for single-point calculations, structure relaxation, molecular dynamics, and fine-tuning on custom materials data.
|
| 30 |
+
|
| 31 |
+
Upstream implementation: [FAIR-Chem/fairchem](https://github.com/FAIR-Chem/fairchem)
|
| 32 |
+
|
| 33 |
+
# Model Description
|
| 34 |
+
|
| 35 |
+
eSEN takes periodic atomic structure graphs as input, learns a smooth potential energy surface through rotationally equivariant representations, and obtains conservative forces from energy gradients. Different pretrained checkpoints correspond to different materials domains. Select weights whose target elemental systems and DFT labeling settings are close to your use case.
|
| 36 |
+
|
| 37 |
+
This repository contains the eSEN model code, inference scripts, an oxide PBE fine-tuning example, and multi-device launch configurations. Restricted pretrained eSEN checkpoints are not distributed with this repository.
|
| 38 |
+
|
| 39 |
+
# Use Cases
|
| 40 |
+
|
| 41 |
+
| Use case | Description |
|
| 42 |
+
| :---: | :--- |
|
| 43 |
+
| Single-point calculation | Predict the total energy, atomic forces, and stress of a periodic structure |
|
| 44 |
+
| Structure relaxation | Optimize atomic positions and, optionally, the unit cell with ASE BFGS |
|
| 45 |
+
| Molecular dynamics | Run NVT trajectories with ASE Langevin dynamics |
|
| 46 |
+
| Oxide PBE fine-tuning | Fine-tune an MPTrj checkpoint using energy and force labels |
|
| 47 |
+
| Distributed fine-tuning | Support single-node multi-device and multi-node Slurm DDP |
|
| 48 |
+
|
| 49 |
+
This repository does not provide a complete pretraining workflow from random initialization. Its training entry point is intended for checkpoint fine-tuning.
|
| 50 |
+
|
| 51 |
+
# Usage
|
| 52 |
+
|
| 53 |
+
## 1. Using OneCode
|
| 54 |
+
|
| 55 |
+
Try intelligent, one-click AI4S programming in the OneCode online environment:
|
| 56 |
+
|
| 57 |
+
[Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 58 |
+
|
| 59 |
+
## 2. Manual Installation and Usage
|
| 60 |
+
|
| 61 |
+
**Hardware requirements**
|
| 62 |
+
|
| 63 |
+
- A GPU or DCU is recommended for inference and fine-tuning.
|
| 64 |
+
- A CPU can be used for import and configuration checks but is not recommended for production workloads.
|
| 65 |
+
- A DCU requires a DTK runtime matching the PyTorch build.
|
| 66 |
+
|
| 67 |
+
### Download the Model Package
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
hf download --model OneScience-Group/eSEN --local-dir ./eSEN
|
| 71 |
+
cd eSEN
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### Install the Runtime Environment
|
| 75 |
+
|
| 76 |
+
**DCU environment**
|
| 77 |
+
|
| 78 |
+
```bash
|
| 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 |
+
conda create -n onescience311 python=3.11 -y \
|
| 90 |
+
libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 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 |
+
### Pretrained Weights
|
| 98 |
+
|
| 99 |
+
Access to pretrained eSEN checkpoints must be requested from the official FAIR Chemistry model release page. This repository neither provides nor redistributes those weights. After obtaining access, place the required checkpoint at:
|
| 100 |
+
|
| 101 |
+
```text
|
| 102 |
+
weight/
|
| 103 |
+
├── Jd.pt # Rotation-basis file included in this repository
|
| 104 |
+
├── esen_30m_mptrj.pt # Add after obtaining access
|
| 105 |
+
├── esen_30m_omat.pt # Add after obtaining access
|
| 106 |
+
└── esen_30m_oam.pt # Add after obtaining access
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
Checkpoint details:
|
| 110 |
+
|
| 111 |
+
| Checkpoint | Training domain | Recommended use |
|
| 112 |
+
| --- | --- | --- |
|
| 113 |
+
| `esen_30m_mptrj.pt` | MPTrj | PBE/PBE+U inference for inorganic crystals and fine-tuning in a similar labeling domain |
|
| 114 |
+
| `esen_30m_omat.pt` | OMat24 | A broader range of non-equilibrium inorganic structures |
|
| 115 |
+
| `esen_30m_oam.pt` | OAM | General-purpose materials pretraining starting point |
|
| 116 |
+
|
| 117 |
+
Access and download resources:
|
| 118 |
+
|
| 119 |
+
- FAIR Chemistry model page: https://huggingface.co/fairchem
|
| 120 |
+
- Official FAIR Chemistry repository: https://github.com/FAIR-Chem/fairchem
|
| 121 |
+
|
| 122 |
+
`Jd.pt` is included in the repository under `weight/`. The inference and fine-tuning scripts use it automatically, with no additional download or UMA dependency required.
|
| 123 |
+
|
| 124 |
+
### Fine-Tuning Dataset
|
| 125 |
+
|
| 126 |
+
This repository does not include the training data. The oxide PBE example dataset is published separately on Hugging Face:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
hf download --dataset OneScience-Group/oxides \
|
| 130 |
+
--local-dir ./datasets/oxides
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
The downloaded data has the following layout:
|
| 134 |
+
|
| 135 |
+
```text
|
| 136 |
+
datasets/oxides/data/OXIDES/prepared/
|
| 137 |
+
├── train.db
|
| 138 |
+
├── val.db
|
| 139 |
+
└── test.db
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
The data has already been converted to ASE DB format. The default configuration uses energy and force supervision and does not train on stress. Use `prepare_oxide_dataset.py` to regenerate the data from the official oxide JSON files.
|
| 143 |
+
|
| 144 |
+
### Inference
|
| 145 |
+
|
| 146 |
+
Predict single-point energy, forces, and stress:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
python single_point.py --checkpoint weight/esen_30m_mptrj.pt
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
Relax a structure:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
python relax.py \
|
| 156 |
+
--checkpoint weight/esen_30m_mptrj.pt \
|
| 157 |
+
--fmax 0.05 --steps 100 --output relaxed.cif
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
Run NVT molecular dynamics:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
python md.py \
|
| 164 |
+
--checkpoint weight/esen_30m_mptrj.pt \
|
| 165 |
+
--steps 100 --temperature 300 --timestep 1.0 --output md.traj
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
Use `--input` to read CIF, POSCAR, XYZ, or any other structure format supported by ASE.
|
| 169 |
+
|
| 170 |
+
### Fine-Tuning
|
| 171 |
+
|
| 172 |
+
Fine-tune on oxide PBE data with one device:
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
bash demo/run.sh --config configs/finetune_1dcu.yaml
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Main configuration fields:
|
| 179 |
+
|
| 180 |
+
| YAML field | Purpose |
|
| 181 |
+
| --- | --- |
|
| 182 |
+
| `checkpoint` | Path to the initialization checkpoint |
|
| 183 |
+
| `train`, `val` | Paths to ASE DB or ASE-LMDB data |
|
| 184 |
+
| `epochs`, `batch_size`, `workers` | Number of training epochs, batch size, and data-loading workers |
|
| 185 |
+
| `lr` | AdamW learning rate |
|
| 186 |
+
| `energy_weight`, `force_weight`, `stress_weight` | Loss weight for each supervised target |
|
| 187 |
+
| `fit_element_references` | Whether to refit elemental energy references on the training set |
|
| 188 |
+
| `launch.num_nodes`, `launch.num_gpus` | Number of nodes and devices per node |
|
| 189 |
+
| `launch.mode` | Run directly with `local` or submit to Slurm with `submit` |
|
| 190 |
+
| `slurm.*` | Slurm partition, time limit, and CPU resources |
|
| 191 |
+
|
| 192 |
+
Use the same entry point for multi-device and multi-node jobs by selecting another YAML file:
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
bash demo/run.sh --config configs/finetune_2dcu.yaml
|
| 196 |
+
bash demo/run.sh --config configs/finetune_16dcu.yaml
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
When the current node has fewer visible devices than requested, or when the configuration requests multiple nodes, `demo/run.sh` automatically submits the job through `sbatch`.
|
| 200 |
+
|
| 201 |
+
Evaluate a fine-tuned checkpoint:
|
| 202 |
+
|
| 203 |
+
```bash
|
| 204 |
+
python evaluate.py \
|
| 205 |
+
--checkpoint outputs/<run>/checkpoints/esen_oxides_1dcu_finetuned.pt \
|
| 206 |
+
--data datasets/oxides/data/OXIDES/prepared/test.db
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
# File Reference
|
| 210 |
+
|
| 211 |
+
| Path | Purpose |
|
| 212 |
+
| --- | --- |
|
| 213 |
+
| `model/` | Source code for the eSEN backbone, prediction heads, and graph interface |
|
| 214 |
+
| `single_point.py` | Single-point energy, force, and stress inference |
|
| 215 |
+
| `relax.py` | Periodic structure relaxation |
|
| 216 |
+
| `md.py` | NVT molecular dynamics |
|
| 217 |
+
| `finetune.py` | Low-level checkpoint fine-tuning entry point |
|
| 218 |
+
| `evaluate.py` | Error evaluation on an independent dataset |
|
| 219 |
+
| `prepare_oxide_dataset.py` | Convert oxide JSON data to ASE DB format |
|
| 220 |
+
| `demo/run.sh` | YAML-driven local and Slurm fine-tuning entry point |
|
| 221 |
+
| `demo/configs/` | Single-device, multi-device, and multi-node fine-tuning configurations |
|
| 222 |
+
|
| 223 |
+
# Official OneScience Resources
|
| 224 |
+
|
| 225 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 226 |
+
| --- | --- | --- |
|
| 227 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 228 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 229 |
+
|
| 230 |
+
# Citation and License
|
| 231 |
+
|
| 232 |
+
- The eSEN model code is adapted from FairChem Core and follows the upstream FairChem MIT License. The model code and the included `Jd.pt` file are used under that model license.
|
| 233 |
+
- eSEN checkpoints are not distributed with this repository. Follow the access conditions and licenses specified on the FAIR Chemistry model pages.
|
| 234 |
+
- When using OMat24, MPTrj, OAM, or oxide data, cite the datasets and corresponding model work actually used.
|
configuration.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"framework":"Pytorch","task":"other"}
|
demo/_parse_config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Extract eSEN demo metadata and the native finetune configuration from YAML."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
META_KEYS = {"name", "description", "launch", "slurm", "nccl"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _config(path: str) -> dict:
|
| 15 |
+
with open(path, encoding="utf-8") as handle:
|
| 16 |
+
return yaml.safe_load(handle) or {}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _launch(cfg: dict) -> dict:
|
| 20 |
+
return cfg.get("launch", {}) or {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _slurm(cfg: dict) -> dict:
|
| 24 |
+
return cfg.get("slurm", {}) or {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _print_launch(cfg: dict) -> None:
|
| 28 |
+
launch = _launch(cfg)
|
| 29 |
+
mode = launch.get("mode", "local")
|
| 30 |
+
if mode not in {"local", "submit"}:
|
| 31 |
+
raise ValueError("launch.mode must be 'local' or 'submit'")
|
| 32 |
+
print(f"RUN_MODE={mode}")
|
| 33 |
+
print(f"NODES={launch.get('num_nodes', 1)}")
|
| 34 |
+
print(f"GPUS_PER_NODE={launch.get('num_gpus', 1)}")
|
| 35 |
+
print(f"OMP_NUM_THREADS={launch.get('omp_num_threads', 1)}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _print_slurm(cfg: dict) -> None:
|
| 39 |
+
slurm = _slurm(cfg)
|
| 40 |
+
print(f"PARTITION={slurm.get('partition', 'hx1hdexclu12')}")
|
| 41 |
+
print(f"TIME={slurm.get('time', '24:00:00')}")
|
| 42 |
+
print(f"CPUS_PER_TASK={slurm.get('cpus_per_task', 16)}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _print_env(cfg: dict) -> None:
|
| 46 |
+
nccl = cfg.get("nccl", {}) or {}
|
| 47 |
+
if nccl.get("socket_ifname"):
|
| 48 |
+
print(f"export NCCL_SOCKET_IFNAME={nccl['socket_ifname']}")
|
| 49 |
+
if nccl.get("ib_hca"):
|
| 50 |
+
print(f"export NCCL_IB_HCA={nccl['ib_hca']}")
|
| 51 |
+
if nccl.get("proto"):
|
| 52 |
+
print(f"export NCCL_PROTO={nccl['proto']}")
|
| 53 |
+
print("export HSA_FORCE_FINE_GRAIN_PCIE=1")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _print_finetune_config(cfg: dict) -> None:
|
| 57 |
+
finetune = {key: value for key, value in cfg.items() if key not in META_KEYS}
|
| 58 |
+
yaml.safe_dump(
|
| 59 |
+
finetune,
|
| 60 |
+
sys.stdout,
|
| 61 |
+
sort_keys=False,
|
| 62 |
+
default_flow_style=False,
|
| 63 |
+
allow_unicode=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main() -> None:
|
| 68 |
+
if len(sys.argv) != 3:
|
| 69 |
+
raise SystemExit(
|
| 70 |
+
"usage: _parse_config.py <config.yaml> <name|launch|slurm|env|finetune-config>"
|
| 71 |
+
)
|
| 72 |
+
cfg = _config(sys.argv[1])
|
| 73 |
+
action = sys.argv[2]
|
| 74 |
+
actions = {
|
| 75 |
+
"name": lambda: print(cfg.get("name", "esen_finetune")),
|
| 76 |
+
"launch": lambda: _print_launch(cfg),
|
| 77 |
+
"slurm": lambda: _print_slurm(cfg),
|
| 78 |
+
"env": lambda: _print_env(cfg),
|
| 79 |
+
"finetune-config": lambda: _print_finetune_config(cfg),
|
| 80 |
+
}
|
| 81 |
+
try:
|
| 82 |
+
actions[action]()
|
| 83 |
+
except KeyError as error:
|
| 84 |
+
raise SystemExit(f"unknown action: {action}") from error
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
main()
|
demo/configs/finetune_16dcu.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# eSEN oxide PBE fine-tuning: 2 nodes x 8 DCUs.
|
| 2 |
+
# Submit from examples/matchem/esen:
|
| 3 |
+
# bash demo/run.sh --config configs/finetune_16dcu.yaml
|
| 4 |
+
name: esen_oxides_16dcu
|
| 5 |
+
description: "eSEN oxide PBE fine-tuning on 2 nodes x 8 DCUs"
|
| 6 |
+
|
| 7 |
+
launch:
|
| 8 |
+
mode: submit
|
| 9 |
+
num_nodes: 2
|
| 10 |
+
num_gpus: 8
|
| 11 |
+
omp_num_threads: 1
|
| 12 |
+
|
| 13 |
+
slurm:
|
| 14 |
+
partition: hx1hdexclu12
|
| 15 |
+
time: "24:00:00"
|
| 16 |
+
cpus_per_task: 128
|
| 17 |
+
|
| 18 |
+
# Set fabric-specific NCCL values here when required by the cluster.
|
| 19 |
+
nccl: {}
|
| 20 |
+
|
| 21 |
+
checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
|
| 22 |
+
train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
|
| 23 |
+
val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
|
| 24 |
+
output: "checkpoints/esen_oxides_16dcu_finetuned.pt"
|
| 25 |
+
|
| 26 |
+
device: cuda
|
| 27 |
+
backend: nccl
|
| 28 |
+
seed: 0
|
| 29 |
+
fit_element_references: true
|
| 30 |
+
epochs: 10
|
| 31 |
+
batch_size: 2
|
| 32 |
+
workers: 0
|
| 33 |
+
lr: 1.0e-6
|
| 34 |
+
energy_weight: 1.0
|
| 35 |
+
force_weight: 10.0
|
| 36 |
+
stress_weight: 0.0
|
demo/configs/finetune_16dcu_smoke.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# End-to-end validation only: 2 nodes x 8 DCUs, one batch per rank.
|
| 2 |
+
# This is not a scientific fine-tuning recipe.
|
| 3 |
+
name: esen_oxides_16dcu_smoke
|
| 4 |
+
description: "eSEN two-node DDP smoke test"
|
| 5 |
+
|
| 6 |
+
launch:
|
| 7 |
+
mode: submit
|
| 8 |
+
num_nodes: 2
|
| 9 |
+
num_gpus: 8
|
| 10 |
+
omp_num_threads: 1
|
| 11 |
+
|
| 12 |
+
slurm:
|
| 13 |
+
partition: hx1hdexclu12
|
| 14 |
+
time: "00:20:00"
|
| 15 |
+
cpus_per_task: 128
|
| 16 |
+
|
| 17 |
+
nccl: {}
|
| 18 |
+
|
| 19 |
+
checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
|
| 20 |
+
train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
|
| 21 |
+
val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
|
| 22 |
+
output: "checkpoints/esen_oxides_16dcu_smoke.pt"
|
| 23 |
+
|
| 24 |
+
device: cuda
|
| 25 |
+
backend: nccl
|
| 26 |
+
seed: 0
|
| 27 |
+
fit_element_references: true
|
| 28 |
+
epochs: 1
|
| 29 |
+
batch_size: 2
|
| 30 |
+
workers: 0
|
| 31 |
+
max_train_samples: 16
|
| 32 |
+
max_val_samples: 16
|
| 33 |
+
lr: 1.0e-6
|
| 34 |
+
energy_weight: 1.0
|
| 35 |
+
force_weight: 10.0
|
| 36 |
+
stress_weight: 0.0
|
demo/configs/finetune_1dcu.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# eSEN oxide PBE fine-tuning: 1 node x 1 DCU.
|
| 2 |
+
# Run from examples/matchem/esen:
|
| 3 |
+
# bash demo/run.sh --config configs/finetune_1dcu.yaml
|
| 4 |
+
name: esen_oxides_1dcu
|
| 5 |
+
description: "eSEN oxide PBE fine-tuning on one DCU"
|
| 6 |
+
|
| 7 |
+
launch:
|
| 8 |
+
mode: local
|
| 9 |
+
num_nodes: 1
|
| 10 |
+
num_gpus: 1
|
| 11 |
+
omp_num_threads: 1
|
| 12 |
+
|
| 13 |
+
slurm:
|
| 14 |
+
partition: hx1hdexclu12
|
| 15 |
+
time: "24:00:00"
|
| 16 |
+
cpus_per_task: 16
|
| 17 |
+
|
| 18 |
+
nccl: {}
|
| 19 |
+
|
| 20 |
+
checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
|
| 21 |
+
train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
|
| 22 |
+
val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
|
| 23 |
+
output: "checkpoints/esen_oxides_1dcu_finetuned.pt"
|
| 24 |
+
|
| 25 |
+
device: cuda
|
| 26 |
+
backend: nccl
|
| 27 |
+
seed: 0
|
| 28 |
+
fit_element_references: true
|
| 29 |
+
epochs: 10
|
| 30 |
+
batch_size: 2
|
| 31 |
+
workers: 0
|
| 32 |
+
lr: 1.0e-6
|
| 33 |
+
energy_weight: 1.0
|
| 34 |
+
force_weight: 10.0
|
| 35 |
+
stress_weight: 0.0
|
demo/configs/finetune_2dcu.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# eSEN oxide PBE fine-tuning: 1 node x 2 DCUs.
|
| 2 |
+
# Run from examples/matchem/esen:
|
| 3 |
+
# bash demo/run.sh --config configs/finetune_2dcu.yaml
|
| 4 |
+
name: esen_oxides_2dcu
|
| 5 |
+
description: "eSEN oxide PBE fine-tuning on 1 node x 2 DCUs"
|
| 6 |
+
|
| 7 |
+
launch:
|
| 8 |
+
mode: local
|
| 9 |
+
num_nodes: 1
|
| 10 |
+
num_gpus: 2
|
| 11 |
+
omp_num_threads: 1
|
| 12 |
+
|
| 13 |
+
slurm:
|
| 14 |
+
partition: hx1hdexclu12
|
| 15 |
+
time: "24:00:00"
|
| 16 |
+
cpus_per_task: 32
|
| 17 |
+
|
| 18 |
+
nccl: {}
|
| 19 |
+
|
| 20 |
+
checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
|
| 21 |
+
train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
|
| 22 |
+
val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
|
| 23 |
+
output: "checkpoints/esen_oxides_2dcu_finetuned.pt"
|
| 24 |
+
|
| 25 |
+
device: cuda
|
| 26 |
+
backend: nccl
|
| 27 |
+
seed: 0
|
| 28 |
+
fit_element_references: true
|
| 29 |
+
epochs: 10
|
| 30 |
+
batch_size: 2
|
| 31 |
+
workers: 0
|
| 32 |
+
lr: 1.0e-6
|
| 33 |
+
energy_weight: 1.0
|
| 34 |
+
force_weight: 10.0
|
| 35 |
+
stress_weight: 0.0
|
demo/run.sh
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Run native eSEN fine-tuning directly or submit it to Slurm.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 6 |
+
ESEN_DIR="$(cd "$DEMO_DIR/.." && pwd)"
|
| 7 |
+
PARSER="$DEMO_DIR/_parse_config.py"
|
| 8 |
+
CONFIG=""
|
| 9 |
+
SUBMIT=false
|
| 10 |
+
|
| 11 |
+
while [[ $# -gt 0 ]]; do
|
| 12 |
+
case "$1" in
|
| 13 |
+
--config) CONFIG="$2"; shift 2 ;;
|
| 14 |
+
--config=*) CONFIG="${1#*=}"; shift ;;
|
| 15 |
+
--submit) SUBMIT=true; shift ;;
|
| 16 |
+
-h|--help)
|
| 17 |
+
echo "Usage: bash demo/run.sh --config configs/<name>.yaml"
|
| 18 |
+
echo "Set launch.mode: submit in YAML to submit a Slurm job."
|
| 19 |
+
exit 0
|
| 20 |
+
;;
|
| 21 |
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
| 22 |
+
esac
|
| 23 |
+
done
|
| 24 |
+
|
| 25 |
+
[[ -n "$CONFIG" ]] || { echo "Please specify --config configs/<name>.yaml" >&2; exit 2; }
|
| 26 |
+
[[ "$CONFIG" = /* ]] || CONFIG="$DEMO_DIR/$CONFIG"
|
| 27 |
+
[[ -f "$CONFIG" ]] || { echo "Config not found: $CONFIG" >&2; exit 2; }
|
| 28 |
+
|
| 29 |
+
if [[ -z "${CONDA_PREFIX:-}" ]]; then
|
| 30 |
+
echo "Activate a OneScience MatChem conda environment before running this script." >&2
|
| 31 |
+
exit 2
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
export ESEN_REPO_DIR="$ESEN_DIR"
|
| 35 |
+
export ESEN_DATASET_DIR="${ESEN_DATASET_DIR:-$ESEN_DIR/datasets/oxides}"
|
| 36 |
+
export ONESCIENCE_ESEN_JD_PATH="${ONESCIENCE_ESEN_JD_PATH:-$ESEN_DIR/weight/Jd.pt}"
|
| 37 |
+
|
| 38 |
+
NAME="$(python3 "$PARSER" "$CONFIG" name)"
|
| 39 |
+
eval "$(python3 "$PARSER" "$CONFIG" launch)"
|
| 40 |
+
eval "$(python3 "$PARSER" "$CONFIG" slurm)"
|
| 41 |
+
ENV_EXPORTS="$(python3 "$PARSER" "$CONFIG" env)"
|
| 42 |
+
|
| 43 |
+
if [[ "$RUN_MODE" == "submit" ]]; then
|
| 44 |
+
SUBMIT=true
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
if ! $SUBMIT; then
|
| 48 |
+
AUTO_SUBMIT_REASON=""
|
| 49 |
+
if (( NODES > 1 )); then
|
| 50 |
+
AUTO_SUBMIT_REASON="the config requests $NODES nodes"
|
| 51 |
+
else
|
| 52 |
+
AVAILABLE_GPUS="$(python3 -c 'import torch; print(torch.cuda.device_count() if torch.cuda.is_available() else 0)' 2>/dev/null || echo 0)"
|
| 53 |
+
if (( AVAILABLE_GPUS < GPUS_PER_NODE )); then
|
| 54 |
+
AUTO_SUBMIT_REASON="the config requests $GPUS_PER_NODE DCUs but only $AVAILABLE_GPUS are visible"
|
| 55 |
+
fi
|
| 56 |
+
fi
|
| 57 |
+
if [[ -n "$AUTO_SUBMIT_REASON" ]]; then
|
| 58 |
+
if ! command -v sbatch >/dev/null 2>&1; then
|
| 59 |
+
echo "Cannot run locally: $AUTO_SUBMIT_REASON, and sbatch is unavailable." >&2
|
| 60 |
+
exit 2
|
| 61 |
+
fi
|
| 62 |
+
echo "Local resources are insufficient: $AUTO_SUBMIT_REASON. Submitting to Slurm."
|
| 63 |
+
SUBMIT=true
|
| 64 |
+
fi
|
| 65 |
+
fi
|
| 66 |
+
|
| 67 |
+
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
| 68 |
+
OUTPUT_ROOT="${ESEN_OUTPUT_ROOT:-$ESEN_DIR/outputs}"
|
| 69 |
+
OUTPUT_DIR="$OUTPUT_ROOT/${NAME}_${TIMESTAMP}"
|
| 70 |
+
mkdir -p "$OUTPUT_DIR/checkpoints"
|
| 71 |
+
cp "$CONFIG" "$OUTPUT_DIR/config.yaml"
|
| 72 |
+
FINETUNE_CONFIG="$OUTPUT_DIR/finetune.yaml"
|
| 73 |
+
python3 "$PARSER" "$CONFIG" finetune-config > "$FINETUNE_CONFIG"
|
| 74 |
+
|
| 75 |
+
if $SUBMIT; then
|
| 76 |
+
SLURM_SCRIPT="$OUTPUT_DIR/submit.sh"
|
| 77 |
+
cat > "$SLURM_SCRIPT" <<EOF
|
| 78 |
+
#!/bin/bash
|
| 79 |
+
#SBATCH --job-name=$NAME
|
| 80 |
+
#SBATCH --partition=$PARTITION
|
| 81 |
+
#SBATCH --nodes=$NODES
|
| 82 |
+
#SBATCH --ntasks-per-node=1
|
| 83 |
+
#SBATCH --cpus-per-task=$CPUS_PER_TASK
|
| 84 |
+
#SBATCH --gres=dcu:$GPUS_PER_NODE
|
| 85 |
+
#SBATCH --time=$TIME
|
| 86 |
+
#SBATCH --output=$OUTPUT_DIR/slurm_%j.out
|
| 87 |
+
#SBATCH --error=$OUTPUT_DIR/slurm_%j.err
|
| 88 |
+
EOF
|
| 89 |
+
|
| 90 |
+
cat >> "$SLURM_SCRIPT" <<EOF
|
| 91 |
+
|
| 92 |
+
set -euo pipefail
|
| 93 |
+
export ESEN_REPO_DIR="$ESEN_DIR"
|
| 94 |
+
export ESEN_DATASET_DIR="$ESEN_DATASET_DIR"
|
| 95 |
+
export ONESCIENCE_ESEN_JD_PATH="$ONESCIENCE_ESEN_JD_PATH"
|
| 96 |
+
export OMP_NUM_THREADS="$OMP_NUM_THREADS"
|
| 97 |
+
# A login/compute shell may carry a single-device selection into sbatch.
|
| 98 |
+
# Let Slurm expose all devices requested by this YAML.
|
| 99 |
+
if (( $GPUS_PER_NODE > 1 )); then
|
| 100 |
+
unset HIP_VISIBLE_DEVICES
|
| 101 |
+
fi
|
| 102 |
+
$ENV_EXPORTS
|
| 103 |
+
cd "$OUTPUT_DIR"
|
| 104 |
+
EOF
|
| 105 |
+
|
| 106 |
+
if (( NODES > 1 )); then
|
| 107 |
+
cat >> "$SLURM_SCRIPT" <<EOF
|
| 108 |
+
|
| 109 |
+
nodes=(\$(scontrol show hostnames "\$SLURM_JOB_NODELIST"))
|
| 110 |
+
export MASTER_ADDR="\${nodes[0]}"
|
| 111 |
+
export MASTER_PORT=\$((20000 + SLURM_JOB_ID % 20000))
|
| 112 |
+
echo "eSEN multi-node DDP: nodes=\$SLURM_NNODES, devices/node=$GPUS_PER_NODE"
|
| 113 |
+
echo "MASTER_ADDR=\$MASTER_ADDR MASTER_PORT=\$MASTER_PORT"
|
| 114 |
+
|
| 115 |
+
srun --nodes="\$SLURM_NNODES" --ntasks="\$SLURM_NNODES" --ntasks-per-node=1 \\
|
| 116 |
+
bash -c 'exec torchrun \\
|
| 117 |
+
--nnodes=$NODES \\
|
| 118 |
+
--node_rank="\${SLURM_NODEID}" \\
|
| 119 |
+
--nproc_per_node=$GPUS_PER_NODE \\
|
| 120 |
+
--rdzv_id="\${SLURM_JOB_ID}" \\
|
| 121 |
+
--rdzv_backend=c10d \\
|
| 122 |
+
--rdzv_endpoint="\${MASTER_ADDR}:\${MASTER_PORT}" \\
|
| 123 |
+
"$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"'
|
| 124 |
+
EOF
|
| 125 |
+
elif (( GPUS_PER_NODE > 1 )); then
|
| 126 |
+
cat >> "$SLURM_SCRIPT" <<EOF
|
| 127 |
+
|
| 128 |
+
torchrun --standalone --nproc_per_node=$GPUS_PER_NODE \\
|
| 129 |
+
"$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
|
| 130 |
+
EOF
|
| 131 |
+
else
|
| 132 |
+
echo "python \"$ESEN_DIR/finetune.py\" --config \"$FINETUNE_CONFIG\"" >> "$SLURM_SCRIPT"
|
| 133 |
+
fi
|
| 134 |
+
|
| 135 |
+
chmod u+x "$SLURM_SCRIPT"
|
| 136 |
+
echo "Submitting eSEN job: $SLURM_SCRIPT"
|
| 137 |
+
sbatch "$SLURM_SCRIPT"
|
| 138 |
+
exit 0
|
| 139 |
+
fi
|
| 140 |
+
|
| 141 |
+
eval "$ENV_EXPORTS"
|
| 142 |
+
cd "$OUTPUT_DIR"
|
| 143 |
+
if (( GPUS_PER_NODE > 1 )); then
|
| 144 |
+
exec torchrun --standalone --nproc_per_node="$GPUS_PER_NODE" \
|
| 145 |
+
"$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
|
| 146 |
+
fi
|
| 147 |
+
exec python "$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
|
evaluate.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate an eSEN checkpoint in physical units on an independent ASE DB."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
os.environ.setdefault(
|
| 11 |
+
"ONESCIENCE_ESEN_JD_PATH",
|
| 12 |
+
os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from onescience.utils.esen.checkpoint import ESENCheckpointTransforms
|
| 18 |
+
from onescience.utils.uma.common.utils import load_model_and_weights_from_checkpoint
|
| 19 |
+
|
| 20 |
+
from finetune import _loader
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _stats(error: torch.Tensor) -> dict[str, float]:
|
| 24 |
+
error = error.detach().reshape(-1).double()
|
| 25 |
+
return {
|
| 26 |
+
"mae": float(error.abs().mean()),
|
| 27 |
+
"rmse": float(error.square().mean().sqrt()),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 33 |
+
parser.add_argument("--checkpoint", required=True)
|
| 34 |
+
parser.add_argument("--data", required=True, help="Independent ASE DB/ASE-LMDB")
|
| 35 |
+
parser.add_argument("--device", default="cuda")
|
| 36 |
+
parser.add_argument("--batch-size", type=int, default=2)
|
| 37 |
+
parser.add_argument("--workers", type=int, default=0)
|
| 38 |
+
parser.add_argument("--max-samples", type=int)
|
| 39 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 40 |
+
parser.add_argument("--output")
|
| 41 |
+
args = parser.parse_args()
|
| 42 |
+
|
| 43 |
+
device = torch.device(args.device)
|
| 44 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 45 |
+
raise RuntimeError("CUDA/DCU was requested but is unavailable")
|
| 46 |
+
import onescience.models.esen # noqa: F401
|
| 47 |
+
|
| 48 |
+
model = load_model_and_weights_from_checkpoint(args.checkpoint).to(device)
|
| 49 |
+
model.eval()
|
| 50 |
+
transforms = ESENCheckpointTransforms.from_checkpoint(args.checkpoint).to(device)
|
| 51 |
+
loader = _loader(
|
| 52 |
+
args.data,
|
| 53 |
+
args.batch_size,
|
| 54 |
+
args.workers,
|
| 55 |
+
max_samples=args.max_samples,
|
| 56 |
+
train=False,
|
| 57 |
+
seed=args.seed,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
energy_errors = []
|
| 61 |
+
energy_per_atom_errors = []
|
| 62 |
+
force_errors = []
|
| 63 |
+
stress_errors = []
|
| 64 |
+
with torch.enable_grad():
|
| 65 |
+
for batch in loader:
|
| 66 |
+
batch = batch.to(device)
|
| 67 |
+
prediction = model(batch)
|
| 68 |
+
pred_energy = transforms.denormalize_prediction("energy", prediction["energy"], batch)
|
| 69 |
+
target_energy = batch.energy.reshape_as(pred_energy)
|
| 70 |
+
energy_errors.append((pred_energy - target_energy).detach().cpu())
|
| 71 |
+
natoms = batch.natoms.to(pred_energy).reshape((-1,) + (1,) * (pred_energy.ndim - 1))
|
| 72 |
+
energy_per_atom_errors.append(((pred_energy - target_energy) / natoms).detach().cpu())
|
| 73 |
+
|
| 74 |
+
pred_forces = transforms.denormalize_prediction("forces", prediction["forces"], batch)
|
| 75 |
+
target_forces = batch.forces.reshape_as(pred_forces)
|
| 76 |
+
force_errors.append((pred_forces - target_forces).detach().cpu())
|
| 77 |
+
if "stress" in prediction and hasattr(batch, "stress"):
|
| 78 |
+
pred_stress = transforms.denormalize_prediction("stress", prediction["stress"], batch)
|
| 79 |
+
target_stress = batch.stress.reshape_as(pred_stress)
|
| 80 |
+
stress_errors.append((pred_stress - target_stress).detach().cpu())
|
| 81 |
+
|
| 82 |
+
result = {
|
| 83 |
+
"checkpoint": str(Path(args.checkpoint).expanduser()),
|
| 84 |
+
"data": str(Path(args.data).expanduser()),
|
| 85 |
+
"samples": len(loader.dataset),
|
| 86 |
+
"energy_total_eV": _stats(torch.cat(energy_errors)),
|
| 87 |
+
"energy_per_atom_eV": _stats(torch.cat(energy_per_atom_errors)),
|
| 88 |
+
"forces_eV_per_A": _stats(torch.cat(force_errors)),
|
| 89 |
+
}
|
| 90 |
+
if stress_errors:
|
| 91 |
+
result["stress_eV_per_A3"] = _stats(torch.cat(stress_errors))
|
| 92 |
+
print(json.dumps(result, indent=2, sort_keys=True))
|
| 93 |
+
if args.output:
|
| 94 |
+
output = Path(args.output).expanduser()
|
| 95 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
finetune.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fine-tune an eSEN checkpoint on an ASE database.
|
| 2 |
+
|
| 3 |
+
The input database must contain ASE calculator results (energy, forces, and
|
| 4 |
+
optionally stress). The output checkpoint keeps the native OneScience model
|
| 5 |
+
configuration and can be loaded by ``eSENCalculator.from_checkpoint``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import copy
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
os.environ.setdefault(
|
| 18 |
+
"ONESCIENCE_ESEN_JD_PATH",
|
| 19 |
+
os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import yaml
|
| 24 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 25 |
+
from torch.utils.data import DataLoader, Subset
|
| 26 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 27 |
+
|
| 28 |
+
from onescience.datapipes.materials.custom_stack import data_list_collater
|
| 29 |
+
from onescience.datapipes.materials.custom_stack.storage.ase_datasets import AseDBDataset
|
| 30 |
+
from onescience.utils.esen.checkpoint import ESENCheckpointTransforms
|
| 31 |
+
from onescience.utils.uma.normalization.element_references import (
|
| 32 |
+
fit_linear_references,
|
| 33 |
+
)
|
| 34 |
+
from onescience.utils.uma.common.utils import load_model_and_weights_from_checkpoint
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class DistributedContext:
|
| 39 |
+
"""Runtime information for a normal Python process or a torchrun worker."""
|
| 40 |
+
|
| 41 |
+
rank: int = 0
|
| 42 |
+
world_size: int = 1
|
| 43 |
+
local_rank: int = 0
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def enabled(self) -> bool:
|
| 47 |
+
return self.world_size > 1
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def is_main(self) -> bool:
|
| 51 |
+
return self.rank == 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _init_distributed(device_name: str, backend: str) -> DistributedContext:
|
| 55 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 56 |
+
if world_size == 1:
|
| 57 |
+
if device_name.startswith("cuda") and torch.cuda.is_available():
|
| 58 |
+
torch.cuda.set_device(0)
|
| 59 |
+
return DistributedContext()
|
| 60 |
+
|
| 61 |
+
if not torch.distributed.is_available():
|
| 62 |
+
raise RuntimeError("torch.distributed is required for multi-device fine-tuning.")
|
| 63 |
+
rank = int(os.environ["RANK"])
|
| 64 |
+
local_rank = int(os.environ.get("LOCAL_RANK", rank))
|
| 65 |
+
if device_name.startswith("cuda"):
|
| 66 |
+
if not torch.cuda.is_available():
|
| 67 |
+
raise RuntimeError("torchrun requested multiple CUDA/DCU devices, but CUDA is unavailable.")
|
| 68 |
+
torch.cuda.set_device(local_rank)
|
| 69 |
+
torch.distributed.init_process_group(backend=backend, rank=rank, world_size=world_size)
|
| 70 |
+
return DistributedContext(rank=rank, world_size=world_size, local_rank=local_rank)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _close_distributed(context: DistributedContext) -> None:
|
| 74 |
+
if context.enabled and torch.distributed.is_initialized():
|
| 75 |
+
torch.distributed.barrier()
|
| 76 |
+
torch.distributed.destroy_process_group()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _loader(
|
| 80 |
+
path: str | list[str],
|
| 81 |
+
batch_size: int,
|
| 82 |
+
workers: int,
|
| 83 |
+
max_samples: int | None = None,
|
| 84 |
+
context: DistributedContext | None = None,
|
| 85 |
+
train: bool = False,
|
| 86 |
+
seed: int = 0,
|
| 87 |
+
) -> DataLoader:
|
| 88 |
+
dataset = AseDBDataset(
|
| 89 |
+
{
|
| 90 |
+
"src": path,
|
| 91 |
+
"a2g_args": {
|
| 92 |
+
"r_edges": False,
|
| 93 |
+
"r_energy": True,
|
| 94 |
+
"r_forces": True,
|
| 95 |
+
"r_stress": True,
|
| 96 |
+
},
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
if max_samples is not None:
|
| 100 |
+
sample_count = min(max_samples, len(dataset))
|
| 101 |
+
generator = torch.Generator().manual_seed(seed)
|
| 102 |
+
indices = torch.randperm(len(dataset), generator=generator)[:sample_count].tolist()
|
| 103 |
+
dataset = Subset(dataset, indices)
|
| 104 |
+
context = context or DistributedContext()
|
| 105 |
+
sampler = None
|
| 106 |
+
if context.enabled:
|
| 107 |
+
sampler = DistributedSampler(
|
| 108 |
+
dataset,
|
| 109 |
+
num_replicas=context.world_size,
|
| 110 |
+
rank=context.rank,
|
| 111 |
+
shuffle=train,
|
| 112 |
+
drop_last=False,
|
| 113 |
+
)
|
| 114 |
+
return DataLoader(
|
| 115 |
+
dataset,
|
| 116 |
+
batch_size=batch_size,
|
| 117 |
+
shuffle=sampler is None and train,
|
| 118 |
+
sampler=sampler,
|
| 119 |
+
num_workers=workers,
|
| 120 |
+
collate_fn=lambda items: data_list_collater(items, otf_graph=True),
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _loss(
|
| 125 |
+
pred,
|
| 126 |
+
batch,
|
| 127 |
+
energy_weight: float,
|
| 128 |
+
force_weight: float,
|
| 129 |
+
stress_weight: float,
|
| 130 |
+
transforms: ESENCheckpointTransforms,
|
| 131 |
+
):
|
| 132 |
+
losses = {}
|
| 133 |
+
if energy_weight:
|
| 134 |
+
energy_target = transforms.normalize_target(
|
| 135 |
+
"energy", batch.energy, pred["energy"], batch
|
| 136 |
+
)
|
| 137 |
+
energy_error = pred["energy"] - energy_target
|
| 138 |
+
natoms_shape = (-1,) + (1,) * (energy_error.ndim - 1)
|
| 139 |
+
natoms = batch.natoms.to(energy_error).reshape(natoms_shape)
|
| 140 |
+
losses["energy"] = (energy_error / natoms).square().mean()
|
| 141 |
+
if force_weight:
|
| 142 |
+
force_target = transforms.normalize_target(
|
| 143 |
+
"forces", batch.forces, pred["forces"], batch
|
| 144 |
+
)
|
| 145 |
+
losses["forces"] = (pred["forces"] - force_target).square().mean()
|
| 146 |
+
if stress_weight and hasattr(batch, "stress") and "stress" in pred:
|
| 147 |
+
stress_target = transforms.normalize_target(
|
| 148 |
+
"stress", batch.stress, pred["stress"], batch
|
| 149 |
+
)
|
| 150 |
+
losses["stress"] = (pred["stress"] - stress_target).square().mean()
|
| 151 |
+
total = energy_weight * losses.get("energy", 0.0)
|
| 152 |
+
total = total + force_weight * losses.get("forces", 0.0)
|
| 153 |
+
total = total + stress_weight * losses.get("stress", 0.0)
|
| 154 |
+
return total, {key: float(value.detach()) for key, value in losses.items()}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _run_epoch(
|
| 158 |
+
model,
|
| 159 |
+
loader,
|
| 160 |
+
device,
|
| 161 |
+
optimizer,
|
| 162 |
+
weights,
|
| 163 |
+
transforms: ESENCheckpointTransforms,
|
| 164 |
+
context: DistributedContext,
|
| 165 |
+
):
|
| 166 |
+
training = optimizer is not None
|
| 167 |
+
model.train(training)
|
| 168 |
+
total = 0.0
|
| 169 |
+
batches = 0
|
| 170 |
+
metric_names = tuple(
|
| 171 |
+
name
|
| 172 |
+
for name, weight in zip(("energy", "forces", "stress"), weights)
|
| 173 |
+
if weight
|
| 174 |
+
)
|
| 175 |
+
metrics = {name: 0.0 for name in metric_names}
|
| 176 |
+
for batch in loader:
|
| 177 |
+
batch = batch.to(device)
|
| 178 |
+
if training:
|
| 179 |
+
optimizer.zero_grad(set_to_none=True)
|
| 180 |
+
prediction = model(batch)
|
| 181 |
+
loss, batch_metrics = _loss(prediction, batch, *weights, transforms)
|
| 182 |
+
if training:
|
| 183 |
+
loss.backward()
|
| 184 |
+
optimizer.step()
|
| 185 |
+
total += float(loss.detach())
|
| 186 |
+
batches += 1
|
| 187 |
+
for key, value in batch_metrics.items():
|
| 188 |
+
metrics[key] = metrics.get(key, 0.0) + value
|
| 189 |
+
if batches == 0:
|
| 190 |
+
raise RuntimeError("The dataset contains no samples.")
|
| 191 |
+
values = torch.tensor([total, *metrics.values(), float(batches)], dtype=torch.float64, device=device)
|
| 192 |
+
if context.enabled:
|
| 193 |
+
torch.distributed.all_reduce(values, op=torch.distributed.ReduceOp.SUM)
|
| 194 |
+
global_batches = values[-1].item()
|
| 195 |
+
return {
|
| 196 |
+
"loss": values[0].item() / global_batches,
|
| 197 |
+
**{
|
| 198 |
+
key: values[index].item() / global_batches
|
| 199 |
+
for index, key in enumerate(metrics, start=1)
|
| 200 |
+
},
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def main() -> None:
|
| 205 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 206 |
+
parser.add_argument("--config", help="YAML configuration path")
|
| 207 |
+
parser.add_argument("--checkpoint")
|
| 208 |
+
parser.add_argument("--train", help="ASE DB or ASE-LMDB training path")
|
| 209 |
+
parser.add_argument("--val", help="ASE DB or ASE-LMDB validation path")
|
| 210 |
+
parser.add_argument("--output")
|
| 211 |
+
parser.add_argument("--device")
|
| 212 |
+
parser.add_argument("--epochs", type=int)
|
| 213 |
+
parser.add_argument("--batch-size", type=int)
|
| 214 |
+
parser.add_argument("--workers", type=int)
|
| 215 |
+
parser.add_argument("--lr", type=float)
|
| 216 |
+
parser.add_argument("--energy-weight", type=float)
|
| 217 |
+
parser.add_argument("--force-weight", type=float)
|
| 218 |
+
parser.add_argument("--stress-weight", type=float)
|
| 219 |
+
parser.add_argument("--max-train-samples", type=int)
|
| 220 |
+
parser.add_argument("--max-val-samples", type=int)
|
| 221 |
+
parser.add_argument("--backend", help="torch.distributed backend for torchrun")
|
| 222 |
+
parser.add_argument("--seed", type=int)
|
| 223 |
+
parser.add_argument(
|
| 224 |
+
"--fit-element-references",
|
| 225 |
+
action=argparse.BooleanOptionalAction,
|
| 226 |
+
default=None,
|
| 227 |
+
help="fit energy element references on the training data",
|
| 228 |
+
)
|
| 229 |
+
args = parser.parse_args()
|
| 230 |
+
|
| 231 |
+
if not args.config:
|
| 232 |
+
parser.error("--config is required; use a YAML file from demo/configs")
|
| 233 |
+
config_path = args.config
|
| 234 |
+
with Path(config_path).expanduser().open() as handle:
|
| 235 |
+
config = yaml.safe_load(handle) or {}
|
| 236 |
+
for key, value in config.items():
|
| 237 |
+
if getattr(args, key.replace("-", "_"), None) is None:
|
| 238 |
+
setattr(args, key.replace("-", "_"), value)
|
| 239 |
+
for key in ("checkpoint", "train", "val", "output"):
|
| 240 |
+
value = getattr(args, key)
|
| 241 |
+
if value is not None:
|
| 242 |
+
if isinstance(value, list):
|
| 243 |
+
value = [
|
| 244 |
+
os.path.expandvars(os.path.expanduser(str(item)))
|
| 245 |
+
for item in value
|
| 246 |
+
]
|
| 247 |
+
else:
|
| 248 |
+
value = os.path.expandvars(os.path.expanduser(str(value)))
|
| 249 |
+
setattr(args, key, value)
|
| 250 |
+
required = ("checkpoint", "train", "val", "output")
|
| 251 |
+
missing = [key for key in required if not getattr(args, key)]
|
| 252 |
+
if missing:
|
| 253 |
+
parser.error("missing required config fields: " + ", ".join(missing))
|
| 254 |
+
args.backend = args.backend or "nccl"
|
| 255 |
+
args.seed = 0 if args.seed is None else args.seed
|
| 256 |
+
args.fit_element_references = bool(args.fit_element_references)
|
| 257 |
+
if not any((args.energy_weight, args.force_weight, args.stress_weight)):
|
| 258 |
+
parser.error("at least one of energy_weight, force_weight, or stress_weight must be nonzero")
|
| 259 |
+
|
| 260 |
+
if args.device.startswith("cuda") and not torch.cuda.is_available():
|
| 261 |
+
raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false.")
|
| 262 |
+
context = _init_distributed(args.device, args.backend)
|
| 263 |
+
try:
|
| 264 |
+
if args.device.startswith("cuda"):
|
| 265 |
+
device = torch.device(f"cuda:{context.local_rank}")
|
| 266 |
+
else:
|
| 267 |
+
device = torch.device(args.device)
|
| 268 |
+
torch.manual_seed(args.seed + context.rank)
|
| 269 |
+
|
| 270 |
+
# Import registrations before the generic native checkpoint loader.
|
| 271 |
+
import onescience.models.esen # noqa: F401
|
| 272 |
+
|
| 273 |
+
model = load_model_and_weights_from_checkpoint(args.checkpoint).to(device)
|
| 274 |
+
transforms = ESENCheckpointTransforms.from_checkpoint(args.checkpoint)
|
| 275 |
+
if args.fit_element_references:
|
| 276 |
+
reference_dataset = _loader(
|
| 277 |
+
args.train, args.batch_size, args.workers
|
| 278 |
+
).dataset
|
| 279 |
+
fitted_references = fit_linear_references(
|
| 280 |
+
targets=["energy"],
|
| 281 |
+
dataset=reference_dataset,
|
| 282 |
+
batch_size=args.batch_size,
|
| 283 |
+
num_workers=args.workers,
|
| 284 |
+
log_metrics=False,
|
| 285 |
+
shuffle=False,
|
| 286 |
+
)
|
| 287 |
+
transforms.elementrefs["energy"] = fitted_references["energy"]
|
| 288 |
+
if context.is_main:
|
| 289 |
+
print("fitted energy element references from training data", flush=True)
|
| 290 |
+
transforms = transforms.to(device)
|
| 291 |
+
if context.enabled:
|
| 292 |
+
model = DistributedDataParallel(
|
| 293 |
+
model,
|
| 294 |
+
device_ids=[context.local_rank] if device.type == "cuda" else None,
|
| 295 |
+
output_device=context.local_rank if device.type == "cuda" else None,
|
| 296 |
+
)
|
| 297 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
|
| 298 |
+
train_loader = _loader(
|
| 299 |
+
args.train,
|
| 300 |
+
args.batch_size,
|
| 301 |
+
args.workers,
|
| 302 |
+
args.max_train_samples,
|
| 303 |
+
context=context,
|
| 304 |
+
train=True,
|
| 305 |
+
seed=args.seed,
|
| 306 |
+
)
|
| 307 |
+
val_loader = _loader(
|
| 308 |
+
args.val,
|
| 309 |
+
args.batch_size,
|
| 310 |
+
args.workers,
|
| 311 |
+
args.max_val_samples,
|
| 312 |
+
context=context,
|
| 313 |
+
train=False,
|
| 314 |
+
seed=args.seed + 1,
|
| 315 |
+
)
|
| 316 |
+
weights = (args.energy_weight, args.force_weight, args.stress_weight)
|
| 317 |
+
|
| 318 |
+
history = []
|
| 319 |
+
for epoch in range(args.epochs):
|
| 320 |
+
if isinstance(train_loader.sampler, DistributedSampler):
|
| 321 |
+
train_loader.sampler.set_epoch(epoch)
|
| 322 |
+
train_metrics = _run_epoch(
|
| 323 |
+
model, train_loader, device, optimizer, weights, transforms, context
|
| 324 |
+
)
|
| 325 |
+
# Force/stress outputs are gradients of the energy, so validation also
|
| 326 |
+
# needs autograd even though model parameters are not updated.
|
| 327 |
+
val_metrics = _run_epoch(
|
| 328 |
+
model, val_loader, device, None, weights, transforms, context
|
| 329 |
+
)
|
| 330 |
+
record = {"epoch": epoch, "train": train_metrics, "val": val_metrics}
|
| 331 |
+
if context.is_main:
|
| 332 |
+
history.append(record)
|
| 333 |
+
print(json.dumps(record, sort_keys=True), flush=True)
|
| 334 |
+
|
| 335 |
+
if context.is_main:
|
| 336 |
+
output = Path(args.output)
|
| 337 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 338 |
+
source = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
|
| 339 |
+
checkpoint = copy.deepcopy(source)
|
| 340 |
+
base_model = model.module if context.enabled else model
|
| 341 |
+
checkpoint["state_dict"] = {
|
| 342 |
+
key: value.detach().cpu() for key, value in base_model.state_dict().items()
|
| 343 |
+
}
|
| 344 |
+
checkpoint["elementrefs"] = {
|
| 345 |
+
name: {
|
| 346 |
+
key: value.detach().cpu()
|
| 347 |
+
for key, value in elementref.state_dict().items()
|
| 348 |
+
}
|
| 349 |
+
for name, elementref in transforms.elementrefs.items()
|
| 350 |
+
}
|
| 351 |
+
checkpoint.setdefault("metadata", {})
|
| 352 |
+
checkpoint["metadata"].update(
|
| 353 |
+
{
|
| 354 |
+
"onescience_esen_history": history,
|
| 355 |
+
"source_checkpoint": args.checkpoint,
|
| 356 |
+
"world_size": context.world_size,
|
| 357 |
+
"loss_space": "checkpoint_normalized",
|
| 358 |
+
"element_references": (
|
| 359 |
+
"fitted_from_training_data"
|
| 360 |
+
if args.fit_element_references
|
| 361 |
+
else "source_checkpoint"
|
| 362 |
+
),
|
| 363 |
+
}
|
| 364 |
+
)
|
| 365 |
+
torch.save(checkpoint, output)
|
| 366 |
+
print(f"saved checkpoint: {output}", flush=True)
|
| 367 |
+
finally:
|
| 368 |
+
_close_distributed(context)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
if __name__ == "__main__":
|
| 372 |
+
main()
|
md.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run a short NVT molecular-dynamics trajectory with eSEN."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
os.environ.setdefault(
|
| 9 |
+
"ONESCIENCE_ESEN_JD_PATH",
|
| 10 |
+
os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from ase import units
|
| 15 |
+
from ase.build import bulk
|
| 16 |
+
from ase.io import read
|
| 17 |
+
from ase.io.trajectory import Trajectory
|
| 18 |
+
from ase.md.langevin import Langevin
|
| 19 |
+
from ase.md.velocitydistribution import MaxwellBoltzmannDistribution, Stationary
|
| 20 |
+
|
| 21 |
+
from onescience.utils.esen import eSENCalculator
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def default_checkpoint() -> str:
|
| 25 |
+
return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_structure(path: str | None, repeat: tuple[int, int, int] | None):
|
| 29 |
+
if path:
|
| 30 |
+
atoms = read(path)
|
| 31 |
+
else:
|
| 32 |
+
atoms = bulk("Si", "diamond", a=5.43).repeat((2, 2, 2))
|
| 33 |
+
return atoms.repeat(repeat) if repeat else atoms
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> None:
|
| 37 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 38 |
+
parser.add_argument("--checkpoint", default=default_checkpoint())
|
| 39 |
+
parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--repeat",
|
| 42 |
+
type=int,
|
| 43 |
+
nargs=3,
|
| 44 |
+
metavar=("NX", "NY", "NZ"),
|
| 45 |
+
help="repeat the input structure along its three cell vectors",
|
| 46 |
+
)
|
| 47 |
+
parser.add_argument("--steps", type=int, default=10)
|
| 48 |
+
parser.add_argument("--temperature", type=float, default=300.0)
|
| 49 |
+
parser.add_argument("--timestep", type=float, default=1.0, help="time step in fs")
|
| 50 |
+
parser.add_argument(
|
| 51 |
+
"--friction", type=float, default=0.01, help="Langevin friction in 1/fs"
|
| 52 |
+
)
|
| 53 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 54 |
+
parser.add_argument("--output", default="md.traj")
|
| 55 |
+
parser.add_argument("--device", default="cuda")
|
| 56 |
+
args = parser.parse_args()
|
| 57 |
+
|
| 58 |
+
repeat = tuple(args.repeat) if args.repeat else None
|
| 59 |
+
atoms = load_structure(args.input, repeat)
|
| 60 |
+
atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
|
| 61 |
+
rng = np.random.default_rng(args.seed)
|
| 62 |
+
MaxwellBoltzmannDistribution(atoms, temperature_K=args.temperature, rng=rng)
|
| 63 |
+
Stationary(atoms)
|
| 64 |
+
dynamics = Langevin(
|
| 65 |
+
atoms,
|
| 66 |
+
timestep=args.timestep * units.fs,
|
| 67 |
+
temperature_K=args.temperature,
|
| 68 |
+
friction=args.friction / units.fs,
|
| 69 |
+
)
|
| 70 |
+
trajectory = Trajectory(args.output, "w", atoms)
|
| 71 |
+
dynamics.attach(trajectory.write, interval=1)
|
| 72 |
+
dynamics.run(args.steps)
|
| 73 |
+
trajectory.close()
|
| 74 |
+
print("formula:", atoms.get_chemical_formula())
|
| 75 |
+
print("atoms:", len(atoms))
|
| 76 |
+
print("steps:", dynamics.nsteps)
|
| 77 |
+
print("energy (eV):", atoms.get_potential_energy())
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
model/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""eSEN atomistic potentials adapted to OneScience's UMA runtime."""
|
| 2 |
+
|
| 3 |
+
from .esen import eSEN_Backbone, Linear_Force_Head, MLP_EFS_Head, MLP_Energy_Head
|
| 4 |
+
from .esen_dens import (
|
| 5 |
+
Linear_Force_Head_DeNS,
|
| 6 |
+
MLP_EFS_Head as MLP_EFS_Head_DeNS,
|
| 7 |
+
eSEN_DeNS_Backbone,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"eSEN_Backbone",
|
| 12 |
+
"eSEN_DeNS_Backbone",
|
| 13 |
+
"MLP_EFS_Head",
|
| 14 |
+
"MLP_EFS_Head_DeNS",
|
| 15 |
+
"MLP_Energy_Head",
|
| 16 |
+
"Linear_Force_Head",
|
| 17 |
+
"Linear_Force_Head_DeNS",
|
| 18 |
+
]
|
model/esen.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Copyright (c) Meta, Inc. and its affiliates.
|
| 3 |
+
|
| 4 |
+
This source code is licensed under the MIT license found in the
|
| 5 |
+
LICENSE file in the root directory of this source tree.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
|
| 15 |
+
from onescience.models.UMA.base import BackboneInterface, HeadInterface
|
| 16 |
+
from onescience.models.esen.graph import GraphModelMixin
|
| 17 |
+
from onescience.utils.uma.common.registry import registry
|
| 18 |
+
from onescience.utils.uma.common.utils import conditional_grad
|
| 19 |
+
|
| 20 |
+
from onescience.modules.layer.esen.common.rotation import (
|
| 21 |
+
init_edge_rot_mat,
|
| 22 |
+
rotation_to_wigner,
|
| 23 |
+
)
|
| 24 |
+
from onescience.modules.layer.esen.common.so3 import (
|
| 25 |
+
CoefficientMapping,
|
| 26 |
+
SO3_Grid,
|
| 27 |
+
)
|
| 28 |
+
from onescience.modules.layer.esen.esen_block import eSEN_Block
|
| 29 |
+
from onescience.modules.layer.esen.nn.embedding import EdgeDegreeEmbedding
|
| 30 |
+
from onescience.modules.layer.esen.nn.layer_norm import (
|
| 31 |
+
EquivariantLayerNormArray,
|
| 32 |
+
EquivariantLayerNormArraySphericalHarmonics,
|
| 33 |
+
EquivariantRMSNormArraySphericalHarmonicsV2,
|
| 34 |
+
get_normalization_layer,
|
| 35 |
+
)
|
| 36 |
+
from onescience.modules.layer.esen.nn.radial import EnvelopedBesselBasis, GaussianSmearing
|
| 37 |
+
from onescience.modules.layer.esen.nn.so3_layers import SO3_Linear
|
| 38 |
+
from onescience.modules.func_utils.esen_path_utils import resolve_esen_jd_path
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@registry.register_model("esen_backbone")
|
| 42 |
+
class eSEN_Backbone(nn.Module, GraphModelMixin):
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
max_num_elements: int = 100,
|
| 46 |
+
sphere_channels: int = 128,
|
| 47 |
+
lmax: int = 2,
|
| 48 |
+
mmax: int = 2,
|
| 49 |
+
grid_resolution: int | None = None,
|
| 50 |
+
otf_graph: bool = False,
|
| 51 |
+
max_neighbors: int = 300,
|
| 52 |
+
use_pbc: bool = True,
|
| 53 |
+
use_pbc_single: bool = False,
|
| 54 |
+
cutoff: float = 5.0,
|
| 55 |
+
edge_channels: int = 128,
|
| 56 |
+
distance_function: str = "gaussian",
|
| 57 |
+
num_distance_basis: int = 512,
|
| 58 |
+
direct_forces: bool = True,
|
| 59 |
+
regress_forces: bool = True,
|
| 60 |
+
regress_stress: bool = False,
|
| 61 |
+
# escnmd specific
|
| 62 |
+
num_layers: int = 2,
|
| 63 |
+
hidden_channels: int = 128,
|
| 64 |
+
norm_type: str = "rms_norm_sh",
|
| 65 |
+
act_type: str = "s2",
|
| 66 |
+
mlp_type: str = "grid",
|
| 67 |
+
use_envelope: bool = False,
|
| 68 |
+
activation_checkpointing: bool = False,
|
| 69 |
+
jd_path: str | None = None,
|
| 70 |
+
):
|
| 71 |
+
super().__init__()
|
| 72 |
+
|
| 73 |
+
self.max_num_elements = max_num_elements
|
| 74 |
+
self.lmax = lmax
|
| 75 |
+
self.mmax = mmax
|
| 76 |
+
self.sphere_channels = sphere_channels
|
| 77 |
+
self.grid_resolution = grid_resolution
|
| 78 |
+
|
| 79 |
+
self.regress_forces = regress_forces
|
| 80 |
+
self.direct_forces = direct_forces
|
| 81 |
+
self.regress_stress = regress_stress
|
| 82 |
+
|
| 83 |
+
self.otf_graph = otf_graph
|
| 84 |
+
self.max_neighbors = max_neighbors
|
| 85 |
+
self.use_pbc = use_pbc
|
| 86 |
+
self.use_pbc_single = use_pbc_single
|
| 87 |
+
self.enforce_max_neighbors_strictly = False
|
| 88 |
+
self.activation_checkpointing = activation_checkpointing
|
| 89 |
+
|
| 90 |
+
self.mlp_type = mlp_type
|
| 91 |
+
self.use_envelope = use_envelope
|
| 92 |
+
|
| 93 |
+
# rotation utils
|
| 94 |
+
Jd_list = torch.load(resolve_esen_jd_path(jd_path))
|
| 95 |
+
for l in range(self.lmax + 1):
|
| 96 |
+
self.register_buffer(f"Jd_{l}", Jd_list[l])
|
| 97 |
+
self.sph_feature_size = int((self.lmax + 1) ** 2)
|
| 98 |
+
self.mappingReduced = CoefficientMapping(self.lmax, self.mmax)
|
| 99 |
+
|
| 100 |
+
# lmax_lmax for node, lmax_mmax for edge
|
| 101 |
+
self.SO3_grid = nn.ModuleDict()
|
| 102 |
+
self.SO3_grid["lmax_lmax"] = SO3_Grid(
|
| 103 |
+
self.lmax, self.lmax, resolution=grid_resolution, rescale=True
|
| 104 |
+
)
|
| 105 |
+
self.SO3_grid["lmax_mmax"] = SO3_Grid(
|
| 106 |
+
self.lmax, self.mmax, resolution=grid_resolution, rescale=True
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# atom embedding
|
| 110 |
+
self.sphere_embedding = nn.Embedding(
|
| 111 |
+
self.max_num_elements, self.sphere_channels
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# edge distance embedding
|
| 115 |
+
self.cutoff = cutoff
|
| 116 |
+
self.edge_channels = edge_channels
|
| 117 |
+
self.distance_function = distance_function
|
| 118 |
+
self.num_distance_basis = num_distance_basis
|
| 119 |
+
|
| 120 |
+
if self.distance_function == "gaussian":
|
| 121 |
+
self.distance_expansion = GaussianSmearing(
|
| 122 |
+
0.0,
|
| 123 |
+
self.cutoff,
|
| 124 |
+
self.num_distance_basis,
|
| 125 |
+
2.0,
|
| 126 |
+
)
|
| 127 |
+
elif self.distance_function == "bessel":
|
| 128 |
+
self.distance_expansion = EnvelopedBesselBasis(
|
| 129 |
+
num_radial=self.num_distance_basis,
|
| 130 |
+
cutoff=cutoff,
|
| 131 |
+
)
|
| 132 |
+
self.distance_expansion.offset = [self.cutoff]
|
| 133 |
+
self.distance_expansion.num_output = self.num_distance_basis
|
| 134 |
+
else:
|
| 135 |
+
raise ValueError("Unknown distance function")
|
| 136 |
+
|
| 137 |
+
# equivariant initial embedding
|
| 138 |
+
self.source_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
|
| 139 |
+
self.target_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
|
| 140 |
+
nn.init.uniform_(self.source_embedding.weight.data, -0.001, 0.001)
|
| 141 |
+
nn.init.uniform_(self.target_embedding.weight.data, -0.001, 0.001)
|
| 142 |
+
|
| 143 |
+
self.edge_channels_list = [
|
| 144 |
+
self.num_distance_basis + 2 * self.edge_channels,
|
| 145 |
+
self.edge_channels,
|
| 146 |
+
self.edge_channels,
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
self.edge_degree_embedding = EdgeDegreeEmbedding(
|
| 150 |
+
sphere_channels=self.sphere_channels,
|
| 151 |
+
lmax=self.lmax,
|
| 152 |
+
mmax=self.mmax,
|
| 153 |
+
max_num_elements=self.max_num_elements,
|
| 154 |
+
edge_channels_list=self.edge_channels_list,
|
| 155 |
+
rescale_factor=5.0,
|
| 156 |
+
cutoff=self.cutoff,
|
| 157 |
+
mappingReduced=self.mappingReduced,
|
| 158 |
+
out_mask=self.SO3_grid["lmax_lmax"].mapping.coefficient_idx(
|
| 159 |
+
self.lmax, self.mmax
|
| 160 |
+
),
|
| 161 |
+
use_envelope=use_envelope,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
self.num_layers = num_layers
|
| 165 |
+
self.hidden_channels = hidden_channels
|
| 166 |
+
self.norm_type = norm_type
|
| 167 |
+
self.act_type = act_type
|
| 168 |
+
|
| 169 |
+
# Initialize the blocks for each layer
|
| 170 |
+
self.blocks = nn.ModuleList()
|
| 171 |
+
for _ in range(self.num_layers):
|
| 172 |
+
block = eSEN_Block(
|
| 173 |
+
self.sphere_channels,
|
| 174 |
+
self.hidden_channels,
|
| 175 |
+
self.lmax,
|
| 176 |
+
self.mmax,
|
| 177 |
+
self.mappingReduced,
|
| 178 |
+
self.SO3_grid,
|
| 179 |
+
self.edge_channels_list,
|
| 180 |
+
self.cutoff,
|
| 181 |
+
self.norm_type,
|
| 182 |
+
self.act_type,
|
| 183 |
+
self.mlp_type,
|
| 184 |
+
self.use_envelope,
|
| 185 |
+
)
|
| 186 |
+
self.blocks.append(block)
|
| 187 |
+
|
| 188 |
+
self.norm = get_normalization_layer(
|
| 189 |
+
self.norm_type,
|
| 190 |
+
lmax=self.lmax,
|
| 191 |
+
num_channels=self.sphere_channels,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
def get_rotmat_and_wigner(self, edge_distance_vecs):
|
| 195 |
+
edge_rot_mat = init_edge_rot_mat(
|
| 196 |
+
edge_distance_vecs, rot_clip=(not self.direct_forces)
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
Jd_buffers = [
|
| 200 |
+
getattr(self, f"Jd_{l}").type(edge_rot_mat.dtype)
|
| 201 |
+
for l in range(self.lmax + 1)
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
wigner = rotation_to_wigner(
|
| 205 |
+
edge_rot_mat,
|
| 206 |
+
0,
|
| 207 |
+
self.lmax,
|
| 208 |
+
Jd_buffers,
|
| 209 |
+
rot_clip=(not self.direct_forces),
|
| 210 |
+
)
|
| 211 |
+
wigner_inv = torch.transpose(wigner, 1, 2).contiguous()
|
| 212 |
+
|
| 213 |
+
return edge_rot_mat, wigner, wigner_inv
|
| 214 |
+
|
| 215 |
+
def generate_graph(self, *args, **kwargs):
|
| 216 |
+
graph = super().generate_graph(*args, **kwargs)
|
| 217 |
+
return {
|
| 218 |
+
"edge_index": graph.edge_index,
|
| 219 |
+
"edge_distance": graph.edge_distance,
|
| 220 |
+
"edge_distance_vec": graph.edge_distance_vec,
|
| 221 |
+
"cell_offsets": graph.cell_offsets,
|
| 222 |
+
"offset_distances": None,
|
| 223 |
+
"neighbors": None,
|
| 224 |
+
"node_offset": 0,
|
| 225 |
+
"batch_full": graph.batch_full,
|
| 226 |
+
"atomic_numbers_full": graph.atomic_numbers_full,
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
@conditional_grad(torch.enable_grad())
|
| 230 |
+
def forward(self, data_dict) -> dict[str, torch.Tensor]:
|
| 231 |
+
###############################################################
|
| 232 |
+
# gradient-based forces/stress
|
| 233 |
+
###############################################################
|
| 234 |
+
data_dict["atomic_numbers"] = data_dict["atomic_numbers"].long()
|
| 235 |
+
|
| 236 |
+
displacement = None
|
| 237 |
+
orig_cell = None
|
| 238 |
+
if self.regress_stress and not self.direct_forces:
|
| 239 |
+
displacement = torch.zeros(
|
| 240 |
+
(3, 3),
|
| 241 |
+
dtype=data_dict["pos"].dtype,
|
| 242 |
+
device=data_dict["pos"].device,
|
| 243 |
+
)
|
| 244 |
+
# num_batch = data_dict["num_graphs"]
|
| 245 |
+
num_batch = data_dict.get("num_graphs", len(data_dict["natoms"]))
|
| 246 |
+
displacement = displacement.view(-1, 3, 3).expand(num_batch, 3, 3)
|
| 247 |
+
displacement.requires_grad_(True)
|
| 248 |
+
symmetric_displacement = 0.5 * (
|
| 249 |
+
displacement + displacement.transpose(-1, -2)
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
data_dict["pos"].requires_grad_(True)
|
| 253 |
+
data_dict["pos"] = data_dict["pos"] + torch.bmm(
|
| 254 |
+
data_dict["pos"].unsqueeze(-2),
|
| 255 |
+
torch.index_select(symmetric_displacement, 0, data_dict["batch"]),
|
| 256 |
+
).squeeze(-2)
|
| 257 |
+
|
| 258 |
+
orig_cell = data_dict["cell"]
|
| 259 |
+
data_dict["cell"] = data_dict["cell"] + torch.bmm(
|
| 260 |
+
data_dict["cell"], symmetric_displacement
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
if not self.regress_stress and self.regress_forces and not self.direct_forces:
|
| 264 |
+
data_dict["pos"].requires_grad_(True)
|
| 265 |
+
|
| 266 |
+
if self.otf_graph:
|
| 267 |
+
graph_dict = self.generate_graph(data_dict)
|
| 268 |
+
else:
|
| 269 |
+
cell_per_edge = data_dict["cell"].repeat_interleave(
|
| 270 |
+
data_dict["nedges"], dim=0
|
| 271 |
+
)
|
| 272 |
+
shifts = torch.einsum(
|
| 273 |
+
"ij,ijk->ik",
|
| 274 |
+
data_dict["cell_offsets"].to(cell_per_edge.dtype),
|
| 275 |
+
cell_per_edge,
|
| 276 |
+
)
|
| 277 |
+
edge_distance_vec = (
|
| 278 |
+
data_dict["pos"][data_dict["edge_index"][0]]
|
| 279 |
+
- data_dict["pos"][data_dict["edge_index"][1]]
|
| 280 |
+
+ shifts
|
| 281 |
+
)
|
| 282 |
+
# pylint: disable=E1102
|
| 283 |
+
edge_distance = torch.linalg.norm(edge_distance_vec, dim=-1, keepdim=False)
|
| 284 |
+
graph_dict = {
|
| 285 |
+
"atomic_numbers_full": data_dict["atomic_numbers_full"],
|
| 286 |
+
"batch_full": data_dict["batch_full"],
|
| 287 |
+
"edge_index": data_dict["edge_index"],
|
| 288 |
+
"edge_distance": edge_distance,
|
| 289 |
+
"edge_distance_vec": edge_distance_vec,
|
| 290 |
+
"node_offset": 0,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
_, wigner, wigner_inv = self.get_rotmat_and_wigner(
|
| 294 |
+
graph_dict["edge_distance_vec"]
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
###############################################################
|
| 298 |
+
# Initialize node embeddings
|
| 299 |
+
###############################################################
|
| 300 |
+
|
| 301 |
+
x_message = torch.zeros(
|
| 302 |
+
data_dict["pos"].shape[0],
|
| 303 |
+
self.sph_feature_size,
|
| 304 |
+
self.sphere_channels,
|
| 305 |
+
device=data_dict["pos"].device,
|
| 306 |
+
dtype=data_dict["pos"].dtype,
|
| 307 |
+
)
|
| 308 |
+
x_message[:, 0, :] = self.sphere_embedding(data_dict["atomic_numbers"])
|
| 309 |
+
|
| 310 |
+
# edge degree embedding
|
| 311 |
+
edge_distance_embedding = self.distance_expansion(graph_dict["edge_distance"])
|
| 312 |
+
source_embedding = self.source_embedding(
|
| 313 |
+
data_dict["atomic_numbers"][graph_dict["edge_index"][0]]
|
| 314 |
+
)
|
| 315 |
+
target_embedding = self.target_embedding(
|
| 316 |
+
data_dict["atomic_numbers"][graph_dict["edge_index"][1]]
|
| 317 |
+
)
|
| 318 |
+
x_edge = torch.cat(
|
| 319 |
+
(edge_distance_embedding, source_embedding, target_embedding), dim=1
|
| 320 |
+
)
|
| 321 |
+
x_message = self.edge_degree_embedding(
|
| 322 |
+
x_message,
|
| 323 |
+
x_edge,
|
| 324 |
+
graph_dict["edge_distance"],
|
| 325 |
+
graph_dict["edge_index"],
|
| 326 |
+
wigner_inv,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
###############################################################
|
| 330 |
+
# Update spherical node embeddings
|
| 331 |
+
###############################################################
|
| 332 |
+
if graph_dict["edge_index"].shape[1] != 0:
|
| 333 |
+
for i in range(self.num_layers):
|
| 334 |
+
if self.activation_checkpointing:
|
| 335 |
+
x_message = torch.utils.checkpoint.checkpoint(
|
| 336 |
+
self.blocks[i],
|
| 337 |
+
x_message,
|
| 338 |
+
x_edge,
|
| 339 |
+
graph_dict["edge_distance"],
|
| 340 |
+
graph_dict["edge_index"],
|
| 341 |
+
wigner,
|
| 342 |
+
wigner_inv,
|
| 343 |
+
graph_dict["node_offset"],
|
| 344 |
+
use_reentrant=False,
|
| 345 |
+
)
|
| 346 |
+
else:
|
| 347 |
+
x_message = self.blocks[i](
|
| 348 |
+
x_message,
|
| 349 |
+
x_edge,
|
| 350 |
+
graph_dict["edge_distance"],
|
| 351 |
+
graph_dict["edge_index"],
|
| 352 |
+
wigner,
|
| 353 |
+
wigner_inv,
|
| 354 |
+
node_offset=graph_dict["node_offset"],
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
# Final layer norm
|
| 358 |
+
x_message = self.norm(x_message)
|
| 359 |
+
|
| 360 |
+
out = {
|
| 361 |
+
"node_embedding": x_message,
|
| 362 |
+
"displacement": displacement,
|
| 363 |
+
"orig_cell": orig_cell,
|
| 364 |
+
}
|
| 365 |
+
out.update(graph_dict)
|
| 366 |
+
|
| 367 |
+
return out
|
| 368 |
+
|
| 369 |
+
@property
|
| 370 |
+
def num_params(self):
|
| 371 |
+
return sum(p.numel() for p in self.parameters())
|
| 372 |
+
|
| 373 |
+
@torch.jit.ignore
|
| 374 |
+
def no_weight_decay(self) -> set:
|
| 375 |
+
no_wd_list = []
|
| 376 |
+
named_parameters_list = [name for name, _ in self.named_parameters()]
|
| 377 |
+
for module_name, module in self.named_modules():
|
| 378 |
+
if isinstance(
|
| 379 |
+
module,
|
| 380 |
+
(
|
| 381 |
+
torch.nn.Linear,
|
| 382 |
+
SO3_Linear,
|
| 383 |
+
torch.nn.LayerNorm,
|
| 384 |
+
EquivariantLayerNormArray,
|
| 385 |
+
EquivariantLayerNormArraySphericalHarmonics,
|
| 386 |
+
EquivariantRMSNormArraySphericalHarmonicsV2,
|
| 387 |
+
),
|
| 388 |
+
):
|
| 389 |
+
for parameter_name, _ in module.named_parameters():
|
| 390 |
+
if (
|
| 391 |
+
isinstance(module, (torch.nn.Linear, SO3_Linear))
|
| 392 |
+
and "weight" in parameter_name
|
| 393 |
+
):
|
| 394 |
+
continue
|
| 395 |
+
global_parameter_name = module_name + "." + parameter_name
|
| 396 |
+
assert global_parameter_name in named_parameters_list
|
| 397 |
+
no_wd_list.append(global_parameter_name)
|
| 398 |
+
|
| 399 |
+
return set(no_wd_list)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
@registry.register_model("esen_mlp_efs_head")
|
| 403 |
+
class MLP_EFS_Head(nn.Module, HeadInterface):
|
| 404 |
+
def __init__(self, backbone):
|
| 405 |
+
super().__init__()
|
| 406 |
+
backbone.energy_block = None
|
| 407 |
+
backbone.force_block = None
|
| 408 |
+
self.regress_stress = backbone.regress_stress
|
| 409 |
+
self.regress_forces = backbone.regress_forces
|
| 410 |
+
|
| 411 |
+
self.sphere_channels = backbone.sphere_channels
|
| 412 |
+
self.hidden_channels = backbone.hidden_channels
|
| 413 |
+
self.energy_block = nn.Sequential(
|
| 414 |
+
nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
|
| 415 |
+
nn.SiLU(),
|
| 416 |
+
nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
|
| 417 |
+
nn.SiLU(),
|
| 418 |
+
nn.Linear(self.hidden_channels, 1, bias=True),
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
backbone.direct_forces = False
|
| 422 |
+
|
| 423 |
+
@conditional_grad(torch.enable_grad())
|
| 424 |
+
def forward(self, data, emb: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 425 |
+
energy_key = "energy"
|
| 426 |
+
forces_key = "forces"
|
| 427 |
+
stress_key = "stress"
|
| 428 |
+
|
| 429 |
+
outputs = {}
|
| 430 |
+
|
| 431 |
+
node_energy = self.energy_block(
|
| 432 |
+
emb["node_embedding"].narrow(1, 0, 1).squeeze()
|
| 433 |
+
).view(-1, 1, 1)
|
| 434 |
+
|
| 435 |
+
energy = torch.zeros(
|
| 436 |
+
len(data["natoms"]), device=data["pos"].device, dtype=node_energy.dtype
|
| 437 |
+
)
|
| 438 |
+
energy.index_add_(0, data["batch"], node_energy.view(-1))
|
| 439 |
+
outputs[energy_key] = energy
|
| 440 |
+
|
| 441 |
+
if self.regress_stress:
|
| 442 |
+
grads = torch.autograd.grad(
|
| 443 |
+
[energy.sum()],
|
| 444 |
+
[data["pos"], emb["displacement"]],
|
| 445 |
+
create_graph=self.training,
|
| 446 |
+
)
|
| 447 |
+
forces = torch.neg(grads[0])
|
| 448 |
+
virial = grads[1].view(-1, 3, 3)
|
| 449 |
+
volume = torch.det(data["cell"]).abs().unsqueeze(-1)
|
| 450 |
+
stress = virial / volume.view(-1, 1, 1)
|
| 451 |
+
virial = torch.neg(virial)
|
| 452 |
+
outputs[forces_key] = forces
|
| 453 |
+
outputs[stress_key] = stress.view(-1, 9)
|
| 454 |
+
data["cell"] = emb["orig_cell"]
|
| 455 |
+
elif self.regress_forces:
|
| 456 |
+
forces = (
|
| 457 |
+
-1
|
| 458 |
+
* torch.autograd.grad(
|
| 459 |
+
energy.sum(), data["pos"], create_graph=self.training
|
| 460 |
+
)[0]
|
| 461 |
+
)
|
| 462 |
+
outputs[forces_key] = forces
|
| 463 |
+
return outputs
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
@registry.register_model("esen_mlp_energy_head")
|
| 467 |
+
class MLP_Energy_Head(nn.Module, HeadInterface):
|
| 468 |
+
def __init__(self, backbone, reduce: str = "sum"):
|
| 469 |
+
super().__init__()
|
| 470 |
+
self.reduce = reduce
|
| 471 |
+
|
| 472 |
+
self.sphere_channels = backbone.sphere_channels
|
| 473 |
+
self.hidden_channels = backbone.hidden_channels
|
| 474 |
+
self.energy_block = nn.Sequential(
|
| 475 |
+
nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
|
| 476 |
+
nn.SiLU(),
|
| 477 |
+
nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
|
| 478 |
+
nn.SiLU(),
|
| 479 |
+
nn.Linear(self.hidden_channels, 1, bias=True),
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
def forward(self, data_dict, emb: dict[str, torch.Tensor]):
|
| 483 |
+
node_energy = self.energy_block(
|
| 484 |
+
emb["node_embedding"].narrow(1, 0, 1).squeeze()
|
| 485 |
+
).view(-1, 1, 1)
|
| 486 |
+
|
| 487 |
+
energy = torch.zeros(
|
| 488 |
+
len(data_dict["natoms"]),
|
| 489 |
+
device=node_energy.device,
|
| 490 |
+
dtype=node_energy.dtype,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
energy.index_add_(0, data_dict["batch"], node_energy.view(-1))
|
| 494 |
+
if self.reduce == "sum":
|
| 495 |
+
return {"energy": energy}
|
| 496 |
+
elif self.reduce == "mean":
|
| 497 |
+
return {"energy": energy / data_dict["natoms"]}
|
| 498 |
+
else:
|
| 499 |
+
raise ValueError(
|
| 500 |
+
f"reduce can only be sum or mean, user provided: {self.reduce}"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@registry.register_model("esen_linear_force_head")
|
| 505 |
+
class Linear_Force_Head(nn.Module, HeadInterface):
|
| 506 |
+
def __init__(self, backbone):
|
| 507 |
+
super().__init__()
|
| 508 |
+
self.linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
|
| 509 |
+
|
| 510 |
+
def forward(self, data_dict, emb: dict[str, torch.Tensor]):
|
| 511 |
+
forces = self.linear(emb["node_embedding"].narrow(1, 0, 4))
|
| 512 |
+
forces = forces.narrow(1, 1, 3)
|
| 513 |
+
forces = forces.view(-1, 3).contiguous()
|
| 514 |
+
return {"forces": forces}
|
model/esen_dens.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Copyright (c) Meta, Inc. and its affiliates.
|
| 3 |
+
|
| 4 |
+
This source code is licensed under the MIT license found in the
|
| 5 |
+
LICENSE file in the root directory of this source tree.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
from e3nn import o3
|
| 16 |
+
|
| 17 |
+
from onescience.models.UMA.base import BackboneInterface, HeadInterface
|
| 18 |
+
from onescience.models.esen.graph import GraphModelMixin
|
| 19 |
+
from onescience.utils.uma.common.registry import registry
|
| 20 |
+
from onescience.utils.uma.common.utils import conditional_grad
|
| 21 |
+
|
| 22 |
+
from onescience.modules.layer.esen.common.rotation import (
|
| 23 |
+
init_edge_rot_mat,
|
| 24 |
+
rotation_to_wigner,
|
| 25 |
+
)
|
| 26 |
+
from onescience.modules.layer.esen.common.so3 import (
|
| 27 |
+
CoefficientMapping,
|
| 28 |
+
SO3_Grid,
|
| 29 |
+
)
|
| 30 |
+
from onescience.modules.layer.esen.esen_block import eSEN_Block
|
| 31 |
+
from onescience.modules.layer.esen.nn.embedding import EdgeDegreeEmbedding
|
| 32 |
+
from onescience.modules.layer.esen.nn.layer_norm import (
|
| 33 |
+
EquivariantLayerNormArray,
|
| 34 |
+
EquivariantLayerNormArraySphericalHarmonics,
|
| 35 |
+
EquivariantRMSNormArraySphericalHarmonicsV2,
|
| 36 |
+
get_normalization_layer,
|
| 37 |
+
)
|
| 38 |
+
from onescience.modules.layer.esen.nn.radial import EnvelopedBesselBasis, GaussianSmearing
|
| 39 |
+
from onescience.modules.layer.esen.nn.so3_layers import SO3_Linear
|
| 40 |
+
from onescience.modules.func_utils.esen_path_utils import resolve_esen_jd_path
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@registry.register_model("esen_backbone_dens")
|
| 44 |
+
class eSEN_DeNS_Backbone(nn.Module, GraphModelMixin):
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
max_num_elements: int = 100,
|
| 48 |
+
sphere_channels: int = 128,
|
| 49 |
+
lmax: int = 2,
|
| 50 |
+
mmax: int = 2,
|
| 51 |
+
grid_resolution: int | None = None,
|
| 52 |
+
otf_graph: bool = False,
|
| 53 |
+
max_neighbors: int = 300,
|
| 54 |
+
use_pbc: bool = True,
|
| 55 |
+
use_pbc_single: bool = False,
|
| 56 |
+
cutoff: float = 5.0,
|
| 57 |
+
edge_channels: int = 128,
|
| 58 |
+
distance_function: str = "gaussian",
|
| 59 |
+
num_distance_basis: int = 512,
|
| 60 |
+
direct_forces: bool = True,
|
| 61 |
+
regress_forces: bool = True,
|
| 62 |
+
regress_stress: bool = False,
|
| 63 |
+
# escnmd specific
|
| 64 |
+
num_layers: int = 2,
|
| 65 |
+
hidden_channels: int = 128,
|
| 66 |
+
norm_type: str = "rms_norm_sh",
|
| 67 |
+
act_type: str = "s2",
|
| 68 |
+
mlp_type: str = "grid",
|
| 69 |
+
use_envelope: bool = False,
|
| 70 |
+
activation_checkpointing: bool = False,
|
| 71 |
+
jd_path: str | None = None,
|
| 72 |
+
):
|
| 73 |
+
super().__init__()
|
| 74 |
+
|
| 75 |
+
self.max_num_elements = max_num_elements
|
| 76 |
+
self.lmax = lmax
|
| 77 |
+
self.mmax = mmax
|
| 78 |
+
self.sphere_channels = sphere_channels
|
| 79 |
+
self.grid_resolution = grid_resolution
|
| 80 |
+
|
| 81 |
+
self.regress_forces = regress_forces
|
| 82 |
+
self.direct_forces = direct_forces
|
| 83 |
+
self.regress_stress = regress_stress
|
| 84 |
+
|
| 85 |
+
self.otf_graph = otf_graph
|
| 86 |
+
self.max_neighbors = max_neighbors
|
| 87 |
+
self.use_pbc = use_pbc
|
| 88 |
+
self.use_pbc_single = use_pbc_single
|
| 89 |
+
self.enforce_max_neighbors_strictly = False
|
| 90 |
+
self.activation_checkpointing = activation_checkpointing
|
| 91 |
+
|
| 92 |
+
self.mlp_type = mlp_type
|
| 93 |
+
self.use_envelope = use_envelope
|
| 94 |
+
|
| 95 |
+
# rotation utils
|
| 96 |
+
Jd_list = torch.load(resolve_esen_jd_path(jd_path))
|
| 97 |
+
for l in range(self.lmax + 1):
|
| 98 |
+
self.register_buffer(f"Jd_{l}", Jd_list[l])
|
| 99 |
+
self.sph_feature_size = int((self.lmax + 1) ** 2)
|
| 100 |
+
self.mappingReduced = CoefficientMapping(self.lmax, self.mmax)
|
| 101 |
+
|
| 102 |
+
# lmax_lmax for node, lmax_mmax for edge
|
| 103 |
+
self.SO3_grid = nn.ModuleDict()
|
| 104 |
+
self.SO3_grid["lmax_lmax"] = SO3_Grid(
|
| 105 |
+
self.lmax, self.lmax, resolution=grid_resolution, rescale=True
|
| 106 |
+
)
|
| 107 |
+
self.SO3_grid["lmax_mmax"] = SO3_Grid(
|
| 108 |
+
self.lmax, self.mmax, resolution=grid_resolution, rescale=True
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# atom embedding
|
| 112 |
+
self.sphere_embedding = nn.Embedding(
|
| 113 |
+
self.max_num_elements, self.sphere_channels
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# edge distance embedding
|
| 117 |
+
self.cutoff = cutoff
|
| 118 |
+
self.edge_channels = edge_channels
|
| 119 |
+
self.distance_function = distance_function
|
| 120 |
+
self.num_distance_basis = num_distance_basis
|
| 121 |
+
|
| 122 |
+
if self.distance_function == "gaussian":
|
| 123 |
+
self.distance_expansion = GaussianSmearing(
|
| 124 |
+
0.0,
|
| 125 |
+
self.cutoff,
|
| 126 |
+
self.num_distance_basis,
|
| 127 |
+
2.0,
|
| 128 |
+
)
|
| 129 |
+
elif self.distance_function == "bessel":
|
| 130 |
+
self.distance_expansion = EnvelopedBesselBasis(
|
| 131 |
+
num_radial=self.num_distance_basis,
|
| 132 |
+
cutoff=cutoff,
|
| 133 |
+
)
|
| 134 |
+
self.distance_expansion.offset = [self.cutoff]
|
| 135 |
+
self.distance_expansion.num_output = self.num_distance_basis
|
| 136 |
+
else:
|
| 137 |
+
raise ValueError("Unknown distance function")
|
| 138 |
+
|
| 139 |
+
# equivariant initial embedding
|
| 140 |
+
self.source_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
|
| 141 |
+
self.target_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
|
| 142 |
+
nn.init.uniform_(self.source_embedding.weight.data, -0.001, 0.001)
|
| 143 |
+
nn.init.uniform_(self.target_embedding.weight.data, -0.001, 0.001)
|
| 144 |
+
|
| 145 |
+
self.edge_channels_list = [
|
| 146 |
+
self.num_distance_basis + 2 * self.edge_channels,
|
| 147 |
+
self.edge_channels,
|
| 148 |
+
self.edge_channels,
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
self.edge_degree_embedding = EdgeDegreeEmbedding(
|
| 152 |
+
sphere_channels=self.sphere_channels,
|
| 153 |
+
lmax=self.lmax,
|
| 154 |
+
mmax=self.mmax,
|
| 155 |
+
max_num_elements=self.max_num_elements,
|
| 156 |
+
edge_channels_list=self.edge_channels_list,
|
| 157 |
+
rescale_factor=5.0,
|
| 158 |
+
cutoff=self.cutoff,
|
| 159 |
+
mappingReduced=self.mappingReduced,
|
| 160 |
+
out_mask=self.SO3_grid["lmax_lmax"].mapping.coefficient_idx(
|
| 161 |
+
self.lmax, self.mmax
|
| 162 |
+
),
|
| 163 |
+
use_envelope=use_envelope,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
self.num_layers = num_layers
|
| 167 |
+
self.hidden_channels = hidden_channels
|
| 168 |
+
self.norm_type = norm_type
|
| 169 |
+
self.act_type = act_type
|
| 170 |
+
|
| 171 |
+
# Initialize the blocks for each layer
|
| 172 |
+
self.blocks = nn.ModuleList()
|
| 173 |
+
for _ in range(self.num_layers):
|
| 174 |
+
block = eSEN_Block(
|
| 175 |
+
self.sphere_channels,
|
| 176 |
+
self.hidden_channels,
|
| 177 |
+
self.lmax,
|
| 178 |
+
self.mmax,
|
| 179 |
+
self.mappingReduced,
|
| 180 |
+
self.SO3_grid,
|
| 181 |
+
self.edge_channels_list,
|
| 182 |
+
self.cutoff,
|
| 183 |
+
self.norm_type,
|
| 184 |
+
self.act_type,
|
| 185 |
+
self.mlp_type,
|
| 186 |
+
self.use_envelope,
|
| 187 |
+
)
|
| 188 |
+
self.blocks.append(block)
|
| 189 |
+
|
| 190 |
+
self.norm = get_normalization_layer(
|
| 191 |
+
self.norm_type,
|
| 192 |
+
lmax=self.lmax,
|
| 193 |
+
num_channels=self.sphere_channels,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
self.irreps_sh = o3.Irreps.spherical_harmonics(lmax=self.lmax, p=1)
|
| 197 |
+
self.force_embedding = SO3_Linear(
|
| 198 |
+
in_features=1, out_features=self.sphere_channels, lmax=self.lmax
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
def get_rotmat_and_wigner(self, edge_distance_vecs):
|
| 202 |
+
edge_rot_mat = init_edge_rot_mat(
|
| 203 |
+
edge_distance_vecs, rot_clip=(not self.direct_forces)
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
Jd_buffers = [
|
| 207 |
+
getattr(self, f"Jd_{l}").type(edge_rot_mat.dtype)
|
| 208 |
+
for l in range(self.lmax + 1)
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
wigner = rotation_to_wigner(
|
| 212 |
+
edge_rot_mat,
|
| 213 |
+
0,
|
| 214 |
+
self.lmax,
|
| 215 |
+
Jd_buffers,
|
| 216 |
+
rot_clip=(not self.direct_forces),
|
| 217 |
+
)
|
| 218 |
+
wigner_inv = torch.transpose(wigner, 1, 2).contiguous()
|
| 219 |
+
|
| 220 |
+
return edge_rot_mat, wigner, wigner_inv
|
| 221 |
+
|
| 222 |
+
def generate_graph(self, *args, **kwargs):
|
| 223 |
+
graph = super().generate_graph(*args, **kwargs)
|
| 224 |
+
return {
|
| 225 |
+
"edge_index": graph.edge_index,
|
| 226 |
+
"edge_distance": graph.edge_distance,
|
| 227 |
+
"edge_distance_vec": graph.edge_distance_vec,
|
| 228 |
+
"cell_offsets": graph.cell_offsets,
|
| 229 |
+
"offset_distances": None,
|
| 230 |
+
"neighbors": None,
|
| 231 |
+
"node_offset": 0,
|
| 232 |
+
"batch_full": graph.batch_full,
|
| 233 |
+
"atomic_numbers_full": graph.atomic_numbers_full,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
@conditional_grad(torch.enable_grad())
|
| 237 |
+
def forward(self, data_dict) -> dict[str, torch.Tensor]:
|
| 238 |
+
###############################################################
|
| 239 |
+
# gradient-based forces/stress
|
| 240 |
+
###############################################################
|
| 241 |
+
data_dict["atomic_numbers"] = data_dict["atomic_numbers"].long()
|
| 242 |
+
|
| 243 |
+
displacement = None
|
| 244 |
+
orig_cell = None
|
| 245 |
+
if self.regress_stress and not self.direct_forces:
|
| 246 |
+
displacement = torch.zeros(
|
| 247 |
+
(3, 3),
|
| 248 |
+
dtype=data_dict["pos"].dtype,
|
| 249 |
+
device=data_dict["pos"].device,
|
| 250 |
+
)
|
| 251 |
+
# num_batch = data_dict["num_graphs"]
|
| 252 |
+
num_batch = data_dict.get("num_graphs", len(data_dict["natoms"]))
|
| 253 |
+
displacement = displacement.view(-1, 3, 3).expand(num_batch, 3, 3)
|
| 254 |
+
displacement.requires_grad_(True)
|
| 255 |
+
symmetric_displacement = 0.5 * (
|
| 256 |
+
displacement + displacement.transpose(-1, -2)
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
data_dict["pos"].requires_grad_(True)
|
| 260 |
+
data_dict["pos"] = data_dict["pos"] + torch.bmm(
|
| 261 |
+
data_dict["pos"].unsqueeze(-2),
|
| 262 |
+
torch.index_select(symmetric_displacement, 0, data_dict["batch"]),
|
| 263 |
+
).squeeze(-2)
|
| 264 |
+
|
| 265 |
+
orig_cell = data_dict["cell"]
|
| 266 |
+
data_dict["cell"] = data_dict["cell"] + torch.bmm(
|
| 267 |
+
data_dict["cell"], symmetric_displacement
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if not self.regress_stress and self.regress_forces and not self.direct_forces:
|
| 271 |
+
data_dict["pos"].requires_grad_(True)
|
| 272 |
+
|
| 273 |
+
if self.otf_graph:
|
| 274 |
+
graph_dict = self.generate_graph(data_dict)
|
| 275 |
+
else:
|
| 276 |
+
cell_per_edge = data_dict["cell"].repeat_interleave(
|
| 277 |
+
data_dict["nedges"], dim=0
|
| 278 |
+
)
|
| 279 |
+
shifts = torch.einsum(
|
| 280 |
+
"ij,ijk->ik",
|
| 281 |
+
data_dict["cell_offsets"].to(cell_per_edge.dtype),
|
| 282 |
+
cell_per_edge,
|
| 283 |
+
)
|
| 284 |
+
edge_distance_vec = (
|
| 285 |
+
data_dict["pos"][data_dict["edge_index"][0]]
|
| 286 |
+
- data_dict["pos"][data_dict["edge_index"][1]]
|
| 287 |
+
+ shifts
|
| 288 |
+
)
|
| 289 |
+
# pylint: disable=E1102
|
| 290 |
+
edge_distance = torch.linalg.norm(edge_distance_vec, dim=-1, keepdim=False)
|
| 291 |
+
graph_dict = {
|
| 292 |
+
"atomic_numbers_full": data_dict["atomic_numbers_full"],
|
| 293 |
+
"batch_full": data_dict["batch_full"],
|
| 294 |
+
"edge_index": data_dict["edge_index"],
|
| 295 |
+
"edge_distance": edge_distance,
|
| 296 |
+
"edge_distance_vec": edge_distance_vec,
|
| 297 |
+
"node_offset": 0,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
_, wigner, wigner_inv = self.get_rotmat_and_wigner(
|
| 301 |
+
graph_dict["edge_distance_vec"]
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
###############################################################
|
| 305 |
+
# Initialize node embeddings
|
| 306 |
+
###############################################################
|
| 307 |
+
|
| 308 |
+
x_message = torch.zeros(
|
| 309 |
+
data_dict["pos"].shape[0],
|
| 310 |
+
self.sph_feature_size,
|
| 311 |
+
self.sphere_channels,
|
| 312 |
+
device=data_dict["pos"].device,
|
| 313 |
+
dtype=data_dict["pos"].dtype,
|
| 314 |
+
)
|
| 315 |
+
x_message[:, 0, :] = self.sphere_embedding(data_dict["atomic_numbers"])
|
| 316 |
+
|
| 317 |
+
##################
|
| 318 |
+
### DeNS Start ###
|
| 319 |
+
##################
|
| 320 |
+
|
| 321 |
+
num_atoms = len(data_dict.atomic_numbers)
|
| 322 |
+
# Node-wise force encoding during denoising positions
|
| 323 |
+
if (
|
| 324 |
+
hasattr(data_dict, "denoising_pos_forward")
|
| 325 |
+
and data_dict.denoising_pos_forward
|
| 326 |
+
):
|
| 327 |
+
assert hasattr(data_dict, "forces")
|
| 328 |
+
force_data = data_dict.forces
|
| 329 |
+
force_sh = o3.spherical_harmonics(
|
| 330 |
+
l=self.irreps_sh,
|
| 331 |
+
x=force_data,
|
| 332 |
+
normalize=True,
|
| 333 |
+
normalization="component",
|
| 334 |
+
)
|
| 335 |
+
force_sh = force_sh.view(num_atoms, (self.lmax + 1) ** 2, 1)
|
| 336 |
+
force_norm = force_data.norm(dim=-1, keepdim=True)
|
| 337 |
+
if hasattr(data_dict, "noise_mask"):
|
| 338 |
+
noise_mask_tensor = data_dict.noise_mask.view(-1, 1, 1)
|
| 339 |
+
force_sh = force_sh * noise_mask_tensor
|
| 340 |
+
else:
|
| 341 |
+
force_sh = torch.zeros(
|
| 342 |
+
(num_atoms, (self.lmax + 1) ** 2, 1),
|
| 343 |
+
dtype=data_dict.pos.dtype,
|
| 344 |
+
device=data_dict.pos.device,
|
| 345 |
+
)
|
| 346 |
+
force_norm = torch.zeros(
|
| 347 |
+
(num_atoms, 1), dtype=data_dict.pos.dtype, device=data_dict.pos.device
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
force_norm = force_norm.view(-1, 1, 1)
|
| 351 |
+
force_norm = force_norm / math.sqrt(
|
| 352 |
+
3.0
|
| 353 |
+
) # since we use `component` normalization
|
| 354 |
+
force_embedding = force_sh * force_norm
|
| 355 |
+
|
| 356 |
+
force_embedding = self.force_embedding(force_embedding)
|
| 357 |
+
x_message = x_message + force_embedding
|
| 358 |
+
|
| 359 |
+
##################
|
| 360 |
+
### DeNS End ###
|
| 361 |
+
##################
|
| 362 |
+
|
| 363 |
+
# edge degree embedding
|
| 364 |
+
edge_distance_embedding = self.distance_expansion(graph_dict["edge_distance"])
|
| 365 |
+
source_embedding = self.source_embedding(
|
| 366 |
+
data_dict["atomic_numbers"][graph_dict["edge_index"][0]]
|
| 367 |
+
)
|
| 368 |
+
target_embedding = self.target_embedding(
|
| 369 |
+
data_dict["atomic_numbers"][graph_dict["edge_index"][1]]
|
| 370 |
+
)
|
| 371 |
+
x_edge = torch.cat(
|
| 372 |
+
(edge_distance_embedding, source_embedding, target_embedding), dim=1
|
| 373 |
+
)
|
| 374 |
+
x_message = self.edge_degree_embedding(
|
| 375 |
+
x_message,
|
| 376 |
+
x_edge,
|
| 377 |
+
graph_dict["edge_distance"],
|
| 378 |
+
graph_dict["edge_index"],
|
| 379 |
+
wigner_inv,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
###############################################################
|
| 383 |
+
# Update spherical node embeddings
|
| 384 |
+
###############################################################
|
| 385 |
+
if graph_dict["edge_index"].shape[1] != 0:
|
| 386 |
+
for i in range(self.num_layers):
|
| 387 |
+
if self.activation_checkpointing:
|
| 388 |
+
x_message = torch.utils.checkpoint.checkpoint(
|
| 389 |
+
self.blocks[i],
|
| 390 |
+
x_message,
|
| 391 |
+
x_edge,
|
| 392 |
+
graph_dict["edge_distance"],
|
| 393 |
+
graph_dict["edge_index"],
|
| 394 |
+
wigner,
|
| 395 |
+
wigner_inv,
|
| 396 |
+
graph_dict["node_offset"],
|
| 397 |
+
use_reentrant=False,
|
| 398 |
+
)
|
| 399 |
+
else:
|
| 400 |
+
x_message = self.blocks[i](
|
| 401 |
+
x_message,
|
| 402 |
+
x_edge,
|
| 403 |
+
graph_dict["edge_distance"],
|
| 404 |
+
graph_dict["edge_index"],
|
| 405 |
+
wigner,
|
| 406 |
+
wigner_inv,
|
| 407 |
+
node_offset=graph_dict["node_offset"],
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# Final layer norm
|
| 411 |
+
x_message = self.norm(x_message)
|
| 412 |
+
|
| 413 |
+
out = {
|
| 414 |
+
"node_embedding": x_message,
|
| 415 |
+
"displacement": displacement,
|
| 416 |
+
"orig_cell": orig_cell,
|
| 417 |
+
}
|
| 418 |
+
out.update(graph_dict)
|
| 419 |
+
|
| 420 |
+
return out
|
| 421 |
+
|
| 422 |
+
@property
|
| 423 |
+
def num_params(self):
|
| 424 |
+
return sum(p.numel() for p in self.parameters())
|
| 425 |
+
|
| 426 |
+
@torch.jit.ignore
|
| 427 |
+
def no_weight_decay(self) -> set:
|
| 428 |
+
no_wd_list = []
|
| 429 |
+
named_parameters_list = [name for name, _ in self.named_parameters()]
|
| 430 |
+
for module_name, module in self.named_modules():
|
| 431 |
+
if isinstance(
|
| 432 |
+
module,
|
| 433 |
+
(
|
| 434 |
+
torch.nn.Linear,
|
| 435 |
+
SO3_Linear,
|
| 436 |
+
torch.nn.LayerNorm,
|
| 437 |
+
EquivariantLayerNormArray,
|
| 438 |
+
EquivariantLayerNormArraySphericalHarmonics,
|
| 439 |
+
EquivariantRMSNormArraySphericalHarmonicsV2,
|
| 440 |
+
),
|
| 441 |
+
):
|
| 442 |
+
for parameter_name, _ in module.named_parameters():
|
| 443 |
+
if (
|
| 444 |
+
isinstance(module, (torch.nn.Linear, SO3_Linear))
|
| 445 |
+
and "weight" in parameter_name
|
| 446 |
+
):
|
| 447 |
+
continue
|
| 448 |
+
global_parameter_name = module_name + "." + parameter_name
|
| 449 |
+
assert global_parameter_name in named_parameters_list
|
| 450 |
+
no_wd_list.append(global_parameter_name)
|
| 451 |
+
|
| 452 |
+
return set(no_wd_list)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@registry.register_model("esen_mlp_efs_head_dens")
|
| 456 |
+
class MLP_EFS_Head(nn.Module, HeadInterface):
|
| 457 |
+
def __init__(self, backbone):
|
| 458 |
+
super().__init__()
|
| 459 |
+
backbone.energy_block = None
|
| 460 |
+
backbone.force_block = None
|
| 461 |
+
self.regress_stress = backbone.regress_stress
|
| 462 |
+
self.regress_forces = backbone.regress_forces
|
| 463 |
+
self.denoising_pos = True
|
| 464 |
+
|
| 465 |
+
self.sphere_channels = backbone.sphere_channels
|
| 466 |
+
self.hidden_channels = backbone.hidden_channels
|
| 467 |
+
self.energy_block = nn.Sequential(
|
| 468 |
+
nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
|
| 469 |
+
nn.SiLU(),
|
| 470 |
+
nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
|
| 471 |
+
nn.SiLU(),
|
| 472 |
+
nn.Linear(self.hidden_channels, 1, bias=True),
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
self.denoising_linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
|
| 476 |
+
|
| 477 |
+
backbone.direct_forces = False
|
| 478 |
+
|
| 479 |
+
def delete_denoising_head(self):
|
| 480 |
+
self.denoising_linear = None
|
| 481 |
+
self.denoising_pos = False
|
| 482 |
+
|
| 483 |
+
@conditional_grad(torch.enable_grad())
|
| 484 |
+
def forward(self, data, emb: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 485 |
+
energy_key = "energy"
|
| 486 |
+
forces_key = "forces"
|
| 487 |
+
stress_key = "stress"
|
| 488 |
+
|
| 489 |
+
outputs = {}
|
| 490 |
+
|
| 491 |
+
node_energy = self.energy_block(
|
| 492 |
+
emb["node_embedding"].narrow(1, 0, 1).squeeze()
|
| 493 |
+
).view(-1, 1, 1)
|
| 494 |
+
|
| 495 |
+
energy = torch.zeros(
|
| 496 |
+
len(data["natoms"]), device=data["pos"].device, dtype=node_energy.dtype
|
| 497 |
+
)
|
| 498 |
+
energy.index_add_(0, data["batch"], node_energy.view(-1))
|
| 499 |
+
|
| 500 |
+
# no train on noisy energy for conserved
|
| 501 |
+
if hasattr(data, "denoising_pos_forward") and data.denoising_pos_forward:
|
| 502 |
+
energy = energy * 0.0
|
| 503 |
+
|
| 504 |
+
outputs[energy_key] = energy
|
| 505 |
+
|
| 506 |
+
if self.regress_stress:
|
| 507 |
+
grads = torch.autograd.grad(
|
| 508 |
+
[energy.sum()],
|
| 509 |
+
[data["pos"], emb["displacement"]],
|
| 510 |
+
create_graph=self.training,
|
| 511 |
+
)
|
| 512 |
+
forces = torch.neg(grads[0])
|
| 513 |
+
virial = grads[1].view(-1, 3, 3)
|
| 514 |
+
volume = torch.det(data["cell"]).abs().unsqueeze(-1)
|
| 515 |
+
stress = virial / volume.view(-1, 1, 1)
|
| 516 |
+
virial = torch.neg(virial)
|
| 517 |
+
outputs[forces_key] = forces
|
| 518 |
+
outputs[stress_key] = stress.view(
|
| 519 |
+
-1, 9
|
| 520 |
+
) # NOTE to work better with current Multi-task trainer
|
| 521 |
+
data["cell"] = emb["orig_cell"]
|
| 522 |
+
elif self.regress_forces:
|
| 523 |
+
forces = (
|
| 524 |
+
-1
|
| 525 |
+
* torch.autograd.grad(
|
| 526 |
+
energy.sum(), data["pos"], create_graph=self.training
|
| 527 |
+
)[0]
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
if self.denoising_pos:
|
| 531 |
+
denoising_pos_vec = self.denoising_linear(
|
| 532 |
+
emb["node_embedding"].narrow(1, 0, 4)
|
| 533 |
+
)
|
| 534 |
+
denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
|
| 535 |
+
denoising_pos_vec = denoising_pos_vec.view(-1, 3).contiguous()
|
| 536 |
+
|
| 537 |
+
if hasattr(data, "denoising_pos_forward") and data.denoising_pos_forward:
|
| 538 |
+
if hasattr(data, "noise_mask"):
|
| 539 |
+
noise_mask_tensor = data.noise_mask.view(-1, 1)
|
| 540 |
+
forces = denoising_pos_vec * noise_mask_tensor + forces * (
|
| 541 |
+
~noise_mask_tensor
|
| 542 |
+
)
|
| 543 |
+
else:
|
| 544 |
+
forces = denoising_pos_vec + 0 * forces
|
| 545 |
+
else:
|
| 546 |
+
forces = 0 * denoising_pos_vec + forces
|
| 547 |
+
|
| 548 |
+
outputs[forces_key] = forces
|
| 549 |
+
|
| 550 |
+
return outputs
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
@registry.register_model("esen_linear_force_head_dens")
|
| 554 |
+
class Linear_Force_Head_DeNS(nn.Module, HeadInterface):
|
| 555 |
+
def __init__(self, backbone):
|
| 556 |
+
super().__init__()
|
| 557 |
+
self.linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
|
| 558 |
+
self.denoising_linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
|
| 559 |
+
|
| 560 |
+
def forward(self, data_dict, emb: dict[str, torch.Tensor]):
|
| 561 |
+
forces = self.linear(emb["node_embedding"].narrow(1, 0, 4))
|
| 562 |
+
denoising_pos_vec = self.denoising_linear(emb["node_embedding"].narrow(1, 0, 4))
|
| 563 |
+
forces = forces.narrow(1, 1, 3)
|
| 564 |
+
forces = forces.view(-1, 3).contiguous()
|
| 565 |
+
denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
|
| 566 |
+
denoising_pos_vec = denoising_pos_vec.view(-1, 3).contiguous()
|
| 567 |
+
|
| 568 |
+
if (
|
| 569 |
+
hasattr(data_dict, "denoising_pos_forward")
|
| 570 |
+
and data_dict.denoising_pos_forward
|
| 571 |
+
):
|
| 572 |
+
if hasattr(data_dict, "noise_mask"):
|
| 573 |
+
noise_mask_tensor = data_dict.noise_mask.view(-1, 1)
|
| 574 |
+
forces = denoising_pos_vec * noise_mask_tensor + forces * (
|
| 575 |
+
~noise_mask_tensor
|
| 576 |
+
)
|
| 577 |
+
else:
|
| 578 |
+
forces = denoising_pos_vec + 0 * forces
|
| 579 |
+
else:
|
| 580 |
+
forces = 0 * denoising_pos_vec + forces
|
| 581 |
+
|
| 582 |
+
return {"forces": forces}
|
model/graph.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph helpers shared by the OneScience eSEN backbones."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from onescience.modules.func_utils.uma_graph.compute import generate_graph
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GraphModelMixin:
|
| 13 |
+
"""Provide FairChem-v1 compatible graph output using OneScience graph code."""
|
| 14 |
+
|
| 15 |
+
def generate_graph(self, data, cutoff=None, max_neighbors=None, **kwargs):
|
| 16 |
+
cutoff = cutoff or self.cutoff
|
| 17 |
+
max_neighbors = max_neighbors or self.max_neighbors
|
| 18 |
+
pbc = kwargs.pop("pbc", None)
|
| 19 |
+
if pbc is None:
|
| 20 |
+
pbc = getattr(data, "pbc", None)
|
| 21 |
+
if pbc is None:
|
| 22 |
+
pbc = torch.ones(
|
| 23 |
+
(data.natoms.numel(), 3), dtype=torch.bool, device=data.pos.device
|
| 24 |
+
)
|
| 25 |
+
elif pbc.ndim == 1:
|
| 26 |
+
pbc = pbc.view(1, 3).expand(data.natoms.numel(), -1)
|
| 27 |
+
|
| 28 |
+
graph = generate_graph(
|
| 29 |
+
data,
|
| 30 |
+
cutoff=cutoff,
|
| 31 |
+
max_neighbors=max_neighbors,
|
| 32 |
+
enforce_max_neighbors_strictly=getattr(
|
| 33 |
+
self, "enforce_max_neighbors_strictly", False
|
| 34 |
+
),
|
| 35 |
+
radius_pbc_version=getattr(self, "radius_pbc_version", 1),
|
| 36 |
+
pbc=pbc,
|
| 37 |
+
)
|
| 38 |
+
graph.update(
|
| 39 |
+
batch_full=data.batch,
|
| 40 |
+
atomic_numbers_full=data.atomic_numbers,
|
| 41 |
+
node_offset=0,
|
| 42 |
+
)
|
| 43 |
+
return SimpleNamespace(**graph)
|
prepare_oxide_dataset.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build reproducible ASE DB splits from FairChem's oxide tutorial data."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
from collections import Counter
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from ase import Atoms, units
|
| 13 |
+
from ase.calculators.singlepoint import SinglePointCalculator
|
| 14 |
+
from ase.db import connect
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_records(path: Path):
|
| 18 |
+
with path.open() as handle:
|
| 19 |
+
source = json.load(handle)
|
| 20 |
+
records = []
|
| 21 |
+
for oxide, polymorphs in source.items():
|
| 22 |
+
for polymorph, calculations in polymorphs.items():
|
| 23 |
+
for calculation in calculations.get("PBE", {}).get("EOS", {}).get(
|
| 24 |
+
"calculations", []
|
| 25 |
+
):
|
| 26 |
+
records.append((oxide, polymorph, calculation))
|
| 27 |
+
return records
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def split_groups(records, seed: int):
|
| 31 |
+
groups = sorted({(oxide, polymorph) for oxide, polymorph, _ in records})
|
| 32 |
+
random.Random(seed).shuffle(groups)
|
| 33 |
+
n_train = int(0.8 * len(groups))
|
| 34 |
+
n_val = int(0.1 * len(groups))
|
| 35 |
+
split_for = {group: "train" for group in groups[:n_train]}
|
| 36 |
+
split_for.update({group: "val" for group in groups[n_train : n_train + n_val]})
|
| 37 |
+
split_for.update({group: "test" for group in groups[n_train + n_val :]})
|
| 38 |
+
return split_for
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def to_atoms(oxide: str, polymorph: str, calculation: dict) -> Atoms:
|
| 42 |
+
structure = calculation["atoms"]
|
| 43 |
+
results = calculation["data"]
|
| 44 |
+
atoms = Atoms(
|
| 45 |
+
symbols=structure["symbols"],
|
| 46 |
+
positions=structure["positions"],
|
| 47 |
+
cell=structure["cell"],
|
| 48 |
+
pbc=structure["pbc"],
|
| 49 |
+
)
|
| 50 |
+
atoms.set_tags(np.ones(len(atoms), dtype=int))
|
| 51 |
+
# ASE stress uses eV/Angstrom^3. The source JSON documents stress in GPa.
|
| 52 |
+
stress = np.asarray(results["stress"], dtype=float) * units.GPa
|
| 53 |
+
atoms.calc = SinglePointCalculator(
|
| 54 |
+
atoms,
|
| 55 |
+
energy=float(results["total_energy"]),
|
| 56 |
+
forces=np.asarray(results["forces"], dtype=float),
|
| 57 |
+
stress=stress,
|
| 58 |
+
)
|
| 59 |
+
atoms.info.update({"oxide": oxide, "polymorph": polymorph, "xc": "PBE"})
|
| 60 |
+
return atoms
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main() -> None:
|
| 64 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 65 |
+
parser.add_argument("--input", required=True, type=Path)
|
| 66 |
+
parser.add_argument("--output", required=True, type=Path)
|
| 67 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 68 |
+
parser.add_argument(
|
| 69 |
+
"--overwrite", action="store_true", help="Replace existing split databases"
|
| 70 |
+
)
|
| 71 |
+
args = parser.parse_args()
|
| 72 |
+
|
| 73 |
+
records = load_records(args.input)
|
| 74 |
+
if not records:
|
| 75 |
+
raise ValueError(f"No PBE EOS structures found in {args.input}")
|
| 76 |
+
args.output.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
paths = {split: args.output / f"{split}.db" for split in ("train", "val", "test")}
|
| 78 |
+
existing = [str(path) for path in paths.values() if path.exists()]
|
| 79 |
+
if existing and not args.overwrite:
|
| 80 |
+
raise FileExistsError("Output exists; pass --overwrite to replace: " + ", ".join(existing))
|
| 81 |
+
for path in paths.values():
|
| 82 |
+
if path.exists():
|
| 83 |
+
path.unlink()
|
| 84 |
+
|
| 85 |
+
split_for = split_groups(records, args.seed)
|
| 86 |
+
counts = Counter()
|
| 87 |
+
databases = {split: connect(path) for split, path in paths.items()}
|
| 88 |
+
for oxide, polymorph, calculation in records:
|
| 89 |
+
split = split_for[(oxide, polymorph)]
|
| 90 |
+
databases[split].write(
|
| 91 |
+
to_atoms(oxide, polymorph, calculation),
|
| 92 |
+
oxide=oxide,
|
| 93 |
+
polymorph=polymorph,
|
| 94 |
+
xc="PBE",
|
| 95 |
+
)
|
| 96 |
+
counts[split] += 1
|
| 97 |
+
|
| 98 |
+
manifest = {
|
| 99 |
+
"source": str(args.input.resolve()),
|
| 100 |
+
"seed": args.seed,
|
| 101 |
+
"split_strategy": "oxide-polymorph grouped 80/10/10",
|
| 102 |
+
"stress_source_unit": "GPa",
|
| 103 |
+
"stress_output_unit": "eV/Angstrom^3",
|
| 104 |
+
"counts": dict(counts),
|
| 105 |
+
"groups": {f"{oxide}/{polymorph}": split for (oxide, polymorph), split in sorted(split_for.items())},
|
| 106 |
+
}
|
| 107 |
+
with (args.output / "manifest.json").open("w") as handle:
|
| 108 |
+
json.dump(manifest, handle, indent=2, sort_keys=True)
|
| 109 |
+
print(json.dumps(manifest["counts"], sort_keys=True))
|
| 110 |
+
print(f"saved dataset: {args.output.resolve()}")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
relax.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relax a periodic structure with an eSEN ASE calculator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
os.environ.setdefault(
|
| 9 |
+
"ONESCIENCE_ESEN_JD_PATH",
|
| 10 |
+
os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from ase.build import bulk
|
| 14 |
+
from ase.filters import FrechetCellFilter
|
| 15 |
+
from ase.io import read, write
|
| 16 |
+
from ase.optimize import BFGS
|
| 17 |
+
|
| 18 |
+
from onescience.utils.esen import eSENCalculator
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def default_checkpoint() -> str:
|
| 22 |
+
return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_structure(path: str | None):
|
| 26 |
+
if path:
|
| 27 |
+
return read(path)
|
| 28 |
+
return bulk("Si", "diamond", a=5.50)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 33 |
+
parser.add_argument("--checkpoint", default=default_checkpoint())
|
| 34 |
+
parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
|
| 35 |
+
parser.add_argument("--fmax", type=float, default=0.05)
|
| 36 |
+
parser.add_argument("--steps", type=int, default=100)
|
| 37 |
+
parser.add_argument("--output", default="relaxed.cif")
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"--fixed-cell",
|
| 40 |
+
action="store_true",
|
| 41 |
+
help="relax atomic positions only; by default the cell is relaxed too",
|
| 42 |
+
)
|
| 43 |
+
parser.add_argument("--device", default="cuda")
|
| 44 |
+
args = parser.parse_args()
|
| 45 |
+
|
| 46 |
+
atoms = load_structure(args.input)
|
| 47 |
+
if not args.fixed_cell and not atoms.pbc.all():
|
| 48 |
+
parser.error("cell relaxation requires periodic boundaries; use --fixed-cell")
|
| 49 |
+
atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
|
| 50 |
+
target = atoms if args.fixed_cell else FrechetCellFilter(atoms)
|
| 51 |
+
optimizer = BFGS(target, logfile="relax.log", trajectory="relax.traj")
|
| 52 |
+
optimizer.run(fmax=args.fmax, steps=args.steps)
|
| 53 |
+
write(args.output, atoms)
|
| 54 |
+
print("formula:", atoms.get_chemical_formula())
|
| 55 |
+
print("atoms:", len(atoms))
|
| 56 |
+
print("steps:", optimizer.nsteps)
|
| 57 |
+
print("energy (eV):", atoms.get_potential_energy())
|
| 58 |
+
print("maximum force (eV/Angstrom):", max((atoms.get_forces() ** 2).sum(1) ** 0.5))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
main()
|
single_point.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run one eSEN single-point calculation through ASE."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
os.environ.setdefault(
|
| 9 |
+
"ONESCIENCE_ESEN_JD_PATH",
|
| 10 |
+
os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from ase.build import bulk
|
| 14 |
+
from ase.io import read
|
| 15 |
+
|
| 16 |
+
from onescience.utils.esen import eSENCalculator
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def default_checkpoint() -> str:
|
| 20 |
+
return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_structure(path: str | None):
|
| 24 |
+
if path:
|
| 25 |
+
return read(path)
|
| 26 |
+
return bulk("Si", "diamond", a=5.43)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> None:
|
| 30 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 31 |
+
parser.add_argument("--checkpoint", default=default_checkpoint())
|
| 32 |
+
parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
|
| 33 |
+
parser.add_argument("--device", default="cuda")
|
| 34 |
+
args = parser.parse_args()
|
| 35 |
+
|
| 36 |
+
atoms = load_structure(args.input)
|
| 37 |
+
atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
|
| 38 |
+
|
| 39 |
+
print("formula:", atoms.get_chemical_formula())
|
| 40 |
+
print("atoms:", len(atoms))
|
| 41 |
+
print("energy (eV):", atoms.get_potential_energy())
|
| 42 |
+
print("forces (eV/Angstrom):\n", atoms.get_forces())
|
| 43 |
+
print("stress (eV/Angstrom^3):", atoms.get_stress())
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
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
|