Kaggle: Titanic

最近,因为感觉好久没有写代码,面试的时候表现也不佳,所以打算拿Kaggle练练手,积累一些项目经验。因为Titanic是Kaggle的第一个项目,所以决定从这个开始入手。

以下是基本流程:

  1. 观察并预处理数据
  2. 建立baseline模型
  3. 根据baseline模型的结果增加新的特征并预处理数据
  4. 建立改进后的模型,并采用不同模型通过投票来决定最后结果

观察并预处理数据

  1. 观察数据主要包括观察数据的类型,确认是连续型变量还是离散型变量,了解各个特征的数量分布、不同特征与存活率之间的关系,根据观察的结果选择想要采用的特征
  2. 预处理数据主要包括找到缺失值并根据需要进行处理(删除对应record或者进行估计),利用one-hot来将处理离散型变量,对连续型变量则进行scaling来加快模型收敛。
  3. 用同样的方式对test数据集进行预处理

观察数据

输入如下命令:

1
2
3
4
train.head(5)
train.info()
train.describe()
train.describe(include=['O'])

以上命令分别打印出训练数据集的前五行,以及其他相关数据信息包括数据缺失情况、数据类型、数据分布等等。
经过观察,可以确认:

  1. 主要的数据缺失集中在Age和Cabin这两个特征
  2. 特征中既有连续变量也有离散变量
  3. 离散变量中有部分变量的频数非常低,比如Cabin、Ticket以及Name,其中最高频数只有7

根据以上观察,频数非常低的三个离散变量暂时被放弃,以后模型改进再做考虑。

接下来需要观察各个特征与存活率之间的关系,例如年龄与生存人数之间的关系:

1
2
grid = sns.FacetGrid(train, col = 'Survived')
grid.map(plt.hist, 'Age', bins=20)

其余图片可在具体notebook中找到。

从数据和图表中可以明显看出部分特征与存活率之间有明显关系,比如Pclass这个特征,Pclass=1的存活率是最高的,随着船舱等级下降(Pclass数值从1变化到3),存活率也在下降,符合事实(有钱人更有几率存活下来)。再比如Sex这个特征,女性的存活率为74.2%,而男性的存活率仅为18.9%,这一点也符合事实,通过这些观察,可以确认这些特征的确有其实际意义也的确与存活率相关。

预处理数据

数据的预处理主要包括三个部分:

  1. 处理缺失数据
  2. 对离散变量进行处理
  3. 对连续变量进行处理

处理缺失数据

存在却是数据的主要包括三个特征:‘Age’、’Cabin’以及’Embarked’,其中’Cabin’暂时不作为特征处理,因此可以略过不提。‘Embarked’缺失两个record,本身是离散型变量,因此我用众数来作为这两个缺失值的估计值。’Age’缺失177个record,本身为连续型变量,用随机森林回归模型来估计这些缺失值。
‘Embarked’变量

1
2
3
# fill the missing value with the mode value
mode = train['Embarked'].mode()
train.loc[train.Embarked.isnull(), 'Embarked'] = mode[0]

‘Age’变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sklearn.ensemble import RandomForestRegressor

# Build the random forest regressor to estimate the age
def Estimate_Age(df):
dataset = df[['Age', 'Pclass', 'SibSp', 'Parch', 'Fare']]

known_age = dataset[dataset.Age.notnull()].as_matrix()
unknown_age = dataset[dataset.Age.isnull()].as_matrix()

X = known_age[:, 1:]
y = known_age[:, 0]

regr = RandomForestRegressor(n_estimators = 1000, random_state = 0)
regr.fit(X, y)

estimate_age = regr.predict(unknown_age[:, 1:])
train.loc[train.Age.isnull(), 'Age'] = estimate_age

return train, regr
train_set, regr_age = Estimate_Age(train)

需要同时返回预处理的train数据集和回归模型是因为我们还需要用这个回顾模型去对test数据集进行预处理。

处理离散变量

采用one-hot对如下变量进行处理:

  1. Pclass
  2. Sex
  3. Embarked
1
2
3
4
5
6
7
8
9
def Transform_Feature(df):
Pclass_dummies = pd.get_dummies(df['Pclass'], prefix = 'Pclass')
Sex_dummies = pd.get_dummies(df['Sex'], prefix = 'Sex')
Embarked_dummies = pd.get_dummies(df['Embarked'], prefix = 'Embarked')

df = pd.concat([df, Pclass_dummies, Sex_dummies, Embarked_dummies], axis=1)
return df

train = Transform_Feature(train_set)

处理连续变量

对如下连续变量进行scaling:

1
2
3
4
5
import sklearn.preprocessing as preprocessing
scaler = preprocessing.StandardScaler()
scale_param = scaler.fit(train[['Age', 'SibSp', 'Parch', 'Fare']].values)
scaled_features = scaler.fit_transform(train[['Age', 'SibSp', 'Parch', 'Fare']].values, scale_param)
train[['Age_scaled', 'SibSp_scaled', 'Parch_scaled', 'Fare_scaled']] = pd.DataFrame(scaled_features, index=train.index)

最后,用同样方法对test数据集进行预处理。

Baseline模型

baseline模型使用logistic regression模型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from sklearn.linear_model import LogisticRegression
train_data = train[['Survived', 'Age_scaled', 'SibSp_scaled', 'Parch_scaled', 'Fare_scaled', 'Pclass_1', 'Pclass_2', 'Pclass_3', 'Sex_female', 'Sex_male', 'Embarked_C', 'Embarked_Q', 'Embarked_S']]
test_data = test[['Age_scaled', 'SibSp_scaled', 'Parch_scaled', 'Fare_scaled', 'Pclass_1', 'Pclass_2', 'Pclass_3', 'Sex_female', 'Sex_male', 'Embarked_C', 'Embarked_Q', 'Embarked_S']]

# train the model
X_train, Y_train = train_data.as_matrix()[:, 1:], train_data.as_matrix()[:, 0]
X_test = test_data.as_matrix()
clf = LogisticRegression(penalty='l1')
clf.fit(X_train, Y_train)

# predict the result of the test set
Y_test = clf.predict(X_test)
result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix(), 'Survived': Y_test.astype(np.int32)})

该模型的预测结果提交Kaggle后,准确率为75.598%。

分析Baseline模型的预测结果

观察各个特征的在模型中的系数

1
pd.DataFrame({'features': list(train_data.columns)[1:], 'coef': list(clf.coef_.T)})

从结果来看,虽然部分特征的系数略显奇怪,但是基本上每个特征都与生存率有关。比如’Sex_female’的系数为1.787,而’Sex_male’的系数为-0.863,这表明女性会有大概率获救,而男性则大概率遇难。

观察bad case

将训练集分割成training set和validation set,然后在预测完validation set后,找到其中预测失误的bad case,打印出来观察特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
# split the data
from sklearn.model_selection import train_test_split
training_set, validation_set = train_test_split(train, test_size=0.4, random_state=0)
train_data = training_set[['Survived', 'Age_scaled', 'SibSp_scaled', 'Parch_scaled', 'Fare_scaled', 'Pclass_1', 'Pclass_2', 'Pclass_3', 'Sex_female', 'Sex_male', 'Embarked_C', 'Embarked_Q', 'Embarked_S']]
validation_data = validation_set[['Survived', 'Age_scaled', 'SibSp_scaled', 'Parch_scaled', 'Fare_scaled', 'Pclass_1', 'Pclass_2', 'Pclass_3', 'Sex_female', 'Sex_male', 'Embarked_C', 'Embarked_Q', 'Embarked_S']]
# train the model and predict the validation set
X_train, Y_train = train_data.as_matrix()[:, 1:], train_data.as_matrix()[:, 0]
X_validation, Y_validation = validation_data.as_matrix()[:, 1:], validation_data.as_matrix()[:, 0]
clf.fit(X_train, Y_train)
Y_pred = clf.predict(X_validation)
bad_case = validation_set.loc[validation_set.PassengerId.isin(validation_set[Y_pred != validation_data.as_matrix()[:, 0]]['PassengerId'].values)]
bad_case.head(10)
bad_case.describe()

(这份bad case我毛都没看出来= =)

添加新的特征

参考了一些别人的思路,我决定加上一下特征:

  • Title
  • Family Szie
  • IsAlone
  • AgeBand

第一项可以在Name中找到,Title在一定程度上反映了社会地位以及性别、年龄,因此有一定的价值,第二项的话是因为SibSp和Parch都与存活率相关,那么两者的结合,理论上也应该相关,而且在这种灾难面前,理论上每个人都会以家族为单位尽可能展开自救或救人。第三项的原因与第二项相同,可以算是第二项的一个特例吧。第四项是对年龄进行离散化,因为实际上幼年和老年获救的概率理论上都应该比壮年高,因此离散化到几个区间可能会更好的预测结果。

以下是相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Title
train['Title'] = train_set.Name.str.extract(' ([A-Za-z]+)\.', expand=False)
pd.crosstab(train['Title'], train['Survived'])

from collections import defaultdict
train['Title'] = train['Title'].replace('Mlle', 'Miss')
train['Title'] = train['Title'].replace('Ms', 'Miss')
train['Title'] = train['Title'].replace('Mme', 'Miss')

tmpDict = {'Mr': 1, 'Miss': 2, 'Mrs': 3, 'Master': 4}
TitleDict = defaultdict(lambda: 5, tmpDict)
train['Title'] = train['Title'].map(TitleDict)
train['Title'] = train['Title'].fillna(0)

# FamilySize
train['FamilySize'] = train['SibSp'] + train['Parch']

# IsAlone
train['IsAlone'] = (train['FamilySize'] == 0)

# AgeBand
train['AgeBand'] = pd.cut(train['Age'], 5, labels=[1, 2, 3, 4, 5])

在添加完这几新的feature之后同样要进行数据的预处理,此处掠过不提(具体代码在这里)。

使用改进后的模型进行预测

此处使用的模型包括以下几种:

  • Logistic Regression
  • Random Forest
  • Knn
  • SVM
  • Decision Tree

这几个模型的训练与预测过程与之前相同,此外需要作出learning curve,以防止有模型过拟合。
以logistic regression的代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# build the dataset from the complete dataset
train_data = train.filter(regex='Survived|Age_.*|SibSp_.*|Parch_.*|Fare_.*|FamilySize_.*|Pclass_.*|Embarked_.*|Sex_.*|Title_.*|IsAlone_.*|AgeBand_.*')
test_data = test.filter(regex='Age_.*|SibSp_.*|Parch_.*|Fare_.*|FamilySize_.*|Pclass_.*|Embarked_.*|Sex_.*|Title_.*|IsAlone_.*|AgeBand_.*')
X_train, Y_train = train_data.as_matrix()[:, 1:], train_data.as_matrix()[:, 0]
X_test = test_data.as_matrix()

# train the model
clf = LogisticRegression(penalty='l1')
clf.fit(X_train, Y_train)

# predict the result of the test set
Y_test = clf.predict(X_test)
result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix(), 'Survived': Y_test.astype(np.int32)})

# Learning curve
from sklearn.model_selection import learning_curve
def plot_learning_curve(estimator, X, y, train_sizes):
train_sizes, train_scores, validation_scores = learning_curve(estimator, X, y, train_sizes=train_sizes)
train_scores_mean = np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
validation_scores_mean = np.mean(validation_scores, axis=1)
validation_scores_std = np.std(validation_scores, axis=1)
plt.fill_between(train_sizes, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.1,
color="r")
plt.fill_between(train_sizes, validation_scores_mean - validation_scores_std,
validation_scores_mean + validation_scores_std, alpha=0.1, color="g")
plt.plot(train_sizes, train_scores_mean, 'o-', color="r",
label="Training score")
plt.plot(train_sizes, validation_scores_mean, 'o-', color="g",
label="Cross-validation score")

plt.legend(loc="best")
return train_sizes, train_scores, validation_scores

train_sizes, train_scores, validation_scores = plot_learning_curve(LogisticRegression(penalty='l1'), X_train, Y_train, np.linspace(.05, 1., 20))

这里可以看到其他几个模型各自的learning curve,从这些learning curve,可以看出random forest和decision tree都存在过拟合的问题, 而knn、logistic regression以及knn的learning curve相对理想,

所以我们用knn、logistic regression以及svm来组成一个投票的模型,三个模型各自独立预测,然后根据预测结果,共同决定结果。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# lr model
lr_clf = LogisticRegression(penalty='l1')
lr_clf.fit(X_train, Y_train)
Y_test = lr_clf.predict(X_test)
lr_result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix(), 'Survived': Y_test.astype(np.int32)})
# knn model
knn_clf = KNeighborsClassifier(n_neighbors=5)
knn_clf.fit(X_train, Y_train)
Y_test = knn_clf.predict(X_test)
knn_result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix(), 'Survived': Y_test.astype(np.int32)})
# svm model
svm_clf = svm.SVC()
svm_clf.fit(X_train, Y_train)
Y_test = svm_clf.predict(X_test)
svm_result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix(), 'Survived': Y_test.astype(np.int32)})

# voting predict
result = pd.DataFrame({'PassengerId': test['PassengerId'].as_matrix()})
result['Survived_Vote'] = lr_result['Survived'] + knn_result['Survived'] + svm_result['Survived'] + rf_result['Survived'] + dt_result['Survived']
result['Survived'] = result['Survived_Vote'].apply(lambda x: 1 if x > 2 else 0)
result.drop(['Survived_Vote'], inplace=True, axis=1)
result.to_csv("../input/voting.csv", index=False)

预测结果为78.47%,比之前的baseline模型要好。

但是值得注意的是,svm模型独立的预测结果准确率为78.95%,也就是说svm的准确率甚至更高,而且svm的learning curve也非常理想。

总结

暂时就先做到这里了,以后有空再往里面加点东西,做点修改吧,这个模型的预测准确率并不高,只有78%左右,究其原因的话,可能在特征工程那一步还是做得有点草率,根据其他一些人的分析和我自己事后的一些思考,有一部分被我丢弃的特征事实上是有非常重要的意义的,比如:

  • Name的长度,虽然并不清楚原因,但是Name的长度与存活率似乎正相关
  • Cabin的有无,有无Cabin也严重影响存活率,可以理解为是只有活下来的人,才能准确记录自己的Cabin吧
  • Fare可以类似于Age做一个离散化
  • 按照Name中的姓氏做一个家族的分类,作为一个新的特征,因为同一个家族应该存活率接近

这个项目的完整ipython notebook已被我传到github上,网页版也可以在这里找到。

参考文献:
机器学习系列(3)_逻辑回归应用之Kaggle泰坦尼克之灾
Titanic Data Science Solutions