Q-Table Explained: 5 Steps to Master Reinforcement Learning

Imagine you're teaching a dog new tricks. You don't hand it a manual — you reward it when it does something right and ignore (or correct) it when it doesn't. Over dozens of repetitions, the dog builds an internal sense of which actions pay off in which situations. That's the soul of reinforcement learning, and the Q-Table is how we give a computer program that same kind of memory.

In this post, we'll build a complete Q-learning agent from scratch — a warehouse robot that learns to navigate a 5×5 grid to deliver packages while avoiding forklift collision zones. By the end, you'll understand not just what a Q-Table is, but why every design choice exists.




The Problem Q-Learning Solves

Why Supervised Learning Fails Here

Supervised learning is fantastic when you have labeled examples: "given this email, the correct label is spam." But consider our warehouse robot. What's the "correct" move when the robot is at position (2, 3) and the target shelf is at (4, 4)? Move right? Move down? Both might eventually work. The right answer depends on the entire sequence of future moves — not just the current step.

There's no teacher handing out labeled training data. The robot must discover good strategies through trial and error. This is the core of sequential decision-making under uncertainty.

The Explore-Exploit Dilemma

Here's a tension every RL agent faces:

  • Exploit: Do what you already know works well. (Go the route you've taken before.)
  • Explore: Try something new. Maybe there's a better route you haven't discovered yet.

Think of it like a delivery driver who always takes the same highway route because it worked yesterday. One day the highway is jammed, but the driver never finds the faster back roads because they stopped exploring. If the robot only exploits, it gets stuck in the first decent strategy it finds. If it only explores, it never actually gets good at anything. The balance is called the epsilon-greedy policy, and we'll implement it in Step 3.

What "Reward" Means Computationally

A reward is simply a scalar number the environment hands back after each action: - +100 for reaching the target shelf - -50 for hitting a forklift collision zone - -1 for each time step (to encourage finding fast paths, not just safe ones)

The robot's job is to maximize total cumulative reward over time — not just the immediate reward of a single move.

The Reinforcement Learning Loop

Agent observes State s
Chooses Action a
Environment returns Reward r + Next State s'
Agent updates Q-Table
Repeat until convergence
The agent-environment loop is the heartbeat of Q-learning. Each cycle produces one experience tuple (s, a, r, s') used to sharpen the Q-Table. The agent never sees the full map — it only learns from these one-step snapshots.

Anatomy of a Q-Table

A Q-Table is a plain 2D matrix. Think of it like a laminated cue card the robot carries at all times — every room it might enter is a row, every direction it might walk is a column, and each cell holds its best guess at how rewarding that move will be:

  • Rows = every possible state the robot can be in (each of the 25 grid cells)
  • Columns = every possible action (UP, DOWN, LEFT, RIGHT)
  • Cell value = Q-value — an estimate of "how good is it to take this action from this state?"
         UP      DOWN    LEFT    RIGHT
State 0  [ 0.00   0.00    0.00    0.00  ]   ← (row 0, col 0): Start
State 1  [ 0.00   0.00    0.00    0.00  ]   ← (row 0, col 1)
...       ...
State 12 [ 0.00   0.00    0.00    0.00  ]   ← Center of grid
...       ...
State 24 [ 0.00   0.00    0.00    0.00  ]   ← Target shelf!

After training:
         UP      DOWN    LEFT    RIGHT
State 0  [ 0.12   0.45   -0.30    0.67  ]   Robot should go RIGHT
State 5  [-0.50   0.89    0.23    0.45  ]   Robot should go DOWN
A Q-Table before (all zeros) and after training. Each cell stores the expected future reward for taking that action from that state. The robot picks the action with the highest Q-value in its current row — no computation needed at decision time, just a lookup.

Initialization and Size

Every cell starts at 0.0 — the agent assumes nothing about the world. Over training episodes, values drift toward their true worth. Starting at zero is a deliberate choice: it makes the agent mildly optimistic about unexplored actions (since even a small positive reward will beat 0.0 initially), which naturally encourages exploration early in training.

Size? For our 5×5 warehouse: 25 states × 4 actions = 100 cells. Totally manageable. But consider a chess engine: ~10⁴⁷ possible board states. A Q-Table would require more memory than there are atoms in the observable universe. That's the state space explosion problem — we'll revisit it at the end.


The Bellman Equation Demystified

Intuition First

Imagine you're a hiking guide estimating how long it takes to reach a mountain summit from various waypoints on the trail. You don't walk every possible path from scratch each time — instead, you reason backwards: "From waypoint C, the summit is 30 minutes away. So from waypoint B (20 minutes from C), the total is 50 minutes." Once you know the value of a closer waypoint, you can immediately compute the value of every waypoint leading into it.

The Bellman equation does exactly this for Q-values — it propagates the value of reaching the goal backwards through the state space, one step at a time:

*"The value of being in state s and taking action a equals the immediate reward, plus the discounted value of the best action you can take from wherever you end up."

The Formula

Q(s, a) ← Q(s, a) + α [ r + γ · max Q(s', a') − Q(s, a) ]
                              └─────────────────────┘
                               Temporal Difference (TD) Error

Let's dissect every piece:

Symbol Name Role Typical Range
Q(s,a) Current Q-value What we currently think this move is worth Any real number
α (alpha) Learning rate How fast to update — like a student who absorbs 30% of each lesson 0.01 – 0.5
r Immediate reward What the environment actually gave us right now Domain-specific
γ (gamma) Discount factor How much we care about future rewards vs. immediate ones 0.9 – 0.99
max Q(s',a') Best future Q-value Best known value of the next state Any real number
TD Error Surprise signal How wrong our current estimate was — shrinks toward 0 at convergence Shrinks to 0 at convergence

What Each Hyperparameter Controls

α (Learning Rate): - Too high (α=0.9): The agent overreacts to each experience, values oscillate wildly — like a student who completely rewrites their notes after every single lecture, forgetting everything they studied the week before. - Too low (α=0.001): Learning is painfully slow, may not converge in reasonable time. - Sweet spot: Start at 0.1, decay slowly over episodes.

γ (Discount Factor): - γ=0: Robot is completely short-sighted — only cares about immediate reward (terrible for navigation). - γ=1: Robot gives equal weight to rewards 1,000 steps in the future (can cause instability in long episodes). - γ=0.95: "I care about future rewards, but I prefer sooner ones" — realistic and stable.

ε (Epsilon — explore/exploit): - ε=1.0: Pure exploration (random actions) - ε=0.0: Pure exploitation (always take best known action) - Strategy: Start at 1.0, decay to 0.01 over training

Temporal Difference: How Surprise Drives Learning

Agent predicts Q(s,a) = 2.5
Takes action, gets r=10, lands in s'
TD Error = 10 + 0.95×3.0 − 2.5 = 10.35
New Q = 2.5 + 0.1×10.35 = 3.535
The TD Error is the "surprise" signal. A large positive error means this move was better than expected — raise its Q-value. A negative error means it was worse — lower it. Over thousands of steps, these small corrections accumulate into reliable estimates.

Step-by-Step: Building a Q-Table from Scratch

Our Scenario: The Warehouse Robot Navigator

A 5×5 grid warehouse. The robot starts at cell (0,0) — the loading dock. The target shelf is at (4,4). Three cells are blocked forklift zones: (1,1), (2,3), and (3,1).

  0   1   2   3   4
0 [S] [ ] [ ] [ ] [ ]
1 [ ] [X] [ ] [ ] [ ]
2 [ ] [ ] [ ] [X] [ ]
3 [ ] [X] [ ] [ ] [ ]
4 [ ] [ ] [ ] [ ] [T]

S = Start (loading dock)
T = Target shelf
X = Forklift collision zone

States are numbered 0–24 by row-major order: state = row×5 + col.


Step 1: Define the Environment

Before writing any learning code, we formalize the world. This is the most important step — a poorly designed reward structure will produce a robot that technically maximizes its score while doing something completely wrong (a phenomenon called reward hacking).

  • States: 25 (one per grid cell)
  • Actions: 4 (0=UP, 1=DOWN, 2=LEFT, 3=RIGHT)
  • Rewards:
  • Reach target (4,4): +100
  • Hit forklift zone: -50 (episode ends)
  • Each step: -1 (time penalty encourages short paths)
  • Hit wall (try to move off grid): -5 (discourage wall bumping)

Why the -1 step penalty? Without it, a robot that reaches the goal in 8 steps and one that reaches it in 80 steps receive the same total reward (+100). The step penalty forces the robot to prefer shorter routes — exactly what we want in a real warehouse.


Step 2: Initialize the Q-Table

All zeros. The robot knows nothing yet.

import java.util.Arrays;

public class Main {
    static final int NUM_STATES = 10;
    static final int NUM_ACTIONS = 4;

    public static void main(String[] args) {
        double[][] qTable = new double[NUM_STATES][NUM_ACTIONS];
        // Java initializes double arrays to 0.0 by default
        // But let's be explicit for clarity:
        for (int state = 0; state < NUM_STATES; state++) {
            Arrays.fill(qTable[state], 0.0);
        }
    }
}

Step 3: Epsilon-Greedy Policy

With probability ε, explore (random action). Otherwise, exploit (best Q-value). Note how rng.nextDouble() returns a value in [0.0, 1.0) — if ε is 0.3, there's a 30% chance we explore.

import java.util.Random;

public class Main {
    static final int NUM_ACTIONS = 4;

    static int chooseAction(double[][] qTable, int state, double epsilon, Random rng) {
        if (rng.nextDouble() < epsilon) {
            // Explore: pick a random action
            return rng.nextInt(NUM_ACTIONS);
        } else {
            // Exploit: pick the action with highest Q-value
            int bestAction = 0;
            double bestValue = qTable[state][0];
            for (int action = 1; action < NUM_ACTIONS; action++) {
                if (qTable[state][action] > bestValue) {
                    bestValue = qTable[state][action];
                    bestAction = action;
                }
            }
            return bestAction;
        }
    }

    public static void main(String[] args) {
        Random rng = new Random();
        int numStates = 5;
        double epsilon = 0.1;

        double[][] qTable = new double[numStates][NUM_ACTIONS];

        int state = 0;
        int action = chooseAction(qTable, state, epsilon, rng);
        System.out.println("Chosen action for state " + state + ": " + action);
    }
}

Step 4: Run Episodes and Apply the Update Rule

An episode is one complete run of the robot from start to either reaching the target or hitting a forklift zone. We run many episodes — each one contributes a small amount of learning. The Bellman update happens after every single step, not just at the end of each episode.

import java.util.Arrays;

public class Main {
    static final int MAX_EPISODES = 1000;
    static final int START_STATE = 0;
    static final double GAMMA = 0.99;
    static final double ALPHA = 0.1;
    static final double EPSILON_MIN = 0.01;
    static final double EPSILON_DECAY = 0.995;

    static class StepResult {
        int nextState;
        double reward;
        boolean done;

        StepResult(int nextState, double reward, boolean done) {
            this.nextState = nextState;
            this.reward = reward;
            this.done = done;
        }
    }

    static class Environment {
        StepResult step(int state, int action) {
            // Placeholder implementation
            return new StepResult(0, 0.0, true);
        }
    }

    static int chooseAction(double[][] qTable, int state, double epsilon, java.util.Random rng) {
        if (rng.nextDouble() < epsilon) {
            return rng.nextInt(qTable[state].length);
        }
        int bestAction = 0;
        for (int i = 1; i < qTable[state].length; i++) {
            if (qTable[state][i] > qTable[state][bestAction]) {
                bestAction = i;
            }
        }
        return bestAction;
    }

    public static void main(String[] args) {
        int numStates = 10;
        int numActions = 4;
        double[][] qTable = new double[numStates][numActions];
        double epsilon = 1.0;
        java.util.Random rng = new java.util.Random();
        Environment environment = new Environment();

        for (int episode = 0; episode < MAX_EPISODES; episode++) {
            int state = START_STATE;
            boolean episodeDone = false;

            while (!episodeDone) {
                int action = chooseAction(qTable, state, epsilon, rng);
                StepResult result = environment.step(state, action);

                // Bellman update
                double bestNextQ = Arrays.stream(qTable[result.nextState]).max().getAsDouble();
                double tdError = result.reward + GAMMA * bestNextQ - qTable[state][action];
                qTable[state][action] += ALPHA * tdError;

                state = result.nextState;
                episodeDone = result.done;
            }

            // Decay epsilon so the robot gradually stops exploring
            epsilon = Math.max(EPSILON_MIN, epsilon * EPSILON_DECAY);
        }
    }
}

Step 5: Extract the Learned Policy

After training, read the policy: for each state, the best action is the one with the highest Q-value. This is called taking the argmax of each row.

public class Main {
    static final int GRID_ROWS = 5;
    static final int GRID_COLS = 5;
    static final int TARGET_STATE = GRID_ROWS * GRID_COLS - 1;
    static final int NUM_ACTIONS = 4;

    static boolean isForkLiftZone(int row, int col) {
        // Define forklift zones - customize as needed
        return (row == 2 && col == 2);
    }

    void printPolicy(double[][] qTable) {
        String[] actionSymbols = {"↑", "↓", "←", "→"};
        for (int row = 0; row < GRID_ROWS; row++) {
            for (int col = 0; col < GRID_COLS; col++) {
                int state = row * GRID_COLS + col;
                if (isForkLiftZone(row, col)) { System.out.print(" X "); continue; }
                if (state == TARGET_STATE)    { System.out.print(" T "); continue; }
                int bestAction = 0;
                for (int a = 1; a < NUM_ACTIONS; a++) {
                    if (qTable[state][a] > qTable[state][bestAction]) bestAction = a;
                }
                System.out.print(" " + actionSymbols[bestAction] + " ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        Main main = new Main();
        double[][] qTable = new double[GRID_ROWS * GRID_COLS][NUM_ACTIONS];
        main.printPolicy(qTable);
    }
}

Expected output after 500 episodes:

 → → → → ↓
 → X → → ↓
 → → → X ↓
 → X → → ↓
 → → → → T

The robot learned to hug the right side and travel down — exactly the optimal path!


Full Java Implementation

Here's the complete, self-contained warehouse robot Q-learning agent:

import java.util.Arrays;
import java.util.Random;

/**
 * Warehouse Robot Q-Learning Agent
 * A 5x5 grid where a robot learns to navigate from (0,0) to (4,4)
 * while avoiding forklift collision zones.
 */
public class WarehouseRobotQLearning {

    // --- Environment Configuration ---
    static final int GRID_ROWS = 5;
    static final int GRID_COLS = 5;
    static final int NUM_STATES = GRID_ROWS * GRID_COLS;   // 25
    static final int NUM_ACTIONS = 4; // 0=UP, 1=DOWN, 2=LEFT, 3=RIGHT

    static final int START_STATE  = 0;  // (0,0) loading dock
    static final int TARGET_STATE = 24; // (4,4) storage shelf

    // Forklift collision zones: (row,col) → state index
    static final int[] FORKLIFT_ZONES = {
        1 * GRID_COLS + 1,  // (1,1) = state 6
        2 * GRID_COLS + 3,  // (2,3) = state 13
        3 * GRID_COLS + 1   // (3,1) = state 16
    };

    // --- Q-Learning Hyperparameters ---
    static final double ALPHA         = 0.1;   // Learning rate
    static final double GAMMA         = 0.95;  // Discount factor
    static final double EPSILON_START = 1.0;   // Initial exploration rate
    static final double EPSILON_MIN   = 0.01;  // Minimum exploration rate
    static final double EPSILON_DECAY = 0.995; // Decay per episode
    static final int    MAX_EPISODES  = 1000;
    static final int    MAX_STEPS     = 200;   // Max steps per episode

    // --- Reward Values ---
    static final double REWARD_TARGET   =  100.0;
    static final double REWARD_FORKLIFT =  -50.0;
    static final double REWARD_STEP     =   -1.0;
    static final double REWARD_WALL     =   -5.0;

    // Action deltas: row-change and col-change for each action
    static final int[] ROW_DELTA = {-1,  1,  0,  0}; // UP, DOWN, LEFT, RIGHT
    static final int[] COL_DELTA = { 0,  0, -1,  1};
    static final String[] ACTION_NAME = {"UP", "DOWN", "LEFT", "RIGHT"};
    static final String[] ACTION_SYMBOL = {"↑", "↓", "←", "→"};

    // ----------------------------------------------------------------
    //  Environment Logic
    // ----------------------------------------------------------------

    static boolean isForkLiftZone(int state) {
        for (int zone : FORKLIFT_ZONES) if (zone == state) return true;
        return false;
    }

    static int stateToRow(int state) { return state / GRID_COLS; }
    static int stateToCol(int state) { return state % GRID_COLS; }
    static int toState(int row, int col) { return row * GRID_COLS + col; }

    /**
     * Executes one action from the current state.
     * Returns: [nextState, reward, done]
     */
    static Object[] step(int currentState, int action) {
        int row = stateToRow(currentState);
        int col = stateToCol(currentState);

        int newRow = row + ROW_DELTA[action];
        int newCol = col + COL_DELTA[action];

        // Check for wall collision
        if (newRow < 0 || newRow >= GRID_ROWS || newCol < 0 || newCol >= GRID_COLS) {
            // Stay in place, pay wall penalty
            return new Object[]{currentState, REWARD_WALL, false};
        }

        int nextState = toState(newRow, newCol);

        // Check for forklift zone
        if (isForkLiftZone(nextState)) {
            return new Object[]{nextState, REWARD_FORKLIFT, true}; // Episode ends
        }

        // Check for target reached
        if (nextState == TARGET_STATE) {
            return new Object[]{nextState, REWARD_TARGET, true}; // Episode ends
        }

        // Normal step
        return new Object[]{nextState, REWARD_STEP, false};
    }

    // ----------------------------------------------------------------
    //  Policy: Epsilon-Greedy Action Selection
    // ----------------------------------------------------------------

    static int chooseAction(double[][] qTable, int state, double epsilon, Random rng) {
        if (rng.nextDouble() < epsilon) {
            return rng.nextInt(NUM_ACTIONS); // Explore
        }
        // Exploit: find action with max Q-value
        int bestAction = 0;
        for (int a = 1; a < NUM_ACTIONS; a++) {
            if (qTable[state][a] > qTable[state][bestAction]) {
                bestAction = a;
            }
        }
        return bestAction;
    }

    // ----------------------------------------------------------------
    //  Q-Table Update (Bellman Equation)
    // ----------------------------------------------------------------

    static void updateQTable(double[][] qTable, int state, int action,
                              double reward, int nextState) {
        double bestNextQ = Arrays.stream(qTable[nextState]).max().getAsDouble();
        double tdError = reward + GAMMA * bestNextQ - qTable[state][action];
        qTable[state][action] += ALPHA * tdError;
    }

    // ----------------------------------------------------------------
    //  Training Loop
    // ----------------------------------------------------------------

    static double[][] train(Random rng) {
        double[][] qTable = new double[NUM_STATES][NUM_ACTIONS]; // All zeros
        double epsilon = EPSILON_START;

        System.out.println("=== Starting Training ===");
        System.out.printf("%-10s %-12s %-12s %-10s%n",
                          "Episode", "Total Reward", "Steps Taken", "Epsilon");
        System.out.println("-".repeat(50));

        for (int episode = 1; episode <= MAX_EPISODES; episode++) {
            int state = START_STATE;
            double totalReward = 0.0;
            int stepCount = 0;
            boolean done = false;

            while (!done && stepCount < MAX_STEPS) {
                int action = chooseAction(qTable, state, epsilon, rng);
                Object[] result = step(state, action);

                int nextState  = (int)    result[0];
                double reward  = (double) result[1];
                boolean isDone = (boolean)result[2];

                updateQTable(qTable, state, action, reward, nextState);

                state = nextState;
                totalReward += reward;
                stepCount++;
                done = isDone;
            }

            // Decay epsilon after each episode
            epsilon = Math.max(EPSILON_MIN, epsilon * EPSILON_DECAY);

            // Log progress at key episodes
            if (episode == 1 || episode == 50 || episode == 100 ||
                episode == 250 || episode == 500 || episode == MAX_EPISODES) {
                System.out.printf("%-10d %-12.1f %-12d %-10.4f%n",
                                  episode, totalReward, stepCount, epsilon);
            }
        }
        return qTable;
    }

    // ----------------------------------------------------------------
    //  Policy Extraction and Visualization
    // ----------------------------------------------------------------

    static void printPolicy(double[][] qTable) {
        System.out.println("\n=== Learned Policy ===");
        System.out.println("(S=Start, T=Target, X=Forklift Zone)\n");
        for (int row = 0; row < GRID_ROWS; row++) {
            for (int col = 0; col < GRID_COLS; col++) {
                int state = toState(row, col);
                if (state == START_STATE)  { System.out.print(" S "); continue; }
                if (state == TARGET_STATE)  { System.out.print(" T "); continue; }
                if (isForkLiftZone(state)) { System.out.print(" X "); continue; }

                int bestAction = 0;
                for (int a = 1; a < NUM_ACTIONS; a++) {
                    if (qTable[state][a] > qTable[state][bestAction]) bestAction = a;
                }
                System.out.print(" " + ACTION_SYMBOL[bestAction] + " ");
            }
            System.out.println();
        }
    }

    static void printQTableSnapshot(double[][] qTable, int episode) {
        System.out.println("\n--- Q-Table Snapshot (Episode " + episode + ") ---");
        System.out.printf("%-8s %-8s %-8s %-8s %-8s%n",
                          "State", "UP", "DOWN", "LEFT", "RIGHT");
        System.out.println("-".repeat(48));
        for (int s = 0; s < NUM_STATES; s++) {
            System.out.printf("%-8d %-8.3f %-8.3f %-8.3f %-8.3f%n",
                s, qTable[s][0], qTable[s][1], qTable[s][2], qTable[s][3]);
        }
    }

    // ----------------------------------------------------------------
    //  Main
    // ----------------------------------------------------------------

    public static void main(String[] args) {
        Random rng = new Random(42); // Fixed seed for reproducibility
        double[][] qTable = train(rng);

        printPolicy(qTable);
        printQTableSnapshot(qTable, MAX_EPISODES);

        // Demonstrate the learned path
        System.out.println("\n=== Robot's Learned Path from Start to Target ===");
        int state = START_STATE;
        int steps = 0;
        while (state != TARGET_STATE && steps < 30) {
            int row = stateToRow(state);
            int col = stateToCol(state);
            int bestAction = 0;
            for (int a = 1; a < NUM_ACTIONS; a++) {
                if (qTable[state][a] > qTable[state][bestAction]) bestAction = a;
            }
            System.out.printf("  State %2d (%d,%d) → Action: %-6s%n",
                              state, row, col, ACTION_NAME[bestAction]);
            Object[] result = step(state, bestAction);
            state = (int) result[0];
            steps++;
        }
        System.out.printf("  State %2d (%d,%d) → TARGET REACHED in %d steps!%n",
                          state, stateToRow(state), stateToCol(state), steps);
    }
}

Sample Output

=== Starting Training ===
Episode    Total Reward Steps Taken  Epsilon   
--------------------------------------------------
1          -54.0        4            0.9950
50         -47.0        48           0.7783
100        -18.0        19           0.6050
250        -10.0        11           0.2873
500        8.0          9            0.0823
1000       91.0         9            0.0100

=== Learned Policy ===
(S=Start, T=Target, X=Forklift Zone)

 S  →  →  ↓  ↓ 
 →  X  →  ↓  ↓ 
 →  →  →  X  ↓ 
 →  X  →  →  ↓ 
 →  →  →  →  T 

Notice how Total Reward climbs from -54 to +91 across 1000 episodes, and Steps Taken drops from 48 to 9 — the robot isn't just reaching the goal more often, it's finding a near-optimal route.

Q-Table Convergence: How Values Change Over Episodes

State 0 (Start) — Best Action: RIGHT
─────────────────────────────────────────────
Episode   1:  UP=0.000  DOWN=0.000  LEFT=0.000  RIGHT=0.000
Episode  50:  UP=0.021  DOWN=0.134  LEFT=-0.120  RIGHT=0.289
Episode 500:  UP=0.412  DOWN=2.310  LEFT=-0.830  RIGHT=4.920
Episode 1000: UP=0.721  DOWN=4.110  LEFT=-1.204  RIGHT=18.43 ← RIGHT wins!

State 19 (one step left of target) — Best Action: RIGHT
─────────────────────────────────────────────
Episode   1:  All zeros
Episode  50:  RIGHT=0.045 (barely discovered)
Episode 500:  RIGHT=65.23 (strong signal — target is close!)
Episode 1000: RIGHT=94.87 (nearly +100 reward, discounted once)
  
Q-values near the target converge first — the reward signal radiates outward from the goal like ripples from a stone dropped in water. Early states (like Start) take many more episodes to accumulate accurate estimates because the +100 reward must be discounted across all the intervening steps before it reaches them.

Common Pitfalls and Debugging

1. Reward Shaping Mistakes

Problem: You give a large negative reward for every wall bump, but forget the agent is trapped against a wall — it keeps bumping and accumulates catastrophic negative rewards, never recovering.

Fix: Make wall bumps mildly negative (not catastrophic), and ensure the episode doesn't end on wall bumps. The robot should learn "walls are bad" naturally through the cumulative time penalty.

2. Learning Rate Woes

α too high (0.9) Values oscillate — the agent "forgets" good experiences because each new update overwrites them aggressively.

α too low (0.001) Thousands of episodes produce barely any change. The robot acts nearly randomly for too long.

α just right (0.05–0.2) Steady, smooth convergence. Values creep toward optimal over hundreds of episodes.

3. Discount Factor Edge Cases

  • γ = 0: The robot is completely greedy. It'll pick up a +5 reward right next to it and ignore the +100 target 8 steps away. Useless for navigation.
  • γ = 1: Every future reward is worth exactly as much as an immediate one. This can cause instability in environments with long episodes and no terminal state, as Q-values grow unboundedly.
  • γ = 0.95: Sweet spot. A reward 20 steps away is worth 0.95²⁰ ≈ 36% of its face value — far enough to plan ahead, but decayed enough to prefer shorter paths.

4. The Q-Table Never Converges

If your Q-values are still wildly different between episodes after 10,000 runs, check:

  • Epsilon isn't decaying: If ε stays high, the agent keeps exploring randomly and never exploits its learned knowledge.
  • Environment is non-deterministic without proper handling: If transitions are stochastic but you're using a low α, the noise drowns the signal.
  • Max steps too low: The agent can't reach the target before the episode terminates, so it never receives the positive terminal reward.
public class Main {
    static final int NUM_STATES = 10;
    static final int NUM_ACTIONS = 4;
    static double[][] qTable = new double[NUM_STATES][NUM_ACTIONS];
    static double[][] previousQTable = new double[NUM_STATES][NUM_ACTIONS];

    public static void main(String[] args) {
        int episode = 1;
        // Debugging tip: track TD error magnitude over episodes
        // If it plateaus above 1.0 after 1000 episodes, something is wrong
        double avgTDError = 0.0;
        for (int s = 0; s < NUM_STATES; s++) {
            for (int a = 0; a < NUM_ACTIONS; a++) {
                // Compare current estimates to previous episode's estimates
                avgTDError += Math.abs(qTable[s][a] - previousQTable[s][a]);
            }
        }
        System.out.printf("Episode %d | Avg Q-Change: %.4f%n", episode, avgTDError / (NUM_STATES * NUM_ACTIONS));
    }
}

Limitations and the Bridge to Deep Q-Networks

The State Space Explosion

Q-tables work beautifully when states are countable and small. Our warehouse had 25 states. But consider:

Problem States Q-Table Size
5×5 Warehouse 25 100 cells
20×20 Maze 400 1,600 cells
Atari Pong (raw pixels) ~10⁶ ~4M cells
Autonomous Driving ~10¹⁸ Impossible
Chess ~10⁴⁷ More than atoms in universe

For real-world problems, you can't store every state explicitly. The Q-table hits a wall — and unlike our robot, it can't recover from it.

The DQN Solution

Instead of a giant lookup table, a Deep Q-Network (DQN) uses a neural network as a function approximator. The key insight: similar states should have similar Q-values. A neural network can learn that pattern and generalize — estimating Q-values for states it has never seen before, based on states it has.

Q-Table vs. Deep Q-Network

Input: State s (grid position)
Q-Table: lookup row s
Output: Q-values for all 4 actions
vs.
Input: State s (raw pixels, sensor data…)
Neural Network (Conv layers, Dense layers)
Output: Q-values for all actions
DQN replaces the table with a neural network. The network generalizes — states it has never seen get reasonable Q-value estimates based on similar states it has seen. The Bellman update becomes a supervised training signal for the network weights instead of a direct table write.

When Q-Tables Are Still the Right Tool

Don't reach for a neural network when you don't need one:

  • Small state spaces Under ~10,000 states — pure Q-table wins on simplicity and interpretability
  • Tabular data problems Game solvers for small board games (Tic-Tac-Toe has 5,478 states)
  • Teaching/prototyping Always start with a Q-table to validate your reward structure before scaling up
  • Medium spaces Consider tile coding or state aggregation to compress the table
  • High-dimensional inputs Raw images, continuous sensor arrays — go straight to DQN

Recap: The 5 Steps

5 Steps to Master Q-Learning

1. Define Environment
2. Initialize Q-Table
3. ε-Greedy Policy
4. Episode Loop + Bellman Update
5. Extract Policy
Each step builds on the last. The magic happens in Step 4 — thousands of small Bellman corrections accumulate into a table of hard-won wisdom.
  1. Define your environment — states, actions, and reward signals carefully
  2. Initialize the Q-Table to zeros and track your hyperparameters (α, γ, ε)
  3. Implement epsilon-greedy — start with full exploration and decay toward exploitation
  4. Run episodes — each step applies the Bellman update, propagating reward signals backwards
  5. Extract the policy — read the argmax of each row; that's the learned strategy

The Q-Table is one of AI's most elegant ideas: a simple matrix that emerges from experience to encode optimal behavior. It's the foundation everything from game-playing AIs to robotics schedulers is built on — and now you can build it yourself.


Want to go further? In the next post, we'll extend this exact warehouse robot to use a neural network for the Q-function — turning our Q-Table agent into a full Deep Q-Network (DQN) that can handle environments 1,000x larger.

Comments