Diagnose Revenue Leakage in Sales

Analyze 8,800+ CRM records to find where B2B pipeline revenue leaks.

Introduction

30 Second Summary

Every quarter, sales teams leave money on the table. Deals stall out, leads go cold, and revenue quietly leaks from the pipeline without anyone noticing until it's too late.

In this project, you will analyze a real-world CRM dataset of 8,800+ sales opportunities to diagnose exactly where revenue is leaking in a B2B sales pipeline. You'll calculate the metrics that RevOps analysts track weekly, build diagnostic visualizations, and produce a polished Jupyter Notebook ready for your GitHub portfolio.

What You'll Build

You will build a diagnostic revenue leakage analysis notebook for MavenTech, a fictitious B2B computer hardware company, using Python, pandas, matplotlib, and seaborn.

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

  • A merged, analysis-ready dataset from four related CRM tables covering 8,800+ sales records.
  • Calculated RevOps KPIs including win rate, stage conversion rates, deal velocity, pipeline coverage, and total revenue leakage.
  • Six diagnostic visualizations that tell a clear story about pipeline health and where deals are dying.
  • A quarterly trend analysis with a programmatic executive briefing that pulls live numbers from your data (Secret Mission).

Are there any prerequisites?

You should have basic familiarity with Python and SQL concepts. No prior RevOps experience is needed. This guide includes a pandas refresher and full setup instructions so you can follow along from scratch.

Set Up Your Python Environment

Every data analysis project starts with the same question: do you have the right tools installed? In this step, you'll get Python, Jupyter Notebook, and three data analysis libraries ready to go on your machine.

You'll also download the CRM dataset you'll be analyzing throughout this project. By the end of this step, your environment is fully configured and you can focus entirely on the analysis.

In this step, get ready to:
  • Install Python 3 and the data analysis libraries (pandas, matplotlib, seaborn).
  • Create a project folder and launch Jupyter Notebook.
  • Download the MavenTech CRM dataset and verify the four CSV files.
Install Python and the analysis libraries

Python is the programming language you'll use for this entire analysis. Let's check if it's already installed on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check if Python is installed by running this command:

macOS

python3 --version

Windows

python --version

I see a version number

You're all set. Any Python version 3.9 or higher works for this project.

Command not found

  • Open python.org/downloads in your browser.
  • Click the large Download Python button at the top of the page.
  • Run the installer after it downloads.

Windows users: check Add Python to PATH

On the very first screen of the installer, check the box that says Add Python to PATH before clicking Install Now.

If you skip this, the python command will not work in your terminal.

  • After the installation finishes, close your terminal and open a new one.
  • Run the version check command again to confirm Python is installed.

Now you'll install the four libraries this project depends on. pandas handles data manipulation. matplotlib and seaborn produce the visualizations. NumPy supports numerical operations under the hood.

  • Install Jupyter Notebook and the analysis libraries by running this command:

macOS

pip3 install notebook pandas matplotlib seaborn numpy

Windows

pip install notebook pandas matplotlib seaborn numpy

What does this command do?

  • pip is Python's package manager. It downloads and installs libraries from the internet.
  • notebook installs Jupyter Notebook, the interactive environment where you'll write and run your analysis.
  • The remaining four packages (pandas, matplotlib, seaborn, numpy) are the data analysis and visualization libraries you'll import in your notebook.
  • Wait for the installation to finish. You should see Successfully installed messages for each package in the terminal output.

Seeing a permission error?

  • On macOS, try adding --user to the end of the command: pip3 install --user notebook pandas matplotlib seaborn numpy.
  • On Windows, try running Command Prompt as Administrator by right-clicking it and selecting Run as administrator.
  • If you still hit errors, help me fix this pip installation error.
Create your project folder and launch Jupyter Notebook

You need a dedicated folder for this project. You'll also create a data/ subfolder inside it now so it's ready when you download the dataset.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder with a data/ subfolder and move into it by running these commands:
mkdir revops-pipeline-analysis
cd revops-pipeline-analysis
mkdir data
  • Confirm the folder structure exists by running:
ls

You should see data listed in the output.

Jupyter Notebook is an interactive environment where you write code in cells and see results immediately below each cell. It's the standard tool for data analysis because it lets you mix code, output, and written narrative in one document.

  • Launch Jupyter Notebook by running this command:
jupyter notebook

What should I see?

Your default browser opens automatically with the Jupyter Notebook dashboard. The dashboard shows the contents of your revops-pipeline-analysis folder.

You should see the data folder listed. Your terminal stays busy running Jupyter in the background. Do not close the terminal window.

  • In the Jupyter dashboard in your browser, click New in the top right corner.
  • Select Python 3 from the dropdown menu.

A new notebook opens in a separate browser tab with a single empty code cell.

  • Click on the text Untitled at the very top of the notebook page.
  • Type revops_pipeline_analysis as the new name.
  • Click Rename (or press Enter).

The tab title changes to revops_pipeline_analysis, and a file called revops_pipeline_analysis.ipynb appears in your project folder.

Browser didn't open automatically?

  • Look in your terminal output for a line that says http://localhost:8888/. Copy and paste that full URL into your browser.
  • If you see jupyter: command not found, close the terminal, open a new one, and try the jupyter notebook command again.
  • Still stuck? Help me launch Jupyter Notebook.
Download the CRM dataset

The dataset comes from Maven Analytics and simulates CRM data from MavenTech, a fictitious B2B computer hardware company.

It contains 8,800+ sales opportunities spread across four CSV files in a star schema (one main fact table connected to several reference tables).

  • Open a new terminal window. Press Cmd+Space (macOS) or the Windows key (Windows), type Terminal or Command Prompt, and press Enter.
  • Navigate to your project folder by running:
cd ~/Desktop/revops-pipeline-analysis
  • Download the dataset ZIP and extract it into your data/ folder by running these commands:

macOS

curl -L -o crm_data.zip "https://maven-datasets.s3.amazonaws.com/CRM+Sales+Opportunities/CRM+Sales+Opportunities.zip"
unzip crm_data.zip -d data/
rm crm_data.zip

Windows

curl -L -o crm_data.zip "https://maven-datasets.s3.amazonaws.com/CRM+Sales+Opportunities/CRM+Sales+Opportunities.zip"
Expand-Archive crm_data.zip -DestinationPath data
Remove-Item crm_data.zip

What do these commands do?

  • curl -L -o crm_data.zip downloads the ZIP file from Maven Analytics and saves it as crm_data.zip.
  • The second command extracts all the CSV files from the ZIP into your data/ folder.
  • The third command deletes the ZIP file since you no longer need it.
  • Verify the four CSV files are present by running:
ls data/

You should see these four files in the output:

  • sales_pipeline.csv (the main fact table with 8,800+ sales opportunities).
  • accounts.csv (customer company details like sector, revenue, and employee count).
  • products.csv (seven products with prices and series info).
  • sales_teams.csv (35 sales agents with their managers and regional offices).

Don't see four CSV files?

  • If you see a subfolder inside data/ instead of the CSV files directly, the ZIP extracted into a nested folder. Move the CSV files up one level into data/.
  • If the curl command failed, download the dataset manually from the Maven Analytics data playground. Click Download dataset, then extract the ZIP and copy the four CSV files into your data/ folder.
  • If you need help, help me download and extract the Maven Analytics CRM dataset.

Pro tip: check in Jupyter too

Switch back to the Jupyter dashboard tab in your browser. Click the data folder. You should see all four CSV files listed there.

Your environment is ready and the dataset is in place. Next up, you'll load these four CSV files into pandas and start exploring what 8,800+ sales opportunities can tell you about pipeline health.

Load and Explore the Raw CRM Data

Your Python environment is set up and the MavenTech dataset is sitting in your data/ folder. Now it is time to load the raw CRM data and see what you are working with.

A RevOps analyst always starts by understanding the shape and quality of the source data. You will load the four CSV tables, audit them for issues, and merge everything into one analysis-ready DataFrame.

In this step, get ready to:
  • Load all four CRM tables into pandas and inspect their structure.
  • Audit the data for missing values, duplicates, and distribution issues.
  • Parse dates, create calculated columns, and merge everything into a single analysis-ready DataFrame.
Import libraries and load the CRM data

The MavenTech dataset is organized as a star schema. One central fact table (sales_pipeline.csv) holds every deal record, and three dimension tables provide supporting details about accounts, products, and sales teams.

  • In your notebook, add a new code cell and import the analysis libraries by pasting this code:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
from datetime import datetime

# Configure plot styling
sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams["figure.figsize"] = (12, 6)
plt.rcParams["figure.dpi"] = 100
plt.rcParams["font.size"] = 11

What does this code do?

  • pandas (pd) is your main data wrangling library. It loads CSVs, filters rows, groups data, and merges tables.
  • matplotlib (plt) and seaborn (sns) handle visualizations. You configure them once at the top so every chart shares a consistent style.
  • sns.set_theme() applies a clean grid background and muted color palette to all plots.
  • Run the cell by pressing **Shift+Enter**.

You should see no output or errors. A clean run means all libraries are installed correctly.

Seeing ModuleNotFoundError?

This means one of the libraries is not installed. Go back to your terminal and run pip install pandas matplotlib seaborn numpy. Then restart your Jupyter kernel by clicking **Kernel**, then **Restart**.

Help me fix my ModuleNotFoundError in Jupyter Notebook.

  • Add a new code cell and load all four CSV files by pasting this code:
# Load all four CSV files
pipeline = pd.read_csv("data/sales_pipeline.csv")
accounts = pd.read_csv("data/accounts.csv")
products = pd.read_csv("data/products.csv")
teams = pd.read_csv("data/sales_teams.csv")

# Inspect shapes
print("sales_pipeline:", pipeline.shape)
print("accounts:", accounts.shape)
print("products:", products.shape)
print("sales_teams:", teams.shape)

# Check column names
print("\npipeline columns:", pipeline.columns.tolist())
print("accounts columns:", accounts.columns.tolist())
print("products columns:", products.columns.tolist())
print("teams columns:", teams.columns.tolist())

What does this code do?

  • pd.read_csv() reads each CSV file into a DataFrame. Each DataFrame is a table of rows and columns, similar to a spreadsheet.
  • .shape returns a tuple of (rows, columns) so you can quickly see how large each table is.
  • .columns.tolist() prints the column names as a plain list. This is your first look at what data fields are available in each table.
  • Run the cell by pressing **Shift+Enter**.

You should see the shape of each table printed. The pipeline table has roughly 8,800 rows and 8 columns. The accounts, products, and teams tables are much smaller dimension tables.

What is a star schema?

This dataset follows a star schema, which is how real CRM systems like Salesforce and HubSpot organize data.

  • sales_pipeline.csv is the fact table. It holds every individual deal record (the events you are analyzing).
  • accounts.csv, products.csv, and sales_teams.csv are dimension tables. They provide descriptive attributes (company sector, product price, regional office) that enrich the fact table.

Seeing FileNotFoundError?

This means pandas cannot find the CSV files. Make sure you extracted the ZIP into a data/ subfolder inside your project folder. The path should be data/sales_pipeline.csv relative to your notebook file.

Help me fix my FileNotFoundError when loading CSVs in Jupyter.

  • Add a new markdown cell above this code cell. Type the following to document the star schema structure for anyone reading your notebook:
## 1. Load and Explore Raw CRM Data

The MavenTech CRM dataset follows a **star schema**:
- **Fact table**: `sales_pipeline.csv` — 8,800+ individual deal records
- **Dimension tables**: `accounts.csv` (company details), `products.csv` (product catalog), `sales_teams.csv` (agent-manager-office mapping)

We'll inspect each table, audit data quality, then merge everything into a single analysis-ready DataFrame.

Pro tip: markdown makes your notebook portfolio-ready

Adding markdown cells throughout your notebook is what separates a polished portfolio piece from a messy script. When a hiring manager opens your notebook on GitHub, the narrative is the first thing they read.

Now take a closer look at the pipeline fact table, since that is where all the deal-level detail lives.

  • Add a new code cell and preview the pipeline table by pasting this code:
# Preview the main fact table
print("\n--- Sales Pipeline Sample ---")
print(pipeline.head())
print("\n--- Pipeline Data Types ---")
print(pipeline.dtypes)
print("\n--- Pipeline Descriptive Stats ---")
print(pipeline.describe())

What should I see?

  • .head() shows the first 5 rows. You will see columns like opportunity_id, sales_agent, deal_stage, engage_date, and close_value.
  • .dtypes reveals that the date columns are currently stored as object (plain text), not proper datetime types. You will fix that in substep 3.
  • .describe() gives summary statistics for numeric columns. Look at the close_value column to get a first sense of deal size ranges.
  • Run the cell by pressing **Shift+Enter** and confirm you see the sample rows, data types, and summary statistics printed below the cell.
Check for data quality issues

Real CRM data is messy. Missing values can skew your metrics and duplicates can inflate your counts. Catching these problems early saves you from misleading results later.

  • Add a new code cell and check for missing values across all four tables by pasting this code:
# Missing values
print("\n--- Missing Values ---")
for name, df in [("pipeline", pipeline), ("accounts", accounts), ("products", products), ("teams", teams)]:
    missing = df.isnull().sum()
    if missing.sum() > 0:
        print(f"\n{name}:")
        print(missing[missing > 0])
    else:
        print(f"{name}: No missing values")

What does this code do?

  • .isnull().sum() counts how many missing values each column has. Missing values in close_value or close_date are expected for deals that have not closed yet (Prospecting and Engaging stages).
  • The loop iterates through all four tables so you can spot issues in any of them at a glance.
  • Run the cell by pressing **Shift+Enter**.

You should see a breakdown of missing values per table. The pipeline table will likely show missing values in close_value and close_date for deals that are still open.

  • Add a new code cell and check for duplicates and deal stage distributions by pasting this code:
# Duplicates
print("\n--- Duplicate Rows ---")
for name, df in [("pipeline", pipeline), ("accounts", accounts), ("products", products), ("teams", teams)]:
    dupes = df.duplicated().sum()
    print(f"{name}: {dupes} duplicates")

# Distribution of deal stages
print("\n--- Deal Stage Distribution ---")
print(pipeline["deal_stage"].value_counts())

What does this code do?

  • .duplicated().sum() counts rows that are exact copies of another row. Duplicates would inflate your conversion rates and revenue figures.
  • .value_counts() on deal_stage shows how many deals sit at each pipeline stage: Prospecting, Engaging, Won, and Lost. This is your first look at the pipeline funnel shape.
  • Run the cell by pressing **Shift+Enter**.

You should see duplicate counts for each table and a breakdown of how many deals are in each stage. Take note of the ratio between Won and Lost deals. That number becomes your win rate in the next step.

Seeing a KeyError on deal_stage?

Double check that you loaded the pipeline table from sales_pipeline.csv (not one of the other files). Run pipeline.columns.tolist() to verify the column names match.

Help me debug my KeyError when accessing deal_stage.

  • Add a new markdown cell below your data quality code to document what you found. Here is an example:
### Data Quality Findings

- **Missing values**: `close_value` and `close_date` are null for open deals (Prospecting/Engaging). This is expected — those deals haven't closed yet.
- **Duplicates**: No duplicate rows found in any table.
- **Deal stage distribution**: The pipeline has four stages (Prospecting, Engaging, Won, Lost). The Won/Lost ratio gives us a first estimate of win rate.

Pro tip: document findings as you go

Update this markdown cell to reflect your actual numbers. Writing findings in the moment is faster than trying to reconstruct your thinking after the analysis is done.

Parse dates, create calculated columns, and merge the tables

Right now the date columns are stored as plain text. Pandas cannot do date math on text strings. You need to convert them to proper datetime objects before calculating how long deals take to close.

  • Add a new code cell and transform the date columns and create calculated fields by pasting this code:
# Parse date columns
pipeline["engage_date"] = pd.to_datetime(pipeline["engage_date"])
pipeline["close_date"] = pd.to_datetime(pipeline["close_date"])

# Calculate time to close (only for closed deals)
closed_mask = pipeline["deal_stage"].isin(["Won", "Lost"])
pipeline.loc[closed_mask, "time_to_close"] = (
    pipeline.loc[closed_mask, "close_date"] - pipeline.loc[closed_mask, "engage_date"]
).dt.days

# Extract close quarter
pipeline["close_quarter"] = pipeline["close_date"].dt.to_period("Q").astype(str)

What does this code do?

  • pd.to_datetime() converts text dates like 2017-03-15 into proper datetime objects that pandas can do math on.
  • time_to_close measures how many days passed between the engage date and close date. This is the deal velocity metric RevOps teams use to gauge pipeline speed.
  • close_quarter groups deals by fiscal quarter (e.g. 2017Q1). You will use this later to calculate pipeline coverage and quarterly trends.
  • The closed_mask filter ensures time_to_close is only calculated for Won and Lost deals. Open deals do not have a close date yet.
  • Run the cell by pressing **Shift+Enter**.

You will not see any output yet. The transformations happen silently on the DataFrame. The next chunk uses this data when you merge the tables.

Now it is time to combine all four tables into one unified dataset. This single merged DataFrame is what you will use for every metric and chart going forward.

  • Add a new code cell and merge all four tables by pasting this code:
# Merge all tables into a single analysis DataFrame
df = pipeline.merge(teams, on="sales_agent", how="left")
df = df.merge(accounts, left_on="account", right_on="account", how="left")
df = df.merge(products, left_on="product", right_on="product", how="left")

print(f"\nMerged dataset shape: {df.shape}")
print(f"Columns: {df.columns.tolist()}")

What does this code do?

  • .merge() joins two DataFrames on a shared column, similar to a SQL JOIN. Each merge attaches columns from a dimension table onto the fact table.
  • how="left" keeps every row from the pipeline table, even if there is no matching record in the dimension table.
  • After all three merges, the result is one DataFrame called df that contains deal records enriched with account details, product info, and sales team assignments.
  • Run the cell by pressing **Shift+Enter**.

You should see the merged dataset shape printed. It will have the same number of rows as the pipeline table (roughly 8,800) but more columns now that the dimension table data has been attached.

Seeing more rows than expected after the merge?

If the row count is significantly higher than the original pipeline table, a merge key has duplicate values in the dimension table. Run teams["sales_agent"].duplicated().sum() to check. A left join with duplicate keys creates extra rows.

Help me debug why my merge created extra rows.

Awesome, I've got everything!

Great. Double check you have saved your notebook before moving on.

I'd like to double check the full code

Here is all the code you should have across your notebook cells up to this point.

# Revenue Leakage Diagnosis: B2B Sales Pipeline Analysis
# Dataset: Maven Analytics CRM Sales Opportunities (Public Domain)
# Tools: Python, pandas, matplotlib, seaborn

# ============================================================
# SETUP AND IMPORTS
# ============================================================

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
from datetime import datetime

# Configure plot styling
sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams["figure.figsize"] = (12, 6)
plt.rcParams["figure.dpi"] = 100
plt.rcParams["font.size"] = 11

# ============================================================
# 1. LOAD AND EXPLORE RAW CRM DATA
# ============================================================

# Load all four CSV files
pipeline = pd.read_csv("data/sales_pipeline.csv")
accounts = pd.read_csv("data/accounts.csv")
products = pd.read_csv("data/products.csv")
teams = pd.read_csv("data/sales_teams.csv")

# Inspect shapes
print("sales_pipeline:", pipeline.shape)
print("accounts:", accounts.shape)
print("products:", products.shape)
print("sales_teams:", teams.shape)

# Check column names
print("\npipeline columns:", pipeline.columns.tolist())
print("accounts columns:", accounts.columns.tolist())
print("products columns:", products.columns.tolist())
print("teams columns:", teams.columns.tolist())

# Preview the main fact table
print("\n--- Sales Pipeline Sample ---")
print(pipeline.head())
print("\n--- Pipeline Data Types ---")
print(pipeline.dtypes)
print("\n--- Pipeline Descriptive Stats ---")
print(pipeline.describe())

# ============================================================
# 2. DATA QUALITY CHECKS
# ============================================================

# Missing values
print("\n--- Missing Values ---")
for name, df in [("pipeline", pipeline), ("accounts", accounts), ("products", products), ("teams", teams)]:
    missing = df.isnull().sum()
    if missing.sum() > 0:
        print(f"\n{name}:")
        print(missing[missing > 0])
    else:
        print(f"{name}: No missing values")

# Duplicates
print("\n--- Duplicate Rows ---")
for name, df in [("pipeline", pipeline), ("accounts", accounts), ("products", products), ("teams", teams)]:
    dupes = df.duplicated().sum()
    print(f"{name}: {dupes} duplicates")

# Distribution of deal stages
print("\n--- Deal Stage Distribution ---")
print(pipeline["deal_stage"].value_counts())

# ============================================================
# 3. DATA TRANSFORMATION AND MERGING
# ============================================================

# Parse date columns
pipeline["engage_date"] = pd.to_datetime(pipeline["engage_date"])
pipeline["close_date"] = pd.to_datetime(pipeline["close_date"])

# Calculate time to close (only for closed deals)
closed_mask = pipeline["deal_stage"].isin(["Won", "Lost"])
pipeline.loc[closed_mask, "time_to_close"] = (
    pipeline.loc[closed_mask, "close_date"] - pipeline.loc[closed_mask, "engage_date"]
).dt.days

# Extract close quarter
pipeline["close_quarter"] = pipeline["close_date"].dt.to_period("Q").astype(str)

# Merge all tables into a single analysis DataFrame
df = pipeline.merge(teams, on="sales_agent", how="left")
df = df.merge(accounts, left_on="account", right_on="account", how="left")
df = df.merge(products, left_on="product", right_on="product", how="left")

print(f"\nMerged dataset shape: {df.shape}")
print(f"Columns: {df.columns.tolist()}")

You now have a single, analysis-ready DataFrame with 8,800+ deal records enriched with account, product, and team details. Next up, you will calculate the five core RevOps metrics that tell a VP of Sales exactly where the pipeline is leaking revenue.

Calculate Core RevOps Metrics

Your analysis-ready DataFrame is built. You loaded four CSV files, checked for data quality issues, and merged everything into a single dataset with deal stages, product details, team assignments, and calculated fields like time_to_close.

Now you will turn that raw data into the five core metrics that RevOps analysts track weekly. These metrics tell sales leadership exactly where deals are dying in the pipeline and which segments need attention.

In this step, get ready to:
  • Calculate win rate and stage conversion rates across products, agents, and regions.
  • Compute deal velocity, pipeline coverage ratio, and total revenue leakage.
  • Build a KPI summary table that consolidates all key metrics in one place.
Calculate win rate and stage conversion rates

Win rate is the most fundamental RevOps metric. It measures what percentage of closed deals were won versus lost, giving you an immediate read on pipeline health.

  • Add a new markdown cell in your notebook with the heading ## 4. Core RevOps Metrics to label this analysis section.
  • In a new code cell below it, calculate the overall win rate and product-level breakdown by adding this code:
# --- Win Rate ---
closed_deals = df[df["deal_stage"].isin(["Won", "Lost"])]
total_closed = len(closed_deals)
total_won = len(closed_deals[closed_deals["deal_stage"] == "Won"])
overall_win_rate = total_won / total_closed * 100
print(f"\nOverall Win Rate: {overall_win_rate:.1f}%")

# Win rate by product
win_rate_by_product = closed_deals.groupby("product").apply(
    lambda x: (x["deal_stage"] == "Won").sum() / len(x) * 100
).reset_index(name="win_rate_pct")
print("\n--- Win Rate by Product ---")
print(win_rate_by_product.sort_values("win_rate_pct", ascending=False))

What does this code do?

  • closed_deals filters the merged DataFrame to only Won and Lost deals. Open deals (Prospecting, Engaging) are excluded because win rate only applies to deals that reached a final outcome.
  • The overall win rate divides total Won deals by total closed deals and multiplies by 100 to get a percentage.
  • The .groupby("product").apply() pattern calculates the same win rate formula separately for each product. This reveals which products close well and which struggle.
  • Run the cell. You should see an overall win rate percentage followed by a table ranking each of the 7 products by their individual win rates.

Seeing a NameError for df?

This means the merged DataFrame from Step 2 is not in memory. Go to the menu bar and click Cell, then Run All Above to re-run all previous cells first.

If you see a KeyError for deal_stage, double check that the merge in Step 2 completed without errors. The column must come from sales_pipeline.csv.

Still stuck? Help me debug my win rate calculation. I'm getting this error:

The overall win rate tells you the big picture, but the real insights come from slicing it by team. Different agents and offices can have wildly different close rates.

  • In a new code cell, break down win rate by sales agent and regional office by adding this code:
# Win rate by sales agent
win_rate_by_agent = closed_deals.groupby("sales_agent").apply(
    lambda x: pd.Series({
        "win_rate_pct": (x["deal_stage"] == "Won").sum() / len(x) * 100,
        "total_deals": len(x),
        "total_revenue": x.loc[x["deal_stage"] == "Won", "close_value"].sum()
    })
).reset_index()
print("\n--- Top 10 Agents by Win Rate (min 20 deals) ---")
qualified_agents = win_rate_by_agent[win_rate_by_agent["total_deals"] >= 20]
print(qualified_agents.sort_values("win_rate_pct", ascending=False).head(10))

# Win rate by regional office
win_rate_by_region = closed_deals.groupby("regional_office").apply(
    lambda x: (x["deal_stage"] == "Won").sum() / len(x) * 100
).reset_index(name="win_rate_pct")
print("\n--- Win Rate by Regional Office ---")
print(win_rate_by_region)

What does this code do?

  • The agent breakdown uses pd.Series inside the .apply() to return multiple metrics per agent: win rate, total deal count, and total revenue.
  • qualified_agents filters to agents with at least 20 deals. This prevents low-volume agents from skewing the rankings with misleadingly high or low win rates.
  • The regional breakdown shows which offices are closing at higher rates. This is the kind of comparison a VP of Sales uses to identify coaching opportunities.
  • Run the cell. You should see a top-10 agents table ranked by win rate, followed by a smaller table of win rates grouped by regional office.

Seeing an empty agents table?

If the top 10 table is empty, the 20-deal minimum filter might be too strict for the subset you are looking at. Check that closed_deals was defined in the previous cell and contains data by running len(closed_deals) in a scratch cell.

Need help? My qualified_agents DataFrame is empty after filtering. Help me figure out why.

Win rate tells you the outcome, but stage conversion rates tell you where deals drop off along the way. A healthy pipeline pushes a high proportion of deals from Prospecting through Engaging and into a closed state.

  • In a new code cell, calculate the stage conversion rates through the funnel by adding this code:
# --- Stage Conversion Rates ---
stage_counts = df["deal_stage"].value_counts()
total_opportunities = len(df)

prospecting_count = stage_counts.get("Prospecting", 0)
engaging_count = stage_counts.get("Engaging", 0)
won_count = stage_counts.get("Won", 0)
lost_count = stage_counts.get("Lost", 0)

# Conversion rates through the funnel
prospect_to_engage = (engaging_count + won_count + lost_count) / total_opportunities * 100
engage_to_close = (won_count + lost_count) / (engaging_count + won_count + lost_count) * 100 if (engaging_count + won_count + lost_count) > 0 else 0

print(f"\n--- Stage Conversion Rates ---")
print(f"Prospecting to Engaging+: {prospect_to_engage:.1f}%")
print(f"Engaging to Closed (Won+Lost): {engage_to_close:.1f}%")
print(f"Closed Won Rate: {overall_win_rate:.1f}%")

Why count it this way?

The funnel measures forward progression. "Prospecting to Engaging+" counts every deal that made it past Prospecting, including those that went all the way to Won or Lost. This gives you the true pass-through rate at each stage.

The if ... else 0 guard prevents a ZeroDivisionError if your data somehow has no deals past Prospecting.

  • Run the cell. You should see three conversion percentages: Prospecting to Engaging+, Engaging to Closed, and the Closed Won Rate.
  • Save your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

All conversion rates showing 0%?

Check that deal_stage values match exactly: Prospecting, Engaging, Won, Lost. The .get() method returns 0 silently if the key does not match, so a capitalization mismatch would produce all zeros.

Run df["deal_stage"].unique() in a scratch cell to see the exact stage names in your data.

Calculate deal velocity, pipeline coverage, and revenue leakage

Win rate tells you who closes. The next three metrics tell you how fast deals move, whether there is enough pipeline to hit targets, and how much revenue is leaking out.

Deal velocity measures how many days a deal takes from first engagement to close. Faster velocity means shorter sales cycles and more predictable revenue.

  • In a new code cell, calculate deal velocity for Won and Lost deals by adding this code:
# --- Deal Velocity ---
won_deals = df[df["deal_stage"] == "Won"]
lost_deals = df[df["deal_stage"] == "Lost"]

avg_velocity_won = won_deals["time_to_close"].mean()
avg_velocity_lost = lost_deals["time_to_close"].mean()
median_velocity_won = won_deals["time_to_close"].median()

print(f"\n--- Deal Velocity ---")
print(f"Average days to close (Won): {avg_velocity_won:.1f}")
print(f"Average days to close (Lost): {avg_velocity_lost:.1f}")
print(f"Median days to close (Won): {median_velocity_won:.1f}")

# Deal velocity by product
velocity_by_product = won_deals.groupby("product")["time_to_close"].agg(["mean", "median"]).round(1)
print("\n--- Deal Velocity by Product (Won Deals) ---")
print(velocity_by_product)

Why compare Won vs Lost velocity?

If Lost deals take significantly longer than Won deals, it means reps are spending too many days on deals that never close. That is a coaching signal: lost deals should be disqualified faster to free up time for winnable opportunities.

The median is reported alongside the mean because outlier deals (extremely long sales cycles) can skew the average. Median gives a more representative picture of the typical deal.

  • Run the cell. You should see average and median days to close for Won and Lost deals, followed by a product-level velocity table.

Seeing NaN for velocity values?

This means time_to_close was not calculated in Step 2. Go back and confirm that the date parsing cell ran successfully. The time_to_close column is only populated for closed deals (Won and Lost).

Run df["time_to_close"].isnull().sum() to check how many rows are missing values.

Still stuck? Help me figure out why time_to_close is NaN for all my deals.

Pipeline coverage ratio answers a critical question: is there enough open pipeline to hit the revenue target? The industry benchmark for a healthy pipeline is 3-4x the quarterly target.

  • In a new code cell, calculate the pipeline coverage ratio by adding this code:
# --- Pipeline Coverage Ratio ---
quarterly_revenue = won_deals.groupby("close_quarter")["close_value"].sum()
open_pipeline = df[df["deal_stage"].isin(["Prospecting", "Engaging"])]
open_pipeline_value = open_pipeline["close_value"].sum() if "close_value" in open_pipeline.columns else 0

# Use last complete quarter's revenue as target
if len(quarterly_revenue) > 0:
    last_quarter_revenue = quarterly_revenue.iloc[-1]
    if last_quarter_revenue > 0:
        coverage_ratio = open_pipeline_value / last_quarter_revenue
    else:
        coverage_ratio = 0
else:
    coverage_ratio = 0

print(f"\n--- Pipeline Coverage ---")
print(f"Open pipeline value: ${open_pipeline_value:,.0f}")
print(f"Last quarter revenue: ${last_quarter_revenue:,.0f}")
print(f"Pipeline coverage ratio: {coverage_ratio:.1f}x")

What is pipeline coverage?

Pipeline coverage compares the total dollar value of open deals against the revenue target. A ratio of 3x means you have three dollars of open pipeline for every dollar you need to close.

Since the dataset does not include an explicit quota, the code uses the last quarter's actual Won revenue as a target proxy. This is a common approach when formal targets are not available.

  • Run the cell. You should see the open pipeline dollar value, the last quarter's revenue, and the coverage ratio expressed as a multiple (e.g. 2.3x).

Coverage ratio showing 0?

Check that close_quarter was created in the data transformation step. Run df["close_quarter"].unique() to verify the column has values. If it is empty, the close_date parsing from Step 2 may have failed.

Need help? My pipeline coverage ratio is 0 even though I have open deals. Help me debug.

Revenue leakage is the headline metric for this entire analysis. It quantifies the total dollar value that leaked out of the pipeline through lost deals and stalled opportunities.

  • In a new code cell, calculate total revenue leakage by adding this code:
# --- Revenue Leakage ---
lost_revenue = lost_deals["close_value"].sum()

# Stalled deals: open deals past median cycle time
median_cycle = closed_deals["time_to_close"].median()
open_deals = df[df["deal_stage"].isin(["Prospecting", "Engaging"])].copy()
if "engage_date" in open_deals.columns:
    open_deals["days_open"] = (pd.Timestamp.now() - open_deals["engage_date"]).dt.days
    stalled = open_deals[open_deals["days_open"] > median_cycle]
    stalled_value = stalled["close_value"].sum() if "close_value" in stalled.columns else 0
else:
    stalled_value = 0

total_leakage = lost_revenue + stalled_value
total_potential = df["close_value"].sum()

print(f"\n--- Revenue Leakage ---")
print(f"Lost deal value: ${lost_revenue:,.0f}")
print(f"Stalled deal value (est.): ${stalled_value:,.0f}")
print(f"Total revenue leakage: ${total_leakage:,.0f}")
print(f"Leakage as % of total pipeline: {total_leakage / total_potential * 100:.1f}%")

What counts as revenue leakage?

  • Lost deals are deals that reached a final Lost outcome. Their close_value represents revenue that was in the pipeline but never closed.
  • Stalled deals are open deals (still in Prospecting or Engaging) that have been sitting longer than the median cycle time for closed deals. They are at high risk of never closing.
  • pd.Timestamp.now() compares each open deal's age against the median. The .copy() prevents a pandas SettingWithCopyWarning when adding the days_open column.
  • Run the cell. You should see four printed lines: lost deal value, stalled deal value, total revenue leakage, and leakage as a percentage of total pipeline value.
  • Save your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Stalled deal value showing $0?

If the stalled value is zero, the dataset may not have open deals with a close_value populated. Run open_deals["close_value"].describe() to check. Some CRM exports leave close_value blank for open deals. If that is the case, your leakage estimate will rely on the Lost deal value alone, which is still valid.

Need help? My stalled deal value is $0 and I'm not sure if that's correct. Help me investigate.

Build a KPI summary table

You now have five metrics scattered across multiple cells. A RevOps analyst always consolidates key numbers into a single reference table that leadership can scan in seconds.

  • In a new code cell, build the KPI summary DataFrame by adding this code:
# --- KPI Summary Table ---
kpi_summary = pd.DataFrame({
    "Metric": [
        "Overall Win Rate",
        "Avg Deal Velocity (Won)",
        "Median Deal Velocity (Won)",
        "Pipeline Coverage Ratio",
        "Total Revenue (Won)",
        "Lost Deal Value",
        "Stalled Deal Value (Est.)",
        "Total Revenue Leakage"
    ],
    "Value": [
        f"{overall_win_rate:.1f}%",
        f"{avg_velocity_won:.0f} days",
        f"{median_velocity_won:.0f} days",
        f"{coverage_ratio:.1f}x",
        f"${won_deals['close_value'].sum():,.0f}",
        f"${lost_revenue:,.0f}",
        f"${stalled_value:,.0f}",
        f"${total_leakage:,.0f}"
    ]
})
print("\n========== REVOPS KPI SUMMARY ==========")
print(kpi_summary.to_string(index=False))

What does this code do?

This creates a clean two-column DataFrame with every key metric and its formatted value. The f-strings handle number formatting: percentages get one decimal place, dollar values get commas, and days are rounded to whole numbers.

The .to_string(index=False) removes the row numbers for a cleaner print output. In the notebook, this table becomes the single reference point that ties all your analysis together.

  • Run the cell. You should see a formatted table with 8 rows listing each metric and its value.

NameError on one of the metric variables?

Each variable in the summary (overall_win_rate, avg_velocity_won, coverage_ratio, etc.) was defined in a previous cell in this step. Make sure you ran every cell above in order.

Click Cell, then Run All Above to re-execute all preceding cells.

Now add the analyst interpretation. This is what separates a data dump from a diagnostic analysis.

  • Add a new markdown cell below the KPI summary cell.
  • Write a short interpretation of the numbers using this structure: what the data shows, what it means for sales leadership, and the top 3 findings. Here is an example to adapt based on your actual output:
### Analyst Interpretation

The KPI summary reveals three key findings:

1. **Win rate varies significantly by product.** Some products close at nearly double the rate of others, suggesting product-market fit issues or misaligned sales strategies for underperforming lines.

2. **Lost deals take longer than Won deals.** Reps are spending valuable selling time on deals that eventually die. Faster disqualification of low-probability deals could free up capacity.

3. **Pipeline coverage may be below the 3-4x benchmark.** If the coverage ratio is under 3x, the team does not have enough open pipeline to reliably hit next quarter's target. This is a lead generation concern.

Pro tip: use your actual numbers

Replace the generic observations above with the specific numbers from your KPI summary output. A finding like "Win rate is 58.3%" is more compelling than "Win rate varies." Concrete numbers make your notebook read like a real deliverable.

  • Save your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Awesome, I've got everything!

Great. Double check you have saved your notebook and that every cell in this step ran without errors.

I'd like to double check the full code

Here is all the code from this step combined. Compare it against the cells you created in your notebook.

# ============================================================
# 4. CORE REVOPS METRICS
# ============================================================

# --- Win Rate ---
closed_deals = df[df["deal_stage"].isin(["Won", "Lost"])]
total_closed = len(closed_deals)
total_won = len(closed_deals[closed_deals["deal_stage"] == "Won"])
overall_win_rate = total_won / total_closed * 100
print(f"\nOverall Win Rate: {overall_win_rate:.1f}%")

# Win rate by product
win_rate_by_product = closed_deals.groupby("product").apply(
    lambda x: (x["deal_stage"] == "Won").sum() / len(x) * 100
).reset_index(name="win_rate_pct")
print("\n--- Win Rate by Product ---")
print(win_rate_by_product.sort_values("win_rate_pct", ascending=False))

# Win rate by sales agent
win_rate_by_agent = closed_deals.groupby("sales_agent").apply(
    lambda x: pd.Series({
        "win_rate_pct": (x["deal_stage"] == "Won").sum() / len(x) * 100,
        "total_deals": len(x),
        "total_revenue": x.loc[x["deal_stage"] == "Won", "close_value"].sum()
    })
).reset_index()
print("\n--- Top 10 Agents by Win Rate (min 20 deals) ---")
qualified_agents = win_rate_by_agent[win_rate_by_agent["total_deals"] >= 20]
print(qualified_agents.sort_values("win_rate_pct", ascending=False).head(10))

# Win rate by regional office
win_rate_by_region = closed_deals.groupby("regional_office").apply(
    lambda x: (x["deal_stage"] == "Won").sum() / len(x) * 100
).reset_index(name="win_rate_pct")
print("\n--- Win Rate by Regional Office ---")
print(win_rate_by_region)

# --- Stage Conversion Rates ---
stage_counts = df["deal_stage"].value_counts()
total_opportunities = len(df)

prospecting_count = stage_counts.get("Prospecting", 0)
engaging_count = stage_counts.get("Engaging", 0)
won_count = stage_counts.get("Won", 0)
lost_count = stage_counts.get("Lost", 0)

# Conversion rates through the funnel
prospect_to_engage = (engaging_count + won_count + lost_count) / total_opportunities * 100
engage_to_close = (won_count + lost_count) / (engaging_count + won_count + lost_count) * 100 if (engaging_count + won_count + lost_count) > 0 else 0

print(f"\n--- Stage Conversion Rates ---")
print(f"Prospecting to Engaging+: {prospect_to_engage:.1f}%")
print(f"Engaging to Closed (Won+Lost): {engage_to_close:.1f}%")
print(f"Closed Won Rate: {overall_win_rate:.1f}%")

# --- Deal Velocity ---
won_deals = df[df["deal_stage"] == "Won"]
lost_deals = df[df["deal_stage"] == "Lost"]

avg_velocity_won = won_deals["time_to_close"].mean()
avg_velocity_lost = lost_deals["time_to_close"].mean()
median_velocity_won = won_deals["time_to_close"].median()

print(f"\n--- Deal Velocity ---")
print(f"Average days to close (Won): {avg_velocity_won:.1f}")
print(f"Average days to close (Lost): {avg_velocity_lost:.1f}")
print(f"Median days to close (Won): {median_velocity_won:.1f}")

# Deal velocity by product
velocity_by_product = won_deals.groupby("product")["time_to_close"].agg(["mean", "median"]).round(1)
print("\n--- Deal Velocity by Product (Won Deals) ---")
print(velocity_by_product)

# --- Pipeline Coverage Ratio ---
quarterly_revenue = won_deals.groupby("close_quarter")["close_value"].sum()
open_pipeline = df[df["deal_stage"].isin(["Prospecting", "Engaging"])]
open_pipeline_value = open_pipeline["close_value"].sum() if "close_value" in open_pipeline.columns else 0

# Use last complete quarter's revenue as target
if len(quarterly_revenue) > 0:
    last_quarter_revenue = quarterly_revenue.iloc[-1]
    if last_quarter_revenue > 0:
        coverage_ratio = open_pipeline_value / last_quarter_revenue
    else:
        coverage_ratio = 0
else:
    coverage_ratio = 0

print(f"\n--- Pipeline Coverage ---")
print(f"Open pipeline value: ${open_pipeline_value:,.0f}")
print(f"Last quarter revenue: ${last_quarter_revenue:,.0f}")
print(f"Pipeline coverage ratio: {coverage_ratio:.1f}x")

# --- Revenue Leakage ---
lost_revenue = lost_deals["close_value"].sum()

# Stalled deals: open deals past median cycle time
median_cycle = closed_deals["time_to_close"].median()
open_deals = df[df["deal_stage"].isin(["Prospecting", "Engaging"])].copy()
if "engage_date" in open_deals.columns:
    open_deals["days_open"] = (pd.Timestamp.now() - open_deals["engage_date"]).dt.days
    stalled = open_deals[open_deals["days_open"] > median_cycle]
    stalled_value = stalled["close_value"].sum() if "close_value" in stalled.columns else 0
else:
    stalled_value = 0

total_leakage = lost_revenue + stalled_value
total_potential = df["close_value"].sum()

print(f"\n--- Revenue Leakage ---")
print(f"Lost deal value: ${lost_revenue:,.0f}")
print(f"Stalled deal value (est.): ${stalled_value:,.0f}")
print(f"Total revenue leakage: ${total_leakage:,.0f}")
print(f"Leakage as % of total pipeline: {total_leakage / total_potential * 100:.1f}%")

# --- KPI Summary Table ---
kpi_summary = pd.DataFrame({
    "Metric": [
        "Overall Win Rate",
        "Avg Deal Velocity (Won)",
        "Median Deal Velocity (Won)",
        "Pipeline Coverage Ratio",
        "Total Revenue (Won)",
        "Lost Deal Value",
        "Stalled Deal Value (Est.)",
        "Total Revenue Leakage"
    ],
    "Value": [
        f"{overall_win_rate:.1f}%",
        f"{avg_velocity_won:.0f} days",
        f"{median_velocity_won:.0f} days",
        f"{coverage_ratio:.1f}x",
        f"${won_deals['close_value'].sum():,.0f}",
        f"${lost_revenue:,.0f}",
        f"${stalled_value:,.0f}",
        f"${total_leakage:,.0f}"
    ]
})
print("\n========== REVOPS KPI SUMMARY ==========")
print(kpi_summary.to_string(index=False))

You now have every core RevOps metric calculated and consolidated into a single summary table. Next up, you will turn these numbers into diagnostic visualizations that tell a clear story about pipeline health.

Build Diagnostic Visualizations

In the previous step, you calculated the core RevOps metrics that drive pipeline decisions. Numbers in a DataFrame are useful, but a VP of Sales needs to see the story at a glance.

In this step, you will build six diagnostic visualizations using matplotlib and seaborn. Each chart targets a specific question about pipeline health, from funnel shape to rep coaching needs to revenue leakage sources.

In this step, get ready to:
  • Build a pipeline funnel chart and a stage conversion rate heatmap.
  • Create deal velocity and rep performance visualizations.
  • Build product win rate and revenue leakage summary charts with key findings.
Create the pipeline funnel and conversion heatmap

The first two charts give sales leadership an instant overview. The funnel shows where deals pile up across stages, and the heatmap reveals which products convert best.

  • Add a new code cell in your notebook and paste the following pipeline funnel chart code:
# --- Chart 1: Pipeline Funnel ---
# Count deals at each stage, ordered from top of funnel to bottom
fig, ax = plt.subplots(figsize=(10, 5))
funnel_data = df["deal_stage"].value_counts().reindex(["Prospecting", "Engaging", "Won", "Lost"])
colors = ["#4A90D9", "#5BA8C8", "#2ECC71", "#E74C3C"]

# Reverse order so Prospecting appears at the top of the horizontal bar chart
bars = ax.barh(funnel_data.index[::-1], funnel_data.values[::-1], color=colors[::-1])

# Add deal count labels to the right of each bar
for bar, val in zip(bars, funnel_data.values[::-1]):
    ax.text(bar.get_width() + 30, bar.get_y() + bar.get_height() / 2,
            f"{val:,}", va="center", fontweight="bold")

ax.set_xlabel("Number of Deals")
ax.set_title("Sales Pipeline Funnel: Deal Distribution by Stage", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("chart_pipeline_funnel.png", bbox_inches="tight")
plt.show()

What does this code do?

  • funnel_data reindexes the deal stage counts into the natural pipeline order: Prospecting, Engaging, Won, Lost.
  • The [::-1] reversal places Prospecting at the top of the horizontal bar chart so it reads like a funnel from top to bottom.
  • plt.savefig() saves a PNG copy of the chart to your project folder. This is useful for embedding in presentations or reports later.
  • Run the cell.
  • You should see a horizontal bar chart with four bars (Prospecting at top, Lost at bottom) with deal counts labeled to the right of each bar.

Chart not showing?

  • Make sure you ran all previous cells first. The chart depends on the merged df DataFrame from Step 3.
  • If you see a NameError: name 'df' is not defined, go to the top of your notebook and run every cell in order using **Cell** then **Run All Above**.
  • help me debug my pipeline funnel chart

The funnel shows the raw volume at each stage. Now let's see how conversion rates differ by product.

  • Add a new code cell below the funnel chart and paste this conversion heatmap code:
# --- Chart 2: Conversion Rate Heatmap by Product ---
# Calculate win and loss rates for each product
conversion_by_product = closed_deals.groupby("product").apply(
    lambda x: pd.Series({
        "Win Rate %": (x["deal_stage"] == "Won").sum() / len(x) * 100,
        "Loss Rate %": (x["deal_stage"] == "Lost").sum() / len(x) * 100,
        "Total Closed": len(x)
    })
).reset_index()

fig, ax = plt.subplots(figsize=(10, 5))
heatmap_data = conversion_by_product.set_index("product")[["Win Rate %", "Loss Rate %"]]

# RdYlGn color map: green for high win rates, red for high loss rates
sns.heatmap(heatmap_data, annot=True, fmt=".1f", cmap="RdYlGn", center=50,
            linewidths=0.5, ax=ax)
ax.set_title("Win/Loss Rate by Product (%)", fontsize=14, fontweight="bold")
ax.set_ylabel("")
plt.tight_layout()
plt.savefig("chart_conversion_heatmap.png", bbox_inches="tight")
plt.show()

What does this code do?

  • The groupby("product").apply() calculates win rate and loss rate percentages for each of the seven products.
  • sns.heatmap() renders a color-coded grid where green cells indicate strong win rates and red cells indicate high loss rates.
  • center=50 sets the midpoint of the color scale at 50%, so anything above 50% trends green and anything below trends red.
  • Run the cell.
  • You should see a heatmap with product names as rows and two columns: Win Rate % and Loss Rate %. Each cell shows a number with a green-to-red color gradient.

Heatmap looks blank or has missing values?

  • Make sure the closed_deals variable was created in Step 3. It filters the merged DataFrame to only Won and Lost deals.
  • If you see a FutureWarning about groupby.apply, the chart should still render correctly. You can safely ignore the warning.
  • help me debug my seaborn heatmap
Build velocity and rep performance charts

These next two charts dig deeper into the pipeline. The velocity chart reveals whether lost deals linger longer than won deals. The scatter plot identifies which sales agents are outperforming and which need coaching.

  • Add a new code cell and paste the following deal velocity box plot code:
# --- Chart 3: Deal Velocity Distribution (Won vs Lost) ---
fig, ax = plt.subplots(figsize=(10, 5))

# Filter to closed deals that have a valid time_to_close value
velocity_data = closed_deals[closed_deals["time_to_close"].notna()]

# Box plot comparing days to close for Won vs Lost outcomes
sns.boxplot(data=velocity_data, x="deal_stage", y="time_to_close",
            order=["Won", "Lost"], palette={"Won": "#2ECC71", "Lost": "#E74C3C"}, ax=ax)
ax.set_xlabel("Deal Outcome")
ax.set_ylabel("Days to Close")
ax.set_title("Deal Velocity: Won vs Lost Deals", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("chart_velocity_distribution.png", bbox_inches="tight")
plt.show()

What does this chart show?

  • A box plot shows the distribution of days-to-close for each outcome. The box spans the 25th to 75th percentile, the line inside is the median, and dots are outliers.
  • If Lost deals have a wider box or higher median, it means deals that eventually fail tend to linger in the pipeline longer. That is a signal for sales managers to cut losses earlier.
  • Run the cell.
  • You should see two side-by-side box plots, one green (Won) and one red (Lost), showing the spread of days-to-close for each outcome.

Box plot shows only one category?

  • Check that time_to_close was calculated in Step 3. It requires both engage_date and close_date to be parsed as datetime.
  • Run velocity_data["deal_stage"].value_counts() in a new cell. You should see counts for both Won and Lost.
  • help me fix my deal velocity box plot

Now let's build the rep performance scatter plot. This is the chart that sales managers care about most because it shows exactly who is delivering and who needs support.

  • Add a new code cell and paste the following rep performance scatter plot code:
# --- Chart 4: Rep Performance Scatter ---
fig, ax = plt.subplots(figsize=(12, 7))
plot_agents = qualified_agents.copy()

# Bubble size = deal count, color = win rate (green = high, red = low)
scatter = ax.scatter(
    plot_agents["win_rate_pct"],
    plot_agents["total_revenue"],
    s=plot_agents["total_deals"] * 3,
    alpha=0.6,
    c=plot_agents["win_rate_pct"],
    cmap="RdYlGn",
    edgecolors="gray",
    linewidth=0.5
)
ax.set_xlabel("Win Rate (%)")
ax.set_ylabel("Total Revenue Closed ($)")
ax.set_title("Sales Agent Performance: Win Rate vs Revenue", fontsize=14, fontweight="bold")

# Label the top 3 and bottom 3 revenue performers
top_agents = plot_agents.nlargest(3, "total_revenue")
bottom_agents = plot_agents.nsmallest(3, "total_revenue")
for _, row in pd.concat([top_agents, bottom_agents]).iterrows():
    ax.annotate(row["sales_agent"], (row["win_rate_pct"], row["total_revenue"]),
                textcoords="offset points", xytext=(5, 5), fontsize=8)
plt.tight_layout()
plt.savefig("chart_rep_performance.png", bbox_inches="tight")
plt.show()

What does this chart show?

  • Each bubble is a sales agent. The x-axis is win rate, the y-axis is total revenue closed, and bubble size reflects deal count.
  • Agents in the top-right corner (high win rate, high revenue) are star performers. Agents in the bottom-left need coaching.
  • The code labels the top 3 and bottom 3 performers by revenue so outliers are immediately identifiable.
  • Run the cell.
  • You should see a scatter plot with colored bubbles of varying sizes. Six agent names should appear as labels on the highest and lowest revenue bubbles.

Seeing a NameError for qualified_agents?

  • The qualified_agents DataFrame was created in Step 3 when you filtered agents with 20+ deals. Make sure that cell ran successfully.
  • If labels overlap and are hard to read, that is normal. The chart prioritizes showing the distribution pattern over label readability.
  • help me fix my rep performance scatter plot
Create the product and leakage summary charts

The final three outputs bring everything together. You will compare product performance, visualize where revenue leaked out, and summarize the key findings in a format a VP of Sales can act on.

  • Add a new code cell and paste the following product win rate comparison chart code:
# --- Chart 5: Product Win Rate Comparison ---
fig, ax = plt.subplots(figsize=(10, 5))

# Sort products by win rate so the highest performer appears at the top
sorted_products = win_rate_by_product.sort_values("win_rate_pct", ascending=True)
bars = ax.barh(sorted_products["product"], sorted_products["win_rate_pct"],
               color=sns.color_palette("viridis", len(sorted_products)))

# Add percentage labels to the right of each bar
for bar, val in zip(bars, sorted_products["win_rate_pct"]):
    ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height() / 2,
            f"{val:.1f}%", va="center", fontsize=10)

ax.set_xlabel("Win Rate (%)")
ax.set_title("Product Win Rate Comparison", fontsize=14, fontweight="bold")
ax.set_xlim(0, sorted_products["win_rate_pct"].max() + 10)
plt.tight_layout()
plt.savefig("chart_product_winrate.png", bbox_inches="tight")
plt.show()

What does this chart show?

  • win_rate_by_product was calculated in Step 3. This chart sorts all seven products from lowest to highest win rate so leadership can immediately see which products close well and which struggle.
  • The viridis color palette assigns darker colors to lower-performing products, creating a natural visual hierarchy.
  • Run the cell.
  • You should see a horizontal bar chart with seven product bars sorted by win rate, with percentage labels on each bar.

Seeing a NameError for win_rate_by_product?

  • This variable was created in Step 3 when you calculated win rates per product. Go back and make sure that cell ran without errors.
  • help me fix my product win rate chart

This next chart is the headline visual of the entire analysis. It answers the question every VP of Sales asks: how much revenue did we actually capture versus how much leaked out?

  • Add a new code cell and paste the following revenue leakage summary chart code:
# --- Chart 6: Revenue Leakage Summary ---
fig, ax = plt.subplots(figsize=(10, 5))

# Three categories: revenue we captured vs revenue that leaked
leakage_categories = ["Won Revenue", "Lost Deals", "Stalled Deals"]
leakage_values = [won_deals["close_value"].sum(), lost_revenue, stalled_value]
leakage_colors = ["#2ECC71", "#E74C3C", "#F39C12"]

bars = ax.bar(leakage_categories, leakage_values, color=leakage_colors, edgecolor="white", linewidth=1.5)

# Add dollar amount labels above each bar
for bar, val in zip(bars, leakage_values):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 5000,
            f"${val:,.0f}", ha="center", fontweight="bold", fontsize=11)

ax.set_ylabel("Revenue ($)")
ax.set_title("Revenue Outcome Breakdown: Won vs Leaked", fontsize=14, fontweight="bold")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"${x:,.0f}"))
plt.tight_layout()
plt.savefig("chart_revenue_leakage.png", bbox_inches="tight")
plt.show()

What does this chart show?

  • The green bar shows revenue from Won deals. The red bar shows revenue lost from deals marked Lost. The orange bar shows the estimated value of stalled deals (open deals that have exceeded the median cycle time).
  • won_deals, lost_revenue, and stalled_value were all calculated in Step 3. This chart puts them side by side so leadership can see the full revenue picture.
  • Run the cell.
  • You should see a vertical bar chart with three bars: a green Won Revenue bar, a red Lost Deals bar, and an orange Stalled Deals bar. Dollar amounts appear above each bar.

Dollar labels overlapping the chart edge?

  • This happens when the largest bar value is close to the chart boundary. The plt.tight_layout() call should handle most cases, but you can add ax.set_ylim(0, max(leakage_values) * 1.15) before plt.tight_layout() to add headroom.
  • help me fix my revenue leakage chart

All six charts are done. The final piece is a programmatic summary of the key findings that ties everything together.

  • Add a new Markdown cell (not a code cell) above the summary code. To create a Markdown cell, click the dropdown in the toolbar that says **Code** and change it to **Markdown**.
  • Type the following into the Markdown cell:
## Key Findings

The five most actionable insights from this pipeline diagnosis, based on the metrics and visualizations above.
  • Run the Markdown cell by pressing **Shift+Enter**. It renders as formatted text.
  • Add a new code cell directly below the Markdown cell and paste the following summary code:
# --- Final Summary: Print key findings programmatically ---
print("\n" + "=" * 60)
print("REVENUE LEAKAGE DIAGNOSIS: KEY FINDINGS")
print("=" * 60)
print(f"""
1. Overall win rate is {overall_win_rate:.1f}%, meaning roughly
   {100 - overall_win_rate:.0f}% of closed deals are lost.

2. Average deal velocity for Won deals is {avg_velocity_won:.0f} days.
   Lost deals average {avg_velocity_lost:.0f} days before being marked lost.

3. Total revenue leakage (lost + stalled) is ${total_leakage:,.0f},
   representing {total_leakage / total_potential * 100:.1f}% of total pipeline value.

4. Pipeline coverage ratio is {coverage_ratio:.1f}x against last quarter's
   revenue. Industry benchmark is 3-4x for healthy pipelines.

5. Top-performing product by win rate:
   {win_rate_by_product.loc[win_rate_by_product['win_rate_pct'].idxmax(), 'product']}
   at {win_rate_by_product['win_rate_pct'].max():.1f}%
""")

What does this summary do?

  • The f-string pulls actual values from the variables you calculated in Step 3. None of the numbers are hardcoded. If the underlying data changes, the summary updates automatically.
  • The five findings follow a structure that RevOps analysts use when presenting to leadership: state the metric, state the implication, and (where relevant) compare to a benchmark.
  • Run the cell.
  • You should see a formatted text summary with five numbered findings, each populated with actual values from your analysis.
  • Save your notebook by pressing **Cmd+S** (macOS) or **Ctrl+S** (Windows).

Seeing a NameError in the summary?

  • The summary references variables from Step 3: overall_win_rate, avg_velocity_won, avg_velocity_lost, total_leakage, total_potential, coverage_ratio, and win_rate_by_product. Make sure all Step 3 cells ran successfully before running this cell.
  • The safest way to confirm is to go to **Cell** then **Run All** to rerun the entire notebook from the top.
  • help me debug my key findings summary cell

Awesome, I've got everything!

Double check that you have saved your notebook. You should have six charts rendered inline and a five-point key findings summary at the bottom.

I'd like to double check the full code

Here is all the visualization code you should have in your notebook for this step. Each block below corresponds to a separate code cell.

# --- Chart 1: Pipeline Funnel ---
fig, ax = plt.subplots(figsize=(10, 5))
funnel_data = df["deal_stage"].value_counts().reindex(["Prospecting", "Engaging", "Won", "Lost"])
colors = ["#4A90D9", "#5BA8C8", "#2ECC71", "#E74C3C"]
bars = ax.barh(funnel_data.index[::-1], funnel_data.values[::-1], color=colors[::-1])
for bar, val in zip(bars, funnel_data.values[::-1]):
    ax.text(bar.get_width() + 30, bar.get_y() + bar.get_height() / 2,
            f"{val:,}", va="center", fontweight="bold")
ax.set_xlabel("Number of Deals")
ax.set_title("Sales Pipeline Funnel: Deal Distribution by Stage", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("chart_pipeline_funnel.png", bbox_inches="tight")
plt.show()
# --- Chart 2: Conversion Rate Heatmap by Product ---
conversion_by_product = closed_deals.groupby("product").apply(
    lambda x: pd.Series({
        "Win Rate %": (x["deal_stage"] == "Won").sum() / len(x) * 100,
        "Loss Rate %": (x["deal_stage"] == "Lost").sum() / len(x) * 100,
        "Total Closed": len(x)
    })
).reset_index()

fig, ax = plt.subplots(figsize=(10, 5))
heatmap_data = conversion_by_product.set_index("product")[["Win Rate %", "Loss Rate %"]]
sns.heatmap(heatmap_data, annot=True, fmt=".1f", cmap="RdYlGn", center=50,
            linewidths=0.5, ax=ax)
ax.set_title("Win/Loss Rate by Product (%)", fontsize=14, fontweight="bold")
ax.set_ylabel("")
plt.tight_layout()
plt.savefig("chart_conversion_heatmap.png", bbox_inches="tight")
plt.show()
# --- Chart 3: Deal Velocity Distribution (Won vs Lost) ---
fig, ax = plt.subplots(figsize=(10, 5))
velocity_data = closed_deals[closed_deals["time_to_close"].notna()]
sns.boxplot(data=velocity_data, x="deal_stage", y="time_to_close",
            order=["Won", "Lost"], palette={"Won": "#2ECC71", "Lost": "#E74C3C"}, ax=ax)
ax.set_xlabel("Deal Outcome")
ax.set_ylabel("Days to Close")
ax.set_title("Deal Velocity: Won vs Lost Deals", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("chart_velocity_distribution.png", bbox_inches="tight")
plt.show()
# --- Chart 4: Rep Performance Scatter ---
fig, ax = plt.subplots(figsize=(12, 7))
plot_agents = qualified_agents.copy()
scatter = ax.scatter(
    plot_agents["win_rate_pct"],
    plot_agents["total_revenue"],
    s=plot_agents["total_deals"] * 3,
    alpha=0.6,
    c=plot_agents["win_rate_pct"],
    cmap="RdYlGn",
    edgecolors="gray",
    linewidth=0.5
)
ax.set_xlabel("Win Rate (%)")
ax.set_ylabel("Total Revenue Closed ($)")
ax.set_title("Sales Agent Performance: Win Rate vs Revenue", fontsize=14, fontweight="bold")

top_agents = plot_agents.nlargest(3, "total_revenue")
bottom_agents = plot_agents.nsmallest(3, "total_revenue")
for _, row in pd.concat([top_agents, bottom_agents]).iterrows():
    ax.annotate(row["sales_agent"], (row["win_rate_pct"], row["total_revenue"]),
                textcoords="offset points", xytext=(5, 5), fontsize=8)
plt.tight_layout()
plt.savefig("chart_rep_performance.png", bbox_inches="tight")
plt.show()
# --- Chart 5: Product Win Rate Comparison ---
fig, ax = plt.subplots(figsize=(10, 5))
sorted_products = win_rate_by_product.sort_values("win_rate_pct", ascending=True)
bars = ax.barh(sorted_products["product"], sorted_products["win_rate_pct"],
               color=sns.color_palette("viridis", len(sorted_products)))
for bar, val in zip(bars, sorted_products["win_rate_pct"]):
    ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height() / 2,
            f"{val:.1f}%", va="center", fontsize=10)
ax.set_xlabel("Win Rate (%)")
ax.set_title("Product Win Rate Comparison", fontsize=14, fontweight="bold")
ax.set_xlim(0, sorted_products["win_rate_pct"].max() + 10)
plt.tight_layout()
plt.savefig("chart_product_winrate.png", bbox_inches="tight")
plt.show()
# --- Chart 6: Revenue Leakage Summary ---
fig, ax = plt.subplots(figsize=(10, 5))
leakage_categories = ["Won Revenue", "Lost Deals", "Stalled Deals"]
leakage_values = [won_deals["close_value"].sum(), lost_revenue, stalled_value]
leakage_colors = ["#2ECC71", "#E74C3C", "#F39C12"]
bars = ax.bar(leakage_categories, leakage_values, color=leakage_colors, edgecolor="white", linewidth=1.5)
for bar, val in zip(bars, leakage_values):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 5000,
            f"${val:,.0f}", ha="center", fontweight="bold", fontsize=11)
ax.set_ylabel("Revenue ($)")
ax.set_title("Revenue Outcome Breakdown: Won vs Leaked", fontsize=14, fontweight="bold")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"${x:,.0f}"))
plt.tight_layout()
plt.savefig("chart_revenue_leakage.png", bbox_inches="tight")
plt.show()
# --- Final Summary ---
print("\n" + "=" * 60)
print("REVENUE LEAKAGE DIAGNOSIS: KEY FINDINGS")
print("=" * 60)
print(f"""
1. Overall win rate is {overall_win_rate:.1f}%, meaning roughly
   {100 - overall_win_rate:.0f}% of closed deals are lost.

2. Average deal velocity for Won deals is {avg_velocity_won:.0f} days.
   Lost deals average {avg_velocity_lost:.0f} days before being marked lost.

3. Total revenue leakage (lost + stalled) is ${total_leakage:,.0f},
   representing {total_leakage / total_potential * 100:.1f}% of total pipeline value.

4. Pipeline coverage ratio is {coverage_ratio:.1f}x against last quarter's
   revenue. Industry benchmark is 3-4x for healthy pipelines.

5. Top-performing product by win rate:
   {win_rate_by_product.loc[win_rate_by_product['win_rate_pct'].idxmax(), 'product']}
   at {win_rate_by_product['win_rate_pct'].max():.1f}%
""")

Your analysis now has six publication-quality charts and a programmatic summary of key findings. Next up, you will polish the notebook with professional markdown narrative and prepare it for GitHub.

Polish for GitHub and Write the Narrative

Your visualizations are built, your metrics are calculated, and MavenTech's pipeline problems are clearly visible in the data. The analysis work is done.

A notebook that only shows code and charts is not a portfolio piece. What separates a professional RevOps analysis from a homework assignment is the narrative layer that explains each finding to a non-technical reader. In this step, you will add a polished title cell, write storytelling Markdown throughout the notebook, and publish it to GitHub with a professional README.

In this step, get ready to:
  • Add a professional title cell with an executive summary and table of contents.
  • Write narrative markdown using the Context-Finding-Implication pattern.
  • Verify clean execution and create a README.md for the GitHub repository.
Add a title cell and executive summary

When someone opens your notebook on GitHub, the first thing they see is the top cell. A professional title cell tells a hiring manager exactly what this project is, who built it, and what the key takeaway is before they scroll a single line of code.

  • In your Jupyter Notebook, click on the very first cell in the notebook.
  • Click Insert in the menu bar, then select Insert Cell Above.
  • Change the new cell's type from Code to Markdown using the dropdown in the toolbar.
  • Paste the following markdown into the cell:
# Revenue Leakage Diagnosis: B2B Sales Pipeline Analysis

**Author:** [[YOURNAME="enter your name"]]  
**Date:** April 2026  
**Data Source:** [Maven Analytics CRM Sales Opportunities](https://mavenanalytics.io/data-playground) (Public Domain)

## Executive Summary

This analysis diagnoses revenue leakage in MavenTech's B2B sales pipeline
using 8,800+ CRM records across four related tables. The key finding is
that a significant share of total pipeline value is lost through deal
abandonment and stalled opportunities. The analysis identifies specific
products, agents, and pipeline stages where targeted intervention could
recover the most revenue.

## Table of Contents

1. [Setup and Imports](#setup-and-imports)
2. [Load and Explore Raw CRM Data](#load-and-explore-raw-crm-data)
3. [Data Quality Checks](#data-quality-checks)
4. [Data Transformation and Merging](#data-transformation-and-merging)
5. [Core RevOps Metrics](#core-revops-metrics)
6. [Diagnostic Visualizations](#diagnostic-visualizations)
7. [Key Findings and Recommendations](#key-findings-and-recommendations)

What does this cell include?

  • The # heading renders as a large title that GitHub displays prominently.
  • The author line, date, and data source give immediate credibility and attribution.
  • The executive summary gives a 2-3 sentence overview so a reader knows the key finding without scrolling further.
  • The table of contents uses Markdown anchor links to let readers jump straight to any section.
  • Replace the placeholder name with your actual name.
  • Update the executive summary sentences with the actual numbers from your analysis. Use the KPI summary you built in Step 3 to pull the real leakage percentage and top finding.
  • Run the cell by pressing Shift+Enter.

You should see a cleanly rendered title, your name, the data source link, and a clickable table of contents.

Table of contents links not working?

Anchor links in Jupyter only work if the target headings exist as markdown cells further down in the notebook. Make sure each section of your notebook has a markdown heading that matches the anchor text (for example, ## Setup and Imports for the first link). Jupyter converts headings to lowercase anchors with hyphens replacing spaces.

If your links still do not scroll to the right cell, help me fix my Jupyter Notebook table of contents anchor links.

Pro tip: personalize the executive summary

Hiring managers skim portfolios fast. Replace generic placeholder sentences with the actual dollar amounts and percentages from your KPI summary table. A sentence like "$2.1M in pipeline value is at risk from stalled deals alone" is far more compelling than "a significant share of value is lost."

Write narrative using Context-Finding-Implication

Raw code output does not tell a story. A VP of Sales does not want to see a DataFrame printout. They want to know what the number means and what to do about it.

Professional RevOps analysts structure every finding with three parts: Context, Finding, and Implication. You will now add this narrative pattern to each analysis section of your notebook.

What is Context-Finding-Implication?

  • Context explains why this metric matters in RevOps. Example: "Pipeline coverage ratio tells us whether we have enough open deals to hit next quarter's target."
  • Finding states what the data actually shows. Example: "Our coverage ratio is 1.8x, well below the 3-4x industry benchmark."
  • Implication tells a sales leader what to do about it. Example: "The sales team needs to generate 2x more qualified pipeline in the next 30 days to avoid a revenue shortfall."
  • Scroll through your notebook from top to bottom.
  • For each major analysis section (win rate, stage conversions, deal velocity, pipeline coverage, revenue leakage, and each visualization), add a markdown cell above the code with one sentence of Context.
  • Add a markdown cell below the code output with one sentence of Finding and one sentence of Implication.

What does a good narrative cell look like?

Here is an example for the win rate section:

Before the code: "Win rate is the most fundamental health metric for a sales pipeline. It tells us what percentage of deals that reach a decision point actually close successfully."

After the output: "MavenTech's overall win rate is X%, with GTX products significantly outperforming the rest of the portfolio. Sales leadership should investigate whether the lower-performing products have a positioning problem or a qualification problem."

  • Delete any scratch or exploratory code cells that do not contribute to the final narrative. If you tested something that did not make it into the analysis, remove it.
  • Verify every chart has a descriptive title, labeled axes, and a legend where applicable. The charts you built in Step 4 already include these, but double-check nothing was lost during edits.

Your notebook should now read like a report, not a code dump. A reader can follow the story without understanding Python.

Not sure if your narrative is strong enough?

Read each markdown cell out loud. If it sounds like a code comment ("This calculates win rate"), rewrite it as a business insight ("Win rate reveals how efficiently the team converts qualified opportunities into revenue"). Every sentence should answer "so what?" for a non-technical reader.

help me improve the narrative markdown in my RevOps notebook so it follows the Context-Finding-Implication pattern.

Verify clean execution and create a README

A notebook that errors halfway through is not a portfolio piece. Before publishing, you need to confirm the entire notebook runs cleanly from a fresh kernel state.

  • In Jupyter Notebook, click Kernel in the menu bar.
  • Select Restart & Run All.
  • Click Restart and Run All Cells in the confirmation dialog.
  • Wait for every cell to finish executing. Each cell number in the left margin should show a number (like [1], [2]) instead of an asterisk ([*]).
  • Scroll through the entire notebook and confirm all charts render and no cells show error output.

Seeing an error during Restart & Run All?

The most common cause is a cell that depends on a variable defined in a cell you deleted during cleanup. Check the error traceback for the variable name, then find where it was originally defined. Either restore that cell or move the definition to an earlier cell.

Another common issue is cells that were run out of order during development. Restart & Run All runs cells top-to-bottom, so any cell that references something defined below it will fail.

help me debug my Jupyter Notebook so it runs cleanly from top to bottom with Restart and Run All.

Once the notebook runs cleanly, save it with Cmd+S (macOS) or Ctrl+S (Windows). Now create the README file that will sit alongside the notebook in your GitHub repository.

  • Create a new file called README.md in the same project folder as your notebook (not inside the data/ subfolder). Open it in any text editor.
  • Paste the following content into README.md:
# Revenue Leakage Diagnosis: B2B Sales Pipeline Analysis

A RevOps data analysis project that diagnoses where revenue leaks out of a B2B sales pipeline.
Built with Python, pandas, matplotlib, and seaborn.

## Overview

This project analyzes CRM sales pipeline data from MavenTech, a fictitious B2B computer
hardware company. The analysis identifies revenue leakage patterns, calculates core RevOps
metrics, and produces diagnostic visualizations that a VP of Sales would use to make
pipeline decisions.

## Dataset

**Source**: [Maven Analytics CRM Sales Opportunities](https://mavenanalytics.io/data-playground/crm-sales-opportunities)
**License**: Public Domain
**Size**: 8,800+ records across 4 related tables (accounts, products, sales teams, pipeline)

## Key Metrics Calculated

- Win Rate (overall and segmented by product/agent/region)
- Stage Conversion Rates (Prospecting to Engaging to Won)
- Deal Velocity (average and median days to close)
- Pipeline Coverage Ratio
- Revenue Leakage (lost deals + stalled deals)

## Key Findings

[Your findings will go here after running the analysis]

## How to Run

1. Install dependencies: `pip install pandas matplotlib seaborn jupyter`
2. Download the dataset from Maven Analytics and extract to `data/`
3. Open `revops_pipeline_analysis.ipynb` in Jupyter Notebook
4. Run all cells (Cell > Run All)

## Tools Used

- Python 3
- pandas (data wrangling)
- matplotlib + seaborn (visualization)
- Jupyter Notebook (interactive analysis)

What does this README do?

  • The Overview section tells a visitor what the project is about in plain language.
  • The Dataset section credits the data source and links to the original download page.
  • The How to Run section lets anyone reproduce your analysis from scratch.
  • The Key Findings section is a placeholder for now. You will fill it in with your top 3-5 findings after the full analysis run.
  • Replace the [Your findings will go here after running the analysis] placeholder with your actual top 3-5 findings. Use the numbers from your KPI summary and key findings cells.
  • Save README.md.

Open the project folder in your file browser. You should see README.md, revops_pipeline_analysis.ipynb, and the data/ subfolder side by side.

README not rendering on GitHub?

GitHub only renders README.md automatically if the file is named exactly README.md (case-sensitive) and sits in the root of the repository. Check that the file is not inside the data/ folder.

help me fix my README.md so it renders correctly on my GitHub repository page.

Awesome, I've got everything!

Make sure you have saved both your notebook and your README.md file.

I'd like to double check the full code

Here is the complete README.md for comparison:

# Revenue Leakage Diagnosis: B2B Sales Pipeline Analysis

A RevOps data analysis project that diagnoses where revenue leaks out of a B2B sales pipeline.
Built with Python, pandas, matplotlib, and seaborn.

## Overview

This project analyzes CRM sales pipeline data from MavenTech, a fictitious B2B computer
hardware company. The analysis identifies revenue leakage patterns, calculates core RevOps
metrics, and produces diagnostic visualizations that a VP of Sales would use to make
pipeline decisions.

## Dataset

**Source**: [Maven Analytics CRM Sales Opportunities](https://mavenanalytics.io/data-playground/crm-sales-opportunities)
**License**: Public Domain
**Size**: 8,800+ records across 4 related tables (accounts, products, sales teams, pipeline)

## Key Metrics Calculated

- Win Rate (overall and segmented by product/agent/region)
- Stage Conversion Rates (Prospecting to Engaging to Won)
- Deal Velocity (average and median days to close)
- Pipeline Coverage Ratio
- Revenue Leakage (lost deals + stalled deals)

## Key Findings

[Your findings will go here after running the analysis]

## How to Run

1. Install dependencies: `pip install pandas matplotlib seaborn jupyter`
2. Download the dataset from Maven Analytics and extract to `data/`
3. Open `revops_pipeline_analysis.ipynb` in Jupyter Notebook
4. Run all cells (Cell > Run All)

## Tools Used

- Python 3
- pandas (data wrangling)
- matplotlib + seaborn (visualization)
- Jupyter Notebook (interactive analysis)

Secret mission

Quarterly Trend Analysis and Executive Briefing

Your analysis so far captures a static snapshot of the pipeline. Real RevOps analysts track how metrics shift over time to spot trends before they become problems. Build a quarterly trend layer and generate a programmatic executive briefing that pulls live numbers from your analysis.

Before you go

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:

  • Python 3 installation.
  • Jupyter Notebook and Python libraries (pandas, matplotlib, seaborn, numpy).
  • Project folder containing revops_pipeline_analysis.ipynb and README.md.
  • The data/ subfolder with four CSV files (sales_pipeline.csv, accounts.csv, products.csv, sales_teams.csv).
  • Saved chart PNG files (chart_pipeline_funnel.png, chart_conversion_heatmap.png, etc.).

Keep everything running

No action needed. Choose this if you're still actively building on your analysis or plan to push it to GitHub soon.

  • Your project folder, notebook, data files, and chart images will remain on your local machine.
  • If you left Jupyter Notebook running in your terminal, you can close the browser tab and press Ctrl + C in the terminal to stop the server when you're done.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files stay on disk so you can pick up where you left off.

  • In Jupyter Notebook, click File, then Save and Checkpoint to save your progress.
  • Close the Jupyter browser tab.
  • In your terminal, press Ctrl + C to stop the Jupyter server.
  • To resume later, open a terminal in your project folder and run jupyter notebook again.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Jupyter server by pressing Ctrl + C in your terminal.
  • Delete the entire project folder (this removes the notebook, data files, and chart images).
  • Optionally, uninstall the Python libraries you installed for this project.
pip uninstall pandas matplotlib seaborn jupyter -y
  • Keep your Python installation if you plan to use Python for other projects.

Mission accomplished

Nice Work!

You just built a professional-grade RevOps pipeline diagnosis from raw CRM data. That notebook on your GitHub now demonstrates the exact analytical workflow that separates a RevOps analyst from a basic reporting role.

You've learned how to:

  • Merge four related CRM tables into an analysis-ready dataset with 8,800+ sales records using pandas.
  • Calculate the five core RevOps metrics that analysts track weekly: pipeline coverage, stage conversion rates, deal velocity, win rate, and revenue leakage.
  • Build six diagnostic visualizations with professional markdown narrative that follows the Context-Finding-Implication pattern used by top RevOps analysts.
  • Add a quarterly trend analysis and a programmatic executive briefing that pulls live numbers from the analysis.

Ready to quiz yourself?