├── source ├── __init__.py ├── fitness_function_ga.png ├── helpers.py ├── patterns.py ├── lib │ └── descriptions.json ├── two_opt.py ├── traveling_saleman_problem.py ├── plotting.py ├── self_organizing_maps.py └── geneticalgorithm.py ├── tests └── __init__.py ├── .devcontainer └── welcome.txt ├── requirements.txt ├── viktor.config.toml ├── .idea ├── .gitignore ├── vcs.xml ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── sample-travelling-salesman.iml └── sample-traveling-salesman-problemm.iml ├── App_figure.png ├── Screenshot.PNG ├── .gitignore ├── welcome.txt ├── CHANGELOG.md ├── devcontainer.json ├── README.md ├── next_step.html ├── parametrization.py └── app.py /source/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.devcontainer/welcome.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | viktor==14.0.0 2 | plotly==5.9.0 3 | 4 | -------------------------------------------------------------------------------- /viktor.config.toml: -------------------------------------------------------------------------------- 1 | python_version='3.9' 2 | app_type = 'editor' 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /App_figure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktor-platform/sample-travelling-salesman/HEAD/App_figure.png -------------------------------------------------------------------------------- /Screenshot.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktor-platform/sample-travelling-salesman/HEAD/Screenshot.PNG -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | dev-requirements.txt 4 | pyproject.toml 5 | run_code_quality_tools.sh 6 | *.mp4 7 | *.Identifier 8 | *.zip -------------------------------------------------------------------------------- /source/fitness_function_ga.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktor-platform/sample-travelling-salesman/HEAD/source/fitness_function_ga.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /welcome.txt: -------------------------------------------------------------------------------- 1 | __ __ __ __ __ 2 | \ \ / / | | | |/ / 3 | \ \ / / | | | ' / 4 | \ \/ / | | | < 5 | \ / | | | . \ 6 | \/ |_| |_|\_\ 7 | _______ ____ _____ 8 | |__ __| / __ \ | __ \ 9 | | | | | | | | |__) | 10 | | | | | | | | _ / 11 | | | | |__| | | | \ \ 12 | |_| \____/ |_| \_\ 13 | 14 | 👋 Welcome to VIKTOR using Codespaces! 👋 15 | -------------------------------------------------------------------------------- /.idea/sample-travelling-salesman.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /.idea/sample-traveling-salesman-problemm.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /source/helpers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def path_distance(cities: np.ndarray, route: list = None) -> float: 5 | """Calculate the euclidian distance in n-space of the route traversing cities, ending at the path start. 6 | 7 | Args: 8 | cities: The cities used in the problem. 9 | route: A solution. If None the function sees the order of cities as the route. 10 | 11 | Returns: 12 | The total distance the route takes. 13 | """ 14 | if route is not None: # Cities are not in the right order 15 | return np.sum([np.linalg.norm(cities[route[p]] - cities[route[p - 1]]) for p in range(len(route))]) 16 | else: # Cities are in the right order 17 | return np.sum(np.linalg.norm(cities - np.roll(cities, 1, axis=0), axis=1)) 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## v#.#.# [dd-mm-yyyy] 5 | ### Added 6 | None. 7 | 8 | ### Changed 9 | None. 10 | 11 | ### Deprecated 12 | None. 13 | 14 | ### Removed 15 | None. 16 | 17 | ### Fixed 18 | None. 19 | 20 | ### Security 21 | None. 22 | 23 | ## v0.2.0 [14-09-2022] 24 | ### Added 25 | None. 26 | 27 | ### Changed 28 | - Merged the route and fitness function views to a single view. 29 | - Bumped to viktor v13.4.0. 30 | - Converted the app to viktor simple app. 31 | - Python version changed to 3.9. 32 | 33 | ### Deprecated 34 | None. 35 | 36 | ### Removed 37 | - Removed the route visualization. 38 | - Removed the fitness function visualization. 39 | 40 | ### Fixed 41 | None. 42 | 43 | ### Security 44 | None. 45 | 46 | ## v0.1.0 [30-08-2022] 47 | ### Added 48 | - Initial setup of traveling salesman problem app. 49 | 50 | ### Changed 51 | None. 52 | 53 | ### Deprecated 54 | None. 55 | 56 | ### Removed 57 | None. 58 | 59 | ### Fixed 60 | None. 61 | 62 | ### Security 63 | None. 64 | -------------------------------------------------------------------------------- /devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "postCreateCommand": "sudo cp .devcontainer/welcome.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt && mkdir ~/.viktor && echo \"export PATH=$PATH:~/.viktor\" >> ~/.bashrc && curl -Lo viktor-cli 'https://sys.viktor.ai/api/v1/get-cli/?platform=linux&format=binary' && chmod +x viktor-cli && mv viktor-cli ~/.viktor/viktor-cli && python -m venv $PWD/venv", 5 | "postStartCommand": "~/.viktor/viktor-cli upgrade", 6 | "postAttachCommand": { 7 | "viktor-cli": "~/.viktor/viktor-cli check-system && source venv/bin/activate" 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "ms-python.python" 13 | ], 14 | "settings": { 15 | "python.defaultInterpreterPath": "", 16 | "python.terminal.activateEnvironment": false, 17 | "python.terminal.activateEnvInCurrentTerminal": false, 18 | "python.interpreter.infoVisibility": "always", 19 | "terminal.integrated.hideOnStartup": "never" 20 | } 21 | }, 22 | "codespaces": { 23 | "openFiles": ["app.py"] 24 | } 25 | }, 26 | "secrets": { 27 | "VIKTOR_CREDENTIALS": { 28 | "description": "Paste the token obtained during the onboarding flow." 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/patterns.py: -------------------------------------------------------------------------------- 1 | from numpy import cos 2 | from numpy import ndarray 3 | from numpy import pi 4 | from numpy import random 5 | from numpy import sin 6 | 7 | 8 | def get_circle_points(radius: int, n: int) -> ndarray: 9 | """Create a circle with a certain radius and place cities evenly on this circle. 10 | 11 | Args: 12 | radius: The radius of the circle you are generating. 13 | n: The number of nodes you want to generate. 14 | 15 | Returns: 16 | A ndarray with cities with two coordinates. 17 | """ 18 | points = ndarray(shape=(n, 2)) 19 | for x, point in enumerate(points): 20 | point[0] = cos(2 * pi / n * x) * radius 21 | point[1] = sin(2 * pi / n * x) * radius 22 | 23 | # Shuffle the points so the initial route is not the perfect one 24 | random.RandomState(n).shuffle(points) 25 | return points 26 | 27 | 28 | def get_random_points(n, seed): 29 | """Generate cities on random points given a seed. The same seed and n will always generate the same topology. 30 | 31 | Args: 32 | n: The number of nodes you want to generate. 33 | seed: The random seed for generating the topology. Makes sure that we can generate the same topology whenever we like. 34 | 35 | Returns: 36 | A ndarray with cities with two coordinates. 37 | """ 38 | return random.RandomState(seed).rand(n, 2) 39 | -------------------------------------------------------------------------------- /source/lib/descriptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "Population size" : "Number of solutions we are going to compare to each other every generation. For the self-organizing maps algorithm a population size eight times the number of nodes is suggested.", 3 | "Generations" : "The number of generations this algorithm will try to get a better solution then the previous.", 4 | "Learning rate" : "Controls the exploration and explotation of the algorithm. A high number indicates an aggressive search.", 5 | "Decay" : "Decays the learning rate over time so the algorithm gets less aggressive.", 6 | "Elite size" : "Because the genetical algorithm is random we want to breed only the best. The elite size is the number of solutions we want to use for the offspring each generation.", 7 | "Mutation rate" : "The mutation rate is the chance a city get's swapped with a random other city on its route.", 8 | "Improvement threshold" : "This algorithm stops if a new solution is not improvement more than the threshold.", 9 | "Two opt" : "[2-opt](https://en.wikipedia.org/wiki/2-opt) is a simple local search algorithm for solving the traveling salesman problem. The main idea behind it is to take a route that crosses over itself and reorder it so that it does not.", 10 | "Genetical algorithm" : "A [genetical algorithm](https://towardsdatascience.com/evolution-of-a-salesman-a-complete-genetic-algorithm-tutorial-for-python-6fe5d2b3ca35) is a search heuristic that is inspired by Charles Darwin's theory of natural evolution. This algorithm reflects the process of natural selection where the fittest individuals are selected for reproduction in order to produce offspring of the next generation. In this app each individual is a different route.", 11 | "Self-organizing maps" : "A [self-organizing map](https://diego.codes/post/som-tsp/) is inspired by a neural network. Closely related to the map. The purpose of the technique is to represent the model with a lower number of dimensions, while maintaining the relations of similarity of the nodes contained in it.\nTo capture this similarity, the nodes in the map are spatially organized to be closer the more similar they are with each other.", 12 | "Topology" : "Generate the topology however you like by adjusting these parameters. The app will keep a highscore for every different topology created." 13 | } -------------------------------------------------------------------------------- /source/two_opt.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | 5 | from .helpers import path_distance 6 | 7 | 8 | def _two_opt_swap(r, i, k): 9 | """Reverse the order of all elements from element i to element k in array r.""" 10 | return np.concatenate((r[0:i], r[k : -len(r) + i - 1 : -1], r[k + 1 : len(r)])) 11 | 12 | 13 | def two_opt(cities: np.ndarray, improvement_threshold: float) -> Union[list, list, list]: 14 | """2-opt Algorithm adapted from https://en.wikipedia.org/wiki/2-opt 15 | 16 | Args: 17 | cities: The problem definition. A list of cities with x and y coordinates. 18 | improvement_threshold: This algorithm stops if a new solution is not improvement more than the threshold. 19 | Returns: 20 | routes: A list of routes. 21 | iterations: The different iterations. 22 | distances: The calculated distances for the best route of each generation. 23 | """ 24 | route = np.arange(cities.shape[0]) # Make an array of row numbers corresponding to cities. 25 | routes = [route] 26 | improvement_factor = 1 # Initialize the improvement factor. 27 | best_distance = path_distance(cities, route) # Calculate the distance of the initial path. 28 | distances = [best_distance] 29 | i = 0 30 | iterations = [i] 31 | while improvement_factor > improvement_threshold: # If the route is still improving, keep going! 32 | i += 1 33 | distance_to_beat = best_distance # Record the distance at the beginning of the loop. 34 | for swap_first in range(1, len(route) - 2): # From each city except the first and last, 35 | for swap_last in range(swap_first + 1, len(route)): # to each of the cities following, 36 | new_route = _two_opt_swap(route, swap_first, swap_last) # try reversing the order of these cities 37 | new_distance = path_distance(cities, new_route) # and check the total distance with this modification. 38 | if new_distance < best_distance: # If the path distance is an improvement, 39 | route = new_route # make this the accepted best route 40 | best_distance = new_distance # and update the distance corresponding to this route. 41 | improvement_factor = 1 - best_distance / distance_to_beat # Calculate how much the route has improved. 42 | routes.append(route) 43 | iterations.append(i) 44 | distances.append(best_distance) 45 | return ( 46 | routes, 47 | iterations, 48 | distances, 49 | ) # When the route is no longer improving substantially, stop searching and return the route. 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://img.shields.io/badge/SDK-v14.0.0-blue) 2 | # sample-traveling-salesman-problem 3 | 4 | ![Alt text](Screenshot.PNG) 5 | 6 | 7 | The [traveling salesman problem](https://en.wikipedia.org/wiki/Travelling_salesman_problem) is a popular subject when discussing [NP-hard](https://en.wikipedia.org/wiki/NP-hardness) problems. A screenshot of the VIKTOR app is shown in the figure above. There are a lot of different approaches to solving this problem. The goal of this app is to showcase some of these approaches inside the VIKTOR environment. While no new approach is developed in this app, the app will allow you to play with the different algorithms so you can try to get the best solution for a generated topology. 8 | 9 | ### 2-opt 10 | 11 | The first and easiest algorithm to be implemented in this app is the [2-opt](https://en.wikipedia.org/wiki/2-opt) local search algorithm. This algorithm detects if the route crosses over itself and if it does it tries to reorder itself so that it does not. 12 | 13 | ### Genetical Algorithm 14 | 15 | The [Genetical Algorithm](https://towardsdatascience.com/evolution-of-a-salesman-a-complete-genetic-algorithm-tutorial-for-python-6fe5d2b3ca35) (GA) is an [evolutionary algorithm](https://towardsdatascience.com/introduction-to-evolutionary-algorithms-a8594b484ac) that is based on Charles Darwin's theory of natural evolution. After initializing the population (which is defined as different possible routes) we select the fittest individuals and combine them to get new, and hopefully better, solutions. We can also implement random mutations in the population. This is helpful because otherwise there is a chance that the solution will never improve. After all, we keep having the same individuals when breeding. 16 | 17 | ### Self-organizing maps 18 | 19 | The [self-organizing maps](https://diego.codes/post/som-tsp/) algorithm implements a neural network that is closely related to the topology we generate. The purpose of the technique is to represent the model with a lower number of dimensions while maintaining the relations of similarity of the nodes contained in it. The algorithm used in this app generates a circular array of neurons, behaving as an elastic ring. During the execution of the algorithm, the neurons are searching for cities closest to their neighborhood. The ring then warps around the cities. To ensure convergence we can add a learning rate to the algorithm. The higher the rate the more the neurons will move, but we might want to make the algorithm less aggressive the longer it runs. Because of this, we can also add decay to the learning rate. 20 | 21 | ## App structure 22 | This is an editor-only app type. 23 | 24 | -------------------------------------------------------------------------------- /next_step.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title of the document 5 | 70 | 71 | 72 |
73 |
74 | 🔩

So, what's next? 75 |
76 |

77 | Do you want to explore more public VIKTOR apps? 78 |

79 | Explore apps 80 |

81 | Do you want to build your own VIKTOR app? 82 |

83 | Get started! 84 |

85 | Curious how this app was built? Go check out the repository: 86 |

87 | Github repository 88 |
89 | 90 | -------------------------------------------------------------------------------- /source/traveling_saleman_problem.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union 3 | 4 | import numpy as np 5 | 6 | from .geneticalgorithm import geneticAlgorithm 7 | from .plotting import create_animation 8 | from .self_organizing_maps import self_organizing_maps 9 | from .two_opt import two_opt 10 | 11 | 12 | class Method(Enum): 13 | two_opt = 0 14 | GA = 1 15 | SOM = 2 16 | 17 | 18 | def tsp( 19 | cities: np.ndarray, 20 | method: Method = Method.two_opt, 21 | improvement_threshold: float = 0.001, 22 | popSize: int = 100, 23 | eliteSize: int = 20, 24 | mutationRate: float = 0.01, 25 | generations: int = 500, 26 | learning_rate: float = 0.8, 27 | decay: float = 0.0003, 28 | ) -> Union[str, dict]: 29 | """Solve the traveling salesman problem with the selected method, the different parameters are used for different methods. 30 | 31 | Args: 32 | cities: The topology of the problem. 33 | method: The algorithm to be used. 34 | improvement_threshold: The minimum improvement for two-opt to continue searching. 35 | popSize: Population size for evolutionary algorithms. 36 | eliteSize: The selection size for evolutionary algorithms. 37 | mutationRate: Mutation rate for evolutionary algorithms. 38 | generations: The number of generations an evolutionary algorithm must go through. 39 | learning_rate: Displacement of the neurons to search a route. 40 | decay: Decays the learning rate so we get less aggressive searches over time. 41 | 42 | Returns: 43 | str: The plotly figure to json so we can use it on the plotlyview. 44 | data: dictionary of the maximum iteration and calculated distance. 45 | """ 46 | 47 | # If method is an int change to Enum 48 | if type(method) == int: 49 | method = Method(method) 50 | 51 | # Select the correct method 52 | if method == Method.two_opt: 53 | routes, i, distance = two_opt(cities, improvement_threshold) 54 | elif method == Method.GA: 55 | routes, i, distance = geneticAlgorithm(cities, popSize, eliteSize, mutationRate, generations) 56 | elif method == Method.SOM: 57 | routes, i, distance = self_organizing_maps(cities, generations, learning_rate, popSize, decay) 58 | else: 59 | raise NotImplementedError(f"Method {method.name} not implemented.") 60 | 61 | # Reorder the cities matrix by route order in a new matrix for plotting. 62 | new_cities_orders = [ 63 | np.concatenate((np.array([cities[route[i]] for i in range(len(route))]), np.array([cities[route[0]]]))) 64 | for route in routes 65 | ] 66 | 67 | # Create figure for plotly view 68 | fig = create_animation(new_cities_orders) 69 | 70 | # Create data for data view 71 | data = {"Max iteration": i, "Calculated distance": distance} 72 | 73 | return fig.to_json(), data 74 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 46 | -------------------------------------------------------------------------------- /source/plotting.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import plotly.express as px 3 | import plotly.graph_objects as go 4 | from plotly.subplots import make_subplots 5 | 6 | 7 | def create_animation(new_cities_orders: list) -> px.line: 8 | """Creates an animation of the different routes found over different generations. 9 | 10 | Args: 11 | new_cities_orders: A list containing city lists in the order of the route taken. 12 | 13 | Returns: 14 | A plotly figure with animation frames. 15 | """ 16 | data = {"x": [], "y": [], "frame": [], "city": []} 17 | for frame, new_cities_order in enumerate(new_cities_orders): 18 | for city in new_cities_order: 19 | data["x"].append(city[0]) 20 | data["y"].append(city[1]) 21 | data["frame"].append(frame) 22 | data["city"].append(city) 23 | df = pd.DataFrame(data=data) 24 | 25 | fig = px.line(df, x="x", y="y", animation_frame="frame", markers=True, animation_group="city") 26 | 27 | return fig 28 | 29 | 30 | def plot_fitness(generations: list, distances: list) -> px.line: 31 | """Creates a plot for the fitness function. Here we use distance to measure the fitness. 32 | 33 | Args: 34 | generations: The different generations of the results. 35 | distances: The distances corresponding the generations. 36 | 37 | Returns: 38 | A plotly figure visualizing the distance over the generations. 39 | """ 40 | fig = px.line(x=generations, y=distances, title="Fitness") 41 | fig.update_layout(xaxis_title="Iteration", yaxis_title="Distance") 42 | return fig 43 | 44 | 45 | def plot_animation_and_fitness(route_fig: dict, data: dict) -> go.Figure: 46 | """Creates a subplot with the one, a plot for the fitness function. Here we use distance to measure the fitness. 47 | The other plot is an animation of the different routes found over different generations. 48 | 49 | Args: 50 | route_fig: A plotly Figure object converted to a dict format. 51 | data: A dictionary containing the "Max iteration" and "Calculated distance" data points. 52 | 53 | Returns: 54 | A plotly figure visualizing the distance over the generations, as well as the route frames. 55 | """ 56 | fig = make_subplots( 57 | cols=2, 58 | column_widths=[0.5, 0.5], 59 | horizontal_spacing=0.02, 60 | subplot_titles=('Route', 'Fitness') 61 | ) 62 | fig.add_trace(go.Scatter( 63 | x=route_fig['data'][0]['x'], 64 | y=route_fig['data'][0]['y'], 65 | name='Route', 66 | showlegend=True, 67 | hoverinfo='name', 68 | legendgroup='Route', 69 | mode='markers+lines', 70 | )) 71 | fig.add_trace(go.Scatter( 72 | x=[data["Max iteration"][0]], 73 | y=[data["Calculated distance"][0]], 74 | name='Fitness', 75 | showlegend=True, 76 | hoverinfo='name', 77 | legendgroup='Fitness', 78 | mode='markers+lines', 79 | ), row=1, col=2) 80 | fig_dict = fig.to_dict() 81 | fig_dict['frames'] = route_fig['frames'] 82 | for i, frame in enumerate(fig_dict['frames']): 83 | frame['data'].append(go.Scatter(x=data["Max iteration"][0: i + 1], 84 | y=data["Calculated distance"][0: i + 1])) 85 | fig_dict['layout']['updatemenus'] = route_fig['layout']['updatemenus'] 86 | fig_dict['layout']['sliders'] = route_fig['layout']['sliders'] 87 | 88 | fig_dict['layout']['xaxis2']['range'] = [min(data["Max iteration"]) - 0.5, max(data["Max iteration"]) + 0.5] 89 | fig_dict['layout']['yaxis2']['range'] = [min(data["Calculated distance"]) - 0.5, max(data["Calculated distance"]) + 0.5] 90 | return go.Figure(fig_dict) 91 | -------------------------------------------------------------------------------- /parametrization.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from viktor.parametrization import IsEqual 5 | from viktor.parametrization import Lookup 6 | from viktor.parametrization import NumberField 7 | from viktor.parametrization import OptionField 8 | from viktor.parametrization import OptionListElement 9 | from viktor.parametrization import Or 10 | from viktor.parametrization import Parametrization 11 | from viktor.parametrization import Section 12 | from viktor.parametrization import Text 13 | 14 | _options = [OptionListElement("circle", "Circle"), OptionListElement("random", "Random")] 15 | 16 | _method_options = [ 17 | OptionListElement(0, "2-opt"), 18 | OptionListElement(1, "Genetic Algorithm"), 19 | OptionListElement(2, "Self-Organizing Maps"), 20 | ] 21 | 22 | # Use a json file with all the descriptions to not clutter this file 23 | with open(Path(__file__).parent / "source" / "lib" / "descriptions.json") as json_file: 24 | descriptions = json.load(json_file) 25 | 26 | 27 | class AppParametrization(Parametrization): 28 | topology = Section("Topology") 29 | topology.text = Text(descriptions["Topology"]) 30 | topology.topology = OptionField("Topology", options=_options, default="circle") 31 | topology.n = NumberField("Nodes", min=3, max=100, step=1, default=5) 32 | topology.r = NumberField( 33 | "Radius", min=1, max=100, step=1, default=1, visible=IsEqual(Lookup("topology.topology"), "circle") 34 | ) 35 | topology.seed = NumberField("Seed", default=1, visible=IsEqual(Lookup("topology.topology"), "random")) 36 | 37 | variables = Section("Variables") 38 | # Different texts depending on method selected 39 | variables.text_two_opt = Text(descriptions["Two opt"], visible=IsEqual(Lookup("variables.method"), 0)) 40 | variables.text_ga = Text(descriptions["Genetical algorithm"], visible=IsEqual(Lookup("variables.method"), 1)) 41 | variables.text_som = Text(descriptions["Self-organizing maps"], visible=IsEqual(Lookup("variables.method"), 2)) 42 | 43 | # The method and its variables 44 | variables.method = OptionField("Method", options=_method_options, default=0) 45 | variables.improvement_threshold = NumberField( 46 | "Improvement threshold", 47 | default=0.001, 48 | step=0.001, 49 | visible=IsEqual(Lookup("variables.method"), 0), 50 | description=descriptions["Improvement threshold"], 51 | ) 52 | 53 | variables.popSize = NumberField( 54 | "Population size", 55 | default=10, 56 | step=1, 57 | visible=Or(IsEqual(Lookup("variables.method"), 1), IsEqual(Lookup("variables.method"), 2)), 58 | description=descriptions["Population size"], 59 | ) 60 | variables.eliteSize = NumberField( 61 | "Elite size", 62 | default=5, 63 | step=1, 64 | visible=IsEqual(Lookup("variables.method"), 1), 65 | description=descriptions["Elite size"], 66 | ) 67 | variables.mutationRate = NumberField( 68 | "Mutation rate", 69 | default=0.01, 70 | step=0.01, 71 | visible=IsEqual(Lookup("variables.method"), 1), 72 | description=descriptions["Mutation rate"], 73 | ) 74 | variables.generations = NumberField( 75 | "Generations", 76 | default=500, 77 | step=100, 78 | visible=Or(IsEqual(Lookup("variables.method"), 1), IsEqual(Lookup("variables.method"), 2)), 79 | description=descriptions["Generations"], 80 | ) 81 | variables.learning_rate = NumberField( 82 | "Learning rate", 83 | default=0.8, 84 | min=0.1, 85 | max=1, 86 | step=0.1, 87 | visible=IsEqual(Lookup("variables.method"), 2), 88 | description=descriptions["Learning rate"], 89 | ) 90 | variables.decay = NumberField( 91 | "Decay", 92 | default=0.0003, 93 | min=0, 94 | max=1, 95 | step=0.0001, 96 | visible=IsEqual(Lookup("variables.method"), 2), 97 | description=descriptions["Decay"], 98 | ) 99 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union 3 | 4 | import numpy as np 5 | import plotly.express as px 6 | from munch import Munch 7 | from pathlib import Path 8 | 9 | from viktor.core import File 10 | from viktor.core import Storage 11 | from viktor.core import ViktorController 12 | from viktor.utils import memoize 13 | from viktor.views import PlotlyResult 14 | from viktor.views import PlotlyView 15 | from viktor.views import WebResult 16 | from viktor.views import WebView 17 | 18 | from parametrization import AppParametrization 19 | from source.patterns import get_circle_points 20 | from source.patterns import get_random_points 21 | from source.plotting import plot_animation_and_fitness 22 | from source.traveling_saleman_problem import tsp 23 | 24 | 25 | def update_highscore(score: float, key: int) -> float: 26 | """Updates the highscore for a particular topology for all the different methods. 27 | 28 | Args: 29 | score: The score of the current solution. 30 | key: The created by hash of the topolgy, used for accessing the storage. 31 | 32 | Returns: 33 | A float representing the highscore. Being it the score of the current solution or that from the storage. 34 | """ 35 | 36 | key = str(key) # Key is a hash so convert to string 37 | storage = Storage() # Initialise the storage 38 | try: 39 | old_score = float(storage.get(key, scope="entity").getvalue()) 40 | except FileNotFoundError as e: 41 | old_score = np.inf # First score for this topology 42 | 43 | if score < old_score: # New score better then old score 44 | storage.set(key, File(data=str(score)), scope="entity") # Save new score 45 | else: 46 | score = old_score # Return old score 47 | 48 | return score 49 | 50 | 51 | @memoize # Memoize because for the same input we don't need to run the algorithm again 52 | def run_tsp(params: Munch) -> Union[px.line, dict, int]: 53 | """Run the traveling salesman problem with the selected topology and method. 54 | 55 | Args: 56 | params: The parameters from the parametrization class. 57 | 58 | Returns: 59 | fig: A plotly animation for the best route on each generation. 60 | data: A dictionary containing the generations and the distances for each generation. 61 | key: A hash key generated from the current topology. Used for the storage. 62 | """ 63 | # Pars input 64 | topology = params.topology 65 | n = topology.n 66 | variables = params.variables 67 | 68 | # Build the topology 69 | if topology.topology == "circle": 70 | r = topology.r 71 | cities = get_circle_points(r, n) 72 | elif topology.topology == "random": 73 | seed = params.topology.seed 74 | cities = get_random_points(n, seed) 75 | 76 | # Get key for saving the highscore using a hash of the current topology 77 | key = hash(str(cities)) 78 | 79 | # Parse the arguments 80 | fig, data = tsp( 81 | cities, 82 | int(variables.method), 83 | improvement_threshold=variables.improvement_threshold, 84 | popSize=variables.popSize, 85 | eliteSize=variables.eliteSize, 86 | mutationRate=variables.mutationRate, 87 | generations=variables.generations, 88 | learning_rate=variables.learning_rate, 89 | decay=variables.decay, 90 | ) 91 | 92 | return fig, data, key 93 | 94 | 95 | class Controller(ViktorController): 96 | label = "Traveling Salesman Problem" 97 | parametrization = AppParametrization 98 | 99 | @PlotlyView("Route and fitness function", duration_guess=10) 100 | def get_tsp_result_and_fitness(self, params, **kwargs): 101 | """Plot an animation made by solving the traveling salesman problem, together with the fitness 102 | function.""" 103 | fig_animation, data, highscore_key = run_tsp(params) 104 | 105 | fig = plot_animation_and_fitness(json.loads(fig_animation), data) 106 | 107 | return PlotlyResult(fig.to_json()) 108 | 109 | 110 | @WebView("What's next?", duration_guess=1) 111 | def whats_next(self, params, **kwargs): 112 | """Initiates the process of rendering the "What's next" tab.""" 113 | html_path = Path(__file__).parent / "next_step.html" 114 | with html_path.open(encoding="utf-8") as _file: 115 | html_string = _file.read() 116 | return WebResult(html=html_string) 117 | 118 | -------------------------------------------------------------------------------- /source/self_organizing_maps.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from viktor.core import UserError 7 | 8 | from .helpers import path_distance 9 | 10 | 11 | def _select_closest(candidates: np.ndarray, origin: np.ndarray) -> int: 12 | """Return the index of the closest candidate to a given point. 13 | 14 | Args: 15 | candidates: The neuron network. 16 | origin: The original place of the city. 17 | 18 | Returns: 19 | The index of the closest candidate to a given point. 20 | """ 21 | return np.linalg.norm(candidates - origin, axis=1).argmin() 22 | 23 | 24 | def _read_tsp(cities: np.ndarray) -> pd.DataFrame: 25 | """Convert the cities in our examples to the format that this algorithm uses. 26 | 27 | Args: 28 | cities: List of cities with x and y coordinates. 29 | 30 | Returns: 31 | Pandas dataframe containing the cities with x and y coordinates. 32 | """ 33 | data = {"city": [], "y": [], "x": []} 34 | for idx, city in enumerate(cities, start=1): 35 | data["city"].append(idx) 36 | data["y"].append(city[1]) 37 | data["x"].append(city[0]) 38 | 39 | df = pd.DataFrame(data=data) 40 | 41 | return df 42 | 43 | 44 | def _normalize(points: pd.DataFrame) -> pd.DataFrame: 45 | """For a given array of n-dimensions, normalize each dimension by removing the 46 | initial offset and normalizing the points in a proportional interval: [0,1] 47 | on y, maintining the original ratio on x. 48 | 49 | Args: 50 | points: A vector of points. 51 | 52 | Returns: 53 | The normalized version of a given vector of points. 54 | """ 55 | ratio = (points.x.max() - points.x.min()) / (points.y.max() - points.y.min()), 1 56 | ratio = np.array(ratio) / max(ratio) 57 | norm = points.apply(lambda c: (c - c.min()) / (c.max() - c.min())) 58 | return norm.apply(lambda p: ratio * p, axis=1) 59 | 60 | 61 | def _generate_network(size: int) -> np.ndarray: 62 | """Generate a neuron network of a given size. 63 | 64 | Args: 65 | The size of the network you want to generate. 66 | 67 | Returns: 68 | A vector of two dimensional points in the interval [0,1]. 69 | """ 70 | return np.random.rand(size, 2) 71 | 72 | 73 | def _get_neighborhood(center: int, radix: float, domain: np.ndarray) -> np.ndarray: 74 | """Get the range gaussian of given radix around a center index. 75 | 76 | Args: 77 | center: The id of the city you want to search from. 78 | radix: The distance from the center where you want to search. 79 | domain: The network. 80 | 81 | Returns: 82 | The distances of points around the center index given a radix. 83 | """ 84 | 85 | # Impose an upper bound on the radix to prevent NaN and blocks 86 | if radix < 1: 87 | radix = 1 88 | 89 | # Compute the circular network distance to the center 90 | deltas = np.absolute(center - np.arange(domain)) 91 | distances = np.minimum(deltas, domain - deltas) 92 | 93 | # Compute Gaussian distribution around the given center 94 | return np.exp(-(distances * distances) / (2 * (radix * radix))) 95 | 96 | 97 | def _get_route(cities: pd.DataFrame, network: np.ndarray) -> pd.Int64Index: 98 | """Get the route generated by the algorithm. 99 | 100 | Args: 101 | cities: The problem definition. 102 | network: The neuron network closely representing the map. 103 | 104 | Returns: 105 | The route computed by a network. 106 | """ 107 | cities["winner"] = cities[["x", "y"]].apply(lambda c: _select_closest(network, c), axis=1, raw=True) 108 | 109 | return cities.sort_values("winner").index 110 | 111 | 112 | def self_organizing_maps( 113 | cities: np.ndarray, iterations: int, learning_rate: float = 0.8, popSize: int = 100, decay: float = 0.0003 114 | ) -> Union[list, list, list]: 115 | """Solve the TSP using a Self-Organizing Map. 116 | 117 | Args: 118 | cities: The problem definition. A list of cities with x and y coordinates. 119 | iterations: The number of generations we want to try getting a better solution. 120 | popSize: Size of the population. 121 | learning_rate: Controls the exploration and explotation of the algorithm. A high number indicates an aggressive search. 122 | decay: Decays the learning rate over time so the algorithm gets less aggressive. 123 | 124 | Returns: 125 | routes: A list of routes. 126 | iterations: The different generations. 127 | distances: The calculated distances for the best route of each generation. 128 | """ 129 | problem = _read_tsp(cities) 130 | 131 | # Obtain the normalized set of cities (w/ coord in [0,1]) 132 | cities = problem.copy() 133 | 134 | cities[["x", "y"]] = _normalize(cities[["x", "y"]]) 135 | 136 | # The population size 137 | n = popSize 138 | 139 | # Generate an adequate network of neurons: 140 | network = _generate_network(n) 141 | 142 | routes = [_get_route(cities, network).values.tolist()] 143 | distances = [path_distance(problem[["x", "y"]].to_numpy())] 144 | 145 | for i in range(iterations): 146 | # Choose a random city 147 | city = cities.sample(1)[["x", "y"]].values 148 | winner_idx = _select_closest(network, city) 149 | # Generate a filter that applies changes to the winner's gaussian 150 | gaussian = _get_neighborhood(winner_idx, n // 10, network.shape[0]) 151 | # Update the network's weights (closer to the city) 152 | network += gaussian[:, np.newaxis] * learning_rate * (city - network) 153 | # Decay the variables 154 | learning_rate = learning_rate * (1 - decay) 155 | n = n * (1 - decay) 156 | 157 | # Adding data for the frames 158 | route = _get_route(cities, network) 159 | problem = problem.reindex(route) 160 | distances.append(path_distance(problem[["x", "y"]].to_numpy())) 161 | route = route.values.tolist() 162 | routes.append(route) 163 | 164 | # Check if any parameter has completely decayed. 165 | if n < 1: 166 | raise UserError("Radius has completely decayed, finishing execution at {} iterations".format(i)) 167 | if learning_rate < 0.001: 168 | raise UserError("Learning rate has completely decayed, finishing execution at {} iterations".format(i)) 169 | 170 | return routes, np.arange(0, iterations + 1, 1).tolist(), distances 171 | -------------------------------------------------------------------------------- /source/geneticalgorithm.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from .helpers import path_distance 7 | 8 | 9 | def _createRoute(cities: np.ndarray) -> np.ndarray: 10 | """Rearange the cities so we get a random route. 11 | 12 | Args: 13 | cities: list of cities as ndarray(size=(2,n)). 14 | 15 | Returns: 16 | Array containing the city id's in the order of the route. 17 | """ 18 | route = np.arange(cities.shape[0]) # cities list to array with indices to cities 19 | np.random.shuffle(route) # Shuffle the route 20 | return route 21 | 22 | 23 | def _initialPopulation(popSize: int, cities: np.ndarray) -> list: 24 | """Initialise the population with different routes. 25 | 26 | Args: 27 | popSize: population size. 28 | cities: list of cities as ndarray(size=(2,n)). 29 | 30 | Returns: 31 | List of different routes. 32 | """ 33 | population = [] 34 | 35 | for i in range(0, popSize): 36 | population.append(_createRoute(cities)) 37 | return population 38 | 39 | 40 | def _rankRoutes(population: list, cities: np.ndarray) -> np.ndarray: 41 | """Sort the routes of the population so that we can select the best. 42 | 43 | Args: 44 | population: The population consisting of routes. 45 | cities list ofcities as ndarray(size=(2,n)). 46 | 47 | Returns: 48 | Sorted list with the best routes. 49 | """ 50 | fitnessResults = {} 51 | for idx, route in enumerate(population): 52 | fitnessResults[idx] = 1 / float(path_distance(cities, route)) 53 | 54 | return sorted(fitnessResults.items(), key=operator.itemgetter(1), reverse=True) 55 | 56 | 57 | def _selection(popRanked: list, eliteSize: int) -> list: 58 | """Select the creme de la creme. 59 | 60 | Args: 61 | popRanked: population ranked from best to worst. 62 | eliteSize: size to be selected. 63 | 64 | Returns: 65 | Selection of the best routes used for the matingpool. 66 | """ 67 | selectionResults = [] 68 | df = pd.DataFrame(np.array(popRanked), columns=["Index", "Fitness"]) 69 | df["cum_sum"] = df.Fitness.cumsum() 70 | df["cum_perc"] = 1000 * df.cum_sum / df.Fitness.sum() 71 | 72 | for i in range(0, eliteSize): 73 | selectionResults.append(popRanked[i][0]) 74 | for i in range(0, len(popRanked) - eliteSize): 75 | pick = 100 * np.random.random() 76 | for j in range(0, len(popRanked)): 77 | if pick <= df.iat[j, 3]: 78 | selectionResults.append(popRanked[j][0]) 79 | break 80 | return selectionResults 81 | 82 | 83 | def _matingPool(population: list, selectionResults: list) -> list: 84 | """Put the selection into a pool for breeding. 85 | 86 | Args: 87 | population: The population. 88 | selectionResults: The index of the population to be added to the pool. 89 | 90 | Returns: 91 | List of the best roots used as matingpool. 92 | """ 93 | matingpool = [] 94 | for result in selectionResults: 95 | matingpool.append(population[result]) 96 | return matingpool 97 | 98 | 99 | def _breed(parent1: list, parent2: list) -> list: 100 | """Combine different genes from two different parents. 101 | 102 | Args: 103 | parent1: A route. 104 | parent2: A route. 105 | 106 | Returns: 107 | A child with random genes from both parents Uses two-point crossover. 108 | """ 109 | child = [] 110 | childP1 = [] 111 | childP2 = [] 112 | 113 | geneA = int(np.random.random() * len(parent1)) 114 | geneB = int(np.random.random() * len(parent1)) 115 | 116 | startGene = min(geneA, geneB) 117 | endGene = max(geneA, geneB) 118 | 119 | for i in range(startGene, endGene): 120 | childP1.append(parent1[i]) 121 | 122 | childP2 = [item for item in parent2 if item not in childP1] 123 | child = childP1 + childP2 124 | return child 125 | 126 | 127 | def _breedPopulation(matingpool: list, eliteSize: int) -> list: 128 | """Uses the matingpool and combines different parents to create new solutions. 129 | 130 | Args: 131 | matingpool: The selected pops to mate. 132 | eliteSize: The size of the selection we want to give to the next generation. 133 | 134 | Returns: 135 | A new population generated from combining fit parents. 136 | """ 137 | children = [] 138 | length = len(matingpool) - eliteSize 139 | pool = np.copy(matingpool) 140 | np.random.shuffle(pool) 141 | 142 | for i in range(0, eliteSize): 143 | children.append(matingpool[i]) 144 | 145 | for i in range(0, length): 146 | child = _breed(pool[i], pool[len(matingpool) - i - 1]) 147 | children.append(child) 148 | 149 | return children 150 | 151 | 152 | def _mutate(individual: list, mutationRate: float) -> list: 153 | """Mutate an individual. We use this so we do not deadlock our algorithm. 154 | 155 | Args: 156 | individual: Single child to mutate. 157 | mutationRate: The chance to swap a city. 158 | 159 | Returns: 160 | The original individual with random cities switched on its route. 161 | """ 162 | for swapped in range(len(individual)): 163 | if np.random.random() < mutationRate: 164 | swapWith = int(np.random.random() * len(individual)) 165 | 166 | city1 = individual[swapped] 167 | city2 = individual[swapWith] 168 | 169 | individual[swapped] = city2 170 | individual[swapWith] = city1 171 | 172 | return individual 173 | 174 | 175 | def _mutatePopulation(population: list, mutationRate: float) -> list: 176 | """Mutate the whole population. 177 | 178 | Args: 179 | population: The population. 180 | mutationRate: The mutation rate of the population. 181 | 182 | Returns: 183 | The original population but with mutated routes. 184 | """ 185 | mutatedPop = [] 186 | for ind in range(0, len(population)): 187 | mutatedInd = _mutate(population[ind], mutationRate) 188 | mutatedPop.append(mutatedInd) 189 | return mutatedPop 190 | 191 | 192 | def _nextGeneration(currentGen: list, eliteSize: int, mutationRate: float, cities: np.ndarray) -> list: 193 | """Take steps to the next generation. Rank, select, mate, breed and mutate. 194 | 195 | Args: 196 | currentGen: The population of the current generation. 197 | eliteSize: Size of the best routes we want to select. 198 | mutationRate: The chance for each route to swap a city. 199 | cities: The problem definition. 200 | 201 | Returns: 202 | The population for the next generation. 203 | """ 204 | popRanked = _rankRoutes(currentGen, cities) 205 | selectionResults = _selection(popRanked, eliteSize) 206 | matingpool = _matingPool(currentGen, selectionResults) 207 | children = _breedPopulation(matingpool, eliteSize) 208 | nextGeneration = _mutatePopulation(children, mutationRate) 209 | return nextGeneration 210 | 211 | 212 | def geneticAlgorithm( 213 | cities: np.ndarray, popSize: int, eliteSize: int, mutationRate: float, generations: int 214 | ) -> (list, list, list): 215 | """This algorithm reflects the process of natural selection where the fittest individuals are selected for reproduction in order to produce offspring of the next generation. In this app each individual is a different route. 216 | 217 | Args: 218 | cities: The problem definition. A list of cities with x and y coordinates. 219 | popSize: Size of the population. 220 | eliteSize: Size of the best routes we want to select. 221 | mutationRate: The chance for each route to swap a city. 222 | generations: The number of generations we want to try getting a better solution. 223 | 224 | Returns: 225 | routes: A list of routes. 226 | iterations: The different generations. 227 | distances: The calculated distances for the best route of each generation. 228 | """ 229 | route = np.arange(cities.shape[0]) # Make an array of row numbers corresponding to cities. 230 | best_distance = path_distance(cities, route) # Calculate the distance of the initial path. 231 | 232 | iterations = [0] 233 | distances = [best_distance] 234 | routes = [route] 235 | 236 | pop = _initialPopulation(popSize, cities) 237 | for i in range(1, generations + 1): 238 | pop = _nextGeneration(pop, eliteSize, mutationRate, cities) 239 | best_route = pop[_rankRoutes(pop, cities)[0][0]] 240 | distance = path_distance(cities, best_route) 241 | iterations.append(i) 242 | distances.append(distance) 243 | routes.append(best_route) 244 | 245 | return routes, iterations, distances 246 | --------------------------------------------------------------------------------