Deep Neural Nets: 33 years ago and 33 years from now
Андрей Карпаты воспроизводит легендарную статью Янна ЛеКуна и соавторов 1989 года о применении обратного распространения ошибки к распознаванию рукописных почтовых индексов. Оригинальная сеть на 9760 параметров обучалась 3 дня на рабочей станции SUN-4/260, а реализация на PyTorch на MacBook Air (M1) укладывается примерно в 90 секунд — наивное ускорение около 3000×. Применяя современные приёмы (cross-entropy вместо MSE, AdamW, аугментация данных, Dropout, ReLU), Карпаты сокращает ошибку на тесте примерно на 60% — с ~80 до ~30 ошибок и ~1,5% error rate. Дополнительный прирост даёт расширение датасета за счёт MNIST до 50 000 примеров. Главный вывод: за 33 года на макроуровне мало что изменилось, но датасеты и модели выросли примерно в 10 000 000 раз, а к 2055 году обучение сетей с нуля под конкретную задачу, по мнению автора, будет почти полностью вытеснено дообучением и промптингом гигантских foundation-моделей вроде GPT.
Статья Янна ЛеКуна и соавторов 1989 года Backpropagation Applied to Handwritten Zip Code Recognition, на мой взгляд, имеет определённое историческое значение, потому что это, насколько мне известно, самое раннее реальное применение нейросети, обученной end-to-end с помощью обратного распространения ошибки. Если не считать крошечного датасета (7291 чёрно-белое изображение цифр 16×16) и крошечной сети (всего 1000 нейронов), статья читается удивительно современно даже сегодня, спустя 33 года: в ней описан датасет, архитектура сети, функция потерь, оптимизация и приводятся экспериментальные показатели ошибки классификации на обучении и тесте. Всё это абсолютно узнаваемо и проходит «type check» как современная статья по deep learning, только написана она 33 года назад. Поэтому я решил воспроизвести её, во-первых, ради удовольствия, а во-вторых, чтобы использовать это упражнение как кейс о природе прогресса в deep learning.
Реализация. Я старался следовать статье как можно точнее и переписал всё на PyTorch — код лежит в репозитории karpathy/lecun1989-repro на GitHub. Оригинальная сеть была реализована на Lisp с помощью симулятора обратного распространения SN (позже переименованного в Lush) Боттью и ЛеКуна 1988 года. Статья на французском, так что прочитать её я толком не могу, но по синтаксису видно, что нейросети там задаются через высокоуровневое API, похожее на то, как это сегодня делается в PyTorch. Если коротко про дизайн софта: современные библиотеки сошлись на архитектуре из трёх компонентов: 1) быстрая (C/CUDA) универсальная библиотека тензоров, реализующая базовые математические операции над многомерными тензорами; 2) autograd-движок, который отслеживает граф прямого вычисления и умеет генерировать операции для обратного прохода; 3) скриптуемое (Python) высокоуровневое API, понимающее специфику deep learning — типовые операции, слои, архитектуры, оптимизаторы, функции потерь и т. д.
Обучение. В процессе обучения нужно сделать 23 прохода по тренировочному набору из 7291 примера, то есть всего 167 693 предъявлений пар (пример, метка) сети. Оригинальная сеть обучалась 3 дня на рабочей станции SUN-4/260. Я запустил свою реализацию на CPU MacBook Air (M1) — она прожевала всё примерно за 90 секунд (~3000× наивного ускорения). Моя conda настроена использовать нативные arm64-сборки, а не эмуляцию через Rosetta. Ускорение могло бы быть ещё более впечатляющим, если бы PyTorch поддерживал все возможности M1 (включая GPU и NPU), но это, похоже, всё ещё в разработке. Я также попробовал наивно запустить код на A100, но обучение, наоборот, шло медленнее, скорее всего, потому что сеть совсем маленькая (свёрточная сеть из 4 слоёв с до 12 каналами, всего 9760 параметров, 64K MAC, 1K активаций), а SGD использует по одному примеру за раз. При этом, если бы кто-то всерьёз захотел разделать эту задачу с современным железом (A100) и софтом (CUDA, PyTorch), пришлось бы променять пер-сэмпловый SGD на full-batch-обучение, чтобы максимально загрузить GPU, и тогда вполне реально получить ещё ~100× ускорения по времени обучения.
Воспроизведение результатов 1989 года. Оригинальная статья приводит такие результаты:
eval: split train. loss 2.5e-3. error 0.14%. misses: 10 eval: split test . loss 1.8e-2. error 5.00%. misses: 102
Мой обучающий скрипт repro.py в текущем виде в конце 23-го прохода выводит:
eval: split train. loss 4.073383e-03. error 0.62%. misses: 45 eval: split test . loss 2.838382e-02. error 4.09%. misses: 82
То есть я воспроизвожу цифры примерно, но не точно. К сожалению, точная репродукция, скорее всего, невозможна, потому что оригинальный датасет, насколько я понимаю, утрачен. Вместо этого мне пришлось симулировать его с помощью большего датасета MNIST (ха, никогда не думал, что произнесу такое): я взял цифры 28×28, уменьшил их билинейной интерполяцией до 16×16 пикселей и случайно без возвращения выбрал нужное количество тренировочных и тестовых примеров. Но я уверен, что играют роль и другие факторы. Например, статья слишком абстрактно описывает схему инициализации весов, и я подозреваю, что в pdf-файле есть ошибки форматирования: например, теряются точки «.», из-за чего «2.5» выглядит как «2 5», и, возможно, исчезают знаки корня. Скажем, нам сообщают, что веса инициализируются из равномерного распределения «2 4 / F», где F — fan-in, но я предполагаю, что это, конечно (?), означает «2.4 / sqrt(F)», где sqrt помогает сохранить стандартное отклонение выходов. Конкретная структура разреженной связности между слоями H1 и H2 тоже описана вскользь — в статье сказано, что она «выбрана по схеме, которая здесь обсуждаться не будет», так что мне пришлось разумно угадать, и я выбрал перекрывающуюся блочно-разреженную структуру. В статье также утверждается, что используется нелинейность tanh, но я опасаюсь, что на самом деле имелся в виду «нормализованный tanh», для которого ntanh(1) = 1, возможно, с добавленным масштабированным skip-соединением — в то время это было модно, чтобы хоть немного градиента просачивалось через плоские хвосты tanh. Наконец, в статье используется «специальная версия метода Ньютона с положительным диагональным приближением гессиана», а я использовал только SGD, потому что он значительно проще, и, как пишет сама статья, «считается, что этот алгоритм не даёт колоссального ускорения обучения».
Жульничество с путешествием во времени. Примерно здесь начинается моя любимая часть. Мы живём на 33 года в будущем, и deep learning — крайне активная область исследований. Насколько можно улучшить оригинальный результат с помощью нашего современного понимания и 33 лет R&D? Мой исходный результат был:
eval: split train. loss 4.073383e-03. error 0.62%. misses: 45 eval: split test . loss 2.838382e-02. error 4.09%. misses: 82
Первое, что меня немного смущало: мы решаем простую классификацию на 10 категорий, но в то время это моделировалось как mean squared error (MSE) — регрессия в таргеты −1 (для отрицательного класса) или +1 (для положительного), причём выходные нейроны тоже имели нелинейность tanh. Поэтому я убрал tanh с выходного слоя, чтобы получить логиты классов, и заменил функцию потерь на стандартную (мультикласс) cross entropy loss. Это изменение драматически улучшило ошибку на обучении — модель полностью переобучилась на тренировочном сете:
eval: split train. loss 9.536698e-06. error 0.00%. misses: 0 eval: split test . loss 9.536698e-06. error 4.38%. misses: 87
Подозреваю, что с инициализацией весов нужно быть гораздо аккуратнее, если на выходном слое стоит насыщающийся tanh и MSE сверху. Дальше — по моему опыту, очень тщательно подкрученный SGD может работать прекрасно, но современный оптимизатор Adam (с learning rate 3e-4, разумеется :)) почти всегда является сильным базовым решением и требует минимум тюнинга. Чтобы убедиться, что оптимизация не сдерживает производительность, я переключился на AdamW с LR 3e-4, плавно понижая его до 1e-4 за время обучения, что дало:
eval: split train. loss 0.000000e+00. error 0.00%. misses: 0 eval: split test . loss 0.000000e+00. error 3.59%. misses: 72
Это немного улучшило результат поверх SGD, но не стоит забывать, что заодно подъехал weight decay через дефолтные параметры, и он помогает бороться с переобучением. Поскольку мы всё ещё сильно переобучаемся, дальше я внедрил простую стратегию аугментации данных: сдвигаю входные изображения на ±1 пиксель по горизонтали или вертикали. Но поскольку это эквивалентно увеличению размера датасета, мне пришлось увеличить количество проходов с 23 до 60 (я проверил, что простое увеличение числа проходов в исходной постановке существенно результаты не улучшает):
eval: split train. loss 8.780676e-04. error 1.70%. misses: 123 eval: split test . loss 8.780676e-04. error 2.19%. misses: 43
Как видно по тестовой ошибке, это очень даже помогло! Аугментация данных — довольно простая и очень стандартная техника борьбы с переобучением, но в статье 1989 года я её не заметил — возможно, это более позднее изобретение (?). Поскольку мы всё ещё немного переобучаемся, я потянулся за ещё одним современным инструментом из арсенала — Dropout. Я добавил слабый dropout с вероятностью 0.25 прямо перед слоем с наибольшим числом параметров (H3). Поскольку dropout зануляет активации, его не очень осмысленно применять с tanh, у которого активный диапазон [−1, 1], так что я заодно поменял все нелинейности на куда более простой ReLU. Так как dropout вносит больше шума во время обучения, обучать приходится дольше — пришлось увеличить число проходов до 80, что дало:
eval: split train. loss 2.601336e-03. error 1.47%. misses: 106 eval: split test . loss 2.601336e-03. error 1.59%. misses: 32
Это сократило ошибки на тесте всего до 32 из 2007! Я проверил, что простая замена tanh → relu в оригинальной сети существенного выигрыша не даёт, так что почти весь прирост здесь — заслуга dropout. В сумме, если бы я отправился в 1989 год, я смог бы сократить долю ошибок примерно на 60% — с ~80 до ~30 промахов и общим уровнем ошибки ~1,5% на тесте. Это улучшение пришло не совсем бесплатно: время обучения тоже выросло почти в 4 раза, что увеличило бы тренировку с 3 дней до почти 12. Зато инференс-латентность бы не пострадала. Оставшиеся ошибки приведены ниже:
Дальше. После замены MSE → Softmax, SGD → AdamW, добавления аугментации, dropout и замены tanh → relu идеи «низковисящих плодов» у меня начали иссякать. Я попробовал ещё несколько вещей (например, weight normalization), но существенного улучшения не получил. Также я попытался уменьшить Visual Transformer (ViT) до «micro-ViT», примерно совпадающего по числу параметров и FLOP, но достичь качества свёрточной сети не удалось. Конечно, за последние 33 года появилось много других инноваций, но большинство из них (например, residual-связи, layer/batch normalization) становятся актуальными только в гораздо более крупных моделях и в основном помогают стабилизировать крупномасштабную оптимизацию. Дальнейшие приросты на этом этапе, скорее всего, требовали бы увеличения размера сети, но это раздуло бы инференс-латентность.
Жульничество с данными. Ещё один способ улучшить производительность — масштабировать датасет, хотя это и обходится в деньги за разметку. Наш исходный базовый репродукционный результат, напомню, был:
eval: split train. loss 4.073383e-03. error 0.62%. misses: 45 eval: split test . loss 2.838382e-02. error 4.09%. misses: 82
Учитывая, что весь MNIST у нас под рукой, можно просто увеличить тренировочный набор примерно в 7 раз (с 7 291 до 50 000 примеров). Запустив базовое обучение на 100 проходов, мы уже видим заметное улучшение только за счёт добавленных данных:
eval: split train. loss 1.305315e-02. error 2.03%. misses: 60 eval: split test . loss 1.943992e-02. error 2.74%. misses: 54
А объединив это с инновациями современных знаний (описанными в предыдущем разделе), мы получаем лучший результат на сегодняшний день:
eval: split train. loss 3.238392e-04. error 1.07%. misses: 31 eval: split test . loss 3.238392e-04. error 1.25%. misses: 24
В сумме, простое масштабирование датасета в 1989 году было бы эффективным способом повысить производительность системы, без каких-либо потерь по инференс-латентности.
Рефлексия. Подытожим, что мы узнали как путешественник во времени из 2022 года, изучающий передовые технологии deep learning 1989 года:
Прежде всего, на макроуровне за 33 года изменилось не так уж много. Мы по-прежнему собираем дифференцируемые архитектуры нейросетей из слоёв нейронов и оптимизируем их end-to-end с помощью обратного распространения и стохастического градиентного спуска. Всё читается удивительно знакомо, только меньше масштабом. Датасет по сегодняшним меркам — младенец: тренировочный сет — всего 7291 чёрно-белое изображение 16×16. Современные датасеты в computer vision обычно содержат сотни миллионов цветных изображений высокого разрешения из веба (например, у Google есть JFT-300M, OpenAI CLIP обучался на 400M), а доходят и до нескольких миллиардов. Это примерно ~1000× больше пиксельной информации на изображение (384·384·3/(16·16)), помноженное на 100 000× больше изображений (1e9/1e4), что даёт грубо 100 000 000× больше пиксельных данных на входе. Сеть тоже младенец: сеть 1989 года имеет ~9760 параметров, 64K MAC и 1K активаций. Современные (vision-) нейросети работают в масштабе нескольких миллиардов параметров (1 000 000×) и O(~1e12) MAC (~10 000 000×). Языковые модели достигают триллионов параметров. Лучший классификатор того времени, на обучение которого уходило 3 дня на рабочей станции, теперь обучается за 90 секунд на моём пассивно охлаждаемом ноутбуке (3000× наивного ускорения), и ещё ~100× выигрыша вполне вероятен при переходе к full-batch-оптимизации и использованию GPU. Я смог подкрутить модель, аугментацию, функцию потерь и оптимизацию на основе современных R&D-инноваций так, чтобы сократить ошибку на 60%, оставив датасет и инференс-латентность модели прежними. Скромные приросты доступны просто за счёт увеличения датасета. Дальнейшие существенные улучшения, скорее всего, потребовали бы более крупной модели, что означает больше вычислений и дополнительный R&D, чтобы стабилизировать обучение на этом масштабе. В частности, если бы меня перенесли в 1989 год, я бы в итоге упёрся в потолок по дальнейшему улучшению системы без более мощного компьютера.
Предположим, что выводы этого упражнения инвариантны во времени. Что это означает для deep learning 2022 года? Что подумал бы путешественник во времени из 2055 года о производительности нынешних сетей?
Нейросети 2055 года на макроуровне в основе своей такие же, как нейросети 2022 года, только больше. Наши датасеты и модели сегодня выглядят как шутка. И те, и другие примерно в 10 000 000× меньше. Современные state-of-the-art модели 2022 года можно обучить за ~1 минуту, наивно запустив их на личном вычислительном устройстве в качестве проекта на выходные. Сегодняшние модели сформулированы неоптимально, и, просто поменяв детали модели, функции потерь, аугментации или оптимизатора, можно примерно вдвое сократить ошибку. Наши датасеты слишком малы, скромные приросты дало бы одно только их масштабирование. Дальнейшие приросты на самом деле невозможны без расширения вычислительной инфраструктуры и без вложений в R&D по эффективному обучению моделей такого масштаба.
Но самый важный тренд, который я хочу отметить, — это то, что сама постановка «обучить нейросеть с нуля под конкретную целевую задачу» (вроде распознавания цифр) быстро устаревает из-за дообучения, особенно с появлением foundation models вроде GPT. Эти foundation-модели тренируют лишь несколько институций с серьёзными вычислительными ресурсами, а большинство приложений достигается лёгким дообучением части сети, prompt engineering или опциональной дистилляцией данных или модели в более компактные специализированные инференс-сети. Думаю, этот тренд будет очень даже жив и, более того, будет только усиливаться. В крайней экстраполяции вы вообще не захотите обучать никакие нейросети. В 2055 году вы будете просить нейросеть-мегамозг размером 10 000 000× выполнить какую-то задачу, обращаясь к ней (или думая к ней) на английском. И если вы попросите достаточно вежливо, она выполнит. Да, вы могли бы и обучить нейросеть… но зачем?