.. _user_guide.post_processing_external_tools.sensitivity_analysis:
.. For documentation in external tools, it should be made with pre-existing output, and will not be rerun during compilation.
====================
Sensitivity Analysis
====================
In this tutorial, we will show how to perform sensitivity analysis on a
single catchment using `smash` and `SALib `__. We will use the :ref:`user_guide.data_and_format_description.cance` dataset as an example in this tutorial.
First, open a Python interface:
.. code-block:: none
python3
Imports
-------
We will first import the necessary libraries for this tutorial.
.. code-block:: python
>>> import smash
>>> import numpy as np
>>> from SALib.analyze.sobol import analyze
>>> from SALib.sample.sobol import sample
.. note::
The library `SALib `__ is a requirement for this tutorial.
It is not installed by default but can be installed with pip as follows:
.. code-block:: none
pip install SALib
Define the catchment, load the model and data
---------------------------------------------
The setup and the mesh corresponding to the catchment.
.. code-block:: python
>>> setup, mesh = smash.factory.load_dataset("cance")
Create the model object. This also loads the data into the model object.
.. code-block:: python
>>> model = smash.Model(setup, mesh)
.. code-block:: output
> Computing mean atmospheric data
> Adjusting GR interception capacity
Generate the samples
--------------------
A problem of sensitivity analysis is defined by the number of parameters, the names of the
parameters, and the bounds of the parameters. Please refer to the `SALib
documentation `__ for
more examples on how to define a problem.
Sample size is the number of distinct values for each parameter.
.. code-block:: python
>>> problem = {
... "num_vars": 4,
... "names": ["cp", "ct", "kexc", "llr"],
... "bounds": [(1, 3000), (1, 4000), (-25, 5), (1, 200)],
... }
>>>
>>> sample_size = 1024
.. note::
A larger ``sample_size`` improves the accuracy of sensitivity index estimates but also increases computational time.
Generate the samples, which will be used for sensitivity analysis, using the Saltelli sampling method implemented in SALib.
.. code-block:: python
>>> param_values = sample(problem, sample_size, seed=1, calc_second_order=False)
>>> param_values
.. code-block:: output
array([[ 467.24048835, 2355.40055781, -6.77403474, 49.23759794],
[2537.22270641, 2355.40055781, -6.77403474, 49.23759794],
[ 467.24048835, 1050.97977774, -6.77403474, 49.23759794],
...,
[ 465.07810281, 1061.24262134, -8.39497082, 6.94480178],
[ 465.07810281, 1061.24262134, -10.48251348, 141.57036807],
[2627.85471268, 468.67423326, -8.39497082, 141.57036807]])
.. code-block:: python
>>> param_values.shape
.. code-block:: output
(6144, 4)
In this example, we have 6144 sets of 4 parameters. The number of sets varies based on
the sample size, the number of parameters, and whether we want to include second order sensitivity.
Details can be found in the `SALib documentation `__.
Run the model on the chosen samples
-----------------------------------
We define a function ``run_with_params``, that performs a forward run using a set of parameters to compute performance metrics and/or hydrological signatures based on simulated discharge. In this case, we use
NSE - a classical hydrological metric, Crc - continuous runoff
coefficients, and Eff - flood flow as examples.
For more information on the available signatures and indices, please refer
to the :ref:`api_reference.principal_methods.signal_analysis` section.
.. code-block:: python
>>> def run_with_params(model, params):
... model.set_rr_parameters('cp', params[0])
... model.set_rr_parameters('ct', params[1])
... model.set_rr_parameters('kexc', params[2])
... model.set_rr_parameters('llr', params[3])
... model.forward_run()
... signatures = smash.signatures(model, sign=['Crc', 'Eff'], domain='sim')
... crc = signatures.cont.iloc[0]['Crc']
... eff = signatures.event.iloc[0]['Eff']
... nse = smash.evaluation(model, metric='nse')[0][0]
...
... return nse, crc, eff
.. hint::
Using ``common_options={'n_cpu': n}`` (with n based on your system configuration)
in the `smash.Model.forward_run` function will help accelerate the computation.
Run the function for all the samples using a simple ``for`` loop.
.. code-block:: python
>>> output = []
>>> for i in range(param_values.shape[0]):
... output.append(np.array(run_with_params(model, param_values[i])))
.. hint::
Each iteration calls the ``run_with_params`` function, which calls the `smash.Model.forward_run` function.
Each ``forward_run`` prints a line of text, which is a lot of redundant text considering the number of iterations.
You can suppress these outputs by redirecting them to a ``StringIO`` object. For example:
.. code-block:: python
>>> from contextlib import redirect_stdout
>>> import io
>>>
>>> def run_with_params(model, params):
... # Redirect stdout to a null stream
... with redirect_stdout(io.StringIO()):
... # Set the parameters
... print("This won't be displayed")
... model.forward_run() # The output text in this function also won't be displayed
... # The rest of the function
However, this trick is beyond the scope of this tutorial, so it is just a tip, not a requirement.
Take out the 3 outputs array from the list.
.. code-block:: python
>>> output = np.array(output)
>>>
>>> Y_nse = np.array(output[:, 0])
>>> Y_crc = np.array(output[:, 1])
>>> Y_eff = np.array(output[:, 2])
Normalize the NSE. The normalized NSE is calculated as:
.. math::
\text{NNSE} = \frac{1}{2 - \text{NSE}}
This normalized NSE maps the NSE metric from :math:`[-\infty, 1]` to :math:`[0, 1]`
in a manner that preserves valuable information on effective forward runs
while reducing the influence of ineffective runs on the sensitivity analysis.
This is why we utilize the normalized NSE for this analysis.
.. code-block:: python
>>> Y_nnse = 1/(2 - Y_nse)
Perform the sensitivity analysis
--------------------------------
Now that the problem and their outputs are defined, we can perform
the sensitivity analysis using SALib and show the results.
.. code-block:: python
>>> Si_nnse = analyze(problem, Y_nnse, print_to_console=False, calc_second_order=False, seed=1)
>>> print('> First order sensitivity analysis on NSE')
>>> print(' Sensitivity indices: ', Si_nnse['S1'])
>>> print(' Confidence intervals: ', Si_nnse['S1_conf'])
>>>
>>> Si_crc = analyze(problem, Y_crc, print_to_console=False, calc_second_order=False, seed=1)
>>> print('> First order sensitivity analysis on Crc')
>>> print(' Sensitivity indices: ', Si_crc['S1'])
>>> print(' Confidence intervals: ', Si_crc['S1_conf'])
>>>
>>> Si_eff = analyze(problem, Y_eff, print_to_console=False, calc_second_order=False, seed=1)
>>> print('> First order sensitivity analysis on Eff')
>>> print(' Sensitivity indices: ', Si_eff['S1'])
>>> print(' Confidence intervals: ', Si_eff['S1_conf'])
.. code-block:: output
> First order sensitivity analysis on NSE
Sensitivity indices: [ 0.55052169 0.20563381 -0.02693246 0.02273316]
Confidence intervals: [0.23071231 0.27603189 0.08007255 0.02181141]
> First order sensitivity analysis on Crc
Sensitivity indices: [1.55958585e-02 3.28269253e-01 4.15976060e-03 2.44881492e-06]
Confidence intervals: [0.55138231 0.29921365 0.26705503 0.00057764]
> First order sensitivity analysis on Eff
Sensitivity indices: [0.40991146 0.0829772 0.01993396 0.00846561]
Confidence intervals: [0.24355879 0.13125132 0.04128444 0.01077616]
The sensitivity indices show the relative importance of each parameter in
affecting the model outputs. A higher sensitivity index indicates that the
parameter has a stronger influence on that particular metric. The confidence
intervals provide a measure of uncertainty in these sensitivity estimates.
.. note::
This analysis is performed on a single catchment. You can also perform this
analysis on multiple catchments by doing the same in a loop. Specifying the
``ncpu`` or using multiprocessing could help reduce the run time.