Titanic : Allons plus loin ! (Partie 2)

Pour faire suite à mon précédent article sur la prédiction des survivants du Titanic, il me parait important d’illustrer quelques autres techniques et par là même aller plus loin dans la modélisation. Cet article est donc dédié au travail sur les caractéristiques qui nous sont données. Comme d’habitude pour ce type de projet de Machine Learning j’utiliserai Python, Scikit-learn et Jupyter.

Travail préalable

  • Déclarer les librairies Python que nous allons utiliser (Pandas, RegEx, scikit-learn, etc.)
  • Importer/lire les fichiers d’entrainement et test dans des DataFrame Pandas
  • Créer un DataFrame complet (full) qui concatène les deux jeux de données précédents.
import pandas as pd
import re
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import LinearSVC
from sklearn.preprocessing import MinMaxScaler

train = pd.read_csv("./data/train.csv")
test = pd.read_csv("./data/test.csv")
full = pd.concat([train, test]) # Assemble les deux jeux de données

Feature ingeneering

L’objectif ce billet est de travailler sur les caractéristiques et non sur les algorithmes eux-même. Pour cela nous allons retravailler certaines données qui en l’état sont peu ou pas exploitable.

Ticket & Prix : Avez-vous remarqué que le tarif mentionné n’est pas le prix unitaire par personne ? et que la donnée Ticket n’est pas une clé unique ? Nous allons devoir corriger celà en calculant le prix unitaire par personne et modifier les prix (Fare) faux (c’est à dire les billets groupés)

Passager sans prix : C’est une erreur dans les données. En effet un passager n’a pas de prix (Fare). Nous allons devoir lui en attribuer un afin de conserver cette donnée.

Le nom de famille : Ce n’est pas une information directement fournie, il va donc  falloir « parser » la chaine Name pour la récupérer correctement. Ensuite on regroupera les données (train & test) par nom de famille et on comptera le nombre de personnes par famille. Fort de ce constat, il sera interressant de fair en encodage one-hot sur les familles de plus de 2, 3 personnes.

Le titre : Comme le nom de famille il faut récupérer cette information à partir de la colonne Name. Ensuite il sera interressant de re-catégoriser ces titre pour par exemple avoir 3 catégories finales : Femmes & enfants, adultes et VIP.

l’Age : l’age est une information interressante en soi mais créer des catégories d’age en complément l’est encore plus.

Nous nous arreterons là pour cet article, mais il reste encore bien d’autres pistes d’amélioration.

Ticket & Prix

Tout d’abord regardons la caractéristique Ticket. Vérifions que tous les passagers ont bien un ticket :

noticket = []
full['Ticket'].fillna('X')
for ticketnn in full['Ticket']:
    if (ticketnn == 'X'):
        noticket.append(1)
    else:
        noticket.append(0)
pd.DataFrame(noticket)[0].value_counts()

Bonne nouvelle, tous les passagers possèdent cette information !

test['Ticket'].value_counts().head()

Regardons maintenant les valeurs distinctes de ces Tickets :

PC 17608      5
113503        4
CA. 2343      4
C.A. 31029    3
347077        3
Name: Ticket, dtype: int64

Interressant, les valeurs ne sont pas uniques comme nous aurions pu le penser. Celà est en fait du que l’on pouvait avoir des tickets groupés (plusieurs personnes avec le même ticket). Clà change beaucoup de choses car si le Ticket pouvait être groupé, le prix aussi donc.

Il va donc falloir diviser le prix du Ticket par le nombre de personnes ayant le même ticket !

Calcul du prix unitaire du billet

Pour ce faire nous allons utiliser les capacités de Pandas a effectuer des jointures (gauche) entres DataFrame. Au préalable nous allons constituer un DataFrame qui regroupe les Tickets avec leur nombre d’occurences : TicketCounts. Ensuite nous ferons une jointure gauche entre le jeu de données et ce nouveau DataFrame. Nous n’aurons ensuite plus qu’à ajouter une colonne PrixUnitaire qui divise le prix total par le nombre de personne sur le Ticket. Attention ici de bien utiliser la fonction fillna() sur le nombre de ticket.

# Prépartion d'un DF (TicketCounts) contenant les ticket avec leur nb d'occurence
TicketCounts = pd.DataFrame(test['Ticket'].value_counts().head())
TicketCounts['TicketCount'] = TicketCounts['Ticket'] # renomme la colonne Ticket
TicketCounts['Ticket'] = TicketCounts.index # rajoute une colonne Ticket pour le merge (jointure)

# Reporte le résultat dans le dataframe test (jointure des datasets)
fin = pd.merge(test, TicketCounts, how='left', on='Ticket')
fin['PrixUnitaire'] = fin['Fare'] / fin['TicketCount'].fillna(1)

Passager sans Prix !

Attention, car nous avons aussi un passager qui n’a pas de Prix. Regardons de qui il s’agit :

import numpy as np
test.loc[np.isnan(test['Fare'])]

Il s’agit d’un passager de 3ème classe, calculons donc le prix moyen de ce type de billet:

test.loc[test['Pclass'] == 3]['Fare'].mean()
12.459677880184334

Nous affecterons ce prix à ce passager.

Le Nom de famille

Le nom de famille n’est pas immédiatement utilisable. Il faut l’extraire de la caracéristique Name qui contient d’autres informations comme le titre. Utilisons pour celà les RegEx :

familynames = []
for noms in full["Name"]:
    familynames.append(re.search('([A-Za-z0-9]*),\ ([A-Za-z0-9 ]*)\. (.*)', noms).group(1))
pdfamilynames = pd.DataFrame(familynames, columns = ['familynames'])

L’idée est maintenant de faire un encodage one-hot avec le nom de famille. Ça peut paraitre un peu fou mais nous avons peu de données et certains noms de famille apparaissent dans les deux jeux de données justement.
Nous allons tout d’abord créer un DataFrame avec les noms de famille apparaissant 2 fois ou plus :

# Créé une liste des noms de famille avec plus de 2 occurences
famsurv = full.join(pdfamilynames)
famCount = famsurv['familynames'].value_counts()
pdfamCounts = pd.DataFrame(famCount, columns = ['familynames'])
pdfamCounts['famCount'] = pdfamCounts['familynames']
pdfamCounts['familynames'] = pdfamCounts.index
pdfamCounts[pdfamCounts['famCount'] >= 2]

Ce DataFrame pourra être ensuite utilisé au travers d’une fonction pour rajouter les colonnes dummies (one-hot) :

# Fonction ajoutant les colonnes noms famille dans un DF
def addColumnFamilyName(data):
    # ajoute les colonnes nulles avec les noms de famille
    for family in pdfamCounts['familynames']:
        data[family] = 0
    # récupère le nom de famille dans le DF
    for idx, f in enumerate(data["Name"]):
        # Modifie les colonnes dummies du nom de famille en 1 ou 0 selon le nom de famille
        iNom = re.search('([A-Za-z0-9]*),\ ([A-Za-z0-9 ]*)\. (.*)', f).group(1)
        for col in data.columns:
            if (col == iNom):
                data.loc[idx, col] = 1

Nous utiliserons cette fonction lors de la préparation des données (plus loin).

Le Titre

De la même manière que le nom de famille, nous devons extraire le titre en parsant la caractéristique Name. Regardons les titres sur l’ensemble du jeu de données (full) :

full['Titre'] = full.Name.map(lambda x : x.split(",")[1].split(".")[0])
full['NomFamille'] = full.Name.map(lambda x : x.split(",")[0])
titre = pd.DataFrame(full['Titre'])
full['Titre'].value_counts() # affiche tous les titres possible

Voici les possibilités que nous allons traiter :


 Mr              757
 Miss            260
 Mrs             197
 Master           61
 Dr                8
 Rev               8
 Col               4
 Ms                2
 Mlle              2
 Major             2
 Mme               1
 Lady              1
 Capt              1
 Don               1
 Jonkheer          1
 Sir               1
 the Countess      1
 Dona              1
Name: Titre, dtype: int64

Pour les titre nous allons créer des catégories que nous encoderons (one-hot) ensuite. Normalement la consigne les femmes et les enfants a dû être respectée, mais a mon avis les personnes de rangs ont aussi été privilégiées. Créons donc 3 catégories : Femme et enfant, VIP et les autres :

X = test
X['Rang'] = 0
X['Titre'] = X.Name.map(lambda x : x.split(",")[1].split(".")[0])
vip = ['Don','Sir', 'Major', 'Col', 'Jonkheer', 'Dr', 'Rev']
femmeenfant = ['Miss', 'Mrs', 'Lady', 'Mlle', 'the Countess', 'Ms', 'Mme', 'Dona', 'Master']
for idx, titre in enumerate(X['Titre']):
    if (titre.strip() in femmeenfant) :
        X.loc[idx, 'Rang'] = 'FE'
    elif (titre.strip() in vip) :
        X.loc[idx, 'Rang'] = 'VIP'
    else :
        X.loc[idx, 'Rang'] = 'Autres'
X['Rang'].value_counts()

L’age

Nous allons créer là aussi plusieurs catégories d’age selon la variable Age:

  • Les bébés : de 0 a 3 ans
  • Les enfants: de 3 à 15 ans
  • Les adultes de 15 à 60 ans
  • Les « vieux » de plus de 60 ans
age = X['Age'].fillna(X['Age'].mean())
catAge = []
for i in range(X.shape[0]) :
    if age[i] <= 3:
        catAge.append("bebe")
    elif age[i] > 3 and age[i] >= 15:
        catAge.append("enfant")
    elif age[i] > 15 and age[i] <= 60:
        catAge.append("adulte")
    else:
        catAge.append("vieux")
print(pd.DataFrame(catAge, columns = ['catAge'])['catAge'].value_counts())
cat = pd.get_dummies(pd.DataFrame(catAge, columns = ['catAge']), prefix='catAge')
cat.head(3)

Jetons un coup d'oeil au résultat :

adulte    373
enfant     21
vieux      14
bebe       10
Name: catAge, dtype: int64

Fonction globale de préparation/modèle

Regroupons maintenant tous ces éléments dans une fonction de préparation :

def dataprep(data):
    # Sexe
    sexe = pd.get_dummies(data['Sex'], prefix='sex')

    # Cabine, récupération du pont (on remplace le pont T proche du pont A)
    cabin = pd.get_dummies(data['Cabin'].fillna('X').str[0].replace('T', 'A'), prefix='Cabin')

    # Age et catégories d'age
    age = data['Age'].fillna(data['Age'].mean())
    catAge = []
    for i in range(data.shape[0]) :
        if age[i] > 3:
            catAge.append("bebe")
        elif age[i] >= 3 and age[i] < 15:
            catAge.append("enfant")
        elif age[i] >= 15 and age[i] < 60:
            catAge.append("adulte")
        else:
            catAge.append("vieux")
    catage = pd.get_dummies(pd.DataFrame(catAge, columns = ['catAge']), prefix='catAge')

    # Titre et Rang
    data['Titre'] = data.Name.map(lambda x : x.split(",")[1].split(".")[0]).fillna('X')
    data['Rang'] = 0
    vip = ['Don','Sir', 'Major', 'Col', 'Jonkheer', 'Dr']
    femmeenfant = ['Miss', 'Mrs', 'Lady', 'Mlle', 'the Countess', 'Ms', 'Mme', 'Dona', 'Master']
    for idx, titre in enumerate(data['Titre']):
        if (titre.strip() in femmeenfant) :
            data.loc[idx, 'Rang'] = 'FE'
        elif (titre.strip() in vip) :
            data.loc[idx, 'Rang'] = 'VIP'
        else :
            data.loc[idx, 'Rang'] = 'Autres'
    rg = pd.get_dummies(data['Rang'], prefix='Rang')

    # Embarquement
    emb = pd.get_dummies(data['Embarked'], prefix='emb')

    # Prix unitaire - Ticket, Prépartion d'un DF (TicketCounts) contenant les ticket avec leur nb d'occurence
    TicketCounts = pd.DataFrame(data['Ticket'].value_counts())
    TicketCounts['TicketCount'] = TicketCounts['Ticket'] # renomme la colonne Ticket
    TicketCounts['Ticket'] = TicketCounts.index # rajoute une colonne Ticket pour le merge (jointure)
    # reporte le résultat dans le dataframe test (jointure des datasets)
    fin = pd.merge(data, TicketCounts, how='left', on='Ticket')
    fin['PrixUnitaire'] = fin['Fare'] / fin['TicketCount'].fillna(1)
    prxunit = pd.DataFrame(fin['PrixUnitaire'])
    # Prix moyen 3eme classe (pour le passager de 3eme qui n'a pas de prix) ... on aurait pu faire une fonction ici ;-)
    prx3eme = data.loc[data['Pclass'] == 3]['Fare'].mean()
    prxunit = prxunit['PrixUnitaire'].fillna(prx3eme)

    # Classe
    pc = pd.DataFrame(MinMaxScaler().fit_transform(data[['Pclass']]), columns = ['Classe'])

    dp = data[['SibSp', 'Parch', 'Name']].join(pc).join(sexe).join(emb).join(prxunit).join(cabin).join(age).join(catage).join(rg)
    addColumnFamilyName(dp)
    del dp['Name']

    return dp

Entrainons le modèle

Xtrain = dataprep(train)
Xtest = dataprep(test)

y = train.Survived
clf = LinearSVC(random_state=4)
clf.fit(Xtrain, y)
p_tr = clf.predict(Xtrain)
print ("Score Train : ", round(clf.score(Xtrain, y) *100,4), " %")

Nous obtenons ainsi un très (trop !?) beau 98% (sur les données d'entrainement). Sur les données de test nous aurons un raisonnable 76,5% !

Partager cet article

4 Replies to “Titanic : Allons plus loin ! (Partie 2)”

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.