Analysis¶
Only md-tools' own analysis tools are described here. RMSD, MBAR, WHAM and kinetic analysis
are standard methods with a literature; they are used in the tutorials and explained where they
are used. This page covers the three things this package does that you will not find elsewhere,
all under md_tools.analysis:
| needs | ||
|---|---|---|
t_hdbscan |
a density partition of a torsional ensemble, symmetry-first by default | scikit-learn, rdkit |
t_mi |
mutual information between torsions, with its own bias measured | numpy |
t_symmetry |
the older post-hoc route: which clusters of an existing partition are the same conformer | rdkit |
They are independent: none imports another's estimator, and import md_tools.analysis loads none
of them; t_hdbscan reaches the symmetry enumeration only on its default route, lazily. See Installing for the extra.
t_hdbscan¶
The default workflow: symmetry first¶
enumerate molecular symmetry -> symmetry-aware torsional distance -> HDBSCAN -> classify frames
(same distance, 18 of 20)
from md_tools.analysis import t_hdbscan, TorsionDefinitions, verify_atom_mapping
definitions = TorsionDefinitions(quads=((0, 1, 3, 4), (1, 3, 4, 5), (6, 7, 8, 17)),
names=("omega", "aryl", "phenol"),
indexing="zero-based", units="radian")
fit = t_hdbscan(torsions, # (n_frames, 3) radians, in that column order
molecule=mol, # RDKit Mol with explicit H (the SDF)
torsion_definitions=definitions,
coordinates=traj, # mdtraj.Trajectory: coordinates, unit cell, topology
atom_mapping=mapping).fit() # trajectory index of each reference atom
fit.labels_ # 0, 1, ... by population; -2 density noise; -1 ambiguous
fit.summary() # populations, unassigned masses, vote, symmetry, metric, caveats
A torsion is measured through four NAMED atoms, and a molecular symmetry renames atoms without
moving any. Paracetamol's ring flip renames C5 as C10, so the aryl torsion measured to C5
becomes the torsion measured to C10: the same configuration, a different number. The distance
between two frames therefore has to be independent of which equivalent naming each is written in:
Phi(theta) = (..., sqrt(w_j) cos theta_j, sqrt(w_j) sin theta_j, ...)
d_sym(x, y) = min over g, h in G of || Phi(g x) - Phi(h y) ||
Frames that differ only by a relabelling are then at distance zero before HDBSCAN builds its density, so symmetry-related basins share a cluster during the fit, before the minimum cluster size is applied. The older workflow fitted the labellings as separate clusters and merged them afterwards; it is still available (below) and the two are compared on paracetamol in the comparison notebook.
Relabelling is not motion. The enumerations are alternative descriptions of one observation. They are never appended to the data as extra frames, which would multiply frame counts and statistical weights by the group order.
The input contract¶
Angles alone cannot establish a molecular symmetry, so the default route needs:
| input | what it is | when |
|---|---|---|
torsions |
(n_frames, n_selected) stored angles, in the order of the definitions |
always |
molecule |
an RDKit molecule with explicit hydrogens | always |
torsion_definitions |
a TorsionDefinitions indexing that molecule |
always |
coordinates |
an mdtraj.Trajectory, or an (n, n_atoms, 3) array with box_vectors (or periodic=False) and a topology |
when a symmetry maps a selected torsion onto one that is not in the table |
atom_mapping |
the trajectory index of each reference atom; never defaulted | with coordinates |
Missing information raises with the call that fixes it. Nothing guesses a symmetry, and nothing falls back to ordinary clustering on its own. The mapping is checked twice: by graph (elements and every bond among the mapped atoms) and by geometry (each stored column must agree with the same quadruplet recomputed from the coordinates, to 1e-3 rad by default). Molecules split across the periodic box are made whole along their bonds, for orthorhombic and reduced triclinic cells, before any dihedral is computed.
Enumerating the symmetry¶
enumerate_symmetry matches the molecule against ITSELF with RDKit (uniquify=False,
useChirality=True), which returns every graph automorphism of the whole molecule -- rings,
methyl groups, anything. It works on a copy with atom-map numbers cleared, re-verifies every match
as an element- and bond-order-preserving bijection, checks that every specified stereocentre and
stereo double bond keeps its CIP label, puts the identity first, and asks for one match more than
its cap (200 000): a search that hits the cap raises IncompleteSymmetryEnumeration instead of
being used. Permutations that act identically on the descriptor collapse to one operation:
paracetamol has 12 automorphisms (3! methyl-hydrogen orders x the ring flip) and 2 distinct
operations, because no selected torsion contains a methyl hydrogen.
When the one-sided minimum is exact¶
If every operation acts on the descriptor as an ISOMETRY of the embedding,
which costs |G| evaluations instead of |G|^2. That holds exactly when (1) every operation maps the
set of descriptor torsions onto itself, so it acts as a permutation of columns, and (2) the metric
weights are constant on every orbit of that permutation group. An arbitrary selection satisfies
neither. Paracetamol's aryl (1-3-4-5) maps to 1-3-4-10, which nobody selected, so the flip does
not even act on the three selected columns. SymmetricTorsionDescriptor therefore:
- closes the selection: every image of a selected quadruplet becomes a column, named
<torsion>@<atoms>(aryl@1-3-4-10,phenol@9-7-8-17), recomputed from coordinates -- never approximated by a 180 degree shift, which on paracetamol would be wrong by up to 25 degrees; - normalises the weights: each orbit carries the summed weight of the torsions selected in it,
split equally among its columns (paracetamol:
omega1, the four ring-dependent columns 0.5 each), so the ring orientation counts once and not twice; selected torsions in one orbit must have equal weights, or the constructor refuses; - verifies closure under composition and the isometry when it is built, and raises if either
fails.
exhaustive_distancecomputes the two-sided minimum, and the test suite checks the two agree on small fixtures, including a molecule with a three-fold axis, where an operation and its inverse differ.
The minimising operation is returned for inspection (distance_matrix(..., return_operation=True),
distance, pair_table); ties go to the earlier operation. What is NOT done: choosing a
"canonical" labelling per frame and taking ordinary Euclidean distances between representatives,
which breaks pairs that sit on either side of the canonicalisation boundary.
One distance, everywhere¶
d_sym is used for the HDBSCAN fit, the nearest-neighbour vote, the density threshold and the
query density check, and the medoid tie-break of representatives(). It is a minimum over
relabellings and not Euclidean in any fixed embedding, so it is handed to HDBSCAN as an exact
dense matrix (metric="precomputed") and never forced into a Euclidean KD-tree. One copy is
8 n^2 bytes -- 122 MiB at the 4000 frames of the paracetamol tutorial -- and sklearn holds about
three. Above max_precomputed_frames (10000) the fit refuses and names the subset route,
resampling=N: N frames are drawn in proportion to the weights, fitted, and every frame is then
classified with the same distance. The metric is never swapped to make a fit cheaper.
Assigning frames: 18 of 20¶
Every frame is classified by its k = 20 nearest CLUSTERED training frames, itself excluded. It
joins the winning cluster when
so 18 of 20 assigns and 17 of 20 does not. k_effective is 20, or the number of eligible training
frames when there are fewer, and it is reported per frame (fit.k_effective_). Neighbours at
equal distance are ordered by index, so the decision does not depend on the numpy build.
Two kinds of unassigned frame, with different codes and different meanings:
- -2, density noise -- the frame's
min_samples-th neighbour is farther than that of any clustered training frame, under the same distance. Takes precedence. - -1, ambiguous -- fewer than 90 % of the neighbours agree. This describes the sample around
the frame; it is not evidence of a free-energy barrier. (
AMBIGUOUS_LABEL; the former nameBARRIER_LABELis kept as an alias of the same value.)
noise_mass, ambiguous_mass and their sum unassigned_mass are reported separately. Populations
are weighted sums over every frame; the density the fit sees is that of the fitted frames,
unweighted unless resampling=N.
Duplicated torsions, resonance-equivalent atoms (the two oxygens of a carboxylate, the nitrogens of a
guanidinium), the minimised distance, the 1 % floor and how a frame is written in one labelling
are explained on their own page. enumerate_symmetry, the descriptor and
t_hdbscan take resonance=True by default; resonance=False compares the drawn structure.
Migrating an existing call¶
| before | now |
|---|---|
t_hdbscan(tors) |
refused: pass molecule, torsion_definitions (and coordinates, atom_mapping), or symmetry=False |
t_hdbscan(tors) for the old numbers |
t_hdbscan(tors, symmetry=False, vote_rule="legacy-margin") |
min_vote=0.9 (a MARGIN, k = 15) |
refused unless vote_rule="legacy-margin"; the new threshold is min_vote_fraction (k = 20). A margin is never reinterpreted as a fraction: 18 of 20 against two votes is a fraction of 0.90 and a margin of 0.80 |
summary()["min_vote"] |
summary()["vote"] (rule, k, k_effective_min/max, min_vote_fraction); min_vote is still written on the legacy-margin rule |
noise_mass_ (both codes) |
noise_mass_ is DENSITY noise only; add ambiguous_mass_, or read unassigned_mass_ |
name "barrier" for -1 |
"ambiguous" |
representative_by_vote(fit) |
unchanged call; it now uses the fit's own distance and vote (fit.representatives()) |
multiplicities=[...] with symmetry |
refused: per-torsion folding is what the symmetry-first distance replaces |
cluster, then TorsionSymmetry merge |
the default route needs no merge; the merge remains available for the legacy route |
Structural, not thermodynamic. A graph automorphism says two configurations are the same
conformer under another naming. It does not make the populations of the two namings equal: an
atom-specific restraint, an atom-indexed parameter or a REST2 region covering one side breaks the
energetic symmetry and leaves the graph unchanged. Declare such a thing with broken_by=[...]; it
is recorded in the summary.
Three things it does not claim.
A cluster is a density basin, NOT a metastable state. Nothing in this module looks at time. Two density peaks separated by a sampled region are two clusters whatever the barrier between them, and a long-lived state that is geometrically diffuse may be one cluster or none. Metastability needs dynamical evidence this estimator does not have.
Seed agreement is PRECISION, not accuracy. draw_agreement_ says the cluster count was stable
under resampling. It does not say a small state was seen.
With non-uniform weights and resampling=False, only the POPULATIONS are reweighted. HDBSCAN
cannot weight its density estimate — the core distance is a k-th nearest neighbour distance, a
RANK statistic, so there is no sample_weight to pass — and the partition is therefore still
fitted on the sampled ensemble. resampling=N draws N frames with probability proportional to
the weights (Madow systematic, WITHOUT replacement, because duplicate frames give zero distances
and a core distance of zero) and fits the target distribution itself.
allow_single_cluster defaults to True, unlike sklearn¶
With it False, HDBSCAN's excess-of-mass selection refuses the root of the condensed tree, so a unimodal ensemble cannot come back as one state — it is split into two or three with a plausible mass split and nothing saying one state was never a candidate. Measured here: a 4000-frame single Gaussian splits into 2.
The cost of True points the other way: a genuinely multi-state ensemble can collapse into one cluster when the between-basin density does not beat the root's stability. True is still the better default because the two errors are not equally visible. One state reported where there are three is a single population that looks wrong and prompts a check; three reported where there is one is several plausible populations that look like a result. Over-splitting is the harder error to notice.
summary() carries single_cluster_excluded_by_construction either way, so which case you are in
is reported rather than inferred — and when the answer IS one cluster, a caveat tells you to check
it against the torsion marginals first.
t_mi¶
Mutual information between pairs of torsions: how much knowing one tells you about the other, in nats, from a 2D histogram on the torus.
The plug-in histogram estimator is biased UPWARD, and that is the whole reason this is not three lines of numpy. An independent pair does not return zero; it returns a small positive number that reads as a weak coupling. Measured on a paracetamol trajectory, the pair with the LARGEST raw MI was the one that is entirely bias:
| pair | raw | debiased | excess over null |
|---|---|---|---|
| omega–aryl | 0.0334 | +0.0206 | +9.6 σ |
| aryl–phenol | 0.0462 | +0.0013 | +0.4 σ |
| omega–phenol | 0.0117 | −0.0027 | −1.2 σ |
So the bias is MEASURED, per pair, against a null built by shuffling ONE axis — which destroys
the dependence while keeping both marginals and the binning exactly as they were. With n_null=0
the debiased value is None, never silently equal to the raw one.
No verdict field. A boolean "significant" would collapse +9.6 σ and +0.4 σ into the same
answer, and +0.4 σ is exactly where a reader needs the number rather than a token. Three
normalisations are returned because they answer different questions: I/sqrt(HiHj) (symmetric,
the usual NMI), I/min(Hi,Hj), and the asymmetric I/Hi, which is the one a redundancy rule must
use — dropping torsion i is only safe if i is determined by what remains.
Across the whole set¶
analyse asks which pairs are coupled among all of them, which is a different problem: d
torsions means d(d−1)/2 simultaneous comparisons, so a handful of "significant" pairs is the
expected yield of pure noise. It sweeps the binning and the origin, bootstraps over BLOCKS rather
than frames (a correlated trajectory has far fewer independent samples than frames), and applies
a Benjamini–Hochberg false-discovery-rate correction.
Its verdicts are three-valued and none of them says "independent": a pair where no dependence
was detected at this resolution and this sample size has not been shown independent.
dependence_graph gives the connected components over supported pairs — a CANDIDATE analysis
group, not proof of irreducible many-body coupling, and a missing edge rules nothing out, because
pairwise MI is blind to dependence that appears only in three or more variables jointly.
t_symmetry¶
The post-hoc route, kept for the legacy workflow and for comparison. The default t_hdbscan
enumerates the symmetry before clustering and needs no merge. TorsionSymmetry takes an EXISTING
partition and decides which of its clusters to merge; it is what the
archived 0.6.4 tutorial used.
Some clusters are the same physical conformer seen through a permutation of chemically identical atoms. Paracetamol's phenyl ring has a two-fold axis: flipping it maps one basin onto another with no physical change at all. Merging those is right; merging two genuinely different conformers because their populations happen to match is wrong, and everything here is arranged to prevent the second.
What counts as evidence: a graph AUTOMORPHISM of the molecule, found by RDKit, respecting element, bond order, formal charge, isotope and specified stereochemistry — and then agreement of the two clusters' JOINT distributions, in both directions.
What does not: equal populations, similar one-dimensional histograms, equal canonical ranks, a force-field multiplicity, a bin count, or an unconstrained spatial reflection.
Why a permutation is applied whole¶
In a para-disubstituted benzene the two-fold flip moves BOTH attachment torsions together. Folding each torsion into its own period independently would also identify the configuration where only one substituent was rotated — a different relative orientation, and a different conformer. So an operation acts on the whole descriptor vector as one object, never torsion by torsion.
When the transformed torsion is not in the table¶
An operation maps 1-3-4-5 to 1-3-4-10: same central bond, different outer atom. If that
quadruplet is among the selected torsions its stored column is reused (a dihedral is exactly
reversal-invariant, so D-C-B-A counts). Otherwise it is recomputed from coordinates, and if
there are none the operation is reported UNRESOLVED with the missing quadruplets named.
It is never approximated by an idealised 180° shift. Ring distortion and pyramidalization move the outer atoms independently of the central bond, so the shift is not a constant — and substituting one would hide exactly the deviations the comparison is meant to be sensitive to.
Deciding equivalence¶
The statistic is the energy distance on the periodic embedding: a genuine distance between distributions, zero if and only if they are equal, using the JOINT configuration of every torsion at once, and needing no binning — a binned test on d torsions needs exponentially many frames to fill the grid.
It is symmetric, so it can stay small when one cluster is a SUBSET of another — a narrow basin nested inside a broad one. Coverage is therefore asked in each direction separately and both must pass, which is what distinguishes "the same distribution" from "this one fits inside that one". A pair that passes energy but only one direction of coverage is reported as a partial relationship rather than merged.
Tolerances are calibrated from the data, not invented. Each cluster is split into its first and second half BY FRAME INDEX — temporally separated, so the two samples carry the autocorrelation a random split would hide — and the energy distance between halves is measured. That is what two samples of the same distribution produce here. A split-half distance is a FLOOR on the sampling error: it says nothing about a slow motion neither half sampled, and that caveat travels with the tolerance.
Transitivity is not assumed. Under a loose threshold A~B and B~C can both pass while A and C are plainly different. A connected component is only a candidate group: every member is then re-tested against the group's reference directly, and one that fails is split back out.
Populations¶
Masses SUM across a merge, once. Nothing is multiplied by the symmetry order — the order says how many labels the ensemble was split into, not how much probability it holds. Noise keeps its own label and its own mass, and merged masses plus noise equal the original total exactly.
What it does not establish¶
That two clusters are related by a graph automorphism says their conformations are structurally
equivalent. It does NOT establish that their thermodynamic populations should be equal. An
atom-specific restraint, or a REST2 scaling region covering one side and not the other, breaks the
equivalence while leaving the graph intact — declare those with broken_by=[...] and they are
recorded on every accepted pair.
Where these are used¶
- Symmetry-aware clustering and the torsion representation
- Paracetamol: symmetry-first clustering
- Paracetamol: torsions in one labelling
- c(RGDfV): conformations of a cyclic peptide
- Paracetamol: the old and new workflows compared
- Paracetamol: which torsions move together
- Archived 0.6.4: cluster, then merge by symmetry