Как учится нейросеть, объяснённое кодом 1С: потеря, градиент и шаг на модели, обученной прямо на сервере
Иван Недомолков · Chop etilgan:
Слова “модель обучилась” обычно ничего не объясняют: где-то крутится видеокарта, на выходе файл весов. Мы сделали по-другому и обучили маленькую языковую модель на встроенном языке 1С, на сервере, без компонент и видеокарты. Теперь каждое слово из учебника можно показать процедурой модуля, которую открываешь в Конфигураторе и проходишь отладчиком.
Модель та же, что в разборе пяти граблей с нейросетью: 21 920 чисел, два слоя, алфавит из 36 символов, сорок пар вопрос-ответ про объекты 1С. Ночной прогон 11 октября: старт в 22:00, конец в 00:58, 800 шагов, 2,96 часа чистого счёта. После него модель отвечает на 38 вопросов из 40 слово в слово.
Ниже сначала словарь, термин за термином, с кодом и числом из прогона. Потом то, как три часа получились из 51 суток, как проверить, что производные посчитаны верно, и что модель сделала на втором корпусе.
Словарь обучения на коде 1С
| Термин | Что это в 1С | Где в модуле объекта |
|---|---|---|
| вес | одно число в Массив; вся модель это 21 920 чисел в 20 таблицах | Обучение.Т |
| пачка | сколько примеров берётся в один шаг, у нас две пары | Обучение.Расписание |
| эпоха | все сорок пар по разу; 800 шагов по две пары это 40 эпох | Обучение.Расписание |
| потеря | насколько модель удивилась правильному символу | ПотеряПачки |
| градиент | куда и насколько сдвигать каждый вес | ГрадиентыПачки |
| обратный проход | градиенты от выхода модели назад к входу | НазадЛинейный |
| шаг | пачка, потеря, градиенты, сдвиг весов | ШагОбучения, ШагAdamW |
| скорость обучения | главная ручка: насколько широко шагать | СкоростьНаШаге |
Дальше по строкам этой таблицы.
Шаг обучения целиком
// Один шаг обучения: пачка пар по расписанию, градиенты, AdamW.
Функция ШагОбучения(Корпус)
Номера = Обучение.Расписание[Обучение.Шаг];
Пачка = Новый Массив;
Для Каждого Номер Из Номера Цикл
Пачка.Добавить(Корпус[Номер]);
КонецЦикла;
Итог = ГрадиентыПачки(Пачка);
ШагAdamW(Итог.Г);
Возврат Итог.Потеря;
КонецФункции
Всё обучение это эта функция, вызванная 800 раз. Таблица весов в коде это Массив строк, где каждая строка тоже Массив чисел. Обучение не добавляет и не удаляет ни одной клетки, оно меняет только значения.
Потеря
На каждой позиции пары модель выдаёт оценку каждому из 36 символов: какой, по её мнению, следующий. Какой следующий на самом деле, мы знаем. Ошибка на позиции считается одной строкой:
Потеря = Потеря + Ф.ЛСЕ[Поз] - Ф.Логиты[Поз][Посл[Поз + 1]];
Логиты здесь сырые оценки по символам, ЛСЕ логарифм суммы их экспонент. Разность равна минус логарифму вероятности, которую модель отдала правильному символу. Отдала 100 %, ошибка ноль. Отдала 1 %, ошибка 4,6. Средняя по всем символам пачки и есть потеря.
Опорная точка для чисел: если угадывать символ наугад из 36, потеря будет ln 36, то есть 3,58. Наша модель на нулевом шаге показала 3,745, хуже случайного угадывания: случайные стартовые веса уверенно ставят не на те символы. На 800-м шаге потеря пачки 0,085. Это значит, что правильному символу модель обычно отдаёт около 92 %.
Градиент и обратный проход
Градиент веса отвечает на вопрос: вырастет потеря или упадёт, если этот вес чуть-чуть увеличить, и насколько. Нужен он для каждого из 21 920 чисел, поэтому ГрадиентыПачки возвращает Г той же формы, что и веса.
Начинается расчёт на выходе модели. Для оценок символов градиент выглядит так:
// градиент потери по логитам: вероятность минус единица на правильном символе
Вероятности = Ф.Вероятности[Поз];
ДЛог = Новый Массив(Вероятности.Количество());
Для Ном = 0 По Вероятности.ВГраница() Цикл
ДЛог[Ном] = Окр(Вероятности[Ном] / Всего, Знаков);
КонецЦикла;
ДЛог[Цель] = Окр((Вероятности[Цель] - 1) / Всего, Знаков);
Читается это так. Неправильный символ, на который модель поставила 0,3, получает толчок вниз размером 0,3. Правильный получает толчок вверх на то, чего ему не хватило до единицы. Чем увереннее модель ошиблась, тем сильнее поправка.
Дальше градиент идёт через все слои от выхода к входу. Почти каждый слой модели это умножение таблицы на вектор, и правило для него одно на всю модель:
// Обратный ход через y = W x. Пришёл градиент Д по выходу y:
// градиент веса W[i][j] копится как Д[i] * x[j],
// градиент входа x[j] копится как сумма W[i][j] * Д[i].
Процедура НазадЛинейный(Тензор, Град, Д, Вход, ДВход)
Колонок = Вход.Количество();
Для Ном = 0 По Тензор.ВГраница() Цикл
Дн = Д[Ном];
Если Дн = 0 Тогда
Продолжить;
КонецЕсли;
Ряд = Тензор[Ном];
ГРяд = Град[Ном];
Для Кол = 0 По Колонок - 1 Цикл ДВход[Кол] = ДВход[Кол] + Дн * Ряд[Кол]; ГРяд[Кол] = ГРяд[Кол] + Дн * Вход[Кол]; КонецЦикла;
КонецЦикла;
КонецПроцедуры
Вес, который умножался на большое входное число, получает большую поправку: он сильнее влиял на результат. Градиент входа уходит в предыдущий слой, и там всё повторяется. Если Дн = 0 здесь про экономию, языка оно не касается: после слоя ReLU, который обнуляет отрицательные числа, примерно половина градиентов нулевая, и такие строки можно не считать. Весь блок обучения вместе с нормализацией и вниманием занял шестьсот с небольшим строк модуля.
Шаг AdamW
Самый простой сдвиг веса: вычесть градиент, умноженный на маленькое число. На практике градиенты разных весов отличаются в тысячи раз, и при таком сдвиге одни веса прыгают, а другие стоят. Поэтому берут AdamW:
Градиент = ГР[Кол];
МР[Кол] = Окр(Б1 * МР[Кол] + (1 - Б1) * Градиент, ЗнМомента);
ВР[Кол] = Окр(Б2 * ВР[Кол] + (1 - Б2) * Градиент * Градиент, ЗнМомента);
Ряд[Кол] = Окр(Ряд[Кол] * Затухание - РазмерШага * МР[Кол] / (Sqrt(ВР[Кол]) / Поправка2 + Эпсилон), ЗнВеса);
Для каждого веса хранятся два скользящих средних: самого градиента (МР) и его квадрата (ВР). Сдвиг это первое, делённое на корень из второго. Получается, что каждый вес мерится собственным масштабом. Если градиент держится в одну сторону, вес сдвигается заметно, крупный он или мелкий. Если градиент скачет туда-сюда, среднее гасится, и вес почти не двигается. Затухание на каждом шаге чуть поджимает все веса к нулю. На все 21 920 весов этот блок тратит около четверти секунды из 13 секунд шага.
Скорость обучения
Функция СкоростьНаШаге(ШагНомер)
Пи = 3.14159265358979323846264338328;
Возврат Обучение.СкоростьОбучения * (1 + Cos(Пи * ШагНомер / Обучение.ВсегоШагов)) / 2;
КонецФункции
Начинаем с 0,01 и по косинусу сходим к нулю на последнем шаге: сначала крупные шаги, к концу мелкая доводка. Эта ручка оказалась самой чувствительной из всех:
| Скорость обучения | Что вышло |
|---|---|
| 0,003, 800 шагов | 0 верных из 40 |
| 0,003, 1 600 шагов | 39 из 40 |
| 0,01, 800 шагов | 39 из 40, рабочий режим |
| 0,03 | ещё 39 из 40 |
| 0,1 | 0 верных, потеря застревает на 2,5-2,9 |
При 0,1 модель выучивает только частые буквы и на вопрос про справочник пишет “ие иениениенаие иение иение ние ие ие”. В 1С первые пять потерь при такой скорости скачут: 3,745, 3,270, 3,578, 3,338, 3,627. В рабочем режиме они идут вниз ровно: 3,745, 3,509, 3,314, 3,262, 3,109. Шаг слишком широкий, и модель каждый раз проскакивает яму, в которую должна скатиться.
Откуда три часа вместо 51 суток
В августе мы посчитали обучение этой модели в 1С по классическому рецепту и получили 51 сутки. Точнее, 69: формула не учитывала внимание, а это ещё 35 % умножений. Рецепт был такой: пачка из 32 кусков текста по 96 символов, двенадцать тысяч шагов, 438 тысяч умножений в секунду.
Скорость встроенного языка тут почти ни при чём. Изменилась сама работа:
| Что поменяли | Во сколько раз меньше умножений |
|---|---|
| пачка из 2 пар вместо 32 кусков | 16 |
| 800 шагов вместо 12 000 | 15 |
| короткий пример, внимание дешевле | 1,46 |
| вместе | 349 |
Это 7,5 миллиарда умножений против 2,6 триллиона. Чтобы так урезать работу и не потерять качество, понадобились две правки.
Нарезка примеров. Раньше сорок пар склеивались в одну строку, и кусок по 96 символов вырезался с любого места. Вопрос почти всегда оказывался в середине куска, на позиции 30-90. А отвечать модели приходится с нулевой позиции: функция Ответить начинает с вопроса. Модель тратила шаги на продолжение текста с середины. Новая нарезка: один пример это одна пара целиком, с начала, в среднем около 72 символов.
Скорость обучения 0,01 вместо 0,003. Новая нарезка учит и на старой скорости, но за 1 600 шагов. Старую нарезку на новой скорости мы не прогоняли, поэтому вклад каждой правки отдельно неизвестен.
До этого был перебор в лоб: 27 вариантов размеров в Python, мерили одним числом, сколько из 40 вопросов модель отвечает дословно. В десятичасовую ночь влезали пять, и ни один ничего не выучил. Куски по 48 символов к тому же короче любой из пар.
Сама 1С при обучении считала быстрее, чем на ответе модели, где снимали августовский замер: 681 тысяча умножений в секунду на первых шагах и 706 тысяч в среднем за ночь. Отсюда прогноз 3,1 часа, а вышло 2,96.
Как это гоняется на сервере
Держать один вызов сервера три часа плохая идея: оборвался, и всё сначала. Поэтому обучение режется на порции. Снаружи работает маленький скрипт на Node.js, который через HTTP-сервис базы вызывает экспортную функцию:
Функция ОбучитьПорцию(Каталог, МаксШагов = 1, МаксСекунд = 180) Экспорт
Путь = Каталог + "\sostoyanie.dat";
ФайлСостояния = Новый Файл(Путь);
Если ФайлСостояния.Существует() Тогда
Обучение = ЗначениеИзФайла(Путь);
Иначе
Обучение = НовоеОбучение(Каталог);
КонецЕсли;
Корпус = ПрочитатьJSONФайл(Каталог + "\korpus.json").pary;
Начало = ТекущаяУниверсальнаяДатаВМиллисекундах();
Сделано = 0;
Пока Сделано < МаксШагов И Обучение.Шаг < Обучение.ВсегоШагов Цикл
Потеря = ШагОбучения(Корпус);
Сделано = Сделано + 1;
Если ТекущаяУниверсальнаяДатаВМиллисекундах() - Начало > МаксСекунд * 1000 Тогда
Прервать;
КонецЕсли;
КонецЦикла;
СохранитьОбучение(Каталог);
// ... журнал шагов и ответ скрипту в JSON
КонецФункции
Листинг сокращён до сути. За вызов идёт до восьми шагов и не дольше 150 секунд, на практике около 105 секунд. Всё состояние живёт в файле: веса, оба средних AdamW, номер шага, расписание пар. Файл на 2,5 МБ пишется одним ЗначениеВФайл из структуры, на это уходит 22 мс. Оборвался вызов, и следующий продолжит с последнего сохранённого шага.
Один шаг ночью шёл 10-19 секунд, средний 13,3 секунды. Мерить надо на свободной машине: пока рядом работал перебор в Python, тот же шаг 1С шёл 25,8 секунды вместо 10,7.
Две особенности встроенного языка, о которые спотыкаешься сразу:
- Числа 1С растут в длину. Число десятичное и точное: два множителя по 20 знаков после запятой дают 40 знаков. Сумма в цикле у нас накопила 72 знака, а среднее квадратов в AdamW прибавляло по три знака на каждом шаге без конца. Длинные числа считаются примерно в 1,6 раза медленнее, поэтому в коде везде
Окр: 20 знаков для векторов, 28 для средних AdamW. Число("1.5e-3")экспоненту не понимает, аЧтениеJSONпонимает. Стартовые веса из PyTorch записаны с экспонентой, поэтому в 1С они приезжают файлом JSON.
Как проверить, что производные верны
Ошибка в обратном проходе коварная. Перепутал знак или индекс, и модель всё равно учится, только хуже, и заметно это через три часа или никогда. Проверка, которая обходится без чужих библиотек, называется конечными разностями: сдвигаем один вес чуть вверх, считаем потерю, сдвигаем чуть вниз, считаем снова. Разность потерь, делённая на двойной сдвиг, это градиент, посчитанный в лоб. Он должен совпасть с тем, что дал обратный проход:
Для Каждого Точка Из Точки Цикл
Ряд = Обучение.Т[Точка[0]][Точка[1]];
Было = Ряд[Точка[2]];
Ряд[Точка[2]] = Было + Сдвиг;
Плюс = ПотеряПачки(Пачка);
Ряд[Точка[2]] = Было - Сдвиг;
Минус = ПотеряПачки(Пачка);
Ряд[Точка[2]] = Было;
Численный = (Плюс - Минус) / (2 * Сдвиг);
Аналитический = Итог.Г[Точка[0]][Точка[1]][Точка[2]];
// сравниваем Численный и Аналитический
КонецЦикла;
Мы проверили так 40 весов, по два на таблицу, со сдвигом 0,00001. У 38 из них градиент больше 0,0001, и расхождение не хуже 1,9e-7 в относительных долях. Худшее, 1,3e-6, у веса с крошечным градиентом 1,2e-5, где уже сказывается погрешность самой формулы. Приём годится для любого своего расчёта, где есть производная: от модели до формулы скидки.
Вторая проверка была против эталона: те же стартовые веса и та же пачка в 1С и в PyTorch с двойной точностью. Потеря совпала в 16 знаках, 3,745062506464834, худшая из 20 таблиц градиентов разошлась на 6,4e-11.
Кто посчитал точнее, 1С или PyTorch
После ночи модель 1С ответила верно на 38 вопросов, модель PyTorch в обычном режиме на 39. Ответы двух моделей совпали дословно на 39 вопросах из 40, а на “где хранятся права” 1С выдала кашу, PyTorch правильный ответ.
Округление 1С тут ни при чём. Мы обучили ту же модель в PyTorch дважды: в обычной одинарной точности, около 7 значащих цифр, и в двойной, около 16:
| Что с чем | Наибольшая разница потерь за 800 шагов | Итоговые веса, разница к крупнейшему весу таблицы |
|---|---|---|
| 1С и PyTorch, двойная точность | 7,8e-13 | 1,4e-12 |
| 1С и PyTorch, одинарная | 0,176 | 61 % |
1С прошла все 800 шагов вместе с двойной точностью, и модель двойной точности отвечает про права той же кашей. Одинарная начала расходиться с первого шага, к 236-му разница потерь дошла до 0,1, и на вопросе про права ей повезло. Это один старт, так что везение или закономерность, сказать нельзя. Зато видно другое: одинарная точность не воспроизводит сама себя. При шести потоках процессора на шаге 200 потеря 1,0128, при двух 1,0165, потому что меняется порядок сложения. Повтор прогона в 1С совпал с ночным на всех проверенных 400 шагах.
Второй корпус: заголовки наших статей
Сорок пар про объекты 1С писались руками. Для второго опыта корпус собрали из 38 наших статей: заголовок раздела как вопрос, первое предложение под ним как ответ, без кода и таблиц. Всего получилось 253 пары и алфавит из 49 символов. Подбор в Python при тех же 800 шагах показал, где кончается память такой модели:
| Пар | Эпох за 800 шагов | Верно дословно |
|---|---|---|
| 40 | 40 | 39 из 40 |
| 80 | 20 | 0 |
| 253 | 6,3 | 0 |
На 80 и 253 парах модель недоучилась: 20 и 6 эпох вместо 40. Дать каждой фразе те же 40 эпох стоит около шести минут счёта 1С на фразу.
Вариант на 40 парах потом прогнали уже в 1С, ночью 12 октября. Восемьсот шагов заняли 3,2 часа счёта при прогнозе 4,0, потеря на последнем шаге 0,094. На свои заголовки модель ответила дословно в 39 случаях из 40. На “сразу про границу, это не код на встроенном языке” она пишет “честно, чтобы дальше не было обид.”, как в статье. Единственная ошибка тоже показательна: вместо “началось обычно, реализация не создается, блокировка.” вышло “началось обычнокосеализация не создается, блокировка.”, то есть фраза узнана, а в середине модель сбилась на несколько символов.
На пять заголовков, которых в корпусе не было, ответы бессмысленные. “Почему обработка работает медленно” получает “-всь?”, “зачем нужен регламент” получает “прересно: встем - внтчнаци”. Модель на 22 тысячи параметров запоминает фразы и не обобщает, и никакое обучение в 1С этого не меняет.
Попробовать самому
Всё описанное лежит в бесплатной обработке Локальная LLM на встроенном языке 1С, версия 2.0. Обучение там вынесено на закладку формы:
- Откройте обработку через “Файл - Открыть”, подключать ничего не надо.
- На закладке “Обучение” нажмите “Обучить” с 20 шагами по умолчанию. Это около четырёх минут, окно в это время не отвечает: шаг считается на сервере около 12 секунд.
- Смотрите на кривую: потеря падает с 3,75 до 2,66. Модель выучила частые буквы, слов ещё нет. Слова появляются к 200-му шагу, полные ответы к 800-му.
- “Спросить обученную” задаёт вопрос модели прямо по ходу обучения. Можно учить на своих парах “вопрос? ответ.” и сохранить веса в файл около 25 КБ.
Первые шаги в форме дают ровно те потери, что записаны в журнале ночного прогона: стартовые веса и порядок пар те же.
Один из читателей переписал счёт модели на JavaScript внутри поля HTML-документа и прислал замер: на тот же вопрос про регистр накопления его компьютер ответил на BSL за 7,2 секунды, на JavaScript за 0,024. Разница в 300 раз, и она показывает честный потолок: на встроенном языке механику понять можно, обучать полезные модели нельзя.
Рабочая задача, вроде разбора обращений или поиска похожей номенклатуры, решается иначе: готовой моделью, например ru-e5-small через ONNX Runtime во внешней компоненте. Это в сотни раз быстрее, но уже не чистый встроенный язык. А если нужно, чтобы руководитель задавал вопросы к данным 1С обычными словами и получал проверяемый расчёт, это отдельная работа, она описана в услуге аналитика данных с нейросетями. А почему модель вообще уверенно отвечает на то, чего не знает, разобрано на той же модели в статье про внимание и зацикливание.