Optimisation Combinatoire en Programmation par Contraintes
Ce notebook explore les problèmes d’optimisation combinatoire classiques résolus par CP.
Objectifs
À la fin de ce notebook, vous saurez : 1. Résoudre le Bin Packing Problem (BPP) avec CP-SAT 2. Implémenter le Knapsack Problem 0/1 3. Modéliser le Cutting Stock Problem 4. Appliquer au Portfolio Optimization
Prérequis
Notebooks CSP-1 à CSP-3 (fondements CSP)
CSP-4-Scheduling (ordonnancement)
Python 3.10+ : ortools, matplotlib, numpy
Notions de base en optimisation combinatoire
Ancres savantes – Dantzig, G.B. (1957), Discrete-Variable Extremum Problems, Opérations Research 5(2):266-288 (knapsack 0/1, relaxation continue fractionnaire borneant l’optimum, fondement de la resolution par branch-and-bound) ; Johnson, D.S. (1974), Fast Algorithms for Bin Packing, Journal of Computer and System Sciences 8(3):272-314 (heuristique First-Fit-Decreasing pour le Bin Packing, garantie de performance 11/9OPT) ; Garey, M.R. & Johnson, D.S. (1979), Computers and Intractability, W.H. Freeman (Bin Packing et Knapsack parmi les 21 problemes NP-complets fondateurs) ; Gilmore, P.C. & Gomory, R.E. (1961), A Linear Programming Approach to the Cutting-Stock Problem, Opérations Research 9(6):849-859 (Cutting Stock, generation de colonnes fondatrice) ; Markowitz, H. (1952), Portfolio Sélection, The Journal of Finance 7(1):77-91 (théorie moderne du portefeuille : frontier efficiente moyenne-variance, déjà nomme ci-dessous).*
# Installation des dépendancesimport subprocessimport sysdef install_if_missing(package):try:__import__(package.replace('-', '_'))exceptImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", package])install_if_missing('ortools')install_if_missing('matplotlib')from ortools.sat.python import cp_modelimport matplotlib.pyplot as pltimport numpy as npfrom typing import List, Dict, Tuple, Optionalprint("Dépendances prêtes.")
Dépendances prêtes.
1. Bin Packing Problem (BPP)
Le BPP consiste à placer des objets de tailles différentes dans le minimum de bins de capacité fixe.
Définition formelle
n objets avec tailles \(w_1, w_2, ..., w_n\)
Bins de capacité \(C\)
Objectif: Minimiser le nombre de bins utilisés
Complexité
NP-hard, avec plusieurs heuristiques classiques: - First Fit (FF): Premier bin avec assez d’espace - Best Fit (BF): Bin laissant le moins d’espace restant - First Fit Decreasing (FFD): FF après tri décroissant
Application : Bin Packing Problem avec Minimisation du Nombre de Bins
Cette section démontre la résolution du problème de Bin Packing (remplissage de conteneurs) où l’on doit placer des objets de tailles différentes dans un nombre minimum de conteneurs de capacité fixe.
Problème à résoudre : - 6 objets de tailles variées : [2, 2, 2, 3, 5, 6] - Bins de capacité : 10 unités chacun - Contrainte : chaque objet doit être placé dans exactement un bin - Objectif : minimiser le nombre de bins utilisés
Modélisation CP-SAT : - Variables binairesx[i,j] : 1 si l’objet i est placé dans le bin j, 0 sinon - Variables binairesy[j] : 1 si le bin j est utilisé, 0 sinon - Contrainte d’assignation : pour chaque objet i, Σ x[i,j] = 1 (exactement un bin) - Contrainte de capacité : pour chaque bin j, Σ taille[i] × x[i,j] ≤ capacité × y[j] - Symmetry breaking : y[j] ≥ y[j+1] (utiliser les bins dans l’ordre pour réduire les symétries) - Objectif : minimiser Σ y[j]
Le code ci-dessous illustre comment CP-SAT trouve la solution optimale en utilisant des variables binaires d’assignation et des techniques avancées de réduction des symétries pour accélérer la résolution.
def solve_bin_packing_cp(items: List[int], capacity: int) -> Dict:""" Résout le Bin Packing Problem avec OR-Tools CP-SAT. Args: items: Liste des tailles des objets capacity: Capacité de chaque bin Returns: Dictionnaire avec nombre de bins, assignations et status """ n =len(items)# Borne supérieure: chaque objet dans son propre bin max_bins = n model = cp_model.CpModel()# Variables binaires: x[i,j] = 1 si objet i dans bin j x = {}for i inrange(n):for j inrange(max_bins): x[(i, j)] = model.NewBoolVar(f'x_{i}_{j}')# Variables: y[j] = 1 si bin j est utilisé y = {}for j inrange(max_bins): y[j] = model.NewBoolVar(f'y_{j}')# Contrainte 1: Chaque objet dans exactement un binfor i inrange(n): model.Add(sum(x[(i, j)] for j inrange(max_bins)) ==1)# Contrainte 2: Capacité des binsfor j inrange(max_bins): model.Add(sum(items[i] * x[(i, j)] for i inrange(n)) <= capacity * y[j])# Contrainte 3: Symmetry breaking - utiliser bins dans l'ordrefor j inrange(max_bins -1): model.Add(y[j] >= y[j +1])# Objectif: minimiser le nombre de bins model.Minimize(sum(y[j] for j inrange(max_bins)))# Résolution solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: bins = {}for i inrange(n):for j inrange(max_bins):if solver.Value(x[(i, j)]) ==1:if j notin bins: bins[j] = [] bins[j].append(i)return {'num_bins': len(bins),'bins': bins,'bin_loads': {j: sum(items[i] for i in items_list) for j, items_list in bins.items()},'status': 'OPTIMAL'if status == cp_model.OPTIMAL else'FEASIBLE' }return {'num_bins': None, 'bins': {}, 'status': 'INFEASIBLE'}print("Fonction solve_bin_packing_cp definie.")
Fonction solve_bin_packing_cp definie.
Exemple Bin Packing
Nous allons résoudre un problème de Bin Packing avec : - 6 objets de tailles variées (2 à 6 unités) : [2, 2, 2, 3, 5, 6] - Bins de capacité : 10 unités - Objectif : minimiser le nombre de bins utilisés
Le solveur CP-SAT utilise des variables binaires pour assigner chaque objet à un bin, avec des contraintes de capacité et des techniques de symmetry breaking pour accélérer la résolution.
# Exemple Bin Packingitems = [2, 2, 2, 3, 5, 6]capacity =10bpp_result = solve_bin_packing_cp(items, capacity)print(f"Nombre optimal de bins: {bpp_result['num_bins']}")print(f"Status: {bpp_result['status']}")print(f"\nDétail des bins:")for bin_id, bin_items in bpp_result['bins'].items():print(f" Bin {bin_id}: objets {[items[i] for i in bin_items]} (charge: {bpp_result['bin_loads'][bin_id]}/{capacity})")
Nombre optimal de bins: 2
Status: OPTIMAL
Détail des bins:
Bin 1: objets [2, 2, 6] (charge: 10/10)
Bin 0: objets [2, 3, 5] (charge: 10/10)
Visualisation du Bin Packing
Le diagramme de visualisation montre pour chaque bin : - Barres colorées : objets placés avec leur taille - Espace grisé : capacité restante (gaspillage) - Ligne rouge : capacité maximale du bin
Cette représentation permet de vérifier l’efficacité de la solution et d’identifier les bins sous-utilisés.
Application : Knapsack Problem 0/1 avec Maximisation de Valeur
Cette section illustre le problème classique du sac à dos (Knapsack Problem) dans sa variante 0/1, où chaque objet peut être soit pris entièreme nt, soit laissé.
Problème à résoudre : - 8 objets disponibles, chacun avec un poids et une valeur - Capacité du sac : 20 unités de poids - Contrainte 0/1 : chaque objet est soit pris (1), soit laissé (0) - pas de sélection partielle - Objectif : maximiser la valeur totale des objets sélectionnés
Techniques CP-SAT utilisées : - Variables BoolVar : une variable binaire par objet (x[i] = 1 si l’objet i est sélectionné) - Contrainte linéaire : somme des poids ≤ capacité - Optimisation : maximisation d’une fonction linéaire
Le code ci-dessous démontre la résolution exacte du Knapsack 0/1 avec CP-SAT, qui garantit de trouver la solution optimale (contrairement aux heuristiques gloutonnes qui peuvent être sous-optimales).
def solve_knapsack_cp( weights: List[int], values: List[int], capacity: int) -> Dict:""" Résout le Knapsack Problem 0/1 avec CP-SAT. Args: weights: Poids des objets values: Valeurs des objets capacity: Capacité maximale du sac Returns: Dictionnaire avec valeur totale, objets sélectionnés et status """ n =len(weights) model = cp_model.CpModel()# Variables binaires x = [model.NewBoolVar(f'x_{i}') for i inrange(n)]# Contrainte de capacité model.Add(sum(weights[i] * x[i] for i inrange(n)) <= capacity)# Objectif: maximiser la valeur total_value =sum(values[i] * x[i] for i inrange(n)) model.Maximize(total_value)# Résolution solver = cp_model.CpSolver() status = solver.Solve(model)if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: selected = [i for i inrange(n) if solver.Value(x[i]) ==1] total_weight =sum(weights[i] for i in selected)return {'total_value': solver.Value(total_value),'total_weight': total_weight,'selected': selected,'status': 'OPTIMAL'if status == cp_model.OPTIMAL else'FEASIBLE' }return {'total_value': 0, 'total_weight': 0, 'selected': [], 'status': 'INFEASIBLE'}print("Fonction solve_knapsack_cp definie.")
Fonction solve_knapsack_cp definie.
Exemple Knapsack Problem
Nous allons résoudre un problème de sac à dos avec : - 8 objets ayant des poids et valeurs différents - Capacité du sac : 20 unités - Objectif : maximiser la valeur totale sans dépasser la capacité
C’est un problème classique de sélection où chaque objet peut être pris (1) ou laissé (0), d’où le nom “Knapsack 0/1”.
Sortie obtenue : Le solveur sélectionne 5 objets (indices 0, 1, 2, 3, 7) pour une valeur totale de 26, en utilisant exactement toute la capacité du sac (20/20).
Objet
Poids
Valeur
Ratio v/w
Sélectionné
0
2
3
1.50
Oui
1
3
4
1.33
Oui
2
4
5
1.25
Oui
3
5
8
1.60
Oui
4
9
10
1.11
Non
5
7
7
1.00
Non
6
8
9
1.13
Non
7
6
6
1.00
Oui
Analyse de la sélection : - Poids total : 2 + 3 + 4 + 5 + 6 = 20 (capacité utilisée à 100%) - Valeur totale : 3 + 4 + 5 + 8 + 6 = 26 - Ratio moyen : 26/20 = 1.3 (valeur par unité de poids)
Points cles : 1. Capacité saturée : Le sac est rempli à 100% de sa capacité (20/20) 2. Optimalité garantie : CP-SAT garantit que 26 est la valeur maximale possible 3. Stratégie de sélection : Le choix n’est pas dicté par le seul ratio v/w — les objets 4 (1.11) et 6 (1.13) ont un meilleur ratio que l’objet 7 (1.00) retenu, mais ne tiennent pas dans la combinaison qui sature exactement la capacité (2+3+4+5+6=20). Le Knapsack 0/1 maximise la valeur sous contrainte de capacité, pas le ratio 4. Complexité 0/1 : Chaque objet est soit pris entièrement (1), soit laissé (0) - pas de sélection partielle
Note technique : Le Knapsack 0/1 est NP-hard, mais existe une version “fractionnelle” (où on peut prendre des fractions d’objets) qui est résoluble en O(n log n) par un algorithme glouton basé sur le ratio valeur/poids. La version 0/1 nécessite une approche CP/PLNE ou de programmation dynamique.
3. Cutting Stock Problem
Le Cutting Stock Problem est similaire au Bin Packing avec des demandes multiples: - Commandes: plusieurs pièces de chaque longueur - Stocks: barres de longueur fixe - Objectif: minimiser le nombre de barres utilisées
Applications
Industrie du bois/métal
Industrie du papier
Fabrication de vêtements
Application : Cutting Stock Problem avec Patterns de Coupe Optimaux
Cette section applique la programmation par contraintes au problème industriel de découpe de barres (Cutting Stock Problem), où l’on doit découper des pièces de différentes longueurs à partir de barres de stock en minimisant le gaspillage.
Problème à résoudre : - 4 types de pièces demandées : longueurs de 20cm, 25cm, 30cm, 40cm - Demandes : respectivement 5, 3, 4, 2 pièces de chaque type - Barres en stock : longueur standard de 100cm - Objectif : minimiser le nombre de barres utilisées
Modélisation CP-SAT : - Variables entièresy[j,i] : nombre de pièces de type i découpées dans la barre j - Variables binairesz[j] : 1 si la barre j est utilisée, 0 sinon - Contrainte de demande : pour chaque type i, somme(y[j,i]) ≥ demande[i] - Contrainte de capacité : pour chaque barre j, somme(longueur[i] × y[j,i]) ≤ stock_length × z[j] - Symmetry breaking : z[j] ≥ z[j+1] (utiliser les barres dans l’ordre) - Objectif : minimiser somme(z[j])
Le code ci-dessous montre comment CP-SAT trouve automatiquement les patterns de coupe optimaux, c’est-à-dire la combinaison de pièces à découper dans chaque barre pour minimiser le nombre total de barres et le gaspillage.
Application du cutting stock a un ensemble de pieces.
def solve_cutting_stock_cp(piece_lengths: List[int], demands: List[int], stock_length: int) -> Dict:""" Resolve le Cutting Stock Problem avec OR-Tools CP-SAT. Args: piece_lengths: Longueurs des pieces demandees demands: Nombre de pieces demandees pour chaque longueur stock_length: Longueur des barres en stock Returns: Dictionnaire avec nombre de barres, patterns et status """ n_types =len(piece_lengths) max_bars =sum(demands) # Borne superieure model = cp_model.CpModel()# Variables: y[j,i] = nombre de pieces de type i coupees dans la barre j y = {}for j inrange(max_bars):for i inrange(n_types): y[(j, i)] = model.NewIntVar(0, demands[i], f"y_{j}_{i}")# Variables: z[j] = 1 si la barre j est utilisee z = {}for j inrange(max_bars): z[j] = model.NewBoolVar(f"z_{j}")# Contrainte 1: Satisfaction de la demandefor i inrange(n_types): model.Add(sum(y[(j, i)] for j inrange(max_bars)) >= demands[i])# Contrainte 2: Capacite par barrefor j inrange(max_bars): model.Add(sum(piece_lengths[i] * y[(j, i)] for i inrange(n_types)) <= stock_length * z[j])# Contrainte 3: Symmetry breakingfor j inrange(max_bars -1): model.Add(z[j] >= z[j +1])# Objectif: minimiser le nombre de barres model.Minimize(sum(z[j] for j inrange(max_bars)))# Resolution solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: patterns = []for j inrange(max_bars):if solver.Value(z[j]) ==1: pattern = {} used =0for i inrange(n_types): count = solver.Value(y[(j, i)])if count >0: pattern[i] = count used += piece_lengths[i] * count patterns.append({"bar_id": j,"pattern": pattern,"used_length": used,"waste": stock_length - used })return {"num_bars": len(patterns),"patterns": patterns,"status": "OPTIMAL"if status == cp_model.OPTIMAL else"FEASIBLE" }return {"num_bars": None, "patterns": [], "status": "INFEASIBLE"}print("Fonction solve_cutting_stock_cp definie.")
Fonction solve_cutting_stock_cp definie.
Exemple Cutting Stock
Nous allons résoudre un problème de découpe avec : - 4 longueurs de pièces demandées : 20cm, 25cm, 30cm, 40cm - Demandes : 5, 3, 4, 2 pièces respectivement - Barres en stock : 100cm de longueur
Le solveur doit trouver les patterns de coupe optimaux qui minimisent le nombre de barres utilisées tout en satisfaisant toutes les demandes.
# Exemple Cutting Stockpiece_lengths = [20, 25, 30, 40] # cmdemands = [5, 3, 4, 2] # pièces de chaque longueurstock_length =100# cmcsp_result = solve_cutting_stock_cp(piece_lengths, demands, stock_length)print(f"Nombre de barres: {csp_result['num_bars']}")print(f"Status: {csp_result['status']}")print(f"\nPatterns de coupe:")for p in csp_result['patterns']: pieces = [(piece_lengths[i], c) for i, c in p['pattern'].items()]print(f" Barre {p['bar_id']}: {pieces} - utilisé: {p['used_length']}cm, perte: {p['waste']}cm")
Sortie obtenue : Le solveur utilise 4 barres de 100cm pour satisfaire toutes les demandes, avec 3 barres utilisées à 100% et 1 barre avec 25cm de perte (gaspillage).
Points cles : 1. Optimalité : 4 barres est le minimum théorique pour ces demandes (poids total des pièces : 20×5 + 25×3 + 30×4 + 40×2 = 375cm, donc au moins 4 barres de 100cm) 2. Efficacité globale : 375cm utilisés / 400cm disponibles = 93.75% d’efficacité 3. Gaspillage minimal : Seulement 25cm perdus sur 4 barres (6.25%) 4. Patterns diversifiés : Le solveur trouve 3 patterns optimaux différents pour maximiser l’efficacité
Note technique : Le Cutting Stock Problem est une généralisation du Bin Packing où chaque type d’objet peut être demandé en plusieurs exemplaires. Les industries utilisent souvent des techniques de “column generation” pour les grandes instances, car le nombre de patterns possibles croît exponentiellement.
4. Optimisation de portefeuille
Application : Optimisation de Portefeuille avec Contraintes de Cardinalité
Cette section démontre l’application de la programmation par contraintes à un problème de finance : la sélection optimale d’actifs pour un portefeuille d’investissement.
Problème à résoudre : - 5 actifs disponibles (AAPL, GOOGL, MSFT, AMZN, TSLA) avec historique de prix - Budget total de $50,000 à investir - Contraintes de diversification : sélectionner entre 2 et 3 actifs différents - Objectif : maximiser le rendement attendu du portefeuille
Concepts clés implémentés : - Variables binaires de sélection (selected[i]) : 1 si l’actif i est choisi, 0 sinon - Variables entières de quantité (quantity[i]) : nombre d’unités de l’actif i à acheter - Contrainte de budget : somme(prix[i] × quantité[i]) ≤ budget - Contraintes de cardinalité : min_assets ≤ somme(selected[i]) ≤ max_assets - Lien sélection-quantité : quantity[i] ≥ selected[i] (au moins 1 unité si sélectionné) - Fonction objective : maximiser somme(rendement[i] × quantité[i])
Le code ci-dessous illustre comment modéliser ce problème avec CP-SAT, en utilisant des variables mixtes (binaires et entières) et des contraintes de liaison entre sélection et quantité.
def expected_return(prices):"""Calcule le rendement attendu simple."""iflen(prices) <2:return0return (prices[-1] - prices[0]) / prices[0]def solve_portfolio_optimization( assets: List[str], prices: List[List[float]], budget: float, max_assets: int, min_assets: int=1, max_per_asset: int=100) -> Dict:""" Optimise un portefeuille avec contraintes de cardinalite. Args: assets: Noms des actifs prices: Historique des prix pour chaque actif budget: Budget total max_assets: Nombre maximum d'actifs differents min_assets: Nombre minimum d'actifs differents max_per_asset: Quantite max par actif Returns: Dictionnaire avec portefeuille optimal et metriques """ n =len(assets) current_prices = [p[-1] for p in prices] returns = [expected_return(p) for p in prices] model = cp_model.CpModel()# Variables selected = [model.NewBoolVar(f'sel_{i}') for i inrange(n)] quantity = [model.NewIntVar(0, max_per_asset, f'q_{i}') for i inrange(n)]# Contrainte 1: Budget# Note: on utilise des entiers, on multiplie les prix par 100 scale =100 model.Add(sum(int(current_prices[i] * scale) * quantity[i] for i inrange(n)) <=int(budget * scale) )# Contrainte 2: Cardinalite model.Add(sum(selected) >= min_assets) model.Add(sum(selected) <= max_assets)# Contrainte 3: Quantite > 0 seulement si selectionnefor i inrange(n): model.Add(quantity[i] <= max_per_asset * selected[i]) model.Add(quantity[i] >= selected[i]) # Au moins 1 si selectionne# Objectif: maximiser le rendement attendu# Approximation en entiers scaled_returns = [int(r *1000) for r in returns]# On maximise sum(return_i * q_i) portfolio_value =sum(scaled_returns[i] * quantity[i] for i inrange(n)) model.Maximize(portfolio_value)# Resolution solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: portfolio = {} total_cost =0for i inrange(n): q = solver.Value(quantity[i])if q >0: cost = q * current_prices[i] portfolio[assets[i]] = {'quantity': q,'price': current_prices[i],'cost': cost,'expected_return': returns[i] } total_cost += costreturn {'portfolio': portfolio,'total_cost': total_cost,'num_assets': len(portfolio),'status': 'OPTIMAL'if status == cp_model.OPTIMAL else'FEASIBLE' }return {'portfolio': {}, 'total_cost': 0, 'num_assets': 0, 'status': 'INFEASIBLE'}print("Fonction solve_portfolio_optimization definie.")
Fonction solve_portfolio_optimization definie.
Optimisation de portefeuille avec rendement et covariance.
Exemple d’optimisation de portefeuille
Nous allons résoudre un problème de sélection de portefeuille avec : - 5 actifs (AAPL, GOOGL, MSFT, AMZN, TSLA) - Budget total de $50,000 - Cardinalité : entre 2 et 3 actifs maximum - Objectif : maximiser le rendement attendu basé sur l’historique des prix
Le solveur CP-SAT doit trouver la combinaison d’actifs qui maximise le rendement tout en respectant les contraintes de budget et de diversification.
Status: OPTIMAL
Nombre d'actifs: 3
Coût total: $50000.00
Portefeuille:
AAPL: 100 x $165.00 = $16500.00 (rendement: 10.0%)
MSFT: 100 x $325.00 = $32500.00 (rendement: 8.3%)
TSLA: 1 x $1000.00 = $1000.00 (rendement: 11.1%)
Interpretation : Optimisation de Portefeuille
Sortie obtenue : Le solveur sélectionne 3 actifs (AAPL, MSFT, TSLA) pour un coût total de $50,000, maximisant le rendement attendu tout en respectant les contraintes de cardinalité (2-3 actifs).
Actif
Quantité
Coût
Rendement
Contribution
AAPL
100
$16,500
10.0%
33% du budget
MSFT
100
$32,500
8.3%
65% du budget
TSLA
1
$1,000
11.1%
2% du budget
Points cles : 1. Maximisation du rendement : TSLA (11.1%) et AAPL (10.0%) sont favorisés pour leur rendement élevé 2. Contrainte de cardinalité : Exactement 3 actifs sélectionnés (maximum autorisé) 3. Allocation du budget : Utilisation complète du budget ($50,000/$50,000) 4. Sélection gloutonne : GOOGL et AMZN ne sont pas sélectionnés malgré leur présence, car leur rendement est moins favorable
Note technique : Ce modèle simplifié utilise le rendement historique simple ((prix_final - prix_initial) / prix_initial). En pratique, on utiliserait la moyenne des rendements, la variance (risque), et les corrélations entre actifs pour une optimisation plus robuste (théorie moderne du portefeuille de Markowitz).
5. Comparaison des méthodes
Problème
Variables
Contraintes
Complexité
Bin Packing
BoolVar + IntVar
Capacité, Symétrie
NP-hard
Knapsack
BoolVar
Capacité
NP-hard (pseudo-poly)
Cutting Stock
IntVar
Demande, Capacité
NP-hard
Portfolio
BoolVar + IntVar
Budget, Cardinalité
NP-hard
Heuristiques vs Optimal
Bin Packing: FFD souvent à 11% de l’optimal
Knapsack: Greedy par ratio valeur/poids, bon pour grandes instances
Cutting Stock: Column Generation pour grandes instances
Portfolio: Mean-variance avec relaxation continue
# Benchmark: Heuristique First Fit Decreasing pour Bin Packingdef bin_packing_ffd(items: List[int], capacity: int) -> Dict:""" Heuristique First Fit Decreasing pour Bin Packing. """ sorted_items =sorted(enumerate(items), key=lambda x: -x[1]) bins = [] # Liste des charges actuelles assignments = {} # item_id -> bin_idfor item_id, size in sorted_items: placed =Falsefor bin_id, load inenumerate(bins):if load + size <= capacity: bins[bin_id] += size assignments[item_id] = bin_id placed =Truebreakifnot placed: bins.append(size) assignments[item_id] =len(bins) -1return {'num_bins': len(bins),'bins': bins,'assignments': assignments,'status': 'HEURISTIC' }# Comparaison — instance de la section 1 (items [2,2,2,3,5,6], capacité 10)# La variable globale capacity peut avoir été écrasée par une section ultérieure# (Knapsack: capacity = 20) — utiliser une variable locale pour FFD afin que la# comparaison CP-SAT vs FFD porte BIEN sur la même instance.capacity_local =10# capacité de la section 1 Bin Packingffd_result = bin_packing_ffd(items, capacity_local)print(f"CP-SAT optimal: {bpp_result['num_bins']} bins")print(f"FFD heuristic: {ffd_result['num_bins']} bins")if bpp_result['num_bins']: gap = (ffd_result['num_bins'] - bpp_result['num_bins']) / bpp_result['num_bins'] *100print(f"Gap: {gap:.1f}%")
Interpretation : Comparaison CP-SAT vs Heuristique FFD
Sortie obtenue : sur cette instance (objets [2, 2, 2, 3, 5, 6], capacité 10), l’heuristique First Fit Decreasing (FFD) utilise 3 bins alors que CP-SAT prouve l’optimal à 2 bins — un gap de 50 %. C’est précisément le cas où une heuristique gloutonne échoue et où le solveur exact justifie son coût.
Aspect
Valeur CP-SAT
Valeur FFD
Analyse
Nombre de bins
2
3
FFD dépasse l’optimal sur cette instance
Statut
OPTIMAL
HEURISTIC
CP-SAT garantit l’optimalité
Gap
-
50.0 %
FFD utilise 50 % plus de bins que l’optimal
Pourquoi FFD échoue-t-elle ici ? FFD trie les objets par taille décroissante [6, 5, 3, 2, 2, 2] puis place chaque objet dans le premier bin où il tient : - 6 → Bin 0 (charge 6), 5 → Bin 1 (6+5 > 10), 3 → Bin 0 (charge 9), puis les trois 2 remplissent Bin 1 (5+2+2 = 9) et le dernier 2 doit ouvrir un Bin 2. - FFD gaspille le 3 en le plaçant avec le 6 (charge 9, perte 1) au lieu de le réserver à un bin [5, 3, 2]. L’optimal [6, 2, 2] + [5, 3, 2] remplit parfaitement deux bins (charge 10 chacun), sans aucune perte.
Points clés : 1. Une heuristique ne bat jamais l’optimal : par définition, l’optimum est le nombre minimal de bins. FFD ne peut donc qu’égaler ce minimum (gap 0 %) ou l’excéder (gap > 0 %), jamais faire mieux — ici 50 %. 2. Garantie d’optimalité : CP-SAT prouve que 2 bins est l’optimal (status OPTIMAL) ; FFD s’arrête à 3 sans pouvoir certifier qu’il n’en existe pas moins. 3. Borne théorique : FFD garantit au pire 11/9 × OPT + 1 bins. Avec OPT = 2, cela donne environ 3,4 → au plus 4 bins ; FFD obtient ici 3, conforme à sa garantie mais au-dessus de l’optimal. Cette instance illustre que la borne 11/9 n’est pas qu’un résultat théorique : l’écart se matérialise sur des cas concrets. 4. Quand le solveur exact s’impose : c’est sur ce type d’instance défavorable que CP-SAT apporte sa valeur — il prouve l’optimal (2) là où l’heuristique gloutonne échoue (3). En production, FFD donne une solution rapide non certifiée ; CP-SAT certifie le vrai minimum, au prix d’un temps de calcul supérieur.
Note technique : FFD reste excellente en pratique (souvent à l’optimal ou très proche), mais sa nature gloutonne la rend vulnérable aux instances où un choix localement optimal (3 → Bin 0) empêche le packing globalement optimal. CP-SAT explore l’espace complet et trouve le vrai minimum.
Benchmark de passage a l’echelle : ou la garantie d’optimalite a un cout
La comparaison ci-dessus (n=6, gap 50 %) montre CP-SAT battre FFD sur une instance soigneusement choisie. Mais cette instance est-elle representative ? Et que se passe-t-il quand la taille du probleme augmente ? Mesurons FFD vs CP-SAT sur l’instance du notebook plus des instances aleatoires croissantes (tailles d’objets uniformes dans [10, capacity/2], graine fixee pour la reproductibilite), en chronometrant les deux methodes.
# Benchmark de passage a l'echelle : FFD vs CP-SAT (solve_bin_packing_cp, bin_packing_ffd definis plus haut)import timeimport randomdef _scaling_instance(n, seed=42, capacity=100): random.seed(seed)return [random.randint(10, capacity //2) for _ inrange(n)]# L'instance adverse du notebook (gap 50 %) + instances reproductibles croissantesscaling = [(6, [2, 2, 2, 3, 5, 6], 10)]for _n in [20, 50, 100]: scaling.append((_n, _scaling_instance(_n), 100))print("Scaling : ou la garantie d'optimalite de CP-SAT devient couteuse")print(f"{'n':>5}{'FFD':>5}{'CP-SAT':>7}{'gap%':>6}{'t(FFD)':>9}{'t(CP-SAT)':>11}{'status':>10}")print("-"*60)for n, items, cap in scaling: t0 = time.time() f = bin_packing_ffd(items, cap)["num_bins"] t_ffd = time.time() - t0 t0 = time.time() r = solve_bin_packing_cp(items, cap) t_cp = time.time() - t0 c = r["num_bins"] gap = (f - c) / c *100if c else0.0print(f"{n:>5}{f:>5}{c:>7}{gap:>5.1f}% {t_ffd:>7.3f}s {t_cp:>9.2f}s {r['status']:>10}")
Scaling : ou la garantie d'optimalite de CP-SAT devient couteuse
n FFD CP-SAT gap% t(FFD) t(CP-SAT) status
------------------------------------------------------------
6 3 2 50.0% 0.000s 0.01s OPTIMAL
20 6 6 0.0% 0.000s 0.04s OPTIMAL
50 15 14 7.1% 0.000s 0.48s OPTIMAL
100 30 30 0.0% 0.000s 30.41s FEASIBLE
Interpretation : FFD quasi-optimale en pratique, garantie CP-SAT limitee par la taille
Resultat mesure :
L’instance du notebook (n=6, gap 50 %) est adversariale : c’est un cas soigneusement choisi pour illustrer l’echec de FFD. Sur des instances aleatoires, FFD est quasi-optimale : gap de 0 % a n=20 et n=100, 7 % a n=50. Une heuristique gloutonne bien concue echoue rarement aussi severement que le suggere l’exemple isole.
Le cout de CP-SAT croit avec la taille : de quelques dizaines de millisecondes (n=6) a une demi-seconde (n=50), jusqu’a atteindre la limite du budget temps a n=100 (ordres de grandeur ; mesures exactes : cf la sortie du benchmark ci-dessus). A n=100, CP-SAT retourne le statut FEASIBLE, pas OPTIMAL : il a trouve une bonne solution mais ne peut plus prouver l’optimalite dans le temps imparti. La certification – l’avantage distinctif du solveur exact – disparait donc sur les grandes instances.
Compromis pratique : FFD offre une solution quasi-optimale en temps constant, insensible a la taille (sub-milliseconde : cf mesure ci-dessus) ; CP-SAT certifie l’optimalite seulement quand l’instance est assez petite pour le prouver dans le budget temps. Sur une grande instance, CP-SAT se transforme en une heuristique couteuse (cf la mesure a n=100) sans garantie – souvent moins utile que FFD qui a deja donne une quasi-optimale instantanement. La regle empirique : utiliser le solveur exact pour certifier l’optimal sur des instances de taille moyenne (ici n = 50 se prouve en une fraction de seconde), et s’en remettre a FFD (ou a un CP-SAT avec limite de temps courte comme solution approximee) des que la taille explose.
Note (mandat #9377/#9434) : les durees wall-clock de cette interpretation (anciennement epinglees : 20 ms, 0,5 s, 30 s, < 1 ms) sont machine-dependantes par construction (charge CPU du runner, version OR-Tools) et ont ete drainees. Le rapport d’ordres de grandeur (cout CP-SAT croissant avec la taille, perte de certification OPTIMAL -> FEASIBLE a n=100, FFD sub-milliseconde insensible a la taille) est reproductible d’une execution a l’autre (instances seedes, seed = 42) et conserve. Les mesures exactes restent visibles dans la sortie du benchmark de la cellule ci-dessus. Les resultats algorithmiques (gaps 50 % / 0 % / 7 %, statuts OPTIMAL / FEASIBLE) sont deterministes et conserves.
6. Dominance Breaking
Dominance Breaking est une technique d’optimisation qui elimine les solutions dominees avant même de les explorer. Une solution \(s_1\)domine\(s_2\) si :
\(s_1\) est au moins aussi bonne que \(s_2\) sur tous les critères
\(s_1\) est strictement meilleure sur au moins un critere
En eliminant les solutions dominees, on reduit l’espace de recherche sans perdre la solution optimale.
Definition de la dominance
Definition : Une solution \(s_1\)domine\(s_2\) si : 1. \(s_1\) est au moins aussi bonne que \(s_2\) sur tous les objectifs 2. \(s_1\) est strictement meilleure sur au moins un objectif
Dans ce cas, explorer \(s_2\) est inutile car \(s_1\) sera toujours preferee.
# Implementation du Dominance Breakingfrom typing import List, Tupleimport numpy as npclass DominanceAnalyzer:""" Analyse et detection des relations de dominance dans les solutions. Utile pour post-traitement ou pour guider la recherche. """def__init__(self, num_objectives: int=1):self.num_objectives = num_objectivesself.solutions: List[Tuple] = [] # (values, solution_data)def add_solution(self, objective_values: List[float], solution_data: any):"""Ajoute une solution candidate."""self.solutions.append((objective_values, solution_data))def check_dominance(self, sol1_idx: int, sol2_idx: int) ->bool:""" Verifie si sol1 domine sol2. Pour un probleme de minimisation : - sol1 domine sol2 si sol1[i] <= sol2[i] pour tout i - et sol1[j] < sol2[j] pour au moins un j """ obj1, _ =self.solutions[sol1_idx] obj2, _ =self.solutions[sol2_idx]# Verifier la dominance Pareto at_least_one_better =False all_at_least_equal =Truefor i inrange(len(obj1)):if obj1[i] > obj2[i]: # Minimisation all_at_least_equal =Falsebreakif obj1[i] < obj2[i]: at_least_one_better =Truereturn all_at_least_equal and at_least_one_betterdef find_pareto_front(self) -> List[int]:"""Trouve les indices des solutions non-dominees (front de Pareto).""" pareto_indices = []for i inrange(len(self.solutions)): dominated =Falsefor j inrange(len(self.solutions)):if i != j andself.check_dominance(j, i): dominated =Truebreakifnot dominated: pareto_indices.append(i)return pareto_indices# Exemple : Dominance dans le Bin Packingprint("=== Dominance Breaking dans le Bin Packing ===\n")# Simulons plusieurs solutions pour un probleme de bin packinganalyzer = DominanceAnalyzer(num_objectives=2) # Objectifs: nombre de bins, gaspillage maxsolutions = [ ([3, 2.5], "Solution A: 3 bins, gaspillage 2.5"), # 3 bins, some waste ([3, 1.0], "Solution B: 3 bins, gaspillage 1.0"), # 3 bins, less waste - DOMINE A ([4, 0.0], "Solution C: 4 bins, gaspillage 0.0"), # 4 bins, no waste - DOMINEE par B ([2, 5.0], "Solution D: 2 bins, gaspillage 5.0"), # 2 bins, much waste - peut-etre optimal]for obj_vals, desc in solutions: analyzer.add_solution(obj_vals, desc)print("Solutions candidates:")for i, (obj_vals, desc) inenumerate(solutions):print(f" {i}: {desc} - Objectifs: {obj_vals}")pareto_front = analyzer.find_pareto_front()print(f"\nFront de Pareto (solutions non-dominees):")for idx in pareto_front: obj_vals, desc = solutions[idx]print(f" {idx}: {desc}")
Reduction de l’espace de recherche : Elimine les branches de l’arbre de recherche qui menent a des solutions dominees.
Acceleration de la convergence : Le solveur se concentre sur les solutions potentiellement optimales.
Multi-objectif : Particulierement utile en optimisation multi-objectif ou le front de Pareto peut etre grand.
Implementation pratique :
OR-Tools CP-SAT integre automatiquement certaines formes de dominance breaking.
Pour des problemes spécifiques, des contraintes de dominance peuvent etre ajoutees manuellement.
Combine avec le cassage de symetries pour un effet maximal.
Attention : Le dominance breaking doit etre prouve correct. Une dominance incorrecte peut eliminer des solutions optimales !
7. Exemple guide
Exemple guide 1: Multi-Knapsack
Généralisez le Knapsack à plusieurs sacs avec capacités différentes.
Exemple guide 2: Bin Packing avec contraintes de conflit
Certains objets ne peuvent pas être dans le même bin. Ajoutez cette contrainte.
Exemple guide 3: Portfolio avec contraintes de risque
Ajoutez une contrainte de variance maximale du portefeuille.
Exemple guide 4: Cutting Stock avec chutes réutilisables
Les chutes de longueur > seuil peuvent être réutilisées comme nouvelles barres.
# Exemple resolu : Multi-Knapsack (plusieurs sacs)def solve_multi_knapsack(weights: List[int], values: List[int], capacities: List[int]) -> Dict:"""Multi-Knapsack : chaque objet va dans au plus un sac. Variables : x[i, k] = 1 ssi l'objet i est place dans le sac k. Contraintes : - sum_k x[i, k] <= 1 (chaque objet dans au plus un sac) - sum_i w_i * x[i, k] <= C_k (capacite de chaque sac) Objectif : maximiser la valeur totale des objets places. """ n =len(weights) K =len(capacities)assertlen(values) == n, "values et weights de meme taille" model = cp_model.CpModel() x = {(i, k): model.NewBoolVar(f"x_{i}_{k}")for i inrange(n) for k inrange(K)}for i inrange(n): model.Add(sum(x[i, k] for k inrange(K)) <=1)for k inrange(K): model.Add(sum(weights[i] * x[i, k] for i inrange(n)) <= capacities[k]) model.Maximize(sum(values[i] * x[i, k] for i inrange(n) for k inrange(K))) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): bags = {k: [] for k inrange(K)} loads = {k: 0for k inrange(K)}for i inrange(n):for k inrange(K):if solver.Value(x[i, k]) ==1: bags[k].append(i) loads[k] += weights[i]return {"total_value": int(solver.ObjectiveValue()),"bags": bags,"loads": loads,"capacities": capacities,"status": "OPTIMAL"if status == cp_model.OPTIMAL else"FEASIBLE", }return {"total_value": 0, "bags": {}, "status": "INFEASIBLE"}# Test avec 8 objets et 2 sacs de capacites differentesresult_mk = solve_multi_knapsack(weights, values, capacities=[10, 15])print(f"Status : {result_mk['status']}")print(f"Valeur tot : {result_mk['total_value']}")for k, items_in_bag in result_mk["bags"].items(): load = result_mk["loads"][k]; cap = result_mk["capacities"][k] val =sum(values[i] for i in items_in_bag)print(f" Sac {k} ({load}/{cap}) : objets {items_in_bag} valeur = {val}")
Consignes : 1. Utilisez solve_multi_knapsack définie dans l’exemple ci-dessus 2. Affichez le contenu de chaque sac, sa charge et sa valeur 3. Verifiez qu’aucun objet n’est place dans plus d’un sac
# Exercice 1b : Multi-Knapsack avec 3 sacs# Exercice: definissez les nouvelles donnees (12 objets, 3 sacs)new_weights = [3, 6, 2, 4, 8, 5, 7, 1, 9, 3, 4, 6]new_values = [5, 9, 3, 7, 12, 8, 10, 2, 14, 4, 6, 11]new_caps = [12, 18, 10]# Exercice: appelez solve_multi_knapsack et affichez les resultats# Indice : utilisez la meme fonction que dans l'exemple# Votre code iciprint("Exercice a completer")
Exercice a completer
Exemple guide 2 : Bin Packing avec contraintes de conflit
Certaines paires d’objets ne peuvent pas cohabiter dans le même bin (produits chimiques incompatibles, allergies alimentaires, etc.). On ajoute une contrainte x[i, j] + x[k, j] <= 1 pour chaque paire en conflit.
# Exemple resolu : Bin Packing avec conflitsdef solve_bin_packing_conflicts(items: List[int], capacity: int, conflicts: List[Tuple[int, int]]) -> Dict:"""Bin Packing avec paires d'objets interdites dans le meme bin.""" n =len(items) max_bins = n model = cp_model.CpModel() x = {(i, j): model.NewBoolVar(f"x_{i}_{j}")for i inrange(n) for j inrange(max_bins)} y = [model.NewBoolVar(f"y_{j}") for j inrange(max_bins)]for i inrange(n): model.Add(sum(x[i, j] for j inrange(max_bins)) ==1)for j inrange(max_bins): model.Add(sum(items[i] * x[i, j] for i inrange(n)) <= capacity * y[j])# Symmetry breaking : bins utilises dans l'ordrefor j inrange(max_bins -1): model.Add(y[j] >= y[j +1])# Contraintes de conflitfor (a, b) in conflicts:for j inrange(max_bins): model.Add(x[a, j] + x[b, j] <=1) model.Minimize(sum(y)) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): bins = {}for i inrange(n):for j inrange(max_bins):if solver.Value(x[i, j]) ==1: bins.setdefault(j, []).append(i)return {"num_bins": int(solver.ObjectiveValue()),"bins": bins,"bin_loads": {j: sum(items[i] for i in it) for j, it in bins.items()},"status": "OPTIMAL"if status == cp_model.OPTIMAL else"FEASIBLE", }return {"num_bins": None, "bins": {}, "status": "INFEASIBLE"}# Avec conflits : (0, 2), (3, 7) ne peuvent pas etre ensembleconflicts = [(0, 2), (3, 7), (1, 4)]res_base = solve_bin_packing_cp(items, capacity)res_conf = solve_bin_packing_conflicts(items, capacity, conflicts)print(f"Bin packing sans conflits : {res_base['num_bins']} bins")print(f"Bin packing avec conflits : {res_conf['num_bins']} bins (conflits={conflicts})")for bid, it in res_conf["bins"].items():print(f" Bin {bid} ({res_conf['bin_loads'][bid]}/{capacity}) : objets {it} tailles {[items[i] for i in it]}")# Verification qu'aucun conflit n'est violefor (a, b) in conflicts: same = [bid for bid, it in res_conf["bins"].items() if a in it and b in it]assertnot same, f"Conflit ({a}, {b}) viole dans les bins {same}"print("Tous les conflits sont respectes.")
Bin packing sans conflits : None bins
Bin packing avec conflits : None bins (conflits=[(0, 2), (3, 7), (1, 4)])
Tous les conflits sont respectes.
Exercice 2b : Bin Packing avec conflits etendus
Enonce : Resolvez un Bin Packing avec 12 objets et des conflits plus nombreux.
Consignes : 1. Utilisez solve_bin_packing_conflicts définie dans l’exemple ci-dessus 2. Comparez le nombre de bins avec et sans conflits 3. Verifiez qu’aucune paire en conflit ne partage le même bin
# Exercice 2b : Bin Packing avec conflits etendus# Exercice: definissez les nouvelles donneesnew_items = [5, 3, 7, 2, 8, 4, 6, 1, 9, 3, 5, 2]new_capacity =15new_conflicts = [(0, 3), (1, 6), (2, 5), (4, 8), (7, 10), (0, 9)]# Exercice: resolvez avec solve_bin_packing_conflicts# Exercice: comparez avec solve_bin_packing_cp (sans conflits)# Indice : les conflits forcent souvent un bin supplementaire# Votre code iciprint("Exercice a completer")
Exercice a completer
Exemple guide 3 : Portfolio avec contrainte de risque (variance bornee)
On ajoute une borne superieure sur la variance du portefeuille. La variance quadratique n’est pas lineaire ; on la lineariseau par la somme des variances individuelles ponderees (diagonale de la matrice de covariance), ce qui est une approximation couramment utilisee pour un premier modèle.
# Exemple resolu : Portfolio avec contrainte de risquedef solve_portfolio_with_risk( assets: List[str], prices: List[List[float]], budget: float, max_assets: int, min_assets: int, max_per_asset: int, max_variance_per_euro: float,) -> Dict:"""Portefeuille avec contrainte de risque. Approximation : la variance du portefeuille est approximee par sum_i q_i * var(actif_i) (diagonale de la matrice de covariance). On borne cette somme par max_variance_per_euro * cout_total. """ n =len(assets) current_prices = [p[-1] for p in prices] returns = [expected_return(p) for p in prices] variances = [float(np.var(p)) for p in prices] scale =100 scaled_prices = [int(p * scale) for p in current_prices] scaled_returns = [int(r *1000) for r in returns] scaled_var = [int(v * scale) for v in variances] scaled_var_limit =int(max_variance_per_euro * scale * scale) model = cp_model.CpModel() sel = [model.NewBoolVar(f"s_{i}") for i inrange(n)] q = [model.NewIntVar(0, max_per_asset, f"q_{i}") for i inrange(n)] total_cost =sum(scaled_prices[i] * q[i] for i inrange(n)) model.Add(total_cost <=int(budget * scale)) model.Add(sum(sel) >= min_assets) model.Add(sum(sel) <= max_assets)for i inrange(n): model.Add(q[i] <= max_per_asset * sel[i]) model.Add(q[i] >= sel[i])# Contrainte de variance : sum_i var_i * q_i <= max_var_per_euro * cout# (var_i * q_i approxime la variance associee a la position i) lhs =sum(scaled_var[i] * q[i] for i inrange(n)) model.Add(lhs * scale <= scaled_var_limit * total_cost) model.Maximize(sum(scaled_returns[i] * q[i] for i inrange(n))) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): portfolio = {}; cost =0.0; variance =0.0for i inrange(n): qv = solver.Value(q[i])if qv >0: c = qv * current_prices[i] portfolio[assets[i]] = {"quantity": qv, "price": current_prices[i],"cost": c, "expected_return": returns[i],"variance": variances[i]} cost += c variance += variances[i] * qvreturn {"portfolio": portfolio, "total_cost": cost,"estimated_variance_per_euro": variance / cost if cost else0,"status": "OPTIMAL"if status == cp_model.OPTIMAL else"FEASIBLE"}return {"portfolio": {}, "status": "INFEASIBLE"}res = solve_portfolio_with_risk( assets, prices, budget=50000, max_assets=3, min_assets=2, max_per_asset=100, max_variance_per_euro=10.0,)print(f"Status : {res['status']}")if res["status"] !="INFEASIBLE":print(f"Cout total : ${res['total_cost']:.2f}")print(f"Variance estimee par euro : {res['estimated_variance_per_euro']:.3f}")for a, info in res["portfolio"].items():print(f" {a}: {info['quantity']} x ${info['price']:.2f} "f"(var = {info['variance']:.1f}, rendement = {info['expected_return']*100:.1f}%)")
Status : OPTIMAL
Cout total : $50000.00
Variance estimee par euro : 0.233
AAPL: 100 x $165.00 (var = 25.0, rendement = 10.0%)
MSFT: 100 x $325.00 (var = 74.0, rendement = 8.3%)
TSLA: 1 x $1000.00 (var = 1760.0, rendement = 11.1%)
Exercice 3b : Portefeuille conservative
Enonce : Construisez un portefeuille conservatif avec une contrainte de variance plus stricte (max_variance_per_euro = 3.0 au lieu de 10.0).
Consignes : 1. Utilisez solve_portfolio_with_risk définie dans l’exemple ci-dessus 2. Selectionnez entre 2 et 4 actifs avec une variance max par euro de 3.0 3. Comparez le rendement avec le portefeuille moins contraint de l’exemple
# Exercice 3b : Portefeuille conservative# Exercice: definissez les 6 actifs avec leurs historiques de prixassets_conservative = ['AAPL', 'GOOGL', 'MSFT', 'AMZN', 'TSLA', 'NVDA']prices_conservative = [ [150, 155, 160, 158, 165], [2800, 2850, 2900, 2880, 2950], [300, 310, 315, 320, 325], [3200, 3150, 3300, 3400, 3350], [900, 950, 880, 920, 1000], [450, 480, 510, 490, 520],]# Exercice: appelez solve_portfolio_with_risk avec max_variance_per_euro=3.0# Indice : avec 6 actifs au lieu de 5, vous avez plus de choix# Votre code iciprint("Exercice a completer")
Exercice a completer
Exemple guide 4 : Cutting Stock avec chutes reutilisables
Les chutes de longueur superieure a un seuil reuse_threshold peuvent etre considerees comme de nouvelles barres. On modelise ceci en creant une deuxieme catégorie de barres de longueur dynamique (capturee par une variable bornee par l’ensemble des longueurs de chutes possibles) ; une approche plus simple suffit ici : on accepte deux longueurs de stock et on force les barres du deuxieme type a provenir d’une chute (via un petit cout d’usage différent).
# Exemple resolu : Cutting Stock avec chutes reutilisables## Idee : on utilise deux "niveaux" de barres :# - niveau 0 : barres neuves de longueur stock_length# - niveau 1 : barres issues des chutes (longueur <= stock_length - reuse_threshold# pour modeliser le fait qu'une chute courte ne merite pas d'etre gardee)# On minimise le nombre de barres neuves, puis le gaspillage total en second critere.def solve_cutting_stock_reuse(piece_lengths: List[int], demands: List[int], stock_length: int, reuse_threshold: int) -> Dict:"""Cutting Stock ou chaque barre neuve peut ceder sa chute comme nouvelle barre. Modele : chaque barre neuve j peut creer au plus 1 barre "chute" j', dont la longueur est l'espace restant de j s'il depasse reuse_threshold, 0 sinon. """ n_types =len(piece_lengths) max_bars =sum(demands) model = cp_model.CpModel()# y[b, i] : nombre de pieces de type i coupees dans la barre (neuve) b y = {(b, i): model.NewIntVar(0, demands[i], f"y_{b}_{i}")for b inrange(max_bars) for i inrange(n_types)}# yc[b, i] : nombre de pieces de type i coupees dans la chute de la barre b yc = {(b, i): model.NewIntVar(0, demands[i], f"yc_{b}_{i}")for b inrange(max_bars) for i inrange(n_types)} z = [model.NewBoolVar(f"z_{b}") for b inrange(max_bars)] # barre neuve utilisee ? zc = [model.NewBoolVar(f"zc_{b}") for b inrange(max_bars)] # chute reutilisee ?# Demande : total des coupes (barre + chute) >= demandefor i inrange(n_types): model.Add(sum(y[b, i] + yc[b, i] for b inrange(max_bars)) >= demands[i])# Capacite : longueur utilisee par la barre neuve <= stock_length * z[b]for b inrange(max_bars): used =sum(piece_lengths[i] * y[b, i] for i inrange(n_types)) usedc =sum(piece_lengths[i] * yc[b, i] for i inrange(n_types)) model.Add(used + usedc <= stock_length * z[b])# Une chute ne peut etre reutilisee que si la barre neuve est utilisee model.Add(zc[b] <= z[b])# Une chute reutilisee doit contenir au moins une piece (sinon zc reste 0) model.Add(usedc <= stock_length * zc[b])# Interdire l'utilisation d'une chute si la longueur restante < reuse_threshold# (chute nette = stock_length - used), donc usedc <= stock_length - used ;# on veut que la chute soit "significative" : si usedc > 0, on veut reuse. model.Add(usedc >= zc[b]) # au moins 1 (ou 0 si zc = 0)# Symmetry breaking : ordre des barres neuvesfor b inrange(max_bars -1): model.Add(z[b] >= z[b +1])# Objectif : minimiser le nombre de barres neuves. Bonus : penaliser les chutes reutilisees# avec un poids plus faible (ce sont des barres gratuites si elles permettent d'economiser). model.Minimize(sum(z) *1000-sum(zc)) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =30 status = solver.Solve(model)if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): patterns = []for b inrange(max_bars):if solver.Value(z[b]) ==1: main = {i: solver.Value(y[b, i]) for i inrange(n_types)if solver.Value(y[b, i]) >0} chute = {i: solver.Value(yc[b, i]) for i inrange(n_types)if solver.Value(yc[b, i]) >0} used_main =sum(piece_lengths[i] * c for i, c in main.items()) used_chute =sum(piece_lengths[i] * c for i, c in chute.items()) patterns.append({"bar_id": b,"main": main, "main_length": used_main,"reused_chute": bool(chute),"chute_pieces": chute, "chute_length": used_chute,"waste": stock_length - used_main - used_chute, }) num_new =sum(1for p in patterns) num_reused =sum(1for p in patterns if p["reused_chute"])return {"num_new_bars": num_new,"num_reused_chutes": num_reused,"patterns": patterns,"status": "OPTIMAL"if status == cp_model.OPTIMAL else"FEASIBLE", }return {"num_new_bars": None, "patterns": [], "status": "INFEASIBLE"}res = solve_cutting_stock_reuse(piece_lengths, demands, stock_length=100, reuse_threshold=20)print(f"Status : {res['status']}")print(f"Barres neuves : {res['num_new_bars']} | Chutes reutilisees : {res['num_reused_chutes']}")for p in res["patterns"]: tag =" (+ chute)"if p["reused_chute"] else"" pieces_main = [(piece_lengths[i], c) for i, c in p["main"].items()]print(f" Barre {p['bar_id']}{tag} : coupe {pieces_main} len={p['main_length']}")if p["reused_chute"]: pieces_chute = [(piece_lengths[i], c) for i, c in p["chute_pieces"].items()]print(f" chute : {pieces_chute} len={p['chute_length']} reste={p['waste']}")
Exercice 4b : Cutting Stock avec seuil de reutilisation eleve
Enonce : Resolvez un Cutting Stock avec des demandes plus elevees et un seuil de reutilisation plus strict (30 cm au lieu de 20 cm).
Données : - Longueurs de pieces : [15, 22, 35, 40] cm - Demandes : [8, 6, 5, 3] - Longueur des barres : 120 cm - Seuil de reutilisation : 30 cm
Consignes : 1. Utilisez solve_cutting_stock_reuse définie dans l’exemple ci-dessus 2. Comparez avec solve_cutting_stock_cp (sans reutilisation des chutes) 3. Affichez le nombre de barres neuves et le nombre de chutes reutilisees
# Exercice 4b : Cutting Stock avec seuil de reutilisation eleve# Exercice: definissez les nouvelles donneesnew_piece_lengths = [15, 22, 35, 40]new_demands = [8, 6, 5, 3]new_stock_length =120new_reuse_thresh =30# Exercice: resolvez avec solve_cutting_stock_reuse# Exercice: comparez avec solve_cutting_stock_cp (sans chutes)# Indice : un seuil plus eleve signifie moins de chutes reutilisables# Votre code iciprint("Exercice a completer")
Exercice a completer
Annexe : parité lib-vs-lib — le même moteur Choco que le jumeau C
Les sections précédentes ont résolu les quatre problèmes d’optimisation avec OR-Tools CP-SAT (moteur C++ de Google). Le jumeau C# de ce notebook (CSP-5-Optimization-CSharp.ipynb) résout les mêmes familles de problèmes sur Choco-solver 4.10.17 via IKVM. Les deux moteurs sont SOTA-tier, mais différents : c’est l’asymétrie de machinerie documentée dans le registre de parité (#8057/#10382). Avec pychoco — le binding Python officiel de Choco (PyPI, MIT) — le jumeau Python peut rejouer les modèles du jumeau C# sur le même moteur industriel : c’est la parité lib-vs-lib, plus fine que la parité concept-vs-concept.
Chaque démonstration ci-dessous reprend l’instance exacte du jumeau C# (cellules 6/8/10/12) — instances volontairement distinctes de celles des sections 1 à 4, pour que l’annexe mesure la convergence des moteurs sans biaiser la comparaison du corps du notebook.
# Parite lib-vs-lib : le meme moteur Choco que le jumeau C# (pychoco)# pychoco = binding Python officiel de Choco-solver (PyPI, MIT) -- meme moteur# que le Choco 4.10.17/IKVM du jumeau C#. Instances = cellules 6/8/10/12 du twin C#.import timeinstall_if_missing('pychoco')try:import pychoco as pc HAS_PYCHOCO =TrueexceptImportError: HAS_PYCHOCO =Falseprint("pychoco non disponible. Installer avec : pip install pychoco")# --- Demo 1 (twin C# cell 6) : Bin Packing [2, 5, 4, 7, 2], capacite 10 ---# Borne inferieure ceil(20/10) = 2 bins INFAISABLE (le 7 ne partage avec# rien d'autre qu'un 2 : 7+4=11, 7+5=12). Optimum reel : 3 bins.if HAS_PYCHOCO: sizes5 = [2, 5, 4, 7, 2] n5, cap5 =len(sizes5), 10 nbins5 = n5 # majorant : chaque objet dans son propre bin m1 = pc.Model() bin_of5 = [m1.intvar(0, nbins5 -1, name=f"b_{i}") for i inrange(n5)] loads5 = [m1.intvar(0, cap5, name=f"load_{j}") for j inrange(nbins5)]# eq[i][j] = 1 ssi objet i dans bin j (reification iff, comme le bEqJ du C#) eq5 = [[m1.boolvar(name=f"eq_{i}_{j}") for j inrange(nbins5)] for i inrange(n5)]for i inrange(n5):for j inrange(nbins5): m1.arithm(bin_of5[i], "=", j).reify_with(eq5[i][j])# Charge du bin j = somme des tailles des objets qui y sont (scalar)for j inrange(nbins5): m1.scalar([eq5[i][j] for i inrange(n5)], sizes5, "=", loads5[j]).post()# Symetrie brisee (compacite) : les bins vides en queue -- pattern Choco# "C1 => C2" : reifier C1 puis if_then(reif, C2), comme le C#.for j inrange(nbins5 -1): rj = m1.boolvar(name=f"cmp_{j}") m1.arithm(loads5[j], "=", 0).reify_with(rj) m1.if_then(rj, m1.arithm(loads5[j +1], "=", 0)) nb_used5 = m1.intvar(1, nbins5, name="nb_used") used5 = [m1.boolvar(name=f"used_{j}") for j inrange(nbins5)]for j inrange(nbins5): m1.arithm(loads5[j], ">", 0).reify_with(used5[j]) # iff m1.sum(used5, "=", nb_used5).post() m1.set_objective(nb_used5, maximize=False) # MINIMIZE t0 = time.perf_counter() best5, best_assign5 =0, Nonewhile m1.get_solver().solve(): best5 = nb_used5.get_value() best_assign5 = [bin_of5[i].get_value() for i inrange(n5)] dt1 = (time.perf_counter() - t0) *1000print(f"Bin Packing : nombre optimal de bins = {best5} (capacite {cap5}) [{dt1:.0f} ms]")for j inrange(nbins5): inbin = [i for i inrange(n5) if best_assign5[i] == j]if inbin: ld =sum(sizes5[i] for i in inbin)print(f" Bin {j} (charge {ld}/{cap5}) : objets {inbin} tailles {[sizes5[i] for i in inbin]}")
Bin Packing : nombre optimal de bins = 3 (capacite 10) [1 ms]
Bin 0 (charge 2/10) : objets [4] tailles [2]
Bin 1 (charge 9/10) : objets [1, 2] tailles [5, 4]
Bin 2 (charge 9/10) : objets [0, 3] tailles [2, 7]
Interprétation : Bin Packing — le même optimum que le jumeau C
Sortie obtenue : 3 bins — le même optimum que la cellule BPP du jumeau C# sur la même instance [2, 5, 4, 7, 2]. La borne inférieure ceil(20/10) = 2 est inatteignable (le 7 ne peut partager qu’avec un 2) : les deux moteurs prouvent l’optimum à 3. L’assignation peut différer parmi les optima équivalents — ce qui converge ici, c’est la grandeur optimale certifiée, pas le choix de branche à qualité égale.
Le modèle est ligne à ligne celui du C# : réification eq[i,j] = (b[i] = j) puis scalar pour les charges (le C# décompose de la même façon, faute de contrainte globale sur ce premier modèle), symétrie brisée par compacité (if_then sur « bin vide ⇒ suivant vide »), objectif nb_used minimisé par la boucle solve() jusqu’à épuisement — le pattern branch-and-bound natif de Choco.
# --- Demo 2 (twin C# cell 8) : Knapsack 0/1, 5 objets, capacite 10 ---# Poids [2,3,4,5,1], valeurs [6,10,12,15,1]. Optimum connu : 31 (objets {0,1,3}).if HAS_PYCHOCO: w5 = [2, 3, 4, 5, 1] v5 = [6, 10, 12, 15, 1] kcap5 =10 m2 = pc.Model() take5 = [m2.boolvar(name=f"take_{i}") for i inrange(5)] tw5 = m2.intvar(0, sum(w5), name="total_weight") tv5 = m2.intvar(0, sum(v5), name="total_value") m2.scalar(take5, w5, "=", tw5).post() m2.arithm(tw5, "<=", kcap5).post() m2.scalar(take5, v5, "=", tv5).post() m2.set_objective(tv5, maximize=True) # MAXIMIZE t0 = time.perf_counter() best_v5, best_w5, best_t5 =0, 0, Nonewhile m2.get_solver().solve(): best_v5, best_w5 = tv5.get_value(), tw5.get_value() best_t5 = [t.get_value() for t in take5] dt2 = (time.perf_counter() - t0) *1000 sel5 = [i for i inrange(5) if best_t5[i] ==1]print(f"Knapsack : valeur optimale = {best_v5} (poids {best_w5}/{kcap5}) [{dt2:.0f} ms]")print(f" Objets selectionnes : {sel5}")print(f" Poids : {[w5[i] for i in sel5]}, Valeurs : {[v5[i] for i in sel5]}")
Interprétation : Knapsack — convergence totale avec le jumeau C
Sortie obtenue : valeur optimale 31, poids exactement saturé 10/10, objets sélectionnés {0, 1, 3} — identiques à la cellule Knapsack du jumeau C#, jusqu’au choix d’optimum. L’instance n’admet qu’un seul optimum à 31 ({2,3,5} = 10 de poids, valeur 6+10+15) : la convergence des grandeurs et de la sélection matérialise ce que « même moteur » signifie — la modélisation scalar + set_objective(maximize=True) + boucle solve() est le miroir exact du setObjective(true, totalValue) du C#.
À rapprocher de la section 2 ci-dessus : CP-SAT (moteur C++) et Choco (moteur Java) trouvent chacun l’optimum sur leurs instances respectives — ici, les deux jumeaux exécutent le même bytecode Choco sous deux bindings (pychoco côté Python, IKVM côté .NET).
Interprétation : Cutting Stock — la même décomposition optimale que le jumeau C
Sortie obtenue : 10 barres, avec exactement la structure prédite par l’analyse d’instance : 3 barres [9] isolées, 4 barres [7] isolées, 3 barres [4, 4] — la même décomposition que la cellule Cutting Stock du jumeau C#. L’instance force la solution (aucun groupement inter-type n’est possible), donc l’optimum est unique en structure : les deux moteurs convergent sur le multiset complet des patterns.
Le point lib-vs-lib de cette démo : la contrainte globalebin_packing du C# (modelCs.binPacking(...)) existe à l’identique côté pychoco (m3.bin_packing(...)). C’est l’idiome natif de Choco pour le Bin Packing / Cutting Stock : la propagation intégrée au solveur est plus forte que la décomposition scalar de la démo 1. À comparer à la section 3 ci-dessus, où CP-SAT modélise le Cutting Stock en variables de comptage par pattern (y[j,i]) : deux modélisations distinctes du même problème, un même optimum — la contrainte globale encode la structure du domaine directement dans le solveur.
# --- Demo 4 (twin C# cell 12) : Portefeuille lineaire, 5 actifs ---# Rendements [5,12,8,15,10], risques [10,20,15,30,18], max 3 actifs,# risque total <= 50. Deux optima a 27 : {1,3} (risque 50) et {0,1,4} (risque 48).if HAS_PYCHOCO: r5 = [5, 12, 8, 15, 10] k5 = [10, 20, 15, 30, 18] maxa5, riskcap5 =3, 50 m4 = pc.Model() hold5 = [m4.boolvar(name=f"hold_{i}") for i inrange(5)] tr5 = m4.intvar(0, sum(r5), name="total_return") tk5 = m4.intvar(0, sum(k5), name="total_risk") nh5 = m4.intvar(0, 5, name="nb_hold") m4.scalar(hold5, r5, "=", tr5).post() m4.scalar(hold5, k5, "=", tk5).post() m4.sum(hold5, "=", nh5).post() m4.arithm(nh5, "<=", maxa5).post() m4.arithm(tk5, "<=", riskcap5).post() m4.set_objective(tr5, maximize=True) t0 = time.perf_counter() best_r5, best_k5, best_h5 =0, 0, Nonewhile m4.get_solver().solve(): best_r5, best_k5 = tr5.get_value(), tk5.get_value() best_h5 = [h.get_value() for h in hold5] dt4 = (time.perf_counter() - t0) *1000 sel4 = [i for i inrange(5) if best_h5[i] ==1]print(f"Portfolio : rendement optimal = {best_r5} (risque {best_k5}/{riskcap5}, {len(sel4)}/{maxa5} actifs) [{dt4:.0f} ms]")print(f" Actifs selectionnes : {sel4}")print(f" Rendements : {[r5[i] for i in sel4]}, Risques : {[k5[i] for i in sel4]}")
Interprétation : Portfolio — le même optimum, le même choix parmi les exqua-optima
Sortie obtenue : rendement optimal 27, risque 48/50, actifs {0, 1, 4} — exactement la solution de la cellule Portfolio du jumeau C#. L’instance admet deux optima à 27 ({1,3} au risque 50 exact, {0,1,4} au risque 48) : les deux jumeaux retombent sur le même. La version CP reste linéaire (proxy de risque = somme des risques marginaux, cf section 4) : le solveur par contraintes ne gère pas les produits de variables — la frontière complète moyenne-variance de Markowitz exigerait un solveur quadratique.
Bilan de l’annexe — parité lib-vs-lib mesurée sur les 4 démonstrations du jumeau C# :
Démonstration
Observable
Python (pychoco)
Jumeau C# (Choco/IKVM)
Convergence
Bin Packing [2,5,4,7,2]/10
bins optimaux
3
3
optimum identique
Knapsack 5 objets cap 10
valeur / sélection
31 / {0,1,3}
31 / {0,1,3}
totale
Cutting Stock 13 pièces
barres / patterns
10 / 3×[9] + 4×[7] + 3×[4,4]
10 / idem
structure identique
Portfolio 5 actifs
rendement / actifs
27 / {0,1,4}
27 / {0,1,4}
totale
Avec cette annexe, le jumeau Python exécute le même moteur industriel (Choco-solver) que le jumeau C# — la parité passe de concept-vs-concept (sections 1-4 : CP-SAT d’un côté, Choco de l’autre) à lib-vs-lib (registre de parité #8057/#10382) : verdict SOTA-OK.
Références
OR-Tools Bin Packing: https://developers.google.com/optimization/bin
Knapsack Problems (2004): H. Kellerer, U. Pferschy, D. Pisinger
Integer Programming (1998): L. Wolsey
Modern Portfolio Theory: H. Markowitz (1952)
Dantzig, G.B. (1957). Discrete-Variable Extremum Problems. Opérations Research 5(2):266-288.
Johnson, D.S. (1974). Fast Algorithms for Bin Packing. Journal of Computer and System Sciences 8(3):272-314.
Gilmore, P.C. & Gomory, R.E. (1961). A Linear Programming Approach to the Cutting-Stock Problem. Opérations Research 9(6):849-859.
Markowitz, H. (1952). Portfolio Sélection. The Journal of Finance 7(1):77-91.
Conclusion
Ce notebook a exploré les problèmes d’optimisation combinatoire où l’on cherche la meilleure solution parmi toutes les solutions valides.
Concepts clés
Concept
Description
CSP d’optimisation
CSP avec fonction objective à minimiser/maximiser
Branch & Bound
Recherche avec bornes pour élaguer les sous-optimaux
Programmation linéaire
Relaxation continue pour les bornes
Méthodes exactes
Garantissent l’optimalité (mais coûteuses)
Méthodes approchées
Heuristiques/Métaheuristiques (rapides mais sans garantie)
Approches pour l’optimisation
Approche
Principe
Garantie
Coût
Branch & Bound
Arbre de recherche + bornes
Oui (optimale)
Exponentiel
PLNE
Programmation linéaire en nombres entiers
Oui
Très élevé
Recherche locale
Voisinage + hill climbing
Non
Faible
Métaheuristiques
Population, essaims, recuit
Non
Moyen
Points clés à retenir
L’optimisation ajoute une couche de complexité aux CSP standards
Branch & Bound est la base des solveurs d’optimisation exacts
Les bornes (LP relaxation) sont cruciales pour l’efficacité
Pour les problèmes NP-durs, les méthodes approchées sont souvent préférées
Le choix de la méthode dépend du compromis temps/qualité souhaité