Optimizing Marketing Budget with a Genetic Algorithm
Business owners face a critical question every month: "Where should I invest my marketing budget for the best possible return?" Throwing it all at Google Ads feels wrong. Spreading it evenly seems arbitrary. The truth is, finding the optimal mix is a complex puzzle, but it’s one that can be solved with a powerful, nature-inspired technique: the Genetic Algorithm (GA).
This article will break down the core business problem of diminishing returns and show, step-by-step, how a GA can find a near-perfect solution that maximizes your ROI.
The Law of Diminishing Returns
The single most important concept in budget allocation is the Law of Diminishing Returns [1]. In a marketing context, it states that for any given channel, the first dollar you spend will generate the highest return. Each subsequent dollar you invest in that same channel will generate a progressively smaller return, until you eventually hit a plateau where more spending yields almost no additional benefit.
Think of it like watering a plant. The first bit of water is essential and brings the plant to life. A little more helps it grow strong. But after a certain point, more water doesn't help—it just runs off or even drowns the plant.
We can model this relationship mathematically with a simple power function for each channel:
Where:
- is the return from channel i.
- is an effectiveness coefficient for the channel (a higher 'a' means the channel is generally more effective).
- is is the diminishing returns exponent for the channel (a value between 0 and 1). A value closer to 1 means slow decay, while a value closer to 0 means returns diminish very quickly.
The goal is to find the perfect allocation of our total budget across all channels to maximize the total ROI. Trying to solve this with a spreadsheet for five or more channels becomes a nightmare of trial-and-error. This is where a Genetic Algorithm shines.
The Genetic Algorithm
A Genetic Algorithm borrows its ideas from Darwinian evolution: survival of the fittest. Instead of evolving species, we evolve a population of potential solutions—in our case, different budget allocation strategies. The "fittest" strategies are the ones that produce the highest ROI.
Framing The Problem for GA
We need a way to represent a single solution. In a GA, this is called a chromosome. For our problem, a chromosome is simply a list (or array) of percentages representing the budget allocation for each channel.
If we have n channels, a chromosome is:
Subject to the constraint that the percentages must sum to 100%:
The Fitness Function
Next, we need a way to measure how "good" each chromosome is. This is the fitness function. Its job is to take a chromosome (a budget plan) and calculate its total ROI. This is the most critical part of the algorithm.
Using our earlier formula, the fitness of a chromosome given a total budget is:
The algorithm's entire goal is to find the chromosome C that maximizes this value.
The Evolutionary Cycle
The GA starts with a large, random population of chromosomes (e.g., 100 different random budget plans). Then, it iteratively refines this population through a cycle of selection, crossover, and mutation.
Selection
Just like in nature, the fittest individuals are more likely to survive and reproduce. We evaluate the fitness (total ROI) of every chromosome in the population. Then, we select the "parents" for the next generation, giving a strong preference to those with the highest scores.
Crossover (Breeding)
This step mimics biological reproduction. We take two "parent" chromosomes (two successful budget plans) and combine them to create a new "child" chromosome. A common method is arithmetic crossover, where the child is a weighted average of the parents.
Where α is a random weight between 0 and 1. This allows good traits from two successful strategies to be combined into a new, potentially superior strategy. The child's percentages are then re-normalized to ensure they sum to 1.0.
Mutation
To ensure we don't get stuck in a rut, we introduce a small chance of random mutation. This is a small, spontaneous change to a child chromosome. For our problem, a great mutation strategy is to pick two channels at random and move a small amount of budget from one to the other.
Where:
- is a random change
- is a mutation rate
- is a small portion of the budget
This explores new possibilities that couldn't be reached by simply combining existing plans.
This cycle repeats for hundreds of generations. With each generation, the average fitness of the population increases, and the algorithm converges on a solution that is very close to the true optimum.
From Guesswork to Data-Driven Strategy
By translating a complex business problem into the language of genetics and evolution, we can navigate an almost infinite landscape of possible solutions and find one that is mathematically optimized for success. It's a powerful example of how computational intelligence can provide a real competitive edge, moving your marketing strategy from intuition and guesswork to a truly data-driven approach.
Python Implementation
We'll use numpy
to implement the math and matplotlib
to visualise our findings.
! pip install -q numpy matplotlib
import numpy as np
Define Channles
First, let's define the marketing channels:
CHANNELS = [
'Facebook Ads',
'Google Ads',
'Email Marketing',
'Content Creation (SEO)',
'Trade Shows'
]
Generate ROI Models
Second, we generate random ROI models for each channel that fit the "diminishing returns" factor:
np.random.seed(69) # comment it to get actual results
def generate_roi_models(num_channels):
"""
Generates a set of realistic, non-linear ROI models for each channel.
This function simulates the "diminishing returns" effect. Each channel
has a different response curve.
The model used is: ROI = a * investment^b
- 'a' controls the overall effectiveness of the channel.
- 'b' (between 0 and 1) controls the rate of diminishing returns.
Returns a list of lambda functions, one for each channel.
"""
models = []
for _ in range(num_channels):
# Generate random parameters for a realistic, varied simulation
a = np.random.uniform(5, 15) # Overall effectiveness
b = np.random.uniform(0.4, 0.8) # Diminishing returns factor
models.append(lambda x, a=a, b=b: a * (x ** b))
return models
ROI_MODELS = generate_roi_models(len(CHANNELS))
len(ROI_MODELS)
5
Create a fitnes function
Our fitness function is a sum of percentages that should result to 1.0
:
def fitness_function(allocation, total_budget, roi_models):
"""
Calculates the total ROI for a given budget allocation.
"""
# An allocation is an array of percentages, e.g., [0.2, 0.3, 0.1, 0.3, 0.1]
investments = allocation * total_budget
total_roi = 0
for i in range(len(investments)):
total_roi += roi_models[i](investments[i])
return total_roi
Bootstrap Initial Population
def create_initial_population(pop_size, num_channels):
"""
Creates an initial population of random budget allocations.
Each individual (chromosome) is an array of percentages that sum to 1.0.
"""
population = np.random.rand(pop_size, num_channels)
# Normalize each row to ensure the allocations sum to 1
population = population / population.sum(axis=1, keepdims=True)
return population
Ranking Function
Using our fitness function we can score every chromosome in new population:
def rank_allocations(population, total_budget, roi_models):
"""Calculates fitness (ROI) for each allocation and ranks them."""
fitness_scores = {}
for i in range(len(population)):
roi = fitness_function(population[i], total_budget, roi_models)
# We want to maximize ROI, so fitness is the ROI itself
fitness_scores[i] = roi
# Sort by fitness score (ROI) in descending order
return sorted(fitness_scores.items(), key=lambda x: x[1], reverse=True)
Selection
We'll use elitizm and roulette selection to include random individuals.
Elitism is the straightforward inclusion of the best individuals of a generation in the population of the following generation [3].
Roulette selection is a stochastic selection method, where the probability for selection of an individual is proportional to its fitness [4].
def selection(ranked_population, population, elite_size):
"""
Selects parents for the next generation using elitism and roulette wheel selection.
"""
selection_results_indices = []
# Elitism: carry over the best individuals' indices to the next generation
for i in range(elite_size):
selection_results_indices.append(ranked_population[i][0])
# Prepare for Roulette Wheel Selection
fitness_sum = sum(score for _, score in ranked_population)
probabilities = [score / fitness_sum for _, score in ranked_population]
# Select the rest of the individuals using Roulette Wheel
for _ in range(len(population) - elite_size):
pick = np.random.choice(len(ranked_population), p=probabilities)
selection_results_indices.append(ranked_population[pick][0])
return [population[i] for i in selection_results_indices]
Crossover
Crossover is a proccess of "mating" in GA.
def crossover(parent1, parent2):
"""
Creates a child from two parents using arithmetic crossover.
The child is a weighted average of the two parents.
"""
# Choose a random weighting factor
alpha = np.random.random()
child = alpha * parent1 + (1 - alpha) * parent2
# Re-normalize the child to ensure its allocations sum to 1
child = child / np.sum(child)
return child
Breeding Function
Using crossover we breed a new population:
def breed_population(mating_pool, elite_size):
"""Creates the next generation through crossover."""
children = []
# Keep the elite individuals unchanged
for i in range(elite_size):
children.append(mating_pool[i])
# Breed the rest of the population
pool = np.random.permutation(mating_pool)
for i in range(len(mating_pool) - elite_size):
child = crossover(pool[i], pool[len(mating_pool)-i-1])
children.append(child)
return np.array(children)
Mutation
The mutation function introduces small changes in the individuals. We'll use this function on the new populations:
def mutate(individual, mutation_rate):
"""
Mutates an individual by reallocating a small portion of the budget
from one randomly chosen channel to another. This is a more robust
method for "sum-to-one" chromosomes as it preserves the sum.
"""
if np.random.random() < mutation_rate:
# Choose two distinct genes (channels) for reallocation
source_idx, dest_idx = np.random.choice(len(individual), 2, replace=False)
# Determine a small portion to reallocate (e.g., up to 20% of the source value)
reallocation_amount = individual[source_idx] * np.random.random() * 0.2
# Ensure the source doesn't go below zero
if individual[source_idx] - reallocation_amount >= 0:
individual[source_idx] -= reallocation_amount
individual[dest_idx] += reallocation_amount
return individual
def mutate_population(population, mutation_rate):
"""Applies mutation to the entire population."""
mutated_pop = [mutate(ind, mutation_rate) for ind in population]
return np.array(mutated_pop)
Creating New Generation
Let's combine our ranking function, breeding, and mutations together to create a new generation:
def new_generation(current_gen, elite_size, mutation_rate, total_budget, roi_models):
"""Evolves the population to produce the next generation."""
ranked_pop = rank_allocations(current_gen, total_budget, roi_models)
selected_pool = selection(ranked_pop, current_gen, elite_size)
children = breed_population(selected_pool, elite_size)
next_gen = mutate_population(children, mutation_rate)
return next_gen
Running the Genetic Algorithm
Let's define a function for running our GA:
- Create initial population
- Select best candidates
- Breed new generations until the ROI is maximized
*We use elitizm to select best individuals from a population.
def genetic_algorithm(num_channels, pop_size, elite_size, mutation_rate, generations, total_budget, roi_models):
"""The main function to run the genetic algorithm."""
population = create_initial_population(pop_size, num_channels)
initial_rank = rank_allocations(population, total_budget, roi_models)
initial_best_roi = initial_rank[0][1]
print(f"Initial best ROI: ${initial_best_roi:,.2f}\n")
progress = [initial_best_roi]
for i in range(generations):
population = new_generation(population, elite_size, mutation_rate, total_budget, roi_models)
current_best_roi = rank_allocations(population, total_budget, roi_models)[0][1]
progress.append(current_best_roi)
if (i % 50 == 0):
print(f"Generation {i}: Best ROI = ${current_best_roi:,.2f}\n")
final_rank = rank_allocations(population, total_budget, roi_models)
best_allocation_index = final_rank[0][0]
best_allocation = population[best_allocation_index]
final_roi = final_rank[0][1]
print(f"\nFinal ROI: ${final_roi:,.2f}\n")
return best_allocation, progress
Let's find the best allocations for a $10 000 budget:
TOTAL_BUDGET = 10000
POPULATION_SIZE = 100
ELITE_SIZE = 10
MUTATION_RATE = 0.05
GENERATIONS = 300
best_alloc, progress_data = genetic_algorithm(
num_channels=len(CHANNELS),
pop_size=POPULATION_SIZE,
elite_size=ELITE_SIZE,
mutation_rate=MUTATION_RATE,
generations=GENERATIONS,
total_budget=TOTAL_BUDGET,
roi_models=ROI_MODELS
)
Initial best ROI: $8,163.88
Generation 0: Best ROI = $8,163.88
Generation 50: Best ROI = $8,427.70
Generation 100: Best ROI = $8,528.30
Generation 150: Best ROI = $8,562.86
Generation 200: Best ROI = $8,588.74
Generation 250: Best ROI = $8,599.35
Final ROI: $8,602.06
Visualization
import matplotlib.pyplot as plt
def plot_results(channels, best_allocation, progress, total_budget):
"""Plots the progress and the final optimal budget allocation."""
plt.style.use('seaborn-v0_8-whitegrid')
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))
# Plot 1: ROI Improvement over Generations
ax1.plot(progress)
ax1.set_title("ROI Improvement Over Generations", fontsize=16)
ax1.set_xlabel("Generation", fontsize=12)
ax1.set_ylabel("Total Return on Investment ($)", fontsize=12)
# Plot 2: Final Optimal Budget Allocation
final_investments = best_allocation * total_budget
bars = ax2.bar(channels, final_investments, color=plt.cm.viridis(np.linspace(0, 1, len(channels))))
ax2.set_title("Optimal Marketing Budget Allocation", fontsize=16)
ax2.set_xlabel("Marketing Channel", fontsize=12)
ax2.set_ylabel("Budget Allocated ($)", fontsize=12)
ax2.tick_params(axis='x', rotation=45)
ax2.bar_label(bars, fmt='$%.2f', padding=3)
plt.tight_layout()
plt.show()
plot_results(CHANNELS, best_alloc, progress_data, TOTAL_BUDGET)
References
- [1] Wikipedia - Law of Diminishing Returns
- [2] Investopedia - What Is Stagflation, What Causes It, and Why Is It Bad?
- [3] Baeldung - Elitism in Evolutionary Algorithms
- [4] Baeldung - Roulette Selection in Genetic Algorithms
- [5] Luis Miralles-Pechuán, Hiram Ponce, Lourdes Martínez-Villaseñor - A novel methodology for optimizing display advertising campaigns using genetic algorithms