Matplotlib: Align text with axis ticks - python

I want to display a custom text next to my plot's y-axis, as in this minimal example:
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.text(-0.05, 0.5, '$x$')
plt.show()
The horizontal alignment 0.05 is something I figure out by trial and error.
Unfortunately, 0.05 is only right for exactly one size of the plot window.
For the default window size, the text is where I want it:
But once I enlarge the plot window, my text gets lost in no-man's-land:
I tried ax.text(-ax.yaxis.get_tick_padding, 0.5, '$x$'), but padding appears to be measured in different units.
How can I make sure my text has the same distance from the y-axis for every window size?

You may use ax.annotate instead of ax.text as it allows a little bit more freedom. Specifically it allows to annotate a point in some coordinate system with the text being offsetted to that location in different coordinates.
The following would create an annotation at position (0, 0.5) in the coordinate system given by the yaxis_transform, i.e (axes coordinates, data coordinates). The text itself is then offsetted by -5 points from this location.
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.annotate('$x$', xy=(0, 0.5), xycoords=ax.get_yaxis_transform(),
xytext=(-5,0), textcoords="offset points", ha="right", va="center")
plt.show()
Note that -5 is also just an estimate and you may again want to find the best value yourself. However, having done so, this will stay the same as long as the padding and fontsize of the labels do not change.

When tick lengths or padding have been changed, you can find out the exact offset by querying one of the ticks.
The ticks have a "pad" (between label and tick mark) and a "padding" (length of the tick mark), both measured in "points".
In the default setting, both are 3.5, giving a padding of 7.
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
tick = ax.yaxis.get_major_ticks()[-1]
padding = tick.get_pad() + tick.get_tick_padding()
ax.annotate('$half$', xy=(0, 0.5), xycoords=ax.get_yaxis_transform(),
xytext=(-padding,0), textcoords="offset points", ha="right", va="center")
plt.show()

Related

Add custom labels to seaborn color palette

import matplotlib.pyplot as plt
import seaborn as sns
low = '#00FF00'
medium = '#FFFF00'
high = '#FF0000'
plt.figure(figsize=(1,2))
sns.color_palette("blend:#00FF00,#FFFF00,#FF0000",as_cmap=True)
The result that I get is:
But the labels are "under, bad, over". Not sure from where it is pulling it, is there a way to rename or remove those variables? I tried the following, but did not work
ax = sns.color_palette("blend:#00FF00,#FFFF00,#FF0000",as_cmap=True)
ax.collections[0].colorbar.set_label("Hello")
I want the labels to be low, med and high
Using appropriate grid of subplots, we can create the expected figure. However, it involves fine adjustments to the plot size and grid size to really get the expected result:
import matplotlib.pyplot as plt
import matplotlib as mpl
import seaborn as sns
low = '#00FF00'
medium = '#FFFF00'
high = '#FF0000'
plt.figure(figsize = (10, 2))
# Plot the wider colorbar
ax = plt.subplot(4, 10, (1, 30))
cmap = sns.color_palette("blend:#00FF00,#FFFF00,#FF0000",as_cmap=True)
cbar = plt.colorbar(mpl.cm.ScalarMappable(cmap=cmap), cax = ax, orientation="horizontal", ticks=None)
cbar.set_ticks([])
ax.set_title("blend", loc='left', fontweight='bold')
# Function to create box labels and arrange corresponding text
def labels(position, color, label, label_position):
"""
The first and second arguments decides the position of the subplot and its fill color, respectively.
The third argument decides the text to put alongside the subplot.
"""
ax = plt.subplot(5, 100, position)
ax.set_xticks([])
ax.set_yticks([])
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
x = [0, 1]
y = [1, 1]
ax.fill_between(x, y, color=color)
ax.text(*label_position, label)
labels((401, 403), low, "low", (1.5, 0.2))
labels((449, 451), medium, "medium", (-3.0, 0.2))
labels((498, 500), high, "high", (-1.5, 0.2))
plt.show()
This gives:
In the code above:
The colorbar spans first thirty subplots of the grid with size 4 x 10 (created using plt.subplot(4, 10, (1, 30)). However, to add labels below the colorbar, we can use a grid that has a lot more Axes in a given row, so we have a 5 x 100 grid for labels, but we only utilise a few Axes of this grid to get label boxes (created using plt.subplot(5, 100, position). I was not exactly sure how many rows would be suitable for the grid of labels, but the number of rows definitely need to be more than 3. Otherwise, the grid of label boxes will cover colorbar and it will not be visible.
We want to make sure that label boxes don't cover the colorbar, thus we use the 5th row of the grid for the boxes. As there are 100 columns in this grid, the first box spans the grid from the index 401 and ends at 403: I used more than one Axes on the grid otherwise the boxes might have looked very thin. This indexing is motivated directly by the expected output: the medium box must cover 50th Axes in the last row of the grid (since it should be in the middle), and the high box must be at the end of the row, thus it should span from the index 498 to 500.
plt.subplot creates an Axes for label boxes whose xlim and ylim are both set to (0, 1). This means the squares we see at the bottom of the figure are of size 1 x 1.
Using ax.set_ticks, we have removed the ticks on x- and y-axis of the Axes giving us squares without any ticks. Same for colorbar as well.
ax.text adds the label at the position label_position. I wasn't sure about the y-coordinate of the label. But since we have already set the x-lim and y-lim of the Axes to (0, 1), x-coordinate in the label_position will be more than one if the label needs on the right side of the Axes and less than zero if it needs to be on the left side of the Axes e.g. the xs in the label_position of the labels low and medium are of opposite sign. The position of the text ultimately boils down to the font size and the length of the string the label represents, thus it's a matter of fine adjustments, but can be programmatically taken care of.

Shrink and anchor matplotlib colorbar

How do I use colorbar attributes such as in this snippet:
import seaborn as sns
uniform_data = np.random.rand(10, 12) # random data
ax = sns.heatmap(uniform_data)
cbar = ax.collections[0].colorbar
plt.show()
To shrink the colorbar and put it to the bottom and anchored to the lower left corner (that is, NOT centered)?
Something like this, but with the colorbar shrunk to, let's say 70% and anchored to the bottom left
I am unsure how to search for the methods as cbar.set_location() is not available.
If you want infinite customizability, you need to go more low level than you will get with seaborn, which gives convenience, but can't have knobs for everything.
The most straightforward way to get what you want is to place the colorbar axes manually. Note that you will need to play with the y offset, which I set here to -0.2.
import matplotlib.pyplot as plt
import numpy as np
uniform_data = np.random.rand(10, 12) # random data
fig, ax = plt.subplots(layout='constrained')
pc = ax.imshow(uniform_data)
cbax = ax.inset_axes([0, -0.2, 0.7, 0.05], transform=ax.transAxes)
fig.colorbar(pc, ax=ax, cax=cbax, shrink=0.7, orientation='horizontal')
plt.show()
You could create the colorbar via seaborn, extract its position, adapt it and set it again:
from matplotlib import pyplot as plt
import seaborn as sns
import numpy as np
uniform_data = np.random.rand(10, 12)
ax = sns.heatmap(uniform_data, cmap='rocket_r', cbar_kws={'orientation': 'horizontal', 'ticks': np.linspace(0, 1, 6)})
cax = ax.collections[0].colorbar.ax # get the ax of the colorbar
pos = cax.get_position() # get the original position
cax.set_position([pos.x0, pos.y0, pos.width * 0.6, pos.height]) # set a new position
cax.set_frame_on(True)
cax.invert_xaxis() # invert the direction of the colorbar
for spine in cax.spines.values(): # show the colorbar frame again
spine.set(visible=True, lw=.8, edgecolor='black')
plt.show()
Note that you need cbar_kws={'orientation': 'horizontal'} for a horizontal colorbar that by default is aligned with the x-axis.
After using .set_position, something like plt.tight_layout() won't work anymore.
About your new questions:
cax.invert_xaxis() doesn't invert the colorbar direction
Yes it does. You seem to want to reverse the colormap. Matplotlib's convention is to append _r to the colormap name. In this case, seaborn is using the rocket colormap, rocket_r would be the reverse. Note that changing the ticks doesn't work the way you try it, as these are just numeric positions which will be sorted before they are applied.
If you want to show 0 and 1 in the colorbar (while the values in the heatmap are e.g. between 0.001 and 0.999, you could use vmin and vmax. E.g. sns.heatmap(..., vmin=0, vmax=1). vmin and vmax are one way to change the mapping between the values and the colors. By default, vmin=data.min() and vmax=data.max().
To show the colorbar outline: Add a black frame around a colorbar
ax.collections[0].colorbar is a colorbar, which in the latest versions also supports some functions to set ticks
ax.collections[0].colorbar.ax is an Axes object (a subplot). Matplotlib creates a small subplot on which the colorbar will be drawn. axs support a huge number of functions to change how the subplot looks or to add new elements. Note that a stackoverflow answer isn't meant to put of full matplotlib tutorial. The standard tutorials could be a starting point.

Place legend above the ax at a consistent distance

I'm trying to place a legend just above the ax in matplotlib using ax.legend(loc=(0, 1.1)); however, if I change the figure size from (5,5) to (5,10) the legend shows up at a different distance from the top edge of the plot.
Is there any way to reference the top edge of the plot and offset it a set distance from it?
Thanks
There is a constant distance between the legend bounding box and the axes by default. This is set via the borderaxespad parameter. This defaults to the rc value of rcParams["legend.borderaxespad"], which is usually set to 0.5 (in units of the fontsize).
So essentially you get the behaviour you're asking for for free. Mind however that you should specify the loc to the corner of the legend from which that padding is to be taken. I.e.
import numpy as np
import matplotlib.pyplot as plt
for figsize in [(5,4), (5,9)]:
fig, ax = plt.subplots(figsize=figsize)
ax.plot([1,2,3], label="label")
ax.legend(loc="lower left", bbox_to_anchor=(0,1))
plt.show()
For more detailed explanations on how to position legend outside the axes, see How to put the legend out of the plot. Also relevant: How to specify legend position in matplotlib in graph coordinates

Anchor or lock text to a marker in Matplotlib

Is there any way to anchor or lock text to a marker? When using the interactive zoom provided by pyplot, the text is moving out of bounds like shown in picture.
import matplotlib.pyplot as plt
x=[2,4]
y=[2,3]
fig, ax = plt.subplots()
ax.plot(x, y, 'ro',markersize=23)
offset = 1.0
ax.set_xlim(min(x)-offset, max(x)+ offset)
ax.set_ylim(min(y)-offset, max(y)+ offset)
for x,y in zip(x,y):
ax.annotate(str(y), xy=(x-0.028,y-0.028))
plt.show()
The simple answer is that it is done by default. The text's lower-left corner is tied to the position specified with xy. Now, as you can see in the figures below, when you zoom interactively onto one of the markers, the relative position of the marker and text is preserved.
import matplotlib.pyplot as plt
x=[2,4]
y=[2,3]
fig, ax = plt.subplots()
ax.plot(x, y, 'ro',markersize=23)
offset = 1.0
ax.set_xlim(min(x)-offset, max(x)+ offset)
ax.set_ylim(min(y)-offset, max(y)+ offset)
for x,y in zip(x,y):
ax.annotate(str(y), xy=(x,y))
plt.show()
However, this looks rather ugly as the text is now in the upper right quadrant of your marker and sometimes even lies over the marker's edge. I suppose this is the reason why you have added an offset of 0.028 in xy=(x-0.028,y-0.028), hence introducing the behavior you are now trying to get rid of. What happens is that by default matplotlib uses the data's coordinate system to position your text. When you zoom, the 0.028 data-units represents an increasing proportion of your frame and the text "drifts away" from your marker, eventually ending outside of the visible range of values.
To get rid of this behavior you need to change the coordinate system. The annotate argument textcoords can be set to offset point. Here xytext lets you specify an offset (in points) from the xy position:
ax.annotate(str(y), xy=(x,y), xytext=(-5.0,-5.0), textcoords='offset points')
Now the challenging part will be to assess the size of the text that you want to add to your plot in order to determine the offset's value. Likely the text will change but there is no way to determine the size of the rendered text string before it is drawn. See this post on that matter. In the example code below, I attempted to introduce a bit of flexibility:
import matplotlib.pyplot as plt
x=[2,4]
y=[2,12]
fig, ax = plt.subplots()
ax.plot(x, y, 'ro',markersize=23)
offset = 1.0
ax.set_xlim(min(x)-offset, max(x)+ offset)
ax.set_ylim(min(y)-offset, max(y)+ offset)
for x,y in zip(x,y):
text = str(y)
fontsize, aspect_ratio = (12, 0.5) # needs to be adapted to font
width = len(text) * aspect_ratio * fontsize
height = fontsize
a = ax.annotate(text, xy=(x,y), xytext=(-width/2.0,-height/2.0), textcoords='offset points')
plt.show()
Here, the text is a string of length 2 and it is still roughly centered on the marker despite a lot of zooming. Yet you will need to adapt this solution to your font and fontsize. Suggestions for improvements are much welcome.

How can I make the xtick labels of a plot be simple drawings using matplotlib?

Instead of words or numbers being the tick labels of the x axis, I want to draw a simple drawing (made of lines and circles) as the label for each x tick. Is this possible? If so, what is the best way to go about it in matplotlib?
I would remove the tick labels and replace the text with patches. Here is a brief example of performing this task:
import matplotlib.pyplot as plt
import matplotlib.patches as patches
# define where to put symbols vertically
TICKYPOS = -.6
fig = plt.figure()
ax = fig.add_subplot(111)
ax.plot(range(10))
# set ticks where your images will be
ax.get_xaxis().set_ticks([2,4,6,8])
# remove tick labels
ax.get_xaxis().set_ticklabels([])
# add a series of patches to serve as tick labels
ax.add_patch(patches.Circle((2,TICKYPOS),radius=.2,
fill=True,clip_on=False))
ax.add_patch(patches.Circle((4,TICKYPOS),radius=.2,
fill=False,clip_on=False))
ax.add_patch(patches.Rectangle((6-.1,TICKYPOS-.05),.2,.2,
fill=True,clip_on=False))
ax.add_patch(patches.Rectangle((8-.1,TICKYPOS-.05),.2,.2,
fill=False,clip_on=False))
This results in the following figure:
It is key to set clip_on to False, otherwise patches outside the axes will not be shown. The coordinates and sizes (radius, width, height, etc.) of the patches will depend on where your axes is in the figure. For example, if you are considering doing this with subplots, you will need to be sensitive of the patches placement so as to not overlap any other axes. It may be worth your time investigating Transformations, and defining the positions and sizes in an other unit (Axes, Figure or display).
If you have specific image files that you want to use for the symbols, you can use the BboxImage class to create artists to be added to the axes instead of patches. For example I made a simple icon with the following script:
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(1,1),dpi=400)
ax = fig.add_axes([0,0,1,1],frameon=False)
ax.set_axis_off()
ax.plot(range(10),linewidth=32)
ax.plot(range(9,-1,-1),linewidth=32)
fig.savefig('thumb.png')
producing this image:
Then I created a BboxImage at the location I want the tick label and of the size I want:
lowerCorner = ax.transData.transform((.8,TICKYPOS-.2))
upperCorner = ax.transData.transform((1.2,TICKYPOS+.2))
bbox_image = BboxImage(Bbox([lowerCorner[0],
lowerCorner[1],
upperCorner[0],
upperCorner[1],
]),
norm = None,
origin=None,
clip_on=False,
)
Noticed how I used the transData transformation to convert from data units to display units, which are required in the definition of the Bbox.
Now I read in the image using the imread routine, and set it's results (a numpy array) to the data of bbox_image and add the artist to the axes:
bbox_image.set_data(imread('thumb.png'))
ax.add_artist(bbox_image)
This results in an updated figure:
If you do directly use images, make sure to import the required classes and methods:
from matplotlib.image import BboxImage,imread
from matplotlib.transforms import Bbox
The other answer has some drawbacks because it uses static coordinates. It will hence not work when changing the figure size or zooming and panning the plot.
A better option is to directly define the positions in the coordinate systems of choice. For the xaxis it makes sense to use data coordinates for the x position and axes coordinates for y position.
Using matplotlib.offsetboxes makes this rather simple. The following would position a box with a circle and a box with an image at coordinates (-5,0) and (5,0) respectively and offsets them a bit to the lower such that they'll look as if they were ticklabels.
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.offsetbox import (DrawingArea, OffsetImage,AnnotationBbox)
fig, ax = plt.subplots()
ax.plot([-10,10], [1,3])
# Annotate the 1st position with a circle patch
da = DrawingArea(20, 20, 10, 10)
p = mpatches.Circle((0, 0), 10)
da.add_artist(p)
ab = AnnotationBbox(da, (-5,0),
xybox=(0, -7),
xycoords=("data", "axes fraction"),
box_alignment=(.5, 1),
boxcoords="offset points",
bboxprops={"edgecolor" : "none"})
ax.add_artist(ab)
# Annotate the 2nd position with an image
arr_img = plt.imread("https://i.stack.imgur.com/FmX9n.png", format='png')
imagebox = OffsetImage(arr_img, zoom=0.2)
imagebox.image.axes = ax
ab = AnnotationBbox(imagebox, (5,0),
xybox=(0, -7),
xycoords=("data", "axes fraction"),
boxcoords="offset points",
box_alignment=(.5, 1),
bboxprops={"edgecolor" : "none"})
ax.add_artist(ab)
plt.show()
Note that many shapes exist as unicode symbols, such that one can simply set the ticklabels with those symbols. For such a solution, see How to use a colored shape as yticks in matplotlib or seaborn?

Categories

Resources