from collections import UserDict
import inspect
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.
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(0.3)
params = 0.4
params.transfer_prob
try:
= 0.7
params.transfer_probs 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
= param_function()
params print(params)
{'transfer_prob': 0.3}
Now suppose we meant to update transfer_prob
but made a typo:
"transfer_probs"] = 0.4
params[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
= ParamClass()
params print(params.__dict__)
{'transfer_prob': 0.3}
Now let’s introduce a typo:
= 0.4
params.transfer_probs 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.
<- function(transfer_prob = 0.3) {
param_function list(transfer_prob = transfer_prob)
}
This gives:
# Create list using the function
<- param_function()
params print(params)
$transfer_prob
[1] 0.3
Now suppose we meant to update transfer_prob
but made a typo:
$transfer_probs <- 0.4
params 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).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
}
) )
# Create instance of ParamClass
<- ParamClass$new()
params
# Mistype transfer_prob
try({
$transfer_probs <- 0.4
params })
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
= set(inspect.signature(param_function).parameters)
valid_params
# Get the set of input parameter names from the provided dictionary
= set(param_dict)
input_params
# Identify missing and extra parameters
= valid_params - input_params
missing = input_params - valid_params
extra
# 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
=param_dict, param_function=param_function)
check_param_names(param_dict
# Simulation code...
# Example usage
# No extra or missing parameters - model runs without issue
= param_function()
params
model(params, param_function)
# Mistype transfer_prob - model returns an error
"transfer_probs"] = 0.4
params[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
= Param()
params
# Successfully modify existing attributes
= 0.4
params.transfer_prob
# Attempts to add new attributes raise an error
try:
= 0.7 # typo!
params.transfer_probs 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
= Param()
params
# Successfully modify an existing attribute
= "newtest"
params.param1
# Attempts to add new attributes should raise an error
try:
= 3
params.new_attribute 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:
RestrictAttributesMeta
(metaclass).RestrictAttributes
(parent/base class).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
= super().__call__(*args, **kwargs)
instance # Set the "_initialised" flag to True, marking end of initialisation
"_initialised"] = True
instance.__dict__[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
= ", ".join(self.__dict__.keys())
existing 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
= Param()
params
# Successfully modify an existing attribute
= "newtest"
params.param1
# Attempts to add new attributes should raise an error
try:
= 3
params.new_attribute 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:
= LockedDict({
param "param1": "test",
"param2": 42
})
# Successfuly modify an existing attribute
"param1"] = "newtest"
param[
# Attempts to add new attributes should raise an error
try:
"newattribute"] = 3
param[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()
param_instance
# Successfully modify an existing attribute
"param1"] = "newtest"
param_instance.param_dict[
# Attempts to add new attributes should raise an error
try:
"new_attribute"] = 3
param_instance.param_dict[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.
<- function(transfer_prob = 0.3) {
param_function 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.
<- function(param, param_function) {
check_param_names
# Get valid argument names from the function
<- names(formals(param_function))
valid_names
# Get names from input parameter list
<- names(param)
input_names
# 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)
<- setdiff(valid_names, input_names)
missing_keys <- setdiff(input_names, valid_names)
extra_keys
# 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) {
<- paste0(
error_message "Missing keys: ", toString(missing_keys), ". "
error_message,
)
}if (length(extra_keys) > 0L) {
<- paste0(
error_message "Extra keys: ", toString(extra_keys), ". "
error_message,
)
}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.
<- function(param, param_file) {
check_param_names_json
# Check the distribution names....
# Import JSON with the required names
<- fromJSON(
config system.file("extdata", "parameters.json", package = "simulation"),
simplifyVector = FALSE
"simulation_parameters"]]
)[[<- names(config)
required
# Check what names are within param, if any missing or extra
<- setdiff(required, names(param[["dist_config"]]))
missing_names <- setdiff(names(param[["dist_config"]]), required)
extra_names 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
<- setdiff(names(parameters()), names(param))
missing_names <- setdiff(names(param), names(parameters()))
extra_names 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.
<- function(param, param_function) {
model
# 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
<- param_function()
params model(params, param_function)
# Mistype transfer_prob - model returns an error
$transfer_probs <- 0.4
paramstry(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).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
}
) )
# Create instance of ParamClass
<- ParamClass$new()
params
# Mistype transfer_prob
try({
$transfer_probs <- 0.4
params })
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).
<- R6Class( # nolint: object_name_linter
ParamClass lock_objects = FALSE,
public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
}
) )
# Create instance of ParamClass
<- ParamClass$new()
params
# Mistype transfer_prob
try({
$transfer_probs <- 0.4
params
}) 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).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
parameters = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$parameters <- list(
selftransfer_prob = transfer_prob
)
}
) )
# Create instance of ParamClass
<- ParamClass$new()
params
# Mistype transfer_prob
try({
$parameters$transfer_probs <- 0.4
params
})$parameters params
$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).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
},
#' @description
#' Returns parameters as a named list.
get_params = function() {
# Get all non-function fields
<- ls(self)
all_names <- vapply(
is_not_function
all_names,function(x) !is.function(self[[x]]),
FUN.VALUE = logical(1L)
)<- all_names[is_not_function]
param_names mget(param_names, envir = self)
}
) )
# Create instance of ParamClass
<- ParamClass$new()
params
# Mistype transfer_prob
try({
$transfer_probs <- 0.4
params })
Error in params$transfer_probs <- 0.4 :
cannot add bindings to a locked environment
# Get all parameters
$get_params() 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.
<- function(transfer_prob = 0.3) {
param_function 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.
"""
= parameters["transfer_prob"]
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.
<- function(parameters) {
validate_param <- parameters$transfer_prob
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
=param_dict)
validate_param(parameters
# Simulation code...
print("The simulation has run!")
# Example usage
# Run model() invalid transfer_prob - raises an error
= param_function(transfer_prob=1.4)
param 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.
<- function(param) {
model
# 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
<- param_function({
params <- 1.4
transfer_prob
})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_function(transfer_prob=0.25)
param model(param)
The simulation has run!
<- param_function(transfer_prob = 0.25)
params 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
= ParamClass(transfer_prob=1.4) param
#' @title Returns transfer_prob for validation example.
#'
#' @field transfer_prob Numeric. Transfer probability (0-1).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
}
) )
# Create instance with invalid transfer_prob - no error raised
<- ParamClass$new(transfer_prob = 1.4) param
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
= ParamClass(transfer_prob=1.4)
param 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).
<- R6Class( # nolint: object_name_linter
ParamClass public = list(
transfer_prob = NULL,
#' @description
#' Initialises the R6 object.
initialize = function(transfer_prob = 0.3) {
$transfer_prob <- transfer_prob
self
},
#' @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: ",
$transfer_prob, call. = FALSE
self
)
}
}
) )
# Create instance with invalid transfer_prob and run method
<- ParamClass$new(transfer_prob = 1.4)
param try(param$validate_param())
Error : transfer_prob must be between 0 and 1, but is: 1.4
🔍 Explore the example models
🩺 Nurse visit simulation
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. |
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
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 RestrictAttributes to 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. |
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).