Ad Selection¶
Problem Statement¶
Select which ads should be run on your website to maximize revenue, taking into account the associated cost and user experience.
Full example scenario
A backend engineer is tasked with optimizing the placement and selection of advertisements on a popular web platform. The goal is to maximize the click-through rate (CTR) and revenue generated from ads while ensuring a good user experience — not overwhelming visitors with too many ads, and keeping the ads shown relevant to them. Which ads should be shown on which pages, in order to maximize revenue?
The average CTR across all ads shown must remain above 2.5%.
No more than 3 ads may be shown on any single page.
At least 70% of the ads shown must match user interests.
The total cost of ads displayed must not exceed $500 per day.
The ad-page performance data can be found in
ad_selection.csv, with the columnsPage ID,Ad ID,CTR (%),Revenue per click ($),User Interest Match (Yes/No),Cost per ad ($).
What an optimal solution looks like
Color = ad id
Shape = page · dashed border = selected
Hover or focus a point for its exact CTR, revenue, cost and relevance. The number on each point is its ad id.
Selected ads per page (cap: 3 per page).
Page 1
Page 2
Page 3
Page 4
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same ad selection problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
We show ads on our website and want to decide which ads to display on each page. We want to maximize revenue, but we also need to keep the site usable for visitors and keep ad spend under control. Can you help me build a model for this?
This is an ad placement / selection optimization problem. Revenue mechanics vary a lot between ad businesses — per click, per impression, per conversion — and that choice drives the whole objective function, so it is the first thing to pin down before anything else.
Great topic! This is a well-known problem in the advertising and marketing industry, and Gurobi is well-suited for it. Let me ask a few focused questions to build a precise specification.
Question 1: How is revenue generated in your setup? Which of the following best describes your model?
Per click — revenue is earned each time a visitor clicks an ad (typically based on a click-through rate and a revenue-per-click value)
Per impression — revenue is earned each time an ad is displayed, regardless of clicks
Per conversion — revenue is earned when a visitor completes a specific action after clicking
Combination — a mix of the above
1. Revenue is per click - each ad has a click-through rate (CTR) and a revenue-per-click value, and revenue is CTR times revenue-per-click for each ad shown.
Some more constraints: we want the average CTR across everything we show to stay above 2.5%, we don’t want to show more than 3 ads on any single page, and total ad cost across the whole site should not exceed $500 per day.
Oh, and one more thing - at least 70% of the ads we actually show need to match the visitor’s stated interests. We don’t want to bombard people with irrelevant ads too often.
That is option 1, plus three numeric constraints and a fourth, volunteered one on relevance. Recording the objective and all four constraints. What is still open is the shape of the decision itself — whether an ad is placed at most once per page, shown some number of times, or assigned to a named slot — since that determines what the decision variable actually represents.
Good, that is saved. Now let me clarify a key structural aspect of the model:
Question 2: What is the nature of the decision you are making for each ad-page combination? Which best describes your setup?
Binary assignment — for each (ad, page) pair, you decide simply: show this ad on this page, yes or no
Frequency-based — you decide how many times to show the ad on that page per day
Selection with slots — each page has named slots (top banner, sidebar, footer), and you assign one ad per slot
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
The remaining clarifying questions settled the decision structure (a binary
show/don’t-show choice per ad-page pair), that every ad is a candidate for every
page, and that cost and relevance are both defined per (ad, page) pair rather
than per ad alone. With the shape of the problem pinned down, Modeler asked for
the actual numbers as a file rather than as 40 rows typed into the chat. Attach
ad_selection.csv to the conversation and it reads the real shape of your
data instead of guessing at it — entity names, column headers, units and all.
Page ID,Ad ID,CTR (%),Revenue per click ($),User Interest Match (Yes/No),Cost per ad ($)
1,1,2.5,0.50,Yes,20
1,2,3.3,0.55,No,15
1,3,1.7,0.60,Yes,25
1,4,2.0,0.48,No,18
1,5,3.4,0.62,Yes,26
1,6,2.3,0.42,Yes,12
1,7,2.1,0.54,No,20
1,8,2.7,0.65,Yes,28
1,9,3.1,0.53,No,24
1,10,1.8,0.57,Yes,22
2,1,2.2,0.45,Yes,30
2,2,2.9,0.50,No,10
2,3,3.2,0.55,Yes,22
2,4,2.6,0.65,Yes,28
2,5,3.0,0.53,No,24
2,6,1.6,0.50,Yes,16
2,7,3.5,0.60,Yes,30
2,8,2.4,0.47,No,18
2,9,2.8,0.68,Yes,32
2,10,1.9,0.40,No,14
3,1,3.3,0.52,No,20
3,2,1.9,0.57,Yes,15
3,3,3.1,0.50,Yes,16
3,4,2.8,0.68,No,30
3,5,2.9,0.38,Yes,10
3,6,3.0,0.51,No,12
3,7,1.8,0.62,Yes,25
3,8,2.0,0.55,No,18
3,9,2.7,0.63,Yes,27
3,10,2.3,0.63,No,27
4,1,3.2,0.40,Yes,12
4,2,2.4,0.55,No,28
4,3,2.6,0.60,Yes,30
4,4,3.4,0.51,Yes,14
4,5,1.6,0.70,No,32
4,6,2.5,0.45,Yes,20
4,7,3.0,0.52,No,16
4,8,1.9,0.61,Yes,24
4,9,2.8,0.50,No,22
4,10,2.3,0.56,Yes,26
ad_selection.csv attached
Here’s the actual ad-page data - cost and CTR are already page-specific in this file, and revenue-per-click is per ad but happens to be listed per row too. Columns are: Page ID, Ad ID, CTR (%), Revenue per click ($), User Interest Match (Yes/No), Cost per ad ($).
One row per (page, ad) pair, 4 pages by 10 ads, 40 rows in total — a flat table rather than a wide matrix, which matches a model where every ad is a candidate for every page. CTR is in percent and cost and revenue are both in dollars, consistent with what is already in the specification. Relevance is a Yes/No flag per row, confirming it is defined per (ad, page) pair rather than per ad globally. This is the complete input table — nothing else is still open.
Got it — 40 rows, one per (ad, page) pair, covering all 4 pages and all 10 ads. CTR is in percent and cost and revenue-per-click are both in dollars, so nothing needs rescaling against the thresholds already in the specification. I have added an Ad-page assignment entity to the specification’s Input section, with CTR, revenue-per-click, cost and a relevance flag on every row.
That was the last open item. Shall I proceed to Step 2: Model Implementation using Python with gurobipy?
Where your files live
Everything in the conversation — the file you attached and every artifact Modeler generates from it — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The ad-page data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
A website displays ads on its pages and wants to decide which ads to show on each page in order to maximize expected daily revenue from clicks. Revenue is earned per click, calculated as CTR multiplied by revenue-per-click for each ad shown. The model decides, for every (ad, page) pair, whether to display that ad on that page.
2. Objective
Maximize the total expected daily revenue from all ad placements across all pages.
3. Constraints
C1 — Maximum ads per page. No page may display more than 3 ads at a time, to keep the site usable for visitors.
C2 — Minimum total placements. The total number of ad placements across the entire site must be at least a configured minimum, currently 0. Left at its default it does not restrict the plan; it exists so a floor can be turned on later without a fresh round of questions.
C3 — Daily budget. The total cost of all placements across the entire site must not exceed $500 per day. Cost is charged per (ad, page) placement and can vary by both the ad and the page it appears on.
C4 — Minimum average CTR. The average CTR across all selected placements, site-wide, must be at least 2.5%, so the plan is not padded with ads visitors are unlikely to click.
C5 — Minimum relevance ratio. At least 70% of all selected placements must be marked as relevant to the visitor’s stated interests on that page, so visitors are not shown irrelevant ads too often.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Ad-page assignment |
page id, ad id, CTR (%), revenue per click ($), relevant (yes/no), cost ($) |
40 rows: 4 pages by 10 ads, supplied in |
Global parameters |
max ads per page, min total placements, daily budget, min average CTR, min relevance ratio |
3, 0, $500, 2.5%, 70% |
Every ad is a candidate for every page, so the model chooses from all 40 (ad, page) pairs rather than a pre-filtered subset.
5. Output
The selected (ad, page) placements.
Per-page metrics: number of ads shown, average CTR, total cost.
Site-wide totals: total placements, total cost, average CTR, relevance ratio, and total expected revenue.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — the data above, default parameters. |
Optimal. 12 placements (3 per page), $0.2145 expected revenue, $286 of $500 spent, average CTR 3.04%, relevance ratio 75%. |
T2 |
Daily budget tightened to $50. |
Optimal. 4 placements, cost lands exactly at the $50 ceiling, expected revenue $0.05836 — the budget constraint is now the binding one. |
T3 |
A synthetic 2-ad dataset where at least 1 placement is required, but no single ad can satisfy both the CTR and relevance floors at once. |
Reported as infeasible — no plan is returned. |
T4 |
Maximum ads per page tightened to 1. |
Optimal. 4 placements (one per page), $100 spent, expected revenue $0.07846. |
T5 |
Compare the per-page metrics and site-wide totals against the raw selected assignments. |
The two always agree — ads shown, average CTR and cost per page sum correctly to the site-wide totals. |
T6 |
Naive baseline: show every eligible ad on every page, no optimization at all (40 placements). |
Costs $858 (72% over budget) and falls to a 57.5% relevance ratio — it violates C1 and C3 outright and barely clears the CTR floor. The optimal plan is 29% more revenue-efficient per placement shown. |
7. Out of scope
Frequency-based or slot-based ad placement — an ad either appears on a page or it doesn’t, once per day.
Per-ad page caps — how many different pages a single ad is allowed to appear on.
Forbidden (ad, page) combinations — every ad is currently eligible for every page.
Click prediction or CTR estimation — CTR is a given input, not something the model forecasts.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version that came out of the run-and-fix loop in the Cloud solve
tab, after Modeler ran the tests and fixed what it found.
"""
Ad Assignment Optimization
--------------------------
Decides which ads to display on each page of a website to maximize
expected daily click revenue, subject to:
- At most max_ads_per_page ads per page
- At least min_total_ads placements site-wide
- Total placement cost within a daily budget
- Minimum average CTR across all placements
- Minimum fraction of relevant placements
"""
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
class AdAssignmentOptimizer:
"""
Optimizer that assigns ads to pages to maximize expected daily revenue
from clicks, while respecting per-page ad caps, a minimum total placement
count, a daily budget, a minimum average CTR, and a minimum relevance ratio.
"""
def __init__(self, env):
self.env = env
self.model = None
self.data = None
self.x = None
self.max_ads_per_page = 3
self.min_total_ads = 0
self.daily_budget = 500.0
self.min_avg_ctr = 2.5
self.min_relevance_ratio = 0.70
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.model is not None:
self.model.dispose()
return False
def set_data(self, df: pd.DataFrame,
max_ads_per_page: int = 3,
min_total_ads: int = 0,
daily_budget: float = 500.0,
min_avg_ctr: float = 2.5,
min_relevance_ratio: float = 0.70):
required = {"page_id", "ad_id", "ctr", "revenue_per_click", "is_relevant", "cost"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Input DataFrame is missing columns: {missing}")
self.data = df.copy()
self.max_ads_per_page = max_ads_per_page
self.min_total_ads = min_total_ads
self.daily_budget = daily_budget
self.min_avg_ctr = min_avg_ctr
self.min_relevance_ratio = min_relevance_ratio
def build_model(self):
self.model = gp.Model("AdAssignment", env=self.env)
self._create_variables()
self._set_objective()
self._add_constraints()
def _create_variables(self):
df = self.data
pairs = list(zip(df["page_id"], df["ad_id"]))
self.x = self.model.addVars(pairs, vtype=GRB.BINARY, name="assign")
def _set_objective(self):
df = self.data
self.model.setObjective(
gp.quicksum(
(row.ctr / 100.0) * row.revenue_per_click * self.x[row.page_id, row.ad_id]
for row in df.itertuples(index=False)
),
GRB.MAXIMIZE
)
def _add_constraints(self):
df = self.data
pages = df["page_id"].unique()
# C1: At most max_ads_per_page ads per page
for page in pages:
page_pairs = [(row.page_id, row.ad_id)
for row in df[df["page_id"] == page].itertuples(index=False)]
self.model.addConstr(
gp.quicksum(self.x[p] for p in page_pairs) <= self.max_ads_per_page,
name=f"max_ads_page_{page}"
)
# C2: Minimum total placements site-wide
self.model.addConstr(
gp.quicksum(self.x[row.page_id, row.ad_id]
for row in df.itertuples(index=False)) >= self.min_total_ads,
name="min_total_ads"
)
# C3: Total daily budget
self.model.addConstr(
gp.quicksum(row.cost * self.x[row.page_id, row.ad_id]
for row in df.itertuples(index=False)) <= self.daily_budget,
name="daily_budget"
)
# C4: Minimum average CTR
# sum((ctr - min_avg_ctr) * x) >= 0
self.model.addConstr(
gp.quicksum(
(row.ctr - self.min_avg_ctr) * self.x[row.page_id, row.ad_id]
for row in df.itertuples(index=False)
) >= 0,
name="min_avg_ctr"
)
# C5: Minimum relevance ratio
# sum((is_relevant - min_relevance_ratio) * x) >= 0
self.model.addConstr(
gp.quicksum(
(int(row.is_relevant) - self.min_relevance_ratio)
* self.x[row.page_id, row.ad_id]
for row in df.itertuples(index=False)
) >= 0,
name="min_relevance_ratio"
)
def solve(self):
if self.model is None:
raise RuntimeError("Model has not been built. Call build_model() first.")
self.model.optimize()
return self.model.Status
def get_solution(self) -> dict:
status = self.model.Status
is_optimal = status == GRB.OPTIMAL
if not is_optimal:
return {"status": status, "is_optimal": False,
"objective_value": None, "assignments": [],
"per_page_metrics": {}, "site_totals": {}}
df = self.data
assignments = []
for row in df.itertuples(index=False):
if self.x[row.page_id, row.ad_id].X > 0.5:
assignments.append({
"page_id": row.page_id, "ad_id": row.ad_id,
"ctr": row.ctr, "revenue_per_click": row.revenue_per_click,
"cost": row.cost, "is_relevant": bool(row.is_relevant)
})
per_page_metrics = {}
if assignments:
adf = pd.DataFrame(assignments)
for page_id, grp in adf.groupby("page_id"):
per_page_metrics[page_id] = {
"ads_shown": len(grp),
"avg_ctr": round(grp["ctr"].mean(), 4),
"total_cost": round(grp["cost"].sum(), 4)
}
if assignments:
adf = pd.DataFrame(assignments)
site_totals = {
"total_ads": len(adf),
"total_cost": round(adf["cost"].sum(), 4),
"avg_ctr": round(adf["ctr"].mean(), 4),
"relevance_ratio": round(adf["is_relevant"].mean(), 4)
}
else:
site_totals = {"total_ads": 0, "total_cost": 0.0,
"avg_ctr": 0.0, "relevance_ratio": 0.0}
return {
"status": status, "is_optimal": True,
"objective_value": round(self.model.ObjVal, 6),
"assignments": assignments,
"per_page_metrics": per_page_metrics,
"site_totals": site_totals
}
def display_results(self):
solution = self.get_solution()
if not solution["is_optimal"]:
print(f"No optimal solution found. Gurobi status: {solution['status']}")
return
print("=" * 60)
print("AD ASSIGNMENT OPTIMIZATION - RESULTS")
print("=" * 60)
print(f"Total Expected Daily Revenue: ${solution['objective_value']:.4f}")
print()
print("Selected Assignments:")
print(f" {'Page':>6} {'Ad':>5} {'CTR%':>6} {'Rev/Click':>10} {'Cost':>8} {'Relevant':>8}")
print(" " + "-" * 52)
for a in solution["assignments"]:
print(f" {a['page_id']:>6} {a['ad_id']:>5} {a['ctr']:>6.1f} "
f"{a['revenue_per_click']:>10.2f} {a['cost']:>8.2f} "
f"{'Yes' if a['is_relevant'] else 'No':>8}")
print()
print("Per-Page Metrics:")
print(f" {'Page':>6} {'Ads':>4} {'Avg CTR%':>9} {'Total Cost':>11}")
print(" " + "-" * 36)
for page_id, m in solution["per_page_metrics"].items():
print(f" {page_id:>6} {m['ads_shown']:>4} {m['avg_ctr']:>9.2f} {m['total_cost']:>11.2f}")
print()
print("Site-Wide Totals:")
st = solution["site_totals"]
print(f" Total Ads Placed : {st['total_ads']}")
print(f" Total Cost : ${st['total_cost']:.2f}")
print(f" Average CTR : {st['avg_ctr']:.2f}%")
print(f" Relevance Ratio : {st['relevance_ratio']:.2%}")
print("=" * 60)
def load_ad_data(filepath: str) -> pd.DataFrame:
"""Load and normalise the ad-page assignment CSV file."""
df = pd.read_csv(filepath)
df = df.rename(columns={
"Page ID": "page_id", "Ad ID": "ad_id", "CTR (%)": "ctr",
"Revenue per click ($)": "revenue_per_click",
"User Interest Match (Yes/No)": "is_relevant",
"Cost per ad ($)": "cost"
})
df["is_relevant"] = df["is_relevant"].str.strip().str.lower() == "yes"
return df
if __name__ == "__main__":
df = load_ad_data("ad_selection.csv")
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 1)
env.setParam("TimeLimit", 60)
env.start()
with AdAssignmentOptimizer(env) as optimizer:
optimizer.set_data(df, max_ads_per_page=3, min_total_ads=0,
daily_budget=500.0, min_avg_ctr=2.5,
min_relevance_ratio=0.70)
optimizer.build_model()
optimizer.solve()
optimizer.display_results()
Alongside the implementation, Modeler writes a pytest suite with one test per
scenario in the specification. Each test references its scenario identifier, and
uses small hard-coded ad-page data and a short solver time limit so the suite
runs in well under a second.
"""
Unit tests for Ad Assignment Optimization (ad_selection_modeler.py)
Covers all scenarios defined in specification.md (T1 - T5).
"""
import time
import pytest
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
from ad_selection_modeler import AdAssignmentOptimizer
def make_env():
env = gp.Env(empty=True)
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", 10)
env.start()
return env
def full_dataset() -> pd.DataFrame:
rows = [
(1, 1, 2.5, 0.50, True, 20), (1, 2, 3.3, 0.55, False, 15),
(1, 3, 1.7, 0.60, True, 25), (1, 4, 2.0, 0.48, False, 18),
(1, 5, 3.4, 0.62, True, 26), (1, 6, 2.3, 0.42, True, 12),
(1, 7, 2.1, 0.54, False, 20), (1, 8, 2.7, 0.65, True, 28),
(1, 9, 3.1, 0.53, False, 24), (1, 10, 1.8, 0.57, True, 22),
(2, 1, 2.2, 0.45, True, 30), (2, 2, 2.9, 0.50, False, 10),
(2, 3, 3.2, 0.55, True, 22), (2, 4, 2.6, 0.65, True, 28),
(2, 5, 3.0, 0.53, False, 24), (2, 6, 1.6, 0.50, True, 16),
(2, 7, 3.5, 0.60, True, 30), (2, 8, 2.4, 0.47, False, 18),
(2, 9, 2.8, 0.68, True, 32), (2, 10, 1.9, 0.40, False, 14),
(3, 1, 3.3, 0.52, False, 20), (3, 2, 1.9, 0.57, True, 15),
(3, 3, 3.1, 0.50, True, 16), (3, 4, 2.8, 0.68, False, 30),
(3, 5, 2.9, 0.38, True, 10), (3, 6, 3.0, 0.51, False, 12),
(3, 7, 1.8, 0.62, True, 25), (3, 8, 2.0, 0.55, False, 18),
(3, 9, 2.7, 0.63, True, 27), (3, 10, 2.3, 0.63, False, 27),
(4, 1, 3.2, 0.40, True, 12), (4, 2, 2.4, 0.55, False, 28),
(4, 3, 2.6, 0.60, True, 30), (4, 4, 3.4, 0.51, True, 14),
(4, 5, 1.6, 0.70, False, 32), (4, 6, 2.5, 0.45, True, 20),
(4, 7, 3.0, 0.52, False, 16), (4, 8, 1.9, 0.61, True, 24),
(4, 9, 2.8, 0.50, False, 22), (4, 10, 2.3, 0.56, True, 26),
]
return pd.DataFrame(rows, columns=[
"page_id", "ad_id", "ctr", "revenue_per_click", "is_relevant", "cost"
])
TOLERANCE = 1e-4
def test_T1_basic_optimization():
"""T1: Full dataset, default parameters. All five constraints verified."""
start = time.time()
df = full_dataset()
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=3, min_total_ads=0,
daily_budget=500.0, min_avg_ctr=2.5, min_relevance_ratio=0.70)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert sol["is_optimal"]
assert sol["objective_value"] > 0
for page_id, m in sol["per_page_metrics"].items():
assert m["ads_shown"] <= 3
assert sol["site_totals"]["total_cost"] <= 500.0 + TOLERANCE
assert sol["site_totals"]["avg_ctr"] >= 2.5 - TOLERANCE
assert sol["site_totals"]["relevance_ratio"] >= 0.70 - TOLERANCE
assert len(sol["assignments"]) > 0
assert time.time() - start < 10
def test_T2_tight_budget():
"""T2: Budget=$50. Cost constraint binding, all other constraints satisfied."""
start = time.time()
df = full_dataset()
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=3, min_total_ads=0,
daily_budget=50.0, min_avg_ctr=2.5, min_relevance_ratio=0.70)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert sol["is_optimal"]
assert sol["site_totals"]["total_cost"] <= 50.0 + TOLERANCE
for page_id, m in sol["per_page_metrics"].items():
assert m["ads_shown"] <= 3
assert sol["site_totals"]["avg_ctr"] >= 2.5 - TOLERANCE
assert sol["site_totals"]["relevance_ratio"] >= 0.70 - TOLERANCE
assert time.time() - start < 10
def test_T3_infeasible():
"""T3: min_total_ads=1 forces a placement but no ad satisfies both CTR and relevance."""
start = time.time()
df = pd.DataFrame([
{"page_id": 1, "ad_id": 1, "ctr": 1.0, "revenue_per_click": 0.50,
"is_relevant": True, "cost": 10},
{"page_id": 1, "ad_id": 2, "ctr": 5.0, "revenue_per_click": 0.50,
"is_relevant": False, "cost": 10},
])
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=1, min_total_ads=1,
daily_budget=500.0, min_avg_ctr=4.0, min_relevance_ratio=1.0)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert not sol["is_optimal"]
assert sol["objective_value"] is None
assert time.time() - start < 10
def test_T4_per_page_cap():
"""T4: max_ads_per_page=1. No page gets more than 1 ad."""
start = time.time()
df = full_dataset()
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=1, min_total_ads=0,
daily_budget=500.0, min_avg_ctr=2.5, min_relevance_ratio=0.70)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert sol["is_optimal"]
for page_id, m in sol["per_page_metrics"].items():
assert m["ads_shown"] <= 1
assert sol["site_totals"]["total_cost"] <= 500.0 + TOLERANCE
assert sol["site_totals"]["avg_ctr"] >= 2.5 - TOLERANCE
assert sol["site_totals"]["relevance_ratio"] >= 0.70 - TOLERANCE
assert time.time() - start < 10
def test_T5_output_metrics():
"""T5: Per-page and site-wide metrics are consistent with assignments."""
start = time.time()
df = full_dataset()
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=3, min_total_ads=0,
daily_budget=500.0, min_avg_ctr=2.5, min_relevance_ratio=0.70)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert sol["is_optimal"]
adf = pd.DataFrame(sol["assignments"])
for page_id, m in sol["per_page_metrics"].items():
page_ads = adf[adf["page_id"] == page_id]
assert m["ads_shown"] == len(page_ads)
assert abs(m["avg_ctr"] - page_ads["ctr"].mean()) < TOLERANCE
assert abs(m["total_cost"] - page_ads["cost"].sum()) < TOLERANCE
st = sol["site_totals"]
assert st["total_ads"] == sum(m["ads_shown"] for m in sol["per_page_metrics"].values())
assert abs(st["total_cost"] - sum(m["total_cost"] for m in sol["per_page_metrics"].values())) < TOLERANCE
assert abs(st["avg_ctr"] - adf["ctr"].mean()) < TOLERANCE
assert abs(st["relevance_ratio"] - adf["is_relevant"].mean()) < TOLERANCE
assert time.time() - start < 10
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and fixes what it finds, so what you get back is a model that has been executed, not one that merely looks plausible.
The specification was already complete by the time the data arrived, and
attaching ad_selection.csv doubled as the go-ahead — the same message asked
Modeler to finalize the specification, implement it, write tests, run them, and
report back.
The specification has no open items left, so there is nothing to ask
before implementing. Writing the AdAssignmentOptimizer class and a
pytest suite covering scenarios T1-T5, then running the tests.
All 5 tests pass cleanly in under half a second — no failures on the first run.
$ pytest test_ad_selection_modeler.py -q
.....
5 passed in 0.45s
Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (armlinux64)
Optimize a model with 8 rows, 40 columns and 198 nonzeros (Max)
Variable types: 0 continuous, 40 integer (40 binary)
Coefficient statistics:
Matrix range [1e-01, 3e+01]
Objective range [8e-03, 2e-02]
Bounds range [1e+00, 1e+00]
RHS range [3e+00, 5e+02]
Root relaxation: objective 2.155060e-01, 7 iterations, 0.00 seconds
Explored 1 nodes (7 simplex iterations) in 0.00 seconds (0.00 work units)
Best objective 2.145100000000e-01, best bound 2.145100000000e-01, gap 0.0000%
Here is a full summary of everything that was built:
Results
Optimal solution
12 placements across the 4 pages, exactly 3 per page, for an expected daily revenue of $0.2145:
Page |
Ads shown |
Selected ads |
Page cost |
|---|---|---|---|
Page 1 |
3 |
Ad 2, Ad 5, Ad 8 |
$69.00 |
Page 2 |
3 |
Ad 3, Ad 7, Ad 9 |
$84.00 |
Page 3 |
3 |
Ad 3, Ad 4, Ad 9 |
$73.00 |
Page 4 |
3 |
Ad 3, Ad 4, Ad 7 |
$60.00 |
Site-wide metrics achieved:
Metric |
Value |
|---|---|
Total placements |
12 |
Total cost |
$286.00 of $500.00 |
Average CTR |
3.04% (floor 2.5%) |
Relevance ratio |
75.00% (floor 70%) |
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Input ad-page dataset |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1-T5 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — 12 placements, $0.2145 expected revenue |
PASSED |
T2 |
Budget tightened to $50 — cost lands exactly at the ceiling |
PASSED |
T3 |
Infeasible — CTR and relevance floors cannot both be met |
PASSED |
T4 |
Per-page cap of 1 — one ad per page, four placements total |
PASSED |
T5 |
Per-page and site-wide metrics are consistent with the assignments |
PASSED |
Let me know if you would like any changes, such as adjusting the budget or per-page cap, or generating the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 40 columns and 8 rows, is nowhere near the cap — but a full-scale catalog of ads and pages could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full data set for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts per-ad page caps out of scope — a reasonable simplification, until the marketing team points out that the same ad keeps showing up on every page a visitor browses. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
One more thing - we don’t want the same ad to appear on more than 2 pages at once anymore. Visitors who browse multiple pages end up seeing the same ad over and over, which feels repetitive. Can you add a cap of 2 pages per ad?
This is a scope change, not a data correction: per-ad page caps are
currently listed under “Out of scope”. Updating the specification
first — remove that bullet, add a max_pages_per_ad parameter, and add
a new constraint bounding, for every ad, how many pages it can appear on
at once. Then the implementation and tests, and a re-run of the whole
suite to see whether the existing baseline still holds.
That moves per-ad page caps from out of scope into the model. Here is what changed, drawn directly from the updated files:
max_pages_per_ad to the Global Parameters section. Removed “Per-ad page caps” from Out of Scope. Added test scenario T6._add_constraints, purely additive: def _add_constraints(self):
df = self.data
pages = df["page_id"].unique()
+ ads = df["ad_id"].unique()
# ... C1 through C5 unchanged ...
+
+ # C6: At most max_pages_per_ad pages per ad
+ for ad in ads:
+ ad_pairs = [(row.page_id, row.ad_id)
+ for row in df[df["ad_id"] == ad].itertuples(index=False)]
+ self.model.addConstr(
+ gp.quicksum(self.x[p] for p in ad_pairs) <= self.max_pages_per_ad,
+ name=f"max_pages_ad_{ad}"
+ )
All 6 tests pass, including the pre-existing five — but T1’s result is not the same as before. I’ll walk through exactly what changed next.
Go ahead, and let me know if the answer actually changes.
Specification, then code, then tests
The new test, added alongside T1’s extra assertion:
def test_T6_max_pages_per_ad():
"""
T6: Set max_pages_per_ad=1 so every ad may appear on at most one page.
Verify no ad_id appears in the assignments list more than once.
"""
start = time.time()
df = full_dataset()
with make_env() as env:
with AdAssignmentOptimizer(env) as opt:
opt.set_data(df, max_ads_per_page=3, max_pages_per_ad=1,
min_total_ads=0, daily_budget=500.0,
min_avg_ctr=2.5, min_relevance_ratio=0.70)
opt.build_model()
opt.solve()
sol = opt.get_solution()
assert sol["is_optimal"], "Expected optimal solution with max_pages_per_ad=1"
# C6: each ad appears on at most 1 page
adf = pd.DataFrame(sol["assignments"])
pages_per_ad = adf.groupby("ad_id")["page_id"].nunique()
assert pages_per_ad.max() <= 1, \
f"An ad appears on more than 1 page: {pages_per_ad[pages_per_ad > 1]}"
# All other constraints still satisfied
assert sol["site_totals"]["total_cost"] <= 500.0 + TOLERANCE
assert sol["site_totals"]["avg_ctr"] >= 2.5 - TOLERANCE
assert sol["site_totals"]["relevance_ratio"] >= 0.70 - TOLERANCE
for page_id, m in sol["per_page_metrics"].items():
assert m["ads_shown"] <= 3
elapsed = time.time() - start
assert elapsed < 10, f"Test T6 took too long: {elapsed:.1f}s"
Modeler re-runs the whole suite, not just the new test — the point of propagating in this order is that nothing is left stale behind the change.
Checking for hidden ambiguity
Facility’s version of this tab (see that page) hits a case where the change makes an old test scenario genuinely ambiguous, and Modeler stops to ask rather than guess. Asked directly here whether the same thing happened — whether any old scenario’s expected result became ambiguous, or whether a default assumption was made silently — Modeler’s answer was no on both counts: “at most 2 pages” has exactly one linear reading, and C6 does not interact with any other constraint in a way that needed a judgment call. That check is itself part of the workflow: a spec-first tool should be able to tell you when a change is clean, not just when it isn’t.
The answer flips
Ad 3 was the one placement doing the most work in the baseline plan — it was
selected on pages 2, 3 and 4, which is exactly the pattern C6 exists to stop.
With the cap in place, the solver drops (page 3, ad 3) and picks up
(page 3, ad 1) instead:
Plan |
Ad 3 on pages |
Page 3’s third ad |
Expected revenue |
|---|---|---|---|
Before C6 (baseline) |
2, 3, 4 |
Ad 3 |
$0.21451 |
After C6 (cap = 2) |
2, 4 |
Ad 1 |
$0.21345 |
The cap costs $0.00106 in expected revenue — small, because ad 1 on page 3 (CTR 3.3%, $0.52/click, $20 cost) is nearly as good a placement as ad 3 was there. Every other scenario in the specification (T2 through T5) turned out not to depend on any ad appearing on more than 2 pages in the first place, so they carry over unchanged — the cap only bites where the baseline actually needed it to.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(p \in P\): pages.
\(a \in A\): ads.
\(E = P \times A\): eligible (page, ad) pairs — every ad is a candidate for every page, so \(E\) is the full product.
Parameters
\(\text{ctr}_{p,a}\): click-through rate of ad \(a\) on page \(p\), in percent.
\(r_{p,a}\): revenue per click of ad \(a\) on page \(p\), in dollars.
\(c_{p,a}\): cost charged when ad \(a\) is placed on page \(p\), in dollars.
\(\text{rel}_{p,a} \in \{0, 1\}\): 1 if ad \(a\) is relevant to visitors on page \(p\), 0 otherwise.
\(M\): maximum ads per page (3).
\(N\): minimum total placements site-wide (0).
\(B\): daily budget cap in dollars (500).
\(\bar{\tau}\): minimum average CTR across all placements, in percent (2.5).
\(\rho\): minimum fraction of placements that must be relevant (0.70).
Decision variables
\(x_{p,a} \in \{0, 1\}\): 1 if ad \(a\) is placed on page \(p\), 0 otherwise, for every \((p, a) \in E\).
Objective function
Maximize total expected daily revenue, where CTR is converted from a percent to a fraction:
Constraints
C1 — Maximum ads per page.
C2 — Minimum total placements.
C3 — Daily budget.
C4 — Minimum average CTR. The proportional form
is linearised by multiplying through by the (non-negative) denominator:
C5 — Minimum relevance ratio. Linearised the same way:
Complete model
Additional notes
Constraints C4 and C5 are both originally fractional — a ratio of two linear expressions in \(x\). Multiplying through by the shared denominator, the total number of placements, converts each into a valid linear constraint without introducing any new variables.
C2, set to \(N = 0\) by default, is not binding on its own: the all-zero solution trivially satisfies C4 and C5 when nothing is placed at all. Setting \(N > 0\) forces at least some placements and makes those two ratio constraints binding — this is exactly how test scenario T3 constructs a provably infeasible instance.
With \(|P|\) pages and \(|A|\) ads, the model has \(|P| \times |A|\) binary variables and \(|P| + 4\) constraints. On the sample data that is 40 variables and 8 constraints, solved by Gurobi in a single branch-and-bound node.
Note that this is the formulation of the original model, before the per-ad page cap (constraint C6) added in the Changing the model tab.
### Sets and indices
- $p \in P$: pages.
- $a \in A$: ads.
- $E = P \times A$: eligible (page, ad) pairs — every ad is a candidate for
every page, so $E$ is the full product.
### Parameters
- $\text{ctr}_{p,a}$: click-through rate of ad $a$ on page $p$, in percent.
- $r_{p,a}$: revenue per click of ad $a$ on page $p$, in dollars.
- $c_{p,a}$: cost charged when ad $a$ is placed on page $p$, in dollars.
- $\text{rel}_{p,a} \in \{0, 1\}$: 1 if ad $a$ is relevant to visitors on
page $p$, 0 otherwise.
- $M$: maximum ads per page (3).
- $N$: minimum total placements site-wide (0).
- $B$: daily budget cap in dollars (500).
- $\bar{\tau}$: minimum average CTR across all placements, in percent (2.5).
- $\rho$: minimum fraction of placements that must be relevant (0.70).
### Decision variables
- $x_{p,a} \in \{0, 1\}$: 1 if ad $a$ is placed on page $p$, 0 otherwise, for
every $(p, a) \in E$.
### Objective function
Maximize total expected daily revenue, where CTR is converted from a percent
to a fraction:
$$
\text{Maximize} \quad \sum_{(p,a) \in E} \frac{\text{ctr}_{p,a}}{100} \cdot r_{p,a} \cdot x_{p,a}
$$
### Constraints
**C1 — Maximum ads per page.**
$$
\sum_{a \in A} x_{p,a} \leq M \qquad \forall p \in P
$$
**C2 — Minimum total placements.**
$$
\sum_{(p,a) \in E} x_{p,a} \geq N
$$
**C3 — Daily budget.**
$$
\sum_{(p,a) \in E} c_{p,a} \cdot x_{p,a} \leq B
$$
**C4 — Minimum average CTR.** The proportional form
$$
\frac{\sum_{(p,a) \in E} \text{ctr}_{p,a} \cdot x_{p,a}}{\sum_{(p,a) \in E} x_{p,a}} \geq \bar{\tau}
$$
is linearised by multiplying through by the (non-negative) denominator:
$$
\sum_{(p,a) \in E} \left(\text{ctr}_{p,a} - \bar{\tau}\right) \cdot x_{p,a} \geq 0
$$
**C5 — Minimum relevance ratio.** Linearised the same way:
$$
\sum_{(p,a) \in E} \left(\text{rel}_{p,a} - \rho\right) \cdot x_{p,a} \geq 0
$$
### Complete model
$$
\begin{aligned}
\max \quad & \sum_{(p,a) \in E} \frac{\text{ctr}_{p,a}}{100} \, r_{p,a} \, x_{p,a} \\
\text{s.t.} \quad & \sum_{a \in A} x_{p,a} \leq M & \forall p \in P \\
& \sum_{(p,a) \in E} x_{p,a} \geq N \\
& \sum_{(p,a) \in E} c_{p,a} \, x_{p,a} \leq B \\
& \sum_{(p,a) \in E} \left(\text{ctr}_{p,a} - \bar{\tau}\right) x_{p,a} \geq 0 \\
& \sum_{(p,a) \in E} \left(\text{rel}_{p,a} - \rho\right) x_{p,a} \geq 0 \\
& x_{p,a} \in \{0, 1\} & \forall (p,a) \in E
\end{aligned}
$$
### Additional notes
- Constraints C4 and C5 are both originally fractional — a ratio of two
linear expressions in $x$. Multiplying through by the shared denominator,
the total number of placements, converts each into a valid linear
constraint without introducing any new variables.
- C2, set to $N = 0$ by default, is not binding on its own: the all-zero
solution trivially satisfies C4 and C5 when nothing is placed at all.
Setting $N > 0$ forces at least some placements and makes those two ratio
constraints binding — this is exactly how test scenario T3 constructs a
provably infeasible instance.
- With $|P|$ pages and $|A|$ ads, the model has $|P| \times |A|$ binary
variables and $|P| + 4$ constraints. On the sample data that is 40
variables and 8 constraints, solved by Gurobi in a single branch-and-bound
node.
Note that this is the formulation of the original model, before the per-ad
page cap (constraint C6) added in the **Changing the model** tab.