diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 834f57df..034a8754 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,11 @@ on: - 'examples/**' - 'requirements.txt' - 'pyproject.toml' + - 'setup.py' + - 'MANIFEST.in' - '.github/workflows/main.yml' + - '.github/workflows/release.yml' + - 'tools/release.py' # Test relevant pull requests, including fork contributions, against master. pull_request: branches: @@ -29,9 +33,15 @@ on: - 'examples/**' - 'requirements.txt' - 'pyproject.toml' + - 'setup.py' + - 'MANIFEST.in' - '.github/workflows/main.yml' + - '.github/workflows/release.yml' + - 'tools/release.py' # Allows manual triggering of the workflow from the GitHub Actions tab. workflow_dispatch: + # Release tags run this same matrix before publishing. + workflow_call: jobs: pytest: @@ -114,8 +124,13 @@ jobs: # This includes our new tests for visualization, operators, parallel processing, etc. - name: Run Tests run: | + # Run outside the checkout so imports exercise the installed wheel. + cd "$RUNNER_TEMP" + python -c "import pygad; print('Testing installed package:', pygad.__file__)" if [ "${{ matrix.python-version }}" == "3.14" ] || [ "${{ matrix.python-version }}" == "3.8" ]; then - pytest --ignore=tests/test_kerasga.py --ignore=tests/test_torchga.py + python -m pytest "$GITHUB_WORKSPACE/tests" \ + --ignore="$GITHUB_WORKSPACE/tests/test_kerasga.py" \ + --ignore="$GITHUB_WORKSPACE/tests/test_torchga.py" else - pytest + python -m pytest "$GITHUB_WORKSPACE/tests" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 782a39a8..64087375 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,8 @@ name: release # On a version tag this builds the package once, publishes it to PyPI via # trusted publishing (no API token stored in the repo), and attaches the built -# wheel and sdist to a GitHub Release for the tag. The PyPI project must list +# wheel and sdist downloaded back from PyPI to a GitHub Release for the tag. +# Both downloads must match the checked build. The PyPI project must list # this repo and workflow as a trusted publisher first. on: @@ -11,14 +12,33 @@ on: tags: - '[0-9]+.[0-9]+.[0-9]+' +permissions: + contents: read + jobs: + tests: + uses: ./.github/workflows/main.yml + permissions: + contents: read + build: + needs: tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Verify release tag matches package version + run: | + python - <<'PY' + import os + import runpy + version = runpy.run_path("pygad/_version.py")["__version__"] + tag = os.environ["GITHUB_REF_NAME"] + if tag != version: + raise SystemExit(f"Release tag {tag} does not match package version {version}") + PY - name: Build distributions run: | pip install build . pytest responses 'vilvik>=0.5.3' @@ -28,10 +48,21 @@ jobs: run: | pip install twine python -m twine check dist/* + - name: Check documentation + run: | + pip install -r docs/requirements.txt + python docs/markdown_compatibility.py + python -m sphinx -b html -W --keep-going docs/source docs/build/html + - name: Prepare documented release notes + run: python tools/release.py notes "$GITHUB_REF_NAME" release-notes.md - uses: actions/upload-artifact@v4 with: name: dist path: dist/ + - uses: actions/upload-artifact@v4 + with: + name: release-notes + path: release-notes.md publish: needs: build @@ -48,22 +79,35 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 github-release: - needs: build + needs: [build, publish] runs-on: ubuntu-latest permissions: contents: write steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Attach the built files to the GitHub release + - uses: actions/download-artifact@v4 + with: + name: release-notes + - name: Download and verify the published PyPI distributions + run: python tools/release.py fetch-pypi "$GITHUB_REF_NAME" dist published-dist + - name: Publish GitHub release with documented notes and PyPI files env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" dist/* \ - --repo "$GITHUB_REPOSITORY" \ - --title "$GITHUB_REF_NAME" \ - --generate-notes \ - || gh release upload "$GITHUB_REF_NAME" dist/* \ - --repo "$GITHUB_REPOSITORY" --clobber + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" published-dist/* \ + --repo "$GITHUB_REPOSITORY" --clobber + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --title "PyGAD $GITHUB_REF_NAME" --notes-file release-notes.md --draft=false --latest + else + gh release create "$GITHUB_REF_NAME" published-dist/* \ + --repo "$GITHUB_REPOSITORY" --verify-tag --latest \ + --title "PyGAD $GITHUB_REF_NAME" --notes-file release-notes.md + fi diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b52a4c61 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include tools/release.py +include docs/source/releases.md diff --git a/README.md b/README.md index ef7a0a76..feeebc0a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ The library is under active development and more features are added regularly. I # Installation +The current release is [PyGAD 3.8.0](https://github.com/ahmedfgad/GeneticAlgorithmPython/releases/tag/3.8.0), dated October 9, 2026. Read the [release notes](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/3.8.0/docs/source/releases.md#pygad-380) for its new features, fixes, and compatibility changes. PyGAD requires Python 3.8 or newer. + To install [PyGAD](https://pypi.org/project/pygad), use pip to download and install the library from [PyPI](https://pypi.org/project/pygad) (Python Package Index). The library is available on PyPI at this page: https://pypi.org/project/pygad. Install PyGAD with the following command: @@ -46,6 +48,9 @@ pip install pygad[visualize] # Training Keras/PyTorch models (pygad.kerasga, pygad.torchga): pip install pygad[deep_learning] + +# PDF reports need ReportLab and matplotlib: +pip install pygad[report] ``` To get started with PyGAD, read the documentation at [Read the Docs](https://pygad.readthedocs.io). diff --git a/RELEASING.md b/RELEASING.md index 37ed635a..223ca194 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,35 +1,58 @@ # Releasing Releases are automated. Pushing a version tag builds the package, publishes it to -PyPI, and attaches the built files to a GitHub Release. Nothing is uploaded by -hand. +PyPI, and downloads the published wheel and source distribution for the GitHub +Release. Downloaded files must match the checked build's SHA-256 hashes. +The GitHub Release uses the release notes from `docs/source/releases.md`. ## Steps 1. Bump the version in `pygad/_version.py`. This is the only place the version lives. -2. Update the release notes in the docs if you keep them there. +2. Update the matching `PyGAD ` section in `docs/source/releases.md`. + Set `Release Date: Month D, YYYY.` and remove pending-publication text. 3. Commit and push: ```bash - git add pygad/_version.py - git commit -m "Release 3.6.1" + git add pygad/_version.py docs/source/releases.md + git commit -m "Prepare PyGAD 3.8.0" git push ``` -4. Wait for the test workflow (`main.yml`) to pass on that commit. -5. Tag the release and push the tag: + Stage any other intended release changes before committing. Preparation stays + on `github-actions` until the maintainer chooses the final release commit; + these steps do not require changes to `master`. +4. Wait for the test workflow (`main.yml`) to pass on that commit. Confirm the + release notes describe the intended version and replace its pending release + date with the actual publication date. Documentation reads the package version + automatically. Build and check the distributions before tagging: ```bash - git tag 3.6.1 - git push origin 3.6.1 + python -m build + python -m twine check dist/* + ``` +5. Create or update a pull request from `github-actions` to `master`, using the + documented release notes as its description. Generate the description with: + ```bash + python tools/release.py notes 3.8.0 docs/build/release-notes-3.8.0.md + ``` + Wait for its checks and merge it. Tag the merged `master` commit and push the tag: + ```bash + git switch master + git pull --ff-only origin master + git tag 3.8.0 + git push origin 3.8.0 ``` -The `release` workflow does the rest: it builds the wheel and sdist, publishes -them to PyPI, and creates a GitHub Release with both files attached. Follow it -with `gh run watch` or the Actions tab. +The `release` workflow first runs the full Python 3.8 through 3.14 test matrix, +then builds the wheel and sdist, checks documentation and release notes, and +publishes the packages to PyPI. It downloads both published files, verifies +their SHA-256 hashes against the build, and creates a GitHub Release with those +files and the documented notes. Documentation links in the PR and release notes +point to the tagged source. Follow the workflow with `gh run watch` or the Actions tab. +Verify the PyPI version and GitHub assets after it succeeds. ## Rules - The tag must match `pygad/_version.py` and is the bare version number with no - `v` prefix, for example `3.6.1`. The tag is what triggers the release. + `v` prefix, for example `3.8.0`. The tag is what triggers the release. - Every release needs a new version number. PyPI does not allow re-uploading or overwriting a version that already exists. - Do not run `twine upload` or upload files to the GitHub Release by hand. The diff --git a/docs/MARKDOWN.md b/docs/MARKDOWN.md new file mode 100644 index 00000000..51dc7175 --- /dev/null +++ b/docs/MARKDOWN.md @@ -0,0 +1,41 @@ +# Writing Documentation in Markdown + +Documentation pages should be readable on GitHub and in Markdown previews as well as on Read the Docs. Use ordinary Markdown for headings, links, images, lists, tables, and code blocks. + +For links within the documentation, use a relative `.md` path and the GitHub-style heading anchor: + +```markdown +[Plot Lifecycle](visualize.md#plot_lifecycle) +``` + +MyST resolves these links to the appropriate pages and section IDs during a Sphinx build. Existing published anchors remain available. Builds report missing pages and heading anchors. + +Use HTML `
` and `` for collapsible descriptions. Leave blank lines around their Markdown content. Use `` for code in a summary because Markdown formatting is not processed inside the summary itself: + +```markdown +
+gene_type=float: Data type of the genes. + +The type used to store each gene value. + +
+``` + +The `markdown_compatibility.py` extension converts these blocks into the existing Sphinx Design dropdowns for HTML and other documentation formats. + +Keep Sphinx-only metadata, such as explicit labels and toctrees, inside `` comments. The extension restores this metadata during a build; Markdown previews hide it. A toctree should have visible Markdown navigation beside it, either a list on the home page or a navigation group: + +```markdown + + +- [Controlling Gene Values](gene_values.md) — Set ranges, types, constraints, and duplicate prevention. +- [Controlling Generations](generations.md) — Configure stopping, elitism, and continuation. + + +``` + +The build presents these lists as the existing navigation cards. Their titles, destinations, and descriptions are written only once. + +For diagrams with a preferred display width, put a normal PNG image and its caption between `documentation-figure` comments, following the existing pages. The image works directly in Markdown; the build restores the centered figure, caption, and width and selects the appropriate image format. Embedded videos stay in Sphinx comments with a visible YouTube link beside them. + +Python example sections are generated from the catalog and shared templates. See [Connecting Python Examples to the Documentation](PYTHON_EXAMPLES.md) for the editing and checking commands. Regeneration needs only Python; it does not require Sphinx or execute the examples. diff --git a/docs/PYTHON_EXAMPLES.md b/docs/PYTHON_EXAMPLES.md new file mode 100644 index 00000000..fdb4f93a --- /dev/null +++ b/docs/PYTHON_EXAMPLES.md @@ -0,0 +1,45 @@ +# Connecting Python Examples to the Documentation + +`python_examples.json` is the shared catalog for the Examples index and the Python example cards in the guides. Keep the descriptions and requirements here rather than copying them into each guide. Paths are relative to the repository's `examples/` directory. + +For the general documentation conventions, see [Writing Documentation in Markdown](MARKDOWN.md). + +To show one or more examples beside a relevant explanation, use this template in a documentation page: + +```markdown + + + +``` + +After changing the catalog or adding a section, update the checked-in Markdown from the repository root: + +```console +python docs/markdown_compatibility.py --update-examples +``` + +The generated section contains ordinary Markdown links, descriptions, and expandable run instructions. It is readable on GitHub and in Markdown previews before any build. Do not edit the generated text directly; edit the catalog or shared templates, then regenerate it. To check that the generated sections are current, run `python docs/markdown_compatibility.py`. + +For larger groups, the section uses a compact table with expandable run instructions. The Examples index uses `python-examples-index` comments to list every entry by topic, with links back to its guide. Sphinx presents these sections using the existing example cards, tables, dropdowns, and downloads. + +Each catalog entry has these fields: + +- `path`: Existing Python script or notebook under `examples/`. +- `title`: Short descriptive name for the example. +- `description`: What readers will learn from the script. +- `category`: Topic heading in the Examples index. Categories follow their first appearance in the catalog. +- `guide`: Existing Markdown guide, relative to `docs/source/`. +- `requirements`: Libraries or optional extras needed in addition to a matching version of PyGAD. +- `run`: Command to run from the repository root, or an empty string for a notebook. +- `run_note` (optional): Working-directory instructions when the root cannot be used directly. +- `data` (optional): Dataset filenames, expected layout, and any setup limitations. +- `download` (optional, default `true`): Set to `false` when downloading a script alone would omit required data or companion files. Readers receive a folder link instead. + +The shared templates are in `python_example_templates/`. The `*-source.md.template` files use standard Markdown and generate the checked-in sections. The `.md.jinja` files use Sphinx Design cards and dropdowns and Sphinx's native download links. Both presentations use the same catalog. The `markdown_compatibility.py` extension checks the source sections and passes them to `python_examples.py` for the built presentation; neither executes example scripts. + +The documentation build checks that every Python script appears in the catalog, all catalog paths stay inside `examples/`, and the linked guides exist. Missing entries, unknown paths, incomplete section comments, and stale generated Markdown fail the build so examples are not silently left out. Sphinx copies downloadable scripts from the repository into the built documentation; no second script copy needs to be maintained. + +GitHub links use the commit checked out for the documentation build. Without Git, the configured Read the Docs identifier is used, falling back to `master`. This keeps source links aligned with versioned documentation. diff --git a/docs/RELEASE_READINESS_3.8.0.md b/docs/RELEASE_READINESS_3.8.0.md new file mode 100644 index 00000000..c1684201 --- /dev/null +++ b/docs/RELEASE_READINESS_3.8.0.md @@ -0,0 +1,131 @@ +# PyGAD 3.8.0 release readiness + +Prepublication review completed October 9, 2026. This document records the +preparation and validation performed on `github-actions` before its release +pull request to `master`. The publication date in the release notes is +October 9, 2026. + +## Version + +At the start of this review, the latest public release was **3.7.0**, published +June 5, 2026. The prepared release is **3.8.0** because +it adds public features, including `plot_lifecycle()` and generation metadata, +alongside fixes and internal refactoring. A patch release would understate +those additions. The main `GA` constructor signature is unchanged from 3.7.0; +the reviewed changes do not require a new major version. + +The package version is prepared in `pygad/_version.py`. Sphinx reads that file +directly so documentation and package versions cannot drift. + +## Validation + +- Release preparation baseline: 1,871 tests passed, one skipped, on Python 3.11.13 with + NumPy 2.4.4. The skip is the unavailable Windows `fork` process start method. +- TensorFlow/Keras and PyTorch tests were excluded locally because those + optional frameworks are absent. They are covered by the existing remote + matrix on its supported framework versions. +- Latest matrix: all seven Python 3.8 through 3.14 jobs passed at commit + `adde6bfe83f1e8b2ba8a3c8f73205f2dbcab8951`, including the lifecycle export fix, + using the changed workflow and + installed 3.8.0 wheels: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37995352872 +- SDK compatibility: all six minimum/latest/development SDK jobs on Python 3.9 + and 3.12 passed for that same commit: + https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/37995352864 +- The lifecycle fix passed all 41 focused lifecycle tests locally, related + report tests, and a strict Sphinx build with `-n -W --keep-going`. + Transparent PNG tests verify alpha, small margins and unclipped chart text. +- Wheel and source distribution built as 3.8.0 and both passed `twine check`. + The wheel contains the logo needed by PDF reports and declares its extras. +- Installed wheel: 1,871 tests passed, one skipped, in a separate virtual + environment from outside the repository. Imports resolved to the installed + 3.8.0 wheel, not the source checkout. The PyPI submodule-version check was + required to pass rather than silently skip if unavailable. +- HTML documentation built with `-W --keep-going` and no warnings. +- Generated example Markdown is current; its catalog covers 81 Python scripts + and one notebook. +- Changed workflows passed actionlint 1.7.12. Whitespace checks passed. + +## Publishing safeguards prepared + +The release workflow now runs the same full Python matrix before building +and publishing. Matrix tests run outside the checkout to import the installed +wheel. The workflow rejects mismatches between the tag and package version, +checks distributions, and requires a clean documentation build. The GitHub +Release job waits for successful PyPI publication, then downloads the published +wheel and source distribution and verifies their SHA-256 hashes against the +build. The documented release notes are used for both the PR and GitHub Release. + +The GitHub `pypi` environment exists. The prior 3.7.0 release completed through +the trusted-publishing workflow: +https://github.com/ahmedfgad/GeneticAlgorithmPython/actions/runs/27043771194 +Its previous success does not establish that account permissions can never +change; the next release run remains the verification of live publishing. + +## Compatibility notes to retain + +- Seeded results can differ from earlier versions. Reproducibility is within + the same version and environment, with independent generators per instance. +- Invalid fitness values and malformed constructor settings are rejected + earlier and consistently. These corrections are detailed in the release notes. +- Saved histories retain both snapshots at repeated-run boundaries and expose + actual generation numbers. +- Metadata now declares Python 3.8 or newer, matching the minimum tested version. + +## Publication procedure + +The reviewed tree passed the local prepublication checks. The changed test +workflow and SDK compatibility workflow also passed on GitHub for the release +preparation commit. Publication is authorized by the maintainer. The release +notes have the requested October 9, 2026 date. The release PR uses those notes, +and the merged `master` commit receives the matching 3.8.0 tag. Pushing that tag +publishes automatically. The final verification checks PyPI installation and +confirms that both GitHub assets match the published PyPI packages. + +## Announcement video + +The previous source project was found at +https://github.com/ahmedfgad/PyGADReleaseVideo and cloned as a sibling at +`D:/Projects/PyGADReleaseVideo`. All video work is consolidated on `main`. +It reuses the original animation kit, fonts, logo, typing sounds and transition +effects. Bell cues use the user-approved soft wooden tap. The revised background +music is the user-approved A: Warm keys. C: Floating ambient and the original +release music are retained as separate three-minute WAV and MP3 tracks, with +their synthesis code, in `Music_Library_3.8.0/`. +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/main/Music_Library_3.8.0 +Runnable on-screen examples, real output, chapters and draft social posts +are in `content/3.8.0/`. See that repository's `RELEASE_3.8.0.md` for builds. + +The landscape (3840x2160), vertical (2160x3840), and square (2160x2160) +videos are complete. The announcement kit was uploaded to `PyGAD_3.8.0/` +on that repository's `main` branch, with MP4 files stored through Git LFS: +https://github.com/ahmedfgad/PyGADReleaseVideo/tree/main/PyGAD_3.8.0 +The revised video commit is `f31aaad8ff7c9bc7b02fa956b6a010848fc9ee16`, +using the library implementation at `adde6bfe`. +All four MP4 objects uploaded successfully and local Git LFS integrity checks +passed. A fresh authenticated download of the preview matched its SHA-256 hash. +The three full videos are 134.13 seconds each, and the preview is 22.5 seconds. +The opening shows the logo and version for 5.5 seconds, down from 9.5 seconds. +All four run at 60 fps, +with H.264 video and 48 kHz AAC stereo audio. Every encoded frame decoded successfully; +contact sheets from all nine segments were visually checked in all orientations. +Thumbnails, a Reel cover, chapter timestamps, title captions, draft social posts, +and machine-readable media validation are included. Social posts remain drafts; +no videos have been published to social platforms. + +The October 9 revisions move section takeaways into larger, high-contrast +callouts above the output and enlarge the history chart in every orientation. +Plots come from 360 dpi exports. All section titles are uppercase. Code typing +is reduced from 60 to 44 characters per second, and the documentation command +from 34 to 30. The permutation demo highlights and prints its initial row. +The lifecycle call is fully highlighted, and the library now exports its chart +with small margins and optional transparency. The video uses that actual export. +The worker demo explains a target sum of 20 and illustrates two real process +workers reused over four generations; a pool/PID audit is included. The +documentation demo uses an actual screenshot and its complete example command. + +Audio checks verify the approved music and preserved non-bell effects, +including all 469 typing clicks. Encoded +music, typing transients, tap levels, loudness and peaks are checked in all four +videos. Layout and audio measurements are included in the +video repository's `PyGAD_3.8.0/layout_review.json` and `audio_validation.json`. diff --git a/docs/markdown_compatibility.py b/docs/markdown_compatibility.py new file mode 100644 index 00000000..529c581e --- /dev/null +++ b/docs/markdown_compatibility.py @@ -0,0 +1,171 @@ +"""Keep Markdown readable directly while retaining the Sphinx presentation.""" + +import argparse +import html +import json +from pathlib import Path, PurePosixPath +import re +from string import Template + + +DOCUMENTATION_DIRECTORY = Path(__file__).resolve().parent +EXAMPLE_BLOCK = re.compile( + r'\n' + r'(?P.*?)\n', re.DOTALL) + + +def render_markdown_examples(catalog, paths, render_index=False): + """Render checked-in example links and instructions from the shared catalog.""" + examples_by_path = {example['path']: example for example in catalog} + examples = catalog if render_index else [examples_by_path[path] for path in paths] + if not examples: + raise ValueError('List at least one Python example in the Markdown marker.') + templates_directory = DOCUMENTATION_DIRECTORY / 'python_example_templates' + card_template = Template((templates_directory / 'card-source.md.template').read_text(encoding='utf-8')) + row_template = Template((templates_directory / 'table-source.md.template').read_text(encoding='utf-8')) + + def render_table(group): + rows = ['| Python script | What it shows | Related information |', '| --- | --- | --- |'] + for example in group: + folder = PurePosixPath(example['path']).parent.as_posix() + links = f"[Guide]({example['guide']})" + if not example.get('download', True): + links += f' · [Folder](../../examples/{folder}/) · [Data setup](../../examples/data/README.md)' + rows.append(row_template.substitute( + path=example['path'], title=example['title'], description=example['description'], + links=links).strip()) + return '\n'.join(rows) + + if render_index: + categories = dict.fromkeys(example['category'] for example in examples) + return '\n\n'.join(f'## {category}\n\n' + render_table([ + example for example in examples if example['category'] == category]) for category in categories) + + cards = [] + group_instructions = [] + for example in examples: + data_instructions = '' + if example.get('data'): + data_instructions = (f"**Data:** {example['data']} " + 'See the [dataset setup instructions](../../examples/data/README.md).\n\n') + run_instructions = '' + if example['run']: + run_note = example.get('run_note', 'From the repository root, with the repository version of PyGAD installed:') + run_instructions = f"{run_note}\n\n```console\n{example['run']}\n```\n\n" + cards.append(card_template.substitute( + title=example['title'], path=example['path'], description=example['description'], + requirements=example['requirements'], data_instructions=data_instructions, + run_instructions=run_instructions).strip()) + group_instructions.append((f"**{example['title']}** — Requires: {example['requirements']}\n\n" + + data_instructions + run_instructions).strip()) + if len(examples) > 3: + instructions = '
\nRun these examples\n\n' + instructions += '\n\n'.join(group_instructions).strip() + '\n\n
' + return '**Python examples**\n\n' + render_table(examples) + '\n\n' + instructions + title = '**Python example**' if len(examples) == 1 else '**Python examples**' + return title + '\n\n' + '\n\n'.join(cards) + + +def update_example_blocks(source, catalog, check_only=False): + """Refresh example sections, or reject stale sections during a build.""" + marker_count = len(re.findall(r'', source)) + if marker_count != closing_marker_count or marker_count != len(list(EXAMPLE_BLOCK.finditer(source))): + raise ValueError('Each Python example section needs a matching closing comment.') + + def replace_block(match): + paths = match['paths'].split() + render_index = match['kind'] == 'python-examples-index' + if render_index and paths: + raise ValueError('The Python examples index includes the whole catalog and does not accept paths.') + try: + expected = render_markdown_examples(catalog, paths, render_index) + except KeyError as error: + raise ValueError(f'Unknown Python example in docs/python_examples.json: {error.args[0]}') from error + if check_only and match['body'].strip() != expected: + raise ValueError('Python example content is out of date. Run python docs/markdown_compatibility.py --update-examples.') + if check_only: + return match.group(0) + header = '' + return header + '\n\n' + expected + '\n\n' + return EXAMPLE_BLOCK.sub(replace_block, source) + + +def prepare_sphinx_source(app, document_name, source): + """Restore presentation directives from portable Markdown and hidden metadata.""" + if not (Path(app.srcdir) / (document_name + '.md')).is_file(): + return + from sphinx.errors import ExtensionError + + try: + content = update_example_blocks(source[0], app.python_examples_catalog, check_only=True) + except (KeyError, ValueError) as error: + raise ExtensionError(f'{document_name}: {error}') from error + + def restore_example_section(match): + paths = '\n'.join(match['paths'].split()) + return ':::{' + match['kind'] + '}\n' + paths + '\n:::' + + content = EXAMPLE_BLOCK.sub(restore_example_section, content) + # Labels, toctrees, and embedded videos are build metadata, not visible prose. + content = re.sub(r'', r'\1', content, flags=re.DOTALL) + + def restore_grid(match): + cards = [] + for line in match['body'].splitlines(): + if not line.strip(): + continue + item = re.fullmatch(r'- \[([^]]+)\]\(([^)]+)\.md\)(?: — (.*))?', line) + if item is None: + raise ExtensionError(f'{document_name}: Invalid navigation item: {line}') + title, destination, description = item.groups() + cards.append(f':::{{grid-item-card}} {title}\n:link: {destination}\n:link-type: doc\n\n{description or ""}\n:::') + return '::::{grid} ' + match['dimensions'].strip() + '\n:gutter: 3\n\n' + '\n\n'.join(cards) + '\n::::' + + content = re.sub(r'\n(?P.*?)\n', + restore_grid, content, flags=re.DOTALL) + + def restore_figure(match): + # The PNG works in repository previews; Sphinx can select SVG or PNG. + image_path = str(PurePosixPath(match['path']).with_suffix('.*')) + return (f':::{{figure}} {image_path}\n:alt: {match["alt"]}\n' + f':width: {match["width"]}\n:align: center\n\n{match["caption"]}\n:::') + + content = re.sub( + r'\n\n' + r'!\[(?P.*?)\]\((?P.*?)\)\n\n(?P.*?)\n\n', + restore_figure, content, flags=re.DOTALL) + + def restore_dropdown(match): + title = re.sub(r'(.*?)', r'`\1`', html.unescape(match['title'])) + return f':::{{dropdown}} {title}\n:animate: fade-in-slide-down\n\n{match["body"].strip()}\n:::' + + content = re.sub(r'
\n(?P.*?)</summary>\n(?P<body>.*?)\n</details>', + restore_dropdown, content, flags=re.DOTALL) + for filename in ['card-source.md.template', 'table-source.md.template']: + app.env.note_dependency(str(DOCUMENTATION_DIRECTORY / 'python_example_templates' / filename)) + source[0] = content + + +def setup(app): + """Use readable source Markdown for all Sphinx output formats.""" + app.connect('source-read', prepare_sphinx_source) + return {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True} + + +def main(): + """Update or check generated Markdown without installing documentation tools.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--update-examples', action='store_true', help='Refresh example sections from the catalog.') + arguments = parser.parse_args() + catalog = json.loads((DOCUMENTATION_DIRECTORY / 'python_examples.json').read_text(encoding='utf-8')) + for path in sorted((DOCUMENTATION_DIRECTORY / 'source').glob('*.md')): + source = path.read_text(encoding='utf-8') + updated = update_example_blocks(source, catalog, check_only=not arguments.update_examples) + if arguments.update_examples and updated != source: + path.write_text(updated, encoding='utf-8') + print('Python example Markdown updated.' if arguments.update_examples else 'Python example Markdown is current.') + + +if __name__ == '__main__': + main() diff --git a/docs/python_example_templates/card-source.md.template b/docs/python_example_templates/card-source.md.template new file mode 100644 index 00000000..a36eb74b --- /dev/null +++ b/docs/python_example_templates/card-source.md.template @@ -0,0 +1,12 @@ +**[$title](../../examples/$path)** + +$description + +`examples/$path` + +<details> +<summary>Run this example</summary> + +**Requires:** $requirements + +$data_instructions$run_instructions</details> diff --git a/docs/python_example_templates/card.md.jinja b/docs/python_example_templates/card.md.jinja new file mode 100644 index 00000000..28e00c77 --- /dev/null +++ b/docs/python_example_templates/card.md.jinja @@ -0,0 +1,61 @@ +:::::{card} Python example{% if examples | length > 1 %}s{% endif %} +:class-card: python-example +{% if examples | length > 3 %} + +{% include 'table.md.jinja' %} + +:::{dropdown} Run these examples +{% for example in examples %} + +**{{ example.title }}** — Requires: {{ example.requirements }} +{% if example.data %} + +Data: {{ example.data }} See the [dataset setup instructions]({{ example.data_url }}). +{% endif %} +{% if example.run %} + +{{ example.run_note | default('From the repository root, with the repository version of PyGAD installed:') }} + +```console +{{ example.run }} +``` +{% endif %} +{% endfor %} +::: +{% else %} +{% for example in examples %} + +**{{ example.title }}** + +{{ example.description }} + +`examples/{{ example.path }}` + +:::{dropdown} Run this example + +**Requires:** {{ example.requirements }} +{% if example.data %} + +**Data:** {{ example.data }} See the [dataset setup instructions]({{ example.data_url }}). Use the repository folder layout rather than downloading this script alone. +{% endif %} +{% if example.run %} + +{{ example.run_note | default('From the repository root, with the repository version of PyGAD installed:') }} + +```console +{{ example.run }} +``` +{% endif %} +::: +{% if examples | length == 1 %} + ++++ +{% endif %} +[View script on GitHub]({{ example.source_url }}){% if example.download | default(true) %} · {download}`Download Python script <{{ example.download_path }}>`{% else %} · [Open example folder]({{ example.folder_url }}){% endif %} +{% if not loop.last %} + +--- +{% endif %} +{% endfor %} +{% endif %} +::::: diff --git a/docs/python_example_templates/index.md.jinja b/docs/python_example_templates/index.md.jinja new file mode 100644 index 00000000..8840d91c --- /dev/null +++ b/docs/python_example_templates/index.md.jinja @@ -0,0 +1,5 @@ +{% for category, examples in categories %} +## {{ category }} + +{% include 'table.md.jinja' %} +{% endfor %} diff --git a/docs/python_example_templates/table-source.md.template b/docs/python_example_templates/table-source.md.template new file mode 100644 index 00000000..6f84e5b1 --- /dev/null +++ b/docs/python_example_templates/table-source.md.template @@ -0,0 +1 @@ +| [$path](../../examples/$path) | **$title.** $description | $links | diff --git a/docs/python_example_templates/table.md.jinja b/docs/python_example_templates/table.md.jinja new file mode 100644 index 00000000..dbbb30e2 --- /dev/null +++ b/docs/python_example_templates/table.md.jinja @@ -0,0 +1,5 @@ +| Python script | What it shows | Related information | +| --- | --- | --- | +{% for example in examples -%} +| [{{ example.path }}]({{ example.source_url }}) | **{{ example.title }}.** {{ example.description }}{% if example.data %} Requires external data.{% endif %} | [Guide]({{ example.guide }}){% if example.download | default(true) %} · {download}`Download <{{ example.download_path }}>`{% else %} · [Folder]({{ example.folder_url }}) · [Data setup]({{ example.data_url }}){% endif %} | +{% endfor %} diff --git a/docs/python_examples.json b/docs/python_examples.json new file mode 100644 index 00000000..e285ba82 --- /dev/null +++ b/docs/python_examples.json @@ -0,0 +1,781 @@ +[ + { + "path": "example.py", + "title": "First GA run", + "description": "Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA.", + "category": "Getting Started", + "guide": "steps_to_use.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example.py" + }, + { + "path": "example_initial_population.py", + "title": "Initial populations", + "description": "Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_initial_population.py" + }, + { + "path": "example_gene_space.py", + "title": "Gene spaces", + "description": "Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_space.py" + }, + { + "path": "example_gene_constraint.py", + "title": "Gene constraints", + "description": "Filter gene candidates with constraints that depend on other genes.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_constraint.py" + }, + { + "path": "example_duplicate_gene_repair.py", + "title": "Duplicate repair", + "description": "Repair duplicates through a chain of replacements while respecting each gene space.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_duplicate_gene_repair.py" + }, + { + "path": "example_gene_type_conversion.py", + "title": "Gene types and rounding", + "description": "Preserve mixed numeric types, apply precision, and convert custom mutation outputs.", + "category": "Population and Genes", + "guide": "gene_values.md", + "requirements": "PyGAD", + "run": "python examples/example_gene_type_conversion.py" + }, + { + "path": "example_dynamic_population_size.py", + "title": "Changing population size", + "description": "Adjust the population and related runtime settings during evolution.", + "category": "Population and Genes", + "guide": "generations.md", + "requirements": "PyGAD", + "run": "python examples/example_dynamic_population_size.py" + }, + { + "path": "example_custom_operators.py", + "title": "Custom GA operators", + "description": "Implement parent selection, crossover, and mutation functions.", + "category": "Operators and Configuration", + "guide": "user_defined_operators.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_custom_operators.py" + }, + { + "path": "example_constructor_parameters.py", + "title": "Constructor settings and random seeds", + "description": "Use callable fitness signatures, NumPy counts, and independent seeded GA instances.", + "category": "Operators and Configuration", + "guide": "pygad.md", + "requirements": "PyGAD", + "run": "python examples/example_constructor_parameters.py" + }, + { + "path": "example_fitness_batch_size.py", + "title": "Batch fitness", + "description": "Return one fitness result per solution, including a shorter final batch.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/example_fitness_batch_size.py" + }, + { + "path": "example_parallel_processing.py", + "title": "Parallel fitness", + "description": "Evaluate population fitness with process workers and report the run time.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/example_parallel_processing.py" + }, + { + "path": "benchmarks/parallel_processing.py", + "title": "Compare fitness execution modes", + "description": "Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation.", + "category": "Fitness and Parallel Processing", + "guide": "fitness_calculation.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/parallel_processing.py --workload cpu" + }, + { + "path": "example_fitness_wrapper.py", + "title": "Extra fitness arguments", + "description": "Wrap a fitness function to pass additional values while preserving its PyGAD signature.", + "category": "Fitness and Parallel Processing", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_fitness_wrapper.py" + }, + { + "path": "pygad_lifecycle.py", + "title": "Lifecycle callbacks", + "description": "Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks.", + "category": "Lifecycle and Saved Runs", + "guide": "lifecycle.md", + "requirements": "PyGAD", + "run": "python examples/pygad_lifecycle.py" + }, + { + "path": "example_lifecycle_methods.py", + "title": "Callbacks as methods", + "description": "Implement fitness and lifecycle callbacks with bound methods.", + "category": "Lifecycle and Saved Runs", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_lifecycle_methods.py" + }, + { + "path": "example_lifecycle_classes.py", + "title": "Callbacks as callable classes", + "description": "Implement fitness and lifecycle callbacks with callable class instances.", + "category": "Lifecycle and Saved Runs", + "guide": "custom_functions.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_lifecycle_classes.py" + }, + { + "path": "example_summary.py", + "title": "Text lifecycle summary", + "description": "Print the configured GA stages and their parameters.", + "category": "Lifecycle and Saved Runs", + "guide": "pygad_more.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_summary.py" + }, + { + "path": "example_logger.py", + "title": "Logging", + "description": "Send progress and GA messages to a configured logger.", + "category": "Lifecycle and Saved Runs", + "guide": "logging.md", + "requirements": "PyGAD", + "run": "python examples/example_logger.py" + }, + { + "path": "example_repeated_runs.py", + "title": "Repeated runs and checkpoints", + "description": "Continue from a saved GA and inspect the actual generation numbers in its histories.", + "category": "Lifecycle and Saved Runs", + "guide": "generations.md", + "requirements": "PyGAD", + "run": "python examples/example_repeated_runs.py" + }, + { + "path": "example_load_fitness_function.py", + "title": "Change a loaded fitness function", + "description": "Replace the fitness callable after loading, or start fresh when the objective changes.", + "category": "Lifecycle and Saved Runs", + "guide": "pygad.md", + "requirements": "PyGAD", + "run": "python examples/example_load_fitness_function.py" + }, + { + "path": "example_multi_objective.py", + "title": "NSGA-II optimization", + "description": "Optimize two objectives and inspect the resulting trade-offs.", + "category": "Multi-Objective Optimization", + "guide": "multi_objective.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_multi_objective.py" + }, + { + "path": "example_multi_objective_nsga3.py", + "title": "NSGA-III optimization", + "description": "Configure reference points and optimize two objectives with NSGA-III.", + "category": "Multi-Objective Optimization", + "guide": "multi_objective.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/example_multi_objective_nsga3.py" + }, + { + "path": "plots/example_plot_fitness.py", + "title": "Best-fitness curve", + "description": "Plot best fitness across generations on the Sphere benchmark.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_fitness.py" + }, + { + "path": "plots/example_plot_fitness_band.py", + "title": "Fitness band", + "description": "Plot per-generation minimum, mean, and maximum fitness with a shaded band.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_fitness_band.py" + }, + { + "path": "plots/example_plot_genes.py", + "title": "Gene histories", + "description": "Show how gene values change across saved generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_genes.py" + }, + { + "path": "plots/example_plot_lifecycle.py", + "title": "Configured lifecycle", + "description": "Draw detailed and compact lifecycle charts and export SVG and PNG files.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_lifecycle.py" + }, + { + "path": "plots/example_plot_new_solution_rate.py", + "title": "New-solution rate", + "description": "Count previously unseen solutions in each generation.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_new_solution_rate.py" + }, + { + "path": "plots/example_plot_non_dominated_hypervolume.py", + "title": "Hypervolume history", + "description": "Track the hypervolume of the non-dominated set across generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_non_dominated_hypervolume.py" + }, + { + "path": "plots/example_plot_pareto_front_curve_2d.py", + "title": "2D Pareto front", + "description": "Plot a two-objective Pareto front after NSGA-II optimization.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_curve_2d.py" + }, + { + "path": "plots/example_plot_pareto_front_curve_3d.py", + "title": "3D Pareto front", + "description": "Plot a three-objective Pareto front after NSGA-III optimization.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_curve_3d.py" + }, + { + "path": "plots/example_plot_pareto_front_evolution.py", + "title": "Pareto-front evolution", + "description": "Overlay the non-dominated fronts from selected generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_evolution.py" + }, + { + "path": "plots/example_plot_pareto_front_heatmap.py", + "title": "Pareto heatmap", + "description": "Compare objective values with a solutions-by-objectives heatmap.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_heatmap.py" + }, + { + "path": "plots/example_plot_pareto_front_pcp.py", + "title": "Parallel coordinates", + "description": "Compare Pareto solutions across objective axes.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_pcp.py" + }, + { + "path": "plots/example_plot_pareto_front_scatter_matrix.py", + "title": "Pareto scatter matrix", + "description": "Compare every pair of objectives in a many-objective run.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_pareto_front_scatter_matrix.py" + }, + { + "path": "plots/example_plot_population_diversity.py", + "title": "Population diversity", + "description": "Track mean pairwise distance between solutions across generations.", + "category": "Plots", + "guide": "visualize.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/plots/example_plot_population_diversity.py" + }, + { + "path": "example_generate_report.py", + "title": "PDF report", + "description": "Export the run configuration, summary, best solution, and applicable plots to PDF.", + "category": "Reports", + "guide": "pygad.md", + "requirements": "PyGAD with the report extra (Matplotlib and ReportLab)", + "run": "python examples/example_generate_report.py" + }, + { + "path": "benchmarks/example_classic_sphere.py", + "title": "Sphere", + "description": "Optimize the Sphere single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_sphere.py" + }, + { + "path": "benchmarks/example_classic_rastrigin.py", + "title": "Rastrigin", + "description": "Optimize the Rastrigin single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_rastrigin.py" + }, + { + "path": "benchmarks/example_classic_rosenbrock.py", + "title": "Rosenbrock", + "description": "Optimize the Rosenbrock single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_rosenbrock.py" + }, + { + "path": "benchmarks/example_classic_griewank.py", + "title": "Griewank", + "description": "Optimize the Griewank single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_griewank.py" + }, + { + "path": "benchmarks/example_classic_schwefel.py", + "title": "Schwefel", + "description": "Optimize the Schwefel single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_schwefel.py" + }, + { + "path": "benchmarks/example_classic_ackley.py", + "title": "Ackley", + "description": "Optimize the Ackley single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_ackley.py" + }, + { + "path": "benchmarks/example_classic_himmelblau.py", + "title": "Himmelblau", + "description": "Optimize the Himmelblau single-objective benchmark.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_classic_himmelblau.py" + }, + { + "path": "benchmarks/example_zdt1.py", + "title": "ZDT1", + "description": "Optimize the ZDT1 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt1.py" + }, + { + "path": "benchmarks/example_zdt2.py", + "title": "ZDT2", + "description": "Optimize the ZDT2 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt2.py" + }, + { + "path": "benchmarks/example_zdt3.py", + "title": "ZDT3", + "description": "Optimize the ZDT3 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt3.py" + }, + { + "path": "benchmarks/example_zdt4.py", + "title": "ZDT4", + "description": "Optimize the ZDT4 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt4.py" + }, + { + "path": "benchmarks/example_zdt6.py", + "title": "ZDT6", + "description": "Optimize the ZDT6 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/benchmarks/example_zdt6.py" + }, + { + "path": "benchmarks/example_dtlz1.py", + "title": "DTLZ1", + "description": "Optimize the DTLZ1 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz1.py" + }, + { + "path": "benchmarks/example_dtlz2.py", + "title": "DTLZ2", + "description": "Optimize the DTLZ2 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz2.py" + }, + { + "path": "benchmarks/example_dtlz3.py", + "title": "DTLZ3", + "description": "Optimize the DTLZ3 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz3.py" + }, + { + "path": "benchmarks/example_dtlz4.py", + "title": "DTLZ4", + "description": "Optimize the DTLZ4 problem and plot its Pareto front.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_dtlz4.py" + }, + { + "path": "benchmarks/example_knapsack.py", + "title": "Knapsack", + "description": "Select items to maximize value within a weight capacity.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_knapsack.py" + }, + { + "path": "benchmarks/example_tsp.py", + "title": "Travelling salesman", + "description": "Find a short tour using a permutation of four cities.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD", + "run": "python examples/benchmarks/example_tsp.py" + }, + { + "path": "quality_indicators/example_hypervolume.py", + "title": "Hypervolume", + "description": "Measure the objective-space volume dominated by the final population.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_hypervolume.py" + }, + { + "path": "quality_indicators/example_inverted_generational_distance.py", + "title": "Inverted generational distance", + "description": "Measure distance from a reference front to the approximation.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_inverted_generational_distance.py" + }, + { + "path": "quality_indicators/example_generational_distance.py", + "title": "Generational distance", + "description": "Measure distance from the approximation to a reference front.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_generational_distance.py" + }, + { + "path": "quality_indicators/example_spacing.py", + "title": "Spacing", + "description": "Measure how evenly the approximation points are spread.", + "category": "Quality Indicators", + "guide": "utils.md", + "requirements": "PyGAD", + "run": "python examples/quality_indicators/example_spacing.py" + }, + { + "path": "nn/example_regression.py", + "title": "Regression", + "description": "Fit a neural network to a small numeric regression problem.", + "category": "Neural Networks", + "guide": "nn_regression_1.md", + "requirements": "PyGAD", + "run": "python examples/nn/example_regression.py" + }, + { + "path": "nn/example_XOR_classification.py", + "title": "XOR classification", + "description": "Train a neural network on the four XOR inputs.", + "category": "Neural Networks", + "guide": "nn_xor.md", + "requirements": "PyGAD", + "run": "python examples/nn/example_XOR_classification.py" + }, + { + "path": "nn/example_classification.py", + "title": "Image classification", + "description": "Classify fruit images from prepared feature vectors.", + "category": "Neural Networks", + "guide": "nn_image_classification.md", + "requirements": "PyGAD", + "run": "cd examples/nn\npython example_classification.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "nn/example_regression_fish.py", + "title": "Fish-weight regression", + "description": "Predict fish weight from numeric measurements.", + "category": "Neural Networks", + "guide": "nn_regression_2.md", + "requirements": "PyGAD, pandas", + "run": "cd examples/nn\npython example_regression_fish.py", + "data": "examples/data/Fish.csv.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "gann/example_regression.py", + "title": "Regression", + "description": "Fit a neural network to a small numeric regression problem.", + "category": "Neural Networks with the GA", + "guide": "gann_regression_1.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/gann/example_regression.py" + }, + { + "path": "gann/example_XOR_classification.py", + "title": "XOR classification", + "description": "Train a neural network on the four XOR inputs.", + "category": "Neural Networks with the GA", + "guide": "gann_xor.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/gann/example_XOR_classification.py" + }, + { + "path": "gann/example_classification.py", + "title": "Image classification", + "description": "Classify fruit images from prepared feature vectors.", + "category": "Neural Networks with the GA", + "guide": "gann_image_classification.md", + "requirements": "PyGAD, Matplotlib", + "run": "cd examples/gann\npython example_classification.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/gann/ so the relative data paths resolve:" + }, + { + "path": "gann/example_regression_fish.py", + "title": "Fish-weight regression", + "description": "Predict fish weight from numeric measurements.", + "category": "Neural Networks with the GA", + "guide": "gann_regression_2.md", + "requirements": "PyGAD, Matplotlib, pandas", + "run": "cd examples/gann\npython example_regression_fish.py", + "data": "examples/data/Fish.csv.", + "download": false, + "run_note": "From the repository root, change to examples/gann/ so the relative data paths resolve:" + }, + { + "path": "nn/extract_features.py", + "title": "Prepare image features", + "description": "Extract fruit-image features and write the arrays used by the dense classifiers.", + "category": "Neural Networks", + "guide": "nn_image_classification.md", + "requirements": "PyGAD, scikit-image", + "run": "cd examples/nn\npython extract_features.py", + "data": "The apple, lemon, mango, and raspberry folders under examples/data/Fruit360/.", + "download": false, + "run_note": "From the repository root, change to examples/nn/ so the relative data paths resolve:" + }, + { + "path": "cnn/example_image_classification.py", + "title": "Build a CNN", + "description": "Classify fruit images using prepared image arrays.", + "category": "Convolutional Networks", + "guide": "cnn.md", + "requirements": "PyGAD", + "run": "cd examples/cnn\npython example_image_classification.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/cnn/ so the relative data paths resolve:" + }, + { + "path": "gacnn/example_image_classification.py", + "title": "Optimize a CNN with the GA", + "description": "Classify fruit images using prepared image arrays.", + "category": "Convolutional Networks", + "guide": "gacnn.md", + "requirements": "PyGAD, Matplotlib", + "run": "cd examples/gacnn\npython example_image_classification.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/gacnn/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/regression_example.py", + "title": "Regression", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_regression.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "python examples/KerasGA/regression_example.py" + }, + { + "path": "KerasGA/XOR_classification.py", + "title": "XOR classification", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_xor.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "python examples/KerasGA/XOR_classification.py" + }, + { + "path": "KerasGA/image_classification_Dense.py", + "title": "Dense image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_image_dense.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython image_classification_Dense.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/image_classification_CNN.py", + "title": "Convolutional image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "Keras", + "guide": "kerasga_image_conv.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython image_classification_CNN.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "TorchGA/regression_example.py", + "title": "Regression", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_regression.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "python examples/TorchGA/regression_example.py" + }, + { + "path": "TorchGA/XOR_classification.py", + "title": "XOR classification", + "description": "Optimize neural-network weights with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_xor.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "python examples/TorchGA/XOR_classification.py" + }, + { + "path": "TorchGA/image_classification_Dense.py", + "title": "Dense image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_image_dense.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras, PyTorch", + "run": "cd examples/TorchGA\npython image_classification_Dense.py", + "data": "examples/data/dataset_features.npy and examples/data/outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/TorchGA/ so the relative data paths resolve:" + }, + { + "path": "TorchGA/image_classification_CNN.py", + "title": "Convolutional image classifier", + "description": "Train an image classifier with the genetic algorithm.", + "category": "PyTorch", + "guide": "torchga_image_conv.md", + "requirements": "PyGAD, Matplotlib, PyTorch", + "run": "cd examples/TorchGA\npython image_classification_CNN.py", + "data": "examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy.", + "download": false, + "run_note": "From the repository root, change to examples/TorchGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/cancer_dataset.py", + "title": "Image-directory classification", + "description": "Use directory-based image input for a two-class Keras CNN.", + "category": "Keras", + "guide": "kerasga_image_datagen.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython cancer_dataset.py", + "data": "benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "KerasGA/cancer_dataset_generator.py", + "title": "Batched image-directory classification", + "description": "Use directory-based image input for a two-class Keras CNN.", + "category": "Keras", + "guide": "kerasga_image_datagen.md", + "requirements": "PyGAD, Matplotlib, TensorFlow/Keras", + "run": "cd examples/KerasGA\npython cancer_dataset_generator.py", + "data": "benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/.", + "download": false, + "run_note": "From the repository root, change to examples/KerasGA/ so the relative data paths resolve:" + }, + { + "path": "clustering/example_clustering_2.py", + "title": "2-cluster example", + "description": "Optimize 2 cluster centers for generated two-dimensional data.", + "category": "Clustering", + "guide": "pygad.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/clustering/example_clustering_2.py" + }, + { + "path": "clustering/example_clustering_3.py", + "title": "3-cluster example", + "description": "Optimize 3 cluster centers for generated two-dimensional data.", + "category": "Clustering", + "guide": "pygad.md", + "requirements": "PyGAD, Matplotlib", + "run": "python examples/clustering/example_clustering_3.py" + }, + { + "path": "example_travelling_salesman.ipynb", + "title": "Travelling-salesman Colab notebook", + "description": "Explore a city-tour problem using a user-supplied CSV and interactive maps.", + "category": "Benchmarks", + "guide": "benchmarks.md", + "requirements": "PyGAD, Google Colab, NumPy, pandas, Plotly, folium, and geopy", + "run": "", + "data": "The notebook reads /content/sample_data/startbucks.csv in Google Colab. Supply a compatible CSV at that path. The original data source was not recorded. For local Jupyter use, adapt the Colab-specific imports and CSV path.", + "download": false + } +] diff --git a/docs/python_examples.py b/docs/python_examples.py new file mode 100644 index 00000000..30b50e55 --- /dev/null +++ b/docs/python_examples.py @@ -0,0 +1,110 @@ +"""Render example cards and their index from the same catalog and templates.""" + +import json +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +from docutils import nodes +from docutils.statemachine import StringList +from jinja2 import Environment, FileSystemLoader +from sphinx.errors import ExtensionError +from sphinx.util.docutils import SphinxDirective + + +def load_example_catalog(app): + """Validate the catalog against the scripts shipped in the repository.""" + documentation_directory = Path(app.confdir).parent + examples_directory = documentation_directory.parent / 'examples' + catalog_path = documentation_directory / 'python_examples.json' + catalog = json.loads(catalog_path.read_text(encoding='utf-8')) + examples_by_path = {} + for example in catalog: + path = example['path'] + if path in examples_by_path: + raise ExtensionError(f"Duplicate Python example in the catalog: {path}") + resolved_path = (examples_directory / path).resolve() + try: + resolved_path.relative_to(examples_directory.resolve()) + except ValueError: + raise ExtensionError(f"Python example must be inside examples/: {path}") + if not resolved_path.is_file(): + raise ExtensionError(f"Python example does not exist in examples/: {path}") + if not (Path(app.confdir) / example['guide']).is_file(): + raise ExtensionError(f"Documentation guide does not exist for Python example: {path}") + examples_by_path[path] = example + + missing_examples = sorted(path.relative_to(examples_directory).as_posix() + for path in examples_directory.rglob('*.py') + if path.relative_to(examples_directory).as_posix() not in examples_by_path) + if missing_examples: + raise ExtensionError('Add these scripts to docs/python_examples.json: ' + ', '.join(missing_examples)) + app.python_examples_catalog = catalog + + +class PythonExamples(SphinxDirective): + """Show cards for catalog paths, or a compact index of all examples.""" + + has_content = True + render_examples_index = False + + def run(self): + documentation_directory = Path(self.env.srcdir).parent + self.env.note_dependency(str(documentation_directory / 'python_examples.json')) + examples_by_path = {example['path']: example for example in self.env.app.python_examples_catalog} + if self.render_examples_index: + examples = list(examples_by_path.values()) + else: + paths = [line.strip() for line in self.content if line.strip()] + if not paths: + raise self.error('List at least one path relative to examples/.') + try: + examples = [examples_by_path[path] for path in paths] + except KeyError as error: + raise self.error(f"Unknown Python example in docs/python_examples.json: {error.args[0]}") + + source_revision = quote(self.config.python_examples_source_revision, safe='') + repository_url = 'https://github.com/ahmedfgad/GeneticAlgorithmPython' + examples_with_links = [] + for example in examples: + folder = PurePosixPath(example['path']).parent.as_posix() + folder_url = f"{repository_url}/tree/{source_revision}/examples" + if folder != '.': + folder_url += '/' + quote(folder) + examples_with_links.append(dict( + example, + source_url=f"{repository_url}/blob/{source_revision}/examples/{quote(example['path'])}", + folder_url=folder_url, + data_url=f"{repository_url}/blob/{source_revision}/examples/data/README.md", + download_path='../../examples/' + example['path'])) + template_name = 'index.md.jinja' if self.render_examples_index else 'card.md.jinja' + templates_directory = documentation_directory / 'python_example_templates' + self.env.note_dependency(str(templates_directory / template_name)) + self.env.note_dependency(str(templates_directory / 'table.md.jinja')) + template_environment = Environment(loader=FileSystemLoader(str(templates_directory)), + autoescape=False, keep_trailing_newline=True) + categories = [(category, [example for example in examples_with_links + if example['category'] == category]) + for category in dict.fromkeys(example['category'] for example in examples_with_links)] + rendered_content = template_environment.get_template(template_name).render( + examples=examples_with_links, categories=categories) + container = nodes.container() + # MyST parses the generated Markdown through its normal Sphinx state. + # Native cards, dropdowns, and downloads also support non-HTML builders. + self.state.nested_parse(StringList(rendered_content.splitlines()), 0, + container, match_titles=self.render_examples_index) + return container.children + + +class PythonExamplesIndex(PythonExamples): + """Show every catalog entry in topic tables, with links back to guides.""" + + render_examples_index = True + + +def setup(app): + """Register the shared example catalog and its two documentation directives.""" + app.add_config_value('python_examples_source_revision', 'master', 'env') + app.connect('builder-inited', load_example_catalog) + app.add_directive('python-examples', PythonExamples) + app.add_directive('python-examples-index', PythonExamplesIndex) + return {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True} diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 7f356d08..b9cabe76 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -41,3 +41,104 @@ details.sd-dropdown > summary.sd-summary-title code, details.sd-dropdown > .sd-summary-title code { font-weight: 600; } + +/* Shared cards connecting documentation topics to complete Python examples. */ +.python-example { + border-left: 3px solid var(--color-brand-primary); +} + +.python-example .sd-card-header { + background: var(--color-background-secondary); + font-weight: 600; +} + +.python-example code { + overflow-wrap: anywhere; + white-space: normal; +} + +.python-example pre code { + white-space: pre; + overflow-wrap: normal; +} + +.python-example .sd-card-footer { + background: var(--color-background-primary); +} + +/* Keep the release-page header compact so the newest notes are in view. */ +#release-history > p > img[alt="PYGAD-LOGO"] { + max-width: 12rem; + height: auto; +} + +/* Release-history controls follow the theme in light and dark mode. */ +.release-history-navigation { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0; +} + +.release-history-navigation select { + max-width: 100%; + padding: 0.5rem; + border: 1px solid var(--color-brand-primary); + border-radius: 0.35rem; + background: var(--color-background-primary); + color: var(--color-foreground-primary); + font: inherit; +} + +.release-history-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 2rem 0; +} + +.release-history-controls p { + flex-basis: 100%; + margin: 0; + color: var(--color-foreground-secondary); +} + +.release-history-controls button { + padding: 0.65rem 1rem; + border: 1px solid var(--color-brand-primary); + border-radius: 0.35rem; + background: var(--color-background-primary); + color: var(--color-brand-content); + font: inherit; + cursor: pointer; +} + +.release-history-controls button:hover { + background: var(--color-background-secondary); +} + +.release-history-controls button:focus-visible, +.release-history-navigation select:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 3px; +} + +/* Some theme display rules otherwise override the native hidden attribute. */ +#release-history > section[hidden], +.toc-tree li[hidden], +.release-history-controls button[hidden] { + display: none; +} + +/* Printed and exported notes always include the complete release history. */ +@media print { + #release-history > section[hidden] { + display: block; + } + + .release-history-controls, + .release-history-navigation { + display: none; + } +} diff --git a/docs/source/_static/release-history.js b/docs/source/_static/release-history.js new file mode 100644 index 00000000..8d2559f3 --- /dev/null +++ b/docs/source/_static/release-history.js @@ -0,0 +1,134 @@ +// Show recent release notes first, with older notes available on the same page. +// All notes remain in the HTML for documentation search, printing, and readers +// without JavaScript. Existing release links reveal their target automatically. +window.addEventListener("DOMContentLoaded", function () { + var releaseHistory = document.getElementById("release-history"); + if (!releaseHistory) { + return; + } + + var releaseSections = Array.from(releaseHistory.children).filter(function (element) { + return element.tagName === "SECTION"; + }); + var releasesPerPage = 10; + if (releaseSections.length <= releasesPerPage) { + return; + } + + var visibleReleaseCount = releasesPerPage; + var contentsLinks = Array.from(document.querySelectorAll(".toc-tree a")); + var navigation = document.createElement("div"); + navigation.className = "release-history-navigation"; + var jumpLabel = document.createElement("label"); + jumpLabel.htmlFor = "release-history-version"; + jumpLabel.textContent = "Jump to a release"; + navigation.appendChild(jumpLabel); + var releaseSelector = document.createElement("select"); + releaseSelector.id = "release-history-version"; + var placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select a release"; + releaseSelector.appendChild(placeholder); + releaseSections.forEach(function (section) { + var option = document.createElement("option"); + option.value = section.id; + option.textContent = section.querySelector("h2").firstChild.textContent.trim(); + releaseSelector.appendChild(option); + }); + navigation.appendChild(releaseSelector); + releaseHistory.insertBefore(navigation, releaseSections[0]); + + var controls = document.createElement("div"); + controls.className = "release-history-controls"; + controls.setAttribute("role", "group"); + controls.setAttribute("aria-label", "Older release notes"); + + var status = document.createElement("p"); + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + controls.appendChild(status); + + var showMoreButton = document.createElement("button"); + showMoreButton.type = "button"; + showMoreButton.textContent = "Show 10 more releases"; + controls.appendChild(showMoreButton); + + var showAllButton = document.createElement("button"); + showAllButton.type = "button"; + showAllButton.textContent = "Show all releases"; + controls.appendChild(showAllButton); + releaseHistory.appendChild(controls); + + function updateVisibleReleases() { + releaseSections.forEach(function (section, index) { + section.hidden = index >= visibleReleaseCount; + }); + contentsLinks.forEach(function (link) { + var section = document.getElementById(link.hash.slice(1)); + var item = link.closest("li"); + if (section && releaseSections.indexOf(section) !== -1 && item) { + item.hidden = section.hidden; + } + }); + var remainingReleaseCount = releaseSections.length - visibleReleaseCount; + status.textContent = "Showing " + visibleReleaseCount + " of " + + releaseSections.length + " release entries."; + var nextReleaseCount = Math.min(releasesPerPage, remainingReleaseCount); + showMoreButton.textContent = "Show " + nextReleaseCount + " more " + + (nextReleaseCount === 1 ? "release" : "releases"); + showMoreButton.hidden = remainingReleaseCount === 0; + showAllButton.hidden = remainingReleaseCount === 0; + } + + function showOlderReleases(count) { + var firstNewSection = releaseSections[visibleReleaseCount]; + visibleReleaseCount = Math.min(count, releaseSections.length); + updateVisibleReleases(); + // Move keyboard focus to the newly revealed notes, rather than leaving + // it on a button that moved below them or disappeared after "Show all". + var heading = firstNewSection.querySelector("h2"); + heading.setAttribute("tabindex", "-1"); + heading.focus({ preventScroll: true }); + heading.scrollIntoView({ block: "start" }); + } + + function revealLinkedRelease() { + var targetId; + try { + targetId = decodeURIComponent(window.location.hash.slice(1)); + } catch (error) { + return; + } + var target = document.getElementById(targetId); + var releaseIndex = releaseSections.findIndex(function (section) { + return target && section.contains(target); + }); + if (releaseIndex === -1) { + return; + } + releaseSelector.value = releaseSections[releaseIndex].id; + if (releaseIndex >= visibleReleaseCount) { + visibleReleaseCount = Math.min(releaseSections.length, + Math.ceil((releaseIndex + 1) / releasesPerPage) * releasesPerPage); + updateVisibleReleases(); + } + target.scrollIntoView({ block: "start" }); + } + + showMoreButton.addEventListener("click", function () { + showOlderReleases(visibleReleaseCount + releasesPerPage); + }); + showAllButton.addEventListener("click", function () { + showOlderReleases(releaseSections.length); + }); + releaseSelector.addEventListener("change", function () { + if (releaseSelector.value) { + var releaseHash = "#" + releaseSelector.value; + window.location.hash = releaseHash; + revealLinkedRelease(); + } + }); + window.addEventListener("hashchange", revealLinkedRelease); + updateVisibleReleases(); + revealLinkedRelease(); +}); diff --git a/docs/source/adaptive_mutation.md b/docs/source/adaptive_mutation.md index 921a8dd6..8a937473 100644 --- a/docs/source/adaptive_mutation.md +++ b/docs/source/adaptive_mutation.md @@ -34,7 +34,7 @@ In [PyGAD 2.10.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-1 1. In the constructor of the `pygad.GA` class, set `mutation_type="adaptive"` to specify that the type of mutation is adaptive. 2. Specify the mutation rates for the low and high quality solutions using one of these 3 parameters according to your preference: `mutation_probability`, `mutation_num_genes`, and `mutation_percent_genes`. Please check the [documentation of each of these parameters](https://pygad.readthedocs.io/en/latest/pygad.html#init) for more information. -For permutations with `allow_duplicate_genes=False` and no unused values in `gene_space`, both adaptive mutation controls use a compatible-swap fallback. The configured rates select the genes that can initiate mutation. A fallback swap changes two positions and can involve a partner that was not selected by the mutation rate. Each gene participates in at most one fallback swap per mutation pass. See {ref}`Mutation Methods <mutation-methods>` for the type, gene-space, and constraint checks. +For permutations with `allow_duplicate_genes=False` and no unused values in `gene_space`, both adaptive mutation controls use a compatible-swap fallback. The configured rates select the genes that can initiate mutation. A fallback swap changes two positions and can involve a partner that was not selected by the mutation rate. Each gene participates in at most one fallback swap per mutation pass. See [Mutation Methods](utils.md#mutation-methods) for the type, gene-space, and constraint checks. When adaptive mutation is used, then the value assigned to any of the 3 parameters can be of any of these data types: diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 6caec365..f0e1e09b 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -10,7 +10,7 @@ Attributes for setting up the GA (some are class attributes and others are set o ZDT classes also have a `pareto_front(num_points)` method that returns true-front reference points. Pass these to the IGD or GD indicators as `reference_front`. -A runnable example per benchmark lives under `examples/benchmarks/`. +Complete scripts are linked beside each benchmark family and listed in the [Examples index](examples.md). ## Single-Objective Problems @@ -26,6 +26,91 @@ Available in `pygad.benchmarks.classic`: | `Ackley` | f(0, ..., 0) = 0 | `(-32.768, 32.768)` | | `Himmelblau` | four equal minima at f = 0 (2D only) | `(-5.0, 5.0)` | +<!-- python-examples +benchmarks/example_classic_sphere.py +benchmarks/example_classic_rastrigin.py +benchmarks/example_classic_rosenbrock.py +benchmarks/example_classic_griewank.py +benchmarks/example_classic_schwefel.py +benchmarks/example_classic_ackley.py +benchmarks/example_classic_himmelblau.py +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_classic_sphere.py](../../examples/benchmarks/example_classic_sphere.py) | **Sphere.** Optimize the Sphere single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rastrigin.py](../../examples/benchmarks/example_classic_rastrigin.py) | **Rastrigin.** Optimize the Rastrigin single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rosenbrock.py](../../examples/benchmarks/example_classic_rosenbrock.py) | **Rosenbrock.** Optimize the Rosenbrock single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_griewank.py](../../examples/benchmarks/example_classic_griewank.py) | **Griewank.** Optimize the Griewank single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_schwefel.py](../../examples/benchmarks/example_classic_schwefel.py) | **Schwefel.** Optimize the Schwefel single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_ackley.py](../../examples/benchmarks/example_classic_ackley.py) | **Ackley.** Optimize the Ackley single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_himmelblau.py](../../examples/benchmarks/example_classic_himmelblau.py) | **Himmelblau.** Optimize the Himmelblau single-objective benchmark. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**Sphere** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_sphere.py +``` + +**Rastrigin** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_rastrigin.py +``` + +**Rosenbrock** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_rosenbrock.py +``` + +**Griewank** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_griewank.py +``` + +**Schwefel** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_schwefel.py +``` + +**Ackley** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_ackley.py +``` + +**Himmelblau** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_classic_himmelblau.py +``` + +</details> + +<!-- /python-examples --> + ## Multi-Objective Problems (ZDT family) In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5, 5]` for the rest). @@ -38,6 +123,71 @@ In `pygad.benchmarks.zdt`. Two objectives, variables in `[0, 1]` (ZDT4 uses `[-5 | `ZDT4` | convex, many local minima in the search space | | `ZDT6` | non-uniform | +<!-- python-examples +benchmarks/example_zdt1.py +benchmarks/example_zdt2.py +benchmarks/example_zdt3.py +benchmarks/example_zdt4.py +benchmarks/example_zdt6.py +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_zdt1.py](../../examples/benchmarks/example_zdt1.py) | **ZDT1.** Optimize the ZDT1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt2.py](../../examples/benchmarks/example_zdt2.py) | **ZDT2.** Optimize the ZDT2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt3.py](../../examples/benchmarks/example_zdt3.py) | **ZDT3.** Optimize the ZDT3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt4.py](../../examples/benchmarks/example_zdt4.py) | **ZDT4.** Optimize the ZDT4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt6.py](../../examples/benchmarks/example_zdt6.py) | **ZDT6.** Optimize the ZDT6 problem and plot its Pareto front. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**ZDT1** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt1.py +``` + +**ZDT2** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt2.py +``` + +**ZDT3** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt3.py +``` + +**ZDT4** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt4.py +``` + +**ZDT6** — Requires: PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_zdt6.py +``` + +</details> + +<!-- /python-examples --> + ## Many-Objective Problems (DTLZ family) In `pygad.benchmarks.dtlz`. Any number of objectives `M`. Decision variables: `M + k - 1`, where `k` is the distance-variable count. @@ -49,6 +199,61 @@ In `pygad.benchmarks.dtlz`. Any number of objectives `M`. Decision variables: `M | `DTLZ3` | 3 | unit sphere with hard multimodal g-function | | `DTLZ4` | 3 | unit sphere with strong bias toward one corner | +<!-- python-examples +benchmarks/example_dtlz1.py +benchmarks/example_dtlz2.py +benchmarks/example_dtlz3.py +benchmarks/example_dtlz4.py +--> + +**Python examples** + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_dtlz1.py](../../examples/benchmarks/example_dtlz1.py) | **DTLZ1.** Optimize the DTLZ1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz2.py](../../examples/benchmarks/example_dtlz2.py) | **DTLZ2.** Optimize the DTLZ2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz3.py](../../examples/benchmarks/example_dtlz3.py) | **DTLZ3.** Optimize the DTLZ3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz4.py](../../examples/benchmarks/example_dtlz4.py) | **DTLZ4.** Optimize the DTLZ4 problem and plot its Pareto front. | [Guide](benchmarks.md) | + +<details> +<summary>Run these examples</summary> + +**DTLZ1** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz1.py +``` + +**DTLZ2** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz2.py +``` + +**DTLZ3** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz3.py +``` + +**DTLZ4** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_dtlz4.py +``` + +</details> + +<!-- /python-examples --> + ## Combinatorial Problems Two combinatorial benchmarks: 0/1 `Knapsack` and `TSP`. @@ -79,6 +284,36 @@ ga = pygad.GA( ga.run() ``` +<!-- python-examples +benchmarks/example_knapsack.py +--> + +**Python example** + +**[Knapsack](../../examples/benchmarks/example_knapsack.py)** + +Select items to maximize value within a weight capacity. + +`examples/benchmarks/example_knapsack.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_knapsack.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(tsp-benchmark)= +--> ### Travelling Salesman Problem In `pygad.benchmarks.tsp`. Build `TSP` from either a 2D `coordinates` array or a square `distance_matrix`. A solution is a permutation of city indices and the fitness is the negative tour length (the tour closes back to the start). Non-permutation candidates get a large negative penalty. @@ -125,6 +360,49 @@ ga = pygad.GA( ga.run() ``` +<!-- python-examples +benchmarks/example_tsp.py +example_travelling_salesman.ipynb +--> + +**Python examples** + +**[Travelling salesman](../../examples/benchmarks/example_tsp.py)** + +Find a short tour using a permutation of four cities. + +`examples/benchmarks/example_tsp.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/example_tsp.py +``` + +</details> + +**[Travelling-salesman Colab notebook](../../examples/example_travelling_salesman.ipynb)** + +Explore a city-tour problem using a user-supplied CSV and interactive maps. + +`examples/example_travelling_salesman.ipynb` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Google Colab, NumPy, pandas, Plotly, folium, and geopy + +**Data:** The notebook reads /content/sample_data/startbucks.csv in Google Colab. Supply a compatible CSV at that path. The original data source was not recorded. For local Jupyter use, adapt the Colab-specific imports and CSV path. See the [dataset setup instructions](../../examples/data/README.md). + +</details> + +<!-- /python-examples --> + ## Example: SOO ```python diff --git a/docs/source/cnn.md b/docs/source/cnn.md index 6cac8c35..9b72c10f 100644 --- a/docs/source/cnn.md +++ b/docs/source/cnn.md @@ -512,3 +512,33 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +<!-- python-examples +cnn/example_image_classification.py +--> + +**Python example** + +**[Build a CNN](../../examples/cnn/example_image_classification.py)** + +Classify fruit images using prepared image arrays. + +`examples/cnn/example_image_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/cnn/ so the relative data paths resolve: + +```console +cd examples/cnn +python example_image_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/conf.py b/docs/source/conf.py index ebf4c2da..b656f6eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,19 +9,38 @@ copyright = '2026, Ahmed Fawzy Gad' author = 'Ahmed Fawzy Gad' -# The full version, including alpha/beta/rc tags. -release = '3.7.0' - master_doc = 'index' # -- General configuration --------------------------------------------------- +import os +from pathlib import Path +import subprocess +import sys +import runpy + +# Read the package version without importing optional package dependencies. +release = runpy.run_path(str(Path(__file__).resolve().parents[2] / 'pygad' / '_version.py'))['__version__'] + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Pin example links to the checkout used to build the documentation. This also +# works for release branches and older documentation versions on Read the Docs. +try: + python_examples_source_revision = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], cwd=Path(__file__).resolve().parent, + text=True, stderr=subprocess.DEVNULL).strip() +except (OSError, subprocess.CalledProcessError): + python_examples_source_revision = os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'master') + # The documentation is written in Markdown and read directly by Sphinx # through the MyST parser. There is no Markdown-to-reStructuredText step. extensions = [ 'myst_parser', 'sphinx_design', 'sphinx_copybutton', + 'python_examples', + 'markdown_compatibility', ] # Read both Markdown and reStructuredText. Markdown is the source of truth. @@ -48,10 +67,10 @@ 'dollarmath', ] -# Do NOT set myst_heading_anchors. Leaving it unset keeps Sphinx using the -# docutils section IDs (for example "PyGAD 2.18.0" -> "pygad-2-18-0"), which -# are the anchors the live site already links to. Turning it on would switch -# to GitHub-style slugs and break those links. +# Resolve Markdown links such as [Plot Lifecycle](visualize.md#plot_lifecycle) +# using GitHub-style heading anchors at every heading level. MyST maps these +# anchors to the existing Sphinx section IDs, preserving published links. +myst_heading_anchors = 6 # -- Options for HTML output ------------------------------------------------- @@ -59,7 +78,7 @@ html_title = 'PyGAD' html_static_path = ['_static'] html_css_files = ['custom.css'] -html_js_files = ['scroll-sidebar.js'] +html_js_files = ['scroll-sidebar.js', 'release-history.js'] html_theme_options = { 'light_css_variables': { diff --git a/docs/source/custom_functions.md b/docs/source/custom_functions.md index 6b3297ec..73ea134f 100644 --- a/docs/source/custom_functions.md +++ b/docs/source/custom_functions.md @@ -67,6 +67,33 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` +<!-- python-examples +example_fitness_wrapper.py +--> + +**Python example** + +**[Extra fitness arguments](../../examples/example_fitness_wrapper.py)** + +Wrap a fitness function to pass additional values while preserving its PyGAD signature. + +`examples/example_fitness_wrapper.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_fitness_wrapper.py +``` + +</details> + +<!-- /python-examples --> + ## Assign Methods The next example has all the methods defined inside the class `Test`. All of the methods accept an additional parameter representing the method's object of the class `Test`. @@ -118,6 +145,33 @@ ga_instance = pygad.GA(num_generations=5, ga_instance.run() ``` +<!-- python-examples +example_lifecycle_methods.py +--> + +**Python example** + +**[Callbacks as methods](../../examples/example_lifecycle_methods.py)** + +Implement fitness and lifecycle callbacks with bound methods. + +`examples/example_lifecycle_methods.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_lifecycle_methods.py +``` + +</details> + +<!-- /python-examples --> + ## Assign a Class Besides functions and methods, you can pass an instance of a class. The class must implement the `__call__()` method, which makes its instances callable like a function. PyGAD calls the instance the same way it calls a function. @@ -191,3 +245,30 @@ ga_instance = pygad.GA(num_generations=10, ga_instance.run() ``` + +<!-- python-examples +example_lifecycle_classes.py +--> + +**Python example** + +**[Callbacks as callable classes](../../examples/example_lifecycle_classes.py)** + +Implement fitness and lifecycle callbacks with callable class instances. + +`examples/example_lifecycle_classes.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_lifecycle_classes.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/examples.md b/docs/source/examples.md new file mode 100644 index 00000000..5403549d --- /dev/null +++ b/docs/source/examples.md @@ -0,0 +1,195 @@ +# Examples + +Find complete Python scripts by topic, open their source on GitHub, or download examples that need no companion files. Each entry links back to its documentation guide. The same examples appear in **Python example** cards beside the relevant explanations in those guides. + +## Running the Examples + +For examples from this documentation revision, use the matching version of PyGAD. The PyGAD 3.8.0 examples can use the published package: + +```console +python -m pip install "pygad[visualize]==3.8.0" +``` + +For development examples, including features in the Unreleased notes, clone or download the matching repository revision, then install it from the repository root: + +```console +python -m pip install -e ".[visualize]" +``` + +Run a script using its path, for example: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +For a script downloaded separately, run `python` with the path where you saved it. It still requires a compatible installed version of PyGAD. Plotting examples need Matplotlib; PDF reports need `pygad[report]`. Keras and PyTorch examples need their respective frameworks. The **Run this example** dropdowns in the guides give additional requirements and working directories. + +Examples requiring datasets link to their folders instead of offering a standalone download. Their datasets are not bundled with PyGAD or the repository. Follow the linked data setup instructions and keep the repository directory layout. The TSP notebook is listed alongside the Python scripts; it uses Google Colab and a user-supplied CSV. Adapt its Colab-specific imports and CSV path before running it locally with Jupyter. + +Use documentation search or your browser's find command to locate a topic or filename on this page. + +<!-- python-examples-index --> + +## Getting Started + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example.py](../../examples/example.py) | **First GA run.** Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA. | [Guide](steps_to_use.md) | + +## Population and Genes + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_initial_population.py](../../examples/example_initial_population.py) | **Initial populations.** Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions. | [Guide](gene_values.md) | +| [example_gene_space.py](../../examples/example_gene_space.py) | **Gene spaces.** Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries. | [Guide](gene_values.md) | +| [example_gene_constraint.py](../../examples/example_gene_constraint.py) | **Gene constraints.** Filter gene candidates with constraints that depend on other genes. | [Guide](gene_values.md) | +| [example_duplicate_gene_repair.py](../../examples/example_duplicate_gene_repair.py) | **Duplicate repair.** Repair duplicates through a chain of replacements while respecting each gene space. | [Guide](gene_values.md) | +| [example_gene_type_conversion.py](../../examples/example_gene_type_conversion.py) | **Gene types and rounding.** Preserve mixed numeric types, apply precision, and convert custom mutation outputs. | [Guide](gene_values.md) | +| [example_dynamic_population_size.py](../../examples/example_dynamic_population_size.py) | **Changing population size.** Adjust the population and related runtime settings during evolution. | [Guide](generations.md) | + +## Operators and Configuration + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_custom_operators.py](../../examples/example_custom_operators.py) | **Custom GA operators.** Implement parent selection, crossover, and mutation functions. | [Guide](user_defined_operators.md) | +| [example_constructor_parameters.py](../../examples/example_constructor_parameters.py) | **Constructor settings and random seeds.** Use callable fitness signatures, NumPy counts, and independent seeded GA instances. | [Guide](pygad.md) | + +## Fitness and Parallel Processing + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_fitness_batch_size.py](../../examples/example_fitness_batch_size.py) | **Batch fitness.** Return one fitness result per solution, including a shorter final batch. | [Guide](fitness_calculation.md) | +| [example_parallel_processing.py](../../examples/example_parallel_processing.py) | **Parallel fitness.** Evaluate population fitness with process workers and report the run time. | [Guide](fitness_calculation.md) | +| [benchmarks/parallel_processing.py](../../examples/benchmarks/parallel_processing.py) | **Compare fitness execution modes.** Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation. | [Guide](fitness_calculation.md) | +| [example_fitness_wrapper.py](../../examples/example_fitness_wrapper.py) | **Extra fitness arguments.** Wrap a fitness function to pass additional values while preserving its PyGAD signature. | [Guide](custom_functions.md) | + +## Lifecycle and Saved Runs + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [pygad_lifecycle.py](../../examples/pygad_lifecycle.py) | **Lifecycle callbacks.** Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks. | [Guide](lifecycle.md) | +| [example_lifecycle_methods.py](../../examples/example_lifecycle_methods.py) | **Callbacks as methods.** Implement fitness and lifecycle callbacks with bound methods. | [Guide](custom_functions.md) | +| [example_lifecycle_classes.py](../../examples/example_lifecycle_classes.py) | **Callbacks as callable classes.** Implement fitness and lifecycle callbacks with callable class instances. | [Guide](custom_functions.md) | +| [example_summary.py](../../examples/example_summary.py) | **Text lifecycle summary.** Print the configured GA stages and their parameters. | [Guide](pygad_more.md) | +| [example_logger.py](../../examples/example_logger.py) | **Logging.** Send progress and GA messages to a configured logger. | [Guide](logging.md) | +| [example_repeated_runs.py](../../examples/example_repeated_runs.py) | **Repeated runs and checkpoints.** Continue from a saved GA and inspect the actual generation numbers in its histories. | [Guide](generations.md) | +| [example_load_fitness_function.py](../../examples/example_load_fitness_function.py) | **Change a loaded fitness function.** Replace the fitness callable after loading, or start fresh when the objective changes. | [Guide](pygad.md) | + +## Multi-Objective Optimization + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_multi_objective.py](../../examples/example_multi_objective.py) | **NSGA-II optimization.** Optimize two objectives and inspect the resulting trade-offs. | [Guide](multi_objective.md) | +| [example_multi_objective_nsga3.py](../../examples/example_multi_objective_nsga3.py) | **NSGA-III optimization.** Configure reference points and optimize two objectives with NSGA-III. | [Guide](multi_objective.md) | + +## Plots + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [plots/example_plot_fitness.py](../../examples/plots/example_plot_fitness.py) | **Best-fitness curve.** Plot best fitness across generations on the Sphere benchmark. | [Guide](visualize.md) | +| [plots/example_plot_fitness_band.py](../../examples/plots/example_plot_fitness_band.py) | **Fitness band.** Plot per-generation minimum, mean, and maximum fitness with a shaded band. | [Guide](visualize.md) | +| [plots/example_plot_genes.py](../../examples/plots/example_plot_genes.py) | **Gene histories.** Show how gene values change across saved generations. | [Guide](visualize.md) | +| [plots/example_plot_lifecycle.py](../../examples/plots/example_plot_lifecycle.py) | **Configured lifecycle.** Draw detailed and compact lifecycle charts and export SVG and PNG files. | [Guide](visualize.md) | +| [plots/example_plot_new_solution_rate.py](../../examples/plots/example_plot_new_solution_rate.py) | **New-solution rate.** Count previously unseen solutions in each generation. | [Guide](visualize.md) | +| [plots/example_plot_non_dominated_hypervolume.py](../../examples/plots/example_plot_non_dominated_hypervolume.py) | **Hypervolume history.** Track the hypervolume of the non-dominated set across generations. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_curve_2d.py](../../examples/plots/example_plot_pareto_front_curve_2d.py) | **2D Pareto front.** Plot a two-objective Pareto front after NSGA-II optimization. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_curve_3d.py](../../examples/plots/example_plot_pareto_front_curve_3d.py) | **3D Pareto front.** Plot a three-objective Pareto front after NSGA-III optimization. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_evolution.py](../../examples/plots/example_plot_pareto_front_evolution.py) | **Pareto-front evolution.** Overlay the non-dominated fronts from selected generations. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_heatmap.py](../../examples/plots/example_plot_pareto_front_heatmap.py) | **Pareto heatmap.** Compare objective values with a solutions-by-objectives heatmap. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_pcp.py](../../examples/plots/example_plot_pareto_front_pcp.py) | **Parallel coordinates.** Compare Pareto solutions across objective axes. | [Guide](visualize.md) | +| [plots/example_plot_pareto_front_scatter_matrix.py](../../examples/plots/example_plot_pareto_front_scatter_matrix.py) | **Pareto scatter matrix.** Compare every pair of objectives in a many-objective run. | [Guide](visualize.md) | +| [plots/example_plot_population_diversity.py](../../examples/plots/example_plot_population_diversity.py) | **Population diversity.** Track mean pairwise distance between solutions across generations. | [Guide](visualize.md) | + +## Reports + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [example_generate_report.py](../../examples/example_generate_report.py) | **PDF report.** Export the run configuration, summary, best solution, and applicable plots to PDF. | [Guide](pygad.md) | + +## Benchmarks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [benchmarks/example_classic_sphere.py](../../examples/benchmarks/example_classic_sphere.py) | **Sphere.** Optimize the Sphere single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rastrigin.py](../../examples/benchmarks/example_classic_rastrigin.py) | **Rastrigin.** Optimize the Rastrigin single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_rosenbrock.py](../../examples/benchmarks/example_classic_rosenbrock.py) | **Rosenbrock.** Optimize the Rosenbrock single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_griewank.py](../../examples/benchmarks/example_classic_griewank.py) | **Griewank.** Optimize the Griewank single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_schwefel.py](../../examples/benchmarks/example_classic_schwefel.py) | **Schwefel.** Optimize the Schwefel single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_ackley.py](../../examples/benchmarks/example_classic_ackley.py) | **Ackley.** Optimize the Ackley single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_classic_himmelblau.py](../../examples/benchmarks/example_classic_himmelblau.py) | **Himmelblau.** Optimize the Himmelblau single-objective benchmark. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt1.py](../../examples/benchmarks/example_zdt1.py) | **ZDT1.** Optimize the ZDT1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt2.py](../../examples/benchmarks/example_zdt2.py) | **ZDT2.** Optimize the ZDT2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt3.py](../../examples/benchmarks/example_zdt3.py) | **ZDT3.** Optimize the ZDT3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt4.py](../../examples/benchmarks/example_zdt4.py) | **ZDT4.** Optimize the ZDT4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_zdt6.py](../../examples/benchmarks/example_zdt6.py) | **ZDT6.** Optimize the ZDT6 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz1.py](../../examples/benchmarks/example_dtlz1.py) | **DTLZ1.** Optimize the DTLZ1 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz2.py](../../examples/benchmarks/example_dtlz2.py) | **DTLZ2.** Optimize the DTLZ2 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz3.py](../../examples/benchmarks/example_dtlz3.py) | **DTLZ3.** Optimize the DTLZ3 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_dtlz4.py](../../examples/benchmarks/example_dtlz4.py) | **DTLZ4.** Optimize the DTLZ4 problem and plot its Pareto front. | [Guide](benchmarks.md) | +| [benchmarks/example_knapsack.py](../../examples/benchmarks/example_knapsack.py) | **Knapsack.** Select items to maximize value within a weight capacity. | [Guide](benchmarks.md) | +| [benchmarks/example_tsp.py](../../examples/benchmarks/example_tsp.py) | **Travelling salesman.** Find a short tour using a permutation of four cities. | [Guide](benchmarks.md) | +| [example_travelling_salesman.ipynb](../../examples/example_travelling_salesman.ipynb) | **Travelling-salesman Colab notebook.** Explore a city-tour problem using a user-supplied CSV and interactive maps. | [Guide](benchmarks.md) · [Folder](../../examples/./) · [Data setup](../../examples/data/README.md) | + +## Quality Indicators + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [quality_indicators/example_hypervolume.py](../../examples/quality_indicators/example_hypervolume.py) | **Hypervolume.** Measure the objective-space volume dominated by the final population. | [Guide](utils.md) | +| [quality_indicators/example_inverted_generational_distance.py](../../examples/quality_indicators/example_inverted_generational_distance.py) | **Inverted generational distance.** Measure distance from a reference front to the approximation. | [Guide](utils.md) | +| [quality_indicators/example_generational_distance.py](../../examples/quality_indicators/example_generational_distance.py) | **Generational distance.** Measure distance from the approximation to a reference front. | [Guide](utils.md) | +| [quality_indicators/example_spacing.py](../../examples/quality_indicators/example_spacing.py) | **Spacing.** Measure how evenly the approximation points are spread. | [Guide](utils.md) | + +## Neural Networks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [nn/example_regression.py](../../examples/nn/example_regression.py) | **Regression.** Fit a neural network to a small numeric regression problem. | [Guide](nn_regression_1.md) | +| [nn/example_XOR_classification.py](../../examples/nn/example_XOR_classification.py) | **XOR classification.** Train a neural network on the four XOR inputs. | [Guide](nn_xor.md) | +| [nn/example_classification.py](../../examples/nn/example_classification.py) | **Image classification.** Classify fruit images from prepared feature vectors. | [Guide](nn_image_classification.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | +| [nn/example_regression_fish.py](../../examples/nn/example_regression_fish.py) | **Fish-weight regression.** Predict fish weight from numeric measurements. | [Guide](nn_regression_2.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | +| [nn/extract_features.py](../../examples/nn/extract_features.py) | **Prepare image features.** Extract fruit-image features and write the arrays used by the dense classifiers. | [Guide](nn_image_classification.md) · [Folder](../../examples/nn/) · [Data setup](../../examples/data/README.md) | + +## Neural Networks with the GA + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [gann/example_regression.py](../../examples/gann/example_regression.py) | **Regression.** Fit a neural network to a small numeric regression problem. | [Guide](gann_regression_1.md) | +| [gann/example_XOR_classification.py](../../examples/gann/example_XOR_classification.py) | **XOR classification.** Train a neural network on the four XOR inputs. | [Guide](gann_xor.md) | +| [gann/example_classification.py](../../examples/gann/example_classification.py) | **Image classification.** Classify fruit images from prepared feature vectors. | [Guide](gann_image_classification.md) · [Folder](../../examples/gann/) · [Data setup](../../examples/data/README.md) | +| [gann/example_regression_fish.py](../../examples/gann/example_regression_fish.py) | **Fish-weight regression.** Predict fish weight from numeric measurements. | [Guide](gann_regression_2.md) · [Folder](../../examples/gann/) · [Data setup](../../examples/data/README.md) | + +## Convolutional Networks + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [cnn/example_image_classification.py](../../examples/cnn/example_image_classification.py) | **Build a CNN.** Classify fruit images using prepared image arrays. | [Guide](cnn.md) · [Folder](../../examples/cnn/) · [Data setup](../../examples/data/README.md) | +| [gacnn/example_image_classification.py](../../examples/gacnn/example_image_classification.py) | **Optimize a CNN with the GA.** Classify fruit images using prepared image arrays. | [Guide](gacnn.md) · [Folder](../../examples/gacnn/) · [Data setup](../../examples/data/README.md) | + +## Keras + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [KerasGA/regression_example.py](../../examples/KerasGA/regression_example.py) | **Regression.** Optimize neural-network weights with the genetic algorithm. | [Guide](kerasga_regression.md) | +| [KerasGA/XOR_classification.py](../../examples/KerasGA/XOR_classification.py) | **XOR classification.** Optimize neural-network weights with the genetic algorithm. | [Guide](kerasga_xor.md) | +| [KerasGA/image_classification_Dense.py](../../examples/KerasGA/image_classification_Dense.py) | **Dense image classifier.** Train an image classifier with the genetic algorithm. | [Guide](kerasga_image_dense.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/image_classification_CNN.py](../../examples/KerasGA/image_classification_CNN.py) | **Convolutional image classifier.** Train an image classifier with the genetic algorithm. | [Guide](kerasga_image_conv.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/cancer_dataset.py](../../examples/KerasGA/cancer_dataset.py) | **Image-directory classification.** Use directory-based image input for a two-class Keras CNN. | [Guide](kerasga_image_datagen.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | +| [KerasGA/cancer_dataset_generator.py](../../examples/KerasGA/cancer_dataset_generator.py) | **Batched image-directory classification.** Use directory-based image input for a two-class Keras CNN. | [Guide](kerasga_image_datagen.md) · [Folder](../../examples/KerasGA/) · [Data setup](../../examples/data/README.md) | + +## PyTorch + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [TorchGA/regression_example.py](../../examples/TorchGA/regression_example.py) | **Regression.** Optimize neural-network weights with the genetic algorithm. | [Guide](torchga_regression.md) | +| [TorchGA/XOR_classification.py](../../examples/TorchGA/XOR_classification.py) | **XOR classification.** Optimize neural-network weights with the genetic algorithm. | [Guide](torchga_xor.md) | +| [TorchGA/image_classification_Dense.py](../../examples/TorchGA/image_classification_Dense.py) | **Dense image classifier.** Train an image classifier with the genetic algorithm. | [Guide](torchga_image_dense.md) · [Folder](../../examples/TorchGA/) · [Data setup](../../examples/data/README.md) | +| [TorchGA/image_classification_CNN.py](../../examples/TorchGA/image_classification_CNN.py) | **Convolutional image classifier.** Train an image classifier with the genetic algorithm. | [Guide](torchga_image_conv.md) · [Folder](../../examples/TorchGA/) · [Data setup](../../examples/data/README.md) | + +## Clustering + +| Python script | What it shows | Related information | +| --- | --- | --- | +| [clustering/example_clustering_2.py](../../examples/clustering/example_clustering_2.py) | **2-cluster example.** Optimize 2 cluster centers for generated two-dimensional data. | [Guide](pygad.md) | +| [clustering/example_clustering_3.py](../../examples/clustering/example_clustering_3.py) | **3-cluster example.** Optimize 3 cluster centers for generated two-dimensional data. | [Guide](pygad.md) | + +<!-- /python-examples-index --> diff --git a/docs/source/figures/plot_lifecycle.png b/docs/source/figures/plot_lifecycle.png new file mode 100644 index 00000000..cf294d03 Binary files /dev/null and b/docs/source/figures/plot_lifecycle.png differ diff --git a/docs/source/fitness_calculation.md b/docs/source/fitness_calculation.md index 82634f03..f58d940e 100644 --- a/docs/source/fitness_calculation.md +++ b/docs/source/fitness_calculation.md @@ -2,7 +2,61 @@ This page covers how PyGAD calculates the fitness efficiently: parallel processing, non-deterministic problems, reusing fitness values, and batch fitness calculation. +<!-- sphinx +(fitness-output-validation)= +--> +## Fitness Output Validation + +For a single-objective problem, `fitness_func` returns one numeric value per solution. For a multi-objective problem, it returns a non-empty, one-dimensional list, tuple, or NumPy array of numeric objective values. Every solution must return the same number of objectives throughout a run, including cached solutions and offspring evaluated for adaptive mutation. Empty vectors, nested vectors, non-numeric values, and inconsistent objective counts raise a descriptive error before parent selection. + +`NaN` is rejected. Single-objective fitness may be positive or negative infinity, for example to represent a perfect or rejected solution; proportional selection and numerical plots can still require finite scores. Multi-objective fitness must contain finite values because crowding distances and reference-point normalization use differences between objective values. + +Batch evaluation returns one such fitness value per supplied solution, including a smaller final batch. Sequential, threaded, and process evaluation use the same validation. `on_fitness` outputs are validated too, whether the callback returns replacement values or edits the supplied array in place. + +<!-- sphinx +(saved-fitness-across-repeated-runs)= +--> +## Saved Fitness across Repeated Runs + +Calling `run()` again continues from `generations_completed` and extends the existing histories. Each run saves its starting population and final population. For two runs of 2 generations, `best_solutions_generations` contains `[0, 1, 2, 2, 3, 4]`. Both snapshots of generation 2 remain available. The corresponding `best_solutions_fitness` entries have the same positions, and `best_solutions` uses those positions when `save_best_solutions=True`. + +When `save_solutions=True`, `solutions_generations` contains one generation number per saved population. `solutions` and `solutions_fitness` retain their existing flat layout, with one entry per solution. Population boundaries are recorded internally, including populations enlarged by NSGA-III. `best_solution_generation` reports the actual generation of the best saved fitness rather than its position in the history. For multi-objective histories, it uses the same NSGA-II ordering as `best_solution()`. + +`on_fitness(ga_instance, population_fitness)` runs before parent selection for each generation. After it returns, PyGAD recomputes the best solution so the saved solution and fitness agree. The final population is saved without an additional `on_fitness` call. Previously saved arrays are independent of later callback edits. Callbacks receive the population fitness after cache reuse, so changes to already cached scores can accumulate if the callback repeatedly adds to them. + +Checkpoints preserve the generation numbers and population boundaries. Older checkpoints containing a single-run history recover the generation numbers automatically. Older repeated-run checkpoints did not record run boundaries, so unavailable generation numbers are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. New snapshots have their actual generation numbers. Plots use snapshot positions only for those unknown legacy entries. + +<!-- sphinx (parallel-processing-guide)= +--> + +<!-- python-examples +example_repeated_runs.py +--> + +**Python example** + +**[Repeated runs and checkpoints](../../examples/example_repeated_runs.py)** + +Continue from a saved GA and inspect the actual generation numbers in its histories. + +`examples/example_repeated_runs.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_repeated_runs.py +``` + +</details> + +<!-- /python-examples --> + ## Parallel Processing in PyGAD Starting from [PyGAD 2.17.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-17-0), parallel processing is supported. This section explains how to use parallel processing in PyGAD. @@ -100,7 +154,57 @@ The repository's `examples/benchmarks/parallel_processing.py` measures complete For Keras, calls to `pygad.kerasga.predict()` sharing one model are synchronized; they preserve each solution's weights but run one at a time. Separate models are needed for concurrent predictions. Direct changes to shared models outside that helper require their own synchronization. +<!-- sphinx (non-deterministic-fitness)= +--> + +<!-- python-examples +example_parallel_processing.py +benchmarks/parallel_processing.py +--> + +**Python examples** + +**[Parallel fitness](../../examples/example_parallel_processing.py)** + +Evaluate population fitness with process workers and report the run time. + +`examples/example_parallel_processing.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_parallel_processing.py +``` + +</details> + +**[Compare fitness execution modes](../../examples/benchmarks/parallel_processing.py)** + +Measure complete runs for CPU, I/O, and NumPy workloads with serial, thread, process, and batch evaluation. + +`examples/benchmarks/parallel_processing.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/benchmarks/parallel_processing.py --workload cpu +``` + +</details> + +<!-- /python-examples --> + ## Solve Non-Deterministic Problems PyGAD can be used to solve both deterministic and non-deterministic problems. Deterministic problems are those that return the same fitness for the same solution. For non-deterministic problems, a different fitness value may be returned for the same solution. @@ -132,8 +236,15 @@ ga_instance = pygad.GA(..., This way, PyGAD will not save any explored solution, so the fitness function has to be called for each individual solution. +<!-- sphinx +(fitness-cache-reuse)= +--> ## Reuse the Fitness instead of Calling the Fitness Function +Saved solutions are indexed by their complete gene values to avoid scanning the entire history for every population member. Built-in evolution indexes new snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Duplicate solutions that have not been evaluated or saved are still evaluated independently. + +Indexes are rebuilt for direct evaluations outside `run()`, at the start of each run, and after user operators or callbacks that may edit the public histories. The indexes are omitted from checkpoints and process-worker snapshots and rebuilt when needed. This preserves history edits and cache behavior without adding configuration parameters. + It may happen that a previously explored solution in generation X is explored again in another generation Y (where Y > X). For some problems, calling the fitness function takes much time. For deterministic problems, it is better not to call the fitness function for an already explored solution. Instead, reuse the fitness of the old solution. PyGAD supports some options to help you save the time of calling the fitness function for a previously explored solution. @@ -183,7 +294,9 @@ ga_instance = pygad.GA(..., ...) ``` +<!-- sphinx (batch-fitness-calculation)= +--> ## Batch Fitness Calculation In [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0), a new optional parameter called `fitness_batch_size` is supported to calculate the fitness function in batches. Thanks to [Linan Qiu](https://github.com/linanqiu) for opening the [GitHub issue #136](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136). @@ -193,7 +306,9 @@ Its values can be: * `1` or `None`: If the `fitness_batch_size` parameter is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. That is if there are 15 solutions, then the fitness function is called 15 times. * `1 < fitness_batch_size <= sol_per_pop`: If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. +<!-- sphinx (short-fitness-batches)= +--> ### Why a Fitness Batch Can Be Smaller `fitness_batch_size` is the maximum number of solutions passed in one call. The final batch is smaller when the number of solutions needing evaluation is not a multiple of that size. Cached parents, elites, and previously saved solutions can also reduce the number of rows to evaluate. @@ -234,7 +349,34 @@ ga_instance = pygad.GA(num_generations=1, ga_instance.run() ``` -The runnable script is [`examples/example_fitness_batch_size.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_fitness_batch_size.py). The same variable-batch-size contract applies to serial, thread, and process evaluation. +The same variable-batch-size contract applies to serial, thread, and process evaluation. + +<!-- python-examples +example_fitness_batch_size.py +--> + +**Python example** + +**[Batch fitness](../../examples/example_fitness_batch_size.py)** + +Return one fitness result per solution, including a shorter final batch. + +`examples/example_fitness_batch_size.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_fitness_batch_size.py +``` + +</details> + +<!-- /python-examples --> ### Example without `fitness_batch_size` Parameter @@ -341,4 +483,4 @@ print(number_of_calls) 30 ``` -When batch fitness calculation is used, then we saved `120 - 30 = 90` calls to the fitness function. +When batch fitness calculation is used, then we saved `120 - 30 = 90` calls to the fitness function. diff --git a/docs/source/gacnn.md b/docs/source/gacnn.md index ab9273b4..82c87c92 100644 --- a/docs/source/gacnn.md +++ b/docs/source/gacnn.md @@ -476,3 +476,33 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +<!-- python-examples +gacnn/example_image_classification.py +--> + +**Python example** + +**[Optimize a CNN with the GA](../../examples/gacnn/example_image_classification.py)** + +Classify fruit images using prepared image arrays. + +`examples/gacnn/example_image_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gacnn/ so the relative data paths resolve: + +```console +cd examples/gacnn +python example_image_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann.md b/docs/source/gann.md index e304c7fe..d2834bd1 100644 --- a/docs/source/gann.md +++ b/docs/source/gann.md @@ -384,31 +384,16 @@ Classification accuracy : 100.0. This section gives the complete code of some examples that build and train neural networks using the genetic algorithm. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} XOR Classification -:link: gann_xor -:link-type: doc -::: - -:::{grid-item-card} Image Classification -:link: gann_image_classification -:link-type: doc -::: - -:::{grid-item-card} Regression Example 1 -:link: gann_regression_1 -:link-type: doc -::: - -:::{grid-item-card} Regression Example 2 - Fish Weight Prediction -:link: gann_regression_2 -:link-type: doc -::: +- [XOR Classification](gann_xor.md) +- [Image Classification](gann_image_classification.md) +- [Regression Example 1](gann_regression_1.md) +- [Regression Example 2 - Fish Weight Prediction](gann_regression_2.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -417,3 +402,4 @@ gann_image_classification gann_regression_1 gann_regression_2 ::: +--> diff --git a/docs/source/gann_image_classification.md b/docs/source/gann_image_classification.md index 782d66cd..28cef326 100644 --- a/docs/source/gann_image_classification.md +++ b/docs/source/gann_image_classification.md @@ -144,3 +144,33 @@ Classification accuracy : 99.94903160040775. The next figure shows how fitness value evolves by generation. ![Training Neural Networks using Genetic Algorithm](images/82152993-21898180-9865-11ea-8387-b995f88b83f7.png) + +<!-- python-examples +gann/example_classification.py +--> + +**Python example** + +**[Image classification](../../examples/gann/example_classification.py)** + +Classify fruit images from prepared feature vectors. + +`examples/gann/example_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gann/ so the relative data paths resolve: + +```console +cd examples/gann +python example_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_regression_1.md b/docs/source/gann_regression_1.md index feafb8be..6f977db8 100644 --- a/docs/source/gann_regression_1.md +++ b/docs/source/gann_regression_1.md @@ -151,3 +151,30 @@ print(f"Absolute error : {abs_error}.") The next figure shows how the fitness value changes for the generations used. ![example_regression](images/92948154-3cf24b00-f459-11ea-94ea-952b66ab2145.png) + +<!-- python-examples +gann/example_regression.py +--> + +**Python example** + +**[Regression](../../examples/gann/example_regression.py)** + +Fit a neural network to a small numeric regression problem. + +`examples/gann/example_regression.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/gann/example_regression.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_regression_2.md b/docs/source/gann_regression_2.md index 0fe0aa9d..183834b5 100644 --- a/docs/source/gann_regression_2.md +++ b/docs/source/gann_regression_2.md @@ -146,3 +146,33 @@ print(f"Absolute error : {abs_error}.") The next figure shows how the fitness value changes for the 500 generations used. ![example_regression_fish](images/92948486-bbe78380-f459-11ea-9e31-0d4c7269d606.png) + +<!-- python-examples +gann/example_regression_fish.py +--> + +**Python example** + +**[Fish-weight regression](../../examples/gann/example_regression_fish.py)** + +Predict fish weight from numeric measurements. + +`examples/gann/example_regression_fish.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, pandas + +**Data:** examples/data/Fish.csv. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/gann/ so the relative data paths resolve: + +```console +cd examples/gann +python example_regression_fish.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gann_xor.md b/docs/source/gann_xor.md index db2c6d1b..dfec47f6 100644 --- a/docs/source/gann_xor.md +++ b/docs/source/gann_xor.md @@ -132,3 +132,30 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +<!-- python-examples +gann/example_XOR_classification.py +--> + +**Python example** + +**[XOR classification](../../examples/gann/example_XOR_classification.py)** + +Train a neural network on the four XOR inputs. + +`examples/gann/example_XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/gann/example_XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/gene_values.md b/docs/source/gene_values.md index 0cbe2566..342d8981 100644 --- a/docs/source/gene_values.md +++ b/docs/source/gene_values.md @@ -2,6 +2,89 @@ This page covers the parameters that control the values a gene can take: the `gene_space` and `gene_type` parameters, gene constraints, the `sample_size` parameter, and preventing duplicate genes. +<!-- sphinx +(initial-population-guide)= +--> +## Creating the Initial Population + +PyGAD can generate the initial population or start from a population passed to `initial_population`. + +### Generate a Population + +When `initial_population=None`, set positive integers for `sol_per_pop` and `num_genes`. PyGAD samples the gene values using these rules: + +| Gene settings | Source of initial values | +| --- | --- | +| `gene_space=None` | The range defined by `init_range_low` and `init_range_high`. | +| A flat list, tuple, range, or NumPy array | The same finite choices for every gene, converted to that gene's type and precision. | +| A nested gene space | Each gene's own choices, fixed numeric value, dictionary, or `None` entry. | +| A dictionary without `step` | The continuous interval between `low` and `high`, restricted to the configured gene type and precision. | +| A dictionary with `step` | The finite values generated by `numpy.arange(low, high, step)`, converted to the configured type and precision. | +| `None`, including inside a list of choices | A fresh random value from that gene's initialization range. Explicit choices in the same list remain available. | + +Both initialization bounds can be numbers shared by all genes or 1D lists, tuples, or NumPy arrays with one bound per gene. For example: + +```python +init_range_low = [0, -2, 20] +init_range_high = [5, 2, 30] +gene_type = [int, [float, 2], float] +``` + +For random ranges and dictionaries without `step`, generated values stay within the interval after conversion and rounding. The smaller bound is included and the larger bound is excluded. Reversed bounds use the same interval. Equal bounds produce a fixed value if that value can be represented by the gene type and precision. A `ValueError` is raised if no valid value can be represented. For example, `gene_type=int` cannot generate a value in `[0.1, 0.9)`. + +Finite choices are converted as supplied. Integer conversion truncates fractional values towards zero, and floating-point values use the requested precision. Conversion can make different choices equal. + +PyGAD samples arrays of values rather than calling the sampler for every gene value. Integer intervals are sampled directly, and finite choices are prepared once per column. Continuous populations sharing one floating-point type are drawn as one array in solution-then-gene order. NSGA-III population growth uses the same generation and preparation methods. + +### Supply a Population + +Pass a non-empty rectangular 2D list, tuple, or NumPy array containing numeric values. PyGAD infers `sol_per_pop` and `num_genes` from its shape. These inferred dimensions take precedence over explicit values for the 2 parameters, including when validating per-gene ranges, spaces, types, and constraints. + +```python +initial_population = ((1.236, 10), + (2.341, 20)) +gene_type = ([float, 2], int) +``` + +This population has 2 solutions and 2 genes per solution. Its values are converted to `[[1.24, 10], [2.34, 20]]`, with the configured type preserved for each gene. The supplied population and `gene_type` specification are not modified. `population` and `initial_population` are separate arrays, so changing the working population does not change the initial snapshot. + +Supplied values can lie outside `gene_space` and the initialization ranges. They are retained after type conversion and rounding unless a constraint or duplicate repair requires a replacement. Replacement values follow the gene's own initialization settings. + +### Constraints and Duplicates + +Both generated and supplied populations are converted before checking `gene_constraint`. Constraints are applied in gene-index order to complete solutions. Explicit lists and small finite domains are searched in full; continuous intervals and large ranges or stepped dictionaries contribute up to `sample_size` candidates for constraint checks. If no candidate satisfies a constraint, the existing value remains and PyGAD warns unless `suppress_warnings=True`. + +Constraints depending on other genes should follow the dependency order: a gene should depend on earlier genes, as explained in the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section. Initialization does not solve arbitrary systems of dependent constraints. + +When `allow_duplicate_genes=False`, duplicate repair follows constraint handling and uses the same converted domains. The search behavior and limits are described in [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). + +<!-- python-examples +example_initial_population.py +--> + +**Python example** + +**[Initial populations](../../examples/example_initial_population.py)** + +Generate values from per-gene ranges and nested spaces, or supply values and infer the dimensions. + +`examples/example_initial_population.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_initial_population.py +``` + +</details> + +<!-- /python-examples --> + ## Limit the Gene Value Range using the `gene_space` Parameter In [PyGAD 2.11.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-11-0), the `gene_space` parameter added a new feature that lets you customize the range of accepted values for each gene. Let us first review the `gene_space` parameter and build on it. @@ -50,6 +133,9 @@ For a 3-gene problem, the next code creates a dictionary for each gene to restri gene_space = [{'low': 1, 'high': 5}, {'low': 0.3, 'high': 1.4}, {'low': -0.2, 'high': 4.5}] ``` +<!-- sphinx +(gene-space-guide)= +--> ## More about the `gene_space` Parameter The `gene_space` parameter customizes the space of values of each gene. @@ -99,7 +185,7 @@ gene_space = {"low": 4, "high": 30} gene_space = {"low": 4, "high": 30, "step": 2.5} ``` -> Setting a `dict` like `{"low": 0, "high": 10}` in the `gene_space` means that random values from the continuous range [0, 10) are sampled. Note that `0` is included but `10` is not included while sampling. Thus, the maximum value that could be returned is less than `10` like `9.9999`. But if the user decided to round the genes using, for example, `[float, 2]`, then this value will become 10. So, the user should be careful to the inputs. +> Setting a `dict` like `{"low": 0, "high": 10}` in the `gene_space` samples values from [0, 10). During initialization, values remain within these bounds after conversion and rounding, including with `[float, 2]`. If the interval contains no value representable by the configured gene type and precision, PyGAD raises a `ValueError`. If a `None` is assigned to only a single gene, then its value will be randomly generated initially using the `init_range_low` and `init_range_high` parameters in the `pygad.GA` class's constructor. During mutation, the value is sampled from the range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This is an example where the second gene is given a `None` value. @@ -130,7 +216,7 @@ For the second gene, its space is set to `None`. So, traditional mutation happen 1. Generating a random value from the range defined by the `random_mutation_min_val` and `random_mutation_max_val` parameters. 2. Adding this random value to the current gene's value. -If its current value is 5 and the random value is `-0.5`, then the new value is 4.5. If the gene type is integer, then the value will be rounded. +If its current value is 5 and the random value is `-0.5`, then the new value is 4.5. If the gene type is integer, then conversion truncates the fractional part, giving 4. On the other hand, if a gene has a **continuous space** defined in the `gene_space` parameter, then mutation occurs by adding a random value to the current gene value. @@ -149,6 +235,36 @@ If the dictionary has a step like the example below, then it is considered a dis Gene space: {'low': 1, 'high': 5, 'step': 0.5} ``` +<!-- python-examples +example_gene_space.py +--> + +**Python example** + +**[Gene spaces](../../examples/example_gene_space.py)** + +Compare shared and per-gene choices, ranges, dictionaries, fixed values, and None entries. + +`examples/example_gene_space.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_space.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(gene-constraints-guide)= +--> ## Gene Constraint In [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0), a new parameter called `gene_constraint` is added to the constructor of the `pygad.GA` class. An instance attribute of the same name is created for any instance of the `pygad.GA` class. @@ -228,13 +344,11 @@ Then the value of the first gene in the passed solution is `1`. By filtering the Sometimes it is normal for PyGAD to fail to find a gene value that satisfies the constraint. For example, if the possible gene values are only `[20,30,40]` and the gene constraint restricts the values to be greater than 50, then it is impossible to meet the constraint. -For some other cases, the constraint can be met but with some changes. For example, increasing the range from which a value is sampled. If the `gene_space` is used and assigned `range(10)`, then the gene constraint can be met by using `range(50)` so that we can find values greater than 50. +For some other cases, the constraint can be met but with some changes. For example, increasing the range from which a value is sampled. If the `gene_space` is used and assigned `range(10)`, then the gene constraint can be met by using `range(100)` so that we can find values greater than 50. -Even if the gene space is already assigned `range(1000)`, it might still not find values that meet the constraints. This is because PyGAD samples a number of values equal to the `sample_size` parameter which defaults to *100*. +Explicit lists provide their full candidate list for constraint checks. Ranges, stepped dictionaries, and integer intervals are sampled by index when only a small candidate set is needed, avoiding allocation of the entire domain. When candidates come from random sampling, a larger `sample_size` can increase the chance of finding a value that meets a narrow constraint. -Out of the range of *1000* numbers, all the 100 values might not be satisfying the constraint. This issue could be solved by simply assigning a larger value for the `sample_size` parameter. - -> PyGAD does not yet handle the **dependencies** among the genes in the `gene_constraint` parameter. +> Initialization and ordinary mutation apply gene constraints sequentially. They do not determine the dependency order among the genes automatically. > > This is an example where gene 0 depends on gene 1. To efficiently enforce the constraints, the constraint for gene 1 must be enforced first (if not `None`) then the constraint for gene 0. > @@ -248,9 +362,38 @@ Out of the range of *1000* numbers, all the 100 values might not be satisfying t > > PyGAD applies constraints sequentially, starting from the first gene to the last. To ensure correct behavior when genes depend on each other, structure your GA problem so that if gene X depends on gene Y, then gene Y appears earlier in the chromosome (solution) than gene X. As a result, its gene constraint will be earlier in the list. +Swap, inversion, and scramble mutation also check constraints against the complete proposed solution after destination conversion. SBX and polynomial mutation use shared space, type, precision, constraint, and duplicate checks. Proposals that cannot be made valid leave the original solution unchanged. + +Duplicate repair also checks all constraints against complete candidate solutions before accepting changes. Its additional search for dependent constraints is bounded by `sample_size`; this does not reorder the general initialization or mutation constraint checks. + ### Full Example -For a full example, please check the [`examples/example_gene_constraint.py` script](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_gene_constraint.py). +<!-- python-examples +example_gene_constraint.py +--> + +**Python example** + +**[Gene constraints](../../examples/example_gene_constraint.py)** + +Filter gene candidates with constraints that depend on other genes. + +`examples/example_gene_constraint.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_constraint.py +``` + +</details> + +<!-- /python-examples --> ## `sample_size` Parameter @@ -270,11 +413,20 @@ If the objective is to find a unique value or enforce the gene constraint, then Sometimes 100 values is not enough and PyGAD sometimes fails to find a good value. In this case, it is highly recommended to increase the `sample_size` parameter. This is to create a larger sample to increase the chance of finding a value that meets our objectives. +For duplicate repair, finite spaces are considered in full. These include lists, tuples, NumPy arrays, `range` objects, stepped dictionaries, and integer random ranges. Increasing `sample_size` is useful for continuous candidates and constraints that depend on other genes; it is not needed to explore a larger finite space. + +When replacement chains do not satisfy a dependent constraint, PyGAD also tries alternative complete assignments. This additional search considers up to `sample_size * num_genes` tentative gene assignments. A larger value allows more alternatives to be checked. The limit prevents arbitrary constraint functions from requiring an unbounded combinatorial search. + +<!-- sphinx +(duplicate-gene-repair-guide)= +--> ## Prevent Duplicates in Gene Values In [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0), a new bool parameter called `allow_duplicate_genes` is supported to control whether duplicates are supported in the chromosome or not. In other words, whether 2 or more genes might have the same exact value. -If `allow_duplicate_genes=True` (which is the default case), genes may have the same value. If `allow_duplicate_genes=False`, then no 2 genes will have the same value given that there are enough unique values for the genes. +If `allow_duplicate_genes=True` (which is the default case), genes may have the same value. If `allow_duplicate_genes=False`, PyGAD tries to give each gene a different numeric value within its solution. Duplicates are checked after applying the configured gene types and rounding. Mixed numeric types are compared by their exact stored values; for example, integer 1 and float 1.0 duplicate each other. + +The same repair is used for generated and manually supplied initial populations, crossover, mutation, and NSGA-III population growth. It also checks outputs from custom crossover and mutation functions and the `on_crossover` and `on_mutation` callbacks. Duplicate solutions in different population rows are allowed; this parameter controls repeated values within a single row. The next code gives an example to use the `allow_duplicate_genes` parameter. A callback generation function is implemented to print the population after each generation. @@ -398,79 +550,71 @@ Generation 5 [1 2 4 3]] ``` -You should give enough values for the genes so that PyGAD can find an alternative when a gene value duplicates another gene. +<!-- sphinx +(solve-duplicates-using-a-third-gene)= +--> -If PyGAD fails to find a unique gene value while there is still room to find one, then set the `sample_size` parameter to a larger value. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. +### Repair through Other Genes -### Limitation +There must be enough distinct values that can be assigned to the individual genes. Counting all values in the combined space is not sufficient. For example, `gene_space=[[0], [0], [1, 2]]` cannot give the first two genes different values. -There might be 2 duplicate genes where changing either of the 2 duplicating genes will not solve the problem. For example, if `gene_space=[[3, 0, 1], [4, 1, 2], [0, 2], [3, 2, 0]]` and the solution is `[3 2 0 0]`, then the values of the last 2 genes duplicate. There are no possible changes in the last 2 genes to solve the problem. +Repair first tries unused values. If a needed value is already used by another gene, PyGAD looks for an alternative for that gene. This can involve either duplicate occurrence and a chain of several replacements. The chain is applied together so no temporary duplicate is treated as a finished solution. -This problem can be solved by randomly changing one of the non-duplicating genes to make room for a unique value in one of the 2 duplicating genes. For example, by changing the second gene from 2 to 4, then any of the last 2 genes can take the value 2 and solve the duplicates. The resultant gene is then `[3 4 2 0]`. But this option is not yet supported in PyGAD. +For example: -### Solve Duplicates using a Third Gene +```python +gene_space = [[0, 1], [1, 2], [2, 3], [0]] +initial_population = [[0, 1, 2, 0], [0, 1, 2, 0]] +``` -When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happens that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene with another. In [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0), the duplicates are solved by looking for a third gene that helps solve them. The following examples explain how it works. +The last gene can only keep 0. Repair moves the third gene from 2 to 3, the second gene from 1 to 2, and the first gene from 0 to 1. The repaired solution is `[1, 2, 3, 0]`. Each replacement belongs to its destination gene's space. -Example 1: +This behavior also handles third-gene repairs, such as changing `[3, 4, 4, 5]` into `[2, 3, 4, 5]` for `gene_space=[[2, 3], [3, 4], [4, 5], [5, 6]]`. -Let's assume that this gene space is used and there is a solution with 2 duplicate genes with the same value 4. +<!-- python-examples +example_duplicate_gene_repair.py +--> -```python -Gene space: [[2, 3], - [3, 4], - [4, 5], - [5, 6]] -Solution: [3, 4, 4, 5] -``` +**Python example** -By checking the gene space, the second gene can have the values `[3, 4]` and the third gene can have the values `[4, 5]`. To solve the duplicates, we change the value of one of these 2 genes. +**[Duplicate repair](../../examples/example_duplicate_gene_repair.py)** -If the value of the second gene changes from 4 to 3, then it will duplicate the first gene. If we change the value of the third gene from 4 to 5, then it will duplicate the fourth gene. In short, simply selecting a different value for either the second or third gene will introduce new duplicate genes. +Repair duplicates through a chain of replacements while respecting each gene space. -When there are 2 duplicate genes but there is no way to solve their duplicates, then the solution is to change a third gene that makes a room to solve the duplicates between the 2 genes. +`examples/example_duplicate_gene_repair.py` -In our example, duplicates between the second and third genes can be solved by, for example,: +<details> +<summary>Run this example</summary> -* Changing the first gene from 3 to 2 then changing the second gene from 4 to 3. -* Or changing the fourth gene from 5 to 6 then changing the third gene from 4 to 5. +**Requires:** PyGAD -Generally, this is how to solve such duplicates: +From the repository root, with the repository version of PyGAD installed: -1. For any duplicate gene **GENE1**, select another value. -2. Check which other gene **GENEX** has duplicate with this new value. -3. Find if **GENEX** can have another value that will not cause any more duplicates. If so, go to step 7. -4. If all the other values of **GENEX** will cause duplicates, then try another gene **GENEY**. -5. Repeat steps 3 and 4 until exploring all the genes. -6. If there is no way to solve the duplicates, then we have to keep the duplicate value. -7. If a value for a gene **GENEM** is found that will not cause more duplicates, then use this value for the gene **GENEM**. -8. Replace the value of the gene **GENE1** by the old value of the gene **GENEM**. This solves the duplicates. +```console +python examples/example_duplicate_gene_repair.py +``` -This is an example to solve the duplicate for the solution `[3, 4, 4, 5]`: +</details> -1. Let's use the second gene with value 4. Because the space of this gene is `[3, 4]`, then the only other value we can select is 3. -2. The first gene also has the value 3. -3. The first gene has another value 2 that will not cause more duplicates in the solution. Then go to step 7. -4. Skip. -5. Skip. -6. Skip. -7. The value of the first gene 3 will be replaced by the new value 2. The new solution is [2, 4, 4, 5]. -8. Replace the value of the second gene 4 by the old value of the first gene which is 3. The new solution is [2, 3, 4, 5]. The duplicate is solved. +<!-- /python-examples --> -Example 2: +### Ranges, Types, and Constraints -```python -Gene space: [[0, 1], - [1, 2], - [2, 3], - [3, 4]] -Solution: [1, 2, 2, 3] -``` +Each replacement uses its own gene's range, type, precision, and constraint. Initialization uses `init_range_low` and `init_range_high` with replacement. Random and adaptive mutation use `random_mutation_min_val` and `random_mutation_max_val`, respecting `mutation_by_replacement`. SBX crossover and polynomial mutation use their per-gene initialization bounds for repair. + +An explicit gene-space value replaces the gene regardless of `mutation_by_replacement`. A nested space entry of `None` follows the random-mutation mode. A `None` inside a list, tuple, or array supplies fresh replacement candidates from the relevant initialization or mutation range. It is not frozen into a single cached initial value. + +Constraints on other genes may depend on a replaced position. PyGAD checks all constraints against a complete repaired solution before accepting it. If the repair would violate another gene's constraint, that assignment is rejected and alternatives are considered. + +Manually supplied values and values inherited from parents are kept when possible. Supplying a population does not automatically replace every value outside `gene_space` or the initialization range. New repair values follow the configured destination space or range. + +### When Duplicates Remain + +Finite spaces without dependent constraints are searched completely, including replacement chains. If no unique assignment exists, repair keeps as many distinct values as possible and reports the remaining duplicates with warnings unless `suppress_warnings=True`. -The quick summary is: +Continuous ranges are sampled, so finding every possible value cannot be guaranteed. Rounding or a narrow numeric type can also reduce the number of distinct available values. Increase `sample_size` when continuous sampling or the additional search for dependent constraints needs more candidates or alternatives. If no constraint-valid improvement is found, existing values are retained instead of accepting a repair that violates a constraint. -* Change the value of the first gene from 1 to 0. The solution becomes [0, 2, 2, 3]. -* Change the value of the second gene from 2 to 1. The solution becomes [0, 1, 2, 3]. The duplicate is solved. +Changes made directly to the population in other callbacks remain the responsibility of those callbacks. Custom operators still need to generate meaningful values for the problem; duplicate repair is not a general validator of every custom operator output. ## More about the `gene_type` Parameter @@ -481,6 +625,49 @@ The `gene_type` parameter allows the user to control the data type for all genes Let us look at some examples. +<!-- sphinx +(gene-type-conversion-guide)= +--> +### Conversion and Rounding Rules + +PyGAD applies the same conversion rules to generated and supplied initial populations, mutation candidates, and custom operator outputs. `on_parents`, `on_crossover`, and `on_mutation` receive converted values; any replacements returned or made in place by these callbacks are converted again before use. These rules apply whether `allow_duplicate_genes` is `True` or `False`. + +- Integer conversion truncates the fractional part towards zero. For example, `1.9` becomes `1` and `-1.9` becomes `-1`. Additive mutation adds the random value before converting the result. +- Floating-point precision rounds before casting to the requested type. This avoids rounding in a narrow type such as `numpy.float16` before the final conversion. +- Precision must be an integer or `None`. `None` keeps the value unrounded, `0` rounds to whole numbers, and a negative precision rounds to positions before the decimal point. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. +- Rounding uses NumPy's nearest-even rule at halfway points: `0.5` rounds to `0.0` and `1.5` rounds to `2.0` with precision `0`. + +Floating-point types use binary representations, so a stored value can differ slightly from its decimal representation. Requested precision does not increase the accuracy or range of the selected type. For unusually large values or precisions where NumPy's intermediate decimal scaling overflows, PyGAD uses scalar rounding to preserve finite values before the final cast. + +When types are specified per gene, population arrays use `dtype=object` so each column can retain its requested Python or NumPy scalar type. Saved best solutions retain these types too. This also preserves large integers when other genes are floating-point values. A NumPy array constructed without `dtype=object` can already lose integer precision through conversion to a shared floating-point type; use a list or an object array for mixed input values that must remain exact. + +<!-- python-examples +example_gene_type_conversion.py +--> + +**Python example** + +**[Gene types and rounding](../../examples/example_gene_type_conversion.py)** + +Preserve mixed numeric types, apply precision, and convert custom mutation outputs. + +`examples/example_gene_type_conversion.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_gene_type_conversion.py +``` + +</details> + +<!-- /python-examples --> + ### Data Type for All Genes without Precision The data type for all genes can be specified by assigning the numeric data type directly to the `gene_type` parameter. This is an example to make all genes of `int` data types. @@ -489,7 +676,7 @@ The data type for all genes can be specified by assigning the numeric data type gene_type=int ``` -Given that the supported numeric data types of PyGAD include Python's `int` and `float` in addition to all numeric types of `NumPy`, then any of these types can be assigned to the `gene_type` parameter. +The supported numeric types include Python's `int` and `float`, NumPy's signed and unsigned integer types with widths of 8, 16, 32, and 64 bits, and `numpy.float16`, `numpy.float32`, and `numpy.float64`. Any of these types can be assigned to `gene_type`. If no precision is specified for a `float` data type, then the complete floating-point number is kept. diff --git a/docs/source/generations.md b/docs/source/generations.md index 4793761d..38c7e0a4 100644 --- a/docs/source/generations.md +++ b/docs/source/generations.md @@ -16,6 +16,9 @@ def func_generation(ga_instance): return "stop" ``` +<!-- sphinx +(stop-criteria-guide)= +--> ## Stop Criteria In [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0), a new parameter named `stop_criteria` is added to the constructor of the `pygad.GA` class. It helps to stop the evolution based on some criteria. It can be assigned one or more criteria. @@ -35,7 +38,11 @@ The supported words are `reach`, `saturate`, `time`, and `evaluations`. The `reach` word stops the `run()` method if the fitness value is equal to or greater than a given fitness value. An example for `reach` is `"reach_40"` which stops the evolution if the fitness is >= 40. -`saturate` stops the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. +`saturate` stops the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. + +The initial population's best fitness is the baseline. `saturate_1` stops after one completed generation with unchanged best fitness. Any change resets the count, even if a later generation returns to an earlier fitness value. Multi-objective problems compare the entire best-fitness vector. Every `run()` starts a new saturation count while `generations_completed` continues from the previous run. + +The counts for `saturate` and `evaluations` must be positive integers. The `reach` threshold must be finite, and the `time` duration must be finite and non-negative. Scientific notation is accepted, for example `"evaluations_1e3"` or `"time_1e-2"`. `time` stops after the elapsed runtime reaches the specified seconds, for example `"time_30"`. `evaluations` stops after the number of evaluated solutions reaches its threshold, for example `"evaluations_1000"`. This count includes adaptive mutation's offspring evaluations and counts every solution in a fitness batch. Reusing a cached fitness value contributes zero. Both criteria are checked after a generation, so runtime and evaluation count can exceed their thresholds. @@ -158,13 +165,13 @@ number of offspring = sol_per_pop - (number of kept solutions) The next tree shows how the two parameters decide the number of offspring. -:::{figure} images/offspring_decision_tree.* -:alt: Decision tree showing how keep_elitism and keep_parents decide the number of offspring -:width: 680px -:align: center +<!-- documentation-figure: 680px --> + +![Decision tree showing how keep_elitism and keep_parents decide the number of offspring](images/offspring_decision_tree.png) How `keep_elitism` and `keep_parents` decide the number of offspring. -::: + +<!-- /documentation-figure --> There are four cases: @@ -177,20 +184,25 @@ There are four cases: The kept solutions are placed at the top of the next population, starting at index 0. The offspring fill the slots that remain. -:::{figure} images/population_assembly.* -:alt: The kept solutions sit at the top of the next population and the offspring fill the rest -:width: 620px -:align: center +<!-- documentation-figure: 620px --> + +![The kept solutions sit at the top of the next population and the offspring fill the rest](images/population_assembly.png) The kept solutions are copied to the top of the population. The offspring fill the rest. -::: + +<!-- /documentation-figure --> Watch the tutorial on [YouTube](https://www.youtube.com/shorts/-uupRJhesjI). +<!-- sphinx ```{raw} html <iframe width="315" height="560" src="https://www.youtube.com/embed/-uupRJhesjI" title="keep_elitism vs keep_parents" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> ``` +--> +<!-- sphinx +(random-seed-guide)= +--> ## Random Seed In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0), a new parameter called `random_seed` is supported. Its value is used as a seed for the random function generators. @@ -200,7 +212,7 @@ In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-1 1. NumPy 2. random -The `random_seed` parameter defaults to `None` which means no seed is used. As a result, different random numbers are generated for each run of PyGAD. +Each GA instance owns its NumPy and Python random generators. Creating or running another GA, or drawing from the global generators, does not change that instance's random state. The `random_seed` parameter accepts Python and NumPy integer seeds. It defaults to `None`, so separate instances can generate different random values. If this parameter is assigned a proper seed, then the results will be reproducible. In the next example, the integer 2 is used as a random seed. @@ -232,17 +244,65 @@ print(best_solution_fitness) This is the best solution found and its fitness value. ``` -[ 2.77249188 -4.06570662 0.04196872 -3.47770796 -0.57502138 -3.22775267] -0.04872203136549972 +[ 2.77249188 -3.36283618 0.62335921 -0.51742086 -0.63705758 -1.35732143] +0.0757422952817227 ``` After running the code again, it will find the same result. ``` -[ 2.77249188 -4.06570662 0.04196872 -3.47770796 -0.57502138 -3.22775267] -0.04872203136549972 +[ 2.77249188 -3.36283618 0.62335921 -0.51742086 -0.63705758 -1.35732143] +0.0757422952817227 ``` +Reproducibility applies within the same PyGAD version and environment. Changes to operators can produce different results from earlier versions with the same seed. Repeated calls to `run()` continue the existing generator states rather than restarting from the seed, and saving and loading a GA preserves those states. + +### Random Choices in Custom Operators and Callbacks + +Use `ga_instance.numpy_random_generator` (a `numpy.random.RandomState`) or `ga_instance.python_random_generator` (a `random.Random`) for reproducible random choices in user code. For example, this custom mutation selects one gene per offspring and draws its replacement from the GA's NumPy generator: + +```python +def custom_mutation(offspring, ga_instance): + for solution in offspring: + gene_index = ga_instance.numpy_random_generator.randint(ga_instance.num_genes) + solution[gene_index] = ga_instance.numpy_random_generator.uniform(-1.0, 1.0) + return offspring + + +ga_instance = pygad.GA(..., + mutation_type=custom_mutation, + random_seed=2) +``` + +The custom operator must choose values appropriate for the problem's gene spaces and constraints. Calls to global `numpy.random` or `random` functions in user code need their own seeds; `random_seed` does not seed these global generators. + +<!-- python-examples +example_constructor_parameters.py +--> + +**Python example** + +**[Constructor settings and random seeds](../../examples/example_constructor_parameters.py)** + +Use callable fitness signatures, NumPy counts, and independent seeded GA instances. + +`examples/example_constructor_parameters.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_constructor_parameters.py +``` + +</details> + +<!-- /python-examples --> + ## Continue without Losing Progress In [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0), and thanks for [Felix Bernhard](https://github.com/FeBe95) for opening [this GitHub issue](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123#issuecomment-1203035106), the values of these 4 instance attributes are no longer reset after each call to the `run()` method. @@ -290,7 +350,44 @@ loaded_ga_instance.plot_fitness() The plot created by the `plot_fitness()` method will show the data collected from both the runs. -Note that the 2 attributes (`self.best_solutions` and `self.best_solutions_fitness`) only work if the `save_best_solutions` parameter is set to `True`. Also, the 2 attributes (`self.solutions` and `self.solutions_fitness`) only work if the `save_solutions` parameter is `True`. +`best_solutions_fitness` is collected regardless of `save_best_solutions`. Set `save_best_solutions=True` to save the corresponding gene values in `best_solutions`. The `solutions` and `solutions_fitness` histories require `save_solutions=True`. + +### Generation Numbers in Saved Histories + +`num_generations` specifies how many additional generations each `run()` can complete. `generations_completed` records the cumulative count. With `num_generations=2`, two completed runs leave `generations_completed=4`. + +`best_solutions_generations` records the actual generation number for each entry in `best_solutions_fitness`. Both the starting and final snapshots of each run are kept, so two runs of 2 generations produce `[0, 1, 2, 2, 3, 4]`. The two entries for generation 2 represent the final snapshot of the first run and the starting snapshot of the second run. + +With `save_solutions=True`, `solutions_generations` records one generation number per saved population, while `solutions` and `solutions_fitness` keep one entry per solution. `best_solution_generation` reports the actual generation of the best saved fitness, rather than its position in the history. History plots and PDF reports use these generation numbers. + +Saving and loading preserves the metadata. Older checkpoints with a single-run history recover their generation numbers. Unknown generations in older repeated-run histories are represented by `None`; `best_solution_generation` is `-1` if the winning snapshot has an unknown generation. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs) for callback behavior and checkpoint compatibility. + +<!-- python-examples +example_repeated_runs.py +--> + +**Python example** + +**[Repeated runs and checkpoints](../../examples/example_repeated_runs.py)** + +Continue from a saved GA and inspect the actual generation numbers in its histories. + +`examples/example_repeated_runs.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_repeated_runs.py +``` + +</details> + +<!-- /python-examples --> ## Change Population Size during Runtime @@ -315,3 +412,30 @@ These are examples of the instance attributes that might be changed. The user sh 2. `last_generation_parents` and `last_generation_parents_indices`: Two NumPy arrays: 2D array representing the parents and 1D array of the parents indices. 3. `last_generation_elitism` and `last_generation_elitism_indices`: Must be changed if `keep_elitism != 0`. The default value of `keep_elitism` is 1. Two NumPy arrays: 2D array representing the elitism and 1D array of the elitism indices. 2. `pop_size`: The population size. + +<!-- python-examples +example_dynamic_population_size.py +--> + +**Python example** + +**[Changing population size](../../examples/example_dynamic_population_size.py)** + +Adjust the population and related runtime settings during evolution. + +`examples/example_dynamic_population_size.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_dynamic_population_size.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/help.md b/docs/source/help.md index 25edf0af..b36d525c 100644 --- a/docs/source/help.md +++ b/docs/source/help.md @@ -2,39 +2,16 @@ This section collects extra information about PyGAD: where to get help, how to contribute, and resources that use or explain PyGAD. Pick a topic: -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Getting Help -:link: help_support -:link-type: doc +- [Getting Help](help_support.md) — Submit issues, request features, ask on Stack Overflow, and contact us. +- [Tutorials and Resources](help_tutorials.md) — Tutorials, articles, and a book about PyGAD. +- [Projects and Research](help_projects.md) — PyGAD projects, projects built with PyGAD, and research papers. +- [PyGAD in Other Languages](help_languages.md) — Read about PyGAD in several languages. -Submit issues, request features, ask on Stack Overflow, and contact us. -::: - -:::{grid-item-card} Tutorials and Resources -:link: help_tutorials -:link-type: doc - -Tutorials, articles, and a book about PyGAD. -::: - -:::{grid-item-card} Projects and Research -:link: help_projects -:link-type: doc - -PyGAD projects, projects built with PyGAD, and research papers. -::: - -:::{grid-item-card} PyGAD in Other Languages -:link: help_languages -:link-type: doc - -Read about PyGAD in several languages. -::: - -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -43,3 +20,4 @@ help_tutorials help_projects help_languages ::: +--> diff --git a/docs/source/index.md b/docs/source/index.md index 78a19a7e..108dd879 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -10,13 +10,13 @@ > Run PyGAD in the cloud with [Vilvik](https://vilvik.com): push your PyGAD problem to Vilvik, let it run in the cloud, and get the results back. -:::{figure} images/pygad_vilvik_cloud.* -:alt: Run PyGAD in the cloud with Vilvik -:width: 100% -:align: center +<!-- documentation-figure: 100% --> + +![Run PyGAD in the cloud with Vilvik](images/pygad_vilvik_cloud.png) Push your PyGAD problem to [Vilvik](https://vilvik.com) and run it in the cloud. To get started, follow this tutorial: [Push your PyGAD problem to Vilvik in 10 minutes](https://vilvik.com/blog/@vilvik/pygad-to-vilvik-in-10-minutes). -::: + +<!-- /documentation-figure --> [PyGAD](https://github.com/ahmedfgad/GeneticAlgorithmPython) supports different types of crossover, mutation, and parent selection operators. It lets you optimize many types of problems with the genetic algorithm by writing your own fitness function. It works with both single-objective and multi-objective optimization problems. @@ -174,15 +174,33 @@ If you used PyGAD, please consider citing its paper with the following details: } ``` +**Genetic Algorithm** + +- [`pygad` Module](pygad.md) +- [More About PyGAD](pygad_more.md) +- [Examples](examples.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Genetic Algorithm pygad pygad_more +examples ``` +--> +**Operators & Visualization** + +- [`pygad.utils` Module](utils.md) +- [`pygad.visualize` Module](visualize.md) +- [`pygad.helper` Module](helper.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Operators & Visualization @@ -190,8 +208,18 @@ utils visualize helper ``` +--> + +**Neural Networks** +- [`pygad.nn` Module](nn.md) +- [`pygad.gann` Module](gann.md) +- [`pygad.cnn` Module](cnn.md) +- [`pygad.gacnn` Module](gacnn.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Neural Networks @@ -200,25 +228,48 @@ gann cnn gacnn ``` +--> + +**Keras & PyTorch** +- [`pygad.kerasga` Module](kerasga.md) +- [`pygad.torchga` Module](torchga.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Keras & PyTorch kerasga torchga ``` +--> + +**Releases** +- [Release History](releases.md) + +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Releases releases ``` +--> + +**Help & Resources** + +- [Help & Resources](help.md) +<!-- sphinx ```{toctree} +:hidden: :maxdepth: 1 :caption: Help & Resources help ``` +--> diff --git a/docs/source/kerasga.md b/docs/source/kerasga.md index 64b63e19..91790372 100644 --- a/docs/source/kerasga.md +++ b/docs/source/kerasga.md @@ -119,6 +119,9 @@ The `model_weights_as_matrix()` function accepts the following parameters: It returns the restored model weights after reshaping the vector. +<!-- sphinx +(keras-predict)= +--> ### `pygad.kerasga.predict()` The `predict()` function makes a prediction based on a solution. It accepts the following parameters: @@ -154,36 +157,17 @@ These objects are local to a Python process. They are module attributes, not add This section gives the complete code of some examples that build and train a Keras model using PyGAD. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Example 1: Regression Example -:link: kerasga_regression -:link-type: doc -::: - -:::{grid-item-card} Example 2: XOR Binary Classification -:link: kerasga_xor -:link-type: doc -::: - -:::{grid-item-card} Example 3: Image Multi-Class Classification (Dense Layers) -:link: kerasga_image_dense -:link-type: doc -::: - -:::{grid-item-card} Example 4: Image Multi-Class Classification (Conv Layers) -:link: kerasga_image_conv -:link-type: doc -::: - -:::{grid-item-card} Example 5: Image Classification using Data Generator -:link: kerasga_image_datagen -:link-type: doc -::: +- [Example 1: Regression Example](kerasga_regression.md) +- [Example 2: XOR Binary Classification](kerasga_xor.md) +- [Example 3: Image Multi-Class Classification (Dense Layers)](kerasga_image_dense.md) +- [Example 4: Image Multi-Class Classification (Conv Layers)](kerasga_image_conv.md) +- [Example 5: Image Classification using Data Generator](kerasga_image_datagen.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -193,3 +177,4 @@ kerasga_image_dense kerasga_image_conv kerasga_image_datagen ::: +--> diff --git a/docs/source/kerasga_image_conv.md b/docs/source/kerasga_image_conv.md index 1f2c8399..dbf6ec2a 100644 --- a/docs/source/kerasga_image_conv.md +++ b/docs/source/kerasga_image_conv.md @@ -152,3 +152,33 @@ To improve the model performance, you can do the following: - Modify the existing layers. - Use different parameters for the layers. - Use different parameters for the genetic algorithm (e.g. number of solution, number of generations, etc) + +<!-- python-examples +KerasGA/image_classification_CNN.py +--> + +**Python example** + +**[Convolutional image classifier](../../examples/KerasGA/image_classification_CNN.py)** + +Train an image classifier with the genetic algorithm. + +`examples/KerasGA/image_classification_CNN.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python image_classification_CNN.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_image_datagen.md b/docs/source/kerasga_image_datagen.md index da3fc1bf..00c1e056 100644 --- a/docs/source/kerasga_image_datagen.md +++ b/docs/source/kerasga_image_datagen.md @@ -88,3 +88,56 @@ ca.update_state(data_outputs, predictions) accuracy = ca.result().numpy() print(f"Accuracy : {accuracy}") ``` + +<!-- python-examples +KerasGA/cancer_dataset.py +KerasGA/cancer_dataset_generator.py +--> + +**Python examples** + +**[Image-directory classification](../../examples/KerasGA/cancer_dataset.py)** + +Use directory-based image input for a two-class Keras CNN. + +`examples/KerasGA/cancer_dataset.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python cancer_dataset.py +``` + +</details> + +**[Batched image-directory classification](../../examples/KerasGA/cancer_dataset_generator.py)** + +Use directory-based image input for a two-class Keras CNN. + +`examples/KerasGA/cancer_dataset_generator.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** benign/ and malignant/ image folders under examples/data/Skin_Cancer_Dataset/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python cancer_dataset_generator.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_image_dense.md b/docs/source/kerasga_image_dense.md index a4d78ecd..6ce22e37 100644 --- a/docs/source/kerasga_image_dense.md +++ b/docs/source/kerasga_image_dense.md @@ -123,3 +123,33 @@ Index of the best solution : 0 Categorical Crossentropy : 0.23823906 Accuracy : 0.9852192 ``` + +<!-- python-examples +KerasGA/image_classification_Dense.py +--> + +**Python example** + +**[Dense image classifier](../../examples/KerasGA/image_classification_Dense.py)** + +Train an image classifier with the genetic algorithm. + +`examples/KerasGA/image_classification_Dense.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/KerasGA/ so the relative data paths resolve: + +```console +cd examples/KerasGA +python image_classification_Dense.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_regression.md b/docs/source/kerasga_regression.md index cffb5507..5eec8103 100644 --- a/docs/source/kerasga_regression.md +++ b/docs/source/kerasga_regression.md @@ -237,3 +237,30 @@ print(f"Absolute Error : {abs_error}") ``` Absolute Error : 0.013740465 ``` + +<!-- python-examples +KerasGA/regression_example.py +--> + +**Python example** + +**[Regression](../../examples/KerasGA/regression_example.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/KerasGA/regression_example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/KerasGA/regression_example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/kerasga_xor.md b/docs/source/kerasga_xor.md index d09c67d9..4d8236ba 100644 --- a/docs/source/kerasga_xor.md +++ b/docs/source/kerasga_xor.md @@ -143,3 +143,30 @@ Binary Crossentropy : 0.0013527311 Accuracy : 1.0 ``` + +<!-- python-examples +KerasGA/XOR_classification.py +--> + +**Python example** + +**[XOR classification](../../examples/KerasGA/XOR_classification.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/KerasGA/XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/KerasGA/XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/lifecycle.md b/docs/source/lifecycle.md index b066b344..46db1ce9 100644 --- a/docs/source/lifecycle.md +++ b/docs/source/lifecycle.md @@ -2,24 +2,74 @@ The next figure shows the main steps in the life cycle of a `pygad.GA` instance. The genetic algorithm evaluates its initial population, then repeats parent selection, crossover, mutation, population update, and fitness evaluation for each generation. It can reuse cached fitness values. PyGAD stops when all generations are done, a stopping criterion is met, or the function passed to `on_generation` returns the string `stop`. -:::{figure} images/ga_lifecycle.* -:alt: The PyGAD genetic algorithm life cycle -:width: 480px -:align: center +<!-- documentation-figure: 480px --> + +![The PyGAD genetic algorithm life cycle](images/ga_lifecycle.png) The main steps of the genetic algorithm in PyGAD. -::: + +<!-- /documentation-figure --> The next figure shows the same life cycle in more detail, including the callback functions that PyGAD calls at each stage. -:::{figure} images/pygad_lifecycle.* -:alt: The PyGAD life cycle with callback functions -:width: 480px -:align: center +<!-- documentation-figure: 480px --> + +![The PyGAD life cycle with callback functions](images/pygad_lifecycle.png) The PyGAD life cycle in detail, including the callback functions called at each stage. -::: +<!-- /documentation-figure --> + +## Plotting the Configured Lifecycle + +Call `plot_lifecycle()` to create a chart adjusted to the operators, callbacks, gene settings, and stopping conditions of a GA instance. It can be called before or after `run()`. + +```python +ga_instance.plot_lifecycle() +``` + +The chart shows the generation loop and exit paths. It includes population replacement and fitness evaluation before `on_generation`, and omits disabled crossover or mutation. Configured callbacks still appear at their execution points, including `on_crossover` and `on_mutation` when their corresponding operators are disabled. The detailed `Stop Early?` block lists the configured stopping criteria and the possible `"stop"` return from `on_generation`, when that callback is supplied. + +Use `show_parameters=False` for a compact chart, or save the figure by passing `save_dir`. The filename extension selects the output format. + +```python +ga_instance.plot_lifecycle(title="PyGAD - My Optimization Problem", + save_dir="lifecycle.svg", + show=False) +``` + +Drawing the chart does not run the GA or call user functions. See [plot_lifecycle()](visualize.md#plot_lifecycle) for the parameters, a sample chart, and a runnable example. To print a text description, use [summary()](logging.md#print-lifecycle-summary). + +<!-- python-examples +plots/example_plot_lifecycle.py +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(reporting-progress)= +--> ## Reporting Progress Use `on_generation` to report progress once a generation has completed. There is no need to change the fitness function or the GA operators: @@ -121,4 +171,31 @@ on_generation() on_stop() ``` -The same example is available as [`examples/pygad_lifecycle.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/pygad_lifecycle.py). To stop from `on_generation`, return `"stop"`; otherwise no return value is needed. +To stop from `on_generation`, return `"stop"`; otherwise no return value is needed. + +<!-- python-examples +pygad_lifecycle.py +--> + +**Python example** + +**[Lifecycle callbacks](../../examples/pygad_lifecycle.py)** + +Trace fitness, parent selection, crossover, mutation, generation, and stop callbacks. + +`examples/pygad_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/pygad_lifecycle.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/logging.md b/docs/source/logging.md index f70ff3b7..19eccbd0 100644 --- a/docs/source/logging.md +++ b/docs/source/logging.md @@ -2,6 +2,9 @@ This page covers how to see what PyGAD is doing: printing a lifecycle summary and logging the outputs. +<!-- sphinx +(print-lifecycle-summary)= +--> ## Print Lifecycle Summary In [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0), a new method called `summary()` is supported. It prints a Keras-like summary of the PyGAD lifecycle showing the steps, callback functions, parameters, etc. @@ -121,6 +124,70 @@ On Generation on_gen() None ====================================================================== ``` +<!-- python-examples +example_summary.py +--> + +**Python example** + +**[Text lifecycle summary](../../examples/example_summary.py)** + +Print the configured GA stages and their parameters. + +`examples/example_summary.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_summary.py +``` + +</details> + +<!-- /python-examples --> + +## Plot Lifecycle Chart + +Use `plot_lifecycle()` to draw the configured lifecycle as a flowchart with operators, callbacks, population replacement, and stopping decisions. It works before or after `run()`. + +```python +ga_instance.plot_lifecycle(save_dir="lifecycle.svg") +``` + +The method returns a matplotlib figure. Use `show_parameters=False` for a compact chart or `show=False` to save without displaying it. See [plot_lifecycle()](visualize.md#plot_lifecycle) for the full description and an example. + +<!-- python-examples +plots/example_plot_lifecycle.py +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +</details> + +<!-- /python-examples --> + ## Logging Outputs In [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0), the `print()` statement is no longer used and the outputs are printed using the [logging](https://docs.python.org/3/library/logging.html) module. A new parameter called `logger` is supported to accept a user-defined logger. @@ -371,3 +438,30 @@ By executing this code, the logged messages are printed to the console and also 2023-04-03 19:04:27 INFO: Generation = 10 2023-04-03 19:04:27 INFO: Fitness = 0.000389832593101348 ``` + +<!-- python-examples +example_logger.py +--> + +**Python example** + +**[Logging](../../examples/example_logger.py)** + +Send progress and GA messages to a configured logger. + +`examples/example_logger.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_logger.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/multi_objective.md b/docs/source/multi_objective.md index 2779194e..e95f510c 100644 --- a/docs/source/multi_objective.md +++ b/docs/source/multi_objective.md @@ -120,6 +120,9 @@ This is the figure created by the `plot_fitness()` method. The fitness of the fi ![multi-objective-pygad](https://github.com/ahmedfgad/GeneticAlgorithmPython/assets/16560492/7896f8d8-01c5-4ff9-8d15-52191c309b63) +<!-- sphinx +(nsga3-guide)= +--> ## NSGA-III Example This is the same problem solved with `nsga3` instead of `nsga2`. The only differences are the `parent_selection_type` value and the new `nsga3_num_divisions` parameter. @@ -169,3 +172,50 @@ print(f"Predicted output 2 based on the best solution : {prediction}") ``` For M = 2 objectives and `nsga3_num_divisions = 12`, the number of reference points is `C(13, 12) = 13`, which is within `sol_per_pop = 20`. For higher-dimensional problems pick `nsga3_num_divisions` such that `C(M + p - 1, p)` stays close to the population size you want. + +<!-- python-examples +example_multi_objective.py +example_multi_objective_nsga3.py +--> + +**Python examples** + +**[NSGA-II optimization](../../examples/example_multi_objective.py)** + +Optimize two objectives and inspect the resulting trade-offs. + +`examples/example_multi_objective.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_multi_objective.py +``` + +</details> + +**[NSGA-III optimization](../../examples/example_multi_objective_nsga3.py)** + +Configure reference points and optimize two objectives with NSGA-III. + +`examples/example_multi_objective_nsga3.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_multi_objective_nsga3.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn.md b/docs/source/nn.md index 8688cb6a..50009bdb 100644 --- a/docs/source/nn.md +++ b/docs/source/nn.md @@ -429,31 +429,16 @@ It is very important to note that it is not expected that the classification acc This section gives the complete code of some examples that build neural networks using `pygad.nn`. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} XOR Classification -:link: nn_xor -:link-type: doc -::: - -:::{grid-item-card} Image Classification -:link: nn_image_classification -:link-type: doc -::: - -:::{grid-item-card} Regression Example 1 -:link: nn_regression_1 -:link-type: doc -::: - -:::{grid-item-card} Regression Example 2 - Fish Weight Prediction -:link: nn_regression_2 -:link-type: doc -::: +- [XOR Classification](nn_xor.md) +- [Image Classification](nn_image_classification.md) +- [Regression Example 1](nn_regression_1.md) +- [Regression Example 2 - Fish Weight Prediction](nn_regression_2.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -462,3 +447,4 @@ nn_image_classification nn_regression_1 nn_regression_2 ::: +--> diff --git a/docs/source/nn_image_classification.md b/docs/source/nn_image_classification.md index 5e2ca766..98a1df90 100644 --- a/docs/source/nn_image_classification.md +++ b/docs/source/nn_image_classification.md @@ -50,3 +50,56 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +<!-- python-examples +nn/example_classification.py +nn/extract_features.py +--> + +**Python examples** + +**[Image classification](../../examples/nn/example_classification.py)** + +Classify fruit images from prepared feature vectors. + +`examples/nn/example_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python example_classification.py +``` + +</details> + +**[Prepare image features](../../examples/nn/extract_features.py)** + +Extract fruit-image features and write the arrays used by the dense classifiers. + +`examples/nn/extract_features.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, scikit-image + +**Data:** The apple, lemon, mango, and raspberry folders under examples/data/Fruit360/. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python extract_features.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_regression_1.md b/docs/source/nn_regression_1.md index 22d18061..565ff3f5 100644 --- a/docs/source/nn_regression_1.md +++ b/docs/source/nn_regression_1.md @@ -68,3 +68,30 @@ predictions = pygad.nn.predict(last_layer=output_layer, abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` + +<!-- python-examples +nn/example_regression.py +--> + +**Python example** + +**[Regression](../../examples/nn/example_regression.py)** + +Fit a neural network to a small numeric regression problem. + +`examples/nn/example_regression.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/nn/example_regression.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_regression_2.md b/docs/source/nn_regression_2.md index d78971a9..83bea213 100644 --- a/docs/source/nn_regression_2.md +++ b/docs/source/nn_regression_2.md @@ -71,3 +71,33 @@ predictions = pygad.nn.predict(last_layer=output_layer, abs_error = numpy.mean(numpy.abs(predictions - data_outputs)) print(f"Absolute error : {abs_error}.") ``` + +<!-- python-examples +nn/example_regression_fish.py +--> + +**Python example** + +**[Fish-weight regression](../../examples/nn/example_regression_fish.py)** + +Predict fish weight from numeric measurements. + +`examples/nn/example_regression_fish.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, pandas + +**Data:** examples/data/Fish.csv. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/nn/ so the relative data paths resolve: + +```console +cd examples/nn +python example_regression_fish.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/nn_xor.md b/docs/source/nn_xor.md index 78480c2c..a1de725c 100644 --- a/docs/source/nn_xor.md +++ b/docs/source/nn_xor.md @@ -48,3 +48,30 @@ print(f"Number of correct classifications : {num_correct}.") print(f"Number of wrong classifications : {num_wrong.size}.") print(f"Classification accuracy : {accuracy}.") ``` + +<!-- python-examples +nn/example_XOR_classification.py +--> + +**Python example** + +**[XOR classification](../../examples/nn/example_XOR_classification.py)** + +Train a neural network on the four XOR inputs. + +`examples/nn/example_XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/nn/example_XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/pygad.md b/docs/source/pygad.md index b660accc..66d09275 100644 --- a/docs/source/pygad.md +++ b/docs/source/pygad.md @@ -8,50 +8,62 @@ With the `pygad` module, you can create, run, save, and load instances of the ge The `pygad` module has a class named `GA` for building the genetic algorithm. This section explains the class constructor, its methods, functions, and attributes. +<!-- sphinx +(ga-constructor)= +--> ### `__init__()` To create an instance of the `pygad.GA` class, the constructor accepts several parameters. These let you adjust the genetic algorithm for different types of applications. +The constructor validates settings before generating a population or calling gene constraints. Counts accept Python and NumPy integers, excluding Boolean values; ranges, probabilities, and distribution indices must be finite. Mutable settings such as gene spaces, per-gene ranges, and adaptive mutation rates are copied, so changing the original containers does not change the GA configuration. + The `pygad.GA` class constructor supports the parameters below, grouped by purpose. Click a parameter to expand its full description. #### Population and Generations -:::{dropdown} `num_generations`: Number of generations to run. -:animate: fade-in-slide-down +<details> +<summary><code>num_generations</code>: Number of generations to run.</summary> -Number of generations. -::: +Number of generations per `run()` call. Must be a non-negative integer; `0` evaluates the initial population without evolving it. -:::{dropdown} `num_parents_mating`: How many solutions are selected as parents. -:animate: fade-in-slide-down +</details> -Number of solutions to be selected as parents. -::: +<details> +<summary><code>num_parents_mating</code>: How many solutions are selected as parents.</summary> -:::{dropdown} `sol_per_pop`: Number of solutions in the population. -:animate: fade-in-slide-down +Number of solutions to be selected as parents. Must be an integer between `1` and `sol_per_pop`, inclusive. + +</details> + +<details> +<summary><code>sol_per_pop</code>: Number of solutions in the population.</summary> Number of solutions (i.e. chromosomes) within the population. This parameter has no action if `initial_population` parameter exists. -::: -:::{dropdown} `num_genes`: Number of genes in each solution. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>num_genes</code>: Number of genes in each solution.</summary> Number of genes in the solution/chromosome. This parameter is not needed if the user feeds the initial population to the `initial_population` parameter. -::: -:::{dropdown} `initial_population`: Start from your own population. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>initial_population</code>: Start from your own population.</summary> A population you provide yourself to start the run instead of a random one. It defaults to `None`, in which case PyGAD builds the initial population from the `sol_per_pop` and `num_genes` parameters. +Pass a non-empty rectangular 2D list, tuple, or NumPy array of numeric values. PyGAD infers both dimensions from its shape, overriding any explicit `sol_per_pop` and `num_genes` values. It copies and converts the values using `gene_type`, applies `gene_constraint`, and repairs duplicates when `allow_duplicate_genes=False`. Supplied values may lie outside the gene space or initialization ranges; replacements follow the initialization settings. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). + If `initial_population` is `None` and either `sol_per_pop` or `num_genes` is also `None`, an exception is raised. Introduced in [PyGAD 2.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-0-0) and higher. -::: -:::{dropdown} `stop_criteria=None`: Stop early when a condition is met. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>stop_criteria=None</code>: Stop early when a condition is met.</summary> One or more conditions that stop the evolution early. Each criterion is a string made of a stop word and a number, like `"reach_40"`. @@ -62,15 +74,20 @@ Four stop words are supported: - `time`: stop when the time spent inside `run()` is at least the given number of seconds. Example: `"time_30"` stops the run after 30 seconds. - `evaluations`: stop when the number of solutions whose fitness was evaluated inside `run()` reaches the given count. Example: `"evaluations_1000"` stops after at least 1000 solution evaluations. A batch counts once per solution, and adaptive mutation's offspring evaluations are included. Cached fitness values do not count. The criterion is checked at generation boundaries, so the count can exceed the threshold. -You can also pass a list of criteria; the run stops as soon as any one of them is met. +You can also pass a list, tuple, or 1D NumPy array of criteria; the run stops as soon as any one of them is met. Duplicate criteria are removed while preserving their order. + +The counts for `saturate` and `evaluations` must be positive integers. Fractional counts are rejected. The threshold for `reach` must be finite, and the duration for `time` must be finite and non-negative; `time_0` stops at the first stopping check. Scientific notation is accepted, for example `evaluations_1e3` or `time_1e-2`. For multi-objective problems, `reach_10_20` requires both objective thresholds to be met; a single threshold applies to every objective. + +`saturate_N` counts consecutive completed generations whose best fitness equals the preceding population's best fitness, including the initial population as the baseline. `saturate_1` stops after one unchanged generation. Any change resets the count, so matching endpoints with changes in between do not count as saturation. Multi-objective problems compare the whole best-fitness vector. Each `run()` starts a new saturation count while `generations_completed` continues increasing across runs. Added in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). The `time` and `evaluations` keywords were added in PyGAD 3.6.0. -::: + +</details> #### Fitness Function -:::{dropdown} `fitness_func`: Function that scores each solution. -:animate: fade-in-slide-down +<details> +<summary><code>fitness_func</code>: Function that scores each solution.</summary> The function, bound method, or callable instance that calculates the fitness of a solution. This is the one parameter you almost always need to set. @@ -82,15 +99,16 @@ A fitness **function** must accept 3 parameters: If you pass a **method**, it takes a fourth parameter for the method's class instance. -A callable instance's `__call__(self, ga_instance, solution, solution_idx)` uses the same three fitness arguments after `self`. Process evaluation transports the callable and current GA state with cloudpickle; custom attributes and resources must be serializable. Thread evaluation shares the GA instance, so changes to shared state need synchronization. +A callable instance's `__call__(self, ga_instance, solution, solution_idx)` uses the same three fitness arguments after `self`. Functions, bound methods, callable instances, and `functools.partial` are supported when their signatures accept PyGAD's positional arguments. Additional optional parameters are allowed. Required keyword-only parameters and asynchronous callables are rejected during construction. Process evaluation transports the callable and current GA state with cloudpickle; custom attributes and resources must be serializable. Thread evaluation shares the GA instance, so changes to shared state need synchronization. Return a single number for a single-objective problem, or a `list`, `tuple`, or `numpy.ndarray` for a multi-objective problem (supported since [PyGAD 3.2.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-2-0)). See [Preparing the fitness_func Parameter](https://pygad.readthedocs.io/en/latest/steps_to_use.html#preparing-the-fitness-func-parameter) for how to build one. -::: -:::{dropdown} `fitness_batch_size=None`: Score the solutions in batches. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>fitness_batch_size=None</code>: Score the solutions in batches.</summary> Calculates the fitness in batches instead of one solution at a time. @@ -100,30 +118,36 @@ Calculates the fitness in batches instead of one solution at a time. In batch mode, the second fitness argument is a two-dimensional array of solutions and the third is a list of their population indices. Adaptive mutation passes `None` as the third argument instead. Return a `list`, `tuple`, or NumPy array with one fitness value per solution (a scalar for each single-objective solution, or an objective vector for each multi-objective solution). Cached rows are skipped, so batches can contain non-contiguous indices and the final batch can be smaller than the configured size. See [Batch Fitness Calculation](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#batch-fitness-calculation) for details and examples. Added in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). -::: + +</details> #### Genes: Values and Types -:::{dropdown} `gene_type=float`: Data type (and precision) of the genes. -:animate: fade-in-slide-down +<details> +<summary><code>gene_type=float</code>: Data type (and precision) of the genes.</summary> Sets the data type (and optional precision) of the genes. It defaults to `float`, so every gene is a `float`. You can set it to: -- **One type for all genes:** a numeric type such as `int`, `float`, or any `numpy.int/uint/float(8-64)` type. Example: `gene_type=int`. +- **One type for all genes:** `int`, `float`, NumPy signed or unsigned integer types with widths of 8, 16, 32, or 64 bits, or `numpy.float16`, `numpy.float32`, or `numpy.float64`. Example: `gene_type=int`. - **A type per gene:** a `list`, `tuple`, or `numpy.ndarray` with one type per gene. Example: `gene_type=[int, float, numpy.int8]`. - **A float precision:** pair a `float` type with the number of decimal places. Example: `gene_type=[float, 2]`. +Integer conversion truncates towards zero. Floating-point values are rounded before casting, using nearest-even rounding at halfway points. Precision can be `None` to leave values unrounded, or an integer including `0` and negative values. For example, `[float, -1]` rounds to tens. Integer types may only be paired with `None`. + +The same rules apply to initialization, mutation, custom operators, and the outputs of `on_parents`, `on_crossover`, and `on_mutation`, independently of `allow_duplicate_genes`. Per-gene type specifications and saved best solutions preserve mixed scalar types using object arrays. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). + Version history: - [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0): a single numeric type can be used. - [PyGAD 2.14.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-14-0): a type per gene can be used. - [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0): a precision can be set for `float` types. -::: -:::{dropdown} `gene_space=None`: Allowed values or range for each gene. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>gene_space=None</code>: Allowed values or range for each gene.</summary> Sets the allowed values for each gene, so you can limit the search space to a range or to a set of discrete values. @@ -140,50 +164,58 @@ Version history: - [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0): NumPy arrays can be used. - [PyGAD 2.11.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-11-0): a dictionary can set the low and high limits. - [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0): the `"step"` key was added. -::: -:::{dropdown} `gene_constraint=None`: Functions that restrict gene values. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>gene_constraint=None</code>: Functions that restrict gene values.</summary> -A list of callables (functions), one per gene, that restrict the values a gene can take. Before a value is chosen for a gene, its callable checks that the candidate value is valid. +A list of callables (functions), one per gene, that restrict the values a gene can take. Before a value is chosen for a gene, its callable checks that the candidate value is valid. A list or tuple must contain exactly one callable or `None` per gene. Each callable must accept the solution and candidate values as 2 positional arguments; bound methods, callable instances, and partial functions are supported. Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. -::: -:::{dropdown} `init_range_low=-4`: Lower bound for the initial gene values. -:animate: fade-in-slide-down +</details> -The lower value of the random range from which the gene values in the initial population are selected. `init_range_low` defaults to `-4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. This parameter has no action if the `initial_population` parameter exists. -::: +<details> +<summary><code>init_range_low=-4</code>: Lower bound for the initial gene values.</summary> -:::{dropdown} `init_range_high=4`: Upper bound for the initial gene values. -:animate: fade-in-slide-down +The lower value of the random range from which the gene values in the initial population are selected. `init_range_low` defaults to `-4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). -The upper value of the random range from which the gene values in the initial population are selected. `init_range_high` defaults to `+4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. This parameter has no action if the `initial_population` parameter exists. -::: +</details> -:::{dropdown} `allow_duplicate_genes=True`: Allow repeated values within a solution. -:animate: fade-in-slide-down +<details> +<summary><code>init_range_high=4</code>: Upper bound for the initial gene values.</summary> -Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. +The upper value of the random range from which the gene values in the initial population are selected. `init_range_high` defaults to `+4`. Available in [PyGAD 1.0.20](https://pygad.readthedocs.io/en/latest/releases.html#pygad-1-0-20) and higher. Supplied values are preserved, but this bound is used when replacing a value to satisfy a constraint or repair duplicates. Generated range values stay within their bounds after conversion and rounding. See [Creating the Initial Population](https://pygad.readthedocs.io/en/latest/gene_values.html#creating-the-initial-population). -For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See {ref}`Mutation Methods <mutation-methods>`. -::: +</details> + +<details> +<summary><code>allow_duplicate_genes=True</code>: Allow repeated values within a solution.</summary> -:::{dropdown} `sample_size=100`: Sample size used when searching for a valid value. -:animate: fade-in-slide-down +Added in [PyGAD 2.13.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-13-0). If `True`, then a solution/chromosome may have duplicate gene values. If `False`, PyGAD tries to give each gene a different numeric value after conversion and rounding. Repair can follow chains of replacements using each destination gene's space, range, type, precision, and constraint. If no usable alternative is found, duplicates remain with a warning unless warnings are suppressed. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). + +For permutation encodings where every value in `gene_space` is already used, random and adaptive mutation try a compatible swap instead of keeping the selected gene unchanged. The fallback preserves destination gene types, numeric values, gene spaces, uniqueness, and constraints. Each gene can participate in at most one fallback swap per mutation pass. If no compatible partner exists, the gene stays unchanged. See [Mutation Methods](utils.md#mutation-methods). + +</details> + +<details> +<summary><code>sample_size=100</code>: Sample size used when searching for a valid value.</summary> The size of the sample of candidate values PyGAD draws when it needs to pick a gene value. It defaults to `100`. It is useful when `allow_duplicate_genes=False` or `gene_constraint` is used. If PyGAD cannot find a unique value or a value that meets a constraint, increase this parameter. +Duplicate repair considers finite spaces in full. For constraints depending on other genes, an additional search checks up to `sample_size * num_genes` tentative assignments when replacement chains do not satisfy all constraints. + Added in [PyGAD 3.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-5-0). See the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. -::: + +</details> #### Parent Selection -:::{dropdown} `parent_selection_type="sss"`: How the parents are selected. -:animate: fade-in-slide-down +<details> +<summary><code>parent_selection_type="sss"</code>: How the parents are selected.</summary> How the parents are selected. It defaults to `"sss"`. @@ -201,26 +233,29 @@ The built-in types are: - `tournament_nsga3`: Tournament selection that ranks competitors with NSGA-III niche count instead of crowding distance. Requires the `nsga3_num_divisions` parameter. You can also pass your own parent selection function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). -::: -:::{dropdown} `K_tournament=3`: Contestants per tournament selection. -:animate: fade-in-slide-down +</details> -In case that the parent selection type is `tournament`, the `K_tournament` specifies the number of parents participating in the tournament selection. It defaults to `3`. -::: +<details> +<summary><code>K_tournament=3</code>: Contestants per tournament selection.</summary> + +For `tournament`, `tournament_nsga2`, and `tournament_nsga3`, this is the number of contestants per tournament. It must be a positive integer and defaults to `3`. Values larger than `sol_per_pop` are clipped to the population size with a warning unless warnings are suppressed. Other selection types do not use this parameter. + +</details> -:::{dropdown} `nsga3_num_divisions=None`: Number of divisions per objective axis for NSGA-III. -:animate: fade-in-slide-down +<details> +<summary><code>nsga3_num_divisions=None</code>: Number of divisions per objective axis for NSGA-III.</summary> Only used when `parent_selection_type` is `'nsga3'` or `'tournament_nsga3'`. It is the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. Must be a positive integer. Defaults to `None`. If `sol_per_pop` is smaller than the resulting number of reference points, PyGAD raises a warning and grows the population to match before the generational loop starts. -::: + +</details> #### Keeping Solutions -:::{dropdown} `keep_elitism=1`: Keep the best solutions each generation. -:animate: fade-in-slide-down +<details> +<summary><code>keep_elitism=1</code>: Keep the best solutions each generation.</summary> The number of best solutions (the elitism) to keep in the next generation. It defaults to `1`, so only the best solution is kept. @@ -230,10 +265,11 @@ The number of best solutions (the elitism) to keep in the next generation. It de If this parameter is not `0`, then `keep_parents` has no effect. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). To see how `keep_elitism` and `keep_parents` work together, see [How the Number of Offspring Is Decided](https://pygad.readthedocs.io/en/latest/generations.html#how-the-number-of-offspring-is-decided). -::: -:::{dropdown} `keep_parents=-1`: Keep the parents in the next generation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>keep_parents=-1</code>: Keep the parents in the next generation.</summary> The number of parents to keep in the next population. It defaults to `-1`. @@ -241,17 +277,18 @@ The number of parents to keep in the next population. It defaults to `-1`. - `0`: keep no parents. - A positive integer: keep that many parents. -The value cannot be less than `-1` or greater than `sol_per_pop`. +The value must be an integer from `-1` through `num_parents_mating`. Passing `None` uses the default of keeping all parents. This parameter has an effect only when `keep_elitism=0` (since [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0)). Since PyGAD 2.20.0, the parents' fitness from the last generation is not re-used if `keep_parents=0`. To see how `keep_parents` and `keep_elitism` work together, see [How the Number of Offspring Is Decided](https://pygad.readthedocs.io/en/latest/generations.html#how-the-number-of-offspring-is-decided). -::: + +</details> #### Crossover -:::{dropdown} `crossover_type="single_point"`: How parents are combined into offspring. -:animate: fade-in-slide-down +<details> +<summary><code>crossover_type="single_point"</code>: How parents are combined into offspring.</summary> The type of crossover. It defaults to `"single_point"`. @@ -266,30 +303,36 @@ The built-in types are: You can also pass your own crossover function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). If `crossover_type=None`, the crossover step is skipped and no offspring are created, so the next generation reuses the current population (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `sbx_crossover_eta=30`: Distribution index for SBX crossover. -:animate: fade-in-slide-down +</details> -Only used when `crossover_type` is `'sbx'`. Sets how close the children stay to the parents. A higher value means children stay closer. Must be a positive number. Defaults to `30`. +<details> +<summary><code>sbx_crossover_eta=30</code>: Distribution index for SBX crossover.</summary> -Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds are taken from `init_range_low` and `init_range_high`. -::: +Only used when `crossover_type` is `'sbx'`. Sets how close the children stay to the parents. A higher value means children stay closer. Must be a finite positive number. Defaults to `30`. + +Each crossed gene selects the lower or upper SBX child with equal probability. This avoids consistently moving genes below their parents' midpoint. The bounds come from each gene's `gene_space`, falling back to its initialization range for a `None` space. Reversed initialization bounds are sorted for calculation. Supplied parent values outside these bounds are clipped before the SBX calculation. Results apply the destination type, precision, space, constraints, and duplicate policy; an invalid proposed child falls back to its parent. + +</details> -:::{dropdown} `crossover_probability=None`: Chance a parent is used for crossover. -:animate: fade-in-slide-down +<details> +<summary><code>crossover_probability=None</code>: Chance a parent is used for crossover.</summary> The probability of selecting a parent for crossover. Its value must be between 0.0 and 1.0. -For each parent, a random value between 0.0 and 1.0 is generated. If that value is less than or equal to `crossover_probability`, the parent is selected. +For each parent, a random value between 0.0 and 1.0 is generated. If that value is less than `crossover_probability`, the parent is selected. Setting `0` copies parents without crossing them. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. -::: +</details> + +<!-- sphinx +(mutation-controls)= +--> #### Mutation -:::{dropdown} `mutation_type="random"`: How offspring genes are mutated. -:animate: fade-in-slide-down +<details> +<summary><code>mutation_type="random"</code>: How offspring genes are mutated.</summary> The type of mutation. It defaults to `"random"`. @@ -304,74 +347,86 @@ The built-in types are: You can also pass your own mutation function (since [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0)). See [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators). +Permutation mutations (`swap`, `inversion`, and `scramble`) check the complete proposed solution against destination gene spaces, types, constraints, and the duplicate policy. An incompatible permutation is retried; if no acceptable proposal is found, the solution is retained. With no explicit mutation probability, count, or percentage, swap selects one pair and inversion/scramble use their usual half-length segment. When a control is explicitly supplied, it selects the eligible positions: swap exchanges pairs, inversion reverses the selected values, and scramble shuffles them. Fewer than 2 eligible positions leave the solution unchanged; an odd number in swap leaves one eligible position unpaired. + If `mutation_type=None`, the mutation step is skipped and the offspring are used unchanged (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `polynomial_mutation_eta=20`: Distribution index for polynomial mutation. -:animate: fade-in-slide-down +</details> -Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A higher value means a smaller change. Must be a positive number. Defaults to `20`. -::: +<details> +<summary><code>polynomial_mutation_eta=20</code>: Distribution index for polynomial mutation.</summary> -:::{dropdown} `mutation_probability=None`: Per-gene chance of mutation. -:animate: fade-in-slide-down +Only used when `mutation_type` is `'polynomial'`. Sets the size of the change. A higher value means a smaller change. Must be a finite positive number. Defaults to `20`. + +Polynomial mutation uses the gene-space bounds, falling back to the initialization range for a `None` space. It supports probabilities, explicit counts, and percentages. When none is explicitly supplied, its default per-gene probability is `1 / num_genes`. It clips supplied values before calculation, converts and rounds the results, and checks spaces, constraints, and duplicates before accepting the complete solution. + +</details> + +<details> +<summary><code>mutation_probability=None</code>: Per-gene chance of mutation.</summary> The probability of selecting a gene for mutation. Its value must be between 0.0 and 1.0. -For each gene, a random value between 0.0 and 1.0 is generated. If that value is less than or equal to `mutation_probability`, the gene is mutated. +For each gene, a random value between 0.0 and 1.0 is generated. If that value is less than `mutation_probability`, the gene is selected. Setting `0` leaves the offspring unchanged, including permutation and polynomial mutation; `1` selects every gene. Adaptive mutation takes 2 probabilities, for below-average and above-average solutions, respectively. -If this parameter is set, you do not need `mutation_percent_genes` or `mutation_num_genes`. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. -::: +Only the active mutation control is validated: `mutation_probability` takes precedence over `mutation_num_genes`, which takes precedence over `mutation_percent_genes`. Values for inactive controls are ignored. Built-in operators apply these controls; custom mutation functions implement their own selection rules. Added in [PyGAD 2.5.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-5-0) and higher. + +</details> -:::{dropdown} `mutation_by_replacement=False`: Replace the gene value instead of adding to it. -:animate: fade-in-slide-down +<details> +<summary><code>mutation_by_replacement=False</code>: Replace the gene value instead of adding to it.</summary> -A bool that controls how `random` mutation changes a gene. It works only when `mutation_type="random"`. +A bool that controls how `random` and `adaptive` mutation change a gene when drawing random values. Finite gene spaces select replacement values directly. - `True`: replace the gene with the randomly generated value. - `False` (default): add the random value to the gene. Supported in [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) and higher. See the [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2) release notes for an example. -::: -:::{dropdown} `mutation_percent_genes="default"`: Percentage of genes to mutate. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>mutation_percent_genes="default"</code>: Percentage of genes to mutate.</summary> The percentage of genes to mutate. It defaults to the string `"default"`, which becomes `10` (10% of the genes). The value must be `> 0` and `<= 100`. -PyGAD uses this percentage to compute `mutation_num_genes`. +PyGAD computes `mutation_num_genes` by multiplying the percentage by `num_genes` and discarding the fractional part. If this gives `0`, it selects `1` gene and warns unless warnings are suppressed. Adaptive mutation requires 2 percentages, for below-average and above-average solutions, respectively. This parameter has no effect if `mutation_probability` or `mutation_num_genes` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `mutation_num_genes=None`: Number of genes to mutate. -:animate: fade-in-slide-down +</details> -The number of genes to mutate. It defaults to `None`, meaning no number is set. +<details> +<summary><code>mutation_num_genes=None</code>: Number of genes to mutate.</summary> + +The number of genes eligible for mutation. It defaults to `None`, meaning no number is set. When active, it must be an integer between `1` and `num_genes`, inclusive. Adaptive mutation requires 2 counts, for below-average and above-average solutions, respectively. Permutation operators may leave selected positions unchanged when a compatible rearrangement is unavailable. This parameter has no effect if `mutation_probability` is set, or if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `random_mutation_min_val=-1.0`: Lower bound of the random mutation value. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_mutation_min_val=-1.0</code>: Lower bound of the random mutation value.</summary> For `random` mutation, the start of the range from which a random value is drawn and added to the gene. It defaults to `-1`. This parameter has no effect if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: -:::{dropdown} `random_mutation_max_val=1.0`: Upper bound of the random mutation value. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_mutation_max_val=1.0</code>: Upper bound of the random mutation value.</summary> For `random` mutation, the end of the range from which a random value is drawn and added to the gene. It defaults to `+1`. This parameter has no effect if `mutation_type` is `None` (since [PyGAD 2.2.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-2-2)). -::: + +</details> #### Lifecycle Callbacks -:::{dropdown} `on_start=None`: Called once before the run starts. -:animate: fade-in-slide-down +<details> +<summary><code>on_start=None</code>: Called once before the run starts.</summary> A function (or method) called once before the run starts. @@ -379,21 +434,23 @@ A function (or method) called once before the run starts. - As a **method**, it takes a second parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_fitness=None`: Called after the fitness is calculated. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_fitness=None</code>: Called after the fitness is calculated.</summary> -A function (or method) called after the fitness of all solutions is calculated. +A function (or method) called before parent selection after the fitness of all solutions is calculated. It receives `on_fitness(ga_instance, population_fitness)` and may return replacement fitness with the same shape, or modify the supplied array in place and return `None`. Both forms are validated before selection, and saved best solutions are updated to agree with the resulting fitness. -- As a **function**, it takes 2 parameters: a list of all the solutions' fitness values, and the instance of the genetic algorithm. +- As a **function**, it takes 2 parameters: the instance of the genetic algorithm, and a NumPy array of all the solutions' fitness values. - As a **method**, it takes a third parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_parents=None`: Called after the parents are selected. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_parents=None</code>: Called after the parents are selected.</summary> A function (or method) called after the parents are selected. @@ -401,96 +458,110 @@ A function (or method) called after the parents are selected. - As a **method**, it takes a third parameter for the method's object. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_crossover=None`: Called after crossover. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_crossover=None</code>: Called after crossover.</summary> A function called each time crossover is applied. It takes 2 parameters: the instance of the genetic algorithm, and the offspring generated by crossover. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_mutation=None`: Called after mutation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_mutation=None</code>: Called after mutation.</summary> A function called each time mutation is applied. It takes 2 parameters: the instance of the genetic algorithm, and the offspring after mutation. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_generation=None`: Called after each generation. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_generation=None</code>: Called after each generation.</summary> A function called after each generation. It takes 1 parameter: the instance of the genetic algorithm. If it returns the string `"stop"`, the `run()` method stops without completing the remaining generations. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: -:::{dropdown} `on_stop=None`: Called once when the run ends. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>on_stop=None</code>: Called once when the run ends.</summary> A function called once just before the run ends (or after the last generation). It takes 2 parameters: the instance of the genetic algorithm, and the list of the last population's fitness values. Added in [PyGAD 2.6.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-6-0). -::: + +</details> #### Saving and Logging -:::{dropdown} `save_best_solutions=False`: Save the best solution of each generation. -:animate: fade-in-slide-down +<details> +<summary><code>save_best_solutions=False</code>: Save the best solution of each generation.</summary> When `True`, the best solution of each generation is saved into the `best_solutions` attribute. When `False` (default), nothing is saved and `best_solutions` stays empty. Supported in [PyGAD 2.9.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-9-0). -::: -:::{dropdown} `save_solutions=False`: Save every solution of each generation. -:animate: fade-in-slide-down +</details> -If `True`, then all solutions in each generation are appended into an attribute called `solutions` which is NumPy array. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). -::: +<details> +<summary><code>save_solutions=False</code>: Save every solution of each generation.</summary> + +If `True`, then all solutions in each generation are appended into the `solutions` list, with their fitness in `solutions_fitness`. Each run includes its starting and final populations. `solutions_generations` records one generation number per saved population. Supported in [PyGAD 2.15.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-15-0). + +</details> -:::{dropdown} `logger=None`: Custom logger for the outputs. -:animate: fade-in-slide-down +<details> +<summary><code>logger=None</code>: Custom logger for the outputs.</summary> -An instance of the `logging.Logger` class used to log the outputs. When set, messages are logged instead of printed with `print()`. If `None`, PyGAD creates a logger that uses a `StreamHandler` to write the messages to the console. +An instance of the `logging.Logger` class used to log the outputs. When set, messages are logged instead of printed with `print()`. If `None`, PyGAD uses its default logger and adds a console `StreamHandler` only when it has no handlers. Existing handlers are preserved. Invalid logger values raise a `TypeError` naming the parameter. Added in [PyGAD 3.0.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-0-0). See [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. -::: -:::{dropdown} `suppress_warnings=False`: Turn warning messages on or off. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>suppress_warnings=False</code>: Turn warning messages on or off.</summary> A bool parameter to control whether the warning messages are printed or not. It defaults to `False`. -::: + +</details> #### Performance and Reproducibility -:::{dropdown} `parallel_processing=None`: Use threads or processes to speed up fitness. -:animate: fade-in-slide-down +<details> +<summary><code>parallel_processing=None</code>: Use threads or processes to speed up fitness.</summary> Runs the fitness calculation in parallel. It defaults to `None` (no parallel processing). You can set it to: +- **`None` or integer `0`:** disable parallel processing. - **A positive integer:** the number of threads. Example: `parallel_processing=5` uses 5 threads (the same as `["thread", 5]`). - **A list/tuple of 2 elements:** the first is `"process"` or `"thread"`; the second is a positive maximum worker count, `None` for the executor's default, or `0` to disable parallel processing. Example: `parallel_processing=["process", 10]` uses up to 10 processes. Validation normalizes a positive integer to `["thread", count]` and a zero worker count to `None`. Workers are created only for uncached fitness work and reused during one `run()`, including adaptive offspring evaluation. Completion, early stopping, and exceptions shut down the pool. Calling `cal_pop_fitness()` outside a run uses a temporary pool. Process-based scripts should start the GA under `if __name__ == "__main__":`. Added in [PyGAD 2.17.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-17-0). See [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) for more information. -::: -:::{dropdown} `random_seed=None`: Seed for reproducible runs. -:animate: fade-in-slide-down +</details> + +<details> +<summary><code>random_seed=None</code>: Seed for reproducible runs.</summary> + +The seed for this instance's NumPy and Python random generators. Accepts `None` or a Python/NumPy integer from `0` through `2**32 - 1`. Setting it makes built-in operations reproducible (for example, `random_seed=2`). With `None`, each instance starts with an automatically initialized state. -The random seed used by the NumPy and `random` number generators. Setting it makes runs reproducible (for example, `random_seed=2`). It defaults to `None`, which means no seed is used. +Each GA owns `numpy_random_generator` (a `numpy.random.RandomState`) and `python_random_generator` (a `random.Random`). Creating or running another GA and drawing from the global generators do not alter its state. Repeated `run()` calls continue the same generator states, and checkpoints preserve them. To reproduce random draws in custom operators or callbacks, use these instance generators, for example `ga_instance.numpy_random_generator.random()`. Global random draws in user code need their own seed. Seeded results may differ between PyGAD versions. See `examples/example_constructor_parameters.py` for an example. Added in [PyGAD 2.18.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-18-0). -::: + +</details> You do not have to set all of these parameters when you create an instance of the `GA` class. The most important one is `fitness_func`, which defines the fitness function. @@ -500,6 +571,33 @@ If both the `mutation_type` and `crossover_type` parameters are `None`, then the The parameters are validated by calling the `validate_parameters()` method of the `utils.validation.Validation` class inside the constructor. If any parameter is not correct, an exception is raised and the `valid_parameters` attribute is set to `False`. +<!-- python-examples +example_constructor_parameters.py +--> + +**Python example** + +**[Constructor settings and random seeds](../../examples/example_constructor_parameters.py)** + +Use callable fitness signatures, NumPy counts, and independent seeded GA instances. + +`examples/example_constructor_parameters.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_constructor_parameters.py +``` + +</details> + +<!-- /python-examples --> + ## Extended Classes To keep the library modular and structured, the code is split into several scripts, where each script has one or more classes. Each class has its own purpose. @@ -530,8 +628,10 @@ Here is the list of scripts and the classes that the `pygad.GA` class extends: 1. `helper.misc.Helper`: Generic helpers used across the library (population dtype handling, per-gene value generation, constraint sampling, lifecycle summary). 12. `visualize/plot.py` 1. `visualize.plot.Plot`: All plot methods. See [`pygad.visualize`](https://pygad.readthedocs.io/en/latest/visualize.html). +13. `visualize/lifecycle.py` + 1. Internal helpers that describe the configured lifecycle and draw its flowchart without running the GA or calling user functions. -`utils.engine.GAEngine` also extends `utils.parallel.FitnessEvaluation`, so `pygad.GA` indirectly inherits its fitness dispatch and serialization methods. See the {ref}`pygad.utils.parallel reference <fitness-evaluation>` for all of its methods, the process-worker function, and runtime attributes. +`utils.engine.GAEngine` also extends `utils.parallel.FitnessEvaluation`, so `pygad.GA` indirectly inherits its fitness dispatch and serialization methods. See the [pygad.utils.parallel reference](utils.md#pygadutilsparallel-submodule) for all of its methods, the process-worker function, and runtime attributes. Since the `pygad.GA` class extends such classes, the attributes and methods inside them can be retrieved by instances of the `pygad.GA` class. @@ -543,7 +643,7 @@ Since the `pygad.GA` class extends such classes, the attributes and methods insi ### Other Instance Attributes & Methods -Constructor settings and user callables are stored as instance attributes, with some values normalized during validation. Active operators are exposed as `select_parents`, `crossover`, and `mutation`. The following sections describe additional instance attributes and inherited methods; the supported numeric type lists above are class attributes. +Constructor settings and user callables are stored as instance attributes, with some values normalized during validation. Numeric counts become Python integers, mutable parameter containers are copied, and inactive mutation counts/percentages are normalized to `None`/`"default"`. Active operators are exposed as `select_parents`, `crossover`, and `mutation`. The following sections describe additional instance attributes and inherited methods; the supported numeric type lists above are class attributes. > The `GA` class gains the attributes of its parent classes via inheritance, making them accessible through the `GA` object even if they are defined externally to its specific class body. @@ -568,6 +668,7 @@ Constructor settings and user callables are stored as instance attributes, with - `run_mutation()`: Apply mutation and call `on_mutation` when defined. Internal. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). - `run_update_population()`: Replace `self.population` with the crossed-over and mutated offspring. Internal. Added in [PyGAD 3.3.1](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-3-1). - `summary(...)`: Prints a Keras-like summary of the PyGAD lifecycle. Added in [PyGAD 2.19.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-19-0). See [Print Lifecycle Summary](https://pygad.readthedocs.io/en/latest/logging.html#print-lifecycle-summary). +- `plot_lifecycle(title="PyGAD - Lifecycle", font_size=11, show_parameters=True, save_dir=None, show=True)`: Draws the configured lifecycle with operators, callbacks, population replacement, and stopping decisions. Works before or after `run()` and returns a matplotlib figure. See [plot_lifecycle()](visualize.md#plot_lifecycle). #### Population and Initialization @@ -577,15 +678,21 @@ Constructor settings and user callables are stored as instance attributes, with - `initial_population`: Frozen copy of the initial population, set after `initialize_population` runs. - `pop_size`: A `(sol_per_pop, num_genes)` tuple describing the population shape. - `gene_type_single`: `True` when every gene shares the same dtype; `False` when `gene_type` is a list/tuple/numpy.ndarray. Added in [PyGAD 2.14.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-14-0). -- `gene_space_unpacked`: Unpacked version of `gene_space`. For example, `range(1, 5)` becomes `[1, 2, 3, 4]`; `{'low': 2, 'high': 4}` becomes a finite sample. Added in [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0). +- `gene_space_unpacked`: An inspection snapshot of converted values in `gene_space`. Small finite spaces are kept in full; large ranges, stepped dictionaries, and continuous intervals contribute at most 100 values per gene by default. Generation and duplicate repair read the original space, so inspection samples do not restrict the available values and `None` entries remain random. For example, `range(1, 5)` becomes `[1, 2, 3, 4]`; `{'low': 2, 'high': 4}` becomes a finite sample. Added in [PyGAD 3.1.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-3-1-0). ##### Methods - `initialize_population(allow_duplicate_genes, gene_type, gene_constraint)`: Build the initial population, apply gene types and constraints, resolve duplicates when not allowed. +- `generate_initial_population(num_solutions)`: Sample new population values in bulk and prepare the resulting solutions. Also used by NSGA-III population growth. +- `prepare_initial_population(population)`: Convert generated or supplied rows, apply constraints, and repair duplicates. +- `apply_initial_population_gene_constraints(population)`: Replace rejected values using initialization candidates, warning when no candidate satisfies a constraint. +- `sample_initial_population_gene_values(gene_index, num_values)`: Sample one column from its space or initialization range. +- `get_initial_population_gene_candidates(gene_index, sample_size, all_integer_values=True)`: Return converted replacement candidates for initialization constraints and duplicate repair. - `initialize_parents_array(shape)`: Allocate an empty parents (or offspring) array with the right dtype. - `change_population_dtype_and_round(population)`: Cast a 2D population to the dtype encoded in `self.gene_type` and round non-integer genes. -- `change_gene_dtype_and_round(gene_index, gene_value)`: Same as above, but for a single gene value. -- `round_genes(solutions)`: Round genes in a 2D array according to `self.gene_type` precision. +- `change_gene_dtype_and_round(gene_index, gene_value)`: Apply one gene's type and precision to a scalar or an array of candidates, preserving the input shape. +- `round_genes(solutions)`: Convert and round genes in a 2D array according to `self.gene_type`. Update the input array when its dtype matches the converted output. +- `prepare_operator_output(population, build_initial_pop=False)`: Apply gene types and precision, then repair duplicates if `allow_duplicate_genes=False`. SBX and polynomial mutation use initialization bounds for repair. - `get_initial_population_range(gene_index)`: Return the `[init_range_low, init_range_high]` window for a specific gene. - `get_random_mutation_range(gene_index)`: Return the `[random_mutation_min_val, random_mutation_max_val]` window for a specific gene. - `get_gene_dtype(gene_index)`: Return the `(type, precision)` pair for a specific gene. @@ -600,17 +707,19 @@ Constructor settings and user callables are stored as instance attributes, with - `last_generation_fitness`: Fitness values of the solutions in the last generation. Added in [PyGAD 2.12.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-12-0). - `previous_generation_fitness`: Fitness of the population one step before `last_generation_fitness`. Used to skip re-evaluating solutions PyGAD has already seen. Added in [PyGAD 2.16.2](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-2). - `best_solutions_fitness`: Fitness history of the best solution per generation, including the final population. Recorded even when `save_best_solutions=False`; with saving enabled, entries correspond to `best_solutions`. +- `best_solutions_generations`: The actual generation number for each entry in `best_solutions_fitness`. Repeated runs retain both the final and starting snapshots at their boundary. - `best_solutions`: A NumPy array of the best solution per generation. Only populated when `save_best_solutions=True`. - `solutions`: All visited solutions when `save_solutions=True`. - `solutions_fitness`: Fitness for every entry in `solutions`. +- `solutions_generations`: One generation number per saved population when `save_solutions=True`. The solutions and their fitness retain one entry per solution. - `num_fitness_evaluations`: Number of solutions evaluated during the current `run()`, including adaptive offspring and every solution in returned fitness batches. Cache hits do not count. Each run resets the counter after `on_start`; direct fitness evaluations outside a run increment the existing count. `evaluations_<N>` checks the count at generation boundaries, so the run can exceed the requested budget by a generation's work. -- `best_solution_generation`: Generation at which the best fitness was reached. `-1` until `run()` completes. +- `best_solution_generation`: Actual generation at which the best saved fitness was reached, using the same single-objective or NSGA-II ordering as `best_solution()`. `-1` until `run()` completes, or when an older checkpoint lacks the winning snapshot's generation number. See [Saved Fitness across Repeated Runs](fitness_calculation.md#saved-fitness-across-repeated-runs). ##### Methods - `cal_pop_fitness()`: Compute the fitness of every solution in the current population, reusing previously calculated values where possible. -- `best_solution(pop_fitness=None)`: Return the best solution in the current population, its fitness, and its population index. Pass the current population's fitness to avoid additional evaluation. See {ref}`best_solution() <current-population-best-solution>` for single-objective and multi-objective selection rules. -- `adaptive_mutation_population_fitness(offspring)`: Return `(average_fitness, offspring_fitness)` using retained solutions and actual offspring before mutation. See the {ref}`adaptive fitness reference <adaptive-offspring-fitness>`. +- `best_solution(pop_fitness=None)`: Return the best solution in the current population, its fitness, and its population index. Pass the current population's fitness to avoid additional evaluation. See [best_solution()](utils.md#best_solution) for single-objective and multi-objective selection rules. +- `adaptive_mutation_population_fitness(offspring)`: Return `(average_fitness, offspring_fitness)` using retained solutions and actual offspring before mutation. See the [adaptive fitness reference](utils.md#adaptive_mutation_population_fitnessoffspring). #### Fitness Worker Lifecycle (internal) @@ -630,7 +739,7 @@ The `FitnessEvaluation` mixin manages these attributes and methods. They may not - `_map_fitness(tasks)`: Yield fitness results in task order using serial calls, threads, or process snapshots. - `_evaluate_fitness(population, indices, adaptive=False)`: Evaluate selected rows, prepare scalar or batch arguments, validate results, and update the evaluation count. -The {ref}`complete fitness dispatch reference <fitness-evaluation>` documents parameters, return values, exceptions, process isolation, and attribute lifetimes. +The [complete fitness dispatch reference](utils.md#pygadutilsparallel-submodule) documents parameters, return values, exceptions, process isolation, and attribute lifetimes. #### Parent Selection (general) @@ -760,16 +869,26 @@ The {ref}`complete fitness dispatch reference <fitness-evaluation>` documents pa - `validate_gene_constraint_callable_output(selected_values, values)`: Sanity-check the return value of a user-defined `gene_constraint`. - `filter_gene_values_by_constraint(values, solution, gene_idx)`: Run `gene_constraint[gene_idx]` and return the filtered list. - `get_valid_gene_constraint_values(...)`: Sample candidate values until one satisfies the gene constraint. -- `solve_duplicate_genes_randomly(...)`: Resolve duplicate genes by sampling new values from the random range. -- `solve_duplicate_genes_by_space(...)`: Resolve duplicate genes by sampling new values from `gene_space`. -- `solve_duplicates_deeply(...)`: Slow, exhaustive fallback for duplicate resolution. +- `solve_duplicate_genes(solution, build_initial_pop=False, ...)`: Shared repair for a solution using each gene's space, range, type, precision, and constraint. Returns the repaired copy, remaining duplicate indices, and their count. Can follow chains of replacements. +- `solve_duplicate_genes_in_population(population, build_initial_pop=False)`: Convert and round population rows before applying the shared repair. +- `get_duplicate_gene_indices(solution)`: Return the indices after the first occurrence of each repeated value. +- `solution_satisfies_gene_constraints(solution)`: Check every constraint against a complete candidate solution. +- `get_gene_space_values(..., all_values=True)`: Return converted candidates for one gene. The default includes complete finite domains for duplicate repair; `all_values=False` samples ranges, stepped dictionaries, and integer intervals without allocating their complete domains. +- `is_gene_value_in_space(...)`: Check a swap candidate against the original finite space or continuous bounds. +- `solve_duplicate_genes_randomly(...)`: Compatibility helper for the shared repair using explicit random ranges. +- `solve_duplicate_genes_by_space(...)`: Compatibility helper for the shared repair using `gene_space`. +- `solve_duplicates_deeply(...)`: Compatibility helper for replacement-chain repair. Returns `None` when no duplicates are resolved. - `unique_int_gene_from_range(...)`: Pick an integer gene that does not already appear in the solution. - `unique_float_gene_from_range(...)`: Pick a float gene that does not already appear in the solution. - `unique_gene_by_space(...)`: Pick a unique value from `gene_space`. - `unique_genes_by_space(...)`: Pick unique values for several genes from `gene_space`. -- `select_unique_value(...)`: Sample one value uniformly at random from a list of candidates. -- `find_two_duplicates(solution)`: Locate the first pair of duplicated indices in a solution. -- `unpack_gene_space(...)`: Materialize the unpacked `gene_space` (used to build `gene_space_unpacked`). +- `select_unique_value(...)`: Pick an unused candidate when possible, otherwise keep the current value. +- `find_two_duplicates(solution, gene_space_unpacked)`: Find a duplicated gene with alternative values in its space. +- `unpack_gene_space(...)`: Build an inspection snapshot of `gene_space`, sampling large lazy domains without allocating them in full. +- `get_bounded_operator_gene_range(gene_index)`: Resolve SBX/polynomial bounds from a gene's space or initialization range. +- `convert_bounded_operator_gene_value(gene_index, value)`: Apply the destination type and precision and select an allowed value. +- `prepare_changed_operator_solution(...)`: Validate a complete converted proposal against spaces, constraints, and duplicates. +- `prepare_bounded_operator_solution(...)`: Repair bounded-operator constraints and accept a valid proposal or retain the original solution. #### Saving, Loading, and Reporting @@ -791,9 +910,11 @@ Accepts the following parameter: * `filename`: Name of the file to save the instance. No extension is needed. -The file is written as `filename + ".pkl"` using cloudpickle. Saving during a run excludes live worker handles and the active-run flag through `__getstate__()`. The remaining GA state, including custom attributes, must be serializable. {ref}`pygad.load() <loading-ga-checkpoint>` restores the saved optimization state; the next `run()` creates a new pool when needed. A checkpoint does not contain in-flight worker tasks. +The file is written as `filename + ".pkl"` using cloudpickle. Saving during a run excludes live worker handles and the active-run flag through `__getstate__()`. The remaining GA state, including custom attributes, must be serializable. [pygad.load()](pygad.md#pygadload) restores the saved optimization state; the next `run()` creates a new pool when needed. A checkpoint does not contain in-flight worker tasks. +<!-- sphinx (generate-report)= +--> ### `generate_report()` Builds a PDF report of the current GA run. It bundles the configuration table, a run-summary table, the best solution, and every applicable plot. Requires the optional `report` extra: @@ -819,15 +940,44 @@ Parameters: - `notes` (`str` or `None`, default `None`): Free-form text rendered in the optional `"notes"` section. - `page_size` (`str`, default `"letter"`): Either `"letter"` or `"A4"`. -The report skips any plot whose preconditions are not met. For example, `plot_pareto_front_curve` is included only for multi-objective runs with 2 or 3 objectives; `plot_non_dominated_hypervolume` is included only when `save_solutions=True` is set on the GA. A full example lives at [`examples/example_generate_report.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_generate_report.py). +The report skips any plot whose preconditions are not met. For example, `plot_pareto_front_curve` is included only for multi-objective runs with 2 or 3 objectives; `plot_non_dominated_hypervolume` is included only when `save_solutions=True` is set on the GA. The title page shows the PyGAD logo. The image ships with the package, so it works without network access. If the image file is missing, the report is built without it. +<!-- python-examples +example_generate_report.py +--> + +**Python example** + +**[PDF report](../../examples/example_generate_report.py)** + +Export the run configuration, summary, best solution, and applicable plots to PDF. + +`examples/example_generate_report.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD with the report extra (Matplotlib and ReportLab) + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_generate_report.py +``` + +</details> + +<!-- /python-examples --> + ## Functions in `pygad` Besides the methods available in the `pygad.GA` class, this section discusses the functions available in `pygad`. Up to this time, there is only a single function named `load()`. +<!-- sphinx (loading-ga-checkpoint)= +--> ### `pygad.load()` Reads a saved instance of the genetic algorithm. This is not a method but a function that is indented under the `pygad` module. So, it could be called by the pygad module as follows: `pygad.load(filename)`. @@ -838,6 +988,9 @@ Accepts the following parameter: Returns the genetic algorithm instance. +<!-- sphinx +(updating-loaded-fitness-function)= +--> #### Updating the Fitness Function after Loading The checkpoint includes the fitness function assigned when `save()` was called. Editing a function in the script does not automatically replace the function in a loaded GA. Assign the updated callable explicitly: @@ -862,52 +1015,55 @@ new_ga_instance = pygad.GA( new_ga_instance.run() ``` -Set the constructor options needed by the new problem, including `gene_type`, `gene_space`, constraints, operators, and batch settings. This starts new fitness histories and generation counters; it reuses the chromosomes, not the previous run's fitness values. A complete example is available in [`examples/example_load_fitness_function.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/master/examples/example_load_fitness_function.py). +Set the constructor options needed by the new problem, including `gene_type`, `gene_space`, constraints, operators, and batch settings. This starts new fitness histories and generation counters; it reuses the chromosomes, not the previous run's fitness values. -## Using PyGAD +<!-- python-examples +example_load_fitness_function.py +--> -::::{grid} 1 2 2 2 -:gutter: 3 +**Python example** -:::{grid-item-card} Steps to Use PyGAD -:link: steps_to_use -:link-type: doc +**[Change a loaded fitness function](../../examples/example_load_fitness_function.py)** -A step-by-step walkthrough to build and run the genetic algorithm. -::: +Replace the fitness callable after loading, or start fresh when the objective changes. -:::{grid-item-card} Life Cycle of PyGAD -:link: lifecycle -:link-type: doc +`examples/example_load_fitness_function.py` -How a generation runs and where each callback is called. -::: +<details> +<summary>Run this example</summary> -:::: +**Requires:** PyGAD -## Examples +From the repository root, with the repository version of PyGAD installed: -This section gives the complete code of some examples that use `pygad`. Each subsection builds a different example. +```console +python examples/example_load_fitness_function.py +``` -::::{grid} 1 2 2 2 -:gutter: 3 +</details> -:::{grid-item-card} Linear Model - Single Objective -:link: pygad_example_linear -:link-type: doc -::: +<!-- /python-examples --> -:::{grid-item-card} Linear Model - Multi-Objective -:link: pygad_example_multi_objective -:link-type: doc -::: +## Using PyGAD -:::{grid-item-card} Reproducing Images -:link: pygad_example_reproducing_images -:link-type: doc -::: +<!-- navigation-grid: 1 2 2 2 --> + +- [Steps to Use PyGAD](steps_to_use.md) — A step-by-step walkthrough to build and run the genetic algorithm. +- [Life Cycle of PyGAD](lifecycle.md) — How a generation runs and where each callback is called. + +<!-- /navigation-grid --> + +## Examples -:::: +This section gives the complete code of some examples that use `pygad`. Each subsection builds a different example. + +<!-- navigation-grid: 1 2 2 2 --> + +- [Linear Model - Single Objective](pygad_example_linear.md) +- [Linear Model - Multi-Objective](pygad_example_multi_objective.md) +- [Reproducing Images](pygad_example_reproducing_images.md) + +<!-- /navigation-grid --> ### Clustering @@ -915,12 +1071,60 @@ For a 2-cluster problem, the code is available [here](https://github.com/ahmedfg Soon a tutorial will be published at [Paperspace](https://blog.paperspace.com/author/ahmed) to explain how clustering works using the genetic algorithm with examples in PyGAD. +<!-- python-examples +clustering/example_clustering_2.py +clustering/example_clustering_3.py +--> + +**Python examples** + +**[2-cluster example](../../examples/clustering/example_clustering_2.py)** + +Optimize 2 cluster centers for generated two-dimensional data. + +`examples/clustering/example_clustering_2.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/clustering/example_clustering_2.py +``` + +</details> + +**[3-cluster example](../../examples/clustering/example_clustering_3.py)** + +Optimize 3 cluster centers for generated two-dimensional data. + +`examples/clustering/example_clustering_3.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/clustering/example_clustering_3.py +``` + +</details> + +<!-- /python-examples --> + ### CoinTex Game Playing using PyGAD The code is available at the [CoinTex GitHub project](https://github.com/ahmedfgad/CoinTex/tree/master/PlayerGA). CoinTex is an Android game written in Python using the Kivy framework. Find CoinTex at [Google Play](https://play.google.com/store/apps/details?id=coin.tex.cointexreactfast): https://play.google.com/store/apps/details?id=coin.tex.cointexreactfast Check this [Paperspace tutorial](https://blog.paperspace.com/building-agent-for-cointex-using-genetic-algorithm) for how the genetic algorithm plays CoinTex: https://blog.paperspace.com/building-agent-for-cointex-using-genetic-algorithm. Check also this [YouTube video](https://youtu.be/Sp_0RGjaL-0) showing the genetic algorithm while playing CoinTex. +<!-- sphinx :::{toctree} :hidden: @@ -930,3 +1134,4 @@ pygad_example_linear pygad_example_multi_objective pygad_example_reproducing_images ::: +--> diff --git a/docs/source/pygad_more.md b/docs/source/pygad_more.md index 8fc2e576..7efab296 100644 --- a/docs/source/pygad_more.md +++ b/docs/source/pygad_more.md @@ -2,60 +2,19 @@ This section covers the more advanced features of the `pygad` module. Pick a topic: -::::{grid} 1 2 2 3 -:gutter: 3 +<!-- navigation-grid: 1 2 2 3 --> -:::{grid-item-card} Multi-Objective Optimization -:link: multi_objective -:link-type: doc +- [Multi-Objective Optimization](multi_objective.md) — Optimize several objectives at once using NSGA-II or NSGA-III. +- [Controlling Gene Values](gene_values.md) — Restrict gene values with `gene_space`, `gene_type`, constraints, `sample_size`, and duplicate prevention. +- [Controlling Generations](generations.md) — Elitism, stopping criteria, random seed, saving and continuing, and population size. +- [Fitness Calculation and Performance](fitness_calculation.md) — Parallel processing, batch fitness, reusing fitness, and non-deterministic problems. +- [Logging and the Lifecycle Summary](logging.md) — Print a Keras-like summary and log the outputs. +- [User-Defined Functions, Methods, and Classes](custom_functions.md) — Pass your own functions, methods, or classes for the fitness and callbacks. +- [Benchmark Problems](benchmarks.md) — Built-in single, multi, and many-objective benchmark problems to plug into the GA. -Optimize several objectives at once using NSGA-II or NSGA-III. -::: - -:::{grid-item-card} Controlling Gene Values -:link: gene_values -:link-type: doc - -Restrict gene values with `gene_space`, `gene_type`, constraints, `sample_size`, and duplicate prevention. -::: - -:::{grid-item-card} Controlling Generations -:link: generations -:link-type: doc - -Elitism, stopping criteria, random seed, saving and continuing, and population size. -::: - -:::{grid-item-card} Fitness Calculation and Performance -:link: fitness_calculation -:link-type: doc - -Parallel processing, batch fitness, reusing fitness, and non-deterministic problems. -::: - -:::{grid-item-card} Logging and the Lifecycle Summary -:link: logging -:link-type: doc - -Print a Keras-like summary and log the outputs. -::: - -:::{grid-item-card} User-Defined Functions, Methods, and Classes -:link: custom_functions -:link-type: doc - -Pass your own functions, methods, or classes for the fitness and callbacks. -::: - -:::{grid-item-card} Benchmark Problems -:link: benchmarks -:link-type: doc - -Built-in single, multi, and many-objective benchmark problems to plug into the GA. -::: - -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -67,3 +26,4 @@ logging custom_functions benchmarks ::: +--> diff --git a/docs/source/releases.md b/docs/source/releases.md index 301efedc..4a7e418d 100644 --- a/docs/source/releases.md +++ b/docs/source/releases.md @@ -2,304 +2,419 @@ ![PYGAD-LOGO](images/101267295-c74c0180-375f-11eb-9ad0-f8e37bd796ce.png) -## PyGAD 1.0.17 - -Release Date: 15 April 2020 - -1. The **pygad.GA** class accepts a new argument named `fitness_func` which accepts a function to be used for calculating the fitness values for the solutions. This allows the project to be customized to any problem by building the right fitness function. +Release notes are listed from newest to oldest. Unreleased contains changes planned for a future release. -## PyGAD 1.0.20 +## Unreleased -Release Date: 4 May 2020 +No changes yet. -1. The **pygad.GA** attributes are moved from the class scope to the instance scope. -2. Raising an exception for incorrect values of the passed parameters. -3. Two new parameters are added to the **pygad.GA** class constructor (`init_range_low` and `init_range_high`) allowing the user to customize the range from which the genes values in the initial population are selected. -4. The code object `__code__` of the passed fitness function is checked to ensure it has the right number of parameters. +## PyGAD 3.8.0 -## PyGAD 2.0.0 +Release Date: October 9, 2026. -Release Date: 13 May 2020 +1. [Two-point crossover](utils.md#two_points_crossover) selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). +2. [Swap mutation](utils.md#swap_mutation) can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). +3. [SBX crossover](utils.md#sbx_crossover) selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). +4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). +5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. +6. [Parallel fitness evaluation](fitness_calculation.md#parallel-processing-in-pygad) now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. +7. Serial, thread, and process modes use the same fitness-cache rules and result validation. [Adaptive mutation evaluates the actual offspring](utils.md#adaptive_mutation_population_fitnessoffspring), supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_<N>` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). +8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). +9. [pygad.kerasga.predict()](kerasga.md#pygadkerasgapredict) synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). +10. [Stochastic universal selection](utils.md#stochastic_universal_selection) uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). +11. [Scramble mutation](utils.md#scramble_mutation) shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). +12. New examples explain [replacing a loaded fitness function](pygad.md#updating-the-fitness-function-after-loading), starting fresh when the objective changes, and [handling short final fitness batches](fitness_calculation.md#why-a-fitness-batch-can-be-smaller). The lifecycle guide also explains [progress reporting](lifecycle.md#reporting-progress) and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). +13. [Rank selection](utils.md#rank_selection) assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +14. A new [plot_lifecycle()](visualize.md#plot_lifecycle) method draws the lifecycle configured for a GA instance, including operators, callbacks, population replacement, generation loops, and stopping decisions. Stage annotations and a configuration panel show relevant settings, including gene types, batching, and offspring shapes. Use `show_parameters=False` for a compact view, `save_dir` to export SVG, PNG, or PDF, and `show=False` to create a chart without displaying it. Use `transparent=True` for a transparent background. Charts fit their labels and connectors with small outer margins. The method works before or after `run()` without executing user functions or changing GA state. A new example is available at `examples/plots/example_plot_lifecycle.py`. The `pygad.visualize` submodule version is `1.2.1`. -1. The fitness function accepts a new argument named `sol_idx` representing the index of the solution within the population. -2. A new parameter to the **pygad.GA** class constructor named `initial_population` is supported to allow the user to use a custom initial population to be used by the genetic algorithm. If not None, then the passed population will be used. If `None`, then the genetic algorithm will create the initial population using the `sol_per_pop` and `num_genes` parameters. -3. The parameters `sol_per_pop` and `num_genes` are optional and set to `None` by default. -4. A new parameter named `callback_generation` is introduced in the **pygad.GA** class constructor. It accepts a function with a single parameter representing the **pygad.GA** class instance. This function is called after each generation. This helps the user to do post-processing or debugging operations after each generation. +15. [Duplicate-gene repair](gene_values.md#prevent-duplicates-in-gene-values) now uses one shared implementation for generated and manual initial populations, crossover, mutation, and NSGA-III population growth. Custom crossover and mutation outputs and their callbacks are also repaired when `allow_duplicate_genes=False`. Finite domains are searched completely through replacement chains, including changes to earlier duplicate occurrences. Continuous candidates and additional searches for dependent constraints use `sample_size`. +16. Repair uses each destination gene's type, precision, and [range](gene_values.md#more-about-the-gene_space-parameter), and validates [constraints](gene_values.md#gene-constraint) against complete candidate solutions. Mixed types are compared by their exact stored numeric values. Mixed types, `sample_size=1`, stepped spaces, per-gene ranges, and `None` entries are handled consistently. Impossible initialization spaces warn instead of accessing uninitialized attributes. Equal and reversed integer bounds are handled consistently. Swap fallback uses original continuous and `None` bounds instead of membership in cached samples. SBX and polynomial mutation convert and round generated values before repair and use their own bounds. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.2` and `1.5.4`. +17. A new `examples/example_duplicate_gene_repair.py` demonstrates repair through several genes. Regression tests compare small finite spaces with exhaustive search and cover long chains, impossible spaces, constraints, callbacks, mixed types, and reproducible runs. -## PyGAD 2.1.0 +18. [Initial population creation](gene_values.md#creating-the-initial-population) and NSGA-III population growth share column sampling and preparation methods. Integer ranges are sampled directly instead of being allocated for each gene value. Generated range values remain within their bounds after conversion and rounding, with a descriptive error when the type and precision cannot represent any valid value. Supplied population dimensions are inferred before per-gene validation, overriding explicit dimensions. Supplied populations also apply gene constraints, and mixed numeric values retain their exact values during conversion. Empty and malformed populations are rejected early; tuple and NumPy gene-type specifications are accepted without modifying caller-owned inputs. The new `examples/example_initial_population.py` demonstrates generated and supplied populations. -Release Date: 14 May 2020 +19. [Gene-type validation and conversion](gene_values.md#conversion-and-rounding-rules) share methods for scalar values, candidate arrays, and populations. Columns with matching types and precisions are converted together. Floating-point values are rounded before casting, including narrow NumPy types, and extreme decimal scaling preserves finite values before the cast. Additive mutation computes the sum before conversion, preserving fractional offsets and exact integer addition. Finite spaces keep large integers exact during conversion, and integer ranges use exact Python values for NumPy scalar bounds. Custom operators and their callbacks apply gene types whether duplicates are allowed or not. Permutation mutation applies each destination gene's type and precision, and saved best solutions preserve mixed scalar types and large integers across repeated runs. The new `examples/example_gene_type_conversion.py` demonstrates these rules. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.3` and `1.5.5`. -1. The `best_solution()` method in the **pygad.GA** class returns a new output representing the index of the best solution within the population. Now, it returns a total of 3 outputs and their order is: best solution, best solution fitness, and best solution index. Here is an example: -```python -solution, solution_fitness, solution_idx = ga_instance.best_solution() -print("Parameters of the best solution :", solution) -print("Fitness value of the best solution :", solution_fitness, "\n") -print("Index of the best solution :", solution_idx, "\n") -``` +20. [Constructor validation](pygad.md#__init__) shares checks for integer counts, finite numeric settings, ranges, callable signatures, and operator selection. NumPy counts become Python integers before arithmetic, preventing narrow-integer overflow in mutation percentages and repeated runs. Tournament sizes are validated for ordinary, NSGA-II, and NSGA-III tournaments. [Stop criteria](generations.md#stop-criteria) share one parser, accept scientific notation, preserve large integer counts, and reject zero, negative, or fractional saturation/evaluation counts. Zero worker counts consistently disable parallel processing. +21. Only the [active mutation control](pygad.md#mutation) is validated, in the order probability, count, percentage. Permutation and polynomial mutation apply explicit controls, including zero probability. Zero crossover probability preserves parents even when a random draw is exactly zero. Permutations check complete proposals against destination spaces, types, constraints, and duplicates, retrying compatible alternatives before retaining the original solution. SBX and polynomial mutation resolve bounds from gene spaces or initialization ranges, sort reversed bounds, and clip supplied values before calculation. Converted results stay within the permitted space, including excluded continuous upper bounds. +22. Each GA owns [NumPy and Python random generators](generations.md#random-seed). NumPy integer seeds are accepted, separate instances and global generators do not interfere, and checkpoints preserve generator states. Custom operators and callbacks can use `numpy_random_generator` and `python_random_generator` for reproducible choices. Built-in seeded results may differ from earlier versions. +23. Ranges and stepped dictionaries are sampled by index instead of being materialized for ordinary generation and constraint sampling. Inspection snapshots remain compact for large domains; duplicate repair still searches complete finite domains from the original settings. Constructor containers are copied, existing logger handlers are retained, invalid loggers report the original validation error, and adaptive replacement no longer emits an incorrect warning. Parameter checks precede population generation and constraint execution. The new `examples/example_constructor_parameters.py` demonstrates callable signatures, NumPy counts, and independent seeded instances. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.4` and `1.5.6`. -2. A new attribute named `best_solution_generation` is added to the instances of the **pygad.GA** class. it holds the generation number at which the best solution is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. -Example: -```python -print("Best solution reached after {best_solution_generation} generations.".format(best_solution_generation=ga_instance.best_solution_generation)) -``` +24. The new [best_solutions_generations and solutions_generations attributes](fitness_calculation.md#saved-fitness-across-repeated-runs) record actual generation numbers across repeated `run()` calls, with one entry per best-fitness snapshot and saved population, respectively. Existing histories retain all starting and final snapshots, including both snapshots at a run boundary. `best_solution_generation` uses actual generation numbers and the same single-objective or NSGA-II ordering as `best_solution()`, without changing the current population's Pareto fronts. Population history records each snapshot's size, including NSGA-III growth. Fitness plots, best-solution gene plots, population diagnostics, and PDF reports use this metadata. New-solution-rate plots use the latest population once per generation and exclude the final population; Pareto evolution selects actual generation intervals and includes the final population. Checkpoints preserve the metadata. Older single-run checkpoints recover their generation numbers; unavailable numbers in older repeated-run histories become `None`, with `best_solution_generation=-1` when the winning snapshot's generation is unknown. The new `examples/example_repeated_runs.py` demonstrates continuing from a checkpoint. +25. [saturate_N](generations.md#stop-criteria) checks consecutive unchanged generations, including the current population and the initial baseline. Changes between matching endpoints reset the count, `saturate_1` no longer stops improving runs, and every `run()` resets its saturation count. Multi-objective comparisons use the whole best-fitness vector. +26. Returned and in-place `on_fitness` changes are validated before selection. The best solution is recomputed after the callback, keeping saved solutions and fitness aligned. Saved population fitness and best-fitness vectors are copied to prevent later callback edits from changing earlier snapshots, and saved genes retain their configured NumPy scalar types. Callback order and call counts are preserved, including the absence of an additional `on_fitness` call for the final population. Callbacks continue to receive fitness after cache reuse. +27. [Fitness validation](fitness_calculation.md#fitness-output-validation) is shared by sequential, threaded, process, batch, cached, and adaptive evaluation. Empty or nested objective vectors, non-numeric values, inconsistent objective counts, and NaN values fail with descriptive errors before selection. Single-objective infinities remain accepted; objective vectors require finite values for Pareto calculations. Explicit fitness passed to `best_solution()` is validated too. +28. [Saved fitness](fitness_calculation.md#reuse-the-fitness-instead-of-calling-the-fitness-function) uses indexes of complete solutions instead of repeated linear history searches, keeping large integer gene values exact. Built-in evolution indexes newly saved snapshots incrementally. Cache precedence remains saved solutions, saved best solutions, retained elites, then retained parents, using the first matching entry in each source. Unsaved duplicate solutions are still evaluated independently. Indexes are rebuilt around direct evaluations, repeated runs, user operators, and callbacks to honor history edits, and are omitted from checkpoints and worker snapshots. No additional user configuration is required. +29. The NSGA-III DTLZ2 custom mutation uses the GA's random generator, making its quality tests independent of global random draws without relaxing their thresholds. A regression test checks reproducibility despite changes to the global random state. +30. Regression tests cover zero-generation runs, early stopping, repeated runs, checkpoint continuation and older checkpoints, manually cleared histories, callback edits, NumPy gene types, multi-objective history and Pareto fronts, NSGA-III population growth, history plots and PDF reports, malformed fitness in sequential/thread/process and batch modes, adaptive objective counts, cache precedence, and incremental indexing. Documentation covers the new attributes, stopping rules, fitness validation, cache behavior, plots, and checkpoint compatibility. The `pygad.utils` and `pygad.visualize` submodule versions are `1.5.7` and `1.2.2`. -3. The `best_solution_fitness` attribute is renamed to `best_solutions_fitness` (plural solution). -4. Mutation is applied independently for the genes. +31. Release history is ordered from newest to oldest, with Unreleased first and the latest 10 entries visible initially. Readers can show 10 more entries at a time, show the complete history, or jump directly to a selected release on the same page. Existing release links automatically reveal their target, the table of contents follows the visible entries, and keyboard focus moves to newly revealed notes. All release content remains available to documentation search, printing, and readers without JavaScript. Feature links use standard Markdown paths and heading anchors so they work in repository views and built documentation. -## PyGAD 2.2.1 +32. The [generation guide](generations.md) explains instance-owned random generators with a custom mutation example, precise saturation counting, and generation metadata across repeated runs and checkpoints. The seeded example output is refreshed, and the guide clarifies that best-fitness history is collected even when best-solution gene values are not saved. -Release Date: 17 May 2020 +33. A new [Examples index](examples.md) connects all 81 repository Python scripts and the TSP notebook to their documentation guides. Shared Python example cards link scripts beside the relevant explanations, use compact tables for larger groups, and provide expandable run instructions, requirements, and working directories. Self-contained scripts can be downloaded directly from the built documentation; examples needing data link to their folders and dataset setup instructions. One catalog and shared templates keep descriptions and links consistent, and the documentation build rejects missing scripts, uncataloged Python files, unknown example references, and missing guides. GitHub links match the documentation checkout. The TSP notebook's Colab-specific CSV path and local adaptation requirements are clarified. Earlier entries describe the regression tests and runnable examples added with the library changes. -1. Adding 2 extra modules (pygad.nn and pygad.gann) for building and training neural networks with the genetic algorithm. +34. Documentation guides also render directly on GitHub and in compatible Markdown previews. Internal references use Markdown links; parameter descriptions use expandable details; navigation lists and PNG diagrams remain visible; and Sphinx-only labels, toctrees, and video embeds are hidden from previews. All Python example sections and the Examples index include checked-in Markdown generated from the shared catalog and templates, with script links, requirements, dataset setup, and run instructions. A Python-only command updates these sections, and builds reject stale content or incomplete section comments. Built documentation retains its cards, dropdowns, figures, downloads, navigation, and published anchors. -## PyGAD 2.2.2 +35. Package metadata declares Python 3.8 or newer, matching the minimum version in the test matrix. Documentation reads the package version from `pygad/_version.py`. Release tags must match that version, and publication requires the Python 3.8 through 3.14 test matrix and a documentation build with warnings treated as errors. The matrix imports the installed wheel from outside the checkout. A GitHub Release is created only after PyPI publication succeeds, using the documented release notes and the published PyPI source distribution and wheel after verifying their SHA-256 hashes against the checked build. -Release Date: 18 May 2020 -1. The initial value of the `generations_completed` attribute of instances from the pygad.GA class is `0` rather than `None`. +The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. -2. An optional bool parameter named `mutation_by_replacement` is added to the constructor of the pygad.GA class. It works only when the selected type of mutation is random (`mutation_type="random"`). In this case, setting `mutation_by_replacement=True` means replace the gene by the randomly generated value. If `False`, then it has no effect and random mutation works by adding the random value to the gene. This parameter should be used when the gene falls within a fixed range and its value must not go out of this range. Here are some examples: +## PyGAD 3.7.0 - Assume there is a gene with the value 0.5. +Release Date June 5, 2026 - If `mutation_type="random"` and `mutation_by_replacement=False`, then the generated random value (e.g. 0.1) will be added to the gene value. The new gene value is **0.5+0.1=0.6**. +Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). - If `mutation_type="random"` and `mutation_by_replacement=True`, then the generated random value (e.g. 0.1) will replace the gene value. The new gene value is **0.1**. +<!-- sphinx +```{raw} html +<iframe width="560" height="315" src="https://www.youtube.com/embed/EXMy37crL7c" title="PyGAD 3.7.0 Release" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> +``` +--> -3. `None` value could be assigned to the `mutation_type` and `crossover_type` parameters of the pygad.GA class constructor. When `None`, this means the step is bypassed and has no action. +1. Validation logic is applied to validate the `num_generations` parameter. +2. The `num_generations` parameter must be assigned a positive integer. Previously, any number (positive/negative, int/float) was accepted. +3. A new script called `activation.py` is added into the `pygad.helper` module to include the activation function used by the `cnn` and `nn` modules. +4. In the `pygad.parent_selection.ParentSelection` class, the `stochastic_universal_selection()` method now calls the `wheel_cumulative_probs()` method instead of repeating the code of calculating the probabilities used for parent selection. +5. The `wheel_cumulative_probs()` method in the `pygad.parent_selection.ParentSelection` class is refactored to reduce its computational time. +6. Use `numpy.where()` to decide which the source parent of each gene within the `uniform_crossover()` method in the `utils/crossover.py` script. The same was already applied to the `scattered_crossover()` method. +7. Add tests for the following modules: + 1. `nn` + 2. `cnn` + 3. `gacnn` + 4. `kerasga` + 5. `torchga` +8. Fix a bug in the `visualize/plot.py` script where the `labels` parameter of `boxplot()` has been renamed `tick_labels` in Matplotlib. +9. Fix a bug where the `best_solutions_fitness` list (instance attribute to `pygad.GA`) has the fitness of the last generation duplicated when an early stop happens inside the `on_generation()` callback. This made its size incompatible with the `best_solutions` list. +10. The documentation is refactored to solve many language issues and the Furo theme is applied. For easy navigation, the index is reformatted to only show the main sections. At each page, its index is shown at the right side. A new theme toggle button to change theme between light and dark. +11. Support of multi-objective optimization using the [Non-Dominated Sorting Genetic Algorithm III (NSGA-III)](multi_objective.md#nsga-iii-example). NSGA-III replaces the crowding distance of NSGA-II with niching against a structured grid of reference points, so it scales better to problems with 4 or more objectives. The new `NSGA3` class lives in the new `pygad/utils/nsga3.py` script and is mixed into the `pygad.GA` class the same way `NSGA2` is. +12. Two new parent selection methods are added to support NSGA-III: 1) `nsga3_selection()` for plain NSGA-III selection, and 2) `tournament_selection_nsga3()` for the tournament variant. Use them by setting `parent_selection_type` to `'nsga3'` or `'tournament_nsga3'`. +13. A new parameter `nsga3_num_divisions` is added to the `pygad.GA` constructor. It is required when `parent_selection_type` is `'nsga3'` or `'tournament_nsga3'` and sets the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. +14. When `sol_per_pop` is smaller than the number of NSGA-III reference points, PyGAD raises a warning and grows the population to match before the generational loop starts. +15. A new crossover operator: [Simulated Binary Crossover (SBX)](utils.md#sbx_crossover). Use it by setting `crossover_type='sbx'`. The shape of the spread is controlled by the new `sbx_crossover_eta` parameter (default 30). +16. A new mutation operator: [polynomial mutation](utils.md#polynomial_mutationoffspring). Use it by setting `mutation_type='polynomial'`. The size of the change is controlled by the new `polynomial_mutation_eta` parameter (default 20). +17. Two new [stop criteria](generations.md#stop-criteria): `time_<seconds>` stops the run when the time inside `run()` is at least the given number of seconds; `evaluations_<N>` stops the run when the number of fitness function calls reaches the given count. New instance attribute `num_fitness_evaluations` counts the calls. +18. A new submodule [pygad.utils.quality_indicators](utils.md#pygadutilsquality_indicators-submodule) with four functions to measure the quality of a Pareto front: `hypervolume`, `inverted_generational_distance`, `generational_distance`, and `spacing`. +19. A new submodule [pygad.benchmarks](benchmarks.md) with built-in benchmark problems. `pygad.benchmarks.classic` has Sphere, Rastrigin, Rosenbrock, Griewank, Schwefel, Ackley, and Himmelblau. `pygad.benchmarks.zdt` has the ZDT family (ZDT1, ZDT2, ZDT3, ZDT4, ZDT6). `pygad.benchmarks.dtlz` has DTLZ1, DTLZ2, DTLZ3, and DTLZ4. `pygad.benchmarks.knapsack` has the 0/1 Knapsack problem. Each class is callable with the PyGAD fitness signature and returns negated values (for the minimization-style problems) so PyGAD can maximize toward the original minimum. +20. Update the documentation to reflect the recent additions and changes to the library structure. +21. A new benchmark [pygad.benchmarks.tsp](benchmarks.md#travelling-salesman-problem) with a `TSP` class for the Travelling Salesman Problem. The class accepts either 2D `coordinates` or a precomputed `distance_matrix`, exposes `gene_space`, `gene_type`, and `allow_duplicate_genes` for the permutation encoding, and returns the negative tour length as the fitness. +22. Two new example folders under `/examples`: `examples/benchmarks/` has one runnable example per benchmark (classic, ZDT, DTLZ, knapsack, and TSP), and `examples/quality_indicators/` has one runnable example per quality indicator (hypervolume, IGD, GD, and spacing). +23. [plot_pareto_front_curve()](visualize.md#plot_pareto_front_curve) now also supports 3 objectives (3D scatter). M >= 4 still raises and points to the new high-dimensional plots. +24. Seven new plot methods on `pygad.GA`. The first three work on the final population (no extra flag needed): [plot_pareto_front_pcp()](visualize.md#plot_pareto_front_pcp) (parallel coordinates, any M >= 2), [plot_pareto_front_scatter_matrix()](visualize.md#plot_pareto_front_scatter_matrix) (M-by-M pairwise scatter, best for M >= 4), and [plot_pareto_front_heatmap()](visualize.md#plot_pareto_front_heatmap) (solutions-by-objectives heatmap). The other four require `save_solutions=True`: [plot_fitness_band()](visualize.md#plot_fitness_band) (per-generation min / mean / max with a shaded band), [plot_non_dominated_hypervolume()](visualize.md#plot_non_dominated_hypervolume) (hypervolume of the non-dominated set per generation), [plot_population_diversity()](visualize.md#plot_population_diversity) (mean pairwise distance per generation), and [plot_pareto_front_evolution()](visualize.md#plot_pareto_front_evolution) (non-dominated set overlaid every k generations). +25. Fix a latent divide-by-zero in `NSGA3.nsga3_normalize_fitness()`. The safeguard for near-zero denominators used to collapse to `0` for tiny negative values (the realistic case under PyGAD-max), which silently produced wrong normalized values. The safeguard now keeps the negative sign. +26. Refactor the NSGA classes to keep each script focused. A new module `pygad/utils/nsga.py` hosts the `NSGA` mixin with `non_dominated_sorting()` and `get_non_dominated_set()`, which are shared between NSGA-II and NSGA-III. `nsga2.py` now only carries NSGA-II specific code (`crowding_distance`, `sort_solutions_nsga2`). `nsga3.py` now only carries the NSGA-III algorithm primitives. The `nsga3_selection()` and `tournament_selection_nsga3()` methods have moved to `pygad/utils/parent_selection.py` next to their NSGA-II counterparts. The engine-time helpers `_bootstrap_nsga3_reference_points()`, `_nsga3_grow_population()`, `_nsga3_generate_extra_random_solutions()`, and `_nsga3_generate_single_random_gene()` now live in `pygad/utils/engine.py`. +27. Rename NSGA-III novel names to start with `nsga3_` so the algorithm-specific surface is easy to spot. Algorithm primitives become `nsga3_generate_reference_points`, `nsga3_compute_ideal_point`, `nsga3_find_extreme_points`, `nsga3_compute_intercepts`, `nsga3_normalize_fitness`, `nsga3_associate_to_reference_points`, and `nsga3_niching_select`. Module-level helpers gain the same prefix (`_nsga3_pick_target_reference_point`, `_nsga3_pick_candidate_at_reference`, `_nsga3_enumerate_compositions`, `_nsga3_validate_multi_objective_fitness`, `_nsga3_accumulate_fronts`). The constants are renamed `NSGA3_ASF_EPSILON` and `NSGA3_INTERCEPT_NEAR_ZERO`. Names that already had NSGA-II parallels (`tournament_selection_nsga3`, `pareto_fronts`, `non_dominated_sorting`) keep their original spelling. +28. Spell every name and docstring in American English (`normalize`, `maximize`, `behavior`, `color`, `optimization`, ...) so the library stays consistent. +29. Expand abbreviated names introduced by the NSGA-III refactor: `fl_indices` to `critical_front_indices`, `fl_assoc` to `critical_front_associations`, `fl_dist` to `critical_front_distances`, `st_indices` to `selection_pool_indices`, `st_fitness` to `selection_pool_fitness`, `accepted_assoc` to `accepted_associations`, `K` to `num_to_select` (in `nsga3_niching_select`). +30. The NSGA-III population auto-growth path now respects every initial-population rule: `init_range_low`/`init_range_high`, `gene_space`, `gene_type` (single dtype or nested per-gene `[type, precision]`), `gene_constraint`, and `allow_duplicate_genes=False`. Previously, only the gene-space / init-range sampling step was applied; gene constraints and duplicate resolution were skipped, which could leave the grown rows in an invalid state. +31. A new `Report` mixin in `pygad/utils/report.py` adds [ga_instance.generate_report(filename, ...)](pygad.md#generate_report) to build a PDF report of the run. The report bundles a configuration table, a run-summary table, the best solution, and every applicable plot (auto-selected based on the run's properties: SOO vs MOO, number of objectives, `save_solutions`, `save_best_solutions`). The report uses `reportlab` and `matplotlib`, both available through the new optional dependency extra `pip install pygad[report]`. +32. A new example `examples/example_generate_report.py` shows how to build a PDF report after running a multi-objective GA. +33. The `pygad.md`, `releases.md`, `visualize.md`, and `utils.md` documentation pages were updated to reflect the new module layout, the renamed methods, the new `generate_report()` entry point, and the new NSGA-III instance attributes (`nsga3_num_divisions`, `nsga3_reference_points`). The "Other Instance Attributes & Methods" section in `pygad.md` is now grouped by area (Lifecycle, Population, Fitness, Parent Selection, NSGA-II, NSGA-III, Crossover, Mutation, Elitism, Gene Constraints, Saving) so each method or attribute appears under its topic. +34. Fix issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/351 by updating the documentation to clarify what the `solution` has. +35. Version changed in the following modules: + 1. A new submodule `pygad.benchmarks` is added with the version `1.0.0`. + 2. The version of the `pygad.utils` submodule is upgraded from `1.4.0` to `1.5.0`. + 3. The version of the `pygad.helper` submodule is upgraded from `1.3.0` to `1.4.0`. + 4. The version of the `pygad.visualize` submodule is upgraded from `1.1.1` to `1.2.0`. + 5. The version of the `pygad.nn` submodule is upgraded from `1.2.2` to `1.2.3`. + 6. The version of the `pygad.cnn` submodule is upgraded from `1.1.1` to `1.1.2`. + 7. The version of the `pygad.kerasga` submodule is upgraded from `1.3.1` to `1.3.2`. + 8. The version of the `pygad.torchga` submodule is upgraded from `1.4.1` to `1.4.2`. + 9. The version of the `pygad.gann` submodule is upgraded from `1.0.0` to `1.0.1`. + 10. The version of the `pygad.gacnn` submodule is upgraded from `1.0.0` to `1.0.1`. +36. The PDF report built by `generate_report()` now shows the PyGAD logo on its title page. The logo image ships with the package, so no network access is needed. If the image file is missing, the report is built without it. +37. Two private helper functions are added to the `pygad/utils/report.py` script for the logo. `_pdf_report_read_logo_bytes()` reads the bundled logo file and returns its bytes, or `None` if the file is missing. `_pdf_report_build_logo_image()` builds the image that is placed on the title page, or returns `None` so the report still builds without the logo. +38. The private helper functions in the `pygad/utils/report.py` script are renamed to start with the `_pdf_report_` prefix so their purpose is clear from the name. For example, `_build_title_section()` becomes `_pdf_report_build_title_section()` and `_render_plot_to_png()` becomes `_pdf_report_render_plot_to_png()`. -## PyGAD 2.3.0 +## PyGAD 3.6.0 -Release date: 1 June 2020 +Release Date April 8, 2026 -1. A new module named `pygad.cnn` is supported for building convolutional neural networks. -2. A new module named `pygad.gacnn` is supported for training convolutional neural networks using the genetic algorithm. -3. The `pygad.plot_result()` method has 3 optional parameters named `title`, `xlabel`, and `ylabel` to customize the plot title, x-axis label, and y-axis label, respectively. -4. The `pygad.nn` module supports the softmax activation function. -5. The name of the `pygad.nn.predict_outputs()` function is changed to `pygad.nn.predict()`. -6. The name of the `pygad.nn.train_network()` function is changed to `pygad.nn.train()`. +1. Support passing a class to the fitness, crossover, and mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/342 +2. A new class called `Validation` is created in the new `pygad/utils/validation.py` script. It has a method called `validate_parameters()` to validate all the parameters passed while instantiating the `pygad.GA` class. +3. Refactoring the `pygad.py` script by moving a lot of functions and methods to other classes in other scripts. + 4. The `summary()` method was moved to `Helper` class in the `pygad/helper/misc.py` script. + 5. The validation code in the `__init__()` method of the `pygad.GA` class is moved to the new `validate_parameters()` method in the new `Validation` class in the new `pygad/utils/validation.py` script. Moreover, the `validate_multi_stop_criteria()` method is also moved to the same class. + 6. The GA main workflow is moved into the new `GAEngine` class in the new `pygad/utils/engine.py` script. Specifically, these methods are moved from the `pygad.GA` class to the new `GAEngine` class: + 1. `run()` + 1. `run_loop_head()` + 2. `run_select_parents()` + 3. `run_crossover()` + 4. `run_mutation()` + 5. `run_update_population()` + 2. `initialize_population()` + 3. `cal_pop_fitness()` + 4. `best_solution()` + 5. `round_genes()` +7. The `pygad.GA` class now extends the two new classes `utils.validation.Validation` and `utils.engine.GAEngine`. +8. The version of the `pygad.utils` submodule is upgraded from `1.3.0` to `1.4.0`. +9. The version of the `pygad.helper` submodule is upgraded from `1.2.0` to `1.3.0`. +10. The version of the `pygad.visualize` submodule is upgraded from `1.1.0` to `1.1.1`. +11. The version of the `pygad.nn` submodule is upgraded from `1.2.1` to `1.2.2`. +12. The version of the `pygad.cnn` submodule is upgraded from `1.1.0` to `1.1.1`. +13. The version of the `pygad.torchga` submodule is upgraded from `1.4.0` to `1.4.1`. +14. The version of the `pygad.kerasga` submodule is upgraded from `1.3.0` to `1.3.1`. +15. Update the elitism after the evolution ends to fix issue where the best solution returned by the `best_solution()` method is not correct. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/337 +16. Fix a bug in calling the `numpy.reshape()` function. The parameter `newshape` is removed since it is no longer supported started from NumPy `2.4.0`. https://numpy.org/doc/stable/release/2.4.0-notes.html#removed-newshape-parameter-from-numpy-reshape +17. A minor change in the documentation is made to replace the `newshape` parameter when calling `numpy.reshape()`. +18. Fix a bug in the `visualize/plot.py` script that causes a warning to be given when the plot leged is used with single-objective problems. +19. A new method called `initialize_parents_array()` is added to the `Helper` class in the `pygad/helper/misc.py` script. It is usually called from the methods in the `ParentSelection` class in the `pygad/utils/parent_selection.py` script to initialize the parents array. +20. Add more tests about: + 1. Operators (crossover, mutation, and parent selection). + 2. The `best_solution()` method. + 3. Parallel processing. + 4. The `GANN` module. + 5. The plots created by the `visualize`. +21. Instead of using repeated code for converting the data type and rounding the genes during crossover and mutation, the `change_gene_dtype_and_round()` method is called from the `pygad.helper.misc.Helper` class. +22. Fix some documentation issues. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/336 +23. Update the documentation to reflect the recent additions and changes to the library structure. -## PyGAD 2.4.0 +## PyGAD 3.5.0 -Release date: 5 July 2020 +Release Date 08 July 2025 -1. A new parameter named `delay_after_gen` is added which accepts a non-negative number specifying the time in seconds to wait after a generation completes and before going to the next generation. It defaults to `0.0` which means no delay after the generation. +1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/323 +2. Fix a bug when the `stop_criteria` parameter is passed as an iterable (e.g. list) for multi-objective problems (e.g. `['reach_50_60', 'reach_20, 40']`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 +3. Call the `get_matplotlib()` function from the `plot_genes()` method inside the `pygad.visualize.plot.Plot` class to import the matplotlib library. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/315 +4. Create a new helper method called `select_unique_value()` inside the `pygad/helper/unique.py` script to select a unique gene from an array of values. +5. Create a new helper method called `get_random_mutation_range()` inside the `pygad/utils/mutation.py` script that returns the random mutation range (min and max) for a single gene by its index. +6. Create a new helper method called `change_random_mutation_value_dtype` inside the `pygad/utils/mutation.py` script that changes the data type of the value used to apply random mutation. +7. Create a new helper method called `round_random_mutation_value()` inside the `pygad/utils/mutation.py` script that rounds the value used to apply random mutation. +8. Create the `pygad/helper/misc.py` script with a class called `Helper` that has the following helper methods: + 1. `change_population_dtype_and_round()`: For each gene in the population, round the gene value and change the data type. + 2. `change_gene_dtype_and_round()`: Round the change the data type of a single gene. + 3. `mutation_change_gene_dtype_and_round()`: Decides whether mutation is done by replacement or not. Then it rounds and change the data type of the new gene value. + 4. `validate_gene_constraint_callable_output()`: Validates the output of the user-defined callable/function that checks whether the gene constraint defined in the `gene_constraint` parameter is satisfied or not. + 5. `get_gene_dtype()`: Returns the gene data type from the `gene_type` instance attribute. + 6. `get_random_mutation_range()`: Returns the random mutation range using the `random_mutation_min_val` and `random_mutation_min_val` instance attributes. + 7. `get_initial_population_range()`: Returns the initial population values range using the `init_range_low` and `init_range_high` instance attributes. + 8. `generate_gene_value_from_space()`: Generates/selects a value for a gene using the `gene_space` instance attribute. + 9. `generate_gene_value_randomly()`: Generates a random value for the gene. Only used if `gene_space` is `None`. + 10. `generate_gene_value()`: Generates a value for the gene. It checks whether `gene_space` is `None` and calls either `generate_gene_value_randomly()` or `generate_gene_value_from_space()`. + 11. `filter_gene_values_by_constraint()`: Receives a list of values for a gene. Then it filters such values using the gene constraint. + 12. `get_valid_gene_constraint_values()`: Selects one valid gene value that satisfy the gene constraint. It simply calls `generate_gene_value()` to generate some gene values then it filters such values using `filter_gene_values_by_constraint()`. +9. Create a new helper method called `mutation_process_random_value()` inside the `pygad/utils/mutation.py` script that generates constrained random values for mutation. It calls either `generate_gene_value()` or `get_valid_gene_constraint_values()` based on whether the `gene_constraint` parameter is used or not. +10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/119 +11. A new parameter called `sample_size` is added. To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either `allow_duplicate_genes` or `gene_constraint` is used. An instance attribute of the same name is created in the instances of the `pygad.GA` class. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. +12. Use the `sample_size` parameter instead of `num_trials` in the methods `solve_duplicate_genes_randomly()` and `unique_float_gene_from_range()` inside the `pygad/helper/unique.py` script. It is the maximum number of values to generate as the search space when looking for a unique float value out of a range. +13. Fixed a bug in population initialization when `allow_duplicate_genes=False`. Previously, gene values were checked for duplicates before rounding, which could allow near-duplicates like 7.61 and 7.62 to pass. After rounding (e.g., both becoming 7.6), this resulted in unintended duplicates. The fix ensures gene values are now rounded before duplicate checks, preventing such cases. +14. More tests are created. +15. More examples are created. +16. Edited the `sort_solutions_nsga2()` method in the `pygad/utils/nsga2.py` script to accept an optional parameter called `find_best_solution` when calling this method just to find the best solution. +17. Fixed a bug while applying the non-dominated sorting in the `get_non_dominated_set()` method inside the `pygad/utils/nsga2.py` script. It was swapping the non-dominated and dominated sets. In other words, it used the non-dominated set as if it is the dominated set and vice versa. All the calls to this method were edited accordingly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/320. +18. Fix a bug retrieving in the `best_solution()` method when retrieving the best solution for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/331 -2. The passed function to the `callback_generation` parameter of the pygad.GA class constructor can terminate the execution of the genetic algorithm if it returns the string `stop`. This causes the `run()` method to stop. +## PyGAD 3.4.0 -One important use case for that feature is to stop the genetic algorithm when a condition is met before passing though all the generations. The user may assigned a value of 100 to the `num_generations` parameter of the pygad.GA class constructor. Assuming that at generation 50, for example, a condition is met and the user wants to stop the execution before waiting the remaining 50 generations. To do that, just make the function passed to the `callback_generation` parameter to return the string `stop`. +Release Date 07 January 2025 -Here is an example of a function to be passed to the `callback_generation` parameter which stops the execution if the fitness value 70 is reached. The value 70 might be the best possible fitness value. After being reached, then there is no need to pass through more generations because no further improvement is possible. +1. The `delay_after_gen` parameter is removed from the `pygad.GA` class constructor. As a result, it is no longer an attribute of the `pygad.GA` class instances. To add a delay after each generation, apply it inside the `on_generation` callback. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/283 +2. In the `single_point_crossover()` method of the `pygad.utils.crossover.Crossover` class, all the random crossover points are returned before the `for` loop. This is by calling the `numpy.random.randint()` function only once before the loop to generate all the K points (where K is the offspring size). This is compared to calling the `numpy.random.randint()` function inside the `for` loop K times, once for each individual offspring. +3. Bug fix in the `examples/example_custom_operators.py` script. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/285 +4. While making prediction using the `pygad.torchga.predict()` function, no gradients are calculated. +5. The `gene_type` parameter of the `pygad.helper.unique.Unique.unique_int_gene_from_range()` method accepts the type of the current gene only instead of the full gene_type list. +6. Created a new method called `unique_float_gene_from_range()` inside the `pygad.helper.unique.Unique` class to find a unique floating-point number from a range. +7. Fix a bug in the `pygad.helper.unique.Unique.unique_gene_by_space()` method to return the numeric value only instead of a NumPy array. +8. Refactoring the `pygad/helper/unique.py` script to remove duplicate codes and reformatting the docstrings. +9. The `plot_pareto_front_curve()` method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 +11. Fix a bug converting a nested NumPy array to a nested list. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/300 +12. The `Matplotlib` library is only imported when a method inside the `pygad/visualize/plot.py` script is used. This is more efficient than using `import matplotlib.pyplot` at the module level as this causes it to be imported when `pygad` is imported even when it is not needed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/292 +13. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter (e.g. `stop_criteria=["saturate_10", "reach_-0.5"]`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/296 +14. Make sure `self.best_solutions` is a list of lists inside the `cal_pop_fitness` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/293 +15. Fix a bug where the `cal_pop_fitness()` method was using the `previous_generation_fitness` attribute to return the parents fitness. This instance attribute was not using the fitness of the latest population, instead the fitness of the population before the last one. The issue is solved by updating the `previous_generation_fitness` attribute to the latest population fitness before the GA completes. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/291 - ```python - def func_generation(ga_instance): - if ga_instance.best_solution()[1] >= 70: - return "stop" - ``` +## PyGAD 3.3.1 -## PyGAD 2.5.0 +Release Date 17 February 2024 -Release date: 19 July 2020 +1. After the last generation and before the `run()` method completes, update the 2 instance attributes: 1) `last_generation_parents` 2) `last_generation_parents_indices`. This is to keep the list of parents up-to-date with the latest population fitness `last_generation_fitness`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/275 +2. 5 methods with names starting with `run_`. Their purpose is to keep the main loop inside the `run()` method clean. Check the [Other Methods](https://pygad.readthedocs.io/en/latest/pygad.html#other-methods) section for more information. + 1. `run_loop_head()`: The code before the loop starts. + 2. `run_select_parents()`: The parent selection-related code. + 3. `run_crossover()`: The crossover-related code. + 4. `run_mutation()`: The mutation-related code. + 5. `run_update_population()`: Update the `population` instance attribute after completing the processes of crossover and mutation. -1. 2 new optional parameters added to the constructor of the `pygad.GA` class which are `crossover_probability` and `mutation_probability`. - While applying the crossover operation, each parent has a random value generated between 0.0 and 1.0. If this random value is less than or equal to the value assigned to the `crossover_probability` parameter, then the parent is selected for the crossover operation. - For the mutation operation, a random value between 0.0 and 1.0 is generated for each gene in the solution. If this value is less than or equal to the value assigned to the `mutation_probability`, then this gene is selected for mutation. -2. A new optional parameter named `linewidth` is added to the `plot_result()` method to specify the width of the curve in the plot. It defaults to 3.0. -3. Previously, the indices of the genes selected for mutation was randomly generated once for all solutions within the generation. Currently, the genes' indices are randomly generated for each solution in the population. If the population has 4 solutions, the indices are randomly generated 4 times inside the single generation, 1 time for each solution. -4. Previously, the position of the point(s) for the single-point and two-points crossover was(were) randomly selected once for all solutions within the generation. Currently, the position(s) is(are) randomly selected for each solution in the population. If the population has 4 solutions, the position(s) is(are) randomly generated 4 times inside the single generation, 1 time for each solution. -5. A new optional parameter named `gene_space` as added to the `pygad.GA` class constructor. It is used to specify the possible values for each gene in case the user wants to restrict the gene values. It is useful if the gene space is restricted to a certain range or to discrete values. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for requesting this useful feature. +## PyGAD 3.3.0 -## PyGAD 2.6.0 +Release Date 29 January 2024 -Release Date: 6 August 2020 +1. Solve bugs when multi-objective optimization is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 +2. When the `stop_ciiteria` parameter is used with the `reach` keyword, then multiple numeric values can be passed when solving a multi-objective problem. For example, if a problem has 3 objective functions, then `stop_criteria="reach_10_20_30"` means the GA stops if the fitness of the 3 objectives are at least 10, 20, and 30, respectively. The number values must match the number of objective functions. If a single value found (e.g. `stop_criteria=reach_5`) when solving a multi-objective problem, then it is used across all the objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 +3. The `delay_after_gen` parameter is now deprecated and will be removed in a future release. If it is necessary to have a time delay after each generation, then assign a callback function/method to the `on_generation` parameter to pause the evolution. +4. Parallel processing now supports calculating the fitness during adaptive mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201 +5. The population size can be changed during runtime by changing all the parameters that would affect the size of any thing used by the GA. For more information, check the [Change Population Size during Runtime](https://pygad.readthedocs.io/en/latest/generations.html#change-population-size-during-runtime) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/234 +6. When a dictionary exists in the `gene_space` parameter without a step, then mutation occurs by adding a random value to the gene value. The random vaue is generated based on the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. For more information, check the [How Mutation Works with the gene_space Parameter?](https://pygad.readthedocs.io/en/latest/gene_values.html#how-mutation-works-with-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/229 +7. Add `object` as a supported data type for int (GA.supported_int_types) and float (GA.supported_float_types). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/174 +8. Use the `raise` clause instead of the `sys.exit(-1)` to terminate the execution. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/213 +9. Fix a bug when multi-objective optimization is used with batch fitness calculation (e.g. `fitness_batch_size` set to a non-zero number). +10. Fix a bug in the `pygad.py` script when finding the index of the best solution. It does not work properly with multi-objective optimization where `self.best_solutions_fitness` have multiple columns. -1. A bug fix in assigning the value to the `initial_population` parameter. -2. A new parameter named `gene_type` is added to control the gene type. It can be either `int` or `float`. It has an effect only when the parameter `gene_space` is `None`. -3. 7 new parameters that accept callback functions: `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. + ```python + self.best_solution_generation = numpy.where(numpy.array( + self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + ``` -## PyGAD 2.7.0 +## PyGAD 3.2.0 -Release Date: 11 September 2020 -1. The `learning_rate` parameter in the `pygad.nn.train()` function defaults to **0.01**. -2. Added support of building neural networks for regression using the new parameter named `problem_type`. It is added as a parameter to both `pygad.nn.train()` and `pygad.nn.predict()` functions. The value of this parameter can be either **classification** or **regression** to define the problem type. It defaults to **classification**. -3. The activation function for a layer can be set to the string `"None"` to refer that there is no activation function at this layer. As a result, the supported values for the activation function are `"sigmoid"`, `"relu"`, `"softmax"`, and `"None"`. +Release Date 7 September 2023 -To build a regression network using the `pygad.nn` module, just do the following: -1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. -2. Set the activation function for the output layer to the string `"None"`. This sets no limits on the range of the outputs as it will be from `-infinity` to `+infinity`. If you are sure that all outputs will be nonnegative values, then use the ReLU function. +1. A new module `pygad.utils.nsga2` is created that has the `NSGA2` class that includes the functionalities of NSGA-II. The class has these methods: 1) `get_non_dominated_set()` 2) `non_dominated_sorting()` 3) `crowding_distance()` 4) `sort_solutions_nsga2()`. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. +2. Support of multi-objective optimization using Non-Dominated Sorting Genetic Algorithm II (NSGA-II) using the `NSGA2` class in the `pygad.utils.nsga2` module. Just return a `list`, `tuple`, or `numpy.ndarray` from the fitness function and the library will consider the problem as multi-objective optimization. All the objectives are expected to be maximization. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. +3. The parent selection methods and adaptive mutation are edited to support multi-objective optimization. +4. Two new NSGA-II parent selection methods are supported in the `pygad.utils.parent_selection` module: 1) Tournament selection for NSGA-II 2) NSGA-II selection. +5. The `plot_fitness()` method in the `pygad.plot` module has a new optional parameter named `label` to accept the label of the plots. This is only used for multi-objective problems. Otherwise, it is ignored. It defaults to `None` and accepts a `list`, `tuple`, or `numpy.ndarray`. The labels are used in a legend inside the plot. +6. The default color in the methods of the `pygad.plot` module is changed to the greenish `#64f20c` color. +7. A new instance attribute named `pareto_fronts` added to the `pygad.GA` instances that holds the pareto fronts when solving a multi-objective problem. +8. The `gene_type` accepts a `list`, `tuple`, or `numpy.ndarray` for integer data types given that the precision is set to `None` (e.g. `gene_type=[float, [int, None]]`). +9. In the `cal_pop_fitness()` method, the fitness value is re-used if `save_best_solutions=True` and the solution is found in the `best_solutions` attribute. These parameters also can help re-using the fitness of a solution instead of calling the fitness function: `keep_elitism`, `keep_parents`, and `save_solutions`. +10. The value `99999999999` is replaced by `float('inf')` in the 2 methods `wheel_cumulative_probs()` and `stochastic_universal_selection()` inside the `pygad.utils.parent_selection.ParentSelection` class. +11. The `plot_result()` method in the `pygad.visualize.plot.Plot` class is removed. Instead, please use the `plot_fitness()` if you did not upgrade yet. -Check the documentation of the `pygad.nn` module for an example that builds a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NumPyANN): https://github.com/ahmedfgad/NumPyANN +## PyGAD 3.1.0 -To build and train a regression network using the `pygad.gann` module, do the following: +Release Date 20 June 2023 -1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. -2. Set the `output_activation` parameter in the constructor of the `pygad.gann.GANN` class to `"None"`. - -Check the documentation of the `pygad.gann` module for an example that builds and trains a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NeuralGenetic): https://github.com/ahmedfgad/NeuralGenetic - -To build a classification network, either ignore the `problem_type` parameter or set it to `"classification"` (default value). In this case, the activation function of the last layer can be set to any type (e.g. softmax). - -## PyGAD 2.7.1 - -Release Date: 11 September 2020 - -1. A bug fix when the `problem_type` argument is set to `regression`. - -## PyGAD 2.7.2 - -Release Date: 14 September 2020 - -1. Bug fix to support building and training regression neural networks with multiple outputs. - -## PyGAD 2.8.0 - -Release Date: 20 September 2020 - -1. Support of a new module named `kerasga` so that the Keras models can be trained by the genetic algorithm using PyGAD. - -## PyGAD 2.8.1 - -Release Date: 3 October 2020 - -1. Bug fix in applying the crossover operation when the `crossover_probability` parameter is used. Thanks to [Eng. Hamada Kassem, Research and Teaching Assistant, Construction Engineering and Management, Faculty of Engineering, Alexandria University, Egypt](https://www.linkedin.com/in/hamadakassem). - -## PyGAD 2.9.0 - -Release Date: 06 December 2020 - -1. The fitness values of the initial population are considered in the `best_solutions_fitness` attribute. -2. An optional parameter named `save_best_solutions` is added. It defaults to `False`. When it is `True`, then the best solution after each generation is saved into an attribute named `best_solutions`. If `False`, then no solutions are saved and the `best_solutions` attribute will be empty. -3. Scattered crossover is supported. To use it, assign the `crossover_type` parameter the value `"scattered"`. -4. NumPy arrays are now supported by the `gene_space` parameter. -5. The following parameters (`gene_type`, `crossover_probability`, `mutation_probability`, `delay_after_gen`) can be assigned to a numeric value of any of these data types: `int`, `float`, `numpy.int`, `numpy.int8`, `numpy.int16`, `numpy.int32`, `numpy.int64`, `numpy.float`, `numpy.float16`, `numpy.float32`, or `numpy.float64`. - -## PyGAD 2.10.0 - -Release Date: 03 January 2021 - -1. Support of a new module `pygad.torchga` to train PyTorch models using PyGAD. Check [its documentation](https://pygad.readthedocs.io/en/latest/torchga.html). -2. Support of adaptive mutation where the mutation rate is determined by the fitness value of each solution. Read the [Adaptive Mutation](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#adaptive-mutation) section for more details. Also, read this paper: [Libelli, S. Marsili, and P. Alba. "Adaptive mutation in genetic algorithms." Soft computing 4.2 (2000): 76-80.](https://www.researchgate.net/publication/225642916_Adaptive_mutation_in_genetic_algorithms) -3. Before the `run()` method completes or exits, the fitness value of the best solution in the current population is appended to the `best_solution_fitness` list attribute. Note that the fitness value of the best solution in the initial population is already saved at the beginning of the list. So, the fitness value of the best solution is saved before the genetic algorithm starts and after it ends. -4. When the parameter `parent_selection_type` is set to `sss` (steady-state selection), then a warning message is printed if the value of the `keep_parents` parameter is set to 0. -5. More validations to the user input parameters. -6. The default value of the `mutation_percent_genes` is set to the string `"default"` rather than the integer 10. This change helps to know whether the user explicitly passed a value to the `mutation_percent_genes` parameter or it is left to its default one. The `"default"` value is later translated into the integer 10. -7. The `mutation_percent_genes` parameter is no longer accepting the value 0. It must be `>0` and `<=100`. -8. The built-in `warnings` module is used to show warning messages rather than just using the `print()` function. -9. A new `bool` parameter called `suppress_warnings` is added to the constructor of the `pygad.GA` class. It allows the user to control whether the warning messages are printed or not. It defaults to `False` which means the messages are printed. -10. A helper method called `adaptive_mutation_population_fitness()` is created to calculate the average fitness value used in adaptive mutation to filter the solutions. -11. The `best_solution()` method accepts a new optional parameter called `pop_fitness`. It accepts a list of the fitness values of the solutions in the population. If `None`, then the `cal_pop_fitness()` method is called to calculate the fitness values of the population. - -## PyGAD 2.10.1 - -Release Date: 10 January 2021 - -1. In the `gene_space` parameter, any `None` value (regardless of its index or axis), is replaced by a randomly generated number based on the 3 parameters `init_range_low`, `init_range_high`, and `gene_type`. So, the `None` value in `[..., None, ...]` or `[..., [..., None, ...], ...]` are replaced with random values. This gives more freedom in building the space of values for the genes. -2. All the numbers passed to the `gene_space` parameter are casted to the type specified in the `gene_type` parameter. -3. The `numpy.uint` data type is supported for the parameters that accept integer values. -4. In the `pygad.kerasga` module, the `model_weights_as_vector()` function uses the `trainable` attribute of the model's layers to only return the trainable weights in the network. So, only the trainable layers with their `trainable` attribute set to `True` (`trainable=True`), which is the default value, have their weights evolved. All non-trainable layers with the `trainable` attribute set to `False` (`trainable=False`) will not be evolved. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for pointing about that at [GitHub](https://github.com/ahmedfgad/KerasGA/issues/1). - -## PyGAD 2.10.2 - -Release Date: 15 January 2021 - -1. A bug fix when `save_best_solutions=True`. Refer to this issue for more information: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/25 - -## PyGAD 2.11.0 +1. Fix a bug when the initial population has duplciate genes if a nested gene space is used. +2. The `gene_space` parameter can no longer be assigned a tuple. +3. Fix a bug when the `gene_space` parameter has a member of type `tuple`. +4. A new instance attribute called `gene_space_unpacked` which has the unpacked `gene_space`. It is used to solve duplicates. For infinite ranges in the `gene_space`, they are unpacked to a limited number of values (e.g. 100). +5. Bug fixes when creating the initial population using `gene_space` attribute. +6. When a `dict` is used with the `gene_space` attribute, the new gene value was calculated by summing 2 values: 1) the value sampled from the `dict` 2) a random value returned from the random mutation range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This might cause the gene value to exceed the range limit defined in the `gene_space`. To respect the `gene_space` range, this release only returns the value from the `dict` without summing it to a random value. +7. Formatting the strings using f-string instead of the `format()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 +8. In the `__init__()` of the `pygad.GA` class, the logged error messages are handled using a `try-except` block instead of repeating the `logger.error()` command. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 +9. A new class named `CustomLogger` is created in the `pygad.cnn` module to create a default logger using the `logging` module assigned to the `logger` attribute. This class is extended in all other classes in the module. The constructors of these classes have a new parameter named `logger` which defaults to `None`. If no logger is passed, then the default logger in the `CustomLogger` class is used. +10. Except for the `pygad.nn` module, the `print()` function in all other modules are replaced by the `logging` module to log messages. +11. The callback functions/methods `on_fitness()`, `on_parents()`, `on_crossover()`, and `on_mutation()` can return values. These returned values override the corresponding properties. The output of `on_fitness()` overrides the population fitness. The `on_parents()` function/method must return 2 values representing the parents and their indices. The output of `on_crossover()` overrides the crossover offspring. The output of `on_mutation()` overrides the mutation offspring. +12. Fix a bug when adaptive mutation is used while `fitness_batch_size`>1. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195 +13. When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happen that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene by another gene. This release tries to solve such duplicates by looking for a third gene that will help in solving the duplicates. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) for more information. +14. Use probabilities to select parents using the rank parent selection method. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/205 +15. The 2 parameters `random_mutation_min_val` and `random_mutation_max_val` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the mutation range for each individual gene. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 +16. The 2 parameters `init_range_low` and `init_range_high` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the initial range for each individual gene when creating the initial population. +17. The `data` parameter in the `predict()` function of the `pygad.kerasga` module can be assigned a data generator. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/115 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 +18. The `predict()` function of the `pygad.kerasga` module accepts 3 optional parameters: 1) `batch_size=None`, `verbose=0`, and `steps=None`. Check documentation of the [Keras Model.predict()](https://keras.io/api/models/model_training_apis) method for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 +19. The documentation is updated to explain how mutation works when `gene_space` is used with `int` or `float` data types. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter). https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 -Release Date: 16 February 2021 +## PyGAD 3.0.1 -1. In the `gene_space` argument, the user can use a dictionary to specify the lower and upper limits of the gene. This dictionary must have only 2 items with keys `low` and `high` to specify the low and high limits of the gene, respectively. This way, PyGAD takes care of not exceeding the value limits of the gene. For a problem with only 2 genes, then using `gene_space=[{'low': 1, 'high': 5}, {'low': 0.2, 'high': 0.81}]` means the accepted values in the first gene start from 1 (inclusive) to 5 (exclusive) while the second one has values between 0.2 (inclusive) and 0.85 (exclusive). For more information, please check the [Limit the Gene Value Range](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter) section of the documentation. -2. The `plot_result()` method returns the figure so that the user can save it. -3. Bug fixes in copying elements from the gene space. -4. For a gene with a set of discrete values (more than 1 value) in the `gene_space` parameter like `[0, 1]`, it was possible that the gene value may not change after mutation. That is if the current value is 0, then the randomly selected value could also be 0. Now, it is verified that the new value is changed. So, if the current value is 0, then the new value after mutation will not be 0 but 1. +Release Date 20 April 2023 -## PyGAD 2.12.0 +1. Fix an issue with passing user-defined function/method for parent selection. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/179 -Release Date: 20 February 2021 +## PyGAD 3.0.0 -1. 4 new instance attributes are added to hold temporary results after each generation: `last_generation_fitness` holds the fitness values of the solutions in the last generation, `last_generation_parents` holds the parents selected from the last generation, `last_generation_offspring_crossover` holds the offspring generated after applying the crossover in the last generation, and `last_generation_offspring_mutation` holds the offspring generated after applying the mutation in the last generation. You can access these attributes inside the `on_generation()` method for example. -2. A bug fixed when the `initial_population` parameter is used. The bug occurred due to a mismatch between the data type of the array assigned to `initial_population` and the gene type in the `gene_type` attribute. Assuming that the array assigned to the `initial_population` parameter is `((1, 1), (3, 3), (5, 5), (7, 7))` which has type `int`. When `gene_type` is set to `float`, then the genes will not be float but casted to `int` because the defined array has `int` type. The bug is fixed by forcing the array assigned to `initial_population` to have the data type in the `gene_type` attribute. Check the [issue at GitHub](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27 +Release Date 8 April 2023 -Thanks to Andrei Rozanski [PhD Bioinformatics Specialist, Department of Tissue Dynamics and Regeneration, Max Planck Institute for Biophysical Chemistry, Germany] for opening my eye to the first change. +1. The structure of the library is changed and some methods defined in the `pygad.py` module are moved to the `pygad.utils`, `pygad.helper`, and `pygad.visualize` submodules. + 2. The `pygad.utils.parent_selection` module has a class named `ParentSelection` where all the parent selection operators exist. The `pygad.GA` class extends this class. + 3. The `pygad.utils.crossover` module has a class named `Crossover` where all the crossover operators exist. The `pygad.GA` class extends this class. + 4. The `pygad.utils.mutation` module has a class named `Mutation` where all the mutation operators exist. The `pygad.GA` class extends this class. + 5. The `pygad.helper.unique` module has a class named `Unique` some helper methods exist to solve duplicate genes and make sure every gene is unique. The `pygad.GA` class extends this class. + 6. The `pygad.visualize.plot` module has a class named `Plot` where all the methods that create plots exist. The `pygad.GA` class extends this class. + 7. Support of using the `logging` module to log the outputs to both the console and text file instead of using the `print()` function. This is by assigning the `logging.Logger` to the new `logger` parameter. Check the [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. + 8. A new instance attribute called `logger` to save the logger. + 9. The function/method passed to the `fitness_func` parameter accepts a new parameter that refers to the instance of the `pygad.GA` class. Check this for an example: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/163 + 10. Update the documentation to include an example of using functions and methods to calculate the fitness and build callbacks. Check this for more details: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92#issuecomment-1443635003 + 11. Validate the value passed to the `initial_population` parameter. + 12. Validate the type and length of the `pop_fitness` parameter of the `best_solution()` method. + 13. Some edits in the documentation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/106 + 14. Fix an issue when building the initial population as (some) genes have their value taken from the mutation range (defined by the parameters `random_mutation_min_val` and `random_mutation_max_val`) instead of using the parameters `init_range_low` and `init_range_high`. + 15. The `summary()` method returns the summary as a single-line string. Just log/print the returned string it to see it properly. + 16. The `callback_generation` parameter is removed. Use the `on_generation` parameter instead. + 17. There was an issue when using the `parallel_processing` parameter with Keras and PyTorch. As Keras/PyTorch are not thread-safe, the `predict()` method gives incorrect and weird results when more than 1 thread is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/145 https://github.com/ahmedfgad/TorchGA/issues/5 https://github.com/ahmedfgad/KerasGA/issues/6. Thanks to this [StackOverflow answer](https://stackoverflow.com/a/75606666/5426539). + 18. Replace `numpy.float` by `float` in the 2 parent selection operators roulette wheel and stochastic universal. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/168 -Thanks to [Marios Giouvanakis](https://www.researchgate.net/profile/Marios-Giouvanakis), a PhD candidate in Electrical & Computer Engineer, [Aristotle University of Thessaloniki (Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης), Greece](https://www.auth.gr/en), for emailing me about the second issue. +## PyGAD 2.19.2 -## PyGAD 2.13.0 +Release Date 23 February 2023 -Release Date: 12 March 2021 +1. Fix an issue when parallel processing was used where the elitism solutions' fitness values are not re-used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160#issuecomment-1441718184 -1. A new `bool` parameter called `allow_duplicate_genes` is supported. If `True`, which is the default, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. Check the [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) section for more details. -2. The `last_generation_fitness` is updated at the end of each generation not at the beginning. This keeps the fitness values of the most up-to-date population assigned to the `last_generation_fitness` parameter. +## PyGAD 2.19.1 -## PyGAD 2.14.0 +Release Date: 22 February 2023 -PyGAD 2.14.0 has an issue that is solved in PyGAD 2.14.1. Please consider using 2.14.1 not 2.14.0. +1. Add the [cloudpickle](https://github.com/cloudpipe/cloudpickle) library as a dependency. -Release Date: 19 May 2021 +## PyGAD 2.19.0 -1. [Issue #40](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40) is solved. Now, the `None` value works with the `crossover_type` and `mutation_type` parameters: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40 -2. The `gene_type` parameter supports accepting a `list/tuple/numpy.ndarray` of numeric data types for the genes. This helps to control the data type of each individual gene. Previously, the `gene_type` can be assigned only to a single data type that is applied for all genes. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for asking about this feature in [this discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43): https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43 -3. A new `bool` attribute named `gene_type_single` is added to the `pygad.GA` class. It is `True` when there is a single data type assigned to the `gene_type` parameter. When the `gene_type` parameter is assigned a `list/tuple/numpy.ndarray`, then `gene_type_single` is set to `False`. -4. The `mutation_by_replacement` flag now has no effect if `gene_space` exists except for the genes with `None` values. For example, for `gene_space=[None, [5, 6]]` the `mutation_by_replacement` flag affects only the first gene which has `None` for its value space. -5. When an element has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[None, [5, 6]]`), then its value will be randomly generated for each solution rather than being generate once for all solutions. Previously, the gene with `None` value in `gene_space` is the same across all solutions -6. Some changes in the documentation according to [issue #32](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32 +Release Date: 22 February 2023 +1. A new `summary()` method is supported to return a Keras-like summary of the PyGAD lifecycle. +2. A new optional parameter called `fitness_batch_size` is supported to calculate the fitness in batches. If it is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136. +3. The `cloudpickle` library (https://github.com/cloudpipe/cloudpickle) is used instead of the `pickle` library to pickle the `pygad.GA` objects. This solves the issue of having to redefine the functions (e.g. fitness function). The `cloudpickle` library is added as a dependency in the `requirements.txt` file. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/159 +4. Support of assigning methods to these parameters: `fitness_func`, `crossover_type`, `mutation_type`, `parent_selection_type`, `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +5. Validating the output of the parent selection, crossover, and mutation functions. +6. The built-in parent selection operators return the parent's indices as a NumPy array. +7. The outputs of the parent selection, crossover, and mutation operators must be NumPy arrays. +8. Fix an issue when `allow_duplicate_genes=True`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 +9. Fix an issue creating scatter plots of the solutions' fitness. +10. Sampling from a `set()` is no longer supported in Python 3.11. Instead, sampling happens from a `list()`. Thanks `Marco Brenna` for pointing to this issue. +11. The lifecycle is updated to reflect that the new population's fitness is calculated at the end of the lifecycle not at the beginning. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154#issuecomment-1438739483 +12. There was an issue when `save_solutions=True` that causes the fitness function to be called for solutions already explored and have their fitness pre-calculated. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160 +13. A new instance attribute named `last_generation_elitism_indices` added to hold the indices of the selected elitism. This attribute helps to re-use the fitness of the elitism instead of calling the fitness function. +14. Fewer calls to the `best_solution()` method which in turns saves some calls to the fitness function. +15. Some updates in the documentation to give more details about the `cal_pop_fitness()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/79#issuecomment-1439605442 -## PyGAD 2.14.2 +## PyGAD 2.18.3 -Release Date: 27 May 2021 +Release Date: 14 February 2023 -1. Some bug fixes when the `gene_type` parameter is nested. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for opening [a discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342) to report this bug: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342 +1. Bug fixes. -[Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) helped a lot in suggesting new features and suggesting enhancements in 2.14.0 to 2.14.2 releases. +## PyGAD 2.18.2 +Release Date: 14 February 2023 -## PyGAD 2.14.3 +1. Remove `numpy.int` and `numpy.float` from the list of supported data types. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/151 https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/152 +2. Call the `on_crossover()` callback function even if `crossover_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +3. Call the `on_mutation()` callback function even if `mutation_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -Release Date: 6 June 2021 +## PyGAD 2.18.1 -1. Some bug fixes when setting the `save_best_solutions` parameter to `True`. Previously, the best solution for generation `i` was added into the `best_solutions` attribute at generation `i+1`. Now, the `best_solutions` attribute is updated by each best solution at its exact generation. +Release Date: 19 September 2022 -## PyGAD 2.15.0 +1. A big fix when `keep_elitism` is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/132 -Release Date: 17 June 2021 +## PyGAD 2.18.0 +Release Date: 9 September 2022 -1. Control the precision of all genes/individual genes. Thanks to [Rainer](https://github.com/rengel8) for asking about this feature: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763452 -2. A new attribute named `last_generation_parents_indices` holds the indices of the selected parents in the last generation. -3. In adaptive mutation, no need to recalculate the fitness values of the parents selected in the last generation as these values can be returned based on the `last_generation_fitness` and `last_generation_parents_indices` attributes. This speeds-up the adaptive mutation. -4. When a sublist has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[[1, 2, 3], [5, 6, None]]`), then its value will be randomly generated for each solution rather than being generated once for all solutions. Previously, a value of `None` in a sublist of the `gene_space` parameter was identical across all solutions. -5. The dictionary assigned to the `gene_space` parameter itself or one of its elements has a new key called `"step"` to specify the step of moving from the start to the end of the range specified by the 2 existing keys `"low"` and `"high"`. An example is `{"low": 0, "high": 30, "step": 2}` to have only even values for the gene(s) starting from 0 to 30. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/48 -6. A new function called `predict()` is added in both the `pygad.kerasga` and `pygad.torchga` modules to make predictions. This makes it easier than using custom code each time a prediction is to be made. -7. A new parameter called `stop_criteria` allows the user to specify one or more stop criteria to stop the evolution based on some conditions. Each criterion is passed as `str` which has a stop word. The current 2 supported words are `reach` and `saturate`. `reach` stops the `run()` method if the fitness value is equal to or greater than a given fitness value. An example for `reach` is `"reach_40"` which stops the evolution if the fitness is >= 40. `saturate` means stop the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. Thanks to [Rainer](https://github.com/rengel8) for asking about this feature: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/44 -8. A new bool parameter, defaults to `False`, named `save_solutions` is added to the constructor of the `pygad.GA` class. If `True`, then all solutions in each generation are appended into an attribute called `solutions` which is NumPy array. -9. The `plot_result()` method is renamed to `plot_fitness()`. The users should migrate to the new name as the old name will be removed in the future. -10. Four new optional parameters are added to the `plot_fitness()` function in the `pygad.GA` class which are `font_size=14`, `save_dir=None`, `color="#3870FF"`, and `plot_type="plot"`. Use `font_size` to change the font of the plot title and labels. `save_dir` accepts the directory to which the figure is saved. It defaults to `None` which means do not save the figure. `color` changes the color of the plot. `plot_type` changes the plot type which can be either `"plot"` (default), `"scatter"`, or `"bar"`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/47 -11. The default value of the `title` parameter in the `plot_fitness()` method is `"PyGAD - Generation vs. Fitness"` rather than `"PyGAD - Iteration vs. Fitness"`. -12. A new method named `plot_new_solution_rate()` creates, shows, and returns a figure showing the rate of new/unique solutions explored in each generation. It accepts the same parameters as in the `plot_fitness()` method. This method only works when `save_solutions=True` in the `pygad.GA` class's constructor. -13. A new method named `plot_genes()` creates, shows, and returns a figure to show how each gene changes per each generation. It accepts similar parameters like the `plot_fitness()` method in addition to the `graph_type`, `fill_color`, and `solutions` parameters. The `graph_type` parameter can be either `"plot"` (default), `"boxplot"`, or `"histogram"`. `fill_color` accepts the fill color which works when `graph_type` is either `"boxplot"` or `"histogram"`. `solutions` can be either `"all"` or `"best"` to decide whether all solutions or only best solutions are used. -14. The `gene_type` parameter now supports controlling the precision of `float` data types. For a gene, rather than assigning just the data type like `float`, assign a `list`/`tuple`/`numpy.ndarray` with 2 elements where the first one is the type and the second one is the precision. For example, `[float, 2]` forces a gene with a value like `0.1234` to be `0.12`. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. +1. Raise an exception if the sum of fitness values is zero while either roulette wheel or stochastic universal parent selection is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/129 +2. Initialize the value of the `run_completed` property to `False`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/122 +3. The values of these properties are no longer reset with each call to the `run()` method `self.best_solutions, self.best_solutions_fitness, self.solutions, self.solutions_fitness`: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123. Now, the user can have the flexibility of calling the `run()` method more than once while extending the data collected after each generation. Another advantage happens when the instance is loaded and the `run()` method is called, as the old fitness value are shown on the graph alongside with the new fitness values. Read more in this section: [Continue without Losing Progress](https://pygad.readthedocs.io/en/latest/generations.html#continue-without-losing-progress) +4. Thanks [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan) (Dept. of Information and Communications Engineering, University of Murcia, Murcia, Spain) for editing this [comment](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/5315bbec02777df96ce1ec665c94dece81c440f4/pygad.py#L73) in the code. https://github.com/ahmedfgad/GeneticAlgorithmPython/commit/5315bbec02777df96ce1ec665c94dece81c440f4 +5. A bug fixed when `crossover_type=None`. +6. Support of elitism selection through a new parameter named `keep_elitism`. It defaults to 1 which means for each generation keep only the best solution in the next generation. If assigned 0, then it has no effect. Read more in this section: [Elitism Selection](https://pygad.readthedocs.io/en/latest/generations.html#elitism-selection). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/74 +7. A new instance attribute named `last_generation_elitism` added to hold the elitism in the last generation. +8. A new parameter called `random_seed` added to accept a seed for the random function generators. Credit to this issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/70 and [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan). Read more in this section: [Random Seed](https://pygad.readthedocs.io/en/latest/generations.html#random-seed). +9. Editing the `pygad.TorchGA` module to make sure the tensor data is moved from GPU to CPU. Thanks to Rasmus Johansson for opening this pull request: https://github.com/ahmedfgad/TorchGA/pull/2 -## PyGAD 2.15.1 +## PyGAD 2.17.0 -Release Date: 18 June 2021 +Release Date: 8 July 2022 -1. Fix a bug when `keep_parents` is set to a positive integer. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/49 +1. An issue is solved when the `gene_space` parameter is given a fixed value. e.g. gene_space=[range(5), 4]. The second gene's value is static (4) which causes an exception. +2. Fixed the issue where the `allow_duplicate_genes` parameter did not work when mutation is disabled (i.e. `mutation_type=None`). This is by checking for duplicates after crossover directly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 +3. Solve an issue in the `tournament_selection()` method as the indices of the selected parents were incorrect. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/89 +4. Reuse the fitness values of the previously explored solutions rather than recalculating them. This feature only works if `save_solutions=True`. +4. Parallel processing is supported. This is by the introduction of a new parameter named `parallel_processing` in the constructor of the `pygad.GA` class. Thanks to [@windowshopr](https://github.com/windowshopr) for opening the issue [#78](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/78) at GitHub. Check the [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) section for more information and examples. -## PyGAD 2.15.2 +## PyGAD 2.16.3 -Release Date: 18 June 2021 +Release Date: 2 February 2022 -1. Fix a bug when using the `kerasga` or `torchga` modules. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/51 +1. Validate the fitness value returned from the fitness function. An exception is raised if something is wrong. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/67 -## PyGAD 2.16.0 +## PyGAD 2.16.2 -Release Date: 19 June 2021 +Release Date: 2 February 2022 -1. A user-defined function can be passed to the `mutation_type`, `crossover_type`, and `parent_selection_type` parameters in the `pygad.GA` class to create a custom mutation, crossover, and parent selection operators. Check the [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators) section for more details. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50 +1. A new instance attribute called `previous_generation_fitness` added in the `pygad.GA` class. It holds the fitness values of one generation before the fitness values saved in the `last_generation_fitness`. +3. Issue in the `cal_pop_fitness()` method in getting the correct indices of the previous parents. This is solved by using the previous generation's fitness saved in the new attribute `previous_generation_fitness` to return the parents' fitness values. Thanks to Tobias Tischhauser (M.Sc. - [Mitarbeiter Institut EMS, Departement Technik, OST – Ostschweizer Fachhochschule, Switzerland](https://www.ost.ch/de/forschung-und-dienstleistungen/technik/systemtechnik/ems/team)) for detecting this bug. ## PyGAD 2.16.1 Release Date: 28 September 2021 -1. The user can use the `tqdm` library to show a progress bar. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50. +1. The user can use the `tqdm` library to show a progress bar. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50. ```python import pygad @@ -322,12 +437,12 @@ with tqdm.tqdm(total=num_generations) as pbar: num_genes=len(equation_inputs), fitness_func=fitness_func, on_generation=lambda _: pbar.update(1)) - + ga_instance.run() ga_instance.plot_result() ``` -But this work does not work if the `ga_instance` will be pickled (i.e. the `save()` method will be called. +But this work does not work if the `ga_instance` will be pickled (i.e. the `save()` method will be called. ```python ga_instance.save("test") @@ -372,371 +487,295 @@ ga_instance.save("test") 3. There was an issue of getting the length of these 4 variables (`solutions`, `solutions_fitness`, `best_solutions`, and `best_solutions_fitness`) doubled after each call of the `run()` method. This is solved by resetting these variables at the beginning of the `run()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/62 4. Bug fixes when adaptive mutation is used (`mutation_type="adaptive"`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/65 -## PyGAD 2.16.2 +## PyGAD 2.16.0 -Release Date: 2 February 2022 +Release Date: 19 June 2021 -1. A new instance attribute called `previous_generation_fitness` added in the `pygad.GA` class. It holds the fitness values of one generation before the fitness values saved in the `last_generation_fitness`. -3. Issue in the `cal_pop_fitness()` method in getting the correct indices of the previous parents. This is solved by using the previous generation's fitness saved in the new attribute `previous_generation_fitness` to return the parents' fitness values. Thanks to Tobias Tischhauser (M.Sc. - [Mitarbeiter Institut EMS, Departement Technik, OST – Ostschweizer Fachhochschule, Switzerland](https://www.ost.ch/de/forschung-und-dienstleistungen/technik/systemtechnik/ems/team)) for detecting this bug. +1. A user-defined function can be passed to the `mutation_type`, `crossover_type`, and `parent_selection_type` parameters in the `pygad.GA` class to create a custom mutation, crossover, and parent selection operators. Check the [User-Defined Crossover, Mutation, and Parent Selection Operators](https://pygad.readthedocs.io/en/latest/user_defined_operators.html#user-defined-crossover-mutation-and-parent-selection-operators) section for more details. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/50 -## PyGAD 2.16.3 +## PyGAD 2.15.2 -Release Date: 2 February 2022 +Release Date: 18 June 2021 -1. Validate the fitness value returned from the fitness function. An exception is raised if something is wrong. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/67 +1. Fix a bug when using the `kerasga` or `torchga` modules. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/51 -## PyGAD 2.17.0 +## PyGAD 2.15.1 -Release Date: 8 July 2022 +Release Date: 18 June 2021 -1. An issue is solved when the `gene_space` parameter is given a fixed value. e.g. gene_space=[range(5), 4]. The second gene's value is static (4) which causes an exception. -2. Fixed the issue where the `allow_duplicate_genes` parameter did not work when mutation is disabled (i.e. `mutation_type=None`). This is by checking for duplicates after crossover directly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 -3. Solve an issue in the `tournament_selection()` method as the indices of the selected parents were incorrect. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/89 -4. Reuse the fitness values of the previously explored solutions rather than recalculating them. This feature only works if `save_solutions=True`. -4. Parallel processing is supported. This is by the introduction of a new parameter named `parallel_processing` in the constructor of the `pygad.GA` class. Thanks to [@windowshopr](https://github.com/windowshopr) for opening the issue [#78](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/78) at GitHub. Check the [Parallel Processing in PyGAD](https://pygad.readthedocs.io/en/latest/fitness_calculation.html#parallel-processing-in-pygad) section for more information and examples. +1. Fix a bug when `keep_parents` is set to a positive integer. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/49 -## PyGAD 2.18.0 -Release Date: 9 September 2022 +## PyGAD 2.15.0 -1. Raise an exception if the sum of fitness values is zero while either roulette wheel or stochastic universal parent selection is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/129 -2. Initialize the value of the `run_completed` property to `False`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/122 -3. The values of these properties are no longer reset with each call to the `run()` method `self.best_solutions, self.best_solutions_fitness, self.solutions, self.solutions_fitness`: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/123. Now, the user can have the flexibility of calling the `run()` method more than once while extending the data collected after each generation. Another advantage happens when the instance is loaded and the `run()` method is called, as the old fitness value are shown on the graph alongside with the new fitness values. Read more in this section: [Continue without Losing Progress](https://pygad.readthedocs.io/en/latest/generations.html#continue-without-losing-progress) -4. Thanks [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan) (Dept. of Information and Communications Engineering, University of Murcia, Murcia, Spain) for editing this [comment](https://github.com/ahmedfgad/GeneticAlgorithmPython/blob/5315bbec02777df96ce1ec665c94dece81c440f4/pygad.py#L73) in the code. https://github.com/ahmedfgad/GeneticAlgorithmPython/commit/5315bbec02777df96ce1ec665c94dece81c440f4 -5. A bug fixed when `crossover_type=None`. -6. Support of elitism selection through a new parameter named `keep_elitism`. It defaults to 1 which means for each generation keep only the best solution in the next generation. If assigned 0, then it has no effect. Read more in this section: [Elitism Selection](https://pygad.readthedocs.io/en/latest/generations.html#elitism-selection). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/74 -7. A new instance attribute named `last_generation_elitism` added to hold the elitism in the last generation. -8. A new parameter called `random_seed` added to accept a seed for the random function generators. Credit to this issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/70 and [Prof. Fernando Jiménez Barrionuevo](http://webs.um.es/fernan). Read more in this section: [Random Seed](https://pygad.readthedocs.io/en/latest/generations.html#random-seed). -9. Editing the `pygad.TorchGA` module to make sure the tensor data is moved from GPU to CPU. Thanks to Rasmus Johansson for opening this pull request: https://github.com/ahmedfgad/TorchGA/pull/2 +Release Date: 17 June 2021 -## PyGAD 2.18.1 +1. Control the precision of all genes/individual genes. Thanks to [Rainer](https://github.com/rengel8) for asking about this feature: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763452 +2. A new attribute named `last_generation_parents_indices` holds the indices of the selected parents in the last generation. +3. In adaptive mutation, no need to recalculate the fitness values of the parents selected in the last generation as these values can be returned based on the `last_generation_fitness` and `last_generation_parents_indices` attributes. This speeds-up the adaptive mutation. +4. When a sublist has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[[1, 2, 3], [5, 6, None]]`), then its value will be randomly generated for each solution rather than being generated once for all solutions. Previously, a value of `None` in a sublist of the `gene_space` parameter was identical across all solutions. +5. The dictionary assigned to the `gene_space` parameter itself or one of its elements has a new key called `"step"` to specify the step of moving from the start to the end of the range specified by the 2 existing keys `"low"` and `"high"`. An example is `{"low": 0, "high": 30, "step": 2}` to have only even values for the gene(s) starting from 0 to 30. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/48 +6. A new function called `predict()` is added in both the `pygad.kerasga` and `pygad.torchga` modules to make predictions. This makes it easier than using custom code each time a prediction is to be made. +7. A new parameter called `stop_criteria` allows the user to specify one or more stop criteria to stop the evolution based on some conditions. Each criterion is passed as `str` which has a stop word. The current 2 supported words are `reach` and `saturate`. `reach` stops the `run()` method if the fitness value is equal to or greater than a given fitness value. An example for `reach` is `"reach_40"` which stops the evolution if the fitness is >= 40. `saturate` means stop the evolution if the fitness saturates for a given number of consecutive generations. An example for `saturate` is `"saturate_7"` which means stop the `run()` method if the fitness does not change for 7 consecutive generations. Thanks to [Rainer](https://github.com/rengel8) for asking about this feature: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/44 +8. A new bool parameter, defaults to `False`, named `save_solutions` is added to the constructor of the `pygad.GA` class. If `True`, then all solutions in each generation are appended into an attribute called `solutions` which is NumPy array. +9. The `plot_result()` method is renamed to `plot_fitness()`. The users should migrate to the new name as the old name will be removed in the future. +10. Four new optional parameters are added to the `plot_fitness()` function in the `pygad.GA` class which are `font_size=14`, `save_dir=None`, `color="#3870FF"`, and `plot_type="plot"`. Use `font_size` to change the font of the plot title and labels. `save_dir` accepts the directory to which the figure is saved. It defaults to `None` which means do not save the figure. `color` changes the color of the plot. `plot_type` changes the plot type which can be either `"plot"` (default), `"scatter"`, or `"bar"`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/47 +11. The default value of the `title` parameter in the `plot_fitness()` method is `"PyGAD - Generation vs. Fitness"` rather than `"PyGAD - Iteration vs. Fitness"`. +12. A new method named `plot_new_solution_rate()` creates, shows, and returns a figure showing the rate of new/unique solutions explored in each generation. It accepts the same parameters as in the `plot_fitness()` method. This method only works when `save_solutions=True` in the `pygad.GA` class's constructor. +13. A new method named `plot_genes()` creates, shows, and returns a figure to show how each gene changes per each generation. It accepts similar parameters like the `plot_fitness()` method in addition to the `graph_type`, `fill_color`, and `solutions` parameters. The `graph_type` parameter can be either `"plot"` (default), `"boxplot"`, or `"histogram"`. `fill_color` accepts the fill color which works when `graph_type` is either `"boxplot"` or `"histogram"`. `solutions` can be either `"all"` or `"best"` to decide whether all solutions or only best solutions are used. +14. The `gene_type` parameter now supports controlling the precision of `float` data types. For a gene, rather than assigning just the data type like `float`, assign a `list`/`tuple`/`numpy.ndarray` with 2 elements where the first one is the type and the second one is the precision. For example, `[float, 2]` forces a gene with a value like `0.1234` to be `0.12`. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. -Release Date: 19 September 2022 +## PyGAD 2.14.3 -1. A big fix when `keep_elitism` is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/132 +Release Date: 6 June 2021 -## PyGAD 2.18.2 -Release Date: 14 February 2023 +1. Some bug fixes when setting the `save_best_solutions` parameter to `True`. Previously, the best solution for generation `i` was added into the `best_solutions` attribute at generation `i+1`. Now, the `best_solutions` attribute is updated by each best solution at its exact generation. -1. Remove `numpy.int` and `numpy.float` from the list of supported data types. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/151 https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/152 -2. Call the `on_crossover()` callback function even if `crossover_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -3. Call the `on_mutation()` callback function even if `mutation_type` is `None`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 +## PyGAD 2.14.2 -## PyGAD 2.18.3 +Release Date: 27 May 2021 -Release Date: 14 February 2023 +1. Some bug fixes when the `gene_type` parameter is nested. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for opening [a discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342) to report this bug: https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43#discussioncomment-763342 -1. Bug fixes. +[Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) helped a lot in suggesting new features and suggesting enhancements in 2.14.0 to 2.14.2 releases. -## PyGAD 2.19.0 +## PyGAD 2.14.0 -Release Date: 22 February 2023 -1. A new `summary()` method is supported to return a Keras-like summary of the PyGAD lifecycle. -2. A new optional parameter called `fitness_batch_size` is supported to calculate the fitness in batches. If it is assigned the value `1` or `None` (default), then the normal flow is used where the fitness function is called for each individual solution. If the `fitness_batch_size` parameter is assigned a value satisfying this condition `1 < fitness_batch_size <= sol_per_pop`, then the solutions are grouped into batches of size `fitness_batch_size` and the fitness function is called once for each batch. In this case, the fitness function must return a list/tuple/numpy.ndarray with a length equal to the number of solutions passed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/136. -3. The `cloudpickle` library (https://github.com/cloudpipe/cloudpickle) is used instead of the `pickle` library to pickle the `pygad.GA` objects. This solves the issue of having to redefine the functions (e.g. fitness function). The `cloudpickle` library is added as a dependency in the `requirements.txt` file. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/159 -4. Support of assigning methods to these parameters: `fitness_func`, `crossover_type`, `mutation_type`, `parent_selection_type`, `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/138 -5. Validating the output of the parent selection, crossover, and mutation functions. -6. The built-in parent selection operators return the parent's indices as a NumPy array. -7. The outputs of the parent selection, crossover, and mutation operators must be NumPy arrays. -8. Fix an issue when `allow_duplicate_genes=True`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/39 -9. Fix an issue creating scatter plots of the solutions' fitness. -10. Sampling from a `set()` is no longer supported in Python 3.11. Instead, sampling happens from a `list()`. Thanks `Marco Brenna` for pointing to this issue. -11. The lifecycle is updated to reflect that the new population's fitness is calculated at the end of the lifecycle not at the beginning. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154#issuecomment-1438739483 -12. There was an issue when `save_solutions=True` that causes the fitness function to be called for solutions already explored and have their fitness pre-calculated. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160 -13. A new instance attribute named `last_generation_elitism_indices` added to hold the indices of the selected elitism. This attribute helps to re-use the fitness of the elitism instead of calling the fitness function. -14. Fewer calls to the `best_solution()` method which in turns saves some calls to the fitness function. -15. Some updates in the documentation to give more details about the `cal_pop_fitness()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/79#issuecomment-1439605442 +PyGAD 2.14.0 has an issue that is solved in PyGAD 2.14.1. Please consider using 2.14.1 not 2.14.0. -## PyGAD 2.19.1 +Release Date: 19 May 2021 -Release Date: 22 February 2023 +1. [Issue #40](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40) is solved. Now, the `None` value works with the `crossover_type` and `mutation_type` parameters: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/40 +2. The `gene_type` parameter supports accepting a `list/tuple/numpy.ndarray` of numeric data types for the genes. This helps to control the data type of each individual gene. Previously, the `gene_type` can be assigned only to a single data type that is applied for all genes. For more information, check the [More about the `gene_type` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-type-parameter) section. Thanks to [Rainer Engel](https://www.linkedin.com/in/rainer-matthias-engel-5ba47a9) for asking about this feature in [this discussion](https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43): https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/43 +3. A new `bool` attribute named `gene_type_single` is added to the `pygad.GA` class. It is `True` when there is a single data type assigned to the `gene_type` parameter. When the `gene_type` parameter is assigned a `list/tuple/numpy.ndarray`, then `gene_type_single` is set to `False`. +4. The `mutation_by_replacement` flag now has no effect if `gene_space` exists except for the genes with `None` values. For example, for `gene_space=[None, [5, 6]]` the `mutation_by_replacement` flag affects only the first gene which has `None` for its value space. +5. When an element has a value of `None` in the `gene_space` parameter (e.g. `gene_space=[None, [5, 6]]`), then its value will be randomly generated for each solution rather than being generate once for all solutions. Previously, the gene with `None` value in `gene_space` is the same across all solutions +6. Some changes in the documentation according to [issue #32](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/32 -1. Add the [cloudpickle](https://github.com/cloudpipe/cloudpickle) library as a dependency. +## PyGAD 2.13.0 -## PyGAD 2.19.2 +Release Date: 12 March 2021 -Release Date 23 February 2023 +1. A new `bool` parameter called `allow_duplicate_genes` is supported. If `True`, which is the default, then a solution/chromosome may have duplicate gene values. If `False`, then each gene will have a unique value in its solution. Check the [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) section for more details. +2. The `last_generation_fitness` is updated at the end of each generation not at the beginning. This keeps the fitness values of the most up-to-date population assigned to the `last_generation_fitness` parameter. -1. Fix an issue when parallel processing was used where the elitism solutions' fitness values are not re-used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/160#issuecomment-1441718184 +## PyGAD 2.12.0 -## PyGAD 3.0.0 +Release Date: 20 February 2021 -Release Date 8 April 2023 +1. 4 new instance attributes are added to hold temporary results after each generation: `last_generation_fitness` holds the fitness values of the solutions in the last generation, `last_generation_parents` holds the parents selected from the last generation, `last_generation_offspring_crossover` holds the offspring generated after applying the crossover in the last generation, and `last_generation_offspring_mutation` holds the offspring generated after applying the mutation in the last generation. You can access these attributes inside the `on_generation()` method for example. +2. A bug fixed when the `initial_population` parameter is used. The bug occurred due to a mismatch between the data type of the array assigned to `initial_population` and the gene type in the `gene_type` attribute. Assuming that the array assigned to the `initial_population` parameter is `((1, 1), (3, 3), (5, 5), (7, 7))` which has type `int`. When `gene_type` is set to `float`, then the genes will not be float but casted to `int` because the defined array has `int` type. The bug is fixed by forcing the array assigned to `initial_population` to have the data type in the `gene_type` attribute. Check the [issue at GitHub](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27): https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/27 -1. The structure of the library is changed and some methods defined in the `pygad.py` module are moved to the `pygad.utils`, `pygad.helper`, and `pygad.visualize` submodules. - 2. The `pygad.utils.parent_selection` module has a class named `ParentSelection` where all the parent selection operators exist. The `pygad.GA` class extends this class. - 3. The `pygad.utils.crossover` module has a class named `Crossover` where all the crossover operators exist. The `pygad.GA` class extends this class. - 4. The `pygad.utils.mutation` module has a class named `Mutation` where all the mutation operators exist. The `pygad.GA` class extends this class. - 5. The `pygad.helper.unique` module has a class named `Unique` some helper methods exist to solve duplicate genes and make sure every gene is unique. The `pygad.GA` class extends this class. - 6. The `pygad.visualize.plot` module has a class named `Plot` where all the methods that create plots exist. The `pygad.GA` class extends this class. - 7. Support of using the `logging` module to log the outputs to both the console and text file instead of using the `print()` function. This is by assigning the `logging.Logger` to the new `logger` parameter. Check the [Logging Outputs](https://pygad.readthedocs.io/en/latest/logging.html#logging-outputs) for more information. - 8. A new instance attribute called `logger` to save the logger. - 9. The function/method passed to the `fitness_func` parameter accepts a new parameter that refers to the instance of the `pygad.GA` class. Check this for an example: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/163 - 10. Update the documentation to include an example of using functions and methods to calculate the fitness and build callbacks. Check this for more details: [Use Functions and Methods to Build Fitness Function and Callbacks](https://pygad.readthedocs.io/en/latest/custom_functions.html#use-functions-methods-and-classes-to-build-fitness-and-callbacks). https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/92#issuecomment-1443635003 - 11. Validate the value passed to the `initial_population` parameter. - 12. Validate the type and length of the `pop_fitness` parameter of the `best_solution()` method. - 13. Some edits in the documentation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/106 - 14. Fix an issue when building the initial population as (some) genes have their value taken from the mutation range (defined by the parameters `random_mutation_min_val` and `random_mutation_max_val`) instead of using the parameters `init_range_low` and `init_range_high`. - 15. The `summary()` method returns the summary as a single-line string. Just log/print the returned string it to see it properly. - 16. The `callback_generation` parameter is removed. Use the `on_generation` parameter instead. - 17. There was an issue when using the `parallel_processing` parameter with Keras and PyTorch. As Keras/PyTorch are not thread-safe, the `predict()` method gives incorrect and weird results when more than 1 thread is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/145 https://github.com/ahmedfgad/TorchGA/issues/5 https://github.com/ahmedfgad/KerasGA/issues/6. Thanks to this [StackOverflow answer](https://stackoverflow.com/a/75606666/5426539). - 18. Replace `numpy.float` by `float` in the 2 parent selection operators roulette wheel and stochastic universal. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/168 +Thanks to Andrei Rozanski [PhD Bioinformatics Specialist, Department of Tissue Dynamics and Regeneration, Max Planck Institute for Biophysical Chemistry, Germany] for opening my eye to the first change. -## PyGAD 3.0.1 +Thanks to [Marios Giouvanakis](https://www.researchgate.net/profile/Marios-Giouvanakis), a PhD candidate in Electrical & Computer Engineer, [Aristotle University of Thessaloniki (Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης), Greece](https://www.auth.gr/en), for emailing me about the second issue. -Release Date 20 April 2023 +## PyGAD 2.11.0 -1. Fix an issue with passing user-defined function/method for parent selection. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/179 +Release Date: 16 February 2021 -## PyGAD 3.1.0 +1. In the `gene_space` argument, the user can use a dictionary to specify the lower and upper limits of the gene. This dictionary must have only 2 items with keys `low` and `high` to specify the low and high limits of the gene, respectively. This way, PyGAD takes care of not exceeding the value limits of the gene. For a problem with only 2 genes, then using `gene_space=[{'low': 1, 'high': 5}, {'low': 0.2, 'high': 0.81}]` means the accepted values in the first gene start from 1 (inclusive) to 5 (exclusive) while the second one has values between 0.2 (inclusive) and 0.85 (exclusive). For more information, please check the [Limit the Gene Value Range](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter) section of the documentation. +2. The `plot_result()` method returns the figure so that the user can save it. +3. Bug fixes in copying elements from the gene space. +4. For a gene with a set of discrete values (more than 1 value) in the `gene_space` parameter like `[0, 1]`, it was possible that the gene value may not change after mutation. That is if the current value is 0, then the randomly selected value could also be 0. Now, it is verified that the new value is changed. So, if the current value is 0, then the new value after mutation will not be 0 but 1. -Release Date 20 June 2023 +## PyGAD 2.10.2 -1. Fix a bug when the initial population has duplciate genes if a nested gene space is used. -2. The `gene_space` parameter can no longer be assigned a tuple. -3. Fix a bug when the `gene_space` parameter has a member of type `tuple`. -4. A new instance attribute called `gene_space_unpacked` which has the unpacked `gene_space`. It is used to solve duplicates. For infinite ranges in the `gene_space`, they are unpacked to a limited number of values (e.g. 100). -5. Bug fixes when creating the initial population using `gene_space` attribute. -6. When a `dict` is used with the `gene_space` attribute, the new gene value was calculated by summing 2 values: 1) the value sampled from the `dict` 2) a random value returned from the random mutation range defined by the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. This might cause the gene value to exceed the range limit defined in the `gene_space`. To respect the `gene_space` range, this release only returns the value from the `dict` without summing it to a random value. -7. Formatting the strings using f-string instead of the `format()` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 -8. In the `__init__()` of the `pygad.GA` class, the logged error messages are handled using a `try-except` block instead of repeating the `logger.error()` command. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/189 -9. A new class named `CustomLogger` is created in the `pygad.cnn` module to create a default logger using the `logging` module assigned to the `logger` attribute. This class is extended in all other classes in the module. The constructors of these classes have a new parameter named `logger` which defaults to `None`. If no logger is passed, then the default logger in the `CustomLogger` class is used. -10. Except for the `pygad.nn` module, the `print()` function in all other modules are replaced by the `logging` module to log messages. -11. The callback functions/methods `on_fitness()`, `on_parents()`, `on_crossover()`, and `on_mutation()` can return values. These returned values override the corresponding properties. The output of `on_fitness()` overrides the population fitness. The `on_parents()` function/method must return 2 values representing the parents and their indices. The output of `on_crossover()` overrides the crossover offspring. The output of `on_mutation()` overrides the mutation offspring. -12. Fix a bug when adaptive mutation is used while `fitness_batch_size`>1. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195 -13. When `allow_duplicate_genes=False` and a user-defined `gene_space` is used, it sometimes happen that there is no room to solve the duplicates between the 2 genes by simply replacing the value of one gene by another gene. This release tries to solve such duplicates by looking for a third gene that will help in solving the duplicates. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values) for more information. -14. Use probabilities to select parents using the rank parent selection method. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/205 -15. The 2 parameters `random_mutation_min_val` and `random_mutation_max_val` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the mutation range for each individual gene. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 -16. The 2 parameters `init_range_low` and `init_range_high` can accept iterables (list/tuple/numpy.ndarray) with length equal to the number of genes. This enables customizing the initial range for each individual gene when creating the initial population. -17. The `data` parameter in the `predict()` function of the `pygad.kerasga` module can be assigned a data generator. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/115 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 -18. The `predict()` function of the `pygad.kerasga` module accepts 3 optional parameters: 1) `batch_size=None`, `verbose=0`, and `steps=None`. Check documentation of the [Keras Model.predict()](https://keras.io/api/models/model_training_apis) method for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/207 -19. The documentation is updated to explain how mutation works when `gene_space` is used with `int` or `float` data types. Check [this section](https://pygad.readthedocs.io/en/latest/gene_values.html#limit-the-gene-value-range-using-the-gene-space-parameter). https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/198 +Release Date: 15 January 2021 -## PyGAD 3.2.0 +1. A bug fix when `save_best_solutions=True`. Refer to this issue for more information: https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/25 -Release Date 7 September 2023 +## PyGAD 2.10.1 -1. A new module `pygad.utils.nsga2` is created that has the `NSGA2` class that includes the functionalities of NSGA-II. The class has these methods: 1) `get_non_dominated_set()` 2) `non_dominated_sorting()` 3) `crowding_distance()` 4) `sort_solutions_nsga2()`. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. -2. Support of multi-objective optimization using Non-Dominated Sorting Genetic Algorithm II (NSGA-II) using the `NSGA2` class in the `pygad.utils.nsga2` module. Just return a `list`, `tuple`, or `numpy.ndarray` from the fitness function and the library will consider the problem as multi-objective optimization. All the objectives are expected to be maximization. Check [this section](https://pygad.readthedocs.io/en/latest/multi_objective.html#multi-objective-optimization) for an example. -3. The parent selection methods and adaptive mutation are edited to support multi-objective optimization. -4. Two new NSGA-II parent selection methods are supported in the `pygad.utils.parent_selection` module: 1) Tournament selection for NSGA-II 2) NSGA-II selection. -5. The `plot_fitness()` method in the `pygad.plot` module has a new optional parameter named `label` to accept the label of the plots. This is only used for multi-objective problems. Otherwise, it is ignored. It defaults to `None` and accepts a `list`, `tuple`, or `numpy.ndarray`. The labels are used in a legend inside the plot. -6. The default color in the methods of the `pygad.plot` module is changed to the greenish `#64f20c` color. -7. A new instance attribute named `pareto_fronts` added to the `pygad.GA` instances that holds the pareto fronts when solving a multi-objective problem. -8. The `gene_type` accepts a `list`, `tuple`, or `numpy.ndarray` for integer data types given that the precision is set to `None` (e.g. `gene_type=[float, [int, None]]`). -9. In the `cal_pop_fitness()` method, the fitness value is re-used if `save_best_solutions=True` and the solution is found in the `best_solutions` attribute. These parameters also can help re-using the fitness of a solution instead of calling the fitness function: `keep_elitism`, `keep_parents`, and `save_solutions`. -10. The value `99999999999` is replaced by `float('inf')` in the 2 methods `wheel_cumulative_probs()` and `stochastic_universal_selection()` inside the `pygad.utils.parent_selection.ParentSelection` class. -11. The `plot_result()` method in the `pygad.visualize.plot.Plot` class is removed. Instead, please use the `plot_fitness()` if you did not upgrade yet. +Release Date: 10 January 2021 -## PyGAD 3.3.0 +1. In the `gene_space` parameter, any `None` value (regardless of its index or axis), is replaced by a randomly generated number based on the 3 parameters `init_range_low`, `init_range_high`, and `gene_type`. So, the `None` value in `[..., None, ...]` or `[..., [..., None, ...], ...]` are replaced with random values. This gives more freedom in building the space of values for the genes. +2. All the numbers passed to the `gene_space` parameter are casted to the type specified in the `gene_type` parameter. +3. The `numpy.uint` data type is supported for the parameters that accept integer values. +4. In the `pygad.kerasga` module, the `model_weights_as_vector()` function uses the `trainable` attribute of the model's layers to only return the trainable weights in the network. So, only the trainable layers with their `trainable` attribute set to `True` (`trainable=True`), which is the default value, have their weights evolved. All non-trainable layers with the `trainable` attribute set to `False` (`trainable=False`) will not be evolved. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for pointing about that at [GitHub](https://github.com/ahmedfgad/KerasGA/issues/1). -Release Date 29 January 2024 +## PyGAD 2.10.0 -1. Solve bugs when multi-objective optimization is used. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 -2. When the `stop_ciiteria` parameter is used with the `reach` keyword, then multiple numeric values can be passed when solving a multi-objective problem. For example, if a problem has 3 objective functions, then `stop_criteria="reach_10_20_30"` means the GA stops if the fitness of the 3 objectives are at least 10, 20, and 30, respectively. The number values must match the number of objective functions. If a single value found (e.g. `stop_criteria=reach_5`) when solving a multi-objective problem, then it is used across all the objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/238 -3. The `delay_after_gen` parameter is now deprecated and will be removed in a future release. If it is necessary to have a time delay after each generation, then assign a callback function/method to the `on_generation` parameter to pause the evolution. -4. Parallel processing now supports calculating the fitness during adaptive mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201 -5. The population size can be changed during runtime by changing all the parameters that would affect the size of any thing used by the GA. For more information, check the [Change Population Size during Runtime](https://pygad.readthedocs.io/en/latest/generations.html#change-population-size-during-runtime) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/discussions/234 -6. When a dictionary exists in the `gene_space` parameter without a step, then mutation occurs by adding a random value to the gene value. The random vaue is generated based on the 2 parameters `random_mutation_min_val` and `random_mutation_max_val`. For more information, check the [How Mutation Works with the gene_space Parameter?](https://pygad.readthedocs.io/en/latest/gene_values.html#how-mutation-works-with-the-gene-space-parameter) section. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/229 -7. Add `object` as a supported data type for int (GA.supported_int_types) and float (GA.supported_float_types). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/174 -8. Use the `raise` clause instead of the `sys.exit(-1)` to terminate the execution. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/213 -9. Fix a bug when multi-objective optimization is used with batch fitness calculation (e.g. `fitness_batch_size` set to a non-zero number). -10. Fix a bug in the `pygad.py` script when finding the index of the best solution. It does not work properly with multi-objective optimization where `self.best_solutions_fitness` have multiple columns. +Release Date: 03 January 2021 + +1. Support of a new module `pygad.torchga` to train PyTorch models using PyGAD. Check [its documentation](https://pygad.readthedocs.io/en/latest/torchga.html). +2. Support of adaptive mutation where the mutation rate is determined by the fitness value of each solution. Read the [Adaptive Mutation](https://pygad.readthedocs.io/en/latest/adaptive_mutation.html#adaptive-mutation) section for more details. Also, read this paper: [Libelli, S. Marsili, and P. Alba. "Adaptive mutation in genetic algorithms." Soft computing 4.2 (2000): 76-80.](https://www.researchgate.net/publication/225642916_Adaptive_mutation_in_genetic_algorithms) +3. Before the `run()` method completes or exits, the fitness value of the best solution in the current population is appended to the `best_solution_fitness` list attribute. Note that the fitness value of the best solution in the initial population is already saved at the beginning of the list. So, the fitness value of the best solution is saved before the genetic algorithm starts and after it ends. +4. When the parameter `parent_selection_type` is set to `sss` (steady-state selection), then a warning message is printed if the value of the `keep_parents` parameter is set to 0. +5. More validations to the user input parameters. +6. The default value of the `mutation_percent_genes` is set to the string `"default"` rather than the integer 10. This change helps to know whether the user explicitly passed a value to the `mutation_percent_genes` parameter or it is left to its default one. The `"default"` value is later translated into the integer 10. +7. The `mutation_percent_genes` parameter is no longer accepting the value 0. It must be `>0` and `<=100`. +8. The built-in `warnings` module is used to show warning messages rather than just using the `print()` function. +9. A new `bool` parameter called `suppress_warnings` is added to the constructor of the `pygad.GA` class. It allows the user to control whether the warning messages are printed or not. It defaults to `False` which means the messages are printed. +10. A helper method called `adaptive_mutation_population_fitness()` is created to calculate the average fitness value used in adaptive mutation to filter the solutions. +11. The `best_solution()` method accepts a new optional parameter called `pop_fitness`. It accepts a list of the fitness values of the solutions in the population. If `None`, then the `cal_pop_fitness()` method is called to calculate the fitness values of the population. + +## PyGAD 2.9.0 + +Release Date: 06 December 2020 + +1. The fitness values of the initial population are considered in the `best_solutions_fitness` attribute. +2. An optional parameter named `save_best_solutions` is added. It defaults to `False`. When it is `True`, then the best solution after each generation is saved into an attribute named `best_solutions`. If `False`, then no solutions are saved and the `best_solutions` attribute will be empty. +3. Scattered crossover is supported. To use it, assign the `crossover_type` parameter the value `"scattered"`. +4. NumPy arrays are now supported by the `gene_space` parameter. +5. The following parameters (`gene_type`, `crossover_probability`, `mutation_probability`, `delay_after_gen`) can be assigned to a numeric value of any of these data types: `int`, `float`, `numpy.int`, `numpy.int8`, `numpy.int16`, `numpy.int32`, `numpy.int64`, `numpy.float`, `numpy.float16`, `numpy.float32`, or `numpy.float64`. + +## PyGAD 2.8.1 + +Release Date: 3 October 2020 + +1. Bug fix in applying the crossover operation when the `crossover_probability` parameter is used. Thanks to [Eng. Hamada Kassem, Research and Teaching Assistant, Construction Engineering and Management, Faculty of Engineering, Alexandria University, Egypt](https://www.linkedin.com/in/hamadakassem). + +## PyGAD 2.8.0 + +Release Date: 20 September 2020 + +1. Support of a new module named `kerasga` so that the Keras models can be trained by the genetic algorithm using PyGAD. + +## PyGAD 2.7.2 + +Release Date: 14 September 2020 + +1. Bug fix to support building and training regression neural networks with multiple outputs. + +## PyGAD 2.7.1 + +Release Date: 11 September 2020 + +1. A bug fix when the `problem_type` argument is set to `regression`. + +## PyGAD 2.7.0 + +Release Date: 11 September 2020 +1. The `learning_rate` parameter in the `pygad.nn.train()` function defaults to **0.01**. +2. Added support of building neural networks for regression using the new parameter named `problem_type`. It is added as a parameter to both `pygad.nn.train()` and `pygad.nn.predict()` functions. The value of this parameter can be either **classification** or **regression** to define the problem type. It defaults to **classification**. +3. The activation function for a layer can be set to the string `"None"` to refer that there is no activation function at this layer. As a result, the supported values for the activation function are `"sigmoid"`, `"relu"`, `"softmax"`, and `"None"`. + +To build a regression network using the `pygad.nn` module, just do the following: +1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. +2. Set the activation function for the output layer to the string `"None"`. This sets no limits on the range of the outputs as it will be from `-infinity` to `+infinity`. If you are sure that all outputs will be nonnegative values, then use the ReLU function. + +Check the documentation of the `pygad.nn` module for an example that builds a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NumPyANN): https://github.com/ahmedfgad/NumPyANN + +To build and train a regression network using the `pygad.gann` module, do the following: + +1. Set the `problem_type` parameter in the `pygad.nn.train()` and `pygad.nn.predict()` functions to the string `"regression"`. +2. Set the `output_activation` parameter in the constructor of the `pygad.gann.GANN` class to `"None"`. + +Check the documentation of the `pygad.gann` module for an example that builds and trains a neural network for regression. The regression example is also available at [this GitHub project](https://github.com/ahmedfgad/NeuralGenetic): https://github.com/ahmedfgad/NeuralGenetic + +To build a classification network, either ignore the `problem_type` parameter or set it to `"classification"` (default value). In this case, the activation function of the last layer can be set to any type (e.g. softmax). + +## PyGAD 2.6.0 + +Release Date: 6 August 2020 + +1. A bug fix in assigning the value to the `initial_population` parameter. +2. A new parameter named `gene_type` is added to control the gene type. It can be either `int` or `float`. It has an effect only when the parameter `gene_space` is `None`. +3. 7 new parameters that accept callback functions: `on_start`, `on_fitness`, `on_parents`, `on_crossover`, `on_mutation`, `on_generation`, and `on_stop`. + +## PyGAD 2.5.0 + +Release date: 19 July 2020 + +1. 2 new optional parameters added to the constructor of the `pygad.GA` class which are `crossover_probability` and `mutation_probability`. + While applying the crossover operation, each parent has a random value generated between 0.0 and 1.0. If this random value is less than or equal to the value assigned to the `crossover_probability` parameter, then the parent is selected for the crossover operation. + For the mutation operation, a random value between 0.0 and 1.0 is generated for each gene in the solution. If this value is less than or equal to the value assigned to the `mutation_probability`, then this gene is selected for mutation. +2. A new optional parameter named `linewidth` is added to the `plot_result()` method to specify the width of the curve in the plot. It defaults to 3.0. +3. Previously, the indices of the genes selected for mutation was randomly generated once for all solutions within the generation. Currently, the genes' indices are randomly generated for each solution in the population. If the population has 4 solutions, the indices are randomly generated 4 times inside the single generation, 1 time for each solution. +4. Previously, the position of the point(s) for the single-point and two-points crossover was(were) randomly selected once for all solutions within the generation. Currently, the position(s) is(are) randomly selected for each solution in the population. If the population has 4 solutions, the position(s) is(are) randomly generated 4 times inside the single generation, 1 time for each solution. +5. A new optional parameter named `gene_space` as added to the `pygad.GA` class constructor. It is used to specify the possible values for each gene in case the user wants to restrict the gene values. It is useful if the gene space is restricted to a certain range or to discrete values. For more information, check the [More about the `gene_space` Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#more-about-the-gene-space-parameter) section. Thanks to [Prof. Tamer A. Farrag](https://github.com/tfarrag2000) for requesting this useful feature. + +## PyGAD 2.4.0 + +Release date: 5 July 2020 + +1. A new parameter named `delay_after_gen` is added which accepts a non-negative number specifying the time in seconds to wait after a generation completes and before going to the next generation. It defaults to `0.0` which means no delay after the generation. + +2. The passed function to the `callback_generation` parameter of the pygad.GA class constructor can terminate the execution of the genetic algorithm if it returns the string `stop`. This causes the `run()` method to stop. + +One important use case for that feature is to stop the genetic algorithm when a condition is met before passing though all the generations. The user may assigned a value of 100 to the `num_generations` parameter of the pygad.GA class constructor. Assuming that at generation 50, for example, a condition is met and the user wants to stop the execution before waiting the remaining 50 generations. To do that, just make the function passed to the `callback_generation` parameter to return the string `stop`. + +Here is an example of a function to be passed to the `callback_generation` parameter which stops the execution if the fitness value 70 is reached. The value 70 might be the best possible fitness value. After being reached, then there is no need to pass through more generations because no further improvement is possible. ```python - self.best_solution_generation = numpy.where(numpy.array( - self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + def func_generation(ga_instance): + if ga_instance.best_solution()[1] >= 70: + return "stop" ``` -## PyGAD 3.3.1 +## PyGAD 2.3.0 -Release Date 17 February 2024 +Release date: 1 June 2020 -1. After the last generation and before the `run()` method completes, update the 2 instance attributes: 1) `last_generation_parents` 2) `last_generation_parents_indices`. This is to keep the list of parents up-to-date with the latest population fitness `last_generation_fitness`. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/275 -2. 5 methods with names starting with `run_`. Their purpose is to keep the main loop inside the `run()` method clean. Check the [Other Methods](https://pygad.readthedocs.io/en/latest/pygad.html#other-methods) section for more information. - 1. `run_loop_head()`: The code before the loop starts. - 2. `run_select_parents()`: The parent selection-related code. - 3. `run_crossover()`: The crossover-related code. - 4. `run_mutation()`: The mutation-related code. - 5. `run_update_population()`: Update the `population` instance attribute after completing the processes of crossover and mutation. +1. A new module named `pygad.cnn` is supported for building convolutional neural networks. +2. A new module named `pygad.gacnn` is supported for training convolutional neural networks using the genetic algorithm. +3. The `pygad.plot_result()` method has 3 optional parameters named `title`, `xlabel`, and `ylabel` to customize the plot title, x-axis label, and y-axis label, respectively. +4. The `pygad.nn` module supports the softmax activation function. +5. The name of the `pygad.nn.predict_outputs()` function is changed to `pygad.nn.predict()`. +6. The name of the `pygad.nn.train_network()` function is changed to `pygad.nn.train()`. +## PyGAD 2.2.2 -## PyGAD 3.4.0 +Release Date: 18 May 2020 +1. The initial value of the `generations_completed` attribute of instances from the pygad.GA class is `0` rather than `None`. -Release Date 07 January 2025 +2. An optional bool parameter named `mutation_by_replacement` is added to the constructor of the pygad.GA class. It works only when the selected type of mutation is random (`mutation_type="random"`). In this case, setting `mutation_by_replacement=True` means replace the gene by the randomly generated value. If `False`, then it has no effect and random mutation works by adding the random value to the gene. This parameter should be used when the gene falls within a fixed range and its value must not go out of this range. Here are some examples: -1. The `delay_after_gen` parameter is removed from the `pygad.GA` class constructor. As a result, it is no longer an attribute of the `pygad.GA` class instances. To add a delay after each generation, apply it inside the `on_generation` callback. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/283 -2. In the `single_point_crossover()` method of the `pygad.utils.crossover.Crossover` class, all the random crossover points are returned before the `for` loop. This is by calling the `numpy.random.randint()` function only once before the loop to generate all the K points (where K is the offspring size). This is compared to calling the `numpy.random.randint()` function inside the `for` loop K times, once for each individual offspring. -3. Bug fix in the `examples/example_custom_operators.py` script. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/285 -4. While making prediction using the `pygad.torchga.predict()` function, no gradients are calculated. -5. The `gene_type` parameter of the `pygad.helper.unique.Unique.unique_int_gene_from_range()` method accepts the type of the current gene only instead of the full gene_type list. -6. Created a new method called `unique_float_gene_from_range()` inside the `pygad.helper.unique.Unique` class to find a unique floating-point number from a range. -7. Fix a bug in the `pygad.helper.unique.Unique.unique_gene_by_space()` method to return the numeric value only instead of a NumPy array. -8. Refactoring the `pygad/helper/unique.py` script to remove duplicate codes and reformatting the docstrings. -9. The `plot_pareto_front_curve()` method added to the pygad.visualize.plot.Plot class to visualize the Pareto front for multi-objective problems. It only supports 2 objectives. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/279 -11. Fix a bug converting a nested NumPy array to a nested list. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/300 -12. The `Matplotlib` library is only imported when a method inside the `pygad/visualize/plot.py` script is used. This is more efficient than using `import matplotlib.pyplot` at the module level as this causes it to be imported when `pygad` is imported even when it is not needed. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/292 -13. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter (e.g. `stop_criteria=["saturate_10", "reach_-0.5"]`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/296 -14. Make sure `self.best_solutions` is a list of lists inside the `cal_pop_fitness` method. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/293 -15. Fix a bug where the `cal_pop_fitness()` method was using the `previous_generation_fitness` attribute to return the parents fitness. This instance attribute was not using the fitness of the latest population, instead the fitness of the population before the last one. The issue is solved by updating the `previous_generation_fitness` attribute to the latest population fitness before the GA completes. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/291 + Assume there is a gene with the value 0.5. -## PyGAD 3.5.0 + If `mutation_type="random"` and `mutation_by_replacement=False`, then the generated random value (e.g. 0.1) will be added to the gene value. The new gene value is **0.5+0.1=0.6**. -Release Date 08 July 2025 + If `mutation_type="random"` and `mutation_by_replacement=True`, then the generated random value (e.g. 0.1) will replace the gene value. The new gene value is **0.1**. -1. Fix a bug when minus sign (-) is used inside the `stop_criteria` parameter for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/323 -2. Fix a bug when the `stop_criteria` parameter is passed as an iterable (e.g. list) for multi-objective problems (e.g. `['reach_50_60', 'reach_20, 40']`). https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/314 -3. Call the `get_matplotlib()` function from the `plot_genes()` method inside the `pygad.visualize.plot.Plot` class to import the matplotlib library. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/315 -4. Create a new helper method called `select_unique_value()` inside the `pygad/helper/unique.py` script to select a unique gene from an array of values. -5. Create a new helper method called `get_random_mutation_range()` inside the `pygad/utils/mutation.py` script that returns the random mutation range (min and max) for a single gene by its index. -6. Create a new helper method called `change_random_mutation_value_dtype` inside the `pygad/utils/mutation.py` script that changes the data type of the value used to apply random mutation. -7. Create a new helper method called `round_random_mutation_value()` inside the `pygad/utils/mutation.py` script that rounds the value used to apply random mutation. -8. Create the `pygad/helper/misc.py` script with a class called `Helper` that has the following helper methods: - 1. `change_population_dtype_and_round()`: For each gene in the population, round the gene value and change the data type. - 2. `change_gene_dtype_and_round()`: Round the change the data type of a single gene. - 3. `mutation_change_gene_dtype_and_round()`: Decides whether mutation is done by replacement or not. Then it rounds and change the data type of the new gene value. - 4. `validate_gene_constraint_callable_output()`: Validates the output of the user-defined callable/function that checks whether the gene constraint defined in the `gene_constraint` parameter is satisfied or not. - 5. `get_gene_dtype()`: Returns the gene data type from the `gene_type` instance attribute. - 6. `get_random_mutation_range()`: Returns the random mutation range using the `random_mutation_min_val` and `random_mutation_min_val` instance attributes. - 7. `get_initial_population_range()`: Returns the initial population values range using the `init_range_low` and `init_range_high` instance attributes. - 8. `generate_gene_value_from_space()`: Generates/selects a value for a gene using the `gene_space` instance attribute. - 9. `generate_gene_value_randomly()`: Generates a random value for the gene. Only used if `gene_space` is `None`. - 10. `generate_gene_value()`: Generates a value for the gene. It checks whether `gene_space` is `None` and calls either `generate_gene_value_randomly()` or `generate_gene_value_from_space()`. - 11. `filter_gene_values_by_constraint()`: Receives a list of values for a gene. Then it filters such values using the gene constraint. - 12. `get_valid_gene_constraint_values()`: Selects one valid gene value that satisfy the gene constraint. It simply calls `generate_gene_value()` to generate some gene values then it filters such values using `filter_gene_values_by_constraint()`. -9. Create a new helper method called `mutation_process_random_value()` inside the `pygad/utils/mutation.py` script that generates constrained random values for mutation. It calls either `generate_gene_value()` or `get_valid_gene_constraint_values()` based on whether the `gene_constraint` parameter is used or not. -10. A new parameter called `gene_constraint` is added. It accepts a list of callables (i.e. functions) acting as constraints for the gene values. Before selecting a value for a gene, the callable is called to ensure the candidate value is valid. Check the [Gene Constraint](https://pygad.readthedocs.io/en/latest/gene_values.html#gene-constraint) section for more information. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/119 -11. A new parameter called `sample_size` is added. To select a gene value that respects a constraint, this variable defines the size of the sample from which a value is selected randomly. Useful if either `allow_duplicate_genes` or `gene_constraint` is used. An instance attribute of the same name is created in the instances of the `pygad.GA` class. Check the [sample_size Parameter](https://pygad.readthedocs.io/en/latest/gene_values.html#sample-size-parameter) section for more information. -12. Use the `sample_size` parameter instead of `num_trials` in the methods `solve_duplicate_genes_randomly()` and `unique_float_gene_from_range()` inside the `pygad/helper/unique.py` script. It is the maximum number of values to generate as the search space when looking for a unique float value out of a range. -13. Fixed a bug in population initialization when `allow_duplicate_genes=False`. Previously, gene values were checked for duplicates before rounding, which could allow near-duplicates like 7.61 and 7.62 to pass. After rounding (e.g., both becoming 7.6), this resulted in unintended duplicates. The fix ensures gene values are now rounded before duplicate checks, preventing such cases. -14. More tests are created. -15. More examples are created. -16. Edited the `sort_solutions_nsga2()` method in the `pygad/utils/nsga2.py` script to accept an optional parameter called `find_best_solution` when calling this method just to find the best solution. -17. Fixed a bug while applying the non-dominated sorting in the `get_non_dominated_set()` method inside the `pygad/utils/nsga2.py` script. It was swapping the non-dominated and dominated sets. In other words, it used the non-dominated set as if it is the dominated set and vice versa. All the calls to this method were edited accordingly. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/320. -18. Fix a bug retrieving in the `best_solution()` method when retrieving the best solution for multi-objective problems. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/331 +3. `None` value could be assigned to the `mutation_type` and `crossover_type` parameters of the pygad.GA class constructor. When `None`, this means the step is bypassed and has no action. -## PyGAD 3.6.0 +## PyGAD 2.2.1 -Release Date April 8, 2026 +Release Date: 17 May 2020 -1. Support passing a class to the fitness, crossover, and mutation. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/342 -2. A new class called `Validation` is created in the new `pygad/utils/validation.py` script. It has a method called `validate_parameters()` to validate all the parameters passed while instantiating the `pygad.GA` class. -3. Refactoring the `pygad.py` script by moving a lot of functions and methods to other classes in other scripts. - 4. The `summary()` method was moved to `Helper` class in the `pygad/helper/misc.py` script. - 5. The validation code in the `__init__()` method of the `pygad.GA` class is moved to the new `validate_parameters()` method in the new `Validation` class in the new `pygad/utils/validation.py` script. Moreover, the `validate_multi_stop_criteria()` method is also moved to the same class. - 6. The GA main workflow is moved into the new `GAEngine` class in the new `pygad/utils/engine.py` script. Specifically, these methods are moved from the `pygad.GA` class to the new `GAEngine` class: - 1. `run()` - 1. `run_loop_head()` - 2. `run_select_parents()` - 3. `run_crossover()` - 4. `run_mutation()` - 5. `run_update_population()` - 2. `initialize_population()` - 3. `cal_pop_fitness()` - 4. `best_solution()` - 5. `round_genes()` -7. The `pygad.GA` class now extends the two new classes `utils.validation.Validation` and `utils.engine.GAEngine`. -8. The version of the `pygad.utils` submodule is upgraded from `1.3.0` to `1.4.0`. -9. The version of the `pygad.helper` submodule is upgraded from `1.2.0` to `1.3.0`. -10. The version of the `pygad.visualize` submodule is upgraded from `1.1.0` to `1.1.1`. -11. The version of the `pygad.nn` submodule is upgraded from `1.2.1` to `1.2.2`. -12. The version of the `pygad.cnn` submodule is upgraded from `1.1.0` to `1.1.1`. -13. The version of the `pygad.torchga` submodule is upgraded from `1.4.0` to `1.4.1`. -14. The version of the `pygad.kerasga` submodule is upgraded from `1.3.0` to `1.3.1`. -15. Update the elitism after the evolution ends to fix issue where the best solution returned by the `best_solution()` method is not correct. https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/337 -16. Fix a bug in calling the `numpy.reshape()` function. The parameter `newshape` is removed since it is no longer supported started from NumPy `2.4.0`. https://numpy.org/doc/stable/release/2.4.0-notes.html#removed-newshape-parameter-from-numpy-reshape -17. A minor change in the documentation is made to replace the `newshape` parameter when calling `numpy.reshape()`. -18. Fix a bug in the `visualize/plot.py` script that causes a warning to be given when the plot leged is used with single-objective problems. -19. A new method called `initialize_parents_array()` is added to the `Helper` class in the `pygad/helper/misc.py` script. It is usually called from the methods in the `ParentSelection` class in the `pygad/utils/parent_selection.py` script to initialize the parents array. -20. Add more tests about: - 1. Operators (crossover, mutation, and parent selection). - 2. The `best_solution()` method. - 3. Parallel processing. - 4. The `GANN` module. - 5. The plots created by the `visualize`. -21. Instead of using repeated code for converting the data type and rounding the genes during crossover and mutation, the `change_gene_dtype_and_round()` method is called from the `pygad.helper.misc.Helper` class. -22. Fix some documentation issues. https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/336 -23. Update the documentation to reflect the recent additions and changes to the library structure. +1. Adding 2 extra modules (pygad.nn and pygad.gann) for building and training neural networks with the genetic algorithm. -## PyGAD 3.7.0 +## PyGAD 2.1.0 -Release Date June 5, 2026 +Release Date: 14 May 2020 -Watch the release video on [YouTube](https://youtu.be/EXMy37crL7c). +1. The `best_solution()` method in the **pygad.GA** class returns a new output representing the index of the best solution within the population. Now, it returns a total of 3 outputs and their order is: best solution, best solution fitness, and best solution index. Here is an example: +```python +solution, solution_fitness, solution_idx = ga_instance.best_solution() +print("Parameters of the best solution :", solution) +print("Fitness value of the best solution :", solution_fitness, "\n") +print("Index of the best solution :", solution_idx, "\n") +``` -```{raw} html -<iframe width="560" height="315" src="https://www.youtube.com/embed/EXMy37crL7c" title="PyGAD 3.7.0 Release" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> +2. A new attribute named `best_solution_generation` is added to the instances of the **pygad.GA** class. it holds the generation number at which the best solution is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. +Example: +```python +print("Best solution reached after {best_solution_generation} generations.".format(best_solution_generation=ga_instance.best_solution_generation)) ``` -1. Validation logic is applied to validate the `num_generations` parameter. -2. The `num_generations` parameter must be assigned a positive integer. Previously, any number (positive/negative, int/float) was accepted. -3. A new script called `activation.py` is added into the `pygad.helper` module to include the activation function used by the `cnn` and `nn` modules. -4. In the `pygad.parent_selection.ParentSelection` class, the `stochastic_universal_selection()` method now calls the `wheel_cumulative_probs()` method instead of repeating the code of calculating the probabilities used for parent selection. -5. The `wheel_cumulative_probs()` method in the `pygad.parent_selection.ParentSelection` class is refactored to reduce its computational time. -6. Use `numpy.where()` to decide which the source parent of each gene within the `uniform_crossover()` method in the `utils/crossover.py` script. The same was already applied to the `scattered_crossover()` method. -7. Add tests for the following modules: - 1. `nn` - 2. `cnn` - 3. `gacnn` - 4. `kerasga` - 5. `torchga` -8. Fix a bug in the `visualize/plot.py` script where the `labels` parameter of `boxplot()` has been renamed `tick_labels` in Matplotlib. -9. Fix a bug where the `best_solutions_fitness` list (instance attribute to `pygad.GA`) has the fitness of the last generation duplicated when an early stop happens inside the `on_generation()` callback. This made its size incompatible with the `best_solutions` list. -10. The documentation is refactored to solve many language issues and the Furo theme is applied. For easy navigation, the index is reformatted to only show the main sections. At each page, its index is shown at the right side. A new theme toggle button to change theme between light and dark. -11. Support of multi-objective optimization using the Non-Dominated Sorting Genetic Algorithm III (NSGA-III). NSGA-III replaces the crowding distance of NSGA-II with niching against a structured grid of reference points, so it scales better to problems with 4 or more objectives. The new `NSGA3` class lives in the new `pygad/utils/nsga3.py` script and is mixed into the `pygad.GA` class the same way `NSGA2` is. -12. Two new parent selection methods are added to support NSGA-III: 1) `nsga3_selection()` for plain NSGA-III selection, and 2) `tournament_selection_nsga3()` for the tournament variant. Use them by setting `parent_selection_type` to `'nsga3'` or `'tournament_nsga3'`. -13. A new parameter `nsga3_num_divisions` is added to the `pygad.GA` constructor. It is required when `parent_selection_type` is `'nsga3'` or `'tournament_nsga3'` and sets the number of divisions per objective axis used to build the structured reference points (the `p` parameter from Deb & Jain 2014). The total number of reference points is `C(M + p - 1, p)` where `M` is the number of objectives. -14. When `sol_per_pop` is smaller than the number of NSGA-III reference points, PyGAD raises a warning and grows the population to match before the generational loop starts. -15. A new crossover operator: Simulated Binary Crossover (SBX). Use it by setting `crossover_type='sbx'`. The shape of the spread is controlled by the new `sbx_crossover_eta` parameter (default 30). -16. A new mutation operator: polynomial mutation. Use it by setting `mutation_type='polynomial'`. The size of the change is controlled by the new `polynomial_mutation_eta` parameter (default 20). -17. Two new stop criteria: `time_<seconds>` stops the run when the time inside `run()` is at least the given number of seconds; `evaluations_<N>` stops the run when the number of fitness function calls reaches the given count. New instance attribute `num_fitness_evaluations` counts the calls. -18. A new submodule `pygad.utils.quality_indicators` with four functions to measure the quality of a Pareto front: `hypervolume`, `inverted_generational_distance`, `generational_distance`, and `spacing`. -19. A new submodule `pygad.benchmarks` with built-in benchmark problems. `pygad.benchmarks.classic` has Sphere, Rastrigin, Rosenbrock, Griewank, Schwefel, Ackley, and Himmelblau. `pygad.benchmarks.zdt` has the ZDT family (ZDT1, ZDT2, ZDT3, ZDT4, ZDT6). `pygad.benchmarks.dtlz` has DTLZ1, DTLZ2, DTLZ3, and DTLZ4. `pygad.benchmarks.knapsack` has the 0/1 Knapsack problem. Each class is callable with the PyGAD fitness signature and returns negated values (for the minimization-style problems) so PyGAD can maximize toward the original minimum. -20. Update the documentation to reflect the recent additions and changes to the library structure. -21. A new benchmark `pygad.benchmarks.tsp` with a `TSP` class for the Travelling Salesman Problem. The class accepts either 2D `coordinates` or a precomputed `distance_matrix`, exposes `gene_space`, `gene_type`, and `allow_duplicate_genes` for the permutation encoding, and returns the negative tour length as the fitness. -22. Two new example folders under `/examples`: `examples/benchmarks/` has one runnable example per benchmark (classic, ZDT, DTLZ, knapsack, and TSP), and `examples/quality_indicators/` has one runnable example per quality indicator (hypervolume, IGD, GD, and spacing). -23. `plot_pareto_front_curve()` now also supports 3 objectives (3D scatter). M >= 4 still raises and points to the new high-dimensional plots. -24. Seven new plot methods on `pygad.GA`. The first three work on the final population (no extra flag needed): `plot_pareto_front_pcp()` (parallel coordinates, any M >= 2), `plot_pareto_front_scatter_matrix()` (M-by-M pairwise scatter, best for M >= 4), and `plot_pareto_front_heatmap()` (solutions-by-objectives heatmap). The other four require `save_solutions=True`: `plot_fitness_band()` (per-generation min / mean / max with a shaded band), `plot_non_dominated_hypervolume()` (hypervolume of the non-dominated set per generation), `plot_population_diversity()` (mean pairwise distance per generation), and `plot_pareto_front_evolution()` (non-dominated set overlaid every k generations). -25. Fix a latent divide-by-zero in `NSGA3.nsga3_normalize_fitness()`. The safeguard for near-zero denominators used to collapse to `0` for tiny negative values (the realistic case under PyGAD-max), which silently produced wrong normalized values. The safeguard now keeps the negative sign. -26. Refactor the NSGA classes to keep each script focused. A new module `pygad/utils/nsga.py` hosts the `NSGA` mixin with `non_dominated_sorting()` and `get_non_dominated_set()`, which are shared between NSGA-II and NSGA-III. `nsga2.py` now only carries NSGA-II specific code (`crowding_distance`, `sort_solutions_nsga2`). `nsga3.py` now only carries the NSGA-III algorithm primitives. The `nsga3_selection()` and `tournament_selection_nsga3()` methods have moved to `pygad/utils/parent_selection.py` next to their NSGA-II counterparts. The engine-time helpers `_bootstrap_nsga3_reference_points()`, `_nsga3_grow_population()`, `_nsga3_generate_extra_random_solutions()`, and `_nsga3_generate_single_random_gene()` now live in `pygad/utils/engine.py`. -27. Rename NSGA-III novel names to start with `nsga3_` so the algorithm-specific surface is easy to spot. Algorithm primitives become `nsga3_generate_reference_points`, `nsga3_compute_ideal_point`, `nsga3_find_extreme_points`, `nsga3_compute_intercepts`, `nsga3_normalize_fitness`, `nsga3_associate_to_reference_points`, and `nsga3_niching_select`. Module-level helpers gain the same prefix (`_nsga3_pick_target_reference_point`, `_nsga3_pick_candidate_at_reference`, `_nsga3_enumerate_compositions`, `_nsga3_validate_multi_objective_fitness`, `_nsga3_accumulate_fronts`). The constants are renamed `NSGA3_ASF_EPSILON` and `NSGA3_INTERCEPT_NEAR_ZERO`. Names that already had NSGA-II parallels (`tournament_selection_nsga3`, `pareto_fronts`, `non_dominated_sorting`) keep their original spelling. -28. Spell every name and docstring in American English (`normalize`, `maximize`, `behavior`, `color`, `optimization`, ...) so the library stays consistent. -29. Expand abbreviated names introduced by the NSGA-III refactor: `fl_indices` to `critical_front_indices`, `fl_assoc` to `critical_front_associations`, `fl_dist` to `critical_front_distances`, `st_indices` to `selection_pool_indices`, `st_fitness` to `selection_pool_fitness`, `accepted_assoc` to `accepted_associations`, `K` to `num_to_select` (in `nsga3_niching_select`). -30. The NSGA-III population auto-growth path now respects every initial-population rule: `init_range_low`/`init_range_high`, `gene_space`, `gene_type` (single dtype or nested per-gene `[type, precision]`), `gene_constraint`, and `allow_duplicate_genes=False`. Previously, only the gene-space / init-range sampling step was applied; gene constraints and duplicate resolution were skipped, which could leave the grown rows in an invalid state. -31. A new `Report` mixin in `pygad/utils/report.py` adds `ga_instance.generate_report(filename, ...)` to build a PDF report of the run. The report bundles a configuration table, a run-summary table, the best solution, and every applicable plot (auto-selected based on the run's properties: SOO vs MOO, number of objectives, `save_solutions`, `save_best_solutions`). The report uses `reportlab` and `matplotlib`, both available through the new optional dependency extra `pip install pygad[report]`. -32. A new example `examples/example_generate_report.py` shows how to build a PDF report after running a multi-objective GA. -33. The `pygad.md`, `releases.md`, `visualize.md`, and `utils.md` documentation pages were updated to reflect the new module layout, the renamed methods, the new `generate_report()` entry point, and the new NSGA-III instance attributes (`nsga3_num_divisions`, `nsga3_reference_points`). The "Other Instance Attributes & Methods" section in `pygad.md` is now grouped by area (Lifecycle, Population, Fitness, Parent Selection, NSGA-II, NSGA-III, Crossover, Mutation, Elitism, Gene Constraints, Saving) so each method or attribute appears under its topic. -34. Fix issue https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/351 by updating the documentation to clarify what the `solution` has. -35. Version changed in the following modules: - 1. A new submodule `pygad.benchmarks` is added with the version `1.0.0`. - 2. The version of the `pygad.utils` submodule is upgraded from `1.4.0` to `1.5.0`. - 3. The version of the `pygad.helper` submodule is upgraded from `1.3.0` to `1.4.0`. - 4. The version of the `pygad.visualize` submodule is upgraded from `1.1.1` to `1.2.0`. - 5. The version of the `pygad.nn` submodule is upgraded from `1.2.2` to `1.2.3`. - 6. The version of the `pygad.cnn` submodule is upgraded from `1.1.1` to `1.1.2`. - 7. The version of the `pygad.kerasga` submodule is upgraded from `1.3.1` to `1.3.2`. - 8. The version of the `pygad.torchga` submodule is upgraded from `1.4.1` to `1.4.2`. - 9. The version of the `pygad.gann` submodule is upgraded from `1.0.0` to `1.0.1`. - 10. The version of the `pygad.gacnn` submodule is upgraded from `1.0.0` to `1.0.1`. -36. The PDF report built by `generate_report()` now shows the PyGAD logo on its title page. The logo image ships with the package, so no network access is needed. If the image file is missing, the report is built without it. -37. Two private helper functions are added to the `pygad/utils/report.py` script for the logo. `_pdf_report_read_logo_bytes()` reads the bundled logo file and returns its bytes, or `None` if the file is missing. `_pdf_report_build_logo_image()` builds the image that is placed on the title page, or returns `None` so the report still builds without the logo. -38. The private helper functions in the `pygad/utils/report.py` script are renamed to start with the `_pdf_report_` prefix so their purpose is clear from the name. For example, `_build_title_section()` becomes `_pdf_report_build_title_section()` and `_render_plot_to_png()` becomes `_pdf_report_render_plot_to_png()`. +3. The `best_solution_fitness` attribute is renamed to `best_solutions_fitness` (plural solution). +4. Mutation is applied independently for the genes. -## Unreleased +## PyGAD 2.0.0 -These changes are available in the repository after PyGAD 3.7.0 and will be included in a future release. +Release Date: 13 May 2020 -1. Two-point crossover selects two distinct random cut points from `0` through `num_genes`, with every pair equally likely. The segment length can vary from one to all genes, and the single-gene case no longer raises a slicing error. See [PR #371](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/371). -2. Swap mutation can select any pair of distinct gene positions, matching its documentation. Single-gene offspring are returned unchanged. See [PR #375](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/375). -3. SBX crossover selects the lower or upper child with equal probability, removing the bias toward lower gene values. See [PR #376](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/376). -4. Random and adaptive mutation can change permutations when `allow_duplicate_genes=False` leaves no unused replacement value. The fallback swaps compatible genes while preserving their numeric values, destination types, gene spaces, uniqueness, and constraints. Swapped genes are tracked within each mutation pass to prevent immediately undoing a swap. See [PR #373](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/373). -5. Regression tests cover single-gene behavior, cut-point and swap-pair coverage, SBX symmetry and bounds, mixed gene types, constrained permutations, both adaptive mutation controls, and reproducibility. The `pygad.utils` submodule version is `1.5.2`. -6. Parallel fitness evaluation now reuses its executor within each `run()` call, including adaptive offspring evaluation. Workers are shut down after normal completion, early stopping, and exceptions. Executors are excluded from checkpoints and worker snapshots. -7. Serial, thread, and process modes use the same fitness-cache rules and result validation. Adaptive mutation evaluates the actual offspring, supplies `None` for their not-yet-assigned population indices, preserves fractional fitness, and uses the correct retained-parent or elite fitness. These evaluations are included in `num_fitness_evaluations` and the `evaluations_<N>` stop criterion. See issues [#195](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/195) and [#201](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/201). -8. Process workers use cloudpickle payloads for callable and GA state, supporting local functions and continuation after loading a checkpoint. Current state is sent for each evaluation round; grouped tasks reduce repeated state transfers. No new dependency is required. See issues [#121](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/121) and [#250](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/250). -9. `pygad.kerasga.predict()` synchronizes calls sharing a model across threads and restores the model's original weights even after prediction errors. See issue [#150](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/150). -10. Stochastic universal selection uses the requested `num_parents` for pointer spacing, so direct calls can select a different number of parents from `num_parents_mating`. Regression tests cover smaller and larger counts, equal-fitness sampling, objective vectors, and mixed gene types. See issue [#85](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/85). -11. Scramble mutation shuffles the selected segment's values directly, removing the separate index shuffle and reversal. Every permutation of that segment is possible; its values, array dtype, and unselected genes are preserved. Seeded results can differ from earlier versions. See issue [#76](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/76). -12. New examples explain replacing a loaded fitness function, starting fresh when the objective changes, and handling short final fitness batches. The lifecycle guide also explains progress reporting and the order of fitness evaluation and callbacks. See issues [#263](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/263), [#217](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/217), and [#154](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/154). -13. Rank selection assigns descending selection weights to the best-to-worst sorted solutions, correcting a bias that gave worse solutions higher selection probabilities. Regression tests verify exact probabilities, original population indices, negative fitness, objective vectors, crowding distance, ties, and parent copies. See issue [#120](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/120). Seeded rank-selection results can differ from earlier versions. +1. The fitness function accepts a new argument named `sol_idx` representing the index of the solution within the population. +2. A new parameter to the **pygad.GA** class constructor named `initial_population` is supported to allow the user to use a custom initial population to be used by the genetic algorithm. If not None, then the passed population will be used. If `None`, then the genetic algorithm will create the initial population using the `sol_per_pop` and `num_genes` parameters. +3. The parameters `sol_per_pop` and `num_genes` are optional and set to `None` by default. +4. A new parameter named `callback_generation` is introduced in the **pygad.GA** class constructor. It accepts a function with a single parameter representing the **pygad.GA** class instance. This function is called after each generation. This helps the user to do post-processing or debugging operations after each generation. -The operators consume different random draws from earlier versions. Runs with the same `random_seed` remain reproducible within the same version and environment, but can produce different results from earlier versions. +## PyGAD 1.0.20 + +Release Date: 4 May 2020 + +1. The **pygad.GA** attributes are moved from the class scope to the instance scope. +2. Raising an exception for incorrect values of the passed parameters. +3. Two new parameters are added to the **pygad.GA** class constructor (`init_range_low` and `init_range_high`) allowing the user to customize the range from which the genes values in the initial population are selected. +4. The code object `__code__` of the passed fitness function is checked to ensure it has the right number of parameters. + +## PyGAD 1.0.17 + +Release Date: 15 April 2020 + +1. The **pygad.GA** class accepts a new argument named `fitness_func` which accepts a function to be used for calculating the fitness values for the solutions. This allows the project to be customized to any problem by building the right fitness function. diff --git a/docs/source/steps_to_use.md b/docs/source/steps_to_use.md index 1c3bbfc2..fe1ebc7a 100644 --- a/docs/source/steps_to_use.md +++ b/docs/source/steps_to_use.md @@ -200,3 +200,30 @@ After the instance is loaded, you can use it to run any method or access any pro ```python print(loaded_ga_instance.best_solution()) ``` + +<!-- python-examples +example.py +--> + +**Python example** + +**[First GA run](../../examples/example.py)** + +Optimize a linear equation, inspect the best solution, plot fitness, and save and reload the GA. + +`examples/example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga.md b/docs/source/torchga.md index 25463621..25dec0c5 100644 --- a/docs/source/torchga.md +++ b/docs/source/torchga.md @@ -110,31 +110,16 @@ It returns the predictions for the data samples. This section gives the complete code of some examples that build and train a PyTorch model using PyGAD. Each subsection builds a different network. -::::{grid} 1 2 2 2 -:gutter: 3 +<!-- navigation-grid: 1 2 2 2 --> -:::{grid-item-card} Example 1: Regression Example -:link: torchga_regression -:link-type: doc -::: - -:::{grid-item-card} Example 2: XOR Binary Classification -:link: torchga_xor -:link-type: doc -::: - -:::{grid-item-card} Example 3: Image Multi-Class Classification (Dense Layers) -:link: torchga_image_dense -:link-type: doc -::: - -:::{grid-item-card} Example 4: Image Multi-Class Classification (Conv Layers) -:link: torchga_image_conv -:link-type: doc -::: +- [Example 1: Regression Example](torchga_regression.md) +- [Example 2: XOR Binary Classification](torchga_xor.md) +- [Example 3: Image Multi-Class Classification (Dense Layers)](torchga_image_dense.md) +- [Example 4: Image Multi-Class Classification (Conv Layers)](torchga_image_conv.md) -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: @@ -143,3 +128,4 @@ torchga_xor torchga_image_dense torchga_image_conv ::: +--> diff --git a/docs/source/torchga_image_conv.md b/docs/source/torchga_image_conv.md index 0e3d2385..2fa31d54 100644 --- a/docs/source/torchga_image_conv.md +++ b/docs/source/torchga_image_conv.md @@ -163,3 +163,33 @@ Index of the best solution : 0 Crossentropy : 0.7686678 Accuracy : 0.975 ``` + +<!-- python-examples +TorchGA/image_classification_CNN.py +--> + +**Python example** + +**[Convolutional image classifier](../../examples/TorchGA/image_classification_CNN.py)** + +Train an image classifier with the genetic algorithm. + +`examples/TorchGA/image_classification_CNN.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +**Data:** examples/data/dataset_inputs.npy and examples/data/dataset_outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/TorchGA/ so the relative data paths resolve: + +```console +cd examples/TorchGA +python image_classification_CNN.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_image_dense.md b/docs/source/torchga_image_dense.md index 462bea3d..e0c90dab 100644 --- a/docs/source/torchga_image_dense.md +++ b/docs/source/torchga_image_dense.md @@ -124,3 +124,33 @@ Index of the best solution : 0 Crossentropy : 0.74366045 Accuracy : 1.0 ``` + +<!-- python-examples +TorchGA/image_classification_Dense.py +--> + +**Python example** + +**[Dense image classifier](../../examples/TorchGA/image_classification_Dense.py)** + +Train an image classifier with the genetic algorithm. + +`examples/TorchGA/image_classification_Dense.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, TensorFlow/Keras, PyTorch + +**Data:** examples/data/dataset_features.npy and examples/data/outputs.npy. See the [dataset setup instructions](../../examples/data/README.md). + +From the repository root, change to examples/TorchGA/ so the relative data paths resolve: + +```console +cd examples/TorchGA +python image_classification_Dense.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_regression.md b/docs/source/torchga_regression.md index a4ff3291..926581cf 100644 --- a/docs/source/torchga_regression.md +++ b/docs/source/torchga_regression.md @@ -231,3 +231,30 @@ print("Absolute Error : ", abs_error.detach().numpy()) ``` Absolute Error : 0.006876422 ``` + +<!-- python-examples +TorchGA/regression_example.py +--> + +**Python example** + +**[Regression](../../examples/TorchGA/regression_example.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/TorchGA/regression_example.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/TorchGA/regression_example.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/torchga_xor.md b/docs/source/torchga_xor.md index e5b777f5..ccc83a34 100644 --- a/docs/source/torchga_xor.md +++ b/docs/source/torchga_xor.md @@ -150,3 +150,30 @@ Binary Crossentropy : 0.0 Accuracy : 1.0 ``` + +<!-- python-examples +TorchGA/XOR_classification.py +--> + +**Python example** + +**[XOR classification](../../examples/TorchGA/XOR_classification.py)** + +Optimize neural-network weights with the genetic algorithm. + +`examples/TorchGA/XOR_classification.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib, PyTorch + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/TorchGA/XOR_classification.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/user_defined_operators.md b/docs/source/user_defined_operators.md index 61b6c5e7..75ef1119 100644 --- a/docs/source/user_defined_operators.md +++ b/docs/source/user_defined_operators.md @@ -10,6 +10,14 @@ This way, the user can only use the built-in functions for each of these operato Starting from [PyGAD 2.16.0](https://pygad.readthedocs.io/en/latest/releases.html#pygad-2-16-0), the user can create a custom crossover, mutation, and parent selection operators and assign these functions to the above parameters. Thus, a new operator can be plugged easily into the [PyGAD Lifecycle](https://pygad.readthedocs.io/en/latest/lifecycle.html#life-cycle-of-pygad). +Custom operators may be functions, bound methods, callable instances, or `functools.partial` objects. The constructor checks whether the callable accepts the positional arguments shown below; additional optional parameters are allowed. Required keyword-only parameters and asynchronous callables are rejected. + +For reproducible random choices, use `ga_instance.numpy_random_generator` or `ga_instance.python_random_generator`. These generators belong to the GA and use its `random_seed`. They are also preserved when saving and loading the GA. Mutation selection and replacement settings remain the responsibility of a custom mutation function. + +When `allow_duplicate_genes=False`, PyGAD applies its shared duplicate repair to custom crossover and mutation outputs after the corresponding callback has finished. Values are converted and rounded before repair. This also handles duplicate values returned or changed in place by `on_crossover` and `on_mutation`. If the configured spaces, ranges, or constraints leave no usable alternative, duplicates can remain with a warning. See [Prevent Duplicates in Gene Values](https://pygad.readthedocs.io/en/latest/gene_values.html#prevent-duplicates-in-gene-values). + +PyGAD applies `gene_type` and its precision to custom parent selection, crossover, and mutation outputs before the corresponding callback receives them. Values returned or changed in place by `on_parents`, `on_crossover`, and `on_mutation` are converted again before use, even when duplicates are allowed. When constructing an output array containing both large integers and floating-point values, use `numpy.array(values, dtype=object)` to preserve the original values until each gene's type is applied. See [Conversion and Rounding Rules](https://pygad.readthedocs.io/en/latest/gene_values.html#conversion-and-rounding-rules). + This is a sample code that does not use any custom function. ```python @@ -65,7 +73,7 @@ def crossover_func(parents, offspring_size, ga_instance): parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[1])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -108,9 +116,9 @@ The next code builds the random mutation where a single gene from each chromosom def mutation_func(offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[1])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring ``` @@ -230,7 +238,7 @@ def crossover_func(parents, offspring_size, ga_instance): parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[1])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -243,9 +251,9 @@ def crossover_func(parents, offspring_size, ga_instance): def mutation_func(offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[0])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring @@ -299,7 +307,7 @@ class Test: parent1 = parents[idx % parents.shape[0], :].copy() parent2 = parents[(idx + 1) % parents.shape[0], :].copy() - random_split_point = numpy.random.choice(range(offspring_size[0])) + random_split_point = ga_instance.numpy_random_generator.choice(range(offspring_size[1])) parent1[random_split_point:] = parent2[random_split_point:] @@ -312,9 +320,9 @@ class Test: def mutation_func(self, offspring, ga_instance): for chromosome_idx in range(offspring.shape[0]): - random_gene_idx = numpy.random.choice(range(offspring.shape[1])) + random_gene_idx = ga_instance.numpy_random_generator.choice(range(offspring.shape[1])) - offspring[chromosome_idx, random_gene_idx] += numpy.random.random() + offspring[chromosome_idx, random_gene_idx] += ga_instance.numpy_random_generator.random() return offspring @@ -330,3 +338,30 @@ ga_instance = pygad.GA(num_generations=10, ga_instance.run() ga_instance.plot_fitness() ``` + +<!-- python-examples +example_custom_operators.py +--> + +**Python example** + +**[Custom GA operators](../../examples/example_custom_operators.py)** + +Implement parent selection, crossover, and mutation functions. + +`examples/example_custom_operators.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_custom_operators.py +``` + +</details> + +<!-- /python-examples --> diff --git a/docs/source/utils.md b/docs/source/utils.md index 616c2f54..635dedfb 100644 --- a/docs/source/utils.md +++ b/docs/source/utils.md @@ -24,7 +24,7 @@ The next sections discuss each submodule. ## `pygad.utils.engine` Submodule -The `pygad.utils.engine` module has the `GAEngine` class that implements the engine of the library. It inherits fitness dispatch and serialization methods from {ref}`FitnessEvaluation <fitness-evaluation>`. The main methods defined in `GAEngine` are: +The `pygad.utils.engine` module has the `GAEngine` class that implements the engine of the library. It inherits fitness dispatch and serialization methods from [FitnessEvaluation](utils.md#pygadutilsparallel-submodule). The main methods defined in `GAEngine` are: 1. `initialize_population()` 2. `cal_pop_fitness()` @@ -64,9 +64,9 @@ For each solution, it checks the following sources in order: 4. Retained parents, when `keep_parents != 0`. `last_generation_parents_indices` maps each parent back to its value in `previous_generation_fitness`. 5. The fitness function, for solutions with no cached value. -These cache rules apply in serial, thread, and process modes. Only uncached rows are passed to `_evaluate_fitness()`, which respects `fitness_batch_size`, validates returned values, and increments `num_fitness_evaluations` by the number of evaluated solutions. Cached rows contribute zero to that counter. These rules assume that a solution's fitness can be reused; see {ref}`non-deterministic problems <non-deterministic-fitness>` for settings that disable reuse. +These cache rules apply in serial, thread, and process modes. Only uncached rows are passed to `_evaluate_fitness()`, which respects `fitness_batch_size`, validates returned values, and increments `num_fitness_evaluations` by the number of evaluated solutions. Cached rows contribute zero to that counter. These rules assume that a solution's fitness can be reused; see [non-deterministic problems](fitness_calculation.md#solve-non-deterministic-problems) for settings that disable reuse. -During `run()`, evaluations reuse the run's worker pool. Outside `run()`, parallel evaluation creates and closes a temporary pool. If all fitness values are cached, no pool is created. Fitness-function exceptions and invalid return values propagate to the caller after being logged. See the {ref}`fitness dispatch reference <fitness-evaluation>` for return-value validation. +During `run()`, evaluations reuse the run's worker pool. Outside `run()`, parallel evaluation creates and closes a temporary pool. If all fitness values are cached, no pool is created. Fitness-function exceptions and invalid return values propagate to the caller after being logged. See the [fitness dispatch reference](utils.md#pygadutilsparallel-submodule) for return-value validation. ### `run()` @@ -99,7 +99,9 @@ Note that the `run()` method is calling 5 different methods during the loop: 4. `run_mutation()` 5. `run_update_population()` +<!-- sphinx (current-population-best-solution)= +--> ### `best_solution()` Returns information about the best solution in the **current population**. Single-objective problems use the maximum fitness; multi-objective problems use the first solution in the NSGA-II ordering (non-dominated front, then crowding distance). This method does not search all saved generations. @@ -120,10 +122,12 @@ It returns the following: A method to round the genes in the passed solutions. It loops through each gene across all the passed solutions and rounds their values if applicable. +<!-- sphinx (fitness-evaluation)= +--> ## `pygad.utils.parallel` Submodule -This module contains the `FitnessEvaluation` mixin and the process-worker function `_process_fitness_chunk()`. A mixin is a class that provides methods for another class to inherit. `GAEngine` inherits this mixin, so `pygad.GA` inherits its methods indirectly. Configure evaluation through `fitness_func`, `fitness_batch_size`, and `parallel_processing`; see the {ref}`parallel processing guide <parallel-processing-guide>` for examples and performance tradeoffs. +This module contains the `FitnessEvaluation` mixin and the process-worker function `_process_fitness_chunk()`. A mixin is a class that provides methods for another class to inherit. `GAEngine` inherits this mixin, so `pygad.GA` inherits its methods indirectly. Configure evaluation through `fitness_func`, `fitness_batch_size`, and `parallel_processing`; see the [parallel processing guide](fitness_calculation.md#parallel-processing-in-pygad) for examples and performance tradeoffs. The methods and worker attributes below are internal implementation details, documented for completeness. Their signatures and lifetime are not a stable public API. @@ -151,7 +155,9 @@ Serial evaluation calls `fitness_func(self, solution, index_argument)` directly Internal process grouping preserves the scalar fitness signature. Only `fitness_batch_size` changes the function's input to a batch. Fitness-function and serialization exceptions propagate while results are consumed. +<!-- sphinx (evaluate-selected-fitness)= +--> #### `_evaluate_fitness(population, indices, adaptive=False)` Parameters: @@ -204,13 +210,13 @@ The `pygad.utils.crossover` module has a class named `Crossover` with the suppor Crossover takes two parents and builds a child by mixing their genes. The next figure shows how single-point, two-point, and uniform crossover do this. -:::{figure} images/crossover_types.* -:alt: Single-point, two-point, and uniform crossover -:width: 560px -:align: center +<!-- documentation-figure: 560px --> + +![Single-point, two-point, and uniform crossover](images/crossover_types.png) How single-point, two-point, and uniform crossover build a child from two parents. -::: + +<!-- /documentation-figure --> All crossover methods accept these parameters: @@ -232,6 +238,9 @@ The next subsections list the supported methods for crossover. Applies the single-point crossover. It selects a point randomly at which crossover takes place between the pairs of parents. +<!-- sphinx +(two-points-crossover)= +--> #### `two_points_crossover()` Applies the 2 points crossover. It selects the 2 points randomly at which crossover takes place between the pairs of parents. @@ -248,6 +257,9 @@ Applies the uniform crossover. For each gene, a parent out of the 2 mating paren Applies the scattered crossover. It randomly selects the gene from one of the 2 parents. +<!-- sphinx +(sbx-crossover)= +--> #### `sbx_crossover()` Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` parameter controls the spread: larger values keep children closer to their parents. Bounds come from `init_range_low` and `init_range_high`, which can specify a separate range for each gene. @@ -267,19 +279,21 @@ The `pygad.utils.mutation` module has a class named `Mutation` with the supporte Mutation makes small random changes to the offspring so the search can explore new values. The next figure shows random mutation, where a few genes are picked at random and their values are changed. -:::{figure} images/mutation.* -:alt: Random mutation changes a few genes -:width: 560px -:align: center +<!-- documentation-figure: 560px --> + +![Random mutation changes a few genes](images/mutation.png) Random mutation changes the values of a few genes that are picked at random. -::: + +<!-- /documentation-figure --> All mutation methods accept this parameter: 1. `offspring`: The offspring to mutate. +<!-- sphinx (mutation-methods)= +--> ### Mutation Methods The `Mutation` class in the `pygad.utils.mutation` module supports several methods for applying mutation. All of these methods accept the same parameter which is: @@ -302,6 +316,9 @@ Each gene participates in at most one fallback swap per offspring per mutation p For each gene, a random value is selected according to the range specified by the 2 attributes `random_mutation_min_val` and `random_mutation_max_val`. The random value is added to the selected gene. +<!-- sphinx +(swap-mutation)= +--> #### `swap_mutation()` Applies the swap mutation which interchanges the values of 2 randomly selected genes. @@ -312,6 +329,9 @@ Any pair of distinct positions can be selected. An offspring with only one gene Applies the inversion mutation which selects a subset of genes and inverts them. +<!-- sphinx +(scramble-mutation)= +--> #### `scramble_mutation()` Applies the scramble mutation which selects a subset of genes and shuffles their order randomly. @@ -324,6 +344,9 @@ Applies the adaptive mutation, which selects the number/percentage of genes to m The count-based and probability-based adaptive mutation methods use the same compatible-swap fallback for permutations as random mutation. Their fitness-based controls select which genes can initiate a mutation; swapped partners are not mutated again in the same pass. +<!-- sphinx +(polynomial-mutation)= +--> #### `polynomial_mutation(offspring)` Applies polynomial mutation to the passed two-dimensional offspring array in place and returns it. Each gene is selected with `mutation_probability`, or with probability `1 / num_genes` when that parameter is `None`. `polynomial_mutation_eta` controls the size of the change; higher values favor smaller changes. Bounds come from `init_range_low` and `init_range_high` for each gene, and mutated values are clipped to those bounds. Genes whose range has effectively zero width are skipped. When `allow_duplicate_genes=False`, the existing random duplicate-resolution helper is applied after changing a gene. @@ -344,7 +367,9 @@ The `pygad.utils.mutation` module has some helper methods to assist applying the 10. `adaptive_mutation_probs_randomly()`: Uses the mutation probabilities to decide which genes to apply the adaptive mutation randomly. 11. `swap_gene_by_space(solution, gene_idx, swapped_genes=None)`: Swap one gene with a compatible partner while preserving gene types, numeric values, gene spaces, uniqueness, and constraints. The solution is modified in place. The optional `swapped_genes` set tracks both positions already swapped in the same offspring's mutation pass; start with a new set for each pass. +<!-- sphinx (adaptive-offspring-fitness)= +--> #### `adaptive_mutation_population_fitness(offspring)` Accepts a two-dimensional NumPy array of offspring before mutation, with one chromosome per row. It builds a temporary population containing retained solutions followed by these actual offspring, without replacing `self.population`. The number of offspring must match the available rows after retention, as prepared by PyGAD's crossover step. @@ -353,7 +378,7 @@ Retention follows the GA configuration: positive `keep_elitism` selects the best Returns `(average_fitness, offspring_fitness)`. For a single objective, the average is a scalar and offspring fitness is a one-dimensional NumPy array. For multiple objectives, the average is a vector and offspring fitness has one row per offspring and one column per objective. The average includes both retained solutions and offspring. NumPy infers a common fitness dtype, preserving fractional offspring values when earlier population fitness was integer-valued. -All execution modes and batch sizes evaluate the same offspring values. The fitness function receives `None` as its index argument in both scalar and batch calls. It must use the supplied chromosomes rather than indexing the current `ga_instance.population`. Every evaluated offspring contributes to `num_fitness_evaluations`; retained fitness does not. During a run, these calls reuse the same executor as ordinary population evaluation. Return validation and propagated exceptions are described in {ref}`_evaluate_fitness() <evaluate-selected-fitness>`. +All execution modes and batch sizes evaluate the same offspring values. The fitness function receives `None` as its index argument in both scalar and batch calls. It must use the supplied chromosomes rather than indexing the current `ga_instance.population`. Every evaluated offspring contributes to `num_fitness_evaluations`; retained fitness does not. During a run, these calls reuse the same executor as ordinary population evaluation. Return validation and propagated exceptions are described in [_evaluate_fitness()](utils.md#_evaluate_fitnesspopulation-indices-adaptivefalse). #### `swap_gene_by_space(solution, gene_idx, swapped_genes=None)` @@ -400,6 +425,9 @@ The next subsections list the supported methods for parent selection. Selects the parents using the steady-state selection technique. +<!-- sphinx +(rank-selection)= +--> #### `rank_selection()` Selects the parents using the rank selection technique. @@ -418,6 +446,9 @@ Selects the parents using the tournament selection technique. Selects the parents using the roulette wheel selection technique. +<!-- sphinx +(stochastic-universal-selection)= +--> #### `stochastic_universal_selection()` Selects the parents using the stochastic universal selection technique. @@ -478,8 +509,38 @@ The `pygad.utils.report` module has a class named `Report` that adds the `genera pip install pygad[report] ``` -See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#generate-report) and the runnable example at [`examples/example_generate_report.py`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/example_generate_report.py). +See [`generate_report()`](https://pygad.readthedocs.io/en/latest/pygad.html#generate-report). + +<!-- python-examples +example_generate_report.py +--> + +**Python example** + +**[PDF report](../../examples/example_generate_report.py)** + +Export the run configuration, summary, best solution, and applicable plots to PDF. + +`examples/example_generate_report.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD with the report extra (Matplotlib and ReportLab) + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/example_generate_report.py +``` + +</details> + +<!-- /python-examples --> +<!-- sphinx +(quality-indicators)= +--> ## `pygad.utils.quality_indicators` Submodule The `pygad.utils.quality_indicators` module has functions to measure the quality of a Pareto front. All functions take fitness values in PyGAD's maximization format. The functions are: @@ -506,32 +567,75 @@ true_front = problem.pareto_front(num_points=100) igd = inverted_generational_distance(fitness, true_front) ``` -A runnable example per indicator lives under `examples/quality_indicators/`. +<!-- python-examples +quality_indicators/example_hypervolume.py +quality_indicators/example_inverted_generational_distance.py +quality_indicators/example_generational_distance.py +quality_indicators/example_spacing.py +--> -## More about the Operators +**Python examples** -::::{grid} 1 2 2 2 -:gutter: 3 +| Python script | What it shows | Related information | +| --- | --- | --- | +| [quality_indicators/example_hypervolume.py](../../examples/quality_indicators/example_hypervolume.py) | **Hypervolume.** Measure the objective-space volume dominated by the final population. | [Guide](utils.md) | +| [quality_indicators/example_inverted_generational_distance.py](../../examples/quality_indicators/example_inverted_generational_distance.py) | **Inverted generational distance.** Measure distance from a reference front to the approximation. | [Guide](utils.md) | +| [quality_indicators/example_generational_distance.py](../../examples/quality_indicators/example_generational_distance.py) | **Generational distance.** Measure distance from the approximation to a reference front. | [Guide](utils.md) | +| [quality_indicators/example_spacing.py](../../examples/quality_indicators/example_spacing.py) | **Spacing.** Measure how evenly the approximation points are spread. | [Guide](utils.md) | -:::{grid-item-card} Adaptive Mutation -:link: adaptive_mutation -:link-type: doc +<details> +<summary>Run these examples</summary> -Change the mutation rate per solution based on its fitness. -::: +**Hypervolume** — Requires: PyGAD -:::{grid-item-card} User-Defined Operators -:link: user_defined_operators -:link-type: doc +From the repository root, with the repository version of PyGAD installed: -Plug in your own crossover, mutation, and parent selection. -::: +```console +python examples/quality_indicators/example_hypervolume.py +``` + +**Inverted generational distance** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_inverted_generational_distance.py +``` + +**Generational distance** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_generational_distance.py +``` + +**Spacing** — Requires: PyGAD + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/quality_indicators/example_spacing.py +``` + +</details> + +<!-- /python-examples --> + +## More about the Operators + +<!-- navigation-grid: 1 2 2 2 --> + +- [Adaptive Mutation](adaptive_mutation.md) — Change the mutation rate per solution based on its fitness. +- [User-Defined Operators](user_defined_operators.md) — Plug in your own crossover, mutation, and parent selection. -:::: +<!-- /navigation-grid --> +<!-- sphinx :::{toctree} :hidden: adaptive_mutation user_defined_operators ::: +--> diff --git a/docs/source/visualize.md b/docs/source/visualize.md index 99c1e943..2457055a 100644 --- a/docs/source/visualize.md +++ b/docs/source/visualize.md @@ -1,13 +1,14 @@ # `pygad.visualize` Module -The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. +The `pygad.visualize.plot.Plot` class is mixed into `pygad.GA`. Each method below is callable on a GA instance after `run()`. `plot_lifecycle()` can also be called before `run()` because it draws the configured execution flow. -Every method returns the `matplotlib.figure.Figure` it created and optionally writes it to disk via `save_dir`. A runnable script for each plot lives under [`examples/plots/`](https://github.com/ahmedfgad/GeneticAlgorithmPython/tree/master/examples/plots). +Every method returns the `matplotlib.figure.Figure` it created and optionally writes it to disk via `save_dir`. Complete scripts are linked below and listed in the [Examples index](examples.md). ## Plot inventory | Method | Works for | Needs `save_solutions=True` | |---|---|---| +| `plot_lifecycle()` | SOO + MOO, before or after `run()` | no | | `plot_fitness()` | SOO + MOO | no | | `plot_new_solution_rate()` | SOO + MOO | yes | | `plot_genes()` | SOO + MOO | yes (`solutions="all"`) or `save_best_solutions=True` (`solutions="best"`) | @@ -20,7 +21,75 @@ Every method returns the `matplotlib.figure.Figure` it created and optionally wr | `plot_population_diversity()` | SOO + MOO | yes | | `plot_pareto_front_evolution()` | MOO (M=2 or M=3) | yes | -Every method requires at least one completed generation. Each one raises `RuntimeError` with a clear message if it is called too early, on a single-objective problem when MOO is required, or without the `save_solutions` flag when one is required. +Except for `plot_lifecycle()`, every method requires at least one completed generation. Each one raises `RuntimeError` with a clear message if it is called too early, on a single-objective problem when MOO is required, or without the `save_solutions` flag when one is required. + +After repeated `run()` calls, fitness plots, best-solution gene plots, and population diagnostics use the actual generation numbers. Histories retain both snapshots at a run boundary, so two points can have the same generation number. Population diagnostics also retain each snapshot's population size. `plot_new_solution_rate()` uses the latest saved population once per generation and excludes the final population, as in a single run. `plot_pareto_front_evolution(every_k=N)` selects actual generation numbers divisible by `N`, uses the latest snapshot at repeated boundaries, and always includes the final population. These plots also work in generated PDF reports. + +<!-- sphinx +(plot-lifecycle)= +--> +## `plot_lifecycle()` + +Draw the lifecycle configured for a GA instance: initial fitness evaluation, parent selection, crossover, mutation, population update, fitness reevaluation, and the generation loop. The chart includes the configured callbacks at their execution points, a generation-limit decision, and early stopping when a stopping criterion is set or `on_generation` can return `"stop"`. + +```python +ga_instance.plot_lifecycle() +``` + +![plot_lifecycle](figures/plot_lifecycle.png) + +Operator cards show handler names, relevant probabilities, and parent or offspring shapes. The population update shows the effective retention policy: `keep_elitism` takes precedence over `keep_parents`. The configuration panel shows population size, generations per `run()`, gene types and precision, gene space or initialization range, constraints, and saving settings. Fitness batching and parallel processing appear when configured. Long gene configurations are abbreviated to keep the chart readable. + +Callbacks appear only when supplied. If `crossover_type=None` or `mutation_type=None`, the corresponding operator card is omitted and the remaining stages are connected directly. Configured `on_crossover` and `on_mutation` callbacks still appear because they run even when the operator is disabled. Adaptive mutation includes its additional offspring fitness evaluation, and NSGA-III includes reference-point preparation. NSGA-III may grow the population during this preparation; shapes in a chart drawn before `run()` describe the current configuration. + +In the detailed view, the `Stop Early?` block lists the configured `stop_criteria` and, when an `on_generation` callback is supplied, the possible condition `on_generation() returns "stop"`. Any one of these conditions ends the run. The chart does not analyze the callback's code or assume that it will return `"stop"`. The block is omitted when neither early stopping mechanism is configured. Built-in block and configuration titles capitalize the first letter of each word; method and handler names retain their original spelling. + +Parameters: `title` (default `"PyGAD - Lifecycle"`), `font_size` (default `11`, finite and positive), `show_parameters` (default `True`), `save_dir` (default `None`), `show` (default `True`), `transparent` (default `False`). + +Use `show_parameters=False` for a compact chart that keeps handler names and control flow. Set `show=False` to create or save a chart without displaying it. The method always returns the figure, so it can be customized further. + +Charts fit their labels, cards, and connectors with small outer margins. Set `transparent=True` to remove the background for embedding in slides, videos, or web pages; the stage cards keep their fill colors. + +```python +# Save a detailed chart. The filename extension selects SVG, PNG, or PDF. +fig = ga_instance.plot_lifecycle(title="PyGAD - Scheduling Optimization", + save_dir="lifecycle.svg", + show=False) + +# Display a compact chart. +ga_instance.plot_lifecycle(show_parameters=False) +``` + +The method reads the current GA configuration without evaluating fitness, calling operators or callbacks, or changing GA state. It describes the configured flow rather than recording the path taken during a run. Before fitness is available, the objective count is marked as unknown. After a run, the chart can show the known objective count and fitness shape. Each `run()` call uses the configured generation count, including when continuing a previous run. + +Install the optional plotting dependency with `pip install pygad[visualize]`. + +<!-- python-examples +plots/example_plot_lifecycle.py +--> + +**Python example** + +**[Configured lifecycle](../../examples/plots/example_plot_lifecycle.py)** + +Draw detailed and compact lifecycle charts and export SVG and PNG files. + +`examples/plots/example_plot_lifecycle.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_lifecycle.py +``` + +</details> + +<!-- /python-examples --> ## `plot_fitness()` @@ -34,6 +103,33 @@ ga_instance.plot_fitness() ![plot_fitness](figures/plot_fitness.png) +<!-- python-examples +plots/example_plot_fitness.py +--> + +**Python example** + +**[Best-fitness curve](../../examples/plots/example_plot_fitness.py)** + +Plot best fitness across generations on the Sphere benchmark. + +`examples/plots/example_plot_fitness.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_fitness.py +``` + +</details> + +<!-- /python-examples --> + ## `plot_new_solution_rate()` Number of previously-unseen solutions per generation. A flat curve means the GA is repeating itself; a high curve means it is still exploring. Requires `save_solutions=True`. @@ -46,6 +142,33 @@ ga_instance.plot_new_solution_rate() ![plot_new_solution_rate](figures/plot_new_solution_rate.png) +<!-- python-examples +plots/example_plot_new_solution_rate.py +--> + +**Python example** + +**[New-solution rate](../../examples/plots/example_plot_new_solution_rate.py)** + +Count previously unseen solutions in each generation. + +`examples/plots/example_plot_new_solution_rate.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_new_solution_rate.py +``` + +</details> + +<!-- /python-examples --> + ## `plot_genes()` One subplot per gene showing how that gene drifts across generations. Three views: line per gene (`graph_type="plot"`), per-gene boxplot, per-gene histogram. @@ -60,6 +183,36 @@ ga_instance.plot_genes(graph_type="boxplot") ![plot_genes](figures/plot_genes.png) +<!-- python-examples +plots/example_plot_genes.py +--> + +**Python example** + +**[Gene histories](../../examples/plots/example_plot_genes.py)** + +Show how gene values change across saved generations. + +`examples/plots/example_plot_genes.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_genes.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-pareto-front-curve)= +--> ## `plot_pareto_front_curve()` Pareto front of the final population. With 2 objectives it draws the population as a scatter and connects the non-dominated points with a curve. With 3 objectives it switches to a 3D scatter and highlights the non-dominated points. With 4 or more objectives it raises and points to the high-dimensional plots below. @@ -78,6 +231,56 @@ For M=3 (NSGA-III on DTLZ2): ![plot_pareto_front_curve_3d](figures/plot_pareto_front_curve_3d.png) +<!-- python-examples +plots/example_plot_pareto_front_curve_2d.py +plots/example_plot_pareto_front_curve_3d.py +--> + +**Python examples** + +**[2D Pareto front](../../examples/plots/example_plot_pareto_front_curve_2d.py)** + +Plot a two-objective Pareto front after NSGA-II optimization. + +`examples/plots/example_plot_pareto_front_curve_2d.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_curve_2d.py +``` + +</details> + +**[3D Pareto front](../../examples/plots/example_plot_pareto_front_curve_3d.py)** + +Plot a three-objective Pareto front after NSGA-III optimization. + +`examples/plots/example_plot_pareto_front_curve_3d.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_curve_3d.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-pareto-front-pcp)= +--> ## `plot_pareto_front_pcp()` Parallel-coordinates view of the final non-dominated set. Each objective is a vertical axis. Each non-dominated solution becomes a polyline that crosses every axis. Values are normalized per objective so very different scales remain comparable. Useful for any M >= 2 and especially for M >= 4. @@ -90,6 +293,36 @@ ga_instance.plot_pareto_front_pcp() ![plot_pareto_front_pcp](figures/plot_pareto_front_pcp.png) +<!-- python-examples +plots/example_plot_pareto_front_pcp.py +--> + +**Python example** + +**[Parallel coordinates](../../examples/plots/example_plot_pareto_front_pcp.py)** + +Compare Pareto solutions across objective axes. + +`examples/plots/example_plot_pareto_front_pcp.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_pcp.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-pareto-front-scatter-matrix)= +--> ## `plot_pareto_front_scatter_matrix()` M-by-M grid of pairwise scatter plots for the final non-dominated set. The diagonal shows a histogram of each objective. The best fit when M >= 4 and a single 3D scatter no longer reads well. @@ -102,6 +335,36 @@ ga_instance.plot_pareto_front_scatter_matrix() ![plot_pareto_front_scatter_matrix](figures/plot_pareto_front_scatter_matrix.png) +<!-- python-examples +plots/example_plot_pareto_front_scatter_matrix.py +--> + +**Python example** + +**[Pareto scatter matrix](../../examples/plots/example_plot_pareto_front_scatter_matrix.py)** + +Compare every pair of objectives in a many-objective run. + +`examples/plots/example_plot_pareto_front_scatter_matrix.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_scatter_matrix.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-pareto-front-heatmap)= +--> ## `plot_pareto_front_heatmap()` Heatmap of the final non-dominated set. Rows are solutions, columns are objectives, color is the raw objective value. Rows are sorted by objective `sort_by` (default `0`); pass `sort_by=None` to keep the original order. @@ -114,6 +377,36 @@ ga_instance.plot_pareto_front_heatmap(sort_by=0) ![plot_pareto_front_heatmap](figures/plot_pareto_front_heatmap.png) +<!-- python-examples +plots/example_plot_pareto_front_heatmap.py +--> + +**Python example** + +**[Pareto heatmap](../../examples/plots/example_plot_pareto_front_heatmap.py)** + +Compare objective values with a solutions-by-objectives heatmap. + +`examples/plots/example_plot_pareto_front_heatmap.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_heatmap.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-fitness-band)= +--> ## `plot_fitness_band()` Per-generation min, mean, and max with a shaded min-max band. Reveals selection pressure and diversity collapse at a glance. For MOO, pick one objective via `objective_index` (default `0`). Requires `save_solutions=True`. @@ -126,6 +419,36 @@ ga_instance.plot_fitness_band() ![plot_fitness_band](figures/plot_fitness_band.png) +<!-- python-examples +plots/example_plot_fitness_band.py +--> + +**Python example** + +**[Fitness band](../../examples/plots/example_plot_fitness_band.py)** + +Plot per-generation minimum, mean, and maximum fitness with a shaded band. + +`examples/plots/example_plot_fitness_band.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_fitness_band.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-non-dominated-hypervolume)= +--> ## `plot_non_dominated_hypervolume()` Hypervolume of the non-dominated set per generation. Uses `pygad.utils.quality_indicators.hypervolume`. Pass `reference_point` explicitly, or let the method pick the column-wise min across all saved generations minus `0.1`. Requires `save_solutions=True`. @@ -138,6 +461,36 @@ ga_instance.plot_non_dominated_hypervolume() ![plot_non_dominated_hypervolume](figures/plot_non_dominated_hypervolume.png) +<!-- python-examples +plots/example_plot_non_dominated_hypervolume.py +--> + +**Python example** + +**[Hypervolume history](../../examples/plots/example_plot_non_dominated_hypervolume.py)** + +Track the hypervolume of the non-dominated set across generations. + +`examples/plots/example_plot_non_dominated_hypervolume.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_non_dominated_hypervolume.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-population-diversity)= +--> ## `plot_population_diversity()` Mean pairwise Euclidean distance between solutions per generation. A drop signals the population is converging or collapsing into duplicates. Requires `save_solutions=True`. @@ -150,6 +503,36 @@ ga_instance.plot_population_diversity() ![plot_population_diversity](figures/plot_population_diversity.png) +<!-- python-examples +plots/example_plot_population_diversity.py +--> + +**Python example** + +**[Population diversity](../../examples/plots/example_plot_population_diversity.py)** + +Track mean pairwise distance between solutions across generations. + +`examples/plots/example_plot_population_diversity.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_population_diversity.py +``` + +</details> + +<!-- /python-examples --> + +<!-- sphinx +(plot-pareto-front-evolution)= +--> ## `plot_pareto_front_evolution()` Overlays the non-dominated set every `every_k` generations on a single figure. The colormap goes from early to late so you can see the front converge. Works for 2 or 3 objectives. Requires `save_solutions=True`. @@ -161,3 +544,30 @@ ga_instance.plot_pareto_front_evolution(every_k=20) ``` ![plot_pareto_front_evolution](figures/plot_pareto_front_evolution.png) + +<!-- python-examples +plots/example_plot_pareto_front_evolution.py +--> + +**Python example** + +**[Pareto-front evolution](../../examples/plots/example_plot_pareto_front_evolution.py)** + +Overlay the non-dominated fronts from selected generations. + +`examples/plots/example_plot_pareto_front_evolution.py` + +<details> +<summary>Run this example</summary> + +**Requires:** PyGAD, Matplotlib + +From the repository root, with the repository version of PyGAD installed: + +```console +python examples/plots/example_plot_pareto_front_evolution.py +``` + +</details> + +<!-- /python-examples --> diff --git a/examples/data/README.md b/examples/data/README.md index aeb955f3..cb4a72be 100644 --- a/examples/data/README.md +++ b/examples/data/README.md @@ -54,4 +54,4 @@ Put the image folders here as `Skin_Cancer_Dataset/benign/` and `Skin_Cancer_Dat ## Travelling salesman notebook -`examples/example_travelling_salesman.ipynb` reads `data/startbucks.csv`. That file is not in the repository and its source was not recorded. Add your own CSV at `examples/data/startbucks.csv` or change the path in the notebook. +`examples/example_travelling_salesman.ipynb` uses Google Colab and reads `/content/sample_data/startbucks.csv`. That file is not in the repository and its source was not recorded. Supply your own compatible CSV at that path in Colab. To run the notebook locally with Jupyter, adapt its Colab-specific imports and change the CSV path, for example to `examples/data/startbucks.csv` when running from the repository root. diff --git a/examples/example_constructor_parameters.py b/examples/example_constructor_parameters.py new file mode 100644 index 00000000..dc222a03 --- /dev/null +++ b/examples/example_constructor_parameters.py @@ -0,0 +1,52 @@ +from functools import partial + +import numpy +import pygad + + +def fitness_func(ga_instance, solution, solution_idx, target): + return 1.0 / (1.0 + abs(numpy.sum(solution) - target)) + + +def mutation_func(offspring, ga_instance): + # Use the GA's own generator so random_seed also covers this operator. + for solution in offspring: + gene_index = ga_instance.numpy_random_generator.randint(ga_instance.num_genes) + lower, upper = ga_instance.get_initial_population_range(gene_index) + solution[gene_index] = ga_instance.numpy_random_generator.uniform(lower, upper) + return offspring + + +def create_ga(random_seed): + return pygad.GA(num_generations=numpy.uint8(5), + num_parents_mating=numpy.int64(2), + fitness_func=partial(fitness_func, target=3.0), + sol_per_pop=6, + num_genes=3, + init_range_low=0.0, + init_range_high=2.0, + gene_type=[float, 2], + mutation_type=mutation_func, + mutation_num_genes=1, + random_seed=random_seed) + + +first_ga = create_ga(numpy.int64(7)) +second_ga = create_ga(7) + +first_ga.run() + +# Another GA and global random draws do not change second_ga's generator states. +other_ga = create_ga(99) +other_ga.run() +numpy.random.seed(100) +numpy.random.random(10) + +second_ga.run() +numpy.testing.assert_array_equal(first_ga.population, second_ga.population) +print("Independent instances with the same seed produce the same population.") +print(first_ga.population) + +# Count arithmetic uses Python integers even when NumPy counts are supplied. +first_ga.run() +print("Generations after two runs:", first_ga.generations_completed) diff --git a/examples/example_duplicate_gene_repair.py b/examples/example_duplicate_gene_repair.py new file mode 100644 index 00000000..8fb0826c --- /dev/null +++ b/examples/example_duplicate_gene_repair.py @@ -0,0 +1,38 @@ +"""Prevent duplicate genes when repair needs a chain of replacements.""" + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return sum((index + 1) * value for index, value in enumerate(solution)) + + +def on_generation(ga_instance): + for solution in ga_instance.population: + assert len(set(solution)) == len(solution) + assert all(value in space for value, space in zip(solution, ga_instance.gene_space)) + + +gene_space = [[0, 1], [1, 2], [2, 3], [0]] +initial_population = [[0, 1, 2, 0], [0, 1, 2, 0]] + +ga_instance = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_space=gene_space, + gene_type=int, + allow_duplicate_genes=False, + mutation_num_genes=1, + on_generation=on_generation, + random_seed=1) + +# The last gene can only keep 0. Repair moves the first three genes to +# 1, 2, and 3, making room for 0 without leaving any gene's own space. +print("Initial Population") +print(ga_instance.initial_population) + +ga_instance.run() + +print("Final Population") +print(ga_instance.population) diff --git a/examples/example_gene_type_conversion.py b/examples/example_gene_type_conversion.py new file mode 100644 index 00000000..a176db4c --- /dev/null +++ b/examples/example_gene_type_conversion.py @@ -0,0 +1,44 @@ +import numpy +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + # Keep the integer exact while comparing it with the target. + integer_error = abs(solution[0] - (2**53 + 1)) + return 1.0 / (1.0 + integer_error + abs(solution[1] - 1.25)) + + +def mutation_func(offspring, ga_instance): + # Object arrays preserve mixed integer and floating-point values. + offspring = offspring.copy() + offspring[:, 1] = [float(value) + 0.126 for value in offspring[:, 1]] + # PyGAD applies each gene's type and precision to the returned values. + return offspring + + +initial_population = [[2**53 + 1, 1.236, 3.9], + [2**53 + 2, 1.754, 4.1], + [2**53 + 3, 2.345, 5.8], + [2**53 + 4, 2.876, 6.2]] + +ga_instance = pygad.GA(num_generations=3, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_type=[int, [numpy.float32, 2], numpy.int8], + mutation_type=mutation_func, + mutation_num_genes=1, + save_best_solutions=True, + random_seed=7) + +print("Initial Population") +print(ga_instance.initial_population) + +ga_instance.run() + +print("Final Population") +print(ga_instance.population) +print("Gene Types") +print([type(value) for value in ga_instance.population[0]]) +print("Saved Best Solutions") +print(ga_instance.best_solutions) diff --git a/examples/example_initial_population.py b/examples/example_initial_population.py new file mode 100644 index 00000000..3bbc7af4 --- /dev/null +++ b/examples/example_initial_population.py @@ -0,0 +1,57 @@ +"""Generate an initial population or start from supplied numeric values.""" + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return sum(solution) + + +# Each column uses its own range and type. The float column is rounded +# to 2 decimal places while remaining inside [-2, 2). +random_population_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=4, + num_genes=3, + init_range_low=[0, -2, 20], + init_range_high=[5, 2, 30], + gene_type=[int, [float, 2], float], + mutation_num_genes=1, + random_seed=7) + +print("Population from Per-Gene Ranges") +print(random_population_ga.initial_population) + +# The third gene has no explicit space, so its values come from [20, 30). +# The last gene has the fixed value 5. +gene_space_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + sol_per_pop=4, + num_genes=4, + gene_space=[[0, 1, 2], {'low': 1, 'high': 2}, None, 5], + init_range_low=[0, 0, 20, 0], + init_range_high=[3, 3, 30, 10], + gene_type=[int, [float, 2], [float, 1], int], + mutation_num_genes=1, + random_seed=7) + +print("Population from a Nested Gene Space") +print(gene_space_ga.initial_population) + +# Neither sol_per_pop nor num_genes is needed. PyGAD infers both from +# the supplied population, converts the values, and keeps its own copy. +initial_population = ((1.236, 10), (2.341, 20)) +supplied_population_ga = pygad.GA(num_generations=5, + num_parents_mating=2, + fitness_func=fitness_func, + initial_population=initial_population, + gene_type=([float, 2], int), + mutation_num_genes=1, + random_seed=7) + +print("Population from Supplied Values") +print(supplied_population_ga.initial_population) +print("Inferred Population Shape", supplied_population_ga.pop_size) +print("Original Supplied Values", initial_population) diff --git a/examples/example_repeated_runs.py b/examples/example_repeated_runs.py new file mode 100644 index 00000000..fa92983e --- /dev/null +++ b/examples/example_repeated_runs.py @@ -0,0 +1,53 @@ +"""Continue a GA from a checkpoint and inspect its saved generation numbers.""" + +import os +import tempfile + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return int(solution[0]) + + +def mutation_func(offspring, ga_instance): + return offspring + 10 + + +def main(): + ga_instance = pygad.GA(num_generations=2, + num_parents_mating=1, + fitness_func=fitness_func, + initial_population=[[0], [1]], + gene_type=int, + crossover_type=None, + mutation_type=mutation_func, + keep_parents=0, + keep_elitism=0, + save_best_solutions=True, + save_solutions=True, + suppress_warnings=True, + random_seed=7) + ga_instance.run() + + with tempfile.TemporaryDirectory() as directory: + filename = os.path.join(directory, 'repeated_runs') + ga_instance.save(filename) + resumed_instance = pygad.load(filename) + resumed_instance.run() + + print('Completed generations:', resumed_instance.generations_completed) + print('Best solution generation:', resumed_instance.best_solution_generation) + print('Saved best generation numbers:', resumed_instance.best_solutions_generations) + for generation, solution, fitness in zip(resumed_instance.best_solutions_generations, + resumed_instance.best_solutions, + resumed_instance.best_solutions_fitness): + print('Generation:', generation, 'Best solution:', solution, 'Fitness:', fitness) + # The final population of the first run and the starting population of + # the resumed run are both kept, with the same generation number (2). + assert resumed_instance.best_solutions_generations == [0, 1, 2, 2, 3, 4] + assert resumed_instance.best_solution_generation == 4 + + +if __name__ == '__main__': + main() diff --git a/examples/plots/example_plot_lifecycle.py b/examples/plots/example_plot_lifecycle.py new file mode 100644 index 00000000..28f08e1d --- /dev/null +++ b/examples/plots/example_plot_lifecycle.py @@ -0,0 +1,49 @@ +"""Configured lifecycle for a single-objective GA on the Sphere benchmark.""" + +import pygad +from pygad.benchmarks.classic import Sphere + +problem = Sphere(num_genes=5) + + +def report_generation(ga_instance): + """Report progress using fitness already calculated by the GA.""" + solution, fitness, solution_idx = ga_instance.best_solution( + pop_fitness=ga_instance.last_generation_fitness) + print(f"Generation {ga_instance.generations_completed}: best fitness = {fitness}") + + +ga_instance = pygad.GA(num_generations=50, + num_parents_mating=10, + fitness_func=problem, + sol_per_pop=20, + num_genes=problem.num_genes, + gene_type=[float, 3], + init_range_low=problem.bounds[0], + init_range_high=problem.bounds[1], + parent_selection_type="tournament", + K_tournament=3, + crossover_type="uniform", + crossover_probability=0.8, + mutation_type="adaptive", + mutation_probability=[0.2, 0.05], + keep_elitism=2, + on_generation=report_generation, + stop_criteria="saturate_10", + random_seed=42) + +# The configuration is enough to draw the chart. No fitness function +# or callback is called by plot_lifecycle(). SVG stays sharp when resized. +ga_instance.plot_lifecycle(title="PyGAD - Sphere Optimization", + save_dir="lifecycle.svg") + +ga_instance.run() + +# After run(), the chart also shows the known number of objectives +# and the fitness shape. show=False saves it without opening a window. +ga_instance.plot_lifecycle(title="PyGAD - Sphere Optimization", + save_dir="lifecycle_after_run.png", + show=False) + +# A compact view keeps operator and callback names and the control flow. +ga_instance.plot_lifecycle(show_parameters=False) diff --git a/pygad/_version.py b/pygad/_version.py index 46f67e7f..32a78190 100644 --- a/pygad/_version.py +++ b/pygad/_version.py @@ -1 +1 @@ -__version__ = "3.7.0" +__version__ = "3.8.0" diff --git a/pygad/helper/__init__.py b/pygad/helper/__init__.py index f905dd01..2fdad67f 100644 --- a/pygad/helper/__init__.py +++ b/pygad/helper/__init__.py @@ -1,4 +1,4 @@ from pygad.helper import unique from pygad.helper import misc -__version__ = "1.4.0" \ No newline at end of file +__version__ = "1.4.4" diff --git a/pygad/helper/misc.py b/pygad/helper/misc.py index c9125289..5adad37c 100644 --- a/pygad/helper/misc.py +++ b/pygad/helper/misc.py @@ -3,8 +3,8 @@ """ import numpy +import math import warnings -import random import pygad class Helper: @@ -306,93 +306,87 @@ def change_population_dtype_and_round(self, The same data cast (and rounded) to the right type. """ - population_new = numpy.array(population.copy(), dtype=object) - - # Forcing the iterable to have the data type assigned to the gene_type parameter. - if self.gene_type_single == True: - # Round the numbers first then change the data type. - # This solves issues with some data types such as numpy.float32. - if self.gene_type[1] is None: - pass + if self.gene_type_single: + return self._convert_gene_values(population, self.gene_type) + + # An object array keeps each value exact until its own type is + # applied. Group matching types and precisions to convert whole + # blocks instead of converting every scalar separately. + population = numpy.asarray(population, dtype=object) + population_new = numpy.empty(population.shape, dtype=object) + gene_columns_by_type = {} + for gene_index, gene_type in enumerate(self.gene_type): + gene_columns_by_type.setdefault(tuple(gene_type), []).append(gene_index) + for gene_type, gene_indices in gene_columns_by_type.items(): + values = self._convert_gene_values(population[:, gene_indices], gene_type) + dtype = gene_type[0] + if dtype in [int, float, object]: + population_new[:, gene_indices] = values.astype(object) else: - # This block is reached only for non-integer data types (i.e. float). - population_new = numpy.round(numpy.array(population_new, float), - self.gene_type[1]) - - population_new = numpy.array(population_new, - dtype=self.gene_type[0]) - else: - population = numpy.array(population.copy()) - population_new = numpy.zeros(shape=population.shape, - dtype=object) - for gene_idx in range(population.shape[1]): - # Round the numbers first then change the data type. - # This solves issues with some data types such as numpy.float32. - if self.gene_type[gene_idx][1] is None: - # Do not round. - population_new[:, gene_idx] = population[:, gene_idx] - else: - # This block is reached only for non-integer data types (i.e. float). - population_new[:, gene_idx] = numpy.round(numpy.array(population[:, gene_idx], float), - self.gene_type[gene_idx][1]) - # Once rounding is done, change the data type. - # population_new[:, gene_idx] = numpy.asarray(population_new[:, gene_idx], dtype=self.gene_type[gene_idx][0]) - # Use a for loop to maintain the data type of each individual gene. - for sol_idx in range(population.shape[0]): - population_new[sol_idx, gene_idx] = self.gene_type[gene_idx][0](population_new[sol_idx, gene_idx]) + # astype(object) alone turns NumPy scalars into Python + # numbers. Preserve explicitly requested NumPy types. + population_new[:, gene_indices] = numpy.frompyfunc(dtype, 1, 1)(values) return population_new - def change_gene_dtype_and_round(self, - gene_index, - gene_value): + def change_gene_dtype_and_round(self, gene_index, gene_value): """ - Cast and round one or more candidate values that all belong - to the same gene index. Useful when generating mutation - values for a specific gene. + Convert a scalar or an array of candidates using one gene's type + and precision. Return a scalar for a scalar input, or an array + with the input shape. The input is not modified. Parameters ---------- gene_index : int - Index of the gene whose dtype / precision should be used. + Index of the gene whose type and precision are applied. gene_value : numeric or iterable - Either a single value or a vector of values for that - gene. + A single value or an array of candidate values for this gene. Returns ------- - gene_value_new : numeric - The first (or only) value after casting and rounding. + numeric or numpy.ndarray + The converted scalar or array of candidates. """ + return self._convert_gene_values(gene_value, self.get_gene_dtype(gene_index)) - if self.gene_type_single == True: - dtype = self.gene_type[0] - if self.gene_type[1] is None: - # No rounding for this gene. Use the old gene value. - round_precision = None - else: - round_precision = self.gene_type[1] - else: - dtype = self.gene_type[gene_index][0] - if self.gene_type[gene_index][1] is None: - # No rounding for this gene. Use the old gene value. - round_precision = None - else: - round_precision = self.gene_type[gene_index][1] - - # Sometimes the values represent the gene_space when it is not nested (e.g. gene_space=range(10)) - # Copy it to avoid changing the original gene_space. - gene_value = [gene_value].copy() - - # Round the number before changing its data type to avoid precision loss for some data types like numpy.float32. - if round_precision is None: - pass + def _convert_gene_values(self, values, gene_type): + """ + Apply the shared conversion rule: round floating-point values + before casting to the requested type. Integer casts truncate + towards zero. None precision leaves values unrounded. NumPy's + rounding rule selects the nearest even value at halfway points. + """ + dtype, precision = gene_type + if precision is None: + if numpy.isscalar(values): + return values if dtype is object else dtype(values) + converted_values = numpy.array(values, dtype=dtype, copy=True) else: - gene_value = numpy.round(gene_value, round_precision) - - gene_value_new = numpy.asarray(gene_value, dtype=dtype) - gene_value_new = gene_value_new[0] - - return gene_value_new + rounded_values = self._round_gene_values(numpy.asarray(values, dtype=float), precision) + converted_values = numpy.asarray(rounded_values, dtype=dtype) + if converted_values.ndim == 0: + value = converted_values[()] + return value if dtype is object else dtype(value) + return converted_values + + def _round_gene_values(self, values, precision): + """ + Use NumPy's array rounding, preserving finite inputs when its + decimal scaling overflows. Python's scalar round handles those + uncommon values without the intermediate scaling operation. + """ + try: + with numpy.errstate(over='ignore', invalid='ignore', divide='ignore'): + rounded_values = numpy.round(values, precision) + except OverflowError: + # NumPy limits decimals to a C integer; Python round accepts + # the full integer precision supplied by the user. + return numpy.asarray(numpy.frompyfunc(lambda value: round(float(value), precision), 1, 1)(values), dtype=float) + invalid_results = numpy.isfinite(values) & ~numpy.isfinite(rounded_values) + if numpy.any(invalid_results): + rounded_values = numpy.asarray(rounded_values).copy() + rounded_values[invalid_results] = [round(float(value), precision) + for value in numpy.atleast_1d(values[invalid_results])] + return rounded_values def mutation_change_gene_dtype_and_round(self, random_value, @@ -425,15 +419,19 @@ def mutation_change_gene_dtype_and_round(self, """ if mutation_by_replacement: - # If the mutation_by_replacement attribute is True, then the random value replaces the current gene value. - gene_value = random_value + mutated_value = random_value else: - # If the mutation_by_replacement attribute is False, then the random value is added to the gene value. - gene_value = gene_value + random_value - - gene_value_new = self.change_gene_dtype_and_round(gene_index=gene_index, - gene_value=gene_value) - return gene_value_new + # NumPy can add narrow scalars in their original dtype, losing + # precision or overflowing before the final cast. Python + # numeric values keep integer addition exact and float + # addition in the working precision used by the converter. + gene_value = gene_value.item() if isinstance(gene_value, numpy.generic) else gene_value + if numpy.ndim(random_value) == 0: + random_value = random_value.item() if isinstance(random_value, (numpy.generic, numpy.ndarray)) else random_value + mutated_value = gene_value + random_value + else: + mutated_value = gene_value + numpy.asarray(random_value, dtype=object) + return self.change_gene_dtype_and_round(gene_index, mutated_value) def validate_gene_constraint_callable_output(self, selected_values, @@ -472,7 +470,8 @@ def validate_gene_constraint_callable_output(self, def filter_gene_values_by_constraint(self, values, solution, - gene_idx): + gene_idx, + warn=True): """ Pass a list of candidate values through the user-supplied gene constraint callable and return the subset that satisfies @@ -487,6 +486,9 @@ def filter_gene_values_by_constraint(self, callable so it can look at the other genes if needed. gene_idx : int Index of the gene inside ``solution``. + warn : bool + Warn if no candidate satisfies the constraint. Repair uses + False while exploring alternatives and warns about its final result. Returns ------- @@ -501,7 +503,7 @@ def filter_gene_values_by_constraint(self, returns a result that is not a subset of ``values``. """ - if self.gene_constraint and self.gene_constraint[gene_idx]: + if self.gene_constraint and self.gene_constraint[gene_idx] is not None: pass else: raise Exception(f"Either the gene at index {gene_idx} is not assigned a callable/function or the gene_constraint itself is not used.") @@ -522,7 +524,7 @@ def filter_gene_values_by_constraint(self, pass else: # No value found for the current gene that satisfies the constraint. - if not self.suppress_warnings: warnings.warn(f"Failed to find a value that satisfies its gene constraint for the gene at index {gene_idx} with value {solution[gene_idx]} at generation {self.generations_completed}.") + if warn and not self.suppress_warnings: warnings.warn(f"Failed to find a value that satisfies its gene constraint for the gene at index {gene_idx} with value {solution[gene_idx]} at generation {getattr(self, 'generations_completed', 0)}.") return None return filtered_values @@ -581,7 +583,7 @@ def get_random_mutation_range(self, gene_index): else: range_min = self.random_mutation_min_val[gene_index] range_max = self.random_mutation_max_val[gene_index] - return range_min, range_max + return tuple(sorted([range_min, range_max])) def get_initial_population_range(self, gene_index): """ @@ -611,186 +613,542 @@ def get_initial_population_range(self, gene_index): else: range_min = self.init_range_low[gene_index] range_max = self.init_range_high[gene_index] - return range_min, range_max + return tuple(sorted([range_min, range_max])) - def generate_gene_value_from_space(self, - gene_idx, - mutation_by_replacement, - solution=None, - gene_value=None, - sample_size=1): + def get_bounded_operator_gene_range(self, gene_index): + """Return SBX and polynomial bounds from the gene space or initialization range.""" + if self.gene_space is None: + return self.get_initial_population_range(gene_index) + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + return self.get_initial_population_range(gene_index) + if isinstance(space, dict) and 'step' not in space: + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + return self._continuous_gene_space_integer_bounds(space) + return space['low'], space['high'] + if isinstance(space, range) or isinstance(space, dict): + count = self._finite_gene_space_length(space) + values = [self._finite_gene_space_value(space, 0), self._finite_gene_space_value(space, count - 1)] + elif isinstance(space, (list, tuple, numpy.ndarray)): + values = [value for value in space if value is not None] + if any(value is None for value in space): + values.extend(self.get_initial_population_range(gene_index)) + else: + values = [space] + values = self.change_gene_dtype_and_round(gene_index, values) + return min(values), max(values) + + def convert_bounded_operator_gene_value(self, gene_index, value): + """Convert a generated value within its bounds and select an allowed space value.""" + lower, upper = self.get_bounded_operator_gene_range(gene_index) + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + dtype = self.get_gene_dtype(gene_index)[0] + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): + value = value.item() if isinstance(value, numpy.generic) else value + type_limits = numpy.iinfo(dtype) + first = max(math.ceil(lower), int(type_limits.min)) + last = min(math.floor(upper), int(type_limits.max)) + if first > last: + raise ValueError(f'The operator bounds have no value representable by gene_type for the gene at index {gene_index}.') + # Real-coded arithmetic may round an integer type's maximum + # up to the next power of two. Clip as Python integers before + # casting so a valid bounded proposal does not overflow. + value = min(max(math.trunc(value), first), last) + converted = self.change_gene_dtype_and_round(gene_index, value) + if not lower <= float(converted) <= upper: + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + converted = min(max(converted, math.ceil(lower)), math.floor(upper)) + else: + converted = self._convert_initial_population_range_values(gene_index, lower, upper, [value])[0] + if self.gene_space is not None: + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is not None and not self.is_bounded_operator_gene_value_in_space(gene_index, converted): + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + low = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + position = math.floor((float(converted) - low) / step) + indices = {max(0, min(count - 1, position + offset)) for offset in range(-2, 4)} + candidates = [self._finite_gene_space_value(space, index) for index in sorted(indices)] + candidates = self.change_gene_dtype_and_round(gene_index, candidates) + elif isinstance(space, dict): + if numpy.issubdtype(numpy.dtype(self.get_gene_dtype(gene_index)[0]), numpy.integer): + # Integer conversion of a continuous space truncates + # its endpoints, including a fixed fractional value. + first, last = self._continuous_gene_space_integer_bounds(space) + converted = min(max(converted, first), last) + else: + converted = self._convert_initial_population_range_values( + gene_index, space['low'], space['high'], [value])[0] + candidates = [converted] + else: + candidates = self.get_initial_population_gene_candidates(gene_index, self.sample_size, all_integer_values=False) + converted = min(candidates, key=lambda candidate: abs(float(candidate) - float(converted))) + return converted if dtype is object else dtype(converted) + + def is_bounded_operator_gene_value_in_space(self, gene_index, value): + """Check a bounded operator against the original space, including None entries.""" + if self.gene_space is None: + return True + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + return True + if isinstance(space, (list, tuple, numpy.ndarray)) and any(item is None for item in space): + lower, upper = self.get_initial_population_range(gene_index) + if lower <= float(value) <= upper: + return True + return self.is_gene_value_in_space(gene_index, value, value) + + def prepare_changed_operator_solution(self, original_solution, proposed_solution, build_initial_pop=False): + """Return a valid converted change, or None when it violates a destination rule. + + Validate complete solutions after conversion and duplicate repair so + dependent constraints can see every changed gene. The caller keeps the + original solution when its candidate cannot be accepted. + """ + candidate = self.change_population_dtype_and_round([proposed_solution])[0] + if not self.allow_duplicate_genes: + candidate, _, remaining = self.solve_duplicate_genes(candidate, build_initial_pop=build_initial_pop, warn=False) + if remaining > len(self.get_duplicate_gene_indices(original_solution)): + return None + if self.gene_space is not None: + for index, value in enumerate(candidate): + valid = (self.is_bounded_operator_gene_value_in_space(index, value) if build_initial_pop else + self.is_gene_value_in_space(index, value, original_solution[index])) + if not valid: + return None + if not self.solution_satisfies_gene_constraints(candidate): + return None + return candidate + + def prepare_bounded_operator_solution(self, original_solution, proposed_solution): + """Apply constraints to a bounded proposal and retain a valid fallback if needed.""" + candidate = self.change_population_dtype_and_round([proposed_solution])[0] + if self.gene_constraint: + candidate = self.apply_initial_population_gene_constraints([candidate], warn=False)[0] + prepared = self.prepare_changed_operator_solution(original_solution, candidate, build_initial_pop=True) + return original_solution.copy() if prepared is None else prepared + + def _continuous_gene_space_integer_bounds(self, space): + """Find the integer endpoints produced by truncating a continuous space. + + Include integers reached from fractional endpoints, while excluding + a positive integral upper bound. Equal bounds describe one fixed value. + """ + lower, upper = space['low'], space['high'] + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + first = math.trunc(lower) + if lower == upper: + last = first + elif upper > 0: + last = math.ceil(upper) - 1 + else: + last = math.trunc(upper) + return first, last + + def _finite_gene_space_length(self, space): + """Count a range or stepped dictionary without allocating its values.""" + if isinstance(space, range): + lower, upper, step = space.start, space.stop, space.step + else: + lower, upper, step = space['low'], space['high'], space['step'] + # Integer arithmetic keeps large discrete bounds exact. + if all(isinstance(value, (int, numpy.integer)) for value in (lower, upper, step)): + lower, upper, step = int(lower), int(upper), int(step) + return max(0, (abs(upper - lower) + abs(step) - 1) // abs(step)) if (upper - lower) * step > 0 else 0 + return max(0, math.ceil((upper - lower) / step)) + + def _finite_gene_space_value(self, space, index): + """Return one indexed value from a lazy finite numeric space.""" + if isinstance(space, range): + return space.start + index * space.step + lower, step = space['low'], space['step'] + if index == 0: + return lower + if isinstance(lower, int) and isinstance(step, int) and isinstance(space['high'], int): + return lower + index * step + # Match numpy.arange's stored floating-point increment, which + # can differ slightly from the supplied step after adding low. + lower = float(lower) + stored_step = (lower + float(step)) - lower + return lower + index * stored_step + + def _sample_finite_gene_space(self, space, sample_size, without_replacement=False): + """Sample finite-space indices, enumerating only when explicitly requested. + + None requests the full domain for exhaustive duplicate repair. Regular + generation samples indices directly, including domains larger than a + NumPy integer bound or Python's maximum sequence length. + """ + count = self._finite_gene_space_length(space) + if count == 0: + return numpy.empty(0, dtype=object) + if sample_size is None or (without_replacement and sample_size >= count): + indices = range(count) + elif without_replacement: + # random.sample requires len(range(count)) to fit a platform + # integer. Small samples from larger domains use rejection. + if count <= numpy.iinfo(numpy.intp).max: + indices = self.python_random_generator.sample(range(count), sample_size) + else: + indices, selected = [], set() + while len(indices) < sample_size: + index = self.python_random_generator.randrange(count) + if index not in selected: + selected.add(index) + indices.append(index) + else: + indices = [self.python_random_generator.randrange(count) for _ in range(sample_size)] + return numpy.asarray([self._finite_gene_space_value(space, index) for index in indices], dtype=object) + + def sample_initial_population_gene_values(self, gene_index, num_values): + """ + Sample a column using its own space, range, type, and precision. + Finite spaces are converted once before selection. A None entry + draws fresh values from the initialization range. Integer ranges + are sampled directly without allocating every possible value. + """ + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + lower, upper = self.get_initial_population_range(gene_index) + return self._initial_population_range_values(gene_index, lower, upper, num_values) + if type(space) is dict and 'step' not in space: + return self._initial_population_range_values( + gene_index, space['low'], space['high'], num_values) + if type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + explicit_values = [value for value in space if value is not None] + explicit_values = numpy.unique(self.change_gene_dtype_and_round(gene_index, explicit_values)) + # None is one choice in the space, rather than a fixed value + # sampled once and reused throughout the column. + selected_indices = self.numpy_random_generator.randint(0, len(explicit_values) + 1, size=num_values) + values = numpy.empty(num_values, dtype=object) + random_positions = selected_indices == len(explicit_values) + values[~random_positions] = explicit_values[selected_indices[~random_positions]] + if numpy.any(random_positions): + lower, upper = self.get_initial_population_range(gene_index) + values[random_positions] = self._initial_population_range_values( + gene_index, lower, upper, int(numpy.sum(random_positions))) + return values + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + values = self._sample_finite_gene_space(space, num_values) + return self.change_gene_dtype_and_round(gene_index, values) + candidates = self.get_gene_space_values(gene_index) + if len(candidates) == 0: + raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_index}.") + return self.numpy_random_generator.choice(candidates, size=num_values, replace=True) + + def get_initial_population_gene_candidates(self, gene_index, sample_size, + all_integer_values=True): + """ + Return replacement candidates for initialization constraints and + duplicates. Finite domains are considered in full; continuous + domains contribute sample_size values. When all_integer_values + is False, large ranges and stepped dictionaries are also sampled. Candidates + already have the configured gene type and precision. + """ + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + if space is None: + lower, upper = self.get_initial_population_range(gene_index) + return self._initial_population_range_values( + gene_index, lower, upper, sample_size, + all_integer_values=all_integer_values or abs(math.ceil(upper) - math.ceil(lower)) <= sample_size) + if type(space) is dict and 'step' not in space: + return self._initial_population_range_values( + gene_index, space['low'], space['high'], sample_size, + all_integer_values=all_integer_values or abs(math.ceil(space['high']) - math.ceil(space['low'])) <= sample_size) + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + return self.get_gene_space_values(gene_index, sample_size=sample_size, + all_values=all_integer_values) + if type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + explicit_values = [value for value in space if value is not None] + explicit_values = self.change_gene_dtype_and_round(gene_index, explicit_values) + lower, upper = self.get_initial_population_range(gene_index) + random_values = self._initial_population_range_values( + gene_index, lower, upper, sample_size, + all_integer_values=all_integer_values or abs(math.ceil(upper) - math.ceil(lower)) <= sample_size) + return numpy.unique(numpy.concatenate([explicit_values, random_values])) + return self.get_gene_space_values(gene_index) + + def _initial_population_range_values(self, gene_index, lower, upper, num_values, + all_integer_values=False): """ - Generate one or more candidate values for the gene from its - ``gene_space`` entry. Handles flat spaces, nested spaces, - ``range`` objects, and ``{low, high, step}`` dictionaries. + Sample converted values inside an initialization interval. + The smaller bound is included and the larger bound is excluded. + Equal bounds describe a fixed value. Raise a descriptive error + if the gene type and precision cannot represent any valid value. + """ + lower, upper = sorted([lower, upper]) + dtype = self.get_gene_dtype(gene_index)[0] + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): + first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) + if first_value > last_value: + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type for the gene at index {gene_index}.") + if all_integer_values: + return numpy.arange(first_value, last_value + 1, dtype=dtype) + # RandomState interprets the Python int type as C long on + # Windows. Use NumPy's resolved dtype to match the population. + return self.numpy_random_generator.randint(first_value, last_value + 1, + size=num_values, dtype=numpy.dtype(dtype).type) + + values = self.numpy_random_generator.uniform(lower, upper, size=num_values) + return self._convert_initial_population_range_values(gene_index, lower, upper, values) + + def _initial_population_integer_bounds(self, gene_index, lower, upper): + """Return the first and last representable integers in an interval.""" + # Python math functions may coerce NumPy integers to floats. + # Use their exact Python values before computing integer bounds. + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + lower, upper = sorted([lower, upper]) + type_limits = numpy.iinfo(self.get_gene_dtype(gene_index)[0]) + first_value = max(math.ceil(lower), int(type_limits.min)) + last_value = min(math.ceil(upper) - 1, int(type_limits.max)) + if lower == upper and lower == first_value: + last_value = first_value + return first_value, last_value + + def _initial_population_range_snapshot(self, gene_index, lower, upper, sample_size): + """Create an inspection sample without allocating a range or drawing random values.""" + dtype = self.get_gene_dtype(gene_index)[0] + if numpy.issubdtype(numpy.dtype(dtype), numpy.integer): + first_value, last_value = self._initial_population_integer_bounds(gene_index, lower, upper) + count = min(sample_size, last_value - first_value + 1) + if count <= 0: + return numpy.empty(0, dtype=dtype) + # Use integer arithmetic so large bounds are not coerced to floats. + values = [first_value + (last_value - first_value) * index // max(1, count - 1) + for index in range(count)] + else: + values = numpy.linspace(lower, upper, num=sample_size, endpoint=False) + return self.change_gene_dtype_and_round(gene_index, values) + + def _convert_initial_population_range_values(self, gene_index, lower, upper, values): + """Convert floating range samples without crossing their bounds.""" + # Compare stored values as Python numbers. NumPy scalar promotion + # can otherwise cast a Python bound to the narrower gene dtype. + lower = lower.item() if isinstance(lower, numpy.generic) else lower + upper = upper.item() if isinstance(upper, numpy.generic) else upper + dtype, precision = self.get_gene_dtype(gene_index) + if dtype is object: + dtype = float + # Rounding or a narrow NumPy dtype can reach the excluded upper + # bound. Keep sampled values within the representable interval. + first_value = self.change_gene_dtype_and_round(gene_index, lower) + last_value = self.change_gene_dtype_and_round(gene_index, upper) + if lower != upper: + # At 324 decimal places, a decimal step is smaller than the + # smallest positive float64 value used during rounding. + if precision is None or precision >= 324: + if float(first_value) < lower: + first_value = numpy.nextafter(first_value, dtype(numpy.inf), dtype=dtype) + if float(last_value) >= upper: + last_value = numpy.nextafter(last_value, dtype(-numpy.inf), dtype=dtype) + else: + if float(first_value) < lower or float(last_value) >= upper: + try: + precision_step = 10.0 ** -precision + except OverflowError: + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type and its precision for the gene at index {gene_index}.") from None + if float(first_value) < lower: + first_unrounded_value = dtype(lower) + if float(first_unrounded_value) < lower: + first_unrounded_value = numpy.nextafter(first_unrounded_value, dtype(numpy.inf), dtype=dtype) + decimal_units = float(first_unrounded_value) / precision_step + rounded_bound = numpy.ceil(decimal_units) * precision_step if numpy.isfinite(decimal_units) else first_unrounded_value + first_value = self.change_gene_dtype_and_round(gene_index, rounded_bound) + if float(last_value) >= upper: + last_unrounded_value = dtype(upper) + if float(last_unrounded_value) >= upper: + last_unrounded_value = numpy.nextafter(last_unrounded_value, dtype(-numpy.inf), dtype=dtype) + decimal_units = float(last_unrounded_value) / precision_step + rounded_bound = numpy.floor(decimal_units) * precision_step if numpy.isfinite(decimal_units) else last_unrounded_value + last_value = self.change_gene_dtype_and_round(gene_index, rounded_bound) + if (not numpy.isfinite(first_value) or not numpy.isfinite(last_value) + or float(first_value) < lower or first_value > last_value + or (lower != upper and float(last_value) >= upper) + or (lower == upper and float(first_value) != lower)): + raise ValueError(f"The initialization range [{lower}, {upper}) has no value representable by gene_type and its precision for the gene at index {gene_index}.") + values = self.change_gene_dtype_and_round(gene_index, values) + return numpy.clip(values, first_value, last_value) + + def get_gene_space_values(self, gene_idx, gene_value=None, + mutation_by_replacement=True, sample_size=100, + range_min=None, range_max=None, all_values=True): + """ + Generate converted candidates for one gene from its original space. + Lists, tuples, arrays, ranges, fixed values, and stepped dictionaries + keep all their finite values. Continuous entries are sampled, and + None entries draw fresh values from the appropriate per-gene range. Parameters ---------- gene_idx : int - Index of the gene inside the solution. - mutation_by_replacement : bool - If True (mutation by replacement) the generated value is - used as-is. If False the generated value is added to - ``gene_value``. Set to True when building the initial - population. - solution : iterable or None - The solution the gene belongs to. When provided and - ``sample_size`` is 1, the helper tries to pick a value - that does not duplicate any existing gene. + Index of the gene whose space and type are used. gene_value : numeric or None - The current gene value. Required when applying mutation - with ``mutation_by_replacement=False`` so the random - value can be added on top. - sample_size : int - Number of candidate values to generate. ``1`` returns a - single number; larger values return an array; ``None`` - keeps the full integer range or a single float value. + Current value, or None when building the initial population. + mutation_by_replacement : bool + Replace the gene or add an offset for a None space entry. + Explicit space values always replace the gene. + sample_size : int or None + Number of samples for continuous entries. None uses + ``self.sample_size``. With all_values=False, this also limits + samples from ranges, stepped dictionaries, and integer intervals. + range_min, range_max : numeric or None + Optional bounds for None entries when unpacking a space. + all_values : bool + If True, return complete finite domains for duplicate repair. + If False, sample lazy finite domains without materializing them. Returns ------- - value : numeric or numpy.ndarray - A single value when ``sample_size=1``; otherwise an - array of up to ``sample_size`` values. + values : numpy.ndarray + Distinct candidates after conversion and rounding. """ - + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + dtype = self.get_gene_dtype(gene_idx) + if sample_size is None: + sample_size = self.sample_size if gene_value is None: - # Use the initial population range. - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) + if range_min is None: + range_min, range_max = self.get_initial_population_range(gene_idx) + mutation_by_replacement = True else: - # Use the mutation range. - range_min, range_max = self.get_random_mutation_range(gene_idx) - - if self.gene_space_nested: - # Returning the current gene space from the 'gene_space' attribute. - # It is used to determine the way of selecting the next gene value: - # 1) List/NumPy Array: Whether it has only one value, multiple values, or one of its values is None. - # 2) Fixed Numeric Value - # 3) None - # 4) Dict: Whether the dict has the key `step` or not. - if type(self.gene_space[gene_idx]) in [numpy.ndarray, list]: - # Get the gene space from the `gene_space_unpacked` property because it undergoes data type change and rounded. - curr_gene_space = self.gene_space_unpacked[gene_idx].copy() - elif type(self.gene_space[gene_idx]) in pygad.GA.supported_int_float_types: - # Get the gene space from the `gene_space_unpacked` property because it undergoes data type change and rounded. - curr_gene_space = self.gene_space_unpacked[gene_idx] - else: - curr_gene_space = self.gene_space[gene_idx] - - if type(curr_gene_space) in pygad.GA.supported_int_float_types: - # If the gene space is simply a single numeric value (e.g. 5), use it as the new gene value. - value_from_space = curr_gene_space - elif curr_gene_space is None: - # If the gene space is None, apply mutation by adding a random value between the range defined by the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. - rand_val = numpy.random.uniform(low=range_min, - high=range_max, - size=sample_size) - if mutation_by_replacement: - value_from_space = rand_val - else: - value_from_space = gene_value + rand_val - elif type(curr_gene_space) is dict: - # Selecting a value randomly from the current gene's space in the 'gene_space' attribute. - # The gene's space of type dict specifies the lower and upper limits of a gene. - if 'step' in curr_gene_space.keys(): - # When the `size` parameter is used, the numpy.random.choice() and numpy.random.uniform() functions return a NumPy array as the output even if the array has a single value (i.e. size=1). - # We have to return the output at index 0 to force a numeric value to be returned not an object of type numpy.ndarray. - # If numpy.ndarray is returned, then it will cause an issue later while using the set() function. - # Randomly select a value from a discrete range. - value_from_space = numpy.random.choice(numpy.arange(start=curr_gene_space['low'], - stop=curr_gene_space['high'], - step=curr_gene_space['step']), - size=sample_size) - else: - value_from_space = numpy.random.uniform(low=curr_gene_space['low'], - high=curr_gene_space['high'], - size=sample_size) + if range_min is None: + range_min, range_max = self.get_random_mutation_range(gene_idx) + + if space is None: + values = self.generate_gene_value_randomly( + range_min, range_max, gene_value, gene_idx, + mutation_by_replacement, + sample_size=None if all_values and numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) + elif type(space) is dict: + if 'step' in space: + values = self._sample_finite_gene_space(space, None if all_values else sample_size, + without_replacement=True) + elif numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): + # A continuous dictionary with fractional bounds can cast + # to an integer near either end, not just values on a grid + # starting at low. Include every representable integer. + first_value, last_value = self._continuous_gene_space_integer_bounds(space) + values = self._sample_finite_gene_space( + {'low': first_value, 'high': last_value + 1, 'step': 1}, + None if all_values else sample_size, without_replacement=True) else: - # Selecting a value randomly from the current gene's space in the 'gene_space' attribute. - # If the gene space has only 1 value, then select it. The old and new values of the gene are identical. - if len(curr_gene_space) == 1: - value_from_space = curr_gene_space - else: - # Change the data type and round the generated values. - curr_gene_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=curr_gene_space) - - if gene_value is None: - # Just generate the value(s) without being added to the gene value specially when initializing the population. - value_from_space = curr_gene_space - else: - # If the gene space has more than 1 value, then select a new one that is different from the current value. - # To avoid selecting the current gene value again, remove it from the current gene space and do the selection. - value_from_space = list(set(curr_gene_space) - set([gene_value])) + values = self.numpy_random_generator.uniform(space['low'], space['high'], size=sample_size) + elif type(space) in pygad.GA.supported_int_float_types: + values = [space] + elif isinstance(space, range): + values = self._sample_finite_gene_space(space, None if all_values else sample_size, + without_replacement=True) else: - # Selecting a value randomly from the global gene space in the 'gene_space' attribute. - # The gene's space of type dict specifies the lower and upper limits of a gene. - if type(self.gene_space) is dict: - # When the gene_space is assigned a dict object, then it specifies the lower and upper limits of all genes in the space. - if 'step' in self.gene_space.keys(): - value_from_space = numpy.random.choice(numpy.arange(start=self.gene_space['low'], - stop=self.gene_space['high'], - step=self.gene_space['step']), - size=sample_size) - else: - value_from_space = numpy.random.uniform(low=self.gene_space['low'], - high=self.gene_space['high'], - size=sample_size) - else: - curr_gene_space = list(self.gene_space).copy() - for idx in range(len(curr_gene_space)): - if curr_gene_space[idx] is None: - curr_gene_space[idx] = numpy.random.uniform(low=range_min, - high=range_max) - curr_gene_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=curr_gene_space) - - if gene_value is None: - # Just generate the value(s) without being added to the gene value specially when initializing the population. - value_from_space = curr_gene_space - else: - # If the space type is not of type dict, then a value is randomly selected from the gene_space attribute. - # To avoid selecting the current gene value again, remove it from the current gene space and do the selection. - value_from_space = list(set(curr_gene_space) - set([gene_value])) - - if len(value_from_space) == 0: + values = [value for value in space if value is not None] + if any(value is None for value in space): + random_values = self.generate_gene_value_randomly( + range_min, range_max, gene_value, gene_idx, + True, + sample_size=None if all_values and numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) + values.extend(numpy.atleast_1d(random_values)) + + # Convert directly from the input values so mixed finite spaces + # do not promote large integers to a shared floating-point type. + values = self.change_gene_dtype_and_round(gene_idx, values) + if type(space) is dict and 'step' not in space and not numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): + # Rounding may reach the excluded upper bound. Such a value + # cannot be selected from this continuous space. + compared_values = values.astype(float) + values = values[(compared_values >= space['low']) & ((compared_values < space['high']) if space['low'] != space['high'] else compared_values == space['high'])] + if len(values) == 0: + # A single sample can round to the upper bound. Use a + # representable in-range value instead of failing randomly. + values = self._convert_initial_population_range_values( + gene_idx, space['low'], space['high'], [space['low']]) + return numpy.unique(values) + + def is_gene_value_in_space(self, gene_idx, gene_value, current_gene_value): + """ + Check a prospective swap against the destination's original space. + Continuous and None entries use their bounds rather than membership + in an inspection sample. Finite entries use converted values. + """ + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + dtype = self.get_gene_dtype(gene_idx) + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + lower = space.start if isinstance(space, range) else space['low'] + step = space.step if isinstance(space, range) else space['step'] + value = self._gene_value_key(gene_value) + position = math.floor((value - lower) / step) if not isinstance(value, int) or not isinstance(step, int) else (value - lower) // step + indices = {max(0, min(count - 1, position + offset)) for offset in range(-2, 4)} + return any(self._gene_value_key(self.change_gene_dtype_and_round(gene_idx, self._finite_gene_space_value(space, index))) == value + for index in indices) if count else False + if type(space) is dict: + if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer): + first, last = self._continuous_gene_space_integer_bounds(space) + return first <= self._gene_value_key(gene_value) <= last + value = float(gene_value) + return space['low'] <= value < space['high'] if space['low'] != space['high'] else value == space['low'] + has_none = space is None + if type(space) in [list, tuple, numpy.ndarray, range]: + explicit_values = [value for value in space if value is not None] + explicit_values = self.change_gene_dtype_and_round(gene_idx, explicit_values) + if self._gene_value_key(gene_value) in {self._gene_value_key(value) for value in explicit_values}: + return True + has_none = any(value is None for value in space) + if has_none: + range_min, range_max = self.get_random_mutation_range(gene_idx) + replacement = self.mutation_by_replacement if space is None else True + if not replacement: + range_min += self._gene_value_key(current_gene_value) + range_max += self._gene_value_key(current_gene_value) + lower, upper = sorted([range_min, range_max]) + lower = self.change_gene_dtype_and_round(gene_idx, lower) + upper = self.change_gene_dtype_and_round(gene_idx, upper) + return lower <= gene_value <= upper + return gene_value in self.get_gene_space_values(gene_idx, current_gene_value) + + def generate_gene_value_from_space(self, gene_idx, mutation_by_replacement, + solution=None, gene_value=None, + sample_size=1): + """ + Generate values from the gene's space using its type and precision. + A single candidate is returned when sample_size=1; otherwise an + array is returned. Finite spaces are considered in full, while + continuous and None entries use sample_size random candidates. + With allow_duplicate_genes=False, single-value selection prefers + an unused value. If no alternative exists, keep the current value. + """ + space = self.gene_space[gene_idx] if self.gene_space_nested else self.gene_space + if space is None and sample_size == 1: + # Traditional mutation of a None entry draws a continuous + # offset before conversion. Casting the offset to an integer + # first would bias additive mutation toward negative changes. if gene_value is None: - raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_idx}.") - else: - # After removing the current gene value from the space, there are no more values. - # Then keep the current gene value. - value_from_space = gene_value - if sample_size > 1: - value_from_space = numpy.array([gene_value]) - elif sample_size == 1: - if self.allow_duplicate_genes == True: - # Select a value randomly from the current gene space. - value_from_space = random.choice(value_from_space) + range_min, range_max = self.get_initial_population_range(gene_idx) + mutation_by_replacement = True else: - # We must check if the selected value will respect the allow_duplicate_genes parameter. - # Instead of selecting a value randomly, we have to select a value that will be unique if allow_duplicate_genes=False. - # Only select a value from the current gene space that is, hopefully, unique. - value_from_space = self.select_unique_value(gene_values=value_from_space, - solution=solution, - gene_index=gene_idx) - - # The gene space might be [None, 1, 7]. - # It might happen that the value None is selected. - # In this case, generate a random value out of the mutation range. - if value_from_space is None: - value_from_space = numpy.random.uniform(low=range_min, - high=range_max, - size=sample_size) + range_min, range_max = self.get_random_mutation_range(gene_idx) + random_value = self.numpy_random_generator.uniform(range_min, range_max) + values = numpy.atleast_1d(self.mutation_change_gene_dtype_and_round( + random_value, gene_idx, gene_value, mutation_by_replacement)) else: - value_from_space = numpy.array(value_from_space) - - # Change the data type and round the generated values. - # It has to be called here for all the missed cases. - value_from_space = self.change_gene_dtype_and_round(gene_index=gene_idx, - gene_value=value_from_space) - if sample_size == 1 and type(value_from_space) not in pygad.GA.supported_int_float_types: - value_from_space = value_from_space[0] - - return value_from_space + values = self.get_gene_space_values(gene_idx, gene_value, + mutation_by_replacement, sample_size, + all_values=not self.allow_duplicate_genes) + if gene_value is not None: + alternatives = values[values != gene_value] + if len(alternatives): + values = alternatives + else: + values = numpy.atleast_1d(gene_value) + if len(values) == 0: + raise ValueError(f"There are no values to select from the gene_space for the gene at index {gene_idx}.") + if sample_size == 1: + if self.allow_duplicate_genes or solution is None: + return self.python_random_generator.choice(values) + return self.select_unique_value(values, solution, gene_idx) + return values def generate_gene_value_randomly(self, range_min, @@ -803,7 +1161,7 @@ def generate_gene_value_randomly(self, """ Generate one or more candidate values for the gene by drawing from the random range ``[range_min, range_max)``. For integer - gene types the helper iterates over the discrete values; for + gene types the helper samples discrete indices directly; for float types it samples uniformly. Parameters @@ -836,38 +1194,28 @@ def generate_gene_value_randomly(self, array of unique values. """ + if step == 0: + raise ValueError('step must be non-zero when generating gene values.') + if step > 0: + range_min, range_max = sorted([range_min, range_max]) gene_type = self.get_gene_dtype(gene_index=gene_idx) - if gene_type[0] in pygad.GA.supported_int_types: - random_value = numpy.asarray(numpy.arange(range_min, - range_max, - step=step), - dtype=gene_type[0]) - if sample_size is None: - # Keep all the values. - pass + if numpy.issubdtype(numpy.dtype(gene_type[0]), numpy.integer): + if range_min == range_max: + random_value = numpy.asarray([range_min], dtype=object) else: - if sample_size >= len(random_value): - # Number of values is larger than or equal to the number of elements in random_value. - # Makes no sense to create a larger sample out of the population because it just creates redundant values. - pass - else: - # Set replace=True to avoid selecting the same value more than once. - random_value = numpy.random.choice(random_value, - size=sample_size, - replace=False) + random_value = self._sample_finite_gene_space( + {'low': range_min, 'high': range_max, 'step': step}, sample_size, + without_replacement=True) else: # Generating a random value. - random_value = numpy.asarray(numpy.random.uniform(low=range_min, + random_value = numpy.asarray(self.numpy_random_generator.uniform(low=range_min, high=range_max, - size=sample_size), + size=1 if sample_size is None else sample_size), dtype=object) - # Change the random mutation value data type. - for idx, val in enumerate(random_value): - random_value[idx] = self.mutation_change_gene_dtype_and_round(random_value[idx], - gene_idx, - gene_value, - mutation_by_replacement=mutation_by_replacement) + # Apply the offset and convert the entire candidate array once. + random_value = self.mutation_change_gene_dtype_and_round( + random_value, gene_idx, gene_value, mutation_by_replacement) # Rounding different values could return the same value multiple times. # For example, 2.8 and 2.7 will be 3.0. @@ -991,7 +1339,7 @@ def get_valid_gene_constraint_values(self, sample_size=sample_size, step=step) # It returns None if no value found that satisfies the constraint. - values_filtered = self.filter_gene_values_by_constraint(values=values, + values_filtered = self.filter_gene_values_by_constraint(values=numpy.atleast_1d(values), solution=solution, gene_idx=gene_idx) return values_filtered diff --git a/pygad/helper/unique.py b/pygad/helper/unique.py index 435cc72f..c44db1cc 100644 --- a/pygad/helper/unique.py +++ b/pygad/helper/unique.py @@ -2,608 +2,405 @@ The pygad.helper.unique module has helper methods to solve duplicate genes and make sure every gene is unique. """ +from collections import deque import numpy import warnings -import random import pygad -class Unique: - - def solve_duplicate_genes_randomly(self, - solution, - min_val, - max_val, - mutation_by_replacement, - gene_type, - sample_size=100): - """ - Resolves duplicates in a solution by randomly selecting new values for the duplicate genes. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - min_val (int): The minimum value of the range to sample a number randomly. - max_val (int): The maximum value of the range to sample a number randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., int, float). - sample_size (int): The maximum number of random values to generate to find a unique value. - - Returns: - tuple: - list: The updated solution after attempting to resolve duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. - """ - - new_solution = solution.copy() - - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - num_unsolved_duplicates = 0 - if len(not_unique_indices) > 0: - for duplicate_index in not_unique_indices: - dtype = self.get_gene_dtype(gene_index=duplicate_index) - - if type(min_val) in self.supported_int_float_types: - min_val_gene = min_val - max_val_gene = max_val - else: - min_val_gene = min_val[duplicate_index] - max_val_gene = max_val[duplicate_index] - - if dtype[0] in pygad.GA.supported_int_types: - temp_val = self.unique_int_gene_from_range(solution=new_solution, - gene_index=duplicate_index, - min_val=min_val_gene, - max_val=max_val_gene, - mutation_by_replacement=mutation_by_replacement, - gene_type=gene_type) - else: - temp_val = self.unique_float_gene_from_range(solution=new_solution, - gene_index=duplicate_index, - min_val=min_val_gene, - max_val=max_val_gene, - mutation_by_replacement=mutation_by_replacement, - gene_type=gene_type, - sample_size=sample_size) - - if temp_val in new_solution: - num_unsolved_duplicates = num_unsolved_duplicates + 1 - if not self.suppress_warnings: warnings.warn(f"Failed to find a unique value for gene with index {duplicate_index} whose value is {solution[duplicate_index]} at generation {self.generations_completed}. Consider adding more values in the gene space or use a wider range for initial population or random mutation.") - else: - # Unique gene value found. - new_solution[duplicate_index] = temp_val - # Update the list of duplicate indices after each iteration. - _, unique_gene_indices = numpy.unique(new_solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - # self.logger.info("not_unique_indices INSIDE", not_unique_indices) - - return new_solution, not_unique_indices, num_unsolved_duplicates - - def solve_duplicate_genes_by_space(self, - solution, - gene_type, - mutation_by_replacement, - sample_size=100, - build_initial_pop=False): +class Unique: + def get_duplicate_gene_indices(self, solution): + """Return the indices after the first occurrence of each gene value.""" + seen_values = set() + duplicate_indices = set() + for gene_index, gene_value in enumerate(solution): + value_key = self._gene_value_key(gene_value) + if value_key in seen_values: + duplicate_indices.add(gene_index) + else: + seen_values.add(value_key) + return duplicate_indices + + def _gene_value_key(self, gene_value): + """Compare exact numeric values without NumPy scalar promotion.""" + if isinstance(gene_value, numpy.generic): + gene_value = gene_value.item() + # Preserve the treatment of repeated NaNs as duplicates. + if gene_value != gene_value: + return ('nan',) + return gene_value + + def solve_duplicate_genes_in_population(self, population, build_initial_pop=False): """ - Resolves duplicates in a solution by selecting new values for the duplicate genes from the gene space. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_type (type): The data type of the gene (e.g., int, float). - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int, optional): The maximum number of attempts to resolve duplicates by selecting values from the gene space. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - tuple: - list: The updated solution after attempting to resolve duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. + Convert and round a population before repairing each solution. + Used for initialization, population growth, and user-supplied + operator or callback outputs. Returns a repaired copy. """ - - new_solution = solution.copy() - - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - # First try to solve the duplicates. - # For a solution like [3 2 0 0], the indices of the 2 duplicating genes are 2 and 3. - # The next call to the find_unique_value() method tries to change the value of the gene with index 3 to solve the duplicate. - if len(not_unique_indices) > 0: - new_solution, not_unique_indices, num_unsolved_duplicates = self.unique_genes_by_space(solution=new_solution, - gene_type=gene_type, - not_unique_indices=not_unique_indices, - sample_size=sample_size, - mutation_by_replacement=mutation_by_replacement, - build_initial_pop=build_initial_pop) - else: - return new_solution, not_unique_indices, len(not_unique_indices) - - # DEEP-DUPLICATE-REMOVAL-NEEDED - # Search by this phrase to find where deep duplicates removal should be applied. - # If there exist duplicate genes, then changing either of the 2 duplicating genes (with indices 2 and 3) will not solve the problem. - # This problem can be solved by randomly changing one of the non-duplicating genes that may make room for a unique value in one of the 2 duplicating genes. - # For example, if gene_space=[[3, 0, 1], [4, 1, 2], [0, 2], [3, 2, 0]] and the solution is [3 2 0 0], then the values of the last 2 genes duplicate. - # There are no possible changes in the last 2 genes to solve the problem. But it could be solved by changing the second gene from 2 to 4. - # As a result, any of the last 2 genes can take the value 2 and solve the duplicates. - - return new_solution, not_unique_indices, num_unsolved_duplicates - - def unique_int_gene_from_range(self, - solution, - gene_index, - min_val, - max_val, - mutation_by_replacement, - gene_type, - step=1): - + population = self.change_population_dtype_and_round(population) + for solution_index, solution in enumerate(population): + population[solution_index], _, _ = self.solve_duplicate_genes( + solution, build_initial_pop=build_initial_pop) + return population + + def solve_duplicate_genes(self, solution, build_initial_pop=False, + mutation_by_replacement=None, sample_size=None, + min_val=None, max_val=None, warn=True): """ - Finds a unique integer value for a specific gene in a solution. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. - min_val (int): The minimum value of the range to sample an integer randomly. - max_val (int): The maximum value of the range to sample an integer randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., int, int8, uint16, etc). - step (int, optional): The step size for generating candidate values. Defaults to 1. - - Returns: - int: The new integer value of the gene. If no unique value can be found, the original gene value is returned. + Resolve duplicates using the space, type, precision, and range of + each gene. Existing unique values are kept whenever possible. + A chain of replacements can move an earlier gene to make room + for a later gene, including one whose space has a single value. + + Parameters + ---------- + solution : numpy.ndarray or list + The solution to repair. The input is not modified. + build_initial_pop : bool + Use initialization ranges and replacement when True. + Otherwise use the random-mutation ranges and mode. + mutation_by_replacement : bool or None + Override the mutation mode. None uses the GA setting. + sample_size : int or None + Number of candidates for continuous ranges. None uses + ``self.sample_size``. Finite spaces are searched in full. + min_val, max_val : numeric, iterable, or None + Optional range overrides for the compatibility helpers. + warn : bool + Issue warnings for duplicates left after all repair attempts. + + Returns + ------- + solution : numpy.ndarray + A copy of the solution after repair. + duplicate_indices : set + Indices that still duplicate earlier genes. + num_unsolved_duplicates : int + The number of remaining duplicate indices. """ - - if self.gene_constraint and self.gene_constraint[gene_index]: - # A unique value is created out of the values that satisfy the constraint. - # sample_size=None to return all the values. - random_values = self.get_valid_gene_constraint_values(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=None, - step=step) - # If there is no value satisfying the constraint, then return the current gene value. - if random_values is None: - return solution[gene_index] + new_solution = self.change_population_dtype_and_round([solution])[0] + duplicate_indices = self.get_duplicate_gene_indices(new_solution) + if not duplicate_indices: + return new_solution, duplicate_indices, 0 + + if sample_size is None: + sample_size = self.sample_size + if mutation_by_replacement is None: + mutation_by_replacement = self.mutation_by_replacement + if build_initial_pop: + mutation_by_replacement = True + + candidate_values = [] + for gene_index, gene_value in enumerate(new_solution): + dtype = self.get_gene_dtype(gene_index) + if build_initial_pop and min_val is None: + values = self.get_initial_population_gene_candidates(gene_index, sample_size) + elif self.gene_space is None: + if min_val is None: + if build_initial_pop: + range_min, range_max = self.get_initial_population_range(gene_index) + else: + range_min, range_max = self.get_random_mutation_range(gene_index) + elif type(min_val) in self.supported_int_float_types: + range_min, range_max = min_val, max_val + else: + range_min, range_max = min_val[gene_index], max_val[gene_index] + values = self.generate_gene_value_randomly( + range_min=range_min, range_max=range_max, + gene_value=gene_value, gene_idx=gene_index, + mutation_by_replacement=mutation_by_replacement, + sample_size=None if numpy.issubdtype(numpy.dtype(dtype[0]), numpy.integer) else sample_size) else: - pass - else: - # There is no constraint for the current gene. Return the same range. - # sample_size=None to return all the values. - random_values = self.generate_gene_value(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=None, - step=step) - - selected_value = self.select_unique_value(gene_values=random_values, - solution=solution, - gene_index=gene_index) - - # The gene_type is of the form [type, precision] - selected_value = gene_type[0](selected_value) - - return selected_value - - def unique_float_gene_from_range(self, - solution, - gene_index, - min_val, - max_val, - mutation_by_replacement, - gene_type, - sample_size=100): - - """ - Finds a unique floating-point value for a specific gene in a solution. - - Args: - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. - min_val (int): The minimum value of the range to sample a floating-point number randomly. - max_val (int): The maximum value of the range to sample a floating-point number randomly. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - gene_type (type): The data type of the gene (e.g., float, float16, float32, etc). - sample_size (int): The maximum number of random values to generate to find a unique value. - - Returns: - float: The new floating-point value of the gene. If no unique value can be found, the original gene value is returned. - """ - - if self.gene_constraint and self.gene_constraint[gene_index]: - # A unique value is created out of the values that satisfy the constraint. - values = self.get_valid_gene_constraint_values(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=sample_size) - # If there is no value satisfying the constraint, then return the current gene value. - if values is None: - return solution[gene_index] + values = self.get_gene_space_values( + gene_idx=gene_index, + gene_value=None if build_initial_pop else gene_value, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) + + # Compare converted values: rounding and casting can turn + # different candidates into the same numeric value. + values = list(dict.fromkeys((value if dtype[0] is object else dtype[0](value)) for value in numpy.atleast_1d(values))) + values = [value for value in values if value != gene_value] + self.python_random_generator.shuffle(values) + # Keep manually supplied values and values inherited from parents. + # Only a replacement must come from the current domain. + candidate_values.append([gene_value] + values) + + # First try values satisfying each constraint in the current + # solution. This completely searches independent finite constraints. + # Keep the full domains for constraints depending on changed genes. + constrained_values = [] + for gene_index, values in enumerate(candidate_values): + if self.gene_constraint and self.gene_constraint[gene_index] is not None: + selected_values = self.filter_gene_values_by_constraint( + numpy.array(values), new_solution, gene_index, warn=False) + dtype = self.get_gene_dtype(gene_index) + constrained_values.append([] if selected_values is None else [(value if dtype[0] is object else dtype[0](value)) for value in selected_values]) else: - pass - else: - # There is no constraint for the current gene. Return the same range. - values = self.generate_gene_value(range_min=min_val, - range_max=max_val, - gene_value=solution[gene_index], - gene_idx=gene_index, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size) - - selected_value = self.select_unique_value(gene_values=values, - solution=solution, - gene_index=gene_index) - return selected_value - - def select_unique_value(self, - gene_values, - solution, - gene_index): - + constrained_values.append(values) + repaired_solution = self._assign_unique_gene_values(new_solution, constrained_values) + if self.solution_satisfies_gene_constraints(repaired_solution): + new_solution = repaired_solution + if self.get_duplicate_gene_indices(new_solution) and self.gene_constraint: + unconstrained_solution = self._assign_unique_gene_values(new_solution, candidate_values) + if self.solution_satisfies_gene_constraints(unconstrained_solution): + new_solution = unconstrained_solution + elif not self.get_duplicate_gene_indices(unconstrained_solution): + # A dependent constraint must see the complete assignment, + # including other positions changed by the replacement chain. + constrained_solution = self._assign_unique_gene_values_by_constraint( + new_solution, candidate_values, sample_size) + if constrained_solution is not None: + new_solution = constrained_solution + + duplicate_indices = self.get_duplicate_gene_indices(new_solution) + if warn and not self.suppress_warnings: + for gene_index in sorted(duplicate_indices): + stage = "while creating the initial population" if build_initial_pop else f"at generation {getattr(self, 'generations_completed', 0)}" + warnings.warn(f"Failed to find a unique value for gene with index {gene_index} whose value is {new_solution[gene_index]} {stage}. Consider adding more values in the gene space, using a wider range, or increasing sample_size for continuous ranges and gene constraints.") + return new_solution, duplicate_indices, len(duplicate_indices) + + def _assign_unique_gene_values(self, solution, candidate_values): """ - Select a unique value (if possible) from a list of gene values. - - Args: - gene_values (NumPy Array): An array of values from which a unique value should be selected. - solution (list): A solution containing genes, potentially with duplicate values. - gene_index (int): The index of the gene for which to find a unique value. - - Returns: - selected_gene: The new (hopefully unique) value of the gene. If no unique value can be found, the original gene value is returned. + Find a maximum assignment of different candidate values to genes. + Search replacement chains iteratively so long chromosomes do not + depend on Python's recursion limit. Without constraints, a finite + candidate space is searched completely. """ - - values_to_select_from = list(set(list(gene_values)) - set(solution)) - - if len(values_to_select_from) == 0: - if solution[gene_index] is None: - # The initial population is created as an empty array (numpy.empty()). - # If we are assigning values to the initial population, then the gene value is already None. - # If the gene value is None, then we do not have an option other than selecting a value even if it causes duplicates. - # If there is no value that is unique to the solution, then select any of the current values randomly from the current set of gene values. - selected_value = random.choice(gene_values) + new_solution = solution.copy() + value_owners = {} + duplicate_indices = [] + for gene_index, gene_value in enumerate(solution): + value_key = self._gene_value_key(gene_value) + if value_key in value_owners: + duplicate_indices.append(gene_index) else: - # If the gene is not None, then just keep its current value as long as there are no values that make it unique. - selected_value = solution[gene_index] - else: - selected_value = random.choice(values_to_select_from) - return selected_value - - def unique_genes_by_space(self, - solution, - gene_type, - not_unique_indices, - mutation_by_replacement, - sample_size=100, - build_initial_pop=False): - + value_owners[value_key] = gene_index + + for duplicate_index in duplicate_indices: + genes_to_search = deque([duplicate_index]) + previous_genes = {duplicate_index: None} + replacement_found = False + while genes_to_search and not replacement_found: + gene_index = genes_to_search.popleft() + for value in candidate_values[gene_index]: + if self._gene_value_key(value) not in value_owners: + # Walk back from the unused value to the duplicate. + # Each gene releases its predecessor's needed value. + while True: + new_solution[gene_index] = value + value_owners[self._gene_value_key(value)] = gene_index + previous_gene = previous_genes[gene_index] + if previous_gene is None: + break + gene_index, value = previous_gene + replacement_found = True + break + owner_index = value_owners[self._gene_value_key(value)] + if owner_index not in previous_genes: + previous_genes[owner_index] = (gene_index, value) + genes_to_search.append(owner_index) + return new_solution + + def _assign_unique_gene_values_by_constraint(self, solution, candidate_values, + sample_size): """ - Iterates through all duplicate genes to find unique values from their gene spaces and resolve duplicates. - For each duplicate gene, a call is made to the `unique_gene_by_space()` function. - - Args: - solution (list): A solution containing genes with duplicate values. - gene_type (type): The data type of the all the genes (e.g., int, float). - not_unique_indices (list): The indices of genes with duplicate values. - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int): The maximum number of attempts to resolve duplicates for each gene. Only works for floating-point numbers. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - tuple: - list: The updated solution after attempting to resolve all duplicates. If no duplicates are resolved, the solution remains unchanged. - list: The indices of genes that still have duplicate values. - int: The number of duplicates that could not be resolved. + Try alternative complete assignments when a replacement chain + violates a dependent constraint. Limit tentative assignments to + ``sample_size * num_genes`` to keep arbitrary user constraints + from causing an unbounded combinatorial search. """ + gene_order = sorted(range(len(solution)), key=lambda index: len(candidate_values[index])) + candidate_solution = solution.copy() + candidate_positions = [0] * len(solution) + selected_values = set() + search_depth = 0 + num_attempts = 0 + max_attempts = sample_size * len(solution) + while search_depth >= 0 and (num_attempts < max_attempts or search_depth == len(solution)): + if search_depth == len(solution): + if self.solution_satisfies_gene_constraints(candidate_solution): + return candidate_solution + search_depth -= 1 + selected_values.remove(self._gene_value_key(candidate_solution[gene_order[search_depth]])) + continue + gene_index = gene_order[search_depth] + values = candidate_values[gene_index] + if candidate_positions[search_depth] == len(values): + candidate_positions[search_depth] = 0 + search_depth -= 1 + if search_depth >= 0: + selected_values.remove(self._gene_value_key(candidate_solution[gene_order[search_depth]])) + continue + value = values[candidate_positions[search_depth]] + candidate_positions[search_depth] += 1 + num_attempts += 1 + value_key = self._gene_value_key(value) + if value_key in selected_values: + continue + candidate_solution[gene_index] = value + selected_values.add(value_key) + search_depth += 1 + return None - num_unsolved_duplicates = 0 - for duplicate_index in not_unique_indices: - temp_val = self.unique_gene_by_space(solution=solution, - gene_idx=duplicate_index, - gene_type=gene_type, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size, - build_initial_pop=build_initial_pop) - - if temp_val in solution: - num_unsolved_duplicates = num_unsolved_duplicates + 1 - if not self.suppress_warnings: warnings.warn(f"Failed to find a unique value for gene with index {duplicate_index} whose value is {solution[duplicate_index]} at generation {self.generations_completed+1}. Consider adding more values in the gene space or use a wider range for initial population or random mutation.") - else: - solution[duplicate_index] = temp_val - - # Update the list of duplicate indices after each iteration. - _, unique_gene_indices = numpy.unique(solution, return_index=True) - not_unique_indices = set(range(len(solution))) - set(unique_gene_indices) - - return solution, not_unique_indices, num_unsolved_duplicates - - def unique_gene_by_space(self, - solution, - gene_idx, - gene_type, - mutation_by_replacement, - sample_size=100, - build_initial_pop=False): - + def solution_satisfies_gene_constraints(self, solution): + """Check all gene constraints against a complete candidate solution.""" + if not self.gene_constraint: + return True + for gene_index, constraint in enumerate(self.gene_constraint): + if constraint is None: + continue + selected_values = self.filter_gene_values_by_constraint( + numpy.array([solution[gene_index]]), solution, gene_index, warn=False) + if selected_values is None: + return False + return True + + def solve_duplicate_genes_randomly(self, solution, min_val, max_val, + mutation_by_replacement, gene_type, + sample_size=100): """ - Returns a unique value for a specific gene based on its value space to resolve duplicates. - - Args: - solution (list): A solution containing genes with duplicate values. - gene_idx (int): The index of the gene that has a duplicate value. - gene_type (type): The data type of the gene (e.g., int, float). - mutation_by_replacement (bool): Indicates if mutation is performed by replacement. - sample_size (int): The maximum number of attempts to resolve duplicates for each gene. Only works for floating-point numbers. - build_initial_pop (bool, optional): Indicates if initial population should be built. - - Returns: - Any: A unique value for the gene, if one exists; otherwise, the original gene value. + Compatibility helper for repair using explicit random ranges. + Returns the repaired solution, remaining duplicate indices, and + their count. Gene types are obtained from the GA configuration. """ + return self.solve_duplicate_genes(solution, min_val=min_val, max_val=max_val, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) - # When gene_value is None, this forces the gene value generators to select a value for use by the initial population. - # Otherwise, it considers selecting a value for mutation. - if build_initial_pop: - gene_value = None - else: - gene_value = solution[gene_idx] - - if self.gene_constraint and self.gene_constraint[gene_idx]: - # A unique value is created out of the values that satisfy the constraint. - values = self.get_valid_gene_constraint_values(range_min=None, - range_max=None, - gene_value=gene_value, - gene_idx=gene_idx, - mutation_by_replacement=mutation_by_replacement, - solution=solution, - sample_size=sample_size) - # If there is no value satisfying the constraint, then return the current gene value. + def solve_duplicate_genes_by_space(self, solution, gene_type, + mutation_by_replacement, sample_size=100, + build_initial_pop=False): + """Compatibility helper for repair using the configured gene space.""" + return self.solve_duplicate_genes(solution, build_initial_pop=build_initial_pop, + mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size) + + def unique_int_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, gene_type, step=1): + """Return an unused integer candidate, or keep the gene if none exists.""" + return self._unique_gene_from_range(solution, gene_index, min_val, max_val, + mutation_by_replacement, None, step) + + def unique_float_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, gene_type, + sample_size=100): + """Return an unused float candidate, or keep the gene if none exists.""" + return self._unique_gene_from_range(solution, gene_index, min_val, max_val, + mutation_by_replacement, sample_size, 1) + + def _unique_gene_from_range(self, solution, gene_index, min_val, max_val, + mutation_by_replacement, sample_size, step): + """Generate, filter, and select a candidate for the compatibility helpers.""" + values = self.generate_gene_value_randomly( + range_min=min_val, range_max=max_val, gene_value=solution[gene_index], + gene_idx=gene_index, mutation_by_replacement=mutation_by_replacement, + sample_size=sample_size, step=step) + return self._select_unique_value_by_constraint(values, solution, gene_index) + + def select_unique_value(self, gene_values, solution, gene_index): + """Select an unused value, accepting both scalar and array candidates.""" + gene_values = list(numpy.atleast_1d(gene_values)) + used_values = {self._gene_value_key(value) for value in solution} + values_to_select_from = list({self._gene_value_key(value): value for value in gene_values + if self._gene_value_key(value) not in used_values}.values()) + if values_to_select_from: + return self.python_random_generator.choice(values_to_select_from) + if solution[gene_index] is None: + if not gene_values: + raise ValueError(f"There are no values to select for the gene at index {gene_index}.") + return self.python_random_generator.choice(gene_values) + return solution[gene_index] + + def _select_unique_value_by_constraint(self, values, solution, gene_index): + """Filter candidates before selecting an unused value for one gene.""" + values = numpy.atleast_1d(values) + if self.gene_constraint and self.gene_constraint[gene_index] is not None: + values = self.filter_gene_values_by_constraint(values, solution, gene_index) if values is None: - return solution[gene_idx] - else: - pass - else: - # There is no constraint for the current gene. Return the same range. - values = self.generate_gene_value(range_min=None, - range_max=None, - gene_value=gene_value, - gene_idx=gene_idx, - solution=solution, - mutation_by_replacement=mutation_by_replacement, - sample_size=sample_size) - - selected_value = self.select_unique_value(gene_values=values, - solution=solution, - gene_index=gene_idx) - - return selected_value - - def find_two_duplicates(self, - solution, - gene_space_unpacked): - """ - Identifies the first occurrence of a duplicate gene in the solution. - - Args: - solution: The solution containing genes with duplicate values. - gene_space_unpacked: A list of values from the gene space to choose the values that resolve duplicates. + return solution[gene_index] + return self.select_unique_value(values, solution, gene_index) - Returns: - int: The index of the first gene with a duplicate value. - Any: The value of the duplicate gene. - """ + def unique_genes_by_space(self, solution, gene_type, not_unique_indices, + mutation_by_replacement, sample_size=100, + build_initial_pop=False): + """Compatibility helper that repairs duplicates and replacement chains.""" + return self.solve_duplicate_genes_by_space(solution, gene_type, + mutation_by_replacement, + sample_size, build_initial_pop) - for gene in set(solution): - gene_indices = numpy.where(numpy.array(solution) == gene)[0] - if len(gene_indices) == 1: + def unique_gene_by_space(self, solution, gene_idx, gene_type, + mutation_by_replacement, sample_size=100, + build_initial_pop=False): + """Return an unused candidate from a gene's space, if available.""" + values = self.get_gene_space_values( + gene_idx, None if build_initial_pop else solution[gene_idx], + mutation_by_replacement, sample_size) + return self._select_unique_value_by_constraint(values, solution, gene_idx) + + def find_two_duplicates(self, solution, gene_space_unpacked): + """Return a duplicate gene with alternatives, or ``(None, None)``.""" + duplicate_values = {self._gene_value_key(solution[index]) + for index in self.get_duplicate_gene_indices(solution)} + for gene_index, gene_value in enumerate(solution): + if self._gene_value_key(gene_value) not in duplicate_values: continue - for gene_idx in gene_indices: - number_alternate_values = len(set(gene_space_unpacked[gene_idx])) - if number_alternate_values > 1: - return gene_idx, gene - # This means there is no way to solve the duplicates between the genes. - # Because the space of the duplicate genes only has a single value and there are no alternatives. - return None, gene + space = gene_space_unpacked[gene_index] if self.gene_space_nested or not self.gene_type_single else gene_space_unpacked + if len({self._gene_value_key(value) for value in numpy.atleast_1d(space)}) > 1: + return gene_index, gene_value + return None, None - def unpack_gene_space(self, - range_min, - range_max, - sample_size_from_inf_range=100): + def unpack_gene_space(self, range_min, range_max, sample_size_from_inf_range=100): """ - Unpacks the gene space for selecting a value to resolve duplicates by converting ranges into lists of values. - - Args: - range_min (float or int): The minimum value of the range. - range_max (float or int): The maximum value of the range. - sample_size_from_inf_range (int): The number of values to generate for an infinite range of float values using `numpy.linspace()`. - - Returns: - list: A list representing the unpacked gene space. + Return converted finite spaces and samples of continuous spaces. + This attribute is a snapshot for inspection. Value generation + reads the original space so ``None`` entries remain random and + use the current initialization or mutation range. """ - - # Copy the gene_space to keep it isolated from the changes. if self.gene_space is None: return None - - if self.gene_space_nested == False: - if type(self.gene_space) is range: - gene_space_unpacked = list(self.gene_space) - elif type(self.gene_space) in [numpy.ndarray, list]: - gene_space_unpacked = self.gene_space.copy() - elif type(self.gene_space) is dict: - if 'step' in self.gene_space.keys(): - gene_space_unpacked = numpy.arange(start=self.gene_space['low'], - stop=self.gene_space['high'], - step=self.gene_space['step']) - else: - gene_space_unpacked = numpy.linspace(start=self.gene_space['low'], - stop=self.gene_space['high'], - num=sample_size_from_inf_range, - endpoint=False) - - if self.gene_type_single == True: - # Change the data type. - for idx in range(len(gene_space_unpacked)): - if gene_space_unpacked[idx] is None: - gene_space_unpacked[idx] = numpy.random.uniform(low=range_min, - high=range_max) - gene_space_unpacked = numpy.array(gene_space_unpacked, - dtype=self.gene_type[0]) - if not self.gene_type[1] is None: - # Round the values for float (non-int) data types. - gene_space_unpacked = numpy.round(gene_space_unpacked, - self.gene_type[1]) + if self.gene_space_nested: + num_spaces = len(self.gene_space) + elif not self.gene_type_single: + num_spaces = len(self.gene_type) + else: + num_spaces = 1 + unpacked_spaces = [] + for gene_index in range(num_spaces): + if type(range_min) in self.supported_int_float_types: + low, high = range_min, range_max else: - temp_gene_space_unpacked = gene_space_unpacked.copy() - gene_space_unpacked = [] - # Get the number of genes from the length of gene_type. - # The num_genes attribute is not set yet when this method (unpack_gene_space) is called for the first time. - for gene_idx in range(len(self.gene_type)): - # Change the data type. - gene_space_item_unpacked = numpy.array(temp_gene_space_unpacked, - self.gene_type[gene_idx][0]) - if not self.gene_type[gene_idx][1] is None: - # Round the values for float (non-int) data types. - gene_space_item_unpacked = numpy.round(temp_gene_space_unpacked, - self.gene_type[gene_idx][1]) - gene_space_unpacked.append(gene_space_item_unpacked) - - elif self.gene_space_nested == True: - gene_space_unpacked = self.gene_space.copy() - for space_idx, space in enumerate(gene_space_unpacked): - if type(space) in pygad.GA.supported_int_float_types: - gene_space_unpacked[space_idx] = [space] - elif space is None: - # Randomly generate the value using the mutation range. - gene_space_unpacked[space_idx] = numpy.arange(start=range_min, - stop=range_max) - elif type(space) is range: - # Convert the range to a list. - gene_space_unpacked[space_idx] = list(space) - elif type(space) is dict: - # Create a list of values using the dict range. - # Use numpy.linspace() - dtype = self.get_gene_dtype(gene_index=space_idx) - - if dtype[0] in pygad.GA.supported_int_types: - if 'step' in space.keys(): - step = space['step'] - else: - step = 1 - - gene_space_unpacked[space_idx] = numpy.arange(start=space['low'], - stop=space['high'], - step=step) - else: - if 'step' in space.keys(): - gene_space_unpacked[space_idx] = numpy.arange(start=space['low'], - stop=space['high'], - step=space['step']) - else: - gene_space_unpacked[space_idx] = numpy.linspace(start=space['low'], - stop=space['high'], - num=sample_size_from_inf_range, - endpoint=False) - elif type(space) in [numpy.ndarray, list, tuple]: - # list/tuple/numpy.ndarray - # Convert all to list - gene_space_unpacked[space_idx] = list(space) - - # Check if there is an item with the value None. If so, replace it with a random value using the mutation range. - none_indices = numpy.where(numpy.array(gene_space_unpacked[space_idx]) == None)[0] - if len(none_indices) > 0: - for idx in none_indices: - random_value = numpy.random.uniform(low=range_min, - high=range_max, - size=1)[0] - gene_space_unpacked[space_idx][idx] = random_value - - dtype = self.get_gene_dtype(gene_index=space_idx) - - # Change the data type. - gene_space_unpacked[space_idx] = numpy.array(gene_space_unpacked[space_idx], - dtype=dtype[0]) - if not dtype[1] is None: - # Round the values for float (non-int) data types. - gene_space_unpacked[space_idx] = numpy.round(gene_space_unpacked[space_idx], - dtype[1]) - - return gene_space_unpacked - - def solve_duplicates_deeply(self, - solution): - """ - Sometimes it is impossible to solve the duplicate genes by simply selecting another value for either genes. - This function solves the duplicates between 2 genes by searching for a third gene that can assist in the solution. - - Args: - solution (list): The current solution containing genes, potentially with duplicates. - - Returns: - list or None: The updated solution with duplicates resolved, or `None` if the duplicates cannot be resolved. - """ - - # gene_space_unpacked = self.unpack_gene_space() - # Create a copy of the gene_space_unpacked attribute because it will be changed later. - gene_space_unpacked = self.gene_space_unpacked.copy() - - duplicate_index, duplicate_value = self.find_two_duplicates(solution, - gene_space_unpacked) - - if duplicate_index is None: - # Impossible to solve the duplicates for the genes with value duplicate_value. - return None - - - # Without copy(), the gene will be removed from the gene_space. - # Convert the space to list because tuples do not have copy() - gene_other_values = list(gene_space_unpacked[duplicate_index]).copy() - - # This removes all the occurrences of this value. - gene_other_values = [v for v in gene_other_values if v != duplicate_value] - - # Two conditions to solve the duplicates of the value D: - # 1. From gene_other_values, select a value V such that it is available in the gene space of another gene X. - # 2. Find an alternate value for the gene X that will not cause any duplicates. - # 2.1 If the gene X does not have alternatives, then go back to step 1 to find another gene. - # 2.2 Set the gene X to the value D. - # 2.3 Set the target gene to the value V. - # Set the space of the duplicate gene to empty list []. Do not remove it to not alter the indices of the gene spaces. - gene_space_unpacked[duplicate_index] = [] - - for other_value in gene_other_values: - for space_idx, space in enumerate(gene_space_unpacked): - if other_value in space: - if other_value in solution and list(solution).index(other_value) != space_idx: - continue - else: - # Find an alternate value for the third gene. - # Copy the space so that the original space is not changed after removing the value. - space_other_values = space.copy() - # This removes all the occurrences of this value. It is not enough to use the remove() function because it only removes the first occurrence. - space_other_values = [v for v in space_other_values if v != other_value] - - for val in space_other_values: - if val in solution: - # If the value exists in another gene of the solution, then we cannot use this value as it will cause another duplicate. - # End the current iteration and go check another value. - continue - else: - solution[space_idx] = val - solution[duplicate_index] = other_value - return solution - - # Reaching here means we cannot solve the duplicate genes. + low, high = range_min[gene_index], range_max[gene_index] + space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space + # Large ranges and stepped spaces remain compact inspection + # snapshots. Repair reads the original space in full when needed. + if isinstance(space, range) or (isinstance(space, dict) and 'step' in space): + count = self._finite_gene_space_length(space) + sample_count = min(count, sample_size_from_inf_range) + indices = [index * (count - 1) // max(1, sample_count - 1) for index in range(sample_count)] + values = [self._finite_gene_space_value(space, index) for index in indices] + unpacked_spaces.append(numpy.unique(self.change_gene_dtype_and_round(gene_index, values))) + elif space is None or (type(space) is dict and 'step' not in space): + if type(space) is dict: + low, high = space['low'], space['high'] + unpacked_spaces.append(self._initial_population_range_snapshot( + gene_index, low, high, sample_size_from_inf_range)) + elif type(space) in [list, tuple, numpy.ndarray] and any(value is None for value in space): + values = [value for value in space if value is not None] + values.extend(self._initial_population_range_snapshot( + gene_index, low, high, sample_size_from_inf_range)) + unpacked_spaces.append(numpy.unique(self.change_gene_dtype_and_round(gene_index, values))) + else: + unpacked_spaces.append(self.get_gene_space_values( + gene_index, sample_size=sample_size_from_inf_range, + range_min=low, range_max=high)) + if not self.gene_space_nested and self.gene_type_single: + return unpacked_spaces[0] + return unpacked_spaces + + def solve_duplicates_deeply(self, solution): + """Repair replacement chains, returning None if no progress is possible.""" + repaired_solution, duplicate_indices, _ = self.solve_duplicate_genes(solution, warn=False) + if len(duplicate_indices) < len(self.get_duplicate_gene_indices(solution)): + return repaired_solution return None diff --git a/pygad/pygad.py b/pygad/pygad.py index 7db5f560..497d8764 100644 --- a/pygad/pygad.py +++ b/pygad/pygad.py @@ -79,13 +79,13 @@ def __init__(self, fitness_func: Accepts a function/method and returns the fitness value of the solution. In PyGAD 2.20.0, a third parameter is passed referring to the 'pygad.GA' instance. fitness_batch_size: Added in PyGAD 2.19.0. Supports calculating the fitness in batches. If the value is 1 or None, then the fitness function is called for each individual solution. If given another value X where X is neither 1 nor None (e.g. X=3), then the fitness function is called once for each X (3) solutions. - initial_population: A user-defined initial population. It is useful when the user wants to start the generations with a custom initial population. It defaults to None which means no initial population is specified by the user. In this case, PyGAD creates an initial population using the 'sol_per_pop' and 'num_genes' parameters. An exception is raised if the 'initial_population' is None while any of the 2 parameters ('sol_per_pop' or 'num_genes') is also None. + initial_population: A user-defined initial population. It is useful when the user wants to start the generations with a custom initial population. It defaults to None which means no initial population is specified by the user. In this case, PyGAD creates an initial population using the 'sol_per_pop' and 'num_genes' parameters. An exception is raised if the 'initial_population' is None while any of the 2 parameters ('sol_per_pop' or 'num_genes') is also None. A supplied population must be a non-empty rectangular 2D numeric list, tuple, or NumPy array. Its shape determines sol_per_pop and num_genes before per-gene validation. The input is copied, converted, checked against gene constraints, and repaired for duplicates when required. sol_per_pop: Number of solutions in the population. num_genes: Number of genes in the solution. init_range_low: The lower value of the random range from which the gene values in the initial population are selected. It defaults to -4. Available in PyGAD 1.0.20 and higher. init_range_high: The upper value of the random range from which the gene values in the initial population are selected. It defaults to 4. Available in PyGAD 1.0.20. - It is OK for the 2 parameters ('init_range_low' and 'init_range_high') to be equal, or for one to be higher or lower than the other (i.e. 'init_range_low' does not need to be lower than 'init_range_high'). + It is OK for the 2 parameters ('init_range_low' and 'init_range_high') to be equal, or for one to be higher or lower than the other (i.e. 'init_range_low' does not need to be lower than 'init_range_high'). Generated range values remain inside the interval after conversion and rounding; an error is raised if the type and precision cannot represent any valid value. gene_type: The type of the gene. It is assigned to any of these types (int, numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, float, numpy.float16, numpy.float32, numpy.float64) and forces all the genes to be of that type. @@ -97,14 +97,14 @@ def __init__(self, keep_elitism: Added in PyGAD 2.18.0. It can take the value 0 or a positive integer that satisfies (0 <= keep_elitism <= sol_per_pop). It defaults to 1 which means only the best solution in the current generation is kept in the next generation. If assigned 0, this means it has no effect. If assigned a positive integer K, then the best K solutions are kept in the next generation. It cannot be assigned a value greater than the value assigned to the sol_per_pop parameter. If this parameter has a value different from 0, then it takes precedence over the keep_parents parameter, which will have no effect (a warning is raised if keep_parents was explicitly set in this case). To use keep_parents instead, set keep_elitism=0. crossover_type: Type of the crossover operator. If crossover_type=None, then the crossover step is bypassed which means no crossover is applied and thus no offspring will be created in the next generations. The next generation will use the solutions in the current population. - crossover_probability: The probability of selecting a solution for the crossover operation. If the solution probability is <= crossover_probability, the solution is selected. The value must be between 0 and 1 inclusive. + crossover_probability: The probability of selecting a solution for the crossover operation. If the generated value is < crossover_probability, the solution is selected. The value must be between 0 and 1 inclusive. sbx_crossover_eta: Only used when 'crossover_type' is 'sbx'. The distribution index that controls how close the children stay to the parents (higher value = closer). Defaults to 30. mutation_type: Type of the mutation operator. If mutation_type=None, then the mutation step is bypassed which means no mutation is applied and thus no changes are applied to the offspring created using the crossover operation. The offspring will be used unchanged in the next generation. - mutation_probability: The probability of selecting a gene for the mutation operation. If the gene probability is <= mutation_probability, the gene is selected. It accepts either a single value for fixed mutation or a list/tuple/numpy.ndarray of 2 values for adaptive mutation. The values must be between 0 and 1 inclusive. If specified, then no need for the 2 parameters mutation_percent_genes and mutation_num_genes. + mutation_probability: The probability of selecting a gene for the mutation operation. If the generated value is < mutation_probability, the gene is selected. Zero leaves offspring unchanged. It accepts either a single value for fixed mutation or a list/tuple/numpy.ndarray of 2 values for adaptive mutation. The values must be between 0 and 1 inclusive. If specified, then no need for the 2 parameters mutation_percent_genes and mutation_num_genes. polynomial_mutation_eta: Only used when 'mutation_type' is 'polynomial'. The distribution index that controls how small the mutation step is (higher value = smaller step). Defaults to 20. - mutation_by_replacement: An optional bool parameter. It works only when the selected type of mutation is random (mutation_type="random"). In this case, setting mutation_by_replacement=True means replace the gene by the randomly generated value. If False, then it has no effect and random mutation works by adding the random value to the gene. + mutation_by_replacement: An optional bool parameter. It applies when random or adaptive mutation draws random values. In this case, setting mutation_by_replacement=True means replace the gene by the randomly generated value. If False, then it has no effect and random mutation works by adding the random value to the gene. mutation_percent_genes: Percentage of genes to mutate which defaults to the string 'default' which means 10%. This parameter has no action if any of the 2 parameters mutation_probability or mutation_num_genes exist. mutation_num_genes: Number of genes to mutate which defaults to None. If the parameter mutation_num_genes exists, then no need for the parameter mutation_percent_genes. This parameter has no action if the mutation_probability parameter exists. @@ -129,13 +129,13 @@ def __init__(self, suppress_warnings: Added in PyGAD 2.10.0 and its type is bool. If True, then no warning messages will be displayed. It defaults to False. - allow_duplicate_genes: Added in PyGAD 2.13.0. If True, then a solution/chromosome may have duplicate gene values. If False, then each gene will have a unique value in its solution. + allow_duplicate_genes: Added in PyGAD 2.13.0. If True, then a solution/chromosome may have duplicate gene values. If False, PyGAD tries to give every gene a unique numeric value after conversion and rounding, using replacement chains when needed. Duplicates may remain if the spaces, ranges, constraints, or sampling limit leave no usable alternative. stop_criteria: Added in PyGAD 2.15.0. It is assigned to some criteria to stop the evolution if at least one criterion holds. parallel_processing: Added in PyGAD 2.17.0. Defaults to `None` which means no parallel processing is used. If a positive integer is assigned, it specifies the number of threads to be used. If a list or a tuple of exactly 2 elements is assigned, then: 1) The first element can be either "process" or "thread" to specify whether processes or threads are used, respectively. 2) The second element can be: 1) A positive integer to select the maximum number of processes or threads to be used. 2) 0 to indicate that parallel processing is not used. This is identical to setting 'parallel_processing=None'. 3) None to use the default value as calculated by the concurrent.futures module. - random_seed: Added in PyGAD 2.18.0. It defines the random seed to be used by the random function generators (we use random functions in the NumPy and random modules). This helps to reproduce the same results by setting the same random seed. + random_seed: Added in PyGAD 2.18.0. It accepts None or a Python/NumPy integer from 0 through 2**32-1. Each GA owns numpy_random_generator and python_random_generator; their states continue across runs and checkpoints. Custom operators can use these generators for reproducible random draws. logger: Added in PyGAD 2.20.0. It accepts a logger object of the 'logging.Logger' class to log the messages. If no logger is passed, then a default logger is created to log/print the messages to the console exactly like using the 'print()' function. """ @@ -185,7 +185,8 @@ def __init__(self, random_seed=random_seed, logger=logger) except Exception as e: - self.logger.exception(e) + if hasattr(self, 'logger'): + self.logger.exception(e) # sys.exit(-1) raise e diff --git a/pygad/utils/__init__.py b/pygad/utils/__init__.py index fba9e31f..67dd2205 100644 --- a/pygad/utils/__init__.py +++ b/pygad/utils/__init__.py @@ -9,4 +9,4 @@ from pygad.utils import validation from pygad.utils import engine -__version__ = "1.5.2" +__version__ = "1.5.7" diff --git a/pygad/utils/crossover.py b/pygad/utils/crossover.py index 5178ef64..9c7d9795 100644 --- a/pygad/utils/crossover.py +++ b/pygad/utils/crossover.py @@ -3,7 +3,6 @@ """ import numpy -import random class Crossover: @@ -38,16 +37,16 @@ def single_point_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the K points at which crossover takes place between each two parents. The point does not have to be always at the center of the solutions. - # This saves time by calling the numpy.random.randint() function only once. - crossover_points = numpy.random.randint(low=0, + # This saves time by calling the self.numpy_random_generator.randint() function only once. + crossover_points = self.numpy_random_generator.randint(low=0, high=parents.shape[1], size=offspring_size[0]) for k in range(offspring_size[0]): # Check if the crossover_probability parameter is used. if not (self.crossover_probability is None): - probs = numpy.random.random(size=parents.shape[0]) - indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected as is. if len(indices) == 0: @@ -57,7 +56,7 @@ def single_point_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -72,19 +71,7 @@ def single_point_crossover(self, parents, offspring_size): offspring[k, crossover_points[k]:] = parents[parent2_idx, crossover_points[k]:] if self.allow_duplicate_genes == False: - if self.gene_space is None: - offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], - min_val=self.random_mutation_min_val, - max_val=self.random_mutation_max_val, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring @@ -115,13 +102,13 @@ def two_points_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the K pairs of points at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only twice. + # This saves time by calling the self.numpy_random_generator.randint() function only twice. # The 2 points of a pair are different values in [0, num_genes], and every such pair is equally likely. # If the chromosome has only a single gene, the points are 0 and 1: the gene is copied from the second parent. - points_a = numpy.random.randint(low=0, + points_a = self.numpy_random_generator.randint(low=0, high=parents.shape[1] + 1, size=offspring_size[0]) - points_b = numpy.random.randint(low=0, + points_b = self.numpy_random_generator.randint(low=0, high=parents.shape[1], size=offspring_size[0]) # Skip the value of the first point so that the 2 points differ. @@ -134,8 +121,8 @@ def two_points_crossover(self, parents, offspring_size): for k in range(offspring_size[0]): if not (self.crossover_probability is None): - probs = numpy.random.random(size=parents.shape[0]) - indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -145,7 +132,7 @@ def two_points_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -162,19 +149,7 @@ def two_points_crossover(self, parents, offspring_size): offspring[k, crossover_points_1[k]:crossover_points_2[k]] = parents[parent2_idx, crossover_points_1[k]:crossover_points_2[k]] if self.allow_duplicate_genes == False: - if self.gene_space is None: - offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], - min_val=self.random_mutation_min_val, - max_val=self.random_mutation_max_val, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring def uniform_crossover(self, parents, offspring_size): @@ -203,17 +178,17 @@ def uniform_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the genes sources at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only once. + # This saves time by calling the self.numpy_random_generator.randint() function only once. # There is a list of 0 and 1 for each offspring. # [0, 1, 0, 0, 1, 1]: If the value is 0, then take the gene from the first parent. If 1, take it from the second parent. - genes_sources = numpy.random.randint(low=0, + genes_sources = self.numpy_random_generator.randint(low=0, high=2, size=offspring_size) for k in range(offspring_size[0]): if not (self.crossover_probability is None): - probs = numpy.random.random(size=parents.shape[0]) - indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -223,7 +198,7 @@ def uniform_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -239,19 +214,7 @@ def uniform_crossover(self, parents, offspring_size): parents[parent2_idx, :]) if self.allow_duplicate_genes == False: - if self.gene_space is None: - offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], - min_val=self.random_mutation_min_val, - max_val=self.random_mutation_max_val, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring @@ -289,8 +252,8 @@ def sbx_crossover(self, parents, offspring_size): for k in range(offspring_size[0]): if not (self.crossover_probability is None): - probs = numpy.random.random(size=parents.shape[0]) - indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) if len(indices) == 0: offspring[k, :] = parents[k % parents.shape[0], :] @@ -299,7 +262,7 @@ def sbx_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -307,25 +270,23 @@ def sbx_crossover(self, parents, offspring_size): parent2_idx = (k + 1) % parents.shape[0] for gene_idx in range(offspring_size[1]): - p1 = float(parents[parent1_idx, gene_idx]) - p2 = float(parents[parent2_idx, gene_idx]) + range_min, range_max = self.get_bounded_operator_gene_range(gene_idx) + lower, upper = float(range_min), float(range_max) + p1 = float(numpy.clip(parents[parent1_idx, gene_idx], lower, upper)) + p2 = float(numpy.clip(parents[parent2_idx, gene_idx], lower, upper)) y1 = min(p1, p2) y2 = max(p1, p2) if y2 - y1 < near_zero: # The two parents have the same value on this gene. - offspring[k, gene_idx] = p1 + offspring[k, gene_idx] = self.convert_bounded_operator_gene_value(gene_idx, p1) continue - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - lower = float(range_min) - upper = float(range_max) - # Beta is the spread factor that controls how far the # child can move away from the parents. beta = 1.0 + 2.0 * min(y1 - lower, upper - y2) / (y2 - y1) alpha = 2.0 - pow(beta, -(eta + 1.0)) - rand_u = numpy.random.random() + rand_u = self.numpy_random_generator.random() if rand_u <= 1.0 / alpha: beta_q = pow(rand_u * alpha, 1.0 / (eta + 1.0)) else: @@ -333,27 +294,14 @@ def sbx_crossover(self, parents, offspring_size): # SBX makes 2 children, symmetric around the parents' mean. # Pick one of them at random so that the child is not always below the mean. - if numpy.random.random() < 0.5: + if self.numpy_random_generator.random() < 0.5: child = 0.5 * ((y1 + y2) - beta_q * (y2 - y1)) else: child = 0.5 * ((y1 + y2) + beta_q * (y2 - y1)) child = numpy.clip(child, lower, upper) - offspring[k, gene_idx] = child + offspring[k, gene_idx] = self.convert_bounded_operator_gene_value(gene_idx, child) - if self.allow_duplicate_genes == False: - if self.gene_space is None: - offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], - min_val=self.random_mutation_min_val, - max_val=self.random_mutation_max_val, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[k] = self.prepare_bounded_operator_solution(parents[parent1_idx], offspring[k]) return offspring @@ -385,17 +333,17 @@ def scattered_crossover(self, parents, offspring_size): offspring = numpy.empty(offspring_size, dtype=object) # Randomly generate all the genes sources at which crossover takes place between each two parents. - # This saves time by calling the numpy.random.randint() function only once. + # This saves time by calling the self.numpy_random_generator.randint() function only once. # There is a list of 0 and 1 for each offspring. # [0, 1, 0, 0, 1, 1]: If the value is 0, then take the gene from the first parent. If 1, take it from the second parent. - genes_sources = numpy.random.randint(low=0, + genes_sources = self.numpy_random_generator.randint(low=0, high=2, size=offspring_size) for k in range(offspring_size[0]): if not (self.crossover_probability is None): - probs = numpy.random.random(size=parents.shape[0]) - indices = list(set(numpy.where(probs <= self.crossover_probability)[0])) + probs = self.numpy_random_generator.random(size=parents.shape[0]) + indices = list(set(numpy.where(probs < self.crossover_probability)[0])) # If no parent satisfied the probability, no crossover is applied and a parent is selected. if len(indices) == 0: @@ -405,7 +353,7 @@ def scattered_crossover(self, parents, offspring_size): parent1_idx = indices[0] parent2_idx = parent1_idx else: - indices = random.sample(indices, 2) + indices = self.python_random_generator.sample(indices, 2) parent1_idx = indices[0] parent2_idx = indices[1] else: @@ -421,17 +369,5 @@ def scattered_crossover(self, parents, offspring_size): parents[parent2_idx, :]) if self.allow_duplicate_genes == False: - if self.gene_space is None: - offspring[k], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[k], - min_val=self.random_mutation_min_val, - max_val=self.random_mutation_max_val, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - offspring[k], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[k], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[k], _, _ = self.solve_duplicate_genes(solution=offspring[k]) return offspring diff --git a/pygad/utils/engine.py b/pygad/utils/engine.py index b173f9a8..2900cd3e 100644 --- a/pygad/utils/engine.py +++ b/pygad/utils/engine.py @@ -5,10 +5,98 @@ class GAEngine(FitnessEvaluation): + def __setstate__(self, state): + """Restore generator states, initializing them for older checkpoints.""" + legacy_history = 'best_solutions_generations' not in state + self.__dict__.update(state) + for name in ['random_seed', 'num_generations', 'num_parents_mating', 'sol_per_pop', + 'num_genes', 'K_tournament', 'nsga3_num_divisions', 'sample_size', + 'fitness_batch_size', 'keep_parents', 'keep_elitism']: + value = getattr(self, name, None) + if isinstance(value, numpy.integer): + setattr(self, name, int(value)) + if not hasattr(self, 'numpy_random_generator'): + self.numpy_random_generator = numpy.random.RandomState(self.random_seed) + if not hasattr(self, 'python_random_generator'): + self.python_random_generator = random.Random(self.random_seed) + if not hasattr(self, 'mutation_control_explicitly_set'): + self.mutation_control_explicitly_set = False + self._restore_history_generations() + if legacy_history and self.run_completed and len(self.best_solutions_fitness) > 0: + self._update_best_solution_generation() + + def _restore_history_generations(self): + """Supply history metadata for checkpoints saved before it existed. + + A single-run history has an unambiguous generation sequence. Older + repeated-run checkpoints did not save their run boundaries; None + marks generation numbers that cannot be recovered reliably. + """ + count = len(self.best_solutions_fitness) + if (not hasattr(self, 'best_solutions_generations') + or len(self.best_solutions_generations) != count): + self.best_solutions_generations = (list(range(count)) + if count == self.generations_completed + 1 else [None] * count) + if (not hasattr(self, '_saved_population_sizes') + or sum(self._saved_population_sizes) != len(self.solutions)): + count = len(self.solutions) // self.sol_per_pop + self._saved_population_sizes = [self.sol_per_pop] * count + count = len(self._saved_population_sizes) + if (not hasattr(self, 'solutions_generations') + or len(self.solutions_generations) != count): + self.solutions_generations = (list(range(count)) + if count == self.generations_completed + 1 else [None] * count) + + def _history_generation_numbers(self, generations): + """Use snapshot positions only for unknown legacy generation numbers.""" + return [index if generation is None else generation + for index, generation in enumerate(generations)] + + def _update_best_solution_generation(self): + """Find the best history entry using the same ordering as best_solution().""" + fitness = numpy.asarray(self.best_solutions_fitness) + if fitness.ndim == 1: + index = int(numpy.argmax(fitness)) + else: + index = self.sort_solutions_nsga2(fitness=fitness, + find_best_solution=True)[0] + generation = self.best_solutions_generations[index] + self.best_solution_generation = -1 if generation is None else generation + + def _saved_fitness_index(self, name, solutions, fitness): + """Index saved solutions incrementally, retaining the first match. + + Rebuild outside run() so edits to public history arrays are honored. + During a run, only newly appended snapshots need indexing. Fitness + is read from the source list on each hit, so changes to saved scores + are also respected without duplicating those scores in the index. + """ + indexes = getattr(self, '_saved_fitness_indexes', {}) + self._saved_fitness_indexes = indexes + count = min(len(solutions), len(fitness)) + indexed_solutions, indexed_count, lookup = indexes.get(name, (None, 0, {})) + if (not getattr(self, '_fitness_run_active', False) + or indexed_solutions is not solutions or indexed_count > count): + indexed_count, lookup = 0, {} + for index in range(indexed_count, count): + lookup.setdefault(tuple(solutions[index]), index) + indexes[name] = (solutions, count, lookup) + return lookup + + def _save_population_snapshot(self): + """Append independent population and fitness snapshots with their generation.""" + if self.save_solutions: + # Building each row preserves NumPy scalar types. tolist() + # would convert narrow NumPy types into Python int or float. + self.solutions.extend([list(solution) for solution in self.population]) + self.solutions_fitness.extend(self.last_generation_fitness.copy()) + self.solutions_generations.append(self.generations_completed) + self._saved_population_sizes.append(len(self.population)) + def round_genes(self, solutions): """ - Round the genes in ``solutions`` according to the precision - encoded in ``self.gene_type``. When ``gene_type_single`` is + Convert and round genes in ``solutions`` using ``self.gene_type``. + When ``gene_type_single`` is True, the same dtype and precision are applied to every gene; otherwise the per-gene dtype / precision pair is used. @@ -20,172 +108,117 @@ def round_genes(self, solutions): Returns ------- solutions : numpy.ndarray - The same array with the rounding applied. + The converted and rounded array. The original array is + updated when its dtype can hold the configured gene types. """ - if self.gene_type_single: - if not self.gene_type[1] is None: - solutions = numpy.round(numpy.asarray(solutions, dtype=self.gene_type[0]), - self.gene_type[1]) - else: - for gene_idx in range(self.num_genes): - if not self.gene_type[gene_idx][1] is None: - solutions[:, gene_idx] = numpy.round(numpy.asarray(solutions[:, gene_idx], - dtype=self.gene_type[gene_idx][0]), - self.gene_type[gene_idx][1]) - return solutions - - def initialize_population(self, - allow_duplicate_genes, - gene_type, - gene_constraint): - """ - Build the initial population at random and store it on the GA - instance. The procedure has four steps: generate the gene - values (from the gene space or the init range), apply the - gene dtype and rounding, enforce gene constraints, and resolve - duplicate genes when not allowed. - - Sets the following instance attributes: + converted_solutions = self.change_population_dtype_and_round(solutions) + if isinstance(solutions, numpy.ndarray) and solutions.dtype == converted_solutions.dtype: + solutions[:] = converted_solutions + return solutions + return converted_solutions - - ``pop_size``: a ``(sol_per_pop, num_genes)`` tuple. - - ``population``: the working population. Updated every - generation after this initial call. - - ``initial_population``: a frozen copy of the initial - population for later reference. + def initialize_population(self, allow_duplicate_genes, gene_type, gene_constraint): + """ + Generate and store the initial population using the validated + initialization settings. Sample the values, apply constraints, + and repair duplicate genes when they are not allowed. The working + population and its initial snapshot are independent arrays. Parameters ---------- allow_duplicate_genes : bool - If False, duplicate genes inside a single solution are - resolved by sampling new values. - gene_type : list or type - The dtype (and optional precision) for the genes. Used by - ``solve_duplicate_genes_randomly`` when resolving - duplicates outside the gene space. + Whether repeated gene values are allowed within a solution. + gene_type : type or list + Retained for compatibility. Sampling uses the validated + gene types and optional precisions stored in self.gene_type. gene_constraint : list or None - One callable per gene that returns the subset of a - candidate values list which satisfy the constraint. ``None`` - disables the per-gene constraint check. + Validated per-gene constraint callables. """ - - # Population size = (number of chromosomes, number of genes per chromosome) - # The population will have sol_per_pop chromosome where each chromosome has num_genes genes. - self.pop_size = (self.sol_per_pop, self.num_genes) - - # There are 4 steps to build the initial population: - # 1) Generate the population. - # 2) Change the data type and round the values. - # 3) Check for the constraints. - # 4) Solve duplicates if not allowed. - - # Create an empty population. - self.population = numpy.empty(shape=self.pop_size, dtype=object) - - # 1) Create the initial population either randomly or using the gene space. - if self.gene_space is None: - # Create the initial population randomly. - - # Set gene_value=None to consider generating values for the initial population instead of generating values for mutation. - # Loop through the genes, randomly generate the values of a single gene at a time, and insert the values of each gene to the population. - for sol_idx in range(self.sol_per_pop): - for gene_idx in range(self.num_genes): - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - self.population[sol_idx, gene_idx] = self.generate_gene_value_randomly(range_min=range_min, - range_max=range_max, - gene_idx=gene_idx, - mutation_by_replacement=True, - gene_value=None, - sample_size=1, - step=1) - - else: - # Generate the initial population using the gene_space. - for sol_idx in range(self.sol_per_pop): - for gene_idx in range(self.num_genes): - self.population[sol_idx, gene_idx] = self.generate_gene_value_from_space(gene_idx=gene_idx, - mutation_by_replacement=True, - gene_value=None, - solution=self.population[sol_idx], - sample_size=1) - - # 2) Change the data type and round all genes within the initial population. - # This step is necessary before applying the gene constraints since the right gene value must be used for accuracy. - self.population = self.change_population_dtype_and_round(self.population) - - # Note that gene_constraint is not validated yet. - # We have to set it as a property of the pygad.GA instance to retrieve without passing it as an additional parameter. + # Store the policies passed by the constructor or by a caller + # explicitly rebuilding the initial population. + self.allow_duplicate_genes = allow_duplicate_genes self.gene_constraint = gene_constraint + self.pop_size = (self.sol_per_pop, self.num_genes) + self.population = self.generate_initial_population(self.sol_per_pop) + self.initial_population = self.population.copy() - # 3) Enforce the gene constraints as much as possible. - if self.gene_constraint is None: - pass - else: - for sol_idx, solution in enumerate(self.population): - for gene_idx in range(self.num_genes): - # Check that a constraint is available for the gene and that the current value does not satisfy that constraint - if self.gene_constraint[gene_idx]: - # Remember that the second argument to the gene constraint callable is a list/numpy.ndarray of the values to check if they meet the gene constraint. - values = [solution[gene_idx]] - filtered_values = self.gene_constraint[gene_idx](solution, values) - result = self.validate_gene_constraint_callable_output(selected_values=filtered_values, - values=values) - if result: - pass - else: - raise Exception("The output from the gene_constraint callable/function must be a list or NumPy array that is a subset of the passed values (second argument).") - - if len(filtered_values) ==1 and filtered_values[0] != solution[gene_idx]: - # Error by the user's defined gene constraint callable. - raise Exception(f"It is expected to receive a list/numpy.ndarray from the gene_constraint callable with a single value equal to {values[0]}, but the value {filtered_values[0]} found.") - - # Check if the gene value does not satisfy the gene constraint. - # Note that we already passed a list of a single value. - # It is expected to receive a list of either a single value or an empty list. - if len(filtered_values) < 1: - # Search for a value that satisfies the gene constraint. - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - # While initializing the population, we follow a mutation by replacement approach. So, the original gene value is not needed. - values_filtered = self.get_valid_gene_constraint_values(range_min=range_min, - range_max=range_max, - gene_value=None, - gene_idx=gene_idx, - mutation_by_replacement=True, - solution=solution, - sample_size=self.sample_size) - if values_filtered is None: - if not self.suppress_warnings: - warnings.warn(f"No value satisfied the constraint for the gene at index {gene_idx} with value {solution[gene_idx]} while creating the initial population.") - else: - self.population[sol_idx, gene_idx] = random.choice(values_filtered) - elif len(filtered_values) == 1: - # The value already satisfied the gene constraint. - pass - else: - # Error by the user's defined gene constraint callable. - raise Exception(f"It is expected to receive a list/numpy.ndarray from the gene_constraint callable that is either empty or has a single value equal, but received a list/numpy.ndarray of length {len(filtered_values)}.") - - # 4) Solve duplicate genes. - if allow_duplicate_genes == False: - for solution_idx in range(self.population.shape[0]): + def generate_initial_population(self, num_solutions): + """ + Return new solutions using the initialization settings. Sampling + in bulk avoids rebuilding finite candidate sets for every solution + and calling the sampler for every value. NSGA-III population growth + uses this same method. + """ + continuous_space = (self.gene_space is None or + (type(self.gene_space) is dict and 'step' not in self.gene_space)) + if self.gene_type_single and self.gene_type[0] in self.supported_float_types and continuous_space: + if self.gene_space is None: + lower = numpy.minimum(self.init_range_low, self.init_range_high) + upper = numpy.maximum(self.init_range_low, self.init_range_high) + else: + lower = min(self.gene_space['low'], self.gene_space['high']) + upper = max(self.gene_space['low'], self.gene_space['high']) + # A single draw retains the traditional solution-then-gene + # order for continuous populations while avoiding scalar calls. + population = self.numpy_random_generator.uniform(lower, upper, size=(num_solutions, self.num_genes)) + for gene_index in range(self.num_genes): if self.gene_space is None: - self.population[solution_idx], _, _ = self.solve_duplicate_genes_randomly(solution=self.population[solution_idx], - min_val=self.init_range_low, - max_val=self.init_range_high, - gene_type=gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size) + gene_lower, gene_upper = self.get_initial_population_range(gene_index) + gene_lower, gene_upper = sorted([gene_lower, gene_upper]) else: - self.population[solution_idx], _, _ = self.solve_duplicate_genes_by_space(solution=self.population[solution_idx].copy(), - gene_type=self.gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size, - build_initial_pop=True) + gene_lower, gene_upper = lower, upper + population[:, gene_index] = self._convert_initial_population_range_values( + gene_index, gene_lower, gene_upper, population[:, gene_index]) + else: + population = numpy.empty((num_solutions, self.num_genes), dtype=object) + for gene_index in range(self.num_genes): + population[:, gene_index] = self.sample_initial_population_gene_values( + gene_index, num_solutions) + return self.prepare_initial_population(population) - # Change the data type and round all genes within the initial population. - self.population = self.change_population_dtype_and_round(self.population) + def prepare_initial_population(self, population): + """ + Convert a generated or supplied population, then apply constraints + and duplicate repair. Existing supplied values need not belong to + the gene space or initialization range. Any replacement uses the + initialization settings for its own gene. + """ + population = self.change_population_dtype_and_round(population) + population = self.apply_initial_population_gene_constraints(population) + if not self.allow_duplicate_genes: + population = self.solve_duplicate_genes_in_population( + population, build_initial_pop=True) + return population - # Keeping the initial population in the initial_population attribute. - self.initial_population = self.population.copy() + def apply_initial_population_gene_constraints(self, population, warn=True): + """ + Replace values rejected by their constraints using converted + initialization candidates. Constraints see the complete solution + and are applied in gene-index order. Leave the existing value and + warn when no candidate satisfies a constraint. + """ + if self.gene_constraint is None: + return population + for solution in population: + for gene_index, constraint in enumerate(self.gene_constraint): + if constraint is None: + continue + accepted_values = self.filter_gene_values_by_constraint( + [solution[gene_index]], solution, gene_index, warn=False) + if accepted_values is not None: + if len(accepted_values) != 1: + raise ValueError("A gene constraint checking a single value must return an empty list or NumPy array, or one containing only that value.") + continue + candidates = self.get_initial_population_gene_candidates( + gene_index, self.sample_size, all_integer_values=False) + accepted_values = self.filter_gene_values_by_constraint( + candidates, solution, gene_index, warn=False) + if accepted_values is None: + if warn and not self.suppress_warnings: + warnings.warn(f"No value satisfied the constraint for the gene at index {gene_index} with value {solution[gene_index]} while creating the initial population.") + else: + solution[gene_index] = self.python_random_generator.choice(accepted_values) + return population def cal_pop_fitness(self): """Compute population fitness with the same cache rules in all modes.""" @@ -197,31 +230,41 @@ def cal_pop_fitness(self): if type(self.best_solutions) is numpy.ndarray: self.best_solutions = self.best_solutions.tolist() - saved_solutions = (self.solutions.tolist() - if type(self.solutions) is numpy.ndarray - else self.solutions) + saved_solutions = self.solutions + saved_index = (self._saved_fitness_index('solutions', saved_solutions, + self.solutions_fitness) if self.save_solutions else {}) + best_index = (self._saved_fitness_index('best_solutions', self.best_solutions, + self.best_solutions_fitness) if self.save_best_solutions else {}) parents = (self.last_generation_parents.tolist() if self.last_generation_parents is not None else []) elites = (self.last_generation_elitism.tolist() if self.last_generation_elitism is not None else []) + parent_index = {} + elite_index = {} + for index, solution in enumerate(parents): + parent_index.setdefault(tuple(solution), index) + for index, solution in enumerate(elites): + elite_index.setdefault(tuple(solution), index) pop_fitness = [None] * len(self.population) missing_indices = [] + if not getattr(self, '_fitness_run_active', False): + self._fitness_value_shape = None for index, solution in enumerate(self.population): - values = solution.tolist() - if self.save_solutions and values in saved_solutions: - fitness = self.solutions_fitness[saved_solutions.index(values)] - elif self.save_best_solutions and values in self.best_solutions: - fitness = self.best_solutions_fitness[self.best_solutions.index(values)] - elif self.keep_elitism > 0 and values in elites: - previous_index = self.last_generation_elitism_indices[elites.index(values)] + values = tuple(solution) + if values in saved_index: + fitness = self.solutions_fitness[saved_index[values]] + elif values in best_index: + fitness = self.best_solutions_fitness[best_index[values]] + elif self.keep_elitism > 0 and values in elite_index: + previous_index = self.last_generation_elitism_indices[elite_index[values]] fitness = self.previous_generation_fitness[previous_index] - elif self.keep_parents != 0 and values in parents: - previous_index = self.last_generation_parents_indices[parents.index(values)] + elif self.keep_parents != 0 and values in parent_index: + previous_index = self.last_generation_parents_indices[parent_index[values]] fitness = self.previous_generation_fitness[previous_index] else: missing_indices.append(index) continue - pop_fitness[index] = fitness + pop_fitness[index] = self._validate_fitness_value(fitness, 'cached fitness') fitness_values = self._evaluate_fitness(self.population, missing_indices) for index, fitness in zip(missing_indices, fitness_values): @@ -258,12 +301,15 @@ def run(self): current number of objectives. """ self._fitness_run_active = True + self._saved_fitness_indexes = {} + self._fitness_value_shape = None try: if self.valid_parameters == False: raise Exception("Error calling the run() method: \nThe run() method cannot be executed with invalid parameters. Please check the parameters passed while creating an instance of the GA class.\n") # Starting from PyGAD 2.18.0, the 4 properties (best_solutions, best_solutions_fitness, solutions, and solutions_fitness) are no longer reset with each call to the run() method. Instead, they are extended. - # For example, if there are 50 generations and the user set save_best_solutions=True, then the length of the 2 properties best_solutions and best_solutions_fitness will be 50 after the first call to the run() method, then 100 after the second call, 150 after the third, and so on. + # Each run retains its starting population and every completed generation. + self._restore_history_generations() # self.best_solutions: Holds the best solution in each generation. if type(self.best_solutions) is numpy.ndarray: @@ -327,9 +373,12 @@ def run(self): if self.save_best_solutions: self.best_solutions.append(list(best_solution)) + unchanged_generations = 0 + for generation in range(generation_first_idx, generation_last_idx): self.run_loop_head(best_solution_fitness) + previous_best_fitness = self.best_solutions_fitness[-1] # Call the 'run_select_parents()' method to select the parents. # It edits these 2 instance attributes: @@ -353,6 +402,15 @@ def run(self): # 1) population: A NumPy array of the population of solutions/chromosomes. self.run_update_population() + # User operators and callbacks may edit the public histories. + # Rebuild their indexes after those calls; ordinary built-in + # evolution keeps the incremental indexes between generations. + if (any(callable(operator) for operator in + (self.parent_selection_type, self.crossover_type, self.mutation_type)) + or any(callback is not None for callback in + (self.on_parents, self.on_crossover, self.on_mutation))): + self._saved_fitness_indexes = {} + # The generations_completed attribute holds the number of the last completed generation. self.generations_completed = generation + 1 @@ -362,6 +420,10 @@ def run(self): best_solution, best_solution_fitness, best_match_idx = self.best_solution( pop_fitness=self.last_generation_fitness) + if numpy.array_equal(previous_best_fitness, best_solution_fitness): + unchanged_generations += 1 + else: + unchanged_generations = 0 # Appending the best solution in the current generation to the best_solutions list. if self.save_best_solutions: @@ -371,6 +433,7 @@ def run(self): # If the on_generation attribute is not None, then call the callback function after the generation. if not (self.on_generation is None): r = self.on_generation(self) + self._saved_fitness_indexes = {} if type(r) is str and r.lower() == "stop": break @@ -415,22 +478,9 @@ def run(self): stop_run = False break elif criterion[0] == "saturate": - criterion[1] = int(criterion[1]) - if self.generations_completed >= criterion[1]: - # Single-objective problem. - if type(self.last_generation_fitness[0]) in self.supported_int_float_types: - if (self.best_solutions_fitness[self.generations_completed - criterion[1]] - self.best_solutions_fitness[self.generations_completed - 1]) == 0: - stop_run = True - break - # Multi-objective problem. - elif type(self.last_generation_fitness[0]) in [list, tuple, numpy.ndarray]: - stop_run = True - for obj_idx in range(len(self.last_generation_fitness[0])): - if (self.best_solutions_fitness[self.generations_completed - criterion[1]][obj_idx] - self.best_solutions_fitness[self.generations_completed - 1][obj_idx]) == 0: - pass - else: - stop_run = False - break + if unchanged_generations >= criterion[1]: + stop_run = True + break elif criterion[0] == "time": # Stop when the time spent inside run() # passes the user limit. @@ -449,13 +499,7 @@ def run(self): break # Save the fitness of the last generation. - if self.save_solutions: - # self.solutions.extend(self.population.copy()) - population_as_list = self.population.copy() - population_as_list = [list(item) for item in population_as_list] - self.solutions.extend(population_as_list) - - self.solutions_fitness.extend(self.last_generation_fitness) + self._save_population_snapshot() # Call the run_select_parents() method to update these 2 attributes according to the 'last_generation_fitness' attribute: # 1) last_generation_parents 2) last_generation_parents_indices @@ -468,12 +512,15 @@ def run(self): num_parents=self.keep_elitism) # Save the fitness value of the best solution. - _, best_solution_fitness, _ = self.best_solution( + best_solution, best_solution_fitness, _ = self.best_solution( pop_fitness=self.last_generation_fitness) - self.best_solutions_fitness.append(best_solution_fitness) + if self.save_best_solutions: + self.best_solutions[-1] = best_solution.tolist() + self.best_solutions_fitness.append(numpy.copy(best_solution_fitness) + if isinstance(best_solution_fitness, numpy.ndarray) else best_solution_fitness) + self.best_solutions_generations.append(self.generations_completed) - self.best_solution_generation = numpy.where(numpy.array( - self.best_solutions_fitness) == numpy.max(numpy.array(self.best_solutions_fitness)))[0][0] + self._update_best_solution_generation() # After the run() method completes, the run_completed flag is changed from False to True. # Set to True only after the run() method completes gracefully. self.run_completed = True @@ -482,7 +529,7 @@ def run(self): self.on_stop(self, self.last_generation_fitness) # Converting the 'best_solutions' list into a NumPy array. - self.best_solutions = numpy.array(self.best_solutions) + self.best_solutions = numpy.array(self.best_solutions, dtype=self.population.dtype) # Update previous_generation_fitness because it is used to get the fitness of the parents. self.previous_generation_fitness = self.last_generation_fitness.copy() @@ -502,7 +549,7 @@ def run_loop_head(self, best_solution_fitness): """ Run the bookkeeping that takes place at the top of every generation: call ``self.on_fitness`` if set (with optional - validation of the returned values), append the running best + validation of returned or edited values), recompute and append the best fitness to ``self.best_solutions_fitness``, and append the current population and fitness to ``self.solutions`` / ``self.solutions_fitness`` when ``self.save_solutions`` is @@ -513,7 +560,8 @@ def run_loop_head(self, best_solution_fitness): Parameters ---------- best_solution_fitness : numeric or numpy.ndarray - Fitness of the best solution in the previous generation. + Retained for compatibility. The best fitness is recomputed + after on_fitness so it agrees with the saved solution. Raises ------ @@ -522,8 +570,10 @@ def run_loop_head(self, best_solution_fitness): match the population fitness, or an unsupported type. """ if not (self.on_fitness is None): + expected_shape = self.last_generation_fitness.shape on_fitness_output = self.on_fitness(self, self.last_generation_fitness) + self._saved_fitness_indexes = {} if on_fitness_output is None: pass @@ -536,18 +586,25 @@ def run_loop_head(self, best_solution_fitness): raise ValueError(f"Size mismatch between the output of on_fitness() {on_fitness_output.shape} and the expected fitness output {self.last_generation_fitness.shape}.") else: raise ValueError(f"The output of on_fitness() is expected to be tuple/list/range/numpy.ndarray but {type(on_fitness_output)} found.") + self.last_generation_fitness = self._validate_population_fitness( + self.last_generation_fitness, 'on_fitness output') + if self.last_generation_fitness.shape != expected_shape: + raise ValueError(f"Size mismatch between the output of on_fitness() {self.last_generation_fitness.shape} and the expected fitness output {expected_shape}.") # Appending the fitness value of the best solution in the current generation to the best_solutions_fitness attribute. - self.best_solutions_fitness.append(best_solution_fitness) + if self.on_fitness is not None: + best_solution, best_solution_fitness, _ = self.best_solution( + pop_fitness=self.last_generation_fitness) + self.best_solutions_fitness.append(numpy.copy(best_solution_fitness) + if isinstance(best_solution_fitness, numpy.ndarray) else best_solution_fitness) + self.best_solutions_generations.append(self.generations_completed) + if self.save_best_solutions and self.on_fitness is not None: + # This snapshot was appended before on_fitness ran. The callback + # may change which solution is best, so update the matching entry. + self.best_solutions[-1] = best_solution.tolist() # Appending the solutions in the current generation to the solutions list. - if self.save_solutions: - # self.solutions.extend(self.population.copy()) - population_as_list = self.population.copy() - population_as_list = [list(item) for item in population_as_list] - self.solutions.extend(population_as_list) - - self.solutions_fitness.extend(self.last_generation_fitness) + self._save_population_snapshot() def run_select_parents(self, call_on_parents=True): """ @@ -612,6 +669,9 @@ def run_select_parents(self, call_on_parents=True): elif len(self.last_generation_parents_indices) != self.num_parents_mating: raise ValueError(f"The iterable holding the selected parents indices is expected to have ({self.num_parents_mating}) values but ({len(self.last_generation_parents_indices)}) found.") + if callable(self.parent_selection_type): + self.last_generation_parents = self.change_population_dtype_and_round(self.last_generation_parents) + if call_on_parents: if not (self.on_parents is None): on_parents_output = self.on_parents(self, @@ -630,7 +690,7 @@ def run_select_parents(self, call_on_parents=True): raise ValueError("The returned outputs of on_parents() cannot be None but the first output is None.") else: if type(on_parents_selected_parents) in [tuple, list, numpy.ndarray]: - on_parents_selected_parents = numpy.array(on_parents_selected_parents) + on_parents_selected_parents = numpy.asarray(on_parents_selected_parents, dtype=object) if on_parents_selected_parents.shape == self.last_generation_parents.shape: self.last_generation_parents = on_parents_selected_parents else: @@ -655,6 +715,9 @@ def run_select_parents(self, call_on_parents=True): else: raise TypeError(f"The output of on_parents() is expected to be tuple/list/numpy.ndarray but {type(on_parents_output)} found.") + if call_on_parents and self.on_parents is not None: + self.last_generation_parents = self.change_population_dtype_and_round(self.last_generation_parents) + def run_crossover(self): """ Run the crossover step of one generation. Produces @@ -721,6 +784,10 @@ def run_crossover(self): elif self.last_generation_offspring_crossover.shape[1] != self.num_genes: raise ValueError(f"Size mismatch between the crossover output {self.last_generation_offspring_crossover.shape} and the expected crossover output {(self.num_offspring, self.num_genes)}. It is expected that the offspring has ({self.num_genes}) genes but ({self.last_generation_offspring_crossover.shape[1]}) produced.") + if callable(self.crossover_type) and self.on_crossover is not None: + self.last_generation_offspring_crossover = self.change_population_dtype_and_round( + self.last_generation_offspring_crossover) + # PyGAD 2.18.2 // The on_crossover() callback function is called even if crossover_type is None. if not (self.on_crossover is None): on_crossover_output = self.on_crossover(self, @@ -729,7 +796,7 @@ def run_crossover(self): pass else: if type(on_crossover_output) in [tuple, list, numpy.ndarray]: - on_crossover_output = numpy.array(on_crossover_output) + on_crossover_output = numpy.asarray(on_crossover_output, dtype=object) if on_crossover_output.shape == self.last_generation_offspring_crossover.shape: self.last_generation_offspring_crossover = on_crossover_output else: @@ -737,6 +804,11 @@ def run_crossover(self): else: raise ValueError(f"The output of on_crossover() is expected to be tuple/list/numpy.ndarray but {type(on_crossover_output)} found.") + if callable(self.crossover_type) or self.on_crossover is not None: + self.last_generation_offspring_crossover = self.prepare_operator_output( + self.last_generation_offspring_crossover, + build_initial_pop=self.crossover_type == 'sbx') + def run_mutation(self): """ Run the mutation step of one generation. Mutates the @@ -781,6 +853,10 @@ def run_mutation(self): elif self.last_generation_offspring_mutation.shape[1] != self.num_genes: raise ValueError(f"Size mismatch between the mutation output {self.last_generation_offspring_mutation.shape} and the expected mutation output {(self.num_offspring, self.num_genes)}. It is expected that the offspring has ({self.num_genes}) genes but ({self.last_generation_offspring_mutation.shape[1]}) produced.") + if callable(self.mutation_type) and self.on_mutation is not None: + self.last_generation_offspring_mutation = self.change_population_dtype_and_round( + self.last_generation_offspring_mutation) + # PyGAD 2.18.2 // The on_mutation() callback function is called even if mutation_type is None. if not (self.on_mutation is None): on_mutation_output = self.on_mutation(self, @@ -790,7 +866,7 @@ def run_mutation(self): pass else: if type(on_mutation_output) in [tuple, list, numpy.ndarray]: - on_mutation_output = numpy.array(on_mutation_output) + on_mutation_output = numpy.asarray(on_mutation_output, dtype=object) if on_mutation_output.shape == self.last_generation_offspring_mutation.shape: self.last_generation_offspring_mutation = on_mutation_output else: @@ -798,6 +874,34 @@ def run_mutation(self): else: raise ValueError(f"The output of on_mutation() is expected to be tuple/list/numpy.ndarray but {type(on_mutation_output)} found.") + if callable(self.mutation_type) or self.on_mutation is not None: + self.last_generation_offspring_mutation = self.prepare_operator_output( + self.last_generation_offspring_mutation, + build_initial_pop=self.mutation_type == 'polynomial') + + def prepare_operator_output(self, population, build_initial_pop=False): + """ + Convert an operator's population using the configured gene types + and precision, then repair duplicates if they are disallowed. + + Parameters + ---------- + population : numpy.ndarray + Parents or offspring to prepare without modifying the input. + build_initial_pop : bool + Use initialization bounds for duplicate repair, as required + for SBX crossover and polynomial mutation. + + Returns + ------- + numpy.ndarray + The converted population, with duplicate repair applied when + allow_duplicate_genes is False. + """ + if self.allow_duplicate_genes: + return self.change_population_dtype_and_round(population) + return self.solve_duplicate_genes_in_population(population, build_initial_pop=build_initial_pop) + def run_update_population(self): """ Build the next generation in ``self.population`` from the @@ -878,24 +982,24 @@ def best_solution(self, pop_fitness=None): pop_fitness = self.cal_pop_fitness() # Verify the type of the 'pop_fitness' parameter. elif type(pop_fitness) in [tuple, list, numpy.ndarray]: + if isinstance(pop_fitness, numpy.ndarray) and pop_fitness.ndim == 0: + raise ValueError("pop_fitness must contain one fitness value per population solution.") # Verify that the length of the passed population fitness matches the length of the 'self.population' attribute. if len(pop_fitness) == len(self.population): # This successfully verifies the 'pop_fitness' parameter. - pass + pop_fitness = self._validate_population_fitness( + pop_fitness, 'pop_fitness', check_shape=False) else: raise ValueError(f"The length of the list/tuple/numpy.ndarray passed to the 'pop_fitness' parameter ({len(pop_fitness)}) must match the length of the 'self.population' attribute ({len(self.population)}).") else: raise ValueError(f"The type of the 'pop_fitness' parameter is expected to be list, tuple, or numpy.ndarray but ({type(pop_fitness)}) found.") - # Return the index of the best solution that has the best fitness value. - # For multi-objective optimization: find the index of the solution with the maximum fitness in the first objective, - # break ties using the second objective, then third, etc. + # Use the same ordering as parent selection for multi-objective fitness. pop_fitness_arr = numpy.array(pop_fitness) # Get the indices that would sort by all objectives in descending order if pop_fitness_arr.ndim == 1: # Single-objective optimization. - best_match_idx = numpy.where( - pop_fitness == numpy.max(pop_fitness))[0][0] + best_match_idx = int(numpy.argmax(pop_fitness_arr)) elif pop_fitness_arr.ndim == 2: # Multi-objective optimization. # Use NSGA-2 to sort the solutions using the fitness. @@ -961,137 +1065,17 @@ def _nsga3_grow_population(self, required_size, num_objectives): self.last_generation_fitness = self.cal_pop_fitness() def _nsga3_generate_extra_random_solutions(self, count): - """ - Build ``count`` random solutions that obey every initial- - population rule: ``gene_space``, ``init_range_low`` / - ``init_range_high``, ``gene_type`` (including nested per-gene - type / precision), ``gene_constraint``, and - ``allow_duplicate_genes``. - - Steps mirror ``initialize_population``: - 1. Sample each gene from its space (or init range). - 2. Cast and round to the configured gene type. - 3. Enforce gene constraints when present. - 4. Resolve duplicate genes when not allowed. - """ - extra = numpy.empty((count, self.num_genes), dtype=object) - for sol_idx in range(count): - for gene_idx in range(self.num_genes): - extra[sol_idx, gene_idx] = self._nsga3_generate_single_random_gene( - gene_idx, extra[sol_idx]) - extra = self.change_population_dtype_and_round(extra) - - if self.gene_constraint is not None: - extra = self._nsga3_apply_gene_constraints(extra) - - if not self.allow_duplicate_genes: - extra = self._nsga3_resolve_duplicate_genes(extra) - extra = self.change_population_dtype_and_round(extra) - - return extra + """Generate NSGA-III growth rows using the initialization settings.""" + return self.generate_initial_population(count) def _nsga3_generate_single_random_gene(self, gene_idx, partial_solution): - """ - Pick a single random gene value for ``gene_idx`` using the - initial-population settings. When ``gene_space`` is set, the - gene-space sampler is used; otherwise the per-gene init range - is used. ``mutation_by_replacement`` is forced to True so the - sampler returns a value drawn from the configured range rather - than an offset to add to an existing gene (which is the - mutation-time behavior). - """ - if self.gene_space is None: - range_min, range_max = self.get_initial_population_range( - gene_index=gene_idx) - return self.generate_gene_value_randomly(range_min=range_min, - range_max=range_max, - gene_idx=gene_idx, - mutation_by_replacement=True, - gene_value=None, - sample_size=1, - step=1) - return self.generate_gene_value_from_space(gene_idx=gene_idx, - mutation_by_replacement=True, - gene_value=None, - solution=partial_solution, - sample_size=1) + """Compatibility helper for sampling one initialization value.""" + return self.sample_initial_population_gene_values(gene_idx, 1)[0] def _nsga3_apply_gene_constraints(self, population): - """ - Walk the new rows and replace any gene that does not satisfy - its gene constraint, using the same logic that - ``initialize_population`` runs during the initial build. - """ - for sol_idx, solution in enumerate(population): - for gene_idx in range(self.num_genes): - if not self.gene_constraint[gene_idx]: - continue - values = [solution[gene_idx]] - filtered_values = self.gene_constraint[gene_idx](solution, values) - result = self.validate_gene_constraint_callable_output( - selected_values=filtered_values, values=values) - if not result: - raise Exception( - "The output from the gene_constraint callable/function " - "must be a list or NumPy array that is a subset of the " - "passed values (second argument).") - if len(filtered_values) == 1 and filtered_values[0] != solution[gene_idx]: - raise Exception( - f"It is expected to receive a list/numpy.ndarray from " - f"the gene_constraint callable with a single value " - f"equal to {values[0]}, but the value " - f"{filtered_values[0]} found.") - if len(filtered_values) < 1: - range_min, range_max = self.get_initial_population_range( - gene_index=gene_idx) - values_filtered = self.get_valid_gene_constraint_values( - range_min=range_min, - range_max=range_max, - gene_value=None, - gene_idx=gene_idx, - mutation_by_replacement=True, - solution=solution, - sample_size=self.sample_size, - ) - if values_filtered is None: - if not self.suppress_warnings: - warnings.warn( - f"No value satisfied the constraint for the " - f"gene at index {gene_idx} with value " - f"{solution[gene_idx]} while growing the " - f"population for NSGA-III.") - else: - population[sol_idx, gene_idx] = random.choice(values_filtered) - elif len(filtered_values) > 1: - raise Exception( - f"It is expected to receive a list/numpy.ndarray from " - f"the gene_constraint callable that is either empty or " - f"has a single value equal, but received a list/numpy." - f"ndarray of length {len(filtered_values)}.") - return population + """Compatibility helper for applying initialization constraints.""" + return self.apply_initial_population_gene_constraints(population) def _nsga3_resolve_duplicate_genes(self, population): - """ - Apply the same duplicate-resolution path - ``initialize_population`` uses, so the grown rows never carry - duplicate genes when ``allow_duplicate_genes`` is False. - """ - for solution_idx in range(population.shape[0]): - if self.gene_space is None: - population[solution_idx], _, _ = self.solve_duplicate_genes_randomly( - solution=population[solution_idx], - min_val=self.init_range_low, - max_val=self.init_range_high, - gene_type=self.gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size, - ) - else: - population[solution_idx], _, _ = self.solve_duplicate_genes_by_space( - solution=population[solution_idx].copy(), - gene_type=self.gene_type, - mutation_by_replacement=True, - sample_size=self.sample_size, - build_initial_pop=True, - ) - return population + """Repair newly generated rows using initialization rules.""" + return self.solve_duplicate_genes_in_population(population, build_initial_pop=True) diff --git a/pygad/utils/mutation.py b/pygad/utils/mutation.py index 0fd44e1c..8333e557 100644 --- a/pygad/utils/mutation.py +++ b/pygad/utils/mutation.py @@ -2,8 +2,9 @@ The pygad.utils.mutation module has all the built-in mutation operators. """ +from itertools import chain, combinations + import numpy -import random import pygad @@ -72,7 +73,7 @@ def mutation_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), self.mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), self.mutation_num_genes)) swapped_genes = set() for gene_idx in mutation_indices: @@ -90,15 +91,10 @@ def mutation_by_space(self, offspring): swapped_genes=swapped_genes) continue - # Before assigning the selected value from the space to the gene, change its data type and round it. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def mutation_probs_by_space(self, offspring): @@ -121,14 +117,14 @@ def mutation_probs_by_space(self, offspring): # For each offspring, a value from the gene space is selected randomly and assigned to the selected mutated gene. for offspring_idx in range(offspring.shape[0]): - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) swapped_genes = set() for gene_idx in range(offspring.shape[1]): if gene_idx in swapped_genes: continue - if probs[gene_idx] <= self.mutation_probability: + if probs[gene_idx] < self.mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, sample_size=self.sample_size) @@ -141,14 +137,10 @@ def mutation_probs_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def mutation_process_gene_value(self, @@ -192,7 +184,7 @@ def mutation_process_gene_value(self, """ # Check if the gene has a constraint. - if self.gene_constraint and self.gene_constraint[gene_idx]: + if self.gene_constraint and self.gene_constraint[gene_idx] is not None: # Generate values that meet the gene constraint. Select more than 1 value. # This method: 1) generates or selects the values 2) filters the values according to the constraint. values = self.get_valid_gene_constraint_values(range_min=range_min, @@ -208,8 +200,8 @@ def mutation_process_gene_value(self, value_selected = solution[gene_idx] else: # Select a value randomly from the list of values satisfying the constraint. - # If size is used with numpy.random.choice(), it returns an array even if it has a single value. To return a numeric value, not an array, then return index 0. - value_selected = numpy.random.choice(values, size=1)[0] + # If size is used with self.numpy_random_generator.choice(), it returns an array even if it has a single value. To return a numeric value, not an array, then return index 0. + value_selected = self.numpy_random_generator.choice(values, size=1)[0] else: # The gene does not have a constraint. Just select a single value. value_selected = self.generate_gene_value(range_min=range_min, @@ -219,8 +211,10 @@ def mutation_process_gene_value(self, solution=solution, mutation_by_replacement=self.mutation_by_replacement, sample_size=1) - # Even though its name is singular, it might hold multiple values. - return value_selected + # Candidate arrays use NumPy scalar types. Match the declared + # Python or NumPy scalar type before storing the selected value. + dtype = self.get_gene_dtype(gene_idx)[0] + return value_selected if dtype is object else dtype(value_selected) def swap_gene_by_space(self, solution, @@ -256,14 +250,8 @@ def swap_gene_by_space(self, The solution after the swap, unchanged if no gene qualifies. """ - def gene_space_values(idx): - if self.gene_space_nested or not self.gene_type_single: - return self.gene_space_unpacked[idx] - else: - return self.gene_space_unpacked - def has_constraint(idx): - return bool(self.gene_constraint and self.gene_constraint[idx]) + return bool(self.gene_constraint and self.gene_constraint[idx] is not None) if swapped_genes is None: swapped_genes = set() @@ -288,10 +276,11 @@ def has_constraint(idx): # Preserve the original set of numeric values. In particular, # casting or rounding must not turn a value into a duplicate. - if new_gene_value != other_value or new_other_value != gene_value: + if (self._gene_value_key(new_gene_value) != self._gene_value_key(other_value) + or self._gene_value_key(new_other_value) != self._gene_value_key(gene_value)): continue - if (new_gene_value not in gene_space_values(gene_idx) - or new_other_value not in gene_space_values(other_idx)): + if (not self.is_gene_value_in_space(gene_idx, new_gene_value, gene_value) + or not self.is_gene_value_in_space(other_idx, new_other_value, other_value)): continue # A constraint on a different gene may depend on either @@ -300,24 +289,13 @@ def has_constraint(idx): candidate_solution = solution.copy() candidate_solution[gene_idx] = new_gene_value candidate_solution[other_idx] = new_other_value - constraints_satisfied = True - for idx, constraint in enumerate(self.gene_constraint): - if constraint is None: - continue - values = numpy.array([candidate_solution[idx]]) - selected_values = constraint(candidate_solution.copy(), values.copy()) - if not self.validate_gene_constraint_callable_output(selected_values, values): - raise Exception("The output from the gene_constraint callable/function must be a list or NumPy array that is a subset of the passed values (second argument).") - if len(selected_values) == 0: - constraints_satisfied = False - break - if not constraints_satisfied: + if not self.solution_satisfies_gene_constraints(candidate_solution): continue candidates.append((other_idx, new_gene_value, new_other_value)) if len(candidates) > 0: - other_idx, new_gene_value, new_other_value = random.choice(candidates) + other_idx, new_gene_value, new_other_value = self.python_random_generator.choice(candidates) solution[gene_idx] = new_gene_value solution[other_idx] = new_other_value swapped_genes.update((gene_idx, other_idx)) @@ -343,7 +321,7 @@ def mutation_randomly(self, offspring): # Random mutation changes one or more genes in each offspring randomly. for offspring_idx in range(offspring.shape[0]): # Return the indices of the genes to mutate. - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), self.mutation_num_genes)) for gene_idx in mutation_indices: @@ -359,12 +337,7 @@ def mutation_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring @@ -389,13 +362,13 @@ def mutation_probs_randomly(self, offspring): # Random mutation changes one or more genes in each offspring randomly. for offspring_idx in range(offspring.shape[0]): # The mutation probabilities for the current offspring. - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): range_min, range_max = self.get_random_mutation_range(gene_idx) - # A gene is mutated only if its mutation probability is less than or equal to the threshold. - if probs[gene_idx] <= self.mutation_probability: + # A gene is mutated only if its mutation probability is less than the threshold. + if probs[gene_idx] < self.mutation_probability: # Generate a random value for mutation that meets the gene constraint, if one exists. random_value = self.mutation_process_gene_value(range_min=range_min, @@ -407,12 +380,7 @@ def mutation_probs_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def polynomial_mutation(self, offspring): @@ -437,129 +405,115 @@ def polynomial_mutation(self, offspring): The mutated offspring. """ eta = float(self.polynomial_mutation_eta) - per_gene_probability = (self.mutation_probability - if self.mutation_probability is not None - else 1.0 / self.num_genes) eta_plus_one = eta + 1.0 - near_zero = 1e-14 - - for sol_idx in range(offspring.shape[0]): - for gene_idx in range(offspring.shape[1]): - if numpy.random.random() > per_gene_probability: - continue - - range_min, range_max = self.get_initial_population_range(gene_index=gene_idx) - lower = float(range_min) - upper = float(range_max) - if upper - lower < near_zero: + for solution_index, solution in enumerate(offspring): + original = solution.copy() + if self.mutation_probability is not None or not self.mutation_control_explicitly_set: + probability = self.mutation_probability if self.mutation_probability is not None else 1.0 / self.num_genes + gene_indices = (index for index in range(self.num_genes) if self.numpy_random_generator.random() < probability) + else: + gene_indices = self.select_mutation_gene_indices() + selected_any_gene = False + for gene_index in gene_indices: + selected_any_gene = True + lower, upper = map(float, self.get_bounded_operator_gene_range(gene_index)) + if upper - lower < 1e-14: + solution[gene_index] = self.convert_bounded_operator_gene_value(gene_index, lower) continue - - gene_value = float(offspring[sol_idx, gene_idx]) - delta_lower = (gene_value - lower) / (upper - lower) - delta_upper = (upper - gene_value) / (upper - lower) - - rand_u = numpy.random.random() - if rand_u <= 0.5: - xy = 1.0 - delta_lower - val = 2.0 * rand_u + (1.0 - 2.0 * rand_u) * pow(xy, eta_plus_one) - delta_q = pow(val, 1.0 / eta_plus_one) - 1.0 + value = float(numpy.clip(solution[gene_index], lower, upper)) + delta_lower = (value - lower) / (upper - lower) + delta_upper = (upper - value) / (upper - lower) + quantile = self.numpy_random_generator.random() + if quantile <= 0.5: + distance = 1.0 - delta_lower + spread = 2.0 * quantile + (1.0 - 2.0 * quantile) * pow(distance, eta_plus_one) + change = pow(spread, 1.0 / eta_plus_one) - 1.0 else: - xy = 1.0 - delta_upper - val = 2.0 * (1.0 - rand_u) + 2.0 * (rand_u - 0.5) * pow(xy, eta_plus_one) - delta_q = 1.0 - pow(val, 1.0 / eta_plus_one) - - new_value = gene_value + delta_q * (upper - lower) - new_value = numpy.clip(new_value, lower, upper) - offspring[sol_idx, gene_idx] = new_value - - if self.allow_duplicate_genes == False: - offspring[sol_idx], _, _ = self.solve_duplicate_genes_randomly( - solution=offspring[sol_idx], - min_val=lower, - max_val=upper, - mutation_by_replacement=True, - gene_type=self.gene_type, - sample_size=self.sample_size) + distance = 1.0 - delta_upper + spread = 2.0 * (1.0 - quantile) + 2.0 * (quantile - 0.5) * pow(distance, eta_plus_one) + change = 1.0 - pow(spread, 1.0 / eta_plus_one) + new_value = numpy.clip(value + change * (upper - lower), lower, upper) + solution[gene_index] = self.convert_bounded_operator_gene_value(gene_index, new_value) + if selected_any_gene: + offspring[solution_index] = self.prepare_bounded_operator_solution(original, solution) return offspring - def swap_mutation(self, offspring): - """ - Swap the values of two genes inside each offspring. The two - genes are 2 different genes picked at random. - Offspring with fewer than two genes are returned unchanged. + def select_mutation_gene_indices(self): + """Select eligible genes using the active probability or gene count.""" + if self.mutation_probability is not None: + return numpy.where(self.numpy_random_generator.random(self.num_genes) < self.mutation_probability)[0] + return numpy.asarray(self.python_random_generator.sample(range(self.num_genes), self.mutation_num_genes), dtype=int) - Parameters - ---------- - offspring : numpy.ndarray - The offspring solutions to mutate (modified in place). + def swap_mutation(self, offspring): + """Swap a compatible pair of eligible genes in each offspring. - Returns - ------- - offspring : numpy.ndarray - The mutated offspring. + Explicit mutation controls select eligible positions. With no explicit + control, all positions are eligible and a pair is selected uniformly. """ - - if offspring.shape[1] < 2: - return offspring - - for idx in range(offspring.shape[0]): - mutation_gene1, mutation_gene2 = numpy.random.choice(offspring.shape[1], size=2, replace=False) - - temp = offspring[idx, mutation_gene1] - offspring[idx, mutation_gene1] = offspring[idx, mutation_gene2] - offspring[idx, mutation_gene2] = temp - return offspring + return self._apply_permutation_mutation(offspring, 'swap') def inversion_mutation(self, offspring): - """ - Pick a slice of genes inside each offspring and reverse the - order of the values in that slice. - - Parameters - ---------- - offspring : numpy.ndarray - The offspring solutions to mutate (modified in place). - - Returns - ------- - offspring : numpy.ndarray - The mutated offspring. - """ - - for idx in range(offspring.shape[0]): - mutation_gene1 = numpy.random.randint(low=0, high=numpy.ceil(offspring.shape[1]/2 + 1), size=1)[0] - mutation_gene2 = mutation_gene1 + int(offspring.shape[1]/2) - - genes_to_scramble = numpy.flip(offspring[idx, mutation_gene1:mutation_gene2]) - offspring[idx, mutation_gene1:mutation_gene2] = genes_to_scramble - return offspring + """Reverse eligible genes, keeping changes that satisfy destination rules.""" + return self._apply_permutation_mutation(offspring, 'inversion') def scramble_mutation(self, offspring): - """ - Pick a slice of genes inside each offspring and shuffle the - values in that slice. The segment contains num_genes // 2 genes; - genes outside it are unchanged. A shuffle may keep the original - order, and segments with fewer than two genes cannot change. + """Shuffle eligible genes, keeping changes that satisfy destination rules.""" + return self._apply_permutation_mutation(offspring, 'scramble') - Parameters - ---------- - offspring : numpy.ndarray - The offspring solutions to mutate (modified in place). + def _apply_permutation_mutation(self, offspring, mutation_type): + """Share gene selection and complete-solution validation for permutations. - Returns - ------- - offspring : numpy.ndarray - The mutated offspring. + Inversion and scramble retain their historical half-length segment when + no control is explicitly set. Swap checks alternative pairs and + inversion checks shorter eligible intervals when the first proposal + fails. Scramble and swaps of multiple pairs try sample_size proposals. """ - - for offspring_idx in range(offspring.shape[0]): - segment_start = numpy.random.randint(low=0, high=numpy.ceil(offspring.shape[1]/2 + 1), size=1)[0] - segment_end = segment_start + int(offspring.shape[1]/2) - # Shuffle values, not indices, so each permutation of the - # selected segment is possible without a separate reversal. - genes_to_scramble = offspring[offspring_idx, segment_start:segment_end].copy() - numpy.random.shuffle(genes_to_scramble) - offspring[offspring_idx, segment_start:segment_end] = genes_to_scramble + if self.num_genes < 2: + return offspring + for solution_index, solution in enumerate(offspring): + if self.mutation_control_explicitly_set: + eligible = numpy.sort(self.select_mutation_gene_indices()) + elif mutation_type == 'swap': + eligible = numpy.arange(self.num_genes) + else: + start = self.numpy_random_generator.randint(low=0, high=int(numpy.ceil(self.num_genes / 2 + 1)), size=1)[0] + eligible = numpy.arange(start, start + self.num_genes // 2) + if len(eligible) < 2: + continue + original = solution.copy() + grouped_swap = mutation_type == 'swap' and self.mutation_control_explicitly_set and len(eligible) > 2 + if mutation_type == 'swap' and not grouped_swap: + first, second = self.numpy_random_generator.choice(eligible, size=2, replace=False) + candidate_pairs = [(first, second)] + # Iterate remaining pairs lazily only if the initial swap + # fails, rather than allocating a quadratic pair array. + candidates = chain(candidate_pairs, combinations(eligible.tolist(), 2)) + elif mutation_type == 'inversion': + candidates = chain([(0, len(eligible) - 1)], combinations(range(len(eligible)), 2)) + else: + candidates = range(self.sample_size) + for candidate in candidates: + proposed = original.copy() + if grouped_swap: + positions = eligible.copy() + self.numpy_random_generator.shuffle(positions) + for first, second in zip(positions[::2], positions[1::2]): + proposed[first], proposed[second] = original[second], original[first] + elif mutation_type == 'swap': + first, second = candidate + proposed[first], proposed[second] = original[second], original[first] + elif mutation_type == 'inversion': + first, last = candidate + selected = eligible[first:last + 1] + proposed[selected] = original[selected][::-1] + else: + values = original[eligible].copy() + self.numpy_random_generator.shuffle(values) + proposed[eligible] = values + prepared = self.prepare_changed_operator_solution(original, proposed) + if prepared is not None: + offspring[solution_index] = prepared + break return offspring def adaptive_mutation_population_fitness(self, offspring): @@ -610,6 +564,9 @@ def adaptive_mutation(self, offspring): The mutated offspring. """ + if self.mutation_probability is not None and not any(self.mutation_probability): + return offspring + # If the attribute 'gene_space' exists (i.e. not None), then the mutation values are selected from the 'gene_space' parameter according to the space of values of each gene. Otherwise, it is selected randomly based on the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val'. # When the 'mutation_probability' parameter exists (i.e. not None), then it is used in the mutation. Otherwise, the 'mutation_num_genes' parameter is used. @@ -683,7 +640,7 @@ def adaptive_mutation_by_space(self, offspring): else: adaptive_mutation_num_genes = self.mutation_num_genes[1] - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) swapped_genes = set() for gene_idx in mutation_indices: @@ -702,14 +659,10 @@ def adaptive_mutation_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_randomly(self, offspring): @@ -762,7 +715,7 @@ def adaptive_mutation_randomly(self, offspring): else: adaptive_mutation_num_genes = self.mutation_num_genes[1] - mutation_indices = numpy.array(random.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) + mutation_indices = numpy.array(self.python_random_generator.sample(range(0, self.num_genes), adaptive_mutation_num_genes)) for gene_idx in mutation_indices: range_min, range_max = self.get_random_mutation_range(gene_idx) @@ -777,12 +730,7 @@ def adaptive_mutation_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_probs_by_space(self, offspring): @@ -837,14 +785,14 @@ def adaptive_mutation_probs_by_space(self, offspring): else: adaptive_mutation_probability = self.mutation_probability[1] - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) swapped_genes = set() for gene_idx in range(offspring.shape[1]): if gene_idx in swapped_genes: continue - if probs[gene_idx] <= adaptive_mutation_probability: + if probs[gene_idx] < adaptive_mutation_probability: value_from_space = self.mutation_process_gene_value(solution=offspring[offspring_idx], gene_idx=gene_idx, @@ -858,14 +806,10 @@ def adaptive_mutation_probs_by_space(self, offspring): continue # Assigning the selected value from the space to the gene. - offspring[offspring_idx, gene_idx] = self.change_gene_dtype_and_round(gene_idx, value_from_space) + offspring[offspring_idx, gene_idx] = value_from_space if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_by_space(solution=offspring[offspring_idx], - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=self.mutation_by_replacement, - build_initial_pop=False) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring def adaptive_mutation_probs_randomly(self, offspring): @@ -919,12 +863,12 @@ def adaptive_mutation_probs_randomly(self, offspring): else: adaptive_mutation_probability = self.mutation_probability[1] - probs = numpy.random.random(size=offspring.shape[1]) + probs = self.numpy_random_generator.random(size=offspring.shape[1]) for gene_idx in range(offspring.shape[1]): range_min, range_max = self.get_random_mutation_range(gene_idx) - if probs[gene_idx] <= adaptive_mutation_probability: + if probs[gene_idx] < adaptive_mutation_probability: # Generate a random value for mutation that meets the gene constraint, if one exists. random_value = self.mutation_process_gene_value(range_min=range_min, range_max=range_max, @@ -935,10 +879,5 @@ def adaptive_mutation_probs_randomly(self, offspring): offspring[offspring_idx, gene_idx] = random_value if self.allow_duplicate_genes == False: - offspring[offspring_idx], _, _ = self.solve_duplicate_genes_randomly(solution=offspring[offspring_idx], - min_val=range_min, - max_val=range_max, - mutation_by_replacement=self.mutation_by_replacement, - gene_type=self.gene_type, - sample_size=self.sample_size) + offspring[offspring_idx], _, _ = self.solve_duplicate_genes(solution=offspring[offspring_idx]) return offspring diff --git a/pygad/utils/nsga3.py b/pygad/utils/nsga3.py index 546b93d5..bc050ff2 100644 --- a/pygad/utils/nsga3.py +++ b/pygad/utils/nsga3.py @@ -344,6 +344,7 @@ def nsga3_niching_select(self, candidates_at_target, critical_front_distances, niche_counts[target_reference_index], + random_generator=getattr(self, 'numpy_random_generator', numpy.random), ) picked.append(critical_front_indices[chosen_position]) niche_counts[target_reference_index] += 1 @@ -373,7 +374,7 @@ def _nsga3_pick_target_reference_point(niche_counts, def _nsga3_pick_candidate_at_reference(candidates_at_target, critical_front_distances, - niche_count_at_target): + niche_count_at_target, random_generator=None): """ Choose one critical-front candidate at the given reference point. If the niche count is 0 (empty niche), pick the closest candidate. @@ -382,9 +383,8 @@ def _nsga3_pick_candidate_at_reference(candidates_at_target, if niche_count_at_target == 0: return min(candidates_at_target, key=lambda position: critical_front_distances[position]) - return candidates_at_target[ - numpy.random.randint(len(candidates_at_target)) - ] + random_generator = numpy.random if random_generator is None else random_generator + return candidates_at_target[random_generator.randint(len(candidates_at_target))] def _nsga3_enumerate_compositions(num_objectives, num_divisions): diff --git a/pygad/utils/parallel.py b/pygad/utils/parallel.py index b0cdb986..60ad1383 100644 --- a/pygad/utils/parallel.py +++ b/pygad/utils/parallel.py @@ -55,14 +55,14 @@ def __getstate__(self): ------- state : dict A shallow copy of instance attributes excluding the executor, - its configuration, and the active-run flag. Cloudpickle uses + its configuration, the active-run flag, and saved fitness indexes. Cloudpickle uses it for checkpoints and GA snapshots sent to process workers. """ # Executors contain locks and worker handles. Neither checkpoints # nor GA snapshots sent to workers should contain these resources. state = self.__dict__.copy() for name in ("_fitness_executor", "_fitness_executor_config", - "_fitness_run_active"): + "_fitness_run_active", "_saved_fitness_indexes"): state.pop(name, None) return state @@ -188,7 +188,8 @@ def _evaluate_fitness(self, population, indices, adaptive=False): If a batch call returns neither list, tuple, nor numpy.ndarray. ValueError If a batch's result length differs from its solution count, - or an individual fitness value has an unsupported type. + or a fitness value has an unsupported type, shape, objective + count, or non-finite objective value. Notes ----- @@ -224,6 +225,8 @@ def _evaluate_fitness(self, population, indices, adaptive=False): raise TypeError("Expected to receive a list, tuple, or " "numpy.ndarray from the fitness function " f"but the value ({result}) of type {type(result)}.") + if isinstance(result, numpy.ndarray) and result.ndim == 0: + raise ValueError("A batched fitness_func must return one fitness value per solution, not a scalar array.") if len(result) != len(group): raise ValueError("There is a mismatch between the number " "of solutions passed to the fitness function " @@ -232,15 +235,56 @@ def _evaluate_fitness(self, population, indices, adaptive=False): values = result else: values = [result] - for value in values: - if (type(value) not in self.supported_int_float_types - and type(value) not in (list, tuple, numpy.ndarray)): - raise ValueError("The fitness function should return a " - "number or an iterable (list, tuple, or " - f"numpy.ndarray) but the value {value} " - f"of type {type(value)} found.") - fitness_values.append(value) + for index, value in zip(group, values): + fitness_values.append(self._validate_fitness_value( + value, f"fitness_func for solution {index}")) finally: # Close an out-of-run executor even if result validation fails. results.close() return fitness_values + + def _validate_fitness_value(self, value, source, check_shape=True): + """Validate one scalar or non-empty objective vector before selection. + + Scalars may include infinity to represent a perfect or rejected + solution. Objective vectors must be finite because Pareto distance + and normalization calculations require finite objective ranges. + Every solution must return the same number of objectives. + """ + if isinstance(value, numpy.ndarray) and value.ndim == 0: + value = value.item() + if type(value) in self.supported_int_float_types and type(value) is not object: + shape = () + values = [value] + elif isinstance(value, (list, tuple, numpy.ndarray)): + try: + array = numpy.asarray(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{source} must return a non-empty one-dimensional numeric objective vector.") from error + if array.ndim != 1 or array.size == 0: + raise ValueError(f"{source} must return a number or a non-empty one-dimensional objective vector.") + shape = array.shape + values = value + else: + raise ValueError(f"{source} must return a number or a non-empty one-dimensional objective vector, but received {type(value)}.") + for objective in values: + if type(objective) not in self.supported_int_float_types or type(objective) is object: + raise ValueError(f"{source} contains a non-numeric fitness value: {objective!r}.") + if isinstance(objective, (float, numpy.floating)): + if numpy.isnan(objective) or (shape and not numpy.isfinite(objective)): + raise ValueError(f"{source} contains an invalid fitness value. NaN is not supported, and objective vectors must contain finite numbers.") + if check_shape: + expected_shape = getattr(self, '_fitness_value_shape', None) + if expected_shape is not None and shape != expected_shape: + raise ValueError(f"{source} has fitness shape {shape}, but all solutions must use the same fitness shape {expected_shape}.") + self._fitness_value_shape = shape + return value + + def _validate_population_fitness(self, fitness, source, check_shape=True): + """Return validated population fitness without changing its shape.""" + values = [self._validate_fitness_value(value, source, check_shape=check_shape) + for value in fitness] + shapes = [numpy.shape(value) for value in values] + if shapes and any(shape != shapes[0] for shape in shapes): + raise ValueError(f"{source} must use the same fitness shape for all solutions.") + return numpy.asarray(values) diff --git a/pygad/utils/parent_selection.py b/pygad/utils/parent_selection.py index 8b893a9c..3d7174dd 100644 --- a/pygad/utils/parent_selection.py +++ b/pygad/utils/parent_selection.py @@ -80,7 +80,7 @@ def rank_selection(self, fitness, num_parents): parents_indices = [] for parent_num in range(num_parents): - rand_prob = numpy.random.rand() + rand_prob = self.numpy_random_generator.rand() for idx in range(probs.shape[0]): if (rand_prob >= probs_start[idx] and rand_prob < probs_end[idx]): # The variable idx holds the rank of the solution, not its index in the population. @@ -113,7 +113,7 @@ def random_selection(self, fitness, num_parents): """ parents = self.initialize_parents_array((num_parents, self.population.shape[1])) - rand_indices = numpy.random.randint(low=0.0, high=fitness.shape[0], size=num_parents) + rand_indices = self.numpy_random_generator.randint(low=0.0, high=fitness.shape[0], size=num_parents) parents[:, :] = self.population[rand_indices, :].copy() return parents, rand_indices @@ -150,7 +150,7 @@ def tournament_selection(self, fitness, num_parents): for parent_num in range(num_parents): # Generate random indices for the candidate solutions. - rand_indices = numpy.random.randint(low=0, high=len(fitness), size=self.K_tournament) + rand_indices = self.numpy_random_generator.randint(low=0, high=len(fitness), size=self.K_tournament) # Find the rank of the candidate solutions. The lower the rank, the better the solution. rand_indices_rank = [rank_lookup[rand_idx] for rand_idx in rand_indices] @@ -223,7 +223,7 @@ def roulette_wheel_selection(self, fitness, num_parents): parents_indices = [] for parent_num in range(num_parents): - rand_prob = numpy.random.rand() + rand_prob = self.numpy_random_generator.rand() for idx in range(probs.shape[0]): if (rand_prob >= probs_start[idx] and rand_prob < probs_end[idx]): parents_indices.append(idx) @@ -332,7 +332,7 @@ def stochastic_universal_selection(self, fitness, num_parents): # Space pointers using the requested count, which can differ from # num_parents_mating when this operator is called directly. pointers_distance = 1.0 / num_parents - first_pointer = numpy.random.uniform(low=0.0, + first_pointer = self.numpy_random_generator.uniform(low=0.0, high=pointers_distance, size=1)[0] # Location of the first pointer. @@ -412,7 +412,7 @@ def tournament_selection_nsga2(self, self.pareto_fronts = pareto_fronts.copy() # Randomly generate pairs of indices to apply for NSGA-II tournament selection for selecting the parents solutions. - rand_indices = numpy.random.randint(low=0, + rand_indices = self.numpy_random_generator.randint(low=0, high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) @@ -454,7 +454,7 @@ def tournament_selection_nsga2(self, if len(current_indices_unique) == 1: #### DONE # There is only one solution in the best pareto front. Just select it as a parent. - # The same solution index was randomly generated more than once using the numpy.random.randint() + # The same solution index was randomly generated more than once using the self.numpy_random_generator.randint() selected_parent_index = current_indices_unique[0] else: # There are different solutions at the same front. @@ -496,7 +496,7 @@ def tournament_selection_nsga2(self, selected_parent_index = current_indices_unique[solutions_crowding_distance.index(max_crowding_distance)] else: # If the crowding distance is equal across multiple solutions, select a solution randomly as a parent. - selected_parent_index = numpy.random.choice(current_indices_unique) + selected_parent_index = self.numpy_random_generator.choice(current_indices_unique) # Insert the selected parent index. parents_indices.append(selected_parent_index) @@ -768,7 +768,7 @@ def tournament_selection_nsga3(self, fitness, num_parents): niche_counts = numpy.bincount(associations, minlength=len(self.nsga3_reference_points)) - rand_indices = numpy.random.randint(low=0, + rand_indices = self.numpy_random_generator.randint(low=0, high=len(solutions_fronts_indices), size=(num_parents, self.K_tournament)) parents_indices = [self._nsga3_pick_tournament_winner(rand_indices[slot], diff --git a/pygad/utils/validation.py b/pygad/utils/validation.py index 62c46534..6abef7b4 100644 --- a/pygad/utils/validation.py +++ b/pygad/utils/validation.py @@ -6,125 +6,121 @@ class Validation: - def _validate_header(self, - logger, - random_seed, - suppress_warnings, - mutation_by_replacement, - sample_size, - allow_duplicate_genes): - """ - Validate the first group of constructor parameters and store - them on the GA instance. Sets up the logger (creating a - default console logger when ``logger`` is None), seeds the - random generators when ``random_seed`` is given, and persists - the four flag-style parameters on ``self``. - - Parameters - ---------- - logger : logging.Logger or None - A logger object. When None, a default console logger is - created. - random_seed : int or None - Seed for the numpy and random random generators. When - None, the generators are left in their current state. - suppress_warnings : bool - If True, ``warnings.warn`` calls inside PyGAD are skipped. - mutation_by_replacement : bool - If True, the random mutation replaces the gene value - instead of adding a random delta. - sample_size : int - Number of candidate values drawn when resolving gene - constraints and duplicates. - allow_duplicate_genes : bool - If False, duplicate genes inside a single solution are - resolved by sampling new values. - - Raises - ------ - TypeError - If ``logger`` is neither None nor a ``logging.Logger``. - TypeError - If any of the bool flags is not a bool. - ValueError - If ``sample_size`` is not a positive integer or - ``random_seed`` is of an unsupported type. - """ - # If no logger is passed, then create a logger that logs the messages only to the console. - if logger is None: - # Create a logger named with the module name. - logger = logging.getLogger(__name__) - # Set the logger log level to 'DEBUG' to log all kinds of messages. - logger.setLevel(logging.DEBUG) - - # Clear any attached handlers to the logger from the previous runs. - logger.handlers.clear() + def _validate_header(self, logger, random_seed, suppress_warnings, + mutation_by_replacement, sample_size, allow_duplicate_genes): + """Set up logging and validate flags, sample size, and the random seed. + + Random generators are created after the remaining parameter checks, + so rejected configurations do not sample values or run constraints. + """ + self.valid_parameters = False + if logger is not None and not isinstance(logger, logging.Logger): + raise TypeError("logger must be a logging.Logger instance or None.") + self.logger = logger if logger is not None else logging.getLogger(__name__) + if logger is None and not self.logger.handlers: + self.logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('%(message)s')) + self.logger.addHandler(handler) + self.suppress_warnings = self._validate_boolean_parameter(suppress_warnings, 'suppress_warnings') + self.mutation_by_replacement = self._validate_boolean_parameter(mutation_by_replacement, 'mutation_by_replacement') + self.allow_duplicate_genes = self._validate_boolean_parameter(allow_duplicate_genes, 'allow_duplicate_genes') + self.sample_size = self._validate_integer_parameter(sample_size, 'sample_size', minimum=1) + self.random_seed = (None if random_seed is None else + self._validate_integer_parameter(random_seed, 'random_seed', minimum=0, maximum=2**32 - 1)) + + def _validate_boolean_parameter(self, value, parameter_name): + """Return a boolean setting or raise an error naming the parameter.""" + if type(value) is not bool: + raise TypeError(f"{parameter_name} must be a bool, but {type(value).__name__} found.") + return value + + def _validate_integer_parameter(self, value, parameter_name, minimum=None, maximum=None): + """Validate a count and return a Python integer before any arithmetic.""" + if isinstance(value, (bool, numpy.bool_)) or not isinstance(value, (int, numpy.integer)): + raise TypeError(f"{parameter_name} must be an integer, but {type(value).__name__} found.") + value = int(value) + if minimum is not None and value < minimum: + raise ValueError(f"{parameter_name} must be >= {minimum}, but {value} found.") + if maximum is not None and value > maximum: + raise ValueError(f"{parameter_name} must be <= {maximum}, but {value} found.") + return value + + def _validate_numeric_parameter(self, value, parameter_name, minimum=None, maximum=None): + """Validate a finite real number and normalize NumPy scalar values.""" + if isinstance(value, (bool, numpy.bool_)) or not isinstance(value, (int, float, numpy.integer, numpy.floating)): + raise TypeError(f"{parameter_name} must be numeric, but {type(value).__name__} found.") + if isinstance(value, numpy.integer): + value = int(value) + elif isinstance(value, numpy.floating): + value = float(value) + if isinstance(value, float) and not numpy.isfinite(value): + raise ValueError(f"{parameter_name} must be finite, but {value} found.") + if minimum is not None and value < minimum: + raise ValueError(f"{parameter_name} must be >= {minimum}, but {value} found.") + if maximum is not None and value > maximum: + raise ValueError(f"{parameter_name} must be <= {maximum}, but {value} found.") + return value + + def _validate_callable_parameter(self, function, parameter_name, num_arguments): + """Check the positional call PyGAD makes without invoking user code. + + Signature binding supports functions, bound methods, callable objects, + partial functions, and additional optional parameters. Counting names + alone would also accept required keyword-only parameters incorrectly. + """ + if (not callable(function) or inspect.isclass(function) or inspect.iscoroutinefunction(function) + or inspect.iscoroutinefunction(getattr(function, '__call__', None))): + raise TypeError(f"{parameter_name} must be a synchronous callable.") + try: + signature = inspect.signature(function) + signature.bind(*([None] * num_arguments)) + except (TypeError, ValueError) as error: + raise ValueError(f"{parameter_name} must accept {num_arguments} positional arguments: {error}") from error + return function + + def _resolve_operator(self, operator, parameter_name, built_in_operators, num_arguments, allow_none=False): + """Resolve a built-in name or validate a user-defined operator once.""" + if operator is None and allow_none: + return None, None + if isinstance(operator, str): + operator = operator.lower() + if operator not in built_in_operators: + raise TypeError(f"Unknown {parameter_name} '{operator}'. Supported names are {list(built_in_operators)}.") + return operator, getattr(self, built_in_operators[operator]) + return operator, self._validate_callable_parameter(operator, parameter_name, num_arguments) + + def _validate_range_parameters(self, lower, upper, lower_name, upper_name): + """Copy scalar or per-gene bounds, validating their shape and values.""" + sequences = (list, tuple, numpy.ndarray) + for value, name in [(lower, lower_name), (upper, upper_name)]: + if isinstance(value, numpy.ndarray) and value.ndim == 0: + raise ValueError(f'{name} must be a numeric scalar or a 1D sequence, not a 0D array.') + if isinstance(lower, sequences) and isinstance(upper, sequences): + result = [] + for values, name in [(lower, lower_name), (upper, upper_name)]: + if numpy.asarray(values, dtype=object).ndim != 1 or len(values) != self.num_genes: + raise ValueError(f"{name} must be a 1D sequence with length equal to num_genes ({self.num_genes}).") + result.append([self._validate_numeric_parameter(value, name) for value in values]) + return tuple(result) + if isinstance(lower, sequences) or isinstance(upper, sequences): + raise TypeError(f"{lower_name} and {upper_name} must both be numeric or both be per-gene sequences.") + lower = self._validate_numeric_parameter(lower, lower_name) + upper = self._validate_numeric_parameter(upper, upper_name) + if lower == upper and not self.suppress_warnings: + warnings.warn(f"The values of {lower_name} and {upper_name} are equal, so sampling uses a fixed value.") + return lower, upper + + def _copy_parameter_container(self, value): + """Copy parameter containers recursively, retaining numeric values and callables.""" + if isinstance(value, dict): + return {key: self._copy_parameter_container(item) for key, item in value.items()} + if isinstance(value, (list, tuple, numpy.ndarray)): + if isinstance(value, numpy.ndarray) and value.ndim == 0: + raise ValueError('Parameter arrays must be sequences, not 0D arrays.') + return [self._copy_parameter_container(item) for item in value] + return value - # Create the handlers. - stream_handler = logging.StreamHandler() - # Set the handler log level to 'DEBUG' to log all kinds of messages received from the logger. - stream_handler.setLevel(logging.DEBUG) - - # Create the formatter that just includes the log message. - formatter = logging.Formatter('%(message)s') - - # Add the formatter to the handler. - stream_handler.setFormatter(formatter) - - # Add the handler to the logger. - logger.addHandler(stream_handler) - else: - # Validate that the passed logger is of type 'logging.Logger'. - if isinstance(logger, logging.Logger): - pass - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'logger' parameter is 'logging.Logger' but {type(logger)} found.") - - # Create the 'self.logger' attribute to hold the logger. - self.logger = logger - - self.random_seed = random_seed - if random_seed is None: - pass - else: - numpy.random.seed(self.random_seed) - random.seed(self.random_seed) - - # If suppress_warnings is bool and its value is False, then print warning messages. - if type(suppress_warnings) is bool: - self.suppress_warnings = suppress_warnings - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'suppress_warnings' parameter is bool but {type(suppress_warnings)} found.") - - # Validating mutation_by_replacement - if not (type(mutation_by_replacement) is bool): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'mutation_by_replacement' parameter is bool but {type(mutation_by_replacement)} found.") - - self.mutation_by_replacement = mutation_by_replacement - - # Validate the sample_size parameter. - if type(sample_size) in self.supported_int_types: - if sample_size > 0: - pass - else: - self.valid_parameters = False - raise ValueError(f"The value of the sample_size parameter must be > 0 but the value ({sample_size}) found.") - else: - self.valid_parameters = False - raise TypeError(f"The type of the sample_size parameter must be integer but the value ({sample_size}) of type {type(sample_size)} found.") - - self.sample_size = sample_size - - # Validate allow_duplicate_genes - if not (type(allow_duplicate_genes) is bool): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'allow_duplicate_genes' parameter is bool but {type(allow_duplicate_genes)} found.") - - self.allow_duplicate_genes = allow_duplicate_genes - def _validate_gene_space(self, gene_space): """ @@ -152,20 +148,26 @@ def _validate_gene_space(self, If a nested gene space has an unsupported element type, or if a dict gene space is missing required keys. """ + gene_space = self._copy_parameter_container(gene_space) # Validate gene_space self.gene_space_nested = False if type(gene_space) is type(None): pass elif type(gene_space) is range: - if len(gene_space) == 0: + if self._finite_gene_space_length(gene_space) == 0: self.valid_parameters = False raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") - elif type(gene_space) in [list, numpy.ndarray]: + elif type(gene_space) in [list, tuple, numpy.ndarray]: if len(gene_space) == 0: self.valid_parameters = False raise ValueError("'gene_space' cannot be empty (i.e. its length must be >= 0).") else: for index, el in enumerate(gene_space): + if isinstance(el, range): + if self._finite_gene_space_length(el) == 0: + raise ValueError(f'The gene_space range at index {index} cannot be empty.') + self.gene_space_nested = True + continue if type(el) in [numpy.ndarray, list, tuple, range]: if len(el) == 0: self.valid_parameters = False @@ -178,42 +180,14 @@ def _validate_gene_space(self, elif type(el) == type(None): pass elif type(el) is dict: - if len(el.items()) == 2: - if ('low' in el.keys()) and ('high' in el.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it can have the keys 'low', 'high', and 'step' (optional) but the following keys found: {el.keys()}") - elif len(el.items()) == 3: - if ('low' in el.keys()) and ('high' in el.keys()) and ('step' in el.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it can have the keys 'low', 'high', and 'step' (optional) but the following keys found: {el.keys()}") - else: - self.valid_parameters = False - raise ValueError(f"When an element in the 'gene_space' parameter is of type dict, then it must have only 2 items but ({len(el.items())}) items found.") + self._validate_gene_space_dictionary(el) self.gene_space_nested = True elif not (type(el) in self.supported_int_float_types): self.valid_parameters = False raise TypeError(f"Unexpected type {type(el)} for the element indexed {index} of 'gene_space'. The accepted types are list/tuple/range/numpy.ndarray of numbers, a single number (int/float), or None.") elif type(gene_space) is dict: - if len(gene_space.items()) == 2: - if ('low' in gene_space.keys()) and ('high' in gene_space.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it can have only the keys 'low', 'high', and 'step' (optional) but the following keys found: {gene_space.keys()}") - elif len(gene_space.items()) == 3: - if ('low' in gene_space.keys()) and ('high' in gene_space.keys()) and ('step' in gene_space.keys()): - pass - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it can have only the keys 'low', 'high', and 'step' (optional) but the following keys found: {gene_space.keys()}") - else: - self.valid_parameters = False - raise ValueError(f"When the 'gene_space' parameter is of type dict, then it must have only 2 items but ({len(gene_space.items())}) items found.") + self._validate_gene_space_dictionary(gene_space) else: self.valid_parameters = False @@ -221,1073 +195,269 @@ def _validate_gene_space(self, self.gene_space = gene_space - def _validate_init_range(self, - init_range_low, - init_range_high, - num_genes, - initial_population): - """ - Validate the ``init_range_low`` and ``init_range_high`` - parameters used to build the initial population when the user - does not pass one explicitly. Both may be a scalar (one range - shared by every gene) or a per-gene iterable. - - Sets ``self.init_range_low`` and ``self.init_range_high`` on - the GA instance. - - Parameters - ---------- - init_range_low : numeric or iterable - Lower bound(s) for the random initial gene values. - init_range_high : numeric or iterable - Upper bound(s) for the random initial gene values. - num_genes : int or None - Number of genes per solution. Used to check the length of - the per-gene iterables. - initial_population : list / numpy.ndarray or None - The user-provided initial population, if any. Only used to - skip the length check when the population is being - inferred from it. - - Raises - ------ - TypeError - If either parameter is not a supported type. - ValueError - If the per-gene iterables have a length different from - ``num_genes``. - """ - # Validate init_range_low and init_range_high - if type(init_range_low) in self.supported_int_float_types: - if type(init_range_high) in self.supported_int_float_types: - if init_range_low == init_range_high: - if not self.suppress_warnings: - warnings.warn("The values of the 2 parameters 'init_range_low' and 'init_range_high' are equal and this might return the same value for some genes in the initial population.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'init_range_low' {type(init_range_low)} and 'init_range_high' {type(init_range_high)}.") - elif type(init_range_low) in [list, tuple, numpy.ndarray]: - # Get the number of genes before validating the num_genes parameter. - if num_genes is None: - if initial_population is None: - self.valid_parameters = False - raise TypeError("When the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.") - elif not len(init_range_low) == len(initial_population[0]): - self.valid_parameters = False - raise ValueError(f"The length of the 'init_range_low' parameter is {len(init_range_low)} which is different from the number of genes {len(initial_population[0])}.") - elif not len(init_range_low) == num_genes: + def _validate_gene_space_dictionary(self, space): + """Validate range bounds and an optional step in a gene-space dict.""" + if set(space) not in [{'low', 'high'}, {'low', 'high', 'step'}]: + self.valid_parameters = False + raise ValueError("A gene_space dictionary must have 'low' and 'high' keys and may also have 'step'.") + for name, value in space.items(): + space[name] = self._validate_numeric_parameter(value, f"gene_space '{name}'") + if 'step' not in space: + space['low'], space['high'] = sorted([space['low'], space['high']]) + if 'step' in space: + if (space['step'] == 0 + or (space['step'] > 0 and space['high'] <= space['low']) + or (space['step'] < 0 and space['high'] >= space['low'])): self.valid_parameters = False - raise ValueError(f"The length of the 'init_range_low' parameter is {len(init_range_low)} which is different from the number of genes {num_genes}.") - - if type(init_range_high) in [list, tuple, numpy.ndarray]: - if len(init_range_low) == len(init_range_high): - pass - else: - self.valid_parameters = False - raise ValueError(f"Size mismatch between the 2 parameters 'init_range_low' {len(init_range_low)} and 'init_range_high' {len(init_range_high)}.") + raise ValueError("The step in a gene_space dictionary must be non-zero and lead from low towards high so the space is not empty.") - # Validate the values in init_range_low - for val in init_range_low: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'init_range_low' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") + def _validate_init_range(self, init_range_low, init_range_high, num_genes): + """Validate initialization bounds using the resolved population dimensions.""" + self.init_range_low, self.init_range_high = self._validate_range_parameters( + init_range_low, init_range_high, 'init_range_low', 'init_range_high') - # Validate the values in init_range_high - for val in init_range_high: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'init_range_high' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'init_range_low' {type(init_range_low)} and 'init_range_high' {type(init_range_high)}. Both of them can be either numeric or iterable (list/tuple/numpy.ndarray).") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'init_range_low' parameter is numeric or list/tuple/numpy.ndarray but {type(init_range_low)} found.") - - self.init_range_low = init_range_low - self.init_range_high = init_range_high - - def _validate_gene_type(self, - gene_type, - num_genes, - initial_population): + def _validate_gene_type(self, gene_type, num_genes): """ - Validate the ``gene_type`` parameter and store it on the GA - instance. A gene type may be: - - - a single Python or numpy numeric type that applies to every - gene (``self.gene_type_single`` is set to True); - - a ``[type, precision]`` pair applied to every gene; - - a per-gene list of types or ``[type, precision]`` pairs - (``self.gene_type_single`` is set to False). - - Parameters - ---------- - gene_type : type, list, or tuple - The gene type specification. - num_genes : int or None - Number of genes per solution. Used to check the length of - a per-gene specification. - initial_population : list / numpy.ndarray or None - The user-provided initial population, if any. Used to - decide whether ``num_genes`` is already known. - - Raises - ------ - TypeError - If ``gene_type`` (or any of its elements) is not a - supported numeric type. - ValueError - If the per-gene specification has a length different from - ``num_genes``, or the precision is not an integer. + Normalize one type, a [type, precision] pair, or a per-gene + specification into independent [type, precision] lists. A pair + such as [float, int] specifies two gene types, while [float, 2] + specifies one floating-point type with decimal precision. """ - # Validate gene_type - if gene_type in self.supported_int_float_types: + if any(gene_type is supported_type for supported_type in self.supported_int_float_types): self.gene_type = [gene_type, None] self.gene_type_single = True - # A single data type of float with precision. - elif len(gene_type) == 2 and gene_type[0] in self.supported_float_types and (type(gene_type[1]) in self.supported_int_types or gene_type[1] is None): - self.gene_type = gene_type - self.gene_type_single = True - # A single data type of integer with precision None ([int, None]). - elif len(gene_type) == 2 and gene_type[0] in self.supported_int_types and gene_type[1] is None: - self.gene_type = gene_type - self.gene_type_single = True - # Raise an exception for a single data type of int with integer precision. - elif len(gene_type) == 2 and gene_type[0] in self.supported_int_types and (type(gene_type[1]) in self.supported_int_types or gene_type[1] is None): - self.gene_type_single = False - raise ValueError(f"Integers cannot have precision. Please use the integer data type directly instead of {gene_type}.") - elif type(gene_type) in [list, tuple, numpy.ndarray]: - # Get the number of genes before validating the num_genes parameter. - if num_genes is None: - if initial_population is None: - self.valid_parameters = False - raise TypeError("When the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.") - elif not len(gene_type) == len(initial_population[0]): - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_type' is nested, then it can be either [float, int<precision>] or with length equal to the number of genes parameter. Instead, value {gene_type} with len(gene_type) ({len(gene_type)}) != number of genes ({len(initial_population[0])}) found.") - elif not len(gene_type) == num_genes: - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_type' is nested, then it can be either [float, int<precision>] or with length equal to the value passed to the 'num_genes' parameter. Instead, value {gene_type} with len(gene_type) ({len(gene_type)}) != len(num_genes) ({num_genes}) found.") - for gene_type_idx, gene_type_val in enumerate(gene_type): - if gene_type_val in self.supported_int_float_types: - # If the gene type is float and no precision is passed or an integer, set its precision to None. - gene_type[gene_type_idx] = [gene_type_val, None] - elif type(gene_type_val) in [list, tuple, numpy.ndarray]: - # A float type is expected in a list/tuple/numpy.ndarray of length 2. - if len(gene_type_val) == 2: - if gene_type_val[0] in self.supported_float_types: - if type(gene_type_val[1]) in self.supported_int_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"In the 'gene_type' parameter, the precision for float gene data types must be an integer but the element {gene_type_val} at index {gene_type_idx} has a precision of {gene_type_val[1]} with type {gene_type_val[0]}.") - elif gene_type_val[0] in self.supported_int_types: - if gene_type_val[1] is None: - pass - else: - self.valid_parameters = False - raise TypeError(f"In the 'gene_type' parameter, either do not set a precision for integer data types or set it to None. But the element {gene_type_val} at index {gene_type_idx} has a precision of {gene_type_val[1]} with type {gene_type_val[0]}.") - else: - self.valid_parameters = False - raise TypeError( - f"In the 'gene_type' parameter, a precision is expected only for float gene data types but the element {gene_type_val} found at index {gene_type_idx}.\nNote that the data type must be at index 0 of the item followed by precision at index 1.") - else: - self.valid_parameters = False - raise ValueError(f"In the 'gene_type' parameter, a precision is specified in a list/tuple/numpy.ndarray of length 2 but value ({gene_type_val}) of type {type(gene_type_val)} with length {len(gene_type_val)} found at index {gene_type_idx}.") - else: - self.valid_parameters = False - raise ValueError(f"When a list/tuple/numpy.ndarray is assigned to the 'gene_type' parameter, then its elements must be of integer, floating-point, list, tuple, or numpy.ndarray data types but the value ({gene_type_val}) of type {type(gene_type_val)} found at index {gene_type_idx}.") - self.gene_type = gene_type - self.gene_type_single = False - else: + return + if type(gene_type) not in [list, tuple, numpy.ndarray]: self.valid_parameters = False - raise ValueError(f"The value passed to the 'gene_type' parameter must be either a single integer, floating-point, list, tuple, or numpy.ndarray but ({gene_type}) of type {type(gene_type)} found.") - - - def _build_initial_population(self, - initial_population, - sol_per_pop, - num_genes, - gene_space, - allow_duplicate_genes, - gene_constraint): - """ - Build or accept the initial population and store it on the GA - instance. When ``initial_population`` is None, the population - is generated from scratch by ``initialize_population`` using - ``sol_per_pop`` and ``num_genes``. Otherwise the user-provided - array is validated, cast to the right gene types, and - de-duplicated when ``allow_duplicate_genes`` is False. - - Sets ``self.population``, ``self.initial_population``, - ``self.sol_per_pop``, ``self.num_genes`` and ``self.pop_size`` - as side effects. - - Parameters - ---------- - initial_population : list / numpy.ndarray or None - User-provided initial population. When None, the - population is built from ``sol_per_pop`` and ``num_genes``. - sol_per_pop : int or None - Number of solutions per population. Required when - ``initial_population`` is None. - num_genes : int or None - Number of genes per solution. Required when - ``initial_population`` is None. - gene_space : see ``_validate_gene_space`` - The gene space used by the duplicate resolver. - allow_duplicate_genes : bool - If False, duplicate genes inside a single solution are - resolved. - gene_constraint : list or None - Per-gene callable constraints; passed through to - ``initialize_population``. + raise TypeError("gene_type must be a supported numeric type, list, tuple, or NumPy array.") + if isinstance(gene_type, numpy.ndarray) and gene_type.ndim == 0: + self.valid_parameters = False + raise ValueError("gene_type must contain a type or a sequence of types, not a 0D NumPy array.") - Raises - ------ - TypeError - If ``initial_population`` is not a list / tuple / - numpy.ndarray, or its values are not numeric. - ValueError - If ``sol_per_pop`` or ``num_genes`` is non-positive, or - ``initial_population`` is not 2-dimensional. - """ - # Build the initial population - if initial_population is None: - if (sol_per_pop is None) or (num_genes is None): + specification = list(gene_type) + first_is_type = len(specification) > 0 and any( + specification[0] is supported_type for supported_type in self.supported_int_float_types) + second_is_type = len(specification) == 2 and any( + specification[1] is supported_type for supported_type in self.supported_int_float_types) + second_is_sequence = len(specification) == 2 and type(specification[1]) in [list, tuple, numpy.ndarray] + if len(specification) == 2 and first_is_type and not second_is_type and not second_is_sequence: + self.gene_type = self._normalize_gene_type_entry(specification, 'gene_type') + self.gene_type_single = True + else: + if len(specification) != num_genes: self.valid_parameters = False - raise TypeError("Error creating the initial population:\n\nWhen the parameter 'initial_population' is None, then the 2 parameters 'sol_per_pop' and 'num_genes' cannot be None too.\nThere are 2 options to prepare the initial population:\n1) Assigning the initial population to the 'initial_population' parameter. In this case, the values of the 2 parameters sol_per_pop and num_genes will be deduced.\n2) Assign integer values to the 'sol_per_pop' and 'num_genes' parameters so that PyGAD can create the initial population automatically.") - elif (type(sol_per_pop) is int) and (type(num_genes) is int): - # Validating the number of solutions in the population (sol_per_pop) - if sol_per_pop <= 0: - self.valid_parameters = False - raise ValueError(f"The number of solutions in the population (sol_per_pop) must be > 0 but ({sol_per_pop}) found. \nThe following parameters must be > 0: \n1) Population size (i.e. number of solutions per population) (sol_per_pop).\n2) Number of selected parents in the mating pool (num_parents_mating).\n") - # Validating the number of gene. - if (num_genes <= 0): - self.valid_parameters = False - raise ValueError(f"The number of genes cannot be <= 0 but ({num_genes}) found.\n") - # When initial_population=None and the 2 parameters sol_per_pop and num_genes have valid integer values, then the initial population is created. - # Inside the initialize_population() method, the initial_population attribute is assigned to keep the initial population accessible. - self.num_genes = num_genes # Number of genes in the solution. - - # In case the 'gene_space' parameter is nested, then make sure the number of its elements equals to the number of genes. - if self.gene_space_nested: - if len(gene_space) != self.num_genes: - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") + raise ValueError(f"When gene_type specifies a type for each gene, its length ({len(specification)}) must equal the number of genes ({num_genes}).") + self.gene_type = [self._normalize_gene_type_entry(entry, f'gene_type at index {gene_index}') + for gene_index, entry in enumerate(specification)] + self.gene_type_single = False - # Number of solutions in the population. - self.sol_per_pop = sol_per_pop - self.initialize_population(allow_duplicate_genes=allow_duplicate_genes, - gene_type=self.gene_type, - gene_constraint=gene_constraint) - else: - self.valid_parameters = False - raise TypeError(f"The expected type of both the sol_per_pop and num_genes parameters is int but {type(sol_per_pop)} and {type(num_genes)} found.") - elif not type(initial_population) in [list, tuple, numpy.ndarray]: + def _normalize_gene_type_entry(self, specification, parameter_name): + """Return an independent type/precision pair for one specification.""" + if any(specification is supported_type for supported_type in self.supported_int_float_types): + return [specification, None] + if type(specification) not in [list, tuple, numpy.ndarray]: self.valid_parameters = False - raise TypeError(f"The value assigned to the 'initial_population' parameter is expected to be of type list, tuple, or ndarray but {type(initial_population)} found.") - elif numpy.array(initial_population).ndim != 2: + raise TypeError(f"{parameter_name} must be a supported numeric type or a [type, precision] pair.") + if isinstance(specification, numpy.ndarray) and specification.ndim != 1: self.valid_parameters = False - raise ValueError(f"A 2D list is expected to the initial_population parameter but a ({numpy.array(initial_population).ndim}-D) list found.") - else: - # Validate the type of each value in the 'initial_population' parameter. - for row_idx in range(len(initial_population)): - for col_idx in range(len(initial_population[0])): - if type(initial_population[row_idx][col_idx]) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"The values in the initial population can be integers or floats but the value ({initial_population[row_idx][col_idx]}) of type {type(initial_population[row_idx][col_idx])} found.") - - # Change the data type and round all genes within the initial population. - self.initial_population = self.change_population_dtype_and_round(initial_population) - - # Check if duplicates are allowed. If not, then solve any existing duplicates in the passed initial population. - if self.allow_duplicate_genes == False: - for initial_solution_idx, initial_solution in enumerate(self.initial_population): - if self.gene_space is None: - self.initial_population[initial_solution_idx], _, _ = self.solve_duplicate_genes_randomly(solution=initial_solution, - min_val=self.init_range_low, - max_val=self.init_range_high, - mutation_by_replacement=True, - gene_type=self.gene_type, - sample_size=self.sample_size) - else: - self.initial_population[initial_solution_idx], _, _ = self.solve_duplicate_genes_by_space(solution=initial_solution, - gene_type=self.gene_type, - sample_size=self.sample_size, - mutation_by_replacement=True, - build_initial_pop=True) - - # A NumPy array holding the initial population. - self.population = self.initial_population.copy() - # Number of genes in the solution. - self.num_genes = self.initial_population.shape[1] - # Number of solutions in the population. - self.sol_per_pop = self.initial_population.shape[0] - # The population size. - self.pop_size = (self.sol_per_pop, self.num_genes) - - # Change the data type and round all genes within the initial population. - self.initial_population = self.change_population_dtype_and_round(self.initial_population) - self.population = self.initial_population.copy() - - def _validate_mutation_range(self, - random_mutation_min_val, - random_mutation_max_val): - """ - Validate the random mutation range parameters and store them - on the GA instance. Both parameters may be scalars (one range - shared by every gene) or per-gene iterables. - - Sets ``self.random_mutation_min_val`` and - ``self.random_mutation_max_val``. - - Parameters - ---------- - random_mutation_min_val : numeric or iterable - Lower bound(s) for the random delta added during mutation. - random_mutation_max_val : numeric or iterable - Upper bound(s) for the random delta added during mutation. - - Raises - ------ - TypeError - If either parameter is not a supported numeric type. - ValueError - If the per-gene iterables have a length different from - ``num_genes``. - """ - # Validate random_mutation_min_val and random_mutation_max_val - if type(random_mutation_min_val) in self.supported_int_float_types: - if type(random_mutation_max_val) in self.supported_int_float_types: - if random_mutation_min_val == random_mutation_max_val: - if not self.suppress_warnings: - warnings.warn("The values of the 2 parameters 'random_mutation_min_val' and 'random_mutation_max_val' are equal and this might cause a fixed mutation to some genes.") - else: + raise ValueError(f"A type/precision pair in {parameter_name} must be a 1D sequence of 2 elements.") + if len(specification) != 2: + self.valid_parameters = False + raise ValueError(f"A type/precision pair in {parameter_name} must have 2 elements.") + gene_dtype, precision = specification + if not any(gene_dtype is supported_type for supported_type in self.supported_int_float_types): + self.valid_parameters = False + raise TypeError(f"The data type in {parameter_name} must be a supported numeric type.") + if precision is not None: + if gene_dtype not in self.supported_float_types: self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'random_mutation_min_val' {type(random_mutation_min_val)} and 'random_mutation_max_val' {type(random_mutation_max_val)}.") - elif type(random_mutation_min_val) in [list, tuple, numpy.ndarray]: - if len(random_mutation_min_val) == self.num_genes: - pass - else: + raise ValueError(f"Integers cannot have precision in {parameter_name}. Use the integer type directly or pair it with None.") + if type(precision) not in self.supported_int_types or type(precision) is object: self.valid_parameters = False - raise ValueError(f"The length of the 'random_mutation_min_val' parameter is {len(random_mutation_min_val)} which is different from the number of genes {self.num_genes}.") - if type(random_mutation_max_val) in [list, tuple, numpy.ndarray]: - if len(random_mutation_min_val) == len(random_mutation_max_val): - pass - else: - self.valid_parameters = False - raise ValueError(f"Size mismatch between the 2 parameters 'random_mutation_min_val' {len(random_mutation_min_val)} and 'random_mutation_max_val' {len(random_mutation_max_val)}.") - - # Validate the values in random_mutation_min_val - for val in random_mutation_min_val: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'random_mutation_min_val' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - - # Validate the values in random_mutation_max_val - for val in random_mutation_max_val: - if type(val) in self.supported_int_float_types: - pass - else: - self.valid_parameters = False - raise TypeError(f"When an iterable (list/tuple/numpy.ndarray) is assigned to the 'random_mutation_max_val' parameter, its elements must be numeric but the value {val} of type {type(val)} found.") - else: - self.valid_parameters = False - raise TypeError(f"Type mismatch between the 2 parameters 'random_mutation_min_val' {type(random_mutation_min_val)} and 'random_mutation_max_val' {type(random_mutation_max_val)}.") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of the 'random_mutation_min_val' parameter is numeric or list/tuple/numpy.ndarray but {type(random_mutation_min_val)} found.") + raise TypeError(f"The precision in {parameter_name} must be an integer or None.") + precision = int(precision) + return [gene_dtype, precision] - self.random_mutation_min_val = random_mutation_min_val - self.random_mutation_max_val = random_mutation_max_val - - - def _validate_gene_constraint(self, - gene_constraint): + def _validate_initial_population_shape(self, initial_population, sol_per_pop, num_genes): """ - Validate the ``gene_constraint`` parameter. The constraint is - a list with one entry per gene; each entry is either None (no - constraint) or a callable that filters a list of candidate - values down to the subset that satisfies the constraint. - - Sets ``self.gene_constraint`` on the GA instance. - - Parameters - ---------- - gene_constraint : list, tuple, or None - One callable per gene (or None to disable). Length must - equal ``self.num_genes``. - - Raises - ------ - TypeError - If ``gene_constraint`` is not a list / tuple, or any - element is not None and not callable. - ValueError - If the list length does not match ``self.num_genes``. + Validate the population dimensions before any per-gene settings. + A supplied population determines both dimensions, regardless of + the values passed to ``sol_per_pop`` and ``num_genes``. Return an + independent object array so mixed numeric values remain exact. """ - # Validate that gene_constraint is a list or tuple and every element inside it is either None or callable. - if gene_constraint: - if type(gene_constraint) in [list, tuple]: - if len(gene_constraint) == self.num_genes: - for constraint_idx, item in enumerate(gene_constraint): - # Check whether the element is None or a callable. - if item is None: - pass - elif item and callable(item): - if item.__code__.co_argcount == 2: - # Every callable is valid if it receives 2 arguments. - # The 2 arguments: 1) solution 2) A list or numpy.ndarray of values to check if they meet the constraint. - pass - else: - self.valid_parameters = False - raise ValueError(f"Every callable inside the gene_constraint parameter must accept 2 arguments representing 1) The solution/chromosome where the gene exists 2) A list or NumPy array of values to check if they meet the constraint. But the callable at index {constraint_idx} named '{item.__code__.co_name}' accepts {item.__code__.co_argcount} argument(s).") - else: - self.valid_parameters = False - raise TypeError(f"The expected type of an element in the 'gene_constraint' parameter is None or a callable (e.g. function). But {item} at index {constraint_idx} of type {type(item)} found.") - else: - self.valid_parameters = False - raise ValueError(f"The number of constraints ({len(gene_constraint)}) in the 'gene_constraint' parameter must be equal to the number of genes ({self.num_genes}).") - else: + if initial_population is None: + if sol_per_pop is None or num_genes is None: self.valid_parameters = False - raise TypeError(f"The expected type of the 'gene_constraint' parameter is either a list or tuple. But the value {gene_constraint} of type {type(gene_constraint)} found.") + raise TypeError("When initial_population is None, both sol_per_pop and num_genes must be specified.") + sol_per_pop = self._validate_integer_parameter(sol_per_pop, 'sol_per_pop', minimum=1) + num_genes = self._validate_integer_parameter(num_genes, 'num_genes', minimum=1) + population = None else: - # gene_constraint is None and not used. - pass - - self.gene_constraint = gene_constraint - - def _validate_crossover(self, - crossover_type, - crossover_probability, - sbx_crossover_eta=30): - """ - Validate the ``crossover_type`` and ``crossover_probability`` - parameters and store them on the GA instance. ``crossover_type`` - may be: - - - one of the built-in strings (``"single_point"``, - ``"two_points"``, ``"uniform"``, ``"scattered"``); - - a callable that takes ``(parents, offspring_size)`` and - returns the offspring array; - - None to skip the crossover step entirely. - - ``crossover_probability`` is the per-parent probability of - being selected for mating; only used by the built-in operators. - - Sets ``self.crossover`` (the operator function) plus - ``self.crossover_type`` and ``self.crossover_probability``. - - Parameters - ---------- - crossover_type : str, callable, or None - The crossover operator selector. - crossover_probability : float or None - Per-parent crossover probability between 0 and 1 - inclusive, or None to disable. - - Raises - ------ - TypeError - If ``crossover_type`` is neither a string, callable, nor - None. - ValueError - If ``crossover_type`` is an unknown string, the callable - has the wrong number of parameters, or - ``crossover_probability`` is outside [0, 1]. - """ - # crossover: Refers to the method that applies the crossover operator based on the selected type of crossover in the crossover_type property. - # Validating the crossover type: crossover_type - if crossover_type is None: - self.crossover = None - elif inspect.ismethod(crossover_type): - # Check if the crossover_type is a method that accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover method assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned to a method, then this crossover method must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.\n3) The instance from the pygad.GA class.\n\nThe passed crossover method named '{crossover_type.__code__.co_name}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - elif inspect.isfunction(crossover_type): - # Check if the crossover_type is a function that accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover function assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned to a function, then this crossover function must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed crossover function named '{crossover_type.__code__.co_name}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - elif callable(crossover_type) and not inspect.isclass(crossover_type): - # The object must have the __call__() method. - if hasattr(crossover_type, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(crossover_type).parameters) == 3: - # The crossover class instance assigned to the crossover_type parameter is validated. - self.crossover = crossover_type - else: - self.valid_parameters = False - raise ValueError(f"When 'crossover_type' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The selected parents.\n2) The size of the offspring to be produced.\n3) The instance from the pygad.GA class.\n\nThe passed instance of the class named '{crossover_type.__class__.__name__}' accepts {len(inspect.signature(crossover_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'crossover_type' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - elif not (type(crossover_type) is str): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'crossover_type' parameter is either callable or str but {type(crossover_type)} found.") - else: # type crossover_type is str - crossover_type = crossover_type.lower() - if crossover_type == "single_point": - self.crossover = self.single_point_crossover - elif crossover_type == "two_points": - self.crossover = self.two_points_crossover - elif crossover_type == "uniform": - self.crossover = self.uniform_crossover - elif crossover_type == "scattered": - self.crossover = self.scattered_crossover - elif crossover_type == "sbx": - self.crossover = self.sbx_crossover - else: - self.valid_parameters = False - raise TypeError(f"Undefined crossover type. \nThe assigned value to the crossover_type ({crossover_type}) parameter does not refer to one of the supported crossover types which are: \n-single_point (for single point crossover)\n-two_points (for two points crossover)\n-uniform (for uniform crossover)\n-scattered (for scattered crossover)\n-sbx (for simulated binary crossover).\n") - - self.crossover_type = crossover_type - - # Validate sbx_crossover_eta. It is only used when - # crossover_type is 'sbx', but it is stored on the instance - # in all cases so user callables can read it too. - if type(sbx_crossover_eta) not in self.supported_int_float_types or sbx_crossover_eta <= 0: - self.valid_parameters = False - raise ValueError( - f"sbx_crossover_eta must be a positive number, but got {sbx_crossover_eta!r} " - f"of type {type(sbx_crossover_eta).__name__}." - ) - self.sbx_crossover_eta = float(sbx_crossover_eta) - - # Calculate the value of crossover_probability - if crossover_probability is None: - self.crossover_probability = None - elif type(crossover_probability) in self.supported_int_float_types: - if 0 <= crossover_probability <= 1: - self.crossover_probability = crossover_probability - else: + if type(initial_population) not in [list, tuple, numpy.ndarray]: self.valid_parameters = False - raise ValueError(f"The value assigned to the 'crossover_probability' parameter must be between 0 and 1 inclusive but ({crossover_probability}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'crossover_probability' parameter. Float is expected but ({crossover_probability}) of type {type(crossover_probability)} found.") - - def _validate_mutation(self, - mutation_type, - mutation_probability, - mutation_num_genes, - mutation_percent_genes, - polynomial_mutation_eta=20): - """ - Validate the mutation-related parameters and store them on the - GA instance. ``mutation_type`` may be one of the built-in - strings (``"random"``, ``"swap"``, ``"inversion"``, - ``"scramble"``, ``"adaptive"``), a user-supplied callable, or - None to skip mutation. - - The function also resolves which of ``mutation_probability``, - ``mutation_num_genes`` and ``mutation_percent_genes`` is in - effect and translates percentages to gene counts. - - Sets ``self.mutation`` plus ``self.mutation_type``, - ``self.mutation_probability``, ``self.mutation_num_genes`` and - ``self.mutation_percent_genes``. - - Parameters - ---------- - mutation_type : str, callable, or None - The mutation operator selector. - mutation_probability : float, list, tuple, numpy.ndarray, or None - Per-gene mutation probability between 0 and 1 inclusive. - For adaptive mutation it may be a pair ``[high, low]`` - applied to below-average / above-average solutions. - mutation_num_genes : int, list, tuple, numpy.ndarray, or None - Number of genes to mutate per solution. For adaptive - mutation it may be a pair ``[high, low]``. - mutation_percent_genes : numeric, list, tuple, numpy.ndarray, or 'default' - Percentage of genes to mutate. Ignored when - ``mutation_probability`` or ``mutation_num_genes`` is set. - - Returns - ------- - mutation_num_genes : int, list, tuple, or numpy.ndarray - The resolved number of genes to mutate. - mutation_percent_genes : numeric, list, tuple, or numpy.ndarray - The resolved percentage of genes to mutate. - - Raises - ------ - TypeError - If any parameter has an unexpected type. - ValueError - If a probability is outside [0, 1], a count is non-positive - or larger than ``num_genes``, or a callable has the wrong - number of parameters. - """ - # mutation: Refers to the method that applies the mutation operator based on the selected type of mutation in the mutation_type property. - # Validating the mutation type: mutation_type - # "adaptive" mutation is supported starting from PyGAD 2.10.0 - if mutation_type is None: - self.mutation = None - elif inspect.ismethod(mutation_type): - # Check if the mutation_type is a method that accepts 2 parameters. - if (len(inspect.signature(mutation_type).parameters) == 2): - # The mutation method assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: + raise TypeError(f"The value assigned to the 'initial_population' parameter is expected to be of type list, tuple, or ndarray but {type(initial_population)} found.") + try: + population = numpy.array(initial_population, dtype=object, copy=True) + except ValueError as error: self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned to a method, then it must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class.\n\nThe passed mutation method named '{mutation_type.__code__.co_name}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - elif inspect.isfunction(mutation_type): - # Check if the mutation_type is a function that accepts 2 parameters. - if (len(inspect.signature(mutation_type).parameters) == 2): - # The mutation function assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: + raise ValueError("initial_population must be a rectangular 2D list, tuple, or NumPy array.") from error + if population.ndim != 2 or 0 in population.shape: self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned to a function, then this mutation function must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed mutation function named '{mutation_type.__code__.co_name}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - elif callable(mutation_type) and not inspect.isclass(mutation_type): - # The object must have the __call__() method. - if hasattr(mutation_type, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(mutation_type).parameters) == 2: - # The mutation class instance assigned to the mutation_type parameter is validated. - self.mutation = mutation_type - else: + raise ValueError("initial_population must be a non-empty rectangular 2D list, tuple, or NumPy array.") + for value in population.flat: + if type(value) not in self.supported_int_float_types: self.valid_parameters = False - raise ValueError(f"When 'mutation_type' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The offspring to be mutated.\n2) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed instance of the class named '{mutation_type.__class__.__name__}' accepts {len(inspect.signature(mutation_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'mutation_type' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - elif not (type(mutation_type) is str): - self.valid_parameters = False - raise TypeError(f"The expected type of the 'mutation_type' parameter is either callable or str but {type(mutation_type)} found.") - else: # type mutation_type is str - mutation_type = mutation_type.lower() - if mutation_type == "random": - self.mutation = self.random_mutation - elif mutation_type == "swap": - self.mutation = self.swap_mutation - elif mutation_type == "scramble": - self.mutation = self.scramble_mutation - elif mutation_type == "inversion": - self.mutation = self.inversion_mutation - elif mutation_type == "adaptive": - self.mutation = self.adaptive_mutation - elif mutation_type == "polynomial": - self.mutation = self.polynomial_mutation - else: - self.valid_parameters = False - raise TypeError(f"Undefined mutation type. \nThe assigned string value to the 'mutation_type' parameter ({mutation_type}) does not refer to one of the supported mutation types which are: \n-random (for random mutation)\n-swap (for swap mutation)\n-inversion (for inversion mutation)\n-scramble (for scramble mutation)\n-adaptive (for adaptive mutation)\n-polynomial (for polynomial mutation).\n") + raise TypeError(f"The values in the initial population can be integers or floats but the value ({value}) of type {type(value)} found.") + sol_per_pop, num_genes = population.shape - self.mutation_type = mutation_type - - # Validate polynomial_mutation_eta. It is only used when - # mutation_type is 'polynomial', but it is stored on the - # instance in all cases so user callables can read it too. - if (type(polynomial_mutation_eta) not in self.supported_int_float_types - or polynomial_mutation_eta <= 0): - self.valid_parameters = False - raise ValueError( - f"polynomial_mutation_eta must be a positive number, but " - f"got {polynomial_mutation_eta!r} of type " - f"{type(polynomial_mutation_eta).__name__}." - ) - self.polynomial_mutation_eta = float(polynomial_mutation_eta) - - # Calculate the value of mutation_probability - if not (self.mutation_type is None): - if mutation_probability is None: - self.mutation_probability = None - elif mutation_type != "adaptive": - # Mutation probability is fixed not adaptive. - if type(mutation_probability) in self.supported_int_float_types: - if 0 <= mutation_probability <= 1: - self.mutation_probability = mutation_probability - else: - self.valid_parameters = False - raise ValueError(f"The value assigned to the 'mutation_probability' parameter must be between 0 and 1 inclusive but ({mutation_probability}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_probability' parameter. A numeric value is expected but ({mutation_probability}) of type {type(mutation_probability)} found.") - else: - # Mutation probability is adaptive not fixed. - if type(mutation_probability) in [list, tuple, numpy.ndarray]: - if len(mutation_probability) == 2: - for el in mutation_probability: - if type(el) in self.supported_int_float_types: - if 0 <= el <= 1: - pass - else: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_probability' parameter must be between 0 and 1 inclusive but ({el}) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_probability' parameter. A numeric value is expected but ({el}) of type {type(el)} found.") - if mutation_probability[0] < mutation_probability[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_probability' parameter is {mutation_probability[0]} which is smaller than the second element {mutation_probability[1]}. This means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions. Please make the first element higher than the second element.") - self.mutation_probability = mutation_probability - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_probability' parameter must have only 2 elements but ({len(mutation_probability)}) element(s) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_probability' parameter. When mutation_type='adaptive', then list/tuple/numpy.ndarray is expected but ({mutation_probability}) of type {type(mutation_probability)} found.") - else: - pass + self.sol_per_pop = sol_per_pop + self.num_genes = num_genes + self.pop_size = (sol_per_pop, num_genes) + return population - # Calculate the value of mutation_num_genes - if not (self.mutation_type is None): - if mutation_num_genes is None: - # The mutation_num_genes parameter does not exist. Checking whether adaptive mutation is used. - if mutation_type != "adaptive": - # The percent of genes to mutate is fixed not adaptive. - if mutation_percent_genes == 'default'.lower(): - mutation_percent_genes = 10 - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes = numpy.uint32( - (mutation_percent_genes*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes == 0: - if self.mutation_probability is None: - if not self.suppress_warnings: - warnings.warn( - f"The percentage of genes to mutate (mutation_percent_genes={mutation_percent_genes}) resulted in selecting ({mutation_num_genes}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes = 1 - - elif type(mutation_percent_genes) in self.supported_int_float_types: - if mutation_percent_genes <= 0 or mutation_percent_genes > 100: - self.valid_parameters = False - raise ValueError(f"The percentage of selected genes for mutation (mutation_percent_genes) must be > 0 and <= 100 but ({mutation_percent_genes}) found.\n") - else: - # If mutation_percent_genes equals the string "default", then it is replaced by the numeric value 10. - if mutation_percent_genes == 'default'.lower(): - mutation_percent_genes = 10 - - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes = numpy.uint32( - (mutation_percent_genes*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes == 0: - if self.mutation_probability is None: - if not self.suppress_warnings: - warnings.warn(f"The percentage of genes to mutate (mutation_percent_genes={mutation_percent_genes}) resulted in selecting ({mutation_num_genes}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes = 1 - else: - self.valid_parameters = False - raise TypeError(f"Unexpected value or type of the 'mutation_percent_genes' parameter. It only accepts the string 'default' or a numeric value but ({mutation_percent_genes}) of type {type(mutation_percent_genes)} found.") - else: - # The percent of genes to mutate is adaptive not fixed. - if type(mutation_percent_genes) in [list, tuple, numpy.ndarray]: - if len(mutation_percent_genes) == 2: - mutation_num_genes = numpy.zeros_like( - mutation_percent_genes, dtype=numpy.uint32) - for idx, el in enumerate(mutation_percent_genes): - if type(el) in self.supported_int_float_types: - if el <= 0 or el > 100: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_percent_genes' must be > 0 and <= 100 but ({mutation_percent_genes}) found.\n") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_percent_genes' parameter. An integer value is expected but ({el}) of type {type(el)} found.") - # At this point of the loop, the current value assigned to the parameter 'mutation_percent_genes' is validated. - # Based on the mutation percentage in the 'mutation_percent_genes' parameter, the number of genes to mutate is calculated. - mutation_num_genes[idx] = numpy.uint32( - (mutation_percent_genes[idx]*self.num_genes)/100) - # Based on the mutation percentage of genes, if the number of selected genes for mutation is less than the least possible value which is 1, then the number will be set to 1. - if mutation_num_genes[idx] == 0: - if not self.suppress_warnings: - warnings.warn(f"The percentage of genes to mutate ({mutation_percent_genes[idx]}) resulted in selecting ({mutation_num_genes[idx]}) genes. The number of genes to mutate is set to 1 (mutation_num_genes=1).\nIf you do not want to mutate any gene, please set mutation_type=None.") - mutation_num_genes[idx] = 1 - if mutation_percent_genes[0] < mutation_percent_genes[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_percent_genes' parameter is ({mutation_percent_genes[0]}) which is smaller than the second element ({mutation_percent_genes[1]}).\nThis means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions.\nPlease make the first element higher than the second element.") - # At this point outside the loop, all values of the parameter 'mutation_percent_genes' are validated. Everything is OK. - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_percent_genes' parameter must have only 2 elements but ({len(mutation_percent_genes)}) element(s) found.") - else: - if self.mutation_probability is None: - self.valid_parameters = False - raise TypeError(f"Unexpected type of the 'mutation_percent_genes' parameter. When mutation_type='adaptive', then the 'mutation_percent_genes' parameter should exist and assigned a list/tuple/numpy.ndarray with 2 values but ({mutation_percent_genes}) found.") - # The mutation_num_genes parameter exists. Checking whether adaptive mutation is used. - elif mutation_type != "adaptive": - # Number of genes to mutate is fixed not adaptive. - if type(mutation_num_genes) in self.supported_int_types: - if mutation_num_genes <= 0: - self.valid_parameters = False - raise ValueError(f"The number of selected genes for mutation (mutation_num_genes) cannot be <= 0 but ({mutation_num_genes}) found. If you do not want to use mutation, please set mutation_type=None\n") - elif mutation_num_genes > self.num_genes: - self.valid_parameters = False - raise ValueError(f"The number of selected genes for mutation (mutation_num_genes), which is ({mutation_num_genes}), cannot be greater than the number of genes ({self.num_genes}).\n") - else: - self.valid_parameters = False - raise TypeError(f"The 'mutation_num_genes' parameter is expected to be a positive integer but the value ({mutation_num_genes}) of type {type(mutation_num_genes)} found.\n") - else: - # Number of genes to mutate is adaptive not fixed. - if type(mutation_num_genes) in [list, tuple, numpy.ndarray]: - if len(mutation_num_genes) == 2: - for el in mutation_num_genes: - if type(el) in self.supported_int_types: - if el <= 0: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_num_genes' cannot be <= 0 but ({el}) found. If you do not want to use mutation, please set mutation_type=None\n") - elif el > self.num_genes: - self.valid_parameters = False - raise ValueError(f"The values assigned to the 'mutation_num_genes' cannot be greater than the number of genes ({self.num_genes}) but ({el}) found.\n") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for a value assigned to the 'mutation_num_genes' parameter. An integer value is expected but ({el}) of type {type(el)} found.") - # At this point of the loop, the current value assigned to the parameter 'mutation_num_genes' is validated. - if mutation_num_genes[0] < mutation_num_genes[1]: - if not self.suppress_warnings: - warnings.warn(f"The first element in the 'mutation_num_genes' parameter is {mutation_num_genes[0]} which is smaller than the second element {mutation_num_genes[1]}. This means the mutation rate for the high-quality solutions is higher than the mutation rate of the low-quality ones. This causes high disruption in the high quality solutions while making little changes in the low quality solutions. Please make the first element higher than the second element.") - # At this point outside the loop, all values of the parameter 'mutation_num_genes' are validated. Everything is OK. - else: - self.valid_parameters = False - raise ValueError(f"When mutation_type='adaptive', then the 'mutation_num_genes' parameter must have only 2 elements but ({len(mutation_num_genes)}) element(s) found.") - else: - self.valid_parameters = False - raise TypeError(f"Unexpected type for the 'mutation_num_genes' parameter. When mutation_type='adaptive', then list/tuple/numpy.ndarray is expected but ({mutation_num_genes}) of type {type(mutation_num_genes)} found.") - else: - pass - - # Validating mutation_by_replacement and mutation_type - if self.mutation_type != "random" and self.mutation_by_replacement: - if not self.suppress_warnings: - warnings.warn(f"The mutation_by_replacement parameter is set to True while the mutation_type parameter is not set to random but ({mutation_type}). Note that the mutation_by_replacement parameter has an effect only when mutation_type='random'.") - - # Check if crossover and mutation are both disabled. - if (self.mutation_type is None) and (self.crossover_type is None): - if not self.suppress_warnings: - warnings.warn("The 2 parameters mutation_type and crossover_type are None. This disables any type of evolution the genetic algorithm can make. As a result, the genetic algorithm cannot find a better solution than the best solution in the initial population.") - return mutation_num_genes, mutation_percent_genes - - def _validate_nsga3_num_divisions(self, parent_selection_type, nsga3_num_divisions): + def _build_initial_population(self, initial_population): """ - Validate ``nsga3_num_divisions`` and store it on the GA - instance. The parameter is only required when - ``parent_selection_type`` is ``"nsga3"`` or - ``"tournament_nsga3"``; otherwise the value is accepted as-is - for forward compatibility. - - Parameters - ---------- - parent_selection_type : str - The selection operator name. Only the two NSGA-III - variants treat ``nsga3_num_divisions`` as required. - nsga3_num_divisions : int or None - Number of divisions per objective axis (the ``p`` - parameter of the Das-Dennis reference grid). - - Raises - ------ - ValueError - If ``parent_selection_type`` is one of the NSGA-III - variants and ``nsga3_num_divisions`` is None, not an - integer, or not positive. + Store a generated or supplied population after applying gene types, + constraints, and duplicate repair. Dimensions and numeric values + are validated before this method is called. Supplied values are + preserved even outside the generation range or gene space; only + replacements use the configured generation settings. """ - if parent_selection_type not in ("nsga3", "tournament_nsga3"): - self.nsga3_num_divisions = nsga3_num_divisions + if initial_population is None: + self.initialize_population(self.allow_duplicate_genes, self.gene_type, self.gene_constraint) + else: + self.population = self.prepare_initial_population(initial_population) + # Keep separate arrays so evolution cannot modify this snapshot. + self.initial_population = self.population.copy() + + def _validate_mutation_range(self, random_mutation_min_val, random_mutation_max_val): + """Validate and copy the scalar or per-gene random mutation bounds.""" + self.random_mutation_min_val, self.random_mutation_max_val = self._validate_range_parameters( + random_mutation_min_val, random_mutation_max_val, + 'random_mutation_min_val', 'random_mutation_max_val') + + def _validate_gene_constraint(self, gene_constraint): + """Copy one optional constraint per gene and validate its positional call.""" + if gene_constraint is None: + self.gene_constraint = None return - if nsga3_num_divisions is None: - self.valid_parameters = False - raise ValueError( - f"parent_selection_type='{parent_selection_type}' requires " - f"nsga3_num_divisions to be a positive integer. Pass " - f"nsga3_num_divisions=<int> to GA(...)." - ) - if (type(nsga3_num_divisions) not in self.supported_int_types - or nsga3_num_divisions <= 0): - self.valid_parameters = False - raise ValueError( - f"nsga3_num_divisions must be a positive integer when " - f"parent_selection_type='{parent_selection_type}', but got " - f"{nsga3_num_divisions!r} of type " - f"{type(nsga3_num_divisions).__name__}." - ) - self.nsga3_num_divisions = int(nsga3_num_divisions) - - def _validate_parent_selection(self, - parent_selection_type, - K_tournament, - keep_parents, - keep_elitism, - nsga3_num_divisions=None): - """ - Validate the parameters that control parent selection, - retention and elitism. Resolves ``parent_selection_type`` to - an actual operator (built-in string or user callable) and - stores it on ``self.select_parents``. Also computes - ``self.num_offspring`` from ``sol_per_pop``, ``keep_parents`` - and ``keep_elitism``. - - Parameters - ---------- - parent_selection_type : str or callable - One of the built-in selection names or a user-supplied - function with three parameters (fitness, num_parents, - ga_instance). - K_tournament : int - Tournament size used by the tournament-based operators. - Clipped to ``self.sol_per_pop`` when too large. - keep_parents : int - Number of parents to carry over to the next generation. - ``-1`` keeps all selected parents; ``0`` keeps none; - positive values keep exactly that many. - keep_elitism : int - Number of top solutions to copy unchanged into the next - generation. Takes priority over ``keep_parents``. - nsga3_num_divisions : int or None - Forwarded to ``_validate_nsga3_num_divisions``. - - Returns - ------- - parent_selection_type : str or callable - The (possibly lowercased) selection type stored on - ``self``. - - Raises - ------ - TypeError - If ``parent_selection_type`` is not a supported type, or - a user callable does not have three parameters, or - ``K_tournament`` / ``keep_parents`` / ``keep_elitism`` is - of the wrong type. - ValueError - If a numeric parameter is out of range, or the selection - name is unknown. - """ - # select_parents: Refers to a method that selects the parents based on the parent selection type specified in the parent_selection_type attribute. - # Validating the selected type of parent selection: parent_selection_type - if inspect.ismethod(parent_selection_type): - # Check if the parent_selection_type is a method that accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection method assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned to a method, then it must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class.\n\nThe passed parent selection method named '{parent_selection_type.__code__.co_name}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - elif inspect.isfunction(parent_selection_type): - # Check if the parent_selection_type is a function that accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection function assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned to a user-defined function, then this parent selection function must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed parent selection function named '{parent_selection_type.__code__.co_name}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - elif callable(parent_selection_type) and not inspect.isclass(parent_selection_type): - # The object must have the __call__() method. - if hasattr(parent_selection_type, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(parent_selection_type).parameters) == 3: - # The parent selection class instance assigned to the parent_selection_type parameter is validated. - self.select_parents = parent_selection_type - else: - self.valid_parameters = False - raise ValueError(f"When 'parent_selection_type' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The fitness values of the current population.\n2) The number of parents needed.\n3) The instance from the pygad.GA class to retrieve any property like population, gene data type, gene space, etc.\n\nThe passed instance of the class named '{parent_selection_type.__class__.__name__}' accepts {len(inspect.signature(parent_selection_type).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'parent_selection_type' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - elif not (type(parent_selection_type) is str): - self.valid_parameters = False - - raise TypeError(f"The expected type of the 'parent_selection_type' parameter is either callable or str but {type(parent_selection_type)} found.") + if not isinstance(gene_constraint, (list, tuple)): + raise TypeError("gene_constraint must be a list or tuple, or None.") + if len(gene_constraint) != self.num_genes: + raise ValueError(f"The number of constraints ({len(gene_constraint)}) must equal num_genes ({self.num_genes}).") + self.gene_constraint = [None if constraint is None else + self._validate_callable_parameter(constraint, f'gene_constraint at index {index}', 2) + for index, constraint in enumerate(gene_constraint)] + + def _validate_crossover(self, crossover_type, crossover_probability, sbx_crossover_eta=30): + """Resolve crossover and validate its probability and finite distribution index.""" + operators = {'single_point': 'single_point_crossover', 'two_points': 'two_points_crossover', + 'uniform': 'uniform_crossover', 'scattered': 'scattered_crossover', 'sbx': 'sbx_crossover'} + self.crossover_type, self.crossover = self._resolve_operator(crossover_type, 'crossover_type', operators, 3, allow_none=True) + self.sbx_crossover_eta = self._validate_numeric_parameter(sbx_crossover_eta, 'sbx_crossover_eta') + if self.sbx_crossover_eta <= 0: + raise ValueError('sbx_crossover_eta must be positive.') + self.crossover_probability = (None if crossover_probability is None else + self._validate_numeric_parameter(crossover_probability, 'crossover_probability', 0, 1)) + + def _validate_mutation(self, mutation_type, mutation_probability, mutation_num_genes, + mutation_percent_genes, polynomial_mutation_eta=20): + """Resolve the active mutation control before validating its values. + + Probability takes precedence over a gene count, which takes precedence + over a percentage. Permutation and polynomial operators keep their + historical defaults when none of these controls is explicitly set. + """ + operators = {'random': 'random_mutation', 'swap': 'swap_mutation', 'inversion': 'inversion_mutation', + 'scramble': 'scramble_mutation', 'adaptive': 'adaptive_mutation', 'polynomial': 'polynomial_mutation'} + self.mutation_type, self.mutation = self._resolve_operator(mutation_type, 'mutation_type', operators, 2, allow_none=True) + self.polynomial_mutation_eta = self._validate_numeric_parameter(polynomial_mutation_eta, 'polynomial_mutation_eta') + if self.polynomial_mutation_eta <= 0: + raise ValueError('polynomial_mutation_eta must be positive.') + self.mutation_probability = None + self.mutation_control_explicitly_set = (mutation_probability is not None or mutation_num_genes is not None or + not (isinstance(mutation_percent_genes, str) and mutation_percent_genes == 'default')) + if self.mutation_type is None: + if self.crossover_type is None and not self.suppress_warnings: + warnings.warn('Crossover and mutation are disabled, so the initial population cannot evolve.') + return None, 'default' + adaptive = self.mutation_type == 'adaptive' + if mutation_probability is not None: + self.mutation_probability = self._validate_mutation_control(mutation_probability, 'mutation_probability', adaptive, 0, 1) + mutation_num_genes, mutation_percent_genes = None, 'default' + elif mutation_num_genes is not None: + mutation_num_genes = self._validate_mutation_control(mutation_num_genes, 'mutation_num_genes', adaptive, 1, self.num_genes, integer=True) + mutation_percent_genes = 'default' else: - parent_selection_type = parent_selection_type.lower() - if parent_selection_type == "sss": - self.select_parents = self.steady_state_selection - elif parent_selection_type == "rws": - self.select_parents = self.roulette_wheel_selection - elif parent_selection_type == "sus": - self.select_parents = self.stochastic_universal_selection - elif parent_selection_type == "random": - self.select_parents = self.random_selection - elif parent_selection_type == "tournament": - self.select_parents = self.tournament_selection - elif parent_selection_type == "tournament_nsga2": # Supported in PyGAD >= 3.2 - self.select_parents = self.tournament_selection_nsga2 - elif parent_selection_type == "nsga2": # Supported in PyGAD >= 3.2 - self.select_parents = self.nsga2_selection - elif parent_selection_type == "tournament_nsga3": - self.select_parents = self.tournament_selection_nsga3 - elif parent_selection_type == "nsga3": - self.select_parents = self.nsga3_selection - elif parent_selection_type == "rank": - self.select_parents = self.rank_selection - else: - self.valid_parameters = False - raise TypeError(f"Undefined parent selection type: {parent_selection_type}. \nThe assigned value to the 'parent_selection_type' parameter does not refer to one of the supported parent selection techniques which are: \n-sss (steady state selection)\n-rws (roulette wheel selection)\n-sus (stochastic universal selection)\n-rank (rank selection)\n-random (random selection)\n-tournament (tournament selection)\n-tournament_nsga2: (Tournament selection for NSGA-II)\n-nsga2: (NSGA-II parent selection)\n-tournament_nsga3: (Tournament selection for NSGA-III)\n-nsga3: (NSGA-III parent selection).\n") - - # For tournament selection, validate the K value. - if parent_selection_type == "tournament": - if type(K_tournament) in self.supported_int_types: - if K_tournament > self.sol_per_pop: - K_tournament = self.sol_per_pop + if isinstance(mutation_percent_genes, str) and mutation_percent_genes == 'default': + if adaptive: + raise TypeError("Adaptive mutation requires a pair of probabilities, gene counts, or percentages.") + mutation_percent_genes = 10 + mutation_percent_genes = self._validate_mutation_control(mutation_percent_genes, 'mutation_percent_genes', adaptive, 0, 100) + percentages = mutation_percent_genes if adaptive else [mutation_percent_genes] + counts = [] + for percentage in percentages: + if percentage <= 0: + raise ValueError('mutation_percent_genes must be > 0 and <= 100.') + count = int(percentage * self.num_genes / 100) + if count == 0: + count = 1 if not self.suppress_warnings: - warnings.warn(f"K of the tournament selection ({K_tournament}) should not be greater than the number of solutions within the population ({self.sol_per_pop}).\nK will be clipped to be equal to the number of solutions in the population (sol_per_pop).\n") - elif K_tournament <= 0: - self.valid_parameters = False - raise ValueError(f"K of the tournament selection cannot be <=0 but ({K_tournament}) found.\n") - else: - self.valid_parameters = False - raise ValueError(f"The type of K of the tournament selection must be integer but the value ({K_tournament}) of type ({type(K_tournament)}) found.") + warnings.warn('mutation_percent_genes selects fewer than one gene. mutation_num_genes is set to 1.') + counts.append(count) + mutation_num_genes = counts if adaptive else counts[0] + if self.mutation_by_replacement and self.mutation_type not in ('random', 'adaptive') and not self.suppress_warnings: + warnings.warn('mutation_by_replacement applies only to random and adaptive mutation.') + if self.crossover_type is None and self.mutation_type is None and not self.suppress_warnings: + warnings.warn('Crossover and mutation are disabled, so the initial population cannot evolve.') + return mutation_num_genes, mutation_percent_genes - self.K_tournament = K_tournament + def _validate_mutation_control(self, value, parameter_name, adaptive, minimum, maximum, integer=False): + """Validate a scalar setting or the two rates used by adaptive mutation.""" + if adaptive: + if not isinstance(value, (list, tuple, numpy.ndarray)) or numpy.asarray(value, dtype=object).shape != (2,): + raise ValueError(f"{parameter_name} must be a 1D sequence of two values for adaptive mutation.") + values = list(value) + else: + values = [value] + validator = self._validate_integer_parameter if integer else self._validate_numeric_parameter + values = [validator(item, parameter_name, minimum, maximum) for item in values] + if adaptive and values[0] < values[1] and not self.suppress_warnings: + warnings.warn(f'The first {parameter_name} value is smaller than the second, so high-quality solutions mutate more frequently.') + return values if adaptive else values[0] + def _validate_nsga3_num_divisions(self, parent_selection_type, nsga3_num_divisions): + """Validate the division count when an NSGA-III operator uses it.""" + if parent_selection_type in ('nsga3', 'tournament_nsga3'): + if nsga3_num_divisions is None: + raise ValueError('NSGA-III requires nsga3_num_divisions to be a positive integer.') + nsga3_num_divisions = self._validate_integer_parameter(nsga3_num_divisions, 'nsga3_num_divisions', minimum=1) + self.nsga3_num_divisions = nsga3_num_divisions + + def _validate_parent_selection(self, parent_selection_type, K_tournament, + keep_parents, keep_elitism, nsga3_num_divisions=None): + """Resolve selection and validate tournament size and retained solutions.""" + operators = {'sss': 'steady_state_selection', 'rws': 'roulette_wheel_selection', + 'sus': 'stochastic_universal_selection', 'random': 'random_selection', + 'tournament': 'tournament_selection', 'tournament_nsga2': 'tournament_selection_nsga2', + 'nsga2': 'nsga2_selection', 'tournament_nsga3': 'tournament_selection_nsga3', + 'nsga3': 'nsga3_selection', 'rank': 'rank_selection'} + parent_selection_type, self.select_parents = self._resolve_operator(parent_selection_type, 'parent_selection_type', operators, 3) + if parent_selection_type in ('tournament', 'tournament_nsga2', 'tournament_nsga3'): + K_tournament = self._validate_integer_parameter(K_tournament, 'K_tournament', minimum=1) + if K_tournament > self.sol_per_pop: + if not self.suppress_warnings: + warnings.warn(f'K_tournament is clipped to sol_per_pop ({self.sol_per_pop}).') + K_tournament = self.sol_per_pop + self.K_tournament = int(K_tournament) if isinstance(K_tournament, numpy.integer) else K_tournament self._validate_nsga3_num_divisions(parent_selection_type, nsga3_num_divisions) - - # Validating the number of parents to keep in the next population: keep_parents - # keep_parents defaults to None (sentinel) so we can tell whether the user - # explicitly set it. Resolve None to -1 to preserve the historical default - # behavior (keep all selected parents) byte-for-byte. self.keep_parents_explicitly_set = keep_parents is not None - if keep_parents is None: - keep_parents = -1 - if not (type(keep_parents) in self.supported_int_types): - self.valid_parameters = False - raise TypeError(f"Incorrect type of the value assigned to the keep_parents parameter. The value ({keep_parents}) of type {type(keep_parents)} found but an integer is expected.") - elif keep_parents > self.sol_per_pop or keep_parents > self.num_parents_mating or keep_parents < -1: - self.valid_parameters = False - raise ValueError(f"Incorrect value to the keep_parents parameter: {keep_parents}. \nThe assigned value to the keep_parent parameter must satisfy the following conditions: \n1) Less than or equal to sol_per_pop\n2) Less than or equal to num_parents_mating\n3) Greater than or equal to -1.") - - self.keep_parents = keep_parents - - if parent_selection_type == "sss" and self.keep_parents == 0: - if not self.suppress_warnings: - warnings.warn("The steady-state parent (sss) selection operator is used despite that no parents are kept in the next generation.") - - # Validating the number of elitism to keep in the next population: keep_elitism - if not (type(keep_elitism) in self.supported_int_types): - self.valid_parameters = False - raise TypeError(f"Incorrect type of the value assigned to the keep_elitism parameter. The value ({keep_elitism}) of type {type(keep_elitism)} found but an integer is expected.") - elif keep_elitism > self.sol_per_pop or keep_elitism < 0: - self.valid_parameters = False - raise ValueError(f"Incorrect value to the keep_elitism parameter: {keep_elitism}. \nThe assigned value to the keep_elitism parameter must satisfy the following conditions: \n1) Less than or equal to sol_per_pop\n2) Greater than or equal to 0.") - - self.keep_elitism = keep_elitism - - # keep_elitism takes precedence over keep_parents: when keep_elitism > 0, - # keep_parents is ignored. Warn if the user explicitly set keep_parents while - # keep_elitism is non-zero, instead of silently ignoring it. - if self.keep_parents_explicitly_set and self.keep_elitism != 0: - if not self.suppress_warnings: - warnings.warn(f"Both keep_parents (={self.keep_parents}) and keep_elitism (={self.keep_elitism}) are set. Because keep_elitism is greater than 0, it takes precedence and keep_parents is ignored. To make keep_parents take effect, set keep_elitism=0.") - + self.keep_parents = self._validate_integer_parameter(-1 if keep_parents is None else keep_parents, + 'keep_parents', -1, self.num_parents_mating) + self.keep_elitism = self._validate_integer_parameter(keep_elitism, 'keep_elitism', 0, self.sol_per_pop) + if self.keep_parents_explicitly_set and self.keep_elitism > 0 and not self.suppress_warnings: + warnings.warn(f'keep_elitism (={self.keep_elitism}) takes precedence over keep_parents (={self.keep_parents}). Set keep_elitism=0 to retain parents instead.') self._refresh_num_offspring() - return parent_selection_type def _refresh_num_offspring(self): @@ -1308,551 +478,91 @@ def _refresh_num_offspring(self): else: self.num_offspring = self.sol_per_pop - self.keep_elitism - def _validate_fitness_func(self, - fitness_func, - fitness_batch_size): - """ - Validate the ``fitness_func`` and ``fitness_batch_size`` - parameters and store them on the GA instance. The fitness - function must be a method or function (or a class with a - ``__call__`` method) that takes three parameters: the GA - instance, a solution (or a batch), and the solution index (or - a batch of indices). - - Sets ``self.fitness_func`` and ``self.fitness_batch_size``. - - Parameters - ---------- - fitness_func : callable - The fitness function described above. - fitness_batch_size : int or None - When set, batches of this many solutions are passed to - ``fitness_func`` at once. ``None`` or ``1`` evaluates one - solution per call. - - Raises - ------ - TypeError - If ``fitness_func`` is not callable. - ValueError - If ``fitness_func`` does not accept three parameters, or - ``fitness_batch_size`` is not a positive integer. - """ - # Check if the fitness_func is a method. - if inspect.ismethod(fitness_func): - # Check if the fitness method accepts 3 parameters. - if len(inspect.signature(fitness_func).parameters) == 3: - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"In PyGAD 2.20.0, if a method is used to calculate the fitness value, then it must accept 3 parameters\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed fitness method named '{fitness_func.__code__.co_name}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - elif inspect.isfunction(fitness_func): - # Check if the fitness function accepts 3 parameters. - if len(inspect.signature(fitness_func).parameters) == 3: - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"In PyGAD 2.20.0, the fitness function must accept 3 parameters:\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed fitness function named '{fitness_func.__code__.co_name}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - elif callable(fitness_func) and not inspect.isclass(fitness_func): - # The object must have the __call__() method. - if hasattr(fitness_func, '__call__'): - # Check if the __call__() method accepts 3 parameters. - if len(inspect.signature(fitness_func).parameters) == 3: - # The fitness class instance assigned to the fitness_func parameter is validated. - self.fitness_func = fitness_func - else: - self.valid_parameters = False - raise ValueError(f"When 'fitness_func' is assigned a class instance, then its __call__ method must accept 3 parameters:\n1) The instance of the 'pygad.GA' class.\n2) A solution to calculate its fitness value.\n3) The solution's index within the population.\n\nThe passed instance of the class named '{fitness_func.__class__.__name__}' accepts {len(inspect.signature(fitness_func).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'fitness_func' is assigned a class instance, then its __call__ method must be implemented and accept 3 parameters.") - else: - self.valid_parameters = False - - raise TypeError(f"The value assigned to the fitness_func parameter is expected to be a function or a method but {type(fitness_func)} found.") - - if fitness_batch_size is None: - pass - elif not (type(fitness_batch_size) in self.supported_int_types): - self.valid_parameters = False - raise TypeError(f"The value assigned to the fitness_batch_size parameter is expected to be integer but the value ({fitness_batch_size}) of type {type(fitness_batch_size)} found.") - elif fitness_batch_size <= 0 or fitness_batch_size > self.sol_per_pop: - self.valid_parameters = False - raise ValueError(f"The value assigned to the fitness_batch_size parameter must be:\n1) Greater than 0.\n2) Less than or equal to sol_per_pop ({self.sol_per_pop}).\nBut the value ({fitness_batch_size}) found.") - - self.fitness_batch_size = fitness_batch_size - - def _validate_callbacks(self, - on_start, - on_fitness, - on_parents, - on_crossover, - on_mutation, - on_generation, - on_stop): - """ - Validate the seven optional lifecycle callbacks and store - them on the GA instance under matching ``self.on_*`` - attributes. Each callback must be a function or method with - the expected number of parameters. - - Parameters - ---------- - on_start : callable or None - Called once before the generational loop. Receives the - GA instance. - on_fitness : callable or None - Called after the fitness of the current population has - been evaluated. Receives the GA instance and the fitness - array. - on_parents : callable or None - Called after the parent selection step. Receives the GA - instance and the selected parents. - on_crossover : callable or None - Called after the crossover step. Receives the GA instance - and the crossover offspring. - on_mutation : callable or None - Called after the mutation step. Receives the GA instance - and the mutated offspring. - on_generation : callable or None - Called after each generation completes. Receives the GA - instance. Returning the string ``"stop"`` ends the run. - on_stop : callable or None - Called once after the generational loop ends. Receives - the GA instance and the last-generation fitness array. - - Raises - ------ - TypeError - If a callback is not callable. - ValueError - If a callback does not have the expected number of - parameters. - """ - # Check if the on_start exists. - if not (on_start is None): - if inspect.ismethod(on_start): - # Check if the on_start method accepts 1 parameter. - if len(inspect.signature(on_start).parameters) == 1: - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_start parameter must accept only 1 parameter representing the instance of the genetic algorithm. The passed method named '{on_start.__code__.co_name}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - # Check if the on_start is a function. - elif inspect.isfunction(on_start): - # Check if the on_start function accepts only a single parameter. - if len(inspect.signature(on_start).parameters) == 1: - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_start parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed function named '{on_start.__code__.co_name}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - elif callable(on_start) and not inspect.isclass(on_start): - # The object must have the __call__() method. - if hasattr(on_start, '__call__'): - # Check if the __call__() method accepts 1 parameter. - if len(inspect.signature(on_start).parameters) == 1: - # The on_start class instance assigned to the on_start parameter is validated. - self.on_start = on_start - else: - self.valid_parameters = False - raise ValueError(f"When 'on_start' is assigned a class instance, then its __call__ method must accept only 1 parameter representing the instance of the genetic algorithm.\n\nThe passed instance of the class named '{on_start.__class__.__name__}' accepts {len(inspect.signature(on_start).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_start' is assigned a class instance, then its __call__ method must be implemented and accept 1 parameter.") - else: - self.valid_parameters = False - - raise TypeError(f"The value assigned to the on_start parameter is expected to be of type function but {type(on_start)} found.") - else: - self.on_start = None - - # Check if the on_fitness exists. - if not (on_fitness is None): - # Check if the on_fitness is a method. - if inspect.ismethod(on_fitness): - # Check if the on_fitness method accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_fitness parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed method named '{on_fitness.__code__.co_name}' accepts {len(inspect.signature(on_fitness).parameters)} parameter(s).") - # Check if the on_fitness is a function. - elif inspect.isfunction(on_fitness): - # Check if the on_fitness function accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_fitness parameter must accept 2 parameters representing the instance of the genetic algorithm and the fitness values of all solutions.\nThe passed function named '{on_fitness.__code__.co_name}' accepts {on_fitness.__code__.co_argcount} parameter(s).") - elif callable(on_fitness) and not inspect.isclass(on_fitness): - # The object must have the __call__() method. - if hasattr(on_fitness, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_fitness).parameters) == 2: - # The on_fitness class instance assigned to the on_fitness parameter is validated. - self.on_fitness = on_fitness - else: - self.valid_parameters = False - raise ValueError(f"When 'on_fitness' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\n\nThe passed instance of the class named '{on_fitness.__class__.__name__}' accepts {len(inspect.signature(on_fitness).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_fitness' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_fitness parameter is expected to be of type function but {type(on_fitness)} found.") - else: - self.on_fitness = None - - # Check if the on_parents exists. - if not (on_parents is None): - # Check if the on_parents is a method. - if inspect.ismethod(on_parents): - # Check if the on_parents method accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_parents parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed method named '{on_parents.__code__.co_name}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - # Check if the on_parents is a function. - elif inspect.isfunction(on_parents): - # Check if the on_parents function accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_parents parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\nThe passed function named '{on_parents.__code__.co_name}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - elif callable(on_parents) and not inspect.isclass(on_parents): - # The object must have the __call__() method. - if hasattr(on_parents, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_parents).parameters) == 2: - # The on_parents class instance assigned to the on_parents parameter is validated. - self.on_parents = on_parents - else: - self.valid_parameters = False - raise ValueError(f"When 'on_parents' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The fitness values of all solutions.\n\nThe passed instance of the class named '{on_parents.__class__.__name__}' accepts {len(inspect.signature(on_parents).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_parents' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_parents parameter is expected to be of type function but {type(on_parents)} found.") - else: - self.on_parents = None - - # Check if the on_crossover exists. - if not (on_crossover is None): - # Check if the on_crossover is a method. - if inspect.ismethod(on_crossover): - # Check if the on_crossover method accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_crossover parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring generated using crossover.\nThe passed method named '{on_crossover.__code__.co_name}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - # Check if the on_crossover is a function. - elif inspect.isfunction(on_crossover): - # Check if the on_crossover function accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_crossover parameter must accept 2 parameters representing the instance of the genetic algorithm and the offspring generated using crossover.\nThe passed function named '{on_crossover.__code__.co_name}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - elif callable(on_crossover) and not inspect.isclass(on_crossover): - # The object must have the __call__() method. - if hasattr(on_crossover, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_crossover).parameters) == 2: - # The on_crossover class instance assigned to the on_crossover parameter is validated. - self.on_crossover = on_crossover - else: - self.valid_parameters = False - raise ValueError(f"When 'on_crossover' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring generated using crossover.\n\nThe passed instance of the class named '{on_crossover.__class__.__name__}' accepts {len(inspect.signature(on_crossover).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_crossover' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_crossover parameter is expected to be of type function but {type(on_crossover)} found.") - else: - self.on_crossover = None - - # Check if the on_mutation exists. - if not (on_mutation is None): - # Check if the on_mutation is a method. - if inspect.ismethod(on_mutation): - # Check if the on_mutation method accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_mutation parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring after applying the mutation operation.\nThe passed method named '{on_mutation.__code__.co_name}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - # Check if the on_mutation is a function. - elif inspect.isfunction(on_mutation): - # Check if the on_mutation function accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_mutation parameter must accept 2 parameters representing the instance of the genetic algorithm and the offspring after applying the mutation operation.\nThe passed function named '{on_mutation.__code__.co_name}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - elif callable(on_mutation) and not inspect.isclass(on_mutation): - # The object must have the __call__() method. - if hasattr(on_mutation, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_mutation).parameters) == 2: - # The on_mutation class instance assigned to the on_mutation parameter is validated. - self.on_mutation = on_mutation - else: - self.valid_parameters = False - raise ValueError(f"When 'on_mutation' is assigned a class instance, then its __call__ method must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) The offspring after applying the mutation operation.\n\nThe passed instance of the class named '{on_mutation.__class__.__name__}' accepts {len(inspect.signature(on_mutation).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_mutation' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_mutation parameter is expected to be of type function but {type(on_mutation)} found.") - else: - self.on_mutation = None - - # Check if the on_generation exists. - if not (on_generation is None): - # Check if the on_generation is a method. - if inspect.ismethod(on_generation): - # Check if the on_generation method accepts 1 parameter. - if len(inspect.signature(on_generation).parameters) == 1: - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_generation parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed method named '{on_generation.__code__.co_name}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - # Check if the on_generation is a function. - elif inspect.isfunction(on_generation): - # Check if the on_generation function accepts only a single parameter. - if len(inspect.signature(on_generation).parameters) == 1: - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_generation parameter must accept only 1 parameter representing the instance of the genetic algorithm.\nThe passed function named '{on_generation.__code__.co_name}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - elif callable(on_generation) and not inspect.isclass(on_generation): - # The object must have the __call__() method. - if hasattr(on_generation, '__call__'): - # Check if the __call__() method accepts 1 parameter. - if len(inspect.signature(on_generation).parameters) == 1: - # The on_generation class instance assigned to the on_generation parameter is validated. - self.on_generation = on_generation - else: - self.valid_parameters = False - raise ValueError(f"When 'on_generation' is assigned a class instance, then its __call__ method must accept only 1 parameter representing the instance of the genetic algorithm.\n\nThe passed instance of the class named '{on_generation.__class__.__name__}' accepts {len(inspect.signature(on_generation).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_generation' is assigned a class instance, then its __call__ method must be implemented and accept 1 parameter.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the on_generation parameter is expected to be of type function but {type(on_generation)} found.") - else: - self.on_generation = None - - # Check if the on_stop exists. - if not (on_stop is None): - # Check if the on_stop is a method. - if inspect.ismethod(on_stop): - # Check if the on_stop method accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"The method assigned to the on_stop parameter must accept 2 parameters:\n1) The instance of the genetic algorithm.\n2) A list of the fitness values of the solutions in the last population.\n\nThe passed method named '{on_stop.__code__.co_name}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - # Check if the on_stop is a function. - elif inspect.isfunction(on_stop): - # Check if the on_stop function accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"The function assigned to the on_stop parameter must accept 2 parameters representing the instance of the genetic algorithm and a list of the fitness values of the solutions in the last population.\nThe passed function named '{on_stop.__code__.co_name}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - elif callable(on_stop) and not inspect.isclass(on_stop): - # The object must have the __call__() method. - if hasattr(on_stop, '__call__'): - # Check if the __call__() method accepts 2 parameters. - if len(inspect.signature(on_stop).parameters) == 2: - # The on_stop class instance assigned to the on_stop parameter is validated. - self.on_stop = on_stop - else: - self.valid_parameters = False - raise ValueError(f"When 'on_stop' is assigned a class instance, then its __call__ method must accept 2 parameters: \n1) The instance of the genetic algorithm.\n2) A list of the fitness values of the solutions in the last population.\n\nThe passed instance of the class named '{on_stop.__class__.__name__}' accepts {len(inspect.signature(on_stop).parameters)} parameter(s).") - else: - self.valid_parameters = False - raise ValueError("When 'on_stop' is assigned a class instance, then its __call__ method must be implemented and accept 2 parameters.") - else: - self.valid_parameters = False - raise TypeError(f"The value assigned to the 'on_stop' parameter is expected to be of type function but {type(on_stop)} found.") - else: - self.on_stop = None - - def _validate_stop_criteria(self, - stop_criteria): - """ - Validate the ``stop_criteria`` parameter and store the parsed - criteria on ``self.stop_criteria`` for later use by ``run``. - Each criterion follows the form ``"keyword_value"`` (or - ``"keyword_v1_v2_..."`` for multi-objective ``reach``). - Supported keywords: - - - ``"reach"``: stop when the best fitness is at least the - target value. - - ``"saturate"``: stop when the best fitness does not change - for the given number of generations. - - ``"time"``: stop when the time spent inside ``run()`` is - at least the given number of seconds. - - ``"evaluations"``: stop when the number of fitness function - calls made inside ``run()`` reaches the given count. - - Parameters - ---------- - stop_criteria : str, list, tuple, or None - A single criterion string, an iterable of criterion - strings, or ``None`` to run for all generations. - - Raises - ------ - TypeError - If ``stop_criteria`` is not a string, list, tuple, or - None, or if a list element is not a string. - ValueError - If a criterion uses an unknown keyword or its value is - not a number. - """ - self.stop_criteria = [] - self.supported_stop_words = ["reach", "saturate", "time", "evaluations"] + def _validate_fitness_func(self, fitness_func, fitness_batch_size): + """Validate the fitness call and optional batch size before sampling.""" + self.fitness_func = self._validate_callable_parameter(fitness_func, 'fitness_func', 3) + self.fitness_batch_size = (None if fitness_batch_size is None else + self._validate_integer_parameter(fitness_batch_size, 'fitness_batch_size', 1, self.sol_per_pop)) + + def _validate_callbacks(self, on_start, on_fitness, on_parents, on_crossover, + on_mutation, on_generation, on_stop): + """Validate each lifecycle callback using the arguments PyGAD supplies.""" + callbacks = [('on_start', on_start, 1), ('on_fitness', on_fitness, 2), + ('on_parents', on_parents, 2), ('on_crossover', on_crossover, 2), + ('on_mutation', on_mutation, 2), ('on_generation', on_generation, 1), + ('on_stop', on_stop, 2)] + for name, function, num_arguments in callbacks: + setattr(self, name, None if function is None else self._validate_callable_parameter(function, name, num_arguments)) + + def _validate_stop_criteria(self, stop_criteria): + """Parse stopping criteria once, preserving order while removing duplicates.""" + self.supported_stop_words = ['reach', 'saturate', 'time', 'evaluations'] if stop_criteria is None: - # None: Stop after passing through all generations. self.stop_criteria = None - elif type(stop_criteria) is str: - # reach_{target_fitness}: Stop if the target fitness value is reached. - # saturate_{num_generations}: Stop if the fitness value does not change (saturates) for the given number of generations. - criterion = stop_criteria.split("_") - stop_word = criterion[0] - # criterion[1] might be a single or multiple numbers. - number = criterion[1:] - if stop_word in self.supported_stop_words: - pass - else: - self.valid_parameters = False - raise ValueError(f"In the 'stop_criteria' parameter, the supported stop words are '{self.supported_stop_words}' but '{stop_word}' found.") - - if len(criterion) == 2: - # There is only a single number. - number = number[0] - if number.replace(".", "").replace("-", "").isnumeric(): - number = float(number) - else: - self.valid_parameters = False - raise ValueError(f"The value following the stop word in the 'stop_criteria' parameter must be a number but the value ({number}) of type {type(number)} found.") - - self.stop_criteria.append([stop_word, number]) - elif len(criterion) > 2: - number = self.validate_multi_stop_criteria(stop_word, number) - self.stop_criteria.append([stop_word] + number) - else: - self.valid_parameters = False - raise ValueError(f"The format of a single criterion in the 'stop_criteria' parameter is 'word_number' but '{stop_criteria}' found.") - - elif type(stop_criteria) in [list, tuple, numpy.ndarray]: - # Remove duplicate criteria by converting the list to a set then back to a list. - stop_criteria = list(set(stop_criteria)) - for idx, val in enumerate(stop_criteria): - if type(val) is str: - criterion = val.split("_") - stop_word = criterion[0] - number = criterion[1:] - if len(criterion) == 2: - # There is only a single number. - number = number[0] - if stop_word in self.supported_stop_words: - pass - else: - self.valid_parameters = False - raise ValueError(f"In the 'stop_criteria' parameter, the supported stop words are {self.supported_stop_words} but '{stop_word}' found.") - - if number.replace(".", "").replace("-", "").isnumeric(): - number = float(number) - else: - self.valid_parameters = False - raise ValueError(f"The value following the stop word in the 'stop_criteria' parameter must be a number but the value ({number}) of type {type(number)} found.") - - self.stop_criteria.append([stop_word, number]) - elif len(criterion) > 2: - number = self.validate_multi_stop_criteria(stop_word, number) - self.stop_criteria.append([stop_word] + number) - else: - self.valid_parameters = False - raise ValueError(f"The format of a single criterion in the 'stop_criteria' parameter is 'word_number' but {criterion} found.") - else: - self.valid_parameters = False - raise TypeError(f"When the 'stop_criteria' parameter is assigned a tuple/list/numpy.ndarray, then its elements must be strings but the value ({val}) of type {type(val)} found at index {idx}.") + return + if isinstance(stop_criteria, str): + criteria = [stop_criteria] + elif isinstance(stop_criteria, (list, tuple, numpy.ndarray)): + if numpy.asarray(stop_criteria, dtype=object).ndim != 1: + raise ValueError('stop_criteria must be a 1D sequence of strings.') + criteria = list(stop_criteria) else: - self.valid_parameters = False - raise TypeError(f"The expected value of the 'stop_criteria' is a single string or a list/tuple/numpy.ndarray of strings but the value ({stop_criteria}) of type {type(stop_criteria)} found.") - - def _validate_parallel_processing(self, - parallel_processing): - """ - Validate the ``parallel_processing`` parameter and store the - parsed value on ``self.parallel_processing``. Supported forms: - - - ``None`` or ``0``: no parallel processing. - - positive int N: use up to N threads. - - ``["thread", N]`` or ``["process", N]``: pick the executor - family and the worker count (``N`` may be a positive int or - ``None`` for the default). - - Parameters - ---------- - parallel_processing : None, int, list, or tuple - The parallel processing specification. - - Raises - ------ - TypeError - If ``parallel_processing`` is of an unsupported type. - ValueError - If the first element is not ``"process"`` / ``"thread"``, - the worker count is invalid, or the list length is not 2. - """ - # Validate the parallel_processing parameter. + raise TypeError('stop_criteria must be a string, a sequence of strings, or None.') + self.stop_criteria = [] + seen = set() + for criterion in criteria: + if not isinstance(criterion, str): + raise TypeError('Each stop_criteria entry must be a string.') + if criterion not in seen: + self.stop_criteria.append(self._parse_stop_criterion(criterion)) + seen.add(criterion) + + def _parse_stop_criterion(self, criterion): + """Parse a finite threshold or an exact positive integer count.""" + from decimal import Decimal, InvalidOperation + parts = criterion.split('_') + word, numbers = parts[0], parts[1:] + if word not in self.supported_stop_words: + raise ValueError(f'Unknown stop criterion {word!r}. Supported words are {self.supported_stop_words}.') + if not numbers or (word != 'reach' and len(numbers) != 1): + raise ValueError('A stop criterion has the form word_number; only reach accepts multiple thresholds.') + result = [word] + for number in numbers: + try: + value = Decimal(number) + except InvalidOperation: + raise ValueError(f'The threshold in stop_criteria must be numeric, but {number!r} found.') from None + if not value.is_finite(): + raise ValueError('Stop criterion thresholds must be finite.') + if word in ('saturate', 'evaluations'): + if value <= 0 or value != value.to_integral_value(): + raise ValueError(f'{word} requires a positive integer count.') + result.append(int(value)) + else: + if word == 'time' and value < 0: + raise ValueError('time requires a non-negative number of seconds.') + threshold = float(value) + if not numpy.isfinite(threshold): + raise ValueError('Stop criterion thresholds must fit a finite float.') + result.append(threshold) + return result + + def _validate_parallel_processing(self, parallel_processing): + """Normalize the executor mode and an optional integer worker count.""" if parallel_processing is None: self.parallel_processing = None - elif type(parallel_processing) in self.supported_int_types: - if parallel_processing > 0: - self.parallel_processing = ["thread", parallel_processing] - else: - self.valid_parameters = False - raise ValueError(f"When the 'parallel_processing' parameter is assigned an integer, then the integer must be positive but the value ({parallel_processing}) found.") - elif type(parallel_processing) in [list, tuple]: - if len(parallel_processing) == 2: - if type(parallel_processing[0]) is str: - if parallel_processing[0] in ["process", "thread"]: - if (type(parallel_processing[1]) in self.supported_int_types and parallel_processing[1] > 0) or (parallel_processing[1] == 0) or (parallel_processing[1] is None): - if parallel_processing[1] == 0: - # If the number of processes/threads is 0, this means no parallel processing is used. It is equivalent to setting parallel_processing=None. - self.parallel_processing = None - else: - # Whether the second value is None or a positive integer. - self.parallel_processing = parallel_processing - else: - self.valid_parameters = False - raise TypeError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the second element must be an integer but the value ({parallel_processing[1]}) of type {type(parallel_processing[1])} found.") - else: - self.valid_parameters = False - raise ValueError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the value of the first element must be either 'process' or 'thread' but the value ({parallel_processing[0]}) found.") - else: - self.valid_parameters = False - raise TypeError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then the first element must be of type 'str' but the value ({parallel_processing[0]}) of type {type(parallel_processing[0])} found.") - else: - self.valid_parameters = False - raise ValueError(f"When a list or tuple is assigned to the 'parallel_processing' parameter, then it must have 2 elements but ({len(parallel_processing)}) found.") + return + if isinstance(parallel_processing, (list, tuple)): + if len(parallel_processing) != 2: + raise ValueError('parallel_processing must contain a mode and a worker count.') + mode, workers = parallel_processing + if not isinstance(mode, str) or mode not in ('thread', 'process'): + raise ValueError("The parallel_processing mode must be 'thread' or 'process'.") + workers = None if workers is None else self._validate_integer_parameter(workers, 'parallel_processing worker count', minimum=0) else: - self.valid_parameters = False - raise ValueError(f"Unexpected value ({parallel_processing}) of type ({type(parallel_processing)}) assigned to the 'parallel_processing' parameter. The accepted values for this parameter are:\n1) None: (Default) It means no parallel processing is used.\n2) A positive integer referring to the number of threads to be used (i.e. threads, not processes, are used.\n3) list/tuple: If a list or a tuple of exactly 2 elements is assigned, then:\n\t*1) The first element can be either 'process' or 'thread' to specify whether processes or threads are used, respectively.\n\t*2) The second element can be:\n\t\t**1) A positive integer to select the maximum number of processes or threads to be used.\n\t\t**2) 0 to indicate that parallel processing is not used. This is identical to setting 'parallel_processing=None'.\n\t\t**3) None to use the default value as calculated by the concurrent.futures module.") + mode = 'thread' + workers = self._validate_integer_parameter(parallel_processing, 'parallel_processing', minimum=0) + self.parallel_processing = None if workers == 0 else [mode, workers] def _validate_footer(self, num_generations, @@ -1864,9 +574,8 @@ def _validate_footer(self, """ Validate the last group of parameters and store them on the GA instance: ``num_generations``, ``save_best_solutions``, - and ``save_solutions``. Also re-checks the - ``mutation_percent_genes`` / ``mutation_num_genes`` pair now - that ``num_genes`` has been resolved. + and ``save_solutions``. Store the already validated mutation + controls and initialize the lifecycle state before population creation. Parameters ---------- @@ -1897,33 +606,14 @@ def _validate_footer(self, If ``num_generations`` is negative. """ - # Validate num_generations - if type(num_generations) in self.supported_int_types: - if num_generations >= 0: - self.num_generations = num_generations - else: - raise ValueError(f"The value assigned to the 'num_generations' parameter must be a non-negative integer >= 0. But the value {num_generations} found.") - else: - self.valid_parameters = False - raise ValueError(f"Unexpected value ({num_generations}) of type ({type(num_generations)}) assigned to the 'num_generations' parameter. It must be assigned a non-negative integer.") - - # Validate save_best_solutions - if type(save_best_solutions) is bool: - if save_best_solutions == True: - if not self.suppress_warnings: - warnings.warn("Use the 'save_best_solutions' parameter with caution as it may cause memory overflow when either the number of generations or number of genes is large.") - else: - self.valid_parameters = False - raise TypeError(f"The value passed to the 'save_best_solutions' parameter must be of type bool but {type(save_best_solutions)} found.") - - # Validate save_solutions - if type(save_solutions) is bool: - if save_solutions == True: - if not self.suppress_warnings: - warnings.warn("Use the 'save_solutions' parameter with caution as it may cause memory overflow when either the number of generations, number of genes, or number of solutions in population is large.") - else: - self.valid_parameters = False - raise TypeError(f"The value passed to the 'save_solutions' parameter must be of type bool but {type(save_solutions)} found.") + self.num_generations = self._validate_integer_parameter(num_generations, 'num_generations', minimum=0) + self.save_best_solutions = self._validate_boolean_parameter(save_best_solutions, 'save_best_solutions') + self.save_solutions = self._validate_boolean_parameter(save_solutions, 'save_solutions') + if not self.suppress_warnings: + if save_best_solutions: + warnings.warn('Use save_best_solutions with caution as saving large histories can cause memory overflow.') + if save_solutions: + warnings.warn('Use save_solutions with caution as saving large histories can cause memory overflow.') # Set the `run_completed` property to False. It is set to `True` only after the `run()` method is complete. self.run_completed = False @@ -1939,10 +629,6 @@ def _validate_footer(self, # "time_<seconds>" stop criterion. None outside of run(). self.run_start_time = None - # At this point, all necessary parameters validation is done successfully, and we are sure that the parameters are valid. - # Set to True when all the parameters passed in the GA class constructor are valid. - self.valid_parameters = True - # Parameters of the genetic algorithm. self.parent_selection_type = parent_selection_type @@ -1953,6 +639,11 @@ def _validate_footer(self, # Even though this parameter is declared in the class header, it is assigned to the object here to access it after saving the object. # A list holding the fitness value of the best solution for each generation. self.best_solutions_fitness = [] + # Histories retain the final and starting snapshots of repeated runs. + # Their positions are therefore different from generation numbers. + self.best_solutions_generations = [] + self.solutions_generations = [] + self._saved_population_sizes = [] # The generation number at which the best fitness value is reached. It is only assigned the generation number after the `run()` method completes. Otherwise, its value is -1. self.best_solution_generation = -1 @@ -2057,50 +748,21 @@ def validate_parameters(self, sample_size, allow_duplicate_genes) + # Establish dimensions first, especially when the supplied population + # overrides sol_per_pop and num_genes. + initial_population = self._validate_initial_population_shape( + initial_population, sol_per_pop, num_genes) self._validate_gene_space(gene_space) - - self._validate_init_range(init_range_low, - init_range_high, - num_genes, - initial_population) - - self._validate_gene_type(gene_type, - num_genes, - initial_population) - - # Call the unpack_gene_space() method in the pygad.helper.unique.Unique class. - self.gene_space_unpacked = self.unpack_gene_space(range_min=self.init_range_low, - range_max=self.init_range_high) - - self._build_initial_population(initial_population, - sol_per_pop, - num_genes, - gene_space, - allow_duplicate_genes, - gene_constraint) - - # In case the 'gene_space' parameter is nested, then make sure the number of its elements equals to the number of genes. - if self.gene_space_nested: - if len(gene_space) != self.num_genes: - self.valid_parameters = False - raise ValueError(f"When the parameter 'gene_space' is nested, then its length must be equal to the value passed to the 'num_genes' parameter. Instead, length of gene_space ({len(gene_space)}) != num_genes ({self.num_genes})") - - self._validate_mutation_range(random_mutation_min_val, - random_mutation_max_val) - + self._validate_init_range(init_range_low, init_range_high, self.num_genes) + self._validate_gene_type(gene_type, self.num_genes) self._validate_gene_constraint(gene_constraint) - - # Validating the number of parents to be selected for mating (num_parents_mating) - if num_parents_mating <= 0: + if self.gene_space_nested and len(gene_space) != self.num_genes: self.valid_parameters = False - raise ValueError(f"The number of parents mating (num_parents_mating) parameter must be > 0 but ({num_parents_mating}) found. \nThe following parameters must be > 0: \n1) Population size (i.e. number of solutions per population) (sol_per_pop).\n2) Number of selected parents in the mating pool (num_parents_mating).\n") + raise ValueError(f"When gene_space is nested, its length ({len(gene_space)}) must equal the number of genes ({self.num_genes}).") - # Validating the number of parents to be selected for mating: num_parents_mating - if num_parents_mating > self.sol_per_pop: - self.valid_parameters = False - raise ValueError(f"The number of parents to select for mating ({num_parents_mating}) cannot be greater than the number of solutions in the population ({self.sol_per_pop}) (i.e., num_parents_mating must always be <= sol_per_pop).\n") - - self.num_parents_mating = num_parents_mating + self._validate_mutation_range(random_mutation_min_val, random_mutation_max_val) + self.num_parents_mating = self._validate_integer_parameter( + num_parents_mating, 'num_parents_mating', 1, self.sol_per_pop) self._validate_crossover(crossover_type, crossover_probability, @@ -2140,42 +802,15 @@ def validate_parameters(self, save_best_solutions, save_solutions) - def validate_multi_stop_criteria(self, stop_word, number): - """ - Validate one ``(keyword, value)`` element of a - multi-objective stop criterion. Only the ``"reach"`` keyword - accepts multiple numeric values (one per objective). - - Parameters - ---------- - stop_word : str - The criterion keyword. Must be ``"reach"`` to be valid for - the multi-objective case. - number : str - The numeric value (as it appeared in the criterion - string). The method parses it into a float. - - Returns - ------- - number : float - The parsed numeric value. - - Raises - ------ - ValueError - If ``stop_word`` is not ``"reach"``, or ``number`` is not - a numeric string. - """ - if stop_word == 'reach': - pass - else: - self.valid_parameters = False - raise ValueError(f"Passing multiple numbers following the keyword in the 'stop_criteria' parameter is expected only with the 'reach' keyword but the keyword ({stop_word}) found.") + self.numpy_random_generator = numpy.random.RandomState(self.random_seed) + self.python_random_generator = random.Random(self.random_seed) + self.gene_space_unpacked = self.unpack_gene_space( + range_min=self.init_range_low, range_max=self.init_range_high) + self._build_initial_population(initial_population) + self.valid_parameters = True - for idx, num in enumerate(number): - if num.replace(".", "").replace("-", "").isnumeric(): - number[idx] = float(num) - else: - self.valid_parameters = False - raise ValueError(f"The value(s) following the stop word in the 'stop_criteria' parameter must be numeric but the value ({num}) of type {type(num)} found.") - return number + def validate_multi_stop_criteria(self, stop_word, number): + """Compatibility helper for parsing multiple reach thresholds.""" + if stop_word != 'reach': + raise ValueError('Only reach accepts multiple stop thresholds.') + return self._parse_stop_criterion('_'.join([stop_word] + list(number)))[1:] diff --git a/pygad/visualize/__init__.py b/pygad/visualize/__init__.py index aa4c7690..be5a79e0 100644 --- a/pygad/visualize/__init__.py +++ b/pygad/visualize/__init__.py @@ -1,3 +1,3 @@ from pygad.visualize import plot -__version__ = "1.2.0" \ No newline at end of file +__version__ = "1.2.2" diff --git a/pygad/visualize/lifecycle.py b/pygad/visualize/lifecycle.py new file mode 100644 index 00000000..9b3051d7 --- /dev/null +++ b/pygad/visualize/lifecycle.py @@ -0,0 +1,418 @@ +""" +Internal helpers for describing and drawing a configured PyGAD lifecycle. + +The description reads GA settings and already available fitness only. +It never runs user code. Matplotlib is imported only when drawing. +""" + +import reprlib +import textwrap + +import numpy + + +def _lifecycle_handler_name(handler): + """Return a readable function, method, or callable-instance name.""" + return getattr(handler, "__name__", type(handler).__name__) + "()" + + +def _lifecycle_parameter_text(value): + """Abbreviate large settings so gene lists cannot fill the chart.""" + if callable(value): + return getattr(value, "__name__", type(value).__name__) + if isinstance(value, numpy.ndarray): + # Do not change NumPy's global print options to format a chart. + array_text = numpy.array2string(value, threshold=6, edgeitems=2, + max_line_width=60).replace("\n", " ") + return array_text if len(array_text) <= 100 else array_text[:97] + "..." + formatter = reprlib.Repr() + formatter.maxlist = 4 + formatter.maxtuple = 4 + formatter.maxdict = 3 + formatter.maxstring = 65 + formatter.maxother = 65 + return formatter.repr(value) + + +def _describe_lifecycle(ga_instance, show_parameters=True): + """ + Build stages, connections, and configuration for one GA instance. + + Stage identifiers describe execution points rather than operator + names. Both fitness evaluations therefore remain distinct, even + though they call the same fitness function. Connections include + the zero-generation exit and the return to the loop's entry. + + Returns + ------- + lifecycle : dict + ``stages`` is an ordered list of stage dictionaries. + ``connections`` lists source, target, label, and route. + ``configuration`` lists labels and abbreviated setting values. + All helpers in this module are internal. + """ + stages = [] + + def add_stage(identifier, title, kind="operation", handler=None, parameters=None): + """Append a stage, keeping handler names in the compact view.""" + details = [] + if handler is not None: + details.append(_lifecycle_handler_name(handler)) + if show_parameters and parameters: + details.extend(parameters) + stages.append({"id": identifier, "title": title, + "kind": kind, "details": details}) + + def add_callback(name): + """Only show callbacks that the GA instance actually uses.""" + callback = getattr(ga_instance, name) + if callback is not None: + add_stage(name, name + "()", "callback", handler=callback) + + population_shape = f"({ga_instance.sol_per_pop}, {ga_instance.num_genes})" + offspring_shape = f"({ga_instance.num_offspring}, {ga_instance.num_genes})" + fitness_parameters = [] + if ga_instance.last_generation_fitness is not None: + fitness_shape = numpy.shape(ga_instance.last_generation_fitness) + fitness_parameters.append(f"Population fitness: {fitness_shape}") + if ga_instance.fitness_batch_size is not None and ga_instance.fitness_batch_size > 1: + fitness_parameters.append(f"Batch size: {ga_instance.fitness_batch_size}") + fitness_parameters.append("Reuse available fitness where applicable") + + add_stage("population", "Population Ready", "population", + parameters=[f"Shape: {population_shape}", "Prepared before run()"]) + add_callback("on_start") + add_stage("initial_fitness", "Evaluate Initial Fitness", handler=ga_instance.fitness_func, + parameters=fitness_parameters) + if ga_instance.parent_selection_type in ("nsga3", "tournament_nsga3"): + add_stage("reference_points", "Prepare NSGA-III Reference Points", + parameters=[f"Divisions: {ga_instance.nsga3_num_divisions}", + "Grow population and evaluate added solutions if needed"]) + + add_stage("generation_check", "Generations Remaining?", "decision", + parameters=[f"{ga_instance.num_generations} generations per run()"]) + add_callback("on_fitness") + + selection_parameters = [f"Parents: ({ga_instance.num_parents_mating}, {ga_instance.num_genes})"] + if ga_instance.parent_selection_type in ("tournament", "tournament_nsga2", "tournament_nsga3"): + selection_parameters.append(f"Tournament size: {ga_instance.K_tournament}") + add_stage("selection", "Select Parents", handler=ga_instance.select_parents, + parameters=selection_parameters) + add_callback("on_parents") + + crossover_parameters = [f"Offspring: {offspring_shape}"] + if ga_instance.crossover_type is not None: + if not callable(ga_instance.crossover_type): + if ga_instance.crossover_probability is not None: + crossover_parameters.append(f"Probability: {ga_instance.crossover_probability}") + if ga_instance.crossover_type == "sbx": + crossover_parameters.append(f"Distribution index: {ga_instance.sbx_crossover_eta}") + add_stage("crossover", "Crossover", handler=ga_instance.crossover, + parameters=crossover_parameters) + # These callbacks run even when the corresponding operator is None. + add_callback("on_crossover") + + mutation_parameters = [f"Offspring: {offspring_shape}"] + if ga_instance.mutation_type is not None: + if ga_instance.mutation_type in ("random", "adaptive", "polynomial"): + if ga_instance.mutation_type == "polynomial": + probability = ga_instance.mutation_probability + if probability is None: + probability = 1.0 / ga_instance.num_genes + mutation_parameters.extend([f"Probability per gene: {probability}", + f"Distribution index: {ga_instance.polynomial_mutation_eta}"]) + elif ga_instance.mutation_probability is not None: + mutation_parameters.append("Probability per gene: " + + _lifecycle_parameter_text(ga_instance.mutation_probability)) + else: + mutation_parameters.append("Genes to mutate: " + + _lifecycle_parameter_text(ga_instance.mutation_num_genes)) + if ga_instance.mutation_type in ("random", "adaptive"): + if ga_instance.gene_space is None: + mutation_parameters.append("Random range: " + _lifecycle_parameter_text( + (ga_instance.random_mutation_min_val, ga_instance.random_mutation_max_val))) + else: + mutation_parameters.append("Use configured gene space") + mutation_parameters.append("Replace gene values" if ga_instance.mutation_by_replacement + else "Add random values when no gene space is set") + if ga_instance.mutation_type == "adaptive": + mutation_parameters.append("Evaluate offspring fitness to choose mutation amount") + mutation_parameters.append("Control values: low-quality, high-quality offspring") + add_stage("mutation", "Mutation", handler=ga_instance.mutation, + parameters=mutation_parameters) + add_callback("on_mutation") + + # Elitism overrides keep_parents; display the effective policy only. + if ga_instance.keep_elitism > 0: + retention_text = f"Keep {ga_instance.keep_elitism} elite solution(s)" + elif ga_instance.keep_parents == -1: + retention_text = f"Keep all {ga_instance.num_parents_mating} selected parents" + elif ga_instance.keep_parents > 0: + retention_text = f"Keep {ga_instance.keep_parents} parent(s)" + else: + retention_text = "Keep no parents or elite solutions" + add_stage("update_population", "Update Population", + parameters=[retention_text, f"Add {ga_instance.num_offspring} offspring", + f"Population: {population_shape}"]) + add_stage("generation_fitness", "Evaluate Updated Population", handler=ga_instance.fitness_func, + parameters=fitness_parameters) + add_callback("on_generation") + + stopping_parameters = [] + if ga_instance.on_generation is not None: + stopping_parameters.append('on_generation() returns "stop"') + if ga_instance.stop_criteria is not None: + for criterion in ga_instance.stop_criteria: + stopping_parameters.append("_".join(str(value) for value in criterion)) + if stopping_parameters: + add_stage("early_stop", "Stop Early?", "decision", + parameters=["Any condition below:"] + stopping_parameters) + last_generation_stage = stages[-1]["id"] + + add_stage("finalize", "Finalize Results", + parameters=["Refresh final parents and elitism", "Record best solution fitness"]) + add_callback("on_stop") + add_stage("complete", "Run Complete", "end") + + connections = [] + for source, target in zip(stages, stages[1:]): + # The body returns to the generation check, rather than falling + # through to finalization after the first generation. + if source["id"] == last_generation_stage: + continue + label = "Yes" if source["id"] == "generation_check" else "" + connections.append({"source": source["id"], "target": target["id"], + "label": label, "route": "forward"}) + connections.append({"source": "generation_check", "target": "finalize", + "label": "No", "route": "finish"}) + connections.append({"source": last_generation_stage, "target": "generation_check", + "label": "No" if stopping_parameters else "Next Generation", + "route": "repeat"}) + if stopping_parameters: + connections.append({"source": "early_stop", "target": "finalize", + "label": "Yes", "route": "finish"}) + + configuration = [] + if show_parameters: + if ga_instance.gene_type_single: + gene_types = [ga_instance.gene_type] + else: + gene_types = ga_instance.gene_type + gene_type_names = [] + for gene_type, precision in gene_types: + name = getattr(gene_type, "__name__", str(gene_type)) + if precision is not None: + name += f" ({precision} decimal places)" + gene_type_names.append(name) + gene_type_text = (gene_type_names[0] if ga_instance.gene_type_single + else "Per gene: " + _lifecycle_parameter_text(gene_type_names)) + configuration.extend([("Population", population_shape), + ("Generations Per Run", str(ga_instance.num_generations)), + ("Gene Type", gene_type_text)]) + if ga_instance.last_generation_fitness is not None: + first_fitness = ga_instance.last_generation_fitness[0] + objectives = len(first_fitness) if isinstance(first_fitness, (list, tuple, numpy.ndarray)) else 1 + configuration.append(("Objectives", str(objectives))) + else: + configuration.append(("Objectives", "Known after fitness evaluation")) + if ga_instance.gene_space is not None: + configuration.append(("Gene Space", _lifecycle_parameter_text(ga_instance.gene_space))) + else: + configuration.append(("Initial Population Range", _lifecycle_parameter_text( + (ga_instance.init_range_low, ga_instance.init_range_high)))) + if ga_instance.gene_constraint is not None: + constraint_count = sum(constraint is not None for constraint in ga_instance.gene_constraint) + configuration.append(("Gene Constraints", f"{constraint_count} constrained gene(s)")) + configuration.append(("Allow Duplicate Genes", str(ga_instance.allow_duplicate_genes))) + if ga_instance.parallel_processing is not None: + configuration.append(("Parallel Fitness Evaluation", _lifecycle_parameter_text(ga_instance.parallel_processing))) + if ga_instance.random_seed is not None: + configuration.append(("Random Seed", str(ga_instance.random_seed))) + configuration.extend([("Save Solutions", str(ga_instance.save_solutions)), + ("Save Best Solutions", str(ga_instance.save_best_solutions))]) + + return {"stages": stages, "connections": connections, + "configuration": configuration} + + +def _draw_lifecycle(lifecycle, matplt, title, font_size): + """ + Render a lifecycle description as a vertically arranged flowchart. + + Card heights follow wrapped text lengths. The repeat connection + runs on the left and exit connections run on the right, keeping + arrows outside the cards. Coordinate units correspond to inches + at the default font size; scaling the figure keeps text readable. + """ + from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon + from matplotlib.path import Path + from matplotlib.font_manager import FontProperties + from matplotlib.textpath import TextToPath + + text_measurement = TextToPath() + + def wrap_text(text, width_inches, text_font_size, weight="normal"): + """Wrap using font measurements, including long handler names.""" + font_properties = FontProperties(size=text_font_size, weight=weight) + # Coordinates scale with font_size, so compare widths at the + # corresponding figure scale. Math characters remain literal, + # just as they do in the rendered labels (parse_math=False). + available_width = width_inches * 72 * font_size / 11 + approximate_columns = max(1, int(available_width / (text_font_size * 0.55))) + wrapped_lines = [] + for line in textwrap.wrap(text, width=approximate_columns): + while line: + fitted_length = len(line) + while fitted_length > 1: + measured_width, _, _ = text_measurement.get_text_width_height_descent( + line[:fitted_length], font_properties, ismath=False) + if measured_width <= available_width: + break + fitted_length -= 1 + if fitted_length < len(line): + last_space = line.rfind(" ", 0, fitted_length + 1) + if last_space > 0: + fitted_length = last_space + wrapped_lines.append(line[:fitted_length].strip()) + line = line[fitted_length:].lstrip() + return wrapped_lines + + colors = {"operation": ("#eef4ff", "#4773ba"), + "population": ("#e8eef9", "#4773ba"), + "callback": ("#e6f5ef", "#26866c"), + "decision": ("#fff4da", "#b38325"), + "end": ("#253e65", "#253e65")} + text_color = "#25354b" + arrow_color = "#718198" + stage_center = 3.4 + stage_width = 4.5 + stage_gap = 0.34 + stage_positions = {} + wrapped_stages = [] + figure_width = 10.4 if lifecycle["configuration"] else 7.0 + title_lines = wrap_text(title, figure_width - 1.4, font_size * 1.4, "bold") or [""] + current_top = 0.82 + 0.27 * len(title_lines) + + for stage in lifecycle["stages"]: + # Put decision questions and their conditions inside the middle + # half of the diamond, where the sloping sides leave room for text. + text_width = stage_width / 2 - 0.2 if stage["kind"] == "decision" else stage_width - 0.4 + title_text = wrap_text(stage["title"], text_width, font_size, "bold") + detail_lines = [] + for detail in stage["details"]: + detail_lines.extend(wrap_text(detail, text_width, font_size * 0.88)) + stage_height = 0.30 + 0.20 * len(title_text) + 0.17 * len(detail_lines) + if stage["kind"] == "decision": + stage_height = max(0.95, 2 * stage_height) + stage_positions[stage["id"]] = {"top": current_top, "bottom": current_top + stage_height, + "center": current_top + stage_height / 2, + "height": stage_height} + wrapped_stages.append((stage, title_text, detail_lines)) + current_top += stage_height + stage_gap + + configuration_lines = [] + for label, value in lifecycle["configuration"]: + configuration_lines.append((wrap_text(label, 2.65, font_size * 0.86, "bold"), + wrap_text(value, 2.65, font_size * 0.84))) + configuration_height = 0.65 + sum(0.20 * len(label_lines) + 0.17 * len(value_lines) + 0.17 + for label_lines, value_lines in configuration_lines) + figure_height = max(current_top + 0.55, configuration_height + 2.0) + figure_scale = font_size / 11 + fig, axes = matplt.subplots(figsize=(figure_width * figure_scale, figure_height * figure_scale)) + fig.patch.set_facecolor("#ffffff") + fig.subplots_adjust(left=0, right=1, bottom=0, top=1) + axes.set_xlim(0, figure_width) + axes.set_ylim(figure_height, 0) + axes.axis("off") + axes.text(0.7, 0.28, "\n".join(title_lines), fontsize=font_size * 1.4, + weight="bold", color=text_color, va="top", parse_math=False) + axes.text(0.7, current_top + 0.12, "Configured Lifecycle | Operators / Callbacks / Decisions", + fontsize=font_size * 0.82, color=arrow_color, va="top", parse_math=False) + + for stage, title_text, detail_lines in wrapped_stages: + position = stage_positions[stage["id"]] + fill_color, border_color = colors[stage["kind"]] + if stage["kind"] == "decision": + card = Polygon([(stage_center, position["top"]), + (stage_center + stage_width / 2, position["center"]), + (stage_center, position["bottom"]), + (stage_center - stage_width / 2, position["center"])], + facecolor=fill_color, edgecolor=border_color, linewidth=1.2) + else: + card = FancyBboxPatch((stage_center - stage_width / 2, position["top"]), + stage_width, position["height"], + boxstyle="round,pad=0,rounding_size=0.10", + facecolor=fill_color, edgecolor=border_color, linewidth=1.2) + axes.add_patch(card) + text_top = position["center"] - (0.20 * len(title_text) + 0.17 * len(detail_lines)) / 2 + stage_text_color = "#ffffff" if stage["kind"] == "end" else text_color + axes.text(stage_center, text_top, "\n".join(title_text), ha="center", va="top", + fontsize=font_size, weight="bold", color=stage_text_color, + linespacing=1.2, parse_math=False) + if detail_lines: + axes.text(stage_center, text_top + 0.20 * len(title_text) + 0.03, + "\n".join(detail_lines), ha="center", va="top", + fontsize=font_size * 0.88, color=stage_text_color, + linespacing=1.2, parse_math=False) + + for connection in lifecycle["connections"]: + source = stage_positions[connection["source"]] + target = stage_positions[connection["target"]] + if connection["route"] == "forward": + points = [(stage_center, source["bottom"]), (stage_center, target["top"])] + label_position = (stage_center + 0.15, (source["bottom"] + target["top"]) / 2) + elif connection["route"] == "finish": + exit_column = 6.25 + points = [(stage_center + stage_width / 2, source["center"]), + (exit_column, source["center"]), (exit_column, target["center"]), + (stage_center + stage_width / 2, target["center"])] + label_position = (5.83, source["center"] - 0.13) + else: + repeat_column = 0.52 + points = [(stage_center - stage_width / 2, source["center"]), + (repeat_column, source["center"]), (repeat_column, target["center"]), + (stage_center - stage_width / 2, target["center"])] + label_position = (repeat_column - 0.20, (source["center"] + target["center"]) / 2) + arrow_path = Path(points, [Path.MOVETO] + [Path.LINETO] * (len(points) - 1)) + axes.add_patch(FancyArrowPatch(path=arrow_path, arrowstyle="-|>", + mutation_scale=11 * figure_scale, + color=arrow_color, linewidth=1.2, zorder=0)) + if connection["label"]: + axes.text(*label_position, connection["label"], fontsize=font_size * 0.82, + color=arrow_color, va="center", + rotation=90 if connection["route"] == "repeat" else 0, + parse_math=False) + + if configuration_lines: + panel_left = 6.85 + panel_top = stage_positions["population"]["top"] + axes.add_patch(FancyBboxPatch((panel_left, panel_top), 3.15, configuration_height, + boxstyle="round,pad=0,rounding_size=0.10", + facecolor="#f7f9fc", edgecolor="#dce3ed")) + axes.text(panel_left + 0.22, panel_top + 0.22, "Configuration", weight="bold", + fontsize=font_size * 1.05, color=text_color, va="top", parse_math=False) + configuration_top = panel_top + 0.65 + for label_lines, value_lines in configuration_lines: + axes.text(panel_left + 0.22, configuration_top, "\n".join(label_lines), weight="bold", + fontsize=font_size * 0.86, color=text_color, va="top", parse_math=False) + axes.text(panel_left + 0.22, configuration_top + 0.20 * len(label_lines), "\n".join(value_lines), + fontsize=font_size * 0.84, color=text_color, va="top", + linespacing=1.2, parse_math=False) + configuration_top += 0.20 * len(label_lines) + 0.17 * len(value_lines) + 0.17 + + # Axes with axis("off") still occupy their full canvas in a tight export. + # Fit the canvas to the actual labels, cards, and connectors instead. + from matplotlib.transforms import Bbox + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + bounds = Bbox.union([artist.get_window_extent(renderer) + for artist in list(axes.texts) + list(axes.patches)]) + data_bounds = bounds.transformed(axes.transData.inverted()) + margin = 0.08 + left, right = data_bounds.xmin - margin, data_bounds.xmax + margin + top, bottom = data_bounds.ymin - margin, data_bounds.ymax + margin + axes.set_xlim(left, right) + axes.set_ylim(bottom, top) + fig.set_size_inches((right - left) * figure_scale, + (bottom - top) * figure_scale) + return fig diff --git a/pygad/visualize/plot.py b/pygad/visualize/plot.py index e0a756e0..25b6f4f8 100644 --- a/pygad/visualize/plot.py +++ b/pygad/visualize/plot.py @@ -26,6 +26,88 @@ class Plot: def __init__(): pass + def plot_lifecycle(self, + title="PyGAD - Lifecycle", + font_size=11, + show_parameters=True, + save_dir=None, + show=True, + transparent=False): + """ + Draw the configured lifecycle, including active operators, + callbacks, the generation loop, and stopping conditions. + + Can be called before or after ``run()``. Drawing the chart + does not evaluate fitness, call callbacks, or change GA state. + Objective counts are shown only when fitness is already known. + + Parameters + ---------- + title : str + Figure title. Use a problem name to identify the chart. + font_size : numeric + Positive font size. The figure scales with the font size. + show_parameters : bool + If True, include stage parameters, decision conditions, + and a configuration panel. If False, show a compact chart + with handler names. Disabled operators and unset callbacks + are omitted in either view. + save_dir : str or None + If set, save the figure to this path. The extension + determines the format, for example SVG, PNG, or PDF. + show : bool + If True, display the figure. Set to False when saving + charts in scripts, notebooks, or reports without showing + a window. The figure is returned in either case. + transparent : bool + If True, use a transparent figure background when displaying + or exporting the chart. Stage cards retain their fill colors. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure that was created. + + Raises + ------ + TypeError + If a parameter has an unsupported type. + ValueError + If ``font_size`` is not finite and positive. + ImportError + If the optional matplotlib dependency is not installed. + """ + if not isinstance(title, str): + raise TypeError("The title parameter must be a string.") + if isinstance(font_size, bool) or not isinstance(font_size, (int, float, numpy.integer, numpy.floating)): + raise TypeError("The font_size parameter must be a positive number.") + if not numpy.isfinite(font_size) or font_size <= 0: + raise ValueError("The font_size parameter must be finite and greater than 0.") + if not all(isinstance(value, bool) for value in (show_parameters, show, transparent)): + raise TypeError("The show_parameters, show, and transparent parameters must be bool values.") + + # Keep chart construction separate from rendering so its flow + # can be checked without importing matplotlib or running a GA. + from pygad.visualize.lifecycle import _describe_lifecycle, _draw_lifecycle + lifecycle = _describe_lifecycle(self, show_parameters=show_parameters) + try: + matplt = get_matplotlib() + except ImportError as exc: + raise ImportError("plot_lifecycle requires matplotlib. Install it with: " + "pip install pygad[visualize] (or pip install matplotlib).") from exc + + fig = _draw_lifecycle(lifecycle, matplt, title, font_size) + if transparent: + fig.patch.set_alpha(0) + for axes in fig.axes: + axes.patch.set_alpha(0) + if save_dir is not None: + fig.savefig(fname=save_dir, bbox_inches="tight", pad_inches=0.02, + transparent=transparent) + if show: + matplt.show() + return fig + def plot_fitness(self, title="PyGAD - Generation vs. Fitness", xlabel="Generation", @@ -87,6 +169,7 @@ def plot_fitness(self, matplt = get_matplotlib() + generations = self._history_generation_numbers(self.best_solutions_generations) fig = matplt.figure() if type(self.best_solutions_fitness[0]) in [list, tuple, numpy.ndarray] and len(self.best_solutions_fitness[0]) > 1: # Multi-objective optimization problem. @@ -114,18 +197,18 @@ def plot_fitness(self, # Return the fitness values for the current objective function across all generations. fitness = numpy.array(self.best_solutions_fitness)[:, objective_idx] if plot_type == "plot": - matplt.plot(fitness, + matplt.plot(generations, fitness, linewidth=current_linewidth, color=current_color, label=current_label) elif plot_type == "scatter": - matplt.scatter(range(len(fitness)), + matplt.scatter(generations, fitness, linewidth=current_linewidth, color=current_color, label=current_label) elif plot_type == "bar": - matplt.bar(range(len(fitness)), + matplt.bar(generations, fitness, linewidth=current_linewidth, color=current_color, @@ -133,16 +216,16 @@ def plot_fitness(self, else: # Single-objective optimization problem. if plot_type == "plot": - matplt.plot(self.best_solutions_fitness, + matplt.plot(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) elif plot_type == "scatter": - matplt.scatter(range(len(self.best_solutions_fitness)), + matplt.scatter(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) elif plot_type == "bar": - matplt.bar(range(len(self.best_solutions_fitness)), + matplt.bar(generations, self.best_solutions_fitness, linewidth=linewidth, color=color) @@ -223,30 +306,29 @@ def plot_new_solution_rate(self, unique_solutions = set() num_unique_solutions_per_generation = [] - for generation_idx in range(self.generations_completed): - + populations = self._per_generation_solutions() + generations = self._population_history_generations(len(populations)) + # Repeated runs retain two snapshots at their boundary. Use the + # latest snapshot once per generation, excluding the final population + # as in a single run's new-solution-rate plot. + population_by_generation = dict(zip(generations, populations)) + generations = sorted(generation for generation in population_by_generation + if generation < self.generations_completed) + for generation in generations: len_before = len(unique_solutions) - - start = generation_idx * self.sol_per_pop - end = start + self.sol_per_pop - - for sol in self.solutions[start:end]: - unique_solutions.add(tuple(sol)) - - len_after = len(unique_solutions) - - generation_num_unique_solutions = len_after - len_before - num_unique_solutions_per_generation.append(generation_num_unique_solutions) + unique_solutions.update(tuple(solution) + for solution in population_by_generation[generation]) + num_unique_solutions_per_generation.append(len(unique_solutions) - len_before) matplt = get_matplotlib() fig = matplt.figure() if plot_type == "plot": - matplt.plot(num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.plot(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) elif plot_type == "scatter": - matplt.scatter(range(self.generations_completed), num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.scatter(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) elif plot_type == "bar": - matplt.bar(range(self.generations_completed), num_unique_solutions_per_generation, linewidth=linewidth, color=color) + matplt.bar(generations, num_unique_solutions_per_generation, linewidth=linewidth, color=color) matplt.title(title, fontsize=font_size) matplt.xlabel(xlabel, fontsize=font_size) matplt.ylabel(ylabel, fontsize=font_size) @@ -350,6 +432,8 @@ def plot_genes(self, self.logger.error("The solutions parameter must be a string but {solutions_type} found.".format(solutions_type=type(solutions))) raise RuntimeError("The solutions parameter must be a string but {solutions_type} found.".format(solutions_type=type(solutions))) + generations = (self._history_generation_numbers(self.best_solutions_generations) + if solutions == 'best' else range(solutions_to_plot.shape[0])) if graph_type == "plot": # num_rows will be always be >= 1 # num_cols can only be 0 if num_genes=1 @@ -361,11 +445,11 @@ def plot_genes(self, # There is only a single gene fig, ax = matplt.subplots(num_rows, figsize=figsize) if plot_type == "plot": - ax.plot(solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.plot(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - ax.scatter(range(self.generations_completed + 1), solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.scatter(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - ax.bar(range(self.generations_completed + 1), solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + ax.bar(generations, solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) ax.set_xlabel(0, fontsize=font_size) else: fig, axs = matplt.subplots(num_rows, num_cols) @@ -373,18 +457,19 @@ def plot_genes(self, if num_cols == 1 and num_rows == 1: fig.set_figwidth(5 * num_cols) fig.set_figheight(4) - axs.plot(solutions_to_plot[:, 0], linewidth=linewidth, color=fill_color) + getattr(axs, plot_type)(generations, solutions_to_plot[:, 0], + linewidth=linewidth, color=fill_color) axs.set_xlabel("Gene " + str(0), fontsize=font_size) elif num_cols == 1 or num_rows == 1: fig.set_figwidth(5 * num_cols) fig.set_figheight(4) for gene_idx in range(len(axs)): if plot_type == "plot": - axs[gene_idx].plot(solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].plot(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - axs[gene_idx].scatter(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].scatter(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - axs[gene_idx].bar(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[gene_idx].bar(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) axs[gene_idx].set_xlabel("Gene " + str(gene_idx), fontsize=font_size) else: gene_idx = 0 @@ -396,11 +481,11 @@ def plot_genes(self, # axs[row_idx, col_idx].remove() break if plot_type == "plot": - axs[row_idx, col_idx].plot(solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].plot(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "scatter": - axs[row_idx, col_idx].scatter(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].scatter(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) elif plot_type == "bar": - axs[row_idx, col_idx].bar(range(solutions_to_plot.shape[0]), solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) + axs[row_idx, col_idx].bar(generations, solutions_to_plot[:, gene_idx], linewidth=linewidth, color=fill_color) axs[row_idx, col_idx].set_xlabel("Gene " + str(gene_idx), fontsize=font_size) gene_idx += 1 @@ -667,33 +752,34 @@ def _require_save_solutions(self, method_name): self.logger.error(f"The {method_name} method requires save_solutions=True in the pygad.GA constructor.") raise RuntimeError(f"The {method_name} method requires save_solutions=True in the pygad.GA constructor.") + def _population_history_slices(self, count): + """Return saved population boundaries, including varying population sizes.""" + sizes = self._saved_population_sizes + if sum(sizes) != count: + # Public histories and older checkpoints may lack size metadata. + sizes = [self.sol_per_pop] * (count // self.sol_per_pop) + start = 0 + for size in sizes: + yield slice(start, start + size) + start += size + + def _population_history_generations(self, count): + """Return generation labels for saved population snapshots.""" + if len(self.solutions_generations) != count: + return list(range(count)) + return self._history_generation_numbers(self.solutions_generations) + def _per_generation_fitness(self): - """ - Return a list of length (generations_completed + 1) where - each entry is the fitness array of one generation. Only valid - when save_solutions=True. - """ - per_gen = [] - fitness_flat = numpy.asarray(self.solutions_fitness) - sol_per_pop = self.sol_per_pop - num_blocks = fitness_flat.shape[0] // sol_per_pop - for g in range(num_blocks): - per_gen.append(fitness_flat[g * sol_per_pop:(g + 1) * sol_per_pop]) - return per_gen + """Return fitness arrays for saved population snapshots in order.""" + fitness = numpy.asarray(self.solutions_fitness) + return [fitness[boundary] for boundary in + self._population_history_slices(len(fitness))] def _per_generation_solutions(self): - """ - Return a list of length (generations_completed + 1) where - each entry is the population array of one generation. Only - valid when save_solutions=True. - """ - per_gen = [] - solutions_flat = numpy.asarray(self.solutions, dtype=float) - sol_per_pop = self.sol_per_pop - num_blocks = solutions_flat.shape[0] // sol_per_pop - for g in range(num_blocks): - per_gen.append(solutions_flat[g * sol_per_pop:(g + 1) * sol_per_pop]) - return per_gen + """Return saved populations in order, retaining their original gene types.""" + solutions = numpy.asarray(self.solutions, dtype=self.population.dtype) + return [solutions[boundary] for boundary in + self._population_history_slices(len(solutions))] # ── Pareto-front views for M >= 3 ──────────────────────────────────────── @@ -964,7 +1050,7 @@ def plot_fitness_band(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(per_gen)) + generations = self._population_history_generations(len(per_gen)) ax.fill_between(generations, min_vals, max_vals, color=color, alpha=band_alpha, label='min-max') ax.plot(generations, mean_vals, @@ -1038,7 +1124,7 @@ def plot_non_dominated_hypervolume(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(hv_values)) + generations = self._population_history_generations(len(hv_values)) ax.plot(generations, hv_values, color=color, linewidth=linewidth) ax.set_title(title, fontsize=font_size) ax.set_xlabel(xlabel, fontsize=font_size) @@ -1092,6 +1178,7 @@ def plot_population_diversity(self, per_gen = self._per_generation_solutions() diversity = [] for population in per_gen: + population = numpy.asarray(population, dtype=float) diff = population[:, None, :] - population[None, :, :] distances = numpy.sqrt((diff * diff).sum(axis=2)) # Mean over the upper triangle (each pair counted once). @@ -1104,7 +1191,7 @@ def plot_population_diversity(self, matplt = get_matplotlib() fig, ax = matplt.subplots() - generations = numpy.arange(len(diversity)) + generations = self._population_history_generations(len(diversity)) ax.plot(generations, diversity, color=color, linewidth=linewidth) ax.set_title(title, fontsize=font_size) ax.set_xlabel(xlabel, fontsize=font_size) @@ -1172,8 +1259,11 @@ def plot_pareto_front_evolution(self, per_gen = self._per_generation_fitness() # Pick generations to draw. Always include the last one. - indices = list(range(0, len(per_gen), every_k)) - if indices[-1] != len(per_gen) - 1: + generations = self._population_history_generations(len(per_gen)) + latest_snapshot = dict((generation, index) for index, generation in enumerate(generations)) + indices = [index for generation, index in latest_snapshot.items() + if generation % every_k == 0] + if not indices or indices[-1] != len(per_gen) - 1: indices.append(len(per_gen) - 1) matplt = get_matplotlib() @@ -1195,11 +1285,11 @@ def plot_pareto_front_evolution(self, if num_objectives == 2: ax.scatter(front[:, 0], front[:, 1], color=color, marker=marker, alpha=alpha, - label=f"gen {gen_idx}") + label=f"gen {generations[gen_idx]}") else: ax.scatter(front[:, 0], front[:, 1], front[:, 2], color=color, marker=marker, alpha=alpha, - label=f"gen {gen_idx}") + label=f"gen {generations[gen_idx]}") ax.set_title(title, fontsize=font_size) ax.set_xlabel(xlabel, fontsize=font_size) diff --git a/pyproject.toml b/pyproject.toml index d3d83b57..54a273b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ name = "pygad" dynamic = ["version"] description = "PyGAD: A Python Library for Building the Genetic Algorithm and Training Machine Learning Algoithms (Keras & PyTorch)." readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3" +requires-python = ">=3.8" license = {file = "LICENSE"} # license = "BSD-3-Clause" # license-files = ["LICENSE"] diff --git a/tests/test_allow_duplicate_genes.py b/tests/test_allow_duplicate_genes.py index 63037f3f..87c62066 100644 --- a/tests/test_allow_duplicate_genes.py +++ b/tests/test_allow_duplicate_genes.py @@ -247,44 +247,14 @@ def test_number_duplicates_nested_gene_space_initial_population(): assert num_duplicates == 0 -# def test_number_duplicates_nested_gene_space_nested_gene_type(): - """ - This example causes duplicate genes that can only be solved by changing the values of a chain of genes. - Let's explain it using this solution: [0, 2, 3, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 6 at indices 5 and 6. - According to the gene space, none of these genes can has a different value that solves the duplicates. - -If the value of the gene at index 5 is changed from 6 to 5, then it causes another duplicate with the gene at index 4. - -If the value of the gene at index 6 is changed from 6 to 7, then it causes another duplicate with the gene at index 7. - The solution is to change a chain of genes that make a room to solve the duplicates between the 2 genes. - 1) Change the second gene from 2 to 1. - 2) Change the third gene from 3 to 2. - 3) Change the fourth gene from 4 to 3. - 4) Change the fifth gene from 5 to 4. - 5) Change the sixth gene from 6 to 5. This solves the duplicates. - But this is NOT SUPPORTED yet. - We support changing only a single gene that makes a room to solve the duplicates. - - Let's explain it using this solution: [1, 2, 2, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 2 at indices 1 and 2. - This is how the duplicates are solved: - 1) Change the first gene from 1 to 0. - 2) Change the second gene from 2 to 1. This solves the duplicates. - The result is [0, 1, 2, 4, 5, 6, 6, 7, 8, 9] - """ - # num_duplicates = number_duplicate_genes(gene_space=[[0, 1], - # [1, 2], - # [2, 3], - # [3, 4], - # [4, 5], - # [5, 6], - # [6, 7], - # [7, 8], - # [8, 9], - # [9, 10]], - # gene_type=[int, int, int, int, int, int, int, int, int, int], - # num_genes=10) - - # assert num_duplicates == 0 +def test_number_duplicates_nested_gene_space_nested_gene_type(): + """Repair duplicates that require a chain of gene replacements.""" + num_duplicates = number_duplicate_genes(gene_space=[[index, index + 1] for index in range(10)], + gene_type=[int] * 10, + num_genes=10) + + assert num_duplicates == 0 + def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population(): num_duplicates = number_duplicate_genes(gene_space=[[0, 1], @@ -480,44 +450,15 @@ def test_number_duplicates_nested_gene_space_initial_population_multi_objective( assert num_duplicates == 0 -# def test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective(): - """ - This example causes duplicate genes that can only be solved by changing the values of a chain of genes. - Let's explain it using this solution: [0, 2, 3, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 6 at indices 5 and 6. - According to the gene space, none of these genes can has a different value that solves the duplicates. - -If the value of the gene at index 5 is changed from 6 to 5, then it causes another duplicate with the gene at index 4. - -If the value of the gene at index 6 is changed from 6 to 7, then it causes another duplicate with the gene at index 7. - The solution is to change a chain of genes that make a room to solve the duplicates between the 2 genes. - 1) Change the second gene from 2 to 1. - 2) Change the third gene from 3 to 2. - 3) Change the fourth gene from 4 to 3. - 4) Change the fifth gene from 5 to 4. - 5) Change the sixth gene from 6 to 5. This solves the duplicates. - But this is NOT SUPPORTED yet. - We support changing only a single gene that makes a room to solve the duplicates. - - Let's explain it using this solution: [1, 2, 2, 4, 5, 6, 6, 7, 8, 9] - It has 2 genes with the value 2 at indices 1 and 2. - This is how the duplicates are solved: - 1) Change the first gene from 1 to 0. - 2) Change the second gene from 2 to 1. This solves the duplicates. - The result is [0, 1, 2, 4, 5, 6, 6, 7, 8, 9] - """ - # num_duplicates = number_duplicate_genes(gene_space=[[0, 1], - # [1, 2], - # [2, 3], - # [3, 4], - # [4, 5], - # [5, 6], - # [6, 7], - # [7, 8], - # [8, 9], - # [9, 10]], - # gene_type=[int, int, int, int, int, int, int, int, int, int], - # num_genes=10) - - # assert num_duplicates == 0 +def test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective(): + """Repair duplicates that require a chain of gene replacements.""" + num_duplicates = number_duplicate_genes(gene_space=[[index, index + 1] for index in range(10)], + gene_type=[int] * 10, + num_genes=10, + multi_objective=True) + + assert num_duplicates == 0 + def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population_multi_objective(): num_duplicates = number_duplicate_genes(gene_space=[[0, 1], @@ -580,7 +521,7 @@ def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population print() # This example causes duplicates that can only be solved by changing a chain of genes. - # test_number_duplicates_nested_gene_space_nested_gene_type() + test_number_duplicates_nested_gene_space_nested_gene_type() # print() test_number_duplicates_nested_gene_space_nested_gene_type_initial_population() print() @@ -626,7 +567,7 @@ def test_number_duplicates_nested_gene_space_nested_gene_type_initial_population print() # This example causes duplicates that can only be solved by changing a chain of genes. - # test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective() + test_number_duplicates_nested_gene_space_nested_gene_type_multi_objective() # print() test_number_duplicates_nested_gene_space_nested_gene_type_initial_population_multi_objective() print() diff --git a/tests/test_constructor_parameters.py b/tests/test_constructor_parameters.py new file mode 100644 index 00000000..e0903914 --- /dev/null +++ b/tests/test_constructor_parameters.py @@ -0,0 +1,459 @@ +"""Constructor validation, parameter ownership, and operator compatibility.""" + +import functools +import logging +import random +import warnings +from unittest.mock import Mock + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, fitness_func=fitness_func, + sol_per_pop=4, num_genes=8, random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("name", ['num_parents_mating', 'num_generations', 'sol_per_pop', 'num_genes', + 'sample_size', 'keep_elitism', 'fitness_batch_size', 'random_seed']) +@pytest.mark.parametrize("value", [True, numpy.bool_(False), 1.5, numpy.nan, '2']) +def test_integer_parameters_reject_non_integer_values(name, value): + with pytest.raises(TypeError, match=name): + make_ga(**{name: value}) + + +@pytest.mark.parametrize("dtype", [numpy.int8, numpy.uint8, numpy.int64, numpy.uint64]) +def test_numpy_counts_are_python_integers_before_arithmetic(dtype): + ga_instance = make_ga(num_generations=dtype(200) if dtype is not numpy.int8 else dtype(100), + sol_per_pop=dtype(4), num_genes=dtype(3), num_parents_mating=dtype(2), + mutation_type=None, crossover_type=None, fitness_batch_size=dtype(2), + fitness_func=lambda ga, solutions, indices: [1.0] * len(solutions)) + count = int(ga_instance.num_generations) + ga_instance.run() + ga_instance.run() + assert ga_instance.generations_completed == 2 * count + for name in ['num_generations', 'sol_per_pop', 'num_genes', 'num_parents_mating', 'fitness_batch_size']: + assert type(getattr(ga_instance, name)) is int + + +@pytest.mark.parametrize("adaptive", [False, True]) +def test_numpy_percentages_do_not_overflow(adaptive): + ga_instance = make_ga(num_genes=3, mutation_type='adaptive' if adaptive else 'random', + mutation_percent_genes=[numpy.uint8(100), numpy.uint8(100)] if adaptive else numpy.uint8(100)) + assert ga_instance.mutation_num_genes == ([3, 3] if adaptive else 3) + + +@pytest.mark.parametrize("mutation_type,probability", [('random', 0.5), ('adaptive', [0.8, 0.2])]) +def test_probability_takes_precedence_over_inactive_counts_and_percentages(mutation_type, probability): + ga_instance = make_ga(mutation_type=mutation_type, mutation_probability=probability, + mutation_num_genes='ignored', mutation_percent_genes=numpy.array([0, numpy.nan])) + ga_instance.run() + assert ga_instance.mutation_num_genes is None + + +def test_gene_count_takes_precedence_over_inactive_percentage(): + ga_instance = make_ga(mutation_num_genes=2, mutation_percent_genes=numpy.array([0, 0])) + assert ga_instance.mutation_num_genes == 2 + + +@pytest.mark.parametrize("operator", ['random', 'adaptive', 'swap', 'inversion', 'scramble', 'polynomial']) +def test_zero_probability_keeps_offspring_unchanged(operator): + ga_instance = make_ga(mutation_type=operator, mutation_probability=[0.0, 0.0] if operator == 'adaptive' else 0.0) + original = ga_instance.population.copy() + result = ga_instance.mutation(original.copy()) + numpy.testing.assert_array_equal(result, original) + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_mutation_preserves_fixed_destination_spaces(operator): + population = numpy.tile(numpy.arange(8), (4, 1)) + ga_instance = make_ga(initial_population=population, mutation_type=operator, mutation_probability=1.0, + gene_space=[[value] for value in range(8)], allow_duplicate_genes=False) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, population) + + +def fixed_constraint(value): + def constraint(solution, values): + return [candidate for candidate in values if candidate == value] + return constraint + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_mutation_preserves_fixed_constraints(operator): + population = numpy.tile(numpy.arange(8), (4, 1)) + ga_instance = make_ga(initial_population=population, mutation_type=operator, mutation_probability=1.0, + gene_constraint=[fixed_constraint(value) for value in range(8)], allow_duplicate_genes=False) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, population) + + +def test_swap_searches_other_pairs_when_the_initial_pair_is_incompatible(): + ga_instance = make_ga(num_genes=3, initial_population=[[0, 1, 2]] * 4, + gene_space=[[0], [1, 2], [1, 2]], mutation_type='swap', allow_duplicate_genes=False) + ga_instance.numpy_random_generator = Mock(wraps=ga_instance.numpy_random_generator) + ga_instance.numpy_random_generator.choice = lambda *args, **kwargs: numpy.array([0, 1]) + result = ga_instance.swap_mutation(ga_instance.population.copy()) + numpy.testing.assert_array_equal(result, [[0, 2, 1]] * 4) + + +@pytest.mark.parametrize("operator", ['swap', 'inversion', 'scramble', 'polynomial']) +def test_explicit_gene_counts_limit_mutation_to_eligible_positions(operator): + ga_instance = make_ga(mutation_type=operator, mutation_num_genes=2, initial_population=[list(range(8))] * 4, + init_range_low=0, init_range_high=8) + ga_instance.python_random_generator.sample = lambda values, count: [1, 2] + result = ga_instance.mutation(ga_instance.population.copy()) + numpy.testing.assert_array_equal(result[:, [0, 3, 4, 5, 6, 7]], ga_instance.population[:, [0, 3, 4, 5, 6, 7]]) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_use_gene_spaces_outside_default_initialization_bounds(operator): + ga_instance = make_ga(num_genes=3, initial_population=[[10, 20, 30], [11, 21, 31]] * 2, + gene_space=[[10, 11], [20, 21], [30, 31]], + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1.0) + ga_instance.run() + for solution in ga_instance.population: + assert all(value in ga_instance.gene_space[index] for index, value in enumerate(solution)) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_handle_reversed_bounds_and_outside_supplied_values(operator): + ga_instance = make_ga(initial_population=[list(range(10, 18)), list(range(20, 28))] * 2, + init_range_low=4, init_range_high=-4, sbx_crossover_eta=1.5, + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1.0, keep_elitism=0, keep_parents=0) + ga_instance.run() + assert numpy.all(numpy.isfinite(ga_instance.population)) + assert numpy.all((ga_instance.population >= -4) & (ga_instance.population <= 4)) + + +@pytest.mark.parametrize("selection", ['tournament', 'tournament_nsga2', 'tournament_nsga3']) +@pytest.mark.parametrize("value,error", [(0, ValueError), (-1, ValueError), (1.5, TypeError), (True, TypeError)]) +def test_all_tournament_operators_validate_their_size(selection, value, error): + with pytest.raises(error, match='K_tournament'): + make_ga(parent_selection_type=selection, K_tournament=value, nsga3_num_divisions=1) + + +@pytest.mark.parametrize("criterion", ['saturate_0', 'saturate_-1', 'saturate_1.5', + 'evaluations_0', 'evaluations_-1', 'evaluations_1.5', + 'time_-1', 'time_nan', 'reach_inf', 'reach_1..2']) +def test_stopping_criteria_reject_invalid_thresholds(criterion): + with pytest.raises(ValueError): + make_ga(stop_criteria=criterion) + + +def test_stopping_criteria_support_scientific_notation_exact_counts_and_order(): + ga_instance = make_ga(stop_criteria=numpy.array(['reach_1e3', 'saturate_3', 'reach_1e3', 'evaluations_9007199254740993'])) + assert ga_instance.stop_criteria == [['reach', 1000.0], ['saturate', 3], ['evaluations', 9007199254740993]] + + +@pytest.mark.parametrize("name", ['random_mutation_min_val', 'random_mutation_max_val', 'sbx_crossover_eta', 'polynomial_mutation_eta']) +@pytest.mark.parametrize("value", [numpy.nan, numpy.inf, -numpy.inf]) +def test_ranges_and_distribution_indices_must_be_finite(name, value): + with pytest.raises(ValueError, match=name): + make_ga(**{name: value}) + + +@pytest.mark.parametrize("name", ['init_range_low', 'random_mutation_min_val', 'gene_space']) +def test_zero_dimensional_parameter_arrays_fail_descriptively(name): + with pytest.raises(ValueError, match='1D|0D'): + make_ga(**{name: numpy.array(1)}) + + +class Constraint: + def method(self, solution, values): + return values + + def __call__(self, solution, values): + return values + + +def constraint_with_option(solution, values, threshold=0): + return values + + +@pytest.mark.parametrize("constraint", [Constraint(), Constraint().method, + functools.partial(constraint_with_option, threshold=1)]) +def test_constraints_support_bound_methods_callable_objects_and_partials(constraint): + ga_instance = make_ga(gene_constraint=[constraint] * 8) + ga_instance.run() + + +@pytest.mark.parametrize("value", [False, 0, [], ()]) +def test_constraint_parameter_does_not_bypass_validation_when_falsey(value): + with pytest.raises((TypeError, ValueError), match='constraint'): + make_ga(gene_constraint=value) + + +def test_callable_validation_checks_positional_arguments_without_executing_functions(): + def keyword_fitness(ga, solution, *, index): + pytest.fail('Validation must not call fitness functions.') + def keyword_callback(*, ga): + pytest.fail('Validation must not call callbacks.') + with pytest.raises(ValueError, match='fitness_func'): + make_ga(fitness_func=keyword_fitness) + with pytest.raises(ValueError, match='on_start'): + make_ga(on_start=keyword_callback) + + +def test_functions_can_have_additional_optional_parameters(): + def fitness(ga, solution, index, offset=1): + return float(sum(solution)) + offset + def on_generation(ga, unused=None): + pass + make_ga(fitness_func=fitness, on_generation=on_generation).run() + + +def test_invalid_logger_does_not_mask_the_validation_error(): + with pytest.raises(TypeError, match='logger'): + make_ga(logger='invalid') + + +def test_adaptive_replacement_is_supported_without_an_incorrect_warning(): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter('always') + ga_instance = make_ga(mutation_type='adaptive', mutation_probability=[1.0, 1.0], + mutation_by_replacement=True, random_mutation_min_val=7, + random_mutation_max_val=7, suppress_warnings=False, keep_elitism=0, keep_parents=0) + ga_instance.run() + assert not any('replacement' in str(warning.message) for warning in captured) + numpy.testing.assert_array_equal(ga_instance.population, 7) + + +@pytest.mark.parametrize("setting", [0, numpy.int64(0), ['thread', 0], ('process', numpy.int64(0))]) +def test_zero_workers_consistently_disable_parallel_processing(setting): + assert make_ga(parallel_processing=setting).parallel_processing is None + + +@pytest.mark.parametrize("setting", [False, 0.0, ['thread', 0.0], ['process', False]]) +def test_worker_counts_require_integers(setting): + with pytest.raises(TypeError, match='parallel_processing'): + make_ga(parallel_processing=setting) + + +def test_mutable_parameter_containers_are_owned_by_the_ga(): + space = [[0, 1], {'low': 2, 'high': 4}] + [None] * 6 + minimum, maximum = [-1] * 8, [1] * 8 + constraints = [None] * 8 + rates = [0.8, 0.2] + ga_instance = make_ga(gene_space=space, gene_constraint=constraints, mutation_type='adaptive', + mutation_probability=rates, random_mutation_min_val=minimum, random_mutation_max_val=maximum) + space[0][:] = [100] + space[1]['low'] = 100 + minimum[:] = [100] * 8 + maximum[:] = [100] * 8 + constraints[0] = False + rates[:] = [0, 0] + assert ga_instance.gene_space[:2] == [[0, 1], {'low': 2, 'high': 4}] + assert ga_instance.random_mutation_min_val == [-1] * 8 + assert ga_instance.random_mutation_max_val == [1] * 8 + assert ga_instance.gene_constraint == [None] * 8 + assert ga_instance.mutation_probability == [0.8, 0.2] + + +def test_rejected_constructors_do_not_sample_or_execute_constraints(): + numpy_state, python_state = numpy.random.get_state(), random.getstate() + def constraint(solution, values): + pytest.fail('Invalid parameters must be rejected before constraints execute.') + with pytest.raises(TypeError, match='fitness_func'): + make_ga(fitness_func=None, gene_constraint=[constraint] * 8) + numpy.testing.assert_equal(numpy.random.get_state(), numpy_state) + assert random.getstate() == python_state + + +def test_seeded_instances_and_global_generators_do_not_interfere(): + first = make_ga(random_seed=numpy.int64(7)) + second = make_ga(random_seed=7) + first.run() + unrelated = make_ga(random_seed=99) + unrelated.run() + numpy.random.seed(123) + random.seed(123) + second.run() + numpy.testing.assert_array_equal(first.population, second.population) + + +def test_generator_states_survive_checkpoint_continuation(tmp_path): + ga_instance = make_ga() + ga_instance.run() + filename = str(tmp_path / 'generator-state') + ga_instance.save(filename) + loaded = pygad.load(filename) + ga_instance.run() + loaded.run() + numpy.testing.assert_array_equal(ga_instance.population, loaded.population) + + +@pytest.mark.parametrize("space", [range(10**12), {'low': 0, 'high': 10**12, 'step': 1}]) +def test_large_finite_spaces_are_sampled_and_inspected_without_materialization(space, monkeypatch): + original_arange = numpy.arange + def checked_arange(*args, **kwargs): + if args and max(args) > 100: + pytest.fail('A large domain must not be allocated for ordinary sampling.') + return original_arange(*args, **kwargs) + monkeypatch.setattr(numpy, 'arange', checked_arange) + ga_instance = make_ga(num_genes=3, gene_type=int, gene_space=space, mutation_probability=1.0) + assert len(ga_instance.gene_space_unpacked) <= 100 + ga_instance.run() + assert all(0 <= int(value) < 10**12 for value in ga_instance.population.flat) + candidates = ga_instance.get_initial_population_gene_candidates(0, 5, all_integer_values=False) + assert len(candidates) <= 5 + assert ga_instance.is_gene_value_in_space(0, 500_000_000_000, 1) + + +def test_large_integer_mutation_ranges_sample_small_candidate_arrays(): + ga_instance = make_ga(gene_type=int, random_mutation_min_val=-10**12, random_mutation_max_val=10**12, + mutation_probability=1.0) + values = ga_instance.generate_gene_value_randomly(-10**12, 10**12, 1, 0, False, sample_size=5) + assert len(values) == 5 + assert all(-10**12 + 1 <= int(value) < 10**12 + 1 for value in values) + + +def test_default_logger_does_not_remove_existing_handlers(): + logger = logging.getLogger('pygad.utils.validation') + handler = logging.NullHandler() + logger.addHandler(handler) + try: + make_ga() + assert handler in logger.handlers + finally: + logger.removeHandler(handler) + + +@pytest.mark.parametrize("space", [range(10**12), {'low': 0, 'high': 10**12}, + {'low': 0, 'high': 10**12, 'step': 2}]) +def test_large_nested_spaces_and_constraints_sample_bounded_candidates(space, monkeypatch): + original_arange = numpy.arange + def checked_arange(*args, **kwargs): + if args and max(args) > 100: + pytest.fail('Constraint sampling must not materialize a large domain.') + return original_arange(*args, **kwargs) + monkeypatch.setattr(numpy, 'arange', checked_arange) + def constraint(solution, values): + return [value for value in values if value >= 0] + ga_instance = make_ga(num_genes=3, gene_type=int, gene_space=[space] * 3, + gene_constraint=[constraint] * 3, mutation_probability=1.0) + ga_instance.run() + assert all(0 <= int(value) < 10**12 for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("lower,upper,step,expected", [(5, 1, 1, [1, 2, 3, 4]), + (5, 1, -1, [2, 3, 4, 5])]) +def test_integer_range_helper_supports_reversed_bounds_and_descending_steps(lower, upper, step, expected): + ga_instance = make_ga(gene_type=int) + values = ga_instance.generate_gene_value_randomly(lower, upper, 0, 0, True, + sample_size=None, step=step) + numpy.testing.assert_array_equal(values, expected) + + +def test_swap_explicit_count_can_change_multiple_pairs(): + ga_instance = make_ga(mutation_type='swap', mutation_num_genes=4) + original = numpy.arange(8, dtype=float)[None, :] + mutated = ga_instance.mutation(original.copy()) + assert numpy.count_nonzero(original != mutated) == 4 + numpy.testing.assert_array_equal(numpy.sort(mutated), original) + + +@pytest.mark.parametrize("dtype", [numpy.float16, numpy.float32, numpy.float64]) +@pytest.mark.parametrize("precision", [None, 1]) +def test_bounded_conversion_keeps_the_closest_value_below_excluded_upper_bound(dtype, precision): + ga_instance = make_ga(gene_type=[dtype, precision], gene_space={'low': 0.0, 'high': 1.0}) + converted = ga_instance.convert_bounded_operator_gene_value(0, 1.0) + assert isinstance(converted, dtype) + assert float(converted) < 1.0 + expected = numpy.nextafter(dtype(1.0), dtype(-numpy.inf), dtype=dtype) if precision is None else dtype(0.9) + assert converted == expected + + +def test_fixed_integer_dictionary_membership_matches_converted_candidates(): + ga_instance = make_ga(gene_type=int, gene_space={'low': 2, 'high': 2}) + assert ga_instance.is_gene_value_in_space(0, 2, 2) + assert ga_instance.convert_bounded_operator_gene_value(0, 2.0) == 2 + + +def test_falsey_callable_constraints_are_still_applied(): + class Constraint: + def __bool__(self): + return False + def __call__(self, solution, values): + return [value for value in values if value == 2] + ga_instance = make_ga(gene_type=int, gene_space=[1, 2, 3], gene_constraint=[Constraint()] * 8, + mutation_probability=1.0) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.population, 2) + + +def test_async_callable_objects_are_rejected_before_population_creation(): + class Fitness: + async def __call__(self, ga_instance, solution, solution_index): + return 1 + with pytest.raises(TypeError, match='fitness_func'): + make_ga(fitness_func=Fitness()) + + +@pytest.mark.parametrize("space", [{'low': 0.3, 'high': 1.0, 'step': 0.1}, + {'low': 1.0, 'high': 0.3, 'step': -0.1}, + {'low': 1.0, 'high': 2.0, 'step': 0.03}]) +@pytest.mark.parametrize("gene_type", [float, int, [float, 2]]) +def test_lazy_float_steps_match_numpy_arange_before_conversion(space, gene_type): + ga_instance = make_ga(gene_type=gene_type, gene_space=space) + expected = ga_instance.change_gene_dtype_and_round(0, numpy.arange(space['low'], space['high'], space['step'])) + numpy.testing.assert_array_equal(ga_instance.get_gene_space_values(0), numpy.unique(expected)) + assert all(ga_instance.is_gene_value_in_space(0, value, value) for value in expected) + + +def test_legacy_checkpoints_initialize_generators_and_normalize_counts(tmp_path): + ga_instance = make_ga(num_generations=200, mutation_probability=0) + ga_instance.num_generations = numpy.uint8(200) + del ga_instance.numpy_random_generator + del ga_instance.python_random_generator + del ga_instance.mutation_control_explicitly_set + filename = str(tmp_path / 'legacy-generator-state') + ga_instance.save(filename) + loaded = pygad.load(filename) + assert type(loaded.num_generations) is int + loaded.run() + loaded.run() + assert loaded.generations_completed == 400 + + +@pytest.mark.parametrize("crossover_type", ['single_point', 'two_points', 'uniform', 'scattered', 'sbx']) +def test_zero_crossover_probability_preserves_parents_even_when_random_draw_is_zero(crossover_type, monkeypatch): + ga_instance = make_ga(crossover_type=crossover_type, crossover_probability=0) + ga_instance.numpy_random_generator = Mock(wraps=ga_instance.numpy_random_generator) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'random', + lambda size=None: 0.0 if size is None else numpy.zeros(size)) + parents = numpy.array([[0.0] * 8, [1.0] * 8]) + children = ga_instance.crossover(parents, (4, 8)) + numpy.testing.assert_array_equal(children, numpy.tile(parents, (2, 1))) + + +@pytest.mark.parametrize("dtype", [numpy.int64, numpy.uint64]) +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_bounded_operators_do_not_overflow_at_large_integer_type_limits(dtype, operator, use_gene_space): + maximum = int(numpy.iinfo(dtype).max) + def fitness(ga_instance, solution, solution_index): + return float(int(solution[0]) - maximum) + ga_instance = make_ga(num_genes=3, gene_type=dtype, init_range_low=maximum - 10, + init_range_high=maximum + 1, keep_elitism=0, keep_parents=0, + fitness_func=fitness, + gene_space=range(maximum - 10, maximum + 1) if use_gene_space else None, + crossover_type='sbx' if operator == 'sbx' else None, + mutation_type='polynomial' if operator == 'polynomial' else None, + mutation_probability=1) + ga_instance.run() + assert ga_instance.population.dtype == numpy.dtype(dtype) + assert all(maximum - 10 <= int(value) <= maximum for value in ga_instance.population.flat) diff --git a/tests/test_duplicate_gene_repair.py b/tests/test_duplicate_gene_repair.py new file mode 100644 index 00000000..c7b9155c --- /dev/null +++ b/tests/test_duplicate_gene_repair.py @@ -0,0 +1,343 @@ +"""Regression tests for duplicate repair across the GA lifecycle.""" + +from unittest.mock import Mock + +import copy +import itertools +import random + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_idx): + return float(numpy.sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=3, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + gene_type=int, init_range_low=0, init_range_high=6, + random_mutation_min_val=0, random_mutation_max_val=6, + mutation_by_replacement=True, allow_duplicate_genes=False, + random_seed=1, suppress_warnings=True) + parameters.update(copy.deepcopy(options)) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("gene_space", [None, [0, 1, 2], (0, 1, 2), range(3), + numpy.arange(3), {'low': 0, 'high': 3}, + {'low': 0, 'high': 3, 'step': 1}]) +def test_manual_population_duplicates_are_repaired_without_changing_input(gene_space): + population = [[0, 0, 1], [0, 0, 1]] + before = copy.deepcopy(population) + ga_instance = make_ga(initial_population=population, gene_space=gene_space) + assert population == before + for solution in ga_instance.population: + assert len(set(solution)) == 3 + + +@pytest.mark.parametrize("manual", [False, True]) +def test_fixed_later_gene_can_move_an_earlier_duplicate(manual): + options = dict(gene_space=[[0, 1], [0], [2]]) + if manual: + options['initial_population'] = [[0, 0, 2], [0, 0, 2]] + ga_instance = make_ga(**options) + numpy.testing.assert_array_equal(ga_instance.population, + numpy.tile([1, 0, 2], (ga_instance.sol_per_pop, 1))) + + +def test_long_replacement_chain_does_not_use_python_recursion(): + num_genes = 1100 + spaces = [[index, index + 1] for index in range(num_genes - 1)] + [[0]] + solution = numpy.array(list(range(num_genes - 1)) + [0]) + ga_instance = make_ga(num_genes=num_genes, gene_space=spaces, sample_size=1, + initial_population=[solution.tolist(), solution.tolist()]) + numpy.testing.assert_array_equal(ga_instance.population[0], + list(range(1, num_genes)) + [0]) + + +def test_finite_repair_matches_exhaustive_search_for_small_spaces(): + # Compare with all assignments, including spaces that cannot make + # every gene unique. The repair should maximize distinct values. + rng = random.Random(42) + for _ in range(100): + spaces = [rng.sample(range(4), rng.randint(1, 3)) for _ in range(4)] + solution = [rng.choice(space) for space in spaces] + expected = max(len(set(values)) for values in itertools.product(*spaces)) + ga_instance = make_ga(num_genes=4, gene_space=spaces, + initial_population=[solution, solution], sample_size=1) + for repaired_solution in ga_instance.population: + assert len(set(repaired_solution)) == expected + assert all(value in space for value, space in zip(repaired_solution, spaces)) + + +@pytest.mark.parametrize("gene_type", [[numpy.int16, numpy.float32, int], + [int, [float, 0], numpy.int32]]) +def test_mixed_gene_types_are_preserved_during_range_repair(gene_type): + ga_instance = make_ga(gene_type=gene_type, + initial_population=[[0, 0, 1], [0, 0, 1]]) + for solution in ga_instance.population: + assert len(set(solution)) == 3 + for index, value in enumerate(solution): + assert isinstance(value, ga_instance.get_gene_dtype(index)[0]) + + +@pytest.mark.parametrize("method", ['mutation_randomly', 'mutation_probs_randomly', + 'adaptive_mutation_randomly', + 'adaptive_mutation_probs_randomly']) +def test_mutation_repairs_each_gene_using_its_own_range(method, monkeypatch): + adaptive = method.startswith('adaptive') + probability = [1.0, 1.0] if adaptive else 1.0 + ga_instance = make_ga(initial_population=[[0, 2], [0, 2]], + mutation_type='adaptive' if adaptive else 'random', + mutation_probability=probability if 'probs' in method else None, + mutation_num_genes=[1, 1] if adaptive else 1, + random_mutation_min_val=[1, 10], + random_mutation_max_val=[3, 12]) + monkeypatch.setattr(ga_instance.python_random_generator, 'sample', lambda values, count: [0]) + monkeypatch.setattr(ga_instance, 'mutation_process_gene_value', + lambda solution, gene_idx, **kwargs: 2 if gene_idx == 0 else solution[gene_idx]) + if adaptive: + monkeypatch.setattr(ga_instance, 'adaptive_mutation_population_fitness', + lambda offspring: (1.0, numpy.ones(len(offspring)))) + result = getattr(ga_instance, method)(numpy.array([[0, 2]])) + assert len(set(result[0])) == 2 + assert 10 <= result[0, 1] < 12 + + +@pytest.mark.parametrize("gene_space, gene_type", [(None, float), + ([0, 1, 2], int), + ({'low': 0, 'high': 3, 'step': 1}, int)]) +def test_sample_size_one_accepts_scalar_candidates(gene_space, gene_type): + ga_instance = make_ga(gene_space=gene_space, gene_type=gene_type, + sample_size=1, initial_population=[[0, 0, 1], [0, 0, 1]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("space", [None, [None], [None, 100], (None, 100), + numpy.array([None, 100], dtype=object)]) +def test_none_entries_generate_fresh_values_from_per_gene_ranges(space): + ga_instance = make_ga(gene_space=[space, [100], [200]], gene_type=float, + init_range_low=[0, 100, 200], init_range_high=[1, 101, 201], + random_mutation_min_val=[10, 100, 200], + random_mutation_max_val=[11, 101, 201]) + candidates = ga_instance.get_gene_space_values(0, gene_value=0.5, sample_size=10) + random_candidates = candidates[candidates != 100] + assert len(random_candidates) > 1 + assert numpy.all((random_candidates >= 10) & (random_candidates < 11)) + + +def test_flat_none_space_with_mixed_types_and_per_gene_ranges(): + ga_instance = make_ga(gene_space=[None, 100], gene_type=[int, float, numpy.int16], + init_range_low=[0, 10, 20], init_range_high=[3, 13, 23]) + for solution in ga_instance.population: + assert len(set(solution)) == 3 + assert set(ga_instance.get_gene_space_values(2)) == {20, 21, 22, 100} + + +def test_repair_does_not_invalidate_a_constraint_on_another_gene(): + constraint = lambda solution, values: [value for value in values if value >= solution[1]] + ga_instance = make_ga(gene_space=[[0], [0, 1], [2]], + gene_constraint=[constraint, None, None]) + before = numpy.array([0, 0, 2]) + repaired, duplicates, count = ga_instance.solve_duplicate_genes(before) + numpy.testing.assert_array_equal(repaired, before) + assert ga_instance.solution_satisfies_gene_constraints(repaired) + assert duplicates == {1} + assert count == 1 + + +def test_constraint_can_require_changing_another_nonduplicated_gene(): + constraint = lambda solution, values: [value for value in values if value <= solution[2]] + ga_instance = make_ga(gene_space=[[0], [0, 1], [0, 2]], + gene_constraint=[None, constraint, None]) + repaired, duplicates, count = ga_instance.solve_duplicate_genes(numpy.array([0, 0, 0])) + numpy.testing.assert_array_equal(repaired, [0, 1, 2]) + assert duplicates == set() + assert count == 0 + + +def test_impossible_initial_space_warns_and_returns_remaining_duplicates(): + with pytest.warns(UserWarning, match='Failed to find a unique value'): + ga_instance = make_ga(gene_space=[0], suppress_warnings=False) + repaired, duplicates, count = ga_instance.solve_duplicate_genes( + [0, 0, 0], build_initial_pop=True, warn=False) + assert repaired.tolist() == [0, 0, 0] + assert duplicates == {1, 2} + assert count == 2 + + +@pytest.mark.parametrize("crossover_type", ['single_point', 'two_points', 'uniform', + 'scattered']) +def test_crossover_can_repair_a_chain_without_mutation(crossover_type): + ga_instance = make_ga(num_genes=4, gene_space=[[0, 1], [1, 2], [2, 3], [0]], + crossover_type=crossover_type, mutation_type=None) + offspring = ga_instance.crossover(numpy.array([[0, 1, 2, 0], [0, 1, 2, 0]]), (2, 4)) + numpy.testing.assert_array_equal(offspring, [[1, 2, 3, 0], [1, 2, 3, 0]]) + + +@pytest.mark.parametrize("stage", ['crossover', 'mutation', 'on_crossover', 'on_mutation']) +def test_user_operator_and_callback_outputs_are_repaired(stage): + def crossover(parents, offspring_size, ga_instance): + return numpy.zeros(offspring_size) + + def mutation(offspring, ga_instance): + return numpy.zeros_like(offspring) + + def callback(ga_instance, offspring): + offspring[:] = 0 + + options = dict(gene_space=[0, 1, 2], crossover_type=None, mutation_type=None) + if stage == 'crossover': + options['crossover_type'] = crossover + elif stage == 'mutation': + options['mutation_type'] = mutation + else: + options[stage] = callback + ga_instance = make_ga(**options) + ga_instance.run() + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +def test_rounding_is_applied_before_duplicate_repair(): + ga_instance = make_ga(gene_type=[float, 0], gene_space=[0, 1, 2]) + result = ga_instance.solve_duplicate_genes_in_population(numpy.array([[0.1, 0.2, 1.1]])) + assert len(set(result[0])) == 3 + assert set(result[0]) == {0, 1, 2} + + +@pytest.mark.parametrize("method", ['swap_mutation', 'inversion_mutation', + 'scramble_mutation']) +def test_permutation_mutation_repairs_duplicates_created_by_destination_casts(method, + monkeypatch): + ga_instance = make_ga(num_genes=4, gene_type=[float, int, int, int], mutation_type=method.split('_')[0], + initial_population=[[0.5, 1, 0, 2], [0.5, 1, 0, 2]]) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + if method == 'swap_mutation': + monkeypatch.setattr(ga_instance.numpy_random_generator, 'choice', lambda *args, **kwargs: numpy.array([0, 1])) + else: + monkeypatch.setattr(ga_instance.numpy_random_generator, 'randint', lambda *args, **kwargs: numpy.array([0])) + if method == 'scramble_mutation': + monkeypatch.setattr(ga_instance.numpy_random_generator, 'shuffle', lambda values: values.__setitem__(slice(None), values[::-1].copy())) + result = getattr(ga_instance, method)(ga_instance.population.copy()) + for solution in result: + assert len(set(solution)) == 4 + for gene_index, value in enumerate(solution): + assert isinstance(value, ga_instance.get_gene_dtype(gene_index)[0]) + + +@pytest.mark.parametrize("operator", ['sbx', 'polynomial']) +def test_bounded_operators_round_values_and_repair_using_their_own_bounds(operator): + ga_instance = make_ga(gene_type=[float, 0], crossover_type='sbx', + mutation_type='polynomial', mutation_probability=1.0, + init_range_low=0, init_range_high=3, + random_mutation_min_val=100, random_mutation_max_val=200, + initial_population=[[0, 1, 2], [2, 0, 1]]) + if operator == 'sbx': + result = ga_instance.sbx_crossover(ga_instance.population, (20, 3)) + else: + result = ga_instance.polynomial_mutation(numpy.tile([0, 1, 2], (20, 1)).astype(float)) + for solution in result: + assert len(set(solution)) == 3 + assert numpy.all((solution >= 0) & (solution <= 3)) + numpy.testing.assert_array_equal(solution, numpy.round(solution)) + + +def test_constraint_sample_size_one_does_not_crash(): + constraints = [lambda solution, values: values] * 3 + ga_instance = make_ga(gene_constraint=constraints, sample_size=1, + initial_population=[[0, 0, 1], [0, 0, 1]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("gene_type", [int, float, numpy.int16, [float, 1]]) +def test_equal_random_bounds_keep_values_and_report_impossible_duplicates(gene_type): + ga_instance = make_ga(gene_type=gene_type, init_range_low=0, init_range_high=0, + random_mutation_min_val=0, random_mutation_max_val=0) + repaired, duplicates, count = ga_instance.solve_duplicate_genes([0, 0, 0]) + assert repaired.tolist() == [0, 0, 0] + assert duplicates == {1, 2} + assert count == 2 + + +def test_reversed_integer_bounds_remain_usable_for_initialization_and_repair(): + ga_instance = make_ga(init_range_low=3, init_range_high=0, + random_mutation_min_val=3, random_mutation_max_val=0) + assert all(set(solution) == {0, 1, 2} for solution in ga_instance.population) + repaired, duplicates, count = ga_instance.solve_duplicate_genes([0, 0, 0]) + assert set(repaired) == {0, 1, 2} + assert duplicates == set() + assert count == 0 + + +@pytest.mark.parametrize("gene_type", [numpy.float16, numpy.float32, [numpy.float16, 1]]) +def test_none_candidates_support_small_float_types_and_precision(gene_type): + ga_instance = make_ga(gene_type=gene_type, gene_space=[[None], [None], [None]]) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + + +@pytest.mark.parametrize("space", [None, [None], {'low': 0, 'high': 2}]) +def test_swap_fallback_uses_current_none_ranges_and_continuous_bounds(space): + ga_instance = make_ga(num_genes=2, gene_space=[space, [0, 1]], + init_range_low=10, init_range_high=12, + random_mutation_min_val=0, random_mutation_max_val=2, + gene_type=float, initial_population=[[0, 1], [0, 1]]) + solution = numpy.array([0.0, 1.0]) + numpy.testing.assert_array_equal(ga_instance.swap_gene_by_space(solution, 0), [1, 0]) + + +def test_integer_none_mutation_adds_the_offset_before_casting(monkeypatch): + ga_instance = make_ga(gene_space=[None, [100], [200]], + initial_population=[[-2, 100, 200], [-2, 100, 200]], + mutation_by_replacement=False, + random_mutation_min_val=-1, random_mutation_max_val=1) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'uniform', lambda *args, **kwargs: 0.75) + value = ga_instance.generate_gene_value_from_space( + 0, False, ga_instance.population[0], gene_value=-2, sample_size=1) + assert value == -1 + + +def test_integer_dictionary_with_fractional_bounds_includes_all_converted_values(): + ga_instance = make_ga(num_genes=5, gene_space={'low': 0.8, 'high': 4.2}, + initial_population=[[0, 0, 1, 2, 3], [0, 0, 1, 2, 3]], + sample_size=1) + assert set(ga_instance.get_gene_space_values(0)) == {0, 1, 2, 3, 4} + assert all(set(solution) == {0, 1, 2, 3, 4} for solution in ga_instance.population) + + +def test_mixed_float_precision_compares_exact_numeric_values(): + ga_instance = make_ga(num_genes=2, gene_type=[numpy.float32, float], + gene_space=[[0.1, 1], [0.1, 1]], + initial_population=[[0.1, 0.1], [0.1, 0.1]]) + # float32(0.1) and Python's float(0.1) have different stored values. + # NumPy scalar comparison can promote the Python float to float32. + assert ga_instance.get_duplicate_gene_indices(ga_instance.population[0]) == set() + assert ga_instance.get_duplicate_gene_indices([numpy.float32(1), 1.0]) == {1} + assert ga_instance.find_two_duplicates(ga_instance.population[0], + ga_instance.gene_space_unpacked) == (None, None) + value = ga_instance.select_unique_value([numpy.float32(0.1)], [1.0, 0.1], 0) + assert isinstance(value, numpy.float32) + assert float(value) != 0.1 + + +def test_repeated_nans_can_be_repaired_from_a_finite_space(): + ga_instance = make_ga(gene_type=float, gene_space=[0, 1, 2], + initial_population=[[numpy.nan, numpy.nan, 0], + [numpy.nan, numpy.nan, 0]]) + for solution in ga_instance.population: + assert ga_instance.get_duplicate_gene_indices(solution) == set() + assert numpy.count_nonzero(numpy.isnan(solution)) == 1 + + +def test_repair_and_full_runs_are_reproducible(): + populations = [] + for _ in range(2): + ga_instance = make_ga(gene_space=[[0, 1], [1, 2], [0, 2]], + initial_population=[[0, 1, 0], [1, 1, 2]]) + ga_instance.run() + populations.append(ga_instance.population) + numpy.testing.assert_array_equal(*populations) diff --git a/tests/test_fitness_history_regressions.py b/tests/test_fitness_history_regressions.py new file mode 100644 index 00000000..70a2a87c --- /dev/null +++ b/tests/test_fitness_history_regressions.py @@ -0,0 +1,365 @@ +"""Fitness validation, saved history, and repeated-run regression tests.""" + +import cloudpickle +import matplotlib +import numpy +import pytest + +import pygad + +matplotlib.use('Agg') + + +def fitness_func(ga_instance, solution, solution_index): + return int(solution[0]) + + +def increasing_mutation(offspring, ga_instance): + return offspring + 10 + + +def make_ga(**kwargs): + parameters = dict(num_generations=2, num_parents_mating=1, + initial_population=[[0], [1]], gene_type=int, + fitness_func=fitness_func, crossover_type=None, + mutation_type=increasing_mutation, keep_parents=0, + keep_elitism=0, suppress_warnings=True, random_seed=7) + parameters.update(kwargs) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize('save_best', [False, True]) +@pytest.mark.parametrize('save_all', [False, True]) +def test_repeated_runs_preserve_snapshots_and_actual_generation_numbers(save_best, save_all): + ga = make_ga(save_best_solutions=save_best, save_solutions=save_all) + ga.run() + first_fitness = numpy.asarray(ga.best_solutions_fitness).copy() + ga.run() + assert ga.generations_completed == 4 + assert ga.best_solution_generation == 4 + assert ga.best_solutions_generations == [0, 1, 2, 2, 3, 4] + numpy.testing.assert_array_equal(ga.best_solutions_fitness[:3], first_fitness) + assert len(ga.best_solutions_fitness) == 6 + assert len(ga.best_solutions) == (6 if save_best else 0) + if save_all: + assert ga.solutions_generations == [0, 1, 2, 2, 3, 4] + assert len(ga.solutions) == len(ga.solutions_fitness) == 12 + for solution, fitness in zip(ga.solutions, ga.solutions_fitness): + assert solution[0] == fitness + if save_best: + numpy.testing.assert_array_equal(ga.best_solutions[:, 0], ga.best_solutions_fitness) + + +def test_zero_generation_runs_keep_boundary_snapshots(): + ga = make_ga(num_generations=0, save_best_solutions=True, save_solutions=True) + ga.run() + ga.run() + assert ga.generations_completed == ga.best_solution_generation == 0 + assert ga.best_solutions_generations == ga.solutions_generations == [0, 0] + assert len(ga.best_solutions) == 2 + assert len(ga.solutions) == 4 + + +def test_early_stop_and_resume_track_absolute_generations(): + def stop(ga): + return 'stop' + ga = make_ga(on_generation=stop, save_solutions=True) + ga.run() + ga.run() + assert ga.generations_completed == ga.best_solution_generation == 2 + assert ga.best_solutions_generations == ga.solutions_generations == [0, 1, 1, 2] + + +@pytest.mark.parametrize('in_place', [False, True]) +def test_on_fitness_updates_saved_best_and_population_fitness(in_place): + calls = [] + def on_fitness(ga, fitness): + calls.append(ga.generations_completed) + if in_place: + fitness[:] = [100, 0] + else: + return [100, 0] + ga = make_ga(on_fitness=on_fitness, save_best_solutions=True, save_solutions=True) + ga.run() + assert calls == [0, 1] + for snapshot in range(2): + assert ga.best_solutions_fitness[snapshot] == 100 + assert ga.best_solutions[snapshot, 0] == ga.solutions[snapshot * 2][0] + assert ga.solutions_fitness[snapshot * 2:snapshot * 2 + 2] == [100, 0] + + +@pytest.mark.parametrize('sequence,window,expected', [ + ([0, 1, 2, 3, 4, 5], 1, 5), + ([1, 1, 1, 1, 1, 1], 1, 1), + ([1, 1, 1, 1, 1, 1], 3, 3), + ([0, 1, 1, 1, 1, 1], 2, 3), + ([0, 1, 0, 1, 0, 1], 2, 5), + ([0, 0, 1, 1, 1, 1], 2, 4), +]) +@pytest.mark.parametrize('multi_objective', [False, True]) +def test_saturation_counts_consecutive_unchanged_generations(sequence, window, expected, multi_objective): + def scheduled_fitness(ga, solution, index): + value = sequence[ga.generations_completed] + return [value, -value] if multi_objective else value + ga = make_ga(fitness_func=scheduled_fitness, num_generations=5, + stop_criteria=f'saturate_{window}') + ga.run() + assert ga.generations_completed == expected + + +def test_saturation_counter_resets_for_each_run(): + ga = make_ga(fitness_func=lambda ga, solution, index: 1, + num_generations=10, stop_criteria='saturate_3') + ga.run() + assert ga.generations_completed == 3 + ga.run() + assert ga.generations_completed == 6 + assert ga.best_solution_generation == 0 + + +@pytest.mark.parametrize('mode', [None, ['thread', 2], ['process', 2]]) +@pytest.mark.parametrize('batch_size', [None, 2]) +@pytest.mark.parametrize('invalid', [[], [[1], [2]], 'bad', [1, 'bad'], [1, None], + numpy.nan, [1, numpy.nan], [1, numpy.inf], [[1], [2, 3]], object()]) +def test_invalid_fitness_is_rejected_before_selection(mode, batch_size, invalid): + def invalid_fitness(ga, solution, index): + return [invalid] * len(solution) if batch_size else invalid + ga = make_ga(fitness_func=invalid_fitness, parallel_processing=mode, + fitness_batch_size=batch_size) + with pytest.raises(ValueError, match='fitness_func'): + ga.cal_pop_fitness() + assert getattr(ga, '_fitness_executor', None) is None + + +@pytest.mark.parametrize('batch_size', [None, 2]) +def test_inconsistent_objective_counts_are_rejected(batch_size): + def inconsistent_fitness(ga, solution, index): + if batch_size: + return [1, [1, 2]] + return 1 if index == 0 else [1, 2] + ga = make_ga(fitness_func=inconsistent_fitness, fitness_batch_size=batch_size) + with pytest.raises(ValueError, match='same fitness shape'): + ga.cal_pop_fitness() + + +@pytest.mark.parametrize('in_place', [False, True]) +def test_invalid_callback_fitness_is_rejected(in_place): + def invalid_callback(ga, fitness): + if in_place: + fitness[:] = numpy.nan + else: + return ['bad', 'bad'] + ga = make_ga(gene_type=float, fitness_func=lambda ga, solution, index: float(solution[0]), + on_fitness=invalid_callback) + with pytest.raises(ValueError, match='on_fitness'): + ga.run() + + +def test_scalar_infinity_is_supported_by_best_solution_and_saturation(): + ga = make_ga(fitness_func=lambda ga, solution, index: numpy.inf, + stop_criteria='saturate_2', num_generations=5) + ga.run() + assert ga.generations_completed == 2 + assert ga.best_solution()[1] == numpy.inf + + +def test_checkpoint_resume_matches_uninterrupted_repeated_runs(): + ga = make_ga(save_best_solutions=True, save_solutions=True, mutation_type='random') + ga.run() + resumed = cloudpickle.loads(cloudpickle.dumps(ga)) + assert not hasattr(resumed, '_saved_fitness_indexes') + ga.run() + resumed.run() + numpy.testing.assert_array_equal(resumed.population, ga.population) + numpy.testing.assert_array_equal(resumed.best_solutions, ga.best_solutions) + assert resumed.best_solutions_generations == ga.best_solutions_generations + assert resumed.solutions_generations == ga.solutions_generations + assert resumed.best_solution_generation == ga.best_solution_generation + + +@pytest.mark.parametrize('runs', [1, 2]) +@pytest.mark.parametrize('array_history', [False, True]) +def test_older_checkpoints_restore_available_generation_information(runs, array_history): + ga = make_ga(save_solutions=True) + for _ in range(runs): + ga.run() + state = ga.__getstate__() + if array_history: + state['best_solutions_fitness'] = numpy.asarray(state['best_solutions_fitness']) + for name in ['best_solutions_generations', 'solutions_generations', '_saved_population_sizes']: + state.pop(name) + restored = pygad.GA.__new__(pygad.GA) + restored.__setstate__(state) + if runs == 1: + assert restored.best_solutions_generations == [0, 1, 2] + assert restored.best_solution_generation == 2 + else: + assert restored.best_solutions_generations == [None] * 6 + assert restored.best_solution_generation == -1 + restored.run() + assert restored.best_solution_generation == restored.generations_completed + + +def test_clearing_public_histories_keeps_new_generation_numbers_correct(): + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.run() + ga.best_solutions = [] + ga.best_solutions_fitness = [] + ga.solutions = [] + ga.solutions_fitness = [] + ga.run() + assert ga.best_solutions_generations == ga.solutions_generations == [2, 3, 4] + assert ga.best_solution_generation == 4 + + +def test_cache_precedence_first_match_and_public_history_edits(): + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.solutions = [[0], [0], [1]] + ga.solutions_fitness = [12, 99, 13] + ga.best_solutions = [[0], [1]] + ga.best_solutions_fitness = [100, 101] + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [12, 13]) + ga.solutions_fitness[0] = 42 + ga.solutions[2][0] = 2 + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [42, 101]) + assert ga.num_fitness_evaluations == 0 + + +def test_incremental_cache_only_indexes_new_saved_solutions(): + class CountingHistory(list): + reads = 0 + def __getitem__(self, index): + self.reads += 1 + return super().__getitem__(index) + ga = make_ga(save_solutions=True) + ga.solutions = CountingHistory([[index] for index in range(1000)]) + ga.solutions_fitness = list(range(1000)) + ga._fitness_run_active = True + ga.cal_pop_fitness() + assert ga.solutions.reads == 1000 + ga.cal_pop_fitness() + assert ga.solutions.reads == 1000 + ga.solutions.append([1000]) + ga.solutions_fitness.append(1000) + ga.cal_pop_fitness() + assert ga.solutions.reads == 1001 + + +@pytest.mark.parametrize('plot_type', ['plot', 'scatter', 'bar']) +def test_repeated_run_fitness_and_gene_plots_use_generation_numbers(plot_type, monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_best_solutions=True) + ga.run() + ga.run() + expected = [0, 1, 2, 2, 3, 4] + for figure in [ga.plot_fitness(plot_type=plot_type), + ga.plot_genes(solutions='best', plot_type=plot_type)]: + axis = figure.axes[0] + if plot_type == 'plot': + actual = axis.lines[0].get_xdata() + elif plot_type == 'scatter': + actual = axis.collections[0].get_offsets()[:, 0] + else: + actual = [bar.get_x() + bar.get_width() / 2 for bar in axis.patches] + numpy.testing.assert_array_equal(actual, expected) + pyplot.close(figure) + + +def test_repeated_run_population_plots_and_new_solution_rate(monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_solutions=True) + ga.run() + ga.run() + for figure in [ga.plot_fitness_band(), ga.plot_population_diversity()]: + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 2, 3, 4]) + pyplot.close(figure) + figure = ga.plot_new_solution_rate() + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 3]) + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_ydata(), [2, 2, 1, 1]) + pyplot.close(figure) + + +def test_population_snapshot_sizes_survive_nsga3_growth_between_runs(): + ga = make_ga(save_solutions=True, fitness_func=lambda ga, solution, index: [int(solution[0])] * 3) + ga.run() + ga.parent_selection_type = 'nsga3' + ga.nsga3_num_divisions = 2 + # Change the configured operator consistently, as constructor validation does. + ga.select_parents = ga.nsga3_selection + ga.run() + assert ga._saved_population_sizes == [2, 2, 2, 6, 6, 6] + assert [len(population) for population in ga._per_generation_solutions()] == [2, 2, 2, 6, 6, 6] + assert [len(fitness) for fitness in ga._per_generation_fitness()] == [2, 2, 2, 6, 6, 6] + + +def test_repeated_multi_objective_history_does_not_overwrite_current_pareto_fronts(monkeypatch): + from matplotlib import pyplot + monkeypatch.setattr(pyplot, 'show', lambda: None) + ga = make_ga(save_solutions=True, save_best_solutions=True, + fitness_func=lambda ga, solution, index: [int(solution[0]), -int(solution[0])]) + ga.run() + ga.run() + assert 0 <= ga.best_solution_generation <= ga.generations_completed + assert len(ga.pareto_fronts[0]) == len(ga.population) + assert all(int(row[0]) < len(ga.population) for row in ga.pareto_fronts[0]) + figure = ga.plot_non_dominated_hypervolume(reference_point=[-100, -100]) + numpy.testing.assert_array_equal(figure.axes[0].lines[0].get_xdata(), [0, 1, 2, 2, 3, 4]) + pyplot.close(figure) + figure = ga.plot_pareto_front_evolution(every_k=2) + assert figure.axes[0].get_legend_handles_labels()[1] == ['gen 0', 'gen 2', 'gen 4'] + pyplot.close(figure) + + +def test_report_after_repeated_runs_includes_history_plots(tmp_path): + pytest.importorskip('reportlab') + ga = make_ga(save_solutions=True, save_best_solutions=True) + ga.run() + ga.run() + filename = ga.generate_report(str(tmp_path / 'repeated_runs'), + include_plots=['plot_fitness', 'plot_genes', + 'plot_new_solution_rate', 'plot_fitness_band']) + assert (tmp_path / 'repeated_runs.pdf').stat().st_size > 1000 + assert filename.endswith('.pdf') + assert ga.best_solution_generation == 4 + + +def test_cache_keeps_large_integer_solution_keys_exact(): + large_integer = 2 ** 60 + ga = make_ga(initial_population=[[large_integer], [large_integer + 1]], + save_solutions=True) + ga.solutions = [[large_integer], [large_integer + 1]] + ga.solutions_fitness = [12, 13] + numpy.testing.assert_array_equal(ga.cal_pop_fitness(), [12, 13]) + assert ga.num_fitness_evaluations == 0 + + +def test_cache_observes_history_edits_made_by_callbacks(): + def edit_history(ga): + if ga.generations_completed == 1: + ga.solutions[0][0] = int(ga.population[0, 0]) + ga.solutions_fitness[0] = 999 + ga = make_ga(save_solutions=True, on_generation=edit_history, + crossover_type=None, mutation_type=None) + ga.run() + assert 999 in ga.last_generation_fitness + + +@pytest.mark.parametrize('fitness', [[[], []], [[[1]], [[2]]], [1, numpy.nan], + [[1], [1, 2]], ['bad', 'bad'], numpy.array(1)]) +def test_best_solution_validates_explicit_fitness(fitness): + ga = make_ga() + with pytest.raises(ValueError, match='pop_fitness'): + ga.best_solution(pop_fitness=fitness) + + +def test_adaptive_fitness_uses_the_population_objective_count(): + def fitness(ga, solution, index): + return [1, 2] if index is None else 1 + ga = make_ga(fitness_func=fitness, mutation_type='adaptive', + mutation_num_genes=[1, 1]) + ga.last_generation_fitness = ga.cal_pop_fitness() + ga.run_select_parents(call_on_parents=False) + with pytest.raises(ValueError, match='same fitness shape'): + ga.adaptive_mutation_population_fitness(numpy.ones((2, 1))) diff --git a/tests/test_gene_type_conversion.py b/tests/test_gene_type_conversion.py new file mode 100644 index 00000000..6ea69a78 --- /dev/null +++ b/tests/test_gene_type_conversion.py @@ -0,0 +1,336 @@ +"""Conversion, rounding, and gene-type preservation across the GA lifecycle.""" + +from unittest.mock import Mock + +import copy + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=2, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=4, num_genes=3, + mutation_type=None, crossover_type=None, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("dtype", [float, numpy.float16, numpy.float32, numpy.float64]) +@pytest.mark.parametrize("precision", [None, 0, 1, 2, 4, -1]) +def test_scalar_candidate_and_population_conversions_agree(dtype, precision): + ga_instance = make_ga(gene_type=[dtype, precision]) + values = numpy.array([1.25, -1.75, 7.12345]) + expected = values if precision is None else numpy.round(values, precision) + expected = numpy.asarray(expected, dtype=dtype) + candidates = ga_instance.change_gene_dtype_and_round(0, values) + population = ga_instance.change_population_dtype_and_round([values])[0] + numpy.testing.assert_array_equal(candidates, expected) + numpy.testing.assert_array_equal(population, expected) + for value, converted in zip(values, expected): + scalar = ga_instance.change_gene_dtype_and_round(0, value) + assert type(scalar) is dtype + assert scalar == converted + + +def test_rounding_uses_nearest_even_halfway_values(): + ga_instance = make_ga(gene_type=[float, 0]) + values = [0.5, 1.5, 2.5, -0.5, -1.5, -2.5] + numpy.testing.assert_array_equal(ga_instance.change_gene_dtype_and_round(0, values), + [0, 2, 2, 0, -2, -2]) + + +@pytest.mark.parametrize("dtype", [int, numpy.int8, numpy.int16, numpy.int32, numpy.int64, + numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64]) +def test_integer_conversion_truncates_fractional_values(dtype): + ga_instance = make_ga(gene_type=dtype) + values = [0.9, 1.9, 2.1] if numpy.issubdtype(numpy.dtype(dtype), numpy.unsignedinteger) else [-1.9, -0.9, 2.9] + expected = [int(value) for value in values] + numpy.testing.assert_array_equal(ga_instance.change_gene_dtype_and_round(0, values), expected) + assert type(ga_instance.change_gene_dtype_and_round(0, values[0])) is dtype + + +@pytest.mark.parametrize("values", [numpy.array(1.25), [1.25, 1.75], + ((1.25, 1.75), (2.25, 2.75)), numpy.empty((0, 2))]) +def test_candidate_conversion_preserves_input_shape_and_values(values): + ga_instance = make_ga(gene_type=[numpy.float32, 1]) + original_values = copy.deepcopy(values) + converted = ga_instance.change_gene_dtype_and_round(0, values) + assert numpy.shape(converted) == numpy.shape(values) + numpy.testing.assert_array_equal(values, original_values) + if isinstance(converted, numpy.ndarray) and isinstance(values, numpy.ndarray): + assert not numpy.shares_memory(values, converted) + + +@pytest.mark.parametrize("gene_type", [numpy.float32, [float, 2], + [int, [numpy.float32, 2], numpy.int8]]) +def test_population_conversion_returns_an_independent_array(gene_type): + ga_instance = make_ga(gene_type=gene_type) + population = numpy.array([[1, 2.236, 3], [4, 5.678, 6]], dtype=object) + original_population = population.copy() + converted = ga_instance.change_population_dtype_and_round(population) + converted[0, 0] = 10 + numpy.testing.assert_array_equal(population, original_population) + assert not numpy.shares_memory(population, converted) + + +def test_grouped_conversion_preserves_declared_scalar_types_and_large_integers(): + gene_type = [int, [numpy.float32, 2], int, [numpy.float32, 2], numpy.int8, float] + ga_instance = make_ga(num_genes=6, gene_type=gene_type) + values = ((2**53 + 1, 1.236, 2**53 + 3, 2.341, 7, 1.5),) + converted = ga_instance.change_population_dtype_and_round(values) + assert converted[0, 0] == 2**53 + 1 and type(converted[0, 0]) is int + assert converted[0, 2] == 2**53 + 3 and type(converted[0, 2]) is int + assert converted[0, 1] == numpy.float32(1.24) and type(converted[0, 1]) is numpy.float32 + assert converted[0, 3] == numpy.float32(2.34) and type(converted[0, 3]) is numpy.float32 + assert type(converted[0, 4]) is numpy.int8 and type(converted[0, 5]) is float + + +def test_round_genes_rounds_before_narrow_float_casting(): + ga_instance = make_ga(gene_type=[numpy.float16, 4]) + values = numpy.array([[7.12345, 1.25, 1.75]]) + expected = numpy.asarray(numpy.round(values, 4), dtype=numpy.float16) + rounded = ga_instance.round_genes(values) + numpy.testing.assert_array_equal(rounded, expected) + assert numpy.all(numpy.isfinite(rounded)) + + +def test_round_genes_preserves_mixed_array_identity_and_destination_types(): + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], float]) + values = numpy.array([[1.9, 2.236, 3.5]], dtype=object) + rounded = ga_instance.round_genes(values) + assert rounded is values + assert type(rounded[0, 0]) is int and rounded[0, 0] == 1 + assert type(rounded[0, 1]) is numpy.float32 and rounded[0, 1] == numpy.float32(2.24) + + +def test_additive_mutation_rounds_after_adding_in_working_precision(): + ga_instance = make_ga(gene_type=[numpy.float16, 2]) + converted = ga_instance.mutation_change_gene_dtype_and_round( + numpy.float16(0.00501), 0, numpy.float16(1), False) + assert converted == numpy.float16(1.01) + + +def test_additive_mutation_keeps_mixed_signed_and_unsigned_integer_addition_exact(): + ga_instance = make_ga(gene_type=numpy.uint64) + value = 2**63 + 1 + converted = ga_instance.mutation_change_gene_dtype_and_round( + numpy.int64(2), 0, numpy.uint64(value), False) + assert int(converted) == value + 2 + candidates = ga_instance.mutation_change_gene_dtype_and_round( + numpy.array([1, 2], dtype=numpy.int64), 0, numpy.uint64(value), False) + assert [int(candidate) for candidate in candidates] == [value + 1, value + 2] + + +def test_fractional_integer_offsets_are_added_before_truncation(): + ga_instance = make_ga(gene_type=int) + value = ga_instance.generate_gene_value_randomly(0.2, 0.3, -1, 0, False) + assert value == 0 + + +@pytest.mark.parametrize("gene_type", [[int, float], (int, float), + numpy.array([int, float]), [[float, numpy.int32(2)], int], + numpy.array([[float, 2], [int, None]], dtype=object)]) +def test_validation_normalizes_specifications_without_modifying_them(gene_type): + original = copy.deepcopy(gene_type) + ga_instance = make_ga(num_genes=2, gene_type=gene_type) + assert not ga_instance.gene_type_single + numpy.testing.assert_array_equal(numpy.asarray(gene_type, dtype=object), numpy.asarray(original, dtype=object)) + + +@pytest.mark.parametrize("specification,error", [([float, True], TypeError), + ([float, 1.5], TypeError), ([float, '2'], TypeError), + ([int, 1], ValueError), ([int, -1], ValueError), + (numpy.array(float), ValueError), + ([numpy.array(float), int, float], ValueError), + ([[float, 1, 2], int, float], ValueError), + (numpy.dtype('float32'), TypeError), + (bool, TypeError), (complex, TypeError)]) +def test_invalid_type_and_precision_specifications_fail_descriptively(specification, error): + with pytest.raises(error, match="gene_type|precision"): + make_ga(gene_type=specification) + + +def custom_crossover(parents, offspring_size, ga_instance): + return numpy.tile(numpy.array([7.9, 1.236, 2.341]), (offspring_size[0], 1)) + + +def custom_mutation(offspring, ga_instance): + return numpy.tile(numpy.array([8.9, 2.341, 3.456]), (offspring.shape[0], 1)) + + +@pytest.mark.parametrize("allow_duplicate_genes", [True, False]) +def test_custom_operators_are_converted_before_callbacks_and_population_use(allow_duplicate_genes): + events = [] + def check_types(ga_instance, offspring): + events.append(offspring.copy()) + assert type(offspring[0, 0]) is int + assert type(offspring[0, 1]) is numpy.float32 + assert type(offspring[0, 2]) is float + assert offspring[0, 1] in [numpy.float32(1.24), numpy.float32(2.34)] + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], [float, 2]], + crossover_type=custom_crossover, mutation_type=custom_mutation, + on_crossover=check_types, on_mutation=check_types, + keep_elitism=0, keep_parents=0, allow_duplicate_genes=allow_duplicate_genes) + ga_instance.run() + assert len(events) == 4 + numpy.testing.assert_array_equal(ga_instance.population[0], [8, numpy.float32(2.34), 3.46]) + + +@pytest.mark.parametrize("callback_name", ['on_crossover', 'on_mutation', 'on_parents']) +@pytest.mark.parametrize("return_values", [True, False]) +def test_callback_outputs_keep_mixed_types_and_large_integer_values(callback_name, return_values): + value = 2**53 + 1 + def callback(ga_instance, population): + replacement = [[value, 1.236, 4] for _ in population] + if return_values: + return (replacement, ga_instance.last_generation_parents_indices) if callback_name == 'on_parents' else replacement + population[:] = numpy.asarray(replacement, dtype=object) + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], numpy.int8], + keep_elitism=0, keep_parents=0, **{callback_name: callback}) + ga_instance.run() + population = ga_instance.last_generation_parents if callback_name == 'on_parents' else ga_instance.population + assert population[0, 0] == value and type(population[0, 0]) is int + assert population[0, 1] == numpy.float32(1.24) and type(population[0, 1]) is numpy.float32 + assert type(population[0, 2]) is numpy.int8 + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive', 'swap', 'inversion', 'scramble', 'polynomial']) +def test_builtin_mutations_preserve_mixed_destination_types(mutation_type): + parameters = dict(gene_type=[int, [numpy.float32, 2], numpy.int8], mutation_type=mutation_type, + crossover_type='uniform', keep_elitism=0, keep_parents=0) + parameters['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + ga_instance = make_ga(**parameters) + ga_instance.run() + for solution in ga_instance.population: + assert type(solution[0]) is int + assert type(solution[1]) is numpy.float32 + assert type(solution[2]) is numpy.int8 + + +def test_saved_best_solutions_preserve_mixed_types_across_repeated_runs(): + value = 2**53 + 1 + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], numpy.int8], + initial_population=[[value, 1.236, 4], [value, 1.236, 4]], + save_best_solutions=True) + ga_instance.run() + ga_instance.run() + assert ga_instance.best_solutions.dtype == object + for solution in ga_instance.best_solutions: + assert solution[0] == value and type(solution[0]) is int + assert type(solution[1]) is numpy.float32 and solution[1] == numpy.float32(1.24) + assert type(solution[2]) is numpy.int8 + + +@pytest.mark.parametrize("gene_type", [object, [object, 2], [int, object, float]]) +def test_object_storage_keeps_numeric_values_without_integer_range_handling(gene_type): + ga_instance = make_ga(gene_type=gene_type, mutation_type='random', mutation_probability=1.0) + ga_instance.run() + assert ga_instance.population.dtype == object + assert all(isinstance(value, (int, float, numpy.number)) for value in ga_instance.population.flat) + + +@pytest.mark.parametrize("precision", [2, 309, 400, -309, 2**40, -(2**40)]) +def test_extreme_rounding_preserves_finite_values(precision): + values = [1.234, -1.234, 1e308, -1e308, 0.0] + ga_instance = make_ga(gene_type=[float, precision], initial_population=[values, values]) + expected = [round(value, precision) for value in values] + numpy.testing.assert_array_equal(ga_instance.population[0], expected) + for value, rounded_value in zip(values, expected): + assert ga_instance.change_gene_dtype_and_round(0, value) == rounded_value + + +@pytest.mark.parametrize("precision", [309, 400, -309, 2**40, -(2**40)]) +def test_generated_population_handles_extreme_precision(precision): + ga_instance = make_ga(gene_type=[float, precision]) + assert numpy.all(numpy.isfinite(ga_instance.population)) + assert numpy.all(ga_instance.population >= -4) + assert numpy.all(ga_instance.population < 4) + if precision < 0: + numpy.testing.assert_array_equal(ga_instance.population, 0) + + +@pytest.mark.parametrize("gene_type", [int, [int, numpy.float32, numpy.int64]]) +def test_finite_gene_spaces_convert_large_integers_before_float_inference(gene_type): + value = 2**53 + 1 + ga_instance = make_ga(gene_type=gene_type, gene_space=[value, 1.5]) + assert value in ga_instance.get_gene_space_values(0) + unpacked_space = ga_instance.gene_space_unpacked if ga_instance.gene_type_single else ga_instance.gene_space_unpacked[0] + assert value in unpacked_space + + +@pytest.mark.parametrize("lower,upper,expected", [(2**53 + 1, 2**53 + 3, [2**53 + 1, 2**53 + 2]), + (numpy.int64(2**53 + 1), numpy.int64(2**53 + 3), [2**53 + 1, 2**53 + 2]), + (numpy.float32(1.2), numpy.float32(2.0), [1]), + (-1.9, -1.0, [-1]), (-1.9, -0.2, [-1, 0]), + (1.2, 2.0, [1])]) +def test_integer_continuous_space_bounds_remain_exact(lower, upper, expected): + ga_instance = make_ga(gene_type=int, gene_space={'low': lower, 'high': upper}, + initial_population=[[expected[0]] * 3] * 2) + numpy.testing.assert_array_equal(ga_instance.get_gene_space_values(0), expected) + + +@pytest.mark.parametrize("dtype", [numpy.int64, numpy.uint64]) +def test_generated_integer_ranges_preserve_exact_numpy_bounds(dtype): + lower = 2**53 + 1 if dtype is numpy.int64 else 2**63 + 1 + ga_instance = make_ga(gene_type=dtype, init_range_low=dtype(lower), init_range_high=dtype(lower + 3)) + assert all(lower <= int(value) < lower + 3 for value in ga_instance.population.flat) + numpy.testing.assert_array_equal(ga_instance._initial_population_integer_bounds(0, dtype(lower), dtype(lower + 3)), + [lower, lower + 2]) + + +@pytest.mark.parametrize("dtype,precision", [(numpy.float32, 2), (float, 400), (float, 2**40)]) +def test_continuous_space_fallback_respects_stored_value_bounds(dtype, precision, monkeypatch): + ga_instance = make_ga(gene_type=[dtype, precision], gene_space={'low': 0.9, 'high': 1.0}) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, 'uniform', lambda *args, **kwargs: numpy.ones(kwargs['size'])) + values = ga_instance.get_gene_space_values(0, sample_size=1) + assert len(values) == 1 + assert 0.9 <= float(values[0]) < 1.0 + + +def test_custom_parent_selection_applies_types_before_its_callback(): + def selection_func(fitness, num_parents, ga_instance): + return (numpy.tile([7.9, 1.236, 4.9], (num_parents, 1)), numpy.arange(num_parents)) + def on_parents(ga_instance, parents): + assert type(parents[0, 0]) is int and parents[0, 0] == 7 + assert type(parents[0, 1]) is numpy.float32 and parents[0, 1] == numpy.float32(1.24) + assert type(parents[0, 2]) is numpy.int8 and parents[0, 2] == 4 + ga_instance = make_ga(parent_selection_type=selection_func, on_parents=on_parents, + gene_type=[int, [numpy.float32, 2], numpy.int8], + keep_elitism=0, keep_parents=0) + ga_instance.run() + + +def test_custom_operators_apply_types_without_callbacks(): + ga_instance = make_ga(gene_type=[int, [numpy.float32, 2], [float, 2]], + crossover_type=custom_crossover, mutation_type=custom_mutation, + keep_elitism=0, keep_parents=0) + ga_instance.run() + numpy.testing.assert_array_equal(ga_instance.last_generation_offspring_crossover[0], + [7, numpy.float32(1.24), 2.34]) + numpy.testing.assert_array_equal(ga_instance.population[0], [8, numpy.float32(2.34), 3.46]) + + +@pytest.mark.parametrize("mutation_type", ['random', 'adaptive']) +@pytest.mark.parametrize("use_probability", [True, False]) +def test_mutation_from_finite_spaces_preserves_declared_scalar_types(mutation_type, use_probability): + options = dict(gene_type=[int, [numpy.float32, 2], numpy.int8], gene_space=[1.236, 2.345, 3.456], + mutation_type=mutation_type, keep_elitism=0, keep_parents=0) + if use_probability: + options['mutation_probability'] = [1.0, 1.0] if mutation_type == 'adaptive' else 1.0 + else: + options['mutation_num_genes'] = [3, 3] if mutation_type == 'adaptive' else 3 + ga_instance = make_ga(**options) + ga_instance.run() + for solution in ga_instance.population: + assert type(solution[0]) is int + assert type(solution[1]) is numpy.float32 and solution[1] in [numpy.float32(1.24), numpy.float32(2.35), numpy.float32(3.46)] + assert type(solution[2]) is numpy.int8 diff --git a/tests/test_initial_population.py b/tests/test_initial_population.py new file mode 100644 index 00000000..384c03a5 --- /dev/null +++ b/tests/test_initial_population.py @@ -0,0 +1,287 @@ +"""Tests for generating and accepting initial populations.""" + +import copy + +import numpy +import pytest + +import pygad + + +def fitness_func(ga_instance, solution, solution_index): + return float(numpy.sum(solution)) + + +def make_ga(**options): + parameters = dict(num_generations=1, num_parents_mating=2, + fitness_func=fitness_func, sol_per_pop=50, num_genes=3, + mutation_type=None, crossover_type=None, + random_seed=7, suppress_warnings=True) + parameters.update(options) + return pygad.GA(**parameters) + + +@pytest.mark.parametrize("container", [list, tuple, numpy.array]) +def test_supplied_population_determines_dimensions_before_per_gene_validation(container): + population = container([[1, 2, 3], [4, 5, 6]]) + original_population = copy.deepcopy(population) + ga_instance = make_ga(initial_population=population, sol_per_pop=-1, + num_genes=99, gene_type=(int, float, numpy.int8), + gene_space=[[1, 4], [2, 5], [3, 6]], + init_range_low=[0, 0, 0], init_range_high=[10, 10, 10], + gene_constraint=[None, None, None]) + assert ga_instance.sol_per_pop == 2 + assert ga_instance.num_genes == 3 + assert ga_instance.pop_size == (2, 3) + numpy.testing.assert_array_equal(population, original_population) + numpy.testing.assert_array_equal(ga_instance.population, original_population) + assert not numpy.shares_memory(ga_instance.population, ga_instance.initial_population) + + +@pytest.mark.parametrize("population", [[], [[]], [[1], [2, 3]], [1, 2], + numpy.empty((0, 3)), numpy.empty((3, 0)), + numpy.zeros((2, 2, 2))]) +def test_invalid_population_shape_has_a_descriptive_error(population): + with pytest.raises(ValueError, match="non-empty rectangular 2D"): + make_ga(initial_population=population, gene_type=(int, float, int)) + + +@pytest.mark.parametrize("population", ["population", 5, [[None]], [[True]], + [["1"]], [[1 + 2j]]]) +def test_invalid_population_type_is_rejected(population): + with pytest.raises(TypeError, match="initial population|initial_population"): + make_ga(initial_population=population) + + +@pytest.mark.parametrize("gene_type", [[int, [numpy.float32, 2], numpy.int8], + (int, (numpy.float32, 2), numpy.int8), + numpy.array([int, [numpy.float32, 2], numpy.int8], dtype=object)]) +def test_gene_type_specifications_are_not_modified(gene_type): + original_specification = copy.deepcopy(gene_type) + ga_instance = make_ga(initial_population=((2**53 + 1, 1.236, 7), (2**53 + 3, 2.341, 8)), + gene_type=gene_type) + assert ga_instance.population[0, 0] == 2**53 + 1 + assert ga_instance.population[1, 0] == 2**53 + 3 + assert type(ga_instance.population[0, 1]) is numpy.float32 + assert type(ga_instance.population[0, 2]) is numpy.int8 + assert ga_instance.population[0, 1] == numpy.float32(1.24) + for original, actual in zip(original_specification, gene_type): + if isinstance(original, (list, tuple, numpy.ndarray)): + assert list(original) == list(actual) + else: + assert original is actual + + +@pytest.mark.parametrize("gene_type", [(float, None, ), ((float, None), int, float)]) +def test_float_types_accept_an_explicit_none_precision(gene_type): + ga_instance = make_ga(gene_type=gene_type) + assert ga_instance.population.shape == (50, 3) + + +@pytest.mark.parametrize("gene_type", [int, numpy.int8, numpy.uint8, float, + [float, 1], [numpy.float16, 1], + [numpy.float16, 5], numpy.float32]) +@pytest.mark.parametrize("bounds", [(0.2, 3.8), (-3.8, -0.2), (3.8, 0.2)]) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_range_values_remain_inside_bounds_after_conversion(gene_type, bounds, use_gene_space): + lower, upper = bounds + if gene_type is numpy.uint8 and max(bounds) < 0: + with pytest.raises(ValueError, match="representable"): + make_ga(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + return + options = dict(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + if use_gene_space: + options['gene_space'] = {'low': lower, 'high': upper} + ga_instance = make_ga(**options) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) >= min(bounds)) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) < max(bounds)) + + +@pytest.mark.parametrize("gene_space", [None, [None, None, None], + [[None], [None], [None]]]) +def test_none_entries_follow_their_own_ranges(gene_space): + ga_instance = make_ga(gene_space=gene_space, gene_type=(int, (float, 1), numpy.float32), + init_range_low=[2.2, -1.3, 20.0], + init_range_high=[6.1, -0.2, 21.0]) + for gene_index, (lower, upper) in enumerate(zip([2.2, -1.3, 20.0], [6.1, -0.2, 21.0])): + column = ga_instance.population[:, gene_index] + assert numpy.all(column >= lower) and numpy.all(column < upper) + assert len(numpy.unique(column)) > 1 + + +@pytest.mark.parametrize("gene_space", [[10, 20], (10, 20), range(10, 21, 10), + numpy.array([10, 20]), + {'low': 10, 'high': 21, 'step': 10}, + {'low': 20, 'high': 9, 'step': -10}]) +def test_finite_spaces_override_initialization_ranges(gene_space): + ga_instance = make_ga(gene_space=gene_space, gene_type=int, + init_range_low=-5, init_range_high=-1) + assert set(ga_instance.population.flat) == {10, 20} + + +def test_nested_spaces_support_fixed_values_dictionaries_and_none_choices(): + space = [5, {'low': 10, 'high': 13, 'step': 1}, [None, 100]] + original_space = copy.deepcopy(space) + ga_instance = make_ga(gene_space=space, gene_type=int, + init_range_low=[0, 0, 20], init_range_high=[1, 1, 30]) + assert numpy.all(ga_instance.population[:, 0] == 5) + assert set(ga_instance.population[:, 1]).issubset({10, 11, 12}) + values = set(ga_instance.population[:, 2]) + assert 100 in values and len(values) > 2 + assert values.issubset(set(range(20, 30)) | {100}) + assert space == original_space + + +@pytest.mark.parametrize("gene_space", [None, {'low': 0, 'high': 10**9}, + [[None], [None], [None]]]) +def test_large_integer_ranges_do_not_allocate_the_whole_domain(monkeypatch, gene_space): + def fail_if_enumerated(*args, **kwargs): + raise AssertionError("The initialization range must be sampled directly.") + monkeypatch.setattr(numpy, 'arange', fail_if_enumerated) + ga_instance = make_ga(gene_type=int, gene_space=gene_space, + init_range_low=0, init_range_high=10**9) + assert ga_instance.population.shape == (50, 3) + + +def test_large_integer_bounds_preserve_exact_values(): + ga_instance = make_ga(gene_type=int, init_range_low=2**53 + 1, + init_range_high=2**53 + 4) + assert set(ga_instance.population.flat) == {2**53 + 1, 2**53 + 2, 2**53 + 3} + + +def test_large_integer_constraint_search_does_not_allocate_the_whole_domain(monkeypatch): + def fail_if_enumerated(*args, **kwargs): + raise AssertionError("Large initialization intervals must be sampled for constraints.") + monkeypatch.setattr(numpy, 'arange', fail_if_enumerated) + ga_instance = make_ga(gene_type=int, init_range_low=0, init_range_high=10**9, + gene_constraint=[lambda solution, values: [], None, None]) + assert ga_instance.population.shape == (50, 3) + + +@pytest.mark.parametrize("gene_type", [numpy.int64, numpy.uint64]) +@pytest.mark.parametrize("use_gene_space", [False, True]) +def test_integer_type_limits_do_not_overflow_during_sampling(gene_type, use_gene_space): + upper = int(numpy.iinfo(gene_type).max) + 1 + space = {'low': upper - 3, 'high': upper} if use_gene_space else None + ga_instance = make_ga(gene_type=gene_type, init_range_low=upper - 3, + init_range_high=upper, gene_space=space) + assert set(int(value) for value in ga_instance.population.flat) == {upper - 3, upper - 2, upper - 1} + if use_gene_space: + assert set(int(value) for value in ga_instance.gene_space_unpacked) == {upper - 3, upper - 2, upper - 1} + + +@pytest.mark.parametrize("gene_type,lower,upper", [(int, 0.1, 0.9), + (numpy.int8, 128, 130), + ([float, 1], 0.01, 0.09), + (int, 1.5, 1.5)]) +def test_ranges_without_representable_values_fail_clearly(gene_type, lower, upper): + with pytest.raises(ValueError, match="representable.*gene"): + make_ga(gene_type=gene_type, init_range_low=lower, init_range_high=upper) + + +@pytest.mark.parametrize("gene_type", [int, float, [float, 1]]) +def test_equal_bounds_generate_a_fixed_representable_value(gene_type): + ga_instance = make_ga(gene_type=gene_type, init_range_low=2, init_range_high=2) + assert numpy.all(ga_instance.population == 2) + + +@pytest.mark.parametrize("dtype", [numpy.float16, numpy.float32, numpy.float64]) +def test_rounding_uses_the_stored_numpy_value_at_decimal_bounds(dtype): + ga_instance = make_ga(gene_type=[dtype, 1], init_range_low=0.1, init_range_high=0.2) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) >= 0.1) + assert numpy.all(numpy.asarray(ga_instance.population, dtype=float) < 0.2) + + +def test_supplied_values_outside_the_generation_domain_are_preserved(): + ga_instance = make_ga(initial_population=((100, -100, 30), (20, 30, 40)), + gene_space=[0, 1, 2], gene_type=int, + init_range_low=0, init_range_high=3) + numpy.testing.assert_array_equal(ga_instance.population, [[100, -100, 30], [20, 30, 40]]) + + +def test_supplied_population_constraints_use_initialization_candidates(): + population = [[1.236, 1, 1], [1.236, 1, 1]] + ga_instance = make_ga(initial_population=population, gene_type=([float, 2], int, int), + gene_space=[[1.236], [2, 3], [4, 5]], sample_size=1, + gene_constraint=[lambda solution, values: [value for value in values if value == 1.24], + lambda solution, values: [value for value in values if value > 2], + lambda solution, values: [value for value in values if value > solution[1]]]) + assert numpy.all(ga_instance.population[:, 0] == 1.24) + assert numpy.all(ga_instance.population[:, 1] == 3) + assert numpy.all(ga_instance.population[:, 2] > 3) + assert population == [[1.236, 1, 1], [1.236, 1, 1]] + + +def test_failed_constraints_warn_without_modifying_the_supplied_input(): + with pytest.warns(UserWarning, match="No value satisfied"): + ga_instance = make_ga(initial_population=[[1, 2, 3], [1, 2, 3]], + gene_space=[1, 2, 3], suppress_warnings=False, + gene_constraint=[lambda solution, values: [], None, None]) + assert numpy.all(ga_instance.population[:, 0] == 1) + + +def test_constraints_receive_an_independent_complete_solution(): + def constraint(solution, values): + assert len(solution) == 3 and all(value is not None for value in solution) + solution[1] = -100 + return values + ga_instance = make_ga(initial_population=[[1, 2, 3], [1, 2, 3]], + gene_constraint=[constraint, None, None]) + assert numpy.all(ga_instance.population[:, 1] == 2) + + +def test_growth_and_initialization_use_the_same_rules(): + ga_instance = make_ga(num_genes=4, gene_type=(int, int, int, int), + gene_space=[[0, 1], [1, 2], [2, 3], [0]], + allow_duplicate_genes=False, + gene_constraint=[None, None, lambda solution, values: [value for value in values if value >= 2], None]) + extra = ga_instance._nsga3_generate_extra_random_solutions(12) + assert extra.shape == (12, 4) + for solution in numpy.vstack([ga_instance.population, extra]): + assert len(set(solution)) == 4 + assert solution[3] == 0 and solution[2] >= 2 + + +def test_reinitialization_uses_its_constraint_and_duplicate_settings(): + ga_instance = make_ga(gene_space=[1, 2, 3], gene_type=int) + constraints = [lambda solution, values: [value for value in values if value == 3], None, None] + ga_instance.initialize_population(False, ga_instance.gene_type, constraints) + assert numpy.all(ga_instance.population[:, 0] == 3) + assert all(len(set(solution)) == 3 for solution in ga_instance.population) + numpy.testing.assert_array_equal(ga_instance.population, ga_instance.initial_population) + assert not numpy.shares_memory(ga_instance.population, ga_instance.initial_population) + + +def test_seed_reproduces_generated_population_with_none_entries(): + options = dict(gene_space=[[1, None], {'low': -2, 'high': 3}, range(5)], + gene_type=(int, [float, 2], int)) + first = make_ga(**options) + second = make_ga(**options) + numpy.testing.assert_array_equal(first.population, second.population) + + +@pytest.mark.parametrize("gene_space", [None, {'low': 0.0, 'high': 1.0}]) +def test_continuous_population_keeps_solution_then_gene_draw_order(gene_space): + numpy.random.seed(7) + expected = numpy.random.uniform(0, 1, size=(50, 3)) + ga_instance = make_ga(gene_space=gene_space, init_range_low=0, init_range_high=1) + numpy.testing.assert_array_equal(ga_instance.population, expected) + + +@pytest.mark.parametrize("result", [lambda values: [values[0], values[0]], + lambda values: [1000], lambda values: None]) +def test_invalid_constraint_outputs_are_rejected(result): + with pytest.raises(Exception, match="constraint"): + make_ga(gene_constraint=[lambda solution, values: result(values), None, None]) + + +@pytest.mark.parametrize("options,error", [({'init_range_low': [0, 1], 'init_range_high': [2, 3]}, ValueError), + ({'init_range_low': [[0], [1], [2]], 'init_range_high': [2, 3, 4]}, ValueError), + ({'init_range_low': numpy.nan}, ValueError), + ({'gene_space': {'low': '0', 'high': 3}}, TypeError), + ({'gene_space': {'low': 0, 'high': numpy.inf}}, ValueError), + ({'gene_space': {'low': 0, 'high': 3, 'step': 0}}, ValueError), + ({'gene_space': [{'low': 0, 'high': 3, 'step': -1}]}, ValueError)]) +def test_invalid_generation_settings_are_rejected_early(options, error): + with pytest.raises(error): + make_ga(**options) diff --git a/tests/test_nsga3_dtlz2.py b/tests/test_nsga3_dtlz2.py index e55dc67a..dca397a9 100644 --- a/tests/test_nsga3_dtlz2.py +++ b/tests/test_nsga3_dtlz2.py @@ -69,17 +69,16 @@ def _dtlz2_max_fitness(ga, solution, sol_idx): def _polynomial_mutation(offspring, ga_instance): """ - Polynomial mutation operator used in the Deb & Jain paper. PyGAD - only ships a uniform random mutation which is not strong enough to - drive DTLZ2 to convergence in a reasonable number of generations. + Polynomial mutation operator used in the Deb & Jain paper. Use the + GA's generator so random_seed controls this custom operator too. """ per_gene_probability = 1.0 / offspring.shape[1] eta_plus_one = 1.0 + POLY_MUTATION_ETA for solution_index in range(offspring.shape[0]): for gene_index in range(offspring.shape[1]): - if numpy.random.random() >= per_gene_probability: + if ga_instance.numpy_random_generator.random() >= per_gene_probability: continue - u = numpy.random.random() + u = ga_instance.numpy_random_generator.random() if u < 0.5: delta = pow(2.0 * u, 1.0 / eta_plus_one) - 1.0 else: @@ -106,6 +105,24 @@ def _make_dtlz2_ga(): suppress_warnings=True) +def test_custom_mutation_uses_instance_seed_despite_global_random_draws(): + first = _make_dtlz2_ga() + second = _make_dtlz2_ga() + first.num_generations = second.num_generations = 5 + global_state = numpy.random.get_state() + try: + numpy.random.seed(1) + first.run() + numpy.random.seed(123) + numpy.random.random(1000) + second.run() + numpy.testing.assert_array_equal(first.population, second.population) + numpy.testing.assert_array_equal(first.last_generation_fitness, + second.last_generation_fitness) + finally: + numpy.random.set_state(global_state) + + def _evaluate_final_fitness(ga): return numpy.array([_dtlz2_max_fitness(ga, sol, idx) for idx, sol in enumerate(ga.population)], diff --git a/tests/test_operator_regressions.py b/tests/test_operator_regressions.py index efc77180..29aab839 100644 --- a/tests/test_operator_regressions.py +++ b/tests/test_operator_regressions.py @@ -1,5 +1,7 @@ """Edge cases and invariants for the crossover and mutation operators.""" +from unittest.mock import Mock + import itertools import numpy @@ -131,7 +133,8 @@ def test_sbx_can_select_both_symmetric_children_at_boundaries(monkeypatch, paren parents = numpy.array(parents).reshape(2, 1) # The same spread draw with opposite child choices must straddle the mean. draws = iter([quantile, 0.0, quantile, 0.99]) - monkeypatch.setattr(numpy.random, "random", lambda: next(draws)) + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "random", lambda: next(draws)) children = ga.sbx_crossover(parents, (2, 1))[:, 0] assert children[0] < parents.mean() < children[1] @@ -146,7 +149,8 @@ def test_sbx_equal_parents_do_not_draw_random_values(monkeypatch): def unexpected_draw(*args, **kwargs): pytest.fail("Equal parents should be copied without a random draw") - monkeypatch.setattr(numpy.random, "random", unexpected_draw) + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "random", unexpected_draw) numpy.testing.assert_array_equal(ga.sbx_crossover(parents, (3, 2)), numpy.tile(parents[0], (3, 1))) @@ -189,7 +193,8 @@ def test_scramble_mutation_preserves_values_and_unselected_genes( segment_start = (0 if segment_position == "first" else int(numpy.ceil(num_genes / 2 + 1)) - 1) segment_end = segment_start + num_genes // 2 - monkeypatch.setattr(numpy.random, "randint", + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "randint", lambda **options: numpy.array([segment_start])) original = numpy.tile(numpy.arange(num_genes, dtype=gene_type), (64, 1)) offspring = original.copy() @@ -206,7 +211,8 @@ def test_scramble_mutation_preserves_values_and_unselected_genes( def test_scramble_mutation_can_reach_every_permutation_in_selected_segment(monkeypatch): ga = _make_ga(6, gene_type=int, mutation_type="scramble") - monkeypatch.setattr(numpy.random, "randint", + monkeypatch.setattr(ga, 'numpy_random_generator', Mock(wraps=ga.numpy_random_generator)) + monkeypatch.setattr(ga.numpy_random_generator, "randint", lambda **options: numpy.array([0])) offspring = numpy.tile(numpy.arange(6), (256, 1)) diff --git a/tests/test_parent_selection_regressions.py b/tests/test_parent_selection_regressions.py index 09b389f8..3bffd65b 100644 --- a/tests/test_parent_selection_regressions.py +++ b/tests/test_parent_selection_regressions.py @@ -1,5 +1,7 @@ """Sampling probabilities, requested parent counts, and population row mappings.""" +from unittest.mock import Mock + import copy import numpy @@ -30,7 +32,8 @@ def test_rank_selection_favors_better_fitness_and_maps_population_rows( suppress_warnings=True) # Sample every interval uniformly without statistical sampling noise. random_pointers = iter((numpy.arange(1000) + 0.5) / 1000) - monkeypatch.setattr(numpy.random, "rand", lambda: next(random_pointers)) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "rand", lambda: next(random_pointers)) parents, parents_indices = ga_instance.rank_selection( fitness=numpy.array(fitness), num_parents=1000) @@ -51,7 +54,8 @@ def test_rank_selection_favors_best_tied_group_or_crowding_boundaries(monkeypatc mutation_type=None, suppress_warnings=True) random_pointers = iter((numpy.arange(1000) + 0.5) / 1000) - monkeypatch.setattr(numpy.random, "rand", lambda: next(random_pointers)) + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "rand", lambda: next(random_pointers)) parents, parents_indices = ga_instance.rank_selection( fitness=numpy.array(fitness), num_parents=1000) @@ -115,7 +119,8 @@ def test_sus_respects_requested_count_and_balances_equal_fitness( random_seed=17, suppress_warnings=True) fitness = (numpy.ones((4, 2)) if multi_objective else numpy.ones(4)) - monkeypatch.setattr(numpy.random, "uniform", + monkeypatch.setattr(ga_instance, 'numpy_random_generator', Mock(wraps=ga_instance.numpy_random_generator)) + monkeypatch.setattr(ga_instance.numpy_random_generator, "uniform", lambda **options: numpy.array([options["high"] * offset_fraction])) parents, parents_indices = ga_instance.stochastic_universal_selection( diff --git a/tests/test_plot_lifecycle.py b/tests/test_plot_lifecycle.py new file mode 100644 index 00000000..29aa795b --- /dev/null +++ b/tests/test_plot_lifecycle.py @@ -0,0 +1,470 @@ +"""Tests for configuration charts without executing the configured GA.""" + +import random +import subprocess +import sys + +import numpy +import pytest + +import pygad +from pygad.visualize.lifecycle import _describe_lifecycle + +matplotlib = pytest.importorskip("matplotlib") +matplotlib.use("Agg") +import matplotlib.pyplot as matplt + + +def fitness_func(ga_instance, solution, solution_idx): + return float(numpy.sum(solution)) + + +def create_ga_instance(**parameters): + """Create a small GA, allowing each test to choose its configuration.""" + configuration = dict(num_generations=2, num_parents_mating=3, + sol_per_pop=8, num_genes=4, + fitness_func=fitness_func, random_seed=17, + suppress_warnings=True) + configuration.update(parameters) + return pygad.GA(**configuration) + + +def figure_text(fig): + """Read the visible labels rather than inspecting rendering details.""" + return "\n".join(text.get_text().replace("\n", " ") + for axes in fig.axes for text in axes.texts) + + +def test_plot_lifecycle_before_run_has_no_execution_side_effects(): + calls = [] + + def fitness_func_not_called(ga_instance, solution, solution_idx): + calls.append("fitness") + return 1.0 + + def on_start(ga_instance): + calls.append("on_start") + + def on_generation(ga_instance): + calls.append("on_generation") + + ga_instance = create_ga_instance(fitness_func=fitness_func_not_called, + on_start=on_start, on_generation=on_generation) + original_population = ga_instance.population.copy() + original_numpy_random_state = numpy.random.get_state() + original_python_random_state = random.getstate() + original_attribute_names = set(vars(ga_instance)) + fig = ga_instance.plot_lifecycle(show=False) + try: + assert isinstance(fig, matplotlib.figure.Figure) + assert "Known after fitness evaluation" in figure_text(fig) + assert calls == [] + assert ga_instance.generations_completed == 0 + assert ga_instance.last_generation_fitness is None + assert set(vars(ga_instance)) == original_attribute_names + numpy.testing.assert_array_equal(ga_instance.population, original_population) + current_numpy_random_state = numpy.random.get_state() + assert current_numpy_random_state[0] == original_numpy_random_state[0] + numpy.testing.assert_array_equal(current_numpy_random_state[1], original_numpy_random_state[1]) + assert current_numpy_random_state[2:] == original_numpy_random_state[2:] + assert random.getstate() == original_python_random_state + finally: + matplt.close(fig) + + +def test_lifecycle_callback_order_matches_execution_with_disabled_operators(): + events = [] + + def fitness_func_recorded(ga_instance, solution, solution_idx): + events.append("fitness") + return 1.0 + + def on_start(ga_instance): + events.append("on_start") + + def on_fitness(ga_instance, population_fitness): + events.append("on_fitness") + + def on_parents(ga_instance, parents): + events.append("on_parents") + + def on_crossover(ga_instance, offspring): + events.append("on_crossover") + + def on_mutation(ga_instance, offspring): + events.append("on_mutation") + + def on_generation(ga_instance): + events.append("on_generation") + return "stop" + + def on_stop(ga_instance, population_fitness): + events.append("on_stop") + + ga_instance = create_ga_instance(fitness_func=fitness_func_recorded, + crossover_type=None, mutation_type=None, + keep_elitism=0, keep_parents=0, + on_start=on_start, on_fitness=on_fitness, + on_parents=on_parents, on_crossover=on_crossover, + on_mutation=on_mutation, + on_generation=on_generation, on_stop=on_stop) + lifecycle = _describe_lifecycle(ga_instance) + stages = {stage["id"]: stage for stage in lifecycle["stages"]} + assert "crossover" not in stages + assert "mutation" not in stages + assert stages["generation_fitness"]["details"][0] == "fitness_func_recorded()" + + ga_instance.run() + # Fitness executes once per solution in each evaluation; group + # those calls to compare lifecycle stages to the observed order. + observed_order = [] + for event in events: + if event != "fitness" or not observed_order or observed_order[-1] != "fitness": + observed_order.append(event) + chart_order = [] + for stage in lifecycle["stages"]: + if stage["kind"] == "callback": + chart_order.append(stage["id"]) + elif stage["id"] in ("initial_fitness", "generation_fitness"): + chart_order.append("fitness") + assert chart_order == observed_order + assert ga_instance.generations_completed == 1 + assert any(connection["source"] == "early_stop" and connection["target"] == "finalize" + and connection["label"] == "Yes" for connection in lifecycle["connections"]) + + +@pytest.mark.parametrize("num_generations", [0, 2]) +def test_lifecycle_generation_loop_and_exit(num_generations): + ga_instance = create_ga_instance(num_generations=num_generations) + lifecycle = _describe_lifecycle(ga_instance) + connections = lifecycle["connections"] + assert any(connection["source"] == "generation_check" and connection["target"] == "finalize" + and connection["label"] == "No" for connection in connections) + assert any(connection["source"] == "generation_fitness" and connection["target"] == "generation_check" + and connection["route"] == "repeat" for connection in connections) + assert not any(connection["source"] == "generation_fitness" and connection["target"] == "finalize" + for connection in connections) + assert not any(stage["kind"] == "callback" for stage in lifecycle["stages"]) + ga_instance.run() + assert ga_instance.generations_completed == num_generations + + +@pytest.mark.parametrize("keep_elitism,keep_parents,retention_text,offspring_count", [ + (2, 0, "Keep 2 elite solution(s)", 6), + (0, -1, "Keep all 3 selected parents", 5), + (0, 2, "Keep 2 parent(s)", 6), + (0, 0, "Keep no parents or elite solutions", 8), +]) +def test_lifecycle_effective_population_retention(keep_elitism, keep_parents, retention_text, offspring_count): + ga_instance = create_ga_instance(keep_elitism=keep_elitism, keep_parents=keep_parents) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert retention_text in labels + assert f"Add {offspring_count} offspring" in labels + assert f"Offspring: ({offspring_count}, 4)" in labels + finally: + matplt.close(fig) + + +def test_lifecycle_custom_operators_compact_view(): + def select_custom_parents(fitness, num_parents, ga_instance): + raise AssertionError("Drawing must not call the parent selector.") + + def create_custom_offspring(parents, offspring_size, ga_instance): + raise AssertionError("Drawing must not call crossover.") + + def mutate_custom_offspring(offspring, ga_instance): + raise AssertionError("Drawing must not call mutation.") + + ga_instance = create_ga_instance(parent_selection_type=select_custom_parents, + crossover_type=create_custom_offspring, + mutation_type=mutate_custom_offspring) + fig = ga_instance.plot_lifecycle(show_parameters=False, show=False) + try: + labels = figure_text(fig) + assert "select_custom_parents()" in labels + assert "create_custom_offspring()" in labels + assert "mutate_custom_offspring()" in labels + assert "Configuration" not in labels + assert "Offspring:" not in labels + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("parent_selection_type", ["nsga2", "nsga3"]) +def test_lifecycle_multi_objective_after_run(parent_selection_type): + def fitness_func_multi(ga_instance, solution, solution_idx): + return [float(numpy.sum(solution)), -float(numpy.sum(solution ** 2))] + + parameters = dict(fitness_func=fitness_func_multi, parent_selection_type=parent_selection_type) + if parent_selection_type == "nsga3": + parameters["nsga3_num_divisions"] = 2 + ga_instance = create_ga_instance(**parameters) + ga_instance.run() + original_evaluation_count = ga_instance.num_fitness_evaluations + original_fitness = ga_instance.last_generation_fitness.copy() + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Population fitness: (8, 2)" in labels + assert "Known after fitness evaluation" not in labels + assert ("Prepare NSGA-III Reference Points" in labels) == (parent_selection_type == "nsga3") + assert ga_instance.num_fitness_evaluations == original_evaluation_count + numpy.testing.assert_array_equal(ga_instance.last_generation_fitness, original_fitness) + finally: + matplt.close(fig) + + +def test_lifecycle_mixed_genes_batching_constraints_and_stopping(): + def fitness_func_batch(ga_instance, solutions, solution_indices): + raise AssertionError("Drawing must not evaluate a fitness batch.") + + def positive_gene_values(solution, values): + return [value for value in values if value >= 0] + + ga_instance = create_ga_instance(fitness_func=fitness_func_batch, fitness_batch_size=3, + gene_type=[int, [float, 2], int, float], + gene_space=[range(1000), {"low": 0, "high": 10}, range(1000), None], + gene_constraint=[positive_gene_values, None, None, None], + parent_selection_type="tournament", K_tournament=4, + mutation_type="adaptive", mutation_probability=[0.8, 0.2], + parallel_processing=["thread", 2], + stop_criteria=["reach_20", "saturate_3", "time_10", "evaluations_100"]) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Batch size: 3" in labels + assert "Tournament size: 4" in labels + assert "Probability per gene: [0.8, 0.2]" in labels + assert "Evaluate offspring fitness to choose mutation" in labels + assert "decimal places" in labels + assert "1 constrained gene(s)" in labels + assert "['thread', 2]" in labels + for criterion in ["reach_20.0", "saturate_3", "time_10.0", "evaluations_100"]: + assert criterion in labels + finally: + matplt.close(fig) + + +def test_lifecycle_polynomial_mutation_and_sbx_parameters(): + ga_instance = create_ga_instance(crossover_type="sbx", sbx_crossover_eta=15, + mutation_type="polynomial", polynomial_mutation_eta=25) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "Distribution index: 15" in labels + assert "Distribution index: 25" in labels + # Polynomial mutation uses 1 / num_genes when no probability + # is supplied, rather than mutation_num_genes. + assert "Probability per gene: 0.25" in labels + assert "Genes to mutate:" not in labels + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("crossover_type,mutation_type", [ + (None, None), (None, "random"), ("single_point", None), ("single_point", "random"), +]) +def test_lifecycle_only_shows_enabled_operators(crossover_type, mutation_type): + ga_instance = create_ga_instance(crossover_type=crossover_type, mutation_type=mutation_type) + lifecycle = _describe_lifecycle(ga_instance) + stage_identifiers = {stage["id"] for stage in lifecycle["stages"]} + assert ("crossover" in stage_identifiers) == (crossover_type is not None) + assert ("mutation" in stage_identifiers) == (mutation_type is not None) + # Skipping operators must reconnect the remaining stages without + # leaving an arrow to a block that is no longer drawn. + for connection in lifecycle["connections"]: + assert connection["source"] in stage_identifiers + assert connection["target"] in stage_identifiers + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig).splitlines() + assert ("Crossover" in labels) == (crossover_type is not None) + assert ("Mutation" in labels) == (mutation_type is not None) + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("with_callback,stop_criteria", [ + (False, None), (True, None), (False, ["reach_20", "saturate_3"]), + (True, ["reach_20", "time_10", "evaluations_100"]), +]) +def test_lifecycle_stop_conditions_appear_inside_the_decision(with_callback, stop_criteria): + def on_generation(ga_instance): + # Drawing cannot predict this callback's result; it should + # describe the possible stop even when the function returns None. + return None + + ga_instance = create_ga_instance(on_generation=on_generation if with_callback else None, + stop_criteria=stop_criteria) + fig = ga_instance.plot_lifecycle(show=False) + try: + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + axes = fig.axes[0] + decisions = [card for card in axes.patches if isinstance(card, matplotlib.patches.Polygon)] + assert len(decisions) == (2 if with_callback or stop_criteria else 1) + if not with_callback and not stop_criteria: + assert "Stop Early?" not in figure_text(fig) + return + stop_decision = decisions[-1] + decision_path = stop_decision.get_path().transformed(stop_decision.get_transform()) + decision_texts = [text for text in axes.texts + if stop_decision.get_window_extent(renderer).contains( + *text.get_window_extent(renderer).get_points()[0])] + labels = "\n".join(text.get_text().replace("\n", " ") for text in decision_texts) + assert "Stop Early?" in labels + assert ('on_generation() returns "stop"' in labels) == with_callback + for criterion in ga_instance.stop_criteria or []: + assert "_".join(str(value) for value in criterion) in labels + for text in decision_texts: + text_bounds = text.get_window_extent(renderer) + corners = [(text_bounds.x0, text_bounds.y0), (text_bounds.x1, text_bounds.y0), + (text_bounds.x0, text_bounds.y1), (text_bounds.x1, text_bounds.y1)] + assert decision_path.contains_points(corners).all(), text.get_text() + finally: + matplt.close(fig) + + +def test_lifecycle_titles_use_capital_initials_and_preserve_method_names(): + def on_generation(ga_instance): + return None + + ga_instance = create_ga_instance(on_generation=on_generation) + lifecycle = _describe_lifecycle(ga_instance) + for stage in lifecycle["stages"]: + if stage["kind"] == "callback": + assert stage["title"] == stage["id"] + "()" + else: + assert all(word[0].isupper() for word in stage["title"].split()) + for label, value in lifecycle["configuration"]: + assert all(word[0].isupper() for word in label.split()) + fig = ga_instance.plot_lifecycle(show=False) + try: + labels = figure_text(fig) + assert "fitness_func()" in labels + assert "on_generation()" in labels + assert "On_Generation()" not in labels + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("show_parameters,font_size", [(True, 11), (False, 16)]) +def test_lifecycle_long_names_fit_in_the_chart(show_parameters, font_size): + def fitness_func_with_long_name(ga_instance, solution, solution_idx): + return 1.0 + + # Wide glyphs expose clipping that a character-count limit misses. + fitness_func_with_long_name.__name__ = "W" * 120 + ga_instance = create_ga_instance(fitness_func=fitness_func_with_long_name) + fig = ga_instance.plot_lifecycle(title="Wide lifecycle " + "W" * 100, + show_parameters=show_parameters, + font_size=font_size, show=False) + try: + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + for axes in fig.axes: + for text in axes.texts: + text_bounds = text.get_window_extent(renderer) + assert fig.bbox.contains(*text_bounds.get_points()[0]), text.get_text() + assert fig.bbox.contains(*text_bounds.get_points()[1]), text.get_text() + if text.get_text().replace("\n", "").startswith("W" * 20): + # Handler labels must fit inside a card as well as + # inside the figure's outer boundary. + assert any(card.get_window_extent(renderer).contains(*text_bounds.get_points()[0]) + and card.get_window_extent(renderer).contains(*text_bounds.get_points()[1]) + for card in axes.patches), text.get_text() + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("extension", ["svg", "png", "pdf"]) +def test_lifecycle_export_and_display_control(tmp_path, monkeypatch, extension): + shown_figures = [] + monkeypatch.setattr(matplt, "show", lambda: shown_figures.append(True)) + output_path = tmp_path / ("lifecycle." + extension) + ga_instance = create_ga_instance() + fig = ga_instance.plot_lifecycle(title="Scheduling $problem$", save_dir=output_path, show=False) + try: + assert shown_figures == [] + assert output_path.stat().st_size > 1000 + assert "Scheduling $problem$" in figure_text(fig) + finally: + matplt.close(fig) + fig = ga_instance.plot_lifecycle() + assert shown_figures == [True] + matplt.close(fig) + + +@pytest.mark.parametrize("show_parameters", [True, False]) +def test_lifecycle_transparent_export_has_small_outer_margins(tmp_path, show_parameters): + from PIL import Image + + path = tmp_path / "transparent.png" + fig = create_ga_instance().plot_lifecycle(show_parameters=show_parameters, + transparent=True, save_dir=path, show=False) + try: + assert fig.patch.get_alpha() == 0 + with Image.open(path) as image: + alpha = image.convert("RGBA").getchannel("A") + left, top, right, bottom = alpha.getbbox() + assert alpha.getextrema() == (0, 255) + # Default exports use 100 dpi; a content crop leaves about ten + # pixels of safety padding, rather than an unused axes canvas. + assert max(left, top, image.width-right, image.height-bottom) <= 15 + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + for text in fig.axes[0].texts: + bounds = text.get_window_extent(renderer) + assert fig.bbox.contains(*bounds.get_points()[0]), text.get_text() + assert fig.bbox.contains(*bounds.get_points()[1]), text.get_text() + finally: + matplt.close(fig) + + +def test_lifecycle_default_export_keeps_opaque_background(tmp_path): + from PIL import Image + + path = tmp_path / "opaque.png" + fig = create_ga_instance().plot_lifecycle(save_dir=path, show=False) + try: + with Image.open(path) as image: + assert image.convert("RGBA").getchannel("A").getextrema() == (255, 255) + finally: + matplt.close(fig) + + +@pytest.mark.parametrize("parameters,error", [ + ({"title": None}, TypeError), + ({"font_size": "large"}, TypeError), + ({"font_size": True}, TypeError), + ({"font_size": 0}, ValueError), + ({"font_size": -1}, ValueError), + ({"font_size": numpy.nan}, ValueError), + ({"font_size": numpy.inf}, ValueError), + ({"show_parameters": "yes"}, TypeError), + ({"show": None}, TypeError), + ({"transparent": "yes"}, TypeError), +]) +def test_lifecycle_parameter_validation(parameters, error): + with pytest.raises(error): + create_ga_instance().plot_lifecycle(**parameters) + + +def test_lifecycle_optional_matplotlib_import(monkeypatch): + # Importing PyGAD and building its lifecycle description stay usable + # without the plotting extra installed. + result = subprocess.run([sys.executable, "-c", + "import sys; import pygad; " + "from pygad.visualize.lifecycle import _describe_lifecycle; " + "assert 'matplotlib.pyplot' not in sys.modules"], + capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + def missing_matplotlib(): + raise ImportError("matplotlib is unavailable") + + monkeypatch.setattr(pygad.visualize.plot, "get_matplotlib", missing_matplotlib) + with pytest.raises(ImportError, match=r"pip install pygad\[visualize\]"): + create_ga_instance().plot_lifecycle(show=False) diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 00000000..e0eaf559 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,89 @@ +"""Publication must use dated notes and exactly the checked PyPI distributions.""" + +import hashlib +import importlib.util +import io +import json +from pathlib import Path + +import pytest + + +spec = importlib.util.spec_from_file_location( + "release_tools", Path(__file__).resolve().parents[1] / "tools/release.py") +release_tools = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_tools) + + +def test_release_notes_are_scoped_and_links_are_portable(tmp_path, monkeypatch): + source = tmp_path / "docs/source" + source.mkdir(parents=True) + (source / "releases.md").write_text( + "## Unreleased\n\nNo changes.\n\n## PyGAD 3.8.0\n\n" + "Release Date: October 9, 2026.\n\n" + "1. [Lifecycle](visualize.md#plot_lifecycle).\n" + "2. [Issue](https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/1).\n\n" + "## PyGAD 3.7.0\n\nOld release.\n", encoding="utf-8") + monkeypatch.setattr(release_tools, "ROOT", tmp_path) + notes = release_tools.release_notes("3.8.0") + assert notes.startswith("# PyGAD 3.8.0\n") + assert "October 9, 2026" in notes + assert "/blob/3.8.0/docs/source/visualize.md#plot_lifecycle" in notes + assert "https://github.com/ahmedfgad/GeneticAlgorithmPython/issues/1" in notes + assert "Unreleased" not in notes and "Old release" not in notes + + +def test_release_notes_reject_pending_date(tmp_path, monkeypatch): + source = tmp_path / "docs/source" + source.mkdir(parents=True) + (source / "releases.md").write_text( + "## PyGAD 3.8.0\n\nRelease Date: pending publication.\n", encoding="utf-8") + monkeypatch.setattr(release_tools, "ROOT", tmp_path) + with pytest.raises(ValueError, match="publication date"): + release_tools.release_notes("3.8.0") + + +def published_files(tmp_path, monkeypatch, change=None): + names = ["pygad-3.8.0-py3-none-any.whl", "pygad-3.8.0.tar.gz"] + contents = [b"checked wheel", b"checked sdist"] + files = [] + for name, data, kind in zip(names, contents, ["bdist_wheel", "sdist"]): + (tmp_path / name).write_bytes(data) + files.append({"filename": name, "packagetype": kind, "yanked": False, + "digests": {"sha256": hashlib.sha256(data).hexdigest()}, + "url": "https://files.pythonhosted.org/" + name}) + metadata = {"info": {"version": "3.8.0"}, "urls": files} + if change: + change(metadata) + replies = iter([json.dumps(metadata).encode()] + contents) + monkeypatch.setattr(release_tools, "urlopen", lambda *args, **kwargs: io.BytesIO(next(replies))) + return names, contents + + +def test_downloads_both_verified_pypi_files(tmp_path, monkeypatch): + names, contents = published_files(tmp_path, monkeypatch) + destination = tmp_path / "published" + release_tools.fetch_pypi("3.8.0", tmp_path, destination) + assert [(destination / name).read_bytes() for name in names] == contents + + +@pytest.mark.parametrize("change, message", [ + (lambda data: data["urls"][0]["digests"].update(sha256="bad"), "differs"), + (lambda data: data["urls"][0].update(yanked=True), "Unexpected published"), + (lambda data: data["urls"][0].update(url="https://example.com/file"), "download host"), + (lambda data: data["info"].update(version="3.7.0"), "different package version"), +]) +def test_rejects_incorrect_pypi_files(tmp_path, monkeypatch, change, message): + published_files(tmp_path, monkeypatch, change) + with pytest.raises(ValueError, match=message): + release_tools.fetch_pypi("3.8.0", tmp_path, tmp_path / "published") + + +def test_rejects_corrupted_download(tmp_path, monkeypatch): + names, contents = published_files(tmp_path, monkeypatch) + first_open = release_tools.urlopen + metadata = first_open().read() + replies = iter([metadata, b"corrupted download"]) + monkeypatch.setattr(release_tools, "urlopen", lambda *args, **kwargs: io.BytesIO(next(replies))) + with pytest.raises(ValueError, match="failed verification"): + release_tools.fetch_pypi("3.8.0", tmp_path, tmp_path / "published") diff --git a/tests/test_sbx_polynomial.py b/tests/test_sbx_polynomial.py index dd20d811..ef694afb 100644 --- a/tests/test_sbx_polynomial.py +++ b/tests/test_sbx_polynomial.py @@ -132,7 +132,7 @@ def test_unknown_mutation_error_message_lists_polynomial(): def test_sbx_with_fixed_seed_matches_pinned_output(): - # Pinned regression: with numpy.random seeded to 0 and the two + # Pinned regression: with the GA's NumPy generator seeded to 0 and the two # parents below, the SBX formula must produce exactly these # offspring values. The expected values come from the standard # Deb-Beyer bounded SBX formula on these inputs. @@ -145,7 +145,7 @@ def test_sbx_with_fixed_seed_matches_pinned_output(): [0.2, 0.5, 0.7, 0.9], [0.4, 0.3, 0.5, 0.1], ]) - numpy.random.seed(0) + ga.numpy_random_generator.seed(0) offspring = ga.sbx_crossover(parents, (2, 4)) expected = numpy.array([ [0.4003319281416015, 0.5007449415190373, 0.6994669447528481, 0.8982769171720244], @@ -170,7 +170,7 @@ def test_sbx_children_are_symmetric_around_the_parents_mean(): def test_polynomial_mutation_with_fixed_seed_matches_pinned_output(): - # Pinned regression: with numpy.random seeded to 0 and the input + # Pinned regression: with the GA's NumPy generator seeded to 0 and the input # vector below, polynomial mutation must produce exactly these # values. The expected values come from the standard Deb 1996 # bounded polynomial mutation formula on these inputs. @@ -179,7 +179,7 @@ def test_polynomial_mutation_with_fixed_seed_matches_pinned_output(): init_range_low=0.0, init_range_high=1.0, mutation_type='polynomial', polynomial_mutation_eta=20, mutation_probability=1.0, suppress_warnings=True) - numpy.random.seed(0) + ga.numpy_random_generator.seed(0) mutated = ga.polynomial_mutation(numpy.array([[0.5, 0.5, 0.5, 0.5]], dtype=float)) expected = numpy.array([[0.5264432889397432, 0.5044687436392203, 0.5162949167026651, 0.5702829850254326]]) diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 00000000..0ba8790e --- /dev/null +++ b/tools/release.py @@ -0,0 +1,99 @@ +"""Prepare documented release notes and verified copies of PyPI distributions.""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import urlopen + + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "https://github.com/ahmedfgad/GeneticAlgorithmPython" + + +def release_notes(version): + """Extract one published release and make its documentation links portable.""" + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("Use a bare release version, such as 3.8.0.") + source = (ROOT / "docs/source/releases.md").read_text(encoding="utf-8") + heading = "## PyGAD " + version + sections = re.split(r"(?m)^## ", source) + matches = [section for section in sections if section.startswith("PyGAD " + version + "\n")] + if len(matches) != 1: + raise ValueError("Expected exactly one release-notes section for " + version) + notes = "## " + matches[0].strip() + if not re.search(r"(?m)^Release Date: [A-Z][a-z]+ [0-9]{1,2}, [0-9]{4}\.$", notes): + raise ValueError("Set a publication date before creating release notes.") + if "pending publication" in notes or "has not been published" in notes: + raise ValueError("Remove pending-publication text before releasing.") + base = REPOSITORY + "/blob/" + version + "/docs/source/" + notes = re.sub(r"\]\(([^)]+)\)", lambda match: "](" + urljoin(base, match[1]) + ")", notes) + return notes.replace(heading, "# PyGAD " + version, 1) + "\n" + + +def fetch_pypi(version, built_directory, output_directory): + """Download the published wheel and sdist only when their build hashes match.""" + expected = { + "pygad-" + version + "-py3-none-any.whl": "bdist_wheel", + "pygad-" + version + ".tar.gz": "sdist", + } + built_hashes = { + name: hashlib.sha256((built_directory / name).read_bytes()).hexdigest() + for name in expected + } + for attempt in range(30): + try: + with urlopen("https://pypi.org/pypi/pygad/" + version + "/json", timeout=30) as response: + metadata = json.load(response) + if metadata["info"]["version"] != version: + raise ValueError("PyPI returned a different package version.") + files = {item["filename"]: item for item in metadata["urls"]} + if set(files) == set(expected): + break + except (HTTPError, URLError) as error: + if isinstance(error, HTTPError) and error.code != 404: + raise + if attempt == 29: + raise RuntimeError("Both published distributions are not available on PyPI.") + time.sleep(10) + output_directory.mkdir(parents=True, exist_ok=True) + for name, package_type in expected.items(): + item = files[name] + if item["yanked"] or item["packagetype"] != package_type: + raise ValueError("Unexpected published distribution: " + name) + if item["digests"]["sha256"] != built_hashes[name]: + raise ValueError("PyPI distribution differs from the checked build: " + name) + if not item["url"].startswith("https://files.pythonhosted.org/"): + raise ValueError("Unexpected PyPI download host.") + with urlopen(item["url"], timeout=60) as response: + contents = response.read() + if hashlib.sha256(contents).hexdigest() != built_hashes[name]: + raise ValueError("Downloaded PyPI distribution failed verification: " + name) + (output_directory / name).write_bytes(contents) + print(name + " SHA-256 " + built_hashes[name]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + notes = commands.add_parser("notes") + notes.add_argument("version") + notes.add_argument("output", type=Path) + packages = commands.add_parser("fetch-pypi") + packages.add_argument("version") + packages.add_argument("built_directory", type=Path) + packages.add_argument("output_directory", type=Path) + arguments = parser.parse_args() + if arguments.command == "notes": + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(release_notes(arguments.version), encoding="utf-8") + else: + fetch_pypi(arguments.version, arguments.built_directory, arguments.output_directory) + + +if __name__ == "__main__": + main()