from ray.rllib.core.columns import Columns from ray.rllib.core.models.base import ( ENCODER_OUT, ActorCriticEncoder, Encoder, Model, StatefulActorCriticEncoder, tokenize, ) from ray.rllib.core.models.configs import ( ActorCriticEncoderConfig, CNNEncoderConfig, MLPEncoderConfig, MultiStreamEncoderConfig, RecurrentEncoderConfig, ) from ray.rllib.core.models.torch.base import TorchModel from ray.rllib.core.models.torch.primitives import TorchCNN, TorchMLP from ray.rllib.models.utils import get_activation_fn, get_initializer_fn from ray.rllib.utils.annotations import override from ray.rllib.utils.framework import try_import_torch torch, nn = try_import_torch() class TorchActorCriticEncoder(TorchModel, ActorCriticEncoder): """An actor-critic encoder for torch.""" framework = "torch" def __init__(self, config: ActorCriticEncoderConfig) -> None: TorchModel.__init__(self, config) ActorCriticEncoder.__init__(self, config) class TorchStatefulActorCriticEncoder(TorchModel, StatefulActorCriticEncoder): """A stateful actor-critic encoder for torch.""" framework = "torch" def __init__(self, config: ActorCriticEncoderConfig) -> None: TorchModel.__init__(self, config) StatefulActorCriticEncoder.__init__(self, config) class TorchMLPEncoder(TorchModel, Encoder): def __init__(self, config: MLPEncoderConfig) -> None: TorchModel.__init__(self, config) Encoder.__init__(self, config) # Create the neural network. self.net = TorchMLP( input_dim=config.input_dims[0], hidden_layer_dims=config.hidden_layer_dims, hidden_layer_activation=config.hidden_layer_activation, hidden_layer_use_layernorm=config.hidden_layer_use_layernorm, hidden_layer_use_bias=config.hidden_layer_use_bias, hidden_layer_weights_initializer=config.hidden_layer_weights_initializer, hidden_layer_weights_initializer_config=( config.hidden_layer_weights_initializer_config ), hidden_layer_bias_initializer=config.hidden_layer_bias_initializer, hidden_layer_bias_initializer_config=( config.hidden_layer_bias_initializer_config ), output_dim=config.output_layer_dim, output_activation=config.output_layer_activation, output_use_bias=config.output_layer_use_bias, output_layer_use_layernorm=config.output_layer_use_layernorm, output_weights_initializer=config.output_layer_weights_initializer, output_weights_initializer_config=( config.output_layer_weights_initializer_config ), output_bias_initializer=config.output_layer_bias_initializer, output_bias_initializer_config=config.output_layer_bias_initializer_config, ) @override(Model) def _forward(self, inputs: dict, **kwargs) -> dict: return {ENCODER_OUT: self.net(inputs[Columns.OBS])} class TorchCNNEncoder(TorchModel, Encoder): def __init__(self, config: CNNEncoderConfig) -> None: TorchModel.__init__(self, config) Encoder.__init__(self, config) layers = [] # The bare-bones CNN (no flatten, no succeeding dense). cnn = TorchCNN( input_dims=config.input_dims, cnn_filter_specifiers=config.cnn_filter_specifiers, cnn_activation=config.cnn_activation, cnn_use_layernorm=config.cnn_use_layernorm, cnn_use_bias=config.cnn_use_bias, cnn_kernel_initializer=config.cnn_kernel_initializer, cnn_kernel_initializer_config=config.cnn_kernel_initializer_config, cnn_bias_initializer=config.cnn_bias_initializer, cnn_bias_initializer_config=config.cnn_bias_initializer_config, ) layers.append(cnn) # Add a flatten operation to move from 2/3D into 1D space. if config.flatten_at_end: layers.append(nn.Flatten()) # Create the network from gathered layers. self.net = nn.Sequential(*layers) @override(Model) def _forward(self, inputs: dict, **kwargs) -> dict: return {ENCODER_OUT: self.net(inputs[Columns.OBS])} class TorchGRUEncoder(TorchModel, Encoder): """A recurrent GRU encoder. On the usage of `torch.utils._pytree`: Unlike `dm_tree`/`tree`, it is traceable by `torch.export`/TorchDynamo (used by the modern `torch.onnx.export(dynamo=True)` path), so we use it for the state (un)mapping inside the recurrent encoders. """ def __init__(self, config: RecurrentEncoderConfig) -> None: TorchModel.__init__(self, config) # Maybe create a tokenizer if config.tokenizer_config is not None: self.tokenizer = config.tokenizer_config.build(framework="torch") gru_input_dims = config.tokenizer_config.output_dims else: self.tokenizer = None gru_input_dims = config.input_dims # We only support 1D spaces right now. assert len(gru_input_dims) == 1 gru_input_dim = gru_input_dims[0] gru_weights_initializer = get_initializer_fn( config.hidden_weights_initializer, framework="torch" ) gru_bias_initializer = get_initializer_fn( config.hidden_bias_initializer, framework="torch" ) # Create the torch GRU layer. self.gru = nn.GRU( gru_input_dim, config.hidden_dim, config.num_layers, batch_first=config.batch_major, bias=config.use_bias, ) # Initialize, GRU weights, if necessary. if gru_weights_initializer: gru_weights_initializer( self.gru.weight, **config.hidden_weights_initializer_config or {} ) # Initialize GRU bias, if necessary. if gru_bias_initializer: gru_bias_initializer( self.gru.weight, **config.hidden_bias_initializer_config or {} ) @override(Model) def get_initial_state(self): return { "h": torch.zeros(self.config.num_layers, self.config.hidden_dim), } @override(Model) def _forward(self, inputs: dict, **kwargs) -> dict: outputs = {} if self.tokenizer is not None: # Push observations through the tokenizer encoder if we built one. out = tokenize(self.tokenizer, inputs, framework="torch") else: # Otherwise, just use the raw observations. out = inputs[Columns.OBS].float() # States are batch-first when coming in. Make them layers-first. states_in = torch.utils._pytree.tree_map( lambda s: s.transpose(0, 1), inputs[Columns.STATE_IN] ) out, states_out = self.gru(out, states_in["h"]) states_out = {"h": states_out} # Insert them into the output dict. outputs[ENCODER_OUT] = out outputs[Columns.STATE_OUT] = torch.utils._pytree.tree_map( lambda s: s.transpose(0, 1), states_out ) return outputs class TorchLSTMEncoder(TorchModel, Encoder): """A recurrent LSTM encoder. On the usage of `torch.utils._pytree`: Unlike `dm_tree`/`tree`, it is traceable by `torch.export`/TorchDynamo (used by the modern `torch.onnx.export(dynamo=True)` path), so we use it for the state (un)mapping inside the recurrent encoders. """ def __init__(self, config: RecurrentEncoderConfig) -> None: TorchModel.__init__(self, config) # Maybe create a tokenizer if config.tokenizer_config is not None: self.tokenizer = config.tokenizer_config.build(framework="torch") lstm_input_dims = config.tokenizer_config.output_dims else: self.tokenizer = None lstm_input_dims = config.input_dims # We only support 1D spaces right now. assert len(lstm_input_dims) == 1 lstm_input_dim = lstm_input_dims[0] lstm_weights_initializer = get_initializer_fn( config.hidden_weights_initializer, framework="torch" ) lstm_bias_initializer = get_initializer_fn( config.hidden_bias_initializer, framework="torch" ) # Create the torch LSTM layer. self.lstm = nn.LSTM( lstm_input_dim, config.hidden_dim, config.num_layers, batch_first=config.batch_major, bias=config.use_bias, ) # Initialize LSTM layer weigths and biases, if necessary. for layer in self.lstm.all_weights: if lstm_weights_initializer: lstm_weights_initializer( layer[0], **config.hidden_weights_initializer_config or {} ) lstm_weights_initializer( layer[1], **config.hidden_weights_initializer_config or {} ) if lstm_bias_initializer: lstm_bias_initializer( layer[2], **config.hidden_bias_initializer_config or {} ) lstm_bias_initializer( layer[3], **config.hidden_bias_initializer_config or {} ) @override(Model) def get_initial_state(self): return { "h": torch.zeros(self.config.num_layers, self.config.hidden_dim), "c": torch.zeros(self.config.num_layers, self.config.hidden_dim), } @override(Model) def _forward(self, inputs: dict, **kwargs) -> dict: outputs = {} if self.tokenizer is not None: # Push observations through the tokenizer encoder if we built one. out = tokenize(self.tokenizer, inputs, framework="torch") else: # Otherwise, just use the raw observations. out = inputs[Columns.OBS].float() # States are batch-first when coming in. Make them layers-first. states_in = torch.utils._pytree.tree_map( lambda s: s.transpose(0, 1), inputs[Columns.STATE_IN] ) out, states_out = self.lstm(out, (states_in["h"], states_in["c"])) states_out = {"h": states_out[0], "c": states_out[1]} # Insert them into the output dict. outputs[ENCODER_OUT] = out outputs[Columns.STATE_OUT] = torch.utils._pytree.tree_map( lambda s: s.transpose(0, 1), states_out ) return outputs class TorchMultiStreamEncoder(TorchModel, Encoder): """An encoder that encodes multiple input streams separately. Each input stream is encoded with its own encoder defined in `base_encoder_configs`. The resulting embeddings are concatenated and passed through a final fusion network to produce the output embedding. """ def __init__(self, config: MultiStreamEncoderConfig) -> None: TorchModel.__init__(self, config) Encoder.__init__(self, config) # Create the neural network for observation stream. self.base_encoders = nn.ModuleDict( { k: cfg.build(framework="torch") for k, cfg in sorted(config.base_encoder_configs.items()) } ) # Get activation functions. self.hidden_activation = ( get_activation_fn(config.hidden_layer_activation, framework="torch") or nn.Identity ) # Calculate total embed dim. self._total_embed_dim = sum( cfg.output_dims[0] for cfg in config.base_encoder_configs.values() ) # Build fusion layers (hidden layers with skip connections). fusion_layers = [] if config.hidden_layer_dims: # Input layer: total_embed_dim -> hidden_layer_dims[0] input_layer = nn.Linear( self._total_embed_dim, config.hidden_layer_dims[0], bias=config.hidden_layer_use_bias, ) fusion_layers.append(input_layer) # Intermediate fusion layers with skip connections. for i in range(1, len(config.hidden_layer_dims)): fusion_layers.append( nn.Linear( config.hidden_layer_dims[i - 1] + self._total_embed_dim, config.hidden_layer_dims[i], bias=config.hidden_layer_use_bias, ) ) # Initialize hidden layer weights if necessary. if config.hidden_layer_weights_initializer: hidden_weights_initializer = get_initializer_fn( config.hidden_layer_weights_initializer, framework="torch" ) for layer in fusion_layers: hidden_weights_initializer( layer.weight, **config.hidden_layer_weights_initializer_config or {} ) # Initialize hidden layer bias if necessary. if config.hidden_layer_bias_initializer: hidden_bias_initializer = get_initializer_fn( config.hidden_layer_bias_initializer, framework="torch" ) for layer in fusion_layers: hidden_bias_initializer( layer.bias, **config.hidden_layer_bias_initializer_config or {} ) self.fusion_layers = nn.ModuleList(fusion_layers) # Build output layer only if output_layer_dim is defined. self.output_layer = None if config.output_layer_dim is not None: # Get output activation function. self.output_activation = ( get_activation_fn(config.output_layer_activation, framework="torch") or nn.Identity ) # Determine input dim for output layer. if config.hidden_layer_dims: output_input_dim = config.hidden_layer_dims[-1] + self._total_embed_dim else: output_input_dim = self._total_embed_dim self.output_layer = nn.Linear( output_input_dim, config.output_layer_dim, bias=config.output_layer_use_bias, ) # Initialize output layer weights if necessary. if config.output_layer_weights_initializer: output_weights_initializer = get_initializer_fn( config.output_layer_weights_initializer, framework="torch" ) output_weights_initializer( self.output_layer.weight, **config.output_layer_weights_initializer_config or {} ) # Initialize output layer bias if necessary. if config.output_layer_bias_initializer: output_bias_initializer = get_initializer_fn( config.output_layer_bias_initializer, framework="torch" ) output_bias_initializer( self.output_layer.bias, **config.output_layer_bias_initializer_config or {} ) @override(Model) def _forward(self, inputs, **kwargs): # Run the inputs through the base encoders. keys = sorted(self.config.base_encoder_configs.keys()) encoder_outs = [ self.base_encoders[k]({Columns.OBS: inputs[k]})[ENCODER_OUT] for k in keys ] # Concatenate the embeddings. embeds = torch.cat(encoder_outs, dim=-1) # Pass through fusion layers (if any). out = embeds if self.fusion_layers: out = self.hidden_activation()(self.fusion_layers[0](embeds)) for layer in self.fusion_layers[1:]: out = self.hidden_activation()(layer(torch.cat([out, embeds], dim=-1))) # Pass through output layer (if defined). if self.output_layer is not None: # Concatenate with skip connection if we have fusion layers. if self.fusion_layers: out = torch.cat([out, embeds], dim=-1) out = self.output_activation()(self.output_layer(out)) return {ENCODER_OUT: out}