feat(generator): день-функция — трафик, визиты и просмотры страниц #49
+30
-4
@@ -31,8 +31,8 @@ _Избегать_: состояние мира
|
|||||||
там колонки, здесь числа.
|
там колонки, здесь числа.
|
||||||
|
|
||||||
**Когорта дня**:
|
**Когорта дня**:
|
||||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
|
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
||||||
Единица плана состава: когорта — функция зерна и номера дня.
|
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
||||||
|
|
||||||
**Приток**:
|
**Приток**:
|
||||||
Появление новых кук на всём протяжении оси модельного времени; единица —
|
Появление новых кук на всём протяжении оси модельного времени; единица —
|
||||||
@@ -40,11 +40,37 @@ _Избегать_: состояние мира
|
|||||||
горизонтом и не совпадает с дневной.
|
горизонтом и не совпадает с дневной.
|
||||||
|
|
||||||
**Хвост возвратов**:
|
**Хвост возвратов**:
|
||||||
Окно активности, отсчитанное от первого визита человека и общее на обе
|
Окно активности, отсчитанное от первого дня человека и общее на обе
|
||||||
его куки; дольше окна кука не возвращается.
|
его куки; дольше окна кука не возвращается.
|
||||||
|
|
||||||
**Предыстория**:
|
**Предыстория**:
|
||||||
Когорты плана с первым визитом до D0; событий не порождают.
|
Когорты плана с первым днём активности до D0; событий не порождают.
|
||||||
|
|
||||||
|
**День активности**:
|
||||||
|
День, в который кука хоть раз появилась в мире: день её рождения или день
|
||||||
|
возврата. Единица плана состава: за день он у куки один, а визитов внутри
|
||||||
|
него бывает несколько.
|
||||||
|
_Избегать_: визит (визит — про сессию)
|
||||||
|
|
||||||
|
**Визит**:
|
||||||
|
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
|
||||||
|
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
|
||||||
|
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
|
||||||
|
вроде «лаба сессий» и «сборка сессий» остаются.
|
||||||
|
|
||||||
|
**Паспорт куки**:
|
||||||
|
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
|
||||||
|
устройстве. Держит их план состава, разворачивает в поля события
|
||||||
|
день-функция; у двухкуковой пары город один на две куки, устройства разные.
|
||||||
|
|
||||||
|
**День-функция**:
|
||||||
|
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
|
||||||
|
модельных суток. Состояния между днями нет: день D не зависит от того,
|
||||||
|
прожиты ли дни до него.
|
||||||
|
|
||||||
|
**Каталог товаров**:
|
||||||
|
`data/catalog/products.csv` — общий справочник генератора и словаря
|
||||||
|
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
|
||||||
|
|
||||||
**Ось модельного времени**:
|
**Ось модельного времени**:
|
||||||
Собственный календарь мира генератора. Дни считаются от фиксированного
|
Собственный календарь мира генератора. Дни считаются от фиксированного
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
sku,name,category,brand,price
|
||||||
|
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000
|
||||||
|
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900
|
||||||
|
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,189000
|
||||||
|
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000
|
||||||
|
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,349000
|
||||||
|
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000
|
||||||
|
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000
|
||||||
|
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,219000
|
||||||
|
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,199000
|
||||||
|
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,459000
|
||||||
|
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,119000
|
||||||
|
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000
|
||||||
|
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,189000
|
||||||
|
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000
|
||||||
|
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000
|
||||||
|
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000
|
||||||
|
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000
|
||||||
|
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,129000
|
||||||
|
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,89000
|
||||||
|
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,99000
|
||||||
|
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000
|
||||||
|
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000
|
||||||
|
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000
|
||||||
|
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000
|
||||||
|
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,39000
|
||||||
|
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000
|
||||||
|
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000
|
||||||
|
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,49000
|
||||||
|
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,179000
|
||||||
|
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000
|
||||||
|
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000
|
||||||
|
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,39000
|
||||||
|
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000
|
||||||
|
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000
|
||||||
|
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,169000
|
||||||
|
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000
|
||||||
|
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,159000
|
||||||
|
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000
|
||||||
|
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000
|
||||||
|
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,25000
|
||||||
|
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,69000
|
||||||
|
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000
|
||||||
|
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,89000
|
||||||
|
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000
|
||||||
|
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000
|
||||||
|
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000
|
||||||
|
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,139000
|
||||||
|
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000
|
||||||
|
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000
|
||||||
|
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,149000
|
||||||
|
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000
|
||||||
|
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000
|
||||||
|
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,129000
|
||||||
|
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000
|
||||||
|
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,239000
|
||||||
|
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,119000
|
||||||
|
TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000
|
||||||
|
TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000
|
||||||
|
TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000
|
||||||
|
TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000
|
||||||
|
TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000
|
||||||
|
TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000
|
||||||
|
TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000
|
||||||
|
TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000
|
||||||
|
TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000
|
||||||
|
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,109000
|
||||||
|
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,69000
|
||||||
|
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000
|
||||||
|
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000
|
||||||
|
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,39000
|
||||||
|
TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000
|
||||||
|
TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,139000
|
||||||
|
TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000
|
||||||
|
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000
|
||||||
|
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,169000
|
||||||
|
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,139000
|
||||||
|
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000
|
||||||
|
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000
|
||||||
|
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000
|
||||||
|
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,119000
|
||||||
|
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000
|
||||||
|
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000
|
||||||
|
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,69000
|
||||||
|
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000
|
||||||
|
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000
|
||||||
|
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,79000
|
||||||
|
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,249000
|
||||||
|
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,59000
|
||||||
|
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000
|
||||||
|
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,139000
|
||||||
|
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,179000
|
||||||
|
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000
|
||||||
|
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,69000
|
||||||
|
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,119000
|
||||||
|
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,99000
|
||||||
|
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000
|
||||||
|
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000
|
||||||
|
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000
|
||||||
|
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000
|
||||||
|
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,69000
|
||||||
|
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,249000
|
||||||
|
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,299000
|
||||||
|
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000
|
||||||
|
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000
|
||||||
|
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000
|
||||||
|
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,349000
|
||||||
|
TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000
|
||||||
|
TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000
|
||||||
|
TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000
|
||||||
|
TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000
|
||||||
|
TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000
|
||||||
|
TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000
|
||||||
|
TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000
|
||||||
|
TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000
|
||||||
|
TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000
|
||||||
|
TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000
|
||||||
|
TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000
|
||||||
|
TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000
|
||||||
|
TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000
|
||||||
|
TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000
|
||||||
|
TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000
|
||||||
|
TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000
|
||||||
|
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,199000
|
||||||
|
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,149000
|
||||||
|
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000
|
||||||
|
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,399000
|
||||||
|
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,249000
|
||||||
|
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,799000
|
||||||
|
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000
|
||||||
|
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,169000
|
||||||
|
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000
|
||||||
|
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000
|
||||||
|
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,139000
|
||||||
|
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000
|
||||||
|
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,149000
|
||||||
|
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,59000
|
||||||
|
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000
|
||||||
|
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,55000
|
||||||
|
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000
|
||||||
|
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000
|
||||||
|
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000
|
||||||
|
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,79000
|
||||||
|
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,99000
|
||||||
|
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000
|
||||||
|
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,129000
|
||||||
|
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,299000
|
||||||
|
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000
|
||||||
|
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000
|
||||||
|
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000
|
||||||
|
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000
|
||||||
|
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,299000
|
||||||
|
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000
|
||||||
|
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,129000
|
||||||
|
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,79000
|
||||||
|
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,59000
|
||||||
|
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,109000
|
||||||
|
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,119000
|
||||||
|
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,199000
|
||||||
|
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000
|
||||||
|
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,99000
|
||||||
|
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000
|
||||||
|
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,129000
|
||||||
|
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000
|
||||||
|
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000
|
||||||
|
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,69000
|
||||||
|
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,89000
|
||||||
|
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000
|
||||||
|
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,79000
|
||||||
|
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,39000
|
||||||
|
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000
|
||||||
|
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000
|
||||||
|
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,249000
|
||||||
|
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,349000
|
||||||
|
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000
|
||||||
|
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,399000
|
||||||
|
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000
|
||||||
|
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000
|
||||||
|
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000
|
||||||
|
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000
|
||||||
|
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,99000
|
||||||
|
@@ -31,8 +31,8 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни |
|
| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни |
|
||||||
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
|
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
|
||||||
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
|
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
|
||||||
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события; по ней режется партиция |
|
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
|
||||||
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики |
|
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)` ≠ `EventDate` |
|
||||||
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
|
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
|
||||||
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
|
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
|
||||||
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
|
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
торговые счётчики сложатся, когда торговое поведение определит #40.
|
торговые счётчики сложатся, когда торговое поведение определит #40.
|
||||||
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
||||||
задаёт календарь появления — у каждого посетителя есть дата первого
|
задаёт календарь появления — у каждого посетителя есть дата первого
|
||||||
визита и профиль возвратов, включая затухание: заметная доля кук
|
дня и профиль возвратов, включая затухание: заметная доля кук
|
||||||
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
||||||
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
||||||
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
|
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
|
||||||
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
|
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
|
||||||
последних дней: окно активности — хвост возвратов (константа мира —
|
последних дней: окно активности — хвост возвратов (константа мира —
|
||||||
раздел 9), отсчитанный от первого визита человека и общий на обе его
|
раздел 9), отсчитанный от первого дня человека и общий на обе его
|
||||||
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
|
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
|
||||||
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
|
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
|
||||||
которым ленивая форма и держится). За краем окна кука не
|
которым ленивая форма и держится). За краем окна кука не
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
||||||
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
||||||
Каждой паре план назначает дни гарантированных заказов: по
|
Каждой паре план назначает дни гарантированных заказов: по
|
||||||
одному на куку, из дней визитов этой куки, на оси от D0 и позже;
|
одному на куку, из дней активности этой куки, на оси от D0 и позже;
|
||||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||||
пара не назначается. День-функция обязана назначенные заказы
|
пара не назначается. День-функция обязана назначенные заказы
|
||||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||||
@@ -405,12 +405,13 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
Решено при исполнении #38 (2026-08-02):
|
Решено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
|
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
|
||||||
модулируется тем же недельным профилем, что трафик (иначе доля
|
модулируется недельным профилем мира; трафик наследует эту волну через
|
||||||
новичков скакала бы по дням недели). Доля одноразовых кук — 75%;
|
дневную аудиторию, а не отдельным умножением (уточнение при исполнении
|
||||||
|
#39 — ниже). Доля одноразовых кук — 75%;
|
||||||
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
|
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
|
||||||
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
|
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
|
||||||
покупок — до края окна (цикл повторной покупки магазина — месяцы).
|
покупок — до края окна (цикл повторной покупки магазина — месяцы).
|
||||||
Хвост возвратов — окно активности человека от его первого визита,
|
Хвост возвратов — окно активности человека от его первого дня,
|
||||||
общее на обе его куки, — и глубина предыстории: 90 дней (решение
|
общее на обе его куки, — и глубина предыстории: 90 дней (решение
|
||||||
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
|
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
|
||||||
старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
|
старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
|
||||||
@@ -447,6 +448,100 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
||||||
раздела 3 против YAML).
|
раздела 3 против YAML).
|
||||||
|
|
||||||
|
Решено при исполнении #39 (2026-08-02) — решения владельца до реализации:
|
||||||
|
|
||||||
|
- **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть
|
||||||
|
`Title` — имя товара, а имена живут в каталоге: карточка без каталога
|
||||||
|
невозможна, и без карточек #39 сделал бы примерно половину трафика.
|
||||||
|
Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`,
|
||||||
|
`price`) — общий у генератора и словаря ClickHouse; #40 получает готовый.
|
||||||
|
Решена форма, а не длина: артикул — четыре буквы категории и четыре
|
||||||
|
цифры, цена — целые копейки, категорий шесть. Строки дописываются
|
||||||
|
механически, поэтому ни код, ни тесты их не считают, а товар для карточки
|
||||||
|
выбирается равномерно внутри категории.
|
||||||
|
- **Ассортимент — непродовольственная розница** (товары для дома, текстиль,
|
||||||
|
посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku.
|
||||||
|
Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной
|
||||||
|
покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит.
|
||||||
|
Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38.
|
||||||
|
- **География — один регион присутствия**: Поволжье с центром в Самаре,
|
||||||
|
города своего региона и тонкий хвост остальной страны. «Топ городов
|
||||||
|
России» дал бы магазину с одним складом карту, которой у него не бывает.
|
||||||
|
- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в
|
||||||
|
выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` —
|
||||||
|
абсолютная метка. Шов суток приходится на местную полночь и не режет
|
||||||
|
утренние сессии, а регион мира выбирается свободно. Суточная волна задана
|
||||||
|
в местном времени посетителя: гостю из другого пояса профиль
|
||||||
|
поворачивается на разницу, и пик слегка размазывается — как в жизни.
|
||||||
|
Следствие для соседних этапов: `toDate(UTCEventTime)` ≠ `EventDate`; оно
|
||||||
|
записано в описание выгрузки, иначе сторона хранилища выведет дату сама и
|
||||||
|
разойдётся на несколько часов данных.
|
||||||
|
- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС,
|
||||||
|
устройство, экран и город у куки одни во всех её днях: кука — это браузер
|
||||||
|
на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают
|
||||||
|
двумя массивами), разворачивает в поля события день-функция. Выводить
|
||||||
|
паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает
|
||||||
|
только план, а два города у одного человека — ложь в данных; пара
|
||||||
|
получает один город и разные устройства («телефон и ноутбук»,
|
||||||
|
мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому
|
||||||
|
измеренные числа канонического мира не сдвинулись: прогон счётчиков до и
|
||||||
|
после дал те же 3 828 / 6 235–7 124 / 68 119 / 170.
|
||||||
|
- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и
|
||||||
|
mimesis не подключаются. Нужны не случайные строки, а связки (город → id
|
||||||
|
региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их
|
||||||
|
фейкер не даёт, таблицу пришлось бы написать всё равно, а новая
|
||||||
|
зависимость молча меняла бы мир при обновлении своих словарей.
|
||||||
|
- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id
|
||||||
|
подтверждались обращением к живым сервисам Яндекса по тому же номеру
|
||||||
|
(`yandex.ru/pogoda/<id>`, `yandex.ru/maps/225/russia/`) — страница
|
||||||
|
открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы
|
||||||
|
геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы
|
||||||
|
той же геобазой — обоснованное допущение, не подтверждённый источником
|
||||||
|
факт. Выдуманные по памяти числа не годятся ни в каком случае.
|
||||||
|
- **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и
|
||||||
|
benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10,
|
||||||
|
как у настоящих операторов. Правдоподобные публичные адреса принадлежат
|
||||||
|
живым организациям, и в учебных данных им не место.
|
||||||
|
- **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое
|
||||||
|
понятие плана состава переименовано в «день активности»: у куки он один на
|
||||||
|
день, а визитов внутри дня бывает несколько.
|
||||||
|
|
||||||
|
Решено при исполнении #39 самой реализацией:
|
||||||
|
|
||||||
|
- **Правила резки визитов** (мастер-спека, раздел 1.2 требовала их
|
||||||
|
задокументировать): визит принадлежит одной куке; пауза дольше 30 минут
|
||||||
|
рвёт визит надвое; граница модельных суток режет визит, события за
|
||||||
|
полночь в день не попадают. Единственное исключение — визит с заказом,
|
||||||
|
обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы
|
||||||
|
воронка уместилась в сутки. Это принятое ограничение модели, а не
|
||||||
|
недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и
|
||||||
|
ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов
|
||||||
|
в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.).
|
||||||
|
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
|
||||||
|
заново, сверяя сборку с `VisitID`.
|
||||||
|
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
|
||||||
|
визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка
|
||||||
|
8% визитов до корзины → 45% из них до оформления → 55% из них до
|
||||||
|
подтверждения, то есть конверсия визита ~2%. Итог сложится после
|
||||||
|
торговых событий (#40) и ляжет в манифест.
|
||||||
|
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
|
||||||
|
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
|
||||||
|
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
|
||||||
|
недельной лабе однажды не хватит сигнала, принципиальный путь — отдельный,
|
||||||
|
более слабый профиль активности, а не повторное применение профиля
|
||||||
|
притока: механизмы разные, и «на выходных приходит меньше новых людей» —
|
||||||
|
не тот же факт, что «на выходных каждый ходит меньше».
|
||||||
|
- **`ParsedParamsKey1` остаётся пустой** — это отложенное решение, а не
|
||||||
|
дыра: вариант A/B-теста был бы постоянной куки, а не поведением дня, и
|
||||||
|
заводится тикетом #47.
|
||||||
|
- **Шов для #40**: день отдаёт, кроме колонок, два выровненных по строкам
|
||||||
|
ряда — какая это страница магазина и какой товар показывала карточка. По
|
||||||
|
ним #40 знает, куда вешать торговое событие и что посетитель на самом
|
||||||
|
деле смотрел: товар в корзине, которого никто не открывал, — видимая
|
||||||
|
глупость в воронке. Визит с назначенным заказом всегда доходит до
|
||||||
|
`/confirmation`, а перед корзиной у него всегда есть карточка. Своей
|
||||||
|
случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут.
|
||||||
|
|
||||||
Остаётся открытым, за тикетами:
|
Остаётся открытым, за тикетами:
|
||||||
|
|
||||||
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
||||||
@@ -454,4 +549,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
|
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
|
||||||
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
||||||
с манифестным решением напрашивается мини-манифест зернового мира —
|
с манифестным решением напрашивается мини-манифест зернового мира —
|
||||||
форма за #42.
|
форма за #42;
|
||||||
|
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не
|
||||||
|
накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш
|
||||||
|
каталога — хвост для #42.
|
||||||
|
|||||||
+45
-6
@@ -4,8 +4,35 @@
|
|||||||
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
||||||
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
||||||
|
|
||||||
Пока здесь контракт схемы события и план состава мира — событий генератор ещё
|
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
|
||||||
не порождает.
|
pageview. Торговые события и запуск снаружи — за следующими тикетами.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
|
||||||
|
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
|
||||||
|
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
|
||||||
|
|
||||||
|
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
|
||||||
|
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
|
||||||
|
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
|
||||||
|
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
|
||||||
|
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
|
||||||
|
история для него не нужна и на него не влияет.
|
||||||
|
|
||||||
|
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
|
||||||
|
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
|
||||||
|
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
||||||
|
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
||||||
|
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||||
|
дня станут другими, счётчики канонического мира разойдутся с манифестом, и
|
||||||
|
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||||
|
`plan.cohort` бросаются последними.
|
||||||
|
|
||||||
|
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
||||||
|
распределения numpy расходятся между версиями и архитектурами, а обещано
|
||||||
|
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
|
||||||
|
`rng.choice` с вероятностями.
|
||||||
|
|
||||||
## Что где лежит
|
## Что где лежит
|
||||||
|
|
||||||
@@ -14,16 +41,28 @@
|
|||||||
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
||||||
подпотока берёт случайность. На ней держится весь детерминизм.
|
подпотока берёт случайность. На ней держится весь детерминизм.
|
||||||
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
||||||
день D. Когорты, приток, двухкуковые пары и счётчики — до генерации
|
день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до
|
||||||
событий.
|
генерации событий.
|
||||||
|
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
|
||||||
|
на весь генератор, чтобы дисциплина целочисленной случайности не жила
|
||||||
|
копиями.
|
||||||
|
- `src/clickstream_generator/reference.py` — справочники: устройства,
|
||||||
|
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в
|
||||||
|
строке, которой принадлежит.
|
||||||
|
- `src/clickstream_generator/catalog.py` — каталог товаров из
|
||||||
|
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
||||||
|
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
||||||
|
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
||||||
|
который сядут торговые события.
|
||||||
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
||||||
колонках выгрузки. Собственность генератора; из него выводятся сам
|
колонках выгрузки. Собственность генератора; из него выводятся сам
|
||||||
генератор, его валидация и описание выгрузки в доках.
|
генератор, его валидация и описание выгрузки в доках.
|
||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
из контракта. Документ руками не правят — пересобирают.
|
||||||
- `tests/` — инварианты контракта, свежесть описания и обещания плана:
|
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
||||||
чистота от зерна, приток, гарантия двухкуковых пар.
|
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
||||||
|
и сборка визитов по задокументированным правилам.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,8 @@
|
|||||||
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
|
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
|
||||||
выгрузки. Из него выводятся сам генератор, его валидация и «описание
|
выгрузки. Из него выводятся сам генератор, его валидация и «описание
|
||||||
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
|
выгрузки» в доках (`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, а обещано побайтовое совпадение
|
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
|
||||||
@@ -26,8 +32,9 @@ from functools import lru_cache
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
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.seeds import cohort_stream
|
||||||
|
from clickstream_generator.weights import pick
|
||||||
|
|
||||||
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
|
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
|
||||||
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
|
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
|
||||||
@@ -37,18 +44,39 @@ CLIENT_ID_LIMIT = 2**53
|
|||||||
# долю общего веса.
|
# долю общего веса.
|
||||||
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
||||||
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Cohort:
|
class Cohort:
|
||||||
"""Люди, впервые пришедшие в мир в день `day`, с их куками и визитами.
|
"""Люди, впервые пришедшие в мир в день `day`, с их куками и днями.
|
||||||
|
|
||||||
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
|
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
|
||||||
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
|
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
|
||||||
и есть человек целиком.
|
и есть человек целиком.
|
||||||
|
|
||||||
Визиты — плоская таблица «кука — день», отсортированная и без повторов:
|
Дни активности — плоская таблица «кука — день», отсортированная и без
|
||||||
на день у куки приходится не больше одного визита. Все дни лежат в окне
|
повторов: дважды за день кука не появляется. Все дни лежат в окне
|
||||||
активности человека: от `day` до `day` + хвост возвратов.
|
активности человека: от `day` до `day` + хвост возвратов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -57,11 +85,14 @@ class Cohort:
|
|||||||
client_id: NDArray[np.uint64]
|
client_id: NDArray[np.uint64]
|
||||||
birth_day: NDArray[np.int64]
|
birth_day: NDArray[np.int64]
|
||||||
buyer: NDArray[np.bool_]
|
buyer: NDArray[np.bool_]
|
||||||
visit_cookie: NDArray[np.int64]
|
active_cookie: NDArray[np.int64]
|
||||||
visit_day: NDArray[np.int64]
|
active_day: NDArray[np.int64]
|
||||||
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
||||||
pair_cookies: NDArray[np.int64]
|
pair_cookies: NDArray[np.int64]
|
||||||
pair_order_days: NDArray[np.int64]
|
pair_order_days: NDArray[np.int64]
|
||||||
|
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
||||||
|
device: NDArray[np.int64]
|
||||||
|
city: NDArray[np.int64]
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
||||||
@@ -87,28 +118,36 @@ class Cohort:
|
|||||||
"""Сколько пар получили назначенные заказы."""
|
"""Сколько пар получили назначенные заказы."""
|
||||||
return len(self.pair_cookies)
|
return len(self.pair_cookies)
|
||||||
|
|
||||||
def visitors_on(
|
def visitors_on(self, day: int) -> DayAudience:
|
||||||
self, day: int
|
"""Кто из когорты пришёл в день `day` — её доля дневной аудитории.
|
||||||
) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]:
|
|
||||||
"""Кто из когорты пришёл в день `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]
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DayAudience:
|
class DayAudience:
|
||||||
"""Куки, пришедшие в день `day`, — вход для будущей дня-функции."""
|
"""Куки, пришедшие в день `day`, — вход дня-функции."""
|
||||||
|
|
||||||
day: int
|
day: int
|
||||||
client_id: NDArray[np.uint64]
|
client_id: NDArray[np.uint64]
|
||||||
buyer: NDArray[np.bool_]
|
buyer: NDArray[np.bool_]
|
||||||
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
||||||
assigned_order: NDArray[np.bool_]
|
assigned_order: NDArray[np.bool_]
|
||||||
|
# Паспорт куки: номера строк в справочниках устройств и городов.
|
||||||
|
device: NDArray[np.int64]
|
||||||
|
city: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@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)
|
active_cookie, active_day = _active_days(
|
||||||
pair_cookies, pair_order_days = _assign_orders(
|
rng, birth_day, day + world.RETURN_TAIL_DAYS
|
||||||
rng,
|
|
||||||
np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))),
|
|
||||||
visit_cookie,
|
|
||||||
visit_day,
|
|
||||||
cookies,
|
|
||||||
)
|
)
|
||||||
|
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(
|
return Cohort(
|
||||||
day=day,
|
day=day,
|
||||||
people=people,
|
people=people,
|
||||||
@@ -165,10 +206,12 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
birth_day=birth_day,
|
birth_day=birth_day,
|
||||||
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
||||||
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
|
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
|
||||||
visit_cookie=visit_cookie,
|
active_cookie=active_cookie,
|
||||||
visit_day=visit_day,
|
active_day=active_day,
|
||||||
pair_cookies=pair_cookies,
|
pair_cookies=pair_cookies,
|
||||||
pair_order_days=pair_order_days,
|
pair_order_days=pair_order_days,
|
||||||
|
device=device,
|
||||||
|
city=city,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -177,18 +220,18 @@ def audience(seed: int, day: int) -> DayAudience:
|
|||||||
if day < 0:
|
if day < 0:
|
||||||
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
||||||
|
|
||||||
client_id, buyer, assigned = [], [], []
|
|
||||||
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
||||||
for born in range(day - world.RETURN_TAIL_DAYS, day + 1):
|
parts = [
|
||||||
came, bought, ordered = cohort(seed, born).visitors_on(day)
|
cohort(seed, born).visitors_on(day)
|
||||||
client_id.append(came)
|
for born in range(day - world.RETURN_TAIL_DAYS, day + 1)
|
||||||
buyer.append(bought)
|
]
|
||||||
assigned.append(ordered)
|
|
||||||
return DayAudience(
|
return DayAudience(
|
||||||
day=day,
|
day=day,
|
||||||
client_id=np.concatenate(client_id),
|
client_id=np.concatenate([part.client_id for part in parts]),
|
||||||
buyer=np.concatenate(buyer),
|
buyer=np.concatenate([part.buyer for part in parts]),
|
||||||
assigned_order=np.concatenate(assigned),
|
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):
|
for born in range(-world.RETURN_TAIL_DAYS, days):
|
||||||
born_cohort = cohort(seed, born)
|
born_cohort = cohort(seed, born)
|
||||||
inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days)
|
inside = (born_cohort.active_day >= 0) & (born_cohort.active_day < days)
|
||||||
daily += np.bincount(born_cohort.visit_day[inside], minlength=days)
|
daily += np.bincount(born_cohort.active_day[inside], minlength=days)
|
||||||
seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])])
|
seen.append(born_cohort.client_id[np.unique(born_cohort.active_cookie[inside])])
|
||||||
appeared = born_cohort.birth_day[
|
appeared = born_cohort.birth_day[
|
||||||
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days)
|
(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))
|
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
|
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
|
||||||
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Дни визитов каждой куки: день рождения и возвраты, пока окно открыто."""
|
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
|
||||||
cookies = birth_day.size
|
cookies = birth_day.size
|
||||||
returns = np.zeros(cookies, dtype=np.int64)
|
returns = np.zeros(cookies, dtype=np.int64)
|
||||||
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
|
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)
|
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))
|
cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
|
||||||
when = np.concatenate((birth_day, birth_day[owner] + delay))
|
when = np.concatenate((birth_day, birth_day[owner] + delay))
|
||||||
|
|
||||||
@@ -247,7 +290,7 @@ def _visits(
|
|||||||
order = np.lexsort((when, cookie))
|
order = np.lexsort((when, cookie))
|
||||||
cookie, when = cookie[order], when[order]
|
cookie, when = cookie[order], when[order]
|
||||||
|
|
||||||
# Два возврата в один день — один визит: день у куки бывает только один.
|
# Два возврата в один день — один день активности: он у куки бывает один.
|
||||||
first_of_day = np.ones(cookie.size, dtype=bool)
|
first_of_day = np.ones(cookie.size, dtype=bool)
|
||||||
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
|
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
|
||||||
return cookie[first_of_day], when[first_of_day]
|
return cookie[first_of_day], when[first_of_day]
|
||||||
@@ -256,32 +299,49 @@ def _visits(
|
|||||||
def _assign_orders(
|
def _assign_orders(
|
||||||
rng: np.random.Generator,
|
rng: np.random.Generator,
|
||||||
pair_cookies: NDArray[np.int64],
|
pair_cookies: NDArray[np.int64],
|
||||||
visit_cookie: NDArray[np.int64],
|
active_cookie: NDArray[np.int64],
|
||||||
visit_day: NDArray[np.int64],
|
active_day: NDArray[np.int64],
|
||||||
cookies: int,
|
cookies: int,
|
||||||
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0.
|
"""Дни гарантированных заказов пары: по дню каждой из двух кук, от D0.
|
||||||
|
|
||||||
Человеку предыстории, у чьей куки визитов на оси не осталось, пара не
|
Человеку предыстории, у чьей куки дней на оси не осталось, пара не
|
||||||
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
||||||
берутся той же случайностью, что и всё остальное, — в данных условность
|
берутся той же случайностью, что и всё остальное, — в данных условность
|
||||||
не видна.
|
не видна.
|
||||||
"""
|
"""
|
||||||
on_axis = visit_day >= 0
|
on_axis = active_day >= 0
|
||||||
counts = np.bincount(visit_cookie[on_axis], minlength=cookies)
|
counts = np.bincount(active_cookie[on_axis], minlength=cookies)
|
||||||
# Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут
|
# Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0
|
||||||
# последними. Значит, дни на оси у куки — хвост её блока: от конца блока
|
# идут последними. Значит, дни на оси у куки — хвост её блока: от конца
|
||||||
# назад ровно `counts` визитов.
|
# блока назад ровно `counts` дней.
|
||||||
first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts
|
first_on_axis = np.searchsorted(active_cookie, np.arange(cookies), "right") - counts
|
||||||
|
|
||||||
assigned = np.all(counts[pair_cookies] > 0, axis=1)
|
assigned = np.all(counts[pair_cookies] > 0, axis=1)
|
||||||
pairs = pair_cookies[assigned]
|
pairs = pair_cookies[assigned]
|
||||||
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
|
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
|
||||||
return pairs, visit_day[chosen]
|
return pairs, active_day[chosen]
|
||||||
|
|
||||||
|
|
||||||
def _pick(
|
def _passports(
|
||||||
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int
|
rng: np.random.Generator, twins: NDArray[np.int64], cookies: int
|
||||||
) -> NDArray[np.int64]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел."""
|
"""Устройство и город каждой куки; у пары город один, устройства разные.
|
||||||
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
|
|
||||||
|
Выводить паспорт арифметикой из `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]",
|
numpy_dtype="datetime64[D]",
|
||||||
normalized_name="event_date",
|
normalized_name="event_date",
|
||||||
group=ColumnGroup.IDENTIFIERS,
|
group=ColumnGroup.IDENTIFIERS,
|
||||||
comment="дата события; по ней режется партиция",
|
comment="дата события в часовом поясе счётчика; по ней режется"
|
||||||
|
" партиция. Дату из `UTCEventTime` не выводить: у ночных событий"
|
||||||
|
" она на сутки другая",
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
name="UTCEventTime",
|
name="UTCEventTime",
|
||||||
@@ -108,7 +110,9 @@ COLUMNS: tuple[Column, ...] = (
|
|||||||
numpy_dtype="datetime64[s]",
|
numpy_dtype="datetime64[s]",
|
||||||
normalized_name="utc_event_time",
|
normalized_name="utc_event_time",
|
||||||
group=ColumnGroup.IDENTIFIERS,
|
group=ColumnGroup.IDENTIFIERS,
|
||||||
comment="время события в UTC — единственная метка времени, как у Метрики",
|
comment="время события в UTC — единственная метка времени, как у"
|
||||||
|
" Метрики; сутки же считаются в поясе счётчика, поэтому"
|
||||||
|
" `toDate(UTCEventTime)` ≠ `EventDate`",
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
name="ClientTimeZone",
|
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
|
from datetime import date
|
||||||
|
|
||||||
|
# Счётчик стенда: сайт один, номер — константа мира.
|
||||||
|
COUNTER_ID = 42150607
|
||||||
|
|
||||||
|
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
|
||||||
|
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
|
||||||
|
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
|
||||||
|
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
|
||||||
|
# у ночных событий (спека генератора, раздел 9).
|
||||||
|
COUNTER_TIMEZONE_MINUTES = 240
|
||||||
|
|
||||||
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||||
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||||
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||||
@@ -25,9 +35,11 @@ ORIGIN = date(2026, 6, 1)
|
|||||||
DAILY_INFLUX = 3_800
|
DAILY_INFLUX = 3_800
|
||||||
|
|
||||||
# Недельная волна мира, проценты от среднего: понедельник … воскресенье.
|
# Недельная волна мира, проценты от среднего: понедельник … воскресенье.
|
||||||
# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик
|
# Волна задана притоку — по ней в мир приходят новые люди. Трафик наследует
|
||||||
# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно
|
# её через дневную аудиторию, а не вторым умножением (довод — у суточной
|
||||||
# 700: за неделю средний день остаётся средним.
|
# волны выходного дня), поэтому доля новичков по дням недели ровная, а
|
||||||
|
# недельный размах трафика выходит мягче притока: ±7% против ±10%. В сумме
|
||||||
|
# ровно 700: за неделю средний день остаётся средним.
|
||||||
WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92)
|
WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92)
|
||||||
|
|
||||||
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
|
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
|
||||||
@@ -43,13 +55,13 @@ ONE_SHOT_PERCENT = 75
|
|||||||
# притоке 3 800 (спека, разделы 5 и 9).
|
# притоке 3 800 (спека, разделы 5 и 9).
|
||||||
RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1)
|
RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1)
|
||||||
|
|
||||||
# Хвост возвратов: окно активности человека от его первого визита, общее на
|
# Хвост возвратов: окно активности человека от его первого дня, общее на
|
||||||
# обе его куки. За краем окна кука не возвращается. Оно же — глубина
|
# обе его куки. За краем окна кука не возвращается. Оно же — глубина
|
||||||
# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на
|
# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на
|
||||||
# полке с самого первого дня.
|
# полке с самого первого дня.
|
||||||
RETURN_TAIL_DAYS = 90
|
RETURN_TAIL_DAYS = 90
|
||||||
|
|
||||||
# Профиль возвратов по дням от первого визита: почти всё в первую неделю,
|
# Профиль возвратов по дням от первого дня куки: почти всё в первую неделю,
|
||||||
# дальше тонкий хвост до края окна — повторные покупки в магазине случаются
|
# дальше тонкий хвост до края окна — повторные покупки в магазине случаются
|
||||||
# и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не
|
# и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не
|
||||||
# виден. Читается по парам «сколько дней — с каким весом»; дней в сумме
|
# виден. Читается по парам «сколько дней — с каким весом»; дней в сумме
|
||||||
@@ -69,3 +81,56 @@ BUYER_PERCENT = 5
|
|||||||
# Такой паре план назначает по гарантированному заказу с каждой куки — на
|
# Такой паре план назначает по гарантированному заказу с каждой куки — на
|
||||||
# этом стоит лаба про склейку личности.
|
# этом стоит лаба про склейку личности.
|
||||||
PAIRED_BUYER_PERCENT = 15
|
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
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Каталог товаров: форма файла, а не его длина.
|
||||||
|
|
||||||
|
Файл дорастает механически, поэтому ни один тест не считает его строки и
|
||||||
|
не знает ни одного артикула наизусть. Сторожится ровно то, на что опираются
|
||||||
|
генератор и словарь ClickHouse: колонки, вид артикула, известные категории,
|
||||||
|
целая цена.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from clickstream_generator import catalog
|
||||||
|
|
||||||
|
SKU = re.compile(r"^[A-Z]{4}-\d{4}$")
|
||||||
|
|
||||||
|
# Вилка цены, копейки: от сотни рублей до полумиллиона. Сторож от нуля,
|
||||||
|
# от минуса и от цены, случайно записанной в рублях.
|
||||||
|
PRICE_RANGE = (10_000, 50_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
def rows() -> list[dict[str, str]]:
|
||||||
|
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
|
||||||
|
return list(csv.DictReader(source))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_file_has_the_columns_the_dictionary_expects():
|
||||||
|
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
|
||||||
|
assert next(csv.reader(source)) == list(catalog.COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalog_is_not_empty():
|
||||||
|
assert rows()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_article_is_unique_and_named_by_its_category():
|
||||||
|
known = {category.name: category.prefix for category in catalog.CATEGORIES}
|
||||||
|
articles = set()
|
||||||
|
for row in rows():
|
||||||
|
sku = row["sku"]
|
||||||
|
assert SKU.match(sku), sku
|
||||||
|
assert sku.split("-")[0] == known[row["category"]], sku
|
||||||
|
articles.add(sku)
|
||||||
|
assert len(articles) == len(rows())
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_row_is_filled_and_priced_in_whole_kopecks():
|
||||||
|
low, high = PRICE_RANGE
|
||||||
|
for row in rows():
|
||||||
|
assert row["name"].strip()
|
||||||
|
assert row["brand"].strip()
|
||||||
|
assert row["price"].isdigit(), row["price"]
|
||||||
|
assert low <= int(row["price"]) <= high, row["sku"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_category_of_the_assortment_is_covered():
|
||||||
|
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
|
||||||
|
present = {row["category"] for row in rows()}
|
||||||
|
assert present == {category.name for category in catalog.CATEGORIES}
|
||||||
|
|
||||||
|
|
||||||
|
def test_categories_are_told_apart_by_prefix_and_by_address():
|
||||||
|
prefixes = {category.prefix for category in catalog.CATEGORIES}
|
||||||
|
slugs = {category.slug for category in catalog.CATEGORIES}
|
||||||
|
assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalog_is_grouped_by_category_whatever_the_file_order():
|
||||||
|
goods = catalog.catalog()
|
||||||
|
assert goods.count.sum() == goods.sku.size
|
||||||
|
for number in range(len(catalog.CATEGORIES)):
|
||||||
|
first = goods.first[number]
|
||||||
|
rows_here = goods.grouped[first : first + goods.count[number]]
|
||||||
|
assert np.all(goods.category[rows_here] == number)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalog_is_read_once():
|
||||||
|
assert catalog.catalog() is catalog.catalog()
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
|
||||||
|
|
||||||
|
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||||
|
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||||||
|
день ~50 тыс. событий», «ночью провал, вечером пик»), а не при каждой правке.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import catalog, day, plan, reference, schema, world
|
||||||
|
from clickstream_generator.reference import Page
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
WEEKDAY = 2
|
||||||
|
WEEKEND = 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_memo():
|
||||||
|
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def weekday() -> day.Day:
|
||||||
|
"""Один буднийный день на весь модуль: пересчитывать его тестам незачем."""
|
||||||
|
return day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
|
|
||||||
|
|
||||||
|
def readable(column: NDArray[Any]) -> list[Any]:
|
||||||
|
"""Колонка в сравнимом виде: массив внутри ячейки — в список."""
|
||||||
|
if column.dtype == object:
|
||||||
|
return [
|
||||||
|
cell.tolist() if isinstance(cell, np.ndarray) else cell for cell in column
|
||||||
|
]
|
||||||
|
return column.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(events: day.Day) -> dict[str, Any]:
|
||||||
|
"""Слепок дня для сравнений: всё, что день отдал наружу.
|
||||||
|
|
||||||
|
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
|
||||||
|
без оглядки на него, а порядок — часть обещания (он же порядок
|
||||||
|
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и
|
||||||
|
сторожить их надо тем же слепком, а не отдельной памятью.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"order": list(events.columns),
|
||||||
|
"values": [readable(column) for column in events.columns.values()],
|
||||||
|
"page": events.page.tolist(),
|
||||||
|
"product": events.product.tolist(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def local_seconds(events: day.Day) -> NDArray[np.int64]:
|
||||||
|
"""Секунда события внутри модельных суток — в поясе счётчика."""
|
||||||
|
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(events.day, "D")
|
||||||
|
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
|
||||||
|
return (events.columns["UTCEventTime"] - midnight + away).astype("int64")
|
||||||
|
|
||||||
|
|
||||||
|
def hourly(events: day.Day) -> NDArray[np.int64]:
|
||||||
|
return np.bincount(local_seconds(events) // 3600, minlength=24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
||||||
|
"""Сторож сторожей: слепок обязан замечать порядок колонок и швы.
|
||||||
|
|
||||||
|
Слепок слепой к чему-нибудь — это три зелёных теста детерминизма при
|
||||||
|
поехавшем выводе, а не одна пропущенная мелочь.
|
||||||
|
"""
|
||||||
|
original = snapshot(weekday)
|
||||||
|
reordered = day.Day(
|
||||||
|
day=weekday.day,
|
||||||
|
columns=dict(reversed(list(weekday.columns.items()))),
|
||||||
|
page=weekday.page,
|
||||||
|
product=weekday.product,
|
||||||
|
)
|
||||||
|
assert snapshot(reordered) != original
|
||||||
|
|
||||||
|
moved = weekday.product.copy()
|
||||||
|
moved[0] += 1
|
||||||
|
assert snapshot(replace(weekday, product=moved)) != original
|
||||||
|
shifted = weekday.page.copy()
|
||||||
|
shifted[0] += 1
|
||||||
|
assert snapshot(replace(weekday, page=shifted)) != original
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
|
||||||
|
first = snapshot(day.stream(CANONICAL_SEED, 3))
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
assert snapshot(day.stream(CANONICAL_SEED, 3)) == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_day_generated_alone_is_the_same_day():
|
||||||
|
"""День D не зависит от того, прожиты ли дни до него."""
|
||||||
|
alone = snapshot(day.stream(CANONICAL_SEED, 3))
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
for earlier in range(3):
|
||||||
|
day.stream(CANONICAL_SEED, earlier)
|
||||||
|
assert snapshot(day.stream(CANONICAL_SEED, 3)) == alone
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_next_day_does_not_move_the_days_before_it():
|
||||||
|
before = [snapshot(day.stream(CANONICAL_SEED, number)) for number in range(2)]
|
||||||
|
day.stream(CANONICAL_SEED, 2)
|
||||||
|
assert [snapshot(day.stream(CANONICAL_SEED, n)) for n in range(2)] == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_seed_is_another_day():
|
||||||
|
ours = snapshot(day.stream(CANONICAL_SEED, 1))
|
||||||
|
assert snapshot(day.stream(CANONICAL_SEED + 1, 1)) != ours
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_do_not_start_before_the_origin():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
day.stream(CANONICAL_SEED, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
|
||||||
|
"""Состав колонок решает контракт схемы; день обязан отдать их все."""
|
||||||
|
assert list(weekday.columns) == [column.name for column in schema.COLUMNS]
|
||||||
|
for column in schema.COLUMNS:
|
||||||
|
values = weekday.columns[column.name]
|
||||||
|
assert values.size == len(weekday), column.name
|
||||||
|
if column.clickhouse_type.startswith("Array("):
|
||||||
|
cells = {cell.dtype for cell in values[:1000]}
|
||||||
|
assert cells == {np.dtype(column.numpy_dtype)}, column.name
|
||||||
|
else:
|
||||||
|
assert values.dtype == np.dtype(column.numpy_dtype), column.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day):
|
||||||
|
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
|
||||||
|
|
||||||
|
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
|
||||||
|
#40 добавит свои, и они должны попасть под тот же сторож.
|
||||||
|
"""
|
||||||
|
waiting = [
|
||||||
|
column
|
||||||
|
for column in schema.COLUMNS
|
||||||
|
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
|
||||||
|
]
|
||||||
|
assert len(waiting) > 10
|
||||||
|
for column in waiting:
|
||||||
|
cells = weekday.columns[column.name][:1000]
|
||||||
|
if column.clickhouse_type.startswith("Array("):
|
||||||
|
assert all(cell.size == 0 for cell in cells), column.name
|
||||||
|
else:
|
||||||
|
assert set(cells) == {""}, column.name
|
||||||
|
assert set(weekday.columns["EventType"].tolist()) == {"pageview"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_column_hides_a_hole(weekday: day.Day):
|
||||||
|
"""`None` в колонке — та же пропажа ключа, только позже и незаметнее."""
|
||||||
|
for column in schema.COLUMNS:
|
||||||
|
values = weekday.columns[column.name]
|
||||||
|
if values.dtype != object:
|
||||||
|
continue
|
||||||
|
kind = np.ndarray if column.clickhouse_type.startswith("Array(") else str
|
||||||
|
assert all(isinstance(cell, kind) for cell in values[:1000]), column.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_identifiers_survive_json(weekday: day.Day):
|
||||||
|
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
|
||||||
|
for name in ("WatchID", "VisitID", "ClientID"):
|
||||||
|
assert weekday.columns[name].max() < day.ID_LIMIT
|
||||||
|
assert weekday.columns[name].dtype == np.uint64
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_event_id_is_unique_because_it_deduplicates(weekday: day.Day):
|
||||||
|
"""`WatchID` — ключ склейки при переигровке дня (спека, раздел 4)."""
|
||||||
|
watch = weekday.columns["WatchID"]
|
||||||
|
assert len(set(watch.tolist())) == watch.size
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_visit_belongs_to_one_cookie(weekday: day.Day):
|
||||||
|
visit = weekday.columns["VisitID"]
|
||||||
|
cookie = weekday.columns["ClientID"]
|
||||||
|
pairs = set(zip(visit.tolist(), cookie.tolist(), strict=True))
|
||||||
|
assert len(pairs) == len(set(visit.tolist()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_visit_cutting_rules_rebuild_the_generator_visits(weekday: day.Day):
|
||||||
|
"""Правило лабы: та же кука, пауза не длиннее таймаута — тот же визит.
|
||||||
|
|
||||||
|
Сборка сессий по правилам из докстринга модуля обязана совпасть с
|
||||||
|
`VisitID` — на этой сверке стоит лаба сессий.
|
||||||
|
"""
|
||||||
|
second = local_seconds(weekday)
|
||||||
|
cookie = weekday.columns["ClientID"]
|
||||||
|
order = np.lexsort((second, cookie))
|
||||||
|
cookie, second = cookie[order], second[order]
|
||||||
|
visit = weekday.columns["VisitID"][order]
|
||||||
|
|
||||||
|
started = np.ones(visit.size, dtype=bool)
|
||||||
|
started[1:] = (cookie[1:] != cookie[:-1]) | (
|
||||||
|
second[1:] - second[:-1] > world.VISIT_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
rebuilt = np.cumsum(started)
|
||||||
|
# Совпадение обоюдное: сколько пар «собранный визит — `VisitID`», столько
|
||||||
|
# же и тех, и других. Одного равенства мало — оно ловит только склейку
|
||||||
|
# двух визитов в один, а разрыв одного визита надвое проходит мимо.
|
||||||
|
matched = set(zip(rebuilt.tolist(), visit.tolist(), strict=True))
|
||||||
|
assert len(matched) == int(rebuilt.max())
|
||||||
|
assert len(matched) == len(set(visit.tolist()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_day_boundary_cuts_the_visits(weekday: day.Day):
|
||||||
|
"""Событий за границей модельных суток в дне нет — партиция дня целая."""
|
||||||
|
second = local_seconds(weekday)
|
||||||
|
assert second.min() >= 0
|
||||||
|
assert second.max() < day.DAY_SECONDS
|
||||||
|
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(weekday.day, "D")
|
||||||
|
assert set(weekday.columns["EventDate"].tolist()) == {date.astype("O")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
|
||||||
|
"""`toDate(UTCEventTime)` ≠ `EventDate`: сутки считаются в поясе счётчика."""
|
||||||
|
utc_date = weekday.columns["UTCEventTime"].astype("datetime64[D]")
|
||||||
|
assert np.any(utc_date != weekday.columns["EventDate"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
|
||||||
|
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
|
||||||
|
assert 30_000 < len(weekday) < 70_000
|
||||||
|
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||||
|
assert 6_000 < visits < 14_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_wave_dips_at_night_and_peaks_in_the_evening(weekday: day.Day):
|
||||||
|
counters = hourly(weekday)
|
||||||
|
average = counters.mean()
|
||||||
|
assert counters[2:6].max() < average / 2
|
||||||
|
assert counters[18:22].max() > 1.5 * average
|
||||||
|
assert 0 <= int(counters.argmin()) <= 6
|
||||||
|
assert 11 <= int(counters.argmax()) <= 22
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
|
||||||
|
"""Форма, а не объём: выходной раскачивается позже буднего."""
|
||||||
|
weekend = hourly(day.stream(CANONICAL_SEED, WEEKEND))
|
||||||
|
workday = hourly(weekday)
|
||||||
|
morning = slice(7, 10)
|
||||||
|
assert weekend[morning].sum() / weekend.sum() < (
|
||||||
|
workday[morning].sum() / workday.sum()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
|
||||||
|
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||||
|
ordered = int((weekday.page == Page.CONFIRMATION).sum())
|
||||||
|
assert 0.01 < ordered / visits < 0.04
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
|
||||||
|
"""Гарантия двухкуковых пар: назначенный планом заказ обязан состояться."""
|
||||||
|
audience = plan.audience(CANONICAL_SEED, weekday.day)
|
||||||
|
promised = set(audience.client_id[audience.assigned_order].tolist())
|
||||||
|
assert promised
|
||||||
|
confirmed = weekday.columns["ClientID"][weekday.page == Page.CONFIRMATION]
|
||||||
|
assert promised <= set(confirmed.tolist())
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
|
||||||
|
"""Шов для #40: товар в корзине посетитель до того открывал."""
|
||||||
|
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
||||||
|
by_visit = weekday.page[order]
|
||||||
|
carts = np.flatnonzero(by_visit == Page.CART)
|
||||||
|
assert carts.size > 0
|
||||||
|
assert np.all(by_visit[carts - 1] == Page.PRODUCT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
|
||||||
|
"""`product` — товар карточки и −1 у прочих страниц; больше ничего."""
|
||||||
|
goods = catalog.catalog()
|
||||||
|
card = weekday.page == Page.PRODUCT
|
||||||
|
assert np.all(weekday.product[~card] == -1)
|
||||||
|
assert np.all(weekday.product[card] >= 0)
|
||||||
|
assert np.all(weekday.product[card] < goods.sku.size)
|
||||||
|
shown = zip(weekday.columns["URL"][card], weekday.product[card], strict=True)
|
||||||
|
for url, number in list(shown)[:200]:
|
||||||
|
# У страницы входа в адресе ещё метки перехода — путь до знака «?».
|
||||||
|
assert url.split("?")[0].endswith(f"/product/{goods.sku[number]}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_referer_is_the_page_before(weekday: day.Day):
|
||||||
|
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника."""
|
||||||
|
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
||||||
|
url = weekday.columns["URL"][order]
|
||||||
|
referer = weekday.columns["Referer"][order]
|
||||||
|
visit = weekday.columns["VisitID"][order]
|
||||||
|
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
|
||||||
|
assert np.all(referer[inside] == url[inside - 1])
|
||||||
|
|
||||||
|
entry = np.flatnonzero(visit[1:] != visit[:-1]) + 1
|
||||||
|
known = {source.referer for source in reference.TRAFFIC_SOURCES}
|
||||||
|
assert set(referer[entry].tolist()) <= known
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_passport_of_a_cookie_does_not_change_between_days():
|
||||||
|
"""Кука — браузер на устройстве: во всех её днях профиль и город одни."""
|
||||||
|
passports: dict[int, tuple[Any, ...]] = {}
|
||||||
|
for number in (0, 1, 2):
|
||||||
|
events = day.stream(CANONICAL_SEED, number)
|
||||||
|
seen = zip(
|
||||||
|
events.columns["ClientID"].tolist(),
|
||||||
|
events.columns["Browser"].tolist(),
|
||||||
|
events.columns["ScreenWidth"].tolist(),
|
||||||
|
events.columns["RegionCity"].tolist(),
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
for cookie, *passport in seen:
|
||||||
|
assert passports.setdefault(cookie, tuple(passport)) == tuple(passport)
|
||||||
|
|
||||||
|
|
||||||
|
def test_geography_is_one_region_of_presence(weekday: day.Day):
|
||||||
|
"""Магазин с одним складом: свой миллионник, свой регион, тонкий хвост."""
|
||||||
|
cities = weekday.columns["RegionCity"]
|
||||||
|
assert (cities == "Samara").mean() > 0.25
|
||||||
|
assert set(weekday.columns["RegionCountry"].tolist()) == {reference.COUNTRY_NAME}
|
||||||
|
assert set(weekday.columns["RegionCountryID"].tolist()) == {
|
||||||
|
reference.COUNTRY_REGION_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_addresses_are_not_routable(weekday: day.Day):
|
||||||
|
"""Правдоподобные публичные адреса принадлежат живым организациям."""
|
||||||
|
fixed = ("192.0.2.", "198.51.100.", "203.0.113.", "198.18.")
|
||||||
|
|
||||||
|
def cgnat(address: str) -> bool:
|
||||||
|
first, second, *_ = (int(byte) for byte in address.split("."))
|
||||||
|
return first == reference.MOBILE_IP_FIRST_BYTE and 64 <= second < 128
|
||||||
|
|
||||||
|
for address in set(weekday.columns["IPAddress"].tolist()):
|
||||||
|
assert address.startswith(fixed) or cgnat(address), address
|
||||||
|
|
||||||
|
|
||||||
|
def test_phones_carry_a_model_and_desktops_do_not(weekday: day.Day):
|
||||||
|
phone = weekday.columns["DeviceCategory"] == reference.PHONE_CATEGORY
|
||||||
|
assert phone.mean() > 0.5
|
||||||
|
assert np.all(weekday.columns["MobilePhoneModel"][~phone] == "")
|
||||||
|
assert np.all(weekday.columns["MobilePhoneModel"][phone] != "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_randomness_is_drawn_in_whole_numbers():
|
||||||
|
"""Дисциплина спеки (раздел 2): целые числа, никакой системной математики."""
|
||||||
|
source = inspect.getsource(day)
|
||||||
|
assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"}
|
||||||
|
assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE)
|
||||||
@@ -11,7 +11,7 @@ import re
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from clickstream_generator import plan, world
|
from clickstream_generator import plan, reference, world
|
||||||
from clickstream_generator.seeds import CANONICAL_SEED
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
||||||
@@ -24,9 +24,9 @@ def fresh_memo():
|
|||||||
plan.cohort.cache_clear()
|
plan.cohort.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
||||||
"""Таблица визитов парами «кука — день», как её видит день-функция."""
|
"""Таблица парами «кука — день активности», как её видит день-функция."""
|
||||||
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist()
|
cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist()
|
||||||
return list(zip(cookies, days, strict=True))
|
return list(zip(cookies, days, strict=True))
|
||||||
|
|
||||||
|
|
||||||
@@ -37,10 +37,12 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
|
|||||||
and np.array_equal(left.client_id, right.client_id)
|
and np.array_equal(left.client_id, right.client_id)
|
||||||
and np.array_equal(left.buyer, right.buyer)
|
and np.array_equal(left.buyer, right.buyer)
|
||||||
and np.array_equal(left.birth_day, right.birth_day)
|
and np.array_equal(left.birth_day, right.birth_day)
|
||||||
and np.array_equal(left.visit_cookie, right.visit_cookie)
|
and np.array_equal(left.active_cookie, right.active_cookie)
|
||||||
and np.array_equal(left.visit_day, right.visit_day)
|
and np.array_equal(left.active_day, right.active_day)
|
||||||
and np.array_equal(left.pair_cookies, right.pair_cookies)
|
and np.array_equal(left.pair_cookies, right.pair_cookies)
|
||||||
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
||||||
|
and np.array_equal(left.device, right.device)
|
||||||
|
and np.array_equal(left.city, right.city)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,25 +94,25 @@ def test_events_do_not_start_before_the_origin():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
|
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
|
||||||
def test_visits_stay_inside_the_activity_window(day: int):
|
def test_active_days_stay_inside_the_activity_window(day: int):
|
||||||
"""Окно активности — хвост возвратов от первого визита человека."""
|
"""Окно активности — хвост возвратов от первого дня человека."""
|
||||||
cohort = plan.cohort(CANONICAL_SEED, day)
|
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||||
assert cohort.visit_day.min() == day
|
assert cohort.active_day.min() == day
|
||||||
assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS
|
assert cohort.active_day.max() <= day + world.RETURN_TAIL_DAYS
|
||||||
|
|
||||||
|
|
||||||
def test_every_cookie_visits_on_the_day_it_was_born():
|
def test_every_cookie_is_active_on_the_day_it_was_born():
|
||||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
cookies = range(cohort.client_id.size)
|
cookies = range(cohort.client_id.size)
|
||||||
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
|
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
|
||||||
assert set(born) <= set(visits_of(cohort))
|
assert set(born) <= set(active_days_of(cohort))
|
||||||
|
|
||||||
|
|
||||||
def test_a_cookie_visits_a_day_once():
|
def test_a_cookie_gets_one_active_day_at_a_time():
|
||||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
visits = visits_of(cohort)
|
days = active_days_of(cohort)
|
||||||
assert visits == sorted(visits)
|
assert days == sorted(days)
|
||||||
assert len(set(visits)) == len(visits)
|
assert len(set(days)) == len(days)
|
||||||
|
|
||||||
|
|
||||||
def test_client_ids_are_unique_and_survive_json():
|
def test_client_ids_are_unique_and_survive_json():
|
||||||
@@ -144,16 +146,44 @@ def test_pairs_are_the_agreed_share_of_buyers():
|
|||||||
|
|
||||||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
|
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
|
||||||
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
|
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
|
||||||
"""Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0."""
|
"""Гарантия двухкуковых: заказ назначен на день активности куки, от D0."""
|
||||||
cohort = plan.cohort(CANONICAL_SEED, day)
|
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||||
visits = set(visits_of(cohort))
|
days_of = set(active_days_of(cohort))
|
||||||
for cookies, days in zip(
|
for cookies, days in zip(
|
||||||
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
|
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
|
||||||
):
|
):
|
||||||
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
|
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
|
||||||
for cookie, order_day in zip(cookies, days, strict=True):
|
for cookie, order_day in zip(cookies, days, strict=True):
|
||||||
assert order_day >= 0
|
assert order_day >= 0
|
||||||
assert (cookie, order_day) in visits
|
assert (cookie, order_day) in days_of
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_passport_of_a_pair_is_one_person_with_two_devices():
|
||||||
|
"""Два города у одного человека — ложь в данных (спека, раздел 9)."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||||||
|
assert np.all(cohort.city[first] == cohort.city[second])
|
||||||
|
assert np.all(cohort.device[first] != cohort.device[second])
|
||||||
|
|
||||||
|
# Обещано не просто «разные строки справочника», а разный род устройства:
|
||||||
|
# ровно одно из двух — телефон, второе десктоп или планшет. Пара «телефон
|
||||||
|
# и ноутбук» мастер-спеки (раздел 5) — про это, а не про запрет планшета.
|
||||||
|
category = np.array([row.category for row in reference.DEVICE_PROFILES])
|
||||||
|
kinds = category[cohort.device[first]], category[cohort.device[second]]
|
||||||
|
assert np.all(kinds[0] != kinds[1])
|
||||||
|
phone = category == reference.PHONE_CATEGORY
|
||||||
|
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_passport_points_into_the_directories():
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
for passport, table in (
|
||||||
|
(cohort.device, reference.DEVICE_PROFILES),
|
||||||
|
(cohort.city, reference.CITIES),
|
||||||
|
):
|
||||||
|
assert passport.size == cohort.client_id.size
|
||||||
|
assert passport.min() >= 0
|
||||||
|
assert passport.max() < len(table)
|
||||||
|
|
||||||
|
|
||||||
def test_the_daily_audience_matches_the_spec_band():
|
def test_the_daily_audience_matches_the_spec_band():
|
||||||
@@ -244,8 +274,8 @@ def test_plan_arrays_are_whole_numbers():
|
|||||||
for array in (
|
for array in (
|
||||||
cohort.client_id,
|
cohort.client_id,
|
||||||
cohort.birth_day,
|
cohort.birth_day,
|
||||||
cohort.visit_cookie,
|
cohort.active_cookie,
|
||||||
cohort.visit_day,
|
cohort.active_day,
|
||||||
cohort.pair_cookies,
|
cohort.pair_cookies,
|
||||||
cohort.pair_order_days,
|
cohort.pair_order_days,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Сторожа справочников: связки, из-за которых таблицы написаны руками.
|
||||||
|
|
||||||
|
Тесты держат не содержание строк — его менти волен менять, — а то, на чём
|
||||||
|
стоит день-функция: доли складываются в сотню, вход и переходы разложены по
|
||||||
|
страницам блуждания, устройство не противоречит само себе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clickstream_generator import reference
|
||||||
|
|
||||||
|
|
||||||
|
def test_shares_of_every_directory_add_up_to_a_hundred():
|
||||||
|
"""Веса — проценты: выбор по ним целочисленный и без остатка."""
|
||||||
|
for table in (
|
||||||
|
reference.DEVICE_PROFILES,
|
||||||
|
reference.CITIES,
|
||||||
|
reference.TRAFFIC_SOURCES,
|
||||||
|
):
|
||||||
|
assert sum(row.weight for row in table) == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_walking_the_site_never_leaves_the_browsing_pages():
|
||||||
|
for percent in (reference.FROM_PRODUCT_PERCENT, reference.FROM_LISTING_PERCENT):
|
||||||
|
assert len(percent) == len(reference.BROWSE_PAGES)
|
||||||
|
assert sum(percent) == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_visitor_comes_to_a_page_that_exists():
|
||||||
|
for source in reference.TRAFFIC_SOURCES:
|
||||||
|
assert len(source.entry_percent) == len(reference.BROWSE_PAGES)
|
||||||
|
assert sum(source.entry_percent) == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_prefers_the_product_card():
|
||||||
|
"""Иначе магазин выглядел бы каталогом, который никто не открывает."""
|
||||||
|
card = reference.BROWSE_PAGES.index(reference.Page.PRODUCT)
|
||||||
|
assert reference.FROM_LISTING_PERCENT[card] > 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_phone_carries_a_model_and_a_desktop_does_not():
|
||||||
|
for profile in reference.DEVICE_PROFILES:
|
||||||
|
phone = profile.category == reference.PHONE_CATEGORY
|
||||||
|
assert bool(profile.phone_model) == phone
|
||||||
|
assert profile.screen_width > 0 and profile.screen_height > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_traffic_outweighs_the_desktop():
|
||||||
|
"""Российская розница мобильная; на этом стоят доли устройств."""
|
||||||
|
phones = sum(
|
||||||
|
row.weight
|
||||||
|
for row in reference.DEVICE_PROFILES
|
||||||
|
if row.category == reference.PHONE_CATEGORY
|
||||||
|
)
|
||||||
|
assert phones > 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_cities_are_told_apart_by_id_and_by_address_block():
|
||||||
|
assert len({city.region_id for city in reference.CITIES}) == len(reference.CITIES)
|
||||||
|
assert len({city.ip_prefix for city in reference.CITIES}) == len(reference.CITIES)
|
||||||
|
assert reference.COUNTRY_REGION_ID not in {c.region_id for c in reference.CITIES}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_region_of_presence_outweighs_the_rest_of_the_country():
|
||||||
|
"""Один регион присутствия, а не «топ городов России» (спека, раздел 9)."""
|
||||||
|
home = reference.CITIES[0]
|
||||||
|
assert home.name == "Samara"
|
||||||
|
assert home.weight > 30
|
||||||
|
nearby = sum(
|
||||||
|
city.weight
|
||||||
|
for city in reference.CITIES
|
||||||
|
if city.timezone_minutes == home.timezone_minutes
|
||||||
|
)
|
||||||
|
assert nearby > 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_timezones_are_whole_hours():
|
||||||
|
"""Волна поворачивается на целые часы: получасовых поясов в мире нет."""
|
||||||
|
for city in reference.CITIES:
|
||||||
|
assert city.timezone_minutes % 60 == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_paid_sources_carry_their_click_labels():
|
||||||
|
for source in reference.TRAFFIC_SOURCES:
|
||||||
|
paid = source.last_traffic_source == "ad"
|
||||||
|
assert bool(source.utm_source) >= paid
|
||||||
|
assert (source.has_gclid or source.has_yclid) == paid
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_sources_carry_no_utm():
|
||||||
|
"""Метки ставит тот, кто платит: у organic и direct их не бывает."""
|
||||||
|
for source in reference.TRAFFIC_SOURCES:
|
||||||
|
if source.last_traffic_source in ("organic", "direct", "recommend"):
|
||||||
|
assert not source.utm_source
|
||||||
@@ -64,3 +64,56 @@ def test_most_returns_land_in_the_first_days():
|
|||||||
def test_pairs_are_a_small_part_of_the_cohort():
|
def test_pairs_are_a_small_part_of_the_cohort():
|
||||||
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
|
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
|
||||||
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100
|
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_daily_waves_cover_a_day_and_average_to_one():
|
||||||
|
"""Форма волны не меняет суточный объём: его задаёт недельный профиль."""
|
||||||
|
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
|
||||||
|
assert len(profile) == 24
|
||||||
|
assert sum(profile) == 2400
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_wave_dips_at_night_and_peaks_up_to_twice_the_average():
|
||||||
|
"""Пики до ~2× среднего, ночью провал (спека, раздел 2)."""
|
||||||
|
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
|
||||||
|
assert min(profile[2:6]) < 50
|
||||||
|
assert 150 < max(profile) <= 220
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_weekend_wakes_up_later_than_a_weekday():
|
||||||
|
morning = slice(7, 10)
|
||||||
|
assert sum(world.WEEKEND_HOURS_PERCENT[morning]) < sum(
|
||||||
|
world.WEEKDAY_HOURS_PERCENT[morning]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_active_day_holds_a_visit_or_two():
|
||||||
|
"""Дневная аудитория 6–8 тыс. и 8–12 тыс. визитов сходятся через это число."""
|
||||||
|
weights = world.VISITS_PER_ACTIVE_DAY_WEIGHTS
|
||||||
|
counts = tuple(range(1, len(weights) + 1))
|
||||||
|
assert 1.2 <= mean_by_weights(counts, weights) <= 1.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_visit_is_a_few_pages_long_and_often_a_single_one():
|
||||||
|
weights = world.VISIT_PAGES_WEIGHTS
|
||||||
|
pages = tuple(range(1, len(weights) + 1))
|
||||||
|
assert 4 <= mean_by_weights(pages, weights) <= 6
|
||||||
|
bounced = weights[0] / sum(weights)
|
||||||
|
assert 0.15 < bounced < 0.35
|
||||||
|
|
||||||
|
|
||||||
|
def test_pauses_stay_inside_the_visit_timeout():
|
||||||
|
"""Иначе визит распался бы там, где генератор этого не обещал."""
|
||||||
|
assert max(world.PAGE_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
|
||||||
|
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_funnel_converts_about_two_percent_of_visits():
|
||||||
|
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
|
||||||
|
conversion = (
|
||||||
|
world.CART_PERCENT
|
||||||
|
* world.CHECKOUT_OF_CART_PERCENT
|
||||||
|
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
|
||||||
|
/ 100**2
|
||||||
|
)
|
||||||
|
assert 1.5 <= conversion <= 2.5
|
||||||
|
|||||||
Reference in New Issue
Block a user