This notebook illustrates amassing a medium-sized dataset (1 second of 32- or 64-bit float mono audio, essentially – a simple sine wave) using the record_history decorator, and then using the stats.history_as_DataFrame attribute to obtain that dataset as a Pandas DataFrame.

After that basic (and naive) example, we compare the performance of this approach to one that uses numpy's ability to vectorize the same computation, and conclude that if you can vectorize, certainly you should do so. (The example is naive precisely because no one would call the function f below in a for loop when it's possible to use numpy universal functions (ufuncs). When that alternative is unavailable, however, record_history can come in handy.)

Using the stats.history_as_DataFrame attribute

In [54]:
%matplotlib inline
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from log_calls import record_history
In [55]:
@record_history()
def f(freq, t):
    return np.sin(freq * 2 * np.pi * t)
In [56]:
ran_t = np.arange(0.0, 1.0, 1/44100, dtype=np.float32)
ran_t
Out[56]:
array([  0.00000000e+00,   2.26757365e-05,   4.53514731e-05, ...,
         9.99931931e-01,   9.99954641e-01,   9.99977291e-01], dtype=float32)

Now, naively, call f 44,100 times in a for loop, and obtain its call history as a Pandas DataFrame:

In [57]:
#f.stats.clear_history()
for t in ran_t: 
    f(17, t)
df = f.stats.history_as_DataFrame

Examine and do stuff with it:

In [60]:
df.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 44100 entries, 1 to 44100
Data columns (total 7 columns):
freq              44100 non-null int64
t                 44100 non-null float64
retval            44100 non-null float64
elapsed_secs      44100 non-null float64
timestamp         44100 non-null object
prefixed_fname    44100 non-null object
caller_chain      44100 non-null object
dtypes: float64(3), int64(1), object(3)
memory usage: 2.7 MB

In [61]:
from IPython.display import display
display(df.head())
display(df.tail())
freq t retval elapsed_secs timestamp prefixed_fname caller_chain
call_num
1 17 0.000000 0.000000 0.000040 11/10/14 01:51:47.257050 'f' ['<module>']
2 17 0.000023 0.002422 0.000016 11/10/14 01:51:47.257338 'f' ['<module>']
3 17 0.000045 0.004844 0.000016 11/10/14 01:51:47.257570 'f' ['<module>']
4 17 0.000068 0.007266 0.000016 11/10/14 01:51:47.257812 'f' ['<module>']
5 17 0.000091 0.009688 0.000015 11/10/14 01:51:47.258043 'f' ['<module>']
freq t retval elapsed_secs timestamp prefixed_fname caller_chain
call_num
44096 17 0.999887 -0.012109 0.000014 11/10/14 01:51:57.920550 'f' ['<module>']
44097 17 0.999909 -0.009690 0.000014 11/10/14 01:51:57.920767 'f' ['<module>']
44098 17 0.999932 -0.007271 0.000014 11/10/14 01:51:57.920979 'f' ['<module>']
44099 17 0.999955 -0.004845 0.000014 11/10/14 01:51:57.921190 'f' ['<module>']
44100 17 0.999977 -0.002426 0.000013 11/10/14 01:51:57.921411 'f' ['<module>']
In [62]:
len(f.stats.history)
Out[62]:
44100
In [63]:
df[['t', 'retval']].head()
Out[63]:
t retval
call_num
1 0.000000 0.000000
2 0.000023 0.002422
3 0.000045 0.004844
4 0.000068 0.007266
5 0.000091 0.009688
In [64]:
plt.plot(df.t, df.retval);

Comparing performance: record_history vs vectorization with numpy ufuncs

(1) No decorator

With no decorator, it's ~2.8 orders of magnitude faster to use a function as a numpy ufunc than to call it in a for loop (looping through ran_t)

In [72]:
def g(freq, t):
    return np.sin(freq * 2 * np.pi * t)

Vectorized:

In [89]:
nodeco_vectorized_secs = %timeit -o Hz_17 = g(17, ran_t)
nodeco_vectorized_secs.best
1000 loops, best of 3: 448 µs per loop

Out[89]:
0.0004476831200008746
In [90]:
Hz_17
Out[90]:
array([ 0.        ,  0.00242209,  0.00484416, ..., -0.00727302,
       -0.00484692, -0.00242842], dtype=float32)
In [91]:
plt.plot(Hz_17);

With a for loop:

In [92]:
nodeco_loop_secs = %timeit -o for t in ran_t: g(7, t)
nodeco_loop_secs.best
1 loops, best of 3: 278 ms per loop

Out[92]:
0.2777122740226332
In [93]:
def comparison(slower, faster):
    'slower, faster: seconds'
    ratio = slower/faster
    order_of_magnitude = np.log10(ratio)
    return ratio, order_of_magnitude
In [94]:
print("With record_history disabled:\n"
      "Vectorized approach is %d times (about %.1f orders of magnitude) faster" 
      % comparison(slower=loop_secs.best, faster=vectorized_secs.best))
With record_history disabled:
Vectorized approach is 619 times (about 2.8 orders of magnitude) faster

Now let's compare the performance of the record_history-decorated version (f) of the same function

(2) record_history disabled

With record_history disabled, it's ~3.6 orders of magnitude faster to use a function as a numpy ufunc than to call it in a for loop (looping through ran_t)

In [83]:
f.stats.clear_history()
f.record_history_settings.enabled = False

Vectorized:

In [84]:
vectorized_secs_rh_disabled = %timeit -o Hz_17 = f(17, ran_t)
vectorized_secs_rh_disabled.best
1000 loops, best of 3: 489 µs per loop

Out[84]:
0.0004893772610230371

With a for loop:

In [87]:
loop_secs_rh_disabled = %timeit -o for t in ran_t: f(7, t)
loop_secs_rh_disabled.best
1 loops, best of 3: 1.98 s per loop

Out[87]:
1.9756690169742797
In [95]:
print("With record_history disabled:\n"
      "Vectorized approach is %d times (about %.1f orders of magnitude) faster" 
      % comparison(slower=loop_secs_rh_disabled.best, faster=vectorized_secs_rh_disabled.best))
With record_history disabled:
Vectorized approach is 4037 times (about 3.6 orders of magnitude) faster

Decorator overhead: about 7x slower (~0.9 orders of magnitude)

In [99]:
print("Called in a for-loop, the no-decorator version is %d times (about %.1f orders of magnitude) faster" 
      % comparison(slower=loop_secs_rh_disabled.best, faster=nodeco_loop_secs.best))
Called in a for-loop, the no-decorator version is 7 times (about 0.9 orders of magnitude) faster

(3) record_history enabled

With record_history enabled, it's ~4.2 orders of magnitude faster to use a function as a numpy ufunc than to call it in a for loop (looping through ran_t)

In [100]:
f.record_history_settings.enabled = True
f.stats.clear_history()

Vectorized:

In [106]:
vectorized_secs_rh_enabled = %timeit -o f.stats.clear_history(); Hz_17 = f(17, ran_t)
vectorized_secs_rh_enabled.best
1000 loops, best of 3: 705 µs per loop

Out[106]:
0.0007048069210140966
In [107]:
len(f.stats.history)
Out[107]:
1
In [108]:
def size_of_t_for_row(row):
    return f.stats.history[row].argvals[1].size

size_of_t_for_row(0)
Out[108]:
44100
In [109]:
f.stats.history[0].retval.size
Out[109]:
44100
In [110]:
f.stats.history
Out[110]:
(CallRecord(call_num=1, argnames=['freq', 't'], argvals=(17, array([  0.00000000e+00,   2.26757365e-05,   4.53514731e-05, ...,
         9.99931931e-01,   9.99954641e-01,   9.99977291e-01], dtype=float32)), varargs=(), explicit_kwargs=OrderedDict(), defaulted_kwargs=OrderedDict(), implicit_kwargs={}, retval=array([ 0.        ,  0.00242209,  0.00484416, ..., -0.00727302,
       -0.00484692, -0.00242842], dtype=float32), elapsed_secs=0.00045490264892578125, timestamp='11/10/14 02:05:01.410089', prefixed_func_name='f', caller_chain=['inner']),)
In [111]:
f.stats.clear_history()

With a for loop:

In [112]:
loop_secs_rh_enabled = %timeit -o for t in ran_t: f(7, t); f.stats.clear_history()
loop_secs_rh_enabled.best
1 loops, best of 3: 10.6 s per loop

Out[112]:
10.644794539985014
In [113]:
print("With record_history enabled:\n"
      "Vectorized approach is %d times (about %.1f orders of magnitude) faster" 
      % comparison(slower=loop_secs_rh_enabled.best, faster=vectorized_secs_rh_enabled.best))
With record_history enabled:
Vectorized approach is 15103 times (about 4.2 orders of magnitude) faster