Я придерживаюсь мнения, что если хочешь в чем-то разобраться, то реализуй этой сам. Когда я только начинал заниматься датасаенсом, я разобрался, как считать градиенты на бумажке, перескочил этап реализации сеток на numpy и сразу стал их обучать. Однако, когда спустя долгое я всё-таки решил это сделать, то столкнулся с тем, что не могу это сделать, потому что у меня не сходятся размерности.

Перебрав множество материалов, я остановился на книге Deep Learning from Scratch. Теперь я разобрался, и хочу сделать свой туториал.

На вопрос "Зачем очередной туториал с сеткой на numpy" я отвечу:

  • В туториале сделаны акценты в неочевидных местах, где могут не сходиться размерности;

  • В коде нет абстракций (классы слоёв), чтобы не отвлекать от сути.

Код доступен тут. Также можно посмотреть это видео с курса deep learning на пальцах, чтобы посчитать градиенты на бумажке.

Для того, чтобы обучить нейросеть, нам нужно понимать chain rule (дифференцирование сложной функции). Данное правило описывает, как брать производную композиций функций. Если у нас есть выражение y = f(g(x)), то производная y по x.

\frac{dy}{dx} = \frac{dy}{du} . \frac{du}{dx}, u = g(x)

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

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

w1 = np.random.randn(in_dim, hidden_dim)
b1 = np.zeros((1, hidden_dim))
w2 = np.random.randn(hidden_dim, out_dim)
b2 = np.zeros((1, out_dim))

Нейронную сеть можно представить в виде следующего вычислительного графа:

Вычислительный граф сети
Вычислительный граф сети
y = B(C(D(E(F(X, w1), b1)), w2), b2)

Это как раз композиция, и нам нужно знать производную функции ошибки для всех весов. Они будут такие:

\frac{dL}{db_2} = \frac{dL}{dA} . \frac{dA}{dB} . \frac{dB}{db_2}\frac{dL}{dw_2} = \frac{dL}{dA} . \frac{dA}{dB} . \frac{dB}{dC} . \frac{dC}{dw_2}\frac{dL}{db_1} = \frac{dL}{dA} . \frac{dA}{dB} . \frac{dB}{dC} . \frac{dC}{dD} . \frac{dD}{dE} . \frac{dE}{db_1}\frac{dL}{dw_1} = \frac{dL}{dA} . \frac{dA}{dB} . \frac{dB}{dC} . \frac{dC}{dD} . \frac{dD}{dE} . \frac{dE}{dF} . \frac{dF}{dw_1}

Посчитаем все промежуточные производные:

L = A^2; \frac{dL}{dА} = 2aA = Y_{true} - Y_{pred}; \frac{dA}{dB} = -1

В следующей производной могут возникнуть проблемы с размерностями. Единица здесь - это единичный вектор с размерностью как у С np.ones_like(C).

B = C + b_2; \frac{dB}{dC} = 1

Аналогично np.ones_like(b2).

B = C + b_2; \frac{dB}{db2} = 1

Тут тоже надо быть аккуратным. Так как производная по D, которая стоит слева, то нам нужно транспонировать w2 и ставить его справа при перемножении с другой матрицей np.dot(prev_grad, w2.T).

C = D @ w2; \frac{dC}{dD} = w2^T

Аналогично нам нужно транспонировать D и ставить его слева при перемножении с другой матрицей np.dot(D.T, prev_grad)

C = D @ w2; \frac{dC}{dw2} = D^T

У сигмоиды классная производная.

D = sigmoid(E); \frac{dD}{dE} = sigmoid(E)*(1 - sigmoid(E)

Тут np.ones_like(b1).

E = F + b_1; \frac{dE}{dF} = 1

Тут np.ones_like(b1).

E = F + b_1; \frac{dE}{db1} = 1

Тут нужно транспонировать D и ставить его справа при перемножении с другой матрицей np.dot(prev_grad, X.T)

E = X @ w_1; \frac{dF}{dw1} = X^T

В коде это выглядит так:

dLdA = 2 * A  # (bs, out_dim)
dAdB = -1  # (bs, out_dim)
dBdC = np.ones_like(C)  # (bs, out_dim)
dBdb2 = np.ones_like(self.B2)  # (bs, out_dim)
dCdD = self.W2.T  # (out_dim, hidden_dim)
dCdw2 = D.T  # (hidden_dim, bs)
dDdE = D * (1 - D)  # (bs, hidden_dim)
dEdF = np.ones_like(F)  # (bs, hidden_dim)
dEdb1 = np.ones_like(self.B1)  # (bs, hidden_dim)
dFdw1 = X.T  # (in_dim, bs)

dLdb2 = np.mean(dLdA * dAdB * dBdb2, axis=0, keepdims=True)  # (1, out_dim)
dLdw2 = np.dot(dCdw2, dLdA * dAdB * dBdC)  # (bs, out_dim)
dLdb1 = np.mean(
  np.dot(dLdA * dAdB * dBdC, dCdD) * dDdE * dEdb1, axis=0, keepdims=True
)  # (1, hidden_dim)
dLdw1 = np.dot(
  dFdw1, np.dot(dLdA * dAdB * dBdC, dCdD) * dDdE * dEdF
)  # (bs, in_dim)

Осталось только обновить веса в соответствии с посчитанными градиентами. Так как градиент показывает, какой вклад веса дают, чтобы функция ошибки росла, то мы его вычитаем. То есть, делаем шаг в сторону, чтобы ошибка уменьшалась.

b2 -= self.lr * dLdb2
w2 -= self.lr * dLdw2
b1 -= self.lr * dLdb1
w1 -= self.lr * dLdw1

Как говорится "Охапку дров и перцептрон готов". Надеюсь, этот туториал поможет тем, кто столкнулся с той же проблемой, что и я.

А еще у меня есть телеграм канал, где я рассказываю про сетки с упором в инференс.

Комментарии (22)


  1. defecator
    21.01.2023 22:28
    +1

    Осталось только выкинуть готовые библиотеки и попробовать на элементарном уровне рассказать про то, как строятся нейронные сети. А то начинается херня, как везде, где питон - берём такую библиотеку, берём сяку библиотеку, и вуаля - смотрите, чего я наделал !

    А что там под капотом - покрыто мраком



    1. lair
      21.01.2023 23:24
      +7

      numpy - это библиотека, которая в этом коде считает линейную алгебру. Зачем самостоятельно реализовывать операции над векторами, которые в формуле одним знаком пишутся?


      1. phoaw
        22.01.2023 19:25
        -4

        а что там под капотом у numpy? лапша из кода на древнем фортране, Си и яве. Когда пишешь свой код, да еще применяешь принципы DRY, никакие библиотеки не нужны.


        1. lair
          22.01.2023 19:33
          +1

          а что там под капотом у numpy?

          А какая разница?

          лапша из кода на древнем фортране, Си и яве.

          Не верю я, что в numpy используется Java. Пруфы есть?

          Когда пишешь свой код, да еще применяешь принципы DRY, никакие библиотеки не нужны.

          На C#, скажем, невозможно писать "без библиотек" (технически, наверное, возможно, но требует очень много лишней работы по настройке всего). И это логично, потому что зачем тратить свои усилия на уже решенные задачи (вроде той же математики над векторами), если можно их тратить на те, которые требуют решения?


        1. thevlad
          22.01.2023 20:26
          +1

          Естественно там нативный, максимально оптимизированный код на C. Это как раз позволяет, писать на NumPy быстрый код даже на таком медленном языке как Python. Особенно если вы будете оперировать предоставленными абстракциями, а не спускаться до ручного итерирования элементов.


    1. Dynasaur
      22.01.2023 12:40
      +6

      И питон тоже выкинуть. Настоящие пацаны пишут на ассемблере! А то начинается херня, как везде, и не понятно что там под капотом.


      1. NickDoom
        23.01.2023 22:08

        Смешно, но я такие сетки запускал :)

        Их мне учили всякими матлабами, а я потом для скорости скармливал полученный массив оптимизированному низкоуровневому коду.


    1. vya
      22.01.2023 19:26

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


  1. NickDoom
    21.01.2023 22:44
    +1

    Можно вопрос не совсем по теме?

    Вот тут вот мы видим, как сеть сжимает изображение в одной ей ведомый формат, разжимает, получается что-то похожее (но вместо домика — совершенно другой домик).

    А что у нас со сжатием в детерминированный формат? Допустим, какие-то вейвлеты в таком общем их виде, что «просто взять и разложить» не получится. Или векторизация в контуры с заполнением в виде каких-то гармоник. Или фрактальное сжатие. Или всё вместе — фрактальная иерархия контуров, ограничивающих вейвлеты, чтобы окончательно сойти с ума (шутка).

    В общем, любой формат, который легко декомпрессится без всяких нейросетей, но сжать — адский ад и чуть ли не метод перебора. Какие-то успехи сейчас были на этом фронте? В теории — там хотя бы можно будет доверять результату.


    1. encyclopedist
      21.01.2023 23:50

      Фрактальное кодирование - пример такого алгоритма.


      1. NickDoom
        22.01.2023 00:01

        Ну да, я так и сказал о_О «Или фрактальное сжатие». Пробовал его кто-то сделать на нейросетке?

        Я нагуглил только «We propose use of two different neural network models to implement fractal image compression and decompression», что вызвало у меня ступор — зачем decompression делать тоже на нейросетке, мне решительно непонятно.

        Я это вижу как элементарную сеть (одну), у которой на входе растр, на выходе — что-то типа .FIF, а потом декомпрессор разжимает, а обучалка даёт сети по башке соразмерно ошибке.

        Плюс куча нюансов, которые я могу излить в песочницу — для комментария это будет слишком «войнаимир».


        1. encyclopedist
          22.01.2023 00:05

          Ну да, я так и сказал о_О «Или фрактальное сжатие».

          Я каким-то образом упустил эту фразу в вашем комментарии.

          Пробовал его кто-то сделать на нейросетке?

          Мне такого не попадалось.


          1. NickDoom
            22.01.2023 00:09

            Мерси боку :) Понял :)


        1. PrinceKorwin
          22.01.2023 07:16

          Пробовал его кто-то сделать на нейросетке?

          Будучи студентом я делал фрактальную компрессию и декомпрессию. Удавалось с помощью нее сжимать файлы которые уже были пожаты (jpg, zip, gzip). Проблема была в двух моментах:

          • Очень медленно сжимает. Прям хотелось тогда аппаратного ускорения (привет GPU)

          • Патенты. Это уже было, как оказалось, закрыто патентами


  1. peacemakerv
    22.01.2023 13:06

    А вот объясните как быть, если все нейронки на вход хотят квадратную картинку, скажем до 50К пикселей, а фото объектов в датасете (объект уже обнаружен предыдущей нейронкой) - длинная картинка 700 х 150 пикселов, с мелкими артефактами (не буквами, не символами...) ?
    Т.е. тупо масштабировать картинку вниз, теряя артефакты - глупо. И как быть ?


    1. lgorSL
      23.01.2023 03:18

      Можно вместо уменьшения наоборот увеличить до 700x700 и заполнить края чем-нибудь нейтральным.
      В некоторые виды нейросеток типа свёрточных можно подавать входные изображения разного размера, но к сожалению это сильно зависит используемых от слоёв в сети и ограничений библиотек.


      1. peacemakerv
        23.01.2023 07:45

        Так задача вместится в малый размер квадрата без потери деталей. Я так понимаю, остается только в паззл играть, кромсая картинку на 9 квадратов и собирая из нее нужный квадрат, без масштабирования.


    1. jobber_man
      23.01.2023 09:45
      +1

      Если есть возможность, то делайте свою сетку под 700x150 и обучайте/дообучайте. Веса для сверхточных слоев можно взять из готовой сетки, а остальное дообучить. Практически любая популярная библиотека позволяет работать с любой размерностью изображения. Даже фильтры не обязательно использовать квадратные, можно попробовать, например, прямоугольные, если они подойдут к вашей задаче.

      Если же возможности использовать свою сетку нет, а только использовать готовую, то да, остаются только три варианта: растягивать картинку, заполнять поля до квадрата и нарезать на квадратные куски и потом собирать результат.


    1. DistortNeo
      23.01.2023 11:01

      А это смотря какую задачу нейронная сеть решает и какая у неё архитектура. Надо понимать, что нейронная сеть обучена на работу на определённых масштабах и не способна делать обобщения на другие масштабы в общем случае. Поэтому вам придётся плясать от масштаба. Либо делать ресайз или экстраполяцию, либо обрабатывать потайлово.


    1. yet_another_mle Автор
      23.01.2023 11:10
      +1

      Есть разные варианты. Например, такие:
      - Дополнить картинку до 700х700
      - Дообучить на 700х150
      - Резать картинку на куски по 150х150


  1. gybson_63
    22.01.2023 19:39

    По этой теме порекомендую вот такую статью

    Let’s code a Neural Network in plain NumPy | by Piotr Skalski | Towards Data Science