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