- Зачем: - визит после восстановления не должен менять браузер и источники перехода внутри одного click_id. - Что: - добавлен base_click_id в state v3 для восстановления донора фактуры. - исправлено восстановление timestamp offset без потери микросекунд. - расширены тесты и стыковая проверка browser/source и device/os/geo. - Проверка: - uv run --with-requirements generator/requirements.txt pytest generator/tests -q. - bash -n scripts/check_generated_analytics.sh. - git diff --cached --check.
140 lines
9.7 KiB
Markdown
140 lines
9.7 KiB
Markdown
# Генератор: известные проблемы и контекст для доработки
|
||
|
||
> **Статус (2026-06-11):** исторический дефект старой плоской генерации закрыт
|
||
> для режима `steady-stream`. Генератор строит визиты с общим `click_id`,
|
||
> монотонным временем событий, путём по страницам воронки, популяцией
|
||
> возвращающихся пользователей и состоянием для активных визитов.
|
||
>
|
||
> Эта заметка больше не является предупреждением «генератор концептуально
|
||
> сломан». Она оставлена как учебный разбор старого дефекта и как место для
|
||
> небольших остаточных ограничений.
|
||
|
||
Заметка написана при дизайне Superset-дашборда (ветка
|
||
`docs/advanced-clickstream-course`): разбирались, почему на дашборде
|
||
`Unique Users == Unique Sessions`, и по ходу вскрылось, что старая реализация
|
||
генератора на тот момент семантику сессии не чинила, а ломала сильнее. Разбор
|
||
оставлен, чтобы не переоткрывать этот дефект заново и показать, почему новая
|
||
модель устроена иначе.
|
||
|
||
## TL;DR
|
||
|
||
- **Модель интенсивности потока (сколько событий и когда) — нормальная.**
|
||
Poisson по тикам + дневной коэффициент + jitter сохранены.
|
||
- **Генеративная модель сущностей переписана.** Поток больше не штампует свежий
|
||
`click_id` на каждое событие: визит живёт несколько событий, пользователь
|
||
может вернуться в новом визите после кулдауна.
|
||
- **Вывод:** старый дефект `Sessions == Events` не считается актуальным
|
||
блокером. Дальше генератор можно улучшать уже как работающую учебную модель,
|
||
а не как концептуально сломанный источник.
|
||
|
||
## Текущие ограничения
|
||
|
||
- Тип события остаётся `pageview` для всех событий. Это осознанное ограничение:
|
||
текущий дашборд и уроки строят воронку по `page_url_path`, а не по
|
||
`event_type`.
|
||
- Device/geo-профиль пользователя стабилен между визитами. Смену устройства
|
||
генератор пока не моделирует.
|
||
- При долгом простое больше 30 минут активный визит закрывается без досылки
|
||
остатка. Популяция пользователей при этом сохраняется.
|
||
|
||
## Доменная модель (как задумано в DDL)
|
||
|
||
В этой модели сущности связаны иерархически:
|
||
|
||
```
|
||
user_domain_id (постоянный пользователь, cookie)
|
||
└── click_id (визит/сессия — envelope из 1..N событий с общим device/geo)
|
||
└── event_id (отдельное событие: pageview, click, purchase ...)
|
||
```
|
||
|
||
- `click_id` — это **визит**, а не одиночный клик: в статическом сиде один
|
||
`click_id` честно группирует 1..27 событий, медиана 10 (`dds.click` =
|
||
«контекст сессии пользователя», см. `sql/ddl/dds/30_dds.sql`; точные цифры —
|
||
профиль сид-датасета в `CONTEXT.md`, ранняя оценка «1..7» была замером по
|
||
срезу файла).
|
||
- `user_domain_id` живёт в `device_events`, привязан к `click_id`.
|
||
|
||
## Что генератор делает правильно
|
||
|
||
Файл `generator/src/clickstream_generator/intensity.py`,
|
||
`calculate_events_count()`:
|
||
|
||
- **Poisson-процесс** прихода событий: λ на минуту → λ на тик → розыгрыш Пуассона.
|
||
- **Дневной коэффициент** `_hour_factor()`: день (9–18) ×1.2, ночь (0–5) ×0.7.
|
||
- **Jitter** ±`GEN_JITTER_PCT`% и границы `min/max_events_per_tick`.
|
||
|
||
Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
|
||
|
||
## Исторический корневой дефект: старая модель сущностей
|
||
|
||
До переработки 2026-06-11 прежняя реализация в монолитном
|
||
`generator/generator.py` на каждое событие в батче делала примерно следующее:
|
||
|
||
```python
|
||
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
|
||
...
|
||
new_event_id = self._new_uuid()
|
||
new_click_id = self._new_uuid() # СВЕЖИЙ click_id на КАЖДОЕ событие
|
||
new_timestamp = self._current_timestamp() # ~now() для всех событий батча
|
||
...
|
||
device_event = {**base_device, "click_id": new_click_id} # user_domain_id наследуется из сида
|
||
```
|
||
|
||
Три уровня иерархии (пользователь → сессия → событие) схлопываются в плоскую
|
||
выборку:
|
||
|
||
1. **`click_id` свежий на каждое событие** → один визит = одно событие. Визит как
|
||
группа из нескольких событий **никогда не формируется**.
|
||
2. **`user_domain_id` переиспользуется** из сид-пула (~99 значений), но без
|
||
привязки к сессиям — события одного пользователя разлетаются отдельными
|
||
`click_id`. Иерархию «пользователь → его сессии → события сессии» из потока
|
||
**не восстановить**.
|
||
3. **Время** у всех событий батча ≈ `now()` — даже временно́й структуры визита
|
||
(последовательность событий внутри сессии) нет.
|
||
|
||
### Что это даёт в данных
|
||
|
||
| | Статический сид (`data/*.jsonl`) | Поток из генератора |
|
||
|---|---|---|
|
||
| `click_id` | визит-envelope (1..27 событий, медиана 10) | свежий на событие → `click_id` ≈ событие |
|
||
| `user_domain_id` | 1:1 с `click_id` | переиспользуется (потолок ~99) |
|
||
| Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) |
|
||
|
||
Парадокс был таким: **статический сид как учебная основа был лучше**, потому что
|
||
на нём `click_id` нёс осмысленную семантику визита, а старый генератор её
|
||
ломал.
|
||
|
||
## Что сделано в новой модели
|
||
|
||
Чинили именно **генеративную модель сущностей**, не переписывая математику
|
||
интенсивности:
|
||
|
||
1. **Иерархическая генерация вместо плоской выборки:**
|
||
- есть ограниченная популяция пользователей с постоянным `user_domain_id`;
|
||
- пользователь со временем открывает 1..N визитов;
|
||
- визит порождает последовательность событий с одним `click_id`, общим
|
||
device/geo-контекстом и путём по страницам.
|
||
2. **Тиковый слой:**
|
||
- событийный бюджет тика превращается в рождения визитов через среднюю длину
|
||
визита;
|
||
- активные визиты живут между тиками;
|
||
- выпускаются только события, у которых наступила запланированная метка
|
||
времени.
|
||
3. **Время событий** внутри визита строго растёт и не прилипает к одному
|
||
`now()` для всего батча.
|
||
4. **Состояние** сохраняет популяцию, активные визиты, накопленный бюджет
|
||
рождения визитов, номер тика и состояние ГПСЧ.
|
||
|
||
После этой переделки поток на длинном окне и при штатных параметрах даёт
|
||
здоровую пирамиду `users < sessions < events`, и дашборд может показывать
|
||
разницу «пользователь vs сессия» честными числами.
|
||
|
||
## Ссылки
|
||
|
||
- Код: `generator/src/clickstream_generator/intensity.py` —
|
||
`calculate_events_count()`; `generator/src/clickstream_generator/generation.py`
|
||
— `EventGenerator.generate_batch()`.
|
||
- Доменная модель: `sql/ddl/dds/30_dds.sql`, `sql/ddl/dm/40_dm.sql`.
|
||
- Контекст обсуждения: ветка `docs/advanced-clickstream-course`, дизайн
|
||
Superset-дашборда (урок 6 курса).
|