Parameter validation

Learning objectives:

  • Learn how to avoid accidental creation of parameters.
  • Validate parameter values (e.g., range) to ensure they meet expected criteria.

Pre-reading:

This page describes how to validate functions/classes introduced on:

Required packages:

These are part of the standard Python library, will be available in any base environment by default.

from collections import UserDict
import inspect

These should be available from environment setup in the “🧪 Test yourself” section of Environments.

library(jsonlite)
library(R6)

🚨 Accidental creation of new parameters

The problem

If you mistype a parameter name when defining parameters, no error is raised. Instead, a new (unused) parameter is created. This means your intended parameter remains unchanged, which can silently invalidate your results.

class Params:
  __slots__ = ["transfer_prob"]
  def __init__(self, transfer_prob):
    self.transfer_prob = transfer_prob
  def __repr__(self):
    return f"Params(transfer_prob={self.transfer_prob})"


params = Params(0.3)
params.transfer_prob = 0.4

try:
    params.transfer_probs = 0.7
except AttributeError as e:
    print(e)
'Params' object has no attribute 'transfer_probs'
print(params)
Params(transfer_prob=0.4)

Function example

Let’s start with a function that returns a dictionary of parameters:

# pylint: disable=missing-module-docstring
def param_function(transfer_prob=0.3):
    """
    Returns transfer_prob for validation example.

    Parameters
    ----------
    transfer_prob : float
        Transfer probability (0-1).

    Returns
    -------
    Dictionary containing the transfer_prob parameter.
    """
    return {"transfer_prob": transfer_prob}

This gives:

# Create dictionary using the function
params = param_function()
print(params)
{'transfer_prob': 0.3}

Now suppose we meant to update transfer_prob but made a typo:

params["transfer_probs"] = 0.4
print(params)
{'transfer_prob': 0.3, 'transfer_probs': 0.4}

Notice what happened: instead of updating the original value, Python added a new key (transfer_probs) and left the real parameter unchanged.

Class example

The same issue occurs with attributes in a class:

# pylint: disable=missing-module-docstring, invalid-name
# pylint: disable=too-few-public-methods
class ParamClass:
    """
    Returns transfer_prob for validation example.
    """
    def __init__(self, transfer_prob=0.3):
        """
        Initialise ParamClass instance.

        Parameters
        ----------
        transfer_prob : float
            Transfer probability (0-1).
        """
        self.transfer_prob = transfer_prob

This gives:

# Create an instance
params = ParamClass()
print(params.__dict__)
{'transfer_prob': 0.3}

Now let’s introduce a typo:

params.transfer_probs = 0.4
print(params.__dict__)
{'transfer_prob': 0.3, 'transfer_probs': 0.4}

Again, no error was raised - we’ve simply created a new attribute (transfer_probs) while the intended one (transfer_prob) remains untouched.

If you mistype a parameter name when defining parameters for a function, no error is raised. Instead, a new (unused) parameter is created. This means your intended parameter remains unchanged, which can silently invalidate your results.

Let’s start with a function that returns a list of parameters:

#' Returns transfer_prob for validation example.
#'
#' @param transfer_prob Numeric. Transfer probability (0-1).
#'
#' @return A named list containing the transfer_prob parameter.

param_function <- function(transfer_prob = 0.3) {
  list(transfer_prob = transfer_prob)
}

This gives:

# Create list using the function
params <- param_function()
print(params)
$transfer_prob
[1] 0.3

Now suppose we meant to update transfer_prob but made a typo:

params$transfer_probs <- 0.4
params
$transfer_prob
[1] 0.3

$transfer_probs
[1] 0.4

Notice what happened: instead of updating the original value, R has add a new item (transfer_probs) and left the real parameter unchanged.

Note: This issue doesn’t occur when using R6 classes, as they prevent adding new fields by default. However, if you extract the parameter list from an R6 class and modify that list separately, you’ll encounter the same silent error problem. For this reason, it’s better to modify parameters through the class interface rather than extracting and modifying the underlying list.

#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    }
  )
)
# Create instance of ParamClass
params <- ParamClass$new()

# Mistype transfer_prob
try({
  params$transfer_probs <- 0.4
})
Error in params$transfer_probs <- 0.4 : 
  cannot add bindings to a locked environment

The solution

We’ve suggested four approaches to prevent these silent failures, depending on the object being used to store parameters:

Our parameter function returns a dictionary. While you could validate arguments within the parameter function, this would not prevent accidental modification after the dictionary is returned.

When calling the function, you are restricted to the defined arguments - so extra entries cannot be added at that stage. However, once the dictionary is generated, it can be altered by adding or removing entries. For this reason, we incorporate validation into the model function (rather than the parameter function).

As a reminder, this is our parameter function:

# pylint: disable=missing-module-docstring
def param_function(transfer_prob=0.3):
    """
    Returns transfer_prob for validation example.

    Parameters
    ----------
    transfer_prob : float
        Transfer probability (0-1).

    Returns
    -------
    Dictionary containing the transfer_prob parameter.
    """
    return {"transfer_prob": transfer_prob}

We can write a validation function which checks that all the required parameters are present, and that no extra parameters are provided.

def check_param_names(param_dict, param_function):
    """
    Validate parameter names.

    Ensure that all required parameters are present,
    and no extra parameters are provided.

    Parameters
    ----------
    param_dict : dict
        Dictionary containing parameters for the simulation.
    param_function : function
        Function used to generate the parameter dictionary.
    """
    # Get the set of valid parameter names from the function signature
    valid_params = set(inspect.signature(param_function).parameters)

    # Get the set of input parameter names from the provided dictionary
    input_params = set(param_dict)

    # Identify missing and extra parameters
    missing = valid_params - input_params
    extra = input_params - valid_params

    # If there are any missing or extra parameters, raise an error message
    if missing or extra:
        raise ValueError("; ".join([
            f"Missing keys: {', '.join(missing)}" if missing else "",
            f"Extra keys: {', '.join(extra)}" if extra else ""
        ]).strip("; "))

Then, in our model function, we call the relevant validation function to check all inputs before proceeding with the simulation:

def model(param_dict, param_function):
    """
    Run simulation after validating parameter names.

    Parameters
    ----------
    param_dict : dict
        Dictionary of parameters.
    param_function : function
        Function used to generate the parameter dictionary.
    """
    # Check all inputs are valid
    check_param_names(param_dict=param_dict, param_function=param_function)

    # Simulation code...


# Example usage
# No extra or missing parameters - model runs without issue
params = param_function()
model(params, param_function)

# Mistype transfer_prob - model returns an error
params["transfer_probs"] = 0.4
try:
    model(params, param_function)
except ValueError as e:
    print(e)
Extra keys: transfer_probs

The simplest way to prevent accidental attribute creation in classes is by using __slots__.

__slots__ tells Python to allocated fixed memory slots for the listed attributes instead of using a flexible dictionary (__dict__). This means you cannot add new attributes beyond those listed in __slots__.

This approach works best with smaller parameter sets. While you could use __slots__ with larger parameter lists, it becomes verbose and harder to maintain - you’d have a long list of parameter names that makes the code less readable.

class Param:
    __slots__ = ["transfer_prob", "service_time", "arrival_rate"]
    
    def __init__(self, transfer_prob=0.3, service_time=2.5, arrival_rate=1.2):
        self.transfer_prob = transfer_prob
        self.service_time = service_time
        self.arrival_rate = arrival_rate


# Example usage
params = Param()

# Successfully modify existing attributes
params.transfer_prob = 0.4

# Attempts to add new attributes raise an error
try:
    params.transfer_probs = 0.7  # typo!
except AttributeError as e:
    print(f"Error: {e}")
Error: 'Param' object has no attribute 'transfer_probs'

For more complex set-ups - for example, when you have multiple parameter classes or lots of individual parameters - writing some custom attribute validation provides more flexibility, as we can lock down to all attributes in class design without needing to list by name.

We can implement this validation either:

  • Directly within the class using custom __setattr__ methods.
  • Using inheritance from a base validation class (recommended if you have multiple parameter classes, or want to keep the parameter class simpler/cleaner/no validation logic in it).

Direct implementation within the class

This approach implements validation logic directly within the class using a custom __setattr__ method.

class Param:
    """
    Parameter class with validation to prevent the addition of new attributes.
    """
    def __init__(self, param1="test", param2=42):
        """
        Initialise Param instance.
        """
        # Disable restriction during initialisation
        object.__setattr__(self, "_initialising", True)

        # Set the attributes
        self.param1 = param1
        self.param2 = param2

        # Re-enable attribute checks after initialisation
        object.__setattr__(self, "_initialising", False)

    def __setattr__(self, name, value):
        """
        Prevent addition of new attributes.

        This method overrides the default `__setattr__` behavior to restrict
        the addition of new attributes to the instance. It allows modification
        of existing attributes but raises an `AttributeError` if an attempt is
        made to create a new attribute. This ensures that accidental typos in
        attribute names do not silently create new attributes.

        Parameters
        ----------
        name : str
          The name of the attribute to set.
        value : Any
          The value to assign to the attribute.

        Raises
        -------
        AttributeError:
            If `name` is not an existing attribute and an attempt is made
            to add it to the instance.
        """
        # Skip validation if still initialising
        if hasattr(self, "_initialising") and self._initialising:
            super().__setattr__(name, value)
        else:
            # Check if attribute already exists
            if name in self.__dict__:
                super().__setattr__(name, value)
            else:
                raise AttributeError(
                    f"Cannot add new attribute '{name}' - only possible to "
                    f"modify existing attributes: {self.__dict__.keys()}"
                )


# Example usage...

# Create an instance of the class
params = Param()

# Successfully modify an existing attribute
params.param1 = "newtest"

# Attempts to add new attributes should raise an error
try:
    params.new_attribute = 3
except AttributeError as e:
    print(f"Error: {e}")
Error: Cannot add new attribute 'new_attribute' - only possible to modify existing attributes: dict_keys(['_initialising', 'param1', 'param2'])

Using class inheritance

Inheritance allows you to create a “blueprint” class that other classes can build upon, inheriting all the methods and attributes from the parent class. This is useful when you have multiple parameter classes that need the same validation logic. For a longer introduction to inheritance, check out the inheritance section on the code organisation page.

Here, we have three classes, with each subsequent class inheriting from those above:

  1. RestrictAttributesMeta (metaclass).
  2. RestrictAttributes (parent/base class).
  3. Param (child/derived class).

RestrictAttributesMeta (metaclass): A metaclass controls how classes and instances are created. In this case, it adds an _initialised flag after __init__ completes.

class RestrictAttributesMeta(type):
    """
    Metaclass for attribute restriction.

    A metaclass modifies class construction. It intercepts instance creation
    via __call__, adding the _initialised flag after __init__ completes. This
    is later used by RestrictAttributes to enforce attribute restrictions.
    """
    def __call__(cls, *args, **kwargs):
        # Create instance using the standard method
        instance = super().__call__(*args, **kwargs)
        # Set the "_initialised" flag to True, marking end of initialisation
        instance.__dict__["_initialised"] = True
        return instance

Are you new to metaclasses? Checkout this video from 2MinutesPy on YouTube for a more detailed explanation:

RestrictAttributes (parent/base class): A parent or base class controls the behaviour (but doesn’t impact initialisation). Here, the class inherits from RestrictAttributesMeta, and adds a new method which prevents the addition of new attributes after initialisation.

class RestrictAttributes(metaclass=RestrictAttributesMeta):
    """
    Base class that prevents the addition of new attributes after
    initialisation.

    This class uses RestrictAttributesMeta as its metaclass to implement
    attribute restriction. It allows for safe initialisation of attributes
    during the __init__ method, but prevents the addition of new attributes
    afterwards.

    The restriction is enforced through the custom __setattr__ method, which
    checks if the attribute already exists before allowing assignment.
    """
    def __setattr__(self, name, value):
        """
        Prevent addition of new attributes.

        Parameters
        ----------
        name: str
            The name of the attribute to set.
        value: any
            The value to assign to the attribute.

        Raises
        ------
        AttributeError
            If `name` is not an existing attribute and an attempt is made
            to add it to the class instance.
        """
        # Check if the instance is initialised and the attribute doesn"t exist
        if hasattr(self, "_initialised") and not hasattr(self, name):
            # Get a list of existing attributes for the error message
            existing = ", ".join(self.__dict__.keys())
            raise AttributeError(
                f"Cannot add new attribute '{name}' - only possible to " +
                f"modify existing attributes: {existing}."
            )
        # If checks pass, set the attribute using the standard method
        object.__setattr__(self, name, value)

As a child or derived class, Param inherits behavior from RestrictAttributes (and, by extension, its metaclass RestrictAttributesMeta). This means Param automatically gains the validation logic defined in its parent classes.

class Param(RestrictAttributes):
    """
    Parameter class with validation to prevent the addition of new attributes.
    """
    def __init__(self, param1="test", param2=42):
        """
        Initialise Param instance.
        """
        self.param1 = param1
        self.param2 = param2


# Example usage...

# Create an instance of the class
params = Param()

# Successfully modify an existing attribute
params.param1 = "newtest"

# Attempts to add new attributes should raise an error
try:
    params.new_attribute = 3
except AttributeError as e:
    print(f"Error: {e}")
Error: Cannot add new attribute 'new_attribute' - only possible to modify existing attributes: param1, param2, _initialised.

LockedDict is a dictionary wrapper that locks its initial set of keys after creation. It ensures that existing keys can still be updated, but adding or deleting top-level keys is not possible, preventing bugs caused by typos or unintended modifications.

class LockedDict(UserDict):
    """
    Wrapper that prevents adding or deleting top-level keys in a dictionary
    after creation.

    This prevents accidental addition or deletion of keys after construction,
    helping to avoid subtle bugs (e.g., typos introducing new keys).

    Attributes
    ----------
    _locked_keys : set
        The set of top-level keys locked after initialisation.
    _locked_keys_initialised : bool
        Indicates whether locked key enforcement is active (True after
        __init__ completes). This flag is crucial for compatibility with
        joblib and multiprocessing, as object reconstruction during
        deserialisation may call __setitem__ before additional attributes are
        restored.
    """
    def __init__(self, *args, **kwargs):
        """
        Initialise the LockedDict.

        Parameters
        ----------
        *args, **kwargs : any
            Arguments passed to dict for initialisation. Top-level keys will
            be locked.

        Notes
        -----
        """
        self._locked_keys_initialised = False
        super().__init__(*args, **kwargs)
        self._locked_keys = set(self.data)
        self._locked_keys_initialised = True

    def __setattr__(self, name, value):
        """
        Block silent attribute assignment by only allowing internal, private
        and known base-class-needed attributes.

        This avoids silent failure when users try to set dict.new_attribute,
        where it does not change the dictionary as it set an attribute.
        """
        if name.startswith("_") or name == "data":
            super().__setattr__(name, value)
        else:
            raise AttributeError(
                f"Cannot set attribute '{name}'. "
                f"Use item syntax: obj['{name}'] = value"
            )

    def __setitem__(self, key, value):
        """
        Restrict assignment to existing top-level keys.

        Parameters
        ----------
        key : str
            Top-level dictionary key.
        value : any
            Value to assign.

        Raises
        ------
        KeyError
            If key is not an original top-level key.

        Notes
        -----
        Key restriction is enforced only after initialisation to prevent errors
        when used with joblib or multiprocessing, which may call __setitem__
        before all attributes are available.
        """
        if getattr(self, '_locked_keys_initialised', False):
            if key not in self._locked_keys:
                raise KeyError(
                    f"Attempted to add or update key '{key}', which is not "
                    f"one of the original locked keys. This is likely due to "
                    f"a typo or unintended new parameter. Allowed top-level "
                    f"keys are: [{self._locked_keys}]"
                )
        super().__setitem__(key, value)

    def __delitem__(self, key):
        """
        Prevent deletion of top-level keys.

        Parameters
        ----------
        key : str
            Top-level dictionary key.

        Raises
        ------
        KeyError
            Always, to disallow top-level key deletion.
        """
        raise KeyError(
            f"Deletion of key '{key}' is not allowed. The set of top-level "
            f"keys is locked to prevent accidental removal of expected "
            f"parameters. Allowed top-level keys are: [{self._locked_keys}]"
        )

This can be used when parameters are simply stored in a dictionary:

param = LockedDict({
  "param1": "test",
  "param2": 42
})

# Successfuly modify an existing attribute
param["param1"] = "newtest"

# Attempts to add new attributes should raise an error
try:
    param["newattribute"] = 3
except KeyError as e:
    print(f"Error: {e}")
Error: "Attempted to add or update key 'newattribute', which is not one of the original locked keys. This is likely due to a typo or unintended new parameter. Allowed top-level keys are: [{'param1', 'param2'}]"
print(param)
{'param1': 'newtest', 'param2': 42}

It is also useful for nested structures - for example, when a nested dictionary of parameters is a class attribute.

class Param(RestrictAttributes):
    """
    Parameter class with validation to prevent the addition of new attributes.
    """
    def __init__(self, param1="test", param2=42):
        """
        Initialise Param instance.
        """
        self.param_dict = LockedDict({
          "param1": param1,
          "param2": param2
        })


param_instance = Param()

# Successfully modify an existing attribute
param_instance.param_dict["param1"] = "newtest"

# Attempts to add new attributes should raise an error
try:
    param_instance.param_dict["new_attribute"] = 3
except KeyError as e:
    print(f"Error: {e}")
Error: "Attempted to add or update key 'new_attribute', which is not one of the original locked keys. This is likely due to a typo or unintended new parameter. Allowed top-level keys are: [{'param1', 'param2'}]"
print(param_instance.param_dict)
{'param1': 'newtest', 'param2': 42}

There are two main approaches to prevent these silent failures when using functions:

  • Implement parameter validation within your model functions.
  • Switch to using R6 classes.

Class-based validation is often preferable as it catches errors at the point when parameters are defined - but both approaches are effective!

Functions are more commonly used in R than classes, so you may prefer to stick with the function-based approach for consistency with R conventions.

Parameter validation within the model function

Our parameter function returns a list. While you could validate arguments within the parameter function, this would not prevent accidental modification after the list is returned.

When calling the function, you are restricted to the defined arguments - so extra entries cannot be added at that stage. However, once the list is generated, it can be altered by adding or removing entries. For this reason, we incorporate validation into the model function (rather than the parameter function).

As a reminder, this is our parameter function:

#' Returns transfer_prob for validation example.
#'
#' @param transfer_prob Numeric. Transfer probability (0-1).
#'
#' @return A named list containing the transfer_prob parameter.

param_function <- function(transfer_prob = 0.3) {
  list(transfer_prob = transfer_prob)
}

We can write a validation function which checks that all the required parameters are present, and that no extra parameters are provided.

This is based on the arguments of param_function:

#' Validate parameter names.
#'
#' Ensure that all required parameters are present,
#' and no extra parameters are provided.
#'
#' @param param List containing parameters for the simulation.
#' @param param_function Function used to generate parameter list.
#'
#' @return Throws an error if there are missing or extra parameters.

check_param_names <- function(param, param_function) {

  # Get valid argument names from the function
  valid_names <- names(formals(param_function))

  # Get names from input parameter list
  input_names <- names(param)

  # Find missing keys (i.e. are there things in valid_names not in input)
  # and extra keys (i.e. are there things in input not in valid_names)
  missing_keys <- setdiff(valid_names, input_names)
  extra_keys <- setdiff(input_names, valid_names)

  # If there are any missing or extra keys, throw an error
  if (length(missing_keys) > 0L || length(extra_keys) > 0L) {
    error_message <- ""
    if (length(missing_keys) > 0L) {
      error_message <- paste0(
        error_message, "Missing keys: ", toString(missing_keys), ". "
      )
    }
    if (length(extra_keys) > 0L) {
      error_message <- paste0(
        error_message, "Extra keys: ", toString(extra_keys), ". "
      )
    }
    stop(error_message, call. = FALSE)
  }
}

If parameters are imported from a file (e.g., using a function that saves them as a list), that file can be reused for validation when no function with explicit arguments exists. Whilst this won’t detect missing or misspelled parameters in the file itself, it will confirm that no parameters have been added or removed since import. This is useful when the list has been modified (e.g., to implement scenarios) before it was provided to the model function.

In this example, we have a package simulation where the parameters are in extdata/parameters.json, and a function parameters() which returns a named list of parameters, one of which is a list itself (dist_config) and so is checked separately.

# nolint start: object_usage_linter

#' Validate parameter names.
#'
#' Ensure that all required parameters are present,
#' and no extra parameters are provided.
#'
#' @param param List containing parameters for the simulation.
#'
#' @return Throws an error if there are missing or extra parameters.

check_param_names_json <- function(param, param_file) {

  # Check the distribution names....
  # Import JSON with the required names
  config <- fromJSON(
    system.file("extdata", "parameters.json", package = "simulation"),
    simplifyVector = FALSE
  )[["simulation_parameters"]]
  required <- names(config)

  # Check what names are within param, if any missing or extra
  missing_names <- setdiff(required, names(param[["dist_config"]]))
  extra_names <- setdiff(names(param[["dist_config"]]), required)
  if (length(missing_names) > 0L || length(extra_names) > 0L)
    stop("Problem in param$dist_config. Missing: ", missing_names, ". ",
         "Extra: ", extra_names, ".", call. = FALSE)

  # Check the names in param
  missing_names <- setdiff(names(parameters()), names(param))
  extra_names <- setdiff(names(param), names(parameters()))
  if (length(missing_names) > 0L || length(extra_names) > 0L)
    stop("Problem in param. Missing: ", missing_names, ". ",
         "Extra: ", extra_names, ".", call. = FALSE)
}

# nolint end

Then, in our model function, we call the relevant validation function to check all inputs before proceeding with the simulation:

#' Run simulation after validating parameter names.
#'
#' @param param Named list of model parameters.
#' @param param_function Function used to generate parameter list.
model <- function(param, param_function) {

  # Check all inputs are valid
  check_param_names(param = param, param_function = param_function)

  # Simulation code...
}


# Example usage
# No extra or missing parameters - model runs without issue
params <- param_function()
model(params, param_function)

# Mistype transfer_prob - model returns an error
params$transfer_probs <- 0.4
try(model(params, param_function))
Error : Extra keys: transfer_probs. 

Parameter validation within the class

By default, R6 classes prevent the addition of new fields. However, there are ways you can override this behavior or set up your classes differently that would not have this protection for your parameters. These include:

  • Setting lock_objects = FALSE.
  • Setting parameters within a list.

As a reminder, this is our default parameter class:

#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    }
  )
)
# Create instance of ParamClass
params <- ParamClass$new()

# Mistype transfer_prob
try({
  params$transfer_probs <- 0.4
})
Error in params$transfer_probs <- 0.4 : 
  cannot add bindings to a locked environment

Setting lock_objects = FALSE

The prevention of new fields is thanks to the default lock_objects = TRUE setting. If we override this and set lock_objects = FALSE, it will not raise an error when new fields are added. Therefore, it’s important not to override this default behavior.

#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  lock_objects = FALSE,
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    }
  )
)
# Create instance of ParamClass
params <- ParamClass$new()

# Mistype transfer_prob
try({
  params$transfer_probs <- 0.4
})
params
<R6>
  Public:
    clone: function (deep = FALSE) 
    initialize: function (transfer_prob = 0.3) 
    transfer_prob: 0.3
    transfer_probs: 0.4

Setting parameters within a list

If you set up your R6 class with each parameter as a class field, then by default, it will have validation to prevent the addition of new fields.

You may choose to store parameters in a list instead, to make it easier to access them all at once - but, if you do this, the validation won’t apply.

#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    parameters = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$parameters <- list(
        transfer_prob = transfer_prob
      )
    }
  )
)
# Create instance of ParamClass
params <- ParamClass$new()

# Mistype transfer_prob
try({
  params$parameters$transfer_probs <- 0.4
})
params$parameters
$transfer_prob
[1] 0.3

$transfer_probs
[1] 0.4


However, there is a clean solution that allows you to access parameters easily while maintaining individual fields with built-in validation. By adding a get_params() method, you can extract parameters without storing them in a list.

#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    },

    #' @description
    #' Returns parameters as a named list.

    get_params = function() {
      # Get all non-function fields
      all_names <- ls(self)
      is_not_function <- vapply(
        all_names,
        function(x) !is.function(self[[x]]),
        FUN.VALUE = logical(1L)
      )
      param_names <- all_names[is_not_function]
      mget(param_names, envir = self)
    }
  )
)
# Create instance of ParamClass
params <- ParamClass$new()

# Mistype transfer_prob
try({
  params$transfer_probs <- 0.4
})
Error in params$transfer_probs <- 0.4 : 
  cannot add bindings to a locked environment
# Get all parameters
params$get_params()
$transfer_prob
[1] 0.3

🛡️ Validating parameter values (e.g. range)

You can check that the provided inputs are valid - expected format, range, etc. This can either be:

  • When using functions: Implement parameter validation within your model functions.
  • When using classes: Build validation directly into the class structure.

Parameter validation within the model functions

As a reminder, this is our parameter function:

# pylint: disable=missing-module-docstring
def param_function(transfer_prob=0.3):
    """
    Returns transfer_prob for validation example.

    Parameters
    ----------
    transfer_prob : float
        Transfer probability (0-1).

    Returns
    -------
    Dictionary containing the transfer_prob parameter.
    """
    return {"transfer_prob": transfer_prob}
#' Returns transfer_prob for validation example.
#'
#' @param transfer_prob Numeric. Transfer probability (0-1).
#'
#' @return A named list containing the transfer_prob parameter.

param_function <- function(transfer_prob = 0.3) {
  list(transfer_prob = transfer_prob)
}

We can write a validation function which checks that the provided transfer probability is between 0 and 1.

def validate_param(parameters):
    """
    Check that the transfer probability is between 0 and 1.

    Parameters
    ----------
    parameters : dict
      Dictionary of parameters.
    """
    transfer_prob = parameters["transfer_prob"]
    if transfer_prob < 0 or transfer_prob > 1:
        raise ValueError(
          f"transfer_prob must be between 0 and 1, but is: {transfer_prob}"
        )
#' @title Check that the transfer probability is between 0 and 1.
#'
#' @param parameters Named list of model parameters.

validate_param <- function(parameters) {
  transfer_prob <- parameters$transfer_prob
  if (transfer_prob < 0L || transfer_prob > 1L) {
    stop(
      "transfer_prob must be between 0 and 1, but is: ", transfer_prob,
      call. = FALSE
    )
  }
}

Then, in our model function, we call the validation function to check all inputs before proceeding with the simulation:

def model(param_dict):
    """
    Run simulation.

    Parameters
    ----------
    param_dict : dict
        Dictionary of parameters.
    """
    # Check all inputs are valid
    validate_param(parameters=param_dict)

    # Simulation code...

    print("The simulation has run!")


# Example usage
# Run model() invalid transfer_prob - raises an error
param = param_function(transfer_prob=1.4)
try:
    model(param)
except ValueError as e:
    print(e)
transfer_prob must be between 0 and 1, but is: 1.4
#' Run simulation.
#'
#' @param param Named list of model parameters.

model <- function(param) {

  # Check all inputs are valid
  validate_param(parameters = param)

  # Simulation code...

  cat("The simulation has run!")
}


# Example usage
# Run model() invalid transfer_prob - raises an error
params <- param_function({
  transfer_prob <- 1.4
})
try(model(params))
Error : transfer_prob must be between 0 and 1, but is: 1.4

Provided an invalid probability, the code has stopped with an error message, and model() has halted execution.

If we modified param to have a valid value, then the model() function will run without error:

param = param_function(transfer_prob=0.25)
model(param)
The simulation has run!
params <- param_function(transfer_prob = 0.25)
model(params)
The simulation has run!

Parameter validation within the class

As a reminder, this is our parameter class:

# pylint: disable=missing-module-docstring, invalid-name
# pylint: disable=too-few-public-methods
class ParamClass:
    """
    Returns transfer_prob for validation example.
    """
    def __init__(self, transfer_prob=0.3):
        """
        Initialise ParamClass instance.

        Parameters
        ----------
        transfer_prob : float
            Transfer probability (0-1).
        """
        self.transfer_prob = transfer_prob
# Create instance with invalid transfer_prob - no error raised
param = ParamClass(transfer_prob=1.4)
#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    }
  )
)
# Create instance with invalid transfer_prob - no error raised
param <- ParamClass$new(transfer_prob = 1.4)

As you’ll see, no error was raised when using an invalid transfer probability.


The new validate_param() method checks whether transfer_prob is between 0 and 1.

Although this is defined within the class, it could also be called from within the model function, so that all parameters are checked before the simulation runs.

class ParamClass:
    """
    Returns transfer_prob for validation example.
    """
    def __init__(self, transfer_prob=0.3):
        """
        Initialise ParamClass instance.

        Parameters
        ----------
        transfer_prob : float
            Transfer probability (0-1).
        """
        self.transfer_prob = transfer_prob

    def validate_param(self):  # <<
        """  # <<
        Check that transfer_prob is between 0 and 1.  # <<
        """  # <<
        if self.transfer_prob < 0 or self.transfer_prob > 1:  # <<
            raise ValueError("transfer_prob must be between 0 and 1" +  # <<
                             f", but is: {self.transfer_prob}")  # <<
# Create instance of ParamClass with invalid transfer_prob and run method
param = ParamClass(transfer_prob=1.4)
try:  # <<
    param.validate_param()  # <<
except ValueError as e:  # <<
    print(f"Error: {e}")  # <<
Error: transfer_prob must be between 0 and 1, but is: 1.4
#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).

ParamClass <- R6Class( # nolint: object_name_linter
  public = list(
    transfer_prob = NULL,

    #' @description
    #' Initialises the R6 object.

    initialize = function(transfer_prob = 0.3) {
      self$transfer_prob <- transfer_prob
    },

    #' @description
    #' Check that transfer_prob is between 0 and 1.
    #' @return No return value; throws an error if invalid.

    validate_param = function() {
      if (self$transfer_prob < 0L || self$transfer_prob > 1L) {
        stop(
          "transfer_prob must be between 0 and 1, but is: ",
          self$transfer_prob, call. = FALSE
        )
      }
    }
  )
)
# Create instance with invalid transfer_prob and run method
param <- ParamClass$new(transfer_prob = 1.4)
try(param$validate_param())
Error : transfer_prob must be between 0 and 1, but is: 1.4

🔍 Explore the example models

🩺 Nurse visit simulation

GitHub Click to visit pydesrap_mms repository

Key files simulation/param.py
simulation/model.py
What to look for? The Param class in param.py uses __setattr__() to prevent accidental creation of new attributes (parameters). The Model class in model.py includes a valid_inputs() method that checks parameter values (e.g., if numbers are positive) before running a simulation. Some additional checks are handled by distribution classes imported from sim-tools.
Why it matters? Putting __setattr__() logic directly in the class is clean and perfectly sufficient for this model with its relatively simple parameter set. Value validation happens in the model function itself, which works well because all validation is needed right before the simulation runs. For a small model, this keeps the code straightforward and easy to follow.

GitHub Click to visit rdesrap_mms repository

Key files R/validate_model_inputs.R
R/model.R
What to look for? Parameter validation functions are defined in validate_model_inputs.R and called from within model.R. These check that the names in the provided parameter list match those in the parameters() function (from parameters.R), and also validate the parameter values themselves.
Why it matters? All validation is performed in the model function. Since parameters() is a straightforward function with no nested arguments, it can be directly referenced to detect accidental creation of new parameters in the list provided to the model.

🧠 Stroke pathway simulation

GitHub Click to visit pydesrap_stroke repository

Key file simulation/parameters.py
simulation/restrictattributes.py
simulation/model.py
What to look for? The Param class inherits from RestrictAttributesto prevent accidental creation of new attributes. The check_param_validity and validate_param methods ensure parameter values are valid (e.g., correct ranges), and these are called from model.py. Some additional checks are handled by distribution classes imported from sim-tools.
Why it matters? Inheriting restriction logic keeps the Param class focused and simpler. Storing validation methods in the class ensures all logic for parameter correctness is together, even though validation is triggered from the model (param.check_param_validity() in model.py). Because the model code is more complex than in the M/M/s model, it’s clearer and more maintainable to keep validation with the parameter classes instead of mixing it into the main simulation logic.

GitHub Click to visit rdesrap_stroke repository

Key file R/validate_model_inputs.R
R/model.R
What to look for? Parameter validation functions are defined in validate_model_inputs.R and called from within model.R. These check that the names in the provided parameter list match those in the parameters() function (from parameters.R), as well as checking that the names in the nested dist_config entry match the names in parameters.json. They also validate the parameter values themselves.
Why it matters? The set-up closely follows the approach in the M/M/s model, but is slightly more complex due to the nested structure within dist_config. This is handled by comparing the supplied parameter names to entries in the JSON file - which is straightforward, but does mean you’re relying on the correctness of the entries in the JSON itself, so the approach is imperfect.

🧪 Test yourself

If you haven’t already, now’s the time to try out parameter validation in practice.

Task:

  • Create a simple set of parameters for a simulation model using a function, dictionary, or class (you can use an example from above if you want to).
  • Create a simple set of parameters for a simulation model using a function, list, or R6 class (you can use an example from above if you want to).
  • Try mistyping a parameter name. Observe what happens - does it raise an error, or does it quietly add a new unused parameter?

  • Next, implement a validation step using one of the examples above. Run the same typo again - does validation now catch the mistake and riase an error?

  • Repeat this task, but with an invalid value for a parameter (e.g. outside the expected range).