When using keras, a desire to create Python-based subclasses can arise in a number of ways. For example, when you want to:

  • define custom layers and/or models
  • implement custom training logic
  • create custom losses or optimizers
  • define custom callbacks
  • … and more!

In such scenarios, the most powerful and flexible approach is to directly inherit from, and then modify and/or enhance an appropriate Python class.

Subclassing a Python class in R is generally straightforward. Two syntaxes are provided: one that adheres to R conventions and uses R6::R6Class as the class constructor, and one that adheres more to Python conventions, and attempts to replicate Python syntax in R.

Examples

A custom constraint (R6)

For demonstration purposes, let’s say you want to implement a custom keras kernel constraint via subclassing. Using R6:

NonNegative <- R6::R6Class("NonNegative",
  inherit = keras$constraints$Constraint,
  public = list(
    "__call__" = function(x) {
       w * k_cast(w >= 0, k_floatx())
    }
  )
)
NonNegative <- r_to_py(NonNegative, convert=TRUE)

The r_to_py method will convert an R6 class generator into a Python class generator. After conversion, Python class generators will be different from R6 class generators in a few ways:

  • New class instances are generated by calling the class directly: NonNegative() (not NonNegative$new())

  • All methods (functions) are (potentially) modified to ensure their first argument is self.

  • All methods have in scope __class__, super and the class name (NonNegative).

  • For convenience, some method names are treated as aliases:

    • initialize is treated as an alias for __init__()
    • finalize is treated as an alias for __del__()
  • super can be accessed in 3 ways:

    1. R6 style, which supports only single inheritance (the most common type)
    super$initialize()
    1. Python 2 style, which requires explicitly providing the class generator and instance
    super(NonNegative, self)$`__init__`()
    1. Python 3 style
    super()$`__init__`()
  • When subclassing Keras base classes, it is generally your responsibility to call super$initialize() if you are masking a superclass initializer by providing your own initialize method.

  • Passing convert=FALSE to r_to_py() will mean that all R methods will receive Python objects as arguments, and are expected to return Python objects. This allows for some features not available with convert=TRUE, namely, modifying some Python objects, like dictionaries or lists, in-place.

  • Active bindings (methods supplied to R6Class(active=...)) are converted to Python @property-decorated methods.

  • R6 classes with private methods or attributes are not supported.

  • The argument supplied to inherit can be:

    • missing or NULL
    • a Python class generator
    • an R6 ClassGenerator, as long as it can be converted to a Python class generator as well
    • a list of Python/R6 classes (for multiple inheritance)
    • A list of superclasses, with optional additional keywords (e.g., metaclass=, only for advanced Python use cases)

A custom constraint (%py_class%)

As an alternative to r_to_py(R6Class(...)), we also provide %py_class%, a more concise alternative syntax for achieving the same outcome. %py_class% is heavily inspired by the Python syntax for the class statement in R, and is especially convenient when translating Python code to R. Translating the above example, you could write the same using %py_class%:

NonNegative(keras$constraints$Constraint) %py_class% {
  "__call__" = function(x) {
    w * k_cast(w >= 0, k_floatx())
  }
}

Notice, this is very similar to the equivalent Python code:

class NonNegative(tf.keras.constraints.Constraint):
    def __call__(self, w):
        return w * tf.cast(tf.math.greater_equal(w, 0.), w.dtype)

Some (potentially surprising) notes about %py_class%:

  • Just like the Python class statement, it assigns the constructed class in the current scope! (There is no need to write NonNegative <- ...).

  • The left hand side can be:

    • A bare symbol, ClassName
    • A pseudo-call, with superclasses and keywords as arguments: ClassName(Superclass1, Superclass2, metaclass=my_metaclass)
  • The right hand side is evaluated in a new environment to form the namespace for the class methods.

  • One keyword treated specially is convert. If you want to call r_to_py with convert=FALSE, pass it as a keyword:

NonNegative(keras$constraints$Constraint, convert=FALSE) %py_class% { ... }
  • An additional convenience is that if the first expression of a function body or the class body is a literal character string, it is automatically taken as the __doc__ attribute of the class or method. The doc string will then be visible to both python and R tools e.g. reticulate::py_help(). See ?py_class for an example.

In all other regards, %py_class% is equivalent to r_to_py(R6Class()) (indeed, under the hood, they do the same thing).

A custom layer (R6)

The same pattern can be extended to all sorts of keras objects. For example, a custom layer can be written by subclassing the base Keras Layer:

CustomLayer <- R6::R6Class("CustomLayer",

  inherit = keras$layers$Layer,

  public = list(

    initialize = function(output_dim) {
      self$output_dim <- output_dim
    },

    build = function(input_shape) {
      self$kernel <- self$add_weight(
        name = 'kernel',
        shape = list(input_shape[[2]], self$output_dim),
        initializer = initializer_random_normal(),
        trainable = TRUE
      )
    },

    call = function(x, mask = NULL) {
      k_dot(x, self$kernel)
    },

    compute_output_shape = function(input_shape) {
      list(input_shape[[1]], self$output_dim)
    }
  )
)

A custom layer (%py_class%)

or using %py_class%:

CustomLayer(keras$layers$Layer) %py_class% {

  initialize = function(output_dim) {
    self$output_dim <- output_dim
  }

  build = function(input_shape) {
    self$kernel <- self$add_weight(
      name = 'kernel',
      shape = list(input_shape[[2]], self$output_dim),
      initializer = initializer_random_normal(),
      trainable = TRUE
    )
  }

  call = function(x, mask = NULL) {
    k_dot(x, self$kernel)
  }

  compute_output_shape = function(input_shape) {
    list(input_shape[[1]], self$output_dim)
  }
}