Writing Custom Keras Layers

library(keras)
knitr::opts_chunk$set(comment = NA, eval = FALSE)

If the existing Keras layers don't meet your requirements you can create a custom layer. For simple, stateless custom operations, you are probably better off using layer_lambda() layers. But for any custom operation that has trainable weights, you should implement your own layer.

The example below illustrates the skeleton of a Keras custom layer.

The Layer function

Layers encapsulate a state (weights) and some computation. The main data structure you'll work with is the Layer. A layer encapsulates both a state (the layer's "weights") and a transformation from inputs to outputs (a "call", the layer's forward pass).

library(tensorflow)
library(keras)

layer_linear <- Layer(
  classname = "Linear", 
  initialize = function(units, input_dim) {
    super()$`__init__`()
    w_init <- tf$random_normal_initializer()
    self$w <- tf$Variable(
      initial_value = w_init(shape = shape(input_dim, units),
                             dtype = tf$float32)
      )
    b_init <- tf$zeros_initializer()
    self$b <- tf$Variable(
      initial_value = b_init(shape = shape(units),
                             dtype = tf$float32)
    )
  },
  call = function(inputs, ...) {
    tf$matmul(inputs, self$w) + self$b
  }
)

x <- tf$ones(shape = list(2,2))
layer <- layer_linear(units = 4, input_dim = 2)
y <- layer(x)
y

Note that the weights w and b are automatically tracked by the layer upon being set as layer attributes.

get_weights(layer)

Note you also have access to a quicker shortcut for adding weight to a layer: the add_weight method:

layer_linear <- Layer(
  classname = "Linear", 
  initialize = function(units, input_dim) {
    super()$`__init__`()
    self$w <- self$add_weight(
      shape = shape(input_dim, units),
      initializer = "random_normal",
      trainable = TRUE
    )
    self$b <- self$add_weight(
      shape = shape(units),
      initializer = "zeros",
      trainable = TRUE
    )
  },
  call = function(inputs, ...) {
    tf$matmul(inputs, self$w) + self$b
  }
)

It's important to call super()$__init__() in the initialize method.

Note that tensor operations are executed using the Keras backend(). See the Keras Backend article for details on the various functions available from Keras backends.

Besides trainable weights, you can add non-trainable weights to a layer as well. Such weights are meant not to be taken into account during backpropagation, when you are training the layer.

Here's how to add and use a non-trainable weight:

layer_compute_sum <- Layer(
  classname = "ComputeSum",
  initialize = function(input_dim) {
    super()$`__init__`()
    self$total <- tf$Variable(
      initial_value = tf$zeros(shape(input_dim)),
      trainable = FALSE
    )
  },
  call = function(inputs, ...) {
    self$total$assign_add(tf$reduce_sum(inputs, axis = 0L))
    self$total
  }
)

x <- tf$ones(shape(2,2))
mysum <- layer_compute_sum(input_dim = 2)
print(mysum(x))
print(mysum(x))

It's part of layer$weights but it gets categorized as a non-trainable weight:

get_weights(mysum)
mysum$non_trainable_weights

Best practice: deferring weight creation until the shape of the inputs is known

In Linear example above, our Linear layer took an input_dim argument that was used to compute the shape of the weights w and b in initialize:

layer_linear <- Layer(
  classname = "Linear", 
  initialize = function(units, input_dim) {
    super()$`__init__`()
    self$w <- self$add_weight(
      shape = shape(input_dim, units),
      initializer = "random_normal",
      trainable = TRUE
    )
    self$b <- self$add_weight(
      shape = shape(units),
      initializer = "zeros",
      trainable = TRUE
    )
  },
  call = function(inputs, ...) {
    tf$matmul(inputs, self$w) + self$b
  }
)

In many cases, you may not know in advance the size of your inputs, and you would like to lazily create weights when that value becomes known, some time after instantiating the layer.

In the Keras API, we recommend creating layer weights in the build(inputs_shape) method of your layer. Like this:

layer_linear <- Layer(
  classname = "Linear", 
  initialize = function(units) {
    super()$`__init__`()
    self$units <- units
  },
  build = function(input_shape) {
    self$w <- self$add_weight(
      shape = shape(input_shape[2], self$units),
      initializer = "random_normal",
      trainable = TRUE
    )
    self$b <- self$add_weight(
      shape = shape(self$units),
      initializer = "zeros",
      trainable = TRUE
    )
  },
  call = function(inputs, ...) {
    tf$matmul(inputs, self$w) + self$b
  }
)

The call method of your layer will automatically run build the first time it is called. You now have a layer that's lazy and easy to use:

layer <- layer_linear(units = 32)
x <- tf$ones(shape = list(2,2))
layer(x)

Layers are recursively composable

If you assign a Layer instance as attribute of another Layer, the outer layer will start tracking the weights of the inner layer.

We recommend creating such sublayers in the initialize method (since the sublayers will typically have a build method, they will be built when the outer layer gets built).

# Let's assume we are reusing the Linear class
# with a `build` method that we defined above.
layer_mlp_block <- Layer(
  classname = "MLPBlock",
  initialize = function() {
    super()$`__init__`()
    self$linear_1 <- layer_linear(units = 32)
    self$linear_2 <- layer_linear(units = 32)
    self$linear_3 <- layer_linear(units = 1)
  },
  call = function(inputs, ...) {
    inputs %>% 
      self$linear_1() %>% 
      tf$nn$relu() %>% 
      self$linear_2() %>% 
      tf$nn$relu() %>% 
      self$linear_3()
  }
)

mlp <- layer_mlp_block()

y <- mlp(tf$ones(shape(3, 64)))  # The first call to the `mlp` will create the weights
length(mlp$weights)
length(mlp$trainable_weights)

Layers recursively collect losses created during the forward pass

When writing the call method of a layer, you can create loss tensors that you will want to use later, when writing your training loop. This is doable by calling self$add_loss(value):

# A layer that creates an activity regularization loss
layer_activity_reg <- Layer(
  classname = "ActivityRegularizationLayer",
  initialize = function(rate = 1e-2) {
    super()$`__init__`()
    self$rate <- rate
  },
  call = function(inputs) {
    self$add_loss(self$rate * tf$reduce_sum(inputs))
    inputs
  }
)

These losses (including those created by any inner layer) can be retrieved via layer$losses. This property is reset at the start of every call to the top-level layer, so that layer$losses always contains the loss values created during the last forward pass.

layer_outer <- Layer(
  classname = "OuterLayer",
  initialize = function() {
    super()$`__init__`()
    self$dense <- layer_dense(
      units = 32, 
      kernel_regularizer = regularizer_l2(1e-3)
    )
  },
  call = function(inputs) {
    self$dense(inputs)
  }
)

layer <- layer_outer()
x <- layer(tf$zeros(shape(1,1)))

# This is `1e-3 * sum(layer.dense.kernel ** 2)`,
# created by the `kernel_regularizer` above.
layer$losses

You can optionally enable serialization on your layers

If you need your custom layers to be serializable as part of a Functional model, you can optionally implement a get_config method.

Note that the initialize method of the base Layer class takes some keyword arguments, in particular a name and a dtype. It's good practice to pass these arguments to the parent class in initialize and to include them in the layer config:

layer_linear <- Layer(
  classname = "Linear", 
  initialize = function(units, ...) {
    super()$`__init__`(...)
    self$units <- units
  },
  build = function(input_shape) {
    self$w <- self$add_weight(
      shape = shape(input_shape[2], self$units),
      initializer = "random_normal",
      trainable = TRUE
    )
    self$b <- self$add_weight(
      shape = shape(self$units),
      initializer = "zeros",
      trainable = TRUE
    )
  },
  call = function(inputs, ...) {
    tf$matmul(inputs, self$w) + self$b
  },
  get_config = function() {
    list(
      units = self$units
    )
  }
)

layer <- layer_linear(units = 64)
config <- get_config(layer)
new_layer <- from_config(config)

If you need more flexibility when deserializing the layer from its config, you can also override the from_config class method. This is the base implementation of from_config:

def from_config(cls, config):
  return cls(**config)

Privileged training argument in the call method

Some layers, in particular the layer_batch_normalization and the layer_dropout, have different behaviors during training and inference. For such layers, it is standard practice to expose a training (boolean) argument in the call method.

By exposing this argument in call, you enable the built-in training and evaluation loops (e.g. fit) to correctly use the layer in training and inference.

layer_custom_dropout <- Layer(
  classname =  "CustomDropout",
  initialize = function(rate, ...) {
    super()$`__init__`(...)
    self$rate <- rate
  },
  call = function(inputs, training = NULL) {
    if (!is.null(inputs) && training) {
      inputs <- tf$nn$dropout(inputs, rate = self$rate)
    }
    inputs
  }
)


Try the keras package in your browser

Any scripts or data that you put into this service are public.

keras documentation built on Aug. 16, 2023, 1:07 a.m.