Skip to content

2 · Anatomy of a problem

Every nefi problem is the same seven pieces. This tutorial builds a small 2-D deblurring problem from scratch — no instance, no registry — so each piece is visible. The contracts are specified in DESIGN.md §3.

Domain ── Field (prior) ──► fields ──► Operator (physics) ──► prediction ──► LossSet ──► loss
             ▲                                                                              │
             └───────────── Solver runs a Curriculum of Stages (∇θ, lr, annealing) ◄────────┘
                                   └─► Postprocess ─► Result ─► metrics / diagnostics

1. Domain

A Domain is a physical box discretized on a uniform, cell-centered grid. Coordinates handed to fields are normalized to [-1, 1] per axis (NeTMY App. D.1, NeFTY App. D.1), so a field can be queried at any resolution of the same physical domain — the basis of multiscale curricula.

import torch
import nefi

torch.manual_seed(0)           # the field below is initialized from torch's global RNG
domain = nefi.Domain((64, 64), extent=((0.0, 1.0), (0.0, 1.0)), axes=("x", "y"))
domain.spacing()               # (0.015625, 0.015625)   physical cell size
domain.coords().shape          # torch.Size([64, 64, 2]) normalized cell centers
domain.at((32, 32)).spacing()  # (0.03125, 0.03125)     same box, coarser grid

Domain.from_spacing((64, 64), 20.0) builds a domain from a pixel size (NeTMY: 64 px × 20 nm).

2. Field — the prior

A Field maps coordinates to named physical fields. The default representation is a coordinate MLP with annealed Fourier features (NeuralField); GridField is the free-pixel baseline, and many others exist (nefi list fields). Heads turn raw network outputs into physically valid values and are where hard knowledge lives:

field = nefi.NeuralField(
    2, nefi.Heads({"x": nefi.Softplus(init_value=0.1)}),   # x >= 0, starts near 0.1 everywhere
    hidden=64, depth=3, skip_at=2, n_octaves=6,
)
field.n_parameters()           # 11777
head value used for
Softplus(init_value=…) ≥ 0 densities, intensities
GatedSoftplus() softplus(h)·σ(g), 2 raw channels sparse sources (NeTMY Eq. 5)
Bounded(lo, hi) lo + (hi − lo)σ(h) material parameters (NeFTY Eq. 6)
SupportMasked(inner, depends_on=…, tau=0.3) inner head × stop-gradient support mask fields identifiable only where another is non-zero (NeTMY Eq. 26)
Exp(), Identity() exp(h), h large dynamic range, signed fields

FourierFeatures implements the annealed encoding of both papers (NeTMY Eq. 27, NeFTY Eq. 21): band k is multiplied by w_k(β) = (1 − cos(π·clip(β − k, 0, 1)))/2 with β = progress·K, so at progress = 0 only the raw coordinate passes and higher frequencies are unlocked progressively.

3. Operator — the physics

An Operator is a differentiable map fields → prediction. It is a hard constraint: the prediction is exactly the physics applied to the current field, never a soft residual.

from nefi.operators import FFTConvolution, gaussian_kernel_fn

operator = FFTConvolution(gaussian_kernel_fn(0.02), domain, field="x")   # σ in physical units
operator.homogeneity                                  # 1.0: F(c·x) = c·F(x)
operator.at_resolution((32, 32)).domain.shape         # (32, 32): kernel rebuilt for the grid

Two methods matter beyond forward: at_resolution(shape) returns the operator for fields sampled on another grid (coarse curriculum stages), and output_shape(shape) tells the problem how to resample the measurement for that stage. homogeneity enables energy-anchored scale correction.

4. Measurement

yy, xx = torch.meshgrid(torch.linspace(0, 1, 64), torch.linspace(0, 1, 64), indexing="ij")
truth = ((xx - 0.35) ** 2 + (yy - 0.5) ** 2 < 0.02).float() \
      + 0.5 * ((xx - 0.7) ** 2 + (yy - 0.4) ** 2 < 0.01).float()
torch.manual_seed(0)
clean = operator({"x": truth})
meas = nefi.Measurement(clean + 0.01 * torch.randn_like(clean), noise_std=0.01)

Measurement(data, mask=None, noise_std=None, meta={}): the mask marks observed entries (losses average over them); a known noise_std enables discrepancy-principle stopping and noise-aware weighting. (Simulating the data with the inversion operator itself is an inverse crime; it is fine for this illustration, and tutorial 3 shows how to avoid it.)

5. Losses

A LossSet is a weighted sum of named terms. Data terms compare ctx.pred with ctx.obs; regularizers act on a named field. All reduce by means, so weights do not depend on the resolution.

losses = nefi.LossSet(
    {"data": nefi.MSE(), "tv": nefi.TV("x", isotropic=True), "l1": nefi.L1("x")},
    weights={"data": 1.0, "tv": 1e-4, "l1": 1e-4},
)

Data fidelities: MSE, LogMSE(normalize="max") (NeTMY Eq. 19), NormalizedMSE("mean"), Huber, PoissonNLL, RelativeMSE. Regularizers: L1, TV (isotropic NeFTY Eq. 22 or anisotropic NeTMY Eq. 3, periodic axes, physical spacing), Laplacian, Tikhonov, RangePenalty, PriorMSE; physics-knowledge terms live in nefi.losses.physics.

6. The problem

problem = nefi.InverseProblem(domain, field, operator, losses, meas, name="blobs")
fields, pred = problem.evaluate()            # native resolution, no grad
total, comps = problem.loss()                # with autograd; comps = {"data": …, "tv": …, "l1": …}

Optional arguments: postprocess=[...] (one-shot post-processing), curriculum= (the problem's default schedule), downsample_obs= (custom measurement resampling for coarse stages).

7. Curriculum and solver

A Curriculum is a list of Stages run on the same parameters. Each stage fixes a resolution, a step budget, a learning-rate schedule and whether β is re-annealed from 0 (NeTMY Tab. 6 resets β at every stage; NeFTY anneals over the first 25 % of its single stage):

cur = nefi.Curriculum(
    [
        nefi.Stage("coarse", shape=(32, 32), steps=150, lr=3e-3),
        nefi.Stage("fine", shape=(64, 64), steps=250, lr=1.5e-3, anneal_fraction=0.5),
    ],
    optim=nefi.OptimConfig(optimizer="adamw", weight_decay=1e-4, grad_clip=1.0),
    discrepancy_tau=1.0,   # Morozov: end a stage once RMSE <= 1.0 × noise_std
)
# the same shape of schedule in one line:
nefi.Curriculum.multiscale((64, 64), n_stages=2, steps=(150, 250), lr=3e-3)

Stage also takes lr_schedule ("cosine", "step", "constant", "warmup_cosine"), lr_min_ratio, loss_weights (per-stage overrides, e.g. NeTMY's stage-2 rebalancing) and freeze (parameter-name prefixes). Curriculum adds early_stop_patience, restarts and time_budget_s (tutorial 8).

from nefi.solve import LoggingCallback

result = nefi.Solver(problem, cur, device="cpu", seed=0,
                     callbacks=[LoggingCallback(every=100)]).run()
print(result.summary())
print(nefi.metrics.psnr(result.fields["x"], truth))
Result: fields={'x': (64, 64)}
  total time 15.2s, steps 400
  stage 0 (coarse, (32, 32)): 150 steps, 3.0s | data=0.00265, tv=1.62, l1=0.0897
  stage 1 (fine, (64, 64)): 250 steps, 11.6s | data=0.000682, tv=1.67, l1=0.0848
22.568004608154297

(CPU; times vary with the hardware, the numbers are reproducible with the seed.)

At each stage start the solver rebuilds the coordinates, calls operator.at_resolution, resamples the measurement (problem.measurement_at), lets the field adapt (field.on_stage_start, e.g. a grid resamples itself), builds a fresh optimizer and resets β. Every step it evaluates field → operator → losses, clips gradients, steps, and logs. Solver options: device="auto" | "cpu" | "cuda", dtype, seed, callbacks (LoggingCallback, ProgressBar, CheckpointCallback, FieldSnapshots, your own Callback), compile=True (torch.compile), nan_guard.

8. Post-processing and the result

Postprocess objects run once after optimization. EnergyScaleCorrection() resolves the scale ambiguity of normalized fidelities by x ← α x, α = (E_obs/E_pred)^(1/p) with p the operator's homogeneity (NeTMY Eq. 30, Prop. 1); Clip and ThresholdMask are also available.

Result fields: fields (post-processed, CPU), raw_fields, pred, history, stage_results, timing, post_info (e.g. the scale factor), config_hash, extra (device, seed, parameter count). Result.final("data_loss") returns the last value of a history key.

Swapping pieces

Because the pieces are independent, experiments are one-line changes:

grid = nefi.GridField((64, 64), nefi.Heads({"x": nefi.Softplus(init_value=0.1)}))
problem_grid = nefi.InverseProblem(domain, grid, operator, losses, meas)       # free pixels
problem_gain = nefi.InverseProblem(domain, field, nefi.Nuisance(operator, gain=True),
                                   losses, meas)                                # unknown gain

Instances (tutorial 4) package exactly these pieces behind a config dataclass so that the CLI, the benchmark protocol and the diagnostics can drive them.