Source code for superstats.networks.recurrent

"""Recurrent summary network layers."""

from collections.abc import Sequence
from numbers import Integral

import bayesflow as bf

import keras

from bayesflow.types import Tensor
from bayesflow.utils import layer_kwargs
from bayesflow.utils.serialization import serializable
from bayesflow.networks.helpers import Time2Vec

from .utils import expand_singletons_to_common_length


[docs] @serializable("custom") class RecurrentNet(bf.networks.SummaryNetwork): """Implements a sequence-producing recurrent network. Parameters ---------- summary_dim : int, optional, default: 64 Per-timestep output dimensionality. hidden_dim : int or sequence of int, optional, default: (128, 128) Dimensionality of the hidden state in each recurrent layer. recurrent_type : {"lstm", "gru"} or sequence, optional, default: "lstm" Type of recurrent unit to use in each layer. bidirectional : bool or sequence of bool, optional, default: True If True, the layer is processed bidirectionally and the two directions are merged according to `merge_mode`. If False, the layer processes the sequence forward only. merge_mode : {"sum", "mul", "ave", "concat"} or sequence, optional, default: "sum" Mode used to merge forward and backward outputs in bidirectional layers. layer_norm : bool or sequence of bool, optional, default: True Whether to apply layer normalization after each recurrent layer. time_embed_dim : int, optional, default: 16 The number of features learned by the time2vec preprocessing layer. dropout : float or sequence of float in [0, 1], optional, default: 0.05 Dropout rate applied after the recurrent layer(s). **kwargs Additional keyword arguments passed to the parent class constructor. Notes ----- All recurrent layers are built with `return_sequences=True`, so the projection is applied per timestep and the sequence-length axis is retained. If any of `hidden_dim`, `recurrent_type`, `bidirectional`, `merge_mode`, or `layer_norm` is a sequence with more than one element, all single values are expanded to that length. Multiple multi-element sequences must have the same length. Raises ------ ValueError If per-layer parameter sequences have incompatible lengths or contain invalid values. """ def __init__( self, summary_dim: int = 64, hidden_dim: int | Sequence[int] = (128, 128), recurrent_type: str | Sequence[str] = "gru", bidirectional: bool | Sequence[bool] = True, merge_mode: str | Sequence[str] = "sum", layer_norm: bool | Sequence[bool] = True, time_axis: int = 0, time_embed_dim: int = 16, dropout: float = 0.05, **kwargs, ): super().__init__(**layer_kwargs(kwargs)) recurrent_kwargs = expand_singletons_to_common_length( hidden_dim=hidden_dim, recurrent_type=recurrent_type, bidirectional=bidirectional, merge_mode=merge_mode, layer_norm=layer_norm, ) recurrent_layers = [] normalization_layers = [] for constructor_kwargs in zip( recurrent_kwargs["hidden_dim"], recurrent_kwargs["recurrent_type"], recurrent_kwargs["bidirectional"], recurrent_kwargs["merge_mode"], recurrent_kwargs["layer_norm"], ): hidden_dim_, recurrent_type_, bidirectional_, merge_mode_, layer_norm_ = self._validate_layer_kwargs( *constructor_kwargs ) recurrent_constructor = self._recurrent_constructor(recurrent_type_) recurrent_layer = recurrent_constructor(units=hidden_dim_, return_sequences=True) if bidirectional_: recurrent_layer = keras.layers.Bidirectional(recurrent_layer, merge_mode=merge_mode_) recurrent_layers.append(recurrent_layer) normalization_layers.append(keras.layers.LayerNormalization(axis=-1) if layer_norm_ else None) self.recurrent_layers = recurrent_layers self.normalization_layers = normalization_layers self.dropout_layer = keras.layers.Dropout(dropout) self.summary_stats = keras.layers.Conv1D(filters=summary_dim, kernel_size=1) self.time_embedding = Time2Vec(time_embed_dim) self.summary_dim = summary_dim self.time_axis = time_axis self.time_embed_dim = time_embed_dim self.hidden_dim = hidden_dim self.recurrent_type = recurrent_type self.bidirectional = bidirectional self.merge_mode = merge_mode self.layer_norm = layer_norm self.dropout = dropout
[docs] def call(self, time_series: Tensor, training: bool = False) -> Tensor: """Compute per-timestep summary statistics for a batch of time series. Parameters ---------- time_series : Tensor of shape (batch_size, sequence_length, num_features) Input time series. training : bool, optional, default: False Whether the layer is in training mode (affects dropout). Returns ------- summary : Tensor - the learned summary of shape (batch_size, sequence_length, summary_dim). """ time = time_series[..., self.time_axis] indices = list(range(keras.ops.shape(time_series)[-1])) indices.pop(self.time_axis) out = keras.ops.take(time_series, indices, axis=-1) out = self.time_embedding(out, t=time) for recurrent_layer, normalization_layer in zip(self.recurrent_layers, self.normalization_layers): out = recurrent_layer(out) if normalization_layer is not None: out = normalization_layer(out, training=training) out = self.dropout_layer(out, training=training) return self.summary_stats(out)
[docs] def get_config(self): config = super().get_config() config.update( { "summary_dim": self.summary_dim, "hidden_dim": self.hidden_dim, "recurrent_type": self.recurrent_type, "bidirectional": self.bidirectional, "merge_mode": self.merge_mode, "layer_norm": self.layer_norm, "dropout": self.dropout, "time_axis": self.time_axis, "time_embed_dim": self.time_embed_dim, } ) return config
@staticmethod def _recurrent_constructor(recurrent_type: str): if recurrent_type == "lstm": return keras.layers.LSTM if recurrent_type == "gru": return keras.layers.GRU raise ValueError(f"recurrent_type must be one of ['lstm', 'gru'], not {recurrent_type!r}.") @staticmethod def _validate_layer_kwargs( hidden_dim: int, recurrent_type: str, bidirectional: bool, merge_mode: str, layer_norm: bool, ) -> tuple[int, str, bool, str, bool]: if isinstance(hidden_dim, bool) or not isinstance(hidden_dim, Integral) or hidden_dim <= 0: raise ValueError(f"hidden_dim must be a positive integer, not {hidden_dim!r}.") if not isinstance(recurrent_type, str): raise ValueError(f"recurrent_type must be one of ['lstm', 'gru'], not {recurrent_type!r}.") recurrent_type = recurrent_type.lower() if not isinstance(bidirectional, bool): raise ValueError(f"bidirectional must be a boolean, not {bidirectional!r}.") if not isinstance(merge_mode, str): raise ValueError(f"merge_mode must be one of ['sum', 'mul', 'ave', 'concat'], not {merge_mode!r}.") merge_mode = merge_mode.lower() if merge_mode not in {"sum", "mul", "ave", "concat"}: raise ValueError(f"merge_mode must be one of ['sum', 'mul', 'ave', 'concat'], not {merge_mode!r}.") if not isinstance(layer_norm, bool): raise ValueError(f"layer_norm must be a boolean, not {layer_norm!r}.") return hidden_dim, recurrent_type, bidirectional, merge_mode, layer_norm