Всем привет, меня зовут Александр, я аналитик в Альфа-Банке. Совместно с командой мы разрабатываем и развиваем платформу для дата-инженеров (DE) и дата-саентистов (DS), именуемую Feature Store. Она даёт возможность коллегам работать с большими данными и упрощает бюрократию жизненного цикла создания ETL и ввода моделей в промышленную эксплуатацию.

Но хотелось бы улучшить процесс по поиску данных в ней, так как объёмы информации стремительно растут.

Классический поиск выдаёт результаты по точному совпадению, и это не самый удобный вариант, когда данных много. Поэтому нужную информацию, если ты точно не знаешь как найти, невозможно отыскать. Озадачившись этой проблемой, я решил сделать MVP «умного» поиска, который позволяет искать данные/фичи/поля не по точному совпадению, а с учётом смысла.

Надеюсь, данная статья поможет показать и пролить свет на вопрос — «А как же ещё бывает?»

Пооогнали!

Проблематика

DS и DE, наши основные пользователи, сталкиваются с многочисленным разнообразием данных. Витрины, таблицы, датасеты, фичи растут не по дням, а по часам. К слову, у нас в Feature Store количество фичей уже приближается к 45 000. Пользуясь стандартным подходом для поиска информации по «сотрудникам», мы можем упустить те данные, которые будут в своем названии или описании содержать слово «персонал» или что-то ещё сложнее, например, «HR info». 

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

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

Но!...Нашей платформой пользуются разные подразделения: розничный бизнес, юридические лица и т.д. Подразделения сильно отличаются своими подходами и «внутренней кухней», поэтому стандартизация не сможет помочь по ряду причин:

  • может не покрыть все кейсы и необходимо тщательно следить за соблюдением введенной стандартизации;

  • нельзя исключать человеческий фактор;

  • если пытаться создавать более гранулярные правила (исходя из п.1), то потребуется аналогичный поиск, чтобы понять, а существует ли такое правило по неймингу;

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

На своём опыте часто оказывался один на один с такой проблемой, и однажды подумал: «А как же можно улучшить и упростить этот процесс?»:-)

Почему стандартный поиск не работает и какие ограничения в поиске?

Стандартный поиск не работает, потому что не учитываются синонимы, похожие формулировки, нет связи между контекстом и смыслом. А при увеличении количества данных в условиях быстрорастущего направления современной «нефти» (ака данных), эффективность стремится к нулю

Но у нас есть же поиск по семантике, при котором учитывается контекст, смысловая схожесть между запросом и данными?

  1. С помощью обработки естественного языка (NLP) улавливается смысл запроса.

  2. Представление слов, предложений и текстов в виде числовых значений в векторе (эмбеддинги).

  3. Далее близость сверяется по расстоянию между векторами.

Почему бы не попробовать?

MVP-решение: быстро, просто, без СМС и регистрации

А точно ли это всё будет работать? Когда работаешь с данными, то часто всё сводится к тому, что у тебя за данные. Порой опыт и примеры не всегда подойдут именно к твоей задаче, так как всегда найдутся злополучные «если».

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

Помимо плюсов выше это позволяет не тратить огромное количество ресурсов команды.

Но как же сделать так, чтобы и у MVP решения не ушли годы на разработку?

При создании своего решения я преследовал следующие принципы:

  1. Небольшая модель, которая весит не гигабайты.

  2. Модель, не требующая регистрации и получения токенов авторизации.

  3. Из пунктов выше вытекает следующий — возможность быстро получить результаты.

  4. Контроль результатов — один из важных пунктов, с которым пришлось столкнуться, так как простые и относительно старые модели сильно галлюцинируют.

Выбор пал на GPT-2: весит около ~400 МБ и, самое главное, не требует регистрации.

Больше всего информации дадут не мои слова и вода, которую пришлось пролить выше, а код, поэтому рад поделиться.

Импортируем необходимые библиотеки

Отдельно выделим библиотеку transformers, которая является базой для решения. Эта библиотека для обучения SOTA (state of the art) моделей в своем сегменте. Её разработкой занимается компания HuggingFace. И из неё я импортирую модули.

import pandas as pd
import torch
from transformers import GPT2Tokenizer, GPT2ForSequenceClassification, 
Trainer, TrainingArguments
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
import torch.nn.functional as F
import numpy as np

Что примечательно, GPT2ForSequenceClassification выбран не случайно — ответы будут представлены только из моих «скормленных» таргетов, так как будет решаться задача классификации.

Используем могучий Pandas для загрузки своего датасета

  1. Используем разделитель в виде знака «;» в CSV-файле.

  2. Удаляем все строки, где есть пустые значения.

  3. Разбиваем датасет на фичи (X) и таргеты (y).

# Загрузка и подготовка данных
df = pd.read_csv('datamart.csv', delimiter=';')
df = df.dropna().reset_index(drop=True)
 
X = df['description'].tolist()
y = df['name'].tolist()

Наименования таргета переводятся в число от 1 до n методом из sklearn LabelEncoder().

# Кодируем метки классов
le = LabelEncoder()
y_encoded = le.fit_transform(y)

Возьмем 20% для проведения валидации и теста результатов.

# Разделяем на обучающую и тестовую выборку
X_train, X_test, y_train, y_test = train_test_split(X, 
                                                    y_encoded, 
                                                    test_size=0.2, 
                                                    random_state=42)

Токенизатор

Токенизатор — это метод, который дробит текст на куски, чтобы модель могла с ними работать. Паддинг нужен, когда обрабатываешь тексты разной длины, добавляешь специальные символы, чтобы все последовательности стали одинаковой длины. EOS(end of sequence) токен означает конец предложения или текста, часто используется как заполнитель, соответственно, и в нашем случае это используется.

В GPT-2 нет отдельного пад-токена — модель по дефолту обучается на всем тексте. Когда надо обрабатывать куски текста с разной длиной, нужно эту длину как-то нормировать. Поэтому берут существующий EOS токен и говорят модели: «Используй его ещё и для паддинга». Это синхронизирует конфигурацию модели с токенизатором — один и тот же токен используется для обеих целей. Если этого не сделать, то может возникнуть путаница при обработке частей текста. 

На примере «Hello world!» будет понятно, как это происходит «под капотом». «Hello» принимает значение 15496 — это значение зафиксировано в словаре при обучении GPT-2, поэтому в других моделях эти слова будут иметь другие значения.

На основании таблицы, которую привел ниже, и токеном с максимальной длиной равной 5, наш вектор примет такой вид для «Hello world!» = [15496, 995, 0, 50256, 50256]

Токен

ID

Текст

Пояснение

«Hello»

15496

Hello

Целое слово

« world»

995

␣world

Пробел + «world»

«!»

0

!

Восклицательный знак

EOS (конец предложения)

50256

PADDING

Заполнитель (EOS как PADDING)

# Загружаем модель GPT-2 и токенизатор
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
tokenizer.pad_token = tokenizer.eos_token
model = GPT2ForSequenceClassification.from_pretrained('gpt2', 
                                                      num_labels=len(le.classes_))
model.config.pad_token_id = tokenizer.eos_token_id

Токенизируем тексты

Сначала токенизируем тексты для обучения и теста, то есть превращаем слова в числа, с которыми будет работать модель. Параметр truncation обрезает слишком длинные тексты, padding дополняет короткие, максимальная длина составляет 512 токенов.

# Токенизируем тексты
train_encodings = tokenizer(X_train, truncation=True, padding=True, max_length=512)
test_encodings = tokenizer(X_test, truncation=True, padding=True, max_length=512)

Класс Dataset

В классе Dataset три метода:

  • init — просто сохраняет что дали на вход;

  • getitem — достает конкретный пример по индексу и превращает его в тензоры PyTorch;

  • len — возвращает количество примеров.

Создаем два датасета: для обучения и тестирования. Это стандартный подход для PyTorch, такие датасеты потом можно будет загрузить в DataLoader, который будет выдавать батчи для обучения.

# Класс dataset'а
class Dataset(torch.utils.data.Dataset):
    def __init__(self, encodings, labels):
        self.encodings = encodings
        self.labels = labels
 
    def __getitem__(self, idx):
        item = {key: torch.tensor(val[idx]) for key, val in self.encodings.items()}
        item['labels'] = torch.tensor(self.labels[idx])
        return item
 
    def __len__(self):
        return len(self.labels)
 
train_dataset = Dataset(train_encodings, y_train)
test_dataset = Dataset(test_encodings, y_test)

Настроим параметры для процесса обучения

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

  • output_dir — где будут сохраняться результаты обучения, чтобы не потерять прогресс и отслеживать результаты.

  • num_train_epochs — сколько раз пройтись по обучающей выборке; целенаправленно взято маленькое значение для ускорения процесса обучения.

  • per_device_train_batch_size/eval_batch_size — размер батча (количество примеров, обрабатываемых за один шаг) для обучения и валидации. Значение 8 выбрано, чтобы не перегружать память GPU, но при этом эффективно использовать ресурсы.

  • learning_rate=0.01 — скорость обучения. Определяет, насколько сильно модель обновляет свои веса на каждом шаге. Просьба повторять только дома! Иначе с таким относительно большим значением можно потерять локальный минимум при обучении.

  • weight_decay=0.05 — это настройка L2-регуляризации, которая предотвращает от переобучения, создавая штраф для больших весов. Введённая настройка приведет к тому, что в ходе обучения метрики будут хуже, но на новых данных должны быть лучше.

  • Про логирование кажется всё понятно — сохраняет в указанную директорию с указанным шагом. Если это не требуется, можно указать параметр logging_strategy='no', либо просто не указывать эти параметры.

# Настройки обучения модели
training_args = TrainingArguments(
    output_dir='./results',
    num_train_epochs=2,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    learning_rate=0.01,
    weight_decay=0.05,
    logging_dir='./logs',
    logging_steps=10,
)

Обучение

Передаем те параметры, которые определили в пунктах выше: саму модель, аргументы для процесса обучения, подготовленные выборки для обучения и валидации. train() запускает этот процесс.

# Обучение
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=test_dataset
)
 
trainer.train()

Сохраняем модель и токенизатор

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

Токенизатор позволит сохранить правила разбиения текста на токены. Это как словарь для модели, который помогает воспринимать новые тексты и скармливать данные в одной системе координат.

# Сохраняем модель
model_path = "./fine_tuned_gpt2_model_datamart_test240"
model.save_pretrained(model_path)
tokenizer.save_pretrained(model_path)

Получаем результаты поиска

# Функция для получения результатов поиска
def predict(text, top_k=5):
    inputs = tokenizer(text, 
                       truncation=True, 
                       padding=True, 
                       max_length=512, 
                       return_tensors="pt")
    with torch.no_grad():
        outputs = model(**inputs)
 
    probabilities = F.softmax(outputs.logits, dim=1)
    top_probs, top_indices = torch.topk(probabilities, k=top_k)
    top_classes = le.inverse_transform(top_indices[0].numpy())
 
    results = list(zip(top_classes, top_probs[0].numpy()))
    results.sort(key=lambda x: x[1], reverse=True)
    return results

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

Текст в inputs подготавливается следующим образом:

  • происходит разбиение на части — токены, — это делалось и при обучении;

  • если текст длиннее 512 токенов, то обрезается и за это отвечает truncation=True;

  • если короче 512, то дополняется padding=True;

  • return_tensors=«pt» говорит о том, что результат возвращается в виде тензора библиотеки PyTorch.

Получение ответа модели в виде outputs:

  • no_grad() — помогает отключить расчёт градиента, нужен только в процессе обучения,

  • и в самом выводе содержатся необработанные оценки для каждого класса/таргета.

Расчет вероятностей можно произвести функцией softmax. Например, у нас есть два класса с такими значениями — [0.1, 0.9] — это говорит о том, что модель уверена во втором классе на 90%.

Выбор топ результатов torch.topk():

  • top_k=5 выбирает 5 классов с наибольшими вероятностями,

  • top_indices номера топов (например, [2, 1, 3]),

  • top_probs — это вероятности

Обратным преобразованием занимается метод из sklearn — LabelEncoder, который мы использовали в самом начале для подготовки датасета. Функция le.inverse_transform() конвертирует обратно числа в наши названия. numpy() — конвертирует тензор в массив для sklearn

Сортировка и что возвращает функция (results):

  • создаётся список пар из названия класса и вероятности в коде — это функции list и zip;

  • sort помогает отсортировать по убыванию вероятности;

  • в результате возвращается что-то вроде этого: [(»Вклад», 0.65), (»Накопительный счет», 0.32), …].

Все приготовления завершены

Осталось запустить эту часть кода...

# Пример использования:
text = "какие таблицы использовать для расчета CLTV по клиенту"
top_predictions = predict(text)
 
print("Топ подходящих вариантов:")
for i, (class_name, probability) in enumerate(top_predictions, 1):
    print(f"{i}. {class_name}: {probability:.4f}")
    ind = df[df['name']==class_name]['description'].index[0]
    print(df[df['name']==class_name]['description'][ind])

...где происходит следующее:

  • Модель анализирует запрос, решая задачу классификации. Проставляется вероятность по подходящему классу.

  • Выбираются топ-5 классов (по умолчанию, заведено ранее).

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

Стоит отдельно отметить функцию активации на выходе — softmax. Она даёт возможность преобразовать выходные значения в вероятности. 

Почему это важно? 

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

Решение проблем с галлюцинированием простой модели

Пример части ответа, который могла выдавать модель и неважно на какой запрос. 

вставленный-фильм.png
Пример галлюцинации модели

В какой-то момент модель «уносит», поэтому код построен так, чтобы модель решала задачу классификации, то есть выдавала ответ из существующих. Нам они как раз известны — это либо название витрины, либо название фичи. Для MVP решения я осуществлял поиск по витринам — их меньше и описание более основательное.

вставленный-фильм.png
Ответ модели с топ-5 вариантов

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

К чему пришли в итоге?

По результатам MVP получили «добро» от коллег и руководства, но для прома решение на коленке и GPT-2 не прокатит.

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

Всем спасибо!

Комментарии (0)