Greedy Registration with Levenberg-Marquardt
The Levenberg-Marquardt (LM) optimizer is a second-order method that approximates the local Hessian of the loss to take more informed steps than plain gradient descent. It adapts a damping parameter λ at every iteration: if the loss improves, λ is shrunk (more Newton-like, larger step); if the loss worsens, λ is grown (more gradient-descent-like, smaller step). This makes the optimizer self-tuning and robust to poorly-scaled gradients.
Calling Greedy with Levenberg-Marquardt (default parameters)
Pass optimizer='levenberg' to GreedyRegistration. No other changes are needed — the defaults are tuned for 3-D brain MRI registration and work well out of the box.
from fireants.io.image import BatchedImages, Image
from fireants.registration.greedy import GreedyRegistration
fixed_image = Image.load_file("fixed.nii.gz")
moving_image = Image.load_file("moving.nii.gz")
fixed_batch = BatchedImages([fixed_image])
moving_batch = BatchedImages([moving_image])
reg = GreedyRegistration(
scales=[8, 4, 2, 1],
iterations=[200, 150, 100, 50],
fixed_images=fixed_batch,
moving_images=moving_batch,
loss_type="fusedcc",
loss_params={"smooth_nr": 1e-5, "smooth_dr": 1e-5},
cc_kernel_size=7,
optimizer="levenberg", # <-- selects LM optimizer
optimizer_lr=0.75,
)
reg.optimize()
moved = reg.evaluate(fixed_batch, moving_batch)
optimizer_lr is still required — it sets the overall step size multiplied onto the LM-scaled gradient.
The three damping parameters
The LM optimizer has three controlling hyper-parameters. They are passed via the optimizer_params dictionary.
lambda_init — initial damping value
| Default | 1e-2 |
|---|---|
| Type | float or 'auto' |
| Must be | > 0 |
λ controls the trade-off between the gradient-descent direction and the Newton direction at each voxel.
- Large λ → behaves like gradient descent (small, safe steps).
- Small λ → behaves like Newton's method (large, aggressive steps).
Setting lambda_init='auto' derives the starting value from the norm of the first gradient batch, which is useful when you do not know the gradient scale in advance.
lambda_increase_factor — damping growth when loss increases
| Default | 1.5 |
|---|---|
| Type | float |
| Must be | > 1.0 |
When a step causes the loss to increase, λ is multiplied by this factor before the next iteration. A larger value makes the optimizer retreat more aggressively to safer, shorter steps after a bad update.
lambda_decrease_factor — damping shrinkage when loss decreases
| Default | 0.975 |
|---|---|
| Type | float |
| Must be | < 1.0 |
When a step causes the loss to decrease, λ is multiplied by this factor, allowing the optimizer to gradually become more Newton-like as it gains confidence. Values close to 1.0 (e.g. 0.975) give a slow, stable progression; values closer to 0 (e.g. 0.7) reduce damping much faster.
Passing custom values
reg = GreedyRegistration(
scales=[8, 4, 2, 1],
iterations=[200, 150, 100, 50],
fixed_images=fixed_batch,
moving_images=moving_batch,
loss_type="fusedcc",
loss_params={"smooth_nr": 1e-5, "smooth_dr": 1e-5},
cc_kernel_size=7,
optimizer="levenberg",
optimizer_lr=0.75,
optimizer_params={
"lambda_init": 0.1, # start with stronger damping
"lambda_increase_factor": 5.0, # retreat faster on bad steps
"lambda_decrease_factor": 0.7, # loosen damping faster on good steps
},
)
reg.optimize()
The constraints lambda_increase_factor > 1.0 and lambda_decrease_factor < 1.0 are enforced at construction time and will raise an AssertionError if violated.
Summary of defaults
| Parameter | Default | Reasonable search range |
|---|---|---|
lambda_init |
1e-2 |
[1e-4, 1.0] (log-uniform) |
lambda_increase_factor |
1.5 |
[1.5, 10.0] |
lambda_decrease_factor |
0.975 |
[0.60, 0.999] |
Minimal reproducible evaluation script
The script below registers a list of image/segmentation pairs, records per-label Dice scores, and saves them to a .npy file. It requires only fireants, torch, and numpy.
"""
evaluate_levenberg.py
---------------------
Registers fixed/moving image pairs with the Levenberg-Marquardt optimizer
and saves per-label Dice scores.
Usage:
python evaluate_levenberg.py \
--pairs pairs.tsv \
--n_labels 35 \
--output_dir ./results
pairs.tsv format (tab-separated, no header):
fixed.nii.gz moving.nii.gz fixed_seg.nii.gz moving_seg.nii.gz
"""
import argparse
import gc
import os
import numpy as np
import torch
from tqdm import tqdm
from fireants.io.image import BatchedImages, Image
from fireants.registration.greedy import GreedyRegistration
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def load_batch(path: str, is_segmentation: bool = False) -> BatchedImages:
return BatchedImages([Image.load_file(path, is_segmentation=is_segmentation)])
def dice_scores(
moved_seg: torch.Tensor,
fixed_seg: torch.Tensor,
n_labels: int,
eps: float = 1e-5,
) -> torch.Tensor:
"""Per-label Dice for integer label maps of shape [B, 1, ...]."""
dices = []
for lab in range(1, n_labels + 1):
pred = moved_seg == lab
gt = fixed_seg == lab
intersection = (pred & gt).sum().float()
union = pred.sum().float() + gt.sum().float()
dices.append((2.0 * intersection + eps) / (union + eps))
return torch.stack(dices)
# ---------------------------------------------------------------------------
# registration
# ---------------------------------------------------------------------------
def register_pair(
fixed_img: BatchedImages,
moving_img: BatchedImages,
args: argparse.Namespace,
) -> GreedyRegistration:
reg = GreedyRegistration(
scales=args.scales,
iterations=args.iterations,
fixed_images=fixed_img,
moving_images=moving_img,
loss_type="fusedcc",
loss_params={"smooth_nr": 1e-5, "smooth_dr": 1e-5},
cc_kernel_size=args.cc_kernel_size,
optimizer="levenberg",
optimizer_lr=args.lr,
optimizer_params={
"lambda_init": args.lambda_init,
"lambda_increase_factor": args.lambda_increase,
"lambda_decrease_factor": args.lambda_decrease,
},
max_tolerance_iters=10,
)
reg.optimize()
return reg
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main(args: argparse.Namespace) -> None:
# parse pairs file
pairs = []
with open(args.pairs) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split("\t")
assert len(parts) == 4, f"Expected 4 tab-separated columns, got: {line}"
pairs.append(parts)
print(f"Found {len(pairs)} pairs")
os.makedirs(args.output_dir, exist_ok=True)
all_dice = []
running_sum, running_count = 0.0, 0
for fixed_path, moving_path, fixed_seg_path, moving_seg_path in tqdm(pairs):
if args.num_samples is not None and running_count >= args.num_samples:
break
fixed_img = load_batch(fixed_path)
moving_img = load_batch(moving_path)
fixed_seg = load_batch(fixed_seg_path, is_segmentation=True)
moving_seg = load_batch(moving_seg_path, is_segmentation=True)
# initial (pre-registration) Dice
init_moved = moving_seg().detach()
fixed_seg_tens = fixed_seg().detach()
init_dice = dice_scores(init_moved, fixed_seg_tens, args.n_labels)
# register and evaluate
reg = register_pair(fixed_img, moving_img, args)
moved_seg = reg.evaluate(fixed_img, moving_seg).detach()
dice = dice_scores(moved_seg, fixed_seg_tens, args.n_labels)
running_sum += dice.mean().item()
running_count += 1
print(
f"[{running_count:>4}] init={init_dice.mean():.4f} -> "
f"after={dice.mean():.4f} (running avg={running_sum / running_count:.4f})"
)
all_dice.append(dice.cpu().numpy())
del reg, moved_seg, fixed_seg_tens, init_moved
torch.cuda.empty_cache()
gc.collect()
all_dice = np.stack(all_dice, axis=0) # [N, n_labels]
out_path = os.path.join(args.output_dir, "levenberg_dice_scores.npy")
np.save(out_path, all_dice)
print(f"\nSaved {all_dice.shape} dice array to {out_path}")
print(f"Mean Dice over {running_count} pairs: {all_dice.mean():.4f}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Evaluate Levenberg-Marquardt registration using Dice scores"
)
# data
parser.add_argument(
"--pairs", required=True,
help="TSV file: fixed.nii.gz moving.nii.gz fixed_seg.nii.gz moving_seg.nii.gz",
)
parser.add_argument("--n_labels", type=int, default=35,
help="Number of segmentation labels (1..n_labels)")
parser.add_argument("--output_dir", required=True, help="Directory for saved results")
parser.add_argument("--num_samples", type=int, default=None,
help="Limit evaluation to this many pairs (useful for debugging)")
# registration
parser.add_argument("--scales", type=str, default="8,4,2,1")
parser.add_argument("--iterations", type=str, default="200,150,100,50")
parser.add_argument("--lr", type=float, default=0.75)
parser.add_argument("--cc_kernel_size", type=int, default=7)
# LM hyper-parameters
parser.add_argument("--lambda_init", type=float, default=1e-2,
help="Initial damping λ (default: 1e-2)")
parser.add_argument("--lambda_increase", type=float, default=1.5,
help="Multiply λ by this when loss increases (must be > 1, default: 1.5)")
parser.add_argument("--lambda_decrease", type=float, default=0.975,
help="Multiply λ by this when loss decreases (must be < 1, default: 0.975)")
args = parser.parse_args()
args.scales = list(map(int, args.scales.split(",")))
args.iterations = list(map(int, args.iterations.split(",")))
main(args)
Running the script
# Register all pairs with default LM parameters
python evaluate_levenberg.py \
--pairs pairs.tsv \
--n_labels 35 \
--output_dir ./results
# Override damping parameters
python evaluate_levenberg.py \
--pairs pairs.tsv \
--n_labels 35 \
--output_dir ./results \
--lambda_init 0.1 \
--lambda_increase 5.0 \
--lambda_decrease 0.7
# Quick smoke-test on 3 pairs
python evaluate_levenberg.py \
--pairs pairs.tsv \
--n_labels 35 \
--output_dir ./results \
--num_samples 3
Loading and inspecting the results
import numpy as np
dice = np.load("results/levenberg_dice_scores.npy") # shape: [N_pairs, n_labels]
print(f"Mean Dice: {dice.mean():.4f}")
print(f"Per-label mean: {dice.mean(axis=0)}")
print(f"Worst label: label {dice.mean(axis=0).argmin() + 1} "
f"({dice.mean(axis=0).min():.4f})")