Composable cycles

Version:

0.12

Date:

Jan 18, 2024

Docs

https://matplotlib.org/cycler

PyPI

https://pypi.python.org/pypi/Cycler

GitHub

https://github.com/matplotlib/cycler

cycler API

cycler()

Create a new Cycler object from a single positional argument, a pair of positional arguments, or the combination of keyword arguments.

Cycler(left[, right, op])

Composable cycles.

concat(left, right)

Concatenate Cyclers, as if chained using itertools.chain.

The public API of cycler consists of a class Cycler, a factory function cycler(), and a concatenation function concat(). The factory function provides a simple interface for creating ‘base’ Cycler objects while the class takes care of the composition and iteration logic.

Cycler Usage

Base

A single entry Cycler object can be used to easily cycle over a single style. To create the Cycler use the cycler() function to link a key/style/keyword argument to series of values. The key must be hashable (as it will eventually be used as the key in a dict).

In [1]: from __future__ import print_function

In [2]: from cycler import cycler

In [3]: color_cycle = cycler(color=['r', 'g', 'b'])

In [4]: color_cycle
Out[4]: cycler('color', ['r', 'g', 'b'])

The Cycler knows its length and keys:

In [5]: len(color_cycle)
Out[5]: 3

In [6]: color_cycle.keys
Out[6]: {'color'}

Iterating over this object will yield a series of dict objects keyed on the label

In [7]: for v in color_cycle:
   ...:     print(v)
   ...: 
{'color': 'r'}
{'color': 'g'}
{'color': 'b'}

Cycler objects can be passed as the argument to cycler() which returns a new Cycler with a new label, but the same values.

In [8]: cycler(ec=color_cycle)
Out[8]: cycler('ec', ['r', 'g', 'b'])

Iterating over a Cycler results in the finite list of entries, to get an infinite cycle, call the Cycler object (a-la a generator)

In [9]: cc = color_cycle()

In [10]: for j, c in zip(range(5),  cc):
   ....:     print(j, c)
   ....: 
0 {'color': 'r'}
1 {'color': 'g'}
2 {'color': 'b'}
3 {'color': 'r'}
4 {'color': 'g'}

Composition

A single Cycler can just as easily be replaced by a single for loop. The power of Cycler objects is that they can be composed to easily create complex multi-key cycles.

Addition

Equal length Cyclers with different keys can be added to get the ‘inner’ product of two cycles

In [11]: lw_cycle = cycler(lw=range(1, 4))

In [12]: wc = lw_cycle + color_cycle

The result has the same length and has keys which are the union of the two input Cycler’s.

In [13]: len(wc)
Out[13]: 3

In [14]: wc.keys
Out[14]: {'color', 'lw'}

and iterating over the result is the zip of the two input cycles

In [15]: for s in wc:
   ....:     print(s)
   ....: 
{'lw': 1, 'color': 'r'}
{'lw': 2, 'color': 'g'}
{'lw': 3, 'color': 'b'}

As with arithmetic, addition is commutative

In [16]: lw_c = lw_cycle + color_cycle

In [17]: c_lw = color_cycle + lw_cycle

In [18]: for j, (a, b) in enumerate(zip(lw_c, c_lw)):
   ....:    print('({j}) A: {A!r} B: {B!r}'.format(j=j, A=a, B=b))
   ....: 
(0) A: {'lw': 1, 'color': 'r'} B: {'color': 'r', 'lw': 1}
(1) A: {'lw': 2, 'color': 'g'} B: {'color': 'g', 'lw': 2}
(2) A: {'lw': 3, 'color': 'b'} B: {'color': 'b', 'lw': 3}

For convenience, the cycler() function can have multiple key-value pairs and will automatically compose them into a single Cycler via addition

In [19]: wc = cycler(c=['r', 'g', 'b'], lw=range(3))

In [20]: for s in wc:
   ....:     print(s)
   ....: 
{'c': 'r', 'lw': 0}
{'c': 'g', 'lw': 1}
{'c': 'b', 'lw': 2}

Multiplication

Any pair of Cycler can be multiplied

In [21]: m_cycle = cycler(marker=['s', 'o'])

In [22]: m_c = m_cycle * color_cycle

which gives the ‘outer product’ of the two cycles (same as itertools.product() )

In [23]: len(m_c)
Out[23]: 6

In [24]: m_c.keys
Out[24]: {'color', 'marker'}

In [25]: for s in m_c:
   ....:     print(s)
   ....: 
{'marker': 's', 'color': 'r'}
{'marker': 's', 'color': 'g'}
{'marker': 's', 'color': 'b'}
{'marker': 'o', 'color': 'r'}
{'marker': 'o', 'color': 'g'}
{'marker': 'o', 'color': 'b'}

Note that unlike addition, multiplication is not commutative (like matrices)

In [26]: c_m = color_cycle * m_cycle

In [27]: for j, (a, b) in enumerate(zip(c_m, m_c)):
   ....:    print('({j}) A: {A!r} B: {B!r}'.format(j=j, A=a, B=b))
   ....: 
(0) A: {'color': 'r', 'marker': 's'} B: {'marker': 's', 'color': 'r'}
(1) A: {'color': 'r', 'marker': 'o'} B: {'marker': 's', 'color': 'g'}
(2) A: {'color': 'g', 'marker': 's'} B: {'marker': 's', 'color': 'b'}
(3) A: {'color': 'g', 'marker': 'o'} B: {'marker': 'o', 'color': 'r'}
(4) A: {'color': 'b', 'marker': 's'} B: {'marker': 'o', 'color': 'g'}
(5) A: {'color': 'b', 'marker': 'o'} B: {'marker': 'o', 'color': 'b'}

Integer Multiplication

Cyclers can also be multiplied by integer values to increase the length.

In [28]: color_cycle * 2
Out[28]: cycler('color', ['r', 'g', 'b', 'r', 'g', 'b'])

In [29]: 2 * color_cycle
Out[29]: cycler('color', ['r', 'g', 'b', 'r', 'g', 'b'])

Concatenation

Cycler objects can be concatenated either via the Cycler.concat() method

In [30]: color_cycle.concat(color_cycle)
Out[30]: cycler('color', ['r', 'g', 'b', 'r', 'g', 'b'])

or the top-level concat() function

In [31]: from cycler import concat

In [32]: concat(color_cycle, color_cycle)
Out[32]: cycler('color', ['r', 'g', 'b', 'r', 'g', 'b'])

Slicing

Cycles can be sliced with slice objects

In [33]: color_cycle[::-1]
Out[33]: cycler('color', ['b', 'g', 'r'])

In [34]: color_cycle[:2]
Out[34]: cycler('color', ['r', 'g'])

In [35]: color_cycle[1:]
Out[35]: cycler('color', ['g', 'b'])

to return a sub-set of the cycle as a new Cycler.

Inspecting the Cycler

To inspect the values of the transposed Cycler use the Cycler.by_key method:

In [36]: c_m.by_key()
Out[36]: 
{'marker': ['s', 'o', 's', 'o', 's', 'o'],
 'color': ['r', 'r', 'g', 'g', 'b', 'b']}

This dict can be mutated and used to create a new Cycler with the updated values

In [37]: bk = c_m.by_key()

In [38]: bk['color'] = ['green'] * len(c_m)

In [39]: cycler(**bk)
Out[39]: (cycler('marker', ['s', 'o', 's', 'o', 's', 'o']) + cycler('color', ['green', 'green', 'green', 'green', 'green', 'green']))

Examples

We can use Cycler instances to cycle over one or more kwarg to plot :

from cycler import cycler
from itertools import cycle

fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True,
                               figsize=(8, 4))
x = np.arange(10)

color_cycle = cycler(c=['r', 'g', 'b'])

for i, sty in enumerate(color_cycle):
   ax1.plot(x, x*(i+1), **sty)


for i, sty in zip(range(1, 5), cycle(color_cycle)):
   ax2.plot(x, x*i, **sty)

(Source code, png, hires.png, pdf)

_images/index-1.png
from cycler import cycler
from itertools import cycle

fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True,
                               figsize=(8, 4))
x = np.arange(10)

color_cycle = cycler(c=['r', 'g', 'b'])
ls_cycle = cycler('ls', ['-', '--'])
lw_cycle = cycler('lw', range(1, 4))

sty_cycle = ls_cycle * (color_cycle + lw_cycle)

for i, sty in enumerate(sty_cycle):
   ax1.plot(x, x*(i+1), **sty)

sty_cycle = (color_cycle + lw_cycle) * ls_cycle

for i, sty in enumerate(sty_cycle):
   ax2.plot(x, x*(i+1), **sty)

(Source code, png, hires.png, pdf)

_images/index-2.png

Persistent Cycles

It can be useful to associate a given label with a style via dictionary lookup and to dynamically generate that mapping. This can easily be accomplished using a defaultdict

In [40]: from cycler import cycler as cy

In [41]: from collections import defaultdict

In [42]: cyl = cy('c', 'rgb') + cy('lw', range(1, 4))

To get a finite set of styles

In [43]: finite_cy_iter = iter(cyl)

In [44]: dd_finite = defaultdict(lambda : next(finite_cy_iter))

or repeating

In [45]: loop_cy_iter = cyl()

In [46]: dd_loop = defaultdict(lambda : next(loop_cy_iter))

This can be helpful when plotting complex data which has both a classification and a label

finite_cy_iter = iter(cyl)
styles = defaultdict(lambda : next(finite_cy_iter))
for group, label, data in DataSet:
    ax.plot(data, label=label, **styles[group])

which will result in every data with the same group being plotted with the same style.

Exceptions

A ValueError is raised if unequal length Cyclers are added together

In [47]: cycler(c=['r', 'g', 'b']) + cycler(ls=['-', '--'])
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[47], line 1
----> 1 cycler(c=['r', 'g', 'b']) + cycler(ls=['-', '--'])

File ~/code/cycler/venv/lib64/python3.12/site-packages/cycler/__init__.py:283, in Cycler.__add__(self, other)
    275 """
    276 Pair-wise combine two equal length cyclers (zip).
    277 
   (...)
    280 other : Cycler
    281 """
    282 if len(self) != len(other):
--> 283     raise ValueError(
    284         f"Can only add equal length cycles, not {len(self)} and {len(other)}"
    285     )
    286 return Cycler(
    287     cast(Cycler[Union[K, L], Union[V, U]], self),
    288     cast(Cycler[Union[K, L], Union[V, U]], other),
    289     zip
    290 )

ValueError: Can only add equal length cycles, not 3 and 2

or if two cycles which have overlapping keys are composed

In [48]: color_cycle = cycler(c=['r', 'g', 'b'])

In [49]: color_cycle + color_cycle
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[49], line 1
----> 1 color_cycle + color_cycle

File ~/code/cycler/venv/lib64/python3.12/site-packages/cycler/__init__.py:286, in Cycler.__add__(self, other)
    282 if len(self) != len(other):
    283     raise ValueError(
    284         f"Can only add equal length cycles, not {len(self)} and {len(other)}"
    285     )
--> 286 return Cycler(
    287     cast(Cycler[Union[K, L], Union[V, U]], self),
    288     cast(Cycler[Union[K, L], Union[V, U]], other),
    289     zip
    290 )

File ~/code/cycler/venv/lib64/python3.12/site-packages/cycler/__init__.py:179, in Cycler.__init__(self, left, right, op)
    176 else:
    177     self._right = None
--> 179 self._keys: set[K] = _process_keys(self._left, self._right)
    180 self._op: Any = op

File ~/code/cycler/venv/lib64/python3.12/site-packages/cycler/__init__.py:84, in _process_keys(left, right)
     82 r_key: set[K] = set(r_peek.keys())
     83 if l_key & r_key:
---> 84     raise ValueError("Can not compose overlapping cycles")
     85 return l_key | r_key

ValueError: Can not compose overlapping cycles

Motivation

When plotting more than one line it is common to want to be able to cycle over one or more artist styles. For simple cases than can be done with out too much trouble:

fig, ax = plt.subplots(tight_layout=True)
x = np.linspace(0, 2*np.pi, 1024)

for i, (lw, c) in enumerate(zip(range(4), ['r', 'g', 'b', 'k'])):
   ax.plot(x, np.sin(x - i * np.pi / 4),
           label=r'$\phi = {{{0}}} \pi / 4$'.format(i),
           lw=lw + 1,
           c=c)

ax.set_xlim([0, 2*np.pi])
ax.set_title(r'$y=\sin(\theta + \phi)$')
ax.set_ylabel(r'[arb]')
ax.set_xlabel(r'$\theta$ [rad]')

ax.legend(loc=0)

(Source code, png, hires.png, pdf)

_images/index-3.png

However, if you want to do something more complicated:

fig, ax = plt.subplots(tight_layout=True)
x = np.linspace(0, 2*np.pi, 1024)

for i, (lw, c) in enumerate(zip(range(4), ['r', 'g', 'b', 'k'])):
   if i % 2:
       ls = '-'
   else:
       ls = '--'
   ax.plot(x, np.sin(x - i * np.pi / 4),
           label=r'$\phi = {{{0}}} \pi / 4$'.format(i),
           lw=lw + 1,
           c=c,
           ls=ls)

ax.set_xlim([0, 2*np.pi])
ax.set_title(r'$y=\sin(\theta + \phi)$')
ax.set_ylabel(r'[arb]')
ax.set_xlabel(r'$\theta$ [rad]')

ax.legend(loc=0)

(Source code, png, hires.png, pdf)

_images/index-4.png

the plotting logic can quickly become very involved. To address this and allow easy cycling over arbitrary kwargs the Cycler class, a composable keyword argument iterator, was developed.