--- title: "Parameter calibration (P-Model) to multiple targets (GPP, bigD13C)" author: "Fabian Bernhard, Josefa Arán" output: rmarkdown::html_vignette: code_folding: show vignette: > %\VignetteIndexEntry{Parameter calibration (P-Model) to multiple targets (GPP, bigD13C)} %\VignetteEngine{knitr::rmarkdown} %\usepackage[utf8]{inputenc} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(rsofun) library(dplyr) library(ggplot2) library(khroma) library(yardstick) ``` This vignette showcases a simplified version of the Bayesian calibration to with a joint log-likelihood to multiple target variables taken from the documentation paper [Paredes et al. (2025)](https://doi.org/10.5194/gmd-18-9855-2025). The separate vignette 'Parameter calibration (P-Model) and cost functions' (see `vignette("new_cost_function")`) provides simpler examples. You may be interested in calibrating the model to different target variables simultaneously, like flux and leaf trait measurements. ### Calibrating one GPP and one D13C site In this section we present an example, where we use `cost_likelihood_pmodel()` to compute the joint likelihood of all the targets specified (that is, by summing the log-likelihoods of GPP and bigD13C) and ultimately calibrate the `beta_unitcostratio` parameter. In the section after, we calibrate another example to multiple sites. ```{r run single-site calibration, eval = FALSE} #| class.source = 'fold-show' # Define calibration settings for two targets params_to_estimate <- list( beta_unitcostratio = list(lower = 14.6, upper = 440, init = 146.0), # uniform priors kc_jmax = list(lower = 0.2, upper = 0.8, init = 0.41), # uniform priors err_gpp = list(lower = 0.001, upper = 4, init = 1), err_bigD13C = list(lower = 0.01, upper = 3, init = 0.8)) params_fixed <- list( # fix parameter value from previous calibration kphio = 0.041, kphio_par_a = 0.0, kphio_par_b = 16, soilm_thetastar = 0.6 * 240, # to recover paper setup with soil moisture stress soilm_betao = 0.01, rd_to_vcmax = 0.014, # value from Atkin et al. 2015 for C3 herbaceous tau_acclim = 30.0) settings_joint_likelihood <- list( method = "BayesianTools", metric = rsofun::cost_likelihood_pmodel, control = list( sampler_runMCMC = "DREAMzs", settings_runMCMC = list( # burnin = 1500, # kept artificially low # iterations = 3000, burnin = 0, iterations = 50, # kept artificially low nrChains = 1 ), # optional parallelization n_parallel_nrChains = 1 # 2, this can be parallelized ), # parameters par = params_to_estimate ) # Run the calibration on the observational data of two sites drivers <- pmodel_drivers |> dplyr::filter(sitename %in% c("FR-Pue", "lon_+146.13_lat_-032.97")) obs <- pmodel_validation |> dplyr::filter(sitename %in% c("FR-Pue", "lon_+146.13_lat_-032.97")) par_calib_join <- calib_sofun( drivers = drivers, obs = obs, settings_calib = settings_joint_likelihood, # arguments for the cost function targets = c("gpp"="fluxnet", "bigD13C"="cornwell"), par_fixed = params_fixed ) ``` ```{r eval = FALSE, include = FALSE} # store output since calibration isn't run at each vignette rendering saveRDS(par_calib_join, "vignettes/files/param_calib_multitarget.Rmd__par_calib_join.RDS", compress = "xz") ``` ```{r include = FALSE} # fake output since calibration isn't run par_calib_join <- readRDS( "files/param_calib_multitarget.Rmd__par_calib_join.RDS") # NOTE: for rendering remove prefix "vignettes/" ``` Calibration with `calib_sofun` returns an object that contains the maximum-a-posteriori (MAP) estimates of the parameters in `par` and the full distribution of the parameters in the MCMC chains in `mod`. Besides those it also reports additional information. ```{r} par_calib_join ``` Note that GPP predictions are directly compared to GPP observations on that day, but the leaf trait bigD13C is predicted by the P-model as "onestep" prediction for average conditions over the growing season and compared to the (multiple) bigD13C observations taken across trees per site. This is specified by the arguments `drivers`, `obs`, and `targets`. Namely, it is specified by the column `params_siml$onestep` within `drivers`, which can be `FALSE` (for daily simulation) or `TRUE` (for onestep acclimation), by the corresponding column `forcing` containing daily or average conditions, and by the column `source` specified for each simulation site (i.e. each row in `obs`) which is used by the `targets` argument to specify what variable to calibrate from each source data set. For more details, unfold the code below or check the documentation of `pmodel_drivers` and `pmodel_validation`. ```{r} #| class.source = 'fold-hide' # Note the column 'data' (containing observations) pmodel_validation cornwell_sites <- pmodel_validation$sitename[pmodel_validation$source == "cornwell"] fluxnet_sites <- pmodel_validation$sitename[pmodel_validation$source == "fluxnet"] pmodel_validation |> dplyr::filter(source == "cornwell") |> tidyr::unnest(data) pmodel_validation |> dplyr::filter(source == "fluxnet") |> tidyr::unnest(data) pmodel_drivers |> dplyr::filter(sitename %in% cornwell_sites) |> tidyr::unnest(forcing) pmodel_drivers |> dplyr::filter(sitename %in% fluxnet_sites) |> tidyr::unnest(forcing) pmodel_drivers |> dplyr::filter(sitename %in% cornwell_sites) |> tidyr::unnest(params_siml) pmodel_drivers |> dplyr::filter(sitename %in% fluxnet_sites) |> tidyr::unnest(params_siml) ``` ### Calibrating multiple GPP and D13C sites #### Some helper functions for calibration Below we define some helper functions to facilitate model calibration with different target variables, resulting thus in different calibration setups. These helper functions simplify the use of parallel execution and avoid code repetition. Here, we showcase two different setups. The first setup uses only D13C as target variable (corresponds to Setup S1 in the documentation paper). The second setup uses D13C and GPP as calibration targets and (truncated) normal priors for the parameters (Setup S6). In below code, we define first these two different calibration setups by specifying the function `setup_rsofun_calibration()`, that returns an object with the necessary specifications. These specifications can differ in the selection of calibration parameters and priors, in the data used and potentially in the test-train split. Here, setups are uniquely identified with a numeric identifer. Below code defines `setup_rsofun_calibration()` for setups 226 and 231. ```{r prepare multisite/multitarget calibration setups, eval = TRUE} #| class.source = 'fold-hide' # 226: Setup c) Delta^'13 C*',VJ # 231: Setup h) Delta^'13 C*',VJ, GPP setup_rsofun_calibration <- function(scenario) { ## Load forcing and targets data from disk (here now from package) ---- bigD13C_vj_gpp_drivers <- rsofun::pmodel_drivers bigD13C_vj_gpp_obs <- rsofun::pmodel_validation # ## Read test-train split from disk ---- # read_csv(here::here("data/01_test_train_split.csv")) # Not needed here for vignette ## Preprocess observation data (gpp) ---- ## # no additinal QC needed ## Apply test-train split to data ---- train_drivers <- bigD13C_vj_gpp_drivers train_obs <- bigD13C_vj_gpp_obs test_drivers <- bigD13C_vj_gpp_drivers |> slice(0) test_obs <- bigD13C_vj_gpp_obs |> slice(0) ## Setup the settings for the different calibration scenarios ---- ## Define default parameter default_par_fixed <- list( # fix parameter value from previous calibration kphio = 0.04998, # value from Stocker et al. 2020 kphio_par_a = 0.0, # 0 corresponds to no temperature dependency of kphio (ORG setup in Stocker et al. 2020) kphio_par_b = 1.0, # soilm_thetastar = 0.6 * 240, # to recover paper setup with soil moisture stress soilm_betao = 0.01, # 1 corresponds to no reduction, 0 to full reduction at theta==0 beta_unitcostratio = 146.0, # value from Stocker et al. 2020 rd_to_vcmax = 0.014, # value from Atkin et al. 2015 for C3 herbaceous tau_acclim = 14.0, # value from Liu et al. 2024 kc_jmax = 0.41 # value from Stocker et al. 2024 (citing Wang et al. 2017) ) ## Define parameters to estimate and their priors if (scenario %in% c(223, 226, 231)) { # 231 is like 223 but using posteriors from 226 par_to_estimate <- list( kphio = list(lower = 0.02, upper = 0.15, init = 0.05), kphio_par_a = list(lower = -0.004, upper = -0.001, init = -0.0025), kphio_par_b = list(lower = 10, upper = 30, init = 20), soilm_thetastar = list(lower = 1, upper = 250, init = 40), beta_unitcostratio = as.list(c(lower = 0.1, upper = 3.0, init = 1.0) * 146.0), # truncated normal, with ~14 days as mean, taken from Liu et al. # 2024, Nat.Plants and Mäkelä et al. 2004, Tree Phys. tau_acclim = list(mean = 14, sd = 8, lower = 0.01, upper = 40), kc_jmax = as.list(0.41 * c(lower = 0.1, upper = 3.0, init = 1.0)), err_gpp = list(lower = 0.01, upper = 3, init = 0.8), err_bigD13C = list(lower = 0.01, upper = 3, init = 0.8) ) if (scenario %in% c(223, 226, 231)) { par_to_estimate$err_bigD13C <- list(lower = 0.01, upper = 15, init = 0.8) } } else { stop(sprintf("Unsupported scenario: %d", scenario)) } if (scenario %in% c(231)) { # 231 is like 223 but using posteriors from 226 # for beta_unitcostratio and kc_jmax as priors # Below was done iteratively # calib_scen226 <- readr::read_rds(file.path(rsofun_doc_output_path, "data","calibrations","out_calib__scen226_DREAMzs-100000-0iter_8x3chains_on_CPU8x1_continued.rds")) # # i) extract samples as a data.frame # burnins_scen226 <- 30000 # samples_scen226 <- getSample(calib_scen226$mod, thin = 1, start = burnins_scen226) %>% as.data.frame() # # # ii) fit normal and lognormal distributions for each parameter # param_normals_scen226 <- lapply(setNames(names(samples_scen226), names(samples_scen226)), function(p) { # list(mean = mean(samples_scen226[[p]]), # sd = sd( samples_scen226[[p]])) # })[c('beta_unitcostratio', 'kc_jmax')] # only keep these # # # then pass on these as prior for these # par_to_estimate$beta_unitcostratio <- param_normals_scen226$beta_unitcostratio # par_to_estimate$kc_jmax <- param_normals_scen226$kc_jmax par_to_estimate$beta_unitcostratio <- list(mean = 207.86, sd = 6.79) par_to_estimate$kc_jmax <- list(mean = 0.4244, sd = 0.0217) if (scenario %in% c(231)) { # truncated normal: par_to_estimate$beta_unitcostratio <- list(mean = 207.86, sd = 6.79, lower = 207.86 - 3 * 6.79, upper = 207.86 + 3 * 6.79) # normal: par_to_estimate$kc_jmax <- list(mean = 0.4244, sd = 0.0217) } } # Remove parameters that are defined to be estimated from default_par_fixed par_to_fix <- default_par_fixed[!(names(default_par_fixed) %in% names(par_to_estimate))] ## Setup the data (drivers and obs) for the three calibration scenarios ---- # Subset different combination sites to define target variables. # For easier handling do this in combined drivobs-object. drivobs_train_bigD13C_vj_gpp <- dplyr::inner_join( train_drivers, train_obs, by = join_by(sitename)) drivobs_test_bigD13C_vj_gpp <- dplyr::inner_join( test_drivers, test_obs, by = join_by(sitename)) all_potential_targets <- c("bigD13C" = "cornwell", "gpp" = "fluxnet", "le" = "fluxnet") if (scenario %in% c(223, 224, 225, 229, 230, 231)) { # GPP and traits data targets_to_keep <- c("gpp", "bigD13C") } else if (scenario %in% c(226)) { # only traits data, either both, or vj only, or bigD13C only targets_to_keep <- c("bigD13C") # } else if (scenario %in% c(220)) { # only GPP data from FR-Pue # targets_to_keep <- c("gpp") # sites_to_keep <- "FR-Pue" } else { stop(sprintf("Unsupported scenario: %d", scenario)) } stopifnot(all(targets_to_keep %in% names(all_potential_targets))) targets_to_request <- all_potential_targets[targets_to_keep] drivobs_train <- drivobs_train_bigD13C_vj_gpp |> # keep only sites with targets we want dplyr::filter(source %in% unname(targets_to_request)) drivobs_test <- drivobs_test_bigD13C_vj_gpp ## for the test data set keep all ## return --- return(list( drivobs_train = drivobs_train, drivobs_test = drivobs_test, targets = targets_to_request, par_fixed = par_to_fix, par = par_to_estimate )) } setup_rsofun_calibration(226) setup_rsofun_calibration(226)$drivobs_train setup_rsofun_calibration(231) setup_rsofun_calibration(231)$drivobs_train ``` Then we also define a function to run the Markov Chain Monte Carlo (MCMC) sampling `run_mcmc_rsofun()`. This function reads in the unique setup identifier and uses the function `setup_rsofun_calibration()` to get all the necessary definitions. By doing so this code can easily be parallelized in multiple jobs. ```{r prepare multisite/multitarget calibration, eval = TRUE} #| class.source = 'fold-hide' run_mcmc_rsofun <- function( curr_calibration_scenario, # MCMC setup: iterations = 6, burnin = 0, n_chains_independent = 3, # number of independent chains (called 'nrChains' in runMCMC) n_chains_within_sampler = 3, # number of internal chains to be sampled (called 'startValue' in runMCMC, at least 2 for DEzs) # parallelization: n_parallel_independent = 3, # number of cores for parallelization of independent chains https://cran.r-project.org/web/packages/BayesianTools/vignettes/InterfacingAModel.html#running-several-mcmcs-in-parallel outpath = "data", suffix_str = "", sampler = "DREAMzs" ) { # Setup simulation model res <- setup_rsofun_calibration(scenario = curr_calibration_scenario) # res$drivobs_train # res$drivobs_test # res$par_fixed # res$par # res$targets # Load loglikelihood (e.g. if using a tailor-made one, instead of package-provided): # source("R/calibration_helpers.R", echo = FALSE) # source("R/cost_likelihood_pmodel.R", echo = FALSE) # Setup MCMC # uses drivobs_train, not drivobs_test driver_to_use_for_mcmc <- select(res$drivobs_train, sitename, params_siml, site_info, forcing) obs_to_use_for_mcmc <- select(res$drivobs_train, sitename, source, data) calib_sofun_settings <- list( method = "BayesianTools", metric = rsofun::cost_likelihood_pmodel, control = list( sampler_runMCMC = sampler, settings_runMCMC = list( burnin = burnin, # 10000, iterations = iterations, # 50000, nrChains = n_chains_independent, # number of independent chains to be sampled startValue = n_chains_within_sampler # number of internal chains to be sampled ), n_parallel_nrChains = n_parallel_independent ), par = res$par ) # setup output directories for parallel calibration logpath <- file.path(outpath, "calibrations", paste0("out_calib_", suffix_str, ".rds.log.txt")) rds_path <- gsub(".log.txt", "", logpath) dir.create(path = dirname(logpath), showWarnings = FALSE) # Run calibration in parallel cat(paste0(Sys.time(), ": start sampling of ", paste0("out_calib_", suffix_str, ".rds"))) out_calib <- calib_sofun( drivers = driver_to_use_for_mcmc, obs = obs_to_use_for_mcmc, settings_calib = calib_sofun_settings, logpath = logpath, # other arguments for the cost function targets = res$targets, par_fixed = res$par_fixed ) ## Store result object to file: ---- ## This enables easy continuation of MCMC sampling saveRDS(out_calib, file = rds_path, compress = "xz") cat(paste0(Sys.time(), ": end sampling of ", out_calib$name, ". \nWritten *.rds-output to: ", rds_path)) # return only performance results as tibble timings <- tibble( setup = curr_calibration_scenario, name = gsub(".rds.log.txt", "", out_calib$name), resultfile = rds_path, # sampling options: sampler = calib_sofun_settings$control$sampler_runMCMC, burnin = burnin, iterations = iterations, n_chains = n_chains_independent, n_chains_inner = n_chains_within_sampler, # performance results: cores = n_parallel_independent, walltime = out_calib$walltime, # logfile = out_calib$logpath ) return(timings) } ``` We additionally showcase below how to parallelize multiple chains to different cores (`n_parallel_independent = 8`) and thus how to significantly speed up long-running calibration tasks. For long-running calibrations it can also be useful to be able to continue an existing sampling chain with `continue_mcmc_rsofun()`. While this is not shown in this vignette, code for this is available on Zenodo ([10.5281/zenodo.17204361](https://dx.doi.org/10.5281/zenodo.17204361)). #### Run calibration With these helper functions specified, we can then run the two calibration setups. The sampled MCMC chains are stored to a specified path so that they can easily be loaded at a later time in a separate R session. Below, the second setup is run a second time making use of parallel sampling of the independent chains. We make use of arguments of the user-defined function `run_mcmc_rsofun()` that allow us to specify the calibration setup, the MCMC setup, and the parallelization strategy. ```{r run multisite/multitarget calibration, eval = FALSE} dir.create("vignettes/files/param_calib_multitarget/", showWarnings = FALSE) df_timings226 <- run_mcmc_rsofun(226, suffix_str = "setup_226", outpath = "vignettes/files/param_calib_multitarget/", # mcmc setup: sampler = "DREAMzs", iterations = 600, burnin = 0, n_chains_independent = 8, n_chains_within_sampler = 3, # parallelization n_parallel_independent = 1 ) # 2026-07-09 10:39:19.622351: start sampling of out_calib_setup_226.rds # runMCMC terminated after 4.39500000000407seconds00 . Current logp -389.1087 -421.1468 -396.6275 Please wait! # runMCMC terminated after 3.95499999998719seconds00 . Current logp -377.7371 -379.5134 -377.3347 Please wait! # ... # runMCMC terminated after 4.23500000000058seconds00 . Current logp -397.9886 -379.6501 -382.6354 Please wait! df_timings231 <- run_mcmc_rsofun(231, suffix_str = "setup_231", outpath = "vignettes/files/param_calib_multitarget/", # mcmc setup: sampler = "DREAMzs", iterations = 600, burnin = 0, n_chains_independent = 8, n_chains_within_sampler = 3, # parallelization n_parallel_independent = 1 ) # 2026-07-09 10:39:53.032407: start sampling of out_calib_setup_231.rds # Running DREAM-MCMC, chain 1 iteration 300 of 600 . Current logp -14645.97 -14571.83 -15471.88 Please wait! # runMCMC terminated after 16.8090000000084seconds00 . Current logp -14616.55 -14462.62 -14529.8 Please wait! # Running DREAM-MCMC, chain 2 iteration 300 of 600 . Current logp -16760.01 -15828.73 -14948.44 Please wait! # runMCMC terminated after 16.4020000000019seconds00 . Current logp -14955.26 -14941.47 -14441.98 Please wait! # ... # runMCMC terminated after 17.8410000000003seconds00 . Current logp -15023.04 -14854.75 -14265.61 Please wait! df_timings231p <- run_mcmc_rsofun(231, suffix_str = "setup_231_parallel", outpath = "vignettes/files/param_calib_multitarget/", # mcmc setup: sampler = "DREAMzs", iterations = 600, burnin = 0, n_chains_independent = 8, n_chains_within_sampler = 3, # parallelization n_parallel_independent = 8 # here now we parallelize the 8 chains to 8 cores ) # 2026-07-09 10:42:24.395628: start sampling of out_calib_setup_231_parallel.rds # Writing MCMC sampling log to: files/param_calib_multitarget//calibrations/out_calib_setup_231_parallel.rds.log.txt # ... # 2026-07-09 10:43:07.689861: end sampling of out_calib_setup_231_parallel.rds.log.txt. # Written *.rds-output to: files/param_calib_multitarget//calibrations/out_calib_setup_231_parallel.rds ``` ```{r eval = FALSE, include = FALSE} # store output since calibration isn't run calib_timings <- bind_rows( df_timings226, df_timings231, df_timings231p) saveRDS(calib_timings, "vignettes/files/param_calib_multitarget.Rmd__calib_timings.RDS", compress = "xz") ``` ```{r eval = TRUE, include = FALSE} # fake output since calibration was run previous to vignette rendering calib_timings <- readRDS( "files/param_calib_multitarget.Rmd__calib_timings.RDS") # NOTE: for rendering remove prefix "vignettes/" ``` ```{r} #| class.source = 'fold-hide' # calib_timings <- bind_rows( # df_timings226, # df_timings231, # df_timings231p) knitr::kable(calib_timings |> dplyr::mutate(name = gsub(".rds.log.txt", "", name)) |> dplyr::mutate(resultfile = gsub("vignettes/files/param_calib_multitarget//", "", resultfile)) |> dplyr::select(-"resultfile", "resultfile") ) ``` Note how the parallelized version reduced the walltime by 4x. With additional independent MCMC chains (e.g. the rsofun documentation paper used up to 8 chains), this can become important. Since MCMC sampling can be long running tasks, the resulting MCMC sampling chains are stored to disk, so that they can easily be recovered in a later R session for an analysis similar to the one below. During the parallel MCMC sampling the workers don't send output to the console. This is why a logfile is created and progress can be checked there. The above example resulted in a common log file of all eight workers: ``` starting worker pid=91053 on localhost:11767 at 10:42:24.511 starting worker pid=91066 on localhost:11767 at 10:42:24.598 starting worker pid=91079 on localhost:11767 at 10:42:24.687 starting worker pid=91092 on localhost:11767 at 10:42:24.769 starting worker pid=91105 on localhost:11767 at 10:42:24.854 starting worker pid=91118 on localhost:11767 at 10:42:24.941 starting worker pid=91131 on localhost:11767 at 10:42:25.025 starting worker pid=91144 on localhost:11767 at 10:42:25.108 Loading required package: rsofun loaded rsofun and set parent environment ... Running DREAM-MCMC, chain 1 iteration 300 of 600 . Current logp -15821.4 ... Please wait! Running DREAM-MCMC, chain 1 iteration 300 of 600 . Current logp -15415.3 ... Please wait! Running DREAM-MCMC, chain 1 iteration 300 of 600 . Current logp -14739.4 ... Please wait! ... Running DREAM-MCMC, chain 1 iteration 600 of 600 . Current logp -14654.7 ... Please wait! runMCMC terminated after 32.359seconds Running DREAM-MCMC, chain 1 iteration 600 of 600 . Current logp -14927.0 ... Please wait! runMCMC terminated after 35.474seconds ``` #### Analyze model calibration After the MCMC sampling we could (potentially in a new R script), load the sampled chains and analyse the chain convergence and posterior distribution. To do so we load the previous results from RDS files: ```{r load MCMC chains} # This loads MCMC chains that might have been generated with a separate R script out_calib_226 <- readRDS("files/param_calib_multitarget/calibrations/out_calib_setup_226.rds") out_calib_231p <- readRDS("files/param_calib_multitarget/calibrations/out_calib_setup_231_parallel.rds") ``` We define some labelling changes to obtain nicer looking plots. ```{r prepare MCMC plotting helpers, fig.width=7, fig.height=4} #| class.source = 'fold-hide' rsofun_symbol_parname_description <- dplyr::tribble( ~variable, ~label, ~Symbol_R, "kphio", "italic(phi)[0]^'*'*' (mol mol-\u00B9)'", "italic(phi)[0]^'*'", "kphio_par_a", "italic(a)[italic(phi)]*' (\u00B0C-\u00B2)'", "italic(a)[italic(phi)]", "kphio_par_b", "italic(b)[italic(phi)]*' (\u00B0C)'", "italic(b)[italic(phi)]", "soilm_thetastar", "italic(theta)^'*'*' ('*'mm'*')'", "italic(theta)^'*'", "soilm_betao", "italic(beta)[0]*' (-)'", "italic(beta)[0]", "beta_unitcostratio", "italic(beta)*' (-)'", "italic(beta)", "rd_to_vcmax", "italic(b)[0]*' (-)'", "italic(b)[0]", "tau_acclim", "italic(tau)*' (days)'", "italic(tau)", "kc_jmax", "italic(c)^'*'*' (-)'", "italic(c)^'*'", "err_gpp", "italic(sigma)['GPP']*' (gC m -\u00B2 s-\u00B9)'", "italic(sigma)['GPP']", "err_bigD13C", "italic(sigma)[Delta]*' ('*'\u2030'*')'", "italic(sigma)[Delta]" ) label_vec_short <- setNames(rsofun_symbol_parname_description$Symbol_R, rsofun_symbol_parname_description$variable) custom_labeller_variable <- function(labels, multi_line = TRUE) { # adapted from label_parsed replaced_labels <- left_join( select(labels, variable), # NOTE: this has variable hardcoded select(rsofun_symbol_parname_description, variable, label), by = join_by(variable)) # print(tibble(replaced_labels)) replaced_labels <- replaced_labels |> select(-variable) replaced_labels <- label_value(replaced_labels, multi_line = multi_line) # print(replaced_labels) lapply(unname(replaced_labels), lapply, function(values) { c(parse(text = as.character(values))) }) } ``` And we define a function `plot_mcmc_trace()` to plot a trace plot of the MCMC sampling including Gelman-Rubin statistics to easily assess convergence for the two calibration setups. For this vignette examples we did not run the MCMC long enough to see converging trace plots. In a real application, one would need to increase the number of iterations. What we can see is the number of chains, which are the 8 independent chains, each consisting of 3 internal chains for a total of 24 traces in the plots. Note that the 3 internal chains also reduce the length of the iterations from the specified 600 to 200. ```{r function for MCMC trace plot, fig.width=7, fig.height=4} #| class.source = 'fold-hide' # Define plotting function plot_mcmc_trace <- function(out_calib, nr_internal_chains, burnin_to_skip, burnin_to_skip_gelman = burnin_to_skip, dont_thin = FALSE, end = NULL) { x <- out_calib$mod title <- basename(out_calib$name) curr_iter <- x[[1]]$settings$iterations if (dont_thin || curr_iter < 10000) { curr_thin <- 1 } else { curr_thin <- floor(curr_iter / 10000) } xsample <- BayesianTools::getSample(x, coda = T, thin = curr_thin, start = burnin_to_skip, end = end) # nr_internal_chains will have same color dat_to_plot <- lapply(xsample, function(single_chain) { as_tibble(single_chain) |> mutate(iteration = burnin_to_skip + curr_thin * (1:n())) }) |> dplyr::bind_rows(.id = "chain_id") |> tidyr::pivot_longer(-c(iteration, chain_id), names_to = "variable") |> # mark inner and outer chains (assumes DEzs or DREAMzs): mutate(outerChain = as.factor(ceiling(as.numeric(chain_id) / 3)), innerChain = (as.numeric(chain_id) + 2) %% 3 + 1, innerChain_str = letters[innerChain], chain_id_str = paste0(outerChain, letters[innerChain])) #|> # fix order: in order of appearance # mutate(variable = forcats::as_factor(variable)) pl <- ggplot(dat_to_plot, aes(x = iteration, y = value, color = outerChain, linetype = innerChain_str)) + geom_line() + # geom_rug(sides = "r") + theme_classic() + # scale_x_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) + facet_wrap(~variable, nrow = 2, scales = "free_y", labeller = custom_labeller_variable) + theme( legend.position = "bottom", strip.text = element_text(size = 12) ) + labs(y = "", color = "chain", linetype = "internal\nchains") # add Gelman Diagnostics get_gelman_diag <- function(mcmc, burnin_to_skip, end) { gelman_df <- BayesianTools::gelmanDiagnostics(mcmc, start = burnin_to_skip, end = end) psrf_values <- gelman_df$psrf[, "Point est."] # psrf_strings <- paste0(substr(names(psrf_values),1,5), "..=", sprintf("%.2f", psrf_values)) psrf_strings <- paste0("'*", unname(label_vec_short[names(psrf_values)]), "*'=", sprintf("%.2f", psrf_values)) psrf_string <- paste0(psrf_strings, collapse = ", ") subtitle <- sprintf("'GelmanDiagnostics: mpsrf=%.1f; psrf:%s'", gelman_df$mpsrf, psrf_string) } subtitle <- tryCatch(get_gelman_diag(x, burnin_to_skip_gelman + 1, end = end), error = function(e) { e }) # unsure why min burnin of 1 is needed pl <- pl + ggtitle(title, subtitle = parse(text = subtitle)) pl <- pl + geom_vline(xintercept = burnin_to_skip_gelman, color = "red", linetype = "dashed") return(pl) } ``` ```{r plot MCMC trace plot, fig.width=7, fig.height=4} plot_mcmc_trace(out_calib_226, nr_internal_chains = 3, burnin_to_skip = 0, burnin_to_skip_gelman = 150) plot_mcmc_trace(out_calib_231p, nr_internal_chains = 3, burnin_to_skip = 0, burnin_to_skip_gelman = 150) ``` We illustrate the posterior (derived from these unconverged example MCMC chains) by defining a function `plot_prior_posterior_density()` that shows the prior and posterior distributions. We can clearly distinguish the different priors used in the the two setups, and how the calibration constrains the posteriors to different parameter values. ```{r function for plot parameter distributions, fig.width=7, fig.height=7} #| class.source = 'fold-hide' plot_prior_posterior_density <- function(x, burnin_to_skip) { require(BayesianTools) require(dplyr) require(tidyr) require(ggplot2) # Get matrices of prior and posterior samples posteriorMat <- BayesianTools::getSample(x, parametersOnly = TRUE, start = burnin_to_skip) priorMat <- BayesianTools:::getSetup(x)$prior$sampler(10000) # nPriorDraws = 10000 # Parameter names parNames <- colnames(posteriorMat) # rename columns priorMat colnames(priorMat) <- parNames # Create data frame for plotting df_plot <- rbind( data.frame(priorMat, par_estimation = "prior"), data.frame(posteriorMat, par_estimation = "posterior") ) |> # dplyr::mutate(par_estimation = forcats::fct_inorder(par_estimation)) |> # order by appearance tibble() levels(df_plot$par_estimation) # Plot with facet wrap gg <- df_plot |> tidyr::pivot_longer(-c(par_estimation), names_to = "variable") |> # dplyr::mutate(variable = forcats::fct_inorder(variable)) |> # order by appearance ggplot( aes(x = value, fill = par_estimation) ) + geom_density(alpha = 0.8) + theme_classic() + facet_wrap(~variable, nrow = 2, scales = "free", labeller = custom_labeller_variable) + # facet_wrap( ~ variable , nrow = 2, scales = "free") + theme( legend.position = "bottom", axis.title.x = element_text(""), axis.ticks.y = element_blank(), axis.text.y = element_blank(), ) + scale_fill_manual(NULL, values = c("posterior" = "#29a274ff", "prior" = "#777055ff")) # GECO colors return(gg) } ``` ```{r plot parameter distributions, fig.width=7, fig.height=7} library(cowplot) p1 <- plot_prior_posterior_density(out_calib_226$mod, burnin_to_skip = 200) + ggtitle(out_calib_226$name) p2 <- plot_prior_posterior_density(out_calib_231p$mod, burnin_to_skip = 20) + ggtitle(out_calib_231p$name) cowplot::plot_grid(p1, p2, ncol = 1) ``` The joint prior distributions of the parameters can be assessed for correlation between parameters, indicating compensating effects between them which is often also called equifinality - same model output with different parameter sets. ```{r plot parameter correlation, fig.width=7, fig.height=7} BayesianTools::correlationPlot(out_calib_231p$mod) ``` #### Compare predictions of the calibrated model with observations Eventually, we want to use the calibrated model for predictions and also check their agreement with the observations used for calibration. For that we define another helper function `run_prediction_rsofun()`, which samples parameter sets from the MCMC chain to make predictions. A second function samples errors from the identified structural uncertainty (error model) and combines them with the predictions. To do so they use helper functions (`setup_rsofun_calibration()`, `apply_bias_correction_and_sample_error()`, and `predict_sofun_parallelized()`). ```{r prepare predictions, eval = TRUE} #| class.source = 'fold-hide' # Samples posterior parameters (MAP or N=n_samples) and runs # model predictions # # This allows to do two things: # A) make predictions at sites/dates/... where we have observations # B) make predictions also on dates/... where we have no observations, e.g. for continuous time series # we do B) once {by setting `return_continuous_timeseries = TRUE`} and then derive A) from it. run_prediction_rsofun <- function( mcmc_posterior, prediction = c("both", "test", "train"), burnin_to_skip = 0, n_samples = 100, # if n_samples == 1, use MAP n_cores = NULL) { if (length(prediction) == 1 && (prediction %in% c("both", "test", "train"))) { # as expected } else { stop("Provide prediction as either: 'both', 'test' or 'train'") } n_cores <- ifelse(is.null(n_cores), 1, n_cores) stopifnot(is(mcmc_posterior$mod, "mcmcSamplerList")) curr_calibration_scenario <- as.integer(gsub( ".*_setup_([0-9]*)_.*", # NOTE: hardcoded expected format of filename "\\1", mcmc_posterior$name)) stopifnot(!is.na(curr_calibration_scenario)) # Catches if expected format is inaccurate # Setup simulation model res <- setup_rsofun_calibration(scenario = curr_calibration_scenario) # Set random seed for reproducibility set.seed(2023) # Sample parameters from MCMC posterior # Evaluation of the uncertainty coming from the model parameters' uncertainty if (n_samples > 1) { samples_par <- getSample( mcmc_posterior$mod, thin = 1, start = burnin_to_skip, numSamples = n_samples ) |> as.data.frame() |> # Add sample IDs dplyr::mutate(posterior_sample_id = 1:n()) |> tidyr::nest(.by = posterior_sample_id, .key = "pars") } else { # mcmc_posterior$par # these are already precomputed... # but more robust to recompute: samples_par <- BayesianTools::MAP(mcmc_posterior$mod)$parametersMAP |> as.list() |> as_tibble() |> # Add sample IDs dplyr::mutate(posterior_sample_id = 0L) |> # posterior_sample_id == 0 means MAP tidyr::nest(.by = posterior_sample_id, .key = "pars") } # Setup prediction predict_sofun_settings <- list(n_cores = n_cores) if (prediction == "both") { curr_driver <- bind_rows(res$drivobs_train, res$drivobs_test) |> select(sitename, params_siml, site_info, forcing) curr_obs <- bind_rows(res$drivobs_train, res$drivobs_test) |> select(sitename, source, data) } else if (prediction == "train") { curr_driver <- select(res$drivobs_train, sitename, params_siml, site_info, forcing) curr_obs <- select(res$drivobs_train, sitename, source, data) } else if (prediction == "test") { curr_driver <- select(res$drivobs_test, sitename, params_siml, site_info, forcing) curr_obs <- select(res$drivobs_test, sitename, source, data) stopifnot(nrow(curr_obs) > 0) } # Run prediction df_pred_vs_obs <- predict_sofun_parallelized( drivers = curr_driver, obs = curr_obs, settings = predict_sofun_settings, par = samples_par, par_fixed = res$par_fixed ) return(df_pred_vs_obs) } # Runs the requested model predictions and returns results # Requested model predictions are defined by data.frame `par_df` containing # model parameter sets predict_sofun_parallelized <- function( drivers, obs, settings, targets, par_fixed, par_df = NULL) { # Set number of cores if not specified if (is.null(settings$n_cores)) { settings$n_cores <- min(detectCores() - 1, 20) # at most 20 } # ensure not more than needed settings$n_cores <- min(nrow(par_df), settings$n_cores) # Function to run prediction for a single parameter set run_pmodel_single_prediction <- function(par, par_fixed, targets, drivers, obs) { # Function that runs the P-model for a sample of parameters # but does not add the observation error # Taken from cost_likelihood_pmodel() stopifnot(nrow(obs) > 0) # ensure some observation data are provided stopifnot(nrow(drivers) > 0) # ensure some driver data are provided # A) Include current parameters ---- stopifnot(length(intersect(names(par), names(par_fixed))) == 0) # no overlap params_modl <- c(par, par_fixed) # B1) append column targets to obs data.frame: (code copy from cost_likelihood) # This ensures that variant that requires targets as function argument can be # run with the code below (which was written with targets as a column of obs defined as: # \item{targets}{A single one-row tibble with a column for each target # containing TRUE or FALSE (or NA_logical)} targets = c( "gpp" = "fluxnet", # NOTE: hardcoding TODO: remove "le" = "fluxnet", # NOTE: hardcoding TODO: remove "bigD13C" = "cornwell", # NOTE: hardcoding TODO: remove "vj" = "cornwell") # NOTE: hardcoding TODO: remove targets_df <- tidyr::tibble(target = names(targets), source = unname(targets)) |> # or use enframe(..., name = "target", value = "source") dplyr::group_by(source) |> dplyr::summarise(targets = unique(list(target))) obs <- dplyr::left_join(obs, targets_df, by = dplyr::join_by("source")) |> dplyr::select(-"source") # B,C) Run model and bring together with observed ---- df_pred_vs_obs <- rsofun:::get_mod_obs_pmodel( drivers, obs, params_modl, parallel = FALSE, ncores = 1, return_continuous_timeseries = TRUE) # D) (DON'T) Sample error model ---- # NOTE: sampling is not done here, but can optionally be done before plotting # Here we rename to clarify that no error model has yet been applied. df_pred_vs_obs <- df_pred_vs_obs |> # clarify name of model output (containing not yet any error model term) rename(mod_no_err = mod) |> relocate(c(mod_no_err, err_par_sd), .after = last_col()) return(df_pred_vs_obs) } # Run the P-model predictions for each set of parameters ## Prepare parallelization: parallel_flag <- (settings$n_cores > 1 && nrow(par_df) > 1) # setup helpers for conditional parallelization do_if <- function(df, cond, f) { # enable conditional lines in dplyr piping if (cond) f(df) else df } cl <- if (parallel_flag) { multidplyr::new_cluster(settings$n_cores) |> multidplyr::cluster_library( packages = c("dplyr", "tidyr", "purrr", "rsofun") ) } ## Run predictions: df_model_predictions <- par_df |> do_if(parallel_flag, function(df) multidplyr::partition(df, cl)) %>% dplyr::mutate("sim" = purrr::map( pars, ~ run_pmodel_single_prediction( par = .x, par_fixed, targets, drivers, obs ) )) |> do_if(parallel_flag, function(df) dplyr::collect(df)) return(df_model_predictions) } apply_bias_correction_and_sample_error <- function(df_pred, N_sample_error) { set.seed(1982) df_pred |> # lazy_dt() |> # if needed, uses lazy data.table and dtplyr for speed # a) repeat lines: once for each sampled error # following line is basically a cross_join: with slice(rep(1:n(), each = N_sample_error)) |> mutate(error_sample_id = as.integer(rep(1:N_sample_error, n() / N_sample_error))) |> arrange(posterior_sample_id) |> # b) sample the error: group_by(err_par_sd, err_par_bias) |> mutate(err_sample = rnorm(n(), sd = err_par_sd), mod_biasremoved_no_err = mod_no_err - err_par_bias, mod_biasremoved_with_err = mod_no_err - err_par_bias + err_sample) |> ungroup() |> as_tibble() |> # to access results of lazy dtplyr-computation as normal tibble() # keep output light: i.e. remove unneded columns: select(-err_sample) |> mutate(across(where(is.character), as.factor)) } ``` With these functions we can generate the predictions using e.g. the Maximum-A-Posteriori (MAP) parameters as well as `n=20` samples from the posterior. ```{r make predictions, eval = FALSE} # get MAP and run model for MAP parameter set df_predict_MAP_train_231p <- run_prediction_rsofun( mcmc_posterior = out_calib_231p, prediction = "train", burnin_to_skip = 200, n_samples = 1, # n_samples == 1, requests MAP n_cores = 1) # this could be increased e.g. to 4 # get parameter samples and run model for thes parameter sets df_predict_train_231p <- run_prediction_rsofun( mcmc_posterior = out_calib_231p, prediction = "train", burnin_to_skip = 200, n_samples = 20, n_cores = 1) # this could be increased e.g. to 4 df_predict <- bind_rows( # df_predict_test |> mutate(is_train0_test1 = 1L, is_MAP = FALSE), df_predict_train_231p |> mutate(is_train0_test1 = 0L, is_MAP = FALSE), # df_predict_MAP_test |> mutate(is_train0_test1 = 1L, is_MAP = TRUE), df_predict_MAP_train_231p |> mutate(is_train0_test1 = 0L, is_MAP = TRUE) ) # Free memory: # rm(df_predict_train_231p, # df_predict_MAP_train_231p) # df_predict_params<- df_predict |> select(posterior_sample_id, is_train0_test1, is_MAP, pars) |> unnest(pars) df_predict_gpp <- df_predict |> select(posterior_sample_id, is_train0_test1, is_MAP, sim) |> unnest(sim) |> filter(target == "gpp") |> unnest(obs_metadata) df_predict_bigD13C <- df_predict |> select(posterior_sample_id, is_train0_test1, is_MAP, sim) |> unnest(sim) |> filter(target == "bigD13C") |> unnest(obs_metadata) # apply bias-correction and simulate structural error N_sample_error <- 3 df_predict_231p_gpp_sampled <- apply_bias_correction_and_sample_error( df_predict_gpp, N_sample_error = N_sample_error) df_predict_231p_bigD13C_sampled <- apply_bias_correction_and_sample_error( df_predict_bigD13C, N_sample_error = N_sample_error) ``` ```{r, eval = FALSE, include = TRUE} #| class.source = 'fold-hide' # Save predictions to plot with separate script: # FOR STORAGE REASONS unnest the obs_metadata and save different targets separately: out_prediction_template <- file.path("vignettes/files/param_calib_multitarget/", "predictions", "pred_231p_XXX.rds") dir.create(dirname(out_prediction_template), showWarnings = FALSE) fname_out_gpp <- gsub("_XXX", paste0( "_gpp_sampled_N", N_sample_error, "errors"), out_prediction_template) fname_out_bigD13C <- gsub("_XXX", paste0("_bigD13C_sampled_N", N_sample_error, "errors"), out_prediction_template) df_predict_231p_gpp_sampled |> select(posterior_sample_id,error_sample_id,is_train0_test1,is_MAP,sitename,target,obs, date,err_par_bias,err_par_sd,mod_no_err,mod_biasremoved_no_err,mod_biasremoved_with_err) |> saveRDS(fname_out_gpp, compress = "xz") df_predict_231p_bigD13C_sampled |> select(posterior_sample_id,error_sample_id,is_train0_test1,is_MAP,sitename,target,obs,id, err_par_bias,err_par_sd,mod_no_err,mod_biasremoved_no_err,mod_biasremoved_with_err) |> saveRDS(fname_out_bigD13C, compress = "xz") ``` ```{r, eval = TRUE, include = TRUE} #| class.source = 'fold-hide' # Load predictions for plotting with separate script: df_predict_231p_gpp_sampled <- readRDS("files/param_calib_multitarget//predictions/pred_231p_gpp_sampled_N3errors.rds") # NOTE: for rendering remove prefix "vignettes/" df_predict_231p_bigD13C_sampled <- readRDS("files/param_calib_multitarget//predictions/pred_231p_bigD13C_sampled_N3errors.rds") # NOTE: for rendering remove prefix "vignettes/" # df_predict_231p_bigD13C_sampled <- readr::read_rds(paste0("/storage/scratch/giub_geco/fbernhard/rsofun_doc_outputs/data/predictions/out_predict_",n_post,"_30000burnin__out_calib__scen222_DREAMzs-100000-0iter_8x3chains_on_CPU8x1_continued.rds_vj_sampled",n_err,".rds")) # df_predict_231p_gpp_sampled <- readr::read_rds(paste0("/storage/scratch/giub_geco/fbernhard/rsofun_doc_outputs/data/predictions/out_predict_",n_post,"_30000burnin__out_calib__scen222_DREAMzs-100000-0iter_8x3chains_on_CPU8x1_continued.rds_gpp_sampled",n_err,".rds")) ``` With a function `plot_predobs_gpp_timeseries3()` and `analyse_modobs3()`, model output and observations can be compared as either time series plots or as scatter plots. Below we plot a time series comparing predictions with observations, we can distinguish the parametric uncertainty ('Posterior') related to the width of the posterior distribtution of the parameters and structural uncertainty (or model uncertainty) that includes the samples of the error model. ```{r plot helper functions, eval = TRUE} #| class.source = 'fold-hide' # add transparency to colors t_col <- function(color, percent = 50, name = NULL) { # color = color name # percent = % transparency # name = an optional name for the color rgb.val <- col2rgb(color) # Get RGB values for named color ## Make new color using input color as base and alpha set by transparency t.col <- rgb( rgb.val[1], rgb.val[2], rgb.val[3], max = 255, alpha = (100 - percent) * 255 / 100, names = name) invisible(t.col) ## Save the color } ## copied from sofunCalVal package (and adapted to analyse_modobs3) analyse_modobs3 <- function(df, mod, obs, type = "points", filnam = NA, relative = FALSE, lower_xlim = 0, use_factor = NULL, shortsubtitle = FALSE, plot_subtitle = TRUE, plot_linmod = TRUE, pal = "batlowW", ...) { require(ggplot2) require(dplyr) # if (identical(filnam, NA)) filnam <- "analyse_modobs.pdf" ## rename to 'mod' and 'obs' and remove rows with NA in mod or obs df <- df %>% as_tibble() %>% ungroup() %>% dplyr::select(dplyr::all_of(c(mod = mod, obs = obs))) %>% tidyr::drop_na(mod, obs) ## get linear regression (coefficients) linmod <- lm(obs ~ mod, data = df) ## construct metrics table using the 'yardstick' library df_metrics <- df %>% yardstick::metrics(obs, mod) %>% dplyr::bind_rows(tibble(.metric = "n", .estimator = "standard", .estimate = summarise(df, numb = n()) %>% unlist())) %>% dplyr::bind_rows(tibble(.metric = "slope", .estimator = "standard", .estimate = coef(linmod)[2])) %>% # dplyr::bind_rows( tibble( .metric = "nse", .estimator = "standard", .estimate = hydroGOF::NSE( obs, mod, na.rm=TRUE ) ) ) %>% dplyr::bind_rows(tibble(.metric = "mean_obs", .estimator = "standard", .estimate = summarise(df, mean = mean(obs, na.rm = TRUE)) %>% unlist())) %>% dplyr::bind_rows(tibble( .metric = "prmse", .estimator = "standard", .estimate = dplyr::filter(., .metric == "rmse") %>% dplyr::select(.estimate) %>% unlist() / dplyr::filter(., .metric == "mean_obs") %>% dplyr::select(.estimate) %>% unlist() )) %>% dplyr::bind_rows(tibble( .metric = "pmae", .estimator = "standard", .estimate = dplyr::filter(., .metric == "mae") %>% dplyr::select(.estimate) %>% unlist() / dplyr::filter(., .metric == "mean_obs") %>% dplyr::select(.estimate) %>% unlist() )) %>% dplyr::bind_rows(tibble(.metric = "bias", .estimator = "standard", .estimate = dplyr::summarise(df, mean((mod - obs), na.rm = TRUE)) %>% unlist())) %>% dplyr::bind_rows(tibble(.metric = "pbias", .estimator = "standard", .estimate = dplyr::summarise(df, mean((mod - obs) / obs, na.rm = TRUE)) %>% unlist())) rsq_val <- df_metrics %>% dplyr::filter(.metric == "rsq") %>% dplyr::select(.estimate) %>% unlist() %>% unname() rmse_val <- df_metrics %>% dplyr::filter(.metric == "rmse") %>% dplyr::select(.estimate) %>% unlist() %>% unname() mae_val <- df_metrics %>% dplyr::filter(.metric == "mae") %>% dplyr::select(.estimate) %>% unlist() %>% unname() bias_val <- df_metrics %>% dplyr::filter(.metric == "bias") %>% dplyr::select(.estimate) %>% unlist() %>% unname() slope_val <- df_metrics %>% dplyr::filter(.metric == "slope") %>% dplyr::select(.estimate) %>% unlist() %>% unname() n_val <- df_metrics %>% dplyr::filter(.metric == "n") %>% dplyr::select(.estimate) %>% unlist() %>% unname() if (relative) { rmse_val <- rmse_val / mean(df$obs, na.rm = TRUE) bias_val <- bias_val / mean(df$obs, na.rm = TRUE) } rsq_lab <- format(rsq_val, digits = 2) rmse_lab <- format(rmse_val, digits = 3) mae_lab <- format(mae_val, digits = 3) bias_lab <- format(bias_val, digits = 3) slope_lab <- format(slope_val, digits = 3) n_lab <- format(n_val, digits = 3) results <- tibble(rsq = rsq_val, rmse = rmse_val, mae = mae_val, bias = bias_val, slope = slope_val, n = n_val) if (shortsubtitle) { subtitle <- bquote(italic(R)^2 == .(rsq_lab) ~ ~ RMSE == .(rmse_lab)) } else { subtitle <- bquote(italic(R)^2 == .(rsq_lab) ~ ~ RMSE == .(rmse_lab) ~ ~ bias == .(bias_lab) ~ ~ slope == .(slope_lab) ~ ~ italic(N) == .(n_lab)) } if (type == "hex") { upper_xlim <- round(max(quantile(df$mod, 0.9999), quantile(df$obs, 0.9999))) stopifnot(is.null(lower_xlim) || (is.numeric(lower_xlim) && length(lower_xlim) == 1)) lower_xlim <- ifelse(is.null(lower_xlim), round(min(quantile(df$mod, 0.0001), quantile(df$obs, 0.0001))), lower_xlim) ## ggplot hexbin gg <- df %>% ggplot2::ggplot(aes(x = mod, y = obs)) + geom_hex(bins = 50, show.legend = FALSE) + geom_abline(intercept = 0, slope = 1, linetype = "dotted") + # geom_hline(yintercept = 0, linetype = "dotted") + # geom_vline(xintercept = 0, linetype = "dotted") + coord_fixed() + xlim(lower_xlim, upper_xlim) + ylim(lower_xlim, upper_xlim) + theme_classic() + labs(x = mod, y = obs) if (pal == "batlowW") { gg <- gg + khroma::scale_fill_batlowW(trans = "log", reverse = TRUE) } else if (pal == "davos") { gg <- gg + khroma::scale_fill_davos(trans = "log", reverse = TRUE) } if (plot_subtitle) gg <- gg + labs(subtitle = subtitle) if (plot_linmod) gg <- gg + geom_smooth(method = "lm", color = "red", linewidth = 0.5, se = FALSE) if (!identical(filnam, NA)) { ggsave(filnam, width = 5, height = 5) } } return(list(df_metrics = df_metrics, gg = gg, linmod = linmod, results = results)) } plot_predobs_gpp_timeseries3 <- function(ts_to_plot) { # separate obs df_tsplot_gpp_obs <- ts_to_plot |> select(sitename, target, date, obs, Scenario, dataset) |> distinct() # compute stats of sampled distributions before plotting them tibble_to_plot <- ts_to_plot |> filter(target == "gpp") |> # dtplyr::lazy_dt() |> # THIS WAS NEEDED FOR THE FULL DATA.FRAME group_by(Scenario, dataset, parameters, sitename, target, date, model_output_type, y_facet) |> summarise( # mod_no_err_p50 = quantile(mod_no_err, 0.5), modelled_p50 = quantile(modelled, 0.5), modelled_p95 = quantile(modelled, 0.95), modelled_p05 = quantile(modelled, 0.05) ) |> as_tibble() |> # and bind back obsevations left_join( df_tsplot_gpp_obs, by = join_by(sitename, target, date, Scenario, dataset)) n_sites <- tibble_to_plot$sitename |> unique() |> length() pl_timeseries_gpp <- ggplot( data = tibble_to_plot, mapping = aes(x = date, y = modelled_p50)) + # Observations underneath (following Cameron 2022) geom_point( data = function(df) df |> select(-parameters, -model_output_type, -y_facet, -starts_with("modelled")) |> distinct(), # data = df_tsplot_gpp_obs, # variant 1, but does not allow %+%-replacement of underlying data mapping = aes(y = obs), color = "black", shape = 4, alpha = 0.5, size = 0.5) + # Structural uncertainty (including error model), a.k.a prediction band geom_ribbon( alpha = 0.5, data = function(df) df |> filter(model_output_type == "with struct. uncert."), mapping = aes(ymin = modelled_p05, ymax = modelled_p95, fill = "Post.+Error")) + # Parametric uncertainty (without error model, only parameter sampling), a.k.a confidence band geom_ribbon( alpha = 0.5, data = function(df) df |> filter(model_output_type == "rsofun"), mapping = aes(ymin = modelled_p05, ymax = modelled_p95, fill = "Posterior")) + geom_line( data = function(df) df |> filter(model_output_type == "rsofun"), mapping = aes(y = modelled_p50, color = "Posterior")) + # layout facet_wrap(~sitename, scales = "free_x") + theme_classic() + theme(legend.position = "bottom") + labs( x = NULL, #' Date', y = expression(paste("GPP (g C m"^-2, "s"^-1, ")")) ) + scale_fill_manual(NULL, aesthetics = c("colour", "fill"), breaks = c("Post.+Error", "Posterior"), values = c("Posterior" = t_col("#29a274ff"), "Post.+Error" = t_col("#777055ff")) ) + scale_x_date(date_breaks = "12 months", date_labels = "%Y-%m") } ``` ```{r plot Pred Vs Obs time series, eval = FALSE} # Time series of GPP (Figure C5) dflong_gpp_train <- df_predict_231p_gpp_sampled |> mutate(Scenario = "231") |> # select only training sites filter(is_train0_test1 == 0) |> select(posterior_sample_id, error_sample_id, Scenario, is_train0_test1, is_MAP, sitename, target, obs, date, # these are target specific observation_metadata mod_no_err, mod_biasremoved_no_err, mod_biasremoved_with_err) |> # pivot the model_output_types to long tidyr::pivot_longer(c(mod_no_err, mod_biasremoved_no_err, mod_biasremoved_with_err), names_to = "model_output_type", values_to = "modelled") |> mutate(model_output_type = factor( model_output_type, levels = c("mod_no_err", "mod_biasremoved_no_err", "mod_biasremoved_with_err"), labels = c("rsofun", "bias-corrected", "with struct. uncert."))) |> # derive column `parameters` ("MAP" or "Posterior") from `is_MAP` mutate(is_MAP = factor(ifelse(is_MAP, "MAP", "Posterior"))) |> rename(parameters = is_MAP) |> # derive column `dataset` ("train" or "test") from column `is_train0_test1` mutate(is_train0_test1 = factor(ifelse(is_train0_test1 == 1, "test", "train"))) |> rename(dataset = is_train0_test1) df_B3_timeseries <- dflong_gpp_train |> # remove the bias-corrected values for gpp since we did not fit a bias filter(!(model_output_type %in% c("bias-corrected"))) |> # select what to plot and how to name it mutate(y_facet = case_when( Scenario == "231" & model_output_type == "rsofun" & parameters == "MAP" ~ "MAP", Scenario == "231" & model_output_type == "rsofun" & parameters == "Posterior" ~ "Posterior", Scenario == "231" & model_output_type == "with struct. uncert." & parameters == "Posterior" ~ "Post.+Error", # all else is not plotted TRUE ~ "remove") |> factor(levels = c("MAP", "Posterior", "Post.+Error"))) |> filter(y_facet != "remove") # Plot raw predictions pl_timeseries_gpp <- plot_predobs_gpp_timeseries3(df_B3_timeseries) pl_timeseries_gpp ``` ```{r eval = FALSE, include = FALSE} # store output since prediction isn't run when vignette is rendered ggplot2::ggsave( filename = "vignettes/files/param_calib_multitarget/predictions/pl_timeseries_gpp.png", plot = pl_timeseries_gpp, width = 7, height = 7, dpi = 100) ``` ```{r eval = TRUE, include = TRUE} # fake output since prediction was run previous to vignette rendering knitr::include_graphics("files/param_calib_multitarget/predictions/pl_timeseries_gpp.png", # NOTE: for rendering remove prefix "vignettes/" dpi = 100) ``` Below we show a density scatter plot comparing predictions with observations. Since we use the MAP values for predictions, the uncertainty of the parameters is not taken into account. These plots are useful to detect model biases for specific target variables or specific ranges of the target variables. ```{r plot Pred Vs Obs scatter plot, eval = FALSE} gpp_labs <- function(xNULL = FALSE) { list(labs(x = ifelse(xNULL, " ", "Predicted GPP (g C m-\u00B2 s-\u00B9)"), y = "Observed GPP (g C m-\u00B2 s-\u00B9)")) } bigD13C_labs <- function(xNULL = FALSE) { list(labs(x = ifelse(xNULL, " ", "Predicted Δ (\u2030)"), y = "Observed Δ (\u2030)")) } # compute scatters (and skills) for MAP of test set: list_of_scenarios_to_loop_over <- list( s231 = list("bigD13C" = df_predict_231p_bigD13C_sampled, "gpp" = df_predict_231p_gpp_sampled) ) parameter_set <- "MAP" list_of_scatters <- lapply(list_of_scenarios_to_loop_over, function(list_of_targets) { lapply(list_of_targets, function(df_target_prediction) { curr_target <- first(df_target_prediction$target) # (MAP, Posterior, Posterior+Error) # MAP: is posterior_sample_id==0 # e.g. filter(df_gpp, is_train0_test1 == 1, is_MAP, error_sample_id==1) # Posterior: just take one error sampling, but all (~25 posterior samples) # e.g. filter(df_gpp, is_train0_test1 == 1, error_sample_id==1), # Posterior+Error: take all errors # e.g. filter(df_gpp, is_train0_test1 == 1, error_sample_id>=1) df <- df_target_prediction %>% { if (parameter_set == "MAP") filter(., is_MAP) else . } %>% filter(error_sample_id == 1) # , is_train0_test1 == 1 # TODO: why was this removed? mod <- "mod_biasremoved_no_err" lower_xlim <- ifelse(curr_target == "gpp", list(0), list(NULL))[[1]] if (nrow(df) == 0) { return(list(gg = ggplot() + theme_void())) } else { return(analyse_modobs3(df, mod = mod, obs = "obs", type = "hex")) } }) }) ### single comparison plot: ---- mark_as_target <- theme(panel.background = element_rect(fill = t_col("darkgreen", 80))) pl_scatter_comparison <- cowplot::plot_grid( list_of_scatters$s231$bigD13C$gg + mark_as_target + bigD13C_labs(), list_of_scatters$s231$gpp$gg + mark_as_target + gpp_labs() ) pl_scatter_comparison ``` ```{r eval = FALSE, include = FALSE} # store output since prediction isn't run when vignette is rendered ggplot2::ggsave( filename = "vignettes/files/param_calib_multitarget/predictions/pl_scatter_comparison.png", plot = pl_scatter_comparison, width = 7, height = 4, dpi = 150) ``` ```{r eval = TRUE, include = TRUE} # fake output since prediction was run previous to vignette rendering knitr::include_graphics("files/param_calib_multitarget/predictions/pl_scatter_comparison.png", # NOTE: for rendering remove prefix "vignettes/" dpi = 150) ``` The above vignette hopefully gives a first glimpse into a realistic workflow that calibrates `rsofun` for multiple sites to multiple targets. For more details refer to the model documentation paper [(Paredes et al. 2025)](https://doi.org/10.5194/gmd-18-9855-2025) and the code referred therein.