Catégorisation basée sur les priorités à l'aide de pandas / python
J'ai des données de facturation et de code dans les Dataframes ci-dessous
Factures
df = pd.DataFrame({
'invoice':[1,1,2,2,2,3,3,3,4,4,4,5,5,6,6,6,7],
'code':[101,104,105,101,106,106,104,101,104,105,111,109,111,110,101,114,112],
'qty':[2,1,1,3,2,4,7,1,1,1,1,4,2,1,2,2,1]
})
+---------+------+-----+
| invoice | code | qty |
+---------+------+-----+
| 1 | 101 | 2 |
+---------+------+-----+
| 1 | 104 | 1 |
+---------+------+-----+
| 2 | 105 | 1 |
+---------+------+-----+
| 2 | 101 | 3 |
+---------+------+-----+
| 2 | 106 | 2 |
+---------+------+-----+
| 3 | 106 | 4 |
+---------+------+-----+
| 3 | 104 | 7 |
+---------+------+-----+
| 3 | 101 | 1 |
+---------+------+-----+
| 4 | 104 | 1 |
+---------+------+-----+
| 4 | 105 | 1 |
+---------+------+-----+
| 4 | 111 | 1 |
+---------+------+-----+
| 5 | 109 | 4 |
+---------+------+-----+
| 5 | 111 | 2 |
+---------+------+-----+
| 6 | 110 | 1 |
+---------+------+-----+
| 6 | 101 | 2 |
+---------+------+-----+
| 6 | 114 | 2 |
+---------+------+-----+
| 7 | 112 | 1 |
+---------+------+-----+
Codes
Hot = [103,109]
Juice = [104,105]
Milk = [106,107,108]
Dessert = [110,111]
Ma tâche est d'ajouter une colonne maintenant, categorybasée sur les priorités suivantes:
Si une facture a plus de \$10\$
qtyil doit être classé dans la catégorie "Mega".
Par exemple, le totalqtyde la facture 3 est \$12\$- \$4 + 7 + 1\$.Si l'un des
invoice'codes est dans la liste de lait ; la catégorie doit être «saine».
Par exemple, la facture 2 contient le code 106 qui se trouve dans la liste de lait. Ainsi, la facture entière est classéeHealthyindépendamment des autres éléments.Si l'un des
invoices'codes est dans la liste de jus ;Si le total
qtydes jus est égal à 1; la catégorie doit être "OneJuice".
Par exemple, la facture 1 acode104 etqty1.Autrement; la catégorie doit être "ManyJuice".
Par exemple, la facture 4 acode104 et 105 s avec un totalqtyde 2 - \$1 + 1\$.
Si l'un des
invoices'codes est dans la liste chaude ; la catégorie doit être "HotLovers".Si l' un des
invoicesdes »codede s sont dans la liste des desserts ; la catégorie doit être "DessertLovers".Toutes les autres factures doivent être classées dans la catégorie «Autres».
Ma sortie souhaitée est comme ci-dessous.
+---------+------+-----+---------------+
| invoice | code | qty | category |
+---------+------+-----+---------------+
| 1 | 101 | 2 | OneJuice |
+---------+------+-----+---------------+
| 1 | 104 | 1 | OneJuice |
+---------+------+-----+---------------+
| 2 | 105 | 1 | Healthy |
+---------+------+-----+---------------+
| 2 | 101 | 3 | Healthy |
+---------+------+-----+---------------+
| 2 | 106 | 2 | Healthy |
+---------+------+-----+---------------+
| 3 | 106 | 4 | Mega |
+---------+------+-----+---------------+
| 3 | 104 | 7 | Mega |
+---------+------+-----+---------------+
| 3 | 101 | 1 | Mega |
+---------+------+-----+---------------+
| 4 | 104 | 1 | ManyJuice |
+---------+------+-----+---------------+
| 4 | 105 | 1 | ManyJuice |
+---------+------+-----+---------------+
| 4 | 111 | 1 | ManyJuice |
+---------+------+-----+---------------+
| 5 | 109 | 4 | HotLovers |
+---------+------+-----+---------------+
| 5 | 111 | 2 | HotLovers |
+---------+------+-----+---------------+
| 6 | 110 | 1 | DessertLovers |
+---------+------+-----+---------------+
| 6 | 101 | 2 | DessertLovers |
+---------+------+-----+---------------+
| 6 | 114 | 2 | DessertLovers |
+---------+------+-----+---------------+
| 7 | 112 | 1 | Others |
+---------+------+-----+---------------+
J'ai ce qui suit. Cela fonctionne mais cela semble assez naïf et pas du tout pythonique.
Lorsque je l'applique à l'ensemble de données d'origine, le code est également très lent.
# 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['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['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
df = df.merge(category,on='invoice', how='left').fillna(value='Others')
Réponses
Votre code est assez impressionnant. De nombreux programmeurs python ne savent pas comment utiliser les pandas aussi bien que vous. Votre code n'a peut-être pas l' air très "pythonique", mais vous avez fait un excellent travail en utilisant des méthodes vectorisées avec indexation. Dans cette réponse, j'inclus une section sur les conventions de code Python et une seconde tentative d'optimisation de votre code.
Conventions du code Python
De nombreuses entreprises ont des guides de style standardisés qui facilitent la lecture du code. Ceci est inestimable lorsque de nombreuses personnes écrivent dans la même base de code. Sans cohérence, le repo se dégraderait en un désordre d'idiosyncrasies.
Vous devriez envisager d'adopter les conventions de code suivantes pour rendre votre code plus facile à lire:
- Suivez les conventions de dénomination des variables standard: Guide de style Google Python sur la dénomination
- Inclure un espace après les virgules: Guide de style Google Python sur les espaces
# most python programmers use CaseLikeThis (pascal case) for class names
# constants are often written in CASE_LIKE_THIS (snake case)
SODA = [101, 102]
HOT = [103, 109]
JUICE = [104, 105] # remember spaces after commas
MILK = [106, 107, 108]
DESSERT = [110, 111]
Tentative d'optimisation
Pour optimiser votre code, vous devez chronométrer la durée de chaque étape. Cela peut être fait en vérifiant l'horloge avant et après un segment de code.
import time
t0 = time.time() # check clock before (milliseconds elapsed since jan 1, 1970)
# segment you want to measure; something like your group by or merge...
t1 = time.time() # check clock after
time_to_run_step = t1 - t0
En mesurant le temps que prend chaque étape pour s'exécuter, vous pouvez concentrer votre énergie en optimisant les étapes les plus lentes. Par exemple, optimiser une opération de 0,1 seconde pour qu'elle soit 100 fois plus rapide est moins bon que d'optimiser une opération de 10 secondes pour être 2x plus rapide.
Lorsque vous réfléchissez à l'optimisation de votre code, deux questions sont venues à l'esprit:
- Pouvons-nous appliquer les priorités dans l'ordre inverse pour éviter de filtrer les priorités déjà catégorisées?
- Pouvons-nous effectuer tout le groupe par œuvre en même temps?
Le regroupement et la fusion sont des opérations coûteuses car elles évoluent généralement de manière quadratique (nombre de factures X nombre de codes). Je parie que ce sont les étapes les plus lentes de votre code, mais vous devriez le chronométrer pour vérifier.
# Act 1: set up everything for the big group by
# priority 1
# will be setup at the end of Act 2
# priority 2
df['milk'] = df['code'].isin(MILK)
# priority 3.a
# priority 3.b
juice = df['code'].isin(JUICE)
df['juice_qty'] = df['qty']
df.loc[~juice, 'juice_qty'] = 0 # I thought df['juice_qty'][~juice] was intuitive, but it gave a warning https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
# distinguish single from many juice in Act 2
# 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',
'juice_qty': 'sum',
'hot': 'any',
'dessert': 'any',
}).rename(columns={
'qty': 'total', # this is renamed because joining with duplicate names leads to qty_x and qty_y
'juice_qty': 'juice_total',
})
# priority 1
invoices['mega'] = invoices['total'] >= 10
# priority 3.a
# priority 3.b
invoices['one_juice'] = invoices['juice_total'] == 1
invoices['many_juice'] = invoices['juice_total'] > 1
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'], 'category'] = 'ManyJuice'
df.loc[df['one_juice'], 'category'] = 'OneJuice'
df.loc[df['milk_y'], 'category'] = 'Healthy'
df.loc[df['mega'], 'category'] = 'Mega'
df = df[['invoice', 'code', 'qty', 'category']] # get the columns you care about
@Tommy et @ MaartenFabré ont remarqué un bug avec la catégorisation des jus simples et nombreux. J'ai édité cette réponse avec une correction.
Edit: Il y a pas mal de réponses à cette question qui s'étend également au débordement de pile. Ci-dessous un résumé au 20/09/2020.
- catégorisation originale basée sur les priorités à l'aide de pandas / python
- one_group_by https://codereview.stackexchange.com/a/249481/230673
- np_select https://stackoverflow.com/a/63947686/14308614
- np_select_where https://codereview.stackexchange.com/a/249586/230673
- https://codereview.stackexchange.com/a/249486/230673 n'a pas été tracé car la complexité temporelle était différente
Les performances ont été tracées à l'aide du code de https://stackoverflow.com/a/63947686/14308614
Au lieu de regrouper par facture sur chaque catégorie, j'inverserais la logique. Regroupez par facture, puis classez cette facture.
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 |
Ensuite, pour ajouter ceci au résultat, vous pouvez attribuer.
result = df.assign(category=categories)
Ici, j'ai utilisé assign, qui renvoie un nouveau DataFrame. Je le fais exprès, afin que vous puissiez conserver votre DataFrame d'origine intact. Les modifications apportées à votre DataFrame d'origine peuvent être une source d'erreurs.
Classificateur
Ensuite, nous avons juste besoin de concevoir le classificateur. Ici, nous avons besoin d'une fonction qui accepte un DataFrame qui couvre exactement 1 facture, et renvoie une série avec la catégorie, avec le même index que la facture.
Priorité 1
La priorité 1 est alors simple:
def classify_invoice(order: pd.DataFrame) -> pd.Series:
if order["qty"].sum() > 10:
return pd.Series("Mega", index=order.index)
Priorité 2
La priorité 2 est également très simple:
milk_codes = {106, 107, 108}
if order["code"].isin(milk_codes).any():
return pd.Series("Healthy", index=order.index)
Notez que j'ai renommé la variable Milken milk_codes, car cela décrit mieux ce que cela signifie, et que je l'ai convertie en a set, puisque c'est la structure de données destinée aux contrôles de confinement
autres priorités
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)
Essai
Puisque vous avez déchargé la catégorisation vers une autre fonction, vous pouvez la tester de manière isolée
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"
)
C'est à peu près aussi rapide que mon autre solution et légèrement plus simple à suivre.
micro optimisations
Maintenant, les codes sont définis à chaque groupby. S'il y a beaucoup de factures, il peut être plus rapide de les définir en dehors de la méthode:
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"
catégorique
Travailler avec un catégoriel peut être plus rapide qu'avec une colonne de chaînes aussi:
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",
)
Dans le benchmark avec les exemples de données, cela a été légèrement plus lent, mais pour des ensembles de données plus volumineux, cela pourrait être plus rapide
engourdi
Vous pouvez également le faire dans un pays numpy:
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
J'ai fait quelques benchmarking
données factices:
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}
)
comparer quand il y a quelque chose de différent
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
)
référence
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
l'appeler
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 |
Donc, mon code démarre plus rapidement pour les petites tailles, mais change presque linéairement avec la taille, tandis que votre code et @dawsons sont presque constants pour la taille
code complet
#!/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)
Ici, je propose une approche différente pour résoudre ce problème plus efficacement. Par rapport à la solution OP, l'optimisation principale se présente sous les aspects suivants:
Appeler
isinquatre fois pour chaque classe d'articles (dessert, chaud, jus, lait) est inefficace. Une meilleure approche consiste à joinutiliser le DataFrame d'originedfavec unSeriesqui mappe chaque élément à une classe, puis à appliquer pd.get_dummiesà la nouvelle colonne de classe pour effectuer un codage à chaud. Ma solution fonctionnera directement sur les informations de classe, donc la deuxième étape n'est pas nécessaire.Chaque classe d'articles se voit attribuer une valeur de priorité alignée sur sa priorité dans la logique de calcul de la
categoryvaleur, c'est-à-dire Dessert <Hot <Juice <Milk. La logique de calcul pourrait alors être réécrite comme suit:- Calculez la quantité totale, la quantité totale de jus et la valeur de priorité maximale de chaque facture;
- Si la quantité totale> 10, la valeur de la catégorie est "Mega";
- Si la valeur de priorité maximale est "Juice" et la quantité totale> 1, la valeur de la catégorie est "ManyJuice";
- Sinon, attribuez la valeur de catégorie en fonction de la valeur de priorité maximale.
Dans l'implémentation, la
categorycolonne est de type catégorielINVOICE_TYPEet chaque valeur de catégorie a son code numérique correspondant. La valeur de priorité de chaque classe d'éléments est le code numérique de la catégorie correspondante de la classe.np.selectest utilisé pour implémenter la logique if-elif-else de manière vectorisée. (Remarque: pour la logique if-else,
np.where/ pd.DataFrame.wherepourrait être utilisé à la place.)
Solution:
import pandas as pd
import numpy as np
def add_category(df: pd.DataFrame, mega_threshold: int = 10):
# Invoice categories
INVOICE_TYPE = pd.CategoricalDtype([
"Others", "DessertLovers", "HotLovers", "ManyJuice", "OneJuice", "Healthy", "Mega"
], ordered=True)
CODE_OTHERS = 0 # Numerical code of 'Others' category
# Mapping from item classes to invoice category codes
class_values = pd.Series(
pd.Categorical(["DessertLovers", "HotLovers", "OneJuice", "Healthy"], dtype=INVOICE_TYPE).codes,
index=["Dessert", "Hot", "Juice", "Milk"]
)
# Mapping from item codes to class priority values, which are equivalent to corresponding invoice category codes
item_code_values = pd.Series(
class_values[["Hot", "Juice", "Juice", "Milk", "Milk", "Milk", "Hot", "Dessert", "Dessert"]].to_numpy(),
index=pd.RangeIndex(103, 112), name="item_value"
)
df_item_values = df.join(item_code_values, on="code")
df_item_values["juice_qty"] = (df_item_values["item_value"] == class_values["Juice"]) * df_item_values["qty"]
# Compute total quantity, total juice quantity, and maximum item priority value of each invoice by aggregation
df_invoice_info = df_item_values.groupby("invoice").agg({
"qty": "sum",
"juice_qty": "sum",
"item_value": "max"
})
df_invoice_info.columns = ["total_qty", "total_juice_qty", "max_item_value"]
## This version of aggregation has better readability but it turns out to be 2~3 times slower than the above
# df_invoice_info = df_item_values.groupby("invoice").agg(
# total_qty=("qty", "sum"),
# total_juice_qty=("juice_qty", "sum"),
# max_item_value=("item_value", "max")
# )
max_invoice_item_values = df_invoice_info["max_item_value"]
max_invoice_item_values.fillna(CODE_OTHERS, inplace=True, downcast="int8")
is_mega = df_invoice_info["total_qty"] > mega_threshold
is_many_juice = ((max_invoice_item_values == class_values["Juice"]) &
(df_invoice_info["total_juice_qty"] > 1))
# Compute invoice category codes
invoice_type_codes = pd.Series(np.select(
[is_mega, is_many_juice],
pd.Categorical(["Mega", "ManyJuice"], dtype=INVOICE_TYPE).codes,
max_invoice_item_values),
index=df_invoice_info.index
)
# Join category codes with the original DataFrame and transform them to the categorical type INVOICE_TYPE
df["category"] = pd.Categorical.from_codes(invoice_type_codes[df["invoice"]], dtype=INVOICE_TYPE)
# For performance testing, returning a copy of df instead of modifying it in-place
# return df.assign(category=pd.Categorical.from_codes(invoice_type_codes[df["invoice"]], dtype=INVOICE_TYPE))
if __name__ == "__main__":
df = pd.DataFrame({
'invoice': [1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 7],
'code': [101, 104, 105, 101, 106, 106, 104, 101, 104, 105, 111, 109, 111, 110, 101, 114, 112],
'qty': [2, 1, 1, 3, 2, 4, 7, 1, 1, 1, 1, 4, 2, 1, 2, 2, 1]
})
add_category(df)
print(df)
Production:
invoice code qty category
0 1 101 2 OneJuice
1 1 104 1 OneJuice
2 2 105 1 Healthy
3 2 101 3 Healthy
4 2 106 2 Healthy
5 3 106 4 Mega
6 3 104 7 Mega
7 3 101 1 Mega
8 4 104 1 ManyJuice
9 4 105 1 ManyJuice
10 4 111 1 ManyJuice
11 5 109 4 HotLovers
12 5 111 2 HotLovers
13 6 110 1 DessertLovers
14 6 101 2 DessertLovers
15 6 114 2 DessertLovers
16 7 112 1 Others
Code de test de performance pour l'exécution de Jupyter Notebook (dans la add_categoryfonction, une copie de dfest retournée au lieu de la modification sur place) par rapport à la solution @ JoshDawson et cette solution sur SO :
df = pd.DataFrame({
'invoice': [1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 7],
'code': [101, 104, 105, 101, 106, 106, 104, 101, 104, 105, 111, 109, 111, 110, 101, 114, 112],
'qty': [2, 1, 1, 3, 2, 4, 7, 1, 1, 1, 1, 4, 2, 1, 2, 2, 1]
})
# Test input DataFrame from OP
test_input = df
%timeit add_category(test_input)
%timeit add_category_dawson(test_input)
%timeit add_category_SO(test_input)
# Test input constructed by duplicating the original DataFrame 10**5 times
# and modifying the output to differentiate the invoice ids in each copy
test_input = pd.concat([df] * 10**5, ignore_index=True)
test_input["invoice"] += test_input.index // df.shape[0] * df["invoice"].max()
%timeit add_category(test_input)
%timeit add_category_dawson(test_input)
%timeit add_category_SO(test_input)
Résultats des tests de performances sur le DataFrame original d'OP:
11.9 ms ± 422 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
17.5 ms ± 357 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
9.52 ms ± 106 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Résultats des tests de performances sur un grand DataFrame:
411 ms ± 3.65 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
1 s ± 5.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
1.1 s ± 10.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)