GiaPol commited on
Commit
d3955c7
·
verified ·
1 Parent(s): 2b78521

Upload 2 files

Browse files
Files changed (2) hide show
  1. ga_tuned_config.json +14 -0
  2. genetic_algorithm.py +483 -0
ga_tuned_config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "best_selection": "tournament",
3
+ "best_crossover": "two-point",
4
+ "best_mutation": "adaptive",
5
+ "opt_pop_size": 150,
6
+ "opt_mutation_rate": 0.013088885992441601,
7
+ "weights": [
8
+ 1.0,
9
+ 0.4,
10
+ 0.2
11
+ ],
12
+ "model_generalization_score": 0.9726962365591397,
13
+ "analysis_date": "2026-03-15 12:46:16"
14
+ }
genetic_algorithm.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import json
4
+ import numpy as np
5
+ import pandas as pd
6
+ from typing import List, Tuple, Dict
7
+ from dataclasses import dataclass, asdict
8
+
9
+ # Setup logging
10
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
11
+ logger = logging.getLogger(__name__)
12
+
13
+ @dataclass
14
+ class GAParams:
15
+ """Configurazione parametri GA."""
16
+ pop_size: int = 100
17
+ generations: int = 100
18
+ crossover_rate: float = 0.8
19
+ mutation_rate: float = 0.01
20
+ elitism_rate: float = 0.05
21
+ patience: int = 20
22
+ selection_method: str = "tournament"
23
+ crossover_method: str = "two-point"
24
+ mutation_method: str = "adaptive"
25
+ weights: Tuple[float, float, float] = (1.0, 0.4, 0.2)
26
+ tournament_size: int = 3
27
+ truncation_rate: float = 0.5
28
+ k_points: int = 2
29
+ max_freq_threshold: int = 16
30
+ seed: int = 42
31
+ load_gold_standard: bool = False # Mantenuto per compatibilità con l'API del Notebook
32
+
33
+ # Campi derivati (Properties) per evitare stati non sincronizzati
34
+ @property
35
+ def w_retention(self) -> float:
36
+ return self.weights[0]
37
+
38
+ @property
39
+ def w_penalty_freq(self) -> float:
40
+ return self.weights[1]
41
+
42
+ @property
43
+ def w_penalty_time(self) -> float:
44
+ return self.weights[2]
45
+
46
+ def to_dict(self):
47
+ return asdict(self)
48
+
49
+ @classmethod
50
+ def from_dict(cls, data):
51
+ # Mappatura sicura per ignorare chiavi extra o mappate diversamente
52
+ valid_keys = cls.__dataclass_fields__.keys()
53
+ filtered_data = {k: v for k, v in data.items() if k in valid_keys}
54
+ return cls(**filtered_data)
55
+
56
+ def save(self, path: str):
57
+ with open(path, 'w') as f:
58
+ json.dump(self.to_dict(), f, indent=4)
59
+
60
+ @classmethod
61
+ def load_gold_standard_config(cls, path: str = "ga_tuned_config.json") -> "GAParams":
62
+ """Factory method pulito per caricare il JSON solo quando esplicitamente richiesto."""
63
+ if not os.path.exists(path):
64
+ logger.warning(f"File {path} non trovato. Fallback ai parametri di default.")
65
+ return cls()
66
+ try:
67
+ with open(path, 'r') as f:
68
+ config = json.load(f)
69
+
70
+ mapping = {
71
+ "selection_method": config.get("best_selection", "tournament"),
72
+ "crossover_method": config.get("best_crossover", "two-point"),
73
+ "mutation_method": config.get("best_mutation", "adaptive"),
74
+ "pop_size": config.get("opt_pop_size", 100),
75
+ "mutation_rate": config.get("opt_mutation_rate", 0.01),
76
+ "weights": tuple(config.get("weights", [1.0, 0.4, 0.2]))
77
+ }
78
+ return cls(**mapping)
79
+ except Exception as e:
80
+ logger.error(f"Errore caricamento JSON: {e}")
81
+ return cls()
82
+
83
+
84
+ class Chromosome:
85
+ """Rappresenta una strategia di nudging (genotipo)."""
86
+ GENE_LENGTHS = {
87
+ 'tipologia': 2,
88
+ 'frequenza': 5,
89
+ 'orario': 24
90
+ }
91
+ TOTAL_LENGTH = sum(GENE_LENGTHS.values())
92
+
93
+ def __init__(self, bits: np.ndarray = None, rng: np.random.Generator = None):
94
+ if bits is None:
95
+ # Blindiamo la riproducibilità usando il generatore RNG se fornito
96
+ if rng is not None:
97
+ self.bits = rng.integers(0, 2, self.TOTAL_LENGTH, dtype=np.int8)
98
+ else:
99
+ self.bits = np.random.randint(0, 2, self.TOTAL_LENGTH, dtype=np.int8)
100
+ else:
101
+ if len(bits) != self.TOTAL_LENGTH:
102
+ raise ValueError(f"Dimensione errata. Attesa: {self.TOTAL_LENGTH}, Trovata: {len(bits)}")
103
+ self.bits = np.array(bits, dtype=np.int8)
104
+ self._fitness = None
105
+
106
+ def decode(self) -> Dict:
107
+ """Decodifica il genotipo nel fenotipo (parametri reali della strategia)."""
108
+ b = self.bits
109
+ tip_bits, freq_bits, ora_bits = b[0:2], b[2:7], b[7:31]
110
+
111
+ tipologia_idx = int(tip_bits.dot(1 << np.arange(tip_bits.size)[::-1]))
112
+ frequenza = int(freq_bits.dot(1 << np.arange(freq_bits.size)[::-1]))
113
+
114
+ tipologie = ["Promemoria", "Motivazionale", "Informativa", "Questionario"]
115
+ frequenza = max(1, frequenza) # Evita strategie da 0 notifiche
116
+
117
+ return {
118
+ 'tipologia': tipologie[tipologia_idx],
119
+ 'frequenza_settimanale': frequenza,
120
+ 'orari_attivi': [h for h, val in enumerate(ora_bits) if val == 1],
121
+ 'start_hour': next((h for h, val in enumerate(ora_bits) if val == 1), 9),
122
+ 'end_hour': next((h for h, val in enumerate(reversed(ora_bits)) if val == 1), 18)
123
+ }
124
+
125
+ @property
126
+ def fitness(self):
127
+ return self._fitness
128
+
129
+ @fitness.setter
130
+ def fitness(self, value):
131
+ self._fitness = value
132
+
133
+
134
+ class FitnessEvaluator:
135
+ """Calcola la fitness pesata a priori (Fase 0)."""
136
+
137
+ def __init__(self, patient_features: pd.Series, params: GAParams = None, rng: np.random.Generator = None):
138
+ self.patient_features = patient_features
139
+ self.params = params if params is not None else GAParams()
140
+ self.rng = rng if rng is not None else np.random.default_rng(self.params.seed)
141
+
142
+ # --- IL CUORE DEL BENCHMARK ---
143
+ # Questo contatore ci permette di fare un confronto "ad armi pari" con la Random Search
144
+ self.evaluation_calls = 0
145
+
146
+ def evaluate(self, chromosome: Chromosome, patient_features: pd.Series = None) -> float:
147
+ self.evaluation_calls += 1
148
+
149
+ fetch_features = patient_features if patient_features is not None else self.patient_features
150
+ phenotype = chromosome.decode()
151
+
152
+ retention_score = self._simulate_retention(phenotype, fetch_features)
153
+
154
+ mood_freq = fetch_features.get('mood_frequency_7d', 0.5)
155
+ dynamic_threshold = self.params.max_freq_threshold
156
+ if mood_freq > 0.8:
157
+ dynamic_threshold += 5
158
+ if mood_freq < 0.2:
159
+ dynamic_threshold -= 5
160
+
161
+ freq = phenotype['frequenza_settimanale']
162
+ # --- TASSA CONTINUA FREQUENZA ---
163
+ base_tax_f = (freq / 31.0) * 0.1 # Ogni messaggio ha un micro-costo di attenzione
164
+ penalty_freq = base_tax_f
165
+ if freq > dynamic_threshold:
166
+ diff = freq - dynamic_threshold
167
+ penalty_freq = min(1.0, base_tax_f + (np.exp(0.2 * diff) - 1) / 50) # Muro clinico
168
+
169
+ night_rate = fetch_features.get('night_activity_rate', 0.0)
170
+ night_hours = [23, 0, 1, 2, 3, 4, 5, 6]
171
+ active_hours = phenotype['orari_attivi']
172
+
173
+ # --- TASSA CONTINUA TEMPORALE ---
174
+ penalty_time = 0.0
175
+ if not active_hours:
176
+ penalty_time = 1.0
177
+ else:
178
+ # Ogni ora occupata ha una 'tassa di ingombro cognitivo'
179
+ base_tax_t = (len(active_hours) / 24.0) * 0.05
180
+ active_night_hours = sum(1 for h in active_hours if h in night_hours)
181
+ time_sensitivity = max(0.2, 1.0 - night_rate)
182
+ night_penalty = (active_night_hours / 8.0) * time_sensitivity
183
+ penalty_time = min(1.0, base_tax_t + night_penalty)
184
+
185
+ raw_fitness = (self.params.w_retention * retention_score) - \
186
+ (self.params.w_penalty_freq * penalty_freq) - \
187
+ (self.params.w_penalty_time * penalty_time)
188
+
189
+ return max(0.0001, float(raw_fitness))
190
+
191
+ def _simulate_retention(self, phenotype: Dict, features: pd.Series) -> float:
192
+ """Simulatore del Patient Environment."""
193
+ mood_freq = features.get('mood_frequency_7d', 0.5)
194
+ avg_valence = features.get('avg_mood_valence_7d', 0.5)
195
+ read_rate = features.get('notification_read_rate', 0.5)
196
+
197
+ # 1. Identificazione Archetipi (Gerarchica e Coerente con Data Pipeline)
198
+ # Engaged: Il paziente ideale (Alta attività e umore stabile/positivo)
199
+ is_engaged = (mood_freq >= 0.6) and (avg_valence >= 0.5)
200
+
201
+ # Ghost: Il paziente che ha abbandonato (attività nulla o quasi)
202
+ is_ghost = not is_engaged and (mood_freq < 0.1) and (read_rate < 0.2)
203
+
204
+ # A Rischio: Il paziente in crisi (calo attività o umore negativo/preoccupante)
205
+ is_at_risk = not (is_engaged or is_ghost) and ((mood_freq < 0.3) or (avg_valence < 0.45))
206
+
207
+ # Moderato: Il paziente stabile, uso intermittente
208
+ is_moderato = not (is_engaged or is_ghost or is_at_risk)
209
+
210
+ score = 0.5
211
+ tipo = phenotype['tipologia']
212
+ freq = phenotype['frequenza_settimanale']
213
+
214
+ if is_engaged:
215
+ # Allineamento: L'engaged vuole mantenere l'abitudine (Promemoria)
216
+ if tipo == 'Promemoria':
217
+ score += 0.2
218
+ if 7 <= freq <= 14:
219
+ score += 0.2
220
+ if freq > 25:
221
+ score -= 0.3 # Anche l'engaged si stanca
222
+ elif is_at_risk:
223
+ # Allineamento: Chi è in crisi ha bisogno di motivazione o info
224
+ if tipo in ['Motivazionale', 'Informativa']:
225
+ score += 0.3
226
+ if 3 <= freq <= 7:
227
+ score += 0.2
228
+ if freq > 10:
229
+ score -= 0.2
230
+ elif is_ghost:
231
+ # Allineamento: Chi è sparito va recuperato con cautela
232
+ if tipo == 'Motivazionale':
233
+ score += 0.3
234
+ if freq <= 2:
235
+ score += 0.2
236
+ if freq > 5:
237
+ score -= 0.4 # Effetto spam garantito
238
+ elif is_moderato:
239
+ if tipo == 'Questionario':
240
+ score += 0.2
241
+ if tipo == 'Promemoria':
242
+ score += 0.1
243
+ if 4 <= freq <= 10:
244
+ score += 0.2
245
+ if freq > 25:
246
+ score -= 0.3
247
+
248
+ if len(phenotype['orari_attivi']) == 0:
249
+ score = 0.0
250
+
251
+ return max(0.0, min(1.0, score))
252
+
253
+
254
+ class GeneticAlgorithm:
255
+ """Implementa il loop evolutivo con operatori configurabili (Fase 1 e 2)."""
256
+
257
+ def __init__(self, evaluator: FitnessEvaluator, params: GAParams, rng: np.random.Generator = None):
258
+ self.evaluator = evaluator
259
+ self.params = params
260
+ self.evaluator.params = self.params
261
+ self.population: List[Chromosome] = []
262
+ self.rng = rng if rng is not None else np.random.default_rng(self.params.seed)
263
+
264
+ self.history = {
265
+ "best_fitness": [],
266
+ "avg_fitness": [],
267
+ "diversity": []
268
+ }
269
+ self.current_diversity = 1.0
270
+
271
+ def initialize_population(self):
272
+ self.population = []
273
+ for _ in range(self.params.pop_size):
274
+ # Passiamo l'RNG locale per evitare leakage stocastico
275
+ self.population.append(Chromosome(rng=self.rng))
276
+ self._evaluate_population()
277
+
278
+ def _evaluate_population(self):
279
+ # Valuta solo chi non ha la fitness (Risparmio computazionale)
280
+ for ind in self.population:
281
+ if ind.fitness is None:
282
+ ind.fitness = self.evaluator.evaluate(ind)
283
+ self.population.sort(key=lambda x: x.fitness, reverse=True)
284
+
285
+ # --- SELECTION METHODS ---
286
+ def _select(self) -> Chromosome:
287
+ method = self.params.selection_method.lower()
288
+ if method == "tournament":
289
+ return self._selection_tournament()
290
+ elif method == "roulette":
291
+ return self._selection_roulette()
292
+ elif method == "ranking":
293
+ return self._selection_ranking()
294
+ elif method == "truncation":
295
+ return self._selection_truncation()
296
+ else:
297
+ return self._selection_roulette()
298
+
299
+ def _selection_tournament(self) -> Chromosome:
300
+ candidates = self.rng.choice(self.population, size=self.params.tournament_size, replace=False)
301
+ best = max(candidates, key=lambda x: x.fitness)
302
+ new_ind = Chromosome(bits=best.bits.copy())
303
+ new_ind.fitness = best.fitness
304
+ return new_ind
305
+
306
+ def _selection_roulette(self) -> Chromosome:
307
+ fitnesses = np.array([max(0, ind.fitness) for ind in self.population])
308
+ total = sum(fitnesses)
309
+ if total == 0:
310
+ idx = self.rng.integers(0, len(self.population))
311
+ new_ind = Chromosome(bits=self.population[idx].bits.copy())
312
+ new_ind.fitness = self.population[idx].fitness
313
+ return new_ind
314
+ probs = fitnesses / total
315
+ idx = self.rng.choice(len(self.population), p=probs)
316
+ new_ind = Chromosome(bits=self.population[idx].bits.copy())
317
+ new_ind.fitness = self.population[idx].fitness
318
+ return new_ind
319
+
320
+ def _selection_truncation(self) -> Chromosome:
321
+ cutoff = max(1, int(len(self.population) * self.params.truncation_rate))
322
+ best_set = self.population[:cutoff]
323
+ idx = self.rng.integers(0, len(best_set))
324
+ new_ind = Chromosome(bits=best_set[idx].bits.copy())
325
+ new_ind.fitness = best_set[idx].fitness
326
+ return new_ind
327
+
328
+ def _selection_ranking(self) -> Chromosome:
329
+ n = len(self.population)
330
+ ranks = np.arange(n, 0, -1)
331
+ total_ranks = sum(ranks)
332
+ probs = ranks / total_ranks
333
+ idx = self.rng.choice(n, p=probs)
334
+ new_ind = Chromosome(bits=self.population[idx].bits.copy())
335
+ new_ind.fitness = self.population[idx].fitness
336
+ return new_ind
337
+
338
+ # --- CROSSOVER METHODS ---
339
+ def _crossover(self, p1: Chromosome, p2: Chromosome) -> Tuple[Chromosome, Chromosome]:
340
+ if self.rng.random() > self.params.crossover_rate:
341
+ # RISPARMIO COMPTUAZIONALE: Se non c'è crossover, passiamo la fitness in eredità intatta
342
+ c1, c2 = Chromosome(bits=p1.bits.copy()), Chromosome(bits=p2.bits.copy())
343
+ c1.fitness, c2.fitness = p1.fitness, p2.fitness
344
+ return c1, c2
345
+
346
+ method = self.params.crossover_method.lower()
347
+ if method == "single-point":
348
+ return self._crossover_1point(p1, p2)
349
+ elif method == "two-point":
350
+ return self._crossover_2point(p1, p2)
351
+ elif method == "uniform":
352
+ return self._crossover_uniform(p1, p2)
353
+ elif method == "k-point":
354
+ return self._crossover_kpoint(p1, p2)
355
+ else:
356
+ return self._crossover_1point(p1, p2)
357
+
358
+ def _crossover_1point(self, p1: Chromosome, p2: Chromosome) -> Tuple[Chromosome, Chromosome]:
359
+ pt = self.rng.integers(1, Chromosome.TOTAL_LENGTH)
360
+ c1 = np.concatenate((p1.bits[:pt], p2.bits[pt:]))
361
+ c2 = np.concatenate((p2.bits[:pt], p1.bits[pt:]))
362
+ return Chromosome(bits=c1), Chromosome(bits=c2)
363
+
364
+ def _crossover_2point(self, p1: Chromosome, p2: Chromosome) -> Tuple[Chromosome, Chromosome]:
365
+ pt1 = self.rng.integers(1, Chromosome.TOTAL_LENGTH - 1)
366
+ pt2 = self.rng.integers(pt1 + 1, Chromosome.TOTAL_LENGTH)
367
+ c1 = np.concatenate((p1.bits[:pt1], p2.bits[pt1:pt2], p1.bits[pt2:]))
368
+ c2 = np.concatenate((p2.bits[:pt1], p1.bits[pt1:pt2], p2.bits[pt2:]))
369
+ return Chromosome(bits=c1), Chromosome(bits=c2)
370
+
371
+ def _crossover_uniform(self, p1: Chromosome, p2: Chromosome) -> Tuple[Chromosome, Chromosome]:
372
+ mask = self.rng.integers(0, 2, Chromosome.TOTAL_LENGTH)
373
+ c1 = np.where(mask == 1, p1.bits, p2.bits)
374
+ c2 = np.where(mask == 1, p2.bits, p1.bits)
375
+ return Chromosome(bits=c1), Chromosome(bits=c2)
376
+
377
+ def _crossover_kpoint(self, p1: Chromosome, p2: Chromosome) -> Tuple[Chromosome, Chromosome]:
378
+ pts = sorted(self.rng.choice(range(1, Chromosome.TOTAL_LENGTH), size=self.params.k_points, replace=False))
379
+ pts = [0] + list(pts) + [Chromosome.TOTAL_LENGTH]
380
+ c1_bits, c2_bits = [], []
381
+ for i in range(len(pts)-1):
382
+ if i % 2 == 0:
383
+ c1_bits.append(p1.bits[pts[i]:pts[i+1]])
384
+ c2_bits.append(p2.bits[pts[i]:pts[i+1]])
385
+ else:
386
+ c1_bits.append(p2.bits[pts[i]:pts[i+1]])
387
+ c2_bits.append(p1.bits[pts[i]:pts[i+1]])
388
+ return Chromosome(bits=np.concatenate(c1_bits)), Chromosome(bits=np.concatenate(c2_bits))
389
+
390
+ # --- MUTATION METHODS ---
391
+ def _mutate(self, ind: Chromosome) -> None:
392
+ """Esegue la mutazione. Azzera la fitness SOLO se c'è stato un reale cambiamento del DNA."""
393
+ method = self.params.mutation_method.lower()
394
+ mutated = False
395
+
396
+ if method == "flip-bit":
397
+ mutated = self._mutation_flip(ind)
398
+ elif method == "multi-bit":
399
+ mutated = self._mutation_multi(ind)
400
+ elif method == "adaptive":
401
+ mutated = self._mutation_adaptive(ind)
402
+ else:
403
+ mutated = self._mutation_flip(ind)
404
+
405
+ if mutated:
406
+ ind.fitness = None # Invalida la cache
407
+
408
+ def _mutation_flip(self, ind: Chromosome) -> bool:
409
+ mutated = False
410
+ for i in range(Chromosome.TOTAL_LENGTH):
411
+ if self.rng.random() < self.params.mutation_rate:
412
+ ind.bits[i] = 1 - ind.bits[i]
413
+ mutated = True
414
+ return mutated
415
+
416
+ def _mutation_multi(self, ind: Chromosome) -> bool:
417
+ k = self.rng.integers(1, 4, endpoint=True)
418
+ indices = self.rng.choice(range(Chromosome.TOTAL_LENGTH), size=k, replace=False)
419
+ for idx in indices:
420
+ ind.bits[idx] = 1 - ind.bits[idx]
421
+ return True
422
+
423
+ def _mutation_adaptive(self, ind: Chromosome) -> bool:
424
+ adj_rate = self.params.mutation_rate
425
+ if self.current_diversity < 0.1:
426
+ adj_rate *= 2.0
427
+ elif self.current_diversity > 0.4:
428
+ adj_rate *= 0.5
429
+
430
+ mutated = False
431
+ for i in range(Chromosome.TOTAL_LENGTH):
432
+ if self.rng.random() < adj_rate:
433
+ ind.bits[i] = 1 - ind.bits[i]
434
+ mutated = True
435
+ return mutated
436
+
437
+ def _calculate_diversity(self) -> float:
438
+ pop_matrix = np.array([ind.bits for ind in self.population])
439
+ p1 = pop_matrix.mean(axis=0)
440
+ avg_hamming = np.sum(2 * p1 * (1 - p1))
441
+ return float(avg_hamming / Chromosome.TOTAL_LENGTH)
442
+
443
+ def run(self):
444
+ self.initialize_population()
445
+ n_elites = max(1, int(self.params.pop_size * self.params.elitism_rate))
446
+
447
+ for gen in range(self.params.generations):
448
+ self.current_diversity = self._calculate_diversity()
449
+ new_population = []
450
+
451
+ # Elitarismo: i migliori passano incondizionatamente con la fitness già calcolata!
452
+ for i in range(n_elites):
453
+ elite_ind = Chromosome(bits=self.population[i].bits.copy())
454
+ elite_ind.fitness = self.population[i].fitness
455
+ new_population.append(elite_ind)
456
+
457
+ # Riproduzione
458
+ while len(new_population) < self.params.pop_size:
459
+ p1 = self._select()
460
+ p2 = self._select()
461
+ c1, c2 = self._crossover(p1, p2)
462
+ self._mutate(c1)
463
+ self._mutate(c2)
464
+ new_population.extend([c1, c2])
465
+
466
+ self.population = new_population[:self.params.pop_size]
467
+ self._evaluate_population()
468
+
469
+ # Statistiche
470
+ best_fit = self.population[0].fitness
471
+ avg_fit = sum(ind.fitness for ind in self.population) / self.params.pop_size
472
+ self.history["best_fitness"].append(best_fit)
473
+ self.history["avg_fitness"].append(avg_fit)
474
+ self.history["diversity"].append(self.current_diversity)
475
+
476
+ # Early Stopping (Fase 1.5)
477
+ if gen >= self.params.patience:
478
+ recent_bests = self.history["best_fitness"][-self.params.patience:]
479
+ if (recent_bests[-1] - recent_bests[0]) < 1e-6:
480
+ logger.debug(f"Early Stopping alla generazione {gen} per mancanza di miglioramento.")
481
+ break
482
+
483
+ return self.population[0]