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:
2026-08-02 16:12:17 +03:00
co-authored by Claude Opus 5
parent 937ba014b4
commit eb433ad023
18 changed files with 2073 additions and 105 deletions
@@ -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