Saltar a contenido

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

  1. Ve a https://www.kaggle.com/settings/account
  2. Haz clic en "Create New API Token"
  3. Se descargará automáticamente un archivo kaggle.json con tus credenciales
  4. 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:

  1. files.upload() es la función de Colab para subir archivos Documentación: https://colab.research.google.com/notebooks/io.ipynb
  2. Los permisos 600 en octal se escriben como 0o600
  3. 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:

  1. dataset_ref = "vijayuv/onlineretail" (referencia del dataset en Kaggle)
  2. download_path = "./data" o "/tmp/data" (donde descargar)
  3. unzip=True descomprime automáticamente archivos ZIP
  4. 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:

  1. El archivo se llama OnlineRetail.csv

  2. Usa encoding='ISO-8859-1' para evitar errores de caracteres especiales

  3. .info() muestra la estructura del DataFrame

  4. .head(10) muestra las primeras 10 filas

  5. Columnas clave del Online Retail dataset:

  6. InvoiceNo: ID de la factura/orden
  7. StockCode: Código del producto
  8. Description: Descripción del producto
  9. Quantity: Cantidad comprada
  10. InvoiceDate: Fecha y hora de la transacción
  11. UnitPrice: Precio unitario
  12. CustomerID: ID del cliente (para análisis temporal por usuario)
  13. 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:

  1. .dropna(subset=['col']) elimina filas donde la columna es nula

  2. Filtra transacciones canceladas con ~df['col'].str.startswith('C')

  3. Asegúrate de que order_date sea tipo datetime: pd.to_datetime()

  4. Calcula total_amount = Quantity × price

  5. CRÍTICO: Ordena con .sort_values(['user_id', 'order_date']) antes de features temporales

  6. .reset_index(drop=True) asegura índices consecutivos

  7. .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:

  1. Para agregar cart_size usa 'count' sobre 'product_id' (cuenta productos por orden) Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.agg.html

  2. Para agregar order_total usa 'sum' sobre 'total_amount' (suma total gastado en la orden)

  3. .cumcount() crea un contador secuencial (0, 1, 2, ...) por grupo, sumamos 1 para empezar en 1

  4. .diff() calcula la diferencia entre filas consecutivas dentro de cada grupo

  5. 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:

  1. .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

  2. .groupby('user_id') asegura que no se mezclen datos entre usuarios

  3. Los NaN aparecen en las primeras n órdenes de cada usuario (esperado y correcto)

  4. 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:

  1. .shift(1) ANTES de .rolling() es CRÍTICO para prevenir data leakage

  2. min_periods=1 permite cálculos con menos de 3 valores (útil para primeras órdenes) Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.rolling.html

  3. .reset_index(level=0, drop=True) es necesario después de .rolling() en un groupby

  4. Para rolling std usa .std() en lugar de .mean()

  5. 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:

  1. .expanding() es como .rolling() pero con ventana infinita Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.expanding.html

  2. .cumcount() cuenta eventos acumulados por grupo (perfecto para "total orders so far")

  3. Siempre usar .shift(1) para excluir el evento actual

  4. Para expanding sum usa .sum() en lugar de .mean()

  5. .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:

  1. Para recency, usa (reference_date - df['order_date']).dt.days

  2. .replace(0, 1) previene división por cero en monetary_avg

  3. .dropna() elimina NaN antes de graficar (primeras órdenes sin historia)

  4. 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:

  1. Por qué no usar .rolling() con timestamps?
  2. .set_index('order_date').rolling('7D') falla cuando hay timestamps duplicados
  3. Error: "cannot reindex on an axis with duplicate labels"
  4. Solución: usar .groupby().apply() con lógica manual que maneja duplicados

  5. La función calculate_time_windows_for_user() procesa un usuario a la vez

  6. Ordena las órdenes por fecha
  7. Para cada orden, calcula ventanas mirando SOLO órdenes previas (previene leakage)

  8. historical_data = user_data.iloc[:i] obtiene todas las órdenes antes de la orden actual

  9. La orden actual (i) no se incluye en el cálculo = NO DATA LEAKAGE ✅

  10. mask_7d = historical_data['order_date'] >= (current_date - pd.Timedelta(days=7))

  11. Filtra órdenes dentro de los últimos 7 días
  12. Para 30d y 90d, cambia el número de días: pd.Timedelta(days=30) o pd.Timedelta(days=90)

  13. .groupby('user_id', group_keys=False).apply() aplica la función a cada usuario

  14. group_keys=False evita agregar el user_id como índice adicional
  15. Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.groupby.html

  16. 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:

  1. El dataset Online Retail no tiene categorías, así que usamos productos únicos como medida de diversidad

  2. product_diversity_ratio indica la tendencia a explorar vs. recomprar productos

  3. 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:

  1. Para is_weekend, compara order_dow >= 5 (sábado=5, domingo=6)

  2. Para day_of_month usa df['order_date'].dt.day

  3. Para is_month_end, usa day_of_month >= 25

  4. Para quarter usa .dt.quarter

  5. Para is_holiday, usa holidays_uk en .isin()

  6. Para hour encoding, normaliza dividiendo por 24 (24 horas)

  7. Para dow encoding, usa la columna 'order_dow' y normaliza por 7

  8. Para month_sin usa np.sin() y para month_cos usa la columna 'month'

  9. 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:

  1. Para crear month_period usa .dt.to_period('M') donde 'M' significa mensual

  2. En el merge, las columnas económicas son: 'gdp_growth', 'unemployment_rate', 'consumer_confidence'

  3. El merge debe ser sobre 'month_period'

  4. Para forward fill usa .fillna(method='ffill') Documentación: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.fillna.html

  5. Para consumer_confidence también usa 'ffill'

  6. 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:

  1. Elige n_splits entre 3 y 5 (común para time series)
  2. TimeSeriesSplit se importa de sklearn.model_selection
  3. 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
  4. Para split usar train_idx y val_idx con .iloc[]
  5. Para entrenar el modelo usa .fit(X_train, y_train)
  6. 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:

  1. ¿Qué window size (7d, 30d, 90d) parece más importante según feature importance?

Tu respuesta:

  1. ¿Las external variables (economic indicators) agregaron valor significativo? ¿Por qué crees que sí o no?

Tu respuesta:

  1. ¿Qué features de RFM (Recency, Frequency, Monetary) son más predictivas?

Tu respuesta:

  1. ¿Observaste alguna señal de data leakage? ¿Cómo lo detectaste?

Tu respuesta:

  1. ¿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:

  1. Brazilian E-Commerce (Olist) - Versión completa
  2. https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce
  3. Ideal para: Customer lifetime value, churn prediction
  4. Target: Predecir si usuario comprará nuevamente

  5. Retail Rocket Dataset (Eventos de e-commerce)

  6. https://www.kaggle.com/datasets/retailrocket/ecommerce-dataset
  7. Ideal para: Session features, event sequences
  8. Target: Predecir conversión de sesión

  9. H&M Personalized Fashion (1.3M+ usuarios)

  10. https://www.kaggle.com/competitions/h-and-m-personalized-fashion-recommendations/data
  11. Ideal para: Seasonal patterns, fashion trends
  12. 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

  1. Blocked Time Series Cross-Validation
  2. https://scikit-learn.org/stable/modules/cross_validation.html#time-series-split

  3. Walk-Forward Validation

  4. https://machinelearningmastery.com/backtest-machine-learning-models-time-series-forecasting/

  5. Purged K-Fold para Features Temporales

  6. https://github.com/hudson-and-thames/mlfinlab