feat(generator): корзина отвязана от страницы, у товара — уровень спроса

- Зачем:
  - приёмка #40 нашла в торговых данных два точных равенства, каких в живом
    магазине не бывает: событие корзины случалось ровно у визитов со
    страницей /cart, а внутри такого визита в корзину уходили все открытые
    карточки. Привлекательность товара было нечем измерить, а аналитик читал
    бы эти равенства как склейку в разметке.
- Что:
  - класть в корзину может любой визит, открывший карточку; страница /cart
    осталась шагом воронки, а у визита, дошедшего до неё, корзина непуста.
  - у товара появился уровень спроса — колонка каталога с тремя значениями
    и два ряда вероятностей в числах мира: намерение визита берётся из шага
    воронки, а не из метки покупателя, которая уже действует через неё.
  - уровни рассыпаны по каталогу одной колодой: одинакового расклада по
    категориям нет, связи с ценой нет, и то и другое сторожится тестами.
  - числа мира перемерены: конверсия карточки в корзину 8,62% (по уровням
    13,1 / 9,2 / 5,8), кладут без открытия корзины 41,4% таких визитов,
    средний день 49 834 события, конверсия визита в покупку 2,43%.
  - спека генератора и мастер-спека приведены к новой форме каталога.
- Проверка:
  - make lint && make typecheck && make test — 394 passed (было 383).
  - трафиковая половина побайтово та же: sha256 по (URL, времени, WatchID)
    всех просмотров за 14 канонических дней совпадает со снимком до правки.
This commit is contained in:
2026-08-02 22:46:30 +03:00
parent 4165f10cdd
commit c0f4d21df9
10 changed files with 734 additions and 248 deletions
+22 -9
View File
@@ -10,16 +10,22 @@
раз в неделю, корзина в двадцать позиций.
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
price`; артикул — четыре латинские буквы категории, дефис и четыре цифры;
цена — целые копейки (деньги генератор считает целыми, спека, раздел 2).
Часть цен кратна рублю, часть несёт копейки — как в обычной рознице
(1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: округлять
нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы
выдумывать (спека генератора, раздел 9).
price,demand`; артикул — четыре латинские буквы категории, дефис и четыре
цифры; цена — целые копейки (деньги генератор считает целыми, спека,
раздел 2). Часть цен кратна рублю, часть несёт копейки — как в обычной
рознице (1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным:
округлять нечего, и разрыв между `productPrice` и `purchaseRevenue`
пришлось бы выдумывать (спека генератора, раздел 9).
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
считают, а товар для карточки выбирается равномерно внутри категории.
Популярность товаров не моделируется — придумывать вес каждой строке
пришлось бы вручную, а каталог растёт механически.
**Уровень спроса** — привлекательность товара, а не частота его показа:
карточку любого товара открывают одинаково часто, но магнит из открытой
карточки уходит в корзину чаще, чем залежавшийся. Уровень приписан строке
в файле — он постоянная часть мира, а не бросок дня, — и приписан россыпью:
ни с ценой, ни с категорией он не связан. Связь с ценой была бы жёсткой, и
менти нашёл бы в данных ровно то, что мы в них вложили (спека генератора,
раздел 9). Сами вероятности живут в числах мира, здесь только имена уровней.
"""
import csv
@@ -34,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)
@@ -70,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]
@@ -85,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,
+98 -32
View File
@@ -8,12 +8,18 @@
граница модельных суток режет всё, что за неё вышло: визит, у которого
подтверждение срезано полуночью, покупки не даёт — заказа не было.
**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые
открывал в этом визите; карточка, открытая дважды, даёт одну позицию —
повторный просмотр это раздумье, а не второй товар. Покупает он не всё:
часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы
ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы
нечего.
**Кладёт любой визит, открывший карточку.** Положить и корзину не открыть —
самый массовый сюжет магазина, поэтому событие корзины не привязано к
странице `/cart`: иначе факт добавления идеально предсказывался бы более
поздним просмотром страницы, а такого равенства в живых данных не бывает.
Решается каждая открытая карточка отдельно, а не визит целиком, и решают её
двое: намерение визита и уровень спроса товара (числа мира). Карточка,
открытая дважды, даёт одну позицию — повторный просмотр это раздумье, а не
второй товар.
**Корзина шире заказа.** Покупает посетитель не всё, что положил: часть
позиций остаётся брошенной. Иначе событие корзины не рассказывало бы ничего
сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся
целые рубли, как у Метрики, а дробное число в событии одно —
@@ -145,7 +151,7 @@ def weave(
события встают между ними, и поток пересобирается одним порядком.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(page, product, columns["VisitID"])
baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets):
return columns, page, product
@@ -172,13 +178,24 @@ def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
def _baskets(
page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64]
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]
@@ -187,54 +204,103 @@ def _baskets(
# упорядоченным, а сортировка по визиту устойчивая.
started = np.concatenate(([True], visit[1:] != visit[:-1]))
number = np.cumsum(started) - 1
visits = int(started.sum())
with_cart = number[page == Page.CART]
basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64)
basket_of_visit[with_cart] = np.arange(with_cart.size)
cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0))
goods = catalog.catalog()
cards = np.flatnonzero(page == Page.PRODUCT)
# Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений,
# поэтому вторая карточка того же товара позиции не добавляет.
key = number[cards] * catalog.catalog().sku.size + product[cards]
positions = np.sort(cards[np.unique(key, return_index=True)[1]])
# поэтому вторая карточка того же товара кандидата не добавляет.
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_cart.size, -1, dtype=np.int64)
confirmation = np.full(with_basket.size, -1, dtype=np.int64)
confirmation[basket_of_visit[number[confirmed]]] = order[confirmed]
basket = basket_of_visit[number[positions]]
count = np.bincount(basket, minlength=with_cart.size)
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
)
rescued = baskets.first + rng.integers(0, baskets.count)
empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0
kept[rescued[empty]] = True
_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)
+3 -2
View File
@@ -258,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]
+30 -1
View File
@@ -160,11 +160,40 @@ 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).
ABANDONED_POSITION_PERCENT = 15
# Число опущено с 15% вместе с отвязкой корзины от страницы корзины: корзина
# стала уже (1,7 позиции вместо 2,4), и прежние 15% чаще выносили её целиком,
# а спасённая позиция возвращала заказ к одному товару.
ABANDONED_POSITION_PERCENT = 10
# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна,
# изредка две-три — непродовольственная розница.
+92
View File
@@ -6,8 +6,10 @@
целая цена.
"""
import collections
import csv
import re
import statistics
import numpy as np
@@ -78,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
+200 -10
View File
@@ -21,6 +21,9 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек.
FORTNIGHT = 14
@pytest.fixture(autouse=True)
@@ -40,6 +43,13 @@ 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
@@ -69,11 +79,58 @@ def test_the_day_carries_both_trade_events(weekday: day.Day):
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"
here = pageview & (events.page == Page.CONFIRMATION)
return set(events.columns["VisitID"][here].tolist())
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):
@@ -238,26 +295,63 @@ def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day):
assert 0.1 < apart / orders["WatchID"].size < 0.9
def test_the_order_is_narrower_than_the_cart(weekday: day.Day):
"""Часть положенных позиций не куплена — иначе сравнивать было бы нечего."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
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)
dropped = whole = 0
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), "позиция в заказе одна на товар"
dropped += len(put[visit]) - len(bought)
whole += len(put[visit])
assert 0.05 < dropped / whole < 0.30
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):
@@ -284,6 +378,102 @@ def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day):
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):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.
+18 -1
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:
@@ -150,6 +150,23 @@ def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
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