Время прочтения: 10 мин.

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

С чего началось

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

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

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

Более простые вопросно-ответные системы не умеют обобщать ответ на вопрос, а, скорее, пытаются подобрать слова, наиболее подходящие по смыслу к вопросу. К примеру, такой текст:

Если системе задать вопрос «Сколько в тексте запятых?», то она не сможет на него ответить, так как не “догадается” пересчитать их, а вот если спросить её: «Когда убирать лёд?», то она однозначно ответит: «на выходных», так как слово «лёд» встречается только в этом предложении и словосочетание «на выходных» относится ко времени.

Выбор модели

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

Также библиотека transformers поддерживает взаимодействие фреймворков PyTorch, TensorFlow и JAX, то есть можно на вход модели передать данные в одном фреймворке, а получить результаты в другом.

Чтобы выбрать подходящую для задачи модель, выставляю на платформе Hugging Face соответствующие фильтры: тип модели – вопросно-ответная, язык – русский. Под такие критерии попадают три модели, попробую их все:

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

from transformers import pipeline

question = 'Когда будем убирать лёд?'
context = ('Вчера выпало много снега, а сегодня из-за'
         ' тёплой погоды он подтаял и заледенел.'
         ' Придётся на выходных взять лом и немного разбить лёд.')

model_pipeline = pipeline(
   task='question-answering',
   model='timpal0l/mdeberta-v3-base-squad2'
)

model_pipeline(question=question, context=context)
{'score': 0.6851194500923157,
 'start': 88,
 'end': 100,
 'answer': ' на выходных'}

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

Программный код

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

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

import torch
import numpy as np
from transformers import AutoTokenizer, AutoModelForQuestionAnswering

tokenizer = AutoTokenizer.from_pretrained("timpal0l/mdeberta-v3-base-squad2")

model = AutoModelForQuestionAnswering.from_pretrained("timpal0l/mdeberta-v3-base-squad2")

Теперь следует с помощью токенизатора обработать вопрос вместе с текстом. Здесь я добавлю специальный флаг, чтобы токенизатор не вставлял в токены специальные символы, как, например, символ для разделения предложений. Для демонстрации работы системы в качестве текста буду использовать первый том произведения Льва Толстого «Война и мир»:

question = 'Кто приехал в гости к Анне Павловне?'

tokenized = tokenizer.encode_plus(
   question, text,
   add_special_tokens=False
)

Чтобы в дальнейшем была возможность вывести ответ на естественном языке, заранее извлекаю символьные токены:

tokens = tokenizer.convert_ids_to_tokens(tokenized['input_ids'])

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

Сначала следует задать длину каждого блока и длину наложения:

# Общая длина каждого блока
max_chunk_length = 512
# Длина наложения
overlapped_length = 30

Элемент token_type_ids в токенизаторе нулями отмечает вопрос, а единицами основной текст, поэтому чтобы вычислить длину вопроса в токенах нужно подсчитать количество нулей в этом элементе:

# Длина вопроса в токенах
answer_tokens_length = tokenized.token_type_ids.count(0)
# Токены вопроса, закодированные числами
answer_input_ids = tokenized.input_ids[:answer_tokens_length]

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

# Длина основного текста первого блока без наложения
first_context_chunk_length = max_chunk_length - answer_tokens_length
# Длина основного текста остальных блоков с наложением
context_chunk_length = 
 max_chunk_length - answer_tokens_length - overlapped_length

Далее отделю первый блок и обработаю оставшиеся:

# Токены основного текста
context_input_ids = tokenized.input_ids[answer_tokens_length:]
# Основной текст первого блока
first = context_input_ids[:first_context_chunk_length]
# Основной текст остальных блоков
others = context_input_ids[first_context_chunk_length:]

# Если есть блоки кроме первого
# тогда обрабатываются все блоки
if len(others) > 0:
  # Кол-во нулевых токенов, для выравнивания последнего блока по длине
  padding_length = context_chunk_length - (len(others) % context_chunk_length)
  others += [0] * padding_length

  # Кол-во блоков и их длина без добавления наложения
  new_size = (
      len(others) // context_chunk_length,
      context_chunk_length
  )

  # Упаковка блоков
  new_context_input_ids = np.reshape(others, new_size)

  # Вычисление наложения
  overlappeds = new_context_input_ids[:, -overlapped_length:]
  # Добавление в наложения частей из первого блока
  overlappeds = np.insert(overlappeds, 0, first[-overlapped_length:], axis=0)
  # Удаление наложение из последнего блока, так как оно не нужно
  overlappeds = overlappeds[:-1]

  # Добавление наложения
  new_context_input_ids = np.c_[overlappeds, new_context_input_ids]
  # Добавление первого блока
  new_context_input_ids = np.insert(new_context_input_ids, 0, first, axis=0)

  # Добавление вопроса в каждый блок
  new_input_ids = np.c_[
    [answer_input_ids] * new_context_input_ids.shape[0],
    new_context_input_ids
  ]
# иначе обрабатывается только первый
else:
  # Кол-во нулевых токенов, для выравнивания блока по длине
  padding_length = first_context_chunk_length - (len(first) % first_context_chunk_length)
  # Добавление нулевых токенов
  new_input_ids = np.array(
    [answer_input_ids + first + [0] * padding_length]
  )

После того, как получены блоки токенов, формирую маски для отделения вопроса от текста и для «внимания» модели:

# Кол-во блоков
count_chunks = new_input_ids.shape[0]

# Маска, разделяющая вопрос и текст
new_token_type_ids = [
  # вопрос блока
  [0] * answer_tokens_length
  # текст блока
  + [1] * (max_chunk_length - answer_tokens_length)
] * count_chunks

# Маска "внимания" модели на все токены, кроме нулевых в последнем блоке
new_attention_mask = (
  # во всех блоках, кроме последнего, "внимание" на все слова
  [[1] * max_chunk_length] * (count_chunks - 1)
  # в последнем блоке "внимание" только на ненулевые токены
  + [([1] * (max_chunk_length - padding_length)) + ([0] * padding_length)]
)

Теперь блоки и маски оборачиваю в tensor и подаю на вход модели:

# Токенизированный текст в виде блоков, упакованный в torch
new_tokenized = {
 'input_ids': torch.tensor(new_input_ids),
 'token_type_ids': torch.tensor(new_token_type_ids),
 'attention_mask': torch.tensor(new_attention_mask)
}

outputs = model(**new_tokenized)

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

# Позиции в 2D списке токенов начала и конца наиболее вероятного ответа
# позиции одним числом
start_index = torch.argmax(outputs.start_logits)
end_index = torch.argmax(outputs.end_logits)

# Пересчёт позиций начала и конца ответа для 1D списка токенов
# = длина первого блока + (
#   позиция - длина первого блока
#   - длина ответов и отступов во всех блоках, кроме первого
# )
start_index = max_chunk_length + (
  start_index - max_chunk_length
  - (answer_tokens_length + overlapped_length)
  * (start_index // max_chunk_length)
)
end_index = max_chunk_length + (
  end_index - max_chunk_length
  - (answer_tokens_length + overlapped_length)
  * (end_index // max_chunk_length)
)

# Составление ответа
# если есть символ начала слова '▁', то он заменяется на пробел
answer = ''.join(
  [t.repace('▁', ' ') for t in tokens[start_index:end_index+1]]
)

print('Вопрос:', question)
print('Ответ:', answer)
Out:
Вопрос: Кто приехал в гости к Анне Павловне?
Ответ: высшая знать Петербурга

Графический интерфейс

Чтобы было совсем уж хорошо, сделаю небольшой графический интерфейс. Для этого буду использовать библиотеку PySimpleGUI. Интерфейс будет состоять из текстового поля для ввода основного текста, вопроса по этому тексту, кнопки для запуска и поля для вывода ответа. Код для создания всего этого представлен ниже. Весь вышеизложенный код поиска ответа упакован в функцию question_answer, у которой первый аргумент текст, а второй – вопрос:

import PySimpleGUI as sg


# Создание и укладка элементов окна
layout = [
   [sg.Text('Текст', key='-Text-label-')],
   [sg.Multiline('', key='-Text-', expand_x=True, expand_y=True)],
   [sg.Text('Вопрос', key='-Question-label-')],
   [sg.Input('', key='-Question-')],
   [sg.Button('Получить ответ')],
   [sg.Text('Ответ', key='-Answer-label-', visible=False)],
   [sg.Text('', key='-Answer-', font=('Arial Bold', 13), visible=False)],
]
# Создание окна
window = sg.Window('', layout, resizable=True, size=(700, 700), finalize=True)

# Обработка событий окна, пока оно не будет закрыто
while True:
   event, values = window.read()
   # Событие закрытие окна
   if event == sg.WINDOW_CLOSED:
       break
   # Событие при нажатии на кнопку для 'Получить ответ'
   elif event == 'Получить ответ':
       window['-Answer-label-'].update(visible=True)
       window['-Answer-'].update(
           question_answer(values['-Text-'], values['-Question-']),
           visible=True
       )

window.close()

В результате интерфейс имеет следующий вид:

Сравнение результатов

Теперь проведу сравнения работы всех ранее выбранных моделей на нескольких вопросах:

Как видно на сравнении, модели лучше всего отвечают на вопросы с однозначным ответом, особенно если ответ расположен рядом с ключевыми словами из вопроса. Так, например, в 5-ом вопросе все модели ответили, своего рода, правильно, а 6-й вопрос более обобщён, так как в тексте словосочетание «сын графа Безухова« и слово «Пьер» достаточно близко не встречаются, поэтому модели не смогли найти точный ответ.

Если сравнивать результативность моделей, то из всех трёх, на мой взгляд, лучше всего справилась первая модель, поэтому именно её и использовали на практике для решения своей задачи.

Вывод: применение и дальнейшее развитие

По итогу получен код для установки и использования предобученых вопросно-ответных моделей с платформы Hugging Face для большого объёма текста. Главная цель его создания была автоматизация анализа произвольного текста путём составления вопросов на естественном языке, в противовес поиску нужной информации по ключевым словам.

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

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

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