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

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

Два визуала, которые я хочу попробовать создать:

  1. Ряд соединенных узлов, где узлы представляют выходные значения каждого слоя, а соединения представляют значения весовых матриц (аналогично визуальному 3Blue1Brown, используемому в этом видео).
  2. Тепловая карта, которая показывает веса, смещения и выходные данные для каждого слоя (аналогична той, которую я создал для своей предыдущей статьи, но динамическая!)

Эта статья (часть 1 из 2) будет посвящена построению визуального представления подключенных узлов, а тепловая карта будет рассмотрена во второй статье.

Вдохновляющие функции из предыдущей статьи

Я собираюсь начать с простого переноса ряда функций, созданных в предыдущей статье, в файл Python с именем scene.py. К ним относятся следующие:

# Relevant imports
from manim import * # added this
import numpy as np
import pandas as pd

# Activation functions
def relu(X):
    return np.maximum(0,X)

# def softmax(X):
#     return np.exp(X)/sum(np.exp(X))

# stable version of the softmax
def softmax(X):
    Z = X - max(X)
    numerator = np.exp(Z)
    denominator = np.sum(numerator)
    return numerator/denominator

# Calculates the output of a given layer
def calculate_layer_output(w, prev_layer_output, b, activation_type="relu"):
    # Steps 1 & 2
    g = w @ prev_layer_output + b

    # Step 3
    if activation_type == "relu":
        return relu(g)
    if activation_type == "softmax":
        return softmax(g)

# Initialize weights & biases
def init_layer_params(row, col):
    w = np.random.randn(row, col)
    b = np.random.randn(row, 1)
    return w, b

# Calculate ReLU derivative
def relu_derivative(g):
    derivative = g.copy()
    derivative[derivative <= 0] = 0
    derivative[derivative > 0] = 1
    return np.diag(derivative.T[0])

def layer_backprop(previous_derivative, layer_output, previous_layer_output
                   , w, activation_type="relu"):
    # 1. Calculate the derivative of the activation func
    dh_dg = None
    if activation_type == "relu":
        dh_dg = relu_derivative(layer_output)
    elif activation_type == "softmax":
        dh_dg = softmax_derivative(layer_output)

    # 2. Apply chain rule to get derivative of Loss function with respect to:
    dL_dg = dh_dg @ previous_derivative # activation function

    # 3. Calculate the derivative of the linear function with respect to:
    dg_dw = previous_layer_output.T     # a) weight matrix
    dg_dh = w.T                         # b) previous layer output
    dg_db = 1.0                         # c) bias vector

    # 4. Apply chain rule to get derivative of Loss function with respect to:
    dL_dw = dL_dg @ dg_dw               # a) weight matrix
    dL_dh = dg_dh @ dL_dg               # b) previous layer output
    dL_db = dL_dg * dg_db               # c) bias vector

    return dL_dw, dL_dh, dL_db

def gradient_descent(w, b, dL_dw, dL_db, learning_rate):
    w -= learning_rate * dL_dw
    b -= learning_rate * dL_db
    return w, b

def get_prediction(o):
    return np.argmax(o)

# Compute Accuracy (%) across all training data
def compute_accuracy(train, label, w1, b1, w2, b2, w3, b3):
    # Set params
    correct = 0
    total = train.shape[0]

    # Iterate through training data
    for index in range(0, total):
        # Select a single data point (image)
        X = train[index: index+1,:].T

        # Forward pass: compute Output/Prediction (o)
        h1 = calculate_layer_output(w1, X, b1, activation_type="relu")
        h2 = calculate_layer_output(w2, h1, b2, activation_type="relu")
        o = calculate_layer_output(w3, h2, b3, activation_type="softmax")

        # If prediction matches label Increment correct count
        if label[index] == get_prediction(o):
            correct+=1

    # Return Accuracy (%)
    return (correct / total) * 100


# Calculate Softmax derivative
def softmax_derivative(o):
    derivative = np.diag(o.T[0])

    for i in range(len(derivative)):
        for j in range(len(derivative)):
            if i == j:
                derivative[i][j] = o[i] * (1 - o[i])
            else:
                derivative[i][j] = -o[i] * o[j]
    return derivative

Настройка Манима

Если вы пытаетесь следовать этой статье, делая это самостоятельно, вам необходимо установить Manim (подробности здесь). Библиотека Manim вращается вокруг класса Scene. К этой сцене мы можем добавлять объекты, которые затем можно анимировать различными способами. Чтобы использовать эту Scene, нам нужно создать собственный класс, который наследует класс Scene, предоставленный Manim — назовем наш класс VisualiseNeuralNetwork и добавьте его в наш файл scene.py. Когда мы запускаем файл, содержащий наш класс, с помощью Manim, он будет искать метод construct, который должен включать инструкции для начала построения сцены. Для нашего метода construct мы начнем с инициализации параметров нейронной сети (все они взяты из моей предыдущей статьи):

class VisualiseNeuralNetwork(Scene):

    def construct(self):
        ### INITIALISE NEURAL NET PARAMETERS ###
        # Extract MNIST csv data into train & test variables
        train = np.array(pd.read_csv('train.csv', delimiter=','))
        test = np.array(pd.read_csv('test.csv', delimiter=','))

        # Extract the first column of the training dataset into a label array
        label = train[:, 0]
        # The train dataset now becomes all columns except the first
        train = train[:, 1:]

        # Initialise vector of all zeroes with 10 columns and the same number
        # of rows as the label array
        Y = np.zeros((label.shape[0], 10))

        # assign a value of 1 to each column index matching the label value
        Y[np.arange(0, label.shape[0]), label] = 1.0

        # Normalize test & training dataset
        train = train / 255
        test = test / 255

        # Set hyperparameter(s)
        learning_rate = 0.01

        # Set other params
        epoch = 0
        previous_accuracy = 100
        accuracy = 0

        # Randomly initialize weights & biases
        w1, b1 = init_layer_params(10, 784)  # Hidden Layer 1
        w2, b2 = init_layer_params(10, 10)  # Hidden Layer 2
        w3, b3 = init_layer_params(10, 10)  # Output Layer

Введите изображение

Моя цель — показать для заданного входного изображения цифры, какие узлы и соединения затронуты в разные эпохи обучения. Для этого нам нужно сначала показать наше изображение в сцене. Мы можем сделать это довольно просто с Manim, создав сетку 28x28 белых квадратов, а затем используя (нормализованные) значения пикселей в градациях серого из изображения в нашем наборе обучающих данных для непрозрачности каждого квадрата. Для этого мы создадим метод в нашем классе VisualiseNeuralNetwork, например:

def create_input_image(self, training_image, left_shift):
    # Initialise params
    square_count = training_image.shape[0]
    rows = np.sqrt(square_count)

    # Create list of squares to represent pixels
    squares = [
        Square(fill_color=WHITE
               , fill_opacity=training_image[i]
               , stroke_width=0.5).scale(0.03)
        for i in range(square_count)
    ]

    # Place all the squares into a VGroup and arrange into a 28x28 grid
    group = VGroup(*squares).arrange_in_grid(rows=int(rows), buff=0)

    # Shift into correct position in the scene
    group.shift(left_shift * LEFT)

    return group

Обратите внимание, что мы можем расположить всю группу квадратов, используя метод «.shift()» и передав его кратно LEFT. Это кратное указывает, насколько мы хотим сместить нашу группу влево.

Теперь мы можем использовать анимацию Manim Create, чтобы анимировать создание этого изображения в нашей сцене. Затем мы воспроизводимэту анимацию, передавая анимации Create метод сцены play. Давайте добавим следующие строки в наш метод construct и посмотрим, что у нас получится:

# Create input image
training_image = train[0:1, :].T
input_image = self.create_input_image(training_image, left_shift=5)

# Play Create animation and then wait for 2 seconds
self.play(Create(input_image))
self.wait(2)

После того, как мы добавили вышеуказанное в метод construct, мы можем выполнить наш файл scecne.py из терминала с помощью команды Manim:

manim -pql scene.py

Это дает нам следующую анимацию:

Узлы

Далее мы напишем метод для создания узлов в каждом слое нейронной сети. Он примет в качестве аргументов left_shit и down_shift, чтобы мы могли позиционировать группу узлов, а также num_nodes, что позволит нам контролировать количество узлов, которые мы хотим создать. Мы также добавим аргумент layer_output, когда мы хотим, чтобы непрозрачность узла определялась значениями вывода слоя. Простой способ вычисления непрозрачности каждого узла — использование нормализованного значения выходных данных каждого слоя.

def create_nodes(self, left_shift, down_shift, num_nodes, layer_output=None):
  # Create VGroup & list to hold created nodes
  node_group = VGroup()
  nodes = []

  # Create list of circles to represent nodes
  for i in range(num_nodes):
      # Set fill opacity to 0
      opacity = 0.0
      text = "0.0"
      # If a layer output has been passed and the max value is not 0
      if layer_output is not None and np.max(layer_output) != 0.0:
          # Set opacity as normalised layer output value
          opacity = (layer_output[i] / np.max(np.absolute(layer_output)))[0]
          # Set text as layer output
          text = f'{layer_output[i][0]:.1f}'

      # Create node
      node = Circle(radius=0.23
          , stroke_color=WHITE
          , stroke_width=0.7
          , fill_color=GRAY
          , fill_opacity=opacity
          )

      # Add to nodes list
      nodes += [node]

      fill_text = Text(text, font_size=12)
      # Position fill text in circle
      fill_text.move_to(node)

      # Group fill text and node and add to node_group
      group = VGroup(node, fill_text)
      node_group.add(group)


  # Arrange & position node_group
  node_group.arrange(DOWN, buff=0.2)
  node_group.shift(left_shift * LEFT).shift(down_shift * DOWN)

  return node_group, nodes

Как и прежде, мы можем добавить следующие строки в наш метод construct для воспроизведения нашей анимации:

# Create the nodes for each of the layers
num_nodes = 10
h1_node_group, h1_nodes_list = self.create_nodes(3, 0.25, num_nodes)
h2_node_group, h2_nodes_list = self.create_nodes(0, 0.25, num_nodes)
o_node_group, o_nodes_list = self.create_nodes(-3, 0.25, num_nodes)

# Play Create animations and then wait for 2 seconds
self.play(Create(input_image)
          , Create(h1_node_group)
          , Create(h2_node_group)
          , Create(o_node_group)
          )
self.wait(2)

Выполняя наш файл scecne.py снова с помощью Manim, мы получаем следующее:

Подключения

Связи между узлами будут представлять веса различных матриц весов, используемых для вычисления выходных данных слоя. Для простоты мы опустим первую весовую матрицу 784x10, которая использовалась для расчета выходных данных первого слоя, чтобы не загромождать визуал! Непрозрачность каждого из соединений снова будет определяться соответствующим значением в матрице весов (то есть после нормализации). Матрица весов отличается от выходных данных слоя тем, что она может включать отрицательные значения. Чтобы справиться с этим, мы будем использовать цвет, чтобы различать положительные и отрицательные значения; Зеленый для значений больше или равных нулю и красный для значений меньше 0.

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

Выходное значение каждого узла представляет собой взвешенную сумму всех узлов в предыдущем слое, поэтому для правильного представления мы хотим, чтобы наши соединения выполнялись от узла с правой стороны к каждому из узлов с левой стороны. Мы можем сделать это легко, сначала перебирая узлы с правой стороны, а затем через каждый из узлов с левой стороны, создавая соединение для каждого по мере продвижения. Каждое соединение будет нарисовано с использованием объекта Line, предоставленного Manim.

Для каждой строки мы получаем начальную позицию и конечную позицию, просто используя метод «get_edge_center()» на нужном узле (либо узел в слое слева, либо узел в слое справа). Мы передаем этому методу «get_edge_center» параметр (ЛЕВЫЙ или ПРАВЫЙ), в зависимости от того, к какой стороне кругового узла мы хотим присоединить линию соединения (мы хотим, чтобы центр ЛЕВОГО края был для правого узла, а центр ПРАВОГО края — для левого узла). . Это нарисует нашу линию соединения от среднего левого края выбранного узла в правом слое до среднего правого края выбранного узла в левом слое.

Это довольно сложное объяснение, но оно должно стать яснее, если вы выполните приведенный ниже код:

def create_connections(self, left_layer_nodes, right_layer_nodes, w):
  # Create VGroup to hold created connections
  connection_group = VGroup()

  # Iterate through right layer nodes
  for l in range(len(right_layer_nodes)):
      # Iterate through left layer nodes
      for r in range(len(left_layer_nodes)):
          # Calculate opacity from normalised weight matrix values
          opacity = 0.0 if np.max(np.absolute(w[l, :])) == 0.0 \
              else w[l, r] / np.max(np.absolute(w[l, :]))
          # Set colour
          colour = GREEN if opacity >= 0 else RED

          # Create connection line
          line = Line(start=right_layer_nodes[l].get_edge_center(LEFT)
                      , end=left_layer_nodes[r].get_edge_center(RIGHT)
                      , color=colour
                      , stroke_opacity=abs(opacity)
                      )

          # Add to connection group
          connection_group.add(line)
  return connection_group

Как и прежде, мы добавим в наш метод construct, чтобы анимировать создание каждого из соединений:

# Create connections
connections_1 = self.create_connections(h1_nodes_list, h2_nodes_list, w2)
connections_2 = self.create_connections(h2_nodes_list, o_nodes_list, w3)

# Play Create animation and then wait for 2 seconds
self.play(Create(input_image)
                  , Create(h1_node_group)
                  , Create(h2_node_group)
                  , Create(o_node_group)
                  , Create(connections_1)
                  , Create(connections_2)
                  )
self.wait(2)

Теперь наша анимация выглядит так:

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

Текст

Следующие части, которые нужно анимировать, — это различные текстовые части визуального элемента, в том числе:

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

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

def create_text(self, text, font_size, left_shift, down_shift):
    # Create text
    text = Text(text, font_size=font_size)

    # Position text
    text.shift(left_shift * LEFT)
    text.shift(down_shift * DOWN)

    return text

def create_prediction_text(self, prediction, left_shift):
    # Create group
    prediction_text_group = VGroup()

    # Create & position text
    prediction_text = Text(f'{prediction}', font_size=40)
    prediction_text.shift(left_shift * LEFT)

    # Create text box (helps with positioning Prediction Header)
    prediction_text_box = Square(fill_opacity=0
                                 , stroke_opacity=0
                                 , side_length=0.75)
    prediction_text_box.move_to(prediction_text)

    # Create Header Text
    prediction_header = Text("Prediction"
                             , font_size=self.HEADER_FONT_SIZE)
    prediction_header.next_to(prediction_text_box, UP)

    # Group items
    prediction_text_group.add(prediction_header)
    prediction_text_group.add(prediction_text)
    prediction_text_group.add(prediction_text_box)

    return prediction_text_group

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

Теперь о создании текста в нашей сцене… Я решил просто добавить их непосредственно в сцену с помощью «self.add(‹object›)», а не с помощью анимации Create. Таким образом, зритель увидит текст как якорь в визуальном элементе, прежде чем он начнет видеть создание узлов и соединений. Мы можем сделать это в нашем методе construct следующим образом:

# Create headers to distinguish the different layers & add to scene
hidden_layer1_text = self.create_text("Hidden Layer 1"
                                      , self.HEADER_FONT_SIZE
                                      , 3
                                      , self.HEADER_HEIGHT)
hidden_layer2_text = self.create_text("Hidden Layer 2"
                                      , self.HEADER_FONT_SIZE
                                      , 0
                                      , self.HEADER_HEIGHT)
output_text = self.create_text("Output Layer"
                               , self.HEADER_FONT_SIZE
                               , -3
                               , self.HEADER_HEIGHT)

self.add(hidden_layer1_text)
self.add(hidden_layer2_text)
self.add(output_text)

# Create header for input image & add to scene
input_image_text = self.create_text("Input Image"
                                    , self.HEADER_FONT_SIZE
                                    , 0
                                    , 0)
input_image_text.next_to(input_image, UP)
self.add(input_image_text)

# Create prediction text & add to scene
prediction_text_group = self.create_prediction_text("...", -5.5)
self.add(prediction_text_group)

# Create status text & add to scene
status_text = self.create_text(f'Epoch: {0}\nAccuracy: {0:.2f}%'
                               , self.HEADER_FONT_SIZE
                               , -5.5
                               , -3)
self.add(status_text)

Снова обратите внимание на использование переменной класса HEADER_HEIGHT (ей присвоено значение -3,5), которая сдвигает позицию текста заголовка ВВЕРХ на 3,5 (т. е. ВНИЗ на -3,5). Теперь наша анимация выглядит так:

Преобразование объектов

Чтобы показать изменение нейронной сети в нашей визуализации, мы будем использовать анимацию transform Манима. Это берет один объект и анимирует его преобразование во второй объект, который вы передаете. Чтобы использовать это, для каждого из наших методов создания мы напишем связанный с ним метод анимации, который:

  1. Использует метод create и возвращает второй объект с новыми требуемыми параметрами.
  2. Преобразует старый объект в новый, который мы только что создали.

Набор методов анимации, которые нам нужны для преобразования входного изображения, узлов, соединений и текста, выглядит следующим образом:

# Animate Methods
def animate_input_image(self, input_image, X, left_shift):
    # 1. Create input image with new parameters
    new_input_image = self.create_input_image(X, left_shift)
    # 2. Transform old input image to new image
    self.play(Transform(input_image, new_input_image)
              , run_time=self.ANIMATION_RUN_TIME)

def animate_nodes(self, layer_group, layer_output
                  , left_shift, down_shift, num_neurons):
    # 1. Create nodes with new parameters
    new_layer_group, _ = self.create_nodes(left_shift
                                           , down_shift
                                           , num_neurons
                                           , layer_output)
    # 2. Transform old nodes to new nodes
    self.play(Transform(layer_group, new_layer_group)
              , run_time=self.ANIMATION_RUN_TIME)

def animate_connections(self, left_layer_centers, right_layer_centers
                        , line_group, w):
    # 1. Create connections with new parameters
    new_line_group = self.create_connections(left_layer_centers
                                             , right_layer_centers
                                             , w)
    # 2. Transform old connections to new connections
    self.play(Transform(line_group, new_line_group)
              , run_time=self.ANIMATION_RUN_TIME)

def animate_text(self, text, new_string, font_size, left_shift, down_shift):
    # 1. Create text with new parameters
    new_text = self.create_text(new_string
                                , font_size
                                , left_shift
                                , down_shift)
    # 2. Transform old text to new text
    self.play(Transform(text, new_text)
              , run_time=self.ANIMATION_RUN_TIME)

def animate_prediction_text(self, prediction_text_group, prediction, left_shift):
    # 1. Create prediction text with new parameters
    new_prediction_text_group = self.create_prediction_text(prediction
                                                            , left_shift)
    # 2. Transform old prediction text to new prediction text
    self.play(Transform(prediction_text_group, new_prediction_text_group)
              , run_time=self.ANIMATION_RUN_TIME)

Обратите внимание на использование аргумента run_time в каждой из функций Transform, который устанавливает время выполнения анимации. Я снова использовал переменную класса для установки этого параметра во всех методах анимации с именем ANIMATION_RUN_TIME, значение которого равно 0,1 с.

Обучение

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

Давайте используем другую переменную класса, чтобы установить конкретное изображение, которое мы хотим показать во время обучения, называемое TRAINING_DATA_POINT, и присвоим ему значение 0, чтобы представить 0-й индекс обучающих данных (т. е. самую первую точку данных). Чтобы убедиться, что мы анимируем только прохождение этой первой точки данных, нам нужно будет добавить следующее условие анимации в наш обучающий цикл, который, в свою очередь, будет находиться в нашем методе construct:

# Decide whether to animate
animate = True if index == self.TRAINING_DATA_POINT else False

# Animate change
if animate:
    self.animate_input_image(input_image, X, 5)
    self.animate_nodes(h1_node_group, h1, 3, 0.25, num_nodes)
    self.animate_nodes(h2_node_group, h2, 0, 0.25, num_nodes)
    self.animate_connections(h1_nodes_list, h2_nodes_list, connections_1, w2)
    self.animate_nodes(o_node_group, o, -3, 0.25, num_nodes)
    self.animate_connections(h2_nodes_list, o_nodes_list, connections_2, w3)
    self.animate_prediction_text(prediction_text_group, get_prediction(o), -5)

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

# Increment epoch
epoch += 1
self.animate_text(f'Epoch: {epoch}\nAccuracy: {accuracy:.2f}%'
                           , self.HEADER_FONT_SIZE
                           , -5.5
                           , -3)

Наш полный метод construct теперь выглядит так:

def construct(self):
    ### INITIALISE NEURAL NET PARAMETERS ###
    # Extract MNIST csv data into train & test variables
    train = np.array(pd.read_csv('train.csv', delimiter=','))
    test = np.array(pd.read_csv('test.csv', delimiter=','))

    # Extract the first column of the training dataset into a label array
    label = train[:, 0]
    # The train dataset now becomes all columns except the first
    train = train[:, 1:]

    # Initialise vector of all zeroes with 10 columns and the same number
    # of rows as the label array
    Y = np.zeros((label.shape[0], 10))

    # assign a value of 1 to each column index matching the label value
    Y[np.arange(0, label.shape[0]), label] = 1.0

    # Normalize test & training dataset
    train = train / 255
    test = test / 255

    # Set hyperparameter(s)
    learning_rate = 0.01

    # Set other params
    epoch = 0
    previous_accuracy = 100
    accuracy = 0

    # Randomly initialize weights & biases
    w1, b1 = init_layer_params(10, 784)  # Hidden Layer 1
    w2, b2 = init_layer_params(10, 10)  # Hidden Layer 2
    w3, b3 = init_layer_params(10, 10)  # Output Layer

    ### CREATE SCENE ###
    # Create input image
    training_image = train[
                        self.TRAINING_DATA_POINT:self.TRAINING_DATA_POINT + 1
                        , :
                     ].T
    input_image = self.create_input_image(training_image, left_shift=5)

    # Create the nodes for each of the layers
    num_nodes = 10
    h1_node_group, h1_nodes_list = self.create_nodes(3, 0.25, num_nodes)
    h2_node_group, h2_nodes_list = self.create_nodes(0, 0.25, num_nodes)
    o_node_group, o_nodes_list = self.create_nodes(-3, 0.25, num_nodes)

    # Create connections
    connections_1 = self.create_connections(h1_nodes_list, h2_nodes_list, w2)
    connections_2 = self.create_connections(h2_nodes_list, o_nodes_list, w3)

    # Create headers to distinguish the different layers & add to scene
    hidden_layer1_text = self.create_text("Hidden Layer 1"
                                          , self.HEADER_FONT_SIZE
                                          , 3
                                          , self.HEADER_HEIGHT)
    hidden_layer2_text = self.create_text("Hidden Layer 2"
                                          , self.HEADER_FONT_SIZE
                                          , 0
                                          , self.HEADER_HEIGHT)
    output_text = self.create_text("Output Layer"
                                   , self.HEADER_FONT_SIZE
                                   , -3
                                   , self.HEADER_HEIGHT)

    self.add(hidden_layer1_text)
    self.add(hidden_layer2_text)
    self.add(output_text)

    # Create header for input image & add to scene
    input_image_text = self.create_text("Input Image"
                                        , self.HEADER_FONT_SIZE
                                        , 0
                                        , 0)
    input_image_text.next_to(input_image, UP)
    self.add(input_image_text)

    # Create prediction text & add to scene
    prediction_text_group = self.create_prediction_text("...", -5.5)
    self.add(prediction_text_group)

    # Create status text & add to scene
    status_text = self.create_text(f'Epoch: {0}\nAccuracy: {0:.2f}%'
                                   , self.HEADER_FONT_SIZE
                                   , -5.5
                                   , -3)
    self.add(status_text)

    # Animate creation of nodes & connections
    self.play(Create(input_image)
              , Create(h1_node_group)
              , Create(h2_node_group)
              , Create(connections_1)
              , Create(o_node_group)
              , Create(connections_2)
              , run_time=3)
    self.wait(2)


    ### NEURAL NET TRAINING ###
    # While:
    #  1. Accuracy is improving by 1% or more per epoch, and
    #  2. There are 20 epochs or less
    while abs(accuracy - previous_accuracy) >= 1 and epoch <= 20:
        print(f'------------- Epoch {epoch} -------------')

        # record previous accuracy
        previous_accuracy = accuracy

        # Iterate through training data
        for index in range(train.shape[0]):
            # Select a single image and associated y vector
            X = train[index:index + 1, :].T
            y = Y[index:index + 1].T

            # 1. Forward pass: compute Output/Prediction (o)
            h1 = calculate_layer_output(w1, X, b1, activation_type="relu")
            h2 = calculate_layer_output(w2, h1, b2, activation_type="relu")
            o = calculate_layer_output(w3, h2, b3, activation_type="softmax")

            # 2. Compute Loss Vector
            L = np.square(o - y)

            # 3. Backpropagation
            # Compute Loss derivative w.r.t. Output/Prediction vector (o)
            dL_do = 2.0 * (o - y)

            # Compute Output Layer derivatives
            dL3_dw3, dL3_dh2, dL3_db3 = layer_backprop(dL_do, o, h2, w3
                                                       , "softmax")
            # Compute Hidden Layer 2 derivatives
            dL2_dw2, dL2_dh2, dL2_db2 = layer_backprop(dL3_dh2, h2, h1, w2
                                                       , "relu")
            # Compute Hidden Layer 1 derivatives
            dL1_dw1, _, dL1_db1 = layer_backprop(dL2_dh2, h1, X, w1
                                                 , "relu")

            # 4. Update weights & biases
            w1, b1 = gradient_descent(w1, b1, dL1_dw1, dL1_db1, learning_rate)
            w2, b2 = gradient_descent(w2, b2, dL2_dw2, dL2_db2, learning_rate)
            w3, b3 = gradient_descent(w3, b3, dL3_dw3, dL3_db3, learning_rate)

            # Decide whether to animate
            animate = True if index == self.TRAINING_DATA_POINT else False

            # Animate change
            if animate:
                self.animate_input_image(input_image, X, 5)
                self.animate_nodes(h1_node_group, h1, 3, 0.25, num_nodes)
                self.animate_nodes(h2_node_group, h2, 0, 0.25, num_nodes)
                self.animate_connections(h1_nodes_list, h2_nodes_list
                                         , connections_1, w2)
                self.animate_nodes(o_node_group, o, -3, 0.25, num_nodes)
                self.animate_connections(h2_nodes_list, o_nodes_list
                                         , connections_2, w3)
                self.animate_prediction_text(prediction_text_group
                                             , get_prediction(o), -5)

        # Compute & print Accuracy (%)
        accuracy = compute_accuracy(train, label, w1, b1, w2, b2, w3, b3)
        print(f'Accuracy: {accuracy:.2f} %')

        # Increment epoch
        epoch += 1
        self.animate_text(status_text
                        , f'Epoch: {epoch}\nAccuracy: {accuracy:.2f}%'
                        , self.HEADER_FONT_SIZE
                        , -5.5
                        , -3)

Выполняя всю нашу визуализацию с помощью manim, мы получаем для нашей первой точки данных следующее:

Визуализация тренировки для разных цифр

Давайте посмотрим на другие входные изображения…

Обучение нейронной сети для входного изображения цифры 2

Обучение нейронной сети для входного изображения цифры 3

Интересно, что на этом изображении нейронная сеть не может правильно угадать, что это изображение 3, и неправильно угадывает сначала 8, а затем 5. Однако, глядя на написанную от руки цифру, вы можете видеть, что это не ужасные предположения… небольшая модификация цифра может довольно легко дать вам либо 8, либо 5.

Обучение нейронной сети для входного изображения числа 4

Обучение нейронной сети для входного изображения числа 5

Мы снова видим, как нейронная сеть делает неверный прогноз, думая, что изображение 5 на самом деле является 6… однако даже человека можно простить за то, что он думает, что это 6!

Обучение нейронной сети для входного изображения числа 6

Обучение нейронной сети для входного изображения цифры 7

Обучение нейронной сети для входного изображения числа 8

Интересно, что с этим изображением, хотя нейронная сеть действительно находит правильную цифру, она не полностью уверена в своем ответе. Он присваивает только 0,5 вероятности того, что изображение имеет 8 баллов, в то время как он присваивает вероятность 0,4 того, что изображение может иметь 5 баллов, и 0,1 вероятность того, что это 0.

Обучение нейронной сети для входного изображения 9

Обучение нейронной сети для входного изображения 0

Заключение

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

Одно из моих разочарований в этом изображении заключается в том, что я не могу эффективно показать предубеждения, которые также влияют на каждый из результатов. Моя цель — исправить это во второй части с помощью тепловой карты. Если у вас есть другие идеи по визуальным эффектам, дайте мне знать! Вы можете найти код из scene.py здесь.