from types import MethodType
import numpy as np
from scipy.stats import norm
from . import common_args
from ..util import ResultDict, read_param_file
from ..plotting.shapley import plot as shapley_plot
[docs]
def analyze(
problem: dict,
X: np.ndarray,
Y: np.ndarray,
conf_level: float = 0.95,
print_to_console: bool = False,
) -> ResultDict:
"""Estimate Shapley effects with Goda's Monte Carlo algorithm.
Returns Shapley effects in output-variance units (``shapley``, summing to
the overall output variance) alongside normalized shares that sum to one
(``shapley_normalized``), each with a normal-approximation confidence
interval half-width (``shapley_conf``, ``shapley_normalized_conf``).
Notes
-----
Compatible with:
Compatible with :func:`SALib.sample.shapley.sample`.
The estimator assumes mutually independent inputs and does not support
grouped parameters.
Parameters
----------
problem : dict
The problem definition.
X : numpy.ndarray
Model inputs generated by :func:`SALib.sample.shapley.sample`.
Y : numpy.ndarray
One-dimensional array of model outputs.
conf_level : float, default=0.95
Confidence level used for normal-approximation interval half-widths.
print_to_console : bool, default=False
Print results directly to the console.
Returns
-------
ResultDict
Raw and normalized Shapley effect estimates, their confidence
interval half-widths, and factor names.
References
----------
1. Goda, T. (2021). A simple algorithm for global sensitivity analysis
with Shapley effects. Reliability Engineering & System Safety, 213,
107702. https://doi.org/10.1016/j.ress.2021.107702
"""
_validate_ungrouped(problem)
X, Y, num_trajectories = _validate_inputs(problem, X, Y, conf_level)
num_vars = problem["num_vars"]
trajectory_size = num_vars + 1
trajectories = X.reshape(num_trajectories, trajectory_size, num_vars)
outputs = Y.reshape(num_trajectories, trajectory_size)
# Each trajectory step changes exactly one factor; recover which one by
# diffing consecutive rows, then scatter the step-ordered contributions
# into factor-ordered columns with a single vectorized reorder instead of
# a per-trajectory, per-step Python loop.
changed = trajectories[:, 1:, :] != trajectories[:, :-1, :]
change_counts = changed.sum(axis=2)
mismatched_steps = change_counts != 1
if np.any(mismatched_steps):
# Identify location of mismatch
trajectory_index, step = (
int(i)
for i in np.unravel_index(np.argmax(mismatched_steps), change_counts.shape)
)
raise ValueError(
"Each Shapley trajectory step must change exactly one factor; "
f"trajectory {trajectory_index}, step {step + 1} changed "
f"{int(change_counts[trajectory_index, step])}."
)
factors = changed.argmax(axis=2)
incomplete = np.any(np.sort(factors, axis=1) != np.arange(num_vars), axis=1)
if np.any(incomplete):
trajectory_index = int(np.argmax(incomplete))
raise ValueError(
"Each Shapley trajectory must change every factor exactly once; "
f"trajectory {trajectory_index} does not."
)
base_output = outputs[:, :1]
before = outputs[:, :-1]
after = outputs[:, 1:]
step_contributions = (base_output - (before + after) / 2.0) * (before - after)
contributions = np.empty_like(step_contributions)
np.put_along_axis(contributions, factors, step_contributions, axis=1)
effects = contributions.mean(axis=0)
estimator_variance = contributions.var(axis=0, ddof=1) / num_trajectories
z_score = norm.ppf(0.5 + conf_level / 2.0)
confidence = z_score * np.sqrt(estimator_variance)
normalized, normalized_confidence = _normalize_with_confidence(
contributions, effects, z_score
)
result = ResultDict(
shapley_normalized=normalized,
shapley_normalized_conf=normalized_confidence,
shapley=effects,
shapley_conf=confidence,
names=problem["names"],
)
result.plot = MethodType(shapley_plot, result)
if print_to_console:
print(result.to_df())
return result
def _normalize_with_confidence(contributions, effects, z_score):
"""Estimate normalized Shapley shares and their confidence half-widths.
Each trajectory's per-factor contributions sum to that trajectory's own
estimate of ``Var[Y]``, so the normalized share of a factor is a ratio of two
correlated trajectory-averaged quantities: the mean contribution to that
factor, and the mean row total. Its sampling variance is approximated
with the standard delta-method formula for a ratio of means (e.g.
Cochran, 1977, on ratio estimators), reusing the per-trajectory
contributions already computed for the raw ``shapley_conf`` interval.
"""
num_trajectories = contributions.shape[0]
row_sums = contributions.sum(axis=1)
total = row_sums.mean()
if total == 0.0:
undefined = np.full_like(effects, np.nan)
return undefined, undefined.copy()
normalized = effects / total
centered = contributions - effects
centered_totals = row_sums - total
var_contributions = np.sum(centered**2, axis=0) / (num_trajectories - 1)
covar_with_total = np.sum(centered * centered_totals[:, None], axis=0) / (
num_trajectories - 1
)
var_total = np.sum(centered_totals**2) / (num_trajectories - 1)
ratio_variance = (
var_contributions
- 2.0 * normalized * covar_with_total
+ normalized**2 * var_total
) / (num_trajectories * total**2)
# Guard against tiny negative values from floating-point round-off.
ratio_variance = np.clip(ratio_variance, a_min=0.0, a_max=None)
return normalized, z_score * np.sqrt(ratio_variance)
def _validate_inputs(problem, X, Y, conf_level):
if not 0 < conf_level < 1:
raise ValueError("Confidence level must be between 0 and 1.")
num_vars = problem["num_vars"]
if num_vars <= 0:
raise ValueError("The problem must contain at least one variable.")
X = np.asarray(X)
Y = np.asarray(Y)
if X.ndim != 2 or X.shape[1] != num_vars:
raise ValueError(f"X must have shape (N * (D + 1), {num_vars}).")
if Y.ndim != 1:
raise ValueError("Y must be a one-dimensional array.")
if X.shape[0] != Y.size:
raise ValueError("X and Y must contain the same number of rows.")
if Y.size % (num_vars + 1):
raise ValueError("The number of model outputs must be divisible by D + 1.")
num_trajectories = Y.size // (num_vars + 1)
if num_trajectories < 2:
raise ValueError("At least two Shapley trajectories are required.")
if not np.all(np.isfinite(X)) or not np.all(np.isfinite(Y)):
raise ValueError("X and Y must contain only finite values.")
return X, Y, num_trajectories
def _validate_ungrouped(problem: dict) -> None:
groups = problem.get("groups")
if groups and list(groups) != list(problem.get("names", [])):
raise ValueError("Goda's Shapley estimator does not support groups.")
[docs]
def cli_parse(parser):
parser.add_argument(
"-X",
"--model-input-file",
type=str,
required=True,
help="Model input file",
)
parser.add_argument(
"--conf-level",
type=float,
required=False,
default=0.95,
help="Confidence interval level",
)
return parser
[docs]
def cli_action(args):
"""Analyze Shapley trajectories from command-line arguments."""
problem = read_param_file(args.paramfile)
X = np.loadtxt(args.model_input_file, delimiter=args.delimiter, ndmin=2)
Y = np.loadtxt(
args.model_output_file,
delimiter=args.delimiter,
usecols=(args.column,),
ndmin=1,
)
analyze(
problem,
X,
Y,
conf_level=args.conf_level,
print_to_console=True,
)
if __name__ == "__main__":
common_args.run_cli(cli_parse, cli_action)