Quiz Q1
~89 wordsTwo of Junco's shipped experiment wins this year never showed up in the subscription numbers afterw…
How LitMetrics works
Data Scientist — Junco
Goes past the headline number to the cause. Directs the AI and checks its numbers — but quoted two answers unverified. Clean hand-written code, conclusions in plain English. A practiced experimentation analyst.
The activation-by-platform numbers came from the assistant's chat, never from a cell that ran. Ask the candidate to reproduce them live.
The candidate proved the headline lift was an artifact of unequal group sizes, traced it to one traffic source in one week, re-estimated the effect two independent ways with ranges, and delivered a clear no-launch call plus a concrete re-run plan.
Prompts carried a stated hypothesis, method constraints and what would count as an answer, and the candidate re-derived the assistant's key correction and blocked a bad conclusion — but took the mix and activation figures into the write-up straight from chat, with no cell of their own behind them.
Strong experiment-validation instincts in their own work — checked group sizes before trusting the result, found and fixed their own coding traps, and explained the caveats in plain business language; the only soft spot is leaning on assistant-reported figures for the activation story.
Strong: recognises the cell had no output, says they'd re-run it and check counts per platform before quoting. Weak: repeats the numbers with confidence and can't say where they came from.
Strong: explains that restricting to the clean window throws away data but is assumption-light, post-stratification keeps the data but assumes only channel mix broke, and notes both intervals cover zero so the honest answer is 'no detectable effect'. Weak: picks one arbitrarily or treats the two as interchangeable.
Strong: names verified assignment monitoring, an up-front sample-size or duration calculation against a realistic conversion effect, and a pre-agreed primary metric. Weak: says 'run it longer' with no sizing or no assignment check.
Strong: distinguishes diagnostic segmentation to explain a broken assignment from inferential segment effects used to make a claim, and mentions multiplicity or pre-registration. Weak: doesn't see the tension or defends the platform cut as an effect estimate.
Strong: concedes activation is genuine, states plainly that shipping means never learning whether the flow pays, and offers a concrete middle path such as a properly assigned staged rollout with a real readout. Weak: either caves immediately or repeats intervals and p-values at her.
A few things could do that. The flow might have moved something next to the money rather than the money itself, so the readout is a proxy win that never reaches the subscription line. The two groups might not have been comparable to begin with, in which case the gap was already there before anyone built the flow. And novelty fades - a new flow gets attention in its first weeks - while a launch goes to everyone, not to the traffic that happened to be in the test.
Is the 13% lift real - the other two barely exist until it is answered. If the readout does not hold, "should it ship Monday" has one answer and "which users it helped most" is a question about an artefact. Twenty minutes covers the check that matters: reproduce the headline, compare the arm sizes against the 50/50 the ticket specified, then cross-tab variant against channel and signup week to see whether any imbalance sits somewhere in particular. Which users it helped most I would leave - segment cuts on a three-week test are the ask most likely to produce a confident wrong answer under time pressure.
That the gap clears significance tells me the difference is unlikely to be chance, and nothing beyond that. A p-value assumes the two groups were built the same way; it cannot tell me whether they were. So before I believe 10.4 against 11.8 I would check how many users are in each arm against the 50/50 the ticket specified, and if those counts are materially apart, find where the imbalance is concentrated - variant by acquisition channel, and by signup week. Then I would want the effect recomputed on a slice where the split is clean and reported with an interval rather than as a point.
The readout says the new onboarding lifts trial-to-paid conversion. Reproduce it.
Return one row per variant with three things: how many users were in it, how many converted, and the conversion rate as a percentage rounded to one decimal place. Order by assigned_variant.
The data is loaded as the SQL table case_data (and sits on disk as case_data.csv).
SELECT * FROM case_data LIMIT 10
-- Rate two ways so the fix is not itself a typo. -- 12943 control against 11057 treatment on a test specified 50/50 - -- that is 1886 users of difference and it is not rounding. SELECT assigned_variant, COUNT(*) AS users, SUM(converted_to_paid) AS converted, ROUND(100.0 * SUM(converted_to_paid) / COUNT(*), 1) AS conversion_pct, ROUND(AVG(converted_to_paid) * 100, 1) AS conversion_pct_check FROM case_data GROUP BY assigned_variant ORDER BY assigned_variant
Break the test down by experiment week.
Write a function weekly(df) that returns one row per (week, assigned_variant) with the number of users and the trial-to-paid conversion rate.
week is 1, 2 or 3 — seven-day blocks counted from the earliest signup_date in the data. Not calendar weeks.
df = pd.read_csv('case_data.csv')
weekly(df)Run the cell and show the output.
import pandas as pd df = pd.read_csv('case_data.csv') def weekly(df): # 7-day blocks from the first signup, not calendar weeks. d = pd.to_datetime(df['signup_date']) week = (d - d.min()).dt.days // 7 + 1 out = df.assign(week=week).groupby(['week', 'assigned_variant']).agg( users=('user_id', 'count'), conversion_rate=('converted_to_paid', 'mean')) out['conversion_rate'] = (100 * out['conversion_rate']).round(1) return out.reset_index() w = weekly(df) print(w.to_string(index=False)) # On a 50/50 design the per-week split is the thing to read first. piv = w.pivot(index='week', columns='assigned_variant', values='users') piv['treatment_share_pct'] = (100 * piv['treatment'] / (piv['control'] + piv['treatment'])).round(1) print() print(piv.to_string())
The readout says trial-to-paid conversion went from 10.4% to 11.8%, a 13% relative lift at p < 0.001.
Reproduce it, then work out whether that number means what the team thinks it means. If it doesn't, say what the honest estimate is.
import pandas as pd
from scipy import stats
df = pd.read_csv('case_data.csv')
df['signup_date'] = pd.to_datetime(df['signup_date'])
# --- Overall headline lift ---
g = df.groupby('assigned_variant').agg(n=('user_id','count'),
conv=('converted_to_paid','mean'),
act=('activated_48h','mean'))
print(g)
lift = g.loc['treatment','conv'] / g.loc['control','conv'] - 1
print(f'\nheadline conversion lift: {lift:.1%}')
# --- Sample Ratio Mismatch check: should be ~50/50 by design ---
n_c = (df.assigned_variant == 'control').sum()
n_t = (df.assigned_variant == 'treatment').sum()
chi2, p_srm = stats.chisquare([n_c, n_t])
print(f'\nn_control={n_c}, n_treatment={n_t}, SRM chi2 p-value={p_srm:.3g}')
# --- Where does the imbalance come from? Split by early vs later days ---
df['period'] = (df.signup_date < '2026-03-11').map({True: 'wk1 (Mar4-10)', False: 'wk2-3 (Mar11-24)'})
print('\nVariant split by period:')
print(df.groupby('period').assigned_variant.value_counts(normalize=True).unstack())
print('\nWithin wk1, variant split by acquisition_channel:')
wk1 = df[df.period == 'wk1 (Mar4-10)']
print(pd.crosstab(wk1.acquisition_channel, wk1.assigned_variant, normalize='index'))
print('\nConversion rate by period x variant:')
print(df.groupby(['period', 'assigned_variant']).converted_to_paid.mean())
n conv act
assigned_variant
control 12943 0.103531 0.464807
treatment 11057 0.117663 0.550330
headline conversion lift: 13.7%
n_control=12943, n_treatment=11057, SRM chi2 p-value=4.27e-34
Variant split by period:
assigned_variant control treatment
period
wk1 (Mar4-10) 0.619776 0.380224
wk2-3 (Mar11-24) 0.498747 0.501253
Within wk1, variant split by acquisition_channel:
assigned_variant control treatment
acquisition_channel
organic 0.484159 0.515841
paid_search 0.527941 0.472059
paid_social 0.877351 0.122649
referral 0.514090 0.485910
Conversion rate by period x variant:
period assigned_variant
wk1 (Mar4-10) control 0.093919
treatment 0.134773
wk2-3 (Mar11-24) control 0.109548
treatment 0.111125
Name: converted_to_paid, dtype: float64import numpy as np
from scipy import stats
# --- Bucket into 7-day experiment blocks from first signup (not calendar week) ---
start = df.signup_date.min()
df['experiment_week'] = ((df.signup_date - start).dt.days // 7) + 1
# --- 1) Where is the imbalance concentrated? count + treatment share per cell ---
def variant_breakdown(index_col):
ct = df.groupby([index_col, 'assigned_variant']).size().unstack(fill_value=0)
ct['n'] = ct['control'] + ct['treatment']
ct['treatment_share'] = (ct['treatment'] / ct['n']).round(3)
return ct
print('By acquisition_channel:')
print(variant_breakdown('acquisition_channel'))
print('\nBy experiment_week (7-day blocks from first signup):')
print(variant_breakdown('experiment_week'))
# --- 2) Conversion difference restricted to clean days only (experiment_week 2-3) ---
clean = df[df.experiment_week != 1]
n_c = (clean.assigned_variant == 'control').sum()
n_t = (clean.assigned_variant == 'treatment').sum()
print(f'\nClean-window arm sizes -> control={n_c}, treatment={n_t}')
p_c = clean.loc[clean.assigned_variant == 'control', 'converted_to_paid'].mean()
p_t = clean.loc[clean.assigned_variant == 'treatment', 'converted_to_paid'].mean()
diff = p_t - p_c
se = np.sqrt(p_c*(1-p_c)/n_c + p_t*(1-p_t)/n_t)
ci_lo, ci_hi = diff - 1.96*se, diff + 1.96*se
print(f'\nClean-window conversion: control={p_c:.4f}, treatment={p_t:.4f}')
print(f'Diff (treatment - control) = {diff:.4f} [95% CI: {ci_lo:.4f}, {ci_hi:.4f}]')
By acquisition_channel: assigned_variant control treatment n treatment_share acquisition_channel organic 3359 3340 6699 0.499 paid_search 3074 3017 6091 0.495 paid_social 4493 2744 7237 0.379 referral 2017 1956 3973 0.492 By experiment_week (7-day blocks from first signup): assigned_variant control treatment n treatment_share experiment_week 1 4983 3057 8040 0.380 2 3954 4084 8038 0.508 3 4006 3916 7922 0.494 Clean-window arm sizes -> control=7960, treatment=8000 Clean-window conversion: control=0.1095, treatment=0.1111 Diff (treatment - control) = 0.0016 [95% CI: -0.0081, 0.0113]
# Cross-check the corrected effect independently, before I take the
# assistant's number for the write-up. Two different corrections:
# post-stratify the WHOLE test on channel, and restrict to the clean
# window by filtering dates directly rather than via a week column.
import pandas as pd
import numpy as np
from scipy import stats
chk = pd.read_csv('case_data.csv')
def eff(a, b):
p1, p2 = a.mean(), b.mean()
se = np.sqrt(p1 * (1 - p1) / len(a) + p2 * (1 - p2) / len(b))
return p2 - p1, se
# 1. Clean window. signup_date is text but it is ISO, so it sorts and
# compares correctly as text - week 1 is 4-10 March, so >= the 11th.
clean = chk[chk['signup_date'] >= '2026-03-11']
c = clean[clean['assigned_variant'] == 'control']['converted_to_paid']
t = clean[clean['assigned_variant'] == 'treatment']['converted_to_paid']
d1, se1 = eff(c, t)
p1 = 2 * (1 - stats.norm.cdf(abs(d1 / se1)))
print('clean window: n=%d, treatment share %.1f%%' % (len(clean), 100 * len(t) / len(clean)))
print('clean effect: %+.2fpp 95%% CI [%+.2f, %+.2f] p=%.2f' % (100 * d1, 100 * (d1 - 1.96 * se1), 100 * (d1 + 1.96 * se1), p1))
# 2. Post-stratified on channel, whole test, weighted by channel size.
est, var = 0.0, 0.0
for ch, sub in chk.groupby('acquisition_channel'):
wgt = len(sub) / len(chk)
a = sub[sub['assigned_variant'] == 'control']['converted_to_paid']
b = sub[sub['assigned_variant'] == 'treatment']['converted_to_paid']
d, se = eff(a, b)
est += wgt * d
var += (wgt * se) ** 2
se2 = np.sqrt(var)
print('post-stratified: %+.2fpp 95%% CI [%+.2f, %+.2f]' % (100 * est, 100 * (est - 1.96 * se2), 100 * (est + 1.96 * se2)))clean window: n=15960, treatment share 50.1% clean effect: +0.16pp 95% CI [-0.81, +1.13] p=0.75 post-stratified: +0.48pp 95% CI [-0.30, +1.27]
# Clean window only (signup_date >= 2026-03-11, where the split is ~50/50).
# 1. activated_48h by variant, and by variant x platform
# 2. converted_to_paid by platform, same window
import pandas as pd
import numpy as np
from scipy import stats
df = pd.read_csv('case_data.csv')
clean = df[df['signup_date'] >= '2026-03-11'].copy()
def diff_ci(a, b):
p1, p2 = a.mean(), b.mean()
se = np.sqrt(p1 * (1 - p1) / len(a) + p2 * (1 - p2) / len(b))
d = p2 - p1
p = 2 * (1 - stats.norm.cdf(abs(d / se)))
return len(a), len(b), p1, p2, d, d - 1.96 * se, d + 1.96 * se, p
# --- activated_48h: overall, then by platform ---
rows = []
c = clean[clean['assigned_variant'] == 'control']['activated_48h']
t = clean[clean['assigned_variant'] == 'treatment']['activated_48h']
n_c, n_t, p_c, p_t, d, lo, hi, p = diff_ci(c, t)
rows.append(('ALL', n_c, n_t, p_c, p_t, d, lo, hi, p))
for plat, sub in clean.groupby('platform'):
c = sub[sub['assigned_variant'] == 'control']['activated_48h']
t = sub[sub['assigned_variant'] == 'treatment']['activated_48h']
n_c, n_t, p_c, p_t, d, lo, hi, p = diff_ci(c, t)
rows.append((plat, n_c, n_t, p_c, p_t, d, lo, hi, p))
act = pd.DataFrame(rows, columns=['group', 'n_control', 'n_treatment', 'control_rate',
'treatment_rate', 'diff_pp', 'ci_lo_pp', 'ci_hi_pp', 'p_value'])
for col in ['control_rate', 'treatment_rate', 'diff_pp', 'ci_lo_pp', 'ci_hi_pp']:
act[col] = (act[col] * 100).round(2)
act['p_value'] = act['p_value'].round(3)
print('activated_48h by variant, clean window (rows are platform-level):')
display(act)
# --- converted_to_paid by platform, same window ---
rows2 = []
for plat, sub in clean.groupby('platform'):
c = sub[sub['assigned_variant'] == 'control']['converted_to_paid']
t = sub[sub['assigned_variant'] == 'treatment']['converted_to_paid']
n_c, n_t, p_c, p_t, d, lo, hi, p = diff_ci(c, t)
rows2.append((plat, n_c, n_t, p_c, p_t, d, lo, hi, p))
conv = pd.DataFrame(rows2, columns=['platform', 'n_control', 'n_treatment', 'control_rate',
'treatment_rate', 'diff_pp', 'ci_lo_pp', 'ci_hi_pp', 'p_value'])
for col in ['control_rate', 'treatment_rate', 'diff_pp', 'ci_lo_pp', 'ci_hi_pp']:
conv[col] = (conv[col] * 100).round(2)
conv['p_value'] = conv['p_value'].round(3)
print('\nconverted_to_paid by platform, clean window:')
display(conv)
activated_48h by variant, clean window (rows are platform-level):
converted_to_paid by platform, clean window:
The new flow goes to 100% of signups on Monday unless someone says otherwise. Say what should happen, and what this test does and does not entitle the team to conclude.
activated_48h is the metric the flow was actually designed to move. Worth a look before you answer.
Task 2 — Monday: what this test entitles the team to conclude
activated_48h is the metric the flow was designed to move, and even on the clean portion of this broken test it moved substantially on mobile. That's a signal the mechanism is doing something real — enough to justify spending a clean test on it — not evidence the flow works, and not something to discard because the randomization failed elsewhere.# Why the broken week moved the headline: the mix
# 1. conversion rate by acquisition_channel, whole test
# 2. each arm's channel composition (%), whole test
# 3. conversion rate for activated vs non-activated users
import pandas as pd
df = pd.read_csv('case_data.csv')
by_channel = df.groupby('acquisition_channel')['converted_to_paid'].mean().mul(100).round(2)
by_channel = by_channel.rename('conversion_rate_pct').sort_values(ascending=False)
arm_mix = pd.crosstab(df['assigned_variant'], df['acquisition_channel'], normalize='index').mul(100).round(2)
by_activation = df.groupby('activated_48h')['converted_to_paid'].mean().mul(100).round(2)
by_activation = by_activation.rename('conversion_rate_pct')
print("Conversion rate by channel (whole test):")
print(by_channel, "\n")
print("Channel mix by arm, % of each arm's signups:")
print(arm_mix, "\n")
print("Conversion rate by activation status:")
print(by_activation)
Conversion rate by channel (whole test): acquisition_channel referral 18.73 organic 15.35 paid_search 9.03 paid_social 4.41 Name: conversion_rate_pct, dtype: float64 Channel mix by arm, % of each arm's signups: acquisition_channel organic paid_search paid_social referral assigned_variant control 25.95 23.75 34.71 15.58 treatment 30.21 27.29 24.82 17.69 Conversion rate by activation status: activated_48h 0 7.42 1 14.53 Name: conversion_rate_pct, dtype: float64
The 13% lift is not real. The test never ran 50/50: control has 12,943 users against treatment's 11,057, and the whole gap sits in one cell - paid-social signups in the first seven days, 2,146 in control against 300 in treatment. Paid social converts at 4.4% against referral's 18.7%, so control was loaded with our worst-converting traffic, and the "lift" is the mix rather than the flow. Corrected two independent ways it disappears: on the 14 days where the split is clean, +0.16pp (p = 0.75, 95% CI -0.81 to +1.13); post-stratified on channel across the whole test, +0.48pp. That does not make the flow bad - this test simply never measured it. So don't ship Monday, and re-run with assignment verified rather than argue about the existing number. The check to hand engineering: why did paid-social traffic assign late in week 1. One result worth keeping - 48-hour activation, the metric the flow was built to move, rose 8.6pp on the clean window, iOS +11.9pp against web's +1.0pp, which is what a native flow would do. That is the reason to re-run it rather than drop it.
- Check the arm sizes against the split the ticket specified before reading the metric at all. 12,943 against 11,057 on a 50/50 test is a broken assignment, and a small p-value on the metric does not survive it. - When the split is off, find the cell it lives in - channel by week, here - rather than adjusting the whole test at once. - Don't read a move in the secondary metric as evidence for the primary one. Activation went up 8.6pp and conversion did not follow it.