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:
2026-08-02 19:13:30 +03:00
co-authored by Claude Opus 5
parent bfbaa96696
commit 722dbe22b7
19 changed files with 1591 additions and 210 deletions
@@ -12,6 +12,10 @@
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
price`; артикул — четыре латинские буквы категории, дефис и четыре цифры;
цена — целые копейки (деньги генератор считает целыми, спека, раздел 2).
Часть цен кратна рублю, часть несёт копейки — как в обычной рознице
(1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: округлять
нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы
выдумывать (спека генератора, раздел 9).
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
считают, а товар для карточки выбирается равномерно внутри категории.
Популярность товаров не моделируется — придумывать вес каждой строке
@@ -0,0 +1,500 @@
"""Торговые события: корзина и заказ на потоке дня.
Здесь торговая половина мира. Событие корзины садится на карточку товара —
в жизни его шлёт кнопка на карточке, а не открытие страницы корзины; при
нескольких товарах в заказе иначе его и не разложить: событий столько, со
скольких карточек положили. Событие покупки садится на страницу
подтверждения. Оба встают на несколько секунд позже своей страницы, а
граница модельных суток режет всё, что за неё вышло: визит, у которого
подтверждение срезано полуночью, покупки не даёт — заказа не было.
**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые
открывал в этом визите; карточка, открытая дважды, даёт одну позицию —
повторный просмотр это раздумье, а не второй товар. Покупает он не всё:
часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы
ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы
нечего.
**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся
целые рубли, как у Метрики, а дробное число в событии одно —
`purchaseRevenue`. Часть цен каталога несёт копейки, поэтому округление
видно: выручку по разобранным массивам не пересчитать, точная сумма живёт в
`purchaseRevenue` и в сыром `ecommerce`. Разрыв внутри одного события — это
настоящий урок формата, а не придуманный.
**Промокод в событии есть, скидки в сумме нет.** Выручка, которую шлёт
клиент, — сумма позиций без скидки и доставки: код на сайте знает корзину, а
не итог расчёта. Скидку по коду насчитывает бэкенд (этап 3), и таблица «код
→ скидка» лежит в числах мира, чтобы обе стороны брали одну.
**Номер заказа читаемый** — день модельного времени и порядковый номер
покупки в этом дне (`20260603-0042`). Он же `order_id` бэкенда: по нему
соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки,
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
присваивается последним ходом, когда поток уже упорядочен.
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
посточно собираются только строки — их numpy не умеет.
"""
from dataclasses import dataclass
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import catalog, ids, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick
# Таксономия: тип события и действие с товаром. Полный словарь торговых
# событий Метрики (detail, remove, impressions) стенд не берёт.
ADD_TO_CART = "add_to_cart"
PURCHASE = "purchase"
ADD_ACTION = "add"
PURCHASE_ACTION = "purchase"
# Копеек в рубле: внутри генератора деньги целые, в колонках — рубли.
KOPECKS = 100
_QUANTITY_CUMULATIVE = np.cumsum(world.ITEM_QUANTITY_WEIGHTS)
_PRODUCT_COLUMNS = (
"productID",
"productName",
"productCategory",
"productPrice",
"productQuantity",
"productEventType",
)
@dataclass(frozen=True, slots=True)
class _Baskets:
"""Корзины дня: позиции по корзинам и строка подтверждения заказа.
Позиция — товар, положенный в корзину: `anchor` — строка карточки, на
которую сядет событие, `product` — номер товара в каталоге. Позиции
лежат подряд по корзинам, внутри корзины — по времени; `basket` говорит,
чья позиция, а `first` и `count` — где чей кусок. `confirmation`
отвечает на вопрос, дошла ли корзина до заказа: строка подтверждения
или −1 у брошенной.
"""
anchor: NDArray[np.int64]
product: NDArray[np.int64]
basket: NDArray[np.int64]
first: NDArray[np.int64]
count: NDArray[np.int64]
confirmation: NDArray[np.int64]
def __len__(self) -> int:
return self.confirmation.size
def positions_of(self, basket: int, kept: NDArray[np.bool_]) -> NDArray[np.int64]:
"""Позиции корзины, у которых стоит отметка: например, купленные."""
here = slice(self.first[basket], self.first[basket] + self.count[basket])
return self.first[basket] + np.flatnonzero(kept[here])
@dataclass(frozen=True, slots=True)
class _Draws:
"""Броски торгового подпотока: случайное решается один раз и разом.
По позициям корзин — сколько штук берут, какой это вариант товара,
дошла ли позиция до заказа и на сколько секунд событие корзины отстало
от карточки. По корзинам — промокод и задержка события покупки.
"""
quantity: NDArray[np.int64]
variant: NDArray[np.int64]
kept: NDArray[np.bool_]
cart_delay: NDArray[np.int64]
coupon: NDArray[np.int64]
order_delay: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
class _Events:
"""Строки одного вида торговых событий, готовые встать в поток.
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
появится, когда поток будет упорядочен, а строка станет байтами один
раз, каноническим сериализатором.
"""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
raw: list[dict[str, Any]]
def weave(
seed: int,
day: int,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
Строки приходят упорядоченными по времени и такими же уходят: торговые
события встают между ними, и поток пересобирается одним порядком.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(page, product, columns["VisitID"])
if not len(baskets):
return columns, page, product
goods = catalog.catalog()
draws = _draws(rng, baskets)
events = (
_cart_events(columns, baskets, goods, draws),
_order_events(columns, baskets, goods, draws),
)
return _stream(rng, columns, page, product, events)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
"""Всё случайное в торговых событиях — целыми числами и векторно."""
positions = baskets.product.size
return _Draws(
quantity=1 + pick(rng, _QUANTITY_CUMULATIVE, positions),
variant=rng.integers(0, len(reference.PRODUCT_VARIANTS), positions),
kept=_kept(rng, baskets),
cart_delay=rng.integers(*world.TRADE_DELAY_SECONDS, positions),
coupon=_coupons(rng, len(baskets)),
order_delay=rng.integers(*world.TRADE_DELAY_SECONDS, len(baskets)),
)
def _baskets(
page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64]
) -> _Baskets:
"""Что посетитель положил в корзину и дошёл ли до заказа.
Корзина есть у визита, дошедшего до страницы корзины; в ней товары всех
карточек этого визита. Карточка, открытая дважды, даёт одну позицию —
остаётся первая: тогда товар и кладут.
"""
order = np.argsort(visit, kind="stable")
page, product, visit = page[order], product[order], visit[order]
# Строки визита лежат подряд, внутри визита — по времени: поток пришёл
# упорядоченным, а сортировка по визиту устойчивая.
started = np.concatenate(([True], visit[1:] != visit[:-1]))
number = np.cumsum(started) - 1
with_cart = number[page == Page.CART]
basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64)
basket_of_visit[with_cart] = np.arange(with_cart.size)
cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0))
# Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений,
# поэтому вторая карточка того же товара позиции не добавляет.
key = number[cards] * catalog.catalog().sku.size + product[cards]
positions = np.sort(cards[np.unique(key, return_index=True)[1]])
# Подтверждение без корзины невозможно: полночь режет визит с хвоста, а
# корзина в нём раньше подтверждения — поэтому у каждого подтверждения
# корзина есть, и номер её всегда найдётся.
confirmed = np.flatnonzero(page == Page.CONFIRMATION)
confirmation = np.full(with_cart.size, -1, dtype=np.int64)
confirmation[basket_of_visit[number[confirmed]]] = order[confirmed]
basket = basket_of_visit[number[positions]]
count = np.bincount(basket, minlength=with_cart.size)
return _Baskets(
anchor=order[positions],
product=product[positions],
basket=basket,
# Позиции лежат подряд, корзины идут по порядку визитов — поэтому
# начало каждого куска находится накопленной суммой.
first=np.cumsum(count) - count,
count=count,
confirmation=confirmation,
)
def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]:
"""Какие позиции корзины дошли до заказа: часть остаётся брошенной.
Пустым заказ не бывает: если брошены все позиции, одна возвращается —
какая, решает свой бросок. Хотя бы одна позиция у корзины есть всегда:
перед корзиной визит обязательно открывал карточку.
"""
kept = (
rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT
)
rescued = baskets.first + rng.integers(0, baskets.count)
empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0
kept[rescued[empty]] = True
return kept
def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
"""Промокод корзины: номер строки в таблице кодов или −1, если кода нет."""
code = rng.integers(0, len(world.COUPONS), baskets)
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
def _cart_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `add_to_cart`: по одной на каждый положенный товар."""
positions = baskets.product.size
alone = [np.array([position]) for position in range(positions)]
rows = _on_page(columns, baskets.anchor, ADD_TO_CART, draws.cart_delay)
rows["GoalsReached"] = _same(
np.array([world.GOAL_CART_ID], dtype=np.uint32), positions
)
side, blocks = _product_side(goods, baskets, alone, draws, ADD_ACTION)
rows.update(side)
return _Events(
columns=rows,
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
product=baskets.product.copy(),
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
)
def _order_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
ordered = np.flatnonzero(baskets.confirmation >= 0)
bought = [baskets.positions_of(basket, draws.kept) for basket in ordered]
rows = _on_page(
columns, baskets.confirmation[ordered], PURCHASE, draws.order_delay[ordered]
)
rows["GoalsReached"] = _same(
np.array([world.GOAL_PURCHASE_ID], dtype=np.uint32), ordered.size
)
side, blocks = _product_side(goods, baskets, bought, draws, PURCHASE_ACTION)
rows.update(side)
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
kopecks = [
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
for group in bought
]
codes = [
world.COUPONS[number][0] if number >= 0 else ""
for number in draws.coupon[ordered].tolist()
]
rows["purchaseRevenue"] = _cells(
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
)
rows["purchaseCurrency"] = _same(
np.array([world.CURRENCY], dtype=object), ordered.size
)
rows["purchaseCoupon"] = _cells([np.array([code], dtype=object) for code in codes])
raw = [
{
"currencyCode": world.CURRENCY,
PURCHASE_ACTION: {"actionField": _action_field(money, code), **block},
}
for money, code, block in zip(kopecks, codes, blocks, strict=True)
]
return _Events(
columns=rows,
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
product=np.full(ordered.size, -1, dtype=np.int64),
raw=raw,
)
def _action_field(kopecks: int, code: str) -> dict[str, Any]:
"""Блок `actionField` заказа: номер, выручка и купон, если он был.
Номер пустой до сборки потока — его присваивает `_seal`, когда порядок
событий дня уже известен.
"""
field: dict[str, Any] = {"id": "", "revenue": kopecks / KOPECKS}
if code:
field["coupon"] = code
return field
def _product_side(
goods: catalog.Catalog,
baskets: _Baskets,
groups: list[NDArray[np.int64]],
draws: _Draws,
action: str,
) -> tuple[dict[str, NDArray[Any]], list[dict[str, Any]]]:
"""Массивы `product*` и товарная часть сырого JSON — одним проходом.
Массивы группы `product*` одной длины между собой: по элементу на товар.
С группой `purchase*` они не совпадают и не должны — там по элементу на
заказ (мастер-спека, раздел 1).
Сырой JSON несёт больше, чем колонки: бренд, вариант товара и точную
цену с копейками. На этом и стоит лаба «сырое против разобранного» —
иначе в сыром лежало бы ровно то же самое.
"""
columns: dict[str, list[NDArray[Any]]] = {name: [] for name in _PRODUCT_COLUMNS}
blocks: list[dict[str, Any]] = []
for group in groups:
numbers = baskets.product[group]
pieces = draws.quantity[group]
columns["productID"].append(np.array(goods.sku[numbers], dtype=object))
columns["productName"].append(np.array(goods.name[numbers], dtype=object))
columns["productCategory"].append(
np.array([_category(goods, number) for number in numbers], dtype=object)
)
# Цена в колонке — целые рубли, как у Метрики: это округление и есть
# тот разрыв, из-за которого выручку по массивам не пересобрать.
columns["productPrice"].append((goods.price[numbers] + KOPECKS // 2) // KOPECKS)
columns["productQuantity"].append(pieces.astype(np.uint64))
columns["productEventType"].append(np.full(group.size, action, dtype=object))
blocks.append(
{
"products": [
{
"id": goods.sku[number],
"name": goods.name[number],
"category": _category(goods, number),
"brand": goods.brand[number],
"variant": reference.PRODUCT_VARIANTS[draws.variant[position]],
"price": int(goods.price[number]) / KOPECKS,
"quantity": int(pieces[place]),
}
for place, (position, number) in enumerate(
zip(group.tolist(), numbers.tolist(), strict=True)
)
]
}
)
return {name: _cells(values) for name, values in columns.items()}, blocks
def _category(goods: catalog.Catalog, number: int) -> str:
"""Имя категории товара — то же, что в файле каталога и в словаре."""
return catalog.CATEGORIES[goods.category[number]].name
def _on_page(
columns: dict[str, NDArray[Any]],
anchor: NDArray[np.int64],
event_type: str,
delay: NDArray[np.int64],
) -> dict[str, NDArray[Any]]:
"""Событие на странице: её колонки целиком, свой тип и своё время.
Страница у торгового события та же, что у просмотра, на который оно
село: тот же адрес и реферер, та же кука, тот же визит, устройство и
гео. Различаются тип, время и торговые колонки — их кладёт вызывающий.
"""
rows = {name: value[anchor] for name, value in columns.items()}
rows["EventType"] = np.full(anchor.size, event_type, dtype=object)
rows["UTCEventTime"] = columns["UTCEventTime"][anchor] + delay.astype(
"timedelta64[s]"
)
return rows
def _stream(
rng: np.random.Generator,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
events: tuple[_Events, ...],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
traffic = page.size
trade = {
name: np.concatenate([part.columns[name] for part in events])
for name in columns
}
raw = [block for part in events for block in part.raw]
alive = trade["UTCEventTime"] < _midnight(columns) + np.timedelta64(1, "D")
trade = {name: value[alive] for name, value in trade.items()}
# Номера торговых строк — из торгового подпотока: возьми их день у
# трафика, и правка торгового поведения сдвинула бы трафиковые `WatchID`.
trade["WatchID"] = ids.unique_apart_from(rng, int(alive.sum()), columns["WatchID"])
raw = [block for block, here in zip(raw, alive.tolist(), strict=True) if here]
rows = {
name: np.concatenate((value, trade[name])) for name, value in columns.items()
}
page = np.concatenate((page, np.concatenate([part.page for part in events])[alive]))
product = np.concatenate(
(product, np.concatenate([part.product for part in events])[alive])
)
# Чей сырой блок в какой строке: у просмотра страницы блока нет.
place = np.full(traffic + len(raw), -1, dtype=np.int64)
place[traffic:] = np.arange(len(raw))
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
rows = {name: value[order] for name, value in rows.items()}
_seal(rows, raw, place[order], _order_prefix(columns))
return rows, page[order], product[order]
def _seal(
rows: dict[str, NDArray[Any]],
raw: list[dict[str, Any]],
place: NDArray[np.int64],
prefix: str,
) -> None:
"""Раздаёт номера заказов и собирает сырой `ecommerce`.
Номер получают все покупки, дошедшие до потока, в порядке событий — до
всяких потерь: этап 6 выбрасывает событие, когда номер уже присвоен,
иначе одна потеря перенумеровала бы чужие заказы и мост к бэкенду
разъехался бы. Строку собирает канонический сериализатор: руками это был
бы второй сериализатор со своим экранированием.
"""
number = 0
for row in np.flatnonzero(place >= 0).tolist():
block = raw[place[row]]
if rows["EventType"][row] == PURCHASE:
number += 1
code = f"{prefix}-{number:04d}"
block[PURCHASE_ACTION]["actionField"]["id"] = code
rows["purchaseID"][row] = np.array([code], dtype=object)
rows["ecommerce"][row] = orjson.dumps(block).decode()
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
date: np.datetime64 = columns["EventDate"][0]
return date.astype("datetime64[s]") - np.timedelta64(
world.COUNTER_TIMEZONE_MINUTES, "m"
)
def _order_prefix(columns: dict[str, NDArray[Any]]) -> str:
"""Первая половина номера заказа: день модельного времени, `20260603`."""
return str(columns["EventDate"][0]).replace("-", "")
def _cells(values: list[NDArray[Any]]) -> NDArray[np.object_]:
"""Колонка-массив: в каждой ячейке свой массив своего типа."""
column = np.empty(len(values), dtype=object)
for row, value in enumerate(values):
column[row] = value
return column
def _same(value: NDArray[Any], size: int) -> NDArray[np.object_]:
"""Колонка-массив, у которой во всех ячейках один и тот же массив."""
value.flags.writeable = False
column = np.empty(size, dtype=object)
column.fill(value)
return column
+84 -58
View File
@@ -1,9 +1,10 @@
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
строки; их колонки в pageview присутствуют, но пусты по смыслу «пусто»
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
гео. Торговые события садятся на этот же поток и добавляют к нему свои
строки их собирает `commerce` и им же поток заканчивается. У просмотра
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» всегда
пустой массив, пустая строка или 0, а не отсутствие ключа.
День чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1N. Держится это на подпотоке
@@ -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 — понедельник.
@@ -0,0 +1,47 @@
"""Идентификаторы событий: неповторяющиеся числа, которые переживут JSON.
Уникальность обещана не для красоты: `WatchID` ключ дедупликации при
переигровке дня (спека генератора, раздел 4), и два одинаковых номера
склеили бы разные события. Строк дня две породы трафиковые и торговые,
и рисует их разная случайность, поэтому обещание держится здесь, в одном
месте на весь генератор: у приёма одно определение, иначе дисциплина живёт
копиями и расходится с ними.
"""
import numpy as np
from numpy.typing import NDArray
# Потолок идентификаторов: выше 2^53 числа в JSON (jq, консоль браузера)
# округляются при разборе, и id перестаёт быть собой. Настоящая Метрика так
# не делает — её id длиннее (мастер-спека, раздел 1.1).
LIMIT = 2**53
def unique(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
Не броски наугад, а шаги случайной длины они не повторяются по
построению, и потом перемешивание, чтобы номер не выдавал порядок
строк. Средний шаг весь диапазон, делённый на число событий, поэтому в
среднем ряд занимает его половину.
"""
step = LIMIT // (size + 1)
numbers = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
return numbers[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
def unique_apart_from(
rng: np.random.Generator, size: int, taken: NDArray[np.uint64]
) -> NDArray[np.uint64]:
"""То же, но и с занятыми номерами ряд не пересекается.
Торговые события берут случайность из своего подпотока иначе правка
торгового поведения сдвинула бы трафик, а значит, про уже розданные
трафиковые номера их ряд ничего не знает. Совпадение двух рядов на
диапазоне в 2^53 невероятно, но обещание уникальности держит дедуп, и
проверить его дешевле, чем предположить.
"""
while True:
numbers = unique(rng, size)
if not np.intersect1d(numbers, taken).size:
return numbers
+33 -14
View File
@@ -9,7 +9,9 @@
Что план решает до генерации событий и чем связывает дни между собой:
- приток кто и когда впервые появился, и сколько раз вернётся;
- приток кто и когда впервые появился, и сколько раз вернётся; кука
помеченного покупателя живёт дольше прочих это один из двух рычагов
метки, второй лежит в воронке дня;
- двухкуковые пары какой человек завёл вторую куку и в какие дни
каждая из двух кук обязана оформить заказ;
- паспорт куки устройство и город: они у куки одни и те же во всех её
@@ -32,17 +34,14 @@ from functools import lru_cache
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import reference, world
from clickstream_generator import ids, reference, world
from clickstream_generator.seeds import cohort_stream
from clickstream_generator.weights import pick
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
CLIENT_ID_LIMIT = 2**53
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
# долю общего веса.
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS)
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
_DEVICE_CUMULATIVE = np.cumsum(
@@ -182,15 +181,19 @@ def cohort(seed: int, day: int) -> Cohort:
]
cookies = people + paired.size
client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64)
# Кука живёт числом ниже 2^53 — тот же потолок, что у номера события:
# выше JSON округляет при разборе. Граница не достигается.
client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64)
birth_day = np.full(cookies, day, dtype=np.int64)
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool)))
active_cookie, active_day = _active_days(
rng, birth_day, day + world.RETURN_TAIL_DAYS
rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS
)
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
pair_cookies, pair_order_days = _assign_orders(
@@ -204,8 +207,7 @@ def cohort(seed: int, day: int) -> Cohort:
people=people,
client_id=client_id,
birth_day=birth_day,
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
buyer=buyer_cookie,
active_cookie=active_cookie,
active_day=active_day,
pair_cookies=pair_cookies,
@@ -271,13 +273,30 @@ def _influx(rng: np.random.Generator, day: int) -> int:
def _active_days(
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
rng: np.random.Generator,
birth_day: NDArray[np.int64],
buyer: NDArray[np.bool_],
window_end: int,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
много реже и возвращается чаще. Это первый из двух рычагов метки
(второй воронка дня): метки в событии нет, поэтому «постоянный
покупатель» читается в данных только как кука, которая ходит неделями и
покупает не раз. Одним лифтом конверсии этого не добиться кука живёт
меньше двух визитов за снимок, и второй покупке негде случиться (спека
генератора, раздел 9).
"""
cookies = birth_day.size
returns = np.zeros(cookies, dtype=np.int64)
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
returning = rng.integers(0, 100, cookies) >= one_shot
for here, weights in (
(returning & ~buyer, _RETURN_COUNT_CUMULATIVE),
(returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE),
):
returns[here] = 1 + pick(rng, weights, int(here.sum()))
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
@@ -77,6 +77,19 @@ SEARCH_QUERIES = tuple(
)
# Вариант товара — цвет или исполнение карточки. Живёт только в сыром
# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из
# причин, по которым лаба «сырое против разобранного» вообще имеет смысл.
PRODUCT_VARIANTS = (
"стандарт",
"белый",
"серый",
"бежевый",
"синий",
"зелёный",
)
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
@@ -21,8 +21,10 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
пересобрать: `make docs`.
Одно событие одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок стороны источника. Хранилище принимает их как есть и
@@ -37,6 +39,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя цены каталога бывают с копейками.
Всего колонок: {count}."""
TABLE_HEADER = (
+62 -3
View File
@@ -82,6 +82,15 @@ BUYER_PERCENT = 5
# этом стоит лаба про склейку личности.
PAIRED_BUYER_PERCENT = 15
# Первый рычаг метки покупателя: помеченный дольше живёт и чаще возвращается.
# Одноразовым он бывает много реже прочих, а возвращается вдвое чаще —
# отсюда кука, которая ходит неделями. Без этого рычага «постоянный
# покупатель» в данных не читается: метки в событии нет и не будет
# (кликстрим анонимен), а кука живёт меньше двух визитов за снимок — второй
# покупке негде случиться (спека генератора, раздел 9).
BUYER_ONE_SHOT_PERCENT = 35
BUYER_RETURN_COUNT_WEIGHTS = (10, 11, 12, 12, 11, 10, 9, 7, 5, 4, 3, 2)
# --- Числа дня: суточная волна, визиты, воронка ---------------------------
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
@@ -111,7 +120,7 @@ VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
# и до ~50 тыс. добирают торговые события (#40).
# и до ~50 тыс. добирают торговые события.
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по
@@ -128,9 +137,59 @@ LONG_PAUSE_SECONDS = (300, 1500)
LONG_PAUSE_PERCENT = 12
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
# предыдущего. Произведение — конверсия визита в оформленный заказ: 2%
# предыдущего. Произведение — конверсия визита в оформленный заказ
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
# пар проходят воронку целиком независимо от этих долей.
CART_PERCENT = 8
CART_PERCENT = 6
CHECKOUT_OF_CART_PERCENT = 45
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
# Второй рычаг метки покупателя: помеченный отличается на обоих шагах
# воронки — и до корзины доходит чаще, и бросает её реже. В жизни
# различаются оба: кто пришёл смотреть, тот и кладёт реже, и до конца
# доводит реже; один шаг дал бы половину картины. Шаг подтверждения общий:
# оплата — про магазин, а не про склонность покупать.
BUYER_CART_PERCENT = 18
BUYER_CHECKOUT_OF_CART_PERCENT = 60
# --- Числа торговых событий: корзина, заказ, деньги ------------------------
# Торговое событие встаёт на несколько секунд позже своей страницы: корзина
# позже карточки, покупка позже подтверждения. Задержка короче самой
# короткой паузы между страницами — иначе событие корзины обогнало бы
# страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8)
# Доля позиций корзины, которые остались брошенными: заказ уже корзины.
# Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был
# бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает:
# если брошены все позиции, одна остаётся (спека генератора, раздел 9).
ABANDONED_POSITION_PERCENT = 15
# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна,
# изредка две-три — непродовольственная розница.
ITEM_QUANTITY_WEIGHTS = (85, 11, 4)
# Валюта магазина: один регион присутствия — одна валюта.
CURRENCY = "RUB"
# Доля заказов с промокодом.
COUPON_PERCENT = 20
# Промокоды: код и скидка в процентах. Таблица — число мира, а не выдумка
# бэкенда: этап 3 берёт её готовой и обязан дать заказу с кодом скидку,
# иначе данные соврут. В клиентскую выручку скидка не входит — код на сайте
# знает корзину, а не итог расчёта (спека генератора, раздел 9). Цифры в
# коде — те же проценты: скидка в рублях потребовала бы второго правила
# чтения таблицы, а вместе с ним и второго вида скидки на стороне бэкенда.
COUPONS = (
("VESNA10", 10),
("DOMASHNIY5", 5),
("PERVYY15", 15),
("UYUT7", 7),
)
# Цели счётчика: корзина и покупка. Цели дублируют торговые события — в бою
# так и бывает (мастер-спека, раздел 1.2).
GOAL_CART_ID = 42150001
GOAL_PURCHASE_ID = 42150002