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:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user