Interactive notebook

This tutorial is a Jupyter notebook. You can view it on GitHub or download it to run locally.

Tutorial 20: HAD for a National Brand Campaign with Regional Spend Intensity#

A practitioner walkthrough for measuring per-dollar lift when every market is treated at a different dose level - treatment varies in intensity, not in status, and no never-treated unit exists. Comparison comes from the dose variation across markets, not from an untreated holdout. The tutorial uses the HeterogeneousAdoptionDiD estimator (alias HAD), built for this case.

1. The Measurement Problem#

Your team launched a Q4 brand campaign with a national TV blast that hit every DMA on the same date. On top of that, regional teams allocated incremental ad budget on a per-DMA basis. The lightest-touch markets put in about $5K; the heaviest committed up to $50K. Every DMA got something - the regional teams all participated - but intensity varied widely. Leadership wants the per-dollar lift on weekly site visits attributable to the regional add-on spend.

Why standard DiD doesn’t fit here. Three things make this hard for the usual estimators.

First, every DMA got the national TV blast simultaneously and every regional team allocated some add-on budget. There is no never-treated unit to serve as the untreated baseline; the comparison structure has to come from the dose variation across markets instead.

Second, regional add-on spend varies continuously across DMAs - from $5K to $50K. Collapsing to a binary high-spend / low-spend indicator throws away the dose information that’s the whole point of the analysis.

Third, continuous-dose DiD methods that DO exist generally require a never-treated unit in the panel. That requirement fails here.

diff-diff’s HeterogeneousAdoptionDiD (alias HAD) is built for this case. It identifies the per-dollar marginal effect from the local-linear behavior of outcome changes near the boundary of the dose distribution (the lightest-touch DMA’s spend), and reports it as a single Weighted Average Slope (WAS) on the dose scale.

[ ]:
import numpy as np
import pandas as pd

from diff_diff import HAD, generate_continuous_did_data

try:
    import matplotlib.pyplot as plt
    HAS_MATPLOTLIB = True
except ImportError:
    HAS_MATPLOTLIB = False
    print('matplotlib not installed - plots will be skipped')

2. The Data#

60 DMAs over 8 weeks. Weeks 1-4 are the pre-campaign baseline; the campaign launches at week 5 with the national TV blast hitting every DMA. Regional teams’ add-on spend varies from $5K to $50K per DMA - every DMA participates, none sits at $0. The true per-$1K lift on weekly site visits is locked at the seed; the tutorial recovers it.

[ ]:
MAIN_SEED = 87
TRUE_SLOPE = 100.0  # weekly visits per $1K of regional spend (locked)
BASELINE_VISITS = 5000.0

raw = generate_continuous_did_data(
    n_units=60,
    n_periods=8,
    cohort_periods=[5],
    never_treated_frac=0.0,
    dose_distribution='uniform',
    dose_params={'low': 5.0, 'high': 50.0},  # $K - every DMA spent at least $5K
    att_function='linear',
    att_intercept=0.0,
    att_slope=TRUE_SLOPE,
    unit_fe_sd=8.0,
    time_trend=0.5,
    noise_sd=2.0,
    seed=MAIN_SEED,
)

panel = raw.copy()
# HAD requires D=0 in the pre-launch period for every unit; the
# generator gives constant dose across periods, so we zero out
# pre-launch rows. Post-launch dose values from the generator are
# kept as-is and the outcomes were generated from them, so the
# DGP is internally consistent (no post-hoc relabeling).
panel.loc[panel['period'] < panel['first_treat'], 'dose'] = 0.0

panel = panel.rename(
    columns={
        'unit': 'dma_id',
        'period': 'week',
        'outcome': 'weekly_visits',
        'dose': 'regional_spend_k',
    }
)
panel['weekly_visits'] = panel['weekly_visits'] + BASELINE_VISITS

panel.head()
[ ]:
post_doses = (
    panel.loc[panel['week'] >= 5]
    .groupby('dma_id')['regional_spend_k']
    .first()
)

print(f'Panel shape: {panel.shape}')
print(f'DMAs: {panel["dma_id"].nunique()}, weeks: {panel["week"].nunique()}')
print(f'\nPost-launch regional spend per DMA ($K):')
print(f'  min:    {post_doses.min():.2f}  (lightest-touch DMA)')
print(f'  median: {post_doses.median():.2f}')
print(f'  max:    {post_doses.max():.2f}')

if HAS_MATPLOTLIB:
    fig, ax = plt.subplots(figsize=(8, 4))
    ax.hist(post_doses, bins=20, edgecolor='white', color='steelblue')
    ax.set_xlabel('Regional add-on spend per DMA ($K)')
    ax.set_ylabel('Number of DMAs')
    ax.set_title('Regional spend distribution across 60 DMAs (post-launch)')
    plt.tight_layout()
    plt.show()
[ ]:
if HAS_MATPLOTLIB:
    tertile = pd.qcut(post_doses, q=3, labels=['low', 'mid', 'high'])
    panel_with_tertile = panel.merge(
        tertile.rename('spend_tertile'), left_on='dma_id', right_index=True
    )
    grouped = (
        panel_with_tertile.groupby(['week', 'spend_tertile'], observed=True)
        ['weekly_visits'].mean().unstack()
    )

    fig, ax = plt.subplots(figsize=(8, 4))
    colors = {'low': 'lightcoral', 'mid': 'goldenrod', 'high': 'steelblue'}
    for tier in ('low', 'mid', 'high'):
        ax.plot(
            grouped.index, grouped[tier], marker='o',
            color=colors[tier], label=f'{tier} regional spend',
        )
    ax.axvline(4.5, linestyle='--', color='gray', alpha=0.7)
    ax.set_xlabel('Week')
    ax.set_ylabel('Mean weekly visits per DMA')
    ax.set_title('Weekly visits by regional-spend tertile (dashed = launch)')
    ax.legend()
    plt.tight_layout()
    plt.show()

3. Fitting HAD: The Headline Per-Dollar Lift#

HAD targets the Weighted Average Slope (WAS): roughly, the average per-dollar marginal effect across the dose distribution. Because all DMAs received at least some regional spend (none at exactly zero), HAD anchors the local-linear fit at the boundary of the dose distribution - the lightest-touch DMA’s spend, called d_lower. The estimand is then WAS_d_lower: the average per-dollar marginal effect of regional spend above d_lower.

HAD’s headline mode wants exactly two time points (a pre-launch snapshot and a post-launch snapshot). We collapse the 8-week panel into a 2-row-per-DMA panel by averaging weekly visits within the pre-launch window (weeks 1-4) and the post-launch window (weeks 5-8). The post-period regional spend is each DMA’s actual spend; the pre-period is zero by construction.

[ ]:
panel_2pd = panel.copy()
panel_2pd['period'] = (panel_2pd['week'] >= 5).astype(int) + 1  # 1=pre, 2=post
panel_2pd = (
    panel_2pd.groupby(['dma_id', 'period'], as_index=False)
    .agg(
        weekly_visits=('weekly_visits', 'mean'),
        regional_spend_k=('regional_spend_k', 'mean'),
    )
)

est = HAD(design='auto')
result = est.fit(
    panel_2pd,
    outcome_col='weekly_visits',
    dose_col='regional_spend_k',
    time_col='period',
    unit_col='dma_id',
)
print(result.summary())

Reading the result. HAD estimates a per-$1K marginal effect of about 100 weekly visits per DMA above the boundary spend, with a 95% confidence interval running from 98.6 to 101.4. The estimate sits essentially on the true per-$1K lift of 100.

Under a (locally) linear dose-response, this WAS_d_lower estimate translates to per-DMA dollar lift through the difference between each DMA’s spend and the boundary d_lower (about $5K). A DMA that allocated $30K of regional add-on spend saw roughly (30 - 5) x 100 = ~2,500 extra weekly visits; a DMA that allocated $10K saw roughly (10 - 5) x 100 = ~500. Multiply by your revenue per visit to put the lift in dollar terms.

A note on the UserWarning above. The library fired a UserWarning reminding us that this regime (Design 1, with d_lower > 0) requires Assumption 6 from de Chaisemartin et al. (2026) for point identification of WAS_d_lower, or Assumption 5 for sign identification only. Both are about local linearity of the dose-response near d_lower - neither is testable from data. In our example, the dose-response is linear by DGP construction, so Assumption 6 holds. In a real analysis, you’d justify this from domain knowledge (e.g., is there reason to believe the marginal effect of the next $1K of regional spend is roughly constant in the $5K-$50K range?).

[ ]:
print(f'WAS_d_lower estimate (att):  {result.att:.4f}')
print(f'Standard error:              {result.se:.4f}')
print(f'95% CI:                      [{result.conf_int[0]:.4f}, {result.conf_int[1]:.4f}]')
print(f'\nDesign diagnostic:')
print(f'  design path:               {result.design!r}')
print(f'  target parameter:          {result.target_parameter!r}')
print(f'  d_lower (boundary spend):  ${result.d_lower:.2f}K')
print(f'  dose mean (D-bar):         ${result.dose_mean:.2f}K')
print(f'  N units:                   {result.n_obs}')
print(f'  N units above d_lower:     {result.n_treated}')

What the design path tells us. HAD picked the continuous_near_d_lower regime because all DMAs have positive regional spend - no DMA sits at exactly $0 to anchor a different design. The boundary d_lower resolves to the lightest-touch DMA’s spend (about $5K). From there, HAD identifies the slope at the boundary using a local-linear fit on the differenced outcome, interpreted as the average per-dollar marginal effect of regional spend above d_lower, integrated across the dose distribution.

If the lightest-touch DMA had spent exactly $0 (no regional add-on), HAD would have switched to a different identification path (continuous_at_zero, Design 1’) with target WAS instead of WAS_d_lower. The auto-detection picks the right one based on the data; you don’t have to choose it manually.

4. Multi-Week Event Study#

The headline WAS_d_lower collapses 4 post-launch weeks into a single number. To see whether the per-dollar lift was immediate, building, or fading across the campaign, fit HAD’s event-study mode on the full multi-week panel. It returns a per-week WAS_d_lower for each post-launch week (weeks since launch e=0, 1, 2, 3) plus pre-launch placebo horizons (e=-2, -3, -4) that should sit on zero if pre-trends are parallel.

(The library fires the same Assumption 5/6 UserWarning on this fit as on the headline fit above; we addressed it there. The filter below keeps the cell output focused on the event-study table.)

[ ]:
import warnings

with warnings.catch_warnings():
    warnings.filterwarnings(
        'ignore',
        message=r".*continuous_near_d_lower.*Assumption.*",
        category=UserWarning,
    )
    est_es = HAD(design='auto')
    result_es = est_es.fit(
        panel,
        outcome_col='weekly_visits',
        dose_col='regional_spend_k',
        time_col='week',
        unit_col='dma_id',
        first_treat_col='first_treat',
        aggregate='event_study',
    )
print(result_es.summary())
[ ]:
es_df = result_es.to_dataframe()
es_df.round(3)
[ ]:
if HAS_MATPLOTLIB:
    event_times = list(result_es.event_times)
    atts = list(result_es.att)
    ci_lows = list(result_es.conf_int_low)
    ci_highs = list(result_es.conf_int_high)

    fig, ax = plt.subplots(figsize=(9, 4.5))
    for e, att, lo, hi in zip(event_times, atts, ci_lows, ci_highs):
        color = 'steelblue' if e >= 0 else 'gray'
        ax.errorbar(
            [e], [att],
            yerr=[[att - lo], [hi - att]],
            fmt='o', color=color, capsize=4,
        )
    ax.axvline(-0.5, linestyle='--', color='gray', alpha=0.7)
    ax.axhline(0, linestyle='--', color='black', alpha=0.5)
    ax.set_xlabel('Weeks since campaign launch')
    ax.set_ylabel('Per-$1K lift above d_lower (weekly visits)')
    ax.set_title('Per-week WAS_d_lower with 95% CIs (gray = pre-launch placebos)')
    plt.tight_layout()
    plt.show()

Reading the dynamics.

  • The pre-launch placebo horizons (weeks -4, -3, -2) all sit at essentially zero - per-$1K effects within ±0.06 with 95% CIs comfortably bracketing zero. Visually consistent with parallel pre-trends. (Note: this is a visual placebo check, not a formal pretest - HAD ships a separate composite pretest workflow we did not run here; see extensions.)

  • The per-week post-launch effects (weeks 0, 1, 2, 3) all hover right around 100 visits per $1K with overlapping 95% CIs and lower bounds well above zero. The per-dollar lift is stable across all four weeks of the campaign.

  • Practically: the campaign delivered its per-dollar lift on impact and held it across all four post-launch weeks. No ramp-up, no fade.

5. Communicating the Result to Leadership#

When you bring this back to a non-technical audience, lead with the headline number and ground each follow-on claim in a specific piece of evidence. The template below mirrors the structure used in the brand-awareness and geo-experiment tutorials in this library.

Headline. Across 60 DMAs, the regional add-on ad spend delivered approximately 100 incremental weekly visits per additional $1,000 of regional spend above the lightest-touch DMA’s $5K floor (95% CI: 98.6 to 101.4). On a baseline of about 5,000 weekly visits per DMA, the heaviest-spending DMAs ($50K) saw roughly 4,500 extra weekly visits during the campaign window. [Source: ``result.att`` and ``result.conf_int`` from Section 3.]

Sample size and design. 60 DMAs observed for 8 weeks (480 DMA-weeks). Every DMA received the national TV blast on the same date; regional add-on spend ranged from a $5K floor to $50K (median ~$25K). Method: HeterogeneousAdoptionDiD (HAD), built for panels where treatment varies in intensity across units and no never-treated unit is available; comparison comes from the dose variation across markets. [Source: panel shape and dose distribution from Section 2.]

Validity evidence. Two visual checks. (a) The per-week placebo horizons before launch (weeks -4, -3, -2) sit at essentially zero, with 95% CIs comfortably bracketing zero - DMAs were not already trending differently by spend tier. (This is a visual parallel-trends check, not a formal pretest; HAD also ships a composite pretest workflow we did not run here.) (b) Per-week post-launch effects are stable across all four campaign weeks at ~100 visits per $1K. [Source: event-study horizons from Section 4.]

Business interpretation. The per-$1K lift applies to spend above the $5K floor (HAD’s local boundary). Subtracting the floor from each DMA’s actual spend and multiplying gives the per-DMA visit lift estimate: a DMA at $30K saw roughly (30 - 5) x 100 = 2,500 extra weekly visits during the campaign; a DMA at $10K saw roughly (10 - 5) x 100 = 500. Translate to your own revenue-per-visit to compare against regional spend.

Practical significance. The per-dollar lift was stable across all four post-launch weeks - the campaign delivered its return on impact and held it. Whether the absolute per-DMA dollar lift justifies the regional spend is a business judgment, not a statistical one. Identification of WAS_d_lower additionally requires Assumption 6 (local linearity of the dose-response near the boundary), which is non-testable and should be argued from domain knowledge. [Source: per-week dynamics from Section 4.]

Adapt this template by swapping in your own numbers from result.att, result.conf_int, result.d_lower, the per-week event-study table, and your own DMA / spend distribution. The pattern - headline → sample → validity → business → practical - is what to keep.

6. Extensions#

This tutorial covered HAD’s headline workflow: the overall WAS_d_lower fit and the multi-week event study. The library also supports several extensions we did not demonstrate here.

  • Population-weighted (survey-aware) inference: when some markets or regions carry more weight than others - e.g., DMAs weighted by population - HAD accepts a SurveyDesign object on the same fit() interface (the deprecated weights= and survey= kwarg aliases will be removed in the next minor release; use survey_design= going forward). Tutorial 22 walks the BRFSS-shape survey-design path end-to-end including the pretest workflow.

  • Composite pretest workflow: HAD ships a did_had_pretest_workflow that combines the QUG support-infimum test (H0: d_lower = 0, which adjudicates between the continuous_at_zero and continuous_near_d_lower design paths) with linearity tests (Stute and Yatchew-HR). On the two-period (aggregate='overall') path this workflow checks QUG and linearity only; the parallel-trends step is closed by the multi-period (aggregate='event_study') joint variants (stute_joint_pretest, joint_pretrends_test, joint_homogeneity_test). The visual placebo check we used in Section 4 is a parallel-trends sanity check, not a substitute for the formal joint pretests; see Tutorial 21 for an end-to-end pretest walkthrough.

  • ``continuous_at_zero`` design path: if the lightest-touch DMA had no regional add-on (spend exactly $0), HAD switches to the Design 1’ identification path with target WAS instead of WAS_d_lower. The auto-detection picks it up.

  • Mass-point design path: if a meaningful chunk of DMAs sit at exactly the same minimum spend (rather than spread continuously near the boundary), HAD switches to a 2SLS estimator with matching identification logic. Auto-detected as well.

See the `HeterogeneousAdoptionDiD API reference <../api/had.html>`__ for the full parameter list.

Related tutorials.

Summary checklist.

  • HAD is the right tool when treatment varies in intensity across units, the dose distribution is continuous, and no never-treated unit exists; comparison comes from the dose variation across units.

  • The WAS_d_lower (Weighted Average Slope at the boundary) reports per-dose-unit lift on the dose scale, anchored at the lightest-touch unit’s dose. Multiply by (actual_dose - d_lower) to get per-unit treatment effects under (locally) linear dose-response.

  • The design auto-detection picks the right identification path from the dose distribution - usually you can leave design='auto' alone.

  • The Design 1 path requires Assumption 5 or 6 for identification, both about local linearity at the boundary - non-testable, justify from domain knowledge.

  • The event-study mode extends the headline WAS_d_lower into per-week dynamics, with pre-launch placebos that double as a parallel-trends visual check (formal pretests are a separate workflow noted in extensions).