Skip to content

Commit 4b1731c

Browse files
fix: symmetrize RMSD matrix in-place to prevent sklearn HDBSCAN rejection
Some backends (mdtraj, torch) produce tiny asymmetry (~1e-7 nm) in the pairwise RMSD matrix due to floating-point ordering in the superposition loop. sklearn's HDBSCAN checks allclose(X, X.T) and rejects asymmetric matrices with ValueError. New _symmetrize_inplace Numba kernel averages (i,j) and (j,i) entries in parallel with zero extra allocation. Called in compute_rmsd_matrix before returning, so all downstream consumers get an exactly symmetric matrix regardless of which backend was used. Tightened the symmetry test from atol=1e-6 to assert_array_equal (bit-identical).
1 parent f1f3773 commit 4b1731c

2 files changed

Lines changed: 32 additions & 2 deletions

File tree

src/mdpp/analysis/clustering.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ def compute_rmsd_matrix(
121121
compute_fn = rmsd_matrix_backends.get(backend)
122122
rmsd_matrix = compute_fn(traj, atom_indices)
123123

124+
# Force exact symmetry: some backends (mdtraj, torch) produce tiny
125+
# asymmetry (~1e-7 nm) that causes sklearn HDBSCAN to reject the
126+
# matrix. The Numba kernel averages (i,j) and (j,i) in-place with
127+
# zero extra allocation.
128+
rmsd_matrix = np.ascontiguousarray(rmsd_matrix)
129+
_symmetrize_inplace(rmsd_matrix)
130+
124131
# ``copy=False`` is critical at large N: when the backend's native
125132
# dtype already matches ``resolved`` we reuse the same buffer
126133
# instead of allocating a second ~N^2 matrix.
@@ -135,6 +142,29 @@ def compute_rmsd_matrix(
135142
# ---------------------------------------------------------------------------
136143

137144

145+
@njit(parallel=True, cache=True)
146+
def _symmetrize_inplace(
147+
matrix: NDArray[np.floating],
148+
) -> None: # pragma: no cover - JIT-compiled
149+
"""Average upper and lower triangles in place (zero allocation).
150+
151+
RMSD is a true metric so the matrix must be symmetric, but some
152+
backends (mdtraj, torch) produce tiny asymmetry (~1e-7 nm) due to
153+
floating-point ordering in the superposition loop. This kernel
154+
forces exact symmetry so downstream consumers (e.g. sklearn HDBSCAN)
155+
that check ``allclose(X, X.T)`` do not reject the matrix.
156+
157+
Parallelised over rows; each thread owns a disjoint set of (i, j)
158+
pairs so writes are race-free.
159+
"""
160+
n = matrix.shape[0]
161+
for i in prange(n):
162+
for j in range(i + 1, n):
163+
avg = (matrix[i, j] + matrix[j, i]) / 2
164+
matrix[i, j] = avg
165+
matrix[j, i] = avg
166+
167+
138168
@njit(parallel=True, cache=True)
139169
def _gromos_initial_counts(
140170
rmsd_matrix: NDArray[np.floating],

tests/analysis/test_clustering.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ def test_diagonal_is_zero(self, backbone_trajectory: md.Trajectory) -> None:
9797
np.testing.assert_allclose(np.diag(result.rmsd_matrix_nm), 0.0, atol=1e-6)
9898

9999
def test_matrix_is_symmetric(self, backbone_trajectory: md.Trajectory) -> None:
100-
"""Pairwise RMSD matrix should be symmetric."""
100+
"""Pairwise RMSD matrix should be exactly symmetric (bit-identical)."""
101101
result = compute_rmsd_matrix(backbone_trajectory, atom_selection="all")
102-
np.testing.assert_allclose(result.rmsd_matrix_nm, result.rmsd_matrix_nm.T, atol=1e-6)
102+
np.testing.assert_array_equal(result.rmsd_matrix_nm, result.rmsd_matrix_nm.T)
103103

104104
def test_values_are_nonnegative(self, backbone_trajectory: md.Trajectory) -> None:
105105
"""All RMSD values should be >= 0."""

0 commit comments

Comments
 (0)