2. Subconfigs¶
Complex systems can be broken into submodules that are described by subconfigs. Consider the following example.
2.1. Define a system with submodules.¶
from torch import nn, Tensor
from pconfigs import pconfiged, pconfig, pdefaults
# 1. Define a model to train
@pconfig(constructs=Model)
class ModelConfig:
image_size: int # Suppose the model needs the input size ahead of time.
# 2. Define a trainer that computes the loss for the model, etc.
@pconfig(constructs=Trainer)
class TrainerConfig:
model_config: ModelConfig
# 3. Define a dataset for training
@pconfig(constructs=Dataset)
class DatasetConfig:
image_size: int
# 4. Define a system that sets up everything for training.
@pconfig(constructs=System)
class SystemConfig:
trainer_config: TrainerConfig
dataset_config: DatasetConfig
Config systems like the one above allow for easy re-configuration. Consider the System class:
@pconfiged(runnable=True)
class System:
config: SystemConfig
def __init__(self):
self.trainer = self.config.trainer_config.construct()
self.dataset = self.config.dataset_config.construct()
Because the Model and Trainer classes are fully described by their respective configs, the System class can construct them without concern for their parameter settings. Furthermore, users can create derived trainer and model classes, and the system will construct those without modification.
Note that the design above simplifies common research practices. In deep learning research, for example, it is common for a class like System to have a config parameter that specifies (say) the dataset type as a string, and all parameters that are needed to construct the dataset. The System must then import all typenames that the user might request, and implement an if-elif-else block to construct every possible type with the associated constructor parameters for that type. In contrast, with @pconfig, that code is unecessary—System can construct any Dataset that the user wants. It’s not the concern of the System to import the submodule types, or understand how to construct them; the submodules are responsible for that.
2.2. Tie parameters between subconfigs.¶
Sometimes subconfigs of different types must share common parameters. For example, the ModelConfig and DatasetConfig above both have a image_size parameter. These should match for the System to function correctly. Systems naturally can manage such tied properties via @pproperty. Let’s augment the SystemConfig:
from pconfigs import pinputs, pproperty
@pconfig(constructs=System)
class SystemConfig:
trainer_config: TrainerConfig
dataset_config: DatasetConfig
@pproperty
def trainer_config(self) -> TrainerConfig:
input_config = pinputs(self).trainer_config
return TrainerConfig(
input_config,
model_config=ModelConfig(
input_config.model_config, # The System ties the dataset
image_size=self.dataset_config.image_size, # ..and model configs together
)
)
In the above example, the user’s input ModelConfig config is used via pinputs(), and the image_size is modified to ensure that it matches the DatasetConfig. This ensures the system will work. Note however,
If the user passes an
input_configthat specifiesModelConfig.image_size, they may not realize that it has no effect. This confusion can cause experimental errors.If the user passes a derived kind of
ModelConfigas input, theSystemConfigwill replace that with an instance of the original type,ModelConfig.The
@ppropertycode must specify all of the subconfig types to set a single parameter,image_size. This makes the code readable and interpretable by code editors, and also longer.
The above points are addressed in the sections below.
2.3. Pin parameters that are tied.¶
When parameters like image_size are shared between submodule configs, users should be notified which parameters will be overwritten by @pproperty logic. You can notify them by using the Pinned typehint (see also Properties)
from pconfigs import pconfig, Pinned, Pin
@pconfig(constructs=Model)
class ModelConfig:
image_size: Pinned[int] # User cannot set image_size.
@pconfig(constructs=System)
class SystemConfig:
@pproperty
def trainer_config(self) -> TrainerConfig:
# ...
image_size=Pin(self.dataset_config.image_size), # Use Pin to set the value.
# ...
As shown above, modules that define config types can use Pin to set properties that are typehinted as Pinned.
Users should not use Pin in config files (files which only specify config instances), and pconfigs.run will throw an error if a runnable config file imports Pin. Users will therefore receive an error if they mistakenly set a parameter that they cannot set. Pinned properties are also marked in printed config (along with their computed values), so users can identify the free parameters that they can set within the system.
2.4. Return the input config type.¶
Users can customize the system behavior by creating new, derived classes and config types. Property functions that tie parameters should respect this by reading the input type:
from typing import Type
@pconfig(constructs=System)
class SystemConfig:
trainer_config: TrainerConfig
dataset_config: DatasetConfig
@pproperty
def trainer_config(self) -> TrainerConfig:
input_config = pinputs(self).trainer_config
InputConfig: Type[TrainerConfig] = type(input_config) # Get the input TrainerType.
return InputConfig( # Return an instance of the input type.
input_config,
model_config=ModelConfig(
input_config.model_config,
image_size=Pin(self.dataset_config.image_size),
)
)
2.5. Make short property functions with psetter.¶
Property functions can become long when subconfig parameters are tied. In the example above, 9 lines of code are used to set image_size. This code is highly readable because code interpreters can jump to type definitions. This pattern can however be less helpful when @pproperty functions need to set parameter values in deeply nested config structures, like the one above.
Use psetter to set deeply nested parameters in subconfigs:
from pconfigs import psetter
@pconfig(constructs=System)
class SystemConfig:
trainer_config: TrainerConfig
dataset_config: DatasetConfig
@pproperty
def trainer_config(self) -> TrainerConfig:
ps = psetter(
type=TrainerConfig,
inputs=pinputs(self).trainer_config,
)
ps.model_config.image_size = Pin(self.dataset_config.image_size)
return psetter(construct=ps)
By using psetter, you can succinctly implement the config nesting logic in the previous section. The user input types will be respected. Furthermore, an error will be thrown if any of the input subconfigs are not derived from the types that are hinted by TrainerConfig.
Note that using psetter sacrificies some ability to jump to type definitions via the python interpreter. It is therefore recommended to use psetter only for deeply nested and complex config structures, where the readability of the @pproperty function is greatly improved. Shallow config structures will be more readable by following the code patterns in the previous sections.