Skip to main content

Titanic Kaggle challenge

·12 mins

Here is my first kernel along with my very first attempt at the Titanic challenge (newbie still learning …). The goal is to achieve a binary classification in order to predict whether a passenger survived the Titanic sinking (variable ‘Survived’). Read on Kaggle.

Data import & wrangling #

Titanic training dataset overview

PassengerIdSurvivedPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
103Braund, Mr. Owen Harrismale2210A/5 211717.2500NAS
211Cumings, Mrs. John Bradley (Florence Briggs Thayer)female3810PC 1759971.2833C85C
313Heikkinen, Miss. Lainafemale2600STON/O2. 31012827.9250NAS
411Futrelle, Mrs. Jacques Heath (Lily May Peel)female351011380353.1000C123S
503Allen, Mr. William Henrymale35003734508.0500NAS
603Moran, Mr. JamesmaleNA003308778.4583NAQ
701McCarthy, Mr. Timothy Jmale54001746351.8625E46S
803Palsson, Master. Gosta Leonardmale23134990921.0750NAS
913Johnson, Mrs. Oscar W (Elisabeth Vilhelmina Berg)female270234774211.1333NAS
1012Nasser, Mrs. Nicholas (Adele Achem)female141023773630.0708NAC

At first sight

  • Some variables are qualitative: Pclass (1, 2 or 3), Sex (male or female) and Embarked (C, Q or S).
  • Some variables are quantitative: Age, SibSp, Parch and Fare.
  • Some variables contain missing values.
  • Within the ‘Name’ variable, the passenger’s title (Miss., Master., Captain.) could be useful information for the predictions.

Missing value rate within variables

map_dbl(titanicTrainingDataset, function(x) mean(is.na(x)))
## PassengerId    Survived      Pclass        Name         Sex         Age
## 0.000000000 0.000000000 0.000000000 0.000000000 0.000000000 0.198653199
##       SibSp       Parch      Ticket        Fare       Cabin    Embarked
## 0.000000000 0.000000000 0.000000000 0.000000000 0.771043771 0.002244669

The Age variable contains 19.86% missing values and the Cabin one contains 77.1%. I will not use the Cabin variable as there are too many missing values.

Import the Titanic training dataset with proper col types for categorical variables

titanicTrainingDataset <- read_csv(
  trainFile,
  col_types = cols(
    Survived = col_factor(0:1),
    Pclass = col_factor(1:3),
    Sex = col_factor(c("male", "female")),
    Embarked = col_factor(c("C", "Q", "S"))
  )
) %>%
  filter(!is.na(Embarked)) %>%
  mutate(Age = ifelse(is.na(Age), median(Age, na.rm = T), Age))

I filled the blank values for the Age variable with the median age and excluded the rows that have no value for ‘Embarked’.

Extract the passengers’ titles from their names into a new dedicated variable

titanicTrainingDataset <- titanicTrainingDataset %>%
  mutate(Title = as.factor(str_extract(Name, regex("([a-z]+\\.)", ignore_case = T))))

levels(titanicTrainingDataset$Title)
##  [1] "Capt."     "Col."      "Countess." "Don."      "Dr."
##  [6] "Jonkheer." "Lady."     "Major."    "Master."   "Miss."
## [11] "Mlle."     "Mme."      "Mr."       "Mrs."      "Ms."
## [16] "Rev."      "Sir."

17 distinct titles have been extracted from the ‘Name’ variable.

Exploratory Data Analysis #

summary(titanicTrainingDataset)
##   PassengerId  Survived Pclass      Name               Sex
##  Min.   :  1   0:549    1:214   Length:889         male  :577
##  1st Qu.:224   1:340    2:184   Class :character   female:312
##  Median :446            3:491   Mode  :character
##  Mean   :446
##  3rd Qu.:668
##  Max.   :891
##
##       Age            SibSp            Parch           Ticket
##  Min.   : 0.42   Min.   :0.0000   Min.   :0.0000   Length:889
##  1st Qu.:22.00   1st Qu.:0.0000   1st Qu.:0.0000   Class :character
##  Median :28.00   Median :0.0000   Median :0.0000   Mode  :character
##  Mean   :29.32   Mean   :0.5242   Mean   :0.3825
##  3rd Qu.:35.00   3rd Qu.:1.0000   3rd Qu.:0.0000
##  Max.   :80.00   Max.   :8.0000   Max.   :6.0000
##
##       Fare            Cabin           Embarked     Title
##  Min.   :  0.000   Length:889         C:168    Mr.    :517
##  1st Qu.:  7.896   Class :character   Q: 77    Miss.  :181
##  Median : 14.454   Mode  :character   S:644    Mrs.   :124
##  Mean   : 32.097                               Master.: 40
##  3rd Qu.: 31.000                               Dr.    :  7
##  Max.   :512.329                               Rev.   :  6
##                                                (Other): 14
titanicTrainingDataset %>%
  ggplot(aes(x = Survived)) +
  geom_bar()

The following function will help me to visualize the response ‘Survived’ against each qualitative or quantitative potential predictor.

analysePredictorResponse <- function(data, predictor, response) {
  if (is.factor(data[[predictor]])) {
      ggplot(mapping = aes(data[[predictor]], fill = data[[response]])) +
        geom_bar() +
        labs(title = paste(predictor, "vs", response), x = predictor, fill = response)
  } else {
    chart1 <- ggplot(mapping = aes(data[[response]], data[[predictor]])) +
      geom_boxplot() +
      labs(title = paste(predictor, "vs", response), x = response, y = predictor)

    chart2 <- ggplot(mapping = aes(x = data[[predictor]], , y = ..density.., colour = data[[response]])) +
      geom_freqpoly(position = "dodge") +
      labs(title = paste(predictor, "vs", response), colour = response, x = predictor)

    grid.arrange(chart1, chart2)
  }
}

Age vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Age', 'Survived')

Sex vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Sex', 'Survived')

titanicTrainingDataset %>%
  group_by(Sex) %>%
  summarize(SurvivedRatio = mean(Survived == 1))
## # A tibble: 2 x 2
##   Sex    SurvivedRatio
##   <fct>          <dbl>
## 1 male           0.189
## 2 female         0.740

It seems obvious that females have significantly more chance of surviving than males.

Pclass vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Pclass', 'Survived')

titanicTrainingDataset %>%
  group_by(Pclass) %>%
  summarize(SurvivedRatio = mean(Survived == 1))
## # A tibble: 3 x 2
##   Pclass SurvivedRatio
##   <fct>          <dbl>
## 1 1              0.626
## 2 2              0.473
## 3 3              0.242

The wealthier the class the passenger belongs to, the higher their likelihood of surviving.

SibSp vs Survived

analysePredictorResponse(titanicTrainingDataset, 'SibSp', 'Survived')

Those who have no sibling seem to have a higher chance of dying than those who have one sibling on board.

Parch vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Parch', 'Survived')

Similarly to SibSp, those who have no parents or children on board seem to have a higher chance of dying than those who have one parent or one child.

Fare vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Fare', 'Survived')

On average, the higher the fare, the higher the likelihood of surviving seems to be.

Embarked vs Survived

analysePredictorResponse(titanicTrainingDataset, 'Embarked', 'Survived')

titanicTrainingDataset %>%
  group_by(Embarked) %>%
  summarize(SurvivedRatio = mean(Survived == 1))
## # A tibble: 3 x 2
##   Embarked SurvivedRatio
##   <fct>            <dbl>
## 1 C                0.554
## 2 Q                0.390
## 3 S                0.337

It seems there are some significant differences regarding the Survived rate depending on the port of embarkation. Port C leads to a 55.36% survival rate whereas port S leads to only 33.69%.

Pclass vs Fare

titanicTrainingDataset %>%
  ggplot(mapping = aes(Pclass, Fare)) +
  geom_boxplot()

Embarked vs Pclass

titanicTrainingDataset %>%
  ggplot(mapping = aes(Embarked, fill = Pclass)) +
  geom_bar()

What about the variable Title that I have extracted from the names?

titanicTrainingDataset %>%
  group_by(Title) %>%
  summarize(SurvivedRatio = mean(Survived == 1)) %>%
  arrange(SurvivedRatio) %>%
  mutate(Title = factor(Title, levels = Title)) %>%
  ggplot(aes(x = Title, y = SurvivedRatio)) +
  geom_col() +
  coord_flip()

The passengers’ title seems to provide interesting information for predicting the survivors. It seems we can refine this variable in order to reduce the number of levels. I added a new categorical variable ‘RefinedTitle’ that splits the passengers’ titles into 3 relevant levels:

titanicTrainingDataset <- titanicTrainingDataset %>% mutate(
  RefinedTitle = factor(ifelse(Title %in% c('Capt.', 'Don', 'Jonkheer.', 'Rev.', 'Mr.'), 1,
    ifelse(Title %in% c('Col.', 'Dr.', 'Major.', 'Master.'), 2, 3
    )
  ))
)

Modeling #

Logistic regression #

Here is a first attempt at modeling on the training dataset with most of the candidate predictors:

model <- glm(Survived ~ RefinedTitle + Pclass + SibSp + Embarked + Age + Sex + Parch, data = titanicTrainingDataset %>% filter(!is.na(Age)), family = binomial)
summary(model)
##
## Call:
## glm(formula = Survived ~ RefinedTitle + Pclass + SibSp + Embarked +
##     Age + Sex + Parch, family = binomial, data = titanicTrainingDataset %>%
##     filter(!is.na(Age)))
##
## Deviance Residuals:
##     Min       1Q   Median       3Q      Max
## -2.3632  -0.5951  -0.3917   0.5812   2.5346
##
## Coefficients:
##                Estimate Std. Error z value Pr(>|z|)
## (Intercept)    0.930213   0.393458   2.364 0.018069 *
## RefinedTitle2  2.556167   0.426595   5.992 2.07e-09 ***
## RefinedTitle3  0.473372   1.439032   0.329 0.742192
## Pclass2       -1.032744   0.282392  -3.657 0.000255 ***
## Pclass3       -2.163955   0.262944  -8.230  < 2e-16 ***
## SibSp         -0.452722   0.114388  -3.958 7.56e-05 ***
## EmbarkedQ     -0.274539   0.392170  -0.700 0.483895
## EmbarkedS     -0.484066   0.244469  -1.980 0.047696 *
## Age           -0.027045   0.008209  -3.294 0.000986 ***
## Sexfemale      2.672175   1.448318   1.845 0.065035 .
## Parch         -0.189686   0.122467  -1.549 0.121413
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
##     Null deviance: 1182.8  on 888  degrees of freedom
## Residual deviance:  745.7  on 878  degrees of freedom
## AIC: 767.7
##
## Number of Fisher Scoring iterations: 5

Parch and Embarked don’t seem to be relevant according to the P-value associated with the Z-statistic. Also, Sex seems to be confounded with RefinedTitle for predicting ‘Survived’. Here is a refined model without the Parch, Embarked and Sex variables:

model <- glm(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset, family = binomial)
summary(model)
##
## Call:
## glm(formula = Survived ~ RefinedTitle + Pclass + SibSp + Age,
##     family = binomial, data = titanicTrainingDataset)
##
## Deviance Residuals:
##     Min       1Q   Median       3Q      Max
## -2.5200  -0.5798  -0.3934   0.5919   2.6299
##
## Coefficients:
##                Estimate Std. Error z value Pr(>|z|)
## (Intercept)    0.646841   0.368287   1.756 0.079029 .
## RefinedTitle2  2.484859   0.415967   5.974 2.32e-09 ***
## RefinedTitle3  3.066697   0.207374  14.788  < 2e-16 ***
## Pclass2       -1.153534   0.271979  -4.241 2.22e-05 ***
## Pclass3       -2.227890   0.250689  -8.887  < 2e-16 ***
## SibSp         -0.524237   0.110349  -4.751 2.03e-06 ***
## Age           -0.028450   0.008128  -3.500 0.000465 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
##     Null deviance: 1182.82  on 888  degrees of freedom
## Residual deviance:  754.59  on 882  degrees of freedom
## AIC: 768.59
##
## Number of Fisher Scoring iterations: 5

All the coefficient estimates for predictors are now statistically significant according to their P-Values.

Estimate the test error rate of the logistic regression with LOOCV #

Here is a “leave-one-out” cross validation performed over several decision boundary values (from 0.1 to 0.9) in order to find the value that minimizes the simulated test error rate with the training dataset. For each value, it will display the estimated error rate along with the confusion matrix.

for (j in seq(.1, .9, .1)) {
  predictions <- rep(0, nrow(titanicTrainingDataset))
  for (i in 1:nrow(titanicTrainingDataset)) {
    model <- glm(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset, family = binomial, subset = -i)
    predictions[i] <- predict(model, titanicTrainingDataset[i,], type = "response") > j
  }

  print(paste("Decision boundary value :", j))
  print(table(predictions, titanicTrainingDataset$Survived))
  print(mean(predictions != titanicTrainingDataset$Survived))
}
## [1] "Decision boundary value : 0.1"
##
## predictions   0   1
##           0 250  33
##           1 299 307
## [1] 0.3734533
## [1] "Decision boundary value : 0.2"
##
## predictions   0   1
##           0 346  44
##           1 203 296
## [1] 0.2778403
## [1] "Decision boundary value : 0.3"
##
## predictions   0   1
##           0 423  57
##           1 126 283
## [1] 0.2058493
## [1] "Decision boundary value : 0.4"
##
## predictions   0   1
##           0 443  68
##           1 106 272
## [1] 0.1957255
## [1] "Decision boundary value : 0.5"
##
## predictions   0   1
##           0 482  92
##           1  67 248
## [1] 0.1788526
## [1] "Decision boundary value : 0.6"
##
## predictions   0   1
##           0 501 110
##           1  48 230
## [1] 0.1777278
## [1] "Decision boundary value : 0.7"
##
## predictions   0   1
##           0 523 152
##           1  26 188
## [1] 0.200225
## [1] "Decision boundary value : 0.8"
##
## predictions   0   1
##           0 538 198
##           1  11 142
## [1] 0.2350956
## [1] "Decision boundary value : 0.9"
##
## predictions   0   1
##           0 544 276
##           1   5  64
## [1] 0.3160855

The estimated test error rate seems to be minimized with a decision boundary value of 0.6, resulting in a test error rate of 17.77%.

Use the logistic regression model to predict the Survived variable with the test dataset #

model <- glm(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset, family = binomial)

titanicTestDataset <- read_csv(
  testFile,
  col_types = cols(
    Pclass = col_factor(1:3),
    Sex = col_factor(c("male", "female")),
    Embarked = col_factor(c("C", "Q", "S"))
  )
) %>%
  mutate(Title = as.factor(str_extract(Name, regex("([a-z]+\\.)", ignore_case = T)))) %>%
  mutate(
    RefinedTitle = factor(ifelse(Title %in% c('Capt.', 'Don', 'Jonkheer.', 'Rev.', 'Mr.'), 1,
      ifelse(Title %in% c('Col.', 'Dr.', 'Major.', 'Master.'), 2, 3
      )
    ))
  ) %>%
  mutate(Age = ifelse(is.na(Age), median(Age, na.rm = T), Age))

predictions <- predict(model, titanicTestDataset, type = "response") > .6

tibble(PassengerId = titanicTestDataset$PassengerId, Survived = as.integer(predictions)) %>%
  write_csv('predictions-logistic-regression.csv')

The Kaggle score is 77.5% with this model.

Quadratic Discriminant Analysis #

model <- qda(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset, family = binomial)
model
## Call:
## qda(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset,
##     family = binomial)
##
## Prior probabilities of groups:
##         0         1
## 0.6175478 0.3824522
##
## Group means:
##   RefinedTitle2 RefinedTitle3   Pclass2   Pclass3     SibSp      Age
## 0    0.04189435     0.1493625 0.1766849 0.6775956 0.5537341 30.02823
## 1    0.08235294     0.6794118 0.2558824 0.3500000 0.4764706 28.16374

LOOCV on the QDA model #

predictions <- factor(rep(0, nrow(titanicTrainingDataset)), levels = 0:1)
for (i in 1:nrow(titanicTrainingDataset)) {
  model <- qda(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset, subset = -i)
  predictions[i] <- predict(model, titanicTrainingDataset[i,])$class
}

table(predictions, titanicTrainingDataset$Survived)
##
## predictions   0   1
##           0 451  78
##           1  98 262
mean(predictions != titanicTrainingDataset$Survived)
## [1] 0.1979753

The estimated test error rate is 19.79% with the Quadratic Discriminant Analysis model.

Use the QDA model to predict the Survived variable with the test dataset from Kaggle #

model <- qda(Survived ~ RefinedTitle + Pclass + SibSp + Age, data = titanicTrainingDataset)
predictions <- predict(model, titanicTestDataset)$class

tibble(PassengerId = titanicTestDataset$PassengerId, Survived = predictions) %>%
  write_csv('predictions-qda.csv')

The Kaggle score is 77.5%, almost like the logistic regression.

K-nearest neighbors #

Let’s keep the same set of predictors that seem to be relevant to predict the response, and use the KNN algorithm with them. First, I evaluate the best K value with a Validation Set approach, by splitting the training dataset into a training and a test dataset:

set.seed(1)
titanicTrainingDatasetKnn <- titanicTrainingDataset
titanicTrainingDatasetKnn$RefinedTitle = as.integer(titanicTrainingDatasetKnn$RefinedTitle)
titanicTrainingDatasetKnn$Pclass = as.integer(titanicTrainingDatasetKnn$Pclass)

testSampleSize <- 150
isTest <- sample(nrow(titanicTrainingDataset), testSampleSize)

knnTrainDataset <- titanicTrainingDatasetKnn[-isTest,] %>%
  subset(select = c(RefinedTitle, Pclass, SibSp, Age)) %>%
  scale()

knnTestDataset <- titanicTrainingDatasetKnn[isTest,] %>%
  subset(select = c(RefinedTitle, Pclass, SibSp, Age)) %>%
  scale()

cl <- titanicTrainingDatasetKnn[-isTest,]$Survived

errorsRate <- rep(0, testSampleSize)
for (k in 1:testSampleSize) {
  predictions <- knn(knnTrainDataset, knnTestDataset, cl, k)
  errorsRate[k] = mean(predictions != titanicTrainingDatasetKnn[isTest,]$Survived)
}

tibble(k = 1:testSampleSize, errorsRate = errorsRate) %>%
  ggplot(aes(x = k, y = errorsRate)) +
  geom_path()

I choose k = 35 to minimize the test error rate.

Use KNN (K = 35) to predict the response with the test dataset #

titanicTestDatasetKnn <- titanicTestDataset %>%
  mutate(RefinedTitle = as.integer(RefinedTitle)) %>%
  mutate(Pclass = as.integer(Pclass))

knnTrainDataset <- titanicTrainingDatasetKnn %>%
  subset(select = c(RefinedTitle, Pclass, SibSp, Age)) %>%
  scale()

knnTestDataset <- titanicTestDatasetKnn %>%
  subset(select = c(RefinedTitle, Pclass, SibSp, Age)) %>%
  scale()

cl <- titanicTrainingDatasetKnn$Survived

predictions <- knn(knnTrainDataset, knnTestDataset, cl, 35)

tibble(PassengerId = titanicTestDataset$PassengerId, Survived = predictions) %>%
  write_csv('predictions-knn.csv')

The Kaggle score is 78.94%.