Skip to content

feat: sobol_indices() -- expose total-order Sobol indices (#120) - #121

Merged
jc-macdonald merged 1 commit into
mainfrom
feat/120-sobol-total-order
Aug 20, 2026
Merged

feat: sobol_indices() -- expose total-order Sobol indices (#120)#121
jc-macdonald merged 1 commit into
mainfrom
feat/120-sobol-total-order

Conversation

@jc-macdonald

Copy link
Copy Markdown
Collaborator

Summary

`screen(method="sobol")` only ever returned S1 (first-order), silently discarding ST (total-order) even though the same underlying `SALib.analyze.sobol.analyze()` call already computes it for free. `ST - S1` is the standard way to detect interaction effects -- a factor with small S1 but large ST is interacting with other factors rather than acting independently -- and `screen()` couldn't surface that at all.

Design

  • New `sobol_indices(run_fn, factors, *, n_samples=100, seed=42) -> dict[str, tuple[NDArray, NDArray]]`, returning `{observable: (S1, ST)}`. `screen()` itself is untouched, so this is fully backward compatible for existing callers.
  • Shares the Saltelli sampling/evaluation loop with `_screen_sobol` via a new `_sobol_sample_and_evaluate()` helper rather than duplicating it -- both draw from and evaluate the exact same design given the same inputs, confirmed by a test asserting `sobol_indices`'s S1 matches `screen()`'s S1 exactly.

Test plan

  • `just ci` green (345 passed, `design.py` 100% coverage)
  • `sobol_indices`'s S1 output matches `screen(method="sobol")`'s S1 exactly (same seed/sampling)
  • `ST >= S1` holds (within MC estimation tolerance)
  • From-scratch interaction-detection test: a pure product of two zero-mean-centered factors (`E[Y|A] = A*E[B] = 0` and vice versa, so S1~0 for both) shows `ST > 0` for both, confirming ST actually catches a real interaction S1 structurally can't see -- not just a shape/type check
  • Rejects a factor list with no continuous factors, matching `screen()`'s existing behavior

Closes #120

screen(method="sobol") only ever returned S1 (first-order), silently
discarding ST (total-order) even though the same SALib
sobol_analyze.analyze() call already computes it. S_T - S_1 is the
standard way to detect interaction effects -- a factor with small S1
but large ST is interacting with other factors rather than acting
independently -- and screen() couldn't surface that at all.

sobol_indices() is a new function (screen() itself is unchanged, so
existing callers are unaffected) returning {observable: (S1, ST)}.
Shares the Saltelli sampling/evaluation loop with _screen_sobol via a
new _sobol_sample_and_evaluate() helper rather than duplicating it.

Tests include a from-scratch interaction-detection case (a pure
product of two zero-mean-centered factors: S1~0 for both, since
E[Y|A]=A*E[B]=0 and vice versa, but ST>0 for both since the
interaction is real) confirming ST actually catches what S1 misses,
not just that the two arrays have the right shape.
@jc-macdonald
jc-macdonald merged commit 8d56284 into main Aug 20, 2026
4 checks passed
@jc-macdonald
jc-macdonald deleted the feat/120-sobol-total-order branch August 20, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

screen(method='sobol') discards SALib's total-order index (ST), only returns S1

1 participant