feat(generator): день-функция — трафик, визиты и просмотры страниц

Зачем: план состава отдаёт дневную аудиторию, но событий у мира ещё не было.
День-функция превращает аудиторию в поток просмотров — на нём стоят лабы про
сборку визитов и про витрины, а следующий этап вешает на него торговые события.

Что:
- `day.py` — день как чистая функция зерна и номера дня: суточная волна в
  местном времени посетителя, визиты по документированным правилам нарезки,
  все 47 колонок выгрузки; шов для торговых событий — ряды `page` и `product`;
- `reference.py` — справочники-литералы: профили устройств, города Поволжья с
  настоящими гео-id Яндекса, источники трафика, карта сайта;
- `catalog.py` и `data/catalog/products.csv` — каталог на 180 позиций, общий у
  генератора и будущего словаря ClickHouse;
- `weights.py` — выбор по целым весам, один на план и на день;
- паспорт куки (устройство и город) переехал в план состава; броски приписаны
  последними, поэтому измеренные числа канонического мира не сдвинулись;
- словарь: «визит» закреплён за сессией, одноимённое понятие плана стало
  «днём активности»; статьи в `CONTEXT.md`;
- решения по ходу — в спеку генератора, раздел 9; наполнение
  `ParsedParamsKey1` отложено тикетом #47.

Проверка: `make lint`, `make typecheck`, `make test` — 353 passed (было 297).
Счётчики плана после правки те же: приток 3827,64/день, дневная аудитория
6235–7124, 68 119 посетителей за 14 дней, 170 двухкуковых пар. День 0 —
45 810 событий за 0,6 с, снимок 14 дней — 5,9 с при пороге 30 с на день.
Две слепые линии ревью, десять находок, все закрыты и перепроверены.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Opus 5 committed 2026-08-02 16:12:17 +03:00
1 parent 937ba014b4
commit eb433ad023
18 files changed
+2073 -105

No files matched your search

+18 -6
View File
@@ -4,8 +4,8 @@
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
Пока здесь контракт схемы события и план состава мира — событий генератор ещё
не порождает.
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
pageview. Торговые события и запуск снаружи — за следующими тикетами.
## Что где лежит
@@ -14,16 +14,28 @@
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
подпотока берёт случайность. На ней держится весь детерминизм.
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
день D. Когорты, приток, двухкуковые пары и счётчики — до генерации
событий.
день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до
генерации событий.
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
на весь генератор, чтобы дисциплина целочисленной случайности не жила
копиями.
- `src/clickstream_generator/reference.py` — справочники: устройства,
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в
строке, которой принадлежит.
- `src/clickstream_generator/catalog.py` — каталог товаров из
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который сядут торговые события.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают.
- `tests/` — инварианты контракта, свежесть описания и обещания плана:
чистота от зерна, приток, гарантия двухкуковых пар.
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам.
## Команды
@@ -3,4 +3,8 @@
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
выгрузки. Из него выводятся сам генератор, его валидация и «описание
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
Мир собирается из четырёх слоёв: числа (`world`) и справочники
(`reference`) описывают его, план состава (`plan`) говорит, кто в нём есть в
день D, а день-функция (`day`) проживает этот день событиями.
"""
@@ -0,0 +1,93 @@
"""Каталог товаров: CSV репозитория, общий у генератора и словаря ClickHouse.
Файл `data/catalog/products.csv` один на всех (мастер-спека, раздел 3):
генератор берёт из него имена и цены карточек, а хранилище поднимает над тем
же файлом словарь. Расхождений нет по построению — в бою так же живёт
справочник, выданный источником.
Ассортимент — непродовольственная розница (спека генератора, раздел 9):
числа мира приняты под неё, продуктовая сеть требовала бы других — возврат
раз в неделю, корзина в двадцать позиций.
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
price`; артикул — четыре латинские буквы категории, дефис и четыре цифры;
цена — целые копейки (деньги генератор считает целыми, спека, раздел 2).
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
считают, а товар для карточки выбирается равномерно внутри категории.
Популярность товаров не моделируется — придумывать вес каждой строке
пришлось бы вручную, а каталог растёт механически.
"""
import csv
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import numpy as np
from numpy.typing import NDArray
# Путь от модуля к корню репозитория: генератор живёт в `generator/src/…`.
# Каталог не настраивается извне — он часть мира, а не запуска.
CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv"
COLUMNS = ("sku", "name", "category", "brand", "price")
@dataclass(frozen=True, slots=True)
class Category:
"""Категория ассортимента: имя в файле, буквы артикула, кусок адреса."""
name: str
prefix: str
slug: str
CATEGORIES = (
Category("Товары для дома", "HOME", "dlya-doma"),
Category("Текстиль", "TEXT", "tekstil"),
Category("Посуда", "POSU", "posuda"),
Category("Бытовая техника", "TECH", "tehnika"),
Category("Детские товары", "KIDS", "detskie"),
Category("Одежда и обувь", "WEAR", "odezhda"),
)
@dataclass(frozen=True, slots=True)
class Catalog:
"""Каталог, разложенный по массивам, плюс указатель на строки категории.
`grouped` — номера строк, сложенные по категориям подряд; `first` и
`count` говорят, где чей кусок. Так выбор товара внутри категории —
один целочисленный бросок, а порядок строк в файле ни на что не влияет.
"""
sku: NDArray[np.object_]
name: NDArray[np.object_]
category: NDArray[np.int64]
brand: NDArray[np.object_]
price: NDArray[np.int64]
grouped: NDArray[np.int64]
first: NDArray[np.int64]
count: NDArray[np.int64]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог из файла; читается один раз — он часть постоянного мира."""
with CATALOG_PATH.open(encoding="utf-8", newline="") as source:
rows = list(csv.DictReader(source))
index = {category.name: number for number, category in enumerate(CATEGORIES)}
category = np.array([index[row["category"]] for row in rows], dtype=np.int64)
grouped = np.argsort(category, kind="stable")
count = np.bincount(category, minlength=len(CATEGORIES))
return Catalog(
sku=np.array([row["sku"] for row in rows], dtype=object),
name=np.array([row["name"] for row in rows], dtype=object),
category=category,
brand=np.array([row["brand"] for row in rows], dtype=object),
price=np.array([int(row["price"]) for row in rows], dtype=np.int64),
grouped=grouped,
first=np.concatenate(([0], np.cumsum(count)[:-1])),
count=count,
)
+525
View File
@@ -0,0 +1,525 @@
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
строки; их колонки в pageview присутствуют, но пусты по смыслу — «пусто»
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
`Component.TRAFFIC` (спека генератора, раздел 2) и на плане состава, который
про горизонт ничего не знает. Случайность целочисленная и векторная: считать
посточно приходится ровно одно — цепочку страниц визита, где следующий шаг
зависит от предыдущего. Посточные проходы есть и кроме неё (адреса,
заголовки, IP), но там ничего не решается: numpy не умеет собирать строки, а
броски к тому времени уже сделаны — векторно и все разом.
Модельные сутки считаются в поясе счётчика, как в выгрузке Метрики:
`EventDate` — дата в поясе счётчика, `UTCEventTime` — абсолютная метка. У
ночных событий гостей из других поясов `toDate(UTCEventTime)` ≠ `EventDate`;
сторона хранилища должна знать это заранее, иначе выведет дату сама и
разойдётся на несколько часов данных.
**Правила резки визитов** — те же, по которым лаба сессий собирает визиты
сама и сверяет сборку с `VisitID`:
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
всегда короче таймаута, а соседние визиты куки разведены дальше него.
3. Граница модельных суток режет визит: события за полночь в дне не живут.
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое
ограничение модели: обещание плана — гарантия, ради неё мы сужаем свободу
старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются
в последние минуты суток.
**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два
выровненных по строкам ряда: `page` — какая это страница магазина, и
`product` — какой товар показывала карточка (−1 у прочих страниц). По ним
#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение),
и то, что посетитель на самом деле смотрел: товар в корзине, которого никто
не открывал, — видимая глупость в воронке. Визит с назначенным заказом
всегда доходит до `/confirmation`, а перед корзиной у него всегда есть
карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE`
нетронут.
"""
from dataclasses import dataclass
from typing import Any
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, 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
_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])
@dataclass(frozen=True, slots=True)
class Day:
"""Поток событий одного дня: колонки выгрузки и шов для торговых событий.
Строки упорядочены по времени — так их и проиграет проигрыватель.
`columns` — колонки контракта схемы по его порядку, все до одной;
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
модуля).
"""
day: int
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
def __len__(self) -> int:
return self.page.size
@dataclass(frozen=True, slots=True)
class _Visits:
"""Визиты дня рядами: строка — визит, а его страницы лежат подряд.
Где именно лежат, говорят `first` и `pages`: с какой строки начинается
визит и сколько их у него. Всё остальное решено до того, как страницы
сложились в цепочку.
"""
cookie: NDArray[np.int64] # номер куки в дневной аудитории
ordinal: NDArray[np.int64] # какой это визит куки за день
ordering: NDArray[np.bool_] # визит с заказом, обещанным планом
source: NDArray[np.int64]
category: NDArray[np.int64]
stage: NDArray[np.int64] # докуда дошла воронка
browse: NDArray[np.int64] # страниц до воронки
pages: NDArray[np.int64]
first: NDArray[np.int64]
def __len__(self) -> int:
return self.cookie.size
def stream(seed: int, day: int) -> Day:
"""События дня `day` мира `seed`, упорядоченные по времени."""
audience = plan.audience(seed, day)
rng = day_stream(seed, day, Component.TRAFFIC)
visits = _visits(rng, audience)
page, product = _walk(rng, visits)
elapsed, duration = _elapsed(rng, visits)
start = _starts(rng, day, audience, visits, duration)
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()))
rest = _columns(rng, day, audience, visits, page, product, second)
columns = {
"WatchID": watch_id,
"VisitID": visit_id[alive],
**{name: value[alive] for name, value in rest.items()},
}
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],
)
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
"""Визиты дня: сколько их у каждой куки и что в каждом.
Всё, кроме цепочки страниц: откуда пришли, за какой категорией, докуда
дойдут по воронке и сколько страниц на это уйдёт.
"""
counts = 1 + pick(rng, _VISIT_COUNT_CUMULATIVE, audience.client_id.size)
cookie = np.repeat(np.arange(counts.size, dtype=np.int64), counts)
ordinal = np.arange(cookie.size) - np.repeat(np.cumsum(counts) - counts, counts)
visits = cookie.size
source = pick(rng, _SOURCE_CUMULATIVE, visits)
category = rng.integers(0, len(catalog.CATEGORIES), visits)
length = 1 + pick(rng, _VISIT_PAGES_CUMULATIVE, visits)
# Обещанный планом заказ достаётся первому визиту дня: слева от него
# соседей нет, поэтому двигать его внутри суток можно свободно.
ordering = audience.assigned_order[cookie] & (ordinal == 0)
stage = _funnel(rng, visits, ordering)
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
browse = np.where(
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
)
pages = browse + stage
return _Visits(
cookie=cookie,
ordinal=ordinal,
ordering=ordering,
source=source,
category=category,
stage=stage,
browse=browse,
pages=pages,
first=np.cumsum(pages) - pages,
)
def _funnel(
rng: np.random.Generator, visits: int, 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)
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
stage = cart.astype(np.int64) + checkout + confirmation
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
# на том, что событие покупки в этот день случится (#40 его и повесит).
stage[ordering] = len(reference.FUNNEL_PAGES)
return stage
def _walk(
rng: np.random.Generator, visits: _Visits
) -> tuple[NDArray[np.uint8], NDArray[np.int64]]:
"""Страницы каждого визита и товар их карточек.
Единственное место дня, где посточен сам расчёт: следующая страница
зависит от предыдущей, векторно такую цепочку не сложить. Броски
заготовлены заранее и целиком — в цикле остаётся ходить по таблицам.
"""
total = int(visits.pages.sum())
page = np.empty(total, dtype=np.uint8)
page[visits.first] = _ENTRY_PAGE[visits.source, rng.integers(0, 100, len(visits))]
step = rng.integers(0, 100, total)
funnel = np.array(reference.FUNNEL_PAGES, dtype=np.uint8)
for visit in range(len(visits)):
begin, browsed = int(visits.first[visit]), int(visits.browse[visit])
for row in range(begin + 1, begin + browsed):
previous = page[row - 1]
table = _NEXT_FROM_PRODUCT if previous == Page.PRODUCT else _NEXT_FROM_LIST
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]
goods = catalog.catalog()
shown = page == Page.PRODUCT
row_category = np.repeat(visits.category, visits.pages)[shown]
# Товар — равномерно внутри категории визита: популярность строк не
# моделируется, каталог дорастает механически (см. `catalog`).
inside = rng.integers(0, goods.count[row_category])
product = np.full(total, -1, dtype=np.int64)
product[shown] = goods.grouped[goods.first[row_category] + inside]
return page, product
def _elapsed(
rng: np.random.Generator, visits: _Visits
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Секунды каждой страницы от начала своего визита и длина визитов."""
total = int(visits.pages.sum())
short = rng.integers(*world.PAGE_PAUSE_SECONDS, total)
long = rng.integers(*world.LONG_PAUSE_SECONDS, total)
thinking = rng.integers(0, 100, total) < world.LONG_PAUSE_PERCENT
pause = np.where(thinking, long, short)
pause[visits.first] = 0
elapsed = np.cumsum(pause)
elapsed -= np.repeat(elapsed[visits.first], visits.pages)
return elapsed, elapsed[visits.first + visits.pages - 1]
def _starts(
rng: np.random.Generator,
day: int,
audience: plan.DayAudience,
visits: _Visits,
duration: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Секунда начала каждого визита внутри модельных суток.
Волна задана в местном времени посетителя, а сутки считаются в поясе
счётчика: гостю из другого пояса профиль поворачивается на разницу.
Пик от этого слегка размазывается — как в жизни.
"""
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)
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,
)
return start
def _columns(
rng: np.random.Generator,
day: int,
audience: plan.DayAudience,
visits: _Visits,
page: NDArray[np.uint8],
product: NDArray[np.int64],
second: NDArray[np.int64],
) -> dict[str, NDArray[Any]]:
"""Колонки выгрузки по строкам-страницам — все, что решил контракт схемы.
Торговые колонки заполнены пустотой своего типа: пустым массивом и
пустой строкой. Ключ есть всегда — потребитель не должен гадать, была
колонка или её забыли.
"""
total = page.size
sources = reference.TRAFFIC_SOURCES
source, pages, first = visits.source, visits.pages, visits.first
device = audience.device[visits.cookie]
city = audience.city[visits.cookie]
url, title = _addresses(rng, visits, page, product)
# Метки перехода живут и в адресе входа, как в жизни: разбор такого
# адреса — материал лабы, а колонки UTM рядом дают ей эталон.
url[first] = [
f"{address}{'&' if '?' in address else '?'}{query}" if query else address
for address, query in zip(url[first], _UTM_QUERY[source], strict=True)
]
referer = np.empty(total, dtype=object)
referer[1:], referer[0] = url[:-1], ""
referer[first] = _of(sources, "referer")[source]
def by_visit(values: NDArray[Any]) -> NDArray[Any]:
return np.repeat(values, pages)
def by_source(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(sources, field, dtype)[source])
def by_device(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(reference.DEVICE_PROFILES, field, dtype)[device])
def by_city(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(reference.CITIES, field, dtype)[city])
# Дата дня — в поясе счётчика, а полночь того же дня — метка UTC, от
# которой отсчитываются секунды: между ними ровно смещение пояса.
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(day, "D")
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day, "D")
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
yclid = np.where(
_of(sources, "has_yclid", bool)[source],
rng.integers(1, YCLID_LIMIT, source.size, dtype=np.uint64),
0,
).astype(np.uint64)
return {
"ClientID": by_visit(audience.client_id[visits.cookie]),
"CounterID": np.full(total, world.COUNTER_ID, dtype=np.uint32),
"EventDate": np.full(total, date, dtype="datetime64[D]"),
"UTCEventTime": midnight - away + second.astype("timedelta64[s]"),
"ClientTimeZone": by_city("timezone_minutes", np.int16),
"EventType": np.full(total, "pageview", dtype=object),
"Sign": np.ones(total, dtype=np.int8),
"URL": url,
"Referer": referer,
"Title": title,
"UTMSource": by_source("utm_source"),
"UTMMedium": by_source("utm_medium"),
"UTMCampaign": by_source("utm_campaign"),
"UTMContent": by_source("utm_content"),
"UTMTerm": by_source("utm_term"),
"LastTrafficSource": by_source("last_traffic_source"),
"HasGCLID": by_source("has_gclid", np.uint8),
"YCLID": by_visit(yclid),
"Browser": by_device("browser"),
"BrowserMajorVersion": by_device("browser_major_version", np.uint16),
"BrowserLanguage": by_device("language"),
"OperatingSystem": by_device("operating_system"),
"OperatingSystemRoot": by_device("operating_system_root"),
"DeviceCategory": by_device("category", np.uint8),
"MobilePhoneModel": by_device("phone_model"),
"ScreenWidth": by_device("screen_width", np.uint16),
"ScreenHeight": by_device("screen_height", np.uint16),
"IPAddress": by_visit(_ip_addresses(rng, city, device)),
"RegionCountry": np.full(total, reference.COUNTRY_NAME, dtype=object),
"RegionCity": by_city("name"),
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
"RegionCityID": by_city("region_id", np.uint32),
# Цели дублируют торговые события, поэтому их ставит #40.
"GoalsReached": _blank(total, "uint32"),
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
# колонку заполнит #47.
"ParsedParamsKey1": _blank(total, object),
"purchaseID": _blank(total, object),
"purchaseRevenue": _blank(total, "float64"),
"purchaseCurrency": _blank(total, object),
"purchaseCoupon": _blank(total, object),
"productID": _blank(total, object),
"productName": _blank(total, object),
"productCategory": _blank(total, object),
"productPrice": _blank(total, "int64"),
"productQuantity": _blank(total, "uint64"),
"productEventType": _blank(total, object),
"ecommerce": np.full(total, "", dtype=object),
}
def _addresses(
rng: np.random.Generator,
visits: _Visits,
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[NDArray[np.object_], NDArray[np.object_]]:
"""Адрес и заголовок каждой страницы; у входа в адресе живут метки UTM."""
total = page.size
goods = catalog.catalog()
row_category = np.repeat(visits.category, visits.pages)
query = rng.integers(0, len(reference.SEARCH_QUERIES), total)
static = rng.integers(0, len(reference.STATIC_PAGES), total)
url: list[str] = []
title: list[str] = []
for row in range(total):
kind = page[row]
if kind == Page.PRODUCT:
number = product[row]
path, heading = f"/product/{goods.sku[number]}", goods.name[number]
elif kind == Page.CATALOG:
group = catalog.CATEGORIES[row_category[row]]
path, heading = f"/catalog/{group.slug}", group.name
elif kind == Page.SEARCH:
text = reference.SEARCH_QUERIES[query[row]]
path, heading = f"/search?text={text}", "Поиск по магазину"
elif kind == Page.STATIC:
path, heading = reference.STATIC_PAGES[static[row]]
else:
path, heading = _FIXED_PAGES[kind]
url.append(reference.SITE_URL + path)
title.append(f"{heading} — {reference.SITE_NAME}")
return np.array(url, dtype=object), np.array(title, dtype=object)
def _ip_addresses(
rng: np.random.Generator, city: NDArray[np.int64], device: NDArray[np.int64]
) -> NDArray[np.object_]:
"""Адрес визита: городской ломоть, а у телефонов — CGNAT оператора."""
count = city.size
prefix = _of(reference.CITIES, "ip_prefix")[city]
category = _of(reference.DEVICE_PROFILES, "category", np.int64)[device]
phone = category == reference.PHONE_CATEGORY
operator = rng.integers(*reference.MOBILE_IP_SECOND_BYTE, count)
block = rng.integers(0, 256, count)
host = rng.integers(1, 255, count)
first_byte = reference.MOBILE_IP_FIRST_BYTE
return np.array(
[
f"{first_byte}.{operator[visit]}.{block[visit]}.{host[visit]}"
if phone[visit]
else f"{prefix[visit]}{host[visit]}"
for visit in range(count)
],
dtype=object,
)
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 — понедельник.
weekend = day % 7 >= 5
profile = np.array(
world.WEEKEND_HOURS_PERCENT if weekend else world.WEEKDAY_HOURS_PERCENT
)
shifts = [
(city.timezone_minutes - world.COUNTER_TIMEZONE_MINUTES) // 60
for city in reference.CITIES
]
return np.cumsum([np.roll(profile, -shift) for shift in shifts], axis=1)
def _of(table: tuple[Any, ...], field: str, dtype: Any = object) -> NDArray[Any]:
"""Колонка справочника массивом: строка выбирается своим номером."""
return np.array([getattr(row, field) for row in table], dtype=dtype)
def _blank(size: int, dtype: Any) -> NDArray[np.object_]:
"""Колонка-массив, пустая по смыслу: у каждой строки пустой массив."""
empty = np.array([], dtype=dtype)
empty.flags.writeable = False
column = np.empty(size, dtype=object)
column.fill(empty)
return column
def _hundred(percent: tuple[int, ...]) -> NDArray[np.uint8]:
"""Сотня ячеек «бросок 0…99 → страница»: выбор одним обращением."""
return np.repeat(np.array(reference.BROWSE_PAGES, dtype=np.uint8), percent)
def _utm_query(source: reference.TrafficSource) -> str:
"""Метки перехода строкой запроса; у источника без меток — пустая."""
marks = ((field, getattr(source, f"utm_{field}")) for field in _UTM_FIELDS)
return "&".join(f"utm_{field}={value}" for field, value in marks if value)
_UTM_FIELDS = ("source", "medium", "campaign", "content", "term")
_UTM_QUERY = np.array(
[_utm_query(source) for source in reference.TRAFFIC_SOURCES], dtype=object
)
_FIXED_PAGES = {
Page.HOME: ("/", "Интернет-магазин товаров для дома"),
Page.CART: ("/cart", "Корзина"),
Page.CHECKOUT: ("/checkout", "Оформление заказа"),
Page.CONFIRMATION: ("/confirmation", "Заказ оформлен"),
}
_ENTRY_PAGE = np.array(
[_hundred(source.entry_percent) for source in reference.TRAFFIC_SOURCES]
)
_NEXT_FROM_LIST = _hundred(reference.FROM_LISTING_PERCENT)
_NEXT_FROM_PRODUCT = _hundred(reference.FROM_PRODUCT_PERCENT)
# Потолок id клика Директа: тринадцать цифр, как у настоящих меток.
YCLID_LIMIT = 10**13
+118 -58
View File
@@ -12,8 +12,14 @@
- приток — кто и когда впервые появился, и сколько раз вернётся;
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
каждая из двух кук обязана оформить заказ;
- паспорт куки — устройство и город: они у куки одни и те же во всех её
днях, а знает об этом только план (у пары один город на двоих);
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
Единица дня здесь — день активности куки, а не визит: слово «визит»
закреплено за сессией и полем `VisitID`, и визитов у куки за день бывает
несколько. Сколько именно — решает день-функция, плану это безразлично.
Случайность тянется целыми числами: диапазоны и выбор по целым весам.
Плавающие распределения системной математики не зовутся — они расходятся
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
@@ -26,8 +32,9 @@ from functools import lru_cache
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import world
from clickstream_generator import reference, world
from clickstream_generator.seeds import cohort_stream
from clickstream_generator.weights import pick
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
@@ -37,18 +44,39 @@ CLIENT_ID_LIMIT = 2**53
# долю общего веса.
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.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(
[profile.weight for profile in reference.DEVICE_PROFILES]
)
# Профили, разложенные надвое — телефоны и всё остальное. Вторая кука пары
# берётся из другой половины: у человека это второе устройство, а не копия
# первого. Мастер-спека (раздел 5) называет пару «телефон и ноутбук»; у нас
# к телефону встаёт десктоп или планшет — вторым устройством бывает и он, а
# запрет на планшет не дал бы ничего, кроме зауженного справочника.
_IS_PHONE = np.array(
[
profile.category == reference.PHONE_CATEGORY
for profile in reference.DEVICE_PROFILES
]
)
_DEVICE_HALVES = (np.flatnonzero(~_IS_PHONE), np.flatnonzero(_IS_PHONE))
_DEVICE_HALF_CUMULATIVE = tuple(
np.cumsum([reference.DEVICE_PROFILES[number].weight for number in half])
for half in _DEVICE_HALVES
)
@dataclass(frozen=True, slots=True)
class Cohort:
"""Люди, впервые пришедшие в мир в день `day`, с их куками и визитами.
"""Люди, впервые пришедшие в мир в день `day`, с их куками и днями.
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
и есть человек целиком.
Визиты — плоская таблица «кука — день», отсортированная и без повторов:
на день у куки приходится не больше одного визита. Все дни лежат в окне
Дни активности — плоская таблица «кука — день», отсортированная и без
повторов: дважды за день кука не появляется. Все дни лежат в окне
активности человека: от `day` до `day` + хвост возвратов.
"""
@@ -57,11 +85,14 @@ class Cohort:
client_id: NDArray[np.uint64]
birth_day: NDArray[np.int64]
buyer: NDArray[np.bool_]
visit_cookie: NDArray[np.int64]
visit_day: NDArray[np.int64]
active_cookie: NDArray[np.int64]
active_day: NDArray[np.int64]
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
pair_cookies: NDArray[np.int64]
pair_order_days: NDArray[np.int64]
# Паспорт куки: номер профиля устройства и номер города в справочниках.
device: NDArray[np.int64]
city: NDArray[np.int64]
def __post_init__(self) -> None:
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
@@ -87,28 +118,36 @@ class Cohort:
"""Сколько пар получили назначенные заказы."""
return len(self.pair_cookies)
def visitors_on(
self, day: int
) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]:
"""Кто из когорты пришёл в день `day`: куки, покупатели, заказы пар.
def visitors_on(self, day: int) -> DayAudience:
"""Кто из когорты пришёл в день `day` — её доля дневной аудитории.
Как визиты и пары уложены в массивы, знает только когорта: снаружи
спрашивают день и получают три ряда одной длины.
Как дни активности и пары уложены в массивы, знает только когорта:
снаружи спрашивают день и получают готовые ряды одной длины.
"""
here = self.visit_cookie[self.visit_day == day]
here = self.active_cookie[self.active_day == day]
ordering = self.pair_cookies[self.pair_order_days == day]
return self.client_id[here], self.buyer[here], np.isin(here, ordering)
return DayAudience(
day=day,
client_id=self.client_id[here],
buyer=self.buyer[here],
assigned_order=np.isin(here, ordering),
device=self.device[here],
city=self.city[here],
)
@dataclass(frozen=True, slots=True)
class DayAudience:
"""Куки, пришедшие в день `day`, — вход для будущей дня-функции."""
"""Куки, пришедшие в день `day`, — вход дня-функции."""
day: int
client_id: NDArray[np.uint64]
buyer: NDArray[np.bool_]
# Куки, которым план назначил на этот день гарантированный заказ пары.
assigned_order: NDArray[np.bool_]
# Паспорт куки: номера строк в справочниках устройств и городов.
device: NDArray[np.int64]
city: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
@@ -148,16 +187,18 @@ def cohort(seed: int, day: int) -> Cohort:
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
birth_day[people:] += 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
visit_cookie, visit_day = _visits(rng, birth_day, day + world.RETURN_TAIL_DAYS)
pair_cookies, pair_order_days = _assign_orders(
rng,
np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))),
visit_cookie,
visit_day,
cookies,
active_cookie, active_day = _active_days(
rng, birth_day, day + world.RETURN_TAIL_DAYS
)
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
pair_cookies, pair_order_days = _assign_orders(
rng, twins, active_cookie, active_day, cookies
)
# Паспорт бросается последним — после всего, что уже измерено: тогда
# счётчики канонического мира от этой добавки не двигаются.
device, city = _passports(rng, twins, cookies)
return Cohort(
day=day,
people=people,
@@ -165,10 +206,12 @@ def cohort(seed: int, day: int) -> Cohort:
birth_day=birth_day,
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
visit_cookie=visit_cookie,
visit_day=visit_day,
active_cookie=active_cookie,
active_day=active_day,
pair_cookies=pair_cookies,
pair_order_days=pair_order_days,
device=device,
city=city,
)
@@ -177,18 +220,18 @@ def audience(seed: int, day: int) -> DayAudience:
if day < 0:
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
client_id, buyer, assigned = [], [], []
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
for born in range(day - world.RETURN_TAIL_DAYS, day + 1):
came, bought, ordered = cohort(seed, born).visitors_on(day)
client_id.append(came)
buyer.append(bought)
assigned.append(ordered)
parts = [
cohort(seed, born).visitors_on(day)
for born in range(day - world.RETURN_TAIL_DAYS, day + 1)
]
return DayAudience(
day=day,
client_id=np.concatenate(client_id),
buyer=np.concatenate(buyer),
assigned_order=np.concatenate(assigned),
client_id=np.concatenate([part.client_id for part in parts]),
buyer=np.concatenate([part.buyer for part in parts]),
assigned_order=np.concatenate([part.assigned_order for part in parts]),
device=np.concatenate([part.device for part in parts]),
city=np.concatenate([part.city for part in parts]),
)
@@ -202,9 +245,9 @@ def counters(seed: int, days: int) -> PlanCounters:
# Ниже — вся предыстория: её когорты ещё возвращаются в горизонт.
for born in range(-world.RETURN_TAIL_DAYS, days):
born_cohort = cohort(seed, born)
inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days)
daily += np.bincount(born_cohort.visit_day[inside], minlength=days)
seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])])
inside = (born_cohort.active_day >= 0) & (born_cohort.active_day < days)
daily += np.bincount(born_cohort.active_day[inside], minlength=days)
seen.append(born_cohort.client_id[np.unique(born_cohort.active_cookie[inside])])
appeared = born_cohort.birth_day[
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days)
]
@@ -227,17 +270,17 @@ def _influx(rng: np.random.Generator, day: int) -> int:
return base + int(rng.integers(-spread, spread + 1))
def _visits(
def _active_days(
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни визитов каждой куки: день рождения и возвраты, пока окно открыто."""
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
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()))
returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
delay = 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
when = np.concatenate((birth_day, birth_day[owner] + delay))
@@ -247,7 +290,7 @@ def _visits(
order = np.lexsort((when, cookie))
cookie, when = cookie[order], when[order]
# Два возврата в один день — один визит: день у куки бывает только один.
# Два возврата в один день — один день активности: он у куки бывает один.
first_of_day = np.ones(cookie.size, dtype=bool)
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
return cookie[first_of_day], when[first_of_day]
@@ -256,32 +299,49 @@ def _visits(
def _assign_orders(
rng: np.random.Generator,
pair_cookies: NDArray[np.int64],
visit_cookie: NDArray[np.int64],
visit_day: NDArray[np.int64],
active_cookie: NDArray[np.int64],
active_day: NDArray[np.int64],
cookies: int,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0.
"""Дни гарантированных заказов пары: по дню каждой из двух кук, от D0.
Человеку предыстории, у чьей куки визитов на оси не осталось, пара не
Человеку предыстории, у чьей куки дней на оси не осталось, пара не
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
берутся той же случайностью, что и всё остальное, — в данных условность
не видна.
"""
on_axis = visit_day >= 0
counts = np.bincount(visit_cookie[on_axis], minlength=cookies)
# Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут
# последними. Значит, дни на оси у куки — хвост её блока: от конца блока
# назад ровно `counts` визитов.
first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts
on_axis = active_day >= 0
counts = np.bincount(active_cookie[on_axis], minlength=cookies)
# Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0
# идут последними. Значит, дни на оси у куки — хвост её блока: от конца
# блока назад ровно `counts` дней.
first_on_axis = np.searchsorted(active_cookie, np.arange(cookies), "right") - counts
assigned = np.all(counts[pair_cookies] > 0, axis=1)
pairs = pair_cookies[assigned]
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
return pairs, visit_day[chosen]
return pairs, active_day[chosen]
def _pick(
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int
) -> NDArray[np.int64]:
"""Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел."""
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
def _passports(
rng: np.random.Generator, twins: NDArray[np.int64], cookies: int
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Устройство и город каждой куки; у пары город один, устройства разные.
Выводить паспорт арифметикой из `ClientID` было бы дешевле, но про
двухкуковые пары знает только план, а два города у одного человека —
ложь в данных (спека генератора, раздел 9).
"""
device = pick(rng, _DEVICE_CUMULATIVE, cookies)
city = pick(rng, _CITY_CUMULATIVE, cookies)
first, second = twins[:, 0], twins[:, 1]
city[second] = city[first]
# Половина справочника выбирается по первой куке, строка в ней — броском.
other_half = np.where(_IS_PHONE[device[first]], 0, 1)
for half in (0, 1):
here = second[other_half == half]
device[here] = _DEVICE_HALVES[half][
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
]
return device, city
@@ -0,0 +1,252 @@
"""Справочники мира: устройства, города, источники трафика, карта сайта.
Таблицы-литералы, а не посточный фейкер (спека генератора, разделы 6 и 9):
нам нужны не случайные строки, а связки — телефон тянет за собой Safari, iOS
и размер экрана; город тянет id региона и часовой пояс. Фейкер связок не
даёт, таблицу пришлось бы написать всё равно, а новая зависимость молча
меняла бы мир при обновлении своих словарей.
Веса долей живут в самих строках: доля неотделима от строки, которой она
принадлежит. Числа, ни к какой строке не привязанные — суточная волна, длина
визита, воронка, — лежат в `world.py`.
Настоящее здесь одно — гео-идентификаторы (оговорка в `City`). Магазин,
сайты-рефереры и адреса вымышлены: домен `example.com` отведён под примеры
RFC 2606, адреса нероутируемы.
Таблицы записаны руками (`fmt: off`): строка справочника — строка таблицы,
а не десять строк исходника.
"""
from dataclasses import dataclass
from enum import IntEnum
from urllib.parse import quote
# Магазин стенда: вымышленный, домен из зарезервированных под примеры.
SITE_NAME = "Дом и уют"
SITE_URL = "https://shop.example.com"
class Page(IntEnum):
"""Страницы магазина: пять для блуждания, три для воронки заказа."""
HOME = 0
CATALOG = 1
SEARCH = 2
PRODUCT = 3
STATIC = 4
CART = 5
CHECKOUT = 6
CONFIRMATION = 7
# Страницы блуждания: их порядок — порядок колонок в таблицах переходов ниже.
BROWSE_PAGES = (Page.HOME, Page.CATALOG, Page.SEARCH, Page.PRODUCT, Page.STATIC)
# Хвост воронки по порядку прохождения: сколько шагов пройдено — то и есть
# «докуда дошёл визит».
FUNNEL_PAGES = (Page.CART, Page.CHECKOUT, Page.CONFIRMATION)
# Куда посетитель уходит со страницы, проценты по колонкам `BROWSE_PAGES`.
# Правил всего два: с карточки товара идут смотреть соседние карточки и
# каталог, с любой листающей страницы — в карточку.
FROM_PRODUCT_PERCENT = (9, 30, 8, 45, 8)
FROM_LISTING_PERCENT = (6, 12, 6, 70, 6)
# Статические страницы: адрес и заголовок.
STATIC_PAGES = (
("/delivery", "Доставка и оплата"),
("/about", "О магазине"),
("/contacts", "Контакты"),
)
# Запросы к поиску по сайту. В адресе они лежат процентными кодами — так их
# отдаёт и выгрузка, и разбор такого адреса сам по себе материал лабы.
SEARCH_QUERIES = tuple(
quote(text)
for text in (
"полотенца",
"сковорода",
"постельное бельё",
"детский стульчик",
"электрочайник",
"шторы",
"набор кастрюль",
"тапочки",
)
)
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
PHONE_CATEGORY = 2
@dataclass(frozen=True, slots=True)
class DeviceProfile:
"""Устройство посетителя целиком: браузер, ОС, экран.
Это паспорт куки: кука — браузер на устройстве, поэтому во всех её
визитах профиль один и тот же (спека генератора, раздел 9).
"""
weight: int
category: int # коды Метрики, см. `PHONE_CATEGORY`
browser: str
browser_major_version: int
language: str
operating_system: str
operating_system_root: str
phone_model: str
screen_width: int
screen_height: int
# Доли устройств — правдоподобная российская розница: мобильных около двух
# третей, десктоп треть, планшеты тонкой полосой.
# fmt: off
DEVICE_PROFILES = (
DeviceProfile(14, 1, "Chrome", 131, "ru",
"Windows 10", "Windows", "", 1920, 1080),
DeviceProfile(6, 1, "Chrome", 131, "ru",
"Windows 11", "Windows", "", 1366, 768),
DeviceProfile(6, 1, "YandexBrowser", 24, "ru",
"Windows 10", "Windows", "", 1920, 1080),
DeviceProfile(3, 1, "Safari", 17, "ru",
"macOS 14", "macOS", "", 1440, 900),
DeviceProfile(2, 1, "Firefox", 133, "ru",
"Windows 10", "Windows", "", 1600, 900),
DeviceProfile(1, 1, "Chrome", 131, "en",
"Ubuntu 24.04", "Linux", "", 1920, 1080),
DeviceProfile(10, 2, "Safari", 17, "ru",
"iOS 17.4", "iOS", "iPhone 14", 390, 844),
DeviceProfile(6, 2, "Safari", 16, "ru",
"iOS 16.6", "iOS", "iPhone 12", 390, 844),
DeviceProfile(13, 2, "Chrome", 131, "ru",
"Android 14", "Android", "Galaxy A53", 412, 915),
DeviceProfile(12, 2, "Chrome", 130, "ru",
"Android 13", "Android", "Redmi Note 12", 393, 873),
DeviceProfile(9, 2, "YandexBrowser", 24, "ru",
"Android 13", "Android", "Honor X8", 360, 780),
DeviceProfile(6, 2, "Samsung Internet", 26, "ru",
"Android 14", "Android", "Galaxy S23", 360, 780),
DeviceProfile(5, 2, "Chrome", 131, "ru",
"Android 12", "Android", "Vivo Y21", 360, 800),
DeviceProfile(4, 3, "Safari", 17, "ru",
"iPadOS 17.4", "iOS", "", 810, 1080),
DeviceProfile(3, 3, "Chrome", 130, "ru",
"Android 13", "Android", "", 800, 1280),
)
# fmt: on
@dataclass(frozen=True, slots=True)
class City:
"""Город посетителя: id региона, часовой пояс, ломоть адресов.
Гео-идентификаторы — настоящие числа геобазы Яндекса, а не выдуманные:
каждый проверен 2026-08-02 обращением к живым сервисам Яндекса по этому
же номеру (`yandex.ru/pogoda/<id>`, `yandex.ru/maps/225/russia/`) —
страница открывает ожидаемое место. Оговорка честная: опубликованной
таблицы геобазы найти не удалось, а что `RegionCityID` Метрики нумерует
регионы той же геобазой — обоснованное допущение, а не подтверждённый
источником факт. Часовые пояса — из Википедии; часы в России не
переводят с 2014 года, поэтому смещение постоянное.
Адреса нероутируемые (спека генератора, раздел 9): городу отводится
ломоть документационных сетей RFC 5737 или benchmark-сети 198.18/15.
Правдоподобные публичные адреса принадлежат живым организациям, и в
учебных данных им не место.
"""
weight: int
name: str # по-английски, как в выгрузке Метрики
region_id: int
timezone_minutes: int
ip_prefix: str
COUNTRY_NAME = "RU"
COUNTRY_REGION_ID = 225
# Регион присутствия — Поволжье с центром в Самаре: свой миллионник, города
# своего региона и тонкий хвост остальной страны (спека генератора,
# раздел 9). «Топ городов России» дал бы магазину с одним складом карту,
# которой у него быть не может.
CITIES = (
City(34, "Samara", 51, 240, "192.0.2."),
City(12, "Tolyatti", 240, 240, "198.51.100."),
City(5, "Syzran", 11139, 240, "203.0.113."),
City(7, "Ulyanovsk", 195, 240, "198.18.0."),
City(7, "Saratov", 194, 240, "198.18.1."),
City(5, "Penza", 49, 180, "198.18.2."),
City(5, "Kazan", 43, 180, "198.18.3."),
City(4, "Ufa", 172, 300, "198.18.4."),
City(4, "Orenburg", 48, 300, "198.18.5."),
City(3, "Nizhny Novgorod", 47, 180, "198.18.6."),
City(2, "Volgograd", 38, 180, "198.18.7."),
City(5, "Moscow", 213, 180, "198.18.8."),
City(3, "Saint Petersburg", 2, 180, "198.18.9."),
City(2, "Yekaterinburg", 54, 300, "198.18.10."),
City(1, "Novosibirsk", 65, 420, "198.18.11."),
City(1, "Krasnodar", 35, 180, "198.18.12."),
)
# Мобильный интернет: операторы раздают телефонам адреса CGNAT-диапазона
# 100.64/10 — второй байт от 64 до 127. Город по такому адресу не читается,
# как и в жизни.
MOBILE_IP_FIRST_BYTE = 100
MOBILE_IP_SECOND_BYTE = (64, 128)
@dataclass(frozen=True, slots=True)
class TrafficSource:
"""Откуда посетитель пришёл: реферер, метки перехода, вход на сайт.
Метки кликов отданы колонками `HasGCLID` и `YCLID`: их выгрузка
разбирает за нас. UTM остаются и в адресе входа — как в жизни.
"""
weight: int
last_traffic_source: str
referer: str
utm_source: str
utm_medium: str
utm_campaign: str
utm_content: str
utm_term: str
has_gclid: int
has_yclid: bool
entry_percent: tuple[int, ...] # вход на сайт по колонкам `BROWSE_PAGES`
# fmt: off
TRAFFIC_SOURCES = (
TrafficSource(30, "organic", "https://yandex.ru/search/",
"", "", "", "", "", 0, False, (10, 30, 5, 50, 5)),
TrafficSource(9, "organic", "https://www.google.com/",
"", "", "", "", "", 0, False, (10, 30, 5, 50, 5)),
TrafficSource(22, "direct", "",
"", "", "", "", "", 0, False, (55, 15, 5, 15, 10)),
TrafficSource(8, "ad", "https://yandex.ru/",
"yandex", "cpc", "posuda-poisk", "text-1", "kupit-skovorodu",
0, True, (5, 45, 0, 50, 0)),
TrafficSource(6, "ad", "https://an.yandex.ru/",
"yandex", "cpc", "tekstil-rsya", "banner-2", "",
0, True, (5, 45, 0, 50, 0)),
TrafficSource(5, "ad", "https://www.google.com/",
"google", "cpc", "home-shopping", "ad-1", "postelnoe-belyo",
1, False, (5, 45, 0, 50, 0)),
TrafficSource(7, "social", "https://vk.com/",
"vk", "social", "vk-lenta", "post-3", "",
0, False, (15, 25, 0, 55, 5)),
TrafficSource(5, "email", "",
"email", "email", "nedelnaya-rassylka", "blok-1", "",
0, False, (10, 40, 0, 45, 5)),
TrafficSource(5, "referral", "https://market.example.com/",
"", "", "", "", "", 0, False, (5, 25, 0, 65, 5)),
TrafficSource(3, "recommend", "https://dzen.ru/",
"", "", "", "", "", 0, False, (10, 30, 0, 55, 5)),
)
# fmt: on
@@ -100,7 +100,9 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="datetime64[D]",
normalized_name="event_date",
group=ColumnGroup.IDENTIFIERS,
comment="дата события; по ней режется партиция",
comment="дата события в часовом поясе счётчика; по ней режется"
" партиция. Дату из `UTCEventTime` не выводить: у ночных событий"
" она на сутки другая",
),
Column(
name="UTCEventTime",
@@ -108,7 +110,9 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="datetime64[s]",
normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS,
comment="время события в UTC — единственная метка времени, как у Метрики",
comment="время события в UTC — единственная метка времени, как у"
" Метрики; сутки же считаются в поясе счётчика, поэтому"
" `toDate(UTCEventTime)` ≠ `EventDate`",
),
Column(
name="ClientTimeZone",
@@ -0,0 +1,37 @@
"""Выбор по целым весам — общий приём плана состава и дня-функции.
Дисциплина спеки (раздел 2): случайность тянется целыми числами, плавающие
распределения системной математики не зовутся — они расходятся между
версиями numpy и архитектурами CPU, а обещано побайтовое совпадение. Отсюда
и способ: веса складываются в накопленный ряд, бросок попадает в чью-то долю
общего веса, и кто долю занимал — тот и выбран.
Модуль маленький нарочно: у приёма одно определение на весь генератор,
иначе дисциплина живёт копиями и расходится с ними.
"""
import numpy as np
from numpy.typing import NDArray
def pick(
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int
) -> NDArray[np.int64]:
"""Куда попал бросок в общий вес — тот вариант и вышел."""
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
def pick_row(
rng: np.random.Generator, cumulative: NDArray[np.int64], rows: NDArray[np.int64]
) -> NDArray[np.int64]:
"""То же, но у каждого броска своя строка таблицы весов.
Строки уложены встык — каждая начинается там, где кончилась предыдущая, —
и поиск идёт по одному ряду: столько же работы, сколько на одну таблицу.
Общий вес у строк поэтому обязан совпадать; у повёрнутой суточной волны
он совпадает по построению.
"""
total = cumulative[0, -1]
shelf = (cumulative + np.arange(cumulative.shape[0])[:, None] * total).ravel()
draw = rows * total + rng.integers(0, total, rows.size)
return np.searchsorted(shelf, draw, "right") - rows * cumulative.shape[1]
+70 -5
View File
@@ -13,6 +13,16 @@
from datetime import date
# Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
# у ночных событий (спека генератора, раздел 9).
COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
@@ -25,9 +35,11 @@ ORIGIN = date(2026, 6, 1)
DAILY_INFLUX = 3_800
# Недельная волна мира, проценты от среднего: понедельник … воскресенье.
# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик
# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно
# 700: за неделю средний день остаётся средним.
# Волна задана притоку — по ней в мир приходят новые люди. Трафик наследует
# её через дневную аудиторию, а не вторым умножением (довод — у суточной
# волны выходного дня), поэтому доля новичков по дням недели ровная, а
# недельный размах трафика выходит мягче притока: ±7% против ±10%. В сумме
# ровно 700: за неделю средний день остаётся средним.
WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92)
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
@@ -43,13 +55,13 @@ ONE_SHOT_PERCENT = 75
# притоке 3 800 (спека, разделы 5 и 9).
RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1)
# Хвост возвратов: окно активности человека от его первого визита, общее на
# Хвост возвратов: окно активности человека от его первого дня, общее на
# обе его куки. За краем окна кука не возвращается. Оно же — глубина
# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на
# полке с самого первого дня.
RETURN_TAIL_DAYS = 90
# Профиль возвратов по дням от первого визита: почти всё в первую неделю,
# Профиль возвратов по дням от первого дня куки: почти всё в первую неделю,
# дальше тонкий хвост до края окна — повторные покупки в магазине случаются
# и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не
# виден. Читается по парам «сколько дней — с каким весом»; дней в сумме
@@ -69,3 +81,56 @@ BUYER_PERCENT = 5
# Такой паре план назначает по гарантированному заказу с каждой куки — на
# этом стоит лаба про склейку личности.
PAIRED_BUYER_PERCENT = 15
# --- Числа дня: суточная волна, визиты, воронка ---------------------------
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
# местного времени посетителя. Ночной провал, обеденный и вечерний пики до
# ~2× среднего (спека генератора, раздел 2). В сумме ровно 2400: средний час
# остаётся средним, и суточный объём от формы волны не зависит.
WEEKDAY_HOURS_PERCENT = (
35, 20, 12, 8, 8, 12, 25, 45, 70, 105, 115, 130,
170, 165, 140, 130, 130, 140, 160, 185, 210, 180, 130, 75,
) # fmt: skip
# Выходной день: подъём позже, обеденного пика нет — день ровнее, вечер
# ниже буднего. Здесь только форма, поэтому сумма та же — 2400: объём
# выходного день-функция не трогает, он приходит сам, потому что дневная
# аудитория уже дышит недельной волной через приток. Умножить на неё
# второй раз значило бы удвоить недельный размах.
WEEKEND_HOURS_PERCENT = (
45, 30, 20, 12, 10, 10, 14, 22, 40, 70, 105, 140,
165, 160, 175, 175, 170, 165, 165, 175, 180, 165, 120, 67,
) # fmt: skip
# Сколько визитов у куки в её день активности: веса для 1, 2 и 3 визитов.
# В среднем ≈1,4 — при дневной аудитории 6–7 тыс. это 8–10 тыс. визитов
# (спека, раздел 5).
VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
# и до ~50 тыс. добирают торговые события (#40).
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по
# которому лаба сессий сверяет свою сборку с `VisitID` (мастер-спека,
# раздел 1.2), поэтому паузы внутри визита всегда короче, а соседние визиты
# одной куки всегда разведены дальше.
VISIT_TIMEOUT_SECONDS = 1800
# Пауза между соседними страницами визита, секунды: обычная и «задумался».
# Обе целиком внутри таймаута — иначе визит распался бы там, где генератор
# этого не обещал.
PAGE_PAUSE_SECONDS = (8, 300)
LONG_PAUSE_SECONDS = (300, 1500)
LONG_PAUSE_PERCENT = 12
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
# предыдущего. Произведение — конверсия визита в оформленный заказ: 2%
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
# пар проходят воронку целиком независимо от этих долей.
CART_PERCENT = 8
CHECKOUT_OF_CART_PERCENT = 45
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
+79
View File
@@ -0,0 +1,79 @@
"""Каталог товаров: форма файла, а не его длина.
Файл дорастает механически, поэтому ни один тест не считает его строки и
не знает ни одного артикула наизусть. Сторожится ровно то, на что опираются
генератор и словарь ClickHouse: колонки, вид артикула, известные категории,
целая цена.
"""
import csv
import re
import numpy as np
from clickstream_generator import catalog
SKU = re.compile(r"^[A-Z]{4}-\d{4}$")
# Вилка цены, копейки: от сотни рублей до полумиллиона. Сторож от нуля,
# от минуса и от цены, случайно записанной в рублях.
PRICE_RANGE = (10_000, 50_000_000)
def rows() -> list[dict[str, str]]:
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
return list(csv.DictReader(source))
def test_the_file_has_the_columns_the_dictionary_expects():
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
assert next(csv.reader(source)) == list(catalog.COLUMNS)
def test_the_catalog_is_not_empty():
assert rows()
def test_every_article_is_unique_and_named_by_its_category():
known = {category.name: category.prefix for category in catalog.CATEGORIES}
articles = set()
for row in rows():
sku = row["sku"]
assert SKU.match(sku), sku
assert sku.split("-")[0] == known[row["category"]], sku
articles.add(sku)
assert len(articles) == len(rows())
def test_every_row_is_filled_and_priced_in_whole_kopecks():
low, high = PRICE_RANGE
for row in rows():
assert row["name"].strip()
assert row["brand"].strip()
assert row["price"].isdigit(), row["price"]
assert low <= int(row["price"]) <= high, row["sku"]
def test_every_category_of_the_assortment_is_covered():
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
present = {row["category"] for row in rows()}
assert present == {category.name for category in catalog.CATEGORIES}
def test_categories_are_told_apart_by_prefix_and_by_address():
prefixes = {category.prefix for category in catalog.CATEGORIES}
slugs = {category.slug for category in catalog.CATEGORIES}
assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES)
def test_the_catalog_is_grouped_by_category_whatever_the_file_order():
goods = catalog.catalog()
assert goods.count.sum() == goods.sku.size
for number in range(len(catalog.CATEGORIES)):
first = goods.first[number]
rows_here = goods.grouped[first : first + goods.count[number]]
assert np.all(goods.category[rows_here] == number)
def test_the_catalog_is_read_once():
assert catalog.catalog() is catalog.catalog()
+357
View File
@@ -0,0 +1,357 @@
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
день ~50 тыс. событий», «ночью провал, вечером пик»), а не при каждой правке.
"""
import inspect
import re
from dataclasses import replace
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, day, plan, reference, schema, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED
WEEKDAY = 2
WEEKEND = 5
@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)
def readable(column: NDArray[Any]) -> list[Any]:
"""Колонка в сравнимом виде: массив внутри ячейки — в список."""
if column.dtype == object:
return [
cell.tolist() if isinstance(cell, np.ndarray) else cell for cell in column
]
return column.tolist()
def snapshot(events: day.Day) -> dict[str, Any]:
"""Слепок дня для сравнений: всё, что день отдал наружу.
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
без оглядки на него, а порядок — часть обещания (он же порядок
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и
сторожить их надо тем же слепком, а не отдельной памятью.
"""
return {
"order": list(events.columns),
"values": [readable(column) for column in events.columns.values()],
"page": events.page.tolist(),
"product": events.product.tolist(),
}
def local_seconds(events: day.Day) -> NDArray[np.int64]:
"""Секунда события внутри модельных суток — в поясе счётчика."""
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(events.day, "D")
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
return (events.columns["UTCEventTime"] - midnight + away).astype("int64")
def hourly(events: day.Day) -> NDArray[np.int64]:
return np.bincount(local_seconds(events) // 3600, minlength=24)
def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
"""Сторож сторожей: слепок обязан замечать порядок колонок и швы.
Слепок слепой к чему-нибудь — это три зелёных теста детерминизма при
поехавшем выводе, а не одна пропущенная мелочь.
"""
original = snapshot(weekday)
reordered = day.Day(
day=weekday.day,
columns=dict(reversed(list(weekday.columns.items()))),
page=weekday.page,
product=weekday.product,
)
assert snapshot(reordered) != original
moved = weekday.product.copy()
moved[0] += 1
assert snapshot(replace(weekday, product=moved)) != original
shifted = weekday.page.copy()
shifted[0] += 1
assert snapshot(replace(weekday, page=shifted)) != original
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
first = snapshot(day.stream(CANONICAL_SEED, 3))
plan.cohort.cache_clear()
assert snapshot(day.stream(CANONICAL_SEED, 3)) == first
def test_a_day_generated_alone_is_the_same_day():
"""День D не зависит от того, прожиты ли дни до него."""
alone = snapshot(day.stream(CANONICAL_SEED, 3))
plan.cohort.cache_clear()
for earlier in range(3):
day.stream(CANONICAL_SEED, earlier)
assert snapshot(day.stream(CANONICAL_SEED, 3)) == alone
def test_the_next_day_does_not_move_the_days_before_it():
before = [snapshot(day.stream(CANONICAL_SEED, number)) for number in range(2)]
day.stream(CANONICAL_SEED, 2)
assert [snapshot(day.stream(CANONICAL_SEED, n)) for n in range(2)] == before
def test_another_seed_is_another_day():
ours = snapshot(day.stream(CANONICAL_SEED, 1))
assert snapshot(day.stream(CANONICAL_SEED + 1, 1)) != ours
def test_events_do_not_start_before_the_origin():
with pytest.raises(ValueError):
day.stream(CANONICAL_SEED, -1)
def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
"""Состав колонок решает контракт схемы; день обязан отдать их все."""
assert list(weekday.columns) == [column.name for column in schema.COLUMNS]
for column in schema.COLUMNS:
values = weekday.columns[column.name]
assert values.size == len(weekday), column.name
if column.clickhouse_type.startswith("Array("):
cells = {cell.dtype for cell in values[:1000]}
assert cells == {np.dtype(column.numpy_dtype)}, column.name
else:
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):
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
#40 добавит свои, и они должны попасть под тот же сторож.
"""
waiting = [
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]
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_no_column_hides_a_hole(weekday: day.Day):
"""`None` в колонке — та же пропажа ключа, только позже и незаметнее."""
for column in schema.COLUMNS:
values = weekday.columns[column.name]
if values.dtype != object:
continue
kind = np.ndarray if column.clickhouse_type.startswith("Array(") else str
assert all(isinstance(cell, kind) for cell in values[:1000]), column.name
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].dtype == np.uint64
def test_the_event_id_is_unique_because_it_deduplicates(weekday: day.Day):
"""`WatchID` — ключ склейки при переигровке дня (спека, раздел 4)."""
watch = weekday.columns["WatchID"]
assert len(set(watch.tolist())) == watch.size
def test_a_visit_belongs_to_one_cookie(weekday: day.Day):
visit = weekday.columns["VisitID"]
cookie = weekday.columns["ClientID"]
pairs = set(zip(visit.tolist(), cookie.tolist(), strict=True))
assert len(pairs) == len(set(visit.tolist()))
def test_the_visit_cutting_rules_rebuild_the_generator_visits(weekday: day.Day):
"""Правило лабы: та же кука, пауза не длиннее таймаута — тот же визит.
Сборка сессий по правилам из докстринга модуля обязана совпасть с
`VisitID` — на этой сверке стоит лаба сессий.
"""
second = local_seconds(weekday)
cookie = weekday.columns["ClientID"]
order = np.lexsort((second, cookie))
cookie, second = cookie[order], second[order]
visit = weekday.columns["VisitID"][order]
started = np.ones(visit.size, dtype=bool)
started[1:] = (cookie[1:] != cookie[:-1]) | (
second[1:] - second[:-1] > world.VISIT_TIMEOUT_SECONDS
)
rebuilt = np.cumsum(started)
# Совпадение обоюдное: сколько пар «собранный визит — `VisitID`», столько
# же и тех, и других. Одного равенства мало — оно ловит только склейку
# двух визитов в один, а разрыв одного визита надвое проходит мимо.
matched = set(zip(rebuilt.tolist(), visit.tolist(), strict=True))
assert len(matched) == int(rebuilt.max())
assert len(matched) == len(set(visit.tolist()))
def test_the_day_boundary_cuts_the_visits(weekday: day.Day):
"""Событий за границей модельных суток в дне нет — партиция дня целая."""
second = local_seconds(weekday)
assert second.min() >= 0
assert second.max() < day.DAY_SECONDS
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(weekday.day, "D")
assert set(weekday.columns["EventDate"].tolist()) == {date.astype("O")}
def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
"""`toDate(UTCEventTime)` ≠ `EventDate`: сутки считаются в поясе счётчика."""
utc_date = weekday.columns["UTCEventTime"].astype("datetime64[D]")
assert np.any(utc_date != weekday.columns["EventDate"])
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
assert 30_000 < len(weekday) < 70_000
visits = len(set(weekday.columns["VisitID"].tolist()))
assert 6_000 < visits < 14_000
def test_the_wave_dips_at_night_and_peaks_in_the_evening(weekday: day.Day):
counters = hourly(weekday)
average = counters.mean()
assert counters[2:6].max() < average / 2
assert counters[18:22].max() > 1.5 * average
assert 0 <= int(counters.argmin()) <= 6
assert 11 <= int(counters.argmax()) <= 22
def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
"""Форма, а не объём: выходной раскачивается позже буднего."""
weekend = hourly(day.stream(CANONICAL_SEED, WEEKEND))
workday = hourly(weekday)
morning = slice(7, 10)
assert weekend[morning].sum() / weekend.sum() < (
workday[morning].sum() / workday.sum()
)
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())
assert 0.01 < ordered / visits < 0.04
def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
"""Гарантия двухкуковых пар: назначенный планом заказ обязан состояться."""
audience = plan.audience(CANONICAL_SEED, weekday.day)
promised = set(audience.client_id[audience.assigned_order].tolist())
assert promised
confirmed = weekday.columns["ClientID"][weekday.page == Page.CONFIRMATION]
assert promised <= set(confirmed.tolist())
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)
assert carts.size > 0
assert np.all(by_visit[carts - 1] == Page.PRODUCT)
def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
"""`product` — товар карточки и −1 у прочих страниц; больше ничего."""
goods = catalog.catalog()
card = weekday.page == Page.PRODUCT
assert np.all(weekday.product[~card] == -1)
assert np.all(weekday.product[card] >= 0)
assert np.all(weekday.product[card] < goods.sku.size)
shown = zip(weekday.columns["URL"][card], weekday.product[card], strict=True)
for url, number in list(shown)[:200]:
# У страницы входа в адресе ещё метки перехода — путь до знака «?».
assert url.split("?")[0].endswith(f"/product/{goods.sku[number]}")
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]
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
assert np.all(referer[inside] == url[inside - 1])
entry = np.flatnonzero(visit[1:] != visit[:-1]) + 1
known = {source.referer for source in reference.TRAFFIC_SOURCES}
assert set(referer[entry].tolist()) <= known
def test_the_passport_of_a_cookie_does_not_change_between_days():
"""Кука — браузер на устройстве: во всех её днях профиль и город одни."""
passports: dict[int, tuple[Any, ...]] = {}
for number in (0, 1, 2):
events = day.stream(CANONICAL_SEED, number)
seen = zip(
events.columns["ClientID"].tolist(),
events.columns["Browser"].tolist(),
events.columns["ScreenWidth"].tolist(),
events.columns["RegionCity"].tolist(),
strict=True,
)
for cookie, *passport in seen:
assert passports.setdefault(cookie, tuple(passport)) == tuple(passport)
def test_geography_is_one_region_of_presence(weekday: day.Day):
"""Магазин с одним складом: свой миллионник, свой регион, тонкий хвост."""
cities = weekday.columns["RegionCity"]
assert (cities == "Samara").mean() > 0.25
assert set(weekday.columns["RegionCountry"].tolist()) == {reference.COUNTRY_NAME}
assert set(weekday.columns["RegionCountryID"].tolist()) == {
reference.COUNTRY_REGION_ID
}
def test_addresses_are_not_routable(weekday: day.Day):
"""Правдоподобные публичные адреса принадлежат живым организациям."""
fixed = ("192.0.2.", "198.51.100.", "203.0.113.", "198.18.")
def cgnat(address: str) -> bool:
first, second, *_ = (int(byte) for byte in address.split("."))
return first == reference.MOBILE_IP_FIRST_BYTE and 64 <= second < 128
for address in set(weekday.columns["IPAddress"].tolist()):
assert address.startswith(fixed) or cgnat(address), address
def test_phones_carry_a_model_and_desktops_do_not(weekday: day.Day):
phone = weekday.columns["DeviceCategory"] == reference.PHONE_CATEGORY
assert phone.mean() > 0.5
assert np.all(weekday.columns["MobilePhoneModel"][~phone] == "")
assert np.all(weekday.columns["MobilePhoneModel"][phone] != "")
def test_randomness_is_drawn_in_whole_numbers():
"""Дисциплина спеки (раздел 2): целые числа, никакой системной математики."""
source = inspect.getsource(day)
assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"}
assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE)
+51 -21
View File
@@ -11,7 +11,7 @@ import re
import numpy as np
import pytest
from clickstream_generator import plan, world
from clickstream_generator import plan, reference, world
from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5).
@@ -24,9 +24,9 @@ def fresh_memo():
plan.cohort.cache_clear()
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
"""Таблица визитов парами «кука — день», как её видит день-функция."""
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist()
def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
"""Таблица парами «кука — день активности», как её видит день-функция."""
cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist()
return list(zip(cookies, days, strict=True))
@@ -37,10 +37,12 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
and np.array_equal(left.client_id, right.client_id)
and np.array_equal(left.buyer, right.buyer)
and np.array_equal(left.birth_day, right.birth_day)
and np.array_equal(left.visit_cookie, right.visit_cookie)
and np.array_equal(left.visit_day, right.visit_day)
and np.array_equal(left.active_cookie, right.active_cookie)
and np.array_equal(left.active_day, right.active_day)
and np.array_equal(left.pair_cookies, right.pair_cookies)
and np.array_equal(left.pair_order_days, right.pair_order_days)
and np.array_equal(left.device, right.device)
and np.array_equal(left.city, right.city)
)
@@ -92,25 +94,25 @@ def test_events_do_not_start_before_the_origin():
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
def test_visits_stay_inside_the_activity_window(day: int):
"""Окно активности — хвост возвратов от первого визита человека."""
def test_active_days_stay_inside_the_activity_window(day: int):
"""Окно активности — хвост возвратов от первого дня человека."""
cohort = plan.cohort(CANONICAL_SEED, day)
assert cohort.visit_day.min() == day
assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS
assert cohort.active_day.min() == day
assert cohort.active_day.max() <= day + world.RETURN_TAIL_DAYS
def test_every_cookie_visits_on_the_day_it_was_born():
def test_every_cookie_is_active_on_the_day_it_was_born():
cohort = plan.cohort(CANONICAL_SEED, 0)
cookies = range(cohort.client_id.size)
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
assert set(born) <= set(visits_of(cohort))
assert set(born) <= set(active_days_of(cohort))
def test_a_cookie_visits_a_day_once():
def test_a_cookie_gets_one_active_day_at_a_time():
cohort = plan.cohort(CANONICAL_SEED, 0)
visits = visits_of(cohort)
assert visits == sorted(visits)
assert len(set(visits)) == len(visits)
days = active_days_of(cohort)
assert days == sorted(days)
assert len(set(days)) == len(days)
def test_client_ids_are_unique_and_survive_json():
@@ -144,16 +146,44 @@ def test_pairs_are_the_agreed_share_of_buyers():
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
"""Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0."""
"""Гарантия двухкуковых: заказ назначен на день активности куки, от D0."""
cohort = plan.cohort(CANONICAL_SEED, day)
visits = set(visits_of(cohort))
days_of = set(active_days_of(cohort))
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
for cookie, order_day in zip(cookies, days, strict=True):
assert order_day >= 0
assert (cookie, order_day) in visits
assert (cookie, order_day) in days_of
def test_the_passport_of_a_pair_is_one_person_with_two_devices():
"""Два города у одного человека — ложь в данных (спека, раздел 9)."""
cohort = plan.cohort(CANONICAL_SEED, 0)
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
assert np.all(cohort.city[first] == cohort.city[second])
assert np.all(cohort.device[first] != cohort.device[second])
# Обещано не просто «разные строки справочника», а разный род устройства:
# ровно одно из двух — телефон, второе десктоп или планшет. Пара «телефон
# и ноутбук» мастер-спеки (раздел 5) — про это, а не про запрет планшета.
category = np.array([row.category for row in reference.DEVICE_PROFILES])
kinds = category[cohort.device[first]], category[cohort.device[second]]
assert np.all(kinds[0] != kinds[1])
phone = category == reference.PHONE_CATEGORY
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
def test_the_passport_points_into_the_directories():
cohort = plan.cohort(CANONICAL_SEED, 0)
for passport, table in (
(cohort.device, reference.DEVICE_PROFILES),
(cohort.city, reference.CITIES),
):
assert passport.size == cohort.client_id.size
assert passport.min() >= 0
assert passport.max() < len(table)
def test_the_daily_audience_matches_the_spec_band():
@@ -244,8 +274,8 @@ def test_plan_arrays_are_whole_numbers():
for array in (
cohort.client_id,
cohort.birth_day,
cohort.visit_cookie,
cohort.visit_day,
cohort.active_cookie,
cohort.active_day,
cohort.pair_cookies,
cohort.pair_order_days,
):
+92
View File
@@ -0,0 +1,92 @@
"""Сторожа справочников: связки, из-за которых таблицы написаны руками.
Тесты держат не содержание строк — его менти волен менять, — а то, на чём
стоит день-функция: доли складываются в сотню, вход и переходы разложены по
страницам блуждания, устройство не противоречит само себе.
"""
from clickstream_generator import reference
def test_shares_of_every_directory_add_up_to_a_hundred():
"""Веса — проценты: выбор по ним целочисленный и без остатка."""
for table in (
reference.DEVICE_PROFILES,
reference.CITIES,
reference.TRAFFIC_SOURCES,
):
assert sum(row.weight for row in table) == 100
def test_walking_the_site_never_leaves_the_browsing_pages():
for percent in (reference.FROM_PRODUCT_PERCENT, reference.FROM_LISTING_PERCENT):
assert len(percent) == len(reference.BROWSE_PAGES)
assert sum(percent) == 100
def test_a_visitor_comes_to_a_page_that_exists():
for source in reference.TRAFFIC_SOURCES:
assert len(source.entry_percent) == len(reference.BROWSE_PAGES)
assert sum(source.entry_percent) == 100
def test_the_walk_prefers_the_product_card():
"""Иначе магазин выглядел бы каталогом, который никто не открывает."""
card = reference.BROWSE_PAGES.index(reference.Page.PRODUCT)
assert reference.FROM_LISTING_PERCENT[card] > 50
def test_a_phone_carries_a_model_and_a_desktop_does_not():
for profile in reference.DEVICE_PROFILES:
phone = profile.category == reference.PHONE_CATEGORY
assert bool(profile.phone_model) == phone
assert profile.screen_width > 0 and profile.screen_height > 0
def test_mobile_traffic_outweighs_the_desktop():
"""Российская розница мобильная; на этом стоят доли устройств."""
phones = sum(
row.weight
for row in reference.DEVICE_PROFILES
if row.category == reference.PHONE_CATEGORY
)
assert phones > 50
def test_cities_are_told_apart_by_id_and_by_address_block():
assert len({city.region_id for city in reference.CITIES}) == len(reference.CITIES)
assert len({city.ip_prefix for city in reference.CITIES}) == len(reference.CITIES)
assert reference.COUNTRY_REGION_ID not in {c.region_id for c in reference.CITIES}
def test_the_region_of_presence_outweighs_the_rest_of_the_country():
"""Один регион присутствия, а не «топ городов России» (спека, раздел 9)."""
home = reference.CITIES[0]
assert home.name == "Samara"
assert home.weight > 30
nearby = sum(
city.weight
for city in reference.CITIES
if city.timezone_minutes == home.timezone_minutes
)
assert nearby > 50
def test_timezones_are_whole_hours():
"""Волна поворачивается на целые часы: получасовых поясов в мире нет."""
for city in reference.CITIES:
assert city.timezone_minutes % 60 == 0
def test_paid_sources_carry_their_click_labels():
for source in reference.TRAFFIC_SOURCES:
paid = source.last_traffic_source == "ad"
assert bool(source.utm_source) >= paid
assert (source.has_gclid or source.has_yclid) == paid
def test_free_sources_carry_no_utm():
"""Метки ставит тот, кто платит: у organic и direct их не бывает."""
for source in reference.TRAFFIC_SOURCES:
if source.last_traffic_source in ("organic", "direct", "recommend"):
assert not source.utm_source
+53
View File
@@ -64,3 +64,56 @@ def test_most_returns_land_in_the_first_days():
def test_pairs_are_a_small_part_of_the_cohort():
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100
def test_both_daily_waves_cover_a_day_and_average_to_one():
"""Форма волны не меняет суточный объём: его задаёт недельный профиль."""
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
assert len(profile) == 24
assert sum(profile) == 2400
def test_the_wave_dips_at_night_and_peaks_up_to_twice_the_average():
"""Пики до ~2× среднего, ночью провал (спека, раздел 2)."""
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
assert min(profile[2:6]) < 50
assert 150 < max(profile) <= 220
def test_the_weekend_wakes_up_later_than_a_weekday():
morning = slice(7, 10)
assert sum(world.WEEKEND_HOURS_PERCENT[morning]) < sum(
world.WEEKDAY_HOURS_PERCENT[morning]
)
def test_an_active_day_holds_a_visit_or_two():
"""Дневная аудитория 6–8 тыс. и 8–12 тыс. визитов сходятся через это число."""
weights = world.VISITS_PER_ACTIVE_DAY_WEIGHTS
counts = tuple(range(1, len(weights) + 1))
assert 1.2 <= mean_by_weights(counts, weights) <= 1.6
def test_a_visit_is_a_few_pages_long_and_often_a_single_one():
weights = world.VISIT_PAGES_WEIGHTS
pages = tuple(range(1, len(weights) + 1))
assert 4 <= mean_by_weights(pages, weights) <= 6
bounced = weights[0] / sum(weights)
assert 0.15 < bounced < 0.35
def test_pauses_stay_inside_the_visit_timeout():
"""Иначе визит распался бы там, где генератор этого не обещал."""
assert max(world.PAGE_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
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