Change from .apply() to a function that uses list comprehension to compare one dataframe with a column of lists to values in another dataframe

Viewed 642

Simply put, I want to change the following code into a funtion that doesn't use apply or progress_apply, so that the performance doesn't take 4+ hours to execute on 20 million+ rows.

d2['B'] = d2['C'].progress_apply(lambda x: [z for y in d1['B'] for z in y if x.startswith(z)])
d2['B'] = d2['B'].progress_apply(max)

Full question below:

I have two dataframes. The first dataframe has a column with 4 categories (A,B,C,D) with four different lists of numbers that I want to compare against a column in the second dataframe, which is not a list like in the first dataframe but instead just a single value that will start with one or more values from the first dataframe. As such, after executing some list comprehension to return a list of matching values in a new column in the second dataframe, the final step is to get the max of those values per list per row:

d1 = pd.DataFrame({'A' : ['A', 'B', 'C', 'D'],
               'B' : [['84'], ['8420', '8421', '8422', '8423', '8424', '8425', '8426'], ['847', '8475'], ['8470', '8471']]})

    A   B
0   A   [84]
1   B   [8420, 8421, 8422, 8423, 8424, 8425, 8426]
2   C   [847, 8475]
3   D   [8470, 8471]

d2 = pd.DataFrame({'C' : [8420513, 8421513, 8426513, 8427513, 8470513, 8470000, 8475000]})

    C
0   8420513
1   8421513
2   8426513
3   8427513
4   8470513
5   8470000
6   8475000

My current code is this:

from tqdm import tqdm, tqdm_notebook
tqdm_notebook().pandas()
d1 = pd.DataFrame({'A' : ['A', 'B', 'C', 'D'], 'B' : [['84'], ['8420', '8421', '8422', '8423', '8424', '8425', '8426'], ['847', '8475'], ['8470', '8471']]})
d2 = pd.DataFrame({'C' : [8420513, 8421513, 8426513, 8427513, 8470513, 8470000, 8475000]})
d2['C'] = d2['C'].astype(str)
d2['B'] = d2['C'].progress_apply(lambda x: [z for y in d1['B'] for z in y if x.startswith(z)])
d2['B'] = d2['B'].progress_apply(max)
d2

and successfully returns this output:

    C       B
0   8420513 8420
1   8421513 8421
2   8426513 8426
3   8427513 84
4   8470513 8470
5   8470000 8470
6   8475000 8475

The problem lies with the fact that the tqdm progress bar is estimating the code will take 4-5 hours to run on my actual DataFrame with 20 million plus rows. I know that .apply should be avoided and that a custom function can be much faster, so that I don't have to go row-by-row. I can usually change apply to a function, but I am struggling with this particular one. I think I am far away, but I will share what I have tried:

def func1(df, d2C, d1B):
    return df[[z for y in d1B for z in y if z in d2C]]


d2['B'] = func1(d2, d2['C'], d1['B'])
d2

With this code, I am receiving ValueError: Wrong number of items passed 0, placement implies 1 and also still need to include code to get the max of each list per row.

4 Answers

Let's try, using explode and regex with extract:

d1e = d1['B'].explode()
regstr = '('+'|'.join(sorted(d1e)[::-1])+')'
d2['B'] = d2['C'].astype('str').str.extract(regstr)

Output:

         C     B
0  8420513  8420
1  8421513  8421
2  8426513  8426
3  8427513    84
4  8470513  8470
5  8470000  8470
6  8475000  8475

Since, .str access is slower than list comprehension

import re
regstr = '|'.join(sorted(d1e)[::-1])
d2['B'] = [re.match(regstr, i).group() for i in d2['C'].astype('str')]

Timings:

from timeit import timeit
import re

d1 = pd.DataFrame({'A' : ['A', 'B', 'C', 'D'], 'B' : [['84'], ['8420', '8421', '8422', '8423', '8424', '8425', '8426'], ['847', '8475'], ['8470', '8471']]})
d2 = pd.DataFrame({'C' : [8420513, 8421513, 8426513, 8427513, 8470513, 8470000, 8475000]})
d2['C'] = d2['C'].astype(str)


def orig(d):
    d['B'] = d['C'].apply(lambda x: [z for y in d1['B'] for z in y if x.startswith(z)])
    d['B'] = d['B'].apply(max)
    return d
    
def comtorecords(d):
    d['B']=[max([z for y in d1.B for z in y if str(row[1]) .startswith(z)]) for row in d.to_records()]
    return d

def regxstracc(d):
    d1e = d1['B'].explode()
    regstr = '('+'|'.join(sorted(d1e)[::-1])+')'
    d['B'] = d['C'].astype('str').str.extract(regstr)
    return d

def regxcompre(d):
    regstr = '|'.join(sorted(d1e)[::-1])
    d['B'] = [re.match(regstr, i).group() for i in d['C'].astype('str')]
    return d


res = pd.DataFrame(
    index=[10, 30, 100, 300, 1000, 3000, 10000, 30000],
    columns='orig comtorecords regxstracc regxcompre'.split(),
    dtype=float
)

for i in res.index:
    d = pd.concat([d2]*i)
    for j in res.columns:
        stmt = '{}(d)'.format(j)
        setp = 'from __main__ import d, {}'.format(j)
        print(stmt, d.shape)
        res.at[i, j] = timeit(stmt, setp, number=100)

# res.groupby(res.columns.str[4:-1], axis=1).plot(loglog=True);
res.plot(loglog=True);

Output:

enter image description here

I have found yet faster solution, compared to both propositions of Scott.

def vect(d):
    def extr(txt):
        mtch = pat.match(txt)
        return mtch.group() if mtch else ''
    d1e = d1.B.explode()
    pat = re.compile('|'.join(sorted(d1e)[::-1]))
    d['B'] = np.vectorize(extr)(d.C)
    return d

One speed gain is from prior compilation of the regex.

The second gain is due to use of a Numpy vectorization, instead of a list comprehension.

Running a testing loop like the one employed by Scott, I received the following result:

enter image description here

So "my" execution time (the red line), especially for larger data volumes, is about 60 % of both regxstracc and regxcompre.

Try explode the d1 so we reduce one for loop

[max([z for z in d1.B.explode() if  x.startswith(z)]) for x in d2.C.astype(str) ]

['8420', '8421', '8426', '84', '8470', '8470', '8475']

Using re.compile() to compile the pattern prior to execution (i.e. outside of the ‘loop’) helps a lot when solving these big data row-by-row ‘requires a custom solution that isn’t a NumPy vector function already‘ problems.

Using the correct flags when compiling a pattern is work checking too.

Not to sound like a broken record but with NLP/text processing it’s definitely significant.

Another suggestion: Also use efficient chunking.

Each computing project has some X memory threshold and some Y processing threshold; balancing these by chunking helps improve execution speed.

Pandas chunking with its read/write functions are useful from my experience.

Finally, if you are executing I/O operations you might squeeze out some extra performance mapping the function within a ThreadPoolExecutor (A quick google can give you some good template code, and the documentation explains the utility well; I found the template code from documentation hard to implement at first so I’d check other resources).

Best of luck!

Related