import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import plotly.graph_objects as goRadar Plot (Python)
The Python counterpart of Radar/Spider Plot โ a radar (spider, web) chart draws one or more series as closed polygons over \(p\) spokes, one per variable, so multivariate profiles can be compared at a glance. In biomedical work it is the standard summary for immune-signature panels [1], tumor functional assessments, and patient-reported outcome profiles, where the shape of the profile matters as much as any single value.
matplotlib builds the chart on a polar axis; plotly.graph_objects Scatterpolar adds an interactive variant for exploratory dashboards [2]. The one trap that ruins real analyses: radar axes must share a comparable scale. When variables live on different ranges (a fraction vs a 0-100 score), raw-value polygons distort every shape โ normalize per axis first.
This tutorial covers three workflows:
- A hand-built single-series radar with matplotlibโs polar projection;
- A multi-series radar with per-axis 0-1 normalization;
- An interactive
plotlyradar for dashboard use.
Example

Setup
- System Requirements: Cross-platform (Linux/MacOS/Windows)
- Programming Language: Python
- Dependencies:
matplotlib,numpy,pandas,plotly
Data Preparation
We simulate an immune-deconvolution panel [1]: signature scores (0-1) for six immune cell types across three tumor immune subtypes. Immune-hot tumors score high on adaptive and NK markers, immune-cold tumors score uniformly low, and fibrotic tumors are myeloid-skewed โ three distinct shapes a radar should make obvious.
rng = np.random.default_rng(42)
cells = ["CD8 T cell", "CD4 T cell", "B cell", "NK cell", "Macrophage", "Dendritic"]
subtypes = ["Immune-hot", "Immune-cold", "Fibrotic"]
base = np.array([
[0.85, 0.55, 0.30, 0.70, 0.60, 0.45], # immune-hot: adaptive + NK high
[0.25, 0.30, 0.15, 0.20, 0.35, 0.20], # immune-cold: uniformly low
[0.45, 0.50, 0.40, 0.25, 0.85, 0.70], # fibrotic: myeloid-skewed
])
scores = pd.DataFrame(base + rng.normal(0, 0.05, base.shape),
index=subtypes, columns=cells).clip(lower=0, upper=1)
# Polar scaffolding: one spoke per cell type, polygon closed by repeating
# the first angle/value at the end.
angles = [n / len(cells) * 2 * np.pi for n in range(len(cells))]
angles += angles[:1]
scores.head()| CD8 T cell | CD4 T cell | B cell | NK cell | Macrophage | Dendritic | |
|---|---|---|---|---|---|---|
| Immune-hot | 0.865236 | 0.498001 | 0.337523 | 0.747028 | 0.502448 | 0.384891 |
| Immune-cold | 0.256392 | 0.284188 | 0.149160 | 0.157348 | 0.393970 | 0.238890 |
| Fibrotic | 0.453302 | 0.556362 | 0.423375 | 0.207035 | 0.868438 | 0.652056 |
Visualization
1. Single-Series Radar
values = scores.loc["Immune-hot"].tolist()
values += values[:1]
fig, ax = plt.subplots(figsize=(5.5, 5.2), subplot_kw={"projection": "polar"})
ax.plot(angles, values, color="#2166AC", linewidth=2)
ax.fill(angles, values, color="#2166AC", alpha=0.25)
ax.set_xticks(angles[:-1]); ax.set_xticklabels(cells, fontsize=10)
ax.set_ylim(0, 1); ax.set_yticks([0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(["0.25", "0.5", "0.75", "1.0"], fontsize=8, color="grey")
ax.set_title("Immune-hot subtype: signature scores", pad=20)
fig.tight_layout()
plt.show()
2. Multi-Series Radar with Per-Axis Normalization
Min-max scaling each axis to 0-1 puts all spokes on a common range, so shape comparisons stay honest even when the underlying variables differ in spread.
norm = (scores - scores.min()) / (scores.max() - scores.min())
colors = {"Immune-hot": "#B2182B", "Immune-cold": "#2166AC",
"Fibrotic": "#1B7837"}
fig, ax = plt.subplots(figsize=(5.8, 5.4), subplot_kw={"projection": "polar"})
for st in subtypes:
v = norm.loc[st].tolist(); v += v[:1]
ax.plot(angles, v, color=colors[st], linewidth=2, label=st)
ax.fill(angles, v, color=colors[st], alpha=0.12)
ax.set_xticks(angles[:-1]); ax.set_xticklabels(cells, fontsize=10)
ax.set_ylim(0, 1); ax.set_yticks([0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(["0.25", "0.5", "0.75", "1.0"], fontsize=8, color="grey")
ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=9)
ax.set_title("Normalized per axis (0-1)", pad=20)
fig.tight_layout()
plt.show()
3. Interactive Radar with plotly
fig = go.Figure()
for st in subtypes:
v = norm.loc[st].tolist(); v += v[:1]
fig.add_trace(go.Scatterpolar(
r=v, theta=cells + [cells[0]], fill="toself", name=st,
line=dict(color=colors[st])))
fig.update_layout(
polar=dict(radialaxis=dict(range=[0, 1], tickvals=[0.25, 0.5, 0.75, 1.0])),
width=560, height=480,
title="Immune signature radar (hover to compare)")
fig- Always
set_ylim(): matplotlibโs polar default r-limit is data-dependent, so two radars rendered from different subsets are silently incomparable. - Never overlay raw values on different scales โ a 0-100 score squashes every 0-1 fraction into the center (see the Application panel).
- More than 3-4 overlaid series makes radar charts unreadable; consider a slope/dumbbell chart or a heatmap of the same matrix.
- Order spokes to carry meaning (cell lineage order, workflow order) โ alphabetical spoke order produces meaningless zigzag polygons.
Application
The panel below reproduces the most common radar-chart mistake in clinical reports: panel A overlays the three subtypes with one raw-score axis rescaled ร100 on one subtype โ the ร100 polygon pins the shared r-limit and every other series collapses into an unreadable center blob. Panel B min-max normalizes per axis and the three profile shapes reappear.

Reference
[1] Becht E, Giraldo NA, Lacroix L, et al.ย Estimating the abundance of cell populations from deconvolution of immune cell infiltrates. Genome Biol. 2016;17:218. doi:10.1186/s13059-016-1070-5
[2] Sievert C. Interactive Web-Based Data Visualization with R, plotly, and shiny. Chapman and Hall/CRC; 2020. doi:10.1201/9780429447273
