Probability-Probability (P-P) Plot — Plotly

A diagnostic plot comparing the cumulative distribution function (CDF) of observed data against a theoretical distribution by plotting empirical CDF values against theoretical CDF values. Unlike Q-Q plots which compare quantiles, P-P plots compare cumulative probabilities on both axes (0 to 1), making them more sensitive to deviations in the center of the distribution. Points falling along the 45-degree diagonal indicate a good fit.

Probability-Probability (P-P) Plot rendered with Plotly

Python source (Plotly)

""" anyplot.ai
pp-basic: Probability-Probability (P-P) Plot
Library: plotly 6.8.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-06-16
"""

import os

import numpy as np
import plotly.graph_objects as go
from scipy import stats


THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-adaptive chrome
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Imprint palette
BRAND = "#009E73"  # brand green — within-range points (first categorical series)
DEVIATION = "#AE3030"  # matte red — semantic anchor for deviation / poor fit
NEUTRAL = INK  # totals / baseline / reference line (theme-adaptive)
MUTED = INK_MUTED  # confidence-band fill (theme-adaptive)

# Data: mixture of normal + exponential simulating process drift
np.random.seed(42)
observed = np.random.normal(loc=50, scale=10, size=200) + np.random.exponential(scale=2, size=200)

observed_sorted = np.sort(observed)
n = len(observed_sorted)

# Fit normal distribution to observed data
mu, sigma = stats.norm.fit(observed_sorted)

# Empirical CDF using plotting position formula i/(n+1)
empirical_cdf = np.arange(1, n + 1) / (n + 1)

# Theoretical CDF from fitted normal
theoretical_cdf = stats.norm.cdf(observed_sorted, loc=mu, scale=sigma)

# Deviation threshold for storytelling — confine flagged points to the genuine
# right tail (theoretical CDF > 0.8), where the added exponential mass makes the
# data depart from the fitted normal. This keeps the red diamonds in the tail
# region the annotation describes, rather than the distribution body.
deviation = np.abs(empirical_cdf - theoretical_cdf)
in_right_tail = theoretical_cdf > 0.8
threshold = np.percentile(deviation, 75)
is_deviant = in_right_tail & (deviation > threshold)

# 95% confidence band (Kolmogorov-Smirnov)
ks_band = 1.36 / np.sqrt(n)
diag = np.linspace(0, 1, 200)

# Plot
fig = go.Figure()

# Confidence band
fig.add_trace(
    go.Scatter(
        x=np.concatenate([diag, diag[::-1]]),
        y=np.concatenate([diag + ks_band, (diag - ks_band)[::-1]]),
        fill="toself",
        fillcolor="rgba(107,106,99,0.12)" if THEME == "light" else "rgba(168,167,159,0.12)",
        line={"width": 0},
        name="95% KS confidence band",
        hoverinfo="skip",
    )
)

# Reference line (perfect fit = structural baseline → neutral)
fig.add_trace(
    go.Scatter(
        x=[0, 1],
        y=[0, 1],
        mode="lines",
        line={"color": NEUTRAL, "width": 2.5, "dash": "dash"},
        name="Perfect normal fit",
        hoverinfo="skip",
    )
)

# Data points: within expected range
fig.add_trace(
    go.Scatter(
        x=theoretical_cdf[~is_deviant],
        y=empirical_cdf[~is_deviant],
        mode="markers",
        marker={"size": 6.5, "color": BRAND, "opacity": 0.6, "line": {"width": 0.6, "color": PAGE_BG}},
        name="Within expected range",
        customdata=np.column_stack([observed_sorted[~is_deviant], deviation[~is_deviant]]),
        hovertemplate=(
            "Theoretical: %{x:.3f}<br>"
            "Empirical: %{y:.3f}<br>"
            "Value: %{customdata[0]:.1f}<br>"
            "Deviation: %{customdata[1]:.3f}"
            "<extra></extra>"
        ),
    )
)

# Data points: tail deviations
fig.add_trace(
    go.Scatter(
        x=theoretical_cdf[is_deviant],
        y=empirical_cdf[is_deviant],
        mode="markers",
        marker={
            "size": 11,
            "color": DEVIATION,
            "symbol": "diamond",
            "opacity": 0.9,
            "line": {"width": 0.8, "color": PAGE_BG},
        },
        name="Tail deviation",
        customdata=np.column_stack([observed_sorted[is_deviant], deviation[is_deviant]]),
        hovertemplate=(
            "Theoretical: %{x:.3f}<br>"
            "Empirical: %{y:.3f}<br>"
            "Value: %{customdata[0]:.1f}<br>"
            "Deviation: %{customdata[1]:.3f}"
            "<extra></extra>"
        ),
    )
)

# Annotation pointing to right-tail deviation
tail_idx = np.where(is_deviant & (theoretical_cdf > 0.8))[0]
if len(tail_idx) > 0:
    ax_pt = theoretical_cdf[tail_idx[len(tail_idx) // 2]]
    ay_pt = empirical_cdf[tail_idx[len(tail_idx) // 2]]
    fig.add_annotation(
        x=ax_pt,
        y=ay_pt,
        ax=-55,
        ay=-45,
        text="Heavier right tail<br>→ process drift detected",
        showarrow=True,
        arrowhead=2,
        arrowsize=1.2,
        arrowwidth=1.6,
        arrowcolor=DEVIATION,
        font={"size": 11, "color": INK_SOFT},
        bgcolor=ELEVATED_BG,
        bordercolor=DEVIATION,
        borderwidth=1.2,
        borderpad=5,
    )

# Layout
fig.update_layout(
    autosize=False,
    width=600,
    height=600,
    margin={"l": 70, "r": 40, "t": 80, "b": 60},
    title={
        "text": "pp-basic · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.97,
    },
    xaxis={
        "title": {"text": "Theoretical CDF (Normal)", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "range": [-0.02, 1.02],
        "showgrid": False,
        "showline": True,
        "linecolor": INK_SOFT,
        "linewidth": 1.2,
        "zeroline": False,
        "ticks": "outside",
        "tickcolor": INK_SOFT,
        "dtick": 0.2,
    },
    yaxis={
        "title": {"text": "Empirical CDF", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "range": [-0.02, 1.02],
        "showgrid": False,
        "showline": True,
        "linecolor": INK_SOFT,
        "linewidth": 1.2,
        "zeroline": False,
        "ticks": "outside",
        "tickcolor": INK_SOFT,
        "dtick": 0.2,
        "scaleanchor": "x",
        "scaleratio": 1,
    },
    legend={
        "x": 0.02,
        "y": 0.98,
        "xanchor": "left",
        "yanchor": "top",
        "font": {"size": 10, "color": INK_SOFT},
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
    },
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
)

# Save — square canvas: 600 × 600 × scale 4 = 2400 × 2400
fig.write_image(f"plot-{THEME}.png", width=600, height=600, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Probability-Probability (P-P) Plot on anyplot.ai.

Other implementations