kerasnip: Un Puente entre ‘keras’ y ‘tidymodels’

Grupo de Usuarios de R de Madrid | Febrero 2026

kerasnip v0.1.0.900

Docs: davidrsch.github.io/kerasnip · Repo: github.com/davidrsch/kerasnip

Hoy

  • Por qué es difícil ajustar modelos Keras dentro de tidymodels
  • Qué genera kerasnip y por qué importa
  • Flujo secuencial: ajuste estructural con num_{block}
  • Flujo funcional: grafos con ramas y uniones con inp_spec()

El Panorama y la Brecha

Keras (vía keras3)

  • Aprendizaje profundo
  • Pensado para tensores, imperativo
  • Agnóstico al backend (TF, JAX, Torch)
  • Grafos arbitrarios de capas

tidymodels

  • Aprendizaje automático
  • Pensado para data frames, declarativo
  • Interfaz unificada (fit, predict)
  • Ajuste de hiperparámetros estandarizado

La brecha: tune_grid() maneja el learning rate sin problema, pero ajustar la estructura es imposible. parsnip espera argumentos fijos; los modelos Keras son grafos arbitrarios.

Modelo Mental


flowchart LR
  classDef step fill:#f5f7fa,stroke:#5c6b7a,color:#1b3a57,stroke-width:2px;
  classDef tune fill:#BF281B,stroke:#BF281B,color:#ffffff,stroke-width:2px;

  A["Bloques<br/>(funciones R simples)"] --> B["Función spec<br/>create_keras_*_spec()"]
  B --> C["Workflow<br/>recipe() + workflow()"]
  C --> D["Ajuste<br/>tune_grid()"]
  A -. "args → ajustables<br/>num_{block} repite" .-> B

  class A,B,C step;
  class D tune;


Una regla: si quieres ajustarlo, hazlo argumento de la función.

Bloques Secuenciales

input_block <- function(model, input_shape) {
  keras_model_sequential(input_shape = input_shape)
}

dense_block <- function(model, units = 128, dropout = 0.0) {
  model |>
    layer_dense(units = units, activation = "relu") |>
    layer_dropout(rate = dropout)
}

output_block <- function(model) {
  model |> layer_dense(units = 1)
}

Generar Spec + Ajuste Estructural

Registrar el spec

create_keras_sequential_spec(
  model_name = "diamond_mlp",
  layer_blocks = list(
    input  = input_block,
    body   = dense_block,  # repetible
    output = output_block
  ),
  mode = "regression"
)

Esto crea diamond_mlp(), una función de modelo parsnip estándar.

Tratar la topología como hiperparámetro

spec <- diamond_mlp(
  num_body     = tune(),  # profundidad: 1, 2, 3…
  body_units   = tune(),  # ancho: 32, 64, 128…
  body_dropout = tune(),
  fit_epochs   = 20
) |>
  set_engine("keras")

num_body y body_units se generan automáticamente del nombre del bloque y sus argumentos.

Workflow con Diamantes

library(tidymodels)
library(kerasnip)
data(diamonds, package = "ggplot2")

diamonds_split <- initial_split(diamonds, strata = price)
diamonds_train <- training(diamonds_split)

diamonds_rec <- recipe(price ~ ., data = diamonds_train) |>
  step_dummy(all_nominal_predictors()) |>
  step_zv(all_predictors()) |>
  step_normalize(all_numeric_predictors())

wf <- workflow() |> add_recipe(diamonds_rec) |> add_model(spec)

params <- extract_parameter_set_dials(wf) |>
  update(
    num_body     = dials::num_terms(c(1, 4)),
    body_units   = dials::hidden_units(c(32, 256)),
    body_dropout = dials::dropout(c(0, 0.4))
  )

grid  <- grid_latin_hypercube(params, size = 20)
folds <- vfold_cv(diamonds_train, v = 5, strata = price)
res   <- tune_grid(wf, resamples = folds, grid = grid)

Resultados (Diamantes): CV — Tabla

num_body body_units body_dropout mean std_err
2 62 0.060 767.101 57.202
2 35 0.180 775.932 45.765
2 49 0.030 782.881 51.891
2 98 0.121 783.726 54.377
1 127 0.276 842.246 45.535

Resultados (Diamantes): CV — Gráfico

Resultados (Diamantes): Test — Tabla

.metric .estimate
rmse 736.1172
mae 349.0455
rsq 0.9660

Resultados (Diamantes): Test — Gráfico

Depuración: compile_keras_grid()

prep_rec <- prep(diamonds_rec, training = diamonds_train)
juiced <- juice(prep_rec)
x <- juiced |> select(-price)
y <- juiced$price
modified_grid <- tibble(
    num_body = c(3, 6),
    body_units = c(-10, 128),
    body_dropout = c(0.2, 0.2)
)

dbg <- compile_keras_grid(spec, modified_grid, x, y)
dbg
# A tibble: 2 × 5
  num_body body_units body_dropout compiled_model                          
     <dbl>      <dbl>        <dbl> <list>                                  
1        3        -10          0.2 <NULL>                                  
2        6        128          0.2 <keras.src.models.sequential.Sequential>
# ℹ 1 more variable: error <chr>

API Funcional: Bloques + Grafo

Los bloques reciben tensores, no modelos

input_block <- function(input_shape) {
  layer_input(shape = input_shape,
              name = "features")
}

dense_block <- function(tensor,
                        units = 64,
                        dropout = 0.0) {
  tensor |>
    layer_dense(units = units,
                activation = "relu") |>
    layer_dropout(rate = dropout)
}

concat_block <- function(input_a, input_b) {
  layer_concatenate(list(input_a, input_b))
}

output_block <- function(tensor, num_classes) {
  tensor |>
    layer_dense(units = num_classes,
                activation = "softmax")
}

inp_spec() conecta salidas con entradas por nombre

create_keras_functional_spec(
  model_name = "attrition_towers",
  layer_blocks = list(
    main_input = input_block,
    tower_a    = inp_spec(
                   dense_block,
                   "main_input"),
    tower_b    = inp_spec(
                   dense_block,
                   "main_input"),
    joined     = inp_spec(
                   concat_block,
                   c(input_a = "tower_a",
                     input_b = "tower_b")),
    output     = inp_spec(
                   output_block,
                   "joined")
  ),
  mode = "classification"
)

Workflow de Rotación

library(tidymodels)
library(modeldata)
data(attrition)

attr_split <- initial_split(attrition, strata = Attrition)
attr_train <- training(attr_split)

attr_rec <- recipe(Attrition ~ ., data = attr_train) |>
  step_zv(all_predictors()) |>
  step_dummy(all_nominal_predictors()) |>
  step_normalize(all_numeric_predictors())

attr_spec <- attrition_towers(
  tower_a_units = tune(),
  tower_b_units = tune(),
  fit_epochs    = 20
) |> set_engine("keras")

attr_wf <- workflow() |> add_recipe(attr_rec) |> add_model(attr_spec)

params <- extract_parameter_set_dials(attr_wf) |>
  update(tower_a_units = dials::hidden_units(c(16, 128)),
         tower_b_units = dials::hidden_units(c(16, 128)))

res <- tune_grid(attr_wf,
                 resamples = vfold_cv(attr_train, v = 3, strata = Attrition),
                 grid      = grid_latin_hypercube(params, size = 12))

Resultados (Rotación): CV — Tabla

tower_a_units tower_b_units mean std_err
70 64 0.8048 0.0218
41 33 0.8017 0.0239
47 24 0.7974 0.0185
21 105 0.7955 0.0278
80 95 0.7926 0.0219

Resultados (Rotación): CV — Gráfico

Resultados (Rotación): Test — Tabla

.metric .estimate
accuracy 0.8699
roc_auc 0.8232

Resultados (Rotación): Test — Gráfico

Consejos Prácticos y Valoración

Para evitar problemas

  • Ejecuta siempre compile_keras_grid() antes de ajustar
  • Bloques pequeños y testeables, aísla errores con 2–3 bloques
  • Ajusta estructura (profundidad/ancho) primero; hiperparámetros de entrenamiento después
  • Mantén el espacio de búsqueda razonable

Por qué vale la pena

  1. Paridad de workflowrecipes, workflows, tune funcionan igual que con glmnet
  2. Reproducibilidad — la topología es código; controla tu arquitectura con git
  3. Extensibilidad — envuelve cualquier capa Keras (Transformers, RNNs) en un bloque


Recursos