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
  • 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
  • Cantilever Beam
  • Edit on GitHub

Cantilever Beam — Demange-Chryst et al. (2022), Example 4.2¶

This notebook replicates the cantilever beam reliability-oriented sensitivity analysis from Demange-Chryst et al. [1]. The goal is to estimate Shapley effects for a structural engineering problem with correlated mixed-distribution inputs (LogNormal + Normal).

Model. A rectangular cantilever beam under two orthogonal tip forces $F_X$ and $F_Y$. The maximum vertical tip displacement is:

$$ D(\mathbf{x}) = \frac{4L^3}{E\,l_X l_Y} \sqrt{\left(\frac{F_X}{l_X^2}\right)^2 + \left(\frac{F_Y}{l_Y^2}\right)^2} $$

The failure threshold is $D > t = 0.066\,\text{m}$.

Input variables (6) with mixed Normal / LogNormal marginals and correlations between the three dimensional parameters.


[1] Demange-Chryst, J., Bachoc, F., & Morio, J. (2022). "Shapley effect estimation in reliability-oriented sensitivity analysis with correlated inputs by importance sampling." IJUQ, arXiv:2202.12679.

In [2]:
Copied!
# Import dependencies
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import norm, lognorm
from scipy.stats.qmc import Sobol

from shapleyx.utilities.mc_shapley import (
    MultivariateNormal,
    shapley_effects,
)

from importlib.metadata import version
print(f"Running on ShapleyX v{version('shapleyx')}")
# Import dependencies import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.stats import norm, lognorm from scipy.stats.qmc import Sobol from shapleyx.utilities.mc_shapley import ( MultivariateNormal, shapley_effects, ) from importlib.metadata import version print(f"Running on ShapleyX v{version('shapleyx')}")
Running on ShapleyX v0.5.1

Input Distribution¶

Six variables — three LogNormal forces/modulus, three correlated Normal geometric parameters. Correlations (Pearson): $\rho(l_X,l_Y)=0.55$, $\rho(L,l_X)=\rho(L,l_Y)=0.45$.

In [3]:
Copied!
d = 6

# Marginal parameters: (dist_type, mean, CV)
# LogNormal params converted to underlying normal (mu, sigma)
def lognorm_params(mean, cv):
    """Convert LogNormal (mean, CV) → underlying normal (mu, sigma)."""
    sigma = np.sqrt(np.log(1 + cv**2))
    mu = np.log(mean) - 0.5 * sigma**2
    return mu, sigma

def normal_params(mean, cv):
    """Convert Normal (mean, CV) → (mu, sigma)."""
    return mean, mean * cv

# Table 1 from the paper
marginals = {
    'FX': ('lognormal', *lognorm_params(556.8,       0.08)),
    'FY': ('lognormal', *lognorm_params(453.6,       0.08)),
    'E':  ('lognormal', *lognorm_params(200e9,       0.06)),
    'lX': ('normal',    *normal_params(0.062,        0.1)),
    'lY': ('normal',    *normal_params(0.0987,       0.1)),
    'L':  ('normal',    *normal_params(4.29,         0.1)),
}

labels = list(marginals.keys())
print(f"{'Variable':>6s}  {'Type':>10s}  {'Mean':>12s}  {'Std':>12s}")
print("-" * 48)
for name, (dist, mu, sigma) in marginals.items():
    if dist == 'lognormal':
        mn = np.exp(mu + 0.5 * sigma**2)
        st = mn * np.sqrt(np.exp(sigma**2) - 1)
    else:
        mn, st = mu, sigma
    print(f"{name:>6s}  {dist:>10s}  {mn:12.4g}  {st:12.4g}")
d = 6 # Marginal parameters: (dist_type, mean, CV) # LogNormal params converted to underlying normal (mu, sigma) def lognorm_params(mean, cv): """Convert LogNormal (mean, CV) → underlying normal (mu, sigma).""" sigma = np.sqrt(np.log(1 + cv**2)) mu = np.log(mean) - 0.5 * sigma**2 return mu, sigma def normal_params(mean, cv): """Convert Normal (mean, CV) → (mu, sigma).""" return mean, mean * cv # Table 1 from the paper marginals = { 'FX': ('lognormal', *lognorm_params(556.8, 0.08)), 'FY': ('lognormal', *lognorm_params(453.6, 0.08)), 'E': ('lognormal', *lognorm_params(200e9, 0.06)), 'lX': ('normal', *normal_params(0.062, 0.1)), 'lY': ('normal', *normal_params(0.0987, 0.1)), 'L': ('normal', *normal_params(4.29, 0.1)), } labels = list(marginals.keys()) print(f"{'Variable':>6s} {'Type':>10s} {'Mean':>12s} {'Std':>12s}") print("-" * 48) for name, (dist, mu, sigma) in marginals.items(): if dist == 'lognormal': mn = np.exp(mu + 0.5 * sigma**2) st = mn * np.sqrt(np.exp(sigma**2) - 1) else: mn, st = mu, sigma print(f"{name:>6s} {dist:>10s} {mn:12.4g} {st:12.4g}")
Variable        Type          Mean           Std
------------------------------------------------
    FX   lognormal         556.8         44.54
    FY   lognormal         453.6         36.29
     E   lognormal         2e+11       1.2e+10
    lX      normal         0.062        0.0062
    lY      normal        0.0987       0.00987
     L      normal          4.29         0.429
In [4]:
Copied!
# Correlation matrix (Pearson) — only dimensional params are correlated
# Indices: 0=FX, 1=FY, 2=E, 3=lX, 4=lY, 5=L
corr_pearson = np.array([
    [1.0, 0.0, 0.0, 0.0,  0.0,  0.0],
    [0.0, 1.0, 0.0, 0.0,  0.0,  0.0],
    [0.0, 0.0, 1.0, 0.0,  0.0,  0.0],
    [0.0, 0.0, 0.0, 1.0,  -0.55, 0.45],
    [0.0, 0.0, 0.0, -0.55, 1.0,  0.45],
    [0.0, 0.0, 0.0, 0.45, 0.45, 1.0],
])

print("Pearson correlation matrix:")
print(pd.DataFrame(corr_pearson, index=labels, columns=labels).round(2))
# Correlation matrix (Pearson) — only dimensional params are correlated # Indices: 0=FX, 1=FY, 2=E, 3=lX, 4=lY, 5=L corr_pearson = np.array([ [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, -0.55, 0.45], [0.0, 0.0, 0.0, -0.55, 1.0, 0.45], [0.0, 0.0, 0.0, 0.45, 0.45, 1.0], ]) print("Pearson correlation matrix:") print(pd.DataFrame(corr_pearson, index=labels, columns=labels).round(2))
Pearson correlation matrix:
     FX   FY    E    lX    lY     L
FX  1.0  0.0  0.0  0.00  0.00  0.00
FY  0.0  1.0  0.0  0.00  0.00  0.00
E   0.0  0.0  1.0  0.00  0.00  0.00
lX  0.0  0.0  0.0  1.00 -0.55  0.45
lY  0.0  0.0  0.0 -0.55  1.00  0.45
L   0.0  0.0  0.0  0.45  0.45  1.00

Gaussian Copula Distribution Class¶

To handle mixed LogNormal + Normal marginals with correlation, we build a Gaussian copula wrapper: transform each marginal to a standard normal via the probability integral transform, apply the correlation structure in the latent normal space, then map back.

The correlation matrix uses the exact Demange-Chryst et al. (2022) latent correlations: ρ(lX,lY)=0.55, ρ(L,lX)=ρ(L,lY)=0.45. For Normal marginals (lX, lY, L), the Pearson correlation equals the latent normal correlation. The LogNormal variables (FX, FY, E) are uncorrelated, so the latent correlation matrix is the identity for that block.

In [5]:
Copied!
class GaussianCopulaMixed:
    """Gaussian copula with arbitrary marginal distributions.

    Supports LogNormal and Normal marginals with a user-specified
    latent correlation matrix.  Conditional sampling is performed
    in the latent normal space using the multivariate normal
    conditional distribution, then mapped back to the original space.
    """
    def __init__(self, marginals, latent_corr):
        self.d = len(marginals)
        self.labels = list(marginals.keys())
        self._marginals = marginals
        self._latent_corr = np.asarray(latent_corr)

        # Underlying multivariate normal for latent-space sampling
        self._mvn = MultivariateNormal(
            mean=np.zeros(self.d), cov=self._latent_corr
        )

    @staticmethod
    def _marginal_to_latent(x, dist, mu, sigma):
        """Transform a single marginal column original -> N(0,1)."""
        if dist == "lognormal":
            x = np.clip(np.asarray(x), 1e-15, None)
            return norm.ppf(lognorm.cdf(x, s=sigma, scale=np.exp(mu)))
        else:
            return (np.asarray(x) - mu) / sigma

    @staticmethod
    def _marginal_from_latent(z, dist, mu, sigma):
        """Transform a single marginal column N(0,1) -> original."""
        if dist == "lognormal":
            return lognorm.ppf(norm.cdf(np.asarray(z)), s=sigma, scale=np.exp(mu))
        else:
            return mu + sigma * np.asarray(z)

    def _to_original(self, Z):
        """Transform latent normal -> original space, vectorised."""
        X = np.zeros_like(Z)
        for j in range(self.d):
            name = self.labels[j]
            dist, mu, sigma = self._marginals[name]
            X[:, j] = self._marginal_from_latent(Z[:, j], dist, mu, sigma)
        return X

    def sample_joint(self, n):
        """Draw n joint samples."""
        Z = self._mvn.sample_joint(n)
        return self._to_original(Z)

    def sample_conditional(self, u_indices, fixed_x):
        """Draw one conditional sample."""
        X = self.sample_conditional_batch(
            u_indices, np.atleast_2d(np.asarray(fixed_x, dtype=float))
        )
        return X[0]

    def sample_conditional_batch(self, u_indices, fixed_X):
        """Draw N conditional samples.

        Strategy: map fixed_X to latent space, condition in the
        latent multivariate normal, map the result back.
        """
        u = np.asarray(u_indices)
        N = fixed_X.shape[0]
        fixed_X = np.asarray(fixed_X, dtype=float)

        if len(u) == 0:
            return self.sample_joint(N)

        # Map fixed variables to latent space
        Z_fixed = np.zeros((N, len(u)))
        for k, idx in enumerate(u):
            name = self.labels[idx]
            dist, mu, sigma = self._marginals[name]
            Z_fixed[:, k] = self._marginal_to_latent(
                fixed_X[:, k], dist, mu, sigma
            )

        # Condition in latent normal space
        Z_cond = self._mvn.sample_conditional_batch(u, Z_fixed)

        # Map back to original space
        return self._to_original(Z_cond)


joint = GaussianCopulaMixed(marginals, corr_pearson)
print("GaussianCopulaMixed distribution ready.")
print(f"Dimension: {joint.d}")
class GaussianCopulaMixed: """Gaussian copula with arbitrary marginal distributions. Supports LogNormal and Normal marginals with a user-specified latent correlation matrix. Conditional sampling is performed in the latent normal space using the multivariate normal conditional distribution, then mapped back to the original space. """ def __init__(self, marginals, latent_corr): self.d = len(marginals) self.labels = list(marginals.keys()) self._marginals = marginals self._latent_corr = np.asarray(latent_corr) # Underlying multivariate normal for latent-space sampling self._mvn = MultivariateNormal( mean=np.zeros(self.d), cov=self._latent_corr ) @staticmethod def _marginal_to_latent(x, dist, mu, sigma): """Transform a single marginal column original -> N(0,1).""" if dist == "lognormal": x = np.clip(np.asarray(x), 1e-15, None) return norm.ppf(lognorm.cdf(x, s=sigma, scale=np.exp(mu))) else: return (np.asarray(x) - mu) / sigma @staticmethod def _marginal_from_latent(z, dist, mu, sigma): """Transform a single marginal column N(0,1) -> original.""" if dist == "lognormal": return lognorm.ppf(norm.cdf(np.asarray(z)), s=sigma, scale=np.exp(mu)) else: return mu + sigma * np.asarray(z) def _to_original(self, Z): """Transform latent normal -> original space, vectorised.""" X = np.zeros_like(Z) for j in range(self.d): name = self.labels[j] dist, mu, sigma = self._marginals[name] X[:, j] = self._marginal_from_latent(Z[:, j], dist, mu, sigma) return X def sample_joint(self, n): """Draw n joint samples.""" Z = self._mvn.sample_joint(n) return self._to_original(Z) def sample_conditional(self, u_indices, fixed_x): """Draw one conditional sample.""" X = self.sample_conditional_batch( u_indices, np.atleast_2d(np.asarray(fixed_x, dtype=float)) ) return X[0] def sample_conditional_batch(self, u_indices, fixed_X): """Draw N conditional samples. Strategy: map fixed_X to latent space, condition in the latent multivariate normal, map the result back. """ u = np.asarray(u_indices) N = fixed_X.shape[0] fixed_X = np.asarray(fixed_X, dtype=float) if len(u) == 0: return self.sample_joint(N) # Map fixed variables to latent space Z_fixed = np.zeros((N, len(u))) for k, idx in enumerate(u): name = self.labels[idx] dist, mu, sigma = self._marginals[name] Z_fixed[:, k] = self._marginal_to_latent( fixed_X[:, k], dist, mu, sigma ) # Condition in latent normal space Z_cond = self._mvn.sample_conditional_batch(u, Z_fixed) # Map back to original space return self._to_original(Z_cond) joint = GaussianCopulaMixed(marginals, corr_pearson) print("GaussianCopulaMixed distribution ready.") print(f"Dimension: {joint.d}")
GaussianCopulaMixed distribution ready.
Dimension: 6

Quick Validation: Empirical Correlations¶

Verify that the copula reproduces the specified Pearson correlations.

In [6]:
Copied!
X_check = joint.sample_joint(50000)
emp_corr = np.corrcoef(X_check.T)

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
pairs = [(3, 4, 'lX vs lY'), (3, 5, 'lX vs L'), (4, 5, 'lY vs L')]
for ax, (i, j, title) in zip(axes, pairs):
    ax.scatter(X_check[:, i], X_check[:, j], alpha=0.15, s=2, color='steelblue')
    ax.set_title(f'{title}\n'
                 f'ρ = {emp_corr[i,j]:.3f} (target: {corr_pearson[i,j]:.2f})')
    ax.set_xlabel(labels[i])
    ax.set_ylabel(labels[j])
plt.suptitle('Empirical Correlations — Gaussian Copula', fontsize=13)
plt.tight_layout()
plt.show()
X_check = joint.sample_joint(50000) emp_corr = np.corrcoef(X_check.T) fig, axes = plt.subplots(1, 3, figsize=(14, 4)) pairs = [(3, 4, 'lX vs lY'), (3, 5, 'lX vs L'), (4, 5, 'lY vs L')] for ax, (i, j, title) in zip(axes, pairs): ax.scatter(X_check[:, i], X_check[:, j], alpha=0.15, s=2, color='steelblue') ax.set_title(f'{title}\n' f'ρ = {emp_corr[i,j]:.3f} (target: {corr_pearson[i,j]:.2f})') ax.set_xlabel(labels[i]) ax.set_ylabel(labels[j]) plt.suptitle('Empirical Correlations — Gaussian Copula', fontsize=13) plt.tight_layout() plt.show()
No description has been provided for this image

Cantilever Beam Deflection Model¶

The analytical displacement formula, Eq. (32) from the paper.

In [7]:
Copied!
def cantilever_deflection(x):
    """Maximum vertical tip displacement (1D input)."""
    FX, FY, E, lX, lY, L = x
    if lX <= 0 or lY <= 0 or E <= 0:
        return 1e10  # penalty for unphysical values
    term = np.sqrt((FX / lX**2)**2 + (FY / lY**2)**2)
    return (4 * L**3) / (E * lX * lY) * term


def cantilever_deflection_batch(X):
    """Vectorised deflection for batch evaluation."""
    FX, FY = X[:, 0], X[:, 1]
    E = X[:, 2]
    lX, lY, L = X[:, 3], X[:, 4], X[:, 5]

    # Guard against unphysical values
    mask = (lX > 0) & (lY > 0) & (E > 0)
    D = np.full(len(X), 1e10)
    term = np.sqrt((FX[mask] / lX[mask]**2)**2
                   + (FY[mask] / lY[mask]**2)**2)
    D[mask] = (4 * L[mask]**3) / (E[mask] * lX[mask] * lY[mask]) * term
    return D


t = 0.066  # failure threshold (m)
print(f"Failure threshold: D > {t} m")
def cantilever_deflection(x): """Maximum vertical tip displacement (1D input).""" FX, FY, E, lX, lY, L = x if lX <= 0 or lY <= 0 or E <= 0: return 1e10 # penalty for unphysical values term = np.sqrt((FX / lX**2)**2 + (FY / lY**2)**2) return (4 * L**3) / (E * lX * lY) * term def cantilever_deflection_batch(X): """Vectorised deflection for batch evaluation.""" FX, FY = X[:, 0], X[:, 1] E = X[:, 2] lX, lY, L = X[:, 3], X[:, 4], X[:, 5] # Guard against unphysical values mask = (lX > 0) & (lY > 0) & (E > 0) D = np.full(len(X), 1e10) term = np.sqrt((FX[mask] / lX[mask]**2)**2 + (FY[mask] / lY[mask]**2)**2) D[mask] = (4 * L[mask]**3) / (E[mask] * lX[mask] * lY[mask]) * term return D t = 0.066 # failure threshold (m) print(f"Failure threshold: D > {t} m")
Failure threshold: D > 0.066 m
In [8]:
Copied!
# Quick histogram of deflection values
D_sample = cantilever_deflection_batch(X_check[:20000])
D_sample = D_sample[D_sample < 1e9]  # remove penalty values

fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(D_sample, bins=80, density=True, color='steelblue', alpha=0.85)
ax.axvline(t, color='crimson', linestyle='--', linewidth=2, label=f'Threshold t = {t} m')
ax.set_xlabel('Deflection D (m)')
ax.set_ylabel('Density')
ax.set_title('Cantilever Beam Tip Deflection Distribution')
ax.legend()
pf_est = np.mean(D_sample > t)
print(f"Estimated failure probability: {pf_est:.4f}")
plt.tight_layout()
plt.show()
# Quick histogram of deflection values D_sample = cantilever_deflection_batch(X_check[:20000]) D_sample = D_sample[D_sample < 1e9] # remove penalty values fig, ax = plt.subplots(figsize=(8, 4)) ax.hist(D_sample, bins=80, density=True, color='steelblue', alpha=0.85) ax.axvline(t, color='crimson', linestyle='--', linewidth=2, label=f'Threshold t = {t} m') ax.set_xlabel('Deflection D (m)') ax.set_ylabel('Density') ax.set_title('Cantilever Beam Tip Deflection Distribution') ax.legend() pf_est = np.mean(D_sample > t) print(f"Estimated failure probability: {pf_est:.4f}") plt.tight_layout() plt.show()
Estimated failure probability: 0.0150
No description has been provided for this image

Variance-Based Shapley Effects (on Deflection $D$)¶

First, we compute standard (variance-based) Shapley effects on the continuous deflection $D$. This tells us which inputs drive the magnitude of the deflection.

The exhaustive method is used with $N = 10{,}000$ samples and $B = 200$ bootstrap replications.

In [9]:
Copied!
eff, sh, var, lower, upper = shapley_effects(
    cantilever_deflection,
    joint,
    N=10000,
    method='exhaustive',
    B=200,
    alpha=0.05,
    predict_batch=cantilever_deflection_batch,
    random_state=42,
    progress=False,
)

results = pd.DataFrame({
    'Variable': labels,
    'Effect': eff,
    'Lower': lower,
    'Upper': upper,
})
results
eff, sh, var, lower, upper = shapley_effects( cantilever_deflection, joint, N=10000, method='exhaustive', B=200, alpha=0.05, predict_batch=cantilever_deflection_batch, random_state=42, progress=False, ) results = pd.DataFrame({ 'Variable': labels, 'Effect': eff, 'Lower': lower, 'Upper': upper, }) results
Out[9]:
Variable Effect Lower Upper
0 FX 0.108895 0.100801 0.119743
1 FY -0.007343 -0.017977 0.005157
2 E 0.075724 0.066379 0.084395
3 lX 0.205135 0.196353 0.214096
4 lY 0.363718 0.350515 0.374136
5 L 0.253870 0.243635 0.263433

Target Shapley Effects (on Failure Indicator $\mathbf{1}_{D>t}$)¶

The paper computes target (reliability-oriented) Shapley effects on the binary failure indicator $\mathbf{1}_{D>t}$. This answers: which inputs are most responsible for pushing the beam into failure?

We define a binary model function and run the same MC Shapley estimation on it.

In [10]:
Copied!
def failure_indicator(x):
    """Binary failure: 1 if deflection exceeds threshold, else 0."""
    D = cantilever_deflection(x)
    return 1.0 if D > t else 0.0


def failure_indicator_batch(X):
    """Vectorised failure indicator."""
    D = cantilever_deflection_batch(X)
    return (D > t).astype(float)


# Estimate failure probability from a large sample
X_pf = joint.sample_joint(200000)
pf_est = failure_indicator_batch(X_pf).mean()
print(f"Estimated failure probability: {pf_est:.4f}  (paper: ~0.015)")
print(f"Expected failures in N=10000: {10000 * pf_est:.0f}")
def failure_indicator(x): """Binary failure: 1 if deflection exceeds threshold, else 0.""" D = cantilever_deflection(x) return 1.0 if D > t else 0.0 def failure_indicator_batch(X): """Vectorised failure indicator.""" D = cantilever_deflection_batch(X) return (D > t).astype(float) # Estimate failure probability from a large sample X_pf = joint.sample_joint(200000) pf_est = failure_indicator_batch(X_pf).mean() print(f"Estimated failure probability: {pf_est:.4f} (paper: ~0.015)") print(f"Expected failures in N=10000: {10000 * pf_est:.0f}")
Estimated failure probability: 0.0153  (paper: ~0.015)
Expected failures in N=10000: 153
In [11]:
Copied!
# Target Shapley effects on the binary failure indicator
# Note: with pf ~ 1-7%, enough failure samples exist for reasonable estimation
eff_t, sh_t, var_t, lower_t, upper_t = shapley_effects(
    failure_indicator,
    joint,
    N=100000,
    method='exhaustive',
    B=200,
    alpha=0.05,
    predict_batch=failure_indicator_batch,
    random_state=42,
    progress=False,
)

results_target = pd.DataFrame({
    'Variable': labels,
    'Target Shapley': eff_t,
    'Lower': lower_t,
    'Upper': upper_t,
})
results_target
# Target Shapley effects on the binary failure indicator # Note: with pf ~ 1-7%, enough failure samples exist for reasonable estimation eff_t, sh_t, var_t, lower_t, upper_t = shapley_effects( failure_indicator, joint, N=100000, method='exhaustive', B=200, alpha=0.05, predict_batch=failure_indicator_batch, random_state=42, progress=False, ) results_target = pd.DataFrame({ 'Variable': labels, 'Target Shapley': eff_t, 'Lower': lower_t, 'Upper': upper_t, }) results_target
Out[11]:
Variable Target Shapley Lower Upper
0 FX 0.150356 0.142793 0.160588
1 FY 0.006359 -0.005752 0.019313
2 E 0.093255 0.085386 0.101609
3 lX 0.281758 0.272918 0.290232
4 lY 0.261647 0.251639 0.270475
5 L 0.206626 0.198368 0.214996

RS-HDMR Surrogate Model¶

To demonstrate the surrogate-based workflow, we train an RS-HDMR model on uniformly sampled data covering the practical range of each input variable (approximately $\mu \pm 5\sigma$). The surrogate is then used as the model function for MC Shapley estimation.

This tests whether a surrogate trained on independent uniform samples can recover accurate Shapley effects under the correlated mixed-distribution deployment.

In [12]:
Copied!
# Generate uniform training data across practical input ranges
N_train = 2048

# Ranges covering ~mean +/- 5 sigma for each variable
ranges = {
    'FX': (350, 850),          # LogNormal(556.8, CV=0.08)
    'FY': (280, 700),          # LogNormal(453.6, CV=0.08)
    'E':  (1.4e11, 2.8e11),   # LogNormal(2e11, CV=0.06)
    'lX': (0.025, 0.100),      # Normal(0.062, CV=0.1)
    'lY': (0.040, 0.160),      # Normal(0.0987, CV=0.1)
    'L':  (2.0, 7.0),          # Normal(4.29, CV=0.1)
}

# Draw shifted and scrambled Sobol samples
sampler = Sobol(d, scramble=True, seed=123)
samples = sampler.random(N_train)
X_unif = np.empty((N_train, d))
for j, name in enumerate(labels):
    lo, hi = ranges[name]
    X_unif[:, j] = lo + (hi - lo) * samples[:, j]
Y_train = cantilever_deflection_batch(X_unif)

# Build DataFrame for RS-HDMR
df_train = pd.DataFrame(X_unif, columns=labels)
df_train['Y'] = Y_train

print(f"{len(df_train)} training samples")
print(f"Y range: [{Y_train.min():.4f}, {Y_train.max():.4f}] m")
print(f"Failure fraction (D > {t}): {(Y_train > t).mean():.3f}")
# Generate uniform training data across practical input ranges N_train = 2048 # Ranges covering ~mean +/- 5 sigma for each variable ranges = { 'FX': (350, 850), # LogNormal(556.8, CV=0.08) 'FY': (280, 700), # LogNormal(453.6, CV=0.08) 'E': (1.4e11, 2.8e11), # LogNormal(2e11, CV=0.06) 'lX': (0.025, 0.100), # Normal(0.062, CV=0.1) 'lY': (0.040, 0.160), # Normal(0.0987, CV=0.1) 'L': (2.0, 7.0), # Normal(4.29, CV=0.1) } # Draw shifted and scrambled Sobol samples sampler = Sobol(d, scramble=True, seed=123) samples = sampler.random(N_train) X_unif = np.empty((N_train, d)) for j, name in enumerate(labels): lo, hi = ranges[name] X_unif[:, j] = lo + (hi - lo) * samples[:, j] Y_train = cantilever_deflection_batch(X_unif) # Build DataFrame for RS-HDMR df_train = pd.DataFrame(X_unif, columns=labels) df_train['Y'] = Y_train print(f"{len(df_train)} training samples") print(f"Y range: [{Y_train.min():.4f}, {Y_train.max():.4f}] m") print(f"Failure fraction (D > {t}): {(Y_train > t).mean():.3f}")
2048 training samples
Y range: [0.0008, 4.4623] m
Failure fraction (D > 0.066): 0.458
In [13]:
Copied!
from shapleyx import rshdmr

# Fit RS-HDMR surrogate
model = rshdmr(
    df_train,
    polys=[10],         # PC type expansion up to order 10
    n_iter=300,
    method='ard_cv',
    cv_method='bayesian',
    cv_tol=0.005,
)

sob_surr, shap_surr, total_surr = model.run_all()
from shapleyx import rshdmr # Fit RS-HDMR surrogate model = rshdmr( df_train, polys=[10], # PC type expansion up to order 10 n_iter=300, method='ard_cv', cv_method='bayesian', cv_tol=0.005, ) sob_surr, shap_surr, total_surr = model.run_all()
Found a DataFrame

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

Feature: FX, Min Value: 350.0288, Max Value: 849.8679
Feature: FY, Min Value: 280.0073, Max Value: 699.9427
Feature: E, Min Value: 140060557518.1544, Max Value: 279998504221.4393
Feature: lX, Min Value: 0.0250, Max Value: 0.1000
Feature: lY, Min Value: 0.0400, Max Value: 0.1600
Feature: L, Min Value: 2.0019, Max Value: 6.9994

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

Total basis functions in basis set : 8007
Total number of features in basis set is 8007

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

running ARD
Fit Execution Time : 168.633618
--
 
 Model complete 
 

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

variance of data        : 0.137
sum of coefficients^2   : 0.137
variance ratio          : 1.000
===============================
mae error on test set   : 0.003
mse error on test set   : 0.000
explained variance score: 1.000
===============================
slope     :  0.9998486027987897
r value   :  0.9999366193215327
r^2       :  0.9998732426601757
p value   :  0.0
std error :  0.00024888316116948144

No description has been provided for this image
============================================================
Running bootstrap resampling 1000 samples for 95.0% CI
============================================================

 |████████████████████████████████████████████████████████████████████████████████████████████████████| 100.0% 

============================================================
Completed bootstrap resampling
============================================================


============================================================
                  Completed all analysis
                 ------------------------

It is in your moments of decision that your destiny is
shaped.  Tony Robbins
============================================================

In [14]:
Copied!
# MC Shapley using the surrogate model with the correlated distribution
mc_surrogate = model.get_mc_shapley(
    joint=joint,
    N=10000,
    method='exhaustive',
    B=200,
    alpha=0.05,
    random_state=42,
    progress=False,
    k_max=None
)
mc_surrogate
# MC Shapley using the surrogate model with the correlated distribution mc_surrogate = model.get_mc_shapley( joint=joint, N=10000, method='exhaustive', B=200, alpha=0.05, random_state=42, progress=False, k_max=None ) mc_surrogate
Out[14]:
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 FX 0.110962 8.878575e-06 0.112668 0.107799 0.00008 0.101907 0.122367 0.093275 0.132230 0.049837 0.156637
1 FY -0.006347 -5.078927e-07 -0.000076 -0.049639 0.00008 -0.017907 0.006894 -0.019465 0.021472 -0.120040 0.010283
2 E 0.082529 6.603539e-06 0.072393 0.092801 0.00008 0.072452 0.091342 0.050069 0.095996 0.038219 0.142334
3 lX 0.211365 1.691226e-05 0.207516 0.131404 0.00008 0.201356 0.221009 0.176207 0.243587 0.074961 0.179069
4 lY 0.359677 2.877946e-05 0.617119 0.028502 0.00008 0.346509 0.370035 0.577686 0.661258 -0.044233 0.086221
5 L 0.241815 1.934874e-05 0.242739 0.158394 0.00008 0.230777 0.251768 0.217016 0.264077 0.105132 0.215704

Target Shapley via RS-HDMR Surrogate¶

The RS-HDMR surrogate can also estimate target (reliability-oriented) Shapley effects on the binary failure indicator $\mathbf{1}_{D>t}$. Instead of the analytical deflection formula, we use model.predict() to evaluate the surrogate at each Monte Carlo sample and threshold the predicted deflection at $t = 0.066$ m.

This tests whether the surrogate can capture not just the bulk sensitivity structure (variance-based Shapley on $D$) but also the tail behaviour relevant to reliability analysis — a much more stringent test of surrogate fidelity.

We use the same $N = 100{,}000$ samples and $B = 200$ bootstrap replications as the analytical target Shapley estimation for a fair comparison.

In [15]:
Copied!
# --- Surrogate-based failure indicator ---
def failure_indicator_surrogate(x):
    """Binary failure via RS-HDMR surrogate model."""
    D = model.predict(x)
    return 1.0 if D > t else 0.0


def failure_indicator_surrogate_batch(X):
    """Vectorised failure indicator via surrogate."""
    D = model.predict(X)
    return (D > t).astype(float)
# --- Surrogate-based failure indicator --- def failure_indicator_surrogate(x): """Binary failure via RS-HDMR surrogate model.""" D = model.predict(x) return 1.0 if D > t else 0.0 def failure_indicator_surrogate_batch(X): """Vectorised failure indicator via surrogate.""" D = model.predict(X) return (D > t).astype(float)
In [16]:
Copied!
# Target Shapley effects via the RS-HDMR surrogate model
# Using the same N and B as the analytical target Shapley for fair comparison
eff_t_s, sh_t_s, var_t_s, lower_t_s, upper_t_s = shapley_effects(
    failure_indicator_surrogate,
    joint,
    N=100000,
    method='exhaustive',
    B=200,
    alpha=0.05,
    predict_batch=failure_indicator_surrogate_batch,
    random_state=42,
    progress=True,
)

results_target_surrogate = pd.DataFrame({
    'Variable': labels,
    'Target Shapley': eff_t_s,
    'Lower': lower_t_s,
    'Upper': upper_t_s,
})
results_target_surrogate
# Target Shapley effects via the RS-HDMR surrogate model # Using the same N and B as the analytical target Shapley for fair comparison eff_t_s, sh_t_s, var_t_s, lower_t_s, upper_t_s = shapley_effects( failure_indicator_surrogate, joint, N=100000, method='exhaustive', B=200, alpha=0.05, predict_batch=failure_indicator_surrogate_batch, random_state=42, progress=True, ) results_target_surrogate = pd.DataFrame({ 'Variable': labels, 'Target Shapley': eff_t_s, 'Lower': lower_t_s, 'Upper': upper_t_s, }) results_target_surrogate
MC Shapley: 100%|██████████| 12500000/12500000 [03:04<00:00, 67683.58evals/s]
Out[16]:
Variable Target Shapley Lower Upper
0 FX 0.143856 0.134221 0.154010
1 FY 0.008878 -0.003017 0.020986
2 E 0.101824 0.091967 0.110492
3 lX 0.274690 0.266192 0.284683
4 lY 0.262888 0.253485 0.272981
5 L 0.207864 0.199338 0.215929

Comparison: All Five Estimates¶

  1. Variance-based Shapley on $D$ — analytical function
  2. Variance-based Shapley on $D$ — RS-HDMR surrogate
  3. Target Shapley on $\mathbf{1}_{D>t}$ — analytical function
  4. Target Shapley on $\mathbf{1}_{D>t}$ — RS-HDMR surrogate
  5. Target Shapley reference — Demange-Chryst et al. (2022) Table 2
In [17]:
Copied!
# Reference target Shapley effects from the paper (Table 2)
ref_target_shap = np.array([0.146, 0.001, 0.103, 0.282, 0.254, 0.214])

comparison = pd.DataFrame({
    'Variable': labels,
    'Var.-based (D, analytical)': eff.round(4),
    'Var.-based (D, surrogate)': mc_surrogate['effect'].values.round(4),
    'Target (1_{D>t}, analytical)': eff_t.round(4),
    'Target (surr.)': eff_t_s.round(4),
    'Paper target': ref_target_shap.round(3),
})
comparison
# Reference target Shapley effects from the paper (Table 2) ref_target_shap = np.array([0.146, 0.001, 0.103, 0.282, 0.254, 0.214]) comparison = pd.DataFrame({ 'Variable': labels, 'Var.-based (D, analytical)': eff.round(4), 'Var.-based (D, surrogate)': mc_surrogate['effect'].values.round(4), 'Target (1_{D>t}, analytical)': eff_t.round(4), 'Target (surr.)': eff_t_s.round(4), 'Paper target': ref_target_shap.round(3), }) comparison
Out[17]:
Variable Var.-based (D, analytical) Var.-based (D, surrogate) Target (1_{D>t}, analytical) Target (surr.) Paper target
0 FX 0.1089 0.1110 0.1504 0.1439 0.146
1 FY -0.0073 -0.0063 0.0064 0.0089 0.001
2 E 0.0757 0.0825 0.0933 0.1018 0.103
3 lX 0.2051 0.2114 0.2818 0.2747 0.282
4 lY 0.3637 0.3597 0.2616 0.2629 0.254
5 L 0.2539 0.2418 0.2066 0.2079 0.214
In [18]:
Copied!
fig, ax = plt.subplots(figsize=(14, 7))
bar_width = 0.18
x = np.arange(d)

ax.bar(x - 2.0*bar_width, eff, bar_width,
       yerr=[eff - lower, upper - eff],
       capsize=3, color='steelblue', alpha=0.85,
       label='Var.-based (D, analytical)')
ax.bar(x - 1.0*bar_width, mc_surrogate['effect'].values, bar_width,
       yerr=[mc_surrogate['effect'].values - mc_surrogate['lower'].values,
             mc_surrogate['upper'].values - mc_surrogate['effect'].values],
       capsize=3, color='seagreen', alpha=0.85,
       label='Var.-based (D, surrogate)')
ax.bar(x + 0.0*bar_width, eff_t, bar_width,
       yerr=[eff_t - lower_t, upper_t - eff_t],
       capsize=3, color='darkorange', alpha=0.85,
       label='Target (1_{D>t}, analytical)')
ax.bar(x + 1.0*bar_width, eff_t_s, bar_width,
       yerr=[eff_t_s - lower_t_s, upper_t_s - eff_t_s],
       capsize=3, color='mediumseagreen', alpha=0.85,
       label='Target (1_{D>t}, surrogate)')
ax.bar(x + 2.0*bar_width, ref_target_shap, bar_width,
       color='crimson', alpha=0.85,
       label='Target — paper reference')

ax.set_xlabel('Input Variable')
ax.set_ylabel('Shapley Effect')
ax.set_title('Cantilever Beam Shapley Effects\nAnalytical vs RS-HDMR Surrogate vs Paper Reference')
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.legend(fontsize=9)
ax.set_ylim(0, None)
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(14, 7)) bar_width = 0.18 x = np.arange(d) ax.bar(x - 2.0*bar_width, eff, bar_width, yerr=[eff - lower, upper - eff], capsize=3, color='steelblue', alpha=0.85, label='Var.-based (D, analytical)') ax.bar(x - 1.0*bar_width, mc_surrogate['effect'].values, bar_width, yerr=[mc_surrogate['effect'].values - mc_surrogate['lower'].values, mc_surrogate['upper'].values - mc_surrogate['effect'].values], capsize=3, color='seagreen', alpha=0.85, label='Var.-based (D, surrogate)') ax.bar(x + 0.0*bar_width, eff_t, bar_width, yerr=[eff_t - lower_t, upper_t - eff_t], capsize=3, color='darkorange', alpha=0.85, label='Target (1_{D>t}, analytical)') ax.bar(x + 1.0*bar_width, eff_t_s, bar_width, yerr=[eff_t_s - lower_t_s, upper_t_s - eff_t_s], capsize=3, color='mediumseagreen', alpha=0.85, label='Target (1_{D>t}, surrogate)') ax.bar(x + 2.0*bar_width, ref_target_shap, bar_width, color='crimson', alpha=0.85, label='Target — paper reference') ax.set_xlabel('Input Variable') ax.set_ylabel('Shapley Effect') ax.set_title('Cantilever Beam Shapley Effects\nAnalytical vs RS-HDMR Surrogate vs Paper Reference') ax.set_xticks(x) ax.set_xticklabels(labels) ax.legend(fontsize=9) ax.set_ylim(0, None) plt.tight_layout() plt.show()
No description has been provided for this image

Key Takeaways¶

  1. Gaussian copula with mixed marginals enables Shapley effect estimation with correlated LogNormal + Normal inputs — the GaussianCopulaMixed wrapper handles the transformation between original and latent normal space transparently.
  2. RS-HDMR surrogate faithfully reproduces both variance-based and target Shapley effects — trained on 2,048 uniform Sobol' samples, the surrogate recovers effects nearly identical to the analytical function, despite the distribution shift from independent uniform training to correlated mixed-distribution deployment.
  3. Target Shapley effects are accurately captured — the surrogate target Shapley estimates (FX=0.144, lX=0.275, lY=0.263, L=0.208) are within ±0.01 of the analytical values and match the Demange-Chryst et al. (2022) reference.
  4. Dimensional parameters dominate — $l_X$, $l_Y$, and $L$ are the most influential inputs in all five analyses.
  5. Correlations match the reference — the notebook uses the same Pearson correlations (ρ=-0.55, 0.45) and marginal parameters (mean, CV) as Demange-Chryst et al. (2022).
In [19]:
Copied!
%load_ext watermark
%watermark -n -u -v -iv -w
%load_ext watermark %watermark -n -u -v -iv -w
Last updated: Mon, 04 May 2026

Python implementation: CPython
Python version       : 3.12.12
IPython version      : 9.13.0

matplotlib: 3.10.8
numpy     : 2.3.5
pandas    : 2.3.3
scipy     : 1.17.1
shapleyx  : 0.5.1

Watermark: 2.6.0

Previous Next

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