El secreto de la optimización reproducible y portátil: la representación intermedia (IR) de ORPilot

En mi publicación anterior, mencioné cuatro innovaciones principales que hacen de ORPilot una herramienta LLM para OR de código abierto orientada a la producción, a saber, agente de entrevistas, agente de recopilación de datos, agente de cálculo de parámetros y representación intermedia (IR). Entre las cuatro innovaciones, el IR es la más importante que diferencia a ORPilot de un prototipo académico y le otorga el potencial de ser una herramienta a nivel de producción, ya que aborda dos cuestiones que más preocupan a un entorno de producción: reproducibilidad y portabilidad. En esta publicación, le brindaré una inmersión profunda en la estructura de IR de ORPilot.

¿Qué es la infrarrojos?

Hay un problema del que casi nadie habla cuando se habla de modelos de optimización generados por IA: ¿qué sucede después de la primera solución?

Consigues que tu modelo funcione. Obtienes una solución óptima. Y luego, tres semanas después, deberá volver a ejecutarlo con datos de demanda actualizados. O su colega en una máquina diferente necesita reproducir el resultado. O su empresa decide cambiar de Gurobi a un solucionador de código abierto debido a los costos de licencia. O quiere preguntar "¿y si aumentamos la capacidad de una instalación en un 20%?" Con la mayoría de las herramientas LLM-for-OR existentes, la respuesta a todas estas preguntas es la misma: debe comenzar de nuevo, llamar al LLM nuevamente, pagar el costo de la API nuevamente, generar el código de solución nuevamente y esperar obtener la misma estructura de modelo. Sin embargo, el agente de modelado de optimización de IA de código abierto ORPilot proporciona una solución alternativa a este problema: la representación intermedia (IR).

El IR es un esquema JSON escrito, independiente del solucionador, que captura la estructura matemática completa de un modelo de optimización. No el código de optimización, sino el modelo en sí, expresado en una forma independiente de cualquier solucionador en particular.

La estructura IR de ORPilot tiene cinco secciones de nivel superior.

(1) Conjuntos: colecciones de entidades con nombre, como Trabajadores, Tareas, Plantas, Períodos. Cada conjunto sabe de dónde provienen sus miembros: un archivo CSV, un recuento escalar o una lista codificada.

(2) Parámetros: datos numéricos indexados de archivos CSV, cada uno vinculado a su dominio (que establece el índice) y a los nombres exactos de las columnas necesarias para cargarlo.

(3) Variables: variables de decisión con tipo (continua, binaria, entera), dominio, límites y indicadores estructurales.

(4) Objetivo: un árbol de expresión simbólica sobre variables y parámetros: sumas, diferencias, productos, sumas indexadas en forma neutral para el solucionador.

(5) Restricciones: restricciones simbólicas nombradas con dominios, árboles de expresión y sentido (<= o = o >=). Cada restricción es un objeto completo que se describe a sí mismo.

Hagamos esto concreto observando un problema específico de asignación de tareas de trabajadores a continuación.

Ejemplo de problema de asignación de tareas-trabajador

En este problema, se deben asignar cuatro trabajadores a cuatro tareas, una tarea por trabajador, un trabajador por tarea. Cada par (trabajador, tarea) tiene un costo de un archivo CSV. Intentamos minimizar el coste total de la asignación. Este es un problema de asignación clásico, que es un programa de números enteros.

Los datos se encuentran en dos archivos:
(1) sets.csv (todos los miembros del conjunto en un solo lugar):
elemento set_name
trabajadores w1
trabajadores w2
trabajadores w3
trabajadores w4
tareas t1
tareas t2
tareas t3
tareas t4
(2) task_costs.csv (la matriz de costos):
cost_id_trabajador_id_tarea
w1 t1 2.0
w1 t2 4.0
…… …
Aquí está el IR completo para este problema:

{ "problem_class": "AssignmentProblem", "model_type": "Programa entero mixto", "sense": "minimize", "sets": { "Workers": { "size": null, "index_symbol": "w", "source": "sets.csv", "column": "element", "filter_column": "set_name", "filter_value": "workers", "ordered": false }, "Tareas": { "tamaño": nulo, "index_symbol": "t", "fuente": "sets.csv", "columna": "elemento", "filtro_columna": "nombre_conjunto", "valor_filtro": "tareas", "ordenado": falso } }, "parámetros": { "costo_asignación": { "dominio": ["Trabajadores", "Tareas"], "tipo": "flotante", "fuente": "assignment_costs.csv", "column": "cost", "index_columns": ["worker_id", "task_id"], "missing_default": "inf" } }, "variables": { "assign": { "description": "1 si el trabajador w está asignado a la tarea t, 0 en caso contrario", "label": "assignments", "domain": ["Trabajadores", "Tareas"], "type": "binary", "lower_bound": 0, "upper_bound": 1, "upper_bound_set": null, "exclude_diagonal": false, "domain_filter": null } }, "constraints": { "one_task_per_worker": { "domain": ["Trabajadores"], "expresión": { "operación": "indexed_sum", "over": ["Tareas:t"], "body": {"tipo": "variable", "nombre": "asignar", "índices": ["w", "t"]} }, "sentido": "=", "rhs": {"tipo": "constante", "valor": 1} }, "un_trabajador_por_tarea": { "dominio": ["Tareas"], "expresión": { "operación": "indexed_sum", "over": ["Trabajadores:w"], "cuerpo": {"tipo": "variable", "nombre": "asignar", "índices": ["w", "t"]} }, "sentido": "=", "rhs": {"tipo": "constante", "valor": 1} } }, "objetivo": { "sentido": "minimizar", "expresión": { "operación": "indexed_sum", "over": ["Trabajadores:w", "Tareas:t"], "cuerpo": { "operación": "multiplicar", "izquierda": {"tipo": "parámetro", "nombre": "costo_asignación", "índices": ["w", "t"]}, "derecha": {"tipo": "variable", "nombre": "asignar", "índices": ["w", "t"]} } } } }

Analicemos qué hace cada sección y por qué se tomaron las decisiones de diseño.

Conjuntos

El campo "conjuntos" indica de dónde provienen los miembros del conjunto. La decisión de diseño más importante en "conjuntos" es la convención de fuente de datos. ORPilot requiere que todos los miembros del conjunto vivan en un único archivo llamado sets.csv, utilizando un formato de dos columnas: "set_name" y "element". Cada conjunto: entidades (trabajadores, tareas, plantas) y conjuntos de tiempo (períodos, meses) es una porción filtrada de este archivo. En este problema, el campo "Trabajadores" dice: cargue miembros desde sets.csv, lea la columna "elemento", mantenga solo las filas donde la columna "nombre_conjunto" sea igual a "trabajadores". El resultado en el momento de la compilación será Workers = [“w1”, “w2”, “w3”, “w4”].

Esta convención tiene dos beneficios. En primer lugar, todos los datos maestros están en un solo lugar. Agregar un trabajador significa agregar una fila a sets.csv, no modificar varios archivos. En segundo lugar, el campo "filter_value" se verifica con los valores distintos reales en sets.csv en el momento de la generación de IR, detectando errores tipográficos antes de que el código del solucionador produzca conjuntos vacíos. El campo “index_symbol” (“w” para Trabajadores, “t” para Tareas) es el nombre de la variable de bucle que aparecerá en el código del solucionador compilado, por ejemplo, “para w en Trabajadores, para t en Tareas”. Debe elegirse para evitar conflictos de símbolos entre bucles anidados (consulte la regla de sombra a continuación). El campo "ordenado" es falso para ambos conjuntos aquí, pero se vuelve crítico para los modelos indexados en el tiempo. Un conjunto ordenado admite referencias de retraso temporal, por ejemplo, hacer referencia al inventario[t-1] desde dentro de una restricción de período-t.

Parámetros

El campo "parámetros" vincula los datos al modelo. El parámetro “assignment_cost” tiene seis campos estructurales.

(1) “dominio”: [“Trabajadores”, “Tareas”]: este parámetro está indexado por ambos conjuntos, lo que produce una tabla 2D.

(2) “tipo”: “flotante”: el tipo de datos de este parámetro es flotante.

(3) “fuente”: “assignment_costs.csv”: el nombre de archivo exacto (con extensión) que contiene los datos.

(4) “columna”: “costo”: la columna CSV que contiene los valores numéricos que se van a cargar.

(5) “index_columns”: [“worker_id”, “task_id”]: las columnas CSV que sirven como claves, en el mismo orden que “dominio”. El campo "index_columns" es una de las partes más importantes del IR. Sin él, el compilador no puede determinar qué columnas del CSV corresponden a qué conjuntos de dominios. Históricamente, un modo de falla común era que el compilador adivinara el nombre de la columna clave incorrecta y cargara silenciosamente los datos incorrectos. El IR exige que los nombres de columna correctos siempre se proporcionen explícitamente.

(6) “missing_default”: “inf”: le dice al compilador que cualquier par (trabajador, tarea) que no esté presente en el CSV debe considerarse como si tuviera un costo infinito, lo que significa que esa ruta no está disponible. Esta es la semántica correcta para los parámetros de costos y penalizaciones.

variables

El campo “variables” define las decisiones a tomar en el modelo de optimización. La variable “asignar” es binaria, indexada sobre “dominio”: [“Trabajadores”, “Tareas”]. De modo que en el momento de la compilación, el compilador compila (suponiendo que utilice el solucionador PuLP):

asignar = {(w, t): pulp.LpVariable(f"assign_{w}_{t}", cat="Binary") para w en Trabajadores para t en Tareas}

Algunos indicadores estructurales clave que no se utilizan aquí pero que vale la pena comprender son "exclude_diagonal", "domain_filter" y "upper_bound_set".

Para variables indexadas en el mismo conjunto dos veces, como "arc[Ubicación, Ubicación]" en un modelo de enrutamiento, configurar "exclude_diagonal=true" le indica al compilador que omita la diagonal (i, i). Ningún lugar viaja hacia sí mismo. El compilador emite un

si l1 == l2: continuar

guard y usa ".get(key, 0)" para todos los accesos, por lo que las claves faltantes nunca causan "KeyError".

Cuando una tabla de costos tiene menos filas que el producto cartesiano completo de sus conjuntos de dominios (por ejemplo, solo existen rutas válidas en el CSV), establecer "domain_filter" en el nombre de ese parámetro restringe la variable solo a esas combinaciones. El compilador emite la comprensión con "if (i, j) in transport_cost", por lo que las rutas inexistentes nunca se crean como variables.

Para variables enteras cuyo límite superior natural es la cardinalidad de un conjunto (por ejemplo, variables de posición MTZ en eliminación de subtour), configurar “upper_bound_set”=”Clientes” hace que el compilador emita “len(Clientes)” como límite superior, manteniendo los datos del modelo independientes incluso cuando el tamaño del conjunto varía entre ejecuciones.

Restricciones

Las “restricciones” contienen árboles de expresión que describen las restricciones definidas para este modelo. Aquí es donde el IR difiere más marcadamente de un archivo de código. Las restricciones no se almacenan como cadenas o código, sino que son árboles de expresión. Cada restricción tiene: (1) “dominio”: los conjuntos que el compilador recorrerá para generar una instancia de restricción por combinación. Por ejemplo, “dominio”: [“Trabajadores”] significa una restricción por trabajador. (2) “expresión”: el lado izquierdo, como un árbol recursivo de nodos. (3) sentido: el signo de esta restricción, “=" o "<=" o ">=". (4) “rhs”: el lado derecho, también un árbol de expresión (pero que contiene solo constantes y parámetros, nunca variables, que deben moverse al LHS). Veamos de cerca la restricción “one_task_per_worker”.

"one_task_per_worker": { "dominio": ["Trabajadores"], "expresión": { "operación": "indexed_sum", "over": ["Tareas:t"], "cuerpo": {"tipo": "variable", "nombre": "asignar", "índices": ["w", "t"]} }, "sentido": "=", "rhs": {"tipo": "constante", "valor": 1} },

En el nodo "expresión" anterior, el campo "sobre" usa el alias "Tareas:t" para nombrar explícitamente la variable de bucle "t" para esta suma interna. Esto es necesario porque "t" ya es el símbolo_índice del conjunto de Tareas, y cuando el dominio de restricción externo no incluye Tareas, el compilador no tendrá una "t" en el alcance, pero el alias obliga a que exista dentro de la suma. Siempre que un conjunto en “over” ya aparezca en el dominio de la restricción (con el mismo index_symbol), use un alias para evitar ensombrecer la variable del bucle externo. De lo contrario, la “t” interna ensombrecería a la “t” externa, y la suma siempre calcularía asignar[t, t] (una diagonal de bucle automático) en lugar de la suma prevista.

Objetivo

En el IR, el objetivo se escribe como se muestra a continuación.

"objetivo": { "sentido": "minimizar", "expresión": { "operación": "indexed_sum", "over": ["Trabajadores:w", "Tareas:t"], "cuerpo": { "operación": "multiplicar", "izquierda": {"tipo": "parámetro", "nombre": "costo_asignación", "índices": ["w", "t"]}, "derecha": {"tipo": "variable", "nombre": "asignar", "índices": ["w", "t"]} } } }

La “suma_indexada” externa itera sobre Trabajadores y Tareas simultáneamente, utilizando los alias “Trabajadores:w” y “Tareas:t” para nombrar explícitamente ambas variables de bucle. El cuerpo es un nodo multiplicador, parámetro × variable, que es la única forma de multiplicación que permite el IR en un modelo lineal. El resultado es un término por par (trabajador, tarea), sumado al costo total.

Ésta es la forma objetiva más simple: una única suma indexada. Los objetivos más complejos combinan múltiples sumas indexadas mediante resta. Digamos que el modelo tenía tanto un costo de asignación como una bonificación para ciertas asignaciones: maximizar suma(bonificación[w,t] × asignar[w,t]) – suma(costo[w,t] × asignar[w,t]). Eso estaría codificado como:

sustraer(
suma_indexada(sobre Trabajadores,Tareas: bonificación[w,t] × asignar[w,t]),
suma_indexada(sobre trabajadores, tareas: costo[w,t] × asignar[w,t])
)

Una regla fundamental sobre la resta: nunca anide una resta en el lado derecho de otra resta. Debido a que restar es una operación binaria, izquierda menos derecha, poner otra resta a la derecha invierte el signo del término interno:

restar(A, restar(B, C))
= A – (B – C)
= A – B + C ← Se suponía que C se restaría pero termina AÑADIDO

Digamos que el objetivo son ingresos – costo_envío – costo_mantenimiento. Un modo de falla común de los LLM es que a veces agrupan los dos costos a la derecha:

restar(ingresos, restar(coste_envío, costo_mantenimiento))
= ingresos – (coste_envío – costo_mantenimiento)
= ingresos – costo_envío + costo_mantenimiento

Esto es incorrecto ya que el costo de mantener el producto se convierte en un ingreso. El modelo aún se ejecuta y el solucionador aún devuelve "óptimo", pero el valor objetivo es
incorrecto, inflado en 2 × holding_cost. La forma correcta es una cadena plana de izquierda a derecha:

restar(resta(ingresos, costo_envío), costo_mantenimiento)
= (ingresos – costo_envío) – costo_mantenimiento
= ingresos – costo_envío – costo_mantenimiento

ORPilot tiene un validador semántico de IR que detecta el patrón de anidamiento del lado derecho antes de la compilación y nombra el término específico cuyo signo se invirtió, para que el LLM pueda corregir el orden de la cadena.

Del IR al código Solver

El compilador IR es una pieza de software determinista, no implica ningún LLM. Dado el mismo ir.json y los mismos archivos de datos CSV, siempre produce un código de resolución idéntico. Siempre. Actualmente, el compilador admite cinco backends: PuLP, Pyomo, OR-Tools, Gurobi y CPLEX. Cambiar de backend no requiere cambios de modelo. El IR es el mismo; sólo cambia el objetivo de compilación. Esto significa que puede archivar ir.json junto con sus datos y reproducir exactamente cualquier resultado anterior, sin realizar una sola llamada a la API. Puede cambiar de Gurobi a PuLP ejecutando: orpilot compile-ir output/ir.json –solver pulp –run. Un comando, cero llamadas LLM, misma estructura de modelo. Puede ejecutar la validación de CI/CD en los resultados del solucionador confirmando ir.json y ejecutando el compilador en su canalización. Puede compartir ir.json con un colega en una máquina diferente y él podrá resolver el mismo modelo sin necesitar su clave API LLM o incluso comprender el problema desde cero.

El canal de compilación de IR

Una vez que tenga un ir.json validado, ORPilot ofrece un proceso de compilación liviano: ir.json + Datos CSV → Compilador IR → Código Solver → Ejecución de código. Este proceso no implica ninguna llamada de LLM de un extremo a otro. Es rápido, barato y totalmente determinista. La única llamada LLM en todo el flujo de trabajo fue la que produjo ir.json en primer lugar. El comando CLI es: orpilot compilar-ir salida/ir.json –run. Eso compila el IR, ejecuta el modelo y genera un informe de solución. Para cambiar de solucionador: orpilot compilar-ir salida/ir.json –solver pyomo –run.

El validador semántico de infrarrojos

Antes de guardar y compilar un IR, ORPilot ejecuta un validador semántico que detecta errores de modelado que son JSON estructuralmente válidos pero matemáticamente incorrectos. Actualmente, el validador detecta tres categorías principales, que son modos de falla comunes del LLMS durante los experimentos.

1. Errores de signo de saldo de inventario. Detecta cuando todas las variables de flujo en una restricción de equilibrio terminan en el mismo lado (por ejemplo, inv = entrada + salida en lugar de inv = entrada – salida). La identidad correcta es: inv_final = inv_inicial + flujo de entrada – flujo de salida. Las violaciones de esto producen modelos que son inviables (el caso excesivamente restringido) o ilimitados (el caso insuficientemente restringido), y el error de signo es casi imposible de detectar en el código compilado.

2. Falta la restricción de inicio. Si existe una restricción de equilibrio de retraso temporal, el validador requiere una variante "_init" correspondiente que represente la restricción en el período de tiempo inicial. Una restricción inicial faltante podría dejar el primer período sin restricciones, produciendo un modelo ilimitado incluso cuando la restricción del período posterior sea correcta.

3. Resta anidada en objetivo. A veces, el LLM del constructor de IR escribiría restar(A, restar(B, C)) mientras tiene la intención de restar secuencialmente los costos B y C de los ingresos A. Sin embargo, matemáticamente esta expresión se evalúa como A – (B – C) = A – B + C, invirtiendo el signo de C de costo a ingreso. El modelo aún se resuelve en "óptimo", pero el valor objetivo está inflado en 2 × C. El validador detecta el anidamiento en el lado derecho y nombra el término afectado para que el LLM pueda reescribir el objetivo como una cadena plana de izquierda a derecha.

Cuando la validación falla, el mensaje de error específico se devuelve al LLM como un mensaje de reintento específico. El LLM no ve "IR no válido", pero ve un mensaje como "error de signo de balance_inventario: la descarga variable parece ser negativa (coeficiente -1) pero debe restarse del flujo de entrada, no agregarse".

Por qué la IR es importante para el análisis hipotético

Las propiedades de reproducibilidad y portabilidad del IR tienen una extensión natural: el análisis sistemático de hipótesis. Una vez que se resuelve un modelo y se guarda su IR, un usuario empresarial normalmente desea explorar cómo cambia la solución óptima bajo diferentes supuestos. ¿Qué pasa si la demanda aumenta un 20% en el tercer trimestre? ¿Qué pasa si el costo de la materia prima aumenta a $15 por unidad? ¿Qué pasa si agregamos la restricción de que ningún proveedor represente más del 40% del total de adquisiciones? La estructura de IR hace que dos categorías de consultas hipotéticas sean trivialmente baratas. La primera categoría son los cambios de datos. Si la pregunta solo modifica los valores de los parámetros (dejando intacta la estructura del modelo), solo necesita actualizar los archivos CSV. El IR JSON no ha cambiado. Ejecute el compilador
contra los nuevos datos y volver a resolver. Esta es una operación de llamada cero LLM. Puede ejecutar cientos de escenarios de esta manera sin costo de API.

La segunda categoría son los cambios estructurales. Si la pregunta modifica una restricción, agrega una nueva o cambia el objetivo, edita el IR JSON directamente. Dado que el IR es un documento mecanografiado y validado por esquema con un árbol de expresión bien definido, dichas ediciones están localizadas. Agregar una restricción es cuestión de agregar un nuevo objeto de restricción, pero no buscar entre cientos de líneas.
de código específico del solucionador tratando de encontrar dónde realizar el cambio.
Esta es una relación cualitativamente diferente con su modelo de optimización que la que ofrece cualquier otra herramienta existente. En lugar de un artefacto de una sola vez, tiene una estructura de modelo viva y editable que puede interrogar y modificar independientemente del LLM.

El panorama más amplio

El IR aborda algo fundamental sobre la relación entre la IA y el software de producción: los resultados de la IA deben ser verificables, portátiles y duraderos. Un archivo de código de resolución generado por un LLM es un blob opaco. Si algo anda mal, necesita el LLM para solucionarlo. Si desea cambiar algo, comprende la sintaxis de la API del solucionador lo suficientemente bien como para editarlo usted mismo o llama nuevamente al LLM. El modelo vive sólo como código. El IR desacopla la inteligencia de modelado (que requiere un LLM) del paso computacional (que no requiere un LLM). El trabajo del LLM es producir un artefacto JSON limpio y estructurado. Una vez que ese artefacto existe y se valida, es de su propiedad, no del LLM. Esta elección de diseño, más que cualquier otra cosa en ORPilot, es lo que lo hace adecuado para la implementación de producción en lugar de la demostración académica.