Как закодировать нейронную сеть только с NumPy

искусственный интеллект алгоритм Нейронные сети NumPy

От к науке о данных

автор:Piotr Skalski

Сборник "Сердце машины"

принимать участие:Гао Сюань, Чжан Цянь

Расширенные фреймворки, такие как Keras, TensorFlow, PyTorch, могут помочь нам быстро создавать сложные модели. Полезно копнуть глубже и понять концепции. Не так давно автор этой статьи опубликовал статью (см.Ресурсы | Глубокие заметки по математике в сети от одноклассников Duxiu, готовы ли вы собрать их?"), в котором кратко объясняется, как работают нейронные сети, но эта статья имеет уклон в сторону математических теоретических знаний. Таким образом, автор намеревается развить эту тему в более практическом ключе. Они попытались построить полностью работающую нейронную сеть, используя только NumPy, протестировали модель, решив простую задачу классификации, и сравнили ее производительность с нейронной сетью, построенной с помощью Keras.

Примечание. Эта статья будет содержать множество фрагментов кода, написанных на Python. Надеюсь, это не слишком скучно читать. :) Весь исходный код можно найти на GitHub автора. Ссылка на сайтGitHub.com/ska skip/IL…

Рисунок 1: Архитектура плотной нейронной сети

Заточка ножей без ошибок рубит столяра

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

Рисунок 2: Блок-схема нейронной сети

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

Рисунок 3: Размеры весовой матрицы W и вектора смещения b для слоя l.

Инициализация слоя нейронной сети

Сначала инициализируйте весовую матрицу W и вектор смещения b каждого слоя. на рисунке 3. Начните с подготовки списка для присвоения соответствующих измерений коэффициентам. Верхний индекс [l] представляет собой индекс текущего слоя (считая с 1), а значение n представляет количество единиц в данном слое. Предположим, что информация, описывающая архитектуру НС, будет передана в программу в виде списка аналогично Фрагменту 1, каждый элемент списка представляет собой словарь, описывающий основные параметры отдельного слоя сети: input_dim — размер входного слоя сигнальный вектор, output_dim — вектор активации выходного слоя. Размер активации — это функция активации, используемая во внутреннем слое.

nn_architecture = [
    {"input_dim": 2, "output_dim": 4, "activation": "relu"},
    {"input_dim": 4, "output_dim": 6, "activation": "relu"},
    {"input_dim": 6, "output_dim": 6, "activation": "relu"},
    {"input_dim": 6, "output_dim": 4, "activation": "relu"},
    {"input_dim": 4, "output_dim": 1, "activation": "sigmoid"},
]

Фрагмент 1: содержит список, описывающий определенные параметры нейронной сети. Этот список соответствует NN, показанному на рисунке 1.

Если вы знакомы с этой темой, то наверняка слышали в своей голове тревожный голос: «Эй, эй! Тут проблема! Некоторые участки лишние…» Да, на этот раз ваш внутренний голос прав. Выходной вектор предыдущего слоя является входом для следующего слоя, поэтому на практике достаточно знать только размер одного вектора. Но я намеренно использовал следующую нотацию, чтобы обеспечить единообразие целей на всех уровнях и облегчить понимание кода для тех, кто плохо знаком с предметом.

def init_layers(nn_architecture, seed = 99):
    np.random.seed(seed)
    number_of_layers = len(nn_architecture)
    params_values = {}

    for idx, layer in enumerate(nn_architecture):
        layer_idx = idx + 1
        layer_input_size = layer["input_dim"]
        layer_output_size = layer["output_dim"]

        params_values['W' + str(layer_idx)] = np.random.randn(
            layer_output_size, layer_input_size) * 0.1
        params_values['b' + str(layer_idx)] = np.random.randn(
            layer_output_size, 1) * 0.1

    return params_values

Фрагмент 2: функция для инициализации весовой матрицы и значений вектора смещения.

Наконец, основная задача этой части — инициализация параметров слоя. Любой, кто видел код во фрагменте 2 и имеет некоторый опыт работы с NumPy, заметит, что матрица W и вектор b заполнены небольшими случайными числами. Эта практика не случайна. Веса не могут быть инициализированы одним и тем же числом, иначе возникнет «проблема симметрии». Если значения владения одинаковы, все единицы в скрытом слое одинаковы независимо от входного X. В какой-то момент мы застреваем в бесконечном цикле на начальных этапах, независимо от того, как долго обучается модель или насколько глубока сеть. Линейная алгебра не отменяет.

В первой итерации использование меньшего значения может повысить эффективность алгоритма. Из графика сигмовидной функции, представленного на рисунке 4, видно, что при больших значениях она практически плоская, что сильно влияет на скорость обучения НС. В заключение, инициализация параметров небольшими случайными числами — это простой способ гарантировать достаточно хорошую отправную точку для нашего алгоритма. Подготовленные значения параметров хранятся в словаре python с уникальной меткой к его родителю. Словарь возвращается в конце функции, поэтому следующим шагом алгоритма является доступ к его содержимому.

Рисунок 4: Функции активации, используемые в алгоритме.

Среди функций, которые мы будем использовать, есть несколько очень простых, но мощных. Функции активации могут быть написаны в одной строке кода, но позволяют нейронным сетям демонстрировать необходимую нелинейную производительность и выразительность. «Без них наша нейронная сеть становится линейной функцией, состоящей из нескольких линейных функций.» Есть много функций активации на выбор, но для этого проекта я решил использовать две — сигмовидную и ReLU. Чтобы иметь возможность получить полный цикл и выполнить как прямое, так и обратное распространение, нам также нужно взять производную.

def sigmoid(Z):
    return 1/(1+np.exp(-Z))

def relu(Z):
    return np.maximum(0,Z)

def sigmoid_backward(dA, Z):
    sig = sigmoid(Z)
    return dA * sig * (1 - sig)

def relu_backward(dA, Z):
    dZ = np.array(dA, copy = True)
    dZ[Z <= 0] = 0;
    return dZ;

Фрагмент 3: функции активации ReLU и Sigmoid и их производные.

прямое распространение

Хорошо спроектированная нейронная сеть имеет простую архитектуру. Информация передается в одном направлении в виде матрицы X через скрытые блоки, в результате чего получается вектор предсказания Y_hat. Для удобства чтения я разбил прямой проход на две отдельные функции — прямой проход для одного слоя и прямой проход для всей NN.

def single_layer_forward_propagation(A_prev, W_curr, b_curr, activation="relu"):
    Z_curr = np.dot(W_curr, A_prev) + b_curr

    if activation is "relu":
        activation_func = relu
    elif activation is "sigmoid":
        activation_func = sigmoid
    else:
        raise Exception('Non-supported activation function')

    return activation_func(Z_curr), Z_curr

Фрагмент 4: шаг прямого распространения одного слоя

Эта часть кода, вероятно, самая простая для понимания. Учитывая входной сигнал предыдущего слоя, мы вычисляем аффинное преобразование Z и затем применяем выбранную функцию активации. Используя NumPy, мы можем воспользоваться преимуществами векторизации, выполняя матричные операции сразу над целыми слоями и пакетами примеров. Это сокращает количество итераций и значительно ускоряет вычисления. Помимо вычисления матрицы A, наша функция также возвращает промежуточное значение Z. Каков эффект? Ответ показан на рисунке 2. Нам нужно использовать Z в обратном распространении.

Рисунок 5: Размеры одной матрицы, используемой в прямом проходе.

После использования предустановленной одноуровневой прямой функции можно легко построить весь прямой проход. Эта функция немного сложнее, и ее роль заключается не только в прогнозировании, но и в управлении набором промежуточных значений. Он возвращает словарь Python, содержащий значения A и Z, вычисленные для определенного слоя.

def full_forward_propagation(X, params_values, nn_architecture):
    memory = {}
    A_curr = X

    for idx, layer in enumerate(nn_architecture):
        layer_idx = idx + 1
        A_prev = A_curr

        activ_function_curr = layer["activation"]
        W_curr = params_values["W" + str(layer_idx)]
        b_curr = params_values["b" + str(layer_idx)]
        A_curr, Z_curr = single_layer_forward_propagation(A_prev, W_curr, b_curr, activ_function_curr)

        memory["A" + str(idx)] = A_prev
        memory["Z" + str(layer_idx)] = Z_curr

    return A_curr, memory

Фрагмент 5: Завершите этапы прямого распространения

функция потерь

Чтобы наблюдать за прогрессом и обеспечить правильное направление, нам обычно нужно вычислить значение функции потерь. «В общем, функция потерь используется для характеристики того, насколько мы близки к «идеальному» решению». Поскольку я планирую протестировать классификацию нашей НС на двух классах точек, выбрана бинарная кросс-энтропия, которая определяется следующим образом. Чтобы получить больше информации о процессе обучения, я решил ввести функцию, которая вычисляет точность.

Фрагмент 6: Функция потерь и расчет точности

обратное распространение

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

def single_layer_backward_propagation(dA_curr, W_curr, b_curr, Z_curr, A_prev, activation="relu"):
    m = A_prev.shape[1]

    if activation is "relu":
        backward_activation_func = relu_backward
    elif activation is "sigmoid":
        backward_activation_func = sigmoid_backward
    else:
        raise Exception('Non-supported activation function')

    dZ_curr = backward_activation_func(dA_curr, Z_curr)
    dW_curr = np.dot(dZ_curr, A_prev.T) / m
    db_curr = np.sum(dZ_curr, axis=1, keepdims=True) / m
    dA_prev = np.dot(W_curr.T, dZ_curr)

    return dA_prev, dW_curr, db_curr

Фрагмент 7: шаг одноуровневого обратного распространения

Люди часто путают обратное распространение с градиентным спуском, но на самом деле это две разные проблемы. Цель первого — эффективно вычислить градиенты, а второго — использовать вычисленные градиенты для оптимизации. В NN мы вычисляем градиент функции стоимости относительно параметров (обсуждавшихся ранее), но обратное распространение можно использовать для вычисления производной любой функции. Суть этого алгоритма заключается в использовании цепного правила в дифференциальном исчислении для вычисления производной комбинированной функции после того, как известны производные каждой функции. Для однослойной сети этот процесс можно описать следующей формулой. В этой статье основное внимание уделяется фактической реализации, поэтому процесс вывода опущен. Из формулы видно, что необходимо заранее запомнить значения матрицы A и матрицы Z промежуточного слоя.


Рисунок 6: Прямое и обратное распространение в одном слое.

Как и в случае с прямым проходом, я решил разделить вычисления на две отдельные функции. Первая функция (Snippnet7) фокусируется на одном слое и сводится к переписыванию приведенной выше формулы в NumPy. Второй представляет собой полное обратное распространение, в основном чтение и обновление значений в трех словарях. Затем вычислите производную функции стоимости для вектора предсказания (результат прямого прохода). Это просто, это просто повторяет приведенную ниже формулу. Затем слои сети обходят с конца и рассчитывают производные всех параметров в соответствии с графиком, показанным на рисунке 6. Наконец, функция возвращает словарь Python с градиентом, который мы хотим найти.

def full_backward_propagation(Y_hat, Y, memory, params_values, nn_architecture):
    grads_values = {}
    m = Y.shape[1]
    Y = Y.reshape(Y_hat.shape)

    dA_prev = - (np.divide(Y, Y_hat) - np.divide(1 - Y, 1 - Y_hat));

    for layer_idx_prev, layer in reversed(list(enumerate(nn_architecture))):
        layer_idx_curr = layer_idx_prev + 1
        activ_function_curr = layer["activation"]

        dA_curr = dA_prev

        A_prev = memory["A" + str(layer_idx_prev)]
        Z_curr = memory["Z" + str(layer_idx_curr)]
        W_curr = params_values["W" + str(layer_idx_curr)]
        b_curr = params_values["b" + str(layer_idx_curr)]

        dA_prev, dW_curr, db_curr = single_layer_backward_propagation(
            dA_curr, W_curr, b_curr, Z_curr, A_prev, activ_function_curr)

        grads_values["dW" + str(layer_idx_curr)] = dW_curr
        grads_values["db" + str(layer_idx_curr)] = db_curr

    return grads_values

Фрагмент 8: полные шаги обратного распространения

обновить значение параметра

Цель этого метода — использовать градиентную оптимизацию для обновления параметров сети, чтобы приблизить целевую функцию к минимуму. Для выполнения этой задачи мы используем два словаря в качестве параметров функции: params_values ​​хранит текущие значения параметров, grads_values ​​хранит производную функции стоимости, вычисленную по параметрам. Хотя этот алгоритм оптимизации очень прост, достаточно применить приведенные ниже уравнения для каждого слоя, он может послужить хорошей отправной точкой для более продвинутых оптимизаторов, поэтому я решил использовать его, что также может стать темой моей следующей статьи.

def update(params_values, grads_values, nn_architecture, learning_rate):
    for layer_idx, layer in enumerate(nn_architecture):
        params_values["W" + str(layer_idx)] -= learning_rate * grads_values["dW" + str(layer_idx)]        
        params_values["b" + str(layer_idx)] -= learning_rate * grads_values["db" + str(layer_idx)]

    return params_values;

Фрагмент 9: Обновление значений параметров с помощью градиентного спуска

комбинированное формование

Самая сложная часть задачи позади, у нас готовы все необходимые функции, осталось только собрать их в правильном порядке. Чтобы лучше понять последовательность операций, необходимо обратиться к таблице на рис. 2. Функция возвращает оптимальные веса после тренировки и изменения веса в течение периода. Прогнозы можно сделать, запустив полный прямой проход, используя только полученную матрицу весов и набор тестовых данных.

def train(X, Y, nn_architecture, epochs, learning_rate):
    params_values = init_layers(nn_architecture, 2)
    cost_history = []
    accuracy_history = []

    for i in range(epochs):
        Y_hat, cashe = full_forward_propagation(X, params_values, nn_architecture)
        cost = get_cost_value(Y_hat, Y)
        cost_history.append(cost)
        accuracy = get_accuracy_value(Y_hat, Y)
        accuracy_history.append(accuracy)

        grads_values = full_backward_propagation(Y_hat, Y, cashe, params_values, nn_architecture)
        params_values = update(params_values, grads_values, nn_architecture, learning_rate)

    return params_values, cost_history, accuracy_history

Фрагмент 10: Обучение модели

David vs Goliath

Теперь пришло время проверить производительность нашей модели на простой задаче классификации. Я создал набор данных, состоящий из двух классов точек, как показано на рисунке 7. Затем позвольте модели научиться классифицировать два класса точек. Для сравнения я также написал модель Keras в высокоуровневом фреймворке. Обе модели имеют одинаковую архитектуру и скорость обучения. Тем не менее, это сравнение немного несправедливо, потому что подготовленный нами тест был слишком упрощенным. В итоге и модель NumPy, и модель Keras достигли точности 95% на тестовом наборе, но нашей модели потребовалось в десятки раз больше времени для достижения такой точности. На мой взгляд, такое состояние в основном связано с отсутствием должной оптимизации.


Рисунок 7: Тестовый набор данных

Рисунок 8: Визуализация границ классификации, достигнутых обеими моделями

Оригинальная ссылка:к data science.com/lets-code-ah…