pygeoinf 1.2.2__py3-none-any.whl → 1.2.4__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/__init__.py CHANGED
@@ -1,3 +1,7 @@
1
+ """
2
+ Unified imports for the package.
3
+ """
4
+
1
5
  from .random_matrix import (
2
6
  fixed_rank_random_range,
3
7
  variable_rank_random_range,
@@ -71,6 +75,8 @@ from .linear_optimisation import (
71
75
 
72
76
  from .linear_bayesian import LinearBayesianInversion, LinearBayesianInference
73
77
 
78
+ from .backus_gilbert import HyperEllipsoid
79
+
74
80
  from .nonlinear_optimisation import (
75
81
  ScipyUnconstrainedOptimiser,
76
82
  )
@@ -2,10 +2,15 @@
2
2
  Module for Backus-Gilbert like methods for solving inference problems. To be done...
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  from .hilbert_space import HilbertSpace, Vector
6
8
  from .linear_operators import LinearOperator
7
9
  from .nonlinear_forms import NonLinearForm
8
10
 
11
+ from .forward_problem import LinearForwardProblem
12
+ from .inversion import LinearInference
13
+
9
14
 
10
15
  class HyperEllipsoid:
11
16
  """
@@ -0,0 +1,183 @@
1
+ import numpy as np
2
+
3
+
4
+ class HilbertSpaceAxiomChecks:
5
+ """
6
+ A mixin class providing a self-checking mechanism for Hilbert space axioms.
7
+
8
+ When inherited by a HilbertSpace subclass, it provides the `.check()` method
9
+ to run a suite of randomized tests, ensuring the implementation is valid.
10
+ """
11
+
12
+ def _check_vector_space_axioms(self, x, y, a):
13
+ """Checks axioms related to vector addition and scalar multiplication."""
14
+ # (x + y) - y == x
15
+ sum_vec = self.add(x, y)
16
+ res_vec = self.subtract(sum_vec, y)
17
+ if not np.allclose(self.to_components(x), self.to_components(res_vec)):
18
+ raise AssertionError("Axiom failed: (x + y) - y != x")
19
+
20
+ # a*(x+y) == a*x + a*y
21
+ lhs = self.multiply(a, self.add(x, y))
22
+ rhs = self.add(self.multiply(a, x), self.multiply(a, y))
23
+ if not np.allclose(self.to_components(lhs), self.to_components(rhs)):
24
+ raise AssertionError("Axiom failed: a*(x+y) != a*x + a*y")
25
+
26
+ # x + 0 = x
27
+ zero_vec = self.zero
28
+ res_vec = self.add(x, zero_vec)
29
+ if not np.allclose(self.to_components(x), self.to_components(res_vec)):
30
+ raise AssertionError("Axiom failed: x + 0 != x")
31
+
32
+ def _check_inner_product_axioms(self, x, y, z, a, b):
33
+ """Checks axioms related to the inner product and norm."""
34
+ # Linearity: <ax+by, z> = a<x,z> + b<y,z>
35
+ lhs = self.inner_product(self.add(self.multiply(a, x), self.multiply(b, y)), z)
36
+ rhs = a * self.inner_product(x, z) + b * self.inner_product(y, z)
37
+ if not np.isclose(lhs, rhs):
38
+ raise AssertionError("Axiom failed: Inner product linearity")
39
+
40
+ # Symmetry: <x, y> == <y, x>
41
+ if not np.isclose(self.inner_product(x, y), self.inner_product(y, x)):
42
+ raise AssertionError("Axiom failed: Inner product symmetry")
43
+
44
+ # Triangle Inequality: ||x + y|| <= ||x|| + ||y||
45
+ norm_sum = self.norm(self.add(x, y))
46
+ if not norm_sum <= self.norm(x) + self.norm(y):
47
+ raise AssertionError("Axiom failed: Triangle inequality")
48
+
49
+ def _check_mapping_identities(self, x):
50
+ """Checks that component and dual mappings are self-consistent."""
51
+ # from_components(to_components(x)) == x
52
+ components = self.to_components(x)
53
+ reconstructed_x = self.from_components(components)
54
+ if not np.allclose(components, self.to_components(reconstructed_x)):
55
+ raise AssertionError("Axiom failed: Component mapping round-trip")
56
+
57
+ # from_dual(to_dual(x)) == x
58
+ x_dual = self.to_dual(x)
59
+ reconstructed_x = self.from_dual(x_dual)
60
+ if not np.allclose(self.to_components(x), self.to_components(reconstructed_x)):
61
+ raise AssertionError("Axiom failed: Dual mapping round-trip")
62
+
63
+ def _check_inplace_operations(self, x, y, a):
64
+ """Checks the in-place operations `ax` and `axpy`."""
65
+ # Test ax: y := a*x
66
+ x_copy = self.copy(x)
67
+ expected_ax = self.multiply(a, x)
68
+ self.ax(a, x_copy)
69
+ if not np.allclose(self.to_components(expected_ax), self.to_components(x_copy)):
70
+ raise AssertionError("Axiom failed: In-place operation ax")
71
+
72
+ # Test axpy: y := a*x + y
73
+ y_copy = self.copy(y)
74
+ expected_axpy = self.add(self.multiply(a, x), y)
75
+ self.axpy(a, x, y_copy)
76
+ if not np.allclose(
77
+ self.to_components(expected_axpy), self.to_components(y_copy)
78
+ ):
79
+ raise AssertionError("Axiom failed: In-place operation axpy")
80
+
81
+ def _check_copy(self, x):
82
+ """Checks that the copy method creates a deep, independent copy."""
83
+ x_copy = self.copy(x)
84
+
85
+ # The copy should have the same value but be a different object
86
+ if x is x_copy:
87
+ raise AssertionError("Axiom failed: copy() returned the same object.")
88
+ if not np.allclose(self.to_components(x), self.to_components(x_copy)):
89
+ raise AssertionError("Axiom failed: copy() did not preserve values.")
90
+
91
+ # Modify the copy and ensure the original is unchanged
92
+ self.ax(2.0, x_copy)
93
+ if np.allclose(self.to_components(x), self.to_components(x_copy)):
94
+ raise AssertionError("Axiom failed: copy() is not a deep copy.")
95
+
96
+ def _check_gram_schmidt(self):
97
+ """Checks the Gram-Schmidt orthonormalization process."""
98
+ # Create a list of linearly independent vectors
99
+ vectors = [self.random() for _ in range(min(self.dim, 5))]
100
+ if not vectors:
101
+ return # Skip if dimension is 0
102
+
103
+ try:
104
+ orthonormal_vectors = self.gram_schmidt(vectors)
105
+ except ValueError as e:
106
+ # This can happen if the random vectors are not linearly independent
107
+ print(f"Skipping Gram-Schmidt check due to non-independent vectors: {e}")
108
+ return
109
+
110
+ # Check for orthonormality
111
+ for i, v1 in enumerate(orthonormal_vectors):
112
+ for j, v2 in enumerate(orthonormal_vectors):
113
+ inner_product = self.inner_product(v1, v2)
114
+ if i == j:
115
+ if not np.isclose(inner_product, 1.0):
116
+ raise AssertionError(
117
+ "Axiom failed: Gram-Schmidt vector norm is not 1."
118
+ )
119
+ else:
120
+ if not np.isclose(inner_product, 0.0):
121
+ raise AssertionError(
122
+ "Axiom failed: Gram-Schmidt vectors are not orthogonal."
123
+ )
124
+
125
+ def _check_basis_and_expectation(self):
126
+ """Checks the basis_vector and sample_expectation methods."""
127
+ if self.dim == 0:
128
+ return # Skip for zero-dimensional spaces
129
+
130
+ # Check basis vectors
131
+ for i in range(self.dim):
132
+ basis_vector = self.basis_vector(i)
133
+ components = self.to_components(basis_vector)
134
+ expected_components = np.zeros(self.dim)
135
+ expected_components[i] = 1.0
136
+ if not np.allclose(components, expected_components):
137
+ raise AssertionError(
138
+ "Axiom failed: basis_vector has incorrect components."
139
+ )
140
+
141
+ # Check sample expectation
142
+ vectors = [self.random() for _ in range(5)]
143
+ mean_vec = self.sample_expectation(vectors)
144
+
145
+ mean_comps = np.mean([self.to_components(v) for v in vectors], axis=0)
146
+ if not np.allclose(self.to_components(mean_vec), mean_comps):
147
+ raise AssertionError("Axiom failed: sample_expectation is incorrect.")
148
+
149
+ def check(self, n_checks: int = 10) -> None:
150
+ """
151
+ Runs a suite of randomized checks to verify the Hilbert space axioms.
152
+
153
+ This method performs `n_checks` iterations, generating new random
154
+ vectors and scalars for each one. It provides an "interactive" way
155
+ to validate any concrete HilbertSpace implementation.
156
+
157
+ Args:
158
+ n_checks: The number of randomized trials to run.
159
+
160
+ Raises:
161
+ AssertionError: If any of the underlying axiom checks fail.
162
+ """
163
+ print(
164
+ f"\nRunning {n_checks} randomized axiom checks for {self.__class__.__name__}... (and some one-off checks)"
165
+ )
166
+
167
+ # These checks only need to be run once
168
+ self._check_gram_schmidt()
169
+ self._check_basis_and_expectation()
170
+
171
+ for _ in range(n_checks):
172
+ # Generate fresh random data for each trial
173
+ x, y, z = self.random(), self.random(), self.random()
174
+ a, b = np.random.randn(), np.random.randn()
175
+
176
+ # Run all checks
177
+ self._check_vector_space_axioms(x, y, a)
178
+ self._check_inner_product_axioms(x, y, z, a, b)
179
+ self._check_mapping_identities(x)
180
+ self._check_inplace_operations(x, y, a)
181
+ self._check_copy(x)
182
+
183
+ print(f"✅ All {n_checks} Hilbert space axiom checks passed successfully.")
@@ -0,0 +1,124 @@
1
+ """
2
+ Provides a self-checking mechanism for LinearOperator implementations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from typing import TYPE_CHECKING
7
+ import numpy as np
8
+
9
+ # Import the base checks from the sibling module
10
+ from .nonlinear_operators import NonLinearOperatorAxiomChecks
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from ..hilbert_space import Vector
15
+
16
+
17
+ class LinearOperatorAxiomChecks(NonLinearOperatorAxiomChecks):
18
+ """
19
+ A mixin for checking the properties of a LinearOperator.
20
+
21
+ Inherits the derivative check from NonLinearOperatorAxiomChecks and adds
22
+ checks for linearity and the adjoint identity.
23
+ """
24
+
25
+ def _check_linearity(self, x: Vector, y: Vector, a: float, b: float):
26
+ """Verifies the linearity property: L(ax + by) = a*L(x) + b*L(y)"""
27
+ ax_plus_by = self.domain.add(
28
+ self.domain.multiply(a, x), self.domain.multiply(b, y)
29
+ )
30
+ lhs = self(ax_plus_by)
31
+
32
+ aLx = self.codomain.multiply(a, self(x))
33
+ bLy = self.codomain.multiply(b, self(y))
34
+ rhs = self.codomain.add(aLx, bLy)
35
+
36
+ # Compare the results in the codomain
37
+ diff_norm = self.codomain.norm(self.codomain.subtract(lhs, rhs))
38
+ rhs_norm = self.codomain.norm(rhs)
39
+ relative_error = diff_norm / (rhs_norm + 1e-12)
40
+
41
+ if relative_error > 1e-9:
42
+ raise AssertionError(
43
+ f"Linearity check failed: L(ax+by) != aL(x)+bL(y). Relative error: {relative_error:.2e}"
44
+ )
45
+
46
+ def _check_adjoint_definition(self, x: Vector, y: Vector):
47
+ """Verifies the adjoint identity: <L(x), y> = <x, L*(y)>"""
48
+ lhs = self.codomain.inner_product(self(x), y)
49
+ rhs = self.domain.inner_product(x, self.adjoint(y))
50
+
51
+ if not np.isclose(lhs, rhs):
52
+ raise AssertionError(
53
+ f"Adjoint definition failed: <L(x),y> = {lhs:.4e}, but <x,L*(y)> = {rhs:.4e}"
54
+ )
55
+
56
+ def _check_algebraic_identities(self, op1, op2, x, y, a):
57
+ """
58
+ Verifies the algebraic properties of the adjoint and dual operators.
59
+ Requires a second compatible operator (op2).
60
+ """
61
+ # --- Adjoint Identities ---
62
+ # (A+B)* = A* + B*
63
+ op_sum_adj = (op1 + op2).adjoint
64
+ adj_sum = op1.adjoint + op2.adjoint
65
+ diff = op1.domain.subtract(op_sum_adj(y), adj_sum(y))
66
+ if op1.domain.norm(diff) > 1e-9:
67
+ raise AssertionError("Axiom failed: (A+B)* != A* + B*")
68
+
69
+ # (a*A)* = a*A*
70
+ op_scaled_adj = (a * op1).adjoint
71
+ adj_scaled = a * op1.adjoint
72
+ diff = op1.domain.subtract(op_scaled_adj(y), adj_scaled(y))
73
+ if op1.domain.norm(diff) > 1e-9:
74
+ raise AssertionError("Axiom failed: (a*A)* != a*A*")
75
+
76
+ # (A*)* = A
77
+ op_adj_adj = op1.adjoint.adjoint
78
+ diff = op1.codomain.subtract(op_adj_adj(x), op1(x))
79
+ if op1.codomain.norm(diff) > 1e-9:
80
+ raise AssertionError("Axiom failed: (A*)* != A")
81
+
82
+ # (A@B)* = B*@A*
83
+ if op1.domain == op2.codomain:
84
+ op_comp_adj = (op1 @ op2).adjoint
85
+ adj_comp = op2.adjoint @ op1.adjoint
86
+ diff = op2.domain.subtract(op_comp_adj(y), adj_comp(y))
87
+ if op2.domain.norm(diff) > 1e-9:
88
+ raise AssertionError("Axiom failed: (A@B)* != B*@A*")
89
+
90
+ # --- Dual Identities ---
91
+ # (A+B)' = A' + B'
92
+ op_sum_dual = (op1 + op2).dual
93
+ dual_sum = op1.dual + op2.dual
94
+ y_dual = op1.codomain.to_dual(y)
95
+ # The result of applying a dual operator is a LinearForm, which supports subtraction
96
+ diff_dual = op_sum_dual(y_dual) - dual_sum(y_dual)
97
+ if op1.domain.dual.norm(diff_dual) > 1e-9:
98
+ raise AssertionError("Axiom failed: (A+B)' != A' + B'")
99
+
100
+ def check(self, n_checks: int = 5, op2=None) -> None:
101
+ """
102
+ Runs all checks for the LinearOperator, including non-linear checks
103
+ and algebraic identities.
104
+ """
105
+ # First, run the parent (non-linear) checks from the base class
106
+ super().check(n_checks, op2=op2)
107
+
108
+ # Now, run the linear-specific checks
109
+ print(
110
+ f"Running {n_checks} additional randomized checks for linearity and adjoints..."
111
+ )
112
+ for _ in range(n_checks):
113
+ x1 = self.domain.random()
114
+ x2 = self.domain.random()
115
+ y = self.codomain.random()
116
+ a, b = np.random.randn(), np.random.randn()
117
+
118
+ self._check_linearity(x1, x2, a, b)
119
+ self._check_adjoint_definition(x1, y)
120
+
121
+ if op2:
122
+ self._check_algebraic_identities(self, op2, x1, y, a)
123
+
124
+ print(f"✅ All {n_checks} linear operator checks passed successfully.")
@@ -0,0 +1,154 @@
1
+ """
2
+ Provides a self-checking mechanism for NonLinearOperator implementations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from typing import TYPE_CHECKING
7
+ import numpy as np
8
+
9
+ if TYPE_CHECKING:
10
+ from ..linear_operators import LinearOperator
11
+
12
+
13
+ class NonLinearOperatorAxiomChecks:
14
+ """A mixin for checking the properties of a NonLinearOperator."""
15
+
16
+ def _check_derivative_finite_difference(self, x, v, h=1e-7):
17
+ """
18
+ Verifies the derivative using the finite difference formula:
19
+ D[F](x) @ v ≈ (F(x + h*v) - F(x)) / h
20
+ """
21
+ from ..linear_operators import LinearOperator
22
+
23
+ derivative_op = self.derivative(x)
24
+
25
+ # 1. Check that the derivative is a valid LinearOperator
26
+ if not isinstance(derivative_op, LinearOperator):
27
+ raise AssertionError("The derivative must be a valid LinearOperator.")
28
+ if not (
29
+ derivative_op.domain == self.domain
30
+ and derivative_op.codomain == self.codomain
31
+ ):
32
+ raise AssertionError("The derivative has a mismatched domain or codomain.")
33
+
34
+ # 2. Calculate the analytical derivative's action on a random vector v
35
+ analytic_result = derivative_op(v)
36
+
37
+ # 3. Calculate the numerical approximation using the finite difference formula
38
+ x_plus_hv = self.domain.add(x, self.domain.multiply(h, v))
39
+ fx_plus_hv = self(x_plus_hv)
40
+ fx = self(x)
41
+ finite_diff_result = self.codomain.multiply(
42
+ 1 / h, self.codomain.subtract(fx_plus_hv, fx)
43
+ )
44
+
45
+ # 4. Compare the analytical and numerical results
46
+ diff_norm = self.codomain.norm(
47
+ self.codomain.subtract(analytic_result, finite_diff_result)
48
+ )
49
+ analytic_norm = self.codomain.norm(analytic_result)
50
+ relative_error = diff_norm / (analytic_norm + 1e-12)
51
+
52
+ if relative_error > 1e-4:
53
+ raise AssertionError(
54
+ f"Finite difference check failed. Relative error: {relative_error:.2e}"
55
+ )
56
+
57
+ def _check_add_derivative(self, op1, op2, x, v):
58
+ """Verifies the sum rule for derivatives: (F+G)' = F' + G'"""
59
+ if not (op1.has_derivative and op2.has_derivative):
60
+ return # Skip if derivatives aren't defined
61
+
62
+ # Derivative of the sum of operators
63
+ sum_op = op1 + op2
64
+ derivative_of_sum = sum_op.derivative(x)
65
+
66
+ # Sum of the individual derivatives
67
+ sum_of_derivatives = op1.derivative(x) + op2.derivative(x)
68
+
69
+ # Compare their action on a random vector
70
+ res1 = derivative_of_sum(v)
71
+ res2 = sum_of_derivatives(v)
72
+
73
+ diff_norm = self.codomain.norm(self.codomain.subtract(res1, res2))
74
+ if diff_norm > 1e-9:
75
+ raise AssertionError("Axiom failed: Derivative of sum is incorrect.")
76
+
77
+ def _check_scalar_mul_derivative(self, op, x, v, a):
78
+ """Verifies the scalar multiple rule: (a*F)' = a*F'"""
79
+ if not op.has_derivative:
80
+ return
81
+
82
+ # Derivative of the scaled operator
83
+ scaled_op = a * op
84
+ derivative_of_scaled = scaled_op.derivative(x)
85
+
86
+ # Scaled original derivative
87
+ scaled_derivative = a * op.derivative(x)
88
+
89
+ # Compare their action
90
+ res1 = derivative_of_scaled(v)
91
+ res2 = scaled_derivative(v)
92
+
93
+ diff_norm = self.codomain.norm(self.codomain.subtract(res1, res2))
94
+ if diff_norm > 1e-9:
95
+ raise AssertionError(
96
+ "Axiom failed: Derivative of scalar multiple is incorrect."
97
+ )
98
+
99
+ def _check_matmul_derivative(self, op1, op2, x, v):
100
+ """Verifies the chain rule for derivatives: (F o G)'(x) = F'(G(x)) @ G'(x)"""
101
+ if not (op1.has_derivative and op2.has_derivative):
102
+ return
103
+ if op1.domain != op2.codomain:
104
+ return # Skip if not composable
105
+
106
+ # Derivative of the composed operator
107
+ composed_op = op1 @ op2
108
+ derivative_of_composed = composed_op.derivative(x)
109
+
110
+ # Apply the chain rule manually
111
+ gx = op2(x)
112
+ chain_rule_derivative = op1.derivative(gx) @ op2.derivative(x)
113
+
114
+ # Compare their action
115
+ res1 = derivative_of_composed(v)
116
+ res2 = chain_rule_derivative(v)
117
+
118
+ diff_norm = op1.codomain.norm(op1.codomain.subtract(res1, res2))
119
+ if diff_norm > 1e-9:
120
+ raise AssertionError(
121
+ "Axiom failed: Chain rule for derivatives is incorrect."
122
+ )
123
+
124
+ def check(self, n_checks: int = 5, op2=None) -> None:
125
+ """
126
+ Runs randomized checks to validate the operator's derivative and
127
+ its algebraic properties.
128
+
129
+ Args:
130
+ n_checks: The number of randomized trials to perform.
131
+ op2: An optional second operator for testing algebraic rules.
132
+ """
133
+ print(
134
+ f"\nRunning {n_checks} randomized checks for {self.__class__.__name__}..."
135
+ )
136
+ for _ in range(n_checks):
137
+ x = self.domain.random()
138
+ v = self.domain.random()
139
+ a = np.random.randn()
140
+
141
+ # Ensure the direction vector 'v' is not a zero vector
142
+ if self.domain.norm(v) < 1e-12:
143
+ v = self.domain.random()
144
+
145
+ # Original check
146
+ self._check_derivative_finite_difference(x, v)
147
+
148
+ # New algebraic checks
149
+ self._check_scalar_mul_derivative(self, x, v, a)
150
+ if op2:
151
+ self._check_add_derivative(self, op2, x, v)
152
+ self._check_matmul_derivative(self, op2, x, v)
153
+
154
+ print(f"✅ All {n_checks} non-linear operator checks passed successfully.")
pygeoinf/direct_sum.py CHANGED
@@ -395,7 +395,6 @@ class ColumnLinearOperator(LinearOperator, BlockStructure):
395
395
  x = domain.zero
396
396
  for op, y in zip(self._operators, ys):
397
397
  domain.axpy(1.0, op.adjoint(y), x)
398
- print(op.adjoint(y).data)
399
398
  return x
400
399
 
401
400
  LinearOperator.__init__(
@@ -237,6 +237,27 @@ class LinearForwardProblem(ForwardProblem):
237
237
  """
238
238
  return chi2.ppf(significance_level, self.data_space.dim)
239
239
 
240
+ def chi_squared_from_residual(self, residual: Vector) -> float:
241
+ """
242
+ Calculates the chi-squared statistic from a residual vector.
243
+
244
+ Args:
245
+ residual: The residual vector.
246
+
247
+ Returns:
248
+ The chi-squared statistic.
249
+ """
250
+ if self.data_error_measure_set:
251
+ residual = self.data_space.subtract(
252
+ residual, self.data_error_measure.expectation
253
+ )
254
+ inverse_data_covariance = self.data_error_measure.inverse_covariance
255
+ return self.data_space.inner_product(
256
+ inverse_data_covariance(residual), residual
257
+ )
258
+ else:
259
+ return self.data_space.squared_norm(residual)
260
+
240
261
  def chi_squared(self, model: Vector, data: Vector) -> float:
241
262
  """
242
263
  Calculates the chi-squared statistic for a given model and data.
@@ -256,19 +277,7 @@ class LinearForwardProblem(ForwardProblem):
256
277
  """
257
278
 
258
279
  residual = self.data_space.subtract(data, self.forward_operator(model))
259
-
260
- if self.data_error_measure_set:
261
- # Center the residual with respect to the error measure's mean
262
- residual = self.data_space.subtract(
263
- residual, self.data_error_measure.expectation
264
- )
265
- inverse_data_covariance = self.data_error_measure.inverse_covariance
266
- return self.data_space.inner_product(
267
- inverse_data_covariance(residual), residual
268
- )
269
- else:
270
- # Fallback to the squared L2 norm of the residual
271
- return self.data_space.squared_norm(residual)
280
+ return self.chi_squared_from_residual(residual)
272
281
 
273
282
  def chi_squared_test(
274
283
  self, significance_level: float, model: Vector, data: Vector
pygeoinf/hilbert_space.py CHANGED
@@ -36,6 +36,8 @@ from typing import (
36
36
 
37
37
  import numpy as np
38
38
 
39
+ from .checks.hilbert_space import HilbertSpaceAxiomChecks
40
+
39
41
  # This block only runs for type checkers, not at runtime
40
42
  if TYPE_CHECKING:
41
43
  from .linear_operators import LinearOperator
@@ -45,7 +47,7 @@ if TYPE_CHECKING:
45
47
  Vector = TypeVar("Vector")
46
48
 
47
49
 
48
- class HilbertSpace(ABC):
50
+ class HilbertSpace(ABC, HilbertSpaceAxiomChecks):
49
51
  """
50
52
  An abstract base class for real Hilbert spaces.
51
53
 
pygeoinf/inversion.py CHANGED
@@ -126,6 +126,10 @@ class Inference(Inversion):
126
126
  relationship between model parameters and data.
127
127
  property_operator: A mapping takes elements of the model space to
128
128
  property vector of interest.
129
+
130
+ Raises:
131
+ ValueError: If the domain of the property operator is
132
+ not equal to the model space.
129
133
  """
130
134
 
131
135
  super().__init__(forward_problem)
@@ -166,6 +170,10 @@ class LinearInference(Inference):
166
170
  relationship between model parameters and data.
167
171
  property_operator: A linear mapping takes elements of the model space to
168
172
  property vector of interest.
173
+
174
+ Raises:
175
+ ValueError: If the domain of the property operator is
176
+ not equal to the model space.
169
177
  """
170
178
 
171
179
  if not isinstance(forward_problem, LinearForwardProblem):
@@ -36,13 +36,15 @@ from .random_matrix import (
36
36
 
37
37
  from .parallel import parallel_compute_dense_matrix_from_scipy_op
38
38
 
39
+ from .checks.linear_operators import LinearOperatorAxiomChecks
40
+
39
41
  # This block only runs for type checkers, not at runtime
40
42
  if TYPE_CHECKING:
41
43
  from .hilbert_space import HilbertSpace, EuclideanSpace
42
44
  from .linear_forms import LinearForm
43
45
 
44
46
 
45
- class LinearOperator(NonLinearOperator):
47
+ class LinearOperator(NonLinearOperator, LinearOperatorAxiomChecks):
46
48
  """A linear operator between two Hilbert spaces.
47
49
 
48
50
  This class represents a linear map `L(x) = Ax` and provides rich
@@ -21,6 +21,7 @@ Key Classes
21
21
  from __future__ import annotations
22
22
  from typing import Optional, Union
23
23
 
24
+
24
25
  from .nonlinear_operators import NonLinearOperator
25
26
  from .inversion import LinearInversion
26
27
 
@@ -82,6 +83,27 @@ class LinearLeastSquaresInversion(LinearInversion):
82
83
  else:
83
84
  return forward_operator.adjoint @ forward_operator + damping * identity
84
85
 
86
+ def normal_rhs(self, data: Vector) -> Vector:
87
+ """
88
+ Returns the right hand side of the normal equations for given data.
89
+ """
90
+
91
+ forward_operator = self.forward_problem.forward_operator
92
+
93
+ if self.forward_problem.data_error_measure_set:
94
+ inverse_data_covariance = (
95
+ self.forward_problem.data_error_measure.inverse_covariance
96
+ )
97
+
98
+ shifted_data = self.forward_problem.data_space.subtract(
99
+ data, self.forward_problem.data_error_measure.expectation
100
+ )
101
+
102
+ return (forward_operator.adjoint @ inverse_data_covariance)(shifted_data)
103
+
104
+ else:
105
+ return forward_operator.adjoint(data)
106
+
85
107
  def least_squares_operator(
86
108
  self,
87
109
  damping: float,
@@ -105,6 +127,7 @@ class LinearLeastSquaresInversion(LinearInversion):
105
127
  Returns:
106
128
  An operator that maps from the data space to the model space.
107
129
  """
130
+
108
131
  forward_operator = self.forward_problem.forward_operator
109
132
  normal_operator = self.normal_operator(damping)
110
133
 
@@ -198,20 +221,33 @@ class LinearMinimumNormInversion(LinearInversion):
198
221
  def get_model_for_damping(
199
222
  damping: float, data: Vector, model0: Optional[Vector] = None
200
223
  ) -> tuple[Vector, float]:
201
- """Computes the LS model and its chi-squared for a given damping."""
202
- op = lsq_inversion.least_squares_operator(
203
- damping, solver, preconditioner=preconditioner
204
- )
205
- model = op(data)
224
+ """
225
+ Computes the LS model and its chi-squared for a given damping.
226
+
227
+ When an iterative solver is used, an initial guess can be provided.
228
+ """
229
+
230
+ normal_operator = lsq_inversion.normal_operator(damping)
231
+ normal_rhs = lsq_inversion.normal_rhs(data)
232
+
233
+ if isinstance(solver, IterativeLinearSolver):
234
+ model = solver.solve_linear_system(
235
+ normal_operator, preconditioner, normal_rhs, model0
236
+ )
237
+ else:
238
+ inverse_normal_operator = solver(normal_operator)
239
+ model = inverse_normal_operator(normal_rhs)
240
+
206
241
  chi_squared = self.forward_problem.chi_squared(model, data)
207
242
  return model, chi_squared
208
243
 
209
244
  def mapping(data: Vector) -> Vector:
210
245
  """The non-linear mapping from data to the minimum-norm model."""
211
- model = self.model_space.zero
212
- chi_squared = self.forward_problem.chi_squared(model, data)
246
+
247
+ # Check to see if the zero model fits the data.
248
+ chi_squared = self.forward_problem.chi_squared_from_residual(data)
213
249
  if chi_squared <= critical_value:
214
- return model
250
+ return self.model_space.zero
215
251
 
216
252
  # Find upper and lower bounds for the optimal damping parameter
217
253
  damping = 1.0
@@ -246,9 +282,10 @@ class LinearMinimumNormInversion(LinearInversion):
246
282
  )
247
283
 
248
284
  # Bracket search for the optimal damping
285
+ model0 = None
249
286
  for _ in range(maxiter):
250
287
  damping = 0.5 * (damping_lower + damping_upper)
251
- model, chi_squared = get_model_for_damping(damping, data)
288
+ model, chi_squared = get_model_for_damping(damping, data, model0)
252
289
 
253
290
  if chi_squared < critical_value:
254
291
  damping_lower = damping
@@ -260,6 +297,8 @@ class LinearMinimumNormInversion(LinearInversion):
260
297
  ):
261
298
  return model
262
299
 
300
+ model0 = model
301
+
263
302
  raise RuntimeError("Bracketing search failed to converge.")
264
303
 
265
304
  return NonLinearOperator(self.data_space, self.model_space, mapping)
@@ -463,7 +463,7 @@ class CGSolver(IterativeLinearSolver):
463
463
 
464
464
  num = domain.inner_product(r, z)
465
465
 
466
- for _ in range(maxiter):
466
+ for i in range(maxiter):
467
467
  # Check for convergence
468
468
  if domain.squared_norm(r) <= tol_sq:
469
469
  break
@@ -11,13 +11,16 @@ from __future__ import annotations
11
11
  from typing import Callable, Optional, Any, TYPE_CHECKING
12
12
 
13
13
 
14
+ from .checks.nonlinear_operators import NonLinearOperatorAxiomChecks
15
+
16
+
14
17
  # This block only runs for type checkers, not at runtime
15
18
  if TYPE_CHECKING:
16
19
  from .hilbert_space import HilbertSpace, EuclideanSpace, Vector
17
20
  from .linear_operators import LinearOperator
18
21
 
19
22
 
20
- class NonLinearOperator:
23
+ class NonLinearOperator(NonLinearOperatorAxiomChecks):
21
24
  """
22
25
  Represents a general non-linear operator that maps vectors to vectors.
23
26
 
@@ -17,6 +17,13 @@ from .nonlinear_forms import NonLinearForm
17
17
  class ScipyUnconstrainedOptimiser:
18
18
  """
19
19
  A wrapper for scipy.optimize.minimize that adapts a NonLinearForm.
20
+
21
+ Note on derivative-free methods:
22
+ Internal testing has shown that the 'Nelder-Mead' solver can be unreliable
23
+ for some problems, failing to converge to the correct minimum while still
24
+ reporting success. The 'Powell' method appears to be more robust. Users
25
+ should exercise caution and verify results when using derivative-free
26
+ methods.
20
27
  """
21
28
 
22
29
  _HESSIAN_METHODS = {
@@ -168,7 +175,7 @@ def line_search(
168
175
  # terms of the components of the derivative (i.e., an element
169
176
  # of the dual space) and not the gradient, this meaning that
170
177
  # the standard Euclidean pairing with the components on the
171
- # descent direction will yield the correct slope.
178
+ # descent direction will yield the correct slope.
172
179
  def myfprime(c: np.ndarray) -> np.ndarray:
173
180
  x = domain.from_components(c)
174
181
  g = form.derivative(x)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pygeoinf
3
- Version: 1.2.2
3
+ Version: 1.2.4
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
@@ -16,7 +16,7 @@ Requires-Dist: matplotlib (>=3.0.0)
16
16
  Requires-Dist: numpy (>=1.26.0)
17
17
  Requires-Dist: pyqt6 (>=6.0.0)
18
18
  Requires-Dist: pyshtools (>=4.0.0)
19
- Requires-Dist: scipy (>=1.0.0)
19
+ Requires-Dist: scipy (>=1.16.1)
20
20
  Description-Content-Type: text/markdown
21
21
 
22
22
  # pygeoinf: A Python Library for Geophysical Inference
@@ -233,7 +233,7 @@ plt.show()
233
233
 
234
234
  The output of the above script will look similar to the following figure:
235
235
 
236
- ![Example of Bayesian Inference on a Circle](figures/fig1.png)
236
+ ![Example of Bayesian Inference on a Circle](docs/source/figures/fig1.png)
237
237
 
238
238
  ## Dependencies
239
239
 
@@ -0,0 +1,28 @@
1
+ pygeoinf/__init__.py,sha256=r5dumZhCLtuk9I-YXFDKnXB8t1gzjmgmjazkbqrZQpQ,1505
2
+ pygeoinf/backus_gilbert.py,sha256=vpIWvryUIy6pHWhT9A4bVB3A9MuTll1MyN9U8zmVI5c,3783
3
+ pygeoinf/checks/hilbert_space.py,sha256=Kr7PcOGrNIISezty0FBj5uXavIHC91yjCp2FVGNlHeE,7931
4
+ pygeoinf/checks/linear_operators.py,sha256=RkmtAW6e5Zr6EuhX6GAt_pI0IWu2WZ-CrfjSBN_7dsU,4664
5
+ pygeoinf/checks/nonlinear_operators.py,sha256=Rn9LTftyw5eGU3akx6xYNUJVGvX9J6gyTEXVFgLfYqs,5601
6
+ pygeoinf/direct_sum.py,sha256=1RHPJI_PoEvSRH9AmjX-v88IkSW4uT2rPSt5pmZQEZY,19377
7
+ pygeoinf/forward_problem.py,sha256=NnqWp7iMfkhHa9d-jBHzYHClaAfhKmO5D058AcJLLYg,10724
8
+ pygeoinf/gaussian_measure.py,sha256=EOUyBYT-K9u2ZD_uwPXDv17BJHk-L0RM55jfIR-DmXY,24020
9
+ pygeoinf/hilbert_space.py,sha256=0NCCG-OOHysdXYEFUs1wtJhGgOnuKvjZCZg8NJZO-DA,25331
10
+ pygeoinf/inversion.py,sha256=RV0hG2bGnciWdja0oOPKPxnFhYzufqdj-mKYNr4JJ_o,6447
11
+ pygeoinf/linear_bayesian.py,sha256=L1cJkeHtba4fPXZ8CmiLRBtuG2fmzG228M_iEar-iP8,9643
12
+ pygeoinf/linear_forms.py,sha256=sgynBvlQ35CaH12PKU2vWPHh9ikrmQbD5IASCUQtlbw,9197
13
+ pygeoinf/linear_operators.py,sha256=p03t2Azvdd4gakJws-myYDYDthyEskylsg6wODKbzJk,36424
14
+ pygeoinf/linear_optimisation.py,sha256=UbSr6AOPpR2sRYoN1Pvv24-Zu7_XlJk1zE1IhQu83hg,12428
15
+ pygeoinf/linear_solvers.py,sha256=mPhPiWKW82WHul_tfc0Xf-Y0GRtZQcPxfwEsWXh9G6M,15706
16
+ pygeoinf/nonlinear_forms.py,sha256=eQudA-HfedbURvRmzVvU8HfNCxHTuWUpdDoWe_KlA4Y,7067
17
+ pygeoinf/nonlinear_operators.py,sha256=X4_UMV1Rn4MqorjfN4P_UTckzCb4Gy1XceR3Ix8G4F8,7170
18
+ pygeoinf/nonlinear_optimisation.py,sha256=skK1ikn9GrVYherD64Qt9WrEYHA2NAJ48msOu_J8Oig,7431
19
+ pygeoinf/parallel.py,sha256=VVFvNHszy4wSa9LuErIsch4NAkLaZezhdN9YpRROBJo,2267
20
+ pygeoinf/random_matrix.py,sha256=afEUFuoVbkFobhC9Jy9SuGb4Yib-fn3pQyiWUqXrA-8,13629
21
+ pygeoinf/symmetric_space/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ pygeoinf/symmetric_space/circle.py,sha256=7Bz9BfSkbDnoz5-HFwTsAQE4a09jUapBePwoCK0xYWw,18007
23
+ pygeoinf/symmetric_space/sphere.py,sha256=poasBQEXV5WNSA9LBuCY2lsxv79aV90jKP13FSoQUmU,21950
24
+ pygeoinf/symmetric_space/symmetric_space.py,sha256=Q3KtfCtHO0_8LjsdKtH-5WVhRQurt5Bdk4yx1D2F5YY,17977
25
+ pygeoinf-1.2.4.dist-info/LICENSE,sha256=GrTQnKJemVi69FSbHprq60KN0OJGsOSR-joQoTq-oD8,1501
26
+ pygeoinf-1.2.4.dist-info/METADATA,sha256=77JDAOvB_vTl5iqYo7Q-DhRqDstULboR59iGEsDMZSM,15376
27
+ pygeoinf-1.2.4.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
28
+ pygeoinf-1.2.4.dist-info/RECORD,,
@@ -1,25 +0,0 @@
1
- pygeoinf/__init__.py,sha256=LG49pW8RUK-NxO0KSEHNVG_tBClUK5GFLtjvNLDRnjE,1419
2
- pygeoinf/backus_gilbert.py,sha256=bXJ0JKh49elNKLm5cGJj_RBh0oXcH3hpR7U-QUFHj8M,3657
3
- pygeoinf/direct_sum.py,sha256=SuW4OJuMjGme5nNhYTzcrTyo957g0OvNCC3GpQue5Bc,19419
4
- pygeoinf/forward_problem.py,sha256=iQsTQ4CV4XAqWd48EzhA82NMySGJSQ0_PaEtfG40agw,10529
5
- pygeoinf/gaussian_measure.py,sha256=EOUyBYT-K9u2ZD_uwPXDv17BJHk-L0RM55jfIR-DmXY,24020
6
- pygeoinf/hilbert_space.py,sha256=StS2AoTnOFTrh3XRyZ6K9lhQDqJijDaJGMC8RRagoTQ,25247
7
- pygeoinf/inversion.py,sha256=3FiujTK4PDBPjS0aYdo02nHQjsVFL4GDqv4gvg2YilA,6189
8
- pygeoinf/linear_bayesian.py,sha256=L1cJkeHtba4fPXZ8CmiLRBtuG2fmzG228M_iEar-iP8,9643
9
- pygeoinf/linear_forms.py,sha256=sgynBvlQ35CaH12PKU2vWPHh9ikrmQbD5IASCUQtlbw,9197
10
- pygeoinf/linear_operators.py,sha256=ha6QHKHVBd_MLMNmk8zAoqm_yDM2dClb8C6p13jo7Ik,36333
11
- pygeoinf/linear_optimisation.py,sha256=sO155SkGg5H1RR-jmULru7R4vlCPjUce--6Z52l3Pks,11147
12
- pygeoinf/linear_solvers.py,sha256=fPcr4f2mhSK34cHdRXk9LsonQJ_gLhXQYwCYA4O6Jv4,15706
13
- pygeoinf/nonlinear_forms.py,sha256=eQudA-HfedbURvRmzVvU8HfNCxHTuWUpdDoWe_KlA4Y,7067
14
- pygeoinf/nonlinear_operators.py,sha256=1FvimPwMxt0h1qOvTTjGabm-2ctDO4bT71LLro-7t68,7069
15
- pygeoinf/nonlinear_optimisation.py,sha256=xcIJX6Uw6HuJ3OySGXm3cDQ-BVgIVi3jjtOpIHNq8ks,7074
16
- pygeoinf/parallel.py,sha256=VVFvNHszy4wSa9LuErIsch4NAkLaZezhdN9YpRROBJo,2267
17
- pygeoinf/random_matrix.py,sha256=afEUFuoVbkFobhC9Jy9SuGb4Yib-fn3pQyiWUqXrA-8,13629
18
- pygeoinf/symmetric_space/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- pygeoinf/symmetric_space/circle.py,sha256=7Bz9BfSkbDnoz5-HFwTsAQE4a09jUapBePwoCK0xYWw,18007
20
- pygeoinf/symmetric_space/sphere.py,sha256=poasBQEXV5WNSA9LBuCY2lsxv79aV90jKP13FSoQUmU,21950
21
- pygeoinf/symmetric_space/symmetric_space.py,sha256=Q3KtfCtHO0_8LjsdKtH-5WVhRQurt5Bdk4yx1D2F5YY,17977
22
- pygeoinf-1.2.2.dist-info/LICENSE,sha256=GrTQnKJemVi69FSbHprq60KN0OJGsOSR-joQoTq-oD8,1501
23
- pygeoinf-1.2.2.dist-info/METADATA,sha256=avOFENnp8CogJepyHc0BfcyN7wq2PHtpXYCrE0KscQ0,15363
24
- pygeoinf-1.2.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
25
- pygeoinf-1.2.2.dist-info/RECORD,,