ShapleyX Documentation
  • Home
  • User Guide

Getting Started

  • Installation
  • Quickstart
  • Deployment

Tutorials

  • Basic Usage
  • Example Workflow
  • Cantilever Beam
  • MC Shapley
  • MC Shapley + Sobol
  • Coalition Truncation
  • Truncated Normal
  • Correlated Ishigami
  • Wing Weight
  • Fire Spread
  • Sobol G50
  • Global Sensitivity Analysis of the SAC-SMA Rainfall-Runoff Model
    • A ShapleyX Example with Correlated Inputs
    • 1. Load Forcing Data and Observations
    • 2. Define the SAC-SMA NSE Wrapper
    • 3. Generate Training Data: QMC Sobol Sequence (4096 samples)
    • 4. Evaluate SAC-SMA for All Training Samples
    • 5. Build the RS-HDMR Surrogate Model
    • 6. RS-HDMR Shapley Effects (Independent Inputs)
    • 7. Build the Parameter Correlation Matrix
    • 8. MC Shapley Effects Under Correlated Inputs
    • 9. Direct MC Shapley on the Original SAC-SMA Model
      • Direct vs Surrogate-Assisted MC Shapley
    • 10. Comparison: Independent vs Correlated Shapley Effects
    • 11. Summary
  • Owen (Grouped) Shapley Effects for the SAC-SMA Model

How-to Guides

  • Common Tasks
  • MC Shapley

Reference

  • API
  • ARD
  • MC Shapley
  • Streaming OMP

Explanation

  • Theory
  • Value Function v(S)
  • RS-HDMR Construction
  • Distribution Classes
  • Arbitrary Marginals
  • Owen (Grouped) Effects
  • Case Studies
  • ARD Theory
  • Streaming Basis Expansion
  • Moment-Free Measures
  • Performance
ShapleyX Documentation
  • Tutorials
  • Global Sensitivity Analysis of the SAC-SMA Rainfall-Runoff Model
  • Edit on GitHub

Global Sensitivity Analysis of the SAC-SMA Rainfall-Runoff Model¶

A ShapleyX Example with Correlated Inputs¶

This notebook demonstrates a complete ShapleyX workflow for the Sacramento Soil Moisture Accounting (SAC-SMA) model:

  1. Phase 1 — Build an RS-HDMR surrogate of the NSE response using QMC Sobol (scrambled + shifted) training data
  2. Phase 2 — Compute Shapley Effects under independent inputs (standard RS-HDMR)
  3. Phase 3 — Compute Shapley Effects under correlated inputs using MC Shapley with a Gaussian copula and a literature-based correlation matrix from Vrugt et al. (2006)
  4. Phase 4 — Compare independent vs correlated sensitivity rankings

Reference: Vrugt, J.A., et al. (2006). Application of stochastic parameter optimization to the Sacramento Soil Moisture Accounting model. Journal of Hydrology, 325, 288–307.

Catchment: St Helens Creek, North Queensland (area = 120.5 km²), daily data from Feb 1973 – Dec 2018.

In [27]:
Copied!
import numpy as np
import pandas as pd
import matplotlib
#matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
from scipy.stats.qmc import Sobol
import warnings
import sys
import os
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'font.family': 'serif',
    'font.size': 11,
    'figure.dpi': 120,
    'savefig.dpi': 200,
    'savefig.bbox': 'tight'
})

# ShapleyX
from shapleyx import rshdmr
from shapleyx.utilities.mc_shapley import (
    GaussianCopulaUniform,
    shapley_effects,
)

# SAC-SMA model
data_dir = os.path.join(os.path.dirname(os.path.abspath('')), 'sac_sma_data')
sys.path.insert(0, data_dir)
from sacramento import sacsma

print('All imports OK')
import numpy as np import pandas as pd import matplotlib #matplotlib.use('Agg') import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec from scipy.stats.qmc import Sobol import warnings import sys import os warnings.filterwarnings('ignore') plt.rcParams.update({ 'font.family': 'serif', 'font.size': 11, 'figure.dpi': 120, 'savefig.dpi': 200, 'savefig.bbox': 'tight' }) # ShapleyX from shapleyx import rshdmr from shapleyx.utilities.mc_shapley import ( GaussianCopulaUniform, shapley_effects, ) # SAC-SMA model data_dir = os.path.join(os.path.dirname(os.path.abspath('')), 'sac_sma_data') sys.path.insert(0, data_dir) from sacramento import sacsma print('All imports OK')
All imports OK

1. Load Forcing Data and Observations¶

We have ~46 years of daily rainfall, PET, and observed discharge for St Helens Creek (North Queensland). Catchment area: 120.5 km².

In [2]:
Copied!
df_forcing = pd.read_csv(f'{data_dir}/st_helens_forcing_original.csv', parse_dates=['date'], dayfirst=True)
print(f'Forcing data: {len(df_forcing)} daily timesteps')
print(f'Date range: {df_forcing.date.iloc[0].date()} to {df_forcing.date.iloc[-1].date()}')

CATCHMENT_AREA_M2 = 120_500_000.0

prcp = df_forcing['rainfall'].values
pet  = df_forcing['pet'].values
q_obs = df_forcing['Q_CUMEC'].values

def flow_mm_to_cumec(flow_mm):
    return flow_mm * CATCHMENT_AREA_M2 / 1000.0 / 86400.0

WARMUP_DAYS = 365
print(f'Warm-up: {WARMUP_DAYS} days')
print(f'Evaluation: day {WARMUP_DAYS+1} to {len(df_forcing)} ({len(df_forcing)-WARMUP_DAYS} timesteps)')
df_forcing = pd.read_csv(f'{data_dir}/st_helens_forcing_original.csv', parse_dates=['date'], dayfirst=True) print(f'Forcing data: {len(df_forcing)} daily timesteps') print(f'Date range: {df_forcing.date.iloc[0].date()} to {df_forcing.date.iloc[-1].date()}') CATCHMENT_AREA_M2 = 120_500_000.0 prcp = df_forcing['rainfall'].values pet = df_forcing['pet'].values q_obs = df_forcing['Q_CUMEC'].values def flow_mm_to_cumec(flow_mm): return flow_mm * CATCHMENT_AREA_M2 / 1000.0 / 86400.0 WARMUP_DAYS = 365 print(f'Warm-up: {WARMUP_DAYS} days') print(f'Evaluation: day {WARMUP_DAYS+1} to {len(df_forcing)} ({len(df_forcing)-WARMUP_DAYS} timesteps)')
Forcing data: 16763 daily timesteps
Date range: 1973-02-08 to 2018-12-31
Warm-up: 365 days
Evaluation: day 366 to 16763 (16398 timesteps)

2. Define the SAC-SMA NSE Wrapper¶

This function takes a 14-parameter vector, runs the Numba-JIT-compiled SAC-SMA model, converts the simulated flow from mm/day to m³/s, and computes the Nash-Sutcliffe Efficiency (NSE) against observed discharge.

We first run a warmup call to trigger Numba compilation.

In [3]:
Copied!
def nse_from_params(params, warmup=WARMUP_DAYS):
    flow_mm = sacsma(prcp, pet, params)
    sim = flow_mm_to_cumec(flow_mm[warmup:])
    obs = q_obs[warmup:]
    residual = obs - sim
    numerator = np.sum(residual ** 2)
    denominator = np.sum((obs - np.mean(obs)) ** 2)
    return 1.0 - numerator / denominator if denominator > 0 else -np.inf

# Warm-up (triggers Numba JIT)
params_default = np.array([44.13, 52.76, 80.87, 112.70, 52.81, 0.084, 0.490,
                           0.0148, 0.133, 35.31, 2.532, 0.00578, 0.424, 0.0093])
nse_default = nse_from_params(params_default)
print(f'NSE at default parameters: {nse_default:.4f}')
def nse_from_params(params, warmup=WARMUP_DAYS): flow_mm = sacsma(prcp, pet, params) sim = flow_mm_to_cumec(flow_mm[warmup:]) obs = q_obs[warmup:] residual = obs - sim numerator = np.sum(residual ** 2) denominator = np.sum((obs - np.mean(obs)) ** 2) return 1.0 - numerator / denominator if denominator > 0 else -np.inf # Warm-up (triggers Numba JIT) params_default = np.array([44.13, 52.76, 80.87, 112.70, 52.81, 0.084, 0.490, 0.0148, 0.133, 35.31, 2.532, 0.00578, 0.424, 0.0093]) nse_default = nse_from_params(params_default) print(f'NSE at default parameters: {nse_default:.4f}')
NSE at default parameters: 0.8406

3. Generate Training Data: QMC Sobol Sequence (4096 samples)¶

We use a scrambled Sobol sequence with Cranley-Patterson rotation (random shift) to produce 4096 well-spaced samples in the 14-dimensional unit hypercube. Each dimension is then mapped to the physically plausible range from es_parameters.csv.

In [4]:
Copied!
N_PARAMS = 14
N_SAMPLES = 4096

param_names = ['Uztwm', 'Uzfwm', 'Lztwm', 'Lzfpm', 'Lzfsm',
               'Adimp', 'Uzk', 'Lzpk', 'Lzsk', 'Zperc',
               'Rexp', 'Pctim', 'Pfree', 'Side']

param_lower = np.array([30, 10, 20, 40, 15, 0, 0.2, 0.001, 0.03, 0, 0, 0, 0, 0])
param_upper = np.array([125, 75, 300, 600, 300, 0.2, 0.9, 0.03, 0.8, 80, 5, 0.05, 0.8, 0.8])
param_ranges = param_upper - param_lower

print(f'Parameter space: {N_PARAMS} dimensions x {N_SAMPLES} Sobol samples')
for i, name in enumerate(param_names):
    print(f'  {name:>6s}: [{param_lower[i]:8.3f}, {param_upper[i]:8.3f}]')
N_PARAMS = 14 N_SAMPLES = 4096 param_names = ['Uztwm', 'Uzfwm', 'Lztwm', 'Lzfpm', 'Lzfsm', 'Adimp', 'Uzk', 'Lzpk', 'Lzsk', 'Zperc', 'Rexp', 'Pctim', 'Pfree', 'Side'] param_lower = np.array([30, 10, 20, 40, 15, 0, 0.2, 0.001, 0.03, 0, 0, 0, 0, 0]) param_upper = np.array([125, 75, 300, 600, 300, 0.2, 0.9, 0.03, 0.8, 80, 5, 0.05, 0.8, 0.8]) param_ranges = param_upper - param_lower print(f'Parameter space: {N_PARAMS} dimensions x {N_SAMPLES} Sobol samples') for i, name in enumerate(param_names): print(f' {name:>6s}: [{param_lower[i]:8.3f}, {param_upper[i]:8.3f}]')
Parameter space: 14 dimensions x 4096 Sobol samples
   Uztwm: [  30.000,  125.000]
   Uzfwm: [  10.000,   75.000]
   Lztwm: [  20.000,  300.000]
   Lzfpm: [  40.000,  600.000]
   Lzfsm: [  15.000,  300.000]
   Adimp: [   0.000,    0.200]
     Uzk: [   0.200,    0.900]
    Lzpk: [   0.001,    0.030]
    Lzsk: [   0.030,    0.800]
   Zperc: [   0.000,   80.000]
    Rexp: [   0.000,    5.000]
   Pctim: [   0.000,    0.050]
   Pfree: [   0.000,    0.800]
    Side: [   0.000,    0.800]
In [5]:
Copied!
sobol_gen = Sobol(d=N_PARAMS, scramble=True, seed=42)
X_uniform = sobol_gen.random(n=N_SAMPLES)

# Cranley-Patterson rotation
rng = np.random.default_rng(123)
shift = rng.uniform(0, 1, size=N_PARAMS)
X_uniform = (X_uniform + shift) % 1.0

X_params = param_lower + X_uniform * param_ranges
print(f'Sobol samples shape: {X_params.shape}')
print(f'First 3 samples:')
for i in range(min(3, N_SAMPLES)):
    print(f'  [{i}] ' + ', '.join(f'{v:.2f}' for v in X_params[i]))
sobol_gen = Sobol(d=N_PARAMS, scramble=True, seed=42) X_uniform = sobol_gen.random(n=N_SAMPLES) # Cranley-Patterson rotation rng = np.random.default_rng(123) shift = rng.uniform(0, 1, size=N_PARAMS) X_uniform = (X_uniform + shift) % 1.0 X_params = param_lower + X_uniform * param_ranges print(f'Sobol samples shape: {X_params.shape}') print(f'First 3 samples:') for i in range(min(3, N_SAMPLES)): print(f' [{i}] ' + ', '.join(f'{v:.2f}' for v in X_params[i]))
Sobol samples shape: (4096, 14)
First 3 samples:
  [0] 40.77, 66.43, 27.50, 173.18, 150.05, 0.13, 0.63, 0.03, 0.51, 0.36, 0.21, 0.05, 0.16, 0.65
  [1] 65.53, 45.54, 132.44, 68.88, 36.73, 0.04, 0.29, 0.02, 0.13, 57.01, 4.46, 0.03, 0.45, 0.32
  [2] 72.51, 49.16, 232.04, 310.12, 257.91, 0.17, 0.72, 0.02, 0.03, 39.35, 3.77, 0.02, 0.80, 0.01

4. Evaluate SAC-SMA for All Training Samples¶

This runs the model 4096 times (~30–60 seconds with Numba JIT). Each run simulates ~46 years of daily streamflow.

In [6]:
Copied!
print('Evaluating 4096 parameter sets...')
Y_nse = np.array([nse_from_params(X_params[i]) for i in range(N_SAMPLES)])

valid = Y_nse > -np.inf
print(f'NSE distribution ({valid.sum()} valid samples):')
Yv = Y_nse[valid]
print(f'  Mean:   {Yv.mean():.4f}')
print(f'  Std:    {Yv.std():.4f}')
print(f'  Min:    {Yv.min():.4f}')
print(f'  Max:    {Yv.max():.4f}')
print(f'  Median: {np.median(Yv):.4f}')
print(f'  NSE > 0: {(Yv > 0).mean()*100:.1f}% (better than mean-flow benchmark)')
print('Evaluating 4096 parameter sets...') Y_nse = np.array([nse_from_params(X_params[i]) for i in range(N_SAMPLES)]) valid = Y_nse > -np.inf print(f'NSE distribution ({valid.sum()} valid samples):') Yv = Y_nse[valid] print(f' Mean: {Yv.mean():.4f}') print(f' Std: {Yv.std():.4f}') print(f' Min: {Yv.min():.4f}') print(f' Max: {Yv.max():.4f}') print(f' Median: {np.median(Yv):.4f}') print(f' NSE > 0: {(Yv > 0).mean()*100:.1f}% (better than mean-flow benchmark)')
Evaluating 4096 parameter sets...
NSE distribution (4096 valid samples):
  Mean:   0.6927
  Std:    0.0977
  Min:    0.3339
  Max:    0.8580
  Median: 0.7029
  NSE > 0: 100.0% (better than mean-flow benchmark)
In [7]:
Copied!
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(Yv, bins=40, color='steelblue', edgecolor='white', alpha=0.8)
ax.axvline(Yv.mean(), color='firebrick', ls='--', lw=1.5, label=f'Mean = {Yv.mean():.3f}')
ax.axvline(0, color='grey', ls=':', lw=1, label='NSE = 0')
ax.set_xlabel('NSE')
ax.set_ylabel('Frequency')
ax.set_title('Distribution of NSE Across 4096 Sobol Samples')
ax.legend(fontsize=9)
plt.tight_layout()
plt.savefig('nse_distribution.eps')
plt.savefig('nse_distribution.svg')
plt.show()
fig, ax = plt.subplots(figsize=(8, 4)) ax.hist(Yv, bins=40, color='steelblue', edgecolor='white', alpha=0.8) ax.axvline(Yv.mean(), color='firebrick', ls='--', lw=1.5, label=f'Mean = {Yv.mean():.3f}') ax.axvline(0, color='grey', ls=':', lw=1, label='NSE = 0') ax.set_xlabel('NSE') ax.set_ylabel('Frequency') ax.set_title('Distribution of NSE Across 4096 Sobol Samples') ax.legend(fontsize=9) plt.tight_layout() plt.savefig('nse_distribution.eps') plt.savefig('nse_distribution.svg') plt.show()
The PostScript backend does not support transparency; partially transparent artists will be rendered opaque.
No description has been provided for this image

5. Build the RS-HDMR Surrogate Model¶

We train the surrogate using:

  • polys=[10, 6, 2] — 1st-order terms up to degree 10, 2nd-order up to degree 6, 3rd-order up to degree 2
  • method='omp_cv' — OMP with CV (suitable for ~9K basis functions)
In [8]:
Copied!
df_train = pd.DataFrame(X_params, columns=param_names)
df_train['Y'] = Y_nse

print('Training RS-HDMR surrogate (streaming OMP-CV)...')
analyzer = rshdmr(
    data_file=df_train,
    polys=[10, 8, 2],
    method='omp_cv',
    n_iter=400,
    test_size=0.25,
    verbose=True,
    resampling=False,
    n_jobs=4,
)

sobol, shap, total = analyzer.run_all()
print('\nSurrogate training complete.')
df_train = pd.DataFrame(X_params, columns=param_names) df_train['Y'] = Y_nse print('Training RS-HDMR surrogate (streaming OMP-CV)...') analyzer = rshdmr( data_file=df_train, polys=[10, 8, 2], method='omp_cv', n_iter=400, test_size=0.25, verbose=True, resampling=False, n_jobs=4, ) sobol, shap, total = analyzer.run_all() print('\nSurrogate training complete.')
Training RS-HDMR surrogate (streaming OMP-CV)...
Found a DataFrame

============================================================
Transforming data to unit hypercube
============================================================

Feature: Uztwm, Min Value: 30.0162, Max Value: 124.9915
Feature: Uzfwm, Min Value: 10.0222, Max Value: 74.9941
Feature: Lztwm, Min Value: 20.0122, Max Value: 299.9523
Feature: Lzfpm, Min Value: 40.1022, Max Value: 599.9162
Feature: Lzfsm, Min Value: 15.1039, Max Value: 299.9918
Feature: Adimp, Min Value: 0.0000, Max Value: 0.1999
Feature: Uzk, Min Value: 0.2000, Max Value: 0.8999
Feature: Lzpk, Min Value: 0.0010, Max Value: 0.0300
Feature: Lzsk, Min Value: 0.0302, Max Value: 0.8000
Feature: Zperc, Min Value: 0.0116, Max Value: 79.9933
Feature: Rexp, Min Value: 0.0008, Max Value: 4.9996
Feature: Pctim, Min Value: 0.0000, Max Value: 0.0500
Feature: Pfree, Min Value: 0.0001, Max Value: 0.7999
Feature: Side, Min Value: 0.0001, Max Value: 0.8000

============================================================
Building basis functions
============================================================

Basis functions of 1 order : 140
Basis functions of 2 order : 5824
Basis functions of 3 order : 2912
Total basis functions in basis set : 8876
Total number of features in basis set is 8876

============================================================
Running regression analysis
============================================================

running OMP_CV
Fit Execution Time : 53.471971
Basis functions selected: 338 / 8876 (3.81%)
--
 
 Model complete 
 

============================================================
RS-HDMR model performance statistics
============================================================

variance of data        : 0.010
sum of coefficients^2   : 0.010
variance ratio          : 1.000
===============================
mae error on test set   : 0.008
mse error on test set   : 0.000
explained variance score: 0.989
===============================
slope     :  0.9891364639445397
r value   :  0.9945533992423632
r^2       :  0.9891364639445395
p value   :  0.0
std error :  0.0016200924271033601

No description has been provided for this image
============================================================
                  Completed all analysis
                 ------------------------

The secret of success is constancy to purpose.  Benjamin
Disraeli
============================================================


Surrogate training complete.

6. RS-HDMR Shapley Effects (Independent Inputs)¶

The standard RS-HDMR Shapley effects assume independent inputs. They redistribute interaction variance equally among the participating variables. This is our baseline.

In [9]:
Copied!
display_col = 'scaled effect' if 'scaled effect' in shap.columns else 'effect'
print(f'RS-HDMR Shapley Effects — Independent Inputs:')
print(shap.to_string(float_format=lambda x: f'{x:.6f}'))

shap_sorted = shap.sort_values(display_col, ascending=True)
fig, ax = plt.subplots(figsize=(8, 5))
colors = plt.cm.Blues(np.linspace(0.4, 0.9, len(shap_sorted)))
ax.barh(range(len(shap_sorted)), shap_sorted[display_col].values, color=colors)
ax.set_yticks(range(len(shap_sorted)))
ax.set_yticklabels(shap_sorted['label'].values)
ax.set_xlabel('Shapley Effect (fraction of explained variance)')
ax.set_title('RS-HDMR Shapley Effects — Independent Inputs')
ax.axvline(0, color='grey', lw=0.8)
plt.tight_layout()
plt.savefig('shapley_independent.png')
plt.show()
display_col = 'scaled effect' if 'scaled effect' in shap.columns else 'effect' print(f'RS-HDMR Shapley Effects — Independent Inputs:') print(shap.to_string(float_format=lambda x: f'{x:.6f}')) shap_sorted = shap.sort_values(display_col, ascending=True) fig, ax = plt.subplots(figsize=(8, 5)) colors = plt.cm.Blues(np.linspace(0.4, 0.9, len(shap_sorted))) ax.barh(range(len(shap_sorted)), shap_sorted[display_col].values, color=colors) ax.set_yticks(range(len(shap_sorted))) ax.set_yticklabels(shap_sorted['label'].values) ax.set_xlabel('Shapley Effect (fraction of explained variance)') ax.set_title('RS-HDMR Shapley Effects — Independent Inputs') ax.axvline(0, color='grey', lw=0.8) plt.tight_layout() plt.savefig('shapley_independent.png') plt.show()
RS-HDMR Shapley Effects — Independent Inputs:
    label   effect  scaled effect
0   Uztwm 0.003016       0.003050
1   Uzfwm 0.004344       0.004393
2   Lztwm 0.042440       0.042911
3   Lzfpm 0.246484       0.249221
4   Lzfsm 0.203561       0.205822
5   Adimp 0.082821       0.083741
6     Uzk 0.013455       0.013604
7    Lzpk 0.049998       0.050554
8    Lzsk 0.061728       0.062414
9   Zperc 0.063062       0.063762
10   Rexp 0.162799       0.164607
11  Pctim 0.007306       0.007387
12  Pfree 0.000865       0.000875
13   Side 0.047137       0.047661
No description has been provided for this image

7. Build the Parameter Correlation Matrix¶

We construct the correlation matrix from Vrugt et al. (2006, Table 2) — the full 13×13 posterior correlation matrix from MCMC (SCEM-UA) calibration of SAC-SMA on the Leaf River, Mississippi. Parameter SIDE was fixed (not calibrated), so its correlations are set to zero.

This matrix defines the dependence structure for the Gaussian copula used in MC Shapley.

In [10]:
Copied!
# Vrugt et al. (2006) Table 2 — 13x13 correlation matrix
# Vrugt order: UZTWM, UZFWM, UZK, PCTIM, ADIMP, ZPERC, REXP,
#               LZTWM, LZFSM, LZFPM, LZSK, LZPK, PFREE
corr_vrugt = np.array([
    [ 1.00, -0.02, -0.04,  0.19,  0.36, -0.01,  0.11, -0.84, -0.17,  0.28,  0.17,  0.16,  0.57],
    [-0.02,  1.00, -0.53,  0.05,  0.01,  0.15,  0.11, -0.01, -0.08,  0.22, -0.15, -0.18, -0.33],
    [-0.04, -0.53,  1.00, -0.01, -0.41, -0.13, -0.03,  0.07, -0.11, -0.01, -0.02,  0.08,  0.08],
    [ 0.19,  0.05, -0.01,  1.00, -0.16,  0.01,  0.03, -0.14,  0.01,  0.02, -0.08,  0.02, -0.04],
    [ 0.36,  0.01, -0.41, -0.16,  1.00,  0.07,  0.04, -0.21, -0.15,  0.09,  0.17,  0.15,  0.31],
    [-0.01,  0.15, -0.13,  0.01,  0.07,  1.00,  0.05,  0.06, -0.06, -0.10, -0.13,  0.02,  0.00],
    [ 0.11,  0.11, -0.03,  0.03,  0.04,  0.05,  1.00, -0.03,  0.65,  0.68,  0.24, -0.10, -0.12],
    [-0.84, -0.01,  0.07, -0.14, -0.21,  0.06, -0.03,  1.00,  0.28, -0.16, -0.14, -0.23, -0.63],
    [-0.17, -0.08, -0.11,  0.01, -0.15, -0.06,  0.65,  0.28,  1.00,  0.41, -0.33, -0.55, -0.36],
    [ 0.28,  0.22, -0.01,  0.02,  0.09, -0.10,  0.68, -0.16,  0.41,  1.00,  0.06, -0.43, -0.26],
    [ 0.17, -0.15, -0.02, -0.08,  0.17, -0.13,  0.24, -0.14, -0.33,  0.06,  1.00,  0.60,  0.41],
    [ 0.16, -0.18,  0.08,  0.02,  0.15,  0.02, -0.10, -0.23, -0.55, -0.43,  0.60,  1.00,  0.56],
    [ 0.57, -0.33,  0.08, -0.04,  0.31,  0.00, -0.12, -0.63, -0.36, -0.26,  0.41,  0.56,  1.00],
])

# Map Vrugt order -> our parameter order
# Ours: Uztwm(0), Uzfwm(1), Lztwm(2), Lzfpm(3), Lzfsm(4), Adimp(5), Uzk(6),
#       Lzpk(7),  Lzsk(8),  Zperc(9), Rexp(10), Pctim(11), Pfree(12), Side(13)
vrugt_to_our = [0, 1, 6, 11, 5, 9, 10, 2, 4, 3, 8, 7, 12]

corr_14 = np.eye(N_PARAMS)
for i, vi in enumerate(vrugt_to_our):
    for j, vj in enumerate(vrugt_to_our):
        corr_14[vi, vj] = corr_vrugt[i, j]

# Ensure symmetry and positive definiteness
corr_14 = (corr_14 + corr_14.T) / 2
eigvals = np.linalg.eigvalsh(corr_14)
if eigvals.min() < 0:
    corr_14 += np.eye(N_PARAMS) * (-eigvals.min() + 1e-8)

print('Correlation matrix:')
pd.set_option('display.precision', 3)
display(pd.DataFrame(corr_14, index=param_names, columns=param_names))

print('\nStrongest correlations:')
for i in range(N_PARAMS):
    for j in range(i+1, N_PARAMS):
        if abs(corr_14[i, j]) > 0.3:
            print(f'  {param_names[i]:>6s} <-> {param_names[j]:<6s}: rho = {corr_14[i, j]:+.3f}')
# Vrugt et al. (2006) Table 2 — 13x13 correlation matrix # Vrugt order: UZTWM, UZFWM, UZK, PCTIM, ADIMP, ZPERC, REXP, # LZTWM, LZFSM, LZFPM, LZSK, LZPK, PFREE corr_vrugt = np.array([ [ 1.00, -0.02, -0.04, 0.19, 0.36, -0.01, 0.11, -0.84, -0.17, 0.28, 0.17, 0.16, 0.57], [-0.02, 1.00, -0.53, 0.05, 0.01, 0.15, 0.11, -0.01, -0.08, 0.22, -0.15, -0.18, -0.33], [-0.04, -0.53, 1.00, -0.01, -0.41, -0.13, -0.03, 0.07, -0.11, -0.01, -0.02, 0.08, 0.08], [ 0.19, 0.05, -0.01, 1.00, -0.16, 0.01, 0.03, -0.14, 0.01, 0.02, -0.08, 0.02, -0.04], [ 0.36, 0.01, -0.41, -0.16, 1.00, 0.07, 0.04, -0.21, -0.15, 0.09, 0.17, 0.15, 0.31], [-0.01, 0.15, -0.13, 0.01, 0.07, 1.00, 0.05, 0.06, -0.06, -0.10, -0.13, 0.02, 0.00], [ 0.11, 0.11, -0.03, 0.03, 0.04, 0.05, 1.00, -0.03, 0.65, 0.68, 0.24, -0.10, -0.12], [-0.84, -0.01, 0.07, -0.14, -0.21, 0.06, -0.03, 1.00, 0.28, -0.16, -0.14, -0.23, -0.63], [-0.17, -0.08, -0.11, 0.01, -0.15, -0.06, 0.65, 0.28, 1.00, 0.41, -0.33, -0.55, -0.36], [ 0.28, 0.22, -0.01, 0.02, 0.09, -0.10, 0.68, -0.16, 0.41, 1.00, 0.06, -0.43, -0.26], [ 0.17, -0.15, -0.02, -0.08, 0.17, -0.13, 0.24, -0.14, -0.33, 0.06, 1.00, 0.60, 0.41], [ 0.16, -0.18, 0.08, 0.02, 0.15, 0.02, -0.10, -0.23, -0.55, -0.43, 0.60, 1.00, 0.56], [ 0.57, -0.33, 0.08, -0.04, 0.31, 0.00, -0.12, -0.63, -0.36, -0.26, 0.41, 0.56, 1.00], ]) # Map Vrugt order -> our parameter order # Ours: Uztwm(0), Uzfwm(1), Lztwm(2), Lzfpm(3), Lzfsm(4), Adimp(5), Uzk(6), # Lzpk(7), Lzsk(8), Zperc(9), Rexp(10), Pctim(11), Pfree(12), Side(13) vrugt_to_our = [0, 1, 6, 11, 5, 9, 10, 2, 4, 3, 8, 7, 12] corr_14 = np.eye(N_PARAMS) for i, vi in enumerate(vrugt_to_our): for j, vj in enumerate(vrugt_to_our): corr_14[vi, vj] = corr_vrugt[i, j] # Ensure symmetry and positive definiteness corr_14 = (corr_14 + corr_14.T) / 2 eigvals = np.linalg.eigvalsh(corr_14) if eigvals.min() < 0: corr_14 += np.eye(N_PARAMS) * (-eigvals.min() + 1e-8) print('Correlation matrix:') pd.set_option('display.precision', 3) display(pd.DataFrame(corr_14, index=param_names, columns=param_names)) print('\nStrongest correlations:') for i in range(N_PARAMS): for j in range(i+1, N_PARAMS): if abs(corr_14[i, j]) > 0.3: print(f' {param_names[i]:>6s} <-> {param_names[j]:<6s}: rho = {corr_14[i, j]:+.3f}')
Correlation matrix:
Uztwm Uzfwm Lztwm Lzfpm Lzfsm Adimp Uzk Lzpk Lzsk Zperc Rexp Pctim Pfree Side
Uztwm 1.00 -0.02 -0.84 0.28 -0.17 0.36 -0.04 0.16 0.17 -0.01 0.11 0.19 0.57 0.0
Uzfwm -0.02 1.00 -0.01 0.22 -0.08 0.01 -0.53 -0.18 -0.15 0.15 0.11 0.05 -0.33 0.0
Lztwm -0.84 -0.01 1.00 -0.16 0.28 -0.21 0.07 -0.23 -0.14 0.06 -0.03 -0.14 -0.63 0.0
Lzfpm 0.28 0.22 -0.16 1.00 0.41 0.09 -0.01 -0.43 0.06 -0.10 0.68 0.02 -0.26 0.0
Lzfsm -0.17 -0.08 0.28 0.41 1.00 -0.15 -0.11 -0.55 -0.33 -0.06 0.65 0.01 -0.36 0.0
Adimp 0.36 0.01 -0.21 0.09 -0.15 1.00 -0.41 0.15 0.17 0.07 0.04 -0.16 0.31 0.0
Uzk -0.04 -0.53 0.07 -0.01 -0.11 -0.41 1.00 0.08 -0.02 -0.13 -0.03 -0.01 0.08 0.0
Lzpk 0.16 -0.18 -0.23 -0.43 -0.55 0.15 0.08 1.00 0.60 0.02 -0.10 0.02 0.56 0.0
Lzsk 0.17 -0.15 -0.14 0.06 -0.33 0.17 -0.02 0.60 1.00 -0.13 0.24 -0.08 0.41 0.0
Zperc -0.01 0.15 0.06 -0.10 -0.06 0.07 -0.13 0.02 -0.13 1.00 0.05 0.01 0.00 0.0
Rexp 0.11 0.11 -0.03 0.68 0.65 0.04 -0.03 -0.10 0.24 0.05 1.00 0.03 -0.12 0.0
Pctim 0.19 0.05 -0.14 0.02 0.01 -0.16 -0.01 0.02 -0.08 0.01 0.03 1.00 -0.04 0.0
Pfree 0.57 -0.33 -0.63 -0.26 -0.36 0.31 0.08 0.56 0.41 0.00 -0.12 -0.04 1.00 0.0
Side 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 1.0
Strongest correlations:
   Uztwm <-> Lztwm : rho = -0.840
   Uztwm <-> Adimp : rho = +0.360
   Uztwm <-> Pfree : rho = +0.570
   Uzfwm <-> Uzk   : rho = -0.530
   Uzfwm <-> Pfree : rho = -0.330
   Lztwm <-> Pfree : rho = -0.630
   Lzfpm <-> Lzfsm : rho = +0.410
   Lzfpm <-> Lzpk  : rho = -0.430
   Lzfpm <-> Rexp  : rho = +0.680
   Lzfsm <-> Lzpk  : rho = -0.550
   Lzfsm <-> Lzsk  : rho = -0.330
   Lzfsm <-> Rexp  : rho = +0.650
   Lzfsm <-> Pfree : rho = -0.360
   Adimp <-> Uzk   : rho = -0.410
   Adimp <-> Pfree : rho = +0.310
    Lzpk <-> Lzsk  : rho = +0.600
    Lzpk <-> Pfree : rho = +0.560
    Lzsk <-> Pfree : rho = +0.410

8. MC Shapley Effects Under Correlated Inputs¶

We use GaussianCopulaUniform with the Vrugt correlation matrix and the Monte Carlo exhaustive method (Owen & Prieur, 2017):

  • k_max=3 — only subsets up to size 3 are enumerated (exact for our 3rd-order surrogate)
  • N=2000 Monte Carlo samples per subset
  • B=500 bootstrap iterations for 95% confidence intervals

The copula induces dependence in the latent normal space while preserving uniform marginals for each parameter.

In [26]:
Copied!
print('Computing MC Shapley Effects with correlated inputs...')
print(f'  k_max = 3, N = 2000, B = 500\n')

df_mc = analyzer.get_mc_shapley(
    corr=corr_14,
    N=2000,
    method='exhaustive',
    B=500,
    alpha=0.05,
    random_state=42,
    progress=True,
    k_max=3,
)

print('\nMC Shapley Effects (correlated inputs):')
display(df_mc)
print('Computing MC Shapley Effects with correlated inputs...') print(f' k_max = 3, N = 2000, B = 500\n') df_mc = analyzer.get_mc_shapley( corr=corr_14, N=2000, method='exhaustive', B=500, alpha=0.05, random_state=42, progress=True, k_max=3, ) print('\nMC Shapley Effects (correlated inputs):') display(df_mc)
Computing MC Shapley Effects with correlated inputs...
  k_max = 3, N = 2000, B = 500

MC Shapley: 100%|██████████| 1878000/1878000 [00:22<00:00, 84752.11evals/s]
MC Shapley Effects (correlated inputs):
variable effect shapley_value sobol_first sobol_total total_variance lower upper sobol_first_lower sobol_first_upper sobol_total_lower sobol_total_upper
0 Uztwm 0.019 9.616e-05 1.523e-02 NaN 0.005 0.005 0.032 -0.032 0.064 NaN NaN
1 Uzfwm 0.003 1.435e-05 -4.528e-04 NaN 0.005 -0.010 0.015 -0.042 0.045 NaN NaN
2 Lztwm 0.060 3.043e-04 7.527e-02 NaN 0.005 0.047 0.072 0.029 0.116 NaN NaN
3 Lzfpm 0.168 8.578e-04 2.221e-01 NaN 0.005 0.155 0.181 0.178 0.270 NaN NaN
4 Lzfsm 0.216 1.101e-03 3.342e-01 NaN 0.005 0.203 0.228 0.287 0.381 NaN NaN
5 Adimp 0.043 2.212e-04 6.166e-02 NaN 0.005 0.030 0.056 0.016 0.105 NaN NaN
6 Uzk -0.004 -2.067e-05 -1.307e-02 NaN 0.005 -0.016 0.008 -0.058 0.030 NaN NaN
7 Lzpk 0.104 5.327e-04 1.697e-01 NaN 0.005 0.092 0.117 0.125 0.211 NaN NaN
8 Lzsk 0.052 2.642e-04 2.863e-02 NaN 0.005 0.040 0.064 -0.016 0.072 NaN NaN
9 Zperc 0.040 2.053e-04 6.856e-02 NaN 0.005 0.029 0.053 0.028 0.112 NaN NaN
10 Rexp 0.177 9.034e-04 3.057e-01 NaN 0.005 0.163 0.193 0.257 0.359 NaN NaN
11 Pctim 0.004 2.043e-05 1.190e-02 NaN 0.005 -0.009 0.016 -0.033 0.054 NaN NaN
12 Pfree 0.070 3.571e-04 1.156e-01 NaN 0.005 0.058 0.083 0.074 0.162 NaN NaN
13 Side 0.048 2.429e-04 5.529e-02 NaN 0.005 0.035 0.061 0.014 0.103 NaN NaN

9. Direct MC Shapley on the Original SAC-SMA Model¶

The section above computed MC Shapley on the RS-HDMR surrogate. Here we estimate them directly on the SAC-SMA model using ShapleyX's built-in support for user-defined functions via the f parameter. This enables a clean comparison: same distribution, same algorithm, different model function.

We use a smaller sample size ($N = 100$) to keep computation practical. At $N = 100$ with $k_{\max} = 2$, the evaluation requires $105 \times 100 \times 2 = 21{,}000$ SAC-SMA evaluations, taking approximately 5 minutes.

In [42]:
Copied!
N_direct = 1000
B_direct = 100

def sacsma_nse_wrapper(x):
    """SAC-SMA NSE function taking model parameter vector.
    Note: GaussianCopulaUniform handles the [0,1] → physical mapping."""
    return nse_from_params(x)

print('Computing MC Shapley directly on SAC-SMA model...')
print(f'  Distribution: GaussianCopulaUniform (Vrugt correlations)')
print(f'  N = {N_direct}, B = {B_direct}, k_max = 2')
print(f'  Estimated evals: ~{105 * N_direct * 2:,}')

eff_dir, sh_dir, var_dir, lower_dir, upper_dir = shapley_effects(
    sacsma_nse_wrapper,
    joint_correlated,
    N=N_direct,
    method='exhaustive',
    B=B_direct,
    alpha=0.05,
    random_state=42,
    progress=True,
    k_max=2,
)

df_direct = pd.DataFrame({
    'variable': param_names,
    'effect': eff_dir,
    'value': sh_dir,
    'total_var': var_dir,
    'lower': lower_dir,
    'upper': upper_dir,
})
print('\nDone. Top 5 parameters (direct SAC-SMA):')
print(df_direct.sort_values('effect', ascending=False).head(10).to_string(index=False))
N_direct = 1000 B_direct = 100 def sacsma_nse_wrapper(x): """SAC-SMA NSE function taking model parameter vector. Note: GaussianCopulaUniform handles the [0,1] → physical mapping.""" return nse_from_params(x) print('Computing MC Shapley directly on SAC-SMA model...') print(f' Distribution: GaussianCopulaUniform (Vrugt correlations)') print(f' N = {N_direct}, B = {B_direct}, k_max = 2') print(f' Estimated evals: ~{105 * N_direct * 2:,}') eff_dir, sh_dir, var_dir, lower_dir, upper_dir = shapley_effects( sacsma_nse_wrapper, joint_correlated, N=N_direct, method='exhaustive', B=B_direct, alpha=0.05, random_state=42, progress=True, k_max=2, ) df_direct = pd.DataFrame({ 'variable': param_names, 'effect': eff_dir, 'value': sh_dir, 'total_var': var_dir, 'lower': lower_dir, 'upper': upper_dir, }) print('\nDone. Top 5 parameters (direct SAC-SMA):') print(df_direct.sort_values('effect', ascending=False).head(10).to_string(index=False))
Computing MC Shapley directly on SAC-SMA model...
  Distribution: GaussianCopulaUniform (Vrugt correlations)
  N = 1000, B = 100, k_max = 2
  Estimated evals: ~210,000 (~5 min)
MC Shapley: 100%|██████████| 211000/211000 [05:57<00:00, 589.82evals/s]
In [43]:
Copied!
# Compute surrogate MC Shapley at same settings for fair comparison
print('Computing surrogate MC Shapley at same N=1000, k_max=2...')
eff_surr, sh_surr, var_surr, lower_surr, upper_surr = shapley_effects(
    analyzer.predict,
    joint_correlated,
    N=N_direct,
    method='exhaustive',
    B=B_direct,
    alpha=0.05,
    random_state=42,
    progress=True,
    k_max=2,
)

df_surrogate = pd.DataFrame({
    'variable': param_names,
    'effect': eff_surr,
    'value': sh_surr,
    'total_var': var_surr,
    'lower': lower_surr,
    'upper': upper_surr,
})

# Merge into comparison table
df_comp_direct = df_direct[['variable', 'effect', 'lower', 'upper']].copy()
df_comp_direct.columns = ['Variable', 'SAC-SMA', 'CI_low', 'CI_high']
df_comp_surr = df_surrogate[['variable', 'effect']].copy()
df_comp_surr.columns = ['Variable', 'Surrogate']

df_compare = df_comp_direct.merge(df_comp_surr, on='Variable')
df_compare['Delta'] = df_compare['SAC-SMA'] - df_compare['Surrogate']
df_compare = df_compare.sort_values('SAC-SMA', ascending=False).reset_index(drop=True)

print('\n=== Direct SAC-SMA vs Surrogate MC Shapley (N=1000, k_max=2) ===')
print(df_compare.to_string(index=False))
print(f'\nSum SAC-SMA: {df_compare["SAC-SMA"].sum():.4f}')
print(f'Sum Surrogate: {df_compare["Surrogate"].sum():.4f}')
print(f'Max |Delta|: {df_compare["Delta"].abs().max():.4f}')
# Compute surrogate MC Shapley at same settings for fair comparison print('Computing surrogate MC Shapley at same N=1000, k_max=2...') eff_surr, sh_surr, var_surr, lower_surr, upper_surr = shapley_effects( analyzer.predict, joint_correlated, N=N_direct, method='exhaustive', B=B_direct, alpha=0.05, random_state=42, progress=True, k_max=2, ) df_surrogate = pd.DataFrame({ 'variable': param_names, 'effect': eff_surr, 'value': sh_surr, 'total_var': var_surr, 'lower': lower_surr, 'upper': upper_surr, }) # Merge into comparison table df_comp_direct = df_direct[['variable', 'effect', 'lower', 'upper']].copy() df_comp_direct.columns = ['Variable', 'SAC-SMA', 'CI_low', 'CI_high'] df_comp_surr = df_surrogate[['variable', 'effect']].copy() df_comp_surr.columns = ['Variable', 'Surrogate'] df_compare = df_comp_direct.merge(df_comp_surr, on='Variable') df_compare['Delta'] = df_compare['SAC-SMA'] - df_compare['Surrogate'] df_compare = df_compare.sort_values('SAC-SMA', ascending=False).reset_index(drop=True) print('\n=== Direct SAC-SMA vs Surrogate MC Shapley (N=1000, k_max=2) ===') print(df_compare.to_string(index=False)) print(f'\nSum SAC-SMA: {df_compare["SAC-SMA"].sum():.4f}') print(f'Sum Surrogate: {df_compare["Surrogate"].sum():.4f}') print(f'Max |Delta|: {df_compare["Delta"].abs().max():.4f}')
Computing surrogate MC Shapley at same N=100, k_max=2...

Direct SAC-SMA vs Surrogate MC Shapley (N=100, k_max=2):
Variable    SAC-SMA  CI_low  CI_high  Surrogate(k2)      Delta
   Lzfsm  1.963e-01   0.175    0.226      2.016e-01 -5.262e-03
   Lzfpm  1.826e-01   0.158    0.209      1.797e-01  2.824e-03
    Rexp  1.596e-01   0.132    0.190      1.709e-01 -1.127e-02
    Lzpk  1.198e-01   0.097    0.143      1.051e-01  1.469e-02
   Lztwm  8.458e-02   0.052    0.109      8.038e-02  4.203e-03
   Pfree  8.073e-02   0.059    0.108      8.470e-02 -3.969e-03
    Lzsk  5.237e-02   0.026    0.076      4.408e-02  8.296e-03
    Side  4.414e-02   0.013    0.071      4.829e-02 -4.151e-03
   Zperc  4.180e-02   0.018    0.066      4.336e-02 -1.555e-03
   Adimp  3.484e-02   0.012    0.059      4.191e-02 -7.071e-03
     Uzk  1.012e-02  -0.015    0.036      5.006e-03  5.113e-03
   Pctim  2.788e-03  -0.023    0.030      1.436e-03  1.352e-03
   Uztwm -5.840e-04  -0.027    0.024     -5.897e-04  5.730e-06
   Uzfwm -9.062e-03  -0.031    0.018     -5.850e-03 -3.212e-03

Sum SAC-SMA: 1.0000
Sum Surr(k2): 1.0000
Max |Delta|: 0.0147
In [50]:
Copied!
# Bar chart comparison: SAC-SMA vs Surrogate
import matplotlib.pyplot as plt
import numpy as np

N_PLOT = 10
plot_df = df_compare.head(N_PLOT)

fig, ax = plt.subplots(figsize=(10, 5))
x = np.arange(N_PLOT)
width = 0.35

ax.bar(x - width/2, plot_df['SAC-SMA'], width, label='SAC-SMA (direct)',
       color='#4C72B0', edgecolor='white', linewidth=0.5)
ax.bar(x + width/2, plot_df['Surrogate'], width, label='RS-HDMR Surrogate',
       color='#DD8452', edgecolor='white', linewidth=0.5)

ax.set_xticks(x)
ax.set_xticklabels(plot_df['Variable'], rotation=45, ha='right')
ax.set_ylabel('Shapley Effect')
ax.set_title('Direct SAC-SMA vs Surrogate MC Shapley Effects (N=1000, k_max=2)')
ax.legend(frameon=False)
fig.tight_layout()
plt.show()

# Save for the case study report
fig.savefig('anal_surr_comparison.png', dpi=200, bbox_inches='tight')
print('Saved: anal_surr_comparison.png')
# Bar chart comparison: SAC-SMA vs Surrogate import matplotlib.pyplot as plt import numpy as np N_PLOT = 10 plot_df = df_compare.head(N_PLOT) fig, ax = plt.subplots(figsize=(10, 5)) x = np.arange(N_PLOT) width = 0.35 ax.bar(x - width/2, plot_df['SAC-SMA'], width, label='SAC-SMA (direct)', color='#4C72B0', edgecolor='white', linewidth=0.5) ax.bar(x + width/2, plot_df['Surrogate'], width, label='RS-HDMR Surrogate', color='#DD8452', edgecolor='white', linewidth=0.5) ax.set_xticks(x) ax.set_xticklabels(plot_df['Variable'], rotation=45, ha='right') ax.set_ylabel('Shapley Effect') ax.set_title('Direct SAC-SMA vs Surrogate MC Shapley Effects (N=1000, k_max=2)') ax.legend(frameon=False) fig.tight_layout() plt.show() # Save for the case study report fig.savefig('anal_surr_comparison.png', dpi=200, bbox_inches='tight') print('Saved: anal_surr_comparison.png')
No description has been provided for this image

Direct vs Surrogate-Assisted MC Shapley¶

This comparison validates the RS-HDMR surrogate's fidelity for correlated-input sensitivity analysis using ShapleyX's unified API. Both estimates use the same GaussianCopulaUniform distribution, the same exhaustive method with $k_{\max}=2$, and the same Monte Carlo sample size ($N = 100$). The only difference is the model function: the original SAC-SMA code versus the RS-HDMR surrogate.

Key observations:

  • Computational cost: Direct SAC-SMA requires $\sim$5 minutes for $21{,}000$ evaluations at $N = 100$. The surrogate completes the same calculation in seconds.
  • Statistical noise: The direct estimates have higher Monte Carlo variance (visible as wider bootstrap CIs) because each SAC-SMA evaluation includes the full 46-year simulation, introducing response variability that the noise-free surrogate does not have.
  • Surrogate fidelity: Close agreement between direct and surrogate estimates confirms that the RS-HDMR model with OMP-CV ($r^2 = 0.989$) captures the NSE response surface sufficiently well to reproduce the correlated sensitivity structure.

10. Comparison: Independent vs Correlated Shapley Effects¶

This side-by-side comparison reveals how accounting for parameter correlations changes the sensitivity ranking. Parameters with strong collinearity (e.g., UZTWM↔LZTWM at ρ = −0.84) may show the largest shifts.

In [16]:
Copied!
# Extract RS-HDMR effects
indep_col = 'scaled effect' if 'scaled effect' in shap.columns else 'effect'
indep_dict = {}
for _, row in shap.iterrows():
    label = row['label']
    # ShapleyX may label like "Uztwm_0" — match by prefix
    matched = False
    for p in param_names:
        if label.startswith(p):
            indep_dict[p] = row[indep_col]
            matched = True
            break
    if not matched:
        indep_dict[label] = row[indep_col]

df_comp = pd.DataFrame({
    'Parameter': param_names,
    'Independent': [indep_dict.get(p, 0) for p in param_names],
    'Correlated': df_mc['effect'].values,
})

if 'lower' in df_mc.columns:
    df_comp['CI_lower'] = df_mc['lower'].values
    df_comp['CI_upper'] = df_mc['upper'].values

# Normalise independent to sum to 1
df_comp['Independent'] /= df_comp['Independent'].sum()
df_comp['Change'] = df_comp['Correlated'] - df_comp['Independent']

df_comp_sorted = df_comp.sort_values('Correlated', ascending=False)
print('Comparison: Independent vs Correlated Shapley Effects')
display(df_comp_sorted.round(4))
# Extract RS-HDMR effects indep_col = 'scaled effect' if 'scaled effect' in shap.columns else 'effect' indep_dict = {} for _, row in shap.iterrows(): label = row['label'] # ShapleyX may label like "Uztwm_0" — match by prefix matched = False for p in param_names: if label.startswith(p): indep_dict[p] = row[indep_col] matched = True break if not matched: indep_dict[label] = row[indep_col] df_comp = pd.DataFrame({ 'Parameter': param_names, 'Independent': [indep_dict.get(p, 0) for p in param_names], 'Correlated': df_mc['effect'].values, }) if 'lower' in df_mc.columns: df_comp['CI_lower'] = df_mc['lower'].values df_comp['CI_upper'] = df_mc['upper'].values # Normalise independent to sum to 1 df_comp['Independent'] /= df_comp['Independent'].sum() df_comp['Change'] = df_comp['Correlated'] - df_comp['Independent'] df_comp_sorted = df_comp.sort_values('Correlated', ascending=False) print('Comparison: Independent vs Correlated Shapley Effects') display(df_comp_sorted.round(4))
Comparison: Independent vs Correlated Shapley Effects
Parameter Independent Correlated CI_lower CI_upper Change
4 Lzfsm 2.058e-01 0.216 0.203 0.228 1.010e-02
10 Rexp 1.646e-01 0.177 0.163 0.193 1.260e-02
3 Lzfpm 2.492e-01 0.168 0.155 0.181 -8.110e-02
7 Lzpk 5.060e-02 0.104 0.091 0.117 5.390e-02
12 Pfree 9.000e-04 0.070 0.058 0.083 6.910e-02
2 Lztwm 4.290e-02 0.060 0.047 0.072 1.670e-02
8 Lzsk 6.240e-02 0.052 0.040 0.064 -1.050e-02
13 Side 4.770e-02 0.048 0.035 0.060 -1.000e-04
5 Adimp 8.370e-02 0.043 0.030 0.056 -4.040e-02
9 Zperc 6.380e-02 0.040 0.029 0.053 -2.340e-02
0 Uztwm 3.000e-03 0.019 0.005 0.032 1.580e-02
11 Pctim 7.400e-03 0.004 -0.009 0.016 -3.400e-03
1 Uzfwm 4.400e-03 0.003 -0.009 0.015 -1.600e-03
6 Uzk 1.360e-02 -0.004 -0.016 0.008 -1.770e-02
In [17]:
Copied!
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

y = np.arange(len(param_names))
order = np.argsort(df_comp['Correlated'].values)

# Left: side-by-side bars
for col, color, label, offset in [
    ('Independent', 'royalblue', 'Independent (RS-HDMR)', -0.17),
    ('Correlated', 'firebrick', 'Correlated (MC Shapley)', 0.17),
]:
    vals = df_comp[col].values[order]
    ax1.barh(y + offset, vals, 0.3, label=label,
             color=color, alpha=0.75, edgecolor='white', linewidth=0.5)

ax1.set_yticks(y)
ax1.set_yticklabels(np.array(param_names)[order])
ax1.set_xlabel('Shapley Effect')
ax1.set_title('Independent vs Correlated')
ax1.legend(fontsize=9, loc='lower right')
ax1.axvline(0, color='grey', lw=0.8)

# Right: delta
changes = df_comp['Change'].values[order]
clrs = ['firebrick' if c > 0 else 'royalblue' for c in changes]
ax2.barh(y, changes, 0.35, color=clrs, alpha=0.75,
         edgecolor='white', linewidth=0.5)
ax2.axvline(0, color='grey', lw=0.8)
ax2.set_yticks([])
ax2.set_xlabel('Δ (Correlated − Independent)')
ax2.set_title('Change Due to Correlation')

plt.tight_layout()
plt.savefig('shapley_comparison.png')
plt.show()
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) y = np.arange(len(param_names)) order = np.argsort(df_comp['Correlated'].values) # Left: side-by-side bars for col, color, label, offset in [ ('Independent', 'royalblue', 'Independent (RS-HDMR)', -0.17), ('Correlated', 'firebrick', 'Correlated (MC Shapley)', 0.17), ]: vals = df_comp[col].values[order] ax1.barh(y + offset, vals, 0.3, label=label, color=color, alpha=0.75, edgecolor='white', linewidth=0.5) ax1.set_yticks(y) ax1.set_yticklabels(np.array(param_names)[order]) ax1.set_xlabel('Shapley Effect') ax1.set_title('Independent vs Correlated') ax1.legend(fontsize=9, loc='lower right') ax1.axvline(0, color='grey', lw=0.8) # Right: delta changes = df_comp['Change'].values[order] clrs = ['firebrick' if c > 0 else 'royalblue' for c in changes] ax2.barh(y, changes, 0.35, color=clrs, alpha=0.75, edgecolor='white', linewidth=0.5) ax2.axvline(0, color='grey', lw=0.8) ax2.set_yticks([]) ax2.set_xlabel('Δ (Correlated − Independent)') ax2.set_title('Change Due to Correlation') plt.tight_layout() plt.savefig('shapley_comparison.png') plt.show()
No description has been provided for this image
In [18]:
Copied!
print('Top 5 parameters by sensitivity:')

print('\n  Under INDEPENDENT inputs (RS-HDMR):')
top5_indep = df_comp_sorted.sort_values('Independent', ascending=False).head(5)
for _, row in top5_indep.iterrows():
    print(f'    {row["Parameter"]:>6s}: {row["Independent"]:.4f}')

print('\n  Under CORRELATED inputs (MC Shapley):')
has_ci = 'CI_lower' in df_comp.columns
for _, row in df_comp_sorted.head(5).iterrows():
    ci = ''
    if has_ci:
        ci = f' [{row["CI_lower"]:.4f}, {row["CI_upper"]:.4f}]'
    print(f'    {row["Parameter"]:>6s}: {row["Correlated"]:.4f}{ci}')

print('\n  Largest positive change (gained importance with correlation):')
print(df_comp_sorted.sort_values('Change', ascending=False).head(3)[['Parameter','Change']].to_string(index=False))

print('\n  Largest negative change (lost importance with correlation):')
print(df_comp_sorted.sort_values('Change', ascending=True).head(3)[['Parameter','Change']].to_string(index=False))
print('Top 5 parameters by sensitivity:') print('\n Under INDEPENDENT inputs (RS-HDMR):') top5_indep = df_comp_sorted.sort_values('Independent', ascending=False).head(5) for _, row in top5_indep.iterrows(): print(f' {row["Parameter"]:>6s}: {row["Independent"]:.4f}') print('\n Under CORRELATED inputs (MC Shapley):') has_ci = 'CI_lower' in df_comp.columns for _, row in df_comp_sorted.head(5).iterrows(): ci = '' if has_ci: ci = f' [{row["CI_lower"]:.4f}, {row["CI_upper"]:.4f}]' print(f' {row["Parameter"]:>6s}: {row["Correlated"]:.4f}{ci}') print('\n Largest positive change (gained importance with correlation):') print(df_comp_sorted.sort_values('Change', ascending=False).head(3)[['Parameter','Change']].to_string(index=False)) print('\n Largest negative change (lost importance with correlation):') print(df_comp_sorted.sort_values('Change', ascending=True).head(3)[['Parameter','Change']].to_string(index=False))
Top 5 parameters by sensitivity:

  Under INDEPENDENT inputs (RS-HDMR):
     Lzfpm: 0.2492
     Lzfsm: 0.2058
      Rexp: 0.1646
     Adimp: 0.0837
     Zperc: 0.0638

  Under CORRELATED inputs (MC Shapley):
     Lzfsm: 0.2160 [0.2035, 0.2284]
      Rexp: 0.1772 [0.1630, 0.1933]
     Lzfpm: 0.1682 [0.1555, 0.1810]
      Lzpk: 0.1044 [0.0915, 0.1165]
     Pfree: 0.0699 [0.0584, 0.0825]

  Largest positive change (gained importance with correlation):
Parameter  Change
    Pfree   0.069
     Lzpk   0.054
    Lztwm   0.017

  Largest negative change (lost importance with correlation):
Parameter  Change
    Lzfpm  -0.081
    Adimp  -0.040
    Zperc  -0.023

11. Summary¶

This example demonstrated a complete ShapleyX workflow for global sensitivity analysis of the SAC-SMA model under correlated inputs:

Step Method Detail
Training data QMC Sobol (scrambled + shifted) 4096 samples, 14 parameters
Surrogate RS-HDMR + streaming OMP-CV polys=[10,6,2], sparse model from ~9K basis functions
Independent SA RS-HDMR Shapley Effects Redistributes interaction variance equally
Correlation source Vrugt et al. (2006) Table 2 13×13 posterior MCMC correlation matrix
Correlated SA MC Shapley (Owen & Prieur, 2017) Gaussian copula + conditional sampling, k_max=3, B=500

Key result: Standard sensitivity analysis assumes independent inputs, but real hydrological parameters exhibit strong correlations (e.g., UZTWM↔LZTWM, ρ = −0.84). The MC Shapley method correctly accounts for this shared variance, providing more reliable sensitivity rankings for model understanding and calibration prioritisation.

Previous Next

Built with MkDocs using a theme provided by Read the Docs.
GitHub « Previous Next »