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
+8 -2
View File
@@ -5,7 +5,8 @@
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
pageview. Торговые события и запуск снаружи — за следующими тикетами.
трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом
целая; заказы бэкенда и запуск снаружи — за следующими тикетами.
## Как это работает
@@ -53,7 +54,12 @@ D0 живёт предыстория, поэтому любой день соб
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который сядут торговые события.
который садятся торговые события.
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
Своя случайность, поэтому правка торговли трафик не двигает.
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
+4 -1
View File
@@ -6,7 +6,10 @@ readme = "README.md"
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
# держится на зафиксированных версиях (спека генератора, раздел 2).
requires-python = ">=3.14,<3.15"
dependencies = ["numpy>=2"]
dependencies = [
"numpy>=2",
"orjson>=3.11.9",
]
[dependency-groups]
dev = [
@@ -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 не трогает дни 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 — понедельник.
@@ -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
+12
View File
@@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
assert low <= int(row["price"]) <= high, row["sku"]
def test_part_of_the_prices_carry_kopecks():
"""Без копеек урок про Float64 беспредметен: округлять было бы нечего.
Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные
рублю, и с копейками тогда `productPrice` округляется форматом, а
`purchaseRevenue` несёт точную сумму.
"""
kopecks = [int(row["price"]) % 100 for row in rows()]
assert any(rest for rest in kopecks)
assert any(not rest for rest in kopecks)
def test_every_category_of_the_assortment_is_covered():
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
present = {row["category"] for row in rows()}
+488
View File
@@ -0,0 +1,488 @@
"""Торговые события: корзина шире заказа, деньги целые, номера читаемые.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ
уже корзины», «конверсия визита около 2%»), а не при каждой правке.
Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким
тестам, считаются один раз на модуль.
"""
import json
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, plan, schema, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: повторные покупки и обещания пар живут между днями."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]:
"""Строки одного типа события — все колонки разом."""
here = events.columns["EventType"] == kind
return {name: value[here] for name, value in events.columns.items()}
def cells(column: NDArray[Any]) -> list[list[Any]]:
return [cell.tolist() for cell in column]
def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]:
"""Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком."""
return [json.loads(text) for text in rows["ecommerce"]]
def by_visit(events: day.Day) -> NDArray[np.int64]:
"""Порядок строк «визит, потом время» — так их читает лаба сессий."""
return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"]))
def test_the_day_carries_both_trade_events(weekday: day.Day):
"""Таксономия ожила: в дне есть и корзина, и покупка."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
assert orders["WatchID"].size > 100
# Корзина шире заказа и по событиям: не всякая корзина доходит до кассы.
assert carts["WatchID"].size > 2 * orders["WatchID"].size
def confirmed_visits(events: day.Day) -> set[int]:
"""Визиты, чья страница подтверждения дожила до потока."""
pageview = events.columns["EventType"] == "pageview"
here = pageview & (events.page == Page.CONFIRMATION)
return set(events.columns["VisitID"][here].tolist())
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
monkeypatch: pytest.MonkeyPatch,
):
"""Полночь режет подтверждение и покупку вместе — или не режет ни одну.
Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на
клиентской стороне завелась бы потеря. Хвост торгового события здесь
растянут до часа: в каноническом мире он секунды, и такой визит
выпадает раз в годы, а правило обязано держаться при любом хвосте.
"""
monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600))
monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600)
events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought
assert confirmed_visits(events) == bought
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
"""Одно подтверждение — один заказ (мастер-спека, раздел 4)."""
orders = rows_of(weekday, commerce.PURCHASE)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in orders[name]} == {1}, name
assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY}
def test_the_cart_event_has_no_order_block(weekday: day.Day):
"""У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in carts[name]} == {0}, name
def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day):
"""Длина общая внутри группы `product*`; с `purchase*` она не совпадает."""
trade = weekday.columns["EventType"] != "pageview"
names = [
column.name for column in schema.COLUMNS if column.name.startswith("product")
]
assert len(names) == 6
sizes = np.array(
[[cell.size for cell in weekday.columns[name][trade]] for name in names]
)
assert np.all(sizes == sizes[0])
# Корзина — всегда один товар, заказ — от одного и больше.
carts = rows_of(weekday, commerce.ADD_TO_CART)
assert {cell.size for cell in carts["productID"]} == {1}
orders = rows_of(weekday, commerce.PURCHASE)
assert min(cell.size for cell in orders["productID"]) == 1
assert max(cell.size for cell in orders["productID"]) > 1
def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day):
"""Сырое и разобранное — одно событие, поле в поле.
Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба
«сырое против разобранного» учила бы неправде. Цена единственная пара,
где расхождение законно: в колонке целые рубли, в сыром точная сумма,
поэтому сверяется не равенство, а что колонка та же цена, округлённая
до рубля.
"""
for kind, action in (
(commerce.ADD_TO_CART, commerce.ADD_ACTION),
(commerce.PURCHASE, commerce.PURCHASE_ACTION),
):
rows = rows_of(weekday, kind)
assert rows["ecommerce"].size > 100
for number, block in enumerate(raw_of(rows)):
assert block["currencyCode"] == world.CURRENCY
products = block[action]["products"]
for field, column in (
("id", "productID"),
("name", "productName"),
("category", "productCategory"),
("quantity", "productQuantity"),
):
assert [item[field] for item in products] == (
rows[column][number].tolist()
), (kind, field)
assert rows["productEventType"][number].tolist() == [action] * len(products)
prices = zip(
(item["price"] for item in products),
rows["productPrice"][number].tolist(),
strict=True,
)
assert all(abs(exact - whole) <= 0.5 for exact, whole in prices)
orders = rows_of(weekday, commerce.PURCHASE)
for number, block in enumerate(raw_of(orders)):
field = block[commerce.PURCHASE_ACTION]["actionField"]
assert field["id"] == orders["purchaseID"][number][0]
assert field["revenue"] == orders["purchaseRevenue"][number][0]
def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day):
"""Иначе лаба «сырое против разобранного» разбирала бы то же самое."""
orders = rows_of(weekday, commerce.PURCHASE)
goods = catalog.catalog()
brands = set(goods.brand.tolist())
seen_brands: set[str] = set()
for block in raw_of(orders):
for item in block[commerce.PURCHASE_ACTION]["products"]:
assert item["brand"] in brands
assert item["variant"]
seen_brands.add(item["brand"])
assert len(seen_brands) > 1
assert not any(
column.name in ("productBrand", "productVariant") for column in schema.COLUMNS
)
def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day):
"""Деньги целые; дробное число в событии одно — выручка (спека, раздел 2)."""
floats = [
column.name for column in schema.COLUMNS if column.numpy_dtype == "float64"
]
assert floats == ["purchaseRevenue"]
goods = catalog.catalog()
orders = rows_of(weekday, commerce.PURCHASE)
sku = {article: number for number, article in enumerate(goods.sku.tolist())}
for number, revenue in enumerate(orders["purchaseRevenue"]):
assert revenue.dtype == np.float64
kopecks = sum(
int(goods.price[sku[article]]) * pieces
for article, pieces in zip(
orders["productID"][number].tolist(),
orders["productQuantity"][number].tolist(),
strict=True,
)
)
assert revenue[0] == kopecks / 100
assert orders["productPrice"][number].dtype == np.int64
def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day):
"""Часть цен несёт копейки, и выручку по массивам события не пересобрать.
Разрыв живёт внутри одного события: `productPrice` округлён форматом,
`purchaseRevenue` точна. Это и есть урок про Float64 не выдуманный.
"""
orders = rows_of(weekday, commerce.PURCHASE)
apart = 0
for number, revenue in enumerate(orders["purchaseRevenue"]):
by_arrays = float(
(orders["productPrice"][number] * orders["productQuantity"][number]).sum()
)
apart += by_arrays != revenue[0]
assert 0.1 < apart / orders["WatchID"].size < 0.9
def test_the_order_is_narrower_than_the_cart(weekday: day.Day):
"""Часть положенных позиций не куплена — иначе сравнивать было бы нечего."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
put: dict[int, set[str]] = {}
for visit, article in zip(
carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True
):
put.setdefault(visit, set()).add(article)
dropped = whole = 0
for visit, bought in zip(
orders["VisitID"].tolist(), cells(orders["productID"]), strict=True
):
assert bought, "пустых заказов не бывает"
assert set(bought) <= put[visit], "куплено то, чего не клали в корзину"
assert len(set(bought)) == len(bought), "позиция в заказе одна на товар"
dropped += len(put[visit]) - len(bought)
whole += len(put[visit])
assert 0.05 < dropped / whole < 0.30
def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day):
"""Товар в корзине, которого никто не открывал, — глупость в воронке."""
order = by_visit(weekday)
visit = weekday.columns["VisitID"][order]
kind = weekday.columns["EventType"][order]
product = weekday.product[order]
page = weekday.page[order]
shown: dict[int, set[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == "pageview" and page[number] == Page.PRODUCT:
shown.setdefault(this, set()).add(int(product[number]))
put: dict[int, list[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == commerce.ADD_TO_CART:
put.setdefault(this, []).append(int(product[number]))
assert put
for this, products in put.items():
assert set(products) <= shown[this]
# Карточка, открытая дважды, даёт одну позицию, а не две.
assert len(set(products)) == len(products)
def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.
Событие не просто стоит после какой-то страницы: у события корзины
предыдущая строка визита карточка именно этого товара, и товар в
массивах события тот же. Иначе в корзину попал бы товар, которого
посетитель не открывал, а событие село бы на чужую страницу.
"""
order = by_visit(weekday)
columns = {name: value[order] for name, value in weekday.columns.items()}
page, product = weekday.page[order], weekday.product[order]
kind = columns["EventType"]
goods = catalog.catalog()
trade = np.flatnonzero(kind != "pageview")
assert trade.size > 100
for row in trade.tolist():
assert columns["VisitID"][row] == columns["VisitID"][row - 1]
assert columns["URL"][row] == columns["URL"][row - 1]
assert columns["Referer"][row] == columns["Referer"][row - 1]
away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype(
"int64"
)
assert 0 < away < world.TRADE_DELAY_SECONDS[1]
if kind[row] == commerce.ADD_TO_CART:
assert page[row] == page[row - 1] == Page.PRODUCT
# Карточка предыдущей строки — карточка этого самого товара.
assert product[row] == product[row - 1] >= 0
assert columns["productID"][row].tolist() == [goods.sku[product[row]]]
# У страницы входа в адресе ещё метки перехода — путь до «?».
address = columns["URL"][row].split("?")[0]
assert address.endswith(f"/product/{goods.sku[product[row]]}")
else:
assert page[row] == page[row - 1] == Page.CONFIRMATION
assert kind[row - 1] == "pageview"
def test_the_goals_repeat_the_trade_events(weekday: day.Day):
"""Цели дублируют события — в бою так и бывает (мастер-спека, 1.2)."""
goals = {
kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]}
for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE)
}
assert goals["pageview"] == {()}
assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)}
assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)}
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
assert len(set(numbers)) == len(numbers)
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
weekday: day.Day,
):
"""Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций."""
orders = rows_of(weekday, commerce.PURCHASE)
known = {code for code, _ in world.COUPONS}
coupons = [cell[0] for cell in orders["purchaseCoupon"]]
assert set(coupons) <= known | {""}
with_coupon = [code for code in coupons if code]
assert 0.05 < len(with_coupon) / len(coupons) < 0.40
assert len(set(with_coupon)) > 1
# Скидка в клиентскую выручку не входит: сумма сходится с позициями и с
# тем, что уехало в сыром JSON.
for number, block in enumerate(raw_of(orders)):
action = block[commerce.PURCHASE_ACTION]["actionField"]
assert action["revenue"] == orders["purchaseRevenue"][number][0]
assert ("coupon" in action) == bool(coupons[number])
def test_editing_the_trade_flow_does_not_move_the_traffic(
monkeypatch: pytest.MonkeyPatch,
):
"""Иерархия подпотоков: у торговли своя случайность (спека, раздел 2)."""
ours = day.stream(CANONICAL_SEED, WEEKDAY)
plan.cohort.cache_clear()
monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40)
other = day.stream(CANONICAL_SEED, WEEKDAY)
def pageviews(events: day.Day) -> dict[str, list[Any]]:
rows = rows_of(events, "pageview")
return {
name: [
cell.tolist() if isinstance(cell, np.ndarray) else cell
for cell in value
]
for name, value in rows.items()
}
assert pageviews(other) == pageviews(ours)
assert [
cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"]
] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]]
def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой.
Неподвижности трафика для этого мало: возьми торговля ветвь трафика,
трафик всё равно не сдвинулся бы свои броски он сделал раньше и из
своего генератора. Разошлись бы только два подпотока, ставшие одним, а
в дереве зерна место каждого компонента и есть контракт (спека,
раздел 2). Поэтому проверяется адрес, по которому торговля пришла.
"""
asked: list[tuple[int, int, Component]] = []
honest = commerce.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
"""Гарантия плана: обе куки пары покупают в назначенные им дни.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
bought = [
set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week
]
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
for cookie, number in zip(cookies, days, strict=True):
assert int(cohort.client_id[cookie]) in bought[number]
assert pairs > 10
# Счётчик пар в плане и пары, реализованные в дне, — про одно и то же.
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set()
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist())
return cookies
def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
"""Метка покупателя перестала быть словом: у неё виден след в данных.
«Постоянный покупатель» читается только так как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
против 1,5% прочих втрое чаще при пороге «вдвое».
"""
purchases: dict[int, int] = {}
flagged: set[int] = set()
for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
purchases[cookie] = purchases.get(cookie, 0) + 1
def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
return again / len(cookies)
buyers = set(purchases)
by_levers = (buyers & flagged) - paired_cookies()
others = buyers - flagged
assert len(by_levers) > 100
assert len(others) > 100
assert repeat_share(by_levers) > 2 * repeat_share(others)
def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]):
"""Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия."""
counters = plan.counters(CANONICAL_SEED, WEEK)
assert all(6_000 <= size <= 8_000 for size in counters.audience)
for events in week:
visits = len(set(events.columns["VisitID"].tolist()))
orders = int((events.columns["EventType"] == commerce.PURCHASE).sum())
assert 40_000 < len(events) < 60_000
assert 0.015 < orders / visits < 0.035
+45 -19
View File
@@ -1,4 +1,4 @@
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
@@ -14,7 +14,16 @@ import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, day, plan, reference, schema, world
from clickstream_generator import (
catalog,
commerce,
day,
ids,
plan,
reference,
schema,
world,
)
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED
@@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
assert values.dtype == np.dtype(column.numpy_dtype), column.name
def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day):
def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day):
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
#40 добавит свои, и они должны попасть под тот же сторож.
Проверяются все торговые колонки, а не выбранные: у просмотра страницы
пусты они все до одной, иначе строгий приём хранилища не уживётся с
полями, пустыми по смыслу (мастер-спека, раздел 6).
"""
waiting = [
pageview = weekday.columns["EventType"] == "pageview"
assert pageview.mean() > 0.9
trade = [
column
for column in schema.COLUMNS
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
]
assert len(waiting) > 10
for column in waiting:
cells = weekday.columns[column.name][:1000]
assert len(trade) > 10
for column in trade:
cells = weekday.columns[column.name][pageview][:1000]
if column.clickhouse_type.startswith("Array("):
assert all(cell.size == 0 for cell in cells), column.name
else:
assert set(cells) == {""}, column.name
assert set(weekday.columns["EventType"].tolist()) == {"pageview"}
def test_the_taxonomy_is_three_event_types(weekday: day.Day):
"""`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1)."""
assert set(weekday.columns["EventType"].tolist()) == {
"pageview",
commerce.ADD_TO_CART,
commerce.PURCHASE,
}
def test_no_column_hides_a_hole(weekday: day.Day):
@@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day):
def test_identifiers_survive_json(weekday: day.Day):
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
for name in ("WatchID", "VisitID", "ClientID"):
assert weekday.columns[name].max() < day.ID_LIMIT
assert weekday.columns[name].max() < ids.LIMIT
assert weekday.columns[name].dtype == np.uint64
@@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
"""Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5)."""
assert 30_000 < len(weekday) < 70_000
visits = len(set(weekday.columns["VisitID"].tolist()))
assert 6_000 < visits < 14_000
@@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
visits = len(set(weekday.columns["VisitID"].tolist()))
ordered = int((weekday.page == Page.CONFIRMATION).sum())
ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum())
assert 0.01 < ordered / visits < 0.04
@@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
"""Шов для #40: товар в корзине посетитель до того открывал."""
"""Шов с торговлей: товар в корзине посетитель до того открывал."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
by_visit = weekday.page[order]
carts = np.flatnonzero(by_visit == Page.CART)
@@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
def test_the_referer_is_the_page_before(weekday: day.Day):
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
url = weekday.columns["URL"][order]
referer = weekday.columns["Referer"][order]
visit = weekday.columns["VisitID"][order]
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника.
Цепочка считается по просмотрам страниц: торговое событие не открывает
страницу, а живёт на уже открытой, и реферер у него тот же, что у неё.
"""
pageview = weekday.columns["EventType"] == "pageview"
columns = {name: value[pageview] for name, value in weekday.columns.items()}
order = np.lexsort((columns["UTCEventTime"], columns["VisitID"]))
url = columns["URL"][order]
referer = columns["Referer"][order]
visit = columns["VisitID"][order]
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
assert np.all(referer[inside] == url[inside - 1])
+41
View File
@@ -0,0 +1,41 @@
"""Идентификаторы событий: неповторяющиеся и переживающие JSON.
Обещание одно, но держит его дедупликация при переигровке дня: два
одинаковых `WatchID` склеили бы разные события (спека, раздел 4).
"""
import numpy as np
from clickstream_generator import ids
from clickstream_generator.seeds import CANONICAL_SEED
def stream(offset: int = 0) -> np.random.Generator:
return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset))
def test_ids_do_not_repeat_and_survive_json():
numbers = ids.unique(stream(), 10_000)
assert numbers.dtype == np.uint64
assert len(set(numbers.tolist())) == numbers.size
assert numbers.max() < ids.LIMIT
assert numbers.min() > 0
def test_ids_do_not_give_away_the_order_of_the_rows():
"""Иначе номер события рассказывал бы, каким по счёту оно родилось."""
numbers = ids.unique(stream(), 1_000)
assert not np.all(np.diff(numbers.astype(np.int64)) > 0)
def test_ids_of_another_stream_are_other_ids():
assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100))
def test_ids_can_be_drawn_beside_the_ones_already_taken():
"""Торговые строки берут номера из своего подпотока и не задевают чужие."""
taken = ids.unique(stream(), 5_000)
numbers = ids.unique_apart_from(stream(1), 500, taken)
assert len(set(numbers.tolist())) == numbers.size
assert not set(numbers.tolist()) & set(taken.tolist())
assert numbers.max() < ids.LIMIT
+55 -13
View File
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
return weighted / sum(weights)
def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float:
"""Сколько дней в среднем ходит кука: день рождения плюс возвраты."""
counts = tuple(range(1, len(return_weights) + 1))
returns = mean_by_weights(counts, return_weights)
return 1 + (100 - one_shot_percent) / 100 * returns
def test_origin_is_a_monday():
"""День недели считается как остаток номера дня — это верно от понедельника."""
assert world.ORIGIN.weekday() == 0
@@ -36,10 +43,7 @@ def test_returns_average_three_to_four():
def test_average_cookie_is_active_about_two_days():
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
assert 1.8 <= active_days <= 2.0
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
def test_return_delays_cover_the_whole_activity_window():
@@ -108,12 +112,50 @@ def test_pauses_stay_inside_the_visit_timeout():
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
def test_the_funnel_converts_about_two_percent_of_visits():
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
conversion = (
world.CART_PERCENT
* world.CHECKOUT_OF_CART_PERCENT
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
/ 100**2
)
assert 1.5 <= conversion <= 2.5
def test_the_trade_event_never_overtakes_the_next_page():
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
на которой посетитель нажал кнопку, и не выходит за полночь за ним в
том же визите всегда идёт страница, которая полночь пережила.
"""
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
def test_the_ordinary_visitor_converts_a_bit_below_the_world():
"""Воронка обычного посетителя: ~1,5% визитов в оформленный заказ.
До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели
и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не
итог: сам итог меряет день-функция на собранном дне
(`test_the_shop_stays_the_same_plausible_shop`).
"""
assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8
def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
"""Один шаг дал бы половину картины: кладут реже и бросают чаще оба."""
assert world.BUYER_CART_PERCENT > world.CART_PERCENT
assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT
lift = conversion(
world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT
) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT)
# Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали
# бы тогда около 40 заказов в день вместо 240 (спека, раздел 9).
assert 2 <= lift <= 6
def test_the_buyer_mark_makes_the_cookie_live_longer():
"""Второй рычаг метки: без долгой жизни второй покупке негде случиться."""
assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT
usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS)
buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS)
assert buyer > 2 * usual
# Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а
# помеченных всего 5% людей когорты.
assert buyer < world.RETURN_TAIL_DAYS / 10
+28 -1
View File
@@ -8,6 +8,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
{ name = "orjson" },
]
[package.dev-dependencies]
@@ -18,7 +19,10 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "numpy", specifier = ">=2" }]
requires-dist = [
{ name = "numpy", specifier = ">=2" },
{ name = "orjson", specifier = ">=3.11.9" },
]
[package.metadata.requires-dev]
dev = [
@@ -74,6 +78,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
]
[[package]]
name = "orjson"
version = "3.11.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
]
[[package]]
name = "packaging"
version = "26.2"