Algorithmes Génétiques
Quand on ne connaît pas la solution à un problème complexe, on peut parfois laisser l'évolution la trouver pour nous. Les Algorithmes Génétiques s'inspirent de la théorie de l'évolution de Darwin.
Le Concept : L'Évolution Artificielle
L'idée est de créer une population de "solutions potentielles" (même mauvaises) et de les faire évoluer sur des milliers de générations pour qu'elles s'améliorent.
Le Cycle de Vie Algorithmique Détaillé
graph TB
A[Population Initiale<br/>Génération aléatoire] --> B[Évaluation Fitness<br/>Calculer scores]
B --> C{Critère<br/>Stop?}
C -->|Non| D[Sélection<br/>Choisir parents]
C -->|Oui| Z[Solution Finale]
D --> E[Crossover<br/>Reproduction]
E --> F[Mutation<br/>Variations aléatoires]
F --> G[Nouvelle Génération]
G --> B
style A fill:#2196F3
style C fill:#f44336
style Z fill:#4CAF50
style E fill:#FF9800800800
1. Population Initiale
On génère N solutions au hasard (ex: 100 configurations d'un emploi du temps).
import random
def generate_random_solution(size):
"""Génère une solution aléatoire (liste de 0 et 1)"""
return [random.randint(0, 1) for _ in range(size)]
# Population de 50 individus de taille 10
population = [generate_random_solution(10) for _ in range(50)]
2. Évaluation (Fitness Function)
On donne une note à chaque solution. C'est la fonction la plus importante : elle définit ce qu'est une "bonne" solution.
Exemples de fonctions fitness :
# Exemple 1: Maximiser le nombre de 1
def fitness_count_ones(individual):
return sum(individual)
# Exemple 2: Optimisation de fonction mathématique
def fitness_rastrigin(individual):
"""Plus la valeur est faible, meilleure est la solution"""
n = len(individual)
A = 10
total = A * n
for x in individual:
total += x**2 - A * np.cos(2 * np.pi * x)
return -total # Négation car on maximise le fitness
# Exemple 3: Problème du sac à dos (knapsack)
def fitness_knapsack(individual, weights, values, max_weight):
total_weight = sum(w * gene for w, gene in zip(weights, individual))
total_value = sum(v * gene for v, gene in zip(values, individual))
if total_weight > max_weight:
return 0 # Solution invalide
return total_value
3. Sélection (Selection)
On choisit les individus qui vont se reproduire. Plusieurs méthodes :
Sélection par Tournoi (Tournament Selection)
def tournament_selection(population, fitnesses, k=3):
"""
Sélectionne le meilleur parmi k individus tirés au hasard
k=3 est un bon compromis
"""
tournament = random.sample(list(zip(population, fitnesses)), k)
winner = max(tournament, key=lambda x: x[1])
return winner[0]
Sélection par Roulette (Roulette Wheel Selection)
def roulette_selection(population, fitnesses):
"""Probabilité de sélection proportionnelle au fitness"""
total_fitness = sum(fitnesses)
pick = random.uniform(0, total_fitness)
current = 0
for individual, fitness in zip(population, fitnesses):
current += fitness
if current > pick:
return individual
Sélection Élitiste
def elitist_selection(population, fitnesses, n_elite=2):
"""Garde les n meilleurs individus automatiquement"""
sorted_pop = [x for _, x in sorted(zip(fitnesses, population),
key=lambda pair: pair[0],
reverse=True)]
return sorted_pop[:n_elite]
4. Crossover (Reproduction)
On mélange le "code génétique" de deux parents pour créer des enfants.
Crossover à 1 Point
def single_point_crossover(parent1, parent2):
"""Coupe et échange à un point aléatoire"""
point = random.randint(1, len(parent1) - 1)
child1 = parent1[:point] + parent2[point:]
child2 = parent2[:point] + parent1[point:]
return child1, child2
# Exemple:
# Parent1: [1,1,1,1,1]
# ↓ point=2
# Parent2: [0,0,0,0,0]
# Child1: [1,1,0,0,0]
# Child2: [0,0,1,1,1]
Crossover à 2 Points
def two_point_crossover(parent1, parent2):
"""Échange le segment entre deux points"""
size = len(parent1)
point1, point2 = sorted(random.sample(range(1, size), 2))
child1 = parent1[:point1] + parent2[point1:point2] + parent1[point2:]
child2 = parent2[:point1] + parent1[point1:point2] + parent2[point2:]
return child1, child2
Crossover Uniforme
def uniform_crossover(parent1, parent2, prob=0.5):
"""Chaque gène a 50% de venir de parent1 ou parent2"""
child1 = []
child2 = []
for g1, g2 in zip(parent1, parent2):
if random.random() < prob:
child1.append(g1)
child2.append(g2)
else:
child1.append(g2)
child2.append(g1)
return child1, child2
5. Mutation
De temps en temps, on change un détail au hasard. Cela évite la convergence prématurée (rester bloqué dans un optimum local).
def mutate(individual, mutation_rate=0.01):
"""
Flip chaque bit avec une probabilité mutation_rate
Taux typique: 0.01 à 0.1 (1% à 10%)
"""
mutated = individual.copy()
for i in range(len(mutated)):
if random.random() < mutation_rate:
mutated[i] = 1 - mutated[i] # Flip 0→1 ou 1→0
return mutated
# Pour valeurs continues
def mutate_continuous(individual, mutation_rate=0.1, mutation_strength=0.5):
"""Ajoute du bruit gaussien"""
mutated = individual.copy()
for i in range(len(mutated)):
if random.random() < mutation_rate:
mutated[i] += random.gauss(0, mutation_strength)
return mutated
Exemple Complet : Problème du Voyageur de Commerce (TSP)
Le TSP est un problème NP-difficile : trouver le plus court chemin visitant N villes exactement une fois.
Implémentation avec DEAP
import random
import numpy as np
from deap import base, creator, tools, algorithms
# Coordonnées de 10 villes
cities = np.random.rand(10, 2) * 100
def distance(city1, city2):
"""Distance euclidienne entre deux villes"""
return np.sqrt((city1[0] - city2[0])**2 + (city1[1] - city2[1])**2)
def total_distance(tour):
"""Fitness: distance totale du parcours (à minimiser)"""
dist = 0
for i in range(len(tour)):
dist += distance(cities[tour[i]], cities[tour[(i+1) % len(tour)]])
return (dist,) # DEAP exige un tuple
# Configuration DEAP
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) # Minimisation
creator.create("Individual", list, fitness=creator.FitnessMin)
toolbox = base.Toolbox()
toolbox.register("indices", random.sample, range(len(cities)), len(cities))
toolbox.register("individual", tools.initIterate, creator.Individual, toolbox.indices)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)
# Opérateurs génétiques
toolbox.register("mate", tools.cxOrdered) # Crossover pour permutations
toolbox.register("mutate", tools.mutShuffleIndexes, indpb=0.05)
toolbox.register("select", tools.selTournament, tournsize=3)
toolbox.register("evaluate", total_distance)
# Évolution
def run_ga():
random.seed(42)
pop = toolbox.population(n=100)
hof = tools.HallOfFame(1) # Garde le meilleur
stats = tools.Statistics(lambda ind: ind.fitness.values)
stats.register("min", np.min)
stats.register("avg", np.mean)
pop, logbook = algorithms.eaSimple(
pop, toolbox,
cxpb=0.7, # Probabilité de crossover
mutpb=0.2, # Probabilité de mutation
ngen=200, # 200 générations
stats=stats,
halloffame=hof,
verbose=True
)
best = hof[0]
print(f"\nMeilleur parcours: {best}")
print(f"Distance: {total_distance(best)[0]:.2f}")
return best, logbook
best_tour, log = run_ga()
Résultat typique :
gen nevals min avg
0 100 458.234 652.891
10 70 312.456 398.234
50 70 278.123 312.456
100 70 265.891 285.234
200 70 254.123 268.456
Meilleur parcours: [0, 3, 7, 2, 9, 4, 1, 8, 5, 6]
Distance: 254.12
Visualisation de l'Évolution
import matplotlib.pyplot as plt
def plot_evolution(logbook):
gen = logbook.select("gen")
min_fit = logbook.select("min")
avg_fit = logbook.select("avg")
plt.figure(figsize=(10, 5))
plt.plot(gen, min_fit, label="Meilleur", linewidth=2)
plt.plot(gen, avg_fit, label="Moyenne", linestyle="--")
plt.xlabel("Génération")
plt.ylabel("Distance totale")
plt.title("Évolution du TSP")
plt.legend()
plt.grid(True)
plt.show()
plot_evolution(log)
Exemple Simplifié Sans Bibliothèque
import random
import numpy as np
# Problème: Maximiser f(x) = x² où x ∈ [0, 31]
# Encodage: x en binaire sur 5 bits (00000 à 11111)
def decode(binary):
"""Convertit liste binaire en entier"""
return int(''.join(map(str, binary)), 2)
def fitness(individual):
"""Fitness = x²"""
x = decode(individual)
return x ** 2
def genetic_algorithm():
POP_SIZE = 20
GENE_SIZE = 5
GENERATIONS = 50
MUTATION_RATE = 0.01
# Population initiale
population = [[random.randint(0, 1) for _ in range(GENE_SIZE)]
for _ in range(POP_SIZE)]
for gen in range(GENERATIONS):
# Évaluation
fitnesses = [fitness(ind) for ind in population]
# Nouvelle génération
new_population = []
for _ in range(POP_SIZE // 2):
# Sélection par tournoi
parent1 = tournament_selection(population, fitnesses, k=3)
parent2 = tournament_selection(population, fitnesses, k=3)
# Crossover
child1, child2 = single_point_crossover(parent1, parent2)
# Mutation
child1 = mutate(child1, MUTATION_RATE)
child2 = mutate(child2, MUTATION_RATE)
new_population.extend([child1, child2])
population = new_population
# Affichage
best_fitness = max(fitnesses)
best_individual = population[fitnesses.index(best_fitness)]
if gen % 10 == 0:
print(f"Gen {gen}: Meilleur x={decode(best_individual)}, f(x)={best_fitness}")
# Résultat final
fitnesses = [fitness(ind) for ind in population]
best_individual = population[fitnesses.index(max(fitnesses))]
print(f"\nSolution finale: x={decode(best_individual)}, f(x)={fitness(best_individual)}")
genetic_algorithm()
Sortie :
Gen 0: Meilleur x=29, f(x)=841
Gen 10: Meilleur x=31, f(x)=961
Gen 20: Meilleur x=31, f(x)=961
Gen 30: Meilleur x=31, f(x)=961
Gen 40: Meilleur x=31, f(x)=961
Solution finale: x=31, f(x)=961
Analogie Biologique Complète
| Biologie | Informatique (IA) | Exemple TSP |
|---|---|---|
| ADN | Code de la solution | [0,3,7,2,9,4,1,8,5,6] |
| Individu | Une solution candidate | Un parcours complet |
| Environnement | Le problème à résoudre | La carte avec les villes |
| Survie | Score de performance (Fitness) | Distance totale (à minimiser) |
| Mutation | Changement aléatoire | Échanger deux villes |
| Crossover | Reproduction sexuée | Mélanger deux parcours |
| Sélection Naturelle | Survie du plus apte | Garder les parcours courts |
| Génération | Itération de l'algorithme | Population à l'instant t |
Cas d'Usage en Infrastructure
1. Optimisation de Placement de VMs
# Objectif: Placer N VMs sur M serveurs physiques
# Contraintes: CPU, RAM, bande passante
# Objectif: Minimiser nombre de serveurs utilisés + équilibrer la charge
def fitness_vm_placement(individual, vms, servers):
"""
individual: [0,2,1,0,3] signifie VM0→Serveur0, VM1→Serveur2, etc.
"""
server_load = {i: {'cpu': 0, 'ram': 0} for i in range(len(servers))}
# Calculer charge de chaque serveur
for vm_idx, server_idx in enumerate(individual):
server_load[server_idx]['cpu'] += vms[vm_idx]['cpu']
server_load[server_idx]['ram'] += vms[vm_idx]['ram']
# Pénalités
penalty = 0
for server_idx, load in server_load.items():
if load['cpu'] > servers[server_idx]['cpu_max']:
penalty += 1000 # Violation contrainte
if load['ram'] > servers[server_idx]['ram_max']:
penalty += 1000
# Score: minimiser serveurs utilisés + équilibrer charge
servers_used = len([l for l in server_load.values() if l['cpu'] > 0])
load_variance = np.var([l['cpu'] for l in server_load.values()])
return -(servers_used * 10 + load_variance + penalty)
2. Ordonnancement de Tâches Cron
# Optimiser horaires de backups/maintenances pour minimiser impact
# Variables: heure de démarrage, jour de la semaine
# Contraintes: pas de chevauchement, fenêtres de maintenance
def fitness_cron_schedule(individual, tasks):
"""
individual: [(hour, day), ...] pour chaque tâche
"""
score = 0
# Bonus si tâches en heures creuses (2h-5h)
for hour, day in individual:
if 2 <= hour <= 5:
score += 10
# Pénalité si chevauchements
for i, (h1, d1) in enumerate(individual):
for j, (h2, d2) in enumerate(individual[i+1:], i+1):
if d1 == d2 and abs(h1 - h2) < tasks[i]['duration']:
score -= 50 # Chevauchement!
return score
3. Optimisation de Configurations
# Trouver paramètres optimaux pour un service (cache size, threads, timeout)
# Fitness = throughput / latency sur benchmark réel
def fitness_config_tuning(individual):
"""
individual: [cache_size, n_threads, timeout, buffer_size]
"""
# Lancer benchmark avec ces paramètres
result = run_benchmark(
cache_size=individual[0],
threads=individual[1],
timeout=individual[2],
buffer=individual[3]
)
# Maximiser throughput, minimiser latency
return result['throughput'] / result['latency_p95']
Paramètres Clés et Tuning
| Paramètre | Valeurs typiques | Impact |
|---|---|---|
| Taille Population | 50-500 | Plus = exploration, mais lent |
| Nombre Générations | 100-1000 | Jusqu'à convergence |
| Taux Crossover | 0.6-0.9 | 70-80% recommandé |
| Taux Mutation | 0.001-0.1 | 1-5% typique, empêche stagnation |
| Pression Sélection | Tournoi k=3-7 | Plus = convergence rapide mais risque local minimum |
| Élitisme | 1-5% | Garde meilleurs pour éviter régression |
Avantages et Limites
Avantages
- Pas de gradient : Fonctionne sur problèmes non-différentiables
- Parallélisable : Évaluation fitness indépendante
- Robuste : Explore largement l'espace de recherche
- Universel : Adaptable à tout problème avec fonction fitness
Limites
- Lent : Nécessite beaucoup d'évaluations (milliers)
- Pas de garantie : Trouve des bonnes solutions, rarement l'optimum global
- Tuning : Paramètres sensibles (taux mutation, taille pop...)
- Fitness Design : Qualité de solution dépend de la fonction fitness
Quand Utiliser un GA ?
Utilisez un GA si : - Espace de recherche discret et large (combinatoire) - Pas de méthode exacte connue ou trop lente - Fonction fitness facile à évaluer - Solution "assez bonne" acceptable (pas besoin d'optimum parfait)
N'utilisez PAS un GA si : - Problème a une solution analytique - Espace continu et différentiable → Gradient Descent - Évaluation fitness très coûteuse (>1s) → Bayesian Optimization - Contraintes complexes → Solveurs CP/SAT
Note : Ce n'est pas de l'apprentissage (Machine Learning) au sens strict, c'est de l'Optimisation Stochastique (Recherche aléatoire dirigée). Les GAs ne généralisent pas : ils trouvent une solution pour UNE instance du problème.