Нет записи о покупке — нет покупки? Как реальные данные изменили постановку задачи. coveo.. coveo. качество данных.. coveo. качество данных. Машинное обучение.. coveo. качество данных. Машинное обучение. неявная обратная связь.. coveo. качество данных. Машинное обучение. неявная обратная связь. оценка моделей.. coveo. качество данных. Машинное обучение. неявная обратная связь. оценка моделей. рекомендательные системы.. coveo. качество данных. Машинное обучение. неявная обратная связь. оценка моделей. рекомендательные системы. сессионные рекомендации.. coveo. качество данных. Машинное обучение. неявная обратная связь. оценка моделей. рекомендательные системы. сессионные рекомендации. утечка данных.

При подготовке данных 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=minbigl(t_{0,s}+H, t_{mathrm{last},s}^{mathrm{obs}}bigr).

В цель входят все различные SKU зарегистрированных покупок той же сессии при t_{0,s}<tleqtau_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=30text{ минут}.

Здесь 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.

Автор: YaninaK

Источник