- MC Angewandtes Machine Learning -
Frühlingssemester 2025 -
Autor: Nabil Mikhael | Alessandro Gregori - Import Libraries
- Einführung
- Datenaufbereitung
- Modellkonstruktion
- Feature Engineering
- Modellentwicklung
- Modellvergleich, -selektion und -optimierung
- Modellerklärung und -reduktion
- Anhang
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import matplotlib.pylab as plt
import plotly.express as px
plt.style.use('ggplot')
from datetime import timedelta
import glob
import os
# Dezimalstellen auf 2 stellen für pandas DataFrames
pd.set_option('display.float_format', '{:.2f}'.format)
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import matplotlib.dates as mdates
from matplotlib.ticker import FuncFormatter
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix, RocCurveDisplay
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
from statsmodels.stats.outliers_influence import variance_inflation_factor
from sklearn.ensemble import RandomForestClassifier
from imblearn.ensemble import BalancedRandomForestClassifier
from xgboost import XGBClassifier
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.preprocessing import FunctionTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import RandomizedSearchCV
from sklearn.metrics import (classification_report, roc_auc_score, confusion_matrix, RocCurveDisplay)
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
from scipy.stats import randint
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import precision_score, recall_score, f1_score
import pandas as pd
from sklearn.inspection import permutation_importance
from sklearn.inspection import PartialDependenceDisplay
import matplotlib.ticker as mtick
from sklearn.metrics import precision_recall_curve, roc_curve, auc
from sklearn.model_selection import StratifiedKFold, cross_val_predict
import plotly.io as pio
pio.renderers.default = "notebook_connected"
import warnings
# Unterdrückt alle zukünftigen Warnungen (z. B. FutureWarning)
warnings.simplefilter(action='ignore', category=FutureWarning)
Ziel ist es Kundenlisten für eine personalisierte Kreditkarten-Werbekampagne zu erzeugen, wobei keine Junior-Karten angeboten werden sollen.
https://sorry.vse.cz/~berka/challenge/PAST/index.html (Beschreibung der Daten)
In diesem Abschnitt geht es um das Einlesen, Bereinigung und erste Analyse der Daten, um die Qualität und Struktur der Datensätze zu überprüfen. Der Ablauf erfolgt in zwei Hauptschritten:
Laden & Bereinigen der Daten:
- Laden des Datensatzes
- Duplikate, fehlende und fehlerhafte Werte werden identifiziert und behandelt.
- Datentypen werden überprüft und gegebenenfalls angepasst, um sicherzustellen, dass die Daten korrekt vorliegen.
- Primärschlüssel werden geprüft, um die Eindeutigkeit und Integrität der Datensätze sicherzustellen.
Explorative Analyse:
- Deskriptive Statistiken und Visualisierungen helfen, grundlegende Muster, Verteilungen und Ausreisser in den Daten zu erkennen.
- Erste Einblicke in die Datenstruktur ermöglichen die Identifikation potenziell relevanter Merkmale.
Am Ende jedes Unterkapitels fassen wir die gewonnenen Erkenntnisse zusammen und formulieren erste offene Fragen oder Handlungsempfehlungen für den weiteren Verlauf.
Unterscheidung statische und dynamische Entitäten:
Die Datensätze lassen sich in statische und dynamische Entitäten unterteilen. Diese Differenzierung bildet die Grundlage für die gezielte Aggregation und Ableitung von Merkmalen im Rollup-Fenster und ermöglicht eine strukturierte Analyse sowohl langfristiger als auch zeitabhängiger Informationen.
Statische Entitäten: Client, Disposition, Account, Credit Card, Districts Diese Daten sind weitgehend unveränderlich und werden nur bei spezifischen Ereignissen – etwa einem Umzug oder einer Änderung der Kontodaten – aktualisiert. Sie umfassen langfristig stabile Informationen wie Kundendaten, Kontoinformationen und Kreditkartendetails. Dadurch eignen sich statische Entitäten besonders für die Modellierung konstanter Merkmale eines Kunden, z. B. Alter, Geschlecht, Region oder Kartentyp.
Dynamische Entitäten: Transaction, Permanent Order, Loan Dynamische Entitäten erzeugen fortlaufend neue Datenpunkte und bilden die Grundlage für zeitabhängige Merkmale (z. B. Kontostände, Aktivitätsmuster).
Bei den dynamischen Entitäten führen wir zwar eine explorative Analyse durch, jedoch erfolgt an dieser Stelle noch keine gezielte Formulierung von Fragestellungen. Diese ergeben sich erst im Rahmen der Aggregation im Rollup-Fenster und werden im Kapitel 4 Feature Engineering detaillierter behandelt. Dadurch bleibt die Analyse der dynamischen Entitäten zunächst beschreibend und wird erst später in den Kontext der Modellbildung gesetzt.
Laden und Bereinigen der Daten
# Einlesen der Kreditkartendaten mit spezifizierten Datentypen:
# 'card_id' und 'disp_id' als Integer, 'type' als kategorische Variable,
# sowie 'issued' als Datum im Format "%y%m%d %H:%M:%S"
credit_card_df = pd.read_csv("xselling_banking_data/card.csv", delimiter=";", dtype={
"card_id": "int64",
"disp_id": "int64",
"type": "category"},
parse_dates=["issued"],
date_parser=lambda x: pd.to_datetime(x, format="%y%m%d %H:%M:%S"))
credit_card_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 892 entries, 0 to 891 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 card_id 892 non-null int64 1 disp_id 892 non-null int64 2 type 892 non-null category 3 issued 892 non-null datetime64[ns] dtypes: category(1), datetime64[ns](1), int64(2) memory usage: 22.0 KB
Die Spaltentypen sehen korrekt aus, wie sie eingelesen wurden.
# Pandas zeigt in head() keine Uhrzeit an, wenn sie 00:00:00 ist – Anzeigeoptimierung
credit_card_df.head()
card_id | disp_id | type | issued | |
---|---|---|---|---|
0 | 1005 | 9285 | classic | 1993-11-07 |
1 | 104 | 588 | classic | 1994-01-19 |
2 | 747 | 4915 | classic | 1994-02-05 |
3 | 70 | 439 | classic | 1994-02-08 |
4 | 577 | 3687 | classic | 1994-02-15 |
# Prüfen der Nullwerte
credit_card_df.isna().sum()
card_id 0 disp_id 0 type 0 issued 0 dtype: int64
Der Datensatz enthält keine Nullwerte
credit_card_df.describe(include="all")
card_id | disp_id | type | issued | |
---|---|---|---|---|
count | 892.00 | 892.00 | 892 | 892 |
unique | NaN | NaN | 3 | NaN |
top | NaN | NaN | classic | NaN |
freq | NaN | NaN | 659 | NaN |
mean | 480.86 | 3511.86 | NaN | 1997-09-19 05:19:38.475336320 |
min | 1.00 | 9.00 | NaN | 1993-11-07 00:00:00 |
25% | 229.75 | 1387.00 | NaN | 1997-01-25 12:00:00 |
50% | 456.50 | 2938.50 | NaN | 1998-01-06 00:00:00 |
75% | 684.25 | 4459.50 | NaN | 1998-08-05 06:00:00 |
max | 1247.00 | 13660.00 | NaN | 1998-12-29 00:00:00 |
std | 306.93 | 2984.37 | NaN | NaN |
credit_card_df.nunique()
card_id 892 disp_id 892 type 3 issued 607 dtype: int64
credit_card_df['disp_id'].is_unique and credit_card_df['card_id'].is_unique
True
- card_id ist innerhalb von credit_card_df eindeutig und eignet sich daher als Primärschlüssel der Tabelle.
- disp_id ist ebenfalls eindeutig in credit_card_df und stellt einen Fremdschlüssel zur Tabelle disposition_df dar.
- Zwischen card_id und disp_id besteht eine 1:1-Beziehung: Jeder Dispositionseintrag (disp_id) erhält maximal eine Karte, und jede Karte ist genau einem Nutzer (Dispositionseintrag) zugeordnet.
EDA
# Berechnung der Anzahl und Prozentanteile der verschiedenen Kreditkartentypen
type_counts = credit_card_df["type"].value_counts().reset_index()
type_counts.columns = ['type', 'count']
# Prozentuale Anteile der Kreditkartentypen berechnen und auf 1 Dezimalstelle runden
type_counts['percent'] = ((type_counts['count'] / type_counts['count'].sum()) * 100).round(1)
# Balkendiagramm zur Visualisierung der Verteilung der Kreditkartentypen
fig = px.bar(
type_counts,
x='type',
y='percent',
text_auto='.1f',
color='type',
hover_data=['count'],
title='Distribution of Credit Cards by Type',
labels={'type': 'Type', 'percent': 'Percentage (%)'}
)
# Textbeschriftungen ausserhalb der Balken anzeigen
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Classic-Karten machen 73.9 % aus, gefolgt von Junior- (16.3 %) und Gold-Karten (9.9 %). Für die geplante Werbekampagne sollen keine Junior-Karten angeboten werden. Da jedoch noch unklar ist, bis zu welchem Alter eine Karte als "Junior" gilt, werden sie erst nach dem Mergen der DataFrames entfernt.
# Berechnung der Anzahl ausgegebener Kreditkarten pro Jahr inklusive prozentualer Anteile
yearly_counts = credit_card_df['issued'].dt.year.value_counts().sort_index().reset_index()
yearly_counts.columns = ['year', 'count']
yearly_counts['percent'] = (yearly_counts['count'] / yearly_counts['count'].sum() * 100).round(1)
# Balkendiagramm zur Darstellung des prozentualen Anteils der ausgegebenen Karten pro Jahr
fig = px.bar(
yearly_counts,
x='year',
y='percent',
text_auto=True,
title="Percentage of Cards Issued per Year"
)
fig.update_traces(textfont_size=12, textangle=0, textposition="outside", cliponaxis=False)
fig.update_layout(yaxis_title='Percent (%)', xaxis_title='Year')
fig.show()
Analyse der Kartenausgaben pro Jahr (in Prozent)
- Ziel: Überprüfen, ob die zeitliche Verteilung realistisch und plausibel ist
- Ergebnis: Kontinuierlicher Anstieg – Grossteil der Karten wurde 1997–1998 ausgestellt
#Zeitliche Reihenfolge vs. ID-Reihenfolge
#Ist card_id in etwa mit issued korreliert?
credit_card_df[['card_id', 'issued']].sort_values('issued').head(10)
card_id | issued | |
---|---|---|
0 | 1005 | 1993-11-07 |
1 | 104 | 1994-01-19 |
2 | 747 | 1994-02-05 |
3 | 70 | 1994-02-08 |
4 | 577 | 1994-02-15 |
5 | 377 | 1994-03-03 |
6 | 721 | 1994-04-05 |
7 | 437 | 1994-06-01 |
8 | 188 | 1994-06-19 |
9 | 13 | 1994-06-29 |
Zeitliche Reihenfolge vs. ID-Reihenfolge
- Überprüfung, ob card_id chronologisch mit dem Ausgabedatum vergeben wurde
- Ergebnis: IDs sind nicht strikt aufsteigend zur Zeit → keine zeitliche Logik in der Vergabe
credit_card_df['issued'].dt.month.value_counts(normalize=True).mul(100).round(1)
issued 12 10.90 11 10.70 10 10.40 9 10.10 7 9.20 8 8.90 6 8.30 5 7.60 1 7.20 4 6.10 3 5.50 2 5.30 Name: proportion, dtype: float64
Die Analyse der Kreditkartenausgabe über alle Monate hinweg zeigt eine deutliche Saisonalität: Die meisten Karten werden in den Monaten Oktober bis Dezember ausgestellt, mit einem Höhepunkt im Dezember (10.9 %).
Die schwächsten Monate sind Februar (5.3 %) und März (5.5 %). Diese Verteilung deutet auf ein saisonales Kundeninteresse zum Jahresende hin – möglicherweise bedingt durch bevorstehende Feiertage, vermehrte Ausgaben oder Marketingaktionen in dieser Zeit.
Fazit und nächste Schritte
Schlüsselstruktur und Datenmodell
card_id
ist der Primärschlüssel der Tabellecredit_card
.disp_id
ist ein Fremdschlüssel zur Tabelledisposition
.- Es liegt eine 1:1-Beziehung vor:
Jede Karte ist genau einem Dispositionseintrag (disp_id
) zugeordnet, und jeder Dispositionseintrag erhält höchstens eine Karte.
Zusammenfassung der EDA-Erkenntnisse
- Der Kartentyp classic dominiert mit einem Anteil von 74 %.
- Kein erkennbarer linearer Zusammenhang zwischen
card_id
und Ausstellungsdatum. - Die Mehrheit der Karten wurde in den Jahren 1997–1998 ausgestellt (77 %).
- Kontinuierlicher Anstieg der Kartenausstellungen von 1993 bis 1998.
- Saisonalität: Hohe Ausstellungszahlen zwischen Oktober und Dezember, niedrige zwischen Februar und März.
- Es konnte kein zeitlicher Zusammenhang zwischen
card_id
und dem Ausstellungsdatum festgestellt werden, weshalbcard_id
nicht als zeitliches Merkmal interpretiert werden sollte. - Der Kartentyp ist ein potenziell nützliches Merkmal, allerdings müssen Junior-Karten ausgeschlossen werden, da sie nicht zur Zielgruppe der Werbekampagne zählen.
Offene Punkte und nächste Schritte
- Überprüfen, ob ausschliesslich
owner
-Einträge (nichtDisponent
) in derdisposition
-Tabelle eine Kreditkarte besitzen. Dies stellt sicher, dass nur tatsächlich entscheidungsbefugte Personen (Kontoinhaber:innen) in die Modellierungsbasis einbezogen werden. - Filtern von Junior-Karten: Altersbasierter Ausschluss von Kreditkarten, die nicht zur Zielgruppe der Kampagne gehören.
- Temporales Feature Engineering: Bewertung, ob der Ausstellungsmonat saisonale Muster zeigt und als erklärendes Merkmal für das Modell genutzt werden kann.
Laden und Bereinigen der Daten
# Einlesen der Dispositionsdaten mit spezifizierten Datentypen für IDs und Kategorie
disposition_df = pd.read_csv("xselling_banking_data/disp.csv", delimiter=";", dtype={
"client_id": "int64",
"account_id": "int64",
"disp_id": "int64",
"type": "category"})
disposition_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 5369 entries, 0 to 5368 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 disp_id 5369 non-null int64 1 client_id 5369 non-null int64 2 account_id 5369 non-null int64 3 type 5369 non-null category dtypes: category(1), int64(3) memory usage: 131.3 KB
Die Spaltentypen sehen korrekt aus, wie sie eingelesen wurden.
disposition_df.head()
disp_id | client_id | account_id | type | |
---|---|---|---|---|
0 | 1 | 1 | 1 | OWNER |
1 | 2 | 2 | 2 | OWNER |
2 | 3 | 3 | 2 | DISPONENT |
3 | 4 | 4 | 3 | OWNER |
4 | 5 | 5 | 3 | DISPONENT |
# Prüfen der Nullwerte
disposition_df.isna().sum()
disp_id 0 client_id 0 account_id 0 type 0 dtype: int64
Der Datensatz enthält keine Nullwerte
disposition_df.nunique()
disp_id 5369 client_id 5369 account_id 4500 type 2 dtype: int64
disposition_df['disp_id'].is_unique and disposition_df['client_id'].is_unique
True
- Die Spalte disp_id ist eindeutig und dient als Primärschlüssel.
- In der aktuellen Datenlage kommt jede client_id nur einmal vor
- Die Spalte account_id ist nicht eindeutig:
- Ein Konto kann mehreren Personen zugeordnet sein ( OWNER +USER).
EDA
disposition_df.describe(include="all")
disp_id | client_id | account_id | type | |
---|---|---|---|---|
count | 5369.00 | 5369.00 | 5369.00 | 5369 |
unique | NaN | NaN | NaN | 2 |
top | NaN | NaN | NaN | OWNER |
freq | NaN | NaN | NaN | 4500 |
mean | 3337.10 | 3359.01 | 2767.50 | NaN |
std | 2770.42 | 2832.91 | 2307.84 | NaN |
min | 1.00 | 1.00 | 1.00 | NaN |
25% | 1418.00 | 1418.00 | 1178.00 | NaN |
50% | 2839.00 | 2839.00 | 2349.00 | NaN |
75% | 4257.00 | 4257.00 | 3526.00 | NaN |
max | 13690.00 | 13998.00 | 11382.00 | NaN |
# Berechnung der Anzahl und prozentualen Anteile der verschiedenen Dispositionstypen (Kontozugriffsrollen)
type_counts_disp = disposition_df['type'].value_counts().reset_index(name='count')
type_counts_disp.columns = ['type', 'count']
type_counts_disp['percent'] = (type_counts_disp['count'] / type_counts_disp['count'].sum() * 100).round(2)
# Balkendiagramm zur Visualisierung der Verteilung der Kontozugriffsrollen
fig = px.bar(
type_counts_disp,
x='type',
y='percent',
text_auto=True,
color='type',
hover_data={"count": True}
)
fig.update_traces(
textfont_size=12,
textangle=0,
textposition="outside",
cliponaxis=False
)
fig.update_layout(
title="Distribution of Account Access Roles",
xaxis_title="Role",
yaxis_title="Percentage (%)"
)
fig.show()
- OWNER ist deutlich häufiger als DISPONENT vorhanden
# Wie viele disposition-Einträge hat jede client_id?
client_counts = disposition_df['client_id'].value_counts()
client_counts
# Zeige alle client_ids, die mehr als 1 Eintrag haben:
client_counts[client_counts > 1]
Series([], Name: count, dtype: int64)
- Es liegt eine 1:1-Beziehung zwischen client_id und disp_id vor.
- Jede Kundin bzw. jeder Kunde nur einem Konto zugeordnet.
- Mehrfachrollen kommen nicht vor
Fazit und nächste Schritte
Schlüsselstruktur und Datenmodell
disp_id
ist der Primärschlüssel der Tabelledisposition
.client_id
ist ein Fremdschlüssel zur Tabelleclient
.account_id
ist ein Fremdschlüssel zur Tabelleaccount
.
Die Analyse zeigt, dass jede client_id
im Datensatz nur einmal vorkommt.
→ Es liegt somit eine 1:1-Beziehung zwischen Kund:in und Dispositionseintrag vor:
- Jede Person ist aktuell genau einem Konto zugeordnet.
- Und besitzt genau eine Rolle – entweder
owner
oderdisponent
.
Diese 1:1-Zuordnung vereinfacht die Analyse, ist jedoch nicht zwingend durch das Datenmodell vorgegeben und sollte nach dem Zusammenführen mit anderen Tabellen erneut überprüft werden.
Im Gegensatz dazu ist account_id
nicht eindeutig – ein Konto kann mehreren Personen zugeordnet sein ( ein owner
und ein disponent
). → Es liegt vermutlich eine 1:n-Beziehung zwischen account_id
und disp_id
vor.
Dies stellt keinen Widerspruch zur 1:1-Beziehung zwischen client_id
und disp_id
dar, sondern ist eine typische 1:n-Beziehung auf Kontoebene.
Für spätere Analysen (z. B. Zuordnung von Kreditkarten oder Zahlungsverhalten) ist diese Struktur relevant, da sie Auswirkungen auf die Aggregation und Filterung der Daten haben kann.
Zusammenfassung der EDA-Erkenntnisse
- Der Grossteil der Einträge (ca. 84 %) trägt den Typ
owner
, der Restdisponent
. - Es existieren zwei Rollen, über die Kund:innen einem Konto zugeordnet sein können:
- owner → Kontoinhaber:in mit Entscheidungsbefugnis
- disponent → Mitnutzer:in ohne volle Rechte
Offene Punkte und nächste Schritte
Eindeutigkeit von
client_id
nach Datenfusion prüfen:
Aktuell ist jedeclient_id
eindeutig. Es ist zu prüfen, ob dies auch nach dem Join mit weiteren Tabellen (z. B.card
,account
,loan
) erhalten bleibt.Validierung der Rolle im Kontext der Modellierung:
Sollte sich bestätigen, dass nurowner
-Einträge eine Kreditkarte besitzen, könnendisponent
-Einträge ausgeschlossen werden. Ansonsten ist zu klären, ob deren Verhalten separat analysiert werden sollte.Prüfen, ob es pro
account_id
mehreredisp_id
-Einträge mit verschiedenen Rollen gibt:
→ Das ist besonders wichtig, um zu verstehen, ob ein Konto gemeinsam genutzt wird (z. B.owner
+disponent
). In diesem Fall müssten Features pro Konto aggregiert oder differenziert behandelt werden.Vorbereitung eines binären Features zur Rolle (
is_owner
)
→ Dieses kann beim späteren Modelltraining nützlich sein, um zwischen Haupt- und Nebenrollen zu unterscheiden.
Laden und Bereinigen der Daten
# Einlesen der Kontodaten mit spezifizierten Datentypen für IDs und Frequenz
# sowie Parsing der Spalte 'date' als Datum im Format "%y%m%d"
accounts_df = pd.read_csv("xselling_banking_data/account.csv", delimiter=";", dtype={
"account_id": "int64",
"district_id": "int64",
"frequency": "category"},
parse_dates=["date"], date_format="%y%m%d")
accounts_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4500 entries, 0 to 4499 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 account_id 4500 non-null int64 1 district_id 4500 non-null int64 2 frequency 4500 non-null category 3 date 4500 non-null datetime64[ns] dtypes: category(1), datetime64[ns](1), int64(2) memory usage: 110.0 KB
Die Spaltentypen sehen korrekt aus, wie sie eingelesen wurden.
accounts_df.head()
account_id | district_id | frequency | date | |
---|---|---|---|---|
0 | 576 | 55 | POPLATEK MESICNE | 1993-01-01 |
1 | 3818 | 74 | POPLATEK MESICNE | 1993-01-01 |
2 | 704 | 55 | POPLATEK MESICNE | 1993-01-01 |
3 | 2378 | 16 | POPLATEK MESICNE | 1993-01-01 |
4 | 2632 | 24 | POPLATEK MESICNE | 1993-01-02 |
accounts_df.isna().sum()
account_id 0 district_id 0 frequency 0 date 0 dtype: int64
Der Datensatz enthält keine Nullwerte
Aus der Datensatzbeschreibung geht hervor, dass es drei Typen für die Häufigkeit der Kontoauszüge gibt:
- POPLATEK MESICNE = monthly_issuance
- POPLATEK TYDNE = weekly_issuance"
- POPLATEK PO OBRATU = issuance_after_ transaction
Die tschechischen Bezeichnungen in den Spalten werden ins Englische übersetzt, um eine einheitliche und konsistente Benennung innerhalb der Analyse sicherzustellen.
accounts_df['frequency'] = accounts_df['frequency'].map({
"POPLATEK MESICNE": "monthly_issuance",
"POPLATEK TYDNE": "weekly_issuance",
"POPLATEK PO OBRATU": "issuance_after_transaction"
})
accounts_df["frequency"].unique()
['monthly_issuance', 'issuance_after_transaction', 'weekly_issuance'] Categories (3, object): ['monthly_issuance', 'issuance_after_transaction', 'weekly_issuance']
EDA
accounts_df.describe(include="all")
account_id | district_id | frequency | date | |
---|---|---|---|---|
count | 4500.00 | 4500.00 | 4500 | 4500 |
unique | NaN | NaN | 3 | NaN |
top | NaN | NaN | monthly_issuance | NaN |
freq | NaN | NaN | 4167 | NaN |
mean | 2786.07 | 37.31 | NaN | 1995-08-08 01:38:52.800000 |
min | 1.00 | 1.00 | NaN | 1993-01-01 00:00:00 |
25% | 1182.75 | 13.00 | NaN | 1993-12-27 00:00:00 |
50% | 2368.00 | 38.00 | NaN | 1996-01-02 00:00:00 |
75% | 3552.25 | 60.00 | NaN | 1996-11-01 00:00:00 |
max | 11382.00 | 77.00 | NaN | 1997-12-29 00:00:00 |
std | 2313.81 | 25.18 | NaN | NaN |
Ergebnis aus describe()):
- Es gibt 4'500 Konten (account_id), verteilt auf 77 Distrikte (district_id)
- Die Spalte 'frequency' enthält 3 Werte → häufigster: 'POPLATEK MESICNE' (monatlich, 4167×)
- Das Kontoeröffnungsdatum ('date') reicht von 01.01.1993 bis 29.12.1997
accounts_df['account_id'].is_unique
True
- account_id ist eindeutig und eignet sich daher als Primärschlüssel der Tabelle.
accounts_df['district_id'].value_counts(normalize=True).mul(100).round(1)
district_id 1 12.30 70 3.40 74 3.00 54 2.80 64 2.00 ... 37 0.80 13 0.80 20 0.80 22 0.80 58 0.70 Name: proportion, Length: 77, dtype: float64
Die Analyse zeigt, dass die Konten auf insgesamt 77 Distrikte verteilt sind. Die Verteilung ist dabei deutlich ungleichmässig:
- Der Distrikt mit der ID 1 enthält 12.3 % aller Konten und ist damit mit Abstand am stärksten vertreten.
- Die Mehrheit der übrigen Distrikte liegt jeweils unter 4 % Anteil, viele sogar unter 1 %.
Dies deutet auf eine konzentrierte Kundenbasis in wenigen Regionen hin .
accounts_df['frequency'].value_counts(normalize=True).mul(100).round(1)
frequency monthly_issuance 92.60 weekly_issuance 5.30 issuance_after_transaction 2.10 Name: proportion, dtype: float64
# Berechnung der Anzahl und prozentualen Anteile der verschiedenen Frequenzwerte in den Kontodaten
type_counts_account = accounts_df['frequency'].value_counts().reset_index(name='count')
type_counts_account.columns = ['frequency', 'count']
type_counts_account['percent'] = (type_counts_account['count'] / type_counts_account['count'].sum() * 100).round(2)
# Balkendiagramm zur Darstellung der Verteilung der Frequenzen
fig = px.bar(
type_counts_account,
x='frequency',
y='percent',
text_auto=True,
color='frequency',
hover_data={"count": True}
)
fig.update_traces(
textfont_size=12,
textangle=0,
textposition="outside",
cliponaxis=False
)
fig.update_layout(
title="Distribution of Frequencies",
xaxis_title="Frequency",
yaxis_title="Percentage (%)"
)
fig.show()
Die Auswertung zeigt deutlich, dass monatliche Ausszüge dominieren.
accounts_df[['account_id', 'date']].sort_values('date').head(10)
account_id | date | |
---|---|---|
0 | 576 | 1993-01-01 |
1 | 3818 | 1993-01-01 |
2 | 704 | 1993-01-01 |
3 | 2378 | 1993-01-01 |
4 | 2632 | 1993-01-02 |
5 | 1972 | 1993-01-02 |
10 | 1726 | 1993-01-03 |
9 | 1695 | 1993-01-03 |
8 | 2484 | 1993-01-03 |
7 | 793 | 1993-01-03 |
Zeitliche Reihenfolge vs. ID-Reihenfolge
- Überprüfung, ob account_id chronologisch mit dem Ausgabedatum vergeben wurde
- Ergebnis: IDs sind nicht strikt aufsteigend zur Zeit → keine zeitliche Logik in der Vergabe
Fazit und nächste Schritte
Entitäten
account_id
ist der Primärschlüssel der Tabelleaccount
.district_id
ist ein Fremdschlüssel zur Tabelledistrict
.
Zusammenfassung der EDA-Erkenntnisse
- Die Kontofrequenz (Ausstellung der Kontoauszüge) ist bei über 93 % der Konten als
monthly_issuance
klassifiziert. - Der Distrikt mit
district_id = 1
weist mit 12.3 % aller Konten die höchste Kontendichte auf. - Es besteht kein linearer Zusammenhang zwischen
account_id
und dem Eröffnungsdatum (date
), d. h. die ID ist nicht zeitlich sortiert. - Die Distriktzugehörigkeit (über
district_id
) könnte relevant sein, weil bestimmte Distrikte mehr Konten aufweisen und ggf. andere Kauf- oder Kreditkartenmuster zeigen.
Offene Punkte und nächste Schritte
- Regionale Differenzierung: Prüfen, ob es Distrikte gibt, die signifikant mehr (oder weniger) Kreditkarten besitzen, um sie gezielt zu analysieren
- Nutzungsfrequenz-Analyse: Untersuchen, ob bestimmte Frequenzgruppen (
weekly
,yearly
) tatsächlich seltener Karten haben (oder ob kein Zusammenhang besteht). - Verknüpfung mit anderen Tabellen
Zusammenführen der Kontodaten (account_df
) mitdistrict_df
(z. B. soziodemografische Merkmale) und ggf.card_df
, um das Zusammenspiel von Distrikt, Frequenz und Kartenbesitz zu analysieren.
Laden und Bereinigen der Daten
- Während des Einlesens der Daten wurde festgestellt, dass in gewissen Spalten ein "?" als Wert hinterlegt ist. Dies wurde beim Einlesen entsprechend berücksichtigt und behandelt.
- Da die Spaltenbezeichnungen nicht aussagend sind, werden diese entsprechend angepasst.
- Zudem wurden gewisse Spalten mit dem korrekten Format angegeben/umgewandelt.
# Einlesen der Bezirksdaten (ohne spezielle Datentypangaben)
district_df = pd.read_csv(
"xselling_banking_data/district.csv",
sep=";"
)
district_df.head()
A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | Hl.m. Praha | Prague | 1204953 | 0 | 0 | 0 | 1 | 1 | 100.00 | 12541 | 0.29 | 0.43 | 167 | 85677 | 99107 |
1 | 2 | Benesov | central Bohemia | 88884 | 80 | 26 | 6 | 2 | 5 | 46.70 | 8507 | 1.67 | 1.85 | 132 | 2159 | 2674 |
2 | 3 | Beroun | central Bohemia | 75232 | 55 | 26 | 4 | 1 | 5 | 41.70 | 8980 | 1.95 | 2.21 | 111 | 2824 | 2813 |
3 | 4 | Kladno | central Bohemia | 149893 | 63 | 29 | 6 | 2 | 6 | 67.40 | 9753 | 4.64 | 5.05 | 109 | 5244 | 5892 |
4 | 5 | Kolin | central Bohemia | 95616 | 65 | 30 | 4 | 1 | 6 | 51.40 | 9307 | 3.85 | 4.43 | 118 | 2616 | 3040 |
Die Spaltennamen ändern wir sodass diese besser interpretierbar sind.
district_df.columns = [
'district_id',
'district_name',
'region',
'n_inhabitants',
'n_municipals_lower_499',
'n_municipals_between_500_1999',
'n_municipals_between_2000_9999',
'n_municipals_higher_10000',
'n_cities',
'ratio_urban_inhabitants',
'avg_salary',
'unemployment_rate_1995',
'unemployment_rate_1996',
'n_enterpreneurs_per_1k_inhabitants',
'n_crimes_1995',
'n_crimes_1996'
]
district_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 77 entries, 0 to 76 Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 district_id 77 non-null int64 1 district_name 77 non-null object 2 region 77 non-null object 3 n_inhabitants 77 non-null int64 4 n_municipals_lower_499 77 non-null int64 5 n_municipals_between_500_1999 77 non-null int64 6 n_municipals_between_2000_9999 77 non-null int64 7 n_municipals_higher_10000 77 non-null int64 8 n_cities 77 non-null int64 9 ratio_urban_inhabitants 77 non-null float64 10 avg_salary 77 non-null int64 11 unemployment_rate_1995 77 non-null object 12 unemployment_rate_1996 77 non-null float64 13 n_enterpreneurs_per_1k_inhabitants 77 non-null int64 14 n_crimes_1995 77 non-null object 15 n_crimes_1996 77 non-null int64 dtypes: float64(2), int64(10), object(4) memory usage: 9.8+ KB
Die meisten Spalten werden bereits korrekt eingelesen. Jedoch scheint es bei den Spalten "unemployment_rate_1995" und "n_crimes_1995" fehlerhafte Werte zu geben. Die Spalten werden als "object" eingelesen obwohl diese numerische Werte aufweisen. Wir untersuchen nun den möglichen Grund hierfür.
district_df["n_crimes_1995"].unique()
array(['85677', '2159', '2824', '5244', '2616', '2640', '4289', '5179', '2987', '3810', '3475', '3804', '1597', '6604', '1845', '1874', '1003', '1740', '999', '1563', '2299', '1089', '2879', '5198', '1822', '6041', '1029', '1580', '818', '2985', '1328', '4340', '4650', '5323', '3384', '5796', '4147', '2653', '4947', '6949', '6445', '1658', '4085', '2166', '2080', '2854', '6079', '1655', '1660', '2123', '3496', '2564', '1850', '18721', '3659', '3729', '2212', '2595', '1879', '2112', '2719', '1562', '4484', '2157', '2247', '3244', '5623', '?', '9878', '4980', '9672', '4355', '18782', '4063', '3736', '3460'], dtype=object)
district_df[district_df == "?"].count()
district_id 0 district_name 0 region 0 n_inhabitants 0 n_municipals_lower_499 0 n_municipals_between_500_1999 0 n_municipals_between_2000_9999 0 n_municipals_higher_10000 0 n_cities 0 ratio_urban_inhabitants 0 avg_salary 0 unemployment_rate_1995 1 unemployment_rate_1996 0 n_enterpreneurs_per_1k_inhabitants 0 n_crimes_1995 1 n_crimes_1996 0 dtype: int64
district_df['district_id'].count()
np.int64(77)
Die Vermutung war korrekt. In gewissen Spalten finden sich "?" als Werte. Wir werden diese nun mit NaN Werten ersetzen. Zudem passen wir noch weitere Spalten an sodass der Typ klarer ist.
# Fehlende Werte in 'unemployment_rate_1995' und 'n_crimes_1995' durch NaN ersetzen
district_df['unemployment_rate_1995'] = district_df['unemployment_rate_1995'].replace("?", np.nan)
district_df['n_crimes_1995'] = district_df['n_crimes_1995'].replace("?", np.nan)
# Umwandlung der Spalten in numerische Datentypen
district_df['unemployment_rate_1995'] = district_df['unemployment_rate_1995'].apply(pd.to_numeric)
district_df['n_crimes_1995'] = district_df['n_crimes_1995'].apply(pd.to_numeric)
# Umwandlung der Spalten 'district_name' und 'region' in den String-Datentyp
district_df['district_name'] = district_df['district_name'].astype('string')
district_df['region'] = district_df['region'].astype('string')
district_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 77 entries, 0 to 76 Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 district_id 77 non-null int64 1 district_name 77 non-null string 2 region 77 non-null string 3 n_inhabitants 77 non-null int64 4 n_municipals_lower_499 77 non-null int64 5 n_municipals_between_500_1999 77 non-null int64 6 n_municipals_between_2000_9999 77 non-null int64 7 n_municipals_higher_10000 77 non-null int64 8 n_cities 77 non-null int64 9 ratio_urban_inhabitants 77 non-null float64 10 avg_salary 77 non-null int64 11 unemployment_rate_1995 76 non-null float64 12 unemployment_rate_1996 77 non-null float64 13 n_enterpreneurs_per_1k_inhabitants 77 non-null int64 14 n_crimes_1995 76 non-null float64 15 n_crimes_1996 77 non-null int64 dtypes: float64(4), int64(10), string(2) memory usage: 9.8 KB
district_df.describe(include="all")
district_id | district_name | region | n_inhabitants | n_municipals_lower_499 | n_municipals_between_500_1999 | n_municipals_between_2000_9999 | n_municipals_higher_10000 | n_cities | ratio_urban_inhabitants | avg_salary | unemployment_rate_1995 | unemployment_rate_1996 | n_enterpreneurs_per_1k_inhabitants | n_crimes_1995 | n_crimes_1996 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
count | 77.00 | 77 | 77 | 77.00 | 77.00 | 77.00 | 77.00 | 77.00 | 77.00 | 77.00 | 77.00 | 76.00 | 77.00 | 77.00 | 76.00 | 77.00 |
unique | NaN | 77 | 8 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
top | NaN | Hl.m. Praha | south Moravia | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
freq | NaN | 1 | 14 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
mean | 39.00 | NaN | NaN | 133884.90 | 48.62 | 24.32 | 6.27 | 1.73 | 6.26 | 63.04 | 9031.68 | 3.12 | 3.79 | 116.13 | 4850.32 | 5030.83 |
std | 22.37 | NaN | NaN | 136913.46 | 32.74 | 12.78 | 4.02 | 1.01 | 2.44 | 16.22 | 790.20 | 1.67 | 1.91 | 16.61 | 9888.95 | 11270.80 |
min | 1.00 | NaN | NaN | 42821.00 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 | 33.90 | 8110.00 | 0.29 | 0.43 | 81.00 | 818.00 | 888.00 |
25% | 20.00 | NaN | NaN | 85852.00 | 22.00 | 16.00 | 4.00 | 1.00 | 5.00 | 51.90 | 8512.00 | 1.79 | 2.31 | 105.00 | 2029.75 | 2122.00 |
50% | 39.00 | NaN | NaN | 108871.00 | 49.00 | 25.00 | 6.00 | 2.00 | 6.00 | 59.80 | 8814.00 | 2.83 | 3.60 | 113.00 | 2932.00 | 3040.00 |
75% | 58.00 | NaN | NaN | 139012.00 | 71.00 | 32.00 | 8.00 | 2.00 | 8.00 | 73.50 | 9317.00 | 3.89 | 4.79 | 126.00 | 4525.50 | 4595.00 |
max | 77.00 | NaN | NaN | 1204953.00 | 151.00 | 70.00 | 20.00 | 5.00 | 11.00 | 100.00 | 12541.00 | 7.34 | 9.40 | 167.00 | 85677.00 | 99107.00 |
district_df.nunique()
district_id 77 district_name 77 region 8 n_inhabitants 77 n_municipals_lower_499 53 n_municipals_between_500_1999 36 n_municipals_between_2000_9999 17 n_municipals_higher_10000 6 n_cities 11 ratio_urban_inhabitants 70 avg_salary 76 unemployment_rate_1995 70 unemployment_rate_1996 73 n_enterpreneurs_per_1k_inhabitants 44 n_crimes_1995 75 n_crimes_1996 76 dtype: int64
district_id ist eindeutig und eignet sich daher als Primärschlüssel der Tabelle.
district_df.head()
district_id | district_name | region | n_inhabitants | n_municipals_lower_499 | n_municipals_between_500_1999 | n_municipals_between_2000_9999 | n_municipals_higher_10000 | n_cities | ratio_urban_inhabitants | avg_salary | unemployment_rate_1995 | unemployment_rate_1996 | n_enterpreneurs_per_1k_inhabitants | n_crimes_1995 | n_crimes_1996 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | Hl.m. Praha | Prague | 1204953 | 0 | 0 | 0 | 1 | 1 | 100.00 | 12541 | 0.29 | 0.43 | 167 | 85677.00 | 99107 |
1 | 2 | Benesov | central Bohemia | 88884 | 80 | 26 | 6 | 2 | 5 | 46.70 | 8507 | 1.67 | 1.85 | 132 | 2159.00 | 2674 |
2 | 3 | Beroun | central Bohemia | 75232 | 55 | 26 | 4 | 1 | 5 | 41.70 | 8980 | 1.95 | 2.21 | 111 | 2824.00 | 2813 |
3 | 4 | Kladno | central Bohemia | 149893 | 63 | 29 | 6 | 2 | 6 | 67.40 | 9753 | 4.64 | 5.05 | 109 | 5244.00 | 5892 |
4 | 5 | Kolin | central Bohemia | 95616 | 65 | 30 | 4 | 1 | 6 | 51.40 | 9307 | 3.85 | 4.43 | 118 | 2616.00 | 3040 |
EDA
# Berechnung der Anzahl und prozentualen Anteile der Bezirke pro Region
type_counts = district_df["region"].value_counts().reset_index()
type_counts.columns = ['region', 'count']
type_counts['percent'] = ((type_counts['count'] / type_counts['count'].sum()) * 100).round(1)
# Balkendiagramm zur Verteilung der Bezirke auf die Regionen
fig = px.bar(
type_counts,
x='region',
y='percent',
text_auto='.1f',
color='region',
hover_data=['count'],
title='Distribution of Districts to Regions',
labels={'region': 'Region', 'percent': 'Percentage (%)'}
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Die meisten Regions weisen die ungefähre gleiche Anzahl an Districts auf. Nur Prag hat nur einen District.
# Summe der Einwohner pro Region berechnen und prozentualen Anteil an Gesamtbevölkerung ermitteln
region_population = district_df.groupby('region')['n_inhabitants'].sum().reset_index()
region_population['inhabitant_percentage'] = (region_population['n_inhabitants'] / region_population['n_inhabitants'].sum()) * 100
# Sortierung der Regionen nach Bevölkerungsanteil (absteigend)
region_population = region_population.sort_values(by='inhabitant_percentage', ascending=False)
# Balkendiagramm zur Darstellung des Bevölkerungsanteils je Region
fig = px.bar(
region_population,
x='region',
y='inhabitant_percentage',
title='Share of Total Population by Region (%)',
labels={'region': 'Region', 'inhabitant_percentage': 'Population Share (%)'},
text_auto='.2f',
color='region'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Bei der Einwohnerzahl sieht es schon anders aus: Prag hat trotz des einzigen Districts eine ähnliche Einwohnerzahl wie jene Regions mit vielen Districts.
# Auswahl der Spalten zu Gemeinden verschiedener Grössenklassen je Region
municipals_df = district_df[['region', 'n_municipals_lower_499', 'n_municipals_between_500_1999',
'n_municipals_between_2000_9999', 'n_municipals_higher_10000']]
# Umwandlung in ein langes Format für gestapeltes Balkendiagramm
municipals_df_long = municipals_df.melt(id_vars='region',
value_vars=['n_municipals_lower_499', 'n_municipals_between_500_1999',
'n_municipals_between_2000_9999', 'n_municipals_higher_10000'],
var_name='municipal_type', value_name='municipal_count')
# Berechnung des prozentualen Anteils der Gemeinden je Region
municipals_df_long['percent'] = municipals_df_long.groupby('region')['municipal_count'].transform(lambda x: x / x.sum() * 100)
# Gestapeltes Balkendiagramm zur Verteilung der Gemeindegrössenklassen je Region
fig = px.bar(
municipals_df_long,
x='region',
y='percent',
color='municipal_type',
title='Percentage Distribution of Municipal Counts by Region',
labels={'percent': 'Percentage (%)', 'region': 'Region', 'municipal_type': 'Municipal Type'},
barmode='stack'
)
# Layout-Anpassungen
fig.update_layout(
xaxis_title='Region',
yaxis_title='Percentage (%)',
xaxis=dict(tickangle=45)
)
fig.show()
# Summe der Städte pro Region berechnen und absteigend sortieren
region_cities = district_df.groupby('region')['n_cities'].sum().reset_index()
region_cities = region_cities.sort_values(by='n_cities', ascending=False)
# Balkendiagramm zur Darstellung der Gesamtzahl der Städte je Region
fig = px.bar(
region_cities,
x='region',
y='n_cities',
title='Total Number of Cities by Region',
labels={'region': 'Region', 'n_cities': 'Total Number of Cities'},
color='region',
text_auto=True
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
# Durchschnittliches Gehalt pro Region berechnen und absteigend sortieren
region_avg_salary = district_df.groupby('region')['avg_salary'].mean().reset_index()
region_avg_salary = region_avg_salary.sort_values(by='avg_salary', ascending=False)
# Balkendiagramm zur Darstellung des durchschnittlichen Gehalts je Region
fig = px.bar(
region_avg_salary,
x='region',
y='avg_salary',
title='Average Salary by Region',
labels={'region': 'Region', 'avg_salary': 'Average Salary'},
color='region',
text_auto='.1f'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Prag hat ganz klar den höchsten durschnittlichen salary. Die restlichen Regions sind ca. uniform verteilt.
# Durchschnittliche Anzahl Unternehmer pro 1.000 Einwohner je Region berechnen
region_n_enterpreneurs_per_1k = district_df.groupby('region')['n_enterpreneurs_per_1k_inhabitants'].mean().reset_index()
# Prozentualer Anteil an der Gesamtzahl der Unternehmer pro 1.000 Einwohner berechnen
region_n_enterpreneurs_per_1k['n_enterpreneurs_percentage'] = (
region_n_enterpreneurs_per_1k['n_enterpreneurs_per_1k_inhabitants'] /
region_n_enterpreneurs_per_1k['n_enterpreneurs_per_1k_inhabitants'].sum()
) * 100
# Sortierung nach der durchschnittlichen Unternehmerzahl pro 1.000 Einwohner (absteigend)
region_n_enterpreneurs_per_1k = region_n_enterpreneurs_per_1k.sort_values(by='n_enterpreneurs_per_1k_inhabitants', ascending=False)
# Balkendiagramm zur Darstellung des Anteils der Unternehmer je Region
fig = px.bar(
region_n_enterpreneurs_per_1k,
x='region',
y='n_enterpreneurs_percentage',
title='Share of Total Entrepreneurs per 1,000 Inhabitants by Region (%)',
labels={
'region': 'Region',
'n_enterpreneurs_percentage': 'Entrepreneur Share (%)'
},
color='region',
text_auto='.2f'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Prag hat auch die höchste Anzahl an Unternehmern pro 1k Einwohner.
# Durchschnittliche Arbeitslosenquoten 1995 und 1996 je Region berechnen
region_unemployment_rate = district_df.groupby('region')[['unemployment_rate_1995', 'unemployment_rate_1996']].mean().reset_index()
# Gruppiertes Balkendiagramm zur Darstellung der Arbeitslosenquoten je Region für 1995 und 1996
fig = px.bar(
region_unemployment_rate,
x='region',
y=['unemployment_rate_1995', 'unemployment_rate_1996'],
title='Unemployment Rates 1995 and 1996 by Region',
labels={'region': 'Region', 'value': 'Unemployment Rate'},
barmode='group',
text_auto='.2f'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
# Summe der Straftaten 1995 und 1996 je Region berechnen
region_n_crimes = district_df.groupby('region')[['n_crimes_1995', 'n_crimes_1996']].sum().reset_index()
# Gruppiertes Balkendiagramm zur Darstellung der Anzahl der Straftaten je Region für 1995 und 1996
fig = px.bar(
region_n_crimes,
x='region',
y=['n_crimes_1995', 'n_crimes_1996'],
title='Number of Crimes 1995 and 1996 by Region',
labels={'region': 'Region', 'value': 'Number of Crimes'},
barmode='group',
text_auto='.0f'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Wir normalisieren die absoluten Zahlen der Anzahl Kriminalität auf pro 1000 Einwohner. So ist der Plot aussagekräftiger.
# Summe der Straftaten und Einwohner je Region berechnen
region_n_crimes = district_df.groupby('region')[
['n_crimes_1995', 'n_crimes_1996', 'n_inhabitants']
].sum().reset_index()
# Berechnung der Anzahl der Straftaten pro 1000 Einwohner für 1995 und 1996
region_n_crimes['n_crimes_1995_per_1000'] = (region_n_crimes['n_crimes_1995'] / region_n_crimes['n_inhabitants']) * 1000
region_n_crimes['n_crimes_1996_per_1000'] = (region_n_crimes['n_crimes_1996'] / region_n_crimes['n_inhabitants']) * 1000
# Gruppiertes Balkendiagramm zur Darstellung der Straftaten pro 1000 Einwohner je Region
fig = px.bar(
region_n_crimes,
x='region',
y=['n_crimes_1995_per_1000', 'n_crimes_1996_per_1000'],
title='Number of Crimes per 1000 Inhabitants (1995 and 1996) by Region',
labels={'region': 'Region', 'value': 'Crimes per 1000 Inhabitants'},
barmode='group',
text_auto='.2f'
)
fig.update_traces(textposition='outside', cliponaxis=False)
fig.show()
Prag hat die höchste Kriminalität per 1000 EW
Fazit und nächste Schritte
Entitäten
- district_id ist Primärschlüssel
- Keine Fremdschlüssel zu anderen Datensätzen
Fazit EDA
- Prag sticht als Metropole klar heraus. Die restlichen Regionen sind im Vergleich eher ländlich geprägt (mit einer gewissen Varianz).
- Prag hat einen überdurchschnittlichen:
- average salary
- Unternehmer/Einwohner Ratio
- Kriminalitätsrate
- Zudem hat Prag eine (im Vergleich) sehr niedrige Arbeitslosenrate.
- Der Vergleich der Jahre 1995 und 1996 zeigt einen Anstieg (oder gleichbleibend) an Kriminalität und Arbeitslosigkeit.
Offene Punkte und nächste Schritte:
- Regionale Merkmale als Prädiktoren einbeziehen:
Regionale Differenzierung: Gibt es Distrikte, die signifikant mehr (oder weniger) Kreditkarten besitzen?
Laden und Bereinigen der Daten
# Einlesen der Kundendaten mit spezifizierten Datentypen
# Hinweis: 'birth_number' wird als String eingelesen, um weitere Verarbeitung zu ermöglichen
client_df = pd.read_csv(
"xselling_banking_data/client.csv",
sep=";",
dtype={
"client_id": "int64",
"district_id": "int64",
"birth_number": "string",
}
)
client_df.head()
client_id | birth_number | district_id | |
---|---|---|---|
0 | 1 | 706213 | 18 |
1 | 2 | 450204 | 1 |
2 | 3 | 406009 | 1 |
3 | 4 | 561201 | 5 |
4 | 5 | 605703 | 5 |
#the number is in the form YYMMDD for men,
#the number is in the form YYMM+50DD for women,
#where YYMMDD is the date of birth
# Extrahieren von Jahr, Monat und Tag aus 'birth_number'
# Für Frauen wird zum Monat 50 addiert, daher Korrektur durch Abzug von 50, falls Monat > 12
year = client_df["birth_number"].str.slice(0, 2)
month_true = client_df["birth_number"].str.slice(2, 4).astype(int).apply(lambda x: x - 50 if x > 12 else x)
day = client_df["birth_number"].str.slice(4, 6)
# Geschlecht basierend auf Monatsteil bestimmen: Monat > 12 -> Frau (F), sonst Mann (M)
client_df["gender"] = client_df["birth_number"].str.slice(2, 4).astype(int).apply(lambda x: "F" if x > 12 else "M")
# Monat mit führender Null auffüllen und zusammenfügen
month = month_true.astype(str).str.zfill(2)
birth_fixed = year + month + day
# Ausgabe der originalen und korrigierten Geburtsnummern der ersten fünf Kunden
print(pd.DataFrame({
"original": client_df["birth_number"].head(),
"fixed": birth_fixed.head()
}))
original fixed 0 706213 701213 1 450204 450204 2 406009 401009 3 561201 561201 4 605703 600703
# Funktion zur Korrektur des Jahres (Jahr wird mit "19" ergänzt)
def correct_year(year):
return "19" + year
# Korrigierte Geburtsnummer mit vollständigem Jahr (z.B. "19930115")
birth_fixed_corrected = birth_fixed.apply(lambda x: correct_year(x[:2]) + x[2:])
# Umwandlung in datetime-Format, fehlerhafte Werte werden als NaT gesetzt
client_df["birth_number"] = pd.to_datetime(birth_fixed_corrected, format="%Y%m%d", errors="coerce")
# Ausgabe der ersten Zeilen zur Kontrolle
print(client_df.head())
client_id birth_number district_id gender 0 1 1970-12-13 18 F 1 2 1945-02-04 1 M 2 3 1940-10-09 1 F 3 4 1956-12-01 5 M 4 5 1960-07-03 5 F
Für das weitere Vorgehen, ist es nützlich eine Spalte einzuführen, welche das absolute Alter aufführt. Hierzu verwenden wir das aktuellste Datum, welches irgendwo in den Daten vorkommt. Dieses Datum verwenden wir dann als das Ende des Datensatzes an und berechnen hiermit das Alter.
# Maximaldatum aus bereits geladenen DataFrames
max_accounts_date = accounts_df['date'].max()
max_card_date = credit_card_df['issued'].max()
# Nur Datumsspalte aus trans.csv einlesen
trans_date = pd.read_csv("xselling_banking_data/trans.csv", delimiter=";", usecols=["date"], parse_dates=["date"])
max_trans_date = trans_date["date"].max()
# Nur Datumsspalte aus loan.csv einlesen
loan_date = pd.read_csv("xselling_banking_data/loan.csv", delimiter=";", usecols=["date"], parse_dates=["date"])
max_loan_date = loan_date["date"].max()
# Gesamtmaximaldatum bestimmen
latest_date = max(max_accounts_date, max_card_date, max_trans_date, max_loan_date)
# Alter berechnen
client_df['birth_number'] = pd.to_datetime(client_df['birth_number'], errors='coerce')
client_df['age'] = (latest_date - client_df['birth_number']).dt.days // 365
C:\Users\grego\AppData\Local\Temp\ipykernel_23588\2441548511.py:6: UserWarning: Could not infer format, so each element will be parsed individually, falling back to `dateutil`. To ensure parsing is consistent and as-expected, please specify a format. C:\Users\grego\AppData\Local\Temp\ipykernel_23588\2441548511.py:10: UserWarning: Could not infer format, so each element will be parsed individually, falling back to `dateutil`. To ensure parsing is consistent and as-expected, please specify a format.
EDA
client_df.describe(include='all')
client_id | birth_number | district_id | gender | age | |
---|---|---|---|---|---|
count | 5369.00 | 5369 | 5369.00 | 5369 | 5369.00 |
unique | NaN | NaN | NaN | 2 | NaN |
top | NaN | NaN | NaN | M | NaN |
freq | NaN | NaN | NaN | 2724 | NaN |
mean | 3359.01 | 1953-09-12 09:32:21.143602176 | 37.31 | NaN | 44.83 |
min | 1.00 | 1911-08-20 00:00:00 | 1.00 | NaN | 11.00 |
25% | 1418.00 | 1940-11-25 00:00:00 | 14.00 | NaN | 30.00 |
50% | 2839.00 | 1954-05-06 00:00:00 | 38.00 | NaN | 44.00 |
75% | 4257.00 | 1968-06-09 00:00:00 | 60.00 | NaN | 58.00 |
max | 13998.00 | 1987-09-27 00:00:00 | 77.00 | NaN | 87.00 |
std | 2832.91 | NaN | 25.04 | NaN | 17.29 |
# Gruppierung der Kunden nach Alter und Geschlecht mit Zählung der Personen pro Gruppe
year_gender_counts = client_df.groupby(['age', 'gender']).size().reset_index(name='count')
# Horizontales Balkendiagramm zur Verteilung des Alters nach Geschlecht
fig = px.bar(
year_gender_counts,
x='count',
y='age',
color='gender',
orientation='h',
title='Distribution of Age by Gender',
labels={'age': 'Age', 'count': 'Count'},
color_discrete_map={"M": "blue", "F": "red"},
height=600
)
# Negative Werte für Männer (erste Gruppe) zum Plotten auf der linken Seite
fig.data[0].x = -fig.data[0].x
# Balkenkontur mit schwarzer Linie für bessere Abgrenzung
fig.update_traces(
marker=dict(line=dict(width=2, color='black'))
)
# Keine Textbeschriftung auf den Balken
fig.update_traces(textposition='none')
# Layout-Anpassungen für Achsentitel, Legende und Tick-Intervalle
fig.update_layout(
xaxis_title='Count',
yaxis_title='Age',
showlegend=True,
legend_title='Gender',
xaxis=dict(tickmode='linear', dtick=10),
)
fig.show()
Kundenverteilung folgt einer typischen Bevölkerungsverteilung eines Industrielandes (breite Basis und abfallende Geburtenrate)
# Kundenanzahl pro Bezirk gruppieren und Prozentanteil berechnen
clients_per_district = client_df.groupby('district_id').size().reset_index(name='count')
clients_per_district['percentage'] = (clients_per_district['count'] / clients_per_district['count'].sum() * 100).round(1)
clients_per_district['district_id'] = clients_per_district['district_id'].astype(str)
clients_per_district_sorted = clients_per_district.sort_values(by='count', ascending=False)
fig = px.bar(
clients_per_district_sorted,
x='district_id',
y='count',
title='Anzahl der Kunden pro Bezirk',
labels={'district_id': 'Bezirk', 'count': 'Anzahl der Kunden'},
color='percentage'
)
fig.show()
Die Verteilung der Kundenanzahl pro Bezirk zeigt eine starke Konzentration der Kunden im Bezirk mit der ID 1 (12 %), der mit über 600 Kunden den höchsten Anteil hat. Weitere Bezirke folgen mit deutlich geringeren Kundenanzahlen.
Fazit und nächste Schritte
Schlüsselstruktur und Datenmodell
- client_id ist Primärschlüssel
- district_id ist Fremdschlüssel zum Datensatz district_df
Zusammenfassung der EDA Erkenntnisse
- Frauen und Männer sind ähnlich häufig verteilt.
- Durchschnittsalter ist 45. Der jüngste Kunde ist 11 und der älteste ist 87.
- Kundenverteilung folgt einer typischen Bevölkerungsverteilung eines Industrielandes (breite Basis und abfallende Geburtenrate)
- Gegen hohes Alter sinken die Kundenbeobachtungen logischerweise.
- Die Verteilung der Kundenanzahl pro Bezirk zeigt eine starke Konzentration der Kunden im Bezirk mit der ID 1 (12 %), der mit über 600 Kunden den höchsten Anteil hat. Weitere Bezirke folgen mit deutlich geringeren Kundenanzahlen.
Laden und Bereinigen der Daten
# Daueraufträge einlesen mit spezifizierten Datentypen
order_df = pd.read_csv(
"xselling_banking_data/order.csv",
sep=";",
dtype={
"order_id": "int64",
"account_id": "int64",
"bank_to": "string",
"account_to": "string",
"amount": "float64",
"k_symbol": "category"
}
)
order_df.dtypes
order_id int64 account_id int64 bank_to string[python] account_to string[python] amount float64 k_symbol category dtype: object
order_df.head()
order_id | account_id | bank_to | account_to | amount | k_symbol | |
---|---|---|---|---|---|---|
0 | 29401 | 1 | YZ | 87144583 | 2452.00 | SIPO |
1 | 29402 | 2 | ST | 89597016 | 3372.70 | UVER |
2 | 29403 | 2 | QR | 13943797 | 7266.00 | SIPO |
3 | 29404 | 3 | WX | 83084338 | 1135.00 | SIPO |
4 | 29405 | 3 | CD | 24485939 | 327.00 |
Die Datentypen wurden korrekt eingelesen.
order_df.isna().sum()
order_id 0 account_id 0 bank_to 0 account_to 0 amount 0 k_symbol 0 dtype: int64
Die Daten weisen keine NaN Werte auf.
order_df['order_id'].is_unique
True
Die Spalte order_id weist nur unique Werte auf.
Die Datenbeschreibung gibt uns die Übersetzungen für die Werte der Variable k_symbol.
- POJISTNE = insurance_payment
- SIPO = household_payment
- LEASING = leasing_payment
- UVER = loan_payment
Wir übersetzen bzw. nennen diese Werte nun um.
order_df['k_symbol'] = order_df['k_symbol'].map({
"POJISTNE": "insurance_payment",
"SIPO": "household_payment",
"LEASING": "leasing_payment",
"UVER" : "loan_payment"
})
EDA
order_df.describe(include='all')
order_id | account_id | bank_to | account_to | amount | k_symbol | |
---|---|---|---|---|---|---|
count | 6471.00 | 6471.00 | 6471 | 6471 | 6471.00 | 5092 |
unique | NaN | NaN | 13 | 6446 | NaN | 4 |
top | NaN | NaN | QR | 29934013 | NaN | household_payment |
freq | NaN | NaN | 531 | 2 | NaN | 3502 |
mean | 33778.20 | 2962.30 | NaN | NaN | 3280.64 | NaN |
std | 3737.68 | 2518.50 | NaN | NaN | 2714.48 | NaN |
min | 29401.00 | 1.00 | NaN | NaN | 1.00 | NaN |
25% | 31187.50 | 1223.00 | NaN | NaN | 1241.50 | NaN |
50% | 32988.00 | 2433.00 | NaN | NaN | 2596.00 | NaN |
75% | 34785.50 | 3645.50 | NaN | NaN | 4613.50 | NaN |
max | 46338.00 | 11362.00 | NaN | NaN | 14882.00 | NaN |
- Der mittlere Wert einer permanent order beträgt 3281. Der Datensatz weist 6471 orders auf.
- bank_to: Es gibt 13 verschiedene Banken, wobei "QR" (10807258) am häufigsten vorkommt (531 Mal).
- k_symbol: Dieser Code beschreibt den Verwendungszweck der Bestellung und ist häufig mit "household_payment" (3.502 Einträge) zu finden.
# Berechnung der Häufigkeiten und Prozentanteile der Kategorien in 'k_symbol'
type_counts_k_symbol = order_df['k_symbol'].value_counts().reset_index(name='count')
type_counts_k_symbol.columns = ['k_symbol', 'count']
type_counts_k_symbol = type_counts_k_symbol.sort_values('k_symbol')
type_counts_k_symbol['percent'] = (type_counts_k_symbol['count'] / type_counts_k_symbol['count'].sum() * 100).round(2)
# Balkendiagramm der Verteilung der 'k_symbol' Kategorien
fig = px.bar(
type_counts_k_symbol,
x='k_symbol',
y='percent',
text_auto=True,
hover_data={"count": True},
color='k_symbol'
)
fig.update_layout(
xaxis_title='K Symbol',
yaxis_title='Percent',
xaxis=dict(type='category')
)
fig.update_traces(
textfont_size=12,
textangle=0,
textposition="outside",
cliponaxis=False
)
fig.show()
household payments dominieren mit 69%.
# Subplot mit Boxplot und Histogramm zur Verteilung der Auftragsbeträge erstellen
fig = make_subplots(rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.02)
# Boxplot oben
fig.add_trace(go.Box(x=order_df["amount"], name="Boxplot", marker_color="blue", orientation='h'), row=1, col=1)
# Histogramm unten
fig.add_trace(go.Histogram(x=order_df["amount"], nbinsx=100, name="Histogramm", marker_color="blue"), row=2, col=1)
# Layout konfigurieren
fig.update_layout(
height=600,
title_text="Distribution of Order amount",
showlegend=True,
xaxis_title="Order Amount (CZK)",
xaxis2_title="Order Amount (CZK)",
bargap=0.05
)
fig.show()
Die Verteilung der Besellbeträge ist rechtsschief, mit einer Häufung der Bestellungen im Bereich von 1.000 bis 5.000 CZK
Fazit und nächste Schritte
Schlüsselstruktur und Datenmodell
- order_id ist Primärschlüssel
- account_id ist Fremdschlüssel zu Datensatz account_df
Fazit EDA
- Die Verteilung der Besellbeträge ist rechtsschief, mit einer Häufung der Bestellungen im Bereich von 1.000 bis 5.000 CZK
- household payments dominieren klar mit 69%. Die restlichen permanten order Gründe sind ziemlich uniform bei ca. 10% (+/- 4%).
Laden und Bereinigen der Daten
# Einlesen der Darlehensdaten mit spezifizierten Datentypen und Datumsformat
loan_df = pd.read_csv(
"xselling_banking_data/loan.csv",
sep=";",
dtype={
"loan_id": "int64",
"account_id": "int64",
"amount": "float64",
"duration": "int64",
"payments": "float64",
"status": "category"
},
parse_dates=["date"],
date_parser=lambda x: pd.to_datetime(x, format="%y%m%d"))
loan_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 682 entries, 0 to 681 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 loan_id 682 non-null int64 1 account_id 682 non-null int64 2 date 682 non-null datetime64[ns] 3 amount 682 non-null float64 4 duration 682 non-null int64 5 payments 682 non-null float64 6 status 682 non-null category dtypes: category(1), datetime64[ns](1), float64(2), int64(3) memory usage: 33.0 KB
Die Spaltentypen sehen korrekt aus, wie sie eingelesen wurden.
loan_df.head()
loan_id | account_id | date | amount | duration | payments | status | |
---|---|---|---|---|---|---|---|
0 | 5314 | 1787 | 1993-07-05 | 96396.00 | 12 | 8033.00 | B |
1 | 5316 | 1801 | 1993-07-11 | 165960.00 | 36 | 4610.00 | A |
2 | 6863 | 9188 | 1993-07-28 | 127080.00 | 60 | 2118.00 | A |
3 | 5325 | 1843 | 1993-08-03 | 105804.00 | 36 | 2939.00 | A |
4 | 7240 | 11013 | 1993-09-06 | 274740.00 | 60 | 4579.00 | A |
loan_df.isna().sum()
loan_id 0 account_id 0 date 0 amount 0 duration 0 payments 0 status 0 dtype: int64
Der Datensatz enthält keine Nullwerte
loan_df['loan_id'].is_unique
True
- Die Spalte disp_id ist eindeutig und dient als Primärschlüssel.
Um die Spalte status leichter interpretieren zu können, benennen wir sie um mit der korrekten Bedeutung.
- A = finished_ok
- B = finished_debts
- C = running_ok
- D = running_debts
loan_df['status'] = loan_df['status'].map({
"A" : "finished_ok",
"B" : "finished_debts",
"C" : "running_ok",
"D" : "running_debts"
})
EDA
loan_df.describe(include='all')
loan_id | account_id | date | amount | duration | payments | status | |
---|---|---|---|---|---|---|---|
count | 682.00 | 682.00 | 682 | 682.00 | 682.00 | 682.00 | 682 |
unique | NaN | NaN | NaN | NaN | NaN | NaN | 4 |
top | NaN | NaN | NaN | NaN | NaN | NaN | running_ok |
freq | NaN | NaN | NaN | NaN | NaN | NaN | 403 |
mean | 6172.47 | 5824.16 | 1996-09-29 05:35:43.108504448 | 151410.18 | 36.49 | 4190.66 | NaN |
min | 4959.00 | 2.00 | 1993-07-05 00:00:00 | 4980.00 | 12.00 | 304.00 | NaN |
25% | 5577.50 | 2967.00 | 1995-07-04 12:00:00 | 66732.00 | 24.00 | 2477.00 | NaN |
50% | 6176.50 | 5738.50 | 1997-02-06 12:00:00 | 116928.00 | 36.00 | 3934.00 | NaN |
75% | 6752.50 | 8686.00 | 1997-12-12 12:00:00 | 210654.00 | 48.00 | 5813.50 | NaN |
max | 7308.00 | 11362.00 | 1998-12-08 00:00:00 | 590820.00 | 60.00 | 9910.00 | NaN |
std | 682.58 | 3283.51 | NaN | 113372.41 | 17.08 | 2215.83 | NaN |
# Subplot mit Boxplot und Histogramm zur Verteilung der Darlehensbeträge erstellen
fig = make_subplots(rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.02)
# Boxplot oben
fig.add_trace(go.Box(x=loan_df["amount"], name="Boxplot", marker_color="red", orientation='h'), row=1, col=1)
# Histogramm unten
fig.add_trace(go.Histogram(x=loan_df["amount"], nbinsx=100, name="Histogramm", marker_color="red"), row=2, col=1)
# Layout konfigurieren
fig.update_layout(
height=600,
title_text="Distribution of Loan Amounts",
showlegend=True,
xaxis_title="Loan Amount (CZK)",
xaxis2_title="Loan Amount (CZK)",
bargap=0.05
)
fig.show()
Verteilung: Die Kreditbeträge sind meist im unteren bis mittleren Bereich konzentriert, während nur wenige sehr grosse Kredite die Verteilung nach oben verzerren und so eine rechtsschiefe Verteilung erzeugen.
Boxplot:
- Der Median der Kreditbeträge liegt bei 116.928 CZK.
- 75 % der Kredite liegen unter 210.744 CZK.
- Der untere Whisker endet bei 4.980 CZK, was auf einen relativ niedrigen Bereich hinweist.
- Der obere Whisker endet bei 421.008 CZK und repräsentiert den oberen Bereich der normalen Kreditbeträge.
- Es gibt Ausreisser im oberen Bereich mit einem Maximum von 590.824 CZK.
# Häufigkeiten und Prozentanteile der Darlehenslaufzeiten berechnen und sortieren
type_counts_duration = loan_df['duration'].value_counts().reset_index(name='count')
type_counts_duration.columns = ['duration', 'count']
type_counts_duration = type_counts_duration.sort_values('duration')
type_counts_duration['percent'] = (type_counts_duration['count'] / type_counts_duration['count'].sum() * 100).round(2)
type_counts_duration['duration'] = type_counts_duration['duration'].astype(str)
# Balkendiagramm zur Verteilung der Darlehenslaufzeiten
fig = px.bar(
type_counts_duration,
x='duration',
y='percent',
text_auto=True,
hover_data={"count": True},
color='duration'
)
fig.update_layout(
xaxis_title='Loan duration',
yaxis_title='Percent %',
xaxis=dict(type='category')
)
fig.update_traces(
textfont_size=12,
textangle=0,
textposition="outside",
cliponaxis=False
)
fig.show()
Die Loan duration weist 5 diskrete Werte auf, welche alle ziemlich uniform verteilt sind.
# Subplot mit Boxplot und Histogramm zur Verteilung der Darlehenszahlungen erstellen
fig = make_subplots(rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.02)
# Boxplot oben
fig.add_trace(go.Box(x=loan_df["payments"], name="Boxplot", marker_color="blue", orientation='h'), row=1, col=1)
# Histogramm unten
fig.add_trace(go.Histogram(x=loan_df["amount"], nbinsx=100, name="Histogramm", marker_color="blue"), row=2, col=1)
# Layout konfigurieren
fig.update_layout(
height=600,
title_text="Distribution of Loan Payments",
showlegend=True,
xaxis_title="Loan Payments (CZK)",
xaxis2_title="Loan Payments (CZK)",
bargap=0.05
)
fig.show()
Verteilung: Die meisten Kreditrückzahlungen liegen im Bereich von etwa 10.000 bis 150.000 CZK. Die Verteilung ist rechtsschief, mit einem langen Schwanz nach rechts, der höhere Rückzahlungen darstellt, die seltener auftreten.
Boxplot Analyse:
- Minimum: Der niedrigste Wert liegt bei etwa 304 CZK.
- Q1 (25%): Der erste Quartilwert liegt bei etwa 2.477 CZK.
- Median (50%): Der Medianwert beträgt etwa 3.934 CZK.
- Q3 (75%): Das dritte Quartil liegt bei etwa 5.814 CZK.
- Oberer Whisker (Upper Fence): Der obere Whisker endet bei etwa 9.910 CZK.
- Maximum: Der höchste Wert liegt bei etwa 9.910 CZK.
# Häufigkeiten und Prozentanteile der Darlehensstatus berechnen und sortieren
type_counts_status = loan_df['status'].value_counts().reset_index(name='count')
type_counts_status.columns = ['status', 'count']
type_counts_status = type_counts_status.sort_values('status') # alphabetische Sortierung
type_counts_status['percent'] = (type_counts_status['count'] / type_counts_status['count'].sum() * 100).round(2)
# Balkendiagramm der Verteilung der Darlehensstatus
fig = px.bar(
type_counts_status,
x='status',
y='percent',
text_auto=True,
hover_data={"count": True},
color='status'
)
fig.update_layout(
xaxis_title='Loan status',
yaxis_title='Percent',
xaxis=dict(type='category')
)
fig.update_traces(
textfont_size=12,
textangle=0,
textposition="outside",
cliponaxis=False
)
fig.show()
Die Loan status lassen sich in zwei sinnvolle Kategorien einteilen: OK (finished_ok und running_ok; machen zusammen 89% aus) und problemanfällig (finished_debts und running_debts; machen 11% aus). Also lässt sich sagen, dass jeder zehnte Loan ein Problem aufweist.
Fazit und nächste Schritte
Schlüsselstruktur und Datenmodell
- `loan_id ist der Primärschlüssel.
account_id
ist ein Fremdschlüssel, der auf den Datensatz account_df verweist.
Zusammenfassung der EDA-Erkenntnisse
- Loan Amounts: Der durchschnittliche Kreditbetrag liegt bei 117.000 CZK. 75% der Kredite liegen bei einem Betrag von bis zu 210.000 CZK.
- Duration: Die Kreditlaufzeiten (durations) sind uniform verteilt und weisen keine signifikanten Abweichungen oder Spitzen auf.
- Payments: Die meisten Kreditrückzahlungen liegen im Bereich von etwa 10.000 bis 150.000 CZK. Die Verteilung ist rechtsschief, mit einem langen Schwanz nach rechts, der höhere Rückzahlungen darstellt, die seltener auftreten.
- Loan Status: Der Status der Kredite ist überwiegend positiv, mit 59% der Kredite als
running_ok
. 30% der Kredite sindfinished_ok
, und 10% weisen Problemstellungen auf (entwederrunning_debts
oderfinished_debts
).
Laden und Bereinigen der Daten
# Einlesen der Transaktionsdaten mit spezifizierten Datentypen und Datumsformat
transaction_df = pd.read_csv("xselling_banking_data/trans.csv", delimiter=";", dtype={
"trans_id": "int64",
"account_id": "int64",
"type": "category",
"operation": "category",
"k_symbol": "category",
"bank": "string",
"account": "string"
}, parse_dates=["date"],
date_format="%y%m%d")
transaction_df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1056320 entries, 0 to 1056319 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 trans_id 1056320 non-null int64 1 account_id 1056320 non-null int64 2 date 1056320 non-null datetime64[ns] 3 type 1056320 non-null category 4 operation 873206 non-null category 5 amount 1056320 non-null float64 6 balance 1056320 non-null float64 7 k_symbol 574439 non-null category 8 bank 273508 non-null string 9 account 295389 non-null string dtypes: category(3), datetime64[ns](1), float64(2), int64(2), string(2) memory usage: 59.4 MB
Die Spaltentypen sehen korrekt aus, wie sie eingelesen wurden.
transaction_df.head()
trans_id | account_id | date | type | operation | amount | balance | k_symbol | bank | account | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 695247 | 2378 | 1993-01-01 | PRIJEM | VKLAD | 700.00 | 700.00 | NaN | <NA> | <NA> |
1 | 171812 | 576 | 1993-01-01 | PRIJEM | VKLAD | 900.00 | 900.00 | NaN | <NA> | <NA> |
2 | 207264 | 704 | 1993-01-01 | PRIJEM | VKLAD | 1000.00 | 1000.00 | NaN | <NA> | <NA> |
3 | 1117247 | 3818 | 1993-01-01 | PRIJEM | VKLAD | 600.00 | 600.00 | NaN | <NA> | <NA> |
4 | 579373 | 1972 | 1993-01-02 | PRIJEM | VKLAD | 400.00 | 400.00 | NaN | <NA> | <NA> |
Die tschechischen Bezeichnungen in den Spalten operation (Art der Transaktion) und k_symbol (Zweck der Zahlung) werden ins Englische übersetzt, um eine einheitliche und verständliche Benennung innerhalb der Analyse sicherzustellen.
transaction_df["type"] = transaction_df["type"].cat.rename_categories({
"PRIJEM": "credit",
"VYDAJ": "withdrawal"
})
transaction_df["operation"] = transaction_df["operation"].cat.rename_categories({
"VYBER KARTOU": "credit_card_withdrawal",
"VKLAD": "credit_in_cash",
"PREVOD Z UCTU": "collection_from_another_bank",
"VYBER": "withdrawal_in_cash",
"PREVOD NA UCET": "remittance_to_another_bank"
})
transaction_df["k_symbol"] = transaction_df["k_symbol"].cat.rename_categories({
"POJISTNE": "insurance_payment",
"SLUZBY": "payment_for_statement",
"UROK": "interest_credited",
"SANKC. UROK": "sanction_interest_negative_balance",
"SIPO": "household_payment",
"DUCHOD": "oldage_pension",
"UVER": "loan_payment"
})
for col in transaction_df.select_dtypes(include='category'):
print(f"\nSpalte: {col}")
print(f"Anzahl eindeutiger Werte: {transaction_df[col].nunique()}")
print(transaction_df[col].cat.categories.tolist())
Spalte: type Anzahl eindeutiger Werte: 3 ['credit', 'VYBER', 'withdrawal'] Spalte: operation Anzahl eindeutiger Werte: 5 ['remittance_to_another_bank', 'collection_from_another_bank', 'credit_in_cash', 'withdrawal_in_cash', 'credit_card_withdrawal'] Spalte: k_symbol Anzahl eindeutiger Werte: 8 [' ', 'oldage_pension', 'insurance_payment', 'sanction_interest_negative_balance', 'household_payment', 'payment_for_statement', 'interest_credited', 'loan_payment']
Die Umbenennung der Werte in den kategorialen Spalten hat grundsätzlich funktioniert. Allerdings fällt auf:
In der Spalte type ist der ursprüngliche tschechische Wert VYBER weiterhin vorhanden – dieser sollte weiter untersucht und gegebenenfalls korrigiert werden.
In der Spalte k_symbol existiert eine leere Kategorie (' '), die vermutlich auf fehlende oder nicht interpretierte Werte zurückzuführen ist. Auch diese sollte näher analysiert und bereinigt werden.
transaction_df.query("type == 'VYBER'")["operation"].value_counts()
operation withdrawal_in_cash 16666 remittance_to_another_bank 0 collection_from_another_bank 0 credit_in_cash 0 credit_card_withdrawal 0 Name: count, dtype: int64
Bei genauer Analyse zeigt sich, dass alle 16'666 Transaktionen mit type == "VYBER" die Operation "withdrawal_in_cash" besitzen. Dies legt nahe, dass "VYBER" fälschlicherweise in der Spalte type verblieben ist und korrekterweise als "withdrawal" interpretiert werden sollte. Wir passen dies nun an.
transaction_df["type"] = transaction_df["type"].replace("VYBER" , "withdrawal")
for col in transaction_df.select_dtypes(include="category"):
print(f"\nSpalte: {col}")
print(f"Anzahl eindeutiger Werte: {transaction_df[col].nunique()}")
print(f"Kategorien: {transaction_df[col].cat.categories.tolist()}")
Spalte: type Anzahl eindeutiger Werte: 2 Kategorien: ['credit', 'withdrawal'] Spalte: operation Anzahl eindeutiger Werte: 5 Kategorien: ['remittance_to_another_bank', 'collection_from_another_bank', 'credit_in_cash', 'withdrawal_in_cash', 'credit_card_withdrawal'] Spalte: k_symbol Anzahl eindeutiger Werte: 8 Kategorien: [' ', 'oldage_pension', 'insurance_payment', 'sanction_interest_negative_balance', 'household_payment', 'payment_for_statement', 'interest_credited', 'loan_payment']
Die Korrektur hat funktioniert.
transaction_df.isna().mean().mul(100).round(1)
trans_id 0.00 account_id 0.00 date 0.00 type 0.00 operation 17.30 amount 0.00 balance 0.00 k_symbol 45.60 bank 74.10 account 72.00 dtype: float64
Die Analyse der fehlenden Werte zeigt, dass bank
(74.1 %) und account
(72.0 %) einen sehr hohen Anteil an NaN-Werten enthalten. Da sie nur wenig zur Analyse des Transaktionsverhaltens beitragen und keine zuverlässige Information liefern, werden diese Spalten aus dem Datensatz entfernt.
Die Spalten operation
und k_symbol
hingegen sind relevant für das Transaktionsverhalten und werden daher beibehalten. Fehlende Werte werden dort mit der Kategorie "unknown" ersetzt.
# 'unknown' als Kategorie in 'operation' hinzufügen, falls noch nicht vorhanden
if "unknown" not in transaction_df['operation'].cat.categories:
transaction_df['operation'] = transaction_df['operation'].cat.add_categories("unknown")
# Leere Strings und NaN in 'operation' durch 'unknown' ersetzen
transaction_df['operation'] = transaction_df['operation'].replace(' ', 'unknown')
transaction_df['operation'] = transaction_df['operation'].fillna('unknown')
# 'unknown' als Kategorie in 'k_symbol' hinzufügen, falls noch nicht vorhanden
if "unknown" not in transaction_df['k_symbol'].cat.categories:
transaction_df['k_symbol'] = transaction_df['k_symbol'].cat.add_categories("unknown")
# Leere Strings und NaN in 'k_symbol' durch 'unknown' ersetzen
transaction_df['k_symbol'] = transaction_df['k_symbol'].replace(' ', 'unknown')
transaction_df['k_symbol'] = transaction_df['k_symbol'].fillna('unknown')
# Entfernen der Spalten 'bank' und 'account', falls vorhanden
transaction_df.drop(columns=['bank', 'account'], inplace=True, errors='ignore')
transaction_df.isna().mean().mul(100).round(1)
trans_id 0.00 account_id 0.00 date 0.00 type 0.00 operation 0.00 amount 0.00 balance 0.00 k_symbol 0.00 dtype: float64
Alle fehlenden Werte wurden erfolgreich bereinigt:
Die Spalten operation
und k_symbol
enthalten nun die Ersatzkategorie "unknown"
.
Die Spalten bank
und account
wurden aufgrund zu vieler fehlender Werte entfernt.
for col in transaction_df.select_dtypes(include='category'):
print(f"\nSpalte: {col}")
print(f"Anzahl eindeutiger Werte: {transaction_df[col].nunique()}")
print(transaction_df[col].cat.categories.tolist())
Spalte: type Anzahl eindeutiger Werte: 2 ['credit', 'withdrawal'] Spalte: operation Anzahl eindeutiger Werte: 6 ['remittance_to_another_bank', 'collection_from_another_bank', 'credit_in_cash', 'withdrawal_in_cash', 'credit_card_withdrawal', 'unknown'] Spalte: k_symbol Anzahl eindeutiger Werte: 8 ['oldage_pension', 'insurance_payment', 'sanction_interest_negative_balance', 'household_payment', 'payment_for_statement', 'interest_credited', 'loan_payment', 'unknown']
In operation
und k_symbol
wurde eine zusätzliche Spalte "unknown"
ergänzt
transaction_df["amount"].describe().apply(lambda x: round(x, 2))
count 1056320.00 mean 5924.15 std 9522.74 min 0.00 25% 135.90 50% 2100.00 75% 6800.00 max 87400.00 Name: amount, dtype: float64
In der Datenbeschreibung steht: type bezeichnet die Richtung der Transaktion – "PRIJEM" steht für Gutschrift (credit), "VYDAJ" für Abbuchung (withdrawal).
Anhand der Auswertung der Spalte amount
sehen wir jedoch, dass alle Beträge positiv gespeichert sind.
Es ist somit nicht direkt erkennbar, ob es sich um eine Abhebung oder Einzahlung handelt, da das Vorzeichen nicht zwischen Credit (Gutschrift) und Withdrawal (Abhebung) unterscheidet.
Zur Vorbereitung für die Modellierung setzen wir daher alle Abbuchungen (Withdrawal) in amount
auf negative Werte, um Ein- und Ausgänge klar voneinander zu unterscheiden.
transaction_df.loc[transaction_df["type"] == "withdrawal", "amount"] *= -1
(transaction_df.loc[transaction_df["type"] == "withdrawal", "amount"] > 0).sum()
np.int64(0)
Die Beträge aller withdrawal-Transaktionen wurden erfolgreich auf negative Werte gesetzt.
Eine anschliessende Prüfung zeigt, dass nun sämtliche amount
-Werte für Abbuchungen negativ sind.
Damit ist sichergestellt, dass Ein- und Ausgänge im Datensatz korrekt unterschieden werden – eine wichtige Voraussetzung für die spätere Analyse und Modellierung.
transaction_df['trans_id'].is_unique
True
- Die Spalte
trans_id
ist eindeutig und dient als Primärschlüssel.
EDA
transaction_df.describe(include='all')
trans_id | account_id | date | type | operation | amount | balance | k_symbol | |
---|---|---|---|---|---|---|---|---|
count | 1056320.00 | 1056320.00 | 1056320 | 1056320 | 1056320 | 1056320.00 | 1056320.00 | 1056320 |
unique | NaN | NaN | NaN | 2 | 6 | NaN | NaN | 8 |
top | NaN | NaN | NaN | withdrawal | withdrawal_in_cash | NaN | NaN | unknown |
freq | NaN | NaN | NaN | 651237 | 434918 | NaN | NaN | 535314 |
mean | 1335310.70 | 2936.87 | 1997-01-04 07:29:27.037261952 | NaN | NaN | 186.64 | 38518.33 | NaN |
min | 1.00 | 1.00 | 1993-01-01 00:00:00 | NaN | NaN | -87400.00 | -41125.70 | NaN |
25% | 430262.75 | 1204.00 | 1996-01-16 00:00:00 | NaN | NaN | -3019.00 | 22402.50 | NaN |
50% | 858506.50 | 2434.00 | 1997-04-10 00:00:00 | NaN | NaN | -14.60 | 33143.40 | NaN |
75% | 2060979.25 | 3660.00 | 1998-02-28 00:00:00 | NaN | NaN | 200.00 | 49603.62 | NaN |
max | 3682987.00 | 11382.00 | 1998-12-31 00:00:00 | NaN | NaN | 74812.00 | 209637.00 | NaN |
std | 1227486.51 | 2477.35 | NaN | NaN | NaN | 11213.53 | 22117.87 | NaN |
- Der Datensatz enthält 1.056.320 Transaktionen, alle wichtigen Spalten sind vollständig.
- In
amount
sind nun sowohl positive (Gutschrift) als auch negative Werte (Abbuchung) korrekt erfasst – bestätigt durch einen negativen Minimalwert (min: -87400). - Die Spalte date umfasst einen Zeitraum von 1993-01-01 bis 1998-12-31
- Die Kategorie "unknown" wurde erfolgreich eingeführt, um fehlende Angaben in k_symbol und operation konsistent zu behandeln.
# Aufteilung der Transaktionen in Einzahlungen (>0) und Abhebungen (<0)
deposits_df = transaction_df[transaction_df['amount'] > 0]
withdrawals_df = transaction_df[transaction_df['amount'] < 0]
# Subplot mit Boxplot und Histogramm für Einzahlungen
fig = make_subplots(rows=2, cols=1,
shared_xaxes=True,
row_heights=[0.2, 0.8],
vertical_spacing=0.02)
fig.add_trace(go.Box(x=deposits_df["amount"], name="Boxplot", marker_color="red", orientation='h'), row=1, col=1)
fig.add_trace(go.Histogram(x=deposits_df["amount"], nbinsx=100, name="Histogramm", marker_color="red"), row=2, col=1)
fig.update_layout(
height=600,
title_text="Deposits Distribution: Box Plot and Histogram",
showlegend=True,
xaxis_title="Transaction Amount (CZK)",
bargap=0.05
)
fig.show()
# Subplot mit Boxplot und Histogramm für Abhebungen (Beträge absolut)
fig = make_subplots(rows=2, cols=1,
shared_xaxes=True,
row_heights=[0.2, 0.8],
vertical_spacing=0.02)
fig.add_trace(go.Box(x=withdrawals_df["amount"].abs(), name="Boxplot", marker_color="blue", orientation='h'), row=1, col=1)
fig.add_trace(go.Histogram(x=withdrawals_df["amount"].abs(), nbinsx=100, name="Histogramm", marker_color="blue"), row=2, col=1)
fig.update_layout(
height=600,
title_text="Withdrawals Distribution: Box Plot and Histogram (Absolute Values)",
showlegend=True,
bargap=0.05
)
fig.update_xaxes(title_text="Withdrawal Amount (CZK, abs.)", row=1, col=1)
fig.update_xaxes(title_text="Withdrawal Amount (CZK, abs.)", row=2, col=1)
fig.update_xaxes(range=[0, withdrawals_df["amount"].abs().max()], row=1, col=1)
fig.update_xaxes(range=[0, withdrawals_df["amount"].abs().max()], row=2, col=1)
fig.show()