Мне нужно было, чтобы языковая модель читала письма со счетами на оплату и отдавала 5 полей в JSON, от отправителя до срока оплаты. У GPT-2 large с 3 примерами в промпте примерно половина ответов не проходила схему. Очевидным казалось решение не давать модели писать ничего, чего схема не допускает, как это делает PICARD для SQL. План был простой: если ответ проходит jsonschema, он идёт в таблицу. Что этот план на самом деле пропускает, я не измерял, поэтому измерил.
Стенд
Я сгенерировал скриптом 150 коротких писем, seed 2022, так что каждый верный ответ известен. Часть полей пропущена нарочно. В 53 письмах нет номера счёта на оплату, но есть номер заказа. В 51 нет срока оплаты, но есть дата выставления счёта. Валюта не указана в 40. Сумма записана в 4 форматах:
Amount to pay: USD 11,113.79.
Amount to pay: 1 951,01 USD.
Amount to pay: €15060.75.
Amount to pay: 16 128,82 EUR.
В схеме 5 обязательных ключей, сумма это число, валюта это один из 3 кодов (PLN, EUR или USD), срок оплаты подходит под шаблон ^\d{4}-\d{2}-\d{2}$. Номер счёта, валюта и срок оплаты могут быть null. В промпте сказано “Use null when a field is not in the text”, а в одном из 3 примеров null стоит в 3 полях.
Я запускал GPT-2 large (774M параметров) на 4 ядрах виртуальной машины, Python 3.9, torch 1.11, transformers 4.18, jsonschema 4.4, жадное декодирование. transformers 4.18 загружает весь файл весов в память рядом с моделью. Мой лимит в 5 ГиБ этого не позволял, поэтому веса я загружаю по частям. На числа это никак не влияет, но стоило мне 2 прогонов, которые система убила, пока я не понял, в чём дело.
Письмо считается верным, только если верны все 5 полей. Отсутствующее поле засчитывается, только если в нём null.
Свободная генерация
Здесь модель пишет JSON сама, потом json.loads и проверка схемой. Схему проходят 74 ответа из 150. Все 5 полей верны в 12 из них.
58 из 76 сломанных ответов падают по одной причине. Модель копирует сумму так, как она напечатана: "amount": 11,113.79 или "amount": 1 951,01. Разбор JSON обрывается на запятой или на пробеле. Остальные 18 пишут срок оплаты как "10.04.2022", а такое шаблон не пропускает. Оба отказа громкие: разбор останавливается и я знаю, что письмо надо посмотреть.
Что до null, указание почти ничего не дало. На 150 ответов модель написала null один раз.
Декодирование по схеме
Второй прогон идёт на той же модели и с тем же промптом. Теперь ключи, запятые и скобки пишет мой код, а модель выбирает только сами значения. На каждом шаге я сортирую токены по оценке модели. Потом беру первый, после которого значение остаётся допустимым началом своего типа. В числе могут быть цифры и одна точка. Дата должна оставаться началом строки вида 2022-04-10. Для валюты остаются только 3 кода.
Первая версия заканчивала число только на запятой или закрывающей скобке. В письме после суммы идёт пробел или точка в конце предложения, а моя грамматика запрещала и то и другое, поэтому модель дописывала цифры до моего лимита в 40 токенов: 15060.750000100000000080000... на каждом из 32 писем пробного прогона на GPT-2 medium. JSON допускает пробел после значения, так что теперь число заканчивается и на пробеле. Я довольно долго думал, что виновата модель. Виновата была моя грамматика.
С исправленным декодером схему проходят все 150 ответов. Все 5 полей верны в 11, на один меньше, чем без него.
Куда делись громкие отказы
Все 76 писем, которые в свободной генерации падали громко, теперь проходят схему с неверным ответом. Ни одно из них не стало верным.
Большую часть объясняют суммы. GPT-2 начинает копировать 11,113.79 точно так же, как раньше. После 11 лучший токен, который допускает грамматика, начинается с запятой. В JSON запятая заканчивает число. Значение получается 11. С 1 951,01 число заканчивает пробел, значение 1. Неверных сумм 62. В 56 из них стоят цифры до первого пробела или запятой. На 55 из этих 56 писем свободная генерация давала сломанный JSON. На оставшемся она написала верное 668.29, а декодер сделал из него 668.
С датами то же самое. В 25 письмах срок оплаты напечатан как 10.04.2022. Модель начинает с 10, как в письме, шаблону до дефиса нужны ещё 2 цифры и ответ получается 1034-04-20. Ни одна из этих 25 дат не верна. 21 из них приходится на годы вроде 1034, 2603 и 0501, остальные 4 это даты из примеров в промпте. В jsonschema есть проверка format: date, по умолчанию выключенная. Я её включил. Она отклонила 4 ответа из 150, с месяцем 17, 20, 24 или 26. 2603-04-16 это допустимая календарная дата, поэтому она проходит.
Что попадает в поле, которого нет
Каждый ключ должен быть на месте, а этот декодер никогда не предлагает null, так что отсутствующее поле чем-то заполняется. И заполняется оно не случайно:
- номер счёта: номер заказа в 51 письме из 53, номер заказа с префиксом в остальных 2;
- валюта: EUR во всех 40;
- срок оплаты: дата из примеров в промпте в 17 из 51, дата выставления счёта в 14, дата не в 2022 году в 15, какая-то другая дата в 5.
Каждое из этих значений проходит схему, а большинство выглядит правдоподобно. Номер заказа в поле номера счёта это число из 4 или 5 цифр из того же письма. Моя проверка схемой его бы не поймала.
Разрешить null
Третий прогон это тот же декодер, но в 3 полях, которые могут отсутствовать, разрешён ещё и null. Модель выбрала null 9 раз из 450 полей, где могла. 4 раза из 144 полей, которых в письме действительно нет. Остальные 5 на полях, которые в письме были. Все 5 полей верны в 10 из 150, на один меньше, чем без null.
Модель поменьше
Те же 3 прогона я сделал на GPT-2 medium (355M параметров). В свободной генерации схему проходят 52 ответа из 150, все 5 полей верны в 1. Декодирование по схеме проходит в 149, а последний отказ на моей совести: номер счёта у него пошёл KOW-S-S-S-... до моего лимита в 40 токенов. Верных писем 2. Из 98 громких отказов 95 стали тихими и 2 стали верными, так что этой модели декодер помог, на 1 письмо. Когда null разрешён, она не выбрала его ни разу и дала те же 150 ответов, что и без него. Отсутствующий номер счёта у неё это чаще всего номер заказа с буквами из имени отправителя впереди, вроде ORZ-34592, в 36 письмах из 53.
Что я из этого вынес
До этого проверка схемой была для меня последним шагом перед таблицей. Она проверяет форму значения. Откуда значение взялось, она не знает. С GPT-2 large декодер сделал только хуже: случаи, где разбор останавливался и я бы посмотрел глазами, он превратил в строки, которые выглядят нормально.
Для этой задачи я бы скорее оставил свободную генерацию с её громкими отказами и потом сверял каждое значение с самим письмом, но такую проверку я ещё не измерял.
Чего я не проверял
- Модели крупнее. GPT-2 large для такой задачи маленькая. Как распределились бы эти 76 писем у GPT-3, я не знаю, её я не вызывал.
- Beam search, сэмплирование или промпт без примеров.
- Грамматику, которая допускает запятую внутри числа и потом её исправляет. Она спасла бы
11,113.79, но номер заказа всё равно не спасла бы. - Цену. Декодирование по схеме здесь не было медленнее, в среднем 16 секунд на письмо против 19 у свободной генерации, но это время одного прогона на общей машине.
- Настоящие письма со счетами. Это 150 сгенерированных писем по одному шаблону, с 4 форматами суммы и 3 форматами даты.