Nasz Blog

Analiza koszykowa przy wykorzystaniu Qlika i Pythona

Artykuł przedstawia kroki pozwalające na wykonanie analizy koszykowej, na przykładzie analizy danych o sprzedaży produktów.

Proponujemy stopniowe rozszerzanie projektu. Rozpoczęcie od przećwiczenia wykonywania tylko analizy w Pythonie, a następnie włączenie Qlika dla łatwiejszego przygotowania danych wejściowych. Później po użycie Qlika do analizy wyników, ale jeszcze z przesyłaniem wyników analizy koszykowej za pomocą plików. Ostatnim etapem będzie wykorzystanie konektora SSE, który pozwala na wymianę danych pomiędzy Qlikiem i Pythonem w tle, w trakcie pracy aplikacji analitycznych.

Etap 1. Analiza koszykowa w Pythonie.

Wykonanie analizy wymaga przygotowania środowiska, przygotowania danych oraz wywołania funkcji dokonującej właściwej analizy.

1. Przygotowanie środowiska

Jednym z najpopularniejszych środowisk do analizy danych jest obecnie Jupyter Notebook. Aby utworzyć środowisko należy najpierw zainstalować pythona najlepiej w wersji 64 bitowej. 

Analiza jest wykonywana w środowisku Windows dlatego linki będą dotyczyły tego środowiska. Wszystkie wersje na Windows są zebrane pod poniższym linkiem: 

https://www.python.org/downloads/windows/ 

Często zdarza się że biblioteki dla data science lub machine learning nie są na bieżąco z najnowszymi wersjami pythona, dlatego lepiej zainstalować nieco starszą wersję 3.8 lub 3.7. W analizie koszykowej będzie wykorzystywana wersja 3.8 64bit dostępna pod linkiem: 

https://www.python.org/ftp/python/3.8.10/python-3.8.10-amd64.exe

Po zainstalowaniu pythona należy zainstalować następujące biblioteki poniższymi komendami z linii poleceń Windows:

pip install pandas
pip install mlxtend
pip install notebook

Następnie uruchamiamy środowisko webowe notebook z poziomu linii poleceń Windows:

jupyter notebook

Pierwszym krokiem jest zaimportowanie wymaganych bibliotek. Biblioteka pandas służy głównie do wciągnięcia danych z pliku csv lub xlsx a następnie przygotowaniu danych w formacie wymaganym przez biblioteki właściwe odpowiedzialne za analizę koszykową.

import numpy as np
import pandas as pd
from mlxtend.frequent_patterns import apriori
from mlxtend.frequent_patterns import association_rules
pd.options.mode.chained_assignment = None  # default='warn'

2. Załadowanie danych

Do wykonania analizy koszykowej potrzebny nam będzie dowolny zbiór danych o transakcjach sprzedaży, ograniczony do dwóch informacji: identyfikatora transakcji i identyfikatora produktu.

Do pierwszego uruchomienia proponujemy użycie danych dostępnych publicznie, np. zbioru Retail.xlsx z repozytorium danych Machine Learning MCI:

https://archive-beta.ics.uci.edu/ml/datasets#retail

Zbiór zawiera pół miliona transakcji zarejestrowanych pomiędzy 01/12/2010 a 09/12/2011 w brytyjskim sklepie internetowym.

Pierwszym krokiem jest wczytanie pliku, który zawiera  dane o transakcjach (paragonach).

Użyjemy dodatkowo komendy %%time, tzw.  magic command, która pozwala ona mierzyć czas wykonywania poszczególnej komórki

%%time
data = pd.read_excel('./data/Online Retail.xlsx')
Wall time: 56.7 s

Wybieramy tylko kolumny z numerami paragonów oraz kodami produktów

data = data[['InvoiceNo', 'StockCode']]

Następnie zmieniamy nazwy kolumn na order_id i product_id ze względu na to że w poniższym kodzie będziemy się właśnie takimi posługiwać

data.columns = ['order_id', 'product_id']

Za pomocą funkcji head() wyświetlamy 5 pierwszych wierszy żeby podejrzeć dane. Będziemy tą operację wykonywali wielokrotnie, dla sprawdzenia poprawności wykonanej operacji.

data.head()
order_id product_id
0 536365 85123A
1 536365 71053
2 536365 84406B
3 536365 84029G
4 536365 84029E

Sprawdźmy statystykę danych załadowanych do tzw. DataFrame czyli ramki danych. Dane przechowywane w DataFrame, są traktowane jak tabela danych. 

Używamy do tego funkcji info(). Jak widać mamy około 0.5 mln linii paragonowych.

data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 541909 entries, 0 to 541908
Data columns (total 2 columns):
 #   Column      Non-Null Count   Dtype 
---  ------      --------------   ----- 
 0   order_id    541909 non-null  object
 1   product_id  541909 non-null  object
dtypes: object(2)
memory usage: 8.3+ MB

3. Przygotowanie danych

Przygotowanie danych ma na celu przekształcenie ich do postaci wymaganej przez funkcję wykonującą analizę, oraz ograniczenie do zbioru danych istotnych, tak aby analizę udało się wykonać w dostępnym środowisku.

W kolejnych etapach, operacje przygotowania danych będziemy wykonywali w skrypcie Qlik Sense.

Poniżej kilka funkcji pomocniczych, które będą przydatne przy przygotowaniu danych.

def encode(x):
    if x <= 0:
        return 0
    if x >= 1:
        return 1

def set_to_str(myset):
    return ','.join(list(myset))

def str_concat(str1,str2):
    return str1 + ' -> ' + str2

Do zliczania transakcji i liczenia częstotliwości występowania produktów, potrzebna nam będzie dodatkowa kolumna ze stałą wartością 1.

data['Qty']=1

Utworzymy teraz DataFrame o nazwie products z listą unikalnych produktów i liczbą wystąpień każdego z nich a następnie posortujemy je od najczęściej występujących.

products może być przydatny przy kolejnych krokach, kiedy okaże się że rozmiar zbioru do analizy jest zbyt duży.

%%time
products = pd.DataFrame(data.groupby(['product_id'])['Qty'].sum().sort_values(ascending=False))
Wall time: 43.8 ms
products.info()
<class 'pandas.core.frame.DataFrame'>
Index: 4070 entries, 85123A to m
Data columns (total 1 columns):
 #   Column  Non-Null Count  Dtype
---  ------  --------------  -----
 0   Qty     4070 non-null   int64
dtypes: int64(1)
memory usage: 63.6+ KB

products.head()
Qty
product_id
85123A 2313
22423 2203
85099B 2159
47566 1727
20725 1639

Liczba wystąpień produktów ma znaczenie w kontekście ilości wszystkich transakcji, dlatego utworzymy teraz nową kolumnę 'freq’ dzieląc ilość wystąpień produktów przez liczbę wszystkich transakcji na produktach.

products['freq'] = products['Qty']/len(data)

Ustawmy jeszcze format wyświetlania dla kolumn typu float na dziesiętny z 10 miejscami po przecinku zamiast domyślnego naukowego

pd.options.display.float_format = '{:.10f}'.format

products
Qty freq
product_id
85123A 2313 0.0042682443
22423 2203 0.0040652582
85099B 2159 0.0039840637
47566 1727 0.0031868819
20725 1639 0.0030244930
84031b 1 0.0000018453
84031a 1 0.0000018453
82613a 1 0.0000018453
20950 1 0.0000018453
m 1 0.0000018453
4070 rows × 2 columns

Istnieje 4070 produktów i tak duży zbiór może być problemem dla funkcji wykonującej analizę apriori.

Aby ograniczyć liczbę wierszy tylko do produktów istotnych, czyli występujących w zbiorze danych z zakładaną minimalną częstotliwością tworzymy nowy DataFrame, ustalając minimalną wartość częstotliwości na poziomie 0.0001. W ten sposób ograniczamy zbiór do 2133 najczęściej kupowanych produktów. 

W praktyce analizę koszykową wykonuje się często na zdecydowanie większych zbiorach danych, ale też za produkty nieistotne uznaje się często występujące w transakcjach znacznie częściej niż użyta tu wartość progowa  0.0001.

products_top = products[products['freq']>0.0001]
products_top.info()
<class 'pandas.core.frame.DataFrame'>
Index: 2133 entries, 85123A to 84086C
Data columns (total 2 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   Qty     2133 non-null   int64  
 1   freq    2133 non-null   float64
dtypes: float64(1), int64(1)
memory usage: 50.0+ KB

Tak przygotowane dane mogą być już użyte do wykonania analizy koszykowej.

Analiza koszykowa algorytmem Apriori

Dalsza analizę koszykową można wykonać za pomocą metody Apriori. Wymaga ona aby w wierszach danych wejściowych w kolejnych wierszach znajdowały się paragony (w tym przypadku kolumna order_id), natomiast każdy produkt musi mieć oddzielną kolumnę,  co wymaga przekształcenia tabeli. W komórkach tabeli muszą się znajdować wartości:

  • 1 – jeśli produkt wystąpił na tym paragonie
  • 0 – w przeciwnym wypadku

Rozmiar macierzy, która musiałaby powstać po przekształceniu może być za duży i operacja może się nie powieść z powodu braku zasobów. Dlatego należy ograniczyć ilość kolumn (produktów) lub wierszy (transakcji). W wyniku operacji będącej odpowiednikiem join po product_id między najczęściej występującymi produktami products_top oraz całym zbiorem transakcji data otrzymujemy ograniczony zbiór o nazwie data_reduced

%%time
data_reduced = pd.merge(data, products_top, how='inner', on=['product_id'])
Wall time: 105 ms
data_reduced.head()
order_id product_id Qty_x Qty_y freq
0 536365 85123A 1 2313 0.0042682443
1 536373 85123A 1 2313 0.0042682443
2 536375 85123A 1 2313 0.0042682443
3 536390 85123A 1 2313 0.0042682443
4 536394 85123A 1 2313 0.0042682443

 

Kilka przekształceń żeby doprowadzić do formatu kolumn jak w oryginalnym data.

data_reduced = data_reduced[['order_id', 'product_id', 'Qty_x']]
data_reduced.columns = ['order_id', 'product_id', 'Qty']

data_reduced.head()
order_id product_id Qty
0 536365 85123A 1
1 536373 85123A 1
2 536375 85123A 1
3 536390 85123A 1
4 536394 85123A 1

DataFrame data_reduce ma teraz mniej wierszy, ale co ważniejsze, ma też o wiele mniej produktów, co przełoży się na ilość kolumn po pivocie.

%%time
basket = data_reduced.groupby(['order_id', 'product_id'])['Qty'].sum().unstack().reset_index().fillna(0).set_index('order_id')
Wall time: 1.21 s
basket.head()
product_id 10002 10125 10133 10135 11001 15034 15036 15039 16008 16016 85232D 90200D 90209B 90214A C2 D DOT M POST S
order_id
536365 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000
536366 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000
536367 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000
536368 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000
536369 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000
5 rows × 2133 columns
basket.info()
<class 'pandas.core.frame.DataFrame'>
Index: 24372 entries, 536365 to C581569
Columns: 2133 entries, 10002 to S
dtypes: float64(2133)
memory usage: 396.8+ MB

Powstały DataFrame zajmuje 396MB posiadając 24372 wierszy i 2133 kolumn.

W tablicy wynikowej wszystkie wartości większe niż 1, muszą być zmienione w 1. 

Można do tego użyć funkcji zdefiniowanej poniżej:

def encode(x): if x <= 0: return 0 if x >= 1: return 1

i wywoływanej

basket = basket.applymap(encode)

Jednak jest to metoda bardzo powolna i zdarza się, że nie zostaje ukończona ze względu na ograniczenia pamięci. Jak łatwo wyliczyć 3 mln wierszy razy 500 kolumn daje 1,5 mld komórek dla każdej z nich jest wołana funkcja. Ponadto funkcja applymap musi utworzyć kopie data frame a dopiero ja przypisać co wymaga dużych zasobów pamięci. 

Dlatego lepiej zastosować metodę wywołującą funkcję dla każdej kolumny indywidualnie, według zadanych warunków.

%%time
for col in basket.columns:
    basket.loc[basket[col] >= 1, col] = 1
    basket.loc[basket[col] <= 0, col] = 0
Wall time: 1.47 s

basket.info()
<class 'pandas.core.frame.DataFrame'>
Index: 24372 entries, 536365 to C581569
Columns: 2133 entries, 10002 to S
dtypes: float64(2133)
memory usage: 396.8+ MB

Właściwe wykonanie analizy koszykowej wymaga podania przygotowanego zestawu danych oraz wskazania kolejno minimalnych wartości parametrów support i lift, powyżej których wyniki uznawane będą za istotne. Wartości tą trzeba ustalić metodą kolejnych prób, oceniając ilość i parametry zidentyfikowanych reguł.

Pierwszym krokiem jest wygenerowanie listy kombinacji najczęściej występujących produktów pojedynczo lub w parach lub trójkach itd.

%%time
frequent_itemsets = apriori(basket, min_support=0.02, use_colnames=True)
Wall time: 5.57 s

frequent_itemsets
support itemsets
0 0.0201930502 (15036)
1 0.0271814672 (20685)
2 0.0205405405 (20711)
3 0.0336679537 (20712)
4 0.0260231660 (20713)
215 0.0224710425 (23203, 85099B)
216 0.0211969112 (23300, 23301)
217 0.0228957529 (85099C, 85099B)
218 0.0210424710 (85099F, 85099B)
219 0.0211969112 (22697, 22698, 22699)
220 rows × 2 columns

frequent_itemsets = frequent_itemsets.sort_values("support",ascending=False)
frequent_itemsets.reset_index(drop=True, inplace=True)

Drugim krokiem jest wygenerowanie reguł asocjacyjnych. Przyjmuje się, że produkty mogą być łączone w zestawy, jeśli dla łączącej je reguły parametr lift jest co najmniej równy 1.

rules = association_rules(frequent_itemsets, metric="lift", min_threshold=1)

rules
antecedents consequents antecedent support consequent support support confidence lift leverage conviction
0 (22386) (85099B) 0.0505087806 0.0876005252 0.0341785656 0.6766856214 7.7246753939 0.0297539699 2.8220194811
1 (85099B) (22386) 0.0876005252 0.0505087806 0.0341785656 0.3901639344 7.7246753939 0.0297539699 1.5569614082
2 (22697) (22699) 0.0433694403 0.0459543739 0.0321680617 0.7417218543 16.1403973510 0.0301750462 3.6938689627
3 (22699) (22697) 0.0459543739 0.0433694403 0.0321680617 0.7000000000 16.1403973510 0.0301750462 3.1887685322
4 (21931) (85099B) 0.0492778598 0.0876005252 0.0300754965 0.6103247294 6.9671355057 0.0257587301 2.3414354044
91 (85123A) (21733) 0.0921549319 0.0309371410 0.0202691613 0.2199465717 7.1094666380 0.0174181512 1.2423031857
92 (20725) (23206) 0.0659773511 0.0430411948 0.0201050386 0.3047263682 7.0798770684 0.0172652945 1.3763772440
93 (23206) (20725) 0.0430411948 0.0659773511 0.0201050386 0.4671115348 7.0798770684 0.0172652945 1.7527544879
94 (20723) (20724) 0.0301165272 0.0430822255 0.0201050386 0.6675749319 15.4953678474 0.0188075516 2.8785969118
95 (20724) (20723) 0.0430822255 0.0301165272 0.0201050386 0.4666666667 15.4953678474 0.0188075516 1.8185315116
96 rows × 9 columns

W powyższym przykładzie uzyskaliśmy zbiór 96 reguł asocjacyjnych, łączących dwa produkty. i opisanych siedmioma parametrami.

Dodatkowo, dla wygody przy interpretacji wyników, tabelę warto uzupełnić o kolumnę z nazwą reguły np. będącą zlepkiem kodów występujących w regule produktów.

rules['association_rule'] = rules.apply(
    lambda row: str_concat(row['antecedents'], row['consequents']),
    axis=1
)

Utworzenie pomocniczej tabeli pozwalającej na podstawie

product_rule_L = rules[['association_rule','antecedents']]
product_rule_L['Role'] = 'Antecedent'
product_rule_L.columns = ['association_rule', 'ArticleName','Role']

product_rule_R = rules[['association_rule','consequents']]
product_rule_R['Role'] = 'Consequent'
product_rule_R.columns = ['association_rule', 'ArticleName','Role']

product_rule = pd.concat([product_rule_L,product_rule_R])

Zapisanie reguł do plików tekstowych, skąd mogą być załadowane do Qlik Sense.

rules.to_csv(’rules.csv’, index=False) product_rule.to_csv(’rule_items.csv’, index=False)

Reguły asocjacyjne wygenerowane dla danych użytych w tym przykładzie, bezpośrednio po załadowaniu do Qlik Sense.
Reguły asocjacyjne w Qlik Sense

Podsumowanie

Przedstawiliśmy wykonanie analizy koszykowej algorytmem apriori wyłącznie przy użyciu Pythona.

Analiza wykonana na zbiorze danych testowych, liczącym pół miliona rekordów, ograniczonym do produktów występujących częściej niż w promilu transakcji, zidentyfikowała 96 istotnych reguł asocjacyjnych, czyli powiązań pomiędzy produktami.

Poziom istotności ustalony został empirycznie na: 

  • istotne produkty, biorące udział w analizie – o częstotliwości występowania w paragonach powyżej 0,0001
  • istotne asocjacje, brane pod uwagę a tworzeniu reguł – o parametrze support wyższym niż 0,02
  • istotne reguły, wchodzące do wyniku analizy – o parametrze lift wyższym niż 1.

W kolejnej części przedstawimy jak wykorzystać Qlik Sense do przygotowania danych, analizy wyników, oraz jak połączyć te dwa narzędzia przy pomocy konektora SSE, aby wymiana danych mogła się odbywać na bieżąco, w trakcie pracy użytkowników Qlika.