funcflows 0.1.2__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.
- FuncFlows/__init__.py +0 -0
- FuncFlows/base_measures/__init__.py +3 -0
- FuncFlows/base_measures/abstract_reference_measure.py +28 -0
- FuncFlows/base_measures/bases.py +112 -0
- FuncFlows/base_measures/gaussian_reference_measure.py +37 -0
- FuncFlows/diagnostics/__init__.py +2 -0
- FuncFlows/diagnostics/coverage.py +46 -0
- FuncFlows/diagnostics/importance.py +60 -0
- FuncFlows/objectives/__init__.py +5 -0
- FuncFlows/objectives/alpha_divergence.py +67 -0
- FuncFlows/objectives/flow_matching.py +132 -0
- FuncFlows/objectives/negative_logl.py +15 -0
- FuncFlows/objectives/reverse_kl.py +48 -0
- FuncFlows/samplers/__init__.py +1 -0
- FuncFlows/samplers/latent_pcn.py +53 -0
- FuncFlows/transports/__init__.py +3 -0
- FuncFlows/transports/abstract_transformation.py +32 -0
- FuncFlows/transports/continuous/__init__.py +6 -0
- FuncFlows/transports/continuous/base_continuous.py +63 -0
- FuncFlows/transports/continuous/conditioners.py +83 -0
- FuncFlows/transports/continuous/grid_fields.py +403 -0
- FuncFlows/transports/continuous/vector_fields.py +306 -0
- FuncFlows/transports/layers/__init__.py +2 -0
- FuncFlows/transports/layers/base_discrete.py +29 -0
- FuncFlows/transports/layers/layer_classes.py +81 -0
- FuncFlows/utils/__init__.py +0 -0
- FuncFlows/utils/gaussian_misfit.py +16 -0
- FuncFlows/utils/train.py +31 -0
- funcflows-0.1.2.dist-info/METADATA +43 -0
- funcflows-0.1.2.dist-info/RECORD +33 -0
- funcflows-0.1.2.dist-info/WHEEL +5 -0
- funcflows-0.1.2.dist-info/licenses/LICENSE +21 -0
- funcflows-0.1.2.dist-info/top_level.txt +1 -0
FuncFlows/__init__.py
ADDED
|
File without changes
|
|
@@ -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,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,67 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AlphaDivergence:
|
|
7
|
+
"""Renyi / alpha divergence between the flow q and the posterior, as a mass-covering
|
|
8
|
+
alternative to reverse KL:
|
|
9
|
+
|
|
10
|
+
D_alpha = log E_q[w^alpha] / (alpha (alpha - 1)), w = ptilde / q.
|
|
11
|
+
|
|
12
|
+
alpha = 2 is the chi^2 divergence of FAB (Midgley et al. 2023): the variance of the
|
|
13
|
+
importance weights, which punishes exactly the regions where q is too narrow. 0 < alpha < 1
|
|
14
|
+
sits between reverse KL (alpha -> 0) and the evidence (alpha -> 1 from below in the Li &
|
|
15
|
+
Turner 2016 convention). The 1/(alpha - 1) is essential: for 0 < alpha < 1 the expectation is
|
|
16
|
+
MAXIMISED at q = p, so dividing by alpha alone would push the flow away from the posterior.
|
|
17
|
+
|
|
18
|
+
Where the samples come from decides whether this is usable. Estimated with draws from q
|
|
19
|
+
alone, E_q[w^alpha] for alpha > 1 only sees where q already has mass, and the gradient can
|
|
20
|
+
make q ever narrower (log q -> infinity, w -> 0, loss -> -infinity) without the estimator
|
|
21
|
+
ever noticing the posterior mass it left behind -- the true quantity is bounded below by
|
|
22
|
+
Z^alpha, the estimate is not. FAB fixes this with AIS toward ptilde^alpha q^(1-alpha). The fix
|
|
23
|
+
here is simpler: a defensive mixture proposal r = (1 - f) q + f mu0, evaluated by importance
|
|
24
|
+
weights,
|
|
25
|
+
|
|
26
|
+
E_q[w^alpha] = E_r[ ptilde^alpha q^(1-alpha) / r ],
|
|
27
|
+
|
|
28
|
+
with everything written against the reference measure, so only log dq/dmu0 (the quantity the
|
|
29
|
+
flow already computes) and Phi appear. The prior half covers the whole posterior support, so
|
|
30
|
+
a q that collapses is caught by the prior draws it no longer covers. prior_fraction = 0 is
|
|
31
|
+
the plain q-sample estimator, kept for comparison; the default 0.5 is what to use.
|
|
32
|
+
|
|
33
|
+
log-sum-exp throughout, so the weights never leave log space.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, transformation, potential, num_samples=30, alpha=2.0, context=None,
|
|
37
|
+
prior_fraction=0.5):
|
|
38
|
+
if alpha in (0.0, 1.0):
|
|
39
|
+
raise ValueError("alpha = 0 and alpha = 1 are the KL limits; use ReverseKL")
|
|
40
|
+
self.transformation, self.potential, self.num_samples = transformation, potential, num_samples
|
|
41
|
+
self.alpha, self.context, self.prior_fraction = alpha, context, prior_fraction
|
|
42
|
+
|
|
43
|
+
def __call__(self):
|
|
44
|
+
flow, alpha = self.transformation, self.alpha
|
|
45
|
+
num_prior = int(round(self.prior_fraction * self.num_samples))
|
|
46
|
+
num_flow = self.num_samples - num_prior
|
|
47
|
+
points, log_ratio = [], [] # log_ratio = log dq/dmu0 at the point
|
|
48
|
+
if num_flow:
|
|
49
|
+
coeffs_out, log_rn = flow.push_forward(flow.base_measure.sample(num_flow), self.context)
|
|
50
|
+
points.append(coeffs_out)
|
|
51
|
+
log_ratio.append(log_rn)
|
|
52
|
+
if num_prior:
|
|
53
|
+
prior_points = flow.base_measure.sample(num_prior)
|
|
54
|
+
points.append(prior_points)
|
|
55
|
+
log_ratio.append(flow.log_rn_at(prior_points, self.context))
|
|
56
|
+
points, log_ratio = torch.cat(points), torch.cat(log_ratio)
|
|
57
|
+
log_potential = -self.potential(points)
|
|
58
|
+
if num_prior == 0 or num_flow == 0:
|
|
59
|
+
# single proposal (q or mu0): log of the integrand relative to that proposal
|
|
60
|
+
log_proposal = log_ratio if num_prior == 0 else torch.zeros_like(log_ratio)
|
|
61
|
+
else:
|
|
62
|
+
fraction = num_prior / self.num_samples
|
|
63
|
+
log_proposal = torch.logaddexp(math.log(1 - fraction) + log_ratio,
|
|
64
|
+
torch.full_like(log_ratio, math.log(fraction)))
|
|
65
|
+
# ptilde^alpha q^(1-alpha) / r, all relative to mu0: alpha (-Phi) + (1 - alpha) log_ratio - log_proposal
|
|
66
|
+
log_terms = alpha * log_potential + (1 - alpha) * log_ratio - log_proposal
|
|
67
|
+
return (torch.logsumexp(log_terms, 0) - math.log(len(log_terms))) / (alpha * (alpha - 1))
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FlowMatching:
|
|
5
|
+
"""Regress the field onto the straight-path velocity from the reference measure to the data.
|
|
6
|
+
|
|
7
|
+
coupling: 'independent' pairs each base draw with a random target (Lipman et al. 2023;
|
|
8
|
+
FFM). 'optimal' re-pairs the minibatch by exact optimal transport in the training metric
|
|
9
|
+
(Tong et al. 2023 OT-CFM; in function space FOT-CFM, Li et al. 2026): straighter flows,
|
|
10
|
+
lower-variance targets, and fewer integration steps at sampling time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, transformation, dataset_coeffs, batch_size, weights=None, coupling="independent"):
|
|
14
|
+
self.field, self.measure = transformation.vector_field, transformation.base_measure
|
|
15
|
+
self.total_time = transformation.total_time
|
|
16
|
+
self.dataset_coeffs, self.batch_size, self.weights = dataset_coeffs, batch_size, weights
|
|
17
|
+
self.coupling = coupling
|
|
18
|
+
|
|
19
|
+
def __call__(self):
|
|
20
|
+
if callable(self.dataset_coeffs):
|
|
21
|
+
target_coeffs = self.dataset_coeffs(self.batch_size)
|
|
22
|
+
else:
|
|
23
|
+
index = torch.randint(len(self.dataset_coeffs), (self.batch_size,),
|
|
24
|
+
device=self.dataset_coeffs.device)
|
|
25
|
+
target_coeffs = self.dataset_coeffs[index]
|
|
26
|
+
start_coeffs = self.measure.sample(self.batch_size)
|
|
27
|
+
return straight_path_loss(self.field, start_coeffs, target_coeffs, self.total_time, None,
|
|
28
|
+
self.weights, self.coupling)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PairedFlowMatching:
|
|
32
|
+
"""Reflow (Liu et al. 2022): regress on the model's OWN (base, endpoint) pairs.
|
|
33
|
+
|
|
34
|
+
A trained flow defines a coupling whose marginals are the base measure and the model, and
|
|
35
|
+
whose paths cross. Refitting a field to those exact pairs -- never redrawing the base --
|
|
36
|
+
removes the crossings without changing either marginal, so the flow gets straighter and the
|
|
37
|
+
same sample needs fewer integration steps. Repeatable; each round compounds, and each round
|
|
38
|
+
also compounds whatever error the previous model had, so two or three is the usual limit.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, transformation, base_coeffs, target_coeffs, batch_size, weights=None):
|
|
42
|
+
self.field, self.total_time = transformation.vector_field, transformation.total_time
|
|
43
|
+
self.base_coeffs, self.target_coeffs = base_coeffs, target_coeffs
|
|
44
|
+
self.batch_size, self.weights = batch_size, weights
|
|
45
|
+
|
|
46
|
+
def __call__(self):
|
|
47
|
+
index = torch.randint(len(self.base_coeffs), (self.batch_size,),
|
|
48
|
+
device=self.base_coeffs.device)
|
|
49
|
+
return straight_path_loss(self.field, self.base_coeffs[index], self.target_coeffs[index],
|
|
50
|
+
self.total_time, None, self.weights, "independent")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reflow_pairs(flow, count, batch_size=512):
|
|
54
|
+
"""Base draws and where the flow sends them: the dataset PairedFlowMatching refits."""
|
|
55
|
+
base, target = [], []
|
|
56
|
+
with torch.no_grad():
|
|
57
|
+
for done in range(0, count, batch_size):
|
|
58
|
+
draws = flow.base_measure.sample(min(batch_size, count - done))
|
|
59
|
+
base.append(draws)
|
|
60
|
+
target.append(flow.transport(draws))
|
|
61
|
+
return torch.cat(base), torch.cat(target)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConditionalFlowMatching:
|
|
65
|
+
"""Same, but base and target come from a simulator and the field sees the context.
|
|
66
|
+
|
|
67
|
+
coupling='optimal' pairs base draws with targets ACROSS contexts, which biases which base
|
|
68
|
+
draw reaches each context, so the conditional flow no longer starts from the base measure.
|
|
69
|
+
context_weight is the fix (Chemseddine et al. 2024; Kerrigan et al. 2024): the transport cost
|
|
70
|
+
carries the squared context distance too, so a pair is only swapped when the contexts nearly
|
|
71
|
+
agree. Large weight -> only near-identical contexts are re-paired, and the conditional is
|
|
72
|
+
preserved; zero -> the biased coupling. It only does anything when the simulator repeats or
|
|
73
|
+
nearly repeats contexts within a batch.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, transformation, simulate, draw_base, batch_size, weights=None,
|
|
77
|
+
coupling="independent", context_weight=0.0):
|
|
78
|
+
self.field, self.total_time = transformation.vector_field, transformation.total_time
|
|
79
|
+
self.simulate, self.draw_base = simulate, draw_base
|
|
80
|
+
self.batch_size, self.weights, self.coupling = batch_size, weights, coupling
|
|
81
|
+
self.context_weight = context_weight
|
|
82
|
+
|
|
83
|
+
def __call__(self):
|
|
84
|
+
target_coeffs, context = self.simulate(self.batch_size)
|
|
85
|
+
start_coeffs = self.draw_base(self.batch_size)
|
|
86
|
+
return straight_path_loss(self.field, start_coeffs, target_coeffs, self.total_time, context,
|
|
87
|
+
self.weights, self.coupling, self.context_weight)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def squared_distances(left, right):
|
|
91
|
+
"""[n, m] of squared distances, by expansion rather than an [n, m, dim] difference."""
|
|
92
|
+
return ((left ** 2).sum(-1)[:, None] + (right ** 2).sum(-1)[None, :] - 2 * left @ right.T)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def optimal_pairing(start_coeffs, target_coeffs, weights, context=None, context_weight=0.0):
|
|
96
|
+
"""Minibatch optimal transport: the permutation of the targets that minimises the summed
|
|
97
|
+
squared distance to the base draws, in the metric the loss uses (weights = the same per-mode
|
|
98
|
+
weights as the loss, so 'cameron-martin' and 'l2' stay consistent).
|
|
99
|
+
|
|
100
|
+
For uniform equal-size batches the OT plan is a permutation, so an exact assignment is the
|
|
101
|
+
whole solver: O(batch^3) on the CPU, about 2 ms at batch 128. One device sync per step.
|
|
102
|
+
"""
|
|
103
|
+
from scipy.optimize import linear_sum_assignment # only needed for this coupling
|
|
104
|
+
scaled_start = start_coeffs if weights is None else start_coeffs * weights
|
|
105
|
+
scaled_target = target_coeffs if weights is None else target_coeffs * weights
|
|
106
|
+
cost = squared_distances(scaled_start, scaled_target)
|
|
107
|
+
if context is not None and context_weight:
|
|
108
|
+
cost = cost + context_weight * squared_distances(context, context)
|
|
109
|
+
_, columns = linear_sum_assignment(cost.detach().cpu().numpy())
|
|
110
|
+
return torch.as_tensor(columns, device=target_coeffs.device)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def straight_path_loss(field, start_coeffs, target_coeffs, total_time, context, weights,
|
|
114
|
+
coupling="independent", context_weight=0.0):
|
|
115
|
+
"""One time per sample when the field allows it, otherwise one time for the whole batch."""
|
|
116
|
+
if coupling == "optimal":
|
|
117
|
+
order = optimal_pairing(start_coeffs, target_coeffs, weights, context, context_weight)
|
|
118
|
+
target_coeffs = target_coeffs[order]
|
|
119
|
+
context = None if context is None else context[order] # the context stays with its target
|
|
120
|
+
elif coupling != "independent":
|
|
121
|
+
raise ValueError(f"coupling must be 'independent' or 'optimal', not {coupling!r}")
|
|
122
|
+
batch = len(start_coeffs)
|
|
123
|
+
if getattr(field, "supports_batched_time", False):
|
|
124
|
+
times = torch.rand(batch, dtype=start_coeffs.dtype, device=start_coeffs.device) * total_time
|
|
125
|
+
else:
|
|
126
|
+
times = torch.as_tensor(torch.rand(()).item() * total_time, dtype=start_coeffs.dtype,
|
|
127
|
+
device=start_coeffs.device)
|
|
128
|
+
path_coeffs = start_coeffs + times.reshape(-1, 1) / total_time * (target_coeffs - start_coeffs)
|
|
129
|
+
residual = field.velocity(path_coeffs, times, context) - (target_coeffs - start_coeffs) / total_time
|
|
130
|
+
if weights is not None:
|
|
131
|
+
residual = residual * weights
|
|
132
|
+
return residual.pow(2).sum(-1).mean()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NegativeLogL:
|
|
5
|
+
"""P2 Alg. 1 (direct data): -mean_i log d(f#mu0)/dmu0 (u_i) over a minibatch of dataset coefficients."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, transformation, dataset_coeffs, batch_size=64, context=None):
|
|
8
|
+
self.transformation, self.dataset_coeffs, self.batch_size = transformation, dataset_coeffs, batch_size
|
|
9
|
+
self.context = context
|
|
10
|
+
|
|
11
|
+
def __call__(self):
|
|
12
|
+
batch_indices = torch.randint(len(self.dataset_coeffs), (self.batch_size,),
|
|
13
|
+
device=self.dataset_coeffs.device) # no host round trip
|
|
14
|
+
context = None if self.context is None else self.context[batch_indices]
|
|
15
|
+
return -self.transformation.log_rn_at(self.dataset_coeffs[batch_indices], context).mean()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReverseKL:
|
|
5
|
+
"""E_{coeffs ~ mu0}[ log_rn_weight + Phi(f(coeffs)) ]: KL(f#mu0 || posterior) up to the evidence.
|
|
6
|
+
|
|
7
|
+
context, if given, is handed to the transformation unchanged (one context for the whole
|
|
8
|
+
objective -- the single-observation posterior case).
|
|
9
|
+
|
|
10
|
+
path_gradient=True uses the path-gradient (sticking-the-landing) estimator of Vaitl et al.
|
|
11
|
+
2022. The reparameterised gradient splits into a path term and a term that differentiates the
|
|
12
|
+
density at a FIXED sample; the second has zero mean and pure variance, and it vanishes only in
|
|
13
|
+
expectation, so dropping it leaves an unbiased estimator with strictly less noise. The gain
|
|
14
|
+
grows as the fit improves: in the Gaussian check in this package's notes the variance ratio is
|
|
15
|
+
about 1.4x far from the optimum and over 1000x near it, which is exactly where the ordinary
|
|
16
|
+
estimator stalls. It also skips the trace on the forward pass, so a step is no more expensive.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, transformation, potential, num_samples=30, context=None, path_gradient=False):
|
|
20
|
+
self.transformation, self.potential, self.num_samples = transformation, potential, num_samples
|
|
21
|
+
self.context, self.path_gradient = context, path_gradient
|
|
22
|
+
|
|
23
|
+
def __call__(self):
|
|
24
|
+
coeffs = self.transformation.base_measure.sample(self.num_samples)
|
|
25
|
+
if not self.path_gradient:
|
|
26
|
+
coeffs_out, log_rn_weight = self.transformation.push_forward(coeffs, self.context)
|
|
27
|
+
return (log_rn_weight + self.potential(coeffs_out)).mean()
|
|
28
|
+
coeffs_out = self.transformation.transport(coeffs, self.context) # no trace needed
|
|
29
|
+
value, slope = self.integrand_and_slope(coeffs_out.detach())
|
|
30
|
+
surrogate = (slope * coeffs_out).sum(-1).mean()
|
|
31
|
+
return value + surrogate - surrogate.detach() # value reports, gradient is the path term
|
|
32
|
+
|
|
33
|
+
def integrand_and_slope(self, points):
|
|
34
|
+
"""log (dq/dmu0)(x) + Phi(x) and its x-derivative, with the flow's parameters held fixed."""
|
|
35
|
+
moving = [parameter for parameter in self.transformation.parameters()
|
|
36
|
+
if parameter.requires_grad]
|
|
37
|
+
for parameter in moving:
|
|
38
|
+
parameter.requires_grad_(False)
|
|
39
|
+
try:
|
|
40
|
+
with torch.enable_grad():
|
|
41
|
+
state = points.detach().requires_grad_(True)
|
|
42
|
+
integrand = (self.transformation.log_rn_at(state, self.context)
|
|
43
|
+
+ self.potential(state))
|
|
44
|
+
slope, = torch.autograd.grad(integrand.sum(), state)
|
|
45
|
+
finally:
|
|
46
|
+
for parameter in moving:
|
|
47
|
+
parameter.requires_grad_(True)
|
|
48
|
+
return integrand.mean().detach(), slope.detach()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .latent_pcn import latent_pcn
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def latent_pcn(flow, potential, num_chains=128, num_steps=2000, beta=0.2, init=None, context=None,
|
|
5
|
+
burn=None, thin=10, adapt_to=None):
|
|
6
|
+
"""Exact posterior sampling under a trained prior flow, by pCN in the flow's latent space.
|
|
7
|
+
|
|
8
|
+
The flow prior is the push-forward of the base measure mu0 (Gaussian) through T, so a draw is
|
|
9
|
+
f = T(z), z ~ mu0, and the posterior over z is
|
|
10
|
+
|
|
11
|
+
pi(dz) propto exp(-Phi(T(z))) mu0(dz)
|
|
12
|
+
|
|
13
|
+
which is the textbook setting for preconditioned Crank-Nicolson (Cotter et al. 2013): the
|
|
14
|
+
proposal z' = sqrt(1 - beta^2) z + beta xi, xi ~ mu0, is mu0-reversible, so the acceptance
|
|
15
|
+
ratio is exp(Phi(T(z)) - Phi(T(z'))) and the prior never enters -- no density of the flow,
|
|
16
|
+
no trace, no Jacobian. Each step costs one transport of the chain batch.
|
|
17
|
+
|
|
18
|
+
Compared with GuidedTransport / TwistedSampler this is asymptotically exact and has no
|
|
19
|
+
endpoint-estimate heuristic in it; the price is num_steps transports instead of one. Start it
|
|
20
|
+
from good draws (init = guided draws, pulled back through the flow) and the burn-in is short.
|
|
21
|
+
|
|
22
|
+
init: coefficients in the flow's output space, one per chain; pulled back to z. Default: mu0.
|
|
23
|
+
adapt_to: target acceptance rate; if set, beta is rescaled every 50 steps during burn-in
|
|
24
|
+
(pCN acceptance falls with beta; ~0.25 is a common target).
|
|
25
|
+
Returns (draws [kept, num_functions], info dict with acceptance and final beta).
|
|
26
|
+
"""
|
|
27
|
+
measure = flow.base_measure
|
|
28
|
+
burn = num_steps // 4 if burn is None else burn
|
|
29
|
+
with torch.no_grad():
|
|
30
|
+
if init is None:
|
|
31
|
+
latent = measure.sample(num_chains)
|
|
32
|
+
else:
|
|
33
|
+
latent = flow.pull_back(init[:num_chains], context)
|
|
34
|
+
num_chains = len(latent)
|
|
35
|
+
state = flow.transport(latent, context)
|
|
36
|
+
energy = potential(state)
|
|
37
|
+
keep, accepted, window = [], 0, 0
|
|
38
|
+
for step in range(num_steps):
|
|
39
|
+
proposal_latent = (1 - beta ** 2) ** 0.5 * latent + beta * measure.sample(num_chains)
|
|
40
|
+
proposal = flow.transport(proposal_latent, context)
|
|
41
|
+
proposal_energy = potential(proposal)
|
|
42
|
+
accept = torch.log(torch.rand_like(energy)) < energy - proposal_energy
|
|
43
|
+
latent = torch.where(accept[:, None], proposal_latent, latent)
|
|
44
|
+
state = torch.where(accept[:, None], proposal, state)
|
|
45
|
+
energy = torch.where(accept, proposal_energy, energy)
|
|
46
|
+
accepted += accept.double().mean().item()
|
|
47
|
+
window += accept.double().mean().item()
|
|
48
|
+
if adapt_to is not None and step < burn and (step + 1) % 50 == 0:
|
|
49
|
+
beta = float(min(0.999, max(1e-3, beta * (1.0 + 0.5 * (window / 50 - adapt_to)))))
|
|
50
|
+
window = 0
|
|
51
|
+
if step >= burn and (step - burn) % thin == 0:
|
|
52
|
+
keep.append(state.clone())
|
|
53
|
+
return torch.cat(keep), dict(acceptance=accepted / num_steps, beta=beta)
|