Search code examples
pandasfor-loopmatplotliblabelstacked-bar-chart

Matplotlib - Creating a Stacked barh with x axis labels positioned centrally and value labels positioned on inside of outer edges


I'm looking to create a barh plot where the index appears as a label in the center of the row and the values appear on the inside edges of the chart, essentially replicating Excel's 'inside end' and 'inside base' label positions

The following is a simple example to illustrate the problem and desired result:

In [1]:
# Setting up a normalised data frame:

data = {"Names": ["Amy", "Bob", "Chris"],
   "Scores1": [70, 81, 23], 
   "Scores2": [30, 100, 63]}
df = pd.DataFrame(data).set_index("Names")
df_norm = df.div(df.sum(axis = 1), axis = 0)
df_norm

Out [1]:
        Scores1 Scores2
Names       
Amy       0.70  0.30
Bob       0.45  0.55
Chris     0.27  0.73


In [2]:
# Plotting stacked barh and tidying up the asthetics

ax = df_Norm.plot.barh(stacked = True, width = 0.85, color = ['r', 'orange'])
plt.legend(
    bbox_to_anchor = (0.5, -0.1),
    loc = "lower center",
    borderaxespad = 0,
    frameon = False,
    ncol = 2
)

plt.xlim(0.05, 1)
ax.axis('off')

# 'Drawing' the required labels note that the index labels need to appear twice and overlap
# with alpha = 0.5 in the event that one of the bars does not exist e.g Scores1 = 0

plt.bar_label(ax.containers[0], label_type = 'edge', labels = data["Scores1"])
plt.bar_label(Ax.containers[0], label_type = 'center', labels = data["Names"], alpha = 0.5)
plt.bar_label(ax.containers[1], label_type = 'center', labels = data["Names"], alpha = 0.5)
plt.bar_label(ax.containers[1], label_type = 'edge', labels = data["Scores2"])

Out [2]:

enter image description here

Getting the right side labels is a simple task as the padding required to do so is constant, I found that k = -20 works for up to 3 digit numbers.

So changing the last line above to include a constant padding results in the following

In [3]: 
k = -20
plt.bar_label(ax.containers[1], label_type = 'edge', labels = data["Scores2"], padding = k)

Out [3]:

enter image description here

Where I'm struggling is with the left and central sets of labels. The desired result is shown below, any help is appreciated.

enter image description here


Solution

  • You can use ax.text for this. It takes x and y as coordinates, and s as a string (desired "label").

    In order to determine the correct coordinates, let's have a look at ax.containers. We have two (1 for Score1, 1 for Score2). Both have an attribute patches that stores a list of Rectangles with the information that we need. Here's a print:

    for idx_cont, cont in enumerate(ax.containers):
        for idx_p, p in enumerate(cont.patches):
            print(f'cont {idx_cont} -> patch {idx_p}: {p}')
            
    cont 0 -> patch 0: Rectangle(xy=(0, -0.425), width=0.7, height=0.85, angle=0)
    cont 0 -> patch 1: Rectangle(xy=(0, 0.575), width=0.447514, height=0.85, angle=0)
    cont 0 -> patch 2: Rectangle(xy=(0, 1.575), width=0.267442, height=0.85, angle=0)
    cont 1 -> patch 0: Rectangle(xy=(0.7, -0.425), width=0.3, height=0.85, angle=0)
    cont 1 -> patch 1: Rectangle(xy=(0.447514, 0.575), width=0.552486, height=0.85, angle=0)
    cont 1 -> patch 2: Rectangle(xy=(0.267442, 1.575), width=0.732558, height=0.85, angle=0)
    

    E.g. cont 0 -> patch 0 references the red bar in the left bottom corner (Score1: 70) and cont 1 -> patch 0 the orange bar in the right bottom corner (Score2: 30). Both start at y == -0.425, and all your bars have height=0.85.

    Using this knowledge, we can write something as follows instead of your plt.bar_label(ax.containers ...) lines:

    plt.xlim(0.05, 1)
    ax.axis('off')
    
    for idx_cont, cont in enumerate(ax.containers):
        for idx_p, p in enumerate(cont.patches):
            x,y = p.xy
    
            #In order to align the text,
            # use `va` (vertical alignment) and `ha`
            
            if idx_cont == 0:
                ax.text(0.1, y+(0.85/2), data["Scores1"][idx_p], fontsize=10, 
                        ha='left', va='center')
                ax.text(0.525, y+(0.85/2), data["Names"][idx_p], fontsize=10, 
                        ha='center', va='center', alpha = 0.5)
            else:
                ax.text(0.95, y+(0.85/2), data["Scores2"][idx_p], fontsize=10, 
                        ha='right', va='center')
    

    Result:

    plot