feat(stats): add an SRM check to the price experiment report
The report now runs a chi-square sample-ratio-mismatch test of the arm counts against the expected 50/50 split, on both the raw assigned counts and the matured subset (to catch differential attrition), flagging a low p as an imbalance to investigate before trusting results. Extract a shared Normal::cdf so the SRM and Welch tests use one erf implementation.
This commit is contained in:
parent
327e9b1d8b
commit
5686617cf8
|
|
@ -7,6 +7,7 @@ use App\Services\Discord\DiscordWebhook;
|
|||
use App\Services\Stats\BinomialProportion;
|
||||
use App\Services\Stats\PriceExperimentFunnelCollector;
|
||||
use App\Services\Stats\ProportionSignificance;
|
||||
use App\Services\Stats\SampleRatioMismatch;
|
||||
use App\Services\Stats\WelchTTest;
|
||||
use App\Support\Money;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
|
@ -29,6 +30,7 @@ class SendPriceExperimentFunnelReportCommand extends Command
|
|||
private PriceExperimentFunnelCollector $collector,
|
||||
private ProportionSignificance $significance,
|
||||
private WelchTTest $welch,
|
||||
private SampleRatioMismatch $srm,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
|
@ -44,7 +46,7 @@ class SendPriceExperimentFunnelReportCommand extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ([...$this->tableLines($report), ...$this->significanceLines($report)] as $line) {
|
||||
foreach ([...$this->tableLines($report), ...$this->srmLines($report), ...$this->significanceLines($report)] as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +103,39 @@ class SendPriceExperimentFunnelReportCommand extends Command
|
|||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample-ratio-mismatch check: the split must be ~50/50. A low p means the
|
||||
* assignment (or the pipeline that filters it) is broken, which invalidates
|
||||
* every comparison below — so this is checked first, on the raw assigned counts
|
||||
* and on the matured subset (to catch differential attrition).
|
||||
*
|
||||
* @param array{variants: array<string, array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function srmLines(array $report): array
|
||||
{
|
||||
$control = $report['variants'][PriceExperiment::CONTROL];
|
||||
$high = $report['variants'][PriceExperiment::HIGH];
|
||||
$lines = ['', 'SRM (assignment balance, expect 50/50 — a low p means the split is broken):'];
|
||||
|
||||
foreach (['assigned' => 'assigned', 'assignedMature' => 'matured'] as $field => $label) {
|
||||
$c = (int) $control[$field];
|
||||
$h = (int) $high[$field];
|
||||
|
||||
if ($c + $h === 0) {
|
||||
$lines[] = sprintf(' %-8s control %d / high %d (n=0)', $label, $c, $h);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$srm = $this->srm->evenSplit($c, $h);
|
||||
$flag = $srm['p'] < 0.01 ? ' ⚠ IMBALANCE — investigate before trusting results' : '';
|
||||
$lines[] = sprintf(' %-8s control %d / high %d χ²=%.2f p=%.3f%s', $label, $c, $h, $srm['chiSq'], $srm['p'], $flag);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary decision test — Welch on contribution-margin-per-user (control vs
|
||||
* high), the metric we maximise — plus the conversion guardrail (Wilson CIs +
|
||||
|
|
@ -210,7 +245,7 @@ class SendPriceExperimentFunnelReportCommand extends Command
|
|||
{
|
||||
return [
|
||||
'title' => '💶 Price Experiment — Funnel (control vs high)',
|
||||
'description' => "```\n".implode("\n", [...$this->tableLines($report), ...$this->significanceLines($report)])."\n```",
|
||||
'description' => "```\n".implode("\n", [...$this->tableLines($report), ...$this->srmLines($report), ...$this->significanceLines($report)])."\n```",
|
||||
'color' => 0x57F287,
|
||||
'fields' => [
|
||||
[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Standard-normal cumulative distribution, shared by the significance tests that
|
||||
* need a normal tail probability (Welch's t via the large-sample approximation,
|
||||
* and the χ²₁ sample-ratio-mismatch check). One implementation so the erf
|
||||
* approximation lives in a single place.
|
||||
*/
|
||||
final class Normal
|
||||
{
|
||||
public static function cdf(float $x): float
|
||||
{
|
||||
return 0.5 * (1.0 + self::erf($x / sqrt(2.0)));
|
||||
}
|
||||
|
||||
/** Abramowitz & Stegun 7.1.26 — |error| < 1.5e-7. */
|
||||
private static function erf(float $x): float
|
||||
{
|
||||
$sign = $x < 0 ? -1.0 : 1.0;
|
||||
$x = abs($x);
|
||||
$t = 1.0 / (1.0 + 0.3275911 * $x);
|
||||
$y = 1.0 - ((((1.061405429 * $t - 1.453152027) * $t + 1.421413741) * $t - 0.284496736) * $t + 0.254829592) * $t * exp(-$x * $x);
|
||||
|
||||
return $sign * $y;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Sample-ratio-mismatch (SRM) check for a two-arm experiment: a χ² goodness-of-fit
|
||||
* test (1 degree of freedom) of the observed arm counts against an even 1:1 split.
|
||||
* A low p means the assignment is not really 50/50 — a data-pipeline or attrition
|
||||
* bug that invalidates every downstream comparison, so it must be checked before
|
||||
* trusting the funnel. Run it on the assigned counts AND on any filtered subset
|
||||
* (matured / activated) to catch differential attrition a deterministic assignment
|
||||
* can still produce.
|
||||
*/
|
||||
final class SampleRatioMismatch
|
||||
{
|
||||
/**
|
||||
* @return array{chiSq: float, p: float}
|
||||
*/
|
||||
public function evenSplit(int $countA, int $countB): array
|
||||
{
|
||||
$total = $countA + $countB;
|
||||
|
||||
if ($total === 0) {
|
||||
return ['chiSq' => 0.0, 'p' => 1.0];
|
||||
}
|
||||
|
||||
$expected = $total / 2.0;
|
||||
$chiSq = (($countA - $expected) ** 2 + ($countB - $expected) ** 2) / $expected;
|
||||
|
||||
// χ² with 1 df is Z²; its upper tail is P(|Z| > √χ²).
|
||||
$p = 2.0 * (1.0 - Normal::cdf(sqrt($chiSq)));
|
||||
|
||||
return ['chiSq' => $chiSq, 'p' => $p];
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,8 @@ namespace App\Services\Stats;
|
|||
* returned for context.
|
||||
*
|
||||
* ponytail: normal-approx p-value, exact only for large n. If a future experiment
|
||||
* needs it at small n, swap normalCdf() for a Student-t CDF (regularised incomplete
|
||||
* beta) — the statistic and df here are already exact.
|
||||
* needs it at small n, swap Normal::cdf() for a Student-t CDF (regularised
|
||||
* incomplete beta) — the statistic and df here are already exact.
|
||||
*/
|
||||
final class WelchTTest
|
||||
{
|
||||
|
|
@ -38,24 +38,8 @@ final class WelchTTest
|
|||
(($varA / $nA) ** 2) / ($nA - 1)
|
||||
+ (($varB / $nB) ** 2) / ($nB - 1)
|
||||
);
|
||||
$p = 2.0 * (1.0 - $this->normalCdf(abs($t)));
|
||||
$p = 2.0 * (1.0 - Normal::cdf(abs($t)));
|
||||
|
||||
return ['diff' => $diff, 'se' => $se, 't' => $t, 'df' => $df, 'p' => $p];
|
||||
}
|
||||
|
||||
private function normalCdf(float $x): float
|
||||
{
|
||||
return 0.5 * (1.0 + $this->erf($x / sqrt(2.0)));
|
||||
}
|
||||
|
||||
/** Abramowitz & Stegun 7.1.26 — |error| < 1.5e-7. */
|
||||
private function erf(float $x): float
|
||||
{
|
||||
$sign = $x < 0 ? -1.0 : 1.0;
|
||||
$x = abs($x);
|
||||
$t = 1.0 / (1.0 + 0.3275911 * $x);
|
||||
$y = 1.0 - ((((1.061405429 * $t - 1.453152027) * $t + 1.421413741) * $t - 0.284496736) * $t + 0.254829592) * $t * exp(-$x * $x);
|
||||
|
||||
return $sign * $y;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,34 @@ it('prints the primary CM/user (Welch) and conversion guardrail blocks', functio
|
|||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('flags a sample-ratio mismatch when the assignment split is lopsided', function () {
|
||||
$signup = CarbonImmutable::parse('2026-06-05');
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
priceUser(PriceExperiment::CONTROL, $signup);
|
||||
}
|
||||
priceUser(PriceExperiment::HIGH, $signup);
|
||||
|
||||
artisan('stats:price-experiment-funnel', ['--no-discord' => true])
|
||||
->expectsOutputToContain('SRM')
|
||||
->expectsOutputToContain('IMBALANCE')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('does not flag SRM when the split is balanced', function () {
|
||||
$signup = CarbonImmutable::parse('2026-06-05');
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
priceUser(PriceExperiment::CONTROL, $signup);
|
||||
priceUser(PriceExperiment::HIGH, $signup);
|
||||
}
|
||||
|
||||
artisan('stats:price-experiment-funnel', ['--no-discord' => true])
|
||||
->expectsOutputToContain('SRM')
|
||||
->doesntExpectOutputToContain('IMBALANCE')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('posts the price experiment embed to discord with the monitoring-only warning', function () {
|
||||
config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']);
|
||||
Http::fake(['discord.test/*' => Http::response('', 204)]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Stats\SampleRatioMismatch;
|
||||
|
||||
it('reports p=1 for an exactly even split', function () {
|
||||
$result = (new SampleRatioMismatch)->evenSplit(50, 50);
|
||||
|
||||
expect($result['chiSq'])->toBe(0.0)
|
||||
->and($result['p'])->toEqualWithDelta(1.0, 1e-6);
|
||||
});
|
||||
|
||||
it('returns a neutral result for empty arms', function () {
|
||||
$result = (new SampleRatioMismatch)->evenSplit(0, 0);
|
||||
|
||||
expect($result['chiSq'])->toBe(0.0)
|
||||
->and($result['p'])->toBe(1.0);
|
||||
});
|
||||
|
||||
it('matches the known chi-square for a 60/40 split of 100', function () {
|
||||
// χ² = ((60−50)² + (40−50)²)/50 = 4 → p = 2·(1 − Φ(2)) = 0.0455.
|
||||
$result = (new SampleRatioMismatch)->evenSplit(60, 40);
|
||||
|
||||
expect($result['chiSq'])->toEqualWithDelta(4.0, 1e-9)
|
||||
->and($result['p'])->toEqualWithDelta(0.0455, 1e-3);
|
||||
});
|
||||
|
||||
it('flags a badly lopsided split with a tiny p', function () {
|
||||
$result = (new SampleRatioMismatch)->evenSplit(90, 10);
|
||||
|
||||
expect($result['chiSq'])->toEqualWithDelta(64.0, 1e-9)
|
||||
->and($result['p'])->toBeLessThan(0.001);
|
||||
});
|
||||
Loading…
Reference in New Issue