The MushroomObject Interface
In this tutorial, we explain in detail the MushroomObject interface, i.e. the common base class of almost every
MushroomRL component: agents, policies, approximators, parameters, distributions, environments, and datasets all derive
from it. It exists to give these heterogeneous objects two cross-cutting capabilities for free, so that their own code can
stay focused on the reinforcement learning logic. The first one is serialization: an object can be saved to and loaded
from disk in a robust, native format, declaring exactly which of its attributes to persist and how. The second one is
logger forwarding: a logger attached to a top-level object (typically an agent) is automatically propagated down its tree
of sub-objects, so that each component can log its own quantities under a coherent hierarchy of metric names, without
holding a reference to the logger in advance.
Both capabilities rely on the same idea: a MushroomObject declares its relevant attributes and loggable children
(with _add_save_attr and _add_logger_attr), and the base class walks that declaration to recursively save the
object or forward the logger. Because the wiring is data rather than hand-written code, it composes naturally through
nested objects, survives inheritance and reload, and a subclass only needs to declare what it adds.
We first explain how to use classes implementing the MushroomObject interface, and then we provide a small example of
how to implement it on a custom class to serialize the object properly on disk. Finally, we describe how the same
interface is used to forward a logger down the object tree.
The Mushroom RL save format (extension .msh) is nothing else than a zip file, containing some information (stored into
the config file) to load the object. This information can be accessed easily and you can try to recover the information
by hand from corrupted files.
Note that it is always possible to serialize Python objects with the pickle library. However, the MushroomRL serialization interface use a native format, is easy to use, and is more robust to code changes, as it doesn’t serialize the entire class, but only the data. Furthermore, it is possible to avoid the serialization of some class variables, such as shared objects or big arrays, e.g. replay memories.
Save and load from disk
All the algorithms, policies, approximators, and parameters implemented in MushroomRL derive from
MushroomObject, and therefore can be saved and loaded out of the box.
As an example, we save a MushroomRL Parameter on disk. We create the parameter and then we serialize it to disk
using the save method of the MushroomObject class:
from mushroom_rl.rl_utils.parameters import Parameter
parameter = Parameter(1.0)
print('Initial parameter value: ', parameter())
parameter.save('parameter.msh')
This code creates a parameters.msh file in the working directory.
You can also specify a directory:
from pathlib import Path
base_dir = Path('tmp')
file_name = base_dir / 'parameter.msh'
parameter.save(file_name)
This create a tmp folder (if it doesn’t exist) in the working directory and save the parameters.msh file
inside it.
Now, we can set another value for our parameter variable:
parameter = Parameter(0.5)
print('Modified parameter value: ', parameter())
Finally, we load the previously stored parameter to go back to the previous state using the load method:
parameter = Parameter.load('parameter.msh')
print('Loaded parameter value: ', parameter())
You can also call the load directly from the MushroomObject class:
from mushroom_rl.core import MushroomObject
parameter = MushroomObject.load('parameter.msh')
print('Loaded parameter value (MushroomObject): ', parameter())
The same approach can be used to save an agent, a policy, or an approximator.
Full Save
The save method has an optional full_save flag, which by default is set to False. In the previous parameter
example, this flag does not affect. However, when saving a Reinforcement Learning algorithm or other complex
objects, setting this flag to true forces the agent to save data structures that are normally excluded from a save
file, such as the replay memory in DQN.
This implementation choice avoids large save files for agents with huge data structures, and allows to avoid storing
duplicated information (such as the Q function of in epsilon greedy policy, when saving the algorithm).
The full_save instead, enforces a complete serialization of the agent, retaining all the information.
Implementing the MushroomObject interface
We give a simple example of how to implement the MushroomObject interface in MushroomRL for a custom class.
We use almost all possible data persistence types implemented.
We start the example by importing the MushroomObject interface, the torch library, the NumPy library, and the MushroomRL
Parameter class.
from mushroom_rl.core import MushroomObject
import torch
import numpy as np
from mushroom_rl.rl_utils.parameters import Parameter
While it is required to import the MushroomObject interface, the other three imports are only required by this example, as
they are used to create variables of such type.
Now we define a class implementing the MushroomObject interface. In this case, we call it TestClass.
In the constructor we first build a set of variables of different types, and then we specify which variables
we want to be saved in the MushroomRL file passing keywords to the self._add_save_attr method. Note that
MushroomObject has no constructor to call: the important attributes are set up automatically when the
object is created.
class TestClass(MushroomObject):
def __init__(self, value):
# Create some different types of variables
self._primitive_variable = value # Primitive python variable
self._numpy_vector = np.array([1, 2, 3]*value) # Numpy array
self._dictionary = dict(some='random', keywords=2, fill='the dictionary') # A dictionary
# Building a torch object
data_array = np.ones(3)*value
data_tensor = torch.from_numpy(data_array)
self._torch_object = torch.nn.Parameter(data_tensor)
# Some variables that implement the MushroomObject interface
self._mushroom_parameter = Parameter(2.0*value)
self._list_of_objects = [Parameter(i) for i in range(value)] # This is a list!
# A variable that is not important e.g. a buffer
self.not_important = np.zeros(10000)
# A variable that contains a reference to another variable
self._list_reference = [self._dictionary]
# Here we specify how to save each component
self._add_save_attr(
_primitive_variable='primitive',
_numpy_vector='numpy',
_dictionary='pickle',
_torch_object='torch',
_mushroom_parameter='mushroom',
# List of mushroom objects can also be saved with the 'mushroom' mode
_list_of_objects='mushroom',
# The '!' is to specify that we save the variable only if full_save is True
not_important='numpy!',
)
Some remarks about the self._add_save_attr method: the keyword name must be the name of the variable we want to
store in the file, while the associated value is the method we want to use to store such variables.
The available methods are:
primitive, to store any primitive type. This includes lists and dictionaries of primitive values.
mushroom, to store any type implementing the
MushroomObjectinterface. Also, lists ofMushroomObjectinstances are supported.numpy, to store NumPy arrays.
torch, to store any torch object.
pickle, to store any Python object that cannot be stored with the above methods.
json, can be used if you need a textual output version, that is easy to read.
Another important aspect to remember is that any method can be ended by a ‘!’, to specify that the field must be
serialized if and only if the full_save flag is set to true.
To conclude the implementation of our MushroomObject interface, we might want to implement also the
self._post_load method. This method is executed after all the data specified in self._add_save_attr has been
loaded into the class. It can be useful to set the variables not saved in the file to a default variable.
def _post_load(self):
if self.not_important is None:
self.not_important = np.zeros(10000)
self._list_reference = [self._dictionary]
In this scenario, we have to set the self.not_important variable to his default value, but only if it’s None, i.e.
has not been loaded from the file, because the file didn’t contain it.
Also, we set the self._list_reference variable to maintain its original semantic, i.e. to contain a
reference to the content of the self._dictionary variable.
To test the implementation, we write a function to write in easy to read way the content of the class:
def print_variables(obj):
for label, var in vars(obj).items():
if label != '_save_attributes':
if isinstance(var, Parameter):
print(f'{label}: Parameter({var()})')
elif isinstance(var, list) and isinstance(var[0], Parameter):
new_list = [f'Parameter({item()})' for item in var]
print(f'{label}: {new_list}')
else:
print(label, ': ', var)
Finally, we test the save functionality with the following code:
if __name__ == '__main__':
# Create test object and print its variables
test_object = TestClass(1)
print('###########################################################################################################')
print('The test object contains the following:')
print('-----------------------------------------------------------------------------------------------------------')
print_variables(test_object)
# Changing the buffer
test_object.not_important[0] = 1
# Save the object on disk
test_object.save('test.msh')
# Create another test object
test_object = TestClass(2)
print('###########################################################################################################')
print('After overwriting the test object:')
print('-----------------------------------------------------------------------------------------------------------')
print_variables(test_object)
# Changing the buffer again
test_object.not_important[0] = 1
# Save the other test object, this time remember buffer
test_object.save('test_full.msh', full_save=True)
# Load first test object and print its variables
print('###########################################################################################################')
test_object = TestClass.load('test.msh')
print('Loading previous test object:')
print('-----------------------------------------------------------------------------------------------------------')
print_variables(test_object)
# Load second test object and print its variables
print('###########################################################################################################')
test_object = TestClass.load('test_full.msh')
print('Loading previous test object:')
print('-----------------------------------------------------------------------------------------------------------')
print_variables(test_object)
We can see that the content of self.not_important is stored only if the full_save flag is set to true.
The last remark is that the MushroomObject interface works also in presence of inheritance. If you extend a
MushroomObject subclass, you only need to add the new attributes defined by the child class.
Forwarding a logger through the MushroomObject interface
Besides save and load, the MushroomObject interface also takes care of distributing a logger across the
object tree used by the algorithms. A logger is attached to an object with set_logger and is
automatically forwarded to the loggable children declared by the object, so that the relevant quantities of
an object and of its sub-objects are logged under a hierarchy of grouped metric names.
The loggable children are declared with self._add_logger_attr, in the same spirit as
self._add_save_attr: the first argument is an optional group prefix shared by the registered children,
attributes passed positionally use their default metric name, and attributes passed as keywords use an
explicit metric name. For example, an algorithm registers its critic approximator and its exploration
parameter as:
self._add_logger_attr('_V', group='critic') # logs critic/loss
self._add_logger_attr(_epsilon='epsilon', group='policy') # logs policy/epsilon
When set_logger is called (typically by the Core, see the Logger tutorial), the logger is stored
and forwarded to each registered child, joining the child group to the current prefix, so the hierarchy is
composed automatically down the object tree. As for _add_save_attr, children are referenced by name, so
the forwarding keeps working after an object is reassigned or loaded from disk (the registry is part of the
saved data). A single value is logged with self._logger.log_training(...) from inside the object, where
the first positional argument is the metric group and the values are passed as keyword arguments.