鐵達尼號的悲劇:預測船難生還者的特徵工程筆記(搭配 FLAML 自動化建模)以及對 Kaggle 競賽的一些觀察

Alan Wang
46 min readFeb 2, 2022
Photo by Alwi Alaydrus on Unsplash

1912 年 4 月 15 日,英國白星航運旗下的全球最大郵輪鐵達尼號(RMS Titanic)於首航時在大西洋撞上冰山沉沒,船上 2,208 人僅有 712 人生還,或者扣除船員的話,1,317 名乘客只有 500 人生還。

對資料科學和機器學習的入門者而言,這個災難則有另一個意義 — — 在 1994 年由 Michael A. Findlay 依據網站 Encyclopedia Titanica 的乘客名單編撰的鐵達尼號資料集,是如今最常見的入門資料集之一,它在 Kaggle 上自 2012 年起也被列為練習競賽項目。

本文使用套件:Pandas、matplotlib、seaborn、scikit-learn、FLAML,程式都是在 Jupyter Notebook 執行。此外網路上有很多版本的鐵達尼號資料集,有些的資料筆數跟特徵會有出入。本篇我們以 Kaggle 提供的版本為準 。

我不是資料科學家,本篇內容算是我自己摸索此競賽所得到的心得,重點也在於資料處理。

這資料集不大(訓練集 891 筆,測試集 418 筆),只要處理一下丟進模型就能預測,感覺相當適合入門。從 Kaggle 的競賽畫面下載資料集,然後用 Pandas 讀入即可:

import pandas as pdtrain = pd.read_csv('./train.csv')
test = pd.read_csv('./test.csv')
train.info()

這會產生

class 'pandas.core.frame.DataFrame'>
RangeIndex: 891 entries, 0 to 890
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 PassengerId 891 non-null int64
1 Survived 891 non-null int64
2 Pclass 891 non-null int64
3 Name 891 non-null object
4 Sex 891 non-null object
5 Age 714 non-null float64
6 SibSp 891 non-null int64
7 Parch 891 non-null int64
8 Ticket 891 non-null object
9 Fare 891 non-null float64
10 Cabin 204 non-null object
11 Embarked 889 non-null object
dtypes: float64(2), int64(5), object(5)
memory usage: 83.7+ KB
Photo by Mary J. Friedrich on Unsplash

欄位意義如下:

  • PassengerId — 乘客編號
  • Survived — 是否生還(0=否,1=是;只有訓練集有)
  • Pclass — 艙等(1, 2, 3)
  • Name — 姓名
  • Sex — 性別(male, female)
  • Age — 年齡
  • SibSp — 手足及配偶人數
  • Parch — 父母及子女人數
  • Ticket — 船票號碼
  • Fare — 船票價格
  • Cabin — 艙房號碼
  • Embarked — 出發港口(C = Cherbourg, Q = Queenstown, S = Southampton)

觀察訓練集中的缺失值數量:

train.isna().sum()

得到

PassengerId      0
Survived 0
Pclass 0
Name 0
Sex 0
Age 177
SibSp 0
Parch 0
Ticket 0
Fare 0
Cabin 687
Embarked 2
dtype: int64

年齡資料缺少了幾乎 20%,而艙房資料缺少 77%。

Part I:性別 — 艙等 — 年齡模型

Photo by Nathan Wright on Unsplash

要解這個問題,其實並沒有想像中難,你可以用極為簡單的模型就得到一個還不錯的結果。Kaggle 上可下載的提交範例檔 gender_submission.csv 就是只預測女性生還、男性死亡(稱為 Gender model),直接上傳可得到預測準確率 0.76555。這或許可視為本競賽的分數底線吧。

以下這個模型我參考別人的做法跟自己的觀察稍加變化,稱之為 Gender-class-age model。

很多人都應該知道,這資料集中決定性最大的因素就是性別和艙等。女性的生存率明顯較男性高:

import seaborn as sns
import matplotlib.pyplot as plt
plt.figure(figsize=(6, 6))
sns.countplot(data=train, x='Sex', hue='Survived')
plt.show()

三等艙的乘客有明顯較高的死亡率:

當時的三等艙本質上就有歧視意味,用來把移民跟中上層人士隔離開來。電影中船員在沉船時將三等艙出口上鎖一事並非真實,但要從船身深處的三等艙房走到露天甲板上確實更遠且更困難。

三等艙的單身男性艙房全部在船頭,而也許是出於 1989 年郵輪 SS La Bourgogne 沈船事件(男性船員搶奪救生艇,導致女性和孩童乘客幾乎無人生還),這些人被視為危險份子。有跡象顯示鐵達尼號船員無意引導他們往上走去救生艇,而是在船身內往船尾走。

更精確來說,頭等艙與二等艙的女性幾乎都能生還:

sns.catplot(kind='count', data=train, x='Pclass', col='Sex', hue='Survived')
plt.show()

那麼年齡對生還率有關係嗎?

plt.figure(figsize=(10, 6))
sns.histplot(data=train, x='Age', bins=40, hue='Survived', kde=True)
plt.show()

似乎只有年齡較低的兒童有明顯的高生還率。我們來更仔細看這部分:

plt.figure(figsize=(10, 6))
sns.countplot(data=train[train.Age < 16], x='Age', hue='Survived')
plt.show()

看來 6~8 歲以下的兒童生還率較高。這看來印證了船難時婦女與孩童被允許優先上救生艇的假設。

前面提過女性在頭等艙與二等艙幾乎都能生還,那麼三等艙的女性呢?

plt.figure(figsize=(8, 6))
sns.histplot(data=train[(train.Pclass==3) & (train.Sex=='female')], x='Age', bins=40, hue='Survived', kde=True)
plt.show()

看來年紀較輕(30 歲以下)的女性較容易生還。

根據以上圖表,我們也許可以做個簡單假設:

  • 所有 8 歲以下的男女孩童會生還
  • 頭等艙與二等艙的女性會生還
  • 三等艙 30 歲以下的女性會生還
  • 其餘人死亡

先來測試看看對訓練集的預測準確率:

train['Survived_predict'] = 0train.loc[(train.Age <= 8), 'Survived_predict'] = 1
train.loc[((train.Pclass==1) | (train.Pclass==2)) & (train.Sex=='female'), 'Survived_predict'] = 1
train.loc[(train.Pclass==3) & (train.Age <= 30) & (train.Sex=='female'), 'Survived_predict'] = 1
from sklearn.metrics import accuracy_score
accuracy_score(train.Survived, train.Survived_predict).round(5)

得到準確率 0.79461

現在來對測試集套用這個模型,並產生要上傳給 Kaggle 的答案:

test['Survived'] = 0test.loc[(test.Age <= 8), 'Survived'] = 1
test.loc[((test.Pclass==1) | (test.Pclass==2)) & (test.Sex=='female'), 'Survived'] = 1
test.loc[(test.Pclass==3) & (test.Age <= 30) & (test.Sex=='female'), 'Survived'] = 1
submission = test.copy()[['PassengerId', 'Survived']]
submission.to_csv('./submission.csv', index=False)
submission

上傳 Kaggle 後得到分數:

0.77033

後面我們會討論到 Kaggle 排行榜分數的分布,以便了解怎樣的分數是合理的。

Part II:簡單資料預處理的模型

Photo by Heidi Fin on Unsplash

接下來是一般人最常用的方法,也就是將原資料集稍做處理後,把所有可用的特徵丟進模型訓練。

資料探索及預處理

在資料集中,Name 和 Ticket 欄位比較難處裡,所以這裡略過它們。至於缺失的年齡,最簡單的方式是直接填訓練集的年齡中位數給它們:

train.Age.fillna(train.Age.median(), inplace=True)
test.Age.fillna(train.Age.median(), inplace=True)

至於 Cabin,一堆艙房號碼看似只是一堆不相關的值:

train[train.Cabin.notna()].Cabin

得到

1              C85
3 C123
6 E46
10 G6
11 C103
...
871 D35
872 B51 B53 B55
879 C50
887 B42
889 C148
Name: Cabin, Length: 204, dtype: object

艙房資料只有 204 筆,這是因為缺乏可考證的準確資料。唯一一份可靠的資料 — — 不完整的頭等艙乘客名單 — — 是在沙龍服務生 Herbert Cave 的遺體上找到的。

但其實艙房的第一個字母代表甲板(deck):

所以我們可以擷取 Cabin 的第一個字母:

train.Cabin = train.Cabin.str[0]
test.Cabin = test.Cabin.str[0]

若檢視甲板號碼,可發現確實跟艙等有關係(因此跟生還率有關):

plt.figure(figsize=(8, 6))
sns.countplot(data=train, x='Cabin', hue='Pclass', order=train.Cabin.value_counts().index.sort_values())
plt.show()

其實若檢視 Cabin 與 Pclass 的關係就會發現兩者相關係數高,而共線性理論上應該要避免,但這裡我們暫時忽略這個問題(下一個模型再討論)。

Embarked 同樣跟生還率有點關係:

plt.figure(figsize=(8, 6))
sns.countplot(data=train, x=’Pclass’, hue=’Embarked’)
plt.show()

這是因為生還率較高的頭等艙、二等艙乘客幾乎都從英國 Southampton 或法國的 Cherbourg 上船,所以出發地點的確有一些關聯。

我們可以用訓練集中最多的出發地點(眾數)來填補缺失值:

train.Embarked.fillna(train.Embarked.mode()[0], inplace=True)
test.Embarked.fillna(train.Embarked.mode()[0], inplace=True)

最後我們將資料集中的文字分類資料編碼成數字:

from sklearn.preprocessing import LabelEncoderle = LabelEncoder()
encode_cols = ['Sex', 'Cabin', 'Embarked']
for col in encode_cols:
train[col] = le.fit_transform(train[col])
test[col] = le.transform(test[col])
print('Encoding:', col, le.classes_)

留意到我們在使用 LabelEncoder 時,先對訓練集編碼並轉換,然後用一樣的規則套用到測試集,以避免資料洩漏(見後討論)。不過這些欄位的文字分類在兩個資料集是一樣的就是了。

以上程式執行後得到

Encoding: Sex ['female' 'male']
Encoding: Cabin ['A' 'B' 'C' 'D' 'E' 'F' 'G' 'T' nan]
Encoding: Embarked ['C' 'Q' 'S]

LabelEncoder 會把這些文字依序轉成 0, 1, 2…,以利模型訓練用。Cabin 的 NaN 會得到自己的編碼值(意義上便代表甲板號碼未知),因此就自動填補了缺失值。

最後丟掉沒有用的欄位(船票價格 Fare 沿用原資料),但保留測試集的 PassengerId,以便後面拿來產生 Kaggle 解答:

train = train.drop(['PassengerId', 'Name', 'Ticket'], axis=1)passenger_id = test.PassengerId
test = test.drop(['PassengerId', 'Name', 'Ticket'], axis=1)

模型訓練及預測

我們在本篇會使用自動化機器學習(AutoML)套件 — — 微軟開發的 FLAML 來建模,我在之前的文章討論過它。我是在自己的機器上跑,所以需要的時間會比 Colab 短,但這時間可能得視情況調整。

我們下面要 FLAML 使用五種模型(LightGBM、XGBoost、有限深度 XGBoost、隨機森林、Extra Tree)來訓練,衡量指標用 PR(precision-recall),並在訓練 30 秒後給我們表現最好的模型:

from flaml import AutoMLclf = AutoML()
clf.fit(dataframe=train, label='Survived',
task='classification',
estimator_list=['lgbm', 'xgboost', 'xgb_limitdepth', 'rf', 'extra_tree'],
metric='ap',
time_budget=30)

起始的輸出訊息如下:

[flaml.automl: 02-02 14:41:34] {2051} INFO - task = classification
[flaml.automl: 02-02 14:41:34] {2053} INFO - Data split method: stratified
[flaml.automl: 02-02 14:41:34] {2057} INFO - Evaluation method: cv
[flaml.automl: 02-02 14:41:34] {2138} INFO - Minimizing error metric: 1-ap
[flaml.automl: 02-02 14:41:34] {2196} INFO - List of ML learners in AutoML Run: ['lgbm', 'xgboost', 'xgb_limitdepth', 'rf', 'extra_tree']
[flaml.automl: 02-02 14:41:34] {2449} INFO - iteration 0, current learner lgbm
[flaml.automl: 02-02 14:41:34] {2562} INFO - Estimated sufficient time budget=758s. Estimated necessary time budget=5s.

可以看到 FLAML 採用分層抽樣(stratified)來產生驗證集,並會做交叉驗證(cross validation),並顯示它估計的最少/充足訓練時間。我們並沒有給它太多時間,這是為了避免過度配適(overfitting),見後說明。

訓練完畢後,顯示最佳模型以及其超參數:

print('Best ML leaner:', clf.best_estimator)
print('Best hyperparmeter config:\n', clf.best_config)

得到

Best ML leaner: rf
Best hyperparmeter config:
{'n_estimators': 43, 'max_features': 0.4599557755944832, 'max_leaves': 26, 'criterion': 'entropy'}

接著產生預測值,並將結果寫入要上傳 Kaggle 的 CSV 檔:

predicted = clf.predict(test).astype('uint8')submission = pd.DataFrame({'PassengerId': passenger_id, 'Survived': predicted})
submission.to_csv('./submission.csv', index=False)

上傳 Kaggle 後得到分數:

0.77990

Part III:使用特徵工程的模型

Photo by ThisisEngineering RAEng on Unsplash

第三階段要來看比較深一點的特徵工程,這回我們會用到 Name 及 Ticket 欄位的資訊。我們會試著更進一步了解資料中的模式,並試著產生一些新特徵,最後篩選出跟預測結果關係比較大的來給模型訓練。

網路上對這個競賽的特徵工程解法眼花撩亂,且常有資料洩漏的問題(見後面討論)。在這裡我只會做到我覺得合理的程度,而且避免使用測試集的任何資訊。

讀取資料集的方式跟前面一樣,這邊就不重複了。

關於船票

這裡先來討論一下船票。Fare 的資料看起來確實跟艙等和生還率有關係:(下圖沒有顯示離群值,但實際上頭等艙有相當多的離群值)

plt.figure(figsize=(6, 10))
sns.boxplot(data=train, x='Pclass', y='Fare', hue='Survived', showfliers=False)
plt.show()

但要進一步分析船票價格是有困難的,因為這其實是該船票的總價,可能由多位乘客共同分攤。理論上這意味著我們能從價格相同的船票推算多少人是同一家族或同行旅客,但它也存在著艙房價格差異、兒童價折扣、三等艙在歐陸不同國家的售價、包含在內的鐵路車票、免費票等問題。

所以到頭來簡單的辦法就是…只做正規化就好(這裡用 RobustScaler 來避免離群值造成太多影響):

from sklearn.preprocessing import RobustScalerscaler = RobustScaler()scaled = train[['Fare']].copy()
scaled = pd.DataFrame(scaler.fit_transform(scaled), columns=scaled.columns)
train.Fare = scaled.Fare
scaled = test[['Fare']].copy()
scaled = pd.DataFrame(scaler.transform(scaled), columns=scaled.columns)
test.Fare = test.Fare
train.Fare.describe()

這會輸出

count 891.000000
mean 0.768745
std 2.152200
min -0.626005
25% -0.283409
50% 0.000000
75% 0.716591
max 21.562738
Name: Fare, dtype: float64

新特徵:三等艙

既然三等艙乘客遠比另外兩個艙等容易喪命,我們可以加一個特徵叫做 3rd_class:

f = lambda n: n == 3train['3rd_class'] = train.Pclass.apply(f)
test['3rd_class'] = test.Pclass.apply(f)

觀察這特徵和生還率關係:

plt.figure(figsize=(6, 6))
sns.countplot(data=train, x='3rd_class', hue='Survived')
plt.show()

新特徵:頭銜和「Mr」

乘客的姓名會包含像是 Mr、Mrs 或 Master 之類的頭銜,這其實能暗示他們的年齡。例如:

Cumings, Mrs. John Bradley (Florence Briggs Thayer)

第一個詞是姓氏,然後是頭銜,再來是剩餘名字。(有人的頭銜會包含 the,如 the Countess。)所以我們從 Name 產生一個新欄位 Title:

f = lambda x: x.split(',')[1].replace('the', '').split()[0]train['Title'] = train.Name.apply(f)
test['Title'] = test.Name.apply(f)
train.Title.value_counts() # 統計頭銜數量

得到

Mr.          517
Miss. 182
Mrs. 125
Master. 40
Dr. 7
Rev. 6
Mlle. 2
Major. 2
Col. 2
Countess. 1
Capt. 1
Ms. 1
Sir. 1
Lady. 1
Mme. 1
Don. 1
Jonkheer. 1
Name: Title, dtype: int64

現在 Name 欄位可以丟掉了(我們不做家族姓氏分析,這通常得結合測試集來做):

train = train.drop(['Name'], axis=1)
test = test.drop(['Name'], axis=1)

現在來把 Title 的內容簡化一下:

def set_title(x):
title = x.Title.replace('.', '')
sex = x.Sex
if title in ['Sir', 'Jonkheer', 'Countess', 'Lady']:
return 'Royalty'
elif title in ['Capt', 'Col', 'Major', 'Don', 'Rev']:
return 'Mr'
elif title in ['Mlle', 'Mme', 'Ms']:
return 'Miss'
elif title =='Dr':
if sex == 'male':
return 'Mr'
else:
return 'Mrs'
elif title in ['Master', 'Mr', 'Mrs', 'Miss']:
return title
else:
return 'Other'
train.Title = train.apply(set_title, axis=1)
test.Title = test.apply(set_title, axis=1)
train.Title.value_counts()

這會產生

Mr         535
Miss 186
Mrs 126
Master 40
Royalty 4
Name: Title, dtype: int64

這基本上是把頭銜簡化為六類:Mr、Miss、Mrs、Master、Royalty 以及 Other(未知)。Master(少爺)是指不滿 16 歲的男性,而 Miss 是(通常較年輕的)未婚女性。所以頭銜確實可以反映乘客的年齡,有助於讓我們推算遺失值。

有些人會把 Capt(上尉)、Col(上校)、Major(少校)、Rev(神父)獨立分類為「軍官」,不過我自己試過是覺得差異不大。

測試集中其實有個頭銜 Dona 與 Mrs 同義,但為了保持測試資料不洩漏,我們就假裝不知道這件事(Dona 會被轉成 Other)。

來檢視頭銜跟生還率的關係:

plt.figure(figsize=(8, 8))
sns.countplot(data=train, x='Title', hue='Survived')
plt.show()

可見歸類在 Mr 頭銜的人死亡率很高,所以我們可以進一步弄一個新特徵 Mr:

f = lambda t: t == 'Mr'train['Mr'] = train.Title.apply(f)
test['Mr'] = test.Title.apply(f)

檢視 Mr 特徵與生還率的關係:

plt.figure(figsize=(6, 6))
sns.countplot(data=train, x='Mr', hue='Survived')
plt.show()

推算遺失的年齡(Part 1)

前面提過頭銜暗示了乘客的年齡,這點看來也確實是如此:

plt.figure(figsize=(8, 8))
sns.boxplot(data=train, x='Title', y='Age')
plt.show()

若檢視和艙等的關係,會發現遺失年齡的乘客絕大多數都在三等艙,並以頭銜 Mr 居多:

plt.figure(figsize=(8, 8))
sns.countplot(data=train[train.Age.isna()], x='Title', hue='Pclass')
plt.show()

不過為了在推算時有足夠的參考資料,我們要先對一些文字欄位編碼。

對文字分類資料編碼

我們將對三個欄位編碼:Sex,Deck(來自 Cabin)以及 Embarked。

首先從 Cabin 取出甲板編號為新特徵 Deck,並丟棄 Cabin 欄位:

train['Deck'] = train.Cabin.str[0]
test['Deck'] = test.Cabin.str[0]
train = train.drop(['Cabin'], axis=1)
test = test.drop(['Cabin'], axis=1)

對 Embarked 的缺失值填入對應艙等的眾數(該艙等最多人上船的地點):

for c in (1, 2, 3):
mode = train[train.Pclass==c].Embarked.mode()[0]
train.loc[(train.Pclass==c) & (train.Embarked.isna()), 'Embarked'] = mode
test.loc[(test.Pclass==c) & (test.Embarked.isna()), 'Embarked'] = mode

其實最多人上船的地點都是 Southampton 啦,不過這可以讓我們了解一下怎麼針對不同資料填補不同的缺失值。

最後對這些欄位編碼,和之前的模型做法相同:

from sklearn.preprocessing import LabelEncoderle = LabelEncoder()
encode_cols = ['Sex', 'Deck', 'Embarked']
for col in encode_cols:
train[col] = le.fit_transform(train[col])
test[col] = le.transform(test[col])
print('Encoding:', col, le.classes_)

印出

Encoding: Sex ['female' 'male']
Encoding: Deck ['A' 'B' 'C' 'D' 'E' 'F' 'G' 'T' nan]
Encoding: Embarked ['C' 'Q' 'S']

對頭銜編碼

對頭銜編碼稍微麻煩一點,因為理論上我們不知道測試集是否有訓練集內所沒有的頭銜,而 LabelEncoder 在碰到新資料時就會無法編碼、產生錯誤。

所以下面我們把 LabelEncoder 轉成一個 defaultdict — — 這種字典在你嘗試查詢不存在的鍵時不會回報錯誤,而是會用個預設值把該鍵加進去。而有了這個字典,我們就能用 Pandas Series 物件的 map() 來轉換內容,等於是另一種方式的編碼。

我們的 defaultdict 會把不存在的鍵(以此例來說就是「Other」頭銜)配對 None 值,最後我們呼叫 fillna() 來對欄位內的 NaN 值(None)指派比 LabelEncoder 最大編碼值 +1 的數字即可。

from collections import defaultdictle.fit(train.Title)
le_dict = defaultdict(lambda: None, dict(zip(le.classes_, le.transform(le.classes_))))
train.Title = train.Title.map(le_dict).fillna(len(le.classes_))
test.Title = test.Title.map(le_dict).fillna(len(le.classes_))

新特徵:共用票根數和「小團體」

之前我們沒有用過 Ticket 欄位,但若仔細檢視它,會發現裡面有重複的號碼:

train.Ticket.value_counts()

結果為

347082      7
CA. 2343 7
1601 7
3101295 6
CA 2144 6
..
9234 1
19988 1
2693 1
PC 17612 1
370376 1
Name: Ticket, Length: 681, dtype: int64

這和前面提過的一樣,有些乘客會共用票根,他們可能是同家族或同行旅客,而同行者共同倖存或一起罹難的機率可能較大。我們可以新增特徵 Ticket_count,記錄該票根的共用人數:

ticket_counts = defaultdict(lambda: None, train.Ticket.value_counts().to_dict())train['Ticket_count'] = train.Ticket.map(ticket_counts, na_action='ignore').fillna(1) - 1
test['Ticket_count'] = test.Ticket.map(ticket_counts, na_action='ignore').fillna(1) - 1
train.Ticket_count.value_counts()

得到

0    547
1 188
2 63
3 44
6 21
5 18
4 10
Name: Ticket_count, dtype: int64

其實也有人指出有些乘客會買幾張連號票,但這就必須分析額外資料了。本篇我們不會做到這種程度。

檢視票根共用數和生還率的關係:

plt.figure(figsize=(8, 8))
sns.countplot(data=train, x='Ticket_count', hue='Survived')
plt.show()

因此我們或許可再新增一個特徵 Small_group,代表一個乘客的票根和 1~3 人共用,這些人的生存率看來較高:

f = lambda n: 1 <= n <= 3train['Small_group'] = train.Ticket_count.apply(f)
test['Small_group'] = test.Ticket_count.apply(f)

最後我們可捨棄 Ticket 與 Ticket_count 欄位:

train = train.drop(['Ticket', 'Ticket_count'], axis=1)
test = test.drop(['Ticket', 'Ticket_count'], axis=1)

推算遺失的年齡(Part 2)

現在欄位都編碼完成,我們可以來推算遺失的年齡了。這回我們使用 scikit-learn 的 KNNImputer,也就是用 K 鄰近法來推算:

from sklearn.impute import KNNImputercols = ['Pclass', 'Sex', 'Age', 'Title']
imputer = KNNImputer(n_neighbors=10)
imputed = train[cols].copy()
imputed = pd.DataFrame(imputer.fit_transform(imputed), columns=imputed.columns)
train.Age = imputed.Age
imputed = test[cols].copy()
imputed = pd.DataFrame(imputer.transform(imputed), columns=imputed.columns)
test.Age = imputed.Age
train.Age.describe()

KNNImputer 會對你輸入的所有欄位填補缺失值,但既然這裡只有 Age 有缺,其他欄位(Pclass 還有編碼後的 Sex、Title)目的是當成參考值。

以上程式產生

count    891.000000
mean 29.540314
std 13.251220
min 0.420000
25% 21.550000
50% 30.000000
75% 36.000000
max 80.000000
Name: Age, dtype: float64

可見 Age 的值補滿到 891 筆了。若檢視增補資料後的年齡分布,也能發現許多資料新增在 20 多歲和 30 多歲:

plt.figure(figsize=(10, 8))
sns.histplot(data=train, x='Age', bins=40, hue='Survived', kde=True)
plt.show()

若檢視年齡與艙等的關係:

plt.figure(figsize=(10, 8))
sns.histplot(data=train, x='Age', bins=40, hue='Pclass', kde=True)
plt.show()

這其實符合前面的觀察,也就是多數遺失年齡來自三等艙的男性和女性。

新特徵:幼童

和我們在第一個模型看到的一樣,8 歲或更小的孩童有明顯較高的生還率。因此新增特徵 Child:

f = lambda n: n <= 8train['Child'] = train.Age.apply(f)
test['Child'] = test.Age.apply(f)

有許多解法是將低於 16 歲的未成年人劃為新特徵,但他們的直方圖都是以 10 歲為區間,而我自己試過的結果是 ≤ 8 的相關度會高於 < 16。

正規化年齡

現在用不到年齡了,可以給它做正規化。辦法跟前面正規化船票價格的方式一樣:

scaler = RobustScaler()scaled = train[['Age']].copy()
scaled = pd.DataFrame(scaler.fit_transform(scaled), columns=scaled.columns)
train.Age = scaled.Age
scaled = test[['Age']].copy()
scaled = pd.DataFrame(scaler.transform(scaled), columns=scaled.columns)
test.Age = test.Age
train.Age.describe()

新特徵:家族與「小家族」

家族人數對生還率有影響嗎?我們可以合併 SibSp 與 Parch 欄位為新特徵 Family:

train['Family'] = train.SibSp + train.Parch
test['Family'] = test.SibSp + test.Parch

用圖表來檢視:

plt.figure(figsize=(10, 8))
sns.countplot(data=train, x='Family', hue='Survived')
plt.show()

看來家族人數 1~3 人的生還率較高,因此再新增特徵 Small_family:

f = lambda n: 1 <= n <= 3train['Small_family'] = train.Family.apply(f)
test['Small_family'] = test.Family.apply(f)

檢視最終特徵

現在我們可以來檢視最終的特徵,看看它們的相關係數是否夠高、並決定要保留哪些。

首先移除兩個資料集的 PassengerId 欄位(但測試集的要保留):

train.pop('PassengerId')
passenger_id = test.pop('PassengerId')

接著印出所有欄位跟 Survuved 的相關度:

train.corr().Survived

這印出

Survived        1.000000
Pclass -0.338481
Sex -0.543351
Age -0.076057
SibSp -0.035322
Parch 0.081629
Fare 0.257307
Embarked -0.167675
3rd_class -0.322308
Title -0.064717
Mr -0.567068
Deck -0.301116
Small_group 0.298000
Child 0.147223
Family 0.016639
Small_family 0.279855
Name: Survived, dtype: float64

把相關係數絕對值小於 0.1 的欄位都丟棄:

drop_cols = []for col, corr in zip(train.corr().Survived.index, train.corr().Survived):
if abs(corr) < 0.1:
drop_cols.append(col)
print('Dropping cols:', drop_cols)train = train.drop(drop_cols, axis=1)
test = test.drop(drop_cols, axis=1)

印出

Dropping cols: ['Age', 'SibSp', 'Parch', 'Title', 'Family']

接著我們用熱圖檢視剩餘特徵彼此之間的相關係數:

plt.figure(figsize=(12, 12))
sns.heatmap(data=train.corr(), annot=True, vmin=-1, vmax=1, cmap='viridis')
plt.show()

這裡可以注意到,Pclass 與 3rd_class 以及 Deck 都有高相關度(共線性),而 Sex 與 Mr 亦然。

理論上我們應該捨棄 3rd_class、Deck 與 Mr,但我決定轉而捨棄 Pclass 與 Sex,這樣能多保留一個特徵,並讓所有相關度維持在正負 0.6 以下:

drop_cols = ['Pclass', 'Sex']train = train.drop(drop_cols, axis=1)
test = test.drop(drop_cols, axis=1)

檢視最終特徵的相關度熱圖:

plt.figure(figsize=(10, 10))
sns.heatmap(data=train.corr(), annot=True, vmin=-1, vmax=1, cmap='viridis')
plt.show()

你可以看到,我們透過特徵工程產生 6 個新特徵,加上原始資料集的 2 個特徵,做為這個模型要使用的資料。

訓練模型與預測

和前面一樣,我們用完全一樣的方式以 FLAML 來訓練模型,時間改為 60 秒:

from flaml import AutoMLclf = AutoML()
clf.fit(dataframe=train, label='Survived',
task='classification',
estimator_list=['lgbm', 'xgboost', 'xgb_limitdepth', 'rf', 'extra_tree'],
metric='ap',
time_budget=60)

訓練完後印出最佳模型的參數:

print('Best ML leaner:', clf.best_estimator)
print('Best hyperparmeter config:\n', clf.best_config)

得到

Best ML leaner: lgbm
Best hyperparmeter config:
{'n_estimators': 11, 'num_leaves': 106, 'min_child_samples': 12, 'learning_rate': 0.33337728353417095, 'log_max_bin': 9, 'colsample_bytree': 0.4938513205517679, 'reg_alpha': 0.01575295559037546, 'reg_lambda': 0.008659013459249926}

最後產生預測值及 CSV 檔:

predicted = clf.predict(test).astype('uint8')submission = pd.DataFrame({'PassengerId': passenger_id, 'Survived': predicted})
submission.to_csv('./submission.csv', index=False)

上傳 Kaggle 後得到分數:

0.79186

用 Stacking 集成模型會更好嗎?

不會,我至目前為止測試的結果都顯示這會更容易過度配適(見後討論)。

Photo by Sigmund on Unsplash

用正確的資料會更好嗎?

2019 年,一位 Kaggle 使用者提供了 Titanic extended dataset,就是在原有的資料集上附加維基百科的乘客名單資料,並透過考證修正了有問題的資料:

此資料集的欄位及缺失值狀況如下:

PassengerId      0
Survived 0
Pclass 0
Name 0
Sex 0
Age 177
SibSp 0
Parch 0
Ticket 0
Fare 0
Cabin 687
Embarked 2
WikiId 2
Name_wiki 2
Age_wiki 4
Hometown 2
Boarded 2
Destination 2
Lifeboat 546
Body 804
Class 2

新增的欄位意義為:

  • WikiId — 乘客在維基百科名單上的編號
  • Name_wiki — 乘客在維基百科名單上的姓名
  • Age_wiki — 乘客在維基百科名單上的年齡
  • Hometown — 乘客的家鄉
  • Boarded — 出發地(等同於 Embarked 但多了一個 Belfast,鐵達尼號駛出的造船廠港口)
  • Destination — 乘客的目的地
  • Lifeboat — 獲救乘客搭乘的救生艇號碼
  • Body — 尋獲的死亡乘客的遺體編號
  • Class — 艙等(等同於 Pclass 但更為正確)

要特別注意的是 Lifeboat 與 Body 和生還結果直接相關,而有些人直接拿這些資料來做預測,自然能得到非常好的結果。

可以看到 Age_wiki 和 Class 的缺失值都只有 2 個,拿它們來取代 Age/Pclass 會有比較準確的結果嗎?我自己試過幾次,最好的結果是 0.78468,和前面的模型所得結果差不了多少。這或許顯示在艙等和性別的因素之外,其他特徵對整個模式的影響已經微乎其微了。

當然還有另一個問題,就是延伸資料集的新欄位內缺了 2 名乘客的資料,但在舊資料內有。此外我試過查詢實際生還人數,但許多來源的數字都略有差異(最終我引用的來源是 Encyclopedia Titanica,但這數字又跟本篇資料集的人數不符)。會不會就連到底哪些人生還,在原始資料集內都是可能有誤的呢?我們終究只是在對一個人為製造的模式嘗試做預測嗎?

海洋之心:追逐分數的陷阱

Photo by Chase Baker on Unsplash

在提交答案時,你應該會發現模型對訓練集的預測準確率總是遠高於測試集。難道是模型發生過度配適(overfitting)?要怎麼知道怎樣的模型才是好模型?

答案是:你不知道。因為正常情況下我們不曉得測試集的正確答案,所以無法進一步深入驗證預測效果。另一個問題是正如前面所提,訓練集和測試集的生還模式是有差異的,所以過度配適幾乎一定會發生。(例如,我的第三個模型對訓練集的準確率其實高達 0.87542!)

過度配適

此外,你經常能看到人們在網路上寫說他們如何取得 > 0.82 的分數,這是怎麼做到的?我花了許多時間瀏覽此競賽的討論區,整理出人們如何「拿高分」的辦法:

  1. 手動調校模型到過度配適、但剛好也能「矇對」較多答案的程度。
  2. 不少人會混用測試集做特徵工程(如用測試集年齡協助推算遺失值),甚至使用和生還與否有高度關係的新特徵(如延伸資料集的救生艇號碼和屍體編號) — — 換言之,製造資料洩漏(data leakage)讓模型學習測試集的模式。
  3. 拿網路上包含解答的完整資料集來給模型訓練。
  4. 直接上傳網路上取得的解答(即分數 1.0 或預測率 100% 的作弊組) 。

還有必須注意的是:此競賽本來有 Public Leaderboard 和 Private Leaderboard 兩塊,也就是把測試集答案切成一半一半來評分,後者只在競賽結束時才會公布,好避免參賽者過度衝高公開測試集的分數。但這也使得要取得較高的公開分數並不難。

大約在 2020 年 7 月,Kaggle 將此競賽改成只有 Public Leaderboard(使用整個測試集),而很多人的分數都往下掉了,這點便顯示許多人的模型確實存在著過度配適現象;你在網路上看到號稱能得到 0.8x 的解法可能已經不可行。我在用 FLAML 訓練時,後來也發現訓練越久效果反而容易變差。

使用測試集資料是允許的嗎?

Photo by Daan Mooij on Unsplash

資料洩漏的定義如下:

In statistics and machine learning, leakage (also known as data leakage or target leakage) is the use of information in the model training process which would not be expected to be available at prediction time, causing the predictive scores (metrics) to overestimate the model’s utility when run in a production environment.

簡單地說,如果你把測試集的資訊混入訓練集,那麼模型就更能適應測試集的生還者模式和得到更好的分數,但這也將令模型對其他新資料的預測能力變差。只是也有人主張:鐵達尼號的測試集在訓練時早就存在,反正也不可能有新資料了,所以為何不能拿來用?

就前面的字面定義來看,這麼講其實也沒有錯,但這並不是你在正常 ML 流程該做的事。而 Kaggle 初學者們往往也不明所以,先抄襲別人的做法衝分數再說,甚至組合多個現成解法來提高分數,這種風氣也就延續下來了。事實上,混用測試集的行為在該站的每個練習競賽都普遍存在。

其實不只是初學者會這樣而已:2019 年初的 PetFinder.my 競賽作弊事件,參與者之一就是 Kaggle 的一位 Grandmaster,而至少在對岸也有花錢買獎牌的風氣。

就像有人說的:『Kaggle 作為一場比賽,可以體現你在數據科學方面的實力,然而打贏 Kaggle 和成為一個好的數據科學家並不能劃等號,對任何人來說,數據科學的這條路一定是越走越深入,Kaggle 也許只是一個證明自己的過程。』

有些人的特徵工程還做到了非常誇張的程度:比對兩個資料集中姓氏和票根來尋找同行家族,依歷史資料配對跟某家庭同行的僕人/保母等,並依其他家人的生存率來推算該名乘客是否存活。有人甚至會拿藍圖中艙房/甲板跟救生艇的距離、乘客的國籍來做為參考。網上有各式各樣的做法用來產生大量的特徵(這或許確實能拉近兩個資料集的模式),我們卻很少能確定怎樣才是「合適」的,或者只是亂槍打鳥而已。

怎樣的分數是正常的「好」分數?

Photo by Hello I'm Nik on Unsplash

既然 Kaggle 上的分數問題重重,怎樣的分數才是正常分數呢?排行榜是以絕對數值排序,這很難反映真實狀況,所以我們可以下載公開排行榜的 raw data,改用直方圖來看分數的分布:

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
score_df = pd.read_csv('./titanic-publicleaderboard.csv')plt.figure(figsize=(10, 10))
sns.histplot(data=score_df, x='Score', bins=100, kde=True, stat='percent')
plt.xlim([0.0, 1.0])
plt.grid(True)
plt.show()

Kaggle 也設定此競賽會保留分數兩個月,之後就會移除。所以這裡的分數都必然是近兩個月內的。

能注意到在 0.0 與 1.0 各有一小塊突起。1.0 是完全作弊組(0.0 組則很可能是遞交答案時用了錯的資料型別,例如浮點數)。

我們只看中間較有關係的部分,重新繪製直方圖:

plt.figure(figsize=(10, 10))
sns.histplot(data=score_df, x='Score', bins=200, kde=True, stat='percent')
plt.xlim([0.65, 0.85]) # 只看 0.65~0.85 區間
plt.grid(True)
plt.show()

若預測所有人都死亡,分數為 0.627。

可以發現最多分數落在 0.765~0.785 的區間,所以這應該可視為此競賽的「正常」分數,我自己試過較好的分數都在是 0.79 上下。因此個人認為,在現況下要超過 0.8 是非常困難的,能達到的話也很可能是出於過度配適跟運氣的結合,不然就是使用了其他的手段。

考慮到本文第一個超簡單模型就能有 0.77,也許花這麼多力氣去提升僅僅 2% 的準確率、還要冒著過度配適的風險,其實是我們自己把問題搞得太複雜了。

預測船難生還與否的反思

而看到這裡,我們還很容易忘記一個事實:鐵達尼號事件乃是歷史上特例中的特例,為一連串重大失誤與不幸的總和釀成的結果。這當中牽涉的因素(雜訊)實在太多,不是任何模型能夠輕易解釋和預測的。

比如,出於過時的航海觀念及法規,鐵達尼號攜帶的救生艇數量嚴重不足;過去撞擊冰山的案例幾乎無害,鐵達尼號船頭的人工接合鉚釘卻碰巧成了弱點;船員訓練不足,又害怕救生艇翻覆,所以許多救生艇沒有坐滿人,且只有一艘在沈船後嘗試回頭救援落海者(在零下 2 度海水中,只要泡 15 至 30 分鐘就會送命);船長忽視冰山警告,觀察員在冷暖洋流交會的海域視野不佳,而當晚 20 海浬遠的加州人號(SS Californian)也誤判鐵達尼號的身分及求救意圖,直到太晚才抵達現場。

我們也聽過許多生存者的神奇故事:一位說法語的斯洛伐克移民用假名帶兩位兒子搭二等艙,逃難時自我犧牲,使這對小兄弟成了唯一沒有父母的兒童倖存者;女服務生 Violet Jessop 在四年後的一戰期間於改裝成醫護船的不列顛號(鐵達尼號的姊妹艦)上再度經歷沉船並倖存;唯一的日本乘客細野正文回國後,終生背負著懦夫的汙名;更別提有六名三等艙的中國水手神奇獲救,其中一人就是抓著海面上漂流的門板、被折回的救生艇找到的(這成了卡麥隆電影中蘿絲獲救的靈感。事實上,電影還真有這個刪減片段!)。

鐵達尼號的悲劇推動了法案、航海安全與船體設計的革新,還有冰山巡邏隊的成立等等,所以同樣的事應該不會再發生。話說回來,誰又能料到像是韓國世越號(船隻不當轉向、貨物超載、船體缺陷以及錯誤的撤離方式等)這樣的悲劇?當鐵達尼號出航時,幾乎沒有人認為它會出事,這正是為何它的沉沒會如此教人震驚。再多的乘客資料也無法讓你預測到上述的天災人禍會在何時何地發生。

所以如今預測誰會在船難中倖存,就只剩下歷史意義,除此以外就是讓你練習基本的機器學習流程,以及學習一些能對資料做的預處理手段、怎麼試著用特徵工程抽取一些模式等等,順便理解為什麼這個資料集本身會產生預測上的落差。如何避免資料洩漏是當中的大重點,但許多人會在追逐分數的驅使下有意無意忽視它。也許誠如某位 Kaggle 使用者說的,這才是鐵達尼號最大的「災難」吧。

Photo by WEB AGENCY on Unsplash

--

--