Genera prodotti cartesiani binari filtrati


12

Dichiarazione problema

Sto cercando un modo efficiente per generare prodotti cartesiani binari completi (tabelle con tutte le combinazioni di True e False con un certo numero di colonne), filtrate da determinate condizioni esclusive. Ad esempio, per tre colonne / bit n=3otterremmo la tabella completa

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
...

Questo dovrebbe essere filtrato dai dizionari che definiscono combinazioni reciprocamente esclusive come segue:

mutually_excl = [{0: False, 1: False, 2: True},
                 {0: True, 2: True}]

Dove le chiavi indicano le colonne nella tabella sopra. L'esempio verrebbe letto come:

  • Se 0 è falso e 1 è falso, 2 non può essere vero
  • Se 0 è True, 2 non può essere True

Sulla base di questi filtri, l'output previsto è:

       0      1      2
1   True   True  False
3   True  False  False
4  False   True   True
5  False   True  False
7  False  False  False

Nel mio caso d'uso, la tabella filtrata è più ordini di grandezza più piccoli del prodotto cartesiano completo (ad esempio circa 1000 anziché 2**24 (16777216)).

Di seguito sono riportate le mie tre soluzioni attuali, ciascuna con i propri pro e contro, discussi alla fine.


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

Soluzione 1: filtrare prima, quindi unire.

Espandi ogni singola voce di filtro (ad es. {0: True, 2: True}) In una sotto-tabella con colonne corrispondenti agli indici in questa voce di filtro ( [0, 2]). Rimuovere la riga singola filtrata da questa tabella secondaria ( [True, True]). Unisci con la tabella completa per ottenere l'elenco completo delle combinazioni filtrate.

@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)

Soluzione 2: espansione completa, quindi filtro

Genera DataFrame per un prodotto cartesiano completo: l'intera cosa finisce in memoria. Passa attraverso i filtri e crea una maschera per ciascuno. Applica ogni maschera al tavolo.


@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)

Soluzione 3: filtro iteratore

Mantenere il prodotto cartesiano completo come iteratore. Esegui il ciclo controllando per ogni riga se è escluso da uno qualsiasi dei filtri.

@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)

Esegui esempi

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]}

Analisi

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())

inserisci qui la descrizione dell'immagine

Soluzione 3 : l'approccio basato sull'iteratore ( comb_iterator) ha tempi di esecuzione tristi, ma nessun uso significativo della memoria. Sento che ci sono margini di miglioramento, anche se l'inevitabile ciclo impone probabilmente limiti duri in termini di tempo di esecuzione.

Soluzione 2 : l'espansione dell'intero prodotto cartesiano in un DataFrame ( exp_filt) provoca picchi significativi nella memoria, che vorrei evitare. I tempi di esecuzione sono ok però.

Soluzione 1 : Unire DataFrames creati dai singoli filtri ( filt_merge) sembra una buona soluzione per la mia applicazione pratica (notare la riduzione del tempo di esecuzione per un numero maggiore di filtri, che è il risultato della cols_missingtabella più piccola ). Tuttavia, questo approccio non è del tutto soddisfacente: se un singolo filtro include tutte le colonne, l'intero prodotto cartesiano ( 2**n) finirebbe in memoria, peggiorando questa soluzione comb_iterator.

Domanda: altre idee? Un pazzo due fodere intorpidite intelligente? L'approccio basato sull'iteratore potrebbe essere migliorato in qualche modo?


1
I risolutori di vincoli probabilmente supererebbero questi approcci perché trovano queste soluzioni riducendo lo spazio di ricerca. Forse dai un'occhiata a or-tools. Ecco un esempio per SAT.
Ayhan

1
@ayhan, ho provato (vedi risposta). È un approccio interessante, ma non adatto come soluzione generale. Grazie per l'input. Ho imparato qualcosa :)
mcsoini

Sì, questo suona come un problema SAT , quindi dovresti assolutamente usare un risolutore se il problema è abbastanza grande. Puoi anche provare or.stackexchange.com
Stradivari il

La formulazione di @Stradivari come problema SAT ha sicuramente senso. Tuttavia, non mi piace la forte dipendenza dal numero di filtri di questo approccio. Potrebbe essere che non sto accedendo correttamente alle soluzioni. Dato che conosci il tuo modo di aggirare or-tools, forse vuoi dare un'occhiata alla mia domanda corrispondente ... manca ancora una risposta accettata;)
mcsoini

Risposte:


1

Prova a cronometrare quanto segue:

def in_filter(arr, arr_filt, n):
    return ((arr[:, None] >> (n-1-arr_filt[:, 0])) & 1 == arr_filt[:, 1]).all(axis=1)

def bits_to_boolean(arr, n):
    return ((arr[:, None] >> np.arange(n, dtype=arr.dtype)[::-1]) & 1).astype(bool)

@timediff
def recursive_filter(n, nfilt, dtype='uint32'):
    filts = get_mutually_excl(n, nfilt)
    out = np.arange(2**n, dtype=dtype)
    for filt in filts:
        arr_filt = np.array(list(filt.items()))
        out = out[~in_filter(out, arr_filt, n)]
    return bits_to_boolean(out, n)[::-1]

Tratta i prodotti binari cartesiani come bit codificati nell'intervallo di numeri interi 0..<2**ne utilizza funzioni vettorizzate per rimuovere ricorsivamente numeri con sequenze di bit corrispondenti ai filtri indicati.

L'efficienza della memoria è migliore dell'allocazione di tutti i [True, False]prodotti cartesiani poiché ogni booleano verrebbe memorizzato con almeno 8 bit ciascuno (utilizzando 7 bit in più del necessario), ma utilizzerà più memoria di un approccio basato su iteratore. Se è necessaria una soluzione di grandi dimensioni n, è possibile suddividere questa attività allocando e operando su un intervallo secondario alla volta. Ho avuto questo nella mia prima implementazione, ma non ha offerto molti vantaggi n<=22e ha richiesto il calcolo delle dimensioni dell'array di output, il che si complica quando ci sono filtri sovrapposti.


Questo è davvero fantastico!
mcsoini,

1

Sulla base del commento di @ ayhan ho implementato una soluzione basata su SAT or-tools. Mentre l'idea è fantastica, questo in realtà lotta per un numero maggiore di variabili binarie. Ho il sospetto che questo sia simile ai grandi problemi di PI, che non è nemmeno una passeggiata nel parco. Tuttavia, la forte dipendenza dai numeri di filtro potrebbe rendere questa opzione valida per alcune configurazioni di parametri. Ma come soluzione generale non la userei.

from ortools.sat.python import cp_model

class VarArraySolutionCollector(cp_model.CpSolverSolutionCallback):

    def __init__(self, variables):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.__variables = variables
        self.solution_list = []

    def on_solution_callback(self):
        self.solution_list.append([self.Value(v) for v in self.__variables])


@timediff
def make_df_comb_sat(n, nfilt):

    mutually_excl = get_mutually_excl(n, nfilt)

    model = cp_model.CpModel()

    make_var_name = 'x{:02d}'.format
    vrs = dict.fromkeys(map(make_var_name, range(n)))
    for var_name in vrs:
        vrs[var_name] = model.NewBoolVar(var_name)

    for filt in mutually_excl:
        list_expr = [vrs[make_var_name(iv)]
                     if not bool_ else getattr(vrs[make_var_name(iv)], 'Not')()
                     for iv, bool_ in filt.items()]
        model.AddBoolOr(list_expr)

    solver = cp_model.CpSolver()
    solution_printer = VarArraySolutionCollector(vrs.values())
    solver.SearchForAllSolutions(model, solution_printer)

    df_comb = pd.DataFrame(solution_printer.solution_list).astype(bool)
    df_comb = df_comb.sort_values(df_comb.columns.tolist(), ascending=False)
    df_comb = df_comb.reset_index(drop=True)

    return df_comb

inserisci qui la descrizione dell'immagine

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.