52 KiB
Lab 2 - Genetic Algorithms¶
Objective¶
The goal of this laboratory session is to become familiar with the operation and tuning of a genetic algorithm, using the knapsack problem as a running example.
Please double-click a cell to edit it.
Before submitting your solution, please fill in the following information:
| Name | Student ID | Date |
|---|---|---|
| name and surname | student ID number | date |
Please upload the completed file (you can export it to html, pdf or ipynb) to your repository.
Submission deadline: Tuesday, November 10th, 2026
Because every submitted report has to be reviewed, please make sure to respect the deadline.
Task Context¶
The task is to solve the knapsack problem: we are given a set of items, each with a specified weight and value. The goal is to select a subset of items that maximizes the total value, subject to the constraint that the total weight must not exceed the backpack's capacity.
The knapsack problem is NP-hard: with 100 items there are $2^{100}$ possible subsets - far too many to check exhaustively. This makes it a perfect playground for a metaheuristic such as a genetic algorithm, which searches the solution space intelligently instead of exhaustively.
Note on seeds¶
Throughout this notebook the seed ensures that the same hyperparameters always produce the same random run (population initialization, selection, crossover, mutation, etc.). Thanks to this, comparing hyperparameter variants happens under identical random conditions, so differences in the results better reflect the actual influence of the parameter rather than the random luck of one particular run.
The multi-seed option runs the same variant several times with different seeds and reports the mean, standard deviation and min/max. A single seed can happen to be atypical; running many seeds shows the stability of the result and the algorithm's true sensitivity to randomness — which matters far more for drawing conclusions than a single number from a single lucky (or unlucky) draw.
Part 1: Defining the Problem¶
Knapsack problem: we are given a set of items, each with a specified weight and value. The goal is to select a subset of items maximizing the total value, while the total weight must not exceed the backpack's capacity.
Problem data - 100 items.
Run the cell below to define the problem data. The items are grouped into thematic categories, from electronics, through food and clothing, to more exotic gear.
import random import copy import matplotlib.pyplot as plt import numpy as np # Fix the random seed so results are reproducible random.seed(666) # --- PROBLEM DATA --- # 100 items: (name, weight_kg, value_pln) items = [ # --- Electronics and gadgets --- ("Gaming_laptop", 3.0, 800), ("Powerbank_XXL", 1.2, 220), ("Solar_charger", 0.8, 170), ("BT_headphones", 0.2, 100), ("Smartwatch", 0.1, 180), ("Pocket_drone", 0.9, 450), ("E-reader", 0.3, 200), ("Bluetooth_speaker", 0.5, 130), ("GoPro_camera", 0.4, 500), ("3m_cable_useless", 0.1, 10), # --- Food and drinks --- ("Gummy_bears", 0.3, 40), ("Protein_bar", 0.2, 35), ("Instant_coffee", 0.4, 90), ("Tea_100bags", 0.3, 60), ("Dark_chocolate", 0.2, 50), ("Dried_mango", 0.3, 70), ("Water_5L", 5.0, 300), ("Water_1.5L", 1.5, 150), ("Stew_can", 0.8, 100), ("Army_MRE", 1.0, 200), ("Aged_cheese", 0.5, 80), ("Oat_cookies", 0.3, 45), ("Fudge_sweets", 0.2, 30), ("Mixed_nuts", 0.4, 95), ("Classic_jerky_sticks", 0.3, 70), # --- Clothing --- ("Rain_jacket", 0.6, 300), ("Fleece_pullover", 0.8, 250), ("Pompon_beanie", 0.1, 60), ("Wool_socks", 0.1, 40), ("Trekking_gloves", 0.1, 55), ("Spare_tshirt", 0.2, 30), ("Cargo_pants", 0.5, 120), ("Camping_sandals", 0.6, 80), ("Multifunction_bandana",0.05, 25), ("Emergency_poncho", 0.2, 90), # --- Tools and survival --- ("Swiss_army_knife", 0.2, 200), ("Headlamp", 0.15, 180), ("Fire_striker", 0.1, 100), ("Cord_50m", 0.8, 110), ("Duct_tape", 0.3, 120), ("Compass", 0.1, 90), ("Paper_map", 0.1, 80), ("Rescue_whistle", 0.02, 70), ("Signal_mirror", 0.05, 60), ("Folding_shovel", 0.7, 130), # --- Camping and sleep --- ("Tent_2person", 3.5, 500), ("Down_sleeping_bag", 1.5, 400), ("Foam_sleeping_pad", 0.4, 150), ("Ultralight_hammock", 0.5, 200), ("Inflatable_pillow", 0.1, 60), ("Head_mosquito_net", 0.05, 40), ("Tarp_3x3", 0.8, 160), # --- Hygiene and health --- ("First_aid_kit", 0.5, 250), ("Sunscreen", 0.2, 60), ("Mosquito_repellent", 0.15, 50), ("Dr_Bronner_soap", 0.3, 40), ("Travel_toothbrush", 0.05, 15), ("Microfiber_towel", 0.2, 70), ("Toilet_paper", 0.3, 20), ("Water_purification_tabs", 0.05, 100), # --- Fun and hobbies --- ("Book_1000pages", 0.8, 150), ("Playing_cards", 0.1, 50), ("Frisbee", 0.2, 30), ("Binoculars_10x42", 0.7, 250), ("Travel_journal", 0.2, 40), ("Soprano_ukulele", 0.6, 120), ("Rubiks_cube", 0.1, 35), ("Magnetic_chess", 0.3, 60), # --- Field kitchen --- ("Gas_stove", 0.4, 200), ("Gas_cartridge", 0.3, 80), ("Titanium_mess_tin", 0.2, 180), ("Thermal_mug", 0.3, 100), ("Folding_cutlery", 0.1, 50), ("Tea_strainer", 0.02, 10), # --- Unusual but useful --- ("Selfie_stick", 0.2, 25), ("Sunglasses", 0.05, 150), ("Earplugs", 0.01, 30), ("Trash_bags_10x", 0.1, 15), ("Paperclips_XXL_set", 0.05, 5), ("Rubber_bands", 0.02, 5), # --- Odd but someone packs them --- ("Lucky_mascot", 0.3, 10), ("U_travel_pillow", 0.3, 80), ("Rubber_hot_water_bottle", 0.4, 60), ("Folding_stool", 1.2, 140), ("Trekking_umbrella", 0.5, 70), ("Inflatable_flamingo", 0.4, 20), ("NRC_emergency_blanket", 0.05, 90), ("Waterproof_bag", 0.2, 110), # --- Photography --- ("Mirrorless_camera", 1.5, 700), ("Zoom_lens", 0.8, 400), ("Carbon_tripod", 1.0, 300), ("Polarizing_filter", 0.05, 100), ("SD_card_256GB", 0.01, 120), # --- Premium and luxury --- ("Garmin_watch", 0.08, 350), ("Filtering_bottle", 0.3, 180), ("Merino_underwear", 0.15, 130), ("Merino_socks_2x", 0.15, 100), ("Trekking_poles", 0.5, 160), ("Boot_gaiters", 0.2, 70), ("Inflatable_pouf", 0.3, 50), ] BACKPACK_CAPACITY = 25 # kg # Display the data print(f"Number of items: {len(items)}") print(f"Backpack capacity: {BACKPACK_CAPACITY} kg") print(f"Total weight of all items: {sum(p[1] for p in items)} kg") print(f"Total value of all items: {sum(p[2] for p in items)} PLN")
Part 2: Implementing the Genetic Algorithm¶
Below are the functions implementing the individual stages of the genetic algorithm.
2.1 Generating the initial population¶
Each individual is a list of 0s and 1s of length 100 (the number of items). A value of 1 means the item is packed into the backpack. This binary encoding is the genotype; the packed backpack is the phenotype.
def generate_population(population_size, chromosome_length): # Initialize a random starting population (binary 0/1 chromosomes). population = [] for _ in range(population_size): individual = [random.randint(0, 1) for _ in range(chromosome_length)] population.append(individual) return population
def calculate_fitness(individual, items, capacity): # Objective function: maximize total value; overweight solutions get fitness 0. weight = 0 value = 0 for i in range(len(individual)): if individual[i] == 1: weight += items[i][1] value += items[i][2] if weight > capacity: return 0 return value
def calculate_weight(individual, items): weight = 0 for i in range(len(individual)): if individual[i] == 1: weight += items[i][1] return weight
2.2 Selection methods¶
For the experiments we use three selection methods: roulette wheel, tournament, and rank-based selection.
Selection is the mechanism that decides which individuals become parents of the next generation. Each method strikes a different balance between exploiting the best solutions and maintaining population diversity:
- Roulette wheel — selection probability proportional to fitness; fast but easily dominated by a few very strong individuals.
- Tournament — pick a small random group and take its best member; robust and effective even with large populations.
- Rank-based — sort by fitness and select proportionally to rank; prevents a single super-individual from taking over the population.
def roulette_selection(population, fitness_values): # Roulette wheel selection — each individual has a selection # probability proportional to its fitness. total_fitness = sum(fitness_values) if total_fitness == 0: # If all fitness values are 0, pick randomly return random.choice(population) # Compute probabilities probabilities = [f / total_fitness for f in fitness_values] # Draw based on the probability distribution r = random.random() total = 0 for i, p in enumerate(probabilities): total += p if total >= r: return population[i] return population[-1] def tournament_selection(population, fitness_values, tournament_size=3): # Tournament selection — draw a group of individuals, pick the best of the group. # Draw the indices of the tournament participants indices = random.sample(range(len(population)), tournament_size) # Find the best individual in the tournament best_index = indices[0] for idx in indices[1:]: if fitness_values[idx] > fitness_values[best_index]: best_index = idx return population[best_index] def rank_selection(population, fitness_values): # Rank-based selection — sort by fitness, assign ranks, select proportionally to rank. # Create (index, fitness) pairs and sort ascending sorted_pairs = sorted(enumerate(fitness_values), key=lambda x: x[1]) # Assign ranks: worst = 1, best = N ranks = [0] * len(population) for rank, (index, _) in enumerate(sorted_pairs, start=1): ranks[index] = rank # Select proportionally to rank (like in roulette wheel) total_ranks = sum(ranks) r = random.random() total = 0 for i, rank in enumerate(ranks): total += rank / total_ranks if total >= r: return population[i] return population[-1]
2.3 Genetic operators¶
Specifically, crossover (for the exercises we use single-point crossover) and mutation (bit-flip).
Crossover recombines genetic material from two parents, allowing the algorithm to combine the good "building blocks" of different solutions. Mutation introduces random changes gene by gene — it is the primary source of new genetic material and the main defence against getting stuck in a local optimum. Note that the mutation probability applies per gene, not per individual: with a chromosome of 100 genes and p_mutation = 0.05, on average 5 genes of every child will flip.
def crossover(parent_a, parent_b, p_crossover): # Single-point crossover: exchange chromosome fragments between parents. if random.random() > p_crossover: # No crossover – offspring are copies of the parents return copy.copy(parent_a), copy.copy(parent_b) # Draw the crossover point (from 1 to N-1) point = random.randint(1, len(parent_a) - 1) child_a = parent_a[:point] + parent_b[point:] child_b = parent_b[:point] + parent_a[point:] return child_a, child_b
def mutate(individual, p_mutation): # Bit-flip mutation. Each gene flips (0->1 or 1->0) with probability p_mutation. mutated = copy.copy(individual) for i in range(len(mutated)): if random.random() < p_mutation: mutated[i] = 1 - mutated[i] return mutated
2.4 Building the genetic algorithm¶
Parameters:
----------
items : list of tuples (name, weight, value)
capacity : maximum backpack weight
population_size : initial population size
n_iterations : how many generations to run
p_crossover : crossover probability (0.0 - 1.0)
p_mutation : per-gene mutation probability (0.0 - 1.0)
n_pairs : how many parent pairs take part in reproduction
offspring_per_pair : how many children are produced from one pair (1 or 2)
n_remove : how many weakest individuals are removed from the population
selection_method : "roulette", "tournament" or "rank"
tournament_size : tournament size (only for tournament selection)
elitism : whether to preserve the best individual (True/False)
seed : random seed (for reproducibility)
Population dynamics:
-------------------
new = n_pairs * offspring_per_pair
change = new - n_remove
change > 0 -> population grows
change = 0 -> population is stable
change < 0 -> population shrinks
Returns:
--------
best_individual : 0/1 list – the best solution found
best_fitness : fitness value of the best solution
best_history : best fitness in every generation
avg_history : average fitness in every generation
size_history : population size in every generation
def genetic_algorithm( items, capacity, population_size=50, n_iterations=100, p_crossover=0.8, p_mutation=0.05, n_pairs=10, offspring_per_pair=2, n_remove=10, selection_method="tournament", tournament_size=3, elitism=True, seed=None ): # seed fixes the reproducibility of the draws # (the same sequence of results for the same parameters). if seed is not None: random.seed(seed) # Chromosome length = number of available items. length = len(items) # --- STEP 1: Initialize the population --- population = generate_population(population_size, length) # Compute the population size change per iteration new_per_iteration = n_pairs * offspring_per_pair change = new_per_iteration - n_remove print(f" New offspring/iter: {new_per_iteration}, " f"removed/iter: {n_remove}, " f"size change: {'+' if change > 0 else ''}{change}/iter") # History for the plots best_history = [] avg_history = [] size_history = [] # Remember the global best global_best = None global_best_f = 0 for iteration in range(n_iterations): # Safety net: the population cannot drop below 2 if len(population) < 2: print(f" Population died out at iteration {iteration}!") break # --- STEP 2: Evaluate fitness --- fitness_values = [calculate_fitness(o, items, capacity) for o in population] # Record statistics best_f = max(fitness_values) avg_f = sum(fitness_values) / len(fitness_values) best_history.append(best_f) avg_history.append(avg_f) size_history.append(len(population)) # Update the global best if best_f > global_best_f: idx_best = fitness_values.index(best_f) global_best = copy.copy(population[idx_best]) global_best_f = best_f # --- STEP 3: Create offspring --- # This part depends the most on the hyperparameters: # selection_method, p_crossover, p_mutation, n_pairs, offspring_per_pair. offspring = [] for _ in range(n_pairs): # Parent selection if selection_method == "roulette": parent_a = roulette_selection(population, fitness_values) parent_b = roulette_selection(population, fitness_values) elif selection_method == "tournament": parent_a = tournament_selection(population, fitness_values, tournament_size) parent_b = tournament_selection(population, fitness_values, tournament_size) elif selection_method == "rank": parent_a = rank_selection(population, fitness_values) parent_b = rank_selection(population, fitness_values) else: raise ValueError(f"Unknown selection method: {selection_method}") # Crossover (always yields 2 children) child_a, child_b = crossover(parent_a, parent_b, p_crossover) # Mutation child_a = mutate(child_a, p_mutation) child_b = mutate(child_b, p_mutation) # Take 1 or 2 children from the pair offspring.append(child_a) if offspring_per_pair == 2: offspring.append(child_b) # --- STEP 4: Remove individuals --- # How many to actually remove (no more than population - 1) actually_removed = min(n_remove, len(population) - 1) if elitism: # KEY POINT: elitism=True protects good individuals (we remove the weakest). pairs = list(zip(fitness_values, population)) pairs.sort(key=lambda x: x[0]) pairs = pairs[actually_removed:] population = [individual for (_, individual) in pairs] else: # KEY POINT: elitism=False does not protect the leader; # random removal may lose the best solution. indices_to_remove = set(random.sample(range(len(population)), actually_removed)) population = [ individual for idx, individual in enumerate(population) if idx not in indices_to_remove ] # --- STEP 5: Add the offspring --- population.extend(offspring) # --- STEP 6: Elitism --- if elitism and global_best is not None: new_fitness_values = [calculate_fitness(o, items, capacity) for o in population] if max(new_fitness_values) < global_best_f: # Replace the worst individual with the global best min_idx = new_fitness_values.index(min(new_fitness_values)) population[min_idx] = copy.copy(global_best) # Final evaluation fitness_values = [calculate_fitness(o, items, capacity) for o in population] best_idx = fitness_values.index(max(fitness_values)) best_individual = population[best_idx] best_fitness = fitness_values[best_idx] # The global best overrides the result only in the elitist variant. if elitism and global_best_f > best_fitness: best_individual = global_best best_fitness = global_best_f return (best_individual, best_fitness, best_history, avg_history, size_history)
2.5 Visualization helpers¶
def display_result(individual, items, capacity): # Displays the backpack contents for a given individual. print(f"\n{'='*55}") print(f"{'BACKPACK CONTENTS':^55}") print(f"{'='*55}") print(f"{'Nr':<5}{'Name':<22}{'Weight (kg)':<12}{'Value (PLN)':<12}") print("-" * 51) total_weight = 0 total_value = 0 for i in range(len(individual)): if individual[i] == 1: name, weight, value = items[i] print(f"{i+1:<5}{name:<22}{weight:<12}{value:<12}") total_weight += weight total_value += value print("-" * 51) print(f"{'TOTAL':<27}{total_weight:<12.1f}{total_value:<12}") print(f"Backpack capacity: {capacity} kg") print(f"Utilization: {total_weight:.1f}/{capacity} kg " f"({100*total_weight/capacity:.1f}%)") print(f"{'='*55}") def plot_convergence(best_history, avg_history, title=""): # Convergence plot of the genetic algorithm. plt.figure(figsize=(10, 5)) plt.plot(best_history, label="Best fitness", linewidth=2) plt.plot(avg_history, label="Average fitness", linewidth=1, alpha=0.7) plt.xlabel("Generation (iteration)") plt.ylabel("Fitness (backpack value)") plt.title(f"Genetic algorithm convergence{' – ' + title if title else ''}") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() def plot_population_size(size_history, title=""): # Plot of the population size over time. plt.figure(figsize=(10, 3)) plt.plot(size_history, label="Population size", linewidth=2, color="green") plt.xlabel("Generation (iteration)") plt.ylabel("Number of individuals") plt.title(f"Population size{' – ' + title if title else ''}") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show()
Part 3: Running the Baseline Model¶
Run the algorithm with default parameters to obtain a reference point. In the baseline model: 10 parent pairs, 2 offspring per pair = 20 new individuals, we remove the 20 weakest → the population is stable (change = 0).
# --- BASELINE MODEL --- # 10 pairs × 2 offspring = 20 new, we remove 20 → stable population individual, fitness, hist_best, hist_avg, hist_size = genetic_algorithm( items=items, capacity=BACKPACK_CAPACITY, population_size=50, n_iterations=200, p_crossover=0.8, p_mutation=0.05, n_pairs=10, offspring_per_pair=2, n_remove=20, selection_method="tournament", tournament_size=3, elitism=True, seed=666 ) print(f"\nBest fitness: {fitness} PLN") print(f"Final population size: {hist_size[-1]}") display_result(individual, items, BACKPACK_CAPACITY) plot_convergence(hist_best, hist_avg, "Baseline model")
# --- ANALYSIS SETTINGS: fixed seed + randomness --- # FIXED_SEED: fair comparison between hyperparameter variants (same draws). FIXED_SEED = 666 # RANDOM_SEEDS: assessing result stability and sensitivity to randomness. RANDOM_SEEDS = list(range(10)) def run_variant(parameters, seed): # Common runner for all experiments — returns the metrics needed for analysis. _, fitness, hist_best, _, hist_size = genetic_algorithm( items=items, capacity=BACKPACK_CAPACITY, seed=seed, **parameters ) return fitness, hist_best, hist_size[-1] def statistics(values): # Basic statistics for the report: mean, std deviation and range [min, max]. arr = np.array(values, dtype=float) return arr.mean(), arr.std(ddof=0), arr.min(), arr.max() # --- EXPERIMENT 1: Mutation probability --- mutation_rates = [0.001, 0.01, 0.05, 0.1, 0.3] results_mutation_fixed = {} results_mutation_random = {} plt.figure(figsize=(12, 5)) for pm in mutation_rates: parameters = dict( population_size=50, n_iterations=200, p_crossover=0.8, p_mutation=pm, n_pairs=10, offspring_per_pair=2, n_remove=20, selection_method="tournament", elitism=True, ) fitness_fixed, hist_best_fixed, _ = run_variant(parameters, FIXED_SEED) results_mutation_fixed[pm] = fitness_fixed random_fitness = [run_variant(parameters, s)[0] for s in RANDOM_SEEDS] mean, std, minimum, maximum = statistics(random_fitness) results_mutation_random[pm] = (mean, std, minimum, maximum) plt.plot(hist_best_fixed, label=f"p_mut={pm} (fixed seed={fitness_fixed})") plt.xlabel("Generation") plt.ylabel("Best fitness") plt.title("Experiment 1: Effect of the mutation probability (fixed seed)") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for pm in mutation_rates: fs = results_mutation_fixed[pm] mean, std, mn, mx = results_mutation_random[pm] print( f" p_mutation={pm:<6} -> fixed seed, fitness: {fs:<5} | " f"random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}" )
Questions¶
Please answer the questions below.
1. How does the solution quality (fitness) change with very low, medium and high mutation rates?
(your answer here)
2. Which p_mutation value gives the best trade-off between exploration and result stability?
(your answer here)
3. Do the conclusions from the fixed seed agree with the multi-seed results (mean/std deviation)?
(your answer here)
# --- EXPERIMENT 2: Crossover probability --- crossover_rates = [0.0, 0.3, 0.6, 0.8, 1.0] results_crossover_fixed = {} results_crossover_random = {} plt.figure(figsize=(12, 5)) for pc in crossover_rates: parameters = dict( population_size=50, n_iterations=200, p_crossover=pc, p_mutation=0.05, n_pairs=10, offspring_per_pair=2, n_remove=20, selection_method="tournament", elitism=True, ) fitness_fixed, hist_best_fixed, _ = run_variant(parameters, FIXED_SEED) results_crossover_fixed[pc] = fitness_fixed random_fitness = [run_variant(parameters, s)[0] for s in RANDOM_SEEDS] mean, std, minimum, maximum = statistics(random_fitness) results_crossover_random[pc] = (mean, std, minimum, maximum) plt.plot(hist_best_fixed, label=f"p_cross={pc} (fixed seed={fitness_fixed})") plt.xlabel("Generation") plt.ylabel("Best fitness") plt.title("Experiment 2: Effect of the crossover probability (fixed seed)") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for pc in crossover_rates: fs = results_crossover_fixed[pc] mean, std, mn, mx = results_crossover_random[pc] print( f" p_crossover={pc:<4} -> fixed seed, fitness: {fs:<5} | " f"random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}" )
Questions¶
Please answer the questions below.
4. Does increasing p_crossover improve the result, or does the effect fade beyond a certain point?
(your answer here)
5. How does the absence of crossover (p_crossover=0) affect convergence and the final fitness?
(your answer here)
6. For which p_crossover values are the results most stable across seeds?
(your answer here)
# --- EXPERIMENT 3: Population size --- population_sizes = [10, 30, 50, 100, 200] results_population_fixed = {} results_population_random = {} plt.figure(figsize=(12, 5)) for ps in population_sizes: # Scale the replacement parameters proportionally to the population pairs = max(2, ps // 5) remove = pairs * 2 # stable population parameters = dict( population_size=ps, n_iterations=200, p_crossover=0.8, p_mutation=0.05, n_pairs=pairs, offspring_per_pair=2, n_remove=remove, selection_method="tournament", elitism=True, ) fitness_fixed, hist_best_fixed, _ = run_variant(parameters, FIXED_SEED) results_population_fixed[ps] = fitness_fixed random_fitness = [run_variant(parameters, s)[0] for s in RANDOM_SEEDS] mean, std, minimum, maximum = statistics(random_fitness) results_population_random[ps] = (mean, std, minimum, maximum) plt.plot(hist_best_fixed, label=f"pop={ps} (fixed seed={fitness_fixed})") plt.xlabel("Generation") plt.ylabel("Best fitness") plt.title("Experiment 3: Effect of the population size (fixed seed)") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for ps in population_sizes: fs = results_population_fixed[ps] mean, std, mn, mx = results_population_random[ps] print( f" population_size={ps:<4} -> fixed seed, fitness: {fs:<5} | " f"random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}" )
Questions¶
Please answer the questions below.
7. How does the population size affect the quality of the final solution?
(your answer here)
8. Does a larger population always help, or is there diminishing return in quality relative to computational cost?
(your answer here)
9. Which population size offers the best trade-off: fitness vs stability (std deviation) vs runtime?
(your answer here)
# --- EXPERIMENT 4: Selection method --- methods = ["roulette", "tournament", "rank"] results_selection_fixed = {} results_selection_random = {} plt.figure(figsize=(12, 5)) for method in methods: parameters = dict( population_size=50, n_iterations=200, p_crossover=0.8, p_mutation=0.05, n_pairs=10, offspring_per_pair=2, n_remove=20, selection_method=method, elitism=True, ) fitness_fixed, hist_best_fixed, _ = run_variant(parameters, FIXED_SEED) results_selection_fixed[method] = fitness_fixed random_fitness = [run_variant(parameters, s)[0] for s in RANDOM_SEEDS] mean, std, minimum, maximum = statistics(random_fitness) results_selection_random[method] = (mean, std, minimum, maximum) plt.plot(hist_best_fixed, label=f"{method} (fixed seed={fitness_fixed})") plt.xlabel("Generation") plt.ylabel("Best fitness") plt.title("Experiment 4: Comparing selection methods (fixed seed)") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for method in methods: fs = results_selection_fixed[method] mean, std, mn, mx = results_selection_random[method] print( f" selection {method:<12} -> fixed seed, fitness: {fs:<5} | " f"random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}" )
Question¶
Please answer the question below.
10. Which selection method gives the highest fitness, and are the differences significant?
(your answer here)
# --- EXPERIMENT 5: Population dynamics --- # We test different combinations of the generational replacement parameters. # Format: (description, n_pairs, offspring_per_pair, n_remove) scenarios = [ ("stable: 3 pairs×2, remove=6", 3, 2, 6), ("stable: 10 pairs×2, remove=20", 10, 2, 20), ("growing: 10 pairs×2, remove=5", 10, 2, 5), ("shrinking: 3 pairs×1, remove=6", 3, 1, 6), ("shrinking: 5 pairs×2, remove=15", 5, 2, 15), ] results_dynamics_fixed = {} results_dynamics_random = {} fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True) for desc, np_, opp, nr in scenarios: parameters = dict( population_size=50, n_iterations=200, p_crossover=0.8, p_mutation=0.05, n_pairs=np_, offspring_per_pair=opp, n_remove=nr, selection_method="tournament", elitism=True, ) fitness_fixed, hist_best_fixed, size_fixed = run_variant(parameters, FIXED_SEED) results_dynamics_fixed[desc] = (fitness_fixed, size_fixed) random_runs = [run_variant(parameters, s) for s in RANDOM_SEEDS] random_fitness = [x[0] for x in random_runs] random_sizes = [x[2] for x in random_runs] mean_f, std_f, min_f, max_f = statistics(random_fitness) mean_r, std_r, min_r, max_r = statistics(random_sizes) results_dynamics_random[desc] = (mean_f, std_f, min_f, max_f, mean_r, std_r, min_r, max_r) ax1.plot(hist_best_fixed, label=f"{desc} (fixed f={fitness_fixed})") _, hist_size_fixed = genetic_algorithm( items=items, capacity=BACKPACK_CAPACITY, seed=FIXED_SEED, **parameters )[3:5] ax2.plot(hist_size_fixed, label=desc) ax1.set_ylabel("Best fitness") ax1.set_title("Experiment 5: Population dynamics — progress (fixed seed)") ax1.legend(fontsize=8) ax1.grid(True, alpha=0.3) ax2.set_xlabel("Generation") ax2.set_ylabel("Population size") ax2.set_title("Experiment 5: Population dynamics — size (fixed seed)") ax2.legend(fontsize=8) ax2.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for desc, _, _, _ in scenarios: fs, rs = results_dynamics_fixed[desc] mean_f, std_f, min_f, max_f, mean_r, std_r, min_r, max_r = results_dynamics_random[desc] print( f" {desc:<33} -> fixed seed: f={fs}, size={rs} | " f"random f: mean={mean_f:.1f}, std={std_f:.1f}, min={min_f:.0f}, max={max_f:.0f}; " f"size: mean={mean_r:.1f}, std={std_r:.1f}, min={min_r:.0f}, max={max_r:.0f}" )
Question¶
Please answer the question below.
11. How does the dynamics type (stable/growing/shrinking) affect the final fitness? Do strongly growing or strongly shrinking populations degrade the quality or the stability of the results?
(your answer here)
# --- EXPERIMENT 6: Elitism --- plt.figure(figsize=(12, 5)) results_elitism_fixed = {} results_elitism_random = {} for elit in [True, False]: parameters = dict( population_size=20, n_iterations=200, p_crossover=0.8, p_mutation=0.05, n_pairs=8, offspring_per_pair=2, n_remove=16, selection_method="tournament", elitism=elit, ) fitness_fixed, hist_best_fixed, _ = run_variant(parameters, FIXED_SEED) label = "with elitism" if elit else "without elitism" results_elitism_fixed[label] = fitness_fixed random_fitness = [run_variant(parameters, s)[0] for s in RANDOM_SEEDS] mean, std, minimum, maximum = statistics(random_fitness) results_elitism_random[label] = (mean, std, minimum, maximum) plt.plot(hist_best_fixed, label=f"{label} (fixed seed={fitness_fixed})") plt.xlabel("Generation") plt.ylabel("Best fitness") plt.title("Experiment 6: Effect of elitism (fixed seed)") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print("\nSummary (fixed seed vs randomness):") for e in ["with elitism", "without elitism"]: fs = results_elitism_fixed[e] mean, std, mn, mx = results_elitism_random[e] print( f" {e:<20} -> fixed seed: f={fs:<5} | " f"random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}" )
Question¶
Please answer the question below.
12. How does elitism affect the final result and the risk of losing good solutions?
(your answer here)
# --- SUMMARY TABLE: fixed seed + randomness --- print(f"\n{'='*110}") print(f"{'AGGREGATED EXPERIMENT RESULTS (FIXED SEED VS RANDOMNESS)':^110}") print(f"{'='*110}") print(f"\n--- Experiment 1: Mutation probability ---") for pm in mutation_rates: fs = results_mutation_fixed[pm] mean, std, mn, mx = results_mutation_random[pm] print(f" p_mutation={pm:<6} -> seed={fs:<5} | random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}") print(f"\n--- Experiment 2: Crossover probability ---") for pc in crossover_rates: fs = results_crossover_fixed[pc] mean, std, mn, mx = results_crossover_random[pc] print(f" p_crossover={pc:<4} -> seed={fs:<5} | random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}") print(f"\n--- Experiment 3: Population size ---") for ps in population_sizes: fs = results_population_fixed[ps] mean, std, mn, mx = results_population_random[ps] print(f" population_size={ps:<4} -> seed={fs:<5} | random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}") print(f"\n--- Experiment 4: Selection method ---") for m in methods: fs = results_selection_fixed[m] mean, std, mn, mx = results_selection_random[m] print(f" selection {m:<12} -> seed={fs:<5} | random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}") print(f"\n--- Experiment 5: Population dynamics ---") for desc, _, _, _ in scenarios: fs, rs = results_dynamics_fixed[desc] mean_f, std_f, min_f, max_f, mean_r, std_r, min_r, max_r = results_dynamics_random[desc] print( f" {desc:<33} -> seed: f={fs}, size={rs} | " f"random f: mean={mean_f:.1f}, std={std_f:.1f}, min={min_f:.0f}, max={max_f:.0f}; " f"size: mean={mean_r:.1f}, std={std_r:.1f}, min={min_r:.0f}, max={max_r:.0f}" ) print(f"\n--- Experiment 6: Elitism ---") for e in ["with elitism", "without elitism"]: fs = results_elitism_fixed[e] mean, std, mn, mx = results_elitism_random[e] print(f" {e:<20} -> seed={fs:<5} | random: mean={mean:.1f}, std={std:.1f}, min={mn:.0f}, max={mx:.0f}")
Part 4: Running the Optimized Model¶
Please fill in the parameters based on the conclusions drawn from the experiments above. The guiding idea: each value should be justified by what you observed in Parts 3.1–3.6 — there is no single "correct" answer, but there are well- and poorly-justified ones.
individual_opt, fitness_opt, hist_best_opt, hist_avg_opt, hist_size_opt = genetic_algorithm( items=items, capacity=BACKPACK_CAPACITY, population_size=100, # <- change this n_iterations=300, # <- change this p_crossover=0.8, # <- change this p_mutation=0.05, # <- change this n_pairs=2, # <- change this (number of parent pairs) offspring_per_pair=2, # <- change this (1 or 2) n_remove=4, # <- change this (how many weakest to remove) selection_method="tournament", # <- change this tournament_size=3, # <- change this elitism=False, # <- change this seed=666 ) print(f"\nYour optimized fitness: {fitness_opt} PLN") print(f"Final population size: {hist_size_opt[-1]}") display_result(individual_opt, items, BACKPACK_CAPACITY) plot_convergence(hist_best_opt, hist_avg_opt, "Your optimized model") plot_population_size(hist_size_opt, "Your optimized model")