gsax 0.1.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.
- gsax/__init__.py +48 -0
- gsax/_normalization.py +170 -0
- gsax/_transforms.py +56 -0
- gsax/benchmarks/__init__.py +22 -0
- gsax/benchmarks/ishigami.py +111 -0
- gsax/benchmarks/linear.py +94 -0
- gsax/benchmarks/oakley_ohagan.py +189 -0
- gsax/benchmarks/sobol_g.py +102 -0
- gsax/dgsm/__init__.py +20 -0
- gsax/dgsm/_analyze.py +205 -0
- gsax/dgsm/_poincare.py +132 -0
- gsax/dgsm/_result.py +71 -0
- gsax/efast/__init__.py +20 -0
- gsax/efast/_analyze.py +215 -0
- gsax/efast/_result.py +82 -0
- gsax/efast/_sampling.py +104 -0
- gsax/hdmr/__init__.py +17 -0
- gsax/hdmr/_analyze.py +497 -0
- gsax/hdmr/_engine.py +596 -0
- gsax/hdmr/_result.py +151 -0
- gsax/hsic/__init__.py +20 -0
- gsax/hsic/_analyze.py +471 -0
- gsax/hsic/_result.py +88 -0
- gsax/pawn/__init__.py +20 -0
- gsax/pawn/_analyze.py +286 -0
- gsax/pawn/_result.py +74 -0
- gsax/pce/__init__.py +18 -0
- gsax/pce/_analyze.py +154 -0
- gsax/pce/_engine.py +240 -0
- gsax/pce/_result.py +70 -0
- gsax/problem.py +242 -0
- gsax/py.typed +0 -0
- gsax/sampling.py +748 -0
- gsax/sobol/__init__.py +17 -0
- gsax/sobol/_analyze.py +618 -0
- gsax/sobol/_bootstrap.py +155 -0
- gsax/sobol/_indices.py +200 -0
- gsax/sobol/_result.py +119 -0
- gsax-0.1.0.dist-info/METADATA +501 -0
- gsax-0.1.0.dist-info/RECORD +42 -0
- gsax-0.1.0.dist-info/WHEEL +4 -0
- gsax-0.1.0.dist-info/licenses/LICENSE +21 -0
gsax/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from gsax.dgsm import DGSMResult
|
|
2
|
+
from gsax.dgsm import analyze as analyze_dgsm
|
|
3
|
+
from gsax.efast import EFASTResult
|
|
4
|
+
from gsax.efast import analyze as analyze_efast
|
|
5
|
+
from gsax.efast import sample as sample_efast
|
|
6
|
+
from gsax.hdmr import HDMREmulator, HDMRResult
|
|
7
|
+
from gsax.hdmr import analyze as analyze_hdmr
|
|
8
|
+
from gsax.hdmr import emulate as emulate_hdmr
|
|
9
|
+
from gsax.hsic import HSICResult
|
|
10
|
+
from gsax.hsic import analyze as analyze_hsic
|
|
11
|
+
from gsax.pawn import PAWNResult
|
|
12
|
+
from gsax.pawn import analyze as analyze_pawn
|
|
13
|
+
from gsax.pce import PCEResult
|
|
14
|
+
from gsax.pce import analyze as analyze_pce
|
|
15
|
+
from gsax.pce import emulate as emulate_pce
|
|
16
|
+
from gsax.problem import GaussianInputSpec, Problem, UniformInputSpec
|
|
17
|
+
from gsax.sampling import SamplingResult, downsample, load, sample, sample_mc, verify_prefix
|
|
18
|
+
from gsax.sobol import SAResult, analyze
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DGSMResult",
|
|
22
|
+
"EFASTResult",
|
|
23
|
+
"GaussianInputSpec",
|
|
24
|
+
"HDMREmulator",
|
|
25
|
+
"HDMRResult",
|
|
26
|
+
"HSICResult",
|
|
27
|
+
"PAWNResult",
|
|
28
|
+
"PCEResult",
|
|
29
|
+
"Problem",
|
|
30
|
+
"SAResult",
|
|
31
|
+
"SamplingResult",
|
|
32
|
+
"UniformInputSpec",
|
|
33
|
+
"analyze",
|
|
34
|
+
"analyze_dgsm",
|
|
35
|
+
"analyze_efast",
|
|
36
|
+
"analyze_hdmr",
|
|
37
|
+
"analyze_hsic",
|
|
38
|
+
"analyze_pawn",
|
|
39
|
+
"analyze_pce",
|
|
40
|
+
"downsample",
|
|
41
|
+
"emulate_hdmr",
|
|
42
|
+
"emulate_pce",
|
|
43
|
+
"load",
|
|
44
|
+
"sample",
|
|
45
|
+
"sample_efast",
|
|
46
|
+
"sample_mc",
|
|
47
|
+
"verify_prefix",
|
|
48
|
+
]
|
gsax/_normalization.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Shared output helpers for analysis entrypoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
from jax import Array
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from gsax.problem import Problem
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_output_names(K: int, problem: Problem) -> list[str]:
|
|
15
|
+
"""Resolve output coordinate labels, defaulting to y0, y1, ...
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
K: Number of output variables.
|
|
19
|
+
problem: Problem definition (may carry output_names).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of K string labels.
|
|
23
|
+
"""
|
|
24
|
+
if problem.output_names is not None:
|
|
25
|
+
if len(problem.output_names) != K:
|
|
26
|
+
raise ValueError(f"output_names length {len(problem.output_names)} != K={K}")
|
|
27
|
+
return list(problem.output_names)
|
|
28
|
+
return [f"y{i}" for i in range(K)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _prepare_Y(
|
|
32
|
+
Y: Array,
|
|
33
|
+
) -> tuple[Array, bool, bool]:
|
|
34
|
+
"""Promote Y to a canonical 3-D shape (n_total, T, K).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
Y: Model output array with 1, 2, or 3 dimensions.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A tuple ``(Y_3d, squeeze_time, squeeze_output)`` indicating which
|
|
41
|
+
singleton dimensions were inserted and should be removed later.
|
|
42
|
+
"""
|
|
43
|
+
squeeze_time = False
|
|
44
|
+
squeeze_output = False
|
|
45
|
+
# Normalize to 3-D so downstream kernels always see (samples, T, K)
|
|
46
|
+
# without shape-dependent branching.
|
|
47
|
+
if Y.ndim == 1: # scalar output per sample -- both T and K are singleton
|
|
48
|
+
Y = Y[:, None, None]
|
|
49
|
+
squeeze_time = True
|
|
50
|
+
squeeze_output = True
|
|
51
|
+
elif Y.ndim == 2: # multi-output but single timestep -- only T is singleton
|
|
52
|
+
Y = Y[:, None, :]
|
|
53
|
+
squeeze_time = True
|
|
54
|
+
return Y, squeeze_time, squeeze_output
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _warn_zero_variance_slices(
|
|
58
|
+
Y: Array,
|
|
59
|
+
output_names: tuple[str, ...] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Check for zero-variance output slices before analysis and warn.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
Y: Model output array with shape ``(n_expanded, ...)`` where
|
|
65
|
+
trailing dims are ``()``, ``(K,)``, or ``(T, K)``.
|
|
66
|
+
output_names: Optional names for the K output dimension.
|
|
67
|
+
"""
|
|
68
|
+
import warnings
|
|
69
|
+
|
|
70
|
+
# Collapse trailing dims so variance is computed per (t, k) slice.
|
|
71
|
+
flat = Y.reshape(Y.shape[0], -1)
|
|
72
|
+
n_outputs = flat.shape[1]
|
|
73
|
+
|
|
74
|
+
# Recover K from the original shape to map flat indices back to named outputs.
|
|
75
|
+
trailing = Y.shape[1:]
|
|
76
|
+
if len(trailing) == 0:
|
|
77
|
+
K = 1
|
|
78
|
+
elif len(trailing) == 1:
|
|
79
|
+
K = trailing[0]
|
|
80
|
+
else:
|
|
81
|
+
K = trailing[1]
|
|
82
|
+
|
|
83
|
+
if output_names is not None and len(output_names) != K:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"len(output_names)={len(output_names)} does not match number of outputs K={K}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _fmt_k(k: int) -> str:
|
|
89
|
+
if output_names is not None:
|
|
90
|
+
return f"k={k} ('{output_names[k]}')"
|
|
91
|
+
return f"k={k}"
|
|
92
|
+
|
|
93
|
+
# Sample variance along axis 0; zero means the output is constant
|
|
94
|
+
# and Sobol indices become 0/0 = NaN.
|
|
95
|
+
var_per_slice = jnp.var(flat, axis=0)
|
|
96
|
+
zero_mask = var_per_slice == 0
|
|
97
|
+
n_zero = int(jnp.sum(zero_mask))
|
|
98
|
+
|
|
99
|
+
if n_zero == 0:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if n_outputs == 1:
|
|
103
|
+
if output_names is not None and len(output_names) == 1:
|
|
104
|
+
msg = f"gsax: output '{output_names[0]}' has zero variance — all indices will be NaN"
|
|
105
|
+
else:
|
|
106
|
+
msg = "gsax: output has zero variance — all indices will be NaN"
|
|
107
|
+
warnings.warn(msg, stacklevel=2)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Materialize indices eagerly -- this is a rare warning path, not a hot loop.
|
|
111
|
+
zero_indices = [int(i) for i in jnp.where(zero_mask)[0]]
|
|
112
|
+
|
|
113
|
+
if len(trailing) == 1: # single-timestep: flat index equals output index k
|
|
114
|
+
labels = [_fmt_k(k) for k in zero_indices]
|
|
115
|
+
if len(labels) > 5: # cap displayed labels to keep warnings readable
|
|
116
|
+
shown = ", ".join(labels[:5])
|
|
117
|
+
extra = f"... and {len(labels) - 5} more"
|
|
118
|
+
label_str = f"{shown}, {extra}"
|
|
119
|
+
else:
|
|
120
|
+
label_str = ", ".join(labels)
|
|
121
|
+
warnings.warn(
|
|
122
|
+
f"gsax: {n_zero}/{n_outputs} output(s) have zero variance "
|
|
123
|
+
f"({label_str}) — corresponding indices will be NaN",
|
|
124
|
+
stacklevel=2,
|
|
125
|
+
)
|
|
126
|
+
elif len(trailing) == 2: # multi-timestep: flat index encodes (t, k) in row-major order
|
|
127
|
+
affected = []
|
|
128
|
+
for idx in zero_indices:
|
|
129
|
+
# divmod decomposes flat index into (time_step, output_column)
|
|
130
|
+
t, k = divmod(idx, K)
|
|
131
|
+
affected.append(f"(t={t}, {_fmt_k(k)})")
|
|
132
|
+
if len(affected) > 5: # cap displayed labels to keep warnings readable
|
|
133
|
+
shown = ", ".join(affected[:5])
|
|
134
|
+
extra = f"... and {len(affected) - 5} more"
|
|
135
|
+
label_str = f"{shown}, {extra}"
|
|
136
|
+
else:
|
|
137
|
+
label_str = ", ".join(affected)
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"gsax: {n_zero}/{n_outputs} output slice(s) have zero variance "
|
|
140
|
+
f"[{label_str}] — corresponding indices will be NaN",
|
|
141
|
+
stacklevel=2,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _prenormalize_outputs(Y: Array) -> tuple[Array, Array, Array, Array]:
|
|
146
|
+
"""Standardize outputs over the sample axis.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
Y: Output array with shape ``(N, ...)``. The first axis is treated as
|
|
150
|
+
the sample axis, and all trailing axes are normalized
|
|
151
|
+
independently.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
A tuple ``(Y_norm, y_mean, y_std, safe_scale)`` where:
|
|
155
|
+
- ``Y_norm`` is the centered/scaled output array.
|
|
156
|
+
- ``y_mean`` is the per-output-slice mean.
|
|
157
|
+
- ``y_std`` is the per-output-slice original standard deviation.
|
|
158
|
+
- ``safe_scale`` is the divisor actually used, with zeros replaced
|
|
159
|
+
by ``1.0`` to avoid division by zero.
|
|
160
|
+
"""
|
|
161
|
+
# Centering + scaling stabilizes Sobol variance estimators when output
|
|
162
|
+
# magnitudes vary across slices (prevents large-magnitude slices from
|
|
163
|
+
# dominating numerical precision).
|
|
164
|
+
y_mean = jnp.mean(Y, axis=0)
|
|
165
|
+
y_std = jnp.std(Y, axis=0)
|
|
166
|
+
# Replace zero std with 1.0 so division doesn't produce NaN; the
|
|
167
|
+
# corresponding zero-variance output slices remain all-zero after scaling.
|
|
168
|
+
safe_scale = jnp.where(y_std == 0, jnp.ones_like(y_std), y_std)
|
|
169
|
+
Y_norm = (Y - y_mean) / safe_scale
|
|
170
|
+
return Y_norm, y_mean, y_std, safe_scale
|
gsax/_transforms.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared input transforms for expansion methods (HDMR, PCE)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
import numpy as np
|
|
7
|
+
from jax import Array
|
|
8
|
+
|
|
9
|
+
from gsax.problem import Problem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cdf_to_unit_interval(X: Array, problem: Problem) -> Array:
|
|
13
|
+
"""Map each column of X to [0, 1] via its marginal CDF.
|
|
14
|
+
|
|
15
|
+
Uniform inputs use an affine map. Gaussian inputs (truncated or
|
|
16
|
+
not) are mapped through their normal CDF. The result is always in
|
|
17
|
+
[0, 1], suitable for B-spline bases or further affine mapping to
|
|
18
|
+
[-1, 1] for Legendre polynomials.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
X: (N, D) input samples in physical units.
|
|
22
|
+
problem: Problem with per-dimension distribution specs.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
(N, D) array with each column in [0, 1].
|
|
26
|
+
"""
|
|
27
|
+
from jax.scipy.stats import norm as jax_norm
|
|
28
|
+
from scipy.stats import truncnorm
|
|
29
|
+
|
|
30
|
+
D = problem.num_vars
|
|
31
|
+
cols = [] # one column per dimension, stacked at the end into (N, D)
|
|
32
|
+
|
|
33
|
+
# CDF-to-unit-interval mapping: apply F(x) per dimension so each column
|
|
34
|
+
# lands in (0, 1). For Gaussian inputs, standardised truncation bounds
|
|
35
|
+
# a=(lo-mu)/sigma, b=(hi-mu)/sigma follow scipy's truncnorm convention.
|
|
36
|
+
# Clipping output to (1e-12, 1-1e-12) keeps values in the open interval
|
|
37
|
+
# so downstream inverse transforms (ppf) never receive exactly 0 or 1.
|
|
38
|
+
for d in range(D):
|
|
39
|
+
dist, first, second, lo, hi = problem.input_specs[d]
|
|
40
|
+
if dist == "uniform": # affine map (x - lo)/(hi - lo) is the CDF of U(lo, hi)
|
|
41
|
+
cols.append((X[:, d] - first) / (second - first))
|
|
42
|
+
# Truncated Gaussian -- need scipy because JAX lacks a truncnorm CDF.
|
|
43
|
+
elif lo is not None or hi is not None:
|
|
44
|
+
mean, variance = first, second
|
|
45
|
+
std = float(jnp.sqrt(variance))
|
|
46
|
+
a = -np.inf if lo is None else (lo - mean) / std
|
|
47
|
+
b = np.inf if hi is None else (hi - mean) / std
|
|
48
|
+
u = jnp.asarray(truncnorm.cdf(np.asarray(X[:, d]), a=a, b=b, loc=mean, scale=std))
|
|
49
|
+
cols.append(jnp.clip(u, 1e-12, 1.0 - 1e-12))
|
|
50
|
+
else: # unbounded Gaussian -- stays on device via jax.scipy
|
|
51
|
+
mean, variance = first, second
|
|
52
|
+
std = float(jnp.sqrt(variance))
|
|
53
|
+
u = jax_norm.cdf(X[:, d], loc=mean, scale=std)
|
|
54
|
+
cols.append(jnp.clip(u, 1e-12, 1.0 - 1e-12))
|
|
55
|
+
|
|
56
|
+
return jnp.column_stack(cols) # reassemble per-dimension columns into (N, D)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Analytical benchmark functions with known Sobol indices.
|
|
2
|
+
|
|
3
|
+
Each submodule provides:
|
|
4
|
+
- ``PROBLEM``: a :class:`~gsax.Problem` definition.
|
|
5
|
+
- ``evaluate(X)``: the benchmark function (JAX-compatible).
|
|
6
|
+
- ``ANALYTICAL_S1``, ``ANALYTICAL_ST``, ``ANALYTICAL_S2``: precomputed
|
|
7
|
+
indices for the default parameters.
|
|
8
|
+
- ``analytical_indices(...)``: compute indices for custom parameters.
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
from gsax.benchmarks import ishigami
|
|
13
|
+
from gsax import sample, analyze
|
|
14
|
+
|
|
15
|
+
sr = sample(ishigami.PROBLEM, 4096)
|
|
16
|
+
Y = ishigami.evaluate(sr.samples)
|
|
17
|
+
result = analyze(sr, Y)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from gsax.benchmarks import ishigami, linear, oakley_ohagan, sobol_g
|
|
21
|
+
|
|
22
|
+
__all__ = ["ishigami", "linear", "oakley_ohagan", "sobol_g"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Ishigami test function for sensitivity analysis benchmarking.
|
|
2
|
+
|
|
3
|
+
A standard benchmark with 3 input parameters (x1, x2, x3) and known
|
|
4
|
+
analytical Sobol indices, commonly used to validate sensitivity analysis methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import jax.numpy as jnp
|
|
10
|
+
import numpy as np
|
|
11
|
+
from jax import Array
|
|
12
|
+
|
|
13
|
+
from gsax.problem import Problem
|
|
14
|
+
|
|
15
|
+
# All three inputs are uniform on [-pi, pi], the standard domain for
|
|
16
|
+
# the trigonometric terms in the Ishigami function.
|
|
17
|
+
PROBLEM = Problem.from_dict(
|
|
18
|
+
{
|
|
19
|
+
"x1": (-np.pi, np.pi),
|
|
20
|
+
"x2": (-np.pi, np.pi),
|
|
21
|
+
"x3": (-np.pi, np.pi),
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def analytical_indices(
|
|
27
|
+
A: float = 7.0, B: float = 0.1
|
|
28
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
29
|
+
"""Compute analytical first-order, total-order, and second-order Sobol indices.
|
|
30
|
+
|
|
31
|
+
For the Ishigami function ``f(x) = sin(x1) + A*sin^2(x2) + B*x3^4*sin(x1)``
|
|
32
|
+
with ``x_i ~ U[-pi, pi]``, the ANOVA decomposition is available in closed
|
|
33
|
+
form using Gaussian moment identities for uniform trig integrals.
|
|
34
|
+
|
|
35
|
+
The main-effect variances are:
|
|
36
|
+
|
|
37
|
+
- ``V1 = (1 + B*pi^4/5)^2 / 2``
|
|
38
|
+
- ``V2 = A^2 / 8``
|
|
39
|
+
- ``V3 = 0``
|
|
40
|
+
|
|
41
|
+
The only pairwise interaction is between x1 and x3:
|
|
42
|
+
|
|
43
|
+
- ``V13 = 8 * B^2 * pi^8 / 225``
|
|
44
|
+
|
|
45
|
+
And the total variance:
|
|
46
|
+
|
|
47
|
+
- ``V = 1/2 + B*pi^4/5 + B^2*pi^8/18 + A^2/8``
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
A: Model parameter controlling the second-order term.
|
|
51
|
+
B: Model parameter controlling the higher-order interaction term.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
``(S1, ST, S2)`` where S1 and ST are ``(3,)`` arrays and S2 is
|
|
55
|
+
a ``(3, 3)`` symmetric matrix with NaN on the diagonal.
|
|
56
|
+
"""
|
|
57
|
+
pi4 = np.pi**4
|
|
58
|
+
pi8 = np.pi**8
|
|
59
|
+
|
|
60
|
+
# Main-effect variances from the ANOVA decomposition.
|
|
61
|
+
V1 = 0.5 * (1.0 + B * pi4 / 5.0) ** 2
|
|
62
|
+
V2 = A**2 / 8.0
|
|
63
|
+
V3 = 0.0
|
|
64
|
+
|
|
65
|
+
# x1-x3 interaction variance (the B*x3^4*sin(x1) cross-term).
|
|
66
|
+
V13 = 8.0 * B**2 * pi8 / 225.0
|
|
67
|
+
|
|
68
|
+
# Total variance = sum of all partial variances.
|
|
69
|
+
VY = 0.5 + B * pi4 / 5.0 + B**2 * pi8 / 18.0 + A**2 / 8.0
|
|
70
|
+
|
|
71
|
+
Vi = np.array([V1, V2, V3])
|
|
72
|
+
S1 = Vi / VY
|
|
73
|
+
|
|
74
|
+
# Total-order: main effect + all interactions involving that input.
|
|
75
|
+
ST = np.array([V1 + V13, V2, V13]) / VY
|
|
76
|
+
|
|
77
|
+
# Second-order interaction matrix: only (0, 2) and (2, 0) are nonzero.
|
|
78
|
+
S2 = np.full((3, 3), np.nan)
|
|
79
|
+
S2[0, 1] = 0.0
|
|
80
|
+
S2[1, 0] = 0.0
|
|
81
|
+
S2[0, 2] = V13 / VY
|
|
82
|
+
S2[2, 0] = V13 / VY
|
|
83
|
+
S2[1, 2] = 0.0
|
|
84
|
+
S2[2, 1] = 0.0
|
|
85
|
+
|
|
86
|
+
return S1, ST, S2
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Precomputed analytical solutions for A=7, B=0.1.
|
|
90
|
+
# x3 has zero first-order effect (enters only through the B*x3^4*sin(x1) interaction),
|
|
91
|
+
# making it a good test for methods that must distinguish S1=0 from ST>0.
|
|
92
|
+
ANALYTICAL_S1, ANALYTICAL_ST, ANALYTICAL_S2 = analytical_indices()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def evaluate(X: Array, A: float = 7.0, B: float = 0.1) -> Array:
|
|
96
|
+
"""Evaluate the Ishigami function.
|
|
97
|
+
|
|
98
|
+
f(x) = sin(x1) + A*sin^2(x2) + B*x3^4*sin(x1)
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
X: Input array of shape ``(N, 3)`` with columns x1, x2, x3.
|
|
102
|
+
A: Model parameter controlling the second-order term.
|
|
103
|
+
B: Model parameter controlling the higher-order interaction term.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Array of shape ``(N,)`` with the function output for each sample.
|
|
107
|
+
"""
|
|
108
|
+
# sin(x1): first-order effect of x1
|
|
109
|
+
# A*sin^2(x2): purely additive in x2 (large A=7 makes it the dominant first-order term)
|
|
110
|
+
# B*x3^4*sin(x1): x1-x3 interaction (small B=0.1 keeps it a minority contribution)
|
|
111
|
+
return jnp.sin(X[:, 0]) + A * jnp.sin(X[:, 1]) ** 2 + B * X[:, 2] ** 4 * jnp.sin(X[:, 0])
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Linear additive model for sensitivity analysis benchmarking.
|
|
2
|
+
|
|
3
|
+
The simplest possible benchmark: a weighted sum of independent uniform
|
|
4
|
+
inputs. Because the model is purely additive, first-order indices equal
|
|
5
|
+
total-order indices and all second-order interactions are exactly zero.
|
|
6
|
+
|
|
7
|
+
Useful for:
|
|
8
|
+
- Verifying that a SA method correctly identifies zero interactions.
|
|
9
|
+
- Sanity-checking that S1 == ST when there are no interactions.
|
|
10
|
+
- Testing convergence rates (exact analytical solution is trivial).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import jax.numpy as jnp
|
|
14
|
+
import numpy as np
|
|
15
|
+
from jax import Array
|
|
16
|
+
|
|
17
|
+
from gsax.problem import Problem
|
|
18
|
+
|
|
19
|
+
# Increasing weights (1, 2, 3) test whether the method correctly ranks
|
|
20
|
+
# input importance: x3 should have the largest index, x1 the smallest.
|
|
21
|
+
DEFAULT_COEFFS = (1.0, 2.0, 3.0)
|
|
22
|
+
DEFAULT_BOUNDS = ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0))
|
|
23
|
+
|
|
24
|
+
PROBLEM = Problem(
|
|
25
|
+
names=tuple(f"x{i + 1}" for i in range(len(DEFAULT_COEFFS))),
|
|
26
|
+
bounds=DEFAULT_BOUNDS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def evaluate(
|
|
31
|
+
X: Array,
|
|
32
|
+
coeffs: tuple[float, ...] = DEFAULT_COEFFS,
|
|
33
|
+
) -> Array:
|
|
34
|
+
"""Evaluate the linear additive model.
|
|
35
|
+
|
|
36
|
+
.. math::
|
|
37
|
+
f(\\mathbf{x}) = \\sum_{j=1}^{D} c_j \\, x_j
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
X: Input array of shape ``(N, D)``.
|
|
41
|
+
coeffs: Coefficient per dimension.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Array of shape ``(N,)`` with function values.
|
|
45
|
+
"""
|
|
46
|
+
# Matrix-vector multiply: vectorized weighted sum across all N samples at once.
|
|
47
|
+
return X @ jnp.asarray(coeffs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analytical_indices(
|
|
51
|
+
coeffs: tuple[float, ...] = DEFAULT_COEFFS,
|
|
52
|
+
bounds: tuple[tuple[float, float], ...] = DEFAULT_BOUNDS,
|
|
53
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
54
|
+
"""Compute analytical Sobol indices for the linear additive model.
|
|
55
|
+
|
|
56
|
+
For ``Y = sum c_j x_j`` with independent inputs:
|
|
57
|
+
|
|
58
|
+
.. math::
|
|
59
|
+
V_j = c_j^2 \\operatorname{Var}(x_j), \\quad
|
|
60
|
+
S_{1,j} = S_{T,j} = V_j / V(Y), \\quad
|
|
61
|
+
S_{2,jk} = 0
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
coeffs: Coefficient per dimension.
|
|
65
|
+
bounds: ``(low, high)`` bounds for each uniform input.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
``(S1, ST, S2)`` where S1 == ST (no interactions) and S2 is
|
|
69
|
+
all-zero off-diagonal with NaN on the diagonal.
|
|
70
|
+
"""
|
|
71
|
+
c = np.asarray(coeffs, dtype=float)
|
|
72
|
+
D = len(c)
|
|
73
|
+
# Var(Uniform[lo, hi]) = (hi - lo)^2 / 12
|
|
74
|
+
var_x = np.array([(hi - lo) ** 2 / 12.0 for lo, hi in bounds])
|
|
75
|
+
# Variance propagation for linear functions: Vi = c_j^2 * Var(x_j)
|
|
76
|
+
Vi = c**2 * var_x
|
|
77
|
+
VY = Vi.sum()
|
|
78
|
+
|
|
79
|
+
# Purely additive model => no interactions => S1 = ST and sum(S1) = 1
|
|
80
|
+
S1 = Vi / VY
|
|
81
|
+
ST = S1.copy()
|
|
82
|
+
|
|
83
|
+
# Diagonal is NaN by convention (S2_jj is undefined); off-diagonals are
|
|
84
|
+
# exactly zero because a purely additive model has no interactions.
|
|
85
|
+
S2 = np.full((D, D), np.nan)
|
|
86
|
+
for j in range(D):
|
|
87
|
+
for k in range(j + 1, D):
|
|
88
|
+
S2[j, k] = 0.0
|
|
89
|
+
S2[k, j] = 0.0
|
|
90
|
+
|
|
91
|
+
return S1, ST, S2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
ANALYTICAL_S1, ANALYTICAL_ST, ANALYTICAL_S2 = analytical_indices()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# ruff: noqa: E501
|
|
2
|
+
"""Oakley & O'Hagan (2004) 15-dimensional Gaussian-input benchmark.
|
|
3
|
+
|
|
4
|
+
A canonical benchmark for variance-based sensitivity analysis with
|
|
5
|
+
Gaussian inputs. The function combines linear, trigonometric, and
|
|
6
|
+
quadratic terms so that first-order, total-order, and pairwise
|
|
7
|
+
interaction Sobol indices are all available in closed form.
|
|
8
|
+
|
|
9
|
+
.. math::
|
|
10
|
+
f(\\mathbf{x}) = \\mathbf{a}_1^\\top \\mathbf{x}
|
|
11
|
+
+ \\mathbf{a}_2^\\top \\sin(\\mathbf{x})
|
|
12
|
+
+ \\mathbf{a}_3^\\top \\cos(\\mathbf{x})
|
|
13
|
+
+ \\mathbf{x}^\\top M \\mathbf{x}
|
|
14
|
+
|
|
15
|
+
with :math:`x_i \\sim \\mathcal{N}(0, \\sigma^2)` i.i.d.
|
|
16
|
+
|
|
17
|
+
The coefficients are the published values from Oakley & O'Hagan (2004,
|
|
18
|
+
JRSS-B 66:751-769), embedded as literals so no external data file is
|
|
19
|
+
needed.
|
|
20
|
+
|
|
21
|
+
References:
|
|
22
|
+
Oakley, J. E. and O'Hagan, A. (2004). Probabilistic sensitivity
|
|
23
|
+
analysis of complex models: a Bayesian approach. J. R. Statist.
|
|
24
|
+
Soc. B, 66(3):751-769.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import jax.numpy as jnp
|
|
30
|
+
import numpy as np
|
|
31
|
+
from jax import Array
|
|
32
|
+
|
|
33
|
+
from gsax.problem import Problem
|
|
34
|
+
|
|
35
|
+
# 15 dimensions — high enough to stress-test scalability and to span
|
|
36
|
+
# a wide range of importance levels (some inputs are nearly inert).
|
|
37
|
+
D = 15
|
|
38
|
+
DEFAULT_SIGMA = 1.0
|
|
39
|
+
|
|
40
|
+
# Published coefficients from Oakley & O'Hagan (2004, Table 1 / psa_example.txt).
|
|
41
|
+
# fmt: off
|
|
42
|
+
_M = np.array([
|
|
43
|
+
[-0.0225, -0.185, 0.134, 0.369, 0.172, 0.137, -0.44, -0.0814, 0.713, -0.444, 0.504, -0.0241, -0.0459, 0.217, 0.0559],
|
|
44
|
+
[0.257, 0.0538, 0.258, 0.238, -0.591, -0.0816, -0.287, 0.416, 0.498, 0.0839, -0.111, 0.0332, -0.14, -0.031, -0.223],
|
|
45
|
+
[-0.056, 0.195, 0.0955, -0.286, -0.144, 0.224, 0.145, 0.29, 0.231, -0.319, -0.29, -0.21, 0.431, 0.0244, 0.0449],
|
|
46
|
+
[0.664, 0.431, 0.299, -0.162, -0.315, -0.39, 0.177, 0.058, 0.172, 0.135, -0.353, 0.251, -0.0188, 0.365, -0.325],
|
|
47
|
+
[-0.121, 0.125, 0.107, 0.0466, -0.217, 0.195, -0.0655, 0.0244, -0.0968, 0.194, 0.334, 0.313, -0.0836, -0.253, 0.373],
|
|
48
|
+
[-0.284, -0.328, -0.105, -0.221, -0.137, -0.144, -0.115, 0.224, -0.0304, -0.515, 0.0173, 0.039, 0.361, 0.309, 0.05],
|
|
49
|
+
[-0.0779, 0.00375, 0.887, -0.266, -0.0793, -0.0427, -0.187, -0.356, -0.175, 0.0887, 0.4, -0.056, 0.137, 0.215, -0.0113],
|
|
50
|
+
[-0.0923, 0.592, 0.0313, -0.0331, -0.243, -0.0998, 0.0345, 0.0951, -0.338, 0.00639, -0.612, 0.0813, 0.887, 0.143, 0.148],
|
|
51
|
+
[-0.132, 0.529, 0.127, 0.0451, 0.584, 0.373, 0.114, -0.295, -0.57, 0.463, -0.0941, 0.14, -0.386, -0.449, -0.146],
|
|
52
|
+
[0.0581, -0.323, 0.0931, 0.0724, -0.569, 0.526, 0.237, -0.0118, 0.0718, 0.0783, -0.134, 0.227, 0.144, -0.452, -0.556],
|
|
53
|
+
[0.661, 0.346, 0.141, 0.519, -0.28, -0.16, -0.0684, -0.204, 0.0697, 0.231, -0.0444, -0.165, 0.216, 0.00427, -0.0874],
|
|
54
|
+
[0.316, -0.0276, 0.134, 0.135, 0.054, -0.174, 0.175, 0.0603, -0.179, -0.311, -0.254, 0.0258, -0.43, -0.623, -0.034],
|
|
55
|
+
[-0.29, 0.0341, 0.0349, -0.121, 0.026, -0.335, -0.414, 0.0532, -0.271, -0.0263, 0.41, 0.266, 0.156, -0.187, 0.0199],
|
|
56
|
+
[-0.244, -0.441, 0.0126, 0.249, 0.0711, 0.246, 0.175, 0.00853, 0.251, -0.147, -0.0846, 0.369, -0.3, 0.11, -0.757],
|
|
57
|
+
[0.0415, -0.26, 0.464, -0.361, -0.95, -0.165, 0.00309, 0.0528, 0.225, 0.384, 0.456, -0.186, 0.00823, 0.167, 0.16],
|
|
58
|
+
], dtype=float)
|
|
59
|
+
|
|
60
|
+
# Coefficients for linear, sin, and cos terms respectively.
|
|
61
|
+
# Magnitudes increase toward higher indices, creating a natural importance
|
|
62
|
+
# gradient: x11-x15 dominate, x1-x5 are nearly inert, x6-x10 intermediate.
|
|
63
|
+
_A1 = np.array([0.0118, 0.0456, 0.2297, 0.0393, 0.1177, 0.3865, 0.3897, 0.6061, 0.6159, 0.4005, 1.0741, 1.1474, 0.788, 1.1242, 1.1982], dtype=float)
|
|
64
|
+
_A2 = np.array([0.4341, 0.0887, 0.0512, 0.3233, 0.1489, 1.036, 0.9892, 0.9672, 0.8977, 0.8083, 1.8426, 2.4712, 2.3946, 2.0045, 2.2621], dtype=float)
|
|
65
|
+
_A3 = np.array([0.1044, 0.2057, 0.0774, 0.273, 0.1253, 0.7526, 0.857, 1.0331, 0.8388, 0.797, 2.2145, 2.0382, 2.4004, 2.0541, 1.9845], dtype=float)
|
|
66
|
+
# fmt: on
|
|
67
|
+
|
|
68
|
+
# Reference S1 values from the original paper; useful for cross-checking
|
|
69
|
+
# our analytical derivation against the published table.
|
|
70
|
+
PUBLISHED_S1 = np.array(
|
|
71
|
+
[
|
|
72
|
+
0.00156,
|
|
73
|
+
0.000186,
|
|
74
|
+
0.001307,
|
|
75
|
+
0.003045,
|
|
76
|
+
0.002905,
|
|
77
|
+
0.023035,
|
|
78
|
+
0.024151,
|
|
79
|
+
0.026517,
|
|
80
|
+
0.046036,
|
|
81
|
+
0.014945,
|
|
82
|
+
0.101823,
|
|
83
|
+
0.135708,
|
|
84
|
+
0.101989,
|
|
85
|
+
0.105169,
|
|
86
|
+
0.122818,
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Gaussian inputs (not uniform) -- one of few SA benchmarks with non-uniform distributions.
|
|
91
|
+
PROBLEM = Problem.from_dict(
|
|
92
|
+
{
|
|
93
|
+
f"x{i + 1}": {"dist": "gaussian", "mean": 0.0, "variance": DEFAULT_SIGMA**2}
|
|
94
|
+
for i in range(D)
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def evaluate(X: Array) -> Array:
|
|
100
|
+
"""Evaluate the Oakley & O'Hagan function.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
X: Input array of shape ``(N, 15)`` with ``x_i ~ N(0, sigma^2)``.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Array of shape ``(N,)`` with function values.
|
|
107
|
+
"""
|
|
108
|
+
Xj = jnp.asarray(X)
|
|
109
|
+
# Four additive components: linear a1^T x, trigonometric a2^T sin(x) + a3^T cos(x),
|
|
110
|
+
# and the quadratic form x^T M x which introduces all pairwise interactions.
|
|
111
|
+
return (
|
|
112
|
+
Xj @ jnp.asarray(_A1)
|
|
113
|
+
+ jnp.sin(Xj) @ jnp.asarray(_A2)
|
|
114
|
+
+ jnp.cos(Xj) @ jnp.asarray(_A3)
|
|
115
|
+
# Batched quadratic form: einsum contracts x_i * M_ij * x_j per sample row.
|
|
116
|
+
+ jnp.einsum("ni,ij,nj->n", Xj, jnp.asarray(_M), Xj)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def analytical_indices(
|
|
121
|
+
sigma: float = DEFAULT_SIGMA,
|
|
122
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
123
|
+
"""Compute analytical first-order, total-order, and second-order Sobol indices.
|
|
124
|
+
|
|
125
|
+
Each input enters through the block ``(x, sin x, cos x, x^2)``
|
|
126
|
+
(main effect) plus off-diagonal quadratic cross-terms (pairwise
|
|
127
|
+
interactions). The main-effect variance is ``c_i' Sigma c_i`` where
|
|
128
|
+
``Sigma`` is the covariance of that block under ``N(0, sigma^2)``,
|
|
129
|
+
and pairwise interaction variance is ``(M_ij + M_ji)^2 * sigma^4``.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
sigma: Standard deviation of each input.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
``(S1, ST, S2)`` where S1 and ST are ``(15,)`` arrays and S2 is
|
|
136
|
+
a ``(15, 15)`` symmetric matrix with NaN on the diagonal.
|
|
137
|
+
"""
|
|
138
|
+
s2 = sigma**2
|
|
139
|
+
# Gaussian moment-generating-function identities for X ~ N(0, s2):
|
|
140
|
+
# E[sin X] = 0, E[cos X] = exp(-s2/2), E[sin^2 X] = (1 - exp(-2s2))/2
|
|
141
|
+
es = np.exp(-0.5 * s2) # = E[cos X], used in cross-covariances
|
|
142
|
+
es2 = np.exp(-2.0 * s2) # appears in Var[sin X] and Var[cos X]
|
|
143
|
+
|
|
144
|
+
# Covariance matrix of the feature vector (x, sin x, cos x, x^2) for X~N(0,s2).
|
|
145
|
+
# Block-diagonal: (x, sin x) decouple from (cos x, x^2) because odd/even symmetry.
|
|
146
|
+
# Each input's main-effect variance is Vi = c_i^T * cov_block * c_i
|
|
147
|
+
# where c_i = [a1_i, a2_i, a3_i, M_ii].
|
|
148
|
+
cov_block = np.array(
|
|
149
|
+
[
|
|
150
|
+
[s2, s2 * es, 0.0, 0.0],
|
|
151
|
+
[s2 * es, (1 - es2) / 2, 0.0, 0.0],
|
|
152
|
+
[0.0, 0.0, (1 + es2) / 2 - es**2, -(s2**2) * es],
|
|
153
|
+
[0.0, 0.0, -(s2**2) * es, 2 * s2**2],
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Main-effect variance for each input: quadratic form c_i^T Sigma c_i
|
|
158
|
+
Vi = np.array(
|
|
159
|
+
[
|
|
160
|
+
np.array([_A1[i], _A2[i], _A3[i], _M[i, i]])
|
|
161
|
+
@ cov_block
|
|
162
|
+
@ np.array([_A1[i], _A2[i], _A3[i], _M[i, i]])
|
|
163
|
+
for i in range(D)
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Off-diagonal cross-terms x_i*x_k from the quadratic form contribute
|
|
168
|
+
# interaction variance Vpair_ik = ((M_ik + M_ki) * s2)^2 (symmetrized).
|
|
169
|
+
Vpair = ((_M + _M.T) * s2) ** 2
|
|
170
|
+
np.fill_diagonal(Vpair, 0.0) # diagonal already captured in Vi via M_ii
|
|
171
|
+
|
|
172
|
+
# Total variance = sum of all main effects + sum of all unique pairwise interactions.
|
|
173
|
+
total_var = float(Vi.sum() + np.triu(Vpair, 1).sum())
|
|
174
|
+
S1 = Vi / total_var
|
|
175
|
+
# ST_i = main effect + all pairwise interactions involving input i
|
|
176
|
+
ST = (Vi + Vpair.sum(axis=1)) / total_var
|
|
177
|
+
|
|
178
|
+
# Second-order interaction matrix: S2_ij = Vpair_ij / total_var.
|
|
179
|
+
S2 = np.full((D, D), np.nan)
|
|
180
|
+
for j in range(D):
|
|
181
|
+
for k in range(j + 1, D):
|
|
182
|
+
val = Vpair[j, k] / total_var
|
|
183
|
+
S2[j, k] = val
|
|
184
|
+
S2[k, j] = val
|
|
185
|
+
|
|
186
|
+
return S1, ST, S2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
ANALYTICAL_S1, ANALYTICAL_ST, ANALYTICAL_S2 = analytical_indices()
|