Résolvez un programme linéaire avec le simplexe
La méthode graphique a résolu la brasserie en un coup d’œil. Mais si le brasseur produit huit bières sous quinze contraintes, plus question de dessiner : l’ensemble réalisable vit en dimension 8 et peut compter des milliers de sommets. Les énumérer tous serait beaucoup trop long.
L’algorithme du simplexe, inventé par George Dantzig en 1947, contourne le problème : il part d’un sommet et se déplace de sommet voisin en sommet voisin, en améliorant l’objectif à chaque pas, jusqu’à ce qu’aucun voisin ne fasse mieux. Dans ce chapitre, vous allez le dérouler à la main sur la brasserie, puis le programmer.
La forme standard et les variables d’écart
Section intitulée « La forme standard et les variables d’écart »Le simplexe travaille avec des égalités, plus faciles à manipuler que des inégalités. On transforme chaque contrainte « inférieur ou égal » en égalité en ajoutant une variable d’écart (slack variable) positive, qui mesure ce qui reste de la ressource. Pour la brasserie :
- est le nombre d’hectolitres de cuve inutilisés ;
- le nombre de sacs de malt restants ;
- le nombre de lots de houblon restants.
Une variable d’écart nulle signifie que la ressource est épuisée : on dit que la contrainte est saturée (ou active). Sous forme matricielle, le problème s’écrit sous , , avec ici une matrice à 3 lignes (les contraintes) et 5 colonnes (les variables).
Quelques règles pour se ramener à cette forme dans tous les cas :
| Situation de départ | Transformation |
|---|---|
| contrainte | ajouter une variable d’écart : , |
| contrainte | retrancher une variable d’excédent : , (voir le chapitre 3) |
| second membre négatif | multiplier la contrainte par (le sens de l’inégalité s’inverse) |
| variable de signe quelconque | écrire avec |
| minimiser | maximiser (ou adapter la règle d’entrée) |
Solutions de base et sommets
Section intitulée « Solutions de base et sommets »Nous avons 3 équations et 5 inconnues. En fixant 2 variables à zéro, il reste un système de 3 équations à 3 inconnues, qui a (en général) une solution unique. C’est l’idée d’une solution de base :
- on choisit variables, dites en base (ici , le nombre de contraintes) ;
- on met toutes les autres, dites hors base, à zéro ;
- on résout le système pour obtenir les variables de base.
Si toutes les valeurs obtenues sont positives, la solution de base est réalisable. Et voici le lien avec la géométrie du chapitre 1 : les solutions de base réalisables sont exactement les sommets du polyèdre.
Deux sommets sont voisins quand leurs bases ne diffèrent que d’une variable. Passer d’un sommet à un voisin, c’est donc faire entrer une variable dans la base et en faire sortir une autre. Toute la question est de choisir lesquelles.
Une itération du simplexe, pas à pas
Section intitulée « Une itération du simplexe, pas à pas »On part de la base des écarts, toujours réalisable quand : le sommet . On écrit chaque variable de base, et l’objectif, en fonction des variables hors base. Cette écriture s’appelle un dictionnaire :
Choisir la variable entrante
Section intitulée « Choisir la variable entrante »Dans la ligne de , les coefficients des variables hors base s’appellent les coûts réduits. Un coût réduit positif signifie qu’augmenter cette variable fait grimper le bénéfice. La règle usuelle (de Dantzig) fait entrer la variable de plus grand coût réduit positif : ici , qui rapporte 3 par hectolitre.
Choisir la variable sortante : le test du rapport
Section intitulée « Choisir la variable sortante : le test du rapport »Jusqu’où peut-on augmenter , en laissant à zéro ? Tant qu’aucune variable de base ne devient négative :
- impose ;
- impose ;
- impose .
La contrainte la plus sévère est la dernière : monte jusqu’à 3, et c’est qui tombe à zéro et sort de la base. En général, on calcule pour chaque ligne le rapport entre le second membre et le coefficient de la variable entrante, uniquement pour les coefficients strictement positifs, et l’on retient le plus petit.
On exprime la variable entrante à partir de l’équation de la sortante, , et on la remplace partout :
Nous sommes au sommet , avec un bénéfice de 9. Le coût réduit de est encore positif : on continue. entre ; les rapports valent pour et pour (la ligne de ne contient pas et ne limite rien). C’est qui sort. Avec :
Tous les coûts réduits sont maintenant négatifs : augmenter ou ne pourrait que faire baisser . On s’arrête. L’optimum est , , , avec sacs de malt inutilisés. C’est exactement le résultat de la méthode graphique, obtenu en visitant seulement trois sommets : , puis .
Le tableau du simplexe
Section intitulée « Le tableau du simplexe »À la main, on range les mêmes calculs dans un tableau : une ligne par variable de base, une colonne par variable, le second membre à droite, et une dernière ligne pour les coûts réduits. Voici les trois tableaux de la brasserie (la case en bas à droite donne la valeur de ).
Tableau initial, base :
| Base | ||||||
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 0 | 0 | 4 | |
| 1 | 3 | 0 | 1 | 0 | 9 | |
| 1 | 0 | 0 | 0 | 1 | 3 | |
| coûts réduits | 3 | 2 | 0 | 0 | 0 |
Après le premier pivot ( entre, sort) :
| Base | ||||||
|---|---|---|---|---|---|---|
| 0 | 1 | 1 | 0 | 1 | ||
| 0 | 3 | 0 | 1 | 6 | ||
| 1 | 0 | 0 | 0 | 1 | 3 | |
| coûts réduits | 0 | 2 | 0 | 0 |
Tableau final ( entre, sort) :
| Base | ||||||
|---|---|---|---|---|---|---|
| 0 | 1 | 1 | 0 | 1 | ||
| 0 | 0 | 1 | 2 | 3 | ||
| 1 | 0 | 0 | 0 | 1 | 3 | |
| coûts réduits | 0 | 0 | 0 |
Le pivot (en gras) se fait par opérations sur les lignes : on divise la ligne du pivot par le pivot, puis on retranche à chaque autre ligne (y compris celle des coûts réduits) le bon multiple de la ligne du pivot pour annuler la colonne entrante. Gardez un œil sur les coûts réduits finaux des écarts, et : ils ont un sens économique précis, que vous découvrirez au chapitre 4.
Arrêt, non-bornitude et dégénérescence
Section intitulée « Arrêt, non-bornitude et dégénérescence »Trois situations particulières se lisent directement dans le tableau.
- Problème non borné : si la colonne de la variable entrante n’a aucun coefficient strictement positif, rien ne limite sa croissance, et peut augmenter indéfiniment. On s’arrête en le signalant.
- Optimums multiples : si, à l’optimum, une variable hors base a un coût réduit nul, on peut la faire entrer sans changer : on obtient un autre sommet optimal.
- Dégénérescence : si une variable de base vaut 0, un pivot peut changer de base sans changer de sommet. Dans des cas rares, l’algorithme peut alors tourner en rond (on parle de cyclage). La règle de Bland (choisir, parmi les candidates, la variable de plus petit indice) l’évite à coup sûr.
Côté performance, le simplexe peut, dans le pire des cas, visiter un nombre exponentiel de sommets. En pratique, il en visite très peu et reste l’une des méthodes les plus utilisées au monde, à côté des méthodes de points intérieurs qui traversent le polyèdre au lieu d’en longer les arêtes.
Voici une implémentation compacte, pour le cas sous , , avec :
import numpy as np
def simplexe(c, A, b): """Maximise c @ x sous A @ x <= b, x >= 0, avec b >= 0.""" m, n = A.shape T = np.hstack([A, np.eye(m), b.reshape(-1, 1)]).astype(float) # contraintes et écarts z = np.concatenate([c, np.zeros(m + 1)]).astype(float) # coûts réduits, puis -valeur base = list(range(n, n + m)) # départ : les écarts en base while True: k = int(np.argmax(z[:-1])) # variable entrante if z[k] <= 1e-12: break # plus de coût réduit positif : optimum col = T[:, k] if np.all(col <= 1e-12): raise ValueError("problème non borné") rapports = np.full(m, np.inf) rapports[col > 1e-12] = T[col > 1e-12, -1] / col[col > 1e-12] r = int(np.argmin(rapports)) # ligne de la variable sortante T[r] /= T[r, k] # pivot for i in range(m): if i != r: T[i] -= T[i, k] * T[r] z -= z[k] * T[r] base[r] = k x = np.zeros(n + m) x[base] = T[:, -1] return x[:n], -z[-1]
x, valeur = simplexe(np.array([3, 2]), np.array([[1, 1], [1, 3], [1, 0]]), np.array([4, 9, 3]))print(x, valeur) # [3. 1.] 11.0En résumé
Section intitulée « En résumé »- La forme standard transforme les inégalités en égalités grâce aux variables d’écart, qui mesurent les ressources inutilisées.
- Une solution de base fixe à zéro les variables hors base et résout pour les variables de base ; les solutions de base réalisables sont les sommets du polyèdre.
- Le simplexe fait entrer la variable de meilleur coût réduit, fait sortir celle de plus petit rapport (sur les coefficients strictement positifs), puis pivote.
- Il s’arrête quand aucun coût réduit n’est favorable ; une colonne entrante sans coefficient positif signale un problème non borné.
- Un coût réduit nul à l’optimum révèle des optimums multiples ; la dégénérescence peut faire cycler l’algorithme, ce qu’évite la règle de Bland.
Exercices
Section intitulée « Exercices »Exercice · Identifier des solutions de base
facilePour la brasserie sous forme standard, calculez la solution de base associée à et à . Sont-elles réalisables ? À quels sommets correspondent-elles ?
Voir la solution
Base : on pose . Les deux premières équations deviennent et , d’où et . La troisième donne . Tout est positif : c’est le sommet réalisable , où cuves et malt sont saturés.
Base : on pose . La deuxième équation donne , puis et . Tout est positif : c’est le sommet , où seul le malt est saturé.
En code
import numpy as np
# Forme standard : colonnes x1, x2, e1, e2, e3noms = ["x1", "x2", "e1", "e2", "e3"]A = np.array([[1, 1, 1, 0, 0], [1, 3, 0, 1, 0], [1, 0, 0, 0, 1]], dtype=float)b = np.array([4, 9, 3], dtype=float)
for base in (["x1", "x2", "e3"], ["x2", "e1", "e3"]): idx = [noms.index(v) for v in base] valeurs = np.linalg.solve(A[:, idx], b) # hors base à zéro print(base, valeurs, "réalisable" if np.all(valeurs >= 0) else "non réalisable")# ['x1', 'x2', 'e3'] [1.5 2.5 1.5] réalisable# ['x2', 'e1', 'e3'] [3. 1. 3.] réalisable# Forme standard : colonnes x1, x2, e1, e2, e3A <- rbind(c(1, 1, 1, 0, 0), c(1, 3, 0, 1, 0), c(1, 0, 0, 0, 1))colnames(A) <- c("x1", "x2", "e1", "e2", "e3")b <- c(4, 9, 3)
for (base in list(c("x1", "x2", "e3"), c("x2", "e1", "e3"))) { valeurs <- solve(A[, base], b) # hors base à zéro print(valeurs)}# x1 = 1.5, x2 = 2.5, e3 = 1.5 : sommet (1,5 ; 2,5)# x2 = 3, e1 = 1, e3 = 3 : sommet (0, 3)Exercice · Dérouler le simplexe
moyenRésolvez par le simplexe le programme de l’exercice 1 du chapitre précédent : sous , , . En cas d’égalité des coûts réduits, faites entrer la variable de plus petit indice.
Voir la solution
Dictionnaire initial : , , .
Itération 1 : entre. Rapports et : sort. On obtient , puis et .
Itération 2 : entre (coût réduit ). Rapports pour et pour : sort. Alors , et .
Les coûts réduits sont négatifs : l’optimum est avec , comme par la méthode graphique.
En code
import numpy as np
A = np.array([[2, 1], [1, 2]], dtype=float)b = np.array([8, 7], dtype=float)c = np.array([1, 1], dtype=float)m, n = A.shapeT = np.hstack([A, np.eye(m), b[:, None]]) # tableau : x1, x2, e1, e2 | bz = np.concatenate([c, np.zeros(m + 1)]) # coûts réduits, puis -valeurbase = [n, n + 1] # départ : les écartswhile z[:-1].max() > 1e-12: k = int(np.argmax(z[:-1])) # plus grand coût réduit (plus petit indice) col = T[:, k] rapports = np.full(m, np.inf) rapports[col > 1e-12] = T[col > 1e-12, -1] / col[col > 1e-12] r = int(np.argmin(rapports)) # test du rapport T[r] /= T[r, k] for i in range(m): if i != r: T[i] -= T[i, k] * T[r] z -= z[k] * T[r] base[r] = k print(f"x{k + 1} entre, rapports {rapports}, z = {-z[-1]}, coûts réduits {z[:-1].round(3)}")# x1 entre, rapports [4. 7.], z = 4.0, coûts réduits [ 0. 0.5 -0.5 0. ]# x2 entre, rapports [8. 2.], z = 5.0, coûts réduits [ 0. 0. -0.333 -0.333]x = np.zeros(n + m)x[base] = T[:, -1]print(x[:n]) # [3. 2.]A <- rbind(c(2, 1), c(1, 2)); b <- c(8, 7); cc <- c(1, 1)m <- nrow(A); n <- ncol(A)T <- cbind(A, diag(m), b) # tableau : x1, x2, e1, e2 | bz <- c(cc, rep(0, m + 1)) # coûts réduits, puis -valeurbase <- n + 1:m # départ : les écartswhile (max(z[1:(n + m)]) > 1e-12) { k <- which.max(z[1:(n + m)]) # plus grand coût réduit (plus petit indice) rapports <- ifelse(T[, k] > 1e-12, T[, n + m + 1] / T[, k], Inf) r <- which.min(rapports) # test du rapport T[r, ] <- T[r, ] / T[r, k] for (i in setdiff(1:m, r)) T[i, ] <- T[i, ] - T[i, k] * T[r, ] z <- z - z[k] * T[r, ] base[r] <- k cat(paste0("x", k, " entre, rapports ", rapports[1], " et ", rapports[2], ", z = ", -z[n + m + 1]), "\n")}# x1 entre, rapports 4 et 7, z = 4# x2 entre, rapports 8 et 2, z = 5round(unname(z[1:(n + m)]), 3) # 0 0 -0.333 -0.333T[order(base), n + m + 1] # x1 = 3, x2 = 2Exercice · Détecter un problème non borné
difficileAppliquez le simplexe à sous , , . Que se passe-t-il ? Donnez une famille de solutions réalisables dont la valeur tend vers l’infini.
Voir la solution
Dictionnaire initial : , , .
Itération 1 : entre. Dans la ligne de , le coefficient de dans la contrainte est (augmenter augmente ) : pas de limite. La ligne de impose : sort. On obtient , et .
Itération 2 : entre (coût réduit 2). Mais n’apparaît pas dans la ligne de , et il apparaît avec un signe dans celle de : aucune variable de base ne diminue quand augmente. La colonne entrante n’a aucun coefficient strictement positif, le problème est non borné.
Concrètement, pour , le point , est réalisable ( et ) et vaut , qui tend vers l’infini.
En code
import numpy as npfrom scipy.optimize import linprog
A_ub, b_ub = [[-1, 1], [1, -1]], [1, 2]res = linprog([-1, -1], A_ub=A_ub, b_ub=b_ub, bounds=[(0, None)] * 2)print(res.status, res.message) # 3 : le problème est non borné
# La famille x = (2 + t, t) reste réalisable et z = 2 + 2t grandit sans finfor t in [0, 10, 1000]: x = np.array([2 + t, t]) print(t, np.all(np.array(A_ub) @ x <= b_ub), x.sum()) # True, z = 2, 22, 2002simplexe <- function(cc, A, b) { # max cc'x sous A x <= b, x >= 0, b >= 0 m <- nrow(A); n <- ncol(A) T <- cbind(A, diag(m), b); z <- c(cc, rep(0, m + 1)); base <- n + 1:m while (max(z[1:(n + m)]) > 1e-12) { k <- which.max(z[1:(n + m)]) if (all(T[, k] <= 1e-12)) stop("colonne de x", k, " sans coefficient positif : non borné") r <- which.min(ifelse(T[, k] > 1e-12, T[, n + m + 1] / T[, k], Inf)) T[r, ] <- T[r, ] / T[r, k] for (i in setdiff(1:m, r)) T[i, ] <- T[i, ] - T[i, k] * T[r, ] z <- z - z[k] * T[r, ]; base[r] <- k cat(paste0("x", k, " entre, z = ", -z[n + m + 1]), "\n") }}A <- rbind(c(-1, 1), c(1, -1)); b <- c(1, 2)tryCatch(simplexe(c(1, 1), A, b), error = function(e) cat(conditionMessage(e), "\n"))# x1 entre, z = 2# colonne de x2 sans coefficient positif : non bornét <- c(0, 10, 1000); x1 <- 2 + t; x2 <- t # la famille (2 + t, t)all(-x1 + x2 <= 1 & x1 - x2 <= 2) # TRUE : toujours réalisablex1 + x2 # 2 22 2002 : z tend vers l'infini