physverify 0.1.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.
- physverify/__init__.py +26 -0
- physverify/__main__.py +3 -0
- physverify/adapters/__init__.py +14 -0
- physverify/adapters/double_pendulum.py +55 -0
- physverify/adapters/lorenz.py +152 -0
- physverify/adapters/projectile_v1.py +92 -0
- physverify/adapters/projectile_v2.py +94 -0
- physverify/adapters/three_body.py +283 -0
- physverify/agent.py +265 -0
- physverify/checks.py +59 -0
- physverify/cli.py +105 -0
- physverify/compare.py +104 -0
- physverify/loaders.py +49 -0
- physverify/matrix.py +160 -0
- physverify/mms.py +118 -0
- physverify/order.py +131 -0
- physverify/problems/__init__.py +6 -0
- physverify/problems/_common.py +89 -0
- physverify/problems/heat1d.py +217 -0
- physverify/problems/kepler.py +176 -0
- physverify/problems/pendulum.py +201 -0
- physverify/problems/projectile.py +240 -0
- physverify/problems/sod.py +291 -0
- physverify/pytest_plugin.py +75 -0
- physverify/report.py +189 -0
- physverify/steppers.py +38 -0
- physverify/stochastic/__init__.py +1 -0
- physverify/stochastic/calibrate.py +202 -0
- physverify/stochastic/checks.py +378 -0
- physverify/stochastic/exact.py +205 -0
- physverify/stochastic/ising.py +170 -0
- physverify/stochastic/matrix.py +151 -0
- physverify/stochastic/reference.py +67 -0
- physverify/stochastic/sampler.py +253 -0
- physverify/stochastic/stats.py +137 -0
- physverify/studies.py +146 -0
- physverify/symbolic.py +89 -0
- physverify-0.1.0.dist-info/METADATA +122 -0
- physverify-0.1.0.dist-info/RECORD +43 -0
- physverify-0.1.0.dist-info/WHEEL +5 -0
- physverify-0.1.0.dist-info/entry_points.txt +5 -0
- physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
- physverify-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Checks for an Ising sampler (see physverify.stochastic.sampler for the interface).
|
|
2
|
+
|
|
3
|
+
Deterministic (plain PASS / FAIL, no statistics):
|
|
4
|
+
|
|
5
|
+
bookkeeping tracked E and M, the code's own measurement, and an independent
|
|
6
|
+
recomputation from the lattice all agree exactly after every sweep
|
|
7
|
+
scripted_rng the code's sweeps, fed recorded random numbers, give exactly the
|
|
8
|
+
lattices of the trusted reference replaying the same numbers
|
|
9
|
+
reproducibility the same seed gives the same run; different seeds do not
|
|
10
|
+
|
|
11
|
+
Statistical (PASS / FAIL / INCONCLUSIVE, false-alarm rate per check stated in the message):
|
|
12
|
+
|
|
13
|
+
exact_distribution fractions of time at each energy level and each |m| level, L = 4,
|
|
14
|
+
against exact enumeration
|
|
15
|
+
moments <e>, <e^2>, <|m|>, <m^2> at temperatures below, at and above T_c
|
|
16
|
+
(L = 4, enumeration), <m> in a field, and <e>, <e^2> at L = 8
|
|
17
|
+
(Kaufman's closed form)
|
|
18
|
+
acceptance single updates at controlled sites: acceptance frequency for each
|
|
19
|
+
energy change against min(1, exp(-dE / T)) (Metropolis) or the
|
|
20
|
+
heat-bath probability
|
|
21
|
+
symmetry translation and spin-flip symmetry: every bond's mean correlation
|
|
22
|
+
and every site's mean spin against the exact values
|
|
23
|
+
equilibration hot and cold starts agree from the first reported sweep
|
|
24
|
+
error_bars the code's reported error bars against the actual spread of
|
|
25
|
+
independent chains
|
|
26
|
+
|
|
27
|
+
Each check uses its own fixed seed, so the whole suite is deterministic.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from ..checks import EXACT, FAIL, INCONCLUSIVE, PASS, Check, Result
|
|
37
|
+
from . import exact as X
|
|
38
|
+
from . import reference as R
|
|
39
|
+
from .stats import ALPHA_CHECK, Comparison, compare_chain_means, t_critical, verdict
|
|
40
|
+
|
|
41
|
+
RESOLUTION = 0.02 # smallest deviation in a per-spin average (or a probability) that a statistical check must resolve
|
|
42
|
+
T_LOW, T_HIGH = 1.8, 3.0
|
|
43
|
+
T_DIST = 2.5
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _missing(sampler, *names):
|
|
47
|
+
return [n for n in names if not callable(getattr(sampler, n, None))]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _skip(names) -> Result:
|
|
51
|
+
return Result(INCONCLUSIVE, f"not available: the sampler does not provide {', '.join(names)}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- deterministic ---
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bookkeeping(sampler, *, L=4, h=0.25, n_chains=8, n_sweeps=40, seed=101) -> Check:
|
|
58
|
+
def run():
|
|
59
|
+
if _missing(sampler, "init", "sweep", "measure"):
|
|
60
|
+
return _skip(_missing(sampler, "init", "sweep", "measure"))
|
|
61
|
+
field = 0.0 if getattr(sampler, "algorithm", "") == "wolff" else h
|
|
62
|
+
rng = np.random.default_rng(seed)
|
|
63
|
+
state = sampler.init(L, n_chains, "hot", rng, field)
|
|
64
|
+
for sweep in range(1, n_sweeps + 1):
|
|
65
|
+
sampler.sweep(state, 2.5, field, rng)
|
|
66
|
+
E_code, M_code = sampler.measure(state, field)
|
|
67
|
+
for c in range(n_chains):
|
|
68
|
+
spins = state.spins[c].tolist()
|
|
69
|
+
E_ref, M_ref = R.energy(spins, field), R.magnetization(spins)
|
|
70
|
+
values = {
|
|
71
|
+
"tracked": (float(state.E[c]), float(state.M[c])),
|
|
72
|
+
"measured by the code": (float(E_code[c]), float(M_code[c])),
|
|
73
|
+
"recomputed independently": (E_ref, M_ref),
|
|
74
|
+
}
|
|
75
|
+
if len({v for v in values.values()}) > 1:
|
|
76
|
+
listing = "; ".join(f"{k} E={v[0]:g}, M={v[1]:g}" for k, v in values.items())
|
|
77
|
+
return Result(FAIL, f"after sweep {sweep}, chain {c}: {listing}")
|
|
78
|
+
return Result(EXACT, f"tracked, measured and recomputed E and M agree exactly over {n_sweeps} sweeps of {n_chains} chains")
|
|
79
|
+
|
|
80
|
+
return Check("bookkeeping", f"L = {L}, h = {h:g}", run)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RecordingRNG:
|
|
84
|
+
"""A numpy Generator that records every integers() and random() call, in order."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, seed):
|
|
87
|
+
self._g = np.random.default_rng(seed)
|
|
88
|
+
self.log = []
|
|
89
|
+
|
|
90
|
+
def integers(self, low, high=None, size=None):
|
|
91
|
+
out = self._g.integers(low, high, size=size)
|
|
92
|
+
self.log.append(("integers", np.array(out, copy=True)))
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def random(self, size=None):
|
|
96
|
+
out = self._g.random(size)
|
|
97
|
+
self.log.append(("random", np.array(out, copy=True)))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scripted_rng(sampler, *, L=4, T=2.5, h=0.25, n_chains=4, n_sweeps=10, seed=202) -> Check:
|
|
102
|
+
algorithm = getattr(sampler, "algorithm", "metropolis")
|
|
103
|
+
|
|
104
|
+
def run():
|
|
105
|
+
if algorithm not in ("metropolis", "heatbath"):
|
|
106
|
+
return Result(INCONCLUSIVE, f"no reference replay for the {algorithm} algorithm")
|
|
107
|
+
if _missing(sampler, "init", "sweep"):
|
|
108
|
+
return _skip(_missing(sampler, "init", "sweep"))
|
|
109
|
+
for field in (0.0, h): # h = 0 has moves with dE = 0, a field has none: both are needed
|
|
110
|
+
result = _replay_once(field)
|
|
111
|
+
if result is not None:
|
|
112
|
+
return result
|
|
113
|
+
return Result(EXACT, f"{n_sweeps} sweeps of {n_chains} chains at h = 0 and h = {h:g} match the reference replay exactly")
|
|
114
|
+
|
|
115
|
+
def _replay_once(h):
|
|
116
|
+
state = sampler.init(L, n_chains, "hot", np.random.default_rng(seed), h)
|
|
117
|
+
lattices = [state.spins[c].tolist() for c in range(n_chains)]
|
|
118
|
+
for sweep in range(1, n_sweeps + 1):
|
|
119
|
+
rng = RecordingRNG(seed + sweep)
|
|
120
|
+
sampler.sweep(state, T, h, rng)
|
|
121
|
+
kinds = [k for k, _ in rng.log]
|
|
122
|
+
if kinds != ["integers", "random"] * (len(kinds) // 2) or len(kinds) != 2 * L * L:
|
|
123
|
+
return Result(
|
|
124
|
+
FAIL,
|
|
125
|
+
f"h = {h:g}, sweep {sweep} drew random numbers as {kinds[:4]}... ({len(kinds)} calls); expected "
|
|
126
|
+
f"{L * L} pairs of (site, uniform) as in physverify.stochastic.reference",
|
|
127
|
+
)
|
|
128
|
+
for c in range(n_chains):
|
|
129
|
+
draws = [(int(rng.log[2 * a][1][c]), float(rng.log[2 * a + 1][1][c])) for a in range(L * L)]
|
|
130
|
+
lattices[c] = R.replay(lattices[c], draws, T, h, algorithm)
|
|
131
|
+
if state.spins[c].tolist() != lattices[c]:
|
|
132
|
+
diff = int(np.sum(np.array(lattices[c]) != state.spins[c]))
|
|
133
|
+
return Result(FAIL, f"h = {h:g}, sweep {sweep}, chain {c}: lattice differs from the reference replay at {diff} sites")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
return Check("scripted_rng", f"{algorithm} replay, L = {L}, h = 0 and {h:g}", run)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reproducibility(sampler, *, L=4, T=2.5, n_sweeps=20) -> Check:
|
|
140
|
+
def run():
|
|
141
|
+
a = sampler.sample(L, T, 0.0, n_sweeps, seed=1, n_chains=2)
|
|
142
|
+
b = sampler.sample(L, T, 0.0, n_sweeps, seed=1, n_chains=2)
|
|
143
|
+
c = sampler.sample(L, T, 0.0, n_sweeps, seed=2, n_chains=2)
|
|
144
|
+
if not all(np.array_equal(x, y) for x, y in zip(a, b)):
|
|
145
|
+
return Result(FAIL, "the same seed gave different runs")
|
|
146
|
+
if all(np.array_equal(x, y) for x, y in zip(a, c)):
|
|
147
|
+
return Result(FAIL, "different seeds gave identical runs: the seed is ignored")
|
|
148
|
+
return Result(PASS, "same seed reproduces the run; a different seed changes it")
|
|
149
|
+
|
|
150
|
+
return Check("reproducibility", "seeds", run)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- statistical ---
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _binned(series, levels, probs, min_p):
|
|
157
|
+
"""Per chain fractions for the levels with probability >= min_p, plus one bin for the rest.
|
|
158
|
+
|
|
159
|
+
Returns (names, exact probabilities, per-chain fractions). Values that are on no
|
|
160
|
+
allowed level at all (a wrong normalisation, {0, 1} spins) fall in the rest bin.
|
|
161
|
+
"""
|
|
162
|
+
keep = probs >= min_p
|
|
163
|
+
fractions = [(np.abs(series - lev) < 1e-9).mean(axis=1) for lev in levels[keep]]
|
|
164
|
+
rest = 1.0 - np.sum(fractions, axis=0)
|
|
165
|
+
names = [f"{lev:g}" for lev in levels[keep]] + ["any other value"]
|
|
166
|
+
return names, list(probs[keep]) + [float(1.0 - probs[keep].sum())], fractions + [rest]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def distribution_comparisons(e, m, L, T, min_p=0.01) -> list:
|
|
170
|
+
n_samples = e.size
|
|
171
|
+
comps = []
|
|
172
|
+
for symbol, series, (levels, probs) in (
|
|
173
|
+
("e", e, X.energy_distribution(L, T)),
|
|
174
|
+
("|m|", np.abs(m), X.abs_magnetization_distribution(L, T)),
|
|
175
|
+
):
|
|
176
|
+
for name, p, f in zip(*_binned(series, levels, probs, min_p)):
|
|
177
|
+
floor = math.sqrt(max(p, 1.0 / n_samples) * (1 - min(p, 0.5)) / n_samples) # binomial lower bound
|
|
178
|
+
comps.append(compare_chain_means(f"P({symbol} = {name})" if name[0].isdigit() or name[0] == "-" else f"P({symbol} {name})", f, p, RESOLUTION, floor))
|
|
179
|
+
return comps
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def exact_distribution(sampler, *, L=4, T=T_DIST, n_chains=512, n_sweeps=200, seed=303, alpha=ALPHA_CHECK) -> Check:
|
|
183
|
+
def run():
|
|
184
|
+
e, m = sampler.sample(L, T, 0.0, n_sweeps, seed=seed, n_chains=n_chains)
|
|
185
|
+
return verdict(distribution_comparisons(e, m, L, T), alpha)
|
|
186
|
+
|
|
187
|
+
return Check("exact_distribution", f"L = {L}, T = {T:g}", run)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def moment_comparisons(e, m, exact: dict, label: str, signed=False) -> list:
|
|
191
|
+
comps = [
|
|
192
|
+
compare_chain_means(f"<e> {label}", e.mean(axis=1), exact["e"], RESOLUTION),
|
|
193
|
+
compare_chain_means(f"<e^2> {label}", (e * e).mean(axis=1), exact["e2"], RESOLUTION * 4), # e^2 is of size 1 to 4
|
|
194
|
+
]
|
|
195
|
+
if m is not None:
|
|
196
|
+
comps += [
|
|
197
|
+
compare_chain_means(f"<|m|> {label}", np.abs(m).mean(axis=1), exact["abs_m"], RESOLUTION),
|
|
198
|
+
compare_chain_means(f"<m^2> {label}", (m * m).mean(axis=1), exact["m2"], RESOLUTION),
|
|
199
|
+
]
|
|
200
|
+
if signed:
|
|
201
|
+
comps.append(compare_chain_means(f"<m> {label}", m.mean(axis=1), exact["m"], RESOLUTION))
|
|
202
|
+
return comps
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def moments(sampler, *, n_chains=512, n_sweeps=300, seed=404, alpha=ALPHA_CHECK) -> Check:
|
|
206
|
+
with_field = getattr(sampler, "algorithm", "") != "wolff"
|
|
207
|
+
|
|
208
|
+
def run():
|
|
209
|
+
comps = []
|
|
210
|
+
for k, T in enumerate((T_LOW, X.T_C, T_HIGH)):
|
|
211
|
+
e, m = sampler.sample(4, T, 0.0, n_sweeps, seed=seed + k, n_chains=n_chains)
|
|
212
|
+
comps += moment_comparisons(e, m, X.exact_moments(4, T), f"L=4 T={T:.3g}")
|
|
213
|
+
if with_field:
|
|
214
|
+
e, m = sampler.sample(4, T_HIGH, 0.5, n_sweeps, seed=seed + 10, n_chains=n_chains)
|
|
215
|
+
comps += moment_comparisons(e, m, X.exact_moments(4, T_HIGH, 0.5), "L=4 T=3 h=0.5", signed=True)
|
|
216
|
+
e, _ = sampler.sample(8, T_HIGH, 0.0, n_sweeps // 2, seed=seed + 20, n_chains=n_chains)
|
|
217
|
+
comps += moment_comparisons(e, None, X.kaufman_moments(8, T_HIGH), "L=8 T=3 (Kaufman)")
|
|
218
|
+
return verdict(comps, alpha)
|
|
219
|
+
|
|
220
|
+
label = "L = 4 at three temperatures" + (", in a field" if with_field else "") + ", L = 8"
|
|
221
|
+
return Check("moments", label, run)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def acceptance(sampler, *, L=4, T=2.5, h=0.25, n_chains=4096, rounds=4, seed=505, alpha=ALPHA_CHECK) -> Check:
|
|
225
|
+
algorithm = getattr(sampler, "algorithm", "metropolis")
|
|
226
|
+
|
|
227
|
+
def run():
|
|
228
|
+
if algorithm not in ("metropolis", "heatbath"):
|
|
229
|
+
return Result(INCONCLUSIVE, f"single-site acceptance does not apply to the {algorithm} algorithm")
|
|
230
|
+
if _missing(sampler, "init", "step"):
|
|
231
|
+
return _skip(_missing(sampler, "init", "step"))
|
|
232
|
+
rng = np.random.default_rng(seed)
|
|
233
|
+
stats = {} # (field, class) -> (expected probability, list of outcomes)
|
|
234
|
+
for field in (0.0, h): # h = 0 has the dE = 0 class, a field does not: both are needed
|
|
235
|
+
failure = _attempts(field, rng, stats)
|
|
236
|
+
if failure is not None:
|
|
237
|
+
return failure
|
|
238
|
+
comps = []
|
|
239
|
+
what = "accept" if algorithm == "metropolis" else "up"
|
|
240
|
+
for (field, key) in sorted(stats):
|
|
241
|
+
p, outs = stats[(field, key)]
|
|
242
|
+
if len(outs) < 50:
|
|
243
|
+
continue
|
|
244
|
+
outs = np.array(outs)
|
|
245
|
+
name = f"P({what} | {'dE' if algorithm == 'metropolis' else 'local field'} = {key:g}, h = {field:g})"
|
|
246
|
+
if p >= 1.0:
|
|
247
|
+
if outs.min() < 1.0:
|
|
248
|
+
return Result(FAIL, f"{name} should be 1 but {int((outs < 1).sum())} of {len(outs)} attempts were rejected")
|
|
249
|
+
continue
|
|
250
|
+
se = math.sqrt(p * (1 - p) / len(outs)) # binomial, independent attempts
|
|
251
|
+
comps.append(Comparison(name, float(outs.mean()), se, p, 0.02, 1e7))
|
|
252
|
+
if not comps:
|
|
253
|
+
return Result(INCONCLUSIVE, "no energy class was sampled often enough")
|
|
254
|
+
return verdict(comps, alpha)
|
|
255
|
+
|
|
256
|
+
def _attempts(h, rng, stats):
|
|
257
|
+
state = sampler.init(L, n_chains, "hot", rng, h)
|
|
258
|
+
for _ in range(rounds * L * L):
|
|
259
|
+
sites = rng.integers(0, L * L, size=n_chains)
|
|
260
|
+
u = rng.random(n_chains)
|
|
261
|
+
before = state.spins.reshape(n_chains, -1)[np.arange(n_chains), sites].copy()
|
|
262
|
+
flat = state.spins.reshape(n_chains, -1)
|
|
263
|
+
sp = state.spins # periodic neighbour sum, computed here independently of the sampler
|
|
264
|
+
nn_all = np.roll(sp, 1, 1) + np.roll(sp, -1, 1) + np.roll(sp, 1, 2) + np.roll(sp, -1, 2)
|
|
265
|
+
nn = nn_all.reshape(n_chains, -1)[np.arange(n_chains), sites]
|
|
266
|
+
phi = R.J * nn + h
|
|
267
|
+
sampler.step(state, sites, u, T, h)
|
|
268
|
+
after = flat[np.arange(n_chains), sites]
|
|
269
|
+
if algorithm == "metropolis":
|
|
270
|
+
dE = 2.0 * before * phi
|
|
271
|
+
keys = np.round(dE, 6)
|
|
272
|
+
outcome = after != before
|
|
273
|
+
expected = np.minimum(1.0, np.exp(-dE / T))
|
|
274
|
+
else:
|
|
275
|
+
keys = np.round(phi, 6)
|
|
276
|
+
outcome = after > 0
|
|
277
|
+
expected = 1.0 / (1.0 + np.exp(-2.0 * phi / T))
|
|
278
|
+
for key, o, p in zip(keys, outcome, expected):
|
|
279
|
+
stats.setdefault((h, float(key)), [float(p), []])[1].append(float(o))
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
return Check("acceptance", f"{algorithm}, L = {L}, h = 0 and {h:g}", run)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def symmetry(sampler, *, L=4, T=T_HIGH, n_chains=256, n_sweeps=150, burn_in=100, seed=606, alpha=ALPHA_CHECK) -> Check:
|
|
286
|
+
def run():
|
|
287
|
+
if _missing(sampler, "init", "sweep"):
|
|
288
|
+
return _skip(_missing(sampler, "init", "sweep"))
|
|
289
|
+
rng = np.random.default_rng(seed)
|
|
290
|
+
state = sampler.init(L, n_chains, "hot", rng, 0.0)
|
|
291
|
+
for _ in range(burn_in):
|
|
292
|
+
sampler.sweep(state, T, 0.0, rng)
|
|
293
|
+
n = L * L
|
|
294
|
+
bond_sum = np.zeros((n_chains, 2, L, L))
|
|
295
|
+
site_sum = np.zeros((n_chains, L, L))
|
|
296
|
+
for _ in range(n_sweeps):
|
|
297
|
+
sampler.sweep(state, T, 0.0, rng)
|
|
298
|
+
s = state.spins
|
|
299
|
+
bond_sum[:, 0] += s * np.roll(s, -1, axis=2)
|
|
300
|
+
bond_sum[:, 1] += s * np.roll(s, -1, axis=1)
|
|
301
|
+
site_sum += s
|
|
302
|
+
ex = X.exact_moments(L, T)
|
|
303
|
+
bond_exact = -ex["e"] / 2.0 # 2N bonds, H = -sum of bond correlations
|
|
304
|
+
comps = []
|
|
305
|
+
for d, name in ((0, "right"), (1, "down")):
|
|
306
|
+
for i in range(L):
|
|
307
|
+
for j in range(L):
|
|
308
|
+
comps.append(compare_chain_means(f"<s s_{name}> at ({i},{j})", bond_sum[:, d, i, j] / n_sweeps, bond_exact, 0.05))
|
|
309
|
+
for i in range(L):
|
|
310
|
+
for j in range(L):
|
|
311
|
+
comps.append(compare_chain_means(f"<s> at ({i},{j})", site_sum[:, i, j] / n_sweeps, 0.0, 0.15))
|
|
312
|
+
return verdict(comps, alpha)
|
|
313
|
+
|
|
314
|
+
return Check("symmetry", f"bonds and sites, L = {L}, T = {T:g}", run)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def equilibration(sampler, *, L=4, T=2.0, n_chains=512, window=10, seed=707, alpha=ALPHA_CHECK) -> Check:
|
|
318
|
+
def run():
|
|
319
|
+
e_hot, _ = sampler.sample(L, T, 0.0, window, seed=seed, n_chains=n_chains, start="hot")
|
|
320
|
+
e_cold, _ = sampler.sample(L, T, 0.0, window, seed=seed + 1, n_chains=n_chains, start="cold")
|
|
321
|
+
a, b = e_hot.mean(axis=1), e_cold.mean(axis=1)
|
|
322
|
+
se = math.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))
|
|
323
|
+
comp = Comparison(f"<e> over the first {window} sweeps, hot minus cold start", float(a.mean() - b.mean()), se, 0.0, 2.5 * RESOLUTION, len(a) + len(b) - 2)
|
|
324
|
+
return verdict([comp], alpha)
|
|
325
|
+
|
|
326
|
+
return Check("equilibration", f"hot and cold starts, L = {L}, T = {T:g}", run)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def error_bars(sampler, *, L=4, T=None, n_chains=128, n_sweeps=1000, seed=808, alpha=ALPHA_CHECK) -> Check:
|
|
330
|
+
T = X.T_C if T is None else T
|
|
331
|
+
|
|
332
|
+
def run():
|
|
333
|
+
if _missing(sampler, "error_bar"):
|
|
334
|
+
return _skip(["error_bar"])
|
|
335
|
+
e, _ = sampler.sample(L, T, 0.0, n_sweeps, seed=seed, n_chains=n_chains)
|
|
336
|
+
reported = np.array([sampler.error_bar(e[c]) for c in range(n_chains)])
|
|
337
|
+
observed = float(e.mean(axis=1).std(ddof=1))
|
|
338
|
+
rms = float(np.sqrt(np.mean(reported**2)))
|
|
339
|
+
if not (np.isfinite(rms) and rms > 0):
|
|
340
|
+
return Result(FAIL, f"reported error bars are not positive and finite (rms {rms})")
|
|
341
|
+
# log of a sample standard deviation has standard error about 1 / sqrt(2 (n - 1))
|
|
342
|
+
comp = Comparison("log(observed spread / reported error bar)", math.log(observed / rms), 1.0 / math.sqrt(2 * (n_chains - 1)), 0.0, 0.3, 1e7)
|
|
343
|
+
result = verdict([comp], alpha)
|
|
344
|
+
result.message = f"observed spread of chain means {observed:.3g}, rms reported error bar {rms:.3g}. " + result.message
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
return Check("error_bars", f"L = {L}, T = {T:.4g}", run)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
ALL = (bookkeeping, scripted_rng, reproducibility, exact_distribution, moments, acceptance, symmetry, equilibration, error_bars)
|
|
351
|
+
FAMILIES = tuple(f.__name__ for f in ALL)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
SINGLE_SITE = ("metropolis", "heatbath")
|
|
355
|
+
NEEDS = {
|
|
356
|
+
"bookkeeping": ("init", "sweep", "measure"),
|
|
357
|
+
"scripted_rng": ("init", "sweep"),
|
|
358
|
+
"acceptance": ("init", "step"),
|
|
359
|
+
"symmetry": ("init", "sweep"),
|
|
360
|
+
"error_bars": ("error_bar",),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def applicable(sampler, family: str) -> bool:
|
|
365
|
+
"""False for a check the sampler cannot support: missing methods, or not a single-site algorithm."""
|
|
366
|
+
if family in ("scripted_rng", "acceptance") and getattr(sampler, "algorithm", "") not in SINGLE_SITE:
|
|
367
|
+
return False
|
|
368
|
+
return not _missing(sampler, *NEEDS.get(family, ()))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def ising_checks(sampler, **overrides) -> list[Check]:
|
|
372
|
+
"""The checks that apply to this sampler (see `applicable`).
|
|
373
|
+
|
|
374
|
+
`overrides` maps a check name to keyword arguments for it, for example
|
|
375
|
+
{"moments": {"n_chains": 128}} for a slow sampler. Called directly, a check
|
|
376
|
+
that does not apply reports INCONCLUSIVE instead.
|
|
377
|
+
"""
|
|
378
|
+
return [f(sampler, **overrides.get(f.__name__, {})) for f in ALL if applicable(sampler, f.__name__)]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Exact reference values for the 2D Ising model on an L x L torus.
|
|
2
|
+
|
|
3
|
+
Convention throughout: H = -J sum_<ij> s_i s_j - h sum_i s_i with s = +1 or -1,
|
|
4
|
+
periodic boundaries, k_B = 1. The bond sum runs over the right and the down
|
|
5
|
+
neighbour of every site, 2 L^2 bonds (for L = 2 each pair is counted twice,
|
|
6
|
+
which is what a code looping over right and down neighbours does).
|
|
7
|
+
|
|
8
|
+
Three independent routes, cross-checked in the tests (milestone M0 of
|
|
9
|
+
docs/plan_ising.md):
|
|
10
|
+
|
|
11
|
+
enumeration every state for L <= 5: the exact joint density of states g(E, M)
|
|
12
|
+
kaufman Kaufman's closed form for the zero-field partition function of a
|
|
13
|
+
finite torus (as written by Beale, PRL 76, 78 (1996))
|
|
14
|
+
onsager the infinite-lattice energy, critical temperature and Yang's
|
|
15
|
+
spontaneous magnetization
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
J = 1.0
|
|
26
|
+
T_C = 2.0 / math.log(1.0 + math.sqrt(2.0)) # Onsager, about 2.2692
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- enumeration ---
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache(maxsize=None)
|
|
33
|
+
def density_of_states(L: int):
|
|
34
|
+
"""Exact counts g[(E_bond + 2N) / 2, (M + N) / 2] for J = 1, as an integer array.
|
|
35
|
+
|
|
36
|
+
E_bond = -sum over the 2N right/down bonds of s_a s_b; M = sum of spins.
|
|
37
|
+
L = 5 has 2^25 states and takes several seconds.
|
|
38
|
+
"""
|
|
39
|
+
if not 1 <= L <= 5:
|
|
40
|
+
raise ValueError("enumeration is limited to L <= 5")
|
|
41
|
+
n = L * L
|
|
42
|
+
bonds = []
|
|
43
|
+
for i in range(L):
|
|
44
|
+
for j in range(L):
|
|
45
|
+
k = i * L + j
|
|
46
|
+
bonds.append((k, i * L + (j + 1) % L))
|
|
47
|
+
bonds.append((k, ((i + 1) % L) * L + j))
|
|
48
|
+
counts = np.zeros((2 * n + 1, n + 1), dtype=np.int64)
|
|
49
|
+
chunk = 1 << min(n, 22)
|
|
50
|
+
for start in range(0, 1 << n, chunk):
|
|
51
|
+
x = np.arange(start, start + chunk, dtype=np.uint64)
|
|
52
|
+
up = np.zeros(chunk, dtype=np.int64)
|
|
53
|
+
for k in range(n):
|
|
54
|
+
up += ((x >> np.uint64(k)) & np.uint64(1)).astype(np.int64)
|
|
55
|
+
unlike = np.zeros(chunk, dtype=np.int64)
|
|
56
|
+
for a, b in bonds:
|
|
57
|
+
unlike += (((x >> np.uint64(a)) ^ (x >> np.uint64(b))) & np.uint64(1)).astype(np.int64)
|
|
58
|
+
energy = -(2 * n - 2 * unlike) # each like bond -1, each unlike bond +1
|
|
59
|
+
magnet = 2 * up - n
|
|
60
|
+
idx = ((energy + 2 * n) // 2) * (n + 1) + (magnet + n) // 2
|
|
61
|
+
counts += np.bincount(idx, minlength=counts.size).reshape(counts.shape)
|
|
62
|
+
return counts
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _levels(L):
|
|
66
|
+
n = L * L
|
|
67
|
+
energies = np.arange(-2 * n, 2 * n + 1, 2, dtype=float)
|
|
68
|
+
magnets = np.arange(-n, n + 1, 2, dtype=float)
|
|
69
|
+
return energies, magnets
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def boltzmann_weights(L: int, T: float, h: float = 0.0):
|
|
73
|
+
"""Normalised probabilities P(E_bond, M) on the (energy, magnetization) grid."""
|
|
74
|
+
g = density_of_states(L).astype(float)
|
|
75
|
+
energies, magnets = _levels(L)
|
|
76
|
+
H = J * energies[:, None] - h * magnets[None, :]
|
|
77
|
+
with np.errstate(divide="ignore"):
|
|
78
|
+
logw = np.where(g > 0, np.log(g) - H / T, -np.inf)
|
|
79
|
+
logw -= logw.max()
|
|
80
|
+
w = np.exp(logw)
|
|
81
|
+
return w / w.sum()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def exact_moments(L: int, T: float, h: float = 0.0) -> dict:
|
|
85
|
+
"""Exact per-spin averages from enumeration. e is the total energy H / N (field term included)."""
|
|
86
|
+
n = L * L
|
|
87
|
+
P = boltzmann_weights(L, T, h)
|
|
88
|
+
energies, magnets = _levels(L)
|
|
89
|
+
e = (J * energies[:, None] - h * magnets[None, :]) / n
|
|
90
|
+
m = np.broadcast_to(magnets[None, :] / n, P.shape)
|
|
91
|
+
return {
|
|
92
|
+
"e": float((P * e).sum()),
|
|
93
|
+
"e2": float((P * e * e).sum()),
|
|
94
|
+
"m": float((P * m).sum()),
|
|
95
|
+
"abs_m": float((P * np.abs(m)).sum()),
|
|
96
|
+
"m2": float((P * m * m).sum()),
|
|
97
|
+
"m4": float((P * m**4).sum()),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def energy_distribution(L: int, T: float, h: float = 0.0):
|
|
102
|
+
"""(levels of e = H / N at h = 0, probabilities). Only valid for h = 0, where H depends on E alone."""
|
|
103
|
+
if h != 0.0:
|
|
104
|
+
raise ValueError("the energy distribution is tabulated for h = 0 only")
|
|
105
|
+
energies, _ = _levels(L)
|
|
106
|
+
p = boltzmann_weights(L, T).sum(axis=1)
|
|
107
|
+
keep = p > 0
|
|
108
|
+
return J * energies[keep] / (L * L), p[keep]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def abs_magnetization_distribution(L: int, T: float, h: float = 0.0):
|
|
112
|
+
"""(levels of |m|, probabilities)."""
|
|
113
|
+
_, magnets = _levels(L)
|
|
114
|
+
p = boltzmann_weights(L, T, h).sum(axis=0)
|
|
115
|
+
levels = np.abs(magnets) / (L * L)
|
|
116
|
+
out = {}
|
|
117
|
+
for lev, prob in zip(levels, p):
|
|
118
|
+
out[round(float(lev), 12)] = out.get(round(float(lev), 12), 0.0) + float(prob)
|
|
119
|
+
keys = sorted(out)
|
|
120
|
+
return np.array(keys), np.array([out[k] for k in keys])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- Kaufman's finite-torus partition function (h = 0) ---
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _log_abs_and_sign(values):
|
|
127
|
+
values = np.asarray(values, dtype=float)
|
|
128
|
+
sign = float(np.prod(np.sign(values)))
|
|
129
|
+
with np.errstate(divide="ignore"):
|
|
130
|
+
return float(np.sum(np.log(np.abs(values)))), sign
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def kaufman_log_z(L: int, T: float, M: int | None = None) -> float:
|
|
134
|
+
"""log Z of the zero-field Ising model on an L x M torus (M = L by default), J = 1.
|
|
135
|
+
|
|
136
|
+
Z = 1/2 (2 sinh 2K)^{LM/2} [Z1 + Z2 + Z3 + Z4],
|
|
137
|
+
Z1 = prod_{r<L} 2 cosh(M g_{2r+1} / 2), Z2 = prod 2 sinh(M g_{2r+1} / 2),
|
|
138
|
+
Z3 = prod_{r<L} 2 cosh(M g_{2r} / 2), Z4 = prod 2 sinh(M g_{2r} / 2),
|
|
139
|
+
cosh g_l = cosh 2K coth 2K - cos(pi l / L) for l >= 1, and g_0 = 2K + ln tanh K
|
|
140
|
+
(negative below T_c, which makes Z4 negative).
|
|
141
|
+
"""
|
|
142
|
+
M = L if M is None else M
|
|
143
|
+
K = J / T
|
|
144
|
+
c = math.cosh(2 * K) / math.tanh(2 * K)
|
|
145
|
+
gam = np.empty(2 * L)
|
|
146
|
+
gam[0] = 2 * K + math.log(math.tanh(K))
|
|
147
|
+
for l in range(1, 2 * L):
|
|
148
|
+
gam[l] = math.acosh(c - math.cos(math.pi * l / L))
|
|
149
|
+
odd, even = gam[1::2], gam[0::2]
|
|
150
|
+
terms = [
|
|
151
|
+
_log_abs_and_sign(2 * np.cosh(M * odd / 2)),
|
|
152
|
+
_log_abs_and_sign(2 * np.sinh(M * odd / 2)),
|
|
153
|
+
_log_abs_and_sign(2 * np.cosh(M * even / 2)),
|
|
154
|
+
_log_abs_and_sign(2 * np.sinh(M * even / 2)),
|
|
155
|
+
]
|
|
156
|
+
top = max(t[0] for t in terms if t[1] != 0)
|
|
157
|
+
total = sum(s * math.exp(lg - top) for lg, s in terms if s != 0)
|
|
158
|
+
return math.log(0.5) + 0.5 * L * M * math.log(2 * math.sinh(2 * K)) + top + math.log(total)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def kaufman_moments(L: int, T: float) -> dict:
|
|
162
|
+
"""Per-spin energy e and e^2 at h = 0 from derivatives of log Z in beta (central differences)."""
|
|
163
|
+
n = L * L
|
|
164
|
+
beta = 1.0 / T
|
|
165
|
+
d = 1e-4 * beta
|
|
166
|
+
|
|
167
|
+
def logz(b):
|
|
168
|
+
return kaufman_log_z(L, 1.0 / b)
|
|
169
|
+
|
|
170
|
+
f0, fp, fm = logz(beta), logz(beta + d), logz(beta - d)
|
|
171
|
+
fpp, fmm = logz(beta + 2 * d), logz(beta - 2 * d)
|
|
172
|
+
d1 = (-fpp + 8 * fp - 8 * fm + fmm) / (12 * d)
|
|
173
|
+
d2 = (-fpp + 16 * fp - 30 * f0 + 16 * fm - fmm) / (12 * d * d)
|
|
174
|
+
mean_E = -d1
|
|
175
|
+
var_E = d2
|
|
176
|
+
return {"e": mean_E / n, "e2": (var_E + mean_E**2) / n**2}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Onsager / Yang, infinite lattice ---
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ellipk(k: float) -> float:
|
|
183
|
+
"""Complete elliptic integral of the first kind K(k), modulus k < 1, by the AGM."""
|
|
184
|
+
a, b = 1.0, math.sqrt(1.0 - k * k)
|
|
185
|
+
for _ in range(60):
|
|
186
|
+
a, b = 0.5 * (a + b), math.sqrt(a * b)
|
|
187
|
+
if abs(a - b) < 1e-16 * a:
|
|
188
|
+
break
|
|
189
|
+
return math.pi / (2.0 * a)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def onsager_energy(T: float) -> float:
|
|
193
|
+
"""Energy per spin of the infinite lattice at h = 0."""
|
|
194
|
+
K = J / T
|
|
195
|
+
if abs(T - T_C) < 1e-12:
|
|
196
|
+
return -math.sqrt(2.0) * J
|
|
197
|
+
k = 2.0 * math.sinh(2 * K) / math.cosh(2 * K) ** 2
|
|
198
|
+
return -J / math.tanh(2 * K) * (1.0 + 2.0 / math.pi * (2.0 * math.tanh(2 * K) ** 2 - 1.0) * _ellipk(k))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def yang_magnetization(T: float) -> float:
|
|
202
|
+
"""Spontaneous magnetization per spin of the infinite lattice (0 above T_c)."""
|
|
203
|
+
if T >= T_C:
|
|
204
|
+
return 0.0
|
|
205
|
+
return (1.0 - math.sinh(2 * J / T) ** -4) ** 0.125
|