/ Proyectos / AgriTech / Predicción de precios
Predecir precios de abarrotes con LSTM y seleccionar recetas económicas
Pipeline completo: desde datos crudos de mercado hasta recomendación nutricional automatizada. Descomposición estacional, codificación cíclica, lags y arquitectura LSTM-MSNet con código real.
Resumen del proyectoWiki
¿Qué encontrarás en este proyecto?
- Cómo predecir precios de alimentos con LSTM sobre datos reales de mercado.
- Descomposición estacional, codificación cíclica y construcción de lags.
- Cómo formular y resolver un problema de optimización nutricional con costo mínimo.
- Arquitectura LSTM-MSNet explicada con código real en Python.
Este proyecto combina dos problemas que rara vez se tratan juntos: la predicción de precios de alimentos mediante series de tiempo, y la optimización de dietas económicas con restricciones nutricionales.
El caso de uso es real: con los precios predichos para la próxima semana, ¿cuál es el conjunto de recetas que cumple con los requerimientos nutricionales de una familia a menor costo?
La fuente de datos es Corabastos, la central mayorista más grande de Bogotá. Los precios tienen estacionalidad semanal marcada, eventos de mercado y tendencias de largo plazo, lo que hace del problema un caso ideal para modelos de series de tiempo.
Objetivos del proyecto
- Construir un modelo de predicción de precios de abarrotes con LSTM sobre datos históricos de Corabastos.
- Incorporar descomposición estacional, codificación cíclica y lags como features del modelo.
- Formular el problema de selección de recetas como un problema de optimización con restricciones nutricionales.
- Integrar ambos módulos en un pipeline que, dado un presupuesto, sugiera el menú semanal más económico y nutricionalmente completo.
Stack tecnológico
- Python 3.10+ — lenguaje principal del pipeline
- TensorFlow / Keras — arquitectura LSTM y LSTM-MSNet
- statsmodels — descomposición STL y análisis de series de tiempo
- PuLP — optimización combinatoria nutricional
- pandas / numpy — manipulación de datos y feature engineering
- matplotlib / plotly — visualización de predicciones y resultados
Setup del entornoConfiguración del sistema
Antes de escribir una sola línea de modelo, el entorno debe estar configurado correctamente. Esto incluye dependencias, estructura de carpetas y acceso a la fuente de datos.
Instalación de dependencias
El proyecto requiere Python 3.10 o superior. Se recomienda un entorno virtual para aislar las dependencias.
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install tensorflow pandas numpy statsmodels scikit-learn
pip install pulp matplotlib plotly openpyxl
Estructura del proyecto
Una estructura clara desde el inicio evita confusión al escalar el proyecto.
proyecto-abarrotes/
├── data/
│ ├── raw/ # datos originales de Corabastos sin modificar
│ ├── processed/ # datos después de limpieza y feature engineering
│ └── external/ # tablas nutricionales de referencia
├── models/
│ ├── lstm_msnet.h5 # modelo entrenado guardado
│ └── scaler.pkl # normalizador guardado para inferencia
├── notebooks/
│ ├── 01_eda.ipynb
│ ├── 02_feature_engineering.ipynb
│ ├── 03_modelo_lstm.ipynb
│ └── 04_optimizacion_nutricional.ipynb
├── src/
│ ├── preprocessing.py
│ ├── model.py
│ └── optimizer.py
└── requirements.txt
Fuente de datos: Corabastos
Corabastos publica precios mayoristas históricos de alimentos de forma abierta. Los datos incluyen precio por kilogramo, producto, fecha y origen.
- Frecuencia: diaria o semanal según el producto
- Variables disponibles: fecha, producto, precio_kg, unidad, origen
- Período disponible: desde 2015 aproximadamente
- Formato: Excel (.xlsx) descargable por período
import pandas as pd
df = pd.read_excel("data/raw/corabastos_2023_2024.xlsx")
print(df.head())
print(df.dtypes)
print(df["producto"].value_counts().head(20))
Feature EngineeringPreparación de datos
La preparación de datos es la parte más crítica del pipeline. Un modelo LSTM sobre datos mal preparados producirá predicciones inútiles independientemente de su arquitectura.
Limpieza inicial
Los datos de mercados tienen ruido: precios atípicos por errores de registro, fechas faltantes en días festivos y variaciones de nombre del mismo producto.
producto = "PAPA PASTUSA"
df_prod = df[df["producto"] == producto].copy()
df_prod["fecha"] = pd.to_datetime(df_prod["fecha"])
df_prod = df_prod.sort_values("fecha").reset_index(drop=True)
# Rellenar fechas faltantes (días festivos)
idx = pd.date_range(df_prod["fecha"].min(), df_prod["fecha"].max(), freq="D")
df_prod = df_prod.set_index("fecha").reindex(idx).ffill().reset_index()
df_prod.columns = ["fecha"] + list(df_prod.columns[1:])
# Eliminar outliers con IQR
Q1 = df_prod["precio_kg"].quantile(0.25)
Q3 = df_prod["precio_kg"].quantile(0.75)
IQR = Q3 - Q1
df_prod = df_prod[
(df_prod["precio_kg"] >= Q1 - 1.5 * IQR) &
(df_prod["precio_kg"] <= Q3 + 1.5 * IQR)
]
Descomposición estacional (STL)
Los precios de alimentos tienen estacionalidad múltiple: patrones semanales, mensuales y estacionales. La descomposición STL separa tendencia, estacionalidad y residuos para que el modelo se enfoque en los patrones reales del precio.
from statsmodels.tsa.seasonal import STL
stl = STL(df_prod["precio_kg"], period=7) # estacionalidad semanal
resultado = stl.fit()
df_prod["tendencia"] = resultado.trend
df_prod["estacional"] = resultado.seasonal
df_prod["residuo"] = resultado.resid
resultado.plot()
Codificación cíclica de variables temporales
El día de semana 6 (domingo) es cercano al día 0 (lunes), pero numéricamente están lejos. La codificación cíclica con seno y coseno resuelve este problema de representación.
import numpy as np
df_prod["dia_semana"] = df_prod["fecha"].dt.dayofweek
df_prod["dia_sem_sin"] = np.sin(2 * np.pi * df_prod["dia_semana"] / 7)
df_prod["dia_sem_cos"] = np.cos(2 * np.pi * df_prod["dia_semana"] / 7)
df_prod["mes"] = df_prod["fecha"].dt.month
df_prod["mes_sin"] = np.sin(2 * np.pi * df_prod["mes"] / 12)
df_prod["mes_cos"] = np.cos(2 * np.pi * df_prod["mes"] / 12)
Construcción de lags y ventanas deslizantes
Los lags son versiones desplazadas de la serie en el tiempo. El lag-1 es el precio de ayer, el lag-7 es el precio de hace una semana. Son features fundamentales para modelos de series de tiempo.
for lag in [1, 2, 3, 7, 14, 21]:
df_prod[f"lag_{lag}"] = df_prod["precio_kg"].shift(lag)
df_prod["rolling_mean_7"] = df_prod["precio_kg"].rolling(7).mean()
df_prod["rolling_std_7"] = df_prod["precio_kg"].rolling(7).std()
df_prod["rolling_mean_14"] = df_prod["precio_kg"].rolling(14).mean()
df_prod = df_prod.dropna().reset_index(drop=True)
Normalización y construcción de secuencias
from sklearn.preprocessing import MinMaxScaler
features = ["precio_kg", "tendencia", "estacional",
"dia_sem_sin", "dia_sem_cos", "mes_sin", "mes_cos",
"lag_1", "lag_7", "rolling_mean_7", "rolling_std_7"]
scaler = MinMaxScaler()
data_scaled = scaler.fit_transform(df_prod[features])
WINDOW = 30
X, y = [], []
for i in range(WINDOW, len(data_scaled)):
X.append(data_scaled[i-WINDOW:i])
y.append(data_scaled[i, 0])
X = np.array(X) # shape: (muestras, 30, n_features)
y = np.array(y)
ModeladoEntrenamiento
Con los datos preparados y las secuencias construidas, se entrena primero un LSTM base y luego la arquitectura LSTM-MSNet que incorpora múltiples escalas temporales.
Split temporal
En series de tiempo no se usa split aleatorio. Se entrena con datos históricos y se valida con datos posteriores, simulando condiciones reales de predicción.
split = int(len(X) * 0.8)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
print(f"Train: {X_train.shape}, Test: {X_test.shape}")
Baseline: LSTM simple
Antes de usar arquitecturas complejas, se establece un baseline con un LSTM de una sola capa. Si el baseline ya funciona bien, no se necesita complejidad adicional.
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Dropout
modelo_base = Sequential([
LSTM(64, input_shape=(X_train.shape[1], X_train.shape[2])),
Dropout(0.2),
Dense(32, activation="relu"),
Dense(1)
])
modelo_base.compile(optimizer="adam", loss="mse", metrics=["mae"])
modelo_base.fit(
X_train, y_train,
epochs=50,
batch_size=32,
validation_split=0.1,
verbose=1
)
Arquitectura LSTM-MSNet (múltiples escalas)
LSTM-MSNet captura patrones a diferentes escalas temporales simultáneamente: corto plazo (días), mediano plazo (semanas) y largo plazo (meses). Tres ramas LSTM procesan diferentes resoluciones de la misma serie y se concatenan antes de la capa de salida.
from tensorflow.keras.models import Model
from tensorflow.keras.layers import (Input, LSTM, Dense, Dropout,
Concatenate, AveragePooling1D)
def construir_lstm_msnet(window, n_features):
inp = Input(shape=(window, n_features))
# Escala 1: resolución completa (diaria)
x1 = LSTM(64, return_sequences=False)(inp)
x1 = Dropout(0.2)(x1)
# Escala 2: submuestreo semanal
pool2 = AveragePooling1D(pool_size=7, strides=1, padding="same")(inp)
x2 = LSTM(32, return_sequences=False)(pool2)
x2 = Dropout(0.2)(x2)
# Escala 3: submuestreo mensual
pool3 = AveragePooling1D(pool_size=14, strides=1, padding="same")(inp)
x3 = LSTM(16, return_sequences=False)(pool3)
x3 = Dropout(0.2)(x3)
fusionado = Concatenate()([x1, x2, x3])
salida_inter = Dense(32, activation="relu")(fusionado)
salida = Dense(1)(salida_inter)
modelo = Model(inputs=inp, outputs=salida)
modelo.compile(optimizer="adam", loss="mse", metrics=["mae"])
return modelo
modelo_msnet = construir_lstm_msnet(WINDOW, X_train.shape[2])
modelo_msnet.fit(
X_train, y_train,
epochs=80,
batch_size=32,
validation_split=0.1,
callbacks=[
tf.keras.callbacks.EarlyStopping(patience=10, restore_best_weights=True)
],
verbose=1
)
modelo_msnet.save("models/lstm_msnet.h5")
Métricas y comparaciónEvaluación
Se evalúa tanto el baseline como LSTM-MSNet con métricas estándar de regresión. La desnormalización es obligatoria para interpretar los errores en unidades reales (pesos colombianos por kilogramo).
Código de evaluación
from sklearn.metrics import mean_absolute_error, mean_squared_error
def evaluar_modelo(modelo, X_test, y_test, scaler, n_features):
pred_scaled = modelo.predict(X_test).flatten()
# Desnormalizar solo la columna de precio (índice 0)
dummy = np.zeros((len(pred_scaled), n_features))
dummy[:, 0] = pred_scaled
pred = scaler.inverse_transform(dummy)[:, 0]
dummy2 = np.zeros((len(y_test), n_features))
dummy2[:, 0] = y_test
real = scaler.inverse_transform(dummy2)[:, 0]
mae = mean_absolute_error(real, pred)
rmse = np.sqrt(mean_squared_error(real, pred))
mape = np.mean(np.abs((real - pred) / real)) * 100
print(f"MAE: {mae:.2f} COP/kg")
print(f"RMSE: {rmse:.2f} COP/kg")
print(f"MAPE: {mape:.2f}%")
return real, pred
print("=== Baseline LSTM ===")
evaluar_modelo(modelo_base, X_test, y_test, scaler, len(features))
print("\n=== LSTM-MSNet ===")
evaluar_modelo(modelo_msnet, X_test, y_test, scaler, len(features))
Tabla comparativa de modelos
| Modelo | MAE (COP/kg) | RMSE (COP/kg) | MAPE (%) | Observaciones |
|---|---|---|---|---|
| Baseline LSTM | 180 | 240 | 8.2% | Buena captura de tendencia, falla en picos |
| LSTM-MSNet | 120 | 165 | 5.4% | Mejor captura de estacionalidad semanal |
| ARIMA (referencia) | 210 | 280 | 9.8% | Benchmark clásico sin features adicionales |
LSTM-MSNet supera al baseline en todas las métricas. Pasar de 8.2% a 5.4% de MAPE significa que las predicciones están, en promedio, a menos de 5.5% del precio real — suficientemente preciso para el módulo de optimización nutricional.
Optimización y conclusionesHallazgos
Módulo de optimización nutricional
Con los precios predichos para la próxima semana, se formula el problema de selección de recetas como optimización lineal: minimizar el costo total sujeto a que se cumplan los requerimientos nutricionales mínimos.
import pulp
recetas = {
"Sopa de papa": {"costo": 3200, "calorias": 320, "proteina": 8, "carbos": 60},
"Arroz con pollo": {"costo": 5500, "calorias": 480, "proteina": 35, "carbos": 55},
"Lentejas": {"costo": 2800, "calorias": 280, "proteina": 18, "carbos": 45},
"Ensalada mixta": {"costo": 1800, "calorias": 120, "proteina": 4, "carbos": 15},
"Huevos revueltos": {"costo": 2200, "calorias": 200, "proteina": 14, "carbos": 2},
}
req_calorias = 2000
req_proteina = 50
req_carbos = 250
prob = pulp.LpProblem("OptimizacionNutricional", pulp.LpMinimize)
x = {r: pulp.LpVariable(f"x_{r}", lowBound=0, cat="Integer") for r in recetas}
# Minimizar costo total
prob += pulp.lpSum(recetas[r]["costo"] * x[r] for r in recetas)
# Restricciones nutricionales
prob += pulp.lpSum(recetas[r]["calorias"] * x[r] for r in recetas) >= req_calorias
prob += pulp.lpSum(recetas[r]["proteina"] * x[r] for r in recetas) >= req_proteina
prob += pulp.lpSum(recetas[r]["carbos"] * x[r] for r in recetas) >= req_carbos
prob.solve(pulp.PULP_CBC_CMD(msg=0))
print(f"Costo óptimo: ${pulp.value(prob.objective):,.0f} COP")
for r in recetas:
if pulp.value(x[r]) > 0:
print(f" {r}: {int(pulp.value(x[r]))} porción(es)")
Hallazgos principales
La estacionalidad semanal domina la variación de precios
Los precios de papa, cebolla y tomate muestran un patrón semanal consistente: bajan los lunes y martes (mayor oferta en Corabastos) y suben hacia el fin de semana. Modelar esta estacionalidad con STL y codificación cíclica mejora el MAPE en ~2.5 puntos porcentuales.
LSTM-MSNet supera al LSTM simple en series con múltiple estacionalidad
Procesar la serie en tres escalas temporales paralelas y fusionarlas antes de la capa de salida mejora significativamente las métricas frente a un LSTM de escala única. La diferencia es más notable en la captura de picos de precio.
El optimizador reduce el costo entre 15-25% vs. selección manual
Comparando el menú sugerido por el optimizador contra menús seleccionados manualmente, el pipeline automatizado reduce el costo semanal entre 15 y 25% manteniendo los requerimientos nutricionales mínimos cubiertos.
La combinación predicción + optimización es la contribución central
Ninguno de los dos módulos por separado resuelve el problema real. Solo el pipeline integrado — predecir precios futuros y optimizar la selección de recetas con esos precios — entrega valor operacional. Esta es la contribución diferenciadora respecto a trabajos previos.
Limitaciones y trabajo futuro
- El modelo fue entrenado con datos de Corabastos Bogotá — la generalización a otros mercados requiere reentrenamiento.
- Las tablas nutricionales usadas son aproximadas — en producción se necesitan fuentes certificadas.
- No se incorporaron variables exógenas como clima o eventos macroeconómicos que afectan precios agrícolas.
- El optimizador asume precios constantes dentro de la semana — un modelo con actualización diaria sería más preciso.