Matplotlib and IPython love child

  |   Source

This is an experiement of how one could try to improve matplotlib configuration system using IPython's traitlets system; It has probably a huge number of disatvantages going from slowing down all the matplotlib stack, as making every class name matter in backward compatibility.

Far from beeing a perfect lovechild of two awesome project, this is for now an horible mutant that at some point inspect up it's statck to find information about it's caller in some places. It woudl need a fair amount of work to be nicely integrated into matplotlib.

Warning

This post has been written with a patched version of Matplotlib, so you will not be able to reproduce this post by re-executing this notebook.

I've also ripped out part of IPython configurable sytem into a small self contained package that you would need.

TL,DR;

Here is an example where the color of any Text object of matplotlib has a configurable color as long as the object that creates it is (also) an Artist, with minimal modification of matplotlib.

In [1]:
%pylab inline
from IPConfigurable.configurable import Config
matplotlib.rc('font',size=20)
Populating the interactive namespace from numpy and matplotlib

Here is the interesting part where one cas see that everything is magically configurable.

In [2]:
matplotlib.config = Config()

# by default all Text are now purple
matplotlib.config.Text.t_color = 'purple'

# Except Text created by X/Y Axes will  red/aqua
matplotlib.config.YAxis.Text.t_color='red'
matplotlib.config.XAxis.Text.t_color='aqua'

# If this is the text of a Tick it should be orange 
matplotlib.config.Tick.Text.t_color='orange'

# unless this is an XTick, then it shoudl be Gray-ish
# as (XTick <: Tick) it will have precedence over Tick
matplotlib.config.XTick.Text.t_color=(0.4,0.4,0.3)

## legend
matplotlib.config.TextArea.Text.t_color='y'
matplotlib.config.AxesSubplot.Text.t_color='pink'
In [3]:
plt.plot(sinc(arange(0,3*np.pi,0.1)),'green', label='a sinc')
plt.ylabel('sinc(x)')
plt.xlabel('This is X')
plt.title('Title')
plt.annotate('Max',(20,0.2))
plt.legend()
Out[3]:
<matplotlib.legend.Legend at 0x1050cc910>

I love Matplotlib

I love Matplotlib and what you can do with it, I am always impressed by how Jake Van Der Plas is able to bend Matpltolib in dooing amazing things. That beeing said, default color for matplotlib graphics are not that nice, mainly because of legacy, and even if Matplotlib is hightly configurable, many libraries are trying to fix it and receipt on the net are common.

IPython configuration is magic

I'm not that familiar with Matplotlib internal, but I'm quite familiar with IPython's internal. In particular, using a lightweight version of Enthought Traits we call Traitlets, almost every pieces of IPython is configurable.

According to Clarke's third law

Any sufficiently advanced technology is indistinguishable from magic.

So I'll assume that IPython configuration system is magic. Still there is some rule you shoudl know.

In IPython, any object that inherit from Configurable can have attributes that are configurable. The name of the configuration attribute that will allow to change the value of this attribute are easy, it's Class.attribute = Value, and if the creator of an object took care of passing a reference to itself, you can nest config as ParentClass.Class.attribute = value, by dooing so only Class created by ParentClass will have value set. With a dummy example.

class Foo(Configurable):

    length = Integer(1,config=True)
    ...

class Bar(Configurable):

    def __init__(self):
        foo = Foo(parent=self)

class Rem(Bar):
    pass

every Foo object length can be configured with Foo.length=2 or you can target a subset of foo by setting Rem.Foo.length or Bar.Foo.lenght.

But this might be a little abstarct, let's do a demo with matplotlib

In [4]:
cd ~/matplotlib/
/Users/bussonniermatthias/matplotlib

let's make matplotlib Artist an IPython Configurable, grab default config from matplotlib.config if it exist, and pass it to parrent.

-class Artist(object):
+class Artist(Configurable):

-    def __init__(self):
+    def __init__(self, config=None, parent=None):
+        
+        c = getattr(matplotlib,'config',Config({}))
+        if config :
+            c.merge(config)
+        super(Artist, self).__init__(config=c, parent=parent)

Now we will define 2 attributes of Patches (a subclass of Artist) ; t_color, t_lw that are respectively a Color or a Float and set the default color of current Patch to this attribute.

 class Patch(artist.Artist):

+    t_color = MaybeColor(None,config=True)
+    t_lw = MaybeFloat(None,config=True)
+
     ...
         if linewidth is None:
-            linewidth = mpl.rcParams['patch.linewidth']
+            if self.t_lw is not None:
+                linewidth = self.t_lw
+            else:
+                linewidth = mpl.rcParams['patch.linewidth']
         ...
         if color is None:
-            color = mpl.rcParams['patch.facecolor']
+            if self.t_color is not None:
+                color = self.t_color
+            else :
+                color = mpl.rcParams['patch.facecolor']

One could also set _t_color_default to mpl.rcParams['patch.facecolor'] but it becommes complicaed for the explanation

That's enough

This is the minimum viable to have this to work we can know magically configure independently any Subclass of Patches

We know that Wedge, Ellipse,... and other are part of this category, so let's play with their t_color

In [5]:
# some minimal imports
import matplotlib.pyplot as plt;

import numpy as np
import matplotlib.path as mpath
import matplotlib.lines as mlines
import matplotlib.patches as mpatches
from matplotlib.collections import PatchCollection
In [6]:
matplotlib.config = Config({
                            "Wedge"         :{"t_color":"0.4"},
                            "Ellipse"       :{"t_color":(0.9, 0.3, 0.7)},
                            "Circle"        :{"t_color":'red'},
                            "Arrow"         :{"t_color":'green'},
                            "RegularPolygon":{"t_color":'aqua'},
                            "FancyBboxPatch":{"t_color":'y'},
                            })

Let's see what this gives :

In [7]:
"""
example derived from 
http://matplotlib.org/examples/shapes_and_collections/artist_reference.html
"""
fig, ax = plt.subplots()
grid = np.mgrid[0.2:0.8:3j, 0.2:0.8:3j].reshape(2, -1).T

patches = []
patches.append(mpatches.Circle(grid[0], 0.1,ec="none"))
patches.append(mpatches.Rectangle(grid[1] - [0.025, 0.05], 0.05, 0.1, ec="none"))
patches.append(mpatches.Wedge(grid[2], 0.1, 30, 270, ec="none"))
patches.append(mpatches.RegularPolygon(grid[3], 5, 0.1))
patches.append(mpatches.Ellipse(grid[4], 0.2, 0.1))
patches.append(mpatches.Arrow(grid[5, 0]-0.05, grid[5, 1]-0.05, 0.1, 0.1, width=0.1))
patches.append(mpatches.FancyBboxPatch(
        grid[7] - [0.025, 0.05], 0.05, 0.1,
        boxstyle=mpatches.BoxStyle("Round", pad=0.02)))
collection = PatchCollection(patches, match_original=True)
ax.add_collection(collection)

plt.subplots_adjust(left=0, right=1, bottom=0, top=1)
plt.axis('equal')
plt.axis('off')
plt.show()

It works !!! Isn't that great ? Free configuration for all Artists ; of course as long as you don't explicitely set the color, or course.

Let's be ugly.

We need slightly more to have nested configuration, each Configurable have to be passed the parent keyword, but Matplotlib is not made to pass the parent keyword to every Artist it creates, this prevent the use of nested configuration. Still using inspect, we can try to get a handle on the parent, by walking up the stack.

adding the following in Artist constructor:

import inspect 
def __init__(self, config=None, parent=None):
    i_parent = inspect.currentframe().f_back.f_back.f_locals.get('self',None)
    if (i_parent is not self) and (parent is not i_parent) :
        if (isinstance(i_parent,Configurable)):
            parent = i_parent
    ....

let's patch Text to also accept a t_color configurable, bacause Text is a good candidate for nesting configurability:

 class Text(Artist):
+    t_color = MaybeColor(None,config=True)

         if color is None:
-            color = rcParams['text.color']
+            if self.t_color is not None:
+                color = self.t_color
+            else :
+                color = rcParams['text.color']
+        if self.t_color is not None:
+            color = self.t_color
+
         if fontproperties is None:
             fontproperties = FontProperties()

Now we shoudl be able to make default Text always purple, nice things about Config object is that once created they accept acces of any attribute with dot notation.

In [8]:
matplotlib.config.Text.t_color = 'purple'
In [9]:
fig,ax = plt.subplots(1,1)
plt.plot(sinc(arange(0,6,0.1)))
plt.ylabel('SinC(x)')
plt.title('SinC of X')
Out[9]:
<matplotlib.text.Text at 0x10512e0d0>

Ok, not much further than current matplotlib configuratin right ?

We also know that XAxis and Yaxis inherit from Axis which itself inherit from Artist. Both are responsible from creating the x- and y-label

In [10]:
matplotlib.config.YAxis.Text.t_color='r'
matplotlib.config.YAxis.Text.t_color='aqua'

same goes for Tick, XTick and YTicks. I can of course set a parameter to the root class:

In [11]:
matplotlib.config.Tick.Text.t_color='orange'

and overwrite it for a specific subclass:

In [12]:
matplotlib.config.XTick.Text.t_color='gray'
In [13]:
fig,ax = plt.subplots(1,1)
plt.plot(sinc(arange(0,6,0.1)))
plt.ylabel('SinC(x)')
plt.title('SinC of X')
Out[13]:
<matplotlib.text.Text at 0x10527d610>

This is, as far as I know not possible to do with current matplotlib confuguration system. At least not without adding a rc-param for each and every imaginable combinaison.

What more ?

First thing it that this make it trivial for external library to plug into matplotlib configuration system to have their own defaults/configurable defaults.

You also can of course refine configurability by use small no-op class that inherit from base classes and give them meaning. Especially right now, Ticks are separated in XTick and YTick with a major/minor attribute. They shoudl probably be refactor into MajorTick/MinorTick. With that you can mix and match configuration from the most global Axis.Tick.value=... to the more precise YAxis.MinorTick.

Let's do an example with a custom artist that create 2 kinds of circles. We'll need a custom no-op class that inherit Circle.

In [14]:
class CenterCircle(mpatches.Circle):
   pass
In [15]:
matplotlib.config = Config()
matplotlib.config.Circle.t_color='red'
matplotlib.config.CenterCircle.t_color='aqua'
In [16]:
from IPConfigurable.configurable import Configurable
import math

class MyGenArtist(Configurable):
    
    def n_circle(self, x,y,r,n=3):
        pi = math.pi
        sin,cos = math.sin, math.cos
        l= []
        for i in range(n):
            l.append(mpatches.Circle(  ## here Circle
                                (x+2*r*cos(i*2*pi/n),y+2*r*sin(i*2*pi/n)),
                                 r,
                                 ec="none",
                                ))
        l.append(CenterCircle((x,y),r))  ## Here CenterCircle
        return l
        
        


fig, ax = plt.subplots()
patches = []
patches.extend(MyGenArtist().n_circle(4,1,0.5))
patches.extend(MyGenArtist().n_circle(2,4,0.5,n=6))
patches.extend(MyGenArtist().n_circle(1,1,0.5,n=5))
collection = PatchCollection(patches, match_original=True)
ax.add_collection(collection)
plt.subplots_adjust(left=0, right=1, bottom=0, top=1)
plt.axis('equal')
plt.axis('off')
Out[16]:
(-1.0, 6.0, -1.0, 6.0)

What's next ?

This configuration system is, of course not limited to Matplotib. But to use it it should probably be better decoupled into a separated package first, independently of IPython.

Also if it is ever accepted into matplotlib, there will still be a need to adapt current mechnisme to work on top of this.

Bonus

This patches version of matplotlib keep track of all the Configurable it discovers while you use it. Here is a non exaustive list.

In [17]:
from matplotlib import artist
print "-------------------------"
print "Some single configurables"
print "-------------------------"
for k in  sorted(artist.Artist.s):
    print k
print ""
print "----------------------------------"
print "Some possible nested configurables"
print "----------------------------------;"
for k in sorted(artist.Artist.ps):
    print k
-------------------------
Some single configurables
-------------------------
Annotation
Arrow
AxesSubplot
CenterCircle
Circle
DrawingArea
Ellipse
FancyBboxPatch
Figure
HPacker
Legend
Line2D
PatchCollection
Rectangle
RegularPolygon
Spine
Text
TextArea
VPacker
Wedge
XAxis
XTick
YAxis
YTick

----------------------------------
Some possible nested configurables
----------------------------------;
AxesSubplot.Legend
AxesSubplot.Text
AxesSubplot.XAxis
AxesSubplot.YAxis
TextArea.Text
XAxis.Text
XTick.Text
YAxis.Text
YTick.Text