learn-experimentation-with-phoebe / Builder session 5 of 10
Learn Experimentation with Phoebe · Builder session 5 of 10

Multivariate testing, and the interactions a stack of A/B tests can't see

A/B tests change one thing. But what if Lumen's new hero image only lifts conversion when it is paired with a punchier CTA - and does nothing, or hurts, with the old copy? That is an interaction, and running two separate A/B tests will never reveal it. Multivariate testing puts every combination in the field at once so you can read main effects AND interactions from a single experiment. Today you fit a two-way ANOVA in statsmodels, read the interaction term, plot it, and see the price MVT charges: cells that multiply and traffic that has to keep up.

🟠 Advanced Builders · DA / DE / DS Python · statsmodels / pyDOE 45 min
0-3 · Setup 3-15 · Factorial + interactions 15-40 · Fit it in Python 40-45 · Wrap
Part 0

Why one-thing-at-a-time misses the story

The clean A/B discipline of b4 - change exactly one thing so the effect is unambiguous - has a blind spot. Real product changes travel in bundles: a new hero image and new CTA copy and a new layout ship together, and their effects are not always additive. When the effect of one factor depends on the level of another, that is an interaction, and a series of isolated A/B tests is structurally incapable of detecting it. Multivariate testing (MVT) tests multiple factors simultaneously in a factorial design, so a single experiment estimates each factor's main effect and the interactions between them. The catch: the number of cells grows with every factor, and your traffic has to fill them all.

Live - built in session Self-study - read after class ★ Build-along - everyone codes it The data: Lumen Skincare
★ What you walk out with today A notebook that (1) simulates a Lumen 2×2 test - hero image {A,B} × CTA copy {A,B} - with a real interaction baked in, fits ols with a C(hero)*C(cta) formula and reads main effects plus the interaction from anova_lm; (2) draws the interaction plot where non-parallel lines betray the interaction; and (3) builds a fractional design with pyDOE2 and demonstrates aliasing - why a smaller design cannot recover a given interaction.
Part 1 · the design

Full vs fractional factorial 6 min live

A full factorial tests every combination of every factor. With k two-level factors that is 2k cells: 2 factors = 4 cells, 3 factors = 8, 5 factors = 32. Its virtue is completeness - it can estimate every main effect and every interaction, up to the k-way. Its cost is traffic: your users split across all those cells, and each cell needs enough volume to be powered. A fractional factorial runs a carefully chosen subset of the cells - halving or quartering the design - to save traffic. The price is aliasing (also called confounding): some effects become mathematically indistinguishable from one another, so you trade the ability to estimate certain (usually higher-order) interactions for a smaller, cheaper design.

Full factorial 2×2 = 4 cells hero {A,B} × CTA {A,B} - every combination runs CTA A CTA B hero A hero B A / A A / B B / A B / B All 4 estimate: hero effect, CTA effect, and the hero×CTA interaction. Fractional of 2³ = run 4 of 8 cells 3 factors, half-fraction (2³⁻¹) - saves traffic 4 cells run 4 cells skipped - their effects alias onto the ones you kept Full = estimate everything, traffic-hungry (2ᵏ cells). Fractional = cheaper, but aliases higher-order interactions with main effects.
🔍 Click to zoom - a full 2×2 fills every cell; a fractional design of a 2³ cube runs only half
LiveWhat "interaction" actually means3 min

An interaction effect exists when the effect of factor A depends on the level of factor B. If the new hero image lifts conversion by 0.5pp with the old CTA but by 1.5pp with the new CTA, hero and CTA interact - you cannot describe the hero effect with a single number, because it is different in each CTA world.

  • Main effect - the average effect of a factor across the levels of the others. "Hero B beats hero A by X on average."
  • Interaction - the extra effect that appears only in specific combinations. Additive models miss it entirely.
  • Why two A/B tests fail - test hero alone, then CTA alone, and you estimate two main effects and assume no interaction. If one exists, both single-factor readings are misleading, and the best cell might be one you never ran together.
Real world

Lumen A/B-tests a bold hero image: flat, no lift, so they shelve it. Separately they A/B-test punchier CTA copy: small lift, they ship it. They never learn that the bold hero + punchy CTA together would have lifted conversion 1.5pp - because the winning combination was never in the same test. That is the interaction an MVT would have caught in one run.

Self-studyWhy MVT is so traffic-hungry3 min read

Two forces make MVT demand far more traffic than a single A/B test.

  • Cells multiply. Each factor multiplies the cell count: 2 factors → 4 cells, 3 → 8, 4 → 16. Your fixed daily traffic (Lumen: ~4,000 checkout sessions/day) is now split many ways, so each cell fills slowly and the test runs longer.
  • Interactions are small. Interaction effects are typically much smaller than main effects, and detecting a small effect needs a large sample (recall the power math from b2 - N scales with 1/δ²). So the very thing MVT exists to find is the thing that needs the most data.
  • The trade. A full factorial with few factors and strong effects is great. Many factors, or a hunt for subtle higher-order interactions, is where you either need enormous traffic or accept a fractional design and its aliasing.
Part 2 · reading it

Parallel lines or crossing lines 3 min live

The fastest way to see an interaction is a picture. Plot the outcome (conversion) on the y-axis, one factor across the x-axis, and one line per level of the other factor. If the lines are parallel, the effect of each factor is the same regardless of the other - no interaction, the model is additive. If the lines are non-parallel (converging, diverging, or crossing), the effect of one factor depends on the level of the other - an interaction is present. Crossing lines are the dramatic case: the best hero image actually flips depending on which CTA you pair it with.

Parallel lines = no interaction conversion CTA A CTA B hero A hero B hero B beats A by the same gap under either CTA - additive. Crossing lines = interaction CTA A CTA B hero A hero B hero A wins under CTA A, hero B wins under CTA B - the effect flips.
🔍 Click to zoom - parallel lines mean no interaction; crossing lines mean the winning factor flips with the other
Self-studyReading the ANOVA table3 min read

Two-way ANOVA decomposes the variation in conversion into pieces and tests each with an F-statistic. In a C(hero)*C(cta) model you get three rows that matter, plus the residual.

TermTestsRead it as
C(hero)main effect of herodoes hero matter on average?
C(cta)main effect of CTAdoes CTA matter on average?
C(hero):C(cta)the interactiondoes hero's effect depend on CTA?
Read the interaction row first If the interaction is significant, the two main effects can no longer be interpreted on their own - "the hero effect" is not a single number, it changes by CTA. Report the interaction and the per-cell means, not just the averages. If the interaction is not significant, then the main effects tell the clean, additive story.
Build-along 1 of 3

Simulate the 2×2 and fit the ANOVA ★ 9 min · everyone

We build a Lumen test with four cells - hero {A,B} × CTA {A,B} - and bake in a real interaction: hero B only helps when paired with CTA B. Then we fit ols with an interaction formula and read anova_lm to recover it.

Simulate ~8,000 users per cell with cell-level conversion rates that carry an interaction:

demo1_simulate_2x2.py
import numpy as np, pandas as pd
rng = np.random.default_rng(42)

# per-cell TRUE conversion rate - note B/B is higher than additivity predicts
rates = {("A","A"): 0.032, ("A","B"): 0.034,
         ("B","A"): 0.031, ("B","B"): 0.045}   # interaction lives in B/B

rows = []
n_cell = 8_000
for (hero, cta), p in rates.items():
    y = (rng.random(n_cell) < p).astype(int)
    rows.append(pd.DataFrame({"hero": hero, "cta": cta, "convert": y}))
df = pd.concat(rows, ignore_index=True)
print(df.groupby(["hero","cta"]).convert.mean().round(4))

Fit the model with a * in the formula - statsmodels expands C(hero)*C(cta) into both main effects plus their interaction:

demo1_anova.py
import statsmodels.formula.api as smf
import statsmodels.api as sm

model = smf.ols("convert ~ C(hero)*C(cta)", data=df).fit()
anova = sm.stats.anova_lm(model, typ=2)
print(anova[["F", "PR(>F)"]].round(4))
# C(hero), C(cta), and C(hero):C(cta) each get an F and a p-value
# the interaction row C(hero):C(cta) should be significant here

Read the interaction row (C(hero):C(cta)) first. Because we built the lift into B/B only, its p-value is small - the effect of the hero image genuinely depends on the CTA. That means you cannot quote "the hero effect" as one number; you report the interaction and the four cell means. Two separate A/B tests would have reported hero B as roughly flat and missed the winning combination entirely.

Build-along 2 of 3

Draw the interaction plot ★ 8 min · everyone

The ANOVA gives you a p-value; the interaction plot gives you the intuition. We plot cell-mean conversion with CTA on the x-axis and one line per hero level - and watch the lines refuse to stay parallel.

Get the four cell means into a shape you can plot:

demo2_cell_means.py
cell = (df.groupby(["hero","cta"]).convert.mean()
          .reset_index())
pivot = cell.pivot(index="cta", columns="hero", values="convert")
print(pivot)
#          hero        A        B
#  cta A               0.032    0.031
#  cta B               0.034    0.045   <- B jumps only here

Plot one line per hero level across the CTA levels - non-parallel lines are the interaction, on screen:

demo2_interaction_plot.py
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(5, 4))
for hero in ["A", "B"]:
    ax.plot(pivot.index, pivot[hero], marker="o", label=f"hero {hero}")
ax.set_xlabel("CTA copy"); ax.set_ylabel("conversion rate")
ax.set_title("Interaction plot - non-parallel = interaction")
ax.legend(); fig.tight_layout(); plt.show()
# hero A is roughly flat; hero B climbs sharply from CTA A to CTA B
# the two lines diverge -> the interaction you just tested, visualized

The lines fan apart: hero B is no better than A under CTA A, but clearly better under CTA B. Statsmodels has an off-the-shelf helper too - statsmodels.graphics.factorplots.interaction_plot(df.cta, df.hero, df.convert) - which draws the same picture from the raw columns. Either way, if you can only show a stakeholder one chart, show this one.

Real world

An interaction plot is the single most persuasive slide in an MVT readout. "Here are two lines; they cross" lands with an exec far better than an F-statistic. When the lines are clearly non-parallel you can say "the best hero depends on the CTA, so we should ship the B/B combination, not hero B everywhere" - a recommendation no pair of A/B tests could have produced.

Build-along 3 of 3

A fractional design, and aliasing ★ 8 min · everyone

Add factors and the full factorial explodes: 5 two-level factors = 32 cells. A fractional design runs a fraction of them to save traffic - but you pay in aliasing. We generate a half-fraction with pyDOE2 and show exactly which effects become indistinguishable.

Generate a full 2³ design and a half-fraction (2³⁻¹) with pyDOE2, using the generator C = A×B:

demo3_fractional.py
from pyDOE2 import ff2n, fracfact
import numpy as np

full = ff2n(3)                 # full 2^3 = 8 runs, factors A B C in -1/+1
print("full factorial runs:", full.shape[0])   # 8

# half-fraction: define C as the product of A and B (generator C = AB)
frac = fracfact("a b ab")      # 2^(3-1) = 4 runs
print("fractional runs:", frac.shape[0])        # 4  -> half the traffic

Show the aliasing directly. Because C was defined as A×B, the column for C and the column for the AB interaction are identical - so their effects cannot be separated:

demo3_aliasing.py
A, B, C = frac[:, 0], frac[:, 1], frac[:, 2]
AB = A * B
print("C  :", C.astype(int))     # e.g. [ 1 -1 -1  1]
print("A*B:", AB.astype(int))    # e.g. [ 1 -1 -1  1]  -> identical!
print("aliased?", np.array_equal(C, AB))   # True

Because column C equals column A×B, the main effect of factor C is aliased with the A×B interaction - the design literally cannot tell them apart. If your fitted "C effect" is large, you genuinely do not know whether it is factor C, the A×B interaction, or a mix. That is the trade: half the traffic, but you gave up the ability to recover that interaction cleanly. Full factorials avoid aliasing entirely - which is why, when you specifically care about an interaction, you either run the full design or choose a fraction whose alias structure protects the effect you care about.

Choose the design by what you need to estimate Want main effects only, with many factors and tight traffic? A fractional (resolution III/IV) design is efficient. Suspect a specific two-factor interaction matters (like hero×CTA)? Run the full factorial for those factors so nothing you care about is aliased. The design is a contract about which effects you will be able to see - sign it before you launch, not after.
Before Session 6

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does an interaction effect between hero image and CTA copy mean?

An interaction means one factor's effect changes with the level of another - hero B might help only under CTA B. Additive models and separate A/B tests cannot capture it.

2 · A full factorial versus a fractional factorial - what is the core trade-off?

Full factorials test every combination (2ᵏ cells) and can estimate every interaction, at a high traffic cost. Fractional designs run a subset to save traffic, but that aliases (confounds) certain effects so they cannot be separated.

3 · On an interaction plot, what do non-parallel (crossing) lines indicate?

Parallel lines mean each factor's effect is constant regardless of the other (additive, no interaction). Non-parallel or crossing lines mean the effect of one factor changes with the other - an interaction.

Source material

What this session covers

Factorial design and interactions are the working core of classical DOE. We teach ~80% of the practical MVT toolkit here - factorial vs fractional, interactions, two-way ANOVA, and aliasing; response-surface optimization and the deeper DOE theory live in the sources below.

Full vs fractional factorial, cells = 2ᵏ, aliasing / confoundingpyDOE2 - Part 1, build-along 3
Interaction effects, main effects, two-way ANOVA (C(A)*C(B))statsmodels ols + anova_lm - Part 1-2, build-along 1
Interaction plots: parallel vs crossing linesmatplotlib - Part 2, build-along 2
Experimentation for Improvement (McMaster, Dunn) 4.9★classical DOE + response-surface methods in depth
Statistics (365 Data Science, Valchanov)the ANOVA / F-test inference backbone

Builder Session 5 cheat sheet · pin this

Multivariate testingTest multiple factors at once in a factorial design to read main effects AND interactions from one experiment.
InteractionEffect of factor A depends on the level of factor B. Separate A/B tests cannot detect it - the winning combo may never be co-tested.
Full factorialEvery combination, 2ᵏ cells. Estimates all interactions; traffic-hungry as k grows.
Fractional factorialA chosen subset of cells. Saves traffic but aliases (confounds) higher-order interactions with main effects.
Two-way ANOVAols("y ~ C(a)*C(b)") + anova_lm. Read the interaction row first; if significant, main effects are not single numbers.
Interaction plotCell means, one line per level. Parallel = no interaction; non-parallel / crossing = interaction present.