Skip to content

Commit 6f0e450

Browse files
committed
FEAT: probe position momentum acceleration in LSQML
1 parent 3d021f0 commit 6f0e450

4 files changed

Lines changed: 508 additions & 5 deletions

File tree

src/ptychi/api/options/lsqml.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class LSQMLReconstructorOptions(base.ReconstructorOptions):
4646
"""
4747

4848
momentum_acceleration_gain: float = 0.0
49-
"""The gain of momentum acceleration. If 0, momentum acceleration is not used."""
49+
"""The gain of momentum acceleration for object and probe. If 0, momentum acceleration is not used."""
5050

5151
momentum_acceleration_gradient_mixing_factor: Optional[float] = 1
5252
"""
@@ -116,7 +116,36 @@ class LSQMLProbeOptions(base.ProbeOptions):
116116

117117
@dataclasses.dataclass
118118
class LSQMLProbePositionOptions(base.ProbePositionOptions):
119-
pass
119+
momentum_acceleration_gain: float = 0.0
120+
"""
121+
The gain of momentum acceleration for probe positions. If 0, momentum
122+
acceleration is not used.
123+
"""
124+
125+
momentum_acceleration_gradient_mixing_factor: Optional[float] = 1
126+
"""
127+
Controls how the current position update is mixed with the accumulated
128+
velocity in probe-position momentum acceleration:
129+
130+
`velocity = (1 - friction) * velocity + momentum_acceleration_gradient_mixing_factor * delta_pos`
131+
132+
If None, this mixing factor is automatically chosen to be `friction`.
133+
Set this parameter to 1 to reproduce the behavior in foldslice.
134+
"""
135+
136+
momentum_acceleration_memory: int = 3
137+
"""
138+
Number of previous epochs used to estimate the friction of probe-position
139+
momentum acceleration.
140+
"""
141+
142+
def check(self, options: "LSQMLOptions"):
143+
super().check(options)
144+
if self.momentum_acceleration_gain > 0 and self.momentum_acceleration_memory < 1:
145+
raise ValueError(
146+
"`probe_position_options.momentum_acceleration_memory` must be positive "
147+
"when probe-position momentum acceleration is enabled."
148+
)
120149

121150

122151
@dataclasses.dataclass

src/ptychi/data_structures/probe_positions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,19 @@ def apply_affine_transform_constraint(self):
215215
pos_new = self.data * (1 - flexibility) + flexibility * estimated_positions
216216
self.set_data(pos_new)
217217

218-
def step_optimizer(self, *args, **kwargs):
218+
def step_optimizer(self, clip_update: bool = True, *args, **kwargs):
219219
"""Step the optimizer with gradient filled in. This function
220220
can optionally impose a limit on the magnitude of the update.
221+
222+
Parameters
223+
----------
224+
clip_update : bool
225+
If True, clip the applied position update after the optimizer step.
226+
Set to False to skip this post-step clipping.
221227
"""
228+
if not clip_update:
229+
return super().step_optimizer(*args, **kwargs)
230+
222231
limit_user = self.options.correction_options.update_magnitude_limit
223232
if limit_user is not None and limit_user <= 0:
224233
raise ValueError("`update_magnitude_limit` should either be None or a positive number.")

src/ptychi/reconstructors/lsqml.py

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def __init__(
7777

7878
self.object_momentum_params = {}
7979
self.probe_momentum_params = {}
80+
self.probe_position_momentum_params = {}
8081

8182

8283
# Fourier error for momentum acceleration.
@@ -353,7 +354,9 @@ def apply_reconstruction_parameter_updates(self, indices: torch.Tensor):
353354

354355
# Update probe positions.
355356
if self.parameter_group.probe_positions.optimization_enabled(self.current_epoch):
356-
self.parameter_group.probe_positions.step_optimizer()
357+
self.parameter_group.probe_positions.step_optimizer(
358+
clip_update=self.parameter_group.probe_positions.options.momentum_acceleration_gain <= 0
359+
)
357360

358361
# Update OPR modes and weights.
359362
if self.parameter_group.opr_mode_weights.optimization_enabled(self.current_epoch):
@@ -824,6 +827,139 @@ def _apply_probe_momentum(self, alpha_p_mean, delta_p_hat):
824827
self.probe_momentum_params["velocity_map"][i_mode] / 2.0
825828
)
826829

830+
@timer()
831+
def _clip_probe_position_update(self, delta_pos):
832+
"""
833+
Clip the probe-position update by the configured limits.
834+
835+
Parameters
836+
----------
837+
delta_pos : torch.Tensor
838+
A (batch_size, 2) tensor giving the probe-position update.
839+
"""
840+
probe_positions = self.parameter_group.probe_positions
841+
limit_user = probe_positions.options.correction_options.update_magnitude_limit
842+
if limit_user is not None and limit_user <= 0:
843+
raise ValueError(
844+
"`probe_position_options.correction_options.update_magnitude_limit` should "
845+
"either be None or a positive number."
846+
)
847+
if limit_user == torch.inf:
848+
limit_user = None
849+
850+
if not probe_positions.options.correction_options.clip_update_magnitude_by_mad and limit_user is None:
851+
return delta_pos
852+
853+
update_mag = delta_pos.abs()
854+
update_signs = delta_pos.sign()
855+
856+
if probe_positions.options.correction_options.clip_update_magnitude_by_mad:
857+
limit_mad = pmath.mad(delta_pos, dim=0) * 10
858+
else:
859+
limit_mad = torch.full(
860+
(delta_pos.shape[-1],), torch.inf, device=delta_pos.device, dtype=delta_pos.dtype
861+
)
862+
if limit_user is not None:
863+
limit = torch.clip(limit_mad, max=limit_user)
864+
else:
865+
limit = limit_mad
866+
delta_pos = update_mag.clip(max=limit) * update_signs
867+
return delta_pos
868+
869+
@timer()
870+
def _calculate_probe_position_corrcoef(self, current_update, previous_update):
871+
"""
872+
Calculate the mean of the per-axis Pearson correlation coefficients
873+
between two batches of probe-position updates.
874+
"""
875+
if len(current_update) < 2 or len(previous_update) < 2:
876+
return torch.tensor(0.0, device=current_update.device, dtype=current_update.dtype)
877+
878+
current_centered = current_update - current_update.mean(0, keepdim=True)
879+
previous_centered = previous_update - previous_update.mean(0, keepdim=True)
880+
denominator = torch.sqrt((current_centered**2).sum(0) * (previous_centered**2).sum(0))
881+
corr = torch.where(
882+
denominator > 0,
883+
(current_centered * previous_centered).sum(0) / denominator,
884+
torch.zeros_like(denominator),
885+
)
886+
return corr.mean()
887+
888+
@timer()
889+
def _apply_probe_position_momentum(self, indices, delta_pos):
890+
"""
891+
Apply foldslice-style momentum acceleration to the current probe-position
892+
update after clipping.
893+
894+
Parameters
895+
----------
896+
indices : torch.Tensor
897+
Indices of diffraction patterns in the current minibatch.
898+
delta_pos : torch.Tensor
899+
A (batch_size, 2) tensor giving the clipped probe-position update
900+
for the current minibatch.
901+
"""
902+
# Only do momentum for far-field.
903+
free_space_propagation_distance_m = self.forward_model.free_space_propagation_distance_m
904+
is_far_field = math.isinf(free_space_propagation_distance_m)
905+
if not is_far_field:
906+
return delta_pos
907+
908+
probe_positions = self.parameter_group.probe_positions
909+
910+
momentum_memory = probe_positions.options.momentum_acceleration_memory
911+
if momentum_memory < 1:
912+
raise ValueError(
913+
"`probe_position_options.momentum_acceleration_memory` must be positive."
914+
)
915+
916+
if "position_update_history" not in self.probe_position_momentum_params.keys():
917+
self.probe_position_momentum_params["position_update_history"] = []
918+
self.probe_position_momentum_params["velocity_map"] = torch.zeros_like(
919+
probe_positions.data
920+
)
921+
922+
history = self.probe_position_momentum_params["position_update_history"]
923+
history_epoch = self.probe_position_momentum_params.get("position_update_history_epoch")
924+
if len(history) == 0 or history_epoch != self.current_epoch:
925+
# Match foldslice's `position_update_memory{iter}` behavior by storing
926+
# one full update map per epoch and filling it across minibatches.
927+
history.append(torch.zeros_like(probe_positions.data))
928+
self.probe_position_momentum_params["position_update_history_epoch"] = self.current_epoch
929+
if len(history) > momentum_memory + 1:
930+
history.pop(0)
931+
history[-1][indices] = delta_pos
932+
933+
if len(history) < momentum_memory + 1:
934+
return delta_pos
935+
936+
corr_level = []
937+
current_update = history[-1][indices]
938+
for i in range(1, momentum_memory + 1):
939+
previous_update = history[-1 - i][indices]
940+
corr_level.append(self._calculate_probe_position_corrcoef(current_update, previous_update))
941+
corr_level = torch.stack(corr_level)
942+
943+
gain = 0.0
944+
friction = torch.tensor(0.5, device=delta_pos.device, dtype=delta_pos.dtype)
945+
if torch.all(corr_level > 0):
946+
p = pmath.polyfit(
947+
torch.arange(0.0, momentum_memory + 1.0, device=delta_pos.device),
948+
torch.concat([torch.zeros([1], device=delta_pos.device), torch.log(corr_level)]),
949+
deg=1,
950+
)
951+
gain = probe_positions.options.momentum_acceleration_gain
952+
friction = 0.1 * (-p[0]).clip(0, None)
953+
954+
m = probe_positions.options.momentum_acceleration_gradient_mixing_factor
955+
m = friction if m is None else m
956+
self.probe_position_momentum_params["velocity_map"][indices] = (
957+
1 - friction
958+
) * self.probe_position_momentum_params["velocity_map"][indices] + m * delta_pos
959+
if delta_pos.abs().max() < 0.1:
960+
delta_pos = delta_pos + gain * self.probe_position_momentum_params["velocity_map"][indices]
961+
return delta_pos
962+
827963
@timer()
828964
def _calculate_object_patch_update_direction(
829965
self, chi, incident_wavefields=None, probe_mode_index=None
@@ -1175,11 +1311,16 @@ def update_probe_positions(
11751311
unique_probes,
11761312
self.parameter_group.object.step_size,
11771313
)
1314+
if self.parameter_group.probe_positions.options.momentum_acceleration_gain > 0:
1315+
delta_pos = self._clip_probe_position_update(delta_pos)
1316+
delta_pos = self._apply_probe_position_momentum(indices, delta_pos)
11781317
delta_pos_full = torch.zeros_like(self.parameter_group.probe_positions.tensor)
11791318
delta_pos_full[indices] = delta_pos
11801319
self.parameter_group.probe_positions.set_grad(-delta_pos_full)
11811320
if apply_updates:
1182-
self.parameter_group.probe_positions.step_optimizer()
1321+
self.parameter_group.probe_positions.step_optimizer(
1322+
clip_update=self.parameter_group.probe_positions.options.momentum_acceleration_gain <= 0
1323+
)
11831324

11841325
@timer()
11851326
def _calculate_final_object_update_step_size(self):

0 commit comments

Comments
 (0)