Neural Network from Scratch
Last Updated on February 1, 2024 by Editorial Team
Author(s): Abhishek Chaudhary
Originally published on Towards AI.
Many of us have a functional understanding of neural networks and how they work. Iβve even studied this particular term multiple times, and every time, I convince myself that I know what Iβm talking about. The human brain is funny that way; it takes the path of least resistance and convinces us that we know something when we donβt. Though Iβve used neural networks multiple times for tasks ranging from binary classification to natural language processing, Iβve never really implemented them from scratch, and if youβre like me, then your understanding of neural networks ends at Y = WX + b.
In this article, Iβll implement a neural network from scratch, going over different concepts like derivatives, gradient descent, and backward propagation of gradients. I would also like to mention that this article is inspired by Anderj Karapthyβs brilliant lecture series on YouTube, and I will be making use of some of the code mentioned in that video.
This article also has a corresponding Jupyter notebook with all of the code thatβs covered below.
Introduction
Feel free to skip to the next section, as the introduction section covers the theory of neural networks and not the implementation.
Neural networks are a type of machine model inspired by the structure of the human brain. Similar to our brain, Neural networks are made up of Neurons that work together to accomplish a whole variety of tasks. The diagram below shows the architecture of a neuron:
Where :
output
: This is the final value or activation of the neuron.activation_function
: This is a mathematical function that determines whether the neuron should fire or not based on its input.weighted_sum
: It is the sum of the products of the input values and their corresponding weights.bias
: A bias term is added to the weighted sum to provide the neuron with some flexibility in its activation.
The weighted sum is calculated as follows: weighted_sum = (w1 * x1) + (w2 * x2) + ... + (wn * xn)
Where:
w1, w2, ..., wn
: These are the weights associated with each input.x1, x2, ..., xn
: These are the input values.
The activation function can be any non-linear function, such as the sigmoid, ReLU (Rectified Linear Unit), or softmax function, depending on the type of neuron and the task at hand.
The role of the activation function is to introduce non-linearity into the neuronβs output, allowing it to learn complex patterns and relationships in the data.
In summary, a single neuron in a neural network takes input values, applies weights to them, sums them up with a bias term, and then passes the result through an activation function to produce an output. This output is then used as input for subsequent layers or neurons in the neural network, enabling the network to learn and make predictions for various tasks.
Now, with all the theory out of the way, letβs implement a neuron. Starting with derivatives.
Getting rid of the imports early on in the process
import math
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inlinep
Derivatives
Before moving to neuron and neural networks, letβs briefly go over the functions and their derivatives. As all of us have learned in school, A derivative of a function with respect to a variable shows the rate of change of the function with respect to that variable.
What does this mean for a function, say f(x) = 5x - 9
?
f'(x)=5
i.e., the function f(x)
increases by a factor of 5 for 1 unit change in x. We can also calculate this value by nudging x by an infinitesimal value h
and observe how f(x)
changes. i.e
f(x) = 5x - 9
f(x+h) = 5(x+h) - 9
f`(x) = (f(x+h) - f(x))/(x+h - x) = 5 -->
This is also referred to as rise-over-run
Multivariate functions
For a multivariate functions such as d = a*b + c
, the rate of change is different for different variables.
def f(x):
return 5*x - 9
xs = np.arange(-5,5,0.25)
ys = f(xs)
plt.plot(xs, ys)
[<matplotlib.lines.Line2D at 0x130c99290>]
Implementation of a Value in Neural Network
Pytorch makes use of Tensors
for computation, to replicate the functionality, we'll write our own implementation Value
class Value:
def __init__(self, data, label=''):
self.data = data
self.label = label
self.grad = 0.0 # We'll go over this field in detail later on
def __repr__(self):
return f"Value=(data={self.data}, label={self.label})"
'''
Let's make use of a multi-variate equation and write it using the Value class defined above
a = 2.0
b = -3.0
c = 10.0
e = a * b
d = e + c
f = -2.0
l = d * f
'''
a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a*b
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[92], line 14
12 b = Value(-3.0, label='b')
13 c = Value(10.0, label='c')
---> 14 e = a*b
TypeError: unsupported operand type(s) for *: 'Value' and 'Value'
The error above shows that * isnβt defined for the Value type. Weβll need to modify the Value type to support + and * operations.
Weβll also add an op field that shows how the Value was created. Since we are working with a neural network, weβll need to keep track of the children of a node. So weβll add a _prev field to the Value type
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.label = label
self._op = _op
self._prev = set(_children)
self.grad = 0
def __repr__(self):
return f"Value=(data={self.data}, label={self.label})"
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
return out
a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a*b; e.label = 'e'
d = e + c; d.label = 'd'
f = Value(-2.0, label='f')
L = d * f; L.label = 'L'
L
Value=(data=-8.0, label=L)
For visualizing the network, weβll make use of graphviz
. The code below is directly referenced from https://github.com/karpathy/nn-zero-to-hero/blob/master/lectures/micrograd.
from graphviz import Digraph
def trace(root):
# builds a set of all nodes and edges in a graph
nodes, edges = set(), set()
def build(v):
if v not in nodes:
nodes.add(v)
for child in v._prev:
edges.add((child, v))
build(child)
build(root)
return nodes, edges
def draw_dot(root):
dot = Digraph(format='png', graph_attr={'rankdir': 'LR'}) # LR = left to right
nodes, edges = trace(root)
for n in nodes:
uid = str(id(n))
# for any value in the graph, create a rectangular ('record') node for it
dot.node(name = uid, label = "{ %s U+007C data %.4f U+007C grad %.4f }" % (n.label, n.data, n.grad), shape='record')
if n._op:
# if this value is a result of some operation, create an op node for it
dot.node(name = uid + n._op, label = n._op)
# and connect this node to it
dot.edge(uid + n._op, uid)
for n1, n2 in edges:
# connect n1 to the op node of n2
dot.edge(str(id(n1)), str(id(n2)) + n2._op)
return dot
'''
Plotting the variable L to layout the network. The _op nodes are just rendered to show the parent/child relation between nodes
'''
draw_dot(L)
Derivatives/Gradient
Manually computing gradient, moving backward from L, which is the base case with a gradient of 1.0
L.grad = 1.0 # Base case as rate of change of L wrt to L is 1
'''
We know that L = d*f
so dL/de (gradient of L wrt to f or rate of change of L wrt to f) = d
similarly dL/dd will be f
'''
d.grad = f.data
f.grad = d.data
'''
Since d = e + c, to find out dL/dd (Rate of change of L wrt to d) we'll make use of the chain rule i.e
dL/de = (dL/dd) * (dd/de) = (dL/dd) * 1.0
similarly dL/dc = dL/dd * 1.0
'''
e.grad = d.grad
c.grad = d.grad
'''
e = a * b
So following the chain rule again
dL/da = (dL/de) * (de/da) = (dL/de) * b
dL/db = (dL/de) * (de/db) = (dL/de) * a
'''
a.grad = e.grad * b.data
b.grad = e.grad * a.data
'''
Plotting the variable L to layout the network.
'''
draw_dot(L)
From the diagram above, we can make a simple observation that +
nodes transfer gradients from child node to parent nodes, for example, the gradient of e
and c
are the same as that of d
. Now that we have established the concepts of gradients. Letβs move towards expression representing a Neuron.
# inputs x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
# weights w1,w2
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
# bias of the neuron
b = Value(6, label='b')
# x1*w1 + x2*w2 + b
x1w1 = x1*w1; x1w1.label = 'x1*w1'
x2w2 = x2*w2; x2w2.label = 'x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1*w1 + x2*w2'
n = x1w1x2w2 + b; n.label = 'n'
draw_dot(n)
Activation function
Neurons also make use of something called an activation function. Activation functions introduce non-linearity into the neural network, allowing it to model complex relationships and learn from data. For the purpose of this article, weβll use tanh
as our activation function. The tanh function squashes the net input, but it maps it to a range between -1 and 1. It is often used in hidden layers of neural networks.
plt.plot(np.arange(-5,5,0.2), np.tanh(np.arange(-5,5,0.2))); plt.grid();
'''
We'll pass n through an activation function tanh but before that we'll need to add tanh to the Value type since tanh doesn't understand Value type.
'''
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
return out
# Attach the instance method to the class
Value.tanh = tanh
o = n.tanh(); o.label = 'o'
draw_dot(o)
Back-propagation
In order to calculate the gradient of each node, itβll be insanity to do that manually as the size of the network grows. Weβd like to create a method _backward
that does that for each node. Since calculating the gradient requires differentiating the function, we'd have to implement that in _backward
method. Here's what that would look like for each operation in Value type
add
method
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
mul
method
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
tanh
method
def _backward():
self.grad += (1 - t**2) * out.grad
'''
Updating Value to include _backward function for each operation method
'''
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.label = label
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
def _backward():
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
'''Reinitializing the values to reflect new class properties '''
# inputs x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
# weights w1,w2
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
# bias of the neuron
b = Value(6, label='b')
# x1*w1 + x2*w2 + b
x1w1 = x1*w1; x1w1.label = 'x1*w1'
x2w2 = x2*w2; x2w2.label = 'x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1*w1 + x2*w2'
n = x1w1x2w2 + b; n.label = 'n'
o = n.tanh(); o.label = 'o'
draw_dot(o)
o.grad = 1.0 # base case
'''
Calling backward on o will change gradient of n
'''
o._backward()
'''
Calling backward on n will change gradient of x1w1 + x2w2 and b nodes
'''
n._backward()
'''
Calling backward on x1w1x2w2 will change gradient of x2w2 and x1w1
'''
x1w1x2w2._backward()
draw_dot(o)
'''
Calling backward on x1w1 and x1w1 will change gradient of x1, x2, w1 and w2
'''
x1w1._backward()
x2w2._backward()
draw_dot(o)
A better way to do this would be to have a method that executes backward methods for all children starting from the current node.
This would require getting the order of nodes such that the current node is the first one, and all its children are processed after it. This is what topological sort provides us with
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(o)
topo
[Value(data=1.0),
Value(data=0.0),
Value(data=0.0),
Value(data=-3.0),
Value(data=2.0),
Value(data=-6.0),
Value(data=-6.0),
Value(data=6),
Value(data=0.0),
Value(data=0.0)]
Using the above approach, we can write a method in Value class that class _backward method for each of the children of a class
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0 # Replicating the base case
for node in reversed(topo):
node._backward()
# Attach the instance method to the class
Value.backward = backward
'''Reinitializing the values to reflect new class properties '''
# inputs x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
# weights w1,w2
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
# bias of the neuron
b = Value(6, label='b')
# x1*w1 + x2*w2 + b
x1w1 = x1*w1; x1w1.label = 'x1*w1'
x2w2 = x2*w2; x2w2.label = 'x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1*w1 + x2*w2'
n = x1w1x2w2 + b; n.label = 'n'
o = n.tanh(); o.label = 'o'
o.backward()
draw_dot(o)
We can verify our implementation using PyTorch library
import torch
x1 = torch.Tensor([2.0]).double() ; x1.requires_grad = True # requires_grad forces the tensor to be used in calculations of gradients
x2 = torch.Tensor([0.0]).double() ; x2.requires_grad = True
w1 = torch.Tensor([-3.0]).double() ; w1.requires_grad = True
w2 = torch.Tensor([1.0]).double() ; w2.requires_grad = True
b = torch.Tensor([6]).double() ; b.requires_grad = True
n = x1*w1 + x2*w2 + b
o = torch.tanh(n)
print(o.data.item())
o.backward()
print('---')
print('x2', x2.grad.item())
print('w2', w2.grad.item())
print('x1', x1.grad.item())
print('w1', w1.grad.item())
0.0
---
x2 1.0
w2 0.0
x1 -3.0
w1 2.0
Coding a Neural Network
In the sections above, we have created a Value class that allows us to create multi-variate equations, and using back-propagation allows us to calculate gradients. For creating a neural network, weβd create the following classes that would make use of the Value class:
Neuron
– Depicting a single neuron in the networkLayer
– Depicting a single layer in the neural network that would contain multipleNeuron
sMLP
– Depicting a Multi-Layer Perceptron or a neural network with multipleLayer
s ofNeuron
s
The diagram above shows the structure of a Neuron with weights(Wi), bias(b) and input(xi) as the inputs
and a single output passed through an activation function tanh
in our case
The weighted sum is calculated as follows: weighted_sum = (w1 * x1) + (w2 * x2) + ... + (wn * xn)
Where:
w1, w2, ..., wn
: These are the weights associated with each input.x1, x2, ..., xn
: These are the input values.
Implementing Neuron
import random
class Neuron:
def __init__(self, ninp):
self.w = [Value(random.uniform(-1, 1)) for _ in range(ninp)]
self.b = Value(random.uniform(-1, 1))
def __call__(self, x):
out = sum([ wi*xi for wi, xi in zip(self.w, x)]) + self.b
res = out.tanh()
return res
def parameters(self): # This function would be used later on while training the network
return self.w + [self.b]
x = [1.0, 2.0, 3.0 ]
n = Neuron(3)
n(x)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[123], line 3
1 x = [1.0, 2.0, 3.0 ]
2 n = Neuron(3)
----> 3 n(x)
Cell In[122], line 8, in Neuron.__call__(self, x)
7 def __call__(self, x):
----> 8 out = sum([ wi*xi for wi, xi in zip(self.w, x)]) + self.b
9 res = out.tanh()
10 return res
Cell In[122], line 8, in <listcomp>(.0)
7 def __call__(self, x):
----> 8 out = sum([ wi*xi for wi, xi in zip(self.w, x)]) + self.b
9 res = out.tanh()
10 return res
Cell In[105], line 29, in Value.__mul__(self, other)
28 def __mul__(self, other):
---> 29 out = Value(self.data * other.data, (self, other), '*')
31 def _backward():
32 self.grad += other.data * out.grad
AttributeError: 'float' object has no attribute 'data'
The error above is the result of multiplying the Value type with a float type. To support such operations, weβll modify Value with a few more methods
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.label = label
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers for now"
out = Value(self.data**other, (self,), f'**{other}')
def _backward():
self.grad += other * (self.data ** (other - 1)) * out.grad
out._backward = _backward
return out
def __rmul__(self, other): # other * self
return self * other
def __truediv__(self, other): # self / other
return self * other**-1
def __neg__(self): # -self
return self * -1
def __sub__(self, other): # self - other
return self + (-other)
def __radd__(self, other): # other + self
return self + other
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
def _backward():
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
def exp(self):
x = self.data
out = Value(math.exp(x), (self, ), 'exp')
def _backward():
self.grad += out.data * out.grad
out._backward = _backward
return out
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for node in reversed(topo):
node._backward()
'''
Trying out the above operation again
'''
x = [1.0, 2.0, 3.0 ]
n = Neuron(3)
n(x)
Value(data=-0.5181071586116809)
Next, we'll define a layer in the neural network
class Layer:
def __init__(self, ninp, nneurons): # ninp is the number of inputs for each neuron in the layer
self.neurons = [Neuron(ninp) for _ in range(nneurons)]
def __call__(self, x):
outs = [n(x) for n in self.neurons]
return outs[0] if len(outs) == 1 else outs
def parameters(self):
return [p for neuron in self.neurons for p in neuron.parameters()]
'''
Input x is passed through each neuron of layer l, hence 4 values output from the layer
'''
x = [1.0, 2.0, 3.0 ]
l = Layer(3, 4)
l(x)
[Value(data=0.971182517031463),
Value(data=-0.9790568300306317),
Value(data=-0.4857385699176223),
Value(data=-0.9636928832453497)]
Next, we'll define an MLP class that's our neural network
class MLP:
def __init__(self, nin, nouts): # nin is the number of inputs for the first layer and nouts is an [] for output in each layer
sz = [nin] + nouts
self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
In the example we create the following MPL
- First layer: 3 inputs
- Middle layers: 4 and 4 outputs
- Output layer: 1
x = [2.0, 3.0, -1.0]
n = MLP(3, [4, 4, 1])
n(x)
Value(data=-0.3286322706370602)
Creating a binary classifier
For the xs as inputs, we have a target ys, which are the true predictions. Weβll create an MLP with the same structure as we saw above
xs = [
[2.0, 3.0, -1.0],
[3.0, -1.0, 0.5],
[0.5, 1.0, 1.0],
[1.0, 1.0, -1.0],
]
ys = [1.0, -1.0, -1.0, 1.0] # desired targets
n = MLP(3, [4, 4, 1])
'''
Each x in xs would be passed to MLP, resulting in ypred, this is termed as forward pass
'''
ypred = [n(x) for x in xs]
ypred
[Value(data=-0.9605353699041816),
Value(data=-0.9652866142967272),
Value(data=0.26960003457444665),
Value(data=-0.9511958636250315)]
Loss
We see above that ypredi
is different than yi
. The difference between the values is termed as loss
. We can use different methods to calculate loss
. In this example, we'll make use of Squared error
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))
loss
Value(data=9.26395350181118)
draw_dot(loss)
Once we've calculated the gradient corresponding to each parameter of the MLP, we'll modify the value of each parameter by a small value also known as the learning rate
for p in n.parameters():
p.data += -0.1 * p.grad
After updating the values, we can check the loss again; this time loss should be reduced
ypred = [n(x) for x in xs]
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))
loss
Value(data=7.829761457067251)
Training loop
We can have the above steps into a training loop
- Forward pass
- Backward pass
- Update
'''
Resetting the values
'''
xs = [
[2.0, 3.0, -1.0],
[3.0, -1.0, 0.5],
[0.5, 1.0, 1.0],
[1.0, 1.0, -1.0],
]
ys = [1.0, -1.0, -1.0, 1.0] # desired targets
n = MLP(3, [4, 4, 1])
for k in range(50):
# forward pass
ypred = [n(x) for x in xs]
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))
# backward pass
for p in n.parameters():
p.grad = 0.0
loss.backward()
# update
for p in n.parameters():
p.data += -0.1 * p.grad
print(k, loss.data)
0 3.6406853121192033
1 3.610944134843126
2 3.741912394115651
3 3.2018009544051136
4 3.2524598245980503
5 1.9728216636593452
6 1.3173214047883315
7 1.0735765629752656
8 0.8890437353493715
9 0.11522009736067115
10 0.05081752438719958
11 0.03672318278028023
12 0.02979416308450841
13 0.02556184631434586
14 0.022653024581680118
15 0.02049638823502594
16 0.01881087828912575
17 0.017442066784978914
18 0.01629801693027828
19 0.015320499407445488
20 0.014470748984735923
21 0.013721869837929553
22 0.013054538143998672
23 0.012454448840696034
24 0.01191073501042354
25 0.011414954174232338
26 0.010960417756148979
27 0.010541735226885831
28 0.010154496500154066
29 0.009795045700014122
30 0.009460316742793573
31 0.009147711635699132
32 0.008855008873882249
33 0.008580293427428378
34 0.008321902472546286
35 0.00807838278062741
36 0.007848456862487212
37 0.007630995774834821
38 0.007424997058651014
39 0.007229566675831996
40 0.007043904093967112
41 0.006867289874369098
42 0.006699075268871989
43 0.00653867344237842
44 0.0063855520216464234
45 0.00623922673400612
46 0.006099255947992355
47 0.005965235965120223
48 0.005836796940995997
49 0.0057135993366707865
Inference
xs = [
[4.0, 5.0, -3.0],
]
ypred = [n(x) for x in xs]
ypred
[Value(data=0.9658818961790056)]
Conclusion
There you have it, a working neural network that is able to learn the input data and optimize its weights using gradient descent. This article was an oversimplification of the actual neural networks, but nonetheless, it covers the operations going underneath, minus the optimizations.
I hope that through this article, you were able to improve your understanding of neural networks and dive deeper into the workings of components that you might already be familiar with.
Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming aΒ sponsor.
Published via Towards AI