pygeoinf 1.2.7__py3-none-any.whl → 1.2.8__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.
@@ -23,9 +23,11 @@ from typing import Callable, List, Optional, Any, Union, Tuple, TYPE_CHECKING, D
23
23
  from collections import defaultdict
24
24
 
25
25
  import numpy as np
26
+ import scipy.sparse as sp
26
27
  from scipy.sparse.linalg import LinearOperator as ScipyLinOp
27
28
  from scipy.sparse import diags
28
29
 
30
+
29
31
  from joblib import Parallel, delayed
30
32
 
31
33
  # from .operators import Operator
@@ -53,8 +55,8 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
53
55
 
54
56
  This class represents a linear map `L(x) = Ax` and provides rich
55
57
  functionality for linear algebraic operations. It specializes
56
- `NonLinearOperator`, correctly defining its derivative as the operator
57
- itself.
58
+ `NonLinearOperator`, with the derivative mapping taking the
59
+ required form (i.e., the derivative is just the operator itself).
58
60
 
59
61
  Key features include operator algebra (`@`, `+`, `*`), automatic
60
62
  derivation of adjoint (`.adjoint`) and dual (`.dual`) operators, and
@@ -71,7 +73,6 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
71
73
  *,
72
74
  dual_mapping: Optional[Callable[[Any], Any]] = None,
73
75
  adjoint_mapping: Optional[Callable[[Any], Any]] = None,
74
- thread_safe: bool = False,
75
76
  dual_base: Optional[LinearOperator] = None,
76
77
  adjoint_base: Optional[LinearOperator] = None,
77
78
  ) -> None:
@@ -84,9 +85,14 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
84
85
  mapping (callable): The function defining the linear mapping.
85
86
  dual_mapping (callable, optional): The action of the dual operator.
86
87
  adjoint_mapping (callable, optional): The action of the adjoint.
87
- thread_safe (bool, optional): True if the mapping is thread-safe.
88
88
  dual_base (LinearOperator, optional): Internal use for duals.
89
89
  adjoint_base (LinearOperator, optional): Internal use for adjoints.
90
+
91
+ Notes:
92
+ If neither the dual or adjoint mappings are provided, an they are
93
+ deduced internally using a correction but very inefficient method.
94
+ In general this functionality should not be relied on other than
95
+ for operators between low-dimensional spaces.
90
96
  """
91
97
  super().__init__(
92
98
  domain, codomain, self._mapping_impl, derivative=self._derivative_impl
@@ -94,7 +100,6 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
94
100
  self._mapping = mapping
95
101
  self._dual_base: Optional[LinearOperator] = dual_base
96
102
  self._adjoint_base: Optional[LinearOperator] = adjoint_base
97
- self._thread_safe: bool = thread_safe
98
103
  self.__adjoint_mapping: Callable[[Any], Any]
99
104
  self.__dual_mapping: Callable[[Any], Any]
100
105
 
@@ -253,83 +258,107 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
253
258
  def from_matrix(
254
259
  domain: HilbertSpace,
255
260
  codomain: HilbertSpace,
256
- matrix: Union[np.ndarray, ScipyLinOp],
261
+ matrix: Union[np.ndarray, sp.sparray, ScipyLinOp],
257
262
  /,
258
263
  *,
259
264
  galerkin: bool = False,
260
- ) -> LinearOperator:
265
+ ) -> MatrixLinearOperator:
261
266
  """
262
- Creates a LinearOperator from its matrix representation.
267
+ Creates the most appropriate LinearOperator from a matrix representation.
263
268
 
264
- This factory defines a `LinearOperator` using a concrete matrix that
265
- acts on the component vectors of the abstract Hilbert space vectors.
269
+ This factory method acts as a dispatcher, inspecting the type of the
270
+ input matrix and returning the most specialized and optimized operator
271
+ subclass (e.g., Dense, Sparse, or DiagonalSparse). It also handles
272
+ matrix-free `scipy.sparse.linalg.LinearOperator` objects.
266
273
 
267
274
  Args:
268
275
  domain: The operator's domain space.
269
276
  codomain: The operator's codomain space.
270
- matrix: The matrix representation (NumPy array or SciPy
271
- LinearOperator). Shape must be `(codomain.dim, domain.dim)`.
272
- galerkin: If `True`, the matrix is interpreted in its "weak form"
273
- or Galerkin representation (`M_ij = <basis_j, A(basis_i)>`),
274
- which maps a vector's components to the components of its
275
- *dual*. This is crucial as it ensures a self-adjoint
276
- operator is represented by a symmetric matrix. If `False`
277
- (default), it's a standard component-to-component map.
277
+ matrix: The matrix representation (NumPy ndarray, SciPy sparray,
278
+ or SciPy LinearOperator).
279
+ galerkin: If `True`, the matrix is interpreted in Galerkin form.
278
280
 
279
281
  Returns:
280
- A new `LinearOperator` defined by the matrix action.
282
+ An instance of the most appropriate MatrixLinearOperator subclass.
281
283
  """
284
+ # The order of these checks is important: from most specific to most general.
282
285
 
283
- assert matrix.shape == (codomain.dim, domain.dim)
286
+ # 1. Check for the most specific diagonal-sparse format
287
+ if isinstance(matrix, sp.dia_array):
288
+ diagonals_tuple = (matrix.data, matrix.offsets)
289
+ return DiagonalSparseMatrixLinearOperator(
290
+ domain, codomain, diagonals_tuple, galerkin=galerkin
291
+ )
284
292
 
285
- if galerkin:
293
+ # 2. Check for any other modern sparse format
294
+ elif isinstance(matrix, sp.sparray):
295
+ return SparseMatrixLinearOperator(
296
+ domain, codomain, matrix, galerkin=galerkin
297
+ )
286
298
 
287
- def mapping(x: Any) -> Any:
288
- cx = domain.to_components(x)
289
- cyp = matrix @ cx
290
- yp = codomain.dual.from_components(cyp)
291
- return codomain.from_dual(yp)
299
+ # 3. Check for a dense NumPy array
300
+ elif isinstance(matrix, np.ndarray):
301
+ return DenseMatrixLinearOperator(
302
+ domain, codomain, matrix, galerkin=galerkin
303
+ )
292
304
 
293
- def adjoint_mapping(y: Any) -> Any:
294
- cy = codomain.to_components(y)
295
- cxp = matrix.T @ cy
296
- xp = domain.dual.from_components(cxp)
297
- return domain.from_dual(xp)
305
+ # 4. Check for a matrix-free SciPy LinearOperator
306
+ elif isinstance(matrix, ScipyLinOp):
307
+ # This is matrix-free, so the general MatrixLinearOperator is the correct wrapper.
308
+ return MatrixLinearOperator(domain, codomain, matrix, galerkin=galerkin)
298
309
 
299
- return LinearOperator(
300
- domain,
301
- codomain,
302
- mapping,
303
- adjoint_mapping=adjoint_mapping,
310
+ # 5. Handle legacy sparse matrix formats (optional but robust)
311
+ elif sp.issparse(matrix):
312
+ modern_array = sp.csr_array(matrix)
313
+ return SparseMatrixLinearOperator(
314
+ domain, codomain, modern_array, galerkin=galerkin
304
315
  )
305
316
 
317
+ # 6. Raise an error for unsupported types
306
318
  else:
307
-
308
- def mapping(x: Any) -> Any:
309
- cx = domain.to_components(x)
310
- cy = matrix @ cx
311
- return codomain.from_components(cy)
312
-
313
- def dual_mapping(yp: Any) -> Any:
314
- cyp = codomain.dual.to_components(yp)
315
- cxp = matrix.T @ cyp
316
- return domain.dual.from_components(cxp)
317
-
318
- return LinearOperator(domain, codomain, mapping, dual_mapping=dual_mapping)
319
+ raise TypeError(f"Unsupported matrix type: {type(matrix)}")
319
320
 
320
321
  @staticmethod
321
322
  def self_adjoint_from_matrix(
322
- domain: HilbertSpace, matrix: Union[np.ndarray, ScipyLinOp]
323
- ) -> LinearOperator:
324
- """Forms a self-adjoint operator from its Galerkin matrix."""
323
+ domain: HilbertSpace,
324
+ matrix: Union[np.ndarray, sp.sparray, ScipyLinOp],
325
+ ) -> MatrixLinearOperator:
326
+ """
327
+ Creates the most appropriate self-adjoint LinearOperator from a matrix.
325
328
 
326
- def mapping(x: Any) -> Any:
327
- cx = domain.to_components(x)
328
- cyp = matrix @ cx
329
- yp = domain.dual.from_components(cyp)
330
- return domain.from_dual(yp)
329
+ This factory acts as a dispatcher, returning the most specialized
330
+ subclass for the given matrix type (e.g., Dense, Sparse).
331
331
 
332
- return LinearOperator.self_adjoint(domain, mapping)
332
+ It ALWAYS assumes the provided matrix is the **Galerkin** representation
333
+ of the operator. The user is responsible for ensuring the input matrix
334
+ is symmetric (or self-adjoint for ScipyLinOp).
335
+
336
+ Args:
337
+ domain: The operator's domain and codomain space.
338
+ matrix: The symmetric matrix representation.
339
+
340
+ Returns:
341
+ An instance of the most appropriate MatrixLinearOperator subclass.
342
+ """
343
+ # Dispatch to the appropriate subclass, always with galerkin=True
344
+ if isinstance(matrix, sp.dia_array):
345
+ diagonals_tuple = (matrix.data, matrix.offsets)
346
+ return DiagonalSparseMatrixLinearOperator(
347
+ domain, domain, diagonals_tuple, galerkin=True
348
+ )
349
+ elif isinstance(matrix, sp.sparray):
350
+ return SparseMatrixLinearOperator(domain, domain, matrix, galerkin=True)
351
+ elif isinstance(matrix, np.ndarray):
352
+ return DenseMatrixLinearOperator(domain, domain, matrix, galerkin=True)
353
+ elif isinstance(matrix, ScipyLinOp):
354
+ return MatrixLinearOperator(domain, domain, matrix, galerkin=True)
355
+ elif sp.issparse(matrix):
356
+ modern_array = sp.csr_array(matrix)
357
+ return SparseMatrixLinearOperator(
358
+ domain, domain, modern_array, galerkin=True
359
+ )
360
+ else:
361
+ raise TypeError(f"Unsupported matrix type: {type(matrix)}")
333
362
 
334
363
  @staticmethod
335
364
  def from_tensor_product(
@@ -417,11 +446,6 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
417
446
  else:
418
447
  return self._adjoint_base
419
448
 
420
- @property
421
- def thread_safe(self) -> bool:
422
- """True if the operator's mapping is thread-safe."""
423
- return self._thread_safe
424
-
425
449
  def matrix(
426
450
  self,
427
451
  /,
@@ -483,22 +507,34 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
483
507
  return self.domain.dual.to_components(xp)
484
508
 
485
509
  def matmat(xmat: np.ndarray) -> np.ndarray:
486
- n, k = xmat.shape
487
- assert n == self.domain.dim
488
- ymat = np.zeros((self.codomain.dim, k))
489
- for j in range(k):
490
- cx = xmat[:, j]
491
- ymat[:, j] = matvec(cx)
492
- return ymat
510
+ _n, k = xmat.shape
511
+ assert _n == self.domain.dim
512
+
513
+ if not parallel:
514
+ ymat = np.zeros((self.codomain.dim, k))
515
+ for j in range(k):
516
+ ymat[:, j] = matvec(xmat[:, j])
517
+ return ymat
518
+ else:
519
+ result_cols = Parallel(n_jobs=n_jobs)(
520
+ delayed(matvec)(xmat[:, j]) for j in range(k)
521
+ )
522
+ return np.column_stack(result_cols)
493
523
 
494
524
  def rmatmat(ymat: np.ndarray) -> np.ndarray:
495
- m, k = ymat.shape
496
- assert m == self.codomain.dim
497
- xmat = np.zeros((self.domain.dim, k))
498
- for j in range(k):
499
- cy = ymat[:, j]
500
- xmat[:, j] = rmatvec(cy)
501
- return xmat
525
+ _m, k = ymat.shape
526
+ assert _m == self.codomain.dim
527
+
528
+ if not parallel:
529
+ xmat = np.zeros((self.domain.dim, k))
530
+ for j in range(k):
531
+ xmat[:, j] = rmatvec(ymat[:, j])
532
+ return xmat
533
+ else:
534
+ result_cols = Parallel(n_jobs=n_jobs)(
535
+ delayed(rmatvec)(ymat[:, j]) for j in range(k)
536
+ )
537
+ return np.column_stack(result_cols)
502
538
 
503
539
  return ScipyLinOp(
504
540
  (self.codomain.dim, self.domain.dim),
@@ -512,7 +548,7 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
512
548
  self,
513
549
  /,
514
550
  *,
515
- galerkin: bool = True,
551
+ galerkin: bool = False,
516
552
  parallel: bool = False,
517
553
  n_jobs: int = -1,
518
554
  ) -> np.ndarray:
@@ -554,7 +590,7 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
554
590
  offsets: List[int],
555
591
  /,
556
592
  *,
557
- galerkin: bool = True,
593
+ galerkin: bool = False,
558
594
  parallel: bool = False,
559
595
  n_jobs: int = -1,
560
596
  ) -> Tuple[np.ndarray, List[int]]:
@@ -639,7 +675,11 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
639
675
  block_size: int = 10,
640
676
  parallel: bool = False,
641
677
  n_jobs: int = -1,
642
- ) -> Tuple[LinearOperator, DiagonalLinearOperator, LinearOperator]:
678
+ ) -> Tuple[
679
+ DenseMatrixLinearOperator,
680
+ DiagonalSparseMatrixLinearOperator,
681
+ DenseMatrixLinearOperator,
682
+ ]:
643
683
  """
644
684
  Computes an approximate SVD using a randomized algorithm.
645
685
 
@@ -662,9 +702,9 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
662
702
  n_jobs: Number of jobs for parallelism.
663
703
 
664
704
  Returns:
665
- left (LinearOperator): The left singular vector matrix.
666
- singular_values (DiagonalLinearOperator): The singular values.
667
- right (LinearOperator): The right singular vector matrix.
705
+ left (DenseMatrixLinearOperator): The left singular vector matrix.
706
+ singular_values (DiagonalSparseMatrixLinearOperator): The singular values.
707
+ right (DenseMatrixLinearOperator): The right singular vector matrix.
668
708
 
669
709
  Notes:
670
710
  The right factor is in transposed form. This means the original
@@ -694,7 +734,9 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
694
734
  )
695
735
 
696
736
  euclidean = EuclideanSpace(qr_factor.shape[1])
697
- diagonal = DiagonalLinearOperator(euclidean, euclidean, singular_values)
737
+ diagonal = DiagonalSparseMatrixLinearOperator.from_diagonal_values(
738
+ euclidean, euclidean, singular_values
739
+ )
698
740
 
699
741
  if galerkin:
700
742
  right = LinearOperator.from_matrix(
@@ -725,7 +767,7 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
725
767
  block_size: int = 10,
726
768
  parallel: bool = False,
727
769
  n_jobs: int = -1,
728
- ) -> Tuple[LinearOperator, DiagonalLinearOperator]:
770
+ ) -> Tuple[DenseMatrixLinearOperator, DiagonalSparseMatrixLinearOperator]:
729
771
  """
730
772
  Computes an approximate eigen-decomposition using a randomized algorithm.
731
773
 
@@ -747,8 +789,8 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
747
789
  n_jobs: Number of jobs for parallelism.
748
790
 
749
791
  Returns:
750
- expansion (LinearOperator): Mapping from coefficients in eigen-basis to vectors.
751
- eigenvaluevalues (DiagonalLinearOperator): The eigenvalues values.
792
+ expansion (DenseMatrixLinearOperator): Mapping from coefficients in eigen-basis to vectors.
793
+ eigenvaluevalues (DiagonalSparseMatrixLinearOperator): The eigenvalues values.
752
794
 
753
795
  """
754
796
  from .hilbert_space import EuclideanSpace
@@ -772,7 +814,9 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
772
814
 
773
815
  eigenvectors, eigenvalues = rm_eig(matrix, qr_factor)
774
816
  euclidean = EuclideanSpace(qr_factor.shape[1])
775
- diagonal = DiagonalLinearOperator(euclidean, euclidean, eigenvalues)
817
+ diagonal = DiagonalSparseMatrixLinearOperator.from_diagonal_values(
818
+ euclidean, euclidean, eigenvalues
819
+ )
776
820
 
777
821
  expansion = LinearOperator.from_matrix(
778
822
  euclidean, self.domain, eigenvectors, galerkin=True
@@ -792,7 +836,7 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
792
836
  block_size: int = 10,
793
837
  parallel: bool = False,
794
838
  n_jobs: int = -1,
795
- ) -> LinearOperator:
839
+ ) -> DenseMatrixLinearOperator:
796
840
  """
797
841
  Computes an approximate Cholesky decomposition for a positive-definite
798
842
  self-adjoint operator using a randomized algorithm.
@@ -815,7 +859,7 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
815
859
  n_jobs: Number of jobs for parallelism.
816
860
 
817
861
  Returns:
818
- factor (LinearOperator): A linear operator from a Euclidean space
862
+ factor (DenseMatrixLinearOperator): A linear operator from a Euclidean space
819
863
  into the domain of the operator.
820
864
 
821
865
  Notes:
@@ -1037,40 +1081,81 @@ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
1037
1081
  return self.matrix(dense=True).__str__()
1038
1082
 
1039
1083
 
1040
- class DiagonalLinearOperator(LinearOperator):
1041
- """A LinearOperator whose Galerkin representation is diagonal.
1084
+ class MatrixLinearOperator(LinearOperator):
1085
+ """
1086
+ A sub-class of LinearOperator for which the operator's action is
1087
+ defined internally through its matrix representation.
1042
1088
 
1043
- This class defines a self-adjoint operator from its diagonal eigenvalues.
1044
- Its key feature is support for **functional calculus**, allowing for the
1045
- direct computation of operator functions like its inverse (`.inverse`) or
1046
- square root (`.sqrt`).
1089
+ This matrix can be either a dense numpy matrix or a
1090
+ scipy LinearOperator.
1047
1091
  """
1048
1092
 
1049
1093
  def __init__(
1050
1094
  self,
1051
1095
  domain: HilbertSpace,
1052
1096
  codomain: HilbertSpace,
1053
- diagonal_values: np.ndarray,
1097
+ matrix: Union[np.ndarray, ScipyLinOp],
1054
1098
  /,
1055
- ) -> None:
1099
+ *,
1100
+ galerkin=False,
1101
+ ):
1056
1102
  """
1057
- Initializes the DiagonalLinearOperator from its diagonal
1058
- Galerkin matrix entries (eigenvalues).
1103
+ Args:
1104
+ domain: The domain of the operator.
1105
+ codomain: The codomain of the operator.
1106
+ matrix: matrix representation of the linear operator in either standard
1107
+ or Galerkin form.
1108
+ galerkin: If True, galerkin representation used. Default is false.
1059
1109
  """
1110
+ assert matrix.shape == (codomain.dim, domain.dim)
1060
1111
 
1061
- assert domain.dim == codomain.dim
1062
- assert domain.dim == len(diagonal_values)
1063
- self._diagonal_values: np.ndarray = diagonal_values
1112
+ self._matrix = matrix
1113
+ self._is_dense = isinstance(matrix, np.ndarray)
1114
+ self._galerkin = galerkin
1064
1115
 
1065
- # The operator is defined by its diagonal Galerkin matrix.
1066
- matrix = diags([diagonal_values], [0])
1067
- operator = LinearOperator.from_matrix(domain, codomain, matrix, galerkin=True)
1068
- super().__init__(
1069
- operator.domain,
1070
- operator.codomain,
1071
- operator,
1072
- adjoint_mapping=operator.adjoint,
1073
- )
1116
+ if galerkin:
1117
+
1118
+ def mapping(x: Any) -> Any:
1119
+ cx = domain.to_components(x)
1120
+ cyp = matrix @ cx
1121
+ yp = codomain.dual.from_components(cyp)
1122
+ return codomain.from_dual(yp)
1123
+
1124
+ def adjoint_mapping(y: Any) -> Any:
1125
+ cy = codomain.to_components(y)
1126
+ cxp = matrix.T @ cy
1127
+ xp = domain.dual.from_components(cxp)
1128
+ return domain.from_dual(xp)
1129
+
1130
+ super().__init__(domain, codomain, mapping, adjoint_mapping=adjoint_mapping)
1131
+
1132
+ else:
1133
+
1134
+ def mapping(x: Any) -> Any:
1135
+ cx = domain.to_components(x)
1136
+ cy = matrix @ cx
1137
+ return codomain.from_components(cy)
1138
+
1139
+ def dual_mapping(yp: Any) -> Any:
1140
+ cyp = codomain.dual.to_components(yp)
1141
+ cxp = matrix.T @ cyp
1142
+ return domain.dual.from_components(cxp)
1143
+
1144
+ super().__init__(domain, codomain, mapping, dual_mapping=dual_mapping)
1145
+
1146
+ @property
1147
+ def is_dense(self) -> bool:
1148
+ """
1149
+ Returns True if the matrix representation is stored internally in dense form.
1150
+ """
1151
+ return self._is_dense
1152
+
1153
+ @property
1154
+ def is_galerkin(self) -> bool:
1155
+ """
1156
+ Returns True if the matrix representation is stored in Galerkin form.
1157
+ """
1158
+ return self._galerkin
1074
1159
 
1075
1160
  def _compute_dense_matrix(
1076
1161
  self, galerkin: bool, parallel: bool, n_jobs: int
@@ -1078,48 +1163,26 @@ class DiagonalLinearOperator(LinearOperator):
1078
1163
  """
1079
1164
  Overloaded method to efficiently compute the dense matrix.
1080
1165
  """
1081
- if galerkin:
1082
- # Fast path: This is how the operator is defined.
1083
- return np.diag(self._diagonal_values)
1166
+
1167
+ if galerkin == self.is_galerkin and self.is_dense:
1168
+ return self._matrix
1084
1169
  else:
1085
- # The user wants the standard matrix, which may differ from the
1086
- # diagonal Galerkin matrix in non-Euclidean spaces. Fall back
1087
- # to the safe, general method for this conversion.
1088
1170
  return super()._compute_dense_matrix(galerkin, parallel, n_jobs)
1089
1171
 
1090
- @property
1091
- def diagonal_values(self) -> np.ndarray:
1092
- """The diagonal entries of the operator's Galerkin matrix."""
1093
- return self._diagonal_values
1094
-
1095
- def function(self, f: Callable[[float], float]) -> "DiagonalLinearOperator":
1096
- """Applies a function to the operator via functional calculus."""
1097
- new_diagonal_values = np.array([f(x) for x in self.diagonal_values])
1098
- return DiagonalLinearOperator(self.domain, self.codomain, new_diagonal_values)
1099
-
1100
- @property
1101
- def inverse(self) -> "DiagonalLinearOperator":
1102
- """The inverse of the operator, computed via functional calculus."""
1103
- assert all(val != 0 for val in self.diagonal_values)
1104
- return self.function(lambda x: 1 / x)
1105
-
1106
- @property
1107
- def sqrt(self) -> "DiagonalLinearOperator":
1108
- """The square root of the operator, computed via functional calculus."""
1109
- assert all(val >= 0 for val in self._diagonal_values)
1110
- return self.function(np.sqrt)
1111
-
1112
1172
  def extract_diagonal(
1113
1173
  self,
1114
1174
  /,
1115
1175
  *,
1116
- galerkin: bool = True,
1176
+ galerkin: bool = False,
1117
1177
  parallel: bool = False,
1118
1178
  n_jobs: int = -1,
1119
1179
  ) -> np.ndarray:
1120
- """Overrides base method for efficiency."""
1121
- if galerkin:
1122
- return self._diagonal_values
1180
+ """
1181
+ Overload for efficiency.
1182
+ """
1183
+
1184
+ if galerkin == self.is_galerkin and self.is_dense:
1185
+ return self._matrix.diagonal()
1123
1186
  else:
1124
1187
  return super().extract_diagonal(
1125
1188
  galerkin=galerkin, parallel=parallel, n_jobs=n_jobs
@@ -1130,23 +1193,460 @@ class DiagonalLinearOperator(LinearOperator):
1130
1193
  offsets: List[int],
1131
1194
  /,
1132
1195
  *,
1133
- galerkin: bool = True,
1196
+ galerkin: bool = False,
1134
1197
  parallel: bool = False,
1135
1198
  n_jobs: int = -1,
1136
1199
  ) -> Tuple[np.ndarray, List[int]]:
1137
- """Overrides base method for efficiency."""
1138
- if galerkin:
1200
+ """
1201
+ Overrides the base method for efficiency by extracting diagonals directly
1202
+ from the stored dense matrix when possible.
1203
+ """
1204
+
1205
+ if self.is_dense and galerkin == self.is_galerkin:
1139
1206
  dim = self.domain.dim
1207
+
1140
1208
  diagonals_array = np.zeros((len(offsets), dim))
1141
- for i, offset in enumerate(offsets):
1142
- if offset == 0:
1143
- diagonals_array[i, :] = self._diagonal_values
1209
+
1210
+ for i, k in enumerate(offsets):
1211
+ diag_k = np.diag(self._matrix, k=k)
1212
+
1213
+ if k >= 0:
1214
+ diagonals_array[i, k : k + len(diag_k)] = diag_k
1215
+ else:
1216
+ diagonals_array[i, : len(diag_k)] = diag_k
1217
+
1144
1218
  return diagonals_array, offsets
1219
+
1220
+ else:
1221
+ return super().extract_diagonals(
1222
+ offsets, galerkin=galerkin, parallel=parallel, n_jobs=n_jobs
1223
+ )
1224
+
1225
+
1226
+ class DenseMatrixLinearOperator(MatrixLinearOperator):
1227
+ """
1228
+ A specialisation of the MatrixLinearOperator class to instances where
1229
+ the matrix representation is always provided as a numpy array.
1230
+
1231
+ This is a class provides some additional methods for component-wise access.
1232
+ """
1233
+
1234
+ def __init__(
1235
+ self,
1236
+ domain: HilbertSpace,
1237
+ codomain: HilbertSpace,
1238
+ matrix: np.ndarray,
1239
+ /,
1240
+ *,
1241
+ galerkin=False,
1242
+ ):
1243
+ """
1244
+ domain: The domain of the operator.
1245
+ codomain: The codomain of the operator.
1246
+ matrix: matrix representation of the linear operator in either standard
1247
+ or Galerkin form.
1248
+ galerkin: If True, galerkin representation used. Default is false.
1249
+ """
1250
+
1251
+ if not isinstance(matrix, np.ndarray):
1252
+ raise ValueError("Matrix must be input in dense form.")
1253
+
1254
+ super().__init__(domain, codomain, matrix, galerkin=galerkin)
1255
+
1256
+ @staticmethod
1257
+ def from_linear_operator(
1258
+ operator: LinearOperator,
1259
+ /,
1260
+ *,
1261
+ galerkin: bool = False,
1262
+ parallel: bool = False,
1263
+ n_jobs: int = -1,
1264
+ ) -> DenseMatrixLinearOperator:
1265
+ """
1266
+ Converts a LinearOperator into a DenseMatrixLinearOperator by forming its dense matrix representation.
1267
+
1268
+ Args:
1269
+ operator: The operator to be converted.
1270
+ galerkin: If True, the Galerkin representation is used. Default is False.
1271
+ parallel: If True, dense matrix calculation is done in parallel. Default is False.
1272
+ n_jobs: Number of jobs used for parallel calculations. Default is False.
1273
+ """
1274
+
1275
+ if isinstance(operator, DenseMatrixLinearOperator):
1276
+ return operator
1277
+
1278
+ domain = operator.domain
1279
+ codomain = operator.codomain
1280
+
1281
+ matrix = operator.matrix(
1282
+ dense=True, galerkin=galerkin, parallel=parallel, n_jobs=n_jobs
1283
+ )
1284
+
1285
+ return DenseMatrixLinearOperator(domain, codomain, matrix, galerkin=galerkin)
1286
+
1287
+ def __getitem__(self, key: tuple[int, int] | int | slice) -> float | np.ndarray:
1288
+ """
1289
+ Provides direct, component-wise access to the underlying matrix.
1290
+
1291
+ This allows for intuitive slicing and indexing, like `op[i, j]` or `op[0, :]`.
1292
+ Note: The access is on the stored matrix, which may be in either
1293
+ standard or Galerkin form depending on how the operator was initialized.
1294
+ """
1295
+ return self._matrix[key]
1296
+
1297
+
1298
+ class SparseMatrixLinearOperator(MatrixLinearOperator):
1299
+ """
1300
+ A specialization for operators represented by a modern SciPy sparse array.
1301
+
1302
+ This class requires a `scipy.sparse.sparray` object (e.g., csr_array)
1303
+ and provides optimized methods that delegate to efficient SciPy routines.
1304
+
1305
+ Upon initialization, the internal array is converted to the CSR
1306
+ (Compressed Sparse Row) format to ensure consistently fast matrix-vector
1307
+ products and row-slicing operations.
1308
+ """
1309
+
1310
+ def __init__(
1311
+ self,
1312
+ domain: HilbertSpace,
1313
+ codomain: HilbertSpace,
1314
+ matrix: sp.sparray,
1315
+ /,
1316
+ *,
1317
+ galerkin: bool = False,
1318
+ ):
1319
+ """
1320
+ Args:
1321
+ domain: The domain of the operator.
1322
+ codomain: The codomain of the operator.
1323
+ matrix: The sparse array representation of the linear operator.
1324
+ Must be a modern sparray object (e.g., csr_array).
1325
+ galerkin: If True, the matrix is in Galerkin form. Defaults to False.
1326
+ """
1327
+ # Strict check for the modern sparse array type
1328
+ if not isinstance(matrix, sp.sparray):
1329
+ raise TypeError(
1330
+ "Matrix must be a modern SciPy sparray object (e.g., csr_array)."
1331
+ )
1332
+
1333
+ super().__init__(domain, codomain, matrix, galerkin=galerkin)
1334
+ self._matrix = self._matrix.asformat("csr")
1335
+
1336
+ def __getitem__(self, key):
1337
+ """Provides direct component access using SciPy's sparse indexing."""
1338
+ return self._matrix[key]
1339
+
1340
+ def _compute_dense_matrix(
1341
+ self, galerkin: bool, parallel: bool, n_jobs: int
1342
+ ) -> np.ndarray:
1343
+ """
1344
+ Overrides the base method to efficiently compute the dense matrix.
1345
+ """
1346
+ # ⚡️ Fast path: Use the highly optimized .toarray() method.
1347
+ if galerkin == self.is_galerkin:
1348
+ return self._matrix.toarray()
1349
+
1350
+ # Fallback path for when a basis conversion is needed.
1145
1351
  else:
1352
+ return super()._compute_dense_matrix(galerkin, parallel, n_jobs)
1353
+
1354
+ def extract_diagonal(
1355
+ self,
1356
+ /,
1357
+ *,
1358
+ galerkin: bool = False,
1359
+ parallel: bool = False,
1360
+ n_jobs: int = -1,
1361
+ ) -> np.ndarray:
1362
+ """
1363
+ Overrides the base method to efficiently extract the main diagonal.
1364
+ """
1365
+ if galerkin == self.is_galerkin:
1366
+ return self._matrix.diagonal(k=0)
1367
+ else:
1368
+ return super().extract_diagonal(
1369
+ galerkin=galerkin, parallel=parallel, n_jobs=n_jobs
1370
+ )
1371
+
1372
+ def extract_diagonals(
1373
+ self,
1374
+ offsets: List[int],
1375
+ /,
1376
+ *,
1377
+ galerkin: bool = False,
1378
+ parallel: bool = False,
1379
+ n_jobs: int = -1,
1380
+ ) -> Tuple[np.ndarray, List[int]]:
1381
+ """
1382
+ Overrides the base method for efficiency by extracting diagonals
1383
+ directly from the stored sparse array.
1384
+ """
1385
+ if galerkin != self.is_galerkin:
1146
1386
  return super().extract_diagonals(
1147
1387
  offsets, galerkin=galerkin, parallel=parallel, n_jobs=n_jobs
1148
1388
  )
1149
1389
 
1390
+ dim = self.domain.dim
1391
+ diagonals_array = np.zeros((len(offsets), dim))
1392
+
1393
+ for i, k in enumerate(offsets):
1394
+ # Use the sparse array's fast .diagonal() method
1395
+ diag_k = self._matrix.diagonal(k=k)
1396
+
1397
+ # Place the raw diagonal into the padded output array
1398
+ if k >= 0:
1399
+ diagonals_array[i, k : k + len(diag_k)] = diag_k
1400
+ else:
1401
+ diagonals_array[i, : len(diag_k)] = diag_k
1402
+
1403
+ return diagonals_array, offsets
1404
+
1405
+
1406
+ class DiagonalSparseMatrixLinearOperator(SparseMatrixLinearOperator):
1407
+ """
1408
+ A highly specialized operator for matrices defined purely by a set of
1409
+ non-zero diagonals.
1410
+
1411
+ This class internally stores the operator using a `scipy.sparse.dia_array`
1412
+ for maximum efficiency in storage and matrix-vector products. It provides
1413
+ extremely fast methods for extracting diagonals, as this is its native
1414
+ storage format.
1415
+
1416
+ A key feature of this class is its support for **functional calculus**. It
1417
+ dynamically proxies element-wise mathematical functions (e.g., `.sqrt()`,
1418
+ `.log()`, `abs()`, `**`) to the underlying sparse array. For reasons of
1419
+ mathematical correctness, these operations are restricted to operators that
1420
+ are **strictly diagonal** (i.e., have only a non-zero main diagonal) and
1421
+ will raise a `NotImplementedError` otherwise.
1422
+
1423
+ Aggregation methods that do not return a new operator (e.g., `.sum()`)
1424
+ are not restricted and can be used on any multi-diagonal operator.
1425
+
1426
+ Class Methods
1427
+ -------------
1428
+ from_diagonal_values:
1429
+ Constructs a strictly diagonal operator from a 1D array of values.
1430
+ from_operator:
1431
+ Creates a diagonal approximation of another LinearOperator.
1432
+
1433
+ Properties
1434
+ ----------
1435
+ offsets:
1436
+ The array of stored diagonal offsets.
1437
+ is_strictly_diagonal:
1438
+ True if the operator only has a non-zero main diagonal.
1439
+ inverse:
1440
+ The inverse of a strictly diagonal operator.
1441
+ sqrt:
1442
+ The square root of a strictly diagonal operator.
1443
+ """
1444
+
1445
+ def __init__(
1446
+ self,
1447
+ domain: HilbertSpace,
1448
+ codomain: HilbertSpace,
1449
+ diagonals: Tuple[np.ndarray, List[int]],
1450
+ /,
1451
+ *,
1452
+ galerkin: bool = False,
1453
+ ):
1454
+ """
1455
+ Args:
1456
+ domain: The domain of the operator.
1457
+ codomain: The codomain of the operator.
1458
+ diagonals: A tuple `(data, offsets)` where `data` is a 2D array
1459
+ of diagonal values and `offsets` is a list of their
1460
+ positions. This is the native format for a dia_array.
1461
+ galerkin: If True, the matrix is in Galerkin form. Defaults to False.
1462
+ """
1463
+ shape = (codomain.dim, domain.dim)
1464
+ dia_array = sp.dia_array(diagonals, shape=shape)
1465
+
1466
+ MatrixLinearOperator.__init__(
1467
+ self, domain, codomain, dia_array, galerkin=galerkin
1468
+ )
1469
+
1470
+ @classmethod
1471
+ def from_operator(
1472
+ cls, operator: LinearOperator, offsets: List[int], /, *, galerkin: bool = True
1473
+ ) -> DiagonalSparseMatrixLinearOperator:
1474
+ """
1475
+ Creates a diagonal approximation of another LinearOperator.
1476
+
1477
+ This factory method works by calling the source operator's
1478
+ `.extract_diagonals()` method and using the result to construct a
1479
+ new, highly efficient DiagonalSparseMatrixLinearOperator.
1480
+
1481
+ Args:
1482
+ operator: The source operator to approximate.
1483
+ offsets: The list of diagonal offsets to extract and keep.
1484
+ galerkin: Specifies which matrix representation to use.
1485
+
1486
+ Returns:
1487
+ A new DiagonalSparseMatrixLinearOperator.
1488
+ """
1489
+ diagonals_data, extracted_offsets = operator.extract_diagonals(
1490
+ offsets, galerkin=galerkin
1491
+ )
1492
+ return cls(
1493
+ operator.domain,
1494
+ operator.codomain,
1495
+ (diagonals_data, extracted_offsets),
1496
+ galerkin=galerkin,
1497
+ )
1498
+
1499
+ @classmethod
1500
+ def from_diagonal_values(
1501
+ cls,
1502
+ domain: HilbertSpace,
1503
+ codomain: HilbertSpace,
1504
+ diagonal_values: np.ndarray,
1505
+ /,
1506
+ *,
1507
+ galerkin: bool = False,
1508
+ ) -> "DiagonalSparseMatrixLinearOperator":
1509
+ """
1510
+ Constructs a purely diagonal operator from a 1D array of values.
1511
+
1512
+ This provides a convenient way to create an operator with non-zero
1513
+ entries only on its main diagonal (offset k=0).
1514
+
1515
+ Args:
1516
+ domain: The domain of the operator.
1517
+ codomain: The codomain of the operator. Must have the same dimension.
1518
+ diagonal_values: A 1D NumPy array of the values for the main diagonal.
1519
+ galerkin: If True, the operator is in Galerkin form.
1520
+
1521
+ Returns:
1522
+ A new DiagonalSparseMatrixLinearOperator.
1523
+ """
1524
+ if domain.dim != codomain.dim or domain.dim != len(diagonal_values):
1525
+ raise ValueError(
1526
+ "Domain, codomain, and diagonal_values must all have the same dimension."
1527
+ )
1528
+
1529
+ # Reshape the 1D array of values into the 2D `data` array format
1530
+ diagonals_data = diagonal_values.reshape(1, -1)
1531
+ offsets = [0]
1532
+
1533
+ return cls(domain, codomain, (diagonals_data, offsets), galerkin=galerkin)
1534
+
1535
+ @property
1536
+ def offsets(self) -> np.ndarray:
1537
+ """Returns the array of stored diagonal offsets."""
1538
+ return self._matrix.offsets
1539
+
1540
+ @property
1541
+ def is_strictly_diagonal(self) -> bool:
1542
+ """
1543
+ True if the operator only has a non-zero main diagonal (offset=0).
1544
+ """
1545
+ return len(self.offsets) == 1 and self.offsets[0] == 0
1546
+
1547
+ @property
1548
+ def inverse(self) -> "DiagonalSparseMatrixLinearOperator":
1549
+ """
1550
+ The inverse of the operator, computed via functional calculus.
1551
+ Requires the operator to be strictly diagonal with no zero entries.
1552
+ """
1553
+ if not self.is_strictly_diagonal:
1554
+ raise NotImplementedError(
1555
+ "Inverse is only implemented for strictly diagonal operators."
1556
+ )
1557
+
1558
+ if np.any(self._matrix.diagonal(k=0) == 0):
1559
+ raise ValueError("Cannot invert an operator with zeros on the diagonal.")
1560
+
1561
+ return self**-1
1562
+
1563
+ @property
1564
+ def sqrt(self) -> "DiagonalSparseMatrixLinearOperator":
1565
+ """
1566
+ The square root of the operator, computed via functional calculus.
1567
+ Requires the operator to be strictly diagonal with non-negative entries.
1568
+ """
1569
+
1570
+ if np.any(self._matrix.data < 0):
1571
+ raise ValueError(
1572
+ "Cannot take the square root of an operator with negative entries."
1573
+ )
1574
+
1575
+ return self.__getattr__("sqrt")()
1576
+
1577
+ def extract_diagonals(
1578
+ self,
1579
+ offsets: List[int],
1580
+ /,
1581
+ *,
1582
+ galerkin: bool = True,
1583
+ # parallel and n_jobs are ignored but kept for signature consistency
1584
+ parallel: bool = False,
1585
+ n_jobs: int = -1,
1586
+ ) -> Tuple[np.ndarray, List[int]]:
1587
+ """
1588
+ Overrides the base method for extreme efficiency.
1589
+
1590
+ This operation is nearly free, as it involves selecting the requested
1591
+ diagonals from the data already stored in the native format.
1592
+ """
1593
+ if galerkin != self.is_galerkin:
1594
+ return super().extract_diagonals(offsets, galerkin=galerkin)
1595
+
1596
+ # Create a result array and fill it with the requested stored diagonals
1597
+ result_diagonals = np.zeros((len(offsets), self.domain.dim))
1598
+
1599
+ # Create a mapping from stored offset to its data row for quick lookup
1600
+ stored_diagonals = dict(zip(self.offsets, self._matrix.data))
1601
+
1602
+ for i, k in enumerate(offsets):
1603
+ if k in stored_diagonals:
1604
+ result_diagonals[i, :] = stored_diagonals[k]
1605
+
1606
+ return result_diagonals, offsets
1607
+
1608
+ def __getattr__(self, name: str):
1609
+ """
1610
+ Dynamically proxies method calls to the underlying dia_array.
1611
+
1612
+ For element-wise mathematical functions that return a new operator,
1613
+ this method enforces that the operator must be strictly diagonal.
1614
+ """
1615
+ attr = getattr(self._matrix, name)
1616
+
1617
+ if callable(attr):
1618
+
1619
+ def wrapper(*args, **kwargs):
1620
+ result = attr(*args, **kwargs)
1621
+
1622
+ if isinstance(result, sp.sparray):
1623
+ if not self.is_strictly_diagonal:
1624
+ raise NotImplementedError(
1625
+ f"Element-wise function '{name}' is only defined for "
1626
+ "strictly diagonal operators."
1627
+ )
1628
+
1629
+ return DiagonalSparseMatrixLinearOperator(
1630
+ self.domain,
1631
+ self.codomain,
1632
+ (result.data, result.offsets),
1633
+ galerkin=self.is_galerkin,
1634
+ )
1635
+ else:
1636
+ return result
1637
+
1638
+ return wrapper
1639
+ else:
1640
+ return attr
1641
+
1642
+ def __abs__(self):
1643
+ """Explicitly handle the built-in abs() function."""
1644
+ return self.__getattr__("__abs__")()
1645
+
1646
+ def __pow__(self, power):
1647
+ """Explicitly handle the power operator (**)."""
1648
+ return self.__getattr__("__pow__")(power)
1649
+
1150
1650
 
1151
1651
  class NormalSumOperator(LinearOperator):
1152
1652
  """
@@ -1169,15 +1669,12 @@ class NormalSumOperator(LinearOperator):
1169
1669
  B: Optional[LinearOperator] = None,
1170
1670
  ) -> None:
1171
1671
 
1172
- # This operator's domain is the codomain of A.
1173
1672
  op_domain = A.codomain
1174
1673
 
1175
1674
  if Q is None:
1176
- # Q must be an operator on the domain of A.
1177
1675
  Q = A.domain.identity_operator()
1178
1676
 
1179
1677
  if B is None:
1180
- # B must be an operator on the codomain of A.
1181
1678
  B = op_domain.zero_operator()
1182
1679
 
1183
1680
  if A.domain != Q.domain:
@@ -1189,7 +1686,6 @@ class NormalSumOperator(LinearOperator):
1189
1686
  self._Q = Q
1190
1687
  self._B = B
1191
1688
 
1192
- # The compositional definition now works with correct dimensions.
1193
1689
  composite_op = self._A @ self._Q @ self._A.adjoint + self._B
1194
1690
 
1195
1691
  super().__init__(
@@ -1207,40 +1703,30 @@ class NormalSumOperator(LinearOperator):
1207
1703
  implementation leveraging the base class's methods.
1208
1704
  """
1209
1705
  if not galerkin:
1210
- # The optimization is specific to the Galerkin representation.
1211
- return super()._compute_dense_matrix(galerkin, parallel, n_jobs) #
1706
+ return super()._compute_dense_matrix(galerkin, parallel, n_jobs)
1212
1707
 
1213
- domain_Y = self._A.codomain #
1214
- dim = self.domain.dim #
1708
+ domain_Y = self._A.codomain
1709
+ dim = self.domain.dim
1215
1710
  jobs = n_jobs if parallel else 1
1216
1711
 
1217
- # Step 1: Get component vectors c(v_j) where v_j = A*(e_j)
1218
- print("Step 1: Computing components of v_j = A*(e_j)...")
1219
1712
  a_star_mat = self._A.adjoint.matrix(
1220
1713
  dense=True, galerkin=False, parallel=parallel, n_jobs=n_jobs
1221
- ) #
1222
-
1223
- # Step 2: Reconstruct v_j vectors and compute w_j = Q(v_j)
1224
- print("Step 2: Computing w_j = Q(v_j)...")
1225
- v_vectors = [domain_Y.from_components(a_star_mat[:, j]) for j in range(dim)] #
1226
- w_vectors = Parallel(n_jobs=jobs)(delayed(self._Q)(v_j) for v_j in v_vectors) #
1714
+ )
1227
1715
 
1228
- # Step 3: Compute the inner product matrix M_AQA = <v_i, w_j>
1229
- print("Step 3: Computing inner product matrix M_AQA...")
1716
+ v_vectors = [domain_Y.from_components(a_star_mat[:, j]) for j in range(dim)]
1717
+ w_vectors = Parallel(n_jobs=jobs)(delayed(self._Q)(v_j) for v_j in v_vectors)
1230
1718
 
1231
1719
  def compute_row(i: int) -> np.ndarray:
1232
1720
  """Computes the i-th row of the inner product matrix."""
1233
1721
  v_i = v_vectors[i]
1234
- return np.array([domain_Y.inner_product(v_i, w_j) for w_j in w_vectors]) #
1722
+ return np.array([domain_Y.inner_product(v_i, w_j) for w_j in w_vectors])
1235
1723
 
1236
1724
  rows = Parallel(n_jobs=jobs)(delayed(compute_row)(i) for i in range(dim))
1237
1725
  m_aqa_mat = np.vstack(rows)
1238
1726
 
1239
- # Step 4: Compute B_mat
1240
- print("Step 4: Computing matrix for B...")
1241
1727
  b_mat = self._B.matrix(
1242
1728
  dense=True, galerkin=True, parallel=parallel, n_jobs=n_jobs
1243
- ) #
1729
+ )
1244
1730
 
1245
1731
  return m_aqa_mat + b_mat
1246
1732
 
@@ -1248,7 +1734,7 @@ class NormalSumOperator(LinearOperator):
1248
1734
  self,
1249
1735
  /,
1250
1736
  *,
1251
- galerkin: bool = True,
1737
+ galerkin: bool = False,
1252
1738
  parallel: bool = False,
1253
1739
  n_jobs: int = -1,
1254
1740
  ) -> np.ndarray:
@@ -1282,7 +1768,7 @@ class NormalSumOperator(LinearOperator):
1282
1768
  offsets: List[int],
1283
1769
  /,
1284
1770
  *,
1285
- galerkin: bool = True,
1771
+ galerkin: bool = False,
1286
1772
  parallel: bool = False,
1287
1773
  n_jobs: int = -1,
1288
1774
  ) -> Tuple[np.ndarray, List[int]]: