diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 00000000..f37cda82
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,7 @@
+include requirements.txt
+include ppmat/models/mattersim/threebody_indices.pyx
+include property_prediction/configs/gmtnet/README.md
+include property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml
+include property_prediction/configs/gmtnet/split_gmtnet_dielectric_seed32.json
+global-exclude *.pdparams
+global-exclude *.pkl
diff --git a/interatomic_potentials/README.md b/interatomic_potentials/README.md
index ae0cd3b2..b4a08224 100644
--- a/interatomic_potentials/README.md
+++ b/interatomic_potentials/README.md
@@ -6,30 +6,31 @@ Machine-learning interatomic potentials (MLIP) bridge the gap between quantum-le
## 2.Models Matrix
-| **Supported Functions** | **[CHGNet](./configs/chgnet/README.md)** | **[MatterSim](./configs/mattersim//README.md)** |
-| ----------------------------------- | ---------------------------------------- | ----------------------------------------------- |
-| **Forward Prediction** | | |
-| Energy | ✅ | ✅ |
-| Force | ✅ | ✅ |
-| Stress | ✅ | ✅ |
-| Magmom | ✅ | - |
-| **ML Capabilities · Training** | | |
-| Single-GPU | ✅ | ✅ |
-| Distributed Train | ✅ | ✅ |
-| Mixed Precision | - | - |
-| Fine-tuning | ✅ | ✅ |
-| Uncertainty / Active-Learning | - | - |
-| Dynamic→Static | - | - |
-| Compiler CINN | - | - |
-| **ML Capabilities · Predict** | | |
-| Distillation / Pruning | - | - |
-| Standard inference | ✅ | ✅ |
-| Distributed inference | - | - |
-| Compiler CINN | - | - |
-| **Molecular Dynamic Interface** | | |
-| ASE | ✅ | ✅ |
-| **Dataset** | | |
-| MPtrj | ✅ | 🚧 |
-| **ML2DDB🌟** | ✅ | - |
+| **Supported Functions** | **[CHGNet](./configs/chgnet/README.md)** | **[MatterSim](./configs/mattersim//README.md)** | **[SphereNet](./configs/spherenet/README.md)** |
+| ----------------------------------- | ---------------------------------------- | ----------------------------------------------- | ----------------------------------------------- |
+| **Forward Prediction** | | | |
+| Energy | ✅ | ✅ | ✅ |
+| Force | ✅ | ✅ | ✅ |
+| Stress | ✅ | ✅ | - |
+| Magmom | ✅ | - | - |
+| **ML Capabilities · Training** | | | |
+| Single-GPU | ✅ | ✅ | ✅ |
+| Distributed Train | ✅ | ✅ | - |
+| Mixed Precision | - | - | - |
+| Fine-tuning | ✅ | ✅ | ✅ |
+| Uncertainty / Active-Learning | - | - | - |
+| Dynamic→Static | - | - | - |
+| Compiler CINN | - | - | - |
+| **ML Capabilities · Predict** | | | |
+| Distillation / Pruning | - | - | - |
+| Standard inference | ✅ | ✅ | ✅ |
+| Distributed inference | - | - | - |
+| Compiler CINN | - | - | - |
+| **Molecular Dynamic Interface** | | | |
+| ASE | ✅ | ✅ | - |
+| **Dataset** | | | |
+| MPtrj | ✅ | 🚧 | - |
+| MD17 | - | - | ✅ |
+| **ML2DDB🌟** | ✅ | - | - |
-**Notice**:🌟 represent originate research work published from paddlematerials toolkit
+**Notice**:🌟 represent originate research work published from paddlematerials toolkit
\ No newline at end of file
diff --git a/interatomic_potentials/configs/chgnet/chgnet_qm9_lumo.yaml b/interatomic_potentials/configs/chgnet/chgnet_qm9_lumo.yaml
deleted file mode 100644
index e741ab6a..00000000
--- a/interatomic_potentials/configs/chgnet/chgnet_qm9_lumo.yaml
+++ /dev/null
@@ -1,176 +0,0 @@
-Global:
- # This config is focused on running prediction (inference) for the 'lumo' property.
- do_train: True
- do_eval: False
- do_test: False
-
- # Target label(s) — changed to only 'lumo'
- label_names: ['energy_per_atom']
-
- # Reuse the same converter structure style as example; adjust to molecule tasks.
- # You can replace __class_name__ and params with your project's actual converter.
- graph_converter:
- __class_name__: CHGNetGraphConverter
- __init_params__:
- cutoff: 5.0
- pdc: [1, 1, 1]
- num_classes: 95
- atom_graph_cutoff: 6.0
- bond_graph_cutoff: 3.0
-
- prim_eager_enabled: True
- prim_backward_white_list: ['concat_grad', 'gather_grad', 'layer_norm_grad', 'split_grad']
-
-
-Trainer:
- # Max epochs to train
- max_epochs: 20
- # Random seed
- seed: 42
- output_dir: ./output/qm9_predict_lumo
- save_freq: 10
- log_freq: 50
-
- start_eval_epoch: 1
- eval_freq: 1
- pretrained_model_path: null
- pretrained_weight_name: null
- resume_from_checkpoint: null
- use_amp: False
- amp_level: 'O1'
- eval_with_no_grad: True
- gradient_accumulation_steps: 1
-
- best_metric_indicator: 'eval_metric'
- name_for_best_metric: "energy_per_atom"
- greater_is_better: False
-
- compute_metric_during_train: False
- metric_strategy_during_eval: 'epoch'
-
- use_visualdl: False
- use_wandb: False
- use_tensorboard: False
-
-
-Model:
- # Keep the same class as the example if you use CHGNet for inference; otherwise change it.
- __class_name__: CHGNet
- __init_params__:
- atom_fea_dim: 64
- bond_fea_dim: 64
- angle_fea_dim: 64
- composition_model: "MPtrj"
- num_radial: 31
- num_angular: 31
- n_conv: 4
- atom_conv_hidden_dim: 64
- update_bond: True
- bond_conv_hidden_dim: 64
- update_angle: True
- angle_layer_hidden_dim: 0
- conv_dropout: 0
- read_out: "ave"
- mlp_hidden_dims: [64, 64, 64]
- mlp_dropout: 0
- mlp_first: True
- # IMPORTANT: only predict 'lumo' — change property_names accordingly
- is_intensive: True
- atom_graph_cutoff: 6
- bond_graph_cutoff: 3
- cutoff_coeff: 8
- learnable_rbf: True
- is_freeze: False
- property_names: ['energy_per_atom']
- return_site_energies: False
- return_atom_feas: False
- return_crystal_feas: False
-
-
-Optimizer:
- __class_name__: Adam
- __init_params__:
- lr:
- __class_name__: Cosine
- __init_params__:
- learning_rate: 1e-3
- eta_min: 1e-5
- by_epoch: False
-
-
-Metric:
- # Only one metric for the single target 'lumo'
- energy_per_atom:
- __class_name__: IgnoreNanMetricWrapper
- __init_params__:
- __class_name__: paddle.nn.L1Loss
- __init_params__: {}
-
-
-Dataset:
- train:
- dataset:
- __class_name__: QM9Dataset
- __init_params__:
- path: "./data/qm9"
- property_names: ${Global.label_names}
- build_graph_cfg: ${Global.graph_converter}
- cache_path: "./data/qm9"
- overwrite: False
- filter_unvalid: True
- # [Delete] url_indices: QM9Dataset does not support this parameter
- num_workers: 4
- use_shared_memory: False
- sampler:
- __class_name__: BatchSampler
- __init_params__:
- shuffle: False
- drop_last: False
- batch_size: 128
- val:
- dataset:
- __class_name__: QM9Dataset
- __init_params__:
- path: "./data/qm9"
- property_names: ${Global.label_names}
- build_graph_cfg: ${Global.graph_converter}
- cache_path: "./data/qm9"
- overwrite: False
- filter_unvalid: True
- num_workers: 4
- use_shared_memory: False
- sampler:
- __class_name__: BatchSampler
- __init_params__:
- shuffle: False
- drop_last: False
- batch_size: 128
- test:
- dataset:
- __class_name__: QM9Dataset
- __init_params__:
- path: "./data/qm9"
- property_names: ${Global.label_names}
- build_graph_cfg: ${Global.graph_converter}
- cache_path: "./data/qm9"
- overwrite: False
- filter_unvalid: True
- num_workers: 4
- use_shared_memory: False
- sampler:
- __class_name__: BatchSampler
- __init_params__:
- shuffle: False
- drop_last: False
- batch_size: 128
-
-
-Predict:
- graph_converter: ${Global.graph_converter}
- eval_with_no_grad: True
- # Path to model checkpoint to load for inference (set to your trained checkpoint)
- checkpoint_path: ./checkpoints/best_model.pdparams
- # Output file for predictions
- output_path: ./predictions/qm9_lumo_predictions.csv
- # Optional: whether to write per-sample details (pos, atomic_numbers) alongside predictions
- write_details: False
\ No newline at end of file
diff --git a/interatomic_potentials/configs/spherenet/README.md b/interatomic_potentials/configs/spherenet/README.md
new file mode 100644
index 00000000..ec92eacb
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/README.md
@@ -0,0 +1,220 @@
+# SphereNet
+
+[Spherical Message Passing for 3D Molecular Graphs](https://arxiv.org/abs/2102.05013) (ICLR 2021)
+
+## Abstract
+
+We propose the spherical message passing (SMP) scheme for 3D molecular graphs,
+which leverages **distance, angle, and torsion** information simultaneously to
+uniquely identify the relative positions of atoms in 3D space. Previous
+methods such as SchNet (distance-only) and DimeNet++ (distance + angle) suffer
+from equivariance ambiguity because multiple spatial configurations can map
+to the same pairwise distances or angles. By incorporating torsion angles
+(dihedral angles), SphereNet resolves this ambiguity and achieves
+state-of-the-art results on the QM9 and MD17 benchmarks.
+
+
+
+
+ Figure 1: SphereNet architecture.
+
+
+## Datasets
+
+### MD17
+
+The MD17 dataset contains DFT molecular dynamics trajectories for 8 small
+organic molecules. Each configuration includes the total energy (kcal/mol) and
+atomic forces (kcal/mol/Å).
+
+| Molecule | Train | Val | Test | Atoms |
+|---------------|------:|-----:|-----:|------:|
+| Aspirin | 1000 | 500 | 1000 | 21 |
+| Benzene | 1000 | 500 | 1000 | 12 |
+| Ethanol | 1000 | 500 | 1000 | 9 |
+| Malonaldehyde | 1000 | 500 | 1000 | 9 |
+| Naphthalene | 1000 | 500 | 1000 | 18 |
+| Salicylic | 1000 | 500 | 1000 | 16 |
+| Toluene | 1000 | 500 | 1000 | 15 |
+| Uracil | 1000 | 500 | 1000 | 12 |
+
+**Data format**: Each molecule is stored as a single `.npz` file with keys
+`E` (energies), `F` (forces), `R` (positions), and `z` (atomic numbers).
+
+## Model
+
+SphereNet is a spherical message passing neural network for 3D molecular
+graphs. It represents each molecule as a graph where nodes correspond to
+atoms, and directed edges encode interatomic interactions within a cutoff
+radius. The model builds a hierarchy of geometric features and propagates
+information using spherical message passing.
+
+### Geometric embedding hierarchy
+
+SphereNet constructs three levels of geometric embeddings to capture the
+full 3D structure:
+
+**1. Radial (distance) embeddings** — For each directed edge $j \to i$, the
+interatomic distance $d_{ji}$ is expanded using a radial basis function
+(RBF) composed with a smooth envelope.
+
+**2. Angular (spherical) embeddings** — For each triplet $k \to j \to i$,
+the bond angle $\theta_{kji}$ is expanded together with the distance
+$d_{kj}$ using spherical Bessel functions combined with Legendre
+polynomials (spherical Fourier-Bessel basis).
+
+**3. Torsional embeddings** — For each quadruplet $l \to k \to j \to i$,
+the torsion (dihedral) angle $\tau_{lkji}$ together with distances $d_{lk}$
+and $d_{kj}$ is expanded using a 3D spherical Fourier-Bessel basis.
+
+## Results
+
+
+
+
+ | Model Name |
+ Dataset |
+ Property |
+ MAE |
+ GPUs |
+ Training time |
+ Config |
+ Checkpoint | Log |
+
+
+
+
+ | spherenet_md17_aspirin |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.26 / 0.44 |
+ 1 |
+ ~6 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_benzene_old |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.14 / 0.21 |
+ 1 |
+ ~3 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_ethanol |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.10 / 0.23 |
+ 1 |
+ ~2 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_malonaldehyde |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.17 / 0.32 |
+ 1 |
+ ~2 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_naphthalene |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.16 / 0.26 |
+ 1 |
+ ~5 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_salicylic |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.22 / 0.38 |
+ 1 |
+ ~5 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_toluene |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.12 / 0.21 |
+ 1 |
+ ~4 h |
+ config |
+ checkpoint | log |
+
+
+ | spherenet_md17_uracil |
+ MD17 |
+ Energy (kcal/mol) / Force (kcal/mol/Å) |
+ 0.12 / 0.30 |
+ 1 |
+ ~2 h |
+ config |
+ checkpoint | log |
+
+
+
+
+### Training
+
+```bash
+# Single-GPU training — MD17 aspirin (energy + force)
+python interatomic_potentials/train.py \
+ -c interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml
+```
+
+### Validation
+
+```bash
+python interatomic_potentials/train.py \
+ -c interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml \
+ Global.do_eval=True Global.do_train=False Global.do_test=False \
+ Trainer.pretrained_model_path='your_model.pdparams'
+```
+
+### Testing
+
+```bash
+python interatomic_potentials/train.py \
+ -c interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml \
+ Global.do_test=True Global.do_train=False Global.do_eval=False \
+ Trainer.pretrained_model_path='your_model.pdparams'
+```
+
+### Prediction
+
+```bash
+# Molecular prediction
+python interatomic_potentials/predict.py \
+ --model_name spherenet_md17_aspirin \
+ --xyz_file_path ./interatomic_potentials/example_data/xyz/md17_aspirin.xyz
+
+# Using a local checkpoint
+python interatomic_potentials/predict.py \
+ --config_path ./interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml \
+ --checkpoint_path ./output/spherenet_aspirin/checkpoints/best.pdparams \
+ --xyz_file_path ./interatomic_potentials/example_data/xyz/md17_aspirin.xyz
+```
+
+## Citation
+
+```bibtex
+@inproceedings{liu2021spherenet,
+ title={Spherical Message Passing for 3D Molecular Graphs},
+ author={Liu, Yi and Wang, Limei and Liu, Meng and Lin, Yuchao and Zhang, Xuan and
+ Oztekin, Bora and Ji, Shuiwang},
+ booktitle={International Conference on Learning Representations (ICLR)},
+ year={2021}
+}
+```
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml
new file mode 100644
index 00000000..114b7cd0
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_aspirin.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_aspirin
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: aspirin
+ split: train
+ split_file: ./data/md17/splits/aspirin_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: aspirin
+ split: val
+ split_file: ./data/md17/splits/aspirin_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: aspirin
+ split: test
+ split_file: ./data/md17/splits/aspirin_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_benzene_old.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_benzene_old.yaml
new file mode 100644
index 00000000..d3e976d5
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_benzene_old.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_benzene_old
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: benzene_old
+ split: train
+ split_file: ./data/md17/splits/benzene_old_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: benzene_old
+ split: val
+ split_file: ./data/md17/splits/benzene_old_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: benzene_old
+ split: test
+ split_file: ./data/md17/splits/benzene_old_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_ethanol.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_ethanol.yaml
new file mode 100644
index 00000000..6d1cc97c
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_ethanol.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_ethanol
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: ethanol
+ split: train
+ split_file: ./data/md17/splits/ethanol_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: ethanol
+ split: val
+ split_file: ./data/md17/splits/ethanol_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: ethanol
+ split: test
+ split_file: ./data/md17/splits/ethanol_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_malonaldehyde.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_malonaldehyde.yaml
new file mode 100644
index 00000000..f0a2353d
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_malonaldehyde.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_malonaldehyde
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: malonaldehyde
+ split: train
+ split_file: ./data/md17/splits/malonaldehyde_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: malonaldehyde
+ split: val
+ split_file: ./data/md17/splits/malonaldehyde_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: malonaldehyde
+ split: test
+ split_file: ./data/md17/splits/malonaldehyde_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_naphthalene.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_naphthalene.yaml
new file mode 100644
index 00000000..1443cf56
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_naphthalene.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_naphthalene
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: naphthalene
+ split: train
+ split_file: ./data/md17/splits/naphthalene_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: naphthalene
+ split: val
+ split_file: ./data/md17/splits/naphthalene_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: naphthalene
+ split: test
+ split_file: ./data/md17/splits/naphthalene_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_salicylic.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_salicylic.yaml
new file mode 100644
index 00000000..9d95dd8e
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_salicylic.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_salicylic
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: salicylic
+ split: train
+ split_file: ./data/md17/splits/salicylic_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: salicylic
+ split: val
+ split_file: ./data/md17/splits/salicylic_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: salicylic
+ split: test
+ split_file: ./data/md17/splits/salicylic_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_toluene.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_toluene.yaml
new file mode 100644
index 00000000..0b98f28b
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_toluene.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_toluene
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: toluene
+ split: train
+ split_file: ./data/md17/splits/toluene_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: toluene
+ split: val
+ split_file: ./data/md17/splits/toluene_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: toluene
+ split: test
+ split_file: ./data/md17/splits/toluene_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/configs/spherenet/spherenet_md17_uracil.yaml b/interatomic_potentials/configs/spherenet/spherenet_md17_uracil.yaml
new file mode 100644
index 00000000..06610164
--- /dev/null
+++ b/interatomic_potentials/configs/spherenet/spherenet_md17_uracil.yaml
@@ -0,0 +1,148 @@
+Global:
+ label_names:
+ - energy
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: dict
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ max_epochs: 500
+ seed: 42
+ output_dir: ./output/spherenet_uracil
+ save_freq: 50
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ use_amp: false
+ eval_with_no_grad: true
+ gradient_accumulation_steps: 1
+ best_metric_indicator: eval_metric
+ name_for_best_metric: energy
+ greater_is_better: false
+ compute_metric_during_train: false
+ metric_strategy_during_eval: step
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: true
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: energy
+
+Metric:
+ energy:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+ force:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: uracil
+ split: train
+ split_file: ./data/md17/splits/uracil_train_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: uracil
+ split: val
+ split_file: ./data/md17/splits/uracil_val_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: MD17Dataset
+ __init_params__:
+ path: ./data/md17
+ name: uracil
+ split: test
+ split_file: ./data/md17/splits/uracil_test_idx.npy
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ force_key: force
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ eval_with_no_grad: false
+ graph_converter: ${Global.graph_converter}
diff --git a/interatomic_potentials/docs/SphereNet.png b/interatomic_potentials/docs/SphereNet.png
new file mode 100644
index 00000000..0e5d6d37
Binary files /dev/null and b/interatomic_potentials/docs/SphereNet.png differ
diff --git a/interatomic_potentials/example_data/xyz/md17_aspirin.xyz b/interatomic_potentials/example_data/xyz/md17_aspirin.xyz
new file mode 100644
index 00000000..51a736e4
--- /dev/null
+++ b/interatomic_potentials/example_data/xyz/md17_aspirin.xyz
@@ -0,0 +1,23 @@
+21
+MD17 aspirin inference example
+C -2.595942 -2.269642 0.066255
+C -1.718569 -1.136781 -0.375382
+O -1.568786 -0.790663 -1.540230
+O -1.142709 -0.550219 0.746129
+C -0.235077 0.460871 0.417163
+C -0.698267 1.777324 0.510780
+C 0.164026 2.833783 0.224236
+C 1.484579 2.575250 -0.141719
+C 1.949765 1.258119 -0.215915
+C 1.094915 0.182227 0.070449
+C 1.590339 -1.218640 0.021824
+O 0.977083 -2.225822 0.323594
+O 2.864388 -1.301050 -0.408129
+H -3.006067 -2.773570 -0.813588
+H -3.422512 -1.884425 0.668231
+H -2.008791 -2.994227 0.636116
+H -1.726984 1.977713 0.796840
+H -0.193200 3.859031 0.284092
+H 2.156660 3.399997 -0.367989
+H 2.986228 1.084400 -0.496432
+H 3.048921 -2.263677 -0.406327
diff --git a/interatomic_potentials/predict.py b/interatomic_potentials/predict.py
index cd671d17..891b575a 100644
--- a/interatomic_potentials/predict.py
+++ b/interatomic_potentials/predict.py
@@ -24,6 +24,7 @@
from pymatgen.core import Structure
from tqdm import tqdm
+from ppmat.datasets.build_molecule import BuildMolecule
from ppmat.datasets.transform import build_post_transforms
from ppmat.models import build_graph_converter
from ppmat.models import build_model
@@ -35,8 +36,8 @@
class PotentialPredictor:
"""Potential predictor.
- This class provides an interface for predicting properties of crystalline
- structures using pre-trained deep learning models. Supports two initialization
+ This class provides an interface for predicting properties of crystal structures and
+ molecules using pre-trained deep learning models. Supports two initialization
modes:
1. **Automatic Model Loading**
@@ -101,7 +102,7 @@ def __init__(
self.model.eval()
- predict_config = config.get("Predict", None)
+ predict_config = config.get("Predict") or {}
self.predict_config = predict_config
self.eval_with_no_grad = predict_config.get("eval_with_no_grad", True)
@@ -183,6 +184,39 @@ def from_cif_file(self, cif_file_path, save_path=None):
return result
+ def from_xyz_file(self, xyz_file_path, save_path=None):
+ """Predict molecular energy and forces from one XYZ file."""
+ if save_path is not None:
+ assert save_path.endswith(".csv"), "save_path must end with .csv"
+ if not osp.isfile(xyz_file_path) or not xyz_file_path.endswith(".xyz"):
+ raise ValueError(f"Expected one XYZ file, but got: {xyz_file_path}")
+ if self.graph_converter_fn is None:
+ raise ValueError("Molecular prediction requires a graph converter.")
+
+ molecule = BuildMolecule(format="xyz_file", sanitize=False)(xyz_file_path)
+ if molecule is None:
+ raise ValueError(f"Failed to parse XYZ file: {xyz_file_path}")
+ graph = self.graph_converter_fn(molecule)
+
+ if self.eval_with_no_grad:
+ with paddle.no_grad():
+ result = self.model.predict(graph)
+ else:
+ result = self.model.predict(graph)
+ result = self.post_process(result)
+
+ if save_path is not None:
+ row = {"xyz_file": osp.basename(xyz_file_path)}
+ row.update(
+ {
+ key: value.tolist() if hasattr(value, "tolist") else value
+ for key, value in result.items()
+ }
+ )
+ pd.DataFrame([row]).to_csv(save_path, index=False)
+ logger.info(f"Saved the prediction result to {save_path}")
+ return result
+
if __name__ == "__main__":
@@ -214,9 +248,15 @@ def from_cif_file(self, cif_file_path, save_path=None):
argparse.add_argument(
"--cif_file_path",
type=str,
- default="./interatomic_potentials/",
+ default=None,
help="Path to the CIF file whose material properties you want to predict.",
)
+ argparse.add_argument(
+ "--xyz_file_path",
+ type=str,
+ default=None,
+ help="Path to the XYZ molecule whose energy and forces to predict.",
+ )
argparse.add_argument(
"--save_path",
type=str,
@@ -232,5 +272,10 @@ def from_cif_file(self, cif_file_path, save_path=None):
checkpoint_path=args.checkpoint_path,
)
- results = predictor.from_cif_file(args.cif_file_path, args.save_path)
+ if args.xyz_file_path is not None:
+ results = predictor.from_xyz_file(args.xyz_file_path, args.save_path)
+ elif args.cif_file_path is not None:
+ results = predictor.from_cif_file(args.cif_file_path, args.save_path)
+ else:
+ raise ValueError("Provide --xyz_file_path or --cif_file_path.")
print(results)
diff --git a/ppmat/datasets/__init__.py b/ppmat/datasets/__init__.py
index 05d3de49..b6703993 100644
--- a/ppmat/datasets/__init__.py
+++ b/ppmat/datasets/__init__.py
@@ -31,6 +31,7 @@
from ppmat.datasets import collate_fn
from ppmat.datasets.high_level_water_dataset import HighLevelWaterDataset
+from ppmat.datasets.gmtnet_dataset import GMTNetDielectricDataset
from ppmat.datasets.jarvis_dataset import JarvisDataset
from ppmat.datasets.matbench_dataset import MatbenchDataset
from ppmat.datasets.mp20_dataset import AlexMP20MatterGenDataset
@@ -42,11 +43,12 @@
from ppmat.datasets.msd_nmr_dataset import MSDnmrDataset
from ppmat.datasets.msd_nmr_dataset import MSDnmrinfos
from ppmat.datasets.density_dataset import DensityDataset
-from ppmat.datasets.small_density_dataset import SmallDensityDataset
+from ppmat.datasets.small_density_dataset import SmallDensityDataset
from ppmat.datasets.sfin_dataset import SFINDataset
from ppmat.datasets.num_atom_crystal_dataset import NumAtomsCrystalDataset
from ppmat.datasets.oc20_s2ef_dataset import OC20S2EFDataset # noqa
from ppmat.datasets.qm9_dataset import QM9Dataset # noqa
+from ppmat.datasets.md17_dataset import MD17Dataset # noqa
from ppmat.datasets.omol25_dataset import OMol25Dataset
from ppmat.datasets.split_mptrj_data import none_to_zero
from ppmat.datasets.transform import build_transforms
@@ -63,12 +65,14 @@
"MPTrjDataset",
"JarvisDataset",
"HighLevelWaterDataset",
+ "GMTNetDielectricDataset",
"MSDnmrDataset",
"MatbenchDataset",
"DensityDataset",
"SmallDensityDataset",
"SFINDataset",
"OMol25Dataset",
+ "MD17Dataset",
]
INFO_CLASS_REGISTRY: Dict[str, type] = {
diff --git a/ppmat/datasets/build_molecule.py b/ppmat/datasets/build_molecule.py
index 32755fdd..945c2d9d 100644
--- a/ppmat/datasets/build_molecule.py
+++ b/ppmat/datasets/build_molecule.py
@@ -23,14 +23,16 @@
from p_tqdm import p_map
from rdkit import Chem
+from rdkit.Geometry import Point3D
class BuildMolecule:
"""Build RDKit Mol from different formats.
Args:
- format (Literal["smiles","mol_block","mol_file","sdf_file", "inchi","dict",
- "rdmol"]): format of input molecules data used by convertion of RDKit
+ format (Literal["smiles","mol_block","mol_file","sdf_file","xyz_block",
+ "xyz_file","inchi","dict","rdmol"]): format of input molecules data used
+ by convertion of RDKit
sanitize (bool): Whether to sanitize the molecule using RDKit after construction
(e.g., validate valence, adjust bond orders). Defaults to True.
add_hs (bool): Whether to add explicit hydrogen atoms to the molecule.
@@ -47,7 +49,15 @@ class BuildMolecule:
def __init__(
self,
format: Literal[
- "smiles", "mol_block", "mol_file", "sdf_file", "inchi", "dict", "rdmol"
+ "smiles",
+ "mol_block",
+ "mol_file",
+ "sdf_file",
+ "xyz_block",
+ "xyz_file",
+ "inchi",
+ "dict",
+ "rdmol",
],
sanitize: bool = True,
add_hs: bool = False,
@@ -101,13 +111,25 @@ def build_one(
elif format == "sdf_file":
suppl = Chem.SDMolSupplier(str(mol_data), sanitize=sanitize, removeHs=False)
mol = next((m for m in suppl if m is not None), None)
+ elif format == "xyz_block":
+ mol = Chem.MolFromXYZBlock(str(mol_data))
+ elif format == "xyz_file":
+ mol = Chem.MolFromXYZFile(str(mol_data))
elif format == "inchi":
mol = Chem.MolFromInchi(str(mol_data))
elif format == "dict":
- mol_block = mol_data.get("mol_block", None)
- if mol_block is None:
- raise ValueError("dict format requires key 'mol_block'.")
- mol = Chem.MolFromMolBlock(mol_block, sanitize=sanitize)
+ atomic_numbers = mol_data["atomic_numbers"]
+ positions = mol_data["positions"]
+ mol = Chem.RWMol()
+ for atomic_number in atomic_numbers:
+ mol.AddAtom(Chem.Atom(int(atomic_number)))
+ mol = mol.GetMol()
+ conformer = Chem.Conformer(len(atomic_numbers))
+ for atom_idx, position in enumerate(positions):
+ conformer.SetAtomPosition(
+ atom_idx, Point3D(*(float(value) for value in position))
+ )
+ mol.AddConformer(conformer)
elif format == "rdmol":
mol = mol_data
else:
diff --git a/ppmat/datasets/collate_fn.py b/ppmat/datasets/collate_fn.py
index 9073af4c..88ad46fa 100644
--- a/ppmat/datasets/collate_fn.py
+++ b/ppmat/datasets/collate_fn.py
@@ -88,6 +88,68 @@ def __call__(self, batch: List[Any]) -> Any:
)
+class RadiusGraphCollator:
+ """Collates radius-graph samples for SphereNet.
+
+ Each sample is expected to contain a ``pgl.Graph`` under ``graph``. The graph
+ comes from ``RadiusGraphConverter`` and stores cached SphereNet triplet
+ indices in ``edge_feat``. Graph fields remain inside the batched graph and
+ are unpacked by the model. Variable-length sample fields should be marked by
+ the dataset with ``ConcatData`` and are collated by ``DefaultCollator``.
+ """
+
+ def __call__(self, batch):
+ graphs = [sample["graph"] for sample in batch]
+ num_edges_list = [np.asarray(graph.edges).shape[0] for graph in graphs]
+ num_triplets_list = [
+ np.asarray(graph.edge_feat["ti_idx_kj"]).shape[0]
+ for graph in graphs
+ ]
+
+ edge_offsets = np.cumsum([0] + num_edges_list[:-1])
+ triplet_offsets = np.cumsum([0] + num_triplets_list[:-1])
+
+ triplet_fields = {
+ key: []
+ for key in ("idx_kj", "idx_ji", "idx_lk", "idx_triplet")
+ }
+ for i, graph in enumerate(graphs):
+ edge_feat = graph.edge_feat
+ triplet_fields["idx_kj"].append(
+ np.asarray(edge_feat["ti_idx_kj"], dtype=np.int64)
+ + edge_offsets[i]
+ )
+ triplet_fields["idx_ji"].append(
+ np.asarray(edge_feat["ti_idx_ji"], dtype=np.int64)
+ + edge_offsets[i]
+ )
+ triplet_fields["idx_lk"].append(
+ np.asarray(edge_feat["ti_idx_lk"], dtype=np.int64)
+ + edge_offsets[i]
+ )
+ triplet_fields["idx_triplet"].append(
+ np.asarray(edge_feat["ti_idx_triplet"], dtype=np.int64)
+ + triplet_offsets[i]
+ )
+
+ graph = pgl.Graph.batch(graphs)
+ graph.edge_feat.update(
+ {
+ f"ti_{key}": np.concatenate(value)
+ for key, value in triplet_fields.items()
+ }
+ )
+
+ result = DefaultCollator()(
+ [
+ {key: value for key, value in sample.items() if key != "graph"}
+ for sample in batch
+ ]
+ )
+ result["graph"] = graph
+ return result
+
+
class DensityCollator:
def __init__(
self,
diff --git a/ppmat/datasets/gmtnet_dataset.py b/ppmat/datasets/gmtnet_dataset.py
new file mode 100644
index 00000000..ca911164
--- /dev/null
+++ b/ppmat/datasets/gmtnet_dataset.py
@@ -0,0 +1,462 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""GMTNet dielectric dataset backed by the normalized frozen pickle."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import operator
+import pickle
+from collections.abc import Mapping
+from contextlib import nullcontext
+from dataclasses import dataclass
+from importlib import resources
+from pathlib import Path
+from typing import Any, ClassVar
+
+import numpy as np
+import paddle
+from paddle.io import Dataset
+from pymatgen.core import Structure
+
+from ppmat.models.gmtnet.gmtnet_graph_converter import GMTNetGraphConverter
+
+_RECORD_COUNT = 4713
+_SPLIT_SIZES = {"train": 3770, "val": 471, "test": 472}
+_DEFAULT_SPLIT_RESOURCE = (
+ "configs",
+ "gmtnet",
+ "split_gmtnet_dielectric_seed32.json",
+)
+_CONVERTER_DEFAULTS = {
+ "cutoff": 4.0,
+ "max_neighbors": 16,
+ "atom_features": "cgcnn",
+ "use_canonize": True,
+ "reduce_cell": False,
+}
+
+
+@dataclass(frozen=True)
+class _CachedPayload:
+ """Validated immutable-by-convention normalized data cache entry."""
+
+ payload: Mapping[str, Any]
+ sha256: str
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _split_indices_sha256(indices: list[int]) -> str:
+ encoded = json.dumps(indices, separators=(",", ":"), ensure_ascii=True).encode(
+ "utf-8"
+ )
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _require_regular_file(path: Path, label: str) -> Path:
+ resolved = path.expanduser().resolve()
+ if not resolved.exists():
+ raise FileNotFoundError(f"{label} does not exist: {resolved}")
+ if not resolved.is_file():
+ raise ValueError(f"{label} must be a regular file: {resolved}")
+ return resolved
+
+
+def _default_split_resource():
+ """Return the canonical split from installed resources or the source tree."""
+ try:
+ resource = resources.files("property_prediction").joinpath(
+ *_DEFAULT_SPLIT_RESOURCE
+ )
+ except ModuleNotFoundError:
+ resource = Path(__file__).resolve().parents[2].joinpath(
+ "property_prediction", *_DEFAULT_SPLIT_RESOURCE
+ )
+ if not resource.is_file():
+ raise FileNotFoundError(
+ "GMTNet split resource is missing: property_prediction/configs/gmtnet/"
+ "split_gmtnet_dielectric_seed32.json"
+ )
+ return resource
+
+
+def _validate_record(record: Any, index: int) -> None:
+ if not isinstance(record, Mapping):
+ raise ValueError(f"Record {index} must be a mapping.")
+ required_fields = {
+ "data_index",
+ "JARVIS_ID",
+ "structure",
+ "equivalent_atoms",
+ "feature_mask",
+ "matrix_equal",
+ "dielectric",
+ }
+ missing = required_fields - set(record)
+ if missing:
+ raise ValueError(f"Record {index} is missing fields: {sorted(missing)}")
+ if record["data_index"] != index:
+ raise ValueError(f"Record {index} data_index does not match its position.")
+ if not isinstance(record["JARVIS_ID"], str) or not record["JARVIS_ID"]:
+ raise ValueError(f"Record {index} JARVIS_ID must be a non-empty string.")
+ if type(record["structure"]) is not dict:
+ raise ValueError(f"Record {index} structure must be a plain dict.")
+ field_shapes = {
+ "equivalent_atoms": (None,),
+ "feature_mask": (32, 32),
+ "matrix_equal": (9, 9),
+ "dielectric": (3, 3),
+ }
+ for field_name, expected_shape in field_shapes.items():
+ value = record[field_name]
+ if not isinstance(value, np.ndarray):
+ raise ValueError(f"Record {index} {field_name} must be a numpy array.")
+ if expected_shape == (None,):
+ if value.ndim != 1:
+ raise ValueError(
+ f"Record {index} equivalent_atoms must have shape [N]."
+ )
+ elif value.shape != expected_shape:
+ raise ValueError(
+ f"Record {index} {field_name} has shape {value.shape}, expected {expected_shape}."
+ )
+
+
+def _validate_payload(payload: Any) -> Mapping[str, Any]:
+ if not isinstance(payload, Mapping):
+ raise ValueError("Normalized pickle root must be a mapping.")
+ if payload.get("schema_version") != 1:
+ raise ValueError("Normalized pickle schema_version must be 1.")
+ if payload.get("num_records") != _RECORD_COUNT:
+ raise ValueError(f"Normalized pickle num_records must be {_RECORD_COUNT}.")
+ source_hash = payload.get("source_original_dataset_sha256")
+ if not isinstance(source_hash, str) or not source_hash:
+ raise ValueError("Normalized pickle source_original_dataset_sha256 is missing.")
+ records = payload.get("records")
+ if not isinstance(records, list) or len(records) != _RECORD_COUNT:
+ raise ValueError(
+ f"Normalized pickle records must contain {_RECORD_COUNT} entries."
+ )
+ for index, record in enumerate(records):
+ _validate_record(record, index)
+ return payload
+
+
+def _validate_split_json(
+ split_path: Path,
+ normalized_sha256: str,
+ normalized_payload: Mapping[str, Any],
+ verify_sha256: bool,
+) -> dict[str, tuple[int, ...]]:
+ with split_path.open("r", encoding="utf-8") as handle:
+ split_data = json.load(handle)
+ required_fields = {
+ "schema_version",
+ "normalized_schema_version",
+ "source_original_dataset_sha256",
+ "normalized_dataset_sha256",
+ "num_records",
+ "seed",
+ "split_sizes",
+ "split_indices_sha256",
+ "train_indices",
+ "val_indices",
+ "test_indices",
+ }
+ if not isinstance(split_data, dict) or not required_fields <= set(split_data):
+ raise ValueError("Split JSON has an invalid schema.")
+ if split_data["schema_version"] != 1:
+ raise ValueError("Split JSON schema_version must be 1.")
+ if split_data["normalized_schema_version"] != 1:
+ raise ValueError("Split JSON normalized_schema_version must be 1.")
+ if split_data["num_records"] != _RECORD_COUNT:
+ raise ValueError(f"Split JSON num_records must be {_RECORD_COUNT}.")
+ if split_data["seed"] != 32:
+ raise ValueError("Split JSON seed must be 32.")
+ if split_data["split_sizes"] != _SPLIT_SIZES:
+ raise ValueError("Split JSON split_sizes are invalid.")
+ if verify_sha256 and split_data["normalized_dataset_sha256"] != normalized_sha256:
+ raise ValueError(
+ "Split JSON normalized_dataset_sha256 does not match data_path."
+ )
+ if (
+ split_data["source_original_dataset_sha256"]
+ != normalized_payload["source_original_dataset_sha256"]
+ ):
+ raise ValueError(
+ "Split JSON source_original_dataset_sha256 does not match normalized data."
+ )
+ hashes = split_data["split_indices_sha256"]
+ if not isinstance(hashes, dict) or set(hashes) != set(_SPLIT_SIZES):
+ raise ValueError("Split JSON split_indices_sha256 is invalid.")
+ split_indices: dict[str, tuple[int, ...]] = {}
+ all_indices: list[int] = []
+ for split_name, expected_size in _SPLIT_SIZES.items():
+ indices = split_data[f"{split_name}_indices"]
+ if not isinstance(indices, list) or len(indices) != expected_size:
+ raise ValueError(f"Split JSON {split_name}_indices length is invalid.")
+ if any(type(index) is not int for index in indices):
+ raise ValueError(f"Split JSON {split_name}_indices must contain integers.")
+ if any(index < 0 or index >= _RECORD_COUNT for index in indices):
+ raise ValueError(
+ f"Split JSON {split_name}_indices contains an out-of-range index."
+ )
+ if len(set(indices)) != len(indices):
+ raise ValueError(
+ f"Split JSON {split_name}_indices contains duplicate indices."
+ )
+ if hashes[split_name] != _split_indices_sha256(indices):
+ raise ValueError(f"Split JSON {split_name}_indices SHA256 does not match.")
+ split_indices[split_name] = tuple(indices)
+ all_indices.extend(indices)
+ if len(set(all_indices)) != _RECORD_COUNT or set(all_indices) != set(
+ range(_RECORD_COUNT)
+ ):
+ raise ValueError(
+ "Split JSON indices overlap or do not cover the normalized data."
+ )
+ if split_indices["test"][:3] != (747, 1423, 1322):
+ raise ValueError("Split JSON test_indices fixed prefix is invalid.")
+ return split_indices
+
+
+def _validate_smoke_split_json(
+ split_path: Path,
+ canonical_split_path: Path,
+ normalized_sha256: str,
+ normalized_payload: Mapping[str, Any],
+ verify_sha256: bool,
+) -> dict[str, tuple[int, ...]]:
+ canonical_indices = _validate_split_json(
+ canonical_split_path,
+ normalized_sha256,
+ normalized_payload,
+ verify_sha256,
+ )
+ with canonical_split_path.open("r", encoding="utf-8") as handle:
+ canonical_data = json.load(handle)
+ with split_path.open("r", encoding="utf-8") as handle:
+ split_data = json.load(handle)
+ if not isinstance(split_data, dict):
+ raise ValueError("Smoke split JSON must be a mapping.")
+ if split_data.get("smoke_only") is not True:
+ raise ValueError("Smoke split JSON smoke_only must be true.")
+ if split_data.get("parent_split_sha256") != _sha256(canonical_split_path):
+ raise ValueError(
+ "Smoke split JSON parent_split_sha256 does not match canonical split."
+ )
+ for field_name in (
+ "schema_version",
+ "normalized_schema_version",
+ "source_original_dataset_sha256",
+ "normalized_dataset_sha256",
+ "num_records",
+ "seed",
+ ):
+ if split_data.get(field_name) != canonical_data.get(field_name):
+ raise ValueError(
+ f"Smoke split JSON {field_name} does not match canonical split."
+ )
+ if verify_sha256 and split_data["normalized_dataset_sha256"] != normalized_sha256:
+ raise ValueError(
+ "Smoke split JSON normalized_dataset_sha256 does not match data_path."
+ )
+ hashes = split_data.get("split_indices_sha256")
+ if not isinstance(hashes, dict) or set(hashes) != set(_SPLIT_SIZES):
+ raise ValueError("Smoke split JSON split_indices_sha256 is invalid.")
+ split_sizes = split_data.get("split_sizes")
+ if not isinstance(split_sizes, dict) or set(split_sizes) != set(_SPLIT_SIZES):
+ raise ValueError("Smoke split JSON split_sizes is invalid.")
+ smoke_indices: dict[str, tuple[int, ...]] = {}
+ all_indices: list[int] = []
+ for split_name in _SPLIT_SIZES:
+ indices = split_data.get(f"{split_name}_indices")
+ if not isinstance(indices, list) or not indices:
+ raise ValueError(
+ f"Smoke split JSON {split_name}_indices must be a non-empty list."
+ )
+ if any(type(index) is not int for index in indices):
+ raise ValueError(
+ f"Smoke split JSON {split_name}_indices must contain integers."
+ )
+ if any(index < 0 or index >= _RECORD_COUNT for index in indices):
+ raise ValueError(
+ f"Smoke split JSON {split_name}_indices contains an out-of-range index."
+ )
+ if split_sizes[split_name] != len(indices):
+ raise ValueError(
+ f"Smoke split JSON split_sizes[{split_name}] does not match indices."
+ )
+ if hashes[split_name] != _split_indices_sha256(indices):
+ raise ValueError(
+ f"Smoke split JSON {split_name}_indices SHA256 does not match."
+ )
+ if (
+ len(indices) > len(canonical_indices[split_name])
+ or tuple(indices) != canonical_indices[split_name][: len(indices)]
+ ):
+ raise ValueError(
+ f"Smoke split JSON {split_name}_indices must be a canonical ordered prefix."
+ )
+ smoke_indices[split_name] = tuple(indices)
+ all_indices.extend(indices)
+ if len(set(all_indices)) != len(all_indices):
+ raise ValueError("Smoke split JSON indices overlap across splits.")
+ return smoke_indices
+
+
+def _converter_params(build_graph_cfg: Mapping[str, Any] | None) -> dict[str, Any]:
+ if build_graph_cfg is None:
+ return dict(_CONVERTER_DEFAULTS)
+ config = dict(build_graph_cfg)
+ if "__class_name__" in config or "__init_params__" in config:
+ class_name = config.pop("__class_name__", None)
+ init_params = config.pop("__init_params__", None)
+ if config or class_name not in {
+ "GMTNetGraphConverter",
+ "ppmat.models.gmtnet.gmtnet_graph_converter.GMTNetGraphConverter",
+ }:
+ raise ValueError("build_graph_cfg must describe GMTNetGraphConverter.")
+ if not isinstance(init_params, Mapping):
+ raise ValueError("build_graph_cfg __init_params__ must be a mapping.")
+ config = dict(init_params)
+ unsupported = set(config) - set(_CONVERTER_DEFAULTS)
+ if unsupported:
+ raise ValueError(
+ f"build_graph_cfg has unsupported GMTNetGraphConverter parameters: {sorted(unsupported)}"
+ )
+ return {**_CONVERTER_DEFAULTS, **config}
+
+
+class GMTNetDielectricDataset(Dataset):
+ """Construct frozen GMTNet dielectric samples for one fixed split."""
+
+ property_names: ClassVar[tuple[str, ...]] = ("dielectric",)
+ _payload_cache: ClassVar[dict[tuple[Path, int, int], _CachedPayload]] = {}
+ _pickle_load_count: ClassVar[int] = 0
+
+ def __init__(
+ self,
+ data_path: str | Path,
+ split: str,
+ build_graph_cfg: Mapping[str, Any] | None = None,
+ split_path: str | Path | None = None,
+ verify_sha256: bool = True,
+ allow_smoke_split: bool = False,
+ canonical_split_path: str | Path | None = None,
+ ) -> None:
+ super().__init__()
+ if not isinstance(allow_smoke_split, bool):
+ raise TypeError("allow_smoke_split must be a bool.")
+ if split not in _SPLIT_SIZES:
+ raise ValueError("split must be one of: train, val, test.")
+ self.data_path = _require_regular_file(Path(data_path), "data_path")
+ split_path_context = (
+ nullcontext(Path(split_path))
+ if split_path is not None
+ else resources.as_file(_default_split_resource())
+ )
+ with split_path_context as resolved_split_path:
+ self.split_path = _require_regular_file(
+ Path(resolved_split_path), "split_path"
+ )
+ cache_entry = self._load_payload(self.data_path)
+ self._payload = cache_entry.payload
+ if allow_smoke_split:
+ if canonical_split_path is None:
+ raise ValueError(
+ "canonical_split_path is required when allow_smoke_split is true."
+ )
+ try:
+ resolved_canonical_path = _require_regular_file(
+ Path(canonical_split_path), "canonical_split_path"
+ )
+ except (FileNotFoundError, ValueError) as error:
+ raise ValueError(
+ f"canonical_split_path is invalid: {error}"
+ ) from error
+ self._split_indices = _validate_smoke_split_json(
+ self.split_path,
+ resolved_canonical_path,
+ cache_entry.sha256,
+ self._payload,
+ verify_sha256,
+ )[split]
+ else:
+ with self.split_path.open("r", encoding="utf-8") as handle:
+ split_data = json.load(handle)
+ if isinstance(split_data, dict) and split_data.get("smoke_only") is True:
+ raise ValueError(
+ "Smoke split JSON requires allow_smoke_split to be true."
+ )
+ self._split_indices = _validate_split_json(
+ self.split_path,
+ cache_entry.sha256,
+ self._payload,
+ verify_sha256,
+ )[split]
+ self.split = split
+ self.graph_converter = GMTNetGraphConverter(
+ **_converter_params(build_graph_cfg)
+ )
+
+ @classmethod
+ def _load_payload(cls, data_path: Path) -> _CachedPayload:
+ stat = data_path.stat()
+ cache_key = (data_path, stat.st_size, stat.st_mtime_ns)
+ cached = cls._payload_cache.get(cache_key)
+ if cached is not None:
+ return cached
+ sha256 = _sha256(data_path)
+ with data_path.open("rb") as handle:
+ payload = _validate_payload(pickle.load(handle))
+ cls._pickle_load_count += 1
+ cache_entry = _CachedPayload(payload=payload, sha256=sha256)
+ cls._payload_cache[cache_key] = cache_entry
+ return cache_entry
+
+ @classmethod
+ def _clear_cache_for_testing(cls) -> None:
+ cls._payload_cache.clear()
+ cls._pickle_load_count = 0
+
+ def __len__(self) -> int:
+ return len(self._split_indices)
+
+ def __getitem__(self, index: int) -> dict[str, Any]:
+ local_index = operator.index(index)
+ if local_index < 0:
+ local_index += len(self)
+ if local_index < 0 or local_index >= len(self):
+ raise IndexError("GMTNetDielectricDataset index out of range.")
+ data_index = self._split_indices[local_index]
+ record = self._payload["records"][data_index]
+ structure = Structure.from_dict(record["structure"])
+ graph = self.graph_converter(structure, record["equivalent_atoms"])
+ return {
+ "graph": graph,
+ "feature_mask": paddle.to_tensor(record["feature_mask"], dtype="float32"),
+ "matrix_equal": paddle.to_tensor(record["matrix_equal"], dtype="bool"),
+ "dielectric": paddle.to_tensor(record["dielectric"], dtype="float32"),
+ "id": record["JARVIS_ID"],
+ "data_index": paddle.to_tensor(record["data_index"], dtype="int64"),
+ }
diff --git a/ppmat/datasets/graph_utils/spherenet_graph_utils.py b/ppmat/datasets/graph_utils/spherenet_graph_utils.py
new file mode 100644
index 00000000..c4ee4769
--- /dev/null
+++ b/ppmat/datasets/graph_utils/spherenet_graph_utils.py
@@ -0,0 +1,63 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""SphereNet-specific graph utilities."""
+
+import paddle
+
+
+def radius_graph(pos, batch, cutoff, loop=False):
+ """Build edge indices for a batch of molecules within a cutoff radius.
+
+ Processes each molecule independently to avoid O(N²) memory on the full
+ concatenated batch. For each molecule, builds a local N_mol × N_mol
+ distance matrix, then remaps edge indices to global positions.
+
+ Args:
+ pos: Tensor of shape ``(num_nodes, 3)`` with atomic coordinates.
+ batch: Tensor of shape ``(num_nodes,)`` with batch indices.
+ cutoff: Neighbor cutoff distance in Ångström.
+ loop: Whether to include self-loops (default False).
+
+ Returns:
+ edge_index: Tensor of shape ``(2, num_edges)`` with global edge indices.
+ """
+ num_nodes = pos.shape[0]
+ if num_nodes == 0:
+ return paddle.empty([2, 0], dtype="int64")
+
+ if batch is None:
+ batch = paddle.zeros([num_nodes], dtype="int64")
+
+ _, counts = paddle.unique(batch, return_counts=True)
+ edge_list = []
+ start = 0
+ for i in range(counts.shape[0]):
+ n = int(counts[i])
+ if n == 0:
+ continue
+ local_pos = pos[start : start + n]
+ diff = local_pos.unsqueeze(1) - local_pos.unsqueeze(0)
+ dist_sq = paddle.sum(diff * diff, axis=-1)
+ mask = dist_sq < cutoff * cutoff
+ if not loop:
+ atom_ids = paddle.arange(n, dtype="int64")
+ mask = mask & (atom_ids.unsqueeze(0) != atom_ids.unsqueeze(1))
+ src, dst = paddle.where(mask)
+ if src.shape[0] > 0:
+ edge_list.append(paddle.stack([src + start, dst + start], axis=0))
+ start += n
+
+ if not edge_list:
+ return paddle.empty([2, 0], dtype="int64")
+ return paddle.concat(edge_list, axis=1)
diff --git a/ppmat/datasets/md17_dataset.py b/ppmat/datasets/md17_dataset.py
new file mode 100644
index 00000000..c5bd4fa7
--- /dev/null
+++ b/ppmat/datasets/md17_dataset.py
@@ -0,0 +1,392 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""MD17 molecular dynamics dataset for energy and force prediction."""
+
+import os
+import os.path as osp
+import pickle
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import Optional
+
+import numpy as np
+import paddle.distributed as dist
+from paddle.io import Dataset
+
+from ppmat.datasets.build_molecule import BuildMolecule
+from ppmat.datasets.custom_data_type import ConcatData
+from ppmat.models import build_graph_converter
+from ppmat.utils import download
+from ppmat.utils import logger
+from ppmat.utils.misc import is_equal
+
+
+_BUNDLE_NPZ_MAP = {
+ "aspirin": "md17_aspirin.npz",
+ "benzene_old": "md17_benzene2017.npz",
+ "ethanol": "md17_ethanol.npz",
+ "malonaldehyde": "md17_malonaldehyde.npz",
+ "naphthalene": "md17_naphthalene.npz",
+ "salicylic": "md17_salicylic.npz",
+ "toluene": "md17_toluene.npz",
+ "uracil": "md17_uracil.npz",
+}
+
+
+class MD17Dataset(Dataset):
+ """MD17 molecular dynamics dataset for energy and force prediction.
+
+ **STATS:**
+ +----------------+----------+--------+-------+----------+-------+
+ | Molecule | #samples | #atoms | #tasks| #targets | Split |
+ +================+==========+========+=======+==========+=======+
+ | Aspirin | 211,762 | 21 | 2 | E + F | 1k/1k/R |
+ | Benzene (old) | 627,983 | 12 | 2 | E + F | 1k/1k/R |
+ | Ethanol | 555,092 | 9 | 2 | E + F | 1k/1k/R |
+ | Malonaldehyde | 993,237 | 9 | 2 | E + F | 1k/1k/R |
+ | Naphthalene | 326,250 | 10 | 2 | E + F | 1k/1k/R |
+ | Salicylic | 320,231 | 16 | 2 | E + F | 1k/1k/R |
+ | Toluene | 442,790 | 15 | 2 | E + F | 1k/1k/R |
+ | Uracil | 133,770 | 12 | 2 | E + F | 1k/1k/R |
+ +----------------+----------+--------+-------+----------+-------+
+
+ Contains ab-initio molecular dynamics trajectories for eight small
+ organic molecules. Each frame provides atomic numbers, 3D positions,
+ total energy, and per-atom forces.
+
+ Data source: https://www.quantum-machine.org/datasets/
+
+ Args:
+ path (str): Root directory for storing raw and cached data.
+ name (str): Molecule name from the supported list. Defaults to ``'benzene_old'``.
+ split (Optional[str]): Split identifier ``'train'``, ``'val'``,
+ ``'test'``, or ``None`` (all). Defaults to ``None``.
+ split_file (Optional[str]): Preprocessed numpy index file for the
+ selected split. Defaults to ``None``.
+ build_molecule_cfg (Optional[Dict]): Configuration dict for molecule
+ converter. Defaults to ``None``.
+ force_key (Optional[str]): Key name for forces in the output dict.
+ Defaults to ``'force'``.
+ energy_key (Optional[str]): Key name for energy in the output dict.
+ Defaults to ``'energy'``.
+ build_graph_cfg (Optional[Dict]): Configuration dict for graph
+ converter. Defaults to ``None``.
+ transforms (Optional[Callable]): Per-sample transform callable.
+ Defaults to ``None``.
+ cache_path (Optional[str]): Explicit cache path. Auto-generated
+ when ``None``. Defaults to ``None``.
+ overwrite (bool): Whether to overwrite existing cached graphs.
+ Defaults to ``False``.
+ filter_unvalid (bool): Whether to filter out invalid samples.
+ Defaults to ``True``.
+ """
+
+ url = "https://paddle-org.bj.bcebos.com/paddlematerials/datasets/MD17/md17.tar.gz"
+ md5 = "634cc25cc8a3fb0d99bd14245eb8dabd"
+ name = "md17"
+
+ def __init__(
+ self,
+ path: str,
+ name: str = "benzene_old",
+ split: str = None,
+ *,
+ split_file: Optional[str] = None,
+ build_molecule_cfg: Optional[Dict] = None,
+ force_key="force",
+ energy_key="energy",
+ build_graph_cfg: Optional[Dict] = None,
+ transforms: Optional[Callable] = None,
+ cache_path: Optional[str] = None,
+ overwrite: bool = False,
+ filter_unvalid: bool = True,
+ **kwargs,
+ ):
+ super().__init__()
+
+ self.mol_name = name
+ self.force_key = force_key
+ self.energy_key = energy_key
+ self.transforms = transforms
+ self.overwrite = overwrite
+ self.filter_unvalid = filter_unvalid
+ if build_molecule_cfg is None:
+ build_molecule_cfg = {
+ "format": "dict",
+ "sanitize": False,
+ "add_hs": False,
+ "remove_hs": False,
+ "kekulize": False,
+ "num_cpus": 1,
+ }
+ logger.message(
+ "The build_molecule_cfg is not set, will use the default "
+ f"configs: {build_molecule_cfg}"
+ )
+ self.build_molecule_cfg = build_molecule_cfg
+
+ # ---- 1. Read full trajectory and selected frame indices ----
+ npz_path = self._resolve_data_path(path, name)
+ self.path = npz_path
+ self.row_data, total = self.read_data(npz_path)
+ self._indices = self._load_split_indices(
+ npz_path, name, split, split_file, total
+ )
+ self.num_samples = len(self._indices)
+
+ # ---- 2. Cache path ----
+ if cache_path is not None:
+ self.cache_path = cache_path
+ else:
+ base_dir = osp.split(npz_path)[0]
+ base_name = osp.splitext(osp.basename(npz_path))[0]
+ split_suffix = split if split is not None else "all"
+ self.cache_path = osp.join(
+ f"{base_dir}_cache", f"{base_name}_{split_suffix}"
+ )
+ logger.info(f"Cache path: {self.cache_path}")
+
+ # ---- 3. Pre-build molecules and graphs (MPtrj-style cache) ----
+ self.cache_exists = True if osp.exists(self.cache_path) else False
+ if self.cache_exists and not overwrite:
+ logger.warning(
+ "Cache enabled. If a cache file exists, it will be automatically "
+ "read and current settings will be ignored. Please ensure that the "
+ "settings used in match your current settings."
+ )
+ try:
+ build_molecule_cfg_cache = self.load_from_cache(
+ osp.join(self.cache_path, "build_molecule_cfg.pkl")
+ )
+ if not is_equal(build_molecule_cfg_cache, build_molecule_cfg):
+ logger.warning(
+ "build_molecule_cfg differs from cache. Rebuilding."
+ )
+ overwrite = True
+ except Exception as e:
+ logger.warning(e)
+ logger.warning(
+ "Failed to load build_molecule_cfg.pkl from cache. "
+ "Will rebuild the molecules and graphs(if need)."
+ )
+ overwrite = True
+
+ if build_graph_cfg is not None and not overwrite:
+ try:
+ build_graph_cfg_cache = self.load_from_cache(
+ osp.join(self.cache_path, "build_graph_cfg.pkl")
+ )
+ if not is_equal(build_graph_cfg_cache, build_graph_cfg):
+ logger.warning(
+ "build_graph_cfg differs from cache. Rebuilding."
+ )
+ overwrite = True
+ except Exception as e:
+ logger.warning(e)
+ logger.warning(
+ "Failed to load build_graph_cfg.pkl from cache. "
+ "Will rebuild the graphs."
+ )
+ overwrite = True
+
+ molecule_cache_path = osp.join(self.cache_path, "molecules")
+ graph_cache_path = osp.join(self.cache_path, "graphs")
+ if overwrite or not self.cache_exists:
+ if dist.get_rank() == 0:
+ os.makedirs(self.cache_path, exist_ok=True)
+ self.save_to_cache(
+ osp.join(self.cache_path, "build_molecule_cfg.pkl"),
+ build_molecule_cfg,
+ )
+ self.save_to_cache(
+ osp.join(self.cache_path, "build_graph_cfg.pkl"), build_graph_cfg
+ )
+
+ molecule_data = [
+ {
+ "atomic_numbers": self.row_data["z"],
+ "positions": self.row_data["pos"][frame],
+ }
+ for frame in self._indices
+ ]
+ molecules = BuildMolecule(**build_molecule_cfg)(molecule_data)
+ os.makedirs(molecule_cache_path, exist_ok=True)
+ for i, mol in enumerate(molecules):
+ self.save_to_cache(
+ osp.join(molecule_cache_path, f"{i:010d}.pkl"), mol
+ )
+ logger.info(
+ f"Save {self.num_samples} molecules to {molecule_cache_path}"
+ )
+
+ if build_graph_cfg is not None:
+ converter = build_graph_converter(build_graph_cfg)
+ graphs = converter(molecules)
+ os.makedirs(graph_cache_path, exist_ok=True)
+ for i in range(self.num_samples):
+ self.save_to_cache(
+ osp.join(graph_cache_path, f"{i:010d}.pkl"), graphs[i]
+ )
+ logger.info(f"Save {self.num_samples} graphs to {graph_cache_path}")
+ if dist.is_initialized():
+ dist.barrier()
+
+ self.molecules = [
+ osp.join(molecule_cache_path, f"{i:010d}.pkl")
+ for i in range(self.num_samples)
+ ]
+ if build_graph_cfg is not None:
+ self.graphs = [
+ osp.join(graph_cache_path, f"{i:010d}.pkl")
+ for i in range(self.num_samples)
+ ]
+ else:
+ self.graphs = None
+ assert (
+ len(self.molecules) == self.num_samples
+ ), "The number of molecules must be equal to the number of samples."
+ assert (
+ self.graphs is None or len(self.graphs) == self.num_samples
+ ), "The number of graphs must be equal to the number of samples."
+
+ logger.info(f"Load {self.num_samples} samples, split={split}")
+
+ def _resolve_data_path(self, path, name):
+ if osp.isfile(path):
+ return path
+
+ candidates = [
+ osp.join(path, f"{name}_dft.npz"),
+ osp.join(path, self.name, f"{name}_dft.npz"),
+ ]
+ if name in _BUNDLE_NPZ_MAP:
+ candidates.extend(
+ [
+ osp.join(path, _BUNDLE_NPZ_MAP[name]),
+ osp.join(path, self.name, _BUNDLE_NPZ_MAP[name]),
+ ]
+ )
+ for candidate in candidates:
+ if osp.exists(candidate):
+ return candidate
+
+ logger.message("The dataset is not found. Will download it now.")
+ root_path = download.get_datasets_path_from_url(self.url, self.md5)
+ root_paths = [root_path]
+ if not osp.exists(root_path):
+ root_paths.append(osp.dirname(root_path))
+
+ candidates = []
+ for candidate_root in root_paths:
+ candidates.extend(
+ [
+ osp.join(candidate_root, self.name, f"{name}_dft.npz"),
+ osp.join(candidate_root, f"{name}_dft.npz"),
+ ]
+ )
+ if name in _BUNDLE_NPZ_MAP:
+ candidates.extend(
+ [
+ osp.join(
+ candidate_root, self.name, _BUNDLE_NPZ_MAP[name]
+ ),
+ osp.join(candidate_root, _BUNDLE_NPZ_MAP[name]),
+ ]
+ )
+ for candidate in candidates:
+ if osp.exists(candidate):
+ return candidate
+ raise FileNotFoundError(f"Cannot find MD17 npz file for molecule: {name}")
+
+ def read_data(self, path):
+ """Load all trajectory frames from the npz file."""
+ data = np.load(path)
+ row_data = {
+ "z": data["z"],
+ "pos": data["R"],
+ "energy": data["E"],
+ "force": data["F"],
+ }
+ return row_data, data["R"].shape[0]
+
+ def _load_split_indices(self, path, name, split, split_file, total):
+ """Load frame indices for the requested split."""
+ if split is None and split_file is None:
+ return np.arange(total, dtype=np.int64)
+ if split_file is None:
+ split_dir = osp.join(osp.dirname(path), "splits")
+ key = split if split is not None else "all"
+ split_file = osp.join(split_dir, f"{name}_{key}_idx.npy")
+ if not osp.exists(split_file):
+ split_file = osp.join(split_dir, f"split_{key}.npy")
+ if not osp.exists(split_file):
+ raise FileNotFoundError(f"No such split file: {split_file}")
+ return np.load(split_file).astype(np.int64)
+
+ def get_molecule_array(self, molecule):
+ conf = molecule.GetConformer()
+ z = np.array(
+ [atom.GetAtomicNum() for atom in molecule.GetAtoms()], dtype=np.int64
+ )
+ pos = np.array(
+ [
+ [
+ conf.GetAtomPosition(i).x,
+ conf.GetAtomPosition(i).y,
+ conf.GetAtomPosition(i).z,
+ ]
+ for i in range(molecule.GetNumAtoms())
+ ],
+ dtype=np.float32,
+ )
+ return {
+ "z": ConcatData(z),
+ "pos": ConcatData(pos),
+ "num_atoms": ConcatData(np.array([z.shape[0]], dtype=np.int64)),
+ }
+
+ def save_to_cache(self, cache_path: str, obj: Any):
+ with open(cache_path, "wb") as f:
+ pickle.dump(obj, f)
+
+ def load_from_cache(self, cache_path: str):
+ if osp.exists(cache_path):
+ with open(cache_path, "rb") as f:
+ return pickle.load(f)
+ raise FileNotFoundError(f"No such file or directory: {cache_path}")
+
+ def __getitem__(self, idx):
+ frame = self._indices[idx]
+ sample = {
+ self.energy_key: np.array(
+ [float(self.row_data["energy"][frame])], dtype=np.float32
+ ),
+ self.force_key: ConcatData(self.row_data["force"][frame]),
+ }
+ if self.graphs is not None:
+ graph = self.graphs[idx]
+ if isinstance(graph, str):
+ graph = self.load_from_cache(graph)
+ sample["graph"] = graph
+ else:
+ mol = self.load_from_cache(self.molecules[idx])
+ sample.update(self.get_molecule_array(mol))
+ sample["id"] = int(frame)
+ if self.transforms is not None:
+ sample = self.transforms(sample)
+ return sample
+
+ def __len__(self):
+ return self.num_samples
diff --git a/ppmat/datasets/mp2018_dataset.py b/ppmat/datasets/mp2018_dataset.py
index 2c0fe2a8..ea4518e8 100644
--- a/ppmat/datasets/mp2018_dataset.py
+++ b/ppmat/datasets/mp2018_dataset.py
@@ -185,9 +185,9 @@ def __init__(
self.filter_unvalid = filter_unvalid
self.cache_exists = True if osp.exists(self.cache_path) else False
- self.row_data, self.num_samples = self.read_data(path)
+ self.raw_data, self.num_samples = self.read_data(path)
logger.info(f"Load {self.num_samples} samples from {path}")
- self.property_data = self.read_property_data(self.row_data, self.property_names)
+ self.property_data = self.read_property_data(self.raw_data, self.property_names)
if self.cache_exists and not overwrite:
logger.warning(
@@ -272,7 +272,7 @@ def __init__(
)
# convert strucutes
structures = BuildStructure(**build_structure_cfg)(
- self.row_data["structure"]
+ self.raw_data["structure"]
)
# save structures to cache file
os.makedirs(structure_cache_path, exist_ok=True)
@@ -352,7 +352,7 @@ def filter_unvalid_by_property(self):
self.property_data[key][i] for i in reserve_idx
]
- self.row_data = [self.row_data[i] for i in reserve_idx]
+ self.raw_data = [self.raw_data[i] for i in reserve_idx]
self.structures = [self.structures[i] for i in reserve_idx]
if self.graphs is not None:
self.graphs = [self.graphs[i] for i in reserve_idx]
@@ -360,7 +360,7 @@ def filter_unvalid_by_property(self):
f"Filter out {len(reserve_idx)} samples with valid properties: "
f"{property_name}"
)
- self.num_samples = len(self.row_data)
+ self.num_samples = len(self.raw_data)
logger.warning(f"Remaining {self.num_samples} samples after filtering.")
def read_property_data(self, data: Dict, property_names: list[str]):
diff --git a/ppmat/datasets/mp20_dataset.py b/ppmat/datasets/mp20_dataset.py
index d257f855..a70e778d 100644
--- a/ppmat/datasets/mp20_dataset.py
+++ b/ppmat/datasets/mp20_dataset.py
@@ -128,6 +128,7 @@ def __init__(
self.path = path
if isinstance(property_names, str):
property_names = [property_names]
+ self.property_names = property_names if property_names is not None else []
if build_structure_cfg is None:
build_structure_cfg = {
@@ -141,7 +142,6 @@ def __init__(
f"configs: {build_structure_cfg}"
)
- self.property_names = property_names if property_names is not None else []
self.build_structure_cfg = build_structure_cfg
self.build_graph_cfg = build_graph_cfg
self.transforms = transforms
diff --git a/ppmat/datasets/msd_nmr_dataset.py b/ppmat/datasets/msd_nmr_dataset.py
index f3e08798..2406bb5e 100644
--- a/ppmat/datasets/msd_nmr_dataset.py
+++ b/ppmat/datasets/msd_nmr_dataset.py
@@ -277,12 +277,12 @@ def __init__(
logger.info(
"The cached build_molecule_cfg configuration matches "
"the current settings. Reusing previously generated"
- " structural data to optimize performance."
+ " moelcular data to optimize performance."
)
else:
logger.warning(
"build_molecule_cfg is different from "
- "build_molecule_cfgg_cache. Will rebuild the molecules and "
+ "build_molecule_cfg_cache. Will rebuild the molecules and "
"graphs."
)
logger.warning(
@@ -365,7 +365,7 @@ def __init__(
spectrum_cache_path = osp.join(self.cache_path, "spectrums")
if overwrite or not self.cache_exists:
- # convert strucutes and graphs
+ # convert molecules and graphs
# only rank 0 process do the conversion
if dist.get_rank() == 0:
# save build_molecule_cfg and build_graph_cfg and build_spechtrum_cfg
diff --git a/ppmat/datasets/qm9_dataset.py b/ppmat/datasets/qm9_dataset.py
index fec8889c..837049e3 100644
--- a/ppmat/datasets/qm9_dataset.py
+++ b/ppmat/datasets/qm9_dataset.py
@@ -1,766 +1,383 @@
-# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
-
+# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
+#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
-
+#
# http://www.apache.org/licenses/LICENSE-2.0
-
+#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-from __future__ import absolute_import
from __future__ import annotations
import math
import os
import os.path as osp
-import pickle ## for dump/load
-from collections import defaultdict
+import pickle
from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
-from typing import Union
-from typing import List
import numpy as np
+import pandas as pd
import paddle.distributed as dist
from paddle.io import Dataset
-
-from ppmat.datasets.build_structure import BuildStructure
-from ppmat.datasets.custom_data_type import ConcatData
+from ppmat.datasets.build_molecule import BuildMolecule
from ppmat.models import build_graph_converter
from ppmat.utils import download
from ppmat.utils import logger
-from ppmat.utils.io import read_json
from ppmat.utils.misc import is_equal
-# Attempt to import tqdm for progress visualization
-try:
- from tqdm import tqdm
-except ImportError:
- def tqdm(iterable, **kwargs):
- return iterable
-
-try:
- import ase.io
- import ase.data
- import ase.build
- from pymatgen.io.ase import AseAtomsAdaptor
-
- read = ase.io.read
- symbols = ase.data.atomic_numbers
- ASE_AVAILABLE = True
-
-except ImportError:
-
- def dummy_read(*args, **kwargs):
- """
- If ASE is not installed, this pseudo-read function will throw an error when called.
- In the `__init__` method of `QM9Dataset`, if `read` is not needed immediately,
- you can simply return `None` or an empty list. However, if it is needed, the error will be more explicit.
-
- """
- raise RuntimeError(
- "Atomic Simulation Environment (ASE) is required but not installed. "
- "Please install it (e.g., pip install ase) to use QM9Dataset."
- )
- read = dummy_read
- symbols = None
- AseAtomsAdaptor = None
- ASE_AVAILABLE = False
- print("Warning: ASE (Atomic Simulation Environment) not found. Data parsing functionality is disabled.")
class QM9Dataset(Dataset):
- """
- QM9 (GDB-9) Dataset Handler
- In order to adapt to the CHGNet model, I made a forced mapping from 'lumo' to 'energy_per_atom'.
- The CHGNet model is used to run machine learning potentials, and the QM9 dataset may not be very suitable.
- LUMO is a property. Please be aware of this when using it to avoid misunderstandings.
- This code is for research purposes only and does not represent the optimal approach.——by wwaawwaaee
-
+ """QM9 Dataset Handler
+
+ This class provides utilities for loading and processing the QM9 molecular
+ quantum chemistry dataset. The implementation supports both standard dataset
+ loading and custom data processing when adhering to the QM9 data schema.
+
**Dataset Overview**
- this class downloads QM9 primiry(end with .xyz)and transfered into
- the input structures and quantum chemical property labels required
- by graph neural network (GNN) models.(maybe)
-
- **dataset format**
- -----------------
- - raw data: qm9.zip ()
- - struncture file: a sample corresponds to a seperate .xyz file
- - attribute(label): 19 quantum chemical properties are embedded in each .xyz file's
- second line comment
-
- **source**:Original data available at https://figshare.com/ndownloader/files/3195389
-
- The dataset can also be found at https://paddle-org.bj.bcebos.com/paddlematerials/datasets/qm9/dsgdb9nsd.xyz.tar.bz2
-
- **Key Properties List (Available for 'property_names' argument)**
- -----------------------------------------------------------------
- 1. mu (Dipole Moment, Debye)
- 2. alpha (Isotropic Polarizability, Bohr^3)
- 3. homo (HOMO Energy, Hartree)
- 4. lumo (LUMO Energy, Hartree)
- 5. gap (LUMO-HOMO Gap, Hartree)
- 6. U0 (Internal Energy at 0 K, Hartree)
- sly
- # ... (remaining 13 properties here in their correct order)
-
- **__getitem__ Sample Contract**
- ----------------------------------------
- - 'atom_types': np.ndarray (dtype=int64) - Atomic numbers (Z).
- - 'coords': np.ndarray (dtype=float32) - 3D Cartesian coordinates in Angstrom.
- - [property_name]: np.ndarray (dtype=float32) - The target label value (e.g., 'lumo').
- - 'graph': (Optional) The graph object constructed by the converter (if configured).
-
+ - **Source**: Original data from "Quantum chemistry structures and properties
+ of 134 kilo molecules" and the QM9/GDB9 dataset.
+ - **Filtering**: The 3,054 uncharacterized molecules listed by the official
+ QM9 consistency check are removed.
+ ```
+ ┌───────────────────┬─────────┬─────────┬─────────┐
+ │ Dataset Partition │ Train │ Val │ Test │
+ ├───────────────────┼─────────┼─────────┼─────────┤
+ │ Sample Count │ 110,000 │ 10,000 │ 10,831 │
+ └───────────────────┴─────────┴─────────┴─────────┘
+ ```
+ The dataset can also be downloaded from the following source:
+ https://paddle-org.bj.bcebos.com/paddlematerials/datasets/qm9/qm9_split.zip
+
+ **Data Format**
+ The dataset is structured as comma-separated values (CSV) files with one
+ molecule per row. The split files are `train.csv`, `val.csv`, and `test.csv`.
+
+ | Column Name | Description | Example Value |
+ |----------------------------|--------------------------------------------------|----------------|
+ | `file_name` | Original QM9 xyz file name | qm9_000001.xyz |
+ | `standard_xyz` | Standard XYZ string without Mulliken charges | xyz_str |
+ | `mulliken_xyz` | XYZ string with Mulliken partial charges | xyz_charge_str |
+ | `molecule_id` | 1-based QM9 molecule identifier | 1 |
+ | `num_atoms` | Number of atoms in the molecule | 5 |
+ | `A`, `B`, `C` | Rotational constants | 157.7118 |
+ | `mu` | Dipole moment | 0.0 |
+ | `alpha` | Isotropic polarizability | 13.21 |
+ | `homo`, `lumo`, `gap` | HOMO, LUMO, and HOMO-LUMO gap | -0.3877 |
+ | `r2` | Electronic spatial extent | 35.3641 |
+ | `zpve` | Zero point vibrational energy | 0.044749 |
+ | `U0`, `U`, `H`, `G` | Thermochemical energies | -40.47893 |
+ | `Cv` | Heat capacity | 6.469 |
+ | `vibrational_frequencies` | Space-separated vibrational frequencies | 1341.307 ... |
+ | `canonical_smiles` | Canonical SMILES string | C |
+ | `isomeric_smiles` | Isomeric SMILES string | C |
+ | `canonical_inchi` | Canonical InChI string | InChI=1S/CH4/h1H4 |
+ | `isomeric_inchi` | Isomeric InChI string | InChI=1S/CH4/h1H4 |
+
+ **Example Row:**
+ ```csv
+ file_name,molecule_id,num_atoms,A,B,C,mu,alpha,homo,lumo,gap,...
+ qm9_000001.xyz,1,5,157.7118,157.70997,157.70699,0.0,13.21,-0.3877,0.1171,0.5048,...
+ ```
+
Args:
- path (str): The root directory to store downloaded and cache files.
- property_names (Union[str, List[str]]): The name(s) of the target property
- to predict. Must be selected from the list above. Defaults to 'lumo'.
- build_graph_cfg (Dict, optional): Configuration dictionary for building
- the graph representation from the molecular structure (e.g., cutoff radius).
- Defaults to None (structure is returned instead of graph).
- transforms (Optional[Callable], optional): A preprocessing function to apply
- to the sample dictionary. Defaults to None.
- cache_path (Optional[str], optional): Explicit path for the cache directory.
- Defaults to None.
- overwrite (bool, optional): If True, forces the rebuilding of caches.
- Defaults to False.
- filter_unvalid (bool, optional): Whether to filter out corrupted samples.
+ path (str, optional): The path of the dataset. If the path does not exist,
+ it will be downloaded. Defaults to "./data/qm9/train.csv".
+ property_names (Optional[list[str]], optional): Property names to use for
+ QM9. The property_names should be selected from
+ ["A", "B", "C", "mu", "alpha", "homo", "lumo", "gap", "r2",
+ "zpve", "U0", "U", "H", "G", "Cv"]. Defaults to None.
+ build_molecule_cfg (Dict, optional): Configs for building molecular
+ structures from xyz strings. If not specified, the default setting
+ will be used. Defaults to None.
+ build_graph_cfg (Dict, optional): Configs for building molecular graphs
+ from structures. Defaults to None.
+ transforms (Optional[Callable], optional): Preprocess transforms for each
+ sample. Defaults to None.
+ cache_path (Optional[str], optional): If a cache_path is set, parsed
+ molecules and graphs will be read directly from this path; if the
+ cache does not exist, the converted molecules and graphs will be
+ saved to this path. Defaults to None.
+ overwrite (bool, optional): Overwrite the existing cache file at the given
+ path if it already exists. Defaults to False.
+ filter_unvalid (bool, optional): Whether to filter out invalid samples.
Defaults to True.
"""
- url = "https://paddle-org.bj.bcebos.com/paddlematerials/datasets/qm9/dsgdb9nsd.xyz.tar.bz2"
name = "qm9"
- md5 = "AD1EBD51EE7F5B3A6E32E974E5D54012"
-
- # Official QM9 second-line property order (including tag/index)
- PROP_ORDER = [
- "tag", # textual tag / molecule identifier (often 'gdb ...')
- "index", # numeric index (maps to vals_float[0] after removing 'gdb')
- "A", # rotational constant A (GHz)
- "B", # rotational constant B (GHz)
- "C", # rotational constant C (GHz)
- "mu", # dipole moment (Debye)
- "alpha", # isotropic polarizability (Bohr^3)
- "homo", # HOMO energy (Hartree)
- "lumo", # LUMO energy (Hartree)
- "gap", # LUMO-HOMO gap (Hartree)
- "r2", # electronic spatial extent (Bohr^2)
- "zpve", # zero point vibrational energy (Hartree)
- "U0", # internal energy at 0K (Hartree)
- "U", # internal energy at 298.15 K (Hartree)
- "H", # enthalpy at 298.15 K (Hartree)
- "G", # free energy at 298.15 K (Hartree)
- "Cv", # heat capacity at 298.15 K (cal/mol/K)
- ]
+ url = "https://paddle-org.bj.bcebos.com/paddlematerials/datasets/qm9/qm9.zip"
+ md5 = "a70eb6cc913427db1fc1f8d3631fe00a"
def __init__(
self,
- path: str,
- url: Optional[str] = None,
- property_names: Union[str, List[str]] = None,
- *,
- url_indices: Optional[List[int]] = None,
+ path: str = "./data/qm9/train.csv",
+ property_names: Optional[list[str]] = None,
+ build_molecule_cfg: Dict = None,
build_graph_cfg: Dict = None,
transforms: Optional[Callable] = None,
cache_path: Optional[str] = None,
overwrite: bool = False,
filter_unvalid: bool = True,
**kwargs,
- ) -> None:
+ ):
super().__init__()
- # Use the ASE_AVAILABLE flag and AseAtomsAdaptor presence to validate dependencies
- if not ASE_AVAILABLE or AseAtomsAdaptor is None:
- raise RuntimeError(
- "QM9Dataset requires 'ase' and 'pymatgen'. "
- "Please install them via: pip install ase pymatgen"
- )
-
- if property_names is None:
- raise ValueError("property_names must be provided for QM9Dataset")
+ if not osp.exists(path):
+ logger.message("The dataset is not found. Will download it now.")
+ root_path = download.get_datasets_path_from_url(self.url, self.md5)
+ path = osp.join(root_path, self.name, osp.basename(path))
- if isinstance(property_names,str):
+ self.path = path
+ if isinstance(property_names, str):
property_names = [property_names]
- self.property_names = list(property_names) if property_names else []
-
- # Handle URLs configuration
- self.url = url if url is not None else self.url
+ self.property_names = property_names if property_names is not None else []
+
+ if build_molecule_cfg is None:
+ build_molecule_cfg = {
+ "format": "xyz_block",
+ "sanitize": False,
+ "add_hs": False,
+ "remove_hs": False,
+ "kekulize": False,
+ "num_cpus": 1,
+ }
+ logger.message(
+ "The build_molecule_cfg is not set, will use the default "
+ f"configs: {build_molecule_cfg}"
+ )
- #Path Configuration
- os.makedirs(path, exist_ok=True)
- self.raw_dir = osp.join(path, "raw_qm9")
- os.makedirs(self.raw_dir, exist_ok=True)
+ self.build_molecule_cfg = build_molecule_cfg
+ self.build_graph_cfg = build_graph_cfg
+ self.transforms = transforms
- self.raw_xyz_path = osp.join(self.raw_dir, "dsgdb9nsd.xyz")
-
- # Generate cache directory naming based on graph config
- if build_graph_cfg is not None:
- graph_converter_name = build_graph_cfg.get("__class_name__", "custom")
- cutoff_name = str(
- int(build_graph_cfg.get("__init_params__", {}).get("cutoff", 5))
- )
+ if cache_path is not None:
+ self.cache_path = cache_path
else:
- graph_converter_name = "none"
- cutoff_name = "none"
-
- base_cache = cache_path if cache_path is not None else path #determine the final path
- self.cache_path = osp.join(
- base_cache,
- f"qm9_cache_{graph_converter_name}_cutoff_{cutoff_name}",
- )
-
- self.transforms = transforms
+ self.cache_path = osp.join(
+ osp.split(path)[0] + "_cache", osp.splitext(osp.basename(path))[0]
+ )
+ logger.info(f"Cache path: {self.cache_path}")
+
self.overwrite = overwrite
self.filter_unvalid = filter_unvalid
- self.build_graph_cfg = build_graph_cfg
- # define sub-directories for cache
- self.structures_dir = osp.join(self.cache_path, "structures")
- self.graphs_dir = osp.join(self.cache_path, "graphs")
- self.props_dir = osp.join(self.cache_path, "properties")
-
- if dist.get_rank() == 0:
- logger.info(f"Cache path: {self.cache_path}")
- os.makedirs(self.structures_dir, exist_ok=True)
- os.makedirs(self.graphs_dir, exist_ok=True)
- os.makedirs(self.props_dir, exist_ok=True)
-
- # =========== data operation ==============
-
- # 1) Download and ensure shard files exist locally
- local_raw_file = self._ensure_raw_data()
-
- # 2) Check or build Structures and Properties cache
- if dist.get_rank() == 0:
- self._prepare_structures_and_properties(local_raw_file)
- # Only rank 0 performs the build process to avoid race conditions
-
- if dist.is_initialized():
- dist.barrier()
-
- # 3) Check or build Graphs cache (if configuration provided)
- if self.build_graph_cfg is not None:
+ self.cache_exists = True if osp.exists(self.cache_path) else False
+ self.raw_data, self.num_samples = self.read_data(path)
+ logger.info(f"Load {self.num_samples} samples from {path}")
+ self.property_data = self.read_property_data(self.raw_data, self.property_names)
+
+ if self.cache_exists and not overwrite:
+ logger.warning(
+ "Cache enabled. If a cache file exists, it will be automatically "
+ "read and current settings will be ignored. Please ensure that the "
+ "settings used in match your current settings."
+ )
+ try:
+ build_molecule_cfg_cache = self.load_from_cache(
+ osp.join(self.cache_path, "build_molecule_cfg.pkl")
+ )
+ if is_equal(build_molecule_cfg_cache, build_molecule_cfg):
+ logger.info(
+ "The cached build_molecule_cfg configuration matches "
+ "the current settings. Reusing previously generated"
+ " molecular data to optimize performance."
+ )
+ else:
+ logger.warning(
+ "build_molecule_cfg is different from "
+ "build_molecule_cfg_cache. Will rebuild the molecules and "
+ "graphs."
+ )
+ logger.warning(
+ "If you want to use the cached molecules and graphs, please "
+ "ensure that the settings used in match your current settings."
+ )
+ overwrite = True
+ except Exception as e:
+ logger.warning(e)
+ logger.warning(
+ "Failed to load builded_molecules_cfg.pkl from cache. "
+ "Will rebuild the molecules and graphs(if need)."
+ )
+ overwrite = True
+
+ if build_graph_cfg is not None and not overwrite:
+ try:
+ build_graph_cfg_cache = self.load_from_cache(
+ osp.join(self.cache_path, "build_graph_cfg.pkl")
+ )
+ if is_equal(build_graph_cfg_cache, build_graph_cfg):
+ logger.info(
+ "The cached build_molecule_cfg configuration "
+ "matches the current settings. Reusing previously "
+ "generated molecular data to optimize performance."
+ )
+ else:
+ logger.warning(
+ "build_graph_cfg is different from build_graph_cfg_cache"
+ ". Will rebuild the graphs."
+ )
+ logger.warning(
+ "If you want to use the cached molecules and graphs, "
+ "please ensure that the settings used in match your "
+ "current settings."
+ )
+ overwrite = True
+
+ except Exception as e:
+ logger.warning(e)
+ logger.warning(
+ "Failed to load builded_graph_cfg.pkl from cache. "
+ "Will rebuild the graphs."
+ )
+ overwrite = True
+
+ molecule_cache_path = osp.join(self.cache_path, "molecules")
+ graph_cache_path = osp.join(self.cache_path, "graphs")
+ if overwrite or not self.cache_exists:
if dist.get_rank() == 0:
- self._prepare_graphs()
+ os.makedirs(self.cache_path, exist_ok=True)
+ self.save_to_cache(
+ osp.join(self.cache_path, "build_molecule_cfg.pkl"),
+ build_molecule_cfg,
+ )
+ self.save_to_cache(
+ osp.join(self.cache_path, "build_graph_cfg.pkl"), build_graph_cfg
+ )
+
+ molecules = BuildMolecule(**build_molecule_cfg)(
+ self.raw_data["molecule"]
+ )
+ os.makedirs(molecule_cache_path, exist_ok=True)
+ for i in range(self.num_samples):
+ self.save_to_cache(
+ osp.join(molecule_cache_path, f"{i:010d}.pkl"), molecules[i]
+ )
+ logger.info(
+ f"Save {self.num_samples} molecules to {molecule_cache_path}"
+ )
+
+ if build_graph_cfg is not None:
+ converter = build_graph_converter(build_graph_cfg)
+ graphs = converter(molecules)
+ os.makedirs(graph_cache_path, exist_ok=True)
+ for i in range(self.num_samples):
+ self.save_to_cache(
+ osp.join(graph_cache_path, f"{i:010d}.pkl"), graphs[i]
+ )
+ logger.info(f"Save {self.num_samples} graphs to {graph_cache_path}")
+
if dist.is_initialized():
dist.barrier()
-
- PROPERTY_FILE_MAP = {
- "energy_per_atom": "lumo", # cheat the model of the way it gets the data
- }
- # 4) Load file lists and property data into memory
- self.structures = [
- osp.join(self.structures_dir, f)
- for f in sorted(os.listdir(self.structures_dir))
- if f.endswith(".pkl")
- ]
- if self.build_graph_cfg is not None:
+ self.molecules = [
+ osp.join(molecule_cache_path, f"{i:010d}.pkl")
+ for i in range(self.num_samples)
+ ]
+ if build_graph_cfg is not None:
self.graphs = [
- osp.join(self.graphs_dir, f)
- for f in sorted(os.listdir(self.graphs_dir))
- if f.endswith(".pkl")
+ osp.join(graph_cache_path, f"{i:010d}.pkl")
+ for i in range(self.num_samples)
]
else:
self.graphs = None
- logger.info(f"Loading properties {self.property_names} into memory...")
-
- self.property_data = {}
- for pname in self.property_names:
-
- # Determine the actual file name: use the mapping table if available; otherwise, use the configuration name itself
- file_name = PROPERTY_FILE_MAP.get(pname, pname)
-
- file_path = osp.join(self.props_dir, f"{file_name}.pkl")
-
- if not osp.exists(file_path):
- raise FileNotFoundError(
- f"[QM9 Map Error]can't find the file: {file_path}. "
- f"(require label: {pname}, actually find the file: {file_name}.pkl)"
- )
-
- # Although the name of file is lumo.pkl,The key stored in the dictionary is still pname (such as energy_per_atom)
- self.property_data[pname] = self._load_pickle(file_path)
-
- # self.property_data = {
- # pname: self._load_pickle(osp.join(self.props_dir, f"{pname}.pkl"))
- # for pname in self.property_names
- # }
-
-
- # Sort files to ensure consistency across distributed ranks
- # 5) Filter invalid data based on properties and graphs
- if self.filter_unvalid:
- self._filter_by_properties()
- if self.graphs is not None:
- self._filter_by_graphs()
- # 6) Ensure data length consistency across all arrays
- self._ensure_length_consistency()
-
- self.num_samples = len(self.structures)
- logger.info(f"Final QM9Dataset samples: {self.num_samples}")
-
-
- def _prepare_structures_and_properties(self, raw_file_path: str):
- """
- Check if structures and properties are cached; rebuild if missing
- or overwrite is True.
- """
-
- num_cached = self._count_files(self.structures_dir)
-
- # Check if all property files exist
- props_exist = all(
- osp.exists(osp.join(self.props_dir, f"{p}.pkl"))
- for p in self.property_names
- )
-
- # Use a completion flag to ensure the previous build was successful
- struct_done_flag = osp.join(self.structures_dir, "completed.flag")
- is_complete = osp.exists(struct_done_flag)
-
-
- should_build = (
- self.overwrite or num_cached == 0 or not props_exist or not is_complete
- )
-
- if should_build:
- if dist.get_rank() == 0:
- logger.info("Building structures and properties from raw QM9 file...")
-
- # Clean old data to prevent mixing files
- self._clean_dir(self.structures_dir)
- self._clean_dir(self.props_dir)
-
- self._build_structures_and_properties(
- raw_file_path, self.structures_dir, self.props_dir
- )
-
- # Write completion flag
- with open(struct_done_flag, "w") as f:
- f.write("done")
- else:
- logger.info(f"Using cached structures ({num_cached}) and properties.")
-
-
- def _prepare_graphs(self):
- """
- Check if graphs are cached; rebuild if missing, incomplete,
- or overwrite is True.
- """
- num_structs = self._count_files(self.structures_dir)
- num_graphs = self._count_files(self.graphs_dir)
-
- # Use a completion flag for graphs
- graph_done_flag = osp.join(self.graphs_dir, "completed.flag")
- is_complete = osp.exists(graph_done_flag)
-
- # Condition: Not overwrite, marked complete, and counts match
- if not self.overwrite and is_complete and num_graphs == num_structs:
- logger.info(f"Using cached graphs ({num_graphs}).")
- return
-
- logger.info(
- f"Rebuilding graphs. (Structs: {num_structs}, Graphs: {num_graphs}, "
- f"Complete: {is_complete}, Overwrite: {self.overwrite})"
- )
-
- self._clean_dir(self.graphs_dir)
- converter = build_graph_converter(self.build_graph_cfg)
- self._build_graphs(converter, self.structures_dir, self.graphs_dir)
-
- # Write completion flag
- with open(graph_done_flag, "w") as f:
- f.write("done")
-
- def _build_graphs(self, converter, structures_dir: str, graphs_dir: str) -> None:
- """
- Builds graph objects from structures using a SINGLE global progress bar.
-
- This method processes structures in batches to manage memory usage, while
- providing a unified progress visualization.
- """
- import gc
- import sys
-
- # Context manager to temporarily suppress stderr
- class SuppressStderr:
- def __init__(self):
- self.null_fds = [os.open(os.devnull, os.O_RDWR)]
- self.save_fds = [os.dup(2)] # Backup stderr (fd 2)
-
- def __enter__(self):
- # Redirect stderr to devnull
- os.dup2(self.null_fds[0], 2)
-
- def __exit__(self, *_):
- # Restore stderr
- os.dup2(self.save_fds[0], 2)
- for fd in self.null_fds + self.save_fds:
- os.close(fd)
-
- # Get file list
- files = sorted([f for f in os.listdir(structures_dir) if f.endswith(".pkl")])
- total = len(files)
- if total == 0:
- logger.warning("No structures found to convert!")
- return
-
- batch_size = 2000 # Define batch size
-
- logger.info(f"Converting {total} structures to graphs...")
-
- # 1. Create global progress bar
- pbar = tqdm(total=total, desc="Graph Conversion", unit="sample")
-
- for start_idx in range(0, total, batch_size):
- end_idx = min(start_idx + batch_size, total)
- batch_files = files[start_idx:end_idx]
-
- try:
- # Load structures for current batch
- structures = [
- self._load_pickle(osp.join(structures_dir, f)) for f in batch_files
+ assert (
+ len(self.molecules) == self.num_samples
+ ), "The number of molecules must be equal to the number of samples."
+ assert (
+ self.graphs is None or len(self.graphs) == self.num_samples
+ ), "The number of graphs must be equal to the number of samples."
+
+ if filter_unvalid:
+ self.filter_unvalid_by_property()
+
+ def read_data(self, path: str):
+ """Read the data from the given csv path."""
+ data = pd.read_csv(path)
+ logger.info(f"Read {len(data)} molecules from {path}")
+
+ data = {key: data[key].tolist() for key in data if "Unnamed" not in key}
+ data["molecule"] = data.pop("standard_xyz") # adapted for this qm9 split file
+ data["id"] = data["molecule_id"] # adapted for this qm9 split file
+ num_samples = 0
+ for key in data:
+ num_samples = max(num_samples, len(data[key]))
+ return data, num_samples
+
+ def filter_unvalid_by_property(self):
+ for property_name in self.property_names:
+ data = self.property_data[property_name]
+ reserve_idx = []
+ for i, data_item in enumerate(data):
+ if isinstance(data_item, str) or (
+ data_item is not None and not math.isnan(data_item)
+ ):
+ reserve_idx.append(i)
+ for key in self.property_data.keys():
+ self.property_data[key] = [
+ self.property_data[key][i] for i in reserve_idx
]
+ for key in self.raw_data.keys():
+ self.raw_data[key] = [self.raw_data[key][i] for i in reserve_idx]
+ self.molecules = [self.molecules[i] for i in reserve_idx]
+ if self.graphs is not None:
+ self.graphs = [self.graphs[i] for i in reserve_idx]
+ logger.warning(
+ f"Filter out {len(reserve_idx)} samples with valid properties: "
+ f"{property_name}"
+ )
+ self.num_samples = len(self.molecules)
+ logger.warning(f"Remaining {self.num_samples} samples after filtering.")
- # 2. Convert to graphs (Suppress internal progress bars if any)
- try:
- with SuppressStderr():
- graphs = converter(structures)
- except Exception:
- # Fallback if low-level FD manipulation fails
- graphs = converter(structures)
-
- # Save graphs
- for f, g in zip(batch_files, graphs):
- self._save_pickle(osp.join(graphs_dir, f), g)
+ def read_property_data(self, data: Dict, property_names: list[str]):
+ property_data = {
+ property_name: data[property_name] for property_name in property_names
+ }
+ return property_data
- # 3. Update global progress bar
- pbar.update(len(batch_files))
+ def save_to_cache(self, cache_path: str, data: Any):
+ with open(cache_path, "wb") as f:
+ pickle.dump(data, f)
- except Exception as e:
- # Restore stderr to print error
- sys.stderr = sys.__stderr__
- logger.warning(f"Batch {start_idx}-{end_idx} failed: {e}")
-
- finally:
- if "structures" in locals():
- del structures
- if "graphs" in locals():
- del graphs
- gc.collect()
-
- pbar.close()
- logger.info("Graph conversion completed.")
-
- def _ensure_length_consistency(self):
- """
- Ensures consistency in length across structures, graphs, and all
- property arrays. Truncates data to the minimum length found.
- """
- lengths = [len(self.structures)]
- if self.graphs is not None:
- lengths.append(len(self.graphs))
- for p in self.property_names:
- lengths.append(len(self.property_data[p]))
-
- min_len = min(lengths)
-
- if any(length != min_len for length in lengths):
- logger.warning(
- f"Data length mismatch detected (lengths={lengths}). "
- f"Truncating to minimum length: {min_len}."
- )
- self.structures = self.structures[:min_len]
- if self.graphs is not None:
- self.graphs = self.graphs[:min_len]
- for p in self.property_names:
- self.property_data[p] = self.property_data[p][:min_len]
+ def load_from_cache(self, cache_path: str):
+ if osp.exists(cache_path):
+ with open(cache_path, "rb") as f:
+ data = pickle.load(f)
+ return data
+ raise FileNotFoundError(f"No such file or directory: {cache_path}")
+ def __getitem__(self, idx: int):
+ data = {}
- def _clean_dir(self, directory: str):
- """Cleans a directory by removing all .pkl and .flag files."""
- for f in os.listdir(directory):
- if f.endswith(".pkl") or f.endswith(".flag"):
- try:
- os.remove(osp.join(directory, f))
- except OSError:
- pass
-
-
- def _ensure_raw_data(self) -> str:
- """
- downloading self.url -> self.raw_xyz_path
- """
- # 1. if the final file exists , return.
- if osp.exists(self.raw_xyz_path):
- return self.raw_xyz_path
-
- # 2. prepare the path to download
- tar_filename = "qm9_raw.tar.bz2"
- tar_path = osp.join(self.raw_dir, tar_filename)
-
- # 3. downloading logic
- if not osp.exists(tar_path):
- if dist.get_rank() == 0:
- logger.info(f"Downloading QM9 from {self.url}...")
- import urllib.request
- try:
- urllib.request.urlretrieve(self.url, tar_path)
- except Exception as e:
- raise RuntimeError(f"Download failed: {e}")
- if dist.is_initialized():
- dist.barrier()
-
- # 4. extacting logic
- if dist.get_rank() == 0:
- logger.info("Extracting QM9...")
- import tarfile
- try:
- with tarfile.open(tar_path, "r:bz2") as tar:
- tar.extractall(path=self.raw_dir)
- except Exception as e:
- raise RuntimeError(f"Extraction failed: {e}")
-
- if dist.is_initialized():
- dist.barrier()
-
- # 5. final check
- # Case A:single file exists.
- if osp.exists(self.raw_xyz_path):
- return self.raw_xyz_path
-
-
- # Case B:merge these .xyz files into a big file.
- xyz_files = [f for f in os.listdir(self.raw_dir) if f.endswith(".xyz") and f != "dsgdb9nsd.xyz"]
- if len(xyz_files) > 0:
- logger.info(f"Found {len(xyz_files)} xyz files, merging into dsgdb9nsd.xyz...")
- merged_path = self.raw_xyz_path
-
- if osp.exists(merged_path):
- os.remove(merged_path)
-
- with open(merged_path, "w") as fout: # use "w" model to rewrite
- for fname in tqdm(sorted(xyz_files), desc="Merging XYZ files"):
- full_path = osp.join(self.raw_dir, fname)
- try:
- with open(full_path, "r") as fin:
- lines = fin.readlines()
-
- if not lines: continue
- natoms = int(lines[0].strip())
-
- # 1. Number of atoms written
- fout.write(f"{natoms}\n")
- # 2. Write attribute line
- prop_line = lines[1].replace('*^', 'e').replace('\t', ' ')
- fout.write(prop_line)
- # 3. Write coordinate lines (only take natoms lines)
- for i in range(2, 2 + natoms):
- coord_line = lines[i].replace('*^', 'e').replace('\t', ' ')
- fout.write(coord_line)
-
- except Exception as e:
- logger.warning(f"Error processing {fname}: {e}")
- continue
- return merged_path
- # Case C: None
- raise RuntimeError(
- f"Decompression is complete, but I couldn't find dsgdb9nsd.xyz or any .xyz files under {self.raw_dir}!"
- "Please check what files are actually included in the downloaded compressed package."
- )
-
- def _count_files(self, directory: str) -> int:
- """Counts the number of .pkl files in a directory."""
- try:
- return len([n for n in os.listdir(directory) if n.endswith(".pkl")])
- except Exception:
- return 0
-
- @staticmethod
- def _save_pickle(path: str, obj: Any) -> None:
- with open(path, "wb") as f:
- pickle.dump(obj, f)
-
- @staticmethod
- def _load_pickle(path: str) -> Any:
- with open(path, "rb") as f:
- return pickle.load(f)
-
- def _build_structures_and_properties(self, raw_path: str, struct_dir: str, prop_dir: str) -> None:
- """
- Core Constructor Function:analyse XYZ -> Pymatgen Structure -> Pickle
- """
- logger.info(f"Parsing {raw_path} using ASE...")
-
- # 1. Read all data into memory (QM9 is about 100MB, which can easily fit into memory)
- atoms_collection = read(raw_path, index=':')
-
- # 2. Read text lines to parse attributes (ASE attribute parsing is sometimes unreliable; manual parsing is more stable)
- with open(raw_path, 'r') as f:
- lines = f.readlines()
-
- prop_buffers = defaultdict(list)
- current_line = 0
- valid_count = 0
-
- total = len(atoms_collection)
- pbar = tqdm(total=total, desc="Processing QM9")
-
- for i, atoms in enumerate(atoms_collection):
- try:
- num_atoms = len(atoms)
- prop_line = lines[current_line + 1]
-
- # clean the property line.
- prop_line_cleaned = prop_line.replace('*^', 'e').replace('\t', ' ')
- raw_vals = prop_line_cleaned.split()
-
- vals_float = []
- for val_str in raw_vals:
- try:
- vals_float.append(float(val_str))
- except ValueError:
- # for strings like 'gdb'
- vals_float.append(0.0)
-
- # Mapping Attribute
- for k, key in enumerate(self.PROP_ORDER):
- if key in ['tag', 'index']:
- continue
-
- # Alignment index: k=2 is 'A', corresponding to raw_vals[2]
- if k < len(vals_float):
- prop_buffers[key].append(vals_float[k])
- else:
- prop_buffers[key].append(np.nan)
-
- # --- B. building Structure (Fake crystal cell) ---
- # Set up a large box to prevent the model from reporting errors due to the absence of cells
- atoms.set_cell([20.0, 20.0, 20.0])
- atoms.center()
- atoms.pbc = True
- structure = AseAtomsAdaptor.get_structure(atoms)
-
- # --- C. save the struncture ---
- self._save_pickle(osp.join(struct_dir, f"{i:06d}.pkl"), structure)
-
- # Update pointer
- current_line += (num_atoms + 2)
- valid_count += 1
- pbar.update(1)
-
- except Exception as e:
- logger.warning(f"Error processing molecule {i}: {e}. Skipping block.")
- current_line += (len(atoms) + 2)
- continue
-
- pbar.close()
-
- if valid_count == 0:
- raise RuntimeError("No valid samples processed from QM9 file!")
-
- # --- D.Save attribute array ---
- logger.info("Saving property arrays...")
- for key, val_list in prop_buffers.items():
- self._save_pickle(
- osp.join(prop_dir, f"{key}.pkl"),
- np.array(val_list, dtype=np.float32)
+ if self.graphs is None:
+ raise ValueError(
+ "QM9Dataset requires build_graph_cfg to return model-ready samples."
)
-
- def _filter_by_properties(self) -> None:
- """
- Filter out samples that contain invalid property values (e.g., NaN, Inf).
- Operation is performed in-memory.
- """
- if not self.property_names:
- return
-
- total = len(self.structures)
- keep = []
-
- # Check properties for each sample
- for i in range(total):
- is_valid = True
- for pname in self.property_names:
- val = self.property_data[pname][i]
- if val is None:
- is_valid = False
- break
-
- # Check for NaN/Inf in scalars
- if isinstance(val, (float, int, np.floating, np.integer)):
- if np.isnan(val) or np.isinf(val):
- is_valid = False
- break
- # Check for NaN/Inf in arrays/lists
- elif isinstance(val, (list, np.ndarray)):
- arr = np.asarray(val)
- if not np.all(np.isfinite(arr)):
- is_valid = False
- break
-
- if is_valid:
- keep.append(i)
-
- if len(keep) < total:
- logger.warning(
- f"Filtering: Dropping {total - len(keep)} samples "
- "due to invalid properties."
- )
- self.structures = [self.structures[i] for i in keep]
- if self.graphs:
- self.graphs = [self.graphs[i] for i in keep]
- # stay property_data 为 numpy.ndarray(Indexing with numpy)
- for pname in self.property_names:
- arr = self.property_data[pname]
- # arr may already be numpy array; this keeps dtype and shape consistent
- self.property_data[pname] = arr[keep]
-
- def _filter_by_graphs(self) -> None:
- """
- Filter out samples with invalid or missing graphs.
- Since graphs are rebuilt fully if mismatch occurs, this is mostly a
- sanity check.
- """
- pass
-
- def __len__(self) -> int:
- return self.num_samples
+ graph = self.graphs[idx]
+ if isinstance(graph, str):
+ graph = self.load_from_cache(graph)
+ data["graph"] = graph
- def __getitem__(self, idx: int) -> Dict[str, Any]:
- data = {}
- # 1. loading the info
- if self.graphs is not None:
- data["graph"] = self._load_pickle(self.graphs[idx])
- else:
- struct = self._load_pickle(self.structures[idx])
- # turn into dictionary format
- data["pos"] = np.array(struct.cart_coords, dtype='float32')
- data["atomic_numbers"] = np.array([s.specie.Z for s in struct], dtype='int64')
- data["cell"] = np.array(struct.lattice.matrix, dtype='float32')
- data["natoms"] = len(struct)
- data["pbc"] = np.array([True, True, True], dtype=bool)
-
- # 2. loading the properties
- for pname in self.property_names:
- val = self.property_data[pname][idx]
- # data[pname] = np.array([val], dtype='float32')
- if pname == 'lumo':
- data['energy_per_atom'] = np.array([val], dtype='float32')
+ for property_name in self.property_names:
+ if property_name in self.property_data:
+ data[property_name] = np.array(
+ [self.property_data[property_name][idx]], dtype=np.float32
+ )
else:
- data[pname] = np.array([val], dtype='float32')
-
- # 3. data transforms
- if self.transforms is not None:
- data = self.transforms(data)
-
- return data
\ No newline at end of file
+ raise KeyError(f"Property {property_name} not found.")
+ data["id"] = self.raw_data["id"][idx]
+ data = self.transforms(data) if self.transforms is not None else data
+
+ return data
+
+ def __len__(self):
+ return self.num_samples
diff --git a/ppmat/datasets/script_split_qm9.py b/ppmat/datasets/script_split_qm9.py
new file mode 100644
index 00000000..b3fa6ab3
--- /dev/null
+++ b/ppmat/datasets/script_split_qm9.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Export QM9 into train.csv, val.csv, and test.csv.
+
+Output rows are one molecule each. The split policy matches the earlier export:
+remove Figshare's 3,054 uncharacterized molecule IDs, then shuffle the remaining
+130,831 molecule IDs with seed 42 and split as 110000/10000/10831.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import random
+import re
+import urllib.request
+from pathlib import Path
+
+
+UNCHARACTERIZED_URL = "https://ndownloader.figshare.com/files/3195404"
+
+PROPERTY_COLUMNS = [
+ "A",
+ "B",
+ "C",
+ "mu",
+ "alpha",
+ "homo",
+ "lumo",
+ "gap",
+ "r2",
+ "zpve",
+ "U0",
+ "U",
+ "H",
+ "G",
+ "Cv",
+]
+
+HEADER = [
+ "file_name",
+ "raw_file_content",
+ "standard_xyz",
+ "mulliken_xyz",
+ "molecule_id",
+ "num_atoms",
+ *PROPERTY_COLUMNS,
+ "vibrational_frequencies",
+ "canonical_smiles",
+ "isomeric_smiles",
+ "canonical_inchi",
+ "isomeric_inchi",
+]
+
+
+def natural_key(path: Path) -> int:
+ match = re.search(r"(\d+)$", path.stem)
+ if not match:
+ raise ValueError(f"Cannot infer molecule id from {path.name}")
+ return int(match.group(1))
+
+
+def parse_float_text(value: str) -> str:
+ """Normalize rare Mathematica-style exponents while keeping CSV text stable."""
+ return str(float(value.replace("*^", "e")))
+
+
+def parse_xyz_coord_text(value: str) -> str:
+ """Format XYZ coordinates as plain decimals for RDKit compatibility."""
+ return f"{float(value.replace('*^', 'e')):.10f}"
+
+
+def parse_charge_text(value: str) -> str:
+ """Format Mulliken partial charges as plain decimals."""
+ return f"{float(value.replace('*^', 'e')):.10f}"
+
+
+def ensure_uncharacterized(path: Path) -> Path:
+ if not path.exists() or path.stat().st_size == 0:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ urllib.request.urlretrieve(UNCHARACTERIZED_URL, path)
+ return path
+
+
+def load_bad_ids(path: Path) -> set[int]:
+ bad = set()
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+ parts = line.split()
+ if parts and parts[0].isdigit():
+ bad.add(int(parts[0]))
+ if len(bad) != 3054:
+ raise ValueError(f"Expected 3054 uncharacterized ids, got {len(bad)}")
+ return bad
+
+
+def split_ids(ids: list[int], seed: int) -> dict[int, str]:
+ shuffled = ids[:]
+ random.Random(seed).shuffle(shuffled)
+ train = set(shuffled[:110_000])
+ val = set(shuffled[110_000:120_000])
+ return {
+ molecule_id: "train"
+ if molecule_id in train
+ else "val"
+ if molecule_id in val
+ else "test"
+ for molecule_id in ids
+ }
+
+
+def parse_qm9_file(path: Path) -> dict[str, object]:
+ raw = path.read_text(encoding="utf-8", errors="replace")
+ lines = raw.splitlines()
+ num_atoms = int(lines[0].strip())
+
+ props = lines[1].split()
+ if len(props) < 17:
+ raise ValueError(f"Malformed property line in {path.name}")
+ molecule_id = int(props[1])
+ if molecule_id != natural_key(path):
+ raise ValueError(f"{path.name} contains molecule id {molecule_id}")
+
+ property_values = dict(zip(PROPERTY_COLUMNS, [parse_float_text(x) for x in props[2:17]]))
+ comment_line = f"gdb {molecule_id}"
+
+ standard_atom_lines = []
+ mulliken_atom_lines = []
+ for line in lines[2 : 2 + num_atoms]:
+ parts = line.split()
+ if len(parts) != 5:
+ raise ValueError(f"Malformed atom line in {path.name}: {line!r}")
+ element = parts[0]
+ x, y, z = [parse_xyz_coord_text(x) for x in parts[1:4]]
+ charge = parse_charge_text(parts[4])
+ standard_atom_lines.append(f"{element}\t{x}\t{y}\t{z}")
+ mulliken_atom_lines.append(f"{element}\t{x}\t{y}\t{z}\t{charge}")
+
+ standard_xyz = "\n".join([str(num_atoms), comment_line, *standard_atom_lines])
+ mulliken_xyz = "\n".join([str(num_atoms), comment_line, *mulliken_atom_lines])
+
+ freq_idx = 2 + num_atoms
+ frequencies = " ".join(parse_float_text(x) for x in lines[freq_idx].split())
+ smiles = lines[freq_idx + 1].split()
+ inchi = lines[freq_idx + 2].split()
+
+ return {
+ "file_name": path.name,
+ "raw_file_content": raw,
+ "standard_xyz": standard_xyz,
+ "mulliken_xyz": mulliken_xyz,
+ "molecule_id": molecule_id,
+ "num_atoms": num_atoms,
+ **property_values,
+ "vibrational_frequencies": frequencies,
+ "canonical_smiles": smiles[0] if len(smiles) > 0 else "",
+ "isomeric_smiles": smiles[1] if len(smiles) > 1 else "",
+ "canonical_inchi": inchi[0] if len(inchi) > 0 else "",
+ "isomeric_inchi": inchi[1] if len(inchi) > 1 else "",
+ }
+
+
+def export(input_dir: Path, output_dir: Path, uncharacterized: Path, seed: int) -> dict[str, int]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ bad_ids = load_bad_ids(ensure_uncharacterized(uncharacterized))
+ files = sorted(input_dir.glob("*.xyz"), key=natural_key)
+ ids = [natural_key(path) for path in files if natural_key(path) not in bad_ids]
+ split_lookup = split_ids(ids, seed)
+
+ handles = {}
+ writers = {}
+ counts = {"train": 0, "val": 0, "test": 0}
+ try:
+ for split in ["train", "val", "test"]:
+ handle = (output_dir / f"{split}.csv").open("w", newline="", encoding="utf-8")
+ writer = csv.DictWriter(handle, fieldnames=HEADER)
+ writer.writeheader()
+ handles[split] = handle
+ writers[split] = writer
+
+ for path in files:
+ molecule_id = natural_key(path)
+ split = split_lookup.get(molecule_id)
+ if split is None:
+ continue
+ writers[split].writerow(parse_qm9_file(path))
+ counts[split] += 1
+ finally:
+ for handle in handles.values():
+ handle.close()
+ return counts
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--input-dir", type=Path, default=Path("./qm9.xyz"))
+ parser.add_argument("--output-dir", type=Path, default=Path("./qm9_split"))
+ parser.add_argument(
+ "--uncharacterized",
+ type=Path,
+ default=Path("./qm9_split_scripts/uncharacterized.txt"),
+ )
+ parser.add_argument("--seed", type=int, default=42)
+ args = parser.parse_args()
+ counts = export(args.input_dir, args.output_dir, args.uncharacterized, args.seed)
+ print(counts)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ppmat/datasets/split_gmtnet_dataset.py b/ppmat/datasets/split_gmtnet_dataset.py
new file mode 100644
index 00000000..242c75ad
--- /dev/null
+++ b/ppmat/datasets/split_gmtnet_dataset.py
@@ -0,0 +1,243 @@
+"""Generate or verify the fixed-seed GMTNet dielectric split.
+
+This offline utility only manages the canonical index split. It neither
+downloads nor converts datasets, and it never overwrites a split unless
+``--force`` is supplied explicitly.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+
+EXPECTED_SOURCE_SHA256 = (
+ "5a2198f51f4a7f9aa26fa6be60ed65db0ecc0a13646d63399e897b8168dcbb4d"
+)
+EXPECTED_NORMALIZED_SHA256 = (
+ "eb0b9516c937575afe3f20a0f88953724abfcde6de07c4af15248468598b349f"
+)
+EXPECTED_RECORD_COUNT = 4713
+EXPECTED_SPLIT_SEED = 32
+EXPECTED_SPLIT_SIZES = {"train": 3770, "val": 471, "test": 472}
+_SPLIT_NAMES = tuple(EXPECTED_SPLIT_SIZES)
+
+
+def _split_indices_sha256(indices: list[int]) -> str:
+ """Return the canonical hash for one ordered index list."""
+ encoded = json.dumps(indices, separators=(",", ":"), ensure_ascii=True).encode(
+ "utf-8"
+ )
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _validate_indices(indices: Any, split_name: str) -> list[int]:
+ """Validate one fixed-size partition without changing its order."""
+ expected_size = EXPECTED_SPLIT_SIZES[split_name]
+ if not isinstance(indices, list) or len(indices) != expected_size:
+ raise ValueError(f"{split_name}_indices length must be {expected_size}.")
+ if any(type(index) is not int for index in indices):
+ raise ValueError(f"{split_name}_indices must contain JSON integers only.")
+ if any(index < 0 or index >= EXPECTED_RECORD_COUNT for index in indices):
+ raise ValueError(f"{split_name}_indices contains an out-of-range index.")
+ if len(indices) != len(set(indices)):
+ raise ValueError(f"{split_name}_indices contains duplicate indices.")
+ return indices
+
+
+def validate_split_data(split_data: Any) -> dict[str, list[int]]:
+ """Validate the canonical GMTNet split schema and ordered partitions."""
+ required_fields = {
+ "schema_version",
+ "source_original_dataset_sha256",
+ "normalized_schema_version",
+ "normalized_dataset_sha256",
+ "num_records",
+ "seed",
+ "generation_method",
+ "torch_version",
+ "split_sizes",
+ "split_indices_sha256",
+ "train_indices",
+ "val_indices",
+ "test_indices",
+ }
+ if not isinstance(split_data, dict) or set(split_data) != required_fields:
+ raise ValueError("Split JSON fields do not match the canonical schema.")
+ expected_values = {
+ "schema_version": 1,
+ "source_original_dataset_sha256": EXPECTED_SOURCE_SHA256,
+ "normalized_schema_version": 1,
+ "normalized_dataset_sha256": EXPECTED_NORMALIZED_SHA256,
+ "num_records": EXPECTED_RECORD_COUNT,
+ "seed": EXPECTED_SPLIT_SEED,
+ "generation_method": "torch.utils.data.random_split",
+ "split_sizes": EXPECTED_SPLIT_SIZES,
+ }
+ for field_name, expected_value in expected_values.items():
+ if split_data[field_name] != expected_value:
+ raise ValueError(f"Split JSON {field_name} is invalid.")
+ if not isinstance(split_data["torch_version"], str) or not split_data["torch_version"]:
+ raise ValueError("Split JSON torch_version is invalid.")
+
+ split_indices = {
+ split_name: _validate_indices(
+ split_data[f"{split_name}_indices"], split_name
+ )
+ for split_name in _SPLIT_NAMES
+ }
+ all_indices = [index for indices in split_indices.values() for index in indices]
+ if len(all_indices) != EXPECTED_RECORD_COUNT or len(set(all_indices)) != len(
+ all_indices
+ ):
+ raise ValueError("Split partitions must be disjoint and cover every record.")
+ if set(all_indices) != set(range(EXPECTED_RECORD_COUNT)):
+ raise ValueError("Split partitions do not cover the complete dataset.")
+ if split_indices["test"][:3] != [747, 1423, 1322]:
+ raise ValueError("Split JSON test_indices fixed prefix is invalid.")
+
+ hashes = split_data["split_indices_sha256"]
+ if not isinstance(hashes, dict) or set(hashes) != set(_SPLIT_NAMES):
+ raise ValueError("Split JSON split_indices_sha256 fields are invalid.")
+ for split_name, indices in split_indices.items():
+ if hashes[split_name] != _split_indices_sha256(indices):
+ raise ValueError(f"Split JSON {split_name}_indices SHA256 is invalid.")
+ return split_indices
+
+
+def load_and_validate_split(split_path: str | Path) -> dict[str, list[int]]:
+ """Load and validate a split JSON file."""
+ path = Path(split_path).expanduser().resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"split_path must be a regular file: {path}")
+ with path.open("r", encoding="utf-8") as handle:
+ return validate_split_data(json.load(handle))
+
+
+def _require_torch_for_generation():
+ try:
+ import torch
+ except ModuleNotFoundError as error:
+ raise RuntimeError(
+ "Generating the historical random_split order requires PyTorch. "
+ "Use --verify to validate an existing split without PyTorch."
+ ) from error
+ return torch
+
+
+def generate_split(seed: int = EXPECTED_SPLIT_SEED) -> dict[str, Any]:
+ """Reproduce the official PyTorch ``random_split`` index order."""
+ if seed != EXPECTED_SPLIT_SEED:
+ raise ValueError(f"seed must be {EXPECTED_SPLIT_SEED}.")
+ torch = _require_torch_for_generation()
+ generator = torch.Generator()
+ generator.manual_seed(seed)
+ train_subset, val_subset, test_subset = torch.utils.data.random_split(
+ range(EXPECTED_RECORD_COUNT),
+ list(EXPECTED_SPLIT_SIZES.values()),
+ generator=generator,
+ )
+ split_indices = {
+ "train": list(train_subset.indices),
+ "val": list(val_subset.indices),
+ "test": list(test_subset.indices),
+ }
+ split_data = {
+ "schema_version": 1,
+ "source_original_dataset_sha256": EXPECTED_SOURCE_SHA256,
+ "normalized_schema_version": 1,
+ "normalized_dataset_sha256": EXPECTED_NORMALIZED_SHA256,
+ "num_records": EXPECTED_RECORD_COUNT,
+ "seed": seed,
+ "generation_method": "torch.utils.data.random_split",
+ "torch_version": str(torch.__version__),
+ "split_sizes": EXPECTED_SPLIT_SIZES,
+ "split_indices_sha256": {
+ split_name: _split_indices_sha256(indices)
+ for split_name, indices in split_indices.items()
+ },
+ "train_indices": split_indices["train"],
+ "val_indices": split_indices["val"],
+ "test_indices": split_indices["test"],
+ }
+ validate_split_data(split_data)
+ return split_data
+
+
+def _write_split(split_data: dict[str, Any], output_path: Path, force: bool) -> None:
+ output_path = output_path.expanduser().resolve()
+ if output_path.exists() and not force:
+ raise FileExistsError(
+ f"Refusing to overwrite existing split JSON: {output_path}. Use --force."
+ )
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{output_path.name}.", suffix=".tmp", dir=output_path.parent
+ )
+ temporary_path = Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ json.dump(split_data, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary_path, output_path)
+ except Exception:
+ temporary_path.unlink(missing_ok=True)
+ raise
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Generate or verify the fixed-seed GMTNet dielectric split."
+ )
+ action = parser.add_mutually_exclusive_group(required=True)
+ action.add_argument("--verify", metavar="SPLIT_JSON", help="Validate a split JSON.")
+ action.add_argument("--output", metavar="SPLIT_JSON", help="Write a generated split JSON.")
+ parser.add_argument("--seed", type=int, default=EXPECTED_SPLIT_SEED)
+ parser.add_argument(
+ "--record-count",
+ type=int,
+ default=EXPECTED_RECORD_COUNT,
+ help=f"Must be {EXPECTED_RECORD_COUNT}; validates the source record count.",
+ )
+ parser.add_argument(
+ "--force", action="store_true", help="Allow overwriting --output."
+ )
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run the split CLI."""
+ args = _build_parser().parse_args(argv)
+ try:
+ if args.record_count != EXPECTED_RECORD_COUNT:
+ raise ValueError(
+ f"record_count must be {EXPECTED_RECORD_COUNT}; "
+ "the source dataset is incomplete or incompatible."
+ )
+ if args.verify:
+ split_indices = load_and_validate_split(args.verify)
+ print(
+ json.dumps(
+ {"status": "ok", "sizes": {key: len(value) for key, value in split_indices.items()}},
+ sort_keys=True,
+ )
+ )
+ else:
+ _write_split(generate_split(args.seed), Path(args.output), args.force)
+ print(json.dumps({"status": "ok", "output": str(Path(args.output).resolve())}))
+ except Exception as error:
+ print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/ppmat/models/__init__.py b/ppmat/models/__init__.py
index d258bd2a..99f10251 100644
--- a/ppmat/models/__init__.py
+++ b/ppmat/models/__init__.py
@@ -29,24 +29,29 @@
from ppmat.models.common.graph_converter import CrystalNN
from ppmat.models.common.graph_converter import FindPointsInSpheres
from ppmat.models.common.graph_converter import MolecularGraphConverter
+from ppmat.models.common.graph_converter import RadiusGraphConverter
from ppmat.models.diffcsp.diffcsp import DiffCSP
from ppmat.models.diffnmr.diffnmr import DiffNMR
from ppmat.models.diffnmr.diffnmr import DiffPrior
from ppmat.models.diffnmr.diffnmr import MolecularGraphFormer
from ppmat.models.diffnmr.diffnmr import NMRNetCLIP
from ppmat.models.dimenetpp.dimenetpp import DimeNetPlusPlus
+from ppmat.models.gmtnet.gmtnet import GMTNet
+from ppmat.models.gmtnet.gmtnet_graph_converter import GMTNetGraphConverter
+from ppmat.models.infgcn.infgcn import InfGCN
+from ppmat.models.mateno.mateno import MatENO
from ppmat.models.mattergen.mattergen import MatterGen
from ppmat.models.mattergen.mattergen import MatterGenWithCondition
from ppmat.models.mattersim.m3gnet import M3GNet
from ppmat.models.mattersim.m3gnet_graph_converter import M3GNetGraphConvertor
from ppmat.models.megnet.megnet import MEGNetPlus
-from ppmat.models.infgcn.infgcn import InfGCN
-from ppmat.models.mateno.mateno import MatENO
from ppmat.models.sfin.sfin import SFIN
+from ppmat.models.spherenet.spherenet import SphereNet
from ppmat.utils import download
from ppmat.utils import logger
from ppmat.utils import save_load
+
__all__ = [
"iComformer",
"ComformerGraphConverter",
@@ -56,12 +61,15 @@
"MatterGen",
"MatterGenWithCondition",
"DimeNetPlusPlus",
+ "GMTNet",
+ "GMTNetGraphConverter",
"CrystalNN",
"CHGNetGraphConverter",
"CHGNet",
"M3GNetGraphConvertor",
"M3GNet",
"MolecularGraphConverter",
+ "RadiusGraphConverter",
"MolecularGraphFormer",
"NMRNetCLIP",
"DiffPrior",
@@ -69,6 +77,7 @@
"InfGCN",
"MatENO",
"SFIN",
+ "SphereNet",
]
# Warning: The key of the dictionary must be consistent with the file name of the value
@@ -117,6 +126,26 @@
"sfin_haadf_detect": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/spectrum_enhancement/sfin/sfin_haadf_detect.zip",
"sfin_bf_enhance": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/spectrum_enhancement/sfin/sfin_bf_enhance.zip",
"sfin_bf_detect": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/spectrum_enhancement/sfin/sfin_bf_detect.zip",
+ "spherenet_qm9_mu": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_mu.zip",
+ "spherenet_qm9_alpha": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_alpha.zip",
+ "spherenet_qm9_homo": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_homo.zip",
+ "spherenet_qm9_lumo": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_lumo.zip",
+ "spherenet_qm9_gap": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_gap.zip",
+ "spherenet_qm9_r2": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_r2.zip",
+ "spherenet_qm9_zpve": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_zpve.zip",
+ "spherenet_qm9_U0": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_U0.zip",
+ "spherenet_qm9_U": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_U.zip",
+ "spherenet_qm9_H": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_H.zip",
+ "spherenet_qm9_G": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_G.zip",
+ "spherenet_qm9_Cv": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/property_prediction/spherenet/spherenet_qm9_Cv.zip",
+ "spherenet_md17_aspirin": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_aspirin.zip",
+ "spherenet_md17_benzene_old": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_benzene_old.zip",
+ "spherenet_md17_ethanol": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_ethanol.zip",
+ "spherenet_md17_malonaldehyde": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_malonaldehyde.zip",
+ "spherenet_md17_naphthalene": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_naphthalene.zip",
+ "spherenet_md17_salicylic": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_salicylic.zip",
+ "spherenet_md17_toluene": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_toluene.zip",
+ "spherenet_md17_uracil": "https://paddle-org.bj.bcebos.com/paddlematerials/checkpoints/interatomic_potentials/spherenet/spherenet_md17_uracil.zip",
}
diff --git a/ppmat/models/common/graph_converter.py b/ppmat/models/common/graph_converter.py
index 2cb13ac7..b71bbc74 100644
--- a/ppmat/models/common/graph_converter.py
+++ b/ppmat/models/common/graph_converter.py
@@ -572,6 +572,255 @@ def __call__(
)
+class RadiusGraphConverter:
+ """Convert RDKit molecules into PGL radius graphs.
+
+ The converter is used by SphereNet-style molecular datasets. It accepts an
+ RDKit ``Mol`` or a list of RDKit ``Mol`` objects and returns PGL graph data
+ with atom numbers, positions, radius edges, and optional SphereNet triplet
+ indices cached in ``graph.edge_feat``.
+
+ Args:
+ cutoff: Neighbor cutoff distance in Angstrom.
+ atom_vocab: Mapping from atomic number to feature index for optional PGL
+ node features.
+ add_self_loops: Whether to add self-loop edges.
+ edge_mode: ``"directed"``, ``"undirected"``, or ``"bidirectional"``.
+ include_distance: Whether to include distance in PGL edge features.
+ include_direction: Whether to include unit direction in PGL edge features.
+ return_triplet_indices: Whether to cache SphereNet triplet indices.
+ num_cpus: Number of CPUs for parallel graph construction.
+ """
+
+ def __init__(
+ self,
+ cutoff: float = 5.0,
+ atom_vocab: Optional[Dict[int, int]] = None,
+ add_self_loops: bool = False,
+ edge_mode: str = "bidirectional",
+ include_distance: bool = True,
+ include_direction: bool = False,
+ return_triplet_indices: bool = False,
+ num_cpus: Optional[int] = None,
+ ) -> None:
+ if atom_vocab is None:
+ atom_vocab = {1: 0, 6: 1, 7: 2, 8: 3, 9: 4}
+ if edge_mode not in {"directed", "undirected", "bidirectional"}:
+ raise ValueError(f"Unknown edge_mode: {edge_mode}")
+
+ self.cutoff = float(cutoff)
+ self.atom_vocab = dict(atom_vocab)
+ self.add_self_loops = add_self_loops
+ self.edge_mode = edge_mode
+ self.include_distance = include_distance
+ self.include_direction = include_direction
+ self.return_triplet_indices = return_triplet_indices
+ self.num_cpus = 1 if num_cpus is None else int(num_cpus)
+
+ def __call__(
+ self, molecule: Union[Chem.Mol, List[Chem.Mol]]
+ ) -> Union[Optional[pgl.Graph], List[Optional[pgl.Graph]]]:
+ if isinstance(molecule, Chem.Mol):
+ graph = self.get_graph_by_radius(molecule)
+ elif isinstance(molecule, list):
+ if self.num_cpus == 1:
+ graph = [self.get_graph_by_radius(mol) for mol in molecule]
+ else:
+ graph = p_map(
+ self.get_graph_by_radius,
+ molecule,
+ num_cpus=self.num_cpus,
+ desc="Building graphs",
+ dynamic_ncols=True,
+ mininterval=0.2,
+ )
+ else:
+ raise TypeError("The input must be an RDKit Mol or a list of them.")
+ return graph
+
+ def get_graph_by_radius(self, molecule: Chem.Mol) -> Optional[pgl.Graph]:
+ if molecule is None:
+ return None
+
+ atomic_numbers, positions = self.get_molecule_array(molecule)
+ num_nodes = positions.shape[0]
+ if num_nodes == 0:
+ return None
+
+ edge_index, distances, directions = self.get_radius_edges(positions)
+ triplet_indices = None
+ if self.return_triplet_indices:
+ triplet_indices = self.get_triplet_indices(edge_index, num_nodes)
+ return self.build_pgl_graph(
+ atomic_numbers,
+ positions,
+ edge_index,
+ distances,
+ directions,
+ triplet_indices,
+ )
+
+ def get_molecule_array(self, molecule: Chem.Mol) -> Tuple[np.ndarray, np.ndarray]:
+ if molecule.GetNumConformers() == 0:
+ raise ValueError("RDKit Mol has no conformer. Cannot build radius graph.")
+
+ atomic_numbers = np.asarray(
+ [atom.GetAtomicNum() for atom in molecule.GetAtoms()], dtype=np.int64
+ )
+ conf = molecule.GetConformer()
+ positions = np.asarray(
+ [
+ [
+ conf.GetAtomPosition(i).x,
+ conf.GetAtomPosition(i).y,
+ conf.GetAtomPosition(i).z,
+ ]
+ for i in range(molecule.GetNumAtoms())
+ ],
+ dtype=np.float32,
+ )
+ return atomic_numbers, positions
+
+ def get_radius_edges(
+ self, positions: np.ndarray
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+ positions = np.asarray(positions, dtype=np.float32)
+ num_nodes = positions.shape[0]
+ if num_nodes == 0:
+ return (
+ np.empty((2, 0), dtype=np.int64),
+ np.empty((0, 1), dtype=np.float32),
+ np.empty((0, 3), dtype=np.float32),
+ )
+
+ diff = positions[None, :, :] - positions[:, None, :]
+ dist = np.linalg.norm(diff, axis=-1)
+ mask = dist < self.cutoff
+ np.fill_diagonal(mask, False)
+ rows, cols = np.where(mask)
+
+ if self.edge_mode == "undirected":
+ keep = rows < cols
+ rows, cols = rows[keep], cols[keep]
+
+ if self.add_self_loops:
+ self_nodes = np.arange(num_nodes, dtype=np.int64)
+ rows = np.concatenate([rows, self_nodes], axis=0)
+ cols = np.concatenate([cols, self_nodes], axis=0)
+
+ if rows.size == 0:
+ return (
+ np.empty((2, 0), dtype=np.int64),
+ np.empty((0, 1), dtype=np.float32),
+ np.empty((0, 3), dtype=np.float32),
+ )
+
+ edge_index = np.stack([rows, cols], axis=0).astype(np.int64)
+ distances = dist[rows, cols].reshape(-1, 1).astype(np.float32)
+ directions = diff[rows, cols].astype(np.float32)
+ directions = directions / np.maximum(distances, 1e-8)
+
+ order = np.argsort(
+ edge_index[0] * max(1, num_nodes) + edge_index[1], kind="mergesort"
+ )
+ edge_index = edge_index[:, order]
+ distances = distances[order]
+ directions = directions[order]
+ return edge_index, distances, directions
+
+ def get_node_feat(
+ self, atomic_numbers: np.ndarray, positions: np.ndarray
+ ) -> Dict[str, np.ndarray]:
+ atomic_numbers = np.asarray(atomic_numbers, dtype=np.int64)
+ node_feat = {
+ "pos": positions.astype(np.float32),
+ "atomic_number": atomic_numbers.reshape(-1, 1),
+ }
+
+ idxs = []
+ for z in atomic_numbers:
+ if int(z) not in self.atom_vocab:
+ return node_feat
+ idxs.append(self.atom_vocab[int(z)])
+ idxs = np.asarray(idxs, dtype=np.int64)
+ node_feat["feat"] = np.eye(len(self.atom_vocab), dtype=np.float32)[idxs]
+ return node_feat
+
+ def build_pgl_graph(
+ self,
+ atomic_numbers: np.ndarray,
+ positions: np.ndarray,
+ edge_index: np.ndarray,
+ distances: np.ndarray,
+ directions: np.ndarray,
+ triplet_indices: Optional[Dict[str, np.ndarray]] = None,
+ ) -> pgl.Graph:
+ edge_feat = {}
+ edge_feats = []
+ if self.include_distance:
+ edge_feat["distance"] = distances
+ edge_feats.append(distances)
+ if self.include_direction:
+ edge_feat["direction"] = directions
+ edge_feats.append(directions)
+ if edge_feats:
+ edge_feat["feat"] = np.concatenate(edge_feats, axis=-1).astype(
+ np.float32
+ )
+ if triplet_indices is not None:
+ edge_feat.update(triplet_indices)
+
+ return pgl.Graph(
+ num_nodes=positions.shape[0],
+ edges=edge_index.T.astype(np.int64),
+ node_feat=self.get_node_feat(atomic_numbers, positions),
+ edge_feat=edge_feat,
+ )
+
+ def get_triplet_indices(
+ self, edge_index: np.ndarray, num_atoms: int
+ ) -> Dict[str, np.ndarray]:
+ edge_index = np.asarray(edge_index, dtype=np.int64)
+ src, dst = edge_index
+ num_edges = edge_index.shape[1]
+
+ in_edges = [[] for _ in range(num_atoms)]
+ out_edges = [[] for _ in range(num_atoms)]
+ for edge_id in range(num_edges):
+ in_edges[int(dst[edge_id])].append(edge_id)
+ out_edges[int(src[edge_id])].append(edge_id)
+
+ idx_kj_list = []
+ idx_ji_list = []
+ for atom_id in range(num_atoms):
+ for kj_edge in in_edges[atom_id]:
+ k_atom = src[kj_edge]
+ for ji_edge in out_edges[atom_id]:
+ i_atom = dst[ji_edge]
+ if k_atom != i_atom:
+ idx_kj_list.append(kj_edge)
+ idx_ji_list.append(ji_edge)
+
+ idx_kj = np.asarray(idx_kj_list, dtype=np.int64)
+ idx_ji = np.asarray(idx_ji_list, dtype=np.int64)
+
+ idx_lk_list = []
+ idx_triplet_list = []
+ for triplet_id, kj_edge in enumerate(idx_kj_list):
+ k_atom = int(src[kj_edge])
+ lk_edges = in_edges[k_atom]
+ if lk_edges:
+ idx_lk_list.extend(lk_edges)
+ idx_triplet_list.extend([triplet_id] * len(lk_edges))
+
+ return {
+ "ti_idx_kj": idx_kj,
+ "ti_idx_ji": idx_ji,
+ "ti_idx_lk": np.asarray(idx_lk_list, dtype=np.int64),
+ "ti_idx_triplet": np.asarray(idx_triplet_list, dtype=np.int64),
+ }
+
+
def subgraph(
subset: Union[np.ndarray, List[int]],
edge_index: np.ndarray,
diff --git a/ppmat/models/common/initializer.py b/ppmat/models/common/initializer.py
index f5afa210..5b929192 100644
--- a/ppmat/models/common/initializer.py
+++ b/ppmat/models/common/initializer.py
@@ -37,6 +37,7 @@
"normal_",
"trunc_normal_",
"glorot_normal_",
+ "glorot_orthogonal_",
"constant_",
"ones_",
"zeros_",
@@ -490,3 +491,24 @@ def he_orthogonal_init(tensor):
tensor.data *= (1 / fan_in) ** 0.5
tensor.stop_gradient = stop_gradient
return tensor
+
+
+def glorot_orthogonal_(tensor: paddle.Tensor, scale: float = 1.0) -> paddle.Tensor:
+ """Orthogonal initialization with scaling (Glorot & Bengio, 2010 style).
+
+ Initializes the tensor as a random (semi-)orthogonal matrix and scales it
+ by the given factor. This is a simpler variant than ``he_orthogonal_init``
+ — it does not standardise or apply fan-in scaling.
+
+ Args:
+ tensor: Paddle Tensor or Parameter.
+ scale: Scaling factor applied after orthogonal initialisation.
+
+ Returns:
+ paddle.Tensor: The initialised tensor (same object, modified in-place).
+ """
+ init_orth = paddle.nn.initializer.Orthogonal()
+ init_orth(tensor)
+ with paddle.no_grad():
+ tensor.set_value(tensor * scale)
+ return tensor
diff --git a/ppmat/models/common/spherical_fourier_bessel.py b/ppmat/models/common/spherical_fourier_bessel.py
new file mode 100644
index 00000000..35223aeb
--- /dev/null
+++ b/ppmat/models/common/spherical_fourier_bessel.py
@@ -0,0 +1,340 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Numerical spherical Fourier-Bessel embeddings for SphereNet."""
+
+import math
+from functools import lru_cache
+
+import numpy as np
+import paddle
+from scipy import optimize
+from scipy import special
+
+
+def _spherical_jn_root(x, order):
+ return special.spherical_jn(order, x)
+
+
+@lru_cache(maxsize=32)
+def _build_basis_constants(num_spherical, num_radial):
+ """Build deterministic Fourier-Bessel constants for one basis shape."""
+ if num_spherical < 1:
+ raise ValueError("num_spherical must be positive.")
+ if num_radial < 1:
+ raise ValueError("num_radial must be positive.")
+
+ zeros = np.zeros((num_spherical, num_radial), dtype=np.float64)
+ zeros[0] = np.arange(1, num_radial + 1, dtype=np.float64) * np.pi
+ points = np.arange(
+ 1, num_radial + num_spherical, dtype=np.float64
+ ) * np.pi
+ roots = np.zeros(num_radial + num_spherical - 1, dtype=np.float64)
+ for order in range(1, num_spherical):
+ for i in range(num_radial + num_spherical - 1 - order):
+ roots[i] = optimize.brentq(
+ _spherical_jn_root,
+ points[i],
+ points[i + 1],
+ args=(order,),
+ )
+ points = roots.copy()
+ zeros[order] = roots[:num_radial]
+
+ normalizers = np.empty_like(zeros)
+ for order in range(num_spherical):
+ values = special.spherical_jn(order + 1, zeros[order])
+ normalizers[order] = 1.0 / np.sqrt(0.5 * values**2)
+
+ harmonic_prefactors = np.zeros(
+ (num_spherical, num_spherical), dtype=np.float64
+ )
+ for degree in range(num_spherical):
+ for order in range(degree + 1):
+ harmonic_prefactors[degree, order] = math.sqrt(
+ (2 * degree + 1)
+ * math.factorial(degree - order)
+ / (4 * math.pi * math.factorial(degree + order))
+ )
+
+ zeros.setflags(write=False)
+ normalizers.setflags(write=False)
+ harmonic_prefactors.setflags(write=False)
+ return zeros, normalizers, harmonic_prefactors
+
+
+def _double_factorial(value):
+ result = 1
+ for factor in range(value, 0, -2):
+ result *= factor
+ return result
+
+
+def _spherical_jn_series(order, x, num_terms=20):
+ """Stable spherical-Bessel series around zero."""
+ x_squared = x * x
+ term = paddle.ones_like(x)
+ series = term
+ for index in range(1, num_terms):
+ term = (
+ term
+ * -x_squared
+ / (2 * index * (2 * order + 2 * index + 1))
+ )
+ series = series + term
+ return x**order / _double_factorial(2 * order + 1) * series
+
+
+class Envelope(paddle.nn.Layer):
+ """Smooth polynomial envelope function for radial cutoff."""
+
+ def __init__(self, exponent):
+ super().__init__()
+ self.p = exponent + 1
+ self.a = -(self.p + 1) * (self.p + 2) / 2
+ self.b = self.p * (self.p + 2)
+ self.c = -self.p * (self.p + 1) / 2
+
+ def forward(self, x):
+ p, a, b, c = self.p, self.a, self.b, self.c
+ x_pow_p0 = x.pow(p - 1)
+ x_pow_p1 = x_pow_p0 * x
+ x_pow_p2 = x_pow_p1 * x
+ return 1.0 / x + a * x_pow_p0 + b * x_pow_p1 + c * x_pow_p2
+
+
+class DistEmbedding(paddle.nn.Layer):
+ """Radial basis with a smooth envelope cutoff."""
+
+ def __init__(self, num_radial, cutoff=5.0, envelope_exponent=5):
+ super().__init__()
+ self.cutoff = cutoff
+ self.envelope = Envelope(envelope_exponent)
+ self.freq = paddle.create_parameter(
+ shape=[num_radial],
+ dtype=paddle.get_default_dtype(),
+ default_initializer=paddle.nn.initializer.Assign(
+ paddle.arange(
+ 1, num_radial + 1, dtype=paddle.get_default_dtype()
+ ).multiply(paddle.to_tensor(math.pi))
+ ),
+ )
+
+ def reset_parameters(self):
+ with paddle.no_grad():
+ self.freq.set_value(
+ paddle.arange(
+ 1,
+ self.freq.shape[0] + 1,
+ dtype=paddle.get_default_dtype(),
+ ).multiply(paddle.to_tensor(math.pi))
+ )
+
+ def forward(self, dist):
+ dist = dist.unsqueeze(-1) / self.cutoff
+ return self.envelope(dist) * paddle.sin(self.freq * dist)
+
+
+class SphericalBesselBasis(paddle.nn.Layer):
+ """Normalized spherical-Bessel basis evaluated with Paddle operations."""
+
+ def __init__(self, num_spherical, num_radial, cutoff=5.0):
+ super().__init__()
+ self.num_spherical = num_spherical
+ self.num_radial = num_radial
+ self.cutoff = cutoff
+
+ zeros, normalizers, _ = _build_basis_constants(
+ num_spherical, num_radial
+ )
+ self.register_buffer(
+ "zeros",
+ paddle.to_tensor(np.array(zeros, copy=True), dtype="float32"),
+ persistable=False,
+ )
+ self.register_buffer(
+ "normalizers",
+ paddle.to_tensor(
+ np.array(normalizers, copy=True), dtype="float32"
+ ),
+ persistable=False,
+ )
+
+ def forward(self, dist):
+ scaled_dist = dist.reshape([-1, 1, 1]) / self.cutoff
+ arguments = scaled_dist * self.zeros.reshape(
+ [1, self.num_spherical, self.num_radial]
+ )
+ safe_arguments = paddle.where(
+ paddle.abs(arguments) < 1e-2,
+ paddle.full_like(arguments, 1e-2),
+ arguments,
+ )
+
+ values = [paddle.sin(safe_arguments) / safe_arguments]
+ if self.num_spherical > 1:
+ values.append(
+ paddle.sin(safe_arguments) / safe_arguments**2
+ - paddle.cos(safe_arguments) / safe_arguments
+ )
+ for degree in range(1, self.num_spherical - 1):
+ values.append(
+ (2 * degree + 1) / safe_arguments * values[-1]
+ - values[-2]
+ )
+
+ basis = []
+ for degree in range(self.num_spherical):
+ degree_arguments = arguments[:, degree, :]
+ degree_values = values[degree][:, degree, :]
+ degree_values = paddle.where(
+ paddle.abs(degree_arguments) < degree + 1.0,
+ _spherical_jn_series(degree, degree_arguments),
+ degree_values,
+ )
+ basis.append(degree_values * self.normalizers[degree])
+ return paddle.stack(basis, axis=1)
+
+
+class RealSphericalHarmonics(paddle.nn.Layer):
+ """Real spherical harmonics with the SphereNet ordering convention."""
+
+ def __init__(self, num_spherical):
+ super().__init__()
+ self.num_spherical = num_spherical
+ _, _, prefactors = _build_basis_constants(num_spherical, 1)
+ self.register_buffer(
+ "prefactors",
+ paddle.to_tensor(
+ np.array(prefactors, copy=True), dtype="float32"
+ ),
+ persistable=False,
+ )
+ self.register_buffer(
+ "m0_indices",
+ paddle.to_tensor(
+ [degree * degree for degree in range(num_spherical)],
+ dtype="int64",
+ ),
+ persistable=False,
+ )
+
+ def forward(self, angle, torsion):
+ cos_angle = paddle.cos(angle)
+ sin_angle = paddle.sin(angle)
+ one = paddle.ones_like(cos_angle)
+ polynomials = {(0, 0): one}
+
+ for order in range(1, self.num_spherical):
+ polynomials[(order, order)] = (
+ 1 - 2 * order
+ ) * polynomials[(order - 1, order - 1)]
+
+ for order in range(self.num_spherical - 1):
+ polynomials[(order + 1, order)] = (
+ (2 * order + 1)
+ * cos_angle
+ * polynomials[(order, order)]
+ )
+
+ for order in range(self.num_spherical):
+ for degree in range(order + 2, self.num_spherical):
+ polynomials[(degree, order)] = (
+ (2 * degree - 1)
+ * cos_angle
+ * polynomials[(degree - 1, order)]
+ - (order + degree - 1)
+ * polynomials[(degree - 2, order)]
+ ) / (degree - order)
+
+ harmonics = []
+ sqrt_two = math.sqrt(2.0)
+ for degree in range(self.num_spherical):
+ harmonics.append(
+ self.prefactors[degree, 0]
+ * polynomials[(degree, 0)]
+ )
+ for order in range(1, degree + 1):
+ harmonics.append(
+ sqrt_two
+ * self.prefactors[degree, order]
+ * polynomials[(degree, order)]
+ * sin_angle**order
+ * paddle.cos(order * torsion)
+ )
+ for order in range(degree, 0, -1):
+ harmonics.append(
+ sqrt_two
+ * self.prefactors[degree, order]
+ * polynomials[(degree, order)]
+ * sin_angle**order
+ * paddle.sin(order * torsion)
+ )
+ return paddle.stack(harmonics, axis=1)
+
+
+class SphericalFourierBesselEmbedding(paddle.nn.Layer):
+ """Shared angle and torsion Fourier-Bessel embedding."""
+
+ def __init__(self, num_spherical, num_radial, cutoff=5.0):
+ super().__init__()
+ self.num_spherical = num_spherical
+ self.num_radial = num_radial
+ self.radial_basis = SphericalBesselBasis(
+ num_spherical, num_radial, cutoff
+ )
+ self.spherical_harmonics = RealSphericalHarmonics(num_spherical)
+
+ def forward(self, dist, angle, torsion, idx_kj):
+ radial_basis = self.radial_basis(dist)[idx_kj]
+ harmonics = self.spherical_harmonics(angle, torsion)
+ angle_harmonics = paddle.index_select(
+ harmonics,
+ self.spherical_harmonics.m0_indices,
+ axis=1,
+ )
+
+ num_triplets = angle.shape[0]
+ angle_embedding = (
+ radial_basis * angle_harmonics.reshape(
+ [num_triplets, self.num_spherical, 1]
+ )
+ ).reshape(
+ [num_triplets, self.num_spherical * self.num_radial]
+ )
+ torsion_embedding = (
+ radial_basis.reshape(
+ [
+ num_triplets,
+ 1,
+ self.num_spherical,
+ self.num_radial,
+ ]
+ )
+ * harmonics.reshape(
+ [
+ num_triplets,
+ self.num_spherical,
+ self.num_spherical,
+ 1,
+ ]
+ )
+ ).reshape(
+ [
+ num_triplets,
+ self.num_spherical
+ * self.num_spherical
+ * self.num_radial,
+ ]
+ )
+ return angle_embedding, torsion_embedding
diff --git a/ppmat/models/gmtnet/__init__.py b/ppmat/models/gmtnet/__init__.py
new file mode 100644
index 00000000..e5d64595
--- /dev/null
+++ b/ppmat/models/gmtnet/__init__.py
@@ -0,0 +1,3 @@
+from .gmtnet import GMTNet
+
+__all__ = ["GMTNet"]
diff --git a/ppmat/models/gmtnet/gmtnet.py b/ppmat/models/gmtnet/gmtnet.py
new file mode 100644
index 00000000..d1c2c861
--- /dev/null
+++ b/ppmat/models/gmtnet/gmtnet.py
@@ -0,0 +1,725 @@
+import copy
+import math
+from collections.abc import Mapping
+
+import paddle
+import paddle.nn as nn
+import paddle.nn.functional as F
+
+from ppmat.losses import build_loss
+from ppmat.models.common.e3nn import o3
+
+
+def get_arg(args, name, default):
+ if isinstance(args, dict):
+ return args.get(name, default)
+ return getattr(args, name, default)
+
+
+def pad_or_slice_last(x, target_dim):
+ """Pad or slice the last dimension to target_dim."""
+ current_dim = x.shape[-1]
+
+ if current_dim == target_dim:
+ return x
+
+ if current_dim > target_dim:
+ return x[..., :target_dim]
+
+ pad_shape = list(x.shape)
+ pad_shape[-1] = target_dim - current_dim
+ pad = paddle.zeros(pad_shape, dtype=x.dtype)
+
+ return paddle.concat([x, pad], axis=-1)
+
+
+def equality_adjustment(equality, batch):
+ """Paddle version of equality_adjustment.
+
+ This is mainly for eval/inference. It mirrors the PyTorch loop logic.
+ """
+ if equality is None:
+ return batch
+
+ out = batch.clone()
+ b, l1, l2 = out.shape
+ flat = out.reshape([b, l1 * l2])
+
+ for i in range(b):
+ mask = equality[i]
+ for j in range(l1 * l2):
+ for k in range(j + 1, l1 * l2):
+ if bool(mask[j, k].item()):
+ avg = (flat[i, j] + flat[i, k]) / 2.0
+ flat[i, j] = avg
+ flat[i, k] = avg
+
+ return flat.reshape([b, l1, l2])
+
+
+class SiLU(nn.Layer):
+ def forward(self, x):
+ return x * F.sigmoid(x)
+
+
+class RBFExpansion(nn.Layer):
+ """Paddle version of PyTorch RBFExpansion."""
+
+ def __init__(self, vmin=0.0, vmax=8.0, bins=40, lengthscale=None):
+ super().__init__()
+
+ self.vmin = vmin
+ self.vmax = vmax
+ self.bins = bins
+
+ centers = paddle.linspace(self.vmin, self.vmax, self.bins)
+ self.register_buffer("centers", centers)
+
+ if lengthscale is None:
+ # Match PyTorch:
+ # self.lengthscale = np.diff(self.centers).mean()
+ # self.gamma = 1 / self.lengthscale
+ if self.bins > 1:
+ self.lengthscale = float((self.vmax - self.vmin) / (self.bins - 1))
+ else:
+ self.lengthscale = float(self.vmax - self.vmin)
+
+ self.gamma = float(1.0 / self.lengthscale)
+ else:
+ self.lengthscale = float(lengthscale)
+ self.gamma = float(1.0 / (self.lengthscale**2))
+
+ def forward(self, distance):
+ return paddle.exp(-self.gamma * (distance.unsqueeze(1) - self.centers) ** 2)
+
+
+class ComformerConv(nn.Layer):
+ """First-stage Paddle implementation of ComformerConv.
+
+ This version focuses on matching module names and state_dict keys.
+ Full message passing logic will be aligned in the next stage.
+ """
+
+ def __init__(self, in_channels, out_channels, heads=1, edge_dim=None):
+ super().__init__()
+
+ self.in_channels = in_channels
+ self.out_channels = out_channels
+ self.heads = heads
+ self.edge_dim = edge_dim if edge_dim is not None else out_channels
+
+ self.lin_key = nn.Linear(in_channels, out_channels)
+ self.lin_query = nn.Linear(in_channels, out_channels)
+ self.lin_value = nn.Linear(in_channels, out_channels)
+ self.lin_edge = nn.Linear(self.edge_dim, out_channels)
+ self.lin_concate = nn.Linear(out_channels, out_channels)
+
+ self.lin_msg_update = nn.Sequential(
+ nn.Linear(out_channels * 3, out_channels),
+ SiLU(),
+ nn.Linear(out_channels, out_channels),
+ )
+
+ self.key_update = nn.Sequential(
+ nn.Linear(out_channels * 3, out_channels),
+ SiLU(),
+ nn.Linear(out_channels, out_channels),
+ )
+
+ self.bn = nn.BatchNorm1D(out_channels)
+ self.bn_att = nn.BatchNorm1D(out_channels)
+ self.softplus = nn.Softplus()
+ self.sigmoid = nn.Sigmoid()
+
+ def forward(self, x, edge_index=None, edge_attr=None):
+ """Paddle implementation of PyG MessagePassing forward.
+
+ PyG convention:
+ edge_index[0] = source node j
+ edge_index[1] = target node i
+ """
+
+ H, C = self.heads, self.out_channels
+
+ if isinstance(x, (tuple, list)):
+ x_src, x_dst = x
+ else:
+ x_src, x_dst = x, x
+
+ query = self.lin_query(x_dst).reshape([-1, H, C])
+ key = self.lin_key(x_src).reshape([-1, H, C])
+ value = self.lin_value(x_src).reshape([-1, H, C])
+
+ # Fallback path for temporary GMTNet.forward smoke tests.
+ # Real graph forward should pass edge_index and edge_attr.
+ if edge_index is None:
+ out = value.reshape([-1, H * C])
+ out = self.lin_concate(out)
+ return self.softplus(x_dst + out)
+
+ edge_index = edge_index.astype("int64")
+ src = edge_index[0]
+ dst = edge_index[1]
+
+ num_nodes = x_dst.shape[0]
+ num_edges = src.shape[0]
+
+ if edge_attr is None:
+ edge_attr = paddle.zeros([num_edges, self.edge_dim], dtype=x_dst.dtype)
+
+ query_i = paddle.gather(query, dst, axis=0)
+ key_i = paddle.gather(key, dst, axis=0)
+ key_j = paddle.gather(key, src, axis=0)
+
+ value_i = paddle.gather(value, dst, axis=0)
+ value_j = paddle.gather(value, src, axis=0)
+
+ edge_attr = self.lin_edge(edge_attr).reshape([-1, H, C])
+
+ key_j = self.key_update(paddle.concat([key_i, key_j, edge_attr], axis=-1))
+
+ alpha = (query_i * key_j) / (C**0.5)
+
+ out = self.lin_msg_update(paddle.concat([value_i, value_j, edge_attr], axis=-1))
+
+ alpha_bn = self.bn_att(alpha.reshape([-1, C])).reshape([-1, H, C])
+ out = out * self.sigmoid(alpha_bn)
+
+ # aggregate messages to target nodes with add aggregation
+ out_nodes = paddle.zeros([num_nodes, H, C], dtype=out.dtype)
+ index = dst.reshape([-1, 1])
+ out_nodes = paddle.scatter_nd_add(out_nodes, index, out)
+
+ out_nodes = out_nodes.reshape([-1, H * C])
+ out_nodes = self.lin_concate(out_nodes)
+
+ return self.softplus(x_dst + out_nodes)
+
+
+class W3JBuffers(nn.Layer):
+ """Container for e3nn Wigner-3j buffers.
+
+ This is a structure/key-compatible placeholder.
+ """
+
+ def __init__(self, shapes):
+ super().__init__()
+ for name, shape in shapes.items():
+ self.register_buffer(name, paddle.zeros(shape, dtype="float32"))
+
+
+class FakeTensorProduct(nn.Layer):
+ """Key-compatible placeholder for e3nn TensorProduct.
+
+ It registers the same state_dict keys used by the PyTorch checkpoint:
+ - weight
+ - output_mask
+ - _compiled_main_left_right._w3j_...
+ """
+
+ def __init__(self, output_mask_dim, w3j_shapes=None):
+ super().__init__()
+
+ self.register_buffer("weight", paddle.empty([0], dtype="float32"))
+ self.register_buffer(
+ "output_mask", paddle.ones([output_mask_dim], dtype="float32")
+ )
+
+ if w3j_shapes is not None and len(w3j_shapes) > 0:
+ self._compiled_main_left_right = self.add_sublayer(
+ "_compiled_main_left_right",
+ W3JBuffers(w3j_shapes),
+ )
+
+
+class TensorProductConvLayer(nn.Layer):
+ """Paddle e3nn implementation of TensorProductConvLayer."""
+
+ def __init__(
+ self,
+ in_irreps,
+ sh_irreps,
+ out_irreps,
+ n_edge_features,
+ residual=True,
+ ):
+ super().__init__()
+
+ self.in_irreps = in_irreps
+ self.out_irreps = out_irreps
+ self.sh_irreps = sh_irreps
+ self.residual = residual
+
+ self.tp = o3.FullyConnectedTensorProduct(
+ in_irreps,
+ sh_irreps,
+ out_irreps,
+ shared_weights=False,
+ )
+
+ self.fc = nn.Sequential(
+ nn.Linear(n_edge_features, n_edge_features),
+ nn.Softplus(),
+ nn.Linear(n_edge_features, self.tp.weight_numel),
+ )
+
+ def forward(
+ self, node_attr, edge_index, edge_attr, edge_sh, out_nodes=None, reduce="mean"
+ ):
+ edge_index = edge_index.astype("int64")
+
+ edge_src = edge_index[0]
+ edge_dst = edge_index[1]
+
+ if out_nodes is None:
+ out_nodes = node_attr.shape[0]
+
+ node_dst = paddle.gather(node_attr, edge_dst, axis=0)
+ weight = self.fc(edge_attr)
+
+ tp_out = self.tp(node_dst, edge_sh, weight)
+
+ out_dim = tp_out.shape[-1]
+
+ out = paddle.zeros([out_nodes, out_dim], dtype=tp_out.dtype)
+ out = paddle.scatter_nd_add(
+ out,
+ edge_src.reshape([-1, 1]),
+ tp_out,
+ )
+
+ if reduce == "mean":
+ counts = paddle.zeros([out_nodes, 1], dtype=tp_out.dtype)
+ counts = paddle.scatter_nd_add(
+ counts,
+ edge_src.reshape([-1, 1]),
+ paddle.ones([edge_src.shape[0], 1], dtype=tp_out.dtype),
+ )
+ out = out / paddle.clip(counts, min=1.0)
+
+ if self.residual:
+ padded = pad_or_slice_last(node_attr, out.shape[-1])
+ out = out + padded
+
+ return out
+
+
+class ComformerConvEqui(nn.Layer):
+ """Paddle e3nn implementation of ComformerConvEqui."""
+
+ def __init__(
+ self,
+ embsize=128,
+ ns=16,
+ nv=2,
+ residual=True,
+ ):
+ super().__init__()
+
+ irrep_seq = [
+ f"{ns}x0e",
+ f"{ns}x0e + {nv}x1o + {nv}x2e",
+ f"{ns}x0e + {nv}x1o + {nv}x1e + {nv}x2e + {nv}x2o",
+ "1x0e + 1x0o + 1x1e + 1x1o + 1x2e + 1x2o + 1x3e + 1x3o",
+ ]
+
+ self.ns = ns
+ self.nv = nv
+
+ self.node_linear = nn.Linear(embsize, ns)
+ self.sh = "1x0e + 1x1o + 1x2e"
+
+ self.nlayer_1 = TensorProductConvLayer(
+ in_irreps=irrep_seq[0],
+ sh_irreps=self.sh,
+ out_irreps=irrep_seq[1],
+ n_edge_features=embsize,
+ residual=residual,
+ )
+
+ self.nlayer_2 = TensorProductConvLayer(
+ in_irreps=irrep_seq[1],
+ sh_irreps=self.sh,
+ out_irreps=irrep_seq[2],
+ n_edge_features=embsize,
+ residual=False,
+ )
+
+ self.nlayer_3 = TensorProductConvLayer(
+ in_irreps=irrep_seq[2],
+ sh_irreps=self.sh,
+ out_irreps=irrep_seq[3],
+ n_edge_features=embsize,
+ residual=False,
+ )
+
+ def forward(self, data, node_features=None, edge_index=None, edge_features=None):
+ edge_vec = data.edge_attr
+
+ edge_irr = o3.spherical_harmonics(
+ self.sh,
+ edge_vec,
+ normalize=True,
+ normalization="component",
+ )
+
+ node_feature = self.node_linear(node_features)
+ node_feature = self.nlayer_1(node_feature, edge_index, edge_features, edge_irr)
+ node_feature = self.nlayer_2(node_feature, edge_index, edge_features, edge_irr)
+ node_feature = self.nlayer_3(node_feature, edge_index, edge_features, edge_irr)
+
+ return node_feature
+
+
+class GradientBlock(nn.Layer):
+ """Paddle e3nn eval implementation of Gradient_block.
+
+ Note:
+ create_graph=True is not supported because Paddle einsum_grad
+ currently does not support higher-order grad.
+ This implementation is for eval/test forward.
+ """
+
+ def __init__(self, nv=2):
+ super().__init__()
+
+ irrep_seq = [
+ "1x0e + 1x0o + 1x1e + 1x1o + 1x2e + 1x2o + 1x3e + 1x3o",
+ "1x1o",
+ ]
+
+ self.nv = nv
+ self.sh = "1x1o"
+
+ self.tp = o3.FullyConnectedTensorProduct(
+ irrep_seq[0],
+ self.sh,
+ irrep_seq[1],
+ internal_weights=False,
+ )
+
+ self.register_buffer(
+ "constant_w",
+ paddle.ones([self.tp.weight_numel], dtype="float32"),
+ )
+
+ def _training_dielectric(self, node_feature):
+ input_slices = self.tp.irreps_in1.slices()
+ field_mul_ir = self.tp.irreps_in2[0]
+ output_mul_ir = self.tp.irreps_out[0]
+
+ if field_mul_ir.mul != 1 or field_mul_ir.ir.l != 1:
+ raise RuntimeError("GradientBlock requires a single 1o field irrep.")
+ if output_mul_ir.mul != 1 or output_mul_ir.ir.l != 1:
+ raise RuntimeError("GradientBlock requires a single 1o output irrep.")
+
+ batch_size = node_feature.shape[0]
+ field_dim = field_mul_ir.ir.dim
+ output_dim = output_mul_ir.ir.dim
+
+ if field_dim != 3 or output_dim != 3:
+ raise RuntimeError("GradientBlock requires three-dimensional field/output.")
+
+ dielectric = paddle.zeros(
+ [batch_size, output_mul_ir.mul, output_dim, field_dim],
+ dtype=node_feature.dtype,
+ )
+ external_weight = self.constant_w.astype(node_feature.dtype)
+
+ for instruction_index, instruction, weight_view in self.tp.weight_views(
+ external_weight,
+ yield_instruction=True,
+ ):
+ if instruction.connection_mode != "uvw" or not instruction.has_weight:
+ raise RuntimeError(
+ "GradientBlock only supports weighted uvw tensor-product paths."
+ )
+ if instruction.i_in2 != 0 or instruction.i_out != 0:
+ raise RuntimeError(
+ "GradientBlock only supports the configured single field/output irrep."
+ )
+
+ input_mul_ir = self.tp.irreps_in1[instruction.i_in1]
+ path_output_mul_ir = self.tp.irreps_out[instruction.i_out]
+
+ if weight_view.shape != [
+ input_mul_ir.mul,
+ field_mul_ir.mul,
+ path_output_mul_ir.mul,
+ ]:
+ raise RuntimeError("Unexpected tensor-product external weight shape.")
+
+ input_feature = node_feature[:, input_slices[instruction.i_in1]]
+ input_feature = input_feature.reshape(
+ [batch_size, input_mul_ir.mul, input_mul_ir.ir.dim]
+ )
+
+ coupling = o3.wigner_3j(
+ input_mul_ir.ir.l,
+ field_mul_ir.ir.l,
+ path_output_mul_ir.ir.l,
+ dtype=node_feature.dtype,
+ device=node_feature.place,
+ )
+
+ feature_term = input_feature.reshape(
+ [batch_size, input_mul_ir.mul, input_mul_ir.ir.dim, 1, 1, 1]
+ )
+ coupling_term = coupling.reshape(
+ [1, 1, input_mul_ir.ir.dim, field_dim, output_dim, 1]
+ )
+ weight_term = weight_view[:, 0, :].reshape(
+ [1, input_mul_ir.mul, 1, 1, 1, path_output_mul_ir.mul]
+ )
+
+ coefficient_qkw = paddle.sum(
+ feature_term * coupling_term * weight_term,
+ axis=[1, 2],
+ )
+ coefficient_qkw = instruction.path_weight * coefficient_qkw
+ coefficient_wkq = coefficient_qkw.transpose([0, 3, 2, 1])
+
+ dielectric = dielectric + coefficient_wkq
+
+ spherical_derivative = math.sqrt(3.0 / (4.0 * math.pi))
+ dielectric = spherical_derivative * dielectric
+ return dielectric.reshape([batch_size, output_dim, field_dim])
+
+ def forward(self, node_feature):
+ if self.training:
+ return self._training_dielectric(node_feature)
+
+ bs = node_feature.shape[0]
+
+ outer_E = paddle.ones([bs, 3], dtype=node_feature.dtype)
+ outer_E.stop_gradient = False
+
+ E_ = o3.spherical_harmonics(
+ self.sh,
+ outer_E,
+ normalize=False,
+ )
+
+ D_ = self.tp(
+ node_feature,
+ E_,
+ self.constant_w.astype(node_feature.dtype),
+ )
+
+ dielectric = []
+
+ for i in range(3):
+ grad_outputs = paddle.zeros([bs, 3], dtype=node_feature.dtype)
+ grad_outputs[:, i] = 1.0
+
+ grad_i = paddle.grad(
+ outputs=[D_],
+ inputs=[outer_E],
+ grad_outputs=[grad_outputs],
+ create_graph=False,
+ retain_graph=True,
+ )[0]
+
+ dielectric.append(grad_i)
+
+ return paddle.stack(dielectric, axis=0).transpose([1, 0, 2])
+
+
+class GMTNet(nn.Layer):
+ """First-stage Paddle GMTNet with RBFExpansion and ComformerConv."""
+
+ requires_forward_grad = True
+
+ def __init__(self, args, loss_cfg=None):
+ super().__init__()
+
+ atom_input_features = get_arg(args, "atom_input_features", 92)
+ embsize = get_arg(args, "embedding_features", 128)
+ edge_features = get_arg(args, "edge_features", 512)
+ output_features = get_arg(args, "output_features", 9)
+ num_layers = get_arg(args, "num_layers", 2)
+
+ self.atom_embedding = nn.Linear(atom_input_features, embsize)
+
+ self.rbf = nn.Sequential(
+ RBFExpansion(vmin=-4.0, vmax=0.0, bins=edge_features),
+ nn.Linear(edge_features, embsize),
+ nn.Softplus(),
+ )
+
+ self.att_layers = nn.LayerList(
+ [
+ ComformerConv(
+ in_channels=embsize,
+ out_channels=embsize,
+ heads=1,
+ edge_dim=embsize,
+ )
+ for _ in range(num_layers)
+ ]
+ )
+
+ self.equi_update = ComformerConvEqui(embsize)
+ self.output_block = GradientBlock()
+
+ self.mask = get_arg(args, "use_mask", False)
+ self.reduce = get_arg(args, "reduce_cell", False)
+
+ self.etgnn_linear = nn.Linear(embsize, 1)
+
+ if loss_cfg is None:
+ self.loss_fn = None
+ else:
+ try:
+ self.loss_fn = build_loss(copy.deepcopy(loss_cfg))
+ except Exception as error:
+ raise ValueError(f"Invalid loss_cfg: {error}") from error
+
+ @staticmethod
+ def _validate_mapping_tensor(name, value, trailing_shape, dtype=None):
+ if not isinstance(value, paddle.Tensor):
+ raise TypeError(f"Mapping key '{name}' must be a Paddle Tensor")
+ if value.ndim != 3:
+ raise ValueError(
+ f"Mapping key '{name}' must have rank 3, got shape {list(value.shape)}"
+ )
+ if list(value.shape[1:]) != list(trailing_shape):
+ raise ValueError(
+ f"Mapping key '{name}' must have shape [B,{trailing_shape[0]},{trailing_shape[1]}], got {list(value.shape)}"
+ )
+ if dtype is not None and value.dtype != dtype:
+ raise TypeError(
+ f"Mapping key '{name}' must have dtype {dtype}, got {value.dtype}"
+ )
+
+ def _mapping_inputs(self, batch):
+ for key in ("graph", "feature_mask", "matrix_equal"):
+ if key not in batch:
+ raise ValueError(f"Mapping input is missing required key '{key}'")
+ feature_mask = batch["feature_mask"]
+ matrix_equal = batch["matrix_equal"]
+ self._validate_mapping_tensor("feature_mask", feature_mask, (32, 32))
+ self._validate_mapping_tensor(
+ "matrix_equal", matrix_equal, (9, 9), dtype=paddle.bool
+ )
+ if feature_mask.shape[0] != matrix_equal.shape[0]:
+ raise ValueError(
+ "Mapping feature_mask and matrix_equal batch sizes must match"
+ )
+ dielectric = batch.get("dielectric")
+ if dielectric is not None:
+ self._validate_mapping_tensor("dielectric", dielectric, (3, 3))
+ if dielectric.shape[0] != feature_mask.shape[0]:
+ raise ValueError(
+ "Mapping dielectric and feature_mask batch sizes must match"
+ )
+ return batch["graph"], feature_mask, matrix_equal, dielectric
+
+ def _forward_tensor(self, data, feat_mask, equality):
+ """Paddle GMTNet forward.
+
+ Current stage:
+ - atom_embedding: real
+ - RBF edge feature: real
+ - ComformerConv att_layers: real
+ - equi_update: placeholder
+ - output_block: placeholder
+
+ Therefore this is still not final metric reproduction.
+ """
+
+ node_features = self.atom_embedding(data.x)
+
+ edge_feat = -0.75 / paddle.norm(data.edge_attr, axis=1)
+ edge_features = self.rbf(edge_feat)
+
+ node_features = self.att_layers[0](
+ node_features,
+ data.edge_index,
+ edge_features,
+ )
+
+ node_features = self.att_layers[1](
+ node_features,
+ data.edge_index,
+ edge_features,
+ )
+
+ # Placeholder currently returns node_features unchanged.
+ node_features = self.equi_update(
+ data,
+ node_features,
+ data.edge_index,
+ edge_features,
+ )
+
+ # Manual global mean pooling by data.batch.
+ if hasattr(data, "batch"):
+ batch = data.batch.astype("int64")
+ else:
+ batch = paddle.zeros([node_features.shape[0]], dtype="int64")
+
+ num_graphs = int(paddle.max(batch).item()) + 1
+
+ crystal_features = paddle.zeros(
+ [num_graphs, node_features.shape[1]],
+ dtype=node_features.dtype,
+ )
+
+ crystal_features = paddle.scatter_nd_add(
+ crystal_features,
+ batch.reshape([-1, 1]),
+ node_features,
+ )
+
+ counts = paddle.zeros([num_graphs, 1], dtype=node_features.dtype)
+ counts = paddle.scatter_nd_add(
+ counts,
+ batch.reshape([-1, 1]),
+ paddle.ones([node_features.shape[0], 1], dtype=node_features.dtype),
+ )
+
+ crystal_features = crystal_features / paddle.clip(counts, min=1.0)
+
+ if self.mask and feat_mask is not None:
+ crystal_features = paddle.bmm(
+ feat_mask,
+ crystal_features.unsqueeze(-1),
+ ).squeeze(-1)
+
+ outputs = self.output_block(crystal_features)
+
+ if equality is not None:
+ outputs = equality_adjustment(equality, outputs)
+
+ return outputs
+
+ def forward(self, data, feat_mask=None, equality=None):
+ if isinstance(data, Mapping):
+ if feat_mask is not None or equality is not None:
+ raise ValueError(
+ "Mapping input must not be combined with feat_mask or equality arguments"
+ )
+ graph, feature_mask, matrix_equal, dielectric = self._mapping_inputs(data)
+ prediction = self._forward_tensor(graph, feature_mask, matrix_equal)
+ result = {"pred_dict": {"dielectric": prediction}}
+ if dielectric is None:
+ return result
+ if self.loss_fn is None:
+ raise ValueError(
+ "Dielectric labels were provided, but no loss function was explicitly configured. No default loss is available."
+ )
+ result["loss_dict"] = {"loss": self.loss_fn(prediction, dielectric)}
+ return result
+ if feat_mask is None or equality is None:
+ raise ValueError(
+ "Raw GMTNet calls require both feat_mask and equality arguments"
+ )
+ return self._forward_tensor(data, feat_mask, equality)
+
+ def predict(self, data):
+ """Predict dielectric tensors from label-free GMTNet Mapping inputs."""
+ if isinstance(data, list):
+ predictions = [self.forward(item)["pred_dict"]["dielectric"] for item in data]
+ return {"pred_dict": {"dielectric": paddle.concat(predictions, axis=0)}}
+ return self.forward(data)
diff --git a/ppmat/models/gmtnet/gmtnet_graph_converter.py b/ppmat/models/gmtnet/gmtnet_graph_converter.py
new file mode 100644
index 00000000..3bcb6792
--- /dev/null
+++ b/ppmat/models/gmtnet/gmtnet_graph_converter.py
@@ -0,0 +1,305 @@
+"""Historical-equivalent crystal graph conversion for GMTNet.
+
+This converter reproduces the graph-construction portion of the historical
+GMTNet ``atoms2graphs`` path for ordered pymatgen structures. It converts the
+structure to JARVIS ``Atoms``, constructs canonicalized periodic neighbors,
+and returns a PaddleMaterials geometric ``Data`` object containing ``x``,
+``edge_index``, and ``edge_attr``.
+
+The first version intentionally serves only the frozen GMTNet graph contract.
+Callers must provide ``equivalent_atoms`` with one entry per structure atom.
+It does not generate feature masks, matrix-equality constraints, labels, data
+splits, or batches.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Mapping
+from collections.abc import Sequence
+
+import numpy as np
+import paddle
+import spglib
+from jarvis.core.specie import get_node_attributes
+from pymatgen.core import Structure
+from pymatgen.io.jarvis import JarvisAtomsAdaptor
+
+from ppmat.datasets.geometric_data_type.data import Data
+from ppmat.models.common.e3nn import o3
+from ppmat.models.common.e3nn.io import CartesianTensor
+
+
+class GMTNetGraphConverter:
+ """Convert an ordered pymatgen structure into the GMTNet graph format.
+
+ Args:
+ cutoff: Initial periodic-neighbor cutoff in Angstrom.
+ max_neighbors: Minimum number of neighbors used to select each site.
+ atom_features: JARVIS node-feature representation. Only ``"cgcnn"``
+ is supported by this historical-equivalence implementation.
+ use_canonize: Whether to canonicalize periodic edge representations.
+ reduce_cell: Historical ``reduce`` argument. The current GMTNet
+ baseline requires ``False``.
+
+ The returned :class:`~ppmat.datasets.geometric_data_type.data.Data` has
+ ``x`` with shape ``[num_nodes, 92]``, ``edge_index`` with shape
+ ``[2, num_edges]``, and ``edge_attr`` with shape ``[num_edges, 3]``.
+ """
+
+ def __init__(
+ self,
+ cutoff: float = 4.0,
+ max_neighbors: int = 16,
+ atom_features: str = "cgcnn",
+ use_canonize: bool = True,
+ reduce_cell: bool = False,
+ ):
+ if atom_features != "cgcnn":
+ raise ValueError(
+ "GMTNetGraphConverter only supports atom_features='cgcnn'."
+ )
+ if cutoff <= 0:
+ raise ValueError("cutoff must be positive.")
+ if max_neighbors <= 0:
+ raise ValueError("max_neighbors must be positive.")
+ if reduce_cell:
+ raise ValueError(
+ "GMTNetGraphConverter v1 supports only reduce_cell=False."
+ )
+
+ self.cutoff = float(cutoff)
+ self.max_neighbors = int(max_neighbors)
+ self.atom_features = atom_features
+ self.use_canonize = bool(use_canonize)
+ self.reduce_cell = bool(reduce_cell)
+ self._adaptor = JarvisAtomsAdaptor()
+ self._irreps_output = o3.Irreps(
+ "1x0e + 1x0o + 1x1e + 1x1o + 1x2e + 1x2o + 1x3e + 1x3o"
+ )
+ self._cartesian_converter = CartesianTensor("ij")
+ self._symprec = 1e-5
+
+ @staticmethod
+ def load_structure_from_cif(cif_path) -> Structure:
+ """Load a CIF without parser-side coordinate idealization for GMTNet."""
+ try:
+ return Structure.from_file(
+ cif_path,
+ primitive=False,
+ sort=False,
+ merge_tol=0.0,
+ frac_tolerance=0.0,
+ )
+ except Exception as error:
+ raise ValueError(
+ f"GMTNet precision-preserving CIF parsing failed for {cif_path!s}."
+ ) from error
+
+ @staticmethod
+ def _dataset_value(dataset, name: str):
+ try:
+ return getattr(dataset, name)
+ except AttributeError:
+ return dataset[name]
+
+ @staticmethod
+ def _unique_rotations(rotations: np.ndarray) -> np.ndarray:
+ unique_rotations = []
+ seen = set()
+ for rotation in rotations:
+ key = tuple(rotation.reshape(-1).tolist())
+ if key not in seen:
+ seen.add(key)
+ unique_rotations.append(rotation)
+ return np.asarray(unique_rotations, dtype=np.float32)
+
+ def _symmetry_dataset(self, structure: Structure):
+ dataset = spglib.get_symmetry_dataset(
+ (structure.lattice.matrix, structure.frac_coords, structure.atomic_numbers),
+ symprec=self._symprec,
+ )
+ if dataset is None:
+ raise ValueError("spglib could not determine the structure symmetry.")
+ return dataset
+
+ def _prediction_constraints(self, structure: Structure, symmetry_dataset):
+ rotations = self._unique_rotations(
+ np.asarray(self._dataset_value(symmetry_dataset, "rotations"))
+ )
+ lattice = np.asarray(structure.lattice.matrix, dtype=np.float32).T
+ transformed_rotations = lattice @ rotations @ np.linalg.inv(lattice)
+ representations = self._irreps_output.D_from_matrix(
+ paddle.to_tensor(transformed_rotations, dtype="float32")
+ )
+ average = representations.sum(axis=0) / representations.shape[0]
+ feature_mask = average * (average > 1e-5).astype("float32")
+ mask = paddle.concat([
+ paddle.arange(8, dtype="float32") + 10.0,
+ (paddle.arange(24, dtype="float32") + 18.0) * 100.0,
+ ])
+ feature_total = representations.sum(axis=0) @ mask
+ selected = feature_total[[0, 2, 3, 4, 8, 9, 10, 11, 12]]
+ ideal_matrix = self._cartesian_converter.to_cartesian(selected).numpy()
+ flattened = ideal_matrix.reshape(-1)
+ matrix_equal = np.abs(flattened[:, None] - flattened[None, :]) < (
+ 0.0001 * np.abs(flattened[:, None] + flattened[None, :]) / 2.0
+ )
+ return feature_mask, matrix_equal
+
+ def _build_prediction_input_from_structure(self, structure: Structure):
+ symmetry_dataset = self._symmetry_dataset(structure)
+ equivalent_atoms = np.asarray(
+ self._dataset_value(symmetry_dataset, "equivalent_atoms"), dtype=np.int32
+ )
+ feature_mask, matrix_equal = self._prediction_constraints(
+ structure, symmetry_dataset
+ )
+ return {
+ "graph": self(structure, equivalent_atoms),
+ "feature_mask": feature_mask.unsqueeze(0),
+ "matrix_equal": paddle.to_tensor(matrix_equal, dtype="bool").unsqueeze(0),
+ }
+
+ def build_prediction_input(self, values):
+ """Return GMTNet prediction Mappings while preserving Mapping inputs."""
+ if isinstance(values, Mapping):
+ return values
+ if isinstance(values, Structure):
+ return self._build_prediction_input_from_structure(values)
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
+ raise TypeError("structures must be a pymatgen Structure or a sequence of Structures.")
+ if not values:
+ raise ValueError("structures must not be empty.")
+ if all(isinstance(value, Mapping) for value in values):
+ return values
+ if not all(isinstance(value, Structure) for value in values):
+ raise TypeError("structures must contain only pymatgen Structure objects.")
+ return [self._build_prediction_input_from_structure(value) for value in values]
+
+ @staticmethod
+ def _canonize_edge(src_id, dst_id, src_image, dst_image):
+ if dst_id < src_id:
+ src_id, dst_id = dst_id, src_id
+ src_image, dst_image = dst_image, src_image
+
+ if not np.array_equal(src_image, (0, 0, 0)):
+ shift = src_image
+ src_image = tuple(np.subtract(src_image, shift))
+ dst_image = tuple(np.subtract(dst_image, shift))
+
+ if src_image != (0, 0, 0):
+ raise RuntimeError("Canonical edge source image must be (0, 0, 0).")
+ return src_id, dst_id, src_image, dst_image
+
+ def _build_edges(self, atoms, cutoff: float):
+ all_neighbors = atoms.get_all_neighbors(r=cutoff)
+ if len(all_neighbors) == 0:
+ raise RuntimeError("Structure contains no atoms.")
+
+ min_neighbors = min(len(neighbor_list) for neighbor_list in all_neighbors)
+ if min_neighbors < self.max_neighbors:
+ lattice = atoms.lattice
+ expanded_cutoff = (
+ max(lattice.a, lattice.b, lattice.c)
+ if cutoff < max(lattice.a, lattice.b, lattice.c)
+ else 2.0 * cutoff
+ )
+ return self._build_edges(atoms, expanded_cutoff)
+
+ edges = defaultdict(set)
+ for site_index, neighbor_list in enumerate(all_neighbors):
+ neighbor_list = sorted(neighbor_list, key=lambda neighbor: neighbor[2])
+ distances = np.asarray([neighbor[2] for neighbor in neighbor_list])
+ neighbor_ids = np.asarray([neighbor[1] for neighbor in neighbor_list])
+ images = np.asarray([neighbor[3] for neighbor in neighbor_list])
+
+ max_distance = distances[self.max_neighbors - 1]
+ selected = distances <= max_distance
+ neighbor_ids = neighbor_ids[selected]
+ images = images[selected]
+
+ for destination_index, image in zip(neighbor_ids, images):
+ source_index, destination_index, _, destination_image = (
+ self._canonize_edge(
+ site_index,
+ int(destination_index),
+ (0, 0, 0),
+ tuple(image),
+ )
+ )
+ if self.use_canonize:
+ edges[(source_index, destination_index)].add(destination_image)
+ else:
+ edges[(site_index, int(destination_index))].add(tuple(image))
+ return edges
+
+ @staticmethod
+ def _build_undirected_edge_data(atoms, edges):
+ sources = []
+ destinations = []
+ displacements = []
+ for (source_index, destination_index), images in edges.items():
+ for destination_image in images:
+ destination_coordinate = (
+ atoms.frac_coords[destination_index] + destination_image
+ )
+ displacement = atoms.lattice.cart_coords(
+ destination_coordinate - atoms.frac_coords[source_index]
+ )
+ for source, destination, vector in (
+ (source_index, destination_index, displacement),
+ (destination_index, source_index, -displacement),
+ ):
+ sources.append(source)
+ destinations.append(destination)
+ displacements.append(vector)
+
+ if not sources:
+ raise RuntimeError("Graph construction produced no edges.")
+
+ edge_index = np.asarray([sources, destinations], dtype=np.int64)
+ edge_attr = np.asarray(displacements, dtype=np.float32)
+ return edge_index, edge_attr
+
+ def __call__(self, structure, equivalent_atoms: Sequence[int]):
+ """Return the historical-equivalent GMTNet graph for ``structure``.
+
+ Args:
+ structure: Ordered pymatgen ``Structure`` accepted by
+ :class:`pymatgen.io.jarvis.JarvisAtomsAdaptor`.
+ equivalent_atoms: Per-atom symmetry-equivalence identifiers. They
+ are required to preserve the historical call contract even
+ though the frozen ``reduce_cell=False`` path does not reduce
+ nodes by equivalence classes.
+ """
+ if structure is None:
+ raise ValueError("structure must be provided.")
+ if equivalent_atoms is None:
+ raise ValueError("equivalent_atoms must be provided.")
+
+ atoms = self._adaptor.get_atoms(structure)
+ if len(equivalent_atoms) != len(atoms.elements):
+ raise ValueError(
+ "equivalent_atoms length must equal the number of structure atoms."
+ )
+
+ edges = self._build_edges(atoms, self.cutoff)
+ edge_index, edge_attr = self._build_undirected_edge_data(atoms, edges)
+
+ node_features = np.asarray(
+ [
+ list(get_node_attributes(element, atom_features=self.atom_features))
+ for element in atoms.elements
+ ],
+ dtype=np.float32,
+ )
+ graph = Data(
+ x=paddle.to_tensor(node_features, dtype="float32"),
+ edge_index=paddle.to_tensor(edge_index, dtype="int64"),
+ edge_attr=paddle.to_tensor(edge_attr, dtype="float32"),
+ )
+ required_fields = ("x", "edge_index", "edge_attr")
+ if any(getattr(graph, field, None) is None for field in required_fields):
+ raise RuntimeError("GMTNet graph is missing required fields.")
+ return graph
diff --git a/ppmat/models/spherenet/geometry.py b/ppmat/models/spherenet/geometry.py
new file mode 100644
index 00000000..35cc2d0f
--- /dev/null
+++ b/ppmat/models/spherenet/geometry.py
@@ -0,0 +1,80 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+3D geometry utilities for spherical message-passing models.
+
+SphereNet-specific — computes distance, angle, and torsion from 3D
+atomic coordinates for spherical message passing.
+"""
+
+import paddle
+
+from ppmat.utils.scatter import scatter_argmin
+
+
+def compute_geometry(pos, edge_index, triplet_indices):
+ """Compute SphereNet distance, angle, and torsion tensors.
+
+ Args:
+ pos: Atom positions with shape [num_nodes, 3].
+ edge_index: Directed edge indices with shape [2, num_edges].
+ triplet_indices: Precomputed edge and triplet index tensors.
+
+ Returns:
+ Distance, angle, torsion, edge source/target indices, and triplet maps.
+ """
+ i, j = edge_index
+ idx_kj = triplet_indices["idx_kj"]
+ idx_ji = triplet_indices["idx_ji"]
+ idx_lk = triplet_indices["idx_lk"]
+ idx_triplet = triplet_indices["idx_triplet"]
+
+ vec = pos[j] - pos[i]
+ dist = paddle.sqrt(paddle.sum(vec * vec, axis=-1) + 1e-8)
+
+ vec_kj = vec[idx_kj]
+ vec_ji = vec[idx_ji]
+
+ angle_cross = paddle.linalg.cross(vec_kj, vec_ji)
+ angle_sin = paddle.sqrt(paddle.sum(angle_cross * angle_cross, axis=-1) + 1e-8)
+ angle_cos = -paddle.sum(vec_kj * vec_ji, axis=-1)
+ angle = paddle.atan2(angle_sin, angle_cos).detach()
+
+ torsion = paddle.zeros_like(angle)
+ if idx_lk.shape[0] > 0:
+ k_idx_from_edge_lk = j[idx_lk]
+ v1 = pos[k_idx_from_edge_lk] - pos[i[idx_lk]]
+ v2 = vec_kj[idx_triplet]
+ v3 = vec_ji[idx_triplet]
+
+ v2_norm = paddle.sqrt(paddle.sum(v2 * v2, axis=-1) + 1e-8)
+ v2_cross_v3 = paddle.linalg.cross(v2, v3)
+ v1_dot_v2crossv3 = paddle.sum(v1 * v2_cross_v3, axis=-1)
+ v1_cross_v2 = paddle.linalg.cross(v1, v2)
+ v1crossv2_dot_v2crossv3 = paddle.sum(v1_cross_v2 * v2_cross_v3, axis=-1)
+
+ torsion_angle = paddle.atan2(
+ v2_norm * v1_dot_v2crossv3, v1crossv2_dot_v2crossv3
+ ).detach()
+
+ torsion_indices = scatter_argmin(
+ paddle.abs(torsion_angle), idx_triplet, idx_kj.shape[0]
+ )
+ torsion = paddle.where(
+ torsion_indices >= 0,
+ torsion_angle[paddle.clip(torsion_indices, min=0)],
+ torsion,
+ )
+
+ return dist, angle, torsion, i, j, idx_kj, idx_ji
diff --git a/ppmat/models/spherenet/spherenet.py b/ppmat/models/spherenet/spherenet.py
new file mode 100644
index 00000000..8062c5b7
--- /dev/null
+++ b/ppmat/models/spherenet/spherenet.py
@@ -0,0 +1,452 @@
+# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import paddle
+from paddle import nn
+from paddle.nn import Embedding
+from paddle.nn import Linear
+from paddle.nn.functional import silu
+
+from ppmat.models.common import initializer
+from ppmat.models.common.spherical_fourier_bessel import DistEmbedding
+from ppmat.models.common.spherical_fourier_bessel import (
+ SphericalFourierBesselEmbedding,
+)
+from ppmat.models.spherenet.geometry import compute_geometry
+from ppmat.utils.scatter import scatter_sum
+
+
+class SphereNetEmbedding(paddle.nn.Layer):
+ def __init__(self, num_spherical, num_radial, cutoff, envelope_exponent):
+ super().__init__()
+ self.dist_emb = DistEmbedding(num_radial, cutoff, envelope_exponent)
+ self.geometry_emb = SphericalFourierBesselEmbedding(
+ num_spherical, num_radial, cutoff
+ )
+
+ def reset_parameters(self):
+ self.dist_emb.reset_parameters()
+
+ def forward(self, dist, angle, torsion, idx_kj):
+ dist_emb = self.dist_emb(dist)
+ angle_emb, torsion_emb = self.geometry_emb(
+ dist, angle, torsion, idx_kj
+ )
+ return dist_emb, angle_emb, torsion_emb
+
+
+class ResidualLayer(paddle.nn.Layer):
+ def __init__(self, hidden_channels, act=silu):
+ super().__init__()
+ self.act = act
+ self.lin1 = Linear(hidden_channels, hidden_channels)
+ self.lin2 = Linear(hidden_channels, hidden_channels)
+
+ def reset_parameters(self):
+ initializer.glorot_orthogonal_(self.lin1.weight, scale=1.0)
+ initializer.zeros_(self.lin1.bias)
+ initializer.glorot_orthogonal_(self.lin2.weight, scale=1.0)
+ initializer.zeros_(self.lin2.bias)
+
+ def forward(self, x):
+ return x + self.act(self.lin2(self.act(self.lin1(x))))
+
+
+class InitialEdgeEmbedding(paddle.nn.Layer):
+ def __init__(
+ self,
+ num_radial,
+ hidden_channels,
+ act=silu,
+ use_node_features=True,
+ use_extra_node_feature=False,
+ ):
+ super().__init__()
+ self.act = act
+ self.use_node_features = use_node_features
+ self.use_extra_node_feature = use_extra_node_feature
+ if self.use_node_features:
+ self.emb = Embedding(95, hidden_channels)
+ else:
+ self.node_embedding = paddle.create_parameter(
+ shape=[hidden_channels],
+ dtype=paddle.get_default_dtype(),
+ default_initializer=paddle.nn.initializer.Normal(),
+ )
+ self.lin_rbf_0 = Linear(num_radial, hidden_channels)
+ if self.use_extra_node_feature:
+ self.lin = Linear(5 * hidden_channels, hidden_channels)
+ else:
+ self.lin = Linear(3 * hidden_channels, hidden_channels)
+ self.lin_rbf_1 = Linear(num_radial, hidden_channels, bias_attr=False)
+
+ def reset_parameters(self):
+ if self.use_node_features:
+ initializer.uniform_(
+ self.emb.weight,
+ -(3.0**0.5),
+ 3.0**0.5,
+ )
+ else:
+ initializer.normal_(self.node_embedding)
+
+ for layer in self.sublayers():
+ if isinstance(layer, Linear):
+ initializer.glorot_orthogonal_(layer.weight, scale=1.0)
+ if layer.bias is not None:
+ initializer.zeros_(layer.bias)
+
+ def forward(self, x, node_feature, emb_in, i, j):
+ rbf, _, _ = emb_in
+ if self.use_node_features:
+ x = self.emb(x)
+ else:
+ x = self.node_embedding.unsqueeze(0).expand([x.shape[0], -1])
+ if node_feature is not None and self.use_extra_node_feature:
+ x = paddle.concat([x, node_feature], axis=1)
+ rbf0 = self.act(self.lin_rbf_0(rbf))
+ e1 = self.act(self.lin(paddle.concat([x[i], x[j], rbf0], axis=-1)))
+ e2 = self.lin_rbf_1(rbf) * e1
+ return e1, e2
+
+
+class EdgeUpdate(paddle.nn.Layer):
+ def __init__(
+ self,
+ hidden_channels,
+ int_emb_size,
+ basis_emb_size_dist,
+ basis_emb_size_angle,
+ basis_emb_size_torsion,
+ num_spherical,
+ num_radial,
+ num_before_skip,
+ num_after_skip,
+ act=silu,
+ ):
+ super().__init__()
+ self.act = act
+ self.lin_rbf1 = Linear(num_radial, basis_emb_size_dist, bias_attr=False)
+ self.lin_rbf2 = Linear(basis_emb_size_dist, hidden_channels, bias_attr=False)
+ self.lin_sbf1 = Linear(
+ num_spherical * num_radial, basis_emb_size_angle, bias_attr=False
+ )
+ self.lin_sbf2 = Linear(basis_emb_size_angle, int_emb_size, bias_attr=False)
+ self.lin_t1 = Linear(
+ num_spherical * num_spherical * num_radial,
+ basis_emb_size_torsion,
+ bias_attr=False,
+ )
+ self.lin_t2 = Linear(basis_emb_size_torsion, int_emb_size, bias_attr=False)
+ self.lin_rbf = Linear(num_radial, hidden_channels, bias_attr=False)
+
+ self.lin_kj = Linear(hidden_channels, hidden_channels)
+ self.lin_ji = Linear(hidden_channels, hidden_channels)
+
+ self.lin_down = Linear(hidden_channels, int_emb_size, bias_attr=False)
+ self.lin_up = Linear(int_emb_size, hidden_channels, bias_attr=False)
+
+ self.layers_before_skip = nn.LayerList(
+ [ResidualLayer(hidden_channels, act) for _ in range(num_before_skip)]
+ )
+ self.lin = Linear(hidden_channels, hidden_channels)
+ self.layers_after_skip = nn.LayerList(
+ [ResidualLayer(hidden_channels, act) for _ in range(num_after_skip)]
+ )
+
+ def reset_parameters(self):
+ for layer in self.sublayers():
+ if isinstance(layer, Linear):
+ initializer.glorot_orthogonal_(layer.weight, scale=1.0)
+ if layer.bias is not None:
+ initializer.zeros_(layer.bias)
+
+ def forward(self, x, emb_in, idx_kj, idx_ji):
+ rbf0, sbf, t = emb_in
+ x1, _ = x
+
+ x_ji = self.act(self.lin_ji(x1))
+ x_kj = self.act(self.lin_kj(x1))
+
+ rbf = self.lin_rbf1(rbf0)
+ rbf = self.lin_rbf2(rbf)
+ x_kj = x_kj * rbf
+
+ x_kj = self.act(self.lin_down(x_kj))
+
+ sbf = self.lin_sbf1(sbf)
+ sbf = self.lin_sbf2(sbf)
+ x_kj = x_kj[idx_kj] * sbf
+
+ t = self.lin_t1(t)
+ t = self.lin_t2(t)
+ x_kj = x_kj * t
+
+ x_kj = scatter_sum(x_kj, idx_ji, dim=0, dim_size=x1.shape[0])
+ x_kj = self.act(self.lin_up(x_kj))
+
+ e1 = x_ji + x_kj
+ for layer in self.layers_before_skip:
+ e1 = layer(e1)
+ e1 = self.act(self.lin(e1)) + x1
+ for layer in self.layers_after_skip:
+ e1 = layer(e1)
+ e2 = self.lin_rbf(rbf0) * e1
+ return e1, e2
+
+
+class NodeUpdate(paddle.nn.Layer):
+ def __init__(
+ self,
+ hidden_channels,
+ out_emb_channels,
+ out_channels,
+ num_output_layers,
+ act,
+ output_init,
+ ):
+ super().__init__()
+ self.act = act
+ self.output_init = output_init
+
+ self.lin_up = Linear(hidden_channels, out_emb_channels, bias_attr=True)
+ self.lins = nn.LayerList()
+ for _ in range(num_output_layers):
+ self.lins.append(Linear(out_emb_channels, out_emb_channels))
+ self.lin = Linear(out_emb_channels, out_channels, bias_attr=False)
+
+ def reset_parameters(self):
+ initializer.glorot_orthogonal_(self.lin_up.weight, scale=1.0)
+ for lin in self.lins:
+ initializer.glorot_orthogonal_(lin.weight, scale=1.0)
+ initializer.zeros_(lin.bias)
+ if self.output_init == "zeros":
+ initializer.zeros_(self.lin.weight)
+ if self.output_init == "GlorotOrthogonal":
+ initializer.glorot_orthogonal_(self.lin.weight, scale=1.0)
+
+ def forward(self, e, i, dim_size=None):
+ _, e2 = e
+ v = scatter_sum(e2, i, dim=0, dim_size=dim_size)
+ v = self.lin_up(v)
+ for lin in self.lins:
+ v = self.act(lin(v))
+ v = self.lin(v)
+ return v
+
+
+class SphereNet(paddle.nn.Layer):
+ """Spherical Message Passing for 3D molecular graph tasks.
+
+ This class follows the PaddleMaterials model protocol directly: ``forward``
+ accepts a batch dict and returns ``loss_dict`` / ``pred_dict``. Core
+ tensor computation is handled by ``_forward``.
+ """
+
+ def __init__(
+ self,
+ energy_and_force=False,
+ cutoff=5.0,
+ num_layers=4,
+ hidden_channels=128,
+ out_channels=1,
+ int_emb_size=64,
+ basis_emb_size_dist=8,
+ basis_emb_size_angle=8,
+ basis_emb_size_torsion=8,
+ out_emb_channels=256,
+ num_spherical=7,
+ num_radial=6,
+ envelope_exponent=5,
+ num_before_skip=1,
+ num_after_skip=2,
+ num_output_layers=3,
+ act="swish",
+ output_init="GlorotOrthogonal",
+ use_node_features=True,
+ use_extra_node_feature=False,
+ extra_node_feature_dim=1,
+ property_name="mu",
+ force_key="force",
+ ):
+ super().__init__()
+
+ act_fn = silu if act in ("swish", "silu") else act
+ if not callable(act_fn):
+ raise ValueError(f"Unsupported activation: {act}")
+
+ self.energy_and_force = energy_and_force
+ self.use_extra_node_feature = use_extra_node_feature
+ self.property_name = property_name
+ self.force_key = force_key
+
+ if use_extra_node_feature:
+ self.extra_emb = Linear(extra_node_feature_dim, hidden_channels)
+
+ self.init_e = InitialEdgeEmbedding(
+ num_radial,
+ hidden_channels,
+ act_fn,
+ use_node_features=use_node_features,
+ use_extra_node_feature=use_extra_node_feature,
+ )
+ node_update_cfg = {
+ "hidden_channels": hidden_channels,
+ "out_emb_channels": out_emb_channels,
+ "out_channels": out_channels,
+ "num_output_layers": num_output_layers,
+ "act": act_fn,
+ "output_init": output_init,
+ }
+ self.init_v = NodeUpdate(**node_update_cfg)
+ self.emb_layer = SphereNetEmbedding(
+ num_spherical, num_radial, cutoff, envelope_exponent
+ )
+
+ self.update_vs = nn.LayerList(
+ [NodeUpdate(**node_update_cfg) for _ in range(num_layers)]
+ )
+
+ self.update_es = nn.LayerList(
+ [
+ EdgeUpdate(
+ hidden_channels,
+ int_emb_size,
+ basis_emb_size_dist,
+ basis_emb_size_angle,
+ basis_emb_size_torsion,
+ num_spherical,
+ num_radial,
+ num_before_skip,
+ num_after_skip,
+ act_fn,
+ )
+ for _ in range(num_layers)
+ ]
+ )
+
+ self.reset_parameters()
+
+ def reset_parameters(self):
+ if self.use_extra_node_feature:
+ initializer.glorot_orthogonal_(self.extra_emb.weight, scale=1.0)
+ initializer.zeros_(self.extra_emb.bias)
+ layers = [
+ self.init_e,
+ self.init_v,
+ self.emb_layer,
+ *self.update_es,
+ *self.update_vs,
+ ]
+ for layer in layers:
+ layer.reset_parameters()
+
+ def _forward(self, data):
+ graph = data["graph"].tensor()
+ z = graph.node_feat["atomic_number"].astype("int64").reshape([-1])
+ pos = graph.node_feat["pos"].astype(paddle.get_default_dtype())
+ if self.energy_and_force:
+ pos = pos.detach()
+ pos.stop_gradient = False
+
+ node_batch = graph.graph_node_id.astype("int64")
+ edge_index = paddle.transpose(graph.edges.astype("int64"), [1, 0])
+ node_feature = graph.node_feat.get("node_feature")
+ triplet_indices = {
+ "idx_kj": graph.edge_feat["ti_idx_kj"].astype("int64"),
+ "idx_ji": graph.edge_feat["ti_idx_ji"].astype("int64"),
+ "idx_lk": graph.edge_feat["ti_idx_lk"].astype("int64"),
+ "idx_triplet": graph.edge_feat["ti_idx_triplet"].astype("int64"),
+ }
+
+ if self.use_extra_node_feature and node_feature is not None:
+ extra_node_feature = self.extra_emb(node_feature)
+ else:
+ extra_node_feature = None
+
+ num_nodes = z.shape[0]
+ dist, angle, torsion, i, j, idx_kj, idx_ji = compute_geometry(
+ pos, edge_index, triplet_indices
+ )
+
+ emb_out = self.emb_layer(dist, angle, torsion, idx_kj)
+
+ e = self.init_e(z, extra_node_feature, emb_out, i, j)
+ v = self.init_v(e, i, dim_size=num_nodes)
+ u = scatter_sum(v, node_batch, dim=0)
+
+ for update_e, update_v in zip(self.update_es, self.update_vs):
+ e = update_e(e, emb_out, idx_kj, idx_ji)
+ v = update_v(e, i, dim_size=num_nodes)
+ u = u + scatter_sum(v, node_batch, dim=0)
+
+ return u, pos
+
+ def forward(self, data, return_loss=True, return_prediction=True):
+ """Forward with the PaddleMaterials dict interface."""
+ assert (
+ return_loss or return_prediction
+ ), "At least one of return_loss or return_prediction must be True."
+
+ pred, pos = self._forward(data)
+
+ forces_pred = None
+ if self.energy_and_force:
+ grad = paddle.grad(pred.sum(), pos, create_graph=False, allow_unused=True)
+ if grad is not None and grad[0] is not None:
+ forces_pred = -grad[0]
+
+ loss_dict = {}
+ if return_loss:
+ label = data[self.property_name]
+ label_tensor = (
+ label.astype(paddle.get_default_dtype())
+ if isinstance(label, paddle.Tensor)
+ else paddle.to_tensor(label, dtype=paddle.get_default_dtype())
+ )
+ loss = paddle.nn.functional.l1_loss(pred, label_tensor)
+ loss_dict["loss"] = loss
+
+ if self.energy_and_force and forces_pred is not None:
+ force = data[self.force_key]
+ force_tensor = (
+ force.astype(paddle.get_default_dtype())
+ if isinstance(force, paddle.Tensor)
+ else paddle.to_tensor(force, dtype=paddle.get_default_dtype())
+ )
+ force_loss = paddle.nn.functional.l1_loss(forces_pred, force_tensor)
+ loss_dict["loss"] = loss + force_loss
+
+ prediction = {}
+ if return_prediction:
+ prediction[self.property_name] = pred
+ if self.energy_and_force:
+ if forces_pred is not None:
+ prediction[self.force_key] = forces_pred.detach()
+ else:
+ prediction[self.force_key] = paddle.zeros_like(pos)
+
+ return {"loss_dict": loss_dict, "pred_dict": prediction}
+
+ def predict(self, graphs):
+ """Inference interface for batch dicts or PGL graphs."""
+ if isinstance(graphs, list):
+ return [self.predict(graph) for graph in graphs]
+
+ data = graphs if isinstance(graphs, dict) else {"graph": graphs}
+ result = self.forward(data, return_loss=False, return_prediction=True)
+ return {
+ key: value.numpy() if isinstance(value, paddle.Tensor) else value
+ for key, value in result["pred_dict"].items()
+ }
diff --git a/ppmat/utils/scatter.py b/ppmat/utils/scatter.py
index b5de6495..db055487 100644
--- a/ppmat/utils/scatter.py
+++ b/ppmat/utils/scatter.py
@@ -31,6 +31,31 @@ def _broadcast(src: paddle.Tensor, other: paddle.Tensor, dim: int):
return src
+def scatter_argmin(
+ src: paddle.Tensor,
+ index: paddle.Tensor,
+ dim_size: Optional[int] = None,
+) -> paddle.Tensor:
+ """Return the source index of the minimum value in each group.
+
+ ``src`` and ``index`` must be one-dimensional. Empty groups are assigned
+ ``-1``. Ties are resolved by selecting the first occurrence in ``src``.
+ """
+ if src.ndim != 1 or index.ndim != 1 or src.shape[0] != index.shape[0]:
+ raise ValueError("src and index must be one-dimensional with equal length")
+
+ if dim_size is None:
+ dim_size = 0 if index.shape[0] == 0 else int(index.max()) + 1
+
+ out = paddle.full([dim_size], -1, dtype="int64")
+ if index.shape[0] == 0:
+ return out
+
+ order = paddle.argsort(src, stable=True)
+ groups, first = paddle.unique(index[order], return_index=True)
+ return paddle.scatter(out, groups, order[first], overwrite=True)
+
+
def _scatter_sum(
src: paddle.Tensor,
index: paddle.Tensor,
@@ -48,9 +73,18 @@ def _scatter_sum(
else:
size[dim] = int(index.max()) + 1
out = paddle.zeros(size, dtype=src.dtype)
- return paddle.put_along_axis(
- arr=out, indices=index, values=src, axis=dim, reduce="add"
- )
+ # FIXME: Paddle's put_along_axis backward (PutAlongAxisGradNode) crashes
+ # for dim=0; use one-hot + matmul as drop-in replacement.
+ if dim == 0:
+ # _broadcast expanded index to src.shape; collapse back to 1D via first column
+ idx_1d = index.reshape([-1, src.shape[1]])[:, 0] if index.ndim > 1 else index
+ one_hot = paddle.nn.functional.one_hot(idx_1d, out.shape[0]).cast(src.dtype)
+ # one_hot: [N, out_dim] -> [out_dim, N] @ [N, C] = [out_dim, C]
+ return paddle.mm(one_hot.t(), src)
+ else:
+ return paddle.put_along_axis(
+ arr=out, indices=index, values=src, axis=dim, reduce="add"
+ )
def _scatter_mean(
@@ -80,6 +114,28 @@ def _scatter_mean(
return out
+def _scatter_min(
+ src: paddle.Tensor,
+ index: paddle.Tensor,
+ dim: int = -1,
+ out: Optional[paddle.Tensor] = None,
+ dim_size: Optional[int] = None,
+) -> paddle.Tensor:
+ index = _broadcast(index, src, dim)
+ if out is None:
+ size = list(src.shape)
+ if dim_size is not None:
+ size[dim] = dim_size
+ elif index.numel() == 0:
+ size[dim] = 0
+ else:
+ size[dim] = int(index.max()) + 1
+ out = paddle.full(size, float("inf"), dtype=src.dtype)
+ return paddle.put_along_axis(
+ arr=out, indices=index, values=src, axis=dim, reduce="amin"
+ )
+
+
def scatter(
src: paddle.Tensor,
index: paddle.Tensor,
@@ -95,8 +151,10 @@ def scatter(
return _scatter_sum(src, index, dim, out, dim_size)
elif reduce == "mean":
return _scatter_mean(src, index, dim, out, dim_size)
+ elif reduce == "min":
+ return _scatter_min(src, index, dim, out, dim_size)
else:
- raise ValueError("Only support add or mean")
+ raise ValueError("Only support add, mean, or min")
def scatter_mean(
diff --git a/ppmat/utils/tests/test_scatter.py b/ppmat/utils/tests/test_scatter.py
new file mode 100644
index 00000000..0d4b92ac
--- /dev/null
+++ b/ppmat/utils/tests/test_scatter.py
@@ -0,0 +1,45 @@
+# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import numpy as np
+import paddle
+
+from ppmat.utils.scatter import scatter_argmin
+
+
+def test_scatter_argmin_handles_unsorted_and_empty_groups():
+ values = paddle.to_tensor([3.0, -2.0, 4.0, -5.0, 1.0])
+ groups = paddle.to_tensor([2, 0, 2, 0, 2], dtype="int64")
+
+ result = scatter_argmin(values, groups, dim_size=4)
+
+ np.testing.assert_array_equal(result.numpy(), [3, -1, 4, -1])
+
+
+def test_scatter_argmin_selects_first_value_on_ties():
+ values = paddle.to_tensor([2.0, 1.0, 1.0, 3.0])
+ groups = paddle.to_tensor([0, 0, 0, 1], dtype="int64")
+
+ result = scatter_argmin(values, groups)
+
+ np.testing.assert_array_equal(result.numpy(), [1, 3])
+
+
+def test_scatter_argmin_handles_empty_input():
+ values = paddle.empty([0], dtype="float32")
+ groups = paddle.empty([0], dtype="int64")
+
+ result = scatter_argmin(values, groups, dim_size=3)
+
+ np.testing.assert_array_equal(result.numpy(), [-1, -1, -1])
diff --git a/property_prediction/README.md b/property_prediction/README.md
index 4d3e265e..5bef54bb 100644
--- a/property_prediction/README.md
+++ b/property_prediction/README.md
@@ -6,39 +6,53 @@ Property Prediction (PP) targets rapid, first-principles-level estimation of key
## 2.Models Matrix
-| **Supported Functions** | **[MEGNet](./configs/megnet/README.md)** | **[Comfomer](./configs/comformer/README.md)** | **GemNet** | **[DimeNet++](./configs/dimenet++/README.md)** |
-| -------------------------------------------- | :--------------------------------------: | :-------------------------------------------: | :--------: | :--------------------------------------------: |
-| **Forward Prediction · Materials Properties**| | | | |
-| Formation energy | ✅ | ✅ | 🚧 | ✅ |
-| Band gap | ✅ | ✅ | 🚧 | ✅ |
-| Bulk modulus | ✅ | ✅ | 🚧 | ✅ |
-| Shear modulus | ✅ | ✅ | 🚧 | ✅ |
-| Young’s modulus | ✅ | ✅ | 🚧 | ✅ |
-| Adsorption energy | 🚧 | 🚧 | 🚧 | 🚧 |
-| Electron density | — | — | — | — |
-| **ML Capabilities · Training** | | | | |
-| Single-GPU | ✅ | ✅ | 🚧 | ✅ |
-| Distributed training | ✅ | ✅ | 🚧 | ✅ |
-| Mixed precision (AMP) | — | — | — | — |
-| Fine-tuning | ✅ | ✅ | 🚧 | ✅ |
-| Uncertainty / Active Learning | — | — | — | — |
-| Dynamic→Static graphs | — | — | — | — |
-| Compiler (CINN) opt. | — | — | — | — |
-| **ML Capabilities · Predict** | | | | |
-| Distillation / Pruning | — | — | — | — |
-| Standard inference | ✅ | ✅ | 🚧 | ✅ |
-| Distributed inference | — | — | — | — |
-| Compiler-level inference | — | — | — | — |
-| **Datasets** | | | | |
-| **Materials Project** | | | | |
-| MP2024 | ✅ | ✅ | — | — |
-| MP2020 | ✅ | ✅ | — | — |
-| MP2018 | ✅ | ✅ | 🚧 | — |
-| **JARVIS** | | | | |
-| dft_2d | ✅ | ✅ | — | ✅ |
-| dft_3d | ✅ | ✅ | — | — |
-| **Alexandria** | | | | |
-| pbe_2d | ✅ | ✅ | 🚧 | — |
-| **ML2DDB🌟** | ✅ | ✅ | ✅ | ✅ |
+| **Supported Functions** | **[MEGNet](./configs/megnet/README.md)** | **[Comfomer](./configs/comformer/README.md)** | **[DimeNet++](./configs/dimenet++/README.md)** | **[SphereNet](./configs/spherenet/README.md)** |
+| -------------------------------------------- | :--------------------------------------: | :-------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
+| **Forward Prediction · Materials Properties**| | | | |
+| Formation energy | ✅ | ✅ | ✅ | — |
+| Band gap | ✅ | ✅ | ✅ | — |
+| Bulk modulus | ✅ | ✅ | ✅ | — |
+| Shear modulus | ✅ | ✅ | ✅ | — |
+| Young’s modulus | ✅ | ✅ | ✅ | — |
+| Adsorption energy | 🚧 | 🚧 | 🚧 | — |
+| **Forward Prediction · Molecular Properties**| | | | |
+| $\mu$ (dipole moment) | — | — | — | ✅ |
+| $\alpha$ (isotropic polarizability) | — | — | — | ✅ |
+| $\varepsilon_{\text{HOMO}}$ | — | — | — | ✅ |
+| $\varepsilon_{\text{LUMO}}$ | — | — | — | ✅ |
+| $\Delta\varepsilon$ (HOMO-LUMO gap) | — | — | — | ✅ |
+| $\langle R^2 \rangle$ (electronic spatial extent) | — | — | — | ✅ |
+| ZPVE (zero-point vibrational energy) | — | — | — | ✅ |
+| $U_0$ (internal energy at 0 K) | — | — | — | ✅ |
+| $U$ (internal energy at 298.15 K) | — | — | — | ✅ |
+| $H$ (enthalpy at 298.15 K) | — | — | — | ✅ |
+| $G$ (free energy at 298.15 K) | — | — | — | ✅ |
+| $C_v$ (heat capacity) | — | — | — | ✅ |
+| **ML Capabilities · Training** | | | | |
+| Single-GPU | ✅ | ✅ | ✅ | ✅ |
+| Distributed training | ✅ | ✅ | ✅ | — |
+| Mixed precision (AMP) | — | — | — | — |
+| Fine-tuning | ✅ | ✅ | ✅ | 🚧 |
+| Uncertainty / Active Learning | — | — | — | — |
+| Dynamic→Static graphs | — | — | — | — |
+| Compiler (CINN) opt. | — | — | — | — |
+| **ML Capabilities · Predict** | | | | |
+| Distillation / Pruning | — | — | — | — |
+| Standard inference | ✅ | ✅ | ✅ | ✅ |
+| Distributed inference | — | — | — | — |
+| Compiler-level inference | — | — | — | — |
+| **Datasets** | | | | |
+| **Materials Project** | | | | |
+| MP2024 | ✅ | ✅ | — | — |
+| MP2020 | ✅ | ✅ | — | — |
+| MP2018 | ✅ | ✅ | — | — |
+| **JARVIS** | | | | |
+| dft_2d | ✅ | ✅ | ✅ | — |
+| dft_3d | ✅ | ✅ | — | — |
+| **Alexandria** | | | | |
+| pbe_2d | ✅ | ✅ | — | — |
+| **ML2DDB🌟** | ✅ | ✅ | ✅ | — |
+| **QM9** | — | — | ✅ | ✅ |
+| **MD17** | — | — | — | ✅ |
**Notice**:🌟 represent originate research work published from paddlematerials toolkit
diff --git a/property_prediction/__init__.py b/property_prediction/__init__.py
new file mode 100644
index 00000000..c0bee9ea
--- /dev/null
+++ b/property_prediction/__init__.py
@@ -0,0 +1 @@
+"""Public property-prediction interfaces."""
diff --git a/property_prediction/configs/gmtnet/README.md b/property_prediction/configs/gmtnet/README.md
new file mode 100644
index 00000000..a194cbec
--- /dev/null
+++ b/property_prediction/configs/gmtnet/README.md
@@ -0,0 +1,79 @@
+# GMTNet dielectric prediction
+
+This configuration integrates GMTNet into the PaddleMaterials public
+property-prediction path. GMTNet predicts a `3 x 3` dielectric tensor from a
+crystal structure.
+
+## Data and training
+
+Verify or reproduce the fixed-seed split for the normalized JARVIS dielectric
+dataset with the repository tool:
+
+```bash
+python -m ppmat.datasets.split_gmtnet_dataset --help
+```
+
+The canonical split is
+`property_prediction/configs/gmtnet/split_gmtnet_dielectric_seed32.json` and
+is installed as a `property_prediction` package resource. Use the public
+trainer entry point and this configuration for training or evaluation; the
+former standalone synthetic `train.py` smoke script is not a public GMTNet
+workflow. The YAML relies on `GMTNetDielectricDataset`'s resource fallback;
+callers can still pass an explicit `split_path`. Checkpoints are deliberately
+not committed to Git.
+
+```bash
+python property_prediction/train.py \
+ -c property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml
+```
+
+GMTNet's inference path requires forward gradients internally. Keep
+`Predict.eval_with_no_grad: false` for this model, even during CPU evaluation.
+
+## Converted checkpoint prediction
+
+```python
+from property_prediction.predict import PropertyPredictor
+
+predictor = PropertyPredictor(
+ config_path="property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml",
+ checkpoint_path="/path/to/gmtnet_checkpoint.pdparams",
+)
+```
+
+`predictor.from_structures(structure)` accepts one pymatgen `Structure`.
+An ordered non-empty list of structures is also supported. Low-level GMTNet
+Mapping inputs contain `graph`, `feature_mask`, and `matrix_equal` and can be
+passed to `predictor.model.predict`.
+
+## CIF contract
+
+GMTNet CIF prediction uses a model-specific precision-preserving reader:
+
+```python
+Structure.from_file(
+ path,
+ primitive=False,
+ sort=False,
+ merge_tol=0.0,
+ frac_tolerance=0.0,
+)
+```
+
+No primitive/conventional-cell conversion, site sort, site merge, or parser
+coordinate idealization is applied. The output tensor is expressed in the
+Cartesian frame of the parsed CIF structure. A CIF cannot recover floating
+point coordinates that were not encoded in its text.
+
+For a CIF directory, all files are parsed before any prediction begins. A
+malformed member therefore raises without producing partial directory
+predictions. Existing directory enumeration order is retained and is not a
+lexicographic-order guarantee.
+
+## Known numerical limits
+
+The float32 edge-order candidate and rotation canonicalization candidate were
+audited but are not applied. Equivalent edge-array orders can produce small
+float32 prediction differences, and symmetry-derived constraints remain
+sensitive to numerically equivalent rotation representations. These limits do
+not alter the precision-preserving CIF parsing contract.
diff --git a/property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml b/property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml
new file mode 100644
index 00000000..c2d3e9c6
--- /dev/null
+++ b/property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml
@@ -0,0 +1,145 @@
+Global:
+ label_names: ["dielectric"]
+ do_train: true
+ do_eval: true
+ do_test: true
+
+Trainer:
+ max_epochs: 10
+ seed: 32
+ output_dir: ./output/gmtnet_dielectric
+ save_freq: 10
+ log_freq: 10
+ start_eval_epoch: 1
+ eval_freq: 1
+ pretrained_model_path: null
+ pretrained_weight_name: null
+ resume_from_checkpoint: null
+ use_amp: false
+ amp_level: "O1"
+ eval_with_no_grad: false
+ gradient_accumulation_steps: 1
+ best_metric_indicator: "eval_metric"
+ name_for_best_metric: "dielectric"
+ greater_is_better: false
+ compute_metric_during_train: true
+ metric_strategy_during_eval: "epoch"
+ use_visualdl: false
+ use_wandb: false
+ use_tensorboard: false
+
+Model:
+ __class_name__: GMTNet
+ __init_params__:
+ args:
+ atom_input_features: 92
+ edge_features: 512
+ embedding_features: 128
+ output_features: 9
+ num_layers: 2
+ target: dielectric
+ use_mask: false
+ reduce_cell: false
+ loss_cfg:
+ __class_name__: MSELoss
+ __init_params__:
+ reduction: mean
+
+Optimizer:
+ __class_name__: AdamW
+ __init_params__:
+ lr: 1.0e-5
+ weight_decay: 1.0e-5
+
+Metric:
+ dielectric:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: GMTNetDielectricDataset
+ __init_params__:
+ data_path: data/gmtnet/gmtnet_dielectric_normalized_v1.pkl
+ split: train
+ verify_sha256: true
+ build_graph_cfg:
+ __class_name__: GMTNetGraphConverter
+ __init_params__:
+ cutoff: 4.0
+ max_neighbors: 16
+ atom_features: cgcnn
+ use_canonize: true
+ reduce_cell: false
+ loader:
+ num_workers: 0
+ use_shared_memory: false
+ collate_fn: DefaultCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 1
+ shuffle: true
+ drop_last: false
+ val:
+ dataset:
+ __class_name__: GMTNetDielectricDataset
+ __init_params__:
+ data_path: data/gmtnet/gmtnet_dielectric_normalized_v1.pkl
+ split: val
+ verify_sha256: true
+ build_graph_cfg:
+ __class_name__: GMTNetGraphConverter
+ __init_params__:
+ cutoff: 4.0
+ max_neighbors: 16
+ atom_features: cgcnn
+ use_canonize: true
+ reduce_cell: false
+ loader:
+ num_workers: 0
+ use_shared_memory: false
+ collate_fn: DefaultCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 1
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: GMTNetDielectricDataset
+ __init_params__:
+ data_path: data/gmtnet/gmtnet_dielectric_normalized_v1.pkl
+ split: test
+ verify_sha256: true
+ build_graph_cfg:
+ __class_name__: GMTNetGraphConverter
+ __init_params__:
+ cutoff: 4.0
+ max_neighbors: 16
+ atom_features: cgcnn
+ use_canonize: true
+ reduce_cell: false
+ loader:
+ num_workers: 0
+ use_shared_memory: false
+ collate_fn: DefaultCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 1
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter:
+ __class_name__: GMTNetGraphConverter
+ __init_params__:
+ cutoff: 4.0
+ max_neighbors: 16
+ atom_features: cgcnn
+ use_canonize: true
+ reduce_cell: false
+ eval_with_no_grad: false
diff --git a/property_prediction/configs/gmtnet/split_gmtnet_dielectric_seed32.json b/property_prediction/configs/gmtnet/split_gmtnet_dielectric_seed32.json
new file mode 100644
index 00000000..c67400f5
--- /dev/null
+++ b/property_prediction/configs/gmtnet/split_gmtnet_dielectric_seed32.json
@@ -0,0 +1,4739 @@
+{
+ "generation_method": "torch.utils.data.random_split",
+ "normalized_dataset_sha256": "eb0b9516c937575afe3f20a0f88953724abfcde6de07c4af15248468598b349f",
+ "normalized_schema_version": 1,
+ "num_records": 4713,
+ "schema_version": 1,
+ "seed": 32,
+ "source_original_dataset_sha256": "5a2198f51f4a7f9aa26fa6be60ed65db0ecc0a13646d63399e897b8168dcbb4d",
+ "split_indices_sha256": {
+ "test": "23bf024dede3d367130dbf74fb0d4f5e70e4aaaacf68172ceca1d7586b903470",
+ "train": "6fc27a45d6c911bc4df0b6687bdb0e38fe825d0aed7b8dc4c912afb458857010",
+ "val": "3907ab990e5202ede42d044bf6eab47b2c7f1280688f3d3ae082b416fc7acb11"
+ },
+ "split_sizes": {
+ "test": 472,
+ "train": 3770,
+ "val": 471
+ },
+ "test_indices": [
+ 747,
+ 1423,
+ 1322,
+ 856,
+ 2521,
+ 2188,
+ 2308,
+ 1878,
+ 3544,
+ 653,
+ 1098,
+ 2646,
+ 178,
+ 3712,
+ 1179,
+ 59,
+ 3714,
+ 3559,
+ 1378,
+ 1494,
+ 3844,
+ 4596,
+ 4476,
+ 2955,
+ 1095,
+ 1340,
+ 3425,
+ 475,
+ 2949,
+ 3471,
+ 2502,
+ 120,
+ 966,
+ 1320,
+ 3403,
+ 3625,
+ 501,
+ 185,
+ 4304,
+ 247,
+ 283,
+ 3091,
+ 3348,
+ 710,
+ 556,
+ 1188,
+ 3331,
+ 646,
+ 801,
+ 2979,
+ 2432,
+ 3701,
+ 3852,
+ 3684,
+ 3334,
+ 988,
+ 1455,
+ 156,
+ 1590,
+ 1005,
+ 3375,
+ 3459,
+ 3927,
+ 4710,
+ 3096,
+ 2583,
+ 4302,
+ 3101,
+ 3823,
+ 3258,
+ 1897,
+ 1499,
+ 269,
+ 1717,
+ 146,
+ 1457,
+ 1913,
+ 4677,
+ 3915,
+ 2246,
+ 1584,
+ 3579,
+ 3965,
+ 281,
+ 1651,
+ 2928,
+ 4120,
+ 2957,
+ 1012,
+ 1099,
+ 3434,
+ 2057,
+ 3063,
+ 667,
+ 1567,
+ 2100,
+ 2916,
+ 4042,
+ 3156,
+ 734,
+ 754,
+ 623,
+ 96,
+ 2985,
+ 571,
+ 1357,
+ 384,
+ 1183,
+ 1995,
+ 2317,
+ 2433,
+ 2913,
+ 4033,
+ 4693,
+ 3338,
+ 301,
+ 491,
+ 1483,
+ 2679,
+ 3937,
+ 4196,
+ 4568,
+ 4188,
+ 1863,
+ 2343,
+ 4634,
+ 4419,
+ 2486,
+ 3468,
+ 1276,
+ 3699,
+ 4254,
+ 2518,
+ 1555,
+ 2818,
+ 762,
+ 3185,
+ 2755,
+ 335,
+ 4122,
+ 601,
+ 4171,
+ 4390,
+ 4108,
+ 4444,
+ 22,
+ 89,
+ 497,
+ 2167,
+ 3969,
+ 3330,
+ 2726,
+ 3647,
+ 1245,
+ 4541,
+ 3076,
+ 392,
+ 4704,
+ 2754,
+ 815,
+ 2161,
+ 1168,
+ 1380,
+ 2825,
+ 640,
+ 2748,
+ 1094,
+ 196,
+ 1079,
+ 2908,
+ 3443,
+ 170,
+ 4566,
+ 777,
+ 943,
+ 954,
+ 2187,
+ 3437,
+ 2284,
+ 3780,
+ 2402,
+ 1299,
+ 663,
+ 1629,
+ 1323,
+ 3901,
+ 969,
+ 1923,
+ 2254,
+ 4208,
+ 3382,
+ 1238,
+ 1283,
+ 4089,
+ 1065,
+ 3648,
+ 1953,
+ 2736,
+ 3159,
+ 2903,
+ 1634,
+ 3855,
+ 4386,
+ 2963,
+ 4610,
+ 1614,
+ 2654,
+ 1068,
+ 2385,
+ 375,
+ 4015,
+ 2013,
+ 4412,
+ 2808,
+ 1026,
+ 3030,
+ 4308,
+ 191,
+ 236,
+ 1515,
+ 2633,
+ 3896,
+ 1613,
+ 760,
+ 2466,
+ 4101,
+ 2429,
+ 396,
+ 530,
+ 4350,
+ 336,
+ 3929,
+ 4462,
+ 2554,
+ 3737,
+ 3970,
+ 4531,
+ 85,
+ 2665,
+ 3781,
+ 3871,
+ 496,
+ 362,
+ 583,
+ 3821,
+ 1550,
+ 3591,
+ 223,
+ 4315,
+ 1571,
+ 3733,
+ 2636,
+ 855,
+ 2552,
+ 3848,
+ 280,
+ 2638,
+ 722,
+ 4446,
+ 2769,
+ 3390,
+ 4130,
+ 458,
+ 2860,
+ 3082,
+ 2293,
+ 1083,
+ 4373,
+ 2288,
+ 91,
+ 1233,
+ 235,
+ 346,
+ 3804,
+ 1663,
+ 665,
+ 244,
+ 3151,
+ 112,
+ 2223,
+ 87,
+ 3492,
+ 133,
+ 4555,
+ 4261,
+ 1165,
+ 3458,
+ 2471,
+ 1730,
+ 681,
+ 1531,
+ 3635,
+ 2040,
+ 1794,
+ 3668,
+ 2833,
+ 3809,
+ 961,
+ 24,
+ 1197,
+ 542,
+ 382,
+ 3549,
+ 4367,
+ 1566,
+ 3343,
+ 2355,
+ 1067,
+ 3790,
+ 1089,
+ 317,
+ 2264,
+ 67,
+ 3496,
+ 2899,
+ 763,
+ 1387,
+ 4553,
+ 1223,
+ 3046,
+ 1498,
+ 4563,
+ 4247,
+ 2737,
+ 4493,
+ 1981,
+ 332,
+ 3165,
+ 1689,
+ 605,
+ 1404,
+ 4025,
+ 271,
+ 3876,
+ 506,
+ 1767,
+ 2711,
+ 3097,
+ 3488,
+ 2143,
+ 1756,
+ 4570,
+ 4486,
+ 714,
+ 441,
+ 2992,
+ 4323,
+ 3181,
+ 2062,
+ 1670,
+ 2468,
+ 3463,
+ 2109,
+ 1894,
+ 1101,
+ 635,
+ 189,
+ 3012,
+ 2602,
+ 1226,
+ 2251,
+ 4552,
+ 2587,
+ 474,
+ 2259,
+ 3016,
+ 4509,
+ 357,
+ 807,
+ 4543,
+ 4193,
+ 3910,
+ 897,
+ 3213,
+ 4626,
+ 3585,
+ 507,
+ 852,
+ 3342,
+ 842,
+ 4206,
+ 1540,
+ 3995,
+ 1843,
+ 4005,
+ 1022,
+ 4199,
+ 894,
+ 4522,
+ 951,
+ 4646,
+ 502,
+ 827,
+ 2031,
+ 3588,
+ 1800,
+ 3044,
+ 3237,
+ 1935,
+ 2830,
+ 1845,
+ 4569,
+ 3278,
+ 720,
+ 3374,
+ 697,
+ 3248,
+ 2898,
+ 2487,
+ 155,
+ 459,
+ 4177,
+ 3750,
+ 4702,
+ 293,
+ 2612,
+ 1078,
+ 2048,
+ 214,
+ 4179,
+ 4104,
+ 536,
+ 719,
+ 2533,
+ 3064,
+ 2410,
+ 3084,
+ 4292,
+ 2342,
+ 2353,
+ 3601,
+ 1112,
+ 2785,
+ 832,
+ 208,
+ 162,
+ 310,
+ 3863,
+ 4106,
+ 4070,
+ 533,
+ 3893,
+ 2083,
+ 4542,
+ 1699,
+ 4429,
+ 203,
+ 3771,
+ 2799,
+ 1548,
+ 4141,
+ 1868,
+ 1914,
+ 493,
+ 3355,
+ 1208,
+ 2959,
+ 795,
+ 1057,
+ 2765,
+ 3045,
+ 1196,
+ 1150,
+ 2988,
+ 3866,
+ 1536,
+ 2140,
+ 75,
+ 4249,
+ 2089,
+ 1696,
+ 1558
+ ],
+ "torch_version": "2.6.0+cu124",
+ "train_indices": [
+ 3244,
+ 3052,
+ 3259,
+ 1107,
+ 3797,
+ 567,
+ 216,
+ 258,
+ 494,
+ 4272,
+ 535,
+ 1570,
+ 2616,
+ 1782,
+ 766,
+ 3038,
+ 1775,
+ 3252,
+ 3327,
+ 2572,
+ 2570,
+ 2131,
+ 1237,
+ 2049,
+ 767,
+ 4075,
+ 2067,
+ 3581,
+ 3155,
+ 3961,
+ 4580,
+ 4088,
+ 2331,
+ 2706,
+ 2306,
+ 70,
+ 29,
+ 1707,
+ 4093,
+ 4515,
+ 1824,
+ 35,
+ 1684,
+ 1866,
+ 2730,
+ 3264,
+ 3164,
+ 33,
+ 4203,
+ 2871,
+ 3184,
+ 2493,
+ 2174,
+ 1274,
+ 3103,
+ 2677,
+ 1202,
+ 1019,
+ 3203,
+ 409,
+ 3373,
+ 2126,
+ 1481,
+ 1108,
+ 3484,
+ 3224,
+ 3795,
+ 3550,
+ 3095,
+ 2828,
+ 3765,
+ 1906,
+ 4276,
+ 875,
+ 2363,
+ 3174,
+ 2035,
+ 2921,
+ 3347,
+ 2055,
+ 1650,
+ 755,
+ 1405,
+ 3862,
+ 1232,
+ 211,
+ 2781,
+ 2365,
+ 3047,
+ 3570,
+ 328,
+ 2329,
+ 2943,
+ 1719,
+ 1048,
+ 3553,
+ 1049,
+ 1400,
+ 3690,
+ 3691,
+ 2930,
+ 2891,
+ 1427,
+ 4348,
+ 4572,
+ 2104,
+ 1062,
+ 3711,
+ 49,
+ 1192,
+ 153,
+ 1597,
+ 1572,
+ 352,
+ 3154,
+ 1045,
+ 3742,
+ 2403,
+ 403,
+ 1187,
+ 2856,
+ 1937,
+ 2382,
+ 4538,
+ 2483,
+ 2253,
+ 4707,
+ 69,
+ 360,
+ 1146,
+ 804,
+ 2610,
+ 2125,
+ 197,
+ 4651,
+ 2549,
+ 46,
+ 4336,
+ 1395,
+ 1587,
+ 1778,
+ 3043,
+ 1811,
+ 1109,
+ 3938,
+ 1313,
+ 4098,
+ 3722,
+ 2310,
+ 2700,
+ 4147,
+ 1159,
+ 31,
+ 2178,
+ 2966,
+ 92,
+ 4482,
+ 521,
+ 3739,
+ 3436,
+ 1044,
+ 2206,
+ 3730,
+ 3541,
+ 585,
+ 2299,
+ 882,
+ 2581,
+ 4151,
+ 4266,
+ 4473,
+ 3515,
+ 1672,
+ 510,
+ 1033,
+ 2300,
+ 2862,
+ 4103,
+ 2182,
+ 3048,
+ 1338,
+ 1267,
+ 1724,
+ 3913,
+ 79,
+ 1462,
+ 848,
+ 4311,
+ 4125,
+ 364,
+ 1693,
+ 3759,
+ 2298,
+ 4394,
+ 837,
+ 1627,
+ 3612,
+ 2227,
+ 2366,
+ 2164,
+ 3104,
+ 204,
+ 1151,
+ 1871,
+ 2747,
+ 2811,
+ 3592,
+ 407,
+ 2337,
+ 1559,
+ 2990,
+ 3251,
+ 3324,
+ 1591,
+ 11,
+ 2105,
+ 4405,
+ 3394,
+ 4212,
+ 262,
+ 4558,
+ 4061,
+ 3299,
+ 4485,
+ 3836,
+ 3903,
+ 1161,
+ 4331,
+ 2361,
+ 2387,
+ 2912,
+ 3410,
+ 972,
+ 973,
+ 2504,
+ 1968,
+ 677,
+ 4508,
+ 2271,
+ 718,
+ 2037,
+ 2460,
+ 3476,
+ 2794,
+ 3417,
+ 787,
+ 4230,
+ 1001,
+ 119,
+ 560,
+ 401,
+ 620,
+ 1411,
+ 4099,
+ 1989,
+ 1793,
+ 3968,
+ 520,
+ 4584,
+ 3829,
+ 4094,
+ 3603,
+ 4111,
+ 2200,
+ 184,
+ 1886,
+ 485,
+ 3747,
+ 4502,
+ 694,
+ 1785,
+ 3041,
+ 1787,
+ 4401,
+ 761,
+ 477,
+ 3485,
+ 2279,
+ 461,
+ 1174,
+ 4687,
+ 999,
+ 1628,
+ 4271,
+ 3227,
+ 4384,
+ 831,
+ 4062,
+ 1144,
+ 1851,
+ 975,
+ 3802,
+ 377,
+ 32,
+ 2670,
+ 3982,
+ 1796,
+ 3306,
+ 1105,
+ 2144,
+ 3015,
+ 1618,
+ 3596,
+ 2738,
+ 776,
+ 2129,
+ 3514,
+ 3607,
+ 4045,
+ 4190,
+ 2698,
+ 228,
+ 237,
+ 4215,
+ 1449,
+ 3646,
+ 3746,
+ 3629,
+ 4016,
+ 2377,
+ 95,
+ 4643,
+ 2275,
+ 1988,
+ 2154,
+ 2065,
+ 711,
+ 1519,
+ 4004,
+ 470,
+ 996,
+ 492,
+ 3621,
+ 2553,
+ 3346,
+ 132,
+ 312,
+ 3675,
+ 1885,
+ 4270,
+ 3308,
+ 3560,
+ 633,
+ 2540,
+ 4607,
+ 4066,
+ 2822,
+ 1042,
+ 54,
+ 4250,
+ 547,
+ 2688,
+ 445,
+ 3397,
+ 909,
+ 222,
+ 1593,
+ 2448,
+ 1394,
+ 4507,
+ 1173,
+ 3637,
+ 599,
+ 115,
+ 2805,
+ 1733,
+ 4447,
+ 2690,
+ 939,
+ 4174,
+ 2563,
+ 1945,
+ 574,
+ 4518,
+ 2147,
+ 1304,
+ 200,
+ 1899,
+ 775,
+ 4418,
+ 576,
+ 2074,
+ 4213,
+ 3627,
+ 3148,
+ 4055,
+ 3088,
+ 2106,
+ 537,
+ 2536,
+ 2831,
+ 4423,
+ 201,
+ 463,
+ 611,
+ 2199,
+ 652,
+ 0,
+ 657,
+ 1252,
+ 1358,
+ 4277,
+ 2053,
+ 1617,
+ 435,
+ 2027,
+ 2629,
+ 2376,
+ 2579,
+ 4624,
+ 254,
+ 1491,
+ 4635,
+ 3239,
+ 4698,
+ 291,
+ 3740,
+ 896,
+ 1305,
+ 4267,
+ 1757,
+ 1364,
+ 159,
+ 3431,
+ 1944,
+ 1747,
+ 3037,
+ 1640,
+ 198,
+ 1537,
+ 1495,
+ 2444,
+ 3123,
+ 3872,
+ 2364,
+ 2606,
+ 1341,
+ 1125,
+ 2177,
+ 2590,
+ 4370,
+ 3911,
+ 3897,
+ 3365,
+ 1583,
+ 3539,
+ 2139,
+ 1947,
+ 3816,
+ 2237,
+ 3479,
+ 221,
+ 3408,
+ 255,
+ 2756,
+ 3460,
+ 658,
+ 3775,
+ 2595,
+ 499,
+ 4191,
+ 582,
+ 3208,
+ 3709,
+ 4533,
+ 3632,
+ 3812,
+ 3870,
+ 1119,
+ 1329,
+ 3412,
+ 2971,
+ 3315,
+ 277,
+ 2938,
+ 1435,
+ 4131,
+ 1644,
+ 139,
+ 4692,
+ 1193,
+ 1568,
+ 466,
+ 4484,
+ 4256,
+ 4638,
+ 250,
+ 270,
+ 1542,
+ 18,
+ 3432,
+ 3391,
+ 3332,
+ 619,
+ 47,
+ 215,
+ 3292,
+ 2489,
+ 1085,
+ 581,
+ 373,
+ 1849,
+ 4468,
+ 1718,
+ 1436,
+ 3068,
+ 4586,
+ 3760,
+ 420,
+ 3414,
+ 4069,
+ 812,
+ 1416,
+ 569,
+ 2668,
+ 1138,
+ 2152,
+ 678,
+ 702,
+ 1728,
+ 942,
+ 1339,
+ 116,
+ 1284,
+ 4636,
+ 630,
+ 1683,
+ 3212,
+ 2705,
+ 858,
+ 4349,
+ 1203,
+ 3891,
+ 4291,
+ 1698,
+ 1791,
+ 2479,
+ 614,
+ 985,
+ 1023,
+ 2881,
+ 511,
+ 3207,
+ 3761,
+ 3880,
+ 615,
+ 3428,
+ 586,
+ 261,
+ 390,
+ 1807,
+ 4690,
+ 464,
+ 3034,
+ 1227,
+ 88,
+ 2893,
+ 3444,
+ 3644,
+ 1859,
+ 2017,
+ 609,
+ 3236,
+ 1037,
+ 2001,
+ 2717,
+ 932,
+ 1077,
+ 597,
+ 1638,
+ 4225,
+ 219,
+ 3577,
+ 2485,
+ 2866,
+ 3439,
+ 2680,
+ 3513,
+ 983,
+ 997,
+ 735,
+ 4567,
+ 1772,
+ 1017,
+ 1501,
+ 3516,
+ 604,
+ 1122,
+ 1991,
+ 3813,
+ 325,
+ 1695,
+ 2556,
+ 2691,
+ 2102,
+ 273,
+ 2283,
+ 1870,
+ 1977,
+ 2853,
+ 4100,
+ 4616,
+ 3145,
+ 3105,
+ 3017,
+ 3462,
+ 1465,
+ 1502,
+ 3825,
+ 1243,
+ 4496,
+ 1965,
+ 4008,
+ 2802,
+ 518,
+ 330,
+ 3540,
+ 2134,
+ 2523,
+ 108,
+ 4594,
+ 3389,
+ 234,
+ 68,
+ 990,
+ 1588,
+ 1222,
+ 2559,
+ 3200,
+ 1948,
+ 2517,
+ 3361,
+ 260,
+ 911,
+ 4035,
+ 1980,
+ 2241,
+ 2791,
+ 3214,
+ 467,
+ 2132,
+ 647,
+ 1419,
+ 602,
+ 2598,
+ 4560,
+ 3307,
+ 1116,
+ 1539,
+ 3353,
+ 4660,
+ 4359,
+ 429,
+ 695,
+ 4170,
+ 2560,
+ 1928,
+ 3121,
+ 3815,
+ 1690,
+ 1549,
+ 3435,
+ 4289,
+ 3433,
+ 2920,
+ 3889,
+ 1189,
+ 3830,
+ 2401,
+ 127,
+ 3947,
+ 2133,
+ 1762,
+ 181,
+ 929,
+ 3614,
+ 1076,
+ 3572,
+ 3222,
+ 4112,
+ 3004,
+ 1241,
+ 398,
+ 1289,
+ 323,
+ 4671,
+ 4083,
+ 4209,
+ 449,
+ 737,
+ 3840,
+ 1319,
+ 4380,
+ 1582,
+ 3061,
+ 1534,
+ 3219,
+ 1626,
+ 2547,
+ 124,
+ 2322,
+ 1784,
+ 27,
+ 664,
+ 4435,
+ 3702,
+ 2773,
+ 2506,
+ 1912,
+ 3924,
+ 4381,
+ 4683,
+ 1382,
+ 1992,
+ 1596,
+ 1575,
+ 3153,
+ 1277,
+ 864,
+ 671,
+ 3135,
+ 3369,
+ 3026,
+ 2028,
+ 2180,
+ 337,
+ 4441,
+ 750,
+ 4158,
+ 912,
+ 431,
+ 2685,
+ 1734,
+ 316,
+ 4255,
+ 4195,
+ 233,
+ 410,
+ 1016,
+ 1661,
+ 4178,
+ 2185,
+ 1399,
+ 2934,
+ 488,
+ 659,
+ 4192,
+ 28,
+ 1035,
+ 989,
+ 4347,
+ 2166,
+ 2520,
+ 2669,
+ 3721,
+ 366,
+ 3053,
+ 4076,
+ 1009,
+ 2592,
+ 3728,
+ 329,
+ 4029,
+ 733,
+ 4402,
+ 1351,
+ 1755,
+ 4345,
+ 1268,
+ 1677,
+ 2260,
+ 2321,
+ 1836,
+ 1403,
+ 2775,
+ 3860,
+ 419,
+ 173,
+ 3328,
+ 4463,
+ 4239,
+ 2838,
+ 3851,
+ 1685,
+ 1974,
+ 769,
+ 3989,
+ 4024,
+ 3636,
+ 1604,
+ 1143,
+ 4332,
+ 618,
+ 2232,
+ 2019,
+ 1407,
+ 580,
+ 1036,
+ 333,
+ 2183,
+ 3505,
+ 4593,
+ 51,
+ 2362,
+ 3475,
+ 4454,
+ 541,
+ 1000,
+ 1008,
+ 2886,
+ 3615,
+ 498,
+ 4265,
+ 1440,
+ 3597,
+ 3705,
+ 2220,
+ 4411,
+ 517,
+ 2760,
+ 3616,
+ 171,
+ 2780,
+ 4202,
+ 3535,
+ 34,
+ 1397,
+ 4356,
+ 324,
+ 2936,
+ 3557,
+ 3192,
+ 2986,
+ 544,
+ 1854,
+ 4164,
+ 3257,
+ 118,
+ 3984,
+ 4358,
+ 2243,
+ 2474,
+ 44,
+ 4183,
+ 40,
+ 2931,
+ 2503,
+ 2076,
+ 1625,
+ 1654,
+ 2249,
+ 3085,
+ 2470,
+ 944,
+ 2196,
+ 2236,
+ 1027,
+ 1671,
+ 73,
+ 4595,
+ 3464,
+ 570,
+ 4133,
+ 950,
+ 4546,
+ 1113,
+ 1406,
+ 296,
+ 4395,
+ 1360,
+ 114,
+ 3215,
+ 2008,
+ 1229,
+ 2202,
+ 2155,
+ 2005,
+ 1508,
+ 515,
+ 736,
+ 2582,
+ 654,
+ 3383,
+ 715,
+ 287,
+ 1711,
+ 712,
+ 617,
+ 3831,
+ 164,
+ 1477,
+ 1561,
+ 205,
+ 3262,
+ 3149,
+ 1919,
+ 1492,
+ 3283,
+ 3998,
+ 3452,
+ 1983,
+ 427,
+ 2081,
+ 4673,
+ 2733,
+ 3639,
+ 100,
+ 4053,
+ 1393,
+ 3531,
+ 4556,
+ 607,
+ 1438,
+ 2660,
+ 3548,
+ 2640,
+ 853,
+ 4194,
+ 4397,
+ 3865,
+ 4023,
+ 2953,
+ 2820,
+ 1874,
+ 3217,
+ 1474,
+ 504,
+ 2868,
+ 2221,
+ 4128,
+ 1471,
+ 4365,
+ 1490,
+ 1951,
+ 3895,
+ 1181,
+ 1557,
+ 572,
+ 2851,
+ 1154,
+ 3057,
+ 4352,
+ 3359,
+ 4232,
+ 824,
+ 548,
+ 1213,
+ 4001,
+ 2235,
+ 1527,
+ 3357,
+ 2537,
+ 2935,
+ 1861,
+ 2550,
+ 4154,
+ 279,
+ 917,
+ 1327,
+ 1097,
+ 1987,
+ 331,
+ 1297,
+ 4238,
+ 4165,
+ 161,
+ 3653,
+ 3882,
+ 3792,
+ 1294,
+ 962,
+ 3379,
+ 765,
+ 3094,
+ 2295,
+ 370,
+ 3973,
+ 3778,
+ 141,
+ 2535,
+ 1310,
+ 1815,
+ 741,
+ 2645,
+ 1443,
+ 2009,
+ 4372,
+ 2023,
+ 4135,
+ 2635,
+ 967,
+ 4318,
+ 2225,
+ 380,
+ 4661,
+ 3842,
+ 257,
+ 4137,
+ 1832,
+ 2925,
+ 3589,
+ 854,
+ 4184,
+ 2163,
+ 1810,
+ 2311,
+ 993,
+ 451,
+ 2788,
+ 4393,
+ 2948,
+ 443,
+ 4124,
+ 4139,
+ 3912,
+ 1715,
+ 1758,
+ 3789,
+ 2087,
+ 1248,
+ 4450,
+ 2911,
+ 4353,
+ 4051,
+ 2900,
+ 4591,
+ 4487,
+ 4010,
+ 1955,
+ 1486,
+ 3900,
+ 4574,
+ 4523,
+ 1170,
+ 3243,
+ 2334,
+ 1674,
+ 2768,
+ 503,
+ 771,
+ 4059,
+ 1556,
+ 2804,
+ 2555,
+ 4328,
+ 3321,
+ 2002,
+ 2519,
+ 2724,
+ 2233,
+ 2653,
+ 4503,
+ 2369,
+ 4641,
+ 3051,
+ 1656,
+ 2354,
+ 3233,
+ 4161,
+ 2247,
+ 2103,
+ 2181,
+ 4228,
+ 252,
+ 1902,
+ 3011,
+ 1032,
+ 2422,
+ 1066,
+ 1736,
+ 4126,
+ 1839,
+ 1260,
+ 4153,
+ 905,
+ 36,
+ 1330,
+ 2324,
+ 3449,
+ 405,
+ 413,
+ 3785,
+ 2150,
+ 193,
+ 4065,
+ 3556,
+ 1994,
+ 3445,
+ 2855,
+ 522,
+ 4527,
+ 1727,
+ 925,
+ 1761,
+ 1774,
+ 2228,
+ 3916,
+ 2014,
+ 1745,
+ 538,
+ 2158,
+ 4408,
+ 1142,
+ 2142,
+ 1776,
+ 2061,
+ 3611,
+ 1949,
+ 1489,
+ 3054,
+ 3282,
+ 2884,
+ 3826,
+ 2043,
+ 1288,
+ 1090,
+ 1599,
+ 2240,
+ 870,
+ 3628,
+ 2749,
+ 145,
+ 2524,
+ 3499,
+ 3036,
+ 2390,
+ 593,
+ 4002,
+ 3738,
+ 160,
+ 3936,
+ 3232,
+ 436,
+ 1503,
+ 2291,
+ 898,
+ 1190,
+ 1891,
+ 3534,
+ 306,
+ 2124,
+ 2850,
+ 2901,
+ 1530,
+ 836,
+ 1362,
+ 4612,
+ 4399,
+ 2514,
+ 1847,
+ 2408,
+ 4320,
+ 314,
+ 1328,
+ 3083,
+ 2406,
+ 4466,
+ 1392,
+ 2020,
+ 209,
+ 2110,
+ 4364,
+ 2615,
+ 3079,
+ 1386,
+ 1780,
+ 4529,
+ 3060,
+ 2418,
+ 411,
+ 4459,
+ 3430,
+ 4424,
+ 3441,
+ 976,
+ 3281,
+ 3843,
+ 2643,
+ 2952,
+ 2883,
+ 1153,
+ 460,
+ 1647,
+ 843,
+ 3808,
+ 2735,
+ 1282,
+ 1295,
+ 3708,
+ 1704,
+ 276,
+ 2025,
+ 2923,
+ 417,
+ 3421,
+ 2168,
+ 2915,
+ 2507,
+ 2060,
+ 2459,
+ 2367,
+ 20,
+ 4391,
+ 1266,
+ 886,
+ 1074,
+ 3301,
+ 10,
+ 2962,
+ 3137,
+ 4539,
+ 4241,
+ 3482,
+ 4605,
+ 2098,
+ 1817,
+ 224,
+ 2409,
+ 3763,
+ 4245,
+ 742,
+ 2975,
+ 4175,
+ 525,
+ 1869,
+ 3692,
+ 1603,
+ 2453,
+ 797,
+ 3558,
+ 4341,
+ 3533,
+ 2258,
+ 4007,
+ 4577,
+ 292,
+ 687,
+ 131,
+ 3873,
+ 2285,
+ 21,
+ 2244,
+ 692,
+ 4519,
+ 4672,
+ 4342,
+ 2046,
+ 4309,
+ 1538,
+ 309,
+ 1391,
+ 62,
+ 4689,
+ 2499,
+ 4420,
+ 1039,
+ 4259,
+ 2270,
+ 991,
+ 2075,
+ 4143,
+ 4145,
+ 4430,
+ 56,
+ 3163,
+ 344,
+ 2647,
+ 3524,
+ 2836,
+ 649,
+ 150,
+ 971,
+ 1493,
+ 1333,
+ 4160,
+ 845,
+ 1086,
+ 584,
+ 2117,
+ 1544,
+ 481,
+ 1667,
+ 4050,
+ 2007,
+ 53,
+ 4483,
+ 3167,
+ 408,
+ 2865,
+ 1217,
+ 1141,
+ 4579,
+ 4166,
+ 1061,
+ 4187,
+ 2758,
+ 3112,
+ 1255,
+ 4064,
+ 977,
+ 4231,
+ 1770,
+ 423,
+ 709,
+ 3351,
+ 2392,
+ 379,
+ 1769,
+ 3674,
+ 3892,
+ 1242,
+ 4324,
+ 149,
+ 2018,
+ 2093,
+ 1971,
+ 1660,
+ 1047,
+ 4583,
+ 2335,
+ 45,
+ 4058,
+ 3413,
+ 1199,
+ 2348,
+ 2442,
+ 3850,
+ 3368,
+ 288,
+ 4319,
+ 1560,
+ 210,
+ 786,
+ 1653,
+ 30,
+ 3861,
+ 2630,
+ 3877,
+ 3925,
+ 953,
+ 1850,
+ 3593,
+ 4078,
+ 2250,
+ 2307,
+ 3172,
+ 4398,
+ 3206,
+ 248,
+ 3966,
+ 2391,
+ 1450,
+ 2929,
+ 3009,
+ 1998,
+ 1901,
+ 2932,
+ 3344,
+ 610,
+ 1754,
+ 3396,
+ 4301,
+ 2739,
+ 4662,
+ 561,
+ 4481,
+ 3729,
+ 2287,
+ 591,
+ 142,
+ 2672,
+ 1898,
+ 3972,
+ 195,
+ 3027,
+ 2063,
+ 1117,
+ 2473,
+ 4609,
+ 4235,
+ 851,
+ 2719,
+ 3994,
+ 1833,
+ 1485,
+ 558,
+ 4642,
+ 1907,
+ 3934,
+ 4257,
+ 3293,
+ 4631,
+ 3784,
+ 3081,
+ 3931,
+ 644,
+ 2175,
+ 63,
+ 4449,
+ 3152,
+ 1702,
+ 1354,
+ 4622,
+ 3376,
+ 425,
+ 3269,
+ 2016,
+ 2405,
+ 1896,
+ 4243,
+ 2793,
+ 1903,
+ 2256,
+ 1726,
+ 2574,
+ 2877,
+ 3717,
+ 4456,
+ 2476,
+ 784,
+ 4426,
+ 938,
+ 819,
+ 724,
+ 1939,
+ 2795,
+ 179,
+ 3748,
+ 1792,
+ 349,
+ 302,
+ 2128,
+ 3744,
+ 2715,
+ 4491,
+ 1705,
+ 1860,
+ 2740,
+ 2565,
+ 3113,
+ 3661,
+ 2919,
+ 2954,
+ 226,
+ 968,
+ 4019,
+ 1966,
+ 4494,
+ 1402,
+ 4285,
+ 3049,
+ 478,
+ 1920,
+ 4439,
+ 2927,
+ 358,
+ 3316,
+ 1230,
+ 688,
+ 1314,
+ 2386,
+ 862,
+ 1371,
+ 930,
+ 4640,
+ 2088,
+ 629,
+ 3743,
+ 3510,
+ 4056,
+ 1853,
+ 4027,
+ 4432,
+ 3941,
+ 1131,
+ 1692,
+ 2812,
+ 3762,
+ 1565,
+ 4696,
+ 2869,
+ 4041,
+ 3356,
+ 670,
+ 1766,
+ 1147,
+ 2216,
+ 2176,
+ 298,
+ 3087,
+ 282,
+ 1448,
+ 4530,
+ 3144,
+ 4589,
+ 746,
+ 794,
+ 2588,
+ 4469,
+ 4134,
+ 3179,
+ 363,
+ 684,
+ 2000,
+ 1337,
+ 3878,
+ 1194,
+ 3467,
+ 1456,
+ 3680,
+ 2320,
+ 4028,
+ 1646,
+ 3794,
+ 1254,
+ 2815,
+ 651,
+ 4510,
+ 213,
+ 1014,
+ 1652,
+ 3241,
+ 1723,
+ 2874,
+ 3688,
+ 3854,
+ 1205,
+ 828,
+ 4237,
+ 3623,
+ 4520,
+ 1469,
+ 1821,
+ 4278,
+ 2939,
+ 1084,
+ 1842,
+ 2477,
+ 3799,
+ 2933,
+ 2029,
+ 1264,
+ 1166,
+ 1942,
+ 1446,
+ 2697,
+ 2389,
+ 1093,
+ 3755,
+ 1118,
+ 2840,
+ 3190,
+ 484,
+ 3820,
+ 2370,
+ 259,
+ 2463,
+ 2069,
+ 3110,
+ 1964,
+ 2599,
+ 4338,
+ 1904,
+ 3827,
+ 3504,
+ 3751,
+ 655,
+ 113,
+ 80,
+ 3126,
+ 974,
+ 3415,
+ 3528,
+ 2701,
+ 2837,
+ 1959,
+ 2801,
+ 97,
+ 4242,
+ 4537,
+ 4071,
+ 320,
+ 4136,
+ 2356,
+ 3336,
+ 3339,
+ 4658,
+ 3566,
+ 1924,
+ 2947,
+ 3619,
+ 2242,
+ 2475,
+ 2209,
+ 2111,
+ 2208,
+ 1801,
+ 2797,
+ 1751,
+ 4549,
+ 3271,
+ 2800,
+ 3202,
+ 669,
+ 791,
+ 3322,
+ 190,
+ 3745,
+ 1444,
+ 453,
+ 592,
+ 1344,
+ 1957,
+ 4688,
+ 805,
+ 4650,
+ 1808,
+ 3543,
+ 2827,
+ 4472,
+ 482,
+ 4706,
+ 4197,
+ 3806,
+ 3511,
+ 4275,
+ 2351,
+ 3724,
+ 4479,
+ 906,
+ 4233,
+ 3573,
+ 798,
+ 2861,
+ 1632,
+ 2693,
+ 395,
+ 180,
+ 3388,
+ 3811,
+ 840,
+ 305,
+ 2318,
+ 1585,
+ 563,
+ 910,
+ 4506,
+ 578,
+ 788,
+ 1759,
+ 394,
+ 2255,
+ 3942,
+ 3089,
+ 1,
+ 3988,
+ 2010,
+ 4097,
+ 136,
+ 3696,
+ 2092,
+ 1976,
+ 3013,
+ 3147,
+ 2281,
+ 1182,
+ 3288,
+ 841,
+ 3974,
+ 1307,
+ 1786,
+ 631,
+ 1918,
+ 3404,
+ 2745,
+ 1442,
+ 3235,
+ 2678,
+ 3486,
+ 4115,
+ 2068,
+ 424,
+ 3800,
+ 3319,
+ 1956,
+ 2036,
+ 2847,
+ 1681,
+ 3838,
+ 4113,
+ 2689,
+ 3325,
+ 1046,
+ 1804,
+ 4269,
+ 3448,
+ 72,
+ 1516,
+ 1746,
+ 1829,
+ 1135,
+ 1706,
+ 2491,
+ 874,
+ 2239,
+ 2594,
+ 706,
+ 1665,
+ 3993,
+ 2605,
+ 4038,
+ 1281,
+ 1982,
+ 3062,
+ 876,
+ 1331,
+ 703,
+ 2399,
+ 2816,
+ 4310,
+ 1359,
+ 3700,
+ 691,
+ 1546,
+ 2809,
+ 2276,
+ 4425,
+ 3756,
+ 2942,
+ 3633,
+ 253,
+ 2532,
+ 1643,
+ 2379,
+ 3671,
+ 4705,
+ 4442,
+ 3869,
+ 2824,
+ 1938,
+ 3180,
+ 4598,
+ 4307,
+ 1497,
+ 4431,
+ 16,
+ 2373,
+ 672,
+ 418,
+ 1635,
+ 1239,
+ 1573,
+ 3039,
+ 3158,
+ 1366,
+ 1930,
+ 3117,
+ 4185,
+ 4701,
+ 1167,
+ 2771,
+ 1312,
+ 1943,
+ 3465,
+ 76,
+ 4500,
+ 3944,
+ 4619,
+ 3125,
+ 4044,
+ 4675,
+ 3329,
+ 4691,
+ 4637,
+ 1069,
+ 3779,
+ 621,
+ 389,
+ 1929,
+ 1369,
+ 42,
+ 1877,
+ 1970,
+ 3953,
+ 2095,
+ 1802,
+ 1999,
+ 3100,
+ 4054,
+ 3193,
+ 83,
+ 559,
+ 3673,
+ 4416,
+ 4414,
+ 1714,
+ 2245,
+ 2011,
+ 2289,
+ 3456,
+ 4630,
+ 2277,
+ 3466,
+ 4216,
+ 3055,
+ 471,
+ 994,
+ 2118,
+ 1750,
+ 4169,
+ 1655,
+ 3716,
+ 8,
+ 1764,
+ 2892,
+ 1577,
+ 1325,
+ 1973,
+ 3256,
+ 426,
+ 2481,
+ 1630,
+ 2201,
+ 1463,
+ 1606,
+ 1509,
+ 1350,
+ 3786,
+ 3314,
+ 1828,
+ 1578,
+ 641,
+ 4544,
+ 4251,
+ 1986,
+ 3951,
+ 2603,
+ 3634,
+ 2656,
+ 300,
+ 1543,
+ 1180,
+ 4686,
+ 230,
+ 2586,
+ 820,
+ 1372,
+ 1958,
+ 2394,
+ 4571,
+ 4102,
+ 2522,
+ 751,
+ 2974,
+ 2789,
+ 3422,
+ 936,
+ 3006,
+ 3918,
+ 4080,
+ 4248,
+ 1985,
+ 2094,
+ 2580,
+ 2374,
+ 3883,
+ 1908,
+ 1941,
+ 963,
+ 3077,
+ 3426,
+ 4316,
+ 698,
+ 4467,
+ 2238,
+ 2446,
+ 1526,
+ 2534,
+ 1088,
+ 2576,
+ 707,
+ 3782,
+ 2222,
+ 3618,
+ 2421,
+ 809,
+ 1620,
+ 2344,
+ 1002,
+ 104,
+ 4461,
+ 2857,
+ 4200,
+ 1893,
+ 1586,
+ 2097,
+ 4085,
+ 3948,
+ 4611,
+ 879,
+ 3517,
+ 2722,
+ 1298,
+ 2136,
+ 495,
+ 229,
+ 3666,
+ 297,
+ 2746,
+ 2456,
+ 1520,
+ 1445,
+ 941,
+ 2879,
+ 877,
+ 1439,
+ 2778,
+ 2843,
+ 2849,
+ 3736,
+ 3645,
+ 3670,
+ 661,
+ 3833,
+ 3960,
+ 143,
+ 1454,
+ 2292,
+ 575,
+ 4263,
+ 4505,
+ 3385,
+ 3199,
+ 3093,
+ 1290,
+ 3310,
+ 4664,
+ 3228,
+ 2145,
+ 1996,
+ 353,
+ 2687,
+ 404,
+ 2863,
+ 299,
+ 3959,
+ 2870,
+ 1688,
+ 2123,
+ 39,
+ 2169,
+ 1103,
+ 2041,
+ 3183,
+ 1082,
+ 1687,
+ 1857,
+ 2989,
+ 3173,
+ 2972,
+ 1488,
+ 2033,
+ 2072,
+ 1771,
+ 3130,
+ 2488,
+ 2395,
+ 2864,
+ 2297,
+ 4313,
+ 2909,
+ 3411,
+ 3014,
+ 251,
+ 2478,
+ 888,
+ 2984,
+ 102,
+ 1500,
+ 138,
+ 2447,
+ 1263,
+ 1286,
+ 2624,
+ 1790,
+ 3685,
+ 2839,
+ 2414,
+ 4022,
+ 915,
+ 4152,
+ 2770,
+ 2787,
+ 1547,
+ 1011,
+ 4645,
+ 432,
+ 1563,
+ 2458,
+ 928,
+ 71,
+ 573,
+ 4224,
+ 2339,
+ 4497,
+ 528,
+ 3274,
+ 3909,
+ 4410,
+ 1739,
+ 2464,
+ 3272,
+ 4362,
+ 3223,
+ 2151,
+ 789,
+ 4081,
+ 2340,
+ 3879,
+ 2191,
+ 3285,
+ 4229,
+ 1219,
+ 3764,
+ 2854,
+ 3731,
+ 4129,
+ 393,
+ 1429,
+ 2099,
+ 3841,
+ 3019,
+ 386,
+ 4297,
+ 3273,
+ 4119,
+ 4217,
+ 1763,
+ 4680,
+ 524,
+ 1184,
+ 987,
+ 1124,
+ 2224,
+ 1321,
+ 2655,
+ 2926,
+ 2995,
+ 920,
+ 4618,
+ 4366,
+ 4540,
+ 1932,
+ 391,
+ 689,
+ 2681,
+ 1700,
+ 3171,
+ 686,
+ 3191,
+ 699,
+ 3323,
+ 2085,
+ 3290,
+ 2375,
+ 1846,
+ 4514,
+ 1285,
+ 796,
+ 4499,
+ 2686,
+ 4140,
+ 1352,
+ 1060,
+ 4488,
+ 3028,
+ 4657,
+ 2792,
+ 2545,
+ 4379,
+ 2621,
+ 934,
+ 3098,
+ 3725,
+ 3689,
+ 1725,
+ 4030,
+ 3221,
+ 4173,
+ 374,
+ 2951,
+ 2451,
+ 1748,
+ 2790,
+ 3240,
+ 4009,
+ 3753,
+ 1007,
+ 721,
+ 1740,
+ 4623,
+ 958,
+ 2416,
+ 1110,
+ 3253,
+ 1864,
+ 1789,
+ 3981,
+ 2873,
+ 1087,
+ 4582,
+ 3102,
+ 1271,
+ 1072,
+ 3080,
+ 1215,
+ 2564,
+ 579,
+ 3211,
+ 1605,
+ 74,
+ 2561,
+ 980,
+ 4205,
+ 650,
+ 3279,
+ 3582,
+ 4679,
+ 3713,
+ 2079,
+ 2330,
+ 3651,
+ 2159,
+ 4573,
+ 3958,
+ 2807,
+ 3663,
+ 2774,
+ 1291,
+ 2024,
+ 1496,
+ 1409,
+ 523,
+ 3107,
+ 158,
+ 9,
+ 513,
+ 3461,
+ 3216,
+ 1735,
+ 4268,
+ 60,
+ 2165,
+ 3649,
+ 3665,
+ 1081,
+ 3491,
+ 4180,
+ 1809,
+ 1390,
+ 2741,
+ 3134,
+ 2294,
+ 1278,
+ 4279,
+ 887,
+ 4511,
+ 84,
+ 218,
+ 2525,
+ 919,
+ 1003,
+ 3857,
+ 3073,
+ 414,
+ 2734,
+ 979,
+ 562,
+ 1962,
+ 4330,
+ 3868,
+ 4460,
+ 992,
+ 2398,
+ 4223,
+ 624,
+ 1034,
+ 3420,
+ 1887,
+ 165,
+ 93,
+ 103,
+ 2156,
+ 818,
+ 3056,
+ 893,
+ 1867,
+ 1875,
+ 2783,
+ 3210,
+ 3032,
+ 3133,
+ 2766,
+ 1922,
+ 1720,
+ 1071,
+ 3824,
+ 2965,
+ 1225,
+ 2127,
+ 2415,
+ 65,
+ 4020,
+ 2492,
+ 4614,
+ 4281,
+ 4548,
+ 3335,
+ 3899,
+ 3604,
+ 924,
+ 304,
+ 1732,
+ 3277,
+ 3150,
+ 2101,
+ 2673,
+ 3225,
+ 437,
+ 3333,
+ 2531,
+ 1317,
+ 4012,
+ 2796,
+ 4077,
+ 1933,
+ 4144,
+ 264,
+ 3704,
+ 3116,
+ 3384,
+ 2910,
+ 743,
+ 128,
+ 3071,
+ 480,
+ 3205,
+ 2997,
+ 4333,
+ 2455,
+ 338,
+ 3945,
+ 666,
+ 2539,
+ 2996,
+ 3720,
+ 111,
+ 2763,
+ 4597,
+ 3001,
+ 3234,
+ 2994,
+ 3954,
+ 825,
+ 4396,
+ 3678,
+ 3108,
+ 3230,
+ 2179,
+ 3962,
+ 2114,
+ 1506,
+ 4369,
+ 519,
+ 4608,
+ 834,
+ 4417,
+ 3710,
+ 3255,
+ 3427,
+ 3229,
+ 4665,
+ 3547,
+ 19,
+ 3997,
+ 1529,
+ 1691,
+ 1332,
+ 3470,
+ 2160,
+ 2148,
+ 873,
+ 154,
+ 1015,
+ 2526,
+ 3474,
+ 4018,
+ 122,
+ 2411,
+ 2272,
+ 2904,
+ 2779,
+ 1475,
+ 3311,
+ 2844,
+ 1195,
+ 2806,
+ 1164,
+ 2999,
+ 232,
+ 3050,
+ 3886,
+ 748,
+ 1768,
+ 2623,
+ 3501,
+ 1218,
+ 4298,
+ 4201,
+ 4003,
+ 1246,
+ 3772,
+ 4176,
+ 2346,
+ 1201,
+ 3189,
+ 1383,
+ 4551,
+ 243,
+ 2210,
+ 3186,
+ 1602,
+ 14,
+ 3992,
+ 871,
+ 4590,
+ 1514,
+ 2290,
+ 758,
+ 3481,
+ 2762,
+ 4339,
+ 952,
+ 4628,
+ 3118,
+ 167,
+ 1507,
+ 1216,
+ 4448,
+ 2511,
+ 2834,
+ 2846,
+ 3209,
+ 4105,
+ 2215,
+ 1512,
+ 3776,
+ 4095,
+ 3406,
+ 3451,
+ 636,
+ 1703,
+ 172,
+ 2611,
+ 3166,
+ 3246,
+ 1262,
+ 3662,
+ 1424,
+ 2543,
+ 1569,
+ 2585,
+ 3220,
+ 1713,
+ 1978,
+ 1270,
+ 1910,
+ 1381,
+ 4063,
+ 728,
+ 4074,
+ 2352,
+ 2316,
+ 1731,
+ 3735,
+ 3478,
+ 3520,
+ 286,
+ 4668,
+ 4354,
+ 744,
+ 3576,
+ 3906,
+ 676,
+ 1532,
+ 2450,
+ 416,
+ 949,
+ 892,
+ 656,
+ 1716,
+ 927,
+ 1876,
+ 3503,
+ 3483,
+ 1889,
+ 3853,
+ 1214,
+ 3654,
+ 2494,
+ 3161,
+ 3846,
+ 2396,
+ 473,
+ 3487,
+ 1052,
+ 3387,
+ 2189,
+ 1280,
+ 3402,
+ 2998,
+ 2496,
+ 58,
+ 1373,
+ 106,
+ 1510,
+ 3551,
+ 3803,
+ 3669,
+ 1258,
+ 648,
+ 4495,
+ 1848,
+ 1580,
+ 2501,
+ 3114,
+ 1259,
+ 1812,
+ 3600,
+ 3364,
+ 3480,
+ 2115,
+ 3489,
+ 303,
+ 739,
+ 361,
+ 802,
+ 2368,
+ 192,
+ 1028,
+ 1601,
+ 2265,
+ 4415,
+ 326,
+ 1972,
+ 4512,
+ 1788,
+ 3473,
+ 3300,
+ 1979,
+ 1056,
+ 1648,
+ 4711,
+ 2973,
+ 4585,
+ 4478,
+ 3590,
+ 479,
+ 1169,
+ 182,
+ 534,
+ 3983,
+ 4182,
+ 140,
+ 1594,
+ 1186,
+ 4629,
+ 2731,
+ 1106,
+ 3967,
+ 3157,
+ 4434,
+ 315,
+ 2589,
+ 2490,
+ 2946,
+ 1311,
+ 860,
+ 1795,
+ 489,
+ 908,
+ 901,
+ 4684,
+ 1631,
+ 4647,
+ 780,
+ 137,
+ 2039,
+ 2513,
+ 3317,
+ 1744,
+ 1473,
+ 1063,
+ 1461,
+ 550,
+ 4300,
+ 2584,
+ 3818,
+ 3296,
+ 549,
+ 3007,
+ 2845,
+ 2229,
+ 2546,
+ 412,
+ 4504,
+ 2744,
+ 2728,
+ 2964,
+ 2842,
+ 2704,
+ 4464,
+ 217,
+ 4490,
+ 3418,
+ 2296,
+ 1145,
+ 2194,
+ 2467,
+ 922,
+ 2130,
+ 1389,
+ 1554,
+ 4303,
+ 2684,
+ 3372,
+ 4084,
+ 2205,
+ 1535,
+ 3536,
+ 4559,
+ 859,
+ 3943,
+ 440,
+ 2712,
+ 1370,
+ 1363,
+ 3990,
+ 4433,
+ 2217,
+ 2661,
+ 2608,
+ 723,
+ 2190,
+ 1961,
+ 1798,
+ 2757,
+ 4681,
+ 981,
+ 2383,
+ 4040,
+ 3337,
+ 187,
+ 2601,
+ 3366,
+ 3817,
+ 2044,
+ 15,
+ 2034,
+ 4092,
+ 3578,
+ 397,
+ 4389,
+ 1163,
+ 2,
+ 3115,
+ 1905,
+ 3312,
+ 1368,
+ 772,
+ 2880,
+ 376,
+ 4091,
+ 986,
+ 4262,
+ 3509,
+ 1712,
+ 3453,
+ 4013,
+ 1880,
+ 2575,
+ 3788,
+ 1293,
+ 867,
+ 2889,
+ 3188,
+ 764,
+ 3734,
+ 4513,
+ 2349,
+ 1336,
+ 2577,
+ 2515,
+ 4334,
+ 3381,
+ 2716,
+ 4451,
+ 3529,
+ 756,
+ 1600,
+ 1070,
+ 1738,
+ 790,
+ 3652,
+ 1414,
+ 3726,
+ 1523,
+ 4127,
+ 1247,
+ 921,
+ 422,
+ 3605,
+ 1356,
+ 2445,
+ 199,
+ 1521,
+ 41,
+ 1417,
+ 1925,
+ 4343,
+ 1361,
+ 1155,
+ 2924,
+ 1803,
+ 2443,
+ 1422,
+ 3923,
+ 1148,
+ 811,
+ 4138,
+ 2991,
+ 3392,
+ 4534,
+ 3010,
+ 378,
+ 78,
+ 4032,
+ 110,
+ 778,
+ 1579,
+ 3302,
+ 3610,
+ 3022,
+ 3286,
+ 3580,
+ 3613,
+ 984,
+ 354,
+ 2969,
+ 4427,
+ 4282,
+ 245,
+ 275,
+ 3506,
+ 183,
+ 444,
+ 2231,
+ 1413,
+ 2059,
+ 880,
+ 263,
+ 1207,
+ 1272,
+ 2538,
+ 3887,
+ 1662,
+ 4620,
+ 2784,
+ 3320,
+ 2956,
+ 3999,
+ 2593,
+ 4293,
+ 3609,
+ 2497,
+ 900,
+ 3643,
+ 225,
+ 3142,
+ 637,
+ 4600,
+ 3664,
+ 3003,
+ 4037,
+ 3963,
+ 933,
+ 6,
+ 3719,
+ 2516,
+ 4667,
+ 4067,
+ 800,
+ 169,
+ 613,
+ 2620,
+ 3018,
+ 457,
+ 3140,
+ 2976,
+ 1633,
+ 1115,
+ 1160,
+ 3783,
+ 4162,
+ 3874,
+ 1610,
+ 1855,
+ 543,
+ 472,
+ 4639,
+ 1722,
+ 2212,
+ 3493,
+ 1729,
+ 3280,
+ 1541,
+ 1616,
+ 2113,
+ 4344,
+ 2634,
+ 2030,
+ 1858,
+ 3569,
+ 2248,
+ 3819,
+ 3195,
+ 589,
+ 2759,
+ 725,
+ 2214,
+ 2162,
+ 902,
+ 904,
+ 1742,
+ 166,
+ 4314,
+ 3810,
+ 1236,
+ 2312,
+ 3858,
+ 3362,
+ 2558,
+ 940,
+ 3298,
+ 4204,
+ 2021,
+ 540,
+ 2082,
+ 1176,
+ 1830,
+ 2658,
+ 455,
+ 3419,
+ 3127,
+ 4159,
+ 508,
+ 3500,
+ 4326,
+ 2609,
+ 1612,
+ 2378,
+ 935,
+ 822,
+ 434,
+ 890,
+ 4421,
+ 148,
+ 2064,
+ 3922,
+ 4114,
+ 546,
+ 1426,
+ 2707,
+ 554,
+ 1709,
+ 2723,
+ 1753,
+ 1940,
+ 806,
+ 2597,
+ 1345,
+ 2357,
+ 4052,
+ 4327,
+ 356,
+ 3058,
+ 625,
+ 77,
+ 4452,
+ 151,
+ 2918,
+ 2419,
+ 2671,
+ 2484,
+ 1441,
+ 1235,
+ 367,
+ 321,
+ 1909,
+ 1668,
+ 3521,
+ 4526,
+ 3472,
+ 3512,
+ 3864,
+ 4536,
+ 693,
+ 57,
+ 4699,
+ 598,
+ 3956,
+ 3787,
+ 1326,
+ 3773,
+ 1355,
+ 1136,
+ 342,
+ 4096,
+ 3238,
+ 3917,
+ 1562,
+ 2721,
+ 4708,
+ 202,
+ 3160,
+ 1883,
+ 2350,
+ 3587,
+ 2440,
+ 3386,
+ 48,
+ 2121,
+ 4221,
+ 2548,
+ 4351,
+ 4659,
+ 2569,
+ 3450,
+ 3946,
+ 4234,
+ 4031,
+ 2073,
+ 1300,
+ 341,
+ 452,
+ 1315,
+ 2426,
+ 176,
+ 1348,
+ 866,
+ 505,
+ 1025,
+ 4378,
+ 846,
+ 1038,
+ 2441,
+ 1710,
+ 2542,
+ 2358,
+ 4346,
+ 3920,
+ 2112,
+ 792,
+ 2066,
+ 3822,
+ 526,
+ 2149,
+ 3005,
+ 4455,
+ 3033,
+ 872,
+ 3,
+ 2436,
+ 3905,
+ 3287,
+ 231,
+ 3832,
+ 1504,
+ 3660,
+ 3194,
+ 3849,
+ 785,
+ 4621,
+ 1525,
+ 3703,
+ 4329,
+ 2875,
+ 4073,
+ 4186,
+ 2420,
+ 4632,
+ 1882,
+ 2649,
+ 4374,
+ 3985,
+ 4360,
+ 2810,
+ 2323,
+ 1269,
+ 383,
+ 4532,
+ 1287,
+ 1927,
+ 3859,
+ 2153,
+ 704,
+ 634,
+ 4615,
+ 1157,
+ 577,
+ 4252,
+ 4157,
+ 4669,
+ 3398,
+ 964,
+ 2173,
+ 1589,
+ 1055,
+ 4211,
+ 1639,
+ 1895,
+ 3401,
+ 1743,
+ 3423,
+ 2591,
+ 730,
+ 1773,
+ 1059,
+ 2720,
+ 869,
+ 4057,
+ 2305,
+ 726,
+ 3694,
+ 4132,
+ 486,
+ 595,
+ 1666,
+ 308,
+ 3586,
+ 2695,
+ 500,
+ 1934,
+ 387,
+ 3124,
+ 3182,
+ 4613,
+ 2269,
+ 2371,
+ 3072,
+ 545,
+ 830,
+ 2137,
+ 1253,
+ 1004,
+ 4588,
+ 3554,
+ 2003,
+ 745,
+ 4547,
+ 3120,
+ 4443,
+ 3090,
+ 923,
+ 1376,
+ 1420,
+ 3975,
+ 1576,
+ 1030,
+ 4578,
+ 272,
+ 3031,
+ 4682,
+ 4286,
+ 4283,
+ 3477,
+ 1482,
+ 3000,
+ 1464,
+ 2941,
+ 3518,
+ 351,
+ 319,
+ 295,
+ 4306,
+ 1856,
+ 2894,
+ 1349,
+ 125,
+ 1162,
+ 529,
+ 4440,
+ 3260,
+ 757,
+ 2659,
+ 4403,
+ 2718,
+ 2767,
+ 3749,
+ 372,
+ 101,
+ 2207,
+ 3168,
+ 2557,
+ 4355,
+ 814,
+ 2814,
+ 2327,
+ 899,
+ 343,
+ 1251,
+ 4321,
+ 2958,
+ 889,
+ 1840,
+ 3845,
+ 3914,
+ 2982,
+ 4123,
+ 2803,
+ 4489,
+ 3532,
+ 2662,
+ 2226,
+ 1126,
+ 3067,
+ 2090,
+ 1421,
+ 450,
+ 4413,
+ 1096,
+ 61,
+ 2462,
+ 539,
+ 3758,
+ 3677,
+ 4492,
+ 2832,
+ 3245,
+ 2393,
+ 850,
+ 2449,
+ 428,
+ 1664,
+ 476,
+ 4445,
+ 4189,
+ 1343,
+ 1990,
+ 3565,
+ 835,
+ 3933,
+ 690,
+ 956,
+ 3508,
+ 3295,
+ 186,
+ 2878,
+ 4006,
+ 3555,
+ 3599,
+ 2091,
+ 2108,
+ 4438,
+ 2666,
+ 2282,
+ 4155,
+ 749,
+ 2683,
+ 3309,
+ 2388,
+ 307,
+ 4021,
+ 884,
+ 240,
+ 2120,
+ 3218,
+ 3023,
+ 4296,
+ 3907,
+ 3796,
+ 1553,
+ 2743,
+ 4648,
+ 1805,
+ 2817,
+ 3697,
+ 970,
+ 4273,
+ 2626,
+ 3769,
+ 1198,
+ 674,
+ 3602,
+ 2461,
+ 3686,
+ 2619,
+ 1431,
+ 2510,
+ 628,
+ 3642,
+ 913,
+ 3754,
+ 3196,
+ 227,
+ 1675,
+ 2667,
+ 3454,
+ 3263,
+ 2381,
+ 2617,
+ 1741,
+ 1581,
+ 1178,
+ 17,
+ 2906,
+ 700,
+ 1721,
+ 1890,
+ 3538,
+ 1797,
+ 105,
+ 3341,
+ 1261,
+ 3791,
+ 4220,
+ 13,
+ 685,
+ 2438,
+ 3856,
+ 3996,
+ 2058,
+ 2835,
+ 3266,
+ 2423,
+ 3928,
+ 4335,
+ 1844,
+ 1551,
+ 2045,
+ 4167,
+ 4118,
+ 1873,
+ 3358,
+ 1825,
+ 2508,
+ 1954,
+ 1760,
+ 3620,
+ 3377,
+ 1822,
+ 2172,
+ 4148,
+ 3297,
+ 3141,
+ 194,
+ 680,
+ 1303,
+ 2400,
+ 1347,
+ 3542,
+ 2054,
+ 878,
+ 4601,
+ 3658,
+ 817,
+ 626,
+ 2914,
+ 3693,
+ 2047,
+ 3249,
+ 86,
+ 4082,
+ 1137,
+ 1041,
+ 3175,
+ 1342,
+ 2897,
+ 99,
+ 3888,
+ 1029,
+ 4409,
+ 212,
+ 1608,
+ 3409,
+ 3065,
+ 4407,
+ 1595,
+ 566,
+ 365,
+ 1619,
+ 4400,
+ 3656,
+ 2674,
+ 2813,
+ 512,
+ 587,
+ 2694,
+ 2777,
+ 345,
+ 3099,
+ 2184,
+ 3884,
+ 3594,
+ 675,
+ 1659,
+ 1993,
+ 1120,
+ 713,
+ 4695,
+ 2987,
+ 2397,
+ 2380,
+ 3986,
+ 1814,
+ 2407,
+ 1211,
+ 348,
+ 2696,
+ 3955,
+ 3393,
+ 732,
+ 3405,
+ 1900,
+ 1676,
+ 334,
+ 2454,
+ 400,
+ 705,
+ 1375,
+ 1240,
+ 2890,
+ 2709,
+ 1952,
+ 2119,
+ 3303,
+ 895,
+ 2086,
+ 1641,
+ 3442,
+ 2752,
+ 2753,
+ 774,
+ 608,
+ 2359,
+ 3624,
+ 2703,
+ 3978,
+ 865,
+ 4524,
+ 4325,
+ 883,
+ 3201,
+ 2078,
+ 551,
+ 2782,
+ 612,
+ 1204,
+ 2195,
+ 3523,
+ 1384,
+ 1820,
+ 3380,
+ 3363,
+ 25,
+ 3267,
+ 4000,
+ 2080,
+ 2135,
+ 81,
+ 2268,
+ 1680,
+ 3122,
+ 1210,
+ 3839,
+ 4337,
+ 4246,
+ 3395,
+ 1636,
+ 2211,
+ 1466,
+ 442,
+ 4258,
+ 844,
+ 3564,
+ 3885,
+ 4465,
+ 3835,
+ 381,
+ 2859,
+ 2427,
+ 3991,
+ 285,
+ 3679,
+ 2729,
+ 4666,
+ 4043,
+ 2452,
+ 555,
+ 1132,
+ 4562,
+ 2614,
+ 2950,
+ 242,
+ 130,
+ 959,
+ 2639,
+ 1127,
+ 781,
+ 4700,
+ 3447,
+ 1708,
+ 2404,
+ 3650,
+ 3667,
+ 4678,
+ 1826,
+ 2944,
+ 3706,
+ 4340,
+ 2197,
+ 3276,
+ 2596,
+ 3682,
+ 278,
+ 885,
+ 3265,
+ 2302,
+ 4388,
+ 2578,
+ 2675,
+ 2345,
+ 1931,
+ 1963,
+ 1658,
+ 3681,
+ 147,
+ 4109,
+ 1737,
+ 1152,
+ 1221,
+ 768,
+ 829,
+ 3957,
+ 4312,
+ 603,
+ 2457,
+ 4117,
+ 1452,
+ 2622,
+ 2171,
+ 1175,
+ 2710,
+ 3527,
+ 2122,
+ 3715,
+ 3318,
+ 1917,
+ 2141,
+ 1823,
+ 2052,
+ 4428,
+ 783,
+ 2096,
+ 2650,
+ 340,
+ 174,
+ 1158,
+ 2218,
+ 4260,
+ 4090,
+ 3766,
+ 284,
+ 717,
+ 531,
+ 3092,
+ 863,
+ 868,
+ 978,
+ 3526,
+ 157,
+ 803,
+ 2798,
+ 1921,
+ 2116,
+ 4236,
+ 3834,
+ 2309,
+ 1128,
+ 4163,
+ 369,
+ 1881,
+ 960,
+ 2872,
+ 2657,
+ 66,
+ 1334,
+ 813,
+ 565,
+ 1224,
+ 4516,
+ 947,
+ 267,
+ 447,
+ 3940,
+ 2050,
+ 2465,
+ 1852,
+ 4685,
+ 1418,
+ 4060,
+ 129,
+ 82,
+ 903,
+ 1244,
+ 2644,
+ 4357,
+ 1936,
+ 2628,
+ 64,
+ 2431,
+ 1172,
+ 4617,
+ 4214,
+ 3069,
+ 2968,
+ 2146,
+ 1831,
+ 3035,
+ 4181,
+ 638,
+ 3440,
+ 1249,
+ 3162,
+ 1212,
+ 1451,
+ 948,
+ 3979,
+ 1209,
+ 1872,
+ 590,
+ 4625,
+ 2641,
+ 3350,
+ 2826,
+ 1220,
+ 1050,
+ 3875,
+ 256,
+ 2267,
+ 2676,
+ 3525,
+ 3226,
+ 3138,
+ 3571,
+ 4674,
+ 4244,
+ 1040,
+ 4368,
+ 311,
+ 2960,
+ 3950,
+ 1975,
+ 152,
+ 833,
+ 552,
+ 2848,
+ 2631,
+ 4517,
+ 2907,
+ 3457,
+ 3640,
+ 3562,
+ 2917,
+ 931,
+ 1396,
+ 3770,
+ 3370,
+ 1273,
+ 368,
+ 1401,
+ 4627,
+ 1984,
+ 2203,
+ 1265,
+ 1779,
+ 773,
+ 564,
+ 4457,
+ 4253,
+ 955,
+ 3270,
+ 1673,
+ 1134,
+ 701,
+ 1545,
+ 3070,
+ 2761,
+ 1694,
+ 3659,
+ 3136,
+ 465,
+ 7,
+ 290,
+ 782,
+ 731,
+ 982,
+ 1140,
+ 4294,
+ 134,
+ 446,
+ 3598,
+ 622,
+ 289,
+ 339,
+ 2922,
+ 1967,
+ 3847,
+ 3106,
+ 1552,
+ 1433,
+ 3294,
+ 679,
+ 594,
+ 4299,
+ 643,
+ 673,
+ 2529,
+ 4068,
+ 322,
+ 3574,
+ 327,
+ 123,
+ 568,
+ 4121,
+ 3416,
+ 793,
+ 1010,
+ 4116,
+ 3641,
+ 483,
+ 3494,
+ 2071,
+ 2360,
+ 1609,
+ 3935,
+ 107,
+ 1111,
+ 4110,
+ 4295,
+ 4219,
+ 1470,
+ 135,
+ 4142,
+ 1783,
+ 144,
+ 3176,
+ 708,
+ 2313,
+ 696,
+ 1434,
+ 38,
+ 3378,
+ 2170,
+ 1415,
+ 4072,
+ 1950,
+ 2026,
+ 1682,
+ 600,
+ 4535,
+ 2186,
+ 2022,
+ 37,
+ 1564,
+ 2315,
+ 3304,
+ 1806,
+ 2042,
+ 1862,
+ 4653,
+ 1916,
+ 3890,
+ 4371,
+ 1139,
+ 2437,
+ 3672,
+ 3020,
+ 1091,
+ 1054,
+ 2888,
+ 2430,
+ 1100,
+ 1915,
+ 23,
+ 2012,
+ 4422,
+ 1043,
+ 4227,
+ 3131,
+ 4676,
+ 126,
+ 1447,
+ 2648,
+ 2652,
+ 1678,
+ 4290,
+ 4587,
+ 1200,
+ 1020,
+ 3977,
+ 4172,
+ 2278,
+ 1838,
+ 2070,
+ 2627,
+ 1518,
+ 2004,
+ 1292,
+ 1149,
+ 3575,
+ 1367,
+ 3881,
+ 3552,
+ 313,
+ 727,
+ 2544,
+ 2480,
+ 2867,
+ 1479,
+ 439,
+ 2637,
+ 821,
+ 627,
+ 98,
+ 1926,
+ 1123,
+ 1129,
+ 3777,
+ 1997,
+ 3455,
+ 1459,
+ 4703,
+ 4048,
+ 4207,
+ 2841,
+ 4480,
+ 94,
+ 2286,
+ 433,
+ 3626,
+ 456,
+ 1185,
+ 2902,
+ 4014,
+ 3469,
+ 4150,
+ 3814,
+ 2325,
+ 3638,
+ 914,
+ 3284,
+ 1513,
+ 945,
+ 2015,
+ 4654,
+ 2571,
+ 2498,
+ 2573,
+ 1231,
+ 2732,
+ 1642,
+ 207,
+ 3752,
+ 1257,
+ 1453,
+ 3608,
+ 3964,
+ 946,
+ 916,
+ 2304,
+ 4564,
+ 4284,
+ 4363,
+ 1484,
+ 3683,
+ 2896,
+ 4274,
+ 4663,
+ 907,
+ 4606,
+ 639,
+ 553,
+ 998,
+ 606,
+ 220,
+ 3326,
+ 740,
+ 2469,
+ 4046,
+ 3894,
+ 4545,
+ 3021,
+ 3495,
+ 816,
+ 4,
+ 4525,
+ 2219,
+ 1133,
+ 1837,
+ 1460,
+ 3059,
+ 2262,
+ 1335,
+ 1092,
+ 2341,
+ 3231,
+ 3399,
+ 1228,
+ 3718,
+ 4550,
+ 3139,
+ 3498,
+ 3904,
+ 1468,
+ 3519,
+ 3502,
+ 3741,
+ 2725,
+ 3177,
+ 532,
+ 2274,
+ 2472,
+ 3254,
+ 3774,
+ 4385,
+ 168,
+ 2384,
+ 2568,
+ 4392,
+ 3400,
+ 1478,
+ 2495,
+ 4697,
+ 3630,
+ 4287,
+ 402,
+ 2764,
+ 2428,
+ 4377,
+ 3002,
+ 4649,
+ 4280,
+ 4198,
+ 2261,
+ 1432,
+ 238,
+ 1645,
+ 3805,
+ 3631
+ ],
+ "val_indices": [
+ 1051,
+ 849,
+ 4437,
+ 847,
+ 738,
+ 1171,
+ 4322,
+ 1476,
+ 1701,
+ 3407,
+ 2887,
+ 3438,
+ 3119,
+ 1365,
+ 4383,
+ 3170,
+ 2257,
+ 3024,
+ 2607,
+ 3146,
+ 3757,
+ 1430,
+ 265,
+ 3247,
+ 468,
+ 1064,
+ 1467,
+ 415,
+ 2157,
+ 2993,
+ 239,
+ 2970,
+ 1412,
+ 3197,
+ 4087,
+ 2084,
+ 1781,
+ 2301,
+ 2541,
+ 4604,
+ 2509,
+ 1827,
+ 3354,
+ 1408,
+ 1104,
+ 881,
+ 1865,
+ 2333,
+ 1623,
+ 4592,
+ 1013,
+ 2642,
+ 1250,
+ 3522,
+ 3424,
+ 177,
+ 1879,
+ 3198,
+ 1075,
+ 1275,
+ 2338,
+ 1946,
+ 1622,
+ 2981,
+ 965,
+ 50,
+ 957,
+ 918,
+ 4039,
+ 4034,
+ 2977,
+ 1191,
+ 1818,
+ 1458,
+ 3707,
+ 2895,
+ 2819,
+ 1296,
+ 729,
+ 527,
+ 3617,
+ 4474,
+ 163,
+ 3507,
+ 3291,
+ 52,
+ 2714,
+ 3111,
+ 2412,
+ 2434,
+ 1505,
+ 90,
+ 838,
+ 861,
+ 645,
+ 3128,
+ 3261,
+ 2424,
+ 1752,
+ 1749,
+ 2618,
+ 4712,
+ 3490,
+ 2326,
+ 4017,
+ 4168,
+ 823,
+ 926,
+ 4226,
+ 448,
+ 2347,
+ 596,
+ 388,
+ 1524,
+ 2751,
+ 2528,
+ 642,
+ 4026,
+ 1309,
+ 1624,
+ 2983,
+ 3949,
+ 2213,
+ 4554,
+ 4156,
+ 4086,
+ 487,
+ 12,
+ 3561,
+ 355,
+ 3976,
+ 3349,
+ 4387,
+ 1308,
+ 4049,
+ 2193,
+ 1073,
+ 2505,
+ 4633,
+ 3345,
+ 3952,
+ 4470,
+ 1511,
+ 2138,
+ 4453,
+ 206,
+ 1318,
+ 3546,
+ 3250,
+ 359,
+ 2077,
+ 2530,
+ 4644,
+ 3908,
+ 1279,
+ 4288,
+ 4382,
+ 268,
+ 810,
+ 1353,
+ 716,
+ 4521,
+ 1960,
+ 808,
+ 43,
+ 1841,
+ 1480,
+ 1374,
+ 2967,
+ 3178,
+ 1031,
+ 1385,
+ 3352,
+ 1533,
+ 1813,
+ 4602,
+ 1892,
+ 2692,
+ 2435,
+ 3622,
+ 516,
+ 616,
+ 2651,
+ 2051,
+ 1114,
+ 1669,
+ 3798,
+ 4528,
+ 4471,
+ 4149,
+ 2663,
+ 3767,
+ 1021,
+ 117,
+ 4656,
+ 2882,
+ 514,
+ 662,
+ 2056,
+ 3545,
+ 1428,
+ 2551,
+ 266,
+ 1302,
+ 175,
+ 2566,
+ 4599,
+ 3078,
+ 3695,
+ 891,
+ 1316,
+ 1816,
+ 1256,
+ 469,
+ 2482,
+ 1425,
+ 4146,
+ 2776,
+ 2852,
+ 839,
+ 121,
+ 4240,
+ 406,
+ 1835,
+ 2303,
+ 3008,
+ 3676,
+ 2332,
+ 4561,
+ 799,
+ 1388,
+ 2038,
+ 588,
+ 2905,
+ 1324,
+ 2107,
+ 1911,
+ 2823,
+ 3367,
+ 1765,
+ 3687,
+ 3169,
+ 2252,
+ 109,
+ 246,
+ 1377,
+ 683,
+ 2273,
+ 1058,
+ 4036,
+ 1018,
+ 4047,
+ 2604,
+ 1888,
+ 2980,
+ 4694,
+ 490,
+ 3655,
+ 3987,
+ 4079,
+ 3075,
+ 3360,
+ 759,
+ 2562,
+ 1607,
+ 3902,
+ 1598,
+ 26,
+ 1398,
+ 995,
+ 2682,
+ 4652,
+ 3563,
+ 2708,
+ 2032,
+ 1006,
+ 2512,
+ 462,
+ 1487,
+ 1121,
+ 3971,
+ 3371,
+ 350,
+ 1522,
+ 3074,
+ 3657,
+ 3727,
+ 2885,
+ 4361,
+ 249,
+ 770,
+ 2263,
+ 1053,
+ 4264,
+ 4107,
+ 2786,
+ 4406,
+ 2567,
+ 3919,
+ 2328,
+ 2632,
+ 385,
+ 3932,
+ 4305,
+ 3584,
+ 557,
+ 3723,
+ 1177,
+ 5,
+ 3143,
+ 1234,
+ 668,
+ 3898,
+ 318,
+ 2961,
+ 399,
+ 1346,
+ 2280,
+ 2500,
+ 4317,
+ 3268,
+ 682,
+ 2319,
+ 3340,
+ 1686,
+ 3698,
+ 2372,
+ 2940,
+ 752,
+ 1306,
+ 2750,
+ 2829,
+ 1884,
+ 937,
+ 294,
+ 3939,
+ 4603,
+ 1777,
+ 4376,
+ 4404,
+ 1437,
+ 2937,
+ 1657,
+ 2417,
+ 3801,
+ 3187,
+ 4581,
+ 3497,
+ 3926,
+ 4576,
+ 371,
+ 1517,
+ 2192,
+ 2876,
+ 3029,
+ 2702,
+ 188,
+ 1130,
+ 1649,
+ 4218,
+ 3275,
+ 274,
+ 55,
+ 3313,
+ 2439,
+ 3132,
+ 1024,
+ 2204,
+ 2978,
+ 1834,
+ 1819,
+ 4375,
+ 4477,
+ 632,
+ 857,
+ 1621,
+ 1679,
+ 2266,
+ 2413,
+ 2821,
+ 3568,
+ 2230,
+ 779,
+ 2699,
+ 4210,
+ 1697,
+ 347,
+ 3242,
+ 2600,
+ 1611,
+ 241,
+ 2314,
+ 1080,
+ 2234,
+ 2425,
+ 4498,
+ 1969,
+ 3446,
+ 3204,
+ 3066,
+ 2006,
+ 4458,
+ 3768,
+ 2527,
+ 3567,
+ 2742,
+ 3040,
+ 3793,
+ 3042,
+ 3289,
+ 1102,
+ 1637,
+ 3930,
+ 4575,
+ 1592,
+ 430,
+ 3530,
+ 4222,
+ 1472,
+ 1410,
+ 3025,
+ 753,
+ 3595,
+ 4436,
+ 1615,
+ 4011,
+ 1301,
+ 3583,
+ 2858,
+ 3867,
+ 2613,
+ 3732,
+ 4475,
+ 2713,
+ 4670,
+ 2772,
+ 421,
+ 3828,
+ 3429,
+ 2945,
+ 3837,
+ 2336,
+ 3980,
+ 1379,
+ 2664,
+ 1206,
+ 4655,
+ 3086,
+ 4565,
+ 1528,
+ 1799,
+ 438,
+ 3305,
+ 2625,
+ 2727,
+ 3129,
+ 3537,
+ 660,
+ 4557,
+ 3921,
+ 826,
+ 509,
+ 3807,
+ 3109,
+ 4709,
+ 2198,
+ 1574,
+ 454,
+ 4501,
+ 3606,
+ 1156
+ ]
+}
diff --git a/property_prediction/configs/spherenet/README.md b/property_prediction/configs/spherenet/README.md
new file mode 100644
index 00000000..ea5c6584
--- /dev/null
+++ b/property_prediction/configs/spherenet/README.md
@@ -0,0 +1,261 @@
+# SphereNet
+
+[Spherical Message Passing for 3D Molecular Graphs](https://arxiv.org/abs/2102.05013) (ICLR 2021)
+
+## Abstract
+
+We propose the spherical message passing (SMP) scheme for 3D molecular graphs,
+which leverages **distance, angle, and torsion** information simultaneously to
+uniquely identify the relative positions of atoms in 3D space. Previous
+methods such as SchNet (distance-only) and DimeNet++ (distance + angle) suffer
+from equivariance ambiguity because multiple spatial configurations can map
+to the same pairwise distances or angles. By incorporating torsion angles
+(dihedral angles), SphereNet resolves this ambiguity and achieves
+state-of-the-art results on the QM9 benchmark.
+
+
+
+
+ Figure 1: SphereNet architecture.
+
+
+## Datasets
+
+### QM9
+
+The QM9 dataset contains 130,831 small organic molecules (up to 9 heavy
+atoms: C, O, N, F) with 12 quantum-chemical properties computed at the
+B3LYP/6-31G(2df,p) level of theory.
+
+| Split | Size |
+|---------|--------|
+| Train | 110,831 |
+| Val | 10,000 |
+| Test | 10,000 |
+| **Total** | **130,831** |
+
+**Data format**: Each molecule contains atomic numbers (`z`), 3D positions
+(`pos`), and 12 property labels. The raw dataset is available at
+[figshare](https://figshare.com/ndownloader/files/3195389).
+
+**Reference**: [Quantum-chemical insights from deep learning](https://arxiv.org/abs/1708.04444) (Gaussian, 2017)
+
+## Model
+
+SphereNet is a spherical message passing neural network for 3D molecular
+graphs. It represents each molecule as a graph where nodes correspond to
+atoms, and directed edges encode interatomic interactions within a cutoff
+radius. The model builds a hierarchy of geometric features and propagates
+information using spherical message passing.
+
+### Geometric embedding hierarchy
+
+SphereNet constructs three levels of geometric embeddings to capture the
+full 3D structure:
+
+**1. Radial (distance) embeddings** — For each directed edge $j \to i$, the
+interatomic distance $d_{ji}$ is expanded using a radial basis function
+(RBF) composed with a smooth envelope.
+
+**2. Angular (spherical) embeddings** — For each triplet $k \to j \to i$,
+the bond angle $\theta_{kji}$ is expanded together with the distance
+$d_{kj}$ using spherical Bessel functions combined with Legendre
+polynomials (spherical Fourier-Bessel basis).
+
+**3. Torsional embeddings** — For each quadruplet $l \to k \to j \to i$,
+the torsion (dihedral) angle $\tau_{lkji}$ together with distances $d_{lk}$
+and $d_{kj}$ is expanded using a 3D spherical Fourier-Bessel basis.
+
+## Results
+
+
+
+### Training
+
+```bash
+# Single-GPU training — QM9 mu property
+python property_prediction/train.py \
+ -c property_prediction/configs/spherenet/spherenet_qm9_mu.yaml
+```
+
+### Validation
+
+```bash
+python property_prediction/train.py \
+ -c property_prediction/configs/spherenet/spherenet_qm9_mu.yaml \
+ Global.do_eval=True Global.do_train=False Global.do_test=False \
+ Trainer.pretrained_model_path='your_model.pdparams'
+```
+
+### Testing
+
+```bash
+python property_prediction/train.py \
+ -c property_prediction/configs/spherenet/spherenet_qm9_mu.yaml \
+ Global.do_test=True Global.do_train=False Global.do_eval=False \
+ Trainer.pretrained_model_path='your_model.pdparams'
+```
+
+### Prediction
+
+```bash
+# Using a registered QM9 model
+python property_prediction/predict.py \
+ --model_name spherenet_qm9_mu \
+ --xyz_file_path ./property_prediction/example_data/molecules/isoguvacine.xyz \
+ --save_path ./output/spherenet_qm9_mu_prediction.csv
+
+# Using a local checkpoint
+python property_prediction/predict.py \
+ --config_path ./property_prediction/configs/spherenet/spherenet_qm9_mu.yaml \
+ --checkpoint_path ./output/spherenet_qm9_mu_t_*/checkpoints/best.pdparams \
+ --xyz_file_path ./property_prediction/example_data/molecules/isoguvacine.xyz \
+ --save_path ./output/spherenet_qm9_mu_prediction.csv
+```
+
+## Citation
+
+```bibtex
+@inproceedings{liu2021spherenet,
+ title={Spherical Message Passing for 3D Molecular Graphs},
+ author={Liu, Yi and Wang, Limei and Liu, Meng and Lin, Yuchao and Zhang, Xuan and
+ Oztekin, Bora and Ji, Shuiwang},
+ booktitle={International Conference on Learning Representations (ICLR)},
+ year={2021}
+}
+```
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_Cv.yaml b/property_prediction/configs/spherenet/spherenet_qm9_Cv.yaml
new file mode 100644
index 00000000..86376acf
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_Cv.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - Cv
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_Cv
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: Cv
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: Cv
+
+Metric:
+ Cv:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [Cv]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [Cv]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [Cv]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_G.yaml b/property_prediction/configs/spherenet/spherenet_qm9_G.yaml
new file mode 100644
index 00000000..1450b355
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_G.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - G
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_G
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: G
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: G
+
+Metric:
+ G:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [G]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [G]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [G]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_H.yaml b/property_prediction/configs/spherenet/spherenet_qm9_H.yaml
new file mode 100644
index 00000000..ce3da794
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_H.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - H
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_H
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: H
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: H
+
+Metric:
+ H:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [H]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [H]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [H]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_U.yaml b/property_prediction/configs/spherenet/spherenet_qm9_U.yaml
new file mode 100644
index 00000000..ff1e524a
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_U.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - U
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_U
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: U
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: U
+
+Metric:
+ U:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [U]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [U]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [U]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_U0.yaml b/property_prediction/configs/spherenet/spherenet_qm9_U0.yaml
new file mode 100644
index 00000000..c7795bb1
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_U0.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - U0
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_U0
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: U0
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: U0
+
+Metric:
+ U0:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [U0]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [U0]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [U0]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_alpha.yaml b/property_prediction/configs/spherenet/spherenet_qm9_alpha.yaml
new file mode 100644
index 00000000..38103386
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_alpha.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - alpha
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_alpha
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: alpha
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: alpha
+
+Metric:
+ alpha:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [alpha]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [alpha]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [alpha]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_gap.yaml b/property_prediction/configs/spherenet/spherenet_qm9_gap.yaml
new file mode 100644
index 00000000..22adb8df
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_gap.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - gap
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_gap
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: gap
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: gap
+
+Metric:
+ gap:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [gap]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [gap]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [gap]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_homo.yaml b/property_prediction/configs/spherenet/spherenet_qm9_homo.yaml
new file mode 100644
index 00000000..21bb21a0
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_homo.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - homo
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_homo
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: homo
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: homo
+
+Metric:
+ homo:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [homo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [homo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [homo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_lumo.yaml b/property_prediction/configs/spherenet/spherenet_qm9_lumo.yaml
new file mode 100644
index 00000000..0837adb4
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_lumo.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - lumo
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_lumo
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: lumo
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: lumo
+
+Metric:
+ lumo:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [lumo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [lumo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [lumo]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_mu.yaml b/property_prediction/configs/spherenet/spherenet_qm9_mu.yaml
new file mode 100644
index 00000000..697e8611
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_mu.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - mu
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_mu
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: mu
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: mu
+
+Metric:
+ mu:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [mu]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [mu]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [mu]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_r2.yaml b/property_prediction/configs/spherenet/spherenet_qm9_r2.yaml
new file mode 100644
index 00000000..9e73438f
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_r2.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - r2
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_r2
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: r2
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: r2
+
+Metric:
+ r2:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [r2]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [r2]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [r2]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/configs/spherenet/spherenet_qm9_zpve.yaml b/property_prediction/configs/spherenet/spherenet_qm9_zpve.yaml
new file mode 100644
index 00000000..29f9b72a
--- /dev/null
+++ b/property_prediction/configs/spherenet/spherenet_qm9_zpve.yaml
@@ -0,0 +1,151 @@
+Global:
+ label_names:
+ - zpve
+ do_train: true
+ do_eval: true
+ do_test: true
+
+ build_molecule_cfg:
+ format: xyz_block
+ sanitize: false
+ add_hs: false
+ remove_hs: false
+ kekulize: false
+ num_cpus: 10
+
+ graph_converter:
+ __class_name__: RadiusGraphConverter
+ __init_params__:
+ cutoff: 5.0
+ return_triplet_indices: true
+ num_cpus: 10
+
+Trainer:
+ # Max epochs to train
+ max_epochs: 100
+ # Random seed
+ seed: 42
+ # Save path for checkpoints and logs
+ output_dir: ./output/spherenet_qm9_zpve
+ # Save frequency [epoch]; set 0 to disable saving during training
+ save_freq: 50
+ # Logging frequency [step]
+ log_freq: 10
+ # Start evaluation epoch
+ start_eval_epoch: 1
+ # Evaluation frequency [epoch]; set 0 to disable evaluation
+ eval_freq: 1
+ # Whether to use automatic mixed precision
+ use_amp: false
+ # Whether to run evaluation with no_grad (saves memory)
+ eval_with_no_grad: true
+ # Gradient accumulation steps
+ gradient_accumulation_steps: 1
+ # Best metric indicator: "train_loss", "eval_loss", "train_metric", "eval_metric"
+ best_metric_indicator: eval_metric
+ # Name of the best metric for checkpoint selection
+ name_for_best_metric: zpve
+ # Whether a greater metric value is better
+ greater_is_better: false
+ # Compute metric during training
+ compute_metric_during_train: false
+ # Metric computation strategy during eval: "step" or "epoch"
+ metric_strategy_during_eval: step
+ # Pretrained model path; null means no pretrained model
+ pretrained_model_path:
+Model:
+ __class_name__: SphereNet
+ __init_params__:
+ energy_and_force: false
+ cutoff: 5.0
+ num_layers: 4
+ hidden_channels: 128
+ out_channels: 1
+ int_emb_size: 64
+ basis_emb_size_dist: 8
+ basis_emb_size_angle: 8
+ basis_emb_size_torsion: 8
+ out_emb_channels: 256
+ num_spherical: 7
+ num_radial: 6
+ envelope_exponent: 5
+ num_before_skip: 1
+ num_after_skip: 2
+ num_output_layers: 3
+ output_init: zeros
+ property_name: zpve
+
+Metric:
+ zpve:
+ __class_name__: paddle.nn.L1Loss
+ __init_params__: {}
+Optimizer:
+ __class_name__: Adam
+ __init_params__:
+ beta1: 0.9
+ beta2: 0.999
+ lr:
+ __class_name__: Cosine
+ __init_params__:
+ learning_rate: 0.0005
+ eta_min: 0.00001
+ by_epoch: true
+
+Dataset:
+ train:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/train.csv
+ property_names: [zpve]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: true
+ drop_last: true
+ val:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/val.csv
+ property_names: [zpve]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+ test:
+ dataset:
+ __class_name__: QM9Dataset
+ __init_params__:
+ path: ./data/qm9/test.csv
+ property_names: [zpve]
+ build_molecule_cfg: ${Global.build_molecule_cfg}
+ build_graph_cfg: ${Global.graph_converter}
+ loader:
+ num_workers: 4
+ use_shared_memory: false
+ collate_fn: RadiusGraphCollator
+ sampler:
+ __class_name__: BatchSampler
+ __init_params__:
+ batch_size: 8
+ shuffle: false
+ drop_last: false
+
+Predict:
+ graph_converter: ${Global.graph_converter}
diff --git a/property_prediction/docs/SphereNet.png b/property_prediction/docs/SphereNet.png
new file mode 100644
index 00000000..0e5d6d37
Binary files /dev/null and b/property_prediction/docs/SphereNet.png differ
diff --git a/property_prediction/example_data/molecules/isoguvacine.xyz b/property_prediction/example_data/molecules/isoguvacine.xyz
new file mode 100644
index 00000000..0d475100
--- /dev/null
+++ b/property_prediction/example_data/molecules/isoguvacine.xyz
@@ -0,0 +1,20 @@
+18
+3765
+O -2.27580 1.09430 -0.52060
+O -2.45290 -0.98760 0.43970
+N 2.48110 0.05010 -0.32780
+C 0.38060 1.27960 0.14140
+C 1.85870 1.08410 0.50360
+C -0.33480 -0.02920 -0.00120
+C 1.82160 -1.24840 -0.13170
+C 0.31920 -1.19430 -0.10830
+C -1.79770 -0.04850 0.00490
+H 0.30880 1.83100 -0.80460
+H -0.09120 1.89220 0.91860
+H 2.39120 2.03070 0.35900
+H 1.96030 0.82180 1.56460
+H 2.16900 -1.70040 0.80520
+H 2.13650 -1.91110 -0.94510
+H 3.46880 -0.03410 -0.09020
+H -0.21000 -2.13970 -0.18490
+H -3.25650 1.11560 -0.52950
diff --git a/property_prediction/predict.py b/property_prediction/predict.py
index 3866bcba..6de83b44 100644
--- a/property_prediction/predict.py
+++ b/property_prediction/predict.py
@@ -24,12 +24,12 @@
from pymatgen.core import Structure
from tqdm import tqdm
+from ppmat.datasets.build_molecule import BuildMolecule
from ppmat.datasets.transform import build_post_transforms
from ppmat.models import build_graph_converter
from ppmat.models import build_model
from ppmat.models import build_model_from_name
from ppmat.utils import logger
-from ppmat.utils import save_load
class PropertyPredictor:
@@ -90,7 +90,18 @@ def __init__(
model_config = config.get("Model", None)
assert model_config is not None, "Model config must be provided."
model = build_model(model_config)
- save_load.load_pretrain(model, checkpoint_path)
+ checkpoint_path = osp.abspath(checkpoint_path)
+ if not osp.isfile(checkpoint_path):
+ raise FileNotFoundError(
+ f"Checkpoint file does not exist: {checkpoint_path}"
+ )
+ state_dict = paddle.load(checkpoint_path)
+ missing_keys, unexpected_keys = model.set_state_dict(state_dict)
+ if missing_keys or unexpected_keys:
+ raise ValueError(
+ "Checkpoint is incompatible with the configured model: "
+ f"missing={list(missing_keys)}, unexpected={list(unexpected_keys)}"
+ )
else:
logger.info("Since model_name is given, downloading it...")
@@ -101,7 +112,7 @@ def __init__(
self.model.eval()
- predict_config = config.get("Predict", None)
+ predict_config = config.get("Predict", {})
self.predict_config = predict_config
self.eval_with_no_grad = predict_config.get("eval_with_no_grad", True)
@@ -111,6 +122,13 @@ def __init__(
if graph_converter_config is not None:
self.graph_converter_fn = build_graph_converter(graph_converter_config)
+ if getattr(self.model, "requires_forward_grad", False):
+ if self.eval_with_no_grad:
+ raise ValueError(
+ "This model requires forward gradients; set Predict.eval_with_no_grad to false."
+ )
+ self.eval_with_no_grad = False
+
self.post_transforms_cfg = predict_config.get("post_transforms", None)
if self.post_transforms_cfg is not None:
self.post_transforms = build_post_transforms(self.post_transforms_cfg)
@@ -120,6 +138,11 @@ def __init__(
def graph_converter(self, structure):
if self.graph_converter_fn is None:
return structure
+ prediction_input = getattr(
+ self.graph_converter_fn, "build_prediction_input", None
+ )
+ if callable(prediction_input):
+ return prediction_input(structure)
return self.graph_converter_fn(structure)
def post_process(self, data):
@@ -127,6 +150,13 @@ def post_process(self, data):
return data
return self.post_transforms(data)
+ def _load_structure_from_cif(self, cif_file_path):
+ """Load one CIF through an optional model-specific input adapter."""
+ loader = getattr(self.graph_converter_fn, "load_structure_from_cif", None)
+ if callable(loader):
+ return loader(cif_file_path)
+ return Structure.from_file(cif_file_path)
+
def from_structures(self, structures):
data = self.graph_converter(structures)
@@ -147,11 +177,12 @@ def from_cif_file(self, cif_file_path, save_path=None):
for f in os.listdir(cif_file_path)
if f.endswith(".cif")
]
- results = []
- for cif_file in tqdm(cif_files):
- structure = Structure.from_file(cif_file)
- result = self.from_structures(structure)
- results.append(result)
+ structures = [
+ self._load_structure_from_cif(cif_file) for cif_file in cif_files
+ ]
+ results = [
+ self.from_structures(structure) for structure in tqdm(structures)
+ ]
if save_path is not None:
keys = list(results[0].keys())
@@ -167,7 +198,7 @@ def from_cif_file(self, cif_file_path, save_path=None):
return results
else:
- structure = Structure.from_file(cif_file_path)
+ structure = self._load_structure_from_cif(cif_file_path)
result = self.from_structures(structure)
keys = list(result.keys())
@@ -182,6 +213,103 @@ def from_cif_file(self, cif_file_path, save_path=None):
return result
+ def from_molecule(self, molecule_data, molecule_format):
+ """Predict properties from molecular data.
+
+ Follows the standard PaddleMaterials molecular pipeline:
+ ``BuildMolecule -> graph_converter -> predict``.
+ """
+ mol = BuildMolecule(format=molecule_format)(molecule_data)
+
+ try:
+ conf = mol.GetConformer()
+ except ValueError:
+ conf = None
+ if conf is None or not conf.Is3D():
+ from rdkit import Chem as RDChem
+ from rdkit.Chem import AllChem
+
+ if molecule_format == "smiles":
+ mol = RDChem.AddHs(mol)
+ AllChem.EmbedMolecule(mol, randomSeed=42)
+ AllChem.MMFFOptimizeMolecule(mol)
+
+ if self.graph_converter_fn is not None:
+ data = self.graph_converter_fn(mol)
+ else:
+ conf = mol.GetConformer()
+ num_atoms = mol.GetNumAtoms()
+ z = [atom.GetAtomicNum() for atom in mol.GetAtoms()]
+ pos = [
+ [
+ conf.GetAtomPosition(i).x,
+ conf.GetAtomPosition(i).y,
+ conf.GetAtomPosition(i).z,
+ ]
+ for i in range(num_atoms)
+ ]
+ data = {
+ "z": paddle.to_tensor(z, dtype=paddle.int64),
+ "pos": paddle.to_tensor(pos, dtype=paddle.get_default_dtype()),
+ "batch": paddle.zeros([num_atoms], dtype=paddle.int64),
+ }
+
+ if self.eval_with_no_grad:
+ with paddle.no_grad():
+ out = self.model.predict(data)
+ else:
+ out = self.model.predict(data)
+ return self.post_process(out)
+
+ def from_xyz_file(self, xyz_file_path, save_path=None):
+ """Predict molecular properties from XYZ file(s).
+
+ Reads each ``.xyz`` file via RDKit, then delegates to
+ :meth:`from_molecule` (``BuildMolecule → graph_converter → predict``).
+
+ Args:
+ xyz_file_path: Path to a single ``.xyz`` file or a directory
+ of ``.xyz`` files.
+ save_path: Optional CSV path.
+
+ Returns:
+ Single result dict or list of result dicts.
+ """
+ from rdkit import Chem
+
+ if save_path is not None:
+ assert save_path.endswith(".csv"), "save_path must end with .csv"
+
+ if osp.isdir(xyz_file_path):
+ xyz_files = sorted([
+ osp.join(xyz_file_path, f)
+ for f in os.listdir(xyz_file_path) if f.endswith(".xyz")
+ ])
+ else:
+ xyz_files = [xyz_file_path]
+
+ results = []
+ for xyz_path in tqdm(xyz_files, desc="Predict"):
+ with open(xyz_path, "r") as f:
+ xyz_block = f.read()
+ mol = Chem.MolFromXYZBlock(xyz_block)
+ if mol is None:
+ raise ValueError(f"Failed to parse XYZ file: {xyz_path}")
+ out = self.from_molecule(mol, "rdmol")
+ results.append(out)
+
+ if save_path is not None and results:
+ keys = list(results[0].keys())
+ props = defaultdict(list)
+ for key in keys:
+ for r in results:
+ props[key].append(r[key])
+ df = pd.DataFrame({"xyz_file": [osp.basename(f) for f in xyz_files], **props})
+ df.to_csv(save_path, index=False)
+ logger.info(f"Saved prediction results to {save_path}")
+
+ return results if len(results) > 1 else results[0]
+
if __name__ == "__main__":
@@ -216,6 +344,12 @@ def from_cif_file(self, cif_file_path, save_path=None):
default="./property_prediction/example_data/cifs/",
help="Path to the CIF file whose material properties you want to predict.",
)
+ argparse.add_argument(
+ "--xyz_file_path",
+ type=str,
+ default=None,
+ help="Path to the XYZ file whose molecular properties you want to predict.",
+ )
argparse.add_argument(
"--save_path",
type=str,
@@ -231,5 +365,13 @@ def from_cif_file(self, cif_file_path, save_path=None):
checkpoint_path=args.checkpoint_path,
)
- results = predictor.from_cif_file(args.cif_file_path, args.save_path)
+ if args.xyz_file_path is not None:
+ results = predictor.from_xyz_file(args.xyz_file_path, args.save_path)
+ elif args.cif_file_path is not None:
+ results = predictor.from_cif_file(args.cif_file_path, args.save_path)
+ else:
+ raise ValueError(
+ "Provide --xyz_file_path for molecular prediction, "
+ "or --cif_file_path for crystal prediction."
+ )
print(results)
diff --git a/setup.py b/setup.py
index a1a71aa2..9f27e152 100644
--- a/setup.py
+++ b/setup.py
@@ -42,7 +42,8 @@ def get_requirements() -> list:
),
long_description=get_readme(),
long_description_content_type="text/markdown",
- packages=setuptools.find_packages(
+ packages=setuptools.find_namespace_packages(
+ include=("ppmat", "ppmat.*"),
exclude=(
"docs",
"examples",
@@ -51,8 +52,19 @@ def get_requirements() -> list:
"interatomic_potentials",
"property_prediction",
"structure_generation",
- )
- ),
+ ),
+ )
+ + ["property_prediction"],
+ package_data={
+ "property_prediction": [
+ "configs/gmtnet/README.md",
+ "configs/gmtnet/gmtnet_jarvis_dielectric.yaml",
+ "configs/gmtnet/split_gmtnet_dielectric_seed32.json",
+ ],
+ },
+ exclude_package_data={
+ "": ["*.pdparams", "*.pkl"],
+ },
classifiers=[
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
diff --git a/test/test_download.py b/test/test_download.py
new file mode 100644
index 00000000..091dc358
--- /dev/null
+++ b/test/test_download.py
@@ -0,0 +1,39 @@
+import zipfile
+from pathlib import Path
+
+from ppmat.datasets.md17_dataset import MD17Dataset
+from ppmat.utils import download
+from ppmat.utils.download import _uncompress_file_zip
+
+
+def test_uncompress_single_directory_preserves_archive_root(tmp_path):
+ archive_path = tmp_path / "model.zip"
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr("model/", b"")
+ archive.writestr("model/checkpoints/best.pdparams", b"weights")
+ archive.writestr("model/model.yaml", b"Model: {}")
+
+ extracted_path = _uncompress_file_zip(str(archive_path))
+
+ assert Path(extracted_path) == tmp_path / "model"
+ assert (tmp_path / "model" / "model" / "model.yaml").exists()
+
+
+def test_md17_resolves_legacy_single_directory_return(tmp_path, monkeypatch):
+ extraction_root = tmp_path / "md17.tar"
+ data_path = extraction_root / "md17" / "md17_aspirin.npz"
+ data_path.parent.mkdir(parents=True)
+ data_path.touch()
+ legacy_return = extraction_root / "first_member"
+ monkeypatch.setattr(
+ download,
+ "get_datasets_path_from_url",
+ lambda *_args: str(legacy_return),
+ )
+
+ dataset = MD17Dataset.__new__(MD17Dataset)
+ resolved_path = dataset._resolve_data_path(
+ str(tmp_path / "missing.npz"), "aspirin"
+ )
+
+ assert resolved_path == str(data_path)
diff --git a/test/test_gmtnet_adapter_migration.py b/test/test_gmtnet_adapter_migration.py
new file mode 100644
index 00000000..4f0d9d82
--- /dev/null
+++ b/test/test_gmtnet_adapter_migration.py
@@ -0,0 +1,31 @@
+from unittest.mock import patch
+
+from pymatgen.core import Structure
+
+from ppmat.models import GMTNetGraphConverter
+
+
+def test_gmtnet_converter_mapping_passthrough():
+ converter = GMTNetGraphConverter()
+ mapping = {
+ "graph": object(),
+ "feature_mask": object(),
+ "matrix_equal": object(),
+ }
+ assert converter.build_prediction_input(mapping) is mapping
+ assert converter.build_prediction_input([mapping]) == [mapping]
+
+
+def test_gmtnet_precision_cif_loader_arguments(tmp_path):
+ converter = GMTNetGraphConverter()
+ cif_path = tmp_path / "sample.cif"
+ cif_path.write_text("data_test\n")
+ with patch.object(Structure, "from_file", return_value=object()) as loader:
+ assert converter.load_structure_from_cif(cif_path) is loader.return_value
+ loader.assert_called_once_with(
+ cif_path,
+ primitive=False,
+ sort=False,
+ merge_tol=0.0,
+ frac_tolerance=0.0,
+ )
diff --git a/test/test_gmtnet_dataset_collator.py b/test/test_gmtnet_dataset_collator.py
new file mode 100644
index 00000000..243d2668
--- /dev/null
+++ b/test/test_gmtnet_dataset_collator.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+
+import paddle
+import yaml
+
+from ppmat.datasets.collate_fn import DefaultCollator
+from ppmat.datasets.geometric_data_type.data import Data
+from ppmat.datasets.gmtnet_dataset import GMTNetDielectricDataset
+
+
+def _sample(data_index: int) -> dict:
+ return {
+ "graph": Data(
+ x=paddle.to_tensor([[1.0, 0.0]], dtype="float32"),
+ edge_index=paddle.to_tensor([[0], [0]], dtype="int64"),
+ edge_attr=paddle.to_tensor([[0.0, 0.0, 1.0]], dtype="float32"),
+ ),
+ "feature_mask": paddle.eye(32, dtype="float32"),
+ "matrix_equal": paddle.eye(9, dtype="float32").astype("bool"),
+ "dielectric": paddle.eye(3, dtype="float32"),
+ "id": f"sample-{data_index}",
+ "data_index": paddle.to_tensor(data_index, dtype="int64"),
+ }
+
+
+def test_gmtnet_dataset_declares_dielectric_property_name():
+ assert GMTNetDielectricDataset.property_names == ("dielectric",)
+
+
+def test_default_collator_batches_gmtnet_sample_without_special_collator():
+ batch = DefaultCollator()([_sample(7)])
+
+ assert type(batch["graph"]).__name__ == "Batch"
+ assert list(batch["graph"].x.shape) == [1, 2]
+ assert list(batch["graph"].edge_index.shape) == [2, 1]
+ assert list(batch["feature_mask"].shape) == [1, 32, 32]
+ assert list(batch["matrix_equal"].shape) == [1, 9, 9]
+ assert list(batch["dielectric"].shape) == [1, 3, 3]
+ assert list(batch["data_index"].shape) == [1]
+ assert batch["data_index"].dtype == paddle.int64
+ assert batch["id"] == ["sample-7"]
+
+
+def test_default_collator_batches_two_gmtnet_samples():
+ batch = DefaultCollator()([_sample(7), _sample(9)])
+
+ assert type(batch["graph"]).__name__ == "Batch"
+ assert list(batch["graph"].x.shape) == [2, 2]
+ assert list(batch["graph"].batch.shape) == [2]
+ assert list(batch["feature_mask"].shape) == [2, 32, 32]
+ assert list(batch["matrix_equal"].shape) == [2, 9, 9]
+ assert list(batch["dielectric"].shape) == [2, 3, 3]
+ assert batch["data_index"].numpy().tolist() == [7, 9]
+
+
+def test_gmtnet_yaml_uses_default_collator_only():
+ config_path = Path("property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml")
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
+
+ for split_name in ("train", "val", "test"):
+ assert config["Dataset"][split_name]["loader"]["collate_fn"] == "DefaultCollator"
diff --git a/test/test_gmtnet_split_resources.py b/test/test_gmtnet_split_resources.py
new file mode 100644
index 00000000..c6118cd0
--- /dev/null
+++ b/test/test_gmtnet_split_resources.py
@@ -0,0 +1,79 @@
+import hashlib
+import json
+from importlib import resources
+from pathlib import Path
+
+import yaml
+
+from ppmat.datasets import gmtnet_dataset
+from ppmat.datasets.gmtnet_dataset import (
+ GMTNetDielectricDataset,
+ _CachedPayload,
+ _default_split_resource,
+)
+from ppmat.datasets.split_gmtnet_dataset import (
+ EXPECTED_SOURCE_SHA256,
+ load_and_validate_split,
+)
+
+
+EXPECTED_SHA256 = "8d426234d0a89d1794cccb3560c3b9d397f186253703f8b9e2cb86777b2fd4df"
+
+
+def test_default_gmtnet_split_resource_is_canonical():
+ resource = _default_split_resource()
+ with resources.as_file(resource) as split_path:
+ assert hashlib.sha256(split_path.read_bytes()).hexdigest() == EXPECTED_SHA256
+ split_indices = load_and_validate_split(split_path)
+
+ assert {name: len(indices) for name, indices in split_indices.items()} == {
+ "train": 3770,
+ "val": 471,
+ "test": 472,
+ }
+ assert split_indices["test"][:3] == [747, 1423, 1322]
+
+
+def test_explicit_gmtnet_split_path_keeps_order():
+ resource = _default_split_resource()
+ with resources.as_file(resource) as split_path:
+ split_indices = load_and_validate_split(split_path)
+ split_data = json.loads(split_path.read_text(encoding="utf-8"))
+
+ for split_name in ("train", "val", "test"):
+ assert split_indices[split_name] == split_data[f"{split_name}_indices"]
+
+
+def test_dataset_uses_resource_fallback_without_current_directory(
+ monkeypatch, tmp_path
+):
+ data_path = tmp_path / "normalized.pkl"
+ data_path.write_bytes(b"placeholder")
+ payload = {"source_original_dataset_sha256": EXPECTED_SOURCE_SHA256}
+ cache_entry = _CachedPayload(payload=payload, sha256="not-checked")
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(
+ GMTNetDielectricDataset,
+ "_load_payload",
+ classmethod(lambda cls, path: cache_entry),
+ )
+ monkeypatch.setattr(gmtnet_dataset, "GMTNetGraphConverter", lambda **kwargs: kwargs)
+
+ dataset = GMTNetDielectricDataset(
+ data_path=data_path,
+ split="test",
+ verify_sha256=False,
+ )
+
+ assert dataset.split_path.name == "split_gmtnet_dielectric_seed32.json"
+ assert dataset._split_indices[:3] == (747, 1423, 1322)
+
+
+def test_gmtnet_yaml_uses_dataset_resource_fallback():
+ config_path = Path("property_prediction/configs/gmtnet/gmtnet_jarvis_dielectric.yaml")
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
+
+ for split_name in ("train", "val", "test"):
+ dataset_params = config["Dataset"][split_name]["dataset"]["__init_params__"]
+ assert "split_path" not in dataset_params
diff --git a/test/test_spherenet.py b/test/test_spherenet.py
new file mode 100644
index 00000000..51be97eb
--- /dev/null
+++ b/test/test_spherenet.py
@@ -0,0 +1,226 @@
+# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import math
+
+import numpy as np
+import paddle
+from rdkit import Chem
+from scipy import special
+
+from ppmat.datasets.build_molecule import BuildMolecule
+from ppmat.datasets.collate_fn import RadiusGraphCollator
+from ppmat.models.common.graph_converter import RadiusGraphConverter
+from ppmat.models.common.spherical_fourier_bessel import RealSphericalHarmonics
+from ppmat.models.common.spherical_fourier_bessel import SphericalBesselBasis
+from ppmat.models.common.spherical_fourier_bessel import (
+ SphericalFourierBesselEmbedding,
+)
+from ppmat.models.common.spherical_fourier_bessel import _build_basis_constants
+from ppmat.models.spherenet.geometry import compute_geometry
+from ppmat.models.spherenet.spherenet import SphereNet
+
+
+def setup_module():
+ paddle.set_device("cpu")
+
+
+def test_build_molecule_from_atomic_numbers_and_positions():
+ molecule = BuildMolecule(format="dict", sanitize=False)(
+ {
+ "atomic_numbers": np.array([8, 1, 1]),
+ "positions": np.array(
+ [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]]
+ ),
+ }
+ )
+
+ assert [atom.GetAtomicNum() for atom in molecule.GetAtoms()] == [8, 1, 1]
+ np.testing.assert_allclose(
+ molecule.GetConformer().GetPositions(),
+ [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]],
+ )
+
+
+def test_basis_constants_are_cached_by_shape():
+ _build_basis_constants.cache_clear()
+ constants_7_6 = _build_basis_constants(7, 6)
+ constants_3_4 = _build_basis_constants(3, 4)
+
+ assert constants_7_6 is _build_basis_constants(7, 6)
+ assert constants_7_6[0].shape == (7, 6)
+ assert constants_3_4[0].shape == (3, 4)
+ assert not constants_7_6[0].flags.writeable
+ assert _build_basis_constants.cache_info().currsize == 2
+
+
+def test_spherical_bessel_matches_scipy_value_and_gradient():
+ num_spherical = 7
+ num_radial = 6
+ distances = np.array(
+ [1e-4, 0.03, 0.15, 0.7, 1.3, 2.6, 4.9],
+ dtype=np.float32,
+ )
+ dist = paddle.to_tensor(distances, stop_gradient=False)
+ actual = SphericalBesselBasis(num_spherical, num_radial)(dist)
+
+ zeros, normalizers, _ = _build_basis_constants(
+ num_spherical, num_radial
+ )
+ arguments = distances[:, None, None].astype(np.float64) / 5.0
+ arguments = arguments * zeros[None]
+ expected = np.empty_like(arguments)
+ expected_gradient = np.zeros(len(distances), dtype=np.float64)
+ for degree in range(num_spherical):
+ expected[:, degree, :] = (
+ special.spherical_jn(degree, arguments[:, degree, :])
+ * normalizers[degree]
+ )
+ expected_gradient += np.sum(
+ special.spherical_jn(
+ degree,
+ arguments[:, degree, :],
+ derivative=True,
+ )
+ * normalizers[degree]
+ * zeros[degree]
+ / 5.0,
+ axis=1,
+ )
+
+ np.testing.assert_allclose(
+ actual.numpy(), expected, rtol=1e-4, atol=1e-5
+ )
+ actual_gradient = paddle.grad(paddle.sum(actual), dist)[0]
+ np.testing.assert_allclose(
+ actual_gradient.numpy(),
+ expected_gradient,
+ rtol=1e-4,
+ atol=3e-5,
+ )
+
+
+def test_real_spherical_harmonics_preserve_spherenet_order():
+ angle = paddle.to_tensor([math.pi / 2], dtype="float32")
+ torsion = paddle.zeros([1], dtype="float32")
+ harmonics = RealSphericalHarmonics(2)(angle, torsion)
+
+ scale = math.sqrt(3.0 / (4.0 * math.pi))
+ expected = np.array(
+ [[1.0 / math.sqrt(4.0 * math.pi), 0.0, -scale, 0.0]],
+ dtype=np.float32,
+ )
+ np.testing.assert_allclose(
+ harmonics.numpy(), expected, rtol=1e-6, atol=1e-6
+ )
+
+
+def test_embedding_supports_dynamic_shapes_and_empty_triplets():
+ for num_spherical, num_radial in ((1, 1), (3, 4), (7, 6)):
+ embedding = SphericalFourierBesselEmbedding(
+ num_spherical, num_radial
+ )
+ dist = paddle.to_tensor([0.8, 1.2], dtype="float32")
+ angle = paddle.to_tensor([0.5], dtype="float32")
+ torsion = paddle.to_tensor([0.2], dtype="float32")
+ idx_kj = paddle.to_tensor([1], dtype="int64")
+ angle_embedding, torsion_embedding = embedding(
+ dist, angle, torsion, idx_kj
+ )
+ assert angle_embedding.shape == [
+ 1, num_spherical * num_radial
+ ]
+ assert torsion_embedding.shape == [
+ 1,
+ num_spherical * num_spherical * num_radial,
+ ]
+
+ empty = paddle.empty([0], dtype="float32")
+ empty_index = paddle.empty([0], dtype="int64")
+ angle_embedding, torsion_embedding = embedding(
+ dist, empty, empty, empty_index
+ )
+ assert angle_embedding.shape == [0, 42]
+ assert torsion_embedding.shape == [0, 294]
+
+
+def test_radius_graph_uses_edges_as_endpoint_indices():
+ xyz_blocks = [
+ "3\nwater\nO 0 0 0\nH 0.96 0 0\nH -0.24 0.93 0\n",
+ (
+ "5\nmethane\nC 0 0 0\nH 0.63 0.63 0.63\n"
+ "H -0.63 -0.63 0.63\nH -0.63 0.63 -0.63\n"
+ "H 0.63 -0.63 -0.63\n"
+ ),
+ ]
+ converter = RadiusGraphConverter(
+ cutoff=5.0, return_triplet_indices=True
+ )
+ graphs = converter(
+ [Chem.MolFromXYZBlock(block) for block in xyz_blocks]
+ )
+ for graph in graphs:
+ assert "ti_i" not in graph.edge_feat
+ assert "ti_j" not in graph.edge_feat
+
+ batch = RadiusGraphCollator()(
+ [
+ {"graph": graph, "id": index}
+ for index, graph in enumerate(graphs)
+ ]
+ )
+ graph = batch["graph"].tensor()
+ edge_index = paddle.transpose(graph.edges.astype("int64"), [1, 0])
+ triplet_indices = {
+ "idx_kj": graph.edge_feat["ti_idx_kj"].astype("int64"),
+ "idx_ji": graph.edge_feat["ti_idx_ji"].astype("int64"),
+ "idx_lk": graph.edge_feat["ti_idx_lk"].astype("int64"),
+ "idx_triplet": graph.edge_feat["ti_idx_triplet"].astype("int64"),
+ }
+ result = compute_geometry(
+ graph.node_feat["pos"], edge_index, triplet_indices
+ )
+ np.testing.assert_array_equal(result[3].numpy(), edge_index[0].numpy())
+ np.testing.assert_array_equal(result[4].numpy(), edge_index[1].numpy())
+
+
+
+def test_predict_returns_energy_and_force():
+ molecule = Chem.MolFromXYZBlock(
+ "3\nwater\nO 0 0 0\nH 0.96 0 0\nH -0.24 0.93 0\n"
+ )
+ graph = RadiusGraphConverter(
+ cutoff=5.0, return_triplet_indices=True
+ )(molecule)
+ model = SphereNet(
+ energy_and_force=True,
+ property_name="energy",
+ num_layers=0,
+ hidden_channels=16,
+ int_emb_size=8,
+ basis_emb_size_dist=4,
+ basis_emb_size_angle=4,
+ basis_emb_size_torsion=4,
+ out_emb_channels=16,
+ num_spherical=2,
+ num_radial=2,
+ num_output_layers=1,
+ )
+
+ prediction = model.predict(graph)
+
+ assert prediction["energy"].shape == (1, 1)
+ assert prediction["force"].shape == (3, 3)
+ assert np.isfinite(prediction["energy"]).all()
+ assert np.isfinite(prediction["force"]).all()