Искусственные нейронные сети (ИНС) сейчас очень популярны, и это правильно.

Они используются повсеместно в крупных технологических компаниях. Например, когда вы используете переводчик Google или когда рекомендации появляются в вашей ленте Netflix, за кулисами используются сложные искусственные нейронные сети. За успехом Альфа Го в игре Го против Ли Седоля для определения следующего лучшего хода использовалась ИНС.

В этой статье я расскажу о причинах популярности нейронных сетей.

Спойлер: это связано с тем, что ИНС являются универсальными аппроксиматорами функций.

Я включил код Julia, чтобы проиллюстрировать, как все работает на практике. Я предпочитаю инструмент Julia, потому что он очень быстрый и становится все более популярным языком программирования (несложно научиться, если вы уже используете Python). Для задач машинного обучения Flux.jl — действительно хороший вариант, поэтому воспользуемся и им. Скачать код можно здесь.

I. Немного теории

Архитектура

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

См. график ниже:

Источник: https://en.wikipedia.org/wiki/Artificial_neural_network#/media/File:Artificial_neural_network.svg

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

На практике так оно и есть. Более того, у нас есть несколько теорем, которые говорят нам, что ИНС действительно хороши.

Теоремы универсального приближения

Позвольте мне описать два важных документа. Ниже я воспроизвожу некоторые избранные части их тезисов:

Хорник, Стинчкомб и Уайт (1989)

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

Бэррон (1993)

«Показано, что сети с прямой связью с одним слоем сигмоидальных нелинейностей достигают интегральной квадратичной ошибки порядка O(1/n), где n — количество узлов. […] Для рассматриваемого класса функций здесь скорость аппроксимации и экономичность параметризации сетей на удивление выгодны в многомерных настройках».

В статье Хорника Стинчкомба и Уайта (1989) говорится, что очень большой класс функций может быть аппроксимирован ИНС с архитектурой, которую мы представили выше. Базовая функция, которую мы стремимся аппроксимировать, должна быть «измеримой по Борелю» (из одного конечномерного пространства в другое), что содержит почти все полезные функции, которые вы используете в экономике (непрерывные функции из одного конечномерного пространства в другое — это борелевские функции). измеримые функции).

В статье Баррона (1993) говорится, что ИНС являются особенно хорошими аппроксиматорами при работе со многими измерениями. Иными словами, ИНС может помочь смягчить проклятие размерности. Один из способов понять проклятие размерности состоит в том, что количество точек, необходимых для аппроксимации функции, растет экспоненциально с количеством измерений, а не линейно. Мы хотели бы объяснить сложное явление со многими измерениями и взаимодействиями, но традиционные методы аппроксимации обычно плохо работают в таких условиях.

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

II. Приложение

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

II. А. Простая функция

Давайте загрузим несколько полезных пакетов и определим функцию, которую мы хотели бы аппроксимировать.

# Dependencies
using Flux
using Plots
using LinearAlgebra
using ProgressMeter
using Statistics
using LaTeXStrings
using Surrogates
gr()
# Define function that we would like to learn with our neural network
f(x) = x[1].^2 + x[2].^2
f (generic function with 1 method)

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

n_samples = 100
lower_bound = [-1.0, -1.0]
upper_bound = [1.0, 1.0]
xys = Surrogates.sample(n_samples, lower_bound, upper_bound, SobolSample())
rawInputs = xys
rawOutputs = [[f(xy)] for xy in xys] # Compute outputs for each input
trainingData = zip(rawInputs, rawOutputs);

Теперь самое интересное — определить архитектуру нашей ИНС. Я выбираю два скрытых слоя. Количество узлов для первого слоя определяется размерностью входных данных (двухмерный вектор), а также размерностью конечного узла (скаляр). Нам все еще нужно выбрать количество узлов между ними. Для первого скрытого слоя я выбираю 784 узла и 50 для второго скрытого слоя. Справедливости ради, эти выборы немного случайны (на меня повлиял учебник по Flux.jl здесь). Не стесняйтесь экспериментировать с разными значениями.

# Define the neural network layers (this defines a function called model(x))
# Specify our model
dim_input = 2
dim_ouptut = 1
Q1 = 784;
Q2 = 50;
# Two inputs, one output
model = Chain(Dense(2,Q1,relu),
            Dense(Q1,Q2,relu),
            Dense(Q2,1,identity));

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

Вот интуитивное объяснение градиентного спуска. Представьте, что вы находитесь на вершине горы, а вокруг много тумана, который мешает вам видеть далеко. Ты очень хочешь спуститься. Что вы должны сделать?

Одна из стратегий заключается в том, чтобы посмотреть, где вы стоите, и оценить направление наибольшего спуска в окрестности вашего местоположения (вы не можете видеть далеко). Тогда сделайте шаг в этом направлении. Затем повторите процесс. Если гора «хорошо себя ведет» (у нее нет локальных минимумов), вам удастся спуститься с горы, даже если вы просто использовали локальную информацию на каждом шагу. (Перейдите в самый низ этого сообщения в блоге, чтобы увидеть иллюстрацию градиентного спуска для очень простой задачи).

# Define loss function and weights
loss(x, y) = Flux.Losses.mse(model(collect(x)), y)
lr = 0.001 # learning rate
# V1. Gradient descent
opt = Descent(lr)
# V2. ADAM
#decay = 0.9
#momentum =0.999
#opt = ADAM(lr, (decay, momentum))
epochs = 1000 # Define the number of epochs
trainingLosses = zeros(epochs);# Initialize a vector to keep track of the training progress

Далее следует самый полезный шаг: обучающая часть. Следующий блок кода выполняет градиентный спуск. Функция Flux.train! использует все наблюдения, которые есть в нашей выборке, один раз. Поскольку одной итерации недостаточно для достижения минимума, мы повторяем процесс несколько epochs. После каждой эпохи мы вычисляем среднеквадратичную ошибку, чтобы увидеть, насколько хорошо работает модель.

ps = Flux.params(model) #initialize weigths
p = Progress(epochs; desc = "Training in progress"); # Creates a progress bar
showProgress = true
# Training loop
@time for ii in 1:epochs
    Flux.train!(loss, ps, trainingData, opt)
    # Update progress indicator
    if showProgress
        trainingLosses[ii] = mean([loss(x,y) for (x,y) in trainingData])
        next!(p; showvalues = [(:loss, trainingLosses[ii]), (:logloss, log10.(trainingLosses[ii]))], valuecolor = :grey)
    end
end
 24.753884 seconds (41.00 M allocations: 37.606 GiB, 6.56% gc time, 0.48% compilation time)

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

II.Б. Более сложная функция

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

Даже с более сложной функцией наша ИНС отлично справляется с приближением истинной функции, как вы можете видеть на графике ниже.

function ackley(x; e = exp(1), a = 10.0, b = -0.2, c=2.0*π)
    #a, b, c = 10.0, -0.2, 2.0*π
    len_recip = inv(length(x))
    sum_sqrs = zero(eltype(x))
    sum_cos = sum_sqrs
    for i in x
        sum_cos += cos(c*i)
        sum_sqrs += i^2
    end
    return -a * exp(b * sqrt(len_recip*sum_sqrs)) - exp(len_recip*sum_cos) + a + e
end
n_samples = 1000
lower_bound = [-2.0, -2.0]
upper_bound = [2.0, 2.0]
xys = Surrogates.sample(n_samples, lower_bound, upper_bound, SobolSample())
rawInputs = xys
rawOutputs = [[ackley(xy)] for xy in xys] # Compute outputs for each input
trainingData = zip(rawInputs, rawOutputs);
# Define the neural network layers (this defines a function called model(x))
# Specify our model
Q1 = 784;
Q2 = 50;
Q3 = 10;
# Two inputs, one output
model = Chain(Dense(2,Q1,relu),
            Dense(Q1,Q2,relu),
            Dense(Q2,1,identity));
# Define loss function and weights
loss(x, y) = Flux.Losses.mse(model(collect(x)), y)
ps = Flux.params(model)
# Train the neural network
epochs = 1000
showProgress = true
lr = 0.001 # learning rate
# Gradient descent
opt = Descent(lr)
trainingLosses = zeros(epochs) # Initialize vectors to keep track of training
p = Progress(epochs; desc = "Training in progress") # Creates a progress bar
@time for ii in 1:epochs
    Flux.train!(loss, ps, trainingData, opt)
    # Update progress indicator
    if showProgress
        trainingLosses[ii] = mean([loss(x,y) for (x,y) in trainingData])
        next!(p; showvalues = [(:loss, trainingLosses[ii]), (:logloss, log10.(trainingLosses[ii]))], valuecolor = :grey)
    end
end
242.064635 seconds (407.63 M allocations: 375.931 GiB, 6.81% gc time, 0.04% compilation time)

Заключение

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

Этот пост в блоге изначально был размещен здесь, на моем веб-сайте:

https://julienpascal.github.io/post/ann_1/

Дополнительно: градиентный спуск визуально

Ниже приведена иллюстрация градиентного спуска. Мы хотим найти минимум функции J(x)=x^2 и начинаем с точки -20.

Алгоритм работает итеративно:

  1. Вычислите градиент при текущем значении. Это дает нам направление максимального изменения функции J.
  2. Поскольку мы ищем минимум, а не максимум, сделайте шаг в направлении, противоположном максимальному изменению.
  3. Повторите шаги 1–2
using GradDescent
#Code from here: https://jacobcvt12.github.io/GradDescent.jl/stable/
#I made only slight modifications to the original code
# objective function and gradient of objective function
J(x) = x^2
dJ(x) = 2*x
# number of epochs
epochs = 150
# instantiation of Adagrad optimizer with learning rate of 2
opt = Adagrad(η=2.0)
# initial value for x (usually initialized with a random value)
x = -20.0 #initial position on the function
values_x = zeros(epochs) #initialization
value_y = zeros(epochs) #initialization
iter_x = zeros(epochs) #initialization
for i in 1:epochs
    # Save values for plotting
    values_x[i] = x
    value_y[i] = J(x)
    iter_x[i] = i
    # calculate the gradient wrt to the current x
    g = dJ(x)
    # change to the current x
    δ = update(opt, g)
    x -= δ
end

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

plot(values_x, value_y, label="J(x)")
scatter!(values_x, value_y, marker_z = iter_x, color = cgrad(:thermal, rev = true), label="Position", colorbar_title="Iteration")
xlabel!(L"x")
ylabel!(L"J(x)")