GitHub repo (MIT)
Clone the repo and replace the contents of example.py
with the one at the bottom and you'll have everything setup.
Explanation of the code
A long while ago I posted the question "Abstract graphing-and-timing functions".
It was a god class, wasn't extendable/configurable, you also had to use strings to interact with timeit
.
And it just wasn't clear what was what.
A little while ago I posted an answer where I used a couple ikky timeit.timeit
calls rather than build a graph. And Graipher showed me a nice looking graph. I've continued to see these nice graphs, with no easy way to make them myself. And so I decided I needed to way-too-early-spring clean my code.
(
timer.py
) ATimer
object should be in charge of building and callingtimeit
.
This object was based oftimeit.Timer
, however I thought it needed some additional functionality:- It should be able to time multiple functions.
- It should be able to test against multiple values.
- It should only perform timings.
(
graph.py
) A graph class should be introduced that plots the data in the correct way. If you want to plot in something that isn'tmatplotlib
then you just need to change the class.- (
plotter.py
) Statistical analysis should not be in either theTimer
or the graph class. I also kept the analysis to be pretty basic as per the Python docs suggestion. - (
plotter.py
) Performing all the above should be simple with good defaults. And soPlotter
handles interacting with everything. Whilst it's a small wrapper it means that I, and other, don't have to write that many lines to get the wanted graph.
What I'd like out of a review.
The thing I find most important right now is, is the design good?
Is
Plotter
a poor design pattern? Is splitting the code out like I have done a bad idea?Is the usage for a user clean and readable?
- Is there a way to make my code more readable? I find
MatPlotLib
to be a bit on the not so clean side. - Any and all critiques are welcome.
Code
(I have left out some code, and the .pyi files. These are available on GitHub.)
graph.py
from .graphtimer import CATEGORY10
class MatPlotLib:
def _graph_times(self, graph, data, domain, colors, error, fmt):
for results, color in zip(data, colors):
values = [v.value for v in results]
if error:
errors = zip(*[v.errors or [] for v in results])
for error in errors:
lower, upper = zip(*error)
graph.fill_between(domain, upper, lower, facecolor=color, edgecolor=None, alpha=0.1)
yield graph.plot(domain, values, fmt, color=color)[0]
def graph(self, graph, data, domain, *, functions=None, colors=CATEGORY10, title=None, legend=True, error=True,
x_label='Input', y_label='Time [s]', fmt='-'):
lines = list(self._graph_times(graph, data, domain, colors, error, fmt))
if x_label is not None:
graph.set_xlabel(x_label)
if y_label is not None:
graph.set_ylabel(y_label)
if title is not None and hasattr(graph, 'set_title'):
graph.set_title(title)
if legend and functions is not None and hasattr(graph, 'legend'):
graph.legend(lines, [fn.__name__ for fn in functions], loc=0)
return lines
timers.py
import timeit
SENTINAL = object()
class MultiTimer:
"""Interface to timeit.Timer to ease timing over multiple functions."""
def __init__(self, functions, timer=timeit.Timer):
self.timer = timer
self.functions = functions
def build_timer(self, fn, domain, stmt='fn(*args)', setup='pass', timer=SENTINAL, globals=SENTINAL,
args_conv=SENTINAL):
"""Build a timeit.Timer"""
if not isinstance(domain, tuple):
domain = domain,
if args_conv is not SENTINAL:
domain = args_conv(*domain)
if not isinstance(domain, tuple):
domain = domain,
if globals is SENTINAL:
globals = {}
else:
globals = globals.copy()
globals.update({'fn': fn, 'args': domain})
# print(f'{self.timer}({stmt!r}, {setup!r}, {timer!r}, {globals!r})')
if timer is SENTINAL:
timer = timeit.default_timer
return self.timer(stmt, setup, timer, globals=globals)
def build_timers(self, domain, *args, **kwargs):
"""Build multiple timers from various inputs and functions"""
return [
[
self.build_timer(fn, dom, *args, **kwargs)
for fn in self.functions
]
for dom in domain
]
def _call(self, domain, repeat, call, *args, **kwargs):
"""Helper function to generate timing data."""
if len(domain) == 0:
raise ValueError('domain must have at least one argument.')
functions = self.build_timers(domain, *args, **kwargs)
output = [[[] for _ in domain] for _ in functions[0]]
for _ in range(repeat):
for j, fns in enumerate(functions):
for i, fn in enumerate(fns):
output[i][j].append(call(fn))
return output
def repeat(self, domain, repeat, number, *args, **kwargs):
"""Interface to timeit.Timer.repeat. `domain` is the values to pass to the functions."""
return self._call(domain, repeat, lambda f: f.timeit(number), *args, **kwargs)
def timeit(self, domain, number, *args, **kwargs):
"""Interface to timeit.Timer.timeit. `domain` is the values to pass to the functions."""
return [
[value[0] for value in values]
for values in self.repeat(domain, 1, number, *args, **kwargs)
]
def autorange(self, domain, *args, **kwargs):
"""Interface to timeit.Timer.autorange. `domain` is the values to pass to the functions."""
return [
[value[0] for value in values]
for values in self._call(domain, 1, lambda f: f.autorange(), *args, **kwargs)
]
class TimerNamespaceMeta(type):
"""Convenience class to ease creation of a MultiTimer."""
def __new__(mcs, name, bases, attrs):
if 'functions' in attrs:
raise TypeError('FunctionTimers cannot define `functions`')
if 'multi_timer' in attrs:
raise TypeError('FunctionTimers cannot define `multi_timer`')
ret: TimerNamespace = super().__new__(mcs, name, bases, attrs)
functions = [v for k, v in attrs.items() if k.startswith('test')]
ret.functions = functions
ret.multi_timer = ret.MULTI_TIMER(functions, ret.TIMER)
return ret
class TimerNamespace(metaclass=TimerNamespaceMeta):
"""Convenience class to ease creation of a MultiTimer."""
TIMER = timeit.Timer
MULTI_TIMER = MultiTimer
plotter.py
from .graph import MatPlotLib
class Plotter:
"""Interface to the timer object. Returns objects made to ease usage."""
def __init__(self, timer):
self.timer = getattr(timer, 'multi_timer', timer)
def timeit(self, number, domain, *args, **kwargs):
"""Interface to self.timer.timeit. Returns a PlotValues."""
return self.repeat(1, number, domain, *args, **kwargs).min(errors=None)
def repeat(self, repeat, number, domain, *args, **kwargs):
"""Interface to self.timer.repeat. Returns a PlotTimings."""
return PlotTimings(
self.timer.repeat(domain, repeat, number, *args, **kwargs),
{
'functions': self.timer.functions,
'domain': domain
}
)
class _DataSet:
"""Holds timeit values and defines statistical methods around them."""
def __init__(self, values):
self.values = sorted(values)
def quartile_indexes(self, outlier):
"""Generates the quartile indexes. Uses tukey's fences to remove outliers."""
delta = (len(self.values) - 1) / 4
quartiles = [int(round(delta * i)) for i in range(5)]
if outlier is not None:
if outlier < 0:
raise ValueError("outlier should be non-negative.")
iqr = outlier * (self.values[quartiles[3]] - self.values[quartiles[1]])
low = self.values[quartiles[1]] - iqr
high = self.values[quartiles[3]] + iqr
for i, v in enumerate(self.values):
if v >= low:
quartiles[0] = i
break
for i, v in reversed(list(enumerate(self.values))):
if v <= high:
quartiles[4] = i
break
return tuple(quartiles)
def errors(self, errors, outlier):
"""Returns tuples containing the quartiles wanted."""
if errors is None:
return None
quartiles = self.quartile_indexes(outlier)
# Allow out of quartile error bars using -1 and 5.
quartiles += (-1, 0)
return [
(
self.values[quartiles[start]],
self.values[quartiles[stop]]
)
for start, stop in errors
]
def quartile(self, quartile, outlier):
"""Return the value of the quartile provided."""
quartiles = self.quartile_indexes(outlier)
return self.values[quartiles[quartile]]
def mean(self, start, end, outlier):
"""Return the mean of the values over the quartiles specified."""
quartiles = self.quartile_indexes(outlier)
start = quartiles[start]
end = quartiles[end]
return sum(self.values[start:end + 1]) / (1 + end - start)
class PlotTimings:
"""Thin interface over _DataSet"""
def __init__(self, data, kwargs):
self.data = [
[_DataSet(results) for results in function_values]
for function_values in data
]
self.kwargs = kwargs
def quartile(self, quartile, *, errors=None, outlier=1.5):
"""Interface to _DataSet.quartile and errors. Returns a PlotValues."""
return PlotValues(
[
[
_DataValues(
ds.quartile(quartile, outlier),
ds.errors(errors, outlier)
)
for ds in function_values
]
for function_values in self.data
],
self.kwargs
)
def min(self, *, errors=((-1, 3),), outlier=1.5):
"""Return the Q1 value and show the error from Q-1 Q3."""
return self.quartile(0, errors=errors, outlier=outlier)
def max(self, *, errors=((1, 5),), outlier=1.5):
"""Return the Q4 value and show the error from Q1 Q5."""
return self.quartile(4, errors=errors, outlier=outlier)
def mean(self, start=0, end=4, *, errors=((1, 3),), outlier=1.5):
"""Interface to _DataSet.mean and errors. Returns a PlotValues."""
return PlotValues(
[
[
_DataValues(
ds.mean(start, end, outlier),
ds.errors(errors, outlier)
)
for ds in function_values
]
for function_values in self.data
],
self.kwargs
)
class _DataValues:
"""Holds the wanted statistical data from the timings."""
def __init__(self, value, errors):
self.value = value
self.errors = errors
class PlotValues:
"""Thin interface to Graph.graph."""
def __init__(self, data, kwargs):
self.data = data
self.kwargs = kwargs
def plot(self, graph, graph_lib=MatPlotLib, **kwargs):
g = graph_lib()
return g.graph(
graph,
self.data,
self.kwargs.pop('domain'),
functions=self.kwargs.pop('functions'),
**kwargs
)
Example usage
I've included the same graph as I did on my old code. And two of Graipher's graphs.
Abstract graphing-and-timing functions - This is to ensure usage is simple in abnormal usage.
It also shows that you can plot multiple error areas, highlighted in the unoptimised graph.Plot timings for a range of inputs - This is to make sure standard usage is simple.
String reversal in Python - This is so I know logerithmic graphs display correctly.
I'm running on Windows and don't have a C compiler, and so I can't include the additional two functions. However I think it nicely shows why the Python docs say to use
min
.
import time
import math
import matplotlib.pyplot as plt
import numpy as np
from graphtimer import flat, Plotter, TimerNamespace
class UnoptimisedRange(object):
def __init__(self, size):
self.size = size
def __getitem__(self, i):
if i >= self.size:
raise IndexError()
return i
class Peilonrayz(TimerNamespace):
def test_comprehension(iterable):
return [i for i in iterable]
def test_append(iterable):
a = []
append = a.append
for i in iterable:
append(i)
return a
SCALE = 10.
class Graipher(TimerNamespace):
def test_o_n(n):
time.sleep(n / SCALE)
def test_o_n2(n):
time.sleep(n ** 2 / SCALE)
def test_o_log(n):
time.sleep(math.log(n + 1) / SCALE)
def test_o_exp(n):
time.sleep((math.exp(n) - 1) / SCALE)
def test_o_nlog(n):
time.sleep(n * math.log(n + 1) / SCALE)
class Reverse(TimerNamespace):
def test_orig(stri):
output = ''
length = len(stri)
while length > 0:
output += stri[-1]
stri, length = (stri[0:length - 1], length - 1)
return output
def test_g(s):
return s[::-1]
def test_s(s):
return ''.join(reversed(s))
def main():
# Reverse
fig, axs = plt.subplots()
axs.set_yscale('log')
axs.set_xscale('log')
(
Plotter(Reverse)
.repeat(10, 10, np.logspace(0, 5), args_conv=lambda i: ' '*int(i))
.min()
.plot(axs, title='Reverse', fmt='-o')
)
fig.show()
# Graipher
fig, axs = plt.subplots()
(
Plotter(Graipher)
.repeat(2, 1, [i / 10 for i in range(10)])
.min()
.plot(axs, title='Graipher', fmt='-o')
)
fig.show()
# Peilonrayz
fig, axs = plt.subplots(nrows=2, ncols=2, sharex=True, sharey=True)
p = Plotter(Peilonrayz)
axis = [
('Range', {'args_conv': range}),
('List', {'args_conv': lambda i: list(range(i))}),
('Unoptimised', {'args_conv': UnoptimisedRange}),
]
for graph, (title, kwargs) in zip(iter(flat(axs)), axis):
(
p.repeat(100, 5, list(range(0, 10001, 1000)), **kwargs)
.min(errors=((-1, 3), (-1, 4)))
.plot(graph, title=title)
)
fig.show()
if __name__ == '__main__':
main()