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 class generator, 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 class statement syntax, 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:

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.

  • %py_class% objects can be safely defined at the top level of an R package. (see details about delay_load below)

  • Two keywords are treated specially: convert and delay_load.

  • If you want to call r_to_py with convert=FALSE, pass it as a keyword:

NonNegative(keras$constraints$Constraint, convert=FALSE) %py_class% { ... }
  • You can delay creating the python type object until this first time a class instance is created by passing delay_load=TRUE. The default value is FALSE for most contexts, but TRUE if you are in an R package. (The actual test performed is identical(topenv(), globalenv())). If a %py_class% type object is delayed, it will display "<<R6type>.ClassName> (delayed)" when printed.

  • 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 <- r_to_py(R6::R6Class(

  classname = "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)
  }
}