Understand and Implement an Artificial Neural Network from Scratch

A Comprehensive Guide for all aspiring Data Scientists

·

16 min read

Understand and Implement an Artificial Neural Network from Scratch

Generated with Bing DALL-E 3

TL;DR Understand and implement an artificial neural network from scratch using NumPy, learn the basics of feedforward and backpropagation, and explore the impact of changing parameters during training.


Artificial intelligence is a hype topic. But what is behind the powerful networks of the big tech companies? An artificial neural network (ANN) is usually implemented with frameworks such as TensorFlow, Keras or PyTorch. Such frameworks are suitable for very complex ANNs. As a data scientist, however, it is essential to understand the basics.

This article aims to help you understand how a neural network works. First, we introduce the basics of an ANN in general. Then we look at the basic concepts of ANNs in detail. After that, we explain how to implement a neural network with NumPy using binary classification. We will also do some experiments to help you understand how neural networks work.

After reading this article, you should have a basic understanding of how artificial neural networks work. We recommend the online course “The Complete Python, Machine Learning, AI Mega Bundle”* as an additional resource. It’s a self-paced online course that offers hands-on experience through real-world projects.

Artificial Neural Networks in general

An artificial neural network uses biology as a model. Such a network consists of artificial neurons (also called nodes) and connections (also called edges) between these neurons. A neural network has one or more hidden layers, each layer consisting of several neurons. Each neuron in each layer receives the output of each neuron in the previous layer as input. Each input to the neuron is weighted. The following figure shows a Feed Forward Neural Network. In such a network, the connections between the nodes are acyclic.

Artificial Neural Network — Image by author

Artificial Neural Network — Image by author

The important terms are marked in bold italics and will be looked at in more detail in this article. Feedforward is the flow of the input data through the neural network from the input layer to the output layer. As it passes through the individual layers of the network, the input data is weighted at the edges and normalized by an activation function. The weighting and the activation function are part of a neuron. The calculated values at the output of the network have an error compared to the true result values. In our training data, we know the true result values. The return of the errors is called backpropagation, where the errors are calculated per node. The gradient descent method is usually used to minimize the error terms. The training of the network with backpropagation is repeated until the network has the smallest possible error.

Then the trained neural network can be used for a prediction on new data (test data). The quality of the prediction depends on many factors. As a data scientist, you need to pay attention to data quality when training a network.


🎓 Our Online Courses and recommendations

Our Online Courses and recommendations


Concept of a Perceptron

The concept of a perceptron was first introduced by Frank Rosenblatt in 1957. A perceptron consists of an artificial neuron with adjustable weights and a threshold. For a better understanding, we explain how a perceptron works with the following figure.

Concept of Perceptron — Image by author

Concept of Perceptron — Image by author

The figure illustrates an input layer and an artificial neuron. The input layer contains the input value and x_0 as bias. In a neural network, a bias is required to shift the activation function either to the positive or negative side. The perceptron has weights on the edges. The weighted sum of input values and weights is then calculated. It is also known as aggregation. The result a finally serves as input into the activation function. In this simple example, the step function is used as the activation function. Here, all values of a > 0 map to 1 and values a < 0 map to -1. There are different activation functions. Without an activation function, the output signal would just be a simple linear function. A linear equation is easy to solve, but it is very limited in complexity and has much less ability to learn a complex function mapping from data. The performance of such a network is severely limited and gives poor results.

Feedforward

Feedforward is the flow of input data through the neural network from the input layer to the output layer. This procedure is explained in the following figure:

Feedforward — Image by author

Feedforward — Image by author

In this example, we have exactly one hidden layer. First, the data x enters into the input layer. x is a vector with individual data points. The individual data points are weighted with the weights of the edges. There are different procedures for initializing the starting weights, which we will not discuss in this article. This step is called aggregation. Mathematically, this step is as follows:

Aggregation Hidden Layer

Aggregation Hidden Layer

The result of this step serves as the input of the activation function. The formula is:

Activation Function f Hidden Layer

Activation function f Hidden Layer

We denote the activation function by f. The result from the activation function finally serves as input for the neuron in the output layer.

This neuron again performs an aggregation. The formula is as follows:

Aggregation Output Layer

Aggregation Output Layer

The result is again the input for the activation function. The formula is:

Activation Function g Output Layer

Activation function f Output Layer

We denote the output of the network by y. y is a vector with all y_k. The activation function in the hidden layer and the output layer does not have to be identical. In practice, different activation functions are suitable depending on the use case.


📘 Get our e-book LangChain for Finance


Gradient descent method

The gradient descent method minimizes the error terms. In this context, the error function is derived to find a minimum. The error function calculates the error between calculated and true result values.

First, the direction in which the curve of the error function slopes most sharply must be determined. That is the negative gradient. A gradient is a multidimensional derivative of a function. Then we walk a bit in the direction of the negative slope and update the weights. The following formula illustrates this procedure:

Update weights

Update weights

The inverted triangle is the Nabla sign and is used to indicate derivatives of vectors. The gradient descent method still requires a learning rate (Eta sign) as a transfer parameter. The learning rate specifies how strongly the weights are adjusted. E is the error function that is derived. This whole process repeats until there is no more significant improvement.

Backpropagation

In principle, Backpropagation can be divided into three steps.

  1. Step: Construct the error function

  2. Step: Calculate the error term for each node on the output and hidden layer

  3. Step: Update the weights on the edges

Backpropagation — Image by author

Backpropagation — Image by author

Construct the error function

The calculated values at the output of the network have an error compared to the true result values. An error function is used to calculate this error. Mathematically, the error can be calculated in different ways. The error function contains the result from the output layer as input. That means that the whole calculation of the feedforward is used as an input to the error function. The n of the function E denotes the n-th data set. m represents the number of output neurons.

Error function

Error function

Calculate the error term for each node on the output and hidden layer

The error terms are marked orange in the Backpropagation figure. To calculate the error terms for the output layer, we have to derive the error function according to the respective weights. For this, we use the chain rule. Mathematically, it looks like this:

Error term Hidden Layer

Error term Hidden Layer

To calculate the error terms for the hidden layer, we derive the error function according to a_j.

Error term Output Layer

Error term Output Layer

Update of the weights on the edges

The new weights between the hidden and output layers can now be calculated using the respective error terms and a learning rate. Through the minus (-), we go a bit into the direction of the descent.

Delta w_jk for weight update

Delta w_jk for weight update

The triangle is the Greek letter delta. In mathematics and computer science, this letter is used to indicate the difference. Furthermore, we can now update the weights between the input and hidden layer. The formula looks like this:

Delta w_ij for weight update

Delta w_ij for weight update

The deltas are used for the weight updates, as shown in the figure at the beginning of the Backpropagation section.


📖 Explore our premium blog articles

Explore our premium blog articles


Implement an artificial neural network with NumPy

1. Step — Technical requirements

You will need the following prerequisites:

  • Access to a bash (macOS, Linux or Windows).

  • Installed Python (≥ 3.7)

  • A Python package manager of your choice like conda

  • Code editor of your choice (We use VSCode.)

Setup

  • Create a conda environment (env): conda create -n neural-network python=3.9.12 -> Answer the question Proceed ([y]/n)? with y.

  • Activate the conda env: conda activate neural-network

  • Install the necessary libraries: pip install matplotlib==3.7.1 numpy==1.24.2 plotly==5.14.1 pandas==2.0.0 scikit-learn==1.2.2

2. Step — Imports

In the second step, we import all requirements. For the visualizations, we use matplotlib and Plotly. In the last line, we set the figure size of the matplotlib plots.

import numpy as np
import sklearn
import sklearn.datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_curve, auc
from sklearn.metrics import accuracy_score
import pandas as pd
import plotly.express as px
import time
import matplotlib.pyplot as plt
%matplotlib inline
import matplotlib
matplotlib.rcParams['figure.figsize'] = (5.0, 4.0)

3. Step — Generate the dataset

We use the simple toy dataset sklearn.datasets.make_moons. You can find more information about the dataset on the sklearn website.

The function has the following parameters:

  • n_samples: Total number of generated points

  • noise: Standard deviation of Gaussian noise added to the data

  • random_state: Seed for reproducible output

n = 1000
data_seed = 4242   # choose seed value

X, y = sklearn.datasets.make_moons(
    n_samples = n, 
    noise = 0., 
    random_state = data_seed)

plt.scatter(
    x = X[:,0], 
    y = X[:,1], 
    s = 40,   # marker size
    c = y)

Scatter-Plot:

Scatter Plot (Image by author)

Scatter Plot of the dataset (Image by author)

We see two interleaving half circles.

4. Step — Split the dataset

In this step, we use the train_test_split()function of the sklearn package to create our training and test dataset with labels.

The function has the following parameters:

  • X: The moons dataset

  • y: The labels

  • test_size: Size of the test dataset in percent

  • random_size: Seed for reproducible output

test_size = 0.25
split_seed = 42 # choose split seed value

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size = test_size,
    random_state = split_seed)

5. Step — Define helper functions

Activation function: The sigmoid activation function has the open interval (0,1) as its value range. Furthermore, the sigmoid function is a bounded and differentiable real function. The formula is:

The derivation is:

The function sigmoid(z, derivation=FALSE) calculates for a value x the result for the sigmoid or the derivation of the sigmoid.

Cost function:

In the function calculate_loss(), we calculate the squared error function. The formula is:

n is the number of classes.

Predict function:

In the predict function, we perform feed-forward propagation.

# activation function (sigmod)
def sigmoid(z, derivation = False):
    if derivation:
        return sigmoid(z)*(1-sigmoid(z))
    else:
        return 1/(1+np.exp(-z))

# cost function (Mean squared error function)
def calculate_loss(model, X, y):    
    # extract model parameters
    W1, b1, W2, b2 = model['W1'], model['b1'], model['W2'], model['b2']

    # calculation of the estimated class probabilities
    ## calc hidden layer
    a1 = X.dot(W1) + b1
    h1 = sigmoid(a1)

    ## calc output layer
    a2 = h1.dot(W2) + b2
    probs = sigmoid(a2)[:,0]

    # calculation of the cost function value
    cost = np.power(y-probs,2)
    cost = np.sum(cost)/2

    return cost

# predict function
def predict(
        model, 
        x,
        proba = False,
        decision_point = 0.5):

    # extract model parameters
    W1, b1, W2, b2 = model['W1'], model['b1'], model['W2'], model['b2']

    # calculation of the estimated class probabilities (Forward Propagation)
    ## calc hidden layer
    a1 = x.dot(W1) + b1
    h1 = sigmoid(a1)

    ## calc output layer
    a2 = h1.dot(W2) + b2
    probs = sigmoid(a2)

    if(proba):
        return probs

    return (probs>decision_point)*1

6. Step — Modeling

Now we can design our neural network. We create a network with only one hidden layer. The function build_neural_network() receives as input variables our training data X_train, the labels y_train, the number of gradient descent iterations, the learning_rate, a seed for reproducibility, the number of hidden nodes, and a value for initializing the weights.

We have always written the shape of the array as a comment after the individual lines of code so that you can understand it better. Please go through the code in detail to understand how it works.

def build_neural_network(
        X,                              # features
        y,                              # target
        iterations = 20000,             # number of gradient descent iterations
        learning_rate = 0.1,            # learning rate of Gradient descent
        random_state = None,            # random seed of weights 
        hidden_nodes = 5,               # number of hidden nodes
        rand_range = 0.05):             # initialise the weights

observations = X.shape[0] # number of observations
features = X.shape[1] # number of features

# initialise the parameters to random values:
np.random.seed(random_state)
W1 = np.random.uniform(low = -rand_range, 
                       high = rand_range, 
                       size= (features,hidden_nodes)) # (2,10)
b1 = np.zeros((1,hidden_nodes))   # (1,10)

W2 = np.random.uniform(low = -rand_range, 
                       high = rand_range, 
                       size= (hidden_nodes,1)) # (10, 1)
b2 = np.zeros((1,1))   # (1,1)

# this is what we return at the end
model = {}

# Gradient descent:
for i in range(0, iterations):
    # Forward propagation
    ## calc hidden layer
    a1 = X.dot(W1) + b1   # X: (750, 2), W1: (2,10), a1: (750, 10)
    h1 = sigmoid(a1)   # z1: (750, 10)

    ## calc output layer
    a2 = h1.dot(W2) + b2   # z1: (750, 10), W2: (10, 1), a2: (750, 1)
    probs = sigmoid(a2)   # probs: (750, 1)

    # Backpropagation     
    # y.reshape: (750,1), probs: (750, 1), delta1: (750, 1)
    delta1 = (probs-y.reshape((observations,1))) * probs * (1 - probs)   # g(a_k)*(1-g(a_k))*(g(a_k)-t_k^n)

    dW2 = np.dot(h1.T, delta1)   # z1.T: (10, 750), delta1: (750,1), dW2: (10,1)
    db2 = np.sum(delta1, axis=0, keepdims=True)  # delta1: (750,1), db2: (1, 1)

    delta_j = delta1 * W2.T * h1 * (1 - h1)   # delta1: (750,1), W2.T: (1,10), z1: (750,10), delta_j: (750,10)

    dW1 = np.dot(X.T, delta_j)   # X.T: (2, 750), delta_j: (750,10), dW1: (750,1)
    db1 = np.sum(delta_j, axis=0) # delta_j: (750,10), db1: (10,1)

    # Gradient descent parameter update
    W1 += -learning_rate * dW1  # dW1: (750,1) 

    b1 += -learning_rate * db1  # db1: (10,1)

    W2 += -learning_rate * dW2
    b2 += -learning_rate * db2

    # assign new parameters to the model
    model = { 'W1': W1, 'b1': b1, 'W2': W2, 'b2': b2}

    if i % 100 == 0:
        print("Loss after iteration %7d:\t%f" %(i, calculate_loss(model,X,y)))
return model

# Train the neural network
model = build_neural_network(X = X_train,
                    y = y_train, 
                    iterations = 20000,
                    random_state = 4242,
                    hidden_nodes = 5,
                    learning_rate = 0.1,
                    rand_range = 0.1)

7. Step — Evaluation

Classification Report:

from sklearn.metrics import classification_report
predictions = predict(model, X_test)
print(classification_report(y_test, predictions))

# Output
#               precision    recall  f1-score   support
#            0       0.95      0.94      0.94       118
#            1       0.95      0.95      0.95       132
#     accuracy                           0.95       250
#    macro avg       0.95      0.95      0.95       250
# weighted avg       0.95      0.95      0.95       250

The trained neural network has a weighted avg recall and precision of 0.95.

ROC curve with test dataset:

The ROC curve represents the trade-off between the true positive rate (y-axis) and the false positive rate (x-axis). A random classifier lies on the bisector. A bad classifier lies below this line. A classifier becomes better the further it deviates upwards from the bisector. Another criterion for the ROC curve is the area under the ROC curve (AUC) score, where the area under the curve is calculated. A good classifier has an area > 0.5.

ROC curve — Image by authors

ROC curve — Image by authors

Our neural network has an AUC score of 0.9879.

Experiments

Default Setup for every experiment

  • Gradient descent iterations: 10000

  • hidden nodes: 5

  • learning rate: 0.1

model = build_neural_network(X = X_train,
                    y = y_train, 
                    iterations = 10000,
                    random_state = 4242,
                    hidden_nodes = 5,
                    learning_rate = 0.1,
                    rand_range = 0.1)

1. Change Gradient descent iterations

In the first experiment, we changed the number of gradient descent iterations. So we determine how often the error should be fed back. In Table 1, you can see the results for several runs. We measured the following metrics for the training and test dataset.

Table 1: Change Gradient descent iterations (Image by author)

Table 1: Change Gradient descent iterations (Image by author)

We can see that the AUC score and accuracy increase as we increase the number of iterations. Furthermore, the misclassification rate decreases strongly with an increasing number of iterations. As the number of iterations increases, the weights on the edges of the neural network are updated more and more. The network learns through the weight updates the structures in the training data. But we also see that the performance of the network improves only minimally from 1000 iterations. The more iterations we do, the longer it takes to train the network.

2. Change the number of hidden nodes

In the second experiment, we change the number of nodes in the hidden layer. You can see the results of the experiment in Table 2.

Table 2: Change number of hidden nodes (Image by author)

Table 2: Change the number of hidden nodes (Image by author)

We can see that the network gives good results even with a small number of neurons. The required number of neurons depends on the complexity of the data. For more complex problems, you need more layers with more neurons. The more neurons we use, the longer it takes to train the network.

3. Change the learning rate

In the third experiment, we changed the learning rate. The learning rate specifies how strongly the weights are adjusted.

Table 3: Change learning rate (Image by author)

Table 3: Change learning rate (Image by author)

We see that the learning rate can have a big impact on the performance of the network. With a learning rate of 0.5, the network performed significantly worse. A large learning rate leads to poor results because the neural network does not find the optimal minimum. Finding the optimal learning rate is an art. Furthermore, the learning rate also determines the duration of the training process.

Conclusion

In this article, you have learned how an artificial neural network works. In this context, we have looked at the central concepts in detail. Feedforward describes the flow of input data through the neural network. Backpropagation is used to calculate the error terms per neuron using the gradient descent method. These concepts also build the basis for more complex network architectures. In addition, you have learned how to implement an artificial neural network from scratch with NumPy. In this context, we looked at the implementation in detail. We also conducted three experiments to see what happens when we change parameters during training.


👉🏽 Join our free weekly Magic AI newsletter for the latest AI updates!

👉🏽 Elevate your Python and ML skills with our top course recommendations!

Did you enjoy our content and find it helpful? If so, be sure to check out our premium offer! Don't forget to follow us on X. 🙏🏽🙏🏽

Thanks so much for reading. Have a great day!


References

  • KUBAT, Miroslav. An introduction to machine learning. Cham, Switzerland: Springer International Publishing, 2021.

  • Hastie, Trevor, et al, The elements of statistical learning: data mining, inference, and prediction (2009). Vol. 2. New York: Springer.

  • Dive into Deep Learning (accessed on 17.07.2023)

  • A Brief Introduction to Neural Networks (accessed on 17.07.2023)

* Disclosure: The links are affiliate links, which means we will receive a commission if you purchase through these links. There are no additional costs for you.