Bland-Altman plot
original 1986 peak-flow data (two meters)
Example from the compendium of canonical charts
Python Code
"""Bland-Altman plot — original 1986 peak-flow data (two meters)."""
from pathlib import Path
# ── Palette + theme (matching the Plotly Studio gallery these charts ship in) ──
VIOLET, TEAL, GREEN, PINK, ORANGE = "#845EEE", "#52B3D0", "#55B685", "#DA5597", "#E9A23B"
PRIMARY, SECONDARY = VIOLET, TEAL
COLORWAY = [VIOLET, TEAL, GREEN, PINK, ORANGE]
BG, TEXT, GRID, MUTED = "#ffffff", "#1c2024", "#d9d9e0", "#60646c"
FONT = "Inter, -apple-system, BlinkMacSystemFont, sans-serif"
COLORSCALE = [[0, "rgba(132, 94, 238, 0.05)"], [1, "rgba(132, 94, 238, 0.9)"]]
def apply_theme(fig):
"""Light gallery theme: white background, Inter font, soft gridlines."""
fig.update_layout(
paper_bgcolor=BG, plot_bgcolor=BG, colorway=COLORWAY,
font=dict(family=FONT, color=TEXT, size=12),
legend=dict(font=dict(color=TEXT)),
hoverlabel=dict(bgcolor="#f0f0f3", font=dict(color=TEXT, family=FONT), bordercolor=GRID),
)
fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
def fetch_csv(url, **kwargs):
import io
import pandas as pd
import requests
r = requests.get(url, timeout=60)
r.raise_for_status()
return pd.read_csv(io.StringIO(r.text), **kwargs)
def fetch_json(url):
import requests
r = requests.get(url, timeout=60)
r.raise_for_status()
return r.json()
import numpy as np
import plotly.graph_objects as go
import requests, warnings, io
URL = "https://raw.githubusercontent.com/OxfordIHTM/teaching_datasets/main/ba.dat"
def generate():
print("fetching Bland-Altman peak-flow data …")
warnings.filterwarnings("ignore")
requests.packages.urllib3.disable_warnings()
r = requests.get(URL, verify=False, timeout=30)
r.raise_for_status()
lines = r.text.strip().split("\n")
print(f" first 3 lines: {lines[:3]}")
# First line is a header/label; data rows follow
data_lines = [l for l in lines[1:] if l.strip()]
data = []
for line in data_lines:
parts = line.split()
if len(parts) >= 2:
try:
data.append((float(parts[0]), float(parts[1])))
except ValueError:
pass
if len(data) == 0:
# Try reading whole file with pandas
import pandas as pd
df = pd.read_csv(io.StringIO(r.text), sep=r"\s+", header=None, skiprows=1)
data = [(row.iloc[0], row.iloc[1]) for _, row in df.iterrows()
if len(df.columns) >= 2]
print(f" parsed {len(data)} pairs")
m1 = np.array([d[0] for d in data])
m2 = np.array([d[1] for d in data])
means = (m1 + m2) / 2
diffs = m1 - m2
bias = np.mean(diffs)
sd_diff = np.std(diffs, ddof=1)
loa_hi = bias + 1.96 * sd_diff
loa_lo = bias - 1.96 * sd_diff
fig = go.Figure()
# Scatter
fig.add_trace(go.Scatter(
x=means, y=diffs,
mode="markers",
marker=dict(color=VIOLET, size=8, opacity=0.8),
name="Subject",
hovertemplate="Mean=%{x:.0f}<br>Difference=%{y:.0f}<extra></extra>",
))
# Horizontal reference lines
for val, label, color, dash in [
(loa_hi, f"+1.96 SD = {loa_hi:.1f}", PINK, "dash"),
(bias, f"Bias = {bias:.1f}", VIOLET, "solid"),
(loa_lo, f"−1.96 SD = {loa_lo:.1f}", PINK, "dash"),
]:
fig.add_hline(
y=val,
line=dict(color=color, width=1.5, dash=dash),
annotation_text=label,
annotation_position="right",
annotation_font=dict(size=10),
)
fig.update_layout(
title=dict(text="Bland-Altman Plot — Peak Flow (two meters, L/min)", x=0.5),
xaxis=dict(title="Mean of two measurements (L/min)"),
yaxis=dict(title="Difference (meter 1 − meter 2, L/min)"),
margin=dict(t=60, b=50, l=70, r=160),
height=440,
)
apply_theme(fig)
return fig
fig = generate()
fig.show()
Made with Plotly