Approach: compute counts and conversion rates per segment, use normal approximation for SE = sqrt(p*(1-p)/n), 95% CI = p ± 1.96*SE, and difference in percentage points with SE_diff = sqrt(SE1^2 + SE2^2).sql
WITH stats AS (
SELECT
segment,
COUNT(*)::bigint AS n,
SUM(CASE WHEN converted THEN 1 ELSE 0 END)::bigint AS conversions,
-- conversion rate as fraction
AVG((converted::int))::numeric AS p
FROM events
WHERE segment IN ('segment_a','segment_b')
GROUP BY segment
),
ci AS (
SELECT
segment,
n,
conversions,
p,
-- standard error for proportion
sqrt(p * (1 - p) / NULLIF(n,0)) AS se,
(p - 1.96 * sqrt(p * (1 - p) / NULLIF(n,0))) AS ci_lower,
(p + 1.96 * sqrt(p * (1 - p) / NULLIF(n,0))) AS ci_upper
FROM stats
),
pair AS (
SELECT
a.p AS p_a, a.n AS n_a, a.se AS se_a,
b.p AS p_b, b.n AS n_b, b.se AS se_b
FROM ci a
JOIN ci b ON a.segment = 'segment_a' AND b.segment = 'segment_b'
)
SELECT
-- per-segment results
ci.segment,
ci.n,
ci.conversions,
ROUND(ci.p::numeric, 6) AS conversion_rate,
ROUND(ci.ci_lower::numeric, 6) AS ci_lower,
ROUND(ci.ci_upper::numeric, 6) AS ci_upper,
-- difference (percentage points) and its 95% CI
ROUND((pair.p_a - pair.p_b)::numeric, 6) AS diff_pp,
ROUND(( (pair.p_a - pair.p_b) - 1.96 * sqrt(pair.se_a^2 + pair.se_b^2) )::numeric, 6) AS diff_ci_lower,
ROUND(( (pair.p_a - pair.p_b) + 1.96 * sqrt(pair.se_a^2 + pair.se_b^2) )::numeric, 6) AS diff_ci_upper
FROM ci
CROSS JOIN pair
ORDER BY segment;
Key points, assumptions and limitations:- Assumes independent samples and sufficiently large n for normal approximation (np and n(1-p) > ~5–10). For small counts or rates near 0/1 use exact (Clopper–Pearson) or bootstrap.- The diff CI assumes independence between segments; if users can appear in both segments, dependence invalidates SE_diff formula.- Rounds shown; multiply by 100 for percentage points if desired.- For multiple testing or stratified comparisons, adjust methodology (e.g., regression with covariates, logistic regression for adjusted differences).