Введение
Ты прошёл курс по Go и умеешь писать сервисы, работать со слайсами, структурами, интерфейсами и горутинами. Этот курс — продолжение: мы возьмём тот же язык и напишем на нём нейросеть целиком с нуля, без единой внешней зависимости. Не обёртку над чужой библиотекой, а настоящую реализацию: матричную арифметику, слои, функции потерь, обратное распространение ошибки, оптимизаторы, загрузчик датасета MNIST и в финале — HTTP-сервис, который распознаёт рукописные цифры и собирается в один статический бинарь.
Зачем писать нейросеть руками, когда есть PyTorch? По той же причине, по которой в курсе Go ты писал свой HTTP-хендлер, а не брал готовый фреймворк: пока модель — чёрный ящик, ты не понимаешь, почему loss превращается в NaN, почему сеть застревает на 11% точности и почему замена одной строки инициализации весов внезапно всё чинит. После этого курса внутри любого фреймворка для тебя не останется магии — только знакомые операции над матрицами.
В этой главе мы разберёмся, что вообще такое нейросеть с точки зрения программиста, честно посмотрим, где Go хорош для этой задачи, а где нет, заведём проект и напишем первый работающий обучающийся код — маленькую модель, которая сама подбирает свои параметры.
Нейросеть — это функция с настраиваемыми параметрами
Забудь на минуту про «искусственный интеллект» и «нейроны мозга». Для программиста нейросеть — это функция. У неё есть вход (числа), выход (числа) и внутреннее состояние — набор чисел, которые называются параметрами или весами. Функция фиксирована, а веса меняются. Обучение — это процесс подбора весов так, чтобы функция на известных примерах выдавала правильные ответы.
От правил к обучению на примерах
Обычная программа устроена так: ты знаешь правило и записываешь его кодом. Нужно определить, спам ли письмо, — пишешь набор if: есть слово «выигрыш», больше пяти восклицательных знаков, отправитель не в контактах. Это работает ровно до тех пор, пока правил немного и они не меняются.
Есть задачи, где правило записать невозможно. Напиши if, который отличает рукописную семёрку от единицы по 784 пикселям яркости. Правила нет — точнее, оно есть, но состоит из тысяч взаимозависимых условий, которые человек не в состоянии выписать. Зато у нас есть 60 000 примеров: картинка и правильный ответ. Машинное обучение переворачивает задачу: вместо «программист пишет правило» — «программист пишет функцию с дырками (весами) и алгоритм, который эти дырки заполняет по примерам».
Нейрон: взвешенная сумма плюс смещение
Базовый кирпич — искусственный нейрон. Он получает вектор входов, умножает каждый вход на свой вес, всё складывает, прибавляет смещение (bias) и пропускает результат через нелинейную функцию активации:
output = activation(x1*w1 + x2*w2 + ... + xn*wn + b)
Взвешенная сумма — это ровно то, чем занимается линейная регрессия: каждый вход вносит вклад, пропорциональный своей важности. Вес больше нуля — признак «за», меньше нуля — «против», близок к нулю — признак не важен. Смещение b сдвигает порог срабатывания: без него функция обязана проходить через ноль, и целый класс задач становится нерешаемым.
Почему без нелинейности всё разваливается
Активация — это то, что превращает набор линейных функций в нечто мощное. Вот ключевой факт, который стоит понять один раз и навсегда: композиция линейных функций — снова линейная функция. Если сложить сто слоёв без активаций, получится ровно одна взвешенная сумма, только записанная громоздко. Сто слоёв не дадут ни грамма выразительности сверх одного.
Стоит вставить между слоями нелинейность — например, обнуление отрицательных значений (ReLU) — и сеть начинает описывать кусочно-линейные поверхности произвольной сложности. Каждый слой «ломает» пространство, следующий работает уже с изломанным. Именно поэтому нелинейность не украшение, а несущая конструкция.
Слои, обучение и потери
Нейроны группируют в слои: слой — это набор нейронов, которые смотрят на один и тот же вход, но со своими весами. Выход слоя становится входом следующего. Сеть из входного вектора длиной 784, скрытого слоя на 128 нейронов и выходного на 10 — это то, что мы обучим на MNIST к седьмой главе.
Обучение выглядит так:
- Прогнать пример через сеть — получить предсказание (прямой проход).
- Сравнить предсказание с правильным ответом — получить число, меру ошибки (функция потерь).
- Вычислить, как надо изменить каждый вес, чтобы ошибка чуть уменьшилась (градиент, обратное распространение).
- Сделать маленький шаг в эту сторону и повторить десятки тысяч раз.
Всё. Никакой другой магии в обучении нейросети нет. Четыре шага, из которых третий требует аккуратной работы с производными — ей будут посвящены главы 4 и 5.
Почему Go — и где он честно проигрывает
Go не самый популярный язык для машинного обучения, и делать вид, что это не так, бессмысленно. Но для нашей задачи — понять, как всё устроено, и довести модель до продакшена — он подходит отлично.
Что Go даёт
- Никакой скрытой магии. В Python
a @ bуходит в C-код, который ты не увидишь. В Go ты сам напишешь три вложенных цикла и своими глазами увидишь, что такое умножение матриц и почему оно стоит O(n³). - Настоящая многопоточность. В Go нет GIL: горутины реально исполняются на разных ядрах. Обучение — задача, которая параллелится почти идеально, и в главе 9 мы этим воспользуемся.
- Один статический бинарь. Модель, обученная на ноутбуке, уезжает в продакшен файлом на несколько мегабайт без интерпретатора, виртуального окружения и конфликта версий numpy. Образ на
scratchвесит меньше, чем базовый слой python-образа. - Статическая типизация и понятная производительность. Размерности матриц ты проверишь сам, а профиль CPU покажет ровно те функции, которые ты написал.
Чего Go не даёт
- Автоматического дифференцирования. В PyTorch градиенты считаются сами. У нас их не будет: каждую производную мы выведем и запишем руками. Для обучения это плюс — ты увидишь механику, — но для исследовательской работы это медленно.
- GPU. Всё, что мы напишем, работает на CPU. MNIST на CPU обучается за минуты, и этого достаточно. Модель на миллиард параметров — нет, для неё нужен другой инструмент, и это нормально.
- Экосистемы. В Python есть готовое всё. В Go придётся написать даже загрузчик датасета. Собственно, поэтому этот курс и существует.
Практический вывод: Go — прекрасный язык, чтобы понять нейросети и чтобы эксплуатировать обученную модель. Обучать на нём гигантские модели никто не предлагает.
Заводим проект
Создадим модуль и разложим будущий код по каталогам сразу — потом переносить будет дороже:
mkdir gonn && cd gonn
go mod init example.com/gonn
go version # нужен Go 1.22 или новее
Структура, к которой мы придём за десять глав:
gonn/
├── go.mod
├── cmd/
│ ├── train/ # обучение: читает данные, учит, сохраняет веса
│ │ └── main.go
│ └── serve/ # инференс-сервис из главы 10
│ └── main.go
└── internal/
├── matrix/ # матрицы и линейная алгебра (глава 2)
├── nn/ # слои, активации, потери, оптимизаторы (главы 3–8)
└── dataset/ # загрузка MNIST (глава 6)
Каталог internal выбран намеренно: пакеты внутри него нельзя импортировать из чужих модулей. Это учебный код, и мы честно говорим компилятору, что публичного API у него нет. cmd — стандартное место для точек входа: у нас их будет две, обучение и сервис.
Первый нейрон на float64
Начнём с самого простого — структуры нейрона и прямого прохода. Создай файл cmd/train/main.go:
package main
import (
"fmt"
"math"
)
// neuron — один искусственный нейрон.
// weights — по одному весу на каждый вход, bias — свободный член.
type neuron struct {
weights []float64
bias float64
}
// forward считает взвешенную сумму входов со смещением.
// Это «сырой» выход нейрона до активации, его принято обозначать z.
func (n neuron) forward(x []float64) float64 {
if len(x) != len(n.weights) {
panic(fmt.Sprintf("neuron: вход длины %d, а весов %d", len(x), len(n.weights)))
}
z := n.bias
for i, xi := range x {
z += xi * n.weights[i]
}
return z
}
// sigmoid сжимает любое вещественное число в интервал (0, 1),
// поэтому его удобно трактовать как вероятность.
func sigmoid(z float64) float64 {
return 1 / (1 + math.Exp(-z))
}
func main() {
n := neuron{weights: []float64{0.7, -1.2}, bias: 0.1}
x := []float64{1.0, 0.5}
z := n.forward(x)
fmt.Printf("z = %.4f, sigmoid(z) = %.4f\n", z, sigmoid(z))
}
Запусти go run ./cmd/train — увидишь z = 0.2000, sigmoid(z) = 0.5498. Это и есть весь прямой проход одного нейрона. Обрати внимание на две детали. Первая: везде float64. float32 экономит память и в больших моделях действительно используется, но при обучении с нуля половинная точность добавляет численных сюрпризов, которые ты сейчас не отличишь от собственных багов. Вторая: panic при несовпадении размерностей. Это не грубость — 90% ошибок в коде нейросетей это перепутанные размерности, и падать нужно сразу и громко, а не считать чушь дальше.
Почему паника, а не error
В курсе Go ты усвоил, что ошибки возвращают, а не паникуют. Здесь исключение осознанное: несовпадение размерностей матриц — это ошибка программиста на этапе сборки сети, а не ситуация времени выполнения вроде «файл не найден». Она либо есть всегда, либо её нет никогда, и обрабатывать её на каждом из миллионов вызовов forward в горячем цикле — значит утопить читаемость. Для операций с данными от пользователя (чтение файла, парсинг датасета) мы, наоборот, будем аккуратно возвращать error.
Кейс: обучаем модель предсказывать отток клиентов
Одного прямого прохода мало — интересно, когда веса подбираются сами. Возьмём маленькую реальную задачу: по двум признакам клиента предсказать, уйдёт ли он (отток). Признаки нормированы в диапазон примерно 0…1: доля дней с активностью за последний месяц и нормированное число обращений в поддержку.
Модель — один нейрон с сигмоидой, то есть логистическая регрессия. Функция потерь — бинарная кросс-энтропия, а градиент у неё поразительно простой: (p - y) * x, где p — предсказанная вероятность, y — правильный ответ 0 или 1. Откуда берётся эта формула, мы аккуратно выведем в главе 4; сейчас важнее увидеть, что цикл обучения умещается в 20 строк.
package main
import (
"fmt"
"math"
)
type sample struct {
x []float64 // признаки
y float64 // 1 — клиент ушёл, 0 — остался
}
func sigmoid(z float64) float64 {
return 1 / (1 + math.Exp(-z))
}
func main() {
// Активные клиенты с малым числом обращений остаются,
// неактивные с жалобами — уходят.
data := []sample{
{[]float64{0.9, 0.1}, 0},
{[]float64{0.8, 0.2}, 0},
{[]float64{0.7, 0.0}, 0},
{[]float64{0.2, 0.8}, 1},
{[]float64{0.1, 0.9}, 1},
{[]float64{0.3, 0.7}, 1},
}
w := []float64{0, 0} // стартуем с нулей: для одного нейрона это допустимо
b := 0.0
lr := 0.5 // скорость обучения — размер шага
for epoch := 1; epoch <= 2000; epoch++ {
var loss float64
gw := []float64{0, 0}
gb := 0.0
for _, s := range data {
z := b
for i := range w {
z += w[i] * s.x[i]
}
p := sigmoid(z)
// Бинарная кросс-энтропия: чем увереннее ошибка, тем больше штраф.
loss += -(s.y*math.Log(p) + (1-s.y)*math.Log(1-p))
// Градиент: насколько каждый вес «виноват» в этой ошибке.
d := p - s.y
for i := range gw {
gw[i] += d * s.x[i]
}
gb += d
}
n := float64(len(data))
for i := range w {
w[i] -= lr * gw[i] / n // шаг против градиента
}
b -= lr * gb / n
if epoch%500 == 0 {
fmt.Printf("эпоха %4d loss = %.4f\n", epoch, loss/n)
}
}
fmt.Printf("итог: w = [%.3f %.3f], b = %.3f\n", w[0], w[1], b)
test := []float64{0.15, 0.85}
fmt.Printf("вероятность оттока = %.3f\n", sigmoid(w[0]*test[0]+w[1]*test[1]+b))
}
Запусти. Loss монотонно падает, веса расходятся в разные стороны: у признака активности он становится заметно отрицательным (активность против оттока), у обращений в поддержку — положительным. Тестовый клиент получает вероятность оттока выше 0,9. Мы нигде не написали правило «если активность низкая, а обращений много — уйдёт». Оно вывелось из шести примеров само.
Это уже обучение в полном смысле слова: прямой проход, потери, градиент, шаг. Всё, что мы сделаем дальше — те же четыре шага, но с матрицами вместо чисел и с несколькими слоями вместо одного нейрона.
Типичные ошибки
1. Ожидать, что сеть «поймёт» ненормированные данные
Если один признак измеряется в диапазоне 0…1, а другой — 0…100000 (например, сумма платежей в рублях), градиент по второму признаку будет в сто тысяч раз больше, и обучение развалится: либо шаг подобран под большой признак и мелкий не учится, либо наоборот всё улетает в бесконечность. Признаки нормируют всегда. В нашем примере оба уже лежат в 0…1 — это не случайность, а обязательное условие.
2. Слишком большая скорость обучения
lr := 50.0 // «пусть учится быстрее»
Результат: loss не падает, а скачет или превращается в NaN. Шаг настолько велик, что модель перепрыгивает минимум и улетает всё дальше. Первое, что нужно делать при NaN в потерях, — уменьшить lr в десять раз. Обратная крайность (lr = 0.00001) не ломает ничего, но обучение растянется на часы.
3. Забыть смещение
Модель без b вынуждена проходить через начало координат. Для задачи «вероятность 0,5 при нулевых признаках» это может быть приемлемо, а для большинства других — нет: сеть не сможет сдвинуть границу решения и застрянет на плохом результате без единого сообщения об ошибке.
4. Копить градиент в той же переменной, что и веса
Обнови вес внутри цикла по примерам — и следующий пример будет считаться уже с изменёнными весами, хотя градиент по всей выборке ещё не собран. Иногда это работает (так устроен стохастический градиентный спуск), но если ты собирался делать батч, а получил случайную смесь — отладка выйдет мучительной. Держи аккумулятор градиента отдельно, как gw и gb выше.
Практика
Проверяемых ответов в этом курсе нет: правильность своего кода ты определяешь по поведению программы. Для каждой главы ниже — что реализовать и по каким признакам понять, что получилось.
Задание 1. Завести проект
Создай модуль gonn командой go mod init, разложи каталоги cmd/train и internal/ как показано выше, добавь .gitignore с игнором бинарей и каталога data/ (туда в главе 6 приедет MNIST). Инициализируй git-репозиторий — к концу курса в нём будет полноценный проект.
Задание 2. Нейрон как отдельный пакет
Перенеси тип neuron и функцию sigmoid в пакет internal/nn, экспортировав их (Neuron, Sigmoid, метод Forward). Напиши табличный тест internal/nn/neuron_test.go: несколько наборов входов с заранее посчитанными на бумаге значениями z. Сравнивай float64 не через ==, а через math.Abs(got-want) < 1e-9.
Задание 3. Обучение с логом
Доведи пример с оттоком до отдельной программы, которая пишет историю обучения в CSV-файл (epoch,loss) через encoding/csv. Построй график в любом редакторе таблиц — кривая должна круто падать в начале и выполаживаться. Это твой основной инструмент диагностики на весь курс.
Задание 4. Эксперименты со скоростью обучения
Прогоняй обучение с lr = 0.01, 0.5, 5, 50 и сохраняй кривые. Посмотри своими глазами, как выглядит слишком медленное обучение, нормальное, колеблющееся и расходящееся. Это единственный способ научиться узнавать их потом по логам.
Задание 5. Свои данные
Придумай третий признак (например, нормированный стаж клиента), допиши его в датасет и убедись, что код работает без изменений — он написан по len(w), а не по константе 2. Если пришлось править — исправь так, чтобы число признаков нигде не было зашито.
Чек-лист самопроверки
go vet ./...иgo test ./...проходят без замечаний.- Loss монотонно убывает (мелкие колебания допустимы, устойчивый рост — нет).
- После обучения активный клиент получает вероятность оттока меньше 0,2, неактивный с жалобами — больше 0,8.
- При
lr = 50ты виделNaNили расходящийся loss и понимаешь, почему. - Веса после обучения имеют разные знаки и интерпретируются осмысленно.
Итог
Нейросеть — это функция с настраиваемыми параметрами, а обучение — цикл из четырёх шагов: прямой проход, потери, градиент, шаг. Нейрон считает взвешенную сумму со смещением, а нелинейная активация — то, без чего стопка слоёв схлопывается в одну линейную функцию. Go даёт прозрачность, настоящий параллелизм и один бинарь на выходе, но не даёт автоградиента и GPU: производные мы будем выводить руками, и это главная учебная ценность курса.
Мы уже обучили настоящую модель — на скалярах и одном нейроне. Дальше нейронов станут сотни, и считать их по одному в цикле невозможно. В следующей главе мы напишем матрицы: структуру данных, на которой держится всё остальное.