breakitdown 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- breakitdown/__init__.py +37 -0
- breakitdown/_common.py +99 -0
- breakitdown/aggregate.py +473 -0
- breakitdown/columns.py +39 -0
- breakitdown/compare.py +267 -0
- breakitdown/experiment.py +391 -0
- breakitdown/metrics.py +369 -0
- breakitdown/py.typed +0 -0
- breakitdown/results.py +266 -0
- breakitdown/stats.py +196 -0
- breakitdown/tables.py +124 -0
- breakitdown-0.1.0.dist-info/METADATA +375 -0
- breakitdown-0.1.0.dist-info/RECORD +15 -0
- breakitdown-0.1.0.dist-info/WHEEL +4 -0
- breakitdown-0.1.0.dist-info/licenses/LICENSE +202 -0
breakitdown/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""breakitdown: A/B test analysis on Spark.
|
|
2
|
+
|
|
3
|
+
from breakitdown import Experiment, MetricTable, Count, Binary, Ratio, Quantile
|
|
4
|
+
|
|
5
|
+
exp = Experiment(control="control", treatments=["t1"], start="2024-03-01", end="2024-03-14",
|
|
6
|
+
user_segments=["os"])
|
|
7
|
+
results = exp.analyze(MetricTable("db.app_events", [Count("clicks"), Binary("purchases")]))
|
|
8
|
+
results.overall()
|
|
9
|
+
|
|
10
|
+
See README.md for the design, the input contract and the result columns.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ._common import NULL_LABEL, OVERALL
|
|
14
|
+
from .columns import Columns
|
|
15
|
+
from .experiment import Experiment
|
|
16
|
+
from .metrics import Binary, Count, Direction, Metric, Quantile, QuantileMethod, Ratio
|
|
17
|
+
from .results import ExperimentResults
|
|
18
|
+
from .tables import FlightMapping, MetricTable
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Experiment",
|
|
24
|
+
"MetricTable",
|
|
25
|
+
"FlightMapping",
|
|
26
|
+
"Columns",
|
|
27
|
+
"Count",
|
|
28
|
+
"Binary",
|
|
29
|
+
"Ratio",
|
|
30
|
+
"Quantile",
|
|
31
|
+
"Metric",
|
|
32
|
+
"Direction",
|
|
33
|
+
"QuantileMethod",
|
|
34
|
+
"ExperimentResults",
|
|
35
|
+
"OVERALL",
|
|
36
|
+
"NULL_LABEL",
|
|
37
|
+
]
|
breakitdown/_common.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Shared constants and helpers: dates, segment specs, column quoting and analysis periods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterable, List, Sequence, Tuple, Union
|
|
8
|
+
|
|
9
|
+
OVERALL = "overall"
|
|
10
|
+
"""Segment name and value of results that are not broken down."""
|
|
11
|
+
|
|
12
|
+
NULL_LABEL = "(null)"
|
|
13
|
+
"""How a NULL segment value is shown. NULLs form their own segment instead of being dropped."""
|
|
14
|
+
|
|
15
|
+
SEPARATOR = "|"
|
|
16
|
+
"""Joins the columns of a multi-column segment: segment ``os|country`` has values like ``iOS|US``."""
|
|
17
|
+
|
|
18
|
+
DateLike = Union[str, _dt.date, _dt.datetime]
|
|
19
|
+
SegmentSpec = Tuple[str, ...]
|
|
20
|
+
SegmentsLike = Union[None, str, Sequence[Union[str, Sequence[str]]]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_date(value: DateLike) -> _dt.date:
|
|
24
|
+
"""Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` strings, dates and datetimes into a ``date``."""
|
|
25
|
+
if isinstance(value, _dt.datetime):
|
|
26
|
+
return value.date()
|
|
27
|
+
if isinstance(value, _dt.date):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
text = value.strip()
|
|
31
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d", "%Y/%m/%d"):
|
|
32
|
+
try:
|
|
33
|
+
return _dt.datetime.strptime(text, fmt).date()
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
raise ValueError(f"Unrecognised date {value!r}; use YYYY-MM-DD or YYYYMMDD.")
|
|
37
|
+
raise TypeError(f"Expected a date, datetime or string, got {type(value).__name__}.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_segments(spec: SegmentsLike, what: str = "segment") -> Tuple[SegmentSpec, ...]:
|
|
41
|
+
"""Normalise a segment spec into a tuple of column tuples.
|
|
42
|
+
|
|
43
|
+
``"os"`` -> ``(("os",),)``, ``"os|country"`` -> ``(("os", "country"),)`` and
|
|
44
|
+
``["os", ("os", "country")]`` -> ``(("os",), ("os", "country"))``. Duplicates are dropped and
|
|
45
|
+
the order is kept. The overall (not broken down) result is always produced and is not part of
|
|
46
|
+
the spec.
|
|
47
|
+
"""
|
|
48
|
+
if spec is None:
|
|
49
|
+
return ()
|
|
50
|
+
items = [spec] if isinstance(spec, str) else list(spec)
|
|
51
|
+
out: List[SegmentSpec] = []
|
|
52
|
+
for item in items:
|
|
53
|
+
if isinstance(item, str):
|
|
54
|
+
cols = tuple(part.strip() for part in item.split(SEPARATOR))
|
|
55
|
+
else:
|
|
56
|
+
cols = tuple(item)
|
|
57
|
+
if not cols or not all(isinstance(c, str) and c for c in cols):
|
|
58
|
+
raise ValueError(f"Invalid {what} {item!r}: expected a column name or a sequence of column names.")
|
|
59
|
+
if len(set(cols)) != len(cols):
|
|
60
|
+
raise ValueError(f"Invalid {what} {item!r}: a column appears twice.")
|
|
61
|
+
if cols not in out:
|
|
62
|
+
out.append(cols)
|
|
63
|
+
return tuple(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def segment_columns(specs: Iterable[SegmentSpec]) -> Tuple[str, ...]:
|
|
67
|
+
"""The distinct columns used by a set of segment specs, in first-seen order."""
|
|
68
|
+
return tuple(dict.fromkeys(col for spec in specs for col in spec))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def segment_name(spec: SegmentSpec) -> str:
|
|
72
|
+
return SEPARATOR.join(spec) if spec else OVERALL
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def quote(name: str) -> str:
|
|
76
|
+
"""Backtick-quote a column name so Spark takes dots and spaces in it literally."""
|
|
77
|
+
return "`" + name.replace("`", "``") + "`"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Period:
|
|
82
|
+
"""A date range that is analysed as one block.
|
|
83
|
+
|
|
84
|
+
``name`` labels the period in the results. Every analysis has a single ``"ab"`` period today.
|
|
85
|
+
Retro A/A (roadmap) adds an ``"aa"`` period over the pre-experiment range and runs it through
|
|
86
|
+
the same pipeline, so results already carry a ``period`` column.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
start: _dt.date
|
|
90
|
+
end: _dt.date
|
|
91
|
+
name: str = "ab"
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
if self.start > self.end:
|
|
95
|
+
raise ValueError(f"Period start {self.start} is after its end {self.end}.")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def days(self) -> List[_dt.date]:
|
|
99
|
+
return [self.start + _dt.timedelta(days=i) for i in range((self.end - self.start).days + 1)]
|
breakitdown/aggregate.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Spark side of the analysis: prepare rows, derive the population, reduce to sufficient statistics.
|
|
2
|
+
|
|
3
|
+
Per MetricTable, everything stays lazy until a single collect per job::
|
|
4
|
+
|
|
5
|
+
source --> rows one schema: __unit, __flight, __day, segment columns, metric inputs
|
|
6
|
+
+-> population one row per unit and flight: user segments and first exposure day
|
|
7
|
+
|
|
8
|
+
rows --> unit-level rollup (unit [x day] [x metric segment]) --> sufficient statistics
|
|
9
|
+
rows --> quantile per group --> per-unit row and above-quantile counts --> sufficient statistics
|
|
10
|
+
population --> units per flight x user segment [x day]: the denominators
|
|
11
|
+
|
|
12
|
+
Sufficient statistics are sums over units *with* rows; the denominators come from the population,
|
|
13
|
+
so units without rows count as zeros without ever being materialised.
|
|
14
|
+
|
|
15
|
+
Pre-joined mode builds both ``rows`` and ``population`` from the metric table. Mapping mode joins
|
|
16
|
+
the metric rows to the mapping and takes the population from the mapping.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
from pyspark.sql import Column, DataFrame, Window
|
|
27
|
+
from pyspark.sql import functions as F
|
|
28
|
+
from pyspark.sql import types as T
|
|
29
|
+
|
|
30
|
+
from ._common import NULL_LABEL, OVERALL, SEPARATOR, Period, SegmentSpec, quote, segment_columns, segment_name
|
|
31
|
+
from .columns import Columns
|
|
32
|
+
from .metrics import Quantile, QuantileMethod, UnitMetric
|
|
33
|
+
|
|
34
|
+
UNIT = "__unit"
|
|
35
|
+
FLIGHT = "__flight"
|
|
36
|
+
DAY = "__day"
|
|
37
|
+
TS = "__ts"
|
|
38
|
+
EXPOSURE_DAY = "__exposure_day"
|
|
39
|
+
EXPOSURE_TS = "__exposure_ts"
|
|
40
|
+
FIRST_DAY = "__first_day"
|
|
41
|
+
VALUE = "__value"
|
|
42
|
+
|
|
43
|
+
CUMULATIVE = "cumulative"
|
|
44
|
+
DAILY = "daily"
|
|
45
|
+
|
|
46
|
+
_TIMESTAMP_TYPES = tuple(t for t in (T.TimestampType, getattr(T, "TimestampNTZType", None)) if t is not None)
|
|
47
|
+
_INTEGRAL_TYPES = (T.ByteType, T.ShortType, T.IntegerType, T.LongType)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Prepared:
|
|
52
|
+
rows: DataFrame
|
|
53
|
+
"""UNIT, FLIGHT, DAY, user segment columns, metric segment columns, metric input columns."""
|
|
54
|
+
population: DataFrame
|
|
55
|
+
"""UNIT, FLIGHT, FIRST_DAY, user segment columns: one row per unit and flight."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------------------------- helpers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cols(names: Iterable[str]) -> List[Column]:
|
|
62
|
+
return [F.col(quote(name)) for name in names]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _union(parts: Sequence[DataFrame]) -> DataFrame:
|
|
66
|
+
return functools.reduce(lambda a, b: a.unionByName(b), parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collect_pandas(df: DataFrame) -> pd.DataFrame:
|
|
70
|
+
"""Collect a small aggregated DataFrame into pandas.
|
|
71
|
+
|
|
72
|
+
A plain ``collect()`` rather than ``toPandas()``: results are small, and this keeps column types
|
|
73
|
+
the same whatever the session's Arrow settings (and avoids ``toPandas``' distutils import, which
|
|
74
|
+
fails on Python 3.12 with PySpark 3.5).
|
|
75
|
+
"""
|
|
76
|
+
return pd.DataFrame.from_records([tuple(r) for r in df.collect()], columns=df.columns)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _field_type(df: DataFrame, name: str) -> T.DataType:
|
|
80
|
+
fields = df.schema.fields
|
|
81
|
+
for field in fields:
|
|
82
|
+
if field.name == name:
|
|
83
|
+
return field.dataType
|
|
84
|
+
for field in fields:
|
|
85
|
+
if field.name.lower() == name.lower():
|
|
86
|
+
return field.dataType
|
|
87
|
+
raise KeyError(name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_columns(df: DataFrame, columns: Iterable[str], where: str) -> None:
|
|
91
|
+
available = {c.lower() for c in df.columns}
|
|
92
|
+
missing = [c for c in dict.fromkeys(columns) if c.lower() not in available]
|
|
93
|
+
if missing:
|
|
94
|
+
raise ValueError(f"{where} is missing column(s) {missing}. Available columns: {df.columns}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def day_column(df: DataFrame, name: str, time_format: Optional[str]) -> Column:
|
|
98
|
+
"""Calendar day of a DATE, TIMESTAMP, string or yyyyMMdd-integer column."""
|
|
99
|
+
dtype = _field_type(df, name)
|
|
100
|
+
col = F.col(quote(name))
|
|
101
|
+
if isinstance(dtype, T.DateType):
|
|
102
|
+
return col
|
|
103
|
+
if isinstance(dtype, _TIMESTAMP_TYPES):
|
|
104
|
+
return F.to_date(col)
|
|
105
|
+
if isinstance(dtype, T.StringType):
|
|
106
|
+
return F.to_date(col, time_format) if time_format else F.to_date(col)
|
|
107
|
+
if isinstance(dtype, _INTEGRAL_TYPES):
|
|
108
|
+
return F.to_date(col.cast("string"), time_format or "yyyyMMdd")
|
|
109
|
+
raise TypeError(f"Column {name!r} has type {dtype.simpleString()}; expected date, timestamp, string or integer.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_timestamp(df: DataFrame, name: str) -> bool:
|
|
113
|
+
return isinstance(_field_type(df, name), _TIMESTAMP_TYPES)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _segment(col: str) -> Column:
|
|
117
|
+
"""A segment column as a non-null string: NULL becomes its own ``(null)`` segment."""
|
|
118
|
+
return F.coalesce(F.col(quote(col)).cast("string"), F.lit(NULL_LABEL)).alias(col)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _in_period(day: Column, period: Period) -> Column:
|
|
122
|
+
return day.between(F.lit(period.start), F.lit(period.end))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ----------------------------------------------------------------------------------- preparation
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def prepare_prejoined(
|
|
129
|
+
df: DataFrame,
|
|
130
|
+
columns: Columns,
|
|
131
|
+
period: Period,
|
|
132
|
+
flights: Sequence[str],
|
|
133
|
+
user_cols: Sequence[str],
|
|
134
|
+
metric_segment_cols: Sequence[str],
|
|
135
|
+
inputs: Sequence[str],
|
|
136
|
+
where: str,
|
|
137
|
+
) -> Prepared:
|
|
138
|
+
"""Rows and population of a table that already carries flight and user segments on every row."""
|
|
139
|
+
exposure = columns.exposure
|
|
140
|
+
require_columns(
|
|
141
|
+
df,
|
|
142
|
+
[columns.unit, columns.flight, columns.time, *([exposure] if exposure else []), *user_cols,
|
|
143
|
+
*metric_segment_cols, *inputs],
|
|
144
|
+
where,
|
|
145
|
+
)
|
|
146
|
+
day = day_column(df, columns.time, columns.time_format)
|
|
147
|
+
flight = F.col(quote(columns.flight)).cast("string")
|
|
148
|
+
keep = _in_period(day, period) & flight.isin(list(flights)) & F.col(quote(columns.unit)).isNotNull()
|
|
149
|
+
selects = [F.col(quote(columns.unit)).alias(UNIT), flight.alias(FLIGHT), day.alias(DAY)]
|
|
150
|
+
first_day = F.min(DAY)
|
|
151
|
+
if exposure:
|
|
152
|
+
exposure_day = day_column(df, exposure, columns.time_format)
|
|
153
|
+
if is_timestamp(df, columns.time) and is_timestamp(df, exposure):
|
|
154
|
+
after = F.col(quote(columns.time)) >= F.col(quote(exposure))
|
|
155
|
+
else:
|
|
156
|
+
after = day >= exposure_day
|
|
157
|
+
keep = keep & (F.col(quote(exposure)).isNull() | after)
|
|
158
|
+
selects.append(exposure_day.alias(EXPOSURE_DAY))
|
|
159
|
+
first_day = F.coalesce(F.min(EXPOSURE_DAY), F.min(DAY))
|
|
160
|
+
selects += [_segment(c) for c in (*user_cols, *metric_segment_cols)]
|
|
161
|
+
selects += [F.col(quote(c)).alias(c) for c in inputs]
|
|
162
|
+
rows = df.where(keep).select(*selects)
|
|
163
|
+
population = (
|
|
164
|
+
rows.groupBy(*_cols((UNIT, FLIGHT, *user_cols)))
|
|
165
|
+
.agg(first_day.alias(FIRST_DAY))
|
|
166
|
+
.withColumn(FIRST_DAY, F.greatest(F.col(FIRST_DAY), F.lit(period.start)))
|
|
167
|
+
)
|
|
168
|
+
if exposure:
|
|
169
|
+
rows = rows.drop(EXPOSURE_DAY)
|
|
170
|
+
return Prepared(rows, population)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def mapping_rows(df: DataFrame, columns: Columns, flights: Sequence[str], user_cols: Sequence[str], where: str) -> DataFrame:
|
|
174
|
+
"""The mapping's rows for the experiment's flights, with internal column names."""
|
|
175
|
+
exposure = columns.exposure
|
|
176
|
+
require_columns(df, [columns.unit, columns.flight, *([exposure] if exposure else []), *user_cols], where)
|
|
177
|
+
flight = F.col(quote(columns.flight)).cast("string")
|
|
178
|
+
selects = [F.col(quote(columns.unit)).alias(UNIT), flight.alias(FLIGHT)]
|
|
179
|
+
selects += [F.col(quote(c)).cast("string").alias(c) for c in user_cols]
|
|
180
|
+
if exposure:
|
|
181
|
+
selects.append(day_column(df, exposure, columns.time_format).alias(EXPOSURE_DAY))
|
|
182
|
+
if is_timestamp(df, exposure):
|
|
183
|
+
selects.append(F.col(quote(exposure)).alias(EXPOSURE_TS))
|
|
184
|
+
keep = flight.isin(list(flights)) & F.col(quote(columns.unit)).isNotNull()
|
|
185
|
+
return df.where(keep).select(*selects)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def prepare_mapping(
|
|
189
|
+
df: DataFrame,
|
|
190
|
+
columns: Columns,
|
|
191
|
+
period: Period,
|
|
192
|
+
flights: Sequence[str],
|
|
193
|
+
user_cols: Sequence[str],
|
|
194
|
+
where: str,
|
|
195
|
+
) -> Tuple[DataFrame, bool]:
|
|
196
|
+
"""Population from a flight mapping. Returns (population, has exact exposure timestamps)."""
|
|
197
|
+
rows = mapping_rows(df, columns, flights, user_cols, where)
|
|
198
|
+
has_day = EXPOSURE_DAY in rows.columns
|
|
199
|
+
precise = EXPOSURE_TS in rows.columns
|
|
200
|
+
aggs = [F.max(F.col(quote(c))).alias(c) for c in user_cols]
|
|
201
|
+
aggs.append((F.min(EXPOSURE_DAY) if has_day else F.lit(None).cast("date")).alias(FIRST_DAY))
|
|
202
|
+
if precise:
|
|
203
|
+
aggs.append(F.min(EXPOSURE_TS).alias(EXPOSURE_TS))
|
|
204
|
+
population = rows.groupBy(UNIT, FLIGHT).agg(*aggs)
|
|
205
|
+
population = population.where(F.col(FIRST_DAY).isNull() | (F.col(FIRST_DAY) <= F.lit(period.end)))
|
|
206
|
+
population = population.withColumn(
|
|
207
|
+
FIRST_DAY, F.greatest(F.coalesce(F.col(FIRST_DAY), F.lit(period.start)), F.lit(period.start))
|
|
208
|
+
)
|
|
209
|
+
for c in user_cols:
|
|
210
|
+
population = population.withColumn(c, F.coalesce(F.col(quote(c)), F.lit(NULL_LABEL)))
|
|
211
|
+
return population, precise
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def prepare_joined(
|
|
215
|
+
df: DataFrame,
|
|
216
|
+
columns: Columns,
|
|
217
|
+
period: Period,
|
|
218
|
+
mapping_population: DataFrame,
|
|
219
|
+
mapping_has_timestamps: bool,
|
|
220
|
+
user_cols: Sequence[str],
|
|
221
|
+
metric_segment_cols: Sequence[str],
|
|
222
|
+
inputs: Sequence[str],
|
|
223
|
+
inner_join: bool,
|
|
224
|
+
where: str,
|
|
225
|
+
) -> Prepared:
|
|
226
|
+
"""Rows of a metric table joined to the mapping; the mapping (or its units with rows) is the population."""
|
|
227
|
+
require_columns(df, [columns.unit, columns.time, *metric_segment_cols, *inputs], where)
|
|
228
|
+
day = day_column(df, columns.time, columns.time_format)
|
|
229
|
+
precise = mapping_has_timestamps and is_timestamp(df, columns.time)
|
|
230
|
+
selects = [F.col(quote(columns.unit)).alias(UNIT), day.alias(DAY)]
|
|
231
|
+
if precise:
|
|
232
|
+
selects.append(F.col(quote(columns.time)).alias(TS))
|
|
233
|
+
selects += [_segment(c) for c in metric_segment_cols]
|
|
234
|
+
selects += [F.col(quote(c)).alias(c) for c in inputs]
|
|
235
|
+
metric_rows = df.where(_in_period(day, period) & F.col(quote(columns.unit)).isNotNull()).select(*selects)
|
|
236
|
+
mapped = mapping_population.select(*_cols((UNIT, FLIGHT, FIRST_DAY, *user_cols, *([EXPOSURE_TS] if precise else []))))
|
|
237
|
+
joined = metric_rows.join(mapped, on=UNIT, how="inner")
|
|
238
|
+
if precise:
|
|
239
|
+
after = F.col(EXPOSURE_TS).isNull() | (F.col(TS) >= F.col(EXPOSURE_TS))
|
|
240
|
+
else:
|
|
241
|
+
after = F.col(DAY) >= F.col(FIRST_DAY)
|
|
242
|
+
rows = joined.where(after).select(*_cols((UNIT, FLIGHT, DAY, *user_cols, *metric_segment_cols, *inputs)))
|
|
243
|
+
population = mapping_population.select(*_cols((UNIT, FLIGHT, FIRST_DAY, *user_cols)))
|
|
244
|
+
if inner_join:
|
|
245
|
+
population = population.join(rows.select(UNIT, FLIGHT).distinct(), on=[UNIT, FLIGHT], how="left_semi")
|
|
246
|
+
return Prepared(rows, population)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------------------- sufficient statistics
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _label(
|
|
253
|
+
df: DataFrame,
|
|
254
|
+
period: Period,
|
|
255
|
+
granularity: str,
|
|
256
|
+
user_spec: SegmentSpec,
|
|
257
|
+
metric_spec: SegmentSpec,
|
|
258
|
+
stat_names: Sequence[str],
|
|
259
|
+
) -> DataFrame:
|
|
260
|
+
"""Put a grouped result into the common schema so all groupings can be unioned and collected once."""
|
|
261
|
+
|
|
262
|
+
def value(spec: SegmentSpec) -> Column:
|
|
263
|
+
return F.concat_ws(SEPARATOR, *_cols(spec)) if spec else F.lit(OVERALL)
|
|
264
|
+
|
|
265
|
+
day = F.col(DAY) if granularity == DAILY else F.lit(None).cast("date")
|
|
266
|
+
return df.select(
|
|
267
|
+
F.lit(period.name).alias("period"),
|
|
268
|
+
F.lit(granularity).alias("granularity"),
|
|
269
|
+
day.alias("day"),
|
|
270
|
+
F.lit(segment_name(user_spec)).alias("user_segment"),
|
|
271
|
+
value(user_spec).alias("user_segment_value"),
|
|
272
|
+
F.lit(segment_name(metric_spec)).alias("metric_segment"),
|
|
273
|
+
value(metric_spec).alias("metric_segment_value"),
|
|
274
|
+
F.col(FLIGHT).alias("flight"),
|
|
275
|
+
F.col("__units_with_data").alias("units_with_data"),
|
|
276
|
+
*_cols(stat_names),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def unit_metric_stats(
|
|
281
|
+
prepared: Prepared,
|
|
282
|
+
keyed_metrics: Sequence[Tuple[str, UnitMetric]],
|
|
283
|
+
period: Period,
|
|
284
|
+
user_specs: Sequence[SegmentSpec],
|
|
285
|
+
metric_specs: Sequence[SegmentSpec],
|
|
286
|
+
granularities: Sequence[str],
|
|
287
|
+
) -> DataFrame:
|
|
288
|
+
"""Sufficient statistics of all Count/Binary/Ratio metrics of a table, for every grouping."""
|
|
289
|
+
user_cols = segment_columns(user_specs)
|
|
290
|
+
unit_aggs: Dict[str, Column] = {}
|
|
291
|
+
group_aggs: Dict[str, Column] = {}
|
|
292
|
+
for key, metric in keyed_metrics:
|
|
293
|
+
unit_aggs.update(metric.unit_aggregations(key))
|
|
294
|
+
group_aggs.update(metric.group_aggregations(key))
|
|
295
|
+
parts = []
|
|
296
|
+
for metric_spec in ((),) + tuple(metric_specs):
|
|
297
|
+
for granularity in granularities:
|
|
298
|
+
day = (DAY,) if granularity == DAILY else ()
|
|
299
|
+
# User segment columns are unit attributes, so keeping them all in the unit key does not
|
|
300
|
+
# change its grain; every user-segment grouping below reuses this one rollup.
|
|
301
|
+
units = prepared.rows.groupBy(*_cols((UNIT, FLIGHT, *user_cols, *metric_spec, *day))).agg(
|
|
302
|
+
*[expr.alias(name) for name, expr in unit_aggs.items()]
|
|
303
|
+
)
|
|
304
|
+
for user_spec in ((),) + tuple(user_specs):
|
|
305
|
+
grouped = units.groupBy(*_cols((FLIGHT, *user_spec, *metric_spec, *day))).agg(
|
|
306
|
+
F.count(F.lit(1)).alias("__units_with_data"),
|
|
307
|
+
*[expr.alias(name) for name, expr in group_aggs.items()],
|
|
308
|
+
)
|
|
309
|
+
parts.append(_label(grouped, period, granularity, user_spec, metric_spec, list(group_aggs)))
|
|
310
|
+
return _union(parts)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _approx_quantiles(events: DataFrame, keys: Sequence[str], metric: Quantile) -> DataFrame:
|
|
314
|
+
"""REGULAR method: percentile_approx at p and p +/- bandwidth."""
|
|
315
|
+
h = metric.bandwidth
|
|
316
|
+
probabilities = [x for p in metric.quantiles for x in (p - h, p, p + h)]
|
|
317
|
+
table = events.groupBy(*_cols(keys)).agg(
|
|
318
|
+
F.percentile_approx(VALUE, probabilities, metric.accuracy).alias("__qs")
|
|
319
|
+
)
|
|
320
|
+
selects = _cols(keys)
|
|
321
|
+
for i in range(len(metric.quantiles)):
|
|
322
|
+
low, mid, high = (F.col("__qs")[3 * i + j] for j in range(3))
|
|
323
|
+
selects += [mid.alias(f"__q{i}"), _density(low, high, h).alias(f"__f{i}")]
|
|
324
|
+
return table.select(*selects)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _interpolated_quantiles(events: DataFrame, keys: Sequence[str], metric: Quantile) -> DataFrame:
|
|
328
|
+
"""DENSITY method: exact histogram with the CDF interpolated between neighbouring values.
|
|
329
|
+
|
|
330
|
+
Each distinct value v spreads over [start, end]: halfway to its neighbours, but at most
|
|
331
|
+
``resolution`` away (so at most resolution / 2 on each side); the smallest and the largest value
|
|
332
|
+
do not spread outward. The quantile for probability f is found in the first value whose
|
|
333
|
+
cumulative count reaches ``f * total`` and interpolated linearly inside its interval.
|
|
334
|
+
"""
|
|
335
|
+
h, r = metric.bandwidth, metric.resolution
|
|
336
|
+
targets = [(3 * i + j, float(p + d)) for i, p in enumerate(metric.quantiles) for j, d in enumerate((-h, 0.0, h))]
|
|
337
|
+
target_df = events.sparkSession.createDataFrame(targets, "__tid INT, __target DOUBLE")
|
|
338
|
+
by_value = Window.partitionBy(*_cols(keys)).orderBy(VALUE)
|
|
339
|
+
whole = Window.partitionBy(*_cols(keys))
|
|
340
|
+
v = F.col(VALUE)
|
|
341
|
+
previous, following = F.lag(v).over(by_value), F.lead(v).over(by_value)
|
|
342
|
+
histogram = events.groupBy(*_cols(keys), v).agg(F.count(F.lit(1)).alias("__c")).select(
|
|
343
|
+
*_cols(keys),
|
|
344
|
+
"__c",
|
|
345
|
+
F.sum("__c").over(by_value.rowsBetween(Window.unboundedPreceding, Window.currentRow)).alias("__cum"),
|
|
346
|
+
F.sum("__c").over(whole).alias("__total"),
|
|
347
|
+
((F.when(previous.isNull(), v).otherwise(F.greatest(previous, v - r)) + v) / 2).alias("__start"),
|
|
348
|
+
((F.when(following.isNull(), v).otherwise(F.least(following, v + r)) + v) / 2).alias("__end"),
|
|
349
|
+
)
|
|
350
|
+
reached = histogram.crossJoin(F.broadcast(target_df)).where(F.col("__cum") >= F.col("__target") * F.col("__total"))
|
|
351
|
+
first = reached.groupBy(*_cols(keys), "__tid").agg(
|
|
352
|
+
F.min(F.struct("__cum", "__c", "__start", "__end", "__total", "__target")).alias("__s")
|
|
353
|
+
)
|
|
354
|
+
s = F.col("__s")
|
|
355
|
+
quantile = s["__start"] + (s["__target"] * s["__total"] - (s["__cum"] - s["__c"])) / s["__c"] * (
|
|
356
|
+
s["__end"] - s["__start"]
|
|
357
|
+
)
|
|
358
|
+
first = first.select(*_cols(keys), "__tid", quantile.alias("__x"))
|
|
359
|
+
|
|
360
|
+
def at(tid: int) -> Column:
|
|
361
|
+
return F.max(F.when(F.col("__tid") == tid, F.col("__x")))
|
|
362
|
+
|
|
363
|
+
aggs = []
|
|
364
|
+
for i in range(len(metric.quantiles)):
|
|
365
|
+
aggs += [at(3 * i + 1).alias(f"__q{i}"), _density(at(3 * i), at(3 * i + 2), h).alias(f"__f{i}")]
|
|
366
|
+
return first.groupBy(*_cols(keys)).agg(*aggs)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _density(low: Column, high: Column, bandwidth: float) -> Column:
|
|
370
|
+
"""2h / (Q(p+h) - Q(p-h)); NULL when the two quantiles tie and the density is undefined."""
|
|
371
|
+
width = high - low
|
|
372
|
+
return F.when(width > 0, F.lit(2 * bandwidth) / width)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def quantile_stats(
|
|
376
|
+
prepared: Prepared,
|
|
377
|
+
key: str,
|
|
378
|
+
metric: Quantile,
|
|
379
|
+
period: Period,
|
|
380
|
+
user_specs: Sequence[SegmentSpec],
|
|
381
|
+
metric_specs: Sequence[SegmentSpec],
|
|
382
|
+
granularities: Sequence[str],
|
|
383
|
+
) -> DataFrame:
|
|
384
|
+
"""Sufficient statistics of one Quantile metric, for every grouping."""
|
|
385
|
+
user_cols = segment_columns(user_specs)
|
|
386
|
+
metric_cols = segment_columns(metric_specs)
|
|
387
|
+
raw = F.col(quote(metric.col))
|
|
388
|
+
events = prepared.rows.where(raw.isNotNull() & ~F.isnan(raw.cast("double"))).select(
|
|
389
|
+
*_cols((UNIT, FLIGHT, DAY, *user_cols, *metric_cols)), metric.value().alias(VALUE)
|
|
390
|
+
)
|
|
391
|
+
find_quantiles = _interpolated_quantiles if metric.method == QuantileMethod.DENSITY else _approx_quantiles
|
|
392
|
+
outputs = metric.outputs(key)
|
|
393
|
+
parts = []
|
|
394
|
+
for metric_spec in ((),) + tuple(metric_specs):
|
|
395
|
+
for granularity in granularities:
|
|
396
|
+
day = (DAY,) if granularity == DAILY else ()
|
|
397
|
+
for user_spec in ((),) + tuple(user_specs):
|
|
398
|
+
keys = (FLIGHT, *user_spec, *metric_spec, *day)
|
|
399
|
+
quantiles = find_quantiles(events, keys, metric)
|
|
400
|
+
joined = events.join(F.broadcast(quantiles), on=list(keys), how="inner")
|
|
401
|
+
unit_aggs = [F.count(F.lit(1)).alias("__n")]
|
|
402
|
+
for i in range(len(outputs)):
|
|
403
|
+
above = F.when(F.col(VALUE) > F.col(f"__q{i}"), F.lit(1.0)).otherwise(F.lit(0.0))
|
|
404
|
+
unit_aggs += [F.sum(above).alias(f"__z{i}"), F.max(f"__q{i}").alias(f"__q{i}"),
|
|
405
|
+
F.max(f"__f{i}").alias(f"__f{i}")]
|
|
406
|
+
units = joined.groupBy(*_cols((UNIT, *keys))).agg(*unit_aggs)
|
|
407
|
+
n = F.col("__n").cast("double")
|
|
408
|
+
group_aggs: Dict[str, Column] = {}
|
|
409
|
+
for i, output in enumerate(outputs):
|
|
410
|
+
z = F.col(f"__z{i}")
|
|
411
|
+
p = output.prefix
|
|
412
|
+
group_aggs.update({
|
|
413
|
+
f"{p}__q": F.max(f"__q{i}"),
|
|
414
|
+
f"{p}__density": F.max(f"__f{i}"),
|
|
415
|
+
f"{p}__events": F.sum(n),
|
|
416
|
+
f"{p}__events2": F.sum(n * n),
|
|
417
|
+
f"{p}__above": F.sum(z),
|
|
418
|
+
f"{p}__above2": F.sum(z * z),
|
|
419
|
+
f"{p}__above_events": F.sum(z * n),
|
|
420
|
+
})
|
|
421
|
+
grouped = units.groupBy(*_cols(keys)).agg(
|
|
422
|
+
F.count(F.lit(1)).alias("__units_with_data"),
|
|
423
|
+
*[expr.alias(name) for name, expr in group_aggs.items()],
|
|
424
|
+
)
|
|
425
|
+
parts.append(_label(grouped, period, granularity, user_spec, metric_spec, list(group_aggs)))
|
|
426
|
+
return _union(parts)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ------------------------------------------------------------------------------------- denominators
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def population_counts(
|
|
433
|
+
population: DataFrame,
|
|
434
|
+
period: Period,
|
|
435
|
+
user_specs: Sequence[SegmentSpec],
|
|
436
|
+
daily: bool,
|
|
437
|
+
) -> pd.DataFrame:
|
|
438
|
+
"""Units per flight and user segment: overall (cumulative) and exposed-by-day (daily)."""
|
|
439
|
+
parts = []
|
|
440
|
+
for spec in ((),) + tuple(user_specs):
|
|
441
|
+
value = F.concat_ws(SEPARATOR, *_cols(spec)) if spec else F.lit(OVERALL)
|
|
442
|
+
parts.append(
|
|
443
|
+
population.groupBy(*_cols((FLIGHT, *spec, FIRST_DAY)))
|
|
444
|
+
.agg(F.count(F.lit(1)).alias("n"))
|
|
445
|
+
.select(
|
|
446
|
+
F.lit(segment_name(spec)).alias("user_segment"),
|
|
447
|
+
value.alias("user_segment_value"),
|
|
448
|
+
F.col(FLIGHT).alias("flight"),
|
|
449
|
+
F.col(FIRST_DAY).alias("first_day"),
|
|
450
|
+
"n",
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
return expand_counts(collect_pandas(_union(parts)), period, daily)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def expand_counts(by_first_day: pd.DataFrame, period: Period, daily: bool) -> pd.DataFrame:
|
|
457
|
+
"""Turn unit counts by first exposure day into cumulative and per-day denominators."""
|
|
458
|
+
keys = ["user_segment", "user_segment_value", "flight"]
|
|
459
|
+
columns = ["period", "granularity", "day", *keys, "n"]
|
|
460
|
+
if by_first_day.empty:
|
|
461
|
+
return pd.DataFrame(columns=columns)
|
|
462
|
+
counts = by_first_day.assign(n=by_first_day["n"].astype(float), first_day=pd.to_datetime(by_first_day["first_day"]))
|
|
463
|
+
cumulative = counts.groupby(keys, as_index=False)["n"].sum().assign(granularity=CUMULATIVE, day=pd.NaT)
|
|
464
|
+
frames = [cumulative]
|
|
465
|
+
if daily:
|
|
466
|
+
days = pd.DatetimeIndex(pd.to_datetime(period.days), name="day")
|
|
467
|
+
wide = counts.pivot_table(index=keys, columns="first_day", values="n", aggfunc="sum", fill_value=0.0)
|
|
468
|
+
wide = wide.reindex(columns=days, fill_value=0.0).cumsum(axis=1)
|
|
469
|
+
wide.columns.name = "day"
|
|
470
|
+
frames.append(wide.reset_index().melt(id_vars=keys, var_name="day", value_name="n").assign(granularity=DAILY))
|
|
471
|
+
out = pd.concat(frames, ignore_index=True).assign(period=period.name)
|
|
472
|
+
out["day"] = pd.to_datetime(out["day"])
|
|
473
|
+
return out[columns]
|
breakitdown/columns.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Column conventions: which columns hold the unit id, the flight, the event time and the exposure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Columns:
|
|
11
|
+
"""Names of the columns breakitdown reads from metric tables and from a flight mapping.
|
|
12
|
+
|
|
13
|
+
Set it once on the ``Experiment``; a ``MetricTable`` or ``FlightMapping`` with a different schema
|
|
14
|
+
can override it with its own ``columns=``.
|
|
15
|
+
|
|
16
|
+
Attributes
|
|
17
|
+
----------
|
|
18
|
+
unit:
|
|
19
|
+
Randomization unit id (ruid). Units are the independent observations of every test.
|
|
20
|
+
flight:
|
|
21
|
+
The flight (variant) the unit is assigned to. Values are compared as strings.
|
|
22
|
+
time:
|
|
23
|
+
Event time of a row: DATE, TIMESTAMP, string (parsed with ``time_format``, default ISO
|
|
24
|
+
``yyyy-MM-dd``) or ``yyyyMMdd`` integers. Rows are bucketed by the calendar day of this
|
|
25
|
+
column and filtered to the experiment's date range; tables are never date-suffixed.
|
|
26
|
+
exposure:
|
|
27
|
+
Optional time of a unit's first exposure, with the same accepted types as ``time``. When
|
|
28
|
+
set, rows before a unit's exposure are dropped and daily denominators count the unit from
|
|
29
|
+
its exposure day on. If both ``time`` and ``exposure`` are timestamps the cut-off is exact,
|
|
30
|
+
otherwise it is by day.
|
|
31
|
+
time_format:
|
|
32
|
+
Spark datetime pattern for string or integer ``time``/``exposure`` columns, e.g. ``"yyyyMMdd"``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
unit: str = "ruid"
|
|
36
|
+
flight: str = "flight"
|
|
37
|
+
time: str = "event_time"
|
|
38
|
+
exposure: Optional[str] = None
|
|
39
|
+
time_format: Optional[str] = None
|