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,170 @@
|
|
|
1
|
+
"""Seeded-bug benchmark for Ising samplers (docs/plan_ising.md, section 5).
|
|
2
|
+
|
|
3
|
+
Each bug lives in one algorithm of physverify.stochastic.sampler.IsingSampler.
|
|
4
|
+
The predicted checks and the expected symptom were written down before any
|
|
5
|
+
bug was run. Unlike the deterministic benchmark, the test suite does not force
|
|
6
|
+
the predictions to come true: results/ising_matrix.md records predicted and
|
|
7
|
+
observed side by side, and wrong predictions are reported as such.
|
|
8
|
+
|
|
9
|
+
The equivalent mutant `reject_zero_de` is a negative control: it changes the
|
|
10
|
+
dynamics but keeps the Boltzmann distribution, so the distribution checks
|
|
11
|
+
should not flag it and the dynamics checks should.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from . import checks as K
|
|
19
|
+
from .sampler import IsingSampler
|
|
20
|
+
|
|
21
|
+
NAME = "ising"
|
|
22
|
+
VARIANTS = ("metropolis", "heatbath", "checkerboard", "wolff")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class IsingBug:
|
|
27
|
+
algorithm: str
|
|
28
|
+
description: str
|
|
29
|
+
predicted: frozenset
|
|
30
|
+
symptom: str
|
|
31
|
+
negative_control: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _f(*names):
|
|
35
|
+
return frozenset(names)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
BUGS = {
|
|
39
|
+
"j_sign_flipped": IsingBug(
|
|
40
|
+
"metropolis", "coupling J = -1 (antiferromagnet) in the dynamics and the measurement",
|
|
41
|
+
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
42
|
+
"on an even lattice the energy distribution is unchanged (sublattice flip maps AF to FM), "
|
|
43
|
+
"but |m| collapses and bond correlations change sign",
|
|
44
|
+
),
|
|
45
|
+
"delta_e_missing_factor_2": IsingBug(
|
|
46
|
+
"metropolis", "energy change of a flip computed as s * phi instead of 2 s * phi",
|
|
47
|
+
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
48
|
+
"samples at twice the temperature; the tracked energy drifts from the lattice",
|
|
49
|
+
),
|
|
50
|
+
"exp_de_times_t": IsingBug(
|
|
51
|
+
"metropolis", "acceptance exp(-dE * T) instead of exp(-dE / T)",
|
|
52
|
+
_f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
53
|
+
"samples at temperature 1 / T^2",
|
|
54
|
+
),
|
|
55
|
+
"missing_wrap_x": IsingBug(
|
|
56
|
+
"metropolis", "no periodic wrap in the x direction, in the dynamics and the measurement",
|
|
57
|
+
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
58
|
+
"a cylinder instead of a torus: wrap bonds uncorrelated, energies shifted",
|
|
59
|
+
),
|
|
60
|
+
"same_neighbour_twice": IsingBug(
|
|
61
|
+
"metropolis", "the neighbour below is used twice and the one above not at all (dynamics only)",
|
|
62
|
+
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
63
|
+
"wrong local fields: wrong distribution, tracked energy drifts",
|
|
64
|
+
),
|
|
65
|
+
"energy_double_counted": IsingBug(
|
|
66
|
+
"metropolis", "energy measurement counts every bond twice (dynamics right)",
|
|
67
|
+
_f("bookkeeping", "exact_distribution", "moments"),
|
|
68
|
+
"reported energies doubled; the lattice evolution is correct",
|
|
69
|
+
),
|
|
70
|
+
"m_normalised_by_L": IsingBug(
|
|
71
|
+
"metropolis", "magnetization per spin divides by L instead of L^2 (reporting only)",
|
|
72
|
+
_f("exact_distribution", "moments"),
|
|
73
|
+
"reported m is L times too large; nothing else changes",
|
|
74
|
+
),
|
|
75
|
+
"sweep_skips_last_row": IsingBug(
|
|
76
|
+
"metropolis", "random sites drawn from the first L - 1 rows only",
|
|
77
|
+
_f("exact_distribution", "moments", "symmetry"),
|
|
78
|
+
"the last row never changes; replay with the same site draws agrees",
|
|
79
|
+
),
|
|
80
|
+
"same_random_number": IsingBug(
|
|
81
|
+
"metropolis", "one uniform used both to choose the site (int(u N)) and to accept",
|
|
82
|
+
_f("scripted_rng", "exact_distribution", "moments", "symmetry"),
|
|
83
|
+
"site-dependent acceptance: sites with high index rarely flip uphill; detailed balance broken",
|
|
84
|
+
),
|
|
85
|
+
"seed_ignored": IsingBug(
|
|
86
|
+
"metropolis", "the generator is always seeded with the same constant",
|
|
87
|
+
_f("reproducibility"),
|
|
88
|
+
"every seed gives the same run; each run is still a correct sample",
|
|
89
|
+
),
|
|
90
|
+
"acceptance_table_off_by_one": IsingBug(
|
|
91
|
+
"metropolis", "acceptance looked up one energy class too high (exp(-(dE + 4) / T))",
|
|
92
|
+
_f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
93
|
+
"uphill moves accepted too rarely; the lattice is too ordered",
|
|
94
|
+
),
|
|
95
|
+
"incremental_energy_not_updated": IsingBug(
|
|
96
|
+
"metropolis", "the tracked energy is not updated after a flip",
|
|
97
|
+
_f("bookkeeping"),
|
|
98
|
+
"only the tracked energy is wrong; reported (measured) values are right",
|
|
99
|
+
),
|
|
100
|
+
"no_burn_in": IsingBug(
|
|
101
|
+
"metropolis", "no burn-in: measurement starts from the initial lattice",
|
|
102
|
+
_f("equilibration"),
|
|
103
|
+
"hot and cold starts disagree over the first sweeps; long averages are barely biased",
|
|
104
|
+
),
|
|
105
|
+
"naive_error_bars": IsingBug(
|
|
106
|
+
"metropolis", "error bar of the mean computed as std / sqrt(n), ignoring autocorrelation",
|
|
107
|
+
_f("error_bars"),
|
|
108
|
+
"reported error bars too small by about sqrt(2 tau)",
|
|
109
|
+
),
|
|
110
|
+
"field_sign_wrong": IsingBug(
|
|
111
|
+
"metropolis", "the field enters the dynamics with the wrong sign (measurement right)",
|
|
112
|
+
_f("bookkeeping", "scripted_rng", "moments", "acceptance"),
|
|
113
|
+
"invisible at h = 0; in a field m has the wrong sign",
|
|
114
|
+
),
|
|
115
|
+
"spins_zero_one": IsingBug(
|
|
116
|
+
"metropolis", "spins stored as {0, 1} and used in the +-1 formulas",
|
|
117
|
+
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
118
|
+
"a different model: energies and magnetizations off the allowed levels",
|
|
119
|
+
),
|
|
120
|
+
"reject_zero_de": IsingBug(
|
|
121
|
+
"metropolis", "moves with dE = 0 are rejected instead of accepted (equivalent mutant)",
|
|
122
|
+
_f("scripted_rng", "acceptance"),
|
|
123
|
+
"same stationary distribution, different dynamics: only the dynamics checks should see it",
|
|
124
|
+
negative_control=True,
|
|
125
|
+
),
|
|
126
|
+
"heatbath_missing_2": IsingBug(
|
|
127
|
+
"heatbath", "heat-bath probability 1 / (1 + exp(-phi / T)) instead of exp(-2 phi / T)",
|
|
128
|
+
_f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
129
|
+
"samples at twice the temperature",
|
|
130
|
+
),
|
|
131
|
+
"synchronous_update": IsingBug(
|
|
132
|
+
"checkerboard", "all sites updated at once from the old lattice instead of two sublattices",
|
|
133
|
+
_f("bookkeeping", "exact_distribution", "moments"),
|
|
134
|
+
"neighbours flip together; wrong distribution, tracked energy wrong",
|
|
135
|
+
),
|
|
136
|
+
"checkerboard_wrong_parity": IsingBug(
|
|
137
|
+
"checkerboard", "both half-sweeps update the even sublattice",
|
|
138
|
+
_f("exact_distribution", "moments", "symmetry"),
|
|
139
|
+
"the odd sublattice never changes",
|
|
140
|
+
),
|
|
141
|
+
"wolff_probability": IsingBug(
|
|
142
|
+
"wolff", "cluster bond probability 1 - exp(-J / T) instead of 1 - exp(-2 J / T)",
|
|
143
|
+
_f("exact_distribution", "moments"),
|
|
144
|
+
"clusters too small; samples a different distribution",
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def variant_checks(sampler) -> list:
|
|
150
|
+
"""The checks that apply to one sampler, with run lengths suited to its speed."""
|
|
151
|
+
if sampler.algorithm == "wolff": # one cluster update per 'sweep', looped in Python per chain
|
|
152
|
+
return K.ising_checks(
|
|
153
|
+
sampler,
|
|
154
|
+
exact_distribution={"n_chains": 128, "n_sweeps": 600},
|
|
155
|
+
moments={"n_chains": 128, "n_sweeps": 800},
|
|
156
|
+
symmetry={"n_chains": 128},
|
|
157
|
+
equilibration={"n_chains": 256},
|
|
158
|
+
error_bars={"n_chains": 64, "n_sweeps": 600},
|
|
159
|
+
)
|
|
160
|
+
return K.ising_checks(sampler)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def checks(bugs=frozenset(), algorithm=None):
|
|
164
|
+
"""Checks for the sampler with the given bugs (all in one algorithm), or a bug-free variant."""
|
|
165
|
+
bugs = frozenset(bugs)
|
|
166
|
+
algorithms = {BUGS[b].algorithm for b in bugs}
|
|
167
|
+
if len(algorithms) > 1:
|
|
168
|
+
raise ValueError("bugs from different algorithms")
|
|
169
|
+
algorithm = algorithms.pop() if algorithms else (algorithm or "metropolis")
|
|
170
|
+
return variant_checks(IsingSampler(algorithm, bugs))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Run every Ising check on every seeded bug; write the table, the prediction scorecard and the messages.
|
|
2
|
+
|
|
3
|
+
python -m physverify.stochastic.matrix results/ising_matrix.md
|
|
4
|
+
|
|
5
|
+
A check "flags" a bug when it returns FAIL or ERROR. INCONCLUSIVE is shown as
|
|
6
|
+
such and is neither a catch nor a pass. Takes several minutes (plain NumPy).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..checks import ERROR, FAIL, INCONCLUSIVE, run_check
|
|
16
|
+
from . import ising
|
|
17
|
+
from .checks import FAMILIES
|
|
18
|
+
|
|
19
|
+
SHORT = {"PASS": "ok", "EXACT": "ok", FAIL: "FAIL", ERROR: "ERROR", INCONCLUSIVE: "inconcl."}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_row(bug=None, algorithm=None):
|
|
23
|
+
bugs = frozenset([bug]) if bug else frozenset()
|
|
24
|
+
out = []
|
|
25
|
+
for check in ising.checks(bugs, algorithm):
|
|
26
|
+
start = time.perf_counter()
|
|
27
|
+
result = run_check(check)
|
|
28
|
+
out.append((check, result, time.perf_counter() - start))
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_all(progress=False):
|
|
33
|
+
rows = []
|
|
34
|
+
for alg in ising.VARIANTS:
|
|
35
|
+
rows.append((alg, None, run_row(None, alg)))
|
|
36
|
+
if progress:
|
|
37
|
+
print(f"baseline {alg} done", file=sys.stderr)
|
|
38
|
+
for bug, info in ising.BUGS.items():
|
|
39
|
+
rows.append((info.algorithm, bug, run_row(bug)))
|
|
40
|
+
if progress:
|
|
41
|
+
print(f"{bug} done", file=sys.stderr)
|
|
42
|
+
return rows
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def flagged(results):
|
|
46
|
+
return {c.family for c, r, _ in results if r.status in (FAIL, ERROR)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_markdown(rows) -> str:
|
|
50
|
+
head = "| Algorithm | Bug | " + " | ".join(FAMILIES) + " |\n|---|---|" + "---|" * len(FAMILIES) + "\n"
|
|
51
|
+
lines = []
|
|
52
|
+
for alg, bug, results in rows:
|
|
53
|
+
by_family = {c.family: r for c, r, _ in results}
|
|
54
|
+
predicted = ising.BUGS[bug].predicted if bug else frozenset()
|
|
55
|
+
cells = []
|
|
56
|
+
for fam in FAMILIES:
|
|
57
|
+
r = by_family.get(fam)
|
|
58
|
+
cell = SHORT.get(r.status, r.status) if r else "n/a"
|
|
59
|
+
if bug and fam in predicted:
|
|
60
|
+
cell += " (p)"
|
|
61
|
+
cells.append(cell)
|
|
62
|
+
lines.append(f"| {alg} | {bug or '(none)'} | " + " | ".join(cells) + " |")
|
|
63
|
+
bug_rows = [r for r in rows if r[1]]
|
|
64
|
+
controls = [r for r in bug_rows if ising.BUGS[r[1]].negative_control]
|
|
65
|
+
real = [r for r in bug_rows if not ising.BUGS[r[1]].negative_control]
|
|
66
|
+
caught = [r for r in real if flagged(r[2])]
|
|
67
|
+
baseline_alarms = [r for r in rows if not r[1] and flagged(r[2])]
|
|
68
|
+
baseline_inconclusive = sum(1 for r in rows if not r[1] for _, res, _ in r[2] if res.status == INCONCLUSIVE)
|
|
69
|
+
hit = miss = surprise = 0
|
|
70
|
+
for _, bug, results in bug_rows:
|
|
71
|
+
p, f = ising.BUGS[bug].predicted, flagged(results)
|
|
72
|
+
hit += len(p & f)
|
|
73
|
+
miss += len(p - f)
|
|
74
|
+
surprise += len(f - p)
|
|
75
|
+
summary = [
|
|
76
|
+
"",
|
|
77
|
+
"(p) marks a check predicted, before running, to flag the bug. ok = PASS or EXACT; inconcl. = INCONCLUSIVE; "
|
|
78
|
+
"n/a = the check does not apply to the algorithm (scripted_rng and acceptance need single-site updates).",
|
|
79
|
+
"",
|
|
80
|
+
f"Seeded bugs: {len(real)}, plus {len(controls)} equivalent mutant (negative control). "
|
|
81
|
+
f"Flagged by at least one check: {len(caught)} of {len(real)}"
|
|
82
|
+
+ (" (missed: " + ", ".join(r[1] for r in real if not flagged(r[2])) + ")" if len(caught) < len(real) else "")
|
|
83
|
+
+ ".",
|
|
84
|
+
f"Bug-free baselines with a FAIL: {len(baseline_alarms)} of {len(rows) - len(bug_rows)}; "
|
|
85
|
+
f"statistical INCONCLUSIVE on baselines: {baseline_inconclusive}.",
|
|
86
|
+
f"Predictions: {hit} predicted detections happened, {miss} predicted detections did not, "
|
|
87
|
+
f"{surprise} detections were not predicted (details in the scorecard).",
|
|
88
|
+
"Each statistical check has a false-alarm rate of at most 0.001 (Bonferroni within the check); "
|
|
89
|
+
"with 6 statistical checks per sampler the rate per sampler is at most 0.006.",
|
|
90
|
+
]
|
|
91
|
+
return head + "\n".join(lines) + "\n" + "\n".join(summary) + "\n"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scorecard(rows) -> str:
|
|
95
|
+
out = [
|
|
96
|
+
"# Ising seeded bugs: predictions against outcomes",
|
|
97
|
+
"",
|
|
98
|
+
"Predictions and symptoms were committed (src/physverify/stochastic/ising.py, commit 0297ffe) before any",
|
|
99
|
+
"bug was run, and have not been changed since.",
|
|
100
|
+
"",
|
|
101
|
+
"One check was changed after a result was seen. `scripted_rng` and `acceptance` first ran only in a field",
|
|
102
|
+
"(h = 0.25), where no single flip has dE = 0, so the equivalent mutant `reject_zero_de` was invisible to",
|
|
103
|
+
"both: a unit test showed this before the first full matrix run. Both checks now also run at h = 0. Their",
|
|
104
|
+
"detection of `reject_zero_de` below is therefore not a blind result.",
|
|
105
|
+
"",
|
|
106
|
+
]
|
|
107
|
+
for alg, bug, results in rows:
|
|
108
|
+
if not bug:
|
|
109
|
+
continue
|
|
110
|
+
info = ising.BUGS[bug]
|
|
111
|
+
f = flagged(results)
|
|
112
|
+
out += [
|
|
113
|
+
f"## {bug} ({alg}){' — negative control' if info.negative_control else ''}",
|
|
114
|
+
"",
|
|
115
|
+
f"- Change: {info.description}",
|
|
116
|
+
f"- Expected symptom: {info.symptom}",
|
|
117
|
+
f"- Predicted: {', '.join(sorted(info.predicted)) or 'nothing'}",
|
|
118
|
+
f"- Flagged: {', '.join(sorted(f)) or 'nothing'}",
|
|
119
|
+
f"- Predicted but not flagged: {', '.join(sorted(info.predicted - f)) or '-'}",
|
|
120
|
+
f"- Flagged but not predicted: {', '.join(sorted(f - info.predicted)) or '-'}",
|
|
121
|
+
"",
|
|
122
|
+
]
|
|
123
|
+
return "\n".join(out)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def details(rows) -> str:
|
|
127
|
+
out = []
|
|
128
|
+
for alg, bug, results in rows:
|
|
129
|
+
out.append(f"### {alg} / {bug or '(baseline)'}")
|
|
130
|
+
for c, r, secs in results:
|
|
131
|
+
out.append(f"- {c.family} [{c.label}] {r.status} ({secs:.1f} s): {r.message}")
|
|
132
|
+
out.append("")
|
|
133
|
+
return "\n".join(out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv=None) -> int:
|
|
137
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
138
|
+
rows = run_all(progress=True)
|
|
139
|
+
text = to_markdown(rows)
|
|
140
|
+
print(text)
|
|
141
|
+
if argv:
|
|
142
|
+
path = Path(argv[0])
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
path.write_text(text, encoding="utf-8")
|
|
145
|
+
path.with_name("ising_scorecard.md").write_text(scorecard(rows), encoding="utf-8")
|
|
146
|
+
path.with_name("ising_details.md").write_text(details(rows), encoding="utf-8")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""A trusted reference: one Ising chain in plain Python, written for clarity, not speed.
|
|
2
|
+
|
|
3
|
+
Used to replay a recorded random-number stream (the `scripted_rng` check) and as
|
|
4
|
+
the independent energy and magnetization functions for `bookkeeping`. The
|
|
5
|
+
vectorised sampler in `physverify.stochastic.sampler` must reproduce it exactly
|
|
6
|
+
when given the same random numbers; a test checks that.
|
|
7
|
+
|
|
8
|
+
Random-number protocol for single-site updates (Metropolis and heat bath): per
|
|
9
|
+
attempt, one integer site index k in [0, L*L) (row-major: i = k // L, j = k % L),
|
|
10
|
+
then one uniform u in [0, 1).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
J = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def neighbour_sum(spins, i, j):
|
|
21
|
+
L = len(spins)
|
|
22
|
+
return spins[(i + 1) % L][j] + spins[(i - 1) % L][j] + spins[i][(j + 1) % L] + spins[i][(j - 1) % L]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def energy(spins, h=0.0):
|
|
26
|
+
"""H = -J sum over right and down bonds of s_i s_j - h sum s_i."""
|
|
27
|
+
L = len(spins)
|
|
28
|
+
bonds = 0.0
|
|
29
|
+
for i in range(L):
|
|
30
|
+
for j in range(L):
|
|
31
|
+
bonds += spins[i][j] * (spins[i][(j + 1) % L] + spins[(i + 1) % L][j])
|
|
32
|
+
return -J * bonds - h * magnetization(spins)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def magnetization(spins):
|
|
36
|
+
return float(sum(sum(row) for row in spins))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def metropolis_attempt(spins, k, u, T, h=0.0):
|
|
40
|
+
"""One Metropolis attempt at site k with uniform u; flips in place. Returns True if accepted."""
|
|
41
|
+
L = len(spins)
|
|
42
|
+
i, j = divmod(int(k), L)
|
|
43
|
+
s = spins[i][j]
|
|
44
|
+
dE = 2.0 * s * (J * neighbour_sum(spins, i, j) + h)
|
|
45
|
+
if dE <= 0.0 or u < math.exp(-dE / T):
|
|
46
|
+
spins[i][j] = -s
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def heat_bath_attempt(spins, k, u, T, h=0.0):
|
|
52
|
+
"""One heat-bath update at site k: the spin is set up with probability 1 / (1 + exp(-2 phi / T))."""
|
|
53
|
+
L = len(spins)
|
|
54
|
+
i, j = divmod(int(k), L)
|
|
55
|
+
phi = J * neighbour_sum(spins, i, j) + h
|
|
56
|
+
p_up = 1.0 / (1.0 + math.exp(-2.0 * phi / T))
|
|
57
|
+
spins[i][j] = 1 if u < p_up else -1
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def replay(spins, draws, T, h, algorithm="metropolis"):
|
|
62
|
+
"""Apply a list of (site, u) draws in order; returns the final lattice (a copy)."""
|
|
63
|
+
spins = [list(row) for row in spins]
|
|
64
|
+
attempt = metropolis_attempt if algorithm == "metropolis" else heat_bath_attempt
|
|
65
|
+
for k, u in draws:
|
|
66
|
+
attempt(spins, k, u, T, h)
|
|
67
|
+
return spins
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The Ising sampler interface, and a vectorised sampler with switchable seeded bugs.
|
|
2
|
+
|
|
3
|
+
Interface a code under test provides (see docs/ising.md):
|
|
4
|
+
|
|
5
|
+
black box
|
|
6
|
+
sample(L, T, h, n_sweeps, seed, n_chains=1, start="hot") -> (e, m)
|
|
7
|
+
per-spin energy H / N and magnetization M / N after every sweep, arrays of
|
|
8
|
+
shape (n_chains, n_sweeps), after the code's own burn-in
|
|
9
|
+
error_bar(series) -> float
|
|
10
|
+
the code's error bar for the mean of one chain's series (optional)
|
|
11
|
+
|
|
12
|
+
white box (optional; checks that need a method the code lacks are skipped)
|
|
13
|
+
init(L, n_chains, start, rng) -> state state.spins (n_chains, L, L), state.E, state.M
|
|
14
|
+
sweep(state, T, h, rng) one sweep, updating state.E and state.M incrementally
|
|
15
|
+
step(state, sites, u, T, h) one single-site update per chain at the given
|
|
16
|
+
sites (flat index) with the given uniforms
|
|
17
|
+
measure(state, h) -> (E, M) totals computed from the lattice
|
|
18
|
+
|
|
19
|
+
`rng` follows numpy's Generator: the code may call only rng.integers(low, high,
|
|
20
|
+
size) and rng.random(size). For single-site algorithms the expected order is one
|
|
21
|
+
site array, then one uniform array, per attempt (physverify.stochastic.reference).
|
|
22
|
+
|
|
23
|
+
Many chains are run at once (arrays over the first axis), which makes the
|
|
24
|
+
statistical checks affordable in plain NumPy. The chains are independent.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from .stats import blocked_error
|
|
32
|
+
|
|
33
|
+
ALGORITHMS = ("metropolis", "heatbath", "checkerboard", "wolff")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class State:
|
|
37
|
+
def __init__(self, spins, E, M):
|
|
38
|
+
self.spins, self.E, self.M = spins, E, M
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IsingSampler:
|
|
42
|
+
"""Metropolis (random site), heat bath (random site), checkerboard Metropolis or Wolff.
|
|
43
|
+
|
|
44
|
+
`bugs` switches on seeded bugs by name (see physverify.stochastic.ising.BUGS).
|
|
45
|
+
With no bugs this sampler reproduces physverify.stochastic.reference exactly.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, algorithm="metropolis", bugs=frozenset(), burn_in=100):
|
|
49
|
+
if algorithm not in ALGORITHMS:
|
|
50
|
+
raise ValueError(algorithm)
|
|
51
|
+
self.algorithm = algorithm
|
|
52
|
+
self.bugs = frozenset(bugs)
|
|
53
|
+
self.burn_in = 0 if "no_burn_in" in self.bugs else burn_in
|
|
54
|
+
self.J = -1.0 if "j_sign_flipped" in self.bugs else 1.0
|
|
55
|
+
self.zero_one = "spins_zero_one" in self.bugs
|
|
56
|
+
|
|
57
|
+
# --- representation ---
|
|
58
|
+
|
|
59
|
+
def _up(self):
|
|
60
|
+
return 1.0
|
|
61
|
+
|
|
62
|
+
def _down(self):
|
|
63
|
+
return 0.0 if self.zero_one else -1.0
|
|
64
|
+
|
|
65
|
+
def _flipped(self, s):
|
|
66
|
+
return 1.0 - s if self.zero_one else -s
|
|
67
|
+
|
|
68
|
+
# --- lattice sums ---
|
|
69
|
+
|
|
70
|
+
def _tables(self, L):
|
|
71
|
+
"""Neighbour indices (N, 4) and weights (N, 4) of every site, with the code's bugs applied."""
|
|
72
|
+
key = ("tables", L)
|
|
73
|
+
if key not in self.__dict__:
|
|
74
|
+
idx = np.empty((L * L, 4), dtype=np.intp)
|
|
75
|
+
w = np.ones((L * L, 4))
|
|
76
|
+
for i in range(L):
|
|
77
|
+
for j in range(L):
|
|
78
|
+
k = i * L + j
|
|
79
|
+
down, up = ((i + 1) % L) * L + j, ((i - 1) % L) * L + j
|
|
80
|
+
right, left = i * L + (j + 1) % L, i * L + (j - 1) % L
|
|
81
|
+
if "same_neighbour_twice" in self.bugs:
|
|
82
|
+
up = down
|
|
83
|
+
idx[k] = (down, up, right, left)
|
|
84
|
+
if "missing_wrap_x" in self.bugs:
|
|
85
|
+
w[k, 2] = 1.0 if j + 1 < L else 0.0
|
|
86
|
+
w[k, 3] = 1.0 if j - 1 >= 0 else 0.0
|
|
87
|
+
self.__dict__[key] = (idx, w)
|
|
88
|
+
return self.__dict__[key]
|
|
89
|
+
|
|
90
|
+
def _neighbour_sum(self, flat, sites, L):
|
|
91
|
+
"""Sum of the four neighbours of the given site in every chain (flat: (n_chains, N))."""
|
|
92
|
+
idx, w = self._tables(L)
|
|
93
|
+
n_chains = flat.shape[0]
|
|
94
|
+
base = np.arange(n_chains) * (L * L)
|
|
95
|
+
values = flat.ravel()
|
|
96
|
+
total = 0.0
|
|
97
|
+
for d in range(4):
|
|
98
|
+
v = values[base + idx[sites, d]]
|
|
99
|
+
total = total + (v if "missing_wrap_x" not in self.bugs else v * w[sites, d])
|
|
100
|
+
return total
|
|
101
|
+
|
|
102
|
+
def _neighbour_sum_all(self, spins):
|
|
103
|
+
s = spins
|
|
104
|
+
down, up = np.roll(s, -1, axis=1), np.roll(s, 1, axis=1)
|
|
105
|
+
right, left = np.roll(s, -1, axis=2), np.roll(s, 1, axis=2)
|
|
106
|
+
return down + up + right + left
|
|
107
|
+
|
|
108
|
+
def measure(self, state, h=0.0):
|
|
109
|
+
s = state.spins
|
|
110
|
+
if "energy_double_counted" in self.bugs:
|
|
111
|
+
bonds = (s * self._neighbour_sum_all(s)).sum(axis=(1, 2))
|
|
112
|
+
else:
|
|
113
|
+
right = np.roll(s, -1, axis=2)
|
|
114
|
+
if "missing_wrap_x" in self.bugs:
|
|
115
|
+
right = right.copy()
|
|
116
|
+
right[:, :, -1] = 0.0
|
|
117
|
+
bonds = (s * (right + np.roll(s, -1, axis=1))).sum(axis=(1, 2))
|
|
118
|
+
M = s.sum(axis=(1, 2))
|
|
119
|
+
return -self.J * bonds - h * M, M
|
|
120
|
+
|
|
121
|
+
# --- set-up ---
|
|
122
|
+
|
|
123
|
+
def init(self, L, n_chains, start, rng, h=0.0):
|
|
124
|
+
if start == "cold":
|
|
125
|
+
spins = np.full((n_chains, L, L), self._up())
|
|
126
|
+
else:
|
|
127
|
+
spins = np.where(rng.random((n_chains, L, L)) < 0.5, self._up(), self._down())
|
|
128
|
+
state = State(spins, None, None)
|
|
129
|
+
state.E, state.M = self.measure(state, h)
|
|
130
|
+
return state
|
|
131
|
+
|
|
132
|
+
# --- single-site updates ---
|
|
133
|
+
|
|
134
|
+
def step(self, state, sites, u, T, h):
|
|
135
|
+
spins = state.spins
|
|
136
|
+
n_chains, L, _ = spins.shape
|
|
137
|
+
flat = spins.reshape(n_chains, L * L) # a view: writes go to state.spins
|
|
138
|
+
rows = np.arange(n_chains)
|
|
139
|
+
sites = np.asarray(sites, dtype=np.intp)
|
|
140
|
+
s = flat[rows, sites]
|
|
141
|
+
h_dyn = -h if "field_sign_wrong" in self.bugs else h
|
|
142
|
+
phi = self.J * self._neighbour_sum(flat, sites, L) + h_dyn
|
|
143
|
+
if self.algorithm == "heatbath":
|
|
144
|
+
factor = 1.0 if "heatbath_missing_2" in self.bugs else 2.0
|
|
145
|
+
p_up = 1.0 / (1.0 + np.exp(-factor * phi / T))
|
|
146
|
+
new = np.where(u < p_up, self._up(), self._down())
|
|
147
|
+
dE_track = -(new - s) * phi
|
|
148
|
+
else:
|
|
149
|
+
dE = (1.0 if "delta_e_missing_factor_2" in self.bugs else 2.0) * s * phi
|
|
150
|
+
with np.errstate(over="ignore"):
|
|
151
|
+
if "exp_de_times_t" in self.bugs:
|
|
152
|
+
p = np.exp(-dE * T)
|
|
153
|
+
elif "acceptance_table_off_by_one" in self.bugs:
|
|
154
|
+
p = np.exp(-(dE + 4.0) / T) # table entry for the next energy class
|
|
155
|
+
else:
|
|
156
|
+
p = np.exp(-dE / T)
|
|
157
|
+
accept = u < np.minimum(1.0, p)
|
|
158
|
+
if "reject_zero_de" in self.bugs:
|
|
159
|
+
accept &= dE != 0.0
|
|
160
|
+
new = np.where(accept, self._flipped(s), s)
|
|
161
|
+
dE_track = dE # the code tracks the energy with its own local energy change
|
|
162
|
+
changed = new != s
|
|
163
|
+
if "incremental_energy_not_updated" not in self.bugs:
|
|
164
|
+
state.E = state.E + np.where(changed, dE_track, 0.0)
|
|
165
|
+
state.M = state.M + (new - s)
|
|
166
|
+
flat[rows, sites] = new
|
|
167
|
+
|
|
168
|
+
# --- sweeps ---
|
|
169
|
+
|
|
170
|
+
def sweep(self, state, T, h, rng):
|
|
171
|
+
if self.algorithm in ("metropolis", "heatbath"):
|
|
172
|
+
n_chains, L, _ = state.spins.shape
|
|
173
|
+
N = L * L
|
|
174
|
+
for _ in range(N):
|
|
175
|
+
if "same_random_number" in self.bugs:
|
|
176
|
+
u = rng.random(n_chains)
|
|
177
|
+
sites = np.minimum((u * N).astype(int), N - 1)
|
|
178
|
+
else:
|
|
179
|
+
high = N - L if "sweep_skips_last_row" in self.bugs else N
|
|
180
|
+
sites = rng.integers(0, high, size=n_chains)
|
|
181
|
+
u = rng.random(n_chains)
|
|
182
|
+
self.step(state, sites, u, T, h)
|
|
183
|
+
elif self.algorithm == "checkerboard":
|
|
184
|
+
self._checkerboard_sweep(state, T, h, rng)
|
|
185
|
+
else:
|
|
186
|
+
self._wolff_update(state, T, h, rng)
|
|
187
|
+
|
|
188
|
+
def _checkerboard_sweep(self, state, T, h, rng):
|
|
189
|
+
s = state.spins
|
|
190
|
+
n_chains, L, _ = s.shape
|
|
191
|
+
ii, jj = np.meshgrid(np.arange(L), np.arange(L), indexing="ij")
|
|
192
|
+
if "synchronous_update" in self.bugs:
|
|
193
|
+
masks = [np.ones((L, L), dtype=bool)]
|
|
194
|
+
elif "checkerboard_wrong_parity" in self.bugs:
|
|
195
|
+
masks = [(ii + jj) % 2 == 0, (ii + jj) % 2 == 0]
|
|
196
|
+
else:
|
|
197
|
+
masks = [(ii + jj) % 2 == 0, (ii + jj) % 2 == 1]
|
|
198
|
+
for mask in masks:
|
|
199
|
+
phi = self.J * self._neighbour_sum_all(s) + h
|
|
200
|
+
dE = 2.0 * s * phi
|
|
201
|
+
u = rng.random((n_chains, L, L))
|
|
202
|
+
accept = mask & (u < np.minimum(1.0, np.exp(-dE / T)))
|
|
203
|
+
state.E = state.E + np.where(accept, dE, 0.0).sum(axis=(1, 2))
|
|
204
|
+
state.M = state.M + np.where(accept, -2.0 * s, 0.0).sum(axis=(1, 2))
|
|
205
|
+
s = np.where(accept, -s, s)
|
|
206
|
+
state.spins[...] = s
|
|
207
|
+
|
|
208
|
+
def _wolff_update(self, state, T, h, rng):
|
|
209
|
+
"""One Wolff cluster flip per chain (h must be 0). A 'sweep' here is one cluster update."""
|
|
210
|
+
if h != 0.0:
|
|
211
|
+
raise ValueError("the Wolff update is implemented for h = 0 only")
|
|
212
|
+
n_chains, L, _ = state.spins.shape
|
|
213
|
+
k = 1.0 if "wolff_probability" in self.bugs else 2.0
|
|
214
|
+
p_add = 1.0 - np.exp(-k * self.J / T)
|
|
215
|
+
for c in range(n_chains):
|
|
216
|
+
s = state.spins[c]
|
|
217
|
+
seed = int(rng.integers(0, L * L))
|
|
218
|
+
i0, j0 = divmod(seed, L)
|
|
219
|
+
sign = s[i0, j0]
|
|
220
|
+
cluster = {(i0, j0)}
|
|
221
|
+
stack = [(i0, j0)]
|
|
222
|
+
while stack:
|
|
223
|
+
i, j = stack.pop()
|
|
224
|
+
for a, b in (((i + 1) % L, j), ((i - 1) % L, j), (i, (j + 1) % L), (i, (j - 1) % L)):
|
|
225
|
+
if (a, b) not in cluster and s[a, b] == sign and rng.random() < p_add:
|
|
226
|
+
cluster.add((a, b))
|
|
227
|
+
stack.append((a, b))
|
|
228
|
+
for a, b in cluster:
|
|
229
|
+
s[a, b] = -s[a, b]
|
|
230
|
+
state.E, state.M = self.measure(state, h)
|
|
231
|
+
|
|
232
|
+
# --- black box ---
|
|
233
|
+
|
|
234
|
+
def sample(self, L, T, h, n_sweeps, seed, n_chains=1, start="hot"):
|
|
235
|
+
rng = np.random.default_rng(12345 if "seed_ignored" in self.bugs else seed)
|
|
236
|
+
state = self.init(L, n_chains, start, rng, h)
|
|
237
|
+
for _ in range(self.burn_in):
|
|
238
|
+
self.sweep(state, T, h, rng)
|
|
239
|
+
e = np.empty((n_chains, n_sweeps))
|
|
240
|
+
m = np.empty((n_chains, n_sweeps))
|
|
241
|
+
N = L * L
|
|
242
|
+
for t in range(n_sweeps):
|
|
243
|
+
self.sweep(state, T, h, rng)
|
|
244
|
+
E, M = self.measure(state, h)
|
|
245
|
+
e[:, t] = E / N
|
|
246
|
+
m[:, t] = M / (L if "m_normalised_by_L" in self.bugs else N)
|
|
247
|
+
return e, m
|
|
248
|
+
|
|
249
|
+
def error_bar(self, series):
|
|
250
|
+
x = np.asarray(series, dtype=float)
|
|
251
|
+
if "naive_error_bars" in self.bugs:
|
|
252
|
+
return float(x.std() / np.sqrt(len(x)))
|
|
253
|
+
return blocked_error(x)
|