polyphys 1.0.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.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- polyphys-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
"""
|
|
2
|
+
==========================================================
|
|
3
|
+
:mod:`polyphys.analyze.correlations`
|
|
4
|
+
==========================================================
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
The :mod:`polyphys.analyze.correlations` module measures how a signal is
|
|
8
|
+
correlated with itself: the autocorrelation function of a time series with
|
|
9
|
+
its confidence band, the decay model fitted to such a curve, and the cosine
|
|
10
|
+
correlation between the bond vectors of a polymer chain, from which a
|
|
11
|
+
persistence length follows. Every routine is a pure NumPy or SciPy kernel on
|
|
12
|
+
arrays; reading time series or frames, choosing the lags to fit, and
|
|
13
|
+
averaging over frames are the caller's work.
|
|
14
|
+
|
|
15
|
+
Notation
|
|
16
|
+
========
|
|
17
|
+
|
|
18
|
+
``x``
|
|
19
|
+
A time series, shape ``(n_frames,)`` or ``(n_frames, n_series)``, in the
|
|
20
|
+
caller's unit; the frames are equally spaced in time and run along axis
|
|
21
|
+
0, and each column is an independent series.
|
|
22
|
+
``nlags``
|
|
23
|
+
The largest lag :math:`k` at which the autocorrelation is reported, in
|
|
24
|
+
frames; a lag is the number of frames between the two values compared.
|
|
25
|
+
``acf``
|
|
26
|
+
The sample autocorrelation function :math:`r_k`, the covariance of the
|
|
27
|
+
series with itself :math:`k` frames later divided by its variance:
|
|
28
|
+
:math:`r_0 = 1` and :math:`|r_k| \\le 1`.
|
|
29
|
+
``half_width``
|
|
30
|
+
The half-width of the confidence interval around :math:`r_k` at level
|
|
31
|
+
:math:`1 - \\alpha`, from Bartlett's large-sample variance of
|
|
32
|
+
:math:`r_k`; the interval is ``acf - half_width`` to
|
|
33
|
+
``acf + half_width``.
|
|
34
|
+
*decay model*
|
|
35
|
+
One of the forms of the stretched exponential (Kohlrausch-Williams-Watts)
|
|
36
|
+
function :math:`A \\exp[-(x / \\tau)^\\beta] + B` listed in
|
|
37
|
+
:data:`DECAY_MODELS`, with the *decay time* :math:`\\tau` in the unit of
|
|
38
|
+
``x``, the *stretching exponent* :math:`\\beta` (1 for a pure
|
|
39
|
+
exponential), the *amplitude* :math:`A`, and the *offset* :math:`B`.
|
|
40
|
+
``positions``
|
|
41
|
+
The coordinates of the monomers of one chain in chain order, shape
|
|
42
|
+
``(..., n_atoms, n_dims)``, unwrapped (no periodic image breaks a bond);
|
|
43
|
+
any leading axes, such as frames, are carried through.
|
|
44
|
+
``topology``
|
|
45
|
+
``'linear'`` or ``'ring'``: whether the chain's last monomer is bonded
|
|
46
|
+
back to its first.
|
|
47
|
+
*bond vector*
|
|
48
|
+
:math:`\\mathbf{b}_i = \\mathbf{r}_{i+1} - \\mathbf{r}_i`, and on a ring
|
|
49
|
+
also :math:`\\mathbf{b}_{n-1} = \\mathbf{r}_0 - \\mathbf{r}_{n-1}`;
|
|
50
|
+
``n_bonds`` is ``n_atoms - 1`` on a linear chain and ``n_atoms`` on a
|
|
51
|
+
ring. :math:`\\hat{\\mathbf{u}}_i` is the unit bond vector.
|
|
52
|
+
*bond cosine correlation*
|
|
53
|
+
:math:`C(k) = \\langle \\hat{\\mathbf{u}}_i \\cdot
|
|
54
|
+
\\hat{\\mathbf{u}}_{i+k} \\rangle`, the mean cosine of the angle between
|
|
55
|
+
two bonds :math:`k` bonds apart along the chain, averaged over the pairs
|
|
56
|
+
in one frame; :math:`C(0) = 1`.
|
|
57
|
+
|
|
58
|
+
Contents
|
|
59
|
+
========
|
|
60
|
+
|
|
61
|
+
Autocorrelation:
|
|
62
|
+
:func:`acf` estimates :math:`r_k` for :math:`k = 0, \\dots,` ``nlags`` by
|
|
63
|
+
the fast Fourier transform, with Bartlett's confidence band, as an
|
|
64
|
+
:class:`AcfResult`; :func:`bartlett_half_width` is that band on its own,
|
|
65
|
+
for a stored :math:`r_k` at any level.
|
|
66
|
+
|
|
67
|
+
Decay fits:
|
|
68
|
+
:data:`DECAY_MODELS` names the decay models; :func:`initial_guess` reads a
|
|
69
|
+
starting point for each parameter off the data; :func:`fit_decay` fits a
|
|
70
|
+
model with :func:`scipy.optimize.curve_fit` and reports the parameters,
|
|
71
|
+
their standard errors, and whether the fit converged as a
|
|
72
|
+
:class:`DecayFit`, whose :meth:`DecayFit.evaluate` draws the fitted curve.
|
|
73
|
+
|
|
74
|
+
Bonds:
|
|
75
|
+
:func:`bond_vectors` differences the positions along the chain;
|
|
76
|
+
:func:`bond_cosine_correlation` averages the cosines between bond pairs at
|
|
77
|
+
every lag by the fast Fourier transform.
|
|
78
|
+
|
|
79
|
+
Conventions
|
|
80
|
+
===========
|
|
81
|
+
|
|
82
|
+
- The autocorrelation uses the ``1/n_frames`` estimator of the
|
|
83
|
+
autocovariance at every lag, which keeps the sequence positive
|
|
84
|
+
semi-definite and is the default of ``statsmodels.tsa.stattools.acf``;
|
|
85
|
+
dividing by ``n_frames - k`` instead is unbiased per lag but can give
|
|
86
|
+
:math:`|r_k| > 1`.
|
|
87
|
+
- Bartlett's variance for lag :math:`k` sums the squared autocorrelations at
|
|
88
|
+
the lags below it, :math:`\\mathrm{var}(r_k) = (1 + 2 \\sum_{j=1}^{k-1}
|
|
89
|
+
r_j^2) / n_\\mathrm{frames}` for :math:`k \\ge 2`, with
|
|
90
|
+
:math:`\\mathrm{var}(r_1) = 1 / n_\\mathrm{frames}` and
|
|
91
|
+
:math:`\\mathrm{var}(r_0) = 0`; this is the convention of
|
|
92
|
+
``statsmodels``, so a band computed here matches one computed there.
|
|
93
|
+
- Decay models are written in the decay time :math:`\\tau`, not the rate
|
|
94
|
+
:math:`\\omega = \\tau^{-\\beta}` of some research code; convert with
|
|
95
|
+
:math:`\\tau = \\omega^{-1/\\beta}`.
|
|
96
|
+
- A failed fit never raises: :attr:`DecayFit.converged` is false and the
|
|
97
|
+
parameters are NaN.
|
|
98
|
+
- On a ring every lag :math:`k` has ``n_bonds`` pairs, going round the ring,
|
|
99
|
+
so :math:`C(k) = C(n_\\mathrm{bonds} - k)` and the lags that carry
|
|
100
|
+
information are :math:`0 \\le k \\le n_\\mathrm{bonds} / 2`. On a linear
|
|
101
|
+
chain lag :math:`k` has ``n_bonds - k`` pairs.
|
|
102
|
+
- Complexities are stated per call in ``n_frames`` (:math:`n`),
|
|
103
|
+
``n_series`` (:math:`m`), ``n_bonds`` (:math:`N`), the number of leading
|
|
104
|
+
frames of a positions array (:math:`F`), and ``n_points`` of a fit.
|
|
105
|
+
|
|
106
|
+
References
|
|
107
|
+
==========
|
|
108
|
+
|
|
109
|
+
- M. S. Bartlett, "On the theoretical specification and sampling properties
|
|
110
|
+
of autocorrelated time-series", Supplement to the Journal of the Royal
|
|
111
|
+
Statistical Society 8, 27-41 (1946). https://doi.org/10.2307/2983611
|
|
112
|
+
- NIST/SEMATECH e-Handbook of Statistical Methods, section 1.3.3.1,
|
|
113
|
+
"Autocorrelation Plot".
|
|
114
|
+
https://www.itl.nist.gov/div898/handbook/eda/section3/autocopl.htm
|
|
115
|
+
- G. Williams and D. C. Watts, "Non-symmetrical dielectric relaxation
|
|
116
|
+
behavior arising from a simple empirical decay function", Trans. Faraday
|
|
117
|
+
Soc. 66, 80-85 (1970). https://doi.org/10.1039/TF9706600080
|
|
118
|
+
- SciPy, :func:`scipy.optimize.curve_fit`.
|
|
119
|
+
https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html
|
|
120
|
+
- M. Rubinstein and R. H. Colby, Polymer Physics (Oxford University Press,
|
|
121
|
+
2003), Chapter 2, on the bond correlation and the persistence length.
|
|
122
|
+
- MDAnalysis, :class:`MDAnalysis.analysis.polymer.PersistenceLength`, the
|
|
123
|
+
same bond autocorrelation and exponential fit for linear chains.
|
|
124
|
+
https://docs.mdanalysis.org/stable/documentation_pages/analysis/polymer.html
|
|
125
|
+
- MDAnalysis User Guide, "Determining the persistence length of a polymer".
|
|
126
|
+
https://userguide.mdanalysis.org/stable/examples/analysis/polymers_and_membranes/polymer.html
|
|
127
|
+
|
|
128
|
+
.. versionadded:: 0.6.0
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
import logging
|
|
132
|
+
from collections.abc import Callable, Mapping
|
|
133
|
+
from dataclasses import dataclass
|
|
134
|
+
from typing import Any, Literal
|
|
135
|
+
|
|
136
|
+
import numpy as np
|
|
137
|
+
import numpy.typing as npt
|
|
138
|
+
from scipy.optimize import curve_fit
|
|
139
|
+
from scipy.stats import norm
|
|
140
|
+
|
|
141
|
+
__all__ = [
|
|
142
|
+
'AcfResult',
|
|
143
|
+
'DECAY_MODELS',
|
|
144
|
+
'DecayFit',
|
|
145
|
+
'DecayModel',
|
|
146
|
+
'Topology',
|
|
147
|
+
'acf',
|
|
148
|
+
'bartlett_half_width',
|
|
149
|
+
'bond_cosine_correlation',
|
|
150
|
+
'bond_vectors',
|
|
151
|
+
'fit_decay',
|
|
152
|
+
'initial_guess',
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
logger = logging.getLogger(__name__)
|
|
156
|
+
|
|
157
|
+
Topology = Literal['linear', 'ring']
|
|
158
|
+
"""The polymer topologies the bond vectors are defined for."""
|
|
159
|
+
|
|
160
|
+
FloatArray = npt.NDArray[np.float64]
|
|
161
|
+
ModelFunction = Callable[..., FloatArray]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_topology(topology: str) -> None:
|
|
165
|
+
"""Raise ``ValueError`` unless `topology` is ``'linear'`` or ``'ring'``."""
|
|
166
|
+
if topology not in ('linear', 'ring'):
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"topology must be 'linear' or 'ring'; got '{topology}'."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fft_length(n: int) -> int:
|
|
173
|
+
"""Return the smallest power of two that is at least ``2 n - 1``."""
|
|
174
|
+
return 1 << (2 * n - 1).bit_length()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Autocorrelation
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class AcfResult:
|
|
184
|
+
"""
|
|
185
|
+
The sample autocorrelation of one or more time series.
|
|
186
|
+
|
|
187
|
+
.. versionadded:: 0.6.0
|
|
188
|
+
|
|
189
|
+
Attributes
|
|
190
|
+
----------
|
|
191
|
+
values : numpy.ndarray
|
|
192
|
+
:math:`r_k` for lags ``0`` to ``nlags``, shape ``(nlags + 1,)`` for a
|
|
193
|
+
one-dimensional series or ``(nlags + 1, n_series)`` for columns.
|
|
194
|
+
``values[0]`` is 1.
|
|
195
|
+
half_width : numpy.ndarray or None
|
|
196
|
+
The half-width of the confidence interval around each value at
|
|
197
|
+
level ``1 - alpha``, the same shape as `values`; ``half_width[0]``
|
|
198
|
+
is 0. ``None`` when no band was asked for.
|
|
199
|
+
alpha : float or None
|
|
200
|
+
The significance level of the band, or ``None``.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
values: FloatArray
|
|
204
|
+
half_width: FloatArray | None
|
|
205
|
+
alpha: float | None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def acf(
|
|
209
|
+
x: npt.ArrayLike,
|
|
210
|
+
nlags: int,
|
|
211
|
+
alpha: float | None = 0.05,
|
|
212
|
+
) -> AcfResult:
|
|
213
|
+
"""
|
|
214
|
+
Estimate the autocorrelation function of a time series by the FFT.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
x : array_like
|
|
219
|
+
The time series, shape ``(n_frames,)`` or ``(n_frames, n_series)``,
|
|
220
|
+
with the frames along axis 0 and one series per column; every value
|
|
221
|
+
finite, and no series constant.
|
|
222
|
+
nlags : int
|
|
223
|
+
The largest lag, in frames, from 0 to ``n_frames - 1``.
|
|
224
|
+
alpha : float or None, default 0.05
|
|
225
|
+
The significance level of the confidence band, in ``(0, 1)``; 0.05
|
|
226
|
+
gives a 95 % band. ``None`` skips the band.
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
AcfResult
|
|
231
|
+
The autocorrelation at lags 0 to `nlags` and its band.
|
|
232
|
+
|
|
233
|
+
Raises
|
|
234
|
+
------
|
|
235
|
+
ValueError
|
|
236
|
+
If `x` is not one- or two-dimensional or has fewer than two frames,
|
|
237
|
+
holds a non-finite value, or has a constant series; if `nlags` is
|
|
238
|
+
outside ``[0, n_frames - 1]``; or if `alpha` is outside ``(0, 1)``.
|
|
239
|
+
|
|
240
|
+
Notes
|
|
241
|
+
-----
|
|
242
|
+
With :math:`\\bar{x}` the mean of the series, the autocovariance is
|
|
243
|
+
:math:`c_k = \\frac{1}{n} \\sum_{t=0}^{n-k-1} (x_t - \\bar{x})
|
|
244
|
+
(x_{t+k} - \\bar{x})` and :math:`r_k = c_k / c_0` (NIST/SEMATECH
|
|
245
|
+
e-Handbook, section 1.3.3.1). The sum over :math:`t` for every
|
|
246
|
+
:math:`k` is one linear correlation, computed as the inverse transform
|
|
247
|
+
of the squared magnitude of the series' Fourier transform after
|
|
248
|
+
zero-padding to at least :math:`2n - 1` points so that no wrap-around
|
|
249
|
+
enters (Wiener-Khinchin).
|
|
250
|
+
|
|
251
|
+
The band is Bartlett's (1946) large-sample approximation under the
|
|
252
|
+
hypothesis that the autocorrelation vanishes beyond lag :math:`k - 1`:
|
|
253
|
+
:math:`\\mathrm{var}(r_k) \\approx (1 + 2 \\sum_{j=1}^{k-1} r_j^2) / n`,
|
|
254
|
+
so the band widens with the lag, and the half-width is
|
|
255
|
+
:math:`z_{1-\\alpha/2} \\sqrt{\\mathrm{var}(r_k)}` with :math:`z` the
|
|
256
|
+
standard normal quantile. For a white-noise series it reduces to
|
|
257
|
+
:math:`\\pm z_{1-\\alpha/2} / \\sqrt{n}`. The approximation needs
|
|
258
|
+
:math:`n \\gg` `nlags`.
|
|
259
|
+
|
|
260
|
+
Time is :math:`O(m\\, n \\log n)` and space :math:`O(m\\, n)`, with
|
|
261
|
+
:math:`n` the frames and :math:`m` the series.
|
|
262
|
+
|
|
263
|
+
Examples
|
|
264
|
+
--------
|
|
265
|
+
The alternating series ``1, -1, 1, -1, ...`` of 8 frames has mean 0,
|
|
266
|
+
:math:`c_k = (-1)^k (8 - k) / 8`, and so :math:`r_1 = -7/8` and
|
|
267
|
+
:math:`r_2 = 6/8`:
|
|
268
|
+
|
|
269
|
+
>>> import numpy as np
|
|
270
|
+
>>> from polyphys.analyze.correlations import acf
|
|
271
|
+
>>> x = np.tile([1.0, -1.0], 4)
|
|
272
|
+
>>> result = acf(x, nlags=2)
|
|
273
|
+
>>> np.round(result.values, 6)
|
|
274
|
+
array([ 1. , -0.875, 0.75 ])
|
|
275
|
+
|
|
276
|
+
The 95 % band is 0 at lag 0, :math:`1.96 / \\sqrt{8}` at lag 1, and
|
|
277
|
+
wider at lag 2 because :math:`r_1^2` enters:
|
|
278
|
+
|
|
279
|
+
>>> np.round(result.half_width, 4)
|
|
280
|
+
array([0. , 0.693 , 1.1025])
|
|
281
|
+
|
|
282
|
+
.. versionadded:: 0.6.0
|
|
283
|
+
"""
|
|
284
|
+
series = np.asarray(x, dtype=np.float64)
|
|
285
|
+
if series.ndim not in (1, 2):
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f'x must be one- or two-dimensional; got {series.ndim}'
|
|
288
|
+
' dimension(s).'
|
|
289
|
+
)
|
|
290
|
+
n_frames = series.shape[0]
|
|
291
|
+
if n_frames < 2:
|
|
292
|
+
raise ValueError(f'x must have at least two frames; got {n_frames}.')
|
|
293
|
+
if not np.all(np.isfinite(series)):
|
|
294
|
+
raise ValueError('x must be finite.')
|
|
295
|
+
if not 0 <= nlags < n_frames:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f'nlags must be between 0 and n_frames - 1 = {n_frames - 1};'
|
|
298
|
+
f' got {nlags}.'
|
|
299
|
+
)
|
|
300
|
+
if alpha is not None and not 0 < alpha < 1:
|
|
301
|
+
raise ValueError(f'alpha must be in (0, 1); got {alpha}.')
|
|
302
|
+
if np.any(np.all(series == series[0], axis=0)):
|
|
303
|
+
raise ValueError('x has a constant series; its acf is undefined.')
|
|
304
|
+
|
|
305
|
+
centered = series - series.mean(axis=0)
|
|
306
|
+
n_fft = _fft_length(n_frames)
|
|
307
|
+
spectrum = np.fft.rfft(centered, n=n_fft, axis=0)
|
|
308
|
+
autocov = np.fft.irfft(np.abs(spectrum) ** 2, n=n_fft, axis=0)
|
|
309
|
+
autocov = autocov[: nlags + 1] / n_frames
|
|
310
|
+
values: FloatArray = autocov / autocov[0]
|
|
311
|
+
values[0] = 1.0
|
|
312
|
+
|
|
313
|
+
half_width: FloatArray | None = None
|
|
314
|
+
if alpha is not None:
|
|
315
|
+
half_width = bartlett_half_width(values, n_frames, alpha)
|
|
316
|
+
return AcfResult(values=values, half_width=half_width, alpha=alpha)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def bartlett_half_width(
|
|
320
|
+
values: npt.ArrayLike,
|
|
321
|
+
n_frames: int,
|
|
322
|
+
alpha: float = 0.05,
|
|
323
|
+
) -> FloatArray:
|
|
324
|
+
"""
|
|
325
|
+
The half-width of Bartlett's confidence band around an autocorrelation.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
values : array_like
|
|
330
|
+
The sample autocorrelation :math:`r_k` for lags 0 to ``nlags``,
|
|
331
|
+
shape ``(nlags + 1,)`` or ``(nlags + 1, n_series)``, as
|
|
332
|
+
:func:`acf` returns it.
|
|
333
|
+
n_frames : int
|
|
334
|
+
The number of frames the autocorrelation was estimated from, at
|
|
335
|
+
least 2.
|
|
336
|
+
alpha : float, default 0.05
|
|
337
|
+
The significance level of the band, in ``(0, 1)``; 0.05 gives a 95 %
|
|
338
|
+
band.
|
|
339
|
+
|
|
340
|
+
Returns
|
|
341
|
+
-------
|
|
342
|
+
numpy.ndarray
|
|
343
|
+
The half-width at each lag, the same shape as `values`, ``float64``;
|
|
344
|
+
0 at lag 0. The band is ``values - half_width`` to
|
|
345
|
+
``values + half_width``.
|
|
346
|
+
|
|
347
|
+
Raises
|
|
348
|
+
------
|
|
349
|
+
ValueError
|
|
350
|
+
If `values` is not one- or two-dimensional, `n_frames` is below 2,
|
|
351
|
+
or `alpha` is outside ``(0, 1)``.
|
|
352
|
+
|
|
353
|
+
Notes
|
|
354
|
+
-----
|
|
355
|
+
Bartlett's (1946) large-sample variance under the hypothesis that the
|
|
356
|
+
autocorrelation vanishes beyond lag :math:`k - 1` is
|
|
357
|
+
:math:`\\mathrm{var}(r_k) \\approx (1 + 2 \\sum_{j=1}^{k-1} r_j^2) / n`,
|
|
358
|
+
and the half-width is :math:`z_{1-\\alpha/2} \\sqrt{\\mathrm{var}(r_k)}`.
|
|
359
|
+
The band depends on the stored :math:`r_k`, `n_frames`, and `alpha`
|
|
360
|
+
only, so a pipeline need store the autocorrelation alone and draw the
|
|
361
|
+
band at any level later. Time and space are :math:`O(m\\, k)`, with
|
|
362
|
+
:math:`k` the lags and :math:`m` the series.
|
|
363
|
+
|
|
364
|
+
Examples
|
|
365
|
+
--------
|
|
366
|
+
The band of the alternating series of :func:`acf`'s example, rebuilt
|
|
367
|
+
from its autocorrelation:
|
|
368
|
+
|
|
369
|
+
>>> import numpy as np
|
|
370
|
+
>>> from polyphys.analyze.correlations import bartlett_half_width
|
|
371
|
+
>>> np.round(bartlett_half_width([1.0, -0.875, 0.75], 8), 4)
|
|
372
|
+
array([0. , 0.693 , 1.1025])
|
|
373
|
+
|
|
374
|
+
.. versionadded:: 0.6.0
|
|
375
|
+
"""
|
|
376
|
+
acf_values = np.asarray(values, dtype=np.float64)
|
|
377
|
+
if acf_values.ndim not in (1, 2):
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f'values must be one- or two-dimensional; got {acf_values.ndim}'
|
|
380
|
+
' dimension(s).'
|
|
381
|
+
)
|
|
382
|
+
if n_frames < 2:
|
|
383
|
+
raise ValueError(f'n_frames must be at least 2; got {n_frames}.')
|
|
384
|
+
if not 0 < alpha < 1:
|
|
385
|
+
raise ValueError(f'alpha must be in (0, 1); got {alpha}.')
|
|
386
|
+
variance = np.full_like(acf_values, 1.0 / n_frames)
|
|
387
|
+
variance[0] = 0.0
|
|
388
|
+
if acf_values.shape[0] > 2:
|
|
389
|
+
variance[2:] *= 1.0 + 2.0 * np.cumsum(acf_values[1:-1] ** 2, axis=0)
|
|
390
|
+
z_score = float(norm.ppf(1.0 - alpha / 2.0))
|
|
391
|
+
half_width: FloatArray = z_score * np.sqrt(variance)
|
|
392
|
+
return half_width
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Decay fits
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _exp(x: FloatArray, tau: float) -> FloatArray:
|
|
401
|
+
""":math:`\\exp(-x / \\tau)`."""
|
|
402
|
+
return np.asarray(np.exp(-x / tau), dtype=np.float64)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _stretched(x: FloatArray, tau: float, beta: float) -> FloatArray:
|
|
406
|
+
""":math:`\\exp[-(x / \\tau)^\\beta]`."""
|
|
407
|
+
return np.asarray(np.exp(-((x / tau) ** beta)), dtype=np.float64)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _stretched_amp(
|
|
411
|
+
x: FloatArray, tau: float, beta: float, amp: float
|
|
412
|
+
) -> FloatArray:
|
|
413
|
+
""":math:`A \\exp[-(x / \\tau)^\\beta]`."""
|
|
414
|
+
return amp * _stretched(x, tau, beta)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _stretched_amp_offset(
|
|
418
|
+
x: FloatArray, tau: float, beta: float, amp: float, offset: float
|
|
419
|
+
) -> FloatArray:
|
|
420
|
+
""":math:`A \\exp[-(x / \\tau)^\\beta] + B`."""
|
|
421
|
+
return amp * _stretched(x, tau, beta) + offset
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class DecayModel:
|
|
426
|
+
"""
|
|
427
|
+
A decay model: its function, parameter names, and formula.
|
|
428
|
+
|
|
429
|
+
.. versionadded:: 0.6.0
|
|
430
|
+
|
|
431
|
+
Attributes
|
|
432
|
+
----------
|
|
433
|
+
function : callable
|
|
434
|
+
``function(x, *params)``, vectorized over `x`.
|
|
435
|
+
names : tuple of str
|
|
436
|
+
The parameter names in the order `function` takes them, from
|
|
437
|
+
``'tau'``, ``'beta'``, ``'amp'``, and ``'offset'``.
|
|
438
|
+
formula : str
|
|
439
|
+
The model as a formula, for labels.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
function: ModelFunction
|
|
443
|
+
names: tuple[str, ...]
|
|
444
|
+
formula: str
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
DECAY_MODELS: Mapping[str, DecayModel] = {
|
|
448
|
+
'exp': DecayModel(_exp, ('tau',), 'exp(-x/tau)'),
|
|
449
|
+
'stretched': DecayModel(
|
|
450
|
+
_stretched, ('tau', 'beta'), 'exp(-(x/tau)^beta)'
|
|
451
|
+
),
|
|
452
|
+
'stretched_amp': DecayModel(
|
|
453
|
+
_stretched_amp, ('tau', 'beta', 'amp'), 'amp exp(-(x/tau)^beta)'
|
|
454
|
+
),
|
|
455
|
+
'stretched_amp_offset': DecayModel(
|
|
456
|
+
_stretched_amp_offset,
|
|
457
|
+
('tau', 'beta', 'amp', 'offset'),
|
|
458
|
+
'amp exp(-(x/tau)^beta) + offset',
|
|
459
|
+
),
|
|
460
|
+
}
|
|
461
|
+
"""
|
|
462
|
+
The decay models :func:`fit_decay` accepts, by name.
|
|
463
|
+
|
|
464
|
+
``'exp'`` is the pure exponential :math:`\\exp(-x/\\tau)`; ``'stretched'``
|
|
465
|
+
the Kohlrausch-Williams-Watts form :math:`\\exp[-(x/\\tau)^\\beta]`;
|
|
466
|
+
``'stretched_amp'`` scales it by an amplitude :math:`A`; and
|
|
467
|
+
``'stretched_amp_offset'`` adds an offset :math:`B`. The parameters are
|
|
468
|
+
ordered ``tau``, ``beta``, ``amp``, ``offset``. :math:`\\tau` is in the
|
|
469
|
+
unit of ``x`` and positive; :math:`\\beta` is positive, 1 for a pure
|
|
470
|
+
exponential.
|
|
471
|
+
|
|
472
|
+
.. versionadded:: 0.6.0
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _get_model(model: str) -> DecayModel:
|
|
477
|
+
"""Return the :class:`DecayModel` named `model` or raise ``ValueError``."""
|
|
478
|
+
try:
|
|
479
|
+
return DECAY_MODELS[model]
|
|
480
|
+
except KeyError:
|
|
481
|
+
names = ', '.join(repr(name) for name in DECAY_MODELS)
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"model must be one of {names}; got '{model}'."
|
|
484
|
+
) from None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _as_curve(
|
|
488
|
+
x: npt.ArrayLike, y: npt.ArrayLike
|
|
489
|
+
) -> tuple[FloatArray, FloatArray]:
|
|
490
|
+
"""Return `x` and `y` as finite one-dimensional arrays of one length."""
|
|
491
|
+
x_arr = np.asarray(x, dtype=np.float64)
|
|
492
|
+
y_arr = np.asarray(y, dtype=np.float64)
|
|
493
|
+
if x_arr.ndim != 1 or y_arr.ndim != 1:
|
|
494
|
+
raise ValueError(
|
|
495
|
+
'x and y must be one-dimensional; got'
|
|
496
|
+
f' {x_arr.ndim} and {y_arr.ndim} dimension(s).'
|
|
497
|
+
)
|
|
498
|
+
if x_arr.shape != y_arr.shape:
|
|
499
|
+
raise ValueError(
|
|
500
|
+
f'x and y must have the same length; got {x_arr.size} and'
|
|
501
|
+
f' {y_arr.size}.'
|
|
502
|
+
)
|
|
503
|
+
if not (np.all(np.isfinite(x_arr)) and np.all(np.isfinite(y_arr))):
|
|
504
|
+
raise ValueError('x and y must be finite.')
|
|
505
|
+
return x_arr, y_arr
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@dataclass(frozen=True)
|
|
509
|
+
class DecayFit:
|
|
510
|
+
"""
|
|
511
|
+
The result of fitting a decay model to a curve.
|
|
512
|
+
|
|
513
|
+
.. versionadded:: 0.6.0
|
|
514
|
+
|
|
515
|
+
Attributes
|
|
516
|
+
----------
|
|
517
|
+
model : str
|
|
518
|
+
The key of the model in :data:`DECAY_MODELS`.
|
|
519
|
+
names : tuple of str
|
|
520
|
+
The parameter names, in the order of `params`.
|
|
521
|
+
params : numpy.ndarray
|
|
522
|
+
The fitted parameters, shape ``(n_params,)``; all NaN when the fit
|
|
523
|
+
did not converge.
|
|
524
|
+
stderr : numpy.ndarray
|
|
525
|
+
The standard error of each parameter, the square root of the
|
|
526
|
+
diagonal of `pcov`; NaN where the covariance could not be estimated
|
|
527
|
+
or the fit did not converge.
|
|
528
|
+
pcov : numpy.ndarray
|
|
529
|
+
The estimated covariance of the parameters, shape
|
|
530
|
+
``(n_params, n_params)``, as :func:`scipy.optimize.curve_fit`
|
|
531
|
+
returns it; all NaN when the fit did not converge.
|
|
532
|
+
p0 : numpy.ndarray
|
|
533
|
+
The starting point the fit used, shape ``(n_params,)``.
|
|
534
|
+
converged : bool
|
|
535
|
+
Whether :func:`scipy.optimize.curve_fit` returned a solution.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
model: str
|
|
539
|
+
names: tuple[str, ...]
|
|
540
|
+
params: FloatArray
|
|
541
|
+
stderr: FloatArray
|
|
542
|
+
pcov: FloatArray
|
|
543
|
+
p0: FloatArray
|
|
544
|
+
converged: bool
|
|
545
|
+
|
|
546
|
+
def evaluate(self, x: npt.ArrayLike) -> FloatArray:
|
|
547
|
+
"""
|
|
548
|
+
Evaluate the fitted model at `x`.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
x : array_like
|
|
553
|
+
Points in the unit of the fitted ``x``.
|
|
554
|
+
|
|
555
|
+
Returns
|
|
556
|
+
-------
|
|
557
|
+
numpy.ndarray
|
|
558
|
+
The model at `x`, the same shape; all NaN when the fit did not
|
|
559
|
+
converge.
|
|
560
|
+
|
|
561
|
+
Examples
|
|
562
|
+
--------
|
|
563
|
+
>>> import numpy as np
|
|
564
|
+
>>> from polyphys.analyze.correlations import fit_decay
|
|
565
|
+
>>> x = np.arange(0.0, 20.0)
|
|
566
|
+
>>> fit = fit_decay(x, np.exp(-x / 4.0), 'exp')
|
|
567
|
+
>>> np.round(fit.evaluate([0.0, 4.0]), 6)
|
|
568
|
+
array([1. , 0.367879])
|
|
569
|
+
"""
|
|
570
|
+
return DECAY_MODELS[self.model].function(
|
|
571
|
+
np.asarray(x, dtype=np.float64), *self.params
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def initial_guess(
|
|
576
|
+
x: npt.ArrayLike, y: npt.ArrayLike, model: str
|
|
577
|
+
) -> FloatArray:
|
|
578
|
+
"""
|
|
579
|
+
Read a starting point for each parameter of a decay model off the data.
|
|
580
|
+
|
|
581
|
+
Parameters
|
|
582
|
+
----------
|
|
583
|
+
x : array_like
|
|
584
|
+
The abscissa, shape ``(n_points,)``, finite, increasing, and
|
|
585
|
+
non-negative, in the caller's unit (lags in frames, or time).
|
|
586
|
+
y : array_like
|
|
587
|
+
The curve at `x`, the same shape, finite.
|
|
588
|
+
model : str
|
|
589
|
+
A key of :data:`DECAY_MODELS`.
|
|
590
|
+
|
|
591
|
+
Returns
|
|
592
|
+
-------
|
|
593
|
+
numpy.ndarray
|
|
594
|
+
One value per parameter of the model, in its order.
|
|
595
|
+
|
|
596
|
+
Raises
|
|
597
|
+
------
|
|
598
|
+
ValueError
|
|
599
|
+
If `model` is unknown, or `x` and `y` are not finite
|
|
600
|
+
one-dimensional arrays of one length.
|
|
601
|
+
|
|
602
|
+
Notes
|
|
603
|
+
-----
|
|
604
|
+
The offset :math:`B` is the mean of the last tenth of `y` (at least one
|
|
605
|
+
point); the amplitude :math:`A` is ``y[0] - B``, or 1 when that is 0;
|
|
606
|
+
the decay time :math:`\\tau` is the first `x` at which
|
|
607
|
+
:math:`(y - B) / A` is at or below :math:`1/e`, which is exact for a
|
|
608
|
+
pure exponential sampled at :math:`\\tau`, or the last `x` when the
|
|
609
|
+
curve never falls that far; and :math:`\\beta` is 1. A model without an
|
|
610
|
+
offset takes :math:`B = 0`; one without an amplitude takes
|
|
611
|
+
:math:`A = 1`. A :math:`\\tau` of 0 (the curve is already at
|
|
612
|
+
:math:`1/e` at ``x[0] = 0``) is replaced by the first positive `x`, or
|
|
613
|
+
1.
|
|
614
|
+
|
|
615
|
+
Time and space are :math:`O(n_\\mathrm{points})`.
|
|
616
|
+
|
|
617
|
+
Examples
|
|
618
|
+
--------
|
|
619
|
+
A pure exponential of decay time 4 crosses :math:`1/e` at ``x = 4``:
|
|
620
|
+
|
|
621
|
+
>>> import numpy as np
|
|
622
|
+
>>> from polyphys.analyze.correlations import initial_guess
|
|
623
|
+
>>> x = np.arange(0.0, 20.0)
|
|
624
|
+
>>> initial_guess(x, np.exp(-x / 4.0), 'exp')
|
|
625
|
+
array([4.])
|
|
626
|
+
|
|
627
|
+
The offset is read off the tail, so it is a starting point, not the
|
|
628
|
+
answer:
|
|
629
|
+
|
|
630
|
+
>>> y = 2.0 * np.exp(-x / 4.0) + 0.5
|
|
631
|
+
>>> np.round(initial_guess(x, y, 'stretched_amp_offset'), 2)
|
|
632
|
+
array([4. , 1. , 1.98, 0.52])
|
|
633
|
+
|
|
634
|
+
.. versionadded:: 0.6.0
|
|
635
|
+
"""
|
|
636
|
+
spec = _get_model(model)
|
|
637
|
+
x_arr, y_arr = _as_curve(x, y)
|
|
638
|
+
n_points = x_arr.size
|
|
639
|
+
if n_points == 0:
|
|
640
|
+
raise ValueError('x and y must not be empty.')
|
|
641
|
+
offset = 0.0
|
|
642
|
+
if 'offset' in spec.names:
|
|
643
|
+
offset = float(np.mean(y_arr[-max(1, n_points // 10):]))
|
|
644
|
+
amp = 1.0
|
|
645
|
+
if 'amp' in spec.names:
|
|
646
|
+
amp = float(y_arr[0] - offset)
|
|
647
|
+
if amp == 0.0:
|
|
648
|
+
amp = 1.0
|
|
649
|
+
decayed = (y_arr - offset) / amp
|
|
650
|
+
below = np.flatnonzero(decayed <= np.exp(-1.0))
|
|
651
|
+
tau = float(x_arr[below[0]]) if below.size else float(x_arr[-1])
|
|
652
|
+
if tau <= 0.0:
|
|
653
|
+
positive = np.flatnonzero(x_arr > 0.0)
|
|
654
|
+
tau = float(x_arr[positive[0]]) if positive.size else 1.0
|
|
655
|
+
guesses = {'tau': tau, 'beta': 1.0, 'amp': amp, 'offset': offset}
|
|
656
|
+
return np.array([guesses[name] for name in spec.names], dtype=np.float64)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def fit_decay(
|
|
660
|
+
x: npt.ArrayLike,
|
|
661
|
+
y: npt.ArrayLike,
|
|
662
|
+
model: str,
|
|
663
|
+
p0: npt.ArrayLike | None = None,
|
|
664
|
+
**kwargs: Any,
|
|
665
|
+
) -> DecayFit:
|
|
666
|
+
"""
|
|
667
|
+
Fit a decay model to a curve by nonlinear least squares.
|
|
668
|
+
|
|
669
|
+
Parameters
|
|
670
|
+
----------
|
|
671
|
+
x : array_like
|
|
672
|
+
The abscissa, shape ``(n_points,)``, finite, in the caller's unit
|
|
673
|
+
(lags in frames, or time). Fit only the range meant for the model:
|
|
674
|
+
on an autocorrelation, the lags the model describes; on a ring's
|
|
675
|
+
bond correlation, lags up to ``n_bonds // 2``.
|
|
676
|
+
y : array_like
|
|
677
|
+
The curve at `x`, the same shape, finite.
|
|
678
|
+
model : str
|
|
679
|
+
A key of :data:`DECAY_MODELS`.
|
|
680
|
+
p0 : array_like or None, default None
|
|
681
|
+
The starting point, one value per parameter of the model, in its
|
|
682
|
+
order. ``None`` takes :func:`initial_guess`.
|
|
683
|
+
**kwargs
|
|
684
|
+
Passed to :func:`scipy.optimize.curve_fit`: ``bounds``, ``sigma``,
|
|
685
|
+
``absolute_sigma``, ``maxfev``, ``method``, and the rest.
|
|
686
|
+
|
|
687
|
+
Returns
|
|
688
|
+
-------
|
|
689
|
+
DecayFit
|
|
690
|
+
The parameters, their standard errors, the covariance, the starting
|
|
691
|
+
point used, and whether the fit converged.
|
|
692
|
+
|
|
693
|
+
Raises
|
|
694
|
+
------
|
|
695
|
+
ValueError
|
|
696
|
+
If `model` is unknown; `x` and `y` are not finite one-dimensional
|
|
697
|
+
arrays of one length; there are fewer points than parameters; or
|
|
698
|
+
`p0` has the wrong length. :func:`scipy.optimize.curve_fit` also
|
|
699
|
+
raises :exc:`ValueError` for an invalid option in `kwargs`, such as
|
|
700
|
+
an unknown ``method`` or a `p0` outside ``bounds``.
|
|
701
|
+
|
|
702
|
+
Notes
|
|
703
|
+
-----
|
|
704
|
+
:func:`scipy.optimize.curve_fit` minimizes the sum of squared residuals
|
|
705
|
+
from `p0`, by default with the Levenberg-Marquardt method (``'lm'``),
|
|
706
|
+
or with ``'trf'`` when ``bounds`` are given, as its documentation
|
|
707
|
+
states; ``method`` in `kwargs` chooses another. A nonlinear model can
|
|
708
|
+
have several local minima, so `p0` matters, and the one used is
|
|
709
|
+
recorded.
|
|
710
|
+
A fit fails when the model is not finite at the start, when the
|
|
711
|
+
optimizer stops without a solution, or when it does not move from the
|
|
712
|
+
start and the covariance cannot be estimated, which is how the
|
|
713
|
+
``'lm'`` method signals a Jacobian that is zero or rank-deficient (the
|
|
714
|
+
:func:`scipy.optimize.curve_fit` documentation, under ``pcov``). A
|
|
715
|
+
failed fit is reported with ``converged=False``, NaN parameters, and a
|
|
716
|
+
warning in the :mod:`logging` log, not an exception; invalid inputs or
|
|
717
|
+
options still raise (see Raises). When the fit
|
|
718
|
+
moves but the covariance cannot be estimated (SciPy emits an
|
|
719
|
+
``OptimizeWarning``), it is converged and the standard errors of the
|
|
720
|
+
affected parameters are NaN. A start at the exact solution of
|
|
721
|
+
noise-free data does not move either, but its covariance is finite, so
|
|
722
|
+
it is converged. The standard
|
|
723
|
+
error is the square root of the covariance diagonal, as in SciPy's
|
|
724
|
+
documentation.
|
|
725
|
+
|
|
726
|
+
The models are the stretched exponential of Williams and Watts (1970),
|
|
727
|
+
Trans. Faraday Soc. 66, 80, https://doi.org/10.1039/TF9706600080, with
|
|
728
|
+
:math:`\\beta = 1` for the pure exponential.
|
|
729
|
+
|
|
730
|
+
Time is :math:`O(n_\\mathrm{iter}\\, n_\\mathrm{points}\\,
|
|
731
|
+
n_\\mathrm{params})` and space :math:`O(n_\\mathrm{points}\\,
|
|
732
|
+
n_\\mathrm{params})`.
|
|
733
|
+
|
|
734
|
+
Examples
|
|
735
|
+
--------
|
|
736
|
+
>>> import numpy as np
|
|
737
|
+
>>> from polyphys.analyze.correlations import fit_decay
|
|
738
|
+
>>> x = np.arange(0.0, 40.0)
|
|
739
|
+
>>> y = 2.0 * np.exp(-((x / 5.0) ** 0.8)) + 0.3
|
|
740
|
+
>>> fit = fit_decay(x, y, 'stretched_amp_offset')
|
|
741
|
+
>>> fit.converged
|
|
742
|
+
True
|
|
743
|
+
>>> fit.names
|
|
744
|
+
('tau', 'beta', 'amp', 'offset')
|
|
745
|
+
>>> np.round(fit.params, 6)
|
|
746
|
+
array([5. , 0.8, 2. , 0.3])
|
|
747
|
+
|
|
748
|
+
.. versionadded:: 0.6.0
|
|
749
|
+
.. versionchanged:: 0.9.0
|
|
750
|
+
A start where the model is not finite, and a fit that does not move
|
|
751
|
+
and has no finite covariance, are failed fits; ``method='trf'`` no
|
|
752
|
+
longer raises on a non-finite start.
|
|
753
|
+
"""
|
|
754
|
+
spec = _get_model(model)
|
|
755
|
+
x_arr, y_arr = _as_curve(x, y)
|
|
756
|
+
n_params = len(spec.names)
|
|
757
|
+
if x_arr.size < n_params:
|
|
758
|
+
raise ValueError(
|
|
759
|
+
f"model '{model}' has {n_params} parameter(s) but x has only"
|
|
760
|
+
f' {x_arr.size} point(s).'
|
|
761
|
+
)
|
|
762
|
+
if p0 is None:
|
|
763
|
+
start = initial_guess(x_arr, y_arr, model)
|
|
764
|
+
else:
|
|
765
|
+
start = np.asarray(p0, dtype=np.float64)
|
|
766
|
+
if start.shape != (n_params,):
|
|
767
|
+
raise ValueError(
|
|
768
|
+
f"p0 must have one value per parameter of '{model}',"
|
|
769
|
+
f' {spec.names}; got shape {start.shape}.'
|
|
770
|
+
)
|
|
771
|
+
nan_params = np.full(n_params, np.nan)
|
|
772
|
+
failed = DecayFit(
|
|
773
|
+
model=model,
|
|
774
|
+
names=spec.names,
|
|
775
|
+
params=nan_params,
|
|
776
|
+
stderr=nan_params.copy(),
|
|
777
|
+
pcov=np.full((n_params, n_params), np.nan),
|
|
778
|
+
p0=start,
|
|
779
|
+
converged=False,
|
|
780
|
+
)
|
|
781
|
+
# A start where the model is not finite cannot be fitted: 'lm' stays
|
|
782
|
+
# there and 'trf' raises, so it is reported as a failed fit up front.
|
|
783
|
+
with np.errstate(invalid='ignore', divide='ignore', over='ignore'):
|
|
784
|
+
at_start = spec.function(x_arr, *start)
|
|
785
|
+
if not np.all(np.isfinite(at_start)):
|
|
786
|
+
logger.warning(
|
|
787
|
+
"fit_decay: '%s' is not finite at the start %s; the fit failed.",
|
|
788
|
+
model,
|
|
789
|
+
start,
|
|
790
|
+
)
|
|
791
|
+
return failed
|
|
792
|
+
try:
|
|
793
|
+
params, pcov = curve_fit(
|
|
794
|
+
spec.function, x_arr, y_arr, p0=start, **kwargs
|
|
795
|
+
)
|
|
796
|
+
except RuntimeError as error:
|
|
797
|
+
logger.warning("fit_decay: '%s' did not converge: %s", model, error)
|
|
798
|
+
return failed
|
|
799
|
+
params = np.asarray(params, dtype=np.float64)
|
|
800
|
+
pcov = np.asarray(pcov, dtype=np.float64)
|
|
801
|
+
diagonal = np.diag(pcov)
|
|
802
|
+
if np.array_equal(params, start) and not np.all(np.isfinite(pcov)):
|
|
803
|
+
logger.warning(
|
|
804
|
+
"fit_decay: '%s' did not move from the start %s and its"
|
|
805
|
+
' covariance could not be estimated; the fit failed.',
|
|
806
|
+
model,
|
|
807
|
+
start,
|
|
808
|
+
)
|
|
809
|
+
return failed
|
|
810
|
+
stderr = np.where(np.isfinite(diagonal), np.sqrt(np.abs(diagonal)), np.nan)
|
|
811
|
+
if not np.all(np.isfinite(diagonal)):
|
|
812
|
+
logger.warning(
|
|
813
|
+
"fit_decay: '%s' converged but its covariance could not be"
|
|
814
|
+
' estimated.',
|
|
815
|
+
model,
|
|
816
|
+
)
|
|
817
|
+
return DecayFit(
|
|
818
|
+
model=model,
|
|
819
|
+
names=spec.names,
|
|
820
|
+
params=params,
|
|
821
|
+
stderr=np.asarray(stderr, dtype=np.float64),
|
|
822
|
+
pcov=pcov,
|
|
823
|
+
p0=start,
|
|
824
|
+
converged=True,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
# Bonds
|
|
830
|
+
# ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def bond_vectors(
|
|
834
|
+
positions: npt.ArrayLike, topology: Topology
|
|
835
|
+
) -> FloatArray:
|
|
836
|
+
"""
|
|
837
|
+
Difference the positions of a chain into its bond vectors.
|
|
838
|
+
|
|
839
|
+
Parameters
|
|
840
|
+
----------
|
|
841
|
+
positions : array_like
|
|
842
|
+
The monomer coordinates in chain order, shape
|
|
843
|
+
``(..., n_atoms, n_dims)``, unwrapped, in the caller's length unit;
|
|
844
|
+
``n_atoms`` at least 2 on a linear chain and 3 on a ring.
|
|
845
|
+
topology : {'linear', 'ring'}
|
|
846
|
+
Whether the last monomer is bonded back to the first.
|
|
847
|
+
|
|
848
|
+
Returns
|
|
849
|
+
-------
|
|
850
|
+
numpy.ndarray
|
|
851
|
+
The bond vectors :math:`\\mathbf{r}_{i+1} - \\mathbf{r}_i`, shape
|
|
852
|
+
``(..., n_bonds, n_dims)``, with ``n_bonds = n_atoms - 1`` on a
|
|
853
|
+
linear chain and ``n_atoms`` on a ring, whose last bond is
|
|
854
|
+
:math:`\\mathbf{r}_0 - \\mathbf{r}_{n-1}`. Bond lengths are
|
|
855
|
+
``numpy.linalg.norm(bonds, axis=-1)``.
|
|
856
|
+
|
|
857
|
+
Raises
|
|
858
|
+
------
|
|
859
|
+
ValueError
|
|
860
|
+
If `topology` is unknown, `positions` has fewer than two dimensions,
|
|
861
|
+
or the chain is too short for its topology.
|
|
862
|
+
|
|
863
|
+
Notes
|
|
864
|
+
-----
|
|
865
|
+
Positions must be unwrapped: a periodic image jump inside a bond gives
|
|
866
|
+
a bond of the box length. Time and space are
|
|
867
|
+
:math:`O(F\\, n_\\mathrm{atoms}\\, n_\\mathrm{dims})`.
|
|
868
|
+
|
|
869
|
+
Examples
|
|
870
|
+
--------
|
|
871
|
+
>>> import numpy as np
|
|
872
|
+
>>> from polyphys.analyze.correlations import bond_vectors
|
|
873
|
+
>>> square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
|
|
874
|
+
>>> bond_vectors(square, 'linear')
|
|
875
|
+
array([[ 1., 0.],
|
|
876
|
+
[ 0., 1.],
|
|
877
|
+
[-1., 0.]])
|
|
878
|
+
>>> bond_vectors(square, 'ring')[-1]
|
|
879
|
+
array([ 0., -1.])
|
|
880
|
+
|
|
881
|
+
.. versionadded:: 0.6.0
|
|
882
|
+
"""
|
|
883
|
+
_check_topology(topology)
|
|
884
|
+
coords = np.asarray(positions, dtype=np.float64)
|
|
885
|
+
if coords.ndim < 2:
|
|
886
|
+
raise ValueError(
|
|
887
|
+
'positions must have shape (..., n_atoms, n_dims); got'
|
|
888
|
+
f' {coords.ndim} dimension(s).'
|
|
889
|
+
)
|
|
890
|
+
n_atoms = coords.shape[-2]
|
|
891
|
+
minimum = 2 if topology == 'linear' else 3
|
|
892
|
+
if n_atoms < minimum:
|
|
893
|
+
raise ValueError(
|
|
894
|
+
f"a '{topology}' chain needs at least {minimum} atoms; got"
|
|
895
|
+
f' {n_atoms}.'
|
|
896
|
+
)
|
|
897
|
+
if topology == 'linear':
|
|
898
|
+
return np.asarray(coords[..., 1:, :] - coords[..., :-1, :])
|
|
899
|
+
return np.asarray(np.roll(coords, -1, axis=-2) - coords)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def bond_cosine_correlation(
|
|
903
|
+
positions: npt.ArrayLike, topology: Topology
|
|
904
|
+
) -> FloatArray:
|
|
905
|
+
"""
|
|
906
|
+
Average the cosine between bond vectors at every lag along a chain.
|
|
907
|
+
|
|
908
|
+
Parameters
|
|
909
|
+
----------
|
|
910
|
+
positions : array_like
|
|
911
|
+
The monomer coordinates in chain order, shape
|
|
912
|
+
``(..., n_atoms, n_dims)``, unwrapped; no two bonded monomers
|
|
913
|
+
coincide.
|
|
914
|
+
topology : {'linear', 'ring'}
|
|
915
|
+
Whether the last monomer is bonded back to the first.
|
|
916
|
+
|
|
917
|
+
Returns
|
|
918
|
+
-------
|
|
919
|
+
numpy.ndarray
|
|
920
|
+
:math:`C(k)` for lags ``0`` to ``n_bonds - 1``, shape
|
|
921
|
+
``(..., n_bonds)``, dimensionless in ``[-1, 1]`` with ``C(0) = 1``.
|
|
922
|
+
Leading axes are kept, so the average over frames is
|
|
923
|
+
``result.mean(axis=0)`` for a ``(n_frames, n_atoms, n_dims)``
|
|
924
|
+
input.
|
|
925
|
+
|
|
926
|
+
Raises
|
|
927
|
+
------
|
|
928
|
+
ValueError
|
|
929
|
+
If `topology` is unknown, `positions` has a bad shape or a chain too
|
|
930
|
+
short for its topology, or a bond has zero length.
|
|
931
|
+
|
|
932
|
+
Notes
|
|
933
|
+
-----
|
|
934
|
+
With :math:`\\hat{\\mathbf{u}}_i` the unit bond vectors, on a linear
|
|
935
|
+
chain :math:`C(k) = \\frac{1}{N - k} \\sum_{i=0}^{N-k-1}
|
|
936
|
+
\\hat{\\mathbf{u}}_i \\cdot \\hat{\\mathbf{u}}_{i+k}` over the
|
|
937
|
+
:math:`N - k` pairs at lag :math:`k`; on a ring :math:`C(k) =
|
|
938
|
+
\\frac{1}{N} \\sum_{i=0}^{N-1} \\hat{\\mathbf{u}}_i \\cdot
|
|
939
|
+
\\hat{\\mathbf{u}}_{(i+k) \\bmod N}` over all :math:`N` pairs, so that
|
|
940
|
+
the result does not depend on which monomer is labeled first and
|
|
941
|
+
:math:`C(k) = C(N - k)`. The sums over :math:`i` are linear (zero-padded)
|
|
942
|
+
or circular autocorrelations, computed by the FFT of each Cartesian
|
|
943
|
+
component and summed over components.
|
|
944
|
+
|
|
945
|
+
For a worm-like chain :math:`C(k) = \\exp(-k\\, b / \\ell_p)` with
|
|
946
|
+
:math:`b` the bond length and :math:`\\ell_p` the persistence length
|
|
947
|
+
(Rubinstein and Colby 2003, section 2.3), so fitting ``'exp'`` with
|
|
948
|
+
:func:`fit_decay` on lags up to ``n_bonds // 2`` for a ring gives
|
|
949
|
+
:math:`\\tau = \\ell_p / b`, and :math:`\\ell_p` is :math:`\\tau` times
|
|
950
|
+
the mean bond length. On a linear chain this is the quantity and the
|
|
951
|
+
fit of :class:`MDAnalysis.analysis.polymer.PersistenceLength`
|
|
952
|
+
(``results.bond_autocorrelation``, ``results.lb``, ``results.lp``),
|
|
953
|
+
which averages over chains and frames and does not treat rings.
|
|
954
|
+
|
|
955
|
+
Time is :math:`O(F\\, N \\log N)` and space :math:`O(F\\, N)` for
|
|
956
|
+
:math:`F` frames of :math:`N` bonds, against the :math:`O(F\\, N^2)`
|
|
957
|
+
of the pairwise matrix.
|
|
958
|
+
|
|
959
|
+
Examples
|
|
960
|
+
--------
|
|
961
|
+
The bonds of a square ring turn by a right angle each, so
|
|
962
|
+
:math:`C(k) = \\cos(\\pi k / 2)`:
|
|
963
|
+
|
|
964
|
+
>>> import numpy as np
|
|
965
|
+
>>> from polyphys.analyze.correlations import bond_cosine_correlation
|
|
966
|
+
>>> square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
|
|
967
|
+
>>> np.round(bond_cosine_correlation(square, 'ring'), 12)
|
|
968
|
+
array([ 1., 0., -1., 0.])
|
|
969
|
+
|
|
970
|
+
A straight rod has every bond parallel:
|
|
971
|
+
|
|
972
|
+
>>> rod = np.outer(np.arange(4.0), [1.0, 2.0, 2.0])
|
|
973
|
+
>>> np.round(bond_cosine_correlation(rod, 'linear'), 12)
|
|
974
|
+
array([1., 1., 1.])
|
|
975
|
+
|
|
976
|
+
.. versionadded:: 0.6.0
|
|
977
|
+
"""
|
|
978
|
+
bonds = bond_vectors(positions, topology)
|
|
979
|
+
lengths = np.linalg.norm(bonds, axis=-1)
|
|
980
|
+
if np.any(lengths == 0.0):
|
|
981
|
+
raise ValueError('every bond must have a positive length.')
|
|
982
|
+
units = bonds / lengths[..., np.newaxis]
|
|
983
|
+
# Put the bond axis last so the FFT runs along it, per component.
|
|
984
|
+
components = np.swapaxes(units, -1, -2)
|
|
985
|
+
n_bonds = components.shape[-1]
|
|
986
|
+
if topology == 'linear':
|
|
987
|
+
n_fft = _fft_length(n_bonds)
|
|
988
|
+
pairs: FloatArray = np.arange(n_bonds, 0, -1, dtype=np.float64)
|
|
989
|
+
else:
|
|
990
|
+
n_fft = n_bonds
|
|
991
|
+
pairs = np.full(n_bonds, float(n_bonds))
|
|
992
|
+
spectrum = np.fft.rfft(components, n=n_fft, axis=-1)
|
|
993
|
+
power = np.sum(np.abs(spectrum) ** 2, axis=-2)
|
|
994
|
+
sums = np.fft.irfft(power, n=n_fft, axis=-1)[..., :n_bonds]
|
|
995
|
+
return np.asarray(sums / pairs, dtype=np.float64)
|