Skip to content

Commit 72bb92f

Browse files
authored
feat(experimentation): add conversion-over-time rows to experiment results (#8451)
1 parent e6aea0f commit 72bb92f

7 files changed

Lines changed: 692 additions & 94 deletions

File tree

api/experimentation/dataclasses.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@ class ExposuresSummary:
5656
timeseries: ExposuresTimeseries
5757

5858

59+
@dataclass(frozen=True)
60+
class ConversionBucket:
61+
variant: str
62+
bucket: datetime
63+
converted_identities: int
64+
65+
66+
@dataclass(frozen=True)
67+
class ConversionsTimeseriesPoint:
68+
bucket: str
69+
converted_identities: dict[str, int]
70+
71+
72+
@dataclass(frozen=True)
73+
class ConversionsTimeseries:
74+
granularity: ExposureGranularity
75+
points: list[ConversionsTimeseriesPoint]
76+
77+
5978
@dataclass(frozen=True)
6079
class MetricSpec:
6180
metric_id: int
@@ -66,26 +85,39 @@ class MetricSpec:
6685

6786
@dataclass(frozen=True)
6887
class ResultsAggregates:
69-
"""Sufficient statistics gathered from the warehouse for one experiment:
70-
the specs they were computed from, per-variant identity counts, and per
71-
metric the per-variant ``VariantStats``. Bundled so the keys can't drift."""
88+
"""Everything one results refresh reads from the warehouse: the specs it
89+
was computed from, per-variant identity counts, per metric the per-variant
90+
``VariantStats``, and the time-bucketed rows behind the over-time charts.
91+
Bundled so the keys can't drift."""
7292

7393
specs: list[MetricSpec]
7494
exposure_counts: dict[str, int]
7595
metric_stats: dict[int, dict[str, VariantStats]]
96+
granularity: ExposureGranularity
97+
exposure_buckets: list[ExposureBucket]
98+
# Keyed by metric id, one entry per charted metric.
99+
conversion_buckets: dict[int, list[ConversionBucket]]
76100

77101

78102
@dataclass(frozen=True)
79103
class MetricResult:
80104
metric_id: int
81105
variants: dict[str, VariantStats]
82106
inference: dict[str, Inference | None]
107+
# Only occurrence metrics chart a conversion rate; None for the rest.
108+
conversions_timeseries: ConversionsTimeseries | None
83109

84110

85111
@dataclass(frozen=True)
86112
class ResultsSummary:
87113
srm_p_value: float | None
88114
metrics: list[MetricResult]
115+
# Denominator for the conversion charts, computed in the same run as the
116+
# metrics so both sides of the rate share one as_of. Exposures are bucketed
117+
# by first exposure and conversions by first conversion, so only running
118+
# totals divide: a bucket's own conversions over its own new identities
119+
# compares different people and can exceed 100%.
120+
exposures_timeseries: ExposuresTimeseries
89121

90122

91123
@dataclass(frozen=True)

api/experimentation/results_query.py

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@
1515

1616
from collections.abc import Sequence
1717
from dataclasses import dataclass
18+
from datetime import datetime
1819
from typing import Any
1920

20-
from experimentation.dataclasses import MetricSpec
21+
from experimentation.constants import EXPOSURE_EVENT_NAME
22+
from experimentation.dataclasses import ConversionBucket, MetricSpec
2123
from experimentation.models import MetricAggregation
2224
from experimentation.stats import VariantStats
2325

2426
_FLOAT_VALUE = "toFloat64OrZero(m.value)"
2527

2628
# Events are delivered at-least-once, so dedup keeps duplicates from inflating
27-
# counts. Shared by the exposures and results queries.
29+
# counts. Shared by the exposures, results and conversions queries.
2830
_EXPOSURES_CTE = """
2931
WITH exposures AS (
3032
SELECT
@@ -50,10 +52,33 @@
5052
GROUP BY variant"""
5153
)
5254

53-
_METRIC_JOIN = """ LEFT JOIN events AS m
55+
56+
def exposure_window_params(
57+
*,
58+
environment_key: str,
59+
feature_name: str,
60+
window_start: datetime,
61+
window_end: datetime,
62+
) -> dict[str, object]:
63+
"""The parameters ``_EXPOSURES_CTE`` binds, for every query that starts
64+
from it."""
65+
return {
66+
"environment_key": environment_key,
67+
"exposure_event": EXPOSURE_EVENT_NAME,
68+
"feature_name": feature_name,
69+
"window_start": window_start,
70+
"window_end": window_end,
71+
}
72+
73+
74+
def _metric_join(events_param: str) -> str:
75+
"""Join each exposed identity to its metric events. ``events_param`` names
76+
the bound list of event names, so a query can join only the events it
77+
aggregates."""
78+
return f""" LEFT JOIN events AS m
5479
ON m.identifier = e.identifier
5580
AND m.environment_key = %(environment_key)s
56-
AND m.event IN %(metric_events)s
81+
AND m.event IN %({events_param})s
5782
AND m.timestamp >= %(window_start)s
5883
AND m.timestamp < %(window_end)s"""
5984

@@ -104,6 +129,23 @@ def outer_select(self) -> str:
104129
a = self._alias
105130
return f"sum({a}) AS {a}_sum, sum({a} * {a}) AS {a}_sum_squares"
106131

132+
@property
133+
def conversion_alias(self) -> str:
134+
return f"c{self.index}"
135+
136+
def first_conversion_select(self) -> str | None:
137+
"""Per-identity timestamp of the first post-exposure conversion, NULL
138+
when the identity never converted. Same attribution condition as
139+
unit_select, so bucket totals add up to the metric's ``sum``.
140+
141+
None for value metrics: a count or sum accrues per event rather than
142+
once per identity, so a first-conversion timestamp can't chart it."""
143+
if self.spec.aggregation != MetricAggregation.OCCURRENCE:
144+
return None
145+
return (
146+
f"minIfOrNull(m.timestamp, {self._condition()}) AS {self.conversion_alias}"
147+
)
148+
107149
def decode(self, n: int, row: Sequence[Any], index: dict[str, int]) -> VariantStats:
108150
"""Read this slot's two columns (sum, sum_squares) from a row by name."""
109151
return VariantStats(
@@ -134,7 +176,7 @@ def build_query(self) -> str:
134176
e.variant AS variant,
135177
{unit_selects}
136178
FROM exposures AS e
137-
{_METRIC_JOIN}
179+
{_metric_join("metric_events")}
138180
WHERE e.quarantined = 0
139181
GROUP BY e.identifier, e.variant
140182
)
@@ -144,13 +186,92 @@ def build_query(self) -> str:
144186
GROUP BY variant"""
145187
)
146188

147-
def add_metric_params(self, params: dict[str, object]) -> None:
148-
"""Add per-metric query parameters into an existing params dict."""
189+
def build_conversions_query(self, *, bucket_function: str) -> str | None:
190+
"""Per variant and charted metric, how many identities first converted
191+
in each time bucket. None when no attached metric charts, since there
192+
is nothing to query."""
193+
slots = self._charted_slots
194+
if not slots:
195+
return None
196+
197+
first_conversion_selects = ",\n ".join(
198+
select for s in slots if (select := s.first_conversion_select())
199+
)
200+
indexes = ", ".join(str(s.index) for s in slots)
201+
aliases = ", ".join(s.conversion_alias for s in slots)
202+
203+
return (
204+
_EXPOSURES_CTE
205+
+ f""",
206+
first_conversions AS (
207+
SELECT
208+
e.variant AS variant,
209+
{first_conversion_selects}
210+
FROM exposures AS e
211+
{_metric_join("conversion_events")}
212+
WHERE e.quarantined = 0
213+
GROUP BY e.identifier, e.variant
214+
)
215+
SELECT
216+
variant,
217+
metric_index,
218+
{bucket_function}(first_conversion, 'UTC') AS bucket,
219+
count() AS converted_identities
220+
FROM first_conversions
221+
ARRAY JOIN [{indexes}] AS metric_index, [{aliases}] AS first_conversion
222+
WHERE first_conversion IS NOT NULL
223+
GROUP BY variant, metric_index, bucket
224+
ORDER BY bucket"""
225+
)
226+
227+
@property
228+
def _charted_slots(self) -> list[_MetricSlot]:
229+
return [s for s in self._slots if s.first_conversion_select() is not None]
230+
231+
def params(
232+
self,
233+
*,
234+
environment_key: str,
235+
feature_name: str,
236+
window_start: datetime,
237+
window_end: datetime,
238+
) -> dict[str, object]:
239+
"""Every parameter the results and conversions queries bind: the
240+
exposure window, each metric's event, and the event lists each join
241+
narrows to."""
242+
params = exposure_window_params(
243+
environment_key=environment_key,
244+
feature_name=feature_name,
245+
window_start=window_start,
246+
window_end=window_end,
247+
)
149248
if not self._slots:
150-
return
249+
return params
151250
params["metric_events"] = [s.spec.event for s in self._slots]
251+
params["conversion_events"] = [s.spec.event for s in self._charted_slots]
152252
for slot in self._slots:
153253
params[f"metric_{slot.index}_event"] = slot.spec.event
254+
return params
255+
256+
def decode_conversion_rows(
257+
self, rows: Sequence[Sequence[Any]], column_names: Sequence[str]
258+
) -> dict[int, list[ConversionBucket]]:
259+
"""Group conversions-query rows by the metric behind each slot index.
260+
Every charted metric gets a key, empty when nobody converted yet."""
261+
index = {name: position for position, name in enumerate(column_names)}
262+
buckets: dict[int, list[ConversionBucket]] = {
263+
slot.spec.metric_id: [] for slot in self._charted_slots
264+
}
265+
for row in rows:
266+
metric_id = self._slots[int(row[index["metric_index"]])].spec.metric_id
267+
buckets[metric_id].append(
268+
ConversionBucket(
269+
variant=str(row[index["variant"]]),
270+
bucket=row[index["bucket"]],
271+
converted_identities=int(row[index["converted_identities"]]),
272+
)
273+
)
274+
return buckets
154275

155276
def decode_rows(
156277
self, rows: list[Any], column_names: Sequence[str]

0 commit comments

Comments
 (0)