funcyflows 0.2.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.
Files changed (42) hide show
  1. FuncyFlows/__init__.py +0 -0
  2. FuncyFlows/base_measures/__init__.py +3 -0
  3. FuncyFlows/base_measures/abstract_reference_measure.py +28 -0
  4. FuncyFlows/base_measures/bases.py +112 -0
  5. FuncyFlows/base_measures/gaussian_reference_measure.py +37 -0
  6. FuncyFlows/diagnostics/__init__.py +2 -0
  7. FuncyFlows/diagnostics/coverage.py +46 -0
  8. FuncyFlows/diagnostics/importance.py +60 -0
  9. FuncyFlows/examples/__init__.py +1 -0
  10. FuncyFlows/examples/__main__.py +36 -0
  11. FuncyFlows/examples/_common.py +414 -0
  12. FuncyFlows/examples/bimodal_posterior.py +129 -0
  13. FuncyFlows/examples/cloud_inpainting.py +180 -0
  14. FuncyFlows/examples/nonstationary.py +127 -0
  15. FuncyFlows/examples/phase_inpainting.py +152 -0
  16. FuncyFlows/examples/positivity.py +79 -0
  17. FuncyFlows/examples/ring_inpainting.py +125 -0
  18. FuncyFlows/objectives/__init__.py +5 -0
  19. FuncyFlows/objectives/alpha_divergence.py +67 -0
  20. FuncyFlows/objectives/flow_matching.py +132 -0
  21. FuncyFlows/objectives/negative_logl.py +15 -0
  22. FuncyFlows/objectives/reverse_kl.py +48 -0
  23. FuncyFlows/samplers/__init__.py +4 -0
  24. FuncyFlows/samplers/latent_pcn.py +219 -0
  25. FuncyFlows/transports/__init__.py +3 -0
  26. FuncyFlows/transports/abstract_transformation.py +32 -0
  27. FuncyFlows/transports/continuous/__init__.py +6 -0
  28. FuncyFlows/transports/continuous/base_continuous.py +63 -0
  29. FuncyFlows/transports/continuous/conditioners.py +83 -0
  30. FuncyFlows/transports/continuous/grid_fields.py +944 -0
  31. FuncyFlows/transports/continuous/vector_fields.py +306 -0
  32. FuncyFlows/transports/layers/__init__.py +2 -0
  33. FuncyFlows/transports/layers/base_discrete.py +29 -0
  34. FuncyFlows/transports/layers/layer_classes.py +81 -0
  35. FuncyFlows/utils/__init__.py +0 -0
  36. FuncyFlows/utils/gaussian_misfit.py +16 -0
  37. FuncyFlows/utils/train.py +31 -0
  38. funcyflows-0.2.0.dist-info/METADATA +177 -0
  39. funcyflows-0.2.0.dist-info/RECORD +42 -0
  40. funcyflows-0.2.0.dist-info/WHEEL +5 -0
  41. funcyflows-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. funcyflows-0.2.0.dist-info/top_level.txt +1 -0
FuncyFlows/__init__.py ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from .bases import Basis, CosineBasis, FourierBasis
2
+ from .abstract_reference_measure import ReferenceMeasure
3
+ from .gaussian_reference_measure import GaussianReferenceMeasure
@@ -0,0 +1,28 @@
1
+ import torch
2
+
3
+ from .bases import Basis
4
+
5
+
6
+ class ReferenceMeasure:
7
+ """Probability measure on coefficient vectors [..., num_functions] in the given basis."""
8
+
9
+ def __init__(self, basis: Basis, dtype=torch.float64):
10
+ self.basis = basis
11
+ self.num_functions = basis.num_functions
12
+ self.dtype = dtype
13
+
14
+ def sample(self, num_samples: int):
15
+ """-> coeffs [num_samples, num_functions]"""
16
+ raise NotImplementedError
17
+
18
+ def log_density_diff(self, coeffs_from, coeffs_to):
19
+ """-> log p(coeffs_from) - log p(coeffs_to), shape [...]. The thing transport needs."""
20
+ raise NotImplementedError
21
+
22
+ def in_support(self, coeffs):
23
+ # always true: Gaussian / Student-t have full support. Adjust for bounded domains.
24
+ return torch.ones(coeffs.shape[:-1], dtype=torch.bool, device=coeffs.device)
25
+
26
+ def evaluate(self, coeffs, points):
27
+ """coeffs [..., num_functions] -> function values [..., num_points]"""
28
+ return coeffs @ self.basis.evaluate(points).T.to(coeffs)
@@ -0,0 +1,112 @@
1
+ import torch
2
+
3
+
4
+ class Basis:
5
+ """Abstract basis class.
6
+
7
+ Args:
8
+ num_functions (int): Number of functions you want to play around with.
9
+ physical_dim (int, optional): Number of physical dimensions the functions will be over. Defaults to 1.
10
+ dtype (optional): Data type. If you're reading this and don't know what that is. God help you. Defaults to torch.float64.
11
+ """
12
+
13
+ def __init__(self, num_functions: int, physical_dim=1, dtype=torch.float64):
14
+ self.num_functions = num_functions
15
+ self.physical_dim = physical_dim
16
+ self.dtype = dtype
17
+
18
+ def evaluate(self, points):
19
+ """points [num_points, physical_dim] -> [num_points, num_functions], L2-orthonormal columns on [0,1]^d."""
20
+ raise NotImplementedError("You are calling an abstract eval method on a Basis class.")
21
+
22
+ def quadrature(self, num_points_per_axis):
23
+ quad_points = torch.linspace(0, 1, num_points_per_axis, dtype=self.dtype)
24
+ quad_weights = torch.full_like(quad_points, 1 / (num_points_per_axis - 1))
25
+ quad_weights[[0, -1]] /= 2
26
+ points = torch.cartesian_prod(*[quad_points] * self.physical_dim).reshape(-1, self.physical_dim)
27
+ weights = torch.cartesian_prod(*[quad_weights] * self.physical_dim).reshape(-1, self.physical_dim).prod(1)
28
+ return points, weights
29
+
30
+
31
+ def _leading_modes(num_functions, physical_dim, per_axis, sort_key):
32
+ """The first num_functions multi-indices from a per_axis^d grid, ordered by sort_key.
33
+
34
+ per_axis only has to reach past the ball that holds num_functions modes. The n-th mode in d
35
+ dimensions sits at radius ~ (n / volume of the unit d-ball)^(1/d), so a bound of order
36
+ n^(1/d) is enough; arange(2n) per axis — the previous choice — built a (2n)^d grid, which at
37
+ 2000 modes in 2-D is 16M entries sorted on every construction (seconds), and in 3-D cannot be
38
+ allocated at all. The selection is only right if the ball fits inside the grid, so the first
39
+ point outside the grid, (per_axis, 0, ...), must sort strictly after the last mode kept.
40
+ """
41
+ axes = [torch.arange(per_axis)] * physical_dim
42
+ modes = torch.cartesian_prod(*axes).reshape(-1, physical_dim) if physical_dim > 1 else axes[0][:, None]
43
+ order = torch.argsort(sort_key(modes), stable=True)[:num_functions]
44
+ outside = torch.zeros(1, physical_dim, dtype=modes.dtype)
45
+ outside[0, 0] = per_axis
46
+ if len(order) < num_functions or sort_key(outside) <= sort_key(modes[order[-1:]]):
47
+ raise ValueError(f"per-axis bound {per_axis} too small for {num_functions} modes in {physical_dim}-D")
48
+ return modes[order]
49
+
50
+
51
+ class CosineBasis(Basis):
52
+ """Neumann Laplacian eigenfunctions on [0,1]^d: prod_axis norm * cos(pi m x).
53
+
54
+ Args:
55
+ num_functions (int): Number of functions you want to play around with.
56
+ physical_dim (int, optional): Number of physical dimensions the functions will be over. Defaults to 1.
57
+ dtype (optional): Data type. If you're reading this and don't know what that is. God help you. Defaults to torch.float64.
58
+ """
59
+
60
+ def __init__(self, num_functions: int, physical_dim: int = 1, dtype=torch.float64):
61
+ super().__init__(num_functions, physical_dim, dtype=dtype)
62
+ per_axis = num_functions + 1 if physical_dim == 1 else int(2 * num_functions ** (1 / physical_dim)) + 3
63
+ self.modes = _leading_modes(num_functions, physical_dim, per_axis,
64
+ lambda modes: (modes ** 2).sum(1)).to(self.dtype)
65
+ self.eval_norm = 1 + (2 ** 0.5 - 1) * (self.modes != 0).to(self.dtype)
66
+ self.laplacian_eigenvalues = ((torch.pi * self.modes) ** 2).sum(1)
67
+
68
+ def evaluate(self, points):
69
+ # one axis at a time: peak memory is num_points x num_functions, not x physical_dim as
70
+ # well. .to(points): the mode tables follow the points' device and dtype.
71
+ points = points.reshape(-1, self.physical_dim)
72
+ modes, norms = self.modes.to(points), self.eval_norm.to(points)
73
+ values = None
74
+ for axis in range(self.physical_dim):
75
+ factor = norms[:, axis] * torch.cos(torch.pi * points[:, axis, None] * modes[:, axis])
76
+ values = factor if values is None else values * factor
77
+ return values
78
+
79
+
80
+ class FourierBasis(Basis):
81
+ """Periodic Laplacian eigenfunctions on [0,1]^d: 1, sqrt2 cos(2 pi k t), sqrt2 sin(2 pi k t), ...
82
+
83
+ Per axis, mode index m -> wavenumber ceil(m/2); odd m is a cosine, even m > 0 a sine.
84
+ Columns are ordered by Laplacian eigenvalue (2 pi)^2 * sum_axis wavenumber^2.
85
+
86
+ Args:
87
+ num_functions (int): Number of functions you want to play around with.
88
+ physical_dim (int, optional): Number of physical dimensions the functions will be over. Defaults to 1.
89
+ dtype (optional): Data type. If you're reading this and don't know what that is. God help you. Defaults to torch.float64.
90
+ """
91
+
92
+ def __init__(self, num_functions: int, physical_dim=1, dtype=torch.float64):
93
+ super().__init__(num_functions, physical_dim, dtype)
94
+ # two mode indices per wavenumber, so twice the cosine bound, plus slack
95
+ per_axis = num_functions + 1 if physical_dim == 1 else 2 * int(2 * num_functions ** (1 / physical_dim)) + 3
96
+ modes = _leading_modes(num_functions, physical_dim, per_axis,
97
+ lambda modes: (((modes + 1) // 2) ** 2).sum(1))
98
+ self.wavenumbers = ((modes + 1) // 2).to(dtype)
99
+ self.phases = -torch.pi / 2 * ((modes % 2 == 0) & (modes > 0)).to(dtype) # sine = shifted cosine
100
+ self.norm_factors = 1 + (2 ** 0.5 - 1) * (modes > 0).to(dtype)
101
+ self.laplacian_eigenvalues = ((2 * torch.pi * self.wavenumbers) ** 2).sum(1)
102
+
103
+ def evaluate(self, points):
104
+ points = points.reshape(-1, self.physical_dim)
105
+ wavenumbers, phases, norms = (self.wavenumbers.to(points), self.phases.to(points),
106
+ self.norm_factors.to(points))
107
+ values = None
108
+ for axis in range(self.physical_dim): # same axis-by-axis product
109
+ angles = 2 * torch.pi * points[:, axis, None] * wavenumbers[:, axis] + phases[:, axis]
110
+ factor = norms[:, axis] * torch.cos(angles)
111
+ values = factor if values is None else values * factor
112
+ return values
@@ -0,0 +1,37 @@
1
+ import torch
2
+
3
+ from .abstract_reference_measure import ReferenceMeasure
4
+
5
+
6
+ class GaussianReferenceMeasure(ReferenceMeasure):
7
+ """N(0, C0) with C0 = (I - alpha Laplacian)^(-power), diagonal in the basis:
8
+ variance_k = (1 + alpha * sigma_k)^(-power), sigma_k = basis.laplacian_eigenvalues[k].
9
+
10
+ The measure lives wherever its variances live: pass variances already on a device (or call
11
+ .move_to(device)) and sample() draws there. Previously sample() always drew on the CPU in the
12
+ default dtype, which every GPU/MPS run then had to work around with a subclass.
13
+ """
14
+
15
+ def __init__(self, basis, alpha=0.1, power=2.0, variances=None, dtype=torch.float64):
16
+ if variances is None:
17
+ variances = ((1 + alpha * basis.laplacian_eigenvalues) ** (-power)).to(dtype)
18
+ elif not torch.is_tensor(variances):
19
+ variances = torch.as_tensor(variances, dtype=dtype)
20
+ super().__init__(basis, variances.dtype) # dtype follows the variances
21
+ self.variances, self.scale = variances, variances.sqrt()
22
+
23
+ def move_to(self, device):
24
+ self.variances, self.scale = self.variances.to(device), self.scale.to(device)
25
+ return self
26
+
27
+ def sample(self, num_samples):
28
+ return self.scale * torch.randn(num_samples, self.num_functions,
29
+ dtype=self.scale.dtype, device=self.scale.device)
30
+
31
+ def log_density_diff(self, coeffs_from, coeffs_to):
32
+ # only this simple because the modes are independent
33
+ return 0.5 * ((coeffs_to ** 2 - coeffs_from ** 2) / self.variances).sum(-1)
34
+
35
+ def cameron_martin_norm2(self, coeffs):
36
+ """Squared Cameron-Martin norm: the L2 norm after whitening by the covariance."""
37
+ return (coeffs ** 2 / self.variances).sum(-1)
@@ -0,0 +1,2 @@
1
+ from .importance import ImportanceCorrection
2
+ from .coverage import coverage_curve, coverage_error
@@ -0,0 +1,46 @@
1
+ import torch
2
+
3
+
4
+ def coverage_curve(samples, truths, references=None, weights=None, levels=None):
5
+ """TARP: expected coverage from samples alone (Lemos et al. 2023).
6
+
7
+ For each test case, count the fraction of posterior draws that land closer to a random
8
+ reference point than the truth does. If the posterior is right, those fractions are uniform,
9
+ so plotting the empirical CDF against the credibility level gives the diagonal.
10
+
11
+ Unlike rank statistics it needs no density and no per-dimension marginalisation, and unlike
12
+ simulation-based calibration it is necessary AND sufficient: a model that ignores the data and
13
+ returns the prior is perfectly self-consistent but fails this.
14
+
15
+ Read the deviation like this: BELOW the diagonal is overconfident (posteriors too narrow),
16
+ ABOVE is conservative (too wide). Both are failures, and they look nothing alike, which is
17
+ why the curve is worth more than the scalar.
18
+
19
+ samples [cases, draws, modes]; truths [cases, modes]; references default to the truths rolled
20
+ by one, which is a draw from the same marginal without needing the prior. weights scale the
21
+ distance, so pass the Cameron-Martin weights to measure in the metric the prior lives in.
22
+ """
23
+ levels = torch.linspace(0.05, 0.95, 19) if levels is None else levels
24
+ levels = levels.to(device=samples.device, dtype=samples.dtype)
25
+ if references is None:
26
+ references = truths.roll(1, dims=0)
27
+ scaled_samples = samples if weights is None else samples * weights
28
+ scaled_truths = truths if weights is None else truths * weights
29
+ scaled_references = references if weights is None else references * weights
30
+ truth_distance = (scaled_truths - scaled_references).norm(dim=-1)
31
+ draw_distance = (scaled_samples - scaled_references.unsqueeze(1)).norm(dim=-1)
32
+ closer = (draw_distance < truth_distance.unsqueeze(1)).to(samples.dtype).mean(1)
33
+ coverage = (closer.unsqueeze(0) <= levels.unsqueeze(1)).to(samples.dtype).mean(1)
34
+ return levels, coverage
35
+
36
+
37
+ def coverage_error(levels, coverage):
38
+ """One number for the curve: the largest gap from the diagonal, signed by direction.
39
+
40
+ Positive means conservative on balance, negative overconfident. On a Gaussian check the exact
41
+ posterior scored 0.03 with 800 cases, a posterior 40% too narrow scored -0.18, and one 60% too
42
+ wide +0.19 -- so roughly 0.05 is the noise floor at that many cases.
43
+ """
44
+ deviation = coverage - levels
45
+ largest = deviation.abs().argmax()
46
+ return deviation[largest].item()
@@ -0,0 +1,60 @@
1
+ import math
2
+
3
+ import torch
4
+
5
+
6
+ class ImportanceCorrection:
7
+ """Turn an approximate posterior into an asymptotically exact one, and measure how good it was
8
+ (Dax et al. 2023).
9
+
10
+ The proposal is whatever produced the draws -- an amortised posterior, a reverse-KL fit, a
11
+ guided sampler. The weights are
12
+
13
+ log w = -Phi(x) - log d(q)/d(mu0)(x)
14
+
15
+ i.e. the true unnormalised posterior over the proposal, both taken against the same reference
16
+ measure so the Radon-Nikodym derivatives are the ones the flow already computes. Nothing is
17
+ trained and nothing is assumed about the proposal beyond being able to evaluate its density.
18
+
19
+ Three things come out:
20
+ * a corrected posterior -- reweight, or resample, and every expectation is consistent;
21
+ * `efficiency`, the effective sample size over the sample count, which is a HARD verdict on
22
+ the proposal (Dax et al. report a median of about 10% and treat low values as failures);
23
+ * `log_evidence`, unbiased up to the reference measure's own normalisation, so model
24
+ comparison between structures comes free.
25
+
26
+ The catch is honest: efficiency falls with dimension, and a proposal narrower than the
27
+ posterior can score well on every self-consistency check and still be wrong -- which is
28
+ exactly what the efficiency number exposes.
29
+ """
30
+
31
+ def __init__(self, flow, draws, potential, context=None):
32
+ self.draws = draws
33
+ with torch.no_grad():
34
+ self.log_ratio = -potential(draws) - flow.log_rn_at(draws, context)
35
+ self.weights = torch.softmax(self.log_ratio, 0)
36
+
37
+ @property
38
+ def efficiency(self):
39
+ """Effective sample size over the sample count, in [1/count, 1]."""
40
+ return (1 / (len(self.weights) * self.weights.pow(2).sum())).item()
41
+
42
+ @property
43
+ def log_evidence(self):
44
+ """log mean w: the evidence, up to the reference measure's normalisation."""
45
+ return (torch.logsumexp(self.log_ratio, 0) - math.log(len(self.log_ratio))).item()
46
+
47
+ def mean(self):
48
+ return self.weights @ self.draws
49
+
50
+ def standard_deviation(self):
51
+ centred = self.draws - self.mean()
52
+ return (self.weights @ centred.pow(2)).sqrt()
53
+
54
+ def resample(self, count=None):
55
+ """Equally weighted draws, for anything downstream that cannot carry weights (plots)."""
56
+ count = count or len(self.draws)
57
+ offsets = (torch.rand((), device=self.weights.device, dtype=self.weights.dtype)
58
+ + torch.arange(count, device=self.weights.device, dtype=self.weights.dtype)) / count
59
+ picked = torch.searchsorted(self.weights.cumsum(0), offsets).clamp(max=len(self.draws) - 1)
60
+ return self.draws[picked]
@@ -0,0 +1 @@
1
+ """Runnable examples. `python -m FuncyFlows.examples` copies them into the current directory."""
@@ -0,0 +1,36 @@
1
+ """Copy the example scripts into the current directory:
2
+
3
+ python -m FuncyFlows.examples # copies *.py (skips files that already exist)
4
+ python -m FuncyFlows.examples --force # overwrite
5
+ python -m FuncyFlows.examples --list
6
+
7
+ Then, e.g.: python bimodal_posterior.py
8
+ """
9
+ import pathlib
10
+ import shutil
11
+ import sys
12
+
13
+ HERE = pathlib.Path(__file__).parent
14
+ EXAMPLES = sorted(p for p in HERE.glob("*.py") if not p.name.startswith("__"))
15
+
16
+
17
+ def main(argv):
18
+ if "--list" in argv:
19
+ for path in EXAMPLES:
20
+ doc = (path.read_text().split('"""')[1].strip().splitlines() or [""])[0]
21
+ print(f" {path.name:24s} {doc}")
22
+ return
23
+ force = "--force" in argv
24
+ target = pathlib.Path.cwd()
25
+ for path in EXAMPLES:
26
+ dest = target / path.name
27
+ if dest.exists() and not force:
28
+ print(f" exists, skipped: {dest.name} (--force to overwrite)")
29
+ continue
30
+ shutil.copy(path, dest)
31
+ print(f" wrote {dest.name}")
32
+ print("\nrun any of them with python <name>.py (they import _common.py from the same directory)")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main(sys.argv[1:])