In my last post, I talked about producing these flow-duration-curve-type figures for an output time-series one might be interested in, and talked about their potential use in an exploratory approach for the purpose of robust decision making. Again, the codes to perform the analysis and visualization are in this Github repository.
As already discussed, there are multiple benefits for visualizing the output in such manner: we are often concerned with the levels and frequencies of extremes when making decisions about systems (e.g. “how bad is the worst case?”, “how rare is the worst case?”), or we might like to know how often we exceed a certain threshold (e.g. “how many years exceed an annual shortage of 1000 af?“). The various percentiles tell a different part of the story of how a system operates, the 5th percentile tells as that its level is exceeded 95% of the time, the 99th tells as that its level is only reached once in every 100 years in our records. These might seem obvious to the readers of this blog, but often times we perform our analyses for only some of these percentiles, “the worst event”, “the average”, etc., which is certainly very informative, but can potentially miss part of the bigger picture.
In this post I’m going to walk the reader through performing a sensitivity analysis using the output of an experiment using multiple Latin Hypercube Samples. The analysis will be magnitude-varying, i.e., it will be performed at different magnitudes of our output of interest. For this particular example, we aim to see what are the most significant drivers of shortage at the different levels it’s experienced by this user. In other words, if some factors appear to be driving the frequent small shortages experienced, are those factors the same for the rare large shortages?
To perform the sensitivity analysis, I am going to use SALib (featured in this blog multiple times already), to perform a Delta Moment-Independent Analysis [1] (also produces a first order Sobol sensitivity index [2]). You’ll probably need to install SALib if it’s not a package you’ve used already. I’m also going to use statsmodels, to perform a simple linear regression on the outputs and look at their R2 values. But, why, you might ask, perform not one, not two, but three sensitivity analyses for this? There are nuanced, yet potentially important differences between what the three methods capture:
Delta method: Look for parameters most significantly affecting the density function of observed shortages. This method is moment-independent, i.e., it looks at differences in the entire distribution of the output we’re interested in.
First order Sobol (S1): Look for parameters that most significantly affect the variance of observed outputs, including non-linear effects.
R2: Look for parameters best able to describe the variance of observed outputs, limited to linear effects.
Another important thing to note is that using the First order Sobol index, the total variance resulting from the parameters should equal 1. This means that if we sum up the S1’s we get from our analysis, the sum represents the variance described by the first order effects of our parameters, leaving whatever is left to interactions between our variables (that S1 cannot capture). The same holds using R2, as we are repeatedly fitting our parameters and scoring them on how much of the output variance they describe as a sole linear predictor (with no interactions or other relationships).
The following Python script will produce all three as well as confidence intervals for the Delta index and S1. The script essentially loops through all percentiles in the time-series and performs the two analyses for each one. In other words, we’re are looking at how sensitive each magnitude percentile is to each of the sampled parameters.
import numpy as np | |
import pandas as pd | |
import statsmodels.api as sm | |
from SALib.analyze import delta | |
# Load parameter samples | |
LHsamples = np.loadtxt('./LHsamples.txt') | |
params_no = len(LHsamples[0,:]) | |
param_bounds=np.loadtxt('./uncertain_params.txt', usecols=(1,2)) | |
# Parameter names | |
param_names=['IWRmultiplier','RESloss','TBDmultiplier','M_Imultiplier', | |
'Shoshone','ENVflows','EVAdelta','XBM_mu0','XBM_sigma0', | |
'XBM_mu1','XBM_sigma1','XBM_p00','XBM_p11'] | |
# Define problem class | |
problem = { | |
'num_vars': params_no, | |
'names': param_names, | |
'bounds': param_bounds.tolist() | |
} | |
# Percentiles for analysis to loop over | |
percentiles = np.arange(0,100) | |
# Function to fit regression with Ordinary Least Squares using statsmodels | |
def fitOLS(dta, predictors): | |
# concatenate intercept column of 1s | |
dta['Intercept'] = np.ones(np.shape(dta)[0]) | |
# get columns of predictors | |
cols = dta.columns.tolist()[-1:] + predictors | |
#fit OLS regression | |
ols = sm.OLS(dta['Shortage'], dta[cols]) | |
result = ols.fit() | |
return result | |
# Create empty dataframes to store results | |
DELTA = pd.DataFrame(np.zeros((params_no, len(percentiles))), columns = percentiles) | |
DELTA_conf = pd.DataFrame(np.zeros((params_no, len(percentiles))), columns = percentiles) | |
S1 = pd.DataFrame(np.zeros((params_no, len(percentiles))), columns = percentiles) | |
S1_conf = pd.DataFrame(np.zeros((params_no, len(percentiles))), columns = percentiles) | |
R2_scores = pd.DataFrame(np.zeros((params_no, len(percentiles))), columns = percentiles) | |
DELTA.index=DELTA_conf.index=S1.index=S1_conf.index = R2_scores.index = param_names | |
# Read in experiment data | |
expData = np.loadtxt('./experiment_data.txt') | |
# Identify magnitude at each percentiles | |
syn_magnitude = np.zeros([len(percentiles),len(LHsamples[:,0])]) | |
for j in range(len(LHsamples[:,0])): | |
syn_magnitude[:,j]=[np.percentile(expData[:,j], i) for i in percentiles] | |
# Delta Method analysis | |
for i in range(len(percentiles)): | |
if syn_magnitude[i,:].any(): | |
try: | |
result= delta.analyze(problem, LHsamples, syn_magnitude[i,:], print_to_console=False, num_resamples=2) | |
DELTA[percentiles[i]]= result['delta'] | |
DELTA_conf[percentiles[i]] = result['delta_conf'] | |
S1[percentiles[i]]=result['S1'] | |
S1_conf[percentiles[i]]=result['S1_conf'] | |
except: | |
pass | |
S1.to_csv('./S1_scores.csv') | |
S1_conf.to_csv('./S1_conf_scores.csv') | |
DELTA.to_csv('./DELTA_scores.csv') | |
DELTA_conf.to_csv('./DELTA_conf_scores.csv') | |
# OLS regression analysis | |
dta = pd.DataFrame(data = LHsamples, columns=param_names) | |
# fig = plt.figure() | |
for i in range(len(percentiles)): | |
shortage = np.zeros(len(LHsamples[:,0])) | |
for k in range(len(LHsamples[:,0])): | |
shortage[k]=syn_magnitude[i,k] | |
dta['Shortage']=shortage | |
for m in range(params_no): | |
predictors = dta.columns.tolist()[m:(m+1)] | |
result = fitOLS(dta, predictors) | |
R2_scores.at[param_names[m],percentiles[i]]=result.rsquared | |
R2_scores.to_csv('./R2_scores.csv') |
The script produces the sensitivity analysis indices for each magnitude percentile and stores them as .csv files.
I will now present a way of visualizing these outputs, using the curves from Fig. 1 as context. The code below reads in the values for each sensitivity index, normalizes them to the range of magnitude at each percentile, and then plots them using matplotlib’s stackplot fuction, which stacks the contribution of each parameter to the sum (in this case the maximum of the resulting range)
I’ll go through what the code does in more detail:
First, we take the range boundaries (globalmax and globalmin) which give us the max and min values for each percentile. We then read in the values for each sensitivity index and normalize them to that range (i.e. globalmax–globalmin for each percentile). The script also adds two more arrays (rows in the pandas dataframe), one representing interaction and one representing the globalmin, upon which we’re going to stack the rest of the values. [Note: This is a bit of a roundabout way of getting the figures how we like them, but it’s essentially creating a pseudo-stack for the globalmin, that we’re plotting in white.]
The interaction array is only used when normalizing the S1 and R2 values, where we attribute to it the difference between 1 and the sum of the calculated indices (i.e. we’re attributing the rest to interaction between the parameters). We don’t need to do this for the delta method indices (if you run the code the array remains empty), but the reason I had to put it there was to make it simpler to create labels and a single legend later.
The plotting simply creates three subplots and for each one uses stackplot to plot the normalized values and then the edges in black. It is important to note that the colorblocks in each figure do not represent the volume of shortage attributed to each parameter at each percentile, but rather the contribution of each parameter to the change in the metric, namely, the density distribution (Delta Method), and the variance (S1 and R2). The code for this visualization is provided at the bottom of the post.
The first thing that pops out from this figure is the large blob of peach, which represents the irrigation demand multiplier in our experiment. The user of interest here was an irrigation user, which would suggest that their shortages are primarily driven by increases in their own demands and of other irrigation users. This is important, because irrigation demand is an uncertainty for which we could potentially have direct or indirect control over, e.g. through conservation efforts.
Looking at the other factors, performing the analysis in a magnitude-varying manner, allowed us to explore the vulnerabilities of this metric across its different levels. For example, dark blue and dark green represent the mean flow of dry and wet years, respectively. Across the three figures we can see that the contribution of mean wet-year flow is larger in the low-magnitude percentiles (left hand side) and diminishes as we move towards the larger-magnitude percentiles.
Another thing that I thought was interesting to note was the difference between the S1 and the R2 plots. They are both variance-based metrics, with R2 limited to linear effects in this case. In this particular case, the plots are fairly similar which would suggest that a lot of the parameter effects on the output variance are linear. Larger differences between the two would point to non-linearities between changes in parameter values and the output.
The code to produce Fig. 2:
# Percentiles for analysis to loop over | |
percentiles = np.arange(0,100) | |
# Estimate upper and lower bounds | |
globalmax = [np.percentile(np.max(expData_sort[:,:],1),p) for p in percentiles] | |
globalmin = [np.percentile(np.min(expData_sort[:,:],1),p) for p in percentiles] | |
delta_values = pd.read_csv('./DELTA_scores.csv') | |
delta_values.set_index(list(delta_values)[0],inplace=True) | |
delta_values = delta_values.clip(lower=0) | |
bottom_row = pd.DataFrame(data=np.array([np.zeros(100)]), index= ['Interaction'], columns=list(delta_values.columns.values)) | |
top_row = pd.DataFrame(data=np.array([globalmin]), index= ['Min'], columns=list(delta_values.columns.values)) | |
delta_values = pd.concat([top_row,delta_values.loc[:],bottom_row]) | |
for p in range(len(percentiles)): | |
total = np.sum(delta_values[str(percentiles[p])])-delta_values.at['Min',str(percentiles[p])] | |
if total!=0: | |
for param in param_names: | |
value = (globalmax[p]-globalmin[p])*delta_values.at[param,str(percentiles[p])]/total | |
delta_values.set_value(param,str(percentiles[p]),value) | |
delta_values = delta_values.round(decimals = 2) | |
delta_values_to_plot = delta_values.values.tolist() | |
S1_values = pd.read_csv('./S1_scores.csv') | |
S1_values.set_index(list(S1_values)[0],inplace=True) | |
S1_values = S1_values.clip(lower=0) | |
bottom_row = pd.DataFrame(data=np.array([np.zeros(100)]), index= ['Interaction'], columns=list(S1_values.columns.values)) | |
top_row = pd.DataFrame(data=np.array([globalmin]), index= ['Min'], columns=list(S1_values.columns.values)) | |
S1_values = pd.concat([top_row,S1_values.loc[:],bottom_row]) | |
for p in range(len(percentiles)): | |
total = np.sum(S1_values[str(percentiles[p])])-S1_values.at['Min',str(percentiles[p])] | |
if total!=0: | |
diff = 1-total | |
S1_values.set_value('Interaction',str(percentiles[p]),diff) | |
for param in param_names+['Interaction']: | |
value = (globalmax[p]-globalmin[p])*S1_values.at[param,str(percentiles[p])] | |
S1_values.set_value(param,str(percentiles[p]),value) | |
S1_values = S1_values.round(decimals = 2) | |
S1_values_to_plot = S1_values.values.tolist() | |
R2_values = pd.read_csv('./R2_scores.csv') | |
R2_values.set_index(list(R2_values)[0],inplace=True) | |
R2_values = R2_values.clip(lower=0) | |
bottom_row = pd.DataFrame(data=np.array([np.zeros(100)]), index= ['Interaction'], columns=list(R2_values.columns.values)) | |
top_row = pd.DataFrame(data=np.array([globalmin]), index= ['Min'], columns=list(R2_values.columns.values)) | |
R2_values = pd.concat([top_row,R2_values.loc[:],bottom_row]) | |
for p in range(len(percentiles)): | |
total = np.sum(R2_values[str(percentiles[p])])-R2_values.at['Min',str(percentiles[p])] | |
if total!=0: | |
diff = 1-total | |
R2_values.set_value('Interaction',str(percentiles[p]),diff) | |
for param in param_names+['Interaction']: | |
value = (globalmax[p]-globalmin[p])*R2_values.at[param,str(percentiles[p])] | |
R2_values.set_value(param,str(percentiles[p]),value) | |
R2_values = R2_values.round(decimals = 2) | |
R2_values_to_plot = R2_values.values.tolist() | |
color_list = ["white", "#F18670", "#E24D3F", "#CF233E", "#681E33", "#676572", "#F3BE22", "#59DEBA", "#14015C", "#DAF8A3", "#0B7A0A", "#F8FFA2", "#578DC0", "#4E4AD8", "#F77632"] | |
fig, (ax1, ax2, ax3) = plt.subplots(1,3, figsize=(14.5,8)) | |
ax1.stackplot(percentiles, delta_values_to_plot, colors = color_list, labels=parameter_names_long) | |
l1 = ax1.plot(percentiles, globalmax, color='black', linewidth=2) | |
l2 = ax1.plot(percentiles, globalmin, color='black', linewidth=2) | |
ax1.set_title("Delta index") | |
ax1.set_xlim(0,100) | |
ax2.stackplot(np.arange(0,100), S1_values_to_plot, colors = color_list, labels=parameter_names_long) | |
ax2.plot(percentiles, globalmax, color='black', linewidth=2) | |
ax2.plot(percentiles, globalmin, color='black', linewidth=2) | |
ax2.set_title("S1") | |
ax2.set_xlim(0,100) | |
ax3.stackplot(np.arange(0,100), R2_values_to_plot, colors = color_list, labels=parameter_names_long) | |
ax3.plot(percentiles, globalmax, color='black', linewidth=2) | |
ax3.plot(percentiles, globalmin, color='black', linewidth=2) | |
ax3.set_title("R^2") | |
ax3.set_xlim(0,100) | |
handles, labels = ax3.get_legend_handles_labels() | |
ax1.set_ylabel('Annual shortage (af)', fontsize=12) | |
ax2.set_xlabel('Shortage magnitude percentile', fontsize=12) | |
ax1.legend((l1), ('Global ensemble',), fontsize=10, loc='upper left') | |
fig.legend(handles[1:], labels[1:], fontsize=10, loc='lower center',ncol = 5) | |
plt.subplots_adjust(bottom=0.2) | |
fig.savefig('./experiment_sensitivity_curves.png') |
References:
[1]: Borgonovo, E. “A New Uncertainty Importance Measure.” Reliability Engineering & System Safety 92, no. 6 (June 1, 2007): 771–84. https://doi.org/10.1016/j.ress.2006.04.015.
[2]: Sobol, I. M. (2001). “Global sensitivity indices for nonlinear mathematical models and their Monte Carlo estimates.” Mathematics and Computers in Simulation, 55(1-3):271-280, doi:10.1016/S0378-4754(00)00270-6.