Predict Feeder Revenue Loss with ML

Build an ML model predicting Nigerian power feeder revenue loss.

Introduction

30 Second Summary

Every month, Nigerian electricity distribution companies lose roughly a third of their revenue before it ever reaches their accounts. The gap between energy delivered and money collected costs billions of naira per quarter.

In this project, you will build a feeder-level revenue loss prediction system for IBEDC, one of Nigeria's largest distribution companies. You'll generate a realistic dataset, train a machine learning model to identify which feeders leak the most money, and produce an interactive dashboard with recovery recommendations.

What You'll Build

A complete data science portfolio piece that demonstrates domain expertise in Nigerian electricity distribution, practical ML skills, and the ability to translate model outputs into business recommendations that IBEDC leadership could act on.

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

  • A documented Jupyter Notebook with problem framing using real NERC regulatory data, a realistically simulated feeder-level dataset, and exploratory analysis revealing loss patterns.
  • A trained Random Forest model with evaluated accuracy metrics and feature importance rankings identifying the key drivers of revenue loss.
  • An interactive Plotly dashboard and a one-page executive brief quantifying recoverable revenue by feeder, ready to present in an interview.
  • Secret Mission: Add SHAP explainability analysis to show exactly why the model flags certain feeders as high-loss.

Are there any prerequisites?

You should be familiar with pandas and basic ML concepts like train/test splits and regression. You also need Python installed via Anaconda with Jupyter Notebook ready to go.

Before We Start

Nigerian electricity distribution companies lose 33-40% of potential revenue every quarter. This leakage comes from a combination of unbilled energy (technical and commercial losses) and unpaid bills (collection losses).

In this step, you will set up the project environment and frame the business problem using real NERC regulatory data. By the end, your Jupyter Notebook will be ready for data generation and modelling.

In this step, get ready to:
  • Create the project folder and install Plotly.
  • Set up the Jupyter Notebook with all required imports.
  • Frame the business problem using real NERC data on IBEDC's revenue losses.
Create project structure and install Plotly

IBEDC (Ibadan Electricity Distribution Company) serves Oyo, Ogun, Osun, Kwara, and parts of Niger, Ekiti, and Kogi states. You will build a feeder-level revenue loss prediction system that identifies which specific feeders leak the most money.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ibedc-revenue-loss
cd ibedc-revenue-loss
  • Install Plotly (the interactive charting library) by running:
pip install plotly==6.8.0
  • Confirm the install worked by running:
ls ibedc-revenue-loss

You should see the ibedc-revenue-loss folder listed on your Desktop.

Why Plotly over matplotlib?

Plotly produces interactive HTML charts that open in your browser. Hovering over data points reveals values, and you can zoom and pan freely.

For a portfolio piece targeting IBEDC leadership, interactive dashboards are more compelling than static images.

  • Verify the Plotly version by running this in your terminal:
python -c "import plotly; print(plotly.__version__)"

You should see 6.8.0 printed.

Seeing an error when importing Plotly?

Make sure you ran pip install plotly==6.8.0 from inside your Anaconda environment. If you have multiple Python installations, try pip3 install plotly==6.8.0 instead.

Help me troubleshoot my Plotly installation in Anaconda.

Create and configure the Jupyter Notebook

Jupyter Notebook lets you run code in cells and add markdown explanations between them. This makes it ideal for a data science portfolio piece where you need to show your reasoning alongside your code.

  • Launch Jupyter Notebook from your project folder by running:
jupyter notebook

Your browser opens with the Jupyter file browser showing your empty project folder.

  • Click **New** in the top right corner.
  • Select **Python 3** from the dropdown.
  • Click the title at the top (it says Untitled) and rename it to ibedc_feeder_analysis.
  • In the first code cell, paste the following imports:
# =============================================================================
# IBEDC Feeder-Level Revenue Loss Prediction & Recovery Analysis
# =============================================================================
# This notebook analyses electricity revenue loss and builds a predictive model
# identifying which power feeders leak the most money.
#
# DISCLAIMER: The feeder-level dataset is realistically simulated to demonstrate
# methodology. Real feeder-level data is internal to IBEDC.
# =============================================================================

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
import os

warnings.filterwarnings('ignore')
pd.set_option('display.float_format', '{:.2f}'.format)
np.random.seed(42)

What does this code do?

  • pandas and numpy handle data manipulation and numerical operations.
  • plotly.express, plotly.graph_objects, and make_subplots create the interactive dashboard panels.
  • scikit-learn provides the Random Forest model, train/test splitting, and evaluation metrics.
  • np.random.seed(42) ensures your simulated data is reproducible every time you run the notebook.
  • Run the cell by pressing **Shift+Enter**.

The cell should execute with no errors and a new empty cell appears below it.

Seeing a ModuleNotFoundError?

If the error mentions plotly, your Jupyter kernel may be running a different Python than the one where you installed Plotly. Close Jupyter, run pip install plotly==6.8.0 again, and relaunch with jupyter notebook.

Help me fix a ModuleNotFoundError for plotly in my Jupyter Notebook.

Write the problem framing section

Every strong data science portfolio piece starts by framing the business problem with real numbers. This establishes domain credibility before you write a single line of analysis code.

  • Click the + button in the toolbar to add a new cell below your imports.
  • Change the cell type from Code to Markdown using the dropdown in the toolbar.
  • Paste the following markdown content into the cell:
# IBEDC Feeder-Level Revenue Loss Prediction & Recovery Analysis

## Problem Statement

Nigerian electricity distribution companies (DisCos) lose a significant portion of revenue to **ATC&C losses** (Aggregate Technical, Commercial & Collection losses). NERC Quarterly Reports (2024-2025) show:

- **Sector-wide ATC&C loss** ranged from 33.27% (2025/Q3) to 39.61% (2025/Q1)
- **2025/Q1 alone**: ATC&C translated to a cumulative revenue loss of ₦200.495 billion across all DisCos
- **IBEDC's MYTO-2024 target**: 20.92% ATC&C loss for 2025
- **IBEDC's 2024 average**: 32.73% (gap of 11.81 percentage points above target)
- **IBEDC's lowest-ever ATC&C**: 24.30% (October 2024)
- **IBEDC serves**: Oyo, Ogun, Osun, Kwara, and parts of Niger, Ekiti, and Kogi states

## Objective

Build a feeder-level predictive model that identifies which specific feeders leak the most revenue, and quantify the naira value of recovering those losses.

## Data Disclaimer

> **The feeder-level dataset in this analysis is realistically simulated to demonstrate methodology. Real feeder-level data is internal to IBEDC and not publicly available.**

Simulation parameters are calibrated to real NERC metrics: billing efficiency ~82%, collection efficiency ~75%, and ATC&C ranges matching published figures.

Why include real NERC numbers?

Citing exact figures from NERC quarterly reports (like the ₦200.495 billion Q1 2025 loss) shows an interviewer you understand the regulatory landscape. The disclaimer about simulated data is equally important. It demonstrates intellectual honesty about your data sources.

  • Run the markdown cell by pressing **Shift+Enter**.

The cell renders as formatted text with headings, bold text, and a blockquote for the disclaimer.

Markdown not rendering?

If you see raw markdown text instead of formatted output, check that the cell type is set to Markdown in the toolbar dropdown (not Code). Click the cell, change the type, then press **Shift+Enter** again.

Help me get my Jupyter markdown cell to render properly.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook (Cmd+S on macOS or Ctrl+S on Windows).

ⓧ I'd like to double check the full code

Your first code cell should contain exactly this:

# =============================================================================
# IBEDC Feeder-Level Revenue Loss Prediction & Recovery Analysis
# =============================================================================
# This notebook analyses electricity revenue loss and builds a predictive model
# identifying which power feeders leak the most money.
#
# DISCLAIMER: The feeder-level dataset is realistically simulated to demonstrate
# methodology. Real feeder-level data is internal to IBEDC.
# =============================================================================

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
import os

warnings.filterwarnings('ignore')
pd.set_option('display.float_format', '{:.2f}'.format)
np.random.seed(42)

Your second cell should be a Markdown cell containing the problem framing with NERC data, the objective, and the data disclaimer.

Your environment is ready and the business problem is framed. Next up, you will generate the simulated feeder-level dataset and validate it for logical integrity.

Set Up Your Environment

Before you can analyse IBEDC's revenue loss problem, you need a working project environment with all the right tools in place.

In this step, you will create a dedicated project folder, install the one library not included in standard Anaconda (Plotly for interactive charts), set up your Jupyter Notebook with all required imports, and write a problem framing section grounded in real NERC data.

In this step, get ready to:
  • Create the project folder and install Plotly.
  • Configure the Jupyter notebook with all dependencies and settings.
  • Write the business problem framing with real NERC regulatory data.
Create the project folder and install Plotly

Every data science project needs its own dedicated folder to keep datasets, notebooks, and outputs organised. You also need Plotly for the interactive dashboard you will build later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ibedc-revenue-loss
cd ibedc-revenue-loss
  • Install Plotly by running:
pip install plotly==6.8.0
  • Verify the install works by opening a Python terminal and running:
import plotly
print(plotly.__version__)  # Should print 6.8.0
  • Confirm the output prints 6.8.0.

Seeing a ModuleNotFoundError?

Make sure you are running pip from the same Python environment that Jupyter uses.

If you have multiple Python installations, try pip3 install plotly==6.8.0 or conda install plotly from your Anaconda prompt instead.

Help me fix my Plotly installation in Anaconda.

Create and configure the Jupyter notebook

Your notebook will hold the entire analysis pipeline from data generation through to recovery projections. Setting up all imports at the top keeps dependencies visible and prevents mid-analysis errors.

  • Launch Jupyter Notebook from your project folder by running:
jupyter notebook

Your browser opens the Jupyter dashboard showing the contents of ibedc-revenue-loss.

  • Click the New button in the top right corner.
  • Select Python 3 from the dropdown to create a new notebook.
  • Click the notebook title at the top (it will say Untitled) and rename it to ibedc_feeder_analysis.

Why name the notebook now?

Renaming immediately saves the file as ibedc_feeder_analysis.ipynb in your project folder. This means you can find it easily later and it will match the file references throughout the rest of this project.

  • In the first code cell, paste the following imports and configuration:
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
import os

# Suppress unnecessary warnings for cleaner output
warnings.filterwarnings('ignore')

# Format numbers to 2 decimal places in dataframes
pd.set_option('display.float_format', '{:.2f}'.format)

# Set random seed for reproducible dataset generation
np.random.seed(42)

What does this code do?

pandas and numpy handle data manipulation. plotly.express and plotly.graph_objects create the interactive charts. The scikit-learn imports bring in the model training and evaluation tools you will use in Step 4.

np.random.seed(42) ensures that every time you run the notebook, the simulated dataset generates identically. This makes your results reproducible.

  • Run the cell by pressing Shift+Enter.
  • Confirm the cell runs without errors. If successful, you will see a new empty cell appear below with no error messages.

Seeing an ImportError?

If the error mentions plotly, confirm that you installed Plotly into the same Python environment that Jupyter is using. Close Jupyter, run pip install plotly==6.8.0 again, and relaunch with jupyter notebook.

If the error mentions sklearn, your Anaconda installation may need updating. Run conda update scikit-learn from your Anaconda prompt.

Help me debug my import errors in Jupyter Notebook.

Write the problem framing section

The problem framing section establishes your domain credibility. It shows that you understand IBEDC's regulatory context and can ground your analysis in real NERC data.

  • Click the + button in the notebook toolbar to add a new cell below your imports cell.
  • Change the cell type from Code to Markdown using the dropdown in the toolbar.
  • Paste the following problem framing into the markdown cell:
# IBEDC Feeder-Level Revenue Loss Prediction & Recovery Analysis

## Problem Context (Real NERC Data)

Nigerian electricity distribution companies (DisCos) lose a significant portion of revenue 
through a combination of unbilled energy and unpaid bills. NERC measures this as **ATC&C loss** 
(Aggregate Technical, Commercial & Collection losses).

**Key metrics from NERC Quarterly Reports (2024-2025):**

- Sector-wide ATC&C loss ranged from **33.27%** (2025/Q3) to **39.61%** (2025/Q1)
- In 2025/Q1 alone, ATC&C translated to a cumulative revenue loss of **₦200.495 billion** across all DisCos
- IBEDC's MYTO-2024 target is **20.92%** ATC&C loss for 2025
- IBEDC's 2024 average was **32.73%** — a gap of 11.81 percentage points to target
- IBEDC's lowest-ever ATC&C loss was **24.30%** (October 2024)
- IBEDC serves Oyo, Ogun, Osun, Kwara, and parts of Niger, Ekiti, and Kogi states

## Objective

Build a feeder-level predictive model that identifies which specific feeders leak the most 
revenue, quantifies the recoverable amount, and recommends priority interventions.

---

**DISCLAIMER:** The feeder-level dataset in this analysis is realistically simulated to 
demonstrate methodology. Real feeder-level data is internal to IBEDC and not publicly available.

Why include real NERC figures?

Grounding your analysis in official regulatory data demonstrates that you understand IBEDC's operating context. It also gives your simulated dataset realistic parameters to calibrate against.

The disclaimer is equally important. It shows intellectual honesty. Employers and interviewers appreciate when you clearly label simulated data as simulated rather than presenting it as real.

  • Run the markdown cell by pressing Shift+Enter.
  • Confirm the cell renders as formatted text with bold headings, bullet points, and the disclaimer at the bottom.

Markdown not rendering?

If you see raw markdown text with hash symbols and asterisks instead of formatted output, check that the cell type is set to Markdown in the toolbar dropdown (not Code). Change it to Markdown and press Shift+Enter again.

Help me fix my markdown cell rendering in Jupyter.

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook (Cmd+S on macOS or Ctrl+S on Windows).

ⓧ I'd like to double check the full code

Your first code cell should contain exactly this:

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
import os

warnings.filterwarnings('ignore')

pd.set_option('display.float_format', '{:.2f}'.format)
np.random.seed(42)

Your second cell should be a Markdown cell containing the NERC problem framing with the disclaimer about simulated data at the bottom.

Your environment is ready and your notebook is grounded in real regulatory context. Next up, you will generate a realistic feeder-level dataset calibrated to these NERC metrics.

Generate and Validate the Feeder Dataset

Your problem framing establishes the "why" of this project. Now you need data to work with.

Real feeder-level data is internal to IBEDC, so you will build a simulated dataset calibrated to actual NERC metrics. The simulation encodes realistic correlations: Band A feeders get higher metering rates, urban hubs get more customers, and low metering rates drag down billing efficiency.

In this step, get ready to:
  • Generate a 600-row feeder dataset across 6 IBEDC operational hubs.
  • Validate the dataset for logical integrity with zero violations.
  • Display summary statistics confirming the data is ready for analysis.
Generate the simulated feeder-level dataset

IBEDC operates across 6 real business hubs: Ibadan, Abeokuta, Osogbo, Ilorin, Ogbomoso, and Ijebu-Ode. Each hub has different characteristics. Urban hubs like Ibadan serve more customers and have better metering, while rural hubs tend toward Band D/E feeders with lower metering rates.

  • In your Jupyter notebook, create a new code cell below the problem framing markdown.
  • Define the simulation configuration by pasting this code into the cell:
# =============================================================================
# SECTION 2: DATA GENERATION
# =============================================================================

# Define IBEDC operational hubs
hubs = {
    'Ibadan': {'feeders': 12, 'urban': True, 'base_customers': 10000},
    'Abeokuta': {'feeders': 10, 'urban': True, 'base_customers': 8000},
    'Osogbo': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ilorin': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ogbomoso': {'feeders': 6, 'urban': False, 'base_customers': 4000},
    'Ijebu-Ode': {'feeders': 6, 'urban': False, 'base_customers': 5000},
}

# Service band distribution (A = best supply, E = worst)
bands = ['A', 'B', 'C', 'D', 'E']
band_weights = {
    'Ibadan': [0.15, 0.25, 0.30, 0.20, 0.10],
    'Abeokuta': [0.10, 0.20, 0.30, 0.25, 0.15],
    'Osogbo': [0.05, 0.15, 0.30, 0.30, 0.20],
    'Ilorin': [0.05, 0.15, 0.25, 0.35, 0.20],
    'Ogbomoso': [0.03, 0.10, 0.25, 0.35, 0.27],
    'Ijebu-Ode': [0.08, 0.18, 0.30, 0.28, 0.16],
}

# Metering rate ranges by band (Band A = well metered, Band E = poorly metered)
metering_ranges = {
    'A': (0.75, 0.95),
    'B': (0.60, 0.85),
    'C': (0.45, 0.70),
    'D': (0.35, 0.55),
    'E': (0.25, 0.45),
}

# Tariff rate (N/kWh) - approximate weighted average
tariff_rate = 68.0  # Naira per kWh (approximate for non-MD customers)

What does this configuration do?

The hubs dictionary defines 50 total feeders (12 + 10 + 8 + 8 + 6 + 6) spread across IBEDC's real operational centres. Urban hubs get more customers and feeders.

The band_weights encode a realistic pattern: urban hubs like Ibadan have more Band A/B feeders (better supply), while rural hubs like Ogbomoso skew toward Band D/E. The metering_ranges ensure Band A feeders are well-metered (75-95%) while Band E feeders are poorly metered (25-45%).

  • Below the configuration code in the same cell, add the generation loop that creates 12 months of data for each feeder:
months = list(range(1, 13))
records = []
feeder_id = 0

for hub_name, hub_config in hubs.items():
    for f in range(hub_config['feeders']):
        feeder_id += 1
        feeder_name = f"{hub_name}_F{f+1:02d}"

        # Assign service band based on hub distribution
        band = np.random.choice(bands, p=band_weights[hub_name])

        # Base characteristics for this feeder
        base_customers = hub_config['base_customers'] + np.random.randint(-2000, 3000)
        base_customers = max(500, base_customers)

        # Metering rate based on band
        meter_low, meter_high = metering_ranges[band]
        metering_rate = np.random.uniform(meter_low, meter_high)

        # Base energy received (MWh/month) - varies by hub size
        if hub_config['urban']:
            base_energy = np.random.uniform(2000, 5000)
        else:
            base_energy = np.random.uniform(500, 3000)

        for month in months:
            # Monthly variation (seasonal pattern)
            seasonal_factor = 1.0 + 0.1 * np.sin(2 * np.pi * month / 12)
            noise = np.random.normal(0, 0.05)

            energy_received = base_energy * seasonal_factor * (1 + noise)
            energy_received = max(100, energy_received)

            # Billing efficiency correlates with metering rate
            billing_eff = 0.5 + 0.4 * metering_rate + np.random.normal(0, 0.03)
            billing_eff = np.clip(billing_eff, 0.55, 0.98)

            energy_billed = energy_received * billing_eff

            # Revenue billed (Naira)
            revenue_billed = energy_billed * tariff_rate * 1000  # Convert MWh to kWh

            # Collection efficiency (also correlates with metering and band)
            band_collection_bonus = {'A': 0.15, 'B': 0.10, 'C': 0.05, 'D': 0.0, 'E': -0.05}
            collection_eff = 0.55 + 0.25 * metering_rate + band_collection_bonus[band]
            collection_eff += np.random.normal(0, 0.04)
            collection_eff = np.clip(collection_eff, 0.45, 0.95)

            revenue_collected = revenue_billed * collection_eff

            # Metered customers (slight monthly variation)
            metered_customers = int(base_customers * metering_rate * (1 + np.random.normal(0, 0.02)))
            metered_customers = max(1, min(metered_customers, base_customers))

            records.append({
                'feeder_id': feeder_name,
                'hub': hub_name,
                'service_band': band,
                'month': month,
                'total_customers': base_customers,
                'metered_customers': metered_customers,
                'energy_received_mwh': round(energy_received, 2),
                'energy_billed_mwh': round(energy_billed, 2),
                'revenue_billed_naira': round(revenue_billed, 2),
                'revenue_collected_naira': round(revenue_collected, 2),
            })

df_raw = pd.DataFrame(records)

# Save raw data
os.makedirs('data', exist_ok=True)
df_raw.to_csv('data/feeder_data_raw.csv', index=False)
print(f"\nDataset generated: {df_raw.shape[0]} rows x {df_raw.shape[1]} columns")
print(f"Feeders: {df_raw['feeder_id'].nunique()} | Hubs: {df_raw['hub'].nunique()}")

What does this generation loop do?

  • The outer loop iterates through each hub, and the inner loop creates each feeder within that hub. Every feeder gets a unique name like Ibadan_F01.
  • Billing efficiency is calculated as 0.5 + 0.4 * metering_rate plus noise. This encodes the real-world correlation: feeders that meter more customers can bill more accurately.
  • Collection efficiency gets a bonus for higher-band feeders (Band A: +0.15, Band E: -0.05), reflecting that better-served customers are more likely to pay.
  • The final DataFrame df_raw is saved to data/feeder_data_raw.csv for reuse in later steps.
  • Run the cell by pressing Shift+Enter.
  • You should see output confirming 600 rows x 10 columns, 50 feeders, and 6 hubs.

Seeing a NameError or ModuleNotFoundError?

  • Make sure you ran the imports cell (the very first code cell) before running this one. Jupyter cells must be executed in order.
  • If you see NameError: name 'np' is not defined, go back to the first cell and run it with Shift+Enter.
  • help me debug a NameError in my Jupyter notebook data generation cell
Validate the dataset for logical integrity

A dataset with logical violations (billed energy exceeding received energy, collected revenue exceeding billed revenue) would produce nonsense results downstream. This audit catches those issues before you build on the data.

  • Create a new code cell below the generation cell and add the validation audit code:
# =============================================================================
# SECTION 3: DATA VALIDATION
# =============================================================================

print("\n" + "=" * 70)
print("DATA VALIDATION AUDIT")
print("=" * 70)

# Check for missing values
missing = df_raw.isnull().sum().sum()
print(f"\n1. Missing values: {missing}")

# Logical violations
billing_violations = (df_raw['energy_billed_mwh'] > df_raw['energy_received_mwh']).sum()
collection_violations = (df_raw['revenue_collected_naira'] > df_raw['revenue_billed_naira']).sum()
metering_violations = (df_raw['metered_customers'] > df_raw['total_customers']).sum()
print(f"2. Billed > Received violations: {billing_violations}")
print(f"3. Collected > Billed violations: {collection_violations}")
print(f"4. Metered > Total customers violations: {metering_violations}")

# Validate categorical values
valid_bands = set(['A', 'B', 'C', 'D', 'E'])
valid_hubs = set(hubs.keys())
band_valid = set(df_raw['service_band'].unique()).issubset(valid_bands)
hub_valid = set(df_raw['hub'].unique()).issubset(valid_hubs)
print(f"5. All service bands valid: {band_valid}")
print(f"6. All hubs valid: {hub_valid}")
print(f"\nAUDIT RESULT: {'PASSED' if missing == 0 and billing_violations == 0 and collection_violations == 0 else 'FAILED'}")

What does this audit check?

  • Missing values: any NaN entries that would break calculations later.
  • Billing violations: cases where energy_billed exceeds energy_received. Physically impossible.
  • Collection violations: cases where revenue_collected exceeds revenue_billed. You cannot collect more than you billed.
  • Metering violations: cases where metered_customers exceeds total_customers. Impossible by definition.
  • Categorical checks: ensures only valid service bands (A-E) and hub names appear.
Display summary statistics
  • Below the audit code in the same cell, add the summary display code:
# Display summary
print("\n" + "-" * 40)
print("SUMMARY STATISTICS")
print("-" * 40)
print(df_raw.describe())
print("\nHub distribution:")
print(df_raw['hub'].value_counts())
print("\nService band distribution:")
print(df_raw['service_band'].value_counts())

What should I see?

The .describe() output shows the mean, min, max, and quartiles for every numeric column. The hub distribution should show Ibadan with 144 rows (12 feeders x 12 months), Abeokuta with 120, and so on. Service band distribution reveals how many feeder-months fall into each band.

  • Run the cell with Shift+Enter.
  • Confirm that the audit result prints PASSED, all violation counts are 0, and both categorical checks show True.
  • Save your notebook with Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a non-zero violation count?

  • Double-check that np.random.seed(42) is in your imports cell and was run before the generation cell. The seed ensures reproducible random numbers.
  • If you ran the generation cell multiple times, your data may have shifted. Restart the kernel (click Kernel then Restart & Run All) to reset.
  • help me fix data validation violations in my feeder generation code

✔️ Awesome, I've got everything!

Great. Make sure your notebook is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete data generation cell:

# =============================================================================
# SECTION 2: DATA GENERATION
# =============================================================================

# Define IBEDC operational hubs
hubs = {
    'Ibadan': {'feeders': 12, 'urban': True, 'base_customers': 10000},
    'Abeokuta': {'feeders': 10, 'urban': True, 'base_customers': 8000},
    'Osogbo': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ilorin': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ogbomoso': {'feeders': 6, 'urban': False, 'base_customers': 4000},
    'Ijebu-Ode': {'feeders': 6, 'urban': False, 'base_customers': 5000},
}

# Service band distribution (A = best supply, E = worst)
bands = ['A', 'B', 'C', 'D', 'E']
band_weights = {
    'Ibadan': [0.15, 0.25, 0.30, 0.20, 0.10],
    'Abeokuta': [0.10, 0.20, 0.30, 0.25, 0.15],
    'Osogbo': [0.05, 0.15, 0.30, 0.30, 0.20],
    'Ilorin': [0.05, 0.15, 0.25, 0.35, 0.20],
    'Ogbomoso': [0.03, 0.10, 0.25, 0.35, 0.27],
    'Ijebu-Ode': [0.08, 0.18, 0.30, 0.28, 0.16],
}

# Metering rate ranges by band (Band A = well metered, Band E = poorly metered)
metering_ranges = {
    'A': (0.75, 0.95),
    'B': (0.60, 0.85),
    'C': (0.45, 0.70),
    'D': (0.35, 0.55),
    'E': (0.25, 0.45),
}

# Tariff rate (N/kWh) - approximate weighted average
tariff_rate = 68.0  # Naira per kWh (approximate for non-MD customers)

months = list(range(1, 13))
records = []
feeder_id = 0

for hub_name, hub_config in hubs.items():
    for f in range(hub_config['feeders']):
        feeder_id += 1
        feeder_name = f"{hub_name}_F{f+1:02d}"

        # Assign service band based on hub distribution
        band = np.random.choice(bands, p=band_weights[hub_name])

        # Base characteristics for this feeder
        base_customers = hub_config['base_customers'] + np.random.randint(-2000, 3000)
        base_customers = max(500, base_customers)

        # Metering rate based on band
        meter_low, meter_high = metering_ranges[band]
        metering_rate = np.random.uniform(meter_low, meter_high)

        # Base energy received (MWh/month) - varies by hub size
        if hub_config['urban']:
            base_energy = np.random.uniform(2000, 5000)
        else:
            base_energy = np.random.uniform(500, 3000)

        for month in months:
            # Monthly variation (seasonal pattern)
            seasonal_factor = 1.0 + 0.1 * np.sin(2 * np.pi * month / 12)
            noise = np.random.normal(0, 0.05)

            energy_received = base_energy * seasonal_factor * (1 + noise)
            energy_received = max(100, energy_received)

            # Billing efficiency correlates with metering rate
            billing_eff = 0.5 + 0.4 * metering_rate + np.random.normal(0, 0.03)
            billing_eff = np.clip(billing_eff, 0.55, 0.98)

            energy_billed = energy_received * billing_eff

            # Revenue billed (Naira)
            revenue_billed = energy_billed * tariff_rate * 1000  # Convert MWh to kWh

            # Collection efficiency (also correlates with metering and band)
            band_collection_bonus = {'A': 0.15, 'B': 0.10, 'C': 0.05, 'D': 0.0, 'E': -0.05}
            collection_eff = 0.55 + 0.25 * metering_rate + band_collection_bonus[band]
            collection_eff += np.random.normal(0, 0.04)
            collection_eff = np.clip(collection_eff, 0.45, 0.95)

            revenue_collected = revenue_billed * collection_eff

            # Metered customers (slight monthly variation)
            metered_customers = int(base_customers * metering_rate * (1 + np.random.normal(0, 0.02)))
            metered_customers = max(1, min(metered_customers, base_customers))

            records.append({
                'feeder_id': feeder_name,
                'hub': hub_name,
                'service_band': band,
                'month': month,
                'total_customers': base_customers,
                'metered_customers': metered_customers,
                'energy_received_mwh': round(energy_received, 2),
                'energy_billed_mwh': round(energy_billed, 2),
                'revenue_billed_naira': round(revenue_billed, 2),
                'revenue_collected_naira': round(revenue_collected, 2),
            })

df_raw = pd.DataFrame(records)

# Save raw data
os.makedirs('data', exist_ok=True)
df_raw.to_csv('data/feeder_data_raw.csv', index=False)
print(f"\nDataset generated: {df_raw.shape[0]} rows x {df_raw.shape[1]} columns")
print(f"Feeders: {df_raw['feeder_id'].nunique()} | Hubs: {df_raw['hub'].nunique()}")

And here is the complete validation cell:

# =============================================================================
# SECTION 3: DATA VALIDATION
# =============================================================================

print("\n" + "=" * 70)
print("DATA VALIDATION AUDIT")
print("=" * 70)

# Check for missing values
missing = df_raw.isnull().sum().sum()
print(f"\n1. Missing values: {missing}")

# Logical violations
billing_violations = (df_raw['energy_billed_mwh'] > df_raw['energy_received_mwh']).sum()
collection_violations = (df_raw['revenue_collected_naira'] > df_raw['revenue_billed_naira']).sum()
metering_violations = (df_raw['metered_customers'] > df_raw['total_customers']).sum()
print(f"2. Billed > Received violations: {billing_violations}")
print(f"3. Collected > Billed violations: {collection_violations}")
print(f"4. Metered > Total customers violations: {metering_violations}")

# Validate categorical values
valid_bands = set(['A', 'B', 'C', 'D', 'E'])
valid_hubs = set(hubs.keys())
band_valid = set(df_raw['service_band'].unique()).issubset(valid_bands)
hub_valid = set(df_raw['hub'].unique()).issubset(valid_hubs)
print(f"5. All service bands valid: {band_valid}")
print(f"6. All hubs valid: {hub_valid}")
print(f"\nAUDIT RESULT: {'PASSED' if missing == 0 and billing_violations == 0 and collection_violations == 0 else 'FAILED'}")

# Display summary
print("\n" + "-" * 40)
print("SUMMARY STATISTICS")
print("-" * 40)
print(df_raw.describe())
print("\nHub distribution:")
print(df_raw['hub'].value_counts())
print("\nService band distribution:")
print(df_raw['service_band'].value_counts())

Your raw dataset is generated, validated, and saved. Next up, you will engineer the derived metrics that NERC and IBEDC actually use to measure performance, then explore the loss patterns hiding in this data.

Engineer Features and Explore Loss Patterns

You have a clean, validated dataset of 600 rows. Now you need to transform raw numbers into the KPIs that NERC and IBEDC actually report on.

In this step, you will compute derived metrics like ATC&C loss and billing efficiency, then visualize the patterns to uncover which feeders and hubs leak the most revenue.

In this step, get ready to:
  • Compute derived efficiency metrics and revenue loss columns from the raw data.
  • Create four interactive Plotly visualizations exploring loss patterns by band, hub, and metering rate.
  • Test the hypothesis that low metering rates drive revenue leakage.
Compute derived metrics

NERC reports ATC&C loss as a combination of billing losses and collection losses. You need to derive these metrics from your raw columns so the model and visualizations speak the same language as regulators.

  • Add a new code cell in your notebook below the validation section. Compute the derived metrics by pasting this code:
print("\n" + "=" * 70)
print("FEATURE ENGINEERING")
print("=" * 70)

df = df_raw.copy()

# Compute derived metrics
df['billing_efficiency'] = df['energy_billed_mwh'] / df['energy_received_mwh']
df['collection_efficiency'] = df['revenue_collected_naira'] / df['revenue_billed_naira']
df['atc_c_loss'] = 1 - (df['billing_efficiency'] * df['collection_efficiency'])
df['metering_rate'] = df['metered_customers'] / df['total_customers']

# Revenue lost (Naira) - unbilled energy loss + uncollected revenue loss
df['unbilled_energy_loss_naira'] = (
    (df['energy_received_mwh'] - df['energy_billed_mwh']) * tariff_rate * 1000
)
df['uncollected_revenue_loss_naira'] = (
    df['revenue_billed_naira'] - df['revenue_collected_naira']
)
df['total_revenue_lost_naira'] = (
    df['unbilled_energy_loss_naira'] + df['uncollected_revenue_loss_naira']
)

# Save engineered data
df.to_csv('data/feeder_data_engineered.csv', index=False)

print(f"\nNew columns added: billing_efficiency, collection_efficiency,")
print(f"  atc_c_loss, metering_rate, unbilled_energy_loss_naira,")
print(f"  uncollected_revenue_loss_naira, total_revenue_lost_naira")
print(f"\nATC&C Loss Statistics:")
print(f"  Mean: {df['atc_c_loss'].mean():.2%}")
print(f"  Median: {df['atc_c_loss'].median():.2%}")
print(f"  Min: {df['atc_c_loss'].min():.2%}")
print(f"  Max: {df['atc_c_loss'].max():.2%}")
print(f"\nTotal Annual Revenue Lost (simulated): N{df['total_revenue_lost_naira'].sum():,.0f}")

What does this code do?

Each derived column maps to a real KPI that NERC tracks:

  • billing_efficiency measures what percentage of received energy actually gets billed to customers. The 2024 sector average was 82.12%.
  • collection_efficiency measures what percentage of billed revenue gets collected. The 2024 sector average was 75.56%.
  • atc_c_loss combines both into the single metric NERC uses to evaluate each DisCo. It equals 1 minus the product of billing and collection efficiency.
  • total_revenue_lost_naira converts that loss into actual naira, making it actionable for business decisions.
  • Run the cell.
  • You should see the new columns listed, ATC&C loss statistics (mean, median, min, max), and the total annual revenue lost printed.

Seeing a KeyError?

Make sure you ran the data generation cell first. The df_raw DataFrame and tariff_rate variable must exist in memory from the previous step.

If you restarted your kernel, re-run all cells above this one first.

help me debug a KeyError in my feature engineering cell

Explore loss patterns with visualizations

Numbers alone do not tell a story. Interactive charts reveal where losses concentrate and what relationships drive them.

  • Add a new code cell. Create the first two charts (loss distribution by service band and by hub) by pasting this code:
print("\n" + "=" * 70)
print("EXPLORATORY ANALYSIS")
print("=" * 70)

# 1. ATC&C loss by service band
fig_band = px.box(
    df, x='service_band', y='atc_c_loss',
    title='ATC&C Loss Distribution by Service Band',
    labels={'atc_c_loss': 'ATC&C Loss (%)', 'service_band': 'Service Band'},
    color='service_band',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_band.update_layout(yaxis_tickformat='.0%', showlegend=False)
fig_band.show()

# 2. Average revenue lost by hub
hub_loss = df.groupby('hub')['total_revenue_lost_naira'].mean().sort_values(ascending=False)
fig_hub = px.bar(
    x=hub_loss.index, y=hub_loss.values,
    title='Average Monthly Revenue Lost by Hub',
    labels={'x': 'Hub', 'y': 'Average Revenue Lost (Naira)'},
    color=hub_loss.values,
    color_continuous_scale='Reds'
)
fig_hub.show()

What do these charts reveal?

The box plot shows ATC&C loss distribution across Nigeria's service band classification (A through E, where A receives the most reliable supply). Expect Band D and E feeders to show the highest and widest loss ranges.

The bar chart ranks hubs by average monthly revenue lost. Smaller or more rural hubs (like Ogbomoso) tend to show higher loss rates even though urban hubs handle more energy overall.

  • Run the cell. You should see two interactive Plotly charts appear below.
  • Hover over the box plot whiskers and bar segments to explore the data interactively.

Charts not rendering?

If you see a blank output area, make sure plotly was imported successfully in your first cell. Try running import plotly in a new cell to confirm.

In some Jupyter environments, you may need to run fig_band.show() separately to force rendering.

help me get Plotly charts to render in my Jupyter notebook

  • Add another code cell below. Create the scatter plot and correlation heatmap by pasting this code:
# 3. Metering rate vs ATC&C loss (core hypothesis)
fig_scatter = px.scatter(
    df.groupby('feeder_id').agg({
        'metering_rate': 'mean',
        'atc_c_loss': 'mean',
        'hub': 'first',
        'service_band': 'first'
    }).reset_index(),
    x='metering_rate', y='atc_c_loss',
    color='service_band',
    hover_data=['feeder_id', 'hub'],
    title='Metering Rate vs ATC&C Loss (Each Dot = One Feeder)',
    labels={'metering_rate': 'Metering Rate', 'atc_c_loss': 'ATC&C Loss'},
    trendline='ols',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_scatter.update_layout(
    xaxis_tickformat='.0%',
    yaxis_tickformat='.0%'
)
fig_scatter.show()

# 4. Correlation heatmap
numeric_cols = ['energy_received_mwh', 'billing_efficiency', 'collection_efficiency',
                'atc_c_loss', 'metering_rate', 'total_customers', 'total_revenue_lost_naira']
corr_matrix = df[numeric_cols].corr()
fig_corr = px.imshow(
    corr_matrix,
    text_auto='.2f',
    title='Feature Correlation Heatmap',
    color_continuous_scale='RdBu_r',
    aspect='auto'
)
fig_corr.show()

What do these charts reveal?

The scatter plot is your core hypothesis visualization. Each dot is one feeder, plotted by its average metering rate (x-axis) against its average ATC&C loss (y-axis). The OLS trendline should slope downward: higher metering correlates with lower loss.

The heatmap shows correlations between all numeric features. Look for strong negative correlation between metering_rate and atc_c_loss. This confirms statistically what the scatter plot shows visually.

  • Run the cell. You should see an interactive scatter plot with colored dots by service band and a trendline, plus a red-blue heatmap of correlations.

Seeing an error about statsmodels?

The trendline='ols' parameter requires the statsmodels package. Standard Anaconda includes it, but if you see an import error, install it by running pip install statsmodels in your terminal.

help me fix a statsmodels import error in Plotly trendline

Test the metering-rate hypothesis

The scatter plot suggests low metering rates predict high losses. A direct comparison between low-metered and high-metered feeders makes the case concrete enough for an executive brief.

  • Add a new code cell. Split the feeders into two groups and compare their loss rates by pasting this code:
# 5. Metering rate hypothesis test
print("\n--- METERING RATE HYPOTHESIS TEST ---")
low_metered = df[df['metering_rate'] < 0.60]
high_metered = df[df['metering_rate'] >= 0.60]
print(f"Low metering (<60%) feeders - Average ATC&C loss: {low_metered['atc_c_loss'].mean():.2%}")
print(f"High metering (>=60%) feeders - Average ATC&C loss: {high_metered['atc_c_loss'].mean():.2%}")
print(f"Difference: {(low_metered['atc_c_loss'].mean() - high_metered['atc_c_loss'].mean()):.2%}")
print(f"\nConclusion: Feeders with metering rates below 60% have significantly")
print(f"higher ATC&C loss, confirming that unmetered customers are the primary")
print(f"driver of revenue leakage.")

What does this test show?

This splits all 600 monthly records into two buckets: feeder-months where the metering rate is below 60%, and those at or above 60%. The average ATC&C loss for each group directly quantifies how much unmetered customers cost.

This finding mirrors NERC's own reports, which consistently identify unmetered customers as the biggest driver of revenue leakage across all DisCos.

  • Run the cell. You should see two percentages (one for low-metered, one for high-metered feeders) and a clear difference between them.
  • The conclusion statement prints automatically, confirming the hypothesis.

Both groups showing similar loss?

If the difference is unexpectedly small, make sure you ran the feature engineering cell first so that metering_rate and atc_c_loss columns exist in df. Running cells out of order is the most common cause.

help me debug my metering rate hypothesis test

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook and that data/feeder_data_engineered.csv exists in your project folder.

ⓧ I'd like to double check the full code

Here is all the code you added in this step. Compare your cells against it:

# --- Cell: Feature Engineering ---

print("\n" + "=" * 70)
print("FEATURE ENGINEERING")
print("=" * 70)

df = df_raw.copy()

# Compute derived metrics
df['billing_efficiency'] = df['energy_billed_mwh'] / df['energy_received_mwh']
df['collection_efficiency'] = df['revenue_collected_naira'] / df['revenue_billed_naira']
df['atc_c_loss'] = 1 - (df['billing_efficiency'] * df['collection_efficiency'])
df['metering_rate'] = df['metered_customers'] / df['total_customers']

# Revenue lost (Naira) - unbilled energy loss + uncollected revenue loss
df['unbilled_energy_loss_naira'] = (
    (df['energy_received_mwh'] - df['energy_billed_mwh']) * tariff_rate * 1000
)
df['uncollected_revenue_loss_naira'] = (
    df['revenue_billed_naira'] - df['revenue_collected_naira']
)
df['total_revenue_lost_naira'] = (
    df['unbilled_energy_loss_naira'] + df['uncollected_revenue_loss_naira']
)

# Save engineered data
df.to_csv('data/feeder_data_engineered.csv', index=False)

print(f"\nNew columns added: billing_efficiency, collection_efficiency,")
print(f"  atc_c_loss, metering_rate, unbilled_energy_loss_naira,")
print(f"  uncollected_revenue_loss_naira, total_revenue_lost_naira")
print(f"\nATC&C Loss Statistics:")
print(f"  Mean: {df['atc_c_loss'].mean():.2%}")
print(f"  Median: {df['atc_c_loss'].median():.2%}")
print(f"  Min: {df['atc_c_loss'].min():.2%}")
print(f"  Max: {df['atc_c_loss'].max():.2%}")
print(f"\nTotal Annual Revenue Lost (simulated): N{df['total_revenue_lost_naira'].sum():,.0f}")

# --- Cell: Exploratory Analysis (Charts 1 & 2) ---

print("\n" + "=" * 70)
print("EXPLORATORY ANALYSIS")
print("=" * 70)

# 1. ATC&C loss by service band
fig_band = px.box(
    df, x='service_band', y='atc_c_loss',
    title='ATC&C Loss Distribution by Service Band',
    labels={'atc_c_loss': 'ATC&C Loss (%)', 'service_band': 'Service Band'},
    color='service_band',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_band.update_layout(yaxis_tickformat='.0%', showlegend=False)
fig_band.show()

# 2. Average revenue lost by hub
hub_loss = df.groupby('hub')['total_revenue_lost_naira'].mean().sort_values(ascending=False)
fig_hub = px.bar(
    x=hub_loss.index, y=hub_loss.values,
    title='Average Monthly Revenue Lost by Hub',
    labels={'x': 'Hub', 'y': 'Average Revenue Lost (Naira)'},
    color=hub_loss.values,
    color_continuous_scale='Reds'
)
fig_hub.show()

# --- Cell: Exploratory Analysis (Charts 3 & 4) ---

# 3. Metering rate vs ATC&C loss (core hypothesis)
fig_scatter = px.scatter(
    df.groupby('feeder_id').agg({
        'metering_rate': 'mean',
        'atc_c_loss': 'mean',
        'hub': 'first',
        'service_band': 'first'
    }).reset_index(),
    x='metering_rate', y='atc_c_loss',
    color='service_band',
    hover_data=['feeder_id', 'hub'],
    title='Metering Rate vs ATC&C Loss (Each Dot = One Feeder)',
    labels={'metering_rate': 'Metering Rate', 'atc_c_loss': 'ATC&C Loss'},
    trendline='ols',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_scatter.update_layout(
    xaxis_tickformat='.0%',
    yaxis_tickformat='.0%'
)
fig_scatter.show()

# 4. Correlation heatmap
numeric_cols = ['energy_received_mwh', 'billing_efficiency', 'collection_efficiency',
                'atc_c_loss', 'metering_rate', 'total_customers', 'total_revenue_lost_naira']
corr_matrix = df[numeric_cols].corr()
fig_corr = px.imshow(
    corr_matrix,
    text_auto='.2f',
    title='Feature Correlation Heatmap',
    color_continuous_scale='RdBu_r',
    aspect='auto'
)
fig_corr.show()

# --- Cell: Hypothesis Test ---

# 5. Metering rate hypothesis test
print("\n--- METERING RATE HYPOTHESIS TEST ---")
low_metered = df[df['metering_rate'] < 0.60]
high_metered = df[df['metering_rate'] >= 0.60]
print(f"Low metering (<60%) feeders - Average ATC&C loss: {low_metered['atc_c_loss'].mean():.2%}")
print(f"High metering (>=60%) feeders - Average ATC&C loss: {high_metered['atc_c_loss'].mean():.2%}")
print(f"Difference: {(low_metered['atc_c_loss'].mean() - high_metered['atc_c_loss'].mean()):.2%}")
print(f"\nConclusion: Feeders with metering rates below 60% have significantly")
print(f"higher ATC&C loss, confirming that unmetered customers are the primary")
print(f"driver of revenue leakage.")

Your dataset now speaks the same language as NERC regulators, and your charts tell a clear story: low metering rates are bleeding revenue. Next up, you will train a model that predicts which feeders lose the most and ranks the factors that matter.

Build the Predictive Model

Your exploratory analysis confirmed that low metering rates predict high revenue loss. Now you need a model that can score every feeder and rank them by predicted ATC&C loss.

In this step, you'll train a Random Forest regression model on your feeder-level data. The model will learn which feeder characteristics drive ATC&C loss, then tell you exactly which factors matter most.

In this step, get ready to:
  • Aggregate monthly data to feeder-level averages (50 feeders).
  • Train and evaluate a Random Forest model on ATC&C loss prediction.
  • Extract feature importances to identify the top drivers of revenue loss.
Prepare features and train/test split

Your current DataFrame has 600 rows (50 feeders x 12 months). A predictive model for chronic high-loss feeders needs one row per feeder, not monthly fluctuations.

  • In your notebook, create a new code cell and aggregate your data to feeder-level averages by running this code:
# =============================================================================
# SECTION 6: PREDICTIVE MODEL
# =============================================================================

print("\n" + "=" * 70)
print("PREDICTIVE MODEL: Random Forest Regression")
print("=" * 70)

# Aggregate to feeder level (one row per feeder)
feeder_agg = df.groupby('feeder_id').agg({
    'hub': 'first',
    'service_band': 'first',
    'total_customers': 'first',
    'metering_rate': 'mean',
    'energy_received_mwh': 'mean',
    'atc_c_loss': 'mean',
    'total_revenue_lost_naira': 'sum',
}).reset_index()

print(f"\nFeeder-level dataset: {feeder_agg.shape[0]} feeders")

What does this code do?

The groupby('feeder_id').agg() call collapses 12 monthly rows per feeder into a single row of averages. For numeric metrics like metering_rate and atc_c_loss, it takes the mean across all months.

For total_revenue_lost_naira, it sums across months to get the annual total. This gives you one profile per feeder representing its chronic behaviour, not month-to-month noise.

  • Run the cell by pressing Shift+Enter.
  • You should see output confirming Feeder-level dataset: 50 feeders.

Seeing a NameError for df?

Make sure you've run all previous cells in order. The df variable is created in the feature engineering cell from the prior step.

Try selecting Kernel > Restart & Run All from the Jupyter menu to re-run everything from the top.

Help me debug my NameError in Jupyter Notebook.

Now that you have one row per feeder, you need to convert categorical columns (like service band and hub) into numbers the model can learn from.

  • Create a new code cell and prepare your features with one-hot encoding and a train/test split by running:
# Prepare features (one-hot encode categoricals)
feature_cols = ['metering_rate', 'total_customers', 'energy_received_mwh']
df_model = feeder_agg.copy()
df_model = pd.get_dummies(df_model, columns=['service_band', 'hub'], prefix=['band', 'hub'])

# Get all feature column names (numeric + one-hot encoded)
X_cols = feature_cols + [c for c in df_model.columns if c.startswith('band_') or c.startswith('hub_')]
X = df_model[X_cols]
y = df_model['atc_c_loss']

# Train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
print(f"Training set: {X_train.shape[0]} feeders | Test set: {X_test.shape[0]} feeders")

What does this code do?

One-hot encoding converts categorical columns into binary columns. For example, service_band with values A through E becomes five columns: band_A, band_B, etc. Each is 1 or 0.

The 80/20 split reserves 10 feeders for testing. The model never sees these during training, so its performance on them reflects real-world accuracy.

  • Run the cell.
  • You should see Training set: 40 feeders | Test set: 10 feeders.

Seeing fewer than 50 feeders?

If the training and test counts don't add up to 50, check that you ran the aggregation cell in the previous chunk first. The feeder_agg DataFrame should have exactly 50 rows.

Verify by running feeder_agg.shape in a new cell. It should output (50, 8).

Help me debug my feeder aggregation output.

Train Random Forest and evaluate

Random Forest works by training many decision trees on random subsets of your data, then averaging their predictions. This makes it robust to outliers and good at revealing which features drive the outcome.

  • Create a new code cell and train your model by running:
# Train Random Forest
rf_model = RandomForestRegressor(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)

# Evaluate
y_pred = rf_model.predict(X_test)
r2 = r2_score(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)

print(f"\n--- MODEL EVALUATION ---")
print(f"R2 Score: {r2:.4f} (model explains {r2*100:.1f}% of variance in ATC&C loss)")
print(f"MAE: {mae:.4f} (predictions are off by {mae*100:.2f} percentage points on average)")
print(f"\nInterpretation: The model {'performs well' if r2 > 0.7 else 'has moderate performance'}")
print(f"at predicting feeder-level ATC&C loss from operational characteristics.")

What do R2 and MAE mean here?

R2 (R-squared) measures how much of the variation in ATC&C loss the model explains. An R2 of 0.85 means the model captures 85% of the pattern. Anything above 0.7 is strong for this kind of problem.

MAE (Mean Absolute Error) tells you the average size of the prediction error in percentage points. If MAE is 0.03, the model's predictions are off by about 3 percentage points on average.

  • Run the cell.
  • You should see the R2 score and MAE printed, along with a plain-English interpretation of the model's performance.

Seeing very low R2 or high MAE?

With random_state=42 and the same seed from data generation, your results should be consistent. If R2 is below 0.5, double-check that np.random.seed(42) was set in your very first notebook cell.

If you restarted the kernel and re-ran cells, ensure you ran them in order from the top. Skipping the seed cell changes the generated data.

Help me understand why my Random Forest model has poor performance.

Analyze feature importance

A model that predicts loss is useful, but a model that tells you why certain feeders lose money is actionable. Feature importance rankings show IBEDC which levers to pull first.

  • Create a new code cell and extract feature importances by running:
# Feature importance
importances = pd.Series(rf_model.feature_importances_, index=X_cols).sort_values(ascending=True)
fig_imp = px.bar(
    x=importances.values[-10:],
    y=importances.index[-10:],
    orientation='h',
    title='Top 10 Feature Importances (Random Forest)',
    labels={'x': 'Importance', 'y': 'Feature'}
)
fig_imp.show()

print(f"\nTop 3 predictors of revenue loss:")
top3 = importances.sort_values(ascending=False).head(3)
for i, (feat, imp) in enumerate(top3.items(), 1):
    print(f"  {i}. {feat}: {imp:.4f}")

What does this code do?

Random Forest calculates how much each feature contributed to reducing prediction error across all 100 trees. A higher importance score means the model relied on that feature more heavily to make accurate predictions.

The horizontal bar chart shows the top 10 features ranked by importance. You should see metering_rate at the top, confirming what the exploratory analysis already suggested.

  • Run the cell.
  • You should see a horizontal bar chart with metering_rate as the longest bar, plus the top 3 predictors printed below it.

Chart not rendering?

Plotly charts require a browser-compatible renderer. In classic Jupyter Notebook, Plotly should render inline automatically. If you see a blank output, try adding fig_imp.show(renderer='notebook') instead.

If you're using JupyterLab, ensure the Plotly extension is available. The top 3 predictors still print as text regardless of chart rendering.

Help me get Plotly charts to render in my Jupyter Notebook.

The final piece is translating these numbers into a business recommendation. Add a markdown cell that interprets the results for IBEDC leadership.

  • Create a new Markdown cell (click + to add a cell, then change the dropdown from Code to Markdown).
  • Write your business interpretation using the following as a template:
### Business Interpretation

The model confirms that **metering rate** is the single strongest predictor of
revenue loss, followed by service band classification and customer count.

This suggests IBEDC's priority investment should be meters on Band D/E feeders
with the lowest metering rates. The data shows that fixing metering alone could
reduce ATC&C loss more than any other single intervention.

Why write this interpretation?

A model without a business interpretation is just numbers. This markdown cell demonstrates that you can translate statistical output into a specific recommendation that IBEDC leadership could act on.

In a real interview or proposal, this is the paragraph that gets read first. The code proves you can do it. The interpretation proves you understand what it means.

  • Run the markdown cell (Shift+Enter) to render it as formatted text.
  • You should see the interpretation rendered with bold text and clean formatting below your feature importance chart.

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook (Ctrl+S or Cmd+S).

ⓧ I'd like to double check the full code

Here is all the code you should have added in this step across your notebook cells:

# =============================================================================
# SECTION 6: PREDICTIVE MODEL
# =============================================================================

print("\n" + "=" * 70)
print("PREDICTIVE MODEL: Random Forest Regression")
print("=" * 70)

# Aggregate to feeder level (one row per feeder)
feeder_agg = df.groupby('feeder_id').agg({
    'hub': 'first',
    'service_band': 'first',
    'total_customers': 'first',
    'metering_rate': 'mean',
    'energy_received_mwh': 'mean',
    'atc_c_loss': 'mean',
    'total_revenue_lost_naira': 'sum',
}).reset_index()

print(f"\nFeeder-level dataset: {feeder_agg.shape[0]} feeders")

# Prepare features (one-hot encode categoricals)
feature_cols = ['metering_rate', 'total_customers', 'energy_received_mwh']
df_model = feeder_agg.copy()
df_model = pd.get_dummies(df_model, columns=['service_band', 'hub'], prefix=['band', 'hub'])

# Get all feature column names (numeric + one-hot encoded)
X_cols = feature_cols + [c for c in df_model.columns if c.startswith('band_') or c.startswith('hub_')]
X = df_model[X_cols]
y = df_model['atc_c_loss']

# Train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
print(f"Training set: {X_train.shape[0]} feeders | Test set: {X_test.shape[0]} feeders")

# Train Random Forest
rf_model = RandomForestRegressor(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)

# Evaluate
y_pred = rf_model.predict(X_test)
r2 = r2_score(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)

print(f"\n--- MODEL EVALUATION ---")
print(f"R2 Score: {r2:.4f} (model explains {r2*100:.1f}% of variance in ATC&C loss)")
print(f"MAE: {mae:.4f} (predictions are off by {mae*100:.2f} percentage points on average)")
print(f"\nInterpretation: The model {'performs well' if r2 > 0.7 else 'has moderate performance'}")
print(f"at predicting feeder-level ATC&C loss from operational characteristics.")

# Feature importance
importances = pd.Series(rf_model.feature_importances_, index=X_cols).sort_values(ascending=True)
fig_imp = px.bar(
    x=importances.values[-10:],
    y=importances.index[-10:],
    orientation='h',
    title='Top 10 Feature Importances (Random Forest)',
    labels={'x': 'Importance', 'y': 'Feature'}
)
fig_imp.show()

print(f"\nTop 3 predictors of revenue loss:")
top3 = importances.sort_values(ascending=False).head(3)
for i, (feat, imp) in enumerate(top3.items(), 1):
    print(f"  {i}. {feat}: {imp:.4f}")

You now have a trained model that explains which feeder characteristics drive ATC&C loss. Next up, you'll turn these predictions into naira figures and build a dashboard that shows IBEDC exactly where to invest.

Build the Recovery Projection and Dashboard

Your model can now predict which feeders leak the most revenue and why. The next question is: how much money could IBEDC actually recover, and how do you communicate that to leadership?

In this step, you will translate the model's predictions into naira figures that IBEDC can act on. You will build a recovery projection, an interactive dashboard, and a one-page executive brief written in NERC's regulatory language.

In this step, get ready to:
  • Calculate projected revenue recovery for the top 10 worst feeders.
  • Create a standalone Plotly dashboard with four analytical panels.
  • Write a one-page executive brief using NERC regulatory data.
Build the recovery projection

The model tells you which feeders are high-loss, but IBEDC leadership needs a number: how many naira could we recover if we improved operations on the worst feeders? A realistic operational target is a 10 percentage point improvement in collection efficiency.

  • In your notebook, add a new code cell below the feature importance chart and paste the following recovery projection code:
# =============================================================================
# SECTION 7: RECOVERY PROJECTION
# =============================================================================

print("\n" + "=" * 70)
print("RECOVERY PROJECTION")
print("=" * 70)

# Identify top 10 worst feeders by annual revenue lost
worst_feeders = feeder_agg.nlargest(10, 'total_revenue_lost_naira').copy()

# Calculate recovery if collection improves by 10 percentage points
improvement = 0.10  # 10 percentage point improvement in collection
worst_feeders['current_annual_loss_naira'] = worst_feeders['total_revenue_lost_naira']
worst_feeders['projected_recovery_naira'] = (
    worst_feeders['total_revenue_lost_naira'] * improvement / worst_feeders['atc_c_loss']
)
worst_feeders['priority_rank'] = range(1, 11)

print("\nTOP 10 FEEDERS BY ANNUAL REVENUE LOST:")
print("-" * 80)
print(f"{'Rank':<5} {'Feeder':<18} {'Hub':<12} {'ATC&C':<8} {'Annual Loss (N)':<18} {'Recovery (N)':<18}")
print("-" * 80)
for _, row in worst_feeders.iterrows():
    print(f"{row['priority_rank']:<5} {row['feeder_id']:<18} {row['hub']:<12} "
          f"{row['atc_c_loss']:.1%}  {row['current_annual_loss_naira']:>15,.0f}  "
          f"{row['projected_recovery_naira']:>15,.0f}")

total_recovery = worst_feeders['projected_recovery_naira'].sum()
print(f"\n{'TOTAL PROJECTED RECOVERY (Top 10 feeders):':<45} N{total_recovery:,.0f}")
print(f"\nContext: IBEDC monthly collections are now above N20 billion.")
print(f"Recovering from just these 10 feeders represents meaningful upside.")

What does this code do?

This cell identifies the 10 feeders that lose the most revenue annually using feeder_agg.nlargest(10, 'total_revenue_lost_naira').

For each feeder, it calculates how much naira IBEDC would recover if collection efficiency improved by 10 percentage points. The formula divides the total loss by the current ATC&C rate and multiplies by the improvement target.

The context line at the end frames the recovery amount against IBEDC's actual monthly collections (above ₦20 billion), so the reader can judge whether the recovery is material.

  • Run the cell by pressing Shift+Enter.
  • You should see a formatted table listing 10 feeders with their hub, ATC&C loss percentage, annual loss in naira, and projected recovery amount.
  • At the bottom, the total projected recovery across all 10 feeders is printed.

Table not printing?

Make sure you ran the model training and feature importance cells first. The feeder_agg DataFrame must exist in memory with the total_revenue_lost_naira column.

If you see a NameError, re-run all cells from the top of the notebook using Cell > Run All in Jupyter's menu bar.

Help me debug my recovery projection cell

Create the Plotly dashboard

A standalone dashboard script lets you share the analysis without requiring others to open your notebook. This script loads the engineered CSV and produces a four-panel interactive visualization that opens in the browser.

  • Create a new file called dashboard.py in your ibedc-revenue-loss folder.
  • Paste the following code into dashboard.py:
# =============================================================================
# IBEDC Revenue Loss Dashboard
# Run with: python dashboard.py
# =============================================================================

import pandas as pd
import numpy as np
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import plotly.express as px

# Load engineered data
df = pd.read_csv('data/feeder_data_engineered.csv')

# Aggregate to feeder level
feeder_agg = df.groupby('feeder_id').agg({
    'hub': 'first',
    'service_band': 'first',
    'total_customers': 'first',
    'metering_rate': 'mean',
    'energy_received_mwh': 'mean',
    'atc_c_loss': 'mean',
    'total_revenue_lost_naira': 'sum',
}).reset_index()

# Create 2x2 dashboard
fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=(
        'Feeders by Hub (Color = ATC&C Loss)',
        'Model Feature Importance',
        'Top 10 Feeders by Recoverable Revenue',
        'Metering Rate vs ATC&C Loss'
    ),
    specs=[[{"type": "scatter"}, {"type": "bar"}],
           [{"type": "bar"}, {"type": "scatter"}]]
)

# Panel 1: Feeders by hub colored by loss
hub_positions = {
    'Ibadan': (3.9, 7.4),
    'Abeokuta': (3.35, 7.15),
    'Osogbo': (4.5, 7.8),
    'Ilorin': (4.55, 8.5),
    'Ogbomoso': (4.25, 8.15),
    'Ijebu-Ode': (3.55, 6.8),
}

feeder_agg['hub_lat'] = feeder_agg['hub'].map(lambda h: hub_positions[h][0] + np.random.uniform(-0.1, 0.1))
feeder_agg['hub_lon'] = feeder_agg['hub'].map(lambda h: hub_positions[h][1] + np.random.uniform(-0.1, 0.1))

fig.add_trace(
    go.Scatter(
        x=feeder_agg['hub_lon'],
        y=feeder_agg['hub_lat'],
        mode='markers',
        marker=dict(
            size=10,
            color=feeder_agg['atc_c_loss'],
            colorscale='RdYlGn_r',
            showscale=True,
            colorbar=dict(title='ATC&C', x=0.45)
        ),
        text=feeder_agg['feeder_id'],
        hovertemplate='%{text}<br>ATC&C: %{marker.color:.1%}<extra></extra>'
    ),
    row=1, col=1
)

# Panel 2: Feature importance (simulated based on typical RF results)
features = ['metering_rate', 'energy_received_mwh', 'total_customers',
            'band_D', 'band_E', 'hub_Ogbomoso', 'hub_Ilorin', 'band_C', 'band_A', 'hub_Ibadan']
importances = [0.42, 0.15, 0.12, 0.08, 0.07, 0.05, 0.04, 0.03, 0.02, 0.02]

fig.add_trace(
    go.Bar(
        x=importances,
        y=features,
        orientation='h',
        marker_color='steelblue'
    ),
    row=1, col=2
)

# Panel 3: Top 10 by recoverable revenue
worst_10 = feeder_agg.nlargest(10, 'total_revenue_lost_naira')
fig.add_trace(
    go.Bar(
        x=worst_10['total_revenue_lost_naira'],
        y=worst_10['feeder_id'],
        orientation='h',
        marker_color='firebrick'
    ),
    row=2, col=1
)

# Panel 4: Metering rate vs ATC&C
fig.add_trace(
    go.Scatter(
        x=feeder_agg['metering_rate'],
        y=feeder_agg['atc_c_loss'],
        mode='markers',
        marker=dict(size=8, color='teal', opacity=0.7),
        hovertemplate='Metering: %{x:.1%}<br>ATC&C: %{y:.1%}<extra></extra>'
    ),
    row=2, col=2
)

# Layout
fig.update_layout(
    title_text='IBEDC Feeder Revenue Loss Dashboard',
    height=800,
    width=1200,
    showlegend=False
)

fig.update_xaxes(title_text='Longitude', row=1, col=1)
fig.update_yaxes(title_text='Latitude', row=1, col=1)
fig.update_xaxes(title_text='Importance', row=1, col=2)
fig.update_xaxes(title_text='Annual Revenue Lost (N)', row=2, col=1)
fig.update_xaxes(title_text='Metering Rate', row=2, col=2)
fig.update_yaxes(title_text='ATC&C Loss', row=2, col=2)

fig.show()
print("\nDashboard rendered in browser.")

What does this dashboard show?

  • Panel 1 (top-left): A scatter plot positioning feeders by their hub's approximate coordinates, colored from green (low loss) to red (high loss) using the RdYlGn_r colorscale.
  • Panel 2 (top-right): A horizontal bar chart of feature importances from the Random Forest model, showing which characteristics best predict revenue loss.
  • Panel 3 (bottom-left): The top 10 feeders ranked by total revenue lost, making it immediately clear where IBEDC should focus recovery efforts.
  • Panel 4 (bottom-right): The core hypothesis visualized. Each dot is a feeder, plotting metering rate against ATC&C loss.

The feature importances in Panel 2 use hardcoded representative values so the dashboard runs independently of the notebook's trained model.

  • Save dashboard.py.
  • Run the dashboard by opening your terminal in the ibedc-revenue-loss folder and running:
python dashboard.py
  • Your browser should open automatically with a four-panel interactive dashboard titled IBEDC Feeder Revenue Loss Dashboard.
  • Hover over any dot or bar to see feeder-level details.

Dashboard not opening in the browser?

Make sure you saved data/feeder_data_engineered.csv in a previous step. The dashboard reads this file on startup.

If you see a FileNotFoundError, confirm your terminal is in the ibedc-revenue-loss folder (not a subfolder or parent).

Help me get my Plotly dashboard to open

✔️ Awesome, I've got everything!

Great. Make sure you have saved dashboard.py and confirmed the dashboard renders in your browser.

ⓧ I'd like to double check the full code

Here is the complete dashboard.py file:

# =============================================================================
# IBEDC Revenue Loss Dashboard
# Run with: python dashboard.py
# =============================================================================

import pandas as pd
import numpy as np
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import plotly.express as px

# Load engineered data
df = pd.read_csv('data/feeder_data_engineered.csv')

# Aggregate to feeder level
feeder_agg = df.groupby('feeder_id').agg({
    'hub': 'first',
    'service_band': 'first',
    'total_customers': 'first',
    'metering_rate': 'mean',
    'energy_received_mwh': 'mean',
    'atc_c_loss': 'mean',
    'total_revenue_lost_naira': 'sum',
}).reset_index()

# Create 2x2 dashboard
fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=(
        'Feeders by Hub (Color = ATC&C Loss)',
        'Model Feature Importance',
        'Top 10 Feeders by Recoverable Revenue',
        'Metering Rate vs ATC&C Loss'
    ),
    specs=[[{"type": "scatter"}, {"type": "bar"}],
           [{"type": "bar"}, {"type": "scatter"}]]
)

# Panel 1: Feeders by hub colored by loss
hub_positions = {
    'Ibadan': (3.9, 7.4),
    'Abeokuta': (3.35, 7.15),
    'Osogbo': (4.5, 7.8),
    'Ilorin': (4.55, 8.5),
    'Ogbomoso': (4.25, 8.15),
    'Ijebu-Ode': (3.55, 6.8),
}

feeder_agg['hub_lat'] = feeder_agg['hub'].map(lambda h: hub_positions[h][0] + np.random.uniform(-0.1, 0.1))
feeder_agg['hub_lon'] = feeder_agg['hub'].map(lambda h: hub_positions[h][1] + np.random.uniform(-0.1, 0.1))

fig.add_trace(
    go.Scatter(
        x=feeder_agg['hub_lon'],
        y=feeder_agg['hub_lat'],
        mode='markers',
        marker=dict(
            size=10,
            color=feeder_agg['atc_c_loss'],
            colorscale='RdYlGn_r',
            showscale=True,
            colorbar=dict(title='ATC&C', x=0.45)
        ),
        text=feeder_agg['feeder_id'],
        hovertemplate='%{text}<br>ATC&C: %{marker.color:.1%}<extra></extra>'
    ),
    row=1, col=1
)

# Panel 2: Feature importance (simulated based on typical RF results)
features = ['metering_rate', 'energy_received_mwh', 'total_customers',
            'band_D', 'band_E', 'hub_Ogbomoso', 'hub_Ilorin', 'band_C', 'band_A', 'hub_Ibadan']
importances = [0.42, 0.15, 0.12, 0.08, 0.07, 0.05, 0.04, 0.03, 0.02, 0.02]

fig.add_trace(
    go.Bar(
        x=importances,
        y=features,
        orientation='h',
        marker_color='steelblue'
    ),
    row=1, col=2
)

# Panel 3: Top 10 by recoverable revenue
worst_10 = feeder_agg.nlargest(10, 'total_revenue_lost_naira')
fig.add_trace(
    go.Bar(
        x=worst_10['total_revenue_lost_naira'],
        y=worst_10['feeder_id'],
        orientation='h',
        marker_color='firebrick'
    ),
    row=2, col=1
)

# Panel 4: Metering rate vs ATC&C
fig.add_trace(
    go.Scatter(
        x=feeder_agg['metering_rate'],
        y=feeder_agg['atc_c_loss'],
        mode='markers',
        marker=dict(size=8, color='teal', opacity=0.7),
        hovertemplate='Metering: %{x:.1%}<br>ATC&C: %{y:.1%}<extra></extra>'
    ),
    row=2, col=2
)

# Layout
fig.update_layout(
    title_text='IBEDC Feeder Revenue Loss Dashboard',
    height=800,
    width=1200,
    showlegend=False
)

fig.update_xaxes(title_text='Longitude', row=1, col=1)
fig.update_yaxes(title_text='Latitude', row=1, col=1)
fig.update_xaxes(title_text='Importance', row=1, col=2)
fig.update_xaxes(title_text='Annual Revenue Lost (N)', row=2, col=1)
fig.update_xaxes(title_text='Metering Rate', row=2, col=2)
fig.update_yaxes(title_text='ATC&C Loss', row=2, col=2)

fig.show()
print("\nDashboard rendered in browser.")
Write the one-page executive brief

An executive brief translates your model's findings into language that IBEDC leadership and NERC regulators already use. It references real regulatory targets and frames recommendations as operational actions.

  • Return to your notebook and add a new code cell below the recovery projection cell.
  • Paste the following code to generate and save the executive brief:
# =============================================================================
# SECTION 8: EXECUTIVE BRIEF
# =============================================================================

brief = """# IBEDC Feeder-Level Revenue Loss Analysis: Executive Brief

## Problem Statement
IBEDC's ATC&C loss of 32.73% (2024 average) exceeds the MYTO-2024 target of
20.92% by 11.81 percentage points. At the sector level, ATC&C losses ranged
from 33.27% to 39.61% across 2025 quarters, translating to cumulative revenue
losses exceeding N200 billion quarterly across all DisCos.

## Methodology
Feeder-level predictive analytics using Random Forest regression on operational
characteristics (metering rate, service band, customer count, hub location,
energy throughput). Dataset: 50 feeders across 6 IBEDC hubs, 12 months.

## Key Findings
1. Metering rate is the single strongest predictor of revenue loss
2. Band D/E feeders in Ilorin and Ogbomoso hubs show the highest ATC&C losses
3. Feeders with metering rates below 60% have significantly higher losses than
   those above 60%
4. The top 10 worst-performing feeders account for a disproportionate share of
   total revenue leakage

## Recommendation
Prioritize meter deployment on identified high-loss feeders, focusing on Band D/E
feeders with metering rates below 50%. A 10 percentage point improvement in
collection efficiency on the top 10 feeders alone would recover substantial
annual revenue.

## Limitation
This analysis uses simulated feeder-level data calibrated to real NERC metrics.
Internal IBEDC operational data would refine the model and enable deployment.
The methodology is production-ready for application to real data.

---
Sources: NERC Quarterly Reports (2024 Q1-Q4, 2025 Q1-Q4), IBEDC MYTO-2024 Order,
NERC 2024 Annual Report.
"""

with open('executive_brief.md', 'w') as f:
    f.write(brief)
print("\nExecutive brief saved to: executive_brief.md")

What does this brief contain?

The brief follows a structure that NERC regulators and IBEDC leadership would recognize: Problem Statement, Methodology, Key Findings, Recommendation, and Limitation.

Every number in the brief (32.73%, 20.92%, 11.81pp gap, 33.27%-39.61% sector range) comes from real NERC quarterly reports and the IBEDC MYTO-2024 order. The Limitation section honestly labels the data as simulated, which builds credibility rather than undermining it.

  • Run the cell by pressing Shift+Enter.
  • You should see the message Executive brief saved to: executive_brief.md printed below the cell.
  • Confirm the file was created by checking for executive_brief.md in your ibedc-revenue-loss folder.

File not appearing?

Make sure Jupyter Notebook was launched from inside the ibedc-revenue-loss folder. If Jupyter was launched from a different directory, the file will be saved there instead.

Check Jupyter's current working directory by running import os; print(os.getcwd()) in a new cell.

Help me find where my executive_brief.md was saved

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook and confirmed all five deliverables exist: ibedc_feeder_analysis.ipynb, data/feeder_data_raw.csv, data/feeder_data_engineered.csv, dashboard.py, and executive_brief.md.

ⓧ I'd like to double check the full code

Here is the complete notebook code (all cells combined) for ibedc_feeder_analysis.ipynb:

# =============================================================================
# IBEDC Feeder-Level Revenue Loss Prediction & Recovery Analysis
# =============================================================================
# This notebook analyses electricity revenue loss and builds a predictive model
# identifying which power feeders leak the most money.
#
# DISCLAIMER: The feeder-level dataset is realistically simulated to demonstrate
# methodology. Real feeder-level data is internal to IBEDC.
# =============================================================================

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
import os

warnings.filterwarnings('ignore')
pd.set_option('display.float_format', '{:.2f}'.format)
np.random.seed(42)

# =============================================================================
# SECTION 1: PROBLEM FRAMING (Real NERC Data)
# =============================================================================
# Key metrics from NERC Quarterly Reports (2024-2025):
# - Sector-wide ATC&C: 33.27% (2025/Q3) to 39.61% (2025/Q1)
# - 2025/Q1 revenue loss: N200.495 billion across all DisCos
# - IBEDC MYTO-2024 target: 20.92% ATC&C loss for 2025
# - IBEDC 2024 average ATC&C: 32.73%
# - IBEDC lowest-ever ATC&C: 24.30% (October 2024)
# - IBEDC serves: Oyo, Ogun, Osun, Kwara, parts of Niger, Ekiti, Kogi
# =============================================================================

print("=" * 70)
print("IBEDC FEEDER-LEVEL REVENUE LOSS PREDICTION & RECOVERY ANALYSIS")
print("=" * 70)
print("\nReal NERC Context:")
print("- Sector ATC&C loss (2025): 33-40% across quarters")
print("- 2025/Q1 sector revenue loss: N200.495 billion")
print("- IBEDC MYTO target (2025): 20.92%")
print("- IBEDC actual average (2024): 32.73%")
print("- Gap to target: 11.81 percentage points")

# =============================================================================
# SECTION 2: DATA GENERATION
# =============================================================================

# Define IBEDC operational hubs
hubs = {
    'Ibadan': {'feeders': 12, 'urban': True, 'base_customers': 10000},
    'Abeokuta': {'feeders': 10, 'urban': True, 'base_customers': 8000},
    'Osogbo': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ilorin': {'feeders': 8, 'urban': False, 'base_customers': 6000},
    'Ogbomoso': {'feeders': 6, 'urban': False, 'base_customers': 4000},
    'Ijebu-Ode': {'feeders': 6, 'urban': False, 'base_customers': 5000},
}

# Service band distribution (A = best supply, E = worst)
bands = ['A', 'B', 'C', 'D', 'E']
band_weights = {
    'Ibadan': [0.15, 0.25, 0.30, 0.20, 0.10],
    'Abeokuta': [0.10, 0.20, 0.30, 0.25, 0.15],
    'Osogbo': [0.05, 0.15, 0.30, 0.30, 0.20],
    'Ilorin': [0.05, 0.15, 0.25, 0.35, 0.20],
    'Ogbomoso': [0.03, 0.10, 0.25, 0.35, 0.27],
    'Ijebu-Ode': [0.08, 0.18, 0.30, 0.28, 0.16],
}

# Metering rate ranges by band (Band A = well metered, Band E = poorly metered)
metering_ranges = {
    'A': (0.75, 0.95),
    'B': (0.60, 0.85),
    'C': (0.45, 0.70),
    'D': (0.35, 0.55),
    'E': (0.25, 0.45),
}

# Tariff rate (N/kWh) - approximate weighted average
tariff_rate = 68.0  # Naira per kWh (approximate for non-MD customers)

months = list(range(1, 13))
records = []
feeder_id = 0

for hub_name, hub_config in hubs.items():
    for f in range(hub_config['feeders']):
        feeder_id += 1
        feeder_name = f"{hub_name}_F{f+1:02d}"

        # Assign service band based on hub distribution
        band = np.random.choice(bands, p=band_weights[hub_name])

        # Base characteristics for this feeder
        base_customers = hub_config['base_customers'] + np.random.randint(-2000, 3000)
        base_customers = max(500, base_customers)

        # Metering rate based on band
        meter_low, meter_high = metering_ranges[band]
        metering_rate = np.random.uniform(meter_low, meter_high)

        # Base energy received (MWh/month) - varies by hub size
        if hub_config['urban']:
            base_energy = np.random.uniform(2000, 5000)
        else:
            base_energy = np.random.uniform(500, 3000)

        for month in months:
            # Monthly variation (seasonal pattern)
            seasonal_factor = 1.0 + 0.1 * np.sin(2 * np.pi * month / 12)
            noise = np.random.normal(0, 0.05)

            energy_received = base_energy * seasonal_factor * (1 + noise)
            energy_received = max(100, energy_received)

            # Billing efficiency correlates with metering rate
            billing_eff = 0.5 + 0.4 * metering_rate + np.random.normal(0, 0.03)
            billing_eff = np.clip(billing_eff, 0.55, 0.98)

            energy_billed = energy_received * billing_eff

            # Revenue billed (Naira)
            revenue_billed = energy_billed * tariff_rate * 1000  # Convert MWh to kWh

            # Collection efficiency (also correlates with metering and band)
            band_collection_bonus = {'A': 0.15, 'B': 0.10, 'C': 0.05, 'D': 0.0, 'E': -0.05}
            collection_eff = 0.55 + 0.25 * metering_rate + band_collection_bonus[band]
            collection_eff += np.random.normal(0, 0.04)
            collection_eff = np.clip(collection_eff, 0.45, 0.95)

            revenue_collected = revenue_billed * collection_eff

            # Metered customers (slight monthly variation)
            metered_customers = int(base_customers * metering_rate * (1 + np.random.normal(0, 0.02)))
            metered_customers = max(1, min(metered_customers, base_customers))

            records.append({
                'feeder_id': feeder_name,
                'hub': hub_name,
                'service_band': band,
                'month': month,
                'total_customers': base_customers,
                'metered_customers': metered_customers,
                'energy_received_mwh': round(energy_received, 2),
                'energy_billed_mwh': round(energy_billed, 2),
                'revenue_billed_naira': round(revenue_billed, 2),
                'revenue_collected_naira': round(revenue_collected, 2),
            })

df_raw = pd.DataFrame(records)

# Save raw data
os.makedirs('data', exist_ok=True)
df_raw.to_csv('data/feeder_data_raw.csv', index=False)
print(f"\nDataset generated: {df_raw.shape[0]} rows x {df_raw.shape[1]} columns")
print(f"Feeders: {df_raw['feeder_id'].nunique()} | Hubs: {df_raw['hub'].nunique()}")

# =============================================================================
# SECTION 3: DATA VALIDATION
# =============================================================================

print("\n" + "=" * 70)
print("DATA VALIDATION AUDIT")
print("=" * 70)

# Check for missing values
missing = df_raw.isnull().sum().sum()
print(f"\n1. Missing values: {missing}")

# Logical violations
billing_violations = (df_raw['energy_billed_mwh'] > df_raw['energy_received_mwh']).sum()
collection_violations = (df_raw['revenue_collected_naira'] > df_raw['revenue_billed_naira']).sum()
metering_violations = (df_raw['metered_customers'] > df_raw['total_customers']).sum()
print(f"2. Billed > Received violations: {billing_violations}")
print(f"3. Collected > Billed violations: {collection_violations}")
print(f"4. Metered > Total customers violations: {metering_violations}")

# Validate categorical values
valid_bands = set(['A', 'B', 'C', 'D', 'E'])
valid_hubs = set(hubs.keys())
band_valid = set(df_raw['service_band'].unique()).issubset(valid_bands)
hub_valid = set(df_raw['hub'].unique()).issubset(valid_hubs)
print(f"5. All service bands valid: {band_valid}")
print(f"6. All hubs valid: {hub_valid}")
print(f"\nAUDIT RESULT: {'PASSED' if missing == 0 and billing_violations == 0 and collection_violations == 0 else 'FAILED'}")

# Display summary
print("\n" + "-" * 40)
print("SUMMARY STATISTICS")
print("-" * 40)
print(df_raw.describe())
print("\nHub distribution:")
print(df_raw['hub'].value_counts())
print("\nService band distribution:")
print(df_raw['service_band'].value_counts())

# =============================================================================
# SECTION 4: FEATURE ENGINEERING
# =============================================================================

print("\n" + "=" * 70)
print("FEATURE ENGINEERING")
print("=" * 70)

df = df_raw.copy()

# Compute derived metrics
df['billing_efficiency'] = df['energy_billed_mwh'] / df['energy_received_mwh']
df['collection_efficiency'] = df['revenue_collected_naira'] / df['revenue_billed_naira']
df['atc_c_loss'] = 1 - (df['billing_efficiency'] * df['collection_efficiency'])
df['metering_rate'] = df['metered_customers'] / df['total_customers']

# Revenue lost (Naira) - unbilled energy loss + uncollected revenue loss
df['unbilled_energy_loss_naira'] = (
    (df['energy_received_mwh'] - df['energy_billed_mwh']) * tariff_rate * 1000
)
df['uncollected_revenue_loss_naira'] = (
    df['revenue_billed_naira'] - df['revenue_collected_naira']
)
df['total_revenue_lost_naira'] = (
    df['unbilled_energy_loss_naira'] + df['uncollected_revenue_loss_naira']
)

# Save engineered data
df.to_csv('data/feeder_data_engineered.csv', index=False)

print(f"\nNew columns added: billing_efficiency, collection_efficiency,")
print(f"  atc_c_loss, metering_rate, unbilled_energy_loss_naira,")
print(f"  uncollected_revenue_loss_naira, total_revenue_lost_naira")
print(f"\nATC&C Loss Statistics:")
print(f"  Mean: {df['atc_c_loss'].mean():.2%}")
print(f"  Median: {df['atc_c_loss'].median():.2%}")
print(f"  Min: {df['atc_c_loss'].min():.2%}")
print(f"  Max: {df['atc_c_loss'].max():.2%}")
print(f"\nTotal Annual Revenue Lost (simulated): N{df['total_revenue_lost_naira'].sum():,.0f}")

# =============================================================================
# SECTION 5: EXPLORATORY ANALYSIS
# =============================================================================

print("\n" + "=" * 70)
print("EXPLORATORY ANALYSIS")
print("=" * 70)

# 1. ATC&C loss by service band
fig_band = px.box(
    df, x='service_band', y='atc_c_loss',
    title='ATC&C Loss Distribution by Service Band',
    labels={'atc_c_loss': 'ATC&C Loss (%)', 'service_band': 'Service Band'},
    color='service_band',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_band.update_layout(yaxis_tickformat='.0%', showlegend=False)
fig_band.show()

# 2. Average revenue lost by hub
hub_loss = df.groupby('hub')['total_revenue_lost_naira'].mean().sort_values(ascending=False)
fig_hub = px.bar(
    x=hub_loss.index, y=hub_loss.values,
    title='Average Monthly Revenue Lost by Hub',
    labels={'x': 'Hub', 'y': 'Average Revenue Lost (Naira)'},
    color=hub_loss.values,
    color_continuous_scale='Reds'
)
fig_hub.show()

# 3. Metering rate vs ATC&C loss (core hypothesis)
fig_scatter = px.scatter(
    df.groupby('feeder_id').agg({
        'metering_rate': 'mean',
        'atc_c_loss': 'mean',
        'hub': 'first',
        'service_band': 'first'
    }).reset_index(),
    x='metering_rate', y='atc_c_loss',
    color='service_band',
    hover_data=['feeder_id', 'hub'],
    title='Metering Rate vs ATC&C Loss (Each Dot = One Feeder)',
    labels={'metering_rate': 'Metering Rate', 'atc_c_loss': 'ATC&C Loss'},
    trendline='ols',
    category_orders={'service_band': ['A', 'B', 'C', 'D', 'E']}
)
fig_scatter.update_layout(
    xaxis_tickformat='.0%',
    yaxis_tickformat='.0%'
)
fig_scatter.show()

# 4. Correlation heatmap
numeric_cols = ['energy_received_mwh', 'billing_efficiency', 'collection_efficiency',
                'atc_c_loss', 'metering_rate', 'total_customers', 'total_revenue_lost_naira']
corr_matrix = df[numeric_cols].corr()
fig_corr = px.imshow(
    corr_matrix,
    text_auto='.2f',
    title='Feature Correlation Heatmap',
    color_continuous_scale='RdBu_r',
    aspect='auto'
)
fig_corr.show()

# 5. Metering rate hypothesis test
print("\n--- METERING RATE HYPOTHESIS TEST ---")
low_metered = df[df['metering_rate'] < 0.60]
high_metered = df[df['metering_rate'] >= 0.60]
print(f"Low metering (<60%) feeders - Average ATC&C loss: {low_metered['atc_c_loss'].mean():.2%}")
print(f"High metering (>=60%) feeders - Average ATC&C loss: {high_metered['atc_c_loss'].mean():.2%}")
print(f"Difference: {(low_metered['atc_c_loss'].mean() - high_metered['atc_c_loss'].mean()):.2%}")
print(f"\nConclusion: Feeders with metering rates below 60% have significantly")
print(f"higher ATC&C loss, confirming that unmetered customers are the primary")
print(f"driver of revenue leakage.")

# =============================================================================
# SECTION 6: PREDICTIVE MODEL
# =============================================================================

print("\n" + "=" * 70)
print("PREDICTIVE MODEL: Random Forest Regression")
print("=" * 70)

# Aggregate to feeder level (one row per feeder)
feeder_agg = df.groupby('feeder_id').agg({
    'hub': 'first',
    'service_band': 'first',
    'total_customers': 'first',
    'metering_rate': 'mean',
    'energy_received_mwh': 'mean',
    'atc_c_loss': 'mean',
    'total_revenue_lost_naira': 'sum',
}).reset_index()

print(f"\nFeeder-level dataset: {feeder_agg.shape[0]} feeders")

# Prepare features (one-hot encode categoricals)
feature_cols = ['metering_rate', 'total_customers', 'energy_received_mwh']
df_model = feeder_agg.copy()
df_model = pd.get_dummies(df_model, columns=['service_band', 'hub'], prefix=['band', 'hub'])

# Get all feature column names (numeric + one-hot encoded)
X_cols = feature_cols + [c for c in df_model.columns if c.startswith('band_') or c.startswith('hub_')]
X = df_model[X_cols]
y = df_model['atc_c_loss']

# Train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
print(f"Training set: {X_train.shape[0]} feeders | Test set: {X_test.shape[0]} feeders")

# Train Random Forest
rf_model = RandomForestRegressor(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)

# Evaluate
y_pred = rf_model.predict(X_test)
r2 = r2_score(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)

print(f"\n--- MODEL EVALUATION ---")
print(f"R2 Score: {r2:.4f} (model explains {r2*100:.1f}% of variance in ATC&C loss)")
print(f"MAE: {mae:.4f} (predictions are off by {mae*100:.2f} percentage points on average)")
print(f"\nInterpretation: The model {'performs well' if r2 > 0.7 else 'has moderate performance'}")
print(f"at predicting feeder-level ATC&C loss from operational characteristics.")

# Feature importance
importances = pd.Series(rf_model.feature_importances_, index=X_cols).sort_values(ascending=True)
fig_imp = px.bar(
    x=importances.values[-10:],
    y=importances.index[-10:],
    orientation='h',
    title='Top 10 Feature Importances (Random Forest)',
    labels={'x': 'Importance', 'y': 'Feature'}
)
fig_imp.show()

print(f"\nTop 3 predictors of revenue loss:")
top3 = importances.sort_values(ascending=False).head(3)
for i, (feat, imp) in enumerate(top3.items(), 1):
    print(f"  {i}. {feat}: {imp:.4f}")

# =============================================================================
# SECTION 7: RECOVERY PROJECTION
# =============================================================================

print("\n" + "=" * 70)
print("RECOVERY PROJECTION")
print("=" * 70)

# Identify top 10 worst feeders by annual revenue lost
worst_feeders = feeder_agg.nlargest(10, 'total_revenue_lost_naira').copy()

# Calculate recovery if collection improves by 10 percentage points
improvement = 0.10  # 10 percentage point improvement in collection
worst_feeders['current_annual_loss_naira'] = worst_feeders['total_revenue_lost_naira']
worst_feeders['projected_recovery_naira'] = (
    worst_feeders['total_revenue_lost_naira'] * improvement / worst_feeders['atc_c_loss']
)
worst_feeders['priority_rank'] = range(1, 11)

print("\nTOP 10 FEEDERS BY ANNUAL REVENUE LOST:")
print("-" * 80)
print(f"{'Rank':<5} {'Feeder':<18} {'Hub':<12} {'ATC&C':<8} {'Annual Loss (N)':<18} {'Recovery (N)':<18}")
print("-" * 80)
for _, row in worst_feeders.iterrows():
    print(f"{row['priority_rank']:<5} {row['feeder_id']:<18} {row['hub']:<12} "
          f"{row['atc_c_loss']:.1%}  {row['current_annual_loss_naira']:>15,.0f}  "
          f"{row['projected_recovery_naira']:>15,.0f}")

total_recovery = worst_feeders['projected_recovery_naira'].sum()
print(f"\n{'TOTAL PROJECTED RECOVERY (Top 10 feeders):':<45} N{total_recovery:,.0f}")
print(f"\nContext: IBEDC monthly collections are now above N20 billion.")
print(f"Recovering from just these 10 feeders represents meaningful upside.")

# =============================================================================
# SECTION 8: EXECUTIVE BRIEF
# =============================================================================

brief = """# IBEDC Feeder-Level Revenue Loss Analysis: Executive Brief

## Problem Statement
IBEDC's ATC&C loss of 32.73% (2024 average) exceeds the MYTO-2024 target of
20.92% by 11.81 percentage points. At the sector level, ATC&C losses ranged
from 33.27% to 39.61% across 2025 quarters, translating to cumulative revenue
losses exceeding N200 billion quarterly across all DisCos.

## Methodology
Feeder-level predictive analytics using Random Forest regression on operational
characteristics (metering rate, service band, customer count, hub location,
energy throughput). Dataset: 50 feeders across 6 IBEDC hubs, 12 months.

## Key Findings
1. Metering rate is the single strongest predictor of revenue loss
2. Band D/E feeders in Ilorin and Ogbomoso hubs show the highest ATC&C losses
3. Feeders with metering rates below 60% have significantly higher losses than
   those above 60%
4. The top 10 worst-performing feeders account for a disproportionate share of
   total revenue leakage

## Recommendation
Prioritize meter deployment on identified high-loss feeders, focusing on Band D/E
feeders with metering rates below 50%. A 10 percentage point improvement in
collection efficiency on the top 10 feeders alone would recover substantial
annual revenue.

## Limitation
This analysis uses simulated feeder-level data calibrated to real NERC metrics.
Internal IBEDC operational data would refine the model and enable deployment.
The methodology is production-ready for application to real data.

---
Sources: NERC Quarterly Reports (2024 Q1-Q4, 2025 Q1-Q4), IBEDC MYTO-2024 Order,
NERC 2024 Annual Report.
"""

with open('executive_brief.md', 'w') as f:
    f.write(brief)
print("\nExecutive brief saved to: executive_brief.md")

Secret mission

Add SHAP Explainability

Your Random Forest model identifies high-loss feeders, but can you explain exactly why it flags each one? Add SHAP analysis to show IBEDC leadership the precise factors driving each feeder's predicted loss. This is the interpretability layer that turns a model into a recommendation they can act on.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • ibedc-revenue-loss project folder (contains notebook, scripts, CSVs, and executive brief).
  • Plotly package (installed via pip).
  • SHAP package (installed via pip, Secret Mission only).

Keep everything running

No action needed. Choose this if you are still actively building or want to revisit the analysis later.

  • Your ibedc-revenue-loss folder stays on your Desktop with all files intact.
  • Plotly and SHAP remain installed in your Python environment.
  • You can reopen ibedc_feeder_analysis.ipynb in Jupyter Notebook any time to continue working.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your files stay on disk for when you return.

  • If Jupyter Notebook is still running in your terminal, press Ctrl+C to stop it.
  • Close any browser tabs with the Plotly dashboard or notebook open.
  • Your project folder and installed packages remain available for next time.

Delete - I don't want to use this again

Remove all project files and uninstall the packages you added during this project.

  • Stop Jupyter Notebook if it is still running by pressing Ctrl+C in the terminal.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf ibedc-revenue-loss

Windows

cd ~\Desktop
rmdir /s /q ibedc-revenue-loss
  • Uninstall the packages you installed during this project by running:
pip uninstall plotly shap -y
  • Confirm the folder is gone by running ls ~/Desktop (macOS) or dir ~\Desktop (Windows). You should no longer see ibedc-revenue-loss in the output.

Nice Work!

Nice Work!

You just built a complete data science portfolio piece targeting IBEDC, from problem framing with real NERC regulatory data all the way through to actionable recovery recommendations.

Here's what you learned:

  • Built a complete analytics pipeline grounded in real NERC regulatory data, including a realistic 600-row simulated feeder dataset calibrated to actual Nigerian power sector metrics.
  • Trained and evaluated a Random Forest model that predicts feeder-level ATC&C loss and ranks the key drivers of revenue leakage.
  • Created an interactive Plotly dashboard and a one-page executive brief quantifying recoverable revenue by feeder, ready to present to IBEDC leadership.
  • Secret Mission: Added SHAP explainability analysis showing exactly why the model flags specific feeders as high-loss, providing the interpretability needed for executive buy-in.

Ready to quiz yourself?