Integración R + Python

Puentes en proceso, ficheros columnares y documentos polyglot

python
r
interoperability
reticulate
rpy2
arrow
quarto
Referencia comentada de las tres vías para combinar R y Python en un mismo proyecto: llamadas en proceso (reticulate, rpy2), intercambio por ficheros columnares (Arrow, Parquet, Feather) y documentos polyglot (Quarto).

Sobre interoperabilidad R ↔︎ Python

Casi ningún proyecto serio de ciencia de datos vive en un solo lenguaje. R domina la modelización estadística clásica y el ecosistema Bioconductor. Python domina el aprendizaje profundo, el tooling productivo y gran parte de la ingeniería de datos. La pregunta práctica no es “cuál uso”, sino cómo los compongo sin pagar un coste alto en mantenimiento ni en reproducibilidad.

Hay tres patrones que cubren la inmensa mayoría de los casos. Conviene escoger conscientemente cuál aplicar antes de empezar:

  • En proceso: una sesión de un lenguaje carga al intérprete del otro y le pasa objetos por referencia. reticulate lo hace desde R. rpy2 lo hace desde Python. Latencia mínima, pero acoplas dos runtimes en el mismo proceso y heredas la fragilidad del más débil (típicamente la coexistencia con entornos conda).
  • Basado en ficheros: cada lenguaje vive en su propio proceso y se intercambian datos a través de un formato columnar eficiente. Apache Arrow (en memoria), Parquet (en disco, comprimido) y Feather (Arrow IPC en disco, sin compresión) son las opciones razonables. Cero acoplamiento de runtimes, ideal para pipelines reproducibles y para CI.
  • Polyglot a nivel de documento: Quarto ejecuta chunks de R y de Python en el mismo .qmd y comparte objetos vía reticulate por debajo. Útil para informes y notebooks. No es el patrón adecuado para código de producción.

Tres reglas que evitan los problemas más comunes:

  • No mezcles patrones sin necesidad. Si ya tienes Arrow funcionando, no añadas reticulate “para una llamada rápida”: acabas debiendo dos cadenas de dependencias.
  • Aísla siempre el entorno de Python. Tanto reticulate como rpy2 se rompen cuando R o el sistema toman un Python distinto del esperado. Fija el intérprete explícitamente.
  • Coerciones de tipos en la frontera. Factor ↔︎ Categorical, integer R (32 bits) ↔︎ int Python (arbitrary precision), NA ↔︎ None / NaN: revisa los tipos en cada cruce, no asumas que el puente “hace lo razonable”.

Esta página cataloga las cinco piezas que cubren los tres patrones, en orden de menor a mayor desacoplamiento: primero los puentes en proceso (reticulate, rpy2), luego el intercambio columnar (pyarrow + Parquet/Feather) y finalmente el patrón polyglot a nivel de documento (Quarto).


reticulate

reticulate es el paquete de R que embebe un intérprete de Python dentro de la sesión de R. Permite llamar funciones Python, importar módulos, ejecutar .py completos y compartir objetos casi sin fricción. La conversión entre data.frame ↔︎ pandas.DataFrame, list ↔︎ list, vectores atómicos ↔︎ numpy.array se hace automáticamente.

Mantenido por Posit. Es la pieza de interoperabilidad más extendida en R y la que Quarto utiliza por debajo para chunks de Python.

Cuándo usarlo

  • Necesitas llamar a una librería Python concreta (scikit-learn, PyTorch, HuggingFace, scvi-tools) desde un análisis principalmente escrito en R.
  • Quieres un único proceso, un único log, un único entorno de error. La sesión R es la “dueña” del flujo y Python es una librería invitada.
  • Estás trabajando dentro de un .qmd polyglot, Quarto delega en reticulate para los chunks de Python.

Cuándo NO usarlo

  • Si el flujo principal es Python. Forzar reticulate desde R cuando la lógica natural vive en Python suele degenerar. Mejor invertir la dirección (script Python que llama a R con rpy2) o desacoplar vía ficheros.
  • Si los intercambios son grandes y repetidos. La conversión data.frame ↔︎ pandas no es gratis. Por encima de unos millones de filas con muchas idas y vueltas, Arrow/Parquet es más eficiente.
  • Si el proyecto debe correr en CI ligero. Mantener un Python concreto disponible en cada job es una carga. Un pipeline desacoplado por ficheros es más simple.

Conceptos clave

  • Selección del intérprete. use_python(), use_virtualenv(), use_condaenv() o la variable de entorno RETICULATE_PYTHON. Una vez Python se ha inicializado en la sesión no se puede cambiar, fíjalo antes de la primera llamada.
  • Conversión automática vs manual. Por defecto los objetos cruzan convertidos. Si quieres trabajar con el objeto Python “tal cual” (p. ej. un torch.Tensor), usa import("torch", convert = FALSE) y luego py_to_r() puntualmente.
  • r_to_py() / py_to_r(). Conversión explícita. Útil para inspeccionar tipos en la frontera.
  • py$obj y r.obj. Desde R, py$x accede al objeto x del namespace Python. Desde un chunk de Python en Quarto, r.x accede al objeto R.
  • Entornos. py_install("paquete", envname = "r-tessera") instala dentro del entorno gestionado por reticulate. Conviene fijarlo en .Rprofile del proyecto o en _quarto.yml.

Patrón mínimo

library(reticulate)

# Fijar el intérprete ANTES de la primera llamada
use_virtualenv("r-tessera", required = TRUE)

# Importar módulos como si fueran namespaces R
np <- import("numpy")
sk <- import("sklearn.ensemble")

# Datos R -> Python (conversión automática)
x  <- as.matrix(iris[, 1:4])
y  <- as.integer(iris$Species) - 1L   # sklearn quiere 0-indexed

clf <- sk$RandomForestClassifier(n_estimators = 200L, random_state = 42L)
clf$fit(x, y)

preds <- clf$predict(x)               # numpy.array -> integer vector R
table(preds, y)

Y desde un chunk Python equivalente en Quarto, accediendo a objetos R:

import numpy as np
from sklearn.ensemble import RandomForestClassifier

x = np.asarray(r.iris.iloc[:, :4])
y = r.iris["Species"].cat.codes.values

clf = RandomForestClassifier(n_estimators=200, random_state=42).fit(x, y)

Trampas habituales

  • Integers. R interpreta 200 como double. Muchas APIs Python (sklearn, numpy) requieren int. Usa el sufijo L (200L) o as.integer() al pasar argumentos.
  • Factores. factor R no es Categorical pandas. Si lo pasas tal cual recibes un vector de strings, perdiendo el orden de niveles. Convierte a entero (as.integer(factor) - 1L) o explícitamente a pd.Categorical con r_to_py() y un paso intermedio.
  • NA. Cruzan como NaN en columnas numéricas y como None en columnas tipo objeto. En enteros R sin extensión, NA_integer_ puede llegar como -2147483648 (el centinela interno), comprueba siempre el dtype resultante en pandas.
  • Múltiples Python en el sistema. Reticulate elige uno con su propia heurística (reticulate::py_discover_config() te dice cuál). Si tu CI tiene varios, fíjalo con RETICULATE_PYTHON o use_virtualenv(..., required = TRUE).
  • Conda + reticulate en Windows corporativo. Conflictos clásicos con SSL/PATH cuando el usuario tiene Anaconda y reticulate intenta crear su propio entorno. Usa virtualenv o fija un único condaenv explícito.

Enlaces

Relacionados en esta página

  • rpy2, el puente simétrico, desde Python.
  • Quarto polyglot, apoyado en reticulate por debajo.
  • pyarrow, alternativa desacoplada cuando los intercambios pesan.

rpy2

rpy2 es el puente equivalente desde el lado contrario: embebe un intérprete de R dentro de una sesión Python. La sesión Python es la dueña del flujo y R aparece como una librería invitada. Mantiene la mayor parte del API de R accesible desde Python (modelos, gráficos, paquetes Bioconductor, etc.) y se integra bien con pandas vía rpy2.robjects.pandas2ri.

Es la opción natural cuando el proyecto principal está en Python (notebook, servicio, pipeline de ML) y necesitas puntualmente la maquinaria estadística de R: survival, lme4, lavaan, DESeq2, etc.

Cuándo usarlo

  • El flujo principal vive en Python y necesitas un test estadístico o modelo que solo está bien implementado en R.
  • Quieres reutilizar un paquete Bioconductor (DESeq2, edgeR, limma, phyloseq) desde un worker de Python.
  • Integración en un servicio Python existente, no quieres cambiar el lenguaje principal del servicio por un único paso.

Cuándo NO usarlo

  • Si el flujo principal es R. Mejor reticulate. Mantener una sesión R como invitada dentro de Python es más frágil (instalación de R, R_HOME, paquetes Bioconductor) que la dirección opuesta.
  • Si los intercambios pesan o se repiten. Igual que con reticulate: por encima de cierto volumen, intercambio columnar por ficheros.
  • Si despliegas en contenedores ligeros. Necesitas R completo instalado en la imagen, con sus paquetes compilados. La imagen crece notablemente.

Conceptos clave

  • Inicialización. rpy2.robjects carga R al importarse. La variable R_HOME debe apuntar al R correcto antes del import. En entornos virtuales conviene fijarla explícitamente.
  • robjects.r como puerta de entrada. robjects.r["nombre"] accede a cualquier objeto/función del entorno global de R. robjects.r('expr') evalúa una expresión.
  • Activación de conversores. Por defecto los objetos R llegan a Python como ListVector, FloatVector, etc. pandas2ri.activate() o el contexto localconverter() añaden la conversión automática a pandas.DataFrame / numpy.array.
  • importr(). Importa un paquete R como módulo Python. base = importr("base"), stats = importr("stats"), deseq2 = importr("DESeq2").
  • Contextos de conversión. El patrón moderno recomendado es usar localconverter en lugar de activate() global. Evita efectos colaterales en otras partes del código.

Patrón mínimo

import pandas as pd
from rpy2 import robjects
from rpy2.robjects import pandas2ri
from rpy2.robjects.packages import importr
from rpy2.robjects.conversion import localconverter

base  = importr("base")
stats = importr("stats")

df = pd.DataFrame({
    "y": [2.3, 3.1, 4.0, 5.2, 6.1, 7.4],
    "x": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
    "g": ["a", "a", "a", "b", "b", "b"],
})

# Conversión pandas <-> R data.frame solo dentro del contexto
with localconverter(robjects.default_converter + pandas2ri.converter):
    r_df = robjects.conversion.py2rpy(df)

# Ajustar un modelo lineal en R y traer el resultado a Python
robjects.globalenv["df"] <- r_df  # asignar a R
fit = robjects.r('lm(y ~ x + g, data = df)')
print(base.summary(fit))

# Coeficientes como numpy
with localconverter(robjects.default_converter + pandas2ri.converter):
    coefs = robjects.conversion.rpy2py(stats.coef(fit))

Trampas habituales

  • GIL. R no es thread-safe desde rpy2: no llames a R desde múltiples hilos del mismo proceso Python. Para paralelizar, lanza procesos separados.
  • R_HOME ambiguo en CI. Si el sistema tiene varios R, rpy2 puede no elegir el correcto. Fija R_HOME y PATH explícitamente antes de importar.
  • Conversores globales. pandas2ri.activate() modifica el conversor global y puede sorprender a librerías terceras que importan rpy2 después. Prefiere localconverter casi siempre.
  • Factores. Cruzan como FactorVector (no Categorical pandas) salvo dentro del conversor. Comprueba el tipo si vas a usarlos en agrupaciones.
  • Diferencia de versiones. rpy2 está fuertemente acoplado a la versión de R con la que se compila. Actualizar R sin reinstalar rpy2 rompe el binding con errores opacos.

Enlaces

Relacionados en esta página

  • reticulate, el puente simétrico, desde R.
  • pyarrow, alternativa desacoplada cuando los intercambios pesan.

pyarrow

pyarrow es la implementación Python de Apache Arrow, el estándar columnar en memoria que sustenta la mayor parte de la analítica de datos moderna. Para interoperar con R, su papel es doble:

  1. Formato en memoria compartido entre R (arrow package) y Python (pyarrow), zero-copy dentro del mismo proceso si se monta vía reticulate, o vía IPC entre procesos.
  2. Lectura/escritura de Parquet y Feather, los dos formatos columnares en disco que ambos lenguajes leen sin pérdida de tipo.

Es la forma más sólida y reproducible de mover datos entre R y Python cuando los flujos viven en procesos distintos. Especialmente cuando se trata de tablas anchas, particionadas o que conviene leer por columnas.

Cuándo usarlo

  • Pipeline en dos pasos: un script R produce datos, un script Python los consume (o viceversa). Cada script gestiona sus propias dependencias.
  • Tablas grandes (millones de filas, muchas columnas) que cargas parcialmente, Parquet permite leer columnas y filtros sin descomprimir todo.
  • Necesitas reproducibilidad estricta: el formato Arrow/Parquet preserva tipos (decimal, timestamp con zona, listas anidadas) mejor que CSV.

Cuándo NO usarlo

  • Tablas pequeñas y un solo intercambio puntual. Para un data.frame de unas miles de filas, reticulate o rpy2 son más directos y no añaden una dependencia binaria.
  • Si no controlas el entorno de despliegue. Arrow es una dependencia compilada relativamente pesada. En entornos restringidos puede ser un problema.

Conceptos clave

  • Parquet vs Feather. Parquet está comprimido, optimizado para almacenamiento prolongado e interoperabilidad con motores OLAP (DuckDB, Spark, BigQuery). Feather (Arrow IPC) es esencialmente un volcado del layout en memoria, ideal para handoffs rápidos entre R y Python sin compresión.
  • pyarrow.Table ↔︎ pandas.DataFrame. table.to_pandas() y pa.Table.from_pandas(df). La conversión preserva tipos cuando es posible. Revisa el schema si tienes tipos complejos.
  • Datasets particionados. pyarrow.dataset permite leer un directorio Parquet con particiones (year=2025/month=04/) aplicando predicate pushdown.
  • Tipo timestamp con zona. Arrow lo preserva. CSV no. Esta es una razón frecuente para migrar a Parquet.
  • nanoarrow / arrow::write_feather() en R producen ficheros que pyarrow lee sin tocar el schema.

Patrón mínimo

Lado R, escribir Parquet:

library(arrow)

# Cualquier data.frame, tibble, o dataset arrow
write_parquet(iris, "iris.parquet")

# Variante Feather (más rápido, sin comprimir)
write_feather(iris, "iris.feather")

Lado Python, leer y operar:

import pyarrow.parquet as pq
import pyarrow.feather as feather

# Lectura completa
tbl = pq.read_table("iris.parquet")
df  = tbl.to_pandas()

# Lectura selectiva: solo dos columnas, con filtro
tbl2 = pq.read_table(
    "iris.parquet",
    columns=["Sepal.Length", "Species"],
    filters=[("Species", "=", "setosa")],
)

# Feather equivalente
df_fea = feather.read_feather("iris.feather")

Trampas habituales

  • Nombres con punto. R usa Sepal.Length. Python lo acepta pero requiere df["Sepal.Length"] (no df.Sepal.Length). Considera renombrar a snake_case en la frontera si el destino es Python.
  • Tipos enteros. R no distingue int32 de int64 en data.frame base por defecto. Arrow sí. Si el destino Python espera int64, fija el tipo explícitamente al escribir.
  • factor ↔︎ dictionary. Arrow representa factor como dictionary<string>. En pandas llega como CategoricalDtype. Si lo quieres como string puro, convierte antes de escribir.
  • NA vs NaN. En columnas numéricas el comportamiento es consistente. En columnas string, NA R se convierte en None (que pandas representa como NaN en object o como pd.NA con dtype string[pyarrow]). Decide explícitamente qué dtype usas en el lado pandas.
  • Versiones de Arrow. arrow R y pyarrow deben ser razonablemente compatibles. Los breaking changes son raros pero ocurren. Fija versiones en renv / pyproject.toml.

Enlaces

Relacionados en esta página


Quarto polyglot

Quarto permite un único documento .qmd con chunks de R y de Python en la misma renderización. Internamente, los chunks de Python se ejecutan vía reticulate cuando el motor es knitr, o vía jupyter (kernel propio) cuando se declara explícitamente. La elección del motor cambia las reglas de compartición de objetos.

Es el patrón natural para informes, notebooks y documentación de análisis que combinan, por ejemplo, ingesta y limpieza en R con un modelo de deep learning entrenado/inferido en Python, todo dentro del mismo render.

Cuándo usarlo

  • Informes y vignettes donde una parte del análisis vive cómoda en R (estadística clásica, gráficos ggplot2, paquetes Bioconductor) y otra en Python (modelo entrenado en PyTorch, llamada a una API).
  • Material docente y guías donde queremos mostrar el mismo análisis en ambos lenguajes.
  • Notebooks de proyecto que un equipo bilingüe edita.

Cuándo NO usarlo

  • Código de producción. Un .qmd es un artefacto de informe, no un módulo. Para producción, separa los pasos en scripts/paquetes y deja el .qmd solo para reporting.
  • Si el equipo no domina los dos lenguajes. Un documento polyglot multiplica la carga cognitiva. Si solo una persona escribe R y otra Python, mejor dos cuadernos separados conectados por Parquet/Arrow.
  • Si el render entra en un CI restrictivo. Necesitas R + Python disponibles y bien configurados en la imagen. Cualquier desalineación rompe el render.

Conceptos clave

  • Motor knitr (por defecto). Cada chunk Python se ejecuta con reticulate dentro de la misma sesión R. r.objeto accede desde Python a objetos R. py$objeto accede desde R a objetos Python.
  • Motor jupyter. Se declara con engine: jupyter en el front matter. Todos los chunks corren en un kernel Jupyter. Los chunks de R requieren el kernel IRkernel. No comparten estado con un proceso R independiente.
  • _quarto.yml centraliza la configuración: motor, intérprete Python, kernel Jupyter, freeze de resultados, etc.
  • freeze: auto. Almacena los resultados ejecutados en _freeze/. Esencial para evitar recomputar análisis pesados en cada render y para que el CI no necesite todas las dependencias.
  • Caché de chunks. cache: true por chunk. Complementario a freeze. La política habitual es freeze para todo el documento + cache para chunks muy lentos durante la edición.

Patrón mínimo

Front matter del .qmd:

---
title: "Análisis polyglot R + Python"
format: html
execute:
  freeze: auto
---

Chunk R que prepara los datos:

# Quarto chunk: {r prep}
library(dplyr)
library(arrow)

datos <- iris |>
  filter(Species != "setosa") |>
  mutate(target = as.integer(Species == "virginica"))

write_feather(datos, "_cache/iris_clean.feather")
datos |> head()

Chunk Python que entrena un modelo y devuelve resultados:

# Quarto chunk: {python model}
import pyarrow.feather as feather
from sklearn.linear_model import LogisticRegression

# Lectura desde el feather producido por R (más robusto que cruzar el data.frame
# por reticulate cuando el dataset crece)
df = feather.read_feather("_cache/iris_clean.feather")

X = df[["Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"]]
y = df["target"]

clf = LogisticRegression(max_iter=1000).fit(X, y)
clf.coef_

Chunk R que recoge el objeto Python:

# Quarto chunk: {r read-back}
coefs <- py$clf$coef_       # numpy array -> matriz R
colnames(coefs) <- colnames(py$X)
coefs

Trampas habituales

  • Motor cambiado a mitad del proyecto. Pasar de knitr a jupyter cambia las reglas de compartición de objetos (py$ / r. solo funcionan con knitr + reticulate). Decide el motor al principio.
  • reticulate no inicializado antes del primer chunk Python. Si el primer chunk Python no encuentra el intérprete esperado, Quarto inicializa con cualquiera del sistema. Fija RETICULATE_PYTHON en _quarto.yml o en .Rprofile.
  • freeze corrompido tras cambios de dependencias. Si actualizas un paquete pero _freeze/ mantiene resultados antiguos, los gráficos del render no reflejan el código actual. Borra _freeze/ ante cambios estructurales.
  • Conflictos de nombres entre r.df y py$df. Mantén convenciones claras (p. ej. df_r y df_py). Reasignar el mismo nombre cruza estados de forma sorprendente.
  • Outputs no determinísticos. Modelos con random_state no fijado producen diffs ruidosos en renders sucesivos. Fija semillas en ambos lenguajes.

Enlaces

Relacionados en esta página

  • reticulate, motor por debajo de los chunks Python en knitr.
  • pyarrow, vía recomendada para cachear datos entre chunks costosos.

pickle / feather / parquet como intercambio

Cuando solo necesitas pasar un objeto entre dos procesos independientes, sin compartir runtime ni renderizar nada conjunto, la opción correcta es serializar a un fichero. Las tres alternativas razonables, y por qué elegir cada una:

  • Parquet (recomendado por defecto). Columnar, comprimido, schema explícito, leído nativamente por R (arrow), Python (pyarrow, pandas), DuckDB, Spark, BigQuery, Polars. Es el formato más portable.
  • Feather / Arrow IPC. Igual de portable que Parquet pero sin compresión y con un layout casi idéntico al de memoria. Más rápido de leer y escribir. Ocupa más en disco. Indicado para caches intermedios de un pipeline corto.
  • Pickle. Específico de Python, pickle.dump() serializa cualquier objeto Python, incluidos modelos sklearn, redes neuronales, etc. Solo legible desde Python. Útil cuando lo que pasas no es una tabla (un modelo entrenado, un grafo de configuración) y no necesitas que R lo lea.

Cuándo usarlo

  • Parquet/Feather: cuando el objeto es tabular (data.frame / DataFrame). Especialmente si los dos lados son procesos distintos en máquinas distintas o si el dataset es grande.
  • Pickle: cuando el objeto es un modelo Python ya entrenado, una estructura compleja Python-específica, o un checkpoint intermedio dentro de un pipeline puramente Python. No lo uses para intercambiar con R.

Cuándo NO usarlo

  • Pickle a través de red o entre versiones distintas de Python/librerías. Es un formato frágil: cambia la versión de sklearn y el pickle deja de cargar. Para persistencia a largo plazo usa joblib (mejor que pickle para arrays grandes) o formatos específicos como ONNX (modelos) o Parquet (datos).
  • Pickle desde fuentes no confiables. Ejecuta código arbitrario en deserialización. Nunca cargues un pickle que no controles.

Conceptos clave

  • joblib.dump / joblib.load es el preferido en el ecosistema scikit-learn para modelos grandes: maneja eficientemente numpy.array de gran tamaño.
  • Versionado del modelo. Si vas a guardar un modelo Python, persiste también el requirements.txt o el pyproject.toml que lo produjo. Sin eso, la carga futura puede fallar silenciosamente.
  • ONNX es el formato neutro de intercambio de modelos entre frameworks (PyTorch ↔︎ TF ↔︎ ONNX Runtime ↔︎ scikit-learn vía skl2onnx). Útil cuando R necesita servir el modelo (onnxruntime tiene binding R vía reticulate).

Patrón mínimo

Serializar un modelo Python:

import joblib
from sklearn.ensemble import RandomForestClassifier

clf = RandomForestClassifier(n_estimators=200).fit(X, y)
joblib.dump(clf, "models/rf.joblib")

Cargarlo en otro proceso Python:

import joblib

clf = joblib.load("models/rf.joblib")
clf.predict(X_new)

Para datos tabulares, usa Parquet/Feather como se muestra en la sección de pyarrow. Para servir un modelo Python desde R sin instalar Python en producción, exporta a ONNX y consume con onnxruntime.

Trampas habituales

  • Pickle entre versiones. Un pickle producido con sklearn 1.3 puede no cargar en 1.5. Pin versionado en el entorno de producción.
  • Tamaño. Pickles de modelos grandes (>1 GB) son lentos de cargar. joblib con compress=3 reduce significativamente sin sacrificar velocidad de carga.
  • Pickle de objetos con referencias a clases propias. Si la clase no está disponible en el PYTHONPATH al cargar, pickle falla con AttributeError opaco. Empaqueta tus clases en un paquete instalable, no las dejes en un script suelto.
  • Mezclar Parquet con pickle. Si tu artefacto es un par “datos + modelo”, guarda datos como Parquet y modelo como joblib. No metas todo en un pickle: pierdes interoperabilidad con R y con cualquier herramienta no-Python.

Enlaces

Relacionados en esta página

  • pyarrow, formato de elección para datos tabulares entre R y Python.
  • reticulate y rpy2, alternativas en proceso cuando no quieres pasar por disco.