RL From Scratch Lab
After this lesson, you will be able to:
- Write the discounted return of an episode, and the return from every step onward, working backwards
- Write an epsilon-greedy choice that uses a seeded random generator, and a running-average update for a bandit arm
- Write the Q-learning update and the SARSA update, and say which line makes one off-policy and the other on-policy
- Write policy evaluation and value iteration as sweeps over a small grid, and read a greedy policy off the values
- Write the REINFORCE gradient for a softmax policy and prove it right with a numerical gradient check
- Write Generalized Advantage Estimation, and name the traps that break these functions
Before You Start
#How This Lab Works
TODO marks. It has a Run button and a Check my work button, and under it sits a Show solution button. The hints appear one at a time, so you can take the smallest nudge that gets you moving.Check my work runs your code, then runs a list of hidden tests, one at a time. It tells you how many passed and shows the message of the first one that failed. The starter is built to fail on purpose, so a fresh cell always says some checks failed. When you finish the TODO lines, all of them pass.
Three things about the tests are worth knowing before you start.
- They are tolerance based. A result close enough to the right answer passes, so a harmless rounding difference never fails you.
- They are deterministic. Any random numbers come from a fixed seed, so a test that fails today fails the same way tomorrow.
- They check more than the one example in the hand trace. Some compare your answer with a second route to the same number, such as a slow loop version, an exact solution of a small linear system, or a slope found by nudging a number a tiny bit each way. If your code only matches the trace, it will still fail those.
The cells use numpy only. The browser has no gym or torch, so none of this needs a simulator or a graphics card, and each cell runs in a moment.
The total reward from a step onward, with later rewards counted for less by the discount.
A number from 0 to 1. A reward k steps away is multiplied by gamma to the power k, so nearer rewards count more.
Exploit means take the move that looks best now. Explore means try something else to learn more.
The number an estimate is being moved toward. In Q-learning it is the reward plus gamma times the value of what comes next.
An on-policy method learns about the behaviour the agent really has. An off-policy method learns about a different one, usually the greedy choice.
One pass that updates the value of every state, all from the old values.
A state where the episode ends. Nothing more can be earned, so its value is 0.
How much better a step went than the critic expected: the reward plus the discounted next value, minus this value.
#The Conventions Used Here
Many mistakes in this area are not about maths. They are about which of two conventions a sentence uses. Fix them before writing code. This table is the single source for the whole lab, and the tests assume it.
| Quantity | Meaning in this lab |
|---|---|
| Episode | A list of rewards, one per step t = 0, 1, 2, and so on. The reward at step t is the one received after acting at step t. |
| Discount | gamma is between 0 and 1. A reward k steps later is multiplied by gamma to the power k. With gamma = 1 nothing is discounted. |
| Q-table | Q has shape (n_states, n_actions). States and actions are whole numbers. An update function returns a NEW table and leaves the one it was given alone. |
| done | True when the move ended the episode. There is no next state, so the target is just the reward r. |
| Ties | When two actions have the same value, np.argmax picks the lowest index. |
| Random numbers | The caller passes in a numpy generator, and you never create one inside a function. A call draws one number with rng.random(), and only if it explores does it draw one more, rng.integers(k). |
| Grid world | next_state[s, a] is where you land, reward[s, a] is the reward for the move, terminal[s] marks a finished state, and a terminal state is worth 0. Moves are deterministic. |
| Sweep | All new values are computed from the OLD values, then swapped in together. A state's new value never feeds another state in the same sweep. |
| Policy gradient | The policy gives the chance of action a in state s as softmax(theta[s])[a]. The gradient points uphill on the return, so a step is theta + lr * grad. Each term uses G_t, the return from step t onward. The textbook form has an extra gamma to the power t in front of each term. Many implementations leave it out, and this lab does too. |
| GAE | values[t] is the critic's value of the state at step t. last_value is its value of the state after the last step, or 0.0 if the episode really ended. The advantage after the last step is 0. |
When a convention differs from the book you learned from, the tests follow this table. Neither is wrong, and a small change of convention gives different numbers, so name yours before you compare results.
#Exercise 1: Discounted Return
Discounted return
Write discounted_return(rewards, gamma) for one episode, and returns_to_go(rewards, gamma) for the return from every step onward. Press Check my work to run the hidden tests.
The test that catches most wrong answers compares your array with a slow double loop that adds up gamma to the power k times the reward k steps ahead, for every start step. A second one passes in a numpy array and checks that it comes back unchanged, which catches code that adds into the input while it works backwards.
#Exercise 2: An Epsilon-Greedy Bandit Step
Randomness works the same way in every function of this lab. The caller passes in a numpy generator, and you never create one inside. A call draws one number, u = rng.random(). If u is below epsilon, it draws one more number, rng.integers(len(q)), to pick an arm. Because the order is fixed, a test with a fixed seed knows exactly which arm you should pick.
An epsilon-greedy bandit step
Write epsilon_greedy(q, epsilon, rng) to choose an arm, and update_estimate(q, n, action, reward) to fold one reward into that arm's running average. Press Check my work.
Most tests replay the same seeded generator on the side and demand the same picks, so the order of the random draws matters. Another test runs 4000 picks and checks that the share of non-best arms is near epsilon x (k - 1) / k, because a random pick can land on the best arm too. A third makes calls at epsilon 0 and demands that the random stream still moves, one draw per call.
#Exercise 3: The Q-Learning Update
The Q-learning update
Write q_learning_update(Q, s, a, r, s_next, done, alpha, gamma) so that it returns a new table in which only Q[s, a] has moved toward the target. Press Check my work.
One test repeats the same update 60 times and expects the entry to arrive at its target. Others hide a huge value in the next state and set done to True, which must be ignored, and use negative Q-values, which catch a max that quietly includes 0.
#Exercise 4: The SARSA Update
The SARSA update
Write sarsa_update(Q, s, a, r, s_next, a_next, done, alpha, gamma). It differs from exercise 3 in one place, the target. Press Check my work.
The strongest test runs 30 random transitions and checks two things: the exact formula, and the fact that a SARSA entry can never end above the Q-learning entry for the same transition, since the action taken is never better than the best action.
#Exercise 5: Policy Evaluation on a Grid
The world is a small grid given as arrays: next_state[s, a], reward[s, a] and terminal[s]. The four actions are 0 up, 1 down, 2 left and 3 right. A move into a wall leaves you where you are. A move into the goal pays 1 and ends the episode. The cell includes a make_grid helper that builds these arrays, so you only write the sweep.
Policy evaluation on a grid
Write policy_evaluation(next_state, reward, terminal, policy, gamma) that sweeps until the values stop changing. The grid builder is given. Press Check my work.
The tests compare your converged answer with the exact one, found by solving a small linear system, on a 2 x 2 grid and on a 3 x 3 grid with a random policy. They also check one and two sweeps against the hand numbers, which catches an update that reads values already changed in the same sweep. A policy that always goes up must give all zeros, which catches code that ignores the policy.
#Exercise 6: Value Iteration
Value iteration
Write value_iteration(next_state, reward, terminal, gamma) that returns the best values and the greedy policy as whole numbers. The trap example from the hand trace is in the cell. Press Check my work.
The tests check the trap above, exact values on a corridor and on a 3 x 3 grid, where a state d moves from the goal is worth gamma to the power d - 1, and the Bellman optimality equation at the answer. They also check that the policy picks an action whose value really is the highest, that the policy is an integer array, and that the best values are at least the values of the random policy.
#Exercise 7: The REINFORCE Gradient
The REINFORCE gradient
Write reinforce_gradient(theta, states, actions, rewards, gamma) for a softmax table policy. softmax_rows is given. Press Check my work, which tests it against a numerical gradient.
The decisive test is numerical, as in the deep learning lab. It builds the sum of G_t x log pi(a_t | s_t) with each G_t held fixed, nudges every entry of theta up and down, and the measured slope must equal your gradient. A state that repeats in the episode catches an assignment where an addition belongs. Each row of the gradient must add to 0, and a small step along your gradient with a positive return must make the taken action more likely.
#Exercise 8: Generalized Advantage Estimation
GAE advantage
Write gae(rewards, values, last_value, gamma, lam) that returns one advantage per step. Press Check my work.
Limits make the best tests. With lambda 0 the answer must be the plain deltas. With lambda 1 it must equal the discounted return plus the discounted last_value, minus values[t]. If the critic is exactly right, meaning its values equal the true returns, every advantage must be 0. A slow double loop over (gamma x lambda) to the power l times delta checks the middle.
#Where the Traps Are
Every exercise has one common wrong answer that looks right. They fall into four families.
| Symptom | Likely cause |
|---|---|
| Q-values near the end of an episode are too high, or keep growing | The done flag was ignored, so the value of a state that does not exist was added to the target |
| Q-learning and SARSA give identical tables when they should differ | The SARSA target used the best next value instead of the value of the action taken |
| The agent grabs the nearest prize and ignores a bigger one later | gamma was left out, or the policy was read from the rewards instead of from reward plus gamma times the value |
| Policy evaluation gives different answers when the states are numbered differently | Values were updated in place, so some states read new values in the same sweep |
| The same seeded run gives different results each time | A new generator was made inside a function, or a random draw was skipped on one branch |
| The policy gets worse as it trains | The gradient step went downhill, a sign slip, or the returns were taken with the wrong sign |
| The last advantage of an episode is wrong | last_value was ignored, or values[t] was used where the next state's value belongs |
#Capstone Checklist
Use this list to check that you are ready to read and write real RL code. Tick each item only if you can do it without looking.
- I can write the discounted return and the return from every step onward, and say why it is easiest to walk backwards
- I can write an epsilon-greedy choice with a passed-in generator, and a running-average update for an arm
- I can write the Q-learning update, with the done rule, and say what makes it off-policy
- I can write the SARSA update and say in one sentence how it differs from Q-learning
- I can write policy evaluation as sweeps and say why each sweep reads only the old values
- I can write value iteration, read the greedy policy off the values, and explain why the greedy policy must come from reward plus gamma times the value
- I can write the REINFORCE gradient for a softmax policy and prove it with a numerical gradient check
- I can write GAE and say what lambda = 0 and lambda = 1 give
- Given an RL loop I have never seen, I can find the index, terminal, target and sign choices in under a minute
If you can tick every line, you can read the training code of an RL library and spot a convention mismatch. If you cannot, go back to the exercise for that line, open the solution, and write the function again without it.
Simple statistical gradient-following algorithms for connectionist reinforcement learning
Ronald Williams (1992)
The REINFORCE gradient from exercise 7.
Q-learning
Christopher Watkins, Peter Dayan (1992)
The update from exercise 3.
High-Dimensional Continuous Control Using Generalized Advantage Estimation
John Schulman, Philipp Moritz, Sergey Levine, Michael Jordan, Pieter Abbeel (2015)
The advantage estimate from exercise 8.
Reinforcement Learning: An Introduction
Richard Sutton, Andrew Barto (2018)
Policy evaluation, value iteration, SARSA and policy gradients, in the second edition.
#Key Takeaways
- The return is its reward plus gamma times the return of the next step, so it is built by walking backwards. gamma = 1 is the plain sum
- Epsilon-greedy draws one number u from the passed-in generator, explores if u is below epsilon, and otherwise takes np.argmax. A step size of 1 / n makes the estimate the plain average
- The Q-learning target is r + gamma times the best next value, or just r when done. One entry moves, a fraction alpha of the way
- SARSA differs in one place: its target uses the value of the action actually taken next, so it is on-policy and Q-learning is off-policy
- Policy evaluation is a policy-weighted average of reward + gamma * V[next_state], swept using only the old values, with terminal states held at 0
- Value iteration replaces that average by the best action, and the greedy policy is read from the final values, not from the rewards
- The REINFORCE gradient adds G_t * (one-hot - probabilities) to the row of each visited state, with +=, and a numerical gradient check proves it
- GAE is adv[t] = delta[t] + gamma * lam * adv[t + 1]. lam = 0 gives the one-step error and lam = 1 gives the full return minus the critic
#Quick Check
Q-learning and SARSA both build a target from r plus gamma times a Q-value of the next state. What is the difference?