Note
Go to the end to download the full example code.
Energy-Derivative Training Contract (Forces, Stress, Charge Gradients)#
This example demonstrates the recommended way to obtain forces, virial/stress,
and charge gradients from PME (and Ewald) for machine-learning interatomic
potential (MLIP) training: energy is the only differentiable output, and all
derivatives are taken from it with torch.autograd.grad.
The legacy compute_forces / compute_virial / compute_charge_gradients
/ hybrid_forces flags on particle_mesh_ewald and ewald_summation are
deprecated and emit a DeprecationWarning. They remain available for
compatibility in v0.4.0, so this example also checks that the autograd results
match the legacy direct outputs, which is the migration check.
Monopole Ewald/PME/slab entry points accept energy_reduction="atom" (default)
or "system". Atom mode returns per-atom energies (N,); system mode
returns per-system totals (B,) for batched per-system losses. Direct-output
fields (forces, charge gradients, virials) keep their existing shapes. On Torch
CUDA, eager atom mode may synchronize when proving a materialized uniform
cotangent; energy_reduction="system" is structurally sync-free.
In this example you will learn:
Forces from energy autograd:
F = -grad(E.sum(), positions)Force-loss training (double-backward) with
create_graph=TrueGeometry-dependent charges
q(R): the full force includes thedE/dq . dq/dRcharge-model chain-rule termStrain-first virial/stress and stress-loss training
Charge gradients from energy autograd:
dE/dq = grad(E.sum(), charges)energy_reduction="system"for batched per-system energy layout(B,)
Important
This script is intended as an API demonstration. Do not use this script for performance benchmarking; refer to the benchmarks folder instead.
Setup and Imports#
We use particle_mesh_ewald throughout; ewald_summation follows the
exact same energy-derivative contract.
from __future__ import annotations
import warnings
import numpy as np
import torch
from nvalchemiops.torch.interactions.electrostatics import particle_mesh_ewald
from nvalchemiops.torch.neighbors import neighbor_list as neighbor_list_fn
Configure Device#
if torch.cuda.is_available():
device = torch.device("cuda:0")
print("Using CUDA device")
print(f" {torch.cuda.get_device_name(0)}")
else:
device = torch.device("cpu")
print("Using CPU")
dtype = torch.float64
Using CUDA device
NVIDIA H100 PCIe
Create a NaCl Crystal System#
A small NaCl rock-salt supercell (2x2x2 = 16 atoms) is enough to demonstrate every derivative path. Charges use the same floating dtype as the geometry.
def create_nacl_system(n_cells: int = 2, lattice_constant: float = 5.64):
"""Create a NaCl crystal supercell."""
base_positions = np.array([[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]])
base_charges = np.array([1.0, -1.0])
positions = []
charges = []
for i in range(n_cells):
for j in range(n_cells):
for k in range(n_cells):
offset = np.array([i, j, k])
for pos, charge in zip(base_positions, base_charges):
positions.append((pos + offset) * lattice_constant)
charges.append(charge)
positions = torch.tensor(np.array(positions), dtype=dtype, device=device)
charges = torch.tensor(np.array(charges), dtype=dtype, device=device)
cell = torch.eye(3, dtype=dtype, device=device) * lattice_constant * n_cells
cell = cell.unsqueeze(0)
pbc = torch.tensor([[True, True, True]], dtype=torch.bool, device=device)
return positions, charges, cell, pbc
positions, charges, cell, pbc = create_nacl_system(n_cells=2)
# Rattle the perfect lattice so the per-atom forces are nonzero -- otherwise the
# centrosymmetric crystal gives zero forces and the autograd-vs-direct force
# check below would compare 0 to 0.
torch.manual_seed(0)
positions = positions + 0.05 * torch.randn_like(positions)
print(f"\nSystem: {len(positions)} atoms NaCl crystal (rattled)")
# Build the real-space neighbor list once; reuse it for every call below.
neighbor_list, neighbor_ptr, neighbor_shifts = neighbor_list_fn(
positions, 8.0, cell=cell, pbc=pbc, return_neighbor_list=True
)
# Fixed PME parameters keep the example deterministic and fast.
pme_kwargs = dict(
alpha=0.35,
mesh_dimensions=(32, 32, 32),
spline_order=4,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
)
System: 16 atoms NaCl crystal (rattled)
Forces From Energy Autograd#
With no deprecated flag, the call returns the per-atom energy only. The full force is the negative gradient of the total energy w.r.t. positions.
positions_f = positions.detach().requires_grad_(True)
energy = particle_mesh_ewald(positions_f, charges, cell, **pme_kwargs)
print(f"\nenergy shape: {tuple(energy.shape)} (per-atom, energy_reduction='atom')")
forces = -torch.autograd.grad(energy.sum(), positions_f)[0]
print(f"forces shape: {tuple(forces.shape)}")
print(f"max force magnitude: {forces.norm(dim=1).max().item():.6f}")
energy shape: (16,) (per-atom, energy_reduction='atom')
forces shape: (16, 3)
max force magnitude: 0.005254
Migration check: the autograd force equals the legacy compute_forces=True
direct output (the deprecated flag still works, with a DeprecationWarning).
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=".*compute_forces.*",
)
_, forces_direct = particle_mesh_ewald(
positions, charges, cell, compute_forces=True, **pme_kwargs
)
force_diff = (forces - forces_direct).abs().max().item()
print(f"max |autograd force - direct force|: {force_diff:.2e}")
if force_diff >= 1e-6:
raise RuntimeError(f"autograd force does not match direct force: {force_diff:.2e}")
max |autograd force - direct force|: 0.00e+00
Force-Loss Training (Double-Backward)#
To train on a force loss, build the force with create_graph=True so the
subsequent loss.backward() can differentiate through the force. We use a
trivial linear charge model q(R) to stand in for a learned charge head.
torch.manual_seed(0)
weight = torch.randn(3, dtype=dtype, device=device, requires_grad=True)
def charge_model(pos: torch.Tensor) -> torch.Tensor:
"""Toy geometry-dependent charge model with enforced neutrality."""
raw = pos @ weight
return raw - raw.mean()
positions_q = positions.detach().requires_grad_(True)
charges_qr = charge_model(positions_q) # kept in the graph -> full q(R) force
energy = particle_mesh_ewald(positions_q, charges_qr, cell, **pme_kwargs)
forces_qr = -torch.autograd.grad(energy.sum(), positions_q, create_graph=True)[0]
target_forces = torch.zeros_like(forces_qr)
force_loss = (forces_qr - target_forces).pow(2).sum()
force_loss.backward() # differentiates through the force construction
print(f"\nq(R) force-loss backward OK; weight.grad shape: {tuple(weight.grad.shape)}")
if not torch.isfinite(weight.grad).all():
raise RuntimeError("q(R) force-loss produced non-finite parameter gradients")
q(R) force-loss backward OK; weight.grad shape: (3,)
Because charges = charge_model(positions) stays connected to positions,
the autograd force includes both the fixed-charge term and the
dE/dq . dq/dR chain-rule term. The legacy compute_forces=True output is
only the fixed-charge partial and does not include the charge-model term –
this is the central reason direct force output on the full API is deprecated.
Strain-First Virial and Stress#
strain is not a PME argument. Build a differentiable strain tensor, deform
positions and cell by I + strain, and let autograd map gradients back to
strain. The virial is W = -dE/d(strain) and tensile-positive stress is
dE/d(strain) / V; see conventions.md for the project-wide sign.
num_systems = cell.shape[0]
positions_s = positions.detach().requires_grad_(True)
strain = torch.zeros(num_systems, 3, 3, device=device, dtype=dtype, requires_grad=True)
eye = torch.eye(3, device=device, dtype=dtype).unsqueeze(0)
deform = eye + strain
# Single system: every atom maps to system 0.
batch_idx = torch.zeros(positions_s.shape[0], dtype=torch.int32, device=device)
positions_def = torch.einsum("ni,nij->nj", positions_s, deform[batch_idx])
cell_def = torch.einsum("bij,bjk->bik", cell, deform)
energy = particle_mesh_ewald(positions_def, charges, cell_def, **pme_kwargs)
grad_strain = torch.autograd.grad(energy.sum(), strain)[0]
virial = -grad_strain
volume = torch.abs(torch.linalg.det(cell_def))
stress = grad_strain / volume[:, None, None] # tensile-positive Cauchy
print(f"\nvirial shape: {tuple(virial.shape)}")
print(f"stress shape: {tuple(stress.shape)}")
virial shape: (1, 3, 3)
stress shape: (1, 3, 3)
Migration check: the strain-first virial equals the legacy
compute_virial=True direct virial (both are -dE/d(strain)).
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=".*compute_virial.*",
)
_, virial_direct = particle_mesh_ewald(
positions, charges, cell, compute_virial=True, **pme_kwargs
)
virial_diff = (virial - virial_direct).abs().max().item()
print(f"max |strain-first virial - direct virial|: {virial_diff:.2e}")
if virial_diff >= 1e-5:
raise RuntimeError(
f"strain-first virial does not match direct virial: {virial_diff:.2e}"
)
max |strain-first virial - direct virial|: 1.11e-16
Stress-Loss Training (Double-Backward)#
Stress training uses the same strain-first recipe with create_graph=True,
so the stress loss back-propagates to model parameters.
weight_s = torch.randn(3, dtype=dtype, device=device, requires_grad=True)
positions_s = positions.detach().requires_grad_(True)
strain = torch.zeros(num_systems, 3, 3, device=device, dtype=dtype, requires_grad=True)
deform = torch.eye(3, device=device, dtype=dtype).unsqueeze(0) + strain
positions_def = torch.einsum("ni,nij->nj", positions_s, deform[batch_idx])
cell_def = torch.einsum("bij,bjk->bik", cell, deform)
charges_qr = positions_def @ weight_s
charges_qr = charges_qr - charges_qr.mean()
energy = particle_mesh_ewald(positions_def, charges_qr, cell_def, **pme_kwargs)
grad_strain = torch.autograd.grad(energy.sum(), strain, create_graph=True)[0]
virial = -grad_strain
volume = torch.abs(torch.linalg.det(cell_def))
stress = grad_strain / volume[:, None, None]
stress_loss = stress.pow(2).sum()
stress_loss.backward()
stress_grad_ok = bool(torch.isfinite(weight_s.grad).all())
print(f"\nstress-loss backward OK; weight_s.grad finite: {stress_grad_ok}")
if not stress_grad_ok:
raise RuntimeError("stress-loss produced non-finite parameter gradients")
stress-loss backward OK; weight_s.grad finite: True
Combined Energy + Force + Stress Loss (Performance Tip)#
When a single loss mixes energy, forces, AND stress, take the forces and the
virial from one torch.autograd.grad call over both positions and
strain – NOT two separate calls. Each create_graph=True grad call
builds its own first-derivative graph node, and loss.backward() then runs the
(O(K*N)) reciprocal second-derivative once per node. Fusing the two into a single
call avoids duplicate reciprocal double-backward work; the gradients are
bit-identical to the two-call form.
weight_m = torch.randn(3, dtype=dtype, device=device, requires_grad=True)
positions_m = positions.detach().requires_grad_(True)
strain_m = torch.zeros(
num_systems, 3, 3, device=device, dtype=dtype, requires_grad=True
)
deform_m = torch.eye(3, device=device, dtype=dtype).unsqueeze(0) + strain_m
positions_md = torch.einsum("ni,nij->nj", positions_m, deform_m[batch_idx])
cell_md = torch.einsum("bij,bjk->bik", cell, deform_m)
charges_m = positions_md @ weight_m
charges_m = charges_m - charges_m.mean()
energy = particle_mesh_ewald(positions_md, charges_m, cell_md, **pme_kwargs)
# ONE combined grad call -> one double-backward (do this instead of separate
# ``grad(E, positions)`` and ``grad(E, strain)`` calls):
grad_pos, grad_strain = torch.autograd.grad(
energy.sum(), (positions_m, strain_m), create_graph=True
)
# ``positions_m`` are the undeformed reference coordinates, so ``forces_m`` are
# reference-frame forces. Differentiate with respect to ``positions_md`` instead
# when training against deformed-coordinate force targets.
forces_m = -grad_pos
virial_m = -grad_strain
volume_m = torch.abs(torch.linalg.det(cell_md))
stress_m = grad_strain / volume_m[:, None, None]
mixed_loss = energy.sum() + forces_m.pow(2).sum() + stress_m.pow(2).sum()
mixed_loss.backward()
mixed_grad_ok = bool(torch.isfinite(weight_m.grad).all())
print(f"\nmixed E+F+stress backward OK; weight_m.grad finite: {mixed_grad_ok}")
if not mixed_grad_ok:
raise RuntimeError("mixed-loss produced non-finite parameter gradients")
mixed E+F+stress backward OK; weight_m.grad finite: True
Charge Gradients From Energy Autograd#
dE/dq is an ordinary gradient of the energy w.r.t. charges.
charges_g = charges.detach().requires_grad_(True)
energy = particle_mesh_ewald(positions, charges_g, cell, **pme_kwargs)
charge_grad = torch.autograd.grad(energy.sum(), charges_g)[0]
print(f"\ncharge gradient shape: {tuple(charge_grad.shape)}")
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=".*compute_forces.*",
)
_, _, charge_grad_direct = particle_mesh_ewald(
positions,
charges,
cell,
compute_forces=True,
compute_charge_gradients=True,
**pme_kwargs,
)
cg_diff = (charge_grad - charge_grad_direct).abs().max().item()
print(f"max |autograd dE/dq - direct dE/dq|: {cg_diff:.2e}")
if cg_diff >= 1e-6:
raise RuntimeError(f"autograd dE/dq does not match direct dE/dq: {cg_diff:.2e}")
charge gradient shape: (16,)
max |autograd dE/dq - direct dE/dq|: 1.80e-09
Summary#
This example demonstrated the energy-derivative training contract:
Forces –
F = -torch.autograd.grad(E.sum(), positions)[0]; withcreate_graph=Truefor force-loss training.q(R) forces – keep
charges = charge_model(positions)in the graph so the fulldE/dRincludes thedE/dq . dq/dRcharge-model term.Virial / stress – strain-first: deform positions and cell by
I + strain, thenvirial = -grad(E.sum(), strain)andstress = grad(E.sum(), strain) / volume[:, None, None].Charge gradients –
dE/dq = torch.autograd.grad(E.sum(), charges)[0].Combined E + F + stress loss – take forces and virial from a single
torch.autograd.grad(E.sum(), (positions, strain), create_graph=True)call, not two separate calls, so the reciprocal double-backward runs once.Batched per-system layout – pass
energy_reduction="system"to get(B,)energies for per-system losses without manualscatter_add.
Each autograd result matched the corresponding (deprecated) direct kernel output, confirming the migration is numerically exact.
print("\nEnergy-derivative training contract example complete!")
Energy-derivative training contract example complete!
Total running time of the script: (0 minutes 29.429 seconds)