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.
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.
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.
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.
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.
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.
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.
| Term | Tests | Read it as |
|---|---|---|
| C(hero) | main effect of hero | does hero matter on average? |
| C(cta) | main effect of CTA | does CTA matter on average? |
| C(hero):C(cta) | the interaction | does hero's effect depend on CTA? |
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:
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:
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.
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:
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:
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.
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.
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:
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:
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.
This week ◐ 40 min total
- Add a third factor. Extend the Lumen test with a layout factor {A,B}, making a 2³ = 8-cell full factorial. At ~8,000 users per cell, that is 64,000 users; at ~4,000 daily checkout sessions, work out the runtime. Feel the cell count bite.
- Count the traffic cost. Tabulate cells and required N for k = 2, 3, 4, 5 factors (2k cells). Plot cells vs k and mark where Lumen's daily traffic makes the test infeasibly long - that is your practical ceiling on full MVT.
- A/B/n vs MVT decision. Take a real Lumen question - say, "which of 4 hero images and 3 CTAs is best?" - and decide: run one big MVT (12 cells), a sequence of A/B tests, or an A/B/n on the single highest-leverage factor. Justify it by expected traffic, whether you suspect interactions, and how fast you need the answer.
- Optional: refit build-along 1 without the interaction term (
convert ~ C(hero) + C(cta)) and compare the fitted B/B prediction to the true 4.5%. See the additive model underpredict exactly the cell where the interaction lived.
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.
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.
Builder Session 5 cheat sheet · pin this
ols("y ~ C(a)*C(b)") + anova_lm. Read the interaction row first; if significant, main effects are not single numbers.