├── .github └── workflows │ └── ci-cd.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── ci ├── get_new_release.py └── set_version.py ├── example.py ├── ga ├── __init__.py ├── __version__.py ├── evolution.py ├── individual.py └── population.py ├── public ├── fitness_function.png ├── fitness_history.png ├── genetic-algo.png └── individual_values.png ├── pyproject.toml ├── requirements.dev.txt ├── requirements.txt └── tests ├── __init__.py ├── test_evolution.py └── test_individual.py /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish Python Package to PyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | tests: 7 | name: Test code 8 | runs-on: ubuntu-latest 9 | if: startsWith(github.ref, 'refs/tags/') != true && endsWith(github.ref, 'master') != true && endsWith(github.ref, 'chore') != true 10 | 11 | env: 12 | VENV_NAME: .venv 13 | PYTHON_VERSION: "3.10.12" 14 | TEST_DIR: tests 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ env.PYTHON_VERSION }} 22 | 23 | - name: Create and start a Python virtual environment 24 | run: | 25 | pip3 install virtualenv 26 | python3 -m virtualenv ${{ env.VENV_NAME }} --python="python${{ env.PYTHON_VERSION }}" 27 | source ${{ env.VENV_NAME }}/bin/activate 28 | 29 | - name: Install dependencies 30 | run: | 31 | python3 -m pip install --upgrade pip setuptools wheel 32 | python3 -m pip install ruff pytest pytest-cov pytest-integration black isort 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | if [ -f requirements.dev.txt ]; then pip install -r requirements.dev.txt; fi 35 | 36 | - name: Lint with ruff 37 | run: | 38 | # This assumes that a ruff.toml or pyproject.toml is included. If not a default configuration is taken 39 | ruff check . 40 | 41 | #- name: Mypy 42 | # run: | 43 | # python3 -m pip install mypy 44 | # mypy . --exclude ${{ env.VENV_NAME }} 45 | 46 | - name: Black check (PEP style check) 47 | run: black --check . --exclude ${{ env.VENV_NAME }} 48 | 49 | - name: Sorted imports check 50 | run: isort --check --profile black . 51 | 52 | - name: Run unit & integration tests and calculate coverage 53 | run: | 54 | python3 -m pytest ${{ env.TEST_DIR }} --cov --cov-report xml:coverage.xml --cov-config=.coveragerc 55 | 56 | - name: Code Coverage Report 57 | uses: irongut/CodeCoverageSummary@v1.3.0 58 | with: 59 | filename: coverage.xml 60 | badge: true 61 | fail_below_min: 75 62 | format: markdown 63 | hide_branch_rate: false 64 | hide_complexity: true 65 | indicators: true 66 | output: both 67 | thresholds: "70 80" 68 | 69 | - name: Show coverage report summary 70 | run: | 71 | cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY 72 | 73 | - name: Add Coverage PR Comment 74 | uses: marocchino/sticky-pull-request-comment@v2 75 | if: github.event_name == 'pull_request' 76 | with: 77 | recreate: true 78 | path: code-coverage-results.md 79 | 80 | - name: Unit tests 81 | run: >- 82 | python -m pytest . 83 | 84 | build: 85 | name: Build distribution 📦 86 | needs: 87 | - tests 88 | if: | 89 | always() && 90 | (needs.tests.result == 'success' || needs.tests.result == 'skipped') && 91 | startsWith(github.ref, 'refs/tags/') 92 | runs-on: ubuntu-latest 93 | env: 94 | CI_SCRIPTS_DIR: ci 95 | PACKAGE_VERSION_DIR: ga 96 | 97 | steps: 98 | - uses: actions/checkout@v4 99 | - name: Set up Python 100 | uses: actions/setup-python@v4 101 | with: 102 | python-version: "3.10.12" 103 | 104 | - name: Update __version__.py file 105 | run: | 106 | pip install typer 107 | echo '${{ github.ref_name }}' 108 | python ${{ env.CI_SCRIPTS_DIR }}/set_version.py '${{ env.PACKAGE_VERSION_DIR }}' '${{ github.ref_name }}' 109 | 110 | - name: Install pypa/build 111 | run: >- 112 | python3 -m pip install build --user 113 | 114 | - name: Build a binary wheel and a source tarball 115 | run: python3 -m build 116 | 117 | - name: Store the distribution packages 118 | uses: actions/upload-artifact@v3 119 | with: 120 | name: python-package-distributions 121 | path: dist/ 122 | 123 | publish-to-pypi: 124 | name: Publish Python distribution 📦 to PyPI 125 | needs: 126 | - build 127 | if: | 128 | always() && 129 | needs.build.result == 'success' && 130 | startsWith(github.ref, 'refs/tags/') 131 | runs-on: ubuntu-latest 132 | environment: 133 | name: pypi 134 | url: https://pypi.org/p/generic-algorithm-light 135 | permissions: 136 | id-token: write # IMPORTANT: mandatory for trusted publishing 137 | 138 | steps: 139 | - name: Download all the dists 140 | uses: actions/download-artifact@v3 141 | with: 142 | name: python-package-distributions 143 | path: dist/ 144 | - name: Publish distribution 📦 to PyPI 145 | uses: pypa/gh-action-pypi-publish@release/v1 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ipynb_checkpoints 2 | *.vscode 3 | __pycache__ 4 | *.ipynb 5 | ga_modules.py 6 | *.lock 7 | .ruff_cache 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | cover/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | .pybuilder/ 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | # For a library or package, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | # .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # poetry 106 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 107 | # This is especially recommended for binary packages to ensure reproducibility, and is more 108 | # commonly ignored for libraries. 109 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 110 | #poetry.lock 111 | 112 | # pdm 113 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 114 | #pdm.lock 115 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 116 | # in version control. 117 | # https://pdm.fming.dev/#use-with-ide 118 | .pdm.toml 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at fernando.zepeda@pm.me. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Fernando Zepeda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tests 2 | 3 | install_requirements: 4 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 5 | if [ -f requirements.dev.txt ]; then pip install -r requirements.dev.txt; fi 6 | 7 | ruff: 8 | ruff check . 9 | 10 | sort: 11 | isort --check --profile black . 12 | 13 | tests: 14 | pytest . 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Genetic Algorithm Class 2 | 3 | > Now as [package](https://pypi.org/project/generic-algorithm-light/): [last version](https://pypi.org/project/generic-algorithm-light/) 4 | 5 | ![GitHub release (with filter)](https://img.shields.io/github/v/release/Fmrhj/genetic-algorithm) ![PyPI - Downloads](https://img.shields.io/pypi/dm/generic-algorithm-light) ![GitHub Repo stars](https://img.shields.io/github/stars/Fmrhj/genetic-algorithm?style=social) 6 | 7 | Python implementation of a genetic algorithm to solve optimization problems with `n` control variables. 8 | 9 | ## Description 10 | 11 | A [genetic algorithm](https://en.wikipedia.org/wiki/Genetic_algorithm) (GA) is a search heuristic part of a broader family of algorithms called [evolutionary algorithms](https://en.wikipedia.org/wiki/Evolutionary_algorithm) (EAs). EAs are population-based metaheuristics that integrate mechanisms inspired by biological evolution such as reproduction, mutation, selection. The GA algorithm is used particularly in optimization problems where calculating gradients of an objective function is problematic or not possible. 12 | 13 | ### Steps in GA 14 | 15 | 1. **Initialization**: initialize a population of _individuals_ or candidate solutions to the problem. This initialization can be done by means of random sampling. Each individual is defined by an encoding which we call _genes_. 16 | 2. **Selection**: calculate the best candidates based on a defined _fitness_ function we want to optimize. We select the best `j` _parents_ which will be combined. The parameter `j` is arbitrary. 17 | 3. **Crossover**: we combine the genes of the parents to produce an _offspring_. These are `s` new individuals in our population. 18 | 4. **Mutation**: add _randomness_ to the generated offspring. We can add e.g. a Gausian noise to one of the genes of the offspring for each individual. 19 | 5. **Replacement**: select the `l` fittest individuals of the population to evaluate on the next epoch. 20 | 21 | We repeat these evolution steps for certain amount of epochs or until an exit condition is met. 22 | 23 | ![](public/genetic-algo.png) 24 | 25 | ## GA implementation 26 | 27 | ### Dependencies 28 | 29 | - [Numpy](https://numpy.org/) >= 1.18 30 | 31 | ### Hyperparameters 32 | 33 | - Individuals: 34 | 35 | - `lower_bound` 36 | - `upper_bound` 37 | - `number_of_genes`: dimension of the search space. In this implementation it indicates the shape of the array that represents each individual. 38 | 39 | | Note: The number of genes of each individual and the fitness function must be congruent | 40 | | --------------------------------------------------------------------------------------- | 41 | 42 | - Population: 43 | 44 | - `n_parents`: `j` parents. 45 | - `offspring_size`: the `s` new individuals from combining `j`parents. 46 | - `mutation_mean`, `mutation_sd`: mean and standard deviation of the Gaussian noise added during the mutation step. 47 | - `size`: maximum size of the population or `l` fittest individuals to survive for the next epoch. 48 | 49 | - Evolution: 50 | - `epochs`: number of times we repet each evolution step. 51 | 52 | ### Example 53 | 54 | An example fitness function could be something like this: 55 | 56 | ```python 57 | def fitness(x, y): 58 | return x*(x-1)*np.cos(2*x-1)*np.sin(2*x-1)*(y-2) 59 | ``` 60 | 61 | ![](public/fitness_function.png) 62 | 63 | We can limit our search to evaluate individuals ![formula]() within the domain ![formula](https://render.githubusercontent.com/render/math?math=x\in[-2,2],y\in[-2,2]) with the `ind_parameters` dictionary. Likewise, we control the population parameters with the `pop_parameters`. 64 | 65 | ```python 66 | # example.py 67 | import numpy as np 68 | from ga.evolution import Evolution 69 | 70 | # Define a fitness function 71 | def fitness(x, y): 72 | return x * (x - 1) * np.cos(2 * x - 1) * np.sin(2 * x - 1) * (y - 2) 73 | 74 | # Define parameter for each individual 75 | ind_parameters = {'lower_bound': -2, 76 | 'upper_bound': 2, 77 | 'number_of_genes': 2} 78 | 79 | # Define parameter for the entire population 80 | pop_parameters = {'n_parents': 6, 81 | 'offspring_size':(2, ind_parameters['number_of_genes']), 82 | 'mutation_mean': 0.25, 83 | 'mutation_sd': 0.5, 84 | 'size': 10} 85 | def example(): 86 | # Instantiate an evolution 87 | evo = Evolution(pop_parameters, ind_parameters, fitness) 88 | # Repeat evolution step 200 epochs 89 | epochs = 10000 90 | # Record fitness history 91 | history = [] 92 | x_history = [] 93 | y_history = [] 94 | for _ in range(epochs): 95 | print('Epoch {}/{}, Progress: {}%\r'.format(_+1, epochs, np.round(((_+1)/epochs)*100, 2)), end="") 96 | evo.step() 97 | history.append(evo._best_score) 98 | x_history.append(evo._best_individual[0][0]) 99 | y_history.append(evo._best_individual[0][1]) 100 | 101 | print('\nResults:') 102 | print('Best individual:', evo.solution.best_individual) 103 | print('Fitness value of best individual:', evo.solution.best_score) 104 | 105 | example() 106 | ``` 107 | 108 | The results are really close to the global optimum within this domain and the best individual does not change after 50 epochs. 109 | 110 | ``` 111 | # Output 112 | Epoch 200/200, Progress 100.0% 113 | Results: 114 | Best individual: [-1.52637873, -2. ] 115 | Fitness value of best individual: 7.4697265870418414 116 | ``` 117 | 118 | ![](public/fitness_history.png) 119 | 120 | ![](public/individual_values.png) 121 | 122 | ## References 123 | 124 | - [An Extensible Evolutionary Algorithm Example in Python](https://towardsdatascience.com/an-extensible-evolutionary-algorithm-example-in-python-7372c56a557b) 125 | - [Genetic Algorithm Implementation in Python](https://towardsdatascience.com/genetic-algorithm-implementation-in-python-5ab67bb124a6) 126 | -------------------------------------------------------------------------------- /ci/get_new_release.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from enum import Enum 3 | 4 | import typer 5 | from git import Repo 6 | from packaging import version 7 | 8 | 9 | class BumpType(str, Enum): 10 | CHORE = "chore" 11 | PATCH = "patch" 12 | MINOR = "minor" 13 | MAJOR = "major" 14 | 15 | 16 | class ReleaseType(str, Enum): 17 | RELEASE_CANDIDATE = "rc" 18 | FINAL = "final" 19 | 20 | 21 | INITIAL_TAG = "v0.1.0" 22 | INITIAL_RC_TAG = "v0.1.0rc0" 23 | 24 | 25 | def get_git_root() -> str: 26 | raw = subprocess.Popen( 27 | ["git", "rev-parse", "--show-toplevel"], stdout=subprocess.PIPE 28 | ).communicate()[0] 29 | return raw.rstrip().decode("utf-8") 30 | 31 | 32 | def get_current_tag() -> str: 33 | """Gets current tag from list of repo tags 34 | 35 | Returns: 36 | str: current tag, e.g. "v0.1.0", "", "v0.1.0rc2" 37 | """ 38 | repo = Repo(get_git_root(), search_parent_directories=True) 39 | tags = [str(tag) for tag in repo.tags] 40 | if len(tags) == 0: 41 | return "" 42 | tags.sort(key=lambda x: version.Version(x)) 43 | return tags[-1] 44 | 45 | 46 | def is_current_tag_rc(current_tag: str) -> bool: 47 | return "rc" in current_tag 48 | 49 | 50 | def print_tag(tag: str, tag_type: str = "Current") -> str: 51 | print(f"{tag_type} tag:\n{tag}") 52 | 53 | 54 | def bump_version( 55 | current_version: str, bump_type: BumpType, release_type: ReleaseType 56 | ) -> str: 57 | major, minor, patch = [int(x) for x in current_version.split(".")] 58 | 59 | match bump_type: 60 | case BumpType.MAJOR: 61 | major += 1 62 | minor = 0 63 | case BumpType.MINOR: 64 | minor += 1 65 | patch = 0 66 | case BumpType.PATCH: 67 | patch += 1 68 | case _: 69 | raise ValueError(f"Check the release type {release_type}") 70 | 71 | return f"v{major}.{minor}.{patch}" 72 | 73 | 74 | def bump_tag(current_tag: str, bump_type: BumpType, release_type: ReleaseType) -> str: 75 | # Validate input 76 | if len(current_tag) <= 5 or "v" not in current_tag: 77 | raise ValueError(f"Error parsing current tag {current_tag}") 78 | 79 | current_version = current_tag.replace("v", "").split("rc")[0] 80 | if is_current_tag_rc(current_tag): 81 | rc_bit = int(current_tag.split("rc")[1]) 82 | else: 83 | rc_bit = "" 84 | 85 | # if current tag is release candidate, no version bump is needed 86 | if is_current_tag_rc(current_tag) and release_type == ReleaseType.RELEASE_CANDIDATE: 87 | rc_bit += 1 88 | return f"v{current_version}rc{rc_bit}" 89 | elif is_current_tag_rc(current_tag) and release_type == ReleaseType.FINAL: 90 | return f"v{current_version}" 91 | 92 | new_version = bump_version(current_version, bump_type, release_type) 93 | 94 | if release_type == ReleaseType.RELEASE_CANDIDATE: 95 | new_version = f"{new_version}rc0" 96 | 97 | return new_version 98 | 99 | 100 | """ 101 | def main( 102 | bump_type: BumpType = typer.Argument(metavar="bump_type"), 103 | release_type: ReleaseType = typer.Argument(metavar="release_type"), 104 | ) -> None: 105 | current_tag = get_current_tag() 106 | print_tag(current_tag) 107 | 108 | # Initialize tags 109 | if current_tag == "": 110 | if release_type == ReleaseType.FINAL: 111 | new_tag = INITIAL_TAG 112 | else: 113 | new_tag = INITIAL_RC_TAG 114 | print_tag(new_tag, "New") 115 | return 116 | 117 | # If Chore -> skip 118 | if bump_type == BumpType.CHORE: 119 | print_tag(current_tag) 120 | else: 121 | new_tag = bump_tag(str(current_tag), bump_type, release_type) 122 | print_tag(new_tag, "New") 123 | """ 124 | 125 | 126 | def main() -> None: 127 | get_current_tag() 128 | 129 | 130 | if __name__ == "__main__": 131 | typer.run(main) 132 | -------------------------------------------------------------------------------- /ci/set_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | import typer 5 | 6 | VERSION_FILE = "__version__.py" 7 | PATTERN_VERSION = r'VERSION\s*=\s*"([^"]+)"' 8 | 9 | PYPROJECT_TOML_FILE = "pyproject.toml" 10 | PATTERN_PYPROJECT_TOML = r'version\s*=\s*"([^"]+)"' 11 | 12 | 13 | def set_version( 14 | package_dir: str = typer.Argument(metavar="package_dir"), 15 | new_version: str = typer.Argument(metavar="new_version"), 16 | ) -> None: 17 | """Updates the version of a __version__.py file. This is used as the 18 | version of the pushed package to PyPI 19 | 20 | Args: 21 | package_dir (Annotated[str, typer.Argument], optional): where to find 22 | the __version__ file. Defaults to 23 | typer.Argument( metavar="package_dir" ). 24 | 25 | new_version (str, optional): New tagged vesion. 26 | Defaults to typer.Argument(metavar="new_version"). 27 | 28 | Raises: 29 | ValueError: if version could not be read from __version__.py file 30 | """ 31 | 32 | def _find_and_replace(file_path: Path, pattern_version: str) -> tuple[str, str]: 33 | with open(file_path, "r") as f: 34 | content = f.read() 35 | match = re.search(pattern_version, content) 36 | if match: 37 | version = match.group(1) 38 | typer.echo(f"Version: {version}", color=typer.colors.BLUE) 39 | return content, version 40 | else: 41 | raise ValueError("Version not found") 42 | 43 | version_file_path = Path(package_dir) / VERSION_FILE 44 | content, version = _find_and_replace(version_file_path, PATTERN_VERSION) 45 | 46 | new_version = new_version.replace("v", "").replace("V", "") 47 | 48 | with open(version_file_path, "w") as f: 49 | f.write(content.replace(version, new_version)) 50 | 51 | pyproject_toml_file_path = Path(".") / PYPROJECT_TOML_FILE 52 | content_toml, version_toml = _find_and_replace( 53 | pyproject_toml_file_path, PATTERN_PYPROJECT_TOML 54 | ) 55 | with open(pyproject_toml_file_path, "w") as f: 56 | f.write(content_toml.replace(version_toml, new_version)) 57 | 58 | typer.echo(f"New version -> {new_version}", color=typer.colors.BRIGHT_CYAN) 59 | 60 | 61 | if __name__ == "__main__": 62 | typer.run(set_version) 63 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ga.evolution import Evolution 4 | 5 | 6 | # Define a fitness function 7 | def fitness(x: int | float, y: int | float) -> float: 8 | return x * (x - 1) * np.cos(2 * x - 1) * np.sin(2 * x - 1) * (y - 2) 9 | 10 | 11 | # Define parameter for each individual 12 | ind_parameters = {"lower_bound": -2, "upper_bound": 2, "number_of_genes": 2} 13 | 14 | # Define parameter for the entire population 15 | pop_parameters = { 16 | "n_parents": 6, 17 | "offspring_size": (2, ind_parameters["number_of_genes"]), 18 | "mutation_mean": 0.25, 19 | "mutation_sd": 0.5, 20 | "size": 10, 21 | } 22 | 23 | 24 | def run(): 25 | # Instantiate an evolution 26 | evo = Evolution(pop_parameters, ind_parameters, fitness) 27 | 28 | # Repeat evolution step 200 epochs 29 | EPOCHS = 10000 30 | 31 | # Record fitness history 32 | history = [] 33 | x_history = [] 34 | y_history = [] 35 | 36 | for _ in range(EPOCHS): 37 | print( 38 | "Epoch {}/{}, Progress: {}%\r".format( 39 | _ + 1, EPOCHS, np.round(((_ + 1) / EPOCHS) * 100, 2) 40 | ), 41 | end="", 42 | ) 43 | evo.step() 44 | history.append(evo.solution.best_score) 45 | x_history.append(evo.solution.best_individual[0]) 46 | y_history.append(evo.solution.best_individual[1]) 47 | 48 | if evo.solution: 49 | print("\nResults:") 50 | print("Best individual:", evo.solution.best_individual) 51 | print("Fitness value of best individual:", evo.solution.best_score) 52 | 53 | 54 | if __name__ == "__main__": 55 | run() 56 | -------------------------------------------------------------------------------- /ga/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/ga/__init__.py -------------------------------------------------------------------------------- /ga/__version__.py: -------------------------------------------------------------------------------- 1 | # CI will overwrite this 2 | VERSION = "0.0.0" 3 | -------------------------------------------------------------------------------- /ga/evolution.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from typing import Callable, Dict, Optional 4 | 5 | import numpy as np 6 | 7 | from ga.population import Population 8 | 9 | 10 | @dataclass 11 | class Solution: 12 | best_individual: np.array 13 | best_score: Optional[float] 14 | 15 | 16 | class EvolutionBase(ABC): 17 | @abstractmethod 18 | def step(self): 19 | pass 20 | 21 | 22 | class Evolution: 23 | _N_PARENTS_KEY = "n_parents" 24 | _SIZE_KEY = "size" 25 | 26 | def __init__( 27 | self, pop_parameters: Dict, ind_parameters: Dict, fitness_function: Callable 28 | ): 29 | self.fitness = fitness_function 30 | self.population = Population(pop_parameters, ind_parameters, fitness_function) 31 | self.ind_parameters = ind_parameters 32 | self.pop_parameters = pop_parameters 33 | self._best_individual = None 34 | self._best_score = None 35 | 36 | def step(self) -> None: 37 | parents = self.population.get_parents(self.pop_parameters[self._N_PARENTS_KEY]) 38 | offspring = self.population.crossover(parents) 39 | mutation = self.population.mutate( 40 | offspring, self.ind_parameters, self.pop_parameters 41 | ) 42 | population_values = [ 43 | self.population.individuals[i].get_values() 44 | for i in range(self.population.size) 45 | ] 46 | population_values.extend(mutation) 47 | population_values.sort(key=lambda x: self.fitness(*x), reverse=True) 48 | population_values = population_values[: self.pop_parameters[self._SIZE_KEY]] 49 | self._best_individual = population_values[:1] 50 | self._best_score = self.fitness(*self._best_individual[0]) 51 | 52 | # update population 53 | self.population = Population( 54 | self.pop_parameters, self.ind_parameters, self.fitness, population_values 55 | ) 56 | 57 | @property 58 | def solution(self) -> Optional[Solution]: 59 | if self._best_individual is not None: 60 | return Solution(self._best_individual[0], self._best_score) 61 | -------------------------------------------------------------------------------- /ga/individual.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, List 3 | 4 | import numpy as np 5 | 6 | 7 | class IndividualBase(ABC): 8 | @abstractmethod 9 | def get_values(self): 10 | pass 11 | 12 | 13 | class Individual: 14 | def __init__(self, params: Dict, value: List[np.array] = None): 15 | if value is None: 16 | self.value = [ 17 | np.random.uniform(params["lower_bound"], params["upper_bound"], 1)[0] 18 | for _ in range(params["number_of_genes"]) 19 | ] 20 | else: 21 | self.value = value 22 | 23 | def get_values(self) -> List[np.array]: 24 | return self.value 25 | -------------------------------------------------------------------------------- /ga/population.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable, Dict, List, Optional 3 | 4 | import numpy as np 5 | 6 | from ga.individual import Individual 7 | 8 | 9 | class PopulationBase(ABC): 10 | @abstractmethod 11 | def get_parents(self): 12 | pass 13 | 14 | @abstractmethod 15 | def crossover(self): 16 | pass 17 | 18 | @abstractmethod 19 | def mutate(self): 20 | pass 21 | 22 | 23 | class Population(PopulationBase): 24 | _SIZE_KEY = "size" 25 | _N_PARENTS_KEY = "n_parents" 26 | _OFFSPRING_SIZE_KEY = "offspring_size" 27 | _LOWER_BOUND_KEY = "lower_bound" 28 | _UPPER_BOUND_KEY = "upper_bound" 29 | _MUTATION_MEAN_KEY = "mutation_mean" 30 | _MUTATION_SD_KEY = "mutation_sd" 31 | 32 | def __init__( 33 | self, 34 | pop_parameters: Dict, 35 | ind_parameters: Dict, 36 | fitness_function: Callable, 37 | ind_values: Optional[List[np.array]] = None, 38 | ): 39 | if ind_values is None: 40 | self.individuals = [ 41 | Individual(ind_parameters) 42 | for _ in range(pop_parameters[self._SIZE_KEY]) 43 | ] 44 | else: 45 | self.individuals = [ 46 | Individual(ind_parameters, value) for value in ind_values 47 | ] 48 | self.fitness_function = fitness_function 49 | self.parents = None 50 | self.size = pop_parameters[self._SIZE_KEY] 51 | self.n_parents = pop_parameters[self._N_PARENTS_KEY] 52 | self.offspring_size = pop_parameters[self._OFFSPRING_SIZE_KEY] 53 | 54 | def get_parents(self, n_parents: int) -> np.array: 55 | parents = [self.individuals[i].get_values() for i in range(self.size)] 56 | parents.sort(key=lambda x: self.fitness_function(*x), reverse=True) 57 | return np.array(parents[:n_parents]) 58 | 59 | def crossover(self, parents: np.array) -> np.array: 60 | offspring = np.empty(self.offspring_size) 61 | crossover_point = np.uint8(self.offspring_size[0] / 2) 62 | for k in range(self.offspring_size[1]): 63 | parent1_idx = k % parents.shape[0] 64 | 65 | # Index of the second parent to mate 66 | parent2_idx = (k + 1) % parents.shape[0] 67 | # Half of the first parent 68 | offspring[k, 0:crossover_point] = parents[parent1_idx, 0:crossover_point] 69 | # Half if the second parent 70 | offspring[k, crossover_point:] = parents[parent2_idx, crossover_point:] 71 | return offspring 72 | 73 | def mutate( 74 | self, offspring_crossover: np.array, ind_params: Dict, pop_params: Dict 75 | ) -> np.array: 76 | for idx in range(offspring_crossover.shape[1]): 77 | # select randomly the gene where randomness is going to be added 78 | g = np.random.choice(range(offspring_crossover.shape[1])) 79 | 80 | # The random value to be added to the gene. 81 | offspring_crossover[idx][g] = offspring_crossover[idx][ 82 | g 83 | ] + np.random.normal( 84 | pop_params[self._MUTATION_MEAN_KEY], 85 | pop_params[self._MUTATION_SD_KEY], 86 | 1, 87 | ) 88 | 89 | # Apply upper and lower bounds 90 | offspring_crossover = np.where( 91 | offspring_crossover > ind_params[self._UPPER_BOUND_KEY], 92 | ind_params[self._UPPER_BOUND_KEY], 93 | offspring_crossover, 94 | ) 95 | offspring_crossover = np.where( 96 | offspring_crossover < ind_params[self._LOWER_BOUND_KEY], 97 | ind_params[self._LOWER_BOUND_KEY], 98 | offspring_crossover, 99 | ) 100 | 101 | return offspring_crossover 102 | -------------------------------------------------------------------------------- /public/fitness_function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/public/fitness_function.png -------------------------------------------------------------------------------- /public/fitness_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/public/fitness_history.png -------------------------------------------------------------------------------- /public/genetic-algo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/public/genetic-algo.png -------------------------------------------------------------------------------- /public/individual_values.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/public/individual_values.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | dependencies = ["numpy>=1.22"] 7 | name = "generic-algorithm-light" 8 | license = { file = "LICENSE.md" } 9 | version = "0.0.0" 10 | description = "A lightweight genetic algorithm package for optimization" 11 | authors = [{ name = "Fernando Zepeda", email = "fernando.zepeda@pm.me" }] 12 | readme = "README.md" 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | ] 18 | 19 | [tool.setuptools.packages.find] 20 | where = ["."] # list of folders that contain the packages (["."] by default) 21 | include = [ 22 | "ga", 23 | ] # package names should match these glob patterns (["*"] by default) 24 | exclude = [] # exclude packages matching these glob patterns (empty by default) 25 | namespaces = false # to disable scanning PEP 420 namespaces (true by default) 26 | 27 | [project.optional-dependencies] 28 | dev = ["ruff"] 29 | 30 | [tool.ruff] 31 | line-length = 88 32 | target-version = "py310" 33 | 34 | [tool.pytest.ini_options] 35 | minversion = "6.0" 36 | addopts = "-ra -q" 37 | testpaths = ["tests"] 38 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | ruff 2 | isort -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.22 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fmrhj/genetic-algorithm/69f09f35f86972bce35731c5b0e9709fec367790/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_evolution.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from ga.evolution import Evolution, Solution 5 | 6 | NUMBER_OF_GENES = 2 7 | MANY_ITERATIONS = 100 8 | 9 | 10 | def fitness(x: int | float, y: int | float) -> float: 11 | return x * (x - 1) * np.cos(2 * x - 1) * np.sin(2 * x - 1) * (y - 2) 12 | 13 | 14 | @pytest.fixture 15 | def individual_params() -> dict: 16 | return {"lower_bound": -2, "upper_bound": 2, "number_of_genes": NUMBER_OF_GENES} 17 | 18 | 19 | @pytest.fixture 20 | def population_params() -> dict: 21 | return { 22 | "n_parents": 6, 23 | "offspring_size": (NUMBER_OF_GENES, NUMBER_OF_GENES), 24 | "mutation_mean": 0.25, 25 | "mutation_sd": 0.5, 26 | "size": 10, 27 | } 28 | 29 | 30 | def test_one_iteration(population_params: dict, individual_params: dict): 31 | def fitness(x: int | float, y: int | float) -> float: 32 | return x * (x - 1) * np.cos(2 * x - 1) * np.sin(2 * x - 1) * (y - 2) 33 | 34 | evo = Evolution(population_params, individual_params, fitness) 35 | 36 | for _ in range(1): 37 | evo.step() 38 | 39 | assert evo.solution 40 | assert all( 41 | [isinstance(solution, float) for solution in evo.solution.best_individual] 42 | ) 43 | assert len(evo.solution.best_individual) == NUMBER_OF_GENES 44 | assert isinstance(evo.solution, Solution) 45 | 46 | 47 | def test_many_iterations(population_params: dict, individual_params: dict): 48 | evo = Evolution(population_params, individual_params, fitness) 49 | 50 | for _ in range(MANY_ITERATIONS): 51 | evo.step() 52 | 53 | assert evo.solution 54 | assert all( 55 | [isinstance(solution, float) for solution in evo.solution.best_individual] 56 | ) 57 | assert len(evo.solution.best_individual) == NUMBER_OF_GENES 58 | assert isinstance(evo.solution, Solution) 59 | -------------------------------------------------------------------------------- /tests/test_individual.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ga.individual import Individual 4 | 5 | 6 | @pytest.fixture 7 | def invididual_instance() -> Individual: 8 | params = {"lower_bound": 1, "upper_bound": 10, "number_of_genes": 100} 9 | return Individual(params=params) 10 | 11 | 12 | def test_individual_values(invididual_instance: Individual): 13 | candidate_values = invididual_instance.get_values() 14 | assert len(candidate_values) == 100 15 | assert min(candidate_values) >= 1 16 | assert max(candidate_values) <= 10 17 | --------------------------------------------------------------------------------