Configs and basic rcognita
applications
RL and control theory are infamous for having overwhelmingly many entities to keep track of: agents, environments, models, training routines, integrators, predictors, observers, optimizers… Each of the above in turn has a number of parameters of its own, and to make things worse, your setup will most likely be highly sensitive to all of these. Therefore tweaking and tuning your setup may and will get tedeous unless you figure out a way to do it conveniently and systematically.
Enter hierarchical configs! Rcognita has a builtin hierarchical config pipeline
built on top of hydra
. It must be noted that a regular hydra
config will run on rcognita
just fine (but not vice versa), since
rcognita
includes all of the original features and syntaxes of hydra
.
However rcognita
additionally provides convenient syntactic sugars that
hydra
does not posses.
This tutorial is focused on explaining the structure and intended workflow of
rcognita
from the perspective of the config paradigm.
The reader is encouraged to familiarize themselves
with hydra
. Some of the basic syntaxes along with the additional features will also
be covered in the present tutorial.
The first two examples may have been reused elsewhere in the documentation, so be sure to skip them if you’ve already seen them.
Example 1: Basics
Consider the following files in your hypothetical project.
my_utilities.py
:
from rcognita.systems import System
from rcognita.controllers import Controller
class MyRobotSystem(System):
def __init__(self, x, y, z):
...
def ...
class MyAgent(Controller):
def __init__(self, a, b, c):
...
def ...
my_config.yaml
:
rate: 0.1
initial_state: = numpy.zeros(5) # The '=' lets us evaluate this
# python code 'numpy.zeros(5)'
robot:
_target_: my_utilities.MyRobotSystem # '_target_' is a special field
x: 1 # that should point to a class
y: 2
z: 3
agent:
_target_: my_utilities.MyAgent
a: 3
b: 4
c: 5
main.py
:
import rcognita as r
from rcognita.simulator import Simulator
from rcognita.scenarios import EpisodicScenario
import my_utilities
import numpy
@r.main(
config_path=".",
config_name="my_config",
)
def my_app(config):
robot = ~config.robot # '~' instantiates the object
controller = ~config.agent # described in the corresponding
# field. It makes use of '_target_'.
simulator = Simulator(robot,
config.initial_state,
sampling_time=config.rate)
scenario = EpisodicScenario(simulator, controller)
scenario.run()
if __name__ == "__main__":
my_app()
The above example project is the equivalent to the first example in section
“What is rcognita
?”. Here instead of providing args for
MyRobotSystem and MyAgent inside the python script, we instead specify
both the classes and their args in my_config.yaml
.
Note, that the operator ~
is necessary to let rcognita know that
the corresponding node within the config describes an instance of a class
and we would like to instantiate it
(as opposed to accessing it as a config-dictionary).
In other words ~config.robot
evaluates to
<my_utilities.MyRobotSystem object at 0x7fe53aa39220>
, while config.robot
evaluates to
{'_target_':'my_utilities.MyRobotSystem', 'x':1, 'y':2, 'z':3}
Example 2: Nested instantiation
Note, that when using this config paradigm nothing impedes us from instantiating literally everything directly inside the config, leaving the python script almost empty. Here’s an example of how this can be done:
my_utilities.py
:
from rcognita.systems import System
from rcognita.controllers import Controller
class MyRobotSystem(System):
def __init__(self, x, y, z):
...
def ...
class MyAgent(Controller):
def __init__(self, a, b, c):
...
def ...
my_config.yaml
:
_target_: rcognita.scenarios.Scenario
simulator:
_target_: rcognita.simulator.Simulator
system:
_target_: my_utilities.MyRobotSystem
x: 1
y: 2
z: 3
initial_state: = numpy.zeros(5)
sampling_time: 0.1
controller:
_target_: my_utilities.MyAgent
a: 3
b: 4
c: 5
main.py
:
import rcognita as r
import my_utilities
import numpy
@r.main(
config_path=".",
config_name="my_config",
)
def my_app(config):
scenario = ~config
scenario.run()
if __name__ == "__main__":
my_app()
This way of doing it has numerous advantages. Notably, you can now conveniently override any input parameters, when running the script like so
python3 main.py controller.a=10
or even
python3 main.py simulator._target_=MyOwnBetterSimulator
Remark on overriding and forwarding
Sure, overriding individual parameters is nice, but writing
simulator.system.x=3
can be rather inconvenient as compared to simply writing
x=3
.
Fortunately, rcognita
’s configs have a feature that allows you
to forward a variable by simply adding the following line to you config:
@simulator.system.x
This way you can simply write
python3 main.py x=10
and this will have the same effect as:
python3 main.py simulator.system.x=10
Forwarding is intended to provide convenience for overriding the select few important parameters that may be deeply nested. Don’t overuse it though or you’ll lose the advantages of a hierarchical structure.
Example 3: Config groups
Sure, we can override a parameters or two, but what if we came up against a case when we want to be able swap out an entire agent or an entire environment without rewriting the whole config?
Consider the following example:
my_utilities.py
:
from rcognita.systems import System
from rcognita.controllers import Controller
class MyRobotSystem(System):
def __init__(self, x, y, z):
...
def ...
class MyAgentReliable(Controller): ## You already know this one works
def __init__(self, a, b, c):
...
def ...
class MyAgentExperimental(Controller): ## Perhaps this one works even better
def __init__(self, e, f, g, h):
...
def ...
my_config.yaml
:
_target_: rcognita.scenarios.Scenario
defaults:
- controller: reliable
simulator:
_target_: rcognita.simulator.Simulator
system:
_target_: my_utilities.MyRobotSystem
x: 1
y: 2
z: 3
initial_state: = numpy.zeros(5)
sampling_time: 0.1
controller/reliable.yaml
:
_target_: my_utilities.MyAgentReliable
a: 4
b: 5
c: 6
controller/experimental.yaml
:
_target_: my_utilities.MyAgentExperimental
e: 7
f: 8
g: 9
h: 10
main.py
:
import rcognita as r
import my_utilities
import numpy
@r.main(
config_path=".",
config_name="my_config",
)
def my_app(config):
scenario = ~config
scenario.run()
if __name__ == "__main__":
my_app()
In the above project we are looking two alternative agents (controller):
the first one called MyAgentReliable
and the other called MyAgentExperimental
.
Observe the default
syntax in my_config.yaml
. The line - controller: reliable
makes it so that the node config.controller
is populated by the contents of
controller/reliable.yaml
. In this case if you wanted to instead try out the
experimental agent (described by controller/experimental.yaml
) you would simply
need to execute the following:
python3 main.py controller=experimental
As simple as that!
Note that the directory controller
matches the name of the node it populates.
Additional remarks on defaults
Consider the following:
config.yaml
:
defaults:
- file
The above code will populate config.yaml
with the contents of file.yaml
.
This feature can help you avoid a lot of unnecessary
rewriting and duplication.
Also, if you want to override a nested config group you need
to use /
instead of .
. For instance, like so
python3 main.py controller/something_inside_my_controller=experimental
Yes, this syntax is a bit strange since some of the things in between those /
may
not even be actual directories, but rather just names of nodes. This however lets
hydra
distinguish between overriding a variable and overriding a config. So if you
were to instead execute
python3 main.py controller.something_inside_the_controller=experimental
this would simply assign the string "experimental"
to
config.controller.something_inside_my_controller
as opposed to swapping out the
respective config file to experimental.yaml
.
Example 4: Instantiating, referencing and inlining
Instantiation (~
)
Imagine the following: you are building an agent that explicitly accounts for the error of the simulations (for the purpose of improving offline learning). To be able to extract the necessary data it needs to have access to the simulator instance. Here’s how you could go about doing it.
my_utilities.py
:
from rcognita.systems import System
from rcognita.controllers import Controller
class MyRobotSystem(System):
def __init__(self, x, y, z):
...
def ...
class MyAgent(Controller):
def __init__(self, simulator, a, b, c):
self.simulator = simulator
...
def ...
my_config.yaml
:
_target_: rcognita.scenarios.Scenario
simulator:
_target_: rcognita.simulator.Simulator
system:
_target_: my_utilities.MyRobotSystem
x: 1
y: 2
z: 3
initial_state: = numpy.zeros(5)
sampling_time: 0.1
controller:
_target_: my_utilities.MyAgent
simulator: ~ simulator ## This is where the magic happens.
a: 3 ## '~' instantiates config.simulator.
b: 4 ## Furthermore, this is going to be the exact
c: 5 ## same instance that is produced by ~config.simulator
## when run in python
main.py
:
import rcognita as r
import my_utilities
import numpy
@r.main(
config_path=".",
config_name="my_config",
)
def my_app(config):
print(~config.simulator is config.controller.simulator) ## Will output True
print((~config).simulator is config.controller.simulator) ## Will output True
if __name__ == "__main__":
my_app()
In configs ~
does the exact same thing that it does in Python: instantiates
an object described by a config node (with a _target_
).
The most important aspect of this feature is your ability to assign
different references of the same instance. By default, ~
will create
a reference to the same object that is created during recursive instantiation,
when, for instance, running ~config
. You can however insist on creating your own
distinct instance by using
field: my_instance_name ~ other.field
field: ~ something
is short for field: ~{something}
.
Reference ($
)
You can use $
to reference other fields within the config. For
instance if the MyAgent
only needs to know the absolute tolerance atol
to account for the accuracy of simulation, then one could implement that in
the following way:
my_utilities.py
:
from rcognita.systems import System
from rcognita.controllers import Controller
class MyRobotSystem(System):
def __init__(self, x, y, z):
...
def ...
class MyAgent(Controller):
def __init__(self, atol, a, b, c):
...
def ...
my_config.yaml
:
_target_: rcognita.scenarios.Scenario
simulator:
_target_: rcognita.simulator.Simulator
system:
_target_: my_utilities.MyRobotSystem
x: 1
y: 2
z: 3
initial_state: = numpy.zeros(5)
sampling_time: 0.1
atol: 0.001
controller:
_target_: my_utilities.MyAgent
atol: $ simulator.atol ## This will insert 0.001
a: 3
b: 4
c: 5
field: $ something
is short for field: ${something}
. You can make use of that,
whe you want to compose something out of different fields. For instance:
a: 1
b: 2
a_plus_b: = ${a} + ${b}
One could also use $
to references entire config nodes. For instance:
stuff:
_target_: builtins.dict
x: 1
y: 1
identical_to_stuff: $ stuff ## will be populated with contents of "stuff"
This will however result in stuff
and identical_to_stuff
being
different instances. I.e. ~config.stuff is ~config.identical_to_stuff
will
evaluate to False
.
$
is used for absolute references, while $$
is used for relative references.
Inline (=
)
This one is pretty simple. All it does is it executes Python code. Make sure
that the relevant modules are imported in your main.py
.
config.yaml
:
pi: = numpy.pi
main.py
:
import numpy as np …
field: = something
is short for field: ={something}
.
Callbacks and Logging
In rcognita
we avoid mixing logging routines with functional
code. This is motivated by the fact that different applications
may require very different logging behaviors from same objects.
We thus introduce a lightweight event handling system that allows
for flexible and convenient configuration of logging: Callbacks.
A callback is a callable equipped with a logger and an even handling routine.
Let’s write a callback the logs the objective every time it’s computed.
my_callbacks.py
:
class ObjectiveCallback(Callback):
def perform(obj, method, output):
if method == 'objective':
self.log(f"The current objective is equal to {output}.")
To make this callback work we would need
to decorate the objective
method with @rcognita.callbacks.apply_callbacks
after
decorating the respective class with @rcognita.callbacks.introduce_callbacks()
. This will
make objective
trigger callback events. To make sure the event is handled we will also need to
register the callback. There are two way of doing so.
You could either specify it in your Python script
main.py
import rcognita as r
import my_callbacks
import numpy
@r.main(
callbacks=[my_callbacks.ObjectiveCallback], ## Do not instantiate it
config_path=".",
config_name="my_config",
)
def my_app(config):
...
if __name__ == "__main__":
my_app()
or you could just write the following
to your config:
config.yaml
callbacks:
- my_callbacks.ObjectiveCallback
...
The above will not mess with your instantiation parameters. config.callbacks
gets
deleted automatically as soon as rcognita
extracts the callbacks from it.
By default rcognita
creates its own Logger
instance and passes it to the callbacks.
You can insist on your own logger with @r.main(logger=..., ...)
.
If you’d like to know more, be sure to read the relevant API Docs.
What if I still don’t know what I’m doing?
If after reading these tutorials you still don’t quite know where to start,
do not be discouraged. You are now well equipped to understand the presets provided in
rcognita
’s repository. As soon as you examine a few of them, you should be able to write
code of your own. In fact many of the presets can likely be conveniently repurposed for
your own projects.
Be sure to hit the API Docs when in doubt, and good luck with your experiments!