Source code for opinf.lstsq._tikhonov

# lstsq/_tikhonov.py
"""Operator Inference least-squares solvers with Tikhonov regularization."""

__all__ = [
    "L2Solver",
    "L2DecoupledSolver",
    "TikhonovSolver",
    "TikhonovDecoupledSolver",
]

import abc
import types
import warnings
import numpy as np
import scipy.linalg as la
import scipy.sparse as sparse

from .. import errors, utils
from ..operators import _utils as oputils
from ._base import SolverTemplate, _require_trained


# Solver classes ==============================================================
class _BaseRegularizedSolver(SolverTemplate):
    r"""Base class for solvers of regularized linear least-squares problems.

    .. math::
       \argmin_{\ohat_i}
       \|\D\ohat_i - \z_i\|_2^2
       + \sum_{i=1}^{r}\|\bfGamma_i\ohat_i\|_2^2,
       \quad i = 1, \ldots, r.

    For each :math:`i`, this is equivalent to the following stacked ordinary
    least-squares problem:

    .. math::
       \argmin{\Ohat}
       \left\|
           \left[\begin{array}{c}\D \\ \bfGamma_i\end{array}\right]\ohat_i
           - \left[\begin{array}{c}\z_i \\ \0\end{array}\right]
        \right\|_2^2.

    The exact solution is described by the normal equations:

    .. math::
        (\D\trp\D + \bfGamma_i\trp\bfGamma_i)\ohat_i = \D\trp\z_i.
    """

    # Properties: regularization ----------------------------------------------
    @abc.abstractmethod
    def regularizer(self):  # pragma: no cover
        """Regularization scalar, matrix, or list of these."""
        raise NotImplementedError

    # Main methods ------------------------------------------------------------
    def fit(self, data_matrix: np.ndarray, lhs_matrix: np.ndarray):
        r"""Verify dimensions and save the data matrices.

        Parameters
        ----------
        data_matrix : (k, d) ndarray
            Data matrix :math:`\D`.
        lhs_matrix : (r, k) or (k,) ndarray
            "Left-hand side" data matrix :math:`\Z` (not its transpose!).
            If one-dimensional, assume :math:`r = 1`.
        """
        SolverTemplate.fit(self, data_matrix, lhs_matrix)
        if self.k < self.d:
            warnings.warn(
                "non-regularized least-squares system is underdetermined",
                errors.OpInfWarning,
            )
        return self

    # Post-processing ---------------------------------------------------------
    @abc.abstractmethod
    def regcond(self) -> float:  # pragma: no cover
        r"""Compute the :math:`2`-norm condition number of the regularized
        data matrix :math:`[~\D\trp~~\bfGamma\trp~]\trp.`
        """
        raise NotImplementedError

    @abc.abstractmethod
    def regresidual(self, Ohat: np.ndarray) -> np.ndarray:  # pragma: no cover
        """Compute the residual of the regularized regression problem."""
        raise NotImplementedError

    # Persistence -------------------------------------------------------------
    def _save(self, savefile, overwrite=False, extras=tuple()):
        """Serialize the solver, saving it in HDF5 format.
        The model can be recovered with the :meth:`_load()` class method.

        Parameters
        ----------
        savefile : str
            File to save to, with extension ``.h5`` (HDF5).
        overwrite : bool
            If ``True`` and the specified ``savefile`` already exists,
            overwrite the file. If ``False`` (default) and the specified
            ``savefile`` already exists, raise an error.
        extras : list
            Names of additional attributes to save.
        """
        with utils.hdf5_savehandle(savefile, overwrite) as hf:
            reg = self.regularizer
            if self.__class__ is L2Solver:
                reg = [reg]
            hf.create_dataset("regularizer", data=reg)

            if isinstance(self, TikhonovSolver):
                meta = hf.create_dataset("meta", shape=(0,))
                meta.attrs["method"] = self.method

            self._save_dict(hf, "options")

            if self.data_matrix is not None:
                hf.create_dataset("data_matrix", data=self.data_matrix)
                hf.create_dataset("lhs_matrix", data=self.lhs_matrix)
                for attr in extras:
                    hf.create_dataset(attr, data=getattr(self, attr))

    @classmethod
    def _load(cls, loadfile: str, extras=tuple()):
        """Load a serialized solver from an HDF5 file, created previously from
        the :meth:`save()` method.

        Parameters
        ----------
        loadfile : str
            Path to the file where the model was stored via :meth:`save()`.
        extras : list
            Names of additional attributes to load.

        Returns
        -------
        solver : _BaseRegularizedSolver
            Loaded solver.
        """
        with utils.hdf5_loadhandle(loadfile) as hf:

            reg = hf["regularizer"][:]
            if cls is L2Solver:
                reg = reg[0]

            options = cls._load_dict(hf, "options")
            kwargs = dict(
                regularizer=reg,
                lapack_driver=options["lapack_driver"],
            )

            if issubclass(cls, TikhonovSolver):
                if "cond" in options:
                    kwargs["cond"] = options["cond"]
                kwargs["method"] = hf["meta"].attrs["method"]

            solver = cls(**kwargs)

            if "data_matrix" in hf:
                D = hf["data_matrix"][:]
                Z = hf["lhs_matrix"][:]
                _BaseRegularizedSolver.fit(solver, D, Z)
                for attr in extras:
                    setattr(solver, attr, hf[attr][:])

        return solver


[docs] class L2Solver(_BaseRegularizedSolver): r"""Solve the Frobenius-norm ordinary least-squares problem with :math:`L_2` regularization. That is, solve .. math:: \argmin_{\Ohat}\|\D\Ohat\trp - \Z\trp\|_F^2 + \|\lambda\Ohat\trp\|_F^2 for some specified :math:`\lambda \ge 0`. The exact solution is described by the normal equations: .. math:: (\D\trp\D + \lambda^2\I)\Ohat\trp = \D\trp\Z\trp, that is, .. math:: \Ohat = \Z\D(\D\trp\D + \lambda^2\I)^{-\mathsf{T}}. Instead of solving these equations directly, the solution is calculated using the singular value decomposition of the data matrix :math:`\D`: if :math:`\D = \bfPhi\bfSigma\bfPsi\trp`, then :math:`\Ohat\trp = \bfPsi\bfSigma^{*}\bfPhi\trp\Z\trp` (i.e., :math:`\Ohat = \Z\bfPhi\bfSigma^{*}\bfPsi\trp`), where :math:`\bfSigma^{*}` is a diagonal matrix with :math:`i`-th diagonal entry :math:`\Sigma_{i,i}^{*} = \Sigma_{i,i}/(\Sigma_{i,i}^{2} + \lambda^2).` Parameters ---------- regularizer : float Scalar :math:`L_2` regularization constant. lapack_driver : str LAPACK routine for computing the singular value decomposition. See :func:`scipy.linalg.svd()`. """ def __init__(self, regularizer, lapack_driver: str = "gesdd"): """Store the regularizer and initialize attributes.""" _BaseRegularizedSolver.__init__(self) self.regularizer = regularizer self.__options = types.MappingProxyType( dict(full_matrices=False, lapack_driver=lapack_driver) ) # Properties -------------------------------------------------------------- @property def regularizer(self): r"""Scalar :math:`L_2` regularization constant :math:`\lambda > 0.` """ return self.__reg @regularizer.setter def regularizer(self, reg): """Set the regularization constant.""" if not np.isscalar(reg): raise TypeError("regularization constant must be a scalar") if reg < 0: raise ValueError("regularization constant must be nonnegative") self.__reg = reg @property def options(self): """Keyword arguments for :func:`scipy.linalg.svd()`. These cannot be changed after instantiation. """ return self.__options def __str__(self): """String representation: dimensions + solver options.""" kwargs = self._print_kwargs(self.options) if np.isscalar(self.regularizer): regstr = f"{self.regularizer:.4e}" else: regstr = f"{self.regularizer.shape}" return "\n ".join( [ SolverTemplate.__str__(self), f"regularizer: {regstr}", f"SVD solver: scipy.linalg.svd({kwargs})", ] ) # Main methods ------------------------------------------------------------
[docs] def fit(self, data_matrix: np.ndarray, lhs_matrix: np.ndarray): r"""Verify dimensions and compute the singular value decomposition of the data matrix in preparation to solve the least-squares problem. Parameters ---------- data_matrix : (k, d) ndarray Data matrix :math:`\D`. lhs_matrix : (r, k) or (k,) ndarray "Left-hand side" data matrix :math:`\Z` (not its transpose!). If one-dimensional, assume :math:`r = 1`. """ _BaseRegularizedSolver.fit(self, data_matrix, lhs_matrix) Phi, svals, PsiT = la.svd(self.data_matrix, **self.options) self._svals = svals self._ZPhi = self.lhs_matrix @ Phi self._PsiT = PsiT return self
[docs] @_require_trained def solve(self) -> np.ndarray: r"""Solve the Operator Inference regression. Returns ------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat` (not its transpose!). """ svals = self._svals.reshape((-1, 1)) svals_inv = svals / (svals**2 + self.regularizer**2) return (self._ZPhi * svals_inv.T) @ self._PsiT
# Post-processing ---------------------------------------------------------
[docs] @_require_trained def cond(self): r"""Compute the :math:`2`-norm condition number of the data matrix :math:`\D`. """ return self._svals.max() / self._svals.min()
[docs] @_require_trained def regcond(self) -> float: r"""Compute the :math:`2`-norm condition number of the regularized data matrix :math:`[~\D\trp~~\lambda\I~]\trp.` Returns ------- cond : float Condition number of the regularized data matrix. """ svals2 = self._svals**2 + self.regularizer**2 return np.sqrt(svals2.max() / svals2.min())
[docs] @_require_trained def regresidual(self, Ohat: np.ndarray) -> np.ndarray: r"""Compute the residual of the regularized regression objective for each row of the given operator matrix. Specifically, given a potential :math:`\Ohat`, compute .. math:: \|\D\ohat_i - \z_i\|_2^2 + \|\lambda\ohat_i\|_2^2, \quad i = 1, \ldots, r, where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively. Parameters ---------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat`. Returns ------- residuals : (r,) ndarray :math:`2`-norm residuals for each row of the operator matrix. """ residual = self.residual(Ohat) return residual + (self.regularizer**2 * np.sum(Ohat**2, axis=-1))
# Persistence -------------------------------------------------------------
[docs] def save(self, savefile: str, overwrite: bool = False): """Serialize the solver, saving it in HDF5 format. The model can be recovered with the :meth:`load()` class method. Parameters ---------- savefile : str File to save to, with extension ``.h5`` (HDF5). overwrite : bool If ``True`` and the specified ``savefile`` already exists, overwrite the file. If ``False`` (default) and the specified ``savefile`` already exists, raise an error. """ return self._save(savefile, overwrite, ["_svals", "_ZPhi", "_PsiT"])
[docs] @classmethod def load(cls, loadfile: str): """Load a serialized solver from an HDF5 file, created previously from the :meth:`save()` method. Parameters ---------- loadfile : str Path to the file where the model was stored via :meth:`save()`. Returns ------- solver : L2Solver Loaded solver. """ return cls._load(loadfile, ["_svals", "_ZPhi", "_PsiT"])
[docs] def copy(self): """Make a copy of the solver.""" solver = self.__class__( regularizer=self.regularizer, lapack_driver=self.options["lapack_driver"], ) if self.data_matrix is not None: SolverTemplate.fit(solver, self.data_matrix, self.lhs_matrix) solver._svals = self._svals solver._ZPhi = self._ZPhi solver._PsiT = self._PsiT return solver
[docs] class L2DecoupledSolver(L2Solver): r"""Solve :math:`r` independent :math:`2`-norm ordinary least-squares problems, each with the same data matrix but a different :math:`L_2` regularization. That is, for :math:`i = 1, \ldots, r`, construct :math:`\Ohat` by solving .. math:: \argmin_{\Ohat}\|\D\ohat_i - \z_i\|_2^2 + \|\lambda_i\Ohat_i\|_2^2 where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively, with corresponding regularization constant :math:`\lambda_i > 0`. The exact solution for the :math:`i`-th problem is described by the normal equations: .. math:: (\D\trp\D + \lambda_i^2\I)\ohat_i = \D\trp\z_i. Instead of solving these equations directly, the solution is calculated using the singular value decomposition of the data matrix (see :class:`L2Solver`). Parameters ---------- regularizer : (r,) ndarray Scalar :math:`L_2` regularization constants, one for each row of the operator matrix. lapack_driver : str LAPACK routine for computing the singular value decomposition. See :func:`scipy.linalg.svd()`. """ # Properties -------------------------------------------------------------- def _check_regularizer_shape(self): if (shape1 := self.regularizer.shape) != (shape2 := (self.r,)): raise errors.DimensionalityError( f"regularizer.shape = {shape1} != {shape2} = (r,)" ) @property def regularizer(self): r"""Scalar :math:`L_2` regularization constants, one for each row of the operator matrix :math:`\Ohat`. """ return self.__regs @regularizer.setter def regularizer(self, regs): """Set the regularization constants.""" regs = np.array(regs) if regs.ndim != 1: raise ValueError("regularizer must be one-dimensional") if np.any(regs < 0): raise ValueError("regularization constants must be nonnegative") self.__regs = regs if self.r is not None: self._check_regularizer_shape() # Main methods ------------------------------------------------------------
[docs] def fit(self, data_matrix: np.ndarray, lhs_matrix: np.ndarray): r"""Verify dimensions and compute the singular value decomposition of the data matrix in preparation to solve the least-squares problem. Parameters ---------- data_matrix : (k, d) ndarray Data matrix :math:`\D`. lhs_matrix : (r, k) or (k,) ndarray "Left-hand side" data matrix :math:`\Z` (not its transpose!). If one-dimensional, assume :math:`r = 1`. """ L2Solver.fit(self, data_matrix, lhs_matrix) self._check_regularizer_shape() return self
# Post-processing ---------------------------------------------------------
[docs] @_require_trained def regcond(self) -> float: r"""Compute the :math:`2`-norm condition number of each regularized data matrix, :math:`[~\D\trp~~\lambda_i\I~]\trp` for :math:`i = 1, \ldots, r`. Returns ------- conds : (r,) ndarray Condition numbers of the regularized data matrices. """ svals2 = self._svals**2 + self.regularizer.reshape((-1, 1)) ** 2 return np.sqrt(svals2.max(axis=1) / svals2.min(axis=1))
[docs] def regresidual(self, Ohat: np.ndarray) -> np.ndarray: r"""Compute the residual of the regularized regression objective for each row of the given operator matrix. Specifically, given a potential :math:`\Ohat`, compute .. math:: \|\D\ohat_i - \z_i\|_2^2 + \|\lambda_i\ohat_i\|_2^2, \quad i = 1, \ldots, r, where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively, and :math:`\lambda_i \ge 0` is the corresponding regularization constant. Parameters ---------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat`. Returns ------- residuals : (r,) ndarray :math:`2`-norm residuals for each row of the operator matrix. """ return L2Solver.regresidual(self, Ohat)
[docs] class TikhonovSolver(_BaseRegularizedSolver): r"""Solve the Frobenius-norm ordinary least-squares problem with Tikhonov regularization. That is, solve .. math:: \argmin_{\Ohat}\|\D\Ohat\trp - \Z\trp\|_F^2 + \|\bfGamma\Ohat\trp\|_F^2 for a specified symmetric-positive-definite regularization matrix :math:`\bfGamma \in \RR^{d \times d}`. This is equivalent to solving the following stacked least-squares problem: .. math:: \argmin_{\Ohat} \left\| \left[\begin{array}{c}\D \\ \bfGamma\end{array}\right]\Ohat\trp - \left[\begin{array}{c}\Z\trp \\ \0\end{array}\right] \right\|_F^2. The exact solution is described by the normal equations: .. math:: (\D\trp\D + \bfGamma\trp\bfGamma)\Ohat\trp = \D\trp\Z\trp, that is, .. math:: \Ohat = \Z\D(\D\trp\D + \bfGamma\trp\bfGamma)^{-\mathsf{T}}. Parameters ---------- regularizer : (d, d) or (d,) ndarray Symmetric semi-positive-definite regularization matrix :math:`\bfGamma` or, if ``regularizer`` is a one-dimensional array, the diagonal entries of :math:`\bfGamma`. Here, ``d`` is the number of columns in the data matrix. method : str Strategy for solving the regularized least-squares problem. **Options**: * ``"lstsq"``: solve the stacked least-squares problem via :func:`scipy.linalg.lstsq()`; by default, this computes and uses the singular value decomposition of the stacked data matrix :math:`[~D\trp~~\bfGamma\trp~]\trp`. * ``"normal"``: directly solve the normal equations :math:`(\D\trp\D + \bfGamma\trp\bfGamma) \Ohat\trp = \D\trp\Z\trp` via :func:`scipy.linalg.solve()`. cond : float or None Cutoff for 'small' singular values of the data matrix, see :func:`scipy.linalg.lstsq()`. Ignored if ``method = "normal"``. lapack_driver : str or None Which LAPACK driver is used to solve the least-squares problem, see :func:`scipy.linalg.lstsq()`. Ignored if ``method = "normal"``. """ def __init__( self, regularizer, method: str = "lstsq", cond: float = None, lapack_driver: str = None, ): """Store the regularizer and initialize attributes.""" _BaseRegularizedSolver.__init__(self) self.regularizer = regularizer self.method = method self.__options = dict(cond=cond, lapack_driver=lapack_driver) # Properties -------------------------------------------------------------- @property def options(self): """Keyword arguments for :func:`scipy.linalg.lstsq()`.""" return self.__options def __str__(self): """String representation: dimensions + solver options.""" kwargs = self._print_kwargs(self.options) if self.regularizer[0].ndim == 1: regstr = f" {self.regularizer.shape}" else: regstr = ( f" {len(self.regularizer)} " f"{self.regularizer[0].shape} ndarrays" ) if self.method == "lstsq": kwargs = self._print_kwargs(self.options) spstr = f"solver ('lstsq'): scipy.linalg.lstsq({kwargs})" else: spstr = "solver ('normal'): scipy.linalg.solve(assume_a='pos')" return "\n ".join( [SolverTemplate.__str__(self), f"regularizer: {regstr}", spstr] ) def _check_regularizer_shape(self): if (shape1 := self.regularizer.shape) != (shape2 := (self.d, self.d)): raise errors.DimensionalityError( f"regularizer.shape = {shape1} != {shape2} = (d, d)" ) @property def regularizer(self): r"""Symmetric semi-positive-definite :math:`d \times d` regularization matrix :math:`\bfGamma`. """ return self.__reg @regularizer.setter def regularizer(self, G): """Set the regularization matrix.""" if sparse.issparse(G): G = G.toarray() elif not isinstance(G, np.ndarray): G = np.array(G) if G.ndim == 1: if np.any(G < 0): raise ValueError( "diagonal regularizer must be positive semi-definite" ) G = np.diag(G) self.__reg = G if self.d is not None: self._check_regularizer_shape()
[docs] @classmethod def get_operator_regularizer( cls, operators: list, regularization_parameters: list, state_dimension: int, input_dimension: int = 0, ): r"""Construct a regularizer so that each operator is regularized separately. The regularization term for this solver is .. math:: \|\bfGamma\Ohat\trp\|_F^2 where :math:`\Ohat\in\RR^{r\times d}` is the unknown and :math:`\bfGamma\in\RR^{d \times d}` is a given regularization matrix. This method constructs :math:`\bfGamma` such that each operator represented in :math:`\Ohat` is regularized separately. For example, if :math:`\Ohat = [~\chat~~\Ahat~~\Hhat~~\Bhat~]`, then :math:`\bfGamma` may be designed so that .. math:: \|\bfGamma\Ohat\trp\|_F^2 = \gamma_1\|\chat\|_F^2 + \gamma_2\|\Ahat\|_F^2 + \gamma_3\|\Hhat\|_F^2 + \gamma_4\|\Bhat\|_F^2. Parameters ---------- operators : list of opinf.operators objects Collection of operators comprising the operator matrix. regularization_parameters : list of floats or ndarrays Regularization hyperparameters for each operator, i.e., ``regularization_parameters[i]`` corresponds to ``operators[i]``. state_dimension : int Dimension of the (reduced) state. input_dimension : int Dimension of the input. If there is no input, this should be 0 (default). """ if (n1 := len(operators)) != (n2 := len(regularization_parameters)): raise ValueError( f"len(operators) == {n1} != " f"{n2} == len(regularization_parameters)" ) if n1 == 1: warnings.warn( "consider using L2Solver for models with only one operator", errors.OpInfWarning, ) # Check if input_dimension is needed or not. has_inputs = [oputils.has_inputs(op) for op in operators] inputs_required = any(has_inputs) if inputs_required and input_dimension == 0: idx = np.argmax(has_inputs) raise ValueError( "argument 'input_dimension' required, " f"operators[{idx}] acts on inputs" ) elif not inputs_required and input_dimension > 0: warnings.warn( "argument 'input_dimension' ignored, " "no operators act on inputs", errors.OpInfWarning, ) # Get operator dimensions. r, m = state_dimension, input_dimension dims = [] for op in operators: if oputils.is_nonparametric(op): dims.append(op.operator_dimension(r, m)) elif oputils.is_affine(op): dims.append(op.operator_dimension(None, r, m)) else: raise TypeError( f"unsupported operator type '{type(op).__name__}'" ) # Construct the regularizer. regularizer = np.zeros(sum(dims)) index = 0 for dim, reg in zip(dims, regularization_parameters): endex = index + dim regularizer[index:endex] = reg index = endex return regularizer
@property def method(self): """Strategy for solving the regularized least-squares problem, either ``"lstsq"`` (default) or ``"normal"``. """ return self.__method @method.setter def method(self, method): """Set the method and precompute stuff as needed.""" if method not in ("lstsq", "normal"): raise ValueError("method must be 'lstsq' or 'normal'") self.__method = method # Main routines -----------------------------------------------------------
[docs] def fit(self, data_matrix: np.ndarray, lhs_matrix: np.ndarray): r"""Verify dimensions and precompute quantities in preparation to solve the least-squares problem. Parameters ---------- data_matrix : (k, d) ndarray Data matrix :math:`\D`. lhs_matrix : (r, k) or (k,) ndarray "Left-hand side" data matrix :math:`\Z` (not its transpose!). """ _BaseRegularizedSolver.fit(self, data_matrix, lhs_matrix) self._check_regularizer_shape() D, Z = self.data_matrix, self.lhs_matrix # Pad lhs matrix for "svd" solve. self._ZtPad = np.vstack((Z.T, np.zeros((self.d, self.r)))) # Precompute normal equations terms for "normal" solve. self._DtD = D.T @ D self._DtZt = D.T @ Z.T return self
[docs] @_require_trained def solve(self) -> np.ndarray: r"""Solve the Operator Inference regression. Returns ------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat` (not its transpose!). """ if self.method == "lstsq": DPad = np.vstack((self.data_matrix, self.regularizer)) Ohat = la.lstsq(DPad, self._ZtPad, **self.options)[0].T elif self.method == "normal": regD = self._DtD + (self.regularizer.T @ self.regularizer) Ohat = la.solve(regD, self._DtZt, assume_a="pos").T return Ohat
# Post-processing ---------------------------------------------------------
[docs] @_require_trained def regcond(self) -> float: r"""Compute the :math:`2`-norm condition number of the regularized data matrix :math:`[~\D\trp~~\bfGamma\trp~]\trp.` Returns ------- cond : float Condition number of the regularized data matrix. """ return np.linalg.cond(np.vstack((self.data_matrix, self.regularizer)))
[docs] @_require_trained def regresidual(self, Ohat: np.ndarray) -> np.ndarray: r"""Compute the residual of the regularized regression objective for each row of the given operator matrix. Specifically, given a potential :math:`\Ohat`, compute .. math:: \|\D\ohat_i - \z_i\|_2^2 + \|\bfGamma\ohat_i\|_2^2, \quad i = 1, \ldots, r, where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively. Parameters ---------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat`. Returns ------- residuals : (r,) ndarray :math:`2`-norm residuals for each row of the operator matrix. """ residual = self.residual(Ohat) return residual + np.sum((self.regularizer @ Ohat.T) ** 2, axis=0)
[docs] def save(self, savefile: str, overwrite: bool = False): """Serialize the solver, saving it in HDF5 format. The model can be recovered with the :meth:`load()` class method. Parameters ---------- savefile : str File to save to, with extension ``.h5`` (HDF5). overwrite : bool If ``True`` and the specified ``savefile`` already exists, overwrite the file. If ``False`` (default) and the specified ``savefile`` already exists, raise an error. """ return self._save(savefile, overwrite, ["_ZtPad", "_DtD", "_DtZt"])
[docs] @classmethod def load(cls, loadfile: str): """Load a serialized solver from an HDF5 file, created previously from the :meth:`save()` method. Parameters ---------- loadfile : str Path to the file where the model was stored via :meth:`save()`. Returns ------- solver : L2Solver Loaded solver. """ return cls._load(loadfile, ["_ZtPad", "_DtD", "_DtZt"])
[docs] def copy(self): """Make a copy of the solver.""" solver = self.__class__( regularizer=self.regularizer, method=self.method, cond=self.options["cond"], lapack_driver=self.options["lapack_driver"], ) if self.data_matrix is not None: SolverTemplate.fit(solver, self.data_matrix, self.lhs_matrix) solver._ZtPad = self._ZtPad solver._DtD = self._DtD solver._DtZt = self._DtZt return solver
[docs] class TikhonovDecoupledSolver(TikhonovSolver): r"""Solve :math:`r` independent :math:`2`-norm ordinary least-squares problems, each with the same data matrix but a different Tikhonov regularization. That is, for :math:`i = 1, \ldots, r`, construct :math:`\Ohat` by solving .. math:: \argmin_\Ohat\|\D\Ohat\trp - \Z\trp\|_F^2 + \|\bfGamma\Ohat\trp\|_F^2 where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively, with corresponding symmetric-positive-definite regularization matrix :math:`\bfGamma_i`. This is equivalent to solving the following stacked least-squares problems: .. math:: \argmin{\Ohat} \left\| \left[\begin{array}{c}\D \\ \bfGamma_i\end{array}\right]\Ohat\trp - \left[\begin{array}{c}\Z\trp \\ \0\end{array}\right] \right\|_F^2, \quad i = 1, \ldots, r. The exact solution of the :math:`i`-th problem is described by the normal equations: .. math:: (\D\trp\D + \bfGamma_i\trp\bfGamma_i)\ohat_i = \D\trp\z_i. Parameters ---------- regularizer : list of r (d, d) or (d,) ndarrays Symmetric semi-positive-definite regularization matrices :math:`\bfGamma_1,\ldots,\bfGamma_r`. If the ``i``th entry of ``regularizer`` is a one-dimensional array, it is intepreted as the diagonal entries of :math:`\bfGamma_i`. Here, `d` is the number of columns in the data matrix. method : str Strategy for solving the regularized least-squares problem. **Options**: * ``"lstsq"``: solve the stacked least-squares problem via :func:`scipy.linalg.lstsq()`; by default, this computes and uses the singular value decomposition of the stacked data matrix :math:`[~D\trp~~\bfGamma\trp~]\trp`. * ``"normal"``: directly solve the normal equations :math:`(\D\trp\D + \bfGamma\trp\bfGamma) \Ohat\trp = \D\trp\Z\trp` via :func:`scipy.linalg.solve()`. cond : float or None Cutoff for 'small' singular values of the data matrix, see :func:`scipy.linalg.lstsq()`. Ignored if ``method = "normal"``. lapack_driver : str or None Which LAPACK driver is used to solve the least-squares problem, see :func:`scipy.linalg.lstsq()`. Ignored if ``method = "normal"``. """ # Properties -------------------------------------------------------------- def _check_regularizer_shape(self): """Check that the regularizer has the correct shape.""" if len(self.regularizer) != self.r: raise ValueError("len(regularizer) != r") for i, G in enumerate(self.regularizer): if (shape1 := G.shape) != (shape2 := (self.d, self.d)): raise ValueError( f"regularizer[{i}].shape = {shape1} != {shape2} = (d, d)" ) @property def regularizer(self): r"""Symmetric semi-positive-definite regularization matrices :math:`\bfGamma_1,\ldots,\bfGamma_r`, one for each row of the operator matrix. """ return self.__regs @regularizer.setter def regularizer(self, Gs): """Set the regularization matrices.""" regs = [] for G in Gs: if sparse.issparse(G): G = G.toarray() elif not isinstance(G, np.ndarray): G = np.array(G) if G.ndim == 1: if np.any(G < 0): raise ValueError( "diagonal regularizer must be positive semi-definite" ) G = np.diag(G) regs.append(G) self.__regs = regs if self.d is not None: self._check_regularizer_shape() # Main methods ------------------------------------------------------------
[docs] @_require_trained def solve(self) -> np.ndarray: r"""Solve the Operator Inference regression. Returns ------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat` (not its transpose!). """ Ohat = np.empty((self.r, self.d)) # Solve each independent regression problem (sequentially for now). for i, Gamma in enumerate(self.regularizer): if self.method == "lstsq": Dpad = np.vstack((self.data_matrix, Gamma)) Ohat[i] = la.lstsq(Dpad, self._ZtPad[:, i])[0] elif self.method == "normal": regD = self._DtD + Gamma.T @ Gamma Ohat[i] = la.solve(regD, self._DtZt[:, i], assume_a="pos") return Ohat
# Post-processing ---------------------------------------------------------
[docs] @_require_trained def regcond(self) -> float: r"""Compute the :math:`2`-norm condition number of each regularized data matrix :math:`[~\D\trp~~\bfGamma_i\trp~]\trp,~~i = 1, \ldots, r`. Returns ------- conds : (r,) ndarray Condition numbers for the regularized data matrices. """ return np.array( [ np.linalg.cond(np.vstack((self.data_matrix, G))) for G in self.regularizer ] )
[docs] @_require_trained def regresidual(self, Ohat: np.ndarray) -> np.ndarray: r"""Compute the residual of the regularized regression objective for each row of the given operator matrix. Specifically, given a potential :math:`\Ohat`, compute .. math:: \|\D\ohat_i - \z_i\|_2^2 + \|\bfGamma_i\ohat_i\|_2^2, \quad i = 1, \ldots, r, where :math:`\ohat_i` and :math:`\z_i` are the :math:`i`-th rows of :math:`\Ohat` and :math:`\Z`, respectively, and :math:`\bfGamma_i` is the corresponding symmetric-positive-definite regularization matrix. Parameters ---------- Ohat : (r, d) ndarray Operator matrix :math:`\Ohat`. Returns ------- residuals : (r,) ndarray :math:`2`-norm residuals for each row of the operator matrix. """ residual = self.residual(Ohat) rg = [np.sum((G @ oi) ** 2) for G, oi in zip(self.regularizer, Ohat)] return residual + np.array(rg)