import numpy as np
from collections import defaultdict
import matplotlib.pyplot as plt


def fitness(individual_gen: np.ndarray, target_gen: np.ndarray):
    """
    Calculates the fitness of an individual.
    :param individual_gen:                      Individual gen.
    :param target_gen:                          Target gen.
    :return:
    """
    return np.sum(individual_gen == target_gen)


def sequence_bits_generator(size):
    """
    Generates a random population of sequence of bits as a numpy array.
    :param size:                                Shape of the generated numpy array.
    :return:                                    A random numpy array of bits of the given shape.
    """
    return np.random.randint(0, 2, size=size)


def roulette_selection(scores: np.ndarray, population: np.ndarray, target_gen: np.ndarray) -> np.ndarray:
    """
    Performs roulette selection of in the population.
    :param scores:
    :param population:
    :param target_gen:
    :return:
    """
    assert population.shape[1] == target_gen.shape[0], "The gen length are not equals"
    # Calculate overall fitness
    overall_fitness = np.sum(scores)

    # Lets play the roulette game!
    # Go through the population and sum fitness's. When the sum s is greater than r,
    # stop and return the individual where you are.
    r = np.random.randint(0, int(overall_fitness))
    s = 0
    selected = 0
    for individual_index in range(population.shape[0]):
        if s >= r:
            selected = individual_index
            break
        s += scores[individual_index]
    return population[selected, :]


def tournament_selection(scores: np.ndarray, population: np.ndarray):
    """
    Performs tournament selection. First, selects 20% of the population randomly. Then, returns the fittest
    of that random selection.
    :param scores:                                  Scores of the population (greater is better).
    :param population:                              Population genes.
    :param target_gen:                              Target gen: sequence of bits.
    :return:
    """

    mask = np.random.randint(0, len(scores), size=int(0.2*len(scores)))
    choosen = population[mask, :]
    sub_scores = scores[mask]
    return choosen[np.argmax(sub_scores), :]


def cross_over(individual_a: np.ndarray, individual_b: np.ndarray) -> np.ndarray:
    """
    Performs cross over between the genes of two individuals.
    :param individual_a:                            First individual gene
    :param individual_b:                            Second individual gene
    :return:                                        Third new individual generated by cross over
    """

    # Selects a random point
    separator = np.random.randint(0, individual_a.shape[0])
    left_gen_half = individual_a[:separator]
    right_gen_half = individual_b[separator:]
    child = np.concatenate([left_gen_half, right_gen_half])
    return child


def mutation(individual: np.ndarray) -> np.ndarray:

    # Change the bits of the 10% of the gen
    n_to_change = len(individual)
    mask = np.random.randint(0, n_to_change, size=int(0.2*n_to_change))
    for idx in mask:
        individual[idx] = 0 if individual[idx] == 1 else 0
    return individual


def calculate_population_scores(population: np.ndarray, target: np.ndarray, fitness_function):
    """
    Calculates the population scores given a fitness function and return a list with the scores.
    :param population:                              Numpy array with genes (each row correspond to a individual)
    :param target:                                  Target array: sequence of bits
    :param fitness_function:                        Fitness function
    :return:
    """

    scores = []
    for idx in range(population.shape[0]):
        scores.append(fitness_function(target, population[idx, :]))
    return np.array(scores)


def find_sequence_bits(*, target: list,
                       population_size: int,
                       max_iter: int,
                       fitness_function,
                       population_generator):

    target = np.array(target)

    # Create the initial population
    population = population_generator((population_size, len(target)))

    # Calculate the scores
    scores = calculate_population_scores(population, target, fitness_function)
    generation = 1
    solution_found = False

    # Summary dict
    summary = defaultdict(list)
    for _ in range(max_iter):

        # Solution found?
        if np.max(scores) == target.shape[0]:
            solution_found = True
            break

        # First we perform elitism, 10% of the best goes to next generation
        scores_idxs = [(idx, score) for idx, score in enumerate(scores)]
        scores_idxs.sort(key=lambda x: x[1])
        scores_idxs = list(reversed(scores_idxs))
        best_individuals = np.array([population[element[0], :] for element in scores_idxs[:int(0.1*population_size)]])

        # ---------------- Selection: Tournament ---------------
        new_generation_cross_over = []
        # We creates child until complete de 80% of the population size
        for _ in range(int(0.6*population_size)):
            parent_a = tournament_selection(scores, population)
            parent_b = tournament_selection(scores, population)

            # Applying cross over
            new_individual = cross_over(parent_a, parent_b)
            new_generation_cross_over.append(new_individual)
        new_generation_cross_over = np.array(new_generation_cross_over)
        # ------------------------------------------------------

        # ---------------- Selection: Roulette ---------------
        new_generation_mutation = []
        # We creates child until complete de 80% of the population size
        for _ in range(population_size-best_individuals.shape[0]-new_generation_cross_over.shape[0]):
            selection = roulette_selection(scores, population, target)

            # Applying cross over
            new_individual = mutation(selection)
            new_generation_mutation.append(new_individual)
        new_generation_mutation = np.array(new_generation_mutation)
        # ------------------------------------------------------
        population = np.concatenate([best_individuals, new_generation_mutation, new_generation_cross_over])
        scores = calculate_population_scores(population, target, fitness_function)

        # Summary of the progress
        summary['best_scores'].append(np.max(scores))
        summary['worst_scores'].append(np.min(scores))
        summary['mean_scores'].append((np.mean(scores)))
        print(f"Generation {generation}, best score: {summary['best_scores'][-1]}, "
              f"worst score: {summary['worst_scores'][-1]}, mean score: {summary['mean_scores'][-1]}")
        generation += 1

    if solution_found:
        best_match = population[np.argmax(scores), :]
        print(f"Solution found at generation {generation}")
        print(f"Solution: {best_match}")
        return best_match, summary
    return None, summary


sequence = [0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
solution, summary = find_sequence_bits(target=sequence,
                                       population_size=50,
                                       max_iter=1000,
                                       fitness_function=fitness,
                                       population_generator=sequence_bits_generator)

# Plots the generation evolution
generations = list(range(len(summary['best_scores'])))
plt.plot(generations, summary['best_scores'], label='best')
plt.plot(generations, summary['worst_scores'], label='worst')
plt.plot(generations, summary['mean_scores'], label='mean')
plt.xlabel('# Generation')
plt.ylabel('Fitness')
plt.legend()
plt.grid()
plt.show()




import numpy as np
from collections import defaultdict
import matplotlib.pyplot as plt


def fitness(individual_gen: np.ndarray, target_gen: np.ndarray):
    """
    Calculates the fitness of an individual.
    :param individual_gen:                      Individual gen.
    :param target_gen:                          Target gen.
    :return:
    """
    return np.sum(individual_gen == target_gen)


def sequence_bits_generator(size):
    """
    Generates a random population of sequence of bits as a numpy array.
    :param size:                                Shape of the generated numpy array.
    :return:                                    A random numpy array of bits of the given shape.
    """
    return np.random.randint(0, 2, size=size)


def roulette_selection(scores: np.ndarray, population: np.ndarray, target_gen: np.ndarray) -> np.ndarray:
    """
    Performs roulette selection of in the population.
    :param scores:
    :param population:
    :param target_gen:
    :return:
    """
    assert population.shape[1] == target_gen.shape[0], "The gen length are not equals"
    # Calculate overall fitness
    overall_fitness = np.sum(scores)

    # Lets play the roulette game!
    # Go through the population and sum fitness's. When the sum s is greater than r,
    # stop and return the individual where you are.
    r = np.random.randint(0, int(overall_fitness))
    s = 0
    selected = 0
    for individual_index in range(population.shape[0]):
        if s >= r:
            selected = individual_index
            break
        s += scores[individual_index]
    return population[selected, :]


def tournament_selection(scores: np.ndarray, population: np.ndarray):
    """
    Performs tournament selection. First, selects 20% of the population randomly. Then, returns the fittest
    of that random selection.
    :param scores:                                  Scores of the population (greater is better).
    :param population:                              Population genes.
    :param target_gen:                              Target gen: sequence of bits.
    :return:
    """

    mask = np.random.randint(0, len(scores), size=int(0.2*len(scores)))
    choosen = population[mask, :]
    sub_scores = scores[mask]
    return choosen[np.argmax(sub_scores), :]


def cross_over(individual_a: np.ndarray, individual_b: np.ndarray) -> np.ndarray:
    """
    Performs cross over between the genes of two individuals.
    :param individual_a:                            First individual gene
    :param individual_b:                            Second individual gene
    :return:                                        Third new individual generated by cross over
    """

    # Selects a random point
    separator = np.random.randint(0, individual_a.shape[0])
    left_gen_half = individual_a[:separator]
    right_gen_half = individual_b[separator:]
    child = np.concatenate([left_gen_half, right_gen_half])
    return child


def mutation(individual: np.ndarray) -> np.ndarray:

    # Change the bits of the 10% of the gen
    n_to_change = len(individual)
    mask = np.random.randint(0, n_to_change, size=int(0.2*n_to_change))
    for idx in mask:
        individual[idx] = 0 if individual[idx] == 1 else 0
    return individual


def calculate_population_scores(population: np.ndarray, target: np.ndarray, fitness_function):
    """
    Calculates the population scores given a fitness function and return a list with the scores.
    :param population:                              Numpy array with genes (each row correspond to a individual)
    :param target:                                  Target array: sequence of bits
    :param fitness_function:                        Fitness function
    :return:
    """

    scores = []
    for idx in range(population.shape[0]):
        scores.append(fitness_function(target, population[idx, :]))
    return np.array(scores)


def find_sequence_bits(*, target: list,
                       population_size: int,
                       max_iter: int,
                       fitness_function,
                       population_generator):

    target = np.array(target)

    # Create the initial population
    population = population_generator((population_size, len(target)))

    # Calculate the scores
    scores = calculate_population_scores(population, target, fitness_function)
    generation = 1
    solution_found = False

    # Summary dict
    summary = defaultdict(list)
    for _ in range(max_iter):

        # Solution found?
        if np.max(scores) == target.shape[0]:
            solution_found = True
            break

        # First we perform elitism, 10% of the best goes to next generation
        scores_idxs = [(idx, score) for idx, score in enumerate(scores)]
        scores_idxs.sort(key=lambda x: x[1])
        scores_idxs = list(reversed(scores_idxs))
        best_individuals = np.array([population[element[0], :] for element in scores_idxs[:int(0.1*population_size)]])

        # ---------------- Selection: Tournament ---------------
        new_generation_cross_over = []
        # We creates child until complete de 80% of the population size
        for _ in range(int(0.6*population_size)):
            parent_a = tournament_selection(scores, population)
            parent_b = tournament_selection(scores, population)

            # Applying cross over
            new_individual = cross_over(parent_a, parent_b)
            new_generation_cross_over.append(new_individual)
        new_generation_cross_over = np.array(new_generation_cross_over)
        # ------------------------------------------------------

        # ---------------- Selection: Roulette ---------------
        new_generation_mutation = []
        # We creates child until complete de 80% of the population size
        for _ in range(population_size-best_individuals.shape[0]-new_generation_cross_over.shape[0]):
            selection = roulette_selection(scores, population, target)

            # Applying cross over
            new_individual = mutation(selection)
            new_generation_mutation.append(new_individual)
        new_generation_mutation = np.array(new_generation_mutation)
        # ------------------------------------------------------
        population = np.concatenate([best_individuals, new_generation_mutation, new_generation_cross_over])
        scores = calculate_population_scores(population, target, fitness_function)

        # Summary of the progress
        summary['best_scores'].append(np.max(scores))
        summary['worst_scores'].append(np.min(scores))
        summary['mean_scores'].append((np.mean(scores)))
        print(f"Generation {generation}, best score: {summary['best_scores'][-1]}, "
              f"worst score: {summary['worst_scores'][-1]}, mean score: {summary['mean_scores'][-1]}")
        generation += 1

    if solution_found:
        best_match = population[np.argmax(scores), :]
        print(f"Solution found at generation {generation}")
        print(f"Solution: {best_match}")
        return best_match, summary
    return None, summary


sequence = [0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
solution, summary = find_sequence_bits(target=sequence,
                                       population_size=50,
                                       max_iter=1000,
                                       fitness_function=fitness,
                                       population_generator=sequence_bits_generator)

# Plots the generation evolution
generations = list(range(len(summary['best_scores'])))
plt.plot(generations, summary['best_scores'], label='best')
plt.plot(generations, summary['worst_scores'], label='worst')
plt.plot(generations, summary['mean_scores'], label='mean')
plt.xlabel('# Generation')
plt.ylabel('Fitness')
plt.legend()
plt.grid()
plt.show()




Calculates the fitness of an individual.     :param individual_gen:                      Individual gen.     :param target_gen:                          Target gen.     :return: Generates a random population of sequence of bits as a numpy array.     :param size:                                Shape of the generated numpy array.     :return:                                    A random numpy array of bits of the given shape. Performs roulette selection of in the population.     :param scores:     :param population:     :param target_gen:     :return: # Calculate overall fitness # Lets play the roulette game! # Go through the population and sum fitness's. When the sum s is greater than r, # stop and return the individual where you are. Performs tournament selection. First, selects 20% of the population randomly. Then, returns the fittest     of that random selection.     :param scores:                                  Scores of the population (greater is better).     :param population:                              Population genes.     :param target_gen:                              Target gen: sequence of bits.     :return: Performs cross over between the genes of two individuals.     :param individual_a:                            First individual gene     :param individual_b:                            Second individual gene     :return:                                        Third new individual generated by cross over # Selects a random point # Change the bits of the 10% of the gen Calculates the population scores given a fitness function and return a list with the scores.     :param population:                              Numpy array with genes (each row correspond to a individual)     :param target:                                  Target array: sequence of bits     :param fitness_function:                        Fitness function     :return: # Create the initial population # Calculate the scores # Summary dict # Solution found? # First we perform elitism, 10% of the best goes to next generation # ---------------- Selection: Tournament --------------- # We creates child until complete de 80% of the population size # Applying cross over # ------------------------------------------------------ # ---------------- Selection: Roulette --------------- # We creates child until complete de 80% of the population size # Applying cross over # ------------------------------------------------------ # Summary of the progress # Plots the generation evolution