--- title: "Neural networks" description: > Converting bare torch, brulee, and keras3 neural networks to orbital objects, including multi-output networks with a shared trunk. output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Neural networks} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r} #| include: false knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` If you fit a feed-forward network through `parsnip::mlp()` inside a `workflow`, `orbital()` works exactly as it does for any other model: call `orbital()` on the fitted workflow and everything else in this package (`predict()`, `orbital_sql()`, `orbital_inline()`, and so on) follows the usual pattern described in `vignette("orbital")`. Nothing below is required for that path. This vignette instead covers converting a network object directly, either because you built one yourself with `torch::nn_sequential()` or `keras3::keras_model_sequential()`/`keras3::keras_model()`, or because you want more control than `mlp()` exposes (a custom `mode`, `type`, or `lvl`, or extracting an intermediate hidden layer instead of the final output). ## Supported layers `orbital()` walks a network's layers directly, so it only supports the layer types it knows how to translate into an equivalent SQL/dplyr expression: - A stack of linear/dense layers, each optionally followed by an activation (`relu`, `sigmoid`, `tanh`, `linear`/`identity`, `leaky_relu`, `elu`, `gelu`, and `softmax` on the final layer only). - `Dropout` layers, which are a no-op at prediction time and are skipped. - `BatchNorm`/`LayerNorm` (torch: `nn_batch_norm1d`, `nn_layer_norm`; keras3: `layer_batch_normalization()`, `layer_layer_normalization()`), applied using their stored evaluation-time statistics. Anything else (convolutional layers, recurrent layers, attention, residual/skip connections, embeddings, and so on) is not supported and `orbital()` will error rather than silently producing an incorrect translation. ## Input names Unlike a fitted `lm()` or `parsnip::model_fit()`, a bare torch or keras3 network carries no record of its input feature names anywhere in the object: torch tensors are positional, and a keras3 `Input()` is an anonymous fixed-width vector. Because of this, `input_names` is required whenever you call `orbital()` on a bare network directly, in the same order the network was trained on: ```{r} #| eval: false library(orbital) library(torch) net <- nn_sequential( nn_linear(4, 8), nn_relu(), nn_linear(8, 1) ) ob <- orbital(net, input_names = c("x1", "x2", "x3", "x4")) predict(ob, new_data) ``` The same requirement applies to bare keras3 models: ```{r} #| eval: false library(keras3) x <- keras_model_sequential(input_shape = 4) |> layer_dense(8, activation = "relu") |> layer_dense(1) ob <- orbital(x, input_names = c("x1", "x2", "x3", "x4")) ``` ## Mode, type, and lvl `orbital()` infers `mode` from the network's shape: a single output unit with a linear (or no) final activation is `"regression"`; anything with more than one output unit, or a single unit behind a `sigmoid`, is `"classification"`. You can override the inferred value with `mode = "regression"`/`mode = "classification"` if you trained the network with a loss function that doesn't match this default (for example, a single-unit classifier with a bare linear final layer and a manually-implemented loss). For a classification network, `type` controls whether `predict()` returns class probabilities (`type = "prob"`, the default) or the predicted class (`type = "class"`). `lvl` names the classes; if you don't supply one, they default to `class_0`, `class_1`, and so on, in the network's own output order: ```{r} #| eval: false ob <- orbital( net, input_names = c("x1", "x2", "x3", "x4"), type = "class", lvl = c("no", "yes") ) ``` ## Extracting an intermediate layer `output_layer` lets you stop at a hidden layer instead of the network's final output, useful for exposing a learned embedding rather than a prediction. It takes the 1-based index of the linear/dense layer to stop at (counting only linear/dense layers, ignoring activations, dropout, and normalization): ```{r} #| eval: false ob <- orbital(net, input_names = c("x1", "x2", "x3", "x4"), output_layer = 1) predict(ob, new_data) #> .pred_layer_1_1 .pred_layer_1_2 ... .pred_layer_1_8 ``` `output_layer` is not supported for multi-output networks (below), since there each head already exposes its own layers separately. ## Multi-output networks A network with a shared trunk feeding two or more separate output heads (for example, predicting both a numeric price and a product category from the same hidden representation) is a branching graph rather than a single chain, so it needs its own input shape. `mode`, `type`, and `lvl` each become a named list keyed by head name instead of a single value, and each head's own value is inferred/defaulted independently, exactly as it would be for a single-output network. ### torch For torch, express the trunk and heads explicitly as a `list(trunk = ..., heads = list(...))`, each element a plain `nn_sequential()`: ```{r} #| eval: false net <- list( trunk = nn_sequential( nn_linear(4, 8), nn_relu() ), heads = list( price = nn_sequential(nn_linear(8, 1)), category = nn_sequential(nn_linear(8, 2)) ) ) ob <- orbital( net, input_names = c("x1", "x2", "x3", "x4"), mode = list(price = "regression", category = "classification"), lvl = list(category = c("standard", "luxury")) ) preds <- predict(ob, new_data) names(preds) #> [1] ".pred_price" ".pred_category_class" ``` `orbital()` has no way to infer trunk/heads from an arbitrary torch `nn_module`'s `forward()` method, which is why the shape has to be spelled out this way rather than passed as a single network object. ### keras3 keras3's functional API already records the model's graph in its own config, so there's no separate trunk/heads argument to fill in: build the model with `keras_model()` and a named list of outputs, and `orbital()` infers the shared trunk and each head's own layers directly from the graph, by finding the longest run of layers common to every output's path back to the input. ```{r} #| eval: false library(keras3) inp <- keras_input(shape = 4L) trunk <- inp |> layer_dense(8, activation = "relu") price <- trunk |> layer_dense(1) category <- trunk |> layer_dense(2) x <- keras_model(inputs = inp, outputs = list(price = price, category = category)) ob <- orbital( x, input_names = c("x1", "x2", "x3", "x4"), mode = list(price = "regression", category = "classification"), lvl = list(category = c("standard", "luxury")) ) ``` If the outputs list isn't named, the head names fall back to the outputs' own layer names. A functional model must have exactly one input and two or more outputs; a layer that is called more than once, or that merges more than one tensor (e.g. `layer_concatenate()`), is not supported, since neither has a meaningful trunk/heads split.