Learning by trial and error: Q-learning
No map, no planner, nobody telling it the answer. Just a robot that tries things, gets points, and slowly writes down which moves were worth it.
safe_move, paper and a pencilStart here
If you can't see the map, you can still learn the way: try moves, notice which ones led to a reward, and keep a scoreboard of "how good is this move from here?"
The problem
In Lesson 6 the robot planned perfect routes with A*. But A* cheated a little: it was handed the whole map of the room. Now drop the robot into a maze it has never seen. No map. All it knows is where it is standing, the four moves it can make, and a beep when it reaches the goal. How can it ever find the shortest way?
The idea in one picture
Think about the first week at a new school. You don't have a map, so on day one you wander, take wrong turns and arrive late. Each time you reach the classroom you remember which corridors got you there. By Friday you walk the quick way without thinking. Nobody gave you a map. You built a mental scoreboard of "from this corridor, turning left is good" by doing it again and again. That scoreboard is exactly what Q-learning builds.
Words to know
- Reward
- Points the robot gets after a move. Here: −1 for every step (moving costs energy), +10 for reaching the goal.
- Episode
- One attempt: start at S, move until you reach G (or give up after 200 steps).
- Q-value
- A number for one square and one move: "how many points do I expect if I do this move here, then play well after?" Q stands for quality.
- Explore / exploit
- Exploit = do the move you currently think is best. Explore = try a random move, in case there's something better you haven't found.
- Learning rate (α)
- How far each new experience moves a Q-value. 0.5 means "move halfway towards the new evidence".
- Discount (γ)
- How much a reward later is worth compared to one now. 0.95 means a point one step later is worth 0.95 points now.
Going deeper: everything below is the full lesson: the idea built from nothing, a demo, your homework, and a challenge away from the screen.
1. The problem
Here is the maze from lesson07_trial_error.py. S is the start, G the goal, # a wall. Coordinates work just like Lesson 1: (x, y), with y growing downwards.
#######
#S..#.# S is at (1, 1)
#.#.#.# G is at (5, 4)
#.#...#
#...#G#
#######
You can see the shortest way is 7 steps (E, E, S, S, E, E, S). The robot can't see this picture. It only knows the square it's on. If it moves into a wall, safe_move from Lesson 1 keeps it where it was, and it has wasted a step.
Obvious idea 1: wander randomly until you hit G. It works, eventually. With random moves, the robot's very first attempt in our code took 58 steps. The second took 101. And it never gets better, because it remembers nothing.
Obvious idea 2: remember the whole route that worked, and repeat it. Better, but the first route that worked was a 58-step mess full of bumps into walls. Repeating a bad route doesn't make it good. We need to remember something smaller and smarter than "the route".
2. First principles
Step 1: give every move a score
Instead of remembering a route, keep a table. For each square and each of the 4 moves, store one number: the Q-value. At the start, everything is 0 because the robot knows nothing.
Q[((5, 3), "S")] = 0 # from (5, 3), moving South: worth 0 so far
Q[((5, 3), "W")] = 0
...
In Python that's a dictionary whose keys are (square, move) pairs. Missing keys count as 0, so we only store what we've learned.
Step 2: rewards say what we want
We never tell the robot the route. We only say what's good and bad:
- every step costs −1 (so long, wandering routes score badly);
- reaching G gives +10, and the episode ends.
A 7-step route scores −1 × 6 + 10 = +4. A 58-step route scores −57 + 10 = −47. So "get the most points" and "find the shortest route" are now the same goal. Choosing rewards like this is a real skill: you describe what you want, and let learning work out how.
Step 3: learn backwards from the goal (the simple version)
Let's pretend the learning rate is 1 for a moment (we just overwrite the old value). The rule is:
new Q = reward if this move ended the episode
new Q = reward + γ × (best Q at the next square) otherwise
"Best Q at the next square" is the robot's current guess of how well things go from there. Watch it spread backwards along the bottom-right corridor, with γ = 0.95:
From (5, 3) move S reaches G: Q = 10 (done, so just the reward)
From (4, 3) move E to (5, 3): Q = −1 + 0.95 × 10 = 8.5
From (3, 3) move E to (4, 3): Q = −1 + 0.95 × 8.5 = 7.075
From (3, 2) move S to (3, 3): Q = −1 + 0.95 × 7.075 ≈ 5.72
Each square near the goal learns "this move is good", and then the square before it learns from that. Good news travels backwards, one square per visit. The robot never needed a map. It just needed to visit the squares enough times.
Predict first: why does each Q-value get smaller as we move away from G?
Two reasons. Each extra step costs −1, and γ = 0.95 shrinks future points a little at every step. So a square far from the goal is honestly worth less: you have more walking to do. That's exactly why the robot will prefer the shorter way. A move that leads somewhere with a higher Q is a move closer to the goal.
Step 4: don't trust one experience too much (learning rate α)
Overwriting is risky. Early guesses are often wrong, and later we'll add random moves. So instead we move the old value part of the way towards the new target:
Q[(square, move)] += α × (target − Q[(square, move)])
With α = 0.5, this means "go halfway". Here's the exact example that check.py uses. The robot is at (1, 1), moves E to (2, 1), gets −1. It already believes Q[((2, 1), "S")] = 4, and every other Q is 0. Use γ = 0.9:
best Q at (2, 1) = max(0, 4, 0, 0) = 4
target = −1 + 0.9 × 4 = 2.6
old Q[(1,1), E] = 0
new Q[(1,1), E] = 0 + 0.5 × (2.6 − 0) = 1.3
And the goal move, visited three times with α = 0.5: 0 → 5 → 7.5 → 8.75. It creeps towards 10 and never jumps there from one lucky try.
Step 5: explore, or you'll get stuck with your first idea
If the robot always picks its best-scoring move (exploit), it may lock onto the first route that worked, even if it's the long one down the left side. So sometimes it must try a random move (explore). We use a number ε (epsilon) = 0.2:
roll a random number between 0 and 1
if it's below 0.2: pick a random move (explore, 20% of the time)
otherwise: pick the best-scoring move (exploit, 80% of the time)
This is called ε-greedy. It's the same choice you make picking food: your favourite (exploit) or something new that might be even better (explore).
When all the Q-values for a square are tied (like at the start, when they're all 0), we pick the first move in the order "NSEW". That rule matters: it makes the program behave the same every time, so the checker can test it.
Step 6: put it all together
repeat for 300 episodes:
stand on S
until you reach G (or 200 steps pass):
choose a move (ε-greedy)
do it with safe_move, see where you land
reward = +10 if you landed on G, else −1
update Q for (square, move)
move to the new square
After training, to use what it learned, the robot turns exploring off and just follows the best move from every square. That's the "greedy path".
Predict first: during training the last episodes take 9–11 steps, but the greedy path is 7. Why the difference?
During training, ε = 0.2 is still switched on, so about one move in five is random. Random moves sometimes bump into walls or wander off, adding a few steps. When we follow the greedy path, nothing is random, so the robot takes the 7-step route it has learned. Exploring costs a little now and buys you knowledge for later.
3. Play with it
Interactive: Q-learning on the real MAZE
The same maze, rewards and settings as the Python file (α = 0.5, γ = 0.95, 200 steps max), with the same random numbers as Python's random.Random(0). With ε at 0.2, the first five episodes are 58, 101, 13, 46, 22 steps, exactly like your program. Brighter squares have a higher best Q. Arrows show the best move from each square. The bar chart (right) shows steps per episode.
Things to try: run single episodes and watch the arrows appear near G first, then spread backwards. Set ε to 0 and Reset: with no exploring, does it still find 7? (Predict first, then check the answer in section 8.) Set ε to 1 and train to 300: every move is random, so the bars never get shorter. But look at "Greedy route". Surprised?
4. Homework
Open homework/lesson07_trial_error.py. The maze, ACTIONS = "NSEW" and find_char are written for you. It imports safe_move from your Lesson 1 file, so Lesson 1 must be finished. Write these five functions, in order. Q is a dictionary with keys like ((1, 1), "E").
4.1 best_action(Q, pos)
Return the move (one letter) with the highest Q-value at pos. Missing values count as 0. On a tie, return the one that comes first in "NSEW".
Example: best_action({}, (1, 1)) → "N" (all zeros, so the first). best_action({((1, 1), "E"): 2.0}, (1, 1)) → "E".
Hint 1
Loop over the four letters in ACTIONS and keep track of the best one so far.
Hint 2
Read a value safely with Q.get((pos, a), 0.0). Only replace your "best so far" when a value is strictly bigger, and the first letter wins ties automatically.
Hint 3
Start with best = ACTIONS[0]. For each a in ACTIONS: if Q-value of a > Q-value of best, set best = a. Return best.
4.2 choose_action(Q, pos, epsilon, rng)
ε-greedy. With probability epsilon return rng.choice(ACTIONS), otherwise return best_action(Q, pos).
Example: with epsilon = 0 it always exploits, so choose_action({((1, 1), "W"): 5.0}, (1, 1), 0.0, rng) → "W".
Hint 1
rng.random() gives a number from 0 up to (not including) 1. How often is that below 0.2?
Hint 2
Use rng, not the random module directly. That way the results repeat exactly, and the checker can test them.
Hint 3
If rng.random() < epsilon: return a random choice from ACTIONS. Otherwise: return the best action. Call rng.random() exactly once per choice, before anything else.
4.3 q_update(Q, pos, action, reward, next_pos, alpha, gamma, done)
The learning rule from Step 4. It changes Q in place and returns nothing.
Worked example (from above): Q = {((2, 1), "S"): 4.0}, then q_update(Q, (1, 1), "E", -1.0, (2, 1), 0.5, 0.9, False) leaves Q[((1, 1), "E")] == 1.3.
Hint 1
Three numbers: the old value, the target, and then the new value. Work them out one per line.
Hint 2
If done is True, the target is just the reward (there's no "next square" to look forward to). Otherwise it's reward plus γ times the biggest Q-value at next_pos, over all four moves.
Hint 3
old = Q.get(...). Target: if done, reward, else reward + gamma * max(Q-value of (next_pos, a) for each a in ACTIONS). Then store old + alpha * (target - old).
4.4 q_learn(room, start, goal, episodes=300, ...)
Run the whole training loop from Step 6. Return two things: the Q dictionary and a list with the number of steps each episode took.
Hint 1
Two loops: an outer for over episodes, an inner while for the steps of one episode.
Hint 2
Make Q and the step list before the outer loop. Q must survive from one episode to the next, otherwise the robot forgets everything. If rng is None, use random.Random(0).
Hint 3
Inside an episode: pos = start, steps = 0. While steps < max_steps: choose a move, nxt = safe_move(room, pos, a), done = nxt == goal, reward 10.0 if done else -1.0, call q_update, set pos = nxt, add 1 to steps, and break if done. After the while, append steps to the list.
4.5 greedy_path(Q, room, start, goal, max_steps=100)
Use what was learned, with no exploring: from start, keep taking best_action until you reach goal. Return every square visited, including the start.
Hint 1
This is very like follow from Lesson 1, except the robot picks each move itself.
Hint 2
Add a safety limit. If Q is untrained, the best move might be "N" into a wall forever, and your loop would never end.
Hint 3
path = [start]. While the last square isn't the goal and len(path) <= max_steps: move with safe_move using best_action, append the new square. Return path.
Run it
cd homework
python3 lesson07_trial_error.py
What you should see (this is the real output of the reference solution):
steps in first 5 episodes: [58, 101, 13, 46, 22] | last 5: [9, 9, 9, 11, 10]
#######
#S**#.#
#.#*#.#
#.#***#
#...#G#
#######
learned route length: 7
The * marks show the route the robot learned on its own. Your first-five numbers will match exactly only if you call rng in the same order as described (one rng.random() per choice, then rng.choice only when exploring). If they differ but the route is still 7, your code is probably fine.
Check it
cd ..
python3 check.py 7
A green ✓ means that function passed. A red ✗ comes with a hint. For example:
✗ best_action: all zeros: pick the first action, 'N'means your tie-break is wrong (maybe you used>=, so the last tied letter wins).✗ q_update: target = -1 + 0.9 * 4; Q moves halfway theremeans your target or your α step is off. Work the 1.3 example on paper and compare.
Yellow … means "not written yet". All four green and you'll see 4/4 passed.
Stretch
- Change the step reward from −1 to 0 and run again. Does the robot still find the 7-step route? (Think: if steps are free, why hurry? γ is the only thing left pushing it.)
- Make ε shrink over time: start at 1.0 and multiply it by 0.98 after each episode. Do the last episodes get down to 7?
- Draw your own bigger maze in the
MAZElist (keep the walls round the edge) and train on it. How many episodes does it need now?
5. Real-world challenge: be the Q-learner
You'll learn a "maze" with no map, using only rewards, exactly like the robot.
- Ask someone to draw a secret 4 × 4 grid maze on paper (start in one corner, goal in another, a few walls), and keep it hidden from you.
- Draw your own empty 4 × 4 grid. This is your Q-table: in each square, leave room for four small numbers (N, S, E, W), all starting at 0.
- Play an episode. Say a move out loud. Your partner answers only "wall" (you stay put), "step" (−1) or "goal!" (+10). Keep count of your steps.
- After every move, update the number for that square and move with the simple rule: target = reward + 0.9 × (best number in the square you landed on). To keep the maths easy, use α = 1 (just write the target in).
- Play 6 to 8 episodes. Mostly pick your best-scoring move, but roll a die before each move: on a 1, pick a random direction (that's ε ≈ 0.17).
- Record the steps per episode in a little table and draw a quick bar chart.
What to notice: the numbers fill in from the goal backwards, and your episodes get shorter even though you never saw the map. Also notice how annoying the die is when you already "know" the way, and think about when it saved you from a long route. Bonus: swap roles and see if your partner learns faster or slower than you.
6. Your Robo-Helper so far
Let's be honest: this lesson is a side quest. The finished Robo-Helper (Lesson 11) does not use Q-learning to drive around the room. It has a map, so it uses astar_path from Lesson 6, which is faster and always finds the shortest route. That's the right engineering choice. Don't use trial and error when you can plan.
So why learn it? Because there's a whole family of robot skills where nobody can write the map or the rules down. Walking, running, picking up squishy things, balancing. For those, real robotics teams use the same loop you just wrote: try, get a reward, update, repeat. They run it millions of times inside a simulator (a video-game copy of the robot and the world), because a real robot falling over a million times would break. Then they copy what was learned to the real robot. The maths is fancier (a neural network instead of a table, and methods such as PPO instead of plain Q-learning), but the idea is yours now.
What you built: q_learn and greedy_path, a robot that learns MAZE by itself. What comes next uses language instead: in Lesson 8 the robot starts turning words into numbers, the first step towards understanding "put the red block in the bin".
7. Common mistakes
- Wrong tie-break. Using
>=instead of>, ormaxon values only, can return the wrong letter when values are equal. Checker hint: "all zeros: pick the first action, 'N'". - Creating Q inside the episode loop. Then the robot forgets everything after each attempt and never improves. Checker hint: "episodes should get shorter as the robot learns".
- Looking ahead after the goal. When
doneis True the target is just the reward. Adding γ × (Q at the goal square) makes the goal look worth more than it is. - Using
Q[(pos, a)]instead ofQ.get(...). Unvisited pairs aren't in the dictionary yet, so you'll get aKeyError. - Exploring when you should exploit.
greedy_pathmust usebest_action, neverchoose_action. Otherwise the route is random each time. Checker hint: "after training, the greedy route should be the shortest: 7 steps".
8. Check yourself
Why is each step worth −1 instead of 0?
So that shorter routes earn more points. With −1 per step, "get the most points" means "reach G in the fewest steps". The reward describes the goal; the learning finds the route.
What's the difference between A* (Lesson 6) and Q-learning?
A* needs the map and plans before moving. Q-learning needs no map. It learns from experience, but needs many attempts. If you have a map, A* is better.
Predict: Q = {((3, 3), "E"): 6.0, ((3, 3), "S"): 6.0}. What does best_action(Q, (3, 3)) return?
"S". N and W are 0. S and E tie at 6.0, and S comes before E in "NSEW", so the tie-break picks S.
What would happen with ε = 0 from the very start?
Surprise: in this maze it still learns the 7-step route (the first episodes take 79, 43, 48, 25, 30 steps, then it settles on 7). Here's why. Every move it tries costs −1, so its Q drops below 0. Moves it has never tried are still at 0, so they now look best. The robot ends up exploring "by accident". In big worlds, or with positive rewards along the way, that trick fails and the robot locks onto the first route that worked. That's why real systems keep some ε. (Bonus: with ε = 1, fully random, the greedy route is also 7. Q-learning learns about the best moves even while acting randomly, because its target always uses the best Q at the next square.)
With α = 0.5, a Q-value starts at 0 and gets the target 10 three times. What is it?
0 → 5 → 7.5 → 8.75. Each time it moves halfway to 10.
9. Go deeper (optional)
- TensorTonic: the RL ladder: practice problems that climb from discounted rewards (your γ) to the Bellman equation (your
q_update) and beyond. - 30u30 Day 29: PPO: the algorithm many walking robots (and ChatGPT's fine-tuning) are trained with. It's the grown-up cousin of what you just wrote.
- GRPO: reinforcement learning used to train language models to reason, with rewards for correct answers.
- Free robotics courses: where to learn more robotics, at no cost.