import marimo as mo
pyTorch, autograd, and Stochastic Gradient Descent¶
August 4 2026 Training Workshop: Introduction to Deep Graph Learning Ingo Scholtes, CAIDAS, Julius-Maximilians-Universität Würzburg (JMU), Germany
For the simple example discussed in the previous notebook, the perceptron model trained by gradient descent minimization of a loss function yields a decision boundary that closely resembles that of a logistic regression classifier. This is one of the simplest possible examples of a neural network, where the "network" actually consists of a single neuron that maps one or more inputs to a single output.
We now implement this simple perceptron classifier in the popular neural network library pytorch. We also introduce the autograd feature, which is used to automatically calculate gradients of loss functions.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scipy.stats
import seaborn as sns
import torch
from scipy.special import expit
plt.style.use('default')
sns.set_style("whitegrid")
def response(x, boundary=0.2, prob_1=1):
"""Generate a probabilistic binary response based on a threshold."""
if x > boundary:
if np.random.random_sample() < prob_1:
return 1
else:
return 0
else:
if np.random.random_sample() < 1-prob_1:
return 1
else:
return 0
x = 2*scipy.stats.uniform.rvs(size=100)-1
y = np.array([ response(x[i]) for i in range(len(x)) ])
data = pd.DataFrame({'x': x, 'y_class': y})
sns.scatterplot(x='x', y="y_class", data=data, hue='y_class')
The machine learning library pytorch heavily builds on the torch.tensor data structure, i.e. all inputs and outputs are assumed to be tensors, i.e. multi-dimensional arrays or higher-dimensional generalizations of vectors and matrices.
For our example, we convert our data to a one-dimensional tensor, i.e. a vector.
train_x = torch.tensor(data['x'].values, dtype=torch.float32)
train_y = torch.tensor(data['y_class'].values, dtype=torch.float32)
print(train_x)
print(train_y)
tensor([ 0.4358, -0.8147, 0.4853, 0.9887, -0.3911, -0.7215, 0.9388, 0.7895,
0.1776, 0.2159, 0.6541, -0.6710, -0.2751, -0.9491, 0.9735, 0.7569,
-0.8216, 0.3474, 0.8277, -0.7326, -0.8975, 0.8506, 0.8836, 0.0234,
-0.0956, 0.4526, -0.7859, 0.2511, -0.4661, -0.8414, 0.8898, 0.4570,
-0.6311, -0.3698, -0.7799, 0.9374, -0.3992, -0.9179, 0.4539, 0.1697,
0.1998, -0.4848, -0.5991, -0.6162, 0.8151, 0.2624, 0.7612, 0.3292,
-0.3441, 0.0051, 0.3916, -0.5406, 0.6901, 0.6885, 0.6741, 0.7681,
0.8761, 0.3049, -0.9213, -0.9353, 0.9076, -0.4240, -0.5495, 0.2922,
-0.6215, 0.3240, 0.3819, -0.0891, 0.1167, 0.0040, -0.2239, -0.7828,
0.2612, -0.9154, 0.6266, -0.5511, -0.3206, 0.7216, 0.0550, 0.0543,
-0.0200, -0.4678, 0.8557, 0.5230, -0.6674, 0.1678, 0.3534, 0.9765,
-0.1445, 0.4591, -0.0834, -0.0281, -0.4038, 0.5563, 0.2837, 0.3226,
-0.8757, -0.2027, 0.6748, 0.2987])
tensor([1., 0., 1., 1., 0., 0., 1., 1., 0., 1., 1., 0., 0., 0., 1., 1., 0., 1.,
1., 0., 0., 1., 1., 0., 0., 1., 0., 1., 0., 0., 1., 1., 0., 0., 0., 1.,
0., 0., 1., 0., 0., 0., 0., 0., 1., 1., 1., 1., 0., 0., 1., 0., 1., 1.,
1., 1., 1., 1., 0., 0., 1., 0., 0., 1., 0., 1., 1., 0., 0., 0., 0., 0.,
1., 0., 1., 0., 0., 1., 0., 0., 0., 0., 1., 1., 0., 0., 1., 1., 0., 1.,
0., 0., 0., 1., 1., 1., 0., 0., 1., 1.])
We now implement a simple perceptron model. All models in pytorch are derived from the base class torch.nn.Module. Such a module can consist of multiple "layers" of neural networks and we can control how those layers are interconnected and what activation functions are used as data pass between the layers. By implementing the forward function, we control what happens as the data (i.e. our features) are passed through the (possibly multiple) layers of our model.
For our example with linearly separable classes, a single layer that consists of a single neuron with one input and one output is sufficient. In the forward function, we apply a linear transformation $f(\vec{x}) = \beta_0 + \beta_1 x_1 + \ldots$ of our input feature. We further "forward" a value that is transformed by a sigmoid "activation" function, i.e. we perform a logistic transformation of the linear combination of features which defines the output of our "network", i.e. the class probability.
Conveniently, we do not need to implement the linear transformation of the feature(s) (based on the slope and the bias parameter) ourselves. We can simply use the torch.nn.Linear class, which implements a single layer perceptron model and automatically includes the necessary weight parameters (i.e. the bias and slope parameters for all perceptrons and input dimensions). With this, implementing a perceptron in pytorch is as simple as:
class Perceptron(torch.nn.Module):
"""A single-layer perceptron model."""
def __init__(self):
"""Initialize the model parameters."""
super(Perceptron, self).__init__()
self.linear = torch.nn.Linear(in_features=1, out_features=1, bias=True)
def forward(self, x):
"""Compute the output of the perceptron."""
# we use a logistic transformation to output class probabilities
# Note that torch.sigmoid is an alias for this function
return torch.special.expit(self.linear(x))
We can now initialize our model, choose a loss function that is used to optimize the model parameters, and pick one of the optimization algorithms implemented in pytorch. To understand how these optimization algorithms work, we need to explain some basics of the autograd module in pytorch. In our previous notebook, we have implemented a Gradient Descent optimization ourselves. For this, we used the gradients (calculated based on partial derivatives) that we calculated in the lecture based on the L2 loss function. We can only use those gradients for the special case of a model using a single perceptron performing a binary classification based on the logistic activation function and the L2 loss function.
However, in pytorch we can use arbitrarily complex, multi-layer models with various activation functions, loss functions, etc. How can we compute the gradients that we need to iteratively update the model parameters during the learning step? For this, we can use the autograd feature of pytorch. A detailed explanation of the fundamental of autograd is available here. Simply speaking, thanks to the autograd feature all tensors trace the history of all computations, which allows to efficiently and automatically compute the local gradients that are needed by optimization algorithms. In each step of the optimization algorithm, these gradients then allow to nudge model parameters in the right direction. A simple example that illustrates the automatic gradient computation can be found here.
We can try this as follows: We create a tensor, specifying that we want torch to keep track of all operations that are performed on the tensor.
x_1 = torch.tensor([42.0], requires_grad=True)
print(x_1.detach().numpy())
print(x_1.grad)
print(x_1.grad_fn)
[42.] None None
We can now compute a new tensor y by adding a value to the elements of tensor x. We find that this lead to the assignment of the grad_fn property, which stores that tensor y was obtained by adding a value to tensor x.
y_1 = x_1 + 0.5
print(y_1.detach().numpy())
print(y_1.grad)
print(y_1.grad_fn)
[42.5]
/tmp/marimo_45628/__marimo__cell_Hstk_.py:3: UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being accessed. Its .grad attribute won't be populated during autograd.backward(). If you indeed want the .grad field to be populated for a non-leaf Tensor, use .retain_grad() on the non-leaf Tensor. If you access the non-leaf Tensor by mistake, make sure you access the leaf Tensor instead. See github.com/pytorch/pytorch/pull/30531 for more informations. (Triggered internally at /pytorch/build/aten/src/ATen/core/TensorBody.h:489.) print(y_1.grad)
None <AddBackward0 object at 0x7641006f8610>
If we assign a new tensor z my multiplying a value to tensor y, the grad_fn property stores that tensor z was obtained by multiplying a value to tensor y.
z = y_1 * 0.1
print(z.detach().numpy())
print(z.grad_fn)
[4.25] <MulBackward0 object at 0x7641006160e0>
You see that by this calculations we have created a computation graph of tensors connected via the grad_fn properties. The output tensor z points to y and y points to x, which is our input tensor. We can call the backward() function of the output tensor to compute the gradients, i.e. the partial derivative of the input tensor.
z.backward()
print('gradient of x =', x_1.grad)
gradient of x = tensor([0.1000])
This gradient can be understood if we consider how the output $z=f(x)$ is calculated based on the input $x$. We have implemented the following expression:
$ f(x) = 0.1*(x+0.5) = 0.1 x + 0.05$
The derivative of this function (i.e. the gradient w.r.t x) is
$ f'(x) = 0.1$
and thus $f'(42) = 0.1$
For the following example, for $x = 42$ we expect a gradient value of $171$:
$ f(x) = 2 \cdot x^2 + 3 \cdot x + 5 $
$ f'(x) = 4 \cdot x + 3 $
$ f'(42) = 4 \cdot 42 + 3 = 171 $
x_2 = torch.tensor([42.0], requires_grad=True)
y_2 = 2 * x_2 ** 2 + 3 * x_2 + 5
y_2.backward()
print(x_2.grad.detach().numpy())
[171.]
Using this convenient autograd feature, in pytorch neural networks can be trained based on backpropagation, i.e. starting from the output that we wish to minimize (i.e. the loss function) we run the backpropagation and obtain gradients for our model parameters, which can be used in the iterative optimization. This works as follows:
1.) Using the current parameter values, we calculate a loss function, i.e. a function that quantifies the difference between the target variable (i.e. our ground truth in the training data) and the current output of our model. Our goal is to minimize this loss function for our training data.
2.) We propagate the loss function backwards through our model, i.e. starting from the output we pass the loss backwards to the inputs. In the process, we calculate the gradients of all model parameters, which will be used in the optimization step.
For a single-layer perceptron, the backpropagation algorithm corresponds to the delta rule, which yields the partial derivatives (i.e. gradient) for a perceptron with activation function \sigma(x), i.e.
$ \Delta \beta_{j}= \eta (\hat{y}_{j}-y_{j})\sigma'(h_{j})x_{i} $
where $\alpha$ is the learning rate $y_i$ is the model output and $\hat{y}_i$ is the target variable, i.e. the ground truth classes.
3.) We finally perform the actual optimization step, i.e. using the learning rate, we nudge the model parameters in the direction of the gradient.
For the sigmoid activation function $\sigma(x) = \frac{1}{1+e^{-x}}$ we obtain the gradients
$ \Delta \beta_{j}= (\hat{y}_{s}-y_{s})y_s(1-y_s)x_{sj} $
We are now ready to train our first pytorch model. We follow the same approach as before, i.e. we create the model, set the hyperparameters and initialize an optimizer. Here we use the implementation of stochastic gradient descent included in pytorch. This actually follows the same approach as our implementation in the previous notebooks, however here the gradients are (automatically) computed such that we minimize a loss function.
The name "stochastic" gradient descent refers to the fact that - rather than calculating the gradient for the whole data set it is estimated based on a small sample (i.e. for the batch size used in each iteration).
model = Perceptron()
loss_func = torch.nn.MSELoss()
x_3 = train_x[0].reshape(1)
label = train_y[0].reshape(1)
y_3 = model(x_3)
print(y_3)
_loss = loss_func(y_3, label)
print(_loss)
_loss.backward()
print('bias gradient =', model.linear.bias.grad)
print('weight gradients =', model.linear.weight.grad)
tensor([0.3963], grad_fn=<SigmoidBackward0>) tensor(0.3644, grad_fn=<MseLossBackward0>) bias gradient = tensor([-0.2889]) weight gradients = tensor([[-0.1259]])
We now compare this to a analytical derivation of the gradient, which we can calculate as (verify this as an home exercise!):
$\nabla L = (\frac{\partial L}{\partial \beta_0}, \frac{\partial L}{\partial \beta_1}) = \left(2 \cdot (y_i-\hat{y}_i)\cdot y_i\cdot (1-y_i), 2 \cdot (y_i-\hat{y}_i)\cdot y_i\cdot (1-y_i)\cdot x_i \right)$
y_val = y_3.detach().numpy()[0]
x_val = x_3.detach().numpy()[0]
label_val = label.detach().numpy()[0]
beta_0_grad = -2 * (label_val - y_val) * y_val * (1 - y_val)
print(beta_0_grad)
beta_1_grad = -2 * (label_val - y_val) * y_val * (1 - y_val) * x_val
print(beta_1_grad)
-0.28886014 -0.12589875
# the number of epochs gives the number of times we run the
# optimization algorithm on all examples in our training set
epochs = 500
# the learning rate controls how much parameters are
# changed (based on the gradients) for each training sample
learning_rate = 0.1
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
model.train()
errors = []
for epoch in range(epochs):
error = 0
for i in range(len(train_x)):
x_4 = train_x[i].reshape(1)
label_1 = train_y[i].reshape(1)
optimizer.zero_grad()
output = model(x_4)
_loss = loss_func(output, label_1)
_loss.backward()
optimizer.step()
error = error + _loss
errors.append(error.detach().numpy())
plt.plot(range(epochs), errors)
print('bias =', model.linear.bias.data)
print('weight =', model.linear.weight.data)
bias = tensor([-2.8661]) weight = tensor([[14.5313]])
We see that the loss function decreases as the SGD optimizer moves the model parameters along the automatically calculated gradients. Note that those gradients are calculated when we call the backward function of the tensor loss (the output we wish to minimize). Using autograd, this will calculate the gradients of the model parameters. Note that we have to reset the previously calculated gradients for each iteration of the algorithm, i.e. we use gradients calculated for each data point separately.
params = [x.detach().numpy()[0] for x in model.parameters()]
print(params)
[array([14.531273], dtype=float32), np.float32(-2.8660738)]
def decision(output):
"""Dichotomize output based on threshold of 0.5."""
if output>0.5:
return 1
else:
return 0
model.eval()
xx = data['x'].values
data['prediction'] = [ decision(model.forward(torch.tensor([x], dtype=torch.float32)).detach().numpy()[0]) for x in xx]
print(data)
sns.relplot(x='x', y="y_class", hue='prediction', data=data, alpha=0.5)
sns.lineplot(x=xx, y=expit(params[1] + xx * params[0][0]).ravel(), color='red', linewidth=1)
x y_class prediction 0 0.435847 1 1 1 -0.814709 0 0 2 0.485323 1 1 3 0.988707 1 1 4 -0.391091 0 0 .. ... ... ... 95 0.322583 1 1 96 -0.875656 0 0 97 -0.202677 0 0 98 0.674839 1 1 99 0.298659 1 1 [100 rows x 3 columns]