Skip to content
Merged
45 changes: 45 additions & 0 deletions docs/source/gene_values.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,50 @@ python examples/example_gene_space.py

<!-- /python-examples -->

## Categorical Genes

PyGAD 3.9.0 adds the `gene_categorical` parameter to the constructor of the `pygad.GA` class. It marks the genes whose values are categories with no order.

For example, an activation function can be stored as `0` for `relu`, `1` for `tanh`, and `2` for `sigmoid`. The numbers are only labels. `tanh` is not between `relu` and `sigmoid`.

SBX crossover and polynomial mutation treat a gene value as a point on a number line. They move it to nearby values. This is right for a learning rate or a number of neurons, but it has no meaning for a category. For a categorical gene, these two operators work differently:

1. Polynomial mutation replaces the value with one of the other allowed values. Each of them is equally likely. `polynomial_mutation_eta` is not used for this gene.
2. SBX crossover gives the child the value of one of the two parents. Each parent is equally likely.

The other crossover and mutation types already copy or pick allowed values, so `gene_categorical` does not change them.

The `gene_categorical` parameter accepts:

1. `None`: The default. Every gene is ordered, which is the behavior before PyGAD 3.9.0.
2. A single `bool`: Applies to all genes.
3. A list or tuple with one `bool` per gene.

A categorical gene must have a finite gene space: a list of numbers, a `range`, or a dictionary with a `step`. A list that has `None` is not accepted because `None` stands for any value in the initialization range. Otherwise, PyGAD raises a `ValueError`.

This example has a continuous learning rate, an integer number of neurons, and a categorical activation function.

```python
import pygad

def fitness_func(ga_instance, solution, solution_idx):
learning_rate, num_neurons, activation = solution
...

ga_instance = pygad.GA(num_generations=20,
num_parents_mating=4,
sol_per_pop=10,
num_genes=3,
fitness_func=fitness_func,
gene_space=[{'low': 0.0001, 'high': 0.1},
{'low': 16, 'high': 129, 'step': 1},
[0, 1, 2]],
gene_type=[float, int, int],
gene_categorical=[False, False, True],
crossover_type="sbx",
mutation_type="polynomial")
```

<!-- sphinx
(gene-constraints-guide)=
-->
Expand Down Expand Up @@ -633,6 +677,7 @@ Let us look at some examples.
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.
- SBX crossover and polynomial mutation are the exception. Their results are rounded to the nearest integer, so `1.9` becomes `2` and `-1.9` becomes `-2`. For a gene with a finite gene space, the result moves to the nearest allowed value. Truncation would make the highest value of the gene almost impossible to reach and would pull values toward zero.
- Random and adaptive mutation clip generated values to the limits of a NumPy integer gene type before casting. This applies to both additive and replacement mutation, including random candidates from `None` gene-space entries, constraint checks, and duplicate repair. For example, a `numpy.uint8` gene at `0` with an offset of `-1` stays at `0`, and a `numpy.int8` gene at `127` with an offset of `1` stays at `127`. Values within the type's limits keep the same conversion behavior. Python `int`, floating-point, and `object` genes keep their existing behavior.
- 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`.
Expand Down
9 changes: 9 additions & 0 deletions docs/source/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ Release notes are listed from newest to oldest. Unreleased contains changes plan

No changes yet.

## PyGAD 3.9.0

Release Date: October 16, 2026.

1. [Roulette wheel selection](utils.md#roulette_wheel_selection) and [stochastic universal selection](utils.md#stochastic_universal_selection) sum fitness values in a floating-point type wide enough for the input. Large `int64`, `uint64`, `float16`, and `float32` fitness values no longer overflow and raise `ZeroDivisionError`. Object arrays of Python integers keep exact values. See [PR #381](https://github.com/ahmedfgad/GeneticAlgorithmPython/pull/381).
2. [SBX crossover](utils.md#sbx_crossover) and [polynomial mutation](utils.md#polynomial_mutationoffspring) round integer results to the nearest integer instead of truncating them. For a gene with a finite gene space, the result moves to the nearest allowed value. Before this change, the highest value of an integer gene was almost never produced, values were pulled toward zero, and SBX could not keep a parent's highest value. Results for integer genes change for the same random seed. Float genes are not affected. See [Conversion and Rounding Rules](gene_values.md#conversion-and-rounding-rules).
3. A new `gene_categorical` parameter marks genes whose values are categories with no order. For these genes, polynomial mutation picks one of the other allowed values at random and SBX crossover copies the value of one parent. The default `None` keeps the previous behavior. See [Categorical Genes](gene_values.md#categorical-genes).
4. Regression tests cover reaching both ends of integer and finite gene spaces, balanced steps up and down, negative ranges, stepped and uneven spaces, and every `gene_categorical` validation rule and operator behavior. The `pygad.helper` and `pygad.utils` submodule versions are `1.4.7` and `1.5.10`.

## PyGAD 3.8.1

Release Date: October 11, 2026.
Expand Down
4 changes: 4 additions & 0 deletions docs/source/utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ Applies simulated binary crossover for numeric genes. The `sbx_crossover_eta` pa

For each crossed gene, SBX produces two possible values on opposite sides of the parents' midpoint. PyGAD selects either value with probability `0.5`, avoiding the downward bias from always selecting the lower child. Equal parent values are copied unchanged.

For an integer gene, the child value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. For a gene marked in [`gene_categorical`](gene_values.md#categorical-genes), the child copies the value of one parent, each with probability `0.5`.

## `pygad.utils.mutation` Submodule

The `pygad.utils.mutation` module has a class named `Mutation` with the supported mutation operations:
Expand Down Expand Up @@ -351,6 +353,8 @@ The count-based and probability-based adaptive mutation methods use the same com

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.

For an integer gene, the new value is rounded to the nearest integer. For a gene with a finite gene space, it moves to the nearest allowed value. For a gene marked in [`gene_categorical`](gene_values.md#categorical-genes), the new value is one of the other allowed values, each equally likely, and `polynomial_mutation_eta` is not used.

### Mutation Helper Methods

The `pygad.utils.mutation` module has some helper methods to assist applying the mutation operation:
Expand Down
2 changes: 1 addition & 1 deletion pygad/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.8.1"
__version__ = "3.9.0"
2 changes: 1 addition & 1 deletion pygad/helper/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pygad.helper import unique
from pygad.helper import misc

__version__ = "1.4.6"
__version__ = "1.4.7"
129 changes: 128 additions & 1 deletion pygad/helper/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,17 +665,26 @@ def convert_bounded_operator_gene_value(self, gene_index, value):
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]
# When the gene has a finite list of allowed values, move the
# generated decimal straight to the nearest allowed value. Rounding
# it to an integer first would round twice and favor lower values.
nearest_allowed_value = self._nearest_finite_gene_space_value(gene_index, value)
if nearest_allowed_value is not None:
value = nearest_allowed_value
if numpy.issubdtype(numpy.dtype(dtype), numpy.integer):
value = value.item() if isinstance(value, numpy.generic) else value
type_limits = numpy.iinfo(self._get_gene_storage_dtype(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}.')
# Round to the nearest integer. Dropping the fraction instead
# would never reach the highest value and would pull every gene
# toward zero.
# 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)
value = min(max(math.floor(value + 0.5), 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):
Expand Down Expand Up @@ -708,6 +717,124 @@ def convert_bounded_operator_gene_value(self, gene_index, value):
converted = min(candidates, key=lambda candidate: abs(float(candidate) - float(converted)))
return converted if dtype is object else dtype(converted)

def _nearest_finite_gene_space_value(self, gene_index, value):
"""
Return the allowed value of a finite gene space that is nearest
to a value generated by SBX crossover or polynomial mutation.

A gene space is finite when it is a range, a dictionary with a
``step``, or a list of numbers. Lists that contain ``None`` are
not finite because ``None`` stands for any value in the
initialization range.

Parameters
----------
gene_index : int
Index of the gene whose space is used.
value : float
The decimal value generated by the operator.

Returns
-------
number or None
The nearest allowed value, or None when the gene has no
finite gene space.
"""
if self.gene_space is None:
return None
space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space
value = float(value)
if isinstance(space, range) or (isinstance(space, dict) and 'step' in space):
# The values are evenly spaced, so the nearest one is found
# from its position without listing all the values.
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((value - low) / step + 0.5)
position = min(max(position, 0), count - 1)
return self._finite_gene_space_value(space, position)
if isinstance(space, (list, tuple, numpy.ndarray)):
if any(item is None for item in space):
return None
return min(space, key=lambda allowed_value: abs(float(allowed_value) - value))
return None

def is_categorical_gene(self, gene_index):
"""
Check whether a gene is marked as categorical in ``gene_categorical``.

Parameters
----------
gene_index : int
Index of the gene.

Returns
-------
bool
True when the gene is categorical. A GA instance saved by an
older PyGAD version has no ``gene_categorical`` attribute, so
its genes are treated as ordered.
"""
gene_categorical = getattr(self, 'gene_categorical', None)
if gene_categorical is None:
return False
return bool(gene_categorical[gene_index])

def pick_other_categorical_value(self, gene_index, current_value):
"""
Pick one allowed value of a categorical gene, other than its
current value, with every other value equally likely.

Parameters
----------
gene_index : int
Index of the categorical gene. Its gene space is finite, which
``gene_categorical`` validation guarantees.
current_value : number
The value the gene has now.

Returns
-------
number
The new value. When the gene has only one allowed value, that
value is returned. When the current value is not one of the
allowed values, any allowed value can be returned.
"""
space = self.gene_space[gene_index] if self.gene_space_nested else self.gene_space
if isinstance(space, range) or isinstance(space, dict):
# Evenly spaced values: work with positions so a long range is
# never listed in full.
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']
current_position = int(round((float(current_value) - low) / step))
if not (0 <= current_position < count
and math.isclose(float(self._finite_gene_space_value(space, current_position)), float(current_value))):
current_position = None
allowed_value_at = lambda position: self._finite_gene_space_value(space, position)
else:
count = len(space)
current_position = None
for position, allowed_value in enumerate(space):
if float(allowed_value) == float(current_value):
current_position = position
break
allowed_value_at = lambda position: space[position]

if count == 1:
new_position = 0
elif current_position is None:
new_position = int(self.numpy_random_generator.randint(0, count))
else:
# Draw from the other count - 1 positions, then skip over the
# current position so it can never be chosen.
new_position = int(self.numpy_random_generator.randint(0, count - 1))
if new_position >= current_position:
new_position += 1
# The chosen value is already allowed. The conversion only applies
# the gene type, for example turning 2.0 into 2 for an int gene.
return self.convert_bounded_operator_gene_value(gene_index, allowed_value_at(new_position))

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:
Expand Down
7 changes: 5 additions & 2 deletions pygad/pygad.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ def __init__(self,
stop_criteria=None,
parallel_processing=None,
random_seed=None,
logger=None):
logger=None,
gene_categorical=None):
"""
The constructor of the GA class accepts all parameters required to create an instance of the GA class. It validates such parameters.

Expand Down Expand Up @@ -114,6 +115,7 @@ def __init__(self,
gene_space: It accepts a list of all possible values of the gene. This list is used in the mutation step. It should be used only if the gene space is a set of discrete values. No need for the 2 parameters (random_mutation_min_val and random_mutation_max_val) if the parameter gene_space exists. Added in PyGAD 2.5.0. In PyGAD 2.11.0, the gene_space can be assigned a dict.

gene_constraint: It accepts a list of constraints for the genes. Each constraint is a Python function. Added in PyGAD 3.5.0.
gene_categorical: Marks the genes whose values are categories with no order, such as 0 for relu, 1 for tanh, and 2 for sigmoid. It accepts None (the default, every gene is ordered), a single bool applied to all genes, or a list with one bool per gene. A categorical gene must have a finite gene_space (a list of numbers, a range, or a dict with a step). For a categorical gene, polynomial mutation picks one of the other allowed values at random and SBX crossover copies the value of one parent. The other operators are not affected. Added in PyGAD 3.9.0.
sample_size: 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. Added in PyGAD 3.5.0.

on_start: Accepts a function/method to be called only once before the genetic algorithm starts its evolution. If function, then it must accept a single parameter representing the instance of the genetic algorithm. If method, then it must accept 2 parameters where the second one refers to the method's object. Added in PyGAD 2.6.0.
Expand Down Expand Up @@ -183,7 +185,8 @@ def __init__(self,
stop_criteria=stop_criteria,
parallel_processing=parallel_processing,
random_seed=random_seed,
logger=logger)
logger=logger,
gene_categorical=gene_categorical)
except Exception as e:
if hasattr(self, 'logger'):
self.logger.exception(e)
Expand Down
2 changes: 1 addition & 1 deletion pygad/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
from pygad.utils import validation
from pygad.utils import engine

__version__ = "1.5.8"
__version__ = "1.5.10"
8 changes: 8 additions & 0 deletions pygad/utils/crossover.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,14 @@ def sbx_crossover(self, parents, offspring_size):
parent2_idx = (k + 1) % parents.shape[0]

for gene_idx in range(offspring_size[1]):
if self.is_categorical_gene(gene_idx):
# Blending two categories has no meaning, so the child
# takes the value of one parent, each with equal chance.
if self.numpy_random_generator.random() < 0.5:
offspring[k, gene_idx] = parents[parent1_idx, gene_idx]
else:
offspring[k, gene_idx] = parents[parent2_idx, gene_idx]
continue
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))
Expand Down
Loading
Loading