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
+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):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.