pygeoinf 1.1.8__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pygeoinf/direct_sum.py CHANGED
@@ -25,10 +25,13 @@ from __future__ import annotations
25
25
  from abc import ABC, abstractmethod
26
26
  from typing import List, Any
27
27
  import numpy as np
28
+ from scipy.linalg import block_diag
29
+ from joblib import Parallel, delayed
28
30
 
29
31
  from .hilbert_space import HilbertSpace
30
32
  from .operators import LinearOperator
31
33
  from .linear_forms import LinearForm
34
+ from .parallel import parallel_compute_dense_matrix_from_scipy_op
32
35
 
33
36
 
34
37
  class HilbertSpaceDirectSum(HilbertSpace):
@@ -300,6 +303,40 @@ class BlockLinearOperator(LinearOperator, BlockStructure):
300
303
  self._check_block_indices(i, j)
301
304
  return self._blocks[i][j]
302
305
 
306
+ def _compute_dense_matrix(
307
+ self, galerkin: bool, parallel: bool, n_jobs: int
308
+ ) -> np.ndarray:
309
+ """Overloaded method to efficiently compute the dense matrix for a block operator."""
310
+ if not parallel:
311
+ block_matrices = [
312
+ [
313
+ self.block(i, j).matrix(
314
+ dense=True, galerkin=galerkin, parallel=False
315
+ )
316
+ for j in range(self.col_dim)
317
+ ]
318
+ for i in range(self.row_dim)
319
+ ]
320
+ return np.block(block_matrices)
321
+
322
+ else:
323
+ block_scipy_ops = [
324
+ self.block(i, j).matrix(galerkin=galerkin)
325
+ for i in range(self.row_dim)
326
+ for j in range(self.col_dim)
327
+ ]
328
+
329
+ computed_blocks = Parallel(n_jobs=n_jobs)(
330
+ delayed(parallel_compute_dense_matrix_from_scipy_op)(op, n_jobs=1)
331
+ for op in block_scipy_ops
332
+ )
333
+
334
+ block_matrices = [
335
+ computed_blocks[i * self.col_dim : (i + 1) * self.col_dim]
336
+ for i in range(self.row_dim)
337
+ ]
338
+ return np.block(block_matrices)
339
+
303
340
  def __mapping(self, xs: List[Any]) -> List[Any]:
304
341
 
305
342
  ys = []
@@ -379,6 +416,28 @@ class ColumnLinearOperator(LinearOperator, BlockStructure):
379
416
  raise IndexError("Column index out of range for ColumnLinearOperator.")
380
417
  return self._operators[i]
381
418
 
419
+ def _compute_dense_matrix(
420
+ self, galerkin: bool, parallel: bool, n_jobs: int
421
+ ) -> np.ndarray:
422
+ """Overloaded method to efficiently compute the dense matrix for a column operator."""
423
+ if not parallel:
424
+ block_matrices = [
425
+ op.matrix(dense=True, galerkin=galerkin, parallel=False)
426
+ for op in self._operators
427
+ ]
428
+ return np.vstack(block_matrices)
429
+ else:
430
+ block_scipy_ops = [
431
+ op.matrix(galerkin=galerkin, parallel=False) for op in self._operators
432
+ ]
433
+
434
+ computed_blocks = Parallel(n_jobs=n_jobs)(
435
+ delayed(parallel_compute_dense_matrix_from_scipy_op)(op, n_jobs=1)
436
+ for op in block_scipy_ops
437
+ )
438
+
439
+ return np.vstack(computed_blocks)
440
+
382
441
 
383
442
  class RowLinearOperator(LinearOperator, BlockStructure):
384
443
  """
@@ -433,6 +492,28 @@ class RowLinearOperator(LinearOperator, BlockStructure):
433
492
  raise IndexError("Row index out of range for RowLinearOperator.")
434
493
  return self._operators[j]
435
494
 
495
+ def _compute_dense_matrix(
496
+ self, galerkin: bool, parallel: bool, n_jobs: int
497
+ ) -> np.ndarray:
498
+ """Overloaded method to efficiently compute the dense matrix for a row operator."""
499
+ if not parallel:
500
+ block_matrices = [
501
+ op.matrix(dense=True, galerkin=galerkin, parallel=False)
502
+ for op in self._operators
503
+ ]
504
+ return np.hstack(block_matrices)
505
+ else:
506
+ block_scipy_ops = [
507
+ op.matrix(galerkin=galerkin, parallel=False) for op in self._operators
508
+ ]
509
+
510
+ computed_blocks = Parallel(n_jobs=n_jobs)(
511
+ delayed(parallel_compute_dense_matrix_from_scipy_op)(op, n_jobs=1)
512
+ for op in block_scipy_ops
513
+ )
514
+
515
+ return np.hstack(computed_blocks)
516
+
436
517
 
437
518
  class BlockDiagonalLinearOperator(LinearOperator, BlockStructure):
438
519
  """
@@ -473,3 +554,25 @@ class BlockDiagonalLinearOperator(LinearOperator, BlockStructure):
473
554
  domain = self._operators[j].domain
474
555
  codomain = self._operators[i].codomain
475
556
  return domain.zero_operator(codomain)
557
+
558
+ def _compute_dense_matrix(
559
+ self, galerkin: bool, parallel: bool, n_jobs: int
560
+ ) -> np.ndarray:
561
+ """Overloaded method to efficiently compute the dense matrix for a block-diagonal operator."""
562
+ if not parallel:
563
+ block_matrices = [
564
+ op.matrix(dense=True, galerkin=galerkin, parallel=False)
565
+ for op in self._operators
566
+ ]
567
+ return block_diag(*block_matrices)
568
+ else:
569
+ block_scipy_ops = [
570
+ op.matrix(galerkin=galerkin, parallel=False) for op in self._operators
571
+ ]
572
+
573
+ computed_blocks = Parallel(n_jobs=n_jobs)(
574
+ delayed(parallel_compute_dense_matrix_from_scipy_op)(op, n_jobs=1)
575
+ for op in block_scipy_ops
576
+ )
577
+
578
+ return block_diag(*computed_blocks)
@@ -484,7 +484,9 @@ class GaussianMeasure:
484
484
  )
485
485
 
486
486
  return multivariate_normal(
487
- mean=self.expectation, cov=self.covariance.matrix(dense=True)
487
+ mean=self.expectation,
488
+ cov=self.covariance.matrix(dense=True),
489
+ allow_singular=True,
488
490
  )
489
491
 
490
492
  def low_rank_approximation(
@@ -51,7 +51,7 @@ class LinearLeastSquaresInversion(Inversion):
51
51
  if self.forward_problem.data_error_measure_set:
52
52
  self.assert_inverse_data_covariance()
53
53
 
54
- def normal_operator(self, damping: float) -> "LinearOperator":
54
+ def normal_operator(self, damping: float) -> LinearOperator:
55
55
  """
56
56
  Returns the Tikhonov-regularized normal operator.
57
57
 
@@ -88,8 +88,8 @@ class LinearLeastSquaresInversion(Inversion):
88
88
  solver: "LinearSolver",
89
89
  /,
90
90
  *,
91
- preconditioner: Optional["LinearOperator"] = None,
92
- ) -> Union[Operator, "LinearOperator"]:
91
+ preconditioner: Optional[LinearOperator] = None,
92
+ ) -> Union[Operator, LinearOperator]:
93
93
  """
94
94
  Returns an operator that maps data to the least-squares solution.
95
95
 
@@ -121,7 +121,7 @@ class LinearLeastSquaresInversion(Inversion):
121
121
  )
122
122
 
123
123
  # This mapping is affine, not linear, if the error measure has a non-zero mean.
124
- def mapping(data: "Vector") -> "Vector":
124
+ def mapping(data: Vector) -> Vector:
125
125
  shifted_data = self.forward_problem.data_space.subtract(
126
126
  data, self.forward_problem.data_error_measure.expectation
127
127
  )
@@ -162,13 +162,13 @@ class LinearMinimumNormInversion(Inversion):
162
162
  solver: "LinearSolver",
163
163
  /,
164
164
  *,
165
- preconditioner: Optional["LinearOperator"] = None,
165
+ preconditioner: Optional[LinearOperator] = None,
166
166
  significance_level: float = 0.95,
167
167
  minimum_damping: float = 0.0,
168
168
  maxiter: int = 100,
169
169
  rtol: float = 1.0e-6,
170
170
  atol: float = 0.0,
171
- ) -> Union[Operator, "LinearOperator"]:
171
+ ) -> Union[Operator, LinearOperator]:
172
172
  """
173
173
  Returns an operator that maps data to the minimum-norm solution.
174
174
 
@@ -196,8 +196,8 @@ class LinearMinimumNormInversion(Inversion):
196
196
  lsq_inversion = LinearLeastSquaresInversion(self.forward_problem)
197
197
 
198
198
  def get_model_for_damping(
199
- damping: float, data: "Vector", model0: Optional["Vector"] = None
200
- ) -> tuple["Vector", float]:
199
+ damping: float, data: Vector, model0: Optional[Vector] = None
200
+ ) -> tuple[Vector, float]:
201
201
  """Computes the LS model and its chi-squared for a given damping."""
202
202
  op = lsq_inversion.least_squares_operator(
203
203
  damping, solver, preconditioner=preconditioner
@@ -206,7 +206,7 @@ class LinearMinimumNormInversion(Inversion):
206
206
  chi_squared = self.forward_problem.chi_squared(model, data)
207
207
  return model, chi_squared
208
208
 
209
- def mapping(data: "Vector") -> "Vector":
209
+ def mapping(data: Vector) -> Vector:
210
210
  """The non-linear mapping from data to the minimum-norm model."""
211
211
  model = self.model_space.zero
212
212
  chi_squared = self.forward_problem.chi_squared(model, data)
@@ -48,6 +48,19 @@ class DirectLinearSolver(LinearSolver):
48
48
  factorization.
49
49
  """
50
50
 
51
+ def __init__(
52
+ self, /, *, galerkin: bool = False, parallel: bool = False, n_jobs: int = -1
53
+ ):
54
+ """
55
+ Args:
56
+ galerkin (bool): If True, the Galerkin matrix representation is used.
57
+ parallel (bool): If True, parallel computation is used.
58
+ n_jobs (int): Number of parallel jobs.
59
+ """
60
+ self._galerkin: bool = galerkin
61
+ self._parallel: bool = parallel
62
+ self._n_jobs: int = n_jobs
63
+
51
64
 
52
65
  class LUSolver(DirectLinearSolver):
53
66
  """
@@ -55,12 +68,16 @@ class LUSolver(DirectLinearSolver):
55
68
  dense matrix representation.
56
69
  """
57
70
 
58
- def __init__(self, /, *, galerkin: bool = False) -> None:
71
+ def __init__(
72
+ self, /, *, galerkin: bool = False, parallel: bool = False, n_jobs: int = -1
73
+ ) -> None:
59
74
  """
60
75
  Args:
61
76
  galerkin (bool): If True, the Galerkin matrix representation is used.
77
+ parallel (bool): If True, parallel computation is used.
78
+ n_jobs (int): Number of parallel jobs.
62
79
  """
63
- self._galerkin: bool = galerkin
80
+ super().__init__(galerkin=galerkin, parallel=parallel, n_jobs=n_jobs)
64
81
 
65
82
  def __call__(self, operator: LinearOperator) -> LinearOperator:
66
83
  """
@@ -74,7 +91,12 @@ class LUSolver(DirectLinearSolver):
74
91
  """
75
92
  assert operator.is_square
76
93
 
77
- matrix = operator.matrix(dense=True, galerkin=self._galerkin)
94
+ matrix = operator.matrix(
95
+ dense=True,
96
+ galerkin=self._galerkin,
97
+ parallel=self._parallel,
98
+ n_jobs=self._n_jobs,
99
+ )
78
100
  factor = lu_factor(matrix, overwrite_a=True)
79
101
 
80
102
  def matvec(cy: np.ndarray) -> np.ndarray:
@@ -102,12 +124,16 @@ class CholeskySolver(DirectLinearSolver):
102
124
  representation is positive-definite.
103
125
  """
104
126
 
105
- def __init__(self, /, *, galerkin: bool = False) -> None:
127
+ def __init__(
128
+ self, /, *, galerkin: bool = False, parallel: bool = False, n_jobs: int = -1
129
+ ) -> None:
106
130
  """
107
131
  Args:
108
132
  galerkin (bool): If True, the Galerkin matrix representation is used.
133
+ parallel (bool): If True, parallel computation is used.
134
+ n_jobs (int): Number of parallel jobs.
109
135
  """
110
- self._galerkin: bool = galerkin
136
+ super().__init__(galerkin=galerkin, parallel=parallel, n_jobs=n_jobs)
111
137
 
112
138
  def __call__(self, operator: LinearOperator) -> LinearOperator:
113
139
  """
@@ -121,7 +147,12 @@ class CholeskySolver(DirectLinearSolver):
121
147
  """
122
148
  assert operator.is_automorphism
123
149
 
124
- matrix = operator.matrix(dense=True, galerkin=self._galerkin)
150
+ matrix = operator.matrix(
151
+ dense=True,
152
+ galerkin=self._galerkin,
153
+ parallel=self._parallel,
154
+ n_jobs=self._n_jobs,
155
+ )
125
156
  factor = cho_factor(matrix, overwrite_a=False)
126
157
 
127
158
  def matvec(cy: np.ndarray) -> np.ndarray:
@@ -173,9 +204,7 @@ class IterativeLinearSolver(LinearSolver):
173
204
  Solves the adjoint linear system A*y = x for y.
174
205
  """
175
206
  adjoint_preconditioner = (
176
- None
177
- if preconditioner is None
178
- else preconditioner.adjoint
207
+ None if preconditioner is None else preconditioner.adjoint
179
208
  )
180
209
  return self.solve_linear_system(operator.adjoint, adjoint_preconditioner, x, y0)
181
210
 
pygeoinf/operators.py CHANGED
@@ -35,12 +35,36 @@ from .random_matrix import (
35
35
  random_eig as rm_eig,
36
36
  )
37
37
 
38
+ from .parallel import parallel_compute_dense_matrix_from_scipy_op
39
+
38
40
  # This block only runs for type checkers, not at runtime
39
41
  if TYPE_CHECKING:
40
42
  from .hilbert_space import HilbertSpace, EuclideanSpace
41
43
  from .linear_forms import LinearForm
42
44
 
43
45
 
46
+ def _parallel_scipy_op_col(scipy_op: ScipyLinOp, j: int, domain_dim: int) -> np.ndarray:
47
+ """
48
+ A top-level helper that applies a scipy.LinearOperator to a basis vector.
49
+
50
+ This function is simple and serializable ("picklable").
51
+
52
+ Args:
53
+ scipy_op: The SciPy LinearOperator wrapper for the matrix action.
54
+ j: The index of the basis vector (column) to compute.
55
+ domain_dim: The dimension of the domain space.
56
+
57
+ Returns:
58
+ The j-th column of the dense matrix as a NumPy array.
59
+ """
60
+ # Create the j-th component basis vector
61
+ cx = np.zeros(domain_dim)
62
+ cx[j] = 1.0
63
+
64
+ # Apply the SciPy wrapper, which handles all necessary conversions
65
+ return scipy_op @ cx
66
+
67
+
44
68
  class Operator:
45
69
  """
46
70
  A general, potentially non-linear operator between two Hilbert spaces.
@@ -489,7 +513,13 @@ class LinearOperator(Operator):
489
513
  return self._thread_safe
490
514
 
491
515
  def matrix(
492
- self, /, *, dense: bool = False, galerkin: bool = False
516
+ self,
517
+ /,
518
+ *,
519
+ dense: bool = False,
520
+ galerkin: bool = False,
521
+ parallel: bool = False,
522
+ n_jobs: int = -1,
493
523
  ) -> Union[ScipyLinOp, np.ndarray]:
494
524
  """
495
525
  Returns a matrix representation of the operator.
@@ -529,12 +559,17 @@ class LinearOperator(Operator):
529
559
  self-adjoint, its Galerkin matrix will be **symmetric**, which is a
530
560
  prerequisite for algorithms like the Conjugate Gradient method.
531
561
 
562
+ parallel (bool): If True, use parallel computing. Defaults to False.
563
+ This is only relevant for dense matrices.
564
+ n_jobs (int): Number of parallel jobs. Defaults to -1.
565
+ This is only relevant for dense matrices.
566
+
532
567
  Returns:
533
568
  Union[ScipyLinOp, np.ndarray]: The matrix representation of the
534
569
  operator, either as a dense array or a matrix-free object.
535
570
  """
536
571
  if dense:
537
- return self._compute_dense_matrix(galerkin)
572
+ return self._compute_dense_matrix(galerkin, parallel, n_jobs)
538
573
  else:
539
574
  if galerkin:
540
575
 
@@ -597,9 +632,35 @@ class LinearOperator(Operator):
597
632
  galerkin: bool = False,
598
633
  rtol: float = 1e-3,
599
634
  method: str = "fixed",
635
+ parallel: bool = False,
636
+ n_jobs: int = -1,
600
637
  ) -> Tuple[LinearOperator, "DiagonalLinearOperator", LinearOperator]:
601
638
  """
602
639
  Computes an approximate SVD using a randomized algorithm.
640
+
641
+ Args:
642
+ rank (int): The desired rank of the SVD.
643
+ power (int): The power of the random matrix.
644
+ galerkin (bool): If True, use the Galerkin representation.
645
+ rtol (float): The relative tolerance for the SVD.
646
+ method (str): The method to use for the SVD.
647
+ - "fixed": Use a fixed rank SVD.
648
+ - "variable": Use a variable rank SVD.
649
+ parallel (bool): If True, use parallel computing. Defaults to False.
650
+ Only used with fixed rank method.
651
+ n_jobs (int): Number of parallel jobs. Defaults to -1.
652
+ Only used with fixed rank method.
653
+
654
+ Returns:
655
+ left (LinearOperator): The left singular vector matrix.
656
+ singular_values (DiagonalLinearOperator): The singular values.
657
+ right (LinearOperator): The right singular vector matrix.
658
+
659
+ Notes:
660
+ The right factor is in transposed form. This means the original
661
+ operator can be approximated as:
662
+ A = left @ singular_values @ right
663
+
603
664
  """
604
665
  from .hilbert_space import EuclideanSpace
605
666
 
@@ -610,7 +671,9 @@ class LinearOperator(Operator):
610
671
 
611
672
  qr_factor: np.ndarray
612
673
  if method == "fixed":
613
- qr_factor = fixed_rank_random_range(matrix, rank, power=power)
674
+ qr_factor = fixed_rank_random_range(
675
+ matrix, rank, power=power, parallel=parallel, n_jobs=n_jobs
676
+ )
614
677
  elif method == "variable":
615
678
  qr_factor = variable_rank_random_range(matrix, rank, power=power, rtol=rtol)
616
679
  else:
@@ -648,10 +711,34 @@ class LinearOperator(Operator):
648
711
  power: int = 0,
649
712
  rtol: float = 1e-3,
650
713
  method: str = "fixed",
714
+ parallel: bool = False,
715
+ n_jobs: int = -1,
651
716
  ) -> Tuple[LinearOperator, "DiagonalLinearOperator"]:
652
717
  """
653
718
  Computes an approximate eigendecomposition for a self-adjoint
654
719
  operator using a randomized algorithm.
720
+
721
+ Args:
722
+ rank (int): The desired rank of the eigendecomposition.
723
+ power (int): The power of the random matrix.
724
+ rtol (float): The relative tolerance for the eigendecomposition.
725
+ method (str): The method to use for the eigendecomposition.
726
+ - "fixed": Use a fixed rank eigendecomposition.
727
+ - "variable": Use a variable rank eigendecomposition.
728
+ parallel (bool): If True, use parallel computing. Defaults to False.
729
+ Only used with fixed rank method.
730
+ n_jobs (int): Number of parallel jobs. Defaults to -1.
731
+ Only used with fixed rank method.
732
+
733
+ Returns:
734
+ expansion (LinearOperator): A linear operator that maps coefficients
735
+ in the eigen-basis to the resulting vector.
736
+ eigenvalues (DiagonalLinearOperator): The eigenvalues.
737
+
738
+ Notes:
739
+ The original operator can be approximated as:
740
+ A = expansion @ eigenvalues @ expansion.adjoint
741
+
655
742
  """
656
743
  from .hilbert_space import EuclideanSpace
657
744
 
@@ -663,7 +750,9 @@ class LinearOperator(Operator):
663
750
 
664
751
  qr_factor: np.ndarray
665
752
  if method == "fixed":
666
- qr_factor = fixed_rank_random_range(matrix, rank, power=power)
753
+ qr_factor = fixed_rank_random_range(
754
+ matrix, rank, power=power, parallel=parallel, n_jobs=n_jobs
755
+ )
667
756
  elif method == "variable":
668
757
  qr_factor = variable_rank_random_range(matrix, rank, power=power, rtol=rtol)
669
758
  else:
@@ -687,11 +776,34 @@ class LinearOperator(Operator):
687
776
  power: int = 0,
688
777
  rtol: float = 1e-3,
689
778
  method: str = "fixed",
779
+ parallel: bool = False,
780
+ n_jobs: int = -1,
690
781
  ) -> LinearOperator:
691
782
  """
692
783
  Computes an approximate Cholesky decomposition for a positive-definite
693
784
  self-adjoint operator using a randomized algorithm.
785
+
786
+ Args:
787
+ rank (int): The desired rank of the Cholesky decomposition.
788
+ power (int): The power of the random matrix.
789
+ rtol (float): The relative tolerance for the Cholesky decomposition.
790
+ method (str): The method to use for the Cholesky decomposition.
791
+ - "fixed": Use a fixed rank Cholesky decomposition.
792
+ - "variable": Use a variable rank Cholesky decomposition.
793
+ parallel (bool): If True, use parallel computing. Defaults to False.
794
+ Only used with fixed rank method.
795
+ n_jobs (int): Number of parallel jobs. Defaults to -1.
796
+ Only used with fixed rank method.
797
+
798
+ Returns:
799
+ factor (LinearOperator): A linear operator from a Euclidean space
800
+ into the domain of the operator.
801
+
802
+ Notes:
803
+ The original operator can be approximated as:
804
+ A = factor @ factor.adjoint
694
805
  """
806
+
695
807
  from .hilbert_space import EuclideanSpace
696
808
 
697
809
  assert self.is_automorphism
@@ -702,7 +814,9 @@ class LinearOperator(Operator):
702
814
 
703
815
  qr_factor: np.ndarray
704
816
  if method == "fixed":
705
- qr_factor = fixed_rank_random_range(matrix, rank, power=power)
817
+ qr_factor = fixed_rank_random_range(
818
+ matrix, rank, power=power, parallel=parallel, n_jobs=n_jobs
819
+ )
706
820
  elif method == "variable":
707
821
  qr_factor = variable_rank_random_range(matrix, rank, power=power, rtol=rtol)
708
822
  else:
@@ -732,15 +846,24 @@ class LinearOperator(Operator):
732
846
  xp = self.__dual_mapping(yp)
733
847
  return self.domain.from_dual(xp)
734
848
 
735
- def _compute_dense_matrix(self, galerkin: bool = False) -> np.ndarray:
736
- matrix = np.zeros((self.codomain.dim, self.domain.dim))
737
- a = self.matrix(galerkin=galerkin)
738
- cx = np.zeros(self.domain.dim)
739
- for i in range(self.domain.dim):
740
- cx[i] = 1.0
741
- matrix[:, i] = (a @ cx)[:]
742
- cx[i] = 0.0
743
- return matrix
849
+ def _compute_dense_matrix(
850
+ self, galerkin: bool, parallel: bool, n_jobs: int
851
+ ) -> np.ndarray:
852
+
853
+ scipy_op_wrapper = self.matrix(galerkin=galerkin)
854
+
855
+ if not parallel:
856
+ matrix = np.zeros((self.codomain.dim, self.domain.dim))
857
+ cx = np.zeros(self.domain.dim)
858
+ for i in range(self.domain.dim):
859
+ cx[i] = 1.0
860
+ matrix[:, i] = (scipy_op_wrapper @ cx)[:]
861
+ cx[i] = 0.0
862
+ return matrix
863
+ else:
864
+ return parallel_compute_dense_matrix_from_scipy_op(
865
+ scipy_op_wrapper, n_jobs=n_jobs
866
+ )
744
867
 
745
868
  def __neg__(self) -> LinearOperator:
746
869
  domain = self.domain
pygeoinf/parallel.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ A collection of helper functions for parallel computation using Joblib.
3
+
4
+ These functions are designed to be top-level to ensure they can be
5
+ "pickled" (serialized) and sent to worker processes by libraries like
6
+ multiprocessing or its wrapper, Joblib.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ from typing import TYPE_CHECKING, Union
11
+ import numpy as np
12
+ from joblib import Parallel, delayed
13
+
14
+ if TYPE_CHECKING:
15
+ from scipy.sparse.linalg import LinearOperator as ScipyLinOp
16
+
17
+ MatrixLike = Union[np.ndarray, ScipyLinOp]
18
+
19
+
20
+ def parallel_mat_mat(A: "MatrixLike", B: np.ndarray, n_jobs: int = -1) -> np.ndarray:
21
+ """
22
+ Computes the matrix product A @ B in parallel by applying A to each column of B.
23
+
24
+ This is particularly useful when A is a LinearOperator whose action is
25
+ computationally expensive.
26
+
27
+ Args:
28
+ A: The matrix or LinearOperator to apply.
29
+ B: The matrix whose columns will be operated on.
30
+ n_jobs: The number of CPU cores to use. -1 means all available.
31
+
32
+ Returns:
33
+ The result of the matrix product A @ B as a dense NumPy array.
34
+ """
35
+ columns = Parallel(n_jobs=n_jobs)(
36
+ delayed(A.__matmul__)(B[:, i]) for i in range(B.shape[1])
37
+ )
38
+ return np.column_stack(columns)
39
+
40
+
41
+ def parallel_compute_dense_matrix_from_scipy_op(
42
+ scipy_op: "ScipyLinOp", n_jobs: int = -1
43
+ ) -> np.ndarray:
44
+ """
45
+ Computes the dense matrix representation of a scipy.LinearOperator in parallel.
46
+
47
+ It builds the matrix column by column by applying the operator to each
48
+ basis vector.
49
+
50
+ Args:
51
+ scipy_op: The SciPy LinearOperator wrapper for the matrix action.
52
+ n_jobs: The number of CPU cores to use. -1 means all available.
53
+
54
+ Returns:
55
+ The dense matrix as a NumPy array.
56
+ """
57
+ codomain_dim, domain_dim = scipy_op.shape
58
+ columns = Parallel(n_jobs=n_jobs)(
59
+ delayed(_worker_compute_scipy_op_col)(scipy_op, j, domain_dim)
60
+ for j in range(domain_dim)
61
+ )
62
+ return np.column_stack(columns)
63
+
64
+
65
+ def _worker_compute_scipy_op_col(
66
+ scipy_op: "ScipyLinOp", j: int, domain_dim: int
67
+ ) -> np.ndarray:
68
+ """
69
+ (Internal worker) Computes a single column of a SciPy LinearOperator's matrix.
70
+ """
71
+ cx = np.zeros(domain_dim)
72
+ cx[j] = 1.0
73
+ return scipy_op @ cx
pygeoinf/random_matrix.py CHANGED
@@ -25,13 +25,18 @@ from scipy.linalg import (
25
25
  )
26
26
  from scipy.sparse.linalg import LinearOperator as ScipyLinOp
27
27
 
28
+ from .parallel import parallel_mat_mat
28
29
 
29
30
  # A type for objects that act like matrices (numpy arrays or SciPy LinearOperators)
30
31
  MatrixLike = Union[np.ndarray, ScipyLinOp]
31
32
 
32
33
 
33
34
  def fixed_rank_random_range(
34
- matrix: MatrixLike, rank: int, power: int = 0
35
+ matrix: MatrixLike,
36
+ rank: int,
37
+ power: int = 0,
38
+ parallel: bool = False,
39
+ n_jobs: int = -1,
35
40
  ) -> np.ndarray:
36
41
  """
37
42
  Computes an orthonormal basis for a fixed-rank approximation of a matrix's range.
@@ -53,17 +58,29 @@ def fixed_rank_random_range(
53
58
  Notes:
54
59
  Based on Algorithm 4.4 in Halko et al. 2011.
55
60
  """
56
-
57
61
  m, n = matrix.shape
58
62
  random_matrix = np.random.randn(n, rank)
59
63
 
60
- product_matrix = matrix @ random_matrix
64
+ if parallel:
65
+ product_matrix = parallel_mat_mat(matrix, random_matrix, n_jobs)
66
+ else:
67
+ product_matrix = matrix @ random_matrix
68
+
61
69
  qr_factor, _ = qr(product_matrix, overwrite_a=True, mode="economic")
62
70
 
63
71
  for _ in range(power):
64
- tilde_product_matrix = matrix.T @ qr_factor
72
+ if parallel:
73
+ tilde_product_matrix = parallel_mat_mat(matrix.T, qr_factor, n_jobs)
74
+ else:
75
+ tilde_product_matrix = matrix.T @ qr_factor
76
+
65
77
  tilde_qr_factor, _ = qr(tilde_product_matrix, overwrite_a=True, mode="economic")
66
- product_matrix = matrix @ tilde_qr_factor
78
+
79
+ if parallel:
80
+ product_matrix = parallel_mat_mat(matrix, tilde_qr_factor, n_jobs)
81
+ else:
82
+ product_matrix = matrix @ tilde_qr_factor
83
+
67
84
  qr_factor, _ = qr(product_matrix, overwrite_a=True, mode="economic")
68
85
 
69
86
  return qr_factor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pygeoinf
3
- Version: 1.1.8
3
+ Version: 1.2.0
4
4
  Summary: A package for solving geophysical inference and inverse problems
5
5
  License: BSD-3-Clause
6
6
  Author: David Al-Attar and Dan Heathcote
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.11
11
11
  Classifier: Programming Language :: Python :: 3.12
12
12
  Classifier: Programming Language :: Python :: 3.13
13
13
  Requires-Dist: Cartopy (>=0.23.0,<0.24.0)
14
+ Requires-Dist: joblib (>=1.5.2,<2.0.0)
14
15
  Requires-Dist: matplotlib (>=3.0.0)
15
16
  Requires-Dist: numpy (>=1.26.0)
16
17
  Requires-Dist: pyqt6 (>=6.0.0)
@@ -1,20 +1,21 @@
1
1
  pygeoinf/__init__.py,sha256=uYRwiHKi4nvzUBwQJxNdt8OZodFQ_nj5rUv2YkDXL9Q,1185
2
- pygeoinf/direct_sum.py,sha256=r0h-3bK6RQiOMoq086oupYaXgUM4pFIV8XNKOB2q9BE,17095
2
+ pygeoinf/direct_sum.py,sha256=ppBWJLjwF1MPF3bL1tJf_ADt_jZw4tnxhiC1DUqPEew,20919
3
3
  pygeoinf/forward_problem.py,sha256=RnnVBMg8Ih7TqZylfSkQ7pdHEowEfCkTiXiKFrxBpnM,9754
4
- pygeoinf/gaussian_measure.py,sha256=SFnHmEjpcrUy09AfO3fXLLB_SaGwA9oc5PU630e3hNM,22826
4
+ pygeoinf/gaussian_measure.py,sha256=X31D-NoBB5f7uagIRsAjTPqAJqcyTOpgUUvBnaWmiAw,22872
5
5
  pygeoinf/hilbert_space.py,sha256=90aaUPUBCqsEuXroOwCmvbRFhi1vf6F9bS4pU3DCseI,23369
6
6
  pygeoinf/inversion.py,sha256=p9k_iDVgJGLM1cGlT-0rgRwqdYVdsYC_euTXZk3kuOc,3199
7
7
  pygeoinf/linear_bayesian.py,sha256=aIOzTZbjJtdtwHKh5e01iS8iMiyr8XuwGx91udS3VK4,9624
8
8
  pygeoinf/linear_forms.py,sha256=Uizipi67i1Sd6m0TzsrJd99Xreo_6V8Db0gMy76fG6g,5953
9
- pygeoinf/linear_optimisation.py,sha256=7lklTRRBGkz8M9WsfvkDl-eoGkc4Ty7BOJq7LWkdxCg,11091
10
- pygeoinf/linear_solvers.py,sha256=zymKX9oZCkhxWbKxP4ZH8g-GDC5gf_rFhlx7viWZo4Q,12294
11
- pygeoinf/operators.py,sha256=kbDfHpaWaSnd0zvoAVMBpWWiQRNfW0ImVhtzEEIH6mM,33165
12
- pygeoinf/random_matrix.py,sha256=_XVwXqM_c0SMpy6JU-8IboXpotug6dDDHKdSPAJpH7c,7788
9
+ pygeoinf/linear_optimisation.py,sha256=zWUMQpuLlEXtA3yJ55sT8809THbU_oQlLUwjroAbZbU,11067
10
+ pygeoinf/linear_solvers.py,sha256=USeUPa0zZ7gXk5OnvEw1LzCxCZoQIKhdwZZn-KNU7jA,13371
11
+ pygeoinf/operators.py,sha256=_t_UqYBIk4rWdRe98hre39sPaoRRbNejOtcvabtf0x8,38010
12
+ pygeoinf/parallel.py,sha256=E148IAKiXojWe6sq3iYtHl1XGAW8w6xaYbI7LOM9oKc,2269
13
+ pygeoinf/random_matrix.py,sha256=Q9jgQVpMOy8jR3s057DzHeKrKLdvwRktF-n_oVZ0xbs,8231
13
14
  pygeoinf/symmetric_space/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
15
  pygeoinf/symmetric_space/circle.py,sha256=eUZPIDwg19RTCK3-bvbARg1g9RQcnV462MTlDaI-l5Y,17716
15
16
  pygeoinf/symmetric_space/sphere.py,sha256=yjmES34jWLi4-EhU_RDILYn1y7_YrvgrTyT2qL2mLVg,21534
16
17
  pygeoinf/symmetric_space/symmetric_space.py,sha256=lEAshbb55qL0iX84H423Pt35Af89Iy2pfB18JCPheX8,17970
17
- pygeoinf-1.1.8.dist-info/LICENSE,sha256=GrTQnKJemVi69FSbHprq60KN0OJGsOSR-joQoTq-oD8,1501
18
- pygeoinf-1.1.8.dist-info/METADATA,sha256=uW521FvGxfmGJLmkfLLOIL1XQfIhJVJfKaoJSznEV5I,15324
19
- pygeoinf-1.1.8.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
20
- pygeoinf-1.1.8.dist-info/RECORD,,
18
+ pygeoinf-1.2.0.dist-info/LICENSE,sha256=GrTQnKJemVi69FSbHprq60KN0OJGsOSR-joQoTq-oD8,1501
19
+ pygeoinf-1.2.0.dist-info/METADATA,sha256=Q9Wo26AZXTTmUy7Fc8AVyhqDlEME7gDQ2udkImPATGo,15363
20
+ pygeoinf-1.2.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
21
+ pygeoinf-1.2.0.dist-info/RECORD,,