~ruther/ctu-fee-eoa

ref: 29b62c2e9620aa9579905c4811f318a9c2048243 ctu-fee-eoa/codes/report.md -rw-r--r-- 21.1 KiB
29b62c2e — Rutherther fix: constraint for ArithmeticCrossover 11 days ago

% EOA HW01 - Solving TSP via evolutionary algorithms % František Boháček % 2025

#Intro

This is a report for hw01 for EOA course on CTU FEE. The goal has been to try to solve traveling salesperson problem by means of evolutionary algorithms.

The traveling salesperson problem tries to find the shortest possible route when traveling through multiple cities, visiting every city exactly once, returning to the origin city.

The report covers the implemented algorithms and the results on 10 TSP instances from TSPLIB. All of those chosen instances are using euclidean metric and are 2D.

eil51 instance candidate solution found by EA with nearest neighbor heuristic.{latex-placement="ht"}

#Prepared algorithms

Three generic algorithms have been used for solving the TSP. Then various representations and operators have been tried on them.

#Evolutionary algorithm

The algorithm keeps a population of N each epoch and generates offsprings out of the current population. Specifically it uses the following steps:

  1. Obtain initial population (i.e., random or specialized heuristic).
  2. Select parents to make offsprings from (selection).
  3. Pair the selected parents (pairing).
  4. Somehow join the parent pairs to make an offspring (crossover).
  5. Replace the current population with a new one, combining the current population and offsprings. (replacement)
  6. Repeat steps 2 to 5 until terminating condition is reached (number of iterations)

During its run the algorithm saves the best candidates encountered. It saves them even during one epoch for better granularity. This also ensures that if best candidate does not make it into the next population, it is still captured by the algorithm and can be returned as best candidate at the end. The implementation is in eoa_lib/src/evolution.rs.

For selections those algorithms have been implemented and tried:

  • Roulette wheel selection
  • Tournament selection
    • Here two parameters have been used, number of parents to choose from and probability to choose the best parent
    • When probability is set at 1, always the parent with best fitness wins, but if it's lower, it gives chance to worse parents as well
  • Best by fitness selection

For replacmement, tournament and best by fitness has been implemented and tried.

For pairing, only adjacent pairing has been implemented (taking parents from selection and pairing first with second, third with fourth and so on)

The local search implementation is implementation of the first improving strategy, where the current best solution is being perturbated, until a better solution is found. And then, the process starts over from this better solution.

The local search implementation supports evolutionary strategies, i.e., changing the perturbation parameters during the run, but none were used for TSP.

The implementation is available in eoa_lib/src/local_search/mod.rs.

Random search initializes new random elements in the search space each iteration and saves new elements if they are better than best found.

The implementation is available in eoa_lib/src/random_search.rs.

#Representations

Two representations have been chosen for the TSP problem,

  1. Node permutation
  2. Binary string half-matrix denoting if city i comes before city j

#Node permutation

Implemented as a vector of indices of the cities. A couple of perturbations and crossovers have been implemented:

  • Perturbations
    • Move single random city to random position
    • Swap two randomly selected cities
    • Reverse subsequence of cities (random starting point and random length)
  • Crossovers
    • Cycle crossover
    • Partially mapped crossover
    • Edge recombination crossover

Also apart from a random initializer, two additional initializers are implemented, one based on minimal spanning tree and second based on nearest neighbors. Detailed descriptions of the algorithms follow in next sections.

#Crossovers

All used crossovers take two parents and it's possible to create two offsprings out of the two parents by switching the parent positions. (switching first parent and second parent)

#Cycle crossover

The cycle crossover creates a cycle of indices, takes parts of the first parent from the cycle and moves them to offspring and fills the rest from the second parent.

The cycle is created as follows:

  1. Start with index 0, call it current index
  2. Save current index
  3. Look at current index in first parent, let's call it current element
  4. Find the same element in second parent and update current index to this new index
  5. Repeat 2 - 4 until reaching index 0 again

Then the offspring is created as follows:

  1. Clone the second parent
  2. Iterate the saved cycle indices, take element at index from first parent and copy it to offspring at the same index
#Partially mapped crossover

The partially mapped/matched crossover randomly selects two points. At the end it should ensure that the offspring has first parent's elements in between the two cross points.

The way to ensure that, while still ensuring the result is a valid permutation, is to always swap the elements.

Offspring is created as follows:

  1. Clone second parent
  2. Then, for every index i between the cross points:
  3. Take the element on index i from first parent
  4. Find the city in the offspring, let's call its index j
  5. Swap i with j
#Edge recombination crossover

Edge recombination is the most complicated from the three crossover operators.

First, an adjacency list is created for both parents. Let's take an example of a permutation: 012345. The element 0 has 5 and 1 as adjacent nodes. 1 has 0 and 2 and so on. The two adjacency lists are then joined together, while omitting repetitions. That means that a single element will have from 2 to 4 elements in its adjacency list.

To make an offspring:

  1. First element is taken out of the first parent, this is the current element (city).
  2. The current element is appended to offspring
  3. The current element is removed from adjacency lists of all other elements.
  4. Then, all the cities in adjacency list for current element are taken and one with minimum neighbors in its adjacency list is taken. If there are multiple such cities, random one is chosen.
  5. The found city becomes current element.
  6. Steps 2 to 5 are repeated until all elements are taken.

In the code, the algorithm has been implemented slightly differently. Instead of removing any elements, the elements are just marked as removed. Also, instead of constructing a list of adjacency nodes progressively, a vector of 4 elements is made beforehand. Each element may be unset (an Option). Then, the adjacencies from first parent are put to positions 0, 1 and adjacencies from second parent to positions 2 and 3. If there would be a repetition, it's left unset. This then allows for static allocation only. This is thanks to a library called nalgebra that is used and it's possible to tell it the dimensions of adjacency matrix beforehand. However for the runs of the algorithms, Dyn dimension has been used out of convenience, so dynamic allocation is performed.

#Binary string

Classical perturbations and crossovers have been implemented for the binary string representation, specifically:

  • Perturbations

    • Flip each bit with probability p
    • Flip single random bit
    • Flip of N bits (N is chosen beforehand, not randomly)
    • Flip of whole binary string
  • Crossover

    • N point crossover

As for initialization, random initializer has been implemented.

The fitness function is implemented by form of a wrapper that converts the BinaryString into NodePermutation and then the same fitness function is used as for node permutation representation.

#N-point crossover

The N point crossover works on two parents and is capable of producing two offsprings. The crossover first chooses N cross points randomly.

Then, the cross points are ordered in an ascending order and first all bits from first parent are chosen until cross point is encountered, then all bits are taken from second parent until another cross point is reached. And this repeats until the end of the string is reached.

Also, one-point and two-point crossovers have been implemented separately for more effective implementation than the generic N-point.

#Heuristics

Instead of starting with random solutions, two heuristics have been tried to make the initial populations for the evolutionary algorithms.

#Nearest neighbors

The heuristic starts at a given node and finds its nearest neighbor. Then adds that neighbor to the permutation and moves to it. Then it repeats search for the nearest neighbor, making sure to not select nodes twice. The whole chromosome is built like this.

Moreover the possibility to select second neighbor instead of first has been incorporated in the heuristic as well. Specifically, it's possible to choose the probability to choose second neighbor instead of first one.

This makes it possible to generate a lot of initial solutions, specifically it's possible to generate nearest neighbors starting from each city and then it's possible to tweak the probability of choosing the second neighbor.

To initialize the whole population:

  1. Generate chromosome starting from each city, choosing the first neighbor.
  2. Generate chromosome starting from each city, but always choosing the second neighbor.
  3. The rest of the population is initialized from randomly selected cities with randomly selected probability of choosing second neighbor.

#Minimal spanning tree

For using a minimal spanning tree the algorithm of Christofides and Serdyukov has been chosen as an inspiration. But instead of trying to find best Eulerian tour on the minimal spanning tree, a slightly different randomized approach has been taken. Specifically, random node is chosen as the starting point and then edges are chosen out of the minimal spanning tree to go through. If there are more edges to travel through, random one is chosen. If there are no edges left, nearest neighbor is chosen as next node.

To initialize the whole population, the same algorithm is ran multiple times. Since it is random, its results should be different, at least slightly.

#Results

To compare all the algorithms on various instances, 10 runs of each algorithm have been performed on each instance. All the graphs of probabilities of reaching target for given function evaluation were then constructed from averaging between the runs and between the instances. The fitness charts sometimes show fewer runs to not be too overwhelming. All evolutionary algorithms ran for 10000 iterations (epochs) and the local search and random search were adjusted to run on the same number of fitness function evaluations. The population size for the evolutionary algorithm is 500. The number of parents (and thus offspring) is 250.

On the x-axis of all graphs is the number of evaluations of the fitness function.

The graphs with probability of reaching targets have been computed as follows:

  1. Individually computed graphs for targets 1 %, 5 %, 10 % and 20 %
  2. Averaged the graphs

To compute the individual graphs, at each evaluation point, we count how many runs have already achieved better results than the target and divide this by the total number of runs.

Then, standard deviation has been added, clamped between 0 and 1.

The instances chosen from TSPLIB, all 2D Euclidean:

  • eil51
  • eil76
  • kroA100
  • eil101
  • pr124
  • ch150
  • kroA150
  • kroA200
  • ts225
  • a280

For all the evolutionary algorithm runs, roulette wheel has been used as selection and truncation of best has been selected for replacement. This is because they seemed to provide the best results.

During runs of the EA, the probabilities of mutations were changed throughout the run, by this formula: P = 0.5 * (1.0 + b / e), where b is iterations since better solution has been found and e is number of iterations till the end (max_iterations - current_iteration), this makes the mutations more aggressive later in the algorithm, as long as no better solutions are found, reaching even 100 % for some cases.

\newpage

#Comparing algorithms

To compare the algorithms, first it has been ensured the algorithms were tweaked to produce the best results (best that the author has been capable of). Then, they were run on 10 instances of TSP and averaged in the following chart:

Probability of reaching a target for EA, LS, RS on all instances.{latex-placement="H"} \newpage

Here is a an averaged fitness sample on eil76 instance:

Best so far fitness of EA, LS, RS on eil76 instance.{latex-placement="H"}

From the results, the LS is capable of finding a better solution early on, but then, the EA catches up and produces a slightly better solution than LS. LS converges earlier than EA.

\newpage

#Comparing perturbations on LS

Four perturbations have been evaluated:

  • Moving single city
  • Swapping of two cities
  • Reversing subsequence
  • Combination of the above (each cycle, single random perturbation has been chosen to run from the above three)

Best so far fitness of various LS on eil76 instance.{latex-placement="H"}

\newpage

Probability of reaching a target for various LS on all instances. Standard deviation is not shown to make the plot more readable. While the swap average is barely above 0, its standard deviation would show it's capable of reaching the targets on some instances.{latex-placement="H"}

from the experiment it seems the reversal of a subsequence is the best singular perturbation, but the combination is even better. That could be explained by the fact that in cases the subsequence reversal gets stuck somewhere, the other perturbations are able to recover.

\newpage

#Comparing crossovers

During evaluation of the various crossovers, it has become apparent that with the currently chosen parameters of the algorithms, the dominant parts that are responsible for the good convergence are: selection, perturbation and replacement, but not the crossover itself. It was even tried to use no crossover at all, passing through the parents and the convergence has been similar. For this reason, the mutations probabilities have been lowered significantly for this comparison. This then allows for comparison between the crossovers themselves. Specifically the probability of mutation is 0.001.

Probability of reaching a target for various EA crosovers on all instances.{latex-placement="H"}

From the runs, the edge recombination has produced the best results, with partially mapped crossover being the second and cycle crossover being the worst. This could be explained by the fact that edge recombination keeps all of the edges of parents, while mapped crossover makes sure to keep edges only inside the two cross point interval. And cycle crossover might miss even more edges.

While at the end CX and PMX are cathing up, I think this is mostly thanks to the mutations rather than the crossovers themselves. When mutations are turned off completely, PMX and CX never reach targets as high as ERX.

\newpage

#Comparing representations

Best so far fitness of binary and permutation representations on eil76 instance.{latex-placement="H"}

Probability of reaching a target for binary and permutation representations on all instances.{latex-placement="H"}

On one hand the binary string representation is simpler, because it doesn't require special treatment for crossovers. Any crossover leads to valid result. On the other hand since it's not so specific to the given problem, it seems to lead to worse results than the node permutation. It seems that mainly the reverse subsequence perturbation is making node permutation representation that much better, as can be seen from comparison of various LS perturbations.

On top of its worse performance, the whole run is also slower for the same number of iterations, but this might be just due to the poor implementation technique chosen for the binary string conversion to node permutation.

\newpage

#Comparing heuristics

Both of the heuristics are capable of making initial solutions much better than random ones. Here is a fitness graph with only the two heuristics.

Probability of reaching a target for two heuristics on all instances. The graph doesn't include standard deviation so that it is readable.{latex-placement="H"}

From the results it can be seen the nearest neighbor heuristic performs slightly better for the initialization, but at the end both heuristics lead to very similar results. If the heuristic with minimal spanning tree has been enhanced by searching for better solutions instead of randomly, it might outperform nearest neighbor.

The evolutionary algorithm still provides a visible improvement even for the heuristics that are already very good starting points. Moreover it has to be noted that the results show that with those heuristics, it's possible to obtain a better result with the evolutionary algorithm overall that hasn't been reached with algorithm with random initialization. This shows possible problem with the configuration, the population stagnates. It could be possible to come up with ways to make the population not stagnate so much so that the EA algorithm doesn't converge so early.

#Things done above minimal requirements

  • 1 point - Compared 2 representations (permutation and binary string with precedences)
  • 1 point - Compared 4 LS perturbations (swap, reverse subsequence, move city, combination)
  • 1 point - Compared 3 crossover operators (edge recombination, partially mapped, cycle)
  • 1 point - Compared all algorithms on 10 instances
  • 1 point - Initialized with two constructive heuristics (nearest neighbors, solutions from minimal spanning tree)

#Code structure

Rust has been chosen as the language. There are three subdirectories, eoa_lib, tsp_hw01 and tsp_plotter.

eoa_lib is the library with the generic operators defined, with random search, local search and evolution algorithm functions. It also contains the most common representations, perturbations and crossovers for them.

tsp_hw01 contains the TSP implementation and runs all of the algorithms. It then produces CSV results with best candidate evaluations for given fitness function evaluation count. This is then utilized by tsp_plotter. The node permutation TSP representation itself is implemented in tsp.rs. The configurations of all algorithms used are located in main.rs. All the instances used are located in tsp_hw01/instances and the solutions are put to tsp_hw01/solutions.

tsp_plotter contains hard-coded presets for charts to create for the report.

As part of the work, apart from the standard library, third party libraries have been used, namely:

  • nalgebra for working with vertices and matrices,
  • plotters for plotting
  • rand
  • rand_distr
  • thiserror for easing work with errors
  • serde, serde_json for loading json files
  • csv for loading csv files

#Running the algorithms

For run instructions, including generation of the plots in this report, see README.md.

#Usage of LLM

While I was working on this homework, I have used LLM for writing certain parts of the code, specifically I have used it very extensively for routine tasks:

  • Loading data,
  • Plotting graphs,
  • Refactoring of a triviality at a lot of places at once (i.e., at first I chose to make perturbation copy the chromosome, but I realized this is very ineffective for EAs afterwards and changed it to change in-place instead),
  • Writing some of the tests

As I am not proficient with Rust, sometimes I asked LLM to help with the syntax as well, to find a library that will help solve a task or what functions are available for a specific task.

I have used LLM only minimally for implementing the algorithms or for deciding on how to make the implementation, mainly sometimes in the form of checking if the algorithm looks correct. It did find a few issues that I then sometimes let it fix and sometimes fixed myself. This is because I believe that I can learn the most by writing the implementations myself.

I use Claude from within claude-code, a CLI tool that is capable of reading files, executing commands and giving the model live feedback, like outputs of ran commands. This means the LLM is able to iterate by itself, without my intervention, for example when fixing errors. On the other hand it can use a lot of tokens quickly unless I make sure to run compact often.

#Resources