Un modèle d'optimisation qui fonctionne aujourd'hui doit encore fonctionner dans trois mois. ORPilot a la solution : son langage IR, une recette magique pour éviter les bugs et les galères.

QUAND VOTRE MODÈLE D'OPTIMISATION DEVIENT UN CAUCHEMAR

Vous lancez votre modèle d'optimisation préféré. Tout fonctionne parfaitement : l'algorithme trouve la meilleure solution, vous êtes soulagé. Trois semaines plus tard, tout s'écroule. Il faut relancer le modèle avec de nouvelles données de demande. Ou pire : votre collègue sur un autre ordinateur ne parvient pas à reproduire vos résultats. Pire encore : votre entreprise change de solveur (le logiciel qui résout le modèle) à cause des coûts de licence. Ou alors vous voulez tester un scénario : "Que se passe-t-il si on augmente la capacité d'une usine de 20 % ?".

Avec la plupart des Outils actuels, la réponse est toujours la même : il faut tout recommencer. Rappeler l'intelligence artificielle pour générer à nouveau le code du solveur, payer à nouveau l'API, croiser les doigts en espérant que la structure du modèle reste identique. Mais ORPilot, un outil open source pour la modélisation d'optimisation par IA, propose une alternative radicale : son langage IR.

Avec ORPilot, plus besoin de rappeler l'IA à chaque modification. Le modèle reste identique, où que vous soyez et quel que soit le solveur utilisé.

LE LANGAGE IR : LA CLÉ DE LA REPRODUCTIBILITÉ ET DE LA PORTABILITÉ

Le langage IR (Intermediate Representation) est un schéma JSON typé, indépendant du solveur, qui capture la structure mathématique complète d'un modèle d'optimisation. Pas le code du solveur, mais le modèle lui-même, exprimé dans une forme qui ne dépend d'aucun solveur particulier.

Le langage IR d'ORPilot se compose de cinq sections principales :

1. Les ensembles (Sets) : des collections nommées d'entités comme des travailleurs, des tâches, des usines ou des périodes. Chaque ensemble sait d'où viennent ses membres : un fichier CSV, un nombre fixe, ou une liste codée en dur.

2. Les paramètres (Parameters) : des données numériques indexées, extraites de fichiers CSV, chacune liée à son domaine (quels ensembles l'indexent) et aux noms exacts des colonnes nécessaires pour les charger.

3. Les variables (Variables) : les décisions à prendre dans le modèle, avec leur type (continue, binaire, entière), leur domaine, leurs bornes et des drapeaux structurels.

4. L'objectif (Objective) : un arbre d'expressions symboliques sur les variables et les paramètres — des sommes, des différences, des produits, des sommes indexées, le tout sous forme neutre pour le solveur.

5. Les contraintes (Constraints) : des contraintes nommées avec leurs domaines, leurs arbres d'expressions et leur sens (<=, = ou >=). Chaque contrainte est un objet complet et auto-descriptif.

Le langage IR ne décrit pas le code du solveur, mais la structure mathématique du modèle. C'est comme donner la recette d'un gâteau au lieu de juste la photo du résultat final.

UN EXEMPLE CONCRET : LE PROBLÈME D'AFFECTATION

Prenons un exemple simple pour illustrer. Quatre travailleurs doivent être affectés à quatre tâches. Chaque travailleur ne peut faire qu'une seule tâche, et chaque tâche ne peut être faite que par un seul travailleur. Chaque paire (travailleur, tâche) a un coût défini dans un fichier CSV. L'objectif est de minimiser le coût total d'affectation. C'est un problème classique d'affectation, qui relève de la programmation en nombres entiers.

Les données sont stockées dans deux fichiers :

sets.csv (tous les membres des ensembles au même endroit) :

set_name,element
workers,w1
workers,w2
workers,w3
workers,w4
tasks,t1
tasks,t2
tasks,t3
tasks,t4

assignment_costs.csv (la matrice des coûts) :

workerid,taskid,cost
w1,t1,2.0
w1,t2,4.0
w1,t3,5.0
w1,t4,3.0
w2,t1,3.0
w2,t2,2.0
w2,t3,4.0
w2,t4,5.0
w3,t1,4.0
w3,t2,3.0
w3,t3,2.0
w3,t4,4.0
w4,t1,5.0
w4,t2,4.0
w4,t3,3.0
w4,t4,2.0

Voici le fichier IR complet pour ce problème :

{
  "problem_class": "AssignmentProblem",
  "model_type": "Mixed Integer Program",
  "sense": "minimize",
  "sets": {
    "Workers": {
      "size": null,
      "index_symbol": "w",
      "source": "sets.csv",
      "column": "element",
      "filtercolumn": "setname",
      "filter_value": "workers",
      "ordered": false
    },
    "Tasks": {
      "size": null,
      "index_symbol": "t",
      "source": "sets.csv",
      "column": "element",
      "filtercolumn": "setname",
      "filter_value": "tasks",
      "ordered": false
    }
  },
  "parameters": {
    "assignment_cost": {
      "domain": ["Workers", "Tasks"],
      "type": "float",
      "source": "assignment_costs.csv",
      "column": "cost",
      "indexcolumns": ["workerid", "task_id"],
      "missing_default": "inf"
    }
  },
  "variables": {
    "assign": {
      "description": "1 si le travailleur w est affecté à la tâche t, 0 sinon",
      "label": "assignments",
      "domain": ["Workers", "Tasks"],
      "type": "binary",
      "lower_bound": 0,
      "upper_bound": 1,
      "upperboundset": null,
      "exclude_diagonal": false,
      "domain_filter": null
    }
  },
  "constraints": {
    "onetaskper_worker": {
      "domain": ["Workers"],
      "expression": {
        "operation": "indexed_sum",
        "over": ["Tasks:t"],
        "body": {"type": "variable", "name": "assign", "indices": ["w", "t"]}
      },
      "sense": "=",
      "rhs": {"type": "constant", "value": 1}
    },
    "oneworkerper_task": {
      "domain": ["Tasks"],
      "expression": {
        "operation": "indexed_sum",
        "over": ["Workers:w"],
        "body": {"type": "variable", "name": "assign", "indices": ["w", "t"]}
      },
      "sense": "=",
      "rhs": {"type": "constant", "value": 1}
    }
  },
  "objective": {
    "sense": "minimize",
    "expression": {
      "operation": "indexed_sum",
      "over": ["Workers:w", "Tasks:t"],
      "body": {
        "operation": "multiply",
        "left":  {"type": "parameter", "name": "assignment_cost", "indices": ["w", "t"]},
        "right": {"type": "variable",  "name": "assign",          "indices": ["w", "t"]}
      }
    }
  }
}

DÉCORTIQUER LE FICHIER IR : CHAQUE SECTION A SON RÔLE

Analysons maintenant chaque section du fichier IR et comprenons pourquoi chaque choix de conception a été fait.

Les ensembles (Sets) : où viennent les membres ?

La section "sets" indique d'où viennent les membres des ensembles. La décision de conception la plus importante ici est la convention de source de données. ORPilot impose que tous les membres des ensembles vivent dans un seul fichier appelé sets.csv, avec un format à deux colonnes : "set_name" et "element".

Chaque ensemble — qu'il s'agisse d'entités (travailleurs, tâches, usines) ou de séries temporelles (périodes, mois) — est une tranche filtrée de ce fichier. Dans notre exemple, le champ "Workers" indique : charger les membres depuis sets.csv, lire la colonne "element", et ne garder que les lignes où la colonne "set_name" vaut "workers". Le résultat, au moment de la compilation, sera Workers = ["w1", "w2", "w3", "w4"].

Cette convention offre deux avantages majeurs. D'abord, toutes les données maîtresses sont au même endroit. Ajouter un travailleur signifie ajouter une ligne dans sets.csv, pas modifier plusieurs fichiers. Ensuite, le champ "filter_value" est vérifié contre les valeurs distinctes réelles dans sets.csv au moment de la génération du IR, ce qui permet de repérer les fautes de frappe avant que le code du solveur ne produise des ensembles vides.

Le champ "index_symbol" ("w" pour Workers, "t" pour Tasks) est le nom de la variable de boucle qui apparaîtra dans le code du solveur compilé, par exemple : "for w in Workers, for t in Tasks". Il doit être choisi pour éviter les conflits de symboles dans les boucles imbriquées. Le champ "ordered" est à false dans notre exemple, mais il devient crucial pour les modèles indexés temporellement. Un ensemble ordonné supporte les références de décalage temporel, par exemple : référencer inventory[t-1] depuis une contrainte de période t.

Les paramètres (Parameters) : le lien entre les données et le modèle

La section "parameters" lie les données au modèle. Le paramètre "assignment_cost" possède six champs structurels :

1. domain : ["Workers", "Tasks"] — ce paramètre est indexé par les deux ensembles, produisant un tableau à deux dimensions.

2. type : "float" — le type de données de ce paramètre est un nombre décimal.

3. source : "assignment_costs.csv" — le nom exact du fichier (avec extension) qui contient les données.

4. column : "cost" — la colonne du CSV qui contient les valeurs numériques à charger.

5. index_columns : ["workerid", "taskid"] — les colonnes du CSV qui servent de clés, dans le même ordre que "domain". Ce champ est l'un des plus importants du langage IR. Sans lui, le compilateur ne peut pas déterminer quelles colonnes du CSV correspondent à quels ensembles de domaine. Historiquement, une erreur courante était que le compilateur devinait le mauvais nom de colonne clé et chargeait silencieusement les mauvaises données. Le langage IR impose que les bons noms de colonnes soient toujours fournis explicitement.

6. missing_default : "inf" — indique au compilateur que toute paire (travailleur, tâche) non présente dans le CSV doit être traitée comme ayant un coût infini, ce qui signifie que cette route est indisponible. C'est la sémantique correcte pour les paramètres de coût et de pénalité.

Les variables (Variables) : les décisions à prendre

La section "variables" définit les décisions à prendre dans le modèle d'optimisation. La variable "assign" est binaire, indexée sur le domaine ["Workers", "Tasks"]. Au moment de la compilation, le compilateur construit (en supposant l'utilisation du solveur PuLP) :

assign = {(w, t): pulp.LpVariable(f"assign{w}{t}", cat="Binary") for w in Workers for t in Tasks}

Certains drapeaux structurels importants, non utilisés ici mais à connaître : "excludediagonal", "domainfilter" et "upperboundset".

Pour les variables indexées sur le même ensemble deux fois, comme "arc[Location, Location]" dans un modèle de routage, le réglage de "exclude_diagonal=true" indique au compilateur de sauter la diagonale (i, i). Aucune localisation ne se rend à elle-même. Le compilateur émet une garde :

if l1 == l2: 
   continue

et utilise ".get(key, 0)" pour tous les accès, afin que les clés manquantes ne provoquent jamais d'erreur "KeyError".

Lorsque qu'un tableau de coûts a moins de lignes que le produit cartésien complet de ses ensembles de domaine (par exemple, seules les routes valides existent dans le CSV), le réglage de "domainfilter" sur le nom de ce paramètre restreint la variable aux seules combinaisons existantes. Le compilateur émet la compréhension avec "if (i, j) in transportcost" afin que les routes non existantes ne soient jamais créées comme variables.

Pour les variables entières dont la borne supérieure naturelle est la cardinalité d'un ensemble (par exemple, les variables de position MTZ dans l'élimination des sous-tours), le réglage de "upperboundset"="Customers" fait que le compilateur émet "len(Customers)" comme borne supérieure, gardant le modèle indépendant des données même lorsque la taille de l'ensemble varie entre les exécutions.

Les contraintes (Constraints) : des arbres d'expressions symboliques

La section "constraints" contient des arbres d'expressions qui décrivent les contraintes définies pour ce modèle. C'est ici que le langage IR diverge le plus fortement d'un fichier de code. Les contraintes ne sont pas stockées sous forme de chaînes de caractères ou de code, mais comme des arbres d'expressions. Chaque contrainte possède :

1. domain : les ensembles sur lesquels le compilateur va boucler pour générer une instance de contrainte par combinaison. Par exemple, "domain": ["Workers"] signifie une contrainte par travailleur.

2. expression : le membre de gauche, sous forme d'arbre récursif de nœuds.

3. sense : le signe de cette contrainte, "=" ou "<=" ou ">=".

4. rhs : le membre de droite, également un arbre d'expressions (mais contenant uniquement des constantes et des paramètres, jamais de variables, qui doivent être déplacées vers le membre de gauche).

Analysons de près la contrainte "onetaskper_worker" :

"onetaskper_worker": {
  "domain": ["Workers"],
  "expression": {
    "operation": "indexed_sum",
    "over": ["Tasks:t"],
    "body": {"type": "variable", "name": "assign", "indices": ["w", "t"]}
  },
  "sense": "=",
  "rhs": {"type": "constant", "value": 1}
}

Dans le nœud "expression" ci-dessus, le champ "over" utilise l'alias "Tasks:t" pour nommer explicitement la variable de boucle "t" pour cette somme interne. C'est nécessaire car "t" est déjà le indexsymbol de l'ensemble Tasks, et lorsque le domaine de la contrainte externe n'inclut pas Tasks, le compilateur n'aura pas de "t" dans sa portée. L'alias force son existence à l'intérieur de la somme. Chaque fois qu'un ensemble dans "over" apparaît déjà dans le domaine de la contrainte (avec le même indexsymbol), utilisez un alias pour éviter de masquer la variable de boucle externe. Sinon, le "t" interne masquerait le "t" externe, et la somme calculerait toujours assign[t, t] (une boucle sur la diagonale) au lieu de la somme souhaitée sur toutes les tâches.

L'objectif (Objective) : l'arbre d'expression qui définit la performance

La section "objective" définit l'objectif du modèle. Voici sa structure pour notre exemple :

"objective": {
  "sense": "minimize",
  "expression": {
    "operation": "indexed_sum",
    "over": ["Workers:w", "Tasks:t"],
    "body": {
      "operation": "multiply",
      "left":  {"type": "parameter", "name": "assignment_cost", "indices": ["w", "t"]},
      "right": {"type": "variable",  "name": "assign",          "indices": ["w", "t"]}
    }
  }
}

La somme indexée externe itère simultanément sur Workers et Tasks, en utilisant les alias "Workers:w" et "Tasks:t" pour nommer explicitement les deux variables de boucle. Le corps est un nœud de multiplication, paramètre × variable, qui est la seule forme de multiplication autorisée dans un modèle linéaire. Le résultat est un terme par paire (travailleur, tâche), sommé pour donner le coût total.

C'est la forme d'objectif la plus simple : une seule somme indexée. Des objectifs plus complexes combinent plusieurs sommes indexées à l'aide de soustractions. Imaginons que le modèle ait à la fois un coût d'affectation et une prime pour certaines affectations : maximiser la somme(prime[w,t] × assign[w,t]) – somme(coût[w,t] × assign[w,t]). Cela serait encodé comme :

subtract(
  indexed_sum(over Workers,Tasks: prime[w,t] × assign[w,t]),
  indexed_sum(over Workers,Tasks: cost[w,t] × assign[w,t])
)

Une règle cruciale concernant la soustraction : ne jamais imbriquer une soustraction du côté droit d'une autre soustraction. Parce que la soustraction est une opération binaire, gauche moins droite, mettre une autre soustraction à droite inverse le signe du terme interne :

subtract(A, subtract(B, C)) = A – (B – C) = A – B + C ← C était censé être soustrait mais se retrouve ADDITIONNÉ

Imaginons que l'objectif soit revenu – coûtachat – coûtstockage. Une erreur courante des modèles de langage est de regrouper les deux coûts ensemble à droite :

subtract(revenu, subtract(coûtachat, coûtstockage)) = revenu – (coûtachat – coûtstockage) = revenu – coûtachat + coûtstockage

C'est incorrect car le coûtstockage devient un revenu. Le modèle s'exécute toujours et le solveur retourne toujours un "optimal", mais la valeur de l'objectif est erronée, surévaluée de 2 × coûtstockage. La forme correcte est une chaîne plate de gauche à droite :

subtract(subtract(revenu, coûtachat), coûtstockage) = (revenu – coûtachat) – coûtstockage = revenu – coûtachat – coûtstockage

ORPilot possède un validateur sémantique du langage IR qui détecte ce motif d'imbrication du côté droit avant la compilation et nomme le terme spécifique dont le signe a été inversé, afin que le modèle de langage puisse corriger l'ordre de la chaîne.

Une seule erreur de signe dans une soustraction peut transformer un coût en revenu et fausser complètement le modèle. Le validateur d'ORPilot empêche ce genre d'erreur.

COMPILER LE LANGAGE IR : DEVENIR UNE MACHINE À CRÉER DES SOLVEURS

Le compilateur du langage IR est un logiciel déterministe — il n'y a pas d'intelligence artificielle impliquée. Étant donné le même fichier ir.json et les mêmes fichiers de données CSV, il produit toujours un code de solveur identique. Toujours. Le compilateur prend actuellement en charge cinq backends : PuLP, Pyomo, OR-Tools, Gurobi et CPLEX. Passer d'un backend à un autre ne nécessite aucun changement dans le modèle. Le langage IR reste identique ; seul le compilateur cible change. Cela signifie que vous pouvez archiver ir.json aux côtés de vos données et reproduire n'importe quel résultat passé exactement, sans faire un seul appel à une API.

Vous pouvez passer de Gurobi à PuLP en exécutant :

orpilot compile-ir output/ir.json --solver pulp --run

Une seule commande, zéro appel à un modèle de langage, même structure de modèle. Vous pouvez exécuter une validation CI/CD sur les sorties du solveur en validant ir.json et en exécutant le compilateur dans votre pipeline. Vous pouvez partager ir.json avec un collègue sur une machine différente et il pourra résoudre le même modèle sans avoir besoin de votre clé API de modèle de langage ou même de comprendre le problème depuis le début.

Avec le langage IR, votre modèle d'optimisation devient une recette de cuisine : tout le monde peut la suivre, où qu'il soit et quel que soit le fournisseur de solveur.

LA PIPELINE DE COMPILATION : ZÉRO IA, 100% DÉTERMINISTE

Une fois que vous avez un fichier ir.json validé, ORPilot propose une pipeline de compilation légère : ir.json + données CSV → compilateur IR → code du solveur → exécution du code. Cette pipeline implique zéro appel à un modèle de langage de bout en bout. Elle est rapide, peu coûteuse et entièrement déterministe. Le seul appel à un modèle de langage dans tout le flux de travail était celui qui a produit le fichier ir.json en premier lieu.

La commande CLI est :

orpilot compile-ir output/ir.json --run

Cela compile le langage IR, exécute le modèle et génère un rapport de solution. Pour changer de solveur :

orpilot compile-ir output/ir.json --solver pyomo --run

LE VALIDATEUR SÉMANTIQUE : ATTRAPER LES ERREURS AVANT QU'ELLES NE FASSENT DES DÉGÂTS

Avant qu'un langage IR ne soit sauvegardé et compilé, ORPilot exécute un validateur sémantique qui détecte les erreurs de modélisation qui sont structurellement valides en JSON mais mathématiquement incorrectes. Le validateur détecte actuellement trois grandes catégories d'erreurs, qui sont toutes des modes de défaillance courants des modèles de langage lors des expériences.

1. Erreurs de signe dans les contraintes de bilan : il détecte lorsque toutes les variables de flux dans une contrainte de bilan se retrouvent du même côté (par exemple, inv = inflow + outflow au lieu de inv = inflow – outflow). L'identité correcte est : invfinal = invinitial + inflow – outflow. Les violations de cette règle produisent des modèles soit non réalisables (le cas surcontraint), soit non bornés (le cas sous-contraint), et l'erreur de signe est presque impossible à repérer dans le code compilé.

2. Contrainte d'initialisation manquante : si une contrainte de bilan avec décalage temporel existe, le validateur exige une variante "_init" correspondante représentant la contrainte dans la première période temporelle. Une contrainte d'initialisation manquante pourrait laisser la première période sans contrainte, produisant un modèle non borné même lorsque la contrainte des périodes suivantes est correcte.

3. Soustraction imbriquée dans l'objectif : parfois, le modèle de langage qui génère le langage IR écrit subtract(A, subtract(B, C)) alors qu'il entend soustraire séquentiellement les coûts B et C du revenu A. Cependant, mathématiquement cette expression s'évalue à A – (B – C) = A – B + C, inversant le signe de C du coût au revenu. Le modèle s'exécute toujours et retourne un "optimal", mais la valeur de l'objectif est surévaluée de 2 × C. Le validateur détecte l'imbrication du côté droit et nomme le terme affecté afin que le modèle de langage puisse réécrire l'objectif comme une chaîne plate de gauche à droite.

Lorsque la validation échoue, le message d'erreur spécifique est renvoyé au modèle de langage sous forme de message de relance ciblé. Le modèle de langage ne voit pas "langage IR invalide", mais il reçoit un message comme : "Erreur de signe dans le bilan : la variable décharge semble être négative (coefficient -1) mais devrait être soustraite de l'afflux, pas ajoutée à lui."

Le validateur sémantique d'ORPilot agit comme un correcteur d'orthographe pour les modèles d'optimisation : il repère les erreurs avant qu'elles ne deviennent des catastrophes.

ET SI ON TESTAIT UN SCÉNARIO ? LA PUISSANCE DU LANGAGE IR

Les propriétés de reproductibilité et de portabilité du langage IR ont une extension naturelle : l'analyse systématique de scénarios "what-if". Imaginez que vous voulez tester l'impact d'une augmentation de 20 % de la capacité d'une usine sur l'ensemble de votre chaîne logistique. Avec un modèle traditionnel, il faudrait :

1. Modifier manuellement le fichier de données ou le code du modèle.

2. Relancer l'IA pour régénérer le code du solveur.

3. Payer à nouveau l'API.

4. Espérer que le nouveau modèle est correct.

Avec le langage IR, c'est bien plus simple :

1. Vous modifiez la valeur du paramètre concerné dans le fichier CSV ou directement dans le langage IR.

2. Vous relancez la commande de compilation :

orpilot compile-ir output/ir.json --run

3. Vous obtenez un nouveau rapport de solution, sans avoir à rappeler l'IA, sans avoir à modifier le modèle de base, et avec la garantie que la structure mathématique reste identique.

Le langage IR transforme l'analyse de scénarios en un processus aussi simple que de changer une valeur dans un tableau Excel.

Le langage IR d'ORPilot fait passer les modèles d'optimisation du statut de prototype fragile à celui d'outil industriel robuste, prêt pour la production.

POURQUOI CELA CHANGE TOUT POUR LES MODÈLES D'OPTIMISATION EN PRODUCTION

Les modèles d'optimisation en production ont des exigences radicalement différentes de celles des prototypes académiques. Ils doivent :

1. Être reproductibles : donner le même résultat aujourd'hui, dans trois mois, ou sur une autre machine.

2. Être portables : fonctionner avec n'importe quel solveur, sans dépendre d'une licence logicielle spécifique.

3. Être maintenables : permettre des modifications mineures sans tout casser.

4. Être auditable : permettre à quiconque de comprendre la structure du modèle sans avoir à décrypter du code généré par une IA.

Le langage IR d'ORPilot répond à tous ces critères. Il sépare la structure mathématique du modèle (ce qui ne change jamais) de la manière dont il est résolu (ce qui peut changer selon les besoins). C'est une révolution pour les équipes qui veulent passer des prototypes académiques aux outils industriels fiables.

Imaginez une équipe de logistique qui doit adapter ses modèles chaque semaine aux nouvelles contraintes du marché. Avec ORPilot, ils peuvent :

• Stocker le fichier ir.json dans leur dépôt Git.

• Partager ce fichier avec tous les membres de l'équipe, où qu'ils soient.

• Changer de solveur en une ligne de commande.

• Valider leurs modèles dans leur pipeline CI/CD.

• Analyser des scénarios "what-if" en quelques minutes.

Tout cela, sans jamais avoir à rappeler une API d'IA ou à craindre que le modèle ne se comporte différemment d'une exécution à l'autre.

Le langage IR d'ORPilot n'est pas juste une amélioration marginale. C'est une rupture technologique qui rend enfin les modèles d'optimisation adaptés à la production à grande échelle.

ORPilot et son langage IR transforment les modèles d'optimisation en outils industriels robustes, prêts pour l'entreprise du XXIe siècle.
Sources :
  • Towards Data Science

L'indépendance de CLODCO est votre garantie.

Pour que l'actualité de l'IA reste sans filtre et sans concession, votre soutien est indispensable. Votre contribution est le seul moteur de notre liberté éditoriale.

Soutenir CLODCO