RoboticsBuild a Robot Brain · Bonus · Lesson 7

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.

Time2–3 hours
You'll needPython 3, your Lesson 1 safe_move, paper and a pencil
New wordsreward, episode, Q-value, explore vs exploit, learning rate, discount
Project pieceLearns a maze by itself (a side quest)

Start 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:

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.

Honest note. Q-learning needed hundreds of attempts to learn a 7-step route in a tiny maze. A* (Lesson 6) solved a bigger room instantly. When you have a map, plan with it. Trial and error shines when you can't write down the rules: how to balance on two legs, how hard to grip an egg, how to win a game.

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.

Episodes
0
Last episode
–
Greedy route
–
Q entries
0

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:

Yellow … means "not written yet". All four green and you'll see 4/4 passed.

Stretch

5. Real-world challenge: be the Q-learner

You'll learn a "maze" with no map, using only rewards, exactly like the robot.

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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).
  6. 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

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)