Создание новеллы на ходу нейросетью RuGPT3.5: Настоящее бесконечное лето

Создание новеллы на ходу нейросетью RuGPT3.5: Настоящее бесконечное лето
Что это за мировая линия?

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

Оглавление:

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


Живы ли сейчас открытые GPT?

Для ознакомления с принципом работы архитектуры GPT советую прочитать этот подробный разбор: GPT-2 в картинках, и этот: GPT-3

Что мы имеем из ванильных моделей:

  • GPT-1 (2018) (Context: 512) — Работала не очень хорошо (длинные тексты генерировались плохо), но при файнтюнинге на отдельных задачах эта модель могла выполнять несложные задания.

  • GPT-2 (2019) (Context: 1024) — стала лучше, научилась писать длинные связные тексты и даже решать задачи при помощи prompt engineering без обучения..

  • GPT-3 (2020) (Context: 2048) — Стала в 10 (!) раз больше, и настолько крутой, что даже научилась писать рабочий программный код.

  • RuGPT3, RuDialoGPT3, (Context: 512!) — и множество других дообученных версий GPT3 под русский язык было обучено и выложено Сбером в открытый доступ (В свое время я даже эссе по истории, общаге и паре спецов написал исключительно ими).
    Но был у них один небольшой нюанс. Небольшой он настолько же, как их контекст, длиной 512 токенов. (это как у самой первой GPT, да).
    Просто выяснилось, что если тренировать модель, рассчитанную на 2048 контекст кусками текста по 512, то она немного отупеет.

Свято место пусто не бывает, кто-то должен был начать это монетизировать. Этим занялись сами создатели архитектуры — OpenAI, которые решили пойти против своего названия и запустить сайт, с чат интерфейсом своей новой версии GPT3, дообученной на контексте разметки чата — ChatGPT. В первый день её выхода в открытый тест я зарегал temp phone number и был разочарован. Он работал ничуть не лучше ванильной GPT3 на английском, а русский язык был вообще машинным переводом на входе и выходе.

После выхода ChatGPT3.5 с закрытыми исходниками долгое время была видимость того, что развитие отечественной индустрии энтузиастами умрёт вместе с рождением ChatGPT4. OpenAI стали продавать API частным компаниям и получать деньги на дальнейшую разработку.

Менее чем через год стали заметны продвижения в пользовательских моделях.

YaML 100b (2022) (Context: ?) — нечто жидкое, сопоставимое с плевком в лицо. Модель о которой неизвестно ничего, хоть она и в опен сорс… Так как весит сие чудо 250гб! А их ещё нужно на видеокарту выгрузить, в общем, своеобразный прикол от всеми любимой компании. Всё что о ней известно, ограничивается одной статьёй, самого Ya:
Яндекс выложил YaLM 100B — сейчас это крупнейшая GPT-подобная нейросеть в свободном доступе.

LLama 2 (2023) (Context: 4096) — Самый сок, совсем недавно вышедшая модель, которая запустила множество процессов. Стали появляться десятки кастомных маленьких моделей.
HF сейчас наполнен чат-ботами на базе LLama, дообученных пользователями за сущие часы для выполнения конкретных задач. Вот только в русский язык эта модель не могла вообще (в данных для обучения его было менее 1%). Дообучение тут не поможет. В то время как ChatGPT стабильно работает на разных языках.

Сбер же недавно анонсировали GigaChat, которую все посчитали рофлом и распилом денег, доступы в ЗБТ давали очень неторопливо.
Я попал в первые 10к человек после блогеров и компаний, и как и ожидалось, GigaChat был тупой. Далее я за ним не следил.
Сейчас вроде уже идёт ОБТ и каждый желающий может потыкать «чисто русский ChatGPT», но совсем недавно случился неожиданный поворот событий. Они выложили в опен сорс чекпоинт, на котором основан GigaChat: Сбер открывает доступ к нейросетевой модели ruGPT-3.5. Скорее всего, стала очевидна неликвидность GigaChat как нового, отечественного API для создания ботов, и они решили дать разработчикам потрогать удовольствие создания чат-бота на базе голых весов, а не API, и если подобная стратегия сработает, то они смогут распространять подобные модели под коммерческой лицензией для компаний, которым нужен «свой, независимый чат-бот». Своего рода превращение услуги в товар.

Не привязанная к разметке диалога и вопросно/ответной системе эта модель является всё той-же продолжалкой теста как и RuGPT3, только без кастрированного контекста (он составляет 2048 токенов, так сказать, догнали оригинал)

Знакомство с RuGPT3.5

Для начала нужно запомнить, что в оригинале наша новая подруга весит 50 гигаметров, однако такое количество видеопамяти мне не по карману. Благо добрые люди уже конвертировали сеть, уменьшив битность каждого из её нейронов, сжав её тем самым в 4 раза! Размер 4х битной модели составляет около 7 гигов.

Теперь мы можем запустить её в бесплатном Google Colab на NVIDIA Tesla T4 16Gb и получить, пусть и менее точный, но практически полный опыт готовки её в домашних условиях.

pip install transformers auto-gptq gradio >> nul

Устанавливаем необходимые библиотеки и переходим к скачиванию и выгрузке на видеокарту самой нейросети.

from transformers import AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM
import gradio as gr

model_name="fffrrt/ruGPT-3.5-13B-GPTQ"
model_basename="gptq_model-4bit-128g"

tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)
model = AutoGPTQForCausalLM.from_quantized(model_name,
        model_basename = model_basename,
        use_safetensors=True,
        trust_remote_code=True,
        device="cuda:0",
        use_triton=False,
        quantize_config=None)

Теперь у нас есть model и tokenizer, как в старые добрые времена GPT2/3.

Простые тесты

Если вы не знаете кто такие эти ваши top_k, top_p, temperature и no_repeat_ngram, но знаете единственный богоугодный язык (он же английский), то можете наверстать или вспомнить всё по ссылке:
Using different decoding methods for language generation with Transformers

Параметры генерации

K:40,
P:0.98,
temperature:0.6,
no_repeat_ngram_size:4

И да, beam search работает уж совсем медленно и плохо. Модель зацикливается буквально на ровном месте, так что в дальнейшем я буду использовать исключительно top_p семплинг.

Предлагаю испытать модель на том, что ей должно быть как кровь родное — литературные тексты. Возьму я, однако, не самый простой кусок: Это будет перегруженный контекст «Зова Ктулуху» Лавкрафта

Пример с Зовом Ктулуху

Великие Старейшины, продолжал Кастро, не целиком состоят из плоти и крови. У них есть форма — …………………………………………………………………………….., Великие старейшины обращались к самым чутким из них при помощи внедрения в них сновидений, ибо только таким образом мог Их язык достичь сознания людей.

И вот, прошептал Кастро, <Дальше генерация> старейшины, обитатели Р’льеха, должны были узнать, что на Земле есть люди, которые могут общаться с Ними, и потому Они послали своего слугу, Ктулхха, чтобы он отыскал таких людей. Он нашел их — по крайней мере, некоторых из них. Он нашел людей, которых мог подчинить себе. И он подчинил их себе. Он нашел таких людей, которые могли общаться с Н…

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

Диалог Ани и Васи в песочнице, когда Аню забирает мама.

Анна: Ты правда не вернёшься?

Я должен уйти, всем будет так лучше, ответил Василий.

Но мне будет плохо без тебя, я не смогу жить без тебя, — заплакала Анна.

Я вернусь, — сказал Василий.

Анна: Я буду ждать тебя, я буду ждать тебя всегда, я буду любить тебя, я всегда буду любить тебя.

Василий: Я вернусь, я обязательно вернусь.

В этот момент в комнату вошла мать Анны.

Мать Анны: Анна, что ты наделала?

Анна (плача): Я люблю его.

Я люблю его, — сказала Анна.

Она подошла к Василию, обняла его и поцеловала.

После этого Василий и Анна вышли из дома и пошли по дороге

Получилось плоско, мда. Ну, как отечественный фильм для зомбоящика покатит.

Ладно, переходим к нашему герою

Игре, которая состоит на 99% из дженерик текста, писанного анончиками, игра, которая получила любовь простых игроков, будучи нишевым произведением про маскотов имиджбордовБесконечное Лето.

Но, как оно обычно и бывает — есть один нюанс.

Почему Лето не Бесконечное?

Так как в нём всего 7 дней, а название игры это обман и провокация.

  1. Качаем игру из стима

  2. Делаем вид, что мы разработчик модов, а значит можем лезть в файлы игры.

  3. Распаковываем файлы RenPy при помощи Unrpyc (github)

  4. Вручную превращаем сценарии RenPy в текст (парсим регулярками (regex))

  5. Получаем сценарий игры в виде текстового документа.

За неделю пассивного перечитывания и редактуры я выполнил пункт 3 (ну, почти)
Результат проделанной работы тут: https://github.com/CodeDruidX/Everlasting-Summer-txt

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

В этой статье мы будем тестировать RuGPT3.5, как генератор истории для визуальной новеллы (буду честен, просто новеллы, ни разу не визуальной). Хотелось бы дообучить RuGPT3.5 на этом наборе текста и добиться адекватного результата, в лучшем случае — интегрировать генератор сценария прямо в RenPy, и получить готовый продукт с минимальными затратами.

Как выглядит сценарий?

.rpy — это файлик на питоне, где от питона остался только синтаксис циклов\ветвлений.
Реплики персонажей выглядят как:
» (id) Привет, как дела!» — где id является идентификатором героя — он определяет имя и его стиль в графическом окне.

В моем случае все эти конструкции были превращены в обычный текст:
«Я: Привет, как дела!»

Для визуальных элементов, в общих чертах, используется что-то вроде «(id)-в-костюме пионера-смотрит-вперед». Это мы всё пока-что убираем, в надежде на светлое будущее.

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

Начало 2-ого дня — Осторожно, длинный текст

Мне снился сон…

Казалось, я нахожусь в каком-то вакууме, а вокруг – пустота.

Но не только _вокруг_ – Я единственное существо во Вселенной.

Как будто она вернулась в некое сингулярное состояние перед самым Большим Взрывом.

И вот-вот что-то должно было произойти.

Вдруг я начал слышать голос.

Слов не разобрать, но он показался мне знакомым.

Голос что-то нежно нашёптывал, как будто убаюкивая.

И тут я понял… Это был голос той Незнакомки из автобуса. Той девушки из сна.

Мысли: Но что она хочет мне сказать? Кто она?..

Я проснулся.

Яркие лучи солнца били в глаза.

Время приближалось к полудню.

Лениво потянувшись и зевнув, я начал вспоминать вчерашний день.

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

Мысли: Но ведь всё не так, неправильно!

Не эта ситуация, не моё положение – они неправильны априори, – а моё отношение к ним.

Мысли: Ведь я вчера запросто заснул здесь, а до этого мило беседовал с местными пионерами, даже умудрялся шутить!

Мысли: Но как можно себя так вести в подобной ситуации?!

Мысли: Я же должен бояться, шарахаться от каждого шороха, избегать любого контакта с потенциально враждебными существами.

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

Мысли: Это очень похоже на утро после хорошей пьянки: вчерашнее естественное, непредосудительное, в высшей степени нормальное поведение утром становится кошмаром, гротескной гравюрой из иллюстраций к «Божественной комедии».

Мысли: Да, всё именно так, однако прошлого уже не вернуть.

Хотя, возможно, оценив обстановку, я действовал по ситуации.

Я огляделся по сторонам, пытаясь понять, не забросило ли меня куда-нибудь ещё, но домик Ольги Дмитриевны выглядел так же, как и вчера.

Всё было как будто на своих местах, разве что на спинке кровати висела пионерская форма.

Я с недоверием покрутил её в руках, примерил и оделся.

Мысли: Всё равно это лучше, чем ходить в зимней одежде.

Мысли: Посмотреть бы теперь на себя – наверняка выгляжу как клоун!

А для этого нужно зеркало. Хотя бы самое маленькое.

Нашлось оно на дверце шкафа.

Семён: Твою!..

Я взглянул на новоиспечённого пионера и аж отпрыгнул в сторону от неожиданности!

На другой стороне зеркала стоял какой-то подросток!

Похожий на меня, но не я!

Куда пропали недельная небритость, мешки под глазами, сутулость и смертельно уставшее выражение лица?!

Похоже, меня не закинули назад во времени или в параллельную реальность, а просто поменяли с кем-то телами.

Мысли: Действительно просто! Такое же на каждом шагу встречается!

Я пригляделся к незнакомцу повнимательнее и только тогда понял, что это я сам!

Только образца конца школы – начала института.

Мысли: Ладно, хотя бы так.

Мысли: Да уж, _человек в стрессовой ситуации_ слона и не приметил!

Мысли: А вот вожатая обратила внимание и вчера ночью меня отчитала за неподобающее обращение к ней…

Мысли: К чёрту!

Мысли: Вряд ли мой внешний вид влияет на что-то ещё.

Если верить часам, завтрак уже давно позади.

Мысли: Ну ладно, попробую всё же в столовой что-нибудь найти.

Мысли: Вчера же со Славей получилось.

От этих воспоминаний я невольно улыбнулся.

На улице ярко светило солнце, дул лёгкий ветерок.

Мысли: Прекрасный летний день.

Я уже несколько лет не чувствовал себя по утрам так хорошо.

Все проблемы на секунду улетели куда-то далеко, растворились в редких, цвета первого снега облаках.

Вдруг передо мной словно из ниоткуда появилась Ольга Дмитриевна.

Ольга Дмитриевна: Доброе утро, Семён!

Семён: Доброе!

Я улыбнулся, пытаясь всем своим видом показать, что несмотря ни на что утро моё было действительно добрым.

Ольга Дмитриевна: Ты только вчера приехал, так что будить я тебя не стала, но завтрак-то…

Ольга Дмитриевна: Хотя ладно! Вот, держи!

Она протянула мне бумажный свёрток.

Судя по масляным пятнам, внутри, скорее всего, бутерброды.

Семён: Ой, спасибо!

Ольга Дмитриевна: А теперь марш умываться!

Я уже собирался уходить.

Ольга Дмитриевна: Сейчас, подожди.

Ольга Дмитриевна забежала в домик и, вернувшись, сунула мне небольшой пакетик.

Внутри оказались зубная щётка, мыло, небольшое полотенце и что-то ещё – я особо не всматривался.

Ольга Дмитриевна: Пионер должен быть всегда чист и опрятен!

Ольга Дмитриевна: Дай я тебе галстук правильно завяжу на первый раз, а то он болтается. Потом научишься, сам будешь!

Семён: А может, не надо? Я сейчас умываться иду.

Мысли: Ну да, вдруг зацеплюсь за кран и удавлюсь…

Ольга Дмитриевна: Ладно, тогда потом. И не забудь про линейку.

Мысли: Карандаши, ручки, линейки… Такие вещи не забываются!

Семён: Какую линейку?

Ольга Дмитриевна: В смысле – какую линейку?!

Она нахмурилась.

Ольга Дмитриевна: Сегодня же понедельник!

Мысли: Странно, а по моим подсчётам – воскресенье…

Мысли: Впрочем, смена дня недели – это ещё не самое страшное.

Ольга Дмитриевна: Обычно у нас линейки рано утром, до завтрака, но сегодня понедельник, поэтому она будет в 12 часов.

Ольга Дмитриевна: Не опаздывай!

Семён: Хорошо. А где?

Ольга Дмитриевна: На площади, где же ещё!

Спорить было бессмысленно.

Я направился в сторону «помывочной».

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

Я не был брезгливым человеком, но тем не менее, стоя тут, понял, что всё же есть какой-то минимальный уровень привычного комфорта, без которого жить мне довольно проблематично.

Вот ведь как бывает – когда теряешь вещи, которые всегда казались совершенно обыденными и естественными, понимаешь, что на самом деле они были незаменимы.

Мысли: А, да и чёрт с ним! Выбирать всё равно не из чего.

Вода оказалась просто ледяной.

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

В пакетике, который мне дала Ольга Дмитриевна, не нашлось зубной пасты.

Можно, конечно, было почистить зубы и так, но в полотенце была завернута какая-то кругленькая коробочка.

«Зубной порошок».

Мысли: Прелестно! +1 за то, что я где-то в прошлом.

Умылся я довольно быстро, в том числе и из-за ледяной воды.

Кто-то быстро шёл, даже бежал в мою сторону.

Я обернулся.

Передо мной стояла Славя в спортивном костюме.

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

Славя: Физкульт-привет!

Семён: Охай… То есть, бобр… Доброе утро! Вот…

Приветствие мне удалось выбрать не сразу.

Славя: Почему на завтрак не пришёл?

Семён: Проспал.

Я сказал это так, словно гордился своим достижением.

Семён: Но мне Ольга Дмитриевна бутерброды принесла.

Славя: А, ну отлично тогда! Не забудь про линейку!

Семён: Да, конечно.

Мысли: Забудешь тут.

Славя: Ладно, я побежала, не скучай!

Она помахала мне на прощание и скрылась за поворотом тропинки.

Мысли: Судя по всему, линейка начнётся через пару минут.

Стоило быстренько забежать «домой», закинуть пакетик с умывальными принадлежностями, съесть бутерброды и только уже потом идти на площадь.

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

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И

А вот что было дальше, нам расскажет нейросеть, окей? Кстати, отвечаю на ваш немой вопрос — да, у этой модели отличный текстовый NSFW, в этом её никто не ограничивал.
Примеров с ним я приводить не буду, просто держу в курсе, да и к этому мы ещё вернёмся.

Подготавливаем функционал

cfg={
"K":50,
"P":0.92,                 # Конфигурация модели
"temperature":0.2,
"uningram":4
}

def editcfg(name,val):    # Функция, которую будет вызывать  Gradio
  print(name,val)         #         для изменения настроек в cfg
  global cfg
  cfg[name]=val

def gen(text,tokens=10):  # Обёртка для простой генерации заданного количества токенов
  encoded_input = tokenizer(text, return_tensors="pt").to('cuda:0')
  #print(encoded_input.input_ids[:,-1900:].shape)
  output = model.generate(
      input_ids=encoded_input.input_ids[:,-1900:],
      max_new_tokens=tokens,
      do_sample=True,
      top_k=cfg['K'],
      top_p=cfg['P'],
      temperature=cfg['temperature'],
      #num_beams=4,
      no_repeat_ngram_size=cfg['uningram'],
      pad_token_id=tokenizer.eos_token_id,
      # num_return_sequences=5,
      # do_sample=True
  )

  return tokenizer.decode(output[0], skip_special_tokens=True)

Теперь у нас есть интуитивно понятная функция gen(), которая просто продолжает переданную в неё строку.
Вы можете заметить что я беру 1900 последних токенов, обрезая все предыдущие, это нужно для того, что-бы с большой вероятностью уложиться в 2048 и не словить случайно Out Of Memory, который случается если контекст перегружен и не влазит в видеопамять (а у нас её в обрез)

Тесты в лабораторных условиях

Тестируем продолжение имеющегося у нас текста, никакого черри пикинга или редактуры — все свежевыжатое из модели (кроме NSFW, тут уж извините):

Куча тестов с комментариями

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И она была не одна.

Рядом с ней стоял какой- то парень.

Парень: Доброе утро!

Он был одет в белую

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И она была не одна.

Рядом с ней стоял какой- то парень.

Парень: Доброе утро!

Парень был одет в джинсы

Хммм, кажется, как под копирку (интересно откуда такое стремление добавить персонажа…)

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И она была не одна.

Рядом с ней стоял какой- то парень.

Парень: Доброе утро!

Парень был одет в спортивный

Это заговор? Ладно дам ей подсказку, пусть и неверную:

И ела мои бутерброды!

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И ела мои бутерброды!

Вожатая: Доброе утро!

От неожиданности я чуть не подавился.

Слава: Доброе утро.

Она стояла

Тут без комментариев, как я дальше выяснил она вообще не принимает «Славя» как имя! Это большой косяк, ведь дальше модель будет постоянно менять имя «Славя» на «Слава», как следствие, со временем меняя пол персонажу, мда…

UPD: В этом примере все сломалось еще до появления Слави, но проблему это не отменяет.

Ещё один пример:

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И ела мои бутерброды!

Вожатая: А, Семён, ты уже проснулся!

Слава: Доброе утро!

Оля: Доброе утро.

Славя как персонаж-баг, это имя ломает модель:

  • Она меняет его на «Слава»

  • А дальше начинает изменять имена всем персонажам как только вздумается

Изменим начальные условия ещё немного:

И танцевала балет!

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И танцевала балет!

В руках у неё была какая-то палка, которой она выделывала замысловатые па.

При этом она напевала что

Неплохо, теперь попробуем отыграть за главного героя вручную.

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И танцевала балет.

Я невольно выпалил.

Семён: Ольга Дмитриевна, что вы делаете!.

========Далее — нейросеть========

Ольга: А что, не видно?

Мысль: И что, она действительно танцует?

Я посмотрел на Ольгу Дмитриевну, которая, похоже, была в прекрасном настроении.

Она была одета в спортивный костюм, а на голове

Очень даже хорошо.

В NSFW тестах результаты всё ещё лучше, примерно 3/5 успешных сценариев со вступлением, выдержанным повествованием и логичным концом.

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

Генерация каждого из них занимала от 5 до 10 секунд — весьма шустро.

Набрасываем Gradio Webui

def complete_with_gpt(text):
    text=gen(text,30)
    return text

with gr.Blocks() as demo:
    gr.Markdown("# Ever novel")
    textbox = gr.Textbox(placeholder="Верни контекст на место!", value=prompt)
    btn = gr.Button("Ожидать")
    gr.Markdown("---")
    with gr.Row():
      K=gr.Number(label="top_K",value=cfg["K"],minimum=1)
      P=gr.Number(label="top_P",value=cfg["P"],minimum=0,maximum=1)
      T=gr.Number(label="Temperature",value=cfg["temperature"],minimum=0)
      U=gr.Number(label="Unique ngram len",value=cfg["uningram"],minimum=0)
      APPLY=gr.Button(value="Применить")

    APPLY.click(fn=lambda x: editcfg("K",x),inputs=K)
    APPLY.click(fn=lambda x: editcfg("P",x),inputs=P)
    APPLY.click(fn=lambda x: editcfg("temperature",x),inputs=T)
    APPLY.click(fn=lambda x: editcfg("uningram",x),inputs=U)
    btn.click(fn=complete_with_gpt, inputs=textbox, outputs=textbox, queue=False)

demo.launch(debug=True)
как элегантно!
как элегантно!

Интерфейс из себя представляет текстовое поле огромной длины, на нижнем конце которого генератор и его параметры — вроде стильно.

Ещё парочка заключительных тестов ваниллы.

Тесты

И танцевала балет!

Я невольно выпалил.

Семён: Ольга Дмитриевна, что вы делаете!?

Ольга: А что, не видно?

Мысль: И что, она действительно танцует?

Я был в шоке.

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

Она двигалась так, словно не было вокруг никого, кроме неё и музыки.

И это было действительно красиво.

Не знаю, сколько времени прошло, но когда танец закончился, я понял, что Ольга Дмитриевна всё ещё стоит посреди комнаты.

Только вот её лицо было красным, а глаза – мокрыми.

– Семён, – сказала она, – ты не мог бы…

– Да, конечно, – ответил я, – я сейчас.

Мне было стыдно за то, как я себя вёл.

Стыдно за то, каким я был.

А ещё я был рад, что она не заметила моего смущения.

Потому что я был смущён.

Очень смущён. И мне было стыдно за это.

За то, что мне было стыдно.

Опа, всё бы ничего — но мы зациклились, господа. Без действия игрока сюжет не хочет двигаться, а модель заполняется контекст мусором, которой вводит её саму в заблуждение.
Попробуем уменьшить температуру, для понижения уровня хаоса генерации.
temperature=0.07 — это реально низко. Рассчитываю на адекватный и предсказуемый ответ.

Ольга: А что, не видно?

Мысль: И что, она действительно танцует?

Я посмотрел на Ольгу Дмитриевну, которая, похоже, была в отличном настроении.

Она, улыбаясь, продолжала танцевать.

Танец был довольно странным, но в то же время завораживающим.

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

Рядом с ней был кто-то ещё, и этот кто-то тоже танцевал.

Мысли

Мыслей было много, но все они были какими-то обрывочными, не связанными друг с другом

Это плохо. Определённо. Получается максимально дженерик текст, но мы опять перешли к литературному стилю повествования — вот что меня реально волнует.

Даём ей еще один шанс:

Рядом с ней был кто-то ещё, и этот кто-то тоже танцевал.

Это был я!

Я был в своём теле, но в теле Ольги Дмитриевной!

Вот это плот-твист. Кажется, температура у нас сегодня работает в обратную сторону — либо мне сильно не повезло.

Возвращаем температуру к значению 0.2 и опускаем top_K до 35 вместо 50

И танцевала балет!

Вожатая была одета в балетную пачку и танцевала, как заправская балерина.

Она кружилась, делала пируэты, прыгала, приседала и даже делала какие-то замысловатые па ногами.

При этом она напевала какую-то мелодию, а в конце танца даже сделала несколько балетных па в стиле «Лебединое озеро».

Я стоял, открыв рот, и не знал, что делать.

А Ольга Дмитриевна тем временем закончила свой танец и, подойдя ко мне, сказала:

Ольга: Ну что же ты стоишь?

Я: А что мне делать?

Она: Как что? Танцевать!

И тут я понял, что она не шутит.

И я начал танцевать.

Сначала я просто стоял, не зная, что делать, но потом, когда Ольга Дмитриевна начала напевать какую-то знакомую мелодию, я вспомнил, что когда-то давно, ещё в школе, мы с ребятами танцевали под эту песню.

Тогда я ещё не умел танцевать, но сейчас, когда Ольга начала петь, я вспомнил всё, чему меня

Нудно и душно, так ещё и переход к местоимениям вместо полных имён (привет разметка).

Я осознал свою ошибку: «Ольга Дмитриева: » токенизируется 5 токенами, и не проходит по минимальной длине уникального блока текста (равной 4), однако этот костыль необходим — что будет если его ослабить:

Результат удручает

И танцевала балет!

Мысли (в панике): Что за…?!

Ольга Дмитриевна (танцуя): А вот и ты!

Мысли(в панике): Что за?!

Ольга Дмитриева: А вот и я!

Мысли в панике: Что за?!

Мысли в ужасе: Что за?!
Мысли: Что за?!
Ольга Дмитриевна: Ну что ты стоишь?

Мысли

Что за!?

Результаты опыта

  1. — И самый важный: Ванильная модель противится сценарному стилю повествования.
    Она пытается генерировать литературный текст, где имена не повторяются, а прямая речь либо сведена к необходимому минимуму, либо просто полностью переходит в косвенную.

  2. На текущий момент модель точно нельзя заставить писать .rpy файл, для отправки его непосредственно в движок игры.

  3. Тут нельзя притвориться что всё хорошо, ну никак. Она периодически теряет нить повествования. Но эта проблема прямо и непосредственно связана с разметкой и стилем.

Поможет ли дообучение (fine-tuning)?

Да — поможет. Заставить модель генерировать другую разметку — не язык ей менять.
Для начала: полный файл текста игры, до момента с выбором рута в 4 дне находится ТУТ

Наш файл представляет из себя:
225к букв
36к слов
4.5к строк

Пишем вызов стандартного transformers.Trainer, и натравливаем этого зверька на нашу модель. Примера ради, запущу 3 эпохи обучения.

import torch
from transformers import TextDataset, DataCollatorForLanguageModeling

train_path="train_dataset.txt"
train_dataset = TextDataset(tokenizer=tokenizer,file_path=train_path,block_size=64)
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)

from transformers import Trainer, TrainingArguments

training_args = TrainingArguments(
    output_dir="./finetuned",
    overwrite_output_dir=True,
    num_train_epochs=3,
    per_device_train_batch_size=10,
    auto_find_batch_size=True,
    per_device_eval_batch_size=1,
    warmup_steps=10,
    gradient_accumulation_steps=10
    )

trainer = Trainer(
    model=model,
    args=training_args,
    data_collator=data_collator,
    train_dataset=train_dataset,
    optimizers = (torch.optim.AdamW(model.parameters(),lr=1e-5), None)
)
trainer.train()

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

RuntimeError: probability tensor contains either `inf`, `nan` or element < 0

Нужно сказать, что ситуация эта меня отправила в тупик. Над решением проблемы я бился всю ночь, и в итоге лёг спать в 9:00 с пустыми руками (или выводом модели, тут как вам угодно).

Отчаявшись я сам задал пару вопросов на форумах и получив ответ об отсутствии такой возможности успокоился.
Остаётся вариант покупки платной A100 в колабе за 900 деревянных в месяц, но даже её 40 гигов не хватит для полной загрузки нейросети.

Уходит красиво
Уходит красиво

Статья бы и осталась в таком виде, если бы спустя день мне не написал чел, сказав что никто не запрещал натренить LoRA, в случае с Quantized сетями - QLoRA, соответственно.
Я, как человек знакомый с Лорами только в контексте модов на чекпоинты Stable-Diffusion удивился, но был весьма заинтересован.

Сейчас начнётся подготовка, и поверьте, она заняла больше всего времени.
Нам понадобится репозиторий GptQlora, и немного настойчивости.

!pip install auto-gptq gradio
!git clone -b peft_integration https://github.com/PanQiWei/AutoGPTQ.git && cd AutoGPTQ
!pip install .[triton]
%cd ..
#!git clone https://github.com/timdettmers/bitsandbytes.git
!pip install bitsandbytes
!git clone https://github.com/qwopqwop200/gptqlora
%cd gptqlora
!pip install git+https://github.com/huggingface/transformers.git
!pip install git+https://github.com/huggingface/peft.git
!pip install git+https://github.com/huggingface/accelerate.git
!pip install -r requirements.txt
!pip install protobuf==3.20.*
%cd ..
!python -m bitsandbytes

Разворачиваем исключительно проверенные и стабильные зависимости.

Скачиваем нейросеть в текущую папку

!pip install huggingface_hub
from huggingface_hub import snapshot_download
snapshot_download(repo_id="gurgutan/ruGPT-13B-4bit",local_dir=r"./ruGPT-3.5")

P.S. Я скачал тут репозиторий gurgutan/ruGPT-13B-4bit, вместо fffrrt/ruGPT-3.5-13B-GPTQ. В нём кроме .safetensors есть простой .bin (gptqlora ищет конкретно .bin), а менять код библиотеки, я на данный момент не рассчитывал.

Ритуал с бубном

Пытаемся начать обучение с единичной выборкой и 100 эпох

!python /gptqlora/gptqlora.py –learning_rate 0.0001 --model_path /ruGPT-3.5 --max_train_samples 1 --max_steps 100

Кто-то нажал Ctrl+C и завершил процесс и это был не я - честно.
Идём смотреть на место завершения в файле библиотеки gptqlora.py

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

model = AutoGPTQForCausalLM.from_quantized(
        ...
        low_cpu_mem_usage=True, #Пониженное использование RAM
        ...

Всё запустилось! - жду летающие помидоры в комментариях)

Держу в курсе, дальше в gptqlora.py будет вноситься ещё больше изменений, так что я просто приложу готовый файл. Итоговый вид gptqlora.py

Пробуем запустить с датасетом по умолчанию - Alpaca.

Успех. Обучение завершилось за 2.5 минуты.

Видим, что создался некий .bin, размером 800мб.

Адаптируем старый код под LoR адаптер и проверим жизнеспособность такой задумки...

Новый код с поддержкой LoRA
import torch
from transformers import AutoTokenizer
import auto_gptq
from auto_gptq import AutoGPTQForCausalLM, get_gptq_peft_model
from auto_gptq.utils.peft_utils import GPTQLoraConfig
#import gradio as gr

model_name="fffrrt/ruGPT-3.5-13B-GPTQ"
model_basename="gptq_model-4bit-128g"

tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)
model = AutoGPTQForCausalLM.from_quantized("/ruGPT-3.5",
        low_cpu_mem_usage=True,
        device_map='auto',
        trust_remote_code=True,
        inject_fused_attention = True,
        inject_fused_mlp = False,
        use_triton=True,
        warmup_triton=False,
        trainable=True)
peft_config = GPTQLoraConfig(
    inference_mode=True,
)
model = get_gptq_peft_model(model, peft_config, '/output/checkpoint-100/adapter_model')

Я всё протестировал - лора работает. Вас грузить лишними примерами не буду. Они ещё ждут нас впереди с нашим датасетом.

Пилим поддержку кастомного датасета

К моему удивлению, GptQLora вообще не предусматривает возможность обучения на пользовательских данных. Только на десятке "поддерживаемых" наборов с HF. Такой расклад, само собой, нас не устраивает.

Меняем код библиотеки, добавляя свой аргумент выбора датасета "CUSTOM-BABY"
def make_data_module(tokenizer: transformers.PreTrainedTokenizer, args) -> Dict:
    """
    Make dataset and collator for supervised fine-tuning.
    Datasets are expected to have the following columns: { `input`, `output` }

    Available datasets to be selected with `dataset` argument:
        - alpaca, 52002 examples
        - alpaca cleaned, 51942 examples   
        - chip2 (OIG), 210289 examples
        - self-instruct, 82612 examples
        - hh-rlhf (Anthropic), 160800 examples
        - longform, 23.7k examples

    Coming soon:
        - unnatural instructions core, 66010 examples
        - unnatural instructions full, 240670 examples
        - alpaca-gpt4, 52002 examples
        - unnatural-instructions-gpt4, 9000 examples
        - oa-rlhf (OpenAssistant) primary message tree only, 9209 examples
        - oa-rlhf-assistant (OpenAssistant) all assistant  replies with ranking
        - supernatural-instructions, 69624 examples (same as paper with 100 ex/task more can be used)
        - flan (FLAN v2), up to 20M examples available

    Not Available:
        - vicuna, not released at the moment.
    """
    # Load dataset.
    # Alpaca
    print(args.dataset)

    if args.dataset == 'CUSTOM-BABY':
      from transformers import TextDataset
      with open("/gptqlora/data.txt","r",encoding="utf-8") as f:
        l=f.read()
      l=l.replace("\n","\n\n")
      import random

      def get_random_substring(input_string, length):
          start = random.randrange(0, len(input_string) - length + 1)
          return input_string[start : start + length]
      print(get_random_substring(l,500))
      l2=list(set([get_random_substring(l,200) for i in range(2000)]))
      print(len(l2),len(set(l2)))
      l = list(map(lambda x: {
                  'input': '',
                  'output': x
              },l2))
      print(l2)
      from datasets import Dataset
      dataset=Dataset.from_list(l)
      print(dataset)
      #train_dataset=LineByLineTextDataset(tokenizer=tokenizer,file_path="/gptqlora/datafromES.txt",block_size=64)
      #print(train_dataset.examples)
      #train_dataset.examples = list(map(lambda x: {
      #      'input': '',
      #      'output': x
      #  },train_dataset.examples))
    #elif args.dataset == 'alpaca':
    # Alpaca clean
    elif args.dataset == 'alpaca-clean':
        dataset = load_dataset("yahma/alpaca-cleaned")
        dataset = dataset.map(extract_alpaca_dataset, remove_columns=['instruction'])
    # Chip2
    elif args.dataset == 'chip2':
        dataset = load_dataset("laion/OIG", data_files="unified_chip2.jsonl")
        dataset = dataset.map(lambda x: {
            'input': x['text'].split('\n: ')[0].replace(': ', ''),
            'output': x['text'].split('\n: ')[1],
        }, remove_columns=['text', 'metadata'])
    ...
    ...
    ...

Функциональная часть - принцип разбиения .txt файла на нужные нам куски данных.
Пожалуй, формат входных данных это самый спорный и противоречивый момент в проделанной мной работе.
Модель Lora является Seq2Seq трансформером, следовательно принимает она словарь вида не:
{"ids":""}
а:
{"input":"",
"output":""}

    elif args.dataset == 'hh-rlhf':
        dataset = load_dataset("Anthropic/hh-rlhf")
        dataset = dataset.map(lambda x: {
            'input': '',
            'output': x['chosen']
        }, remove_columns=['chosen', 'rejected'])
        print(dataset)

Например при обучении на Anthropic/hh-rlhf в качестве input, используется пустышка, сделаем аналогично.

    if args.dataset == 'CUSTOM-BABY':
      from transformers import TextDataset
      with open("/gptqlora/data.txt","r",encoding="utf-8") as f:
        l=f.read()
      l=l.replace("\n","\n\n")
      import random

      def get_random_substring(input_string, length):
          start = random.randrange(0, len(input_string) - length + 1)
          return input_string[start : start + length]
      #print(get_random_substring(l,500))
      l2=list(set([get_random_substring(l,200) for i in range(2000)]))
      print(len(l2),len(set(l2)))
      l = list(map(lambda x: {
                  'input': '',
                  'output': x
              },l2))
      print(l2)
      from datasets import Dataset
      dataset=Dataset.from_list(l)

2000 раз берётся случайная подстрока из файла, длиной 200 символов, что, в среднем является 4мя строками.
Напоминаю что мы имеем 225к букв, 36к слов, 4.5к строк в файле!
Другими словами, в лучшем случае, каждая позиция файла будет участвовать в двух разных контекстах. На практике, могут получиться коллизии и излишне схожие куски - однако, как оказалось, есть нечто ещё более опасное.

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

Результат обучения

Однако, не смотря на всю комичность происходящего, у мнннн н нес

н н нес т нес несет несет нес несет нес несет несет нес несет несу

нес нес нес несу несет несу несу несу несу несу несу несу нес несу несу несу нес нес несу нес нес нес нес несу несу нес несу нес нес несу

Несу.

н и н по по по

н е п н б Ольга Дми еж

Тогда я решил разбивать не по буквам, а на строки. Берём 2000 раз по 4-6 случайных, последовательных строк.

l2=list(set(["\n".join(get_random_substring(l,random.randint(4,6))) for i in range(2000)]))

Буквально во время написания этой статьи и пересоздании colab блокнота, у меня все перестало работать! Как оказалось, библиотека PEFT обновилась и стала ломаться на ровном месте https://github.com/huggingface/peft/issues/156.

Так что в срочном порядке выполняем откат.

!pip uninstall peft
!pip install peft==0.4.0
Выглядит правдоподобно. Оставляем всё как есть на полтора часа.
Выглядит правдоподобно. Оставляем всё как есть на полтора часа.

При использовании на старом длинном промпте с недосказанностью:

Мда, вообще мимо

Но, кажется, это было не лучшим решением – посреди комнаты стояла Ольга Дмитриевна…

И танцевала вальс!

Ольга Дмитриева: Ай, молодца! Ай, как хорошо вальсируешь!

Ольга Дмитриеевна: Давай ещё разок

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

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

Во что выросли первые 2 строки

Семён: Посмотри, он совсем не страшный.

Лена выглянула у меня из-за спины.

Лена: Да, наверное…

Она сделала небольшую паузу.

Лена (задумчиво): Но всё равно как-то не по себе.

Семён: Да брось ты! Самое обычное привидение!

Лена: Ну, если ты так считаешь…

Она сделала обиженное выражение лица.

Семён (улыбнулся): Да, я так считаю!

Лена, похоже, немного успокоилась.

Лена, кажется, совсем забыла о том, что я её сюда позвал, и просто смотрела на огонь.

Модель достроила тематический диалог из игры по первым двум строкам - это неплохой результат.

Генерация по первым 3 строкам

Славя: Физкульт-привет!

Семён: Охай… То есть, бобр… Доброе утро! Вот…

Приветствие мне удалось выбрать не сразу.

Славя: Вижу, ты уже встал.

Славя улыбнулась.

Славя, пионерская форма которой уже успела прийти в полную негодность, выглядела как обычно – красивая и милая девочка, которая так и притягивает взгляд.

Славя (продолжая улыбаться): А вот и нет! Я уже часа два как тут сижу.

Семён: Ясно…

Славя: А ты, наверное, спал ещё?

Семён: Да, немного.

Славя подошла ближе.

Славя взяла мою тарелку и поставила на стол.

Немного криво, но вполне уместно. Модель стала генерировать хуже, однако привыкла к разметке. Она даже сама ввела в контекст пионерскую форму и научилась пользоваться именем "Славя" - что уже является успехом. Хотя, разметка все-же разрушается, если контекст слишком длинный.

Получилось?

Получилось, даже играбельно. Да, небольшая длина контекста из обучающей выборки сделала модель слабее. Я даже скажу, что получилось хуже чем было изначально, ведь длина осмысленного контекста длиной 5 строк для story-tell модели это критический недостаток. НО, она, что явно видно в последнем примере, уловила сеттинг и стиль повествования игры! Интересно, что после обучения на нашем практически безобидном тексте, модель стало часто уносить в NSFW - таков нарратив всей игры, в этом она не прогадала. Можно было бы взять контекст для обучения больше, и тренировать 6+ часов вместо 1:30, но free colab произвольно завершает GPU сессии, так что лучше поберечь нервы. PoC есть, до ума доведёт кто-то другой.

Однако, полученный результат далёк от необходимого для генерации .rpy сценариев. Не подумайте, я изначально не сильно верил в натягивание совы на глобус - модель даже путается в именах собственных, формат текста тут точно является не главной проблемой. Пока я вижу максимальную перспективу только на уровне AI dungeon / Novel AI.

Модель эта, меня, отнюдь не разочаровала. Как я уже говорил, модель может в NSFW и это, по сути, является её важнейшим плюсом, особенно на фоне того, что мы имели ранее. Ведь RuGPT3 в нём была безнадёжна, а chatgpt вручную ограничен (что, конечно, нас никогда не останавливало, верно?).
Аналогов на русском языке просто нет, это заставляет смириться с недостатками, и верить в дальнейшую разработку открытых NLP.

Если ты осилил эту скатерть и ещё не крючишься в приступе тошноты, то это уже небольшая победа для меня - спасибо.

Весь код, исходники и куча мусора лежат в этом colab блокноте. Собственно, на этом у меня всё, здравая критика в комментариях приветствуется.


 

Источник

Leto, RuGPT3.5, бесконечное, на, настоящее, нейросетью, новеллы, создание, ходу

Читайте также