<jupyter_start><jupyter_text>Assignment 6: NeuroevolutionIn our last assignment, we explored the week's major idea (representation and genetic encoding) in a more realistic application in the Traveling Salesman Problem.  This week we'll follow that trend and explore the ideas around mutation rates and measuring diversity in the setting of evoliving artificial neural networks (Neuroevolution).  While neuroevolution really shines outside of standard machine learning benchmarks, for the sake of simplicity, we'll use one of the most basic benchmarks for neural networks, classification of handwritten digits in MNIST.<jupyter_code># imports
import numpy as np
import copy
import matplotlib.pyplot as plt
plt.style.use('seaborn')

import scikits.bootstrap as bootstrap
import warnings
warnings.filterwarnings('ignore') # Danger, <NAME>! (not a scalable hack, and may surpress other helpful warning other than for ill-conditioned bootstrapped CI distributions)

import scipy.stats # for finding statistical significance

import time<jupyter_output><empty_output><jupyter_text>I've taken the liberty to preprocess MNIST for you by deskewing (standard preprocessing step to straigthen tilted images) and downscaling the images from `28x28` to `14x14` to try and keep out genome size down (at the cost of losing some resolution/information in the images), split out the labels (turning them into one-hot encodings), and separating the train and test sets.  If you aren't familiar with machine learning practices like this, don't worry about it -- just load the datasets below.*Note:* This dataset contains 60,000 training examples, and 10,000 testing examples.  This is likely far overkill for what we need, so if your machine is struggling with the size of the dataset, feel free to use only a small portion of the training examples/labels provided (doing so didn't effect runtime much on my laptop, but your mileage may vary)<jupyter_code>train_x = np.loadtxt("train_x.csv", delimiter=',')
test_x = np.loadtxt("test_x.csv", delimiter=',')
train_y = np.loadtxt("train_y.csv", delimiter=',')
test_y = np.loadtxt("test_y.csv", delimiter=',')<jupyter_output><empty_output><jupyter_text>Let's take a look at the images!<jupyter_code># This is what the image looks like
num_images = 6
fig, axs = plt.subplots(1, num_images, figsize=(3*num_images, 3), sharey=True)
for i in range(num_images):
    axs[i].imshow(train_x[i].reshape(14,14)) # we will keep the images flat to easily feed them into the neural network, but we need them back in a square shape to visualize
    axs[i].grid(False)
    axs[i].axis('off')
    axs[i].set_title("Label:"+str(np.argmax(train_y[i]))) # the argmax takes out one-hot encoding and turns it into a readable label<jupyter_output><empty_output><jupyter_text>Q1: ImplementationOur individual solutions this week will be (again keeping things overly simplistic) single-layer neural networks.  These networks are defined by a single weight matrix with input dimenion of the size of the flattened image (`14*14=196`) and output dimension of the size of the number of possible classes (`10`).  Feel free to implement the genome as the weight matrix, or simply as a flattened float vector of size `1960`.<jupyter_code>class Individual:
    
    def __init__(self, fitness_function, genome_length):
        self.fitness_function = fitness_function
        self.genome = np.random.uniform(0,1,genome_length)
        
    def eval_fitness(self):
        self.fitness = self.fitness_function(self.genome)<jupyter_output><empty_output><jupyter_text>There are two main ways to measure the performance of a neural network, loss and accuracy.  For the sake of intuition, let's use accuracy here, but I'm providing the implementaition of loss just in case you want to play around with it as well (thought returning the negative of the loss, as the smaller magnitudes are better so this allows us to continue going "uphill" if we do ever choose to optimize for loss).  As we haven't covered neural networks, I'm also providing the implementation of a single layer neural network (desite its apparent simplicity compared to mult-layer networks) in the fitness function below.<jupyter_code>def accuracy(output, y):
    return np.sum(np.isclose(np.argmax(output,axis=1),np.argmax(y,axis=1)))/y.shape[0]

def loss (output, y):
    return -np.sum(np.square(output-y))/y.shape[0]

def neural_network_fitness(weights,x=train_x,y=train_y):
    weight_matrix = weights.reshape((14*14,10)) 
    output = x.dot(weight_matrix)
    return accuracy(output,y)<jupyter_output><empty_output><jupyter_text>Q1b: Real-valued mutationIn class, we've only alluded indrectly to mutating vectors of floats as genomes (like neural network weights).  Let's play around with the implmentations of these.  For simplicity, we'll ignore crossover for now.  Rather than flipping a given number of bits, let's try adding a small random value to each gene's value by adding `(np.random.rand(genome_length)*2-1)*mutation_size` to the genome. This takes a uniform distribution, normalizes it to be between -1 and 1, then scales it by some `mutation_size` scaling factor that you can pass into your `evolutionary_algorithm` function.    Q1c: Diversity TrackingIn addition to keeping track of the best genome, and fitness at each generation, let's also record the diversity of the population at each generation.  The metric we talked about most in class was measuring genotypic diversity with the average standard deviation of the distribution across the population of the values for each gene.<jupyter_code>def evolutionary_algorithm(fitness_function=None, total_generations=100, num_parents=10, num_children=10, genome_length=10, num_elements_to_mutate=1, mutation_size=0.1, crossover=True, tournament_size=4, num_tournament_winners=2):
    """ Evolutinary Algorithm (copied from the basic hillclimber in our last assignment)
        
        parameters: 
        fitness_funciton: (callable function) that return the fitness of a genome 
                           given the genome as an input parameter (e.g. as defined in Landscape)
        total_generations: (int) number of total iterations for stopping condition
        num_parents: (int) the number of parents we downselect to at each generation (mu)
        num_childre: (int) the number of children (note: parents not included in this count) that we baloon to each generation (lambda)
        genome_length: (int) length of the genome to be evoloved
        num_elements_to_mutate: (int) number of alleles to modify during mutation (0 = no mutation)
        mutation_size: (float) scaling parameter of the magnitidue of mutations for floating point vectors
        crossover: (bool) whether to perform crossover when generating children 
        tournament_size: (int) number of individuals competing in each tournament
        num_tournament_winners: (int) number of individuals selected as future parents from each tournament (must be less than tournament_size)
        
        returns:
        fitness_over_time: (numpy array) track record of the top fitness value at each generation
        solutions_over_time: (numpy array) track record of the top genome value at each generation
        diversity_over_time: (numpy array) track record of the population genetic diversity at each generation
    """

    # initialize record keeping
    solution = None # best genome so far
    solution_fitness = -99999 # fitness of best genome so far
    solution_generation = 0 # time (generations) when solution was found
    fitness_over_time = np.zeros(total_generations)
    solutions_over_time = np.zeros((total_generations,genome_length),dtype=int)
    diversity_over_time = np.zeros(total_generations)
    
    # the initialization proceedure
    population = [] # keep population of individuals in a list
    for i in range(num_parents): # only create parents for initialization (the mu in mu+lambda)
        population.append(Individual(fitness_function,genome_length)) # generate new random individuals as parents
    
    # get population fitness
    for i in range(len(population)):
        population[i].eval_fitness() # evaluate the fitness of each parent
    
    for generation_num in range(total_generations): # repeat
        
#         print(generation_num)
        
        # the modification procedure
        new_children = [] # keep children separate for now (lambda in mu+lambda)
        while len(new_children) < num_children:
            
            # inheretance
            [parent1, parent2] = np.random.choice(population, size=2) # pick 2 random parents
            child1 = copy.deepcopy(parent1) # initialize children as perfect copies of their parents
            child2 = copy.deepcopy(parent2)
            
#           # crossover
            if crossover:
                for child, this_parent, other_parent in [[child1, parent1, parent2],[child2, parent2, parent1]]:
                    child.genome = -1*np.ones(len(child.genome))
                    child.genome[0] = this_parent.genome[0]
                    next_index = np.where(other_parent.genome == this_parent.genome[0])
                    while next_index != 0:
                        child.genome[next_index] = this_parent.genome[next_index]
                        next_index = np.where(other_parent.genome == child.genome[next_index])[0]
                    child.genome[np.where(child.genome == -1)] = other_parent.genome[np.where(child.genome == -1)]
                    child.genome = child.genome.astype(int)
        
            # mutation
            for this_child in [child1,child2]:
                for _ in range(num_elements_to_mutate):
#                     [index_to_swap1, index_to_swap2] = np.random.randint(0,genome_length,2)
#                     while index_to_swap1 == index_to_swap2: [index_to_swap1, index_to_swap2] = np.random.randint(0,genome_length,2)
#                     orig_gene_1 = this_child.genome[index_to_swap1]
#                     this_child.genome = np.delete(this_child.genome,index_to_swap1)
#                     this_child.genome = np.insert(this_child.genome,index_to_swap2,orig_gene_1)
                    this_child.genome = this_child.genome + (np.random.rand(genome_length)*2-1)*mutation_size

            new_children.extend((child1,child2)) # add children to the new_children list
            
        # the assessement procedure
        for i in range(len(new_children)):
            new_children[i].eval_fitness() # assign fitness to each child 

        # selection procedure
        population += new_children # combine parents with new children (the + in mu+lambda)
        population = sorted(population, key=lambda individual: individual.fitness, reverse = True) # sort the full population by each individual's fitness (from highers to lowest)
        
        # tournament selection
        new_population = []
        new_population.append(population[0])
        while len(new_population) < num_parents:
            tournament = np.random.choice(population, size = tournament_size)
            tournament = sorted(tournament, key=lambda individual: individual.fitness, reverse = True)
            new_population.extend(tournament[:num_tournament_winners])    
        population = new_population
        
        # record keeping
        
        if population[0].fitness > solution_fitness: # if the new parent is the best found so far
            solution = population[0].genome                 # update best solution records
            solution_fitness = population[0].fitness
            solution_generation = generation_num
        fitness_over_time[generation_num] = solution_fitness # record the fitness of the current best over evolutionary time
        solutions_over_time[generation_num,:] = solution

        all_gene_std = []
        for x in range(genome_length):
            this_gene_values=[]
            for y in range(len(population)):
                this_gene_values.append(population[y].genome[x])
            all_gene_std.append(np.std(this_gene_values))
        diversity_over_time[generation_num] = np.mean(all_gene_std)
        
    return fitness_over_time, solutions_over_time, diversity_over_time<jupyter_output><empty_output><jupyter_text>Q2: ExperimentationDue to the high dimensionality of this problem, the runs are a bit slower than before, so let's keep the scale small on this with just `50` generations and `5` repitions.  Hopefully this keeps things managable from a runtime persepctive (runs in a little over 30 seconds for each repition, or a little under 3 minutes for all 5, on my machine).  Let's use a mutation size of `1.0`, the same `50` parents and `50` children settings from last week, and a tournament size of `20`, choosing `10` winners.  *Hint:* If this still takes to long to run on your machine (especially while debugging/exploring code), feel free to run smaller test runs first by reducing the number of generations for the runs, plotting without bootstrapping, etc.<jupyter_code>experiment_results = {}
solutions_results = {}
diversity_results = {}
num_runs = 5
total_generations = 50
genome_length = 14*14*10
num_elements_to_mutate = genome_length
mutation_size = 1.0
num_parents = 50
num_children = 50
tournament_size = 20
num_tournament_winners = 10
fitness_function = neural_network_fitness
crossover = False

for run_name in ["mutation_only"]:
    experiment_results[run_name] = np.zeros((num_runs, total_generations))
    solutions_results[run_name] = np.zeros((num_runs, total_generations, genome_length))
    diversity_results[run_name] = np.zeros((num_runs, total_generations))
    for run_num in range(num_runs):       
        start_time = time.time()
        fitness_over_time, solutions_over_time, diversity_over_time = evolutionary_algorithm(fitness_function=fitness_function, total_generations=total_generations, num_parents=num_parents, num_children=num_children, genome_length=genome_length, num_elements_to_mutate=num_elements_to_mutate, crossover=crossover, tournament_size=tournament_size, num_tournament_winners=num_tournament_winners)
        experiment_results[run_name][run_num] = fitness_over_time
        solutions_results[run_name][run_num] = solutions_over_time
        diversity_results[run_name][run_num] = diversity_over_time
        print(run_name, run_num, time.time()-start_time,fitness_over_time[-1])<jupyter_output>mutation_only 0 105.99584913253784 0.5202166666666667
mutation_only 1 106.23305344581604 0.47358333333333336
mutation_only 2 105.68558168411255 0.4451333333333333
mutation_only 3 104.95845532417297 0.44398333333333334
mutation_only 4 105.58599615097046 0.46776666666666666<jupyter_text>Q2b: VisualizationLike last time, please plot the bootstrapped fitness values over time.<jupyter_code>def plot_mean_and_bootstrapped_ci_over_time(input_data = None, name = "change me", x_label = "change me", y_label="change me", y_limit = None, plot_bootstrap = True):
    """
    
    parameters: 
    input_data: (numpy array of shape (max_k, num_repitions)) solution metric to plot
    name: (string) name for legend
    x_label: (string) x axis label
    y_label: (string) y axis label
    
    returns:
    None
    """

    print(input_data.shape)
    generations = input_data.shape[0]

    CIs = []
    mean_values = []
    for i in range(generations):
        mean_values.append(np.mean(input_data[i]))
        CIs.append(bootstrap.ci(input_data[i], statfunction=np.mean))
    mean_values=np.array(mean_values)
    
    print(CIs)
    high = []
    low = []
    for i in range(len(CIs)):
        low.append(CIs[i][0])
        high.append(CIs[i][1])
    
    low = np.array(low)
    high = np.array(high)
    fig, ax = plt.subplots()
    y = range(0, generations)
    ax.plot(y, mean_values, label=name)
    ax.fill_between(y, high, low, color='b', alpha=.2)
    ax.set_xlabel(x_label)
    ax.set_ylabel(y_label)
    ax.legend()
    if (name) and len(name)>0:
        ax.set_title(name)
# plot fitness over time
plot_mean_and_bootstrapped_ci_over_time(input_data=np.transpose(np.array(experiment_results['mutation_only'])), name='mutation_only', y_label="Fitness", x_label='Generations')<jupyter_output>(50, 5)
[array([0.20702667, 0.23659333]), array([0.22110333, 0.24901333]), array([0.22425333, 0.25309667]), array([0.23076667, 0.25441667]), array([0.23747   , 0.25450333]), array([0.24807, 0.27686]), array([0.25638, 0.29037]), array([0.26177, 0.31738]), array([0.27003667, 0.3186    ]), array([0.26952333, 0.32225667]), array([0.28655667, 0.33385667]), array([0.28937667, 0.33417667]), array([0.28890667, 0.33417667]), array([0.29157333, 0.35129333]), array([0.29364333, 0.35972667]), array([0.29423   , 0.36148667]), array([0.30726667, 0.37057667]), array([0.30726667, 0.37057667]), array([0.31211667, 0.37300333]), array([0.31042333, 0.38298333]), array([0.32543667, 0.39209   ]), array([0.33347333, 0.40271333]), array([0.35085333, 0.40417333]), array([0.35179333, 0.40588333]), array([0.35218333, 0.40778667]), array([0.35375333, 0.41314333]), array([0.35638   , 0.41749333]), array([0.36030667, 0.42815333]), array([0.37832333, 0.42946333]), array([0.38381333, 0.43859   ]), array([0.38381333, [...]<jupyter_text>Q3: Visualizing DiversityPlease also plot the diveristy of our population over evolutionary time.<jupyter_code># plot diversity over time
plot_mean_and_bootstrapped_ci_over_time(input_data=np.transpose(np.array(diversity_results['mutation_only'])), name='mutation_only', y_label="Fitness", x_label='Generations')<jupyter_output>(50, 5)
[array([1.63889107, 1.80565008]), array([2.10462457, 2.38680837]), array([2.35397269, 2.79787932]), array([2.72355195, 3.15845761]), array([2.80790824, 3.25355958]), array([3.11277682, 3.50803828]), array([3.31854842, 3.74404904]), array([3.48889336, 4.00433845]), array([3.5345177 , 4.14225393]), array([3.66442273, 4.37107582]), array([3.76515597, 4.32150118]), array([3.70483771, 4.29565305]), array([3.58243146, 4.2119078 ]), array([3.50795931, 4.14844868]), array([3.70375615, 4.17007702]), array([3.69805414, 3.98740259]), array([3.69169745, 4.09174402]), array([3.53641527, 4.21167258]), array([3.58689107, 4.42603252]), array([3.72710066, 4.40495652]), array([3.73198775, 4.28176557]), array([3.73955574, 4.19489802]), array([3.48770185, 3.96529226]), array([3.28492374, 3.81528462]), array([2.90132356, 3.84347349]), array([2.99439515, 4.03757992]), array([3.29311493, 4.08548733]), array([2.81596995, 4.21512307]), array([2.91691355, 4.23345761]), array([3.05616068, 4.34644256]), a[...]<jupyter_text>Q3b: AnalysisWhat do you notice about the diveristy over time?  Is this what you expected to tradeoff exploration and exploitation -- and how it related to fitness?   **insert text here**  Q4: Generalization to Test DatasetsWhenever doing classification, it's good to make sure that your algorithm isn't overfitting to the training data.  Based on your intuition about diversity and overfitting, what do you expect this relationship to look like?  **insert text here**  Q5: Evaluating Test AccuracySince we already have test data loaded in above, let's evaluate your already trained algorithms (using your saved best-solution-so-far genomes at each generation) to see how test fitness tracks with the training fitness.  Please implement a script which calcualtes the test accuracy of the solutions over time below.*Hin:* Look for where the training set is used during fitness evaluation during training for ideas of what functions/syntax to use<jupyter_code>test_accuracy_results = {}

def calc_test_accuracy_over_time(name = None):
    
    test_accuracy_results = np.zeros((num_runs, total_generations))
    
    # loop through each run
    for run_num in range(num_runs):
        # loop through each solution over time
        for generation in range(total_generations):
            # calculate the fitness on the test set for this solution and add this to test_accuracy_results
            test_accuracy_results[run_num][generation] = neural_network_fitness(weights=solutions_results['mutation_only'][run_num][generation],x=test_x, y=test_y)
            
            
    
    return test_accuracy_results
test_accuracy_results['mutation_only'] = calc_test_accuracy_over_time()
# print(test_accuracy_results['mutation_only'])<jupyter_output>[[0.2164 0.2269 0.2016 0.2115 0.2551 0.2551 0.299  0.3227 0.3227 0.3283
  0.3451 0.3309 0.3309 0.3751 0.3751 0.3649 0.3884 0.3884 0.3909 0.4025
  0.422  0.4179 0.4179 0.4179 0.4179 0.4396 0.4396 0.455  0.455  0.4733
  0.4733 0.4733 0.4733 0.4733 0.4733 0.4733 0.4746 0.4746 0.4746 0.4746
  0.4959 0.4959 0.4959 0.4959 0.497  0.5032 0.515  0.515  0.515  0.5289]
 [0.098  0.098  0.098  0.098  0.098  0.2629 0.2684 0.3402 0.3527 0.3527
  0.319  0.319  0.319  0.3686 0.377  0.377  0.377  0.377  0.3761 0.3761
  0.3875 0.4114 0.4114 0.4114 0.4114 0.4114 0.4219 0.4291 0.4291 0.4291
  0.4291 0.4351 0.4351 0.4423 0.46   0.46   0.46   0.46   0.46   0.46
  0.4692 0.4718 0.4718 0.4718 0.4718 0.4718 0.4666 0.4666 0.4543 0.4728]
 [0.2062 0.2373 0.2373 0.2373 0.2569 0.3179 0.3179 0.3179 0.3179 0.2889
  0.2889 0.3002 0.3002 0.3188 0.3188 0.3186 0.3186 0.3201 0.3345 0.3648
  0.3648 0.3553 0.3553 0.3553 0.3858 0.3891 0.3891 0.3891 0.3699 0.4055
  0.4055 0.4055 0.4055 0.4085 0.4085 0.4085 0.4085 0.4108 0.4099[...]<jupyter_text>Q5b: Running and VisualizationRun and plot the test accuracy over time of the runs you performed above (to reduce clutter, feel free to just do this for the tournaments of size `20`).<jupyter_code>plot_mean_and_bootstrapped_ci_over_time(input_data=np.transpose(np.array(test_accuracy_results['mutation_only'])), name='mutation_only', y_label="Fitness", x_label='Generations')<jupyter_output>(50, 5)
[array([0.11964, 0.22448]), array([0.12378, 0.25358]), array([0.11872, 0.24346]), array([0.13124, 0.25882]), array([0.13124, 0.2706 ]), array([0.26144, 0.3069 ]), array([0.26916, 0.30422]), array([0.26924, 0.32778]), array([0.26386, 0.33278]), array([0.26514, 0.33018]), array([0.28604, 0.33136]), array([0.28734, 0.32376]), array([0.28734, 0.32594]), array([0.29324, 0.36124]), array([0.29412, 0.36498]), array([0.29408, 0.36048]), array([0.31376, 0.37794]), array([0.31514, 0.37794]), array([0.31802, 0.3779 ]), array([0.31362, 0.38968]), array([0.33052, 0.40334]), array([0.32796, 0.40518]), array([0.35006, 0.40652]), array([0.35006, 0.40652]), array([0.34832, 0.40894]), array([0.3561 , 0.41896]), array([0.3664 , 0.42242]), array([0.3664 , 0.43186]), array([0.37344, 0.43468]), array([0.38358, 0.45084]), array([0.38358, 0.45084]), array([0.39038, 0.4521 ]), array([0.39038, 0.4521 ]), array([0.39612, 0.45414]), array([0.39868, 0.4589 ]), array([0.40216, 0.45768]), array([0.40406, 0.4[...]<jupyter_text>Q5c: AnalysisWhat did you find for a relationship between genetic diversity and overfitting to the training set?  Was this what you expected? **insert text here**  Q6: Modifying Muation RatesNext well modify the mutation rate for our algorithm.  Based on the results you see above, and how you expect mutation rate to modify the genetic diveristy of a population, how might you think that increasing or decreasing the mutation rate might effect the different tournament size runs above? **insert text here**  Q7: ExperimentationLet's find out!  Let's do a mini grid search on the `mutation_size` and `num_tournament_winners`.  To keep the number of runs down, let's just look at the exteme values of `num_tournament_winners` we had above (`1` and `10`), and run these for a `mutation_size` of `0.5` and `2.0` (in addition to the value of `1.0` we had before).  *Hint:* This is a good time to double check that your `mutation_size` parameter you implemented above is working correctly (i.e. your results for how it should effect diversity below make sense)*Note:* This may take some time to run (if each condition is a couple minutes).  Please try debugging code with smaller runs and make sure that if there are errors in along the way, what you've run already is saved and logged (so you don't have to rerun all 10 or 15 mins if you find a bug at the end of your script).  And just use this time to go grab a coffee (or do some reading in your lovely evolutionary computation textbooks)!<jupyter_code>num_runs = 5
total_generations = 50
genome_length = 14*14*10
num_elements_to_mutate = genome_length
mutation_size = ...
num_parents = 50
num_children = 50
tournament_size = ...
num_tournament_winners = ...

...<jupyter_output><empty_output><jupyter_text>Q8: VisualizePlease plot the results of these experiments (both fitness over time, and diveristy)<jupyter_code>...<jupyter_output><empty_output><jupyter_text>Q8b: AnalysisWhat patterns do you see?  Did you expect this given the implications of each independently?  Does the level of diversity match your intuition about how well search should perform?  Does this tell you anything about the role/interaction of variation (e.g. mutation rate) and selection (e.g. tournament parameters)? **insert text here**  Q9: Dynamic Mutation RateWe talked in class about many way to have dynamic or adaptive mutation rates.  Let's experiment with the simplest form of this, a mutation rate that changes linearly over generational time, from some provided starting value to some provided ending value.  Please modify your evolutionary algorithm code below to enable this.<jupyter_code>def evolutionary_algorithm(fitness_function=None, total_generations=100, num_parents=10, num_children=10, genome_length=10, num_elements_to_mutate=1, mutation_size_start=1.0, mutation_size_end = 0.1, crossover=True, tournament_size=4, num_tournament_winners=2):
    """ Evolutinary Algorithm (copied from the basic hillclimber in our last assignment)
        
        parameters: 
        fitness_funciton: (callable function) that return the fitness of a genome 
                           given the genome as an input parameter (e.g. as defined in Landscape)
        total_generations: (int) number of total iterations for stopping condition
        num_parents: (int) the number of parents we downselect to at each generation (mu)
        num_childre: (int) the number of children (note: parents not included in this count) that we baloon to each generation (lambda)
        genome_length: (int) length of the genome to be evoloved
        num_elements_to_mutate: (int) number of alleles to modify during mutation (0 = no mutation)
        mutation_size_start: (float) scaling parameter of the magnitidue of mutations for floating point vectors at the beginning of search
        mutation_size_end: (float) scaling parameter of the magnitidue of mutations for floating point vectors at the end of search (note: if same as mutation_size_start, mutation rate is static, otherwise mutation rate is linearly interpolated between the two)
        crossover: (bool) whether to perform crossover when generating children 
        tournament_size: (int) number of individuals competing in each tournament
        num_tournament_winners: (int) number of individuals selected as future parents from each tournament (must be less than tournament_size)
        
        returns:
        fitness_over_time: (numpy array) track record of the top fitness value at each generation
        solutions_over_time: (numpy array) track record of the top genome value at each generation
        diversity_over_time: (numpy array) track record of the population genetic diversity at each generation
    """

   ...
        
    return fitness_over_time, solutions_over_time, diversity_over_time<jupyter_output><empty_output><jupyter_text>Q9b: ExperimentationPlease peform a set of runs which decrease the mutation rate from `1.0` to `0.1` linearly over the 50 generations of search for a tournament of size `20` with `1` winner selected.<jupyter_code>num_runs = 5
total_generations = 50
genome_length = 14*14*10
proportion_elements_to_mutate = 1.0
mutation_size_start = 1.0
mutation_size_end = 0.1
num_parents = 50
num_children = 50
tournament_size = 20
num_tournament_winners = 1

...<jupyter_output><empty_output><jupyter_text>Q10: VisualizePlease plot (fitness and diversity of) the dynamic mutation rate against fixed mutation rates of `1.0` and `0.5` for the same tournament parameters.<jupyter_code>...<jupyter_output><empty_output>
StarcoderdataJupyterStructuredCleanDedup