Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Micrograd and gradient descent

Original study notes by Satish Bajagain, following Andrej Karpathy’s Zero to Hero. Comments, intermediate attempts and saved outputs are retained. Section titles are navigation additions.

A scalar-valued autograd engine

https://www.youtube.com/watch?v=VMj-3S1tku0


Our goal in this lecture is to see what neural network training looks like under the hood
We will define and train a neural net
We go through building of a library named micrograd by andrej

Micrograd is an autograd engine, i.e. automatic gradient engine, and it implements backpropagation


autograd: software framework in machine learning that automatically computes the mathematical derivatives during model training
Backpropagation allows us to evaluate the gradient of some loss function w.r.t. weights of a neural network,
then it allows us to iteratively tune the weights of that neural network to minimize the loss function, and thus improve the accuracy of the neural network.

backpropagation is at mathematical core of modern deep neural network library eg. pytorch or jaxx

# micrograd allows us to build mathematical expressions, inputs are wrapped into Value object
# then expressions are built, eg. addition, multiplication, exponentiation, divide by, etc.
# then we build the expression graph with given 2 inputs and output eg. g
# in the background, micrograd knows and stores everything, eg. child nodes of c are a and b, c is result of addition operation
# then micrograd allows us to do forward pass, where we compute and look at the value at g
# not only this, micrograd also allows us to do backpropagation at node g by calling eg. g.backward()
# this backpropagation will start at g, and go backwards through that expression graph, and it will recursively apply the chain rule
# we evaluate derivative of g w.r.t all internal nodes: dg/d(internal node), so with c, d, e, and finally to inputs: a and b too
# then we can query the derivative of g w.r.t a, i.e. dg/da which will be referred via a.grad
# this derivative is very important information, it tells us how a and b are affecting g through this mathematical expression
# eg. if dg/da = 138, and dg/db=645, then if we slightly nudge/increase a, it means g will grow, and the slope of that growth will be 138
# similary, the slope of growth of b is 645 at that point, it also shows that changing b has a higher impact than changing a but only locally
# This tells us that b has the stronger local influence, regardless of whether that influence increases or decreases the output.
# The gradient ∂g/∂a tells us how sensitive the output g is to a tiny change in a, while keeping all other inputs fixed. 
# A larger absolute gradient means that input has a stronger local influence on the output at the current point.
# the expressions themselves are meaningless, we are learning about neural networks, 
# and neural networks themselves are mathematical expressions, or lets say class of mathematical expressions
# neural networks take input data, weights as input, and output: loss function, prediction
# backpropagation is general, it works on arbitrary mathematical expressions
# micrograd is a scalar values autograd engine, its working on level of individual scalars, eg. -4, +2
# and we are breaking neural nets down all the way to the atoms of individual scalars
# this wouldn't be done in production, its just being done right now for pedagogical(for teaching practice) reasons
# so we don't have to deal with n-dimensional tensors that we use in modern deep neural network library
# this is done so we understand backpropagation and chain rule and training
# so if we actually want to train bigger networks, we have to use tensors, but the math/functionality is the same in both cases
# n-dimensional tensor: fundamental data structure in machine learning, acting as multi-dimensional arrays that generalize scalars (0D), vectors (1D), and matrices (2D) to higher dimensions

# summary, we are using scalar values because of simplicity of understanding, but in practice we use tensors
# we are taking scale values and packaging them into tensors(array of scalars)
# operations in large arrays allows to take advantage of parallelism in a computer, whole thing runs faster
# so there's no need to deal with tensors from scratch
# so in micrograd, we understand at the fundamental level, and speed up later
# you need micrograd to train neural networks, it is enough for functionality, everything else is just efficiency

# micrograd: just 2 files, the engine.py doesn't know anything about neural nets, and the other is nn.py(neuralnets.py) that runs on top of engine.py
# actual backpropagation autograd engine: in engine.py, very simple python
# nn.py: neural network library: we define neuron, layer of neurons, multilayer perceptron
# very very few lines of code, about 100 lines each
# so that's enough to understand training, and everything else is just efficiency

Implementing MICROGRAD

Derivatives and finite differences

20.0

https://numpy.org/doc/stable/reference/generated/numpy.arange.html
numpy.arange(): Returns evenly spaced values within a given interval.

[-5.   -4.75 -4.5  -4.25 -4.   -3.75 -3.5  -3.25 -3.   -2.75 -2.5  -2.25
 -2.   -1.75 -1.5  -1.25 -1.   -0.75 -0.5  -0.25  0.    0.25  0.5   0.75
  1.    1.25  1.5   1.75  2.    2.25  2.5   2.75  3.    3.25  3.5   3.75
  4.    4.25  4.5   4.75  5.  ]
[100.      91.6875  83.75    76.1875  69.      62.1875  55.75    49.6875
  44.      38.6875  33.75    29.1875  25.      21.1875  17.75    14.6875
  12.       9.6875   7.75     6.1875   5.       4.1875   3.75     3.6875
   4.       4.6875   5.75     7.1875   9.      11.1875  13.75    16.6875
  20.      23.6875  27.75    32.1875  37.      42.1875  47.75    53.6875
  60.    ]
[<matplotlib.lines.Line2D at 0x7c1760e9cd70>]
<Figure size 640x480 with 1 Axes>
# what is the derivative at a point in the curve?
# df(x)/dx, you used to use the formula nx^(n-1), so our df/dx would be 6x - 4, but we aren't going to do it this way
# we wouldn't write out the expression for neural nets, it would be massive, and 10s of thousands of terms
# we won't take symbolic approach
# instead we use this: lim h->0 (f(x+h)-f(x))/h, the definition asks to take h tends to 0, i.e. very very small value of h, eg. 0.001
f(3):  20.0
f(3+h):  20.000014000003002
Slope or df/dx at x = 3 using formula:  14.000003002223593
f'(x) = 6x-4 = 6*3-4 = 18-4 = 14
# how about slope at -3
# expectation: if we move at slightly positive direction of x from -3, the function (you can see above) goes down
# so for slope at x+h, we get the slope lower than the slope at -3
f(-3):  44.0
f(-3+h):  43.999978000003
Slope or df/dx at x = 3 using formula:  -21.999997002808414
f'(x) = 6x-4 = 6*(-3)-4 = -18-4 = -22
2.999378523327323e-06

Let’s get a little complex now

d1 4.0
d2 3.999699999999999
slope -3.000000000010772
verifying slope: dd/da = d(ab + c)/da = b*da/da + dc/da = b*1 + 0 = b = -3.0
d1 4.0
d2 4.0002
slope 2.0000000000042206
verifying slope: dd/db = d(ab + c)/db = a*db/db + dc/db = a*1 + 0 = a = 2.0
d1 4.0
d2 4.0001
slope 0.9999999999976694
verifying slope: dd/dc = d(ab + c)/dc = d(ab)/dc + dc/dc = 0 + 1 = 1

Building Value and drawing the graph

# as we know, neural networks will be pretty large expressions, so we need data structures that can maintain these expressions
# starting the core Value object of micrograd and its visualization
# note:
# inside a python class, when a method is declared with name: __something__, they are not just naming conventions, they are functional predefined hooks
# when we use normal syntax, eg. print(a), it internally translates it into method call a.__repr__(), or a.__str__()
# other eg. a + b calls a.__add__(b), len(a) calls a.__len__()
# these are special methods and they are commonly called as:
# DUNDER METHODS
# Python looks for these exact names and calls them automatically
# If Python syntax works on an object, there’s probably a __method__ behind it.
# some common dunder methods:
# def __init__(self):  
#     # runs when object is created (constructor)

# def __repr__(self):  
#     # defines how object is shown in console (debug view)

# def __str__(self):  
#     # defines user-friendly print output

# def __add__(self, other):  
#     # defines behavior of + operator

# def __sub__(self, other):  
#     # defines behavior of - operator

# def __mul__(self, other):  
#     # defines behavior of * operator

# def __truediv__(self, other):  
#     # defines behavior of / operator

# def __len__(self):  
#     # defines behavior of len(obj)

# def __getitem__(self, key):  
#     # defines obj[key] access

# def __setitem__(self, key, value):  
#     # defines obj[key] = value

# def __eq__(self, other):  
#     # defines == comparison

# def __lt__(self, other):  
#     # defines < comparison

# def __call__(self):  
#     # allows object to be called like a function

# def __enter__(self):  
#     # start of 'with' context manager

# def __exit__(self, exc_type, exc, tb):  
#     # end of 'with' block (cleanup)
2.0
Value (data=2.0)
Value (data=-1.0)
Value (data=4.0)

Now, we want to introduce multiple other variables/pointers to store intermediate values

Value (data=4.0)
{Value (data=-6.0), Value (data=10.0)}
# so now we know what the actual values of the children were, but not what operation created these values
Value (data=4.0)
{Value (data=-6.0), Value (data=10.0)}
+
Value (data=-6.0)
{Value (data=2.0), Value (data=-3.0)}
*
Value (data=-8.0)
# so finally, we know how we are getting the new values(by + or *) and from which values we are getting them
# since the expressions will be larger, we want a way to properly visualize these expressions
Digraph: Directed graph, i.e. every edge has an arrow
for undirected graph, use just Graph instead of Digraph

Official Documentation:

https://graphviz.readthedocs.io/en/stable/
https://graphviz.readthedocs.io/en/stable/api.html

Constructor for digraph:

Digraph(
    name='G',#graph's internal name, usually we don't care about this
    comment=None, #comment for the generated dot file. used for debugging
    filename=None, #when we save the graph, this is written to the file, eg. filename="graph.gv"
    directory=None, #save the graph into images/graph.gv when directory="images"
    format='pdf', #png, pdf, svg(vector graphics, scalable, looks crisp in notebooks), jpg, gif, ps
    engine='dot', #there are different layout algorithms like neato, fdb, circo, twopi. for trees and computation graphs, dot is usually best, and dot is the default one
    encoding='utf-8',
    graph_attr=None, #by default it is Top-Down, i.e. vertical, we can set it to Left-Right using graph_attr={'rankdir':'LR'}
                graph_attr={
                "rankdir":"TB",
                "bgcolor":"black",
                "label":"My Graph"
            }
    node_attr=None,#default style for every node
                node_attr={
                    "shape":"circle",
                    "color":"blue"
                }
    edge_attr=None, #default styling for all edges
                edge_attr={
                    "color":"red"
                }
)
# create a node
# dot.node(
#     name="A",#every node must have a unique name
#     label="Hello"
# )
# uid = str(id(n))
# No two Value objects will have the same id.
# Python guarantees every object has a unique identity during its lifetime. eg. 139832178284912
# label shows what to display
# shape can be "circle" or "record"(box-like sections)
# why record? to include multiple pieces of information (e.g., value and gradient), 
# so a record lets you display them cleanly in compartments.
# dot.edge("A","B") simply means, start directed edge from A and end to B. A ---> B
# dot.edge(uid + n._op, uid). The operation node points to the value node
# id(obj) returns an integer that's unique for that object during its lifetime. 
# then convert it to string cuz graphviz expects node names as strings
# { data 3.1416 }
Loading...

Manual backpropagation

# we are able to do this forward pass. The output of this forward pass is -8
# now we want to do backpropagation, we start at L, then we go backwards and calculate the gradient along all these intermediate values
# i.e. for each of these intermediate nodes, we are computing their derivative w.r.t L
# dL/dL = 1
# some of these parameters will be weights, and then we can know how the weights impact the loss function
# dOutput/dLeafnodes, leaf nodes: data itself, weights
# we don't want or use dL/dInputNode as the value of input data is fixed
# but the weights will be iteratively adjusted using gradient information
# we want a variable inside the value class that will store the derivative of L w.r.t that value, and we will call this grad
# initially set the grad value to 0, i.e. no effect of input on output, i.e. change in this input doesn't change output
# eg. f.grad = dL/df
# now we fill in these gradients and do backpropagation manually
-2.000000000000668
Loading...
# now we do dL/dd and dL/df
L = d * f
dL/dd = ?. Ans: f
proof:
(f(x+h)-f(x))/h, f(x) = L
= ((d+h)*f - d*f)/h
= (df + hf - df)/h
= (hf)/h
= f
Symmetrically, dL/df = d

So, d.grad = Value(f)
and, f.grad = Value(d)
4.0
-2.0
Loading...
# now to derive dL/dc
# this is the crux of backpropagation
# we know impact of d on L, and impact of c on d, and impact of e on d, but how to find how c impacts L? dL/dc?
# chain rule
# if a car travels twice as fast as a bicycle, and the bicycle is 4x as fast as a man
# then the car travels 2*4=8 times as fast as the man

dd/dc = ?
dd/de = ?
d = c + e
dd/dc = 1.0

(f(x+h) - f(x))/h
= ((c+h+e)-(c+e))/h
= (c+h+e-c-e)/h
= h/h
= 1

By symmetry, dd/de = 1.0
# this is local derivative, cuz we are concerned only on influence of c on d and e on d
dL/dc = ?
we have, dL/dd, dd/dc
So, dL/dc = (dL/dd) * (dd/dc)
dL/dc = (dL/dd) * 1
# So a plus node just routes the gradient
Similarly, dL/de = (dL/dd) * (dd/de) = (dL/dd) * 1
dL/dd = -2.0
dL/de = -2.0
Loading...
# now we find a.grad and b.grad, i.e. dL/da and dL/db
dL/da = (dL/de)*(de/da)
dL/db = (dL/de)*(de/db)

So, de/da = d(a*b)/da = b and 
    de/db = a
Earlier we found that criss cross type of relationship, we are going to reuse that concept
We already know, dL/de = e.grad
so, dL/da = (e.grad) * b.data
    dL/db = (e.grad) * a.data
2.0
-3.0
-2.0
Loading...
-3.9999999999995595
# so, we iterated through all the nodes one by one and locally applied the chain rule to find the gradient
# for any node, we can always find its gradient, i.e. dL/dN
# so backpropagation is just recursive application of chain rule backwards through the computational graph
# so now lets see this power in action briefly
# we are going to make the value of nudge our inputs so that our L goes up
# so for L to move up, we move a.data in the direction of the gradient, i.e. a.data += a.grad
# so the code below is just one step of optimization that we are running
# these gradients give us some power to influence the final output, and this is really useful for training knowledge
Before: -8.0
After: -7.286496

Backpropagating through a neuron

One more example of manual backpropagation but with complex and useful example

# we're going to backpropagate through a neuron
# bias: sort of innate trigger happiness of a neuron, something that is specific to a neuron and independent of the input
# activation function: sigmoid or tanh, sort of a squishing function
<Figure size 640x480 with 1 Axes>
# numpy has np.tanh
# the inputs get squashed on the y coordinate
# the function will only go upto 1, we cap it smoothly at 1 and -1
neuron_model
neural_network
Loading...
Loading...
0.4999999999999999
Loading...
Loading...
Loading...
Loading...

Automating the backward pass

# so if we want the neurons output to increase, we move in the direction of the gradient
# obviously we won't implement backpropagation manually, so now we automate backpropagation for each operation
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
# so one last piece, we are calling _backward manually many times, we want to ensure doing it only once is enough
# first we need to ensure that everything is already in place before doing backpropagation
# everything needed should be there
# this ordering of graphs can be achieved using topological sort: it is the laying out of a graph such that all edges go only from left to right

# Purpose of topological sort:
# Figure out the correct order to perform backpropagation.
# Key rule: A node can only be processed after all the nodes that depend on it have been processed.

# remember:
# c = a * b
# c._prev = {a, b}
# trace an example for topological sort:
# a = Value(2)
# b = Value(3)
# c = a * b
# d = c + a

# build_topo(d)
        # status:
        # topo = []#remembers the order, provides the order to backprop, thus maintained in a list as it preserves order of append
        # visited = {}#remembers whether we have seen the node or not, so it is a set: fast to check membership, no need to worry about order
# we're at d, d is not visited so add d, visited = {d}
# parents of d are c and a
# visit c
# visited = {d, c}
# parents of c are a and b
# visit a
# visited = {d, c, a}
# parents of a: None, so append it to topo as well
# topo = [a] return
# build_topo(b) is called as previously for other nodes d, c and a
# visited = {d,c,a,b}
# parents of b: None, so append it to topo as well
# topo = [a, b]
# we back at c now, append it to topo too
# topo = [a, b, c]
# topo = [a, b, c, d]
[Value (data=6.881373587019543), Value (data=-3.0), Value (data=2.0), Value (data=-6.0), Value (data=0.0), Value (data=1.0), Value (data=0.0), Value (data=-6.0), Value (data=0.8813735870195432), Value (data=0.7071067811865476)]
Loading...
Loading...

MAIN BACKPROPAGATION CODE

Loading...
Loading...

Shared nodes and gradient accumulation

ONE BUG FIX

# we have one bug to fix
# when one node is used multiple times
Loading...
Loading...
Loading...
Loading...
# the bug occurs when a variable gets used more than once
# in above eg. when we are backpropagating, and we are at e, then we write a value to a and b 's gradient
# then after processing e, when we process d, we simply overwrite to a and b's gradient
# look at multivariate case of chain rule and its generalization 
# the solution is to accumulate/add these gradients
# so in the Value class, we just use += instead of just =
# bug fix was very simple, for all three _backward methods, use +=
# eg for one of the cases:
        # def _backward():
        #     self.grad += out.grad * other.data
        #     other.grad += out.grad * self.data

Extending the engine

minor additions, add exponential, division, rmul, sub etc features

Loading...
Value (data=3.0)
Value (data=6.0)
Value (data=4.0)
Value (data=7.38905609893065)
# but its annoying that 2 * a, or 1 + a wont work
# this is because a + 2 calls a.__add__(2) but 2 + a calls 2.__add__(a)
# while we can modify the method of a since it is a Value object that we created
# we can't risky modifying the add method of 2 i guess if it is even possible

# buuut
# there is something called the __rmul__ which acts like a fallback
# so if python can't do 2*a cuz it gives error, it will try doing a * 2 instead

a/b = a * (1/b) = a * (b**-1)

so for division we actually want to implement a^b instead of a/b

Value (data=0.5)
Value (data=-2.0)

Let’s focus on what we were working on previously, we break up tanh and exercise with more operations

Loading...
# summary: the level at which you implement the operations is totally upto you, and we got to work with different operators

Comparing with PyTorch

NOW WE DO THE EXACT SAME THING, BUT BY USING A MODERN DEEP NEURAL NETWORK LIBRARY: PYTORCH

# Notes:
# micrograd is a scalar valued engine, so we only have scalar values like 2.0
# but in pytorch everything is based around tensors, and tensors are just n-dimensional arrays of scalars
tensor([[1., 2., 3.],
        [4., 5., 6.]])
torch.Size([2, 3])
tensor([2.])
tensor([2.], dtype=torch.float64)
# by default pytorch assumes that nodes don't require gradients, i.e. set to false for efficiency reasons, cuz no need of gradients for leaf nodes
# thus we need to explictly state that all nodes require gradients
0.7071066904050358
---
x2 0.5000001283844369
w2 0.0
x1 -1.5000003851533106
w1 1.0000002567688737
tensor([0.7071], dtype=torch.float64, grad_fn=<TanhBackward0>)
tensor([0.7071], dtype=torch.float64)
0.7071066904050358
# adv of pytorch: it can do what we did with tensors as a special case when our tensors are all single element tensors
# but big deal is that everything is very efficient because we are working with these tensor objects, we can do everything in parallel on all of these tensors

Neurons, layers and the MLP

building a neural net library (multi-layer perceptron) in micrograd

# since we now have machinery enough to build complicated mathematical expressions
# we can also start building neural nets (neural nets are just a specific class of mathematical expressions)

Original note stored inside an HTML comment

<!---

a neuron stores a bias and multiple weights.
Suppose we have 3 inputs: x = [2.0, 3.0, -1.0]
Suppose we randomly initialize weights and biases as:
w = [0.5, -0.2, 0.8]
b = 0.1
The neuron computes: x1*w1 + x2*w2 + x3*w3 + b = -0.3
Then it applies tanh: tanh(-0.3) is the neuron's output

1. Neuron initialization:
"Create a neuron that expects 3 inputs."
Neuron(3)
class Neuron:
    def __init__(self, nin): #nin: number of inputs
    
self.w = [Value(random.uniform(-1,1)) for _ in range(nin)]
this creates a list of Value objects (not floats)
[
 Value(0.27),
 Value(-0.91),
 Value(0.63)
]
Why Value objects? cuz we want gradients later

self.b = Value(random.uniform(-1,1))
creates a bias, and each neuron has 1 bias

--- forward pass ---
def __call__(self, x):
instead of using a named forward method, karpathy prefers to use n(x) which calls Neuron.__call__(n, x)

--- zip ---
suppose:
weights: [0.5, -0.2, 0.8]
inputs: [2, 3, -1]
then zip(self.w, x) produces:
	(0.5, 2)
	(-0.2, 3)
	(0.8, -1)
its like merging two lists together
then generator produces something like this after multiplication:
		1.0
		-0.6
		-0.8
		
then act = sum((wi*xi for wi,xi in zip(self.w, x)), self.b)
is equivalent to bias + w1x1 + w2x2 + ... + wnxn
then out = tanh(act)

-----That's it for a single neuron, a neuron's process can be summarized as: n inputs -> weighted sum -> tanh -> one output-----

Now, lets talk about a layer
A layer is just many neurons side by side
eg. Layer(3,4) means there are 4 neurons in a layer and each of the four neurons receive 3 inputs x1,x2 and x3 each.
suppose x = [2,3,-1]
neuron 1 computes n1(x)
neuron 2 computes n2(x)
neuron 3 computes n3(x)
neuron 4 computes n4(x)

Layer initialization:
self.neurons = [Neuron(nin) for _ in range(nout)]
Suppose Layer(3,4)
then python builds [Neuron(3),Neuron(3),Neuron(3),Neuron(3)]
and each neuron(3) has 3 weights and one bias

---Layer's forward pass---
outs = [n(x) for n in self.neurons]
This code is equivalent to this:
outs = []
for n in self.neurons:
    outs.append(n(x))

then output is outs=[0.7,-0.3,0.9,0.1]


---- MLP ----
its just a list of layers, nothing more
MLP(3,[4,4,1])
it means, 3 inputs, 1 layer with 4 neurons, 1 layer with 4 neurons, 1 layer with 1 neuron

----
sz = [nin] + nouts
suppose nin = 3 and nouts = [4,4,1]
then sz = [3,4,4,1]
why do we need this?
because every adjacent pair tells us how to build one layer accordingly
as: 3-4, 4-4 and 4-1

---
self.layers = [
    Layer(sz[i], sz[i+1])
    for i in range(len(nouts)): for i in range(3) which matches count
]
This code creates:
Layer(3,4)
Layer(4,4)
Layer(4,1)


------ FORWARD PASS THROUGH MLP ---
Suppose x=[2,3,-1]
First layer: Layer(3,4) returns [0.2,0.5,-0.1,0.8]
cuz first layer has 4 neurons, 1 neuron returns one output
Then x = layer(x)
i.e. x = [0.2,0.5,-0.1,0.8]
Next layer: Layer(4,4) returns [-0.4,0.3,0.9,-0.2]
Now x = [-0.4,0.3,0.9,-0.2]
Final layer Layer(4,1) returns 0.73
So the network's prediction is 0.73

Parameter count for MLP(3, [4, 4, 1])
For hidden layer 1: 3 weights * 4 neurons + 4 biases = 16
For hidden layer 2: 4 weidghts * 4 neurons + 4 biases = 20
For last layer: 1 weight * 4 neurons + 1 bias = 5
Grand total: 41 parameters
Each one of these 41 parameters is a Value object, and after calling backward(), each Value object will have a .grad that tells us how to adjust the parameters during learning.

---!>
Value (data=0.8126717366460746)
# schematic of a MLP: multi-layer perceptron
# note: Every neuron (except input neurons) has its own bias.
# the actual network has 21 different bias values.
# Hidden layer 1: 5 biases, Hidden layer 2: 5 biases, Hidden layer 3: 5 biases, Hidden layer 4: 5 biases, Output layer: 1 bias
# also: every arryw has a weight, the weight is not just for input neurons
#
# Earlier, we manually built expressions like:
#
#     e = a * b
#     d = e + c
#     L = d.tanh()
#
# and draw_dot(L) visualized that computation graph.
#
# With Neuron, Layer, and MLP, we no longer write those expressions ourselves.
# Instead, we simply call:
#
#     n(x)
#
# where x is the input data (e.g. [2.0, 3.0, -1.0]).
#
# Although n(x) looks like a single function call, it actually performs many
# Value operations internally. For a neuron with three inputs, it is roughly
# equivalent to writing:
#
#     p1 = w1 * x1
#     p2 = w2 * x2
#     p3 = w3 * x3
#
#     s1 = p1 + p2
#     s2 = s1 + p3
#     s3 = s2 + b
#
#     out = tanh(s3)
#
# Every multiplication, addition, and tanh() creates a new Value object.
# Each Value stores references to the Value objects that created it (_prev),
# automatically building the computation graph as the forward pass executes.
#
# Therefore:
#
#     draw_dot(n(x))
#
# does NOT visualize the function call "n(x)" itself. It visualizes the hidden
# computation graph produced by all the Value operations that occurred while
# evaluating n(x). draw_dot() starts from the final output Value and follows
# the _prev links backwards, reconstructing the entire mathematical expression.
#
# The same idea extends to a Layer and an MLP. Calling
#
#     mlp(x)
#
# sequentially calls every layer, and each layer sequentially calls every neuron.
# Each neuron builds its own expression graph, whose outputs become the inputs to
# the next layer. By the time the final prediction is produced, one large connected
# computation graph has been created automatically, even though we only wrote:
#
#     output = mlp(x)
#
# This is why draw_dot(output) can visualize the entire neural network's forward
# computation without us ever manually writing the individual expressions.
Loading...
# notice that gradients for all of them is 0, cuz we only did a forward pass
# now lets do backpropagation here

Loss and gradient descent

[Value (data=0.8126717366460746), Value (data=0.7736417670870228), Value (data=0.7155829626079588), Value (data=0.9297704830267638)]
Value (data=6.12905408285177)
-0.04414672653498933
# we saw that the gradient is positive, so it's influence on the loss is positive, so slightly decreasing this particular weight of this neuron of this layer will reduce the loss
Loading...
# gradients on input data are not significant to us, because input data are fixed and unchangeable, so we are not concerned with their gradients even though they exist
# we are interested with the gradients of weights and biases
# we want some convenient code that can help us gather all parameters of neural net and operate on them simultaneously
# then we will nudge every parameter by a tiny amount based on the gradient information
# defined parameters method for a neuron
<__main__.MLP at 0x76eb026a56a0>
41
-0.04414672653498933
0.701533678275561
0.7019751455409109
Value (data=5.786701829767263)
Value (data=4.953693919118485)
Value (data=3.502461447115042)
Value (data=2.19299253743404)
Value (data=1.2758915302474378)
[Value (data=-0.32449875003546297), Value (data=-0.9236835702546978), Value (data=-0.8555744637945119), Value (data=0.3811903341108977)]
Value (data=0.5191358455506375)
Value (data=0.03662082713803008)
[Value (data=0.8185809216684239), Value (data=-0.9781569187143231), Value (data=-0.9438152648250223), Value (data=0.991391835932298)]
# if your learning rate is too low, it will take long time to converge
# but if its too high, the whole thing gets unstable and the loss might explode
# finding a subtle step size is an art
Value (data=8.476497401128291e-05)
[Value (data=0.999620202267521), Value (data=-0.997202238808908), Value (data=-0.9912466841367535), Value (data=0.9995844023465608)]
# do it again and again, do a forward pass, backward pass, and update until loss is very less or you exceed the number of iterations
# be careful about selecting a step size too high which can result in overshooting and destabilize training
# now we have to iterate this process until desired scores are obtained
[Value (data=0.8088518819355177), Value (data=-0.7377474350651416), Value (data=0.9289440446884769), Value (data=-0.40062213654752804), Value (data=0.6422877552639026), Value (data=-0.40941960708816333), Value (data=0.531432679732588), Value (data=-0.5700844501926393), Value (data=0.5806905900555772), Value (data=-0.18085872399536607), Value (data=1.6621816874144193), Value (data=-0.712127047333736), Value (data=-0.06611187690607105), Value (data=-0.3213291670060825), Value (data=1.4383849020011432), Value (data=1.3394165145261139), Value (data=1.1912081375390375), Value (data=0.7819231331962886), Value (data=0.7526304327237721), Value (data=0.9050518483372895), Value (data=0.03087352613880031), Value (data=0.5190130231609394), Value (data=0.9757681460648503), Value (data=1.7853780339290244), Value (data=0.5798906051385159), Value (data=0.7332032613669759), Value (data=0.3711451212034126), Value (data=-0.9131118393783689), Value (data=1.0116802510598324), Value (data=0.34070381401402955), Value (data=-0.5112225059827382), Value (data=-1.0654026139897113), Value (data=-0.511363490778174), Value (data=-0.1568804168464658), Value (data=0.07179914404016574), Value (data=0.30192389227364896), Value (data=-1.6286353724473421), Value (data=-0.9201454432507071), Value (data=-0.8790888407781438), Value (data=0.8066669261414844), Value (data=0.2084758624186683)]
Value (data=-0.8735856111739421)
0 7.1510237915308235
1 6.649857831439435
2 5.5469600957745
3 3.495952575400155
4 2.2778283159059693
5 1.4694219996623783
6 0.9239611321292588
7 0.6032064669008367
8 0.4194582914005529
9 0.30786128642294985
10 0.23571573278669522
11 0.18648634257376526
12 0.1514939855461927
13 0.12584513551785426
14 0.10657196985070957
15 0.09177101325151531
16 0.08017596572781989
17 0.07092401482834695
18 0.0634163767418835
19 0.05723048088512438
[Value (data=0.9999999999138397), Value (data=-0.9504567985360031), Value (data=-0.9805216455095753), Value (data=0.9999999998546136)]
# there is actually a subtle bug (you forgot to .zero_grad() in pytorch before .backward())
# as you know, all weights and biases have a .data and a .grad, and .grad starts at zero
# then we do .backward(), then fill in the gradients, then we do an update(gradient descent) on the data
# but we don't flush the grad and it stays there
# so when we do the second forward pass and do the backward again, remember that all backward operations do a += on the grad
# so the gradients add up and never get reset to zero
# thus: we need to zero grad before backwards
# you may notice that descent happens slowly and that's fine
# the reason why the previous one worked (no reset of grad) was because this was a very very simple problem
# and it is very easy for this neural net to fit this data
# so the grads ended up accumulating and gave us a massive step size, thus we converged extremely fast
# so now we have to do lots of steps to get a very low value of loss
Loading...
# working with neural nets is tricky because your neural net might work just like ours worked with bugs
# but if the problem was complex, the loss wouldn't have been optimized very well
# now we summarize what we've learnt
# what are neural nets?
# neural nets are the mathematical expressions (fairly simple mathematical expressions in case of MLP) that take input as data, weights and bias of neural net,
# mathematical expression for the forward pass followed by a loss function(tries to measure accuracy of predictions)
# we backward the loss, use backpropagation to get the gradient, then we tune all the parameters to decrease the loss locally
# but we have to iterate this process many times and it's called the gradient descent, simply follow the gradient information
# we can have billions/trillions of parameters and get them to do whatever we want
# in case of gpt, we have massive amounts of text from the internet, and we're trying to make the neural net to predict the next word by taking a few words as input
# in that case the surface level concept is the same, but inner implementation differs
# eg. loss function would not be mean square error, it would be cross-entropy loss for predicting the next token
# there is not that much difference in using non-linearity for tanh, relu or sigmoid functions
# learning rate decay
# learning rate scaled based on number of iterations, learning rate shrinks as the network stabilizes to get the fine details

Reflections: Artificial vs. Biological Learning Building Micrograd from scalar first-principles clarifies exactly how modern neural networks learn, but it also highlights the stark contrast between machine learning and biological nervous systems.

The Weight Transport Problem: Micrograd relies on backpropagation, which requires the backward pass to use the exact same weight values as the forward pass (self.grad += out.grad * other.data). In a physical nervous system, there is no known mechanism for exact symmetric weight transport. How biology approximates this remains an open question.
Global vs. Local Learning Rules: Backpropagation requires a global error signal (the loss) to be routed backward through the entire computational graph. Biological systems are highly distributed and rely primarily on local learning rules (e.g., Hebbian learning or Spike-Timing-Dependent Plasticity), where weight updates depend only on the activity of the two adjacent neurons.
Continuous vs. Spiking: Micrograd uses continuous scalar values and non-linear activation functions like tanh. Biological systems communicate via discrete, asynchronous electrical pulses (spikes).