Hi, and welcome to this Python + Matplotlib tutorial, where I will show you how to create the beautiful polar histogram you see above.
Polar histograms are great when you have too many values for a standard bar chart. The circular shape where each bar gets thinner towards the middle allows us to cram more information into the same area.
I’m using data from the World Happiness Report and information about income levels from the World Bank.
You can find the code and data I’m using in this GitHub repository.
Let’s get started.
Step 1: Preparations
Let's start with a few preperations.
Importing libraries
We only need standard Python libraries familiar to everyone. PIL is not mandatory, but it’s my preferred choice for handling images which we do when adding flags.
import math
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from PIL import Image
from matplotlib.lines import Line2D
from matplotlib.patches import Wedge
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
The only thing that stands out is a few specific Matplotlib imports at the end. I’ll cover those components later in the tutorial.
I use pandas to load the data.
df = pd.read_csv("./hapiness_report_2022.csv", index_col=None)
df = df.sort_values("score").reset_index(drop=True)
Seaborn style settings
Next, I use Seaborn to create a base style by defining the background, text color, and font.
font_family = "PT Mono"
background_color = "#F8F1F1"
text_color = "#040303"
sns.set_style({
"axes.facecolor": background_color,
"figure.facecolor": background_color,
"font.family": font_family,
"text.color": text_color,
})
There are several more parameters for set_style, but these four are the only ones I need in this tutorial.
I use websites such as Colorhunt and Coolors to create beautiful color palettes.
Global settings
I’m also adding a few global settings to control the general look. The first four define the range, size, and width of the wedges in the histogram.
START_ANGLE = 100 # At what angle to start drawing the first wedge
END_ANGLE = 450 # At what angle to finish drawing the last wedge
SIZE = (END_ANGLE - START_ANGLE) / len(df) # The size of each wedge
PAD = 0.2 * SIZE # The padding between wedges
INNER_PADDING = 2 * df.score.min()
LIMIT = (INNER_PADDING + df.score.max()) * 1.3 # Limit of the axes
Inner padding creates distance between the origo and the start of each wedge. It opens a space in the middle of the graph where I can add a title.
Boilerplate code
As a software engineer, I strive to write reusable code, and it’s the same when I’m working on data visualizations.
That’s why I always start by creating a few lines of boilerplate code that I can extend with reusable functions.
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(30, 30))
ax.set(xlim=(-LIMIT, LIMIT), ylim=(-LIMIT, LIMIT))
for i, row in df.iterrows():
bar_length = row.score
name = row.country
length = bar_length + INNER_PADDING
start = 100 + i*SIZE + PAD
end = 100 + (i+1)*SIZE
angle = (end + start) / 2
# Create variables here
# Add wedge functions here
# Add general functions here
plt.axis("off")
plt.tight_layout()
plt.show()
For the rest of the tutorial, I will create and add functions and variables under one of the three comments.
Step 2: Drawing wedges
To get more power over the visuals in Matplotlib, it helps to use the underlying components rather than the built-in graph functions.
Drawing a wedge
For example, instead of using plt.pie()
to create a pie chart, you can use plt.patches.Wedge()
to draw the individual pieces.
That’s why I created the following function, which draws a wedge based on angles, length, bar length, and color.
def draw_wedge(ax, start_angle, end_angle, length, bar_length, color):
ax.add_artist(
Wedge((0, 0),
length, start_angle, end_angle,
color=color, width=bar_length
)
)
In the boilerplate code, I add draw_wedge()
under the “Add functions here” comment as below.
bar_length = row.score
length = bar_length # + INNER_PADDING
start = 100 + i*SIZE + PAD
end = 100 + (i+1)*SIZE
.
.
.
# Add functions here
draw_wedge(ax, start, end, length, bar_length, "#000")
I use row.score
to define bar_length
so that the visible part of the bars has an accurate size relation to each other.
For now, I’ve removed the INNER_PADDING
to show you what it does.
When I run the code, I get the following figure.
As you can see, we have a long way to go until we get something similar to the polar histogram that you saw at the beginning, but at least we’ve managed to draw the wedges.
We get a lot of visual artefacts close to the middle, so let’s uncomment INNER_PADDING
.
Here’s what we get.
Much better.
Adding color
Next, I have a simple color function that decides the color for each wedge based on the income level of that country.
def color(income_group):
if income_group == "High income":
return "#468FA8"
elif income_group == "Lower middle income":
return "#E5625E"
elif income_group == "Upper middle income":
return "#62466B"
elif income_group == "Low income":
return "#6B0F1A"
else:
return "#909090"
I use that function as input to the draw_wedge function.
# Add functions here
draw_wedge(ax, start, end, length, bar_length, color(row.income))
Here’s the result.
With INNER_PADDING
and color()
there are no strange artifacts left. It’s time to add information that explains what we’re looking at.
Step 3: Adding labels
Let’s add labels for each bar in the polar histogram. I want each bar to display the country’s flag, name, and happiness score.
Defining the positions
When you add flags and text to a chart in Matplotlib, you need to calculate the correct positions.
That’s often tricky, especially when you have an unusual shape like we have in the polar histogram.
The function below takes the length of a wedge and its angle to calculate a position. Padding pushes the position away from the bar to add some visual space.
def get_xy_with_padding(length, angle, padding):
x = math.cos(math.radians(angle)) * (length + padding)
y = math.sin(math.radians(angle)) * (length + padding)
return x, y
We can use this function for both flags and text.
Adding flags
For flags, I’m using these rounded ones from FlatIcon.
They require a license, so, unfortunately, I can’t share them, but you can find similar flags in other places.
Here’s my function to add a flag to the graph. It takes the position, the country’s name (which corresponds to the name of the correct file), zoom, and rotation.
def add_flag(ax, x, y, name, zoom, rotation):
flag = Image.open("<location>/{}.png".format(name.lower()))
flag = flag.rotate(rotation if rotation > 270 else rotation - 180)
im = OffsetImage(flag, zoom=zoom, interpolation="lanczos", resample=True, visible=True)
ax.add_artist(AnnotationBbox(
im, (x, y), frameon=False,
xycoords="data",
))
I change how the flag rotates if the angle exceeds 270 degrees. That happens when we start adding bars on the right part of the chart. At that point, the flag is to the left of the text, and changing the rotation makes reading more natural.
Now, we can calculate the angle, use get_xy_with_padding()
and put flags on the chart.
bar_length = row.score
length = bar_length + INNER_PADDING
start = START_ANGLE + i*SIZE + PAD
end = START_ANGLE + (i+1)*SIZE
# Add variables here
angle = (end + start) / 2
flag_zoom = 0.004 * length
flag_x, flag_y = get_xy_with_padding(length, angle, 0.1 * length)
# Add functions here
...
add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)
The flag_zoom parameters decide the size of the flag and depend on the score. If a country has a low score, there’s less room for a flag, and we need to make it a bit smaller.
Fantastic.
Adding country names and scores
To add the name and score of the country, I’ve written the following function.
As with the flags, I change the rotation if the angle exceeds 270 degrees. Otherwise, the text would be upside down.
def add_text(ax, x, y, country, score, angle):
if angle < 270:
text = "{} ({})".format(country, score)
ax.text(x, y, text, fontsize=13, rotation=angle-180, ha="right", va="center", rotation_mode="anchor")
else:
text = "({}) {}".format(score, country)
ax.text(x, y, text, fontsize=13, rotation=angle, ha="left", va="center", rotation_mode="anchor")
We calculate the position of the text in the same way as we did with the flags.
The only difference is that we add more padding since we want it further from the wedges.
bar_length = row.score
length = bar_length + INNER_PADDING
start = START_ANGLE + i*SIZE + PAD
end = START_ANGLE + (i+1)*SIZE
# Add variables here
angle = (end + start) / 2
flag_zoom = 0.004 * length
flag_x, flag_y = get_xy_with_padding(length, angle, 0.1 * length)
text_x, text_y = get_xy_with_padding(length, angle, 16*flag_zoom)
# Add functions here
...
add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)
add_text(ax, text_x, text_y, row.country, bar_length, angle)
Now we have the following graph, and it’s starting to look much better.
Now it’s time to tell the users what they are looking at.
Step 4: Adding information
We have added all the data. It’s time to make the chart readable by adding helpful information and guidance.
Drawing reference lines
An excellent type of visual helper is reference lines; they work just as well here as with standard bar charts.
The idea is to draw a line at a specific score, which indirectly helps us compare different countries.
Here’s my function to draw reference lines. I’m reusing the draw_wedge()
function to draw a wedge from 0 to 360 degrees.
def draw_reference_line(ax, point, size, padding, fontsize=18):
draw_wedge(ax, 0, 360, point+padding+size/2, size, background_color)
ax.text(-0.6, padding + point, point, va="center", rotation=1, fontsize=fontsize)
I run the function once for each score to draw multiple reference lines.
# Add general functions here
draw_reference_line(ax, 2.0, 0.05, INNER_PADDING)
draw_reference_line(ax, 4.0, 0.05, INNER_PADDING)
draw_reference_line(ax, 6.0, 0.05, INNER_PADDING)
Here’s the result.
It makes a significant difference.
Adding a title
The purpose of the gap in the center of the graph is to create a natural place for a title. Having the title in the center is unusual and can immediately capture a viewer’s interest.
The code for adding the title is standard Matplotlib functionality.
# Add general functions here
...
plt.title(
"World Happiness Report 2022".replace(" ", "\n"),
x=0.5, y=0.5, va="center", ha="center",
fontsize=64, linespacing=1.5
)
Here’s what it looks like.
It’s getting close, but we still have one more thing to do.
Adding a legend
There’s no way for the viewer to understand what the colors mean, but we can fix that by adding a legend.
To add a legend, I’ve created the following function that takes the labels to add, their colors, and a title.
def add_legend(labels, colors, title):
lines = [
Line2D([], [], marker='o', markersize=24, linewidth=0, color=c)
for c in colors
]
plt.legend(
lines, labels,
fontsize=18, loc="upper left", alignment="left",
borderpad=1.3, edgecolor="#E4C9C9", labelspacing=1,
facecolor="#F1E4E4", framealpha=1, borderaxespad=1,
title=title, title_fontsize=20,
)
I add the function under “Add general functions here” and run it together with everything else.
# Add general functions here
...
add_legend(
labels=["High income", "Upper middle income", "Lower middle income", "Low income", "Unknown"],
colors=["#468FA8", "#62466B", "#E5625E", "#6B0F1A", "#909090"],
title="Income level according to the World Bank\n"
)
The final result looks like this.
That’s it. We have recreated the beautiful polar histogram you saw at the top.
Your entire main block of code should now look like this.
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(30, 30))
ax.set(xlim=(-LIMIT, LIMIT), ylim=(-LIMIT, LIMIT))
for i, row in df.iterrows():
bar_length = row.score
length = bar_length + INNER_PADDING
start = START_ANGLE + i*SIZE + PAD
end = START_ANGLE + (i+1)*SIZE
angle = (end + start) / 2
# Add variables here
flag_zoom = 0.004 * length
flag_x, flag_y = get_xy_with_padding(length, angle, 8*flag_zoom)
text_x, text_y = get_xy_with_padding(length, angle, 16*flag_zoom)
# Add functions here
draw_wedge(ax, start, end, length, bar_length, color(row.income))
add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)
add_text(ax, text_x, text_y, row.country, bar_length, angle)
ax.text(1-LIMIT, LIMIT-2, "+ main title", fontsize=58)
# Add general functions here
draw_reference_line(ax, 2.0, 0.06, INNER_PADDING)
draw_reference_line(ax, 4.0, 0.06, INNER_PADDING)
draw_reference_line(ax, 6.0, 0.06, INNER_PADDING)
plt.title("World Happiness Report 2022".replace(" ", "\n"), x=0.5, y=0.5, va="center", ha="center", fontsize=64, linespacing=1.5)
add_legend(
labels=["High income", "Upper middle income", "Lower middle income", "Low income", "Unknown"],
colors=["#468FA8", "#62466B", "#E5625E", "#6B0F1A", "#909090"],
title="Income level according to the World Bank\n"
)
plt.axis("off")
plt.tight_layout()
plt.show()
That’s it for this tutorial; congratulations on reaching the end.
Conclusion
Today, we learned to create a beautiful polar histogram using Matplotlib and Python.
Polar histograms are surprisingly easy to create, allowing us to cram more information into a single chart.
I used the World Happiness Report in this tutorial, but you can change it to another inspiring dataset.
I hope you learned a few techniques to help you bring your chart ideas to life.
Top comments (2)
What an excellent, thorough article! I love the attention to design detail and how you build up the program.
Thank you! That means a lot :D