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 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}"
@@ -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)}"
@@ -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
+ ![C across distributions with matched marginals](assets/coupling_hook.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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