Skip to content

Commit 176d4fe

Browse files
authored
fix(models): symmetrize prepare_strain displacement (#111)
Apply only the symmetric part of the displacement tensor when prepare_strain deforms positions and cells. Document that the returned displacement remains an unconstrained autograd leaf, and add tests for symmetric stress from an asymmetric toy energy plus preservation of a symmetric raw stress response. Signed-off-by: Roman Zubatyuk <rzubatiuk@nvidia.com>
1 parent 62c6820 commit 176d4fe

2 files changed

Lines changed: 87 additions & 11 deletions

File tree

nvalchemi/models/_utils.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ def prepare_strain(
9191
"""Set up the affine strain trick for autograd stress computation.
9292
9393
Creates a per-system 3x3 displacement tensor with
94-
``requires_grad=True``, scales positions and cell through it, and
95-
returns all three tensors. After running the model on the scaled
96-
positions/cell, compute stresses with standard PyTorch autograd::
94+
``requires_grad=True``, scales positions and cell through the symmetric
95+
part of it, and returns all three tensors. After running the model on
96+
the scaled positions/cell, compute stresses with standard PyTorch
97+
autograd::
9798
9899
scaled_pos, scaled_cell, displacement = prepare_strain(
99100
positions, cell, batch_idx
@@ -131,6 +132,8 @@ def prepare_strain(
131132
tuple[torch.Tensor, torch.Tensor, torch.Tensor]
132133
``(scaled_positions, scaled_cell, displacement)`` where
133134
``displacement`` is ``[B, 3, 3]`` with ``requires_grad=True``.
135+
The returned tensor is an unconstrained autograd leaf; only its
136+
symmetric part is applied as strain.
134137
"""
135138
n_systems = cell.shape[0]
136139
displacement = torch.zeros(
@@ -141,15 +144,17 @@ def prepare_strain(
141144
device=positions.device,
142145
)
143146
displacement.requires_grad_(True)
144-
symmetric = (
145-
torch.eye(3, dtype=positions.dtype, device=positions.device) + displacement
147+
symmetric_displacement = 0.5 * (displacement + displacement.mT)
148+
deformation = (
149+
torch.eye(3, dtype=positions.dtype, device=positions.device)
150+
+ symmetric_displacement
146151
)
147-
# Scale positions: pos'[n] = pos[n] @ symmetric[system_of_atom[n]]
148-
# Index into symmetric per-atom, then batch-matmul each atom's row.
149-
per_atom_symmetric = symmetric[batch_idx] # [N, 3, 3]
150-
scaled_positions = torch.einsum("ni,nij->nj", positions, per_atom_symmetric)
151-
# Scale cell: cell'[b] = cell[b] @ symmetric[b]
152-
scaled_cell = torch.einsum("bij,bjk->bik", cell, symmetric)
152+
# Scale positions: pos'[n] = pos[n] @ deformation[system_of_atom[n]]
153+
# Index into deformation per-atom, then batch-matmul each atom's row.
154+
per_atom_deformation = deformation[batch_idx] # [N, 3, 3]
155+
scaled_positions = torch.einsum("ni,nij->nj", positions, per_atom_deformation)
156+
# Scale cell: cell'[b] = cell[b] @ deformation[b]
157+
scaled_cell = torch.einsum("bij,bjk->bik", cell, deformation)
153158
return scaled_positions, scaled_cell, displacement
154159

155160

test/models/test_pipeline.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,77 @@ def test_gradient_flows_through_displacement(self):
13141314
assert grad is not None
13151315
assert grad.shape == (1, 3, 3)
13161316

1317+
def test_stress_from_asymmetric_energy_is_symmetric(self):
1318+
"""prepare_strain projects displacement gradients onto symmetric strain."""
1319+
from nvalchemi.models._utils import autograd_stresses, prepare_strain
1320+
1321+
positions = torch.tensor(
1322+
[[0.3, -0.7, 1.1], [1.2, 0.4, -0.2], [-0.5, 0.8, 0.6]],
1323+
dtype=torch.float64,
1324+
)
1325+
cell = torch.eye(3, dtype=torch.float64).unsqueeze(0) * 5.0
1326+
batch_idx = torch.zeros(positions.shape[0], dtype=torch.long)
1327+
1328+
scaled_pos, scaled_cell, displacement = prepare_strain(
1329+
positions,
1330+
cell,
1331+
batch_idx,
1332+
)
1333+
x, y = scaled_pos[:, 0], scaled_pos[:, 1]
1334+
energy = (x * y**2).sum() + scaled_cell[0, 0, 0] * scaled_cell[0, 1, 1] ** 2
1335+
stress = autograd_stresses(energy, displacement, cell, num_graphs=1)
1336+
1337+
torch.testing.assert_close(
1338+
stress,
1339+
stress.transpose(-1, -2),
1340+
atol=1e-12,
1341+
rtol=0,
1342+
)
1343+
1344+
def test_symmetric_response_matches_raw_displacement_reference(self):
1345+
"""Symmetric strain preserves models with symmetric raw stress response."""
1346+
from nvalchemi.models._utils import autograd_stresses, prepare_strain
1347+
1348+
positions = torch.tensor(
1349+
[[0.3, -0.7, 1.1], [1.2, 0.4, -0.2], [-0.5, 0.8, 0.6]],
1350+
dtype=torch.float64,
1351+
)
1352+
cell = torch.eye(3, dtype=torch.float64).unsqueeze(0) * 5.0
1353+
batch_idx = torch.zeros(positions.shape[0], dtype=torch.long)
1354+
1355+
scaled_pos, scaled_cell, displacement = prepare_strain(
1356+
positions,
1357+
cell,
1358+
batch_idx,
1359+
)
1360+
energy = (scaled_pos**2).sum() + (scaled_cell**2).sum()
1361+
stress = autograd_stresses(energy, displacement, cell, num_graphs=1)
1362+
1363+
raw_displacement = torch.zeros(
1364+
1,
1365+
3,
1366+
3,
1367+
dtype=positions.dtype,
1368+
requires_grad=True,
1369+
)
1370+
raw_deformation = torch.eye(3, dtype=positions.dtype).unsqueeze(0)
1371+
raw_deformation = raw_deformation + raw_displacement
1372+
raw_scaled_pos = torch.einsum(
1373+
"ni,nij->nj",
1374+
positions,
1375+
raw_deformation[batch_idx],
1376+
)
1377+
raw_scaled_cell = torch.einsum("bij,bjk->bik", cell, raw_deformation)
1378+
raw_energy = (raw_scaled_pos**2).sum() + (raw_scaled_cell**2).sum()
1379+
raw_stress = autograd_stresses(
1380+
raw_energy,
1381+
raw_displacement,
1382+
cell,
1383+
num_graphs=1,
1384+
)
1385+
1386+
torch.testing.assert_close(stress, raw_stress, atol=1e-12, rtol=0)
1387+
13171388
def test_multi_system_batches(self):
13181389
"""Each system gets its own displacement."""
13191390
from nvalchemi.models._utils import prepare_strain

0 commit comments

Comments
 (0)