How to monitor a filtered version of a metric in EarlyStopping callback in tensorflow?

Viewed 464

I always had this problem. When training neural networks, the validation loss can be noisy (sometimes even the training loss if you are using stochastic layers such as dropout). This is especially true when the dataset is small.

This makes that when using callbacks such as EarlyStopping or ReduceLROnPlateau, these are triggered too early (even using large patience). Also, sometimes I don't want to use large patience in the ReduceLROnPLateau callback.

A solution to this is instead of directly monitoring a certain metric (e.g. val_loss), to monitor a filtered version (across epochs) of the metric (e.g. exponential moving average of val_loss). However, I do not see any easy way to solve this because the callbacks only accept metrics that not depend on the previous epochs. I have tried using a custom training loop to reproduce the functionality of these callbacks with my custom filtered metric, but I don't think it is the correct way. Is there another (simpler) way to do the monitor the filtered version of the loss in the callbacks, without reimplementing the whole functionality of the callbacks?

Edit:

This is what I mean by monitoring a filtered version of a metric. The current EarlyStopping works something like this:

best_loss = float('inf')
best_epoch = 0
for epoch in range(n_epochs):
    # ...
    new_loss = # compute loss of current epoch
    if new_loss < best_loss:
        best_loss = new_loss
        best_epoch = epoch
    if epoch - best_epoch > patience:
        break

Monitoring the filtered metric would be like this:

best_loss = float('inf')
filtered_loss = 10 # example initial value
best_epoch = 0
for epoch in range(n_epochs):
    # ...
    new_loss = # compute loss of current epoch
    filtered_loss = 0.1*new_loss + 0.9*filtered_loss
    if filtered_loss < best_loss:
        best_loss = filtered_loss
        best_epoch = epoch
    if epoch - best_epoch > patience:
        break
4 Answers

I would go down the path of writing a custom Metric (subclassing keras.Metric). Keras Metrics are stateful across batches. To use per epoch smoothing you need to have this metric refer to an additional set of tensors that store the previous epoch results and reset these values manually... e.g. before training begins. Your callbacks can then refer to this metric by name.

Example implementation:

from tensorflow.keras import backend as K

class SmothedMSE(keras.metrics.Metric):
    def __init__(self, **kwargs):
        super().__init__(name='smothed_mse', **kwargs)

        self.history = self.add_weight(name='history', shape=(4,), initializer='zeros')
        self.sum = self.add_weight(name='sum', initializer='zeros')
        self.count = self.add_weight(name='count', initializer='zeros', dtype=tf.int32)

    def clear(self):
        K.set_value(self.history, np.zeros(4))

    def update_state(self, y_true, y_pred, sample_weight=None):
        x = keras.metrics.mse(y_true, y_pred)
        self.sum.assign_add(tf.reduce_sum(x, axis=-1))
        self.count.assign_add(tf.shape(x)[0])

    def result(self):
        h = tf.reduce_sum(self.history)
        nhistory = tf.roll(self.history, shift=1, axis=0)
        x = tf.divide(self.sum, tf.cast(self.count, tf.float32))
        nhistory = tf.tensor_scatter_nd_update(nhistory, [[0]], tf.expand_dims(x, 0))
        self.history.assign(nhistory)
        return (x + h) / 2.
    
    def reset_state(self):
        K.set_value(self.sum, 0.)
        K.set_value(self.count, 0)

more details at: https://colab.research.google.com/drive/1K1a1fovqqs-ARbD_BVnhnYlrMqvk4cCo?usp=sharing

Here is a relatively simple way of approaching the problem. You create a custom tf.keras.metrics.Metric that has a tf.keras.metrics.Metric as an attribute, and a certain memory size. The result of the metric will be the mean of the values in the memory. You monitor that metric in your callbacks.

class AveragePreviousMetric(tf.keras.metrics.Metric):
    def __init__(self, metric, num_updates, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.num_updates = num_updates
        self.accumulator = tf.Variable(tf.zeros(self.num_updates))
        self.idx = tf.Variable(0)
        self.metric = metric

    def update_state(self, *args, **kwargs):
        self.metric.update_state(*args, **kwargs)

    def reset_states(self, *args, **kwargs):
        # a simple queue with modulo indexing
        update_op = self.accumulator.scatter_update(
            tf.IndexedSlices(self.metric.result(), self.idx % self.num_updates)
        )
        # we just want to be sure to only increase the counter 
        # if the variable has been updated
        with tf.control_dependencies([update_op]):
            self.idx.assign_add(1)
        # we reset the state of the internal metric
        self.metric.reset_states(*args, **kwargs)

    def _result(self):
        return self.metric.result()

    def _filtered_result(self):
        return 0.9 * self.accumulator[: self.idx] + 0.1 * self._result()

    def result(self):
        # if the idx is zero, the accumulator is empty and _filtered_result will yield an error
        return tf.cond(tf.equal(self.idx, 0), self._result, self._filtered_result)

And a simple example on how to use it:

m = tf.keras.Sequential([tf.keras.layers.Dense(1, input_shape=(1,))])
x = tf.random.uniform((100, 1))
y = tf.random.uniform((100, 1))
avg_mse = AveragePreviousMetric(
    tf.keras.metrics.MeanSquaredError(), name="avg_mse", num_updates=5
)
m.compile(loss="mse", metrics=[avg_mse, "mse"], optimizer="sgd")
hist = m.fit(x, y, epochs=5, verbose=2)

When this example is run:

Epoch 1/5
4/4 - 0s - loss: 0.1956 - avg_mse: 0.1956 - mse: 0.1956
Epoch 2/5
4/4 - 0s - loss: 0.1943 - avg_mse: 0.1954 - mse: 0.1943
Epoch 3/5
4/4 - 0s - loss: 0.1932 - avg_mse: 0.1948 - mse: 0.1932
Epoch 4/5
4/4 - 0s - loss: 0.1919 - avg_mse: 0.1941 - mse: 0.1919
Epoch 5/5
4/4 - 0s - loss: 0.1908 - avg_mse: 0.1935 - mse: 0.1908

I often encountered this problem so I wrote a custom callback that initially monitors training accuracy and adjust the learning rate based on that. This avoids the problem where validation loss can initially change radically, I have a parameter called 'threshold'. Once the training accuracy exceeds the threshold value the callback switches over to monitor validation loss and adjusts the learning rate based on that. At the conclusion of training the callback also always sets the weights of the model to the weights for the epoch with the lowest loss. This code is to lengthy to provide here. However to learn how to write a custom callback the documentation is here.

Thanks to the contributions of @Pedro Marques and @lescurel, I managed to make a metric class that smooths any other metric across the epochs. It is also able to work when using a validation set during training thanks to the usage of different state variables.

There is a base class that manages the state logic:

class SmoothMetric(tf.keras.metrics.Metric):
    def __init__(self, metric, name=None, **kwargs):
        if name is None:
            name = 'smooth_' + metric.name
        super().__init__(name=name, **kwargs)
        self.metric = metric
        self.in_test_step = tf.Variable(False)
        self.prev_in_test_step = tf.Variable(False)
        self.train_states = {}
        self.test_states = {}

    def add_state_variable(self, name, **kwargs):
        self.train_states[name] = tf.Variable(**kwargs)
        self.test_states[name] = tf.Variable(**kwargs)

    def get_state_variable(self, name):
        return tf.cond(self.in_test_step, lambda: self.test_states[name], lambda: self.train_states[name])

    def update_state(self, *args, **kwargs):
        self.metric.update_state(*args, **kwargs)
        self.prev_in_test_step.assign(self.in_test_step)

    def update_filter_state_prevoius_epoch(self):
        raise NotImplementedError('Needs to be overwritten')

    def reset_state(self, *args, **kwargs):
        # update state of the previous mode
        tmp = self.in_test_step.read_value()
        self.in_test_step.assign(self.prev_in_test_step)
        self.update_filter_state_prevoius_epoch()
        self.in_test_step.assign(tmp)
        self.metric.reset_state(*args, **kwargs)

Then, I define a class that inherits from the previous, which implements the filter. In this case, it implements the Exponential Moving Average, but it is possible to make other filters such as the Simple Moving Average.

class SmoothMetricEMA(SmoothMetric):
    def __init__(self, metric, alpha=0.1, name=None, **kwargs) -> None:
        super().__init__(metric=metric, name=name, **kwargs)
        self.alpha = alpha
        self.add_state_variable('accum', initial_value=0.0)
        self.add_state_variable('idx', initial_value=0)

    def update_filter_state_prevoius_epoch(self):
        self.get_state_variable('accum').assign(self.result())
        self.get_state_variable('idx').assign_add(1)

    def _result(self):
        return self.metric.result()

    def _filtered_result(self):
        return (1 - self.alpha) * self.get_state_variable('accum') + self.alpha * self._result()

    def result(self):
        # if the idx is zero, the accumulator is 0 and _filtered_result will not give the correct result
        return tf.cond(tf.equal(self.get_state_variable('idx'), 0), self._result, self._filtered_result)

Here is the SMA filter alternative:

class SmoothMetricSMA(SmoothMetric):
    def __init__(self, metric, n_samples=10, remove_outliers=None, name=None, **kwargs) -> None:
        super().__init__(metric=metric, name=name, **kwargs)
        self.queue_size = n_samples - 1
        self.remove_outliers = remove_outliers
        self.add_state_variable('accum', initial_value=tf.zeros(self.queue_size))
        self.add_state_variable('idx', initial_value=0)

    def update_filter_state_prevoius_epoch(self):
        self.get_state_variable('accum').scatter_update(
            tf.IndexedSlices(self.metric.result(), self.get_state_variable('idx') % self.queue_size))
        self.get_state_variable('idx').assign_add(1)

    def _result(self):
        return self.metric.result()

    def _filtered_result(self):
        queue = tf.concat([self.get_state_variable('accum')[:self.get_state_variable('idx')], tf.reshape(self._result(), (1,))], axis=0)
        if self.remove_outliers is not None:
            queue = tf.sort(queue)
            eff_size = tf.cast(tf.minimum(self.get_state_variable('idx')+1, self.queue_size), tf.float32)
            queue = queue[tf.cast(tf.math.floor(eff_size*self.remove_outliers/2), tf.int32):tf.cast(tf.math.ceil(eff_size*(1-self.remove_outliers/2)), tf.int32)]
        return tf.reduce_mean(queue)

    def result(self):
        # if the idx is zero, the accumulator is 0 and _filtered_result will not give the correct result
        return tf.cond(tf.equal(self.get_state_variable('idx'), 0), self._result, self._filtered_result)

Finally, for the filter to work, it is necessary to use the following callback. This tells the filter whether it is in training or test mode.

class NotifySmoothMetricsCallback(tf.keras.callbacks.Callback):
    """
    Notifies the smooth metric if it is on train or test step.
    """
    def on_test_begin(self, logs):
        for metric in self.model.metrics:
            if isinstance(metric, SmoothMetric):
                metric.in_test_step.assign(True)
    def on_test_end(self,  logs):
        for metric in self.model.metrics:
            if isinstance(metric, SmoothMetric):
                metric.in_test_step.assign(False)

    def on_epoch_begin(self, epoch, logs):
        for metric in self.model.metrics:
            if isinstance(metric, SmoothMetric):
                metric.in_test_step.assign(False)
    def on_epoch_end(self, epoch, logs):
        for metric in self.model.metrics:
            if isinstance(metric, SmoothMetric):
                metric.in_test_step.assign(False)
    def on_train_begin(self, logs):
        for metric in self.model.metrics:
            if isinstance(metric, SmoothMetric):
                metric.in_test_step.assign(False)

Here is an example of how to use these classes:

metrics=[
    SmoothMetricEMA(tf.metrics.MeanSquaredError(), alpha=0.1, name='smooth'),
]
callbacks = [
    tf.keras.callbacks.EarlyStopping(monitor='val_smooth', patience=10, verbose=1),
    NotifySmoothMetricsCallback(),
]
model.compile(optimizer='adam', loss='mse', metrics=metrics)

model.fit(x_train, y_train, batch_size=32, epochs=100,
    validation_data=(x_val, y_val),
    callbacks=callbacks,
)
Related