Train a DQN Agent for Vehicle Navigation

Build a DQN agent in PyTorch that learns to navigate a vehicle through obstacles.

Introduction

30 Second Summary

Self-driving cars learn to navigate by making thousands of decisions and improving from their mistakes. The same technique powers robots, game AI, and any system that needs to figure out the right action without being explicitly programmed.

In this project, you will build a Deep Q-Network agent that learns to drive a vehicle through obstacles to reach a goal. You'll create the environment from scratch, implement the algorithm in PyTorch, and watch your agent improve in real-time.

What You'll Build

A reinforcement learning system where a DQN agent learns autonomous navigation through a custom 2D environment built with Gymnasium and rendered with Pygame.

By the end of this project, you'll have:

  • A custom Gymnasium-compatible navigation environment with obstacles, a goal, and reward shaping.
  • A complete DQN agent with replay buffer, target network, and epsilon-greedy exploration built from scratch in PyTorch.
  • Training visualizations showing reward curves and exploration decay saved as a portfolio-ready image.
  • Secret Mission: Implement Double DQN to reduce Q-value overestimation and improve navigation stability.

Are there any prerequisites?

You need Python 3.10 or higher installed, VS Code set up, and an NVIDIA GPU with drivers installed. This project uses your GPU to accelerate training, so a CUDA-capable graphics card is required.

Before We Start

Training a Deep Q-Network agent requires a specific set of tools working together. You need a GPU-accelerated deep learning framework, a reinforcement learning environment API, and a real-time visualization library.

In this step, you will create a project folder, set up an isolated Python environment, and install PyTorch with GPU support alongside Gymnasium, Pygame, NumPy, and Matplotlib.

In this step, get ready to:
  • Create a project folder with a Python virtual environment.
  • Install PyTorch with CUDA and all supporting packages.
  • Verify your GPU is accessible for training.
Create a project folder and virtual environment

A virtual environment keeps your project's packages isolated from the rest of your system. This prevents version conflicts between projects.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir dqn-navigation
cd dqn-navigation
  • Open this folder in VS Code by running:
code .

What does this command do?

The code . command opens VS Code with your current folder as the workspace. The dot (.) means "this directory".

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Create a Python virtual environment by running:
python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

Seeing a script execution error?

PowerShell may block script execution by default. Run this command to allow activation scripts, then try the activate command again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Help me fix this execution policy error.

Install all dependencies

PyTorch needs to be installed with CUDA support to use your GPU for training. The other packages handle environment simulation, rendering, and plotting.

  • Install PyTorch with CUDA 12.6 support by running this command:
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126

What does this command do?

The --index-url flag points pip at a special package index that contains GPU-enabled builds of PyTorch. Without it, you would get the CPU-only version which trains much slower.

The cu126 part specifies CUDA 12.6 compatibility, which works with your RTX 4050 GPU.

This download is large

PyTorch with CUDA is approximately 2-3 GB. The download may take a few minutes depending on your internet connection.

  • Install the remaining packages by running:
pip install gymnasium==1.3.0 pygame==2.6.1 numpy matplotlib

What are these packages for?

  • gymnasium provides the standard API for building reinforcement learning environments.
  • pygame handles real-time rendering so you can watch the agent navigate.
  • numpy handles array math for observations and rewards.
  • matplotlib creates training performance plots.

Installation failed?

Make sure you see (venv) in your terminal prompt. If not, the virtual environment is not active and packages will install globally.

If you see a network error, check your internet connection and try the command again.

Help me troubleshoot my package installation.

Verify CUDA is accessible

Before writing any training code, confirm that PyTorch can see your GPU. This catches driver issues early before they become confusing errors mid-training.

  • Check that PyTorch detects your GPU by running:
python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"

You should see two lines printed: True on the first line and NVIDIA GeForce RTX 4050 on the second.

What is this checking?

torch.cuda.is_available() confirms PyTorch can communicate with your GPU through CUDA drivers.

torch.cuda.get_device_name(0) prints the name of your first GPU, confirming the exact hardware PyTorch will use for tensor operations during training.

Seeing False or an error?

If torch.cuda.is_available() returns False, your NVIDIA drivers may be outdated. Update them from the NVIDIA website, restart your computer, and try again.

If you see a ModuleNotFoundError, PyTorch did not install correctly. Make sure your virtual environment is active and re-run the PyTorch install command.

Help me get PyTorch to detect my GPU.

Your environment is ready. Next up, you will build a custom Gymnasium environment that simulates a vehicle navigating through obstacles.

Build the Custom Navigation Environment

Your tools are installed and your GPU is ready. Now you need a world for your agent to learn in.

In reinforcement learning, the environment defines everything the agent interacts with. It determines what the agent sees (observations), what it can do (actions), and how it gets scored (rewards). You will build this environment from scratch using the Gymnasium API so that your DQN agent can plug into it with a standard interface.

In this step, get ready to:
  • Create a custom Gymnasium environment with a vehicle, goal, and obstacles.
  • Define the reward structure that teaches the agent good navigation behavior.
  • Add Pygame rendering for live visualization during evaluation.
Create the environment file and define the class

The VehicleNavigationEnv class subclasses gymnasium.Env to follow the standard RL environment interface. The __init__ method sets up the arena dimensions, object sizes, and tells Gymnasium what the observation and action spaces look like.

  • In VS Code, create a new file called environment.py inside your dqn-navigation folder.
  • Add the following code to set up the class and its initialization:
import gymnasium as gym
from gymnasium import spaces
import numpy as np
import pygame


class VehicleNavigationEnv(gym.Env):
    metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30}

    def __init__(self, render_mode=None):
        super().__init__()
        self.render_mode = render_mode

        self.width = 800
        self.height = 600
        self.vehicle_size = 20
        self.obstacle_size = 30
        self.goal_size = 20
        self.step_size = 20
        self.n_obstacles = 5
        self.max_steps = 200

        self.action_space = spaces.Discrete(4)
        self.observation_space = spaces.Box(
            low=0.0, high=1.0, shape=(14,), dtype=np.float32
        )

        self.window = None
        self.clock = None

What does this code do?

The arena is 800x600 pixels. The vehicle, goal, and obstacles are all squares of different sizes.

  • spaces.Discrete(4) means the agent has 4 possible actions: up, down, left, and right.
  • spaces.Box(low=0.0, high=1.0, shape=(14,)) means the agent observes 14 float values between 0 and 1. These will encode the positions of the vehicle, goal, and all 5 obstacles.
  • max_steps = 200 caps each episode so the agent cannot wander forever.
  • Save environment.py.

Why 14 dimensions?

The observation vector encodes: 2 values for the vehicle position (x, y), 2 for the goal position, and 2 for each of the 5 obstacles. That gives 2 + 2 + (5 * 2) = 14 total values. All are normalized to the 0-1 range by dividing by the arena dimensions.

Implement the reset and step methods

Every Gymnasium environment needs two core methods. The reset() method initializes a fresh episode with randomized positions. The step() method takes an action, moves the vehicle, checks for collisions, and returns the new state with a reward signal.

  • In environment.py, add the reset() method below the __init__ method:
    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        self.steps = 0

        self.vehicle_x = self.np_random.integers(50, self.width // 3)
        self.vehicle_y = self.np_random.integers(50, self.height - 50)

        self.goal_x = self.np_random.integers(2 * self.width // 3, self.width - 50)
        self.goal_y = self.np_random.integers(50, self.height - 50)

        self.obstacles = []
        for _ in range(self.n_obstacles):
            ox = self.np_random.integers(self.width // 4, 3 * self.width // 4)
            oy = self.np_random.integers(50, self.height - 50)
            self.obstacles.append((ox, oy))

        observation = self._get_observation()
        return observation, {}

What does this code do?

Each episode starts fresh with randomized positions so the agent cannot memorize a single layout.

  • The vehicle always spawns in the left third of the arena.
  • The goal always spawns in the right third.
  • Obstacles are scattered in the middle region, forcing the agent to navigate around them.
  • The method returns a tuple of (observation, info) as required by the Gymnasium API.
  • Save environment.py.
  • Now add the step() method directly below reset():
    def step(self, action):
        self.steps += 1

        if action == 0:
            self.vehicle_y -= self.step_size
        elif action == 1:
            self.vehicle_y += self.step_size
        elif action == 2:
            self.vehicle_x -= self.step_size
        elif action == 3:
            self.vehicle_x += self.step_size

        self.vehicle_x = int(np.clip(self.vehicle_x, 0, self.width - self.vehicle_size))
        self.vehicle_y = int(np.clip(self.vehicle_y, 0, self.height - self.vehicle_size))

        terminated = False
        truncated = False
        reward = -0.1

        dist_to_goal = np.sqrt(
            (self.vehicle_x - self.goal_x) ** 2 + (self.vehicle_y - self.goal_y) ** 2
        )

        if dist_to_goal < (self.vehicle_size + self.goal_size) / 2:
            reward = 100.0
            terminated = True

        if not terminated:
            for ox, oy in self.obstacles:
                dist = np.sqrt(
                    (self.vehicle_x - ox) ** 2 + (self.vehicle_y - oy) ** 2
                )
                if dist < (self.vehicle_size + self.obstacle_size) / 2:
                    reward = -50.0
                    terminated = True
                    break

        if self.steps >= self.max_steps:
            truncated = True

        if not terminated and not truncated:
            max_dist = np.sqrt(self.width**2 + self.height**2)
            reward += (1.0 - dist_to_goal / max_dist) * 0.1

        observation = self._get_observation()
        return observation, reward, terminated, truncated, {}

How does the reward structure work?

The reward structure teaches the agent what "good" navigation looks like through four signals:

  • +100 for reaching the goal. This is the primary objective.
  • -50 for hitting an obstacle. This teaches avoidance behavior.
  • -0.1 per step. This small penalty encourages efficiency. Without it, the agent would wander forever.
  • A small proximity bonus when the agent gets closer to the goal. This guides exploration in the right direction during early training.

What is reward shaping?

The proximity bonus is an example of reward shaping. Raw sparse rewards (+100 or -50) only fire when the episode ends, which makes early learning nearly impossible. The proximity bonus gives the agent a continuous signal to follow, like breadcrumbs leading toward the goal.

  • Save environment.py.
  • Now add the _get_observation() helper method directly below step():
    def _get_observation(self):
        obs = np.array(
            [
                self.vehicle_x / self.width,
                self.vehicle_y / self.height,
                self.goal_x / self.width,
                self.goal_y / self.height,
            ]
            + [
                coord / dim
                for ox, oy in self.obstacles
                for coord, dim in [(ox, self.width), (oy, self.height)]
            ],
            dtype=np.float32,
        )
        return obs

What does this code do?

This method builds the 14-dimensional observation vector that the agent "sees" at each step.

  • Indices 0-1 hold the vehicle's normalized x and y position.
  • Indices 2-3 hold the goal's normalized position.
  • Indices 4-13 hold the normalized positions of all 5 obstacles (2 values each).
  • Every value is divided by the arena width or height, producing floats between 0 and 1. This normalization helps the neural network learn faster.
  • Save environment.py.

Seeing an indentation error?

All three methods (reset, step, _get_observation) must be indented one level inside the VehicleNavigationEnv class. Make sure each def line starts with exactly 4 spaces.

Help me fix indentation errors in my environment.py file.

Add Pygame rendering and test the environment

The render() method draws the arena using Pygame so you can watch your trained agent navigate in real-time later. This is what makes your project visually impressive for a portfolio.

  • Add the render() and close() methods below _get_observation():
    def render(self):
        if self.render_mode is None:
            return

        if self.window is None:
            pygame.init()
            if self.render_mode == "human":
                self.window = pygame.display.set_mode((self.width, self.height))
                pygame.display.set_caption("Autonomous Vehicle Navigation")
            self.clock = pygame.time.Clock()

        surface = pygame.Surface((self.width, self.height))
        surface.fill((30, 30, 30))

        for ox, oy in self.obstacles:
            pygame.draw.rect(
                surface,
                (220, 50, 50),
                (
                    ox - self.obstacle_size // 2,
                    oy - self.obstacle_size // 2,
                    self.obstacle_size,
                    self.obstacle_size,
                ),
            )

        pygame.draw.rect(
            surface,
            (255, 215, 0),
            (
                self.goal_x - self.goal_size // 2,
                self.goal_y - self.goal_size // 2,
                self.goal_size,
                self.goal_size,
            ),
        )

        pygame.draw.rect(
            surface,
            (50, 220, 50),
            (
                self.vehicle_x - self.vehicle_size // 2,
                self.vehicle_y - self.vehicle_size // 2,
                self.vehicle_size,
                self.vehicle_size,
            ),
        )

        if self.render_mode == "human":
            self.window.blit(surface, (0, 0))
            pygame.display.flip()
            self.clock.tick(self.metadata["render_fps"])

    def close(self):
        if self.window is not None:
            pygame.quit()
            self.window = None

What does this code do?

The render() method draws the arena at 30 FPS when render_mode="human" is set.

  • Red squares are obstacles the agent must avoid.
  • The gold square is the goal the agent tries to reach.
  • The green square is the vehicle (the agent).
  • The close() method shuts down Pygame cleanly when the environment is done.
  • Save environment.py.
  • Now add a test block at the very bottom of the file (outside the class, with no indentation) to verify everything works:
if __name__ == "__main__":
    env = VehicleNavigationEnv()
    obs, info = env.reset()
    print(f"Observation shape: {obs.shape}")
    print(f"Sample observation: {obs}")
    next_obs, reward, terminated, truncated, info = env.step(3)
    print(f"After step - reward: {reward:.3f}, terminated: {terminated}")
    env.close()

What does this code do?

This test block only runs when you execute environment.py directly. It creates the environment, resets it, takes one action (move right), and prints the observation shape and reward. This confirms your environment class is wired up correctly.

  • Save environment.py.
  • Run the environment test by running this command in your terminal:
python environment.py
  • You should see output like this (exact numbers will vary due to randomization):
Observation shape: (14,)
Sample observation: [0.14  0.55  0.87  0.42  ...]
After step - reward: -0.089, terminated: False

The key confirmation is that the observation shape is (14,). The exact numbers and reward value will differ each time because positions are randomized.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt. If not, activate it again with venv\Scripts\activate.

Help me debug a ModuleNotFoundError when running my environment.py file.

✔️ Awesome, I've got everything!

Great. Double check you have saved environment.py and that your terminal output shows Observation shape: (14,).

ⓧ I'd like to double check the full code

Here is the complete environment.py file. Compare yours against it:

import gymnasium as gym
from gymnasium import spaces
import numpy as np
import pygame


class VehicleNavigationEnv(gym.Env):
    metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30}

    def __init__(self, render_mode=None):
        super().__init__()
        self.render_mode = render_mode

        self.width = 800
        self.height = 600
        self.vehicle_size = 20
        self.obstacle_size = 30
        self.goal_size = 20
        self.step_size = 20
        self.n_obstacles = 5
        self.max_steps = 200

        self.action_space = spaces.Discrete(4)
        self.observation_space = spaces.Box(
            low=0.0, high=1.0, shape=(14,), dtype=np.float32
        )

        self.window = None
        self.clock = None

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        self.steps = 0

        self.vehicle_x = self.np_random.integers(50, self.width // 3)
        self.vehicle_y = self.np_random.integers(50, self.height - 50)

        self.goal_x = self.np_random.integers(2 * self.width // 3, self.width - 50)
        self.goal_y = self.np_random.integers(50, self.height - 50)

        self.obstacles = []
        for _ in range(self.n_obstacles):
            ox = self.np_random.integers(self.width // 4, 3 * self.width // 4)
            oy = self.np_random.integers(50, self.height - 50)
            self.obstacles.append((ox, oy))

        observation = self._get_observation()
        return observation, {}

    def step(self, action):
        self.steps += 1

        if action == 0:
            self.vehicle_y -= self.step_size
        elif action == 1:
            self.vehicle_y += self.step_size
        elif action == 2:
            self.vehicle_x -= self.step_size
        elif action == 3:
            self.vehicle_x += self.step_size

        self.vehicle_x = int(np.clip(self.vehicle_x, 0, self.width - self.vehicle_size))
        self.vehicle_y = int(np.clip(self.vehicle_y, 0, self.height - self.vehicle_size))

        terminated = False
        truncated = False
        reward = -0.1

        dist_to_goal = np.sqrt(
            (self.vehicle_x - self.goal_x) ** 2 + (self.vehicle_y - self.goal_y) ** 2
        )

        if dist_to_goal < (self.vehicle_size + self.goal_size) / 2:
            reward = 100.0
            terminated = True

        if not terminated:
            for ox, oy in self.obstacles:
                dist = np.sqrt(
                    (self.vehicle_x - ox) ** 2 + (self.vehicle_y - oy) ** 2
                )
                if dist < (self.vehicle_size + self.obstacle_size) / 2:
                    reward = -50.0
                    terminated = True
                    break

        if self.steps >= self.max_steps:
            truncated = True

        if not terminated and not truncated:
            max_dist = np.sqrt(self.width**2 + self.height**2)
            reward += (1.0 - dist_to_goal / max_dist) * 0.1

        observation = self._get_observation()
        return observation, reward, terminated, truncated, {}

    def _get_observation(self):
        obs = np.array(
            [
                self.vehicle_x / self.width,
                self.vehicle_y / self.height,
                self.goal_x / self.width,
                self.goal_y / self.height,
            ]
            + [
                coord / dim
                for ox, oy in self.obstacles
                for coord, dim in [(ox, self.width), (oy, self.height)]
            ],
            dtype=np.float32,
        )
        return obs

    def render(self):
        if self.render_mode is None:
            return

        if self.window is None:
            pygame.init()
            if self.render_mode == "human":
                self.window = pygame.display.set_mode((self.width, self.height))
                pygame.display.set_caption("Autonomous Vehicle Navigation")
            self.clock = pygame.time.Clock()

        surface = pygame.Surface((self.width, self.height))
        surface.fill((30, 30, 30))

        for ox, oy in self.obstacles:
            pygame.draw.rect(
                surface,
                (220, 50, 50),
                (
                    ox - self.obstacle_size // 2,
                    oy - self.obstacle_size // 2,
                    self.obstacle_size,
                    self.obstacle_size,
                ),
            )

        pygame.draw.rect(
            surface,
            (255, 215, 0),
            (
                self.goal_x - self.goal_size // 2,
                self.goal_y - self.goal_size // 2,
                self.goal_size,
                self.goal_size,
            ),
        )

        pygame.draw.rect(
            surface,
            (50, 220, 50),
            (
                self.vehicle_x - self.vehicle_size // 2,
                self.vehicle_y - self.vehicle_size // 2,
                self.vehicle_size,
                self.vehicle_size,
            ),
        )

        if self.render_mode == "human":
            self.window.blit(surface, (0, 0))
            pygame.display.flip()
            self.clock.tick(self.metadata["render_fps"])

    def close(self):
        if self.window is not None:
            pygame.quit()
            self.window = None


if __name__ == "__main__":
    env = VehicleNavigationEnv()
    obs, info = env.reset()
    print(f"Observation shape: {obs.shape}")
    print(f"Sample observation: {obs}")
    next_obs, reward, terminated, truncated, info = env.step(3)
    print(f"After step - reward: {reward:.3f}, terminated: {terminated}")
    env.close()

Why build a custom environment instead of using a pre-built one?

Pre-built Gymnasium environments (like CartPole or LunarLander) are great for learning algorithms, but they do not demonstrate your ability to design the problem itself. Building the environment from scratch shows you understand the RL interface at a deeper level.

In real autonomous systems work, the environment is rarely off-the-shelf. You will always need to define what the agent observes, how it acts, and what constitutes success. This is that skill in practice.

Your environment is ready. The agent now has a world to explore, a goal to chase, and obstacles to avoid. Next up, you will build the DQN agent that learns to navigate this environment using a neural network and experience replay.

Build the DQN Agent

Your environment can simulate a vehicle navigating through obstacles. Now you need a brain for that vehicle.

A Deep Q-Network (DQN) learns which action to take in any given state by approximating a Q-value function with a neural network. In this step, you will build the full DQN agent from scratch: the neural network that predicts action values, a replay buffer that stores past experiences for efficient learning, and the agent logic that ties everything together.

In this step, get ready to:
  • Build a 3-layer neural network that maps states to Q-values.
  • Implement a replay buffer for storing and sampling experiences.
  • Create the DQN agent with epsilon-greedy exploration and target network updates.
Create the neural network architecture

The Q-network takes a 14-dimensional state observation and outputs a Q-value for each of the 4 possible actions. The action with the highest Q-value is what the agent considers the best move.

  • In VS Code, create a new file called agent.py inside your dqn-navigation folder.
  • Add the imports and the DQN class by pasting this code:
import numpy as np
import random
from collections import deque

import torch
import torch.nn as nn
import torch.optim as optim


class DQN(nn.Module):
    def __init__(self, state_size, action_size):
        super(DQN, self).__init__()
        # Three fully connected layers: 14 -> 128 -> 64 -> 4
        self.fc1 = nn.Linear(state_size, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, action_size)

    def forward(self, x):
        # ReLU activations between layers, raw output for Q-values
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return self.fc3(x)

What does this code do?

The DQN class is a PyTorch neural network with three linear layers. It takes a 14-dimensional state vector and produces 4 output values, one per action.

ReLU activations between layers introduce non-linearity so the network can learn complex patterns. The final layer has no activation because Q-values can be any real number (positive or negative).

You won't see a change yet. The next chunk builds the replay buffer that feeds training data to this network.

Implement the replay buffer

Training a neural network on sequential experience is unstable because consecutive samples are highly correlated. A replay buffer stores past transitions and lets you sample random mini-batches, breaking the correlation and stabilizing learning.

  • In agent.py, add the ReplayBuffer class below the DQN class:

class ReplayBuffer:
    def __init__(self, capacity):
        # Fixed-size buffer using a deque that auto-discards old entries
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        # Store a single transition
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        # Randomly sample a batch for training
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = zip(*batch)
        return (
            np.array(states, dtype=np.float32),
            np.array(actions, dtype=np.int64),
            np.array(rewards, dtype=np.float32),
            np.array(next_states, dtype=np.float32),
            np.array(dones, dtype=np.float32),
        )

    def __len__(self):
        return len(self.buffer)

What does this code do?

The ReplayBuffer stores up to 10,000 transitions as (state, action, reward, next_state, done) tuples. Once full, the oldest entries are automatically discarded.

The sample() method pulls a random batch and converts the data into NumPy arrays ready for tensor conversion. This is what makes experience replay possible.

You won't see a change yet. The next chunk uses this buffer inside the full agent class.

Implement the DQN agent

The agent ties everything together. It manages two networks (a policy network for decision-making and a target network for stable training), selects actions using epsilon-greedy exploration, and updates its weights by learning from replay buffer samples.

  • In agent.py, add the DQNAgent class below the ReplayBuffer class:

class DQNAgent:
    def __init__(self, state_size, action_size, device):
        self.state_size = state_size
        self.action_size = action_size
        self.device = device

        # Policy network makes decisions, target network provides stable targets
        self.policy_net = DQN(state_size, action_size).to(device)
        self.target_net = DQN(state_size, action_size).to(device)
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()

        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=0.001)
        self.memory = ReplayBuffer(10000)

        # Hyperparameters
        self.gamma = 0.99
        self.epsilon = 1.0
        self.epsilon_min = 0.01
        self.epsilon_decay = 0.995
        self.batch_size = 64
        self.target_update_freq = 10

    def select_action(self, state):
        # Epsilon-greedy: random action or best Q-value action
        if random.random() < self.epsilon:
            return random.randrange(self.action_size)
        with torch.no_grad():
            state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
            q_values = self.policy_net(state_tensor)
            return q_values.argmax().item()

    def store_transition(self, state, action, reward, next_state, done):
        self.memory.push(state, action, reward, next_state, done)

    def learn(self):
        # Only learn when we have enough samples
        if len(self.memory) < self.batch_size:
            return

        states, actions, rewards, next_states, dones = self.memory.sample(
            self.batch_size
        )

        # Convert to tensors on the GPU
        states = torch.FloatTensor(states).to(self.device)
        actions = torch.LongTensor(actions).to(self.device)
        rewards = torch.FloatTensor(rewards).to(self.device)
        next_states = torch.FloatTensor(next_states).to(self.device)
        dones = torch.FloatTensor(dones).to(self.device)

        # Current Q-values for the actions we actually took
        current_q = self.policy_net(states).gather(1, actions.unsqueeze(1)).squeeze(1)

        # Target Q-values from the stable target network
        with torch.no_grad():
            next_q = self.target_net(next_states).max(1)[0]
            target_q = rewards + (1 - dones) * self.gamma * next_q

        # Update policy network via MSE loss
        loss = nn.MSELoss()(current_q, target_q)
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()

    def update_epsilon(self):
        self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)

    def update_target_network(self):
        self.target_net.load_state_dict(self.policy_net.state_dict())

What does this code do?

  • The __init__ creates two identical networks. The policy network is trained every step. The target network is a frozen copy that provides stable Q-value targets during learning.
  • The select_action() method uses epsilon-greedy exploration. Early in training, epsilon is high (1.0) so the agent explores randomly. Over time, epsilon decays to 0.01 and the agent mostly exploits what it has learned.
  • The learn() method samples a batch from the replay buffer, computes the difference between predicted Q-values and target Q-values, then updates the policy network to reduce that error.
  • The update_target_network() method copies the policy network weights into the target network every 10 episodes, keeping the training target stable between updates.

Why two networks instead of one?

If you use the same network to both predict Q-values and generate targets, every weight update shifts the target. The network ends up chasing a moving goal and training becomes unstable.

The target network provides a fixed reference point. By updating it only every 10 episodes, the agent has a stable target to learn towards between updates.

  • Add the test code at the bottom of agent.py to verify the agent works:

if __name__ == "__main__":
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    agent = DQNAgent(state_size=14, action_size=4, device=device)
    dummy_state = np.random.rand(14).astype(np.float32)
    action = agent.select_action(dummy_state)
    print(f"Selected action: {action} (device: {device})")

What does this code do?

This test block creates a DQNAgent with the same dimensions as your environment (14 inputs, 4 actions) and runs a single action selection on a random state. It confirms the network, replay buffer, and CUDA device are all wired up correctly.

  • Save agent.py.
  • Run the file to verify everything works by running this command:
python agent.py
  • You should see output like Selected action: 2 (device: cuda). The action number will vary, but device: cuda confirms your GPU is being used.

Seeing an error or device: cpu?

  • If you see device: cpu instead of device: cuda, your PyTorch installation may not have CUDA support. Re-run the PyTorch install command from Step 1 with the --index-url flag.
  • If you see an ImportError, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If you see a SyntaxError, check for mismatched parentheses in the sample() method. The return statement has multiple levels of nesting.

Help me debug my agent.py file

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py and confirmed the output shows your CUDA device.

ⓧ I'd like to double check the full code

Here is the complete agent.py file:

import numpy as np
import random
from collections import deque

import torch
import torch.nn as nn
import torch.optim as optim


class DQN(nn.Module):
    def __init__(self, state_size, action_size):
        super(DQN, self).__init__()
        self.fc1 = nn.Linear(state_size, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, action_size)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return self.fc3(x)


class ReplayBuffer:
    def __init__(self, capacity):
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = zip(*batch)
        return (
            np.array(states, dtype=np.float32),
            np.array(actions, dtype=np.int64),
            np.array(rewards, dtype=np.float32),
            np.array(next_states, dtype=np.float32),
            np.array(dones, dtype=np.float32),
        )

    def __len__(self):
        return len(self.buffer)


class DQNAgent:
    def __init__(self, state_size, action_size, device):
        self.state_size = state_size
        self.action_size = action_size
        self.device = device

        self.policy_net = DQN(state_size, action_size).to(device)
        self.target_net = DQN(state_size, action_size).to(device)
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()

        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=0.001)
        self.memory = ReplayBuffer(10000)

        self.gamma = 0.99
        self.epsilon = 1.0
        self.epsilon_min = 0.01
        self.epsilon_decay = 0.995
        self.batch_size = 64
        self.target_update_freq = 10

    def select_action(self, state):
        if random.random() < self.epsilon:
            return random.randrange(self.action_size)
        with torch.no_grad():
            state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
            q_values = self.policy_net(state_tensor)
            return q_values.argmax().item()

    def store_transition(self, state, action, reward, next_state, done):
        self.memory.push(state, action, reward, next_state, done)

    def learn(self):
        if len(self.memory) < self.batch_size:
            return

        states, actions, rewards, next_states, dones = self.memory.sample(
            self.batch_size
        )

        states = torch.FloatTensor(states).to(self.device)
        actions = torch.LongTensor(actions).to(self.device)
        rewards = torch.FloatTensor(rewards).to(self.device)
        next_states = torch.FloatTensor(next_states).to(self.device)
        dones = torch.FloatTensor(dones).to(self.device)

        current_q = self.policy_net(states).gather(1, actions.unsqueeze(1)).squeeze(1)

        with torch.no_grad():
            next_q = self.target_net(next_states).max(1)[0]
            target_q = rewards + (1 - dones) * self.gamma * next_q

        loss = nn.MSELoss()(current_q, target_q)
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()

    def update_epsilon(self):
        self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)

    def update_target_network(self):
        self.target_net.load_state_dict(self.policy_net.state_dict())


if __name__ == "__main__":
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    agent = DQNAgent(state_size=14, action_size=4, device=device)
    dummy_state = np.random.rand(14).astype(np.float32)
    action = agent.select_action(dummy_state)
    print(f"Selected action: {action} (device: {device})")

Your DQN agent is ready to learn. Next up, you will write the training loop that runs hundreds of episodes, letting the agent explore, collect experiences, and gradually improve its navigation policy.

Train the Agent and Visualize Results

You have a custom environment and a DQN agent ready to go. The missing piece is the training loop that connects them together.

In this step, you will write the script that runs 500 training episodes on your GPU, plots the learning curves, and then lets you watch the trained agent navigate in real-time through a Pygame window.

In this step, get ready to:
  • Write a training loop that runs 500 episodes and tracks scores.
  • Generate a training performance chart with a moving average and epsilon decay plot.
  • Evaluate the trained agent with live Pygame visualization.
Create the training loop

The training loop is where your agent actually learns. Each episode, the agent interacts with the environment, stores experiences, and updates its neural network weights based on what worked and what did not.

  • Create a new file called main.py in your dqn-navigation folder.
  • Add the imports and the train() function by copying this code into main.py:
import numpy as np
import matplotlib.pyplot as plt
import torch
import pygame

from environment import VehicleNavigationEnv
from agent import DQNAgent


def train(num_episodes=500):
    # Detect GPU and create environment
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Training on: {device}")

    env = VehicleNavigationEnv()
    state_size = env.observation_space.shape[0]
    action_size = env.action_space.n

    agent = DQNAgent(state_size, action_size, device)

    # Track training metrics
    scores = []
    epsilon_history = []
    successes = 0

    for episode in range(num_episodes):
        state, _ = env.reset()
        total_reward = 0
        done = False

        # Run one episode: act, store, learn
        while not done:
            action = agent.select_action(state)
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            agent.store_transition(state, action, reward, next_state, terminated)
            agent.learn()

            state = next_state
            total_reward += reward

        # Decay exploration rate after each episode
        agent.update_epsilon()

        # Sync target network periodically
        if episode % agent.target_update_freq == 0:
            agent.update_target_network()

        # Track successes (score above 50 means goal was likely reached)
        if total_reward > 50:
            successes += 1

        scores.append(total_reward)
        epsilon_history.append(agent.epsilon)

        # Print progress every 50 episodes
        if (episode + 1) % 50 == 0:
            avg_score = np.mean(scores[-50:])
            success_rate = successes / (episode + 1) * 100
            print(
                f"Episode {episode + 1}/{num_episodes} | "
                f"Avg Score: {avg_score:.1f} | "
                f"Epsilon: {agent.epsilon:.3f} | "
                f"Success Rate: {success_rate:.1f}%"
            )

    env.close()
    return agent, scores, epsilon_history

What does this code do?

The train() function orchestrates the entire learning process across 500 episodes.

  • Each episode resets the environment, then loops: the agent picks an action, the environment responds with a reward, and the agent stores that experience and learns from a batch of past experiences.
  • After each episode, epsilon decays (less random exploration over time) and the target network syncs every 10 episodes to keep learning stable.
  • Progress is printed every 50 episodes so you can watch the agent improve.
  • Save main.py.

Seeing an import error?

Make sure environment.py and agent.py are in the same folder as main.py. Python looks for imports relative to the file being run.

Help me debug my import error in main.py

Implement the results plotting function

Raw episode scores are noisy. A moving average smooths out the randomness so you can actually see whether the agent is improving over time.

  • In main.py, add the plot_results() function below the train() function:
def plot_results(scores, epsilon_history):
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))

    # Top plot: episode scores with a smoothed moving average
    ax1.plot(scores, alpha=0.3, color="blue", label="Score")
    window = 50
    if len(scores) >= window:
        moving_avg = np.convolve(scores, np.ones(window) / window, mode="valid")
        ax1.plot(
            range(window - 1, len(scores)),
            moving_avg,
            color="red",
            label=f"{window}-Episode Moving Average",
        )
    ax1.set_xlabel("Episode")
    ax1.set_ylabel("Score")
    ax1.set_title("Training Scores")
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    # Bottom plot: how exploration decays over training
    ax2.plot(epsilon_history, color="green")
    ax2.set_xlabel("Episode")
    ax2.set_ylabel("Epsilon")
    ax2.set_title("Exploration Rate Decay")
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("training_results.png", dpi=150)
    plt.show()
    print("Results saved to training_results.png")

What does this code do?

  • The top subplot shows raw scores (semi-transparent blue) overlaid with a 50-episode moving average (red line). The moving average reveals the true learning trend underneath the noise.
  • The bottom subplot tracks epsilon over time. You should see it decay from 1.0 (pure random) down toward 0.01 (almost pure exploitation).
  • The chart is saved as training_results.png at 150 dpi for portfolio-quality resolution.
  • Save main.py.
Implement the evaluation function and run everything

Training happens without any visualization to maximize speed. The evaluation function creates a separate environment with Pygame rendering so you can watch the trained agent in action.

  • In main.py, add the evaluate() function below plot_results():
def evaluate(agent, num_episodes=5):
    # Create environment with Pygame rendering enabled
    env = VehicleNavigationEnv(render_mode="human")

    for episode in range(num_episodes):
        state, _ = env.reset()
        done = False
        total_reward = 0

        while not done:
            action = agent.select_action(state)
            state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            total_reward += reward
            env.render()

            # Allow closing the Pygame window mid-evaluation
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    env.close()
                    return

        print(f"Evaluation Episode {episode + 1}: Score = {total_reward:.1f}")

    env.close()

What does this code do?

  • The evaluate() function creates the environment with render_mode="human", which opens a Pygame window showing the vehicle, obstacles, and goal.
  • The agent runs with epsilon set to 0 (pure exploitation), so it uses only what it learned. No randomness.
  • The Pygame event loop lets you close the window at any time without crashing the script.
  • Now add the __main__ block at the bottom of main.py to tie everything together:
if __name__ == "__main__":
    print("=" * 50)
    print("DQN Autonomous Vehicle Navigation")
    print("=" * 50)

    # Train the agent for 500 episodes
    agent, scores, epsilon_history = train(num_episodes=500)

    # Generate and save the training performance chart
    plot_results(scores, epsilon_history)

    # Evaluate with live visualization (epsilon = 0, no exploration)
    print("\nEvaluating trained agent...")
    agent.epsilon = 0.0
    evaluate(agent, num_episodes=5)

What does this code do?

  • The __main__ block runs the full pipeline in order: train, plot, evaluate.
  • Setting agent.epsilon = 0.0 before evaluation forces the agent to use its learned policy with zero random actions.
  • Save main.py.
  • Run the full training and evaluation pipeline by running this command in your terminal:
python main.py

What should I see?

Training takes under 2 minutes on your GPU. You will see progress updates every 50 episodes in the terminal, like this:

Episode 50/500 | Avg Score: -18.2 | Epsilon: 0.779 | Success Rate: 2.0%

The average score should trend upward and success rate should climb as epsilon decreases. After training, a Matplotlib chart window pops up, then the Pygame window opens showing 5 evaluation episodes where the green vehicle navigates toward the gold goal while avoiding red obstacles.

Close the chart to continue

The Matplotlib chart window blocks the script until you close it. Close the chart window and the Pygame evaluation will start immediately after.

  • Confirm that training_results.png was saved in your project folder by running:
ls
  • You should see training_results.png alongside main.py, agent.py, and environment.py in the output.

Training seems stuck or scores aren't improving?

  • Check that the terminal prints Training on: cuda at the start. If it says cpu, training will be slower and your venv may not have the CUDA version of PyTorch installed.
  • Reinforcement learning is stochastic. Some runs take longer to converge. If scores are slowly increasing (even slightly) by episode 200, the agent is learning. Let it finish all 500 episodes.
  • If scores stay flat after 300 episodes, double-check that your environment.py reward structure matches the code from the previous step (especially the +100 goal reward and the proximity bonus).

Help me debug why my DQN agent isn't learning

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and confirmed training_results.png exists in your project folder.

ⓧ I'd like to double check the full code

import numpy as np
import matplotlib.pyplot as plt
import torch
import pygame

from environment import VehicleNavigationEnv
from agent import DQNAgent


def train(num_episodes=500):
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Training on: {device}")

    env = VehicleNavigationEnv()
    state_size = env.observation_space.shape[0]
    action_size = env.action_space.n

    agent = DQNAgent(state_size, action_size, device)

    scores = []
    epsilon_history = []
    successes = 0

    for episode in range(num_episodes):
        state, _ = env.reset()
        total_reward = 0
        done = False

        while not done:
            action = agent.select_action(state)
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            agent.store_transition(state, action, reward, next_state, terminated)
            agent.learn()

            state = next_state
            total_reward += reward

        agent.update_epsilon()

        if episode % agent.target_update_freq == 0:
            agent.update_target_network()

        if total_reward > 50:
            successes += 1

        scores.append(total_reward)
        epsilon_history.append(agent.epsilon)

        if (episode + 1) % 50 == 0:
            avg_score = np.mean(scores[-50:])
            success_rate = successes / (episode + 1) * 100
            print(
                f"Episode {episode + 1}/{num_episodes} | "
                f"Avg Score: {avg_score:.1f} | "
                f"Epsilon: {agent.epsilon:.3f} | "
                f"Success Rate: {success_rate:.1f}%"
            )

    env.close()
    return agent, scores, epsilon_history


def plot_results(scores, epsilon_history):
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))

    ax1.plot(scores, alpha=0.3, color="blue", label="Score")
    window = 50
    if len(scores) >= window:
        moving_avg = np.convolve(scores, np.ones(window) / window, mode="valid")
        ax1.plot(
            range(window - 1, len(scores)),
            moving_avg,
            color="red",
            label=f"{window}-Episode Moving Average",
        )
    ax1.set_xlabel("Episode")
    ax1.set_ylabel("Score")
    ax1.set_title("Training Scores")
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    ax2.plot(epsilon_history, color="green")
    ax2.set_xlabel("Episode")
    ax2.set_ylabel("Epsilon")
    ax2.set_title("Exploration Rate Decay")
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("training_results.png", dpi=150)
    plt.show()
    print("Results saved to training_results.png")


def evaluate(agent, num_episodes=5):
    env = VehicleNavigationEnv(render_mode="human")

    for episode in range(num_episodes):
        state, _ = env.reset()
        done = False
        total_reward = 0

        while not done:
            action = agent.select_action(state)
            state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            total_reward += reward
            env.render()

            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    env.close()
                    return

        print(f"Evaluation Episode {episode + 1}: Score = {total_reward:.1f}")

    env.close()


if __name__ == "__main__":
    print("=" * 50)
    print("DQN Autonomous Vehicle Navigation")
    print("=" * 50)

    agent, scores, epsilon_history = train(num_episodes=500)

    plot_results(scores, epsilon_history)

    print("\nEvaluating trained agent...")
    agent.epsilon = 0.0
    evaluate(agent, num_episodes=5)

You have a trained DQN agent that learned to navigate autonomously, complete with performance charts and live visualization. Next up, a Secret Mission that will make your agent even smarter.

Secret mission

Implement Double DQN

Your standard DQN agent sometimes overestimates Q-values, leading to unstable training in later episodes. Double DQN decouples action selection from value estimation to produce more reliable learning. Can you modify a single method to fix this fundamental bias?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future experimentation, or delete them to free up disk space.

Resources you used:

  • The dqn-navigation project folder (includes source files, virtual environment, and generated images).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with hyperparameters, reward shaping, or extending the agent further.

  • Your project folder stays on your Desktop with all files intact.
  • The virtual environment stays ready to reactivate whenever you return.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files remain on disk for later.

  • Deactivate the virtual environment by running:
deactivate
  • When you come back, reactivate it with venv\Scripts\activate from inside the dqn-navigation folder.

Delete - I don't want to use this again

Remove the entire project folder to reclaim disk space. This deletes all source code, the virtual environment, and generated images.

  • Deactivate the virtual environment first by running:
deactivate
  • Navigate to the Desktop and delete the project folder.

macOS

cd ~/Desktop
rm -rf dqn-navigation

Windows

cd ~\Desktop
rmdir /s /q dqn-navigation

Nice Work!

Nice Work!

Nice work! You just built a complete reinforcement learning system from scratch, training a DQN agent to autonomously navigate a vehicle through obstacles using your GPU.

You've learned how to:

  • Build a custom Gymnasium environment from scratch, modeling autonomous vehicle navigation with obstacles, a goal, and a shaped reward signal.
  • Implement a complete DQN agent in PyTorch with a neural network, replay buffer, epsilon-greedy exploration, and target network.
  • Train the agent using GPU acceleration over 500 episodes and produce portfolio-quality training visualizations showing the learning process.
  • Secret Mission: Implemented Double DQN to improve training stability by decoupling action selection from value estimation.

Ready to quiz yourself?