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,708 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for :mod:`polyphys.analyze.correlations`.
|
|
3
|
+
|
|
4
|
+
Every expected value is independent of the code under test. It comes from
|
|
5
|
+
one of these sources, named in the test that uses it:
|
|
6
|
+
|
|
7
|
+
A1. AR(1), ``x_t = phi x_{t-1} + e_t`` with ``|phi| < 1``, independent
|
|
8
|
+
innovations, and a stationary start: multiplying by ``x_{t-k}`` and
|
|
9
|
+
taking expectations gives ``gamma_k = phi gamma_{k-1}``, so the
|
|
10
|
+
autocorrelation is ``rho_k = phi**k``.
|
|
11
|
+
|
|
12
|
+
A2. The unit bond vectors of a regular planar polygon of ``N`` sides turn by
|
|
13
|
+
``2 pi / N`` from one bond to the next, so two bonds ``k`` apart have
|
|
14
|
+
cosine ``cos(2 pi k / N)``.
|
|
15
|
+
|
|
16
|
+
A3. Every bond of a straight rod is parallel to every other, so the bond
|
|
17
|
+
cosine is 1 at every lag.
|
|
18
|
+
|
|
19
|
+
A4. A pure exponential ``A exp(-x/tau) + B`` sampled without noise is in the
|
|
20
|
+
model family, so least squares recovers its parameters exactly (to
|
|
21
|
+
round-off).
|
|
22
|
+
|
|
23
|
+
R1. Brute force: the defining sums, one lag at a time, in
|
|
24
|
+
:func:`_acf_by_definition` (the ``1/N`` autocovariance of the
|
|
25
|
+
NIST/SEMATECH e-Handbook, section 1.3.3.1) and
|
|
26
|
+
:func:`_bond_cosines_by_definition` (a double loop over bond pairs).
|
|
27
|
+
|
|
28
|
+
R2. Bartlett's variance as the e-Handbook states it and ``statsmodels``
|
|
29
|
+
computes it, ``(1 + 2 sum_{j=1}^{k-1} r_j^2) / N`` for ``k >= 2``,
|
|
30
|
+
``1/N`` at ``k = 1``, and 0 at ``k = 0``, written out in
|
|
31
|
+
:func:`_bartlett_half_width`.
|
|
32
|
+
|
|
33
|
+
P. A property: shape, normalization, invariance under affine rescaling of
|
|
34
|
+
a series, rigid motion of a chain, relabeling of a ring, or
|
|
35
|
+
column-wise independence.
|
|
36
|
+
|
|
37
|
+
Random inputs come from ``numpy.random.default_rng(seed)`` with the seed
|
|
38
|
+
shown, so every example is reproducible. Float comparisons use
|
|
39
|
+
``FLOAT64_ATOL`` where two computations differ only in the order of float64
|
|
40
|
+
operations; the one statistical tolerance is derived where it is used.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
import pytest
|
|
47
|
+
from numpy.testing import assert_allclose, assert_array_equal
|
|
48
|
+
from scipy.optimize import OptimizeWarning
|
|
49
|
+
from scipy.stats import norm
|
|
50
|
+
|
|
51
|
+
from polyphys.analyze.correlations import (
|
|
52
|
+
DECAY_MODELS,
|
|
53
|
+
AcfResult,
|
|
54
|
+
DecayFit,
|
|
55
|
+
acf,
|
|
56
|
+
bartlett_half_width,
|
|
57
|
+
bond_cosine_correlation,
|
|
58
|
+
bond_vectors,
|
|
59
|
+
fit_decay,
|
|
60
|
+
initial_guess,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Absolute tolerance for results that differ only by float64 round-off over
|
|
64
|
+
# an FFT of a few thousand points: far above the 2.2e-16 epsilon, far below
|
|
65
|
+
# any value the tests compare.
|
|
66
|
+
FLOAT64_ATOL = 1e-10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Reference computations
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _acf_by_definition(x: np.ndarray, nlags: int) -> np.ndarray:
|
|
75
|
+
"""Return the ``1/N`` sample autocorrelation by its defining sum (R1)."""
|
|
76
|
+
n = len(x)
|
|
77
|
+
centered = x - x.mean()
|
|
78
|
+
autocov = np.array(
|
|
79
|
+
[np.sum(centered[: n - k] * centered[k:]) / n
|
|
80
|
+
for k in range(nlags + 1)]
|
|
81
|
+
)
|
|
82
|
+
return autocov / autocov[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bartlett_half_width(
|
|
86
|
+
values: np.ndarray, n: int, alpha: float
|
|
87
|
+
) -> np.ndarray:
|
|
88
|
+
"""Return Bartlett's half-widths for the given autocorrelations (R2)."""
|
|
89
|
+
z = norm.ppf(1.0 - alpha / 2.0)
|
|
90
|
+
out = np.empty(len(values))
|
|
91
|
+
for k in range(len(values)):
|
|
92
|
+
if k == 0:
|
|
93
|
+
var = 0.0
|
|
94
|
+
else:
|
|
95
|
+
var = (1.0 + 2.0 * np.sum(values[1:k] ** 2)) / n
|
|
96
|
+
out[k] = z * np.sqrt(var)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _bond_cosines_by_definition(
|
|
101
|
+
units: np.ndarray, topology: str
|
|
102
|
+
) -> np.ndarray:
|
|
103
|
+
"""Return the mean bond cosine at every lag by a double loop (R1)."""
|
|
104
|
+
n = len(units)
|
|
105
|
+
out = np.empty(n)
|
|
106
|
+
for k in range(n):
|
|
107
|
+
if topology == 'ring':
|
|
108
|
+
pairs = [units[i] @ units[(i + k) % n] for i in range(n)]
|
|
109
|
+
else:
|
|
110
|
+
pairs = [units[i] @ units[i + k] for i in range(n - k)]
|
|
111
|
+
out[k] = np.mean(pairs)
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Fixtures
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.fixture
|
|
121
|
+
def make_ar1():
|
|
122
|
+
"""Return a factory for a stationary AR(1) series with unit innovations."""
|
|
123
|
+
|
|
124
|
+
def _make(phi: float, n_frames: int, seed: int) -> np.ndarray:
|
|
125
|
+
rng = np.random.default_rng(seed)
|
|
126
|
+
noise = rng.standard_normal(n_frames)
|
|
127
|
+
x = np.empty(n_frames, dtype=np.float64)
|
|
128
|
+
x[0] = noise[0] / np.sqrt(1.0 - phi ** 2) # stationary start
|
|
129
|
+
for t in range(1, n_frames):
|
|
130
|
+
x[t] = phi * x[t - 1] + noise[t]
|
|
131
|
+
return x
|
|
132
|
+
|
|
133
|
+
return _make
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.fixture
|
|
137
|
+
def make_noise():
|
|
138
|
+
"""Return a factory for i.i.d. standard normal series, ``(n,)`` or
|
|
139
|
+
``(n, m)``."""
|
|
140
|
+
|
|
141
|
+
def _make(shape, seed: int) -> np.ndarray:
|
|
142
|
+
return np.random.default_rng(seed).standard_normal(shape)
|
|
143
|
+
|
|
144
|
+
return _make
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.fixture
|
|
148
|
+
def make_polygon():
|
|
149
|
+
"""Return a factory for the vertices of a regular planar polygon."""
|
|
150
|
+
|
|
151
|
+
def _make(n_sides: int, radius: float = 1.0) -> np.ndarray:
|
|
152
|
+
angles = 2.0 * np.pi * np.arange(n_sides) / n_sides
|
|
153
|
+
return radius * np.column_stack(
|
|
154
|
+
[np.cos(angles), np.sin(angles), np.zeros(n_sides)]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return _make
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.fixture
|
|
161
|
+
def make_random_walk():
|
|
162
|
+
"""Return a factory for a random walk of unit steps in three dimensions,
|
|
163
|
+
with any leading frames axis."""
|
|
164
|
+
|
|
165
|
+
def _make(n_atoms: int, seed: int, n_frames: int | None = None):
|
|
166
|
+
rng = np.random.default_rng(seed)
|
|
167
|
+
shape = (n_atoms, 3) if n_frames is None else (n_frames, n_atoms, 3)
|
|
168
|
+
steps = rng.standard_normal(shape)
|
|
169
|
+
steps /= np.linalg.norm(steps, axis=-1, keepdims=True)
|
|
170
|
+
return np.cumsum(steps, axis=-2)
|
|
171
|
+
|
|
172
|
+
return _make
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# acf
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_acf_lag_zero_is_one_with_zero_band(make_noise):
|
|
181
|
+
"""The autocorrelation at lag 0 is exactly 1 with a zero half-width,
|
|
182
|
+
and the result has ``nlags + 1`` rows (P)."""
|
|
183
|
+
x = make_noise(100, seed=1)
|
|
184
|
+
result = acf(x, nlags=5)
|
|
185
|
+
assert isinstance(result, AcfResult)
|
|
186
|
+
assert result.values.shape == (6,)
|
|
187
|
+
assert result.half_width is not None
|
|
188
|
+
assert result.half_width.shape == (6,)
|
|
189
|
+
assert result.values[0] == 1.0
|
|
190
|
+
assert result.half_width[0] == 0.0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_acf_matches_defining_sum(make_ar1):
|
|
194
|
+
"""The FFT autocorrelation equals the ``1/N`` defining sum at every lag
|
|
195
|
+
on an odd, non-power-of-two length (R1)."""
|
|
196
|
+
x = make_ar1(0.5, n_frames=257, seed=2)
|
|
197
|
+
result = acf(x, nlags=40, alpha=None)
|
|
198
|
+
assert_allclose(
|
|
199
|
+
result.values, _acf_by_definition(x, 40), atol=FLOAT64_ATOL
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_acf_of_ar1_is_phi_to_k_within_bartlett_band(make_ar1):
|
|
204
|
+
"""The AR(1) autocorrelation ``phi**k`` lies inside Bartlett's band at
|
|
205
|
+
lags 1 to 10 (A1)."""
|
|
206
|
+
phi, n_frames, n_lags = 0.6, 2 ** 15, 10
|
|
207
|
+
# Bonferroni over the 10 lags: a 1 % family-wise level, so that the
|
|
208
|
+
# check holds for almost every seed rather than one chosen seed.
|
|
209
|
+
alpha = 0.01 / n_lags
|
|
210
|
+
x = make_ar1(phi, n_frames, seed=3)
|
|
211
|
+
result = acf(x, nlags=n_lags, alpha=alpha)
|
|
212
|
+
assert result.half_width is not None
|
|
213
|
+
lags = np.arange(1, n_lags + 1)
|
|
214
|
+
deviation = np.abs(result.values[1:] - phi ** lags)
|
|
215
|
+
assert np.all(deviation <= result.half_width[1:])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_acf_of_white_noise_is_within_root_n_band(make_noise):
|
|
219
|
+
"""White noise has autocorrelations inside ``z / sqrt(N)`` at every lag
|
|
220
|
+
checked, the band the e-Handbook gives for randomness (P, R2)."""
|
|
221
|
+
n_frames, n_lags = 2 ** 14, 10
|
|
222
|
+
alpha = 0.01 / n_lags # Bonferroni over the lags, as above
|
|
223
|
+
x = make_noise(n_frames, seed=4)
|
|
224
|
+
result = acf(x, nlags=n_lags, alpha=alpha)
|
|
225
|
+
band = norm.ppf(1.0 - alpha / 2.0) / np.sqrt(n_frames)
|
|
226
|
+
assert np.all(np.abs(result.values[1:]) <= band)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_acf_band_is_bartlett_formula():
|
|
230
|
+
"""The half-width at lag ``k`` is ``z sqrt((1 + 2 sum_{j<k} r_j^2)/N)``
|
|
231
|
+
with ``1/N`` at lag 1 and 0 at lag 0, on a six-value series (R2)."""
|
|
232
|
+
x = np.array([2.0, 4.0, 1.0, 5.0, 3.0, 6.0])
|
|
233
|
+
result = acf(x, nlags=4, alpha=0.1)
|
|
234
|
+
assert result.half_width is not None
|
|
235
|
+
expected = _bartlett_half_width(result.values, n=6, alpha=0.1)
|
|
236
|
+
assert_allclose(result.half_width, expected, atol=FLOAT64_ATOL)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_bartlett_half_width_rebuilds_the_band_of_acf(make_ar1):
|
|
240
|
+
"""The band rebuilt from a stored autocorrelation, the frame count, and
|
|
241
|
+
the level equals the one ``acf`` computed with them."""
|
|
242
|
+
x = make_ar1(0.5, 256, 11)
|
|
243
|
+
result = acf(x, nlags=10, alpha=0.1)
|
|
244
|
+
|
|
245
|
+
rebuilt = bartlett_half_width(result.values, 256, alpha=0.1)
|
|
246
|
+
|
|
247
|
+
assert_allclose(rebuilt, result.half_width, atol=FLOAT64_ATOL)
|
|
248
|
+
assert rebuilt[0] == 0.0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_bartlett_half_width_treats_columns_independently(make_noise):
|
|
252
|
+
"""Each column's band is the band of that column alone (R2)."""
|
|
253
|
+
x = make_noise((128, 2), seed=5)
|
|
254
|
+
values = acf(x, nlags=6, alpha=None).values
|
|
255
|
+
|
|
256
|
+
band = bartlett_half_width(values, 128, alpha=0.05)
|
|
257
|
+
|
|
258
|
+
for col in range(2):
|
|
259
|
+
assert_allclose(band[:, col],
|
|
260
|
+
_bartlett_half_width(values[:, col], 128, 0.05),
|
|
261
|
+
atol=FLOAT64_ATOL)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.parametrize("values, n_frames, alpha, match", [
|
|
265
|
+
pytest.param(np.ones((2, 2, 2)), 8, 0.05, "one- or two-dimensional",
|
|
266
|
+
id="three-dimensional"),
|
|
267
|
+
pytest.param([1.0, 0.5], 1, 0.05, "at least 2", id="one-frame"),
|
|
268
|
+
pytest.param([1.0, 0.5], 8, 1.0, r"alpha must be in \(0, 1\)",
|
|
269
|
+
id="alpha-one"),
|
|
270
|
+
])
|
|
271
|
+
def test_bartlett_half_width_rejects_bad_input(values, n_frames, alpha,
|
|
272
|
+
match):
|
|
273
|
+
"""Bad shapes, frame counts, and levels raise ``ValueError``."""
|
|
274
|
+
with pytest.raises(ValueError, match=match):
|
|
275
|
+
bartlett_half_width(values, n_frames, alpha)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_acf_treats_columns_as_independent_series(make_noise):
|
|
279
|
+
"""A ``(n, m)`` input gives, column by column, the result of ``m``
|
|
280
|
+
one-dimensional calls (P)."""
|
|
281
|
+
x = make_noise((200, 3), seed=5)
|
|
282
|
+
result = acf(x, nlags=7)
|
|
283
|
+
assert result.half_width is not None
|
|
284
|
+
assert result.values.shape == (8, 3)
|
|
285
|
+
for j in range(3):
|
|
286
|
+
single = acf(x[:, j], nlags=7)
|
|
287
|
+
assert single.half_width is not None
|
|
288
|
+
assert_allclose(result.values[:, j], single.values, atol=FLOAT64_ATOL)
|
|
289
|
+
assert_allclose(
|
|
290
|
+
result.half_width[:, j], single.half_width, atol=FLOAT64_ATOL
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_acf_is_invariant_under_positive_affine_rescaling(make_noise):
|
|
295
|
+
"""Scaling a series by a positive factor and shifting it leaves its
|
|
296
|
+
autocorrelation unchanged (P)."""
|
|
297
|
+
x = make_noise(300, seed=6)
|
|
298
|
+
reference = acf(x, nlags=10, alpha=None).values
|
|
299
|
+
rescaled = acf(3.5 * x - 20.0, nlags=10, alpha=None).values
|
|
300
|
+
assert_allclose(rescaled, reference, atol=FLOAT64_ATOL)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_acf_skips_band_when_alpha_is_none(make_noise):
|
|
304
|
+
"""``alpha=None`` returns no half-width and records no level (P)."""
|
|
305
|
+
result = acf(make_noise(50, seed=7), nlags=3, alpha=None)
|
|
306
|
+
assert result.half_width is None
|
|
307
|
+
assert result.alpha is None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.parametrize(
|
|
311
|
+
'x, nlags, alpha, match',
|
|
312
|
+
[
|
|
313
|
+
pytest.param(np.ones(10), 2, 0.05, 'constant', id='constant-series'),
|
|
314
|
+
pytest.param(
|
|
315
|
+
np.arange(10.0), 10, 0.05, 'nlags must be', id='nlags-too-large'
|
|
316
|
+
),
|
|
317
|
+
pytest.param(
|
|
318
|
+
np.arange(10.0), -1, 0.05, 'nlags must be', id='nlags-negative'
|
|
319
|
+
),
|
|
320
|
+
pytest.param(
|
|
321
|
+
np.array([1.0, np.nan, 2.0]), 1, 0.05, 'finite', id='nan-value'
|
|
322
|
+
),
|
|
323
|
+
pytest.param(
|
|
324
|
+
np.arange(10.0), 2, 1.0, 'alpha must be', id='alpha-one'
|
|
325
|
+
),
|
|
326
|
+
pytest.param(
|
|
327
|
+
np.arange(10.0), 2, 0.0, 'alpha must be', id='alpha-zero'
|
|
328
|
+
),
|
|
329
|
+
pytest.param(np.ones((2, 2, 2)), 1, 0.05, 'dimensional', id='3-d'),
|
|
330
|
+
pytest.param(np.array([1.0]), 0, 0.05, 'two frames', id='one-frame'),
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
def test_acf_rejects_bad_input(x, nlags, alpha, match):
|
|
334
|
+
"""A constant series, a lag outside ``[0, N-1]``, a non-finite value, a
|
|
335
|
+
level outside ``(0, 1)``, or a bad shape raises ``ValueError``."""
|
|
336
|
+
with pytest.raises(ValueError, match=match):
|
|
337
|
+
acf(x, nlags=nlags, alpha=alpha)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# fit_decay and initial_guess
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_fit_decay_recovers_pure_exponential():
|
|
346
|
+
"""A noise-free ``exp(-x/tau)`` is recovered to 1e-10 from a start 50 %
|
|
347
|
+
off (A4)."""
|
|
348
|
+
x = np.arange(0.0, 40.0)
|
|
349
|
+
fit = fit_decay(x, np.exp(-x / 3.7), 'exp', p0=[5.55])
|
|
350
|
+
assert fit.converged
|
|
351
|
+
assert fit.params[0] == pytest.approx(3.7, rel=1e-10)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_fit_decay_recovers_stretched_amp_offset():
|
|
355
|
+
"""A noise-free ``A exp(-(x/tau)^beta) + B`` is recovered to 1e-8 in
|
|
356
|
+
every parameter (A4)."""
|
|
357
|
+
x = np.arange(0.0, 80.0)
|
|
358
|
+
truth = np.array([5.0, 0.7, 2.0, 0.1])
|
|
359
|
+
y = DECAY_MODELS['stretched_amp_offset'].function(x, *truth)
|
|
360
|
+
fit = fit_decay(x, y, 'stretched_amp_offset', p0=[4.0, 1.0, 1.5, 0.0])
|
|
361
|
+
assert fit.converged
|
|
362
|
+
assert fit.names == ('tau', 'beta', 'amp', 'offset')
|
|
363
|
+
assert_allclose(fit.params, truth, rtol=1e-8)
|
|
364
|
+
assert_allclose(fit.evaluate(x), y, rtol=1e-8)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@pytest.mark.parametrize('model', list(DECAY_MODELS))
|
|
368
|
+
def test_fit_decay_returns_one_value_per_parameter(model):
|
|
369
|
+
"""Every model's fit returns parameters, errors, a starting point, and a
|
|
370
|
+
covariance sized by its parameter count, in its parameter order (P)."""
|
|
371
|
+
x = np.arange(0.0, 30.0)
|
|
372
|
+
y = np.exp(-x / 6.0)
|
|
373
|
+
fit = fit_decay(x, y, model)
|
|
374
|
+
n_params = len(DECAY_MODELS[model].names)
|
|
375
|
+
assert isinstance(fit, DecayFit)
|
|
376
|
+
assert fit.model == model
|
|
377
|
+
assert fit.names == DECAY_MODELS[model].names
|
|
378
|
+
assert fit.params.shape == (n_params,)
|
|
379
|
+
assert fit.stderr.shape == (n_params,)
|
|
380
|
+
assert fit.p0.shape == (n_params,)
|
|
381
|
+
assert fit.pcov.shape == (n_params, n_params)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_fit_decay_reports_failure_without_raising(caplog):
|
|
385
|
+
"""A fit the optimizer gives up on returns ``converged=False`` with NaN
|
|
386
|
+
parameters of the model's length and a logged warning (P)."""
|
|
387
|
+
x = np.arange(0.0, 80.0)
|
|
388
|
+
y = 2.0 * np.exp(-((x / 5.0) ** 0.7)) + 0.1
|
|
389
|
+
with caplog.at_level(logging.WARNING, logger='polyphys'):
|
|
390
|
+
fit = fit_decay(x, y, 'stretched_amp_offset', maxfev=1)
|
|
391
|
+
assert fit.converged is False
|
|
392
|
+
assert fit.params.shape == (4,)
|
|
393
|
+
assert np.all(np.isnan(fit.params))
|
|
394
|
+
assert np.all(np.isnan(fit.stderr))
|
|
395
|
+
assert np.all(np.isnan(fit.pcov))
|
|
396
|
+
assert np.all(np.isnan(fit.evaluate(x)))
|
|
397
|
+
assert 'did not converge' in caplog.text
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_fit_decay_stderr_is_root_of_covariance_diagonal():
|
|
401
|
+
"""The standard error of each parameter is the square root of the
|
|
402
|
+
covariance diagonal, not the variance itself (P)."""
|
|
403
|
+
x = np.arange(0.0, 60.0)
|
|
404
|
+
rng = np.random.default_rng(8)
|
|
405
|
+
y = np.exp(-x / 7.0) + 0.02 * rng.standard_normal(x.size)
|
|
406
|
+
fit = fit_decay(x, y, 'stretched_amp', p0=[7.0, 1.0, 1.0])
|
|
407
|
+
assert fit.converged
|
|
408
|
+
assert_allclose(fit.stderr ** 2, np.diag(fit.pcov), rtol=1e-12)
|
|
409
|
+
assert np.all(fit.stderr > 0.0)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_fit_decay_records_the_start_it_used():
|
|
413
|
+
"""With ``p0=None`` the fit starts from :func:`initial_guess` and
|
|
414
|
+
records that start; an explicit ``p0`` is recorded as given (P)."""
|
|
415
|
+
x = np.arange(0.0, 40.0)
|
|
416
|
+
y = 2.0 * np.exp(-x / 4.0) + 0.5
|
|
417
|
+
default = fit_decay(x, y, 'stretched_amp_offset')
|
|
418
|
+
explicit = fit_decay(x, y, 'stretched_amp_offset', p0=[3.0, 1.0, 1.0, 0.0])
|
|
419
|
+
assert_array_equal(
|
|
420
|
+
default.p0, initial_guess(x, y, 'stretched_amp_offset')
|
|
421
|
+
)
|
|
422
|
+
assert_array_equal(explicit.p0, [3.0, 1.0, 1.0, 0.0])
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_initial_guess_is_within_a_factor_of_two_and_fit_converges_from_it():
|
|
426
|
+
"""On a noise-free curve every guess lies within a factor of 2 of the
|
|
427
|
+
truth, and the fit from it recovers the truth (A4)."""
|
|
428
|
+
x = np.arange(0.0, 100.0)
|
|
429
|
+
truth = np.array([5.0, 1.0, 2.0, 0.3])
|
|
430
|
+
y = DECAY_MODELS['stretched_amp_offset'].function(x, *truth)
|
|
431
|
+
guess = initial_guess(x, y, 'stretched_amp_offset')
|
|
432
|
+
ratio = guess / truth
|
|
433
|
+
assert np.all((ratio >= 0.5) & (ratio <= 2.0))
|
|
434
|
+
fit = fit_decay(x, y, 'stretched_amp_offset', p0=guess)
|
|
435
|
+
assert fit.converged
|
|
436
|
+
assert_allclose(fit.params, truth, rtol=1e-8)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@pytest.mark.parametrize(
|
|
440
|
+
'model, expected',
|
|
441
|
+
[
|
|
442
|
+
pytest.param('exp', [4.0], id='exp'),
|
|
443
|
+
pytest.param('stretched', [4.0, 1.0], id='stretched'),
|
|
444
|
+
pytest.param('stretched_amp', [4.0, 1.0, 1.0], id='stretched-amp'),
|
|
445
|
+
],
|
|
446
|
+
)
|
|
447
|
+
def test_initial_guess_reads_tau_at_one_over_e(model, expected):
|
|
448
|
+
"""The decay-time guess is the first ``x`` at which a unit exponential
|
|
449
|
+
is at ``1/e``, which is ``tau`` itself when it is sampled, with ``beta``
|
|
450
|
+
1 and amplitude 1 for a model without an offset (A4)."""
|
|
451
|
+
x = np.arange(0.0, 20.0)
|
|
452
|
+
guess = initial_guess(x, np.exp(-x / 4.0), model)
|
|
453
|
+
assert_allclose(guess, expected, atol=FLOAT64_ATOL)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_initial_guess_uses_unit_amplitude_for_flat_curve():
|
|
457
|
+
"""A curve whose first value equals its tail gives an amplitude guess
|
|
458
|
+
of 1, not 0, so the fit can start (P)."""
|
|
459
|
+
x = np.arange(0.0, 20.0)
|
|
460
|
+
guess = initial_guess(x, np.full(20, 0.3), 'stretched_amp_offset')
|
|
461
|
+
assert guess[2] == 1.0
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_initial_guess_rejects_empty_curve():
|
|
465
|
+
"""An empty curve raises ``ValueError`` instead of indexing nothing."""
|
|
466
|
+
with pytest.raises(ValueError, match='empty'):
|
|
467
|
+
initial_guess([], [], 'exp')
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_fit_decay_marks_unestimable_covariance_as_nan(caplog):
|
|
471
|
+
"""A fit with as many points as parameters converges but gets NaN
|
|
472
|
+
standard errors and a logged warning, since SciPy cannot estimate the
|
|
473
|
+
covariance (P)."""
|
|
474
|
+
x = np.array([2.0])
|
|
475
|
+
y = np.exp(-x / 3.0)
|
|
476
|
+
with caplog.at_level(logging.WARNING, logger='polyphys'):
|
|
477
|
+
with pytest.warns(OptimizeWarning, match='Covariance'):
|
|
478
|
+
fit = fit_decay(x, y, 'exp', p0=[2.5])
|
|
479
|
+
assert fit.converged
|
|
480
|
+
assert fit.params[0] == pytest.approx(3.0, rel=1e-8)
|
|
481
|
+
assert np.all(np.isnan(fit.stderr))
|
|
482
|
+
assert 'covariance' in caplog.text
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@pytest.mark.parametrize('method', [
|
|
486
|
+
pytest.param(None, id='lm'),
|
|
487
|
+
pytest.param('trf', id='trf'),
|
|
488
|
+
])
|
|
489
|
+
def test_fit_decay_reports_non_finite_start_as_failed(method, caplog):
|
|
490
|
+
"""A start where the model is not finite is a failed fit under every
|
|
491
|
+
method, reported without raising; ``(x / tau) ** beta`` is NaN for
|
|
492
|
+
``tau < 0`` and ``x > 0`` (analytic)."""
|
|
493
|
+
x = np.linspace(0.0, 50.0, 501)
|
|
494
|
+
y = np.exp(-x / 5.0)
|
|
495
|
+
kwargs = {} if method is None else {'method': method}
|
|
496
|
+
with caplog.at_level(logging.WARNING, logger='polyphys'):
|
|
497
|
+
fit = fit_decay(x, y, 'stretched', p0=[-2.0, 0.5], **kwargs)
|
|
498
|
+
assert fit.converged is False
|
|
499
|
+
assert np.all(np.isnan(fit.params))
|
|
500
|
+
assert np.all(np.isnan(fit.stderr))
|
|
501
|
+
assert np.all(np.isnan(fit.pcov))
|
|
502
|
+
assert_array_equal(fit.p0, [-2.0, 0.5])
|
|
503
|
+
assert 'not finite' in caplog.text
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_fit_decay_reports_unmoved_fit_without_covariance_as_failed(caplog):
|
|
507
|
+
"""A fit that stays at its start with a non-finite covariance is not
|
|
508
|
+
converged: at ``tau = 1e300`` the model is exactly 1 and its gradient
|
|
509
|
+
underflows to 0, so the optimizer cannot move (analytic)."""
|
|
510
|
+
x = np.linspace(0.0, 50.0, 501)
|
|
511
|
+
y = np.exp(-x / 5.0)
|
|
512
|
+
with caplog.at_level(logging.WARNING, logger='polyphys'):
|
|
513
|
+
with pytest.warns(OptimizeWarning, match='Covariance'):
|
|
514
|
+
fit = fit_decay(x, y, 'exp', p0=[1e300])
|
|
515
|
+
assert fit.converged is False
|
|
516
|
+
assert np.all(np.isnan(fit.params))
|
|
517
|
+
assert 'did not move' in caplog.text
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def test_fit_decay_converges_from_the_exact_start():
|
|
521
|
+
"""A start at the exact parameters of noise-free data is converged, with
|
|
522
|
+
a finite covariance, although the optimizer does not move: the residual
|
|
523
|
+
is zero there (A4)."""
|
|
524
|
+
x = np.linspace(0.0, 50.0, 501)
|
|
525
|
+
y = np.exp(-x / 5.0)
|
|
526
|
+
fit = fit_decay(x, y, 'exp', p0=[5.0])
|
|
527
|
+
assert fit.converged
|
|
528
|
+
# float64 round-off in exp(-x / 5.0) and the zero-residual covariance
|
|
529
|
+
assert_allclose(fit.params, [5.0], rtol=1e-12)
|
|
530
|
+
assert np.all(np.isfinite(fit.stderr))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def test_initial_guess_never_returns_zero_tau():
|
|
534
|
+
"""A curve already at ``1/e`` at ``x = 0`` gets the first positive ``x``
|
|
535
|
+
as its decay time, never 0 (P)."""
|
|
536
|
+
x = np.array([0.0, 0.5, 1.0, 1.5])
|
|
537
|
+
guess = initial_guess(x, np.full(4, 0.1), 'exp')
|
|
538
|
+
assert guess[0] == 0.5
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@pytest.mark.parametrize(
|
|
542
|
+
'kwargs, match',
|
|
543
|
+
[
|
|
544
|
+
pytest.param(
|
|
545
|
+
{'model': 'mono_exp'}, 'model must be one of', id='unknown-model'
|
|
546
|
+
),
|
|
547
|
+
pytest.param(
|
|
548
|
+
{'model': 'exp', 'p0': [1.0, 2.0]}, 'p0 must have', id='p0-length'
|
|
549
|
+
),
|
|
550
|
+
pytest.param(
|
|
551
|
+
{'model': 'exp', 'y': np.ones(5)}, 'same length', id='length'
|
|
552
|
+
),
|
|
553
|
+
pytest.param(
|
|
554
|
+
{'model': 'exp', 'y': np.full(10, np.nan)}, 'finite', id='nan'
|
|
555
|
+
),
|
|
556
|
+
pytest.param(
|
|
557
|
+
{'model': 'exp', 'x': np.ones((2, 5)), 'y': np.ones((2, 5))},
|
|
558
|
+
'one-dimensional',
|
|
559
|
+
id='2-d',
|
|
560
|
+
),
|
|
561
|
+
pytest.param(
|
|
562
|
+
{'model': 'stretched_amp_offset', 'x': np.arange(3.0),
|
|
563
|
+
'y': np.ones(3)},
|
|
564
|
+
'only 3 point',
|
|
565
|
+
id='too-few-points',
|
|
566
|
+
),
|
|
567
|
+
],
|
|
568
|
+
)
|
|
569
|
+
def test_fit_decay_rejects_bad_input(kwargs, match):
|
|
570
|
+
"""An unknown model, a wrong-length ``p0``, mismatched or non-finite
|
|
571
|
+
curves, a 2-D curve, or fewer points than parameters raises
|
|
572
|
+
``ValueError``."""
|
|
573
|
+
args = {'x': np.arange(10.0), 'y': np.exp(-np.arange(10.0))}
|
|
574
|
+
args.update(kwargs)
|
|
575
|
+
with pytest.raises(ValueError, match=match):
|
|
576
|
+
fit_decay(**args)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
# bond_vectors and bond_cosine_correlation
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def test_bond_cosine_of_straight_rod_is_one():
|
|
585
|
+
"""A straight rod has bond cosine 1 at every lag (A3)."""
|
|
586
|
+
rod = np.outer(np.arange(10.0), [0.3, -0.4, 1.2])
|
|
587
|
+
assert_allclose(
|
|
588
|
+
bond_cosine_correlation(rod, 'linear'), np.ones(9), atol=FLOAT64_ATOL
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@pytest.mark.parametrize('n_sides', [pytest.param(12, id='12-gon'),
|
|
593
|
+
pytest.param(7, id='7-gon')])
|
|
594
|
+
def test_bond_cosine_of_regular_ring_is_cosine_of_turning_angle(
|
|
595
|
+
make_polygon, n_sides
|
|
596
|
+
):
|
|
597
|
+
"""A regular ring of ``N`` bonds has ``C(k) = cos(2 pi k / N)`` (A2)."""
|
|
598
|
+
lags = np.arange(n_sides)
|
|
599
|
+
result = bond_cosine_correlation(make_polygon(n_sides), 'ring')
|
|
600
|
+
assert_allclose(
|
|
601
|
+
result, np.cos(2.0 * np.pi * lags / n_sides), atol=FLOAT64_ATOL
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@pytest.mark.parametrize('topology', ['linear', 'ring'])
|
|
606
|
+
def test_bond_cosine_matches_double_loop(make_random_walk, topology):
|
|
607
|
+
"""The FFT bond cosine equals the mean over pairs by a double loop at
|
|
608
|
+
every lag, for both topologies (R1)."""
|
|
609
|
+
positions = make_random_walk(n_atoms=33, seed=9)
|
|
610
|
+
bonds = bond_vectors(positions, topology)
|
|
611
|
+
units = bonds / np.linalg.norm(bonds, axis=-1, keepdims=True)
|
|
612
|
+
assert_allclose(
|
|
613
|
+
bond_cosine_correlation(positions, topology),
|
|
614
|
+
_bond_cosines_by_definition(units, topology),
|
|
615
|
+
atol=FLOAT64_ATOL,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def test_bond_cosine_is_invariant_under_rigid_motion(make_random_walk):
|
|
620
|
+
"""Rotating and translating a chain leaves its bond cosines unchanged
|
|
621
|
+
(P)."""
|
|
622
|
+
positions = make_random_walk(n_atoms=20, seed=10)
|
|
623
|
+
rng = np.random.default_rng(11)
|
|
624
|
+
rotation, _ = np.linalg.qr(rng.standard_normal((3, 3)))
|
|
625
|
+
moved = positions @ rotation.T + np.array([5.0, -2.0, 7.5])
|
|
626
|
+
assert_allclose(
|
|
627
|
+
bond_cosine_correlation(moved, 'linear'),
|
|
628
|
+
bond_cosine_correlation(positions, 'linear'),
|
|
629
|
+
atol=FLOAT64_ATOL,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def test_bond_cosine_keeps_leading_frames_axis(make_random_walk):
|
|
634
|
+
"""A ``(n_frames, n_atoms, 3)`` input gives one row per frame, equal to
|
|
635
|
+
the single-frame results (P)."""
|
|
636
|
+
frames = make_random_walk(n_atoms=15, seed=12, n_frames=4)
|
|
637
|
+
result = bond_cosine_correlation(frames, 'ring')
|
|
638
|
+
assert result.shape == (4, 15)
|
|
639
|
+
for f in range(4):
|
|
640
|
+
assert_allclose(
|
|
641
|
+
result[f], bond_cosine_correlation(frames[f], 'ring'),
|
|
642
|
+
atol=FLOAT64_ATOL,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_bond_cosine_of_ring_is_symmetric_and_relabeling_invariant(
|
|
647
|
+
make_random_walk,
|
|
648
|
+
):
|
|
649
|
+
"""On a ring ``C(k) = C(N - k)``, and starting the labels at another
|
|
650
|
+
monomer gives the same curve (P)."""
|
|
651
|
+
positions = make_random_walk(n_atoms=21, seed=13)
|
|
652
|
+
result = bond_cosine_correlation(positions, 'ring')
|
|
653
|
+
assert_allclose(result[1:], result[1:][::-1], atol=FLOAT64_ATOL)
|
|
654
|
+
relabeled = bond_cosine_correlation(np.roll(positions, 5, axis=0), 'ring')
|
|
655
|
+
assert_allclose(relabeled, result, atol=FLOAT64_ATOL)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_bond_cosine_runs_on_linear_chain():
|
|
659
|
+
"""A linear chain of four beads gives three lags without error, which
|
|
660
|
+
the research ``bond_info`` could not (P)."""
|
|
661
|
+
positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [1.0, 1, 0], [2.0, 1, 0]])
|
|
662
|
+
result = bond_cosine_correlation(positions, 'linear')
|
|
663
|
+
assert result.shape == (3,)
|
|
664
|
+
assert_allclose(result, [1.0, 0.0, 1.0], atol=FLOAT64_ATOL)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@pytest.mark.parametrize(
|
|
668
|
+
'topology, expected',
|
|
669
|
+
[
|
|
670
|
+
pytest.param(
|
|
671
|
+
'linear', [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]], id='linear'
|
|
672
|
+
),
|
|
673
|
+
pytest.param(
|
|
674
|
+
'ring',
|
|
675
|
+
[[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]],
|
|
676
|
+
id='ring',
|
|
677
|
+
),
|
|
678
|
+
],
|
|
679
|
+
)
|
|
680
|
+
def test_bond_vectors_differences_along_chain(topology, expected):
|
|
681
|
+
"""Bond ``i`` is ``r[i+1] - r[i]``, and a ring adds ``r[0] - r[-1]``
|
|
682
|
+
as its last bond (hand count on a unit square)."""
|
|
683
|
+
square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
|
|
684
|
+
assert_array_equal(bond_vectors(square, topology), expected)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@pytest.mark.parametrize(
|
|
688
|
+
'positions, topology, match',
|
|
689
|
+
[
|
|
690
|
+
pytest.param(
|
|
691
|
+
np.array([[0.0, 0, 0], [0.0, 0, 0], [1.0, 0, 0]]),
|
|
692
|
+
'linear',
|
|
693
|
+
'positive length',
|
|
694
|
+
id='coincident-beads',
|
|
695
|
+
),
|
|
696
|
+
pytest.param(np.eye(3), 'helix', "must be 'linear' or 'ring'",
|
|
697
|
+
id='bad-topology'),
|
|
698
|
+
pytest.param(np.arange(6.0), 'linear', 'dimension', id='1-d'),
|
|
699
|
+
pytest.param(np.eye(2), 'ring', 'at least 3 atoms', id='ring-of-two'),
|
|
700
|
+
pytest.param(np.ones((1, 3)), 'linear', 'at least 2 atoms',
|
|
701
|
+
id='one-atom'),
|
|
702
|
+
],
|
|
703
|
+
)
|
|
704
|
+
def test_bond_cosine_rejects_bad_input(positions, topology, match):
|
|
705
|
+
"""Coincident bonded beads, an unknown topology, a 1-D input, or a chain
|
|
706
|
+
too short for its topology raises ``ValueError``."""
|
|
707
|
+
with pytest.raises(ValueError, match=match):
|
|
708
|
+
bond_cosine_correlation(positions, topology)
|