How to avoid loops for specifying limits for Pandas Dataframe filtering?

Viewed 128

The following example code has three for loops:

import numpy as np 
import pandas as pd

#Generating a sample (ndarray) of 25 particles with 3 random coordinates in the range between 0 and 3. 
#Maybe think of the particles as contained in a cube of 3 x 3 x 3 units.
sample_data = np.random.uniform(0, 3, (25,3))

#Converting the narray into a dataframe
df = pd.DataFrame(data = sample_data, columns = ['A', 'B', 'C'])
print(df)

#Generating another narray which will store the number of particles in each cell of the cube
#Each cell has dimentions 1 x 1 x 1; total cells = 27
counts_in_cells = np.empty((3, 3, 3))
counts_in_cells[:] = np.NaN

#Three nested loops to count the number of particles in each cell
for i, x_low in enumerate(np.arange(0, 3, 1)):
    for j, y_low in enumerate(np.arange(0, 3, 1)):
        for k, z_low in enumerate(np.arange(0, 3, 1)):
            
            #Specifying filtering conditions for three dimentions of the cells
            x_condition = (df['A'] >= x_low) & (df['A'] < (x_low + 1))
            y_condition = (df['B'] >= y_low) & (df['B'] < (y_low + 1))
            z_condition = (df['C'] >= z_low) & (df['C'] < (z_low + 1))
            
            #Applying the filtering conditions
            df_select = df[x_condition & y_condition & z_condition]
            
            #Counting the particles in cells (desired outcome)
            counts_in_cells[i][j][k] = len(df_select)

#Paricles in each cell 
print(counts_in_cells)

Sample input

Sample input

Desired outcome

Desired outcome

Quick run

This example code may be run right away at Kaggle: https://www.kaggle.com/awaismirza/counting-particles-in-each-cell-of-a-cube.

Problem

I want to avoid three loops because the actual version of this code takes a few minutes to run. (It has 90k particles and a much bigger cube.) Further, I have to run the actual code 6k times which will take many days.

Is there a way (Pandas functionality or NumPy masking or something) I can avoid the loops and run the code faster?

Original code

The actual version of the code is available here but the above example code should be sufficient to understand the problem.

3 Answers

What you have done is a mixture of plain python, that uses loops, and of pandas, that uses indexes. The result is indeed a degradation in performance because you are, at every loop iteration, accessing and filtering a Dataframe.

As far as I know, there are 2 ways to proceed:
1 - you delete the loops and use pandas with apply()
2 - you implement it in plain python using a loop without the Dataframe.

Here is your code, encapsulated as a function:

import numpy as np
import pandas as pd
import time


def loop_count_hybrid(df):
    # Generating another narray which will store the number of particles in each cell of the cube
    # Each cell has dimentions 1 x 1 x 1; total cells = 27
    counts_in_cells = np.empty((3, 3, 3))
    counts_in_cells[:] = np.NaN

    # Three nested loops to count the number of particles in each cell
    for i, x_low in enumerate(np.arange(0, 3, 1)):
        for j, y_low in enumerate(np.arange(0, 3, 1)):
            for k, z_low in enumerate(np.arange(0, 3, 1)):
                # Specifying filtering conditions for three dimentions of the cells
                x_condition = (df['A'] >= x_low) & (df['A'] < (x_low + 1))
                y_condition = (df['B'] >= y_low) & (df['B'] < (y_low + 1))
                z_condition = (df['C'] >= z_low) & (df['C'] < (z_low + 1))

                # Applying the filtering conditions
                df_select = df[x_condition & y_condition & z_condition]

                # Counting the particles in cells (desired outcome)
                counts_in_cells[i][j][k] = len(df_select)
    return counts_in_cells

Here instead there is an implementation of your function with plain python.

def loop_plain_python(sample_data):

    def find_discrete_position(row):
        for i, x_low in enumerate(range(0, 3, 1)):
            for j, y_low in enumerate(range(0, 3, 1)):
                for k, z_low in enumerate(range(0, 3, 1)):
                    if (x_low < row[0] < x_low + 1) \
                            and (y_low < row[1] < y_low + 1) \
                            and (z_low < row[2] < z_low + 1):
                        return i, j, k

    counts_in_cells = np.zeros([3, 3, 3])
    for row in sample_data:
        i, j, k = find_discrete_position(row)
        counts_in_cells[i][j][k] += 1
    return counts_in_cells

Lastly, there is a time comparison between the two methods:

# Generating a sample (ndarray) of 25 particles with 3 random coordinates in the range between 0 and 3.
# Maybe think of the particles as contained in a cube of 3 x 3 x 3 units.
sample_data = np.random.uniform(0, 3, (25, 3))
df = pd.DataFrame(data=sample_data, columns=['A', 'B', 'C'])

start = time.time()
counts_in_cells = loop_count_hybrid(df)
elapsed1 = time.time() - start

start = time.time()
counts_in_cells = loop_plain_python(sample_data)
elapsed2 = time.time() - start

print("time of hybrid implementation", elapsed1)
print("time of plain python:", elapsed2)

Output:

time of hybrid implementation 0.05029702186584473 s
time of plain python: 0.0006160736083984375 s

Apparently with plain python you can gain up to 2 orders of magnitude! And I guess with pandas you can successfully gain performance too.. ..But is this enough to solve your problem?

-- EDIT: add solution with apply

If you really want to use apply, this is the code:

def find_discrete_position(row):
    for i, x_low in enumerate(range(0, 3, 1)):
        for j, y_low in enumerate(range(0, 3, 1)):
            for k, z_low in enumerate(range(0, 3, 1)):
                if (x_low < row[0] < x_low + 1) \
                        and (y_low < row[1] < y_low + 1) \
                        and (z_low < row[2] < z_low + 1):
                    return i, j, k


# sample data
sample_data = np.random.uniform(0, 3, (25, 3))
df = pd.DataFrame(data=sample_data, columns=['A', 'B', 'C'])

start = time.time()

# apply function to each row
df_pos = df[["A", "B", "C"]].apply(find_discrete_position, axis=1).\
    value_counts().reset_index().rename(columns={0: "position"})

# transfer into ndarray
counts_in_cells = np.zeros([3, 3, 3])
for i, row in df_pos.iterrows():
    counts_in_cells[row["index"][0], row["index"][1], row["index"][2]] = row["position"]

elapsed3 = time.time() - start

Time comparison with the other methods:

time of hybrid implementation 0.04173994064331055
time of plain python: 0.0004730224609375
time of apply and transfer: 0.007564067840576172

plain python seems to win..

As your real data deviates drastically from this example, you need to do some more processing. Please take this code with a grain of salt and check the results yourself.

import numpy as np
import pandas as pd

# Reading a data file 
df_gal = pd.read_csv('massive_galaxies.csv')  # modified for my purposes

 
def density_field_calc(clus_x, clus_y, clus_z): 
    #Converting strings into floats
    clus_x = float(clus_x)
    clus_y = float(clus_y)
    clus_z = float(clus_z)

    # Filtering the input dataframe using the arguments of the function
    df_gal_selected = df_gal[(df_gal['x[kpc/h]'] >= (clus_x - 120000)) & (df_gal['x[kpc/h]'] <= (clus_x + 120000))
                            & (df_gal['y[kpc/h]'] >= (clus_y - 120000)) & (df_gal['y[kpc/h]'] <= (clus_y + 120000)) 
                             & (df_gal['z[kpc/h]'] >= (clus_z - 120000)) & (df_gal['z[kpc/h]'] <= (clus_z + 120000))]

    # copy the filtered value and normalize - subtract the lower bound so we start at 0 
    # max upper value of filtered data are just shy of 240000
    dfs = df_gal_selected.copy()
    dfs['x[kpc/h]'] -= clus_x-120000
    dfs['y[kpc/h]'] -= clus_y-120000
    dfs['z[kpc/h]'] -= clus_z-120000
    
    # now divide by 5000 (integer-div) so we get bin-numbers

    dfs['x[kpc/h]'] = dfs['x[kpc/h]'] // 5000
    dfs['y[kpc/h]'] = dfs['y[kpc/h]'] // 5000
    dfs['z[kpc/h]'] = dfs['z[kpc/h]'] // 5000
    
    # same trick as ealier, make tuples, convert tuples to running bin numbers
    dfs["cell"] = list(zip(dfs['x[kpc/h]'].astype(int), dfs['y[kpc/h]'].astype(int), dfs['z[kpc/h]'].astype(int)))
    
    lu = {(x,y,z):z*49*49 + y*49 + z for x in range(48) for y in range(48) for z in range(48)} 
    
    dfs["idx"] = dfs["cell"].map(lu)
    # print(dfs)

    # occurences by tuples grouped
    print(dfs.groupby(["cell"]).count()["idx"])

    # Creating and initiating an array containing NaN values
    counts_in_cells = np.empty((48, 48, 48))
    counts_in_cells[:] = 0

    for cell in dfs["cell"]:
        x, y, z = cell
        counts_in_cells[x, y, z] += 1

    np.set_printoptions(precision=1, suppress=True )
    print(counts_in_cells)

density_field_calc('416658.59', '455771.69', '72710.742')

Output:

# from groupby and count()
cell
(0, 6, 10)      1
(0, 6, 24)      1
(0, 6, 25)      1
(0, 6, 40)      1
(0, 8, 12)      1
               ..
(47, 44, 14)    1
(47, 44, 15)    3
(47, 45, 33)    1
(47, 45, 43)    1
(47, 47, 44)    1
Name: idx, Length: 3407, dtype: int64

# the np counted - mostly 0's
[[[0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 1. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]]

 [[0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 1.]
  [0. 0. 0. ... 0. 0. 0.]]

 [[0. 0. 0. ... 0. 0. 1.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]]

 ...

 [[0. 0. 0. ... 0. 0. 1.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]]

 [[0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 1. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]]

 [[0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  ...
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]
  [0. 0. 0. ... 0. 0. 0.]]] 

The selection contains 4401 rows, the numpy summations (sum(sum(sum(counts_in_cells))) is also 4401.0 - so it might work. And its over in a few seconds.

For this specific problem you need a frame change. You want to know in which subcell this is - based on the interger-value of a float you get that cell:

import numpy as np 
import pandas as pd
from math import floor

np.random.seed(42)

# Generating a sample (ndarray) of 25 particles with 3 random coordinates 
# in the range between 0 and 3 - inside a cube of 3 x 3 x 3 units.
sample_data = np.random.uniform(0, 3, (25,3))

# Converting the narray into a dataframe
df = pd.DataFrame(data = sample_data, columns = ['A', 'B', 'C'])

df["cell"] = list(zip(df["A"].astype(int),df["B"].astype(int),df["C"].astype(int)))

# map `(0,0,0)` to 0, `(0,0,1)` to 1, ... to get indexes
lu = {(a, b, c): (a * 9 + b * 3 + c) for a in range(3) for b in range(3) for c in range(3)}
df["idx"] = df["cell"].map(lu)

print(df)

Output:

           A         B         C       cell  idx
0   1.123620  2.852143  2.195982  (1, 2, 2)   17
1   1.795975  0.468056  0.467984  (1, 0, 0)    9
2   0.174251  2.598528  1.803345  (0, 2, 1)    7
3   2.124218  0.061753  2.909730  (2, 0, 2)   20
4   2.497328  0.637017  0.545475  (2, 0, 0)   18
5   0.550214  0.912727  1.574269  (0, 0, 1)    1
6   1.295835  0.873687  1.835559  (1, 0, 1)   10
7   0.418482  0.876434  1.099086  (0, 0, 1)    1
8   1.368210  2.355528  0.599021  (1, 2, 0)   15
9   1.542703  1.777244  0.139351  (1, 1, 0)   12
10  1.822635  0.511572  0.195155  (1, 0, 0)    9
11  2.846657  2.896896  2.425192  (2, 2, 2)   26
12  0.913841  0.293016  2.052699  (0, 0, 2)    2
13  1.320457  0.366115  1.485531  (1, 0, 1)   10
14  0.103166  2.727961  0.776340  (0, 2, 0)    6
15  1.987567  0.935133  1.560204  (1, 0, 1)   10
16  1.640131  0.554563  2.908754  (1, 0, 2)   11
17  2.325398  2.818497  2.684482  (2, 2, 2)   26
18  1.793700  2.765623  0.265478  (1, 2, 0)   15
19  0.587949  0.135682  0.975991  (0, 0, 0)    0
20  1.166032  0.814047  2.486213  (1, 0, 2)   11
21  1.070260  0.842804  1.628088  (1, 0, 1)   10
22  0.422773  2.406591  0.223652  (0, 2, 0)    6
23  2.960661  2.316734  0.596147  (2, 2, 0)   24
24  0.016566  2.446384  2.120572  (0, 2, 2)    8

Based on that cell column you could construct your counts:

counts_in_cells = np.empty((3, 3, 3))
for cell in df["cell"]:
    x, y, z = cell
    counts_in_cells[x, y, z] += 1

np.set_printoptions(precision=1, suppress=True)
print(counts_in_cells)

Output:

[[[1. 2. 1.]
  [0. 1. 0.]
  [2. 2. 1.]]

[[2. 4. 0.]
  [0. 0. 0.]
  [1. 0. 0.]]

[[1. 2. 1.]    # 1 times index 0, 2 times index 1, 1 times index 2, 
  [0. 0. 0.]   # 0 times 3,4,5
  [1. 1. 2.]]] # 2 times index 6, 1 times index 7, 1 times index 8, etc
Related