A circular barplot is a barplot where bars are displayed along a circle instead of a horizontal or vertical line. This page is the continuation of this post on basic circular barplots and aims to teach you how to make a circular barplot with groups.
Let's import the libraries needed and generate the data needed for today's guide.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# Ensures reproducibility of random numbers
rng = np.random.default_rng(123)
# Build a dataset
df = pd.DataFrame({
"name": [f"item {i}" for i in range(1, 51)],
"value": rng.integers(low=30, high=100, size=50),
"group": ["A"] * 10 + ["B"] * 20 + ["C"] * 12 + ["D"] * 8
})
# Show 3 first rows
df.head(3)
name | value | group | |
---|---|---|---|
0 | item 1 | 31 | A |
1 | item 2 | 77 | A |
2 | item 3 | 71 | A |
The following is a helper function that given the angle at which the bar is positioned and the offset used in the barchart, determines the rotation and alignment of the labels.
def get_label_rotation(angle, offset):
# Rotation must be specified in degrees :(
rotation = np.rad2deg(angle + offset)
if angle <= np.pi:
alignment = "right"
rotation = rotation + 180
else:
alignment = "left"
return rotation, alignment
And this is the function that actually adds the labels (with ax.text()
) to the plot:
def add_labels(angles, values, labels, offset, ax):
# This is the space between the end of the bar and the label
padding = 4
# Iterate over angles, values, and labels, to add all of them.
for angle, value, label, in zip(angles, values, labels):
angle = angle
# Obtain text rotation and alignment
rotation, alignment = get_label_rotation(angle, offset)
# And finally add the text
ax.text(
x=angle,
y=value + padding,
s=label,
ha=alignment,
va="center",
rotation=rotation,
rotation_mode="anchor"
)
Basic circular barplot with labels
Before creating a circular barplot with groups, let's review how to create a circular barplot with labels at the end of each bar. First of all, let's create ANGLES
, which give the positions where bars are located. The VALUES
array contains the heights of the bars, and LABELS
stores the labels.
ANGLES = np.linspace(0, 2 * np.pi, len(df), endpoint=False)
VALUES = df["value"].values
LABELS = df["name"].values
# Determine the width of each bar.
# The circumference is '2 * pi', so we divide that total width over the number of bars.
WIDTH = 2 * np.pi / len(VALUES)
# Determines where to place the first bar.
# By default, matplotlib starts at 0 (the first bar is horizontal)
# but here we say we want to start at pi/2 (90 deg)
OFFSET = np.pi / 2
# Initialize Figure and Axis
fig, ax = plt.subplots(figsize=(20, 10), subplot_kw={"projection": "polar"})
# Specify offset
ax.set_theta_offset(OFFSET)
# Set limits for radial (y) axis. The negative lower bound creates the whole in the middle.
ax.set_ylim(-100, 100)
# Remove all spines
ax.set_frame_on(False)
# Remove grid and tick marks
ax.xaxis.grid(False)
ax.yaxis.grid(False)
ax.set_xticks([])
ax.set_yticks([])
# Add bars
ax.bar(
ANGLES, VALUES, width=WIDTH, linewidth=2,
color="#61a4b2", edgecolor="white"
)
# Add labels
add_labels(ANGLES, VALUES, LABELS, OFFSET, ax)
Add a gap in the circle
The next step is to build a circular barplot with a break in the circle. Actually, the approach is just to increase the number of values in ANGLES
, but leaving some of them unused so it creates the gap.
# 3 empty bars are added
PAD = 3
ANGLES_N = len(VALUES) + PAD
ANGLES = np.linspace(0, 2 * np.pi, num=ANGLES_N, endpoint=False)
WIDTH = (2 * np.pi) / len(ANGLES)
# The index contains non-empty bards
IDXS = slice(0, ANGLES_N - PAD)
# The layout customization is the same as above
fig, ax = plt.subplots(figsize=(20, 10), subplot_kw={"projection": "polar"})
ax.set_theta_offset(OFFSET)
ax.set_ylim(-100, 100)
ax.set_frame_on(False)
ax.xaxis.grid(False)
ax.yaxis.grid(False)
ax.set_xticks([])
ax.set_yticks([])
# Add bars, subsetting angles to use only those that correspond to non-empty bars
ax.bar(
ANGLES[IDXS], VALUES, width=WIDTH, color="#61a4b2",
edgecolor="white", linewidth=2
)
add_labels(ANGLES[IDXS], VALUES, LABELS, OFFSET, ax)
Space between groups
This concept can now be used to add space between each group of the dataset. In this case, PAD
empty bars are added at the end of each group.
This chart is far more insightful since it allows one to quickly compare the different groups, and to compare the value of items within each group.
# Grab the group values
GROUP = df["group"].values
# Add three empty bars to the end of each group
PAD = 3
ANGLES_N = len(VALUES) + PAD * len(np.unique(GROUP))
ANGLES = np.linspace(0, 2 * np.pi, num=ANGLES_N, endpoint=False)
WIDTH = (2 * np.pi) / len(ANGLES)
# Obtain size of each group
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
# Obtaining the right indexes is now a little more complicated
offset = 0
IDXS = []
for size in GROUPS_SIZE:
IDXS += list(range(offset + PAD, offset + size + PAD))
offset += size + PAD
# Same layout as above
fig, ax = plt.subplots(figsize=(20, 10), subplot_kw={"projection": "polar"})
ax.set_theta_offset(OFFSET)
ax.set_ylim(-100, 100)
ax.set_frame_on(False)
ax.xaxis.grid(False)
ax.yaxis.grid(False)
ax.set_xticks([])
ax.set_yticks([])
# Use different colors for each group!
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
COLORS = [f"C{i}" for i, size in enumerate(GROUPS_SIZE) for _ in range(size)]
# And finally add the bars.
# Note again the `ANGLES[IDXS]` to drop some angles that leave the space between bars.
ax.bar(
ANGLES[IDXS], VALUES, width=WIDTH, color=COLORS,
edgecolor="white", linewidth=2
)
add_labels(ANGLES[IDXS], VALUES, LABELS, OFFSET, ax)
Order bars
Here observations are sorted by bar height within each group. It can be useful if your goal is to understand what are the highest / lowest observations within and across groups.
The method does not modify the code to produce the plot, it only sort values using pandas methods. Basically, you just have to add the following piece of code right after the data frame creation:
# Reorder the dataframe
df_sorted = (
df
.groupby(["group"])
.apply(lambda x: x.sort_values(["value"], ascending = False))
.reset_index(drop=True)
)
VALUES = df_sorted["value"].values
LABELS = df_sorted["name"].values
GROUP = df_sorted["group"].values
PAD = 3
ANGLES_N = len(VALUES) + PAD * len(np.unique(GROUP))
ANGLES = np.linspace(0, 2 * np.pi, num=ANGLES_N, endpoint=False)
WIDTH = (2 * np.pi) / len(ANGLES)
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
offset = 0
IDXS = []
for size in GROUPS_SIZE:
IDXS += list(range(offset + PAD, offset + size + PAD))
offset += size + PAD
fig, ax = plt.subplots(figsize=(20, 10), subplot_kw={"projection": "polar"})
ax.set_theta_offset(OFFSET)
ax.set_ylim(-100, 100)
ax.set_frame_on(False)
ax.xaxis.grid(False)
ax.yaxis.grid(False)
ax.set_xticks([])
ax.set_yticks([])
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
COLORS = [f"C{i}" for i, size in enumerate(GROUPS_SIZE) for _ in range(size)]
# Add bars to represent ...
ax.bar(
ANGLES[IDXS], VALUES, width=WIDTH, color=COLORS,
edgecolor="white", linewidth=2
)
add_labels(ANGLES[IDXS], VALUES, LABELS, OFFSET, ax)
Circular barchart customization
Last but not least, it is highly advisable to add some customisation to your chart. Here we add group names (A, B, C and D), and we add a scale to help compare the sizes of the bars. Voila! The code is a bit long, but the result is quite worth it!
# All this part is like the code above
VALUES = df["value"].values
LABELS = df["name"].values
GROUP = df["group"].values
PAD = 3
ANGLES_N = len(VALUES) + PAD * len(np.unique(GROUP))
ANGLES = np.linspace(0, 2 * np.pi, num=ANGLES_N, endpoint=False)
WIDTH = (2 * np.pi) / len(ANGLES)
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
offset = 0
IDXS = []
for size in GROUPS_SIZE:
IDXS += list(range(offset + PAD, offset + size + PAD))
offset += size + PAD
fig, ax = plt.subplots(figsize=(20, 10), subplot_kw={"projection": "polar"})
ax.set_theta_offset(OFFSET)
ax.set_ylim(-100, 100)
ax.set_frame_on(False)
ax.xaxis.grid(False)
ax.yaxis.grid(False)
ax.set_xticks([])
ax.set_yticks([])
GROUPS_SIZE = [len(i[1]) for i in df.groupby("group")]
COLORS = [f"C{i}" for i, size in enumerate(GROUPS_SIZE) for _ in range(size)]
ax.bar(
ANGLES[IDXS], VALUES, width=WIDTH, color=COLORS,
edgecolor="white", linewidth=2
)
add_labels(ANGLES[IDXS], VALUES, LABELS, OFFSET, ax)
# Extra customization below here --------------------
# This iterates over the sizes of the groups adding reference
# lines and annotations.
offset = 0
for group, size in zip(["A", "B", "C", "D"], GROUPS_SIZE):
# Add line below bars
x1 = np.linspace(ANGLES[offset + PAD], ANGLES[offset + size + PAD - 1], num=50)
ax.plot(x1, [-5] * 50, color="#333333")
# Add text to indicate group
ax.text(
np.mean(x1), -20, group, color="#333333", fontsize=14,
fontweight="bold", ha="center", va="center"
)
# Add reference lines at 20, 40, 60, and 80
x2 = np.linspace(ANGLES[offset], ANGLES[offset + PAD - 1], num=50)
ax.plot(x2, [20] * 50, color="#bebebe", lw=0.8)
ax.plot(x2, [40] * 50, color="#bebebe", lw=0.8)
ax.plot(x2, [60] * 50, color="#bebebe", lw=0.8)
ax.plot(x2, [80] * 50, color="#bebebe", lw=0.8)
offset += size + PAD