Attempted to reimport from egg

This commit is contained in:
2023-01-02 20:00:29 +01:00
parent a107bdf69e
commit b9d8dbe0a2
22 changed files with 577 additions and 587 deletions

View File

@ -2,38 +2,38 @@ from __future__ import annotations
from typing import Optional, MutableSequence, Iterable
# Import all decorators
import decorators
import EasyGA.decorators
# Import all the data structure prebuilt modules
from structure import Population as make_population
from structure import Chromosome as make_chromosome
from structure import Gene as make_gene
from structure import Population
from structure import Chromosome
from structure import Gene
from EasyGA.structure import Population as make_population
from EasyGA.structure import Chromosome as make_chromosome
from EasyGA.structure import Gene as make_gene
from EasyGA.structure import Population
from EasyGA.structure import Chromosome
from EasyGA.structure import Gene
# Misc. Methods
from examples import Fitness
from termination import Termination
from EasyGA.examples import Fitness
from EasyGA.termination import Termination
# Parent/Survivor Selection Methods
from parent import Parent
from survivor import Survivor
from EasyGA.parent import Parent
from EasyGA.survivor import Survivor
# Genetic Operator Methods
from crossover import Crossover
from mutation import Mutation
from EasyGA.crossover import Crossover
from EasyGA.mutation import Mutation
# Default Attributes for the GA
from attributes import Attributes
from EasyGA.attributes import Attributes
# Database class
# from database import SQLDatabase
# from sqlite3 import Error
from EasyGA.database import sql_database
from sqlite3 import Error
# Graphing package
# from database import MatplotlibGraph
# import matplotlib.pyplot as plt
from EasyGA.database import matplotlib_graph
import matplotlib.pyplot as plt
class GA(Attributes):
@ -46,6 +46,7 @@ class GA(Attributes):
https://github.com/danielwilczak101/EasyGA/wiki
"""
def evolve(self: GA, number_of_generations: float = float('inf'), consider_termination: bool = True) -> None:
"""
Evolves the ga until the ga is no longer active.
@ -62,12 +63,9 @@ class GA(Attributes):
if self.population is None:
self.initialize_population()
# Evolve the specified number of generations.
def cond1(): return number_of_generations > 0
# If consider_termination flag is set:
def cond2(): return not consider_termination
# check termination conditions.
def cond3(): return cond2() or self.active()
cond1 = lambda: number_of_generations > 0 # Evolve the specified number of generations.
cond2 = lambda: not consider_termination # If consider_termination flag is set:
cond3 = lambda: cond2() or self.active() # check termination conditions.
while cond1() and cond3():
@ -76,11 +74,10 @@ class GA(Attributes):
# Create the database here to allow the user to change the
# database name and structure before running the function.
# self.database.create_all_tables(self)
self.database.create_all_tables(self)
# Add the current configuration to the config table
# self.database.insert_config(self)
pass
self.database.insert_config(self)
# Otherwise evolve the population.
else:
@ -96,8 +93,7 @@ class GA(Attributes):
self.sort_by_best_fitness()
# Save the population to the database
if self.save_data:
self.save_population()
self.save_population()
# Adapt the ga if the generation times the adapt rate
# passes through an integer value.
@ -105,9 +101,10 @@ class GA(Attributes):
if int(adapt_counter) < int(adapt_counter + self.adapt_rate):
self.adapt()
number_of_generations -= 1
number_of_generations -= 1
self.current_generation += 1
def update_population(self: GA) -> None:
"""
Updates the population to the new population
@ -115,6 +112,7 @@ class GA(Attributes):
"""
self.population.update()
def reset_run(self: GA) -> None:
"""
Resets a run by re-initializing the
@ -124,6 +122,7 @@ class GA(Attributes):
self.current_generation = 0
self.run += 1
def adapt(self: GA) -> None:
"""Adapts the ga to hopefully get better results."""
@ -134,6 +133,7 @@ class GA(Attributes):
self.set_all_fitness()
self.sort_by_best_fitness()
def adapt_probabilities(self: GA) -> None:
"""
Modifies the parent ratio and mutation rates based on the adapt
@ -153,7 +153,7 @@ class GA(Attributes):
# Difference between best and i-th chromosomes
best_chromosome = self.population[0]
def tol(i): return self.dist(best_chromosome, self.population[i])
tol = lambda i: self.dist(best_chromosome, self.population[i])
# Too few converged: cross more and mutate less
if tol(amount_converged//2) > tol(amount_converged//4)*2:
@ -168,14 +168,13 @@ class GA(Attributes):
self.max_gene_mutation_rate)
# Weighted average of x and y
def average(x, y): return weight * x + (1-weight) * y
average = lambda x, y: weight * x + (1-weight) * y
# Adjust rates towards the bounds
self.selection_probability = average(
bounds[0], self.selection_probability)
self.chromosome_mutation_rate = average(
bounds[1], self.chromosome_mutation_rate)
self.gene_mutation_rate = average(bounds[2], self.gene_mutation_rate)
self.selection_probability = average(bounds[0], self.selection_probability)
self.chromosome_mutation_rate = average(bounds[1], self.chromosome_mutation_rate)
self.gene_mutation_rate = average(bounds[2], self.gene_mutation_rate)
def adapt_population(self: GA) -> None:
"""
@ -202,7 +201,7 @@ class GA(Attributes):
self.crossover_individual_impl(
self.population[n],
parent,
weight=-3/4,
weight = -3/4,
)
# If negative weights can't be used or division by 0, use positive weight
@ -210,7 +209,7 @@ class GA(Attributes):
self.crossover_individual_impl(
self.population[n],
parent,
weight=+1/4,
weight = +1/4,
)
# Stop if we've filled up an entire population
@ -218,19 +217,20 @@ class GA(Attributes):
break
# Replace worst chromosomes with new chromosomes, except for the previous best chromosome
min_len = min(len(self.population)-1,
len(self.population.next_population))
min_len = min(len(self.population)-1, len(self.population.next_population))
if min_len > 0:
self.population[-min_len:] = self.population.next_population[:min_len]
self.population.next_population = []
self.population.mating_pool = []
def initialize_population(self: GA) -> None:
"""
Sets self.population using the chromosome implementation and population size.
"""
self.population = self.make_population(self.population_impl())
def set_all_fitness(self: GA) -> None:
"""
Sets the fitness of each chromosome in the population.
@ -251,14 +251,15 @@ class GA(Attributes):
if chromosome.fitness is None or self.update_fitness:
chromosome.fitness = self.fitness_function_impl(chromosome)
def sort_by_best_fitness(
self: GA,
chromosome_list: Optional[
Union[MutableSequence[Chromosome],
Iterable[Chromosome]]
] = None,
in_place: bool = True,
) -> MutableSequence[Chromosome]:
self: GA,
chromosome_list: Optional[
Union[MutableSequence[Chromosome],
Iterable[Chromosome]]
] = None,
in_place: bool = True,
) -> MutableSequence[Chromosome]:
"""
Sorts the chromosome list by fitness based on fitness type.
1st element has best fitness.
@ -313,6 +314,7 @@ class GA(Attributes):
else:
return sorted(chromosome_list, key=key, reverse=reverse)
def get_chromosome_fitness(self: GA, index: int) -> float:
"""
Computes the converted fitness of a chromosome at an index.
@ -336,6 +338,7 @@ class GA(Attributes):
"""
return self.convert_fitness(self.population[index].fitness)
def convert_fitness(self: GA, fitness: float) -> float:
"""
Calculates a modified version of the fitness for various
@ -372,19 +375,23 @@ class GA(Attributes):
return max_fitness - fitness + min_fitness
def print_generation(self: GA) -> None:
"""Prints the current generation."""
print(f"Current Generation \t: {self.current_generation}")
def print_population(self: GA) -> None:
"""Prints the entire population."""
print(self.population)
def print_best_chromosome(self: GA) -> None:
"""Prints the best chromosome and its fitness."""
print(f"Best Chromosome \t: {self.population[0]}")
print(f"Best Fitness \t: {self.population[0].fitness}")
def print_worst_chromosome(self: GA) -> None:
"""Prints the worst chromosome and its fitness."""
print(f"Worst Chromosome \t: {self.population[-1]}")

View File

@ -0,0 +1,2 @@
import EasyGA
from .EasyGA import GA

View File

@ -1,29 +1,124 @@
from __future__ import annotations
from inspect import getmro, signature
from typing import Any, Callable, Dict, Iterable, Iterator, Optional
from inspect import signature
from typing import Callable, Optional, Iterable, Any, Dict
from math import sqrt, ceil
from dataclasses import dataclass, field, _MISSING_TYPE
from dataclasses import dataclass, field
from types import MethodType
import random
# import sqlite3
# import matplotlib.pyplot as plt
import sqlite3
import matplotlib.pyplot as plt
from structure import Population
from structure import Chromosome
from structure import Gene
from EasyGA.structure import Population
from EasyGA.structure import Chromosome
from EasyGA.structure import Gene
from examples import Fitness
from termination import Termination
from parent import Parent
from survivor import Survivor
from crossover import Crossover
from mutation import Mutation
# from database import SQLDatabase, MatplotlibGraph, SQLDatabase as Database, MatplotlibGraph as Graph
from EasyGA.examples import Fitness
from EasyGA.termination import Termination
from EasyGA.parent import Parent
from EasyGA.survivor import Survivor
from EasyGA.crossover import Crossover
from EasyGA.mutation import Mutation
from EasyGA.database import sql_database, matplotlib_graph
#========================================#
# Default methods not defined elsewhere. #
#========================================#
@dataclass
class Attributes:
"""
Attributes class which stores all attributes in a dataclass.
Contains default attributes for each attribute.
"""
properties: Dict[str, Any] = field(default_factory=dict, init=False, repr=False, compare=False)
run: int = 0
chromosome_length: int = 10
population_size: int = 10
population: Optional[Population] = None
target_fitness_type: str = 'max'
update_fitness: bool = False
parent_ratio: float = 0.1
selection_probability: float = 0.5
tournament_size_ratio: float = 0.1
current_generation: int = 0
generation_goal: int = 100
fitness_goal: Optional[float] = None
tolerance_goal: Optional[float] = None
percent_converged: float = 0.5
chromosome_mutation_rate: float = 0.15
gene_mutation_rate: float = 0.05
adapt_rate: float = 0.05
adapt_probability_rate: float = 0.05
adapt_population_flag: bool = True
max_selection_probability: float = 0.75
min_selection_probability: float = 0.25
max_chromosome_mutation_rate: float = None
min_chromosome_mutation_rate: float = None
max_gene_mutation_rate: float = 0.15
min_gene_mutation_rate: float = 0.01
fitness_function_impl: Callable[[Attributes, Chromosome], float] = Fitness.is_it_5
make_population: Callable[[Iterable[Iterable[Any]]], Population] = Population
make_chromosome: Callable[[Iterable[Any]], Chromosome] = Chromosome
make_gene: Callable[[Any], Gene] = Gene
gene_impl: Callable[[Attributes], Any] = field(default_factory=lambda: rand_1_to_10)
chromosome_impl: Optional[[Attributes], Iterable[Any]] = field(default_factory=lambda: use_genes)
population_impl: Optional[[Attributes], Iterable[Iterable[Any]]] = field(default_factory=lambda: use_chromosomes)
weighted_random: Callable[[Attributes, float], float] = field(default_factory=lambda: simple_linear)
dist: Callable[[Attributes, Chromosome, Chromosome], float] = field(default_factory=lambda: dist_fitness)
parent_selection_impl: Callable[[Attributes], None] = Parent.Rank.tournament
crossover_individual_impl: Callable[[Attributes], None] = Crossover.Individual.single_point
crossover_population_impl: Callable[[Attributes], None] = Crossover.Population.sequential
survivor_selection_impl: Callable[[Attributes], None] = Survivor.fill_in_best
mutation_individual_impl: Callable[[Attributes], None] = Mutation.Individual.individual_genes
mutation_population_impl: Callable[[Attributes], None] = Mutation.Population.random_avoid_best
termination_impl: Callable[[Attributes], None] = Termination.fitness_generation_tolerance
database: Database = field(default_factory=sql_database.SQL_Database)
database_name: str = 'database.db'
sql_create_data_structure: str = """
CREATE TABLE IF NOT EXISTS data (
id INTEGER PRIMARY KEY,
config_id INTEGER DEFAULT NULL,
generation INTEGER NOT NULL,
fitness REAL,
chromosome TEXT
);
"""
graph: Callable[[Database], Graph] = matplotlib_graph.Matplotlib_Graph
#============================#
# Built-in database methods: #
#============================#
def save_population(self: Attributes) -> None:
"""Saves the current population to the database."""
self.database.insert_current_population(self)
def save_chromosome(self: Attributes, chromosome: Chromosome) -> None:
"""
Saves a chromosome to the database.
Parameters
----------
chromosome : Chromosome
The chromosome to be saved.
"""
self.database.insert_current_chromosome(self.current_generation, chromosome)
def rand_1_to_10(self: Attributes) -> int:
@ -38,7 +133,7 @@ def rand_1_to_10(self: Attributes) -> int:
return random.randint(1, 10)
def use_genes(self: Attributes) -> Iterator[Any]:
def use_genes(self: Attributes) -> Iterable[Any]:
"""
Default chromosome_impl, generates a chromosome using the gene_impl and chromosome length.
@ -51,14 +146,14 @@ def use_genes(self: Attributes) -> Iterator[Any]:
Returns
-------
chromosome : Iterator[Any]
chromosome : Iterable[Any]
Generates the genes for a chromosome.
"""
for _ in range(self.chromosome_length):
yield self.gene_impl()
def use_chromosomes(self: Attributes) -> Iterator[Iterable[Any]]:
def use_chromosomes(self: Attributes) -> Iterable[Any]:
"""
Default population_impl, generates a population using the chromosome_impl and population size.
@ -71,7 +166,7 @@ def use_chromosomes(self: Attributes) -> Iterator[Iterable[Any]]:
Returns
-------
population : Iterator[Iterable[Any]]
population : Iterable[Iterable[Any]]
Generates the chromosomes for a population.
"""
for _ in range(self.population_size):
@ -117,339 +212,231 @@ def simple_linear(self: Attributes, weight: float) -> float:
return 1 - (1-rand) * weight / (1-weight)
@dataclass
class AttributesData:
#==================================================#
# Properties for attributes behaving like methods. #
#==================================================#
def get_method(name: str) -> Callable[[Attributes], Callable[..., Any]]:
"""
Attributes class which stores all attributes in a dataclass.
This includes type-hints/annotations and default values, except for methods.
Creates a getter method for getting a method from the Attributes class.
Additionally gains dataclass features, including an __init__ and __repr__ to avoid boilerplate code.
Parameters
----------
name : str
The name of the method from Attributes.
Developer Notes:
See the Attributes class for default methods.
Override this class to set default attributes. See help(Attributes) for more information.
If you must override the __post_init__, don't forget to use super().__post_init__().
Returns
-------
getter(ga)(...) -> Any
The getter property, taking in an object and returning the method.
"""
run: int = 0
chromosome_length: int = 10
population_size: int = 10
population: Optional[Population] = None
target_fitness_type: str = 'max'
update_fitness: bool = False
parent_ratio: float = 0.1
selection_probability: float = 0.5
tournament_size_ratio: float = 0.1
current_generation: int = 0
generation_goal: int = 100
fitness_goal: Optional[float] = None
tolerance_goal: Optional[float] = None
percent_converged: float = 0.5
chromosome_mutation_rate: float = 0.15
gene_mutation_rate: float = 0.05
adapt_rate: float = 0.05
adapt_probability_rate: float = 0.05
adapt_population_flag: bool = True
max_selection_probability: float = 0.75
min_selection_probability: float = 0.25
max_chromosome_mutation_rate: float = None
min_chromosome_mutation_rate: float = None
max_gene_mutation_rate: float = 0.15
min_gene_mutation_rate: float = 0.01
#=================================#
# Default methods are implemented #
# in the Attributes descriptors: #
#=================================#
fitness_function_impl: Callable[["Attributes", Chromosome], float] = None
make_gene: Callable[[Any], Gene] = None
make_chromosome: Callable[[Iterable[Any]], Chromosome] = None
make_population: Callable[[Iterable[Iterable[Any]]], Population] = None
gene_impl: Callable[[], Any] = None
chromosome_impl: Callable[[], Iterable[Any]] = None
population_impl: Callable[[], Iterable[Iterable[Any]]] = None
weighted_random: Callable[[float], float] = None
dist: Callable[["Attributes", Chromosome, Chromosome], None] = None
parent_selection_impl: Callable[["Attributes"], None] = None
crossover_individual_impl: Callable[["Attributes"], None] = None
crossover_population_impl: Callable[[
"Attributes", Chromosome, Chromosome], None] = None
survivor_selection_impl: Callable[["Attributes"], None] = None
mutation_individual_impl: Callable[["Attributes", Chromosome], None] = None
mutation_population_impl: Callable[["Attributes"], None] = None
termination_impl: Callable[["Attributes"], bool] = None
# database: Database = field(default_factory=SQLDatabase)
#database_name: str = "database.db"
#save_data: bool = True
# sql_create_data_structure: str = """
# CREATE TABLE IF NOT EXISTS data (
# id INTEGER PRIMARY KEY,
# config_id INTEGER DEFAULT NULL,
# generation INTEGER NOT NULL,
# fitness REAL,
# chromosome TEXT
# );
# """
# graph: Callable[[Database], Graph] = MatplotlibGraph
def __post_init__(self: AttributesData) -> None:
"""
Undo any instance attributes that are None when they should be methods from the class.
Attributes here refers to the __dataclass_fields__.
Methods here refers to AsMethod descriptors on any of the super classes of self's class.
"""
def is_method(cls: type, name: str) -> bool:
"""
The class has the attribute `name` as a method if:
- it has the attribute,
- and it's the AsMethod descriptor.
"""
return hasattr(cls, name) and isinstance(getattr(cls, name), AsMethod)
# Check each dataclass attribute.
for name in self.__dataclass_fields__:
# If the instance attribute is None
# and any of the super classes has that as a method,
# then delete the None instance attribute.
if (
getattr(self, name) is None
and any(is_method(cls, name) for cls in getmro(type(self)))
):
delattr(self, name)
def getter(self: Attributes) -> Callable[..., Any]:
return self.properties[name]
return getter
class AsMethod:
def set_method(name: str) -> Callable[[Attributes, Optional[Callable[..., Any]]], None]:
"""
A descriptor for converting function attributes into bound methods.
Creates a setter method for setting a method from the Attributes class.
To support both inheritance and dataclasses, if the method is None,
then nothing is set.
Parameters
----------
name : str
The name of the method from Attributes.
Returns
-------
setter(ga, method)
The setter property, taking in an object and returning nothing.
"""
def __init__(self: AsMethod, name: str, default: Callable) -> None:
if not callable(default):
raise TypeError(f"'default' must be a method i.e. callable.")
self.name = name
self.default = default
def __get__(self: AsMethod, obj: "Attributes", cls: type) -> Callable:
# Already has the attribute on the object.
if self.name in vars(obj):
return vars(obj)[self.name]
# Otherwise use the default as a method.
if next(iter(signature(self.default).parameters), None) in ("self", "ga"):
return MethodType(self.default, obj)
# Otherwise use the default as a function.
return self.default
def __set__(self: AsMethod, obj: "Attributes", method: Optional[Callable]) -> None:
def setter(self: Attributes, method: Optional[Callable[..., Any]]) -> None:
if method is None:
return
pass
elif not callable(method):
raise TypeError(f"'{self.name}' must be a method i.e. callable.")
raise TypeError(f"{name} must be a method i.e. callable.")
elif next(iter(signature(method).parameters), None) in ("self", "ga"):
method = MethodType(method, obj)
vars(obj)[self.name] = method
def __delete__(self: AsMethod, obj: "Attributes") -> None:
del vars(obj)[self.name]
method = MethodType(method, self)
self.properties[name] = method
return setter
class Attributes(AttributesData):
for name in (
"fitness_function_impl",
"parent_selection_impl",
"crossover_individual_impl",
"crossover_population_impl",
"survivor_selection_impl",
"mutation_individual_impl",
"mutation_population_impl",
"termination_impl",
"dist",
"weighted_random",
"gene_impl",
"chromosome_impl",
"population_impl",
):
setattr(Attributes, name, property(get_method(name), set_method(name)))
#============================#
# Static checking properties #
#============================#
static_checks = {
"run": {
"check": lambda value: isinstance(value, int) and value >= 0,
"error": "ga.run counter must be an integer greater than or equal to 0.",
},
"current_generation": {
"check": lambda value: isinstance(value, int) and value >= 0,
"error": "ga.current_generation must be an integer greater than or equal to 0",
},
"chromosome_length": {
"check": lambda value: isinstance(value, int) and value > 0,
"error": "ga.chromosome_length must be an integer greater than and not equal to 0.",
},
"population_size": {
"check": lambda value: isinstance(value, int) and value > 0,
"error": "ga.population_size must be an integer greater than and not equal to 0.",
},
}
def get_attr(name: str) -> Callable[[Attributes], Any]:
"""
The Attributes class inherits default attributes from AttributesData
and implements methods, descriptors, and properties.
Creates a getter method for getting an attribute from the Attributes class.
The built-in methods provide interfacing to the database.
>>> ga.save_population() # references ga.database.insert_current_population(ga)
The descriptors are used to convert function attributes into methods.
>>> ga.gene_impl = lambda self: ... # self is turned into an implicit argument.
The properties are used to validate certain inputs.
Parameters
----------
name : str
The name of the attribute.
Developer Notes:
If inherited, the descriptors may be overridden with a method implementation,
but this removes the descriptor.
To override default attributes, we recommend creating a dataclass inheriting AttributesData.
Then inherit the Attributes and AttributesDataSubclass, in that order.
>>> from dataclasses import dataclass
>>> @dataclass
>>> class MyDefaults(AttributesData):
... run: int = 10
...
>>> class MyAttributes(Attributes, MyDefaults):
... pass
...
Returns
-------
getter(ga) -> Any
A getter method which returns an attribute.
"""
def getter(self: Attributes) -> Any:
return self.properties[name]
return getter
#============================#
# Built-in database methods: #
#============================#
def save_population(self: Attributes) -> None:
"""Saves the current population to the database."""
self.database.insert_current_population(self)
def set_attr(name: str, check: Callable[[Any], bool], error: str) -> Callable[[Attributes, Any], None]:
"""
Creates a setter method for setting an attribute from the Attributes class.
def save_chromosome(self: Attributes, chromosome: Chromosome) -> None:
"""
Saves a chromosome to the database.
Parameters
----------
name : str
The name of the attribute.
check(Any) -> bool
The condition needed to be passed for the attribute to be added.
error: str
An error message if check(...) turns False.
Parameters
----------
chromosome : Chromosome
The chromosome to be saved.
"""
self.database.insert_current_chromosome(
self.current_generation, chromosome)
#===========================#
# Descriptors which convert #
# functions into methods: #
#===========================#
fitness_function_impl = AsMethod("fitness_function_impl", Fitness.is_it_5)
make_gene = AsMethod("make_gene", Gene)
make_chromosome = AsMethod("make_chromosome", Chromosome)
make_population = AsMethod("make_population", Population)
gene_impl = AsMethod("gene_impl", rand_1_to_10)
chromosome_impl = AsMethod("chromosome_impl", use_genes)
population_impl = AsMethod("population_impl", use_chromosomes)
dist = AsMethod("dist", dist_fitness)
weighted_random = AsMethod("weighted_random", simple_linear)
parent_selection_impl = AsMethod(
"parent_selection_impl", Parent.Rank.tournament)
crossover_individual_impl = AsMethod(
"crossover_individual_impl", Crossover.Individual.single_point)
crossover_population_impl = AsMethod(
"crossover_population_impl", Crossover.Population.sequential)
survivor_selection_impl = AsMethod(
"survivor_selection_impl", Survivor.fill_in_best)
mutation_individual_impl = AsMethod(
"mutation_individual_impl", Mutation.Individual.individual_genes)
mutation_population_impl = AsMethod(
"mutation_population_impl", Mutation.Population.random_avoid_best)
termination_impl = AsMethod(
"termination_impl", Termination.fitness_generation_tolerance)
#=============#
# Properties: #
#=============#
@property
def run(self: AttributesProperties) -> int:
return vars(self)["run"]
@run.setter
def run(self: AttributesProperties, value: int) -> None:
if not isinstance(value, int) or value < 0:
raise ValueError(
"ga.run counter must be an integer greater than or equal to 0.")
vars(self)["run"] = value
@property
def current_generation(self: AttributesProperties) -> int:
return vars(self)["current_generation"]
@current_generation.setter
def current_generation(self: AttributesProperties, value: int) -> None:
if not isinstance(value, int) or value < 0:
raise ValueError(
"ga.current_generation must be an integer greater than or equal to 0")
vars(self)["current_generation"] = value
@property
def chromosome_length(self: AttributesProperties) -> int:
return vars(self)["chromosome_length"]
@chromosome_length.setter
def chromosome_length(self: AttributesProperties, value: int) -> None:
if not isinstance(value, int) or value <= 0:
raise ValueError(
"ga.chromosome_length must be an integer greater than and not equal to 0.")
vars(self)["chromosome_length"] = value
@property
def population_size(self: AttributesProperties) -> int:
return vars(self)["population_size"]
@population_size.setter
def population_size(self: AttributesProperties, value: int) -> None:
if not isinstance(value, int) or value <= 0:
raise ValueError(
"ga.population_size must be an integer greater than and not equal to 0.")
vars(self)["population_size"] = value
@property
def max_chromosome_mutation_rate(self: AttributesProperties) -> float:
# Default value.
if vars(self).get("max_chromosome_mutation_rate", None) is None:
return min(self.chromosome_mutation_rate * 2, (self.chromosome_mutation_rate + 1) / 2)
# Set value.
return vars(self)["max_chromosome_mutation_rate"]
@max_chromosome_mutation_rate.setter
def max_chromosome_mutation_rate(self: AttributesProperties, value: Optional[float]) -> None:
# Use default or a valid float.
if value is None or (isinstance(value, (float, int)) and 0 <= value <= 1):
vars(self)["max_chromosome_mutation_rate"] = value
Returns
-------
setter(ga, Any) -> None
Raises ValueError(error)
A setter method which saves to an attribute.
"""
def setter(self: Attributes, value: Any) -> Any:
if check(value):
self.properties[name] = value
else:
raise ValueError(
"Max chromosome mutation rate must be between 0 and 1")
raise ValueError(error)
return setter
@property
def min_chromosome_mutation_rate(self: AttributesProperties) -> float:
# Default value.
if vars(self).get("min_chromosome_mutation_rate", None) is None:
return max(self.chromosome_mutation_rate / 2, self.chromosome_mutation_rate * 2 - 1)
# Set value.
return vars(self)["min_chromosome_mutation_rate"]
@min_chromosome_mutation_rate.setter
def min_chromosome_mutation_rate(self: AttributesProperties, value: Optional[float]) -> None:
# Use default or a valid float.
if value is None or (isinstance(value, (float, int)) and 0 <= value <= 1):
vars(self)["min_chromosome_mutation_rate"] = value
else:
raise ValueError(
"Min chromosome mutation rate must be between 0 and 1")
for name in static_checks:
setattr(
Attributes,
name,
property(
get_attr(name),
set_attr(name, static_checks[name]["check"], static_checks[name]["error"]),
)
)
# @property
# def database_name(self: AttributesProperties) -> str:
# return vars(self)["database_name"]
# @database_name.setter
# def database_name(self: AttributesProperties, name: str) -> None:
# # Update the database's name.
# self.database._database_name = name
# # Set the attribute for itself.
# vars(self)["database_name"] = name
#==================#
# Other properties #
#==================#
# @property
# def graph(self: AttributesProperties) -> Graph:
# return vars(self)["graph"]
# @graph.setter
# def graph(self: AttributesProperties, graph: Callable[[Database], Graph]) -> None:
# vars(self)["graph"] = graph(self.database)
def get_max_chromosome_mutation_rate(self: Attributes) -> float:
return self._max_chromosome_mutation_rate
@property
def active(self: AttributesProperties) -> Callable[[], bool]:
return self.termination_impl
def set_max_chromosome_mutation_rate(self: Attributes, value: Optional[float]) -> None:
# Default value
if value is None:
self._max_chromosome_mutation_rate = min(
self.chromosome_mutation_rate * 2,
(self.chromosome_mutation_rate + 1) / 2,
)
# Otherwise check value
elif isinstance(value, (float, int)) and 0 <= value <= 1:
self._max_chromosome_mutation_rate = value
# Raise error
else:
raise ValueError("Max chromosome mutation rate must be between 0 and 1")
def get_min_chromosome_mutation_rate(self: Attributes) -> float:
return self._min_chromosome_mutation_rate
def set_min_chromosome_mutation_rate(self: Attributes, value: Optional[float]) -> None:
# Default value
if value is None:
self._min_chromosome_mutation_rate = max(
self.chromosome_mutation_rate / 2,
self.chromosome_mutation_rate * 2 - 1,
)
# Otherwise check value
elif isinstance(value, (float, int)) and 0 <= value <= 1:
self._min_chromosome_mutation_rate = value
# Raise error
else:
raise ValueError("Min chromosome mutation rate must be between 0 and 1")
def get_database_name(self: Attributes) -> str:
return self._database_name
def set_database_name(self: Attributes, name: str) -> None:
# Update the database class' name
self.database._database_name = name
# Set the attribute for itself
self._database_name = name
def get_graph(self: Attributes) -> Graph:
return self._graph
def set_graph(self: Attributes, graph: Callable[[Database], Graph]) -> None:
self._graph = graph(self.database)
def get_active(self: Attributes) -> Callable[[Attributes], None]:
return self.termination_impl
Attributes.max_chromosome_mutation_rate = property(get_max_chromosome_mutation_rate, set_max_chromosome_mutation_rate)
Attributes.min_chromosome_mutation_rate = property(get_min_chromosome_mutation_rate, set_min_chromosome_mutation_rate)
Attributes.database_name = property(get_database_name, set_database_name)
Attributes.graph = property(get_graph, set_graph)
Attributes.active = property(get_active)

View File

@ -1,7 +1,7 @@
import random
# Import all crossover decorators
from decorators import _check_weight, _gene_by_gene
from EasyGA.decorators import _check_weight, _gene_by_gene
# Round to an integer near x with higher probability
# the closer it is to that integer.
@ -160,4 +160,3 @@ class Individual:
input_index += 1
ga.population.add_child(gene_list_1)

View File

@ -1,2 +0,0 @@
from .sql_database import SQLDatabase
from .matplotlib_graph import MatplotlibGraph

View File

@ -3,7 +3,7 @@ import matplotlib.pyplot as plt
import numpy as np
class MatplotlibGraph:
class Matplotlib_Graph:
"""Prebuilt graphing functions to make visual
represention of fitness data."""

View File

@ -1,24 +1,21 @@
import sqlite3
from sqlite3 import Error
from tabulate import tabulate
class SQLDatabase:
class SQL_Database:
"""Main database class that controls all the functionality for input /
out of the database using SQLite3."""
def __init__(self):
self.conn = None
self.config_id = None
self._database_name = 'database.db'
self.config_structure = """
CREATE TABLE IF NOT EXISTS config (
config_id INTEGER,
attribute_name TEXT,
attribute_value TEXT
);
"""
self.config_structure = f"""
CREATE TABLE IF NOT EXISTS config (
config_id INTEGER,
attribute_name TEXT,
attribute_value TEXT)"""
#=====================================#
@ -26,56 +23,102 @@ class SQLDatabase:
#=====================================#
def create_all_tables(self, ga):
"""Create the database if it doenst exist and then the data and config tables."""
# Create the database connection.
"""Create the database if it doenst exist and then the data and config
tables."""
# Create the database connection
self.create_connection()
# No connection.
if self.conn is None:
if self.conn is not None:
# Create data table
self.create_table(ga.sql_create_data_structure)
# Creare config table
self.create_table(self.config_structure)
# Set the config id
self.config_id = self.get_current_config()
else:
raise Exception("Error! Cannot create the database connection.")
# Create data table.
self.create_table(ga.sql_create_data_structure)
# Creare config table.
self.create_table(self.config_structure)
# Set the config id.
self.config_id = self.get_current_config()
def insert_config(self, ga):
"""
Insert the configuration attributes into the config.
Notes:
"Attributes" here refers to ga.__dataclass_fields__.keys(),
which allows the attributes to be customized.
Only attributes that are bool, float, int, or str will be used.
"""
def insert_config(self,ga):
"""Insert the configuration attributes into the config."""
# Get the current config and add one for the new config key
self.config_id = self.get_current_config()
# Setting the config_id index if there is no file
if self.config_id is None:
if self.config_id == None:
self.config_id = 0
else:
self.config_id = self.config_id + 1
# Getting all attribute fields from the attributes class
db_config = [
(self.config_id, attr_name, attr_value)
# Getting all the attributes from the attributes class
db_config_dict = (
(attr_name, getattr(ga, attr_name))
for attr_name
in ga.__dataclass_fields__
if isinstance((attr_value := getattr(ga, attr_name)), (bool, float, int, str))
]
in ga.__annotations__
if attr_name != "population"
)
# Types supported in the database
sql_type_list = [int, float, str]
# Loop through all attributes
for name, value in db_config_dict:
# not a function
if not callable(value):
# Convert to the right type
value = str(value)
if "'" not in value and '"' not in value:
# Insert into database
self.conn.execute(f"""
INSERT INTO config(config_id, attribute_name, attribute_value)
VALUES ('{self.config_id}', '{name}','{value}');""")
query = """
INSERT INTO config(config_id, attribute_name, attribute_value)
VALUES (?, ?, ?);
"""
self.conn.executemany(query, db_config)
self.config_id = self.get_current_config()
#=====================================#
# Decorators: #
#=====================================#
def default_config_id(method):
"""Decorator used to set the default config_id inside other functions."""
def new_method(self, config_id = None):
input_id = self.config_id if config_id is None else config_id
return method(self, input_id)
return new_method
def format_query_data(method):
"""Decorator used to format query data"""
def new_method(self, config_id):
query = method(self, config_id)
# Unpack elements if they are lists with only 1 element
if type(query[0]) in (list, tuple) and len(query[0]) == 1:
query = [i[0] for i in query]
# Unpack list if it is a list with only 1 element
if type(query) in (list, tuple) and len(query) == 1:
query = query[0]
return query
return new_method
#=====================================#
# Request information Queries: #
#=====================================#
@ -88,91 +131,79 @@ class SQLDatabase:
def past_runs(self):
"""Show a summerization of the past runs that the user has done."""
query_data = self.query_all("""
SELECT config_id, attribute_name, attribute_value
FROM config;
""")
query_data = self.query_all(f"""
SELECT config_id,attribute_name,attribute_value
FROM config;""")
table = tabulate(
query_data,
headers = [
'config_id',
'attribute_name',
'attribute_value',
]
print(
tabulate(
query_data,
headers = [
'config_id',
'attribute_name',
'attribute_value'
]
)
)
print(table)
return table
@default_config_id
def get_generation_total_fitness(self, config_id):
"""Get each generations total fitness sum from the database """
config_id = self.config_id if config_id is None else config_id
return self.query_all(f"""
SELECT SUM(fitness)
FROM data
WHERE config_id={config_id}
GROUP BY generation;
""")
SELECT SUM(fitness)
FROM data
WHERE config_id={config_id}
GROUP BY generation;""")
@default_config_id
def get_total_generations(self, config_id):
"""Get the total generations from the database"""
config_id = self.config_id if config_id is None else config_id
return self.query_one_item(f"""
SELECT COUNT(DISTINCT generation)
FROM data
WHERE config_id={config_id};
""")
SELECT COUNT(DISTINCT generation)
FROM data
WHERE config_id={config_id};""")
@default_config_id
def get_highest_chromosome(self, config_id):
"""Get the highest fitness of each generation"""
config_id = self.config_id if config_id is None else config_id
return self.query_all(f"""
SELECT max(fitness)
FROM data
WHERE config_id={config_id}
GROUP by generation;
""")
SELECT max(fitness)
FROM data
WHERE config_id={config_id}
GROUP by generation;""")
@default_config_id
def get_lowest_chromosome(self, config_id):
"""Get the lowest fitness of each generation"""
config_id = self.config_id if config_id is None else config_id
return self.query_all(f"""
SELECT min(fitness)
FROM data
WHERE config_id={config_id}
GROUP by generation;
""")
SELECT min(fitness)
FROM data
WHERE config_id={config_id}
GROUP by generation;""")
def get_all_config_id(self):
"""Get an array of all the DISTINCT config_id in the database"""
return self.query_all(f"""
SELECT DISTINCT config_id
FROM config;
""")
SELECT DISTINCT config_id
FROM config;""")
def get_each_generation_number(self, config_id):
def get_each_generation_number(self,config_id):
"""Get an array of all the generation numbers"""
return self.query_all(f"""
SELECT DISTINCT generation
FROM data
WHERE config_id={config_id};
""")
SELECT DISTINCT generation
FROM data
WHERE config_id={config_id};""")
@ -189,14 +220,12 @@ class SQLDatabase:
self.config_id,
generation,
chromosome.fitness,
repr(chromosome),
repr(chromosome)
)
# Create sql query structure
sql = """
INSERT INTO data(config_id, generation, fitness, chromosome)
VALUES(?, ?, ?, ?)
"""
sql = """INSERT INTO data(config_id, generation, fitness, chromosome)
VALUES(?,?,?,?)"""
cur = self.conn.cursor()
cur.execute(sql, db_chromosome)
@ -220,10 +249,8 @@ class SQLDatabase:
]
# Create sql query structure
sql = """
INSERT INTO data(config_id, generation, fitness, chromosome)
VALUES(?,?,?,?)
"""
sql = """INSERT INTO data(config_id, generation, fitness, chromosome)
VALUES(?,?,?,?)"""
cur = self.conn.cursor()
cur.executemany(sql, db_chromosome_list)
@ -236,7 +263,8 @@ class SQLDatabase:
#=====================================#
def create_connection(self):
"""Create a database connection to the SQLite database specified by db_file."""
"""Create a database connection to the SQLite database
specified by db_file."""
try:
self.conn = sqlite3.connect(self.database_name)
@ -244,7 +272,6 @@ class SQLDatabase:
self.conn = None
print(e)
def create_table(self, create_table_sql):
"""Create a table from the create_table_sql statement."""
@ -255,31 +282,22 @@ class SQLDatabase:
print(e)
def format_query_data(self, data):
"""Used to format query data."""
# Unpack elements if they are lists with only 1 element
if isinstance(data[0], (list, tuple)) and len(data[0]) == 1:
data = [i[0] for i in data]
# Unpack list if it is a list with only 1 element
if isinstance(data, (list, tuple)) and len(data) == 1:
data = data[0]
return data
@format_query_data
def query_all(self, query):
"""Query for muliple rows of data"""
cur = self.conn.cursor()
cur.execute(query)
return self.format_query_data(cur.fetchall())
return cur.fetchall()
@format_query_data
def query_one_item(self, query):
"""Query for single data point"""
cur = self.conn.cursor()
cur.execute(query)
return self.format_query_data(cur.fetchone())
return cur.fetchone()
def remove_database(self):
@ -287,6 +305,16 @@ class SQLDatabase:
os.remove(self._database_name)
def get_var_names(self, ga):
"""Returns a list of the names of attributes of the ga."""
# Loop through all attributes
for var in ga.__dict__.keys():
# Remove leading underscore
yield (var[1:] if (var[0] == '_') else var)
#=====================================#
# Setters and Getters: #
#=====================================#
@ -299,7 +327,7 @@ class SQLDatabase:
@database_name.setter
def database_name(self, value_input):
raise AttributeError("Invalid usage, please use ga.database_name instead.")
raise Exception("Invalid usage, please use ga.database_name instead.")
@property
@ -318,7 +346,7 @@ class SQLDatabase:
# If the connection doesnt exist then print error
except:
raise Exception("You are required to run a ga before you can connect to the database. Run ga.evolve() or ga.active()")
raise Exception("""You are required to run a ga before you can connect to the database. Run ga.evolve() or ga.active()""")
@conn.setter
@ -345,7 +373,7 @@ class SQLDatabase:
# If the config_id doesnt exist then print error
except:
raise Exception("You are required to run a ga before you can connect to the database. Run ga.evolve() or ga.active()")
raise Exception("""You are required to run a ga before you can connect to the database. Run ga.evolve() or ga.active()""")
@config_id.setter

View File

@ -1 +0,0 @@

View File

@ -2,7 +2,7 @@ import random
from math import ceil
# Import all mutation decorators
from decorators import _check_chromosome_mutation_rate, _check_gene_mutation_rate, _reset_fitness, _loop_random_mutations
from EasyGA.decorators import _check_chromosome_mutation_rate, _check_gene_mutation_rate, _reset_fitness, _loop_random_mutations
class Population:

View File

@ -1,7 +1,7 @@
import random
# Import all parent decorators
from decorators import _check_selection_probability, _check_positive_fitness, _ensure_sorted, _compute_parent_amount
from EasyGA.decorators import _check_selection_probability, _check_positive_fitness, _ensure_sorted, _compute_parent_amount
class Rank:
@ -14,8 +14,8 @@ class Rank:
@_compute_parent_amount
def tournament(ga, parent_amount):
"""
Will make tournaments of size tournament_size and choose the winner (best fitness)
from the tournament and use it as a parent for the next generation. The total number
Will make tournaments of size tournament_size and choose the winner (best fitness)
from the tournament and use it as a parent for the next generation. The total number
of parents selected is determined by parent_ratio, an attribute to the GA object.
May require many loops if the selection probability is very low.
"""

View File

@ -1,33 +0,0 @@
import EasyGA
import random
# Create the Genetic algorithm
ga = EasyGA.GA()
ga.save_data = False
def is_it_5(chromosome):
"""A very simple case test function - If the chromosomes gene value is a 5 add one
to the chromosomes overall fitness value."""
# Overall fitness value
fitness = 0
# For each gene in the chromosome
for gene in chromosome.gene_list:
# Check if its value = 5
if(gene.value == 5):
# If its value is 5 then add one to
# the overal fitness of the chromosome.
fitness += 1
return fitness
ga.fitness_function_impl = is_it_5
# Create random genes from 0 to 10
ga.gene_impl = lambda: random.randint(0, 10)
ga.evolve()
# Print your default genetic algorithm
ga.print_generation()
ga.print_population()

View File

@ -1,4 +1,4 @@
from structure import Gene as make_gene
from EasyGA.structure import Gene as make_gene
from itertools import chain
def to_gene(gene):
@ -107,17 +107,6 @@ class Chromosome():
return (to_gene(gene) in self.gene_list)
def __hash__(self):
"""
Returns hash(self).
Allows the user to use
{chromosome}
{chromosome: x}
or any other thing requiring hashes with chromosomes.
"""
return hash(tuple(self))
def __eq__(self, chromosome):
"""Returns self == chromosome, True if all genes match."""
return self.gene_list == chromosome.gene_list

View File

@ -1,4 +1,4 @@
from structure import Chromosome as make_chromosome
from EasyGA.structure import Chromosome as make_chromosome
from itertools import chain
def to_chromosome(chromosome):
@ -159,17 +159,6 @@ class Population:
return (to_chromosome(chromosome) in self.chromosome_list)
def __hash__(self):
"""
Returns hash(self).
Allows the user to use
{population}
{population: x}
or any other thing requiring hashes with populations.
"""
return hash(tuple(self))
def __eq__(self, population):
"""Returns self == population, True if all chromosomes match."""
return self.chromosome_list == population.chromosome_list

View File

@ -1,7 +1,7 @@
import random
# Import all survivor decorators
from decorators import *
# Import all survivor decorators
from EasyGA.decorators import *
def fill_in_best(ga):

View File

@ -1,5 +1,5 @@
# Import all termination decorators
from decorators import _add_by_fitness_goal, _add_by_generation_goal, _add_by_tolerance_goal
# Import all termination decorators
from EasyGA.decorators import _add_by_fitness_goal, _add_by_generation_goal, _add_by_tolerance_goal
@_add_by_fitness_goal
@_add_by_generation_goal

View File

@ -1,8 +1,13 @@
import random
from EasyGA import GA, Parent, Crossover, Mutation, Survivor, Termination
from EasyGA import GA
from parent import Parent
from crossover import Crossover
from mutation import Mutation
from survivor import Survivor
from termination import Termination
# USE THIS COMMAND WHEN TESTING -
# python3 -m pytest
# python3 -m pytest
# Tests can be broken down into three parts.
# - Testing correct size
@ -10,10 +15,11 @@ from EasyGA import GA, Parent, Crossover, Mutation, Survivor, Termination
# - Testing correct value
# - Testing integration with other functions
def test_population_size():
"""Test the population size is create correctly"""
for i in range(4,100):
for i in range(4, 100):
# Create the ga to test
ga = GA()
@ -26,11 +32,12 @@ def test_population_size():
# If they are not equal throw an error
assert int(len(ga.population)) == ga.population_size
def test_chromosome_length():
""" Test to see if the actual chromosome length is the same as defined."""
# Test from 0 to 100 chromosome length
for i in range(1,100):
for i in range(1, 100):
# Create the ga to test
ga = GA()
@ -43,14 +50,17 @@ def test_chromosome_length():
# If they are not equal throw an error
assert len(ga.population.chromosome_list[0]) == ga.chromosome_length
def test_gene_value():
""" """
pass
def test_initilization():
""" """
pass
def test_default():
# Create the Genetic algorithm
ga = GA()
@ -70,7 +80,7 @@ def test_attributes_gene_impl():
# Set necessary attributes
ga.population_size = 3
ga.chromosome_length = 5
ga.generation_goal = 1
ga.generation_goal = 1
# Set gene_impl
ga.gene_impl = lambda: random.randint(1, 10)
@ -89,14 +99,15 @@ def test_attributes_chromosome_impl_lambdas():
ga.gene_impl = None
# Set chromosome_impl
ga.chromosome_impl = lambda: [
random.randrange(1,100),
random.uniform(10,5),
random.choice(["up","down"])
]
random.randrange(1, 100),
random.uniform(10, 5),
random.choice(["up", "down"])
]
# Evolve the genetic algorithm
ga.evolve()
def test_attributes_chromosome_impl_functions():
# Create the Genetic algorithm
ga = GA()
@ -108,10 +119,10 @@ def test_attributes_chromosome_impl_functions():
# Create chromosome_impl user function
def user_chromosome_function():
chromosome_data = [
random.randrange(1,100),
random.uniform(10,5),
random.choice(["up","down"])
]
random.randrange(1, 100),
random.uniform(10, 5),
random.choice(["up", "down"])
]
return chromosome_data
# Set the chromosome_impl
@ -120,6 +131,7 @@ def test_attributes_chromosome_impl_functions():
# Evolve the genetic algorithm
ga.evolve()
def test_while_ga_active():
# Create the Genetic algorithm
ga = GA()
@ -142,7 +154,9 @@ def test_parent_selection_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.parent_selection_impl == Parent.Fitness.roulette) and (ga != None)
assert (ga.parent_selection_impl ==
Parent.Fitness.roulette) and (ga != None)
def test_crossover_population_impl():
# Create the Genetic algorithm
@ -154,7 +168,9 @@ def test_crossover_population_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.crossover_population_impl == Crossover.Population.sequential_selection) and (ga != None)
assert (ga.crossover_population_impl ==
Crossover.Population.sequential_selection) and (ga != None)
def test_crossover_individual_impl():
# Create the Genetic algorithm
@ -166,7 +182,9 @@ def test_crossover_individual_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.crossover_individual_impl == Crossover.Individual.single_point) and (ga != None)
assert (ga.crossover_individual_impl ==
Crossover.Individual.single_point) and (ga != None)
def test_mutation_population_impl():
# Create the Genetic algorithm
@ -178,7 +196,9 @@ def test_mutation_population_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.mutation_population_impl == Mutation.Population.random_selection) and (ga != None)
assert (ga.mutation_population_impl ==
Mutation.Population.random_selection) and (ga != None)
def test_mutation_individual_impl():
# Create the Genetic algorithm
@ -190,7 +210,9 @@ def test_mutation_individual_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.mutation_individual_impl == Mutation.Individual.single_gene) and (ga != None)
assert (ga.mutation_individual_impl ==
Mutation.Individual.single_gene) and (ga != None)
def test_survivor_selection_impl():
# Create the Genetic algorithm
@ -202,7 +224,9 @@ def test_survivor_selection_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.survivor_selection_impl == Survivor.fill_in_random) and (ga != None)
assert (ga.survivor_selection_impl ==
Survivor.fill_in_random) and (ga != None)
def test_termination_impl():
# Create the Genetic algorithm
@ -214,4 +238,5 @@ def test_termination_impl():
# Evolve the genetic algorithm
ga.evolve()
assert (ga.termination_impl == Termination.fitness_and_generation_based) and (ga != None)
assert (ga.termination_impl ==
Termination.fitness_and_generation_based) and (ga != None)

View File

@ -22,8 +22,8 @@ setuptools.setup(
],
install_requires=[
# "matplotlib ~= 3.3.2",
# "pyserial ~= 3.4",
"pytest>=3.7",
"pyserial ~= 3.4",
# "pytest>=3.7",
"tabulate >=0.8.7"
],
)