'stack bar plot in matplotlib and add label to each section

I am trying to replicate the following image in matplotlib and it seems barh is my only option. Though it appears that you can't stack barh graphs so I don't know what to do

enter image description here

If you know of a better python library to draw this kind of thing, please let me know.

This is all I could come up with as a start:

import matplotlib.pyplot as plt; plt.rcdefaults()
import numpy as np
import matplotlib.pyplot as plt

people = ('A','B','C','D','E','F','G','H')
y_pos = np.arange(len(people))
bottomdata = 3 + 10 * np.random.rand(len(people))
topdata = 3 + 10 * np.random.rand(len(people))
fig = plt.figure(figsize=(10,8))
ax = fig.add_subplot(111)
ax.barh(y_pos, bottomdata,color='r',align='center')
ax.barh(y_pos, topdata,color='g',align='center')
ax.set_yticks(y_pos)
ax.set_yticklabels(people)
ax.set_xlabel('Distance')

plt.show()

I would then have to add labels individually using ax.text which would be tedious. Ideally I would like to just specify the width of the part to be inserted then it updates the center of that section with a string of my choosing. The labels on the outside (e.g. 3800) I can add myself later, it is mainly the labeling over the bar section itself and creating this stacked method in a nice way I'm having problems with. Can you even specify a 'distance' i.e. span of color in any way?

enter image description here



Solution 1:[1]

Imports and Test DataFrame

import pandas as pd
import numpy as np

# create sample data as shown in the OP
np.random.seed(365)
people = ('A','B','C','D','E','F','G','H')
bottomdata = 3 + 10 * np.random.rand(len(people))
topdata = 3 + 10 * np.random.rand(len(people))

# create the dataframe
df = pd.DataFrame({'Female': bottomdata, 'Male': topdata}, index=people)

# display(df)
   Female   Male
A   12.41   7.42
B    9.42   4.10
C    9.85   7.38
D    8.89  10.53
E    8.44   5.92
F    6.68  11.86
G   10.67  12.97
H    6.05   7.87

Updated with matplotlib v3.4.2

  • Use matplotlib.pyplot.bar_label
  • See the matplotlib: Bar Label Demo page for additional formatting options.
  • Tested with pandas 1.2.4, which is using matplotlib as the plot engine, and python 3.8.
    • labels = [f'{v.get_width():.2f}%' if v.get_width() > 0 else '' for v in c ] for python < 3.8, without the assignment expression (:=).
ax = df.plot(kind='barh', stacked=True, figsize=(8, 6))

for c in ax.containers:
    
    # customize the label to account for cases when there might not be a bar section
    labels = [f'{w:.2f}%' if (w := v.get_width()) > 0 else '' for v in c ]
    
    # set the bar label
    ax.bar_label(c, labels=labels, label_type='center')

    # uncomment and use the next line if there are no nan or 0 length sections; just use fmt to add a % (the previous two lines of code are not needed, in this case)
#     ax.bar_label(c, fmt='%.2f%%', label_type='center')

# move the legend
ax.legend(bbox_to_anchor=(1.025, 1), loc='upper left', borderaxespad=0.)

# add labels
ax.set_ylabel("People", fontsize=18)
ax.set_xlabel("Percent", fontsize=18)
plt.show()
  • The plots are equivalent to those shown below.

Annotation Resources - from matplotlib v3.4.2

Original Answer - before matplotlib v3.4.2

  • The easiest way to plot a horizontal or vertical stacked bar, is to load the data into a pandas.DataFrame
    • This will plot, and annotate correctly, even when all categories ('People'), don't have all segments (e.g. some value is 0 or NaN)
  • Once the data is in the dataframe:
    1. It's easier to manipulate and analyze
    2. It can be plotted with the matplotlib engine, using:
  • These methods return a matplotlib.axes.Axes or a numpy.ndarray of them.
  • Using the .patches method unpacks a list of matplotlib.patches.Rectangle objects, one for each of the sections of the stacked bar.
    • Each .Rectangle has methods for extracting the various values that define the rectangle.
    • Each .Rectangle is in order from left the right, and bottom to top, so all the .Rectangle objects, for each level, appear in order, when iterating through .patches.
  • The labels are made using an f-string, label_text = f'{width:.2f}%', so any additional text can be added as needed.

Plot and Annotate

  • Plotting the bar, is 1 line, the remainder is annotating the rectangles
# plot the dataframe with 1 line
ax = df.plot.barh(stacked=True, figsize=(8, 6))

# .patches is everything inside of the chart
for rect in ax.patches:
    # Find where everything is located
    height = rect.get_height()
    width = rect.get_width()
    x = rect.get_x()
    y = rect.get_y()
    
    # The height of the bar is the data value and can be used as the label
    label_text = f'{width:.2f}%'  # f'{width:.2f}' to format decimal values
    
    # ax.text(x, y, text)
    label_x = x + width / 2
    label_y = y + height / 2
    
    # only plot labels greater than given width
    if width > 0:
        ax.text(label_x, label_y, label_text, ha='center', va='center', fontsize=8)

# move the legend
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.)

# add labels
ax.set_ylabel("People", fontsize=18)
ax.set_xlabel("Percent", fontsize=18)
plt.show()

enter image description here

Example with Missing Segment

# set one of the dataframe values to 0
df.iloc[4, 1] = 0
  • Note the annotations are all in the correct location from df.

enter image description here

Solution 2:[2]

For this case, the above answers work perfectly. The issue I had, and didn't find a plug-and-play solution online, was that I often have to plot stacked bars in multi-subplot figures, with many values, which tend to have very non-homogenous amplitudes.

(Note: I work usually with pandas dataframes, and matplotlib. I couldn't make the bar_label() method of matplotlib to work all the times.)

So, I just give a kind of ad-hoc, but easily generalizable solution. In this example, I was working with single-row dataframes (for power-exchange monitoring purposes per hour), so, my dataframe (df) had just one row.

(I provide an example figure to show how this can be useful in very densely-packed plots)

[enter image description here][1] [1]: https://i.stack.imgur.com/9akd8.png

''' This implementation produces a stacked, horizontal bar plot.

df --> pandas dataframe. Columns are used as the iterator, and only the firs value of each column is used.

waterfall--> bool: if True, apart from the stack-direction, also a perpendicular offset is added.

cyclic_offset_x --> list (of any length) or None: loop through these values to use as x-offset pixels.

cyclic_offset_y --> list (of any length) or None: loop through these values to use as y-offset pixels.

ax --> matplotlib Axes, or None: if None, creates a new axis and figure. '''

    def magic_stacked_bar(df, waterfall=False, cyclic_offset_x=None, cyclic_offset_y=None, ax=None):



        if isinstance(cyclic_offset_x, type(None)):
            cyclic_offset_x = [0, 0]
        if isinstance(cyclic_offset_y, type(None)):
            cyclic_offset_y = [0, 0]

        ax0 = ax
        if isinstance(ax, type(None)):
            fig, ax = plt.subplots()
            fig.set_size_inches(19, 10)

        cycler = 0;
        prev = 0 # summation variable to make it stacked
        for c in df.columns:
            if waterfall:
                y = c ; label = "" # bidirectional stack
            else:
                y = 0; label = c # unidirectional stack
            ax.barh(y=y, width=df[c].values[0], height=1, left=prev, label = label)
            prev += df[c].values[0] # add to sum-stack

            offset_x = cyclic_offset_x[divmod(cycler, len(cyclic_offset_x))[1]]
            offset_y = cyclic_offset_y[divmod(cycler, len(cyclic_offset_y))[1]]

            ax.annotate(text="{}".format(int(df[c].values[0])), xy=(prev - df[c].values / 2, y),
                        xytext=(offset_x, offset_y), textcoords='offset pixels',
                        ha='center', va='top', fontsize=8,
                        arrowprops=dict(facecolor='black', shrink=0.01, width=0.3, headwidth=0.3),
                        bbox=dict(boxstyle='round', facecolor='grey', alpha=0.5))

            cycler += 1

        if not waterfall:
            ax.legend() # if waterfall, the index annotates the columns. If 
                        # waterfall ==False, the legend annotates the columns
        if isinstance(ax0, type(None)):
            ax.set_title("Voi la")
            ax.set_xlabel("UltraWatts")
            plt.show()
        else:
            return ax

''' (Sometimes, it is more tedious and requires some custom functions to make the labels look alright.

'''

A, B = 80,80
n_units = df.shape[1]
cyclic_offset_x = -A*np.cos(2*np.pi / (2*n_units)  *np.arange(n_units))
cyclic_offset_y = B*np.sin(2*np.pi / (2*n_units) * np.arange(n_units)) + B/2

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Thanos Natsikas