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:
Víctor Falcón 2026-07-18 19:19:03 +02:00
parent 327e9b1d8b
commit 5686617cf8
6 changed files with 163 additions and 21 deletions

View File

@ -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' => [
[

View File

@ -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;
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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)]);

View File

@ -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 () {
// χ² = ((6050)² + (4050)²)/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);
});