couplnorm 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.
- couplnorm/__init__.py +11 -0
- couplnorm/coupling.py +285 -0
- couplnorm/models/__init__.py +25 -0
- couplnorm/models/fourier.py +73 -0
- couplnorm/models/gaussian.py +61 -0
- couplnorm/models/maf.py +171 -0
- couplnorm/models/pca.py +71 -0
- couplnorm/plotting.py +116 -0
- couplnorm/samplers.py +170 -0
- couplnorm-0.1.0.dist-info/METADATA +146 -0
- couplnorm-0.1.0.dist-info/RECORD +14 -0
- couplnorm-0.1.0.dist-info/WHEEL +5 -0
- couplnorm-0.1.0.dist-info/licenses/LICENSE +21 -0
- couplnorm-0.1.0.dist-info/top_level.txt +1 -0
couplnorm/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""couplnorm: normalized 4th-order coupling metrics and losses for
|
|
2
|
+
generative model evaluation on Fourier-decomposable data.
|
|
3
|
+
"""
|
|
4
|
+
from couplnorm.coupling import (
|
|
5
|
+
CouplingLoss,
|
|
6
|
+
CouplingMetric,
|
|
7
|
+
coupling_from_samples,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["CouplingMetric", "CouplingLoss", "coupling_from_samples"]
|
|
11
|
+
__version__ = "0.1.0"
|
couplnorm/coupling.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Normalized off-diagonal coupling diagnostic and loss for evaluating
|
|
2
|
+
generative models on Fourier-decomposable data.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
import torch.nn as nn
|
|
10
|
+
from torch import Tensor
|
|
11
|
+
|
|
12
|
+
__all__ = ["CouplingMetric", "CouplingLoss", "coupling_from_samples"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _spectral_energies(
|
|
16
|
+
x: Tensor,
|
|
17
|
+
input_space: str = "position",
|
|
18
|
+
fft_norm: str = "ortho",
|
|
19
|
+
real_fft: bool = True,
|
|
20
|
+
) -> Tensor:
|
|
21
|
+
if input_space == "position":
|
|
22
|
+
if real_fft:
|
|
23
|
+
x_hat = torch.fft.rfft(x, dim=-1, norm=fft_norm)
|
|
24
|
+
else:
|
|
25
|
+
x_hat = torch.fft.fft(x, dim=-1, norm=fft_norm)
|
|
26
|
+
return x_hat.real.pow(2) + x_hat.imag.pow(2)
|
|
27
|
+
elif input_space == "spectral":
|
|
28
|
+
return x
|
|
29
|
+
else:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"input_space must be 'position' or 'spectral', got {input_space!r}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _batch_covariance(E: Tensor, unbiased: bool = True) -> Tensor:
|
|
36
|
+
if E.dim() != 2:
|
|
37
|
+
raise ValueError(f"Expected 2D tensor (B, M), got shape {tuple(E.shape)}")
|
|
38
|
+
B = E.shape[0]
|
|
39
|
+
if B < 2 and unbiased:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Need B >= 2 for unbiased covariance; got B={B}. "
|
|
42
|
+
"Set unbiased=False or use mode='running' for streaming."
|
|
43
|
+
)
|
|
44
|
+
mean = E.mean(dim=0, keepdim=True)
|
|
45
|
+
centered = E - mean
|
|
46
|
+
denom = (B - 1) if unbiased else B
|
|
47
|
+
return centered.T @ centered / denom
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coupling_from_cov(cov: Tensor, eps: float = 1e-12) -> Tensor:
|
|
51
|
+
diag_vec = torch.diagonal(cov)
|
|
52
|
+
off_diag = cov - torch.diag_embed(diag_vec)
|
|
53
|
+
num = torch.linalg.matrix_norm(off_diag, ord="fro")
|
|
54
|
+
den = torch.linalg.matrix_norm(cov, ord="fro")
|
|
55
|
+
return num / (den + eps)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def coupling_from_samples(
|
|
59
|
+
x: Tensor,
|
|
60
|
+
input_space: str = "position",
|
|
61
|
+
fft_norm: str = "ortho",
|
|
62
|
+
real_fft: bool = True,
|
|
63
|
+
eps: float = 1e-12,
|
|
64
|
+
unbiased: bool = True,
|
|
65
|
+
) -> Tensor:
|
|
66
|
+
if x.dim() == 1:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"coupling_from_samples requires batched input (B, N). "
|
|
69
|
+
"For streaming, use CouplingMetric(mode='running')."
|
|
70
|
+
)
|
|
71
|
+
E = _spectral_energies(x, input_space=input_space, fft_norm=fft_norm, real_fft=real_fft)
|
|
72
|
+
cov = _batch_covariance(E, unbiased=unbiased)
|
|
73
|
+
return _coupling_from_cov(cov, eps)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CouplingMetric(nn.Module):
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
input_space: str = "position",
|
|
80
|
+
mode: str = "batch",
|
|
81
|
+
momentum: float = 0.1,
|
|
82
|
+
eps: float = 1e-12,
|
|
83
|
+
fft_norm: str = "ortho",
|
|
84
|
+
real_fft: bool = True,
|
|
85
|
+
unbiased: bool = True,
|
|
86
|
+
n_modes: Optional[int] = None,
|
|
87
|
+
):
|
|
88
|
+
super().__init__()
|
|
89
|
+
if input_space not in ("position", "spectral"):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"input_space must be 'position' or 'spectral'; got {input_space!r}"
|
|
92
|
+
)
|
|
93
|
+
if mode not in ("batch", "running"):
|
|
94
|
+
raise ValueError(f"mode must be 'batch' or 'running'; got {mode!r}")
|
|
95
|
+
if not (0.0 < momentum <= 1.0):
|
|
96
|
+
raise ValueError(f"momentum must be in (0, 1]; got {momentum}")
|
|
97
|
+
|
|
98
|
+
self.input_space = input_space
|
|
99
|
+
self.mode = mode
|
|
100
|
+
self.momentum = float(momentum)
|
|
101
|
+
self.eps = float(eps)
|
|
102
|
+
self.fft_norm = fft_norm
|
|
103
|
+
self.real_fft = bool(real_fft)
|
|
104
|
+
self.unbiased = bool(unbiased)
|
|
105
|
+
|
|
106
|
+
if mode == "running":
|
|
107
|
+
if n_modes is not None:
|
|
108
|
+
self.register_buffer("running_mean", torch.zeros(n_modes))
|
|
109
|
+
self.register_buffer("running_second", torch.zeros(n_modes, n_modes))
|
|
110
|
+
else:
|
|
111
|
+
self.register_buffer("running_mean", torch.empty(0))
|
|
112
|
+
self.register_buffer("running_second", torch.empty(0))
|
|
113
|
+
self.register_buffer("num_batches_tracked", torch.tensor(0, dtype=torch.long))
|
|
114
|
+
|
|
115
|
+
def _maybe_init_buffers(self, M: int, device, dtype) -> None:
|
|
116
|
+
if self.running_mean.numel() == 0:
|
|
117
|
+
self.running_mean = torch.zeros(M, device=device, dtype=dtype)
|
|
118
|
+
self.running_second = torch.zeros(M, M, device=device, dtype=dtype)
|
|
119
|
+
|
|
120
|
+
def forward(self, x: Tensor) -> Tensor:
|
|
121
|
+
if x.dim() == 1:
|
|
122
|
+
x = x.unsqueeze(0)
|
|
123
|
+
if x.dim() != 2:
|
|
124
|
+
raise ValueError(f"Expected 1D or 2D input, got shape {tuple(x.shape)}")
|
|
125
|
+
E = _spectral_energies(x, self.input_space, self.fft_norm, self.real_fft)
|
|
126
|
+
B, M = E.shape
|
|
127
|
+
|
|
128
|
+
if self.mode == "batch":
|
|
129
|
+
cov = _batch_covariance(E, unbiased=self.unbiased)
|
|
130
|
+
return _coupling_from_cov(cov, self.eps)
|
|
131
|
+
|
|
132
|
+
self._maybe_init_buffers(M, E.device, E.dtype)
|
|
133
|
+
if self.running_mean.shape[0] != M:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"Number of modes changed: running buffers have "
|
|
136
|
+
f"N={self.running_mean.shape[0]} but got N={M}."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
with torch.no_grad():
|
|
140
|
+
batch_mean = E.mean(dim=0)
|
|
141
|
+
batch_second = (E.T @ E) / B
|
|
142
|
+
m = self.momentum
|
|
143
|
+
self.running_mean.mul_(1.0 - m).add_(batch_mean, alpha=m)
|
|
144
|
+
self.running_second.mul_(1.0 - m).add_(batch_second, alpha=m)
|
|
145
|
+
self.num_batches_tracked.add_(1)
|
|
146
|
+
return self.compute()
|
|
147
|
+
|
|
148
|
+
def compute(self) -> Tensor:
|
|
149
|
+
if self.mode != "running":
|
|
150
|
+
raise RuntimeError("compute() is only valid in running mode.")
|
|
151
|
+
if self.running_mean.numel() == 0 or self.num_batches_tracked.item() == 0:
|
|
152
|
+
raise RuntimeError("No batches tracked yet; call forward() at least once.")
|
|
153
|
+
mu = self.running_mean
|
|
154
|
+
M = self.running_second
|
|
155
|
+
cov = M - torch.outer(mu, mu)
|
|
156
|
+
return _coupling_from_cov(cov, self.eps)
|
|
157
|
+
|
|
158
|
+
def covariance(self) -> Tensor:
|
|
159
|
+
if self.mode != "running":
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
"covariance() requires mode='running'. For one-off batches, "
|
|
162
|
+
"use coupling_from_samples or recompute manually."
|
|
163
|
+
)
|
|
164
|
+
mu = self.running_mean
|
|
165
|
+
M = self.running_second
|
|
166
|
+
return M - torch.outer(mu, mu)
|
|
167
|
+
|
|
168
|
+
def reset(self) -> None:
|
|
169
|
+
if self.mode != "running":
|
|
170
|
+
return
|
|
171
|
+
if self.running_mean.numel() > 0:
|
|
172
|
+
self.running_mean.zero_()
|
|
173
|
+
self.running_second.zero_()
|
|
174
|
+
self.num_batches_tracked.zero_()
|
|
175
|
+
|
|
176
|
+
def extra_repr(self) -> str:
|
|
177
|
+
return (
|
|
178
|
+
f"input_space={self.input_space!r}, mode={self.mode!r}, "
|
|
179
|
+
f"momentum={self.momentum}, eps={self.eps}, "
|
|
180
|
+
f"fft_norm={self.fft_norm!r}, real_fft={self.real_fft}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CouplingLoss(nn.Module):
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
target_type: str = "matrix",
|
|
188
|
+
target: Optional[Union[Tensor, float]] = None,
|
|
189
|
+
input_space: str = "position",
|
|
190
|
+
eps: float = 1e-12,
|
|
191
|
+
fft_norm: str = "ortho",
|
|
192
|
+
real_fft: bool = True,
|
|
193
|
+
unbiased: bool = True,
|
|
194
|
+
):
|
|
195
|
+
super().__init__()
|
|
196
|
+
if target_type not in ("matrix", "scalar", "regularize"):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"target_type must be 'matrix', 'scalar', or 'regularize'; "
|
|
199
|
+
f"got {target_type!r}"
|
|
200
|
+
)
|
|
201
|
+
if input_space not in ("position", "spectral"):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"input_space must be 'position' or 'spectral'; got {input_space!r}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if target_type == "matrix":
|
|
207
|
+
if target is None:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
"target_type='matrix' requires a (M, M) target covariance."
|
|
210
|
+
)
|
|
211
|
+
tgt = torch.as_tensor(target, dtype=torch.float32)
|
|
212
|
+
if tgt.dim() != 2 or tgt.shape[0] != tgt.shape[1]:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"matrix target must be (M, M); got shape {tuple(tgt.shape)}"
|
|
215
|
+
)
|
|
216
|
+
self.register_buffer("target_cov", tgt.detach().clone())
|
|
217
|
+
self.register_buffer(
|
|
218
|
+
"target_cov_frob",
|
|
219
|
+
torch.linalg.matrix_norm(tgt, ord="fro").detach().clone(),
|
|
220
|
+
)
|
|
221
|
+
elif target_type == "scalar":
|
|
222
|
+
if target is None:
|
|
223
|
+
raise ValueError("target_type='scalar' requires a target C value.")
|
|
224
|
+
self.register_buffer(
|
|
225
|
+
"target_C", torch.as_tensor(float(target), dtype=torch.float32)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self.target_type = target_type
|
|
229
|
+
self.input_space = input_space
|
|
230
|
+
self.eps = float(eps)
|
|
231
|
+
self.fft_norm = fft_norm
|
|
232
|
+
self.real_fft = bool(real_fft)
|
|
233
|
+
self.unbiased = bool(unbiased)
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def from_data(
|
|
237
|
+
cls,
|
|
238
|
+
data: Tensor,
|
|
239
|
+
target_type: str = "matrix",
|
|
240
|
+
input_space: str = "position",
|
|
241
|
+
fft_norm: str = "ortho",
|
|
242
|
+
real_fft: bool = True,
|
|
243
|
+
unbiased: bool = True,
|
|
244
|
+
eps: float = 1e-12,
|
|
245
|
+
) -> "CouplingLoss":
|
|
246
|
+
if target_type not in ("matrix", "scalar"):
|
|
247
|
+
raise ValueError(f"from_data does not support target_type={target_type!r}")
|
|
248
|
+
with torch.no_grad():
|
|
249
|
+
E = _spectral_energies(
|
|
250
|
+
data, input_space=input_space, fft_norm=fft_norm, real_fft=real_fft
|
|
251
|
+
)
|
|
252
|
+
cov = _batch_covariance(E, unbiased=unbiased)
|
|
253
|
+
if target_type == "matrix":
|
|
254
|
+
return cls(
|
|
255
|
+
target_type="matrix", target=cov, input_space=input_space, eps=eps,
|
|
256
|
+
fft_norm=fft_norm, real_fft=real_fft, unbiased=unbiased,
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
C = _coupling_from_cov(cov, eps).item()
|
|
260
|
+
return cls(
|
|
261
|
+
target_type="scalar", target=C, input_space=input_space, eps=eps,
|
|
262
|
+
fft_norm=fft_norm, real_fft=real_fft, unbiased=unbiased,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def forward(self, x: Tensor) -> Tensor:
|
|
266
|
+
if x.dim() == 1:
|
|
267
|
+
x = x.unsqueeze(0)
|
|
268
|
+
E = _spectral_energies(x, self.input_space, self.fft_norm, self.real_fft)
|
|
269
|
+
cov = _batch_covariance(E, unbiased=self.unbiased)
|
|
270
|
+
if self.target_type == "matrix":
|
|
271
|
+
diff = cov - self.target_cov
|
|
272
|
+
num = torch.linalg.matrix_norm(diff, ord="fro").pow(2)
|
|
273
|
+
den = self.target_cov_frob.pow(2) + self.eps
|
|
274
|
+
return num / den
|
|
275
|
+
elif self.target_type == "scalar":
|
|
276
|
+
C = _coupling_from_cov(cov, self.eps)
|
|
277
|
+
return (C - self.target_C).pow(2)
|
|
278
|
+
else:
|
|
279
|
+
return _coupling_from_cov(cov, self.eps)
|
|
280
|
+
|
|
281
|
+
def extra_repr(self) -> str:
|
|
282
|
+
return (
|
|
283
|
+
f"target_type={self.target_type!r}, "
|
|
284
|
+
f"input_space={self.input_space!r}, fft_norm={self.fft_norm!r}"
|
|
285
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Baseline generative models for couplnorm demos and benchmarks.
|
|
2
|
+
|
|
3
|
+
Every model is an ``nn.Module`` with a common interface:
|
|
4
|
+
|
|
5
|
+
- ``fit(data)`` estimates parameters from a ``(B, N)`` batch of position-space
|
|
6
|
+
field configurations (a no-op / trainer depending on the model).
|
|
7
|
+
- ``sample(n)`` returns ``(n, N)`` real position-space configurations.
|
|
8
|
+
|
|
9
|
+
The models span the spectrum of joint-coupling fidelity:
|
|
10
|
+
|
|
11
|
+
- :class:`FourierModel` assumes marginal independence of Fourier modes, so its
|
|
12
|
+
samples have C near zero regardless of the target. It is the "wrong by
|
|
13
|
+
construction" baseline that motivates the C diagnostic.
|
|
14
|
+
- :class:`FullGaussian` matches the full second-order (covariance) structure of
|
|
15
|
+
the data but, being Gaussian, cannot reproduce genuine 4th-order coupling.
|
|
16
|
+
- :class:`PCAModel` is a low-rank Gaussian; even weaker than FullGaussian.
|
|
17
|
+
- :class:`MAF` is a masked autoregressive flow that can, in principle, capture
|
|
18
|
+
higher-order structure and drive C toward the target.
|
|
19
|
+
"""
|
|
20
|
+
from couplnorm.models.fourier import FourierModel
|
|
21
|
+
from couplnorm.models.gaussian import FullGaussian
|
|
22
|
+
from couplnorm.models.maf import MAF
|
|
23
|
+
from couplnorm.models.pca import PCAModel
|
|
24
|
+
|
|
25
|
+
__all__ = ["FourierModel", "FullGaussian", "PCAModel", "MAF"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Independent-mode Fourier baseline (the marginal-independence model)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
__all__ = ["FourierModel"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FourierModel(nn.Module):
|
|
12
|
+
"""Models each Fourier mode as an independent Gaussian.
|
|
13
|
+
|
|
14
|
+
Fits a per-mode complex Gaussian to ``rfft(data)`` (independent real and
|
|
15
|
+
imaginary parts, no cross-mode correlation) and samples by drawing each mode
|
|
16
|
+
independently and inverting the transform. Because the modes are independent
|
|
17
|
+
by construction, the spectral-energy covariance is diagonal and C is near
|
|
18
|
+
zero regardless of the target distribution.
|
|
19
|
+
|
|
20
|
+
This is the "wrong by construction" baseline: a generative model that
|
|
21
|
+
assumes marginal independence of modes cannot reproduce joint 4th-order
|
|
22
|
+
coupling. It is the whole reason the C diagnostic is interesting.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
N : int
|
|
27
|
+
Field dimension (length of the real position-space signal).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, N: int):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.N = int(N)
|
|
33
|
+
self.M = N // 2 + 1
|
|
34
|
+
self.register_buffer("mean_real", torch.zeros(self.M))
|
|
35
|
+
self.register_buffer("mean_imag", torch.zeros(self.M))
|
|
36
|
+
self.register_buffer("std_real", torch.ones(self.M))
|
|
37
|
+
self.register_buffer("std_imag", torch.ones(self.M))
|
|
38
|
+
self._fitted = False
|
|
39
|
+
|
|
40
|
+
@torch.no_grad()
|
|
41
|
+
def fit(self, data: Tensor) -> "FourierModel":
|
|
42
|
+
if data.dim() != 2 or data.shape[1] != self.N:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Expected data of shape (B, {self.N}); got {tuple(data.shape)}"
|
|
45
|
+
)
|
|
46
|
+
data = data.to(self.mean_real)
|
|
47
|
+
x_hat = torch.fft.rfft(data, dim=-1, norm="ortho")
|
|
48
|
+
self.mean_real = x_hat.real.mean(dim=0)
|
|
49
|
+
self.mean_imag = x_hat.imag.mean(dim=0)
|
|
50
|
+
self.std_real = x_hat.real.std(dim=0).clamp_min(1e-8)
|
|
51
|
+
self.std_imag = x_hat.imag.std(dim=0).clamp_min(1e-8)
|
|
52
|
+
# The DC mode (and Nyquist mode when N is even) is purely real for a
|
|
53
|
+
# real signal; force its imaginary part to zero so irfft stays real.
|
|
54
|
+
self.mean_imag[0] = 0.0
|
|
55
|
+
self.std_imag[0] = 0.0
|
|
56
|
+
if self.N % 2 == 0:
|
|
57
|
+
self.mean_imag[-1] = 0.0
|
|
58
|
+
self.std_imag[-1] = 0.0
|
|
59
|
+
self._fitted = True
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
@torch.no_grad()
|
|
63
|
+
def sample(self, n: int) -> Tensor:
|
|
64
|
+
if not self._fitted:
|
|
65
|
+
raise RuntimeError("Call fit(data) before sample(n).")
|
|
66
|
+
dev, dt = self.mean_real.device, self.mean_real.dtype
|
|
67
|
+
re = self.mean_real + self.std_real * torch.randn(n, self.M, device=dev, dtype=dt)
|
|
68
|
+
im = self.mean_imag + self.std_imag * torch.randn(n, self.M, device=dev, dtype=dt)
|
|
69
|
+
x_hat = torch.complex(re, im)
|
|
70
|
+
return torch.fft.irfft(x_hat, n=self.N, dim=-1, norm="ortho")
|
|
71
|
+
|
|
72
|
+
def extra_repr(self) -> str:
|
|
73
|
+
return f"N={self.N}, M={self.M}, fitted={self._fitted}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Full multivariate Gaussian baseline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
__all__ = ["FullGaussian"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FullGaussian(nn.Module):
|
|
12
|
+
"""Position-space multivariate Gaussian fit to the data covariance.
|
|
13
|
+
|
|
14
|
+
Matches the mean and full covariance of the training fields exactly (up to
|
|
15
|
+
finite-sample error), then samples via the Cholesky factor. Being Gaussian,
|
|
16
|
+
it reproduces all second-order structure but no genuine 4th-order coupling:
|
|
17
|
+
its spectral energies inherit only the coupling implied by the covariance.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
N : int
|
|
22
|
+
Number of lattice sites (field dimension).
|
|
23
|
+
jitter : float
|
|
24
|
+
Diagonal loading added before the Cholesky factorization for numerical
|
|
25
|
+
stability with near-singular covariances.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, N: int, jitter: float = 1e-5):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.N = int(N)
|
|
31
|
+
self.jitter = float(jitter)
|
|
32
|
+
self.register_buffer("mean", torch.zeros(N))
|
|
33
|
+
self.register_buffer("cov", torch.eye(N))
|
|
34
|
+
self.register_buffer("chol", torch.eye(N))
|
|
35
|
+
self._fitted = False
|
|
36
|
+
|
|
37
|
+
@torch.no_grad()
|
|
38
|
+
def fit(self, data: Tensor) -> "FullGaussian":
|
|
39
|
+
if data.dim() != 2 or data.shape[1] != self.N:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Expected data of shape (B, {self.N}); got {tuple(data.shape)}"
|
|
42
|
+
)
|
|
43
|
+
data = data.to(self.mean)
|
|
44
|
+
self.mean = data.mean(dim=0)
|
|
45
|
+
centered = data - self.mean
|
|
46
|
+
cov = centered.T @ centered / (data.shape[0] - 1)
|
|
47
|
+
cov = cov + self.jitter * torch.eye(self.N, device=cov.device, dtype=cov.dtype)
|
|
48
|
+
self.cov = cov
|
|
49
|
+
self.chol = torch.linalg.cholesky(cov)
|
|
50
|
+
self._fitted = True
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
@torch.no_grad()
|
|
54
|
+
def sample(self, n: int) -> Tensor:
|
|
55
|
+
if not self._fitted:
|
|
56
|
+
raise RuntimeError("Call fit(data) before sample(n).")
|
|
57
|
+
z = torch.randn(n, self.N, device=self.mean.device, dtype=self.mean.dtype)
|
|
58
|
+
return self.mean + z @ self.chol.T
|
|
59
|
+
|
|
60
|
+
def extra_repr(self) -> str:
|
|
61
|
+
return f"N={self.N}, fitted={self._fitted}"
|
couplnorm/models/maf.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Masked Autoregressive Flow (MAF) baseline.
|
|
2
|
+
|
|
3
|
+
A compact MADE-based MAF: a stack of autoregressive Gaussian transforms with a
|
|
4
|
+
fixed permutation between blocks and a standard-normal base density. Unlike the
|
|
5
|
+
Gaussian and Fourier baselines, a MAF can represent higher-order structure, so
|
|
6
|
+
it is the model you would actually train to drive C toward a target.
|
|
7
|
+
|
|
8
|
+
Kept intentionally small (this is plumbing, not the contribution). Trained by
|
|
9
|
+
maximum likelihood; sampled by inverting each block sequentially.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn as nn
|
|
17
|
+
import torch.nn.functional as F
|
|
18
|
+
from torch import Tensor
|
|
19
|
+
|
|
20
|
+
__all__ = ["MAF"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _MaskedLinear(nn.Linear):
|
|
24
|
+
"""Linear layer with a fixed binary mask on the weight matrix."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, in_features: int, out_features: int):
|
|
27
|
+
super().__init__(in_features, out_features)
|
|
28
|
+
self.register_buffer("mask", torch.ones(out_features, in_features))
|
|
29
|
+
|
|
30
|
+
def set_mask(self, mask: Tensor) -> None:
|
|
31
|
+
self.mask.copy_(mask)
|
|
32
|
+
|
|
33
|
+
def forward(self, x: Tensor) -> Tensor: # type: ignore[override]
|
|
34
|
+
return F.linear(x, self.weight * self.mask, self.bias)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _MADE(nn.Module):
|
|
38
|
+
"""Masked autoencoder producing (mu, alpha) with the autoregressive property.
|
|
39
|
+
|
|
40
|
+
Output dimension ``d`` depends only on inputs ``0..d-1`` (in the current
|
|
41
|
+
variable order), so ``mu_d`` and ``alpha_d`` are functions of ``x_{<d}``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, D: int, hidden: int = 64, n_hidden: int = 1):
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.D = D
|
|
47
|
+
sizes = [D] + [hidden] * n_hidden + [2 * D]
|
|
48
|
+
self.net = nn.ModuleList()
|
|
49
|
+
for i in range(len(sizes) - 1):
|
|
50
|
+
self.net.append(_MaskedLinear(sizes[i], sizes[i + 1]))
|
|
51
|
+
|
|
52
|
+
# Degree assignment (deterministic): inputs 0..D-1, hidden units cycle
|
|
53
|
+
# through 0..D-2, outputs 0..D-1.
|
|
54
|
+
m_in = torch.arange(D)
|
|
55
|
+
degrees = [m_in]
|
|
56
|
+
for _ in range(n_hidden):
|
|
57
|
+
degrees.append(torch.arange(hidden) % max(D - 1, 1))
|
|
58
|
+
m_out = torch.arange(D)
|
|
59
|
+
|
|
60
|
+
# Hidden connectivity: non-strict >=.
|
|
61
|
+
for i, layer in enumerate(self.net[:-1]):
|
|
62
|
+
prev, cur = degrees[i], degrees[i + 1]
|
|
63
|
+
mask = (cur.unsqueeze(1) >= prev.unsqueeze(0)).float()
|
|
64
|
+
layer.set_mask(mask)
|
|
65
|
+
# Output connectivity: strict > (no self/future leakage), duplicated for
|
|
66
|
+
# the mu and alpha halves.
|
|
67
|
+
out_mask = (m_out.unsqueeze(1) > degrees[-1].unsqueeze(0)).float()
|
|
68
|
+
self.net[-1].set_mask(torch.cat([out_mask, out_mask], dim=0))
|
|
69
|
+
|
|
70
|
+
def forward(self, x: Tensor):
|
|
71
|
+
h = x
|
|
72
|
+
for layer in self.net[:-1]:
|
|
73
|
+
h = F.relu(layer(h))
|
|
74
|
+
out = self.net[-1](h)
|
|
75
|
+
mu, alpha = out.chunk(2, dim=-1)
|
|
76
|
+
alpha = torch.tanh(alpha) # keep the log-scale bounded for stability
|
|
77
|
+
return mu, alpha
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MAF(nn.Module):
|
|
81
|
+
"""Masked autoregressive flow over ``N``-dimensional position-space fields.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
N : int
|
|
86
|
+
Field dimension.
|
|
87
|
+
hidden : int
|
|
88
|
+
Hidden width of each MADE.
|
|
89
|
+
n_blocks : int
|
|
90
|
+
Number of autoregressive blocks (with permutations between them).
|
|
91
|
+
n_hidden : int
|
|
92
|
+
Hidden layers per MADE.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, N: int, hidden: int = 64, n_blocks: int = 4, n_hidden: int = 1):
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.N = int(N)
|
|
98
|
+
self.blocks = nn.ModuleList(
|
|
99
|
+
[_MADE(N, hidden, n_hidden) for _ in range(n_blocks)]
|
|
100
|
+
)
|
|
101
|
+
# Fixed permutations (reverse ordering is a standard, cheap choice that
|
|
102
|
+
# guarantees every variable eventually conditions on every other).
|
|
103
|
+
for i in range(n_blocks):
|
|
104
|
+
perm = torch.arange(N - 1, -1, -1) if i % 2 == 0 else torch.arange(N)
|
|
105
|
+
self.register_buffer(f"perm_{i}", perm)
|
|
106
|
+
self.register_buffer("data_mean", torch.zeros(N))
|
|
107
|
+
self.register_buffer("data_std", torch.ones(N))
|
|
108
|
+
|
|
109
|
+
def _perm(self, i: int) -> Tensor:
|
|
110
|
+
return getattr(self, f"perm_{i}")
|
|
111
|
+
|
|
112
|
+
def log_prob(self, x: Tensor) -> Tensor:
|
|
113
|
+
"""Exact log-density of the flow (up to the constant standardization term)."""
|
|
114
|
+
y = (x - self.data_mean) / self.data_std
|
|
115
|
+
log_det = torch.zeros(x.shape[0], device=x.device, dtype=x.dtype)
|
|
116
|
+
for i, made in enumerate(self.blocks):
|
|
117
|
+
y = y[:, self._perm(i)]
|
|
118
|
+
mu, alpha = made(y)
|
|
119
|
+
y = (y - mu) * torch.exp(-alpha)
|
|
120
|
+
log_det = log_det - alpha.sum(dim=-1)
|
|
121
|
+
base = -0.5 * (y.pow(2) + math.log(2 * math.pi)).sum(dim=-1)
|
|
122
|
+
return base + log_det
|
|
123
|
+
|
|
124
|
+
def fit(
|
|
125
|
+
self,
|
|
126
|
+
data: Tensor,
|
|
127
|
+
epochs: int = 200,
|
|
128
|
+
lr: float = 1e-3,
|
|
129
|
+
batch_size: int = 256,
|
|
130
|
+
verbose: bool = False,
|
|
131
|
+
) -> "MAF":
|
|
132
|
+
if data.dim() != 2 or data.shape[1] != self.N:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Expected data of shape (B, {self.N}); got {tuple(data.shape)}"
|
|
135
|
+
)
|
|
136
|
+
data = data.to(self.data_mean)
|
|
137
|
+
with torch.no_grad():
|
|
138
|
+
self.data_mean = data.mean(dim=0)
|
|
139
|
+
self.data_std = data.std(dim=0).clamp_min(1e-6)
|
|
140
|
+
opt = torch.optim.Adam(self.parameters(), lr=lr)
|
|
141
|
+
n = data.shape[0]
|
|
142
|
+
for epoch in range(epochs):
|
|
143
|
+
perm = torch.randperm(n, device=data.device)
|
|
144
|
+
total = 0.0
|
|
145
|
+
for start in range(0, n, batch_size):
|
|
146
|
+
idx = perm[start : start + batch_size]
|
|
147
|
+
loss = -self.log_prob(data[idx]).mean()
|
|
148
|
+
opt.zero_grad()
|
|
149
|
+
loss.backward()
|
|
150
|
+
opt.step()
|
|
151
|
+
total += loss.item() * idx.numel()
|
|
152
|
+
if verbose and (epoch % 20 == 0 or epoch == epochs - 1):
|
|
153
|
+
print(f"[MAF] epoch {epoch:4d} nll={total / n:.4f}")
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
@torch.no_grad()
|
|
157
|
+
def sample(self, n: int) -> Tensor:
|
|
158
|
+
"""Draw ``n`` samples by inverting each block sequentially."""
|
|
159
|
+
y = torch.randn(n, self.N, device=self.data_mean.device, dtype=self.data_mean.dtype)
|
|
160
|
+
for i in reversed(range(len(self.blocks))):
|
|
161
|
+
made = self.blocks[i]
|
|
162
|
+
t = torch.zeros_like(y)
|
|
163
|
+
for d in range(self.N):
|
|
164
|
+
mu, alpha = made(t)
|
|
165
|
+
t[:, d] = y[:, d] * torch.exp(alpha[:, d]) + mu[:, d]
|
|
166
|
+
inv = torch.argsort(self._perm(i))
|
|
167
|
+
y = t[:, inv]
|
|
168
|
+
return y * self.data_std + self.data_mean
|
|
169
|
+
|
|
170
|
+
def extra_repr(self) -> str:
|
|
171
|
+
return f"N={self.N}, n_blocks={len(self.blocks)}"
|
couplnorm/models/pca.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Low-rank Gaussian (PCA) baseline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
__all__ = ["PCAModel"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PCAModel(nn.Module):
|
|
12
|
+
"""Low-rank Gaussian: mean + top-``k`` principal components + isotropic noise.
|
|
13
|
+
|
|
14
|
+
Fits a rank-``k`` approximation of the data covariance and adds isotropic
|
|
15
|
+
residual noise equal to the mean of the discarded eigenvalues, so the model
|
|
16
|
+
is a proper full-rank Gaussian that under-represents off-principal
|
|
17
|
+
directions. Weaker than :class:`FullGaussian`; useful for showing that
|
|
18
|
+
truncating structure lowers coupling fidelity.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
N : int
|
|
23
|
+
Field dimension.
|
|
24
|
+
n_components : int
|
|
25
|
+
Number of principal components to retain.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, N: int, n_components: int = 4):
|
|
29
|
+
super().__init__()
|
|
30
|
+
if n_components < 1 or n_components > N:
|
|
31
|
+
raise ValueError(f"n_components must be in [1, {N}]; got {n_components}")
|
|
32
|
+
self.N = int(N)
|
|
33
|
+
self.k = int(n_components)
|
|
34
|
+
self.register_buffer("mean", torch.zeros(N))
|
|
35
|
+
self.register_buffer("components", torch.zeros(N, self.k)) # scaled by sqrt(eigval)
|
|
36
|
+
self.register_buffer("noise_std", torch.zeros(()))
|
|
37
|
+
self._fitted = False
|
|
38
|
+
|
|
39
|
+
@torch.no_grad()
|
|
40
|
+
def fit(self, data: Tensor) -> "PCAModel":
|
|
41
|
+
if data.dim() != 2 or data.shape[1] != self.N:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Expected data of shape (B, {self.N}); got {tuple(data.shape)}"
|
|
44
|
+
)
|
|
45
|
+
data = data.to(self.mean)
|
|
46
|
+
self.mean = data.mean(dim=0)
|
|
47
|
+
centered = data - self.mean
|
|
48
|
+
cov = centered.T @ centered / (data.shape[0] - 1)
|
|
49
|
+
# eigh returns ascending eigenvalues; take the top k.
|
|
50
|
+
evals, evecs = torch.linalg.eigh(cov)
|
|
51
|
+
evals = evals.clamp_min(0.0)
|
|
52
|
+
top_vals = evals[-self.k:]
|
|
53
|
+
top_vecs = evecs[:, -self.k:]
|
|
54
|
+
self.components = top_vecs * top_vals.sqrt().unsqueeze(0)
|
|
55
|
+
residual = evals[: self.N - self.k]
|
|
56
|
+
self.noise_std = residual.mean().clamp_min(0.0).sqrt() if residual.numel() else torch.zeros(())
|
|
57
|
+
self._fitted = True
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
@torch.no_grad()
|
|
61
|
+
def sample(self, n: int) -> Tensor:
|
|
62
|
+
if not self._fitted:
|
|
63
|
+
raise RuntimeError("Call fit(data) before sample(n).")
|
|
64
|
+
z = torch.randn(n, self.k, device=self.mean.device, dtype=self.mean.dtype)
|
|
65
|
+
x = self.mean + z @ self.components.T
|
|
66
|
+
if self.noise_std > 0:
|
|
67
|
+
x = x + self.noise_std * torch.randn_like(x)
|
|
68
|
+
return x
|
|
69
|
+
|
|
70
|
+
def extra_repr(self) -> str:
|
|
71
|
+
return f"N={self.N}, n_components={self.k}, fitted={self._fitted}"
|
couplnorm/plotting.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Matplotlib helpers for the demo notebooks.
|
|
2
|
+
|
|
3
|
+
Thin convenience wrappers only; matplotlib is an optional dependency (install
|
|
4
|
+
with ``pip install couplnorm[dev]``). Each function accepts an optional ``ax``
|
|
5
|
+
so plots compose into subplot grids.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Sequence
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
from torch import Tensor
|
|
13
|
+
|
|
14
|
+
from couplnorm.coupling import coupling_from_samples
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"plot_coupling_bars",
|
|
18
|
+
"plot_covariance_heatmap",
|
|
19
|
+
"plot_running_trajectory",
|
|
20
|
+
"plot_training",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_mpl():
|
|
25
|
+
try:
|
|
26
|
+
import matplotlib.pyplot as plt # noqa: F401
|
|
27
|
+
except ImportError as exc: # pragma: no cover - trivial guard
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"Plotting requires matplotlib. Install with: pip install couplnorm[dev]"
|
|
30
|
+
) from exc
|
|
31
|
+
return plt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def plot_coupling_bars(
|
|
35
|
+
named_samples: dict[str, Tensor],
|
|
36
|
+
ax=None,
|
|
37
|
+
real_fft: bool = True,
|
|
38
|
+
title: str = "Coupling C across distributions",
|
|
39
|
+
):
|
|
40
|
+
"""Bar chart of C for each named ``(B, N)`` sample tensor."""
|
|
41
|
+
plt = _require_mpl()
|
|
42
|
+
names, values = [], []
|
|
43
|
+
for name, phi in named_samples.items():
|
|
44
|
+
names.append(name)
|
|
45
|
+
values.append(coupling_from_samples(phi, real_fft=real_fft).item())
|
|
46
|
+
if ax is None:
|
|
47
|
+
_, ax = plt.subplots(figsize=(6, 3.5))
|
|
48
|
+
ax.bar(names, values, color="#3b6ea5")
|
|
49
|
+
ax.set_ylabel("C")
|
|
50
|
+
ax.set_ylim(0, max(values + [0.1]) * 1.15)
|
|
51
|
+
ax.set_title(title)
|
|
52
|
+
for i, v in enumerate(values):
|
|
53
|
+
ax.text(i, v, f"{v:.3f}", ha="center", va="bottom", fontsize=9)
|
|
54
|
+
return ax
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plot_covariance_heatmap(cov: Tensor, ax=None, title: str = "Spectral-energy covariance"):
|
|
58
|
+
"""Heatmap of a spectral-energy covariance matrix."""
|
|
59
|
+
plt = _require_mpl()
|
|
60
|
+
if ax is None:
|
|
61
|
+
_, ax = plt.subplots(figsize=(4.5, 4))
|
|
62
|
+
im = ax.imshow(cov.detach().cpu().numpy(), cmap="magma")
|
|
63
|
+
ax.set_xlabel("mode k'")
|
|
64
|
+
ax.set_ylabel("mode k")
|
|
65
|
+
ax.set_title(title)
|
|
66
|
+
ax.figure.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
|
67
|
+
return ax
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def plot_running_trajectory(
|
|
71
|
+
trajectory: Sequence[float],
|
|
72
|
+
batch_C: Optional[float] = None,
|
|
73
|
+
ax=None,
|
|
74
|
+
title: str = "Running C converges to batch C",
|
|
75
|
+
):
|
|
76
|
+
"""Line plot of a running-C trajectory with an optional batch-C reference."""
|
|
77
|
+
plt = _require_mpl()
|
|
78
|
+
if ax is None:
|
|
79
|
+
_, ax = plt.subplots(figsize=(7, 3.5))
|
|
80
|
+
ax.plot(list(trajectory), label="running C")
|
|
81
|
+
if batch_C is not None:
|
|
82
|
+
ax.axhline(batch_C, color="red", linestyle="--", label=f"batch C = {batch_C:.3f}")
|
|
83
|
+
ax.set_xlabel("chunk index")
|
|
84
|
+
ax.set_ylabel("running C")
|
|
85
|
+
ax.set_title(title)
|
|
86
|
+
ax.legend()
|
|
87
|
+
return ax
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def plot_training(
|
|
91
|
+
losses: Sequence[float],
|
|
92
|
+
c_steps: Optional[Sequence[int]] = None,
|
|
93
|
+
c_values: Optional[Sequence[float]] = None,
|
|
94
|
+
target_C: Optional[float] = None,
|
|
95
|
+
):
|
|
96
|
+
"""Two-panel training summary: loss curve and generator-C trajectory."""
|
|
97
|
+
plt = _require_mpl()
|
|
98
|
+
ncols = 2 if c_values is not None else 1
|
|
99
|
+
fig, axes = plt.subplots(1, ncols, figsize=(5.5 * ncols, 4))
|
|
100
|
+
ax0 = axes[0] if ncols == 2 else axes
|
|
101
|
+
ax0.plot(list(losses))
|
|
102
|
+
ax0.set_yscale("log")
|
|
103
|
+
ax0.set_xlabel("step")
|
|
104
|
+
ax0.set_ylabel("loss")
|
|
105
|
+
ax0.set_title("Training loss")
|
|
106
|
+
if ncols == 2:
|
|
107
|
+
ax1 = axes[1]
|
|
108
|
+
ax1.plot(list(c_steps), list(c_values), marker="o", label="generator C")
|
|
109
|
+
if target_C is not None:
|
|
110
|
+
ax1.axhline(target_C, color="red", linestyle="--", label=f"target C = {target_C:.3f}")
|
|
111
|
+
ax1.set_xlabel("step")
|
|
112
|
+
ax1.set_ylabel("C")
|
|
113
|
+
ax1.set_title("Generator C trajectory")
|
|
114
|
+
ax1.legend()
|
|
115
|
+
fig.tight_layout()
|
|
116
|
+
return fig
|
couplnorm/samplers.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Reference sampler for the 1D periodic phi^4 scalar field.
|
|
2
|
+
|
|
3
|
+
This module is plumbing: it exists so users can install couplnorm and
|
|
4
|
+
reproduce the paper plots without writing their own MCMC. The intellectual
|
|
5
|
+
core of the library is ``coupling.py``; this file just feeds it data.
|
|
6
|
+
|
|
7
|
+
Lattice action (a = 1, periodic boundary conditions)::
|
|
8
|
+
|
|
9
|
+
S(phi) = sum_x [ (1/2) (phi_{x+1} - phi_x)^2
|
|
10
|
+
+ (1/2) m2 * phi_x^2
|
|
11
|
+
+ lam * phi_x^4 ]
|
|
12
|
+
|
|
13
|
+
At ``lam = 0`` the action is quadratic and the Fourier basis diagonalizes it,
|
|
14
|
+
so distinct modes are independent Gaussians. The per-mode variance under the
|
|
15
|
+
unitary ("ortho") FFT is then exactly ``1 / (m2 + 4 sin^2(pi k / N))``; see
|
|
16
|
+
:func:`free_theory_mode_variance`. Turning on ``lam > 0`` couples the modes
|
|
17
|
+
through the quartic vertex, which is exactly the structure the C diagnostic
|
|
18
|
+
picks up.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import torch
|
|
26
|
+
from torch import Tensor
|
|
27
|
+
|
|
28
|
+
__all__ = ["Phi4Sampler", "free_theory_mode_variance"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def free_theory_mode_variance(
|
|
32
|
+
N: int,
|
|
33
|
+
m2: float = 1.0,
|
|
34
|
+
real_fft: bool = True,
|
|
35
|
+
device=None,
|
|
36
|
+
dtype=torch.float32,
|
|
37
|
+
) -> Tensor:
|
|
38
|
+
"""Analytical spectral-energy expectation for the free (lam=0) theory.
|
|
39
|
+
|
|
40
|
+
Returns E[|phi_tilde_k|^2] = 1 / (m2 + 4 sin^2(pi k / N)) under the unitary
|
|
41
|
+
FFT convention (``norm="ortho"``). With ``real_fft=True`` only the
|
|
42
|
+
``N//2 + 1`` unique modes are returned, matching :func:`Phi4Sampler.sample`
|
|
43
|
+
followed by ``torch.fft.rfft(..., norm="ortho")``.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
N : int
|
|
48
|
+
Number of lattice sites.
|
|
49
|
+
m2 : float
|
|
50
|
+
Mass-squared parameter of the action.
|
|
51
|
+
real_fft : bool
|
|
52
|
+
If True, return the ``N//2 + 1`` rfft modes; else all ``N`` modes.
|
|
53
|
+
"""
|
|
54
|
+
k = torch.arange(N // 2 + 1 if real_fft else N, device=device, dtype=dtype)
|
|
55
|
+
lattice_momentum = 4.0 * torch.sin(math.pi * k / N).pow(2)
|
|
56
|
+
return 1.0 / (m2 + lattice_momentum)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Phi4Sampler:
|
|
60
|
+
"""Checkerboard Metropolis-Hastings sampler for the 1D phi^4 field.
|
|
61
|
+
|
|
62
|
+
Each call to :meth:`sample` runs ``n`` independent Markov chains in
|
|
63
|
+
parallel (fully vectorized over chains and lattice sites), thermalizes
|
|
64
|
+
them, and returns their final configurations. Because the 1D nearest-
|
|
65
|
+
neighbor action couples only adjacent sites, even-indexed sites are
|
|
66
|
+
conditionally independent given odd-indexed sites (and vice versa), so a
|
|
67
|
+
whole sublattice can be proposed and accepted in one vectorized step.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
N : int
|
|
72
|
+
Number of lattice sites per configuration.
|
|
73
|
+
m2 : float
|
|
74
|
+
Mass-squared parameter of the action.
|
|
75
|
+
lam : float
|
|
76
|
+
Quartic coupling. ``lam = 0`` recovers the free theory.
|
|
77
|
+
step : float
|
|
78
|
+
Half-width of the uniform Metropolis proposal. Tune for ~40-60%
|
|
79
|
+
acceptance; the default is reasonable for ``m2 ~ 1``.
|
|
80
|
+
n_therm : int
|
|
81
|
+
Number of thermalization sweeps before a configuration is returned.
|
|
82
|
+
device, dtype :
|
|
83
|
+
Passed through to the field tensors.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
N: int = 32,
|
|
89
|
+
m2: float = 1.0,
|
|
90
|
+
lam: float = 0.0,
|
|
91
|
+
step: float = 1.0,
|
|
92
|
+
n_therm: int = 1000,
|
|
93
|
+
device=None,
|
|
94
|
+
dtype=torch.float32,
|
|
95
|
+
):
|
|
96
|
+
if N < 2:
|
|
97
|
+
raise ValueError(f"N must be >= 2; got {N}")
|
|
98
|
+
if N % 2 != 0:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"N must be even for checkerboard updates; got {N}"
|
|
101
|
+
)
|
|
102
|
+
if step <= 0:
|
|
103
|
+
raise ValueError(f"step must be > 0; got {step}")
|
|
104
|
+
self.N = int(N)
|
|
105
|
+
self.m2 = float(m2)
|
|
106
|
+
self.lam = float(lam)
|
|
107
|
+
self.step = float(step)
|
|
108
|
+
self.n_therm = int(n_therm)
|
|
109
|
+
self.device = device
|
|
110
|
+
self.dtype = dtype
|
|
111
|
+
# Cached acceptance rate from the most recent run, for diagnostics.
|
|
112
|
+
self.last_acceptance: Optional[float] = None
|
|
113
|
+
parity = torch.arange(self.N) % 2
|
|
114
|
+
self._even = (parity == 0)
|
|
115
|
+
self._odd = (parity == 1)
|
|
116
|
+
|
|
117
|
+
def _color_update(self, phi: Tensor, color_mask: Tensor) -> Tensor:
|
|
118
|
+
"""Propose and accept/reject on one sublattice, in place-safe fashion."""
|
|
119
|
+
left = phi.roll(1, dims=-1) # phi_{x-1}
|
|
120
|
+
right = phi.roll(-1, dims=-1) # phi_{x+1}
|
|
121
|
+
delta = (torch.rand_like(phi) * 2.0 - 1.0) * self.step
|
|
122
|
+
phi_new = phi + delta
|
|
123
|
+
|
|
124
|
+
d_kin = 0.5 * (
|
|
125
|
+
(phi_new - right).pow(2) - (phi - right).pow(2)
|
|
126
|
+
+ (phi_new - left).pow(2) - (phi - left).pow(2)
|
|
127
|
+
)
|
|
128
|
+
d_mass = 0.5 * self.m2 * (phi_new.pow(2) - phi.pow(2))
|
|
129
|
+
d_quartic = self.lam * (phi_new.pow(4) - phi.pow(4))
|
|
130
|
+
dS = d_kin + d_mass + d_quartic
|
|
131
|
+
|
|
132
|
+
accept = torch.rand_like(phi) < torch.exp(-dS)
|
|
133
|
+
do_update = accept & color_mask
|
|
134
|
+
self._accepted += (do_update & color_mask).sum()
|
|
135
|
+
self._proposed += color_mask.sum() * phi.shape[0]
|
|
136
|
+
return torch.where(do_update, phi_new, phi)
|
|
137
|
+
|
|
138
|
+
def sweep(self, phi: Tensor) -> Tensor:
|
|
139
|
+
"""One full checkerboard sweep (even sublattice, then odd)."""
|
|
140
|
+
mask_even = self._even.to(phi.device)
|
|
141
|
+
mask_odd = self._odd.to(phi.device)
|
|
142
|
+
phi = self._color_update(phi, mask_even)
|
|
143
|
+
phi = self._color_update(phi, mask_odd)
|
|
144
|
+
return phi
|
|
145
|
+
|
|
146
|
+
@torch.no_grad()
|
|
147
|
+
def sample(self, n: int, seed: Optional[int] = None) -> Tensor:
|
|
148
|
+
"""Draw ``n`` thermalized configurations, shape ``(n, N)``.
|
|
149
|
+
|
|
150
|
+
Runs ``n`` parallel chains from a random start and returns each chain's
|
|
151
|
+
configuration after ``n_therm`` sweeps.
|
|
152
|
+
"""
|
|
153
|
+
if n < 1:
|
|
154
|
+
raise ValueError(f"n must be >= 1; got {n}")
|
|
155
|
+
if seed is not None:
|
|
156
|
+
torch.manual_seed(seed)
|
|
157
|
+
phi = torch.randn(n, self.N, device=self.device, dtype=self.dtype)
|
|
158
|
+
self._accepted = torch.zeros((), device=phi.device)
|
|
159
|
+
self._proposed = torch.zeros((), device=phi.device)
|
|
160
|
+
for _ in range(self.n_therm):
|
|
161
|
+
phi = self.sweep(phi)
|
|
162
|
+
denom = self._proposed.clamp_min(1.0)
|
|
163
|
+
self.last_acceptance = float((self._accepted / denom).item())
|
|
164
|
+
return phi
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return (
|
|
168
|
+
f"Phi4Sampler(N={self.N}, m2={self.m2}, lam={self.lam}, "
|
|
169
|
+
f"step={self.step}, n_therm={self.n_therm})"
|
|
170
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: couplnorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Normalized 4th-order coupling metrics and losses for generative model evaluation.
|
|
5
|
+
Author: Anish Bhat
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anishbhat28/couplnorm
|
|
8
|
+
Project-URL: Issues, https://github.com/anishbhat28/couplnorm/issues
|
|
9
|
+
Keywords: generative-models,normalizing-flows,pytorch,coupling,spectral-analysis,model-evaluation,non-gaussianity,fourth-order-statistics,lattice-field-theory,scientific-ml
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: torch>=2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: matplotlib; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# couplnorm
|
|
28
|
+
|
|
29
|
+
**Normalized 4th-order coupling metrics and losses for generative model evaluation.**
|
|
30
|
+
|
|
31
|
+
`couplnorm` implements the normalized off-diagonal coupling diagnostic **C**, a
|
|
32
|
+
4th-order spectral statistic that measures *joint* coupling between Fourier modes
|
|
33
|
+
— structure that second-order metrics (power spectrum, two-point function) miss
|
|
34
|
+
by construction.
|
|
35
|
+
|
|
36
|
+
> Marginal Gaussianity does not imply joint independence. **C measures the gap.**
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
All three distributions above have (near-)identical per-mode marginals; a power
|
|
41
|
+
spectrum cannot tell them apart. C rises from ~0.04 to ~0.82 as joint 4th-order
|
|
42
|
+
coupling is switched on. (Reproduce with `notebooks/01_basic_usage.ipynb`.)
|
|
43
|
+
|
|
44
|
+
Given field samples, `couplnorm` computes the covariance of the per-mode spectral
|
|
45
|
+
energies `E_k = |φ̃_k|²` and reduces it to a single scale-free number:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
C = ‖Σ − diag(Σ)‖_F / ‖Σ‖_F
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`C = 0` means the spectral energies are pairwise uncorrelated (a Gaussian /
|
|
52
|
+
free-field signature); `C → 1` means off-diagonal coupling dominates.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install -e .[dev] # from a checkout
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python ≥ 3.10 and PyTorch ≥ 2.0.
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import torch
|
|
66
|
+
from couplnorm import coupling_from_samples, CouplingMetric, CouplingLoss
|
|
67
|
+
|
|
68
|
+
# One-shot number from a batch of (B, N) real fields
|
|
69
|
+
phi = torch.randn(10_000, 32)
|
|
70
|
+
print(coupling_from_samples(phi).item()) # ~0.03 (independent modes)
|
|
71
|
+
|
|
72
|
+
# As a streaming nn.Module metric
|
|
73
|
+
metric = CouplingMetric(mode="running", momentum=0.02, n_modes=32 // 2 + 1)
|
|
74
|
+
for chunk in loader: # doctest: +SKIP
|
|
75
|
+
metric(chunk)
|
|
76
|
+
print(metric.compute().item())
|
|
77
|
+
|
|
78
|
+
# As a training loss that matches a reference distribution's coupling
|
|
79
|
+
loss_fn = CouplingLoss.from_data(reference_data, target_type="matrix") # doctest: +SKIP
|
|
80
|
+
loss = loss_fn(generated_batch) # doctest: +SKIP
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Three forms
|
|
84
|
+
|
|
85
|
+
| Object | Use |
|
|
86
|
+
| ----------------------- | ------------------------------------------------------------- |
|
|
87
|
+
| `coupling_from_samples` | one-line functional helper — just want the number |
|
|
88
|
+
| `CouplingMetric` | `nn.Module` metric; batch **or** streaming (running EMA) mode |
|
|
89
|
+
| `CouplingLoss` | differentiable loss: match a covariance, match a scalar C, or minimize C (DeCov-style) |
|
|
90
|
+
|
|
91
|
+
## The FFT convention (important)
|
|
92
|
+
|
|
93
|
+
For a **real** field of length `N`, the DFT is conjugate-symmetric
|
|
94
|
+
(`φ̃_k = φ̃*_{N−k}`), so `|φ̃_k|² = |φ̃_{N−k}|²` *exactly*. Feeding the full FFT into
|
|
95
|
+
C creates `N − 2` off-diagonal entries that equal diagonal entries before any
|
|
96
|
+
physics enters, inflating C by an analytical floor of
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
C_floor = sqrt((N − 2) / (2N − 2)) ≈ 0.7
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`couplnorm` defaults to `real_fft=True` (uses `torch.fft.rfft`, keeping only the
|
|
103
|
+
`N//2 + 1` unique modes), which reflects only genuine coupling. Set
|
|
104
|
+
`real_fft=False` only for intrinsically complex fields. A regression test pins
|
|
105
|
+
the analytical floor so this convention can't silently break.
|
|
106
|
+
|
|
107
|
+
## What's in the box
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/couplnorm/
|
|
111
|
+
coupling.py # the metric, loss, and helpers (the contribution)
|
|
112
|
+
samplers.py # Phi4Sampler: 1D phi^4 Metropolis-Hastings reference sampler
|
|
113
|
+
models/ # FourierModel, FullGaussian, PCAModel, MAF baselines
|
|
114
|
+
plotting.py # matplotlib helpers for the notebooks
|
|
115
|
+
notebooks/
|
|
116
|
+
01_basic_usage.ipynb # compare C across distributions
|
|
117
|
+
02_reproduce_paper.ipynb # phi^4 free vs interacting theory
|
|
118
|
+
03_coupling_as_loss.ipynb # train a generator to match coupling
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Baselines share one interface: construct, `.fit(data)` where applicable, then
|
|
122
|
+
`.sample(n)` returning `(n, N)` real position-space fields. The `FourierModel`
|
|
123
|
+
assumes marginal mode independence, so it collapses C to ≈ 0 regardless of the
|
|
124
|
+
target — the concrete demonstration of why C is worth measuring.
|
|
125
|
+
|
|
126
|
+
## Case study
|
|
127
|
+
|
|
128
|
+
The φ⁴ lattice study that motivates C is:
|
|
129
|
+
|
|
130
|
+
> A. Bhat, R. Ide, Z. Zhao. *When Independent Gaussian Models Break Down:
|
|
131
|
+
> Characterizing Regime-Dependent Modeling Failures in φ⁴ Theory.*
|
|
132
|
+
> arXiv:[2605.01145](https://arxiv.org/abs/2605.01145).
|
|
133
|
+
|
|
134
|
+
That paper's reported C values (C(N=32) ≈ 0.06 up to C(N=128) ≈ 0.2, strong
|
|
135
|
+
coupling saturating near 0.17–0.18) are computed on the **unique rfft modes** —
|
|
136
|
+
the `real_fft=True` default here. The full FFT would inflate every value to the
|
|
137
|
+
~0.7 conjugate-symmetry floor (see above), so the published magnitudes are only
|
|
138
|
+
consistent with the unique-mode convention.
|
|
139
|
+
|
|
140
|
+
## Citation
|
|
141
|
+
|
|
142
|
+
See [`CITATION.cff`](CITATION.cff).
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
couplnorm/__init__.py,sha256=a3VlmkTPH0tPyYWQsETWbfvkvQ6hEz-OJJsUkieOYns,322
|
|
2
|
+
couplnorm/coupling.py,sha256=-E6WaSOmG8VyUL757kYOpKMDLBtsZgtJdxH1nm6B_jE,10508
|
|
3
|
+
couplnorm/plotting.py,sha256=Vo221PiUPsOTK-JfMT17Gf6e5RIYVD8JlGEc-gETydM,3670
|
|
4
|
+
couplnorm/samplers.py,sha256=k9J3G4pCSBx9SxfSpP7iwSrN4RjFAQLyimGc_D9fppg,6296
|
|
5
|
+
couplnorm/models/__init__.py,sha256=7lWgWvbc1oJ2vi73WYDeF1wwVZTcdsQf4hC2CW3Y6AY,1225
|
|
6
|
+
couplnorm/models/fourier.py,sha256=GMLZoUckhQIWl8rLf6VIKhRCsimnDCJOCC8VvLn8FK8,2932
|
|
7
|
+
couplnorm/models/gaussian.py,sha256=pTVz12ueYEsJ3uOxDkwOEtZ8phHROO2r8lzTRDZFNeQ,2160
|
|
8
|
+
couplnorm/models/maf.py,sha256=ckYFU32Ui1HzvJbjoAGkGO9TwYUmUxEgNNPhlXVwodo,6476
|
|
9
|
+
couplnorm/models/pca.py,sha256=nZpT8sleIdie2Vp6lWerbsmSvV6zZohGrdsLIO16Mww,2683
|
|
10
|
+
couplnorm-0.1.0.dist-info/licenses/LICENSE,sha256=aAGv9YvWMyE7dbE_qCI-swi8JzEqG7IYb5gkokxGeRg,1067
|
|
11
|
+
couplnorm-0.1.0.dist-info/METADATA,sha256=2GazRukFWRQ0N7iwnT6sYQh8YI7hHLo98ItXTzVLtK0,5843
|
|
12
|
+
couplnorm-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
couplnorm-0.1.0.dist-info/top_level.txt,sha256=9KoE72M7B4GVWSK3K7Yh81R3HeKYAepSL9J3WjATMzk,10
|
|
14
|
+
couplnorm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anish Bhat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
couplnorm
|