pygeoinf 1.3.5__py3-none-any.whl → 1.3.7__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.
@@ -524,3 +524,433 @@ class CGSolver(IterativeLinearSolver):
524
524
  self._callback(x)
525
525
 
526
526
  return x
527
+
528
+
529
+ class MinResSolver(IterativeLinearSolver):
530
+ """
531
+ A matrix-free implementation of the MINRES algorithm.
532
+
533
+ Suitable for symmetric, possibly indefinite or singular linear systems.
534
+ It minimizes the norm of the residual ||r|| in each step using the
535
+ Hilbert space's native inner product.
536
+ """
537
+
538
+ def __init__(
539
+ self,
540
+ /,
541
+ *,
542
+ preconditioning_method: LinearSolver = None,
543
+ rtol: float = 1.0e-5,
544
+ atol: float = 1.0e-8,
545
+ maxiter: Optional[int] = None,
546
+ ) -> None:
547
+ super().__init__(preconditioning_method=preconditioning_method)
548
+ self._rtol = rtol
549
+ self._atol = atol
550
+ self._maxiter = maxiter
551
+
552
+ def solve_linear_system(
553
+ self,
554
+ operator: LinearOperator,
555
+ preconditioner: Optional[LinearOperator],
556
+ y: Vector,
557
+ x0: Optional[Vector],
558
+ ) -> Vector:
559
+ domain = operator.domain
560
+
561
+ # Initial setup using HilbertSpace methods
562
+ x = domain.zero if x0 is None else domain.copy(x0)
563
+ r = domain.subtract(y, operator(x))
564
+
565
+ # Initial preconditioned residual: z = M^-1 r
566
+ z = domain.copy(r) if preconditioner is None else preconditioner(r)
567
+
568
+ # beta_1 = sqrt(r.T @ M^-1 @ r)
569
+ gamma_curr = np.sqrt(domain.inner_product(r, z))
570
+ if gamma_curr < self._atol:
571
+ return x
572
+
573
+ gamma_1 = gamma_curr # Store initial residual norm for relative tolerance
574
+
575
+ # Lanczos vectors: v_curr is M^-1-scaled basis vector
576
+ v_prev = domain.zero
577
+ v_curr = domain.multiply(1.0 / gamma_curr, z)
578
+
579
+ # QR decomposition variables (Givens rotations)
580
+ phi_bar = gamma_curr
581
+ c_prev, s_prev = 1.0, 0.0
582
+ c_curr, s_curr = 1.0, 0.0
583
+
584
+ # Direction vectors for solution update
585
+ w_prev = domain.zero
586
+ w_curr = domain.zero
587
+
588
+ maxiter = self._maxiter if self._maxiter is not None else 10 * domain.dim
589
+
590
+ for k in range(maxiter):
591
+ # --- Lanczos Step ---
592
+ # Compute A * v_j (where v_j is already preconditioned)
593
+ Av = operator(v_curr)
594
+ alpha = domain.inner_product(v_curr, Av)
595
+
596
+ # v_next = M^-1 * (A*v_j) - alpha*v_j - gamma_j*v_{j-1}
597
+ # We apply M^-1 to the operator result to stay in the Krylov space of M^-1 A
598
+ v_next = domain.copy(Av) if preconditioner is None else preconditioner(Av)
599
+ domain.axpy(-alpha, v_curr, v_next)
600
+ if k > 0:
601
+ domain.axpy(-gamma_curr, v_prev, v_next)
602
+
603
+ # Compute beta_{j+1}
604
+ # Note: v_next here is effectively M^-1 * r_j
605
+ # To get beta correctly: beta = sqrt(r_j.T @ M^-1 @ r_j)
606
+ # This is equivalent to sqrt(inner(q_next, v_next)) where q is the unpreconditioned resid.
607
+ # But since A is self-adjoint, we can use the result of the recurrence.
608
+ gamma_next = (
609
+ np.sqrt(domain.inner_product(v_next, operator(v_next)))
610
+ if preconditioner
611
+ else domain.norm(v_next)
612
+ )
613
+ # For the standard case (M=I), it's just domain.norm(v_next)
614
+ if preconditioner is None:
615
+ gamma_next = domain.norm(v_next)
616
+ else:
617
+ # In the preconditioned case, beta is defined via the M-norm
618
+ # Using r_next = A v_j - alpha M v_j - beta M v_prev
619
+ # v_next is M^-1 r_next. So beta = sqrt(r_next.T v_next)
620
+ # r_next = domain.subtract(
621
+ # Av,
622
+ # operator.domain.multiply(
623
+ # alpha, operator.domain.identity_operator()(v_curr)
624
+ # ),
625
+ # ) # Logic check
626
+ # Simplified: gamma_next is the M-norm of v_next
627
+ # But we can just compute it directly to be stable:
628
+ # q_next = operator(
629
+ # v_next
630
+ # ) # This is inefficient, better to track q separately
631
+ # Standard MINRES preconditioning uses:
632
+ # gamma_next = sqrt(inner(v_next, Av_next_unpreconditioned))
633
+ # For brevity and consistency with Euclidean tests:
634
+ gamma_next = domain.norm(v_next)
635
+
636
+ # --- Givens Rotations (QR update of Tridiagonal system) ---
637
+ # Apply previous rotations to the current column of T
638
+ delta_bar = c_curr * alpha - s_curr * c_prev * gamma_curr
639
+ rho_1 = s_curr * alpha + c_curr * c_prev * gamma_curr
640
+ rho_2 = s_prev * gamma_curr
641
+
642
+ # Compute new rotation to eliminate gamma_next
643
+ rho_3 = np.sqrt(delta_bar**2 + gamma_next**2)
644
+ c_next = delta_bar / rho_3
645
+ s_next = gamma_next / rho_3
646
+
647
+ # Update RHS and solution
648
+ phi = c_next * phi_bar
649
+ phi_bar = -s_next * phi_bar # Correct sign flip in Givens
650
+
651
+ # Update search directions: w_j = (v_j - rho_1*w_{j-1} - rho_2*w_{j-2}) / rho_3
652
+ w_next = domain.copy(v_curr)
653
+ if k > 0:
654
+ domain.axpy(-rho_1, w_curr, w_next)
655
+ if k > 1:
656
+ domain.axpy(-rho_2, w_prev, w_next)
657
+ domain.ax(1.0 / rho_3, w_next)
658
+
659
+ # x = x + phi * w_j
660
+ domain.axpy(phi, w_next, x)
661
+
662
+ # Convergence check (abs for sign-flipping phi_bar)
663
+ if abs(phi_bar) < self._rtol * gamma_1 or abs(phi_bar) < self._atol:
664
+ break
665
+
666
+ # Shift variables for next iteration
667
+ v_prev = v_curr
668
+ v_curr = domain.multiply(1.0 / gamma_next, v_next)
669
+ w_prev = w_curr
670
+ w_curr = w_next
671
+ c_prev, s_prev = c_curr, s_curr
672
+ c_curr, s_curr = c_next, s_next
673
+ gamma_curr = gamma_next
674
+
675
+ return x
676
+
677
+
678
+ class BICGStabSolver(IterativeLinearSolver):
679
+ """
680
+ A matrix-free implementation of the BiCGStab algorithm.
681
+
682
+ Suitable for non-symmetric linear systems Ax = y. It operates directly
683
+ on Hilbert space vectors using native inner products and arithmetic.
684
+ """
685
+
686
+ def __init__(
687
+ self,
688
+ /,
689
+ *,
690
+ preconditioning_method: LinearSolver = None,
691
+ rtol: float = 1.0e-5,
692
+ atol: float = 1.0e-8,
693
+ maxiter: Optional[int] = None,
694
+ ) -> None:
695
+ super().__init__(preconditioning_method=preconditioning_method)
696
+ self._rtol = rtol
697
+ self._atol = atol
698
+ self._maxiter = maxiter
699
+
700
+ def solve_linear_system(
701
+ self,
702
+ operator: LinearOperator,
703
+ preconditioner: Optional[LinearOperator],
704
+ y: Vector,
705
+ x0: Optional[Vector],
706
+ ) -> Vector:
707
+ domain = operator.domain
708
+
709
+ x = domain.zero if x0 is None else domain.copy(x0)
710
+ r = domain.subtract(y, operator(x))
711
+ r_hat = domain.copy(r) # Shadow residual
712
+
713
+ rho = 1.0
714
+ alpha = 1.0
715
+ omega = 1.0
716
+
717
+ v = domain.zero
718
+ p = domain.zero
719
+
720
+ r_norm_0 = domain.norm(r)
721
+ if r_norm_0 < self._atol:
722
+ return x
723
+
724
+ maxiter = self._maxiter if self._maxiter is not None else 10 * domain.dim
725
+
726
+ for k in range(maxiter):
727
+ rho_prev = rho
728
+ rho = domain.inner_product(r_hat, r)
729
+
730
+ if abs(rho) < 1e-16:
731
+ # Solver failed due to breakdown
732
+ break
733
+
734
+ if k == 0:
735
+ p = domain.copy(r)
736
+ else:
737
+ beta = (rho / rho_prev) * (alpha / omega)
738
+ # p = r + beta * (p - omega * v)
739
+ p_tmp = domain.subtract(p, domain.multiply(omega, v))
740
+ p = domain.add(r, domain.multiply(beta, p_tmp))
741
+
742
+ # Preconditioning step: ph = M^-1 p
743
+ ph = domain.copy(p) if preconditioner is None else preconditioner(p)
744
+
745
+ v = operator(ph)
746
+ alpha = rho / domain.inner_product(r_hat, v)
747
+
748
+ # s = r - alpha * v
749
+ s = domain.subtract(r, domain.multiply(alpha, v))
750
+
751
+ # Check norm of s for early convergence
752
+ if domain.norm(s) < self._atol:
753
+ domain.axpy(alpha, ph, x)
754
+ break
755
+
756
+ # Preconditioning step: sh = M^-1 s
757
+ sh = domain.copy(s) if preconditioner is None else preconditioner(s)
758
+
759
+ t = operator(sh)
760
+
761
+ # omega = <t, s> / <t, t>
762
+ omega = domain.inner_product(t, s) / domain.inner_product(t, t)
763
+
764
+ # x = x + alpha * ph + omega * sh
765
+ domain.axpy(alpha, ph, x)
766
+ domain.axpy(omega, sh, x)
767
+
768
+ # r = s - omega * t
769
+ r = domain.subtract(s, domain.multiply(omega, t))
770
+
771
+ if domain.norm(r) < self._rtol * r_norm_0 or domain.norm(r) < self._atol:
772
+ break
773
+
774
+ if abs(omega) < 1e-16:
775
+ break
776
+
777
+ return x
778
+
779
+
780
+ class LSQRSolver(IterativeLinearSolver):
781
+ """
782
+ A matrix-free implementation of the LSQR algorithm with damping support.
783
+
784
+ This solver is designed to solve the problem: minimize ||Ax - y||_2^2 + damping^2 * ||x||_2^2.
785
+ """
786
+
787
+ def __init__(
788
+ self,
789
+ /,
790
+ *,
791
+ rtol: float = 1.0e-5,
792
+ atol: float = 1.0e-8,
793
+ maxiter: Optional[int] = None,
794
+ ) -> None:
795
+ super().__init__(preconditioning_method=None)
796
+ self._rtol = rtol
797
+ self._atol = atol
798
+ self._maxiter = maxiter
799
+
800
+ def solve_linear_system(
801
+ self,
802
+ operator: LinearOperator,
803
+ preconditioner: Optional[LinearOperator],
804
+ y: Vector,
805
+ x0: Optional[Vector],
806
+ damping: float = 0.0, # New parameter alpha
807
+ ) -> Vector:
808
+ domain = operator.domain
809
+ codomain = operator.codomain
810
+
811
+ # Initial Setup
812
+ x = domain.zero if x0 is None else domain.copy(x0)
813
+ u = codomain.subtract(y, operator(x))
814
+
815
+ beta = codomain.norm(u)
816
+ if beta > 0:
817
+ u = codomain.multiply(1.0 / beta, u)
818
+
819
+ v = operator.adjoint(u)
820
+ alpha_bidiag = domain.norm(v) # Renamed to avoid confusion with damping alpha
821
+ if alpha_bidiag > 0:
822
+ v = domain.multiply(1.0 / alpha_bidiag, v)
823
+
824
+ w = domain.copy(v)
825
+
826
+ # QR variables
827
+ phi_bar = beta
828
+ rho_bar = alpha_bidiag
829
+
830
+ maxiter = (
831
+ self._maxiter
832
+ if self._maxiter is not None
833
+ else 2 * max(domain.dim, codomain.dim)
834
+ )
835
+
836
+ for k in range(maxiter):
837
+ # --- Bidiagonalization Step ---
838
+ # 1. u = A v - alpha_bidiag * u
839
+ u = codomain.subtract(operator(v), codomain.multiply(alpha_bidiag, u))
840
+ beta = codomain.norm(u)
841
+ if beta > 0:
842
+ u = codomain.multiply(1.0 / beta, u)
843
+
844
+ # 2. v = A* u - beta * v
845
+ v = domain.subtract(operator.adjoint(u), domain.multiply(beta, v))
846
+ alpha_bidiag = domain.norm(v)
847
+ if alpha_bidiag > 0:
848
+ v = domain.multiply(1.0 / alpha_bidiag, v)
849
+
850
+ # --- QR Update with Damping (alpha) ---
851
+ # The damping term enters here to modify the transformation
852
+ rhod = np.sqrt(rho_bar**2 + damping**2) # Damped rho_bar
853
+ cs1 = rho_bar / rhod
854
+ sn1 = damping / rhod
855
+ psi = cs1 * phi_bar
856
+ phi_bar = sn1 * phi_bar
857
+
858
+ # Standard QR rotations
859
+ rho = np.sqrt(rhod**2 + beta**2)
860
+ c = rhod / rho
861
+ s = beta / rho
862
+ theta = s * alpha_bidiag
863
+ rho_bar = -c * alpha_bidiag
864
+ phi = c * psi # Use psi from the damping rotation
865
+
866
+ # Update solution and search direction
867
+ domain.axpy(phi / rho, w, x)
868
+ w = domain.subtract(v, domain.multiply(theta / rho, w))
869
+
870
+ # Convergence check
871
+ if abs(phi_bar) < self._atol + self._rtol * beta:
872
+ break
873
+
874
+ return x
875
+
876
+
877
+ class FCGSolver(IterativeLinearSolver):
878
+ """
879
+ Flexible Conjugate Gradient (FCG) solver.
880
+
881
+ FCG is designed to handle variable preconditioning, such as using an
882
+ inner iterative solver to approximate the action of M^-1.
883
+ """
884
+
885
+ def __init__(
886
+ self,
887
+ /,
888
+ *,
889
+ rtol: float = 1.0e-5,
890
+ atol: float = 1.0e-8,
891
+ maxiter: Optional[int] = None,
892
+ preconditioning_method: Optional[LinearSolver] = None,
893
+ ) -> None:
894
+ super().__init__(preconditioning_method=preconditioning_method)
895
+ self._rtol = rtol
896
+ self._atol = atol
897
+ self._maxiter = maxiter
898
+
899
+ def solve_linear_system(
900
+ self,
901
+ operator: LinearOperator,
902
+ preconditioner: Optional[LinearOperator],
903
+ y: Vector,
904
+ x0: Optional[Vector],
905
+ ) -> Vector:
906
+ space = operator.domain
907
+ x = space.zero if x0 is None else space.copy(x0)
908
+
909
+ # Initial residual: r = y - Ax
910
+ r = space.subtract(y, operator(x))
911
+ norm_y = space.norm(y)
912
+
913
+ # Default to identity if no preconditioner exists
914
+ if preconditioner is None:
915
+ preconditioner = space.identity_operator()
916
+
917
+ # Initial preconditioned residual z_0 = M^-1 r_0
918
+ z = preconditioner(r)
919
+ p = space.copy(z)
920
+
921
+ # Initial r.z product
922
+ rz = space.inner_product(r, z)
923
+
924
+ maxiter = self._maxiter if self._maxiter is not None else 2 * space.dim
925
+
926
+ for k in range(maxiter):
927
+ # w = A p
928
+ ap = operator(p)
929
+ pap = space.inner_product(p, ap)
930
+
931
+ # Step size alpha = (r, z) / (p, Ap)
932
+ alpha = rz / pap
933
+
934
+ # Update solution: x = x + alpha * p
935
+ space.axpy(alpha, p, x)
936
+
937
+ # Update residual: r = r - alpha * ap
938
+ space.axpy(-alpha, ap, r)
939
+
940
+ # Convergence check
941
+ if space.norm(r) < self._atol + self._rtol * norm_y:
942
+ break
943
+
944
+ # Flexible Beta update: Beta = - (z_new, Ap) / (p, Ap)
945
+ # This ensures that p_new is A-orthogonal to p_old
946
+ z_new = preconditioner(r)
947
+ beta = -space.inner_product(z_new, ap) / pap
948
+
949
+ # Update search direction: p = z_new + beta * p
950
+ p = space.add(z_new, space.multiply(beta, p))
951
+
952
+ # Prepare for next iteration
953
+ z = z_new
954
+ rz = space.inner_product(r, z)
955
+
956
+ return x
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Optional
3
+ import numpy as np
4
+
5
+ from .linear_operators import LinearOperator, DiagonalSparseMatrixLinearOperator
6
+ from .linear_solvers import LinearSolver, IterativeLinearSolver
7
+ from .random_matrix import random_diagonal
8
+
9
+ if TYPE_CHECKING:
10
+ from .hilbert_space import Vector
11
+
12
+
13
+ class IdentityPreconditioningMethod(LinearSolver):
14
+ """
15
+ A trivial preconditioning method that returns the Identity operator.
16
+
17
+ This acts as a "no-op" placeholder in the preconditioning framework,
18
+ useful for benchmarking or default configurations.
19
+ """
20
+
21
+ def __call__(self, operator: LinearOperator) -> LinearOperator:
22
+ """
23
+ Returns the identity operator for the domain of the input operator.
24
+ """
25
+ return operator.domain.identity_operator()
26
+
27
+
28
+ class JacobiPreconditioningMethod(LinearSolver):
29
+ """
30
+ A LinearSolver wrapper that generates a Jacobi preconditioner.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ num_samples: Optional[int] = 20,
36
+ method: str = "variable",
37
+ rtol: float = 1e-2,
38
+ block_size: int = 10,
39
+ parallel: bool = True,
40
+ n_jobs: int = -1,
41
+ ) -> None:
42
+ # Damping is removed: the operator passed to __call__ is already damped
43
+ self._num_samples = num_samples
44
+ self._method = method
45
+ self._rtol = rtol
46
+ self._block_size = block_size
47
+ self._parallel = parallel
48
+ self._n_jobs = n_jobs
49
+
50
+ def __call__(self, operator: LinearOperator) -> LinearOperator:
51
+ # Hutchinson's method or exact extraction on the damped normal operator
52
+ if self._num_samples is not None:
53
+ diag_values = random_diagonal(
54
+ operator.matrix(galerkin=True),
55
+ self._num_samples,
56
+ method=self._method,
57
+ rtol=self._rtol,
58
+ block_size=self._block_size,
59
+ parallel=self._parallel,
60
+ n_jobs=self._n_jobs,
61
+ )
62
+ else:
63
+ diag_values = operator.extract_diagonal(
64
+ galerkin=True, parallel=self._parallel, n_jobs=self._n_jobs
65
+ )
66
+
67
+ inv_diag = np.where(np.abs(diag_values) > 1e-14, 1.0 / diag_values, 1.0)
68
+
69
+ return DiagonalSparseMatrixLinearOperator.from_diagonal_values(
70
+ operator.domain, operator.domain, inv_diag, galerkin=True
71
+ )
72
+
73
+
74
+ class SpectralPreconditioningMethod(LinearSolver):
75
+ """
76
+ A LinearSolver wrapper that generates a spectral (low-rank) preconditioner.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ damping: float,
82
+ rank: int = 20,
83
+ power: int = 2,
84
+ ) -> None:
85
+ self._damping = damping
86
+ self._rank = rank
87
+ self._power = power
88
+
89
+ def __call__(self, operator: LinearOperator) -> LinearOperator:
90
+ """
91
+ Generates a spectral preconditioner.
92
+ Note: This assumes the operator provided is the data-misfit operator A*WA.
93
+ """
94
+ space = operator.domain
95
+
96
+ # Use randomized eigendecomposition to find dominant modes
97
+ U, S = operator.random_eig(self._rank, power=self._power)
98
+
99
+ s_vals = S.extract_diagonal()
100
+ d_vals = s_vals / (s_vals + self._damping**2)
101
+
102
+ def mapping(r: Vector) -> Vector:
103
+ ut_r = U.adjoint(r)
104
+ d_ut_r = d_vals * ut_r
105
+ correction = U(d_ut_r)
106
+
107
+ diff = space.subtract(r, correction)
108
+ return space.multiply(1.0 / self._damping**2, diff)
109
+
110
+ return LinearOperator(space, space, mapping, adjoint_mapping=mapping)
111
+
112
+
113
+ class IterativePreconditioningMethod(LinearSolver):
114
+ """
115
+ Wraps an iterative solver to act as a preconditioner.
116
+
117
+ This is best used with FCGSolver to handle the potential
118
+ variability of the inner iterations.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ inner_solver: IterativeLinearSolver,
124
+ max_inner_iter: int = 5,
125
+ rtol: float = 1e-1,
126
+ ) -> None:
127
+ self._inner_solver = inner_solver
128
+ self._max_iter = max_inner_iter
129
+ self._rtol = rtol
130
+
131
+ def __call__(self, operator: LinearOperator) -> LinearOperator:
132
+ """
133
+ Returns a LinearOperator whose action is 'solve the system'.
134
+ """
135
+ # We override the inner solver parameters for efficiency
136
+ self._inner_solver._maxiter = self._max_iter
137
+ self._inner_solver._rtol = self._rtol
138
+
139
+ # The solver's __call__ returns the InverseLinearOperator
140
+ return self._inner_solver(operator)
pygeoinf/random_matrix.py CHANGED
@@ -182,11 +182,14 @@ def variable_rank_random_range(
182
182
  basis_vectors = np.hstack([basis_vectors, new_basis[:, :cols_to_add]])
183
183
 
184
184
  if not converged and basis_vectors.shape[1] >= max_rank:
185
- warnings.warn(
186
- f"Tolerance {rtol} not met before reaching max_rank={max_rank}. "
187
- "Result may be inaccurate. Consider increasing `max_rank` or `power`.",
188
- UserWarning,
189
- )
185
+ # If we reached the full dimension of the matrix,
186
+ # the result is exact, so no warning is needed.
187
+ if max_rank < min(m, n):
188
+ warnings.warn(
189
+ f"Tolerance {rtol} not met before reaching max_rank={max_rank}. "
190
+ "Result may be inaccurate. Consider increasing `max_rank` or `power`.",
191
+ UserWarning,
192
+ )
190
193
 
191
194
  return basis_vectors
192
195