AI · PYTHON TUTORIAL
Build a Simple Genetic Algorithm in Python
Turn the genetic algorithm search loop into a runnable Python experiment, then test what each design choice actually does.

A genetic algorithm becomes easier to understand when you can run one, change a parameter and inspect the result. This tutorial builds a small evolutionary search in plain Python. You do not need a machine-learning framework, a graphics package or a training dataset. Every candidate is a list of bits, and its score is simply the number of ones it contains. That keeps the search mechanics visible.
The problem is deliberately easy: find a list containing only ones. You could solve it immediately by constructing that list yourself. We use it as a teaching exercise because it removes complicated domain rules and lets us examine selection, crossover, mutation and replacement independently. It is not a benchmark proving that genetic algorithms outperform simpler methods, and it is not a reconstruction of my earlier course-scheduling research.
Define a candidate and a fitness function
Our candidate has 24 positions, each containing zero or one. A list with eighteen ones receives a fitness of eighteen, while an all-one list receives the maximum score of twenty-four. This is a maximization problem. The representation also makes validity uncomplicated: changing a zero to one or exchanging a segment between parents still produces a valid candidate.
That convenience disappears in many real optimization tasks. A scheduling candidate might have to prevent two classes from using the same room, while a route must avoid visiting a city twice. Before reusing this code for another problem, write down how a candidate represents a solution and how its operators preserve or restore validity. Replacing the score alone is rarely enough.
Create a varied initial population
The example creates forty independently generated bit lists. A local random-number generator receives a seed so that repeated runs are reproducible in the same Python environment. Keeping that generator inside the function also avoids changing the random state of unrelated code. These are practical debugging decisions, not claims that a particular seed is especially effective.
Initial variety matters because the algorithm selects from what it has already found. If every starting list were identical, crossover would initially have little to rearrange. Mutation could introduce new values, but the search would begin with a much narrower set of possibilities. The teaching example uses a random start; real problems can also include carefully chosen initial candidates.
Select parents and create one child
For each parent, the algorithm samples three population members and keeps the one with the highest score. This is a small tournament. It favors better candidates without deterministically choosing the same global best every time. The two tournaments can choose the same parent, which is acceptable here and worth recognizing when you inspect the output.
A crossover point lies between the first and last positions. The child takes the first segment from one parent and the remaining segment from the other. We create a new list rather than editing either parent. With mutable data structures, an unnoticed shared reference can make the old population change while the new population is still being assembled. Copying the elite below serves the same purpose.
Run the complete example
Save this script as simple-ga.py and run it with Python 3. It uses only the standard library. You can also download the complete Python example. Each generation preserves one best candidate and fills the remaining positions with newly generated children.
import random
def evolve(seed=7, length=24, population_size=40, generations=200):
if length < 2 or population_size < 3 or generations < 0:
raise ValueError("Use length >= 2, population >= 3, generations >= 0")
rng = random.Random(seed)
population = [
[rng.randrange(2) for _ in range(length)]
for _ in range(population_size)
]
history = []
for generation in range(generations + 1):
best = max(population, key=sum)
history.append(sum(best))
if sum(best) == length or generation == generations:
return best[:], generation, history
# Copy the elite so later changes cannot modify its parent.
next_population = [best[:]]
while len(next_population) < population_size:
parent_a = max(rng.sample(population, 3), key=sum)
parent_b = max(rng.sample(population, 3), key=sum)
cut = rng.randrange(1, length)
child = parent_a[:cut] + parent_b[cut:]
child = [
1 - bit if rng.random() < 1 / length else bit
for bit in child
]
next_population.append(child)
population = next_population
raise AssertionError("Unreachable")
if __name__ == "__main__":
best, generation, history = evolve()
print("Best:", "".join(map(str, best)))
print("Fitness:", sum(best), "Generation:", generation)
Read the stopping conditions and mutation rate
The function stops when it finds the maximum score or reaches the configured generation limit. It returns the best candidate, the generation number and a history of best scores. The initial population is generation zero, so a limit of zero still evaluates that population. An explicit limit matters when you later use a problem whose optimum is unknown or whose evaluation is expensive.
Each bit independently has a one-in-twenty-four chance of flipping during mutation. That means approximately one changed bit per child on average, not exactly one. Some children remain unchanged and others receive several changes. Confusing a per-bit probability with a whole-candidate mutation probability is an easy way to make the search behave very differently from what you intended.
Elitism keeps one unmodified copy of the current best. For this deterministic scoring function, the recorded best score should therefore never decrease. The average score of the population can still fall. That distinction gives you a useful debugging check without suggesting that every member must improve at every step.
Make a small experiment instead of trusting one run
Run several seeds before drawing conclusions about a parameter. Record the final score and the generation at termination for each. A run that reaches the limit without finding all ones has still returned a valid candidate; label it as unfinished rather than treating the generation limit as a success. Increasing the population can change both the number of evaluations and the generation count, so compare computational effort as well.
Try disabling crossover, lowering mutation, or keeping more elites, changing one setting at a time. The point is to observe what each choice contributes. On this easy landscape, even a simple hill-climbing baseline can work extremely well. A useful comparison includes that baseline instead of assuming that a more elaborate algorithm deserves the credit.
What to carry into a real project
The reusable lesson is the separation of representation, evaluation, selection and variation. A realistic scheduling example needs additional constraints and a carefully defined objective. If two solutions have the same fitness, ask whether they are equally useful in practice or whether the score hides a tradeoff. Also consider how much time you spend evaluating candidates compared with generating them.
For background, read what a genetic algorithm is and selection, crossover and mutation. The DEAP One Max example documents the same teaching problem using a library; the compact implementation here was written separately for this tutorial.