| """Evaluate an Equiformer V3 checkpoint on an independent ASE database.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| os.environ.setdefault( |
| "ONESCIENCE_EQUIFORMER_V3_JD_PATH", |
| str(Path(__file__).resolve().parent / "weight" / "Jd.pt"), |
| ) |
|
|
| import torch |
|
|
| from onescience.utils.equiformer_v3 import ( |
| EquiformerV3CheckpointTransforms, |
| load_equiformer_v3_checkpoint, |
| ) |
|
|
| from finetune import _loader |
|
|
|
|
| @dataclass |
| class ErrorAccumulator: |
| """Accumulate MAE/RMSE inputs without retaining the full dataset.""" |
|
|
| absolute_sum: float = 0.0 |
| squared_sum: float = 0.0 |
| count: int = 0 |
|
|
| def update(self, error: torch.Tensor) -> None: |
| error = error.detach().reshape(-1).double() |
| self.absolute_sum += float(error.abs().sum()) |
| self.squared_sum += float(error.square().sum()) |
| self.count += error.numel() |
|
|
| def result(self) -> dict[str, float]: |
| if self.count == 0: |
| raise ValueError("cannot compute metrics for an empty tensor") |
| return { |
| "mae": self.absolute_sum / self.count, |
| "rmse": (self.squared_sum / self.count) ** 0.5, |
| } |
|
|
|
|
| @dataclass |
| class MeanAccumulator: |
| total: float = 0.0 |
| count: int = 0 |
|
|
| def update(self, values: torch.Tensor) -> None: |
| values = values.detach().reshape(-1).double() |
| self.total += float(values.sum()) |
| self.count += values.numel() |
|
|
| def result(self) -> float: |
| if self.count == 0: |
| raise ValueError("cannot compute a mean for an empty tensor") |
| return self.total / self.count |
|
|
|
|
| def _force_cosine(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor: |
| prediction = prediction.detach().float().reshape(-1, 3) |
| target = target.detach().float().reshape(-1, 3) |
| if prediction.shape != target.shape or prediction.shape[0] == 0: |
| raise ValueError( |
| "cannot compute force cosine similarity for an empty/mismatched tensor" |
| ) |
| return torch.cosine_similarity(prediction, target, dim=1).detach().cpu() |
|
|
|
|
| def _force_magnitude_error( |
| prediction: torch.Tensor, target: torch.Tensor |
| ) -> torch.Tensor: |
| prediction = prediction.detach().float().reshape(-1, 3) |
| target = target.detach().float().reshape(-1, 3) |
| if prediction.shape != target.shape or prediction.shape[0] == 0: |
| raise ValueError( |
| "cannot compute force magnitude error for an empty/mismatched tensor" |
| ) |
| return ( |
| torch.linalg.vector_norm(prediction, dim=1) |
| - torch.linalg.vector_norm(target, dim=1) |
| ).abs().cpu() |
|
|
|
|
| def _energy_force_success( |
| energy_error: torch.Tensor, |
| force_error: torch.Tensor, |
| selected_natoms: torch.Tensor, |
| ) -> torch.Tensor: |
| """Match FairChem's OC20 energy/force threshold metric per structure.""" |
|
|
| energy_error = energy_error.detach().reshape(-1).abs() |
| force_error = force_error.detach().float().reshape(-1, 3).abs() |
| selected_natoms = selected_natoms.detach().reshape(-1).long() |
| if energy_error.numel() != selected_natoms.numel(): |
| raise ValueError("energy count and per-structure atom counts differ") |
| if int(selected_natoms.sum()) != force_error.shape[0]: |
| raise ValueError("force count and per-structure atom counts differ") |
| if bool((selected_natoms == 0).any()): |
| raise ValueError("a structure contains no selected atoms for force evaluation") |
|
|
| successes = [] |
| for structure_energy_error, structure_force_error in zip( |
| energy_error, torch.split(force_error, selected_natoms.tolist()) |
| ): |
| successes.append( |
| (structure_energy_error < 0.02) |
| & (structure_force_error.max() < 0.03) |
| ) |
| return torch.stack(successes).detach().cpu() |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--checkpoint", required=True) |
| parser.add_argument("--data", required=True, help="Independent ASE DB/ASE-LMDB") |
| parser.add_argument("--device", default="cuda") |
| parser.add_argument("--batch-size", type=int, default=2) |
| parser.add_argument("--workers", type=int, default=0) |
| parser.add_argument("--max-samples", type=int) |
| parser.add_argument("--seed", type=int, default=0) |
| parser.add_argument( |
| "--free-atoms-only", |
| action=argparse.BooleanOptionalAction, |
| default=True, |
| help="Evaluate forces only on atoms not constrained by FixAtoms (default: true).", |
| ) |
| parser.add_argument( |
| "--include-stress", |
| action="store_true", |
| help="Also report stress when both checkpoint and dataset provide it.", |
| ) |
| parser.add_argument( |
| "--include-oc20-threshold", |
| action="store_true", |
| help="Report FairChem's OC20 energy/force threshold success rate.", |
| ) |
| parser.add_argument("--output") |
| args = parser.parse_args() |
|
|
| device = torch.device(args.device) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| raise RuntimeError("CUDA/DCU was requested but is unavailable") |
|
|
| model = load_equiformer_v3_checkpoint(args.checkpoint).to(device) |
| model.eval() |
| transforms = EquiformerV3CheckpointTransforms.from_checkpoint( |
| args.checkpoint |
| ).to(device) |
| loader = _loader( |
| args.data, |
| args.batch_size, |
| args.workers, |
| max_samples=args.max_samples, |
| train=False, |
| seed=args.seed, |
| ) |
|
|
| energy_errors = ErrorAccumulator() |
| energy_per_atom_errors = ErrorAccumulator() |
| force_errors = ErrorAccumulator() |
| force_cosines = MeanAccumulator() |
| force_magnitude_errors = MeanAccumulator() |
| energy_force_successes = MeanAccumulator() |
| stress_errors = ErrorAccumulator() |
| with torch.enable_grad(): |
| for batch in loader: |
| batch = batch.to(device) |
| prediction = model(batch) |
| pred_energy = transforms.denormalize_prediction( |
| "energy", prediction["energy"], batch |
| ) |
| target_energy = batch.energy.reshape_as(pred_energy) |
| energy_error = pred_energy - target_energy |
| energy_errors.update(energy_error) |
| natoms = batch.natoms.to(pred_energy).reshape( |
| (-1,) + (1,) * (pred_energy.ndim - 1) |
| ) |
| energy_per_atom_errors.update(energy_error / natoms) |
|
|
| pred_forces = transforms.denormalize_prediction( |
| "forces", prediction["forces"], batch |
| ) |
| target_forces = batch.forces.reshape_as(pred_forces) |
| selected_natoms = batch.natoms |
| if args.free_atoms_only and hasattr(batch, "fixed"): |
| free_mask = batch.fixed.reshape(-1) == 0 |
| selected_natoms = torch.stack( |
| [ |
| structure_mask.sum() |
| for structure_mask in torch.split( |
| free_mask, batch.natoms.tolist() |
| ) |
| ] |
| ) |
| pred_forces = pred_forces.reshape(-1, 3)[free_mask] |
| target_forces = target_forces.reshape(-1, 3)[free_mask] |
| if not pred_forces.numel(): |
| selection = "free atoms" if args.free_atoms_only else "atoms" |
| raise ValueError(f"an evaluation batch contains no selected {selection}") |
| force_error = pred_forces - target_forces |
| force_errors.update(force_error) |
| force_cosines.update(_force_cosine(pred_forces, target_forces)) |
| force_magnitude_errors.update( |
| _force_magnitude_error(pred_forces, target_forces) |
| ) |
| if args.include_oc20_threshold: |
| energy_force_successes.update( |
| _energy_force_success( |
| energy_error, force_error, selected_natoms |
| ) |
| ) |
| if ( |
| args.include_stress |
| and "stress" in prediction |
| and hasattr(batch, "stress") |
| ): |
| pred_stress = transforms.denormalize_prediction( |
| "stress", prediction["stress"], batch |
| ) |
| target_stress = batch.stress.reshape_as(pred_stress) |
| stress_errors.update(pred_stress - target_stress) |
|
|
| result = { |
| "checkpoint": str(Path(args.checkpoint).expanduser()), |
| "data": str(Path(args.data).expanduser()), |
| "samples": len(loader.dataset), |
| "force_atoms": force_cosines.count, |
| "free_atoms_only": args.free_atoms_only, |
| "energy_total_eV": energy_errors.result(), |
| "energy_per_atom_eV": energy_per_atom_errors.result(), |
| } |
| if force_errors.count == 0: |
| selection = "free atoms" if args.free_atoms_only else "atoms" |
| raise ValueError(f"the evaluation dataset contains no selected {selection}") |
| result["forces_eV_per_A"] = force_errors.result() |
| result["forces_cosine_similarity"] = {"mean": force_cosines.result()} |
| result["forces_magnitude_error_eV_per_A"] = { |
| "mean": force_magnitude_errors.result() |
| } |
| if args.include_oc20_threshold: |
| result["energy_forces_within_threshold"] = { |
| "fraction": energy_force_successes.result(), |
| "energy_threshold_eV": 0.02, |
| "force_threshold_eV_per_A": 0.03, |
| } |
| if stress_errors.count: |
| result["stress_eV_per_A3"] = stress_errors.result() |
| print(json.dumps(result, indent=2, sort_keys=True)) |
| if args.output: |
| output = Path(args.output).expanduser() |
| output.parent.mkdir(parents=True, exist_ok=True) |
| output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|