В нашем предыдущем руководстве мы представили Deep Learning (DL) и попытались понять искусственные нейронные сети (ANN) более подробно: в частности, уделяя особое внимание нейронным сетям с глубокой прямой связью (FFNN), также известным как многослойные персептроны (MLP).

Мы закончили обучением рисовать простую картинку.

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

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

вход (x) → выход (y)

Теперь эти значения могут быть числовыми: например, координаты x-y пикселя: x = (1,1) → значения цвета RGB: y = (225,225,225)

Эти значения могут быть даже символическими, например x = «конечно, Джон получает удовольствие от игры» → y = «естественная шляпа, Джон Спасс Ам Шпиль».

Но, в конце концов, есть некоторые входные значения (x), которые превращаются в некоторые выходные значения (y). Первоначально исследования искусственного интеллекта (ИИ) были в значительной степени сосредоточены на символических правилах, чтобы определить, как эти входные данные (x) должны быть изменены на желаемые выходы (y).

Это было прекрасное начало и работало по большей части, но кодирование правил требует опыта в этой конкретной области, и исследования начали переключаться на автоматизацию этой трудоемкой части, и были разработаны методы машинного обучения (ML) для автоматического изучения этих правил (например, Inductive Logic Программирование ILP).

Конечно, зачем ограничиваться изучением только символических правил? Мы могли бы представить себе «правило» как математическую функцию (y = f ( x )), которая преобразует конкретное входное значение (x) в желаемое выходное значение (y).

Фактически, преобразовав проблему в несимвольную, у нас внезапно появилось множество численных алгоритмов, разработанных в областях, не связанных с ИИ и информатикой (например, линейная регрессия, заимствованная из статистики, или многоагентные системы, заимствованные из науки о сложности, и т. Д.) .

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

Итак, как именно нейронная сеть изучает функцию?

В нашем предыдущем посте вы могли заметить, что каждый нейрон уже включает простую функцию (например, сигмовидную, tanh, relu и т. Д.).

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

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

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

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

Чтобы проиллюстрировать эту концепцию, в приведенном ниже примере показаны явные функции, которые объединяются, чтобы создать границу принятия решения, подобную Бэтмену:

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

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

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

Как только мы узнаем, как отрегулировать веса и смещение, чтобы уменьшить ошибку (т. Е. Двигаться вниз по наклону / спускаться по градиенту), мы быстро придем к глобальным минимумам (т. Е. Значениям весов и смещения которые производят минимально возможную ошибку)

Эффективный способ вычисления градиента нейронной сети известен как обратное распространение.

Однако на практике все не так просто. Этот простой метод может привести к попаданию в ловушку локальных минимумов (т. Е. Значений весов и смещения, которые дают небольшую ошибку по сравнению с ошибкой небольших корректировок здесь и там, но общая комбинация значений неоптимально):

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

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

Популярным решением этой проблемы является использование адаптивной скорости обучения (которая регулируется в зависимости от обстоятельств).

Существуют различные адаптивные скорости обучения, которые были разработаны

Конечно, когда мы говорим о поиске глобального минимума, на самом деле мы говорим об очень хорошо изученной области ИИ, известной как оптимизация. На мой взгляд, одними из наиболее интересных алгоритмов оптимизации являются многоагентные методы (те, которые предполагают совместную работу нескольких агентов для более эффективного изучения пространства поиска), например:
- Оптимизация колоний муравьев (ACO)
- Оптимизация роя частиц (PSO)
- Генетические алгоритмы (GA)
- Эволюционные алгоритмы (EAs)
- Дифференциальная эволюция
- Алгоритм поиска с кукушкой
- Алгоритм поиска Fireworks
- Меметические алгоритмы
- и т. Д. И т. Д.

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

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

Он часто используется в обучении с подкреплением.

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

Основная идея использования GA для развития нейронных сетей:

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

  • Затем отбросьте наихудшие варианты (с низкой степенью пригодности) и оставьте наиболее приспособленные, чтобы перейти к следующему поколению

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

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

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

Давайте создадим GA, чтобы развить веса (и топологию) глубокой FFNN для той же задачи рисования, которую мы видели раньше:

class FFNN:
  def __init__(self, weights):
    self.weights = weights
  
  def _f(self,x): 
    return 1. / (1. + np.exp(-x))
  
  def __call__(self, x): 
    for w in self.weights:
      x = self._f(x @ w)
    return x
class GA:
  evolutionary_history = [0]
  n_hidden = 50
  alpha = .01
  max_generations = 300
  population_size = 40
  p_perturb_weight = .5
  p_mutate_weight = .05
  p_mutate_layer = .5
  p_add_layer = .3
def __init__(self,n_inputs,n_outputs,x_test,y_test):
    self.n_inputs, self.n_outputs = n_inputs,n_outputs 
    self.genomes = [[np.random.randn(self.n_inputs,self.n_hidden),np.random.randn(self.n_hidden,self.n_outputs)] for _ in range(self.population_size)]  
    self.x_test = x_test
    self.y_test = y_test
    
  def fitness(self, weights):
    ann = FFNN(weights)
    y_predicted = np.array([ann(x) for x in self.x_test])
    rms_error = np.sum((self.y_test - y_predicted)**2)**.5 / len(self.y_test)
    return 1-rms_error
  
  def rank(self,genomes):
    self.fitnesses = [self.fitness(genome) for genome in genomes]
    self.evolutionary_history.append( max(self.fitnesses) )
    return [genomes[i] for _,i in sorted(zip(self.fitnesses,[i for i in range(len(self.fitnesses))]), reverse=True)] 
def mutate(self,genomes):
    def noise(x):
      return x + np.random.uniform(-self.alpha,self.alpha) if np.random.random() <= self.p_perturb_weight else np.random.uniform(-1,1) if np.random.random() <= self.p_mutate_weight else x
    noise = np.vectorize(noise)
for i,weights in enumerate(genomes):   
      genomes[i] = [noise(layer) if random.random() <= self.p_mutate_layer else layer for layer in weights] 
      if np.random.random() <= self.p_add_layer: 
        pointer = np.random.randint(1,len(weights)) 
        genomes[i] = weights[:pointer] + [np.random.randn(self.n_hidden,self.n_hidden)] + weights[pointer:]                  
    return genomes
  
  def crossover(self,genome1,genome2): 
    pointer = np.random.randint(1,self.n_hidden)
    for i in range(1,min(len(genome1),len(genome2))-1):
      _genome1 = np.concatenate( (genome1[i][:pointer],genome2[i][pointer:]) )
      _genome2 = np.concatenate( (genome2[i][:pointer],genome1[i][pointer:]) )
      genome1[i],genome2[i] = _genome1,_genome2
    return genome1, genome2
  
  def mate(self,genome1,genome2):
    child1,child2 = self.crossover(genome1,genome2)
    return genome1, genome2, child1, child2 
  
  def g_algorithm(self):
    self.best_genome = self.rank(self.genomes)[0]
    fitness_total = sum(self.fitnesses)
    elite_indexes = np.random.choice(len(self.genomes), (self.population_size // 4) -1, p= [f/fitness_total for f in self.fitnesses]) 
    elites = [self.genomes[i] for i in elite_indexes]
    offspring = [child for parent1,parent2 in zip(elites,random.sample(elites, len(elites))) for child in self.mate(parent1,parent2) ]  
    self.genomes = self.mutate(offspring) + [self.best_genome] + [[np.random.randn(self.n_inputs,self.n_hidden), np.random.randn(self.n_hidden,self.n_outputs)] for _ in range(3)] 
    
  def evolve(self):
    for generation in range(self.max_generations):
      self.g_algorithm()
      print(generation, self.evolutionary_history[-1])

Еще один интересный вариант нейронной сети - это тот, который очень быстро обучается! Этот вариант стал известен как (хотя и несколько спорно) Машина экстремального обучения (ELM).

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

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

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

class ELM:
  def __init__(self, n_inputs: int, n_hidden = 1000):
    self.random_weights = np.random.uniform(low=-.1, high=.1, size =[n_inputs, n_hidden])
    
  def learn(self, X: np.ndarray, Y: np.ndarray):
    H = self._hidden_layer(X)
    self.output_weights = np.linalg.pinv(H) @ Y
    
  def _f(self, x: np.ndarray): 
    return 1. / (1. + np.exp(-x)) #activation function: sigmoid
    
  def _hidden_layer(self, inputs: np.ndarray): 
    return self._f(inputs @ self.random_weights)
  
  def _output_layer(self, hidden: np.ndarray): 
    return hidden @ self.output_weights
  
  def __call__(self, inputs: np.ndarray):  #infer
    return self._output_layer(self._hidden_layer(inputs))

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

Еще вы можете заметить, что точность ELM не так хороша, как у Deep FFNN, обученного с помощью обратного распространения, однако некоторые утверждают, что это не обязательно плохо. Одна из критических замечаний по поводу глубокого обучения - это тенденция к чрезмерному соответствию обучающим данным. Вот где он стал слишком хорошо изучать данные примера, которые он больше не может хорошо обобщать на новые данные. (Вы можете думать о переобучении как о запоминании обучающих примеров, а не о изучении общих тенденций)

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

Мой личный фаворит - это вариант, который полностью переделывает нейрон с нуля. Нейрон иерархической временной памяти (HTM) предназначен для более точного моделирования биологических нейронов (например, нейронов в человеческом мозгу).

p.s. Весь код, используемый в этом блоге, можно запустить через Google Colab здесь: https://colab.research.google.com/github/mohammedterry/ANNs/blob/master/ANN_NeuroEvo_ELM.ipynb