Training a lunar lander AI with genetic algorithms and c++
Beyond manual control, the Python Lunar Lander project features an AI autopilot capable of learning to land the craft. This is achieved using a Neural Network (NN) trained not with typical backpropagation, but with a Genetic Algorithm (GA), leveraging a C++ backend for performance.
The neural network wrapper
The NeuralNetwork class in mod_nn_train.py acts as the bridge between the Python game logic and the C++ NN/GA implementation.
- Initialization/Loading: It can either
initialize a new GA population orloada previously saved state from an.hd5file. The network structure (input/hidden/output layers) is defined inmod_config.py(nn_config.hlayers). The input layer size (6) matches the state vector provided byGameLogic, and the output layer size (4) corresponds to the possible actions (Noop, Up, Left, Right). - Saving: Periodically saves the GA state (
savemethod) using the C++ object’sSerializefunction. - Getting Actions: The
get_actionmethod takes the current game state, passes it to the C++feedforwardfunction (using the best individual, member 0), and returns the index of the highest output neuron, representing the chosen action.
# Getting an action from the NN in mod_nn_train.py
def get_action(self, current_state: np.ndarray) -> int:
if self._net is None: # Check if loaded/initialized
# ... error handling ...
return 0
num_outputs = self._nnsize[-1] # Should be 4
inputs = np.array(current_state, dtype=np.float64)
outputs = np.zeros(num_outputs, dtype=np.float64)
try:
# Call C++ feedforward via pybind11 wrapper
# Use member_id 0 (assumed best) for prediction
self._net.feedforward(inputs, outputs, 0, False)
action = np.argmax(outputs) # Choose action with highest activation
return action
except Exception as e:
# ... error handling ...
return 0
State representation
The GameLogic class provides the crucial get_state method, which prepares the input vector for the NN. This vector needs to contain enough information for the NN to make informed decisions.
# State vector creation in GameLogic.get_state
def get_state(self) -> np.ndarray:
# Calculate distances to the target landing pad
dist_target_x = self.x - self.landing_pad_center_x
dist_target_y = self.y - self.landing_pad_y
# Normalize values for the NN
state = np.array([
self.vx / gcfg.max_vx, # Normalized Horizontal Velocity
self.vy / gcfg.max_vy, # Normalized Vertical Velocity
dist_target_x / cfg.width, # Normalized X distance to pad center
dist_target_y / cfg.height, # Normalized Y distance to pad top
self.fuel / lcfg.max_fuel, # Normalized Fuel
], dtype=float)
# Clip values to prevent extremes
state[0] = np.clip(state[0], -2, 2)
state[1] = np.clip(state[1], -2, 2)
state[2] = np.clip(state[2], -5, 5)
state[3] = np.clip(state[3], -5, 5)
return state
The state includes normalized velocities, normalized distances to the landing pad center and remaining fuel. Normalization helps the NN process these potentially wide-ranging values more effectively.
Genetic algorithm training
The train method implements the GA loop:
-
Evaluation: For each individual (NN) in the current population:
- A full game episode is simulated using
GameLogic. - At each step, the individual’s NN determines the action via
feedforward. - Step penalties are accumulated (e.g., based on distance from the pad).
- Once the episode ends (landed, crashed, or max steps reached), a final
_calculate_terminal_penaltyis computed. This heavily rewards successful landings (especially with fuel left) and penalizes crashes or running out of fuel. - The total fitness score (lower is better) is the sum of step penalties and the terminal penalty.
- A full game episode is simulated using
# Fitness calculation snippet from NeuralNetwork._calculate_terminal_penalty
def _calculate_terminal_penalty(self, game_sim: GameLogic, steps_taken: int) -> float:
terminal_penalty = 0.0
terminal_penalty += steps_taken * 0.1 # Penalty for time
if game_sim.landed_successfully:
terminal_penalty -= 10000.0 # Big reward
terminal_penalty -= game_sim.fuel * 2.0 # Bonus for fuel
elif game_sim.crashed:
terminal_penalty += 10000.0 # Big penalty
final_v_mag = np.sqrt(game_sim.vx**2 + game_sim.vy**2)
terminal_penalty += final_v_mag * 10.0 # Penalty for speed
elif game_sim.fuel <= 0 and not game_sim.landed:
terminal_penalty += 7000.0 # Penalty for running out of fuel
# ... small penalty for final velocity if not landed successfully ...
return terminal_penalty
-
Selection & Evolution:
- The fitness scores for the entire population are collected.
- Indices are sorted based on fitness (ascending).
- The C++ backend’s
UpdateWeightsAndBiasesmethod is called with the sorted indices. This likely performs selection (e.g., taking the topnn_config.top_individuals) and potentially crossover/mutation internally to generate the basis for the next generation. CreatePopulationis called (withelitismflag fromnn_config) to generate the new population, possibly preserving the best individual(s) directly.
-
Repeat: The process repeats for the configured number of
epochs(generations). Pad positions can be reset periodically (nn_config.nb_batches) or if fitness stagnates (nn_config.fit_min,nn_config.fit_streak) to encourage exploration.
C++/Python integration
The magic of calling C++ code from Python happens via pybind11. The ann_mlp_ga_py_interface.h file defines the Python module cpp_nn_py.
// Snippet from ann_mlp_ga_py_interface.h exposing ANN_MLP_GA<double>
PYBIND11_MODULE(cpp_nn_py, m)
{
py::class_<nn::ANN_MLP_GA<double>>(m, "ANN_MLP_GA_double")
// Expose constructor, Serialize, Deserialize, UpdateWeightsAndBiases, etc.
.def(py::init<std::vector<size_t>, int, size_t, size_t, size_t, bool>())
.def("Serialize", &nn::ANN_MLP_GA<double>::Serialize)
.def("Deserialize", &nn::ANN_MLP_GA<double>::Deserialize)
.def("UpdateWeightsAndBiases", &nn::ANN_MLP_GA<double>::UpdateWeightsAndBiases)
.def("CreatePopulation", &nn::ANN_MLP_GA<double>::CreatePopulation)
// Expose feedforward, handling numpy arrays conversion
.def("feedforward",
[](nn::ANN_MLP_GA<double>& self, py::array_t<double> inputs, py::array_t<double> outputs, size_t memberid, bool singleReturn) {
// ... C++ code to get pointers and call the actual feedforward ...
self.feedforward(pInputs, inputsSize, pOutputs, outputsSize, memberid, singleReturn);
},
py::arg("inputs"), py::arg("outputs"), py::arg("memberid"), py::arg("singleReturn"))
// ... other bindings ...
;
}
This binding code makes the C++ ANN_MLP_GA<double> class usable in Python as cpp_nn_py.ANN_MLP_GA_double. It handles the conversion between Python types (like NumPy arrays for feedforward) and C++ types. This allows the computationally intensive GA operations and network evaluations to run as compiled C++ code, while the game logic and overall control remain in Python.
Running the AI
To train the network:
python main.py --mode=nn_train
# To continue training from the last save:
python main.py --mode=nn_train --continue
# To continue from a specific generation (e.g., 50):
python main.py --mode=nn_train --continue --step=50
To watch the trained AI play:
python main.py --mode=nn_play
This mode loads the lunar_lander_last.hd5 file and uses the best network from the last saved generation to control the lander in the Pygame window.
By combining Pygame for the frontend, Python for high-level logic, and a C++ backend for the heavy lifting of NN training via a Genetic Algorithm, this project demonstrates a powerful approach to building game AI.
For more insights into this topic, you can find the details here.
The code for this implementation is available on Github here.