Evolving an 8 Queens solution with PyGAD

Evolving an 8 Queens solution with PyGAD

PyGAD is a python library implementing a genetic algorithm to solve problems. It was created by Ahmed Gad (many thanks!). Complete docs are here. One of the examples used to demonstrate the library is the 8 Queens problem. This was my solution using PyGAD which differs somewhat from other applications.

The 8 Queens Problem

The goal is to place 8 queens on a chessboard such that no queen can attack another queen. For those unfamiliar with chess, the board is an 8x8 grid. Each square can be referenced using algebraic notation. A queen can attack in all directions: horizontally, vertically, and diagonally. See board below:

Genetic Algorithms

A genetic algorithm (GA) uses the ideas of natural selection to find solutions to problems. It is particularly adept at optimization problems. PyGAD is a python GA library that provides customized applications using a series of parameters and callback functions. It well documented and I found it the best of the available libraries I perused.

GAs start with a population of possible solutions, then the individual variables (genes) are modified using crossover and mutation to explore the solution space. PyGAD is very flexible in specifying how populations are created, the data type of the genes, and how crossover and mutations are performed. I came up to speed quickly based on work I had done on GAs in the past.

The Fitness Function

The crux of using a GA is defining the fitness function. This function returns a numerical score used to rank the fitness of each possible solution. The best solutions survive to the next generation for crossover and mutation.

For the 8 Queens Problem, we have an 8x8 array of row and column positions, but it can be simplified to a 1x8 array of column positions if we define each column position as a unique row from 1-8 based on the order in the column array. In other words, given an array of column positions: [8,2,6,5,4,3,7,1], we assign row 1 to the first column, (e.g. row 1 column 8), row 2 to the second column, (row 2 column 2), and so on. This way, we only need 8 variables or genes. The rows remain fixed.

PyGAD calls the fitness function passing each solution and the solution index. Often, fitness is calculated as a fraction with a numerator of 1.0 and denominator the absolute value of the difference between a calculated score and the desired score. To compute the fitness of this problem, we don't need absolute values, we just count the number of successful attacks a given position can make and divide 1.0 by the total. The more attacks, the less the fitness will be. I added one to the denominator to avoid divide by zero errors and so that a solution with zero successful attacks will return a fitness value of 1.0:

def fitness_func(ga_instance, solution, solution_idx):
    # Zero attacks will result in a fitness of 1 
    # The more attacks, the lower the fitness.
    attacks = count_queen_attacks(solution)
    # Add 1 to the denominator to prevent a divide by zero error, and also
    # to calculate a fitness of 1 when the number of attacks is zero.
    fitness = 1.0 / (attacks + 1.0)
    return fitness

The hard part was the count_queen_attacks function. By definition, no queen is in the same row so we can ignore row attacks (horizontal attacks). The function needs to find queens in the same column (vertical attacks), and queens in the same diagonal or antidiagonal. When I first started thinking about computing diagonals, I considered loops that looked stepwise (+1,+1 or +1,-1 or -1,+1 or -1,-1) for another queen, but that seemed tedious and slow. Luckily, there is a very elegant way to check for queens in the same diagonal or antidiagonal.

If you subtract the row number from the column number (col - row), all queens in the same diagonal will have the same total. For the antidiagonal, you add them together (col + row). Queens with the same total will be in the same antidiagonal. One loop for each queen will find all successful attacks. This was not intuitive for me at first. I had to stare at a board and work through several manual examples to make sure it handled all possibilities.

def count_queen_attacks(solution):
    diagonal_attacks = 0
    anti_diagonal_attacks = 0
    column_attacks = 0

    # Frequency dictionaries for columns and diagonals
    col_count = {}
    diag_count = {}
    anti_diag_count = {}

    # Count each queen's position in column, main diagonal, and anti-diagonal
    for i in range(8):
        col = solution[i]
        diag = col - i
        anti_diag = col + i

        if col in col_count:
            column_attacks += col_count[col]
        col_count[col] = col_count.get(col, 0) + 1

        # Calculated by the formula column - row. Queens on the same diagonal 
        # will have the same value.
        if diag in diag_count:
            diagonal_attacks += diag_count[diag]
        diag_count[diag] = diag_count.get(diag, 0) + 1

        # Anti-diagonal Attacks: Calculated by column + row. 
        # Similar to diagonal, but for the other direction.
        if anti_diag in anti_diag_count:
            anti_diagonal_attacks += anti_diag_count[anti_diag]
        anti_diag_count[anti_diag] = anti_diag_count.get(anti_diag, 0) + 1

    # Total attacks is the sum of column, main diagonal, and anti-diagonal attacks
    total_attacks = column_attacks + diagonal_attacks + anti_diagonal_attacks
    return total_attacks

I defined some parameters for PyGAD:

# Gene type - default is float, can be int, numpy.int/uint/float(8-64) or list
gene_type=int
# Initial ranges 
init_range_low = 1
init_range_high = 8
# Gene space (limit values of genes)
gene_space = [1,2,3,4,5,6,7,8]

# Number of generations.
num_generations = 500
# Number of solutions to be selected as parents in the mating pool.
num_parents_mating = 7
# Number of genes to mutate at a time
mutation_num_genes = 1
# Number of solutions in the population.
sol_per_pop = 100

Then, created it and ran it:

ga_instance = pygad.GA(num_generations=num_generations,
                       num_parents_mating=num_parents_mating,
                       fitness_func=fitness_function,
                       sol_per_pop=sol_per_pop,
                       num_genes=num_genes,
                       mutation_num_genes=mutation_num_genes,
                       parent_selection_type="rws",
                       gene_type=gene_type,
                       gene_space=gene_space,
                       init_range_low=init_range_low,
                       init_range_high=init_range_high,
                       on_start=on_start,
                       on_fitness=on_fitness,
                       on_parents=on_parents,
                       on_crossover=on_crossover,
                       on_mutation=on_mutation,
                       on_generation=on_generation,
                       on_stop=on_stop)

ga_instance.run()

Solutions

It turns out there are many solutions to the problem and I got a different one each time I ran it. Here are two example results, please check the results on a chessboard to see if they work.

Parameters of the best solution : [5 7 2 4 8 1 3 6]
Fitness value of the best solution = 1.0
Queen positions: A5 B7 C2 D4 E8 F1 G3 H6
. . . . Q . . .
. Q . . . . . .
. . . . . . . Q
Q . . . . . . .
. . . Q . . . .
. . . . . . Q .
. . Q . . . . .
. . . . . Q . .

Parameters of the best solution : [6 3 7 2 8 5 1 4]
Fitness value of the best solution = 1.0
Queen positions: A6 B3 C7 D2 E8 F5 G1 H4
. . . . Q . . .
. . Q . . . . .
Q . . . . . . .
. . . . . Q . .
. . . . . . . Q
. Q . . . . . .
. . . Q . . . .
. . . . . . Q .

PyGAD has proven to be a fun and functional library for quickly implementing genetic algorithms. It will be a staple in my toolkit. If you are interested in the complete source code, please email me. Obligatory spaces > tabs!