Synthesis Engine — Synthesis and Loss#
Module: foscat.Synthesis
The Synthesis class drives field synthesis, denoising, and component separation
in FOSCAT. It wraps L-BFGS-B (scipy.optimize.fmin_l_bfgs_b) — a
quasi-Newton method that builds a low-memory approximation of the inverse Hessian
from the gradient history. L-BFGS-B converges much faster than first-order
optimisers (Adam, SGD) for smooth, moderately high-dimensional problems like
scattering-covariance synthesis.
Architecture overview#
Initial field x_0 (random Gaussian noise or a prior)
│
▼
┌─────────────────────────────────────────────────────────┐
│ scipy.optimize.fmin_l_bfgs_b │
│ │
│ loop (up to NUM_EPOCHS iterations): │
│ ┌─────────────────────────────────────────────────┐ │
│ │ calc_grad(x) │ │
│ │ → FoCUS.eval(x) → scat_cov stats │ │
│ │ → user loss L(stats, target_stats) │ │
│ │ → scalar loss + ∇ₓ L (via autograd) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ L-BFGS-B step → x_{t+1} │
│ (Hessian approximation updated from gradient history) │
└─────────────────────────────────────────────────────────┘
│
▼
Converged field x* with Φ(x*) ≈ Φ(d_ref)
Loss — wrapping a user loss function#
foscat.Synthesis.Loss(
function,
scat_operator,
*param,
name = "",
batch = None,
batch_data = None,
batch_update = None,
info_callback = False,
)
Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
callable |
— |
Loss function. Signature: |
|
FoCUS |
— |
The scattering operator used inside |
|
any |
— |
Extra arguments packed into a tuple and passed to |
|
str |
|
Optional label printed in the iteration log. |
|
callable|None |
None |
|
|
any |
None |
Data passed to |
|
callable|None |
None |
Called between L-BFGS-B restarts (see |
|
bool |
False |
If True, |
Loss function signature#
def my_loss(x, scat_op, args):
"""
x : torch.Tensor — current field, shape (npix,) or (batch, npix)
scat_op : FoCUS instance
args : tuple of extra arguments
returns : scalar torch.Tensor, differentiable w.r.t. x
"""
target_stat = args[0]
stat = scat_op.eval(x)
return stat.reduce_mean_batch((stat - target_stat) ** 2)
Minimal example#
from foscat.Synthesis import Loss
import foscat.scat_cov as sc
scat_op = sc.funct(KERNELSZ=5, NORIENT=4, all_type='float64')
target_stat = scat_op.eval(target_map)
def scattering_loss(x, scat_op, args):
ref = args[0]
stat = scat_op.eval(x)
return stat.reduce_mean_batch((stat - ref) ** 2)
loss = Loss(scattering_loss, scat_op, target_stat)
Synthesis — the optimisation loop#
foscat.Synthesis.Synthesis(loss_list)
The constructor only takes the list of Loss objects. Optimiser hyperparameters
are passed to run().
Parameter |
Type |
Description |
|---|---|---|
|
list[Loss] |
One or more |
run — execute L-BFGS-B#
result = solver.run(
in_x,
NUM_EPOCHS = 100,
EVAL_FREQUENCY = 100,
factr = 10.0,
NUM_STEP_BIAS = 1,
LEARNING_RATE = 0.03,
DECAY_RATE = 0.95,
batchsz = 1,
totalsz = 1,
grd_mask = None,
idx_grd = None,
KEEP_TRACK = None,
SHOWGPU = False,
MESSAGE = "",
axis = 0,
)
Parameters
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
ndarray, shape |
— |
Initial field. Typically Gaussian noise with power matched to the target. |
|
int |
100 |
Maximum number of L-BFGS-B iterations ( |
|
int |
100 |
Print the current loss every N iterations (via the L-BFGS-B callback). |
|
float |
10.0 |
L-BFGS-B convergence tolerance: the iteration stops when `(f_k - f_{k+1}) / max( |
|
int |
1 |
Number of L-BFGS-B restarts. At each restart the Hessian approximation is reset and |
|
float |
0.03 |
Vestigial — not used by L-BFGS-B. |
|
float |
0.95 |
Vestigial — not used by L-BFGS-B. |
|
int |
1 |
Number of noise realisations used per gradient evaluation (for stochastic losses). |
|
int |
1 |
Total batch pool size. When |
|
ndarray|None |
None |
Binary mask applied to the gradient before each L-BFGS-B step. Pixels with mask = 0 are frozen. |
|
ndarray|None |
None |
Index array selecting the pixels to optimise (partial inpainting). |
|
callable|None |
None |
Called at each log step with the current info dict for custom progress tracking. |
|
bool |
False |
Print GPU memory usage alongside the loss. |
|
str |
|
Prefix prepended to each log line. |
|
int |
0 |
Axis along which to extract the output map (relevant for multi-component problems). |
Returns: ndarray, same shape as in_x — the optimised field.
Why L-BFGS-B?#
L-BFGS-B uses second-order information (an approximation of the Hessian) to take much larger steps than a first-order method like Adam. For scattering-covariance synthesis:
The loss landscape is smooth (sum of squared differences of smooth statistics).
The number of free variables is
npix(up to ~800 000 fornside=256) — within the range where L-BFGS-B is effective.Typical convergence in 50–300 iterations vs. thousands for Adam.
The key parameter is factr: set it to a large value (1e6) for a quick
approximate synthesis; reduce it (10 or 1.0) for high-fidelity results.
Leave NUM_EPOCHS large enough that the factr criterion triggers before
maxiter is hit.
Multiple losses#
from foscat.Synthesis import Loss, Synthesis
loss_scat = Loss(scattering_loss, scat_op, target_stat, name="scat")
loss_power = Loss(power_spec_loss, scat_op, target_Cl, name="Cl")
solver = Synthesis([loss_scat, loss_power])
result = solver.run(x0, NUM_EPOCHS=200, factr=10.0)
The total gradient at each L-BFGS-B step is \(\nabla_x \mathcal{L}_\text{scat} + \nabla_x \mathcal{L}_{C_\ell}\).
Gradient masking#
grd_mask = survey_mask.astype(np.float64) # 1 inside survey, 0 outside
result = solver.run(x0, NUM_EPOCHS=300, grd_mask=grd_mask)
Practical tips#
factr. Start with factr=1e7 for a quick test. For production synthesis
use factr=10 or lower. With factr=1e-32 (effectively disabled), convergence
is determined entirely by NUM_EPOCHS.
NUM_EPOCHS. Set it higher than you expect to need — L-BFGS-B stops
automatically when factr is satisfied. A typical synthesis at nside=64
converges in 100–300 iterations.
Initialisation. A Gaussian white noise map works well. For faster convergence,
pre-colour the noise with the target power spectrum (healpy.synfast).
Multi-resolution warm start. For large nside, synthesise first at low
resolution and upsample the result as the initial guess for the full resolution.
L-BFGS-B benefits significantly from a good initialisation.
get_history(). Retrieve the loss curve after run():
result = solver.run(x0, NUM_EPOCHS=300)
loss_curve = solver.get_history() # ndarray, one value per logged iteration