#!/usr/bin/env python3
"""
Data fidelity classes.
@author: Nicola VIGANĂ’, Computational Imaging group, CWI, The Netherlands,
and ESRF - The European Synchrotron, Grenoble, France
"""
from abc import ABC, abstractmethod
from collections.abc import Sequence
from copy import deepcopy
from typing import Any
import numpy as np
from numpy.typing import NDArray
from . import operators
eps = np.finfo(np.float32).eps
NDArrayFloat = NDArray[np.floating]
[docs]
def _soft_threshold(values: NDArrayFloat, threshold: float | NDArrayFloat) -> None:
values_abs = np.abs(values)
values_sign = np.sign(values)
values[:] = values_sign * np.fmax(values_abs - threshold, 0.0)
[docs]
class DataFidelityBase(ABC):
"""Define the DataFidelity classes interface."""
data: NDArrayFloat | None
sigma: float | NDArrayFloat
background: NDArrayFloat | None
sigma_data: NDArrayFloat | None
__data_fidelity_name__ = ""
def __init__(self, background: float | NDArrayFloat | None = None) -> None:
"""
Initialize the base data-fidelity class.
Parameters
----------
background : float | NDArrayFloat | None, optional
The data background. The default is None.
"""
self.background = np.array(background) if background is not None else None
self.data = None
self.sigma = 1.0
self.sigma_data = None
[docs]
def _slice_attr(self, attr: str, ind: Any) -> None:
attr_val = self.__getattribute__(attr)
if attr_val is not None and isinstance(attr_val, np.ndarray) and attr_val.size > 1:
self.__setattr__(attr, attr_val[ind])
[docs]
def __getitem__(self, ind: Any) -> "DataFidelityBase":
"""
Slice the norm and all its attributes.
Parameters
----------
ind : Any
Slicing indices.
Returns
-------
DataFidelityBase
The sliced norm.
"""
new_self = deepcopy(self)
for attr in self.__dict__.keys():
new_self._slice_attr(attr, ind)
return new_self
[docs]
def info(self) -> str:
"""
Return the data-fidelity info.
Returns
-------
str
Data fidelity info string.
"""
if self.background is not None:
if np.array(self.background).size > 1:
bckgrnd_str = "(B:<array>)"
else:
bckgrnd_str = "(B:%g)" % self.background
else:
bckgrnd_str = ""
return self.__data_fidelity_name__ + bckgrnd_str
[docs]
def upper(self) -> str:
"""
Return the upper case name of the data-fidelity.
Returns
-------
str
Upper case string name of the data-fidelity.
"""
return self.info().upper()
[docs]
def lower(self) -> str:
"""
Return the lower case name of the data-fidelity.
Returns
-------
str
Lower case string name of the data-fidelity.
"""
return self.info().lower()
[docs]
def assign_data(self, data: float | NDArrayFloat | None = None, sigma: float | NDArrayFloat = 1.0) -> None:
"""Initialize the data bias, and sigma of the data term.
Parameters
----------
data : float | NDArrayFloat | None, optional
The data bias, by default None
sigma : float | NDArrayFloat, optional
The sigma, by default 1.0
"""
self.data = np.array(data) if data is not None else None
self.sigma = sigma
self.sigma_data = self._compute_sigma_data()
if self.background is not None and self.data is not None:
self.background = self.background.astype(self.data.dtype)
[docs]
def compute_residual(self, proj_primal: NDArrayFloat, mask: NDArrayFloat | None = None) -> NDArrayFloat:
"""Compute the residual in the dual domain.
Parameters
----------
proj_primal : NDArrayFloat
Projection of the primal solution
mask : NDArrayFloat | None, optional
Mask of the dual domain, by default None
Returns
-------
NDArrayFloat
The residual
"""
if self.background is not None:
proj_primal = proj_primal + self.background
if self.data is not None:
residual = self.data - proj_primal
else:
residual = proj_primal.copy()
if mask is not None:
residual *= mask
return residual
[docs]
@abstractmethod
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
"""Compute the norm of the residual.
Parameters
----------
dual : NDArrayFloat
The residual in the dual domain.
Returns
-------
float
The residual norm.
"""
[docs]
def _compute_sigma_data(self):
if self.data is None:
return None
else:
return self.sigma * self.data
[docs]
def compute_data_dual_dot(self, dual: NDArrayFloat, mask: NDArrayFloat | None = None) -> float:
"""Compute the dot product of the data bias and the dual solution.
Parameters
----------
dual : NDArrayFloat
The dual solution.
mask : NDArrayFloat | None, optional
Mask of the dual domain, by default None
Returns
-------
float
The dot product between the data bias and the dual solution
"""
if self.data is not None:
if mask is not None:
dual = dual * mask
return np.dot(dual.flatten(), self.data.flatten())
else:
return 0.0
[docs]
def initialize_dual(self) -> NDArrayFloat:
"""Initialize the dual domain solution.
Returns
-------
NDArrayFloat
A zero array with the dimensions of the dual domain.
"""
return np.zeros_like(self.data)
[docs]
def update_dual(self, dual: NDArrayFloat, proj_primal: NDArrayFloat) -> None:
"""Update the dual solution.
Parameters
----------
dual : NDArrayFloat
The current dual solution
proj_primal : NDArrayFloat
The projected primal solution
"""
if self.background is None:
dual += proj_primal * self.sigma
else:
dual += (proj_primal + self.background) * self.sigma
[docs]
@abstractmethod
def apply_proximal_dual(self, dual: NDArrayFloat) -> None:
"""Apply the proximal in the dual domain.
Parameters
----------
dual : NDArrayFloat
The dual solution
"""
[docs]
@abstractmethod
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply the proximal operator in the primal domain, in-place.
Computes prox_{tau * f}(primal), i.e. the proximal of the data-fidelity
f with step size tau, and stores the result back into ``primal``.
Note: in FISTA the data term is handled via a gradient step, so this
method is exposed for standalone use or for algorithms that prefer a
pure proximal approach (e.g. when A = I).
Parameters
----------
primal : NDArrayFloat
The current primal variable (modified in-place).
tau : float | NDArrayFloat
The proximal step size.
"""
[docs]
@abstractmethod
def compute_primal_dual_gap(
self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None
) -> float:
"""Compute the primal-dual gap of the current solution.
Parameters
----------
proj_primal : NDArrayFloat
The projected primal solution (in the dual domain)
dual : NDArrayFloat
The dual solution
mask : NDArrayFloat | None, optional
Mask in the dual domain, by default None
Returns
-------
float
The primal-dual gap
"""
[docs]
class DataFidelity_l2(DataFidelityBase):
"""l2-norm data-fidelity class."""
__data_fidelity_name__ = "l2"
sigma1: float | NDArrayFloat
def __init__(self, background: float | NDArrayFloat | None = None) -> None:
super().__init__(background=background)
self.sigma1 = 1.0
[docs]
def assign_data(self, data: float | NDArrayFloat | None = None, sigma: float | NDArrayFloat = 1.0) -> None:
super().assign_data(data=data, sigma=sigma)
self.sigma1 = 1 / (1 + sigma)
[docs]
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
return float(np.linalg.norm(dual.flatten(), ord=2) ** 2)
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat) -> None:
if self.data is not None and self.sigma_data is not None:
dual -= self.sigma_data
dual *= self.sigma1
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * (1/2) * ||. - b||^2} in-place.
The closed-form solution is:
prox(x) = (x + tau * b) / (1 + tau)
When no data has been assigned (b = 0):
prox(x) = x / (1 + tau)
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place.
tau : float | NDArrayFloat
The proximal step size.
"""
if self.data is not None:
primal += tau * self.data
primal /= 1.0 + tau
[docs]
def compute_primal_dual_gap(
self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None
) -> float:
return float(
np.linalg.norm(self.compute_residual(proj_primal, mask), ord=2) + np.linalg.norm(dual, ord=2)
) / 2 + self.compute_data_dual_dot(dual)
[docs]
class DataFidelity_l2w(DataFidelity_l2):
"""Weighted l2-norm data-fidelity class."""
__data_fidelity_name__ = "l2w"
sigma1: float | NDArrayFloat
weights: NDArrayFloat
def __init__(self, weights: float | NDArrayFloat, background: float | NDArrayFloat | None = None) -> None:
super().__init__(background=background)
self.sigma1 = 1.0
self.weights = np.array(weights)
[docs]
def assign_data(self, data: float | NDArrayFloat | None, sigma: float | NDArrayFloat = 1.0):
super().assign_data(data=data, sigma=sigma)
if isinstance(self.sigma, np.ndarray):
dtype = self.sigma.dtype
else:
dtype = type(self.sigma)
invalid_weights = (self.weights == 0).astype(dtype)
self.sigma1 = 1 / (1 + sigma / (self.weights + invalid_weights)) * (1 - invalid_weights)
[docs]
def compute_residual(self, proj_primal, mask: float | NDArrayFloat | None = None):
if self.background is not None:
proj_primal = proj_primal + self.background
if self.data is not None:
residual = (self.data - proj_primal) * self.weights
else:
residual = proj_primal * self.weights
if mask is not None:
residual *= mask
return residual
[docs]
def compute_residual_norm(self, dual: float | NDArrayFloat) -> float:
valid_weights = self.weights != 0
if isinstance(dual, np.ndarray):
dual = dual[valid_weights]
weights = self.weights[valid_weights]
return float(np.linalg.norm((dual / np.sqrt(weights)).flatten(), ord=2) ** 2)
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * (1/2) * ||. - b||^2_W} in-place.
For the weighted l2 norm (1/2)||x - b||^2_W = (1/2) sum_i w_i (x_i - b_i)^2,
the proximal is computed element-wise:
prox(x)_i = (x_i + tau * w_i * b_i) / (1 + tau * w_i)
Zero-weight entries are left unchanged (no constraint enforced there).
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place.
tau : float | NDArrayFloat
The proximal step size.
"""
tau_w = tau * self.weights
if self.data is not None:
primal += tau_w * self.data
# For zero-weight entries: denominator = 1, so they pass through unchanged.
denom = 1.0 + tau_w
primal /= denom
[docs]
class DataFidelity_l2b(DataFidelity_l2):
"""l2-norm ball data-fidelity class."""
__data_fidelity_name__ = "l2b"
sigma1: float | NDArrayFloat
sigma_error: float | NDArrayFloat
sigma_sqrt_error: float | NDArrayFloat
def __init__(self, local_error: float | NDArrayFloat, background: float | NDArrayFloat | None = None):
super().__init__(background=background)
self.sigma1 = 1.0
self.local_error = local_error
self.sigma_error = 1.0 * self.local_error
self.sigma_sqrt_error = 1.0 * np.sqrt(self.local_error)
[docs]
def assign_data(self, data: float | NDArrayFloat | None, sigma: float | NDArrayFloat = 1.0):
self.sigma_error = sigma * self.local_error
self.sigma_sqrt_error = sigma * np.sqrt(self.local_error)
super().assign_data(data=data, sigma=sigma)
self.sigma1 = 1 / (1 + self.sigma_error)
[docs]
def compute_residual(self, proj_primal: NDArrayFloat, mask: NDArrayFloat | None = None) -> NDArrayFloat:
residual = super().compute_residual(proj_primal, mask)
_soft_threshold(residual, self.sigma_sqrt_error)
return residual
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat) -> None:
if self.data is not None and self.sigma_data is not None:
dual -= self.sigma_data
_soft_threshold(dual, self.sigma_sqrt_error)
dual *= self.sigma1
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * f_{l2b}} in-place.
The l2-ball data fidelity is:
f(x) = max(||x - b|| - sqrt(epsilon), 0)^2 / 2
whose proximal is a soft-thresholded then scaled l2 proximal.
Specifically:
v = x - b
soft-threshold v by sqrt(epsilon) * tau / (1 + tau * epsilon)
prox(x) = b + v_thresholded / (1 + tau * epsilon)
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place.
tau : float | NDArrayFloat
The proximal step size.
"""
if self.data is not None:
primal -= self.data
tau_eps = tau * self.local_error
_soft_threshold(primal, np.sqrt(self.local_error) * tau / (1.0 + tau_eps))
primal /= 1.0 + tau_eps
if self.data is not None:
primal += self.data
[docs]
def compute_primal_dual_gap(
self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None
) -> float:
return float(
np.linalg.norm(self.compute_residual(proj_primal, mask), ord=2)
+ np.linalg.norm(np.sqrt(self.local_error) * dual, ord=2)
) / 2 + self.compute_data_dual_dot(dual)
[docs]
class DataFidelity_Huber(DataFidelityBase):
"""Huber-norm data-fidelity class. Given a parameter a: l2-norm for x < a, and l1-norm for x > a."""
__data_fidelity_name__ = "Hub"
one_sigma_error: float | NDArrayFloat
def __init__(
self, local_error: float | NDArrayFloat, background: float | NDArrayFloat | None = None, l2_axis: int | None = None
):
super().__init__(background=background)
self.local_error = local_error
self.l2_axis = l2_axis
self.one_sigma_error = 1.0
[docs]
def assign_data(self, data: NDArrayFloat, sigma: float | NDArrayFloat = 1.0):
self.one_sigma_error = 1.0 / (1.0 + sigma * self.local_error)
super().assign_data(data=data, sigma=sigma)
[docs]
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
l2_points = dual <= self.local_error
l1_points = 1 - l2_points
return float(np.linalg.norm(dual[l2_points].flatten(), ord=2) ** 2 + np.linalg.norm(dual[l1_points].flatten(), ord=1))
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat) -> None:
if self.data is not None and self.sigma_data is not None:
dual -= self.sigma_data
dual *= self.one_sigma_error
if self.l2_axis is None:
dual /= np.fmax(1, np.abs(dual))
else:
dual_dir_norm_l2 = np.linalg.norm(dual, ord=2, axis=self.l2_axis, keepdims=True)
dual /= np.fmax(1, dual_dir_norm_l2)
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Not implemented: the Huber proximal in the primal has no simple closed form for general data.
The Huber function is f(x) = (1/2)||x-b||^2 if ||x-b|| <= a, else a*||x-b|| - a^2/2.
Its proximal is a smooth interpolation between the l2 and l1 proximals, whose
closed form depends on the norm of (x - b) relative to the threshold, making
it straightforward only for the scalar case. For vector inputs with l2_axis,
a Newton iteration would be required.
Raises
------
NotImplementedError
Always raised; use a gradient step or PDHG instead.
"""
raise NotImplementedError(
f"{self.__class__.__name__}.apply_proximal_primal: the Huber proximal has no simple "
"closed-form solution for general (possibly vector-valued) inputs. "
"Use a gradient step in the solver, or switch to PDHG for this data term."
)
[docs]
def compute_primal_dual_gap(
self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None
) -> float:
if self.background is not None:
proj_primal = proj_primal + self.background
return float(
np.linalg.norm(self.compute_residual(proj_primal, mask), ord=2)
+ self.compute_data_dual_dot(dual)
+ self.local_error * np.linalg.norm(dual, ord=2)
)
[docs]
class DataFidelity_l1(DataFidelityBase):
"""l1-norm data-fidelity class."""
__data_fidelity_name__ = "l1"
def __init__(self, background=None):
super().__init__(background=background)
[docs]
def _get_inner_norm(self, dual: NDArrayFloat) -> NDArrayFloat:
return np.abs(dual)
[docs]
def _apply_threshold(self, dual: NDArrayFloat):
pass
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat, weight: float | NDArrayFloat = 1.0):
if self.data is not None and self.sigma_data is not None:
dual -= self.sigma_data
self._apply_threshold(dual)
dual_inner_norm = self._get_inner_norm(dual)
invalid_points = weight <= 0
dual /= np.fmax(dual_inner_norm, weight) + invalid_points
dual *= weight
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * ||. - b||_1} in-place via soft-thresholding.
For f(x) = ||x - b||_1, the proximal is element-wise soft-thresholding
centered on b:
prox(x)_i = b_i + sign(x_i - b_i) * max(|x_i - b_i| - tau, 0)
When no data has been assigned (b = 0), this reduces to plain
soft-thresholding with threshold tau.
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place.
tau : float | NDArrayFloat
The proximal step size (soft-threshold level).
"""
if self.data is not None:
primal -= self.data
_soft_threshold(primal, tau)
if self.data is not None:
primal += self.data
[docs]
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
dual = dual.copy()
self._apply_threshold(dual)
dual_inner_norm = self._get_inner_norm(dual)
return float(np.linalg.norm(dual_inner_norm, ord=1))
[docs]
def compute_primal_dual_gap(
self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None
) -> float:
if self.background is not None:
proj_primal = proj_primal + self.background
residual = self.compute_residual(proj_primal, mask)
self._apply_threshold(residual)
residual_inner_norm = self._get_inner_norm(residual)
return float(np.linalg.norm(residual_inner_norm, ord=1) + self.compute_data_dual_dot(dual))
[docs]
class DataFidelity_l21(DataFidelity_l1):
"""l12-norm data-fidelity class."""
__data_fidelity_name__ = "l12"
l2_axis: int
def __init__(self, background: float | NDArrayFloat | None = None, l2_axis: int = 0):
super().__init__(background=background)
self.l2_axis = l2_axis
[docs]
def _get_inner_norm(self, dual: NDArrayFloat) -> NDArrayFloat:
return np.linalg.norm(dual, ord=2, axis=self.l2_axis, keepdims=True)
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Not implemented: the l12 proximal in the primal domain requires a group soft-threshold
along l2_axis, which is straightforward only when the l2_axis corresponds to independent
groups that do not interact through A. For general use, apply PDHG or provide a
custom group-soft-threshold.
Raises
------
NotImplementedError
Always raised.
"""
raise NotImplementedError(
f"{self.__class__.__name__}.apply_proximal_primal: the l12 norm proximal in the "
"primal domain is axis-dependent and cannot be applied independently per element. "
"Use PDHG for this data term, which handles it correctly via the dual."
)
[docs]
class DataFidelity_l21w(DataFidelity_l21):
"""l12-norm data-fidelity class."""
__data_fidelity_name__ = "l21w"
axis_weights: NDArrayFloat
inner_norm: float
def __init__(
self,
axis_weights: NDArrayFloat,
background: float | NDArrayFloat | None = None,
l2_axis: int = 0,
inner_norm: float = 2,
):
super().__init__(background=background, l2_axis=l2_axis)
self.inner_norm = inner_norm
axis_weights = np.abs(axis_weights)
axis_weights = axis_weights / axis_weights.mean()
self.axis_weights = axis_weights ** (1 / inner_norm)
[docs]
def _get_inner_norm(self, dual: NDArrayFloat) -> NDArrayFloat:
weights = self.axis_weights.reshape([-1, *(1,) * (dual.ndim - 1)])
return np.linalg.norm(dual / weights, ord=self.inner_norm, axis=self.l2_axis, keepdims=True)
[docs]
class DataFidelity_l1b(DataFidelity_l1):
"""l1-norm ball data-fidelity class."""
__data_fidelity_name__ = "l1b"
sigma_error: float | NDArrayFloat
def __init__(self, local_error: float | NDArrayFloat, background: float | NDArrayFloat | None = None) -> None:
super().__init__(background=background)
self.local_error = local_error
self.sigma_error = 1.0 * self.local_error
[docs]
def assign_data(self, data: NDArrayFloat, sigma: float | NDArrayFloat = 1.0) -> None:
self.sigma_error = sigma * self.local_error
super().assign_data(data=data, sigma=sigma)
[docs]
def _apply_threshold(self, dual: NDArrayFloat) -> None:
_soft_threshold(dual, self.local_error)
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * f_{l1b}} in-place.
The l1-ball data fidelity is:
f(x) = max(||x - b||_1 - epsilon, 0)
Its proximal is a two-stage soft-threshold:
v = x - b
soft-threshold v by (tau / (1 + tau)) elementwise, with level epsilon
prox(x) = b + v_thresholded
This is obtained via Moreau's identity applied to the indicator of the
l1-ball of radius epsilon.
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place.
tau : float | NDArrayFloat
The proximal step size.
"""
if self.data is not None:
primal -= self.data
# Two-level soft threshold: first remove the ball radius, then scale
_soft_threshold(primal, self.local_error)
primal *= tau / (1.0 + tau)
if self.data is not None:
primal += self.data
[docs]
class DataFidelity_KL(DataFidelityBase):
"""Kullback-Leibler data-fidelity class."""
__data_fidelity_name__ = "KL"
[docs]
def _compute_sigma_data(self):
if self.data is None:
return None
else:
return 4 * self.sigma * np.fmax(self.data, 0.0)
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat):
if self.sigma_data is not None:
dual[:] = (1 + dual[:] - np.sqrt((dual[:] - 1) ** 2 + self.sigma_data[:])) / 2
else:
dual[:] = (1 + dual[:] - np.sqrt((dual[:] - 1) ** 2)) / 2
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Apply prox_{tau * KL(b, .)} in-place.
The Kullback-Leibler divergence (in the emission-CT convention) is:
f(x) = sum_i (x_i - b_i * log(x_i)) (for x_i > 0)
Its proximal has the closed-form solution:
prox(x)_i = ((x_i - tau) + sqrt((x_i - tau)^2 + 4 * tau * b_i)) / 2
This is always non-negative when b_i >= 0 and x_i > 0.
Parameters
----------
primal : NDArrayFloat
The primal variable to update in-place (must be > 0).
tau : float | NDArrayFloat
The proximal step size.
"""
if self.data is not None:
b = np.fmax(self.data, 0.0)
disc = (primal - tau) ** 2 + 4.0 * tau * b
primal[:] = ((primal - tau) + np.sqrt(disc)) / 2.0
else:
# No data: f(x) = sum x_i, prox is max(x - tau, 0) clamped away from zero
primal[:] = np.fmax(primal - tau, eps)
[docs]
def compute_residual(self, proj_primal: NDArray, mask: NDArray | None = None, use_proximal: bool = True) -> NDArrayFloat:
if self.background is not None:
proj_primal = proj_primal + self.background
proj_primal = np.fmax(proj_primal, eps)
if use_proximal:
# we take the Moreau envelope here, and apply the proximal to it
residual = np.fmax(proj_primal, eps) * self.sigma
self.apply_proximal_dual(residual)
else:
if self.data is not None:
residual = 1.0 - np.fmax(self.data, 0.0) / proj_primal
else:
residual = np.ones_like(proj_primal)
if mask is not None:
residual *= mask
return -residual
[docs]
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
return float(np.linalg.norm(dual.flatten(), ord=1))
[docs]
def compute_primal_dual_gap(self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None):
if self.background is not None:
proj_primal = proj_primal + self.background
if self.data is not None:
data_nn = np.fmax(self.data, eps)
proj_primal_nn = np.fmax(proj_primal, eps)
residual = proj_primal_nn - data_nn * (1 - np.log(data_nn) + np.log(proj_primal_nn))
else:
residual = np.copy(proj_primal)
if mask is not None:
residual *= mask
return np.linalg.norm(residual, ord=1)
[docs]
class DataFidelity_ln(DataFidelityBase):
"""nuclear-norm data-fidelity class."""
__data_fidelity_name__ = "ln"
ln_axes: Sequence[int]
def __init__(
self,
background: float | NDArrayFloat | None = None,
ln_axes: Sequence[int] = (1, -1),
spectral_norm: DataFidelityBase = DataFidelity_l1(),
):
super().__init__(background=background)
self.ln_axes = ln_axes
self.spectral_norm = spectral_norm
self.use_fallback = False
[docs]
def apply_proximal_dual(self, dual: NDArrayFloat) -> None:
dual_tmp = dual.copy()
if self.sigma_data is not None:
# If we have a bias term, we interpret it as an addition to the rows in the SVD decomposition.
# Performing this operation before the transpose is a waste of computation, but it simplifies the logic.
dual_tmp = np.concatenate((dual_tmp, self.sigma_data), axis=self.ln_axes[0])
if self.use_fallback:
t_range = [*range(len(dual_tmp.shape))]
t_range.append(t_range.pop(self.ln_axes[0]))
t_range.append(t_range.pop(self.ln_axes[1]))
dual_tmp = np.transpose(dual_tmp, t_range)
U, s_p, Vt = np.linalg.svd(dual_tmp, full_matrices=False)
self.spectral_norm.apply_proximal_dual(s_p)
dual_tmp = np.matmul(U, s_p[..., None] * Vt)
dual_tmp = np.transpose(dual_tmp, np.argsort(t_range))
else:
op_svd = operators.TransformSVD(dual_tmp.shape, axes_rows=self.ln_axes[0], axes_cols=self.ln_axes[1])
s_p = op_svd(dual_tmp)
self.spectral_norm.apply_proximal_dual(s_p)
dual_tmp = op_svd.T(s_p)
if self.data is not None:
# We now strip the bias data, to make sure that we don't change dimensionality.
# dual_tmp = dual_tmp[..., : dual_tmp.shape[-2] - 1 :, :]
dual_tmp = np.take(dual_tmp, np.arange(dual_tmp.shape[self.ln_axes[0]] - 1), axis=self.ln_axes[0])
dual[:] = dual_tmp[:]
[docs]
def apply_proximal_primal(self, primal: NDArrayFloat, tau: float | NDArrayFloat) -> None:
"""Not implemented: the nuclear-norm proximal requires a full SVD and singular-value
soft-thresholding, which is only well-defined for the dual formulation used here
(where the SVD axes and data structure are set up by the PDHG dual update).
Applying it directly in the primal domain would require re-interpreting primal
axes as matrix rows/columns, which depends on context not available here.
Raises
------
NotImplementedError
Always raised; use PDHG for nuclear-norm data fidelity.
"""
raise NotImplementedError(
f"{self.__class__.__name__}.apply_proximal_primal: the nuclear-norm proximal "
"requires an SVD over axes that are determined by the dual formulation. "
"It cannot be applied generically in the primal domain. Use PDHG instead."
)
[docs]
def compute_residual_norm(self, dual: NDArrayFloat) -> float:
op_svd = operators.TransformSVD(dual.shape, axes_rows=self.ln_axes[0], axes_cols=self.ln_axes[1])
s_p = op_svd(dual)
return float(np.linalg.norm(s_p, ord=1))
[docs]
def compute_primal_dual_gap(self, proj_primal: NDArrayFloat, dual: NDArrayFloat, mask: NDArrayFloat | None = None):
if self.background is not None:
proj_primal = proj_primal + self.background
residual = self.compute_residual(proj_primal, mask)
return self.compute_residual_norm(residual) + self.compute_data_dual_dot(dual)