The mathematical building blocks of neural networks
A first look at a neural network
August 6, 2025
[ ]: import os
[Link]["KERAS_BACKEND"] = "jax"
import os
from [Link] import register_cell_magic
@register_cell_magic
def backend(line, cell):
current, required = [Link]("KERAS_BACKEND", ""), [Link]()[-1]
if current == required:
get_ipython().run_cell(cell)
else:
print(
f"This cell requires the {required} backend. To run it, change␣
,→KERAS_BACKEND to "
f"\"{required}\" at the top of the notebook, restart the runtime,␣
,→and rerun the notebook."
# The mathematical building blocks of neural networks
# A first look at a neural network
from [Link] import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images.shape
len(train_labels)
train_labels
test_images.shape
len(test_labels)
1
test_labels
import keras
from keras import layers
model = [Link](
[
[Link](512, activation="relu"),
[Link](10, activation="softmax"),
]
)
[Link](
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
[Link](train_images, train_labels, epochs=5, batch_size=128)
test_digits = test_images[0:10]
predictions = [Link](test_digits)
predictions[0]
predictions[0].argmax()
predictions[0][7]
test_labels[0]
test_loss, test_acc = [Link](test_images, test_labels)
print(f"test_acc: {test_acc}")
# Data representations for neural networks
# Scalars (rank-0 tensors)
import numpy as np
x = [Link](12)
x
[Link]
2
# Matrices (rank-2 tensors)
x = [Link]([[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]])
[Link]
# Rank-3 tensors and higher-rank tensors
x = [Link]([[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]]])
[Link]
# Key attributes
from [Link] import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images.ndim
train_images.shape
train_images.dtype
import [Link] as plt
digit = train_images[4]
[Link](digit, cmap=[Link])
[Link]()
train_labels[4]
# Manipulating tensors in NumPy
my_slice = train_images[10:100]
my_slice.shape
my_slice = train_images[10:100, :, :]
my_slice.shape
3
my_slice = train_images[10:100, 0:28, 0:28]
my_slice.shape
my_slice = train_images[:, 14:, 14:]
my_slice = train_images[:, 7:-7, 7:-7]
# The notion of data batches
batch = train_images[:128]
batch = train_images[128:256]
n = 3
batch = train_images[128 * n : 128 * (n + 1)]
#Real-world examples of data tensors
#Vector data
#Timeseries data or sequence data
#Image data
#Video data
#The gears of neural networks: tensor operations
#Element-wise operations
def naive_relu(x):
assert len([Link]) == 2
x = [Link]()
for i in range([Link][0]):
for j in range([Link][1]):
x[i, j] = max(x[i, j], 0)
return x
def naive_add(x, y):
assert len([Link]) == 2
assert [Link] == [Link]
x = [Link]()
for i in range([Link][0]):
for j in range([Link][1]):
x[i, j] += y[i, j]
return x
import time
x = [Link]((20, 100))
y = [Link]((20, 100))
t0 = [Link]()
4
for _ in range(1000):
z = x + y
z = [Link](z, 0.0)
print("Took: {0:.2f} s".format([Link]() - t0))
t0 = [Link]()
for _ in range(1000):
z = naive_add(x, y)
z = naive_relu(z)
print("Took: {0:.2f} s".format([Link]() - t0))
# Broadcasting
import numpy as np
X = [Link]((32, 10))
y = [Link]((10,))
y = np.expand_dims(y, axis=0)
Y = [Link](y, (32, 1))
def naive_add_matrix_and_vector(x, y):
assert len([Link]) == 2
assert len([Link]) == 1
assert [Link][1] == [Link][0]
x = [Link]()
for i in range([Link][0]):
for j in range([Link][1]):
x[i, j] += y[j]
return x
import numpy as np
x = [Link]((64, 3, 32, 10))
y = [Link]((32, 10))
z = [Link](x, y)
# Tensor product
x = [Link]((32,))
y = [Link]((32,))
z = [Link](x, y)
z = x @ y
def naive_vector_product(x, y):
assert len([Link]) == 1
assert len([Link]) == 1
5
assert [Link][0] == [Link][0]
z = 0.0
for i in range([Link][0]):
z += x[i] * y[i]
return z
def naive_matrix_vector_product(x, y):
assert len([Link]) == 2
assert len([Link]) == 1
assert [Link][1] == [Link][0]
z = [Link]([Link][0])
for i in range([Link][0]):
for j in range([Link][1]):
z[i] += x[i, j] * y[j]
return z
def naive_matrix_vector_product(x, y):
z = [Link]([Link][0])
for i in range([Link][0]):
z[i] = naive_vector_product(x[i, :], y)
return z
def naive_matrix_product(x, y):
assert len([Link]) == 2
assert len([Link]) == 2
assert [Link][1] == [Link][0]
z = [Link](([Link][0], [Link][1]))
for i in range([Link][0]):
for j in range([Link][1]):
row_x = x[i, :]
column_y = y[:, j]
z[i, j] = naive_vector_product(row_x, column_y)
return z
#Tensor reshaping
train_images = train_images.reshape((60000, 28 * 28))
x = [Link]([[0., 1.],
[2., 3.],
[4., 5.]])
[Link]
x = [Link]((6, 1))
x
x = [Link]((2, 3))
6
x
x = [Link]((300, 20))
x = [Link](x)
[Link]
#Geometric interpretation of tensor operations
#A geometric interpretation of deep learning
#The engine of neural networks: Gradient-based optimization
#What's a derivative?
#Derivative of a tensor operation: the gradient
#Stochastic gradient descent
#Chaining derivatives: The Backpropagation algorithm
#The chain rule
#Automatic differentiation with computation graphs
# Looking back at our first example
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
model = [Link](
[
[Link](512, activation="relu"),
[Link](10, activation="softmax"),
]
)
[Link](
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
[Link](
train_images,
train_labels,
epochs=5,
batch_size=128,
)
# Reimplementing our first example from scratch
# A simple Dense class
7
import keras
from keras import ops
class NaiveDense:
def __init__(self, input_size, output_size, activation=None):
[Link] = activation
self.W = [Link](
shape=(input_size, output_size), initializer="uniform"
)
self.b = [Link](shape=(output_size,), initializer="zeros")
def __call__(self, inputs):
x = [Link](inputs, self.W)
x = x + self.b
if [Link] is not None:
x = [Link](x)
return x
@property
def weights(self):
return [self.W, self.b]
# A simple Sequential class
class NaiveSequential:
def __init__(self, layers):
[Link] = layers
def __call__(self, inputs):
x = inputs
for layer in [Link]:
x = layer(x)
return x
@property
def weights(self):
weights = []
for layer in [Link]:
weights += [Link]
return weights
model = NaiveSequential(
[
NaiveDense(input_size=28 * 28, output_size=512, activation=[Link]),
NaiveDense(input_size=512, output_size=10, activation=[Link]),
]
)
8
assert len([Link]) == 4
# A batch generator
import math
class BatchGenerator:
def __init__(self, images, labels, batch_size=128):
assert len(images) == len(labels)
[Link] = 0
[Link] = images
[Link] = labels
self.batch_size = batch_size
self.num_batches = [Link](len(images) / batch_size)
def next(self):
images = [Link][[Link] : [Link] + self.batch_size]
labels = [Link][[Link] : [Link] + self.batch_size]
[Link] += self.batch_size
return images, labels
#Running one training step
#The weight update step
learning_rate = 1e-3
def update_weights(gradients, weights):
for g, w in zip(gradients, weights):
[Link](w - g * learning_rate)
from keras import optimizers
optimizer = [Link](learning_rate=1e-3)
def update_weights(gradients, weights):
optimizer.apply_gradients(zip(gradients, weights))
# Gradient computation
%%backend tensorflow
import tensorflow as tf
x = [Link](shape=())
with [Link]() as tape:
y = 2 * x + 3
grad_of_y_wrt_x = [Link](y, x)
%%backend tensorflow
def one_training_step(model, images_batch, labels_batch):
9
with [Link]() as tape:
predictions = model(images_batch)
loss = ops.sparse_categorical_crossentropy(labels_batch, predictions)
average_loss = [Link](loss)
gradients = [Link](average_loss, [Link])
update_weights(gradients, [Link])
return average_loss
#The full training loop
%%backend tensorflow
def fit(model, images, labels, epochs, batch_size=128):
for epoch_counter in range(epochs):
print(f"Epoch {epoch_counter}")
batch_generator = BatchGenerator(images, labels)
for batch_counter in range(batch_generator.num_batches):
images_batch, labels_batch = batch_generator.next()
loss = one_training_step(model, images_batch, labels_batch)
if batch_counter % 100 == 0:
print(f"loss at batch {batch_counter}: {loss:.2f}")
%%backend tensorflow
from [Link] import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
fit(model, train_images, train_labels, epochs=10, batch_size=128)
#Evaluating the model
%%backend tensorflow
predictions = model(test_images)
predicted_labels = [Link](predictions, axis=1)
matches = predicted_labels == test_labels
f"accuracy: {[Link](matches):.2f}"
10