torchspin 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- torchspin/__init__.py +363 -0
- torchspin/_cardamom_istos.py +327 -0
- torchspin/_cardamom_propagatedm.py +413 -0
- torchspin/_cardamom_utils.py +113 -0
- torchspin/_compile.py +100 -0
- torchspin/_linalg.py +140 -0
- torchspin/angmom.py +742 -0
- torchspin/autograd.py +557 -0
- torchspin/autoguess.py +342 -0
- torchspin/batch.py +225 -0
- torchspin/blochsteady.py +274 -0
- torchspin/cardamom.py +552 -0
- torchspin/chili.py +932 -0
- torchspin/chili_sle.py +1233 -0
- torchspin/constants.py +88 -0
- torchspin/convspec.py +148 -0
- torchspin/ctafft.py +91 -0
- torchspin/curry.py +241 -0
- torchspin/data/FourierSeriesCoefficients.txt +89 -0
- torchspin/data/GaussianCascadeCoefficients.txt +56 -0
- torchspin/data/isotopedata.txt +416 -0
- torchspin/data/spacegroups.txt +637 -0
- torchspin/dataproc.py +361 -0
- torchspin/dipbackground.py +69 -0
- torchspin/dipkernel.py +102 -0
- torchspin/diptensor.py +138 -0
- torchspin/endorfrq.py +198 -0
- torchspin/endorfrq_perturb.py +302 -0
- torchspin/eprload.py +1538 -0
- torchspin/eprsave.py +165 -0
- torchspin/esfit.py +2427 -0
- torchspin/evolve.py +274 -0
- torchspin/ewrls.py +124 -0
- torchspin/excitation.py +107 -0
- torchspin/exciteprofile.py +132 -0
- torchspin/experiment.py +323 -0
- torchspin/exponfit.py +177 -0
- torchspin/fastmotion.py +182 -0
- torchspin/fdaxis.py +57 -0
- torchspin/fitgui.py +350 -0
- torchspin/garlic.py +760 -0
- torchspin/ham.py +145 -0
- torchspin/ham_cf.py +115 -0
- torchspin/ham_ee.py +124 -0
- torchspin/ham_ez.py +119 -0
- torchspin/ham_ezho.py +403 -0
- torchspin/ham_hf.py +122 -0
- torchspin/ham_nn.py +108 -0
- torchspin/ham_nq.py +100 -0
- torchspin/ham_nz.py +136 -0
- torchspin/ham_oz.py +115 -0
- torchspin/ham_so.py +118 -0
- torchspin/ham_zf.py +254 -0
- torchspin/hamsymm.py +723 -0
- torchspin/initstate.py +158 -0
- torchspin/isotopologues.py +296 -0
- torchspin/levels.py +189 -0
- torchspin/levelsplot.py +363 -0
- torchspin/lineshape.py +715 -0
- torchspin/lpsvd.py +219 -0
- torchspin/makespec.py +87 -0
- torchspin/mdhmm.py +573 -0
- torchspin/mdload.py +788 -0
- torchspin/mdtraj2oripot.py +90 -0
- torchspin/ml.py +377 -0
- torchspin/mlpsvd.py +297 -0
- torchspin/nucdata.py +310 -0
- torchspin/nucfrq2d.py +228 -0
- torchspin/orca2torchspin.py +1198 -0
- torchspin/ordering.py +78 -0
- torchspin/oripotentialplot.py +246 -0
- torchspin/orisel.py +176 -0
- torchspin/pepper.py +2019 -0
- torchspin/pepper_autograd.py +613 -0
- torchspin/photoselect.py +174 -0
- torchspin/plegendre.py +131 -0
- torchspin/propint.py +127 -0
- torchspin/pulse.py +652 -0
- torchspin/py.typed +0 -0
- torchspin/rapidscan2spc.py +101 -0
- torchspin/resfields.py +337 -0
- torchspin/resfields_batch.py +446 -0
- torchspin/resfields_eig.py +250 -0
- torchspin/resfields_perturb.py +706 -0
- torchspin/resfreqs_matrix.py +344 -0
- torchspin/resfreqs_perturb.py +299 -0
- torchspin/resonator.py +310 -0
- torchspin/resonatorprofile.py +85 -0
- torchspin/rfmixer.py +147 -0
- torchspin/rotations.py +87 -0
- torchspin/rotutils.py +880 -0
- torchspin/saffron.py +1523 -0
- torchspin/saffron_pathways.py +92 -0
- torchspin/saffron_peaks.py +580 -0
- torchspin/saffron_thyme.py +472 -0
- torchspin/salt.py +680 -0
- torchspin/sigeq.py +71 -0
- torchspin/signalprocessing.py +226 -0
- torchspin/sitetransforms.py +254 -0
- torchspin/sphgrid.py +470 -0
- torchspin/spidyan.py +1508 -0
- torchspin/spinladder.py +161 -0
- torchspin/spinops.py +265 -0
- torchspin/spinsystem.py +1427 -0
- torchspin/stackplot.py +167 -0
- torchspin/stev.py +283 -0
- torchspin/stochtraj_diffusion.py +494 -0
- torchspin/stochtraj_jump.py +195 -0
- torchspin/strainwidth.py +727 -0
- torchspin/transmitter.py +89 -0
- torchspin/utils.py +605 -0
- torchspin-0.3.0.dist-info/METADATA +267 -0
- torchspin-0.3.0.dist-info/RECORD +116 -0
- torchspin-0.3.0.dist-info/WHEEL +5 -0
- torchspin-0.3.0.dist-info/licenses/LICENSE.md +22 -0
- torchspin-0.3.0.dist-info/top_level.txt +1 -0
torchspin/mdhmm.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mdhmm — Hidden Markov Model for MD dihedral trajectories.
|
|
3
|
+
|
|
4
|
+
Builds an HMM from dihedral angle trajectories using:
|
|
5
|
+
1. Circular K-means clustering for initialization
|
|
6
|
+
2. Baum-Welch (EM) algorithm for parameter estimation
|
|
7
|
+
3. Viterbi decoding for state assignment
|
|
8
|
+
|
|
9
|
+
Based on EasySpin's mdhmm.m, mdhmm_em.m, mdhmm_kmeans.m.
|
|
10
|
+
|
|
11
|
+
All angles are in **radians**.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Optional, Tuple
|
|
17
|
+
from scipy.optimize import minimize
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class HMMResult:
|
|
22
|
+
"""Hidden Markov Model result.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
TransProb : ndarray, shape (nStates, nStates)
|
|
27
|
+
Transition probability matrix.
|
|
28
|
+
eqDistr : ndarray, shape (nStates,)
|
|
29
|
+
Equilibrium distribution.
|
|
30
|
+
mu : ndarray, shape (nDihedrals, nStates)
|
|
31
|
+
State center vectors (dihedral angles).
|
|
32
|
+
Sigma : ndarray, shape (nDihedrals, nDihedrals, nStates)
|
|
33
|
+
Covariance matrices per state.
|
|
34
|
+
viterbiTraj : ndarray, shape (nSteps,) or (nTraj, nSteps)
|
|
35
|
+
Most-likely state sequence.
|
|
36
|
+
tauRelax : ndarray
|
|
37
|
+
Relaxation times from eigenvalues of TransProb.
|
|
38
|
+
tLag : float
|
|
39
|
+
Lag time (dt * nLag).
|
|
40
|
+
nStates : int
|
|
41
|
+
dt : float
|
|
42
|
+
nLag : int
|
|
43
|
+
"""
|
|
44
|
+
TransProb: np.ndarray = None
|
|
45
|
+
eqDistr: np.ndarray = None
|
|
46
|
+
mu: np.ndarray = None
|
|
47
|
+
Sigma: np.ndarray = None
|
|
48
|
+
viterbiTraj: np.ndarray = None
|
|
49
|
+
tauRelax: np.ndarray = None
|
|
50
|
+
tLag: float = 0.0
|
|
51
|
+
nStates: int = 0
|
|
52
|
+
dt: float = 0.0
|
|
53
|
+
nLag: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class HMMOptions:
|
|
58
|
+
"""Options for mdhmm."""
|
|
59
|
+
Verbosity: int = 0
|
|
60
|
+
isSeeded: bool = False
|
|
61
|
+
nTrials: int = 5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Circular distance helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _dist_pbc(x1, x2, W=2 * np.pi):
|
|
69
|
+
"""Circular distance between angles."""
|
|
70
|
+
d = np.abs(x1 - x2)
|
|
71
|
+
return np.minimum(d, W - d)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mean_pbc(angles, axis=None, W=2 * np.pi):
|
|
75
|
+
"""Circular mean of angles using unwrapping."""
|
|
76
|
+
if len(angles) == 0:
|
|
77
|
+
return 0.0
|
|
78
|
+
ref = angles.flat[0] if axis is None else np.take(angles, 0, axis=axis)
|
|
79
|
+
# Unwrap relative to reference
|
|
80
|
+
diff = angles - ref
|
|
81
|
+
diff = (diff + np.pi) % (2 * np.pi) - np.pi
|
|
82
|
+
mean_diff = np.mean(diff, axis=axis)
|
|
83
|
+
return (ref + mean_diff + np.pi) % (2 * np.pi) - np.pi
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Circular K-means (mdhmm_kmeans.m)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _circular_kmeans(data, n_clusters, n_repeats=5, centroids0=None, seed=None):
|
|
91
|
+
"""K-means clustering with circular distance metric.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
data : ndarray, shape (nDihedrals, nPoints)
|
|
96
|
+
Dihedral angle data.
|
|
97
|
+
n_clusters : int
|
|
98
|
+
n_repeats : int
|
|
99
|
+
centroids0 : ndarray, shape (nDihedrals, n_clusters), optional
|
|
100
|
+
seed : int, optional
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
idx : ndarray, shape (nPoints,)
|
|
105
|
+
Cluster assignments.
|
|
106
|
+
centroids : ndarray, shape (nDihedrals, n_clusters)
|
|
107
|
+
"""
|
|
108
|
+
n_dim, n_points = data.shape
|
|
109
|
+
rng = np.random.default_rng(seed)
|
|
110
|
+
|
|
111
|
+
best_cost = np.inf
|
|
112
|
+
best_idx = None
|
|
113
|
+
best_centroids = None
|
|
114
|
+
|
|
115
|
+
for trial in range(n_repeats):
|
|
116
|
+
# Initialize centroids
|
|
117
|
+
if centroids0 is not None and trial == 0:
|
|
118
|
+
centroids = centroids0.copy()
|
|
119
|
+
else:
|
|
120
|
+
# K-means++ initialization with circular distance
|
|
121
|
+
centroids = np.zeros((n_dim, n_clusters))
|
|
122
|
+
# First centroid: random
|
|
123
|
+
idx0 = rng.integers(0, n_points)
|
|
124
|
+
centroids[:, 0] = data[:, idx0]
|
|
125
|
+
|
|
126
|
+
for k in range(1, n_clusters):
|
|
127
|
+
# Distance to nearest centroid
|
|
128
|
+
dists = np.full(n_points, np.inf)
|
|
129
|
+
for j in range(k):
|
|
130
|
+
d = np.sum(_dist_pbc(data, centroids[:, j:j + 1]) ** 2, axis=0)
|
|
131
|
+
dists = np.minimum(dists, d)
|
|
132
|
+
# Probability proportional to distance²
|
|
133
|
+
probs = dists / dists.sum()
|
|
134
|
+
idx_k = rng.choice(n_points, p=probs)
|
|
135
|
+
centroids[:, k] = data[:, idx_k]
|
|
136
|
+
|
|
137
|
+
# Iterate
|
|
138
|
+
max_iter = 500
|
|
139
|
+
for iteration in range(max_iter):
|
|
140
|
+
# Assignment step
|
|
141
|
+
dists = np.zeros((n_clusters, n_points))
|
|
142
|
+
for k in range(n_clusters):
|
|
143
|
+
dists[k] = np.sum(_dist_pbc(data, centroids[:, k:k + 1]) ** 2, axis=0)
|
|
144
|
+
idx = np.argmin(dists, axis=0)
|
|
145
|
+
|
|
146
|
+
# Update step
|
|
147
|
+
new_centroids = np.zeros_like(centroids)
|
|
148
|
+
for k in range(n_clusters):
|
|
149
|
+
members = data[:, idx == k]
|
|
150
|
+
if members.shape[1] == 0:
|
|
151
|
+
new_centroids[:, k] = data[:, rng.integers(0, n_points)]
|
|
152
|
+
else:
|
|
153
|
+
for d in range(n_dim):
|
|
154
|
+
new_centroids[d, k] = _mean_pbc(members[d])
|
|
155
|
+
|
|
156
|
+
# Check convergence
|
|
157
|
+
centroid_change = np.max(_dist_pbc(new_centroids, centroids))
|
|
158
|
+
centroids = new_centroids
|
|
159
|
+
if centroid_change < 1e-4:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# Cost
|
|
163
|
+
cost = 0
|
|
164
|
+
for k in range(n_clusters):
|
|
165
|
+
members = data[:, idx == k]
|
|
166
|
+
cost += np.sum(_dist_pbc(members, centroids[:, k:k + 1]) ** 2)
|
|
167
|
+
|
|
168
|
+
if cost < best_cost:
|
|
169
|
+
best_cost = cost
|
|
170
|
+
best_idx = idx
|
|
171
|
+
best_centroids = centroids
|
|
172
|
+
|
|
173
|
+
return best_idx, best_centroids
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Gaussian probability (circular)
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _gaussian_prob_circular(data, mu, sigma):
|
|
181
|
+
"""Multivariate Gaussian probability for circular data.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
data : ndarray, shape (nDim, nPoints)
|
|
186
|
+
mu : ndarray, shape (nDim,)
|
|
187
|
+
sigma : ndarray, shape (nDim, nDim)
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
prob : ndarray, shape (nPoints,)
|
|
192
|
+
"""
|
|
193
|
+
n_dim = data.shape[0]
|
|
194
|
+
diff = _dist_pbc(data, mu[:, np.newaxis])
|
|
195
|
+
# Use signed difference for proper Gaussian
|
|
196
|
+
diff_signed = data - mu[:, np.newaxis]
|
|
197
|
+
diff_signed = (diff_signed + np.pi) % (2 * np.pi) - np.pi
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
sigma_inv = np.linalg.inv(sigma)
|
|
201
|
+
det_sigma = np.linalg.det(sigma)
|
|
202
|
+
except np.linalg.LinAlgError:
|
|
203
|
+
sigma_reg = sigma + 1e-6 * np.eye(n_dim)
|
|
204
|
+
sigma_inv = np.linalg.inv(sigma_reg)
|
|
205
|
+
det_sigma = np.linalg.det(sigma_reg)
|
|
206
|
+
|
|
207
|
+
det_sigma = max(det_sigma, 1e-300)
|
|
208
|
+
|
|
209
|
+
exponent = -0.5 * np.sum(diff_signed * (sigma_inv @ diff_signed), axis=0)
|
|
210
|
+
norm = 1.0 / ((2 * np.pi) ** (n_dim / 2) * np.sqrt(det_sigma))
|
|
211
|
+
prob = norm * np.exp(exponent)
|
|
212
|
+
|
|
213
|
+
return np.maximum(prob, 1e-300)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Forward-backward algorithm
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _forward_backward(obs_prob, trans_prob, init_distr):
|
|
221
|
+
"""Forward-backward algorithm for HMM.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
obs_prob : ndarray, shape (nStates, nSteps)
|
|
226
|
+
Observation probabilities B(state, time).
|
|
227
|
+
trans_prob : ndarray, shape (nStates, nStates)
|
|
228
|
+
Transition matrix A(i,j) = P(state_j at t+1 | state_i at t).
|
|
229
|
+
init_distr : ndarray, shape (nStates,)
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
gamma : ndarray, shape (nStates, nSteps)
|
|
234
|
+
State occupancy probabilities.
|
|
235
|
+
xi : ndarray, shape (nStates, nStates, nSteps-1)
|
|
236
|
+
Transition counts.
|
|
237
|
+
log_lik : float
|
|
238
|
+
Log-likelihood.
|
|
239
|
+
"""
|
|
240
|
+
n_states, n_steps = obs_prob.shape
|
|
241
|
+
|
|
242
|
+
# Forward pass with scaling
|
|
243
|
+
alpha = np.zeros((n_states, n_steps))
|
|
244
|
+
scale = np.zeros(n_steps)
|
|
245
|
+
|
|
246
|
+
alpha[:, 0] = init_distr * obs_prob[:, 0]
|
|
247
|
+
scale[0] = np.sum(alpha[:, 0])
|
|
248
|
+
if scale[0] > 0:
|
|
249
|
+
alpha[:, 0] /= scale[0]
|
|
250
|
+
|
|
251
|
+
for t in range(1, n_steps):
|
|
252
|
+
alpha[:, t] = (trans_prob.T @ alpha[:, t - 1]) * obs_prob[:, t]
|
|
253
|
+
scale[t] = np.sum(alpha[:, t])
|
|
254
|
+
if scale[t] > 0:
|
|
255
|
+
alpha[:, t] /= scale[t]
|
|
256
|
+
|
|
257
|
+
# Log-likelihood
|
|
258
|
+
log_lik = np.sum(np.log(np.maximum(scale, 1e-300)))
|
|
259
|
+
|
|
260
|
+
# Backward pass
|
|
261
|
+
beta = np.zeros((n_states, n_steps))
|
|
262
|
+
beta[:, -1] = 1.0
|
|
263
|
+
|
|
264
|
+
for t in range(n_steps - 2, -1, -1):
|
|
265
|
+
beta[:, t] = trans_prob @ (obs_prob[:, t + 1] * beta[:, t + 1])
|
|
266
|
+
if scale[t + 1] > 0:
|
|
267
|
+
beta[:, t] /= scale[t + 1]
|
|
268
|
+
|
|
269
|
+
# Gamma and Xi
|
|
270
|
+
gamma = alpha * beta
|
|
271
|
+
gamma_sum = np.sum(gamma, axis=0, keepdims=True)
|
|
272
|
+
gamma = gamma / np.maximum(gamma_sum, 1e-300)
|
|
273
|
+
|
|
274
|
+
xi = np.zeros((n_states, n_states, n_steps - 1))
|
|
275
|
+
for t in range(n_steps - 1):
|
|
276
|
+
xi_t = (alpha[:, t:t + 1] * trans_prob) * (obs_prob[:, t + 1] * beta[:, t + 1])
|
|
277
|
+
xi_sum = np.sum(xi_t)
|
|
278
|
+
if xi_sum > 0:
|
|
279
|
+
xi[:, :, t] = xi_t / xi_sum
|
|
280
|
+
|
|
281
|
+
return gamma, xi, log_lik
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Viterbi algorithm
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _viterbi(obs_prob, trans_prob, init_distr):
|
|
289
|
+
"""Viterbi algorithm for most-likely state sequence.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
obs_prob : ndarray, shape (nStates, nSteps)
|
|
294
|
+
trans_prob : ndarray, shape (nStates, nStates)
|
|
295
|
+
init_distr : ndarray, shape (nStates,)
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
path : ndarray, shape (nSteps,)
|
|
300
|
+
Most-likely state indices.
|
|
301
|
+
"""
|
|
302
|
+
n_states, n_steps = obs_prob.shape
|
|
303
|
+
|
|
304
|
+
log_trans = np.log(np.maximum(trans_prob, 1e-300))
|
|
305
|
+
log_obs = np.log(np.maximum(obs_prob, 1e-300))
|
|
306
|
+
log_init = np.log(np.maximum(init_distr, 1e-300))
|
|
307
|
+
|
|
308
|
+
# Viterbi forward
|
|
309
|
+
V = np.zeros((n_states, n_steps))
|
|
310
|
+
backptr = np.zeros((n_states, n_steps), dtype=int)
|
|
311
|
+
|
|
312
|
+
V[:, 0] = log_init + log_obs[:, 0]
|
|
313
|
+
|
|
314
|
+
for t in range(1, n_steps):
|
|
315
|
+
for j in range(n_states):
|
|
316
|
+
scores = V[:, t - 1] + log_trans[:, j]
|
|
317
|
+
backptr[j, t] = np.argmax(scores)
|
|
318
|
+
V[j, t] = scores[backptr[j, t]] + log_obs[j, t]
|
|
319
|
+
|
|
320
|
+
# Backtrack
|
|
321
|
+
path = np.zeros(n_steps, dtype=int)
|
|
322
|
+
path[-1] = np.argmax(V[:, -1])
|
|
323
|
+
for t in range(n_steps - 2, -1, -1):
|
|
324
|
+
path[t] = backptr[path[t + 1], t + 1]
|
|
325
|
+
|
|
326
|
+
return path
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Transition matrix estimation via L-BFGS-B
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def _estimate_transition_matrix(xi, gamma):
|
|
334
|
+
"""Estimate transition matrix from EM sufficient statistics.
|
|
335
|
+
|
|
336
|
+
Parameters
|
|
337
|
+
----------
|
|
338
|
+
xi : ndarray, shape (nStates, nStates)
|
|
339
|
+
Sum of transition counts.
|
|
340
|
+
gamma : ndarray, shape (nStates,)
|
|
341
|
+
Sum of state occupancies.
|
|
342
|
+
|
|
343
|
+
Returns
|
|
344
|
+
-------
|
|
345
|
+
T : ndarray, shape (nStates, nStates)
|
|
346
|
+
Row-stochastic transition matrix.
|
|
347
|
+
"""
|
|
348
|
+
n = xi.shape[0]
|
|
349
|
+
T = xi.copy()
|
|
350
|
+
|
|
351
|
+
# Normalize rows
|
|
352
|
+
row_sums = T.sum(axis=1, keepdims=True)
|
|
353
|
+
row_sums = np.maximum(row_sums, 1e-300)
|
|
354
|
+
T = T / row_sums
|
|
355
|
+
|
|
356
|
+
return T
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Baum-Welch (EM) algorithm (mdhmm_em.m)
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _baum_welch(data, n_states, init_distr, trans_prob, mu, sigma,
|
|
364
|
+
max_iter=200, tol=1e-4):
|
|
365
|
+
"""Baum-Welch algorithm for HMM parameter estimation.
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
data : ndarray, shape (nDim, nSteps)
|
|
370
|
+
n_states : int
|
|
371
|
+
init_distr : ndarray, shape (nStates,)
|
|
372
|
+
trans_prob : ndarray, shape (nStates, nStates)
|
|
373
|
+
mu : ndarray, shape (nDim, nStates)
|
|
374
|
+
sigma : ndarray, shape (nDim, nDim, nStates)
|
|
375
|
+
max_iter : int
|
|
376
|
+
tol : float
|
|
377
|
+
|
|
378
|
+
Returns
|
|
379
|
+
-------
|
|
380
|
+
log_lik_history : list
|
|
381
|
+
eq_distr : ndarray
|
|
382
|
+
trans_prob : ndarray
|
|
383
|
+
mu : ndarray
|
|
384
|
+
sigma : ndarray
|
|
385
|
+
"""
|
|
386
|
+
n_dim, n_steps = data.shape
|
|
387
|
+
log_lik_history = []
|
|
388
|
+
prev_log_lik = -np.inf
|
|
389
|
+
|
|
390
|
+
for iteration in range(max_iter):
|
|
391
|
+
# E-step: compute observation probabilities
|
|
392
|
+
obs_prob = np.zeros((n_states, n_steps))
|
|
393
|
+
for k in range(n_states):
|
|
394
|
+
obs_prob[k] = _gaussian_prob_circular(data, mu[:, k], sigma[:, :, k])
|
|
395
|
+
|
|
396
|
+
# Forward-backward
|
|
397
|
+
gamma, xi, log_lik = _forward_backward(obs_prob, trans_prob, init_distr)
|
|
398
|
+
log_lik_history.append(log_lik)
|
|
399
|
+
|
|
400
|
+
# Check convergence
|
|
401
|
+
if iteration > 0:
|
|
402
|
+
rel_change = abs(log_lik - prev_log_lik) / max(abs(prev_log_lik), 1)
|
|
403
|
+
if rel_change < tol:
|
|
404
|
+
break
|
|
405
|
+
prev_log_lik = log_lik
|
|
406
|
+
|
|
407
|
+
# M-step: update parameters
|
|
408
|
+
# Transition matrix
|
|
409
|
+
xi_sum = np.sum(xi, axis=2)
|
|
410
|
+
trans_prob = _estimate_transition_matrix(xi_sum, np.sum(gamma[:, :-1], axis=1))
|
|
411
|
+
|
|
412
|
+
# State means (circular)
|
|
413
|
+
for k in range(n_states):
|
|
414
|
+
weights = gamma[k]
|
|
415
|
+
total_weight = np.sum(weights)
|
|
416
|
+
if total_weight > 1e-10:
|
|
417
|
+
for d in range(n_dim):
|
|
418
|
+
# Weighted circular mean
|
|
419
|
+
diff = data[d] - mu[d, k]
|
|
420
|
+
diff = (diff + np.pi) % (2 * np.pi) - np.pi
|
|
421
|
+
mu[d, k] = mu[d, k] + np.sum(weights * diff) / total_weight
|
|
422
|
+
mu[d, k] = (mu[d, k] + np.pi) % (2 * np.pi) - np.pi
|
|
423
|
+
|
|
424
|
+
# Covariance matrices
|
|
425
|
+
for k in range(n_states):
|
|
426
|
+
weights = gamma[k]
|
|
427
|
+
total_weight = np.sum(weights)
|
|
428
|
+
if total_weight > 1e-10:
|
|
429
|
+
diff = data - mu[:, k:k + 1]
|
|
430
|
+
diff = (diff + np.pi) % (2 * np.pi) - np.pi
|
|
431
|
+
sigma[:, :, k] = (diff * weights) @ diff.T / total_weight
|
|
432
|
+
# Regularization
|
|
433
|
+
sigma[:, :, k] += 1e-4 * np.eye(n_dim)
|
|
434
|
+
|
|
435
|
+
# Update initial distribution
|
|
436
|
+
init_distr = gamma[:, 0]
|
|
437
|
+
init_distr = init_distr / np.sum(init_distr)
|
|
438
|
+
|
|
439
|
+
# Equilibrium distribution from eigenvector of TransProb
|
|
440
|
+
eigenvalues, eigenvectors = np.linalg.eig(trans_prob.T)
|
|
441
|
+
idx = np.argmin(np.abs(eigenvalues - 1.0))
|
|
442
|
+
eq_distr = np.real(eigenvectors[:, idx])
|
|
443
|
+
eq_distr = np.abs(eq_distr)
|
|
444
|
+
eq_distr = eq_distr / np.sum(eq_distr)
|
|
445
|
+
|
|
446
|
+
return log_lik_history, eq_distr, trans_prob, mu, sigma
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# Main entry point
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def mdhmm(
|
|
454
|
+
dihedrals: np.ndarray,
|
|
455
|
+
dt: float,
|
|
456
|
+
n_states: int,
|
|
457
|
+
n_lag: int,
|
|
458
|
+
opt: Optional[HMMOptions] = None,
|
|
459
|
+
) -> HMMResult:
|
|
460
|
+
"""Build Hidden Markov Model from dihedral angle trajectories.
|
|
461
|
+
|
|
462
|
+
Parameters
|
|
463
|
+
----------
|
|
464
|
+
dihedrals : ndarray, shape (nDihedrals, nSteps) or (nDihedrals, nTraj, nSteps)
|
|
465
|
+
Spin-label dihedral angles in radians.
|
|
466
|
+
dt : float
|
|
467
|
+
Dihedral trajectory time step.
|
|
468
|
+
n_states : int
|
|
469
|
+
Number of HMM states.
|
|
470
|
+
n_lag : int
|
|
471
|
+
Lag step (number of time steps for downsampling).
|
|
472
|
+
opt : HMMOptions, optional
|
|
473
|
+
|
|
474
|
+
Returns
|
|
475
|
+
-------
|
|
476
|
+
hmm : HMMResult
|
|
477
|
+
"""
|
|
478
|
+
if opt is None:
|
|
479
|
+
opt = HMMOptions()
|
|
480
|
+
|
|
481
|
+
# Handle multi-trajectory input
|
|
482
|
+
if dihedrals.ndim == 3:
|
|
483
|
+
n_dim, n_traj, n_steps_per_traj = dihedrals.shape
|
|
484
|
+
# Concatenate trajectories for clustering and EM
|
|
485
|
+
data = dihedrals.reshape(n_dim, -1)
|
|
486
|
+
else:
|
|
487
|
+
n_dim, total_steps = dihedrals.shape
|
|
488
|
+
n_traj = 1
|
|
489
|
+
data = dihedrals
|
|
490
|
+
|
|
491
|
+
# Downsample by lag
|
|
492
|
+
if n_lag > 1:
|
|
493
|
+
data = data[:, ::n_lag]
|
|
494
|
+
|
|
495
|
+
n_steps = data.shape[1]
|
|
496
|
+
|
|
497
|
+
# Step 1: K-means clustering
|
|
498
|
+
idx, centroids = _circular_kmeans(
|
|
499
|
+
data, n_states, n_repeats=opt.nTrials, seed=42
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Initialize HMM parameters from k-means
|
|
503
|
+
mu = centroids.copy() # (nDim, nStates)
|
|
504
|
+
sigma = np.zeros((n_dim, n_dim, n_states))
|
|
505
|
+
init_distr = np.zeros(n_states)
|
|
506
|
+
|
|
507
|
+
for k in range(n_states):
|
|
508
|
+
members = data[:, idx == k]
|
|
509
|
+
n_members = members.shape[1]
|
|
510
|
+
init_distr[k] = n_members / n_steps
|
|
511
|
+
|
|
512
|
+
if n_members > 1:
|
|
513
|
+
diff = members - mu[:, k:k + 1]
|
|
514
|
+
diff = (diff + np.pi) % (2 * np.pi) - np.pi
|
|
515
|
+
sigma[:, :, k] = (diff @ diff.T) / n_members + 1e-4 * np.eye(n_dim)
|
|
516
|
+
else:
|
|
517
|
+
sigma[:, :, k] = 0.1 * np.eye(n_dim)
|
|
518
|
+
|
|
519
|
+
# Initialize transition matrix
|
|
520
|
+
trans_prob = np.zeros((n_states, n_states))
|
|
521
|
+
for t in range(n_steps - 1):
|
|
522
|
+
trans_prob[idx[t], idx[t + 1]] += 1
|
|
523
|
+
row_sums = trans_prob.sum(axis=1, keepdims=True)
|
|
524
|
+
trans_prob = trans_prob / np.maximum(row_sums, 1)
|
|
525
|
+
|
|
526
|
+
# Step 2: Baum-Welch
|
|
527
|
+
log_lik_history, eq_distr, trans_prob, mu, sigma = _baum_welch(
|
|
528
|
+
data, n_states, init_distr, trans_prob, mu, sigma
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Step 3: Viterbi decoding
|
|
532
|
+
obs_prob = np.zeros((n_states, n_steps))
|
|
533
|
+
for k in range(n_states):
|
|
534
|
+
obs_prob[k] = _gaussian_prob_circular(data, mu[:, k], sigma[:, :, k])
|
|
535
|
+
|
|
536
|
+
viterbi_traj = _viterbi(obs_prob, trans_prob, eq_distr)
|
|
537
|
+
|
|
538
|
+
# Eliminate unvisited states
|
|
539
|
+
visited = np.unique(viterbi_traj)
|
|
540
|
+
if len(visited) < n_states:
|
|
541
|
+
# Remap
|
|
542
|
+
state_map = {old: new for new, old in enumerate(visited)}
|
|
543
|
+
viterbi_traj = np.array([state_map[s] for s in viterbi_traj])
|
|
544
|
+
n_states_new = len(visited)
|
|
545
|
+
mu = mu[:, visited]
|
|
546
|
+
sigma = sigma[:, :, visited]
|
|
547
|
+
trans_prob = trans_prob[np.ix_(visited, visited)]
|
|
548
|
+
eq_distr = eq_distr[visited]
|
|
549
|
+
eq_distr /= eq_distr.sum()
|
|
550
|
+
n_states = n_states_new
|
|
551
|
+
|
|
552
|
+
# Relaxation times
|
|
553
|
+
eigenvalues = np.sort(np.real(np.linalg.eigvals(trans_prob)))[::-1]
|
|
554
|
+
# Skip the eigenvalue = 1 (equilibrium)
|
|
555
|
+
tau_relax = np.zeros(len(eigenvalues) - 1)
|
|
556
|
+
for i in range(1, len(eigenvalues)):
|
|
557
|
+
if eigenvalues[i] > 0 and eigenvalues[i] < 1:
|
|
558
|
+
tau_relax[i - 1] = -n_lag * dt / np.log(eigenvalues[i])
|
|
559
|
+
|
|
560
|
+
# Build result
|
|
561
|
+
result = HMMResult()
|
|
562
|
+
result.TransProb = trans_prob
|
|
563
|
+
result.eqDistr = eq_distr
|
|
564
|
+
result.mu = mu
|
|
565
|
+
result.Sigma = sigma
|
|
566
|
+
result.viterbiTraj = viterbi_traj
|
|
567
|
+
result.tauRelax = tau_relax
|
|
568
|
+
result.tLag = n_lag * dt
|
|
569
|
+
result.nStates = n_states
|
|
570
|
+
result.dt = dt
|
|
571
|
+
result.nLag = n_lag
|
|
572
|
+
|
|
573
|
+
return result
|