Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/genetic_algorithm.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

722 lines
21 KiB
Python

# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Sequence,
Iterable,
Optional,
Callable,
Union,
)
import abc
import copy
from dataclasses import dataclass
import json
import random
import time
# example usage:
# examples\addons\optimize\bin_packing_forms.py
# examples\addons\optimize\tsp.py
class DNA(abc.ABC):
"""Abstract DNA class."""
fitness: Optional[float] = None
_data: list
@abc.abstractmethod
def reset(self, values: Iterable):
...
@property
@abc.abstractmethod
def is_valid(self) -> bool:
...
def copy(self):
return copy.deepcopy(self)
def _taint(self):
self.fitness = None
def __repr__(self):
return f"{self.__class__.__name__}({str(self._data)})"
def __eq__(self, other):
assert isinstance(other, self.__class__)
return self._data == other._data
def __len__(self):
return len(self._data)
def __getitem__(self, item):
return self._data.__getitem__(item)
def __setitem__(self, key, value):
self._data.__setitem__(key, value)
self._taint()
def __iter__(self):
return iter(self._data)
@abc.abstractmethod
def flip_mutate_at(self, index: int) -> None:
...
def dna_fitness(dna: DNA) -> float:
return dna.fitness # type: ignore
class Mutate(abc.ABC):
"""Abstract mutation."""
@abc.abstractmethod
def mutate(self, dna: DNA, rate: float):
...
class FlipMutate(Mutate):
"""Flip one bit mutation."""
def mutate(self, dna: DNA, rate: float):
for index in range(len(dna)):
if random.random() < rate:
dna.flip_mutate_at(index)
class NeighborSwapMutate(Mutate):
"""Swap two neighbors mutation."""
def mutate(self, dna: DNA, rate: float):
for index in range(len(dna)):
if random.random() < rate:
i2 = index - 1
tmp = dna[i2]
dna[i2] = dna[index]
dna[index] = tmp
class RandomSwapMutate(Mutate):
"""Swap two random places mutation."""
def mutate(self, dna: DNA, rate: float):
length = len(dna)
for index in range(length):
if random.random() < rate:
i2 = random.randrange(0, length)
if i2 == index:
i2 -= 1
tmp = dna[i2]
dna[i2] = dna[index]
dna[index] = tmp
class ReverseMutate(Mutate):
"""Reverse some consecutive bits mutation."""
def __init__(self, bits: int = 3):
self._bits = int(max(bits, 1))
def mutate(self, dna: DNA, rate: float):
length = len(dna)
if random.random() < rate * (
length / self._bits
): # applied to all bits at ones
i1 = random.randrange(length - self._bits)
i2 = i1 + self._bits
bits = dna[i1:i2]
dna[i1:i2] = reversed(bits)
class ScrambleMutate(Mutate):
"""Scramble some consecutive bits mutation."""
def __init__(self, bits: int = 3):
self._bits = int(max(bits, 1))
def mutate(self, dna: DNA, rate: float):
length = len(dna)
if random.random() < rate * (
length / self._bits
): # applied to all bits at ones
i1 = random.randrange(length - self._bits)
i2 = i1 + self._bits
bits = dna[i1:i2]
random.shuffle(bits)
dna[i1:i2] = bits
class Mate(abc.ABC):
"""Abstract recombination."""
@abc.abstractmethod
def recombine(self, dna1: DNA, dna2: DNA):
pass
class Mate1pCX(Mate):
"""One point crossover recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
index = random.randrange(0, length)
recombine_dna_2pcx(dna1, dna2, index, length)
class Mate2pCX(Mate):
"""Two point crossover recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
i1 = random.randrange(0, length)
i2 = random.randrange(0, length)
if i1 > i2:
i1, i2 = i2, i1
recombine_dna_2pcx(dna1, dna2, i1, i2)
class MateUniformCX(Mate):
"""Uniform recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
for index in range(len(dna1)):
if random.random() > 0.5:
tmp = dna1[index]
dna1[index] = dna2[index]
dna2[index] = tmp
class MateOrderedCX(Mate):
"""Recombination class for ordered DNA like UniqueIntDNA()."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
i1 = random.randrange(0, length)
i2 = random.randrange(0, length)
if i1 > i2:
i1, i2 = i2, i1
recombine_dna_ocx1(dna1, dna2, i1, i2)
def recombine_dna_2pcx(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Two point crossover."""
part1 = dna1[i1:i2]
part2 = dna2[i1:i2]
dna1[i1:i2] = part2
dna2[i1:i2] = part1
def recombine_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Ordered crossover."""
copy1 = dna1.copy()
replace_dna_ocx1(dna1, dna2, i1, i2)
replace_dna_ocx1(dna2, copy1, i1, i2)
def replace_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Replace a part in dna1 by dna2 and preserve order of remaining values in
dna1.
"""
old = dna1.copy()
new = dna2[i1:i2]
dna1[i1:i2] = new
index = 0
new_set = set(new)
for value in old:
if value in new_set:
continue
if index == i1:
index = i2
dna1[index] = value
index += 1
class FloatDNA(DNA):
"""Arbitrary float numbers in the range [0, 1]."""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable[float]):
self._data: list[float] = list(values)
self._check_valid_data()
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int) -> FloatDNA:
return cls((random.random() for _ in range(length)))
@classmethod
def n_random(cls, n: int, length: int) -> list[FloatDNA]:
return [cls.random(length) for _ in range(n)]
@property
def is_valid(self) -> bool:
return all(0.0 <= v <= 1.0 for v in self._data)
def _check_valid_data(self):
if not self.is_valid:
raise ValueError("data value out of range")
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([round(v, 4) for v in self._data])}{fitness}"
def reset(self, values: Iterable[float]):
self._data = list(values)
self._check_valid_data()
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = 1.0 - self._data[index] # flip pick location
class BitDNA(DNA):
"""One bit DNA."""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable):
self._data: list[bool] = list(bool(v) for v in values)
self.fitness: Optional[float] = None
@property
def is_valid(self) -> bool:
return True # everything can be evaluated to True/False
@classmethod
def random(cls, length: int) -> BitDNA:
return cls(bool(random.randint(0, 1)) for _ in range(length))
@classmethod
def n_random(cls, n: int, length: int) -> list[BitDNA]:
return [cls.random(length) for _ in range(n)]
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(bool(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = not self._data[index]
class UniqueIntDNA(DNA):
"""Unique integer values in the range from 0 to length-1.
E.g. UniqueIntDNA(10) = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Requires MateOrderedCX() as recombination class to preserve order and
validity after DNA recombination.
Requires mutation by swapping like SwapRandom(), SwapNeighbors(),
ReversMutate() or ScrambleMutate()
"""
__slots__ = ("_data", "fitness")
def __init__(self, values: Union[int, Iterable]):
self._data: list[int]
if isinstance(values, int):
self._data = list(range(values))
else:
self._data = [int(v) for v in values]
if not self.is_valid:
raise TypeError(self._data)
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int) -> UniqueIntDNA:
dna = cls(length)
random.shuffle(dna._data)
return dna
@classmethod
def n_random(cls, n: int, length: int) -> list[UniqueIntDNA]:
return [cls.random(length) for _ in range(n)]
@property
def is_valid(self) -> bool:
return len(set(self._data)) == len(self._data)
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(int(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
raise TypeError("flip mutation not supported")
class IntegerDNA(DNA):
"""Integer values in the range from 0 to max_ - 1.
E.g. IntegerDNA([0, 1, 2, 3, 4, 0, 1, 2, 3, 4], 5)
"""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable[int], max_: int):
self._max = int(max_)
self._data: list[int] = list(values)
if not self.is_valid:
raise TypeError(self._data)
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int, max_: int) -> IntegerDNA:
imax = int(max_)
return cls((random.randrange(0, imax) for _ in range(length)), imax)
@classmethod
def n_random(cls, n: int, length: int, max_: int) -> list[IntegerDNA]:
return [cls.random(length, max_) for _ in range(n)]
@property
def is_valid(self) -> bool:
return all(0 <= v < self._max for v in self._data)
def __repr__(self):
return f"{self.__class__.__name__}({str(self._data)}, {self._max})"
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(int(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = self._max - self._data[index] - 1
class Selection(abc.ABC):
"""Abstract selection class."""
@abc.abstractmethod
def pick(self, count: int) -> Iterable[DNA]:
...
@abc.abstractmethod
def reset(self, candidates: Iterable[DNA]):
...
class Evaluator(abc.ABC):
"""Abstract evaluation class."""
@abc.abstractmethod
def evaluate(self, dna: DNA) -> float:
...
class Log:
@dataclass
class Entry:
runtime: float
fitness: float
avg_fitness: float
def __init__(self) -> None:
self.entries: list[Log.Entry] = []
def add(self, runtime: float, fitness: float, avg_fitness: float) -> None:
self.entries.append(Log.Entry(runtime, fitness, avg_fitness))
def dump(self, filename: str) -> None:
data = [(e.runtime, e.fitness, e.avg_fitness) for e in self.entries]
with open(filename, "wt") as fp:
json.dump(data, fp, indent=4)
@classmethod
def load(cls, filename: str) -> Log:
with open(filename, "rt") as fp:
data = json.load(fp)
log = Log()
for runtime, fitness, avg_fitness in data:
log.add(runtime, fitness, avg_fitness)
return log
class HallOfFame:
def __init__(self, count):
self.count = count
self._unique_entries = dict()
def __iter__(self):
return (
self._unique_entries[k] for k in self._sorted_keys()[: self.count]
)
def _sorted_keys(self):
return sorted(self._unique_entries.keys(), reverse=True)
def add(self, dna: DNA):
assert dna.fitness is not None
self._unique_entries[dna.fitness] = dna
def get(self, count: int) -> list[DNA]:
entries = self._unique_entries
keys = self._sorted_keys()
return [entries[k] for k in keys[: min(count, self.count)]]
def purge(self):
if len(self._unique_entries) <= self.count:
return
entries = self._unique_entries
keys = self._sorted_keys()
self._unique_entries = {k: entries[k] for k in keys[: self.count]}
def threshold_filter(
candidates: Sequence[DNA], best_fitness: float, threshold: float
) -> Iterable[DNA]:
if best_fitness <= 0.0:
minimum = min(candidates, key=dna_fitness)
min_value = (1.0 - threshold) * minimum.fitness # type: ignore
else:
min_value = best_fitness * threshold
return (c for c in candidates if c.fitness > min_value) # type: ignore
class GeneticOptimizer:
"""A genetic algorithm (GA) is a meta-heuristic inspired by the process of
natural selection. Genetic algorithms are commonly used to generate
high-quality solutions to optimization and search problems by relying on
biologically inspired operators such as mutation, crossover and selection.
Source: https://en.wikipedia.org/wiki/Genetic_algorithm
This implementation searches always for the maximum fitness, fitness
comparisons are always done by the "greater than" operator (">").
The algorithm supports negative values to search for the minimum fitness
(e.g. Travelling Salesmen Problem: -900 > -1000). Reset the start fitness
by the method :meth:`reset_fitness` accordingly::
optimizer.reset_fitness(-1e99)
"""
def __init__(
self,
evaluator: Evaluator,
max_generations: int,
max_fitness: float = 1.0,
):
if max_generations < 1:
raise ValueError("requires max_generations > 0")
# data:
self.name = "GeneticOptimizer"
self.log = Log()
self.candidates: list[DNA] = []
# core components:
self.evaluator: Evaluator = evaluator
self.selection: Selection = RouletteSelection()
self.mate: Mate = Mate2pCX()
self.mutation = FlipMutate()
# options:
self.max_generations = int(max_generations)
self.max_fitness: float = float(max_fitness)
self.max_runtime: float = 1e99
self.max_stagnation = 100
self.crossover_rate = 0.70
self.mutation_rate = 0.01
self.elitism: int = 2
# percentage (0.1 = 10%) of candidates with least fitness to ignore in
# next generation
self.threshold: float = 0.0
# state of last (current) generation:
self.generation: int = 0
self.start_time = 0.0
self.runtime: float = 0.0
self.best_dna: DNA = BitDNA([])
self.best_fitness: float = 0.0
self.stagnation: int = 0 # generations without improvement
self.hall_of_fame = HallOfFame(10)
def reset_fitness(self, value: float) -> None:
self.best_fitness = float(value)
@property
def is_executed(self) -> bool:
return bool(self.generation)
@property
def count(self) -> int:
return len(self.candidates)
def add_candidates(self, dna: Iterable[DNA]):
if not self.is_executed:
self.candidates.extend(dna)
else:
raise TypeError("already executed")
def execute(
self,
feedback: Optional[Callable[[GeneticOptimizer], bool]] = None,
interval: float = 1.0,
) -> None:
if self.is_executed:
raise TypeError("can only run once")
if not self.candidates:
print("no DNA defined!")
t0 = time.perf_counter()
self.start_time = t0
for self.generation in range(1, self.max_generations + 1):
self.measure_fitness()
t1 = time.perf_counter()
self.runtime = t1 - self.start_time
if (
self.best_fitness >= self.max_fitness
or self.runtime >= self.max_runtime
or self.stagnation >= self.max_stagnation
):
break
if feedback and t1 - t0 > interval:
if feedback(self): # stop if feedback() returns True
break
t0 = t1
self.next_generation()
def measure_fitness(self) -> None:
self.stagnation += 1
fitness_sum: float = 0.0
for dna in self.candidates:
if dna.fitness is not None:
fitness_sum += dna.fitness
continue
fitness = self.evaluator.evaluate(dna)
dna.fitness = fitness
fitness_sum += fitness
self.hall_of_fame.add(dna)
if fitness > self.best_fitness:
self.best_fitness = fitness
self.best_dna = dna
self.stagnation = 0
self.hall_of_fame.purge()
try:
avg_fitness = fitness_sum / len(self.candidates)
except ZeroDivisionError:
avg_fitness = 0.0
self.log.add(
time.perf_counter() - self.start_time,
self.best_fitness,
avg_fitness,
)
def next_generation(self) -> None:
count = len(self.candidates)
candidates: list[DNA] = []
selector = self.selection
selector.reset(self.filter_threshold(self.candidates))
if self.elitism > 0:
candidates.extend(self.hall_of_fame.get(self.elitism))
while len(candidates) < count:
dna1, dna2 = selector.pick(2)
dna1 = dna1.copy()
dna2 = dna2.copy()
self.recombine(dna1, dna2)
self.mutate(dna1, dna2)
candidates.append(dna1)
candidates.append(dna2)
self.candidates = candidates
def filter_threshold(self, candidates: Sequence[DNA]) -> Iterable[DNA]:
if self.threshold > 0.0:
return threshold_filter(
candidates, self.best_fitness, self.threshold
)
else:
return candidates
def recombine(self, dna1: DNA, dna2: DNA):
if random.random() < self.crossover_rate:
self.mate.recombine(dna1, dna2)
def mutate(self, dna1: DNA, dna2: DNA):
self.mutation.mutate(dna1, self.mutation_rate)
self.mutation.mutate(dna2, self.mutation_rate)
def conv_negative_weights(weights: Iterable[float]) -> Iterable[float]:
# random.choices does not accept negative values: -100 -> 1/100, -10 -> 1/10
return (0.0 if w == 0.0 else 1.0 / abs(w) for w in weights)
class RouletteSelection(Selection):
"""Selection by fitness values."""
def __init__(self, negative_values: bool = False) -> None:
self._candidates: list[DNA] = []
self._weights: list[float] = []
self._negative_values = bool(negative_values)
def reset(self, candidates: Iterable[DNA]):
# dna.fitness is not None here!
self._candidates = list(candidates)
if self._negative_values:
self._weights = list(
conv_negative_weights(dna.fitness for dna in self._candidates) # type: ignore
)
else:
self._weights = [dna.fitness for dna in self._candidates] # type: ignore
def pick(self, count: int) -> Iterable[DNA]:
return random.choices(self._candidates, self._weights, k=count)
class RankBasedSelection(RouletteSelection):
"""Selection by rank of fitness."""
def reset(self, candidates: Iterable[DNA]):
# dna.fitness is not None here!
self._candidates = list(candidates)
self._candidates.sort(key=dna_fitness)
# weight of best_fitness == len(strands)
# and decreases until 1 for the least fitness
self._weights = list(range(1, len(self._candidates) + 1))
class TournamentSelection(Selection):
"""Selection by choosing the best of a certain count of candidates."""
def __init__(self, candidates: int):
self._candidates: list[DNA] = []
self.candidates = candidates
def reset(self, candidates: Iterable[DNA]):
self._candidates = list(candidates)
def pick(self, count: int) -> Iterable[DNA]:
for _ in range(count):
values = [
random.choice(self._candidates) for _ in range(self.candidates)
]
values.sort(key=dna_fitness)
yield values[-1]