Zero To Proximity-Policy Optimisers

The goal of this blog is to help someone who knows nothing about reinforcement learning build a proximity policy optimiser. As such, I've started with basic RL theory. If you are already familiar with theory and want to dive into the implementation, please skip to the implementation.

Reinforcement learning is very heavy in mathematics. Although I've tried to explain many of the more complex concepts in short form, it's much better if you learn them yourself.

Here are a few resources I'd suggest checking out for the math,

Basic stat. & prob for basic RL Khan Academy - Probability & Statistics
A bit more detailed probability course. Stanford CS299 - Probability For Machine Learning
Multivariable calculus for policy gradients & PPO Theory Khan Academy - Multivariable Calculus


To understand proximity policy optimisers, we first need to understand some basic Reinforcement Learning Theory. In reinforcement learning, we describe the entire state of the world, every minute detail, altogether at time/step as, . is considered the state of the world. However, there are many possible cases/environments where may not be fully observable; in that case, we used which is the partial observation. It also helps to know the actions our agent can take. This is described as the action space of the agent.

These action spaces can be both continuous, say in the case of an RL agent learning to play football, or discrete, in the case of, say, chess. This distinction is important as different kinds of algorithms may apply to different scenarios.

The way the agent takes up a certain action from these action spaces is through policies, policies may be defined as the brain of the agent itself. Mathematically, a policy is a function over (a state at a given time step t) that gives us an action for that time/step.
However, policies need not be discrete; in continuous environments, we can not directly use discrete action spaces. We can stochastically define a policy as,
In deep RL, we use neural networks as the policy function. Here, to describe the specific weights and biases of the neural network, we use the subscripts or , giving us,
This network is also known as the actor network, since it takes the actions.

Neural Networks

If you know what neural networks are and how to implement them in C, you can jump to types of policies.

Neural networks are a type of machine learning model that consists of layers of interconnected nodes.
Untitled design.png
Where each node contains a specific weight, and each layer has its own bias. The weights are multiplied, while the biases are added.
wb.png
You can think of each node as its own linear regression model, in the form . Mathematically speaking, any layer in a neural network can be defined by This is also known as the forward pass of the network, think of it like your input flowing forward through the layers of the network, and changing according to the nodes.

This isn't super hard, it's just layers and layers of multiplication and addition happening.

In python, this will look like.

def forward(inputs, weights, bias):
    sum = 0
    for i in range(len(inputs)):
        sum += inputs[i] * weights[i]
    sum += bias
    return f(sum)

The NumPy library can help us do it in a cleaner way (+ It's faster!)

import numpy

def forward(inputs, weights, bias):
    return f(np.dot(inputs, weights) + bias)

You might be wondering what the function does, this is the activation function, although there are many activation functions, for the sake of our implementation we are only concerned with what is known as the rectified linear unit(ReLU) function. ReLU is defined as,

def ReLU(x):
    return np.maximum(0, x)

Of course, we don't want our neural network to only be one layer, we will need a neural network with multiple layers, these type of neural networks are also known as multi-layer perceptrons. For this, we will define a Neural Network class.

  
def mlp(sizes, activation=nn.Tanh, output_activation=nn.Identity):
    layers = []
    for j in range(len(sizes)-1):
        act = activation if j < len(sizes)-2 else output_activation
        layers += [nn.Linear(sizes[j], sizes[j+1]), act()]
    return nn.Sequential(*layers)

(Optional) Quick brush over on Back-Propagation

Now, although our neural network can do some operations, it still doesn't change those weights and biases. For that, we will need to use what is known as back-propagation.

If the forward pass was information flowing through the neural network, imagine back-propagation as the feedback to the prediction flowing back through the neural network, adjusting everything in its path. This adjustment is decided by the loss. There are various ways to calculate this loss, but for our case, the proximal policy optimiser will provide us with the policy loss, so let's not worry about that right now.

Mathematically, back-propagation relies heavily on the chain rule from calculus.

Say, our neural network predicts , for input when the actual value was , we have the loss . The goal is to compute the gradient .

So first, we'll define the derivatives of the activation function.

We know that each layer in the neural network computes,

Where,
is weight matrix at layer l
is bias at layer l
is activation/output of layer l
and is the pre-activation/weighted sum/output of the neural network before we pass it through the activation function.

Using the chain rule we want,

This will lead us to a term Delta

Which is vital to machine learning. Once we have the delta, we can compute the gradients using,

However, this change may be too big sometimes, and we may jump over the most optimal answer, thus, we multiply this with a small learning rate to slow it down a bit.

So whenever we update our or ,
In our case, our proximal policy optimiser will supply the loss.

Continuously doing this, will lead to the weights and biases of the network getting optimised. This is known as Stochastic Gradient Descent.

For more, I'd suggest to read: This Post by IBM

Types of policies

Policies come in two different flavours. One is the deterministic policy which we just covered, the other is stochastic policies. Which are further divided into categorical Policies & diagonal Gaussian Policies.

categorical policies work for discrete action spaces.
Say a character can jump or move left and right, we'll get the actions,
If we build a neural network, where the last layer (of probabilities) is denoted by we get the log likelihood as,
(since it will have as many entries as there are actions, we can use to index it.) This computation is very useful for PPOs.

As a refresher, a likelihood just measures how well a certain model predicts data. Say you roll a dice 3 times, and get 2, 3, 5 - how likely are you to get that when the die is fair The reason we take a log instead, is that logs turn multiplication into addition, which simplifies computation.

For continuous action spaces, we must use what is known as the diagonal gaussian policy.
To understand a diagonal gaussian policy we must first understand what a multi-variate gaussian distribution is.

It's simple to understand, if a normal gaussian distribution describes how likely a thing is to happen.
Pasted image 20250328181622.png

A Multi-variate Gaussian Distribution defines how likely a combination of things are to happen. More precisely, it is defined by

  1. A mean vector which gives us the expected value of the distribution
  2. A covariance matrix which describes the relationship between the variables.
    Pasted image 20250328181748.png

A Co-Variance Matrix of this distribution just tells how these things vary and are connected. Mathematically it is,

(You might've ran across this thing in seaborn!)
Pasted image 20250419231301.png
A Diagonal Gaussian Distribution is just the diagonal of the matrix. Essentially,

So, we can represent it using a vector.

A Diagonal Gaussian Policy can be represented by a neural network that maps from observations to mean actions.

To represent our Diagonal Co-Variance matrix, we have two ways.

  1. Single Vector of Log of the Standard Deviations: . This will be used in the PPO implementation.
  2. Neural network that maps from states to log standard deviation.

In both cases, we use log of standard deviation as the while the standard deviation cannot be non-negative, the logarithm can, which makes things much easier for us.

Given the mean action and standard deviation and a vector of noise from a spherical Gaussian (). We can now get an action sample using the equation,

A spherical gaussian is just like a normal gaussian distribution, but taken in 3-D, it's a sphere of points, where the center has the most, while the surface has the least.

Pasted image 20250329100109.png

The log likelihood of a Diagonal Gaussian Policy is found to be,

Trajectories

We've learnt what actions & states are. A trajectory, also known as an episode or rollout, is very simple, it is just a sequence of states and actions in the world, mathematically, it is represented by giving us,The very first state is sampled from something called a start-state distribution, denoted by ,
State transitions refer to what happens to the world between states and , mathematically they are represented by,
or

Rewards

When hearing about reinforcement learning, you must've heard that it's all about rewarding the agent for doing good things, and punishing the agent for doing bad things. Although that's only a subset of the type of reinforcement learning algorithms (as you may learn later, such as evolutionary algorithms.), rewards & returns are critical for Reinforcement Learning.

Mathematically, it is a function over the current state & action, along with the next state.
However, since, , this can just be simplified to,
(also, since , we can also just go with, )

The goal of our agent is to maximise a cumulative reward over trajectory , this can imply a few things, such as a finite un-discounted return, or an infinite discounted return etc. we'll learn what those terms mean in just a second, but, we can just use to denote all of them.

There are multiple kinds of returns, the first kind we'll look at, is the finite-horizon un-discounted return, which just adds up the reward over a finite time frame.
The second is the infinite-horizon discounted return, which fades off the further away from the current state you go.

Why would we want to discount the further away rewards? Well, it's mathematically better, we need the sum to converge, and with cumulative infinite rewards, it may not converge. Thus we discount the rewards.

The Goal

The goal in Reinforcement Learning is to select a policy which maximises the expected cumulative return when the agent acts according to it.

To express the expected return, we must first understand the probability distribution over trajectories.
In a stochastic system, where the policy is , for a trajectory of steps the probability distribution will be,
Therefore, we get the expected measure to be,
Since we want to maximise this return, we get that our optimal policy, denoted by can be expressed by,

Value Functions

The value functions give a certain value to every state/state-action pair. Basically, say you start at state and act according to policy - The value will be the expected return you will get.

The policy network is known as the critic network as it criticises the actor's actions.

The four main functions to note are,

  1. On-Policy Value Function. Gives expected return when you start at and always act according to
  2. On-Policy Action-Value Function. Gives expected return when you start at and take an arbitrary action then always follow the policy .
  3. The Optimal Value Function. Gives expected return if you start at and always act according to the optimal policy.
  4. The Optimal Action-Value Function. Action-Value for the optimal policy.
    also note a very common relation between Value & Action-Value functions

Policy Gradients

Reinforcement Learning can be divided into two major branches, model-free & model-based RL. A model is a function which predicts state transitions and rewards. Although this allows our agent to plan by thinking ahead this type of environment is not usually available. In these scenarios, the agent has to learn the model through trial and error, which is very computationally expensive.

The proximity-policy optimiser is a model-free Algorithm, so we'll be focussing on that.

Model-Free Algorithms have two main methods to training agents. One is Q-learning which tries to approximate the optimal action-value function. The other, which is the focus of this blog, is policy optimisation.

For the sake of simplicity, we'll be covering & implementing the most bare-bones equation for policy gradients in this blog.

A Bare-Bones Policy Gradient

Here, we'll be working with a stochastic policy . (although, I'll also cover a discrete variation later) - here we aim to maximise . We'll first do a derivation for when R() is the finite-horizon discounted return.
From Eq. 2 under The Goal
Now, taking the gradient with respect to ,
Using theleibniz's rule for integration we can swap the gradient and the integral sign.
Since R() is not dependant on , it does not need to come under the gradient.

Now, using what is known as the log-derivative trick, which simply refers to the derivative of being we can rearrange as,
When we multiply both sides by

We can now write our equation as,

Taking a look at the probability of trajectory - Eq. 1 in The Goal,
Taking it's logarithm gives us,
However, since the environment has no dependance on , we get that the gradients of , & are all zero. Thus, when we take the gradient of the logarithm of probability of trajectory.

Putting this into our original equation, we get
Re-writing in the expectation form we get the policy gradient equation for finite horizon un-discounted settings with a T-Step Trajectory.

Alternate Policy Gradient Derivations (Bonus)

Infinite Horizon Discounted Setting

For an infinite horizon discounted setting, we have that
Our policy gradient expression changes to

Finite Horizon Un discounted Setting, when depends on (When the parameters affect the environment)

Similar to the original derivation we have,
However, this time since depends on , when we use the Leibniz's Rule will come under the gradient. So, our equation will change as,

This can also be written as
Again, from the probability of trajectory, eq. 1 in The Goal,
However, this time as the environment is dependant on , so instead
Now, under the Joint Probability Notation,
Finally we get,

The Expected Grad-Prob-Lemma

All probability distributions are normalised. (integration = )
Taking the gradient of both the sides,
Using the log-derivative trick, we get,
Writing in expectation form we get,

This has been termed as the expected grad-log-prob lemma.

Reward-To-Go Policy Gradient

In our policy gradient expression,
Whenever we take a step to up our gradient ascent, it pushes the log-probabilities in proportion to , which is the sum of all reward ever obtained. Intuitively speaking, we don't really care about the rewards that came before the action was taken - only the ones after.

Well, we can change the expression of the policy gradient to,

This is known as the Reward-To-Go Policy Gradient.
This is known as the 'Reward To Go', from that point t.

Baselines

An immediate consequence of this Grad-Log-Prob Lemma is that for any function we get,
Which means we can add or subtract this value from our policy gradient as,

cannot be computed exactly. It has to be approximated. This is usually done using a neural network which is updated along with the policy. In PPO, we use the equation to learn .

Advantage Function

In RL, we don't always need to know absolutely how good an action is, but how good it is relative to other actions. For this, we use what is known as the advantage function,

Generalised Policy Gradient

We have seen so far that the policy gradient has a general form.
We can also put in the advantage function as ,

The Theory Behind PPO

The main question behind proximity policy optimisers is, "how can we take the biggest possible improvement step on a policy using the data we already have without stepping too far to decrease performance?"

The way a PPO does this is, by using a clipping function to prevent large updates when we're optimising our policy.

Mathematically we define is as,
Where is the probability ratio between the new and the old probabilities, and is a small value that lets us know how much change is allowed. Normally it is set as

Deriving PPO From Policy Gradients

From our Policy Gradient Theorem we have,
Setting (Advantage Function), we get,
We now introduce importance sampling. Basically, our agent has collected experience on old policy , but we're trying to train , in this scenario, we can use the ratio . So basically, when our new policy chooses the action more often than the old policy, that means its should count as more. But if our old policy chooses it more, it should count for less.

So, we get the objective to be,
This lets us reuse date from old policies for the new one. The problem with the above objective is that larger policy updates can make training unstable. I.E. one policy uses an action way more often than the normal. To fix this, we prevent from moving too far from one using the clip function.

What clip does is, if is within there is no effect, otherwise, the update is clipped to be within that range.

So - after all that - we finally get,

This is known as PPO-CLIP.

is sometimes also called the surrogate objective.

Our main goal, is to find such that is maximised. Therefore,

PPO-Penalty

There is another version of proximal-policy optimisers known as PPO-Penalty, this is closer to a Trust-Region Policy Optimiser than PPO-Clip, but instead of using a clip to impose a hard constraint, it instead uses a penalty that scales with training instead. For the sake of this blog, we will be only covering PPO-CLIP.

Creating PPO-CLIP

To create the final python implementation of PPO-CLIP, we will be using the algorithm from Open AI's Spinning Up Docs
Pasted image 20250419232847.png

Before we start, let's import a few dependencies, we'll be working with PyTorch & Gymnasium which is a standard API that'll help us build the environment for the training.

import torch
import gymnasium as gym

We'll first create the __init__ function which initialises all our variables & hyper parameters.

class PPO:
	def __init__(self, environment, network_class , **hyperparameters):

We have to check if the environment is compatible or not.

assert isinstance(env.observation_space, gym.spaces.Box), "Observation space must be of type Box"
assert isinstance(env.action_space, gym.spaces.Box), "Action space must be of type Box"

If our environment is not compatible, this will automatically throw an error. Now, we can extract the information from our environment.

self.env = env
self.observation_dim = env.observation_space.shape[0]
self.action_dim = env.action_space.shape[0]

After that - we'll initialise our actor & critic networks. This is the first step in the algorithm
Screenshot 2025-04-19 at 11.30.44 PM.png

self.actor = network_class(self.observation_dim, self.action_dim) # Step 1          
self.critic = network_class(self.observation_dim, 1)

Now, we have to initialise our hyper parameters, we'll do that using a function hyperparameter_init() this is just to make our final code a bit cleaner.

def hyperparameter_init(self, hyperparameters):
	self.TIMESTEPS_PER_BATCH = 4800                 
	self.TIMESTEPS_PER_EPISODE = 1600  
	self.UPDATES_PER_ITERATION = 5  
	self.lr = 0.005                     
	self.gamma = 0.95                  
	self.clip = 0.2                                 
	self.render = True                             
	self.render_every_i = 10                        

And, in our, __init__() function we add,

self.hyperparameter_init(hyperparameters)

We'll start creating our learning loop - which is step two. Pasted image 20250419234705.pngWithin a new function learn we'll define the loop as,

def learn(self, total_timesteps)
	timesteps_completed = 0
	iter_count = 0 # number of iterations till now, we'll use this to save after *n* iterations
	while timesteps_completed < total_timesteps: # Step 2
		pass

	iter_count +=1

The rollout() function 'rolls out' the entire batch data till now in front of us.

Pasted image 20250419234758.png

def learn(self, total_timesteps)
	timesteps_completed = 0
	iter_count = 0 # number of iterations till now, we'll use this to save after *n* iterations
	while timesteps_completed < total_timesteps: # Step 2
		batch_observations, batch_actions, batch_log_probs, batch_reward_to_gos, batch_episode_lengths = self.rollout()  # Step 3

	t_so_far += np.sum(batch_lens)

	iter_count +=1

Defining rollout(). Here, we'll be calculating the reward to go using reward_to_gos() function, this is step 4.
Pasted image 20250420020730.png

def rollout(self):
	# Batch data that gets returned
	batch_observations = [] 
	batch_actions = []
	batch_log_probs = []
	batch_rewards = [] 
	batch_reward_to_gos = []
	batch_episode_lengths = []

	episode_rewards=[] # Keeps track of rewards per episode, get's cleared after every episode.

	timesteps = 0

	while t < self.TIMESTEPS_PER_BATCH: # 
		episode_rewards = [] 

		# Resetting the environment after each batch.
		observation, _ = self.env.reset()
		done = False

		for episode_timestep in range(self.TIMESTEPS_PER_EPISODE):
			# Rendering the environment
			if self.render and (self.logger['i_so_far'] % self.render_every_i == 0) and len(batch_lens) == 0:
					self.env.render()
			# Incrementing The Timestep
			timesteps += 1

			# Now - we'll track the observations in this batch,
			batch_observations.append(observation)

			# We'll be using our get_action function to calculate the action & log_prob
			
			action, log_prob = self.get_action(observation)
			observation, reward, terminated, truncated, _ = self.env.step(action)

			done = truncated | terminated

			# Tracking the recent rewards, & action, probs
			episode_rewards.append(reward)
			batch_actions.append(action)
			batch_log_probs.append(log_prob)
			
			if done:
				break

		# Tracking lengths
		batch_episode_lengths.append(ep_t + 1)
		batch_rewards.append(episode_rewards)

	# Reshaping Data Into Tensors
	batch_observations = torch.tensor(batch_observations, dtype=torch.float)
	batch_actions = torch.tensor(batch_actions, dtype=torch.float)
	batch_log_probs = torch.tensor(batch_log_probs, dtype=torch.float)
	batch_reward_to_gos = self.compute_reward_to_gos(batch_rewards)     # STEP 4   
	
	return batch_obs, batch_acts, batch_log_probs, batch_reward_to_gos, batch_lens

Looking back at the theory from section Reward-To-Go Policy Gradient, we see that, reward to go is defined as,
Therefore, we can define the function as,

def compute_reward_to_gos(self, batch_rewards):
	batch_reward_to_gos = []

	for episode_rewards in reversed(batch_rewards):
		discounted_reward = 0
		for reward in reversed(episode_rewards):
			discounted_reward = reward + discounted_reward * self.gamma
			batch_reward_to_gos.insert(0, discounted_reward)

	batch_reward_to_gos = torch.tensor(batch_reward_to_gos, dtype=torch.float)

	return batch_reward_to_gos

We also need to develop our get_action() function. According to,

def get_action(self, observation)
mean = self.actor(observation)

dist = torch.distributions.MultivariateNormal(mean, self.cov_mat) # Defines a distribution


action = dist.sample() # Samples action


log_prob = dist.log_prob(action) # Calculates log probability for action

return action.detach().numpy(), log_prob.detach()

Cool, now going back to our learn() function, according to step 5, we'll build out the Advantage Function,
Screenshot 2025-04-20 at 2.33.18 AM.png

V, _ = self.evaluate(batch_obs, batch_acts) # Step 5
A_k = batch_reward_to_gos - V.detach() 

Now, we have to update the PPO-Clip Objective.
Screenshot 2025-04-20 at 4.10.22 AM.png

For this, first, we'll go back to the __init__() function, and initialise the Adam optimiser.

First, import the Adam optimiser,

from torch.optim import Adam
self.actor_optimiser = Adam(self.actor.parameters(), lr=self.lr)
self.critic_optimiser = Adam(self.critic.parameters(), lr=self.lr)

Back in the learn() function, we start out summation loop within the main while loop, we first get & .

for _ in range(self.UPDATES_PER_ITERATION):    
	V, curr_log_probs = self.evaluate(batch_observations, batch_actions)

	ratios = torch.exp(curr_log_probs - batch_log_probs)

	#Calculating surrogate loss r_\theta

	surr1 = ratios * A_k
	surr2 = torch.clamp(ratios, 1 - self.clip, 1 + self.clip) * A_k

	actor_loss = (-torch.min(surr1, surr2)).mean() # Negative because adam minimizes, and we want to maximize.
	critic_loss = torch.nn.MSELoss()(V, batch_reward_to_gos)

	# Next - we calculate the gradients for both the networks

	self.actor_optimiser.zero_grad()
	actor_loss.backward(retain_graph=True)
	self.actor_optimiser.step()

	self.critic_optimiser.zero_grad()
	critic_loss.backward()
	self.critic_optimiser.step()

All evaluate() does here is determine the values of each observation along with the log probabilities of each action in the most recent batch.

def evaluate(self, batch_observations, batch_actions)
		V = self.critic(batch_observations).squeeze()

		mean = self.actor(batch_observations)
		dist = MultivariateNormal(mean, self.cov_mat)
		log_probs = dist.log_prob(batch_actions)

		return V, log_probs

That's it! You can check out the final code on my github

Further Reading & Credits

Congratulations on getting this far! You can now create the basis for a proximal-policy optimiser from scratch in python. :) If you'd like to read more about policy optimisers & deep RL in general, here are a few links you should check out.