Обучение LLM с нуля на C#

Обучение LLM с нуля на C#

У меня нет мощной видеокарты от NVidia и, соответственно, доступа к CUDA — стандарту в машинном обучении. Однако желание создать что-то похожее на нейронную сеть осталось. В поисках решений я наткнулся на проект RustGPT — реализацию простой LLM на Rust. Так как разобраться в коде на Rust и переписать его с нуля на C# было за пределами моих возможностей, я решил использовать доступные бесплатные LLM: Qwen CLI, Gemini CLI, Kilo-code, Perplexity и даже ИИ-режим в Google.

Что такое обучение LLM?

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

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

  1. Токенизация. Текст разбивается на части — токены. Каждому слову, символу или сочетанию символов ставится в соответствие число. Например: "привет" → 1375, пробел → 333, "мир" → 777. Знаки препинания и управляющие символы тоже становятся токенами.
  2. Embedding-слой. Преобразует токены в векторы — числовые описания. До обучения веса в этом слое хаотичны.
  3. TransformerBlock. Преобразует последовательность векторов, делая их «умнее». Состоит из:
    • Self-attention: механизм внимания, связывающий токены между собой. Использует четыре линейных слоя:
      • Query — формулирует «вопрос» от текущего слова к другим;
      • Key — описывает характеристики слова для других;
      • Value — содержит содержательную информацию;
      • Output — собирает результаты всех «голов» внимания.
    • FeedForward — небольшая сеть, концентрирующая информацию. Хранит «знания».
    • Layer normalization — выравнивает значения, чтобы они оставались в рабочем диапазоне.
    • Residual connection — сохраняет исходную информацию, складывая входные и выходные данные слоя.
  4. Output projection. Последний линейный слой, преобразующий внутреннее состояние модели в вероятности следующего слова из словаря (например, из 50 000 вариантов).

Главная задача LLM — предсказать следующее слово по контексту. По сути, это продвинутый T9 с пониманием смысла.

Процесс обучения

  1. Pre-processing. Исходные данные (датасет) преобразуются в токены.
  2. Разделение на вход и выход. Например, фраза "Мама мыла раму" (токены: 111, 555, 777). Вход: "Мама мыла" (111, 555), выход: "мыла раму" (555, 777).
  3. Forward Pass. Данные проходят через слои: Embedding → Attention → FeedForward → Normalization → Residual. На выходе — logits — сырые оценки вероятностей следующих слов.
  4. Loss и Backpropagation. Сравниваются logits и ожидаемый выход. Чем больше ошибка (loss), тем выше градиент — мера «виновности» каждого веса. Ошибка распространяется назад: от последнего слоя к первому.
  5. Clip Gradients. Если градиент слишком велик, его обрезают, чтобы не сломать модель при обновлении весов.
  6. Optimizer. Обновляет веса:
    • Положительный градиент — уменьшаем вес.
    • Отрицательный — увеличиваем.
    • Нулевой — не меняем.

    Коэффициент Learning Rate (LR) определяет силу шага. Высокий LR — быстрое, но нестабильное обучение. Низкий — медленное, но точное.

Первая версия на C#

С помощью ИИ я переписал код на C#, используя библиотеку MathNet. Обучение проводилось в два этапа:

  • Pretrain — общие знания (например, "солнышко блестит").
  • Tune — дообучение на диалогах. На маленькой модели эти знания перезатирают предыдущие.

Модель уже показывала признаки работы — дописывала предложения, на которых обучалась. Это был простой, но рабочий T9 с контекстом.

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

Для обучения на русском языке достаточно увеличить количество эпох до достижения loss ≤ 0,185.

Пример работы:

Вход: "User: Как изготавливается стекло?"
Выход: "Assistant: Стекло изготавливается путем нагревания песка, кальцинированной соды и известняка до очень высоких температур, пока они не расплавятся"

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

Если модель встречает неизвестное слово, она выдаёт бессмыслицу. Но можно оценить «уверенность» по вероятностям токенов и отфильтровать ошибки.

Модель сохраняется в файл llm_model.bin — всего 6,87 МБ.

Откапываем OpenCL

Обучение на CPU медленное и шумное. Нейронные сети — это операции с матрицами, и GPU справляется с этим лучше. У меня нет NVIDIA с CUDA, но есть встроенная графика AMD с поддержкой OpenCL.

OpenCL — гетерогенная технология, позволяющая выполнять вычисления параллельно на тысячах потоков. Работает с 2009 года, поддерживается старыми видеокартами.

CUDA появилась благодаря шейдерам в играх. В 2003 году студенты Стэнфорда создали BrookGPU — проект для вычислений через шейдеры (GPGPU). NVIDIA наняла создателя и разработала CUDA. OpenCL появился в 2008 году как ответ от Apple и других компаний на монополию CUDA.

Проект ILGPU — JIT-компилятор с открытым исходным кодом, позволяющий писать GPU-код на C#. Он поддерживает CUDA и OpenCL.

Первые попытки использовать ILGPU привели к тому, что данные перегонялись в GPU и обратно, но основные вычисления шли на CPU. После исправлений нагрузка перешла на GPU — CPU почти не нагружался, вентилятор не шумел.

Mixture of Experts (MoE)

Я добавил в архитектуру блок MoE. Вместо одного FeedForward-слоя теперь несколько «экспертов», а Router — лёгкий слой, определяющий, какой эксперт обработает вектор.

Особой разницы в обучении не заметил — корпус слишком мал.

Токенизатор

Чтобы модель работала с llama.cpp (и софтами вроде Ollama, LM Studio), нужно было сделать совместимый токенизатор. Я реализовал GPT-2 Byte-Level BPE.

Преимущество BPE — нет неизвестных символов. Любой текст можно разбить на 256 базовых байтов и комбинировать их. Часто встречающиеся сочетания (например, "привет") становятся одним токеном.

Процесс обучения токенизатора:

  • Pre-tokenization. Текст разбивается по регулярному выражению, чтобы не смешивать буквы, цифры и знаки препинания.
  • Merges. Находят самые частые пары токенов и объединяют их. Процесс итеративный: "п"+"р" → "пр", затем "при", и т.д.

Для инференса используются специальные токены:

  • <s> — начало последовательности (BOS). Сбрасывает контекст.
  • </s> — конец (EOS). Останавливает генерацию.
  • <user>, <assistant> — разделители диалога.

Шаблон чата позволяет модели понимать контекст запроса. Например:

"<s><user>привет<assistant>"

Модель генерирует продолжение:

"привет! рада тебя видеть. чем могу помочь?</s>"

llama.cpp хранит историю в KV-кеше, но у маленькой модели контекст ограничен — приходится сбрасывать его при каждом запросе.

Метаданные GGUF

При экспорте в формат GGUF важно правильно задать метаданные — инструкции для llama.cpp:

  • general.architecture — указываем gpt2, чтобы добавились нужные поля.
  • gpt2.context_length, embedding_length, feed_forward_length, head_count, block_count — параметры модели.
  • general.file_type — точность: F32, F16, Q4_0 и др.
  • tokenizer.ggml.tokens — список токенов, включая <user>, <assistant>.
  • tokenizer.ggml.merges — правила слияния.
  • tokenizer.ggml.bos_token_id, eos_token_id — ID токенов BOS и EOS.
  • tokenizer.chat_template — шаблон диалога.

Запуск в LM Studio

Конфигурация модели:

  • EmbeddingDim = 64
  • HiddenDim = 128
  • NumHeads = 2
  • NumLayers = 1
  • VocabSize = 512
  • MaxSeqLen = 80

Итого: 103 744 параметра — около 0,0001 миллиарда. Размер файла model.gguf — 422 КБ.

Особенности:

  1. При EmbeddingDim = 64 модель различает только базовые понятия. Сложный контекст и ирония — недоступны.
  2. 2 головы внимания — минимум, чтобы отслеживать подлежащее и сказуемое.
  3. Один слой — модель не углубляется в смысл, работает как продвинутый T9.

Обучение:

  1. Pretrain: ~100 эпох, LR = 0.001.
  2. Tune: ~100 эпох, LR = 0.0005 (loss падает с 8,4 до 0,3), затем ещё несколько циклов с меньшим LR до loss = 0.12.

Даже при loss = 0,3926 модель работает в llama.cpp. При загрузке с флагом --verbose система ругается на плохое обучение, но инференс проходит.

Экспорт в GGUF был сложным. Сначала я пытался сделать архитектуру llama, но застрял в отладке. Пришлось начать с нуля, используя реализацию токенизатора от Microsoft. После множества попыток — успех.

Осталась одна проблема: инференс ломается при восклицательных и вопросительных знаках в запросе.

Исходный код и модель доступны:

Читать оригинал

© 2026 AI News Hub. Новости искусственного интеллекта.