Explainable Causal Reinforcement Learning for wildfire evacuation logistics networks with inverse simulation verification A developer built an Explainable Causal Reinforcement Learning (XC-RL) framework for wildfire evacuation logistics, incorporating a Structural Causal Model to capture causal relationships between fire and road capacity. The project includes an inverse simulation verification method to validate the model's explanations, addressing the challenge of opaque black-box RL agents in safety-critical scenarios. It started with a simulation that refused to behave. I was tinkering with a simple evacuation model—a grid of roads, a spreading fire, and a few thousand virtual agents trying to escape. The reinforcement learning agent I'd trained kept sending evacuees toward the fire. Not because it was broken, but because my reward function had inadvertently rewarded "movement" over "survival." That failure was my gateway into a deeper question: how do we build AI systems that not only optimize complex logistics under extreme uncertainty but also explain why they make those decisions? And how do we verify those explanations when we can't run real-world experiments? This article chronicles my exploration of Explainable Causal Reinforcement Learning XC-RL applied to the nightmarish complexity of wildfire evacuation logistics—and the inverse simulation framework I built to verify what the models claim to have learned. Wildfire evacuation is a "wicked problem" for AI. It combines: Traditional reinforcement learning approaches—like DQN or PPO—can optimize routes, but they operate as opaque black boxes. When an agent reroutes a bus convoy through a smoke-filled canyon, we need to know why . Is it because the canyon is genuinely safer, or because the agent learned a spurious correlation with "shorter distance"? During my research of causal inference in RL, I discovered that the standard toolkit SHAP values, LIME, attention maps provides attributions , not explanations . They tell you which inputs mattered, but not how the system would behave if those inputs were causally intervened upon. For evacuation logistics, that distinction is existential. My journey led me to a three-tier architecture that I believe represents the future of safety-critical RL: Let me walk you through each component as I built it. The first challenge was representing the environment causally. I moved beyond simple state-action pairs to a Structural Causal Model SCM with a graph structure G = V, E , where nodes represent: x, y at time t Edges represent causal relationships. For instance, Fire x,y,t → RoadCapacity x,y,t+1 captures the causal link between fire proximity and road usability. python import networkx as nx import numpy as np from typing import Dict, Tuple class CausalEvacuationModel: def init self, road network: nx.Graph, fire sources: list : self.road network = road network self.causal graph = nx.DiGraph self. build causal structure fire sources def build causal structure self, fire sources : Add fire nodes for i, source in enumerate fire sources : self.causal graph.add node f"fire {i}", type="fire", pos=source, intensity=0.8 Add road nodes with causal edges from fire for u, v, data in self.road network.edges data=True : node id = f"road {u} {v}" self.causal graph.add node node id, type="road", capacity=data 'capacity' Causal edge: fire → road fire reduces capacity for i, source in enumerate fire sources : dist = np.linalg.norm np.array source - np.array data 'midpoint' if dist < 5000: 5km causal radius self.causal graph.add edge f"fire {i}", node id, effect="capacity reduction", strength=1.0 / 1.0 + dist/1000 def causal intervention self, node: str, value: float - Dict str, float : """Do-calculus style intervention: set node value and propagate.""" This is where we use Pearl's do-operator intervened = {} intervened node = value Propagate through causal graph simplified linear approximation for descendant in nx.descendants self.causal graph, node : Apply causal effect along edges parents = list self.causal graph.predecessors descendant effect = sum self.causal graph parent descendant 'strength' intervened.get parent, 0.0 for parent in parents if parent in intervened intervened descendant = effect return intervened Learning Insight : The critical realization here was that standard do-calculus requires knowing the full causal graph a priori . In real wildfire scenarios, we don't. I had to implement a causal discovery layer using PC algorithm variants on historical fire data, then refine with expert knowledge from fire engineers. The policy network doesn't see raw pixels or sensor readings. Instead, it receives the causal embeddings —representations of the current state that have been transformed through the causal graph. This forces the agent to reason about causes rather than correlations. python import torch import torch.nn as nn import torch.optim as optim from torch.distributions import Categorical class CausalPolicyNetwork nn.Module : def init self, causal dim: int, action dim: int, hidden dim: int = 128 : super . init self.causal encoder = nn.Sequential nn.Linear causal dim, hidden dim , nn.ReLU , nn.Linear hidden dim, hidden dim , nn.ReLU Separate heads for action and explanation self.action head = nn.Linear hidden dim, action dim self.explanation head = nn.Linear hidden dim, causal dim Predicts causal factors def forward self, causal state: torch.Tensor : encoded = self.causal encoder causal state action logits = self.action head encoded The explanation head predicts which causal factors drove this decision This is our "explainability" hook explanation weights = torch.softmax self.explanation head encoded , dim=-1 return action logits, explanation weights class CausalPPOAgent: def init self, causal model: CausalEvacuationModel : self.causal model = causal model self.policy = CausalPolicyNetwork causal dim=causal model.causal graph.number of nodes , action dim=4 north, south, east, west routing decisions self.optimizer = optim.Adam self.policy.parameters , lr=3e-4 def select action self, state: np.ndarray, explain: bool = False : Transform raw state through causal model causal state = self. to causal embedding state causal tensor = torch.FloatTensor causal state .unsqueeze 0 action logits, explanation weights = self.policy causal tensor if explain: Return both action and causal explanation action = Categorical logits=action logits .sample explanation = explanation weights.squeeze .detach .numpy Map explanation weights back to causal graph nodes node names = list self.causal model.causal graph.nodes explanation dict = {node names i : float explanation i for i in range len node names } return action.item , explanation dict return Categorical logits=action logits .sample .item Key Discovery : During my experimentation with this architecture, I found that forcing the policy to output explanation weights before the action as a kind of "reasoning scratchpad" dramatically improved both performance and interpretability. The agent couldn't "cheat" by making decisions without committing to causal reasons. This is the part I'm most proud of. The problem with RL explanations is that they're post-hoc —the agent acts, then we try to explain. But what if we could verify the explanation by running the policy backward? Inverse simulation works like this: python import simpy from typing import List, Dict, Any class InverseSimulationVerifier: def init self, causal model: CausalEvacuationModel, policy agent: CausalPPOAgent : self.causal model = causal model self.agent = policy agent def verify explanation self, original state: Dict, original action: int, explanation: Dict str, float , num counterfactuals: int = 100 - Dict str, float : """ Verify if the explanation is causally valid by running inverse simulations with counterfactual interventions. """ verification results = {} For each causal factor in the explanation for factor, weight in explanation.items : if weight < 0.1: Skip negligible factors continue Run counterfactual: intervene on this factor intervention value = original state factor 0.5 Halve it Run multiple counterfactual simulations action changes = 0 total sims = 0 for in range num counterfactuals : Create counterfactual state cf state = self.causal model.causal intervention factor, intervention value Get policy action under counterfactual cf action = self.agent.select action cf state if cf action = original action: action changes += 1 total sims += 1 Causal validity score: how often does changing this factor actually change the policy's decision? causal validity = action changes / total sims verification results factor = { 'causal validity': causal validity, 'explained weight': weight, 'verified': causal validity 0.7 Threshold } return verification results def generate verified explanation self, state: Dict, action: int - Dict str, Any : """Generate and verify explanations in one pass.""" Get initial explanation from policy , raw explanation = self.agent.select action state, explain=True Verify through inverse simulation verified = self.verify explanation state, action, raw explanation Filter to only verified causal factors verified factors = { factor: data for factor, data in verified.items if data 'verified' } Build human-readable explanation explanation text = self. format explanation verified factors return { 'action': action, 'verified causal factors': verified factors, 'explanation text': explanation text, 'confidence': np.mean d 'causal validity' for d in verified factors.values } During my investigation of inverse verification , I discovered something counterintuitive: the most confident explanations high weight in the policy's explanation head were often the least causally valid. The policy had learned to "explain" decisions using salient but non-causal features—like smoke color instead of fire proximity. The inverse simulation caught these spurious explanations and forced the policy to rely on true causal drivers. Let me show you a complete implementation for a simplified but realistic scenario: evacuating a small town with two evacuation routes, one mountain road and one coastal road. python python import numpy as np import matplotlib.pyplot as plt from dataclasses import dataclass from typing import List, Tuple @dataclass class EvacuationScenario: population: int = 5000 vehicles: int = 1500 road capacity: List int = None vehicles per minute fire speed: float = 0.5 km per minute wind direction: float = 45 degrees def post init self : if self.road capacity is None: self.road capacity = 30, 25 mountain, coastal class WildfireEvacuationSystem: def init self, scenario: EvacuationScenario : self.scenario = scenario self.causal model = self. build causal model self.agent = CausalPPOAgent self.causal model self.verifier = InverseSimulationVerifier self.causal model, self.agent def build causal model self - CausalEvacuationModel: Build road network graph road network = nx.Graph road network.add edge "town", "mountain pass", capacity=self.scenario.road capacity 0 , midpoint= 10, 20 road network.add edge "town", "coastal route", capacity=self.scenario.road capacity 1 , midpoint= 5, 15 road network.add edge "mountain pass", "safe zone", capacity=self.scenario.road capacity 0 , midpoint= 20, 25 road network.add edge "coastal route", "safe zone", capacity=self.scenario.road capacity 1 , midpoint= 10, 10 Fire source near coastal route fire sources = 8, 12 return CausalEvacuationModel road network, fire sources def run evacuation self, timesteps: int = 100 - Dict str, Any : """Run the full evacuation with explainable decisions.""" state = self. initialize state decisions log = for t in range timesteps : Agent makes decision with explanation action, explanation = self.agent.select action state, explain=True Verify the explanation through inverse simulation verified = self.verifier.verify explanation state, action, explanation Log decision with verification decisions log.append { 'timestep': t, 'action': action, 'explanation': verified, 'state': state.copy } Update state based on action and fire dynamics state = self. update state state, action, t Emergency stop if fire reaches town if state 'fire proximity' < 1.0: print f"EVACUATION COMPLETE at timestep {t}" break return { 'decisions': decisions log, 'total evacuated': state 'evacuated' , 'casualties': state 'population' - state 'evacuated' } def update state self, state: Dict, action: int, t: int - Dict: """Update evacuation state based on action and fire dynamics.""" Action 0: use mountain route safer but slower Action 1: use coastal route faster but fire risk Action 2: hold position Action 3: split evacuation new state = state.copy fire advance = self.scenario.fire speed np.cos np.radians self.scenario.wind direction Update fire position new state 'fire proximity' -= fire advance if action == 0: evacuated = min state 'road capacity' 0 , state 'population' new state 'evacuated' += evacuated new state 'population' -= evacuated elif action == 1: Coastal route is faster but riskier risk factor = 1.0 / state 'fire proximity' + 0.1 evacuated = min state 'road capacity' 1 risk factor, state 'population' new state 'evacuated' += evacuated new state 'population' -= evacuated Fire risk causes casualties casualties = int evacuated 0.1 risk factor new state 'evacuated' -= casualties new state 'casualties' += casualties elif action == 3: Split evacuation mountain cap = state 'road capacity' 0 0.6 coastal cap = state 'road capacity'