Instead of grouping by the invoice on each category, I would reverse the logic.
Group per invoice, and then classify that invoice.
categories = pd.concat(
classify_invoice(data) for invoice, data in df.groupby("invoice")
)
| | 0 |
|---:|:--------------|
| 0 | OneJuice |
| 1 | OneJuice |
| 2 | Healthy |
| 3 | Healthy |
| 4 | Healthy |
| 5 | Mega |
| 6 | Mega |
| 7 | Mega |
| 8 | ManyJuice |
| 9 | ManyJuice |
| 10 | ManyJuice |
| 11 | HotLovers |
| 12 | HotLovers |
| 13 | DessertLovers |
| 14 | DessertLovers |
| 15 | DessertLovers |
| 16 | Others |
Then to add this to the result, you can assign.
result = df.assign(category=categories)
Here I used assign
, which returns a new DataFrame. I do this on purpose, so you can keep your original DataFrame intact. Changes inplace to your original DataFrame can be a source of errors.
Classifier
Then we just need to design the classifier. Here we need a function that accepts a DataFrame that covers exactly 1 invoice, and returns a series with the category, with the same index as the invoice.
Priority 1
The priority 1 then is easy:
def classify_invoice(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return pd.Series("Mega", index=order.index)
Priority 2
Priority 2 is also very easy:
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return pd.Series("Healthy", index=order.index)
Notice that I renamed the variable Milk
to milk_codes
, since that better describes what it means, and that I converted it to a set
, since that is the datastructure meant for containment checks
further priorities
def classify_invoice(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return pd.Series("Mega", index=order.index)
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return pd.Series("Healthy", index=order.index)
juice_codes = {104, 105}
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return pd.Series("OneJuice", index=order.index)
if juices_amount > 1:
return pd.Series("ManyJuice", index=order.index)
hot_codes = {103, 109}
if order["code"].isin(hot_codes).any():
return pd.Series("HotLovers", index=order.index)
dessert_codes = {110, 111}
if order["code"].isin(dessert_codes).any():
return pd.Series("DessertLovers", index=order.index)
return pd.Series("Others", index=order.index)
Testing
Since you offloaded the categorising to another function, you can test this in isolation
Variation
def classify_invoice2(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return "Mega"
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return "Healthy"
juice_codes = {104, 105}
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return "OneJuice"
if juices_amount > 1:
return "ManyJuice"
hot_codes = {103, 109}
if order["code"].isin(hot_codes).any():
return "HotLovers"
dessert_codes = {110, 111}
if order["code"].isin(dessert_codes).any():
return "DessertLovers"
return "Others"
df.join(
df.groupby("invoice")
.apply(classify_invoice2)
.rename("category"),
on = "invoice"
)
This is about as fast as my other solution and slightly simpler to follow.
micro optimizations
Now the codes get defined each groupby. I there are a lot of invoices, it might be faster to define them outside the method:
milk_codes = {106, 107, 108}
juice_codes = {104, 105}
hot_codes = {103, 109}
dessert_codes = {110, 111}
def classify_invoice3(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return "Mega"
if order["code"].isin(milk_codes).any():
return "Healthy"
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return "OneJuice"
if juices_amount > 1:
return "ManyJuice"
if order["code"].isin(hot_codes).any():
return "HotLovers"
if order["code"].isin(dessert_codes).any():
return "DessertLovers"
return "Others"
categorical
Working with a categorical might be faster than with a column of strings too:
CATEGORIES = {
0: "Mega",
1: "Healthy",
2: "OneJuice",
3: "ManyJuice",
4: "HotLovers",
5: "DessertLovers",
6: "Others",
}
def classify_invoice4(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return 0
if order["code"].isin(milk_codes).any():
return 1
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return 2
if juices_amount > 1:
return 3
if order["code"].isin(hot_codes).any():
return 4
if order["code"].isin(dessert_codes).any():
return 5
return 6
df.join(
(
df.groupby("invoice")
.apply(classify_invoice4)
.rename("category")
.astype(pd.Categorical(list(CATEGORIES)))
.cat.rename_categories(CATEGORIES)
),
on="invoice",
)
In the benchmark with the sample data this was slightly slower, but for larger datasets this might be faster
numpy
You can do this in numpy land too:
def classify_invoice_numpy(invoices, quantities, codes):
SODA = np.array([101, 102])
HOT = np.array([103, 109])
JUICE = np.array([104, 105]) # remember spaces after commas
MILK = np.array([106, 107, 108])
DESSERT = np.array([110, 111])
juices = np.isin(codes, JUICE)
milk = np.isin(codes, MILK)
hot = np.isin(codes, HOT)
dessert = np.isin(codes, DESSERT)
result = -np.ones(len(invoices), dtype=int)
for invoice in np.unique(invoices):
index = invoices == invoice
if quantities[index].sum() >= 10:
result[index] = 0
continue
if milk[index].any():
result[index] = 1
continue
juices_index = index & juices
if juices_index.any():
if quantities[juices_index].sum() == 1:
result[index] = 2
continue
else:
result[index] = 3
continue
if hot[index].any():
result[index] = 4
continue
if dessert[index].any():
result[index] = 5
continue
return result
def solution_maarten_numpy(data):
return data.assign(
category=pd.Series(
classify_invoice_numpy(
data["invoice"].values,
data["qty"].values,
data["code"].values,
),
index=data.index,
).map(CATEGORIES)
)
Benchmarking
I did some benchmarking
dummy data:
def dummy_data(
n: int = 100, lines_per_invoice: int = 3, seed: int = 0
) -> pd.DataFrame:
random_generator = np.random.default_rng(seed=seed)
samples = (
random_generator.normal(loc=lines_per_invoice, scale=2, size=n)
.round()
.astype(int)
)
samples = np.where(samples > 0, samples, 1)
invoices = np.repeat(np.arange(n), samples)
quantities = random_generator.integers(1, 10, size=len(invoices))
codes = random_generator.choice(np.arange(101, 112), size=len(invoices))
return pd.DataFrame(
{"invoice": invoices, "qty": quantities, "code": codes}
)
compare when there is something different
def compare_results(left, right):
differences = (left != right).any(axis=1)
return left[differences].merge(
right.loc[differences, "category"], left_index=True, right_index=True
)
benchmark
def benchmark(functions, size=100, lines_per_invoice=3, seed=0):
data_original = dummy_data(
n=size, lines_per_invoice=lines_per_invoice, seed=seed
)
yield data_original
benchmark_result = categorise_dawson(data_original)
for function in functions:
data = data_original.copy()
result = function(data)
try:
pd.testing.assert_frame_equal(result, benchmark_result)
except AssertionError:
print(f"method {function.__name__} differs from the benchmark")
# print(result)
# print(benchmark_result)
print(compare_results(benchmark_result, result))
# pd.testing.assert_frame_equal(result, benchmark_result)
continue
try:
pd.testing.assert_frame_equal(data, data_original)
except AssertionError:
print(f"method {function.__name__} changes the original data")
continue
time = timeit.timeit(
"function(data)",
globals={"function": function, "data": data},
number=1,
)
yield function.__name__, time
calling it
data_originals = {}
sizes = 10, 100, 1000, 10000
functions = [
solution_maarten_1,
solution_maarten_2,
solution_maarten_3,
solution_maarten4,
solution_maarten_numpy,
categorise_dawson,
categorise_OP,
]
result_df = pd.DataFrame(index=[function.__name__ for function in functions])
for size in sizes:
data_original, *results = benchmark(functions=functions, size=size,)
data_originals[size] = data_original
result_df[size] = pd.Series(dict(results))
| | 10 | 100 | 1000 | 10000 |
|:-----------------------|----------:|----------:|----------:|----------:|
| solution_maarten_1 | 0.0077566 | 0.089533 | 0.838123 | 9.03633 |
| solution_maarten_2 | 0.0085086 | 0.0564532 | 0.521976 | 5.17024 |
| solution_maarten_3 | 0.0051805 | 0.0461194 | 0.545553 | 6.22027 |
| solution_maarten4 | 0.0091025 | 0.0647327 | 0.545063 | 5.88994 |
| solution_maarten_numpy | 0.0013638 | 0.0038171 | 0.0156193 | 0.977562 |
| categorise_dawson | 0.0342312 | 0.0253829 | 0.0320662 | 0.0790319 |
| categorise_OP | 0.0480042 | 0.0463131 | 0.0542139 | 0.150899 |
So my code starts faster for smaller sizes, but changes almost linearly with the size, while your and @dawsons code are almost constant for size
complete code
#!/usr/bin/env python
# coding: utf-8
# In[1]:
import numpy as np
import pandas as pd
import timeit
# In[2]:
def dummy_data(
n: int = 100, lines_per_invoice: int = 3, seed: int = 0
) -> pd.DataFrame:
random_generator = np.random.default_rng(seed=seed)
samples = (
random_generator.normal(loc=lines_per_invoice, scale=2, size=n)
.round()
.astype(int)
)
samples = np.where(samples > 0, samples, 1)
invoices = np.repeat(np.arange(n), samples)
quantities = random_generator.integers(1, 10, size=len(invoices))
codes = random_generator.choice(np.arange(101, 112), size=len(invoices))
return pd.DataFrame(
{"invoice": invoices, "qty": quantities, "code": codes}
)
# In[3]:
def compare_results(left, right):
differences = (left != right).any(axis=1)
return left[differences].merge(
right.loc[differences, "category"], left_index=True, right_index=True
)
# In[63]:
Soda = [101, 102]
Hot = [103, 109]
Juice = [104, 105]
Milk = [106, 107, 108]
Dessert = [110, 111]
def categorise_OP(df):
# Calculating Priority No.1
L = df.groupby(["invoice"])["qty"].transform("sum") >= 10
df_Large = df[L]["invoice"].to_frame()
df_Large["category"] = "Mega"
df_Large.drop_duplicates(["invoice"], inplace=True)
# Calculating Priority No.2
df_1 = df[~L] # removing Priority No.1 calculated above
M = df_1["code"].isin(Milk).groupby(df_1["invoice"]).transform("any")
df_Milk = df_1[M]["invoice"].to_frame()
df_Milk["category"] = "Healthy"
df_Milk.drop_duplicates(["invoice"], inplace=True)
# Calculating Priority No.3
# 3.a Part -1
df_2 = df[~L & ~M] # removing Priority No.1 & 2 calculated above
J_1 = (df_2["qty"] * df_2["code"].isin(Juice)).groupby(
df_2["invoice"]
).transform("sum") == 1
df_SM = df_2[J_1]["invoice"].to_frame()
df_SM["category"] = "OneJuice"
df_SM.drop_duplicates(["invoice"], inplace=True)
# 3.b Part -2
J_2 = (df_2["qty"] * df_2["code"].isin(Juice)).groupby(
df_2["invoice"]
).transform("sum") > 1
df_MM = df_2[J_2]["invoice"].to_frame()
df_MM["category"] = "ManyJuice"
df_MM.drop_duplicates(["invoice"], inplace=True)
# Calculating Priority No.4
df_3 = df[
~L & ~M & ~J_1 & ~J_2
] # removing Priority No.1, 2 & 3 (a & b) calculated above
H = df_3["code"].isin(Hot).groupby(df_3["invoice"]).transform("any")
df_Hot = df_3[H]["invoice"].to_frame()
df_Hot["category"] = "HotLovers"
df_Hot.drop_duplicates(["invoice"], inplace=True)
# Calculating Priority No.5
df_4 = df[
~L & ~M & ~J_1 & ~J_2 & ~H
] # removing Priority No.1, 2, 3 (a & b) and 4 calculated above
D = df_4["code"].isin(Dessert).groupby(df_4["invoice"]).transform("any")
df_Dessert = df_4[D]["invoice"].to_frame()
df_Dessert["category"] = "DessertLovers"
df_Dessert.drop_duplicates(["invoice"], inplace=True)
# merge all dfs
category = pd.concat(
[df_Large, df_Milk, df_SM, df_MM, df_Hot, df_Dessert],
axis=0,
sort=False,
ignore_index=True,
)
# Final merge to the original dataset
return df.merge(category, on="invoice", how="left").fillna(value="Others")
# In[7]:
SODA = [101, 102]
HOT = [103, 109]
JUICE = [104, 105] # remember spaces after commas
MILK = [106, 107, 108]
DESSERT = [110, 111]
def categorise_dawson(df):
df = df.copy()
df["milk"] = df["code"].isin(MILK)
# priority 3.a
juice = df["code"].isin(JUICE)
invoices_one_juice = df[juice].groupby("invoice")["qty"].sum() == 1
df["one_juice"] = (
invoices_one_juice.reindex(df["invoice"]).fillna(False).values
)
# priority 3.b
df["many_juice"] = (
(~invoices_one_juice).reindex(df["invoice"]).fillna(False).values
)
# priority 4
df["hot"] = df["code"].isin(HOT)
# priority 5
df["dessert"] = df["code"].isin(DESSERT)
# Act 2: the big group by and merge
invoices = (
df.groupby(["invoice"])
.agg(
{
"qty": "sum",
"milk": "any",
"one_juice": "any",
"many_juice": "any",
"hot": "any",
"dessert": "any",
}
)
.rename(
columns={
"qty": "total", # this is renamed because joining with duplicate names leads to qty_x and qty_y
}
)
)
# priority 1
invoices["mega"] = invoices["total"] >= 10
df = df.merge(invoices, on="invoice", how="left")
# Act 3: apply the categories
# apply the categories in reverse order to overwrite less important with the more important
df["category"] = "Others"
df.loc[df["dessert_y"], "category"] = "DessertLovers"
df.loc[df["hot_y"], "category"] = "HotLovers"
df.loc[df["many_juice_y"], "category"] = "ManyJuice"
df.loc[df["one_juice_y"], "category"] = "OneJuice"
df.loc[df["milk_y"], "category"] = "Healthy"
df.loc[df["mega"], "category"] = "Mega"
return df[
["invoice", "qty", "code", "category"]
] # get the columns you care about
# In[72]:
def classify_invoice1(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() >= 10:
return pd.Series("Mega", index=order.index)
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return pd.Series("Healthy", index=order.index)
juice_codes = {104, 105}
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return pd.Series("OneJuice", index=order.index)
if juices_amount > 1:
return pd.Series("ManyJuice", index=order.index)
hot_codes = {103, 109}
if order["code"].isin(hot_codes).any():
return pd.Series("HotLovers", index=order.index)
dessert_codes = {110, 111}
if order["code"].isin(dessert_codes).any():
return pd.Series("DessertLovers", index=order.index)
return pd.Series("Others", index=order.index)
def solution_maarten_1(data):
categories = pd.concat(
classify_invoice1(data) for invoice, data in data.groupby("invoice")
)
return data.assign(category=categories)
# In[14]:
def classify_invoice2(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() >= 10:
return "Mega"
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return "Healthy"
juice_codes = {104, 105}
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return "OneJuice"
if juices_amount > 1:
return "ManyJuice"
hot_codes = {103, 109}
if order["code"].isin(hot_codes).any():
return "HotLovers"
dessert_codes = {110, 111}
if order["code"].isin(dessert_codes).any():
return "DessertLovers"
return "Others"
def solution_maarten_2(data):
return data.join(
data.groupby("invoice").apply(classify_invoice2).rename("category"),
on="invoice",
)
# In[17]:
milk_codes = {106, 107, 108}
juice_codes = {104, 105}
hot_codes = {103, 109}
dessert_codes = {110, 111}
def classify_invoice3(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() >= 10:
return "Mega"
if order["code"].isin(milk_codes).any():
return "Healthy"
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return "OneJuice"
if juices_amount > 1:
return "ManyJuice"
if order["code"].isin(hot_codes).any():
return "HotLovers"
if order["code"].isin(dessert_codes).any():
return "DessertLovers"
return "Others"
def solution_maarten_3(data):
return data.join(
data.groupby("invoice").apply(classify_invoice3).rename("category"),
on="invoice",
)
# In[20]:
CATEGORIES = {
0: "Mega",
1: "Healthy",
2: "OneJuice",
3: "ManyJuice",
4: "HotLovers",
5: "DessertLovers",
-1: "Others",
}
def classify_invoice4(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() >= 10:
return 0
if order["code"].isin(milk_codes).any():
return 1
juices_amount = order.loc[order["code"].isin(juice_codes), "qty"].sum()
if juices_amount == 1:
return 2
if juices_amount > 1:
return 3
if order["code"].isin(hot_codes).any():
return 4
if order["code"].isin(dessert_codes).any():
return 5
return -1
def solution_maarten4(data):
return data.join(
(
data.groupby("invoice")
.apply(classify_invoice4)
.map(CATEGORIES)
.rename("category")
),
on="invoice",
)
# In[24]:
def classify_invoice_numpy(invoices, quantities, codes):
SODA = np.array([101, 102])
HOT = np.array([103, 109])
JUICE = np.array([104, 105]) # remember spaces after commas
MILK = np.array([106, 107, 108])
DESSERT = np.array([110, 111])
juices = np.isin(codes, JUICE)
milk = np.isin(codes, MILK)
hot = np.isin(codes, HOT)
dessert = np.isin(codes, DESSERT)
result = -np.ones(len(invoices), dtype=int)
for invoice in np.unique(invoices):
index = invoices == invoice
if quantities[index].sum() >= 10:
result[index] = 0
continue
if milk[index].any():
result[index] = 1
continue
juices_index = index & juices
if juices_index.any():
if quantities[juices_index].sum() == 1:
result[index] = 2
continue
else:
result[index] = 3
continue
if hot[index].any():
result[index] = 4
continue
if dessert[index].any():
result[index] = 5
continue
return result
# In[25]:
def solution_maarten_numpy(data):
return data.assign(
category=pd.Series(
classify_invoice_numpy(
data["invoice"].values,
data["qty"].values,
data["code"].values,
),
index=data.index,
).map(CATEGORIES)
)
# In[28]:
import timeit
# In[52]:
def benchmark(functions, size=100, lines_per_invoice=3, seed=0):
data_original = dummy_data(
n=size, lines_per_invoice=lines_per_invoice, seed=seed
)
yield data_original
benchmark_result = categorise_dawson(data_original)
for function in functions:
data = data_original.copy()
result = function(data)
try:
pd.testing.assert_frame_equal(result, benchmark_result)
except AssertionError:
print(f"method {function.__name__} differs from the benchmark")
# print(result)
# print(benchmark_result)
print(compare_results(benchmark_result, result))
# pd.testing.assert_frame_equal(result, benchmark_result)
continue
try:
pd.testing.assert_frame_equal(data, data_original)
except AssertionError:
print(f"method {function.__name__} changes the original data")
continue
time = timeit.timeit(
"function(data)",
globals={"function": function, "data": data},
number=1,
)
yield function.__name__, time
# In[89]:
data_originals = {}
sizes = 10, 100, 1000, 10000
functions = [
solution_maarten_1,
solution_maarten_2,
solution_maarten_3,
solution_maarten4,
solution_maarten_numpy,
categorise_dawson,
categorise_OP,
]
result_df = pd.DataFrame(index=[function.__name__ for function in functions])
for size in sizes:
data_original, *results = benchmark(functions=functions, size=size,)
data_originals[size] = data_original
result_df[size] = pd.Series(dict(results))
# In[94]:
print(result_df.to_markdown())
# In[99]:
result_df.T.plot(logx=True, logy=True)
OneJuice
instead ofManyJuice
. Or your description is wrong \$\endgroup\$(df_2['code'].isin(Juice) .groupby(df_2['invoice']) .transform('sum') > 1)
. This counts the number of lines of juices in an invoice, without taking the quantities into account \$\endgroup\$J_1 = (df_2["qty"] * df_2["code"].isin(Juice)).groupby( df_2["invoice"] ).transform("sum") == 1
and the same change forJ_2
seems to fix it \$\endgroup\$