Торговые события генератора: корзина, покупка и уровень спроса товара #51

Merged
ddmitry merged 8 commits from feat/40-trade-events into main 2026-08-02 22:59:28 +03:00
21 changed files with 2385 additions and 324 deletions
+18
View File
@@ -63,15 +63,33 @@ _Избегать_: визит (визит — про сессию)
устройстве. Держит их план состава, разворачивает в поля события
день-функция; у двухкуковой пары город один на две куки, устройства разные.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
**Торговое событие**:
Событие корзины или покупки — отдельная строка потока, а не просмотр
страницы. Садится на ту страницу, где случилось: корзина — на карточку
товара, покупка — на страницу подтверждения заказа.
**Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Уровень спроса**:
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
брендам различимой.
**Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного
первого дня D0; реальный календарь в модели не участвует. Между прогонами
+181 -181
View File
@@ -1,181 +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
sku,name,category,brand,price,demand
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000,обычный
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900,обычный
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990,магнит
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000,магнит
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990,обычный
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000,залёживается
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000,обычный
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990,залёживается
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990,залёживается
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999,магнит
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990,магнит
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000,магнит
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990,обычный
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000,залёживается
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000,обычный
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000,залёживается
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000,магнит
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990,магнит
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999,обычный
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990,обычный
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000,магнит
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000,залёживается
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000,магнит
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000,обычный
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990,залёживается
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000,обычный
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000,залёживается
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990,залёживается
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990,обычный
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000,обычный
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000,магнит
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990,магнит
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000,магнит
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000,обычный
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990,залёживается
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000,магнит
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990,обычный
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000,обычный
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000,обычный
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990,залёживается
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999,обычный
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000,обычный
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990,магнит
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000,обычный
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000,магнит
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000,обычный
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990,обычный
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000,обычный
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000,обычный
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990,обычный
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000,залёживается
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000,обычный
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990,обычный
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000,магнит
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999,обычный
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990,обычный
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 штуки,Текстиль,Ладный дом,108990,обычный
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999,магнит
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000,обычный
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000,магнит
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990,магнит
TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000,залёживается
TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999,обычный
TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000,обычный
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000,обычный
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990,обычный
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990,залёживается
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000,магнит
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000,обычный
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000,обычный
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990,магнит
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000,залёживается
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000,залёживается
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990,залёживается
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000,залёживается
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000,обычный
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990,магнит
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990,обычный
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990,обычный
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000,залёживается
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990,залёживается
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999,обычный
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000,обычный
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999,обычный
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990,обычный
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990,обычный
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000,обычный
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000,залёживается
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000,залёживается
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000,магнит
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990,обычный
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990,обычный
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990,обычный
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000,магнит
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000,магнит
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000,залёживается
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999,обычный
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 кг,Бытовая техника,Электрон,198990,залёживается
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990,обычный
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000,обычный
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990,магнит
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999,обычный
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990,залёживается
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000,магнит
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990,обычный
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000,залёживается
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000,обычный
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990,залёживается
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000,обычный
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990,магнит
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999,обычный
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000,обычный
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990,залёживается
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000,магнит
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000,магнит
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000,залёживается
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990,обычный
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990,обычный
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000,обычный
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990,обычный
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999,залёживается
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000,обычный
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000,обычный
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000,обычный
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000,залёживается
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999,обычный
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000,залёживается
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990,обычный
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990,залёживается
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990,обычный
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990,магнит
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990,обычный
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999,обычный
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000,залёживается
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990,залёживается
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000,обычный
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999,обычный
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000,залёживается
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000,обычный
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990,обычный
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990,магнит
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000,обычный
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990,обычный
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990,залёживается
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000,обычный
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000,обычный
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990,обычный
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990,залёживается
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000,обычный
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999,залёживается
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000,обычный
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000,обычный
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000,залёживается
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000,магнит
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990,обычный
1 sku name category brand price demand
2 HOME-0001 Комплект органайзеров для шкафа Товары для дома Домовит 129000 обычный
3 HOME-0002 Сушилка для белья напольная Товары для дома Домовит 249900 обычный
4 HOME-0003 Настольная лампа «Тихий вечер» Товары для дома Ладный дом 189000 188990 магнит
5 HOME-0004 Набор вешалок 12 штук Товары для дома Домовит 79000 магнит
6 TEXT-0001 Комплект постельного белья полутораспальный Текстиль Сатин 349000 348990 обычный
7 TEXT-0002 Плед-покрывало 200×220 Текстиль Сатин 279000 залёживается
8 TEXT-0003 Полотенце банное 70×140 Текстиль Сатин 89000 обычный
9 TEXT-0004 Штора блэкаут 200×260 Текстиль Ладный дом 219000 218990 залёживается
10 POSU-0001 Сковорода 26 см с антипригарным покрытием Посуда Кухмастер 199000 198990 залёживается
11 POSU-0002 Набор кастрюль 3 предмета Посуда Кухмастер 459000 458999 магнит
12 POSU-0003 Чайник заварочный стеклянный 1 л Посуда Кухмастер 119000 118990 магнит
13 POSU-0004 Набор столовых приборов 24 предмета Посуда Ладья 289000 магнит
14 TECH-0001 Электрочайник 1.7 л Бытовая техника Электрон 189000 188990 обычный
15 TECH-0002 Блендер погружной 800 Вт Бытовая техника Электрон 279000 залёживается
16 TECH-0003 Отпариватель ручной Бытовая техника Электрон 349000 обычный
17 TECH-0004 Робот-пылесос Бытовая техника Электрон 1899000 залёживается
18 KIDS-0001 Стульчик для кормления Детские товары Малышок 649000 магнит
19 KIDS-0002 Ночник-проектор «Звёзды» Детские товары Малышок 129000 128990 магнит
20 KIDS-0003 Набор детской посуды 5 предметов Детские товары Малышок 89000 88999 обычный
21 WEAR-0001 Тапочки домашние войлочные Одежда и обувь Ходики 99000 98990 обычный
22 WEAR-0002 Халат махровый Одежда и обувь Сатин 249000 магнит
23 WEAR-0003 Носки хлопковые 5 пар Одежда и обувь Ходики 59000 залёживается
24 HOME-0005 Корзина для белья 45 л Товары для дома Домовит 119000 магнит
25 HOME-0006 Контейнер для хранения 18 л Товары для дома Домовит 69000 обычный
26 HOME-0007 Чехол для одежды 60×100 Товары для дома Ладный дом 39000 38990 залёживается
27 HOME-0008 Коврик придверный резиновый 40×60 Товары для дома Домовит 59000 обычный
28 HOME-0009 Гладильная доска настольная 73×30 Товары для дома Ладный дом 149000 залёживается
29 HOME-0010 Ведро хозяйственное с крышкой 12 л Товары для дома Домовит 49000 48990 залёживается
30 HOME-0011 Швабра с отжимом и ведром 8 л Товары для дома Домовит 179000 178990 обычный
31 HOME-0012 Щётка для пола с черенком 120 см Товары для дома Домовит 45000 обычный
32 HOME-0013 Совок со щёткой на длинной ручке Товары для дома Домовит 79000 магнит
33 HOME-0014 Таз хозяйственный круглый 14 л Товары для дома Домовит 39000 38990 магнит
34 HOME-0015 Этажерка для ванной 3 яруса Товары для дома Ладный дом 139000 магнит
35 HOME-0016 Полка настенная металлическая 45 см Товары для дома Ладный дом 99000 обычный
36 HOME-0017 Зеркало настенное круглое 50 см Товары для дома Ладный дом 169000 168990 залёживается
37 HOME-0018 Набор коробок для обуви 6 штук Товары для дома Домовит 129000 магнит
38 HOME-0019 Подставка для зонтов металлическая Товары для дома Ладный дом 159000 158990 обычный
39 HOME-0020 Крючки самоклеящиеся 8 штук Товары для дома Домовит 29000 обычный
40 HOME-0021 Верёвка бельевая 20 м Товары для дома Домовит 19000 обычный
41 HOME-0022 Прищепки усиленные 24 штуки Товары для дома Домовит 25000 24990 залёживается
42 HOME-0023 Мешки вакуумные для одежды 5 штук Товары для дома Домовит 69000 68999 обычный
43 HOME-0024 Органайзер подвесной с 12 карманами Товары для дома Ладный дом 79000 обычный
44 HOME-0025 Часы настенные круглые 30 см Товары для дома Ладный дом 89000 88990 магнит
45 HOME-0026 Светильник настольный с прищепкой 7 Вт Товары для дома Ладный дом 119000 обычный
46 HOME-0027 Удлинитель бытовой 3 розетки 3 м Товары для дома Ладный дом 99000 магнит
47 HOME-0028 Лестница-стремянка 3 ступени Товары для дома Домовит 299000 обычный
48 HOME-0029 Ящик для инструментов 16 дюймов Товары для дома Домовит 139000 138990 обычный
49 HOME-0030 Набор плечиков для брюк 6 штук Товары для дома Домовит 89000 обычный
50 TEXT-0005 Пододеяльник двуспальный 175×215 Текстиль Сатин 169000 обычный
51 TEXT-0006 Простыня на резинке 160×200 Текстиль Сатин 149000 148990 обычный
52 TEXT-0007 Наволочки хлопковые 50×70 2 штуки Текстиль Сатин 79000 залёживается
53 TEXT-0008 Одеяло всесезонное 172×205 Текстиль Сатин 299000 обычный
54 TEXT-0009 Подушка стёганая 50×70 Текстиль Сатин 129000 128990 обычный
55 TEXT-0010 Наматрасник непромокаемый 160×200 Текстиль Сатин 189000 магнит
56 TEXT-0011 Покрывало стёганое 180×210 Текстиль Сатин 239000 238999 обычный
57 TEXT-0012 Плед флисовый 150×200 Текстиль Сатин 119000 118990 обычный
58 TEXT-0013 Полотенце лицевое 50×90 Текстиль Сатин 49000 обычный
59 TEXT-0014 Полотенца кухонные 40×60 3 штуки Текстиль Сатин 59000 обычный
60 TEXT-0015 Коврик для ванной хлопковый 50×80 Текстиль Ладный дом 99000 залёживается
61 TEXT-0016 Штора для ванной 180×200 Текстиль Ладный дом 79000 обычный
62 TEXT-0017 Тюль вуаль 300×260 Текстиль Ладный дом 199000 обычный
63 TEXT-0018 Шторы кухонные 150×170 2 полотна Текстиль Ладный дом 179000 залёживается
64 TEXT-0019 Скатерть водоотталкивающая 140×180 Текстиль Ладный дом 129000 обычный
65 TEXT-0020 Дорожка на стол хлопковая 40×140 Текстиль Ладный дом 59000 залёживается
66 TEXT-0021 Салфетки столовые льняные 4 штуки Текстиль Сатин 79000 обычный
67 TEXT-0022 Чехлы на табурет 35×35 4 штуки Текстиль Ладный дом 109000 108990 обычный
68 TEXT-0023 Подушка декоративная 40×40 Текстиль Сатин 69000 68999 магнит
69 TEXT-0024 Чехол для диванной подушки 45×45 Текстиль Сатин 39000 обычный
70 TEXT-0025 Фартук кухонный с карманом Текстиль Ладный дом 59000 магнит
71 TEXT-0026 Прихватки стёганые 20×20 2 штуки Текстиль Ладный дом 39000 38990 магнит
72 TEXT-0027 Халат вафельный размер 48–50 Текстиль Сатин 199000 залёживается
73 TEXT-0028 Комплект полотенец 50×90 и 70×140 Текстиль Сатин 139000 138999 обычный
74 TEXT-0029 Одеяло детское байковое 100×140 Текстиль Сатин 169000 обычный
75 TEXT-0030 Подушка для стула 40×40 Текстиль Ладный дом 59000 обычный
76 POSU-0005 Кастрюля эмалированная 3 л Посуда Кухмастер 169000 168990 обычный
77 POSU-0006 Ковш нержавеющий с крышкой 1.5 л Посуда Кухмастер 139000 138990 залёживается
78 POSU-0007 Сотейник литой с крышкой 28 см Посуда Кухмастер 269000 магнит
79 POSU-0008 Сковорода блинная 22 см Посуда Кухмастер 149000 обычный
80 POSU-0009 Казан алюминиевый 6 л Посуда Кухмастер 299000 обычный
81 POSU-0010 Форма для запекания стеклянная 2.4 л Посуда Кухмастер 119000 118990 магнит
82 POSU-0011 Противень стальной 37×27 Посуда Кухмастер 79000 залёживается
83 POSU-0012 Набор форм для выпечки 3 штуки Посуда Кухмастер 139000 залёживается
84 POSU-0013 Миска нержавеющая 24 см Посуда Кухмастер 69000 68990 залёживается
85 POSU-0014 Дуршлаг стальной с ручками 22 см Посуда Кухмастер 89000 залёживается
86 POSU-0015 Тёрка четырёхгранная 23 см Посуда Кухмастер 49000 обычный
87 POSU-0016 Доска разделочная бамбуковая 35×25 Посуда Кухмастер 79000 78990 магнит
88 POSU-0017 Набор ножей с подставкой 6 предметов Посуда Кухмастер 249000 248990 обычный
89 POSU-0018 Половник и шумовка 2 предмета Посуда Кухмастер 59000 58990 обычный
90 POSU-0019 Контейнеры пищевые 7 предметов Посуда Кухмастер 99000 залёживается
91 POSU-0020 Банки для сыпучих продуктов 4 штуки Посуда Ладья 139000 138990 залёживается
92 POSU-0021 Тарелки обеденные фарфоровые 6 штук Посуда Ладья 179000 178999 обычный
93 POSU-0022 Тарелки суповые керамические 4 штуки Посуда Ладья 129000 обычный
94 POSU-0023 Салатник стеклянный 23 см Посуда Ладья 69000 68999 обычный
95 POSU-0024 Кружки керамические 350 мл 4 штуки Посуда Ладья 119000 118990 обычный
96 POSU-0025 Стаканы высокие 400 мл 6 штук Посуда Ладья 99000 98990 обычный
97 POSU-0026 Бокалы для воды 300 мл 6 штук Посуда Ладья 149000 обычный
98 POSU-0027 Термос стальной 1 л Посуда Ладья 179000 залёживается
99 POSU-0028 Термокружка с крышкой 450 мл Посуда Ладья 99000 залёживается
100 POSU-0029 Сахарница фарфоровая 400 мл Посуда Ладья 59000 магнит
101 POSU-0030 Маслёнка стеклянная с крышкой Посуда Ладья 69000 68990 обычный
102 TECH-0005 Миксер ручной 500 Вт Бытовая техника Электрон 249000 248990 обычный
103 TECH-0006 Измельчитель с чашей 1.5 л Бытовая техника Электрон 299000 298990 обычный
104 TECH-0007 Стационарный блендер 1.8 л Бытовая техника Электрон 549000 магнит
105 TECH-0008 Мясорубка электрическая 1800 Вт Бытовая техника Электрон 799000 магнит
106 TECH-0009 Тостер на 2 ломтика 850 Вт Бытовая техника Электрон 299000 залёживается
107 TECH-0010 Вафельница электрическая 1000 Вт Бытовая техника Электрон 349000 348999 обычный
108 TECH-0011 Мультиварка с чашей 5 л Бытовая техника Электрон 699000 магнит
109 TECH-0012 Электроплитка на 1 конфорку 1500 Вт Бытовая техника Электрон 249000 обычный
110 TECH-0013 Мини-печь с грилем 30 л Бытовая техника Электрон 899000 залёживается
111 TECH-0014 Микроволновая печь 20 л Бытовая техника Электрон 999000 обычный
112 TECH-0015 Сушилка для овощей 5 поддонов Бытовая техника Электрон 499000 обычный
113 TECH-0016 Кофемолка электрическая 200 Вт Бытовая техника Электрон 199000 обычный
114 TECH-0017 Капельная кофеварка 1.2 л Бытовая техника Электрон 399000 залёживается
115 TECH-0018 Соковыжималка шнековая 150 Вт Бытовая техника Электрон 799000 залёживается
116 TECH-0019 Утюг паровой 2400 Вт Бытовая техника Электрон 349000 обычный
117 TECH-0020 Швейная машина 12 операций Бытовая техника Электрон 1299000 обычный
118 TECH-0021 Пылесос контейнерный 2000 Вт Бытовая техника Электрон 1099000 залёживается
119 TECH-0022 Пылесос вертикальный аккумуляторный Бытовая техника Электрон 1499000 магнит
120 TECH-0023 Электровеник с контейнером 0.5 л Бытовая техника Электрон 699000 магнит
121 TECH-0024 Фен с диффузором 2200 Вт Бытовая техника Электрон 299000 обычный
122 TECH-0025 Щипцы для волос 45 Вт Бытовая техника Электрон 249000 обычный
123 TECH-0026 Машинка для стрижки с 6 насадками Бытовая техника Электрон 399000 залёживается
124 TECH-0027 Весы напольные до 180 кг Бытовая техника Электрон 199000 198990 залёживается
125 TECH-0028 Весы кухонные до 5 кг Бытовая техника Электрон 149000 148990 обычный
126 TECH-0029 Увлажнитель воздуха 3 л Бытовая техника Электрон 499000 обычный
127 TECH-0030 Тепловентилятор керамический 1500 Вт Бытовая техника Электрон 399000 398990 магнит
128 KIDS-0004 Кроватка кукольная деревянная 45 см Детские товары Малышок 249000 248999 обычный
129 KIDS-0005 Манеж складной 100×100 Детские товары Малышок 799000 798990 залёживается
130 KIDS-0006 Горшок детский со спинкой Детские товары Малышок 79000 магнит
131 KIDS-0007 Ванночка детская 84 см Детские товары Малышок 169000 168990 обычный
132 KIDS-0008 Подставка детская двухступенчатая Детские товары Малышок 89000 залёживается
133 KIDS-0009 Накладка на унитаз с ручками Детские товары Малышок 69000 обычный
134 KIDS-0010 Сиденье для купания на присосках Детские товары Малышок 139000 138990 залёживается
135 KIDS-0011 Пелёнки муслиновые 80×100 3 штуки Детские товары Малышок 129000 обычный
136 KIDS-0012 Одеяло для коляски 90×110 Детские товары Малышок 149000 148990 магнит
137 KIDS-0013 Нагрудники непромокаемые 5 штук Детские товары Малышок 59000 58999 обычный
138 KIDS-0014 Бутылочка с ручками 250 мл Детские товары Малышок 49000 обычный
139 KIDS-0015 Поильник непроливайка 300 мл Детские товары Малышок 55000 54990 залёживается
140 KIDS-0016 Контейнер для пустышки 2 штуки Детские товары Малышок 25000 магнит
141 KIDS-0017 Погремушки мягкие 3 штуки Детские товары Малышок 59000 магнит
142 KIDS-0018 Кубики развивающие 12 штук Детские товары Малышок 89000 залёживается
143 KIDS-0019 Пирамидка деревянная 8 колец Детские товары Малышок 79000 78990 обычный
144 KIDS-0020 Сортер с геометрическими фигурами Детские товары Малышок 99000 98990 обычный
145 KIDS-0021 Конструктор деревянный 50 деталей Детские товары Малышок 139000 обычный
146 KIDS-0022 Доска для рисования магнитная 30×40 Детские товары Малышок 129000 128990 обычный
147 KIDS-0023 Мольберт детский двусторонний 105 см Детские товары Малышок 299000 298999 залёживается
148 KIDS-0024 Набор для лепки 12 цветов Детские товары Малышок 69000 обычный
149 KIDS-0025 Коврик игровой складной 180×150 Детские товары Малышок 399000 обычный
150 KIDS-0026 Палатка игровая с окошками Детские товары Малышок 199000 обычный
151 KIDS-0027 Каталка с ручкой и звуком Детские товары Малышок 169000 залёживается
152 KIDS-0028 Самокат трёхколёсный до 50 кг Детские товары Малышок 299000 298999 обычный
153 KIDS-0029 Санки со спинкой и ремнём Детские товары Малышок 349000 залёживается
154 KIDS-0030 Рюкзак детский объёмом 8 л Детские товары Малышок 129000 128990 обычный
155 WEAR-0004 Футболка хлопковая размер 48 Одежда и обувь Сатин 79000 78990 залёживается
156 WEAR-0005 Майка хлопковая размер 46 Одежда и обувь Сатин 59000 58990 обычный
157 WEAR-0006 Лонгслив трикотажный размер 50 Одежда и обувь Сатин 109000 108990 магнит
158 WEAR-0007 Водолазка хлопковая размер 44 Одежда и обувь Сатин 119000 118990 обычный
159 WEAR-0008 Толстовка с капюшоном размер 52 Одежда и обувь Сатин 199000 198999 обычный
160 WEAR-0009 Брюки спортивные размер 50 Одежда и обувь Сатин 169000 залёживается
161 WEAR-0010 Легинсы хлопковые размер 46 Одежда и обувь Сатин 99000 98990 залёживается
162 WEAR-0011 Пижама трикотажная размер 48 Одежда и обувь Сатин 179000 обычный
163 WEAR-0012 Ночная сорочка хлопковая размер 50 Одежда и обувь Сатин 129000 128999 обычный
164 WEAR-0013 Фартук рабочий с тремя карманами Одежда и обувь Сатин 79000 залёживается
165 WEAR-0014 Перчатки хозяйственные 3 пары Одежда и обувь Ходики 39000 обычный
166 WEAR-0015 Варежки вязаные размер 8 Одежда и обувь Ходики 69000 68990 обычный
167 WEAR-0016 Шапка вязаная с отворотом размер 56 Одежда и обувь Ходики 89000 88990 магнит
168 WEAR-0017 Шарф шерстяной 30×180 Одежда и обувь Ходики 119000 обычный
169 WEAR-0018 Ремень текстильный длиной 120 см Одежда и обувь Ходики 79000 78990 обычный
170 WEAR-0019 Стельки войлочные 2 пары Одежда и обувь Ходики 39000 38990 залёживается
171 WEAR-0020 Шнурки круглые 120 см 3 пары Одежда и обувь Ходики 25000 обычный
172 WEAR-0021 Кеды текстильные размер 42 Одежда и обувь Ходики 179000 обычный
173 WEAR-0022 Кроссовки прогулочные размер 39 Одежда и обувь Ходики 249000 248990 обычный
174 WEAR-0023 Ботинки утеплённые размер 41 Одежда и обувь Ходики 349000 348990 залёживается
175 WEAR-0024 Сапоги резиновые размер 40 Одежда и обувь Ходики 199000 обычный
176 WEAR-0025 Полуботинки кожаные размер 43 Одежда и обувь Ходики 399000 398999 залёживается
177 WEAR-0026 Сандалии с ремешками размер 38 Одежда и обувь Ходики 189000 обычный
178 WEAR-0027 Сланцы резиновые размер 41 Одежда и обувь Ходики 79000 обычный
179 WEAR-0028 Балетки текстильные размер 37 Одежда и обувь Ходики 149000 залёживается
180 WEAR-0029 Туфли домашние утеплённые размер 39 Одежда и обувь Ходики 129000 магнит
181 WEAR-0030 Галоши садовые размер 42 Одежда и обувь Ходики 99000 98990 обычный
+8 -2
View File
@@ -5,8 +5,10 @@
пересобрать: `make docs`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
@@ -21,6 +23,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
Всего колонок: 47.
## Идентификаторы и время
+13 -8
View File
@@ -48,8 +48,10 @@
## 1. Широкое событие кликстрима
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
многозначное — в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS.
многозначное — в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Одна длина у массивов общая внутри группы: `purchase*` — по
элементу на заказ, `product*` — по элементу на товар; между собой группы
разной длины. Сессий в потоке нет — их менти собирает сам в DDS.
### 1.1 Решения по именам и типам
@@ -221,10 +223,12 @@ smoke — сравнение `system.columns` поднятого стенда с
## 3. Каталог товаров
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
разговор о политике обновления словаря. На кластере файл монтируется в обе
ноды, словарь создаётся ON CLUSTER.
`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл
использует генератор — расхождений нет по построению. Даёт `dictGet` в
витринах и разговор о политике обновления словаря. На кластере файл
монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка —
уровень спроса товара, заведена при исполнении #50 (спека генератора,
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
## 4. Сверка `purchase` против заказов
@@ -239,7 +243,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~12% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~12% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
@@ -405,7 +409,8 @@ Kafka день переигрывается генератором заново:
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
в порядке приоритета — классы пересекаются, побеждает более ранний).
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
после приведения к сравнимой базе (округления Float64, ~1–2% заказов).
после приведения к сравнимой базе; его создаёт генератор намеренно
(~1–2% заказов, см. раздел 4).
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
+317 -12
View File
@@ -388,8 +388,26 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
переобработки при исчерпании retention Kafka (раздел 7), формулировка
этапа 7 (раздел 9).
Внесены при исполнении #40 (2026-08-02):
- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая
внутри группы, а `purchase*` и `product*` между собой разной длины.
- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее.
Округления `Float64` сами по себе расхождения не дают: деньги считаются
целыми копейками, и дробное число в событии одно. Класс создаёт генератор
намеренно — так мастер-спека и допускала вторым вариантом.
## 8. Хвосты следующим этапам
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
стороны берут один и тот же (раздел 9).
- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события —
`productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам
не попадает: клиент шлёт точную сумму, и она сходится с `items_total`
бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно —
мастер-спека (раздел 4) это и допускает; материал для разговора про Float64
теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
@@ -429,15 +447,18 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
свежими; привязки к реальному календарю у констант мира всё равно нет.
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
- **Измерено на собранном плане** (канонический мир, 14 дней; числа
перемерены при исполнении #40 — рычаг долгой жизни помеченного
покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная
аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная
аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс.
возвратами предыстории (при нарезке возвраты предыстории оценили
скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба
назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно.
прогоном плана, событий для них не нужно. До правки #40 те же измерения
давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс.
возвратами предыстории), 170 пар.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
@@ -520,10 +541,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
заново, сверяя сборку с `VisitID`.
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка
8% визитов до корзины → 45% из них до оформления → 55% из них до
подтверждения, то есть конверсия визита ~2%. Итог сложится после
торговых событий (#40) и ляжет в манифест.
визит. Воронка при исполнении #39 была общей для всех: 8% визитов до
корзины → 45% из них до оформления → 55% из них до подтверждения, то есть
конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний
день. При исполнении #40 воронка развелась надвое (блок ниже): обычному
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
оттуда же они лягут в манифест.
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
@@ -542,6 +567,286 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
`/confirmation`, а перед корзиной у него всегда есть карточка. Своей
случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут.
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
Три решения выходят за границы тикета: правятся план состава (#38), воронка
(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран
(#42); позже обошлось бы его пересборкой. Часть решений принята после
холодного ревью первой редакции блока.
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
которые открывал в этом визите, а покупает не всё: около 15% позиций
остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх
покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе
выходит 1,9 позиции, однопозиционных заказов около половины. Карточка,
открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр —
раздумье, а не второй товар. Количество штук — свой бросок: обычно одна,
изредка две-три. Отклонено: *заказ всегда из одного товара* — список
позиций как урок умирает, а на нём стоит единственный носитель навыка
«вложенный JSON» (мастер-спека, раздел 2); *досыпать в корзину товары,
которых посетитель не открывал* — ломает шов #39 ровно там, ради чего он
строился; *бросать много* (40% и выше) — две трети заказов схлопываются в
один товар.
- **Покупатель перестаёт быть словом — двумя рычагами сразу.** Метка плана
(«этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не
влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь
помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых,
дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие:
склонность покупать и вправду свойство человека, а не свойство визита.
Второй — про видимость: метки в событии нет и не будет (кликстрим
анонимен), поэтому «постоянный покупатель» читается в данных только как
кука, которая ходит неделями и покупает не раз. Одним лифтом это
недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке
негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на
том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до
корзины и 60% из корзины в оформление против 6% и 45% у обычного
посетителя. Обычного калибровка тоже задела — его доля до корзины опущена
с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов.
Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо
5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% —
за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы
правки — вилки раздела 5: дневная аудитория остаётся в пределах
6–8 тыс., средний день — около 50 тыс. событий, конверсия визита —
около 2%. Расти внутри вилки аудитории
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
Измерено после правки (канонический мир, 14 дней): в средний день
аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765
(7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки
была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной
780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше)
перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки
по-прежнему решает день, но
случайность в них больше не одинакова для всех. Отклонено: *оставить
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
*покупают только помеченные* — 5% людей дают около 40 заказов в день вместо
220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с
ней вырастет и число двухкуковых пар.
- **Событие корзины садится на карточку товара**, событие покупки — на
страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на
карточке, а не открытием страницы корзины; при нескольких товарах в заказе
иначе его и не разложить — событий столько, со скольких карточек положили.
- **Номер заказа читаемый**: день модельного времени и порядковый номер
покупки в этом дне (`20260603-0042`). Магазины так и нумеруют, и по номеру
сразу видно день. Он же — `order_id` бэкенда: обе стороны получают один
номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все
покупки, дошедшие до потока дня, в порядке событий — до всяких потерь.
Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе
одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы.
- **Промокод в событии есть, скидки в сумме нет.** Часть заказов уходит с
промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта.
Класса расхождения промокод не даёт: сверка приведена к `items_total`, где
скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской
суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся»
(мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт
здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено:
*промокода нет никогда* — колонка формата осталась бы мёртвой.
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и
копейки в части цен каталога.** Генератор считает целыми копейками; цена
товара в событии — целые рубли, как у Метрики (`productPrice` там `Int64`);
дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 цен каталога
кратны рублю, и это делало урок про Float64 беспредметным: округлять
нечего, копейки существуют только на словах, а расхождение сумм пришлось бы
выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ —
обычная розница): `productPrice` округляется самим форматом, а
`purchaseRevenue` несёт точную сумму. Разрыв живёт внутри события — считать
выручку по разобранным массивам нельзя, и это настоящий урок формата, а не
придуманный.
- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей
группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один),
`product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о
«параллельных массивах одной длины», и прочесть это как «все массивы
события одной длины» легко — поэтому в описании выгрузки сказано прямо.
- **Сырой `ecommerce` несёт больше, чем плоские колонки.** Лаба «сырое против
разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром
лежит то, чего в массивах нет: бренд товара, его вариант, блок
`actionField` целиком. Собирает строку канонический сериализатор — руками
это второй сериализатор со своим экранированием; зависимость `orjson`,
названная рабочим выбором в разделе 4, появляется здесь, а не в #41.
- **Торговый хвост умещается в сутки резервом, а не зажимом.** Событие
покупки встаёт на несколько секунд позже просмотра страницы подтверждения,
событие корзины — позже своей карточки, а граница суток режет всё, что за
неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы
уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том
расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на
секунды позже подтверждения» держится всегда, а зажим к последней секунде
ломал бы его ровно там, ради чего написан. Визит, у которого страница
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
не было.
- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики
покупок и двухкуковых пар сходятся с планом», но покупок план не считает и
считать не будет: раздел 1 обещает до генерации только состав — посетителей,
приток, пары, — а торговые счётчики складываются из поведения дня. Квота на
покупки заставила бы день добирать до точного числа, и конверсия в данных
вышла бы подозрительно ровной — условность видна там, куда лаба смотрит.
Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по
вилке конверсии (~2% на визит).
Решено при исполнении #40 самой реализацией:
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
расстояние от последней страницы, когда последним событием визита бывает
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
два визита в один и разошлась бы с `VisitID`. Расстояние считается от
последнего события; поймано тестом сборки визитов.
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток
сначала считался только для визитов с обещанным планом заказом, и у
вольной покупки оставалось окно в несколько секунд: страница
подтверждения дожила до потока, а событие покупки за полночь вышло. Это
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по
недосмотру. Теперь запас под торговый хвост есть у всякой страницы
подтверждения: либо она уходит за границу суток вместе с покупкой, либо
остаются обе. Это ровно то правило, которое блок выше называет словами
«визит, у которого страница подтверждения срезана полуночью, покупки не
даёт»; в каноническом мире числа от правки не сдвинулись — случай не
выпал ни разу за 14 дней.
- **Задержка торгового события короче самой короткой паузы между
страницами.** Тогда событие корзины не обгоняет страницу, на которой
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите
всегда идёт страница, которая полночь пережила.
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков
проверяются на непересечение — обещание уникальности держит дедупликацию
при переигровке дня, и предполагать его нельзя.
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`,
которого в плоских колонках нет; строки живут в справочниках рядом с
устройствами и городами, а не сочиняются на месте.
Решено при исполнении #40 после приёмки (2026-08-02) — грилинг тождества
«просмотр → корзина». Приёмка нашла два точных равенства: каждое в живых
данных не встречается и потому читается аналитиком как склейка в разметке,
а не как поведение людей.
- **Корзина отвязана от страницы корзины.** Замерено до правки на всех 14
днях канонического мира: множества «визиты с событием корзины» и «визиты
со страницей `/cart`» совпадали в точности — 828 и 828 на дне 2, и так
каждый день без единого расхождения. Событие корзины
возникало только у визита, дошедшего до воронки, то есть факт добавления
идеально предсказывался более поздним просмотром страницы. Теперь
положить может любой визит, открывший карточку; страница `/cart`
остаётся шагом воронки и решается как прежде, а заказ по-прежнему
рисуется только у визита, дошедшего до подтверждения. Оговорка, без
которой появилась бы новая ложь вместо старой: у визита, дошедшего до
`/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины
при пустой корзине невозможна. Брошенные корзины в мире были и до правки
(в том же дне 828 корзин против 272 покупок); правка добавляет к ним
тех, кто положил и корзину не открывал, — самый массовый сюжет магазина.
Почему это проехало при нарезке: воронка #39 записана в терминах
страниц, #40 принял решение про события («событие корзины садится на
карточку»), но вопрос «какой визит вообще кладёт» заново не задал —
страничное правило молча переехало на уровень событий.
- **Класть или нет решает и товар, а не только визит.** До правки в
корзину ложились все карточки покупающего визита: внутри воронки 100%,
вне её 0%. Привлекательность товара измерить было нечем — конверсия
«просмотр → корзина» по sku давала чистый шум, а вопрос «какие товары
кладут чаще» ответа не имел. Теперь у товара есть уровень спроса:
колонка каталога с тремя значениями (магнит, обычный, залёживается), а
вероятности трёх уровней — числа мира рядом с остальными. Отклонено:
*свой процент у каждого из 180 sku* — рукописные числа, которых никто не
обоснует, а каталог по решению #39 дописывается механически; *выводить
склонность из цены* — жёсткая связь, в которой менти найдёт ровно то,
что мы в неё вложили, и ничего сверх.
- **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний
день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из
корзины, а корзина сузилась, поэтому доля брошенных позиций и состав
заказа перемеряются вместе с остальным: сохраняется не число, а
свойство — заказ уже корзины и однопозиционных заказов около половины.
**Измерено после правки #50** (канонический мир, 14 дней; правый столбец —
то же до правки). Доли по уровням и состав заказа считаются по окну снимка
целиком, а не как среднее дневных долей: заказов в дне около 240, и на одном
дне такая доля гуляет на несколько пунктов от одной случайности выборки.
Трафиковая половина не сдвинулась ни на строку: набор просмотров страниц
сверен со снимком, снятым до первой правки.
| величина | стало | было |
|---|---|---|
| дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений |
| визитов в день | 9 879 | без изменений |
| событий в день, средних | 49 834 | 49 509 |
| просмотров карточек в день | 25 067 | без изменений |
| событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах |
| из них визиты без страницы `/cart` | 541 — 41,4% | 0 |
| визитов со страницей `/cart` без позиции | 0 | 0 |
| просмотров карточек, дошедших до корзины | 8,62% | 7,32% |
| конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех |
| событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех |
| конверсия визита в покупку | 2,43% | без изменений |
| позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% |
| брошено позиций в покупающих корзинах | 7,25% | 12,0% |
Знаменатель «просмотров карточек» здесь — только просмотры страниц. При
постановке #50 в него попали и строки событий корзины: событие садится на
карточку и несёт её же страницу, отчего вышло 26 903 просмотра и 6,82%
вместо 25 067 и 7,32%. Величина одна и та же, счёт разный.
Решено при исполнении #50 самой реализацией:
- **Намерение визита берётся из шага воронки, а не из метки покупателя.**
Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт
почти всё, что сравнивал, а прочий — редко. Метка покупателя в это
решение не заходит, и не потому, что не влияет: она уже влияет — через
воронку, где помеченному дано 18% против 6%. Взять её вторым слагаемым
значило бы посчитать одно и то же дважды и протянуть план состава сквозь
шов, по которому день отдаёт торговой половине только страницы и товары.
Плата названа: у визита без намерения добавление ни с чем в человеке не
связано — упрощение, но не ложь, потому что склонные покупать и так
преобладают среди дошедших до `/cart`.
- **Два ряда вероятностей, а не ряд с множителем.** Уровень спроса читается
дважды: ряд для визита с намерением и ряд для того, кто смотрит. Один ряд
с множителем намерения не годится: вероятности визита с намерением обязаны
быть высокими (иначе его корзина схлопывается до одной позиции, а заказ до
одного товара), а у потолка в 100% разница уровней съедается, и конверсия
по уровням перестаёт различаться. Разрыв уровней внутри рядов поэтому
разный: у смотрящего товар решает почти всё, у пришедшего покупать —
заслонён намерением.
- **Доля брошенных позиций опущена с 15% до 10%.** Корзина сузилась с 2,4
позиции до 1,7, и прежние 15% чаще выносили её целиком: спасённая позиция
возвращала заказ к одному товару, и однопозиционных заказов набиралось под
60%. Свойство сохранено, число перемерено — ровно так, как этот блок и
оговаривал.
- **Ни одному уровню спроса нельзя дать нулевую вероятность.** Ноль у
залежавшегося товара выглядел заманчиво — он дешевле всего разводил
конверсию уровней, — но по товарам этого уровня возвращал ровно то
равенство, ради которого правка и делалась: событие корзины случалось бы
только внутри визита, открывшего `/cart`, без единого исключения на
четверти каталога. Отвязка либо про весь ассортимент, либо она не отвязка.
Разводить уровни поэтому приходится не нулём внизу второго ряда, а
ослаблением залежавшегося в первом: пришедший покупать берёт его реже
ходового.
- **Уровень спроса — колонка `demand` в конце файла каталога**, значения
словами («магнит», «обычный», «залёживается»). Раздан он одной колодой на
весь каталог: доли по категориям расходятся сами (магнитов от четырёх до
девяти на тридцать строк). Раскладывать колоду поровну в каждую категорию
было бы той же неправдоподобной ровностью, какую тикет вычищал из данных.
Связь с ценой отклонена выше, и тест сторожит её отсутствие — по четвертям
цены и по медиане, потому что одни только края списка обманываются
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом
ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не
догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более,
потому что теперь в файле живёт ещё и поведение.
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%
и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне
`/cart` требует своих позиций, а состав заказа требует, чтобы визит с
намерением клал почти всё сравненное. Обе границы тянут число вверх, и
опустить его — значит отпустить одну из них. Место до 9% невелико, и
следующая правка торговой половины считается против него.
- **Вилка 5–9% — ориентир, а не граница** (решение владельца, 2026-08-02).
Доля просмотров, доходящих до корзины, у живых магазинов гуляет широко:
пока число правдоподобно, само по себе оно ничего не сторожит, и
подгонять поведение под его край не надо. Настоящий предел здесь другой и
считается в другой валюте — дневной бюджет событий: события корзины
входят в те самые «около 50 тыс.», на которых стоят манифест (#42) и
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
причины, а не долю.
Остаётся открытым, за тикетами:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
+8 -2
View File
@@ -5,7 +5,8 @@
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
pageview. Торговые события и запуск снаружи — за следующими тикетами.
трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом
целая; заказы бэкенда и запуск снаружи — за следующими тикетами.
## Как это работает
@@ -53,7 +54,12 @@ D0 живёт предыстория, поэтому любой день соб
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который сядут торговые события.
который садятся торговые события.
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
Своя случайность, поэтому правка торговли трафик не двигает.
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
+4 -1
View File
@@ -6,7 +6,10 @@ readme = "README.md"
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
# держится на зафиксированных версиях (спека генератора, раздел 2).
requires-python = ">=3.14,<3.15"
dependencies = ["numpy>=2"]
dependencies = [
"numpy>=2",
"orjson>=3.11.9",
]
[dependency-groups]
dev = [
+22 -5
View File
@@ -10,12 +10,22 @@
раз в неделю, корзина в двадцать позиций.
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
price`; артикул четыре латинские буквы категории, дефис и четыре цифры;
цена целые копейки (деньги генератор считает целыми, спека, раздел 2).
price,demand`; артикул четыре латинские буквы категории, дефис и четыре
цифры; цена целые копейки (деньги генератор считает целыми, спека,
раздел 2). Часть цен кратна рублю, часть несёт копейки как в обычной
рознице (1 289,90 ). Без копеек урок про Float64 был бы беспредметным:
округлять нечего, и разрыв между `productPrice` и `purchaseRevenue`
пришлось бы выдумывать (спека генератора, раздел 9).
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
считают, а товар для карточки выбирается равномерно внутри категории.
Популярность товаров не моделируется придумывать вес каждой строке
пришлось бы вручную, а каталог растёт механически.
**Уровень спроса** привлекательность товара, а не частота его показа:
карточку любого товара открывают одинаково часто, но магнит из открытой
карточки уходит в корзину чаще, чем залежавшийся. Уровень приписан строке
в файле он постоянная часть мира, а не бросок дня, и приписан россыпью:
ни с ценой, ни с категорией он не связан. Связь с ценой была бы жёсткой, и
менти нашёл бы в данных ровно то, что мы в них вложили (спека генератора,
раздел 9). Сами вероятности живут в числах мира, здесь только имена уровней.
"""
import csv
@@ -30,7 +40,11 @@ from numpy.typing import NDArray
# Каталог не настраивается извне — он часть мира, а не запуска.
CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv"
COLUMNS = ("sku", "name", "category", "brand", "price")
COLUMNS = ("sku", "name", "category", "brand", "price", "demand")
# Уровни спроса по убыванию желанности: порядок здесь — порядок чисел мира,
# которыми уровень превращается в вероятность (`world.SHOPPING_ADD_PERCENT`).
DEMAND_LEVELS = ("магнит", "обычный", "залёживается")
@dataclass(frozen=True, slots=True)
@@ -66,6 +80,7 @@ class Catalog:
category: NDArray[np.int64]
brand: NDArray[np.object_]
price: NDArray[np.int64]
demand: NDArray[np.int64]
grouped: NDArray[np.int64]
first: NDArray[np.int64]
count: NDArray[np.int64]
@@ -81,12 +96,14 @@ def catalog() -> Catalog:
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))
level = {name: number for number, name in enumerate(DEMAND_LEVELS)}
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),
demand=np.array([level[row["demand"]] for row in rows], dtype=np.int64),
grouped=grouped,
first=np.concatenate(([0], np.cumsum(count)[:-1])),
count=count,
@@ -0,0 +1,566 @@
"""Торговые события: корзина и заказ на потоке дня.
Здесь торговая половина мира. Событие корзины садится на карточку товара
в жизни его шлёт кнопка на карточке, а не открытие страницы корзины; при
нескольких товарах в заказе иначе его и не разложить: событий столько, со
скольких карточек положили. Событие покупки садится на страницу
подтверждения. Оба встают на несколько секунд позже своей страницы, а
граница модельных суток режет всё, что за неё вышло: визит, у которого
подтверждение срезано полуночью, покупки не даёт заказа не было.
**Кладёт любой визит, открывший карточку.** Положить и корзину не открыть
самый массовый сюжет магазина, поэтому событие корзины не привязано к
странице `/cart`: иначе факт добавления идеально предсказывался бы более
поздним просмотром страницы, а такого равенства в живых данных не бывает.
Решается каждая открытая карточка отдельно, а не визит целиком, и решают её
двое: намерение визита и уровень спроса товара (числа мира). Карточка,
открытая дважды, даёт одну позицию повторный просмотр это раздумье, а не
второй товар.
**Корзина шире заказа.** Покупает посетитель не всё, что положил: часть
позиций остаётся брошенной. Иначе событие корзины не рассказывало бы ничего
сверх покупки заказ был бы её точной копией, и сравнивать было бы нечего.
**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся
целые рубли, как у Метрики, а дробное число в событии одно
`purchaseRevenue`. Часть цен каталога несёт копейки, поэтому округление
видно: выручку по разобранным массивам не пересчитать, точная сумма живёт в
`purchaseRevenue` и в сыром `ecommerce`. Разрыв внутри одного события это
настоящий урок формата, а не придуманный.
**Промокод в событии есть, скидки в сумме нет.** Выручка, которую шлёт
клиент, сумма позиций без скидки и доставки: код на сайте знает корзину, а
не итог расчёта. Скидку по коду насчитывает бэкенд (этап 3), и таблица «код
скидка» лежит в числах мира, чтобы обе стороны брали одну.
**Номер заказа читаемый** день модельного времени и порядковый номер
покупки в этом дне (`20260603-0042`). Он же `order_id` бэкенда: по нему
соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки,
дошедшие до потока дня, в порядке событий до всяких потерь; поэтому номер
присваивается последним ходом, когда поток уже упорядочен.
**Случайность подпоток `COMMERCE`** (спека генератора, раздел 2): правка
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
посточно собираются только строки их numpy не умеет.
"""
from dataclasses import dataclass
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import catalog, ids, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick
# Таксономия: тип события и действие с товаром. Полный словарь торговых
# событий Метрики (detail, remove, impressions) стенд не берёт.
ADD_TO_CART = "add_to_cart"
PURCHASE = "purchase"
ADD_ACTION = "add"
PURCHASE_ACTION = "purchase"
# Копеек в рубле: внутри генератора деньги целые, в колонках — рубли.
KOPECKS = 100
_QUANTITY_CUMULATIVE = np.cumsum(world.ITEM_QUANTITY_WEIGHTS)
_PRODUCT_COLUMNS = (
"productID",
"productName",
"productCategory",
"productPrice",
"productQuantity",
"productEventType",
)
@dataclass(frozen=True, slots=True)
class _Baskets:
"""Корзины дня: позиции по корзинам и строка подтверждения заказа.
Позиция товар, положенный в корзину: `anchor` строка карточки, на
которую сядет событие, `product` номер товара в каталоге. Позиции
лежат подряд по корзинам, внутри корзины по времени; `basket` говорит,
чья позиция, а `first` и `count` где чей кусок. `confirmation`
отвечает на вопрос, дошла ли корзина до заказа: строка подтверждения
или 1 у брошенной.
"""
anchor: NDArray[np.int64]
product: NDArray[np.int64]
basket: NDArray[np.int64]
first: NDArray[np.int64]
count: NDArray[np.int64]
confirmation: NDArray[np.int64]
def __len__(self) -> int:
return self.confirmation.size
def positions_of(self, basket: int, kept: NDArray[np.bool_]) -> NDArray[np.int64]:
"""Позиции корзины, у которых стоит отметка: например, купленные."""
here = slice(self.first[basket], self.first[basket] + self.count[basket])
return self.first[basket] + np.flatnonzero(kept[here])
@dataclass(frozen=True, slots=True)
class _Draws:
"""Броски торгового подпотока: случайное решается один раз и разом.
По позициям корзин сколько штук берут, какой это вариант товара,
дошла ли позиция до заказа и на сколько секунд событие корзины отстало
от карточки. По корзинам промокод и задержка события покупки.
"""
quantity: NDArray[np.int64]
variant: NDArray[np.int64]
kept: NDArray[np.bool_]
cart_delay: NDArray[np.int64]
coupon: NDArray[np.int64]
order_delay: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
class _Events:
"""Строки одного вида торговых событий, готовые встать в поток.
`raw` сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
появится, когда поток будет упорядочен, а строка станет байтами один
раз, каноническим сериализатором.
"""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
raw: list[dict[str, Any]]
def weave(
seed: int,
day: int,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
Строки приходят упорядоченными по времени и такими же уходят: торговые
события встают между ними, и поток пересобирается одним порядком.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets):
return columns, page, product
goods = catalog.catalog()
draws = _draws(rng, baskets)
events = (
_cart_events(columns, baskets, goods, draws),
_order_events(columns, baskets, goods, draws),
)
return _stream(rng, columns, page, product, events)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
"""Всё случайное в торговых событиях — целыми числами и векторно."""
positions = baskets.product.size
return _Draws(
quantity=1 + pick(rng, _QUANTITY_CUMULATIVE, positions),
variant=rng.integers(0, len(reference.PRODUCT_VARIANTS), positions),
kept=_kept(rng, baskets),
cart_delay=rng.integers(*world.TRADE_DELAY_SECONDS, positions),
coupon=_coupons(rng, len(baskets)),
order_delay=rng.integers(*world.TRADE_DELAY_SECONDS, len(baskets)),
)
def _baskets(
rng: np.random.Generator,
page: NDArray[np.uint8],
product: NDArray[np.int64],
visit: NDArray[np.uint64],
) -> _Baskets:
"""Что посетитель положил в корзину и дошёл ли до заказа.
Корзина есть у всякого визита, положившего хоть что-то, не только у
дошедшего до страницы `/cart`. Кандидаты карточки, открытые в визите,
по одной на товар: карточка, открытая дважды, даёт одну позицию, и
остаётся первая тогда товар и кладут.
Намерение визита берётся из шага воронки: тот, кто дошёл до страницы
корзины, пришёл покупать. Позиция у него есть всегда страница корзины
при пустой корзине невозможна, и держится это высокой вероятностью, а
не принуждением: спасать приходится считаные проценты таких визитов.
Принуждай мы всех, корзина схлопнулась бы до одной позиции, а заказ до
одного товара.
"""
order = np.argsort(visit, kind="stable")
page, product, visit = page[order], product[order], visit[order]
# Строки визита лежат подряд, внутри визита — по времени: поток пришёл
# упорядоченным, а сортировка по визиту устойчивая.
started = np.concatenate(([True], visit[1:] != visit[:-1]))
number = np.cumsum(started) - 1
visits = int(started.sum())
goods = catalog.catalog()
cards = np.flatnonzero(page == Page.PRODUCT)
# Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений,
# поэтому вторая карточка того же товара кандидата не добавляет.
key = number[cards] * goods.sku.size + product[cards]
opened = np.sort(cards[np.unique(key, return_index=True)[1]])
holder = number[opened]
shopping = np.zeros(visits, dtype=np.bool_)
shopping[number[page == Page.CART]] = True
taken = _taken(rng, goods.demand[product[opened]], shopping[holder])
# Кандидаты визита лежат подряд, визиты идут по порядку — поэтому начало
# каждого куска находится накопленной суммой.
opened_count = np.bincount(holder, minlength=visits)
opened_first = np.cumsum(opened_count) - opened_count
empty = shopping & (np.bincount(holder[taken], minlength=visits) == 0)
_at_least_one(rng, taken, opened_first, opened_count, empty)
positions, owner = opened[taken], holder[taken]
with_basket = np.unique(owner)
basket_of_visit = np.full(visits, -1, dtype=np.int64)
basket_of_visit[with_basket] = np.arange(with_basket.size)
# Подтверждение без корзины невозможно: до него визит прошёл страницу
# корзины, а у неё позиция есть всегда — поэтому номер корзины найдётся.
confirmed = np.flatnonzero(page == Page.CONFIRMATION)
confirmation = np.full(with_basket.size, -1, dtype=np.int64)
confirmation[basket_of_visit[number[confirmed]]] = order[confirmed]
basket = basket_of_visit[owner]
count = np.bincount(basket, minlength=with_basket.size)
return _Baskets(
anchor=order[positions],
product=product[positions],
basket=basket,
# Позиции лежат подряд, корзины идут по порядку визитов — начало
# каждого куска находится той же накопленной суммой.
first=np.cumsum(count) - count,
count=count,
confirmation=confirmation,
)
def _taken(
rng: np.random.Generator,
demand: NDArray[np.int64],
shopping: NDArray[np.bool_],
) -> NDArray[np.bool_]:
"""Дошла ли открытая карточка до корзины: решают намерение и товар.
Два ряда чисел мира по уровням спроса: один для визита, пришедшего
покупать, другой для того, кто просто смотрит. Товар решает у обоих, но
у смотрящего он решает почти всё потому конверсия «карточка корзина»
по уровням спроса и становится различимой.
"""
chance = np.where(
shopping,
np.array(world.SHOPPING_ADD_PERCENT)[demand],
np.array(world.BROWSING_ADD_PERCENT)[demand],
)
return rng.integers(0, 100, demand.size) < chance
def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]:
"""Какие позиции корзины дошли до заказа: часть остаётся брошенной.
Пустым заказ не бывает: если брошены все позиции, одна возвращается.
"""
kept = (
rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT
)
empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0
_at_least_one(rng, kept, baskets.first, baskets.count, empty)
return kept
def _at_least_one(
rng: np.random.Generator,
chosen: NDArray[np.bool_],
first: NDArray[np.int64],
count: NDArray[np.int64],
empty: NDArray[np.bool_],
) -> None:
"""Возвращает один выбор тем группам, у которых бросок унёс все.
Приём общий у двух мест: у визита выбираются карточки, у корзины
позиции, а правило одно пустой ни та, ни другая быть не может. Какой
выбор вернуть, решает свой бросок: брать первый значило бы, что ранняя
карточка визита переживает любой бросок. Группа без членов сюда не
приходит: у визита со страницей корзины карточка была, у корзины
позиция.
"""
chosen[first[empty] + rng.integers(0, count[empty])] = True
def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
"""Промокод корзины: номер строки в таблице кодов или −1, если кода нет."""
code = rng.integers(0, len(world.COUPONS), baskets)
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
def _cart_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `add_to_cart`: по одной на каждый положенный товар."""
positions = baskets.product.size
alone = [np.array([position]) for position in range(positions)]
rows = _on_page(columns, baskets.anchor, ADD_TO_CART, draws.cart_delay)
rows["GoalsReached"] = _same(
np.array([world.GOAL_CART_ID], dtype=np.uint32), positions
)
side, blocks = _product_side(goods, baskets, alone, draws, ADD_ACTION)
rows.update(side)
return _Events(
columns=rows,
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
product=baskets.product.copy(),
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
)
def _order_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
ordered = np.flatnonzero(baskets.confirmation >= 0)
bought = [baskets.positions_of(basket, draws.kept) for basket in ordered]
rows = _on_page(
columns, baskets.confirmation[ordered], PURCHASE, draws.order_delay[ordered]
)
rows["GoalsReached"] = _same(
np.array([world.GOAL_PURCHASE_ID], dtype=np.uint32), ordered.size
)
side, blocks = _product_side(goods, baskets, bought, draws, PURCHASE_ACTION)
rows.update(side)
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
kopecks = [
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
for group in bought
]
codes = [
world.COUPONS[number][0] if number >= 0 else ""
for number in draws.coupon[ordered].tolist()
]
rows["purchaseRevenue"] = _cells(
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
)
rows["purchaseCurrency"] = _same(
np.array([world.CURRENCY], dtype=object), ordered.size
)
rows["purchaseCoupon"] = _cells([np.array([code], dtype=object) for code in codes])
raw = [
{
"currencyCode": world.CURRENCY,
PURCHASE_ACTION: {"actionField": _action_field(money, code), **block},
}
for money, code, block in zip(kopecks, codes, blocks, strict=True)
]
return _Events(
columns=rows,
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
product=np.full(ordered.size, -1, dtype=np.int64),
raw=raw,
)
def _action_field(kopecks: int, code: str) -> dict[str, Any]:
"""Блок `actionField` заказа: номер, выручка и купон, если он был.
Номер пустой до сборки потока его присваивает `_seal`, когда порядок
событий дня уже известен.
"""
field: dict[str, Any] = {"id": "", "revenue": kopecks / KOPECKS}
if code:
field["coupon"] = code
return field
def _product_side(
goods: catalog.Catalog,
baskets: _Baskets,
groups: list[NDArray[np.int64]],
draws: _Draws,
action: str,
) -> tuple[dict[str, NDArray[Any]], list[dict[str, Any]]]:
"""Массивы `product*` и товарная часть сырого JSON — одним проходом.
Массивы группы `product*` одной длины между собой: по элементу на товар.
С группой `purchase*` они не совпадают и не должны там по элементу на
заказ (мастер-спека, раздел 1).
Сырой JSON несёт больше, чем колонки: бренд, вариант товара и точную
цену с копейками. На этом и стоит лаба «сырое против разобранного»
иначе в сыром лежало бы ровно то же самое.
"""
columns: dict[str, list[NDArray[Any]]] = {name: [] for name in _PRODUCT_COLUMNS}
blocks: list[dict[str, Any]] = []
for group in groups:
numbers = baskets.product[group]
pieces = draws.quantity[group]
columns["productID"].append(np.array(goods.sku[numbers], dtype=object))
columns["productName"].append(np.array(goods.name[numbers], dtype=object))
columns["productCategory"].append(
np.array([_category(goods, number) for number in numbers], dtype=object)
)
# Цена в колонке — целые рубли, как у Метрики: это округление и есть
# тот разрыв, из-за которого выручку по массивам не пересобрать.
columns["productPrice"].append((goods.price[numbers] + KOPECKS // 2) // KOPECKS)
columns["productQuantity"].append(pieces.astype(np.uint64))
columns["productEventType"].append(np.full(group.size, action, dtype=object))
blocks.append(
{
"products": [
{
"id": goods.sku[number],
"name": goods.name[number],
"category": _category(goods, number),
"brand": goods.brand[number],
"variant": reference.PRODUCT_VARIANTS[draws.variant[position]],
"price": int(goods.price[number]) / KOPECKS,
"quantity": int(pieces[place]),
}
for place, (position, number) in enumerate(
zip(group.tolist(), numbers.tolist(), strict=True)
)
]
}
)
return {name: _cells(values) for name, values in columns.items()}, blocks
def _category(goods: catalog.Catalog, number: int) -> str:
"""Имя категории товара — то же, что в файле каталога и в словаре."""
return catalog.CATEGORIES[goods.category[number]].name
def _on_page(
columns: dict[str, NDArray[Any]],
anchor: NDArray[np.int64],
event_type: str,
delay: NDArray[np.int64],
) -> dict[str, NDArray[Any]]:
"""Событие на странице: её колонки целиком, свой тип и своё время.
Страница у торгового события та же, что у просмотра, на который оно
село: тот же адрес и реферер, та же кука, тот же визит, устройство и
гео. Различаются тип, время и торговые колонки их кладёт вызывающий.
"""
rows = {name: value[anchor] for name, value in columns.items()}
rows["EventType"] = np.full(anchor.size, event_type, dtype=object)
rows["UTCEventTime"] = columns["UTCEventTime"][anchor] + delay.astype(
"timedelta64[s]"
)
return rows
def _stream(
rng: np.random.Generator,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
events: tuple[_Events, ...],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
traffic = page.size
trade = {
name: np.concatenate([part.columns[name] for part in events])
for name in columns
}
raw = [block for part in events for block in part.raw]
alive = trade["UTCEventTime"] < _midnight(columns) + np.timedelta64(1, "D")
trade = {name: value[alive] for name, value in trade.items()}
# Номера торговых строк — из торгового подпотока: возьми их день у
# трафика, и правка торгового поведения сдвинула бы трафиковые `WatchID`.
trade["WatchID"] = ids.unique_apart_from(rng, int(alive.sum()), columns["WatchID"])
raw = [block for block, here in zip(raw, alive.tolist(), strict=True) if here]
rows = {
name: np.concatenate((value, trade[name])) for name, value in columns.items()
}
page = np.concatenate((page, np.concatenate([part.page for part in events])[alive]))
product = np.concatenate(
(product, np.concatenate([part.product for part in events])[alive])
)
# Чей сырой блок в какой строке: у просмотра страницы блока нет.
place = np.full(traffic + len(raw), -1, dtype=np.int64)
place[traffic:] = np.arange(len(raw))
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
rows = {name: value[order] for name, value in rows.items()}
_seal(rows, raw, place[order], _order_prefix(columns))
return rows, page[order], product[order]
def _seal(
rows: dict[str, NDArray[Any]],
raw: list[dict[str, Any]],
place: NDArray[np.int64],
prefix: str,
) -> None:
"""Раздаёт номера заказов и собирает сырой `ecommerce`.
Номер получают все покупки, дошедшие до потока, в порядке событий до
всяких потерь: этап 6 выбрасывает событие, когда номер уже присвоен,
иначе одна потеря перенумеровала бы чужие заказы и мост к бэкенду
разъехался бы. Строку собирает канонический сериализатор: руками это был
бы второй сериализатор со своим экранированием.
"""
number = 0
for row in np.flatnonzero(place >= 0).tolist():
block = raw[place[row]]
if rows["EventType"][row] == PURCHASE:
number += 1
code = f"{prefix}-{number:04d}"
block[PURCHASE_ACTION]["actionField"]["id"] = code
rows["purchaseID"][row] = np.array([code], dtype=object)
rows["ecommerce"][row] = orjson.dumps(block).decode()
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
date: np.datetime64 = columns["EventDate"][0]
return date.astype("datetime64[s]") - np.timedelta64(
world.COUNTER_TIMEZONE_MINUTES, "m"
)
def _order_prefix(columns: dict[str, NDArray[Any]]) -> str:
"""Первая половина номера заказа: день модельного времени, `20260603`."""
return str(columns["EventDate"][0]).replace("-", "")
def _cells(values: list[NDArray[Any]]) -> NDArray[np.object_]:
"""Колонка-массив: в каждой ячейке свой массив своего типа."""
column = np.empty(len(values), dtype=object)
for row, value in enumerate(values):
column[row] = value
return column
def _same(value: NDArray[Any], size: int) -> NDArray[np.object_]:
"""Колонка-массив, у которой во всех ячейках один и тот же массив."""
value.flags.writeable = False
column = np.empty(size, dtype=object)
column.fill(value)
return column
+87 -60
View File
@@ -1,9 +1,10 @@
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
строки; их колонки в pageview присутствуют, но пусты по смыслу «пусто»
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
гео. Торговые события садятся на этот же поток и добавляют к нему свои
строки их собирает `commerce` и им же поток заканчивается. У просмотра
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» всегда
пустой массив, пустая строка или 0, а не отсутствие ключа.
День чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1N. Держится это на подпотоке
@@ -25,23 +26,25 @@
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
всегда короче таймаута, а соседние визиты куки разведены дальше него.
всегда короче таймаута, а соседние визиты куки разведены дальше него
считая от последнего события визита, которым бывает покупка, а не от
последней его страницы.
3. Граница модельных суток режет визит: события за полночь в дне не живут.
Исключение одно визит с заказом, обещанным планом двухкуковых пар: его
старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое
ограничение модели: обещание плана гарантия, ради неё мы сужаем свободу
старта. Цена названа около 24 визитов в день из ~9,5 тыс. не начинаются
в последние минуты суток.
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
торговым хвостом. Это принятое ограничение модели: обещание плана
гарантия, ради неё мы сужаем свободу старта. Цена названа около 24
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два
выровненных по строкам ряда: `page` какая это страница магазина, и
`product` какой товар показывала карточка (1 у прочих страниц). По ним
#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение),
и то, что посетитель на самом деле смотрел: товар в корзине, которого никто
не открывал, видимая глупость в воронке. Визит с назначенным заказом
всегда доходит до `/confirmation`, а перед корзиной у него всегда есть
карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE`
нетронут.
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
по строкам ряда: `page` какая это страница магазина, и `product` какой
товар показывала карточка (1 у прочих страниц). По ним `commerce` знает и
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
самом деле смотрел: товар в корзине, которого никто не открывал, видимая
глупость в воронке. Визит с назначенным заказом всегда доходит до
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля
`COMMERCE`, и правка одной не сдвигает другую.
"""
from dataclasses import dataclass
@@ -50,21 +53,24 @@ from typing import Any
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, plan, reference, world
from clickstream_generator import catalog, commerce, ids, 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
# На столько секунд визит длиннее своих страниц: торговое событие встаёт
# позже страницы, на которую село, и последним событием визита бывает
# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит
# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние
# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы.
TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1]
_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])
@@ -72,7 +78,7 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
@dataclass(frozen=True, slots=True)
class Day:
"""Поток событий одного дня: колонки выгрузки и шов для торговых событий.
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
Строки упорядочены по времени так их и проиграет проигрыватель.
`columns` колонки контракта схемы по его порядку, все до одной;
@@ -124,9 +130,15 @@ def stream(seed: int, day: int) -> Day:
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()))
# Странице подтверждения нужно место и под её покупку: подтверждение без
# покупки было бы потерей на клиентской стороне, а она здесь честная —
# потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому
# полночь забирает подтверждение вместе с торговым хвостом или не
# забирает ни того, ни другого.
room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0)
alive = second + room < DAY_SECONDS
visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages)
watch_id = ids.unique(rng, int(alive.sum()))
rest = _columns(rng, day, audience, visits, page, product, second)
columns = {
@@ -136,12 +148,16 @@ def stream(seed: int, day: int) -> Day:
}
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],
# Торговые события садятся на готовый трафиковый поток и отдают его
# целиком: в нём же они и упорядочиваются.
columns, page, product = commerce.weave(
seed,
day,
{name: value[order] for name, value in columns.items()},
page[alive][order],
product[alive][order],
)
return Day(day=day, columns=columns, page=page, product=product)
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
@@ -161,7 +177,7 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
# Обещанный планом заказ достаётся первому визиту дня: слева от него
# соседей нет, поэтому двигать его внутри суток можно свободно.
ordering = audience.assigned_order[cookie] & (ordinal == 0)
stage = _funnel(rng, visits, ordering)
stage = _funnel(rng, audience.buyer[cookie], ordering)
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
browse = np.where(
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
@@ -181,17 +197,32 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
def _funnel(
rng: np.random.Generator, visits: int, ordering: NDArray[np.bool_]
rng: np.random.Generator,
buyer: NDArray[np.bool_],
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)
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
Помеченный планом покупатель отличается на обоих шагах: и до корзины
доходит чаще, и бросает её реже. Склонность покупать свойство
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
метку плана словом без следа в данных (спека генератора, раздел 9).
"""
draw = rng.integers(0, 100, (3, buyer.size))
cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT)
checkout = cart & (
draw[1]
< np.where(
buyer,
world.BUYER_CHECKOUT_OF_CART_PERCENT,
world.CHECKOUT_OF_CART_PERCENT,
)
)
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
stage = cart.astype(np.int64) + checkout + confirmation
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
# на том, что событие покупки в этот день случится (#40 его и повесит).
# на том, что событие покупки в этот день случится: его повесит `commerce`.
stage[ordering] = len(reference.FUNNEL_PAGES)
return stage
@@ -219,7 +250,7 @@ def _walk(
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]
@@ -227,8 +258,9 @@ def _walk(
goods = catalog.catalog()
shown = page == Page.PRODUCT
row_category = np.repeat(visits.category, visits.pages)[shown]
# Товар — равномерно внутри категории визита: популярность строк не
# моделируется, каталог дорастает механически (см. `catalog`).
# Товар — равномерно внутри категории визита: карточки всех товаров
# открывают одинаково часто, а различает их уровень спроса — уже в
# корзине, а не в показе (см. `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]
@@ -266,19 +298,28 @@ def _starts(
"""
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)
# Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с
# торговым хвостом: событие покупки встаёт на секунды позже страницы
# подтверждения, и зажимать его к последней секунде значило бы ломать
# правило ровно там, ради чего оно написано.
fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 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,
start[earlier]
+ duration[earlier]
+ TRADE_TAIL_SECONDS
+ world.VISIT_TIMEOUT_SECONDS
+ 1,
)
return start
@@ -370,7 +411,8 @@ def _columns(
"RegionCity": by_city("name"),
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
"RegionCityID": by_city("region_id", np.uint32),
# Цели дублируют торговые события, поэтому их ставит #40.
# Цели дублируют торговые события, поэтому их ставит `commerce`:
# у просмотра страницы достигнутых целей нет.
"GoalsReached": _blank(total, "uint32"),
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
@@ -449,21 +491,6 @@ def _ip_addresses(
)
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 — понедельник.
@@ -0,0 +1,47 @@
"""Идентификаторы событий: неповторяющиеся числа, которые переживут JSON.
Уникальность обещана не для красоты: `WatchID` ключ дедупликации при
переигровке дня (спека генератора, раздел 4), и два одинаковых номера
склеили бы разные события. Строк дня две породы трафиковые и торговые,
и рисует их разная случайность, поэтому обещание держится здесь, в одном
месте на весь генератор: у приёма одно определение, иначе дисциплина живёт
копиями и расходится с ними.
"""
import numpy as np
from numpy.typing import NDArray
# Потолок идентификаторов: выше 2^53 числа в JSON (jq, консоль браузера)
# округляются при разборе, и id перестаёт быть собой. Настоящая Метрика так
# не делает — её id длиннее (мастер-спека, раздел 1.1).
LIMIT = 2**53
def unique(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
Не броски наугад, а шаги случайной длины они не повторяются по
построению, и потом перемешивание, чтобы номер не выдавал порядок
строк. Средний шаг весь диапазон, делённый на число событий, поэтому в
среднем ряд занимает его половину.
"""
step = LIMIT // (size + 1)
numbers = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
return numbers[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
def unique_apart_from(
rng: np.random.Generator, size: int, taken: NDArray[np.uint64]
) -> NDArray[np.uint64]:
"""То же, но и с занятыми номерами ряд не пересекается.
Торговые события берут случайность из своего подпотока иначе правка
торгового поведения сдвинула бы трафик, а значит, про уже розданные
трафиковые номера их ряд ничего не знает. Совпадение двух рядов на
диапазоне в 2^53 невероятно, но обещание уникальности держит дедуп, и
проверить его дешевле, чем предположить.
"""
while True:
numbers = unique(rng, size)
if not np.intersect1d(numbers, taken).size:
return numbers
+33 -14
View File
@@ -9,7 +9,9 @@
Что план решает до генерации событий и чем связывает дни между собой:
- приток кто и когда впервые появился, и сколько раз вернётся;
- приток кто и когда впервые появился, и сколько раз вернётся; кука
помеченного покупателя живёт дольше прочих это один из двух рычагов
метки, второй лежит в воронке дня;
- двухкуковые пары какой человек завёл вторую куку и в какие дни
каждая из двух кук обязана оформить заказ;
- паспорт куки устройство и город: они у куки одни и те же во всех её
@@ -32,17 +34,14 @@ from functools import lru_cache
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import reference, world
from clickstream_generator import ids, reference, world
from clickstream_generator.seeds import cohort_stream
from clickstream_generator.weights import pick
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
CLIENT_ID_LIMIT = 2**53
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
# долю общего веса.
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS)
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
_DEVICE_CUMULATIVE = np.cumsum(
@@ -182,15 +181,19 @@ def cohort(seed: int, day: int) -> Cohort:
]
cookies = people + paired.size
client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64)
# Кука живёт числом ниже 2^53 — тот же потолок, что у номера события:
# выше JSON округляет при разборе. Граница не достигается.
client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64)
birth_day = np.full(cookies, day, dtype=np.int64)
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool)))
active_cookie, active_day = _active_days(
rng, birth_day, day + world.RETURN_TAIL_DAYS
rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS
)
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
pair_cookies, pair_order_days = _assign_orders(
@@ -204,8 +207,7 @@ def cohort(seed: int, day: int) -> Cohort:
people=people,
client_id=client_id,
birth_day=birth_day,
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
buyer=buyer_cookie,
active_cookie=active_cookie,
active_day=active_day,
pair_cookies=pair_cookies,
@@ -271,13 +273,30 @@ def _influx(rng: np.random.Generator, day: int) -> int:
def _active_days(
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
rng: np.random.Generator,
birth_day: NDArray[np.int64],
buyer: NDArray[np.bool_],
window_end: int,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
много реже и возвращается чаще. Это первый из двух рычагов метки
(второй воронка дня): метки в событии нет, поэтому «постоянный
покупатель» читается в данных только как кука, которая ходит неделями и
покупает не раз. Одним лифтом конверсии этого не добиться кука живёт
меньше двух визитов за снимок, и второй покупке негде случиться (спека
генератора, раздел 9).
"""
cookies = birth_day.size
returns = np.zeros(cookies, dtype=np.int64)
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
returning = rng.integers(0, 100, cookies) >= one_shot
for here, weights in (
(returning & ~buyer, _RETURN_COUNT_CUMULATIVE),
(returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE),
):
returns[here] = 1 + pick(rng, weights, int(here.sum()))
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
@@ -77,6 +77,19 @@ SEARCH_QUERIES = tuple(
)
# Вариант товара — цвет или исполнение карточки. Живёт только в сыром
# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из
# причин, по которым лаба «сырое против разобранного» вообще имеет смысл.
PRODUCT_VARIANTS = (
"стандарт",
"белый",
"серый",
"бежевый",
"синий",
"зелёный",
)
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
@@ -21,8 +21,10 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
пересобрать: `make docs`.
Одно событие одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок стороны источника. Хранилище принимает их как есть и
@@ -37,6 +39,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя цены каталога бывают с копейками.
Всего колонок: {count}."""
TABLE_HEADER = (
+91 -3
View File
@@ -82,6 +82,15 @@ BUYER_PERCENT = 5
# этом стоит лаба про склейку личности.
PAIRED_BUYER_PERCENT = 15
# Первый рычаг метки покупателя: помеченный дольше живёт и чаще возвращается.
# Одноразовым он бывает много реже прочих, а возвращается вдвое чаще —
# отсюда кука, которая ходит неделями. Без этого рычага «постоянный
# покупатель» в данных не читается: метки в событии нет и не будет
# (кликстрим анонимен), а кука живёт меньше двух визитов за снимок — второй
# покупке негде случиться (спека генератора, раздел 9).
BUYER_ONE_SHOT_PERCENT = 35
BUYER_RETURN_COUNT_WEIGHTS = (10, 11, 12, 12, 11, 10, 9, 7, 5, 4, 3, 2)
# --- Числа дня: суточная волна, визиты, воронка ---------------------------
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
@@ -111,7 +120,7 @@ VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
# и до ~50 тыс. добирают торговые события (#40).
# и до ~50 тыс. добирают торговые события.
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по
@@ -128,9 +137,88 @@ LONG_PAUSE_SECONDS = (300, 1500)
LONG_PAUSE_PERCENT = 12
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
# предыдущего. Произведение — конверсия визита в оформленный заказ: 2%
# предыдущего. Произведение — конверсия визита в оформленный заказ
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
# пар проходят воронку целиком независимо от этих долей.
CART_PERCENT = 8
CART_PERCENT = 6
CHECKOUT_OF_CART_PERCENT = 45
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
# Второй рычаг метки покупателя: помеченный отличается на обоих шагах
# воронки — и до корзины доходит чаще, и бросает её реже. В жизни
# различаются оба: кто пришёл смотреть, тот и кладёт реже, и до конца
# доводит реже; один шаг дал бы половину картины. Шаг подтверждения общий:
# оплата — про магазин, а не про склонность покупать.
BUYER_CART_PERCENT = 18
BUYER_CHECKOUT_OF_CART_PERCENT = 60
# --- Числа торговых событий: корзина, заказ, деньги ------------------------
# Торговое событие встаёт на несколько секунд позже своей страницы: корзина
# позже карточки, покупка позже подтверждения. Задержка короче самой
# короткой паузы между страницами — иначе событие корзины обогнало бы
# страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8)
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
# магнит, обычный, залёживается.
#
# Первый ряд — визит, дошедший до страницы корзины. Он пришёл покупать и
# кладёт почти всё, что сравнивал; уровень решает, что именно из сравненного
# он выберет, и залежавшийся товар проигрывает соседям по вкладке. Верх ряда
# высок намеренно: опусти его — и корзина такого визита схлопнется до одной
# позиции, а заказ до одного товара.
#
# Второй ряд — визит, который до страницы корзины не дошёл. Кладёт он редко,
# и магнит уходит у него из карточки втрое чаще обычного товара и вшестеро
# чаще залежавшегося: случайного посетителя решает соблазнить сам товар,
# больше решать нечему. Событий при этом выходит почти поровну — магнитов
# 47% таких добавлений, обычных 42%: обычных в каталоге вдвое с лишним
# больше, и разница уровней живёт в доле открытых карточек, а не в счёте
# событий. Нулю в этом ряду места нет: обнулить уровень значило бы вернуть
# по его товарам ровно то равенство «положил — открыл корзину», ради
# которого правка и делалась.
#
# Спрос растянут во втором ряду сильнее, чем в первом, и это не подгонка:
# намерение заслоняет товар, поэтому привлекательность видна там, где
# намерения нет.
SHOPPING_ADD_PERCENT = (98, 94, 55)
BROWSING_ADD_PERCENT = (6, 2, 1)
# Доля позиций корзины, которые остались брошенными: заказ уже корзины.
# Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был
# бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает:
# если брошены все позиции, одна остаётся (спека генератора, раздел 9).
# Число опущено с 15% вместе с отвязкой корзины от страницы корзины: корзина
# стала уже (1,7 позиции вместо 2,4), и прежние 15% чаще выносили её целиком,
# а спасённая позиция возвращала заказ к одному товару.
ABANDONED_POSITION_PERCENT = 10
# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна,
# изредка две-три — непродовольственная розница.
ITEM_QUANTITY_WEIGHTS = (85, 11, 4)
# Валюта магазина: один регион присутствия — одна валюта.
CURRENCY = "RUB"
# Доля заказов с промокодом.
COUPON_PERCENT = 20
# Промокоды: код и скидка в процентах. Таблица — число мира, а не выдумка
# бэкенда: этап 3 берёт её готовой и обязан дать заказу с кодом скидку,
# иначе данные соврут. В клиентскую выручку скидка не входит — код на сайте
# знает корзину, а не итог расчёта (спека генератора, раздел 9). Цифры в
# коде — те же проценты: скидка в рублях потребовала бы второго правила
# чтения таблицы, а вместе с ним и второго вида скидки на стороне бэкенда.
COUPONS = (
("VESNA10", 10),
("DOMASHNIY5", 5),
("PERVYY15", 15),
("UYUT7", 7),
)
# Цели счётчика: корзина и покупка. Цели дублируют торговые события — в бою
# так и бывает (мастер-спека, раздел 1.2).
GOAL_CART_ID = 42150001
GOAL_PURCHASE_ID = 42150002
+104
View File
@@ -6,8 +6,10 @@
целая цена.
"""
import collections
import csv
import re
import statistics
import numpy as np
@@ -54,6 +56,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
assert low <= int(row["price"]) <= high, row["sku"]
def test_part_of_the_prices_carry_kopecks():
"""Без копеек урок про Float64 беспредметен: округлять было бы нечего.
Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные
рублю, и с копейками тогда `productPrice` округляется форматом, а
`purchaseRevenue` несёт точную сумму.
"""
kopecks = [int(row["price"]) % 100 for row in rows()]
assert any(rest for rest in kopecks)
assert any(not rest for rest in kopecks)
def test_every_category_of_the_assortment_is_covered():
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
present = {row["category"] for row in rows()}
@@ -66,6 +80,96 @@ def test_categories_are_told_apart_by_prefix_and_by_address():
assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES)
def test_every_row_carries_a_known_demand_level():
"""Уровень спроса есть у каждой строки, и уровней ровно три."""
known = set(catalog.DEMAND_LEVELS)
by_category: dict[str, set[str]] = {}
for row in rows():
by_category.setdefault(row["category"], set()).add(row["demand"])
assert set().union(*by_category.values()) == known
# Уровни рассыпаны по всему ассортименту: категории целиком из магнитов
# или целиком из залежавшегося в магазине не бывает.
for category, here in by_category.items():
assert here == known, category
def test_the_demand_is_scattered_and_not_dealt_out_evenly():
"""Одинаковый расклад в каждой категории — та же неправдоподобная ровность.
Ровно её тикет и вычищал из данных: в живом магазине магнитов в посуде и
в одежде не поровну. Уровень тянется из одной колоды на весь каталог,
поэтому доли категорий расходятся сами.
"""
magnets: dict[str, int] = {}
for row in rows():
magnets.setdefault(row["category"], 0)
magnets[row["category"]] += row["demand"] == catalog.DEMAND_LEVELS[0]
assert len(set(magnets.values())) > 1, magnets
def price_tells_the_level(catalogue: list[dict[str, str]]) -> bool:
"""Читается ли уровень спроса по цене товара.
Спрашивается двумя способами сразу, потому что каждый по отдельности
обманывается: по четвертям цены все ли три уровня встречаются в каждой,
и по медиане цены уровня не съехала ли она от медианы каталога. Одни
только края списка ничего не стерегут: четырёх исключений в самых
дешёвых и самых дорогих строках хватает, чтобы вернуть в них все уровни.
"""
known = set(catalog.DEMAND_LEVELS)
by_price = sorted(catalogue, key=lambda row: int(row["price"]))
step = len(by_price) // 4
quarters = [by_price[start : start + step] for start in range(0, 4 * step, step)]
if any({row["demand"] for row in part} != known for part in quarters):
return True
whole = statistics.median(int(row["price"]) for row in catalogue)
for level in catalog.DEMAND_LEVELS:
here = [int(row["price"]) for row in catalogue if row["demand"] == level]
if not 0.7 < statistics.median(here) / whole < 1.3:
return True
return False
def derived_from_price(catalogue: list[dict[str, str]]) -> list[dict[str, str]]:
"""Подложный каталог: уровень выведен из цены, по краям — исключения.
Исключения подобраны так, чтобы в самой дешёвой и самой дорогой четверти
нашлись все три уровня: столько и нужно, чтобы обмануть проверку, которая
смотрит только на края списка.
"""
magnet, usual, slow = catalog.DEMAND_LEVELS
by_price = sorted(catalogue, key=lambda row: int(row["price"]))
counted = collections.Counter(row["demand"] for row in catalogue)
deck = [level for level in catalog.DEMAND_LEVELS for _ in range(counted[level])]
forged = [
dict(row, demand=level) for row, level in zip(by_price, deck, strict=True)
]
forged[0]["demand"] = slow
forged[-1]["demand"] = magnet
forged[-2]["demand"] = usual
return forged
def test_the_demand_level_is_not_the_price_in_disguise():
"""Отклонённый вариант: выводить склонность из цены (спека, раздел 9).
Жёсткая связь дала бы менти ровно то, что мы в неё вложили: «дорогое
залёживается» читалось бы из каталога, а не из данных.
"""
assert not price_tells_the_level(rows())
# Сторож сторожа: подмени уровни ценой — проверка обязана покраснеть,
# и краевые исключения её не должны обманывать.
assert price_tells_the_level(derived_from_price(rows()))
def test_the_demand_level_reaches_the_arrays_row_by_row():
goods = catalog.catalog()
assert [catalog.DEMAND_LEVELS[number] for number in goods.demand.tolist()] == [
row["demand"] for row in rows()
]
def test_the_catalog_is_grouped_by_category_whatever_the_file_order():
goods = catalog.catalog()
assert goods.count.sum() == goods.sku.size
+678
View File
@@ -0,0 +1,678 @@
"""Торговые события: корзина шире заказа, деньги целые, номера читаемые.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ
уже корзины», «конверсия визита около 2%»), а не при каждой правке.
Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким
тестам, считаются один раз на модуль.
"""
import json
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, plan, schema, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек.
FORTNIGHT = 14
@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)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: повторные покупки и обещания пар живут между днями."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
@pytest.fixture(scope="module")
def fortnight(week: list[day.Day]) -> list[day.Day]:
"""Две недели: неделя пересчитанной не бывает — день чист от соседей."""
later = range(WEEK, FORTNIGHT)
return week + [day.stream(CANONICAL_SEED, number) for number in later]
def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]:
"""Строки одного типа события — все колонки разом."""
here = events.columns["EventType"] == kind
return {name: value[here] for name, value in events.columns.items()}
def cells(column: NDArray[Any]) -> list[list[Any]]:
return [cell.tolist() for cell in column]
def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]:
"""Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком."""
return [json.loads(text) for text in rows["ecommerce"]]
def by_visit(events: day.Day) -> NDArray[np.int64]:
"""Порядок строк «визит, потом время» — так их читает лаба сессий."""
return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"]))
def test_the_day_carries_both_trade_events(weekday: day.Day):
"""Таксономия ожила: в дне есть и корзина, и покупка."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
assert orders["WatchID"].size > 100
# Корзина шире заказа и по событиям: не всякая корзина доходит до кассы.
assert carts["WatchID"].size > 2 * orders["WatchID"].size
def visits_with(events: day.Day, page: int) -> set[int]:
"""Визиты, у которых эта страница магазина дожила до потока.
Считаются просмотры страниц, а не строки с такой же страницей: торговое
событие садится на свою страницу и несёт её же, поэтому маска по одному
`page` посчитала бы событие корзины за просмотр карточки.
"""
here = (events.columns["EventType"] == "pageview") & (events.page == page)
return set(events.columns["VisitID"][here].tolist())
def confirmed_visits(events: day.Day) -> set[int]:
"""Визиты, чья страница подтверждения дожила до потока."""
return visits_with(events, Page.CONFIRMATION)
def visits_that_put(events: day.Day) -> set[int]:
"""Визиты с событием корзины — по типу события, а не по массивам товаров.
Строка покупки тоже несёт товары, поэтому «строка с `productID`» это
не «событие корзины».
"""
return set(rows_of(events, commerce.ADD_TO_CART)["VisitID"].tolist())
def cart_products(events: day.Day) -> list[int]:
"""Номера товаров, положенных в корзину, — по позиции на строку."""
here = events.columns["EventType"] == commerce.ADD_TO_CART
return events.product[here].tolist()
def card_views(events: day.Day) -> int:
"""Просмотры карточек товара в дне — строками потока, с повторами."""
pageview = events.columns["EventType"] == "pageview"
return int((pageview & (events.page == Page.PRODUCT)).sum())
def opened_cards(events: day.Day) -> set[tuple[int, int]]:
"""Разные карточки визитов: повторный просмотр внутри визита — один раз.
Так же считается и позиция корзины, поэтому это и есть честный
знаменатель конверсии «просмотр карточки корзина».
"""
pageview = events.columns["EventType"] == "pageview"
here = pageview & (events.page == Page.PRODUCT)
return set(
zip(
events.columns["VisitID"][here].tolist(),
events.product[here].tolist(),
strict=True,
)
)
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
monkeypatch: pytest.MonkeyPatch,
):
"""Полночь режет подтверждение и покупку вместе — или не режет ни одну.
Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на
клиентской стороне завелась бы потеря. Хвост торгового события здесь
растянут до часа: в каноническом мире он секунды, и такой визит
выпадает раз в годы, а правило обязано держаться при любом хвосте.
"""
monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600))
monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600)
events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought
assert confirmed_visits(events) == bought
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
"""Одно подтверждение — один заказ (мастер-спека, раздел 4)."""
orders = rows_of(weekday, commerce.PURCHASE)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in orders[name]} == {1}, name
assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY}
def test_the_cart_event_has_no_order_block(weekday: day.Day):
"""У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in carts[name]} == {0}, name
def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day):
"""Длина общая внутри группы `product*`; с `purchase*` она не совпадает."""
trade = weekday.columns["EventType"] != "pageview"
names = [
column.name for column in schema.COLUMNS if column.name.startswith("product")
]
assert len(names) == 6
sizes = np.array(
[[cell.size for cell in weekday.columns[name][trade]] for name in names]
)
assert np.all(sizes == sizes[0])
# Корзина — всегда один товар, заказ — от одного и больше.
carts = rows_of(weekday, commerce.ADD_TO_CART)
assert {cell.size for cell in carts["productID"]} == {1}
orders = rows_of(weekday, commerce.PURCHASE)
assert min(cell.size for cell in orders["productID"]) == 1
assert max(cell.size for cell in orders["productID"]) > 1
def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day):
"""Сырое и разобранное — одно событие, поле в поле.
Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба
«сырое против разобранного» учила бы неправде. Цена единственная пара,
где расхождение законно: в колонке целые рубли, в сыром точная сумма,
поэтому сверяется не равенство, а что колонка та же цена, округлённая
до рубля.
"""
for kind, action in (
(commerce.ADD_TO_CART, commerce.ADD_ACTION),
(commerce.PURCHASE, commerce.PURCHASE_ACTION),
):
rows = rows_of(weekday, kind)
assert rows["ecommerce"].size > 100
for number, block in enumerate(raw_of(rows)):
assert block["currencyCode"] == world.CURRENCY
products = block[action]["products"]
for field, column in (
("id", "productID"),
("name", "productName"),
("category", "productCategory"),
("quantity", "productQuantity"),
):
assert [item[field] for item in products] == (
rows[column][number].tolist()
), (kind, field)
assert rows["productEventType"][number].tolist() == [action] * len(products)
prices = zip(
(item["price"] for item in products),
rows["productPrice"][number].tolist(),
strict=True,
)
assert all(abs(exact - whole) <= 0.5 for exact, whole in prices)
orders = rows_of(weekday, commerce.PURCHASE)
for number, block in enumerate(raw_of(orders)):
field = block[commerce.PURCHASE_ACTION]["actionField"]
assert field["id"] == orders["purchaseID"][number][0]
assert field["revenue"] == orders["purchaseRevenue"][number][0]
def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day):
"""Иначе лаба «сырое против разобранного» разбирала бы то же самое."""
orders = rows_of(weekday, commerce.PURCHASE)
goods = catalog.catalog()
brands = set(goods.brand.tolist())
seen_brands: set[str] = set()
for block in raw_of(orders):
for item in block[commerce.PURCHASE_ACTION]["products"]:
assert item["brand"] in brands
assert item["variant"]
seen_brands.add(item["brand"])
assert len(seen_brands) > 1
assert not any(
column.name in ("productBrand", "productVariant") for column in schema.COLUMNS
)
def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day):
"""Деньги целые; дробное число в событии одно — выручка (спека, раздел 2)."""
floats = [
column.name for column in schema.COLUMNS if column.numpy_dtype == "float64"
]
assert floats == ["purchaseRevenue"]
goods = catalog.catalog()
orders = rows_of(weekday, commerce.PURCHASE)
sku = {article: number for number, article in enumerate(goods.sku.tolist())}
for number, revenue in enumerate(orders["purchaseRevenue"]):
assert revenue.dtype == np.float64
kopecks = sum(
int(goods.price[sku[article]]) * pieces
for article, pieces in zip(
orders["productID"][number].tolist(),
orders["productQuantity"][number].tolist(),
strict=True,
)
)
assert revenue[0] == kopecks / 100
assert orders["productPrice"][number].dtype == np.int64
def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day):
"""Часть цен несёт копейки, и выручку по массивам события не пересобрать.
Разрыв живёт внутри одного события: `productPrice` округлён форматом,
`purchaseRevenue` точна. Это и есть урок про Float64 не выдуманный.
"""
orders = rows_of(weekday, commerce.PURCHASE)
apart = 0
for number, revenue in enumerate(orders["purchaseRevenue"]):
by_arrays = float(
(orders["productPrice"][number] * orders["productQuantity"][number]).sum()
)
apart += by_arrays != revenue[0]
assert 0.1 < apart / orders["WatchID"].size < 0.9
def basket_and_order(events: day.Day) -> tuple[list[int], list[int]]:
"""По каждому заказу — сколько позиций было в корзине и сколько куплено.
Заодно проверяется само отношение «заказ из корзины»: пустых заказов не
бывает, купленного мимо корзины не бывает, позиция в заказе одна на товар.
"""
carts = rows_of(events, commerce.ADD_TO_CART)
orders = rows_of(events, commerce.PURCHASE)
put: dict[int, set[str]] = {}
for visit, article in zip(
carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True
):
put.setdefault(visit, set()).add(article)
baskets: list[int] = []
sizes: list[int] = []
for visit, bought in zip(
orders["VisitID"].tolist(), cells(orders["productID"]), strict=True
):
assert bought, "пустых заказов не бывает"
assert set(bought) <= put[visit], "куплено то, чего не клали в корзину"
assert len(set(bought)) == len(bought), "позиция в заказе одна на товар"
baskets.append(len(put[visit]))
sizes.append(len(bought))
return baskets, sizes
def test_the_order_is_narrower_than_the_cart(weekday: day.Day):
"""Часть положенных позиций не куплена — иначе сравнивать было бы нечего."""
baskets, sizes = basket_and_order(weekday)
assert sum(baskets) > sum(sizes)
def test_the_order_keeps_its_shape_across_the_snapshot(fortnight: list[day.Day]):
"""Состав заказа — доли окна снимка, а не одного дня.
Заказов в дне около 240, и доля однопозиционных гуляет по дням на
несколько пунктов от одной случайности выборки; вилки тикета заданы на
14 днях, там же они и меряются. Список позиций единственный носитель
урока про вложенный JSON: схлопнись он в один товар, урок бы умер.
"""
baskets: list[int] = []
sizes: list[int] = []
for events in fortnight:
was, bought = basket_and_order(events)
baskets += was
sizes += bought
assert 1.6 < sum(sizes) / len(sizes) < 2.2
assert 0.40 < sum(size == 1 for size in sizes) / len(sizes) < 0.60
# Брошено не ничего и не всё. Вилка та же, что и до правки: число мира
# опустилось с 15% до 10%, замер по покупающим корзинам — с 12,0% до
# 7,2%, и запас до нижней границы остался в полтора раза. Ниже её
# ронять незачем: с ней сторож замечает, что число мира срезали вдвое,
# а без неё — уже нет.
dropped = 1 - sum(sizes) / sum(baskets)
assert 0.05 < dropped < 0.30
def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day):
"""Товар в корзине, которого никто не открывал, — глупость в воронке."""
order = by_visit(weekday)
visit = weekday.columns["VisitID"][order]
kind = weekday.columns["EventType"][order]
product = weekday.product[order]
page = weekday.page[order]
shown: dict[int, set[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == "pageview" and page[number] == Page.PRODUCT:
shown.setdefault(this, set()).add(int(product[number]))
put: dict[int, list[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == commerce.ADD_TO_CART:
put.setdefault(this, []).append(int(product[number]))
assert put
for this, products in put.items():
assert set(products) <= shown[this]
# Карточка, открытая дважды, даёт одну позицию, а не две.
assert len(set(products)) == len(products)
def test_putting_something_in_the_cart_is_not_opening_the_cart_page(
fortnight: list[day.Day],
):
"""Положил и корзину не открыл — самый массовый сюжет магазина.
До правки два множества совпадали в точности все 14 дней, и факт
добавления идеально предсказывался более поздним просмотром страницы
такого равенства в живых данных не бывает. Сторож меряет долю, а не факт
различия одним визитом: одно расхождение прошлую ложь не лечит.
"""
quiet = putting = 0
for events in fortnight:
put = visits_that_put(events)
quiet += len(put - visits_with(events, Page.CART))
putting += len(put)
assert quiet / putting > 1 / 3
def test_a_visit_that_opened_the_cart_page_has_something_in_the_cart(
fortnight: list[day.Day],
):
"""Страница корзины при пустой корзине невозможна.
Это включение намеренное и остаётся: ломалось обратное. Проверяется на
обеих неделях и на буднях, и на выходных: правило про всякий визит, а
не про удачный день.
"""
for events in fortnight:
assert visits_with(events, Page.CART) <= visits_that_put(events)
def test_no_demand_level_is_locked_behind_the_cart_page(fortnight: list[day.Day]):
"""Отвязка от страницы корзины — про весь ассортимент, а не про часть.
Обнули вероятность целому уровню и по его товарам вернётся ровно то
равенство, ради которого правка делалась: «положили значит, откроют
корзину», без единого исключения на четверти каталога. Сторож меряет
долю у каждого уровня, а не факт: одно событие такую примету не лечит.
"""
goods = catalog.catalog()
levels = range(len(catalog.DEMAND_LEVELS))
quiet = [0] * len(catalog.DEMAND_LEVELS)
put = [0] * len(catalog.DEMAND_LEVELS)
for events in fortnight:
shopping = visits_with(events, Page.CART)
here = events.columns["EventType"] == commerce.ADD_TO_CART
for visit, number in zip(
events.columns["VisitID"][here].tolist(),
events.product[here].tolist(),
strict=True,
):
put[goods.demand[number]] += 1
quiet[goods.demand[number]] += visit not in shopping
for level in levels:
assert quiet[level] / put[level] > 0.10, catalog.DEMAND_LEVELS[level]
def test_the_demand_level_tells_the_cart_conversion_apart(fortnight: list[day.Day]):
"""Привлекательность товара стала измеримой величиной, а не шумом.
Знаменатель разные карточки товаров этого уровня: повторный просмотр
внутри визита считается один раз, так же как считается позиция. До
правки решение принималось один раз на визит, и по sku выходил чистый
шум: внутри воронки 100%, вне её 0%.
"""
goods = catalog.catalog()
levels = range(len(catalog.DEMAND_LEVELS))
shown = [0] * len(catalog.DEMAND_LEVELS)
put = [0] * len(catalog.DEMAND_LEVELS)
for events in fortnight:
for _, number in opened_cards(events):
shown[goods.demand[number]] += 1
for number in cart_products(events):
put[goods.demand[number]] += 1
share = [put[level] / shown[level] for level in levels]
magnet, usual, slow = share
assert magnet > usual > slow
assert magnet >= 2 * slow
def test_the_cart_events_stay_inside_the_event_budget(fortnight: list[day.Day]):
"""Меняется не сколько кладут, а кто и что: бюджет событий на месте.
Доля просмотров карточек, дошедших до корзины, та же величина, что
держала бюджет до правки; средний день остаётся около 50 тыс. событий
(спека генератора, разделы 5 и 9).
"""
put = sum(len(cart_products(events)) for events in fortnight)
seen = sum(card_views(events) for events in fortnight)
assert 0.05 < put / seen < 0.09
average = sum(len(events) for events in fortnight) / len(fortnight)
assert 48_000 < average < 52_000
def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.
Событие не просто стоит после какой-то страницы: у события корзины
предыдущая строка визита карточка именно этого товара, и товар в
массивах события тот же. Иначе в корзину попал бы товар, которого
посетитель не открывал, а событие село бы на чужую страницу.
"""
order = by_visit(weekday)
columns = {name: value[order] for name, value in weekday.columns.items()}
page, product = weekday.page[order], weekday.product[order]
kind = columns["EventType"]
goods = catalog.catalog()
trade = np.flatnonzero(kind != "pageview")
assert trade.size > 100
for row in trade.tolist():
assert columns["VisitID"][row] == columns["VisitID"][row - 1]
assert columns["URL"][row] == columns["URL"][row - 1]
assert columns["Referer"][row] == columns["Referer"][row - 1]
away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype(
"int64"
)
assert 0 < away < world.TRADE_DELAY_SECONDS[1]
if kind[row] == commerce.ADD_TO_CART:
assert page[row] == page[row - 1] == Page.PRODUCT
# Карточка предыдущей строки — карточка этого самого товара.
assert product[row] == product[row - 1] >= 0
assert columns["productID"][row].tolist() == [goods.sku[product[row]]]
# У страницы входа в адресе ещё метки перехода — путь до «?».
address = columns["URL"][row].split("?")[0]
assert address.endswith(f"/product/{goods.sku[product[row]]}")
else:
assert page[row] == page[row - 1] == Page.CONFIRMATION
assert kind[row - 1] == "pageview"
def test_the_goals_repeat_the_trade_events(weekday: day.Day):
"""Цели дублируют события — в бою так и бывает (мастер-спека, 1.2)."""
goals = {
kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]}
for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE)
}
assert goals["pageview"] == {()}
assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)}
assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)}
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
assert len(set(numbers)) == len(numbers)
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
weekday: day.Day,
):
"""Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций."""
orders = rows_of(weekday, commerce.PURCHASE)
known = {code for code, _ in world.COUPONS}
coupons = [cell[0] for cell in orders["purchaseCoupon"]]
assert set(coupons) <= known | {""}
with_coupon = [code for code in coupons if code]
assert 0.05 < len(with_coupon) / len(coupons) < 0.40
assert len(set(with_coupon)) > 1
# Скидка в клиентскую выручку не входит: сумма сходится с позициями и с
# тем, что уехало в сыром JSON.
for number, block in enumerate(raw_of(orders)):
action = block[commerce.PURCHASE_ACTION]["actionField"]
assert action["revenue"] == orders["purchaseRevenue"][number][0]
assert ("coupon" in action) == bool(coupons[number])
def test_editing_the_trade_flow_does_not_move_the_traffic(
monkeypatch: pytest.MonkeyPatch,
):
"""Иерархия подпотоков: у торговли своя случайность (спека, раздел 2)."""
ours = day.stream(CANONICAL_SEED, WEEKDAY)
plan.cohort.cache_clear()
monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40)
other = day.stream(CANONICAL_SEED, WEEKDAY)
def pageviews(events: day.Day) -> dict[str, list[Any]]:
rows = rows_of(events, "pageview")
return {
name: [
cell.tolist() if isinstance(cell, np.ndarray) else cell
for cell in value
]
for name, value in rows.items()
}
assert pageviews(other) == pageviews(ours)
assert [
cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"]
] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]]
def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой.
Неподвижности трафика для этого мало: возьми торговля ветвь трафика,
трафик всё равно не сдвинулся бы свои броски он сделал раньше и из
своего генератора. Разошлись бы только два подпотока, ставшие одним, а
в дереве зерна место каждого компонента и есть контракт (спека,
раздел 2). Поэтому проверяется адрес, по которому торговля пришла.
"""
asked: list[tuple[int, int, Component]] = []
honest = commerce.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
"""Гарантия плана: обе куки пары покупают в назначенные им дни.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
bought = [
set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week
]
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
for cookie, number in zip(cookies, days, strict=True):
assert int(cohort.client_id[cookie]) in bought[number]
assert pairs > 10
# Счётчик пар в плане и пары, реализованные в дне, — про одно и то же.
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set()
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist())
return cookies
def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
"""Метка покупателя перестала быть словом: у неё виден след в данных.
«Постоянный покупатель» читается только так как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
против 1,5% прочих втрое чаще при пороге «вдвое».
"""
purchases: dict[int, int] = {}
flagged: set[int] = set()
for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
purchases[cookie] = purchases.get(cookie, 0) + 1
def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
return again / len(cookies)
buyers = set(purchases)
by_levers = (buyers & flagged) - paired_cookies()
others = buyers - flagged
assert len(by_levers) > 100
assert len(others) > 100
assert repeat_share(by_levers) > 2 * repeat_share(others)
def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]):
"""Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия."""
counters = plan.counters(CANONICAL_SEED, WEEK)
assert all(6_000 <= size <= 8_000 for size in counters.audience)
for events in week:
visits = len(set(events.columns["VisitID"].tolist()))
orders = int((events.columns["EventType"] == commerce.PURCHASE).sum())
assert 40_000 < len(events) < 60_000
assert 0.015 < orders / visits < 0.035
+45 -19
View File
@@ -1,4 +1,4 @@
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
@@ -14,7 +14,16 @@ import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, day, plan, reference, schema, world
from clickstream_generator import (
catalog,
commerce,
day,
ids,
plan,
reference,
schema,
world,
)
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED
@@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
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):
def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day):
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
#40 добавит свои, и они должны попасть под тот же сторож.
Проверяются все торговые колонки, а не выбранные: у просмотра страницы
пусты они все до одной, иначе строгий приём хранилища не уживётся с
полями, пустыми по смыслу (мастер-спека, раздел 6).
"""
waiting = [
pageview = weekday.columns["EventType"] == "pageview"
assert pageview.mean() > 0.9
trade = [
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]
assert len(trade) > 10
for column in trade:
cells = weekday.columns[column.name][pageview][: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_the_taxonomy_is_three_event_types(weekday: day.Day):
"""`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1)."""
assert set(weekday.columns["EventType"].tolist()) == {
"pageview",
commerce.ADD_TO_CART,
commerce.PURCHASE,
}
def test_no_column_hides_a_hole(weekday: day.Day):
@@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day):
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].max() < ids.LIMIT
assert weekday.columns[name].dtype == np.uint64
@@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
"""Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5)."""
assert 30_000 < len(weekday) < 70_000
visits = len(set(weekday.columns["VisitID"].tolist()))
assert 6_000 < visits < 14_000
@@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
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())
ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum())
assert 0.01 < ordered / visits < 0.04
@@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
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)
@@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
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]
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника.
Цепочка считается по просмотрам страниц: торговое событие не открывает
страницу, а живёт на уже открытой, и реферер у него тот же, что у неё.
"""
pageview = weekday.columns["EventType"] == "pageview"
columns = {name: value[pageview] for name, value in weekday.columns.items()}
order = np.lexsort((columns["UTCEventTime"], columns["VisitID"]))
url = columns["URL"][order]
referer = columns["Referer"][order]
visit = columns["VisitID"][order]
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
assert np.all(referer[inside] == url[inside - 1])
+41
View File
@@ -0,0 +1,41 @@
"""Идентификаторы событий: неповторяющиеся и переживающие JSON.
Обещание одно, но держит его дедупликация при переигровке дня: два
одинаковых `WatchID` склеили бы разные события (спека, раздел 4).
"""
import numpy as np
from clickstream_generator import ids
from clickstream_generator.seeds import CANONICAL_SEED
def stream(offset: int = 0) -> np.random.Generator:
return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset))
def test_ids_do_not_repeat_and_survive_json():
numbers = ids.unique(stream(), 10_000)
assert numbers.dtype == np.uint64
assert len(set(numbers.tolist())) == numbers.size
assert numbers.max() < ids.LIMIT
assert numbers.min() > 0
def test_ids_do_not_give_away_the_order_of_the_rows():
"""Иначе номер события рассказывал бы, каким по счёту оно родилось."""
numbers = ids.unique(stream(), 1_000)
assert not np.all(np.diff(numbers.astype(np.int64)) > 0)
def test_ids_of_another_stream_are_other_ids():
assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100))
def test_ids_can_be_drawn_beside_the_ones_already_taken():
"""Торговые строки берут номера из своего подпотока и не задевают чужие."""
taken = ids.unique(stream(), 5_000)
numbers = ids.unique_apart_from(stream(1), 500, taken)
assert len(set(numbers.tolist())) == numbers.size
assert not set(numbers.tolist()) & set(taken.tolist())
assert numbers.max() < ids.LIMIT
+73 -14
View File
@@ -6,7 +6,7 @@
«в среднем 34 возврата» и «средняя кука активна 1,9 дня».
"""
from clickstream_generator import world
from clickstream_generator import catalog, world
def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
return weighted / sum(weights)
def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float:
"""Сколько дней в среднем ходит кука: день рождения плюс возвраты."""
counts = tuple(range(1, len(return_weights) + 1))
returns = mean_by_weights(counts, return_weights)
return 1 + (100 - one_shot_percent) / 100 * returns
def test_origin_is_a_monday():
"""День недели считается как остаток номера дня — это верно от понедельника."""
assert world.ORIGIN.weekday() == 0
@@ -36,10 +43,7 @@ def test_returns_average_three_to_four():
def test_average_cookie_is_active_about_two_days():
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
assert 1.8 <= active_days <= 2.0
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
def test_return_delays_cover_the_whole_activity_window():
@@ -108,12 +112,67 @@ def test_pauses_stay_inside_the_visit_timeout():
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
def test_the_trade_event_never_overtakes_the_next_page():
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
на которой посетитель нажал кнопку, и не выходит за полночь за ним в
том же визите всегда идёт страница, которая полночь пережила.
"""
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
def test_the_ordinary_visitor_converts_a_bit_below_the_world():
"""Воронка обычного посетителя: ~1,5% визитов в оформленный заказ.
До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели
и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не
итог: сам итог меряет день-функция на собранном дне
(`test_the_shop_stays_the_same_plausible_shop`).
"""
assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8
def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
"""Один шаг дал бы половину картины: кладут реже и бросают чаще оба."""
assert world.BUYER_CART_PERCENT > world.CART_PERCENT
assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT
lift = conversion(
world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT
) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT)
# Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали
# бы тогда около 40 заказов в день вместо 240 (спека, раздел 9).
assert 2 <= lift <= 6
def test_the_demand_levels_keep_their_order_and_yield_to_the_intent():
"""Ряды вероятностей читаются по уровням каталога, и оба убывают.
Совпадение длин не формальность: ряды индексируются номером уровня,
и новый уровень в файле каталога обязан получить здесь своё число.
"""
rows = (world.SHOPPING_ADD_PERCENT, world.BROWSING_ADD_PERCENT)
for row in rows:
assert len(row) == len(catalog.DEMAND_LEVELS)
assert row[0] > row[1] > row[2], row
# Намерение сильнее товара: визит, пришедший покупать, кладёт чаще на
# любом уровне — иначе слово «намерение» ничего бы не значило.
assert all(shopping > browsing for shopping, browsing in zip(*rows, strict=True))
# Но и не всё подряд: товар решает у обоих, спрос не декорация.
assert min(world.SHOPPING_ADD_PERCENT) < 100
def test_the_buyer_mark_makes_the_cookie_live_longer():
"""Второй рычаг метки: без долгой жизни второй покупке негде случиться."""
assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT
usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS)
buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS)
assert buyer > 2 * usual
# Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а
# помеченных всего 5% людей когорты.
assert buyer < world.RETURN_TAIL_DAYS / 10
+28 -1
View File
@@ -8,6 +8,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
{ name = "orjson" },
]
[package.dev-dependencies]
@@ -18,7 +19,10 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "numpy", specifier = ">=2" }]
requires-dist = [
{ name = "numpy", specifier = ">=2" },
{ name = "orjson", specifier = ">=3.11.9" },
]
[package.metadata.requires-dev]
dev = [
@@ -74,6 +78,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
]
[[package]]
name = "orjson"
version = "3.11.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
]
[[package]]
name = "packaging"
version = "26.2"