Tecnología. Experimentos. Mis cosas

,

Hibridando Inteligencia Generativa y Predictiva

Pronosticar una carrera de caballos es un problema de datos complejo. Por un lado, tienes variables matemáticas puras (estadísticas históricas, ratios de victoria, distancia ideal). Por otro, hay factores puramente cualitativos y de contexto (si un caballo viene de correr en un lote más difícil, o si el cambio de montura es una declaración de intenciones del preparador).

Los modelos tradicionales de Machine Learning son excelentes con los números, pero ciegos al contexto. Los Large Language Models (LLMs) son brillantes leyendo el contexto, pero terriblemente malos haciendo matemáticas o calibrando probabilidades.

En GalopApp solucioné esto con un Pipeline de IA de tres capas, combinando un modelo estadístico (LightGBM) con Inteligencia Generativa (Gemini 2.0 Flash) y un algoritmo de Ensemble que pondera ambos. Vamos a destripar el código.

Capa 1: El Modelo Estadístico (LightGBM)

La primera capa es un modelo predictivo clásico. Extraemos 24 variables (features) por cada participante, como sus últimos resultados, días de descanso, afinidad a la pista y estadísticas recientes del jinete. Entrenamos un modelo LightGBM con más de 49.000 participaciones históricas.

En producción, la clave de este microservicio no es solo calcular la predicción, sino degradar con elegancia. Si por algún motivo el fichero del modelo falla al cargarse desde el almacenamiento (MinIO), el sistema no lanza un error 500, sino que devuelve una distribución uniforme de probabilidades para no bloquear la aplicación.

Así luce el código en services/lightgbm_service.py:

pythondef predict_race(    feature_dicts: List[dict],    feature_cols: List[str],    categorical_cols: List[str],) -> List[float]:    """    Dada la lista de variables por corredor, devuelve las probabilidades de victoria.    Hace fallback a una distribución uniforme si el modelo no está disponible.    """    n = len(feature_dicts)    if n == 0: return []    model = get_model()    if model is None:        logger.warning("Modelo LightGBM no disponible — devolviendo probs uniformes")        return [round(1.0 / n, 4)] * n    df = pd.DataFrame(feature_dicts, columns=feature_cols)    # ... formateo de categoricas ...    raw_probs = model.predict(df)    # Normalizamos para que la suma exacta de la carrera sea 1.0 (100%)    total = raw_probs.sum()    if total > 0:        probs = [round(float(p / total), 4) for p in raw_probs]    else:        probs = [round(1.0 / n, 4)] * n    return probs

Capa 2: Análisis Cualitativo con Gemini

La segunda capa usa a Gemini 2.0 Flash como nuestro «experto» virtual. Pero enviar el historial de la carrera a un LLM no es simplemente volcar un JSON de base de datos; requiere una ingeniería de prompt meticulosa.

Aquí entra en juego el AIContextBuilder, una de las piezas más elaboradas del backend.

El arte del Prompt: Facilitando el razonamiento al LLM

En lugar de dejar que el LLM deduzca patrones complejos, se los masticamos para facilitarle el razonamiento. Algunas de las técnicas que usamos en la construcción del contexto:

  1. Marcadores visuales de afinidad: Cuando el builder genera el historial de carreras de un caballo, comprueba si la distancia o la pista de una carrera pasada coinciden con la de hoy. Si es así, le inyecta etiquetas como ✓dist o ✓sup. Esto permite decirle al sistema en el System Prompt«Prioriza carreras marcadas con ✓dist y/o ✓sup».
  2. Análisis de Tendencias (Valor Streak): En lugar de darle una lista de números sueltos del hándicap, el builder calcula la tendencia matemática y se la pasa traducida a lenguaje natural: 85 → 83 → 82 (↘️ En descenso).
  3. Enfrentamientos Directos (Head-to-Head): Los LLMs sufren cruzando tablas. El AIContextBuilder ejecuta una subquery explícita que busca carreras pasadas donde 2 o más caballos de la carrera de hoy se enfrentaron, y le agrupa el resultado para que el LLM sepa quién batió a quién.
  4. Anti-leakage: Las estadísticas de jockeys y entrenadores se precomputan filtrando estrictamente is_upcoming == False y comprobando que la fecha es anterior a la carrera analizada. Así evitamos pasarle datos «del futuro» al evaluar carreras pasadas.

Forzando la estructura («Structured Outputs»)

El gran problema con los LLMs es asegurar que la respuesta siga un formato estricto y parseable. En la variante más moderna de nuestro prompt (v3_reasoning), forzamos al modelo a usar Structured Outputs pasándole un esquema Pydantic nativo.

Este es el esquema exacto de salida que le pedimos a la API:

pythonfrom pydantic import BaseModel, Fieldfrom typing import Listclass RunnerProbability(BaseModel):    horse_id: int = Field(description="ID del caballo")    win_probability: float = Field(description="Probabilidad de victoria (0-1)")    place_probability: float = Field(description="Probabilidad de quedar colocado")class RacePredictionOutput(BaseModel):    analisis_tecnico: str = Field(description="Contexto de la carrera y análisis")    los_protagonistas: List[ProtagonistaAnalysis] = Field(description="Análisis favoritos")    pronostico: PronosticoDetalle    runner_probabilities: List[RunnerProbability] = Field(description="Probabilidades")

Al pasarle este esquema, Gemini nos devuelve siempre un JSON impecable con sus propias «probabilidades expertas», que luego nosotros renderizamos a Markdown para guardar en base de datos. Se acabó pelear con expresiones regulares fallidas.

Capa 3: El Ensemble (La fusión)

Finalmente, tenemos una probabilidad matemática (LightGBM) y una probabilidad basada en heurística cualitativa (Gemini). ¿Con cuál nos quedamos? Con ambas.

El servicio de Ensemble junta los dos mundos calculando una combinación lineal. Le damos un peso específico al LLM (w_llm, por defecto 0.6) y el resto a LightGBM.

El núcleo matemático del servicio en PredictionService._compute_ensemble es sorprendentemente simple:

pythonw_llm = 0.6  # Peso de la inteligencia generativaw_lgbm = 0.4 # Peso del machine learning tradicionalensemble = []for runner in runners:    # Probabilidad del LLM    llm_p = llm_lookup.get(runner.horse_id)    llm_win = llm_p.get("win_probability", 0) if llm_p else 0        # Probabilidad estadística (Machine Learning puro)    lgbm_win = runner.ml_win_probability or 0        # Fusión ponderada    combined_win = (w_llm * llm_win) + (w_lgbm * lgbm_win)        ensemble.append({        "horse_id": runner.horse_id,        "win_probability": round(combined_win, 4)    })# Renormalizamos el ensemble para que siga sumando exactamente 1.0total = sum(e["win_probability"] for e in ensemble)for e in ensemble:    e["win_probability"] = round(e["win_probability"] / total, 4)# Guardamos el ensemble resultante en la base de datosself._write_ensemble(ensemble)

Conclusión

Al hibridar IA generativa con ML clásico evitamos lo peor de ambos mundos. Si el LLM tiene una alucinación matemática puntual, LightGBM actúa como ancla de cordura tirando de la probabilidad hacia lo estadísticamente sensato. Si hay un matiz crucial (como un cambio radical de distancia o una racha brutal documentada en el Head-to-Head), el LLM capta el contexto e inclina la balanza a su favor.

Pero, ¿cómo sabemos si esto funciona? En el próximo artículo explicaré cómo he montado un sistema de Trazabilidad y A/B Testing de Prompts para evaluar automáticamente si esta versión «V3» acierta más o menos carreras que la versión antigua o que el modelo matemático puro.