Jupyter Notebooks: Exploración Sí, Producción No

Los notebooks de Jupyter son una herramienta brillante atrapada en el cuerpo equivocado cuando se les pide hacer lo que no les corresponde. Un notebook es un documento ejecutable que mezcla código Python, salida y prosa en celdas independientes — perfecto para explorar datos, comunicar análisis o enseñar. El problema empieza cuando ese notebook pasa a ser “el código del proyecto”.

El diseño fundamental de un notebook es el de un estado global mutable con historia implícita. Cada celda comparte el mismo namespace de Python y puede ejecutarse en cualquier orden. Eso significa que si ejecutas la celda 7 antes que la 3, el resultado puede cambiar silenciosamente sin que nada te avise. No hay un grafo de dependencias explícito entre celdas — solo el orden en que tú decidiste ejecutarlas esta tarde.

Esto tiene consecuencias en cascada. El archivo .ipynb es JSON puro que incluye los outputs, contadores de ejecución, metadatos del kernel y el código. Cuando haces git diff de un notebook con outputs commiteados, ves cientos de líneas de base64 codificando imágenes o arrays numéricos. La revisión de código se vuelve imposible. Tampoco puedes importar un notebook como módulo ni correr pytest sobre sus funciones directamente — el código vive encapsulado en una estructura JSON, no en un módulo Python.

El patrón correcto no es “usa solo scripts .py” sino entender qué hace bien cada cosa: explora en el notebook, consolida en módulos. Cuando una función o clase emerge del caos exploratorio y ya sabes qué hace, la mueves a un archivo .py y la importas desde el notebook. El notebook queda como narración y experimento; el módulo queda como código versionable, testeable y reutilizable.

Para el problema del diff, la solución estándar es nbstripout — un filtro de git que limpia automáticamente los outputs de las celdas antes de que el notebook llegue al índice de git. No modifica tu notebook en disco, solo intercepta lo que git ve. Y cuando necesitas reproducibilidad total — ejecutar un notebook como si fuera un pipeline con parámetros de entrada — papermill es la herramienta correcta: toma un notebook, inyecta parámetros en una celda especial y lo ejecuta produciendo un nuevo notebook con los resultados.

# src/analysis/preprocessing.py
# ── Módulo extraído del notebook exploratorio ──────────────────────────────

import pandas as pd
from pathlib import Path


def load_sales(path: Path) -> pd.DataFrame:
    df = pd.read_csv(path, parse_dates=["date"])
    df = df.dropna(subset=["revenue", "units"])
    df["revenue"] = df["revenue"].astype(float)
    return df


def monthly_summary(df: pd.DataFrame) -> pd.DataFrame:
    return (
        df.groupby(df["date"].dt.to_period("M"))
        .agg(total_revenue=("revenue", "sum"), total_units=("units", "sum"))
        .reset_index()
    )
# notebooks/01_sales_exploration.ipynb  (representado como .py para legibilidad)
# ── El notebook importa desde el módulo, no redefine lógica ───────────────

from pathlib import Path
import matplotlib.pyplot as plt
from src.analysis.preprocessing import load_sales, monthly_summary

DATA_PATH = Path("../data/sales_2024.csv")  # parámetro inyectable con papermill

df = load_sales(DATA_PATH)
summary = monthly_summary(df)

fig, ax = plt.subplots(figsize=(10, 4))
ax.bar(summary["date"].astype(str), summary["total_revenue"])
ax.set_title("Ingresos mensuales 2024")
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
# Configurar nbstripout una sola vez por repositorio
pip install nbstripout
nbstripout --install          # registra el filtro en .git/config
nbstripout --install --attributes .gitattributes  # persiste para el equipo
# Ejecutar el notebook como pipeline parametrizado con papermill
pip install papermill

papermill \
  notebooks/01_sales_exploration.ipynb \
  outputs/01_sales_2023_run.ipynb \
  -p DATA_PATH ../data/sales_2023.csv

Lo que hace que este patrón funcione

La clave está en la separación de responsabilidades. preprocessing.py contiene funciones puras — toman datos, devuelven datos, sin estado global. Eso significa que pytest puede importarlas y testearlas exactamente igual que cualquier otro módulo:

# tests/test_preprocessing.py
import pandas as pd
from src.analysis.preprocessing import monthly_summary

def test_monthly_summary_aggregates_correctly():
    df = pd.DataFrame({
        "date": pd.to_datetime(["2024-01-15", "2024-01-20", "2024-02-05"]),
        "revenue": [100.0, 200.0, 150.0],
        "units": [1, 2, 3],
    })
    result = monthly_summary(df)
    assert result.loc[0, "total_revenue"] == 300.0  # enero: 100+200
    assert len(result) == 2

El notebook, mientras tanto, queda como un documento de exploración y comunicación. No tiene lógica propia — solo orquesta llamadas a funciones que viven en módulos versionados y testeados. Cuando alguien del equipo necesita entender cómo funciona la transformación de datos, lee el módulo, no el notebook.

nbstripout resuelve el problema del diff de forma silenciosa. Una vez instalado, git add y git commit funcionan exactamente igual — solo que el JSON que git recibe ya no tiene outputs, metadatos de ejecución ni contadores de celda. El diff muestra solo cambios de código real. Para que el filtro aplique a todo el equipo sin que cada persona lo instale manualmente, el archivo .gitattributes es la pieza que lo hace automático:

# .gitattributes
*.ipynb filter=nbstripout

Con papermill, el notebook deja de ser un artefacto interactivo y pasa a ser un template parametrizado. La celda que tiene la etiqueta parameters (que puedes agregar desde la interfaz de Jupyter o con la API de papermill) actúa como la interfaz pública del pipeline. Eso te da reproducibilidad total: el notebook de output documenta exactamente con qué parámetros se ejecutó y cuáles fueron los resultados — útil para auditoría y para debugging cuando un pipeline falla en producción.

Errores que debes conocer

Error: Committing notebooks con outputs genera diffs ilegibles y puede filtrar datos sensibles al repositorio.

# ❌ Sin nbstripout: el diff incluye outputs, imágenes en base64, timestamps
git diff notebooks/01_analysis.ipynb
# +      "execution_count": 42,
# +      "outputs": [{"data": {"image/png": "iVBORw0KGgoAAAANS..."}, ...}]

# ✅ Con nbstripout instalado: el diff solo muestra cambios de código
git diff notebooks/01_analysis.ipynb
# -    "source": ["df.head(10)\n"]
# +    "source": ["df.head(20)\n"]

El filtro intercepta el notebook antes de que llegue al objeto git, así que el archivo en disco no se modifica — solo lo que el repositorio almacena.


Error: Redefinir lógica de negocio dentro del notebook en lugar de importarla desde un módulo, lo que lleva a duplicación y a código no testeable.

# ❌ La función vive solo en el notebook — no hay forma de testearla con pytest
def clean_dataframe(df):
    return df.dropna().reset_index(drop=True)

result = clean_dataframe(raw_df)

# ✅ La función vive en src/utils.py, el notebook solo la importa
from src.utils import clean_dataframe

result = clean_dataframe(raw_df)

Mover la función al módulo no cambia nada en el notebook — pero ahora pytest puede encontrarla, un colega puede reutilizarla, y el equipo puede hacer code review del código real sin abrir un JSON.


Error: Asumir que el estado del kernel refleja el orden del código escrito, cuando en realidad refleja el orden de ejecución.

# ❌ Celda 1 (ejecutada segunda): usa `total` que se definió en la celda 2
print(total * 1.21)

# ❌ Celda 2 (ejecutada primera): define `total`
total = sum(prices)

# ✅ Reinicia el kernel y ejecuta todas las celdas en orden antes de commitear
# Kernel → Restart & Run All — esto valida que el notebook es reproducible

Si el notebook no funciona con “Restart & Run All”, no es un artefacto reproducible — es un artefacto que solo funciona en la sesión de quien lo creó.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio