solvephase 0.1.0__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.
@@ -0,0 +1,865 @@
1
+ """Phase retrieval for generic linear measurement models: the Wirtinger-flow family.
2
+
3
+ Recover ``x`` (up to a global phase) from ``y = |A x|^2`` for a known linear
4
+ operator ``A`` (:mod:`solvephase.operators`). The algorithms are the gradient
5
+ methods catalogued by PhasePack (Chandra et al., Asilomar 2017), each started
6
+ from a spectral-type initialization:
7
+
8
+ Initializations
9
+ * spectral - leading eigenvector of ``A^H diag(y) A`` (Netrapalli, Jain &
10
+ Sanghavi, NeurIPS 2013; Candès, Li & Soltanolkotabi, IEEE Trans. Inf.
11
+ Theory 61, 1985, 2015);
12
+ * truncated spectral - drops measurements above ``alpha_y^2 mean(y)``
13
+ (Chen & Candès, Comm. Pure Appl. Math. 70, 822, 2017);
14
+ * orthogonality-promoting - the ``m/6`` largest amplitudes, unweighted
15
+ (Wang, Giannakis & Eldar, IEEE Trans. Inf. Theory 64, 773, 2018);
16
+ * weighted maximal correlation - the ``3m/13`` largest amplitudes weighted
17
+ by ``psi^(1/2)`` (Wang, Giannakis, Saad & Chen, IEEE Trans. Signal
18
+ Process. 66, 2818, 2018);
19
+ * optimal preprocessing - ``T(y) = (y - 1) / (y + sqrt(delta) - 1)`` with
20
+ ``y`` normalized to unit mean and ``delta = m / n`` (Luo, Alghamdi & Lu,
21
+ IEEE Trans. Signal Process. 67, 2347, 2019);
22
+ * random.
23
+
24
+ Algorithms
25
+ * ``"wf"`` - Wirtinger Flow on the intensity loss with the step schedule
26
+ ``mu_t = min(1 - exp(-t / tau0), mu_max)`` (Candès et al. 2015;
27
+ ``tau0 = 330``; ``mu_max`` defaults to 0.1 because the paper's 0.2
28
+ diverges or stalls on many Gaussian instances with ``n >= 128``);
29
+ * ``"twf"`` - Truncated Wirtinger Flow on the Poisson likelihood (Chen &
30
+ Candès 2017);
31
+ * ``"taf"`` - Truncated Amplitude Flow (Wang, Giannakis & Eldar 2018);
32
+ * ``"raf"`` - Reweighted Amplitude Flow (Wang, Giannakis, Saad & Chen 2018);
33
+ * ``"lbfgs"`` - L-BFGS (:func:`solvephase.optimize.lbfgs`) on the amplitude
34
+ or intensity least-squares loss, using a real view of the complex vector.
35
+
36
+ Scaling. The literature states every rule for the unit-variance complex
37
+ Gaussian model (``E |A_kj|^2 = 1``). Internally the operator is rescaled to
38
+ that convention, ``A -> sqrt(s) A`` and ``y -> s y`` with
39
+ ``s = m n / ||A||_F^2``, so the published step sizes and truncation thresholds
40
+ apply unchanged to any operator (for coded diffraction with a unitary FFT this
41
+ reproduces the unnormalized-FFT model of the CDP papers). Measurement vectors
42
+ are assumed to have nearly equal norms, which holds for the Gaussian and CDP
43
+ models; the row-norm normalizations of the TAF/RAF initializations are
44
+ therefore dropped.
45
+
46
+ Whitening. When the operator reports ``G = diag(A^H A)``
47
+ (:meth:`~solvephase.operators.LinearOperator.gram_diagonal`; every built-in
48
+ operator does), the power method runs on ``G^{-1/2} Y G^{-1/2}`` and maps the
49
+ eigenvector back with ``G^{1/2}``. For the Gaussian model ``G ~ m I`` and
50
+ nothing changes; for coded diffraction with octanary masks it removes
51
+ eigenvectors localized on pixels where most masks are strong, which otherwise
52
+ beat the signal eigenvector for images of about ``10^4`` pixels or more.
53
+
54
+ All iterations stay on the backend device; the residual is transferred to the
55
+ host only every ``check_every`` iterations.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import math
61
+ import time
62
+ from collections.abc import Callable
63
+ from dataclasses import dataclass, field, replace
64
+ from typing import Any
65
+
66
+ import numpy as np
67
+
68
+ from ..backend import Backend, backend_of, to_numpy
69
+ from ..operators import LinearOperator, _norm
70
+ from ..optimize import lbfgs
71
+
72
+ __all__ = [
73
+ "GenericResult",
74
+ "init_optimal_spectral",
75
+ "init_orthogonality_promoting",
76
+ "init_random",
77
+ "init_spectral",
78
+ "init_truncated_spectral",
79
+ "init_weighted_correlation",
80
+ "relative_error",
81
+ "wirtinger",
82
+ ]
83
+
84
+ Callback = Callable[[int, Any, float], "bool | None"]
85
+
86
+ METHODS = ("wf", "twf", "taf", "raf", "lbfgs")
87
+ INITS = ("spectral", "truncated", "orthogonal", "weighted", "optimal", "random")
88
+
89
+ #: Initialization used by ``init="auto"``: each algorithm's own paper, except WF, whose
90
+ #: plain spectral start is replaced by the truncated one (identical on Gaussian data, and
91
+ #: the plain one is dominated by the heavy tail of ``y`` for coded diffraction).
92
+ DEFAULT_INIT = {
93
+ "wf": "truncated",
94
+ "twf": "truncated",
95
+ "taf": "orthogonal",
96
+ "raf": "weighted",
97
+ "lbfgs": "optimal",
98
+ }
99
+
100
+ #: Default parameters per method; ``step`` is ``mu`` (``mu_max`` for WF).
101
+ DEFAULT_OPTIONS: dict[str, dict[str, float]] = {
102
+ "wf": {"step": 0.1, "tau0": 330.0},
103
+ "twf": {"step": 0.2, "alpha_lb": 0.3, "alpha_ub": 5.0, "alpha_h": 5.0},
104
+ "taf": {"step": 1.0, "gamma": 0.7},
105
+ "raf": {"step": 4.0, "beta": 5.0},
106
+ "lbfgs": {"memory": 10},
107
+ }
108
+
109
+
110
+ # --------------------------------------------------------------------- result
111
+ @dataclass
112
+ class GenericResult:
113
+ """Outcome of :func:`wirtinger`.
114
+
115
+ Attributes
116
+ ----------
117
+ x:
118
+ Recovered vector, shape ``operator.in_shape`` (backend array), in the
119
+ units of the true signal (the global phase is arbitrary).
120
+ history:
121
+ Relative amplitude residual ``|| |A x| - sqrt(y) || / || sqrt(y) ||``
122
+ at the start and at every check.
123
+ times:
124
+ Wall-clock seconds since the call started, for each history entry
125
+ (the first entry includes the initialization).
126
+ n_iter:
127
+ Iterations performed.
128
+ converged:
129
+ Whether a tolerance (rather than the iteration limit) stopped the run.
130
+ message:
131
+ Why the run stopped.
132
+ elapsed:
133
+ Total wall-clock seconds, including the initialization.
134
+ device:
135
+ ``"cpu"`` or ``"gpu"``.
136
+ method:
137
+ Algorithm used.
138
+ init:
139
+ Initialization used (``"given"`` for a user-supplied start).
140
+ options:
141
+ Method parameters used (``step`` and the method's options).
142
+ """
143
+
144
+ x: Any
145
+ history: list[float]
146
+ times: list[float]
147
+ n_iter: int
148
+ converged: bool
149
+ message: str
150
+ elapsed: float
151
+ device: str
152
+ method: str
153
+ init: str
154
+ options: dict[str, float] = field(default_factory=dict)
155
+
156
+ @property
157
+ def residual(self) -> float:
158
+ """Final relative amplitude residual."""
159
+ return self.history[-1]
160
+
161
+ def to_numpy(self) -> GenericResult:
162
+ """A copy with :attr:`x` on the host as a NumPy array."""
163
+ return replace(self, x=to_numpy(self.x))
164
+
165
+
166
+ # ---------------------------------------------------------------- utilities
167
+ def relative_error(x: Any, truth: Any) -> float:
168
+ """Distance to the truth modulo the global phase.
169
+
170
+ ``min_phi ||x - exp(i phi) truth|| / ||truth||``, with the optimal phase
171
+ ``exp(i phi) = truth^H x / |truth^H x|``. Works for NumPy or CuPy input
172
+ (computed on the device of ``x``).
173
+ """
174
+ be = backend_of(x)
175
+ xp = be.xp
176
+ a = be.asarray(x, dtype="complex")
177
+ t = be.asarray(truth, dtype="complex")
178
+ c = complex(xp.sum(xp.conj(t) * a))
179
+ phase = c / abs(c) if c != 0 else 1.0
180
+ d = a - phase * t
181
+ return math.sqrt(be.dot(d, d) / max(be.dot(t, t), 1e-300))
182
+
183
+
184
+ def _abs2(u: Any) -> Any:
185
+ return u.real * u.real + u.imag * u.imag
186
+
187
+
188
+ class _Model:
189
+ """The operator and data rescaled to the unit-variance Gaussian convention."""
190
+
191
+ def __init__(self, operator: LinearOperator, measurements: Any, magnitudes: bool) -> None:
192
+ if not isinstance(operator, LinearOperator):
193
+ raise TypeError(
194
+ f"operator must be a solvephase.operators.LinearOperator, got "
195
+ f"{type(operator).__name__}; wrap a dense matrix in MatrixOperator"
196
+ )
197
+ be = operator.backend
198
+ xp = be.xp
199
+ self.operator = operator
200
+ self.backend: Backend = be
201
+ if getattr(measurements, "dtype", np.dtype(float)).kind == "c":
202
+ raise ValueError(
203
+ "measurements must be real intensities (or magnitudes with magnitudes=True), "
204
+ "got a complex array"
205
+ )
206
+ data = be.asarray(measurements, dtype="real")
207
+ if tuple(data.shape) != tuple(operator.out_shape):
208
+ raise ValueError(
209
+ f"measurements have shape {tuple(data.shape)} but the operator produces "
210
+ f"{tuple(operator.out_shape)}"
211
+ )
212
+ if magnitudes:
213
+ psi = xp.abs(data)
214
+ y = psi * psi
215
+ else:
216
+ # Noise can make intensities negative; they carry no amplitude.
217
+ y = xp.maximum(data, 0)
218
+ psi = xp.sqrt(y)
219
+ self.m = operator.out_size
220
+ self.n = operator.in_size
221
+ gain = operator.frobenius_norm_squared() / self.n
222
+ if not gain > 0:
223
+ raise ValueError("the operator has zero Frobenius norm")
224
+ self.scale = self.m / gain
225
+ self.root = math.sqrt(self.scale)
226
+ self.y = y * self.scale
227
+ self.psi = psi * self.root
228
+ self.psi_norm = math.sqrt(be.dot(self.psi, self.psi))
229
+ if not self.psi_norm > 0:
230
+ raise ValueError("all measurements are zero: the signal is zero or unobservable")
231
+ # ||x||^2 estimate: E y_k = ||x||^2 in the Gaussian convention.
232
+ self.norm0 = self.psi_norm / math.sqrt(self.m)
233
+ self.tiny = float(np.finfo(be.real_dtype).tiny)
234
+ # Diagonal whitening G^{-1/2}, G = diag(A^H A)/m in the rescaled units (mean ~1).
235
+ self.whiten: Any = None
236
+ gram = operator.gram_diagonal()
237
+ if gram is not None:
238
+ gram = be.asarray(gram, dtype="real") * (self.scale / self.m)
239
+ if be.scalar(xp.min(gram)) > 0:
240
+ self.whiten = 1.0 / xp.sqrt(gram)
241
+
242
+ def forward(self, z: Any) -> Any:
243
+ return self.operator.forward(z) * self.root
244
+
245
+ def adjoint(self, v: Any) -> Any:
246
+ return self.operator.adjoint(v) * self.root
247
+
248
+ def residual_norm(self, u: Any) -> Any:
249
+ """``|| |u| - psi ||`` (device 0-d array on the GPU, float on the CPU)."""
250
+ return _norm(self.backend, self.backend.xp.abs(u) - self.psi)
251
+
252
+ def random_vector(self, seed: Any) -> Any:
253
+ rng = self.backend.random(seed)
254
+ g = rng.standard_normal((2, *self.operator.in_shape))
255
+ v = self.backend.asarray(g[0] + 1j * g[1], dtype="complex")
256
+ return v / _norm(self.backend, v)
257
+
258
+
259
+ # --------------------------------------------------------- initializations
260
+ def _power(
261
+ model: _Model,
262
+ weights: Any,
263
+ *,
264
+ shift: float = 0.0,
265
+ iterations: int,
266
+ tol: float,
267
+ seed: Any,
268
+ check_every: int = 10,
269
+ ) -> tuple[Any, float]:
270
+ """Power method for ``M = (1/m) W A^H diag(weights) A W + shift I``.
271
+
272
+ ``W = G^{-1/2}`` is the model's diagonal whitening (identity when the
273
+ operator does not report ``diag(A^H A)``). Returns the unit eigenvector
274
+ estimate (whitened coordinates) and its Rayleigh quotient.
275
+ """
276
+ be = model.backend
277
+ wh = model.whiten
278
+ v = model.random_vector(seed)
279
+ inv_m = 1.0 / model.m
280
+ previous: float | None = None
281
+ value = 0.0
282
+ for it in range(1, iterations + 1):
283
+ w = model.adjoint(weights * model.forward(v if wh is None else wh * v)) * inv_m
284
+ if wh is not None:
285
+ w = wh * w
286
+ if shift:
287
+ w = w + shift * v
288
+ if it % check_every == 0 or it == iterations:
289
+ value = be.dot(v, w)
290
+ if previous is not None and abs(value - previous) <= tol * abs(value):
291
+ return w / _norm(be, w), value
292
+ previous = value
293
+ v = w / _norm(be, w)
294
+ return v, value
295
+
296
+
297
+ def _leading_eigenvector(
298
+ model: _Model,
299
+ weights: Any,
300
+ *,
301
+ shift: float = 0.0,
302
+ iterations: int,
303
+ tol: float,
304
+ seed: Any,
305
+ ) -> Any:
306
+ """Unit-norm leading eigenvector, mapped back from whitened coordinates."""
307
+ v, _ = _power(model, weights, shift=shift, iterations=iterations, tol=tol, seed=seed)
308
+ if model.whiten is not None:
309
+ v = v / model.whiten
310
+ v = v / _norm(model.backend, v)
311
+ return v
312
+
313
+
314
+ def _spectral(model: _Model, iterations: int, tol: float, seed: Any) -> Any:
315
+ v = _leading_eigenvector(model, model.y, iterations=iterations, tol=tol, seed=seed)
316
+ return v * model.norm0
317
+
318
+
319
+ def _truncated(model: _Model, iterations: int, tol: float, seed: Any, alpha_y: float = 3.0) -> Any:
320
+ y = model.y
321
+ weights = y * (y <= alpha_y**2 * model.norm0**2)
322
+ v = _leading_eigenvector(model, weights, iterations=iterations, tol=tol, seed=seed)
323
+ return v * model.norm0
324
+
325
+
326
+ def _top_fraction(model: _Model, fraction: float) -> Any:
327
+ """Boolean mask of the ``ceil(fraction m)`` largest amplitudes."""
328
+ xp = model.backend.xp
329
+ if not 0.0 < fraction <= 1.0:
330
+ raise ValueError(f"fraction must be in (0, 1], got {fraction}")
331
+ k = max(1, min(model.m, math.ceil(fraction * model.m)))
332
+ flat = model.psi.ravel()
333
+ threshold = xp.partition(flat, model.m - k)[model.m - k]
334
+ return model.psi >= threshold
335
+
336
+
337
+ def _orthogonal(
338
+ model: _Model, iterations: int, tol: float, seed: Any, fraction: float = 1.0 / 6.0
339
+ ) -> Any:
340
+ weights = _top_fraction(model, fraction).astype(model.y.dtype)
341
+ v = _leading_eigenvector(model, weights, iterations=iterations, tol=tol, seed=seed)
342
+ return v * model.norm0
343
+
344
+
345
+ def _weighted(
346
+ model: _Model,
347
+ iterations: int,
348
+ tol: float,
349
+ seed: Any,
350
+ fraction: float = 3.0 / 13.0,
351
+ exponent: float = 0.5,
352
+ ) -> Any:
353
+ weights = _top_fraction(model, fraction) * model.psi**exponent
354
+ v = _leading_eigenvector(model, weights, iterations=iterations, tol=tol, seed=seed)
355
+ return v * model.norm0
356
+
357
+
358
+ def _optimal(model: _Model, iterations: int, tol: float, seed: Any) -> Any:
359
+ delta = model.m / model.n
360
+ if delta <= 1.0:
361
+ raise ValueError(
362
+ f"the optimal-preprocessing initialization needs more measurements than "
363
+ f"unknowns (m/n = {delta:.3g} <= 1); use init='spectral' or 'truncated'"
364
+ )
365
+ root_delta = math.sqrt(delta)
366
+ ybar = model.y / model.norm0**2
367
+ weights = (ybar - 1.0) / (ybar + (root_delta - 1.0))
368
+ # T >= -1/(sqrt(delta) - 1); shift by that times ||A W||^2/m so the matrix is PSD and
369
+ # the power method finds the largest algebraic eigenvalue.
370
+ _, gram_norm = _power(model, 1.0, iterations=100, tol=1e-4, seed=seed)
371
+ shift = 1.05 * gram_norm / (root_delta - 1.0)
372
+ v = _leading_eigenvector(model, weights, shift=shift, iterations=iterations, tol=tol, seed=seed)
373
+ return v * model.norm0
374
+
375
+
376
+ def _random(model: _Model, iterations: int, tol: float, seed: Any) -> Any:
377
+ return model.random_vector(seed) * model.norm0
378
+
379
+
380
+ _INIT_FUNCTIONS: dict[str, Callable[..., Any]] = {
381
+ "spectral": _spectral,
382
+ "truncated": _truncated,
383
+ "orthogonal": _orthogonal,
384
+ "weighted": _weighted,
385
+ "optimal": _optimal,
386
+ "random": _random,
387
+ }
388
+
389
+
390
+ def init_spectral(
391
+ operator: LinearOperator,
392
+ measurements: Any,
393
+ *,
394
+ magnitudes: bool = False,
395
+ iterations: int = 100,
396
+ tol: float = 1e-6,
397
+ seed: Any = None,
398
+ ) -> Any:
399
+ """Spectral initialization (Netrapalli et al. 2013; Candès et al. 2015).
400
+
401
+ Leading eigenvector of ``Y = (1/m) A^H diag(y) A`` by the power method,
402
+ scaled to the norm estimate ``||x||^2 ~ mean(y)`` (in the unit-variance
403
+ convention; see the module docstring).
404
+
405
+ Parameters
406
+ ----------
407
+ operator:
408
+ The measurement operator ``A``.
409
+ measurements:
410
+ Intensities ``y = |A x|^2`` (or magnitudes ``|A x|`` with
411
+ ``magnitudes=True``), shape ``operator.out_shape``.
412
+ magnitudes:
413
+ Whether ``measurements`` are magnitudes rather than intensities.
414
+ iterations:
415
+ Maximum power iterations.
416
+ tol:
417
+ Relative change of the Rayleigh quotient that stops the power method.
418
+ seed:
419
+ Seed of the random power-method start (host RNG).
420
+
421
+ Returns
422
+ -------
423
+ array
424
+ Initial estimate of shape ``operator.in_shape`` on the operator's
425
+ backend.
426
+ """
427
+ model = _Model(operator, measurements, magnitudes)
428
+ return _spectral(model, iterations, tol, seed)
429
+
430
+
431
+ def init_truncated_spectral(
432
+ operator: LinearOperator,
433
+ measurements: Any,
434
+ *,
435
+ magnitudes: bool = False,
436
+ alpha_y: float = 3.0,
437
+ iterations: int = 100,
438
+ tol: float = 1e-6,
439
+ seed: Any = None,
440
+ ) -> Any:
441
+ """Truncated spectral initialization (Chen & Candès 2017).
442
+
443
+ As :func:`init_spectral` but keeping only ``y_k <= alpha_y^2 mean(y)``,
444
+ which removes the heavy tail that biases the plain spectral estimate.
445
+ Parameters are those of :func:`init_spectral` plus ``alpha_y``
446
+ (dimensionless, default 3 as in the paper).
447
+ """
448
+ model = _Model(operator, measurements, magnitudes)
449
+ return _truncated(model, iterations, tol, seed, alpha_y=alpha_y)
450
+
451
+
452
+ def init_orthogonality_promoting(
453
+ operator: LinearOperator,
454
+ measurements: Any,
455
+ *,
456
+ magnitudes: bool = False,
457
+ fraction: float = 1.0 / 6.0,
458
+ iterations: int = 100,
459
+ tol: float = 1e-6,
460
+ seed: Any = None,
461
+ ) -> Any:
462
+ """Orthogonality-promoting initialization (Wang, Giannakis & Eldar 2018).
463
+
464
+ Leading eigenvector of ``sum_{k in S} a_k a_k^*`` over the
465
+ ``ceil(fraction m)`` largest amplitudes (the measurement vectors most
466
+ aligned with ``x``), scaled to ``sqrt(mean(y))``. Parameters are those of
467
+ :func:`init_spectral` plus ``fraction`` (default 1/6, as in TAF).
468
+ """
469
+ model = _Model(operator, measurements, magnitudes)
470
+ return _orthogonal(model, iterations, tol, seed, fraction=fraction)
471
+
472
+
473
+ def init_weighted_correlation(
474
+ operator: LinearOperator,
475
+ measurements: Any,
476
+ *,
477
+ magnitudes: bool = False,
478
+ fraction: float = 3.0 / 13.0,
479
+ exponent: float = 0.5,
480
+ iterations: int = 100,
481
+ tol: float = 1e-6,
482
+ seed: Any = None,
483
+ ) -> Any:
484
+ """Weighted maximal correlation initialization (Wang, Giannakis, Saad & Chen 2018).
485
+
486
+ Leading eigenvector of ``sum_{k in S} psi_k^exponent a_k a_k^*`` over the
487
+ ``ceil(fraction m)`` largest amplitudes ``psi = sqrt(y)``, scaled to
488
+ ``sqrt(mean(y))``. Defaults (``3/13`` and ``1/2``) follow the RAF paper.
489
+ """
490
+ model = _Model(operator, measurements, magnitudes)
491
+ return _weighted(model, iterations, tol, seed, fraction=fraction, exponent=exponent)
492
+
493
+
494
+ def init_optimal_spectral(
495
+ operator: LinearOperator,
496
+ measurements: Any,
497
+ *,
498
+ magnitudes: bool = False,
499
+ iterations: int = 200,
500
+ tol: float = 1e-6,
501
+ seed: Any = None,
502
+ ) -> Any:
503
+ """Spectral initialization with optimal preprocessing (Luo, Alghamdi & Lu 2019).
504
+
505
+ Leading (largest algebraic) eigenvector of ``(1/m) A^H diag(T(y)) A`` with
506
+ ``T(y) = (y - 1) / (y + sqrt(delta) - 1)``, ``y`` normalized to unit mean
507
+ and ``delta = m / n > 1``. ``T`` is negative for small ``y``, so the power
508
+ method runs on the matrix shifted by ``||A||^2 / (m (sqrt(delta) - 1))``
509
+ (``||A||`` from :meth:`~solvephase.operators.LinearOperator.norm_estimate`),
510
+ which slows it; the default ``iterations`` is therefore larger. Asymptotically
511
+ this weighting minimizes the number of measurements needed for a
512
+ non-trivial correlation with ``x``.
513
+ """
514
+ model = _Model(operator, measurements, magnitudes)
515
+ return _optimal(model, iterations, tol, seed)
516
+
517
+
518
+ def init_random(
519
+ operator: LinearOperator,
520
+ measurements: Any,
521
+ *,
522
+ magnitudes: bool = False,
523
+ seed: Any = None,
524
+ ) -> Any:
525
+ """Random complex Gaussian start scaled to the norm estimate ``sqrt(mean(y))``."""
526
+ model = _Model(operator, measurements, magnitudes)
527
+ return _random(model, 0, 0.0, seed)
528
+
529
+
530
+ # --------------------------------------------------------------- iterations
531
+ def _wf_step(model: _Model, opts: dict[str, float], norm0_sq: float) -> Callable[..., Any]:
532
+ mu_max, tau0 = opts["step"], opts["tau0"]
533
+ scale = 1.0 / (model.m * norm0_sq)
534
+
535
+ def step(it: int, z: Any, u: Any) -> Any:
536
+ mu = min(1.0 - math.exp(-it / tau0), mu_max)
537
+ return z - (mu * scale) * model.adjoint((_abs2(u) - model.y) * u)
538
+
539
+ return step
540
+
541
+
542
+ def _twf_step(model: _Model, opts: dict[str, float]) -> Callable[..., Any]:
543
+ xp = model.backend.xp
544
+ mu, lb, ub, ah = opts["step"], opts["alpha_lb"], opts["alpha_ub"], opts["alpha_h"]
545
+ factor = 2.0 * mu / model.m
546
+
547
+ def step(it: int, z: Any, u: Any) -> Any:
548
+ au2 = _abs2(u)
549
+ au = xp.sqrt(au2)
550
+ r = model.y - au2
551
+ ar = xp.abs(r)
552
+ ratio = au / _norm(model.backend, z)
553
+ keep = (ratio >= lb) & (ratio <= ub) & (ar <= (ah * xp.mean(ar)) * ratio)
554
+ g = xp.where(keep, r / xp.maximum(au2, model.tiny), 0) * u
555
+ return z + factor * model.adjoint(g)
556
+
557
+ return step
558
+
559
+
560
+ def _amplitude_residual(model: _Model, u: Any) -> tuple[Any, Any]:
561
+ """``(|u|, u - psi u / |u|)``."""
562
+ xp = model.backend.xp
563
+ au = xp.abs(u)
564
+ return au, u - (model.psi / xp.maximum(au, model.tiny)) * u
565
+
566
+
567
+ def _taf_step(model: _Model, opts: dict[str, float]) -> Callable[..., Any]:
568
+ xp = model.backend.xp
569
+ factor = opts["step"] / model.m
570
+ gamma1 = 1.0 + opts["gamma"]
571
+
572
+ def step(it: int, z: Any, u: Any) -> Any:
573
+ au, v = _amplitude_residual(model, u)
574
+ return z - factor * model.adjoint(xp.where(au * gamma1 >= model.psi, v, 0))
575
+
576
+ return step
577
+
578
+
579
+ def _raf_step(model: _Model, opts: dict[str, float]) -> Callable[..., Any]:
580
+ xp = model.backend.xp
581
+ factor = opts["step"] / model.m
582
+ beta = opts["beta"]
583
+
584
+ def step(it: int, z: Any, u: Any) -> Any:
585
+ au, v = _amplitude_residual(model, u)
586
+ w = au / xp.maximum(au + beta * model.psi, model.tiny)
587
+ return z - factor * model.adjoint(w * v)
588
+
589
+ return step
590
+
591
+
592
+ class _Monitor:
593
+ """Shared stopping rules, history and callback handling.
594
+
595
+ Two tests run at every check: the relative amplitude residual ``r`` below
596
+ ``tol`` (an exact fit), and its relative change since the previous check
597
+ below ``tol`` (a stationary point: near a minimizer with residual ``r*``
598
+ the change of ``r`` is quadratic in the distance to it, so this stops only
599
+ once the remaining optimization error is far below the noise-induced one,
600
+ and never during geometric convergence to an exact fit).
601
+ """
602
+
603
+ def __init__(
604
+ self, model: _Model, tol: float, callback: Callback | None, t0: float, u0: Any
605
+ ) -> None:
606
+ self.model = model
607
+ self.tol = max(tol, 8.0 * model.backend.eps)
608
+ self.callback = callback
609
+ self.t0 = t0
610
+ self.history = [float(model.residual_norm(u0)) / model.psi_norm]
611
+ self.times = [time.perf_counter() - t0]
612
+ self.message = "iteration limit reached"
613
+ self.converged = False
614
+
615
+ def check(self, it: int, z: Any, residual_norm: Any, *, final: bool = False) -> bool:
616
+ """Record a check; return True to stop.
617
+
618
+ ``final`` records the end state of a run that stopped by itself and
619
+ applies only the exact-fit test.
620
+ """
621
+ r = float(residual_norm) / self.model.psi_norm
622
+ previous = self.history[-1]
623
+ self.history.append(r)
624
+ self.times.append(time.perf_counter() - self.t0)
625
+ if not math.isfinite(r):
626
+ self.message = "diverged (non-finite residual); reduce the step size"
627
+ return True
628
+ if not final and self.callback is not None and self.callback(it, z, r):
629
+ self.message, self.converged = "stopped by callback", True
630
+ return True
631
+ if r <= self.tol:
632
+ self.message, self.converged = "relative residual below tol", True
633
+ return True
634
+ if not final and abs(previous - r) <= self.tol * previous:
635
+ self.message, self.converged = "residual stagnated (stationary point)", True
636
+ return True
637
+ return False
638
+
639
+
640
+ def _iterate(
641
+ model: _Model,
642
+ z: Any,
643
+ step: Callable[..., Any],
644
+ monitor: _Monitor,
645
+ iterations: int,
646
+ check_every: int,
647
+ ) -> tuple[Any, int]:
648
+ u = model.forward(z)
649
+ it = 0
650
+ for it in range(1, iterations + 1):
651
+ z = step(it, z, u)
652
+ u = model.forward(z)
653
+ if (it % check_every == 0 or it == iterations) and monitor.check(
654
+ it, z, model.residual_norm(u)
655
+ ):
656
+ break
657
+ return z, it
658
+
659
+
660
+ def _run_lbfgs(
661
+ model: _Model,
662
+ z0: Any,
663
+ monitor: _Monitor,
664
+ iterations: int,
665
+ check_every: int,
666
+ loss: str,
667
+ memory: int,
668
+ ) -> tuple[Any, int]:
669
+ be = model.backend
670
+ xp = be.xp
671
+ cdt, rdt = be.complex_dtype, be.real_dtype
672
+ inv_m = 1.0 / model.m
673
+ amplitude = loss == "amplitude"
674
+
675
+ def fun(xr: Any) -> tuple[float, Any]:
676
+ z = xr.view(cdt)
677
+ u = model.forward(z)
678
+ if amplitude:
679
+ au, v = _amplitude_residual(model, u)
680
+ d = au - model.psi
681
+ f = (0.5 * inv_m) * be.dot(d, d)
682
+ g = model.adjoint(v) * inv_m
683
+ else:
684
+ d = _abs2(u) - model.y
685
+ f = (0.25 * inv_m) * be.dot(d, d)
686
+ g = model.adjoint(d * u) * inv_m
687
+ return f, xp.ascontiguousarray(g).view(rdt)
688
+
689
+ def check(it: int, xr: Any, f: float) -> bool:
690
+ if it % check_every:
691
+ return False
692
+ z = xr.view(cdt)
693
+ if amplitude:
694
+ res = math.sqrt(max(2.0 * model.m * f, 0.0))
695
+ else:
696
+ res = model.residual_norm(model.forward(z))
697
+ return monitor.check(it, z, res)
698
+
699
+ x0 = xp.ascontiguousarray(z0).view(rdt)
700
+ out = lbfgs(
701
+ fun,
702
+ x0,
703
+ be,
704
+ max_iter=iterations,
705
+ memory=memory,
706
+ ftol=be.eps,
707
+ gtol=0.0,
708
+ callback=check,
709
+ )
710
+ z = out.x.view(cdt)
711
+ if out.message != "stopped by callback":
712
+ # The optimizer stopped by itself; record the final state.
713
+ monitor.check(out.n_iter, z, model.residual_norm(model.forward(z)), final=True)
714
+ if out.message != "iteration limit reached" and not monitor.converged:
715
+ monitor.message, monitor.converged = out.message, out.converged
716
+ return z, out.n_iter
717
+
718
+
719
+ def _options(method: str, options: dict[str, float] | None, step: float | None) -> dict[str, float]:
720
+ opts = dict(DEFAULT_OPTIONS[method])
721
+ for key, value in (options or {}).items():
722
+ if key not in opts:
723
+ raise ValueError(
724
+ f"unknown option {key!r} for method {method!r}; valid options: {sorted(opts)}"
725
+ )
726
+ opts[key] = float(value)
727
+ if step is not None:
728
+ if method == "lbfgs":
729
+ raise ValueError("method='lbfgs' chooses its steps by line search; omit step")
730
+ opts["step"] = float(step)
731
+ return opts
732
+
733
+
734
+ def wirtinger(
735
+ operator: LinearOperator,
736
+ measurements: Any,
737
+ *,
738
+ method: str = "raf",
739
+ init: Any = "auto",
740
+ magnitudes: bool = False,
741
+ iterations: int = 1000,
742
+ tol: float = 1e-7,
743
+ check_every: int = 10,
744
+ step: float | None = None,
745
+ loss: str = "amplitude",
746
+ options: dict[str, float] | None = None,
747
+ power_iterations: int | None = None,
748
+ seed: Any = None,
749
+ callback: Callback | None = None,
750
+ ) -> GenericResult:
751
+ """Recover ``x`` from ``y = |A x|^2`` with a Wirtinger-flow-family solver.
752
+
753
+ Parameters
754
+ ----------
755
+ operator:
756
+ Measurement operator ``A`` (:mod:`solvephase.operators`); its backend
757
+ sets the device and precision.
758
+ measurements:
759
+ Intensities ``y = |A x|^2``, shape ``operator.out_shape`` (host or
760
+ device array). Negative values (noise) are clipped to zero.
761
+ method:
762
+ ``"raf"`` (Reweighted Amplitude Flow, default), ``"taf"`` (Truncated
763
+ Amplitude Flow), ``"twf"`` (Truncated Wirtinger Flow), ``"wf"``
764
+ (Wirtinger Flow) or ``"lbfgs"`` (L-BFGS on the ``loss``).
765
+ init:
766
+ ``"auto"`` (the method's own initialization, see
767
+ :data:`DEFAULT_INIT`), ``"spectral"``, ``"truncated"``,
768
+ ``"orthogonal"``, ``"weighted"``, ``"optimal"``, ``"random"``, or an
769
+ array of shape ``operator.in_shape`` to start from.
770
+ magnitudes:
771
+ Whether ``measurements`` are magnitudes ``|A x|`` instead of
772
+ intensities.
773
+ iterations:
774
+ Maximum iterations (each costs one ``forward`` and one ``adjoint``;
775
+ an L-BFGS iteration may cost more through its line search).
776
+ tol:
777
+ Stop when the relative amplitude residual falls below ``tol`` (exact
778
+ fit), or changes by less than ``tol`` (relative) between checks
779
+ (stationary point, e.g. with noisy data). It is raised to ``8 eps``
780
+ of the working precision (about ``1e-6`` in single precision).
781
+ check_every:
782
+ Iterations between convergence checks (the only host syncs).
783
+ step:
784
+ Step size ``mu`` overriding the method default (``mu_max`` for WF).
785
+ loss:
786
+ ``"amplitude"`` or ``"intensity"`` least squares, for
787
+ ``method="lbfgs"``.
788
+ options:
789
+ Method parameters overriding :data:`DEFAULT_OPTIONS`: ``tau0`` (WF);
790
+ ``alpha_lb``, ``alpha_ub``, ``alpha_h`` (TWF); ``gamma`` (TAF);
791
+ ``beta`` (RAF); ``memory`` (L-BFGS).
792
+ power_iterations:
793
+ Power-method iterations of the initialization (default 100, 200 for
794
+ ``"optimal"``).
795
+ seed:
796
+ Seed for the initialization (host RNG; CPU and GPU agree).
797
+ callback:
798
+ ``callback(iteration, x, residual)`` at each check; returning
799
+ ``True`` stops the run.
800
+
801
+ Returns
802
+ -------
803
+ GenericResult
804
+ The estimate (global phase arbitrary; compare with
805
+ :func:`relative_error`) and the residual history.
806
+ """
807
+ t0 = time.perf_counter()
808
+ method = str(method).lower()
809
+ if method not in METHODS:
810
+ raise ValueError(f"method must be one of {METHODS}, got {method!r}")
811
+ if loss not in ("amplitude", "intensity"):
812
+ raise ValueError(f"loss must be 'amplitude' or 'intensity', got {loss!r}")
813
+ if iterations < 0 or check_every < 1:
814
+ raise ValueError("iterations must be >= 0 and check_every >= 1")
815
+ opts = _options(method, options, step)
816
+ model = _Model(operator, measurements, magnitudes)
817
+ be = model.backend
818
+ xp = be.xp
819
+
820
+ if isinstance(init, str):
821
+ name = DEFAULT_INIT[method] if init == "auto" else init
822
+ if name not in _INIT_FUNCTIONS:
823
+ raise ValueError(f"init must be 'auto', one of {INITS}, or an array; got {init!r}")
824
+ n_power = power_iterations or (200 if name == "optimal" else 100)
825
+ z = _INIT_FUNCTIONS[name](model, n_power, 1e-6, seed)
826
+ else:
827
+ name = "given"
828
+ z = be.asarray(init, dtype="complex")
829
+ if tuple(z.shape) != tuple(operator.in_shape):
830
+ raise ValueError(
831
+ f"init has shape {tuple(z.shape)} but the operator expects "
832
+ f"{tuple(operator.in_shape)}"
833
+ )
834
+ z = xp.ascontiguousarray(z)
835
+
836
+ monitor = _Monitor(model, tol, callback, t0, model.forward(z))
837
+ if method == "lbfgs":
838
+ z, n_iter = _run_lbfgs(
839
+ model, z, monitor, iterations, check_every, loss, int(opts["memory"])
840
+ )
841
+ else:
842
+ if method == "wf":
843
+ norm0_sq = be.dot(z, z) or model.norm0**2
844
+ stepper = _wf_step(model, opts, norm0_sq)
845
+ elif method == "twf":
846
+ stepper = _twf_step(model, opts)
847
+ elif method == "taf":
848
+ stepper = _taf_step(model, opts)
849
+ else:
850
+ stepper = _raf_step(model, opts)
851
+ z, n_iter = _iterate(model, z, stepper, monitor, iterations, check_every)
852
+ be.synchronize()
853
+ return GenericResult(
854
+ x=z,
855
+ history=monitor.history,
856
+ times=monitor.times,
857
+ n_iter=n_iter,
858
+ converged=monitor.converged,
859
+ message=monitor.message,
860
+ elapsed=time.perf_counter() - t0,
861
+ device=be.device,
862
+ method=method,
863
+ init=name,
864
+ options=opts,
865
+ )