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 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()