Cílem této práce je zpracovat data, kde banka sbírala údaje o tom, jak lidé platili kartou na pumpách především v České Republice, ale i v zahraničí a to v letech 2017-2018.
Popis datasetu
Jde o csv soubor, kde každy řádek zaznamenává jednu transakci. Dataset obsahuje následující sloupce.
clid: identifikace klienta
posid: identifikace čerpací stanice
day: datum platby ve formátu Y-m-d
hour: hodina platby (0-23)
amt: částka platby v CZK
fav: zda je tato stanice označena jako oblíbená klienta
fav_main: zda je tato stanice označena jako nejoblíbenější klienta
cl_gps_lat, cl_gps_lon: přibližné GPS souřadnice bydliště klienta
country: země platby
city: město platby
name: název čerpací stanice
clst: číslo clusteru, do kterého stanice spadá na základě chování klientů (0 = nezařazeno do žádného clusteru)
pos_gps_lat, pos_gps_lon: GPS souřadnice místa platby (platí pouze pro stanice v České republice)
dist: vzdálenost v km mezi bydlištěm klienta a místem platby
Csv soubor má 1853551 zázamů. Jeho velikost je 226 MB.
Cíle
Důvodem této analýzy je především získání zápočtu a dále snaha co nejlépe porozumět lidskému chování při placení na čerpacích stanicích na základě těchto dat. Tato analýza může mít užitek pro čerpací stanice, které mohou na základě dat optimalizovat své chování a zjistit, jak generovat větší zisky nebo jaká očekávání mohou mít pro dané období. Navíc i pro banky mohou být tato data cenná, neboť mohou zjistit, jakým zákazníkům je rozumné nabízet služby spojené například s cestováním, pojistkou na auto a jiné s tímto spojené.
Analýza vlastností datasetu
V této části více prozkoumám dataset s cílem získat co nejvíce vhledu do toho jak v něm data vypadají a jaké jsou jeho nedokonalosti (chybející / špatné hodnoty).
Struktura datasetu
Takto vypadají řádky - zde je náhodný sample 5ti z nich:
Code
import pandas as pdfile_path ='benz-tr.csv'df = pd.read_csv(file_path)df.sample(5)
clid
posid
day
hour
amt
fav
fav_main
cl_gps_lat
cl_gps_lon
country
city
name
clst
pos_gps_lat
pos_gps_lon
dist
1490060
19892481
8059
2017-12-15
16.0
1128.98
1
1
49.677821
13.027792
cze
stribro
mol stribro 709
1.0
49.751507
12.998950
8.451807
544606
10330931
5693
2017-08-07
20.0
37.00
1
1
50.634210
15.489332
cze
jilemnice
gbo hrabacov
8.0
50.643860
15.197062
20.638822
1561060
18214895
8277
2017-07-11
10.0
97.00
1
1
49.978352
16.569073
cze
trutnov
benzina cs 0414
1.0
50.572090
15.893961
81.611036
250934
9864640
4908
2017-11-20
14.0
310.00
0
0
50.303806
12.561612
cze
chodov
benzina cs 0108
2.0
50.239868
12.754909
15.468209
957318
18569452
6904
2017-12-01
14.0
159.80
0
0
49.626580
18.073860
cze
opava jaktar
mol opava 008
7.0
49.952827
17.869177
39.139811
Podívejme se kolik různých klientů bylo sledováno a na kolika různách pumpách platili.
Bohužel nevím jak dataset vznikal a jak lépe interpretovat existenci těchto duplikátů. Je možné, že i některé nebudou vyloženě chybné duplikáty. Některé mohli vzniknout tak, že klient chvíli po první transakci na dané benzínce provedl další za stejnou sumu (například si klient vzpomněl, že i jeho žena chtěla koupit bagetu). Ovšem většina duplicitních transakcí obsahuje vyšší částky a tak určitě duplikáty z důvodu opakovaného nákupu nebudou tak časté a půjde spíše opravdu o dvojitý záznam zvniknuvší nějakou chybou.
Code
duplicate_rows.sample(5)
clid
posid
day
hour
amt
fav
fav_main
cl_gps_lat
cl_gps_lon
country
city
name
clst
pos_gps_lat
pos_gps_lon
dist
1735706
16370954
8786
2017-06-24
8.0
19.00
0
0
49.650314
18.022462
cze
zebrak
cerpaci stanice kapr
7.0
49.869896
13.894603
297.472603
1044559
15214318
7060
2018-01-12
17.0
86.00
1
1
49.829670
18.253827
cze
ostrava
omv 2119
11.0
49.804031
18.287262
3.725882
1801943
13379
28204
2017-09-09
12.0
44.01
0
0
49.837316
18.279926
pol
plock
orlen
NaN
NaN
NaN
NaN
1853114
16455167
42014
2018-01-09
15.0
1053.38
0
0
49.085408
14.719735
swe
vesteras
ingo vesteras stromle
NaN
NaN
NaN
NaN
1317998
18022577
7608
2017-11-16
6.0
84.00
0
0
50.050808
14.489218
cze
praha
omv 2129
13.0
50.014519
14.425369
6.089410
Nevím jestli do tohoto datasetu byly evidovány i transakce jež skončily chybou. Tato možnost mi dává smysl, protože značná část duplicit je ze zahraničí, kde spíše proběhne nějaký problém při platbě a je třeba ji opakovat. Lze si také povšimnout, že hodně duplicit má častku dělitelnou číslem 100. K tomuto se dostanu později.
Code
duplicate_rows_outside_cze = duplicate_rows[duplicate_rows["country"] !="cze"]total_amount_of_rows_outside_cze = df[df["country"] !="cze"].shape[0]duplicate_rows_divisible_by_100 = duplicate_rows[duplicate_rows["amt"] %100==0]total_amount_of_rows_divisible_by_100 = df[df["amt"] %100==0].shape[0]print(f"Duplicitních plateb, které proběhly mimo Českou Republiku je {duplicate_rows_outside_cze.shape[0] / duplicate_rows.shape[0] *100:.2f}%. Ovšem mimo ČR celkově v datasetu proběhlo jen {total_amount_of_rows_outside_cze / df.shape[0] *100:.2f}% plateb.")print(f"Duplicitních plateb, které jsou dělitelné číslem 100 je {duplicate_rows_divisible_by_100.shape[0] / duplicate_rows.shape[0] *100:.2f}%.")
Duplicitních plateb, které proběhly mimo Českou Republiku je 18.40%. Ovšem mimo ČR celkově v datasetu proběhlo jen 4.41% plateb.
Duplicitních plateb, které jsou dělitelné číslem 100 je 22.17%.
Chyby v datasetu
Bohužel jak tomu často bývá, i v tomto datasetu jsou chybějící údaje. Zde je tabulka v jakých sloupcích data chybí a kolik jich chybí:
Vidíme, že cl_gps_lat a lon chybí, tak jak by jeden očekával, vždy společně a to samé platí pro pos_gps_lat a lon. (Byť jich chybí stejně, tak toto není nutně důkaz, ale lze to ověřit i přes pandas.)
Podívejme se na korelace, kdy společne chybí hodnoty. Jelikož mi přišlo i zajimavé sledovat, zdalipak země transakce byla mimo ČR, tak jsem přidělal dummy sloupec, aby se to zahrnulo také do korelace.
Je vidět, že ujetá vzádelost chybí, když nevíme lokalitu bydliště člověka nebo benzínky, což je logické, protože pak není jak jí spočítat. Zařazení do clusteru není provedeno opět když nevíme bydliště či pozici benzínky. Tyto data chybí převážně, když země transakce není Česko. Ovšem další chybějící data vyžadují podrobnější analýzu kdy chybí a proč.
Podívejme se na to kdy chybí hodiny.
Code
missing_hours_cze = df[ (df['country'] =='cze') & df['hour'].isnull()].shape[0]missing_hours_not_cze = df[ (df['country'] !='cze') & df['hour'].isnull()].shape[0]total_missing_hours = df['hour'].isnull().sum()print(f"V ČR chybí {missing_hours_cze} hodinových údajů, mimo ČR chybí {missing_hours_not_cze} hodinových údajů. Celkem chybí {total_missing_hours} hodinových údajů.")print("Zde je tabulka s deseti nahodnými samply, kdy chybí hodinový údaj:")df[df['hour'].isnull()].sample(7)
V ČR chybí 2464 hodinových údajů, mimo ČR chybí 167 hodinových údajů. Celkem chybí 2631 hodinových údajů.
Zde je tabulka s deseti nahodnými samply, kdy chybí hodinový údaj:
clid
posid
day
hour
amt
fav
fav_main
cl_gps_lat
cl_gps_lon
country
city
name
clst
pos_gps_lat
pos_gps_lon
dist
1298881
14395982
7558
2017-02-08
NaN
1655.80
0
0
50.000017
14.560070
cze
pruhonice
union road cerpaci st
16.0
50.000007
14.559255
0.058276
1833516
18014522
37680
2017-02-21
NaN
399.97
0
0
50.030059
13.929977
cze
krivoklat
cerpaci stanice kri
NaN
50.037201
13.877518
3.830136
453743
15844343
5462
2017-02-28
NaN
300.00
1
0
50.184745
15.835921
cze
hradec kralove
benzina cs 0446
16.0
50.202755
15.840381
2.027638
1837499
15231755
37756
2017-03-07
NaN
500.00
1
1
49.849272
18.001223
cze
ostrava
globus tankautomat
16.0
49.846191
18.148991
10.600599
1833967
14329874
37694
2017-02-22
NaN
500.00
1
1
50.064019
16.551233
cze
letohrad
samoobsluzna cerpaci
16.0
50.031953
16.507928
4.719604
1834690
18131802
37703
2017-02-16
NaN
500.00
1
1
50.557408
14.032168
cze
litomerice
cargonet octomat
16.0
50.539491
14.129419
7.154374
1845219
15166461
37827
2017-02-07
NaN
282.60
0
0
49.333379
16.478677
cze
pribram
ps cng pribram
NaN
49.679956
13.999663
183.090660
Nejedná se tedy o chyby, jak jsem předpokládal, že například platba bude v jiné zemi a bude to kvůli rozdílnému časovému pásmu, či že zkrátka jde o obyčejné nahodné chyby, ale souvisí to převážně s divnými transakcemi, jež jsou hezká čísla (dělitelná stem). K těmto transakcím se dostanu později.
Code
missing_hour_df = df[df['hour'].isnull()]divisible_by_100 = missing_hour_df['amt'] %100==0divisible_by_100_count = divisible_by_100.sum()divisible_by_100_df = pd.DataFrame({'Celkem chybějících záznamů hodin': [missing_hour_df.shape[0]],'Počet řádků, kde chybí hodina a platba je dělitelná stem': [divisible_by_100_count]})divisible_by_100_df
Celkem chybějících záznamů hodin
Počet řádků, kde chybí hodina a platba je dělitelná stem
0
2631
1504
Code
print(f"Celkem tedy {divisible_by_100_count / missing_hour_df.shape[0] *100:.2f}% záznamů má částku dělitelnou stem, když chybí hodina.")
Celkem tedy 57.16% záznamů má částku dělitelnou stem, když chybí hodina.
Údaj o městu chybí na jedné beznínce na Slovensku a v Řecku. V obou případech jde o Shellku.
V datasetu chybí 10 záznamů o zemi. Zemi, ale lze určit z názvu města, kde se pumpa nachází - jedná se vždy o Kosovo (pouze částečně uznaný stát, možná proto tam není nic). Hodnoty tak doplním.
Je vidět, že většina lidí na beznínce nechá okolo 500ti korun a naprostá většina se vejde do dvou tisíc. Opět lze tady pozorovat jak moc jsou časté platby jako je 300, 500, 1000 kč.
Bohužel přimo z dat nejde vyčíst důvod k tomu proč je tolik plateb takto “hezkých”. Nemyslím si, že by se jednalo o dýška, jelikož při platbě kartou na beznínce jej opravdu minimum lidí bude dávat. Je možné (tohle považuji jako za dost pravděpodobné), že jde o platby u samoobslužných pump, kde člověk musí prvně dát kartu a limit do kolika chce tankovat. Tomu i nasvědčuje to, že eurooil poskytuje tyto automaty, globus také atd. Bohužel toto ovšem přímo nevysvětluje proč čas chybí často u těchto hodnot - možná je to tím, že platba je zadaná předem, vytvoří se blokace na kartě a strhne se platba až později po natankování - tam můžou být prodlevy a tak se neviduje správně hodina. To by i částečně vysvětlovalo, proč duplicity mají také často tyto částky - často se to pokazí a také se to může zaevidovat 2x (zaeviduje se blokace a i ztrhnutí).
Podívejme se nyní na to jak se platí v ruzných ročních obdobích.
V prvním grafu vidíme, že nejvyšší průměrné platby jsou dělány v Zimě. Důvodem může být to, že auta spotřebovávají více benzínu, kvůli nižší efektivitě motoru za nízkých teplot nebo to, že kvůli zimě tankují klienti raději méně často (také kvůli Vánocům a silvestru) a pak tedy i za více.
Druhý graf není tolik vypovídající, protože data jsou sbírána od února do konce února dalšího roku a spíše odráží to, že banka nabírala nové klienty a tak roste i celkový počet evidonavaných plateb.
Code
unique_clients_upto_date = df.groupby('day')['clid'].apply(lambda x: x.unique())unique_clients_set =set()cumulative_counts = []for date, clients in unique_clients_upto_date.items(): unique_clients_set.update(clients) cumulative_counts.append(len(unique_clients_set))cumulative_unique_clients_series = pd.Series(cumulative_counts, index=unique_clients_upto_date.index)plt.figure(figsize=(18, 9))cumulative_unique_clients_series.plot()plt.title('Kumulativní počet unikátních klientů co provedli tržbu do daného data', fontsize=16)plt.xlabel('Datum', fontsize=14)plt.ylabel('Kumulativní počet unikátních klientů', fontsize=14)plt.xticks(fontsize=14) plt.yticks(fontsize=12) plt.grid(True)plt.show()
Výše plateb v pozdních večerních hodinách je na beznínkách nižší - nejspíše je lidé užívají hodně jako “večerky”, ale jak ukazuje druhý graf, tak se jedná pouze o malou část lidí co pozdě večer takto nakupují. Peak lidí můžou beznínky očekávat okolo 14-18 hodiny, kde i výška plateb je nejvyšší.
df['day_of_month'] = df['day'].dt.daytransactions_by_day = df.groupby('day_of_month').size()avg_payment_by_day = df.groupby('day_of_month')['amt'].mean()plt.figure(figsize=(18, 7))plt.subplot(1, 2, 1)transactions_by_day.plot(kind='bar', color='skyblue')plt.title('Počet transakcí podle dne v měsíci', fontsize=15)plt.xlabel('Den v měsíci', fontsize=13)plt.ylabel('Počet transakcí', fontsize=13)plt.xticks(fontsize=12)plt.yticks(fontsize=12)plt.subplot(1, 2, 2)avg_payment_by_day.plot(kind='bar', color='lightgreen')plt.title('Průměrná výše platby podle dne v měsíci', fontsize=15)plt.xlabel('Den v měsíci', fontsize=13)plt.ylabel('Průměrná výše platby', fontsize=13)plt.xticks(fontsize=13)plt.yticks(fontsize=13)plt.tight_layout()plt.show()
Analýza klientů
Zde je korelační matice klientů - z ní se dá zjistit jak jedno chovnání může ovlivnit jiné.
Podívejme se nyní na to jak klienti různě platí a kolik navštívili různých stanic:
Code
grouped_df = df.groupby('clid').agg( number_of_payments=pd.NamedAgg(column='amt', aggfunc='size'), number_of_distinct_stations=pd.NamedAgg(column='posid', aggfunc='nunique'), average_amount=pd.NamedAgg(column='amt', aggfunc='mean'), average_hour=pd.NamedAgg(column='hour', aggfunc='mean'))plt.figure(figsize=(18, 30))plt.subplot(4, 1, 1)sns.histplot(grouped_df[grouped_df['number_of_payments'] <50]['number_of_payments'], kde=True, color='blue', bins=30) # Adjust the number of binsplt.title('Distribuce počtu plateb na klienta', fontsize=20)plt.xlabel('Počet plateb', fontsize=20)plt.ylabel('Počet klientů', fontsize=20)plt.xticks(fontsize=14) plt.yticks(fontsize=14) plt.subplot(4, 1, 2)sns.histplot(grouped_df[grouped_df['number_of_distinct_stations'] <30]['number_of_distinct_stations'], kde=True, color='green', bins=30) # Adjust the number of binsplt.title('Distribuce počtu navšívených různých stanic na klienta', fontsize=20)plt.xlabel('Počet stanic', fontsize=20)plt.ylabel('Počet klientů', fontsize=20)plt.xticks(fontsize=14) plt.yticks(fontsize=14) plt.subplot(4, 1, 3)sns.histplot(grouped_df[grouped_df['average_amount'] <2000]['average_amount'], kde=True, color='red', bins=100) # Adjust the number of binsplt.title('Distribuce průměrné výše platby na klienta', fontsize=20)plt.xlabel('Průměrná platba', fontsize=20)plt.ylabel('Počet klientů', fontsize=20)plt.xticks(fontsize=14) plt.yticks(fontsize=14)plt.subplot(4, 1, 4)sns.histplot(grouped_df['average_hour'], kde=True, color='brown', bins=24) # Adjust the number of binsplt.title('Distribuce průměrné hodiny platby', fontsize=20)plt.xlabel('Průměrný čas', fontsize=20)plt.ylabel('Počet klientů', fontsize=20)plt.xticks(fontsize=14) plt.yticks(fontsize=14) plt.tight_layout()plt.show()
K mému překvapení se opět ukazuje, že nejoblíbenější čas na tankování je ve 13-14 hodin, v čase kdy bych očekával, že většina lidí pracuje, je možné, že se jedná o řidiče kamionů, či jiné pracující lidi, jejichž práce zahrnuje ježdení autem a kteří nyní mají touto dobou pauzu na oběd, a tak i k tomu tankují.
Podívejme s v jakých městech klienti nejčastěji tankují a tedy asi také i bydlí
Code
from wordcloud import WordClouddf['city'] = df['city'].str.lower().str.strip()text =" ".join(city for city in df.city.dropna() if (" nad "notin city and" u "notin city and" pod "notin city))wordcloud = WordCloud(collocations=False, width=800, height=400, background_color='white').generate(text)plt.figure(figsize=(14, 7))plt.imshow(wordcloud, interpolation='bilinear')plt.axis("off")plt.show()
Cestují zakazníci v ruzných měsících jinak? To lze usoudit ze vzdálenosti mezi bydlištěm a místem platby. Nepřekvapivě peaky jsou v červenci a v srpnu, kdy rodiny často autem jedou do zahraničí na dovolenou. V lednu cestují lidé nejméně.
Code
df['month'] = pd.to_datetime(df['day']).dt.monthavg_distance_per_month_short = df.groupby('month')['dist'].mean()plt.figure(figsize=(16, 8))avg_distance_per_month_short.plot(kind='bar', color='orange', edgecolor='black', alpha=0.7)plt.title('Průměrná vzdálenost dle měsíce', fontsize=20)plt.xlabel('Měsíc', fontsize=16)plt.ylabel('Průměrná vzdálenost (km)',fontsize=16)plt.xticks(rotation=0)plt.grid(axis='y', linestyle='--', alpha=0.7)plt.show()
Analýza benzínek
V této části se obdovně jako v předchozí zaměřím na vzorce, které pro tentokrát vykazují benzínové pumpy.
df['name'] = df['name'].str.lower().str.strip()text =" ".join(name for name in df.name.dropna() if ("cerpaci"notin name and"cs"notin name and"stanice"notin name))wordcloud = WordCloud(collocations=False, width=800, height=400, background_color='white').generate(text)plt.figure(figsize=(14, 7))plt.imshow(wordcloud, interpolation='bilinear')plt.axis("off")plt.show()
Code
import numpy as nptotal_amt_per_station = df.groupby(['pos_gps_lat', 'pos_gps_lon'])['amt'].sum().reset_index()plt.figure(figsize=(14, 8))scatter = plt.scatter(total_amt_per_station['pos_gps_lon'], total_amt_per_station['pos_gps_lat'], c=np.log(total_amt_per_station['amt']), cmap='hot', s=40, alpha=0.7)plt.colorbar(scatter, label='Celkově utraceno na pumpách')plt.title('Stanice dle lokace a celkové částky utracené na nich', fontsize=16)plt.xlabel('Lon')plt.ylabel('Lat')plt.grid(True, which='both', linestyle='--', linewidth=0.5)plt.show()
Časové série
Časové série umožnují získat vhled pro dlouhodobé trendy, cyklické vzory či nám pomoci identifikovat outliers a pak i případně dělat předpovědi pro dalši období (to ale udělám až v explanatorní části).
Zde jsou časové série jak vypadala průměrná transakce během roku a kolik jich bylo uděláno.
Code
from scipy.signal import find_peaksdf['date'] = pd.to_datetime(df['day'])daily_totals = df.groupby(df['date'].dt.date)['amt'].sum()daily_average = df.groupby(df['date'].dt.date)['amt'].mean()daily_transactions = df.groupby(df['date'].dt.date).size()def annotate_peaks(series, ax, height=50, prominence=500): peaks, _ = find_peaks(series.values, height=height, prominence=prominence) peak_dates = series.iloc[peaks].indexfor date in peak_dates: ax.annotate(date.strftime('%m-%d'), (date, series[date]), textcoords="offset points", xytext=(0,10), ha='center', fontsize=12)def annotate_valleys(series, ax, height=50, prominence=500): inverted = series.max() - series valleys, _ = find_peaks(inverted.values, height=height, prominence=prominence) valley_dates = series.iloc[valleys].indexfor date in valley_dates: ax.annotate(date.strftime('%m-%d'), (date, series[date]), textcoords="offset points", xytext=(0,-15), ha='center', fontsize=12, color='blue')plt.figure(figsize=(18, 15))plt.subplot(2, 1, 1)daily_average.plot(kind='line', color='green')plt.title('Průměrná výše transakce v průběhu času', fontsize=15)plt.ylabel('Průměrná částka', fontsize=13)plt.xlabel('Datum', fontsize=13)plt.xticks(fontsize=14) plt.yticks(fontsize=14) annotate_peaks(daily_average, plt.gca(), height=10, prominence=50)annotate_valleys(daily_average, plt.gca(), height=10, prominence=50)plt.subplot(2, 1, 2)daily_transactions.plot(kind='line', color='red')plt.title('Počet transakcí v průběhu času', fontsize=15)plt.ylabel('Počet transakcí', fontsize=13)plt.xlabel('Datum', fontsize=13)plt.xticks(fontsize=14) plt.yticks(fontsize=14) annotate_peaks(daily_transactions, plt.gca(), height=500, prominence=500)annotate_valleys(daily_transactions, plt.gca(), height=2500, prominence=700)plt.tight_layout()plt.show()
V časové sérii počtu transakcí (červený graf) je vidět jasná periodicita peaků, kterou lze odečíst z datumů u nich - jde většinou o pátky, kdy lidé jedou někam na víkend (jen opakování již vyobrazeného grafu).
Interpretovat peaky v prvním (zeleném grafu) zobrazujícím průměrnou částku je složitější. Peaky odpovídají jinému ,již předtím viděnému grafu, jenž zobrazoval počet a výše plateb dle dnu v měsíci. Nejspíše lidé utratí nejvíce po výplatě, která touto dobou chodí. Graf mi ale také připadá mnohem více zašuměný a tak dělat nějaké jasné závěry člověk úplně asi nemůže (minimálně já si na to netroufám).
Poklesy na úplných koncích jsou způsobeny Vánoci a oslavou nového roku. Peak 13 dubna 2017 přisuzuji tomu, že se jednalo o zelený čtvrtek a lidé tankovali před Velikonoci.
Explanatorní analýza
Clustering
Podívejme se jestli klienti vytvářejí nějaké clustery a jak vypadá vůbec varieta jejich chování. Z toho můžeme případně vyčíst jaké jsou typy klientů a jak si je jejich chování podobné.
Pro každého klienta byl vytvořen vektor těchto featur:
celková útrata, průměrná útrata, počet navštívených unikátních stanic a také celkově, počet navštívených unikátních zemí a opět i celkově, průmerná uražená vzdálenost a celkově uražená vzdálenost, modus hodiny kdy tankoval
Jelikož je dimenze vektoru 9, nelze ho nromálně vyzualizovat a je třeba provést dimensionální redukci - pro to jsem zvolil algoritmus PaCMAP.
Předpokladem pro užití UMAPU a i PaCMAPu j, že body jsou na n-dimensionální Reimannovské varietě. Je otázka jak je tento předpoklad splněn (nikdy člověk tohohle ani nemá záruku). Předpokládám, že tady půjde spíše o pseudo-varietu, což ovšem moc nevadí - navíc UMAP i PaCMAP fungují dobře, i když je předpoklad porušen.
Code
umap_df = pd.DataFrame(umap_result, columns=['UMAP1', 'UMAP2'])umap_df['clid'] = final_df.indexfinal_umap_df = pd.merge(umap_df, final_df.reset_index(), on='clid')bins_avg_spent = [0, 50, 100, 200, 400, 700, 1100, 1300, np.inf]amount =30bins_total_spent = [int(1.5**i) for i inrange(10, amount)] + [np.inf]bins_total_spent_labels = [f'{bins_total_spent[i]+1} - {bins_total_spent[i+1]}'for i inrange(len(bins_total_spent)-1)]final_umap_df['avg_spent_binned'] = pd.cut(final_umap_df['avg_spent'], bins_avg_spent, labels=['1-50', '50-100', '100-200','200-400', '400-700', '700-1100', '1100-1300', '1300+'])final_umap_df['total_spent_binned'] = pd.cut(final_umap_df['total_spent'], bins_total_spent, labels=bins_total_spent_labels)# print(final_umap_df['avg_spent_binned'])fig, axs = plt.subplots(1, 2, figsize=(24, 10))sns.scatterplot(x='UMAP1', y='UMAP2', hue='avg_spent_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[0])axs[0].set_title('Colored by Average Amount Spent')axs[0].legend(title='Average Spent (CZK)')sns.scatterplot(x='UMAP1', y='UMAP2', hue='total_spent_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[1])axs[1].set_title('Colored by Total Amount Spent')axs[1].legend(title='Total Spent (CZK)')plt.show()
Code
amount =20amount_2 =7bins_unique_stations = [i*3for i inrange(amount)] + [np.inf] # Adjust bins based on your labelsbins_unique_stations_labels = [f"{bins_unique_stations[i]} - {bins_unique_stations[i+1]}"for i inrange(len(bins_unique_stations)-1)]bins_unique_countries = [i for i inrange(amount_2)] + [np.inf] # Adjust bins based on your labelsbins_unique_countries_labels = [f"{bins_unique_countries[i]} - {bins_unique_countries[i+1]}"for i inrange(len(bins_unique_countries)-1)]final_umap_df['unique_stations_binned'] = pd.cut(final_umap_df['unique_stations'], bins_unique_stations, labels=bins_unique_stations_labels)final_umap_df['unique_countries_binned'] = pd.cut(final_umap_df['unique_countries'], bins_unique_countries, labels=bins_unique_countries_labels)fig, axs = plt.subplots(1, 2, figsize=(24, 10))sns.scatterplot(x='UMAP1', y='UMAP2', hue='unique_stations_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[0])axs[0].set_title('Colored by Unique Stations Count')axs[0].legend(title='Unique Stations Count')sns.scatterplot(x='UMAP1', y='UMAP2', hue='unique_countries_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[1])axs[1].set_title('Colored by Unique Countries Count')axs[1].legend(title='Unique Countries Count')plt.show()
Vidíme, že “rozvětvení” variety je kvůli počtu navštívených zemí.
Code
amount =20bins_avg_distance = [int(1.3**i) for i inrange(4, amount+4)] + [np.inf]bins_avg_distance_labels = [f"{bins_avg_distance[i]} - {bins_avg_distance[i+1]}"for i inrange(len(bins_avg_distance)-1)]bins_total_distance = [int(1.5**i) for i inrange(2, amount+3)] + [np.inf]bins_total_distance_labels = [f"{bins_total_distance[i]} - {bins_total_distance[i+1]}"for i inrange(len(bins_total_distance)-1)]final_umap_df['avg_distance_binned'] = pd.cut(final_umap_df['avg_distance'], bins_avg_distance, labels=bins_avg_distance_labels)final_umap_df['total_distance_binned'] = pd.cut(final_umap_df['total_distance'], bins_total_distance, labels=bins_total_distance_labels)fig, axs = plt.subplots(1, 2, figsize=(24, 10))sns.scatterplot(x='UMAP1', y='UMAP2', hue='avg_distance_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[0])axs[0].set_title('Colored by Average Distance')axs[0].legend(title='Average Distance')sns.scatterplot(x='UMAP1', y='UMAP2', hue='total_distance_binned', data=final_umap_df, palette='viridis', legend="full", s =10, linewidth=0.1, alpha=0.7, ax=axs[1])axs[1].set_title('Colored by Total Distance')axs[1].legend(title='Total Distance')plt.show()
“Paprsky” na levé straně jsou způsobeny hodinami kdy převážně klient byl na pumpě (jen zde nezobrazuji pro to specificky barvený plot, protože není zvlášť zajímavý).
Je vidět z plotu, jak lidé co utratili nejvíce peněz jsou zároveň ti co převážně hodně cestují (navštívili hodně benzínek, také byli v zahraničí a urážejí větší vzdálenosti). Na toto již poukazovala korelační matice. Lze je také považovat za jakýsi “středobod” chování jak ukazuje varieta - to dává i smysl, jelikož chování ostaních skupin je podmnožina jejich chování, pač dělají vlastně všechno co jde (nakupují v cizích zemích, tankují na hodně benzínách, atd.)
Nevypadá to ale, že by existovali klienti s nějakým vyloženě nespecifickým chováním a vyráběli by tak nějaký zvlášť vyhraněný cluster. Je možné, že v realitě takový je, ale tyto data neobsahují dostatečně podrobné informace o chování klientů (další údaje k transakcím), aby se to dalo zjistit.
Hledat clustery - případně jejich počet jde pomocí počítaní takzvané gap statistiky.
Gap statistika zde také poukazuje na to, že neexistuje žádný zvlášť “dobrý” počet clusterů.
Tohle samé by pak šlo dělat pro benzínky, ale neočekávám, že bych našel něco zajimáveho a bylo by to podobné tomuto vzhledem k datové kvalitě. Například mi chybí vysoce relevatní údaje pro clustering jako jestli je beznínka ve městě, na vesnici nebo u dálnice - jaké nabízela ceny za litr v dobu čepování a další podobné. Nyní mám vlastně pouze kolik tam lidi utratili a kdy. Tohle by šlo doscrapovat z internetu (dle polohy a názvu), ale to by bylo mimo rozsah této práce, tak to pouze zmiňuji.
Predikce plateb
GBDT
Zde vyrobím model, který bude předpovídat, kolik celkově utratí člověk daný měsíc, pokud víme chování klienta v daném měsíci (kolik navštívil pump, v jakých zemích atd.), jen nevíme kolik utrácel.
Nejprve je třeba data opět transformovat a vyrobit si další featury.
Code
from sklearn.model_selection import train_test_splitfrom sklearn.ensemble import RandomForestRegressorfrom sklearn.ensemble import GradientBoostingRegressorfrom sklearn.metrics import mean_squared_errormonthly_payments = df.groupby(['clid', 'month']).agg(total_spent=pd.NamedAgg(column='amt', aggfunc='sum'), unique_stations=pd.NamedAgg(column='posid', aggfunc='nunique'), unique_countries=pd.NamedAgg(column='country', aggfunc='nunique'), total_stations_visited=pd.NamedAgg(column='posid', aggfunc='count'), total_countries_visited=pd.NamedAgg(column='country', aggfunc='count'), avg_distance=pd.NamedAgg(column='dist', aggfunc='mean'), total_distance=pd.NamedAgg(column='dist', aggfunc='sum'), mod_hour=pd.NamedAgg(column='hour', aggfunc=lambda x: x.mode()[0] ifnot x.mode().empty else np.nan), ).reset_index()pivot_df = monthly_payments.pivot_table(index='clid', columns='month', values='total_spent')pivot_df.columns = ['total_spent_'+str(col) for col in pivot_df.columns]pivot_df = pivot_df.reset_index()monthly_payments = pd.merge(monthly_payments, pivot_df, on='clid', how='left')total_spent_columns = [col for col in monthly_payments.columns if'total_spent_'in col]#avoid data leakagefor row in monthly_payments.itertuples():for col in total_spent_columns:if"total_spent_"+str(row.month) == col: monthly_payments.at[row.Index, col] = np.nanmonthly_payments['avg_spent_other_months'] = monthly_payments[total_spent_columns].apply(lambda row: row.mean(), axis=1)monthly_payments['total_spent_other_months'] = monthly_payments[total_spent_columns].apply(lambda row: row.sum(), axis=1)monthly_payments = pd.get_dummies(monthly_payments, columns=['mod_hour', "month"])
Takto vypadá dataframe, jenž bude užit pro trenování s targetem: kolik bylo utraceno daný měsíc. Nans byly nahrazeny hodnotou -1. Měsíce a hodiny byly onehot enkódovány. A pak byla data normalizována.
Jako model jsem zde zvolil Gradient Boosted Regression Decision Tree, protože se ukazuje jako nejlepší model pro tabulární data a v mém testování i přinesl ty nejelpší výsledky a porazil náhodné lesy či SVM.
Pro vyhodnocení modelu jsem zvolil Root Mean Absolute Error vzhledem k typu problému a dobré intepretovatelnosti. Poměr train a test setu jsem zvolil 80:20.
Každopádně bohužel model nefunguje příliš přesně a chyba je poměrně vysoká. Vidím za tím primárně dva důvody: Nedostatek featur - ono si můžu přidělat více featur (statistiky pro předchozí měsíce ale to je pak 90+ featur), ale můj počítač to trénoval už pak staršně dlouho (už tohle trvalo tako 2O minut a přece jenom tady jde o demonstraci úlohy…). Druhým důvodem je, že data jsou velice šuměná, což je primárně způsobeno náhodou.
Code
import pickleimport osimport lzmaif os.path.exists('gbm_client_pred_model.pkl'):with lzma.open('gbm_client_pred_model.pkl', 'rb') as f: model = pickle.load(f)else: model = GradientBoostingRegressor(n_estimators=100, max_depth=6, min_samples_split=2, learning_rate=0.1) model.fit(X_train, y_train)with lzma.open('gbm_client_pred_model.pkl', 'wb') as f: pickle.dump(model, f)predictions = model.predict(X_test)rmse = np.sqrt(mean_squared_error(y_test, predictions))print(f'RMSE: {rmse}')df_pred = pd.DataFrame({'Predictions': predictions, 'Actual': y_test})random_subset = df_pred.sample(n=10)random_subset
RMSE: 868.306379195834
Predictions
Actual
41270
1507.56
1564.25
89959
864.66
584.10
421462
1643.53
2000.20
188481
866.35
366.20
62584
649.09
378.39
220181
3631.11
4464.02
348865
2925.14
2831.45
588740
1413.34
2000
160310
1916.03
2900.10
356073
283.53
300
Překvapivě jedna z nejdůležitějších featur pro predikci je to kolikrát klient pumpoval v cizině.
Code
total_spent_weights =0mod_hour_weights =0month_weights =0for feature, importance inzip(features, model.feature_importances_):if"total_spent"in feature: total_spent_weights += importanceelif"mod_hour"in feature: mod_hour_weights += importanceelif"month"in feature: month_weights += importanceelse:print(f'{feature}: {importance:.4f}')print(f'Total spent weights for each month: {total_spent_weights:.4f}')print(f'Mod hour weights for each month: {mod_hour_weights:.4f}')print(f'Month weights for each month: {month_weights:.4f}')
unique_stations: 0.0178
unique_countries: 0.0243
total_stations_visited: 0.0846
total_countries_visited: 0.3407
avg_distance: 0.0027
total_distance: 0.0078
Total spent weights for each month: 0.0628
Mod hour weights for each month: 0.0056
Month weights for each month: 0.4536
Knn
O něco praktičtější je zkusit predikovat kolik daný klient utratí v daný měsíc, když víme jeho chování v ostatních měsícíh. Ovšem kdybych chtěl dělat model, který jen a pouze dostane na vstupu klienta a předpovídal bych kolik utratí v daný měsíc, tak výsledek bude ještě horší než u předchozího problému inherentně (protože uvidím ještě méně dat). Přesto ale model může být užitečný - zvolím přístup pomocí Knn a najdu lidi, kteří vykazují blízké chování danému klientovi a z jejich utráty pro daný měsíc předpovím to jak utrácel náš klient.
Code
from sklearn.neighbors import KNeighborsRegressormonthly_df = df.groupby(['clid', 'month']).agg(total_spent=pd.NamedAgg(column='amt', aggfunc='sum'), unique_stations=pd.NamedAgg(column='posid', aggfunc='nunique'), unique_countries=pd.NamedAgg(column='country', aggfunc='nunique'), total_stations_visited=pd.NamedAgg(column='posid', aggfunc='count'), total_countries_visited=pd.NamedAgg(column='country', aggfunc='count'), avg_distance=pd.NamedAgg(column='dist', aggfunc='mean'), total_distance=pd.NamedAgg(column='dist', aggfunc='sum'), mod_hour=pd.NamedAgg(column='hour', aggfunc=lambda x: x.mode()[0] ifnot x.mode().empty else np.nan), ).reset_index()# Pivot the DataFramepivot_df = monthly_df.pivot(index='clid', columns='month')pivot_df = pivot_df.reset_index()pivot_df.columns = ['_'.join(str(col) for col in col_tuple).strip() for col_tuple in pivot_df.columns.values]
Trénovací data jsou podobná těm předchozím, ale obsahují veškeré info z předchozích měsíců (tady si to můžu dovolit, protože to bude rychlé i tak) a vektor pro jednoho člověka je tedy 88 velký. Zde predikuji data pro červen (jsem si náhodně vybral tento měsíc).
Code
prediction_month ="6"# Change this to the month you want to predictscaler = StandardScaler()pivot_df.fillna(-1, inplace=True)# pivot_df = pivot_df[pivot_df['total_spent_'+prediction_month] != -1]pivot_df_scaled = pd.DataFrame(scaler.fit_transform(pivot_df), columns=pivot_df.columns)cols_to_include = [col for col in pivot_df.columns if prediction_month notin col and"total_spent"in col]# cols_to_include.remove('clid_')X = pivot_df_scaled.loc[:, cols_to_include]y = pivot_df['total_spent_'+prediction_month] # Target month spendingX_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)X_train
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
KNeighborsRegressor(n_neighbors=10)
Code
y_pred = knn.predict(X_test)mse = mean_squared_error(y_test, y_pred)print(f'Root Mean Squared Error: {np.sqrt(mse)}')
Root Mean Squared Error: 871.8034504118918
Překvapivě síla modelu je poměrně podobně kvalitná jako i GBDT. Důležité je ale poznamenat, že zde výsledky nejsou úplně srovnatelné, protože GBDT predikuje utrátu náhodně ze všech měsíců, zatímco tady jsem zvolil pouze červen, ale lze čekat, že výsledky by byly podobné i kdyby dělaly ty samé predikce.
Případný neduh tohoto modelu a i toho předchozího je ten, že rozumně nepředpoví Nan, tedy to když klient nic nenakoupil, ovšem reálně asi nejde o takový problém, protože ho budeme chtít využít pravě pro klienty, kde čekáme, že daný měsíc nějak něco utráceli.
Baseline
Dobré je si výsledky modelů srovnat s trivialním baseline modelem, který vždy předpoví průměrnou hodnotu.
Ukazuje se, že baseline má dokonce lepší výsledky než Knn. Doplnění hodnoty náhodného jevu (utracená částka za měsíc) pomocí průměru je opravdu konzistetní způsob - ostatně, je to nejvíce konzistetní odhad dle MLE. Ovšem zde jevy nejsou úplně nezavávislé a tedy MLE předpoklad je porušen - důvodem je například i to, že v některé měsíce lidé tankují za více, protože je zima, jak již ukázali grafy v exploratorní analýze, i tak ale je baseline model stejně výkonný jak mnou udělané zbylé výše.
Závěr
Dataset je plný informací pro zjištění a dokázání nějakých základních vzorců chování lidí na beznínových pumpách - například to, že v zimě se utrací na beznínce v průměru více než v jiné roční období, že lidé tankují nejvíce v pátek nebo to, že většina plateb je do pětiset korun a také to kdy lidé převážně tankují a zakolik. Téže bylo nalezeno možné spojení mezi samoobslužnými beznínkami a hezkými částkami a problémy v platbách (duplicity či chybějící hodiny) nebo důvod růstu či poklesu držeb v dané dny kvůli svátkům.
Pokročilé modely se ukázaly ne jako příliš praktické, protože lidé autem jezdí poměrně stejně každý měsíc a navíc je v datech šum například, že zaplatí na pumpě manželka a já pak vidím pokles v tom kolik utratil klient daný měsíc, protože si tohle nemám jak propojit. Proto se také odhad pomocí průměru ukázal jako dobrý prediktor kolik člověk utratí/l daný měsíc.
Bohužel v datasetu chybí zajímavé a podstatné informace, které by mohli beznínovým pumpám napomoci k optimalizaci chování a tím zvýšení zisku. Například to jaká byla cena benzínu za litr v době tankování, jaká je jeho lokolita (dálnice, město…). Také chybí informace o tom, jaké služby beznínky nabízí (například samoobslužné pumpy, či jiné) a jaké položky byly nakoupeny v transakci. Něco z tohohle by šlo samozřejmě doplnit z internetu, ale to by bylo mimo rozsah této práce jak jsem již avizoval. Myslím, ale že by tato práce mohla sloužit jako dobrý základ pro další analýzy, které by se mohly zaměřit na další aspekty.