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

+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