Нет записи о покупке — нет покупки? Как реальные данные изменили постановку задачи

—

от автора

При подготовке данных Coveo для сессионной рекомендательной системы мне пришлось разобраться, как учить и проверять модель, если неизвестно, все ли покупки попали в выгрузку.

Для итоговой оценки на Coveo я подготовила 60 000 сессий. Но Hit@20 посчитала только по 748: в них есть зарегистрированная покупка после запроса. Модель дала 441 попадание — 58,96%.

Что произошло с остальными 59 252 сессиями? Я не записала их в промахи. Если строки purchase нет, это ещё не значит, что посетитель ничего не купил.

В первой статье я описала сессионную рекомендательную систему на Coveo: граф и другие источники находят товары-кандидаты, а LightGBM выбирает до 20 рекомендаций. Модель уже обучена, качество измерено, есть локальный HTTP API.

Репозиторий проекта: RecSys-Coveo.

Почему пришлось изменить задачу

Я использую Coveo SIGIR eCOM 2021, Public Data Release 1.0.0. В наборе три CSV: действия посетителей, поисковые запросы и признаки товаров. Авторы предупреждают, что регистрация событий в браузере может быть неполной. Даже порядок строк в файле не гарантирует порядок действий: последовательность приходится восстанавливать по идентификатору сессии и времени события.

Изначально я хотела различать покупку и её отсутствие. Для этого нужно знать, что все покупки попали в выгрузку. При проверке Coveo такого подтверждения не нашлось.

Поэтому я ограничила цель товарами из последующих зарегистрированных покупок. Пустая цель получила статус «неизвестна». Обучающие сравнения и Hit@20 теперь опираются на сессии, в которых цель есть.

Сессии без цели остались в выборке. Система всё равно должна отвечать на их запросы: в момент рекомендации она не знает, появится ли потом запись о покупке.

При подготовке я читаю все опубликованные строки выбранной сессии — действия и поиск. Потеря строк в моём коде была бы ошибкой подготовки. Но чтение всех строк не доказывает, что все покупки зарегистрированы. Даже если одна покупка записана, сведения о других могут отсутствовать.

Что модель знает в момент запроса

Сессии уже выделены в Coveo: у действий одного визита общий обезличенный идентификатор. По описанию данных, перерыв более 30 минут начинает новую сессию. Идентификаторы товаров тоже обезличены; дальше называю их SKU.

Для рекомендации беру первый просмотр товара с корректным SKU. Если несколько событий имеют одну отметку времени, учитываю их вместе: нельзя определить, какое произошло раньше. Если после этого в сессии ещё нет записи о покупке, формирую одну выдачу. Если покупка уже записана, запрос не допускается. Более поздние выдачи этой сессии в эксперимент не входят.

Вот условный пример. Буквы обозначают товары. В таблице время округлено до минут для читаемости; в расчёте использую исходные отметки с точностью до миллисекунды.

Время

Что записано

Как используется

09:59

Поиск

Доступен модели

10:00

Просмотр A

Доступен модели

После блока 10:00

Выдача рекомендаций

Момент запроса t_0 = 10{:}00

10:04

Просмотр B

Ещё неизвестен в момент запроса

10:10

Покупка B

Входит в цель

10:15

Покупка C

Входит в цель

Модель видит поиск и просмотр A. Просмотр B случился позже, поэтому добавлять его в признаки нельзя — даже если при подготовке мы уже прочитали всю сессию. Покупки B и C дают цель Y_s=\{B,C\} для сессии s. Для попадания в метрике достаточно угадать хотя бы один из этих товаров. Повторная запись покупки B размер цели не увеличит.

Покупки ищу строго после запроса, в пределах следующих 24 часов той же сессии. Если опубликованные записи заканчиваются раньше, окно цели заканчивается на последней записи.

Обозначу момент запроса через t_{0,s}, горизонт через H=24 часа, а время последней опубликованной записи поведения или поиска — через t_{\mathrm{last},s}^{\mathrm{obs}}. Тогда правая граница окна:

\tau_s=\min\bigl(t_{0,s}+H,\ t_{\mathrm{last},s}^{\mathrm{obs}}\bigr).

В цель входят все различные SKU зарегистрированных покупок той же сессии при t_{0,s}<t\leq\tau_s.

В примере окно заканчивается в 10:15, если это последняя опубликованная запись. О возможных покупках позднее она ничего не сообщает. Сама сессия не становится суточной: 24 часа — предел поиска цели, а границы визита уже заданы в Coveo.

Сначала выбираю сессии, потом смотрю покупки

Данные я заранее разделила на непересекающиеся отрезки времени — периоды для построения исторического пакета, обучения и проверки качества. Их границы, размеры выборок и правило отбора закрепила до анализа покупок. Сессии упорядочиваются по хешу ID с фиксированным начальным значением; из этого порядка берётся заданное число целых сессий. Для каждой читаются все строки поведения и поиска.

Если сессий не хватило, я не добираю их специально по наличию покупки. Будущая покупка также не влияет на момент запроса.

Исторический период A_G нужен для каталога, графа и статистик. Следующий период, B, — для обучения ранжировщика. Если оставить в A_G только сессии с покупками, из истории исчезнут просмотры и действия с корзиной всех остальных посетителей. Изменятся и популярность, и связи между товарами. Поэтому исторический пакет строю по всем сохранённым сессиям A_G.

Запрос допускаю к расчёту, только если от момента рекомендации до конца соответствующего периода остаётся больше 24 часов 30 минут: сутки для поиска целевых покупок в той же сессии и ещё полчаса запаса. Так окно цели не захватывает следующий период.

Правило действует и для коротких сессий, чьи записи заканчиваются раньше суток. Для периода J=[a_J,b_J) проверяю:

t_{0,s}\in J,\qquad t_{0,s}+H+g<b_J,\qquad g=30\text{ минут}.

Здесь a_J и b_J — начало и конец периода. Для A_G дополнительно требуется t_{\mathrm{last},s}^{\mathrm{obs}}+g<b_{A_G}. Если последнее событие слишком позднее, исключаю всю сессию — независимо от того, был это просмотр, поиск или покупка.

Эти правила одинаковы для сессий с покупкой и без неё. Запас защищает границы периодов, но не доказывает полноту записей. В признаки запроса входят только события, известные к его моменту, и исторические справочники.

Откуда тогда берутся нулевые метки

В подготовленных 50 000 сессий периода B зарегистрированная цель есть у 625. По этим запросам обучаю ранжировщик. Сначала фиксируется вся выборка, затем по последующим записям определяется, какие запросы дают обучающие сравнения. При отборе кандидатов и расчёте признаков эта информация недоступна.

У каждого такого запроса свой набор кандидатов. Товар получает метку 1, если его SKU входит в цель, и 0 — если не входит.

Вернёмся к примеру: в цель попали B и C, а отбор предложил A, B и X. У этих кандидатов будут метки 0, 1 и 0. Парное обучение требует поставить B выше A и X. Товара C в наборе нет, хотя он тоже входит в цель.

Ноль у A и X означает только одно: этих SKU нет в зарегистрированной цели. Посетитель мог вообще не увидеть X, а сведения о покупке могли не попасть в данные. Для обучения я принимаю допущение: кандидат с зарегистрированной покупкой должен стоять выше остальных кандидатов.

Это обучение по неявной обратной связи. На данные продолжают влиять прежние показы и популярность товаров. Балл LightGBM задаёт порядок рекомендаций; вероятность покупки из него я не получаю.

Не каждая из 625 сессий даёт обучающие пары: нужен хотя бы один кандидат с меткой 1 и один с меткой 0. Если отбор вернул только A и X, обе метки равны нулю — такой запрос не даёт обучающих пар. Добавить B или C специально ради обучения означало бы подсказать системе будущую покупку.

При оценке такая сессия всё равно остаётся в выборке. Отбор пропустил цель, и этот промах должен попасть в результат.

Почему в знаменателе 748, а не 60 000

В итоговом периоде D каждая из 748 сессий с зарегистрированной целью даёт одно наблюдение: попал ли хотя бы один целевой SKU в первые 20 рекомендаций. Несколько покупок, повторные записи или несколько угаданных товаров не увеличивают вклад сессии.

У основной модели 441 такое попадание:

\operatorname{Hit@20}_D=\frac{441}{748}\approx58{,}96\%.

Если поделить те же 441 попадание на все 60 000 сессий, получится 0,735%. Это другой показатель: доля всех подготовленных сессий с зарегистрированным попаданием. Он зависит ещё и от того, как часто в данных появляется цель. Hit@20 показывает, как часто рекомендации совпали с целью среди сессий, где она записана. Ни один из этих процентов не устанавливает конверсию посетителей.

Все методы сравниваю на одних и тех же 748 сессиях. Купленный товар остаётся в цели, даже если его нет в каталоге или среди кандидатов. Если системе недоступны все целевые товары, сессию всё равно учитываю в знаменателе.

Оцениваю именно доставленный ответ. Если основной расчёт заменён резервным списком, проверяю попадание резерва. Если допустимый ответ не доставлен в срок, результат считается пустым и даёт промах.

Сессии без цели использую для проверки времени расчёта и отказов.

Что эти проценты позволяют утверждать

Регистрация событий и прежние показы могли повлиять на то, какие сессии получили цель. Доверительный интервал сам по себе это смещение не исправит.

Отдельный вопрос — как оценить статистическую погрешность Hit@20. Сессии, происходящие близко по времени, могут быть связаны общими условиями: временем суток, акциями или изменениями ассортимента. Тысяча сессий за один вечер не обязательно даёт столько же независимой информации, сколько тысяча сессий за несколько недель.

Для учёта этой связи я предусмотрела расчёт доверительного интервала по временным блокам. Но предварительная выборка охватывала менее недели допустимых моментов рекомендации, и в ней был заметен суточный цикл. Этого оказалось недостаточно, чтобы обосновать выбранную длину блока — шесть часов. Поэтому надёжность интервала не подтверждена, а статус статистического вывода — insufficient. Это не ошибка в расчёте Hit@20 и не следствие неполноты записей о покупках.

Различия с другими методами описывают результат на выбранном периоде; статистическое превосходство я не заявляю. Показов моей системы в выгрузке нет, A/B-эксперимент не проводился. Совпадение рекомендации с исторической покупкой не измеряет прирост продаж.

Как это проверяется в коде

Правила закреплены тестами. Покупка с той же отметкой времени, что и первый просмотр, не допускает запрос. Покупка точно на правой границе окна входит в цель, а следующая отметка за границей — уже нет. Повтор SKU не увеличивает цель. Отдельно проверяется, что сессия с целью вне каталога остаётся в знаменателе Hit@20.

Команды есть в инструкции воспроизведения. Я установила собранный пакет в отдельное окружение и проверила весь путь на небольшой реальной выборке: от трёх исходных CSV до модели и отчёта. Результаты прежних запусков для этого не понадобились.

Этот прогон проверил инструкцию, но не повторял полный расчёт на D. API проверен локально; испытаний под рабочей нагрузкой пока не было.

В следующей статье покажу, как получается сама выдача: от графа и метаданных до признаков и LightGBM.

ссылка на оригинал статьи https://habr.com/ru/articles/1095468/