DEV Community

Rikin Patel
Rikin Patel

Posted on

Explainable Causal Reinforcement Learning for sustainable aquaculture monitoring systems with embodied agent feedback loops

Sustainable Aquaculture Monitoring

Explainable Causal Reinforcement Learning for sustainable aquaculture monitoring systems with embodied agent feedback loops

Introduction: A Lesson from Murky Waters

It started with a dying fish farm. Well, not literally dying—but the data told a story of stress, inefficiency, and impending ecological disaster. I was spending a summer researching sensor fusion techniques for environmental monitoring, and a local aquaculture facility had agreed to let me deploy a small experimental network of IoT sensors. The goal was simple: track water quality parameters like dissolved oxygen, pH, temperature, and ammonia levels to optimize feeding schedules and aeration systems.

What I discovered was far more profound than a better control loop. The traditional reinforcement learning (RL) approaches I had been experimenting with—Deep Q-Networks, Proximal Policy Optimization—were working, but they were opaque. When the system decided to reduce aeration at 3 AM, the farm manager couldn't understand why. And when I probed deeper, I realized the RL agent was learning spurious correlations. It had discovered that high pH coincided with low fish activity, so it was adjusting pH levels when the real causal driver was a malfunctioning filter system upstream.

This experience catalyzed my deep dive into Explainable Causal Reinforcement Learning (XCRL)—a paradigm that combines the sequential decision-making power of RL with the structural transparency of causal inference and the interpretability of explainable AI (XAI). The result is a system that not only optimizes but explains its reasoning in terms of cause and effect, not just correlation.

In this article, I'll walk you through my journey of building an embodied agent feedback loop for sustainable aquaculture monitoring that leverages XCRL. We'll explore the technical architecture, dive into implementation details, and discuss the challenges and future directions of this emerging field.

Technical Background: The Convergence of Three Paradigms

The Problem with Purely Predictive RL

While exploring reinforcement learning for environmental control, I discovered a fundamental limitation. Traditional RL agents optimize a reward function by learning a policy that maps states to actions. The policy is a black box—a neural network with millions of parameters that encodes correlations between sensor readings and optimal actions.

Consider a simplified aquaculture scenario:

import numpy as np
import gymnasium as gym
from stable_baselines3 import PPO

class AquacultureEnv(gym.Env):
    def __init__(self):
        super().__init__()
        self.observation_space = gym.spaces.Box(
            low=np.array([0, 0, 0, 0]),
            high=np.array([14, 100, 10, 100]),  # pH, temp, DO, ammonia
            dtype=np.float32
        )
        self.action_space = gym.spaces.Discrete(3)  # reduce, maintain, increase aeration

    def step(self, action):
        # ... sensor simulation and reward computation ...
        pass

    def reset(self):
        # ... initialize environment ...
        pass
Enter fullscreen mode Exit fullscreen mode

The agent learns to maximize cumulative reward (e.g., fish growth rate minus energy costs), but it cannot distinguish between correlation and causation. If the sensor data shows that low dissolved oxygen correlates with high mortality, the agent learns to increase aeration whenever DO drops—but it doesn't know that the cause of low DO might be excessive feeding, which also causes ammonia spikes.

Causal Inference: The Missing Piece

My exploration of Pearl's do-calculus and structural causal models (SCMs) revealed a solution. Instead of learning correlations, we can model the causal structure of the environment:

import networkx as nx
import matplotlib.pyplot as plt

# Define causal graph for aquaculture system
G = nx.DiGraph()
G.add_edges_from([
    ('Feeding', 'Ammonia'),
    ('Ammonia', 'pH'),
    ('pH', 'Dissolved_Oxygen'),
    ('Temperature', 'Dissolved_Oxygen'),
    ('Dissolved_Oxygen', 'Fish_Health'),
    ('Aeration', 'Dissolved_Oxygen'),
    ('Fish_Health', 'Reward')
])

# Visualize the causal structure
pos = nx.spring_layout(G, seed=42)
nx.draw(G, pos, with_labels=True, node_color='lightblue',
        node_size=2000, font_size=8, arrowsize=20)
plt.title("Causal Graph of Aquaculture System")
plt.show()
Enter fullscreen mode Exit fullscreen mode

This causal graph tells us that aeration directly affects dissolved oxygen, but feeding affects DO indirectly through ammonia and pH. An RL agent that understands this structure can make more robust decisions—especially when the environment changes.

Explainability: Bridging the Trust Gap

The final piece of the puzzle is explainability. Through studying the XAI literature, I realized that SHAP (SHapley Additive exPlanations) values and counterfactual explanations could be integrated directly into the RL training loop. This creates a feedback mechanism where the agent not only makes decisions but also generates human-understandable rationales.

Implementation Details: Building the XCRL Framework

Architecture Overview

Let me show you the core architecture I developed during my experimentation. The system consists of four layers:

  1. Perception Layer: Multi-modal sensor fusion (water quality, acoustic, optical)
  2. Causal Layer: Structural causal model that maintains the causal graph
  3. Policy Layer: RL agent that learns actions conditioned on causal features
  4. Explanation Layer: Generates counterfactual explanations for each decision

Here's the complete implementation:

import torch
import torch.nn as nn
import torch.optim as optim
from typing import Dict, List, Tuple
import causalnex
from causalnex.structure import DAGRegressor
from causalnex.discretiser import Discretiser
import shap

class CausalRLAgent(nn.Module):
    def __init__(self, state_dim: int, action_dim: int, causal_graph: Dict):
        super().__init__()
        self.state_dim = state_dim
        self.action_dim = action_dim

        # Causal encoder: projects raw state into causal feature space
        self.causal_encoder = nn.Sequential(
            nn.Linear(state_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 64),
            nn.ReLU()
        )

        # Policy network conditioned on causal features
        self.policy_net = nn.Sequential(
            nn.Linear(64 + len(causal_graph), 128),
            nn.ReLU(),
            nn.Linear(128, action_dim)
        )

        # Causal effect estimator
        self.causal_effect_estimator = nn.Linear(64, len(causal_graph))

        # Store causal graph for explanation generation
        self.causal_graph = causal_graph

    def forward(self, state: torch.Tensor, causal_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        # Encode state
        encoded = self.causal_encoder(state)

        # Estimate causal effects
        causal_effects = self.causal_effect_estimator(encoded)

        # Concatenate encoded state with causal features
        combined_input = torch.cat([encoded, causal_features], dim=1)

        # Generate action probabilities
        action_probs = torch.softmax(self.policy_net(combined_input), dim=-1)

        return action_probs, causal_effects

    def generate_explanation(self, state: torch.Tensor, action: int, causal_features: torch.Tensor) -> Dict:
        """Generate counterfactual explanation for a decision."""
        with torch.no_grad():
            action_probs, causal_effects = self.forward(state, causal_features)

            # Find which causal features most influenced the decision
            baseline_probs, baseline_effects = self.forward(
                state * 0.5,  # counterfactual: reduced state
                causal_features * 0.5
            )

            # Compute SHAP values for causal features
            shap_values = torch.abs(causal_effects - baseline_effects)

            # Identify top causal factors
            top_factors = torch.topk(shap_values, k=3)

            explanation = {
                "action_taken": action,
                "action_probability": action_probs[action].item(),
                "top_causal_factors": [
                    {
                        "factor": list(self.causal_graph.keys())[idx],
                        "importance": shap_values[idx].item(),
                        "counterfactual_effect": (causal_effects[idx] - baseline_effects[idx]).item()
                    }
                    for idx in top_factors.indices.tolist()
                ]
            }
            return explanation
Enter fullscreen mode Exit fullscreen mode

Embodied Agent Feedback Loop

The key innovation in my implementation was the embodied agent feedback loop. Unlike traditional RL where the agent passively receives rewards, my system uses a physical embodiment—a robotic surface vehicle (RSV) that can move through the aquaculture pond to collect targeted measurements.

class EmbodiedFeedbackLoop:
    def __init__(self, rl_agent: CausalRLAgent, robot_controller: RobotController):
        self.rl_agent = rl_agent
        self.robot = robot_controller
        self.causal_memory = CausalMemoryBuffer()

    def collect_targeted_data(self, hypothesis: str) -> np.ndarray:
        """Move robot to locations that can test causal hypotheses."""
        if hypothesis == "low_do_caused_by_feeding":
            # Navigate to feeding zone and measure DO gradient
            trajectory = self.robot.navigate_to("feeding_zone")
            measurements = self.robot.measure_gradient("dissolved_oxygen")
            return measurements
        elif hypothesis == "ammonia_spike_from_fish_waste":
            # Navigate to high-density fish area
            trajectory = self.robot.navigate_to("high_density_zone")
            measurements = self.robot.measure_vertical_profile("ammonia")
            return measurements
        # ... additional hypothesis-driven exploration ...

    def update_causal_model(self, observations: Dict):
        """Update the causal graph based on new evidence."""
        # Use causal discovery algorithm to refine structure
        dag = DAGRegressor()
        dag.fit(observations)

        # Merge discovered edges with existing knowledge
        self.causal_memory.update(dag)

        # Generate new hypotheses for exploration
        new_hypotheses = self.generate_hypotheses_from_uncertainty()
        return new_hypotheses
Enter fullscreen mode Exit fullscreen mode

Training with Causal Regularization

One of the most interesting findings from my experimentation was that adding a causal regularization term to the loss function dramatically improved both performance and explainability:

class CausalRegularizedLoss(nn.Module):
    def __init__(self, causal_graph: Dict, lambda_causal: float = 0.1):
        super().__init__()
        self.causal_graph = causal_graph
        self.lambda_causal = lambda_causal

    def forward(self, state, action, reward, next_state,
                action_probs, causal_effects, target_effects):
        # Standard RL loss (e.g., policy gradient)
        rl_loss = -torch.log(action_probs[action]) * reward

        # Causal consistency loss
        causal_loss = F.mse_loss(causal_effects, target_effects)

        # Interventional consistency: ensure actions have expected causal effects
        interventional_loss = self.compute_interventional_loss(
            state, action, next_state, causal_effects
        )

        total_loss = rl_loss + self.lambda_causal * causal_loss + interventional_loss
        return total_loss

    def compute_interventional_loss(self, state, action, next_state, causal_effects):
        """Ensure that actions cause expected changes in state variables."""
        # Estimate the expected causal effect of the action
        expected_effect = self.predict_causal_effect(state, action)

        # Measure actual effect on next state
        actual_effect = next_state - state

        # Penalize mismatch between predicted and actual causal effects
        return F.mse_loss(expected_effect, actual_effect)
Enter fullscreen mode Exit fullscreen mode

Real-World Applications: From Theory to Practice

Sustainable Aquaculture Management

Through my hands-on testing at the fish farm, I discovered that XCRL excels in several critical applications:

1. Dynamic Feeding Optimization

The system learned to distinguish between "feed more because fish are hungry" (causal) and "feed more because water temperature is high" (correlation). This distinction is crucial for sustainability:

class FeedingOptimizer:
    def __init__(self, causal_agent):
        self.agent = causal_agent

    def decide_feeding_amount(self, sensor_data: Dict) -> Tuple[float, str]:
        """Decide feeding amount with causal explanation."""
        state = self.preprocess_sensor_data(sensor_data)

        # Query agent for action with causal context
        causal_features = self.extract_causal_features(sensor_data)
        action_probs, causal_effects = self.agent(state, causal_features)

        # Determine if feeding is causally appropriate
        feeding_effect = causal_effects[self.causal_graph['feeding']]

        if feeding_effect > 0.3:
            # Feeding has strong positive causal effect on fish health
            amount = self.calculate_optimal_amount(action_probs)
            explanation = f"Increasing feeding by {amount}kg because analysis shows " \
                         f"causal effect of {feeding_effect:.2f} on fish growth, " \
                         f"not just correlation with temperature."
            return amount, explanation
        else:
            explanation = f"Maintaining current feeding levels. Causal analysis " \
                         f"shows feeding effect of {feeding_effect:.2f} is below " \
                         f"threshold, suggesting other factors (ammonia, DO) are " \
                         f"limiting growth."
            return 0, explanation
Enter fullscreen mode Exit fullscreen mode

2. Proactive Disease Detection

My exploration of causal methods revealed that XCRL can identify early warning signs of disease outbreaks by understanding the causal chain: stress → immune suppression → pathogen susceptibility.

def detect_disease_risk(self, sensor_data: Dict) -> Dict:
    """Identify disease risk using causal mechanisms."""
    # Extract causal chain for disease development
    stress_factors = self.extract_stress_causes(sensor_data)

    # Compute causal pathway activation
    pathway_strength = self.compute_causal_pathway(
        stress_factors,
        target="immune_suppression"
    )

    # Generate early warning with causal explanation
    if pathway_strength > 0.7:
        return {
            "risk_level": "HIGH",
            "causal_chain": [
                "temperature_stress → cortisol_increase",
                "cortisol_increase → immune_suppression",
                "immune_suppression → pathogen_susceptibility",
                "pathogen_susceptibility → disease_outbreak"
            ],
            "recommended_intervention": "Reduce feeding, increase water exchange",
            "confidence": pathway_strength
        }
    return {"risk_level": "LOW", "causal_chain": [], "confidence": pathway_strength}
Enter fullscreen mode Exit fullscreen mode

Integration with Quantum Computing

During my investigation of quantum-enhanced ML, I realized that quantum computing could accelerate the causal inference components of XCRL. While exploring this intersection, I discovered that quantum amplitude estimation can speed up Monte Carlo methods used for causal effect estimation:

from qiskit import QuantumCircuit, Aer, execute
import numpy as np

class QuantumCausalEstimator:
    def __init__(self, num_qubits=4):
        self.num_qubits = num_qubits
        self.backend = Aer.get_backend('qasm_simulator')

    def estimate_causal_effect(self, causal_model, intervention, observations):
        """Use quantum amplitude estimation to speed up causal effect estimation."""
        # Encode causal model into quantum state
        circuit = self.encode_causal_model(causal_model)

        # Apply intervention using quantum gates
        circuit = self.apply_intervention(circuit, intervention)

        # Use amplitude estimation to estimate causal effect
        circuit.measure_all()

        # Run multiple shots for statistical significance
        job = execute(circuit, self.backend, shots=1024)
        result = job.result()
        counts = result.get_counts()

        # Extract causal effect from measurement probabilities
        causal_effect = self.extract_effect_from_counts(counts)

        return causal_effect

    def encode_causal_model(self, causal_model):
        """Encode conditional probabilities into quantum amplitudes."""
        circuit = QuantumCircuit(self.num_qubits + 1)

        # Encode prior probabilities
        for node in causal_model.nodes:
            prob = causal_model.get_prior(node)
            circuit.ry(2 * np.arcsin(np.sqrt(prob)), node)

        # Encode conditional dependencies
        for edge in causal_model.edges:
            self.encode_conditional(circuit, edge)

        return circuit
Enter fullscreen mode Exit fullscreen mode

Challenges and Solutions

Challenge 1: Causal Discovery from Noisy Sensor Data

One of the biggest hurdles I encountered was that aquaculture sensor data is incredibly noisy. pH sensors drift, DO probes get fouled, and temperature readings fluctuate with diurnal cycles. My initial causal discovery algorithms produced unstable graphs.

Solution: I implemented a robust causal discovery approach that combines:

  • Temporal causal discovery using Granger causality with deep learning
  • Interventional data collection using the embodied agent to perform targeted perturbations
  • Bayesian causal networks to handle uncertainty

python
class RobustCausalDiscovery:
    def __init__(self):
        self.temporal_model = TemporalCausalModel()
        self.bayesian_network = BayesianNetwork()

    def discover_causal_structure(self, sensor_data: np.ndarray,
                                  interventions: List[Dict]):
        """Combined approach for robust causal discovery."""
        # 1. Temporal causal discovery
        temporal_graph = self.temporal_model.fit(sensor_data)

        # 2. Interventional causal discovery
        interventional_graph = self.bayesian_network.fit(
            sensor_data, interventions
        )

        # 3. Consensus graph using ensemble methods
        consensus_graph = self.consensus_merge(
            temporal_graph, interventional_graph
        )

        # 4. Validate with domain knowledge
        validated_graph = self.validate_with_domain_knowledge(cons
Enter fullscreen mode Exit fullscreen mode

Top comments (0)