Titanic: Machine Learning from Disaster
Die Titanic, einst der Ingeneurstechnische Stolz einer ganzen Generation, als unsinkbar entworfen, sorgte für Entsetzen als diese bereits auf ihrer Jungfernfahrt nach einer Kollision mit einem Eisberg havarierte und sank. Bei diesem Vorfall starben viele hunderte Menschen, doch einige überlebten die Havarie. Ziel dieser Aufgabe ist es, anhand der Daten aus dem Unglück einen Entscheider zu entwickeln der bei einem definierten Set an Eingabevariablen berechnen soll, ob der betreffende Passagier überlebte oder nicht.
Hier wurde sich für einen Klassifikator nach dem Entscheidungsbaumprinzip entschieden. Um die Lösung möglichst gut abzubilden wurden die Entscheidungsbäume nach unterschiedlichen Algorithmen mit Zufallsparametern aufgebaut. Diese spannen einen Random-Forest auf, der Anschließend durch ein Dev-Test-Datenset verifiziert und aufgebessert wird.
Im Rahmen dieser Belegarbeit sollte ein Klassifikator implementiert werden, um an dem Wettbewerb Titanic: Machine Learning from Disaster von Kaggle teilzunehmen.
In [1]:
import pandas as pd
import numpy as np
from matplotlib import pyplot as plt
%matplotlib inline
from math import log2
from random import randint, choice
import warnings
warnings.filterwarnings('ignore')
import json
import pickle
from tqdm import tqdm
from time import strftime
Um den Algorithmus noch aufzubessern, wurde der Trainigsdatensatz in zwei Teile geteilt
In [2]:
# train_all = [train | dev_test]
train_all = pd.read_csv('./data/train.csv', index_col=0)
train = train_all[300:]
dev_test = train_all[:300]
# testdata -- needed to generate the sollution for the contest.
test = pd.read_csv('./data/test.csv', index_col=0)
final='Survived'
print(f"Size of the trainingsdata: {train.shape[0]}")
print(f"Size of the dev-test-data: {dev_test.shape[0]}")
Bei einer ersten Überprüfung fiel auf, dass es zum einen ein starkes Überlebensungleichgewicht in den Geschlechtern zu sehen ist.Außerdem sind die Überlebenden und die Gestorbenen nicht gleich verteilt. Ca. 62% haben die Havarie nicht überlebt.
Stellt man den Fahrpreis und das Alter des Fahrgastes gegenüber, ist eine starke Verdichtung in einem Bereich zu sehen, während die Dichte der daten in den oberen und rechten Rändern abfällt. Eine Warscheinlichkeit über den jeweiligen Ausgang der Fahrt lässt sich daraus aber nicht ableiten.
In [3]:
train_all[train_all['Survived'] == 1].shape[0] / train_all[train_all['Survived'] == 0].shape[0]
Out[3]:
In [4]:
fig, axes = plt.subplots(nrows=1, ncols=2)
fig.set_size_inches(16, 5)
ax = train.plot(kind='scatter', x='Age', color='Orange',
y='Fare', s=4*train['Survived'],
label='survived', ax=axes[0,])
ax = train.plot.scatter( x='Age', y='Fare',
s=4*(1-train['Survived']),
label='drown', ax=ax)
ax = train.groupby(['Sex', final]).size().unstack().plot(
kind='bar', ax=axes[1,])
In [5]:
def gini(S, A=None):
if A == None:
return 1-sum(
[(S[S[final] == o].size / S.size)**2 for
o in pd.unique(S[final])]
)
return sum( [ ( S[A][S[A] == p].size / S[A].size ) *
gini(S[S[A] == p]) for p in pd.unique(S[A])] )
In [6]:
def entropy(S, *args):
outcomes = pd.unique(S[final])
ent = lambda p: 0 if p == 0 else p * log2(p)
return -sum([ ent( S[S[final] == o].size / S.size )
for o in outcomes ])
In [7]:
def information(S, A):
partitions = pd.unique(S[A])
return sum([( S[A][S[A] == p].size / S[A].size ) *
entropy( S[S[A] == p]) for p in partitions])
In [8]:
def gain(S, A):
return entropy(S) - information(S, A)
In [9]:
def intrinsic_information(S, A):
return -sum([0 if S[A][S[A] == p].size == 0
else ( S[A][S[A] == p].size / S[A].size ) *
log2( S[A][S[A] == p].size / S[A].size )
for p in pd.unique(S[A])])
In [10]:
def gain_ratio(S, A):
# Question: how to make this less ugly
if intrinsic_information(S,A) == 0:
return 0
return gain(S,A) / intrinsic_information(S,A)
In [11]:
def status_title(data):
# Extract title from name.
# - The value is right of the comma and ends with a point
data['title'] = data['Name'].map(
lambda n: n.split(',')[1].split('. ')[0])
counts = data.title.value_counts()
# We group all titles if their occurancies is
# smaler then 10.
data['title'] = data['title'].map(
lambda t: t if counts.get(t) > 9 else 'other')
return data
ax = (status_title(train).groupby(['title', final])
.size()
.unstack()
.plot(kind='bar'))
In [12]:
def extract_deck(S):
S['deck'] = S['Cabin'].map(
lambda c: list(c)[0]
if not pd.isnull(c) else 'kA')
return S
ax = (extract_deck(train)
.groupby(['deck', final])
.size()
.unstack()
.plot(kind='bar')
)
ax = (extract_deck(train)[extract_deck(train)['deck'] != 'kA']
.groupby(['deck', final])
.size()
.unstack()
.plot(kind='bar')
)
In [13]:
def classified(S, A):
_d = S[ [final, A] ].sort_values(by=A).dropna()
_d[A] = _d[A].map(lambda a: int(a))
borders = [None, None, None, None]
borders[1] = gini_border(_d, A)
borders[0] = gini_border(_d[_d[A] < borders[1]], A)
borders[2] = gini_border(_d[_d[A] >= borders[1]], A)
borders[3] = gini_border(_d[_d[A] >= borders[2]], A)
ranges = [list(range(0, borders[0])),
list(range(borders[0]+1, borders[1])),
list(range(borders[1]+1, borders[2])),
list(range(borders[2]+1, borders[3])),
list(range(borders[3]+1, 333))]
cats = {str(i): '' if len(ranges[i]) <= 0 else
'%s,%s' % (ranges[i][0], ranges[i][-1])
for i in range(5)}
cats[''] = 'kA'
return S[A].map(
# - The comprehension should only return one or no value.
# - Using join to unpack the values - robust in handling
# empty lists.
lambda a: cats[''.join([str(i)
for i, ran in enumerate(ranges)
if a in ran] ) ] )
In [14]:
def gini_border(S, A, classes=None):
if classes == None:
classes = randint(2, int(log2(S.shape[0])))
rows = S.shape[0]
step = int(rows / classes)
candidates = [S.iloc[c][A] for
c in range(step, S.shape[0], step)]
final = 'Survived'
_A = list()
for c in candidates:
S['cclass'] = S[A].map(lambda a: int(a) < c)
_A.append(gini(S, 'cclass'))
return candidates[np.argmin(A)]
Um eine höhere Diversität in den Algorithmus zu bekommen, wurden die Klassengrößen des durch den Gini-Algorithmus untersuchten Klassen randomisiert. Somit gibt der Algorithmus jedesmal ein leicht abgeändertes Ergebnis aus.
Sowohl für Age
als auch für Fare
sind Plots zu sehen, die die Umverteilung der so ermittelten Klassengrenzen anzeigen.
In [15]:
def plot_classified_age(data):
fig, axes = plt.subplots(nrows=2, ncols=3)
fig.set_size_inches(16, 7)
for x in range(2):
for y in range(3) :
train['classified_age'] = classified(train, 'Age')
ax = (train.groupby(['classified_age', final])
.size()
.unstack()
.plot(kind='bar', ax=axes[x, y] ))
ax.set_xlabel('')
plt.show()
plot_classified_age(train)
In [16]:
def plot_classified_fare(data):
fig, axes = plt.subplots(nrows=2, ncols=3)
fig.set_size_inches(16, 7)
for x in range(2):
for y in range(3) :
data['classified_fare'] = classified(data.dropna(), 'Fare')
ax = (data.groupby(
['classified_fare', final])
.size()
.unstack()
.plot(kind='bar', ax=axes[x, y]))
plt.show()
plot_classified_fare(train)
In [17]:
ignore = ['Name', 'Ticket', 'Cabin', 'Fare', 'Age']
In [18]:
def process_datafitting(data):
data = status_title(data)
data = extract_deck(data)
data['classified_age'] = classified(data, 'Age')
data['classified_fare'] = classified(data, 'Fare')
# Easier calculating with sums over 1 and -1
data['Survived'] = data['Survived'].map(
lambda x: -1 if x == 0 else x)
return data.drop(ignore, axis=1)
data = process_datafitting(train)
In [19]:
# λ-function to execute a given mathematical function.
# and returning a dictionary dataset.
# - needs a function of the type f(S, A)
_exec = lambda f, data : {col: f(data, col)
for col in data.columns
if not col == final}
In der folgenden Tabelle ist eine Übersicht der Entscheidungsalgorithmen und der verschiedenen Klassen zu sehen. Es ist außerdem zu erkennen, dass sich die Entropie und Informationsverluste überall im erwarteten Rahmen bewegen. Es scheint kein stark overfittendes Element vorhanden zu sein.
In [20]:
desc_map = pd.DataFrame( dict(
gain = _exec(gain, data),
information = _exec(information, data),
gini = _exec(gini, data),
gain_ratio = _exec(gain_ratio, data),
intrinsic_information = _exec(intrinsic_information, data),
) )
desc_map
Out[20]:
In [21]:
{algo: desc_map[algo].argmin() for (algo) in desc_map.keys()}
Out[21]:
Im folgenden ist der Algorithmus für das erstellen eines Entscheidungsbaumes zu sehen.
Dieser lässt sich stark randomisieren, oder auf einen Algorithmus feststellen. Wird sich für den randomisierten Weg entschieden, so wird jede dritte Entscheidung außerdem per Zufall entschieden. Dies geschiet um eine möglichst hohe Diversität der Decisiontrees zu erhalten.
Der rekursive Ablauf hat folgende Abbruchbedingungen:
Die Rückgabe ist ein Dictionary, zur Darstellung der Baumstruktur.
Um die spätere Berechnung zu vereinfachen wurde das Feld Survived umcodiert. Ertrunken ist nun -1, überlebt ist 1. Fehlerfälle oder unklare Entscheidungen werden mit 0 codiert.
So kann der Entscheider vereinfacht werden. Bei der Entscheidung über die Bäume $t \in T$ reduziert sich die Abfrage auf: $$ x^{(m)} = \sum\limits_1^n t_n^{(m)}; \,\,\,\, \begin{cases} x^{(m)} \leq 0 & \mbox{Survived} = 0 \\ \mbox{sonst} & \mbox{Survived} = 1 \end{cases} $$
In [22]:
def rec_tree_farm(_df, algorithm=None):
"""Recursive spawning of a decisiontree.
_df: Fully classified and elaborate Dataframe.
algorithm: Mathematical function to determine decisions.
returns: dict with the fully spawned decisiontree.
"""
if algorithm == None:
## randomizer
alg = choice([gain, gain_ratio, gini,
information, intrinsic_information])
branches = pd.Series(_exec(alg, _df))
else:
branches = pd.Series(_exec(algorithm, _df))
# If there just one row, there is nothing more to descide
# This extraclause has to be because singleline
# Series do have no argmin.
if branches.shape[0] <= 1:
out = _df[final].unique()
return {'Survived':
int(out) if out.shape[0] <= 1
else int(out.sum()) }
# Getting the best result by argmin.
# Every third will be choosen by random
dice = 3
branch = (branches.argmin()
if randint(1,dice) != dice or not algorithm == None
else branches[
branches == choice(branches)].index[0])
if _df[final].unique().shape[0] <= 1:
out = _df[final].unique()
return {'Survived':
0 if out.shape[0] <= 0
else int(_df[final].unique()) }
# Here is the recursive call to build the tree.
# Getting the current branch with his subbranches ...
return {branch: {str(b):
# ... directing to the recursive calls ...
rec_tree_farm(
# ... if the rows inside our branch ...
_df[_df[branch] == b].drop(
# ... and drop the current branch...
[branch, ], axis=1), algorithm)
# ... of all subbranches of branch.
for b in _df[branch].unique()}}
In [23]:
from time import time
generate = False
size = 300
if generate:
a = time()
random_forrest = [rec_tree_farm(data) for _ in tqdm(range(size))]
print('Generated %s trees in %s secs.' % (size, time()-a) )
pickle.dump( random_forrest, open("randforrest_%s.p" % size, "wb" ) )
else:
random_forrest = pickle.load( open( "randforrest_300.p", "rb" ) )
Um Entscheidungen aus anderen Datensätzen abzuleiten müssen die Bäume traversiert werden.
Die folgenden Funktionen haben die Aufgabe die Entscheidungen so zu ermitteln. Da die Klassengrenzen in den oben genannten Algorithmen nicht fortlaufend waren müssen daten die "zwischen die Klassen fallen" einer Klasse zugeordnet werden. Dies übernimmt die Funktion re_classify mithilfe der Funktion find_nearest.
Die Funktion traverse_tree traversiert den Baum rekursiv und leitet daraus die Entscheidungen ab.
In [24]:
# The calssified data has to be rebuilt with the classboarders
classified_data = ['classified_age', 'classified_fare']
def find_nearest(array, value):
"""stolen from:
http://stackoverflow.com/questions/\
2566412/find-nearest-value-in-numpy-array"""
if array.shape[0] <= 1:
return array
idx = (np.abs(array-value)).argmin()
return array[idx]
def extract_borders(key, tree):
return ['kA' if c == 'kA' else c.split(',')
for c in tree[key].keys()]
def re_classify(passenger, borders, key):
class_map = {'classified_age': 'Age', 'classified_fare': 'Fare'}
continues_data = passenger[class_map[key]]
if list(borders) == ['kA']:
return 'kA'
borders = [b for b in borders if not b == 'kA']
lower_limits = np.array([int(a[0]) for a in borders])
upper_limits = np.array([int(a[1]) for a in borders])
lb = np.argwhere(
lower_limits == find_nearest(lower_limits, continues_data))
rb = np.argwhere(
upper_limits == find_nearest(upper_limits, continues_data))
if lb == rb:
return ','.join(borders[lb[0][0]])
else:
# draw a coin to decide the left or right border
if randint(0,1) == 0:
return ','.join(borders[lb[0][0]])
return ','.join(borders[rb[0][0]])
In [25]:
def traverse_tree(tree, passenger):
for key, value in tree.items():
if key == final:
return int(value)
if key in classified_data:
borders = extract_borders(key, tree)
passenger[key] = re_classify(passenger, borders, key)
try:
value[str(passenger[key])]
except KeyError:
continue
return traverse_tree(value[str(passenger[key])], passenger)
In [26]:
def _outcome(tree, passenger):
o = traverse_tree(tree, dict(passenger))
return 0 if o == None else o
def get_forcasts(trees, test_data):
ergs = pd.DataFrame([
# from all trees of the randomforest
# .. pointing all indizies to the outcome of the row
# .. in the descition-tree.
{idx: _outcome(tree, dict(row))
for idx, row in test_data.iterrows()}
for tree in trees]).T
# if the sum of the cols > 0 survived,
# else drown to death.
return pd.DataFrame({idx: 0 if sum(list(row)) < 0 else 1
for idx, row in ergs.iterrows()},
index=['results']).T.join(test_data)
def get_forecast_precision(trees, test_data):
df = get_forcasts(trees, test_data)
return df[
df['results'] == df['Survived']
].shape[0] / df.shape[0]
In [27]:
test_data = dev_test
test_data = status_title(test_data)
test_data = extract_deck(test_data)
In [28]:
df = get_forcasts(random_forrest, test_data)
In [29]:
get_forecast_precision([rec_tree_farm(data), ], test_data)
Out[29]:
In [30]:
precision = get_forecast_precision(random_forrest, test_data)
print("Presition of the prediction of the dataset: %s" % precision)
In [31]:
to_lumber = [i for i, tree in enumerate(random_forrest)
if get_forecast_precision([tree,], test_data) > 0.63]
good_trees = [tree
for i, tree in enumerate(random_forrest)
if i in to_lumber]
print("croped %s trees." % (len(random_forrest) - len(good_trees)))
Durch diese Transformation konnte die Genauigkeit auf folgendes Maß angehoben werden.
In [32]:
precision = get_forecast_precision(good_trees, test_data)
print("Presition of the prediction of the dataset: %s" % precision)
In [33]:
test_data = test
test_data = status_title(test_data)
test_data = extract_deck(test_data)
In [34]:
accurcy = precision
timestamp = strftime("%Y-%m-%d_%H-%M-%S")
def extract_forecast_table(trees, test_data):
return pd.DataFrame(
{idx: 0 if sum(list(row)) <= 0 else 1
for idx, row in pd.DataFrame([
{idx: _outcome(tree, dict(row))
for idx, row in test_data.iterrows()}
for tree in trees]).T.iterrows()},
index=['Survived']).T
my_solution = extract_forecast_table(good_trees, test_data)
my_solution['PassengerId'] = my_solution.index
my_solution.to_csv(f'solution_{accurcy}_{timestamp}.csv', index=False)
In [35]:
my_solution = extract_forecast_table(good_trees, test_data)
my_solution['PassengerId'] = my_solution.index