Статья о том как сделать свою ИИ модель на своих диалогах меньше чем за 10 минут, и экспортировать в gguf для запуска в LM Studio, Ollama, llama.cpp.
Python не нужен. Мощная видеокарта NVIDIA не нужна, достаточно встройки.
В конце статьи ссылка на github с готовой программой и gguf моделью 442 КБ.
Нигде не видел что кто-то обучает модели на OpenCL, обычно это делают с помощью CUDA. Вот мы и попробуем использовать для этих целей давно позабытую технологию OpenCL.
Не переключайтесь...
У меня нет мощной видеокарты от NVidia. Соответственно нет CUDA мощи, принятой в качестве стандарта в машинном обучении (ML - Machine Learning). Но есть желание сделать что то похожее на нейронку.
На просторах интернета был найден пост о реализации простой llm на Rust. Сам проект находится на github: tekaratzas/RustGPT.
Т.к. моих знаний и опыта не хватит для самостоятельного разбора исходного кода на Rust, и переписывать на c# логику - за пределами моих возможностей, в помощь были привлечены все доступные бесплатные LLM (Qwen CLI, Gemini CLI, Kilo-code, Perplexy, и даже ИИ режим гугла в браузере).
Теория
В статье Алиса, подвинься, я уже писал немного теории в разделе "LLM, БЯМ, Нейронка, Чат-гпт. Краткий понятийный курс":
Фактически нейронка — это массив чисел загруженный в оперативную память, которому подают на вход какую-либо информацию (текст, фото, звук), и эта информация, проходя через массив, выдает определенный результат, тоже в виде массива чисел.
Что собственно необходимо сделать что бы обучить llm? И что значит "обучить"?
Представим что модель - это конвейер, через который проходит и видоизменяется информация.
Токенизация. Так как на вход нейронки нельзя просто передать слова (а так же изображение, звук), ей нужно передать массив чисел, которые мы "закрепим" за определенной единицей информации. Проще говоря: за словом "привет" можно застолбить число 1375, за пробелом число 333, а за словом "мир" - 777. Многократно передав эти числа (токены) в нейронку - мы обучим её так, что бы она могла запомнить эту последовательность: 1375, 333, 777 (то есть словосочетание "привет мир"). Так вот, токенизация - это разбитие информации на части. Можно разбить предложения по словам ("привет"), можно по символам ("п|р|и|в|е|т"), а можно выбрать золотую середину ("прив|ет|").
Кроме слов, в предложениях существуют и знаки препинания, управляющие "потоком мысли" предложения. Они тоже - отдельные токены.Embedding слой имеет связь между токеном его числовым описанием (вектором) хранящемся в весах. Изначально до обучения, в весах - полный хаос.
-
TransformerBlock - это блок, который при получении последовательности векторов, преобразует эти последовательности в связи, и передает их следующему блоку (так же в виде последовательности векторов). Фактически он получает вектора, делает их "умнее", и передает дальше.
Упрощенно он состоит из следующих частей:-
Self-attention - механизм "внимания" связывающий слова (т.е. векторы) между собой, через четыре Linear слоя:
Query ("запрос") - веса, которые формулируют вопрос от текущего слова к остальным
Key ("ключ") - веса, которые описывают характеристики слова для других
Value ("значение") - сама суть, содержательная информация слова
Output ("проекция") - веса, которые собирают результаты всех "голов" внимания (если их несколько) обратно в один вектор.
Query и Key решают, кто с кем связан.
Value дает ответ на вопрос что именно передать по этой связи.
Output упаковывает результат для передачи дальше.
FeedForward - небольшая "сеть", концентрирующая информацию полученную от Self-attention, для следующего слоя. Здесь хранятся "знания" и "факты". Состоит из двух Linear слоев.
Layer normalization - "выравнивание" чисел после обучения. Содержит небольшие веса (обычно gamma и beta) помогающие модели удерживать числа в рабочем диапазоне.
Residual connection - делает "поправку" с тем что было на входе и с тем что выходит из предыдущих частей: складывает числа на входе и на выходе, что бы не потерять исходную информацию.
-
Output projection - последний Linear слой, переводящий внутреннее состояние модели в вероятность следующего слова. Содержит веса решающие, какое именно слово из 50 000 вариантов станет следующим.
То есть главная задача данного конвейера (нейронки/модели) - предсказать какое слово будет дальше, если ему на вход подать начальное слово. Да, этакий T9, но имеющий контекст и понимающий смысл текста.
Как происходит обучение:
Pre-processing: преобразуем подготовленные данные (dataset) в токены.
Разделение обучающих данных на "вход" и "выход" для обучения предсказанию. Например фраза "Мама мыла раму" (токены: 111, 555, 777). Вход: "Мама мыла" (токены: 111, 555), выход: "мыла раму" (токены: 555, 777).
Forward Pass: передача конвейеру "вход" ("Мама мыла", токены: 111, 555). Данные бегут через все слои (Embedding -> Attention -> FeedForward -> Layer normalization -> Residual connection). На выходе получаем Logits - сырые оценки вероятности для каждого слова из словаря.
-
Оценка ошибки Loss: сравнивается вероятность Logits с "выходом", и чем меньше вероятность с "выходом" тем больше число loss. На основе loss мы вычисляем градиент (Backpropagation) - степень виновности каждого веса в ошибке.
Последний слой (FeedForward) спрашивает: "На сколько мои веса w2 виноваты в этой ошибке?". Ответ записывается в градиент для w2.
Затем этот слой передает сигнал ошибки назад предыдущему слою (SelfAttention). SelfAttention делает то же самое: вычисляет, как его матрицы wQ, wK, wV повлияли на итоговый провал.
ClipGradients: если градиент ("виновность") слишком велик, модель может "психануть" и слишком сильно изменить веса, всё испортив. Поэтому прежде чем обновить веса, мы делаем "обрезку".
-
Optimizer обновляет веса во всех слоях (в Embeddings, SelfAttention, и в FeedForward) на основе градиента:
Если градиент положительный: Значит, увеличение веса увеличивает ошибку. Мы вычитаем его, чтобы уменьшить вес.
Если градиент отрицательный: Значит, увеличение веса уменьшит ошибку. Минус на минус дает плюс — мы увеличиваем вес.
-
Если градиент равен 0: Мы в идеальной точке (или в тупике), менять ничего не нужно.
У оптимизатора есть некий рычаг управления Learning Rate (LR) - коэффициент скорости обучения. Если градиент показывает направление (увеличивать или уменьшать вес), то LR - силу с которой надо это делать. Высокий LR - модель делает большие шаги в обучении, но может проскочить идеальную "обученность" и Loss будет прыгать, так и не обучив модель до конца. Низкий LR - модель делает мелкие шаги в обучении, медленно учится, и может застрять на определенном уровне, так как ей не хватит преодолеть "локальный минимум" Loss.
Первая версия
Бесплатный ИИ сделал перенос кода на c# сразу с использованием библиотеки MathNet.
Данные для обучения:
pretrain.json
[ "The sun rises in the east and sets in the west </s>", "Water flows downhill due to gravity </s>", "Birds fly through the air using their wings </s>", "Fish swim in rivers, lakes, and oceans </s>", "Trees grow tall and produce leaves </s>", "Rain falls from clouds in the sky </s>", "Fire is hot and produces light </s>", "Ice is frozen water that melts when heated </s>", "Mountains are tall and rocky formations </s>", "The moon orbits around planet Earth </s>", "Flowers bloom in spring and summer </s>", "Snow is cold and white </s>", "Wind moves air from place to place </s>", "Rivers flow into larger bodies of water </s>", "Sand is found on beaches and in deserts </s>", "Grass grows in fields and yards </s>", "Rocks are hard and can be different colors </s>", "Stars shine bright in the night sky </s>", "Waves move across the surface of water </s>", "Clouds form when water vapor rises </s>", "Lightning is bright and makes thunder </s>", "Storms bring rain and strong winds </s>", "Seasons change throughout the year </s>", "Animals eat food to survive </s>", "Plants need sunlight and water to grow </s>" ]
tune.json
[ "User: What causes rain? Assistant: Rain is caused by water vapor in clouds condensing into droplets that become too heavy to remain airborne </s>", "User: How do mountains form? Assistant: Mountains are formed through tectonic forces or volcanism over long geological time periods </s>", "User: What is the Amazon rainforest? Assistant: The Amazon rainforest is one of the most biodiverse places on earth, home to countless species </s>", "User: At what temperature does water boil? Assistant: Water boils at 100 degrees celsius at standard atmospheric pressure </s>", "User: How long does it take the moon to orbit Earth? Assistant: The moon orbits the earth approximately every 27.3 days </s>", "User: What is photosynthesis? Assistant: Photosynthesis is the process by which green plants use sunlight to synthesize food from carbon dioxide </s>", "User: How does gravity work? Assistant: Gravity is a force that attracts two bodies toward each other based on their mass </s>", "User: How many neurons are in the human brain? Assistant: The human brain contains about 86 billion neurons that transmit information throughout the body </s>", "User: What is electricity? Assistant: Electricity is the flow of electrons through a conductor, often used to power devices </s>", "User: What is climate change? Assistant: Climate change refers to long-term shifts in temperatures and weather patterns on Earth </s>", "User: How long do oak trees live? Assistant: Oak trees can live for hundreds of years and produce acorns as their fruit </s>", "User: What happened to Pluto? Assistant: Pluto was reclassified from a planet to a dwarf planet in 2006 by astronomers </s>", "User: How is glass made? Assistant: Glass is made by heating sand, soda ash, and limestone to very high temperatures until they melt </s>" ]
Pretrain - общие сведения о мире: "солнышко блестит, дождик капает". Tune - псевдо обучение диалогу. Но на такой маленькой модели, после дообучения (Tune), "знания" натренированные с помощью Pretrain - "перезатираются".
Program.cs
//данные для обучения var pretrain = JsonConvert.DeserializeObject<List<string>>(File.ReadAllText("pretrain.json")); var tune = JsonConvert.DeserializeObject<List<string>>(File.ReadAllText("tune.json")); //разбиваем на токены var vocabSet = new HashSet<string>(); Vocab.ProcessTextForVocab(pretrain, vocabSet); Vocab.ProcessTextForVocab(tune, vocabSet); var vocabWords = vocabSet.ToList(); vocabWords.Sort(); var vocab = new Vocab(vocabWords); //вычисляем оптимальную максимальную длину последовательности на основе данных //maxSeqLen - это фактически размер контекста в токенах var allTexts = pretrain.Concat(tune); var maxSeqLen = DatasetLoader.CalculateOptimalMaxSeqLen(allTexts, vocab); Console.WriteLine($"Вычислена оптимальная MAX_SEQ_LEN: {maxSeqLen}"); //первый Embedding слой var embeddings = new Embeddings(vocab, maxSeqLen); //размер матрицы var embeddingDim = 128; //размер матрицы внутреннего слоя в Feed-forward var hiddenDim = 256; //трансформеры var transformerBlock1 = new TransformerBlock(embeddingDim, hiddenDim); var transformerBlock2 = new TransformerBlock(embeddingDim, hiddenDim); var transformerBlock3 = new TransformerBlock(embeddingDim, hiddenDim); //выходной слой var outputProjection = new OutputProjection(embeddingDim, vocab.Words.Count); //создаем "нейронку" - конвейер из блоков var llm = new LLM(vocab, maxSeqLen, new List<ILayer> { embeddings, transformerBlock1, transformerBlock2, transformerBlock3, outputProjection }); //обучаем общим знаниям 10 эпох Console.WriteLine("=== PRE-TRAINING MODEL ==="); llm.Train(pretrain, 10, 0.0005f); //инференс //исходное предложение: "Flowers bloom in spring and summer" //пишем первые слова из предложения: //"Flowers bloom" //модель должна продолжить текст //"in spring and summer </s>" var text1 = "Flowers bloom"; var result1 = llm.PredictWithConfidence(text1); Console.WriteLine($"Text: {text1}"); Console.WriteLine($"LLM: {result1.Text}"); //обучаем псевдодиалогу 10 эпох Console.WriteLine("=== INSTRUCTION TUNING ==="); llm.Train(tune, 10, 0.0001f); //инференс //проверяем //модель должна продолжить текст //Assistant : The human brain contains about 86 billion neurons that transmit information throughout the body </s> var text2 = "User: How many neurons are in the human brain?"; var result2 = llm.PredictWithConfidence(text2); Console.WriteLine($"Text: {text2}"); Console.WriteLine($"LLM: {result2.Text}");
Результат

Уже тут видно, как нейронка - просто дописывает за пользователем предложения, на которых обучена. Т9 во всей красе.
Но какой потенциал! Это уже хоть какая-то говорилка! Самая маленькая текстовая нейросеть!
Параметр maxSeqLen (Maximum Sequence Length) - это тот самый размер контекста в токенах! Это максимально допустимая длина текста (количество токенов), которую модель может обработать за один раз. В программе параметр вычисляется автоматически, но никто не мешает установить его намного больше вручную.
Ничто не мешает нам обучить модель на русском языке. Нужно только увеличить количество эпох так, что бы значение было примерно или ниже: Loss = 0,1850.
Общение с моделью на русском

В данном случае я передаю модели строку "User: " + текст набранный в консоли.
Модель запомнила строку:
"User: Как изготавливается стекло? Assistant: Стекло изготавливается путем нагревания песка, кальцинированной соды и известняка до очень высоких температур, пока они не расплавятся </s>"
Передаю модели строку:
"User: Как изготавливается стекло?
Модель выдаёт продолжение заученной строки:
"Assistant : Стекло изготавливается путем нагревания песка , кальцинированной соды и известняка до очень высоких температур , пока они не расплавятся </s>"
Заметили что перед запятыми и после них - пробелы? Модель выдает не точную копию исходного текста, а последовательность токенов, преобразованную обратно в слова.
Модель выдает токены (для нагладности добавил разделитель):
293|2|254|104|216|154|182|1|118|246|101|103|78|176|53|261|1|200|173|155|219|5
А потом эти токены преобразовываются в слова:
Assistant|:|Стекло|изготавливается|путем|нагревания|песка|,|кальцинированной|соды|и|известняка|до|очень|высоких|температур|,|пока|они|не|расплавятся|</s>
В коде после каждого токена добавляется пробел.
При инференсе, если дать модели слова которых она не знает, она будет выдавать мусор. Когда модель выдает нам готовый результат, мы можем вычислить "уверенность" с которой модель выдает каждый токен. И на основе этого определять: правильные ли токены она нам выдает, или нет.
Выдаем только "уверенный" ответ
// ждем от пользователя слова Console.Write("Enter prompt: "); var input = Console.ReadLine(); // запрос к модели, инференс var formattedInput = $"User: {input.Trim()}"; var result = llm.PredictWithConfidence(formattedInput); // выдаем только "уверенный" ответ if (result.MinConfidence > 0.6f) Console.WriteLine($"Model output: {result.Text}"); else Console.WriteLine("Model output: Затрудняюсь ответить");
Модель можно сохранить и загрузить из .bin файла. Размер файла готовой модели llm_model.bin - 6,87 Мб (!).
Откапываем OpenCL
Обучать микро модель на центральном процессоре еще можно, но если хочется сделать более осмысленную модель, с бОльшими весами, вмещающую больше информации - терпения не хватит. Это долго, это шум кулера, это ужасно если долго ждал - а результата ноль и нужно переучивать заново.
Нейронка - это матрицы. Умножать, делить, складывать ...матрицы это тяжело для CPU. Нужна мощь видеокарты. Видеокарты заточены на операции с матрицами.
У меня нет видеокарты NVidia с CUDA процессорами на борту. У меня встройка от AMD. Да, у нее есть NPU модуль, но не у всех он есть. За то есть OpenCL.
OpenCL - это гетерогенная технология операций с матрицами. То, что CPU делает в один поток, OpenCL делает тысячами потоков параллельно.
OpenCL работает с 2009 года на GeForce 8000 (8800 GT), Radeon HD 4000, Intel HD 4000.
Главная особенность OpenCL - расчеты ведутся в kernel. Кернел - это блок кода выполняющийся параллельно на нескольких ядрах. Примерно как шейдер для видеокарты, но не для графики, а для вычислений. Код пишется на языке OpenCL C (похож на C99).
...А ведь появление CUDA обязано пиксельным шейдерам для этих ваших игрулек. В 2003 студенты Стендфорда разработали проект BrookGPU, позволяющий производить расчёты сложных физических симулиций и прочего через шейдеры. Этот метод получил название GPGPU - General-Purpose computing on GPU, то есть "универсальные вычисления на графическом процессоре". Затем NVIDIA вдохновленная этим проектом наняла создателя BrookGPU Яна Бака, и придумала CUDA.
OpenCL появился в 2008 году уже как ответка всей остальной индустрии на монополию CUDA. Придумала его Apple, и пошло поехало...
ILGPU
На просторах интернета есть проект https://ilgpu.net. ILGPU это JIT-компилятор с открытым исходным кодом, который позволяет писать код для GPU (CUDA и OpenCL) прямо на обычном C#.
Есть проект на github: https://github.com/m4rs-mt/ILGPU.
Вот пример перемножения матриц на CPU (одна из рутинных операций при обучении нейронки):
float[,] a = new float[size, size]; float[,] b = new float[size, size]; float[,] res = new float[size, size]; //заполнение случайными данными ... //умножение for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { float sum = 0; for (int k = 0; k < size; k++) { sum += a[i, k] * b[k, j]; } res[i, j] = sum; } }
А теперь сравним с GPU OpenCL. Каждая матрица размерностью 1024 на 1024.
Program.cs
using ILGPU; using ILGPU.Runtime; using ILGPU.Runtime.OpenCL; using MathNet.Numerics.LinearAlgebra; using System.Diagnostics; class Program { // Кернел для GPU static void MultiplyKernel( Index2D index, ArrayView2D<float, Stride2D.DenseX> a, ArrayView2D<float, Stride2D.DenseX> b, ArrayView2D<float, Stride2D.DenseX> res) { float sum = 0; for (int k = 0; k < a.Extent.Y; k++) sum += a[new Index2D(index.X, k)] * b[new Index2D(k, index.Y)]; res[index] = sum; } static void Main() { int size = 1024; Console.WriteLine($"Перемножение матриц. Размер матрицы: {size}x{size}"); //1. CPU ===================================== float[,] a = new float[size, size]; float[,] b = new float[size, size]; float[,] resCpu = new float[size, size]; var sw = Stopwatch.StartNew(); //заполнение случайными данными Random r = new Random(); for (int i = 0; i < size; i++) for (int j = 0; j < size; j++) { a[i, j] = (float)r.NextDouble(); b[i, j] = (float)r.NextDouble(); } //умножение for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { float sum = 0; for (int k = 0; k < size; k++) { sum += a[i, k] * b[k, j]; } resCpu[i, j] = sum; } } sw.Stop(); Console.WriteLine($"1. CPU Время: {sw.Elapsed.TotalMilliseconds:F2} мс"); // 2. MATH.NET =============================== var m_a = Matrix<float>.Build.DenseOfArray(a); var m_b = Matrix<float>.Build.DenseOfArray(b); sw.Restart(); var m_res = m_a * m_b; sw.Stop(); Console.WriteLine($"2. Math.NET (SIMD/Multi-thread): {sw.ElapsedMilliseconds} мс"); //3. GPU ===================================== using var context = Context.Create(builder => builder.OpenCL()); using var accelerator = context.GetPreferredDevice(false).CreateAccelerator(context); var kernel = accelerator.LoadAutoGroupedStreamKernel< Index2D, ArrayView2D<float, Stride2D.DenseX>, ArrayView2D<float, Stride2D.DenseX>, ArrayView2D<float, Stride2D.DenseX>>(MultiplyKernel); var extent = new Index2D(size, size); using var bufA = accelerator.Allocate2DDenseX<float>(extent); using var bufB = accelerator.Allocate2DDenseX<float>(extent); using var bufRes = accelerator.Allocate2DDenseX<float>(extent); bufA.CopyFromCPU(a); bufB.CopyFromCPU(b); // Прогрев (исключаем JIT из замера) kernel(extent, bufA.View, bufB.View, bufRes.View); accelerator.Synchronize(); // Чистый замер GPU sw.Restart(); kernel(extent, bufA.View, bufB.View, bufRes.View); accelerator.Synchronize(); sw.Stop(); Console.WriteLine($"3. GPU Время (чистое): {sw.Elapsed.TotalMilliseconds:F2} мс"); Console.WriteLine($"Устройство: {accelerator.Name}"); } }
Результат

Неплохо. Первый вариант нейронки уже работает на стероидах - библиотеке Math.NET использующей некислый хак от производителя процессора: инструкции SIMD (AVX, AVX2, AVX-512, SSE2, FMA3).
Но это всё равно хуже OpenCL.
Как обычно, реальность немного приземляет. Последняя версия ILGPU 1.5.3 поддерживает устройства с OpenCL 2.0 и выше. Проверил на NVIDIA GeForce GTX 560 - библиотека ILGPU не хочет работать с видеокартой, т.к. старая видеокарта поддерживает только OpenCL 1.1.
Можно попробовать задействовать более старые версии библиотеки... Можно, а зачем?
Вторая версия. OpenCL
На этот раз я попросил ИИ перевести вычисления на OpenCL, т.е. задействовать библиотеку ILGPU для работы с матрицами.
Хитрый ИИ сделал так, что программа перегоняла данные в GPU, возвращала обратно, а бОльшую часть вычислениий производила на CPU. В диспетчере задач, во вкладке GPU, на графике "Copy" была большая активность. А в графике "Compute" - почти не заметная активность. В коде обилие методов "CopyFromCPU", "CopyToCPU".
WTF?

После многократных исправлений нагрузка перешла в график "Compute".
Другое дело!

Теперь при вычислениях CPU не нагружается вообще, вентилятор не шумит!
Mixture of Experts (MoE)
Поигравшись в рабочую версию, решил добавить блок MoE.
Если в "обычном" Transformer блоке структура такая: Layer Norm -> Self-Attention (+ Residual) -> Layer Norm -> FeedForward (+ Residual).
То в Transformer MoE (как в Mixtral): Layer Norm -> Self-Attention (+ Residual) -> Layer Norm -> MoE (Router -> FeedForward1 , FeedForward2...) (+ Residual).
Router - это легкий Linear слой, который анализирует вектор и "решает", каким именно FeedForward слоям (экспертам) его передать. Грубо говоря Router - это "диспетчер" выдающий вероятность выбора того или иного "эксперта". А FeedForward - эксперт.
Особых различий в обучении не заметил. Но это и понятно: корпус (текст) для обучения очень маленький.
GGUF
А почему бы собственно не экспортировать модель в формат gguf что бы любой софт использующий llama.cpp (Ollama, LM Studio и прочие) могли загрузить нашу модель?
Оказалось что не всё так просто. С наскока такое не провернуть. Конечно я пробовал экспортировать модель с такой конфигурацией: Embedding -> TransformerBlockMultiHead -> Linear. Но, llama.cpp выдавала ошибки типа: llama_model_load: error loading model: done_getting_tensors: wrong number of tensors; expected 37, got 33
Необходимо создать такую конфигурацию модели, какую понимает llama.cpp. Бесплатный ИИ предложил сделать архитектуру GPT-2: Embedding -> PositionalEmbedding -> TransformerBlockPreNorm -> LayerNorm -> Linear.
Токенизатор
Для полной совместимости с llama.cpp нужно было создать GPT-2 Byte-Level BPE (Byte Pair Encoding) токенизатор. Главная фишка такого токенизатора в том, что для него нет неизвестных символов, он может работать совершенно с любыми словами и символьными последовательностями. В старых токенизаторах (например BERT) если встречался неизвестный символ, модель выдавала [UNK] (Unknown). А BPE работает с базовым словарем на 256 символов и может их комбинировать как угодно. Это гарантирует, что любой файл или символ в любой кодировке может быть прочитан моделью. Он объединяет пары символов которые встречаются часто вместе - в один токен. Т.е. несколько часто встречающихся вместе токенов "п" "р" "и" "в" "е" "т" при анализе текста, объединится в один токен "привет". Фактически это означает что появится новый вектор (число) за которым будет закреплено слово "привет".
Encode - кодирование сырого текста в список токенов предоставленных в виде векторов (чисел).
Decode - преобразование токенов в текст.
![Пример того как модель видит текст: [2, 4, 448, 7, 5] Пример того как модель видит текст: [2, 4, 448, 7, 5]](https://habrastorage.org/r/w780/getpro/habr/upload_files/5f4/cd0/d2c/5f4cd0d2c0e54f9bb38280d3ba31d882.png)
Обучение токенизатора происходит в два этапа:
Разрезание текста на куски (pre-tokenization) по определенному регулярному выражению (Regex). Такое разрезание гарантирует, что буквы, цифры и знаки препинания не будут объединяться в один токен. Например "привет!" будет разбито на "привет" и "!".
Ну и собственно слияние (merges). Токенизатор ищет самую частую пару соседних байтов/токенов внутри нарезанных кусков текста и объединяет их в новый токен. Слияние происходит не сразу. В первый проход из "п" "р" "и" "в" "е" "т", произойдет слияние "пр" "и" "в" "е" "т", затем "при" "в" "е" "т" и т.д. А может и так: "при" "вет". Все зависит от корпуса.
У llama.cpp есть утилита показывающая как она интерпретирует запросы в своем токенизаторе. Команда: lama-tokenize -m model.gguf -p "<user> привет!<assistant>" --ids так же выдает [2, 4, 448, 7, 5]
Корпус
Корпус - это собственно текст на котором учится модель. Корпус можно взять готовый, например здесь: https://huggingface.co/datasets/d0rj/alpaca-cleaned-ru. А можно попросить любую крупную LLM сгенерировать вам свой корпус. Так можно получить одну из разновидностей Distilled (дистилляция) модели. Т.е. малая модель обучается на более концентрированных и чистых корпусах от большой умной модели.
В нашем случае, корпус нужно правильно оформить для обучения. Например строка:
"User: привет Assistant: привет! рада тебя видеть. чем могу помочь?"
для обучения преобразуется в такой вид:
"<s> <user> привет<assistant> привет! рада тебя видеть. чем могу помочь? </s>".
<s> - стартовый токен (Bos - Beginning of Sequence), позволяющий модели понять, что начинается новая генерация. Для большинства моделей этот токен позволяет сбросить контекст и настроить механизмы внимания (Attention) для первого слова.
</s> - конечный токен (Eos - End of Sequence). Модель может генерировать текст бесконечно. Если она запомнит этот токен при обучении, то программа которая будет делать инференс этой модели (llama.cpp) при получении этого заученного токена тут же завершит генерацию следующих токенов. Этот токен нужен больше для инференса.
В таком случае инференс преобразует вашу строку:
"привет"
в такое:
"<s> <user> привет<assistant>"
И модель получив незаконченную строку начинает генерировать недостающие последовательности токенов которые должны стоять друг за другом с высокой вероятностью:
" привет! рада тебя видеть. чем могу помочь? </s>
ёлки палки какой-то мусор который не пропустит llama.cpp после eos токена й3зшо2хщ0шо2хй3щ"
Еще был такой токен <sep> (Separator) - для разделения строки на вопрос и ответ. Но у нас для этого есть <user> и <assistant>.
Шаблон
Для llama.cpp очень важен шаблон по которому она будет преобразовывать ваши запросы к модели в правильные токены, и преобразовывать результат модели в текст.
Вот такой шаблон получился для нашей модели с нашим особенным корпусом:
string chatTemplateHistory = "{% for message in messages %}" + "{% if message['role'] == 'user' %}" + "<user> {{ message['content'] }}<assistant>" + "{% elif message['role'] == 'assistant' %}" + " {{ message['content'] }}</s>" + "{% endif %}" + "{% endfor %}";
Почему особенный корпус? Потому что наши Bos, Eos, User, Assistant токены нестандартные. Например в Llama3 Bos токен - это <|begin_of_text|>, в ChatML (Qwen, DeepSeek): <|im_start|>.
Такой шаблон будет генерировать растущий запрос к модели с каждым запросом пользователя. Вы напишете "привет", он отравит модели
"<s> <user> привет<assistant>"
Затем напишете "как дела?", он отправит:
"<s> <user> привет<assistant>привет! рада тебя видеть. чем могу помочь? </s> <user> как дела?<assistant>"
Но на самом деле llama.cpp держит предыдущий ответ в KV-кеше, и не пересылает всю эту огромную строку текста заново. При вводе нового запроса, llama.cpp дописывает его в свободные ячейки памяти кеша. Если контекст у модели маленький, llama.cpp начнет "выбрасывать" начало истории из кеша и инференс ломается.
Только вот моделька у нас не то что бы микро размеров, она ну очень маленькая, и не может переварить историю запросов. Ей нужно при каждом инференсе сбрасывать накопленные данные. Для этого мы схитрим, сделав такой шаблон:
//чат без истории string chatTemplate = "{% if messages %}" + "{% set message = messages[-1] %}" + // только последнее сообщение "{% if message['role'] == 'user' %}" + "<user> {{ message['content'] }}<assistant>" + "{% endif %}" + "{% endif %}";
Тут надо озвучить одну особенность. Данный шаблон заработает если мы в метаданных экспорта в GGUF укажем такое (установим add_bos_token = true):
kvs.Add(MakeKV("tokenizer.ggml.add_bos_token", GgufType.Bool, true)); kvs.Add(MakeKV("tokenizer.ggml.add_eos_token", GgufType.Bool, false));
Если установить add_bos_token = false, тогда нам надо добавить Bos токен <s> в шаблон:
string chatTemplate = "<s>{% if messages %}" + "{% set message = messages[-1] %}" + // берем только последнее сообщение "{% if message['role'] == 'user' %}" + "<user> {{ message['content'] }}<assistant>" + "{% endif %}" + "{% endif %}";
Метаданные GGUF
Метаданные это фактически инструкция для llama.cpp как загружать модель в память, и как с ней работать.
general.architecture - gpt2. Если указали эту архитектуру, то для архитектуры добавляются специфичные поля:
gpt2.context_length - длина контекста
gpt2.embedding_length - размер Embedding слоя
gpt2.feed_forward_length - размер скрытого слоя
gpt2.attention.head_count - количество "голов"
gpt2.block_count - количество слоёв
и прочее.
general.file_type - точность, степень квантования: 32 бита - полная точность, F16, Q4_0 и т.д.
Токенизатор:
tokenizer.ggml.tokens - массив строк, включая <user>, <assistant>
tokenizer.ggml.merges - правила склейки
tokenizer.ggml.bos_token_id - наши знакомые bos
tokenizer.ggml.eos_token_id - и eos
Ну и конечно же шаблон: tokenizer.chat_template.
Запускаем в LM Studio
Итак. Прежде чем сделать инференс, нужно модель обучить. Давайте посмотрим на конфигурацию нашей модели:
EmbeddingDim = 64 (размер Embedding слоя для связи между токеном его числовым описанием (вектором))
HiddenDim = 128 (размер скрытого слоя)
NumHeads = 2 (количество "голов")
NumLayers = 1 (количество слоёв, т.е. трансформера TransformerBlockPreNorm)
VocabSize = 512 (размер словаря соответствий Токен <-> ID)
MaxSeqLen = 80 (длина контекста... который может переварить модель за один раз)
Итого параметров у модели: 103 744. Это где то примерно 0,000103744 миллиардов параметров... А размер model.gguf файла 422 Кб.
Вот что гугл сказал про такую конфигурацию
Особенности для вашей конфигурации:
Плотность информации: При
EmbeddingDim = 64каждый токен описывается очень коротким вектором. Это значит, что модель сможет различать только самые базовые понятия. Сложные связи (ирония, глубокий контекст) ей будут недоступны.Головы внимания:
NumHeads = 2означает, что на каждую «голову» приходится всего по 32 измерения (64 / 2). Это необходимый минимум, чтобы модель могла одновременно следить, например, за подлежащим и сказуемым.Критичность параметров: При
NumLayers = 1у модели всего один шанс «понять» смысл текста. В глубоких моделях (Llama, GPT-4) десятки слоев уточняют смысл, а здесь модель работает как очень продвинутый Т9.
Обучение модели:
Pretrain около 100 эпох с LR (learning rate) = 0.001
Tune (обучение диалогам) около 100 эпох с LR=0.0005 (loss упадет с 8,4 до 0,3), затем еще несколько раз с меньшим LR но больше эпох, пока loss не достигнет 0.12. Это самое долгое.
Лог обучения (минуты 2-3) с инференсом в конце:
=== GPU INFO === Device: gfx1150 Type: OpenCL Memory: 37357 MB Max threads/group: 256 Warp size: 32 ================ ╔══════════════════════════════════════╗ ║ LLM ILGPU — Главное меню ║ ╠══════════════════════════════════════╣ ║ 1. Загрузить модель из файла ║ ║ 2. Создать новую GPT2 и обучить ║ ╚══════════════════════════════════════╝ Выберите опцию (1 или 2): 2 ═══ СОЗДАНИЕ МИНИМАЛЬНОЙ GPT2 ═══ Архитектура: GPT-2 (Pre-Norm, GELU, обучаемые positional) ═══ Обучение BpeTokenizer ═══ [special] '<pad>' => 0 [special] '<unk>' => 1 [special] '<s>' => 2 [special] '</s>' => 3 [special] '<user>' => 4 [special] '<assistant>' => 5 [special] '<sep>' => 6 Базовый vocab (specials + bytes): 263 Уникальных слов: 324 step= 0 freq= 450 ' '+'�'=>' �' step= 50 freq= 29 ' �'+'�'=>' м' step= 100 freq= 13 ' '+'—'=>' —' step= 150 freq= 7 'и'+'ть'=>'ить' step= 200 freq= 5 'ен'+'и'=>'ени' Готово: 249 слияний, vocab=512 [35ms] [ВЕРИФИКАЦИЯ СПЕЦТОКЕНОВ] ✅ '<pad>' => id=0 ✅ '<unk>' => id=1 ✅ '<s>' => id=2 ✅ '</s>' => id=3 ✅ '<user>' => id=4 ✅ '<assistant>' => id=5 ✅ '<sep>' => id=6 ✅ Все спецтокены корректны Vocab size: 512 BOS=2, EOS=3, UNK=1, PAD=0 ? MaxSeqLen из корпуса: 64 (строка 55) Tokenizer: BPETokenizer (vocab=512) Embedding: 64, Hidden: 128 Heads: 2, Layers: 1 MaxSeqLen: 64 Параметров: 103 744 Параметры модели: 103 744 Архитектура: Embedding → PositionalEmbedding → TransformerBlockPreNorm → LayerNorm → Linear Обучить модель сейчас? (y/n, по умолчанию y): ╔══════════════════════════════════════╗ ║ ФАЗА 1: ПРЕДВАРИТЕЛЬНОЕ ОБУЧЕНИЕ ║ ╚══════════════════════════════════════╝ Примеров: 22 Количество эпох pretrain (по умолчанию 30): 100 Learning rate pretrain (по умолчанию 0,001): Валидных примеров: 22 Warmup steps: 200 Total steps: 2200 Base LR: 0,001 [Pretrain] Epoch 0/100: Loss = 7,2253, LR = 1,10E-004 [Pretrain] Epoch 5/100: Loss = 5,0649, LR = 6,60E-004 [Pretrain] Epoch 10/100: Loss = 4,3860, LR = 9,99E-004 [Pretrain] Epoch 15/100: Loss = 4,0299, LR = 9,86E-004 [Pretrain] Epoch 20/100: Loss = 3,2411, LR = 9,58E-004 [Pretrain] Epoch 25/100: Loss = 2,3507, LR = 9,17E-004 [Pretrain] Epoch 30/100: Loss = 1,6478, LR = 8,63E-004 [Pretrain] Epoch 35/100: Loss = 1,1448, LR = 7,99E-004 [Pretrain] Epoch 40/100: Loss = 0,7596, LR = 7,26E-004 [Pretrain] Epoch 45/100: Loss = 0,5265, LR = 6,46E-004 [Pretrain] Epoch 50/100: Loss = 0,3818, LR = 5,61E-004 [Pretrain] Epoch 55/100: Loss = 0,3068, LR = 4,75E-004 [Pretrain] Epoch 60/100: Loss = 0,2736, LR = 3,89E-004 [Pretrain] Epoch 65/100: Loss = 0,2526, LR = 3,07E-004 [Pretrain] Epoch 70/100: Loss = 0,2375, LR = 2,31E-004 [Pretrain] Epoch 75/100: Loss = 0,2278, LR = 1,62E-004 [Pretrain] Epoch 80/100: Loss = 0,2196, LR = 1,04E-004 [Pretrain] Epoch 85/100: Loss = 0,2157, LR = 5,74E-005 [Pretrain] Epoch 90/100: Loss = 0,2135, LR = 2,40E-005 [Pretrain] Epoch 95/100: Loss = 0,2122, LR = 4,77E-006 [Pretrain] Epoch 99/100: Loss = 0,2120, LR = 0,00E+000 ╔══════════════════════════════════════╗ ║ ФАЗА 2: INSTRUCTION TUNING ║ ╚══════════════════════════════════════╝ Примеров: 52 Количество эпох finetune (по умолчанию 10): 100 Learning rate finetune (по умолчанию 0,0001): 0.0005 Валидных примеров: 51 Warmup steps: 200 Total steps: 5100 Base LR: 0,0005 [Finetune] Epoch 0/100: Loss = 8,4320, LR = 1,28E-004 [Finetune] Epoch 5/100: Loss = 4,5529, LR = 4,99E-004 [Finetune] Epoch 10/100: Loss = 3,9726, LR = 4,93E-004 [Finetune] Epoch 15/100: Loss = 3,1391, LR = 4,81E-004 [Finetune] Epoch 20/100: Loss = 2,4474, LR = 4,62E-004 [Finetune] Epoch 25/100: Loss = 1,9498, LR = 4,38E-004 [Finetune] Epoch 30/100: Loss = 1,4889, LR = 4,08E-004 [Finetune] Epoch 35/100: Loss = 1,2245, LR = 3,75E-004 [Finetune] Epoch 40/100: Loss = 1,0092, LR = 3,38E-004 [Finetune] Epoch 45/100: Loss = 0,8483, LR = 2,98E-004 [Finetune] Epoch 50/100: Loss = 0,7167, LR = 2,58E-004 [Finetune] Epoch 55/100: Loss = 0,6196, LR = 2,17E-004 [Finetune] Epoch 60/100: Loss = 0,5503, LR = 1,77E-004 [Finetune] Epoch 65/100: Loss = 0,4963, LR = 1,39E-004 [Finetune] Epoch 70/100: Loss = 0,4601, LR = 1,04E-004 [Finetune] Epoch 75/100: Loss = 0,4329, LR = 7,31E-005 [Finetune] Epoch 80/100: Loss = 0,4148, LR = 4,67E-005 [Finetune] Epoch 85/100: Loss = 0,4032, LR = 2,57E-005 [Finetune] Epoch 90/100: Loss = 0,3966, LR = 1,07E-005 [Finetune] Epoch 95/100: Loss = 0,3932, LR = 2,14E-006 [Finetune] Epoch 99/100: Loss = 0,3926, LR = 0,00E+000 ╔══════════════════════════════════════╗ ║ Меню действий ║ ╠══════════════════════════════════════╣ ║ 1. Предсказать текст ║ ║ 2. Обучить модель ║ ║ 3. Сохранить модель ║ ║ 4. Интерактивный чат ║ ║ 5. Информация о модели ║ ║ 6. Тест токенизатора ║ ║ 7. Экспорт в GGUF ║ ║ 8. Выход ║ ╚══════════════════════════════════════╝ Выберите опцию: 4 ╔══════════════════════════════════════╗ ║ Интерактивный чат ║ ╠══════════════════════════════════════╣ ║ Команды: ║ ║ exit — выход ║ ║ temp N — установить temperature ║ ║ clear — очистить экран ║ ╚══════════════════════════════════════╝ [temp=0,7] You: привет Assistant: привет! рада тебя видеть. чем могу помочь?
Да, не густо, loss=0,3926. Но даже loss=0.12 для llama.cpp это так себе результат. При загрузке модели через llama-cli с флагом –verbose, нас ругают за очень плохое обучение:
print_info: file format = GGUF V3 (latest) print_info: file type = all F32 print_info: file size = 0.39 MiB (32.00 BPW) load: missing pre-tokenizer type, using: 'default' load: load: ************************************ load: GENERATION QUALITY WILL BE DEGRADED! load: CONSIDER REGENERATING THE MODEL load: ************************************
Но всё же в llama.cpp работает
Скрытый текст

Находим где хранятся наши модели в LM Studio
Скрытый текст

Создаем примерно такой путь: D:\lmstudio\models\test\virex. Сохраняем туда gguf и готово.


Итого
Самым сложным было экспортировать в gguf так, что бы инференс не поломался. Изначально я пытался сделать llama модель, с такой конфигурацией: EmbeddingLayer ->Transformer Blocks (RMSNorm + RoPE + SiLU gated FFN) -> RmsNormLayer -> LinearLayer, но как-то сильно застрял в отладке. Начал всё заново. Попросил бесплатный ИИ написать новый токенизатор основываясь на исходниках мелкософта: https://github.com/dotnet/machinelearning/blob/main/src/Microsoft.ML.Tokenizers/Model/BPETokenizer.cs. И оно заработало. Не сразу, не с десятого и не с двадцатого раза, но таки удалось добиться рабочей версии.
Осталась одна проблема: инференс в llama.cpp ломается если есть вопросительный или восклицательный знак в пользовательском запросе.
Для тех кто хочет проверить сразу:
Исходный код: https://github.com/virex-84/LLMGPT2
Релиз (отдельно model.gguf): https://github.com/virex-84/LLMGPT2/releases/tag/v1
А на сегодня всё...
ComputerPers
Два чая этому господину :-)