Goes with Lecture 01: MDPs and Bellman equations.
You’ll implement value iteration (repeated Bellman-optimality backups) and use it to solve a small gridworld exactly. The environment (GridWorldMDP) is given; you write the algorithm. No deep learning, no PyTorch: just NumPy and the Bellman equation.
The task
Fill in the TODOs in starter.py. Two functions:
value_iteration(mdp, gamma, theta): startVat zeros, then sweep: for each non-terminal state, setV[s] = max_a Σ_{(p, s', r) ∈ transitions(s, a)} p · (r + γ V[s']). Keep sweeping until the largest change in a sweep drops belowtheta. ReturnV.greedy_policy(mdp, V, gamma): for each non-terminal state, the action that maximizesΣ p · (r + γ V[s']). Return an int array.
GridWorldMDP and follow_greedy are given. The MDP interface you use: mdp.n_states, mdp.n_actions, mdp.is_terminal(s), mdp.transitions(s, a) -> [(prob, next_state, reward), ...]. Writing the backup against transitions (which returns a list) rather than assuming determinism means the same value_iteration works on stochastic MDPs too, handy for the variations below.
Acceptance criteria
pytest exercises/01-mdps/
passes:
value_iterationmatches the hand-computedV*on two tiny MDPs (a 2-state one and a 3-state chain).greedy_policypicks the action that reaches the paying terminal state.- On the 5×5 gridworld:
V*is +10 at a goal-adjacent cell, decreases as you move away from the goal, and is 0 at the (terminal) goal; and the greedy policy reaches the goal from every state.
You’re done when the tests pass and you can explain: why value iteration converges (the Bellman-optimality operator is a γ-contraction in max-norm), and why you skip terminal states in the sweep.
If you get stuck
HINTS.md, one at a time. Reference solution: solution/value_iteration.py, after you’ve tried.
Going further (optional)
- Stochastic transitions. Make a
SlipperyGridWorldwhere each action does what you asked with probability 0.8 and slips to a perpendicular cell with probability 0.1 each, sotransitions(s, a)returns three entries. Yourvalue_iterationshould work unchanged. How doesV*and the policy change near holes/walls? - Add obstacles with a big negative reward, and watch the policy route around them.
- Compare to policy iteration (Lecture 01, Part 5): which does fewer total backups on this gridworld? Which does fewer sweeps?
- Sweep γ over
{0.5, 0.9, 0.99, 0.999}and look at how the value function and the policy change. At low γ, why does the agent stop caring about a far-away goal?
Starter code
This is the file you edit. The TODOs are yours.
Show starter.py
"""Value iteration on a gridworld MDP — exercise starter.
Fill in the TODOs, then from the repo root:
pip install -r exercises/requirements.txt
pytest exercises/01-mdps/
See README.md for the task and HINTS.md if you're stuck. Reference solution:
solution/value_iteration.py — look after you've tried.
"""
import numpy as np
GAMMA = 0.99
_DELTAS = {0: (-1, 0), 1: (0, 1), 2: (1, 0), 3: (0, -1)} # up, right, down, left
class GridWorldMDP:
"""Deterministic gridworld. Reach the goal cheaply.
States are grid cells, indexed `i * size + j` for cell (row i, col j). The
goal is the top-right corner (0, size-1). Actions: 0=up, 1=right, 2=down,
3=left, clamped at the walls. Reward: +10 for the step that lands on the
goal, -1 for every other step. The goal is terminal (absorbing, no reward).
Given — you don't implement this. It exposes the interface value_iteration /
greedy_policy below should use:
mdp.n_states, mdp.n_actions
mdp.is_terminal(s) -> bool
mdp.transitions(s, a) -> list of (prob, next_state, reward)
"""
def __init__(self, size: int = 5):
self.size = size
self.n_states = size * size
self.n_actions = 4
self.goal = size - 1 # cell (0, size-1) -> index 0*size + (size-1)
def is_terminal(self, s: int) -> bool:
return s == self.goal
def transitions(self, s: int, a: int):
if self.is_terminal(s):
return [(1.0, s, 0.0)]
i, j = divmod(s, self.size)
di, dj = _DELTAS[a]
i = min(self.size - 1, max(0, i + di))
j = min(self.size - 1, max(0, j + dj))
ns = i * self.size + j
return [(1.0, ns, 10.0 if ns == self.goal else -1.0)]
def value_iteration(mdp, gamma: float = GAMMA, theta: float = 1e-8) -> np.ndarray:
"""Compute V* by repeated Bellman-optimality backups until it stops changing.
V[s] <- max over a of sum over (p, s', r) in mdp.transitions(s, a) of p * (r + gamma * V[s'])
Skip terminal states — their value stays 0. After each sweep, stop if the
largest change to any V[s] was below `theta`. Return V: a 1-D float array of
length mdp.n_states.
"""
# TODO
raise NotImplementedError("value_iteration")
def greedy_policy(mdp, V, gamma: float = GAMMA) -> np.ndarray:
"""The policy greedy with respect to V: for each non-terminal state, the
action maximizing sum over (p, s', r) of p * (r + gamma * V[s']).
Return an int array of length mdp.n_states (one action per state; the value
at terminal states doesn't matter — leave it 0).
"""
# TODO
raise NotImplementedError("greedy_policy")
def follow_greedy(mdp, policy, start_state: int, max_steps: int = 100) -> bool:
"""Walk `policy` from `start_state` (assumes deterministic transitions).
Return True if a terminal state is reached within `max_steps`. Given."""
s = start_state
for _ in range(max_steps):
if mdp.is_terminal(s):
return True
_, s, _ = mdp.transitions(s, int(policy[s]))[0]
return mdp.is_terminal(s)
if __name__ == "__main__":
mdp = GridWorldMDP(size=5)
V = value_iteration(mdp)
policy = greedy_policy(mdp, V)
print("V*:")
print(np.round(V.reshape(mdp.size, mdp.size), 2))
print("\nGreedy policy (G = goal):")
arrows = {0: "^", 1: ">", 2: "v", 3: "<"}
for i in range(mdp.size):
row = []
for j in range(mdp.size):
s = i * mdp.size + j
row.append("G" if mdp.is_terminal(s) else arrows[int(policy[s])])
print(" ".join(row))
Hints
Read one at a time. Try after each before reading the next.
Hint 1: the Q-value helper
Both functions need “the value of taking action a in state s, then acting optimally”: q(s, a) = sum(p * (r + gamma * V[sp]) for (p, sp, r) in mdp.transitions(s, a)). Write that once (a small helper, or inline) and reuse it in both value_iteration and greedy_policy.
Hint 2: value_iteration
V = np.zeros(mdp.n_states). Then loop forever: track delta = 0.0; for each state s, if mdp.is_terminal(s) skip it; else v_old = V[s], V[s] = max(q(s, a) for a in range(mdp.n_actions)), delta = max(delta, abs(v_old - V[s])). After the sweep, if delta < theta: return V. Updating V in place during the sweep (Gauss-Seidel) is fine; it just converges a bit faster.
Hint 3: why skip terminal states
A terminal state has no future, so V[terminal] = 0 by definition. If you run the backup on it, transitions returns a self-loop [(1.0, s, 0.0)], so V[s] = max_a (0 + gamma * V[s]), which forces V[s] = 0 here, but in a different MDP a self-loop could blow up or stall. Cleaner to leave terminal states at 0 and never touch them.
Hint 4: greedy_policy
policy = np.zeros(mdp.n_states, dtype=int); for each non-terminal s, policy[s] = int(np.argmax([q(s, a) for a in range(mdp.n_actions)])). Leave terminal states at 0; the action there is never used. (Ties: np.argmax picks the lowest index; that’s fine here, both tied actions make progress toward the goal.)
Hint 5: if the gridworld tests fail
Print np.round(V.reshape(mdp.size, mdp.size), 2). The goal cell should be 0 (terminal), its left neighbor should be 10 (one “right” step lands on the goal for +10), and the numbers should decrease as you move down and left away from the goal. If the goal-adjacent cell isn’t ≈ 10, you’re probably bootstrapping the goal’s value into it incorrectly. Remember the reward is on the transition into the goal, and transitions already includes that +10 reward.
Hint 6
If you’re past hint 5 and still stuck, read solution/value_iteration.py. Then re-derive value iteration from the Bellman-optimality equation without looking.
Files
starter.py: the file you edit.test_value_iteration.py: 5 tests. Run them from the repo root withpytest exercises/01-mdps/.solution/value_iteration.py: the reference solution. Open it when you decide to, not before.