Клиент — это тоже вектор? Как мы хотели улучшить ML-модель, а построили similarity engine. bert.. bert. deep learning.. bert. deep learning. embeddings.. bert. deep learning. embeddings. machine learning.. bert. deep learning. embeddings. machine learning. transformers.. bert. deep learning. embeddings. machine learning. transformers. анализ данных.. bert. deep learning. embeddings. machine learning. transformers. анализ данных. Машинное обучение.
Клиент — это тоже вектор? Как мы хотели улучшить ML-модель, а построили similarity engine - 1

В этой статье я расскажу, как решение одной прикладной ML-задачи привело нас сразу к нескольким неожиданным результатам. Статья будет полезна в первую очередь специалистам, работающим с классическими ML-задачами, но пока не использовавшим DL-подходы в продакшн-сценариях. Поговорим о трансформерах, эмбеддингах и том, как это можно использовать. Также затрону несколько проблем, с которыми мы столкнулись при решении одной конкретной задачи, и как мы их решали. Это поможет сэкономить вам немало времени при реализации похожего проекта в работе.

Статья разделена на два блока.
В первом блоке разберём, как использовать языковую модель для получения дополнительных признаков из пользовательских событий.
Во втором — посмотрим на результаты: влияние эмбеддингов на качество модели, кластеризацию по клиентским эмбеддингам и построение general similarity engine.
Если вам интереснее сначала посмотреть на практический эффект подхода, можете начать со второго блока.

ВАЖНО
в проекте использовались следующие версии библиотек:

torch == 2.2.2
transformers==4.48.3
datasets==3.4.1

Блок 1

Отправная точка

Не так давно к нам прилетела задача помочь улучшить качество модели, которая предсказывает совершение клиентом целевого действия в окне [15, 21] дней после регистрации. Чтобы проще было воспринимать, предположим, что продукт – это маркетплейс, у которого есть веб-версия и мобильное приложение. А целевое действие – это покупка какого-то товара. Модель, которую надо было улучшить, работает с определённым пулом клиентов, заказчик называет их “сильно сомневающиеся”. Таких клиентов очень много. И на них тратилось много дорогих ресурсов: от персональных менеджеров до бонусов.

Сильных табличных фичей по таким клиентам немного. Зато у заказчика собирались всевозможные ивенты пользователя: от скроллинга новостей и блуждания по разделам сервиса до покупок и предзаказов. Поэтому мы решили попробовать обучить transformer-модель на последовательностях пользовательских событий.

Тот самый клиент

Тот самый клиент

Причём тут языковая модель?

Пользовательские события — это последовательные поведенческие данные, которые сложно напрямую использовать в классических МЛках. Их просто так нельзя засунуть в какой-нибудь бустинг. А когда из не табличных данных нужно извлечь какую-то структуру, тогда на помощь приходят нейронные сети.

Современные модели-трансформеры позволяют извлекать сигнал там, где возможностей для ручной генерации фичей либо мало, либо это требует большого количества времени. Поэтому мы решили строить BERT (mlm). В качестве “языка” мы подавали клиентские события.

При обучении модели каждый пользовательский ивент преобразуется в вектор фиксированной длины (длину вы указываете сами). В течение одного дня у пользователя может быть много ивентов. После прохождения последовательности через модель каждый ивент получает эмбеддинг-представление. Усредняя эмбеддинги событий за конкретный день, можно получить компактное представление состояния пользователя в этот день.

Состояние пользователя в какой-то отдельный день

Состояние пользователя в какой-то отдельный день

К слову о BERT. Вариант дообучить какую-то готовую модель мы отмели сразу. Во-первых, наш “язык” уникален точно так же, как любой иностранный язык, химические цепочки или ноты, поэтому знания модели о других областях нам точно не помогли бы. Во-вторых, уникальных клиентских событий у нас набралось чуть больше 300 штук, поэтому мы выбрали микроскопическую архитектуру: tiny на 4 скрытых слоя и hidden size размером 128 элементов. В итоге мы обучали модель с нуля, и в качестве токенизатора нам достаточно было деления по пробелу (whitespace level), потому что ивенты подавались практически в явном виде:
[логин зашёл_в_раздел_ручек посмотрел_новость принял_купон]

Подготовка данных: унификация событий и вложенные токены

У заказчика есть и веб-версия продукта, и мобильные приложения. Для различной аналитики используются разные сервисы, а единого нейминга событий нет. Поэтому первый огромный шаг, который нужно было выполнить – это составить список тех ивентов, которые кажутся полезными, и унифицировать их названия, чтобы в дальнейшем вытаскивать из БД с одним именем. Например, на мобильных устройствах событие, означающее, что пользователь открыл новость “А”, может называться “mobile_newsBlock_view_A”, а на десктопе то же самое действие может называться “news_looked_A”. Для людей понятно, что это про одно и то же, но для модели – это абсолютно разные сущности.

Следующая особенность продукта заключалась в том, что 90% пользовательских событий – это клики по товарам. Совсем избавиться от этой сущности при подготовке данных для модели нельзя, в них точно есть какой-то сигнал. Но и селектить в явном виде тоже оказалось плохим вариантом. Объясню особенность. Товары могут представлять несколько крупных категорий, для простоты – это дешёвые, со средней стоимостью и дорогие. Если 60% кликов приходится на дешёвые товары, 39% – на средне стоимостные, а 1% – на дорогие, то при обучении модели редкие события, связанные с дорогими товарами, начинали теряться на фоне значительно более частых кликов по дешёвым позициям. Однако сам факт клика на позицию (не важно, к какой категории она относится) мы никак не хотели пропускать, поэтому вместо одного специфичного токена мы начали использовать композицию из общего действия и его категории. В итоге:

  • клик по дешёвому товару click_on_cheap_item стал называться [click] [click_on_cheap_item]

  • клик по средне стоимостному товару click_on_med_item]-> [click] [click_on_med_item]

  • клик по дорогому товару click_on_expnsv_item -> [click] click_on_expnsv_item]

Напомню, это делалось потому, что в качестве токенизатора мы выбрали простой сплит по пробелу. Таким образом, мы не потеряли ни единого события “клик” + прикрутили в качестве дополнительной информации категорию товара.

Другая особенность при подготовке данных, с которой мы столкнулись, вытекает из предыдущего абзаца.Последовательности часто содержали длинные серии одинаковых событий. Для длины последовательности BERT, которую мы выбрали для обучения (128 токенов), это могло стать серьезной проблемой. Мы теряли потенциально полезную информацию. События по типу перехода в кассу или изучение специальных предложений, выходя за 128 токенов, просто обрубались. Поэтому последовательности из одного и того же события, повторяющиеся более 3 раз, мы стали заменять на сущность “событие_[специальный постфикс]“, где [специальный постфикс] – это категория количества повторений события подряд. Это позволяло сократить длину последовательностей без потери информации о характере пользовательского поведения. Таким образом цепочки вида:

“событие_А событие_А событие_А событие_А событие_А”
стали превращаться в один токен
“событие_А_мало”

Тут нет конкретного значения, которое показывает, сколько раз токен повторялся, только категория. Потому что объективно не будет никакой большой разницы между событие_А_130 и событие_А_131 (130 и 131 – это количество подряд идущих повторов), а для модели это получатся абсолютно разные токены.

Также мы протестировали, какая модель будет возвращать лучшие результаты: обученная на просто последовательности действий юзера или обученная на последовательности, где присутствуют специальные токены, которые указывают на номер дня после регистрации. Оказалось, что второй вариант оказался гораздо лучше. Вероятно, это связано с тем, что модель начала различать не только сами действия, но и их временной контекст относительно жизненного цикла пользователя. Немного ниже приложена функция add_days_separator, которая это делает.

add_days_separator
def add_days_separator(df):
    """
    Добавляет специальные токены [D0]..[D14]

    df - датафрейм, в котором есть user_id, event2, server_timestamp
    пример:
    111, login, 2025-01-01 10:10:15
    131, click_news_block, 2025-01-01 10:10:16
    """

    def _build_user_seq(user_id):
        """
        В начало каждого дня добавляем D[k]
        """
        toks = []
        for k in range(14):
            toks.append(f"[D{k}]")
            ev = day_events.get((user_id, k), None)
            if ev is None or len(ev) == 0:
                toks.append("[NO_EVENTS]")
            else:
                toks.extend(ev)
        return " ".join(toks)

    reg = df.groupby("user_id")["server_timestamp"].min()
    df = df.join(reg, on="user_id", rsuffix="_reg")
    df.rename(columns={"server_timestamp_reg": "reg_ts"}, inplace=True)

    # высчитываем номер дня после регистрации
    df["day_idx"] = ((df["server_timestamp"] - df["reg_ts"]).dt.total_seconds() // 86400).astype("int16")
    df = df[(df["day_idx"] >= 0) & (df["day_idx"] <= 13)]

    # сортировка по юзеру и timestamp
    df = df.sort_values(["user_id", "day_idx", "server_timestamp"], kind="mergesort")

    day_events = df.groupby(["user_id", "day_idx"])["event2"].agg(list)

    users = df["user_id"].unique()
    user_seqs = pd.Series({u: _build_user_seq(u) for u in users})

    return user_seqs

А здесь кусочек кода, чтобы было более глубокое понимание, как собираются и обрабатываются данные. При желании, по комментариям и примеру вы можете дособрать составные функции самостоятельно под свое видение проблематики. Но также их без труда восстановит ЧГПТ или ему подобные ЛЛМ.

Пример функции, собирающей последовательность всех ивентов юзеров
def process_day(dt_start, dt_end, future_days, save_files=False, archieve_files=True, directory=None):
    '''
    Наполняем директории токенами
    Args:
        dt_start: левая дата выгрузки
        dt_end: правая дата выгрузки
        future_days: за сколько дне йпосле регистрации нужно собирать статистику
        save_files: сохранять файлы или нет
        archieve_files: нужно ли архивировать файлы или нет
        directory: куда сохранять
    '''

    for current_day in pd.date_range(dt_start, dt_end, freq='D'):
        current_day = current_day.date()

        print(current_day)

        # all_data - список из датафреймов с различными 
        # действиямисобытиями клиентов, собранных из разных источников
        all_data = get_all_data_mlm(current_day, future_days)
        
        # all_events - это единый df, сконкаченный из всех отдельных
        # таблиц списка all_data и отсортированный по user_id и timestamp
        all_events = return_all_events(all_data)

        # функция add_days_separator добавляет специальные токены
        # вида [D0]..[D14] в начало каждого отдельного дня в рамках
        # юзера или [NO_EVENTS],
        # если в этот день не было никаких событий
        users_seqs = add_days_separator(all_events)

        for index, current_user in enumerate(users_seqs.index):
            # process_deal_events_mlm - функция, которая преобразовывает
            # последовательности одинаковых подряд идущих токенов в один токен
            # вида событие_А_постфикс, где постфикс - это категория количества
            # повторений (мало, много, средне)
            tmp_user_events = process_deal_events_mlm(
                users_seqs[users_seqs.index == current_user].values[0].split(' '))

            # в итоге по каждому юзеру получаем строку с подряд идущей историей
            # и специальными токенами (дневными разделителями)
            tmp_doc = ' '.join(tmp_user_events)

            if save_files:
                # сохраняем txt в дректорию (на это были бизнес-требования,
                # но вы у себя можете записывать сразу в БД)
                save_sequence(current_user, tmp_doc, directory)

            if index % 50000 == 0:

                if archieve_files:
                    # добавляем дамп в архив
                    start = dt.utcnow()
                    do_zip_archive(directory)
                    print(f'архив подготовлен за {(dt.utcnow() - start).total_seconds() / 60} минут')      

Про обучение BERT

Сперва пару слов про токенизатор. В качестве токенизации была выбрана токенизация по пробелам. Во-первых, при нашем количестве уникальных “слов” (они же токены) в чуть меньше, чем 300 штук, не нужно было заморачиваться в BPE и прочие токенизаторы. Во-вторых, мы хотели а дальнейшем провести кластеризацию юзеров и посмтротеть топ токенов в различных кластерах. В этом случае видеть части “слов” вместо полного названия ивента – плохой вариант.
Также к токенизатору помимо стандартных служебных токенов по типу [CLS], [SEP], [UNK] и тд нужно было добавить специальные токены начала дней [D1]..[D14] и [NO_EVENTS]. Обычно токенизатор делается в 2 шага: сначала строится движок на Rust (тут про то, как резать текст, какой словарь и тд), а после – надстройка для transformers-пайплайнов на питоне. У нас это делалось таким образом:

Сначала токенизатор на Rust
import os
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, processors

core_special = ["[UNK]", "[PAD]", "[MASK]", "[CLS]", "[SEP]"]
day_tokens = [f"[D{i}]" for i in range(14)]
no_events = ["[NO_EVENTS]"]
seed_line = " ".join(day_tokens + no_events)

tokenizer = Tokenizer(models.WordLevel(unk_token="[UNK]"))
# вот тот самый сплит по пробелу в качестве претокенизации, 
# о котором я говорил выше
tokenizer.pre_tokenizer = pre_tokenizers.WhitespaceSplit()

trainer = trainers.WordLevelTrainer(
    vocab_size=500,
    special_tokens=core_special,
)

def data_iterator():
    # добавляем day tokens как обычные токены
    yield seed_line
    for entry in os.scandir("data/train/extracted_train"):
        if entry.is_file() and not entry.name.startswith("."):
            with open(entry.path, "r", encoding="utf-8") as f:
                yield f.read().strip()

tokenizer.train_from_iterator(data_iterator(), trainer)

# save -> reload -> post_processor с финальными id
tokenizer.save("custom_tokenizer.json")
tokenizer = Tokenizer.from_file("custom_tokenizer.json")

cls_id = tokenizer.token_to_id("[CLS]")
sep_id = tokenizer.token_to_id("[SEP]")
tokenizer.post_processor = processors.TemplateProcessing(
    single="[CLS] $A [SEP]",
    special_tokens=[("[CLS]", cls_id), ("[SEP]", sep_id)],
)
tokenizer.save("custom_tokenizer.json")

print(tokenizer.encode("look_item add_to_cart blablabla").tokens)
А после – HF-обертка
fast_tokenizer = PreTrainedTokenizerFast(
    tokenizer_file="custom_tokenizer.json",
    unk_token="[UNK]",
    pad_token="[PAD]",
    cls_token="[CLS]",
    sep_token="[SEP]",
    mask_token="[MASK]",
)

fast_tokenizer.model_max_length = 128
fast_tokenizer.padding_side = "right"
fast_tokenizer.truncation_side = "right"

day_tokens = [f"[D{i}]" for i in range(0, 14)]  # D[0]..D[13]
no_events = ["[NO_EVENTS]"]

fast_tokenizer.add_special_tokens({
    "additional_special_tokens": day_tokens + no_events
})
fast_tokenizer.save_pretrained("custom_tokenizer_fast")

Чуть выше говорилось, что наша малютка БЕРТ при обучении читает не более 128 токенов из подаваемого документа (напомню, что один документ – это последовательность всех событий одного юзера за первые 14 дней после регистрации). При условии, что пользователь за 14 дней зачастую генерирует больше событий, 128 токенов при обучении – это мало, высокий риск потерять полезную информацию.

Поэтому для длинных последовательностей использовалось случайное сэмплирование окон фиксированной длины (128 токенов).

При каждой итерации модель получала случайный фрагмент пользовательской последовательности, что позволяло постепенно покрывать разные части документа во время обучения (возможно, это самый сложный момент; но достаточно разок осознать структуру и всё сразу станет логично и понятно):

Функция для генерации окон дял обучения модели
import os
import hashlib
import glob
import random


# Путь к данным, каждый txt-файл соответствует одному юзеру
DATA_GLOB = "data/train/extracted_train/*.txt"

# Длина входа модели
SEQ_LEN = 128

# Контекстная длина на 2 элемента меньше, потому что всегда присутствуют
# служебные токены [CLS] и [SEP]
CONTENT_LEN = SEQ_LEN - 2

# Тут указываем, какое максимально количество окон из длинной
# последовательности клиента можем сгенерировать, чтобы подать
# в модель (подбирается опытным путем :-) )
K_MAX = 6


# fast_tokenizer мы иницировали выше, здесь нам нужны айдишки
# слудебных токенов
cls_id = fast_tokenizer.cls_token_id
sep_id = fast_tokenizer.sep_token_id
pad_id = fast_tokenizer.pad_token_id
unk_id = fast_tokenizer.unk_token_id
unk_tok = fast_tokenizer.unk_token



def stable_int_seed(s: str) -> int:
    """
    Функция возвращает один и тот же seed для одного и того же
    user_id при любом запуске на любом компьютере
    """
    return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16)


def extract_user_id_from_path(path):
    """
    Название каждого документа - это айди клиента. Эта функция 
    просто вытаскивает айди клиента
    """
    base = os.path.basename(path)
    return os.path.splitext(base)[0]


def make_windows(batch):
    """
    Принимает батч исходных трейдеров и разворачивает его в батч 
    окон фиксированной длины.

    Вход:
      batch = {
        "text": [...],       # исходные последовательности юзеров
        "user_id": [...]   # идентификаторы юзеров
      }

    Выход:
      {
        "input_ids": [...],       # токены длиной SEQ_LEN
        "attention_mask": [...],  # 1 для реальных токенов, 0 для PAD
        "user_id": [...]        # id юзера для каждого окна
      }
    """
    out_input_ids = []
    out_attention = []
    out_user_id = []

    # идем по каждому юзеру в батче
    for text, tid in zip(batch["text"], batch["out_user_id"]):
        # тут просто сплитуем документ по прбелам
        toks = text.strip().split()

        # если документ пустой, то всталвем [UNK], чтобы документ 
        # не потерялся
        if not toks:
            toks = [unk_tok]

        # конвертим токены в их индексы
        ids = fast_tokenizer.convert_tokens_to_ids(toks)

        # convert_tokens_to_ids может вернуть None для неизвестных токенов.
        # Явно заменяем такие значения на unk_id. (доработка ЧГПТ)
        ids = [unk_id if (x is None) else x for x in ids]

        L = len(ids)

        # считаем, сколько окон сделать из этого юзера:
        # * минимум 1
        # * максимум K_MAX
        # * примерно ceil(L / CONTENT_LEN)
        k_i = max(1, min(K_MAX, (L + CONTENT_LEN - 1) // CONTENT_LEN))

        # Создаём RNG, привязанный к user_id:
        # одинаковый юзер -> одинаковые случайные окна между запусками.
        # (доработка ЧГПТ)
        rng = random.Random(stable_int_seed(tid))

        # геренирим k_i окон
        for _ in range(k_i):
            if L <= CONTENT_LEN:
                # если последовательность умещается в 128 элементов,
                # то берем ее целиком
                window = ids
            else:
                # иначе берем СЛУЧАЙНЫЙ!!! кусок размера SEQ_LEN
                start = rng.randint(0, L - CONTENT_LEN)
                window = ids[start:start + CONTENT_LEN]

            # в начало и в конец добавляем бертовские служебные 
            # токены начала и окончания документа
            input_ids = [cls_id] + window[:CONTENT_LEN] + [sep_id]

            # делаем attention mask
            attn = [1] * len(input_ids)

            # если текущая  последовательность <SEQ_LEN, то добиваем
            # ее токенами дополнения [PAD] до размера SEQ_LEN справа
            if len(input_ids) < SEQ_LEN:
                pad_n = SEQ_LEN - len(input_ids)
                input_ids += [pad_id] * pad_n
                attn += [0] * pad_n

            # накапливаем результат
            out_input_ids.append(input_ids)
            out_attention.append(attn)
            out_user_id.append(tid)

    return {
        "input_ids": out_input_ids,
        "attention_mask": out_attention,
        # user_id сохраняем для дебага
        "user_id": out_user_id,
    }
Делаем train/validate
# Сплит по юзерам (по файлам)
all_files = sorted(glob.glob(DATA_GLOB))
if not all_files:
    raise FileNotFoundError(f"No files matched: {DATA_GLOB}")

rng = random.Random(42)
rng.shuffle(all_files)

# оставляем 10% данных для валидации
cut = int(len(all_files) * (1 - 0.1))
train_files = all_files[:cut]
val_files = all_files[cut:]
Готовим сырой датасет
# load datasets отдельно (чтобы не было утечки юзеров)
ds = load_dataset(
    "text",
    data_files={"train": train_files, "validation": val_files},
    )

# добавляем user_id колонку из названия документа
def add_user_id(idx, split_files):
    return {"user_id": extract_user_id_from_path(split_files[idx])}

ds["train"] = ds["train"].map(
    lambda ex, idx: add_user_id(idx, train_files),
    with_indices=True,
    )

ds["validation"] = ds["validation"].map(
    lambda ex, idx: add_user_id(ex, idx, val_files),
    with_indices=True,
    )
Применяем оконность
train_win = ds["train"].map(
    make_windows,
    batched=True,
    remove_columns=["text"],
    )

val_win = ds["validation"].map(
    make_windows,
    batched=True,
    remove_columns=["text"],
    )

# дополнительно зашафлим трейн
train_win = train_win.shuffle(seed=42)

dataset_final = DatasetDict({"train": train_win, "validation": val_win})

print(dataset_final)
#print("Пример из train-ds:", dataset_final["train"][0])

На выходе мы получим примерно такую структуру:

DatasetDict({
    train: Dataset({
        features: ['user_id', 'input_ids', 'attention_mask'],
        num_rows: 469618
    })
    validation: Dataset({
        features: ['user_id', 'input_ids', 'attention_mask'],
        num_rows: 52149
    })
})

Важные промежуточные итоги:

  • DatasetDict – это набор данных, с которым умеют работать transformers-модели

  • Train и validate у нас преобразованы таким образом, что если у юзера последовательность превышает 128 элементов, то этот юзер возвращает несколько последовательностей со случайной стартовой точкой. То есть один и тот же юзер может встречаться несколько раз в обучении, что эффективно увеличивает размер обучающего датасета

  • input_ids – индексы токенов из словаря обученного под наши данные токенизатора

Всё, можно переходить к обучению BERT!

Модель обучалась в режиме Masked Language Modeling (MLM), как и классический BERT. То есть во время обучения часть токенов в последовательности скрывалась, а модель должна была восстановить их по окружающему контексту пользовательских действий.

Весь код для обучения BERT
config = BertConfig(
    vocab_size=len(fast_tokenizer),  # размер словаря
    hidden_size=128, # размер эмбеддингов
    num_hidden_layers=4, # кол-во внутренних слоев
    num_attention_heads=2, # кол-во голов внимания 
    intermediate_size=4*128, # размер внутреннего слоя
    max_position_embeddings=130, # это 128 с запасом
    pad_token_id = fast_tokenizer.pad_token_id # индекса токена-заполнителя
)

# Создаём модель
model = BertForMaskedLM(config)

data_collator = DataCollatorForLanguageModeling(
    tokenizer=fast_tokenizer,
    mlm=True,
    # какую долю токенов нужно "замьютить"
    mlm_probability=0.15
)

training_args = TrainingArguments(
    output_dir="./tinybert_model",
    eval_strategy="epoch",
    save_strategy="epoch",

    per_device_train_batch_size=16,
    per_device_eval_batch_size=16,
    fp16=True, # чтобы модель занимала меньше памяти в оперативке
    gradient_accumulation_steps=2,

    num_train_epochs=5, #сколько эпох будем обучать
    learning_rate=5e-4, # эти параметры можно не менять (совет ЧГПТ)
    warmup_ratio=0.05,
    weight_decay=0.01,

    logging_dir="./logs",
    logging_steps=200,
    report_to="none",
    remove_unused_columns=False,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=dataset_final["train"],
    eval_dataset=dataset_final["validation"],
    tokenizer=fast_tokenizer,
    data_collator=data_collator,
)

trainer.train()
логи обучения

логи обучения

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

В этом проекте в качестве ресурсов для обучения было доступно 32ГБ ОЗУ и RTX4070 ti super на 16ГБ. Скажу сразу: для такого небольшого набора данных, маленького размера словаря и крошечной архитектуры самой модели вычислительная нагрузка оказалась относительно небольшой. Обучение заняло около 50-60 минут.

Использование BERT в качестве эмбеддера

Таким образом, последовательность пользовательских действий превращалась в компактное векторное представление, которое далее можно было использовать как обычные признаки для downstream ML-задач.

Готовим вспомогательные функции
# глобальные параметры
DATA_DIR = "data/extracted_new/" # папка с документами по юзерам
MODEL_DIR = "tinybert_mlm"       # папка, в которой лежит модель и токенизатор
SEQ_LEN = 128
BATCH_USERS = 64                 # размер батча
RANDOM_STATE = 42

DEVICE = "cuda" if torch.cuda.is_available() else "cpu" # если у вас есть макбук с m-чипом, то видеокарточка для этой задачи вам даже не понадобится

DAY_TOKENS = {f"[D{i}]" for i in range(14)} # кастомные дневные токены, которые мы самостоятельно добавляли в токенизатор модели выше
NO_EVENTS = "[NO_EVENTS]" # спец токен, если в какой-то день у юзера не было никаких ивентов
DAY_RE = re.compile(r"^[D(d+)]$")


# Функции: split + mean pooling + батчевый эмбеддинг
def split_doc_to_days(doc_text: str, n_days=14):
    """
    Возвращает лист длины n_days, где каждый элемент - это лист токенов юзера в конкретный день. Например, 3 элемент списка - это список всех ивентов юзера в 3 день после регистрации
    Ожидает спец токены [D0]..[D13]
    """
    tokens = doc_text.strip().split()
    days = [[] for _ in range(n_days)]
    cur = None
    for tok in tokens:
        m = DAY_RE.match(tok)
        if m:
            cur = int(m.group(1))
            continue
        if cur is not None and 0 <= cur < n_days:
            days[cur].append(tok)
    return days


def mean_pool(last_hidden_state, attention_mask):
    """
    Функция, с помощью которой усредняются все дневные эмбеддинги между собой по соответствующим координатам
    Здесь B - размер батча
          T - длина каждой последовательности, сколько токенов в строке, у нас это 128
          D - размер каждого отдельного токена, у нас это тоже 128
    """
    # last_hidden_state: (B, T, D), attention_mask: (B, T)
    
    # в тензор attention_mask добавляем ещё одну размерность в самый конец
    # это делается для того, чтобы её можно было умножить на last_hidden_state
    # last_hidden_state - это выход последнего скрытого слоя BERT. То есть выход с того слоя, который максимально (точно/качественно/хорошо) обогатил наши токены-векторы
    mask = attention_mask.unsqueeze(-1).type_as(last_hidden_state)  # (B,T,1)

    # здесь мы хотим просуммировать только те токены, которые не являются [PAD]
    # сумма по размерности 1 (T) - это сумма по эмбеддингам всех входящих токенов
    # в результате из тензора убирается одна размерность
    summed = (last_hidden_state * mask).sum(dim=1)                  # (B,D)

    # суммируем маску, тут мы считаем сколько реальных (не [PAD]) токенов было в тексте
    counts = mask.sum(dim=1).clamp(min=1e-9)                        # (B,1)

    # делим каждую координату финального вектора на количество не нулевых слов
    return summed / counts                                          # (B,D)


@torch.no_grad()
def embed_users_batch(user_texts, tokenizer, model, max_len=128, device=device):
    """
    Конвертим юзерские последовательности в эмбеддинги
    """
    all_texts = []
    meta = []  # (user_id, day_idx, is_active, token_count)

    for uid, doc in user_texts.items():
        # разбиваем длинную последовательность на 14 (по количеству дней после регистрации)
        days = split_doc_to_days(doc, 14)
        for k, toks in enumerate(days):
            # активный день, если есть токены и это не просто [NO_EVENTS]
            active = not (len(toks) == 1 and toks[0] == NO_EVENTS)
            # считаем колиество токенов за конкретный день
            token_count = len(toks)

            # кладём [Dk] в начало дневной последовательности
            seq = [f"[D{k}]"] + (toks if len(toks) else [NO_EVENTS])
            all_texts.append(" ".join(seq))
            meta.append((uid, k, int(active), token_count))

    # токенизируем сырой текст через токенизатор, который мы создали ранее
    enc = tokenizer(
        all_texts,
        truncation=True,
        padding=True,
        max_length=max_len,
        add_special_tokens=True,
        return_tensors="pt",
    )
    enc = {k: v.to(device) for k, v in enc.items()}

    # прогоняем тексты через берт
    out = model.bert(**enc)

    # полученные эмбеддинги дней усредняем по соответствующим 
    #координатам и получаем состояние юзера на 14 день после регистрации
    embs = mean_pool(out.last_hidden_state, enc["attention_mask"]).cpu().numpy()

    # складываем результаты в общий словарь
    # помимо эмбеддингов за 14 дней добавим инфрмацию об активности и
    # количестве токенов в каждый из дней
    result = {}
    for (uid, day_idx, active, tc), e in zip(meta, embs):
        if uid not in result:
            result[uid] = {
                "emb": np.zeros((14, e.shape[0]), dtype=np.float32),
                "is_active": np.zeros(14, dtype=np.int8),
                "token_count": np.zeros(14, dtype=np.int32),
            }
        result[uid]["emb"][day_idx] = e
        result[uid]["is_active"][day_idx] = active
        result[uid]["token_count"][day_idx] = tc

    return result


def user_features(emb14, is_active):
    """
    Собираем состояние юзера на 14 день посде регистрации
    """
    # эмбеддинги на 14 день
    mean14 = emb14.mean(axis=0)

    # эмбеддинг последнего дня (доп фичи)
    last = emb14[13]

    # тренд между первым и последним днем (доп фичи)
    trend = emb14[13] - emb14[0]

    # сколько дней с активностью было у юзера
    active_days = int(is_active.sum())

    # здесь мы вычисляем, насколько поведение пользователя "стабильно" день ото дня
    # если каждый день клиент покупает молоко, то среднее расстояние между
    # эмбеддингами соседних дней будет невысоким
    # а если сегодня купил молоко, завтра машину, послезавтра - уран для обогащения
    # в домашних условиях, то его поведение сильно варьируется, тогда среднее
    # расстояние будет большим
    diffs = np.linalg.norm(emb14[1:] - emb14[:-1], axis=1)
    vol = float(diffs.mean())
    return mean14, last, trend, active_days, vol
Собираем финальный датасет
tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)
model = BertForMaskedLM.from_pretrained(MODEL_DIR)

model = model.to(DEVICE)
model.eval()

files = sorted(glob.glob(os.path.join(DATA_DIR, "*.txt")))
if not files:
    raise RuntimeError(f"No .txt files found in {DATA_DIR}")

print("Files:", len(files))

# пробежимся по файлам батчами пользователей
all_user_ids = []
all_emb14 = []
all_active = []
all_token_counts = []

for i in tqdm(range(0, len(files), BATCH_USERS), desc="Embedding users"):
    batch_files = files[i:i+BATCH_USERS]

    user_texts = {}
    for fp in batch_files:
        uid = os.path.splitext(os.path.basename(fp))[0]  # имя файла без .txt (это айди юзера)
        with open(fp, "r", encoding="utf-8") as f:
            user_texts[uid] = f.read()

    batch_res = embed_users_batch(
        user_texts=user_texts,
        tokenizer=tokenizer,
        model=model,
        max_len=SEQ_LEN,
    )

    for uid, r in batch_res.items():
        all_user_ids.append(uid)
        all_emb14.append(r["emb"])
        all_active.append(r["is_active"])
        all_token_counts.append(r["token_count"])

all_emb14 = np.stack(all_emb14, axis=0)          # (N,14,D)
all_active = np.stack(all_active, axis=0)        # (N,14)
all_token_counts = np.stack(all_token_counts, axis=0)


# тут готовим финальный датасет
feats = []
active_days_list = []
vol_list = []

for u in range(N):
    mean14, last, trend, active_days, vol = user_features(all_emb14[u], all_active[u])

    # конкатим фичи в рамках юзера
    x = np.concatenate([mean14, last, trend], axis=0)
    feats.append(x)

    active_days_list.append(active_days)
    vol_list.append(vol)

X = np.stack(feats, axis=0)

# названия колонок-эмбеддингов
feat_cols = (
    [f"mean14_{i}" for i in range(D)] +
    [f"last_{i}" for i in range(D)] +
    [f"trend_{i}" for i in range(D)]
    )

df = pd.DataFrame(X, columns=feat_cols)

# добавим user_id и доп информацию
df.insert(0, "user_id", all_user_ids) # первая колонка
df["active_days"] = np.array(active_days_list)
df["vol"] = np.array(vol_list)

На этом заканчивается первая часть статьи, посвящённая подготовке данных и обучению модели.

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

Блок 2

Эмбеддинги пользователей как дополнительные признаки

Клиент — это тоже вектор? Как мы хотели улучшить ML-модель, а построили similarity engine - 5

Основной задачей проекта было улучшение модели, предсказывающей совершение пользователем целевого действия на основе его поведения в сервисе в первые 14 дней после регистрации. Выборка клиентов оказалась довольно специфичной. Во-первых, их много. Во-вторых, доля таргетов маленькая, => это сильный дисбаланс. В-третьих, это та доля клиентов, у которых практически нет сильных фичей по типу “уже что-то купил” или “что-то добавил в отложенные покупки”, зато у них есть различные взаимодействия с продуктом: посмотрел какие-то новости, побродил по различным разделам и так далее. Эти поведения трансформировались в эмбеддинги с целью дальнейшего использования в качестве фичей в бустингах. Заказчик планировал использовать модель в качестве ранжировщика, чтобы использовать более дорогие взаимодействия с потенциально более перспективными клиентами.

Каждый элемент эмбеддинга – это отдельная фича для бустинга, выглядит это примерно так:

эмбеддинг, разделенный по отдельным ячейкам

эмбеддинг, разделенный по отдельным ячейкам

Далее будут показаны различные результаты модели на 3 разных наборах фичей. Набор юзеров везде один и тот же (это отложенная выборка). Решалась задача максимизации recall при precision не меньше 0.5. Для каждой построенной модели threshold подбирался вручную для сопоставления качества.

исходный набор данных

исходный набор данных

Исходный набор фичей, переданный заказчиком, и результаты работы бустинга на нём. Датасет содержит 71 колонку. При обучении (кросс-валидация на 5 фолдах) pr-auc достиг 0.33. По classification report видим recall = 0.21 при precision = 0.5, что совсем плохо.

только эмбеддинги

только эмбеддинги

Здесь представлены результаты бустинга, обученного только на эмбеддингах пользователей (включая тренды и эмбеддинги последнего дня). Видно, что модель стала лучше находить целевой класс: recall вырос с 0.21 до 0.33 (+12 п.п.) при сопоставимом уровне precision (~0.5). При этом в модели не использовались явные признаки действий пользователей (например, “совершил покупку”), а только последовательности событий, закодированные в эмбеддингах.

исходные фичи + эмбеддинги

исходные фичи + эмбеддинги

На этом скриншоте показаны результаты работы бустинга на объединении предыдущих 2 датасетов. При сопоставимом precision recall подрос на 6 п.п. относительно датасета с эмбеддингами и на 18 п.п. относительно исходного датасета.

Что здесь можно заметить. Во-первых, целевая метрика ощутимо выросла. Во-вторых, с помощью эмбеддингов видно, что юзерское поведение – это достаточно сильный сигнал. В-третьих, представьте, сколько времени и ресурсов можно сэкономить, когда нужно построить хороший датафрейм в условиях, когда полезных фичей особо-то и не вытащить.
Разумеется, эмбеддинги пользовательских состояний не гарантируют улучшение качества в любой задаче и не всегда превосходят классические подходы к построению признаков. Однако результаты эксперимента показывают, что они могут служить сильным дополнительным источником информации и заметно улучшать качество модели в задачах, связанных с пользовательским поведением.

Кластеризация пользователей по эмбеддингам

Поскольку пользовательское поведение показало себя как сильный сигнал для downstream-задач, мы решили дополнительно исследовать структуру эмбеддинг-пространства через кластеризацию пользователей. В своем исследовании мы отобразили исходное пространство в пространство меньшей размерности с помощью PCA так, чтобы суммарная объяснённая дисперсия не была ниже 0.90 (получилось 30 компонент). Затем для этого нового датасета подбирали количество кластеров таким образом, чтобы silhouette был максимальным при KMeans кластеризации. Максимальное значение silhouette достигалось при двух кластерах.

Особенно интересно, что модель обнаружила такую структуру без какой-либо кластерной разметки и обучалась изначально для совершенно другой downstream-задачи.

метрика силуэт в зависимости от количества кластеров

метрика силуэт в зависимости от количества кластеров
отображение обоих кластеров на одном графике

отображение обоих кластеров на одном графике

На двумерной PCA-проекции кластеры визуально пересекаются довольно сильно. Однако важно учитывать, что кластеризация выполнялась в пространстве большей размерности, тогда как двумерная визуализация неизбежно теряет часть информации.

Несмотря на пересечение в двумерном пространтсве, silhouette остается высоким, что указывает на хорошую разделимость кластеров в исходном пространстве.

При отдельной визуализации становится лучше заметна разница в структуре распределений: нулевой кластер выглядит более компактным, тогда как первый кластер демонстрирует большую вариативность и более вытянутую форму по главным компонентам PCA.

оба кластера по отдельности

оба кластера по отдельности

При численном анализе кластеров видны следующие особенности:

общие численные метрики найденных кластеров

общие численные метрики найденных кластеров

Кластер №1 в 23 раза меньше по численности, чем кластер №0. При этом он более “активный”: у юзеров из кластера №1 в среднем меньше дней без активности, больше разнообразия совершаемых действий и больше среднее совершаемое количество действий в день.
Также, как оказалось, в первые 14 дней после регистрации (когда были сформированы кластеры) пользователи из обоих кластеров демонстрировали сопоставимые значения финансовой метрики (для простоты можно представить, что принесли одно и то же количество денег). А при дальнейшем исследовании оказалось, что в следующие 7/14/21/28 дней после даты формирования кластеров по эмбеддингам юзерских состояний кластер №1 оказался существенно более ценным с точки зрения будущей финансовой метрики, нежели более крупный кластер №0.

То есть, на момент формирования кластеров пользователи почти не различались по текущей финансовой метрике. Однако в дальнейшем их траектории начали существенно расходиться.

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

средние значения 2 некоторых финансовых метрики

средние значения 2 некоторых финансовых метрики

Как эмбеддинг-пространство превращается в similarity engine

Клиент — это тоже вектор? Как мы хотели улучшить ML-модель, а построили similarity engine - 15

Эмбеддинг состояния клиента на 14 день после регистрации (14 дней – это требования задачи, которую мы решали) – это вектор в многомерном пространстве. Векторы (юзеры) могут быть близки друг к другу или далеки. Если векторы находятся близко, значит эти юзеры похожи по поведению. Можно вычислить центроид группы наиболее ценных пользователей и искать ближайшие эмбеддинг-представления относительно этого центра.

В качестве эксперимента мы:

  1. брали топ прибыльных пользователей из кластера №1

  2. вычисляли их embedding-центроид

  3. находили ближайших пользователей из кластера №0 по косинусной похожести

  4. и сравнивали их финансовые метрики со случайными пользователями

Функция нахождения похожих на центроид среди прочих юзеров
def find_top_similar_to_reference_on_new_centroid(
        df_ref, # на кого ищем похожих (эмбеддинги первых 14 дней)
        df_new, # те, из кого ищем (эмбеддинги первых 14 дней)
        top_user_ids, # айдишники для df_ref
        emb_prefix="mean14_", # названия колонок с эмбеддингами
        user_col="user_id",
        top_n=1000, # определяем количество, из которого ищем похожих
):
    emb_cols_ref = [c for c in df_ref.columns if c.startswith(emb_prefix)]
    emb_cols_new = [c for c in df_new.columns if c.startswith(emb_prefix)]

    # нормализуем эмбеддинги эталона и новых
    X_ref = normalize(df_ref[emb_cols_ref].to_numpy(dtype=np.float32), axis=1)
    X_new = normalize(df_new[emb_cols_new].to_numpy(dtype=np.float32), axis=1)

    # выбираем эталонных пользователей
    ref_mask = df_ref[user_col].isin(top_user_ids).to_numpy()
    if ref_mask.sum() == 0:
        raise ValueError("No reference users found in df_ref")

    # считаем центроид эталона
    X_proto = X_ref[ref_mask]
    centroid = X_proto.mean(axis=0, keepdims=True)
    centroid = normalize(centroid, axis=1)  # чтобы cosine был корректным

    # similarity новых к центроиду
    score = (X_new @ centroid.T).ravel()

    # собираем результат
    out = df_new[[user_col]].copy()
    out["sim_to_ref_centroid"] = score
    out = out.sort_values("sim_to_ref_centroid", ascending=False).head(top_n).reset_index(drop=True)
    out = out[~out['user_id'].isin(top_user_ids)]

    return out

random state

некоторая финансовая метрика

весь кластер №0

топ-100 кластера №1

случайный семпл из 300 юзеров кластера №0

случайный семпл из 300 похожих юзеров кластера №0 на топ кластера №1

42

среднее

6.54

1581.1

16.97

341.7

медиана

0

613

0

93

43

среднее

6.54

1581.1

5.63

401.5

медиана

0

613

0

100.74

44

среднее

6.54

1581.1

73.1

434.5

медиана

0

613

0

107.18

А теперь проведём эксперимент, в котором мы будем искать похожих не на топ кластера №1, а на анти-топ. Получаем:

random state

некоторая финансовая метрика

весь кластер №0

топ-100 кластера №1

случайный семпл из 300 юзеров кластера №0

случайный семпл из 300 похожих юзеров кластера №0 на антитоп кластера №1

42

среднее

6.54

1581.1

16.97

7.25

медиана

0

613

0

0

43

среднее

6.54

1581.1

5.63

7.76

медиана

0

613

0

0

44

среднее

6.54

1581.1

73.1

7

медиана

0

613

0

0

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

При поиске пользователей, похожих на наиболее прибыльных клиентов кластера №1, стабильно находятся группы с существенно более высокими финансовыми метриками по сравнению со случайным сэмплированием пользователей из кластера №0.

Особенно интересно, что поиск выполнялся именно внутри менее ценного кластера №0. Это означает, что эмбеддинг-пространство позволяет находить скрыто перспективных пользователей даже внутри относительно слабых сегментов.

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

И дополнительно в этом убедимся, построив график зависимости среднего значения определенной финансовой метрики от степени похожести на выделенный топ. На графике ниже по оси x – бакеты юзеров, где бОльший номер бакета означает бОльшый коэффициент похожести на топ. По оси y – среднее значение финансовой метрики.

bucket similarity на основании данных первых 14 дней после регистрации

bucket similarity на основании данных первых 14 дней после регистрации

Чем раньше удаётся выделить пользователей с высоким потенциалом, тем быстрее к такому пользователю можно применить особые воздействия. Например, направлять в отдельный пользовательский сценарий.

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

  1. определили топ, на который хотели искать похожих

  2. для этого топа построили юзерские эмбеддинги первого дня после регистрации

  3. для всех новых юзеров определяли близость к центроиду (из пункта 2) после первого дня

Эксперименты показали, что в текущем проекте даже первого дня оказалось достаточно, чтобы уверенно отделять самых перспективных юзеров от остальных:

bucket similarity на основании первого дня после регистрации

bucket similarity на основании первого дня после регистрации

По сравнению с предыдущим экспериментом, где similarity рассчитывался на основе первых 14 дней поведения, промежуточные similarity-бакеты стали менее стабильными: например, 8-й и 9-й бакеты показывают более низкие значения финансовой метрики, а между 3-м и 4-м бакетами наблюдается небольшое локальное отклонение.

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

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

После построения качественного эмбеддинг-пространства многие задачи поиска похожих пользователей фактически сводятся к сравнению расстояний между векторами.

Выводы

Кончено, эмбеддинги пользователей нельзя рассматривать как универсальную замену классическому feature engineering. Во многих задачах хорошие табличные признаки по-прежнему будут работать лучше и стабильнее. Однако в ситуациях, когда сильных фичей мало, а основная информация скрыта в последовательностях пользовательских событий, обучение эмбеддингов может дать очень сильный дополнительный сигнал.

В рамках этого проекта эмбеддинги не только улучшили качество downstream-модели, но и позволили выделить скрытые поведенческие кластеры, а также построить полноценный similarity engine для поиска похожих пользователей. Причём полезная структура начинала проявляться уже после первого дня пользовательской активности.

И, пожалуй, самое интересное здесь – многие подобные задачи можно реализовать довольно небольшими ресурсами: без огромных датасетов, гигантских моделей и сложной инфраструктуры. Иногда достаточно посмотреть на пользовательское поведение как на “язык”, а на клиента – как на вектор в многомерном пространстве.

Автор: Asmolovskij

Источник