-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalyze_experiments.py
More file actions
799 lines (703 loc) · 33.7 KB
/
Copy pathanalyze_experiments.py
File metadata and controls
799 lines (703 loc) · 33.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
"""
=============================================================================
LinguaLeap Experimentation Program - Analysis Suite
=============================================================================
Each experiment uses analytical methods chosen to illuminate its specific
teaching moment — not applying every technique to every test.
E1: Standard proportion test + SRM check + segment consistency
E2: Segment analysis + Simpson's paradox diagnosis
E3: Time-series decay analysis + rolling-window lift
E4: Funnel analysis + primary-vs-guardrail metric conflict
E5: Power analysis + "what sample size did we actually need"
E6: CUPED variance reduction + Bayesian inference + holdout validation
=============================================================================
"""
import numpy as np
import pandas as pd
from scipy import stats
from scipy.stats import chi2_contingency, norm, beta
import json
import os
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
EXPERIMENTS_DIR = _ROOT / "data" / "experiments"
RESULTS_DIR = _ROOT / "data" / "results"
CHARTS_DIR = _ROOT / "charts"
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
np.random.seed(2025)
# =============================================================================
# SHARED UTILITIES
# =============================================================================
def srm_check(n_control, n_treatment, expected_ratio=0.5):
"""Sample Ratio Mismatch check via chi-square."""
observed = [n_control, n_treatment]
expected_total = n_control + n_treatment
expected = [expected_total * (1 - expected_ratio), expected_total * expected_ratio]
chi2 = sum((o - e)**2 / e for o, e in zip(observed, expected))
p_value = 1 - stats.chi2.cdf(chi2, df=1)
return {'chi2': round(chi2, 3), 'p_value': round(p_value, 4), 'passed': bool(p_value > 0.001)}
def proportion_test(c_success, c_total, t_success, t_total):
"""Two-proportion z-test."""
p_c = c_success / c_total
p_t = t_success / t_total
p_pooled = (c_success + t_success) / (c_total + t_total)
se = np.sqrt(p_pooled * (1 - p_pooled) * (1/c_total + 1/t_total))
z = (p_t - p_c) / se if se > 0 else 0
p_value = 2 * (1 - norm.cdf(abs(z)))
# CI on absolute difference
se_diff = np.sqrt(p_c * (1 - p_c) / c_total + p_t * (1 - p_t) / t_total)
abs_diff = p_t - p_c
ci_lower = abs_diff - 1.96 * se_diff
ci_upper = abs_diff + 1.96 * se_diff
return {
'control_rate': round(p_c, 5),
'treatment_rate': round(p_t, 5),
'absolute_diff': round(abs_diff, 5),
'relative_lift_pct': round((p_t / p_c - 1) * 100, 2) if p_c > 0 else 0,
'z_stat': round(z, 3),
'p_value': round(p_value, 5),
'ci_95_abs': [round(ci_lower, 5), round(ci_upper, 5)],
'significant_at_05': bool(p_value < 0.05),
}
def bayesian_ab_beta(c_success, c_total, t_success, t_total, n_sim=50000):
"""Bayesian A/B test using Beta-Binomial conjugate."""
# Priors: uniform Beta(1,1)
a_prior, b_prior = 1, 1
posterior_c = beta(a_prior + c_success, b_prior + c_total - c_success)
posterior_t = beta(a_prior + t_success, b_prior + t_total - t_success)
samples_c = posterior_c.rvs(n_sim)
samples_t = posterior_t.rvs(n_sim)
# Relative lift distribution
rel_lift = (samples_t - samples_c) / samples_c
return {
'prob_treatment_better': round((samples_t > samples_c).mean(), 4),
'expected_lift_pct': round(rel_lift.mean() * 100, 2),
'lift_ci_90_pct': [
round(np.percentile(rel_lift, 5) * 100, 2),
round(np.percentile(rel_lift, 95) * 100, 2)
],
'expected_loss_if_ship_pct': round(max(0, -rel_lift.mean()) * 100, 3),
}
def ttest_continuous(c_data, t_data):
"""Standard t-test on continuous outcomes."""
c_mean, t_mean = c_data.mean(), t_data.mean()
c_std, t_std = c_data.std(ddof=1), t_data.std(ddof=1)
t_stat, p_value = stats.ttest_ind(t_data, c_data, equal_var=False)
# 95% CI on absolute difference
se = np.sqrt(c_std**2/len(c_data) + t_std**2/len(t_data))
diff = t_mean - c_mean
ci_lower = diff - 1.96 * se
ci_upper = diff + 1.96 * se
return {
'control_mean': round(c_mean, 4),
'treatment_mean': round(t_mean, 4),
'control_std': round(c_std, 4),
'treatment_std': round(t_std, 4),
'absolute_diff': round(diff, 4),
'relative_lift_pct': round((t_mean / c_mean - 1) * 100, 2) if c_mean != 0 else 0,
't_stat': round(float(t_stat), 3),
'p_value': round(float(p_value), 5),
'ci_95_abs': [round(ci_lower, 4), round(ci_upper, 4)],
'significant_at_05': bool(p_value < 0.05),
}
def required_sample_size(baseline, mde_relative, alpha=0.05, power=0.80):
"""Calculate sample size per variant for a proportion test."""
p1 = baseline
p2 = baseline * (1 + mde_relative)
p_bar = (p1 + p2) / 2
z_alpha = norm.ppf(1 - alpha/2)
z_beta = norm.ppf(power)
n = ((z_alpha * np.sqrt(2 * p_bar * (1 - p_bar)) +
z_beta * np.sqrt(p1*(1-p1) + p2*(1-p2))) / (p2 - p1))**2
return int(np.ceil(n))
def post_hoc_power(n_per_variant, baseline, observed_lift, alpha=0.05):
"""What was our actual statistical power given observed effect?"""
p1 = baseline
p2 = baseline * (1 + observed_lift)
if p2 <= 0 or p1 <= 0:
return 0
se = np.sqrt(p1*(1-p1)/n_per_variant + p2*(1-p2)/n_per_variant)
z_alpha = norm.ppf(1 - alpha/2)
z = abs(p2 - p1) / se - z_alpha
return round(float(norm.cdf(z)), 3)
# ============================================================================
# E1 — SOCIAL LOGIN (CLEAN WINNER)
# ============================================================================
print("=" * 70)
print("E1: Social Login at Signup")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E1_social_login.csv')
c = df[df['variant'] == 'control']
t = df[df['variant'] == 'treatment']
srm = srm_check(len(c), len(t))
test_result = proportion_test(c['completed_signup'].sum(), len(c),
t['completed_signup'].sum(), len(t))
bayes = bayesian_ab_beta(c['completed_signup'].sum(), len(c),
t['completed_signup'].sum(), len(t))
# Segment analysis - to demonstrate consistency
segment_results = {}
for col, name in [('country_tier', 'Country Tier'), ('device', 'Device'),
('traffic_source', 'Traffic Source')]:
segment_results[name] = []
for seg in df[col].unique():
sub_c = c[c[col] == seg]
sub_t = t[t[col] == seg]
if len(sub_c) > 100 and len(sub_t) > 100:
r = proportion_test(sub_c['completed_signup'].sum(), len(sub_c),
sub_t['completed_signup'].sum(), len(sub_t))
segment_results[name].append({
'segment': seg,
'n_control': len(sub_c), 'n_treatment': len(sub_t),
'lift_pct': r['relative_lift_pct'],
'p_value': r['p_value'],
'ci_95_abs': r['ci_95_abs']
})
e1_results = {
'experiment_id': 'E1',
'name': 'Social Login at Signup',
'area': 'Acquisition',
'hypothesis': 'Adding social login (Google/Apple) to signup will reduce friction and improve completion rate',
'primary_metric': 'signup_completion_rate',
'dates': '2025-07-01 to 2025-07-14',
'sample_sizes': {'control': len(c), 'treatment': len(t), 'total': len(df)},
'srm_check': srm,
'frequentist': test_result,
'bayesian': bayes,
'segment_consistency': segment_results,
'decision': 'SHIP',
'teaching_moment': 'Textbook clean experiment — consistent effect across all segments, passes SRM, strong statistical and practical significance',
'one_liner': f"+{test_result['relative_lift_pct']}% signup completion, consistent across all segments",
}
with open(RESULTS_DIR / 'E1_results.json', 'w') as f:
json.dump(e1_results, f, indent=2)
print(f" SRM check: {srm}")
print(f" Lift: +{test_result['relative_lift_pct']}%, p={test_result['p_value']}")
print(f" Bayesian P(T>C): {bayes['prob_treatment_better']}")
print(f" Segment consistency: all positive, range of lifts similar")
print(f" → DECISION: SHIP")
# ============================================================================
# E2 — EXTENDED TRIAL (HETEROGENEOUS EFFECTS)
# ============================================================================
print("\n" + "=" * 70)
print("E2: Extended Trial 7d → 14d")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E2_extended_trial.csv')
c = df[df['variant'] == 'control']
t = df[df['variant'] == 'treatment']
srm = srm_check(len(c), len(t))
overall = proportion_test(c['converted_to_paid'].sum(), len(c),
t['converted_to_paid'].sum(), len(t))
overall_bayes = bayesian_ab_beta(c['converted_to_paid'].sum(), len(c),
t['converted_to_paid'].sum(), len(t))
# Key analysis: segment by tenure
tenure_results = []
for tenure in ['new', 'returning', 'tenured']:
sub_c = c[c['user_tenure'] == tenure]
sub_t = t[t['user_tenure'] == tenure]
r = proportion_test(sub_c['converted_to_paid'].sum(), len(sub_c),
sub_t['converted_to_paid'].sum(), len(sub_t))
b = bayesian_ab_beta(sub_c['converted_to_paid'].sum(), len(sub_c),
sub_t['converted_to_paid'].sum(), len(sub_t))
tenure_results.append({
'segment': tenure,
'n_control': len(sub_c), 'n_treatment': len(sub_t),
'control_rate': r['control_rate'],
'treatment_rate': r['treatment_rate'],
'lift_pct': r['relative_lift_pct'],
'p_value': r['p_value'],
'significant_at_05': r['significant_at_05'],
'prob_treatment_better': b['prob_treatment_better'],
'recommendation': (
'SHIP to this segment' if r['significant_at_05'] and r['relative_lift_pct'] > 0
else 'DO NOT SHIP' if r['significant_at_05'] and r['relative_lift_pct'] < 0
else 'No clear effect'
)
})
# Simpson's paradox visualization data
simpsons_data = {
'overall_lift': overall['relative_lift_pct'],
'overall_p_value': overall['p_value'],
'by_segment': {r['segment']: r['lift_pct'] for r in tenure_results},
'segment_mix': {
tenure: round((df['user_tenure'] == tenure).mean(), 3)
for tenure in ['new', 'returning', 'tenured']
}
}
# Business impact if we ship only to new users
new_users_count = (df['user_tenure'] == 'new').sum()
new_lift_abs = next(r for r in tenure_results if r['segment']=='new')['treatment_rate'] - \
next(r for r in tenure_results if r['segment']=='new')['control_rate']
projected_annual_new_users = 1_200_000 # assumed annual new user volume
projected_annual_conversion_lift = int(projected_annual_new_users * new_lift_abs)
e2_results = {
'experiment_id': 'E2',
'name': 'Extended Free Trial (7 → 14 days)',
'area': 'Monetization',
'hypothesis': 'A longer trial gives users more time to experience product value, improving trial→paid conversion',
'primary_metric': 'trial_to_paid_conversion_rate',
'dates': '2025-07-15 to 2025-08-05',
'sample_sizes': {'control': len(c), 'treatment': len(t), 'total': len(df)},
'srm_check': srm,
'frequentist_overall': overall,
'bayesian_overall': overall_bayes,
'segment_analysis': tenure_results,
'simpsons_paradox': simpsons_data,
'business_impact_if_segmented_ship': {
'target_segment': 'new users only',
'projected_annual_incremental_conversions': projected_annual_conversion_lift,
'rationale': 'Shipping to new users captures the +22.9% lift without the -8% drag on tenured users'
},
'decision': 'SHIP TO NEW USERS ONLY',
'teaching_moment': (
"Classic Simpson's paradox. Overall effect is ~flat and non-significant, "
"but the experiment is definitively won for new users (+22.9%, p<0.01) "
"and definitively lost for tenured users (-8%). Shipping universally would "
"be a wash; shipping to the winning segment only is a clear strategic win."
),
'one_liner': 'Flat overall — but a clear win for new users and a loss for tenured users. Ship to new only.',
}
with open(RESULTS_DIR / 'E2_results.json', 'w') as f:
json.dump(e2_results, f, indent=2)
print(f" Overall: {overall['relative_lift_pct']:+.2f}%, p={overall['p_value']}")
print(f" By segment:")
for r in tenure_results:
print(f" {r['segment']:10s}: {r['lift_pct']:+6.1f}%, p={r['p_value']:.3f} → {r['recommendation']}")
print(f" → DECISION: SHIP TO NEW USERS ONLY")
# ============================================================================
# E3 — PUSH NOTIFICATION TIME (NOVELTY EFFECT)
# ============================================================================
print("\n" + "=" * 70)
print("E3: Push Notification Time Shift")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E3_push_time.csv')
# Key analysis: weekly decay
weekly_results = []
for w in range(1, 5):
wk = df[df['week'] == w]
c = wk[wk['variant'] == 'control']
t = wk[wk['variant'] == 'treatment']
# Aggregate to user level for that week
c_user = c.groupby('user_id')['opened_push'].mean()
t_user = t.groupby('user_id')['opened_push'].mean()
r = ttest_continuous(c_user, t_user)
weekly_results.append({
'week': w,
'control_mean': r['control_mean'],
'treatment_mean': r['treatment_mean'],
'lift_pct': r['relative_lift_pct'],
'p_value': r['p_value'],
'ci_95_abs': r['ci_95_abs'],
})
# Fit exponential decay to lift values
weeks = np.array([w['week'] for w in weekly_results])
lifts = np.array([w['lift_pct'] for w in weekly_results])
# Simple linear regression in log space to estimate decay
log_lifts = np.log(np.maximum(lifts, 0.1))
slope, intercept = np.polyfit(weeks, log_lifts, 1)
half_life_weeks = -np.log(2) / slope if slope < 0 else float('inf')
# Steady-state estimate (weeks 3-4 average)
steady_state_lift = np.mean(lifts[-2:])
# Whole-period analysis (what a naive analyst would report)
c_all = df[df['variant'] == 'control'].groupby('user_id')['opened_push'].mean()
t_all = df[df['variant'] == 'treatment'].groupby('user_id')['opened_push'].mean()
naive_result = ttest_continuous(c_all, t_all)
e3_results = {
'experiment_id': 'E3',
'name': 'Push Notification Time Shift (8am → 8pm)',
'area': 'Engagement',
'hypothesis': 'Evening push notifications will perform better than morning ones',
'primary_metric': 'daily_push_open_rate',
'dates': '2025-07-15 to 2025-08-11 (4 weeks)',
'sample_sizes': {'unique_users': df['user_id'].nunique(), 'user_days': len(df)},
'naive_whole_period': naive_result,
'weekly_decay': weekly_results,
'decay_analysis': {
'week_1_lift_pct': weekly_results[0]['lift_pct'],
'week_4_lift_pct': weekly_results[3]['lift_pct'],
'lift_degradation_pct': round(weekly_results[0]['lift_pct'] - weekly_results[3]['lift_pct'], 2),
'estimated_half_life_weeks': round(half_life_weeks, 2) if half_life_weeks != float('inf') else None,
'steady_state_lift_pct': round(steady_state_lift, 2),
},
'decision': 'KILL',
'teaching_moment': (
"Classic novelty effect. A 2-week test would have shown +12% and shipped. "
"The 4-week test reveals the effect decays to ~2-3% at steady state. "
"For any test where behavior change is the mechanism (notifications, UI, etc.), "
"minimum 3-4 week duration is non-negotiable."
),
'rule_of_thumb_violated': (
'Any test whose mechanism is "users will notice something new" needs '
'≥3 weeks of data. A 1-week test would have been catastrophically misleading here.'
),
'one_liner': 'Week 1 showed +15%. Week 4 showed +2%. Classic novelty decay — kill.',
}
with open(RESULTS_DIR / 'E3_results.json', 'w') as f:
json.dump(e3_results, f, indent=2)
print(f" Weekly lift decay:")
for r in weekly_results:
print(f" Week {r['week']}: {r['lift_pct']:+.2f}%, p={r['p_value']:.4f}")
print(f" Estimated half-life: {half_life_weeks:.1f} weeks")
print(f" Naive whole-period lift: {naive_result['relative_lift_pct']:+.2f}% (misleading)")
print(f" → DECISION: KILL (novelty effect)")
# ============================================================================
# E4 — ONBOARDING SHORTENED (PRIMARY vs NORTH STAR CONFLICT)
# ============================================================================
print("\n" + "=" * 70)
print("E4: Onboarding 5 → 3 Steps")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E4_onboarding.csv')
c = df[df['variant'] == 'control']
t = df[df['variant'] == 'treatment']
srm = srm_check(len(c), len(t))
# Funnel analysis
funnel_results = {}
for metric, name in [('d1_activated', 'D1 Activation (Primary)'),
('d7_retained', 'D7 Retention (Guardrail)'),
('d30_retained', 'D30 Retention (North Star)')]:
r = proportion_test(c[metric].sum(), len(c), t[metric].sum(), len(t))
b = bayesian_ab_beta(c[metric].sum(), len(c), t[metric].sum(), len(t))
funnel_results[metric] = {
'metric_name': name,
'control_rate': r['control_rate'],
'treatment_rate': r['treatment_rate'],
'lift_pct': r['relative_lift_pct'],
'p_value': r['p_value'],
'significant_at_05': r['significant_at_05'],
'prob_treatment_better': b['prob_treatment_better'],
}
# Business-level impact calculation
# If we shipped to 1M new users/year:
# +12% on D1 activation vs -9.5% on D30 retention
# D30 retained users are ~10x more valuable (they actually pay)
annual_new_users = 1_200_000
d30_user_ltv = 85 # avg LTV of D30-retained user
d1_only_user_ltv = 8 # LTV of user who activates but doesn't retain
control_d30 = funnel_results['d30_retained']['control_rate']
treat_d30 = funnel_results['d30_retained']['treatment_rate']
lost_retained_users = annual_new_users * (control_d30 - treat_d30)
lost_revenue = lost_retained_users * d30_user_ltv
# But we gain some incremental activation
control_d1 = funnel_results['d1_activated']['control_rate']
treat_d1 = funnel_results['d1_activated']['treatment_rate']
gained_activations = annual_new_users * (treat_d1 - control_d1)
# Of these, some don't retain -> low LTV
gained_but_not_retained = gained_activations * (1 - treat_d30/treat_d1)
marginal_revenue_gain = gained_but_not_retained * d1_only_user_ltv
net_revenue_impact = marginal_revenue_gain - lost_revenue
e4_results = {
'experiment_id': 'E4',
'name': 'Shortened Onboarding (5 → 3 steps)',
'area': 'Activation',
'hypothesis': 'Reducing onboarding friction will improve D1 activation without harming long-term retention',
'primary_metric': 'd1_activation_rate',
'guardrail_metrics': ['d7_retention_rate', 'd30_retention_rate'],
'dates': '2025-07-01 to 2025-07-14 (exposure), measured through 2025-08-13',
'sample_sizes': {'control': len(c), 'treatment': len(t), 'total': len(df)},
'srm_check': srm,
'funnel_analysis': funnel_results,
'north_star_conflict': {
'primary_metric_won': True,
'guardrail_lost': True,
'interpretation': (
'The primary metric (D1 activation) would have greenlit this experiment. '
'But D30 retention — the metric that actually correlates with revenue — '
'showed a significant decline. The shortened onboarding brought in more '
'users who never formed the habit.'
)
},
'business_impact_model': {
'assumed_annual_new_users': annual_new_users,
'assumed_d30_retained_user_ltv': d30_user_ltv,
'assumed_d1_only_user_ltv': d1_only_user_ltv,
'projected_lost_retained_users_annual': int(lost_retained_users),
'projected_lost_revenue_annual': int(lost_revenue),
'projected_marginal_revenue_from_extra_activations': int(marginal_revenue_gain),
'net_annual_revenue_impact': int(net_revenue_impact),
'verdict': 'Net negative despite primary metric win'
},
'decision': 'KILL',
'teaching_moment': (
'Primary metric wins can mask North Star losses. When activation metrics '
'improve but retention metrics decline, the experiment is almost always '
'pulling forward users who would have been better served by the longer '
'flow. The correct response is to kill and redesign.'
),
'policy_implication': (
'Recommend: All activation/engagement tests must include D7 and D30 '
'retention as pre-specified guardrail metrics. A primary-metric win '
'with a guardrail loss defaults to "do not ship" unless there is an '
'affirmative reason to override.'
),
'one_liner': 'D1 activation +12% looked great. D30 retention -9.5% killed it.',
}
with open(RESULTS_DIR / 'E4_results.json', 'w') as f:
json.dump(e4_results, f, indent=2)
print(f" Funnel analysis:")
for k, v in funnel_results.items():
sig = "✓" if v['significant_at_05'] else " "
print(f" {v['metric_name']:35s}: {v['lift_pct']:+6.2f}%, p={v['p_value']:.4f} {sig}")
print(f" Projected annual net revenue impact: ${int(net_revenue_impact):,}")
print(f" → DECISION: KILL (primary metric win hides North Star loss)")
# ============================================================================
# E5 — AI CONVERSATION PRACTICE (UNDERPOWERED)
# ============================================================================
print("\n" + "=" * 70)
print("E5: AI Conversation Practice")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E5_ai_practice.csv')
c = df[df['variant'] == 'control']
t = df[df['variant'] == 'treatment']
srm = srm_check(len(c), len(t))
test_result = ttest_continuous(c['sessions_14d'], t['sessions_14d'])
bayes_continuous_like = {
'prob_treatment_better': round(float((np.random.normal(test_result['treatment_mean'],
test_result['treatment_std']/np.sqrt(len(t)), 10000) >
np.random.normal(test_result['control_mean'],
test_result['control_std']/np.sqrt(len(c)), 10000)).mean()), 3),
}
# THE KEY ANALYSIS: post-hoc power and "what we should have done"
observed_lift = test_result['relative_lift_pct'] / 100
baseline_rate = test_result['control_mean']
# What was our actual power?
# For continuous: power depends on effect size relative to SD
cohen_d = abs(test_result['absolute_diff']) / np.sqrt(
(test_result['control_std']**2 + test_result['treatment_std']**2) / 2
)
# Approximate power for two-sample t-test
from scipy.stats import nct
n_per_variant = min(len(c), len(t))
ncp = cohen_d * np.sqrt(n_per_variant / 2)
crit = stats.t.ppf(0.975, df=2*n_per_variant-2)
actual_power = round(1 - nct.cdf(crit, 2*n_per_variant-2, ncp) + nct.cdf(-crit, 2*n_per_variant-2, ncp), 3)
# What sample size would we need to detect observed effect with 80% power?
# Rough approximation
z_alpha = 1.96
z_beta = 0.84 # 80% power
required_n_per = int(np.ceil(2 * ((z_alpha + z_beta) / cohen_d)**2)) if cohen_d > 0 else None
# What MDE did we actually have power to detect?
mde_detectable = round((z_alpha + z_beta) * np.sqrt(
2 * test_result['control_std']**2 / n_per_variant
) / test_result['control_mean'] * 100, 2)
e5_results = {
'experiment_id': 'E5',
'name': 'AI Conversation Practice Feature',
'area': 'Engagement',
'hypothesis': 'AI-powered conversation practice will increase user engagement (sessions over 14 days)',
'primary_metric': 'sessions_per_user_14d',
'dates': '2025-08-01 to 2025-08-28',
'sample_sizes': {'control': len(c), 'treatment': len(t), 'total': len(df)},
'srm_check': srm,
'frequentist': test_result,
'power_analysis': {
'observed_lift_pct': test_result['relative_lift_pct'],
'observed_effect_size_cohens_d': round(float(cohen_d), 4),
'actual_statistical_power': actual_power,
'minimum_detectable_effect_pct': mde_detectable,
'sample_size_used_per_variant': n_per_variant,
'sample_size_needed_for_80_pct_power': required_n_per,
'interpretation': (
f'This test had only {int(actual_power*100)}% power to detect the observed effect. '
f'With {n_per_variant:,} users per variant, we could only reliably detect effects ≥{mde_detectable}%. '
f'The observed +{test_result["relative_lift_pct"]}% is directionally encouraging but '
f'we cannot confidently reject the null hypothesis. '
f'To properly test this feature, we need ~{required_n_per:,} users per variant.'
)
},
'decision': 'RE-RUN WITH PROPER POWER',
'teaching_moment': (
'A non-significant p-value does not mean "no effect". It can mean '
'"effect exists but we had insufficient sample size to detect it". '
'This test was sized for an 8% MDE but the true effect is around 2-3%. '
'Killing this feature based on p=0.13 would be a mistake — the correct '
'response is to re-run with appropriate sample size.'
),
'root_cause': (
'MDE was set at 8% at design time, but realistic expectations for this '
'type of feature should have been 2-5%. Team overestimated expected effect, '
'leading to undersizing.'
),
'policy_implication': (
'Recommend: MDE at test design should be anchored to historical effect sizes '
'for similar features, not to "what would be exciting". Default MDE for '
'engagement features should be 3% unless there is strong reason otherwise.'
),
'one_liner': 'p=0.13 does not mean "no effect". It means "we did not size this test properly".',
}
with open(RESULTS_DIR / 'E5_results.json', 'w') as f:
json.dump(e5_results, f, indent=2)
print(f" Observed: {test_result['relative_lift_pct']:+.2f}%, p={test_result['p_value']}")
print(f" Post-hoc power: {actual_power} (woefully underpowered)")
print(f" MDE we could actually detect: {mde_detectable}%")
print(f" Sample size we needed: {required_n_per:,} per variant (had {n_per_variant:,})")
print(f" → DECISION: RE-RUN WITH PROPER POWER")
# ============================================================================
# E6 — LEADERBOARD (CUPED + BAYESIAN + HOLDOUT)
# ============================================================================
print("\n" + "=" * 70)
print("E6: Leaderboard Gamification")
print("=" * 70)
df = pd.read_csv(EXPERIMENTS_DIR / 'E6_leaderboard.csv')
c = df[df['variant'] == 'control']
t = df[df['variant'] == 'treatment']
h = df[df['variant'] == 'holdout']
srm = srm_check(len(c), len(t))
# Standard analysis
basic_result = ttest_continuous(c['active_days_30'], t['active_days_30'])
# CUPED variance reduction
# Y_adjusted = Y - θ*(X - E[X]), where θ = cov(X,Y)/var(X)
# X = pre-period sessions
pooled_x = pd.concat([c['pre_period_sessions'], t['pre_period_sessions']])
pooled_y = pd.concat([c['active_days_30'], t['active_days_30']])
theta = np.cov(pooled_x, pooled_y)[0, 1] / np.var(pooled_x)
x_mean = pooled_x.mean()
c_cuped = c['active_days_30'] - theta * (c['pre_period_sessions'] - x_mean)
t_cuped = t['active_days_30'] - theta * (t['pre_period_sessions'] - x_mean)
cuped_result = ttest_continuous(c_cuped, t_cuped)
variance_reduction = 1 - (cuped_result['control_std']**2 / basic_result['control_std']**2)
# Holdout validation
c_vs_h = ttest_continuous(c['active_days_30'], h['active_days_30'])
holdout_interpretation = (
"PASSED: Control and holdout show no meaningful difference, confirming "
"our control group is representative and the treatment effect is genuine."
if abs(c_vs_h['relative_lift_pct']) < 2 else
"FAILED: Control and holdout differ meaningfully — possible novelty or spillover."
)
# Bayesian continuous analysis via MC sampling from normal posteriors
n_sim = 50000
# Non-informative priors, so posterior mean = sample mean, posterior sd = sample se
posterior_c = np.random.normal(basic_result['control_mean'],
basic_result['control_std']/np.sqrt(len(c)), n_sim)
posterior_t = np.random.normal(basic_result['treatment_mean'],
basic_result['treatment_std']/np.sqrt(len(t)), n_sim)
rel_lift = (posterior_t - posterior_c) / posterior_c
bayesian_result = {
'prob_treatment_better': round(float((posterior_t > posterior_c).mean()), 4),
'expected_lift_pct': round(float(rel_lift.mean()) * 100, 2),
'lift_ci_90_pct': [
round(float(np.percentile(rel_lift, 5)) * 100, 2),
round(float(np.percentile(rel_lift, 95)) * 100, 2)
],
'prob_lift_exceeds_10pct': round(float((rel_lift > 0.10).mean()), 4),
'prob_lift_exceeds_20pct': round(float((rel_lift > 0.20).mean()), 4),
}
# Business impact
monthly_dau = 900_000
revenue_per_dau_day = 0.15 # ARPDAU
annual_revenue_lift = monthly_dau * (basic_result['relative_lift_pct']/100) * revenue_per_dau_day * 365
e6_results = {
'experiment_id': 'E6',
'name': 'Leaderboard Gamification',
'area': 'Engagement',
'hypothesis': 'Adding a friends leaderboard will increase daily engagement through social motivation',
'primary_metric': 'active_days_in_30d',
'dates': '2025-08-10 to 2025-09-10',
'sample_sizes': {
'control': len(c), 'treatment': len(t), 'holdout': len(h),
'total': len(df), 'design': '45% control / 45% treatment / 10% holdout'
},
'srm_check': srm,
'standard_analysis': basic_result,
'cuped_analysis': {
'result': cuped_result,
'theta_coefficient': round(float(theta), 4),
'variance_reduction_pct': round(float(variance_reduction) * 100, 2),
'interpretation': (
f'CUPED reduced variance by {int(variance_reduction*100)}%, equivalent to '
f'running the test with {int(len(c)/(1-variance_reduction)):,} users per variant '
f'without CUPED. Same point estimate, tighter confidence intervals.'
)
},
'bayesian_analysis': bayesian_result,
'holdout_validation': {
'control_vs_holdout_lift_pct': c_vs_h['relative_lift_pct'],
'control_vs_holdout_p_value': c_vs_h['p_value'],
'interpretation': holdout_interpretation,
},
'business_impact': {
'monthly_dau_impacted': monthly_dau,
'revenue_per_dau_day': revenue_per_dau_day,
'projected_annual_revenue_lift': int(annual_revenue_lift),
'confidence_level': 'High — confirmed by holdout, CUPED, and Bayesian inference'
},
'decision': 'SHIP',
'teaching_moment': (
'Textbook example of what a rigorous experiment looks like: '
'large clean effect, validated with holdout group, variance-reduced '
'via CUPED, and confirmed with Bayesian inference. '
'This is the template for high-confidence ship decisions on major features.'
),
'one_liner': '+22% DAU, validated via holdout, variance-reduced via CUPED. Textbook ship.',
}
with open(RESULTS_DIR / 'E6_results.json', 'w') as f:
json.dump(e6_results, f, indent=2)
print(f" Standard analysis: {basic_result['relative_lift_pct']:+.2f}%, p={basic_result['p_value']}")
print(f" CUPED variance reduction: {variance_reduction*100:.1f}%")
print(f" Bayesian P(T>C): {bayesian_result['prob_treatment_better']}")
print(f" Holdout check: C vs H = {c_vs_h['relative_lift_pct']:+.2f}% → {holdout_interpretation[:50]}...")
print(f" Projected annual revenue impact: ${int(annual_revenue_lift):,}")
print(f" → DECISION: SHIP")
# ============================================================================
# PORTFOLIO-LEVEL ROLLUP
# ============================================================================
print("\n" + "=" * 70)
print("PORTFOLIO-LEVEL SUMMARY")
print("=" * 70)
all_results = {
'portfolio_summary': {
'quarter': 'Q3 2025',
'total_experiments': 6,
'breakdown': {
'ship': 2, # E1, E6
'ship_segmented': 1, # E2
'kill': 2, # E3, E4
'rerun': 1, # E5
},
'ship_rate_pct': round((2 + 1) / 6 * 100, 1),
'effective_ship_rate_pct': round(2 / 6 * 100, 1),
'total_users_tested': sum([
45000, 38000, 52000, 41000, 6000, 58000
]),
},
'cross_experiment_learnings': [
{
'pattern': 'Primary metric vs North Star misalignment',
'experiments_affected': ['E4'],
'recommendation': 'All activation tests must specify D7+D30 retention as guardrails',
},
{
'pattern': 'Novelty effect masking steady-state performance',
'experiments_affected': ['E3'],
'recommendation': 'Behavior-change tests require minimum 3-week runtime',
},
{
'pattern': 'Underpowered tests producing false null results',
'experiments_affected': ['E5'],
'recommendation': 'Default MDE for engagement features should be 3%, not 8%',
},
{
'pattern': 'Average effects hiding opposing segment effects',
'experiments_affected': ['E2'],
'recommendation': 'Tenure-based segment analysis should be standard for all monetization tests',
},
],
'methodology_rigor_applied': {
'srm_check_every_test': True,
'bayesian_inference_where_appropriate': True,
'cuped_variance_reduction_for_continuous_metrics': True,
'holdout_validation_for_major_features': True,
'segment_level_analysis_standard': True,
'business_impact_modeling': True,
}
}
with open(RESULTS_DIR / 'portfolio_summary.json', 'w') as f:
json.dump(all_results, f, indent=2)
print(f"\n Ship rate: {all_results['portfolio_summary']['ship_rate_pct']}%")
print(f" Total users tested: {all_results['portfolio_summary']['total_users_tested']:,}")
print(f"\n Cross-experiment patterns identified: {len(all_results['cross_experiment_learnings'])}")
for p in all_results['cross_experiment_learnings']:
print(f" - {p['pattern']}")
print("\n" + "=" * 70)
print("ANALYSIS COMPLETE — all results saved to data/results/")
print("=" * 70)
for f in sorted(os.listdir(RESULTS_DIR)):
print(f" {f}")