Parameter calibration (P-Model) and cost functions

The rsofun package allows to calibrate parameters of the pmodel and biomee models via the calib_sofun() function. The implementation of the calibration is fairly flexible and can be adapted to a specific use-case via a tailor-made cost function (used as metrics for the optimization routines in calib_sofun()). The package provides a set of standard cost functions named cost_*, which can be used for a variety of calibrations (different sets of model parameters, using various target variables, etc.). Alternatively, it’s possible to write a more specific new cost function to be used together with calib_sofun().

In this vignette, we go over some examples on how to use the rsofun cost functions for parameter calibration with calib_sofun() and how to write your own custom one from scratch. The separate vignette ‘Parameter calibration (P-Model) to multiple targets’ (see vignette("param_calib_multitarget")) provides another example.

Two calibration approaches are showcased below. First we showcase how to find a single set of parameters that optimize the RMSE using a GenSA optimizer. Second optimizing the likelihood with a Bayesian calibration approach is shown.

Calibration to GPP using RMSE and GenSA optimizer

A simple approach to parameter calibration is to find the parameter values that lead to the best prediction performance, in terms of the RMSE (root mean squared error). The function cost_rmse_pmodel() runs the P-model internally to calculate the RMSE between predicted target values (in this case GPP) and the corresponding observations.

The implementation of cost_rmse_pmodel() allows flexibility in various ways. We can simultaneously calibrate a subset of model parameters and also replicate the different calibration setups in Stocker et al., 2020 GMD, simply by providing the appropriate inputs to calib_sofun() using arguments as settings, obs and the optional argument par_fixed. Note that we must always specify the values of the model parameters that aren’t calibrated (via argument par_fixed). For example, following the ORG setup, only parameter kphio is calibrated with below code:

# Define calibration settings and parameter ranges from previous work
settings_rmse <- list(
  method = "GenSA",                   # minimizes the RMSE
  metric = cost_rmse_pmodel,          # our cost function returning the RMSE
  control = list(                     # control parameters for optimizer GenSA
    maxit = 100),                     
  par = list(                         # bounds for the parameter space
    kphio = list(lower = 0.02, upper = 0.2, init = 0.05)
  )
)

# Calibrate the model and optimize the free parameters using demo datasets
drivers_to_use <- pmodel_drivers    |> dplyr::filter(sitename == "FR-Pue")
obs_to_use     <- pmodel_validation |> dplyr::filter(sitename == "FR-Pue")
pars_calib_rmse <- calib_sofun(
  # calib_sofun arguments:
  drivers  = drivers_to_use,
  obs      = obs_to_use,
  settings = settings_rmse,
  # extra arguments passed to the cost function:
  par_fixed = list( # fix all other parameters
    kphio_par_a        = 0.0,        # set to zero to disable temperature-dependence
                                     # of kphio, setup ORG
    kphio_par_b        = 1.0,
    soilm_thetastar    = 0.6 * 240,  # to recover paper setup with soil moisture stress
    soilm_betao        = 0.0,
    beta_unitcostratio = 146.0,
    rd_to_vcmax        = 0.014,      # value from Atkin et al. 2015 for C3 herbaceous
    tau_acclim         = 30.0,
    kc_jmax            = 0.41
  )
)
pars_calib_rmse
#> $par
#>      kphio 
#> 0.03960258 
#> 
#> $mod
#> $mod$value
#> [1] 1.275524
#> 
#> $mod$par
#>      kphio 
#> 0.03960258 
#> 
#> $mod$trace.mat
#>      nb.steps temperature function.value current.minimum
#> [1,]        1        5230       3.561787        1.275524
#> 
#> $mod$counts
#> [1] 334

The output of calib_sofun() is a list containing the calibrated parameter values (element par) and the raw optimization output from the optimizer (element mod; here from GenSA or, as we see next, from BayesianTools::runMCMC).

Note that the standard cost functions would allow to calibrate to several targets (fluxes and leaf traits predicted by the P-model) simultaneously.

Calibration to GPP using a simple likelihood function and BayesianTools

Let’s calibrate the parameters involved in the temperature dependency of the quantum yield efficiency, kphio, kphio_par_a and kphio_par_b. Taking a Bayesian calibration approach, we need to define a likelihood as cost function (we’ll use cost_likelihood_pmodel()). We assume that the target variable ('gpp') follows a normal distribution centered at the observations and with its unknown standard deviation ('err_gpp'), that we need to add to the calibratable parameters. We also assume a uniform prior distribution for all calibratable parameters. By maximizing the log-likelihood, the MAP (maximum a posteriori) estimators for all 4 parameters are computed. The functions cost_likelihood_pmodel() and calib_sofun(), allow us to perform this type of calibration in the following way:

# Define calibration settings
settings_likelihood <- list(
  method = "BayesianTools",
  metric = cost_likelihood_pmodel,            # our cost function
  control = list(                             # optimization control settings for 
    sampler_runMCMC = "DEzs",                 # BayesianTools::runMCMC
    settings_runMCMC = list(
      burnin = 1500,
      iterations = 3000
  )),
  par = list(
    kphio = list(lower = 0, upper = 0.2, init = 0.05),
    kphio_par_a = list(lower = -0.5, upper = 0.5, init = -0.1),
    kphio_par_b = list(lower = 10, upper = 40, init = 25),
    err_gpp = list(lower = 0.1, upper = 4, init = 0.8)
  )
)

# Calibrate the model and optimize the free parameters using demo datasets
drivers_to_use <- pmodel_drivers    |> dplyr::filter(sitename == "FR-Pue")
obs_to_use     <- pmodel_validation |> dplyr::filter(sitename == "FR-Pue")
pars_calib_likelihood <- calib_sofun(
  # calib_sofun arguments:
  drivers  = drivers_to_use,
  obs      = obs_to_use,
  settings_calib = settings_likelihood,
  # extra arguments passed to the cost function:
  par_fixed = list( # fix all other parameters
    soilm_thetastar    = 0.6 * 240,  # to recover paper setup with soil moisture stress
    soilm_betao        = 0.0,
    beta_unitcostratio = 146.0,
    rd_to_vcmax        = 0.014,      # value from Atkin et al. 2015 for C3 herbaceous
    tau_acclim         = 30.0,
    kc_jmax            = 0.41
  )
)
pars_calib_likelihood
#> $par
#>       kphio kphio_par_a kphio_par_b     err_gpp 
#>  0.03954605  0.40947726 21.31318228  1.27939190 
#> 
#> $mod
#> [1] "mcmcSampler - you can use the following methods to summarize, plot or reduce this class:"
#> [1] plot    print   summary
#> see '?methods' for accessing help and source code

Furthermore, there are equivalent cost functions available for the BiomeE model. Check out the reference pages for more details on how to use cost_likelihood_biomee() and cost_rmse_biomee().

Write your custom cost function

If the RMSE or log-likelihood (for one or several targets) cost functions that we provide do not fit your use case, you can easily write a custom one. In this section, we guide you through the main ideas with an example. To run the calibration, you can still use calib_sofun() in combination with your custom cost function.

The routine calib_sofun() requires drivers, obs and settings as mandatory arguments. These provide data.frames with driver and observational data, as well as settings for the calibration. The optional argument optim_out defines if the raw optimization output should be returned. All other (optional) arguments to calib_sofun() are passed through to the cost function (e.g. par_fixed in above example). They can be used freely inside of your custom cost function, e.g. to control the simulation setup or the processing. On top of these optional arguments, it is also possible to extend the drivers and obs data.frames with additional columns that can be used freely for fine-grained control within your custom cost function.

All cost functions must take at least three arguments:

Below we’ll walk you through the definition of a custom cost function. In this example, we’ll calibrate the soil moisture stress parameters and use the mean absolute error (MAE) as custom cost function.

Since we are calibrating the parameters based on model outputs, the cost function will eventually need to run the P-model and compare its output to observed validation data.

To get started we suggest to write a dummy cost function and use it together with calib_sofun() as shown below. Note that one way of developing the cost function would be to use a browser() statement during the development. It allows you to explore the variables that you have access to from within the cost function.

# Define the custom cost function to be used
cost_mae <- function(par, obs, drivers, my_own_message) {
  # Your code
  browser() # can facilitate the development, remove afterwards
}

# Define calibration settings and parameter ranges
settings_mae <- list(
  method = "GenSA",
  metric = cost_mae, # directly uses the custom cost function
  control = list(
    maxit = 100
  ),
  par = list(
    soilm_thetastar = list(lower = 0.0, upper = 3000, init = 0.6 * 240),
    soilm_betao = list(lower = 0, upper = 1, init = 0.2)
  )
)

# Calibrate the model and optimize the free parameters
pars_calib_mae <- calib_sofun(
  drivers = pmodel_drivers,
  obs = pmodel_validation,
  settings_calib = settings_mae,
  # optional arguments if needed in the cost function
  my_own_message = "Hi from inside the cost_mae function."
)

During the optimization procedure, the cost function receives as argument a suggestion of the parameters par. This might be just a subset of all needed parameters (defined via settings$par). Thus to call runread_pmodel_f() within the cost function, a full set of model parameters is needed. Here, we’ll hardcode the parameters that aren’t being calibrated inside the cost function. (Note, that in above examples they were passed as an additional argument par_fixed).

cost_mae <- function(par, obs, drivers, my_own_message) {
  # Set values for the list of calibrated and non-calibrated model parameters
  params_modl <- list(
    kphio              = 0.09423773,
    kphio_par_a        = 0.0,
    kphio_par_b        = 25,
    soilm_thetastar    = par[["soilm_thetastar"]],
    soilm_betao        = par[["soilm_betao"]],
    beta_unitcostratio = 146.0,
    rd_to_vcmax        = 0.014,
    tau_acclim         = 30.0,
    kc_jmax            = 0.41
  )

  # Run the model
  df <- runread_pmodel_f(
    drivers,
    par = params_modl,
    makecheck = TRUE,
    parallel = FALSE
  )

  # Your code to compute the cost
  print(my_own_message) # useless, but showcases how to use additional arguments
  browser() # can facilitate the development, remove afterwards
}

The following chunk defines the final function. We clean the observations and model output and align the data according to site and date, to compute the mean absolute error (MAE) on GPP. Finally, the function should return a scalar value, in this case the MAE, which we want to minimize. Keep in mind that the GenSA optimization will minimize the cost, but with the BayesianTools method the cost (i.e. the likelihood) is always maximized.

cost_mae <- function(par, obs, drivers) {
  # Set values for the list of calibrated and non-calibrated model parameters
  params_modl <- list(
    kphio              = 0.09423773,
    kphio_par_a        = 0.0,
    kphio_par_b        = 25,
    soilm_thetastar    = par[["soilm_thetastar"]],
    soilm_betao        = par[["soilm_betao"]],
    beta_unitcostratio = 146.0,
    rd_to_vcmax        = 0.014,
    tau_acclim         = 30.0,
    kc_jmax            = 0.41
  ) # Set values for the list of calibrated and non-calibrated model parameters


  # Run the model
  df <- runread_pmodel_f(
    drivers = drivers,
    par = params_modl,
    makecheck = FALSE,
    parallel = FALSE
  )

  # Clean model output to compute cost
  df <- df |>
    dplyr::select(sitename, data) |>
    tidyr::unnest(data)

  # Clean validation data to compute cost
  obs <- obs |>
    dplyr::select(sitename, data) |>
    tidyr::unnest(data) |>
    dplyr::rename("gpp_obs" = "gpp") # rename for later

  # Left join model output with observations by site and date
  df <- dplyr::left_join(df, obs, by = c("sitename", "date"))

  # Compute mean absolute error
  cost <- mean(abs(df$gpp - df$gpp_obs), na.rm = TRUE)

  # Return the computed cost
  return(cost)
  # browser() # can facilitate the development, remove afterwards
}

As a last step, let’s verify that the calibration procedure runs using this cost function.

# Define calibration settings and parameter ranges
settings_mae <- list(
  method = "GenSA",
  metric = cost_mae, # our cost function
  control = list(
    maxit = 100),
  par = list(
    soilm_thetastar = list(lower = 0.0, upper = 3000, init = 0.6 * 240),
    soilm_betao = list(lower = 0, upper = 1, init = 0.2)
  )
)

# Calibrate the model and optimize the free parameters
pars_calib_mae <- calib_sofun(
  drivers = pmodel_drivers,
  obs = pmodel_validation,
  settings_calib = settings_mae
)
pars_calib_mae
#> $par
#> soilm_thetastar     soilm_betao 
#>     1573.595778        0.321293 
#> 
#> $mod
#> $mod$value
#> [1] 1.709216
#> 
#> $mod$par
#> soilm_thetastar     soilm_betao 
#>     1573.595778        0.321293 
#> 
#> $mod$trace.mat
#>      nb.steps temperature function.value current.minimum
#> [1,]        1     5230.00       2.561741        1.718247
#> [2,]        8      317.63       1.717776        1.709216
#> 
#> $mod$counts
#> [1] 717