Énoncé du problème
Je recherche un moyen efficace de générer des produits cartésiens binaires complets (tableaux avec toutes les combinaisons de Vrai et Faux avec un certain nombre de colonnes), filtrés par certaines conditions exclusives. Par exemple, pour trois colonnes / bits, n=3
nous aurions le tableau complet
df_combs = pd.DataFrame(itertools.product(*([[True, False]] * n)))
0 1 2
0 True True True
1 True True False
2 True False True
3 True False False
...
Ceci est censé être filtré par les dictionnaires définissant les combinaisons mutuellement exclusives comme suit:
mutually_excl = [{0: False, 1: False, 2: True},
{0: True, 2: True}]
Où les clés désignent les colonnes du tableau ci-dessus. L'exemple se lirait comme suit:
- Si 0 est faux et 1 est faux, 2 ne peut pas être vrai
- Si 0 est vrai, 2 ne peut pas être vrai
Sur la base de ces filtres, la sortie attendue est:
0 1 2
1 True True False
3 True False False
4 False True True
5 False True False
7 False False False
Dans mon cas d'utilisation, la table filtrée est de plusieurs ordres de grandeur plus petite que le produit cartésien complet (par exemple, environ 1000 au lieu de 2**24 (16777216)
).
Voici mes trois solutions actuelles, chacune avec ses propres avantages et inconvénients, discutées à la toute fin.
import random
import pandas as pd
import itertools
import wrapt
import time
import operator
import functools
def get_mutually_excl(n, nfilt): # generate random example filter
''' Example: `get_mutually_excl(9, 2)` creates a list of two filters with
maximum index `n=9` and each filter length between 2 and `int(n/3)`:
`[{1: True, 2: False}, {3: False, 2: True, 6: False}]` '''
random.seed(2)
return [{random.choice(range(n)): random.choice([True, False])
for _ in range(random.randint(2, int(n/3)))}
for _ in range(nfilt)]
@wrapt.decorator
def timediff(f, _, args, kwargs):
t = time.perf_counter()
res = f(*args)
return res, time.perf_counter() - t
Solution 1: filtrez d'abord, puis fusionnez.
Développez chaque entrée de filtre unique (par exemple {0: True, 2: True}
) dans un sous-tableau avec des colonnes correspondant aux indices de cette entrée de filtre ( [0, 2]
). Supprimez une seule ligne filtrée de ce sous-tableau ( [True, True]
). Fusionnez avec le tableau complet pour obtenir la liste complète des combinaisons filtrées.
@timediff
def make_df_comb_filt_merge(n, nfilt):
mutually_excl = get_mutually_excl(n, nfilt)
# determine missing (unfiltered) columns
cols_missing = set(range(n)) - set(itertools.chain.from_iterable(mutually_excl))
# complete dataframe of unfiltered columns with column "temp" for full outer merge
df_comb = pd.DataFrame(itertools.product(*([[True, False]] * len(cols_missing))),
columns=cols_missing).assign(temp=1)
for filt in mutually_excl: # loop through individual filters
# get columns and bool values of this filters as two tuples with same order
list_col, list_bool = zip(*filt.items())
# construct dataframe
df = pd.DataFrame(itertools.product(*([[True, False]] * len(list_col))),
columns=list_col)
# filter remove a *single* row (by definition)
df = df.loc[df.apply(tuple, axis=1) != list_bool]
# determine which rows to merge on
merge_cols = list(set(df.columns) & set(df_comb.columns))
if not merge_cols:
merge_cols = ['temp']
df['temp'] = 1
# merge with full dataframe
df_comb = pd.merge(df_comb, df, on=merge_cols)
df_comb.drop('temp', axis=1, inplace=True)
df_comb = df_comb[range(n)]
df_comb = df_comb.sort_values(df_comb.columns.tolist(), ascending=False)
return df_comb.reset_index(drop=True)
Solution 2: expansion complète, puis filtre
Générer DataFrame pour un produit cartésien complet: le tout se retrouve en mémoire. Parcourez les filtres et créez un masque pour chacun. Appliquez chaque masque sur la table.
@timediff
def make_df_comb_exp_filt(n, nfilt):
mutually_excl = get_mutually_excl(n, nfilt)
# expand all bool combinations into dataframe
df_comb = pd.DataFrame(itertools.product(*([[True, False]] * n)),
dtype=bool)
for filt in mutually_excl:
# generate total filter mask for given excluded combination
mask = pd.Series(True, index=df_comb.index)
for col, bool_act in filt.items():
mask = mask & (df_comb[col] == bool_act)
# filter dataframe
df_comb = df_comb.loc[~mask]
return df_comb.reset_index(drop=True)
Solution 3: filtrer l'itérateur
Gardez le produit cartésien complet comme itérateur. Boucle en vérifiant pour chaque ligne si elle est exclue par l'un des filtres.
@timediff
def make_df_iter_filt(n, nfilt):
mutually_excl = get_mutually_excl(n, nfilt)
# switch to [[(1, 13), (True, False)], [(4, 9), (False, True)], ...]
mutually_excl_index = [list(zip(*comb.items()))
for comb in mutually_excl]
# create iterator
combs_iter = itertools.product(*([[True, False]] * n))
@functools.lru_cache(maxsize=1024, typed=True) # small benefit
def get_getter(list_):
# Used to access combs_iter row values as indexed by the filter
return operator.itemgetter(*list_)
def check_comb(comb_inp, comb_check):
return get_getter(comb_check[0])(comb_inp) == comb_check[1]
# loop through the iterator
# drop row if any of the filter matches
df_comb = pd.DataFrame([comb_inp for comb_inp in combs_iter
if not any(check_comb(comb_inp, comb_check)
for comb_check in mutually_excl_index)])
return df_comb.reset_index(drop=True)
Exécuter des exemples
dict_time = dict.fromkeys(itertools.product(range(16, 23, 2), range(3, 20)))
for n, nfilt in dict_time:
dict_time[(n, nfilt)] = {'exp_filt': make_df_comb_exp_filt(n, nfilt)[1],
'filt_merge': make_df_comb_filt_merge(n, nfilt)[1],
'iter_filt': make_df_iter_filt(n, nfilt)[1]}
Une analyse
import seaborn as sns
import matplotlib.pyplot as plt
df_time = pd.DataFrame.from_dict(dict_time, orient='index',
).rename_axis(["n", "nfilt"]
).stack().reset_index().rename(columns={'level_2': 'solution', 0: 'time'})
g = sns.FacetGrid(df_time.query('n in %s' % str([16,18,20,22])),
col="n", hue="solution", sharey=False)
g = (g.map(plt.plot, "nfilt", "time", marker="o").add_legend())
Solution 3 : l'approche basée sur les itérateurs ( comb_iterator
) a des temps d'exécution lamentables, mais aucune utilisation significative de la mémoire. Je pense qu'il y a place à amélioration, bien que la boucle inévitable impose probablement des limites strictes en termes de durée de fonctionnement.
Solution 2 : l'expansion du produit cartésien complet dans un DataFrame ( exp_filt
) provoque des pics de mémoire importants, que j'aimerais éviter. Les temps de course sont corrects cependant.
Solution 1 : La fusion de DataFrames créés à partir des filtres individuels ( filt_merge
) semble être une bonne solution pour mon application pratique (notez la réduction du temps d'exécution pour un plus grand nombre de filtres, qui est le résultat de la plus petite cols_missing
table). Pourtant, cette approche n'est pas entièrement satisfaisante: si un seul filtre inclut toutes les colonnes, l'ensemble du produit cartésien ( 2**n
) se retrouverait en mémoire, ce qui rend cette solution pire que comb_iterator
.
Question: D'autres idées? Un bi-liner fou numpy intelligent? L'approche basée sur les itérateurs pourrait-elle être améliorée d'une manière ou d'une autre?