stresspy 0.0.1__tar.gz
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.
- stresspy-0.0.1/PKG-INFO +42 -0
- stresspy-0.0.1/README.md +30 -0
- stresspy-0.0.1/pyproject.toml +23 -0
- stresspy-0.0.1/setup.cfg +4 -0
- stresspy-0.0.1/src/stresspy/__init__.py +1 -0
- stresspy-0.0.1/src/stresspy/core.py +374 -0
- stresspy-0.0.1/src/stresspy.egg-info/PKG-INFO +42 -0
- stresspy-0.0.1/src/stresspy.egg-info/SOURCES.txt +10 -0
- stresspy-0.0.1/src/stresspy.egg-info/dependency_links.txt +1 -0
- stresspy-0.0.1/src/stresspy.egg-info/requires.txt +3 -0
- stresspy-0.0.1/src/stresspy.egg-info/top_level.txt +1 -0
- stresspy-0.0.1/tests/test_core.py +113 -0
stresspy-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stresspy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Geometric stress criterion modeling and tangent space decomposition
|
|
5
|
+
License: CC BY-NC 4.0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
|
|
13
|
+
# StressPy: Geometric Stress Criterion (GSC)
|
|
14
|
+
|
|
15
|
+
`stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Mathematical Background
|
|
20
|
+
|
|
21
|
+
At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
|
|
22
|
+
|
|
23
|
+
After applying an observation-space whitening transformation $L$:
|
|
24
|
+
$$r_W = Lr, \qquad J_W = LJ$$
|
|
25
|
+
|
|
26
|
+
If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
|
|
27
|
+
$$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
|
|
28
|
+
|
|
29
|
+
The reported stresses and normal fraction are:
|
|
30
|
+
$$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
|
|
31
|
+
|
|
32
|
+
The minimum-norm local repair vector $\Delta x$ satisfies:
|
|
33
|
+
$$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
|
|
34
|
+
|
|
35
|
+
Under the local linear approximation, $r - J \Delta x = r_\perp$.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install stresspy
|
stresspy-0.0.1/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# StressPy: Geometric Stress Criterion (GSC)
|
|
2
|
+
|
|
3
|
+
`stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Mathematical Background
|
|
8
|
+
|
|
9
|
+
At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
|
|
10
|
+
|
|
11
|
+
After applying an observation-space whitening transformation $L$:
|
|
12
|
+
$$r_W = Lr, \qquad J_W = LJ$$
|
|
13
|
+
|
|
14
|
+
If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
|
|
15
|
+
$$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
|
|
16
|
+
|
|
17
|
+
The reported stresses and normal fraction are:
|
|
18
|
+
$$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
|
|
19
|
+
|
|
20
|
+
The minimum-norm local repair vector $\Delta x$ satisfies:
|
|
21
|
+
$$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
|
|
22
|
+
|
|
23
|
+
Under the local linear approximation, $r - J \Delta x = r_\perp$.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install stresspy
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stresspy"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Geometric stress criterion modeling and tangent space decomposition"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "CC BY-NC 4.0" }
|
|
12
|
+
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Add any dependencies your core code needs, e.g. "numpy", "scipy"
|
|
18
|
+
dependencies = [
|
|
19
|
+
"numpy",
|
|
20
|
+
"scipy",
|
|
21
|
+
"pandas",
|
|
22
|
+
|
|
23
|
+
]
|
stresspy-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .core import *
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""StressPy core routines for the Geometric Stress Criterion (GSC).
|
|
2
|
+
|
|
3
|
+
This module implements the deterministic local tangent--normal decomposition.
|
|
4
|
+
It deliberately does not fit models, construct Jacobians, prepare biological
|
|
5
|
+
replicates, or perform bootstrap calibration. Those operations belong in
|
|
6
|
+
separate modules built around this small, testable numerical kernel.
|
|
7
|
+
|
|
8
|
+
Conventions
|
|
9
|
+
-----------
|
|
10
|
+
The discrepancy is defined as ``residual = observed - predicted``. If ``J``
|
|
11
|
+
is the Jacobian of the predictions with respect to the chosen parameter
|
|
12
|
+
coordinates, the minimum-norm local repair ``delta`` satisfies
|
|
13
|
+
``J @ delta = tangent_component`` in the selected observation-space metric.
|
|
14
|
+
Consequently, under the linear approximation, ``residual - J @ delta`` is the
|
|
15
|
+
normal component.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, fields
|
|
21
|
+
from typing import Any, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import ArrayLike, NDArray
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
FloatArray = NDArray[np.float64]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class GSCResult:
|
|
32
|
+
"""Result of a local GSC tangent--normal decomposition.
|
|
33
|
+
|
|
34
|
+
Stress values and the ``weighted_*`` vectors are expressed in whitened
|
|
35
|
+
observation coordinates. The unprefixed component vectors are mapped back
|
|
36
|
+
to the original observation coordinates; they are orthogonal under the
|
|
37
|
+
selected metric, not necessarily under the ordinary Euclidean metric.
|
|
38
|
+
|
|
39
|
+
The repair vector is minimum-norm in the parameter coordinates used to
|
|
40
|
+
construct the supplied Jacobian. Its magnitude is coordinate dependent.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
n_observations: int
|
|
44
|
+
n_parameters: int
|
|
45
|
+
rank: int
|
|
46
|
+
rank_threshold: float
|
|
47
|
+
singular_values: FloatArray
|
|
48
|
+
total_stress: float
|
|
49
|
+
tangent_stress: float
|
|
50
|
+
normal_stress: float
|
|
51
|
+
normal_fraction: float
|
|
52
|
+
residual: FloatArray
|
|
53
|
+
tangent_component: FloatArray
|
|
54
|
+
normal_component: FloatArray
|
|
55
|
+
weighted_residual: FloatArray
|
|
56
|
+
weighted_tangent_component: FloatArray
|
|
57
|
+
weighted_normal_component: FloatArray
|
|
58
|
+
repair_vector: FloatArray
|
|
59
|
+
repair_norm: float
|
|
60
|
+
condition_number: float
|
|
61
|
+
tangent_basis: FloatArray
|
|
62
|
+
parameter_basis: FloatArray
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def normal_fraction_pct(self) -> float:
|
|
66
|
+
"""Normal fraction expressed as a percentage."""
|
|
67
|
+
|
|
68
|
+
return 100.0 * self.normal_fraction
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> dict[str, Any]:
|
|
71
|
+
"""Return a shallow dictionary representation of the result."""
|
|
72
|
+
|
|
73
|
+
result = {item.name: getattr(self, item.name) for item in fields(self)}
|
|
74
|
+
result["normal_fraction_pct"] = self.normal_fraction_pct
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _as_vector(name: str, values: ArrayLike) -> FloatArray:
|
|
79
|
+
array = np.asarray(values, dtype=float)
|
|
80
|
+
if array.ndim != 1:
|
|
81
|
+
raise ValueError(f"{name} must be one-dimensional; got shape {array.shape}.")
|
|
82
|
+
if array.size == 0:
|
|
83
|
+
raise ValueError(f"{name} must not be empty.")
|
|
84
|
+
if not np.all(np.isfinite(array)):
|
|
85
|
+
raise ValueError(f"{name} must contain only finite values.")
|
|
86
|
+
return array
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _as_jacobian(values: ArrayLike, n_observations: int) -> FloatArray:
|
|
90
|
+
jacobian = np.asarray(values, dtype=float)
|
|
91
|
+
if jacobian.ndim != 2:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"jacobian must be two-dimensional; got shape {jacobian.shape}."
|
|
94
|
+
)
|
|
95
|
+
if jacobian.shape[0] != n_observations:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"jacobian row count must equal the number of observations; "
|
|
98
|
+
f"got {jacobian.shape[0]} and {n_observations}."
|
|
99
|
+
)
|
|
100
|
+
if jacobian.shape[1] == 0:
|
|
101
|
+
raise ValueError("jacobian must contain at least one parameter column.")
|
|
102
|
+
if not np.all(np.isfinite(jacobian)):
|
|
103
|
+
raise ValueError("jacobian must contain only finite values.")
|
|
104
|
+
return jacobian
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _build_whitener(
|
|
108
|
+
n: int,
|
|
109
|
+
*,
|
|
110
|
+
sigma: Optional[ArrayLike],
|
|
111
|
+
covariance: Optional[ArrayLike],
|
|
112
|
+
whitener: Optional[ArrayLike],
|
|
113
|
+
) -> tuple[FloatArray, FloatArray]:
|
|
114
|
+
supplied = sum(item is not None for item in (sigma, covariance, whitener))
|
|
115
|
+
if supplied > 1:
|
|
116
|
+
raise ValueError("Supply at most one of sigma, covariance, or whitener.")
|
|
117
|
+
|
|
118
|
+
if sigma is not None:
|
|
119
|
+
scale = _as_vector("sigma", sigma)
|
|
120
|
+
if scale.size != n:
|
|
121
|
+
raise ValueError(f"sigma must have length {n}; got {scale.size}.")
|
|
122
|
+
if np.any(scale <= 0.0):
|
|
123
|
+
raise ValueError("sigma values must all be strictly positive.")
|
|
124
|
+
return np.diag(1.0 / scale), np.diag(scale)
|
|
125
|
+
|
|
126
|
+
if covariance is not None:
|
|
127
|
+
cov = np.asarray(covariance, dtype=float)
|
|
128
|
+
if cov.shape != (n, n):
|
|
129
|
+
raise ValueError(f"covariance must have shape {(n, n)}; got {cov.shape}.")
|
|
130
|
+
if not np.all(np.isfinite(cov)):
|
|
131
|
+
raise ValueError("covariance must contain only finite values.")
|
|
132
|
+
if not np.allclose(cov, cov.T, rtol=1e-10, atol=1e-12):
|
|
133
|
+
raise ValueError("covariance must be symmetric.")
|
|
134
|
+
try:
|
|
135
|
+
chol = np.linalg.cholesky(cov)
|
|
136
|
+
except np.linalg.LinAlgError as exc:
|
|
137
|
+
raise ValueError("covariance must be positive definite.") from exc
|
|
138
|
+
whitening = np.linalg.solve(chol, np.eye(n))
|
|
139
|
+
return whitening, chol
|
|
140
|
+
|
|
141
|
+
if whitener is not None:
|
|
142
|
+
whitening = np.asarray(whitener, dtype=float)
|
|
143
|
+
if whitening.shape != (n, n):
|
|
144
|
+
raise ValueError(f"whitener must have shape {(n, n)}; got {whitening.shape}.")
|
|
145
|
+
if not np.all(np.isfinite(whitening)):
|
|
146
|
+
raise ValueError("whitener must contain only finite values.")
|
|
147
|
+
try:
|
|
148
|
+
unwhitening = np.linalg.inv(whitening)
|
|
149
|
+
except np.linalg.LinAlgError as exc:
|
|
150
|
+
raise ValueError("whitener must be nonsingular.") from exc
|
|
151
|
+
return whitening, unwhitening
|
|
152
|
+
|
|
153
|
+
identity = np.eye(n)
|
|
154
|
+
return identity, identity
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def decompose(
|
|
158
|
+
observed: ArrayLike,
|
|
159
|
+
predicted: ArrayLike,
|
|
160
|
+
jacobian: ArrayLike,
|
|
161
|
+
*,
|
|
162
|
+
sigma: Optional[ArrayLike] = None,
|
|
163
|
+
covariance: Optional[ArrayLike] = None,
|
|
164
|
+
whitener: Optional[ArrayLike] = None,
|
|
165
|
+
rank_rtol: Optional[float] = None,
|
|
166
|
+
rank_atol: float = 0.0,
|
|
167
|
+
) -> GSCResult:
|
|
168
|
+
"""Decompose model--data discrepancy into tangent and normal components.
|
|
169
|
+
|
|
170
|
+
``jacobian`` must be evaluated at the parameter point associated with
|
|
171
|
+
``predicted`` and use the same parameter coordinates in which the repair
|
|
172
|
+
vector is to be reported.
|
|
173
|
+
|
|
174
|
+
Supply at most one of ``sigma``, ``covariance``, or ``whitener``. A
|
|
175
|
+
singular value is retained when
|
|
176
|
+
``s > max(rank_atol, rank_rtol * s_max)``. If ``rank_rtol`` is omitted,
|
|
177
|
+
``max(jacobian.shape) * machine_epsilon`` is used.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
y = _as_vector("observed", observed)
|
|
181
|
+
y_hat = _as_vector("predicted", predicted)
|
|
182
|
+
if y_hat.size != y.size:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"observed and predicted must have equal length; got {y.size} and {y_hat.size}."
|
|
185
|
+
)
|
|
186
|
+
J = _as_jacobian(jacobian, y.size)
|
|
187
|
+
|
|
188
|
+
if rank_rtol is not None and (not np.isfinite(rank_rtol) or rank_rtol < 0.0):
|
|
189
|
+
raise ValueError("rank_rtol must be finite and non-negative.")
|
|
190
|
+
if not np.isfinite(rank_atol) or rank_atol < 0.0:
|
|
191
|
+
raise ValueError("rank_atol must be finite and non-negative.")
|
|
192
|
+
|
|
193
|
+
L, L_inv = _build_whitener(
|
|
194
|
+
y.size, sigma=sigma, covariance=covariance, whitener=whitener
|
|
195
|
+
)
|
|
196
|
+
residual = y - y_hat
|
|
197
|
+
residual_w = L @ residual
|
|
198
|
+
jacobian_w = L @ J
|
|
199
|
+
|
|
200
|
+
U, singular_values, Vt = np.linalg.svd(jacobian_w, full_matrices=False)
|
|
201
|
+
s_max = float(singular_values[0]) if singular_values.size else 0.0
|
|
202
|
+
if rank_rtol is None:
|
|
203
|
+
rank_rtol = max(jacobian_w.shape) * np.finfo(float).eps
|
|
204
|
+
threshold = max(float(rank_atol), float(rank_rtol) * s_max)
|
|
205
|
+
rank = int(np.count_nonzero(singular_values > threshold))
|
|
206
|
+
|
|
207
|
+
U_r = U[:, :rank]
|
|
208
|
+
s_r = singular_values[:rank]
|
|
209
|
+
V_r = Vt[:rank, :].T
|
|
210
|
+
|
|
211
|
+
if rank:
|
|
212
|
+
tangent_w = U_r @ (U_r.T @ residual_w)
|
|
213
|
+
repair = V_r @ ((U_r.T @ residual_w) / s_r)
|
|
214
|
+
condition_number = float(s_r[0] / s_r[-1])
|
|
215
|
+
else:
|
|
216
|
+
tangent_w = np.zeros_like(residual_w)
|
|
217
|
+
repair = np.zeros(J.shape[1], dtype=float)
|
|
218
|
+
condition_number = float("inf")
|
|
219
|
+
|
|
220
|
+
normal_w = residual_w - tangent_w
|
|
221
|
+
tangent = L_inv @ tangent_w
|
|
222
|
+
normal = L_inv @ normal_w
|
|
223
|
+
|
|
224
|
+
total_stress = float(residual_w @ residual_w)
|
|
225
|
+
tangent_stress = float(tangent_w @ tangent_w)
|
|
226
|
+
normal_stress = float(normal_w @ normal_w)
|
|
227
|
+
normal_fraction = (
|
|
228
|
+
normal_stress / total_stress if total_stress > 0.0 else float("nan")
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return GSCResult(
|
|
232
|
+
n_observations=y.size,
|
|
233
|
+
n_parameters=J.shape[1],
|
|
234
|
+
rank=rank,
|
|
235
|
+
rank_threshold=threshold,
|
|
236
|
+
singular_values=singular_values,
|
|
237
|
+
total_stress=total_stress,
|
|
238
|
+
tangent_stress=tangent_stress,
|
|
239
|
+
normal_stress=normal_stress,
|
|
240
|
+
normal_fraction=normal_fraction,
|
|
241
|
+
residual=residual,
|
|
242
|
+
tangent_component=tangent,
|
|
243
|
+
normal_component=normal,
|
|
244
|
+
weighted_residual=residual_w,
|
|
245
|
+
weighted_tangent_component=tangent_w,
|
|
246
|
+
weighted_normal_component=normal_w,
|
|
247
|
+
repair_vector=repair,
|
|
248
|
+
repair_norm=float(np.linalg.norm(repair)),
|
|
249
|
+
condition_number=condition_number,
|
|
250
|
+
tangent_basis=U_r,
|
|
251
|
+
parameter_basis=V_r,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def decompose_blocks(
|
|
256
|
+
observed_blocks: Sequence[ArrayLike],
|
|
257
|
+
predicted_blocks: Sequence[ArrayLike],
|
|
258
|
+
jacobian_blocks: Sequence[ArrayLike],
|
|
259
|
+
*,
|
|
260
|
+
sigma_blocks: Optional[Sequence[ArrayLike]] = None,
|
|
261
|
+
rank_rtol: Optional[float] = None,
|
|
262
|
+
rank_atol: float = 0.0,
|
|
263
|
+
) -> GSCResult:
|
|
264
|
+
"""Stack independent blocks and perform one joint GSC interrogation.
|
|
265
|
+
|
|
266
|
+
This convenience function supports diagonal weighting only. Use
|
|
267
|
+
:func:`decompose` with a full covariance matrix when errors are correlated
|
|
268
|
+
across blocks.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
if len(observed_blocks) == 0:
|
|
272
|
+
raise ValueError("At least one observation block is required.")
|
|
273
|
+
if not (
|
|
274
|
+
len(observed_blocks) == len(predicted_blocks) == len(jacobian_blocks)
|
|
275
|
+
):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"observed_blocks, predicted_blocks, and jacobian_blocks must have equal length."
|
|
278
|
+
)
|
|
279
|
+
if sigma_blocks is not None and len(sigma_blocks) != len(observed_blocks):
|
|
280
|
+
raise ValueError("sigma_blocks must have the same number of blocks as observed_blocks.")
|
|
281
|
+
|
|
282
|
+
observed = np.concatenate(
|
|
283
|
+
[_as_vector("observation block", item) for item in observed_blocks]
|
|
284
|
+
)
|
|
285
|
+
predicted = np.concatenate(
|
|
286
|
+
[_as_vector("prediction block", item) for item in predicted_blocks]
|
|
287
|
+
)
|
|
288
|
+
jacobian = np.vstack([np.asarray(item, dtype=float) for item in jacobian_blocks])
|
|
289
|
+
sigma = None
|
|
290
|
+
if sigma_blocks is not None:
|
|
291
|
+
sigma = np.concatenate(
|
|
292
|
+
[_as_vector("sigma block", item) for item in sigma_blocks]
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return decompose(
|
|
296
|
+
observed,
|
|
297
|
+
predicted,
|
|
298
|
+
jacobian,
|
|
299
|
+
sigma=sigma,
|
|
300
|
+
rank_rtol=rank_rtol,
|
|
301
|
+
rank_atol=rank_atol,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def floor_sigma(
|
|
306
|
+
sigma: ArrayLike,
|
|
307
|
+
*,
|
|
308
|
+
quantile: float = 0.10,
|
|
309
|
+
floor: Optional[float] = None,
|
|
310
|
+
) -> tuple[FloatArray, float]:
|
|
311
|
+
"""Apply an explicit lower floor to valid positive standard deviations.
|
|
312
|
+
|
|
313
|
+
This is optional preprocessing, not part of the GSC definition. If
|
|
314
|
+
``floor`` is omitted it is calculated from the requested quantile.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
scale = _as_vector("sigma", sigma)
|
|
318
|
+
if np.any(scale <= 0.0):
|
|
319
|
+
raise ValueError("sigma values must all be strictly positive before flooring.")
|
|
320
|
+
if not np.isfinite(quantile) or not 0.0 <= quantile <= 1.0:
|
|
321
|
+
raise ValueError("quantile must be finite and lie between 0 and 1.")
|
|
322
|
+
if floor is None:
|
|
323
|
+
floor_value = float(np.quantile(scale, quantile))
|
|
324
|
+
else:
|
|
325
|
+
floor_value = float(floor)
|
|
326
|
+
if not np.isfinite(floor_value) or floor_value <= 0.0:
|
|
327
|
+
raise ValueError("floor must be finite and strictly positive.")
|
|
328
|
+
return np.maximum(scale, floor_value), floor_value
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def jacobian_to_log_coordinates(
|
|
332
|
+
jacobian: ArrayLike,
|
|
333
|
+
parameters: ArrayLike,
|
|
334
|
+
*,
|
|
335
|
+
log_mask: Optional[ArrayLike] = None,
|
|
336
|
+
) -> FloatArray:
|
|
337
|
+
"""Convert a raw-parameter Jacobian to mixed identity/log coordinates.
|
|
338
|
+
|
|
339
|
+
For a log-transformed parameter, ``x_j = log(theta_j)``, the corresponding
|
|
340
|
+
Jacobian column is multiplied by ``theta_j``. Columns not selected by
|
|
341
|
+
``log_mask`` are unchanged. If ``log_mask`` is omitted, every parameter is
|
|
342
|
+
treated as log-transformed.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
raw = np.asarray(jacobian, dtype=float)
|
|
346
|
+
if raw.ndim != 2:
|
|
347
|
+
raise ValueError(f"jacobian must be two-dimensional; got shape {raw.shape}.")
|
|
348
|
+
J = _as_jacobian(raw, raw.shape[0])
|
|
349
|
+
theta = _as_vector("parameters", parameters)
|
|
350
|
+
if J.shape[1] != theta.size:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"jacobian has {J.shape[1]} columns but parameters has length {theta.size}."
|
|
353
|
+
)
|
|
354
|
+
if log_mask is None:
|
|
355
|
+
mask = np.ones(theta.size, dtype=bool)
|
|
356
|
+
else:
|
|
357
|
+
mask = np.asarray(log_mask, dtype=bool)
|
|
358
|
+
if mask.shape != theta.shape:
|
|
359
|
+
raise ValueError(f"log_mask must have shape {theta.shape}; got {mask.shape}.")
|
|
360
|
+
if np.any(theta[mask] <= 0.0):
|
|
361
|
+
raise ValueError("Parameters selected for log transformation must be positive.")
|
|
362
|
+
|
|
363
|
+
column_scale = np.ones(theta.size, dtype=float)
|
|
364
|
+
column_scale[mask] = theta[mask]
|
|
365
|
+
return J * column_scale[None, :]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
__all__ = [
|
|
369
|
+
"GSCResult",
|
|
370
|
+
"decompose",
|
|
371
|
+
"decompose_blocks",
|
|
372
|
+
"floor_sigma",
|
|
373
|
+
"jacobian_to_log_coordinates",
|
|
374
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stresspy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Geometric stress criterion modeling and tangent space decomposition
|
|
5
|
+
License: CC BY-NC 4.0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
|
|
13
|
+
# StressPy: Geometric Stress Criterion (GSC)
|
|
14
|
+
|
|
15
|
+
`stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Mathematical Background
|
|
20
|
+
|
|
21
|
+
At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
|
|
22
|
+
|
|
23
|
+
After applying an observation-space whitening transformation $L$:
|
|
24
|
+
$$r_W = Lr, \qquad J_W = LJ$$
|
|
25
|
+
|
|
26
|
+
If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
|
|
27
|
+
$$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
|
|
28
|
+
|
|
29
|
+
The reported stresses and normal fraction are:
|
|
30
|
+
$$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
|
|
31
|
+
|
|
32
|
+
The minimum-norm local repair vector $\Delta x$ satisfies:
|
|
33
|
+
$$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
|
|
34
|
+
|
|
35
|
+
Under the local linear approximation, $r - J \Delta x = r_\perp$.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install stresspy
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/stresspy/__init__.py
|
|
4
|
+
src/stresspy/core.py
|
|
5
|
+
src/stresspy.egg-info/PKG-INFO
|
|
6
|
+
src/stresspy.egg-info/SOURCES.txt
|
|
7
|
+
src/stresspy.egg-info/dependency_links.txt
|
|
8
|
+
src/stresspy.egg-info/requires.txt
|
|
9
|
+
src/stresspy.egg-info/top_level.txt
|
|
10
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stresspy
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from stresspy import (
|
|
4
|
+
decompose,
|
|
5
|
+
decompose_blocks,
|
|
6
|
+
floor_sigma,
|
|
7
|
+
jacobian_to_log_coordinates,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def basic_setup():
|
|
12
|
+
J = np.array([[1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
|
|
13
|
+
predicted = np.array([1.0, 2.0, 1.0])
|
|
14
|
+
observed = np.array([1.1, 1.9, 0.9])
|
|
15
|
+
sigma = np.array([0.1, 0.1, 0.1])
|
|
16
|
+
return observed, predicted, J, sigma
|
|
17
|
+
|
|
18
|
+
# 1. Stress additive identity: total = tangent + normal
|
|
19
|
+
def test_stress_addition(basic_setup):
|
|
20
|
+
y, y_hat, J, sigma = basic_setup
|
|
21
|
+
res = decompose(y, y_hat, J, sigma=sigma)
|
|
22
|
+
np.testing.assert_allclose(res.total_stress, res.tangent_stress + res.normal_stress)
|
|
23
|
+
|
|
24
|
+
# 2. Local linear repair residual identity: (r - J @ repair) == normal_component
|
|
25
|
+
def test_linear_repair_identity(basic_setup):
|
|
26
|
+
y, y_hat, J, sigma = basic_setup
|
|
27
|
+
res = decompose(y, y_hat, J, sigma=sigma)
|
|
28
|
+
residual = y - y_hat
|
|
29
|
+
np.testing.assert_allclose(residual - J @ res.repair_vector, res.normal_component, atol=1e-12)
|
|
30
|
+
|
|
31
|
+
# 3. Pure tangent residual yields zero normal stress
|
|
32
|
+
def test_pure_tangent_residual(basic_setup):
|
|
33
|
+
_, y_hat, J, sigma = basic_setup
|
|
34
|
+
dx_true = np.array([0.2, -0.1])
|
|
35
|
+
y_tangent = y_hat + J @ dx_true
|
|
36
|
+
res = decompose(y_tangent, y_hat, J, sigma=sigma)
|
|
37
|
+
np.testing.assert_allclose(res.normal_stress, 0.0, atol=1e-12)
|
|
38
|
+
|
|
39
|
+
# 4. Pure normal residual yields zero tangent stress and zero repair
|
|
40
|
+
def test_pure_normal_residual():
|
|
41
|
+
J = np.array([[1.0], [0.0]])
|
|
42
|
+
y_hat = np.array([0.0, 0.0])
|
|
43
|
+
y_normal = np.array([0.0, 2.0]) # Orthogonal to column space of J
|
|
44
|
+
res = decompose(y_normal, y_hat, J)
|
|
45
|
+
np.testing.assert_allclose(res.tangent_stress, 0.0, atol=1e-12)
|
|
46
|
+
np.testing.assert_allclose(res.repair_vector, np.array([0.0]), atol=1e-12)
|
|
47
|
+
|
|
48
|
+
# 5. Diagonal covariance and equivalent sigma weighting agree
|
|
49
|
+
def test_sigma_vs_covariance_equivalence(basic_setup):
|
|
50
|
+
y, y_hat, J, sigma = basic_setup
|
|
51
|
+
res_sigma = decompose(y, y_hat, J, sigma=sigma)
|
|
52
|
+
|
|
53
|
+
cov = np.diag(sigma**2)
|
|
54
|
+
res_cov = decompose(y, y_hat, J, covariance=cov)
|
|
55
|
+
|
|
56
|
+
np.testing.assert_allclose(res_sigma.total_stress, res_cov.total_stress)
|
|
57
|
+
np.testing.assert_allclose(res_sigma.normal_fraction_pct, res_cov.normal_fraction_pct)
|
|
58
|
+
|
|
59
|
+
# 6. Rank-deficient and zero Jacobians handled cleanly
|
|
60
|
+
def test_zero_jacobian(basic_setup):
|
|
61
|
+
y, y_hat, _, sigma = basic_setup
|
|
62
|
+
J_zero = np.zeros((3, 2))
|
|
63
|
+
res = decompose(y, y_hat, J_zero, sigma=sigma)
|
|
64
|
+
assert res.rank == 0
|
|
65
|
+
np.testing.assert_allclose(res.normal_stress, res.total_stress)
|
|
66
|
+
np.testing.assert_allclose(res.repair_vector, np.zeros(2))
|
|
67
|
+
|
|
68
|
+
# 7. Permuting observations leaves scalar stress results unchanged
|
|
69
|
+
def test_observation_permutation(basic_setup):
|
|
70
|
+
y, y_hat, J, sigma = basic_setup
|
|
71
|
+
perm = [2, 0, 1]
|
|
72
|
+
|
|
73
|
+
res_orig = decompose(y, y_hat, J, sigma=sigma)
|
|
74
|
+
res_perm = decompose(y[perm], y_hat[perm], J[perm], sigma=sigma[perm])
|
|
75
|
+
|
|
76
|
+
np.testing.assert_allclose(res_orig.total_stress, res_perm.total_stress)
|
|
77
|
+
np.testing.assert_allclose(res_orig.normal_fraction_pct, res_perm.normal_fraction_pct)
|
|
78
|
+
np.testing.assert_allclose(res_orig.repair_vector, res_perm.repair_vector)
|
|
79
|
+
|
|
80
|
+
# 8. Joint block analysis agrees with explicit array stacking
|
|
81
|
+
def test_joint_block_stacking(basic_setup):
|
|
82
|
+
y1, f1, J1, s1 = basic_setup
|
|
83
|
+
y2, f2, J2, s2 = y1 * 0.5, f1 * 0.5, J1 * 0.5, s1 * 0.5
|
|
84
|
+
|
|
85
|
+
joint_res = decompose_blocks(
|
|
86
|
+
observed_blocks=[y1, y2],
|
|
87
|
+
predicted_blocks=[f1, f2],
|
|
88
|
+
jacobian_blocks=[J1, J2],
|
|
89
|
+
sigma_blocks=[s1, s2]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
y_stack = np.hstack([y1, y2])
|
|
93
|
+
f_stack = np.hstack([f1, f2])
|
|
94
|
+
J_stack = np.vstack([J1, J2])
|
|
95
|
+
s_stack = np.hstack([s1, s2])
|
|
96
|
+
stack_res = decompose(y_stack, f_stack, J_stack, sigma=s_stack)
|
|
97
|
+
|
|
98
|
+
np.testing.assert_allclose(joint_res.total_stress, stack_res.total_stress)
|
|
99
|
+
np.testing.assert_allclose(joint_res.repair_vector, stack_res.repair_vector)
|
|
100
|
+
|
|
101
|
+
# 9. Log-coordinate transformation spans same tangent space when un-truncated
|
|
102
|
+
def test_log_coordinate_span(basic_setup):
|
|
103
|
+
y, y_hat, J_raw, sigma = basic_setup
|
|
104
|
+
p_hat = np.array([2.0, 0.5])
|
|
105
|
+
log_mask = np.array([True, True])
|
|
106
|
+
|
|
107
|
+
J_log = jacobian_to_log_coordinates(J_raw, parameters=p_hat, log_mask=log_mask)
|
|
108
|
+
|
|
109
|
+
res_raw = decompose(y, y_hat, J_raw, sigma=sigma)
|
|
110
|
+
res_log = decompose(y, y_hat, J_log, sigma=sigma)
|
|
111
|
+
|
|
112
|
+
np.testing.assert_allclose(res_raw.normal_stress, res_log.normal_stress)
|
|
113
|
+
np.testing.assert_allclose(res_raw.normal_fraction_pct, res_log.normal_fraction_pct)
|