diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..ce2f455 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +relative_files = True \ No newline at end of file diff --git a/.github/workflows/delivery.yml b/.github/workflows/delivery.yml new file mode 100644 index 0000000..290662e --- /dev/null +++ b/.github/workflows/delivery.yml @@ -0,0 +1,20 @@ +name: Continuous Delivery + +on: + pull_request: + branches: + - master + +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install pre-requisites + run: pip install poetry + - name: Install python requirements + run: poetry install + - name: Build package + run: poetry build + - name: Publish builded package + run: poetry publish -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }} \ No newline at end of file diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..861a745 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,29 @@ +name: Continuous Integration + +on: [push, pull_request] + +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install pre-requisites + run: pip install poetry + - name: Install python requirements + run: poetry install + - name: Run unit test and coverage + run: sh scripts/test.sh + - name: Update coveralls data + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + flag-name: Unit Test + + coveralls_finish: + needs: package + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b75edae --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2021 Igor Souza + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 60498bf..44cf221 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,59 @@ -# PyMetaHeuristics +# Pymetaheuristics Combinatorial Optimization problems with quickly good soving. +[![Continuous Integration](https://github.com/igormcsouza/pymetaheuristics/actions/workflows/integration.yml/badge.svg)](https://github.com/igormcsouza/pymetaheuristics/actions/workflows/integration.yml) +[![Coverage Status](https://coveralls.io/repos/github/igormcsouza/pymetaheuristics/badge.svg?branch=master)](https://coveralls.io/github/igormcsouza/pymetaheuristics?branch=master) + + +## Introduction + +Pymetaheuristics is a package to help build and train Metaheuristics to solve +real world problems mathematically modeled. It strives to generalize the +overall idea of the technic and delivers to the user a friendly wrapper so the +cientist may focus on the problem modeling rather than the heuristic +implementation. This package is an open source project so feel free to send +your implementations and fixes so they may be helpful for others too. + + ## Subpackages -What Metaheuristics can be found on this project. +The idea is to implement all possible Metaheuristics found on the market today +and some helper functions to improve what is already there. +**Note: This package is under construction, new features will come up soon.** + +What Metaheuristics can be found on this project? + +1. Genetic Algorithm + +## How to use + +First install the package (available on pypi) +```bash +$ pip install pymetaheuristics +``` + +Import the algorithm model you want to use to solve you problem. Implement the +needed functions and pass to the model. Train and get the results. +```python +from pymetaheuristics.genetic_algorithm.model import GeneticAlgorithm + +model = GeneticAlgorithm( + fitness_function=fitness_function, + genome_generator=genome_generator +) + +result = model.train( + epochs=15, pop_size=10, crossover=pmx_single_point, verbose=True) +``` + +Every module has its integration test, which I submit the model for testing +with very know NP-Hard problems today (Knapsack, tsp, ...). If you want to see +how it goes, check out the integrations under the model testing folder. + +## How to contribute -1. Genetic Algorithm \ No newline at end of file +Your code and help is very appreciate! Please, send your issue and pr's +whenever is good for you! If needed, send an +[email](mailto:igormcsouza@gmail.com) to me I'll be very glad to help. Let's +build up together. \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 300994b..4f19a43 100644 --- a/poetry.lock +++ b/poetry.lock @@ -30,6 +30,22 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.4.4" +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.5" + +[package.dependencies] +[package.dependencies.toml] +optional = true +version = "*" + +[package.extras] +toml = ["toml"] + [[package]] category = "dev" description = "the modular source code checker: pep8 pyflakes and co" @@ -135,6 +151,30 @@ wcwidth = "*" checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +category = "dev" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.12.1" + +[package.dependencies] +coverage = ">=5.2.1" +pytest = ">=4.6" +toml = "*" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "0.10.2" + [[package]] category = "dev" description = "Measures the displayed width of unicode strings in a terminal" @@ -144,7 +184,7 @@ python-versions = "*" version = "0.2.5" [metadata] -content-hash = "4312c8d29ef05dae29f4c5412271fd35d8b3d15051fca5f32b182175253b5845" +content-hash = "25069a11d6a936625432ffc78f1890014a32f690f1769efaf6b62e57115756bc" lock-version = "1.0" python-versions = "^3.8" @@ -161,6 +201,60 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +coverage = [ + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, +] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, @@ -201,6 +295,14 @@ pytest = [ {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] +pytest-cov = [ + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, diff --git a/pymetaheuristics/genetic_algorithm/__init__.py b/pymetaheuristics/genetic_algorithm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pymetaheuristics/genetic_algorithm/exceptions.py b/pymetaheuristics/genetic_algorithm/exceptions.py new file mode 100644 index 0000000..1fd6f9f --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/exceptions.py @@ -0,0 +1,2 @@ +class CrossOverException(BaseException): + pass diff --git a/pymetaheuristics/genetic_algorithm/model.py b/pymetaheuristics/genetic_algorithm/model.py new file mode 100644 index 0000000..0ee2e9d --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/model.py @@ -0,0 +1,163 @@ +from typing import List, Tuple +from time import time + +from pymetaheuristics.genetic_algorithm.types import ( + ConstraintFunction, CrossOverFunction, FitnessFunction, Genome, + GenomeGeneratorFunction, MutationFunction, SelectionFunction) +from pymetaheuristics.genetic_algorithm.steps.selections import ( + random_weighted_selection) +from pymetaheuristics.genetic_algorithm.steps.crossovers import ( + single_point_crossover) +from pymetaheuristics.genetic_algorithm.steps.multations import inter_mutation + + +class GeneticAlgorithm(): + """## A Genetic Representation of a Real World Problem + + Genetic Algorithm tries to mimic what nature does for natural selection to + solve real world problems. A problem may be represent as an Genetic Problem + if can be model with: + + A Genome (Genetic Representation of a Solution) + A Fitness Function (Score to Rank the Genome) + A Selection Method (Ways to choose between Genomes) + A Crossover Method (Ways to shuffle Genomes) + A Multation Method (Ways to change small pieces of a Genome) + + On the Step Module you can find some functions to help on those matters, + and may fit perfectly on you problem, or maybe one might need to implement + their own algorithm to each of this steps. + + ** Note: If you think your problem has not a function to help on those + steps, feel free to open a issue so your code, or someelse's code may + become part of the package too. + + Remember the GA will minimize the fitness function, so if you model is for + maximize, return the result * -1 (See Knapsack model on test folder). You + may also look for a specific result, so what you are looking is to minimize + the difference on the fitness function. + """ + + def __init__( + self, fitness_function: FitnessFunction, + genome_generator: GenomeGeneratorFunction, + constraints: List[ConstraintFunction] = [lambda x: True] + ): + self.fitness_function = fitness_function + self.genome_generator = genome_generator + self.constraints = constraints + self.history = {} + + def _pop_generator(self, pop_size: int) -> List[Genome]: + """Generate a population of genomes.""" + # Initialize the population as an empty list + population: List[Genome] = list() + # Generate pop_size Genomes and append it to the population + for _ in range(pop_size): + accepted = False + genome = None + + # Check if given genome is accepted on the contraints. + while not accepted: + genome = self.genome_generator() + accepted = self._check_constraints(genome) + + if genome: + population.append(genome) + + return population + + def add_constraint(self, constraint: ConstraintFunction): + """Genetic Contraint for a Gene.""" + self.constraints.append(constraint) + + def _check_constraints(self, genome: Genome): + for constraint_it in self.constraints: + if not constraint_it(genome): + return False + + return True + + def train( + self, + epochs: int, + pop_size: int, + selection: SelectionFunction = random_weighted_selection, + crossover: CrossOverFunction = single_point_crossover, + mutation: MutationFunction = inter_mutation, + verbose: bool = False, + **kwargs + ) -> Tuple[Genome, float]: + """Loop over evolutionary steps until get to a limit. + + Below is the Hyperparameters one can change to get better results. + Just a quick note, all the problems have their specific parameters, + it maybe mean the default will not work for one's case. + + Parameters: + :epochs: Number of evolutions algorithm will loop over + :pop_size: Number of Genomes (Genetic Representation of a solution) + :selection: Selection funtion to be used (See Steps Module) + :crossover: Crossover funtion to be used (See Steps Module) + :multation: Multation funtion to be used (See Steps Module) + + Optional Parameters: + Depending on the function one choses, it might come with optional + parameters that can be set as parameters on this function. The code + will automatically deal with it. + """ + # initialize history stats + start = time() + self.history[start] = { + 'runs': list(), + 'args': { + "epochs": epochs, "pop_size": pop_size, + "selection": selection.__name__, + "crossover": crossover.__name__, + "mutation": mutation.__name__, + "verbose": verbose, "kwargs": kwargs + } + } + # initialize the population for this round + population = self._pop_generator(pop_size) + best_result = (population[0]), self.fitness_function(population[0]) + + for i in range(epochs): + # keep the k most fitted and repopulate with new ones + parents = selection(population, self.fitness_function, **kwargs) + # Cross Over the parents to get a better solution + children = crossover(*parents[:2], **kwargs) + # Populate the next generation + population = [*parents, *children] + population.extend( + self._pop_generator(pop_size=pop_size-len(population))) + # Mutate the population + population = [mutation(genome, **kwargs) for genome in population] + # Check if every genome is still accepted by contraints + for idx, genome in enumerate(population): + accepted = False + while not accepted: + accepted = self._check_constraints(genome) + if not accepted: + genome = self.genome_generator() + population[idx] = genome + # sort the population according to their fitness + population.sort(key=lambda x: self.fitness_function(x)) + # print the partial results if verbose + if verbose: + print("Epoch %i got fitness %.2f" % ( + i, self.fitness_function(population[0])), population[0]) + # save the partial run history + self.history[start]['runs'].append(( + population[0], self.fitness_function(population[0]))) + + if self.fitness_function(population[0]) < best_result[1]: + best_result = ( + population[0], self.fitness_function(population[0])) + + # save final results before quit + self.history[start]['best'] = best_result + self.history[start]['elapsed'] = time() - start + + # when done the epochs, return the most fit and its fitness score + return best_result diff --git a/pymetaheuristics/genetic_algorithm/steps/__init__.py b/pymetaheuristics/genetic_algorithm/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pymetaheuristics/genetic_algorithm/steps/crossovers.py b/pymetaheuristics/genetic_algorithm/steps/crossovers.py new file mode 100644 index 0000000..5f74ea5 --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/steps/crossovers.py @@ -0,0 +1,65 @@ +from random import randint +from typing import Tuple + +from pymetaheuristics.genetic_algorithm.types import Genome +from pymetaheuristics.genetic_algorithm.exceptions import CrossOverException + + +def single_point_crossover( + g1: Genome, g2: Genome, **kwargs +) -> Tuple[Genome, Genome]: + """Cut 2 Genomes on index p (randomly choosen) and swap its parts.""" + if len(g1) == len(g2): + length = len(g1) + else: + raise CrossOverException( + "Genomes has to have the same length, got %d, %d" % ( + len(g1), len(g2))) + + if length < 2: + return g1, g2 + + p = randint(1, length - 1) + + return g1[0:p] + g2[p:length], g2[0:p] + g1[p:length] + + +def pmx_single_point( + g1: Genome, g2: Genome, **kwargs +) -> Tuple[Genome, Genome]: + """ + PMX is a crossover function which consider a Genome as a sequence of + nom-repetitive genes through the Genome. So before swapping, checks if + repetition is going to occur, and swap the pretitive gene with its partner + on the other Genome and them swap with other gene on the same Genome. + + See more at + https://user.ceng.metu.edu.tr/~ucoluk/research/publications/tspnew.pdf . + + This implementation suites very well the TSP problem. + """ + if len(g1) == len(g2): + length = len(g1) + else: + raise CrossOverException( + "Genomes has to have the same length, got %d, %d" % ( + len(g1), len(g2))) + + if length < 2: + return g1, g2 + + p = randint(1, length - 1) + + g1child = g1[:] + for i in range(p): + ans = g1child.index(g2[i]) + g1child[ans] = g1child[i] + g1child[i] = g2[i] + + g2child = g2[:] + for i in range(p): + ans = g2child.index(g1[i]) + g2child[ans] = g2child[i] + g2child[i] = g1[i] + + return g1child, g2child diff --git a/pymetaheuristics/genetic_algorithm/steps/multations.py b/pymetaheuristics/genetic_algorithm/steps/multations.py new file mode 100644 index 0000000..c16ddb0 --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/steps/multations.py @@ -0,0 +1,21 @@ +from random import randrange, random + +from pymetaheuristics.genetic_algorithm.types import Genome + + +def inter_mutation( + genome: Genome, + q: int = 2, + probability: float = 0.75, + **kwargs +) -> Genome: + """At a random chance, change interposition of q genes on the Genome.""" + for _ in range(q): + index = randrange(len(genome)) + + if random() > probability: + return genome + + genome[index], genome[index - 1] = genome[index - 1], genome[index] + + return genome diff --git a/pymetaheuristics/genetic_algorithm/steps/selections.py b/pymetaheuristics/genetic_algorithm/steps/selections.py new file mode 100644 index 0000000..b6a1baa --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/steps/selections.py @@ -0,0 +1,21 @@ +from random import choices + +from pymetaheuristics.genetic_algorithm.types import ( + FitnessFunction, Population) + + +def random_weighted_selection( + population: Population, + fitness_function: FitnessFunction, + k: int = 2, + **kwargs +) -> Population: + """Selects randomly k genomes on a population. This approach considers the + fitness of each Genome as weights so the most fitted is very likely to be + choosen, but, still gives room for a little of jumps. + """ + return choices( + population=population, + weights=[-fitness_function(genome) for genome in population], + k=k + ) diff --git a/pymetaheuristics/genetic_algorithm/types.py b/pymetaheuristics/genetic_algorithm/types.py new file mode 100644 index 0000000..5f9f8a8 --- /dev/null +++ b/pymetaheuristics/genetic_algorithm/types.py @@ -0,0 +1,15 @@ +from typing import Any, Callable, List, Tuple + + +# Variable Types +Genome = List[Any] +Population = List[Genome] + +# Function Types +ConstraintFunction = Callable[[Genome], bool] +GenomeGeneratorFunction = Callable[[], Genome] +FitnessFunction = Callable[[Genome], float] +SelectionFunction = Callable[ + [Population, FitnessFunction, Any], Population] +CrossOverFunction = Callable[[Genome, Genome], Tuple[Genome, Genome]] +MutationFunction = Callable[[Genome, Any], Genome] diff --git a/pymetaheuristics/utils/distances.py b/pymetaheuristics/utils/distances.py new file mode 100644 index 0000000..84cbbcc --- /dev/null +++ b/pymetaheuristics/utils/distances.py @@ -0,0 +1,13 @@ +from math import sqrt +from typing import List + + +def euclidian_distance(a: List[float], b: List[float]): + """Calculate a Euclidian distance between 2 tensors.""" + assert len(a) == len(b), "Length of tensor A has to be equal to tensor B." + + distances = list() + for a_axis, b_axis in zip(a, b): + distances.append((a_axis - b_axis) ** 2) + + return sqrt(sum(distances)) diff --git a/pyproject.toml b/pyproject.toml index cd60431..944b836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,14 @@ name = "pymetaheuristics" version = "0.1.0" description = "" authors = ["Igor Souza "] +keywords = ["metaheuristics", "python", "solver"] +readme = "README.md" +license = "MIT" +homepage = "https://github.com/igormcsouza/pymetaheuristics" +repository = "https://github.com/igormcsouza/pymetaheuristics" +include = [ + "LICENSE", +] [tool.poetry.dependencies] python = "^3.8" @@ -10,6 +18,8 @@ python = "^3.8" [tool.poetry.dev-dependencies] pytest = "^5.2" flake8 = "^3.9" +coverage = {extras = ["toml"], version = "^5.5"} +pytest-cov = "^2.12.1" [build-system] requires = ["poetry>=0.12"] diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 0000000..04fc40b --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1 @@ +poetry run pytest --cov=pymetaheuristics \ No newline at end of file diff --git a/tests/genetic_algorithm/integration/knapsack.py b/tests/genetic_algorithm/integration/knapsack.py new file mode 100644 index 0000000..266368e --- /dev/null +++ b/tests/genetic_algorithm/integration/knapsack.py @@ -0,0 +1,51 @@ +from random import randint + +from pymetaheuristics.genetic_algorithm.model import GeneticAlgorithm + +items = [ + [25, 1.2], + [40, 7.6], + [10, 2.5], + [17, 1.5], + [42, 1.1], + [29, 3.1], + [14, 0.5], + [36, 3.5], +] + + +def genome_generator(): + genome = list() + for _ in range(len(items)): + genome.append(randint(0, 1)) + return genome + + +def fitness_function(genome): + score = 0 + for i, digit in enumerate(genome): + score += digit * items[i][1] + return score * -1 + + +def maximun_capacity(genome): + weight = 0 + for i, digit in enumerate(genome): + weight += digit * items[i][0] + return weight <= 100 + + +model = GeneticAlgorithm( + genome_generator=genome_generator, + fitness_function=fitness_function +) + +model.add_constraint(maximun_capacity) + +result = model.train(30, 10, k=5, verbose=True) + +print("Genetic Algorithm result", result, sep="\n") +print("Ground Truth", ([0, 1, 1, 1, 0, 1, 0, 0], -14.7), sep="\n") + +ans = (14.7 - abs(round(result[1], 2))) / 14.7 +print(round(ans*100, 2), "%... off the optimal") diff --git a/tests/genetic_algorithm/integration/tsp.py b/tests/genetic_algorithm/integration/tsp.py new file mode 100644 index 0000000..25f183e --- /dev/null +++ b/tests/genetic_algorithm/integration/tsp.py @@ -0,0 +1,51 @@ +from random import shuffle + +from pymetaheuristics.utils.distances import euclidian_distance +from pymetaheuristics.genetic_algorithm.steps.crossovers import ( + pmx_single_point) +from pymetaheuristics.genetic_algorithm.model import GeneticAlgorithm +from pymetaheuristics.genetic_algorithm.types import Genome + + +cities_list = [ + [42.5, 48.9], + [97.2, 32.1], + [23.5, 85.9], + [32.8, 45.2], + [12.5, 69.9] +] + +distance_matrix = list() +for i, city1 in enumerate(cities_list): + distance_matrix.append([]) + for city2 in cities_list: + distance_matrix[i].append(euclidian_distance(city1, city2)) + + +def genome_generator() -> Genome: + sequence = list(range(len(cities_list))) + shuffle(sequence) + return sequence + + +def fitness_function(genome: Genome) -> float: + fitness = 0 + for i in range(len(genome)): + fitness += distance_matrix[genome[i]][genome[i-1]] + + return fitness + + +model = GeneticAlgorithm( + fitness_function=fitness_function, + genome_generator=genome_generator +) + +result = model.train( + epochs=5, pop_size=10, crossover=pmx_single_point, verbose=True) + +print("Genetic Algorithm result", result, sep="\n") +print("Ground Truth", ([2, 4, 3, 0, 1], 210.24), sep="\n") + +ans = (round(result[1], 2) - 210.24) / 210.24 +print(round(ans*100, 2), "%... off the optimal") diff --git a/tests/genetic_algorithm/steps/test_crossovers.py b/tests/genetic_algorithm/steps/test_crossovers.py new file mode 100644 index 0000000..c25bf9d --- /dev/null +++ b/tests/genetic_algorithm/steps/test_crossovers.py @@ -0,0 +1,74 @@ +from pymetaheuristics.genetic_algorithm.exceptions import CrossOverException +from pymetaheuristics.genetic_algorithm.steps.crossovers import ( + single_point_crossover, pmx_single_point +) + + +def test_genetic_algorithm_steps_crossovers_single_point_crossover(): + + parent_g1 = [0, 0, 1, 0, 1, 0, 0] + parent_g2 = [1, 1, 0, 0, 1, 1, 1] + + g1, g2 = single_point_crossover(parent_g1, parent_g2) + + for i, (gene1, gene2) in enumerate(zip(g1, g2)): + assert gene1 == parent_g1[i] or gene1 == parent_g2[i] + assert gene2 == parent_g1[i] or gene2 == parent_g2[i] + + +def test_genetic_algorithm_steps_crossovers_single_point_crossover_failure(): + + parent_g1 = [0, 0, 1, 0, 1, 0, 0] + parent_g2 = [1, 1, 0, 0, 1] + + try: + _ = single_point_crossover(parent_g1, parent_g2) + except CrossOverException: + assert True + else: + assert False + + +def test_genetic_algorithm_steps_crossovers_single_point_crossover_len_2(): + + parent_g1 = [0] + parent_g2 = [1] + + g1, g2 = single_point_crossover(parent_g1, parent_g2) + + assert g1 == parent_g1 + assert g2 == parent_g2 + + +def test_genetic_algorithm_steps_crossovers_pmx_single_point(): + + parent_g1 = [1, 2, 3, 4, 5] + parent_g2 = [5, 4, 3, 2, 1] + + g1, g2 = pmx_single_point(parent_g1, parent_g2) + + assert sum(g1) == sum(g2) == 15 + + +def test_genetic_algorithm_steps_crossovers_pmx_single_point_failure(): + + parent_g1 = [1, 2, 3, 4, 5] + parent_g2 = [5, 4, 3] + + try: + _ = pmx_single_point(parent_g1, parent_g2) + except CrossOverException: + assert True + else: + assert False + + +def test_genetic_algorithm_steps_crossovers_pmx_single_point_len_2(): + + parent_g1 = [1] + parent_g2 = [5] + + g1, g2 = pmx_single_point(parent_g1, parent_g2) + + assert g1 == parent_g1 + assert g2 == parent_g2 diff --git a/tests/genetic_algorithm/steps/test_multations.py b/tests/genetic_algorithm/steps/test_multations.py new file mode 100644 index 0000000..c077314 --- /dev/null +++ b/tests/genetic_algorithm/steps/test_multations.py @@ -0,0 +1,11 @@ +from pymetaheuristics.genetic_algorithm.steps.multations import inter_mutation + + +def test_genetic_algorithm_steps_multations_inter_mutation(): + + g = [0, 1, 1, 0, 1, 0, 0] + + g_changed = inter_mutation(g, q=3, probability=0.4) + + assert len(g_changed) == len(g) + assert sum(g_changed) == sum(g) diff --git a/tests/genetic_algorithm/steps/test_selections.py b/tests/genetic_algorithm/steps/test_selections.py new file mode 100644 index 0000000..349cf79 --- /dev/null +++ b/tests/genetic_algorithm/steps/test_selections.py @@ -0,0 +1,23 @@ +from pymetaheuristics.genetic_algorithm.steps.selections import ( + random_weighted_selection) + + +def test_genetic_algorithm_steps_selections_random_weighted_selection(): + + population = [ + [0, 1, 1, 0, 1, 0, 0], + [1, 1, 1, 0, 1, 0, 1], + [0, 1, 0, 0, 1, 0, 1], + [1, 0, 1, 1, 0, 1, 0], + [0, 0, 0, 1, 0, 1, 1], + ] + + best = random_weighted_selection( + population=population, + fitness_function=lambda x: sum([i*x for i, x in enumerate(x)]), + k=2 + ) + + assert len(best) == 2 + for i in range(2): + assert best[i] in population diff --git a/tests/genetic_algorithm/test_model.py b/tests/genetic_algorithm/test_model.py new file mode 100644 index 0000000..0173ec3 --- /dev/null +++ b/tests/genetic_algorithm/test_model.py @@ -0,0 +1,50 @@ +from random import randint + +from pymetaheuristics.genetic_algorithm.model import GeneticAlgorithm + + +def test_genetic_algorithm_model_instantiate(): + ga_model = GeneticAlgorithm( + fitness_function=lambda x: sum(x), + genome_generator=lambda: [randint(0, 100) for _ in range(5)], + constraints=[ + lambda x: 10 in x + ] + ) + + assert ga_model is not None + + +def test_genetic_algorithm_model_train(): + ga_model = GeneticAlgorithm( + fitness_function=lambda x: sum(x), + genome_generator=lambda: [randint(0, 10) for _ in range(5)], + constraints=[ + lambda x: 5 not in x + ] + ) + + result, score = ga_model.train(1, 10) + + assert len(result) == 5 + assert sum(result) == score + assert 5 not in result + + +def test_genetic_algorithm_model_add_constraint(): + ga_model = GeneticAlgorithm( + fitness_function=lambda x: sum(x), + genome_generator=lambda: [randint(0, 10) for _ in range(5)], + constraints=[ + lambda x: 5 not in x + ] + ) + + ga_model.add_constraint(constraint=lambda x: 0 not in x) + + result, score = ga_model.train(1, 10, verbose=True) + + assert len(result) == 5 + assert sum(result) == score + assert 5 not in result + assert 0 not in result diff --git a/tests/test_pymetaheuristics.py b/tests/test_pymetaheuristics.py new file mode 100644 index 0000000..0229db2 --- /dev/null +++ b/tests/test_pymetaheuristics.py @@ -0,0 +1,5 @@ +from pymetaheuristics import __version__ + + +def test_version(): + assert __version__ == '0.1.0' diff --git a/tests/utils/test_distances.py b/tests/utils/test_distances.py new file mode 100644 index 0000000..bdf98da --- /dev/null +++ b/tests/utils/test_distances.py @@ -0,0 +1,10 @@ +from pymetaheuristics.utils.distances import euclidian_distance + + +def test_distances_euclidean(): + a = [1., 3.] + b = [4., 7.] + + length = euclidian_distance(a, b) + + assert length == 5.