go through every rows of a dataframe without iteration

Viewed 181

this is my sample data :

Inventory is based on a Product

  Customer  Product  Quantity   Inventory    
  1           A         100        800      
  2           A         1000       800  
  3           A         700        800  
  4           A         50         800   
  5           B         20         100  
  6           B         50         100  
  7           B         40         100  
  8           B         30         100  

Code require to create this data :

data = {
    'Customer':[1,2,3,4,5,6,7,8],
    'Product':['A','A','A','A','B','B','B','B'],
    'Quantity':[100,1000,700,50,20,50,40,30],
    'Inventory':[800,800,800,800,100,100,100,100]
}
df = pd.DataFrame(data)

I need to get a new column which is known Available to promise which is calculated by subtracting the quantity from previously available to promise and calculation only happens if the previously available inventory is greater than the order quantity .

here is my expected output:

Customer  Product  Quantity Inventory   Available to Promise 
  1           A         100        800   700                (800-100 = 700)
  2           A         1000       800   700                (1000 greater than 700 so same value)
  3           A         700        800   0                  (700-700 = 0)
  4           A         50         800   0                  (50 greater than 0)
  5           B         20         100   80                 (100-20 = 80)
  6           B         50         100   30                 (80-50 = 30)
  7           B         40         100   30                 (40 greater than 30)
  8           B         30         100   0                  (30 - 30 = 0)

i have achieved this using for loop and itterows in python pandas

this is my code:

master_df = df[['Product','Inventory']].drop_duplicates()
master_df['free'] = df['Inventory']
df['available_to_promise']=np.NaN
for i,row in df.iterrows():
    if i%1000==0:

        print(i)
    try:
        available = master_df[row['Product']==master_df['Product']]['free'].reset_index(drop=True).iloc[0]
        if available-row['Quantity']>=0:
            df.at[i,'available_to_promise']=available-row['Quantity']
            a = master_df.loc[row['Product']==master_df['Product']].reset_index()['index'].iloc[0]
            master_df.at[a,'free'] = available-row['Quantity']
        else:
            df.at[i,'available_to_promise']=available
    except Exception as e:
         print(i)
         print(e)
print((df.columns))
df = df.fillna(0)

Due to for loop is so slow in python, when there is a huge data input this loop take so much time to execute thus my aws lambda function is failing

Can you guys help me to optimize this code by introducing a better alternative to this loop which can execute in a few seconds ?

4 Answers

I am not sure it is simple to write a vectorized and performant code that replicates the desired logic.

However, it is relatively simple to write it in a way that it is easy to accelerate with Numba.

Firstly, let us write your code as a (pure) function of the dataframe, returning the values to eventually put in df["Available to Promise"]. Eventually, it is easy to inglobate its result into the original dataframe with:

df["Available to Promise"] = calc_avail_OP(df)

The OP's code, save for exception handling and printing (and incorporation into the original dataframe as just discussed) is equivalent to the following:

import numpy as np
import pandas as pd


def calc_avail_OP(df):
    temp_df = df[["Product", "Inventory"]].drop_duplicates()
    temp_df["free"] = df["Inventory"]
    result = np.zeros(len(df), dtype=df["Inventory"].dtype)
    for i, row in df.iterrows():
        available = (
            temp_df[row["Product"] == temp_df["Product"]]["free"]
            .reset_index(drop=True)
            .iloc[0]
        )
        if available - row["Quantity"] >= 0:
            result[i] = available - row["Quantity"]
            a = (
                temp_df.loc[row["Product"] == temp_df["Product"]]
                .reset_index()["index"]
                .iloc[0]
            )
            temp_df.at[a, "free"] = available - row["Quantity"]
        else:
            result[i] = available
    return result

Now, if the input is sorted so that the unique products appear consecutively, the same can be achieved with a few scalar temporary variables on native NumPy objects, and this can be effectively accelerated with Numba:

import numba as nb


@nb.njit
def _calc_avail_nb(products, quantities, stocks):
    n = len(products)
    avails = np.empty(n, dtype=stocks.dtype)
    last_product = products[0]
    avail = stocks[0]
    for i in range(n):
        if products[i] != last_product:
            last_product = products[i]
            avail = stocks[i]
        qty = quantities[i]
        if avail >= qty:
            avail -= qty
        avails[i] = avail
    return avails
            

def calc_avail_nb(df):            
    return _calc_avail_nb(
        df["Product"].to_numpy(dtype="U"),
        df["Quantity"].to_numpy(),
        df["Inventory"].to_numpy()
    )

If the input is not guaranteed to be sorted, one could keep track of inventory information with a dict():

import numba as nb


@nb.njit
def _calc_avail_dict_nb(products, quantities, stocks):
    inventory = {products[0]: stocks[0]}
    n = len(products)
    avails = np.empty(n, dtype=stocks.dtype)
    for i in range(n):
        product = products[i]
        avail = inventory.setdefault(products[i], stocks[i])
        qty = quantities[i]
        if avail >= qty:
            avail -= qty
            inventory[products[i]] = avail
        avails[i] = avail
    return avails
            

def calc_avail_dict_nb(df):            
    return _calc_avail_dict_nb(
        df["Product"].to_numpy(dtype="U"),
        df["Quantity"].to_numpy(),
        df["Inventory"].to_numpy()
    )

The following text include a comparison with some approaches from other answers:

def stock(val):
    s = val
    q = yield 
    while True:
        s = s - q if s >= q else s
        q = yield s

def exaust_stock(df):
    st = stock(df.iloc[0]['Inventory']).send
    st(None)
    return df['Quantity'].apply(st)


def calc_avail_gen(df):
    return (
        df
        .groupby('Product')
        .apply(exaust_stock)
        .reset_index(level=0, drop=True)
        .to_numpy()
    )
@nb.njit
def _calc_avail_grouped_nb(quant, inv):
    stock = inv[0]
    n = len(quant)
    out = np.zeros((n,), dtype=np.int_)
    for i in range(n):
        if stock > 0 and quant[i] <= stock:
            stock -= quant[i]
            out[i] = stock
        else:
            out[i] = stock
    return out


def calc_avail_grouped_nb(df):
    return (
        df
        .groupby('Product')
        .apply(lambda x: _calc_avail_grouped_nb(x['Quantity'].to_numpy(), x['Inventory'].to_numpy()))
        .explode()
        .to_numpy(dtype=np.int_)
    )

The test indicate that while they do provide the same results, calc_avail_nb() and calc_avail_dict_nb() provide a speed increase of ~200x on the test input.

data = {
    'Customer':[1,2,3,4,5,6,7,8],
    'Product':['A','A','A','A','B','B','B','B'],
    'Quantity':[100,1000,700,50,20,50,40,30],
    'Inventory':[800,800,800,800,100,100,100,100]
}
df = pd.DataFrame(data)


funcs = calc_avail_OP, calc_avail_nb, calc_avail_dict_nb, calc_avail_gen, calc_avail_grouped_nb
base = funcs[0](df)
timings = {}
n = len(df)
timings[n] = []
for func in funcs:
    res = func(df)
    is_good = np.allclose(base, res)
    timed = %timeit -n 8 -r 8 -q -o func(df)
    is_good = True
    timing = timed.best * 1e6
    timings[n].append(timing if is_good else None)
    print(f"{func.__name__:>24}  {is_good!s:5}  {timing:10.3f} µs  {timings[n][0] / timing:5.1f}x")
#            calc_avail_OP  True    11699.373 µs    1.0x
#            calc_avail_nb  True       52.821 µs  221.5x
#       calc_avail_dict_nb  True       57.198 µs  204.5x
#           calc_avail_gen  True     3360.806 µs    3.5x
#    calc_avail_grouped_nb  True     1099.665 µs   10.6x

Similar tests on larger inputs seem to point to an even larger speed gain.

The timings are computed with the following:

import string
import random


def gen_df(n, m=None, max_stock=None):
    if not m:
        m = 2 + n // 16
    if not max_stock:
        max_stock = n
    k = n.bit_length()
    inventory = {
        "".join(
            random.choices(string.ascii_letters, k=random.randint(1, 2 + k))
        ): random.randint(max_stock // 2, max_stock)
        for _ in range(m)
    }
    products = random.choices(list(inventory.keys()), k=n)
    return pd.DataFrame(
        {
            "Customer": np.random.randint(1, int(1.1 * max_stock), n),
            "Product": products,
            "Quantity": np.random.randint(1, int(1.1 * max_stock), n),
            "Inventory": [inventory[product] for product in products],
        }
    )


np.random.seed(0)
random.seed(0)

timings = {}
for i in range(3, 18, 3):
    n = 2 ** i
    print(f"i={i}, n={n}")
    df = gen_df(n)
    base = funcs[0](df)
    timings[n] = []
    for func in funcs:
        res = func(df)
        is_good = np.allclose(base, res)
        timed = %timeit -n 1 -r 1 -q -o func(df)
        is_good = True
        timing = timed.best * 1e3
        timings[n].append(timing if is_good else None)
        print(f"{func.__name__:>24}  {is_good!s:5}  {timing:10.3f} ms  {timings[n][0] / timing:5.1f}x")

and plotted with:

import pandas as pd
import matplotlib.pyplot as plt


df = pd.DataFrame(data=timings, index=[func.__name__ for func in funcs]).transpose()
df.plot(marker='o', xlabel='Input Size / #', ylabel='Best timing / µs', figsize=(6, 4))
fig = plt.gcf()
fig.patch.set_facecolor('white')


df = pd.DataFrame(data=timings, index=[func.__name__ for func in funcs]).transpose()
df = df[[funcs[0].__name__]].to_numpy() / df
df.plot(marker='o', xlabel='Input Size / #', ylabel='Speed increase / %x', figsize=(6, 4))
fig = plt.gcf()
fig.patch.set_facecolor('white')

to obtain, respectively:

bm_timing

and

bm_speed

You're doing a lot of manipulating of the two dataframes you have, and I think that might be the cause of the speed issue.

I would use a dict to keep track of the available inventory.

I'm actually curious what the speed comparison if you apply this on a large dataframe... (see my edit below for that)

import pandas as pd


data = {
    'Customer':[1,2,3,4,5,6,7,8],
    'Product':['A','A','A','A','B','B','B','B'],
    'Quantity':[100,1000,700,50,20,50,40,30],
    'Inventory':[800,800,800,800,100,100,100,100]
}
df = pd.DataFrame(data)
df["Available to Promise"] = 0
# create availability tracking
available = {k: None for k in set(df.Product)}


for idx, row in df.iterrows():
    if available[row.Product] == None:
        if row.Quantity <= row.Inventory:
            available[row.Product] = row.Inventory - row.Quantity
            df.at[idx, "Available to Promise"] = available[row.Product]
        else:
            df.at[idx, "Available to Promise"] = row.Inventory
            available[row.Product] = 0
        
    elif available[row.Product] > 0:
        if row.Quantity <= available[row.Product]:
            available[row.Product] = available[row.Product] - row.Quantity
            df.at[idx, "Available to Promise"] = available[row.Product] 
        else:
            df.at[idx, "Available to Promise"] = available[row.Product]
            available[row.Product] = 0
    

print(df)

output

   Customer Product  Quantity  Inventory  Available to Promise
0         1       A       100        800                   700
1         2       A      1000        800                   700
2         3       A       700        800                     0
3         4       A        50        800                     0
4         5       B        20        100                    80
5         6       B        50        100                    30
6         7       B        40        100                    30
7         8       B        30        100                     0

EDIT:

After norok2's comment below I did a speed comparison.

adjusted code with timeit included

import pandas as pd
data = {
    'Customer':[1,2,3,4,5,6,7,8],
    'Product':['A','A','A','A','B','B','B','B'],
    'Quantity':[100,1000,700,50,20,50,40,30],
    'Inventory':[800,800,800,800,100,100,100,100]
}
df = pd.DataFrame(data)
df["Available to Promise"] = 0

def do_stuff(df):
    available = {k: None for k in set(df.Product)}
    for idx, row in df.iterrows():
        if available[row.Product] == None:
            if row.Quantity <= row.Inventory:
                available[row.Product] = row.Inventory - row.Quantity
                df.at[idx, "Available to Promise"] = available[row.Product]
            else:
                df.at[idx, "Available to Promise"] = row.Inventory
                available[row.Product] = 0
        elif available[row.Product] > 0:
            if row.Quantity <= available[row.Product]:
                available[row.Product] = available[row.Product] - row.Quantity
                df.at[idx, "Available to Promise"] = available[row.Product] 
            else:
                df.at[idx, "Available to Promise"] = available[row.Product]
                available[row.Product] = 0

import timeit
import statistics
timings=[]
for _ in range(1000):
    timings.append(timeit.timeit("do_stuff(df)", setup="from __main__ import do_stuff, df", number=1))
print(f"Mine:\n  Mean: {statistics.mean(timings)}\n  Min:  {min(timings)}\n  Max:  {max(timings)}")

I then used the function calc_avail_OP(df, label="Avail") that norok2 created, and timed it in the same way as I did mine, with this piece of code:

import timeit
import statistics
timings=[]
for _ in range(1000):
    timings.append(timeit.timeit("calc_avail_OP(df)", setup="from __main__ import calc_avail_OP, df", number=1))
print(f"OP's:\n  Mean: {statistics.mean(timings)}\n  Min:  {min(timings)}\n  Max:  {max(timings)}")

output for both

Mine:
  Mean: 0.0003488006000061432
  Min:  0.0003338999995321501
  Max:  0.001021500000206288
OP's:
  Mean: 0.0037762733999825286
  Min:  0.003618599999754224
  Max:  0.005391000000599888

so, with %timeit I get this result:

%timeit  -n 16 -r 16 do_stuff(df)
365 µs ± 19.5 µs per loop (mean ± std. dev. of 16 runs, 16 loops each)

%timeit  -n 16 -r 16 calc_avail_nb(df)
30 µs ± 13.2 µs per loop (mean ± std. dev. of 16 runs, 16 loops each)

%timeit  -n 16 -r 16 calc_avail_OP(df)
3.95 ms ± 258 µs per loop (mean ± std. dev. of 16 runs, 16 loops each)

norok2's is still the fastest, on a larger df the difference becomes very obvious

with a 100k row dataframe:

%timeit  -n 16 -r 16 do_stuff(df)
3.26 s ± 153 ms per loop (mean ± std. dev. of 16 runs, 16 loops each)

%timeit  -n 16 -r 16 calc_avail_nb(df)
82.3 ms ± 15.9 ms per loop (mean ± std. dev. of 16 runs, 16 loops each)

%timeit  -n 16 -r 16 calc_avail_OP(df)
39.3 s ± 3.01 s per loop (mean ± std. dev. of 16 runs, 16 loops each)

I have a bit of a solution, it's not incredibly powerful because it still uses loops but it has the advantage of being simpler and easy to optimize.

import pandas as pd
import numpy as np

def func_no_jit(quant, inv):
    stock = inv[0]
    n = len(quant)
    out = np.zeros((n,), dtype=np.int64)
    for i in range(n):
        if stock > 0 and quant[i] <= stock:
            stock -= quant[i]
            out[i] = stock
        else:
            out[i] = stock
    return out


res = (
    df.groupby('Product')
    .apply(lambda x: func(x['Quantity'].values, x['Inventory'].values))
    .explode()
)

df["Promise"] = res

A possible solution is to use numba, When I used it, I could cut the time the process took in half, for a dataframe of 100_000 elements, it has no real effect on small dataframes though.

from numba import njit

@njit
def func(quant, inv):
    stock = inv[0]
    n = len(quant)
    out = np.zeros((n,), dtype=np.int64)
    for i in range(n):
        if stock > 0 and quant[i] <= stock:
            stock -= quant[i]
            out[i] = stock
        else:
            out[i] = stock
    return out

See the results here:

In [11]: big_df
Out[11]: 
       Customer Product  Quantity  Inventory
0             0       I       328        282
1             1       A       668        874
2             2       H        51        496
3             3       A       561        526
4             4       H       143        421
...         ...     ...       ...        ...
99995     99995       D        43        392
99996     99996       F       162        540
99997     99997       C       565        902
99998     99998       H       633        936
99999     99999       A       731        810

[100000 rows x 4 columns]
big_df.sort_values('Product', inplace=True) # Sort to keep track of indices
In [12]: %timeit big_df.groupby('Product').apply(lambda x : func_no_jit(x["Quantity"].values
    ...: ,x["Inventory"].values)).explode()
33.3 ms ± 102 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [13]: %timeit big_df.groupby('Product').apply(lambda x : func(x["Quantity"].values,x["Inv
    ...: entory"].values)).explode()
12.5 ms ± 21.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

OP's solution on the 100_000 elements dataframe:

product_set = set(big_df.Product)
available = dict(zip(list(product_set), [None for _ in range(len(product_set))]))


def op_func():
    big_df['Available to Promise'] = 0
    for idx, row in big_df.iterrows():
        if available[row.Product] == None:
            if row.Quantity <= row.Inventory:
                available[row.Product] = row.Inventory - row.Quantity
                big_df.at[idx, "Available to Promise"] = available[row.Product]
            else:
                big_df.at[idx, "Available to Promise"] = row.Inventory
                available[row.Product] = 0

        elif available[row.Product] > 0:
            if row.Quantity <= available[row.Product]:
                available[row.Product] = available[row.Product] - row.Quantity
                big_df.at[idx, "Available to Promise"] = available[row.Product]
            else:
                big_df.at[idx, "Available to Promise"] = available[row.Product]
                available[row.Product] = 0
In [11]: %timeit op_func()
3.53 s ± 433 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

How to use generators to apply functions with intermediate states to pandas data frames

def stock(val):
    s = val
    q = yield 
    while True:
        q = yield (s:=s-q) if s >= q else s

def exaust_stock(df):
    st = stock(df.iloc[0]['Inventory']).send
    st(None)
    return df['Quantity'].apply(st)

df['Stock'] = (
    df
    .groupby('Product')
    .apply(exaust_stock)
    .reset_index(level=0, drop=True)
)
Related