Code
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from IPython.display import Image, HTML
import warnings
warnings.filterwarnings('ignore')
%matplotlib inlineimport numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from IPython.display import Image, HTML
import warnings
warnings.filterwarnings('ignore')
%matplotlib inlineThis is the first of two lectures on neural networks. Today is about how learning works: we build the model up from regression, define what “training” means, and then look carefully at the machinery that makes it possible.
We’ll cover:
Next lecture, Neural Networks II, is about making training work in practice with scikit-learn.
Neural networks are the model family behind essentially all of modern computer vision, speech, and language systems, and are competitive on tabular data too.
Recall linear regression predicts a continuous output:
\[ \hat{y} = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \cdots + \beta_p x_p = \mathbf{x}^T\boldsymbol{\beta} \]
Or in matrix form for multiple samples:
\[ \hat{\mathbf{y}} = \mathbf{X}\boldsymbol{\beta} \]
Adds Non-linearity
For binary classification, logistic regression applies a sigmoid function:
\[ P(y=1|\mathbf{x}) = \sigma(\mathbf{x}^T\boldsymbol{\beta}) = \frac{1}{1 + e^{-\mathbf{x}^T\boldsymbol{\beta}}} \]
The sigmoid function introduces non-linearity:

A single neuron with a sigmoid activation is essentially logistic regression!
Neural networks extend this by:
This allows neural networks to learn complex, non-linear decision boundaries.
An artificial neuron is loosely modeled on biological neurons:

From cs231n
A neuron performs the following operation:
\[ \text{output} = f\left(\sum_{i=1}^n w_i x_i + b\right) \]
Where:
ReLU (Rectified Linear Unit) - most popular today: \[ \text{ReLU}(x) = \max(0, x) \]

Answer: Without non-linearity, multiple layers collapse to a single linear transformation!
A Multi-Layer Perceptron stacks multiple layers of neurons:

From cs231n

Key property: Every neuron in layer \(i\) connects to every neuron in layer \(i+1\).
This is also called a Fully Connected Network (FCN) or Dense Network.
For a network with \(K\) hidden layers:
\[ \begin{aligned} \mathbf{h}_1 &= f(\boldsymbol{\beta}_0 + \boldsymbol{\Omega}_0 \mathbf{x}) \\ \mathbf{h}_2 &= f(\boldsymbol{\beta}_1 + \boldsymbol{\Omega}_1 \mathbf{h}_1) \\ &\vdots \\ \mathbf{h}_K &= f(\boldsymbol{\beta}_{K-1} + \boldsymbol{\Omega}_{K-1} \mathbf{h}_{K-1}) \\ \mathbf{\hat{y}} &= \boldsymbol{\beta}_K + \boldsymbol{\Omega}_K \mathbf{h}_K \end{aligned} \]
Where:
Training means finding weights that minimize a loss function:
For regression (e.g., predicting house prices): \[ L = \frac{1}{N}\sum_{i=1}^N (\hat{y}_i - y_i)^2 \quad \text{(Mean Squared Error)} \]
For classification (e.g., digit recognition): \[ L = -\frac{1}{N}\sum_{i=1}^N \sum_{c=1}^C y_{ic} \log(\hat{y}_{ic}) \quad \text{(Cross-Entropy)} \]
Goal: Find parameters \(\theta = \{\boldsymbol{\Omega}_k, \boldsymbol{\beta}_k\}\) that minimize \(L\).
The loss function creates a surface over the parameter space:

For neural networks, we can’t solve analytically—we need gradient descent!
Imagine you’re lost in foggy mountains and want to reach the valley:

What would you do?
This is gradient descent!
For a function \(L(\mathbf{w})\) where \(\mathbf{w} = (w_1, \ldots, w_n)\), the gradient is:
\[ \nabla_\mathbf{w} L(\mathbf{w}) = \begin{bmatrix} \frac{\partial L}{\partial w_1}\\ \frac{\partial L}{\partial w_2}\\ \vdots \\ \frac{\partial L}{\partial w_n} \end{bmatrix} \]
Start with random weights \(\mathbf{w}^{(0)}\), then iterate:
\[ \mathbf{w}^{(t+1)} = \mathbf{w}^{(t)} - \eta \nabla_\mathbf{w} L(\mathbf{w}^{(t)}) \]
Where:
Stop when:
The learning rate \(\eta\) is crucial:
Too small: Slow convergence
Too large: May fail to converge or even diverge!

Gradient descent needs \(\nabla_\theta L\) – the partial derivative of the loss with respect to every weight and bias in the network.
But \(L\) is a deeply nested composition:
\[ L = \ell\big(\,\boldsymbol{\beta}_K + \boldsymbol{\Omega}_K\, f(\boldsymbol{\beta}_{K-1} + \boldsymbol{\Omega}_{K-1}\, f(\cdots f(\boldsymbol{\beta}_0 + \boldsymbol{\Omega}_0 \mathbf{x})\cdots))\,,\ y\big) \]
That something is backpropagation: the chain rule, organized on a computation graph.
The way we are going to differentiate more complex functions is to first build a “computation graph.”
We’ll see that we can “propagate backwards” through the graph to calculate the gradients of the loss function with respect to the parameters.
It’s a scalable approach employed by TensorFlow and PyTorch, and in fact we’ll follow the PyTorch interface definition.
This section is a condensed version of NN II – Compute Graph and Backpropagation, which builds a full training framework. Read that for the complete treatment.
Value ClassTo do that we will
class called Value,This is similar to how PyTorch defines its Tensor class.
First, the class has only a simple initialization method and a representation method.
# Value version 1
class Value:
def __init__(self, data):
self.data = data
def __repr__(self):
"""Return a string representation of the object for display"""
return f"Value(data={self.data})"Which we can instantiate and evaluate as follows.
a = Value(4.0)
aValue(data=4.0)
If you are not familiar with python classes, there are a few things to note here.
self is just a pointer to the object itself.__init__ method is called when you initialize a class object.__repr__ method is how you represent the class object.The Value object doesn’t do much yet. When python tries to add two objects a and b, internally it will call a.__add__(b), so we add __add__(), __mul__() and a relu() method.
# Value version 2
class Value:
def __init__(self, data):
self.data = data
def __repr__(self):
"""Return a string representation of the object for display"""
return f"Value(data={self.data})"
def __add__(self, other): # self + other
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data)
return out
def __mul__(self, other): # self * other
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data)
return out
def relu(self):
out = Value(np.maximum(0, self.data))
return outNow we can use the operations.
a = Value(4.0)
b = Value(-3.0)
c = Value(8.0)
d = a*b+c
dValue(data=-4.0)
Internally, python calls __mul__ on a, then __add__ on the temporary product object.
In order to calculate the gradients, we will need to capture the computation graph.
To do that, each output stores pointers to its operands as a tuple of child nodes, plus the operator that produced it. We’ll also add labels for convenience.
# Value version 3
class Value:
# vvvvvvvvvvvv vvvvvvv vvvvvvvv
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self._prev = set(_children) # the operand nodes
self._op = _op # the operation that created this node
self.label = label # label for the node
def __repr__(self):
"""Return a string representation of the object for display"""
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), '+') # store children and operator
return out # ^^^^^^^^^^^^^ ^^^
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
return out
def relu(self):
out = Value(np.maximum(0, self.data), (self,), 'ReLU')
return outLet’s instantiate a few Value objects and do some operations with them.
a = Value(4.0, label='a')
b = Value(-3.0, label='b')
c = Value(8.0, label='c')
d = a*b ; d.label = 'd'
e = d + c ; e.label = 'e'We can now inspect the operands and the operation that created each node.
e._prev, e._op, e.label({Value(data=-12.0), Value(data=8.0)}, '+', 'e')
The name _prev will make more sense when we view these operations as a graph.
Finally we add a member variable, grad, to store the partial derivative of the output node with respect to this node. It defaults to zero.
# Value version 4
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.grad = 0.0 # default to 0 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
self._prev = set(_children)
self._op = _op # store the operation that created this node
self.label = label # label for the node
def __repr__(self):
"""Return a string representation of the object for display"""
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), '+')
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
return out
def relu(self):
out = Value(np.maximum(0, self.data), (self,), 'ReLU')
return outWe now have enough information stored to visualize the graph. These two functions walk the graph to collect all nodes and edges (trace) and draw them as a directed graph (draw_dot).
trace() and draw_dot()from graphviz import Digraph
def trace(root):
# builds a set of all nodes and set of all 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='svg', 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 | data %.4f | 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 dotLet’s build a small three-stage graph, ending in a node we’ll call L (think: loss).
a = Value(4.0, label='a')
b = Value(-3.0, label='b')
c = Value(8.0, label='c')
d = a*b; d.label = 'd'
e = d + c; e.label = 'e'
f = Value(2.0, label='f')
L = e*f; L.label = 'L'
draw_dot(L)Every Value becomes a node; the operators are drawn as small nodes too. Computing the data values left-to-right is the forward pass.
We have placeholders for the gradients, but they are currently all zero.
Before we automate backpropagation, let’s calculate the gradients by hand to understand the procedure.
For the output node \(L\), we trivially have \(\frac{dL}{dL} = 1\):
\[ \frac{dL}{dL} = \lim_{h \rightarrow 0} \frac{ (L+h) - L }{h} = \frac{h}{h} = 1 \]
L.grad = 1.0Going backwards one step, \(L = e \times f\), so
\[ \frac{\partial{L}}{\partial{e}} = \frac{\partial}{\partial{e}} (e\times f) = f, \qquad \frac{\partial{L}}{\partial{f}} = \frac{\partial}{\partial{f}} (e\times f) = e. \]
So we just assign the gradient to the value of the other operand.
e.grad = f.data
f.grad = e.data
draw_dot(L)For products, the partial derivative w.r.t. one operand is simply the other operand.
We needed the node values e.data and f.data to compute these gradients. All the node values come from the forward pass – so the forward pass must run first.
Sanity check: f.grad says \(L\) should change by e.data \(= -4\) for a unit change in \(f\). Let’s wiggle \(f\) by \(h\) and see.
def wiggle(h = 0.0):
a = Value(4.0, label='a')
b = Value(-3.0, label='b')
c = Value(8.0, label='c')
d = a*b; d.label = 'd'
e = d + c; e.label = 'e'
f = Value(2.0, label='f')
f += h
L = e*f; L.label = 'L'
print(L)
wiggle(0.0)
wiggle(1.0)Value(data=-8.0)
Value(data=-12.0)
Now we want \(\frac{\partial{L}}{\partial{c}}\) – how much \(L\) varies if we vary \(c\).
Looking at the graph, \(c\) influences \(e\) and \(e\) influences \(L\):
\[ c \rightarrow e \rightarrow L. \]
We have \(e = d + c\), so the local derivative is
\[ \frac{\partial{e}}{\partial{c}} = \frac{\partial{}}{\partial{c}} (d + c) = 1. \]
For addition, the partial derivative w.r.t. either operand is 1.
We know \(\partial{L}/\partial{e}\) and we know \(\partial{e}/\partial{c}\). How do we get \(\partial{L}/\partial{c}\)?
If a variable \(L\) depends on the variable \(e\), which itself depends on the variable \(c\), then \(L\) depends on \(c\) as well, via the intermediate variable \(e\), and
\[ \frac{\partial L}{\partial c} = \frac{\partial L}{\partial e} \cdot \frac{\partial e}{\partial c}. \]
More precisely, noting where each derivative is evaluated:
\[ \left.\frac{\partial L}{\partial c}\right|_{c} = \left.\frac{\partial L}{\partial e}\right|_{e(c)}\cdot \left. \frac{\partial e}{\partial c}\right|_{c}. \]
We evaluate the derivatives at the specific values of the variables that we calculated in the forward pass.
Since \(\partial e/\partial c = 1\),
\[ \frac{\partial L}{\partial c} = \frac{\partial L}{\partial e} \cdot \frac{\partial{e}}{\partial{c}} = \frac{\partial L}{\partial e} \cdot 1, \]
and identically for \(d\).
With the addition operator, we just route the parent gradient to the child.
d.grad = e.grad
c.grad = e.grad
draw_dot(L)One more step. We have \(\frac{\partial{L}}{\partial{d}}\) and want \(\frac{\partial{L}}{\partial{a}}\) and \(\frac{\partial{L}}{\partial{b}}\).
Since \(d = a \cdot b\), the local derivatives are \(\partial d/\partial b = a\) and \(\partial d/\partial a = b\), so by the chain rule
\[ \frac{\partial{L}}{\partial{b}} = \frac{\partial{L}}{\partial{d}} \cdot \frac{\partial{d}}{\partial{b}} = \frac{\partial{L}}{\partial{d}} \cdot a, \qquad \frac{\partial{L}}{\partial{a}} = \frac{\partial{L}}{\partial{d}} \cdot b. \]
Fully expanded, this is a chain of local derivatives all the way from \(L\) back to \(b\):
\[ \frac{\partial{L}}{\partial{b}} = \frac{\partial{L}}{\partial{e}} \cdot \frac{\partial{e}}{\partial{d}} \cdot \frac{\partial{d}}{\partial{b}}. \]
b.grad = a.data * d.grad
a.grad = b.data * d.grad
draw_dot(L)We’ve traversed all the way back to the inputs and calculated all the partial derivatives.
Check it yourself: b.grad \(= 8\), so wiggling \(b\) by \(1\) should change \(L\) by \(8\). Modify wiggle() above to confirm.
What we just did, stated as an algorithm:
data.L.grad = 1.+ node: pass the gradient through unchanged* node: multiply by the other operand’s valueReLU node: pass through if the input was \(> 0\), otherwise send \(0\)Every step is local. That is why it costs about the same as one forward pass, no matter how many parameters there are.
This is the essence of Back Propagation.
The same recipe applies to a real neuron. Here is one with two inputs, two weights, a bias, and a ReLU:
# 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.8813735870195432, label='b')
x1w1 = x1*w1; x1w1.label = 'x1*w1'
x2w2 = x2*w2; x2w2.label = 'x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1w1 + x2w2'
n = x1w1x2w2 + b; n.label = 'n'
o = n.relu(); o.label = 'o'draw_dot(o)The only new operation is the ReLU. It is technically not differentiable at 0, but in practice we take the derivative to be \(0\) when the input is \(\le 0\) and \(1\) when it is \(> 0\).
o.grad = 1.0
n.grad = (o.data > 0) * o.grad # ReLU: pass through if the input was positive
x1w1x2w2.grad = n.grad # '+' routes the gradient
b.grad = n.grad
x1w1.grad = x1w1x2w2.grad
x2w2.grad = x1w1x2w2.grad
w1.grad = x1.data * x1w1.grad # '*' multiplies by the other operand
w2.grad = x2.data * x2w2.grad
draw_dot(o)Note w2.grad \(= 0\) because \(x_2 = 0\): this weight has no influence on the output for this input.
Once every parameter has its grad, a gradient descent step is just
\[ w \leftarrow w - \eta\, \frac{\partial L}{\partial w} \]
applied to each parameter leaf node – exactly the update rule from the previous section.
In M12 we finish the job: add a backward() method to Value that walks the graph automatically, handle nodes used more than once (gradients accumulate), assemble neurons into layers and an MLP, and write the training loop. In Neural Networks II we let scikit-learn do all of that for us.
Backprop gives us the gradient of the loss for one sample, \(\nabla_\mathbf{w} \ell_i(\mathbf{w})\). How many samples should we run it on before taking a step?
Full Batch Gradient Descent: Compute gradient using ALL training samples:
\[ \nabla_\mathbf{w} L = \frac{1}{N}\sum_{i=1}^N \nabla_\mathbf{w} \ell_i(\mathbf{w}) \]
Problems:
Stochastic Gradient Descent: Historically meant using ONE random sample at a time:
\[ \mathbf{w}^{(t+1)} = \mathbf{w}^{(t)} - \eta \nabla_\mathbf{w} \ell_i(\mathbf{w}^{(t)}) \]
Advantages:
Disadvantage:
Mini-Batch GD: Best of both worlds—use a small batch of samples:
\[ \nabla_\mathbf{w} L \approx \frac{1}{B}\sum_{i \in \text{batch}} \nabla_\mathbf{w} \ell_i(\mathbf{w}) \]
Typical batch sizes: 32, 64, 128, 256
Advantages:
This is what most modern neural network training uses!

(For illustration purposes only – not a real training curve.)
In Neural Networks II we switch to practice: MLPClassifier and MLPRegressor in scikit-learn, MNIST and California housing, hyperparameters, preprocessing, and how to recognize and fix training that isn’t working.
Full treatments of today’s topics in the course notes:
Value framework, automatic backward(), gradient accumulation, and a complete training loopAdditional resources: