File size: 9,420 Bytes
e201ae4 | 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 | """analyze_ab_test — compare a metric across two groups + significance test (§0.9, from comparison).
Rename + reframe of `analyze_comparison` (`comparison.py`): aggregates a value for
two groups of a dimension (group_a is the baseline) and reports the gap — absolute
and percent difference, direction — AND now runs a **Welch two-sample t-test** on the
raw values to say whether the difference is statistically significant. Ships a bar
chart of the two group means with 95% error bars ("comparison + interval").
Absorbs the "significance" analysis discussed on 2026-09-15. Follows
docs/tools/ANALYTICAL_TOOLS_CONVENTIONS.md: pure synchronous compute (a t-test is
fast, not a model fit → no run_fit), Pattern A `data`, errors escape to the
never-throw seam. Deterministic.
"""
from __future__ import annotations
import math
from typing import Any
import pandas as pd
from pydantic import BaseModel, Field
from src.tools.analytics.agg_vocab import normalise_agg
from src.tools.analytics.charts import LAYOUT_PRESET, _axis, envelope
from src.tools.analytics.descriptive import ColumnNotFoundError
from src.tools.spec_builder import build_description
# How to aggregate the value within each group for the headline comparison.
SUPPORTED_AGGS = ("sum", "mean", "count", "min", "max", "median")
# Minimum numeric values per group to run a t-test.
_MIN_TEST_N = 2
# 95% normal error-bar half-width multiplier.
_Z_95 = 1.96
class UnsupportedAggregationError(ValueError):
"""The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
class GroupNotFoundError(ValueError):
"""A requested group value does not occur in the dimension column (maps to GROUP_NOT_FOUND)."""
class AbTestInput(BaseModel):
# `data` FIRST — property order follows field order (planner-prompt readability).
data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).")
dimension: str = Field(..., description="Categorical column defining the two groups.")
value_column: str = Field(..., description="Numeric column to compare between the groups.")
group_a: Any = Field(..., description="Baseline group value (the reference/'from').")
group_b: Any = Field(..., description="Comparison group value (the 'to').")
agg: str = Field("sum", description="How to aggregate the value per group (headline gap).")
alpha: float = Field(0.05, description="Significance level for the t-test (default 0.05).")
DESCRIPTION = build_description(
summary="A/B test: compare one metric between TWO groups (group_a is baseline) and test "
"whether the difference is statistically significant (Welch t-test).",
use_when="the question pits two named groups against each other, or asks if a difference "
"is significant — 'vs'/'versus', 'bandingkan', 'A or B', 'selisih/beda antara', "
"'signifikan', 'A/B test', 'higher/lower than'.",
dont_use_when=[
"it aggregates across many groups at once -> analyze_aggregate",
"it splits a single total into shares -> analyze_pareto",
"it tracks change over time -> analyze_trend",
],
output="each group's value + absolute/percent difference, a Welch t-test (t, p-value, "
"significant at alpha), and a bar chart of the two group means with 95% error bars.",
examples=[
"compare revenue between Jakarta and Surabaya",
"is average order value significantly higher for members vs non-members?",
"produksi bulan 1 vs bulan 2, beda signifikan?",
],
)
def _ab_chart(
group_a: object,
group_b: object,
y_a: float | None,
y_b: float | None,
err_a: float | None,
err_b: float | None,
value_column: str,
) -> dict[str, object]:
"""Bar of the two groups' values with optional 95% error bars."""
names = [str(group_a), str(group_b)]
title = f"{value_column}: {names[0]} vs {names[1]}"
trace: dict[str, object] = {"type": "bar", "x": names, "y": [y_a, y_b], "name": value_column}
if err_a is not None and err_b is not None:
trace["error_y"] = {"type": "data", "array": [err_a, err_b], "visible": True}
layout = {
**LAYOUT_PRESET,
"title": {"text": title},
"showlegend": False,
"xaxis": _axis(""),
"yaxis": _axis(value_column),
}
return envelope("bar", title, [trace], layout)
def _welch_test(
a_vals: pd.Series, b_vals: pd.Series, alpha: float
) -> tuple[dict[str, object], bool]:
"""Welch two-sample t-test on the raw values. Returns (test_dict, can_test)."""
n_a, n_b = len(a_vals), len(b_vals)
if n_a < _MIN_TEST_N or n_b < _MIN_TEST_N:
return (
{
"method": "welch_t_test",
"n_a": n_a,
"n_b": n_b,
"alpha": alpha,
"significant": None,
"reason": f"need >= {_MIN_TEST_N} numeric values per group for a t-test",
},
False,
)
from scipy import stats # lazy — keep module import light
t_stat, p_value = stats.ttest_ind(a_vals, b_vals, equal_var=False)
t_stat, p_value = float(t_stat), float(p_value)
significant = None if math.isnan(p_value) else bool(p_value < alpha)
test = {
"method": "welch_t_test",
"n_a": n_a,
"n_b": n_b,
"mean_a": float(a_vals.mean()),
"mean_b": float(b_vals.mean()),
"std_a": float(a_vals.std(ddof=1)),
"std_b": float(b_vals.std(ddof=1)),
"t_statistic": None if math.isnan(t_stat) else t_stat,
"p_value": None if math.isnan(p_value) else p_value,
"alpha": alpha,
"significant": significant,
}
return test, True
def analyze_ab_test(
df: pd.DataFrame,
dimension: str,
value_column: str,
group_a: object,
group_b: object,
agg: str = "sum",
alpha: float = 0.05,
) -> dict[str, object]:
"""Compare a metric between two groups and test whether the gap is significant.
`data` is materialised upstream (Pattern A) and arrives as `df`.
Returns a dict with: dimension, value_column, agg; group_a/value_a and
group_b/value_b (the headline aggregate); diff_abs, diff_pct, comparison;
`test` (Welch t-test on the raw values); and `charts` (the reserved auto-chart key).
Raises (wrapped by the invoker's never-throw seam):
ColumnNotFoundError, UnsupportedAggregationError, GroupNotFoundError.
"""
missing = [c for c in (dimension, value_column) if c not in df.columns]
if missing:
raise ColumnNotFoundError(f"columns not found: {missing}")
# `avg` is the QueryIR's word for this and planner.md teaches it; the tools
# speak pandas. Accept both, and report whatever the caller actually wrote.
# (A23 Bug 2 -- see agg_vocab.)
requested, agg = agg, normalise_agg(agg)
if agg not in SUPPORTED_AGGS:
raise UnsupportedAggregationError(
f"unsupported aggregation '{requested}'; supported: {list(SUPPORTED_AGGS)}"
)
rows_a = df.loc[df[dimension] == group_a, value_column]
rows_b = df.loc[df[dimension] == group_b, value_column]
empty = [g for g, rows in ((group_a, rows_a), (group_b, rows_b)) if rows.empty]
if empty:
raise GroupNotFoundError(f"no rows for group(s) {empty} in column '{dimension}'")
value_a = float(rows_a.agg(agg))
value_b = float(rows_b.agg(agg))
diff_abs = value_b - value_a
diff_pct = (diff_abs / value_a) if value_a != 0 else None
comparison = "higher" if diff_abs > 0 else "lower" if diff_abs < 0 else "equal"
# Significance test on the raw numeric values (compares MEANS, independent of agg).
a_vals = pd.to_numeric(rows_a, errors="coerce").dropna()
b_vals = pd.to_numeric(rows_b, errors="coerce").dropna()
test, ran = _welch_test(a_vals, b_vals, alpha)
if ran:
y_a, y_b = test["mean_a"], test["mean_b"]
err_a = _Z_95 * float(a_vals.std(ddof=1)) / math.sqrt(len(a_vals))
err_b = _Z_95 * float(b_vals.std(ddof=1)) / math.sqrt(len(b_vals))
else:
y_a, y_b, err_a, err_b = value_a, value_b, None, None
return {
"dimension": dimension,
"value_column": value_column,
"agg": agg,
"group_a": group_a,
"value_a": value_a,
"group_b": group_b,
"value_b": value_b,
"diff_abs": diff_abs,
"diff_pct": diff_pct,
"comparison": comparison,
"test": test,
# Reserved `quality` block (conventions §3). For an A/B comparison the
# statistical verdict IS the quality verdict: a gap the test cannot separate
# from noise is weak evidence no matter how large the headline difference
# looks. `significant is None` means the test could not run (too few rows,
# zero variance) — also weak, for the same reason. CK7 surfaces it.
"quality": {
"metric": "p_value",
"value": None if test.get("p_value") is None else round(test["p_value"], 4),
"floor": alpha,
"weak": not test["significant"],
"note": (
"the difference is not distinguishable from noise at this sample size"
if not test["significant"]
else f"significant at alpha={alpha}"
),
},
"charts": [_ab_chart(group_a, group_b, y_a, y_b, err_a, err_b, value_column)],
}
|