Faster approach to calculate consecutive number of rows in pandas dataframe, where value is lower than in current row while iterating

Viewed 59

I would like to calculate how many last and previous rows contain value lower than current value - while iterating over dataframe - but the iterator over dataframe is not necessary. Example graphical interpretation: Graphical interpretation of what I want to achieve I used solution from: pandas - Count streak of values higher/lower than current rows, but the result is different than mine. My code:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pandas as pd

df_old = pd.DataFrame({'Spam': [10, 1, 2, 3, 4, 5, 6, 8, 3, 4, 7, 8, 9, 1, 2, 7, 11]})
print(df_old, "\n")

for i in range(0, len(df_old)):    
    df = df_old.iloc[0:i+1]
    number_of_rows_with_lower_value = 0

    for j in range(len(df)):
        if (df['Spam'].iloc[-1] > df['Spam'].iloc[-j-1:-1].max()):
            number_of_rows_with_lower_value = j

    print("At row:", i, "value of Spam=", df['Spam'].iloc[-1], "is higher than in last:", number_of_rows_with_lower_value, "rows!")


Spam = df_old['Spam']
Higher=[(Spam[x]>Spam[:x]).sum() for x in range(len(Spam))]
print(Higher)

It gives output:

At row: 0 value of Spam= 10 is higher than in last: 0 rows!
At row: 1 value of Spam= 1 is higher than in last: 0 rows!
At row: 2 value of Spam= 2 is higher than in last: 1 rows!
At row: 3 value of Spam= 3 is higher than in last: 2 rows!
At row: 4 value of Spam= 4 is higher than in last: 3 rows!
At row: 5 value of Spam= 5 is higher than in last: 4 rows!
At row: 6 value of Spam= 6 is higher than in last: 5 rows!
At row: 7 value of Spam= 8 is higher than in last: 6 rows!
At row: 8 value of Spam= 3 is higher than in last: 0 rows!
At row: 9 value of Spam= 4 is higher than in last: 1 rows!
At row: 10 value of Spam= 7 is higher than in last: 2 rows!
At row: 11 value of Spam= 8 is higher than in last: 3 rows!
At row: 12 value of Spam= 9 is higher than in last: 11 rows!
At row: 13 value of Spam= 1 is higher than in last: 0 rows!
At row: 14 value of Spam= 2 is higher than in last: 1 rows!
At row: 15 value of Spam= 7 is higher than in last: 2 rows!
At row: 16 value of Spam= 11 is higher than in last: 16 rows!
[0, 0, 1, 2, 3, 4, 5, 6, 2, 4, 8, 9, 11, 0, 2, 10, 16]

My approach works as desired, giving good results, but is very slow for larger dataframes. The vectorized solution gives different results than mine, so question is - how can I vectorize the code, i.e. using sums?

1 Answers

The answer provided by @benjwadams on the Q that you link to actually works on your problem. It could be optimized, however, as we will see below.

Here's the solution by @benjwadams. I leave out original comments for brevity, but they are well worth the read to grasp the logic):

import pandas as pd
from operator import gt, lt

def consecutive_run(op, ser, i):
    thresh_all = op(ser[:i], ser[i])
    non_passing = thresh_all[~thresh_all]
    start_idx = 0
    if not non_passing.empty:
        start_idx = non_passing.index[-1]
    return thresh_all[start_idx:].sum()

Applied to your case:

df_old = pd.DataFrame({'Spam': [10, 1, 2, 3, 4, 5, 6, 8, 3, 4, 7, 8, 9, 1, 2, 7, 11]})
s = df_old.Spam

# original design is to capture both `gt` and `lt`, the OP needs `lt` here.
res = s.index.to_series().map(lambda i: consecutive_run(lt, s, i))

print(res.to_numpy().tolist())
[0, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 11, 0, 1, 2, 16] # correct

So, this works, but we can (potentially) improve performance by first cutting out all the index values where we can easily check that the needed value is going to be zero. That way we reduce the number of index values that need to go into the lambda function. So:

def wrapper(s):
    # each cell where the diff with previous cell <= 0 should get 0 always,
    # so we cut out the associated index values, and only feed the rest
    # to the `map(lamda i: ...)`
    idx = s[s.diff().ge(0)].index # cutting [0, 1, 8, 13] in OP's case
    t = s.iloc[idx].index.to_series().map(lambda i: consecutive_run(lt, s, i))
    
    # afterwards, we reindex on the original index, and apply `fillna(0)`,
    # i.e. replacing all `NaN`s for index values that were skipped with zeros
    res = t.reindex(s.index).fillna(0)
    return res

N.B.: if people want to use consecutive_run(gt, s, i), they should of course change s[s.diff().ge(0)].index to the le(0)-variant!

Check for equality:

print(wrapper(s).equals(
    s.index.to_series().map(
        lambda i: consecutive_run(lt, s, i)).astype(float)))
# True

Of course, the actual performance improvement will depend on the amount of index values we were able to cut out.

Finally, I've came up with a different solution as well. It is (potentially) faster than the original version of the solution by @benjwadams, but only because it uses the same trick. Otherwise, it seems to be slightly slower. Just adding it here; maybe someone else might see a way to improve it a little more.

import numpy as np

def consecutive_run_alt(s):
    # same idea as in `wrapper()` above
    idx = s[s.diff().ge(0)].index
    
    # for each i, get `s` through to `i` and get the diff;
    # now flip the sequence (`[::-1]` or `np.flip()` as here);
    # for the flipped sequence, get `cumsum()` and ask `> 0` (`gt(0)`);
    # finally, apply `.argmin()` to get the length of the first sequence of
    # `True`'s. 
    t = s.iloc[idx].index.to_series().map(
        lambda i: np.flip(s.iloc[:i+1].diff()).cumsum().gt(0).argmin())
    
    # same idea as in `wrapper()` above
    res = t.reindex(s.index).fillna(0)
    return res

# equality check:
print(wrapper(s).equals(consecutive_run_alt(s)))
# True

Here's a small performance comparison:

df_old = pd.DataFrame({'Spam': [10, 1, 2, 3, 4, 5, 6, 8, 3, 4, 7, 8, 9, 1, 2, 7, 11]})
s = pd.Series(np.tile(df_old.Spam,1000))

print('consecutive_run:', end=' ')
%timeit s.index.to_series().map(lambda i: consecutive_run(lt, s, i))
print('wrapper:', end=' ')
%timeit wrapper(s)
print('consecutive_run_alt:', end=' ')
%timeit consecutive_run_alt(s)

consecutive_run: 5.26 s ± 279 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
wrapper: 4.05 s ± 67.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
consecutive_run_alt: 4.3 s ± 180 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Related