LearningData Science ProjectsGrowth and Funnel Challenges

4.4 Challenge: Ad Channel Profitability

Growth and Funnel Challenges85 min read
Concept

Find the core decision, design, or behavior signal.

Interview answer

Turn the lesson into a concise response blueprint.

Failure mode

Name the trap you would avoid in a real interview.

Lesson map

Use these checkpoints as your reading path before diving into the full lesson.

5 checkpoints
Lesson map based on the main headings in this learning page12345
  1. 1Why this matters in interviews
  2. 2The brief and the table you are handed
  3. 3Step 1: Reconcile the export before you...
  4. 4Step 2: Choose the metric, and say what...
  5. 5Step 3: The channel table, computed the...

A finance director slides a spreadsheet over and asks one question: next quarter we have the same 900,000 dollars a month of paid media, where should it go. Every take-home built on ad data is that question in a costume. The trap is that the file hands you a beautiful ranking about four minutes after you load it, and that ranking is wrong in at least four separate ways. This lesson walks the whole path for Kestrel Goods, a direct-to-consumer housewares brand, from a dirty daily export to a budget recommendation you can defend when the head of growth pushes back.

Why this matters in interviews

Paid acquisition is the most common analytics brief in consumer companies, and it is a favourite take-home because it looks easy and is not. Nine candidates out of ten produce the same deliverable: group by channel, sum revenue, divide by spend, sort descending, scale the top three. The arithmetic is fine. The reasoning is not, and a reviewer who has run a media budget spots it instantly.

Four things separate a hire-level answer from the median one, and this lesson is organised around them:

  • Revenue is not profit. A channel returning 2.4 times spend is losing money if the contribution margin is 42 percent.

  • Average is not marginal. The question is never "was this channel profitable", it is "would the next 50,000 dollars into this channel be profitable", and those have different answers.

  • Orders are not customers. Retargeting racks up cheap orders from people who were already coming back. It acquires almost nobody.

  • Measured is not incremental. The conversion log credits whoever touched last inside a short window, which systematically overpays the bottom of the funnel and underpays the top.

Get those four right and the cleaning work, which is where the export fights you, becomes the warm-up rather than the deliverable.

Interview tip: In the first two minutes say "before I rank anything I want to state the contribution margin I am assuming, because the break-even return on ad spend falls straight out of it." That single sentence separates you from most of the pile.


The brief and the table you are handed

Kestrel Goods sells kitchen and home goods online, average order value in the low seventies, gross margin after cost of goods, shipping, and payment fees of about 42 percent. Eight paid channels, one exported row per channel per day. The brief is three lines:

"Attached is 100 days of channel performance. Tell us which channels to put more money into, which to pull money out of, and what we should be careful about in your answer."

That third clause is the one that carries the marks. Here is the schema as described to you.

ColumnTypeWhat it is supposed to hold
datedateCalendar day, one row per channel per day
channeltextPaid channel identifier from the ad platform
impressionsintTimes an ad was rendered, not billed
clicksintBilled clicks that landed on the site
ordersintOrders credited to the channel, same-day window
new_customersintSubset of orders placed by a first-time buyer
spend_usdfloatMedia cost billed for that channel and day
revenue_usdfloatGross merchandise revenue on the credited orders

The schema names the conversion window but not the attribution model, and it says nothing about whether revenue is net of refunds or whether the eight channel labels stayed stable for 100 days. All four gaps matter. Two of them, refund netting and the label rename, you can find in the file yourself. The other two you have to ask about, because nothing in these eight columns can settle them.

This block builds the export deterministically, defects included, because every real ad export has some of them.

import numpy as np
import pandas as pd

SEED = 20260411
rng = np.random.default_rng(SEED)
spec = {  # impressions/day, ctr, cvr, cpc, aov, new-buyer share, impression growth, cpc growth
    "search_brand":    (41000, .096, .115, .62, 78., .28, 1.05, 1.22),
    "search_generic":  (200000, .021, .034, 1.30, 71., .55, 1.90, 1.40),
    "social_prospect": (700000, .0085, .012, .62, 66., .81, 1.45, 1.43),
    "social_retarget": (96000, .019, .048, 1.05, 83., .17, 1.20, 1.45),
    "display_net":     (1250000, .0011, .004, .48, 59., .62, 1.10, 1.34),
    "video_preroll":   (620000, .0026, .006, .58, 62., .74, 1.15, 1.33),
    "affiliate":       (74000, .034, .052, .44, 69., .48, 1.35, 1.55),
    "shopping_feed":   (310000, .014, .033, .82, 74., .44, 1.30, 1.29)}
days, t, frames = pd.date_range("2026-01-01", periods=100, freq="D"), np.arange(100), []
for ch, (im, ct, cv, cp, av, ns, ig, cg) in spec.items():
    impr = rng.poisson(im * np.linspace(1., ig, 100) * (1 + .12 * np.sin(t / 7 * 2 * np.pi)))
    clicks = rng.binomial(impr, ct)
    orders = rng.binomial(clicks, cv)
    frames.append(pd.DataFrame({
        "date": days, "channel": ch, "impressions": impr, "clicks": clicks,
        "orders": orders, "new_customers": rng.binomial(orders, ns),
        "spend_usd": np.round(clicks * cp * np.linspace(1., cg, 100) * rng.normal(1, .05, 100), 2),
        "revenue_usd": np.round(orders * av * rng.normal(1, .09, 100), 2)}))
ads = pd.concat(frames, ignore_index=True)
neg = rng.choice(ads.index, 6, replace=False)
ads.loc[neg, "revenue_usd"] = -(ads.loc[neg, "revenue_usd"] * .22).round(2)
gap = ads.index[(ads.channel == "video_preroll") & ads.date.between("2026-02-17", "2026-02-19")]
ads.loc[gap, ["impressions", "clicks", "orders", "new_customers", "revenue_usd"]] = 0
ads.loc[rng.choice(ads.index[ads.channel == "display_net"], 9, replace=False), "clicks"] = 0
ads.loc[(ads.channel == "display_net") & (ads.date >= "2026-03-05"), "channel"] = "display_pmax"
dup = ads[ads.date.between("2026-01-20", "2026-01-24")].copy()
ads = pd.concat([ads, dup], ignore_index=True).sort_values(["date", "channel"]).reset_index(drop=True)

Step 1: Reconcile the export before you compute a single ratio

One row per channel per day means eight times 100, which is 800. The file has 840. That gap is the whole first section of your write-up and you find it in one line.

print(len(ads), ads.date.nunique(), ads.channel.nunique())
print(ads.duplicated(subset=["date", "channel"]).sum())
print(sorted(ads.channel.unique()))
840 100 9
40
['affiliate', 'display_net', 'display_pmax', 'search_brand', 'search_generic',
 'shopping_feed', 'social_prospect', 'social_retarget', 'video_preroll']

Nine labels for eight channels, and 40 rows sharing a key. Different problems, different fixes. Run five targeted checks rather than staring at a describe table.

chk = {
    "rows_over_grain": ads.duplicated(subset=["date", "channel"]).sum(),
    "negative_revenue": int((ads.revenue_usd < 0).sum()),
    "orders_exceed_clicks": int((ads.orders > ads.clicks).sum()),
    "spend_with_no_impressions": int(((ads.impressions == 0) & (ads.spend_usd > 0)).sum()),
    "new_exceeds_orders": int((ads.new_customers > ads.orders).sum()),
}
for k, v in chk.items():
    print(f"{k:26s} {v}")
rows_over_grain            40
negative_revenue           8
orders_exceed_clicks       9
spend_with_no_impressions  3
new_exceeds_orders         0

Now diagnose each one, because the fix depends entirely on what caused it.

The 40 duplicated rows all fall between 20 and 24 January, every channel, every day. That is not random corruption, it is an export appended twice for a five-day window, almost certainly a re-run. They carry 118,878 dollars of spend and 398,958 of revenue, so leaving them in inflates total spend by 4.1 percent and, because that window was a good week, inflates blended return too.

The nine channel labels are eight channels plus a rename. display_net has no rows on or after 5 March and display_pmax has none before it: the platform migrated the campaign to a new product name mid-flight. Group by the raw label and you get two half-sized display channels, each too small to attract attention, when the truth is one channel with 80,470 dollars of spend and a return of 0.46. Renames are the most common way a real channel hides from a ranking.

The six negative revenue days (eight rows before deduplication) are refunds netted into the daily figure. Junior instinct is to delete them. Better instinct is to size them first: minus 18,179 dollars against 8.76 million of revenue, which is 0.21 percent. Keep them, note that revenue appears net of refunds, move on. "I checked and it moves nothing" is a stronger sentence than a silent deletion.

The three days with spend and zero impressions are all video_preroll, 17 to 19 February, consecutive. Consecutive is the tell: a tracking outage, not three dead days. They carry 3,212 dollars of real spend and zero recorded performance, so leaving them in drags the channel's average daily return toward zero for a measurement failure rather than an advertising one. The handling is asymmetric: keep the spend in cost totals, because the money left the account, and exclude the rows from any rate with a performance numerator.

The nine rows where orders exceed clicks are all display, all with exactly zero clicks against positive orders and positive spend. Click logging dropped for part of the display feed. Those rows are fine for spend and orders, useless for click-through rate and cost per click.

DefectHow you find itWhat it corruptsHandling
Duplicated key rowsRow count against the stated grainEvery total, spend most of allDrop duplicates on date plus channel
Channel renamed mid-flightLabel count, then first and last date per labelSplits one channel into two small onesMap both labels to one canonical name
Negative daily revenueMinimum of the revenue columnAlmost nothing here, at 0.21 percentQuantify, keep, note refunds are netted
Spend with zero impressionsConsecutive zero rows with positive costDaily-average rates, not totalsKeep the cost, drop from rate denominators
Zero clicks with positive ordersFunnel monotonicity checkCost per click and click-through rateExclude from click metrics only

Here is the cleaning pass those five diagnoses imply.

clean = ads.drop_duplicates(subset=["date", "channel"], keep="first").copy()
clean["channel"] = clean.channel.replace({"display_net": "display", "display_pmax": "display"})
clean["measured"] = clean.impressions > 0
clean["click_ok"] = clean.measured & (clean.clicks > 0) & (clean.clicks >= clean.orders)
print(len(clean), clean.channel.nunique(), (~clean.measured).sum(), (~clean.click_ok).sum())
800 8 3 12

Interview tip: Never write "I removed bad rows." Write "I removed 40 duplicated rows worth 118,878 dollars of spend, which was inflating blended return by 4.1 percent." The number is the evidence that you actually looked.


Step 2: Choose the metric, and say what you are choosing to ignore

Interviewers who have run media budgets ask a specific follow-up: which metric did you rank on, and what does choosing it mean you care about. Below is the ladder. Each rung answers a narrower question and throws away something the rung above kept.

MetricDefinitionWhat it optimises forWhere it misleads
Click-through rateclicks divided by impressionsCreative and targeting qualitySays nothing about whether clicks buy
Cost per clickspend divided by clicksAuction efficiencyA cheap click that never converts is not cheap
Conversion rateorders divided by clicksLanding page and offer fitRewards channels that harvest existing intent
Cost per acquisitionspend divided by ordersVolume of orders per dollarTreats a 40-dollar order like a 140-dollar one
Return on ad spendrevenue divided by spendRevenue per dollar of mediaRevenue is not margin, so the bar is not 1.0
Contribution per dollarmargin times revenue minus spendActual profit createdAverages hide what the next dollar does
Customer acquisition costspend divided by new customersGrowth of the customer basePenalises channels that drive repeat orders

The honest ranking metric for "where does the budget go" is contribution, evaluated at the margin, per new customer where the channel's job is acquisition. Nothing else survives contact with a finance team. Compute the whole ladder anyway: the disagreements between rungs are the analysis.

metric tree

Decomposing return on ad spend

  1. 1
    Return on ad spend

    revenue per dollar of media, the number the platform shows you

  2. 2
    Split one

    orders per dollar, which is 1 divided by cost per acquisition

  3. 3
    Split two

    revenue per order, the average order value the channel attracts

  4. 4
    Orders per dollar splits again

    clicks per dollar times orders per click

  5. 5
    Clicks per dollar

    the inverse of cost per click, an auction-price story

  6. 6
    Orders per click

    conversion rate, an intent and landing-page story

  7. 7
    Reading it

    a falling return is either a price problem or an intent problem, and the split tells you which

Memorise that decomposition, because it turns a vague finding into a diagnosis. Kestrel's generic search return falls from 1.85 in January to 1.40 in the final month, and the split says which half moved. Here it is the auction: cost per click climbs steadily across the window while conversion holds flat. Whether Kestrel bought worse inventory or the whole auction got dearer, this table cannot say, and Step 5 turns on the difference.


Step 3: The channel table, computed the right way

Aggregate first, divide second. That ordering is not a style preference. The filter on measured drops the three outage days from both numerator and denominator; adding their 3,212 dollars back into video's cost moves its contribution from minus 87,457 to minus 90,668 and changes no decision, which is the kind of sensitivity you state in one clause and then stop worrying about.

d = clean[clean.measured].copy()
MARGIN = 0.42
g = d.groupby("channel").agg(spend=("spend_usd", "sum"), revenue=("revenue_usd", "sum"),
                             orders=("orders", "sum"), new_cust=("new_customers", "sum"))
g["aov"] = g.revenue / g.orders
g["cpa"] = g.spend / g.orders
g["cac"] = g.spend / g.new_cust
g["roas"] = g.revenue / g.spend
g["contribution"] = g.revenue * MARGIN - g.spend
print(g.sort_values("roas", ascending=False).round(2).to_string())
                     spend     revenue  orders  new_cust    aov     cpa     cac   roas  contribution
search_brand     278464.58  3591393.94   46319     13016  77.54    6.01   21.39  12.90    1229920.87
affiliate        167536.04  1041585.93   15429      7407  67.51   10.86   22.62   6.22     269930.05
social_retarget  259192.16   810744.64    9715      1640  83.45   26.68  158.04   3.13      81320.59
shopping_feed    471054.33  1174903.80   16250      7142  72.30   28.99   65.96   2.49      22405.27
search_generic   961026.97  1480537.85   20720     11317  71.45   46.38   84.92   1.54    -339201.07
social_prospect  556687.77   562082.66    8682      7022  64.74   64.12   79.28   1.01    -320613.05
video_preroll    113792.71    62705.21    1017       744  61.66  111.89  152.95   0.55     -87456.52
display           80470.13    36744.70     622       371  59.08  129.37  216.90   0.46     -65037.36

The same aggregation as a query, because a lot of take-homes want it in SQL:

SELECT channel,
       SUM(spend_usd)                                   AS spend,
       SUM(revenue_usd)                                 AS revenue,
       SUM(orders)                                      AS orders,
       SUM(spend_usd) / NULLIF(SUM(orders), 0)          AS cpa,
       SUM(spend_usd) / NULLIF(SUM(new_customers), 0)   AS cac,
       SUM(revenue_usd) / NULLIF(SUM(spend_usd), 0)     AS roas,
       0.42 * SUM(revenue_usd) - SUM(spend_usd)         AS contribution
FROM channel_daily
WHERE impressions > 0
GROUP BY channel
ORDER BY roas DESC;

Ratio of sums, not average of ratios

Two ways exist to get a channel-level cost per acquisition and they disagree. Sum the spend, sum the orders, divide once: the ratio of sums, money-weighted. Or compute a daily cost per acquisition and average the 100 values: the average of ratios, which weights a Tuesday with 200 dollars of spend as heavily as a Saturday with 9,000.

day = d[d.orders > 0].copy()
day["daily_cpa"] = day.spend_usd / day.orders
comp = day.groupby("channel").agg(mean_of_daily=("daily_cpa", "mean"))
comp["ratio_of_sums"] = g.cpa
comp["gap_pct"] = 100 * (comp.mean_of_daily / comp.ratio_of_sums - 1)
print(comp.round(2).sort_values("gap_pct", ascending=False).to_string())
                 mean_of_daily  ratio_of_sums  gap_pct
display                 165.11         129.37    27.62
video_preroll           126.74         111.89    13.27
social_retarget          26.83          26.68     0.57
search_brand              6.02           6.01     0.18
shopping_feed            29.04          28.99     0.17
social_prospect          64.17          64.12     0.07
affiliate                10.81          10.86    -0.46
search_generic           45.75          46.38    -1.37

The wrong method overstates display's cost per acquisition by 28 percent, because on a low-volume channel a day with two orders produces an enormous ratio that the mean happily absorbs. Be honest about what it does not do here: it does not reorder the channels. It would if display had zero-order days, because those give an undefined ratio that a naive mean() drops, quietly deleting the worst days. The rule is worth stating in the write-up: any ratio metric gets aggregated numerator-first.

Interview tip: If an interviewer asks why your channel cost per acquisition differs from the number in their dashboard, the first thing to check is not the data, it is whether the dashboard averages daily ratios.


Step 4: Revenue is not profit, so the bar is not 1.0

Return on ad spend of 2.49 sounds like two and a half dollars back for every one. It is, in revenue, and the company does not keep revenue. At 42 percent contribution margin every revenue dollar leaves 42 cents to pay for the ad, so a channel breaks even when

break_even_roas = 1 / margin = 1 / 0.42 = 2.381

That single number reorganises the table. Four of Kestrel's eight channels sit below it, consuming 1,711,978 dollars, 59.3 percent of the media budget, and returning 2,142,070 dollars, 24.5 percent of the revenue.

ChannelReturn on ad spendAbove break-even of 2.381Contribution over 100 days
search_brand12.90Yes, by a factor of 5.41,229,921
affiliate6.22Yes269,930
social_retarget3.13Yes81,321
shopping_feed2.49Barely, by 0.1122,405
search_generic1.54No-339,201
social_prospect1.01No-320,613
video_preroll0.55No-87,457
display0.46No-65,037

Portfolio contribution is 791,269 dollars over 100 days. Brand search alone contributes 1,229,921, more than the whole portfolio nets, so everything else together destroys 438,652 dollars. Hold that fact. It is why the naive recommendation is dangerous rather than merely incomplete, and Step 7 returns to it.

If the take-home does not give you a margin, do not guess silently. State the assumption, show the break-even, and show how the conclusion moves if margin were ten points lower. At 32 percent the break-even return rises to 3.125 and shopping feed joins the losers. That sensitivity is a paragraph of work and it is the paragraph reviewers remember.


Step 5: Average is not marginal, and only marginal answers the question

Most submissions stop here and this is where the interesting part starts. Nobody is asking whether the dollars already spent on shopping feed were profitable. They are asking whether the next dollar would be. Those questions differ whenever a channel faces diminishing returns, which is always, because you exhaust cheap inventory first and bid up for the rest.

The workable estimate is a constant-elasticity fit: regress log orders on log spend within each channel and read the slope. Elasticity of 1.0 means marginal equals average. Elasticity of 0.5 means doubling spend buys about 41 percent more orders, so the marginal cost per acquisition is twice the average.

import numpy as np

rows = []
for ch, x in d[(d.spend_usd > 0) & (d.orders > 0)].groupby("channel"):
    slope, _ = np.polyfit(np.log(x.spend_usd), np.log(x.orders), 1)
    avg_cpa = x.spend_usd.sum() / x.orders.sum()
    rows.append({"channel": ch, "elasticity": slope, "avg_cpa": avg_cpa,
                 "marginal_cpa": avg_cpa / slope,
                 "contrib_per_order": MARGIN * x.revenue_usd.sum() / x.orders.sum()})
m = pd.DataFrame(rows).set_index("channel")
m["marginal_profit"] = m.contrib_per_order - m.marginal_cpa
print(m.round(2).sort_values("marginal_profit", ascending=False).to_string())
                 elasticity  avg_cpa  marginal_cpa  contrib_per_order  marginal_profit
search_brand           0.51     6.01         11.88              32.57            20.68
affiliate              0.45    10.86         24.28              28.35             4.07
shopping_feed          0.52    28.99         55.52              30.37           -25.16
social_retarget        0.36    26.68         74.74              35.05           -39.69
search_generic         0.65    46.38         71.53              30.01           -41.52
social_prospect        0.59    64.12        109.44              27.19           -82.25
video_preroll          0.90   111.89        124.39              25.90           -98.50
display                0.61   129.37        211.17              24.81          -186.35

Read the two middle rows against the previous table. On average contribution, shopping feed and retargeting were both profitable, at 22,405 and 81,321 dollars. The fit says both are underwater at the margin: it puts the next shopping-feed order at 55.52 dollars against 30.37 of contribution, and the next retargeting order at 74.74 against 35.05. Four channels looked profitable on averages, two on this fit. That is where most submissions stop. Before you move a budget on it, check whether the slope is measuring the thing you named it after.

Scatter of average cost per acquisition on the x axis against the fitted marginal cost per acquisition on the y axis, one labelled point per channel, with a diagonal reference line and a horizontal band marking each channel's contribution per order, showing shopping_feed and social_retarget lifted above their contribution band by the fit, a gap the diagnostic later in this step attributes to rising cost per click rather than to saturation

Two honesty requirements come with this method, and stating them unprompted is worth as much as the fit.

First, this elasticity is descriptive, not causal. Spend and orders both trend upward over 100 days, and anything else moving with time, seasonality, a redesign, a promotion, is bundled into the slope. It describes the path the team walked, not what happens if you change spend and hold everything else fixed. Say that, then say what fixes it: a staggered budget change across matched geographies, or at minimum a fit with a time control.

Second, the slope is only as stable as the channel's order volume and the range of spend it was estimated over. Bootstrap it before you build a recommendation on it.

def boot_elasticity(x, n=2000, seed=7):
    rng2 = np.random.default_rng(seed)
    out = np.empty(n)
    lx, ly = np.log(x.spend_usd.values), np.log(x.orders.values)
    for i in range(n):
        idx = rng2.integers(0, len(lx), len(lx))
        out[i] = np.polyfit(lx[idx], ly[idx], 1)[0]
    return np.percentile(out, [2.5, 97.5])

sub = d[(d.channel == "shopping_feed") & (d.orders > 0)]
print(np.round(boot_elasticity(sub), 3))
[0.416 0.626]

For shopping feed that interval maps to a marginal cost per acquisition between 46.32 and 69.76 dollars, against contribution per order of 30.37. The tempting sentence here is that the whole interval sits above the bar, so the conclusion is robust. Do not write it. A bootstrap resamples rows from one specification, so it prices sampling noise inside that specification and is blind to the specification itself. It says nothing about the confounding this step already named, which the next block shows is the larger term by far.

Affiliate gives 0.379 to 0.513, straddling the bar. Display gives minus 0.12 to 1.33, a slope not identified at all on six orders a day. Both intervals are honest about sampling noise and both are about to be overtaken by a bigger problem.

Interview tip: Before you lean on a confidence interval, say out loud what it does not cover. It prices sampling noise around one specification, so if you have just called that specification confounded, the interval is the smaller of your two error sources.

Is that slope diminishing returns, or is it a price trend?

Spend is clicks times cost per click, so log spend carries two moving parts and only one of them is buying more inventory. Regress orders on clicks instead, fit the cost-per-click trend beside it, and add the time control this step promised.

t0 = d.date.min()
diag = []
for ch, x in d[d.click_ok & (d.orders > 0)].groupby("channel"):
    day = (x.date - t0).dt.days
    e_clicks = np.polyfit(np.log(x.clicks), np.log(x.orders), 1)[0]
    e_time = np.linalg.lstsq(np.column_stack([np.ones(len(x)), np.log(x.spend_usd), day]),
                             np.log(x.orders), rcond=None)[0][1]
    cpc_trend = np.polyfit(day, np.log(x.spend_usd / x.clicks), 1)[0]
    diag.append({"channel": ch, "on_spend": m.elasticity[ch], "on_clicks": e_clicks,
                 "spend_plus_time": e_time, "cpc_rise_pct": 100 * (np.exp(cpc_trend * 99) - 1)})
print(pd.DataFrame(diag).set_index("channel").round(2).to_string())
                 on_spend  on_clicks  spend_plus_time  cpc_rise_pct
affiliate            0.45       0.94             0.76         53.00
display              0.61       1.45             1.06         30.89
search_brand         0.51       0.99             0.80         24.50
search_generic       0.65       0.95             0.73         38.52
shopping_feed        0.52       0.95             0.82         28.99
social_prospect      0.59       1.11             0.82         48.95
social_retarget      0.36       0.85             0.73         44.37
video_preroll        0.90       1.78             1.31         31.36

That table takes the previous one apart. Orders track clicks close to one for one: the six channels with real volume land between 0.85 and 1.11, four inside 0.94 to 0.99, so nothing in these 100 days saturates. Cost per click meanwhile climbs 24 to 53 percent on every channel, while those same six ran only 0.36 to 0.65 against spend. That gap is what the spend slope was picking up: log spend rose partly because the team bought more clicks and partly because the same click got dearer, and the second half brings no orders with it. A linear time control pulls the slopes most of the way back toward 1, the same finding told twice.

So the marginal cost column above is not a marginal cost. It is an average cost inflated by a price trend the fit misread as diminishing returns.

What survives is narrower. If orders scale one for one with clicks and only price is moving, the next order costs what an order costs today, not what one cost on the 100-day average.

recent = d[d.date > d.date.max() - pd.Timedelta(days=30)]
r = recent.groupby("channel").agg(spend=("spend_usd", "sum"), orders=("orders", "sum"))
r["cpa_last_30d"] = r.spend / r.orders
r["contrib_per_order"] = m.contrib_per_order
r["margin_at_todays_prices"] = r.contrib_per_order - r.cpa_last_30d
print(r.drop(columns=["spend", "orders"]).round(2)
       .sort_values("margin_at_todays_prices", ascending=False).to_string())
                 cpa_last_30d  contrib_per_order  margin_at_todays_prices
search_brand             6.53              32.57                    26.04
affiliate               12.68              28.35                    15.67
social_retarget         31.09              35.05                     3.96
shopping_feed           32.08              30.37                    -1.71
search_generic          50.59              30.01                   -20.58
social_prospect         71.53              27.19                   -44.34
video_preroll          119.70              25.90                   -93.81
display                135.74              24.81                  -110.93

Now correct the headline. Four channels looked profitable on 100-day averages and three still clear the bar at current prices. Shopping feed slips under by 1.71 dollars an order rather than the 25.16 the fit claimed, and retargeting is 3.96 ahead rather than 39.69 behind. Every dollar figure in Step 8 is sized off this table.

That reversal is the part of the write-up that earns the offer: you checked whether the regressor measured the thing you had named it after and said so before anyone asked. Keep the fit in the deck as a hypothesis with a test attached: step shopping-feed spend up 30 percent for four weeks and see whether orders follow one for one. Until somebody moves spend on purpose, the slope is not estimable from this path.


Step 6: Orders are not customers

Return on ad spend and cost per acquisition treat a repeat buyer identically to a first-time buyer. For a brand whose growth depends on the size of its customer base, that is the wrong unit for at least half the channels.

Assume a Kestrel customer places about 2.4 orders in year one at an average order value near 72 dollars. At 42 percent margin that is roughly 72.6 dollars of first-year contribution per new customer, the ceiling on what you can pay to acquire one under a twelve-month payback rule.

ChannelNew-buyer share of ordersNew customersCost per new customerPasses the 72.6 ceiling
search_brand28 percent13,01621.39Yes
affiliate48 percent7,40722.62Yes
shopping_feed44 percent7,14265.96Yes
social_prospect81 percent7,02279.28No, by 9 percent
search_generic55 percent11,31784.92No
video_preroll73 percent744152.95No
social_retarget17 percent1,640158.04No, by a factor of 2.2
display60 percent371216.90No

Two rows change the story. Retargeting, which looked like the third-best channel on return and was profitable on average contribution, buys new customers at 158 dollars because 83 percent of its orders come from people who already bought. It is a repeat-purchase channel wearing an acquisition channel's clothes, and judging it on cost per acquisition flatters it enormously. Prospecting social, which looked like a disaster at a return of 1.01, produced 7,022 of the 48,659 new customers in the window, 14.4 percent of all acquisition, at a cost only 9 percent above the ceiling.

Judge each channel on the job it actually does. Acquisition channels get measured on cost per new customer against first-year contribution. Retargeting gets measured on incremental orders against a holdout, because its entire value claim is that the order would not have happened otherwise, and that claim is untestable from this table.


Step 7: The attribution caveats that make the naive ranking wrong

Everything so far has taken the orders column at face value. That column is a credit assignment, not a measurement, and understanding how the credit was assigned changes the answer more than any modelling choice in this lesson.

Four specific distortions are visible in Kestrel's numbers.

The conversion window is same-day, as the schema note said. An order counts only if click and purchase land on the same calendar day. Housewares are a considered purchase: a shopper sees a video ad on Sunday, browses Tuesday, buys Friday, and the Sunday video gets nothing. This bias is not uniform. It falls almost entirely on the top of the funnel, exactly the four channels the naive ranking wants to cut. Whatever display and video are truly worth, this table understates it by an amount this file cannot reveal.

Credit goes to the last touch. Somebody who saw three prospecting ads, then typed "kestrel goods" into a search box and clicked the brand ad, appears in this table as a brand-search order at a cost per acquisition of 6.01. Brand search is a toll booth on demand that other channels created. A return of 12.90 on brand search is close to meaningless as a statement about incremental value.

Brand search cannibalises organic. A meaningful share of brand clicks would have arrived through the free organic result had the paid ad not been sitting above it. Companies that run brand holdouts routinely find a large fraction of paid brand clicks is traffic they would have received anyway. You cannot estimate that from these columns, and saying so is the correct answer.

Impressions are free and clicks are billed. Display buys 131 million impressions for 80,470 dollars because its click-through rate is roughly one in a thousand. Any view-through effect is invisible in a click-credited table, a further reason the ranking is biased against display and video specifically.

Put together, the naive ranking overpays the bottom of the funnel and underpays the top, so the recommendation falling out of it, cut everything upper-funnel, is the one most likely to be quietly wrong. It is also self-reinforcing: cut prospecting, watch brand searches decline six weeks later, and the dashboard blames a soft quarter.

checklist

Attribution questions to ask before you rank anything

  • Conversion window how many days after the click does an order still count, and is it the same for every channel

  • Attribution model last click, first click, position-based, or platform-modelled, and does each platform self-report

  • Double counting do the channel order totals sum to more than actual company orders

  • View-through credit are impression-only conversions included, and for which channels

  • Organic baseline what share of brand-search clicks would have arrived through the free result

  • New versus repeat is the new-customer flag defined on first order ever, or first order this year

  • Holdout availability has any channel ever been switched off in a market so incrementality could be measured

The one honest way out is an experiment, and proposing a concrete one separates a good answer from a great one. The cheapest design here is a geographic holdout: take 20 metropolitan markets, match on pre-period revenue trend and channel mix, randomly assign ten to zero prospecting spend for six weeks while ten continue unchanged, and measure total company revenue per market rather than channel-attributed revenue. That last clause is the point. The outcome must be a number the attribution system cannot touch.

Two-panel line chart of weekly total revenue per market for treatment and control groups, with a vertical line at the start of the holdout, showing parallel pre-period trends and a gap that opens after the treatment markets pause prospecting spend

Size it before you propose it, and show the arithmetic, because the naive version of this design fails. With a coefficient of variation near 18 percent on market-level weekly revenue, ten markets per arm detects 2.80 times the square root of 2 over 10, times 0.18, which is 22.6 percent. Detecting a 6 percent drop that way needs 142 markets per arm and Kestrel does not have 142 markets. The design only survives if differencing against the pre-period strips that variance out. If week-to-week revenue inside a market moves by about 8 percent and the rest of the 18 percent is level differences between markets, six differenced weeks put the detectable effect near 6 percent, the same thing as a pre-and-post market correlation around 0.96. Check that split on pre-period data first, because differencing is not automatically a variance reduction: if the whole 18 percent were within-market noise, differencing gives 13.0 percent against 9.2 percent for a plain comparison of six-week means. At a correlation of 0.90 you see only 10.1 percent, and then you buy more markets, run longer, or write the larger number into the proposal.

Interview tip: Whenever you propose an incrementality test, name the outcome metric explicitly as total revenue rather than attributed revenue. Interviewers listen for that exact substitution.


Step 8: What to actually recommend

The deliverable is not the table, it is the reallocation. Here is the structure that lands, in the order a growth lead reads it.

tradeoff matrix

The four moves and when each is right

MoveEvidence that justifies itWhat it risksUse when
Scale nowAn order still pays for itself at today's pricesThe spend path may never have reached the point where the channel saturatesCost per order well under contribution per order, and the step is small enough to re-measure
Hold and testProfitable on average, thin or negative at today's pricesLeaves money in a channel that has stopped payingAverage and marginal disagree, as with shopping feed and retargeting
Cut with a holdoutBelow break-even and structurally under-creditedCutting real demand that shows up elsewhere laterUpper-funnel channels a same-day window cannot measure
Cut nowBelow break-even on every lens, and too small to be worth a testAlmost noneDisplay, which fails on averages, at today's prices, and per new customer

Written out for Kestrel, with the numbers attached:

  1. Scale affiliate, carefully. Besides brand it is the only channel with real headroom at today's prices: an order costs 12.68 and carries 28.35 of contribution. Do not read that gap as licence to double, because nothing here shows where affiliate saturates, so the increase is itself the measurement. Propose 25 percent, about 12,565 dollars a month, and re-read cost per order after four weeks.

  2. Freeze shopping feed at current spend. It cleared break-even on average by 0.11 of return and sits 1.71 dollars an order underwater at today's cost per click, which is inside the noise. Do not cut it, do not grow it. Say plainly that a freeze leaves the elasticity unanswered, because only a deliberate spend step can answer it, and at 1.71 dollars an order that answer is not yet worth buying.

  3. Hold retargeting and buy an answer instead. 259,192 dollars, 9 percent of budget. It clears break-even at 3.13, contributes 81,321, and is 3.96 dollars an order ahead at today's prices, so on unit economics it is neither a scale nor a cut. The deciding number is elsewhere: 83 percent of its orders come from people who already bought, cost per new customer is 158.04 against a 72.60 ceiling, and its whole value claim is that the order would not have happened anyway. Suppress it for a random half of the audience for four weeks and measure total orders per user.

  4. Pull generic search back in stages. The largest lever: 961,027 dollars of spend, negative 339,201 of contribution, 20.58 dollars lost on every order at current prices. Solving the Step 5 fit for the spend where marginal cost equals 30.01 gives 828 dollars a day against 9,610 today, a 91 percent cut, and that answer sits at 16 percent of the lowest daily spend the channel has ever run. That is extrapolation outside the fitted range, a reason to distrust the fit rather than a cut order. Take 25 percent out, about 72,077 dollars a month, and watch whether cost per order falls as volume falls. If it does, the saturation curve is real. If not, take another 25 percent.

  5. Run one upper-funnel holdout before touching prospecting or video. Extend the Step 7 geo design to pause video preroll in the same treatment markets. Together the two are 670,480 dollars, 23 percent of budget and 16.0 percent of new customers, and the same-day window damages both in the same direction. Cutting either on this table's numbers is the move most likely to be quietly wrong.

  6. Cut display now. 80,470 dollars, 2.8 percent of budget, negative on average, 110.93 an order negative at today's prices, negative per new customer by a factor of three, and too small for a dedicated test to be worth the answer.

  7. Test a brand-search budget reduction. Not a cut, a test. Reduce brand bids in half of the markets for four weeks and watch total revenue. Sized like the prospecting test, that sees about 7.3 percent, so read a flat result as "no cannibalisation bigger than 7 percent" rather than as proof the toll booth pays. If it is cannibalisation, you have found the largest saving in the portfolio.

Eight channels in, eight decisions out, and the dollars follow from the decisions.

ChannelMonthly spend nowDecisionMonthly move
search_generic288,308Staged 25 percent pullback-72,077
display24,141Cut now-24,141
affiliate50,261Increase 25 percent+12,565
shopping_feed141,316Freeze0
social_retarget77,758Hold, suppression holdout0
social_prospect167,006Hold, geo holdout0
video_preroll34,138Hold, same geo holdout0
search_brand83,539Hold, bid-reduction test0

That is 96,218 dollars a month out of two channels and 12,565 back into one, a net 83,653 released from a monthly media budget of 866,467. Far smaller than the reallocation most candidates present, and defensible row by row. Five of the eight channels move nothing yet because three experiments come first: the upper-funnel geo holdout, the retargeting suppression test, and the brand-bid reduction. Saying "held pending experiments" instead of inventing a destination is the correct answer and the one most candidates are too nervous to give.


Common traps

Ranking on return on ad spend against a bar of 1.0. The bar is 1 divided by contribution margin. At 42 percent it is 2.381, and two of Kestrel's channels sit between 1.0 and 2.381: generic search at 1.54 and prospecting social at 1.01. That is the band where a channel clears the naive bar and still destroys money, 659,814 dollars of contribution between the two. The other two below break-even, video at 0.55 and display at 0.46, fail on any bar. Fix: compute break-even from margin early and put the line on every chart.

Averaging daily ratios. The mean of daily cost per acquisition overstates display's by 28 percent here, and on a channel with zero-order days it silently drops the worst days. Fix: aggregate numerator and denominator separately, then divide once.

Treating a renamed channel as two channels. The display migration to a new platform product split an 80,470-dollar channel into two rows that each looked too small to bother with. Fix: check the first and last date for every label before grouping.

Deleting anomalies without sizing them. Six negative revenue rows are worth 0.21 percent of revenue. Silent deletion is a habit that will, on a different dataset, delete the finding. Fix: quantify the impact before choosing the fix.

Counting a measurement outage as bad performance. Three consecutive zero-impression days on video are a broken pixel, not three dead days. Left in, they drag a daily average toward zero. Fix: separate "the ads did not work" from "we did not observe the ads", and handle spend and rates differently.

Recommending a scale-up from average return. A 100-day average says the money already spent worked, not what the next dollar does. Fix: compare contribution per order against cost per order over the most recent weeks, and reach for an elasticity only after checking that spend moved because volume moved rather than because price did.

Judging a retargeting channel on cost per acquisition. Retargeting shows a cost per acquisition of 26.68 and a cost per new customer of 158.04 because 83 percent of its orders are repeat buyers. Fix: match the metric to the channel's job, and demand a holdout for any channel whose claim is incrementality.

Cutting all four losing channels at once. They are 59.3 percent of spend and the top of the funnel that feeds brand search, whose 12.90 return is mostly harvested demand. Fix: separate channels you can cut on unit economics from channels whose measurement is structurally biased, and test the second group.

Treating a bootstrap as the whole error budget. The spend slope came from an observational path with a cost-per-click trend sitting inside the regressor, so a tight interval around it is false comfort: a bootstrap prices sampling noise within one specification and is blind to the specification. Fix: name the confounder, run the check that tests it, here orders on clicks against the cost-per-click trend, and only then ask whether the interval is the error term that matters.


Quick self-check

Answer these out loud, in full sentences, before you call the challenge finished.

  1. Your contribution margin is 38 percent instead of 42. What is the break-even return on ad spend, which channels cross the line, and by how much does total portfolio contribution change?

  2. A channel has an average cost per acquisition of 29 dollars, an elasticity of orders with respect to spend of 0.52, and an average order value of 72 dollars. What is the arithmetic that turns those into a marginal answer, and what would you have to check about the spend path before you trusted it?

  3. Why does the same-day conversion window bias the ranking against display and video specifically, and how large would that bias have to be to reverse your recommendation on display?

  4. You are told the eight channel order counts sum to 12 percent more than the company's actual order count. What does that tell you about the attribution setup, and which channel is most likely responsible?

  5. Describe the geographic holdout you would run for prospecting social: number of markets, assignment, duration, outcome metric, and the smallest effect you could detect.

  6. Retargeting has a cost per acquisition of 26.68 and a cost per new customer of 158.04. Which of those two numbers belongs in the recommendation, and what would you need to see before you cut the channel?