Reinforcement learning (RL) 101 with Python | by Gerard Martínez | Towards Data Science

In this post we will introduce few basic concepts of classical RL applied to a very simple task called gridworld in order to solve the so-called state-value function, a function that tells us how good is to be in a certain state t based on future rewards that can be achieved from that state. To do so we will use three different approaches: (1) dynamic programming, (2) Monte Carlo simulations and (3) Temporal-Difference (TD).
Reinforcement learning is a discipline that tries to develop and understand algorithms to model and train agents that can interact with its environment to maximize a specific goal. The idea is quite straightforward: the agent is aware of its own State t, takes an Action At, which leads him to State t+1 and receives a reward Rt. The following scheme summarizes this iterative process of St →At →Rt →St+1 →At+1 →Rt+1 →St+2...:
An example of this process would be a robot with the task of collecting empty cans from the ground. For instance, the robot could be given 1 point every time the robot picks a can and 0 the rest of the time. You can imagine that the actions of the robot could be several, e.g. move front/back/left/right, extend the arm up/down, etc. If the robot was fancy enough, the representation of the environment (perceived as states) could be a simple picture of the street in front of the robot. The robot would be set free to wander around and learn to pick the cans, for which we would give a positive reward of +1 per can. We could then set a termination state, for instance picking 10 cans (reaching reward = 10). The robot would loop in the agent-environment cycle until the terminal state would be achieved, which would mean the end of the task or episode, as it is known.
The gridworld task is similar to the aforementioned example, just that in this case the robot must move through the grid to end up in a termination state (grey squares). Each grid square is a state. The actions that can be taken are up, down, left or right and we assume that these actions are deterministic, meaning every time that the robot picks the option to go up, the robot will go up. There’s an exception, which is when the robot hits the wall. In this case, the final state is the same as the initial state (cannot break the wall). Finally, for every move or attempt against the wall, a reward of -1 will be given except if the initial state is a terminal state, in which case the reward will be 0 and no further action will needed to be taken because the robot would have ended the game.
Now, there are different ways the robot could pick an action. These rules based on which the robot picks an action is what is called the policy. In the simplest of cases, imagine the robot would move to every direction with the same probability, i.e. there is 25% probability it moves to top, 25% to left, 25% to bottom and 25% to right. Let’s call this the random policy. Following this random policy, the question is: what’s the value or how good it is for the robot to be in each of the gridworld states/squares?
If the objective is to end up in a grey square, it is evident that the squares next to a grey one are better because there’s higher chance to end up in a terminal state following the random policy. But how can we quantify how good are each of these squares/states? Or, what is the same, how can we calculate a function V(St) (known as state-value function) that for each state St gives us its real value?
Let’s first talk about the concept of value. Value could be calculated as the sum of all future rewards that can be achieved from a state t. The intuitive difference between value and reward is like happiness to pleasure. While immediate pleasure can be satisfying, it does not ensure a long lasting happiness because it is not taking into consideration all the future rewards, it only takes care of the immediate next one. In RL, the value of a state is the same: the total value is not only the immediate reward but the sum of all future rewards that can be achieved.
A way to solve the aforementioned state-value function is to use policy iteration, an algorithm included in a field of mathematics called dynamic programming. The algorithm is shown in the following box:
The key of the algorithm is the assignment to V(s), which you can find commented here:
The idea is that we start with a value function that is an array of 4x4 dimensions (as big as the grid) with zeroes. Now we iterate for each state and we calculate its new value as the weighted sum of the reward (-1) plus the value of each neighbor states (s’). Notice two things: the V(s’) is the expected value of the final/neighbor state s’ (at the beginning the expected value is 0 as we initialize the value function with zeroes). Finally, the V(s’) is multiplied by a gamma, which is the discounting factor. In our case we use gamma=1 but the idea of the discounting factor is that immediate rewards (the r in our equation) are more important than the future rewards (reflected by the value of s’) and we can adjust the gamma to reflect this fact.
Finally, notice that we can repeat this process over and over in which we “sweep” and update the state-value function for all the states. These values can get iteratively updated until reaching convergence. In fact in the iterative policy evaluation algorithm, you can see we calculate some delta that reflect how much the value of a state changes respect the previous value. These deltas decay over the iterations and are supposed to reach 0 at the infinity.
Here’s an example of how the value function is updated:
Notice in the right column that as we update the values of the states we can now generate more and more efficient policies until we reach the optimal “rules” a robot must follow to end up in the termination states as fast as possible.
Finally, here’s a Python implementation of the iterative policy evaluation and update. Observe in the end how the deltas for each state decay to 0 as we reach convergence.
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_style("darkgrid")
%pylab inline
import random

Populating the interactive namespace from numpy and matplotlib

/home/gerard/miniconda3/lib/python3.5/site-packages/IPython/core/magics/pylab.py:161: UserWarning: pylab import has clobbered these variables: ['random', 'gamma']
`%matplotlib` prevents importing * from pylab and numpy
  "\n`%matplotlib` prevents importing * from pylab and numpy"

gamma = 1 # discounting rate
rewardSize = -1
gridSize = 4
terminationStates = [[0,0], [gridSize-1, gridSize-1]]
actions = [[-1, 0], [1, 0], [0, 1], [0, -1]]
numIterations = 1000

def actionRewardFunction(initialPosition, action):
    
    if initialPosition in terminationStates:
        return initialPosition, 0
    
    reward = rewardSize
    finalPosition = np.array(initialPosition) + np.array(action)
    if -1 in finalPosition or 4 in finalPosition: 
        finalPosition = initialPosition
        
    return finalPosition, reward

valueMap = np.zeros((gridSize, gridSize))
states = [[i, j] for i in range(gridSize) for j in range(gridSize)]

# values of the value function at step 0
valueMap

array([[0., 0., 0., 0.],
       [0., 0., 0., 0.],
       [0., 0., 0., 0.],
       [0., 0., 0., 0.]])
deltas = []
for it in range(numIterations):
    copyValueMap = np.copy(valueMap)
    deltaState = []
    for state in states:
        weightedRewards = 0
        for action in actions:
            finalPosition, reward = actionRewardFunction(state, action)
            weightedRewards += (1/len(actions))*(reward+(gamma*valueMap[finalPosition[0], finalPosition[1]]))
        deltaState.append(np.abs(copyValueMap[state[0], state[1]]-weightedRewards))
        copyValueMap[state[0], state[1]] = weightedRewards
    deltas.append(deltaState)
    valueMap = copyValueMap
    if it in [0,1,2,9, 99, numIterations-1]:
        print("Iteration {}".format(it+1))
        print(valueMap)
        print("")
        

Iteration 1
[[ 0. -1. -1. -1.]
 [-1. -1. -1. -1.]
 [-1. -1. -1. -1.]
 [-1. -1. -1.  0.]]

Iteration 2
[[ 0.   -1.75 -2.   -2.  ]
 [-1.75 -2.   -2.   -2.  ]
 [-2.   -2.   -2.   -1.75]
 [-2.   -2.   -1.75  0.  ]]

Iteration 3
[[ 0.     -2.4375 -2.9375 -3.    ]
 [-2.4375 -2.875  -3.     -2.9375]
 [-2.9375 -3.     -2.875  -2.4375]
 [-3.     -2.9375 -2.4375  0.    ]]

Iteration 10
[[ 0.         -6.13796997 -8.35235596 -8.96731567]
 [-6.13796997 -7.73739624 -8.42782593 -8.35235596]
 [-8.35235596 -8.42782593 -7.73739624 -6.13796997]
 [-8.96731567 -8.35235596 -6.13796997  0.        ]]

Iteration 100
[[  0.         -13.94260509 -19.91495107 -21.90482522]
 [-13.94260509 -17.92507693 -19.91551999 -19.91495107]
 [-19.91495107 -19.91551999 -17.92507693 -13.94260509]
 [-21.90482522 -19.91495107 -13.94260509   0.        ]]

Iteration 1000
[[  0. -14. -20. -22.]
 [-14. -18. -20. -20.]
 [-20. -20. -18. -14.]
 [-22. -20. -14.   0.]]


plt.figure(figsize=(20, 10))
plt.plot(deltas)

[<matplotlib.lines.Line2D at 0x7f50bc077780>,
 <matplotlib.lines.Line2D at 0x7f50bc077a90>,
 <matplotlib.lines.Line2D at 0x7f50bc077cc0>,
 <matplotlib.lines.Line2D at 0x7f50bc077ef0>,
 <matplotlib.lines.Line2D at 0x7f50af3ac160>,
 <matplotlib.lines.Line2D at 0x7f50af3ac390>,
 <matplotlib.lines.Line2D at 0x7f50af3ac5c0>,
 <matplotlib.lines.Line2D at 0x7f50af3ac7f0>,
 <matplotlib.lines.Line2D at 0x7f50af3aca20>,
 <matplotlib.lines.Line2D at 0x7f50af3acc50>,
 <matplotlib.lines.Line2D at 0x7f50af3ace80>,
 <matplotlib.lines.Line2D at 0x7f50af3af0f0>,
 <matplotlib.lines.Line2D at 0x7f50af3af320>,
 <matplotlib.lines.Line2D at 0x7f50af3af550>,
 <matplotlib.lines.Line2D at 0x7f50af3af780>,
 <matplotlib.lines.Line2D at 0x7f50af3af9b0>]
While the previous approach assumes we have a complete knowledge of the environment, many times this is not the case. Monte Carlo (MC) methods are able to learn directly from experience or episodes rather than relying on the prior knowledge of the environment dynamics.
The term “Monte Carlo” is often used broadly for any estimation method whose operation involves a significant random component.
Interestingly, in many cases is possible to generate experiences sampled according to the desired probability distributions but infeasible to obtain the distributions in explicit form.
Here’s the algorithm to estimate the value function following MC:
The Monte Carlo approach to solve the gridworld task is somewhat naive but effective. Basically we can produce n simulations starting from random points of the grid, and let the robot move randomly to the four directions until a termination state is achieved. For each simulation we save the 4 values: (1) the initial state, (2) the action taken, (3) the reward received and (4) the final state. In the end, a simulation is just an array containing x arrays of these values, x being the number of steps the robot had to take until reaching a terminal state.
Now, from these simulations, we iterate from the end of the “experience” array, and compute G as the previous state value in the same experience (weighed by gamma, the discount factor) plus the received reward in that state. We then store G in an array of Returns(St). Finally, for each state we compute the average of the Returns(St) and we set this as the state value at a particular iteration.
Here you can find a Python implementation of this approach applied to the same previous task: the worldgrid.
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_style("darkgrid")
%pylab inline
import random

Populating the interactive namespace from numpy and matplotlib

# parameters
gamma = 0.6 # discounting rate
rewardSize = -1
gridSize = 4
terminationStates = [[0,0], [gridSize-1, gridSize-1]]
actions = [[-1, 0], [1, 0], [0, 1], [0, -1]]
numIterations = 10000

# initialization
V = np.zeros((gridSize, gridSize))
returns = {(i, j):list() for i in range(gridSize) for j in range(gridSize)}
deltas = {(i, j):list() for i in range(gridSize) for j in range(gridSize)}
states = [[i, j] for i in range(gridSize) for j in range(gridSize)]

# utils
def generateEpisode():
    initState = random.choice(states[1:-1])
    episode = []
    while True:
        if list(initState) in terminationStates:
            return episode
        action = random.choice(actions)
        finalState = np.array(initState)+np.array(action)
        if -1 in list(finalState) or gridSize in list(finalState):
            finalState = initState
        episode.append([list(initState), action, rewardSize, list(finalState)])
        initState = finalState

for it in tqdm(range(numIterations)):
    episode = generateEpisode()
    G = 0
    #print(episode)
    for i, step in enumerate(episode[::-1]):
        G = gamma*G + step[2]
        if step[0] not in [x[0] for x in episode[::-1][len(episode)-i:]]:
            idx = (step[0][0], step[0][1])
            returns[idx].append(G)
            newValue = np.average(returns[idx])
            deltas[idx[0], idx[1]].append(np.abs(V[idx[0], idx[1]]-newValue))
            V[idx[0], idx[1]] = newValue

100%|██████████| 10000/10000 [00:09<00:00, 1092.69it/s]

V

array([[  0.        ,  -4.62939535,  -8.06323419, -10.33670955],
       [ -5.01729474,  -6.44643267,  -7.91912568,  -8.35701949],
       [ -8.1403594 ,  -7.82764654,  -6.51265382,  -4.69684525],
       [-10.6207086 ,  -8.28126595,  -4.70611477,   0.        ]])
# using gamma = 1
plt.figure(figsize=(20,10))
all_series = [list(x)[:50] for x in deltas.values()]
for series in all_series:
    plt.plot(series)

# using gamma = 0.6
plt.figure(figsize=(20,10))
all_series = [list(x)[:50] for x in deltas.values()]
for series in all_series:
    plt.plot(series)

 

Note that varying the gamma can decrease the convergence time as we can see in the last two plots using gamma=1 and gamma=0.6. The good side of this approach is that:
Technically, we don’t have to compute all the state-values for all the states if we don’t want. We could just focus on a particular grid point and start all the simulations from that initial state to sample episodes that include that state, ignoring all others. This can radically decrease the computational expense.As we said before, this approach does not require a full understanding of the environment dynamics and we can learn directly from experience or simulation.
Technically, we don’t have to compute all the state-values for all the states if we don’t want. We could just focus on a particular grid point and start all the simulations from that initial state to sample episodes that include that state, ignoring all others. This can radically decrease the computational expense.
As we said before, this approach does not require a full understanding of the environment dynamics and we can learn directly from experience or simulation.
Finally, the last method we will explore is temporal-difference (TD). This third method is said to merge the best of dynamic programming and the best of Monte Carlo approaches. Here we enumerate some of its strong points:
As the dynamic programming method, during the optimization of the value function for an initial state, we use the expected values of next state to enrich the prediction. This process is called bootstrapping.As in Monte Carlo, we don’t have to have a model of the environment dynamics and can learn directly from experience.Furthermore, unlike MC, we don’t have to wait until the end of the episode to start learning. In fact, in the case of TD(0) or one-step TD, we learn at each and every step we take. This particularly powerful because: on one hand, the nature of learning is truly “online” and on the other hand we can deal with tasks which do not have a clear terminal state, learning and approximating value functions ad infinitum (suitable for non-deterministic non-episodic or time-varying value functions).
As the dynamic programming method, during the optimization of the value function for an initial state, we use the expected values of next state to enrich the prediction. This process is called bootstrapping.
As in Monte Carlo, we don’t have to have a model of the environment dynamics and can learn directly from experience.
Furthermore, unlike MC, we don’t have to wait until the end of the episode to start learning. In fact, in the case of TD(0) or one-step TD, we learn at each and every step we take. This particularly powerful because: on one hand, the nature of learning is truly “online” and on the other hand we can deal with tasks which do not have a clear terminal state, learning and approximating value functions ad infinitum (suitable for non-deterministic non-episodic or time-varying value functions).
Here’s the algorithm to calculate the value function using temporal-difference:
And here’s the jupyter notebook with the Python implementation
Notice that adjusting alpha and gamma parameters is critical in this case to reach convergence.
Finally, I’d like to mention that most of the work here is inspired or drawn from the latest edition of the Andrew G. and Richard S. book called Reinforcement Learning: An Introduction, amazing work that these authors have made publicly accessible here.