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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 штук. Обычно одна,
|
||||
# изредка две-три — непродовольственная розница.
|
||||
|
||||
Reference in New Issue
Block a user