English (unofficial) translations of posts at kexue.fm
Source

``Making Keras Even Cooler!'': Niche Custom Optimizers

Translated by DeepSeek V4 Pro. Translations can be inaccurate, please refer to the original post for important stuff.

Continuing from the previous post “Making Keras Even Cooler!”: Exquisite Layers and Fancy Callbacks...

Today we look at a niche requirement: custom optimizers.

Upon reflection, regardless of the framework used, the need for custom optimizers is truly a niche among niches. Generally speaking, for most tasks, we can mindlessly use Adam, while parameter-tuning “alchemy” masters usually use SGD to achieve better results. In other words, whether you are an expert or a novice, there is rarely a need to customize an optimizer.

So, what is the value of this article? In some scenarios, it can be somewhat useful. For example, by learning how to write optimizers in Keras, you can gain a deeper understanding of algorithms like gradient descent. You can also see how concise and elegant the Keras source code is. Furthermore, sometimes we can implement specific functions through custom optimizers, such as rewriting optimizers for simple models (like Word2Vec) by hard-coding gradients instead of using automatic differentiation to make the algorithm faster; custom optimizers can also implement features like “soft batches.”

Keras Optimizers

First, let’s look at the built-in optimizer code in Keras, located at:
https://github.com/keras-team/keras/blob/master/keras/optimizers.py

For simplicity, we can start with SGD. Of course, the SGD algorithm in Keras has already integrated momentum, Nesterov, decay, etc. While convenient to use, it is not ideal for learning. Therefore, I have simplified it slightly to provide an example of a pure SGD algorithm:

from keras.legacy import interfaces
from keras.optimizers import Optimizer
from keras import backend as K


class SGD(Optimizer):
    """Simple custom SGD optimizer in Keras
    """

    def __init__(self, lr=0.01, **kwargs):
        super(SGD, self).__init__(**kwargs)
        with K.name_scope(self.__class__.__name__):
            self.iterations = K.variable(0, dtype='int64', name='iterations')
            self.lr = K.variable(lr, name='lr')

    @interfaces.legacy_get_updates_support
    def get_updates(self, loss, params):
        """Main parameter update algorithm
        """
        grads = self.get_gradients(loss, params) # Get gradients
        self.updates = [K.update_add(self.iterations, 1)] # Define set of assignment operators
        self.weights = [self.iterations] # Weights brought by the optimizer, saved with the model
        for p, g in zip(params, grads):
            # Gradient descent
            new_p = p - self.lr * g
            # If there are constraints, apply them to parameters
            if getattr(p, 'constraint', None) is not None:
                new_p = p.constraint(new_p)
            # Add assignment
            self.updates.append(K.update(p, new_p))

        return self.updates

    def get_config(self):
        config = {'lr': float(K.get_value(self.lr))}
        base_config = super(SGD, self).get_config()
        return dict(list(base_config.items()) + list(config.items()))

It shouldn’t need much explanation, right? Doesn’t it feel exceptionally simple? Defining an optimizer isn’t such a high-brow task after all.

Implementing “Soft Batch”

Now let’s implement a slightly more complex function, the so-called “soft batch.” I’m not entirely sure if that’s the official name, but let’s call it that for now. The scenario is: suppose the model is quite large, and your GPU can only handle a batch size of 16, but you want to achieve the effect of a batch size of 64. What can you do? One possible solution is to calculate a batch size of 16 each time, cache the gradients, and only update the parameters after 4 batches. That is, gradients are calculated for every small batch, but parameters are updated only once every 4 batches.

Update July 8, 2019: The following implementation is incorrect and does not achieve the expected effect. For a corrected implementation, please see “Keras Gradient Accumulation Optimizer: Trading Time for Results.”

If there is truly a need for this, it can only be solved by modifying the optimizer. Based on the previous SGD, the reference code is as follows:

class7 MySGD(Optimizer):
    """Simple custom SGD optimizer in Keras
    Updates parameters only after a certain number of batches
    """
    def __init__(self, lr=0.01, steps_per_update=1, **kwargs):
        super(MySGD, self).__init__(**kwargs)
        with K.name_scope(self.__class__.__name__):
            self.iterations = K.variable(0, dtype='int64', name='iterations')
            self.lr = K.variable(lr, name='lr')
            self.steps_per_update = steps_per_update # How many batches per update

    @interfaces.legacy_get_updates_support
    def get_updates(self, loss, params):
        """Main parameter update algorithm
        """
        shapes = [K.int_shape(p) for p in params]
        sum_grads = [K.zeros(shape) for shape in shapes] # Accumulated gradients for descent
        grads = self.get_gradients(loss, params) # Current batch gradients
        self.updates = [K.update_add(self.iterations, 1)] # Define set of assignment operators
        self.weights = [self.iterations] + sum_grads # Weights saved with the model
        for p, g, sg in zip(params, grads, sum_grads):
            # Gradient descent
            new_p = p - self.lr * sg / float(self.steps_per_update)
            # If there are constraints, apply them to parameters
            if getattr(p, 'constraint', None) is not None:
                new_p = p.constraint(new_p)
            cond = K.equal(self.iterations % self.steps_per_update, 0)
            # Update parameters only when condition is met
            self.updates.append(K.switch(cond, K.update(p, new_p), p))
            # Re-accumulate if condition met, otherwise continue accumulating
            self.updates.append(K.switch(cond, K.update(sg, g), K.update(sg, sg+g)))
        return self.updates

    def get_config(self):
        config = {'lr': float(K.get_value(self.lr)),
                  'steps_per_update': self.steps_per_update}
        base_config = super(MySGD, self).get_config()
        return dict(list(base_config.items()) + list(config.items()))

This should also be easy to understand. If momentum is involved, the code becomes a bit more complex, but the logic remains the same. The key is to introduce an additional variable to store accumulated gradients and use a cond to control whether to update. Everything the original optimizer does should only happen when cond is True (using the accumulated gradients instead). Compared to the original SGD, the changes are not significant.

“Intrusive” Optimizer

The scheme for implementing optimizers above is standard, following Keras’s design specifications, making it quite easy. However, I once wanted to implement an optimizer that could not be achieved this way. After reading the source code, I derived an “intrusive” approach. This method acts like a “plugin” to implement the required functionality, but it is not a standard implementation. I would like to share it with you here.

The original requirement stems from a previous article “Optimization Algorithms from a Dynamics Perspective (I): From SGD to Momentum Acceleration”, which pointed out that gradient descent optimizers can be viewed as Euler’s method for solving systems of differential equations. This leads to the thought: there are many methods more advanced than Euler’s method for solving differential equations; can they be applied to deep learning? For example, the slightly more advanced “Heun’s method”: \begin{aligned} \tilde{p}_{i+1} &= p_i + \epsilon g(p_i)\\ p_{i+1} &= p_i + \frac{1}{2}\epsilon \big[g(p_i)+g(\tilde{p}_{i+1})\big] \end{aligned} where p is the parameter (vector), g is the gradient, and p_i represents the result of p at the i-th iteration. This algorithm requires two steps. Essentially, it performs a standard gradient descent step first (probing), then takes an average based on the probe result to get a more accurate step. Equivalently, it can be rewritten as: \begin{aligned} \tilde{p}_{i+1} &= p_i + \epsilon g(p_i)\\ p_{i+1} &= \tilde{p}_{i+1} + \frac{1}{2}\epsilon \big[g(\tilde{p}_{i+1}) - g(p_i)\big] \end{aligned} This clearly shows that the second step is actually a fine-tuning of the gradient descent.

However, implementing such algorithms poses a challenge: gradients must be calculated twice, once for parameter p_i and once for parameter \tilde{p}_{i+1}. In the previous optimizer definition, the get_updates method can only execute one step (corresponding to a single sess.run in the TensorFlow framework; those familiar with TF know it is difficult to implement this requirement with a single sess.run). Therefore, this algorithm cannot be implemented that way. After researching the Keras model training source code, I found it can be written like this:

#! -*- coding: utf-8 -*-

from keras.optimizers import Optimizer
from keras import backend as K


class InjectOptimizer(Optimizer):
    """Defines a base class for intrusive optimizers.
    Requires passing in the model and directly modifying the model's training function
    instead of using the optimizer in the conventional way, hence called "intrusive".
    In fact, most of the code below is copied directly from Keras source code:
    https://github.com/keras-team/keras/blob/master/keras/engine/training.py#L497
    Which is the _make_train_function in Keras.
    """
    def get_updates(self, loss, params):
        return []
    def get_grouped_updates(self, loss, params):
        raise NotImplementedError
    def inject(self, model):
        """Pass in the model for injection
        """
        if not hasattr(model, 'train_function'):
            raise RuntimeError('You must compile your model before using it.')
        model._check_trainable_weights_consistency()
        if model.train_function is None:
            inputs = (model._feed_inputs +
                      model._feed_targets +
                      model._feed_sample_weights)
            if model._uses_dynamic_learning_phase():
                inputs += [K.learning_phase()]
            with K.name_scope('training'):
                train_functions = []
                with K.name_scope(model.optimizer.__class__.__name__):
                    grouped_training_updates = self.get_grouped_updates(
                        params=model._collected_trainable_weights,
                        loss=model.total_loss)
                for i, updates in enumerate(grouped_training_updates[1:]):
                    f = K.function(
                        inputs,
                        [model.total_loss],
                        updates=updates,
                        name='train_function_%s' % (i + 1),
                        **model._function_kwargs)
                    train_functions.append(f)
                # Gets loss and metrics. Updates weights at each call.
                first_updates = (model.updates +
                                 grouped_training_updates[0],
                                 model.metrics_updates)
                first_train = K.function(
                    inputs,
                    [model.total_loss] + model.metrics_tensors,
                    updates=first_updates,
                    name='train_function',
                    **model._function_kwargs)
                def F(inputs):
                    R = first_train(inputs)
                    for f in train_functions:
                        f(inputs)
                    return R
                model.train_function = F


class HeunOptimizer(InjectOptimizer):
    """Heun Optimizer
    ( https://en.wikipedia.org/wiki/Heun%27s_method )
    """
    def __init__(self, lr, **kwargs):
        super(HeunOptimizer, self).__init__(**kwargs)
        with K.name_scope(self.__class__.__name__):
            self.lr = K.variable(lr, name='lr')
    def get_updates_1(self, loss, params, cache_grads):
        updates = []
        grads = self.get_gradients(loss, params)
        for p, g, cg in zip(params, grads, cache_grads):
            updates.append(K.update(cg, g))
            updates.append(K.update(p, p - self.lr * g))
        return updates
    def get_updates_2(self, loss, params, cache_grads):
        updates = []
        grads = self.get_gradients(loss, params)
        for p, g, cg in zip(params, grads, cache_grads):
            updates.append(K.update(p, p - 0.5 * self.lr * (g - cg)))
        return updates
    def get_grouped_updates(self, loss, params):
        cache_grads = [K.zeros(K.int_shape(p)) for p in params]
        return [
            self.get_updates_1(loss, params, cache_grads),
            self.get_updates_2(loss, params, cache_grads)
        ]

Usage is as follows:

opt = HeunOptimizer(0.1)
model.compile(loss='mse', optimizer=opt)
opt.inject(model) # This step must be executed

model.fit(x_train, y_train, epochs=100, batch_size=32)

The key idea is already noted in the comments. Essentially, Keras optimizers are eventually wrapped into a train_function. Therefore, we only need to design the train_function by referencing the Keras source code and insert our own operations. In this process, it is important to note that an operation defined by K.function is equivalent to a single sess.run.

Note: Similarly, algorithms like RK23 and RK45 can also be implemented. Unfortunately, the performance of such optimizers is not very good.

Elegant Keras

This article discussed a very, very niche requirement: custom optimizers. It introduced the standard way to write Keras optimizers as well as an “intrusive” approach. If you truly have such a special requirement, you can use these as a reference.

Through the analysis and research of optimizers in Keras, we can further observe that the overall Keras code is remarkably concise and elegant, making it hard to find fault with.

Please include the original address when reposting: https://kexue.fm/archives/5879

For more detailed reposting matters, please refer to: “Scientific Space FAQ”