import logging import threading import numpy as np import tree # pip install dm_tree from ray._common.deprecation import Deprecated, deprecation_warning from ray.rllib.utils.annotations import OldAPIStack from ray.rllib.utils.numpy import ( SMALL_NUMBER, ) # Assuming SMALL_NUMBER is a small float like 1e-8 from ray.rllib.utils.serialization import _deserialize_ndarray, _serialize_ndarray from ray.rllib.utils.typing import TensorStructType logger = logging.getLogger(__name__) @OldAPIStack class Filter: """Processes input, possibly statefully.""" def apply_changes(self, other: "Filter", *args, **kwargs) -> None: """Updates self with "new state" from other filter.""" raise NotImplementedError def copy(self) -> "Filter": """Creates a new object with same state as self. Returns: A copy of self. """ raise NotImplementedError def sync(self, other: "Filter") -> None: """Copies all state from other filter to self.""" raise NotImplementedError def reset_buffer(self) -> None: """Creates copy of current state and resets accumulated state""" raise NotImplementedError def as_serializable(self) -> "Filter": raise NotImplementedError @Deprecated(new="Filter.reset_buffer()", error=True) def clear_buffer(self): pass @OldAPIStack class NoFilter(Filter): is_concurrent = True def __call__(self, x: TensorStructType, update=True): # Process no further if already np.ndarray, dict, or tuple. if isinstance(x, (np.ndarray, dict, tuple)): return x try: return np.asarray(x) except Exception: raise ValueError(f"Failed to convert to array: {x!r}") def apply_changes(self, other: "NoFilter", *args, **kwargs) -> None: pass def copy(self) -> "NoFilter": return self def sync(self, other: "NoFilter") -> None: pass def reset_buffer(self) -> None: pass def as_serializable(self) -> "NoFilter": return self # Based on Welford's algorithm for numerical stability # http://www.johndcook.com/blog/standard_deviation/ [4] @OldAPIStack class RunningStat: def __init__(self, shape=()): """Initializes a `RunningStat` instance.""" # Keep always a state and a delta from all attributes. Note, # we use the state for filtering and the delta for updates. # All deltas will be zero(s) after a state synchronization # across different actors. self.num_pushes = 0 self.num_pushes_delta = 0 # Stores the mean. self.mean_array = np.zeros(shape) self.mean_delta_array = np.zeros(shape) # Stores the sum of squared demeaned observations. Note, this # follows Wellington's algorithm. self.sum_sq_diff_array = np.zeros(shape) self.sum_sq_diff_delta_array = np.zeros(shape) def copy(self): """Copies a `RunningStat`.""" # Copy all attributes by creating a new `RunningStat` instance. other = RunningStat(self.shape) other.num_pushes = self.num_pushes other.num_pushes_delta = self.num_pushes_delta other.mean_array = np.copy(self.mean_array) other.mean_delta_array = np.copy(self.mean_delta_array) other.sum_sq_diff_array = np.copy(self.sum_sq_diff_array) other.sum_sq_diff_delta_array = np.copy(self.sum_sq_diff_delta_array) return other def push(self, x): """Updates a `RunningStat` instance by a new value. Args: x: A new value to update mean and sum of squares by. Must have the same shape like the mean. Raises: `ValueError` in case of a shape mismatch. """ x = np.asarray(x) if x.shape != self.mean_array.shape: raise ValueError( "Unexpected input shape {}, expected {}, value = {}".format( x.shape, self.mean_array.shape, x ) ) # Store old mean for Welford's sum of squares update. old_mean = np.copy(self.mean_array) self.num_pushes += 1 # Also increase the delta counter since the last merge. self.num_pushes_delta += 1 if self.num_pushes == 1: self.mean_array[...] = x self.mean_delta_array[...] = x # sum_sq_diff_array remains 0 for the first element else: # Welford's update for mean delta = x - old_mean self.mean_array[...] += delta / self.num_pushes # Update the mean delta. self.mean_delta_array[...] += delta / self.num_pushes # Welford's update for sum of squared differences (S) # S_k = S_{k-1} + (x_k - M_k)(x_k - M_{k-1}). self.sum_sq_diff_array[...] += delta * (x - self.mean_array) # Update the mean sum of squares. self.sum_sq_diff_delta_array[...] += delta * (x - self.mean_array) def update(self, other): """Update this `RunningStat` instance by another one. Args: other: Another `RunningStat` instance whose state should me merged with `self`. """ # Make this explicitly for future changes to avoid ever turning `num_pushes` into # a float (this was a problem in earlier versions). n1_int = self.num_pushes # Note, we use only the delta for the updates, this reduces the risk of numerical # instabilities significantly. n2_int = other.num_pushes_delta # For higher precision use float versions of the counters. n1_flt = float(self.num_pushes) n2_flt = float(other.num_pushes_delta) n_flt = n1_flt + n2_flt # If none of the two `RunningStat`s has seen values, yet, return. if n1_int + n2_int == 0: # Avoid divide by zero, which creates nans return # Numerically stable formula for combining means # M_combined = (n1*M1 + n2*M2) / (n1+n2) # This is equivalent to M1 + delta * n2 / n delta_mean = other.mean_delta_array - self.mean_array self.mean_array += delta_mean * n2_flt / n_flt # Numerically stable formula for combining sums of squared differences (S) # S_combined = S1 + S2 + (n1*n2 / (n1+n2)) * (M1 - M2)^2 [6] delta_mean_sq = delta_mean * delta_mean self.sum_sq_diff_array += other.sum_sq_diff_delta_array + delta_mean_sq * ( n1_flt * n2_flt / n_flt ) # Update the counter with the interger versions of the two counters. self.num_pushes = n1_int + n2_int def __repr__(self): """Represents a `RunningStat` instance. Note, a `RunningStat` is represented by its mean, its standard deviation and the number `n` of values used to compute the two statistics. """ return "(n={}, mean_mean={}, mean_std={})".format( self.n, np.mean(self.mean), np.mean(self.std) ) @property def n(self): """Returns the number of values seen by a `RunningStat` instance.""" return self.num_pushes @property def mean(self): """Returns the (vector) mean estimate of a `RunningStat` instance.""" return self.mean_array @property def var(self): """Returns the (unbiased vector) variance estimate of a `RunningStat` instance.""" # For n=0 or n=1, variance is typically undefined or 0. # Returning 0 for n <= 1 is a common convention for running variance. if self.num_pushes <= 1: return np.zeros_like(self.mean_array).astype(np.float32) # Variance = S / (n-1) for sample variance return (self.sum_sq_diff_array / (float(self.num_pushes) - 1)).astype( np.float32 ) @property def std(self): """Returns the (unbiased vector) std estimate of a `RunningStat` instance.ance.""" # Ensure variance is non-negative before sqrt return np.sqrt(np.maximum(0, self.var)) @property def shape(self): """Returns the shape of the `RunningStat` instance.""" return self.mean_array.shape def to_state(self): """Returns the pickable state of a `RunningStat` instance.""" return { "num_pushes": self.num_pushes, "num_pushes_delta": self.num_pushes_delta, "mean_array": _serialize_ndarray(self.mean_array), "mean_delta_array": _serialize_ndarray(self.mean_delta_array), "sum_sq_diff_array": _serialize_ndarray(self.sum_sq_diff_array), "sum_sq_diff_delta_array": _serialize_ndarray(self.sum_sq_diff_delta_array), } @staticmethod def from_state(state): """Builds a `RunningStat` instance from a pickable state.""" # Need to pass shape to constructor for proper initialization # Assuming shape can be inferred from mean_array in state shape = _deserialize_ndarray(state["mean_array"]).shape running_stats = RunningStat(shape) running_stats.num_pushes = state["num_pushes"] running_stats.num_pushes_delta = state["num_pushes_delta"] running_stats.mean_array = _deserialize_ndarray(state["mean_array"]) running_stats.mean_delta_array = _deserialize_ndarray(state["mean_delta_array"]) running_stats.sum_sq_diff_array = _deserialize_ndarray( state["sum_sq_diff_array"] ) running_stats.sum_sq_diff_delta_array = _deserialize_ndarray( state["sum_sq_diff_delta_array"] ) return running_stats @OldAPIStack class MeanStdFilter(Filter): """Keeps track of a running mean for seen states""" is_concurrent = False def __init__(self, shape, demean=True, destd=True, clip=10.0): self.shape = shape # We don't have a preprocessor, if shape is None (Discrete) or # flat_shape is Tuple[np.ndarray] or Dict[str, np.ndarray] # (complex inputs). flat_shape = tree.flatten(self.shape) self.no_preprocessor = shape is None or ( isinstance(self.shape, (dict, tuple)) and len(flat_shape) > 0 and isinstance(flat_shape, np.ndarray) ) # If preprocessing (flattening dicts/tuples), make sure shape # is an np.ndarray, so we don't confuse it with a complex Tuple # space's shape structure (which is a Tuple[np.ndarray, ...]). if not self.no_preprocessor: self.shape = np.array(self.shape) self.demean = demean self.destd = destd self.clip = clip # Running stats. self.running_stats = tree.map_structure(lambda s: RunningStat(s), self.shape) # In distributed rollouts, each worker sees different states. # The buffer is used to keep track of deltas amongst all the # observation filters. self.buffer = None self.reset_buffer() def reset_buffer(self) -> None: self.buffer = tree.map_structure(lambda s: RunningStat(s), self.shape) def apply_changes( self, other: "MeanStdFilter", with_buffer: bool = False, *args, **kwargs ) -> None: """Applies updates from the buffer of another filter. Args: other: Other filter to apply info from with_buffer: Flag for specifying if the buffer should be copied from other. .. testcode:: :skipif: True a = MeanStdFilter(()) a(1) a(2) print([a.running_stats.n, a.running_stats.mean, a.buffer.n]) .. testoutput:: [2, 1.5, 2] .. testcode:: :skipif: True b = MeanStdFilter(()) b(10) a.apply_changes(b, with_buffer=False) print([a.running_stats.n, a.running_stats.mean, a.buffer.n]) .. testoutput:: [3, 4.333333333333333, 2] .. testcode:: :skipif: True a.apply_changes(b, with_buffer=True) print([a.running_stats.n, a.running_stats.mean, a.buffer.n]) .. testoutput:: [4, 5.75, 1] """ tree.map_structure( lambda rs, other_rs: rs.update(other_rs), self.running_stats, other.buffer ) if with_buffer: self.buffer = tree.map_structure(lambda b: b.copy(), other.buffer) def copy(self) -> "MeanStdFilter": """Returns a copy of `self`.""" other = MeanStdFilter(self.shape) other.sync(self) return other def as_serializable(self) -> "MeanStdFilter": return self.copy() def sync(self, other: "MeanStdFilter") -> None: """Syncs all fields together from other filter. .. testcode:: :skipif: True a = MeanStdFilter(()) a(1) a(2) print([a.running_stats.n, a.running_stats.mean, a.buffer.n]) .. testoutput:: [2, array(1.5), 2] .. testcode:: :skipif: True b = MeanStdFilter(()) b(10) print([b.running_stats.n, b.running_stats.mean, b.buffer.n]) .. testoutput:: [1, array(10.0), 1] .. testcode:: :skipif: True a.sync(b) print([a.running_stats.n, a.running_stats.mean, a.buffer.n]) .. testoutput:: [1, array(10.0), 1] """ self.demean = other.demean self.destd = other.destd self.clip = other.clip self.running_stats = tree.map_structure( lambda rs: rs.copy(), other.running_stats ) self.buffer = tree.map_structure(lambda b: b.copy(), other.buffer) def __call__(self, x: TensorStructType, update: bool = True) -> TensorStructType: if self.no_preprocessor: x = tree.map_structure(lambda x_: np.asarray(x_), x) else: x = np.asarray(x) def _helper(x, rs, buffer, shape): # Discrete|MultiDiscrete spaces -> No normalization. if shape is None: return x # Keep dtype as is througout this filter. orig_dtype = x.dtype if update: if len(x.shape) == len(rs.shape) + 1: # The vectorized case. for i in range(x.shape): rs.push(x[i]) buffer.push(x[i]) else: # The unvectorized case. rs.push(x) buffer.push(x) if self.demean: x = x - rs.mean if self.destd: x = x / (rs.std + SMALL_NUMBER) if self.clip: x = np.clip(x, -self.clip, self.clip) return x.astype(orig_dtype) if self.no_preprocessor: return tree.map_structure_up_to( x, _helper, x, self.running_stats, self.buffer, self.shape ) else: return _helper(x, self.running_stats, self.buffer, self.shape) @OldAPIStack class ConcurrentMeanStdFilter(MeanStdFilter): is_concurrent = True def __init__(self, *args, **kwargs): super(ConcurrentMeanStdFilter, self).__init__(*args, **kwargs) deprecation_warning( old="ConcurrentMeanStdFilter", error=False, help="ConcurrentMeanStd filters are only used for testing and will " "therefore be deprecated in the course of moving to the " "Connetors API, where testing of filters will be done by other " "means.", ) self._lock = threading.RLock() def lock_wrap(func): def wrapper(*args, **kwargs): with self._lock: return func(*args, **kwargs) return wrapper self.__getattribute__ = lock_wrap(self.__getattribute__) def as_serializable(self) -> "MeanStdFilter": """Returns non-concurrent version of current class""" other = MeanStdFilter(self.shape) other.sync(self) return other def copy(self) -> "ConcurrentMeanStdFilter": """Returns a copy of Filter.""" other = ConcurrentMeanStdFilter(self.shape) other.sync(self) return other def __repr__(self) -> str: return "ConcurrentMeanStdFilter({}, {}, {}, {}, {}, {})".format( self.shape, self.demean, self.destd, self.clip, self.running_stats, self.buffer, ) @OldAPIStack def get_filter(filter_config, shape): if filter_config == "MeanStdFilter": return MeanStdFilter(shape, clip=None) elif filter_config == "ConcurrentMeanStdFilter": return ConcurrentMeanStdFilter(shape, clip=None) elif filter_config == "NoFilter": return NoFilter() elif callable(filter_config): return filter_config(shape) else: raise Exception("Unknown observation_filter: " + str(filter_config))