Limpieza y Preprocesamiento de Datos con Pandas 3.0: Guía Práctica con Pipelines Reproducibles

Guía práctica de limpieza de datos con pandas 3.0. Construye pipelines reproducibles con .pipe(), gestiona valores faltantes, duplicados y outliers. Incluye Copy-on-Write, PyArrow y ejemplos con dataset real de e-commerce.

¿Por qué la Limpieza de Datos es la Habilidad más Importante de un Científico de Datos?

Hay una estadística que todo científico de datos conoce de memoria, pero que sigue sorprendiendo a quienes recién empiezan: entre el 50% y el 80% del tiempo en un proyecto de datos se va en tareas de limpieza y preprocesamiento. No en modelado. No en algoritmos sofisticados de machine learning. En limpiar datos sucios, inconsistentes, incompletos y mal formateados.

Sí, puede sonar decepcionante al principio.

Pero con el tiempo entiendes que la calidad del dato es el cimiento de cualquier análisis que valga la pena. Un modelo entrenado con datos basura producirá predicciones basura, sin importar cuán avanzada sea su arquitectura. El viejo dicho anglosajón lo clava: garbage in, garbage out. Y créeme, después de haber visto proyectos enteros fracasar por no limpiar bien los datos, ese dicho duele un poco más cada vez.

Afortunadamente, el ecosistema Python cuenta con pandas, la biblioteca de referencia para manipulación y análisis de datos tabulares. Y el 21 de enero de 2026, el equipo de pandas lanzó oficialmente pandas 3.0, una versión que trae cambios fundamentales en cómo la biblioteca gestiona la memoria, las cadenas de texto y la semántica de las copias. En esta guía vamos desde los fundamentos hasta las técnicas más modernas, siempre con ejemplos prácticos basados en un dataset realista de ventas de comercio electrónico.

Configuración del Entorno de Trabajo

Antes de escribir una sola línea de código de análisis, toca configurar correctamente el entorno. pandas 3.0 requiere Python 3.11 o superior y tiene una dependencia mucho más estrecha con PyArrow, que ya no es opcional sino prácticamente obligatorio para aprovechar todas las mejoras de rendimiento.

Para instalar todo lo necesario, ejecuta esto en tu terminal:

pip install "pandas>=3.0.0" pyarrow>=18.0.0 scikit-learn openpyxl

Una vez instalado, verifica las versiones:

import pandas as pd
import numpy as np
import pyarrow as pa
from sklearn.impute import SimpleImputer

print(f"Pandas version:  {pd.__version__}")
print(f"PyArrow version: {pa.__version__}")
print(f"NumPy version:   {np.__version__}")

Con el entorno listo, vamos a crear el dataset de ejemplo que usaremos a lo largo de toda la guía. Simularemos un archivo CSV de ventas de una tienda de e-commerce con problemas típicos del mundo real (y cuando digo típicos, me refiero a que los vas a encontrar en casi todos los proyectos):

import pandas as pd
import numpy as np
from io import StringIO

csv_data = """order_id,customer_name,email,category,product,quantity,unit_price,order_date,country,status
1001,  Ana García ,[email protected],Electrónica,Laptop Pro 15,1,1299.99,2024-01-15,España,completed
1002,PEDRO MARTÍNEZ,[email protected],Ropa,Camiseta básica,3,19.99,2024-01-16,México,completed
1003,Laura Sánchez,,Electrónica,Laptop Pro 15,1,1299.99,2024-01-16,España,completed
1004,Carlos López,[email protected],Hogar,Lámpara LED,2,45.50,15/01/2024,Argentina,pending
1005,Ana García,[email protected],Electrónica,Laptop Pro 15,1,1299.99,2024-01-15,España,completed
1006,María Torres,[email protected],Ropa,,5,-9.99,2024-02-01,Colombia,completed
1007,  pedro martínez  ,[email protected],Ropa,Camiseta básica,3,19.99,2024-01-16,México,completed
1008,Jorge Ruiz,[email protected],Electrónica,Smartphone X12,2,899.00,2024-02-10,España,shipped
1009,Elena Vega,[email protected],Hogar,Sofá modular,1,2499.00,2024-02-11,España,cancelled
1010,Tomás Díaz,,Electrónica,Tablet Ultra,3,599.99,invalid_date,Peru,pending
1011,Sofía Romero,[email protected],Ropa,Pantalón slim,2,59.99,2024-02-15,España,completed
1012,Luis Herrera,[email protected],Electrónica,Laptop Pro 15,1,9999.99,2024-02-20,México,completed
1013,Carmen Jiménez,[email protected],Hogar,Escritorio bambú,1,349.00,2024-02-22,Argentina,completed
1014,Pablo Morales,[email protected],Electrónica,Smartphone X12,999,899.00,2024-02-25,España,pending
1015,,[email protected],Ropa,Vestido floral,1,89.99,2024-02-28,Colombia,completed
"""

df_raw = pd.read_csv(StringIO(csv_data))
print(f"Dataset cargado: {df_raw.shape[0]} filas x {df_raw.shape[1]} columnas")

Este dataset tiene problemas deliberados: espacios sobrantes en nombres, emails faltantes, fechas con formatos inconsistentes, precios negativos, cantidades absurdas, duplicados y campos vacíos. Es exactamente el tipo de cosa que te vas a encontrar cuando abras ese CSV que te manda el equipo de negocio un lunes a las 9 de la mañana.

Exploración Inicial: Conoce tu Dataset Antes de Limpiarlo

El primer paso de cualquier proceso de limpieza es la exploración. Nunca, nunca empieces a modificar datos sin entender primero qué tienes entre manos. pandas ofrece un arsenal de métodos para esta fase de diagnóstico.

Información estructural con df.info()

df_raw.info()

La salida de df.info() te dice cuántas filas no nulas hay por columna, el tipo de dato inferido y el uso de memoria. Presta especial atención a las columnas donde el número de non-null es menor que el total de filas — esas son las que tienen valores faltantes.

# Estadísticas descriptivas para columnas numéricas
print(df_raw.describe())

# Estadísticas para columnas de texto también
print(df_raw.describe(include='all'))

El nuevo tipo de dato string con PyArrow en pandas 3.0

Una de las novedades más importantes de pandas 3.0 es el cambio en el tipo por defecto para cadenas de texto. Antes, pandas usaba el tipo object para almacenar strings, lo cual era bastante ineficiente en memoria y rendimiento. Con pandas 3.0 y PyArrow instalado, puedes (y deberías) usar el tipo ArrowDtype string:

import pandas as pd
import pyarrow as pa

# Leer con tipos PyArrow explícitamente
df_arrow = pd.read_csv(
    StringIO(csv_data),
    dtype_backend="pyarrow"
)

print(df_arrow.dtypes)
print(f"\nUso de memoria (object):    {df_raw.memory_usage(deep=True).sum():,} bytes")
print(f"Uso de memoria (pyarrow):   {df_arrow.memory_usage(deep=True).sum():,} bytes")

También puedes convertir columnas específicas al tipo ArrowDtype de forma selectiva:

# Convertir columnas de texto a string backed by PyArrow
string_cols = ['customer_name', 'email', 'category', 'product', 'country', 'status']
for col in string_cols:
    df_raw[col] = df_raw[col].astype(pd.ArrowDtype(pa.string()))

print(df_raw[string_cols].dtypes)

Exploración visual rápida

# Primeras filas
print("--- Primeras 5 filas ---")
print(df_raw.head())

# Últimas filas
print("\n--- Últimas 5 filas ---")
print(df_raw.tail())

# Dimensiones
print(f"\nDimensiones del dataset: {df_raw.shape}")
print(f"Filas: {df_raw.shape[0]}, Columnas: {df_raw.shape[1]}")

# Tipos de datos por columna
print("\n--- Tipos de datos ---")
print(df_raw.dtypes)

# Valores únicos por columna categórica
for col in ['category', 'country', 'status']:
    print(f"\n{col}: {df_raw[col].unique()}")

Un consejo que me ha salvado más de una vez: antes de limpiar, documenta en un comentario o celda markdown el inventario de problemas que detectas durante la exploración. Funciona como una lista de tareas y además queda como documentación del proceso.

Manejo de Valores Faltantes

Los valores faltantes (también llamados missing values, NA o NaN) son probablemente el problema más frecuente en datasets del mundo real. Son como las cucarachas de los datos: siempre hay más de las que piensas. pandas 3.0 mantiene y mejora las herramientas clásicas para detectarlos y tratarlos.

Detección de valores faltantes

# Conteo de valores nulos por columna
nulos_por_columna = df_raw.isna().sum()
print("Valores nulos por columna:")
print(nulos_por_columna[nulos_por_columna > 0])

# Porcentaje de nulos
porcentaje_nulos = (df_raw.isna().sum() / len(df_raw)) * 100
print("\nPorcentaje de nulos por columna:")
print(porcentaje_nulos[porcentaje_nulos > 0].round(2))

# Filas con al menos un valor nulo
filas_con_nulos = df_raw[df_raw.isna().any(axis=1)]
print(f"\nFilas con al menos un valor nulo: {len(filas_con_nulos)}")
print(filas_con_nulos)

Tanto isna() como isnull() son equivalentes. El equipo de pandas recomienda usar isna() por su mayor claridad semántica.

Estrategia 1: Eliminar filas o columnas con valores faltantes

# Eliminar filas donde TODOS los valores son nulos
df_sin_todo_nulo = df_raw.dropna(how='all')

# Eliminar filas donde AL MENOS UN valor es nulo
df_sin_ningun_nulo = df_raw.dropna(how='any')

# Eliminar filas donde columnas criticas son nulas
df_criticos = df_raw.dropna(subset=['customer_name', 'order_date'])
print(f"Filas originales: {len(df_raw)}")
print(f"Tras eliminar por columnas críticas: {len(df_criticos)}")

# Eliminar columnas con más del 50% de nulos
umbral = len(df_raw) * 0.5
df_sin_columnas_nulas = df_raw.dropna(axis=1, thresh=int(umbral))

Ojo con esto: eliminar filas con valores nulos puede introducir sesgos si los valores no faltan de forma aleatoria. Antes de usar dropna() a lo loco, analiza si el patrón de valores faltantes tiene algún significado. A veces los datos faltan precisamente en los casos más interesantes.

Estrategia 2: Rellenar valores faltantes

# Rellenar con un valor constante
df_relleno = df_raw.copy()
df_relleno['email'] = df_relleno['email'].fillna('[email protected]')
df_relleno['product'] = df_relleno['product'].fillna('Producto Desconocido')
df_relleno['customer_name'] = df_relleno['customer_name'].fillna('Cliente Anónimo')

# Rellenar con la mediana (para numéricos)
mediana_precio = df_relleno['unit_price'].median()
df_relleno['unit_price'] = df_relleno['unit_price'].fillna(mediana_precio)

# Rellenar con la moda (valor más frecuente)
moda_categoria = df_relleno['category'].mode()[0]
df_relleno['category'] = df_relleno['category'].fillna(moda_categoria)

print(df_relleno.isna().sum())

Estrategia 3: Interpolación

# La interpolación es útil para series temporales o datos con tendencia
# Creemos un ejemplo específico para esta técnica
ventas_diarias = pd.Series([100, 110, np.nan, 130, np.nan, np.nan, 170])

print("Serie original:")
print(ventas_diarias)

print("\nInterpolación lineal:")
print(ventas_diarias.interpolate(method='linear'))

print("\nInterpolación polinómica:")
print(ventas_diarias.interpolate(method='polynomial', order=2))

Estrategia 4: Imputación avanzada con scikit-learn

Para datasets con múltiples columnas numéricas correlacionadas, la imputación simple puede quedarse corta. SimpleImputer de scikit-learn se integra muy bien con pandas:

from sklearn.impute import SimpleImputer

# Preparar datos numéricos con valores nulos
df_num = pd.DataFrame({
    'price': [100, 200, np.nan, 150, np.nan, 300],
    'quantity': [5, np.nan, 3, np.nan, 8, 2],
    'discount': [10, 15, 5, np.nan, 20, np.nan]
})

# Imputar con la mediana
imputer = SimpleImputer(strategy='median')
valores_imputados = imputer.fit_transform(df_num)
df_imputado = pd.DataFrame(valores_imputados, columns=df_num.columns)

print("Antes de imputar:")
print(df_num)
print("\nDespués de imputar con mediana:")
print(df_imputado)

Eliminación de Duplicados

Los duplicados son otra fuente habitual de dolores de cabeza. En nuestro dataset, las órdenes 1002 y 1007 representan al mismo cliente con la misma compra (solo cambian los espacios y las mayúsculas), y la orden 1005 parece una copia exacta de la 1001.

# Detectar filas completamente duplicadas
print("Filas duplicadas exactas:")
print(df_raw[df_raw.duplicated()])

# Detectar duplicados basados en columnas específicas
print("\nDuplicados por cliente + producto + fecha:")
duplicados_semanticos = df_raw.duplicated(
    subset=['customer_name', 'product', 'order_date'],
    keep='first'
)
print(df_raw[duplicados_semanticos])

# Ver todas las filas involucradas en duplicaciones (incluida la primera)
print("\nTodas las filas involucradas (incluyendo originales):")
print(df_raw[df_raw.duplicated(
    subset=['email', 'product', 'order_date'],
    keep=False
)])
# Eliminar duplicados - mantener la primera ocurrencia
df_sin_dup = df_raw.drop_duplicates(keep='first')
print(f"Filas originales: {len(df_raw)}")
print(f"Filas sin duplicados exactos: {len(df_sin_dup)}")

# Eliminar duplicados semánticos (misma orden aunque el nombre tenga variaciones de formato)
# Primero normalizamos el email (identificador único del cliente)
df_sin_dup_sem = df_raw.drop_duplicates(
    subset=['email', 'product', 'order_date'],
    keep='first'
)
print(f"Filas sin duplicados semánticos: {len(df_sin_dup_sem)}")

Dato útil: el parámetro keep acepta tres valores: 'first' (mantiene la primera aparición), 'last' (mantiene la última) y False (elimina todas las filas duplicadas, incluido el original). Personalmente, casi siempre uso 'first', pero hay casos donde 'last' tiene más sentido si quieres conservar la versión más reciente de un registro.

Conversión y Corrección de Tipos de Datos

pandas infiere los tipos de datos al leer un archivo, pero no siempre acierta. En nuestro dataset tenemos columnas de fecha que llegaron como strings y precios que deberían ser float pero pueden traer sorpresas.

Conversión de fechas con pd.to_datetime

# El problema: tenemos fechas en dos formatos distintos y una fecha inválida
print("Fechas originales:")
print(df_raw['order_date'].unique())

# Conversión básica - los errores generarán NaT (Not a Time)
df_raw['order_date'] = pd.to_datetime(df_raw['order_date'], errors='coerce')
print("\nFechas convertidas:")
print(df_raw['order_date'])
print(f"\nFechas que no se pudieron parsear: {df_raw['order_date'].isna().sum()}")

El parámetro errors='coerce' es clave aquí: en lugar de lanzar una excepción cuando se topa con algo que no puede parsear (como 'invalid_date'), lo convierte a NaT (Not a Time, el equivalente de NaN para fechas). Así puedes seguir procesando y después tratar esos valores como nulos.

# Extraer componentes de la fecha
df_raw['year'] = df_raw['order_date'].dt.year
df_raw['month'] = df_raw['order_date'].dt.month
df_raw['day_of_week'] = df_raw['order_date'].dt.day_name()
df_raw['quarter'] = df_raw['order_date'].dt.quarter

print(df_raw[['order_date', 'year', 'month', 'day_of_week', 'quarter']].head(8))

Conversión a tipos numéricos

# Convertir columna de precio que podría tener valores no numéricos
precios_problematicos = pd.Series(['19.99', '45.50', 'precio_invalido', '99.99', None])

# Con errors='coerce', los no numéricos se convierten a NaN
precios_convertidos = pd.to_numeric(precios_problematicos, errors='coerce')
print("Precios convertidos:", precios_convertidos.tolist())

# Optimización de tipos numéricos para ahorrar memoria
print(f"\nMemoria int64:  {df_raw['quantity'].astype('int64').memory_usage(deep=True)} bytes")
print(f"Memoria int32:  {df_raw['quantity'].fillna(0).astype('int32').memory_usage(deep=True)} bytes")
print(f"Memoria int16:  {df_raw['quantity'].fillna(0).astype('int16').memory_usage(deep=True)} bytes")

El tipo ArrowDtype en pandas 3.0

# Convertir columnas categóricas al nuevo tipo ArrowDtype
import pyarrow as pa

# String type backed by PyArrow (hasta 5-10x más rápido en operaciones de texto)
df_raw['category'] = df_raw['category'].astype(pd.ArrowDtype(pa.string()))
df_raw['status'] = df_raw['status'].astype(pd.ArrowDtype(pa.string()))
df_raw['country'] = df_raw['country'].astype(pd.ArrowDtype(pa.string()))

# También puedes usar la categoría pandas clásica para columnas de baja cardinalidad
df_raw['status_cat'] = df_raw['status'].astype('category')
df_raw['category_cat'] = df_raw['category'].astype('category')

print("Tipos optimizados:")
print(df_raw[['category', 'status', 'country', 'status_cat']].dtypes)
print(f"\nValores únicos de status: {df_raw['status'].unique()}")

Detección y Tratamiento de Outliers

Los valores atípicos (u outliers) pueden distorsionar severamente los análisis estadísticos y el entrenamiento de modelos. En nuestro dataset hay dos candidatos evidentes: la orden 1012 con un precio de 9999.99 para una laptop (seguramente un error al teclear) y la orden 1014 con 999 unidades de un smartphone. Nadie compra 999 smartphones de golpe, ¿verdad?

Método del Rango Intercuartílico (IQR)

def detectar_outliers_iqr(serie, factor=1.5):
    """
    Detecta outliers usando el método IQR.
    Retorna una máscara booleana: True donde hay outlier.
    """
    Q1 = serie.quantile(0.25)
    Q3 = serie.quantile(0.75)
    IQR = Q3 - Q1
    limite_inferior = Q1 - factor * IQR
    limite_superior = Q3 + factor * IQR

    return (serie < limite_inferior) | (serie > limite_superior)

# Aplicar a la columna de precio
outliers_precio = detectar_outliers_iqr(df_raw['unit_price'])
print("Outliers detectados en unit_price:")
print(df_raw[outliers_precio][['order_id', 'product', 'unit_price']])

# Aplicar a la columna de cantidad
outliers_cantidad = detectar_outliers_iqr(df_raw['quantity'])
print("\nOutliers detectados en quantity:")
print(df_raw[outliers_cantidad][['order_id', 'product', 'quantity']])

Método Z-Score

def detectar_outliers_zscore(serie, umbral=3.0):
    """
    Detecta outliers usando el Z-Score.
    Valores con |z| > umbral se consideran outliers.
    """
    media = serie.mean()
    std = serie.std()
    z_scores = (serie - media) / std
    return z_scores.abs() > umbral

# Aplicar Z-Score al precio
outliers_z_precio = detectar_outliers_zscore(df_raw['unit_price'])
print("Outliers por Z-Score en unit_price:")
print(df_raw[outliers_z_precio][['order_id', 'product', 'unit_price']])

Tratamiento de outliers: clipping con percentiles

# Estrategia 1: Clipping (recortar) al rango percentil 1-99
p01 = df_raw['unit_price'].quantile(0.01)
p99 = df_raw['unit_price'].quantile(0.99)
df_raw['unit_price_clipped'] = df_raw['unit_price'].clip(lower=p01, upper=p99)

print(f"Precio máximo original: {df_raw['unit_price'].max():.2f}")
print(f"Precio máximo recortado: {df_raw['unit_price_clipped'].max():.2f}")

# Estrategia 2: Eliminar outliers flagrantes (ej. precios negativos)
precios_negativos = df_raw['unit_price'] < 0
print(f"\nPrecios negativos encontrados: {precios_negativos.sum()}")
df_raw.loc[precios_negativos, 'unit_price'] = np.nan

# Estrategia 3: Winsorización (similar al clipping pero basada en percentiles estadísticos)
from scipy.stats import mstats
precios_validos = df_raw['unit_price'].dropna()
precios_winsor = mstats.winsorize(precios_validos, limits=[0.05, 0.05])
print(f"\nEstadísticas tras winsorización:")
print(f"  Media: {precios_winsor.mean():.2f}")
print(f"  Std:   {precios_winsor.std():.2f}")

Limpieza de Texto con el Accessor str

Las columnas de texto son con frecuencia el origen de los problemas más molestos: espacios extra por todos lados, inconsistencias en mayúsculas, caracteres extraños y formatos mezclados. Honestamente, si nunca has tenido que lidiar con un campo de nombre de cliente que tiene tres espacios al inicio y una tabulación al final, todavía no has sufrido lo suficiente. pandas ofrece el accessor .str que permite aplicar operaciones de string vectorizadas a Series completas.

Operaciones básicas de limpieza

# Limpiar espacios extra al inicio y al final
print("Nombres originales:", df_raw['customer_name'].tolist())
df_raw['customer_name'] = df_raw['customer_name'].str.strip()
print("Nombres limpios:", df_raw['customer_name'].tolist())

# Normalizar a Title Case (primera letra de cada palabra en mayúscula)
df_raw['customer_name'] = df_raw['customer_name'].str.title()
print("Nombres en Title Case:", df_raw['customer_name'].tolist())

# Normalizar la categoría a minúsculas
df_raw['category'] = df_raw['category'].str.lower().str.strip()
df_raw['status'] = df_raw['status'].str.lower().str.strip()

# Normalizar país a Title Case
df_raw['country'] = df_raw['country'].str.strip().str.title()

Limpieza con expresiones regulares

# Validar formato de email con regex
patron_email = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
emails_validos = df_raw['email'].str.match(patron_email, na=False)

print("Emails inválidos o ausentes:")
print(df_raw[~emails_validos][['order_id', 'customer_name', 'email']])

# Limpiar caracteres especiales del nombre (mantener solo letras, espacios y acentos)
df_raw['customer_name_clean'] = df_raw['customer_name'].str.replace(
    r'[^a-zA-ZáéíóúÁÉÍÓÚüÜñÑ\s]',
    '',
    regex=True
)

# Extraer dominio del email
df_raw['email_domain'] = df_raw['email'].str.extract(r'@(.+)$')
print("\nDominios de email:")
print(df_raw['email_domain'].value_counts())

Rendimiento: PyArrow strings vs object strings

Una de las razones más convincentes para migrar a PyArrow-backed strings es el rendimiento. Con pandas 3.0, las operaciones .str sobre columnas de tipo pd.ArrowDtype(pa.string()) pueden ser de 5 a 10 veces más rápidas que las equivalentes sobre columnas object. No es poca cosa.

import time

# Crear dataset de prueba grande
n = 500_000
nombres_object = pd.Series(['  Ana García  '] * n, dtype='object')
nombres_arrow = pd.Series(['  Ana García  '] * n, dtype=pd.ArrowDtype(pa.string()))

# Benchmark: strip + lower sobre object dtype
t0 = time.perf_counter()
_ = nombres_object.str.strip().str.lower()
t1 = time.perf_counter()
tiempo_object = t1 - t0

# Benchmark: strip + lower sobre ArrowDtype string
t2 = time.perf_counter()
_ = nombres_arrow.str.strip().str.lower()
t3 = time.perf_counter()
tiempo_arrow = t3 - t2

print(f"Tiempo con object dtype:       {tiempo_object:.3f}s")
print(f"Tiempo con ArrowDtype string:  {tiempo_arrow:.3f}s")
print(f"Speedup:                       {tiempo_object / tiempo_arrow:.1f}x más rápido")

Copy-on-Write en pandas 3.0: Un Cambio que lo Cambia Todo

Este es quizás el cambio más disruptivo de pandas 3.0, y uno de los que más confusión puede generar si vienes de versiones anteriores. Copy-on-Write (CoW) es ahora el comportamiento por defecto, y cambia fundamentalmente cómo funcionan las copias y las vistas de DataFrames.

¿Qué es Copy-on-Write?

En pandas <= 2.x, cuando hacías un slice de un DataFrame, obtenías una vista o una copia dependiendo de la operación, y era francamente difícil saber cuál de las dos tenías. Esto producía el temido SettingWithCopyWarning y comportamientos que te hacían dudar de tu propia cordura:

# Comportamiento en pandas anterior (puede generar SettingWithCopyWarning)
# -- NO ejecutar en pandas 3.0, es solo ilustrativo --
#
# df_subset = df[df['country'] == 'España']
# df_subset['status'] = 'processed'  # Warning! ¿modifica df_subset o df?
#
# En pandas < 2.0 esto era ambiguo y peligroso.

Con pandas 3.0 y Copy-on-Write activado por defecto, la regla es simple: cualquier DataFrame o Series derivado de otro es siempre una copia lazy. No se copia la memoria hasta que intentas modificar uno de los dos objetos. En ese momento, se hace una copia real antes de la modificación. Limpio, predecible, sin sorpresas.

# En pandas 3.0, esto funciona correctamente y sin warnings
df_espana = df_raw[df_raw['country'] == 'España']  # Vista lazy (no copia aún)

# Al modificar df_espana, CoW hace una copia automáticamente
# df_raw queda intacto
df_espana = df_espana.assign(status='revisado')

print("Status en df_raw (sin cambios):")
print(df_raw[df_raw['country'] == 'España']['status'].tolist())

print("\nStatus en df_espana (modificado):")
print(df_espana['status'].tolist())

La asignación encadenada ya no funciona (y está bien así)

# En pandas 3.0, la asignación encadenada simplemente NO modifica el DataFrame original
# No lanza error, pero tampoco hace lo que podrías esperar

df_test = df_raw.copy()

# Esto NO modifica df_test en pandas 3.0 (y no genera warning tampoco)
df_test[df_test['country'] == 'España']['quantity'] = 0

print("Cantidad en España (sin cambios):")
print(df_test[df_test['country'] == 'España']['quantity'].tolist())

# La forma CORRECTA en pandas 3.0:
df_test.loc[df_test['country'] == 'España', 'quantity'] = 0
print("\nCantidad en España (modificada correctamente con .loc):")
print(df_test[df_test['country'] == 'España']['quantity'].tolist())

Beneficios de Copy-on-Write

  • Adiós al SettingWithCopyWarning: ese aviso tan molesto desaparece porque la semántica ahora es inequívoca.
  • Menor uso de memoria: las copias solo se hacen cuando son necesarias (evaluación lazy).
  • Código más predecible: siempre sabes que modificar un subset no afecta al original.
  • No más .copy() defensivo: ya no necesitas escribir df_subset = df[mask].copy() por precaución. Eso sí que era tedioso.

La Nueva API de Expresiones pd.col() en pandas 3.0

pandas 3.0 introduce pd.col(), una API de expresiones inspirada en Polars que permite construir transformaciones de columnas de forma más declarativa. Si has usado Polars, esto te va a resultar muy familiar. Si no, vas a entender enseguida por qué la gente lo pedía tanto.

# pd.col() permite referencias a columnas de forma expresiva
# Calcular el total de cada orden de forma declarativa
df_procesado = df_raw.assign(
    order_total=pd.col("quantity") * pd.col("unit_price")
)

print("Totales por orden:")
print(df_procesado[['order_id', 'product', 'quantity', 'unit_price', 'order_total']].head(8))
# Combinación de múltiples expresiones con pd.col()
df_enriquecido = df_raw.assign(
    order_total=pd.col("quantity") * pd.col("unit_price"),
    price_per_unit_rounded=pd.col("unit_price").round(1),
    is_high_value=pd.col("unit_price") > 500
)

print(df_enriquecido[['order_id', 'order_total', 'price_per_unit_rounded', 'is_high_value']].head(10))

pd.col() devuelve un objeto Expression que se evalúa de forma perezosa dentro del contexto de un DataFrame. Esto permite construir transformaciones reutilizables y más legibles que las lambda functions de toda la vida:

# Definir expresiones reutilizables
expr_total = pd.col("quantity") * pd.col("unit_price")
expr_descuento_10 = expr_total * 0.9
expr_alto_valor = pd.col("unit_price") > 500

df_analisis = (
    df_raw
    .dropna(subset=['quantity', 'unit_price'])
    .assign(
        total_bruto=expr_total,
        total_con_descuento=expr_descuento_10,
        es_premium=expr_alto_valor
    )
)

print(df_analisis[['order_id', 'total_bruto', 'total_con_descuento', 'es_premium']].head(8))

Construcción de Pipelines Reproducibles con .pipe()

Aquí es donde la cosa se pone realmente interesante. El verdadero poder de un proceso de limpieza no está en limpiar un dataset una sola vez, sino en poder reproducir esa limpieza de forma idéntica sobre cualquier dataset nuevo con la misma estructura. Para eso, pandas ofrece .pipe(), que permite encadenar funciones de transformación de una manera bastante elegante.

Definiendo funciones de transformación individuales

def limpiar_nombres(df):
    """Normaliza los nombres de clientes: strip + title case."""
    df = df.assign(
        customer_name=df['customer_name'].str.strip().str.title()
    )
    return df

def normalizar_textos(df):
    """Convierte columnas categóricas a minúsculas."""
    cols_texto = ['category', 'status', 'country']
    for col in cols_texto:
        if col in df.columns:
            df = df.assign(**{col: df[col].str.strip().str.lower()})
    return df

def convertir_fechas(df, columna='order_date'):
    """Convierte la columna de fecha con manejo de errores."""
    df = df.assign(**{
        columna: pd.to_datetime(df[columna], errors='coerce')
    })
    return df

def eliminar_duplicados(df, subset=None):
    """Elimina duplicados manteniendo la primera ocurrencia."""
    return df.drop_duplicates(subset=subset, keep='first')

def filtrar_precios_invalidos(df):
    """Reemplaza precios negativos o cero con NaN."""
    df = df.assign(
        unit_price=df['unit_price'].where(df['unit_price'] > 0, other=np.nan)
    )
    return df

def filtrar_cantidades_invalidas(df, cantidad_maxima=500):
    """Reemplaza cantidades fuera de rango con NaN."""
    df = df.assign(
        quantity=df['quantity'].where(
            df['quantity'].between(1, cantidad_maxima),
            other=np.nan
        )
    )
    return df

def imputar_valores_faltantes(df):
    """Imputa valores faltantes con estrategias predefinidas."""
    df = df.assign(
        customer_name=df['customer_name'].fillna('Cliente Anónimo'),
        email=df['email'].fillna('[email protected]'),
        product=df['product'].fillna('Producto Desconocido'),
        unit_price=df['unit_price'].fillna(df['unit_price'].median()),
        quantity=df['quantity'].fillna(1)
    )
    return df

def agregar_columnas_derivadas(df):
    """Añade columnas calculadas a partir de los datos limpios."""
    df = df.assign(
        order_total=df['quantity'] * df['unit_price'],
        year=df['order_date'].dt.year,
        month=df['order_date'].dt.month,
        is_high_value=df['unit_price'] > 500,
        email_domain=df['email'].str.extract(r'@(.+)$')
    )
    return df

def validar_dataset(df):
    """Realiza validaciones básicas y lanza advertencias si algo falla."""
    assert df['order_id'].is_unique, "ERROR: order_id tiene duplicados"
    assert (df['unit_price'] >= 0).all(), "ERROR: hay precios negativos"
    assert df['customer_name'].notna().all(), "ERROR: hay nombres nulos"

    nulos_criticos = df[['order_id', 'customer_name', 'category']].isna().sum()
    if nulos_criticos.any():
        print(f"ADVERTENCIA - Nulos en columnas críticas:\n{nulos_criticos[nulos_criticos > 0]}")

    print(f"Validación completada. Dataset final: {df.shape[0]} filas x {df.shape[1]} columnas")
    return df

Ensamblar el pipeline completo con .pipe()

df_limpio = (
    df_raw
    .pipe(limpiar_nombres)
    .pipe(normalizar_textos)
    .pipe(convertir_fechas, columna='order_date')
    .pipe(eliminar_duplicados, subset=['email', 'product', 'order_date'])
    .pipe(filtrar_precios_invalidos)
    .pipe(filtrar_cantidades_invalidas, cantidad_maxima=100)
    .pipe(imputar_valores_faltantes)
    .pipe(agregar_columnas_derivadas)
    .pipe(validar_dataset)
)

print("\nDataset limpio y enriquecido:")
print(df_limpio.head(10))
print(f"\nColumnas disponibles: {df_limpio.columns.tolist()}")

Mira qué limpio queda eso. Cada paso del pipeline se lee como una instrucción en lenguaje natural. Y las ventajas en un entorno de producción son enormes:

  • Reproducibilidad total: el mismo código aplicado a cualquier dataset nuevo produce el mismo resultado.
  • Facilidad de prueba: cada función se puede testear unitariamente de forma independiente.
  • Legibilidad: el pipeline cuenta la historia del procesamiento de arriba a abajo, sin variables intermedias que dificulten el seguimiento.
  • Mantenimiento sencillo: si necesitas ajustar una transformación, cambias solo esa función sin tocar el resto.
  • Depuración fácil: puedes comentar pasos del pipeline para aislar problemas.

Añadir logging al pipeline

def log_step(df, mensaje=""):
    """
    Función de logging para usar dentro del pipeline.
    Imprime el estado actual del DataFrame sin modificarlo.
    """
    print(f"[PIPELINE] {mensaje}: {df.shape[0]} filas, {df.shape[1]} columnas | "
          f"Nulos totales: {df.isna().sum().sum()}")
    return df

df_limpio_con_log = (
    df_raw
    .pipe(log_step, "Inicio")
    .pipe(limpiar_nombres)
    .pipe(log_step, "Tras limpiar nombres")
    .pipe(normalizar_textos)
    .pipe(convertir_fechas, columna='order_date')
    .pipe(log_step, "Tras convertir fechas")
    .pipe(eliminar_duplicados, subset=['email', 'product', 'order_date'])
    .pipe(log_step, "Tras eliminar duplicados")
    .pipe(filtrar_precios_invalidos)
    .pipe(filtrar_cantidades_invalidas, cantidad_maxima=100)
    .pipe(imputar_valores_faltantes)
    .pipe(log_step, "Tras imputar valores")
    .pipe(agregar_columnas_derivadas)
    .pipe(log_step, "Pipeline completado")
    .pipe(validar_dataset)
)

Validación Final del Dataset

Una vez completado el proceso de limpieza, toca validar de forma sistemática antes de usar el dataset para análisis o modelado. Las validaciones no son opcionales — son el contrato que garantiza que los datos cumplen las expectativas del negocio. Saltarse este paso es como entregar un proyecto sin tests: puede que funcione, pero no tienes cómo demostrarlo.

Validaciones estructurales

def informe_calidad_datos(df, nombre_dataset="Dataset"):
    """Genera un informe completo de calidad del dataset."""
    print(f"\n{'='*60}")
    print(f"INFORME DE CALIDAD: {nombre_dataset}")
    print(f"{'='*60}")

    # 1. Dimensiones
    print(f"\n1. DIMENSIONES")
    print(f"   Filas:    {df.shape[0]:,}")
    print(f"   Columnas: {df.shape[1]}")

    # 2. Tipos de datos
    print(f"\n2. TIPOS DE DATOS")
    for col, dtype in df.dtypes.items():
        print(f"   {col:<25} {str(dtype)}")

    # 3. Valores faltantes
    print(f"\n3. VALORES FALTANTES")
    nulos = df.isna().sum()
    nulos_pct = (nulos / len(df)) * 100
    for col in df.columns:
        if nulos[col] > 0:
            print(f"   {col:<25} {nulos[col]:>4} ({nulos_pct[col]:.1f}%)")
    if nulos.sum() == 0:
        print("   Sin valores faltantes.")

    # 4. Duplicados
    n_dup = df.duplicated().sum()
    print(f"\n4. DUPLICADOS")
    print(f"   Filas duplicadas: {n_dup}")

    # 5. Estadísticas de columnas numéricas
    print(f"\n5. ESTADÍSTICAS NUMÉRICAS")
    print(df.describe().round(2))

    # 6. Distribución de categóricas
    print(f"\n6. DISTRIBUCIÓN DE CATEGÓRICAS")
    cols_cat = df.select_dtypes(include=['object', 'category']).columns
    for col in cols_cat:
        print(f"\n   {col}:")
        print(df[col].value_counts().to_string(indent=6))

    print(f"\n{'='*60}\n")
    return df

df_limpio = df_limpio.pipe(informe_calidad_datos, "Ventas E-commerce (Limpio)")

Validaciones de negocio con assertions

def validaciones_de_negocio(df):
    """
    Valida reglas de negocio específicas del dominio.
    Lanza AssertionError si alguna regla se viola.
    """
    errores = []

    # Regla 1: order_id debe ser único
    if not df['order_id'].is_unique:
        errores.append("order_id tiene valores duplicados")

    # Regla 2: Los precios deben ser positivos
    if not (df['unit_price'] > 0).all():
        n = (df['unit_price'] <= 0).sum()
        errores.append(f"Hay {n} filas con unit_price <= 0")

    # Regla 3: Las cantidades deben ser enteras positivas
    if not (df['quantity'] > 0).all():
        n = (df['quantity'] <= 0).sum()
        errores.append(f"Hay {n} filas con quantity <= 0")

    # Regla 4: El total calculado debe ser positivo
    if 'order_total' in df.columns:
        if not (df['order_total'] > 0).all():
            errores.append("Hay order_totals negativos o cero")

    # Regla 5: El status debe ser uno de los valores conocidos
    estados_validos = {'completed', 'pending', 'shipped', 'cancelled'}
    estados_desconocidos = set(df['status'].dropna().unique()) - estados_validos
    if estados_desconocidos:
        errores.append(f"Estados desconocidos: {estados_desconocidos}")

    # Regla 6: Las fechas deben estar en rango razonable
    if 'order_date' in df.columns:
        fecha_min = pd.Timestamp('2020-01-01')
        fecha_max = pd.Timestamp.now()
        fuera_rango = df['order_date'].dropna()
        fuera_rango = fuera_rango[(fuera_rango < fecha_min) | (fuera_rango > fecha_max)]
        if len(fuera_rango) > 0:
            errores.append(f"Hay {len(fuera_rango)} fechas fuera del rango esperado")

    if errores:
        print("FALLOS DE VALIDACIÓN DE NEGOCIO:")
        for e in errores:
            print(f"  - {e}")
        raise AssertionError(f"Dataset no supera las validaciones de negocio ({len(errores)} errores)")
    else:
        print("Todas las validaciones de negocio pasaron correctamente.")

    return df

df_limpio = df_limpio.pipe(validaciones_de_negocio)

Exportar el dataset limpio

# Guardar en Parquet (formato recomendado para pipelines de datos modernos)
df_limpio.to_parquet('ventas_ecommerce_limpio.parquet', index=False)

# Verificar que se puede leer correctamente
df_verificacion = pd.read_parquet('ventas_ecommerce_limpio.parquet')
print(f"Dataset exportado y verificado: {df_verificacion.shape}")
print(df_verificacion.dtypes)

# También puedes exportar a CSV si necesitas compatibilidad amplia
df_limpio.to_csv('ventas_ecommerce_limpio.csv', index=False, encoding='utf-8-sig')
print("\nDataset exportado a CSV con codificación UTF-8 BOM (compatible con Excel)")

Sobre los formatos de exportación: siempre que puedas, prefiere Parquet sobre CSV para almacenar datos limpios. Parquet preserva los tipos de datos, es bastante más rápido de leer, ocupa menos espacio en disco y es compatible con prácticamente todo el ecosistema de datos moderno (Spark, DuckDB, BigQuery, Arrow, etc.). CSV está bien para compartir datos con gente que usa Excel, pero para pipelines de datos, Parquet es la elección correcta.

Resumen de Mejores Prácticas

A lo largo de esta guía hemos construido un proceso de limpieza completo, desde la exploración hasta la validación final. Antes de cerrar, vamos a consolidar las mejores prácticas que deberían guiar cualquier proyecto de preprocesamiento.

Principios fundamentales

  1. Nunca modifiques los datos crudos. El archivo original debe permanecer intacto. Trabaja siempre sobre copias y guarda el resultado en un archivo separado. Esta es la regla de oro y, créeme, la vas a agradecer cuando algo salga mal (y algo siempre sale mal).
  2. Documenta todas las decisiones de limpieza. ¿Por qué eliminaste esa fila? ¿Por qué imputaste con la mediana y no con la media? Estas decisiones tienen impacto real en el análisis posterior y deben quedar registradas.
  3. Construye pipelines, no scripts lineales. El método .pipe() no es solo un estilo: es una filosofía que hace tu código reproducible, testeable y mantenible.
  4. Valida antes y después de limpiar. Conocer el estado inicial del dataset es tan importante como verificar el final. Las validaciones no son opcionales.
  5. Adopta los nuevos tipos de pandas 3.0. Usa pd.ArrowDtype(pa.string()) para columnas de texto, aprovecha Copy-on-Write y experimenta con pd.col() para expresiones declarativas.

Tabla de referencia rápida: problemas comunes y soluciones

Problema Detección Solución en pandas 3.0
Valores nulos df.isna().sum() fillna(), dropna(), SimpleImputer
Duplicados df.duplicated() drop_duplicates(subset=...)
Tipos incorrectos df.dtypes astype(), pd.to_datetime(..., errors='coerce')
Outliers IQR, Z-Score clip(), where(), eliminar
Texto sucio Exploración visual .str.strip(), .str.lower(), regex
Fechas inconsistentes pd.to_datetime(..., errors='coerce') Parsear con errors='coerce', tratar NaT
Rendimiento en texto Profiling Migrar a pd.ArrowDtype(pa.string())
Código no reproducible Revisión de código Refactorizar con .pipe() y funciones puras

Antipatrones que debes evitar en pandas 3.0

  • Asignación encadenada: df[mask]['columna'] = valor ya no funciona con CoW. Usa siempre df.loc[mask, 'columna'] = valor.
  • Bucles for sobre filas: iterrows() es entre 10 y 1000 veces más lento que las operaciones vectorizadas. Siempre busca la alternativa vectorizada.
  • Modificar el DataFrame dentro de funciones sin retornar: con CoW, las modificaciones dentro de funciones afectan a la copia local, no al original. Tus funciones deben retornar siempre el DataFrame modificado.
  • Asumir que object es el mejor dtype para texto: migra progresivamente a pd.ArrowDtype(pa.string()) para mejoras gratuitas de rendimiento y memoria.
  • Mezclar lógica de negocio con transformaciones técnicas: en un pipeline, mantén separadas las transformaciones técnicas (limpiar espacios) de las reglas de negocio (un precio máximo de 5000). Son responsabilidades distintas y tratarlas así simplifica mucho el mantenimiento.

Conclusión

La limpieza y el preprocesamiento de datos son mucho más que tareas tediosas previas al "trabajo de verdad". Son el fundamento sobre el que se construye la confianza en cualquier análisis. Un dataset limpio y bien tipado hace que todo lo que viene después — visualizaciones, modelos estadísticos, machine learning, reportes — sea más fiable, más rápido y mucho más fácil de mantener.

pandas 3.0 representa un salto importante. Copy-on-Write elimina una de las fuentes de bugs más históricas y frustrantes de la biblioteca. El soporte mejorado de PyArrow como backend abre la puerta a rendimientos que antes eran impensables con strings y tipos de datos modernos. Y la nueva API de expresiones con pd.col() acerca pandas al modelo declarativo que ha hecho tan popular a Polars.

La metodología que hemos visto aquí — explorar, diagnosticar, transformar con funciones puras, encadenar con .pipe() y validar — es directamente aplicable a proyectos reales de cualquier escala. Los patrones del dataset de e-commerce son los mismos que vas a encontrar una y otra vez: espacios en strings, fechas con formatos mezclados, valores numéricos fuera de rango, duplicados semánticos y campos faltantes en las columnas que más necesitas.

Dominar estas técnicas no solo te hará más productivo como científico de datos. Te hará más riguroso. Y en un campo donde la calidad de la conclusión depende directamente de la calidad del dato de entrada, el rigor no es un lujo. Es el trabajo.

Como próximo paso, te recomiendo explorar la integración de estos pipelines con Great Expectations para validación automática de datos en producción, y con Prefect o Apache Airflow para orquestación de pipelines completos. El proceso de limpieza que hemos construido es el núcleo de un sistema más amplio que puede ejecutarse de forma automática, monitoreada y con alertas configuradas.

Sobre el Autor Editorial Team

Our team of expert writers and editors.