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.
Files changed (33) hide show
  1. FuncFlows/__init__.py +0 -0
  2. FuncFlows/base_measures/__init__.py +3 -0
  3. FuncFlows/base_measures/abstract_reference_measure.py +28 -0
  4. FuncFlows/base_measures/bases.py +112 -0
  5. FuncFlows/base_measures/gaussian_reference_measure.py +37 -0
  6. FuncFlows/diagnostics/__init__.py +2 -0
  7. FuncFlows/diagnostics/coverage.py +46 -0
  8. FuncFlows/diagnostics/importance.py +60 -0
  9. FuncFlows/objectives/__init__.py +5 -0
  10. FuncFlows/objectives/alpha_divergence.py +67 -0
  11. FuncFlows/objectives/flow_matching.py +132 -0
  12. FuncFlows/objectives/negative_logl.py +15 -0
  13. FuncFlows/objectives/reverse_kl.py +48 -0
  14. FuncFlows/samplers/__init__.py +1 -0
  15. FuncFlows/samplers/latent_pcn.py +53 -0
  16. FuncFlows/transports/__init__.py +3 -0
  17. FuncFlows/transports/abstract_transformation.py +32 -0
  18. FuncFlows/transports/continuous/__init__.py +6 -0
  19. FuncFlows/transports/continuous/base_continuous.py +63 -0
  20. FuncFlows/transports/continuous/conditioners.py +83 -0
  21. FuncFlows/transports/continuous/grid_fields.py +403 -0
  22. FuncFlows/transports/continuous/vector_fields.py +306 -0
  23. FuncFlows/transports/layers/__init__.py +2 -0
  24. FuncFlows/transports/layers/base_discrete.py +29 -0
  25. FuncFlows/transports/layers/layer_classes.py +81 -0
  26. FuncFlows/utils/__init__.py +0 -0
  27. FuncFlows/utils/gaussian_misfit.py +16 -0
  28. FuncFlows/utils/train.py +31 -0
  29. funcflows-0.1.2.dist-info/METADATA +43 -0
  30. funcflows-0.1.2.dist-info/RECORD +33 -0
  31. funcflows-0.1.2.dist-info/WHEEL +5 -0
  32. funcflows-0.1.2.dist-info/licenses/LICENSE +21 -0
  33. funcflows-0.1.2.dist-info/top_level.txt +1 -0
FuncFlows/__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,5 @@
1
+ from .reverse_kl import ReverseKL
2
+ from .negative_logl import NegativeLogL
3
+ from .alpha_divergence import AlphaDivergence
4
+ from .flow_matching import (FlowMatching, ConditionalFlowMatching, PairedFlowMatching,
5
+ reflow_pairs, optimal_pairing, straight_path_loss)
@@ -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)
@@ -0,0 +1,3 @@
1
+ from .abstract_transformation import Transformation
2
+ from .layers import DiscreteTransformation
3
+ from .continuous import ContinuousTransformation