Skip to content

Commit 9525c88

Browse files
committed
fix(axes): make principal axes reproducible across LAPACK builds
1 parent 6014564 commit 9525c88

2 files changed

Lines changed: 154 additions & 58 deletions

File tree

‎CodeEntropy/levels/axes.py‎

Lines changed: 64 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -837,13 +837,7 @@ def get_vanilla_axes(self, molecule):
837837
"""
838838
make_whole(molecule.atoms)
839839
moment_of_inertia_tensor = molecule.moment_of_inertia()
840-
principal_axes = self.get_principal_axes(molecule)
841-
842-
eigenvalues, _ = np.linalg.eigh(moment_of_inertia_tensor)
843-
order = np.argsort(np.abs(eigenvalues))[::-1]
844-
moment_of_inertia = eigenvalues[order]
845-
846-
return principal_axes, moment_of_inertia
840+
return self.get_reproducible_axes(moment_of_inertia_tensor)
847841

848842
def get_custom_axes(
849843
self,
@@ -1082,67 +1076,91 @@ def get_custom_principal_axes(
10821076
) -> tuple[np.ndarray, np.ndarray]:
10831077
"""Compute principal axes and moments from a custom MOI tensor.
10841078
1085-
Principal axes and centre of axes from the ordered eigenvalues and
1086-
eigenvectors of a moment of inertia tensor. This function allows for a
1087-
custom moment of inertia tensor to be used, which isn't possible with the
1088-
built-in MDAnalysis principal_axes() function.
1089-
1090-
Original behaviour preserved:
1091-
1092-
- Eigenvalues are sorted by descending absolute magnitude.
1093-
- Eigenvectors are transposed so axes are returned as rows.
1094-
- Z axis is flipped to enforce the same handedness convention as the
1095-
original implementation.
1079+
Principal axes and moments from the ordered eigenvalues and eigenvectors of
1080+
a moment of inertia tensor. This function allows for a custom moment of
1081+
inertia tensor to be used. The axes are made reproducible by
1082+
:meth:`get_reproducible_axes`.
10961083
10971084
Args:
10981085
moment_of_inertia_tensor: (3, 3) custom inertia tensor.
10991086
11001087
Returns:
11011088
Tuple[np.ndarray, np.ndarray]:
11021089
- principal_axes: (3, 3) principal axes (rows).
1103-
- moment_of_inertia: (3,) principal moments.
1090+
- moment_of_inertia: (3,) principal moments sorted descending by
1091+
absolute value.
11041092
"""
1105-
eigenvalues, eigenvectors = np.linalg.eigh(moment_of_inertia_tensor)
1106-
order = np.abs(eigenvalues).argsort()[::-1] # descending order
1107-
transposed = np.transpose(eigenvectors) # columns -> rows
1108-
moment_of_inertia = eigenvalues[order]
1109-
principal_axes = transposed[order]
1110-
1111-
# point z axis in correct direction, as per original code
1112-
cross_xy = np.cross(principal_axes[0], principal_axes[1])
1113-
dot_z = float(np.dot(cross_xy, principal_axes[2]))
1114-
if dot_z < 0:
1115-
principal_axes[2] *= -1
1116-
1117-
return principal_axes, moment_of_inertia
1093+
return self.get_reproducible_axes(moment_of_inertia_tensor)
11181094

11191095
def get_principal_axes(self, group, wrap: bool = False) -> np.ndarray:
11201096
"""Compute the principal axes of an atom group from its moment of inertia.
11211097
1122-
Uses ``np.linalg.eigh`` rather than ``np.linalg.eig``. The moment of inertia
1123-
tensor is symmetric, so ``eigh`` returns orthonormal axes, whereas ``eig``
1124-
can return skewed axes for (near-)degenerate moments.
1125-
1126-
- Axes are sorted by descending (signed) eigenvalue and returned as rows.
1127-
- If the axes are left-handed, all three are flipped.
1128-
11291098
Args:
11301099
group: Atom group (or anything with ``.atoms``).
11311100
wrap: Whether to wrap atoms into the primary unit cell before computing
11321101
the moment of inertia.
11331102
11341103
Returns:
1135-
np.ndarray: (3, 3) principal axes (rows).
1104+
np.ndarray: (3, 3) principal axes (rows), as given by
1105+
:meth:`get_reproducible_axes`.
11361106
"""
1137-
atomgroup = group.atoms
1138-
e_val, e_vec = np.linalg.eigh(atomgroup.moment_of_inertia(wrap=wrap))
1107+
tensor = group.atoms.moment_of_inertia(wrap=wrap)
1108+
principal_axes, _ = self.get_reproducible_axes(tensor)
1109+
return principal_axes
11391110

1140-
e_vec = e_vec[:, np.argsort(e_val)[::-1]].T
1111+
def get_reproducible_axes(
1112+
self, tensor: np.ndarray, rtol: float = 1e-5
1113+
) -> tuple[np.ndarray, np.ndarray]:
1114+
"""Diagonalise a symmetric tensor into reproducible principal axes.
1115+
1116+
``np.linalg.eigh`` returns orthonormal axes, but the sign of each axis, and
1117+
the choice of axes within a set of (near-)equal moments, can differ between
1118+
LAPACK builds. These axes are used to average covariances over molecules and
1119+
frames, so that choice changes the entropy. This method fixes it:
1120+
1121+
- Moments are sorted descending by absolute value.
1122+
- Axes whose moments are equal to within ``rtol`` (relative to the largest
1123+
moment) are re-chosen from the eigenvectors of a fixed reference tensor
1124+
projected onto the shared subspace. Axes with distinct moments are left as
1125+
they are.
1126+
- The first two axes are signed to point along the lab-frame direction
1127+
(1, 2, 3), and the third is their cross product, so the axes are
1128+
right-handed.
11411129
1142-
if np.dot(np.cross(e_vec[0], e_vec[1]), e_vec[2]) < 0:
1143-
e_vec *= -1
1130+
The result is reproducible but is a function of the lab-frame orientation,
1131+
so it is not rotation covariant.
1132+
1133+
Args:
1134+
tensor: (3, 3) symmetric tensor, e.g. a moment of inertia tensor.
1135+
rtol: Relative tolerance for treating two moments as equal.
11441136
1145-
return e_vec
1137+
Returns:
1138+
Tuple[np.ndarray, np.ndarray]:
1139+
- principal_axes: (3, 3) principal axes (rows).
1140+
- moments: (3,) principal moments sorted descending by absolute
1141+
value.
1142+
"""
1143+
moments, vectors = np.linalg.eigh(tensor)
1144+
order = np.argsort(np.abs(moments))[::-1]
1145+
moments, vectors = moments[order], vectors[:, order]
1146+
1147+
reference_direction = np.array([1.0, 2.0, 3.0])
1148+
gaps = np.abs(np.diff(np.abs(moments)))
1149+
group_starts = np.flatnonzero(gaps > rtol * np.abs(moments[0])) + 1
1150+
for group in np.split(np.arange(3), group_starts):
1151+
if len(group) > 1:
1152+
shared = vectors[:, group]
1153+
reference = shared.T @ np.diag(reference_direction) @ shared
1154+
_, rotation = np.linalg.eigh(reference)
1155+
vectors[:, group] = shared @ rotation
1156+
1157+
for i in (0, 1):
1158+
if vectors[:, i] @ reference_direction < 0:
1159+
vectors[:, i] *= -1
1160+
1161+
third_axis = np.cross(vectors[:, 0], vectors[:, 1])
1162+
principal_axes = np.array([vectors[:, 0], vectors[:, 1], third_axis])
1163+
return principal_axes, moments
11461164

11471165
def get_UA_masses(self, molecule) -> list[float]:
11481166
"""Return united-atom (UA) masses for a molecule.

‎tests/unit/CodeEntropy/levels/test_axes.py‎

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def test_get_flipped_axes_flips_negative_dot():
293293
assert np.allclose(flipped[0], np.array([1.0, 0.0, 0.0]))
294294

295295

296-
def test_get_custom_principal_axes_flips_z_when_left_handed():
296+
def test_get_custom_principal_axes_returns_axes_and_moments():
297297
ax = AxesCalculator()
298298
moi = np.eye(3)
299299
axes, vals = ax.get_custom_principal_axes(moi)
@@ -368,19 +368,97 @@ def test_get_principal_axes_orthonormal_and_right_handed_for_near_degenerate():
368368
assert np.isclose(abs(np.dot(axes[0], q[:, 2])), 1.0)
369369

370370

371-
def test_get_principal_axes_flips_all_axes_when_left_handed(monkeypatch):
371+
def _scrambled_eigh(seed):
372+
"""Return an ``eigh`` that gives valid but arbitrary signs and degenerate bases."""
373+
rng = np.random.default_rng(seed)
374+
real_eigh = np.linalg.eigh
375+
376+
def scrambled(matrix):
377+
values, vectors = real_eigh(matrix)
378+
n = len(values)
379+
for i in range(n):
380+
for j in range(i + 1, n):
381+
if np.isclose(values[i], values[j], rtol=1e-5):
382+
angle = rng.uniform(0, 2 * np.pi)
383+
c, s = np.cos(angle), np.sin(angle)
384+
vi, vj = vectors[:, i].copy(), vectors[:, j].copy()
385+
vectors[:, i] = c * vi + s * vj
386+
vectors[:, j] = -s * vi + c * vj
387+
return values, vectors * rng.choice([-1.0, 1.0], size=n)
388+
389+
return scrambled
390+
391+
392+
def _example_tensors():
393+
rng = np.random.default_rng(1)
394+
q, _ = np.linalg.qr(rng.normal(size=(3, 3)))
395+
return {
396+
"asymmetric_top": q @ np.diag([5.0, 3.0, 1.0]) @ q.T,
397+
"linear": q @ np.diag([4.0, 4.0, 0.0]) @ q.T,
398+
"symmetric_top": q @ np.diag([5.0, 2.0, 2.0]) @ q.T,
399+
"spherical_top": 3.0 * np.eye(3),
400+
}
401+
402+
403+
@pytest.mark.parametrize("name", list(_example_tensors()))
404+
def test_get_reproducible_axes_orthonormal_right_handed_and_diagonalising(name):
372405
ax = AxesCalculator()
373-
group = MagicMock()
374-
group.atoms.moment_of_inertia.return_value = np.eye(3)
375-
monkeypatch.setattr(
376-
np.linalg, "eigh", lambda _m: (np.array([1.0, 2.0, 3.0]), np.eye(3))
377-
)
406+
tensor = _example_tensors()[name]
378407

379-
axes = ax.get_principal_axes(group)
408+
axes, moments = ax.get_reproducible_axes(tensor)
409+
410+
assert np.allclose(axes @ axes.T, np.eye(3), atol=1e-12)
411+
assert np.isclose(np.linalg.det(axes), 1.0)
412+
assert np.allclose(axes @ tensor @ axes.T, np.diag(moments), atol=1e-12)
413+
assert np.all(np.diff(np.abs(moments)) <= 1e-12)
414+
415+
416+
@pytest.mark.parametrize("name", list(_example_tensors()))
417+
def test_get_reproducible_axes_independent_of_eigensolver_choices(monkeypatch, name):
418+
ax = AxesCalculator()
419+
tensor = _example_tensors()[name]
420+
expected_axes, expected_moments = ax.get_reproducible_axes(tensor)
421+
422+
for seed in range(20):
423+
monkeypatch.setattr(np.linalg, "eigh", _scrambled_eigh(seed))
424+
axes, moments = ax.get_reproducible_axes(tensor)
425+
assert np.allclose(axes, expected_axes, atol=1e-9)
426+
assert np.allclose(moments, expected_moments)
427+
428+
429+
@pytest.mark.parametrize("name", list(_example_tensors()))
430+
def test_get_reproducible_axes_stable_under_rounding_level_noise(name):
431+
ax = AxesCalculator()
432+
tensor = _example_tensors()[name]
433+
expected_axes, _ = ax.get_reproducible_axes(tensor)
434+
rng = np.random.default_rng(2)
435+
436+
for _ in range(50):
437+
noise = rng.normal(scale=1e-7, size=(3, 3))
438+
axes, _ = ax.get_reproducible_axes(tensor + (noise + noise.T) / 2)
439+
assert np.allclose(axes, expected_axes, atol=1e-4)
440+
441+
442+
def test_get_reproducible_axes_keeps_unique_axis_of_linear_tensor_exact():
443+
ax = AxesCalculator()
444+
direction = np.array([1.0, -2.0, 0.5])
445+
direction /= np.linalg.norm(direction)
446+
tensor = 4.0 * (np.eye(3) - np.outer(direction, direction))
447+
448+
axes, moments = ax.get_reproducible_axes(tensor)
449+
450+
assert np.isclose(moments[2], 0.0, atol=1e-12)
451+
assert np.isclose(abs(axes[2] @ direction), 1.0, atol=1e-12)
452+
453+
454+
def test_get_reproducible_axes_signs_first_two_axes_along_reference_direction():
455+
ax = AxesCalculator()
456+
tensor = np.diag([5.0, 3.0, 1.0])
457+
458+
axes, _ = ax.get_reproducible_axes(tensor)
380459

381-
# descending order gives rows e3, e2, e1 (left-handed), so all are negated
382-
expected = -np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=float)
383-
assert np.allclose(axes, expected)
460+
assert axes[0] @ np.array([1.0, 2.0, 3.0]) > 0
461+
assert axes[1] @ np.array([1.0, 2.0, 3.0]) > 0
384462

385463

386464
def test_get_principal_axes_passes_wrap_to_moment_of_inertia():
@@ -653,7 +731,7 @@ def test_get_custom_axes_raises_when_normalization_degenerate():
653731
ac.get_custom_axes(a=a, b_list=b_list, c=c, dimensions=dims)
654732

655733

656-
def test_get_custom_principal_axes_flips_z_for_handedness():
734+
def test_get_custom_principal_axes_is_right_handed():
657735
ac = AxesCalculator()
658736

659737
moi = np.diag([3.0, 2.0, 1.0])

0 commit comments

Comments
 (0)