I'm creating a table in matplotlib, but the table headers are long strings, and the table values are numbers with only a few digits. This leaves me with two bad options: either my table is much wider than necessary, or my headers overlap. To fix this, I'd like to rotate the table headings (possibly up to 90 degrees). In other words, I want to do this in python
Here's my simplified code right now:
import matplotlib, numpy
import matplotlib.pyplot as plt
data=numpy.array([[1, 2],[3,4]])
headings=['long heading 1', 'long heading 2']
fig=plt.figure(figsize=(5,2))
ax=fig.add_subplot(111, frameon=False, xticks=[], yticks=[])
the_table = plt.table(cellText=data, rowLabels=headings, colLabels=headings, colWidths=[0.3]*data.shape[1], loc='center') #0.3 for second image, 0.03 for first
#the_table.auto_set_font_size(False) #comment out for second image
#the_table.set_fontsize(10) #comment out for second image
the_table.scale(1, 1.6)
plt.show()
This produces either the squished image, or the super-wide image (both shown below). In my actual code, the table is ~30 x 30, so the cells can't be very wide. Does anybody know how to rotate the column titles to fix this spacing issue?
I figured it out. It's not pretty, but it works. I added two annotations for each column - the text, and a line to separate it from the next column header. I had to define some parameters that apply to the table and the fancy labels (width, height, col_width), and some parameters to make the fancy labels line up correctly. This solution worked fine on my 30x30 table.
import matplotlib, numpy
import matplotlib.pyplot as plt
width=5
height=3
col_width=.075
data=numpy.array([[1, 2,5],[3,4,7],[7,9,5]])
headings=['long heading 1', 'long heading 2', 'longish 3']
fig=plt.figure(figsize=(width,height))
ax=fig.add_subplot(111, frameon=False, xticks=[], yticks=[])
the_table = plt.table(cellText=data, rowLabels=headings,
colWidths=[col_width]*data.shape[1], loc='center') #remove colLabels
the_table.auto_set_font_size(False)
the_table.set_fontsize(10)
the_table.scale(1, 1.6)
#custom heading titles - new portion
hoffset=0.42 #find this number from trial and error
voffset=0.66 #find this number from trial and error
line_fac=0.98 #controls the length of the dividing line
count=0
for string in headings:
ax.annotate(' '+string, xy=(hoffset+count*col_width,voffset),
xycoords='axes fraction', ha='left', va='bottom',
rotation=45, size=10)
#add a dividing line
ax.annotate('', xy=(hoffset+(count+0.5)*col_width,voffset),
xytext=(hoffset+(count+0.5)*col_width+line_fac/width,voffset+line_fac/height),
xycoords='axes fraction', arrowprops={'arrowstyle':'-'})
count+=1
plt.show()
I just found an other solution:
for cell in table._cells:
if cell[0] ==0:
table._cells[cell].get_text().set_rotation(90)
The first loop is for going through all cells, the second is for picking the first row/header.
if cell[1] =- -1
this would selecte the the first column, which you might want to rotate, too.
Then you can rotate the cell text by e.g. 90 °.
The following worked for me.
create the table
fig = plt.figure( figsize=(pageWidthInInches, pageHeightInInches) )
panel = plotUtils.createPanelSameSizeAsFig(fig)
tablePanel = panel.table(
cellText=cellText
# ,rowLabels=rowLabels
# ,colLabels=colLabels
#,loc='center' # center table in panel, title is in center
#,loc='bottom' # center table in panel does not work well
,loc='best'
#,rowColours=aedwip,
#,colColourslist=aedwip
,cellColours= cellColors
)
# get rid of bar chart axis and box
panel.get_xaxis().set_visible(False)
panel.get_yaxis().set_visible(False)
tablePanel.scale(1, 1.5)
plt.box(on=None)
panel.set_title(title)
Now add the col header. Note my table did not have row labels, you may have to tweak the startX position
tcell = table._cells[(0, 0)]
cellWidth = tcell.get_width()
startX = tcell.get_x() - cellWidth
y = 0.99 #0.98 #0.975 #0.96 #1
headings = sampleDF.columns
for i in range(len(headings)):
heading = headings[i]
x = startX + i * cellWidth
panel.text(x, y, heading, horizontalalignment="left",
verticalalignment="baseline", rotation=45, fontsize=4)
Related
I have a problem in formating pdf report.
One value in my record list is a big text, that need to be wrap and the size of row must cover all text. I've tried by using bbox option from: https://matplotlib.org/stable/api/transformations.html#matplotlib.transforms.Bbox ,but I need only one- the last 4th parameter, which will be change dynamically from the length of text. I'm not sure how to sapare it from previous ones..and finally is it correct road to establish the height of table
Thanks for every kind of help!
the whole code looks that (it genereted raport, but I can't publish it):
wrapError = ('\n' + ' ').join(wrapError)
ErorrsInTable = {'clas':[clas],'path':[path],'translate':[translate], 'full content':[wrapError], 'row of Error':[rowError], 'column of Error':[columnError]}#'wrapError' set the length of row
df = pd.DataFrame(ErorrsInTable)
fig, ax = plt.subplots(figsize=(26,0.01*len(wrapError)))#size of window
plt.subplots_adjust(left = 0.1, top = 0.95)
tytul=plt.title('Report of errors', fontweight="bold")
ax.axis('auto')
ax.axis('off')
the_table = ax.table(cellText=df.values, cellLoc='center', rowLoc='center', colLabels=df.columns, loc='upper center', bbox=[0,0,0,0.01*len(wrapError)], edges='closed')#here is the problematic bbox
the_table.auto_set_font_size(False)
the_table.set_fontsize(8)
the_table.auto_set_column_width(col=list(range(len(df.columns))))
the_table.scale(0,0.01*len(wrapError))
cellDict = the_table.get_celld()
...
pp = PdfPages(str(path)+ "\errorReports.pdf")
print ("report was created")
pp.savefig(fig)
pp.close()```
I'm trying to automatically move matplotlib Text instances above some annotations which are not taken into account by plt.tight_layout or constrained_layout.
However, I can't seem to get the title positions to update using Text.set_position and redrawing afterwards (neither when using e.g. fig.canvas.draw() every update nor when calling title_text_instance.draw(renderer=fig.canvas.get_renderer())).
The text itself seems to update fine, and so does another annotation, which is weird as both title and annotation are Text objects.
Why doesn't the title position update in the code below?
Animation for illustration; the title text and annotation positions update as expected, but its position remains fixed:
Code:
#!/usr/bin/env/python3
import matplotlib.pyplot as plt
import matplotlib as mpl
N_FRAMES: int = 10
# Setting up the example.
mpl.use("TkAgg")
fig, ax = plt.subplots(figsize=(2, 1), dpi=300)
ax.plot(range(10), range(10))
t = ax.set_title("Example title", fontsize=6, alpha=.8, clip_on=False)
atext1 = '\n'.join(['Overlapping extra-axis annotation',
'above which I want to move the title',
'undetected by tight_layout or constrained_layout'])
atext2 = 'this annotation moves just fine...'
a1 = ax.annotate(atext1, xycoords='axes fraction', xy=(.25, 1),
rotation=10, clip_on=False, fontsize=3, color='r', alpha=.6)
a2 = ax.annotate(atext2, xycoords='axes fraction', xy=(.5, .5),
clip_on=False, fontsize=4, color='g')
fig.canvas.draw()
plt.tight_layout()
# ^^ Leaving out plt.tight_layout doesn't
# make a difference w.r.t position updates
for yoffset in range(N_FRAMES):
new_position = (t.get_position()[0], t.get_position()[1] - .5 + (yoffset / 2))
newpostext = '(' + ', '.join(f'{pos:.2f}' for pos in new_position) + ')'
# Changing positions
print(f'Set new position to', newpostext)
t.set_position(new_position) # Doesn't work
a2.set_position(new_position) # Works fine
t.set_text(f"Supposedly repositioned title position: \n (x, y) = {newpostext}")
plt.pause(.5)
plt.get_current_fig_manager().window.resizable(False, False)
plt.show(block=False)
plt.close(fig)
There is an internal property _autotitlepos for the title which is set to True unless you explicitly specify the y position of the title. So in order to be able to manually set the title position you need to supply a value for y when setting the title, e.g. the default value of 1:
t = ax.set_title("Example title", fontsize=6, alpha=.8, clip_on=False, y=1)
(minimum matplotlib version 3.3.0, see PR #17127)
matplotlib's legend() method has an ncol parameter to lay out the content in multiple columns.
I want to do the same thing in a text annotation.
I can pass multi-line text string (i.e., one that contains \ns) to annotate(), but how can I array the content in two columns?
use tab characters? They don't seem to do anything
use two separate text annotations? But I want a round border (bbox) around the two columns
use something like ncol? It can wrap columns according to the number of columns I've asked for
I couln't find an nice way to do this so I wrote a function that gets the jobs done. Try it out and see if it does what you need.
def place_column_text(ax, text, xy, wrap_n, shift, bbox=False, **kwargs):
""" Creates a text annotation with the text in columns.
The text columns are provided by a list of strings.
A surrounding box can be added via bbox=True parameter.
If so, FancyBboxPatch kwargs can be specified.
The width of the column can be specified by wrap_n,
the shift parameter determines how far apart the columns are.
The axes are specified by the ax parameter.
Requires:
import textwrap
import matplotlib.patches as mpatches
"""
# place the individual text boxes, with a bbox to extract details from later
x,y = xy
n = 0
text_boxes = []
for i in text:
text = textwrap.fill(i, wrap_n)
box = ax.text(x = x + n, y = y, s=text, va='top', ha='left',
bbox=dict(alpha=0, boxstyle='square,pad=0'))
text_boxes.append(box)
n += shift
if bbox == True: # draw surrounding box
# extract box data
plt.draw() # so we can extract real bbox data
# first let's calulate the height of the largest bbox
heights=[]
for box in text_boxes:
heights.append(box.get_bbox_patch().get_extents().transformed(ax.transData.inverted()).bounds[3])
max_height=max(heights)
# then calculate the furthest x value of the last bbox
end_x = text_boxes[-1].get_window_extent().transformed(ax.transData.inverted()).xmax
# draw final
width = end_x - x
fancypatch_y = y - max_height
rect = mpatches.FancyBboxPatch(xy=(x,fancypatch_y), width=width, height=max_height, **kwargs)
ax.add_patch(rect)
Here is it in use:
import matplotlib.patches as mpatches
import textwrap
fig, ax = plt.subplots(2,2,sharex=True, sharey=True,figsize=(16,12))
fig.subplots_adjust(hspace=0.05, wspace=0.05)
ax1, ax2, ax3, ax4 = ax.flatten()
for a in ax.flatten():
a.plot(range(0,20),range(0,20), alpha=0)
# the text to be split into columns and annotated
col_text = ['Colum 1 text is this sentence here.',
'The text for column two is going to be longer',
'Column 3 is the third column.',
'And last but not least we have column 4. In fact this text is the longest.']
# use the function to place the text
place_column_text(ax=ax1,text=col_text, xy=(1,10), wrap_n=10, shift=4.2)
place_column_text(ax=ax2,text=col_text, xy=(0,19), wrap_n=17, bbox=True, shift=5, ec='red', fc='w', boxstyle='square')
place_column_text(ax=ax3,text=col_text, xy=(2,18), wrap_n=6, bbox=True, shift=2.7, ec='blue', fc = 'blue' , alpha=0.3, boxstyle='round,pad=1')
place_column_text(ax=ax4,text=col_text, xy=(3,12), wrap_n=10, bbox=True, shift=3, ec='red', fc='w', boxstyle='circle, pad=3')
plt.show()
Result:
I want this kind of result. I want my code to read elements of a text file and if element=='healthy'
it should create a box in a graph and its color should be green ('healthy written in box').
else if element=='unhealthy'
it should create a box and its color should be red (with 'unhealthy written in box').
boxes should be horizontally aligned, and if more than 5 then remaining should start from the next row. (every row should contain only 5 boxes or less).
The end result should display a graph that contains boxes,
red denoting 'unhealthy' and green denoting 'healthy'
I found the following code, but it is not working they way I want it to.
import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
import numpy as np
from matplotlib import colors
#open text file (percen) that contains healthy/unhealthy
with open('percen.txt', 'r') as f:
result= [int(line) for line in f]
data = np.random.rand(10,10) * 20
cmap = colors.ListedColormap(['green'])
cmap1 = colors.ListedColormap(['red'])
bounds = [0,10,20]
norm = colors.BoundaryNorm(bounds, cmap.N)
fig, ax = plt.subplots(2,5 , sharex='col', sharey='row')
for i in range(2):
for j in range(5):
for element in result:
if (element=='healthy'):
ax[i,j].text(1, -3, 'healthy',
fontsize=15, ha='center', color='green')
ax[i,j].imshow(data,cmap=cmap, norm=norm)
else:
ax[i,j].text(1, -3, 'unhealthy',
fontsize=15, ha='center', color='red')
ax[i,j].imshow(data,cmap=cmap1,norm=norm)
fig
plt.show()
There are a few different ways you can do this and your code is probably not the best but we can use it as a starting point. Your issue is that you are looping through the plots and then looping through your data again for each plot. Your current code also adds text above the plot. If you want the text above I would recommend adding the label as a title, otherwise when you set your text inside the plot you need to specify the coordinates within the grid.
Below is a modified form of your code, play around with it some more to get what you want.
import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
import numpy as np
from matplotlib import colors
result = ['healthy', 'unhealthy', 'healthy', 'unhealthy', 'healthy', 'unhealthy', 'healthy', 'healthy', 'unhealthy', 'unhealthy']
data = np.random.rand(10,10) * 20
cmap = colors.ListedColormap(['green'])
cmap1 = colors.ListedColormap(['red'])
bounds = [0,10,20]
norm = colors.BoundaryNorm(bounds, cmap.N)
fig, ax = plt.subplots(2,5 , sharex='col', sharey='row',figsize=(15,8)) # Added figsize to better show your plot
element_index = 0
for i in range(2):
for j in range(5):
element = result[element_index] #Instead of the for loop, get the corresponding element
if (element=='healthy'):
ax[i,j].text(4.5,4.5, 'healthy',fontsize=15, ha='center' ,color='black',zorder=100) #Change zorder so label is over plot
ax[i,j].imshow(data,cmap=cmap, norm=norm)
ax[i,j].set_yticklabels('') #To remove arbitrary numbers on y axis
ax[i,j].set_xticklabels('') #To remove arbitrary numbers on y axis
elif element == 'unhealthy':
ax[i,j].text(4.5,4.5, 'unhealthy',fontsize=15, ha='center' ,color='black',zorder=100)
ax[i,j].imshow(data,cmap=cmap1,norm=norm)
ax[i,j].set_yticklabels('') #To remove arbitrary numbers on y axis
ax[i,j].set_xticklabels('') #To remove arbitrary numbers on x axis
element_index+=1 #Add 1 to the index so we get the next value for the next plot
fig
plt.show()
I ask this question because I haven't found a working example on how to annotate grouped horizontal Pandas bar charts yet. I'm aware of the following two:
Annotate bars with values on Pandas bar plots
Pandas, Bar Chart Annotations
But they are all about vertical bar charts. I.e., either don't have a solution for horizontal bar chart, or it is not fully working.
After several weeks working on this issue, I finally am able to ask the question with a sample code, which is almost what I want, just not 100% working. Need your help to reach for that 100%.
Here we go, the full code is uploaded here. The result looks like this:
You can see that it is almost working, just the label is not placed at where I want and I can't move them to a better place myself. Besides, because the top of the chart bar is used for displaying error bar, so what I really want is to move the annotate text toward the y-axis, line up nicely on either left or right side of y-axis, depending the X-value. E.g., this is what my colleagues can do with MS Excel:
Is this possible for Python to do that with Pandas chart?
I'm including the code from my above url for the annotation, one is my all-that-I-can-do, and the other is for the reference (from In [23]):
# my all-that-I-can-do
def autolabel(rects):
#if height constant: hbars, vbars otherwise
if (np.diff([plt.getp(item, 'width') for item in rects])==0).all():
x_pos = [rect.get_x() + rect.get_width()/2. for rect in rects]
y_pos = [rect.get_y() + 1.05*rect.get_height() for rect in rects]
scores = [plt.getp(item, 'height') for item in rects]
else:
x_pos = [rect.get_width()+.3 for rect in rects]
y_pos = [rect.get_y()+.3*rect.get_height() for rect in rects]
scores = [plt.getp(item, 'width') for item in rects]
# attach some text labels
for rect, x, y, s in zip(rects, x_pos, y_pos, scores):
ax.text(x,
y,
#'%s'%s,
str(round(s, 2)*100)+'%',
ha='center', va='bottom')
# for the reference
ax.bar(1. + np.arange(len(xv)), xv, align='center')
# Annotate with text
ax.set_xticks(1. + np.arange(len(xv)))
for i, val in enumerate(xv):
ax.text(i+1, val/2, str(round(val, 2)*100)+'%', va='center',
ha='center', color='black')
Please help. Thanks.
So, I changed a bit the way you construct your data for simplicity:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
sns.set_style("white") #for aesthetic purpose only
# fake data
df = pd.DataFrame({'A': np.random.choice(['foo', 'bar'], 100),
'B': np.random.choice(['one', 'two', 'three'], 100),
'C': np.random.choice(['I1', 'I2', 'I3', 'I4'], 100),
'D': np.random.randint(-10,11,100),
'E': np.random.randn(100)})
p = pd.pivot_table(df, index=['A','B'], columns='C', values='D')
e = pd.pivot_table(df, index=['A','B'], columns='C', values='E')
ax = p.plot(kind='barh', xerr=e, width=0.85)
for r in ax.patches:
if r.get_x() < 0: # it it's a negative bar
ax.text(0.25, # set label on the opposite side
r.get_y() + r.get_height()/5., # y
"{:" ">7.1f}%".format(r.get_x()*100), # text
bbox={"facecolor":"red",
"alpha":0.5,
"pad":1},
fontsize=10, family="monospace", zorder=10)
else:
ax.text(-1.5, # set label on the opposite side
r.get_y() + r.get_height()/5., # y
"{:" ">6.1f}%".format(r.get_width()*100),
bbox={"facecolor":"green",
"alpha":0.5,
"pad":1},
fontsize=10, family="monospace", zorder=10)
plt.tight_layout()
which gives:
I plot the label depending on the mean value and put it on the other side of the 0-line so you're pretty sure that it will never overlap to something else, except an error bar sometimes. I set a box behind the text so it reflects the value of the mean.
There are some values you'll need to adjust depending on your figure size so the labels fit right, like:
width=0.85
+r.get_height()/5. # y
"pad":1
fontsize=10
"{:" ">6.1f}%".format(r.get_width()*100) : set total amount of char in the label (here, 6 minimum, fill with white space on the right if less than 6 char). It needs family="monospace"
Tell me if something isn't clear.
HTH