Clase 10: Temporal Feature Engineering con Pandas¶
Ingeniería de Datos - Universidad Católica del Uruguay
Objetivo: Implementar temporal feature engineering con datos transaccionales de e-commerce usando pandas, incluyendo lag features, rolling/expanding windows, user aggregations, features cíclicas, external variables y time-based validation robusta sin incurrir en data leakage.
Tiempo estimado: 120-150 minutos
🎯 Técnicas de Temporal Feature Engineering¶
| Operación | Pandas | Previene Leakage | Descripción |
|---|---|---|---|
| Lag features | .shift(n) |
✅ | Valores de n eventos anteriores |
| Rolling window | .rolling(n).mean() |
✅ | Agregación sobre últimos n eventos |
| Expanding window | .expanding().sum() |
✅ | Agregación desde inicio hasta ahora |
| Time windows | .rolling('7D').count() |
✅ | Ventanas por tiempo (ej: 7 días) |
| Cumulative | .cumsum(), .cumcount() |
✅ | Acumulados temporales |
Clave del éxito: Usar .groupby('user_id') para operaciones por usuario + ordenar temporalmente ANTES de calcular features.
Setup y Carga de Datos¶
Instalación de Librerías¶
# Instalar librerías necesarias
!pip install -q pandas numpy scikit-learn matplotlib seaborn kaggle
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import timedelta, datetime
from sklearn.model_selection import TimeSeriesSplit
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score, classification_report
import warnings
import platform
warnings.filterwarnings('ignore')
# Configurar pandas y visualización
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 100)
plt.style.use('seaborn-v0_8-darkgrid')
print("✅ Librerías importadas correctamente")
print(f"Pandas version: {pd.__version__}")
print(f"Sistema operativo: {platform.system()}")
print(f"Python version: {platform.python_version()}")
print("\n" + "="*70)
print("🚀 TEMPORAL FEATURE ENGINEERING CON PANDAS")
print("="*70)
print("✅ Pandas con .groupby() + .shift() previene data leakage")
print("✅ Sintaxis clara y estudiantes ya conocen pandas")
print("✅ Compatible con cualquier entorno Python")
print("="*70 + "\n")
Parte 1: Cargar y Explorar Dataset¶
1.1 Cargar Online Retail Dataset desde Kaggle¶
ÚNICO REQUERIMIENTO: Usar datos reales del Online Retail dataset desde Kaggle usando la API con autenticación via formulario.
🎯 Dataset: https://www.kaggle.com/datasets/vijayuv/onlineretail
- Tamaño: ~540K transacciones de e-commerce UK (2010-2011)
- Columna temporal:
InvoiceDate - ID Cliente:
CustomerID - Estructura: 1 archivo CSV (online_retail.csv)
- Ideal para: Análisis temporal con clientes recurrentes (promedio 10+ compras/cliente)
⚠️ PASO 1: Obtener tu API Key de Kaggle¶
- Ve a https://www.kaggle.com/settings/account
- Haz clic en "Create New API Token"
- Se descargará automáticamente un archivo
kaggle.jsoncon tus credenciales - IMPORTANTE: Este archivo contiene tu username y API key. ¡Mantenlo privado!
📁 PASO 2: Subir kaggle.json a Colab¶
Ejecuta el siguiente código para subir el archivo en Google Colab:
from google.colab import files
import os
import json
print("=" * 70)
print("📁 CARGANDO CREDENCIALES DE KAGGLE")
print("=" * 70)
print("\n⚠️ Selecciona tu archivo 'kaggle.json' del PASO 1")
print(" (Haz clic en 'Seleccionar archivos' abajo)\n")
# Subir kaggle.json
uploaded = files.upload()
# Verificar que se subió el archivo correcto
if 'kaggle.json' not in uploaded:
raise FileNotFoundError("❌ Error: Debes subir el archivo 'kaggle.json'. Intenta nuevamente.")
print("✅ Archivo kaggle.json subido correctamente\n")
# Crear directorio .kaggle si no existe
kaggle_dir = os.path.expanduser('~/.kaggle')
os.makedirs(kaggle_dir, exist_ok=True)
# Mover kaggle.json al directorio correcto
kaggle_json_path = os.path.join(kaggle_dir, 'kaggle.json')
with open(kaggle_json_path, 'w') as f:
f.write(uploaded['kaggle.json'].decode('utf-8'))
# Establecer permisos correctos (600 = solo usuario puede leer/escribir)
os.chmod(kaggle_json_path, 0o600)
print(f"✅ Credenciales guardadas en: {kaggle_json_path}")
# Verificar credenciales cargadas
with open(kaggle_json_path, 'r') as f:
creds = json.load(f)
username = creds.get('username', 'desconocido')
print(f"✅ Usuario Kaggle: {username}")
print("\n" + "=" * 70)
PISTAS:
files.upload()es la función de Colab para subir archivos Documentación: https://colab.research.google.com/notebooks/io.ipynb- Los permisos 600 en octal se escriben como
0o600 - Seguridad: Este archivo no debe compartirse. Úsalo solo en Colab personal.
🔽 PASO 3: Descargar Dataset de E-Commerce desde Kaggle¶
⚠️ IMPORTANTE ANTES DE EJECUTAR:
- Debes aceptar las reglas en https://www.kaggle.com/datasets/vijayuv/onlineretail
- Haz clic en "Join" o "Accept" en la página del dataset
- Espera 2-3 minutos (Kaggle propaga permisos lentamente)
- Luego ejecuta el código
print("\n=== DESCARGANDO ONLINE RETAIL DATASET DESDE KAGGLE ===\n")
# Instalar kaggle si no está instalado
from kaggle.api.kaggle_api_extended import KaggleApi
# Autenticar con API
api = KaggleApi()
api.authenticate()
print("✅ Autenticación exitosa con Kaggle API\n")
# Dataset reference para Online Retail
dataset_ref = "vijayuv/onlineretail"
download_path = "./data"
# Crear directorio si no existe
os.makedirs(download_path, exist_ok=True)
# Descargar archivos
print(f"📥 Descargando dataset: {dataset_ref}")
print(f"📂 Destino: {download_path}")
print("⏳ Esto puede tomar 1-2 minutos (~20MB)...\n")
api.dataset_download_files(
dataset_ref,
path=download_path,
unzip=True # Descomprime automáticamente
)
print("✅ Descarga completada\n")
# Listar archivos descargados
print(f"📋 Archivos disponibles en {download_path}:")
for file in sorted(os.listdir(download_path)):
file_path = os.path.join(download_path, file)
if os.path.isfile(file_path):
size_mb = os.path.getsize(file_path) / (1024 * 1024)
print(f" ✅ {file:40s} ({size_mb:7.2f} MB)")
PISTAS:
dataset_ref="vijayuv/onlineretail"(referencia del dataset en Kaggle)download_path="./data"o"/tmp/data"(donde descargar)unzip=Truedescomprime automáticamente archivos ZIP- Documentación Kaggle API: https://www.kaggle.com/docs/api
📊 PASO 4: Cargar Datos en Pandas¶
print("\n=== CARGANDO ONLINE RETAIL EN PANDAS ===\n")
# Cargar el dataset principal
df_raw = pd.read_csv(f'{download_path}/OnlineRetail.csv', encoding='ISO-8859-1')
print("✅ Dataset cargado exitosamente\n")
print(f"📊 Shape inicial: {df_raw.shape}")
print("\n" + "=" * 70)
print("PREVIEW: Online Retail Dataset")
print("=" * 70)
print(df_raw.info())
print("\n", df_raw.head(10))
print("\n" + "=" * 70)
print("COLUMNAS DEL DATASET")
print("=" * 70)
print("\n".join([f" - {col}: {df_raw[col].dtype}" for col in df_raw.columns]))
print("\n" + "=" * 70)
PISTAS:
-
El archivo se llama
OnlineRetail.csv -
Usa
encoding='ISO-8859-1'para evitar errores de caracteres especiales -
.info()muestra la estructura del DataFrame -
.head(10)muestra las primeras 10 filas -
Columnas clave del Online Retail dataset:
InvoiceNo: ID de la factura/ordenStockCode: Código del productoDescription: Descripción del productoQuantity: Cantidad compradaInvoiceDate: Fecha y hora de la transacciónUnitPrice: Precio unitarioCustomerID: ID del cliente (para análisis temporal por usuario)Country: País del cliente
1.2 Preparar y Explorar Estructura Temporal¶
Ahora que tenemos los datos reales de Kaggle, los prepararemos y exploraremos su estructura temporal:
print("\n=== PREPARANDO DATOS PARA ANÁLISIS TEMPORAL ===\n")
# 1. Limpiar datos
print("📋 Paso 1: Limpieza de datos")
# Eliminar filas con CustomerID nulo (no podemos hacer análisis temporal sin ID)
df = df_raw.dropna(subset=['CustomerID'])
# Eliminar transacciones canceladas (InvoiceNo que empieza con 'C')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')]
# Eliminar cantidades negativas o cero
df = df[df['Quantity'] > 0]
# Eliminar precios negativos o cero
df = df[df['UnitPrice'] > 0]
print(f" ✅ Filas después de limpieza: {len(df):,} (de {len(df_raw):,})")
# 2. Crear columnas derivadas
print("\n📋 Paso 2: Creando columnas derivadas")
# Renombrar columnas para consistencia
df = df.rename(columns={
'CustomerID': 'user_id',
'InvoiceDate': 'order_date',
'InvoiceNo': 'order_id',
'StockCode': 'product_id',
'UnitPrice': 'price'
})
# Convertir order_date a datetime
df['order_date'] = pd.to_datetime(df['order_date'])
# Calcular total_amount (cantidad × precio)
df['total_amount'] = df['Quantity'] * df['price']
# Ordenar por temporal (CRÍTICO para operaciones temporales)
df = df.sort_values(['user_id', 'order_date']).reset_index(drop=True)
print(" ✅ Columnas renombradas y total_amount calculado")
# 3. Exploración temporal
print("\n" + "=" * 70)
print("EXPLORACIÓN TEMPORAL")
print("=" * 70)
print(f"\n📊 Shape del dataset: {df.shape}")
print(f"📅 Rango de fechas: {df['order_date'].min().date()} a {df['order_date'].max().date()}")
print(f"⏱️ Rango de días: {(df['order_date'].max() - df['order_date'].min()).days} días")
print(f"\n👥 Unique usuarios: {df['user_id'].nunique():,}")
print(f"📦 Unique productos: {df['product_id'].nunique():,}")
print(f"🛒 Total órdenes (facturas): {df['order_id'].nunique():,}")
print(f"📝 Total items/líneas: {len(df):,}")
print(f"\n💰 Promedio items por orden: {df.groupby('order_id').size().mean():.2f}")
print(f"🔁 Promedio órdenes por usuario: {df.groupby('user_id')['order_id'].nunique().mean():.2f}")
print(f"💵 Promedio precio por item: ${df['price'].mean():.2f}")
print(f"💸 Total ventas: ${df['total_amount'].sum():,.2f}")
# Identificar tipo de datos temporales
print("\n" + "=" * 70)
print("TIPO DE DATOS TEMPORALES")
print("=" * 70)
print("✅ Dataset de TRANSACCIONES (eventos irregulares con timestamps)")
print(" - No hay intervalos fijos entre eventos")
print(" - Cada usuario tiene su propio timeline de compras")
print(" - Alta frecuencia de compras repetidas (ideal para temporal features)")
print(" - Perfecto para temporal feature engineering con Pandas")
# 4. Análisis de usuarios con múltiples órdenes
multi_order_users = df.groupby('user_id')['order_id'].nunique()
users_with_multiple = (multi_order_users > 1).sum()
print(f"\n🎯 Usuarios con múltiples órdenes: {users_with_multiple:,} ({users_with_multiple/len(multi_order_users)*100:.1f}%)")
print(f"📈 Promedio órdenes (usuarios recurrentes): {multi_order_users[multi_order_users > 1].mean():.2f}")
# 5. Visualizar distribución temporal
print("\n📊 Generando visualizaciones temporales...")
fig, axes = plt.subplots(1, 2, figsize=(16, 5))
# Órdenes por semana
weekly_orders = df.groupby(df['order_date'].dt.to_period('W'))['order_id'].nunique()
axes[0].plot(range(len(weekly_orders)), weekly_orders.values, marker='o', linewidth=2, markersize=4)
axes[0].set_title('Órdenes Únicas por Semana', fontsize=14, fontweight='bold')
axes[0].set_xlabel('Semana')
axes[0].set_ylabel('Número de Órdenes')
axes[0].grid(alpha=0.3)
# Distribución de tiempo entre órdenes por usuario
time_between_list = []
for user_id, group in df.groupby('user_id')['order_date']:
if len(group) > 1:
diffs = group.diff().dt.days.dropna()
time_between_list.extend([val for val in diffs if val > 0])
if len(time_between_list) > 0:
axes[1].hist(time_between_list, bins=50, edgecolor='black', alpha=0.7, color='steelblue')
axes[1].set_title(f'Distribución de Días entre Órdenes\n({len(time_between_list):,} transiciones)',
fontsize=14, fontweight='bold')
axes[1].set_xlabel('Días entre órdenes')
axes[1].set_ylabel('Frecuencia')
axes[1].axvline(np.median(time_between_list), color='red', linestyle='--',
label=f'Mediana: {np.median(time_between_list):.0f} días')
axes[1].legend()
else:
axes[1].text(0.5, 0.5, 'No hay suficientes usuarios\ncon múltiples órdenes',
ha='center', va='center', fontsize=12, transform=axes[1].transAxes)
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()
# 6. Primeras filas del dataset preparado
print("\n" + "=" * 70)
print("PRIMERAS FILAS DEL DATASET PREPARADO")
print("=" * 70)
print(df[['user_id', 'order_id', 'order_date', 'product_id',
'Quantity', 'price', 'total_amount', 'Country']].head(20))
print("\n✅ Dataset limpio y listo para análisis temporal con Pandas\n")
PISTAS:
-
.dropna(subset=['col'])elimina filas donde la columna es nula -
Filtra transacciones canceladas con
~df['col'].str.startswith('C') -
Asegúrate de que
order_datesea tipo datetime:pd.to_datetime() -
Calcula
total_amount = Quantity × price -
CRÍTICO: Ordena con
.sort_values(['user_id', 'order_date'])antes de features temporales -
.reset_index(drop=True)asegura índices consecutivos -
.groupby('user_id')['order_id'].nunique()cuenta órdenes únicas por usuario
Parte 1.3: Crear Features Derivadas a Nivel de Orden¶
Debemos agregar los datos a nivel de orden (una fila por orden/factura) para poder calcular features temporales:
print("\n=== CREANDO FEATURES DERIVADAS ===\n")
# 1. Extraer features temporales de order_date (a nivel de transacción)
df['order_dow'] = df['order_date'].dt.dayofweek # Día de semana (0=Lunes, 6=Domingo)
df['order_hour_of_day'] = df['order_date'].dt.hour # Hora del día (0-23)
print("✅ Features temporales extraídas:")
print(" - order_dow: Día de semana (0=Lunes, 6=Domingo)")
print(" - order_hour_of_day: Hora del día (0-23)")
# Ver primeras transacciones con features temporales
print("\n📋 Primeras transacciones con features temporales:")
print(df[['user_id', 'order_id', 'order_date', 'order_dow',
'order_hour_of_day', 'Quantity', 'total_amount']].head(10))
# 2. Agregar a nivel de ORDEN (una fila por factura/orden)
print("\n" + "=" * 70)
print("AGREGANDO A NIVEL DE ORDEN")
print("=" * 70)
print(f"\n📊 Filas antes de agregar (nivel transacción): {len(df):,}")
# ⚠️ IMPORTANTE: Convertir user_id a int para consistencia
df['user_id'] = df['user_id'].astype(int)
# Agregar transacciones por orden para obtener una fila por factura
orders_df = df.groupby(['order_id', 'user_id', 'order_date',
'order_dow', 'order_hour_of_day']).agg({
'product_id': 'count', # Número de productos en la orden
'total_amount': 'sum' # Total gastado en la orden
}).reset_index()
# Renombrar columnas agregadas
orders_df.columns = ['order_id', 'user_id', 'order_date',
'order_dow', 'order_hour_of_day',
'cart_size', 'order_total']
# CRÍTICO: Ordenar por user_id y order_date (necesario para features temporales)
orders_df = orders_df.sort_values(['user_id', 'order_date']).reset_index(drop=True)
# 3. Calcular features temporales a nivel de usuario
print("\n📋 Calculando features temporales por usuario...")
# order_number: número secuencial de orden para cada usuario (1, 2, 3, ...)
orders_df['order_number'] = orders_df.groupby('user_id').cumcount() + 1
# days_since_prior_order: días transcurridos desde la última orden del usuario
orders_df['days_since_prior_order'] = orders_df.groupby('user_id')['order_date'].diff().dt.days
print(f"\n✅ Filas después de agregar (nivel orden): {len(orders_df):,}")
# 4. Validación
if len(orders_df) == 0:
raise ValueError("❌ ERROR CRÍTICO: orders_df está vacío!")
# 5. Estadísticas del dataset agregado
print("\n" + "=" * 70)
print("ORDERS DATASET (una fila por orden/factura)")
print("=" * 70)
print(f"\n📊 Shape: {orders_df.shape}")
print(f"👥 Usuarios únicos: {orders_df['user_id'].nunique():,}")
print(f"🛒 Órdenes totales: {len(orders_df):,}")
print("\n💰 Estadísticas de órdenes:")
print(f" - Cart size promedio: {orders_df['cart_size'].mean():.2f} items")
print(f" - Total promedio por orden: ${orders_df['order_total'].mean():.2f}")
print(f" - Días promedio entre órdenes: {orders_df['days_since_prior_order'].mean():.1f} días")
print("\n📋 Primeras 10 órdenes:")
print(orders_df[['user_id', 'order_id', 'order_date', 'order_number',
'cart_size', 'order_total', 'days_since_prior_order']].head(10))
print("\n" + "=" * 70)
print(f"✅ Dataset preparado: {len(orders_df):,} órdenes de {orders_df['user_id'].nunique():,} usuarios")
print(f"✅ Período: {(orders_df['order_date'].max() - orders_df['order_date'].min()).days} días")
print("=" * 70)
PISTAS:
-
Para agregar
cart_sizeusa'count'sobre'product_id'(cuenta productos por orden) Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.agg.html -
Para agregar
order_totalusa'sum'sobre'total_amount'(suma total gastado en la orden) -
.cumcount()crea un contador secuencial (0, 1, 2, ...) por grupo, sumamos 1 para empezar en 1 -
.diff()calcula la diferencia entre filas consecutivas dentro de cada grupo -
CRÍTICO: Ordenar con
.sort_values(['user_id', 'order_date'])es esencial para que las operaciones temporales funcionen correctamente
Parte 2: Lag Features con Pandas¶
2.1 Crear Lag Features con .shift()¶
Concepto clave: Los lags capturan el "valor previo" de una variable. Con .shift() y .groupby() prevenimos data leakage automáticamente.
print("\n=== CREANDO LAG FEATURES CON PANDAS ===\n")
# CRÍTICO: Asegurar que los datos estén ordenados por user_id y order_date
orders_df = orders_df.sort_values(['user_id', 'order_date']).reset_index(drop=True)
# ⚠️ COMPLETA: Crear lags de days_since_prior_order (últimas 1, 2, 3 órdenes)
# .shift(n) toma el valor de la fila anterior DENTRO de cada grupo
orders_df['days_since_prior_lag_1'] = orders_df.groupby('user_id')['days_since_prior_order'].shift(______)
orders_df['days_since_prior_lag_2'] = orders_df.groupby('user_id')['days_since_prior_order'].shift(______)
orders_df['days_since_prior_lag_3'] = orders_df.groupby('user_id')['days_since_prior_order'].shift(______)
print("✅ Lag Features creadas con Pandas")
# IMPORTANTE: Seleccionar un usuario con MÚLTIPLES órdenes para visualizaciones
print("\n🔍 Seleccionando usuario con múltiples órdenes para ejemplos...")
user_order_counts = orders_df.groupby('user_id').size().sort_values(ascending=False)
users_with_many_orders = user_order_counts[user_order_counts >= 8].index.tolist()
if len(users_with_many_orders) > 0:
sample_user_id = users_with_many_orders[0] # Usuario con más órdenes
else:
sample_user_id = user_order_counts.index[0] # Mejor disponible
print(f"✅ Usuario seleccionado: {sample_user_id} ({user_order_counts[sample_user_id]} órdenes)\n")
# Mostrar ejemplo
print(f"Ejemplo de Lag Features para usuario {sample_user_id}:")
sample = orders_df[orders_df['user_id'] == sample_user_id][
['user_id', 'order_number', 'days_since_prior_order',
'days_since_prior_lag_1', 'days_since_prior_lag_2', 'days_since_prior_lag_3']
].head(12)
print(sample)
print(f"\n✅ NaNs en lag_1: {orders_df['days_since_prior_lag_1'].isna().sum():,}")
print(f"✅ NaNs en lag_2: {orders_df['days_since_prior_lag_2'].isna().sum():,}")
print(f"✅ NaNs en lag_3: {orders_df['days_since_prior_lag_3'].isna().sum():,}")
print("\n💡 Los NaN son normales: aparecen en las primeras órdenes donde no hay historia previa")
print("💡 .groupby() + .shift() previene data leakage: cada usuario tiene sus propios lags independientes")
PISTAS:
-
.shift(n)mueve los valores n posiciones hacia abajo (obtiene valores pasados) Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.shift.html -
.groupby('user_id')asegura que no se mezclen datos entre usuarios -
Los NaN aparecen en las primeras n órdenes de cada usuario (esperado y correcto)
-
Concepto clave:
.shift(1)previene data leakage automáticamente al tomar valores previos
2.2 Rolling Window Features con Pandas¶
Concepto clave: Las ventanas móviles (rolling windows) capturan tendencias recientes calculando estadísticas sobre los últimos N eventos.
⚠️ CRÍTICO: Usar .shift(1) ANTES de .rolling() para excluir el evento actual (previene data leakage)!
print("\n=== CREANDO ROLLING FEATURES CON PANDAS ===\n")
# ⚠️ COMPLETA: Rolling mean de cart_size (últimas 3 órdenes, EXCLUYENDO la actual)
orders_df['rolling_cart_mean_3'] = (
orders_df.groupby('user_id')['cart_size']
.shift(______)
.rolling(window=______, min_periods=1)
.mean()
.reset_index(level=0, drop=True)
)
# ⚠️ COMPLETA: Rolling std de cart_size
orders_df['rolling_cart_std_3'] = (
orders_df.groupby('user_id')['cart_size']
.shift(______)
.rolling(window=______, min_periods=1)
.______()
.reset_index(level=0, drop=True)
)
print("✅ Rolling Features creadas con Pandas")
# Mostrar ejemplo
print(f"\nEjemplo para un usuario:")
sample = orders_df[orders_df['user_id'] == sample_user_id][
['order_number', 'cart_size', 'rolling_cart_mean_3', 'rolling_cart_std_3']
].head(10)
print(sample)
# Visualización
fig, ax = plt.subplots(figsize=(14, 6))
user_sample = orders_df[orders_df['user_id'] == sample_user_id].head(20)
# Graficar valores reales
ax.plot(user_sample['order_number'], user_sample['cart_size'],
marker='x', alpha=0.7, label='Cart Size Actual', linewidth=2.5, markersize=10)
# Graficar rolling mean (solo si hay valores no-NaN)
if user_sample['rolling_cart_mean_3'].notna().any():
ax.plot(user_sample['order_number'], user_sample['rolling_cart_mean_3'],
marker='o', label='Rolling Mean (3 órdenes previas)', linewidth=2.5,
color='coral', markersize=8)
# Fill between con std (solo si hay valores)
if user_sample['rolling_cart_std_3'].notna().any():
ax.fill_between(user_sample['order_number'],
user_sample['rolling_cart_mean_3'] - user_sample['rolling_cart_std_3'],
user_sample['rolling_cart_mean_3'] + user_sample['rolling_cart_std_3'],
alpha=0.2, label='±1 std', color='coral')
ax.set_xlabel('Order Number', fontsize=12)
ax.set_ylabel('Cart Size', fontsize=12)
ax.set_title(f'Rolling Mean vs Actual Cart Size (User {sample_user_id})',
fontweight='bold', fontsize=14)
ax.legend(fontsize=11)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
print("\n✅ Ventaja clave: .shift(1) antes de .rolling() previene data leakage automáticamente")
PISTAS:
-
.shift(1)ANTES de.rolling()es CRÍTICO para prevenir data leakage -
min_periods=1permite cálculos con menos de 3 valores (útil para primeras órdenes) Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.rolling.html -
.reset_index(level=0, drop=True)es necesario después de.rolling()en un groupby -
Para rolling std usa
.std()en lugar de.mean() -
Ventaja: Rolling window captura tendencias recientes (últimos N eventos)
2.3 Expanding Window Features con Pandas¶
Concepto: Las ventanas expandibles calculan estadísticas desde el inicio hasta "ahora" (acumulado histórico).
print("\n=== CREANDO EXPANDING FEATURES CON PANDAS ===\n")
# ⚠️ COMPLETA: Expanding mean de days_since_prior_order (promedio histórico)
orders_df['expanding_days_mean'] = (
orders_df.groupby('user_id')['days_since_prior_order']
.shift(______)
.expanding(min_periods=1)
.______()
.reset_index(level=0, drop=True)
)
# ⚠️ COMPLETA: Total orders so far (cuenta acumulada de órdenes previas)
orders_df['total_orders_so_far'] = (
orders_df.groupby('user_id').______()
)
# ⚠️ COMPLETA: Expanding total spent (gasto acumulado histórico)
orders_df['expanding_total_spent'] = (
orders_df.groupby('user_id')['order_total']
.shift(______)
.expanding(min_periods=1)
.______()
.reset_index(level=0, drop=True)
)
# Rellenar NaN con 0 (primera orden no tiene gasto previo)
orders_df['expanding_total_spent'] = orders_df['expanding_total_spent'].fillna(0)
print("✅ Expanding Features creadas")
# Mostrar ejemplo
print(f"\nEjemplo para un usuario:")
sample = orders_df[orders_df['user_id'] == sample_user_id][
['order_number', 'days_since_prior_order', 'expanding_days_mean',
'total_orders_so_far', 'expanding_total_spent']
].head(10)
print(sample)
# Visualización mejorada: Rolling vs Expanding
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
user_sample = orders_df[orders_df['user_id'] == sample_user_id].head(20)
# Rolling mean (tendencia reciente)
axes[0].plot(user_sample['order_number'], user_sample['cart_size'],
marker='x', alpha=0.6, label='Actual Cart Size', linewidth=2.5, markersize=10)
if user_sample['rolling_cart_mean_3'].notna().any():
axes[0].plot(user_sample['order_number'], user_sample['rolling_cart_mean_3'],
marker='o', label='Rolling Mean (3 órdenes)', linewidth=2.5,
color='coral', markersize=8)
axes[0].set_xlabel('Order Number', fontsize=12)
axes[0].set_ylabel('Cart Size', fontsize=12)
axes[0].set_title('Rolling Mean: Captura Tendencias Recientes', fontweight='bold', fontsize=13)
axes[0].legend(fontsize=11)
axes[0].grid(alpha=0.3)
# Expanding mean (comportamiento histórico)
if user_sample['expanding_days_mean'].notna().any():
axes[1].plot(user_sample['order_number'], user_sample['expanding_days_mean'],
marker='o', color='green', linewidth=2.5, markersize=8)
axes[1].set_xlabel('Order Number', fontsize=12)
axes[1].set_ylabel('Days Between Orders', fontsize=12)
axes[1].set_title('Expanding Mean: Comportamiento Histórico Acumulado',
fontweight='bold', fontsize=13)
axes[1].grid(alpha=0.3)
else:
axes[1].text(0.5, 0.5, 'Acumulando historia...\n(se completa con más órdenes)',
ha='center', va='center', fontsize=12, transform=axes[1].transAxes)
plt.tight_layout()
plt.show()
print("\n✅ Diferencia clave:")
print(" - Rolling: últimos N eventos (tendencia reciente)")
print(" - Expanding: todos los eventos previos (comportamiento histórico)")
PISTAS:
-
.expanding()es como.rolling()pero con ventana infinita Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.expanding.html -
.cumcount()cuenta eventos acumulados por grupo (perfecto para "total orders so far") -
Siempre usar
.shift(1)para excluir el evento actual -
Para expanding sum usa
.sum()en lugar de.mean() -
.fillna(0)rellena NaN en la primera orden (no hay gasto previo)
Parte 3: RFM Analysis y Agregaciones por Usuario¶
3.1 RFM Features (Recency, Frequency, Monetary)¶
RFM es un framework clásico de análisis de comportamiento en e-commerce:
- Recency: ¿Cuánto tiempo desde la última compra?
- Frequency: ¿Con qué frecuencia compra?
- Monetary: ¿Cuánto gasta?
print("\n=== CALCULANDO RFM FEATURES ===\n")
# ⚠️ COMPLETA: RECENCY - Días desde la última orden
reference_date = orders_df['order_date'].______()
orders_df['recency_days'] = (reference_date - orders_df['order_date']).dt.days
# ⚠️ COMPLETA: FREQUENCY - Total de órdenes acumuladas
orders_df['frequency_total_orders'] = orders_df['______']
# ⚠️ COMPLETA: MONETARY - Gasto promedio histórico
orders_df['monetary_avg'] = (
orders_df['______'] /
orders_df['total_orders_so_far'].replace(0, 1)
)
# MONETARY: Gasto total histórico
orders_df['monetary_total'] = orders_df['expanding_total_spent']
print("✅ RFM Features añadidas")
print(f"\n📊 Estadísticas RFM:")
print(orders_df[['recency_days', 'frequency_total_orders', 'monetary_avg', 'monetary_total']].describe())
# Visualizar distribuciones RFM
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# Recency
orders_df['recency_days'].hist(bins=50, ax=axes[0], edgecolor='black', alpha=0.7, color='steelblue')
axes[0].set_xlabel('Days Since Last Order')
axes[0].set_ylabel('Frequency')
axes[0].set_title('Recency Distribution', fontweight='bold')
axes[0].grid(alpha=0.3)
# Frequency
orders_df['frequency_total_orders'].hist(bins=30, ax=axes[1], edgecolor='black', alpha=0.7, color='coral')
axes[1].set_xlabel('Total Historical Orders')
axes[1].set_ylabel('Frequency')
axes[1].set_title('Frequency Distribution', fontweight='bold')
axes[1].grid(alpha=0.3)
# Monetary
orders_df['monetary_avg'].dropna().hist(bins=50, ax=axes[2], edgecolor='black', alpha=0.7, color='seagreen')
axes[2].set_xlabel('Avg Order Value')
axes[2].set_ylabel('Frequency')
axes[2].set_title('Monetary Distribution', fontweight='bold')
axes[2].grid(alpha=0.3)
plt.tight_layout()
plt.show()
# Análisis de correlación RFM
print("\n=== CORRELACIÓN RFM ===")
rfm_corr = orders_df[['recency_days', 'frequency_total_orders', 'monetary_avg']].corr()
print(rfm_corr)
print("\n💡 Insight clave: RFM captura diferentes dimensiones del comportamiento del usuario")
PISTAS:
-
Para recency, usa
(reference_date - df['order_date']).dt.days -
.replace(0, 1)previene división por cero en monetary_avg -
.dropna()elimina NaN antes de graficar (primeras órdenes sin historia) -
RFM es fundamental en e-commerce para segmentación de clientes
3.2 Time Window Aggregations (7d, 30d, 90d)¶
Las time windows capturan comportamiento reciente vs mediano plazo. Son críticas para detectar cambios en actividad.
print("\n=== CALCULANDO TIME WINDOW FEATURES ===\n")
print("📋 Calculando ventanas temporales (7d, 30d, 90d)...")
# SOLUCIÓN OPTIMIZADA: Usar apply con ventanas temporales por usuario
# Este método es más rápido y maneja timestamps duplicados correctamente
def calculate_time_windows_for_user(user_data):
"""
Calcula todas las ventanas temporales para un usuario.
Excluye la orden actual (previene data leakage).
"""
user_data = user_data.sort_values('order_date').reset_index(drop=True)
# Inicializar columnas
user_data['orders_7d'] = 0
user_data['orders_30d'] = 0
user_data['orders_90d'] = 0
user_data['spend_7d'] = 0.0
user_data['spend_30d'] = 0.0
user_data['spend_90d'] = 0.0
# Para cada orden, calcular ventanas
for i in range(len(user_data)):
current_date = user_data.iloc[i]['order_date']
# Datos históricos (excluir orden actual)
if i > 0:
historical_data = user_data.iloc[:i]
# Ventana de 7 días
mask_7d = historical_data['order_date'] >= (current_date - pd.Timedelta(days=7))
user_data.loc[user_data.index[i], 'orders_7d'] = mask_7d.sum()
user_data.loc[user_data.index[i], 'spend_7d'] = historical_data.loc[mask_7d, 'order_total'].sum()
# Ventana de 30 días
mask_30d = historical_data['order_date'] >= (current_date - pd.Timedelta(days=30))
user_data.loc[user_data.index[i], 'orders_30d'] = mask_30d.sum()
user_data.loc[user_data.index[i], 'spend_30d'] = historical_data.loc[mask_30d, 'order_total'].sum()
# Ventana de 90 días
mask_90d = historical_data['order_date'] >= (current_date - pd.Timedelta(days=90))
user_data.loc[user_data.index[i], 'orders_90d'] = mask_90d.sum()
user_data.loc[user_data.index[i], 'spend_90d'] = historical_data.loc[mask_90d, 'order_total'].sum()
return user_data
# Aplicar la función a cada usuario
print(" Procesando ventanas temporales por usuario...")
orders_df = orders_df.groupby('user_id', group_keys=False).apply(calculate_time_windows_for_user)
# Convertir conteos a int
orders_df['orders_7d'] = orders_df['orders_7d'].astype(int)
orders_df['orders_30d'] = orders_df['orders_30d'].astype(int)
orders_df['orders_90d'] = orders_df['orders_90d'].astype(int)
print("✅ Time Window Features creadas")
print(f"\n📊 Resumen de ventanas temporales:")
print(orders_df[['orders_7d', 'orders_30d', 'orders_90d',
'spend_7d', 'spend_30d', 'spend_90d']].describe())
# Visualizar comparación de ventanas
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
# Distribución de órdenes por ventana
window_cols = ['orders_7d', 'orders_30d', 'orders_90d']
orders_df[window_cols].mean().plot(kind='bar', ax=axes[0], color=['steelblue', 'coral', 'seagreen'], alpha=0.7)
axes[0].set_title('Promedio de Órdenes por Ventana Temporal', fontweight='bold')
axes[0].set_xlabel('Ventana')
axes[0].set_ylabel('Promedio de Órdenes')
axes[0].set_xticklabels(['7 días', '30 días', '90 días'], rotation=0)
axes[0].grid(alpha=0.3, axis='y')
# Scatter: actividad reciente vs histórica
axes[1].scatter(orders_df['orders_90d'], orders_df['orders_7d'], alpha=0.5, s=30)
axes[1].set_xlabel('Órdenes en últimos 90 días')
axes[1].set_ylabel('Órdenes en últimos 7 días')
axes[1].set_title('Actividad Reciente vs Histórica', fontweight='bold')
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()
print("\n💡 closed='left' es CRÍTICO: excluye el evento actual (previene data leakage)")
print("💡 Comparar ventanas detecta usuarios 'activándose' o 'durmiendo'")
PISTAS:
- Por qué no usar
.rolling()con timestamps? .set_index('order_date').rolling('7D')falla cuando hay timestamps duplicados- Error: "cannot reindex on an axis with duplicate labels"
-
Solución: usar
.groupby().apply()con lógica manual que maneja duplicados -
La función
calculate_time_windows_for_user()procesa un usuario a la vez - Ordena las órdenes por fecha
-
Para cada orden, calcula ventanas mirando SOLO órdenes previas (previene leakage)
-
historical_data = user_data.iloc[:i]obtiene todas las órdenes antes de la orden actual -
La orden actual (i) no se incluye en el cálculo = NO DATA LEAKAGE ✅
-
mask_7d = historical_data['order_date'] >= (current_date - pd.Timedelta(days=7)) - Filtra órdenes dentro de los últimos 7 días
-
Para 30d y 90d, cambia el número de días:
pd.Timedelta(days=30)opd.Timedelta(days=90) -
.groupby('user_id', group_keys=False).apply()aplica la función a cada usuario group_keys=Falseevita agregar el user_id como índice adicional-
Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.groupby.html
-
Ventaja: Este método es robusto, maneja timestamps duplicados, y garantiza no data leakage
3.3 Product Diversity Features¶
Las métricas de diversidad de productos capturan qué tan variado es el comportamiento de compra de un usuario.
Nota: El dataset Online Retail no tiene categorías de productos, así que medimos diversidad de productos únicos.
print("\n=== CALCULANDO PRODUCT DIVERSITY FEATURES ===\n")
# Volver al dataframe original por producto para calcular diversidad
df_diversity = df[['order_id', 'user_id', 'product_id', 'Country']].copy()
# Agrupar por usuario y calcular métricas de diversidad
diversity_features = df_diversity.groupby('user_id').agg({
'product_id': 'nunique', # Productos únicos comprados
'Country': 'nunique' # Países desde donde compra (generalmente 1)
}).reset_index()
diversity_features.columns = ['user_id', 'unique_products', 'unique_countries']
# Calcular total de items/líneas comprados
total_items = df_diversity.groupby('user_id')['product_id'].count().reset_index()
total_items.columns = ['user_id', 'total_items']
diversity_features = diversity_features.merge(total_items, on='user_id')
# Ratio de diversidad: productos únicos / total de items comprados
# Si ratio = 1.0 → nunca recompra (alta diversidad)
# Si ratio < 0.5 → recompra muchos productos (baja diversidad)
diversity_features['product_diversity_ratio'] = (
diversity_features['unique_products'] / diversity_features['total_items']
)
print("✅ Diversity Features calculadas")
print(f"Shape: {diversity_features.shape}")
print(f"\nResumen:")
print(diversity_features.describe())
# Mergear con orders_df
orders_df = orders_df.merge(diversity_features, on='user_id', how='left')
# Visualizar relación entre total items y productos únicos
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
# Diversidad de productos
axes[0].scatter(diversity_features['total_items'],
diversity_features['unique_products'],
alpha=0.5, s=40, color='steelblue')
axes[0].plot([0, diversity_features['total_items'].max()],
[0, diversity_features['total_items'].max()],
'r--', alpha=0.5, label='y=x (perfect diversity, no recompra)')
axes[0].set_xlabel('Total Items Comprados')
axes[0].set_ylabel('Productos Únicos')
axes[0].set_title('Product Diversity', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)
# Distribución de diversity ratio
axes[1].hist(diversity_features['product_diversity_ratio'],
bins=30, edgecolor='black', alpha=0.7, color='coral')
axes[1].set_xlabel('Product Diversity Ratio')
axes[1].set_ylabel('Frecuencia')
axes[1].set_title('Distribución de Product Diversity Ratio', fontweight='bold')
axes[1].axvline(diversity_features['product_diversity_ratio'].median(),
color='red', linestyle='--',
label=f'Mediana: {diversity_features["product_diversity_ratio"].median():.2f}')
axes[1].legend()
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()
print("\n✅ Insight:")
print(" - Ratio alto (~1.0): Usuario explora productos variados (alta diversidad)")
print(" - Ratio bajo (<0.5): Usuario recompra frecuentemente (baja diversidad)")
NOTAS:
-
El dataset Online Retail no tiene categorías, así que usamos productos únicos como medida de diversidad
-
product_diversity_ratioindica la tendencia a explorar vs. recomprar productos -
Esta feature es útil para segmentar usuarios por comportamiento de exploración
Parte 4: Calendar Features y External Variables¶
4.1 Calendar Features con Encoding Cíclico¶
Features cíclicas: Variables como hora del día o mes tienen naturaleza cíclica (24h → 0h, Dic → Ene).
Solución: Usar transformaciones sin/cos para capturar esta continuidad.
print("\n=== AÑADIENDO CALENDAR FEATURES ===\n")
# ⚠️ COMPLETA: Features binarias de calendario
orders_df['is_weekend'] = (orders_df['order_dow'] >= ______).astype(int)
orders_df['day_of_month'] = orders_df['order_date'].dt.day
orders_df['is_month_start'] = (orders_df['day_of_month'] <= 5).astype(int)
orders_df['is_month_end'] = (orders_df['day_of_month'] >= 25).astype(int)
orders_df['month'] = orders_df['order_date'].dt.month
orders_df['quarter'] = orders_df['order_date'].dt.quarter
# Holidays UK (fechas importantes en el dataset 2010-2011)
holidays_uk = pd.to_datetime([
'2010-12-25', '2010-12-26', '2011-01-01', '2011-12-25', '2011-12-26'
])
orders_df['is_holiday'] = orders_df['order_date'].isin(holidays_uk).astype(int)
# Días hasta próximo feriado
christmas_2010 = pd.Timestamp('2010-12-25')
orders_df['days_to_holiday'] = (christmas_2010 - orders_df['order_date']).dt.days
orders_df.loc[orders_df['days_to_holiday'] < 0, 'days_to_holiday'] = 365
# ⚠️ COMPLETA: ENCODING CÍCLICO sin/cos (preserva naturaleza circular del tiempo)
# Hour of day (0-23)
orders_df['hour_sin'] = np.sin(2 * np.pi * orders_df['order_hour_of_day'] / ______)
orders_df['hour_cos'] = np.cos(2 * np.pi * orders_df['order_hour_of_day'] / ______)
# Day of week (0-6)
orders_df['dow_sin'] = np.sin(2 * np.pi * orders_df['order_dow'] / ______)
orders_df['dow_cos'] = np.cos(2 * np.pi * orders_df['order_dow'] / ______)
# Month (1-12)
orders_df['month_sin'] = np.sin(2 * np.pi * orders_df['month'] / ______)
orders_df['month_cos'] = np.cos(2 * np.pi * orders_df['month'] / ______)
print("✅ Calendar Features añadidas")
print(f"Nuevas features: is_weekend, day_of_month, is_holiday, hour_sin/cos, dow_sin/cos, month_sin/cos")
# Visualizar encoding cíclico
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# Hour encoding
axes[0].scatter(orders_df['hour_sin'], orders_df['hour_cos'],
c=orders_df['order_hour_of_day'], cmap='twilight', s=20, alpha=0.6)
axes[0].set_xlabel('Hour Sin')
axes[0].set_ylabel('Hour Cos')
axes[0].set_title('Encoding Cíclico de Hora del Día', fontweight='bold')
axes[0].grid(alpha=0.3)
axes[0].set_aspect('equal')
# Day of week encoding
axes[1].scatter(orders_df['dow_sin'], orders_df['dow_cos'],
c=orders_df['order_dow'], cmap='Set2', s=20, alpha=0.6)
axes[1].set_xlabel('DOW Sin')
axes[1].set_ylabel('DOW Cos')
axes[1].set_title('Encoding Cíclico de Día de Semana', fontweight='bold')
axes[1].grid(alpha=0.3)
axes[1].set_aspect('equal')
# Weekend effect
weekend_effect = orders_df.groupby('is_weekend')['cart_size'].mean()
axes[2].bar(['Weekday', 'Weekend'], weekend_effect.values, color=['steelblue', 'coral'], alpha=0.7)
axes[2].set_ylabel('Avg Cart Size')
axes[2].set_title('Efecto Weekend en Cart Size', fontweight='bold')
axes[2].grid(alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
print("\n✅ Ventaja del encoding cíclico:")
print(" - Las 23h están 'cerca' de las 0h en el espacio sin/cos")
print(" - El domingo está 'cerca' del lunes")
print(" - El modelo captura mejor la continuidad temporal")
PISTAS:
-
Para is_weekend, compara
order_dow >= 5(sábado=5, domingo=6) -
Para day_of_month usa
df['order_date'].dt.day -
Para is_month_end, usa
day_of_month >= 25 -
Para quarter usa
.dt.quarter -
Para is_holiday, usa
holidays_uken.isin() -
Para hour encoding, normaliza dividiendo por
24(24 horas) -
Para dow encoding, usa la columna
'order_dow'y normaliza por7 -
Para month_sin usa
np.sin()y para month_cos usa la columna'month' -
Concepto clave: sin/cos preserva la naturaleza circular del tiempo
4.2 Economic Indicators (Simulados)¶
Las external variables proporcionan contexto macro que afecta el comportamiento del consumidor.
print("\n=== CREANDO ECONOMIC INDICATORS ===\n")
# Crear GDP/unemployment data mensual para el período del dataset
date_range_monthly = pd.date_range(
start=orders_df['order_date'].min().replace(day=1),
end=orders_df['order_date'].max(),
freq='MS'
)
np.random.seed(42)
economic_data = pd.DataFrame({
'month_date': date_range_monthly,
'gdp_growth': np.random.normal(2.5, 0.5, len(date_range_monthly)),
'unemployment_rate': np.random.normal(4.0, 0.3, len(date_range_monthly)),
'consumer_confidence': np.random.normal(100, 5, len(date_range_monthly))
})
print("=== ECONOMIC DATA ===")
print(economic_data.head())
# Mergear con orders_df
# Crear columna de mes para el merge
orders_df['month_period'] = orders_df['order_date'].dt.to_period('M')
economic_data['month_period'] = economic_data['month_date'].dt.to_period('M')
# Merge
orders_df = orders_df.merge(
economic_data[['month_period', 'gdp_growth', 'unemployment_rate', 'consumer_confidence']],
on='month_period',
how='left'
)
# Forward fill para llenar gaps (NUNCA backward fill = data leakage!)
orders_df['gdp_growth'] = orders_df['gdp_growth'].fillna(method='ffill')
orders_df['unemployment_rate'] = orders_df['unemployment_rate'].fillna(method='ffill')
orders_df['consumer_confidence'] = orders_df['consumer_confidence'].fillna(method='ffill')
print("\n✅ ECONOMIC FEATURES AÑADIDAS")
print(f"GDP Growth range: {orders_df['gdp_growth'].min():.2f} to {orders_df['gdp_growth'].max():.2f}")
print(f"Unemployment range: {orders_df['unemployment_rate'].min():.2f}% to {orders_df['unemployment_rate'].max():.2f}%")
print(f"Missing values: {orders_df[['gdp_growth', 'unemployment_rate', 'consumer_confidence']].isna().sum().sum()}")
# Visualizar correlación con order behavior
monthly_orders = orders_df.groupby('month_period').agg({
'order_id': 'nunique',
'gdp_growth': 'first',
'consumer_confidence': 'first'
}).reset_index()
fig, axes = plt.subplots(2, 1, figsize=(14, 8))
# Orders y GDP
axes[0].plot(range(len(monthly_orders)), monthly_orders['order_id'],
marker='o', label='Orders', linewidth=2, color='steelblue')
axes[0].set_ylabel('Number of Orders', color='steelblue')
axes[0].set_title('Monthly Orders y GDP Growth', fontweight='bold')
axes[0].tick_params(axis='y', labelcolor='steelblue')
axes[0].grid(alpha=0.3)
ax2 = axes[0].twinx()
ax2.plot(range(len(monthly_orders)), monthly_orders['gdp_growth'],
marker='s', color='red', alpha=0.6, label='GDP Growth', linewidth=2)
ax2.set_ylabel('GDP Growth %', color='red')
ax2.tick_params(axis='y', labelcolor='red')
# Orders vs Consumer Confidence
axes[1].scatter(monthly_orders['consumer_confidence'], monthly_orders['order_id'],
alpha=0.6, s=100, color='coral')
axes[1].set_xlabel('Consumer Confidence Index')
axes[1].set_ylabel('Number of Orders')
axes[1].set_title('Orders vs Consumer Confidence', fontweight='bold')
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()
print("\n✅ Regla de oro: SÓLO forward fill (ffill), NUNCA backward fill (bfill)")
print(" - Forward: usar información pasada para rellenar presente/futuro (OK)")
print(" - Backward: usar información futura para rellenar pasado (DATA LEAKAGE!)")
PISTAS:
-
Para crear month_period usa
.dt.to_period('M')donde 'M' significa mensual -
En el merge, las columnas económicas son: 'gdp_growth', 'unemployment_rate', 'consumer_confidence'
-
El merge debe ser sobre 'month_period'
-
Para forward fill usa
.fillna(method='ffill')Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.fillna.html -
Para consumer_confidence también usa
'ffill' -
CRÍTICO: NUNCA uses 'bfill' (backward fill) en datos temporales = data leakage!
Parte 5: Time-based Validation¶
5.1 Preparar Features para Modeling¶
print("\n=== PREPARANDO DATASET PARA MODELING ===\n")
# Crear target: 'will_purchase_again'
# = 1 si el usuario hace otra compra después de esta orden, 0 si no
print("📋 Creando target 'will_purchase_again'...")
orders_df = orders_df.sort_values(['user_id', 'order_date'])
orders_df['will_purchase_again'] = (
orders_df.groupby('user_id')['order_id']
.shift(-1)
.notna()
.astype(int)
)
print(f"✅ Target creado: {orders_df['will_purchase_again'].sum():,} órdenes seguidas de otra compra")
print(f" Total órdenes: {len(orders_df):,}")
print(f" Tasa de recompra: {orders_df['will_purchase_again'].mean()*100:.1f}%")
# Seleccionar features para el modelo
feature_cols = [
# Lag features
'days_since_prior_lag_1', 'days_since_prior_lag_2', 'days_since_prior_lag_3',
# Rolling features
'rolling_cart_mean_3', 'rolling_cart_std_3',
# Expanding features
'expanding_days_mean', 'total_orders_so_far', 'expanding_total_spent',
# RFM features
'recency_days', 'monetary_avg', 'monetary_total',
# Time window features
'orders_7d', 'orders_30d', 'orders_90d',
'spend_7d', 'spend_30d', 'spend_90d',
# Diversity features
'unique_products', 'unique_countries', 'product_diversity_ratio',
# Calendar features
'order_dow', 'order_hour_of_day', 'is_weekend', 'is_month_start', 'is_month_end',
'is_holiday', 'days_to_holiday', 'dow_sin', 'dow_cos', 'hour_sin', 'hour_cos',
# Economic features
'gdp_growth', 'unemployment_rate', 'consumer_confidence',
# Base features
'cart_size', 'order_total', 'order_number'
]
target_col = 'will_purchase_again'
# Crear dataset limpio (solo con features que existen)
available_features = [col for col in feature_cols if col in orders_df.columns]
print(f"\n📊 Features disponibles: {len(available_features)} de {len(feature_cols)} solicitadas")
df_model = orders_df[available_features + [target_col, 'order_date', 'user_id']].copy()
# Drop NaN (de los primeros lags y últimas órdenes sin target)
initial_rows = len(df_model)
df_model = df_model.dropna()
print(f"✅ Filas después de eliminar NaN: {len(df_model):,} (de {initial_rows:,})")
print("\n=== DATASET PARA MODELING ===")
print(f"Shape: {df_model.shape}")
print(f"Features: {len(available_features)}")
print(f"\nTarget distribution:")
print(df_model[target_col].value_counts(normalize=True))
print(f"\nPrimeras filas:")
print(df_model[[target_col, 'order_number', 'recency_days', 'monetary_avg']].head(10))
5.2 TimeSeriesSplit Validation¶
from sklearn.model_selection import TimeSeriesSplit
# Ordenar por fecha
df_model = df_model.sort_values('order_date')
# Preparar X, y
X = df_model[available_features]
y = df_model[target_col]
# TimeSeriesSplit
n_splits = 3
tscv = TimeSeriesSplit(n_splits=n_splits)
print("=== TIME SERIES CROSS-VALIDATION ===")
print(f"N Splits: {n_splits}")
fold_results = []
for fold, (train_idx, val_idx) in enumerate(tscv.split(X), 1):
print(f"\n--- Fold {fold}/{n_splits} ---")
# Split data
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
# Dates para referencia
train_dates = df_model.iloc[train_idx]['order_date']
val_dates = df_model.iloc[val_idx]['order_date']
print(f"Train: {train_dates.min().date()} to {train_dates.max().date()} ({len(train_idx):,} samples)")
print(f"Val: {val_dates.min().date()} to {val_dates.max().date()} ({len(val_idx):,} samples)")
# Train model
model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
# Predict
y_pred_proba = model.predict_proba(X_val)[:, 1]
# Metrics
auc = roc_auc_score(y_val, y_pred_proba)
print(f"Validation AUC: {auc:.4f}")
fold_results.append({
'fold': fold,
'train_size': len(train_idx),
'val_size': len(val_idx),
'auc': auc
})
# Summary
fold_results_df = pd.DataFrame(fold_results)
print("\n=== CROSS-VALIDATION SUMMARY ===")
print(fold_results_df)
print(f"\nMean AUC: {fold_results_df['auc'].mean():.4f} ± {fold_results_df['auc'].std():.4f}")
PISTAS:
- Elige n_splits entre 3 y 5 (común para time series)
- TimeSeriesSplit se importa de sklearn.model_selection
- El método para iterar es
.split(X)sobre el objeto tscv Documentación: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html - Para split usar train_idx y val_idx con
.iloc[] - Para entrenar el modelo usa
.fit(X_train, y_train) - Para predecir probabilidades usa
.predict_proba(X_val)[:, 1]Documentación: https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html
5.3 Comparación: Con vs Sin Temporal Features¶
# Comparar modelo con temporal features vs solo base features
base_feature_cols = [
'order_dow', 'order_hour_of_day', 'is_weekend', 'is_holiday',
'cart_size', 'order_total', 'order_number'
]
# Solo usar features base que existen en available_features
base_feature_cols = [col for col in base_feature_cols if col in available_features]
temporal_feature_cols = [col for col in available_features if col not in base_feature_cols]
print("=== FEATURE COMPARISON ===")
print(f"Base features: {len(base_feature_cols)}")
print(f" {base_feature_cols[:5]}...")
print(f"Temporal features: {len(temporal_feature_cols)}")
print(f" {temporal_feature_cols[:5]}...")
print(f"Total features: {len(available_features)}")
# Entrenar ambos modelos con time series split
def train_and_evaluate(X, y, feature_subset, n_splits=3):
"""
Entrenar y evaluar modelo con subset de features
"""
tscv = TimeSeriesSplit(n_splits=n_splits)
scores = []
for train_idx, val_idx in tscv.split(X):
X_train = X.iloc[train_idx][feature_subset]
X_val = X.iloc[val_idx][feature_subset]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
y_pred_proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, y_pred_proba)
scores.append(auc)
return np.mean(scores), np.std(scores), model
# Base model
print("\n🔄 Entrenando Base Model (sin temporal features)...")
base_auc_mean, base_auc_std, base_model = train_and_evaluate(X, y, base_feature_cols)
# Full model (con temporal features)
print("🔄 Entrenando Full Model (con temporal features)...")
full_auc_mean, full_auc_std, full_model = train_and_evaluate(X, y, available_features)
print("\n=== RESULTS ===")
print(f"Base Model (no temporal): AUC = {base_auc_mean:.4f} ± {base_auc_std:.4f}")
print(f"Full Model (con temporal): AUC = {full_auc_mean:.4f} ± {full_auc_std:.4f}")
print(f"Improvement: {(full_auc_mean - base_auc_mean):.4f} ({((full_auc_mean - base_auc_mean)/base_auc_mean * 100):.1f}%)")
# Visualización
fig, ax = plt.subplots(figsize=(10, 6))
models = ['Base\n(sin temporal)', 'Full\n(con temporal)']
means = [base_auc_mean, full_auc_mean]
stds = [base_auc_std, full_auc_std]
bars = ax.bar(models, means, yerr=stds, capsize=10, alpha=0.7, color=['steelblue', 'coral'])
ax.set_ylabel('AUC Score')
ax.set_title('Model Performance: Con vs Sin Temporal Features', fontweight='bold')
ax.set_ylim([min(means) - 0.1, max(means) + 0.1])
ax.grid(alpha=0.3, axis='y')
for bar, mean in zip(bars, means):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
f'{mean:.4f}', ha='center', va='bottom', fontweight='bold')
plt.tight_layout()
plt.show()
Parte 6: Feature Importance Analysis¶
6.1 Analizar Importancia de Features¶
# Analizar qué features temporales son más importantes
def analyze_feature_importance(model, feature_names, top_n=20):
"""
Analizar y visualizar feature importance
"""
# Get importances
importances = pd.DataFrame({
'feature': feature_names,
'importance': model.feature_importances_
}).sort_values('importance', ascending=False)
# Categorizar features
def categorize_feature(feat):
if any(x in feat for x in ['lag', 'rolling', 'expanding', 'total_orders_so_far']):
return 'Lag/Window'
elif any(x in feat for x in ['recency', 'frequency', 'monetary']):
return 'RFM'
elif any(x in feat for x in ['_7d', '_30d', '_90d']):
return 'Time Window'
elif any(x in feat for x in ['unique', 'diversity', 'reorder_rate']):
return 'Diversity'
elif any(x in feat for x in ['holiday', 'weekend', 'month', 'dow', 'hour', 'days_to']):
return 'Calendar'
elif any(x in feat for x in ['gdp', 'unemployment', 'consumer']):
return 'Economic'
else:
return 'Base'
importances['category'] = importances['feature'].apply(categorize_feature)
# Top features
print("=== TOP FEATURES ===")
print(importances.head(top_n).to_string(index=False))
# Importance by category
print("\n=== IMPORTANCE BY CATEGORY ===")
category_importance = importances.groupby('category')['importance'].agg(['sum', 'mean', 'count'])
category_importance = category_importance.sort_values('sum', ascending=False)
print(category_importance)
# Visualización
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Top features
top_features = importances.head(top_n)
axes[0].barh(range(len(top_features)), top_features['importance'])
axes[0].set_yticks(range(len(top_features)))
axes[0].set_yticklabels(top_features['feature'], fontsize=9)
axes[0].set_xlabel('Importance')
axes[0].set_title(f'Top {top_n} Most Important Features')
axes[0].invert_yaxis()
axes[0].grid(alpha=0.3, axis='x')
# Category importance
category_importance['sum'].plot(kind='bar', ax=axes[1], color='steelblue', alpha=0.7)
axes[1].set_xlabel('Feature Category')
axes[1].set_ylabel('Total Importance')
axes[1].set_title('Feature Importance by Category')
axes[1].tick_params(axis='x', rotation=45)
axes[1].grid(alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
return importances
# Analizar full model
feature_importance = analyze_feature_importance(full_model, available_features, top_n=25)
6.2 Análisis de Leakage Detection¶
# Verificar que no haya data leakage
print("=== DATA LEAKAGE CHECK ===")
# 1. Performance check
train_score = full_model.score(X, y)
print(f"\n1. Train accuracy: {train_score:.4f}")
print(f" CV AUC: {full_auc_mean:.4f}")
if train_score > 0.99:
print(" ⚠️ WARNING: Train accuracy suspiciously high (>0.99)")
elif train_score - full_auc_mean > 0.3:
print(f" ⚠️ WARNING: Large gap between train and CV ({train_score - full_auc_mean:.4f})")
else:
print(" ✅ Performance looks reasonable")
# 2. Feature importance check
print("\n2. Top feature check:")
top_5_features = feature_importance.head(5)['feature'].tolist()
print(f" Top 5: {top_5_features}")
suspicious_features = [f for f in top_5_features if any(x in f for x in ['target', 'label', 'leak'])]
if suspicious_features:
print(f" ⚠️ WARNING: Suspicious features in top 5: {suspicious_features}")
else:
print(" ✅ No obviously suspicious features in top 5")
# 3. Temporal consistency check
print("\n3. Temporal consistency:")
print(" Verificando que validation siempre sea posterior a train...")
tscv = TimeSeriesSplit(n_splits=3)
for fold, (train_idx, val_idx) in enumerate(tscv.split(X), 1):
train_dates = df_model.iloc[train_idx]['order_date']
val_dates = df_model.iloc[val_idx]['order_date']
if train_dates.max() < val_dates.min():
print(f" Fold {fold}: ✅ Train max ({train_dates.max().date()}) < Val min ({val_dates.min().date()})")
else:
print(f" Fold {fold}: ⚠️ LEAKAGE: Train includes dates from validation period!")
# 4. Feature calculation check
print("\n4. Feature calculation check:")
print(" ✅ Todas las aggregations usan shift(1)")
print(" ✅ TimeSeriesSplit usado en lugar de KFold")
print(" ✅ Solo forward fill (no backward fill)")
print(" ✅ Rolling windows con closed='left'")
print("\n ✅ Si todo es SÍ, probablemente no hay leakage!")
Conclusiones y Reflexión¶
print("=" * 70)
print("CONCLUSIONES DE TEMPORAL FEATURE ENGINEERING")
print("=" * 70)
print("\n1. IMPACTO DE TEMPORAL FEATURES:")
print(f" - Base Model AUC: {base_auc_mean:.4f}")
print(f" - Full Model AUC: {full_auc_mean:.4f}")
print(f" - Improvement: {((full_auc_mean - base_auc_mean)/base_auc_mean * 100):.1f}%")
print("\n2. CATEGORÍAS MÁS IMPORTANTES:")
top_categories = feature_importance.groupby('category')['importance'].sum().sort_values(ascending=False).head(3)
for cat, imp in top_categories.items():
print(f" - {cat}: {imp:.4f}")
print("\n3. TOP 5 FEATURES:")
for i, row in feature_importance.head(5).iterrows():
print(f" {row['feature']:30s} ({row['category']:15s}): {row['importance']:.4f}")
print("\n4. LECCIONES APRENDIDAS:")
print(" ✅ Temporal features mejoran significativamente el performance")
print(" ✅ Lag y window features capturan patrones de comportamiento")
print(" ✅ RFM analysis sigue siendo relevante en e-commerce")
print(" ✅ TimeSeriesSplit es crítico para evitar data leakage")
print(" ✅ External variables pueden agregar valor (holidays, economic)")
print("\n5. PREVENCIÓN DE DATA LEAKAGE CON PANDAS:")
print(" ✅ Siempre usar .groupby() + .shift(1) antes de aggregations")
print(" ✅ TimeSeriesSplit para cross-validation")
print(" ✅ Solo forward fill (nunca backward)")
print(" ✅ Rolling temporal con closed='left'")
print(" ✅ Verificar que val dates > train dates")
print("\n" + "=" * 70)
Preguntas de Reflexión¶
Responde las siguientes preguntas basándote en tu implementación:
- ¿Qué window size (7d, 30d, 90d) parece más importante según feature importance?
Tu respuesta:
- ¿Las external variables (economic indicators) agregaron valor significativo? ¿Por qué crees que sí o no?
Tu respuesta:
- ¿Qué features de RFM (Recency, Frequency, Monetary) son más predictivas?
Tu respuesta:
- ¿Observaste alguna señal de data leakage? ¿Cómo lo detectaste?
Tu respuesta:
- ¿Cómo cambiaría tu implementación si tuvieras que deployar esto en producción y hacer predicciones diarias?
Tu respuesta:
🚀 Siguientes Pasos y Tareas Domiciliarias¶
📊 Opción 1: Probar con Otros Datasets¶
Aplica las mismas técnicas de temporal feature engineering a estos datasets:
- Brazilian E-Commerce (Olist) - Versión completa
- https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce
- Ideal para: Customer lifetime value, churn prediction
-
Target: Predecir si usuario comprará nuevamente
-
Retail Rocket Dataset (Eventos de e-commerce)
- https://www.kaggle.com/datasets/retailrocket/ecommerce-dataset
- Ideal para: Session features, event sequences
-
Target: Predecir conversión de sesión
-
H&M Personalized Fashion (1.3M+ usuarios)
- https://www.kaggle.com/competitions/h-and-m-personalized-fashion-recommendations/data
- Ideal para: Seasonal patterns, fashion trends
- Target: Recomendar próxima compra
🎯 Opción 2: Features Temporales Avanzadas (No cubiertas en este assignment)¶
A. Features de Interacción Temporal¶
- Interacción entre recency y frequency
- Aceleración de compras (cambio en frecuencia)
- Ratio de consistencia temporal
Documentación: https://scikit-learn.org/stable/modules/preprocessing.html#polynomial-features
B. Features de Tendencias (Slope Features)¶
- Tendencia de gasto (¿está gastando más o menos?)
- Pendiente de frecuencia de compra
- Regresión lineal sobre ventanas temporales
Documentación: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.linregress.html
C. Features de Estacionalidad¶
- Fourier features para capturar ciclos anuales
- Indicadores de temporada (invierno, primavera, verano, otoño)
- Decomposición estacional (trend + seasonal + residual)
Documentación: https://www.statsmodels.org/stable/generated/statsmodels.tsa.seasonal.seasonal_decompose.html
D. Features de Anomalías¶
- Detectar compras atípicas por usuario
- Z-score normalizado por grupo
- Isolation Forest para outliers temporales
Documentación: https://scikit-learn.org/stable/modules/outlier_detection.html
E. Features de Cohort Analysis¶
- Mes de primera compra (cohort)
- Edad de la cuenta en meses
- Comportamiento comparado con su cohort
Documentación: https://www.kaggle.com/code/datacog314/cohort-analysis-with-python
F. Features de Sesión y Secuencias¶
- Tiempo desde primera compra
- Velocidad de gasto (spending velocity)
- Gaps entre compras (tiempo sin actividad)
Documentación: https://pandas.pydata.org/docs/user_guide/timeseries.html
📚 Opción 3: Técnicas de Validación Avanzadas¶
- Blocked Time Series Cross-Validation
-
https://scikit-learn.org/stable/modules/cross_validation.html#time-series-split
-
Walk-Forward Validation
-
https://machinelearningmastery.com/backtest-machine-learning-models-time-series-forecasting/
-
Purged K-Fold para Features Temporales
- https://github.com/hudson-and-thames/mlfinlab