"""Efficiently breed rats to an average weight of 50000 grams.
Use genetic algorithm on a mixed population of male and female rats.
"""
import time
import random
import statistics
[docs]class BreedRats:
"""Efficiently breed rats to an average weight of **target_wt**.
Use genetic algorithm on a mixed population of male and female rats.
Weights and number of each gender vary and can be set by modifying the
following:
Args:
num_males (int): Number of male rats in population.
Default is ``4``.
num_females (int): Number of female rats in population.
Default is ``16``.
target_wt (int): Target weight in grams. Default is ``50000``.
gen_limit (int): Generational cutoff to stop breeding program.
Default is ``500``.
"""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
# Limit is for instance attributes and public methods are 7 and 20,
# but analysis like this requires many constants.
# I am opting to make them modifiable in something that is isn't a
# dictionary.
# If there is a better way, please submit an issue for discussion.
def __init__(self, num_males: int = 4, num_females: int = 16,
target_wt: int = 50000, gen_limit: int = 500):
"""Initialize class."""
self._min_wt = 200
self._max_wt = 600
self._male_mode_wt = 300
self._female_mode_wt = 250
self._mut_odds = 0.01
self._mut_min = 0.5
self._mut_max = 1.2
self._litter_sz = 8
self._litters_per_yr = 10
self._num_males = num_males
self._num_females = num_females
self._target_wt = target_wt
self._gen_limit = gen_limit
@property
def min_wt(self):
"""int: Minimum weight of adult rat in initial population.
Default is ``200``.
"""
return self._min_wt
@min_wt.setter
def min_wt(self, value: int):
self._min_wt = value
@property
def max_wt(self):
"""int: Maximum weight of adult rat in initial population.
Default is ``600``.
"""
return self._max_wt
@max_wt.setter
def max_wt(self, value: int):
self._max_wt = value
@property
def male_mode_wt(self):
"""int: Most common adult male rat weight in initial population.
Default is ``300``.
"""
return self._male_mode_wt
@male_mode_wt.setter
def male_mode_wt(self, value: int):
self._male_mode_wt = value
@property
def female_mode_wt(self):
"""int: Most common adult female rat weight in initial population.
Default is ``250``.
"""
return self._female_mode_wt
@female_mode_wt.setter
def female_mode_wt(self, value: int):
self._female_mode_wt = value
@property
def mut_odds(self):
"""float: Probability of a mutation occurring in a pup.
Default is ``0.01``.
"""
return self._mut_odds
@mut_odds.setter
def mut_odds(self, value: float):
self._mut_odds = value
@property
def mut_min(self):
"""float: Scalar on rat weight of least beneficial mutation.
Default is ``0.5``.
"""
return self._mut_min
@mut_min.setter
def mut_min(self, value: float):
self._mut_min = value
@property
def mut_max(self):
"""float: Scalar on rat weight of most beneficial mutation.
Default is ``1.2``.
"""
return self._mut_max
@mut_max.setter
def mut_max(self, value: float):
self._mut_max = value
@property
def litter_sz(self):
"""int: Number of pups per pair of breeding rats.
Default is ``8``.
"""
return self._litter_sz
@litter_sz.setter
def litter_sz(self, value: int):
self._litter_sz = value
@property
def litters_per_yr(self):
"""int: Number of litters per year per pair of breeding rats.
Default is ``10``.
"""
return self._litters_per_yr
@litters_per_yr.setter
def litters_per_yr(self, value: int):
self._litters_per_yr = value
@property
def num_males(self):
"""int: Number of male rats in population.
Default is ``4``.
"""
return self._num_males
@num_males.setter
def num_males(self, value: int):
self._num_males = value
@property
def num_females(self):
"""int: Number of female rats in population.
Default is ``16``.
"""
return self._num_females
@num_females.setter
def num_females(self, value: int):
self._num_females = value
@property
def target_wt(self):
"""int: Target weight in grams.
Default is ``50000``.
"""
return self._target_wt
@target_wt.setter
def target_wt(self, value: int):
self._target_wt = value
@property
def gen_limit(self):
"""int: Generational cutoff to stop breeding program.
Default is ``500``.
"""
return self._gen_limit
@gen_limit.setter
def gen_limit(self, value: int):
self._gen_limit = value
[docs] def populate(self, pop_total: int, mode_wt: int) -> list:
"""Generate population with a triangular distribution of weights.
Use :py:mod:`~random.triangular` to generate a population with a
triangular distribution of weights based on **mode_wt**.
Args:
pop_total (int): Total number of rats in population.
mode_wt (int): Most common adult rat weight in initial population.
Returns:
List of triangularly distributed weights of a given rat population.
"""
return [int(random.triangular(self._min_wt, self._max_wt, mode_wt))
for _ in range(pop_total)]
[docs] def get_population(self, num_males: int = None,
num_females: int = None) -> dict:
"""Generate random population of rats.
Wraps :func:`populate` using **num_males** and **num_females**.
Args:
num_males (int): Number of males in population.
If :obj:`None`, defaults to instance value.
num_females (int): Number of females in population.
If :obj:`None`, defaults to instance value.
Returns:
Dictionary of lists with ``males`` and ``females`` as keys and
specimen weight in grams as values.
"""
if num_males is None:
num_males = self._num_males
if num_females is None:
num_females = self._num_females
population = {
'males': self.populate(num_males, self._male_mode_wt),
'females': self.populate(num_females, self._female_mode_wt)
}
return population
[docs] @staticmethod
def combine_values(dictionary: dict) -> list:
"""Combine dictionary values.
Combine values in a dictionary of lists into one list.
Args:
dictionary (dict): Dictionary of lists.
Returns:
List containing all values that were in **dictionary**.
"""
values = []
for value in dictionary.values():
values.extend(value)
return values
[docs] def measure(self, population: dict) -> float:
"""Measure average weight of population against target.
Calculate mean weight of **population** and divide by **target_wt** to
determine if goal has been met.
Args:
population (dict): Dictionary of lists with ``males`` and
``females`` as keys and specimen weight in grams as values.
Returns:
:py:obj:`float` representing decimal percentage of completion
where a value of ``1`` is ``100%``, or complete.
"""
mean = statistics.mean(self.combine_values(population))
return mean / self._target_wt
[docs] def select(self, population: dict) -> dict:
"""Select largest members of population.
Sort members in descending order, and then keep largest members up to
instance values for **num_males** and **num_females**.
Args:
population (dict): Dictionary of lists with ``males`` and
``females`` as keys and specimen weight in grams as values.
Returns:
Dictionary of lists of specified length of largest members of
**population**.
Examples:
>>> from src.ch07.c1_breed_rats import BreedRats
>>> sample_one = BreedRats(num_males = 4, num_females = 4)
>>> s1_population = sample_one.get_population(num_males = 5,
... num_females = 10)
>>> selected_population = sample_one.select(s1_population)
>>> print(selected_population)
{'males': [555, 444, 333, 222], 'females': [999, 888, 777, 666]}
"""
new_population = {'males': [], 'females': []}
for gender in population:
if gender == 'males':
new_population[gender].extend(
sorted(population[gender],
reverse=True)[:self._num_males])
else:
new_population[gender].extend(
sorted(population[gender],
reverse=True)[:self._num_females])
return new_population
[docs] def crossover(self, population: dict) -> dict:
"""Crossover genes among members (weights) of a population.
Breed **population** where each breeding pair produces a litter
of instance value for **litter_sz** pups. Pup's gender is assigned
randomly.
To accommodate mismatched pairs, breeding pairs are selected randomly,
and once paired, females are removed from the breeding pool while
males remain.
Args:
population (dict): Dictionary of lists with ``males`` and
``females`` as keys and specimen weight in grams as values.
Returns:
Dictionary of lists with ``males`` and ``females`` as keys and
pup weight in grams as values.
"""
males = population['males']
females = population['females'].copy()
litter = {'males': [], 'females': []}
while females:
male = random.choice(males)
female = random.choice(females)
for pup in range(self._litter_sz):
larger, smaller = male, female
if female > male:
larger, smaller = female, male
pup = random.randint(smaller, larger)
if random.choice([0, 1]):
litter['males'].append(pup)
else:
litter['females'].append(pup)
females.remove(female)
# Sort output for test consistency.
for value in litter.values():
value.sort()
return litter
[docs] def mutate(self, litter: dict) -> dict:
"""Randomly alter pup weights applying input odds as a scalar.
For each pup in **litter**, randomly decide if a floating point number
between instance values for **mut_min** and **mut_max** from
:py:mod:`~random.uniform` will be used as a scalar to modified their
weight.
Args:
litter (dict): Dictionary of lists with ``males`` and ``females``
as keys and specimen weight in grams as values.
Returns:
Same dictionary of lists with weights potentially modified.
"""
for gender in litter:
pups = litter[gender]
for index, pup in enumerate(pups):
if self._mut_odds >= random.random():
pups[index] = round(pup *
random.uniform(self._mut_min,
self._mut_max))
return litter
[docs] def simulate(self, population: dict) -> tuple:
"""Simulate genetic algorithm by breeding rats.
Using **population**, repeat cycle of measure, select, crossover,
and mutate until either **target_wt** or **gen_limit** are met.
Args:
population (dict): Dictionary of lists with ``males`` and
``females`` as keys and specimen weight in grams as values.
Returns:
Tuple containing list of average weights of generations and number
of generations.
Examples:
>>> from src.ch07.c1_breed_rats import BreedRats
>>> sample_one = BreedRats()
>>> s1_population = sample_one.get_population()
>>> ave_wt, generations = sample_one.simulate(s1_population)
>>> print(generations)
248
"""
generations = 0
ave_wt = []
match = self.measure(population)
while match < 1 and generations < self._gen_limit:
population = self.select(population)
litter = self.crossover(population)
litter = self.mutate(litter)
for gender in litter:
population[gender].extend(litter[gender])
match = self.measure(population)
print(f'Generation {generations} match: {match * 100:.4f}%')
ave_wt.append(int(statistics.mean(
self.combine_values(population))))
generations += 1
return ave_wt, generations
[docs]def main():
"""Demonstrate BreedRats class.
Use default values to run a demonstration simulation and display time
(in seconds) it took to run.
"""
start_time = time.time()
experiment = BreedRats()
population = experiment.get_population()
match = experiment.measure(population)
print(f'Initial population: {population}')
print(f'Initial population match: {match * 100}%')
print(f'Number of males, females to keep: {experiment.num_males}, '
f'{experiment.num_females}')
ave_wt, generations = experiment.simulate(population)
print(f'Average weight per generation: {ave_wt}')
print(f'\nNumber of generations: {generations}')
print(f'Number of years: {int(generations/experiment.litters_per_yr)}')
end_time = time.time()
duration = end_time - start_time
print(f'Runtime for this program was {duration} seconds.')
if __name__ == '__main__':
main()