Wageningen University & Research | FEM-31806 | Models for Ecological Systems | FEM | PPS | WEC

Knowledge questions

Why does a model that is too simple not perform well (i.e. yield a good prediction)?

A model that is too simple often does not perform well because it cannot capture the true structure of the system: it systematically misses important structure.

Why does a more complex model not necessarily predict better than a simpler version of the model?

A more complex model sounds like it should perform better: more parameters, more flexibility, more ability to capture patterns. But in practice, complexity can easily become a liability, because a more complex model also will involve more parameters, which likely will have uncertainty of themselves, and which may thus need to be estimated using (perhaps limited) data. Everything else being equal, properly calibrating a more complex model is more challenging than calibrating a simple model with fewer parameters. A model can even be too complex, in that multiple components/parameters capture the same pattern, leading to identifiability (and thus calibration) issues. According to the KISS principle, a model should be kept as simple as possible - avoiding unnecessary complexity - because simpler models are easier to understand, calibrate, and often generalize better.

Suppose that a team of scientists designed a model, and calibrated the parameters in the model using a particular existing dataset. In order to test the predictive performance of the model they use the same dataset for model evaluation. Is this a wise thing to do? If not, how could they better perform model evaluation?

Using the same dataset for both calibrating and evaluating a model is not a wise idea. It gives an overly optimistic impression of performance because the model is being tested on data it has already seen (during calibration). A better approach is to evaluate the model on data that was not used during calibration.

Exercise 8.1

In this practical, we continue with the model, data and scripts used during yesterday’s practical (exercise 7.1). Besides the information given in exercise 7.1, Template 6 may come in handy today! A short recap on the previous exercise: we will thus use the logistic growth function again; where the rate of change of the population NN is given by:

dNdt=rN(1NK)\frac{dN}{dt} = r * N * (1 - \frac{N}{K})

where the parameter rr is the intrinsic (relative) growth rate and KK is the carrying capacity, which is not constant over time, but relates in a linear fashion to a driving variable RR (e.g. the amount of resources available):

K=k0+k1RK = k_0 + k_1 * R

Assume that all parameter values, state variables and driving variables have values > 0. As in exercise 7.1, assume that our initial values are as follows N(time=0)=5N_{(time=0)}=5, r=0.25r=0.25, k0=20k_0=20 and k1=0.2k_1=0.2. As before, measurements (at discreate points in time) on NN and RR are as follows:

surveyData <- data.frame(t = 0:50,
                         R = c(84,82,53,67,74,123,150,136,92,105,120,136,144,117,
                               120,136,125,93,108,123,113,149,112,112,51,64,109,
                               112,98,115,90,102,65,50,63,66,125,150,121,120,125,
                               120,121,139,113,96,115,82,97,101,85),
                         N = c(5,7,8,10,13,14,20,23,27,30,35,44,46,50,49,56,54,55,
                               57,56,58,62,58,54,47,47,42,47,48,48,51,54,51,43,36,
                               36,37,39,51,50,55,48,53,61,62,61,52,55,53,47,48)) 
8.1 a
Create a new R script and implement the above-mentioned model (you are allowed to use your script from yesterday and/or the answers of yesterday’s exercises given) such that you can reproduce the plot of Exercise 7.1e):

# Load dataset
surveyData <- data.frame(t = 0:50,
                         R = c(84,82,53,67,74,123,150,136,92,105,120,136,144,117,
                               120,136,125,93,108,123,113,149,112,112,51,64,109,
                               112,98,115,90,102,65,50,63,66,125,150,121,120,125,
                               120,121,139,113,96,115,82,97,101,85),
                         N = c(5,7,8,10,13,14,20,23,27,30,35,44,46,50,49,56,54,55,
                               57,56,58,62,58,54,47,47,42,47,48,48,51,54,51,43,36,
                               36,37,39,51,50,55,48,53,61,62,61,52,55,53,47,48))

# Define a function that returns a named vector with initial state values
InitLogistic <- function(N = 5) {
  # Gather function arguments in a named vector
  y <- c(N = N)
  
  # Return the named vector
  return(y)
}

# Define a function that returns a named vector with parameter values
ParmsLogistic <- function(r  = 0.25, # intrinsic population growth rate
                          k0 = 20,   # intercept of the carrying capacity
                          k1 = 0.2   # slope of the carrying capacity with R
                          ) {
  # Gather function arguments in a named vector
  y <- c(r = r,
         k0 = k0,
         k1 = k1)
  
  # Return the named vector
  return(y)
}

# Define the forcing function using the data as loaded above
R_linear <- approxfun(x = surveyData$t, y = surveyData$R, method = "linear", rule = 2)

# Define a function for the rates of change of the state variables with respect to time
RatesLogistic <- function(t, y, parms) {
  with(
    as.list(c(y, parms)),{
      
      # Optional: forcing functions: get value of the driving variables at time t (if any)
      R <- R_linear(t)
      
      # Optional: auxiliary equations
      K <- k0 + k1*R
      
      # Rate equations
      dNdt <- r * N * (1 - N / K)
      
      # Gather all rates of change in a vector
      RATES <- c(dNdt = dNdt)
      
      # Optional: get in/out flow used to compute mass balances (or set MB <- NULL)
      MB <- NULL
      
      # Optional: gather auxiliary variables that should be returned (or set AUX <- NULL)
      AUX <- c(R = R,
               K = K)
      
      # Return result as a list
      outList <- list(c(RATES, # the rates of change of the state variables (same order as 'y'!)
                        MB),   # the rates of change of the mass balance terms (or NULL)
                      AUX)     # optional additional output per time step
      return(outList)
    }
  )
}

# Numerically solve the model using the ode() function
states <- ode(y = InitLogistic(),
              times = surveyData$t,
              func = RatesLogistic,
              parms = ParmsLogistic(),
              method = "ode45")

# Plot the results
plot(states[,"time"], states[,"N"], type = "l", xlab = "time", ylab = "N", ylim = c(0, 75))
points(surveyData$t, surveyData$N)

8.1 b
Define a function (called predictionError) that computes the prediction error: the model’s prediction (given parameter values and initial condition) minus the observed data (observations at specified points in time). The function should thus first solve the model numerically (for this you will need at least 4 inputs: the parameter values, the initial state values, the times at which you want the output, and the function returning the rates of change of the state variables). Then, you need to subtract the observed value of the state NN from the predicted value of state NN at all points in time for which you have measurements. Therefore: request model output for the times listed in surveyData.

# Define a function that computes prediction errors
predictionError <- function(p,          # the parameters
                            y0,         # the initial conditions
                            fn,         # the function with rate equation(s)
                            name,       # the name of the state parameter
                            obs_values, # the measured state values
                            obs_times,  # the time-points of the measured state values
                            out_time = seq(from = 0, # times for the numerical integration
                                           to = max(obs_times), # default: 0 - max(obs_times)
                                           by = 1) # in steps of 1
                            ) {
  # Get out_time vector to retrieve predictions for (combining 'obs_times' and 'out_time')
  out_time <- unique(sort(c(obs_times, out_time)))
  
  # Solve the model to get the prediction
  pred <- ode(y = y0,
              times = out_time,
              func = fn,
              parms = p,
              method = "ode45")
  # NOTE: in case of a 'stiff' problem, method "ode45" might result in an error that the max nr of
  # iterations is reached. Using method "rk4" (fixed timestep 4th order Runge-Kutta) might solve this.
  
  # Get predictions for our specific state, at times t
  pred_t <- subset(pred, time %in% obs_times, select = name)
  
  # Compute errors, err: prediction - obs
  err <- pred_t - obs_values
  
  # Return
  return(err)
}
8.1 c
Compute the prediction errors (using the function as defined in exercise b) of the implemented model given the above-mentioned initial parameter values. Plot the prediction errors as function of time.

# Compute prediction error
err <- predictionError(p = ParmsLogistic(),
                       y0 = InitLogistic(),
                       fn = RatesLogistic,
                       name = "N",
                       obs_values = surveyData$N,
                       obs_times = surveyData$t)

# Plot prediction error over time
plot(surveyData$t, err, xlab = "time", ylab = "prediction error")

As can be seen here, but also already in the plot in exercise 8.1a, the model at its default values does have a large error, and systematically underestimates the population’s size.

8.1 d
Compute the Root Mean Squared Error (RMSE; see table 8.1 in the syllabus) of the predictions errors.

sqrt(mean(err^2))
## [1] 11.21278

Similar to the above, we see a poor model fit (RMSE is ca. 11.21). Relative to the average population size (say 50), this error is large!

8.1 e

Define a function called predRMSE that computes the RMSE when you provide it with at least the following information:

  1. Parameter values, which are unconstrained in their values (i.e. they can have any value in the range -\infty to \infty);
  2. Initial state values;
  3. The function calculating the rates of change;
  4. The observations on population size N as given in surveyData;
  5. The points in time at which these observations were conducted.

# Define a function that computes the Root Mean Squared Error, RMSE
# NOTE: nothing needs to be filled in here, so this function can be used as-is
predRMSE <- function(...) { # NOTE: these ... are ellipsis, so DO NOT fill in something here!
  # Get prediction errors
  err <- predictionError(...) # NOTE: these ... are ellipsis, so DO NOT fill in something here!
  
  # Compute the measure of model fit (here the Root Mean Squared Error)
  RMSE <- sqrt(mean(err^2))
  
  # Return the measure of model fit
  return(RMSE)
}
8.1 f
Test the function defined in (e) by calling the function with the appropriate input. Do you get the same result as the RMSE computed in (d)? If not: what would be the cause, and is the difference sufficiently small?

Here, we call the function predRMSE while passing the input arguments via the 3-dot ellipsis ...:

predRMSE(p = ParmsLogistic(),
         y0 = InitLogistic(),
         fn = RatesLogistic,
         name = "N",
         obs_values = surveyData$N,
         obs_times = surveyData$t)
## [1] 11.21278

Indeed, here we get the same answer (11.21) as in exercise d)!

8.1 g

Calibrate the values of all parameters in the model. Use the information given in Template 6. Thus, in order to calibrate the model’s parameter:

  1. Define a function called transformParameters that allows transformation, and back-transformation, the model’s parameters;
  2. Define a function called calibrateParameters that performs the numerical optimization of the vector of parameter values using the optim routine in R;
  3. Call/use the function calibrateParameters with the appropriate inputs, e.g. the names of the parameters whose values need to be calibrated, and the transformations for each parameter. See the section “An example with explanation” of Template 6! Note that the function call may take some time (seconds, minutes) to do the optimization for you (you may want to stretch your legs, take some tea / coffee etc :-).
  4. When the optimization was successfully done, inspect the model fit object by printing it to the screen/console. Also retrieve the element named par from the model fit object. What are the calibrated parameter values? How do these relate to the original parameter values?

We first define the function to allow for (back-)transformations:

# Define a function that allows for transformation and back-transformation
transformParameters <- function(parm, trans, rev = FALSE) {
  # Check transformation vector
  trans <- match.arg(trans, choices = c("none","logit","log"), several.ok = TRUE)
  
  # Get transformation function per parameter
  if(rev == TRUE) {
    # Back-transform from real scope to parameter scope
    transfun <- sapply(trans, switch,
                       "none" = mean,
                       "logit" = plogis,
                       "log" = exp)
  }else {
    # Transform from parameter scope to real scope
    transfun <- sapply(trans, switch,
                       "none" = mean,
                       "logit" = qlogis,
                       "log" = log)
  }
  
  # Apply transformation function
  y <- mapply(function(x, f){f(x)}, parm, transfun)
  
  # Return
  return(y)
}

Then, we define the function to calibrate parameters, i.e. the function that optimizes the model using the optim function:

# Define a function that performs the calibration of specified parameters
# NOTE: nothing needs to be filled in here, so this function can be used as-is
calibrateParameters <- function(par,  # parameters
                                init, # initial state values
                                fun,  # function that returns the rates of change
                                stateCol, # name of the state variable
                                obs,  # observed values of the state variable
                                t,    # timepoints of the observed state values
                                calibrateWhich, # names of the parameters to calibrate
                                transformations, # transformation per calibration parameter
                                times = seq(from = 0, # times for the numerical integration
                                            to = max(t), # default: 0 - max(t)
                                            by = 1), # in steps of 1
                                ... # NOTE: these ... are ellipsis, so DO NOT fill in something here!
                                # these are the optional extra arguments past on to optim()
                                ) {
  # check names of parameters that need calibration
  calibrateWhich <- match.arg(calibrateWhich, choices = names(par), several.ok = TRUE)
  
  # Create vector with transformations for all parameters (set to "none")
  tranforms <- rep("none", length(par))
  names(tranforms) <- names(par) # set the names of each transformation
  
  # Overwrite the elements for parameters that need calibration
  tranforms[calibrateWhich] <- transformations
  
  # Transform parameters
  par <- transformParameters(parm = par, trans = tranforms, rev = FALSE)
  
  # Get parameters to be estimated
  par_fit <- par[calibrateWhich]
  
  # Specify the cost function: the function that will be optimized for the parameters to be estimated
  costFunction <- function(parm_optim, parm_all, 
                           init, fun, stateCol, obs, t,
                           times = t,
                           optimNames = calibrateWhich,
                           transf = tranforms,
                           ... # NOTE: these ... are ellipsis, so DO NOT fill in something here!
                           ) {
    # Gather parameters (those that are and are not to be estimated)
    par <- parm_all
    par[optimNames] <- parm_optim[optimNames]
    
    # Back-transform
    par <- transformParameters(parm = par, trans = transf, rev = TRUE)
    
    # Get RMSE
    y <- predRMSE(p = par, y0 = init, fn = fun, 
                  name = stateCol, obs_values = obs, obs_times = t, 
                  out_time = times)
    
    # Return
    return(y)
  }
  
  # Fit using optim
  fit <- optim(par = par_fit,
               fn =  costFunction,
               parm_all = par, 
               init = init, 
               fun = fun,
               stateCol = stateCol,
               obs = obs,
               t = t,
               times = times,
               ...) # NOTE: these ... are ellipsis, so DO NOT fill in something here!
  # These are the optional additional arguments passed on to optim()
  
  # Gather parameters: all parameters updated with those that are estimated
  y <- par
  y[calibrateWhich] <- fit$par[calibrateWhich]
  
  # Back-transform
  y <- transformParameters(parm = y, trans = tranforms, rev = TRUE)
  
  # put all (constant and estimated) parameters back in $par
  fit$par <- y
  
  # Add the calibrateWhich information
  fit$calibrated <- calibrateWhich
  
  # return 'fit' object:
  return(fit)
}

Now, we can actually calibrate the model parameters. Note that this step may take some time (seconds, minutes):

# Perform the calibration
fit <- calibrateParameters(par = ParmsLogistic(),
                           init = InitLogistic(),
                           fun = RatesLogistic,
                           stateCol = "N",
                           obs = surveyData$N,
                           t = surveyData$t,
                           calibrateWhich = c("r","k0","k1"),
                           transformations = c("log","log","log"))

We can Explore the model fit, first by looking at the object fit:

fit
## $par
##         r        k0        k1 
## 0.3027599 4.1744295 0.4617469 
## 
## $value
## [1] 2.584874
## 
## $counts
## function gradient 
##      502       NA 
## 
## $convergence
## [1] 1
## 
## $message
## NULL
## 
## $calibrated
## [1] "r"  "k0" "k1"

First, let’s look at the estimated parameter values (the element $par in object fit), which we can compare the values of the original parameters and the fitted ones:

# Original parameter values:
ParmsLogistic()
##     r    k0    k1 
##  0.25 20.00  0.20
# Calibrated parameter values:
fit$par
##         r        k0        k1 
## 0.3027599 4.1744295 0.4617469

Compared to the initial values, parameter k1k_1 is more than doubled, while parameter k0k_0 is now much lower!

What we effectively do here is that we calibrate the entire set of model parameters in 1 analyses: we use the optim function to find the vector of parameter values (i.e. the combination of parameter values) that minimizes the RMSE of the predictions. Like in statistical regression analysis: we can fit a line through any cloud of points, even if the line does not represent the shape of the cloud of points faithfully! Also here in calibration: we can ‘fit’ the values of all parameters that we want to estimate, and if we do the fitting correctly we will get an answer: the combination of values of the parameters that maximizes the fit of our prediction compared to the observations in the training/calibration dataset.

Note that this does not guarantee that those values make sense! Namely, errors in the design and implementation of the model can be masked in the step of calibration: the optimization algorithm will find the vector of parameter values that minimizes the prediction error (and thus maximizes the fit!), even if it means that parameters get values beyond a reasonable range. It is thus always good practice to inspect the fitted values of the parameters, and assess whether they are within the known ranges of parameter (un)certainty?

Moreover, when using numerical optimization as done here (using the optim function), we are not guaranteed that the model fit is converging (within the number of iterations that we allow the optimization to do its work). The default optimization method of the optim function is called “Nelder-Mead”, and the default number of optimization iterations it is allowed to do is 500 (see the documentation of the optim) function. In the output of the fit in element $counts we see that it did 502 function evaluations (500 plus a few it always need to do). Also, in the $convergence element we see the value 1, which means (see optim function documentation) that the iteration limit maxit had been reached (and the model did not yet converge fully; the estimate could still be good though!). Even though we have nicely approximated the model’s parameter values, we could perhaps do a better job when increasing the number of iterations. Because the calibrateParameters has the ... three dots ellipsis build in, we can pass on extra arguments to the optim function call, e.g. by specifying that another optimization method should be used (here as example “SANN”, simulated annealing), and specifying a control argument that allows us to increase the maximum number of iterations (maxit). Let’s do so with 1000 iterations:

# Calibration with a higher number of maximum iterations allowed
fit <- calibrateParameters(par = ParmsLogistic(),
                           init = InitLogistic(),
                           fun = RatesLogistic,
                           stateCol = "N",
                           obs = surveyData$N,
                           t = surveyData$t,
                           calibrateWhich = c("r","k0","k1"),
                           transformations = c("log","log","log"),
                           method = "SANN",
                           control = list(maxit = 1000))
8.1 h
Numerically solve the model with the calibrated parameter values and plot the result (plot both the predicted as well as observed / measured values of NN). Also compute the RMSE for the calibrated model, using the function defined earlier.

# Fit the model using the calibrate parameter values
statesFitted <- ode(y = InitLogistic(),
                    times = surveyData$t,
                    parms = fit$par,
                    func = RatesLogistic,
                    method = "ode45")

# Plot the results
plot(surveyData$t, surveyData$N, xlab="time", ylab="N")
lines(statesFitted[,"time"], statesFitted[,"N"], col=2, lwd=2)

# For clarity, let's add a legend:
legend(x="topleft", bty="n", lty=c(NA,1), pch=c(1,NA), legend=c("observation","prediction"))

According to this plot, the model with calibrated parameter values fits the measured data much better! this should also be visible in the RMSE, so let’s confirm this by computing the RMSE (we do the same as in exercise f, but now using the calibrated parameter values):

predRMSE(p = fit$par,
         y0 = InitLogistic(),
         fn = RatesLogistic,
         name = "N",
         obs_values = surveyData$N,
         obs_times = surveyData$t)
## [1] 2.584874

Indeed, we have reduced the RMSE to a value ca 2.58 via calibration. Thus, the calibrated model now fits the data (that was used for calibration) much better!

8.1 i
Is the RMSE measure as computed in (h) a good way of evaluating the model’s predictive performance? Why, or why not?

No: the model is now evaluated on the same data that was used for calibration. This should definitely not be done! The data used in calibration and evaluation should be independent! Without independent datasets, the risk of overfitting increases, and therefore we run the risk of falsely believing we have a very good explanatory model whereas in fact we have not. Here, the RMSE computed is only indicating how good the model was fitted to the calibration data! If we were to properly perform model evaluation, we would need 2 independent datasets (independent here could mean that the datasets could be from different sites, or from different time periods).

Solutions as R script

Download here the code as shown on this page in a separate .r file.