feat(generator): торговые события — корзина, покупка, сырой ecommerce
- Зачем:
- клиентская сторона мира становится целой: без add_to_cart и purchase
в данных нет ни таксономии событий, ни вложенного JSON, ни денег,
а метка «покупатель» из плана состава ни на что не влияла (#40).
- Что:
- добавлен модуль commerce: корзина шире заказа, номер заказа вида
ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson;
- метка покупателя получила два рычага — долгую жизнь куки в плане и
свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия
мира осталась около 2%;
- часть цен каталога получила копейки: productPrice округляется форматом,
purchaseRevenue несёт точную сумму — расхождение живёт внутри события;
- граница суток забирает страницу подтверждения вместе с её покупкой:
потерь на клиентской стороне этот этап не заводит;
- решения и перемеренные числа мира записаны в спеку генератора, §9.
- Проверка:
- make lint && make typecheck && make test — 383 passed (было 353).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfbaa96696
commit
722dbe22b7
19 files changed
+1591
-210
No files matched your search
@@ -1,9 +1,10 @@
|
||||
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
|
||||
|
||||
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
|
||||
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
|
||||
строки; их колонки в pageview присутствуют, но пусты по смыслу — «пусто»
|
||||
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
|
||||
гео. Торговые события садятся на этот же поток и добавляют к нему свои
|
||||
строки — их собирает `commerce` и им же поток заканчивается. У просмотра
|
||||
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» — всегда
|
||||
пустой массив, пустая строка или 0, а не отсутствие ключа.
|
||||
|
||||
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
|
||||
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
|
||||
@@ -25,23 +26,25 @@
|
||||
|
||||
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
|
||||
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
|
||||
всегда короче таймаута, а соседние визиты куки разведены дальше него.
|
||||
всегда короче таймаута, а соседние визиты куки разведены дальше него —
|
||||
считая от последнего события визита, которым бывает покупка, а не от
|
||||
последней его страницы.
|
||||
3. Граница модельных суток режет визит: события за полночь в дне не живут.
|
||||
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
|
||||
старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое
|
||||
ограничение модели: обещание плана — гарантия, ради неё мы сужаем свободу
|
||||
старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются
|
||||
в последние минуты суток.
|
||||
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
|
||||
торговым хвостом. Это принятое ограничение модели: обещание плана —
|
||||
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
||||
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
||||
|
||||
**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два
|
||||
выровненных по строкам ряда: `page` — какая это страница магазина, и
|
||||
`product` — какой товар показывала карточка (−1 у прочих страниц). По ним
|
||||
#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение),
|
||||
и то, что посетитель на самом деле смотрел: товар в корзине, которого никто
|
||||
не открывал, — видимая глупость в воронке. Визит с назначенным заказом
|
||||
всегда доходит до `/confirmation`, а перед корзиной у него всегда есть
|
||||
карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE`
|
||||
нетронут.
|
||||
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
|
||||
по строкам ряда: `page` — какая это страница магазина, и `product` — какой
|
||||
товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и
|
||||
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
|
||||
самом деле смотрел: товар в корзине, которого никто не открывал, — видимая
|
||||
глупость в воронке. Визит с назначенным заказом всегда доходит до
|
||||
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
|
||||
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля —
|
||||
`COMMERCE`, и правка одной не сдвигает другую.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -50,21 +53,24 @@ from typing import Any
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from clickstream_generator import catalog, plan, reference, world
|
||||
from clickstream_generator import catalog, commerce, ids, plan, reference, world
|
||||
from clickstream_generator.reference import Page
|
||||
from clickstream_generator.seeds import Component, day_stream
|
||||
from clickstream_generator.weights import pick, pick_row
|
||||
|
||||
DAY_SECONDS = 24 * 60 * 60
|
||||
|
||||
# Потолок идентификаторов тот же, что у кук: выше 2^53 числа в JSON
|
||||
# округляются (контракт схемы, `WatchID`).
|
||||
ID_LIMIT = plan.CLIENT_ID_LIMIT
|
||||
|
||||
# Карточка перед корзиной обязательна: положить в корзину то, чего не
|
||||
# открывал, посетитель не может.
|
||||
MIN_PAGES_BEFORE_CART = 2
|
||||
|
||||
# На столько секунд визит длиннее своих страниц: торговое событие встаёт
|
||||
# позже страницы, на которую село, и последним событием визита бывает
|
||||
# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит
|
||||
# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние
|
||||
# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы.
|
||||
TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1]
|
||||
|
||||
_VISIT_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)
|
||||
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
|
||||
_SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES])
|
||||
@@ -72,7 +78,7 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Day:
|
||||
"""Поток событий одного дня: колонки выгрузки и шов для торговых событий.
|
||||
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
|
||||
|
||||
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
||||
`columns` — колонки контракта схемы по его порядку, все до одной;
|
||||
@@ -124,9 +130,15 @@ def stream(seed: int, day: int) -> Day:
|
||||
|
||||
second = np.repeat(start, visits.pages) + elapsed
|
||||
# Граница суток режет визит: хвост за полночью в этот день не попадает.
|
||||
alive = second < DAY_SECONDS
|
||||
visit_id = np.repeat(_unique_ids(rng, len(visits)), visits.pages)
|
||||
watch_id = _unique_ids(rng, int(alive.sum()))
|
||||
# Странице подтверждения нужно место и под её покупку: подтверждение без
|
||||
# покупки было бы потерей на клиентской стороне, а она здесь честная —
|
||||
# потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому
|
||||
# полночь забирает подтверждение вместе с торговым хвостом или не
|
||||
# забирает ни того, ни другого.
|
||||
room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0)
|
||||
alive = second + room < DAY_SECONDS
|
||||
visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages)
|
||||
watch_id = ids.unique(rng, int(alive.sum()))
|
||||
|
||||
rest = _columns(rng, day, audience, visits, page, product, second)
|
||||
columns = {
|
||||
@@ -136,12 +148,16 @@ def stream(seed: int, day: int) -> Day:
|
||||
}
|
||||
|
||||
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
||||
return Day(
|
||||
day=day,
|
||||
columns={name: value[order] for name, value in columns.items()},
|
||||
page=page[alive][order],
|
||||
product=product[alive][order],
|
||||
# Торговые события садятся на готовый трафиковый поток и отдают его
|
||||
# целиком: в нём же они и упорядочиваются.
|
||||
columns, page, product = commerce.weave(
|
||||
seed,
|
||||
day,
|
||||
{name: value[order] for name, value in columns.items()},
|
||||
page[alive][order],
|
||||
product[alive][order],
|
||||
)
|
||||
return Day(day=day, columns=columns, page=page, product=product)
|
||||
|
||||
|
||||
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||
@@ -161,7 +177,7 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||
# Обещанный планом заказ достаётся первому визиту дня: слева от него
|
||||
# соседей нет, поэтому двигать его внутри суток можно свободно.
|
||||
ordering = audience.assigned_order[cookie] & (ordinal == 0)
|
||||
stage = _funnel(rng, visits, ordering)
|
||||
stage = _funnel(rng, audience.buyer[cookie], ordering)
|
||||
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
|
||||
browse = np.where(
|
||||
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
|
||||
@@ -181,17 +197,32 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||
|
||||
|
||||
def _funnel(
|
||||
rng: np.random.Generator, visits: int, ordering: NDArray[np.bool_]
|
||||
rng: np.random.Generator,
|
||||
buyer: NDArray[np.bool_],
|
||||
ordering: NDArray[np.bool_],
|
||||
) -> NDArray[np.int64]:
|
||||
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения."""
|
||||
draw = rng.integers(0, 100, (3, visits))
|
||||
cart = draw[0] < world.CART_PERCENT
|
||||
checkout = cart & (draw[1] < world.CHECKOUT_OF_CART_PERCENT)
|
||||
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
|
||||
|
||||
Помеченный планом покупатель отличается на обоих шагах: и до корзины
|
||||
доходит чаще, и бросает её реже. Склонность покупать — свойство
|
||||
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
|
||||
метку плана словом без следа в данных (спека генератора, раздел 9).
|
||||
"""
|
||||
draw = rng.integers(0, 100, (3, buyer.size))
|
||||
cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT)
|
||||
checkout = cart & (
|
||||
draw[1]
|
||||
< np.where(
|
||||
buyer,
|
||||
world.BUYER_CHECKOUT_OF_CART_PERCENT,
|
||||
world.CHECKOUT_OF_CART_PERCENT,
|
||||
)
|
||||
)
|
||||
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
|
||||
|
||||
stage = cart.astype(np.int64) + checkout + confirmation
|
||||
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
|
||||
# на том, что событие покупки в этот день случится (#40 его и повесит).
|
||||
# на том, что событие покупки в этот день случится: его повесит `commerce`.
|
||||
stage[ordering] = len(reference.FUNNEL_PAGES)
|
||||
return stage
|
||||
|
||||
@@ -219,7 +250,7 @@ def _walk(
|
||||
page[row] = table[step[row]]
|
||||
stage = int(visits.stage[visit])
|
||||
if stage:
|
||||
# В корзину — только с карточки: иначе #40 положит туда товар,
|
||||
# В корзину — только с карточки: иначе в ней окажется товар,
|
||||
# которого посетитель не открывал.
|
||||
page[begin + browsed - 1] = Page.PRODUCT
|
||||
page[begin + browsed : begin + browsed + stage] = funnel[:stage]
|
||||
@@ -266,19 +297,28 @@ def _starts(
|
||||
"""
|
||||
hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie])
|
||||
start = hour * 3600 + rng.integers(0, 3600, hour.size)
|
||||
# Визит с обещанным заказом обязан уместиться в сутки целиком.
|
||||
fits = np.minimum(start, DAY_SECONDS - duration - 1)
|
||||
# Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с
|
||||
# торговым хвостом: событие покупки встаёт на секунды позже страницы
|
||||
# подтверждения, и зажимать его к последней секунде значило бы ломать
|
||||
# правило ровно там, ради чего оно написано.
|
||||
fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 1)
|
||||
start = np.where(visits.ordering, fits, start)
|
||||
|
||||
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
|
||||
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
|
||||
# Разводятся они от последнего события визита, а им бывает покупка:
|
||||
# считать от последней страницы значило бы отдать таймауту торговый хвост.
|
||||
start = start[np.lexsort((start, visits.cookie))]
|
||||
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
|
||||
later = np.flatnonzero(visits.ordinal == repeat)
|
||||
earlier = later - 1
|
||||
start[later] = np.maximum(
|
||||
start[later],
|
||||
start[earlier] + duration[earlier] + world.VISIT_TIMEOUT_SECONDS + 1,
|
||||
start[earlier]
|
||||
+ duration[earlier]
|
||||
+ TRADE_TAIL_SECONDS
|
||||
+ world.VISIT_TIMEOUT_SECONDS
|
||||
+ 1,
|
||||
)
|
||||
return start
|
||||
|
||||
@@ -370,7 +410,8 @@ def _columns(
|
||||
"RegionCity": by_city("name"),
|
||||
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
|
||||
"RegionCityID": by_city("region_id", np.uint32),
|
||||
# Цели дублируют торговые события, поэтому их ставит #40.
|
||||
# Цели дублируют торговые события, поэтому их ставит `commerce`:
|
||||
# у просмотра страницы достигнутых целей нет.
|
||||
"GoalsReached": _blank(total, "uint32"),
|
||||
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
|
||||
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
|
||||
@@ -449,21 +490,6 @@ def _ip_addresses(
|
||||
)
|
||||
|
||||
|
||||
def _unique_ids(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
|
||||
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
|
||||
|
||||
Уникальность обещана не для красоты: `WatchID` — ключ дедупликации при
|
||||
переигровке дня (спека генератора, раздел 4), и два одинаковых id
|
||||
склеили бы разные события. Поэтому не броски наугад, а шаги случайной
|
||||
длины — они не повторяются по построению, — и потом перемешивание, чтобы
|
||||
номер не выдавал порядок строк. Средний шаг — весь диапазон, делённый на
|
||||
число событий, поэтому в среднем ряд занимает его половину.
|
||||
"""
|
||||
step = ID_LIMIT // (size + 1)
|
||||
ids = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
|
||||
return ids[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
|
||||
|
||||
|
||||
def _hour_cumulative(day: int) -> NDArray[np.int64]:
|
||||
"""Веса часов суток по городам, накопленные: строка города — его волна."""
|
||||
# Суббота и воскресенье — последние два дня недели, а D0 — понедельник.
|
||||
|
||||
Reference in new issue
Block a user