冰冷的海水,複雜的路線
生死已成定局,但因果仍成迷思
今天,再現過往
📝 前言
寫完了基礎心得後,我們進入一題經典的題目「鐵達尼號生存預測」。
這篇文章記錄了我第一次進行鐵達尼號比賽,以及後來又再度認真的玩這個比賽的過程,會介紹一下我最終的code以及最一開始到最終的心路歷程,分享給大家做參考。
第一次碰到這個題目是在碩士班修了一門人工智慧的課程,一學期的課程能講得不多,老師集中在機器學習這方面做講解,也因為是在職碩士班的課程,同學不見得都有寫程式的經驗,老師就以基本架構,ML的邏輯,然後提供範本code給我們修改的模式上課,那時候對python的各個套件一點概念也沒有,所以也不清楚到底能有什麼功能能改,只敢改改種子碼跟一些超參數還有訓練集比例,過了兩年後,某天突然想到這個作業,才正式的踏入這個領域內。
🧭背景介紹
「Titanic - Machine Learning from Disaster」是一個以鐵達尼號沈船事件為背景的資料預測任務。1912 年,白星航運公司旗下的鐵達尼號首航,卻在途中撞上冰山,加上當時的一連串決策失誤,最終釀成歷史上最知名的海難之一。
所幸當時留下了詳細的乘客紀錄,包括性別、年齡、船艙階級、登船地點與最終是否生還等資訊,雖然不免有部分遺漏與誤差,但這樣的歷史資料,已足以成為機器學習的絕佳練習素材。
這份資料因為已經公開多年,且資料規模不大,其正確答案早已被試驗出來,因此Leaderboard 出現了 100% 準確的分數,不過這仍然是一個很好的練習題目:小而精、容易觀察每一個處理環節的影響。下面,我們就進入這題的過程。
※由於 Titanic 是 Kaggle 的入門練習題,並不是正式競賽,因此它只有公開 leaderboard,並沒有私有分數排名。
📊 資料初探
拿到一份資料,首先要做的事情不是急著跑模型,而是先認識資料本身。包含它有哪些欄位、欄位的資料型態、是否有缺值、這些欄位看起來合理嗎,其實就是整份任務的第一個挑戰。
好在 Python 提供了很多工具可以幫助我們快速檢查資料的樣貌。
1. 載入資料集
from google.colab import drive
drive.mount('/content/drive')
train_path = '/content/drive/My Drive/"你的資料路徑"/train.csv'
test_path = '/content/drive/My Drive/"你的資料路徑"/test.csv'
我這邊使用的是 Google Colab 作為運算環境,所以要先掛載 Google 雲端硬碟,才能讀取存放在雲端的 CSV 資料。如果你是在 Kaggle Notebook 上操作,也會有類似的步驟來讀取比賽的資料集。
2.檢查訓練資料的結構
接著,我們先讀入訓練資料,並使用 .info() 來快速檢查整體欄位資訊:
import pandas as pd
df_train = pd.read_csv(train_path)
df_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
我們可以知道我們擁有以下資料欄位:
- PassengerId
乘客編號(流水號) 數值(int64) - Survived
是否生還(標籤,1 表示生還,0 表示死亡)
數值(int64) - Pclass
艙等(1、2、3 等級,數字越小代表越高級) 數值(int64) - Name
乘客姓名 字串(object) - Sex
性別 字串(object) - Age
年齡 數值(float64) - SibSp
同行的兄弟姊妹與配偶數 數值(int64) - Parch
同行的父母與子女數 數值(int64) - Ticket
船票號碼 字串(object) - Fare
票價 數值(int64) - Cabin
艙房號碼 字串(object) - Embarked
登船港口(C = Cherbourg,Q = Queenstown,S = Southampton) 字串(object)
除了欄位名稱,info() 也給我幾個非常重要的訊號:
- 資料共有 891 筆,這個量不大,但足夠做為分類任務練習。
Age缺值比例高達約 20%(只剩 714 筆)。Cabin缺值比例極高,只有 204 筆,幾乎整欄都是空的。Embarked缺值雖少,但還是缺了 2 筆,需要補值。- 欄位型態混合,有整數(int)、浮點數(float)與文字(object)等,這些之後需要進行適當的轉換與處理。
雖然我們資料分析與補值邏輯都只會看train資料集,但我們還是看一下test資料集裡的情況
df_train = pd.read_csv(test_path)
df_train.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 418 entries, 0 to 417
Data columns (total 11 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 PassengerId 418 non-null int64
1 Pclass 418 non-null int64
2 Name 418 non-null object
3 Sex 418 non-null object
4 Age 332 non-null float64
5 SibSp 418 non-null int64
6 Parch 418 non-null int64
7 Ticket 418 non-null object
8 Fare 417 non-null float64
9 Cabin 91 non-null object
10 Embarked 418 non-null object
dtypes: float64(2), int64(4), object(5)
memory usage: 36.1+ KB
資料顯示:
- 資料共有 418 筆
Age一樣有缺值。Cabin缺值比例一樣極高。Fare有一個缺值,後續得處理。
至此,我們確認了資料的狀態。
這時候其實你已經可以選擇直接補個平均值,把缺值填一填,丟一個模型上 leaderboard 試水溫,
而剛好我第一次在課堂上時也是這麼做的,簡單補值後丟了個隨機森林,然而出來的分數。

準確率 0.7538,沒特別好。撇開那些拿到 1.0(通常是 overfit 或作弊)的人,這個分數在排行榜上也算後段班。 有趣的是,官方的範例答案,就已經落在了 0.7655 ,也就是這樣隨便補個平均上去的結果並沒有提升。
3.資料分析
🧮 欄位分布分析(Univariate Analysis)
首先,我們先看看每個欄位的資料狀況。
df_train.head(100)
輸出:

礙於篇幅我這邊只顯示前5個乘客訊息,但實際在看的時候可多看一點,這邊可以看到票價上下差距很大,年齡也是,我們可以對這些進行分析。
📈 數值欄位的分布觀察
我們先針對幾個關鍵的數值欄位來看分布情形,包含 Age(年齡)、Fare(票價)、SibSp(兄弟姊妹與配偶數)與 Parch(父母與子女數)等,這些都可能與生存率有所關聯。
年齡(Age)
plt.figure(figsize=(8, 5))
sns.histplot(df_train['Age'].dropna(), bins=30, kde=True)
plt.title("Age Distribution")
plt.xlabel("Age")
plt.ylabel("Count")
plt.show()

這張圖可以看出:
- 年齡分布偏右,有大量乘客集中在 20~40 歲之間。
- 幼童(0~10歲)也佔有一定比例,這部分值得關注是否影響生存率。
票價(Fare)
plt.figure(figsize=(8, 5))
sns.histplot(df_train['Fare'], bins=40, kde=True)
plt.title("Fare Distribution")
plt.xlabel("Fare")
plt.ylabel("Count")
plt.show()

從票價分布可以看出:
- 多數乘客的票價落在低價區(<50)
- 高票價(>100)屬於少數乘客
可以考慮對 Fare 做 對數轉換(log1p),讓分布更對稱,幫助模型學習。
📊 分類欄位的分布觀察
除了數值欄位,我們也可以看幾個類別型欄位的分布:
性別(Sex)
sns.countplot(x='Sex', data=df_train)
plt.title("Sex Distribution")
plt.show()
艙等(Pclass)
sns.countplot(x='Pclass', data=df_train)
plt.title("Pclass Distribution")
plt.show()
登船港口(Embarked)
sns.countplot(x='Embarked', data=df_train)
plt.title("Embarked Distribution")
plt.show()
篇幅問題我這邊省略這三張圖。
這些類別型欄位的分布可以幫助我們理解樣本比例是否失衡,例如:
- 男性佔多數,但女性生存率更高(這在後續交叉分析會用到)
- Pclass = 3 的乘客人數最多(但也是生存率最低的群體)
- 登船港口以
S為主(Southampton)
我們大概了解了數值狀況,我們就可以來對各項特徵進行交叉分析。
交叉分析
🚻性別與生存率:
如果各位有看鐵達尼號的電影,或著一些分析影片的話,應該會知道,在船員疏散船客時有一個溝通上的失誤,從婦幼優先變成了只有婦幼能上救生艇,導致許多救生艇並沒有載滿就下水,因此我們就先來分析性別上對生存的差異。
import seaborn as sns
import matplotlib.pyplot as plt
sns.countplot(x='Sex', hue='Survived', data=df_data)
df_train[["Sex", "Survived"]].groupby("Sex").mean().round(3)
display(df_data[["Sex", "Survived"]].groupby(['Sex'], as_index=False).mean().round(3))
輸出:


從圖表與數據來看,性別與生還率之間的關係非常明顯:
- 女性的生還率高達 74.2%
- 男性則只有 18.9%
如同歷史資料留下的結果,同時也代表了 Sex 是很關鍵的欄位之一
有趣的是,我一開始在課堂作業中只是簡單補值就丟模型進去測試分數,結果也大概就是落在 77%~78% 左右。而從現在這個分析來看,光是性別一欄就幾乎能提供 74% 的分類線索了
🛏️艙等與生存率:
但我們的目標是往80%準確率以上走,因此只有性別恐怕是不夠我們去進行補值與特徵工程,在當時,低艙等旅客的逃生速度普遍較慢,有些甚至被船員故意阻攔,封鎖通往甲板的出口。這讓我們有理由相信,**艙等(Pclass)**可能也會與生還率有很大的關聯。
我們先來畫出艙等與生還率的分布圖,並計算每一艙等的生還率平均值。
#這邊開始會省略已經宣告過的函式
sns.countplot(x='Pclass', hue='Survived', data=df_data)
df_data[["Pclass", "Survived"]].groupby(['Pclass'], as_index=False).mean().round(3)
輸出:


可以從圖表發現,第三艙等的死亡率特別高,而第一艙等則突出了生存率。
這時候,你可能會想:
「欸?那我要不要把 Embarked、Fare、Age、SibSp... 全部都拿出來畫一次?」
這當然可以,不過你會很快發現:當特徵數量一多,這樣的土法煉鋼很快就會變成體力活,尤其遇到那些有上百個欄位的大型資料集時,光是圖一張張畫完可能你就已經老了。
那有沒有更聰明的辦法?
🧠 有的,我們可以使用「熱力圖」(Heatmap)
我們接下來要介紹一個非常實用的視覺化工具:Seaborn Heatmap。它能幫助我們一次檢視所有數值型欄位彼此之間的關聯程度,尤其是我們關注的 Survived 和其他欄位的關係。
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
# 把 object(字串)類別排除,只留下數值欄位
numeric_cols = df_train.select_dtypes(include=['number'])
# 計算各欄位的 Pearson 相關係數
corr_matrix = numeric_cols.corr()
# 畫熱力圖
plt.figure(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=True, cmap='coolwarm', center=0, fmt=".2f")
plt.title("Correlation Between Numerical Features", fontsize=16)
plt.tight_layout()
plt.show()

🔍 如何解讀這張圖?
這張圖,其實就是所謂的「相關係數矩陣」(correlation matrix),我們用熱力圖的方式將它視覺化。
- 值越接近 +1:代表這兩個欄位正相關越強(一起升高或降低)
- 值越接近 -1:代表這兩個欄位負相關越強(一個升高、另一個下降)
- 值接近 0:代表這兩個欄位幾乎沒有線性關聯
舉例來說,在 Titanic 資料中:
Pclass和Fare有明顯的負相關(越高級的艙等通常票價越高)Survived和Sex在這張圖中其實無法直接觀察,因為Sex是類別型欄位(object 型別)
這種熱力圖雖然無法涵蓋所有特徵(特別是類別型的),但它對於數值欄位來說是一個非常快速的篩選工具。我們可以透過這張圖:
- 找出跟
Survived高度相關的數值欄位(作為建模優先特徵) - 找出高度相關的欄位(例如
SibSp和Parch),考慮是否合併為新的欄位(如 FamilySize) - 濾掉與其他欄位過度重複的變數,減少冗餘資訊
這樣的工具能讓我們在進行補值、特徵工程前,先對整體數據結構有個清晰掌握,也幫助我們釐清「哪些特徵值得深入分析」。
補充:若是類別類怎麼辦
我們可以考慮將類別類編碼,但是這樣容易讓資料誤判有大小順序的關係,因此我們用另一種方式針對類別類與生存率的關係顯示。
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
# 找出類別型欄位(包含 Pclass,排除 Name、Ticket、Cabin)
cat_cols = df_train.select_dtypes(include='object').columns.tolist()
cat_cols += ['Pclass']
cat_cols = [col for col in cat_cols if col not in ['Name', 'Ticket', 'Cabin']]
# 建立生存率對應表
survival_rate_table = pd.DataFrame()
for col in cat_cols:
temp = df_train[[col, 'Survived']].dropna()
rates = temp.groupby(col)['Survived'].mean().T.to_frame().T
rates.index = [col]
survival_rate_table = pd.concat([survival_rate_table, rates], axis=0)
# 繪製熱力圖
plt.figure(figsize=(12, len(survival_rate_table)*0.5))
sns.heatmap(survival_rate_table, annot=True, cmap="YlGnBu", fmt=".2f")
plt.title("Survival Rate by Category")
plt.xlabel("Category Values")
plt.ylabel("Feature")
plt.show()

這樣就能看到每個類別類與生存率的關係,這裡你可能會好奇,為什麼只有性別艙等與登船港口,姓名與艙名那些被我排除了,以下顯示一下如果未排除的圖。

姓名每個人都不一樣,票也是,艙等也是很混亂,而且他們幾乎都是唯一個體,直接這樣分析意義不大,且也沒辦法顯示這麼多數值,故直接拿掉會比較方便。
小結
資料處理與交叉分析基本上是「多多益善」,尤其在這一階段,能看見越多特徵之間的關係,就能在後續的補值與特徵工程階段有更多選擇空間。有時候甚至會在做特徵工程時,想到某個欄位值得再回頭深入分析。
不過礙於篇幅,我們這裡無法將每個可能性一一展開,只挑選幾個在目前看來對生存率影響較大的變數進行進一步分析與視覺化,包括 Sex、Pclass、Fare 等。這些將成為我們後續建模與缺失補值的重要依據。
🔧 特徵補值與工程(Feature Engineering)
進入了決定分數高不高的重要環節,補值與特徵工程,這一段也是我覺得最難讓人掌握的階段,到底該怎麼補值,特徵工程該製造哪些新的特徵,甚至要挑選哪些特徵進入訓練,哪些特徵是可以不要的,他們會不會影響模型,這些都還是未知的狀態。
在資料探索中,我們已經發現多個欄位存在缺值,其中以 Age 與 Cabin 最為明顯。而有些欄位看似混亂,實際上可能蘊藏資訊(例如 Name、Ticket)。本段將針對缺值處理與欄位再造進行特徵工程。
目前的缺值狀況
從前面我們對train及test兩個資料用 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
RangeIndex: 418 entries, 0 to 417
Data columns (total 11 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 PassengerId 418 non-null int64
1 Pclass 418 non-null int64
2 Name 418 non-null object
3 Sex 418 non-null object
4 Age 332 non-null float64
5 SibSp 418 non-null int64
6 Parch 418 non-null int64
7 Ticket 418 non-null object
8 Fare 417 non-null float64
9 Cabin 91 non-null object
10 Embarked 418 non-null object
dtypes: float64(2), int64(4), object(5)
memory usage: 36.1+ KB
其中 Age 與 Cabin 缺值嚴重, Embarked 在train資料有些微缺失, Fare 在test資料有一筆缺失,因此我們先對age做補值。
一、針對 Age 的補值
一般人想到補值,可能會考慮直接使用總體平均數去補值,然而此舉並不是一個適合的方法去補值,首先我們知道年齡對存活率的關係是-0.08,只有極微小的關係,但這時會有個疑問:
「既然關係這麼小,那是不是乾脆可以放棄這項特徵?或著用平均數去補就好」
此時我們就要關係到年齡的問題了
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
#合併檔案(code裡面我是放到最前面就做了)
df_data = pd.concat([df_train,df_test])
# 分年齡群組
bins = [0, 12, 18, 35, 60, 100]
labels = ['Child', 'Teenager', 'Young Adult', 'Adult', 'Senior']
#這裡開始是用df_data這個train跟test合併的檔案,一起做觀察跟補值
df_data['AgeGroup'] = pd.cut(df_data['Age'], bins=bins, labels=labels)
# 計算各年齡層的存活率
age_survival = df_data.groupby('AgeGroup')['Survived'].mean().reset_index()
# 繪圖
plt.figure(figsize=(8, 5))
sns.barplot(data=age_survival, x='AgeGroup', y='Survived', palette='YlGnBu')
plt.title('Survival Rate by Age Group')
plt.ylabel('Survival Rate')
plt.xlabel('Age Group')
plt.ylim(0, 1)
plt.show()
我們已12, 18, 35, 60分別做0~100的區間,輸出為:

這裡可得知,最小的年紀小孩區間與最大的老人區間,生存率居然差到一倍,然而為甚麼在熱力圖上卻只有-0.08的相關係數呢,因為中間青少年至大人的區間過於平均,導致相關係數被沖淡,但也因為年齡這個特徵,在小孩與老人有一定的辨別度,故用平均值去補充會稀釋掉他們這兩個特徵所能帶來的辨別度,也因此,我們該如何補年紀這個值呢?
這時我們可以看到一個最能代表年紀的欄位「姓名」,男士要尊稱Mr.,女士尊稱Mrs.,我們可以利用這個關係去對每個稱謂的人做分群,並且補上該群眾的平均數,這樣就可以盡量避免全體平均數導致稀釋掉特殊個體的影響。
🧑🎓 利用 Name 提取稱謂 Title 作為補值依據
在 Titanic 資料中,每個人的名字中都含有尊稱(Title),例如:
Mr.:成年男性Miss.:未婚女性,通常較年輕Mrs.:已婚女性,年齡偏大Master.:小男孩(雖然Master意思應該是大師,但在早期的航旅訂位系統,Master代指小男孩)Dr.、Rev.、Col.等:具有職業稱謂的乘客
透過這些稱謂,我們能大致推估乘客的年齡範圍,因此我們可以用這個資訊來進行缺失值補值。
首先,我們先看每個稱呼有多少人,足不足夠拿來做平均數
# 萃取 Title
df_data['Title'] = df_data['Name'].str.extract(' ([A-Za-z]+)\\.', expand=False)
# 看各稱謂人數
title_counts = df_data['Title'].value_counts().sort_values(ascending=False)
print(title_counts)
輸出:
Title
Mr 757
Miss 260
Mrs 197
Master 61
Rev 8
Dr 8
Col 4
Major 2
Mlle 2
Ms 2
Mme 1
Don 1
Sir 1
Lady 1
Capt 1
Countess 1
Jonkheer 1
Dona 1
Name: count, dtype: int64
由上我們可以知道還是有些獨特的稱呼,或著出現了一兩個沒有稱呼的,我們可以做以下方法先將特殊稱呼合併。
# 合併稱謂
df_data['Title'] = df_data['Title'].replace({
'Mlle': 'Miss',
'Ms': 'Miss',
'Mme': 'Mrs',
'Lady': 'Rare',
'Countess': 'Rare',
'Capt': 'Rare',
'Col': 'Rare',
'Don': 'Rare',
'Dr': 'Rare',
'Major': 'Rare',
'Rev': 'Rare',
'Sir': 'Rare',
'Jonkheer': 'Rare',
'Dona': 'Rare'
})
# 查看整理後的分布
df_data['Title'].value_counts()
輸出:

這樣我們就能利用這些去做捕值
# 計算每個稱謂的年齡中位數
title_age_median = df_data.groupby("Title")["Age"].median()
# 依照稱謂補值
def fill_age(row):
if pd.isnull(row["Age"]):
return title_age_median[row["Title"]]
else:
return row["Age"]
df_data["Age"] = df_data.apply(fill_age, axis=1)
df_data.isnull().sum()
輸出:

各位發現剛剛創建的 AgeGroup出現缺值,因為最早我們分群之前還沒有補值,這裡只要重複一次分群就可以了,也可以重新跑一次各年紀的生存率。
而 Survived 則是原本測試集裡本來就空白的部分。

雖然青少年群體的生存率稍微下降,但整體年齡群體的辨別力依然維持,尤其是小孩與長者依然呈現兩極化,因此這樣的補值方式保留了資料的結構性,沒有導致扭曲。
🚢 二、補值 Embarked 欄位
Embarked 代表乘客上船的港口,總共有三種值:
- S:Southampton
- C:Cherbourg
- Q:Queenstown
在 train 資料中,我們只發現兩筆缺值。
# 查看缺值在哪裡
df_data[df_data['Embarked'].isnull()]

結果顯示這兩位乘客的票價是 Fare = 80,而艙等是頭等艙,我們可以觀察整體中和這些條件相同的乘客大多從哪個港口登船:
# 觀察頭等艙、票價 80 附近乘客的上船地
df_data[df_data['Pclass'] == 1].groupby('Embarked')['Fare'].median()

可以看出從 Cherbourg (C) 登船的頭等艙乘客票價最接近這兩位缺值乘客,因此我們使用眾數補值法(推論型),將缺值填上 'C':
#補上C
df_data['Embarked'] = df_data['Embarked'].fillna('C')
💰 三、補值 Fare 欄位(測試集)
這欄只有一筆缺值,發生在測試資料中:
df_data[df_data['Fare'].isnull()]
可觀察該乘客的 Pclass 與 Embarked 為何,再用這兩項條件篩選出類似群體的中位數來補值:
# 觀察該筆資料
df_data[df_data['Fare'].isnull()][['Pclass', 'Embarked']]
# 以 Pclass = 3 且 Embarked = S 的乘客中位數作為補值
fare_median = df_data[(df_data['Pclass'] == 3) & (df_data['Embarked'] == 'S')]['Fare'].median()
df_data['Fare'] = df_data['Fare'].fillna(fare_median)
這樣做的好處是保留了該乘客合理的社經背景預估,而不是直接用整體平均數。
🛏️ 四、Cabin 欄位處理策略
Cabin 缺值太多(超過 77% 缺失),要直接補上原本格式難度很高。這裡可以選擇:
首先是僅取首字母(代表艙段),並補上 "Unknown"
# 只取 Cabin 首字母(代表甲板層級),並補上 Unknown
df_data['Cabin'] = df_data['Cabin'].astype(str).str[0]
df_data['Cabin'] = df_data['Cabin'].replace('n', 'Unknown')
這樣就可以將 Cabin 簡化為一個等級變數,用於後續分析與模型處理。
其實在缺失這麼大的程度上,對整體資料很難有甚麼大幫助,其實也能選擇直接放棄這個欄位,將它整個drop掉,或著先補,後續在選擇不要加入該特徵。
🛠️ 特徵轉換與欄位製造(Feature Transformation & Creation)
補值完成後,我們已經讓大部分的資料變得完整,但接下來更關鍵的一步,是「創造有意義的新欄位」,以及「讓資料變成模型能夠理解的形式」。
這個步驟將幫助我們:
- 提取潛在資訊(例如家庭成員數、是否獨自一人)
- 建立更有辨別力的欄位(例如:從稱謂推測社會地位)
- 轉換類別資料為模型可接受的格式(如 one-hot 或 label encoding)
1️⃣ 建立 FamilySize 與 IsAlone 欄位
在 Titanic 的生存率中,「是否有人陪伴」是一個很強的訊號。搭乘 Titanic 的旅客中,如果有家人同行,或許在危急時刻有較高機會互相幫助、被發現或逃生。
# 建立家庭人數欄位(包含自己)
df_data['FamilySize'] = df_data['SibSp'] + df_data['Parch'] + 1
# 建立是否獨自一人欄位
df_data['IsAlone'] = (df_data['FamilySize'] == 1).astype(int)
# 確認新增欄位
df_data[['SibSp', 'Parch', 'FamilySize', 'IsAlone']].head()
我們可以觀察 FamilySize 與 Survived 的關聯性:
# 分析家庭人數與生存率的關係
sns.barplot(data=df_data[df_data['Survived'].notnull()], x='FamilySize', y='Survived', palette='Set2')
plt.title('Survival Rate by Family Size')
plt.xlabel('Family Size')
plt.ylabel('Survival Rate')
plt.ylim(0, 1)
plt.show()

我們可以觀察到,1人時存活率偏低,2~4人時較高,太多人時又偏低。
擴展:是否共用票號(Shared Ticket)
除了家庭成員,我們也觀察到有些乘客雖然 SibSp 與 Parch 都是 0,但卻與其他人共用同一張票,可能代表朋友或旅行團體。
這類乘客雖非家庭,但可能在船上有一定程度的互助或影響,因此我們可以新增一個 SharedTicket 欄位,標記這種情況。
# 建立是否共用票號欄位(若票號出現超過 1 次,則視為共用)
df_data['SharedTicket'] = df_data.duplicated(subset='Ticket', keep=False).astype(int)
這樣就能簡單標記出「與他人共用票號」的乘客群體。這類群體在生存率分析上可能會有額外價值,後續建模可視情況納入比較。
2️⃣ 精簡 Title 為模型用的類別欄位
我們在前面 Age 補值時已經抽出了 Title,並做過合併,這裡我們可將其轉為類別型變數(用於後續 one-hot 或 label encoding):
df_data['Title'] = df_data['Title'].astype('category')
後續我們可選擇轉為數值(如 label encoding),或直接做 one-hot。
3️⃣ 精簡 Cabin 資訊(只取首字母)
原始 Cabin 欄位非常稀疏而且混亂,但艙等首字母仍代表一種信息(靠船頭還是船尾、上層或下層),我們只保留字母部份作為分類欄位:
# 只取首字母
df_data['CabinInitial'] = df_data['Cabin'].str[0]
df_data['CabinInitial'] = df_data['CabinInitial'].fillna('U') # 以 'U' 作為未知艙等的標記
4️⃣ 處理類別型欄位的轉換
我們目前常見的類別欄位包括:

若使用 樹模型(如 XGBoost、LGBM) 可直接 label encoding;若用 線性模型 則建議用 one-hot。
from sklearn.preprocessing import LabelEncoder
cat_cols = ['Sex', 'Embarked', 'Title', 'CabinInitial']
for col in cat_cols:
le = LabelEncoder()
df_data[col] = le.fit_transform(df_data[col].astype(str))
5️⃣ 針對票價的分箱處理
我們也可以將 Fare 欄位進行分箱(binning),用不同數量的區間切分票價,並轉換為類別編碼,以觀察其與其他特徵(如 Pclass)的對應關係,並作為後續建模的備選特徵之一。
# 分箱
df_data['FareBin_4'] = pd.qcut(df_data['Fare'], 4)
df_data['FareBin_5'] = pd.qcut(df_data['Fare'], 5)
df_data['FareBin_6'] = pd.qcut(df_data['Fare'], 6)
# 編碼
from sklearn.preprocessing import LabelEncoder
label = LabelEncoder()
df_data['FareBin_Code_4'] = label.fit_transform(df_data['FareBin_4'])
df_data['FareBin_Code_5'] = label.fit_transform(df_data['FareBin_5'])
df_data['FareBin_Code_6'] = label.fit_transform(df_data['FareBin_6'])
接著我們可以透過 Pclass 的交叉表與生存率圖形,來看看這些分箱特徵的辨識力:
# 交叉表
df_4 = pd.crosstab(df_data['FareBin_Code_4'], df_data['Pclass'])
df_5 = pd.crosstab(df_data['FareBin_Code_5'], df_data['Pclass'])
df_6 = pd.crosstab(df_data['FareBin_Code_6'], df_data['Pclass'])
# 多表顯示
from IPython.display import display
def display_side_by_side(*args):
for df in args:
display(df)
display_side_by_side(df_4, df_5, df_6)
# 視覺化
fig, [ax1, ax2, ax3] = plt.subplots(1, 3, sharey=True)
fig.set_figwidth(18)
for axi in [ax1, ax2, ax3]:
axi.axhline(0.5, linestyle='dashed', c='black', alpha=0.3)
sns.barplot(x='FareBin_Code_4', y='Survived', data=df_data, ax=ax1, palette='Set2')
sns.barplot(x='FareBin_Code_5', y='Survived', data=df_data, ax=ax2, palette='Set2')
sns.barplot(x='FareBin_Code_6', y='Survived', data=df_data, ax=ax3, palette='Set2')
plt.show()

根據視覺化結果,票價的高低對生存機率確實有明顯差異,而以四等分(FareBin_Code_4)或五等分(FareBin_Code_5)的分類精度與解釋力較為均衡,後續我們可依據模型訓練結果選擇最適用的版本保留。
小結
以上為本次預處理與特徵工程的核心部分,許多特徵雖不直接線性相關,但透過分箱、欄位轉換與交叉分析,讓模型有更多結構化訊號可學習。
在後續建模階段,我們將逐步測試這些特徵的實際貢獻,並視模型需求進行刪減或調整。
🎯 模型建立與預測試跑(Baseline Modeling & Evaluation)
終於是進入這個章節了,這邊要開始建立模型,然後挑選特徵給他下去跑分數看看,本節的目標是建立幾個常見的分類模型,初步觀察分數表現、確認資料特徵是否合理,並為後續進階優化(如調參與集成)打下基礎。
1️⃣ 初步模型測試:Random Forest
我們先使用 Random Forest 作為 baseline,並切分訓練與驗證資料來觀察基本準確度:
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
# 選擇訓練資料(排除測試集)
df_train = df_data[df_data['Survived'].notnull()]
selected_features = [
'Pclass', # 艙等
'Sex', # 性別(已編碼)
'Age', # 年齡
'Embarked', # 登船港口(已編碼)
# 衍生特徵
'Title', # 稱謂(從 Name 萃取)
'CabinInitial', # 艙等首字母(從 Cabin 萃取)
'FamilySize', # 家庭總人數
'IsAlone', # 是否獨自一人
'FareBin_Code_5', # Fare 分箱(已 label encode)
'SharedTicket', #是否共用船票
]
# 建立特徵與標籤
X = df_train[selected_features]
y = df_train['Survived']
# 切分訓練與驗證集
X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2,random_state=42, stratify=y)
# 建立 RF 模型
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)
# 預測與評估
rf_pred = rf_model.predict(X_valid)
print("Random Forest Accuracy:", accuracy_score(y_valid, rf_pred))
print("Confusion Matrix:\\n", confusion_matrix(y_valid, rf_pred))
print("Classification Report:\\n", classification_report(y_valid, rf_pred))
輸出:
Random Forest Accuracy: 0.8100558659217877
Confusion Matrix:
[[94 16]
[18 51]]
Classification Report:
precision recall f1-score support
0.0 0.84 0.85 0.85 110
1.0 0.76 0.74 0.75 69
accuracy 0.81 179
macro avg 0.80 0.80 0.80 179
weighted avg 0.81 0.81 0.81 179
我們得到0.81的模擬分數,這時候我們可以將這個模型實際上戰場看看。

哇,結果只有0.7607,做了這麼多還比範例答案差,也只比隨便補一補值高一點,不過也不用緊張,這代表目前的特徵以及模型泛化能力還不夠,我們只要推進到0.8以上,就能進入前1000名,扣掉前半段那些準確度1、0.99這類多半都有洩漏資料的,也能進入很前面的名次了,以下我們來說一些進階的作法。
🎯 進階特徵工程&超參數調整
我到底哪裡還做不夠呢?
首先先將現在有的特徵叫出來看看吧。
print(df_data.columns.tolist())
['PassengerId', 'Survived', 'Pclass', 'Name', 'Sex', 'Age', 'SibSp', 'Parch', 'Ticket', 'Fare', 'Cabin', 'Embarked', 'Title', 'AgeGroup', 'FareBin_Code_5', 'FareBin_Code_4', 'FareBin_Code_6', 'FamilySize', 'IsAlone', 'SharedTicket', 'CabinInitial']
我們有以下特徵可使用
PassengerId - 編號(無意義,不會加入特徵)
Survived - 是否生存(預測目標,不會加入特徵)
Pclass - 艙等(原始資料)
Name - 姓名(原始資料)
Sex - 性別(原始資料)
Age - 年齡(原始資料)
SibSp - 兄弟姊妹(原始資料)
Parch - 父母子女(原始資料)
Ticket - 票號(原始資料)
Fare - 票價(原始資料)
Cabin - 艙位(原始資料)
Embarked - 上船地點(原始資料)
Title - 稱謂(新稱特徵)
AgeGroup - 年齡組別(新增特徵)
FareBin_Code_4 - 票號分箱(新增特徵分4箱)
FareBin_Code_5 - 票號分箱(新增特徵分5箱)
FareBin_Code_6 - 票號分箱(新增特徵分6箱)
FamilySize - 家庭大小(新增特徵)
IsAlone - 是否獨自一人(新增特徵)
SharedTicket - 是否共票(新增特徵)
CabinInitial - 艙位開頭簡述(新增特徵)
看起來已經將許多特徵新增出來了,但這些還不足以讓我們突破0.8大關,因此我往更深入的關係去思考,中間也參考了一些人的特徵選擇。
✅ Ticket_Frequency:這張票有幾個人一起搭
雖然前面已經有共票的設定了,但我想進一步的設定這張票總共有幾個人使用,我假設一張票越多人共同使用,也就代表他們越容易團體行動,與Family size一樣類型,但它並不是利用家庭成員資訊合成,還考慮到了團體旅遊的部分。
ticket_counts = df_data['Ticket'].value_counts()
df_data['Ticket_Frequency'] = df_data['Ticket'].map(ticket_counts)
✅ Ticket_Survival_Rate:同張票的生還機率
既然有票有幾個人用,同樣的我們加入同張票的生還機率,這邊需要注意,我們只是把train資料的同票生還機率做一個統計,在網路上有些人的做法是直接用同票加權的方式去做(train有這張票且活著,那test的存活率上升),這有點小作弊,算是針對kaggle這類的高分打法,但若是實際要學習模型,這種資料洩漏的方式會使模型泛化程度低。
我們這邊用的是 OOF(Out-Of-Fold)方式來建立這個特徵,也就是把整個資料切成五折,每一折都只用其他四折的資料去預測這一折,這樣可以保證模型學到的 Ticket 生還率,是從「其他人」那邊學來的,而不是偷看答案。
這就好像你在船上看到其他票號的人活下來,你才猜測自己可能也有希望;但你不能偷看到自己已經活下來,然後反過來說「我這票的人都活了」。
如果只是簡單 groupby 去算整體的平均,那其實會讓模型看到目標變數(生還與否),等於資料洩漏。這樣可能會讓你在 leaderboard 上分數很高,但泛化能力很差,實際應用時反而會表現得更差。
首先我們建立OOF交叉評估
def build_oof_rate_feature(df, group_col, target_col='Survived', n_splits=5):
df = df.copy()
df_oof = pd.Series(index=df.index, dtype=float)
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
for train_idx, val_idx in kf.split(df):
train_data = df.iloc[train_idx]
val_data = df.iloc[val_idx]
rate_map = train_data.groupby(group_col)[target_col].mean()
df_oof.iloc[val_idx] = val_data[group_col].map(rate_map)
full_rate = df.groupby(group_col)[target_col].mean()
return df_oof, full_rate
然後建立同票生存機率
oof_ticket_rate, full_ticket_rate = build_oof_rate_feature(df_train, 'Ticket')
df_data['Ticket_Survival_Rate'] = df_data['Ticket'].map(full_ticket_rate)
df_data.loc[df_data['Survived'].notnull(), 'Ticket_Survival_Rate'] = oof_ticket_rate.values
df_data['Ticket_Survival_Rate_NA'] = df_data['Ticket_Survival_Rate'].isnull().astype(int)
我額外建立了Ticket_Survival_Rate_NA因為有些票號是獨有的,在訓練資料中完全沒出現,因此我們也額外標記,讓模型知道:「這張票在歷史上是沒記錄的。」這就有點像一種置信度的提示,讓模型知道這個生還率值本身是不是可信的。
👨👩👧👦 Surname:家庭也可能是群體之一
既然 Ticket 是一種群體,姓氏可能是另一種。雖然前面已經有家庭成員的設定,但是我們並不清楚到底誰是誰的誰,因此這邊我們是用姓氏來判斷是否屬於同一家庭,乍看之下滿合理的,因為如果你們都姓 Smith,又一起搭上這艘船,那大概不是巧合。
但說實在的,同姓的人很多吧?
像 Taylor、Johnson、Brown 這些常見姓氏,如果只是憑「姓氏一樣」就說是家人,這推論未免也太暴力了點。
不過從結果來看,整體來說這個特徵還是提供了一種「可能的群體關係」線索,可能因為 Titanic 上的乘客本來就有不少來自同個家族或鄉里,一起出發、一起搭船、甚至連票號都一樣。
df_data['Surname'] = df_data['Name'].str.extract(r'([A-Za-z]+),', expand=False)
✅ Family_Survival_Rate:同家族的生還機率
有了姓氏後我們就能統益同家族的生還機率了,同樣利用前面OOF交叉驗證的方式進行推測機率,然後就能設定各家族的生存機率了。
oof_surname_rate, full_surname_rate = build_oof_rate_feature(df_train, 'Surname')
df_data['Family_Survival_Rate'] = df_data['Surname'].map(full_surname_rate)
df_data.loc[df_data['Survived'].notnull(), 'Family_Survival_Rate'] = oof_surname_rate.values
df_data['Family_Survival_Rate_NA'] = df_data['Family_Survival_Rate'].isnull().astype(int)
同樣的,我建立了 Family_Survival_Rate_NA 來代表那些獨有的姓氏。
✅ FamilySizeGroup :家庭生存分箱
前面我們有提到家庭成員數量。

但其實這個若直接使用會有個問題,2、3、4都有較高的生存率,1、5、6、7又變少,但對於模型來講,怎樣算是少人怎樣算是多人,其實他不好分別,因此這裡可以幫他們分箱。
def group_family_size(size):
if size == 1:
return 'Small'
elif 2 <= size <= 4:
return 'Medium'
else:
return 'Large'
df_data['FamilySizeGroup'] = df_data['FamilySize'].apply(group_family_size)
我們用1人,2~4人,5人以上分成三箱
sns.barplot(data=df_data[df_data['Survived'].notnull()], x='FamilySizeGroup', y='Survived', palette='Set2')
plt.title('Survival Rate by Family Size')
plt.xlabel('FamilySizeGroup')
plt.ylabel('Survival Rate')
plt.ylim(0, 1)
plt.show()

這就可以很明確的知道,中間人數的團體更有生存的機會。
✅ CabinLevel :艙位分箱
我們前面有提到艙位的問題,艙位的缺失實在太多了,雖然這麼多缺失的情況下,這個特徵的利用價值很低,但考慮到艙位的位置也還是有關係到生存率,而且艙位也從A~T以及一個未知U,數量蠻多的,這種資料直接進入模型,可能會讓模型學到很多Noise,故我們也分箱。
def simplify_cabin_level(initial):
if initial in ['B', 'C', 'D', 'E']:
return 'High'
elif initial in ['F', 'G', 'T']:
return 'Low'
else:
return 'Missing'
df_data['CabinLevel'] = df_data['CabinInitial'].apply(simplify_cabin_level)
df_data = pd.get_dummies(df_data, columns=['CabinLevel'], prefix='CabinLevel')
通常來講,英文排名越靠前的是越好的艙等,所以我們將BCDE編成High,FGT編成Low,剩下U就都是missing了。
👰 Is_Married:已婚女性的隱性優勢?
最後我補了一個非常簡單的欄位:如果是 Mrs. 就當作是已婚女性。
df_data['Is_Married'] = (df_data['Title'] == 'Mrs').astype(int)
主要是加強通常已婚女性不會單獨出遊,而在當下的情況,丈夫通常願意將逃生機會優先給妻子的情況。
補充:One-hot encoding
我們這之前可以發現,像是FamilySizeGroup sex 這類的特徵,內含的都是文字,也就是類別型的特徵,有些強大的模型會自己幫你做編碼,但基本上這應該要自己編碼,首先簡單講一下原理
📦 舉個簡單例子:
Embarked(登船地點),裡面的值是:
S(Southampton)
C(Cherbourg)
Q(Queenstown)
對模型來說這是文字,它不會知道 S 比 C 大、還是 Q 比 S 小,因為這根本就不是有順序的東西。
這時就能對他做One-hot。
One-hot 的意思是「獨熱編碼」,它的做法是:

你會看到每一種類別都被變成一個獨立的欄位,只要是哪一類,就在那個欄位放 1,其他放 0。
這樣就能讓模型理解「這個人是從哪個地方上船」。
為什麼不用LabelEncoder?
有另一個方式叫做LabelEncoder,他比One-hot還要簡單一點,就是直接幫每個類別編成數字,也就是S→0. C→1, Q→2這樣的形式,但這有時候會容易讓模型認為,2>1>0,但其實他們本來就沒有這樣的大小順序關係。
因此我們這邊幫某幾個特徵做One-Hot encoding
cat_cols = ['Sex', 'Embarked', 'Title', 'CabinInitial', 'AgeGroup','FamilySizeGroup']
df_data = pd.get_dummies(df_data, columns=cat_cols)
這邊須注意,如果一個特徵裡有很多不同的項目,例如有100個上船位置,one-hot就會炸出100個特徵欄位,這時候還是得先經過統計篩選再決定要不要使用one-hot,不然可能會造成冗餘特徵。
我們新增的特徵到這邊,檢查一下狀況
df_data[df_data['Survived'].isnull()].info()
<class 'pandas.core.frame.DataFrame'>
Index: 1309 entries, 0 to 417
Data columns (total 54 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 PassengerId 1309 non-null int64
1 Survived 891 non-null float64
2 Pclass 1309 non-null int64
3 Name 1309 non-null object
4 Age 1309 non-null float64
5 SibSp 1309 non-null int64
6 Parch 1309 non-null int64
7 Ticket 1309 non-null object
8 Fare 1309 non-null float64
9 Cabin 1309 non-null object
10 FareBin_Code_5 1309 non-null int64
11 FareBin_Code_4 1309 non-null int64
12 FareBin_Code_6 1309 non-null int64
13 FamilySize 1309 non-null int64
14 IsAlone 1309 non-null int64
15 SharedTicket 1309 non-null int64
16 FarePerPerson 1309 non-null float64
17 CabinLevel_High 1309 non-null bool
18 CabinLevel_Low 1309 non-null bool
19 CabinLevel_Missing 1309 non-null bool
20 Ticket_Frequency 1309 non-null int64
21 Surname 1309 non-null object
22 Ticket_Survival_Rate 448 non-null float64
23 Ticket_Survival_Rate_NA 1309 non-null int64
24 Family_Survival_Rate 511 non-null float64
25 Family_Survival_Rate_NA 1309 non-null int64
26 Is_Married 1309 non-null int64
27 Sex_female 1309 non-null bool
28 Sex_male 1309 non-null bool
29 Embarked_C 1309 non-null bool
30 Embarked_Q 1309 non-null bool
31 Embarked_S 1309 non-null bool
32 Title_Master 1309 non-null bool
33 Title_Miss 1309 non-null bool
34 Title_Mr 1309 non-null bool
35 Title_Mrs 1309 non-null bool
36 Title_Rare 1309 non-null bool
37 CabinInitial_A 1309 non-null bool
38 CabinInitial_B 1309 non-null bool
39 CabinInitial_C 1309 non-null bool
40 CabinInitial_D 1309 non-null bool
41 CabinInitial_E 1309 non-null bool
42 CabinInitial_F 1309 non-null bool
43 CabinInitial_G 1309 non-null bool
44 CabinInitial_T 1309 non-null bool
45 CabinInitial_U 1309 non-null bool
46 AgeGroup_Very Young 1309 non-null bool
47 AgeGroup_Young 1309 non-null bool
48 AgeGroup_Adult 1309 non-null bool
49 AgeGroup_Middle Aged 1309 non-null bool
50 AgeGroup_Old 1309 non-null bool
51 FamilySizeGroup_Large 1309 non-null bool
52 FamilySizeGroup_Medium 1309 non-null bool
53 FamilySizeGroup_Small 1309 non-null bool
dtypes: bool(30), float64(6), int64(14), object(4)
memory usage: 294.0+ KB
一下子多了很多特徵,但在其中發現。
22 Ticket_Survival_Rate 448 non-null float64
23 Ticket_Survival_Rate_NA 1309 non-null int64
24 Family_Survival_Rate 511 non-null float64
25 Family_Survival_Rate_NA 1309 non-null int64
除了Survived 外(因為本來就缺test那筆),這裡有兩個項目還有缺值,他是我們新增生存率的部分,但會有沒共票以及沒有家庭成員一起上船的人沒辦法紀錄生存率,因此這裡應該要補上。
df_data['Ticket_Survival_Rate'] = df_data['Ticket_Survival_Rate'].fillna(0.5)
df_data['Family_Survival_Rate'] = df_data['Family_Survival_Rate'].fillna(0.5)
這裡補上0.5,也就是代表生存死亡一半一半,是較好的數字。
新增了這麼多特徵,該如何挑選
我們在處理特徵、新增特徵時,常用很多數學方式以及現實邏輯來設計,但實際到了最後,將全部特徵都丟入模型,反而可能得不到想像中的變好。
有些看似有邏輯的特徵,其實帶有很多雜訊;也可能是某個特徵太過霸道,幾乎生不生存都看它,導致過擬合。因此我們還是得做一些分析,來幫助我們挑出真正有用的特徵。
🧠 特徵這麼多,模型到底看了什麼?
雖然我們已經加了很多看似合理的特徵,但實際上模型是不是真的有用到它們?還是某些特徵其實被忽略了、根本沒在參與判斷?
這時候就需要工具,來幫我們看看每個特徵的狀況。 這次我使用的是隨機森林內建 feature_importances_ 欄位,以及 SHAP(SHapley Additive exPlanations),它們能幫我們觀察每個特徵對模型輸出的貢獻程度,也能顯示出模型判斷時偏好依賴哪些欄位。
首先,我們先設定要進入分析的特徵。
# 特徵解釋工具
import shap
# 全特徵重要度分析
all_features = [
# 數值與結構
'Pclass', 'Age', 'SibSp', 'Parch', 'Fare',
'FareBin_Code_5', 'FareBin_Code_4', 'FareBin_Code_6',
'FamilySize', 'IsAlone', 'SharedTicket', 'FarePerPerson',
'CabinLevel_High', 'CabinLevel_Low', 'CabinLevel_Missing',
# 生存率與頻率
'Ticket_Frequency', 'Ticket_Survival_Rate', 'Ticket_Survival_Rate_NA',
'Family_Survival_Rate', 'Family_Survival_Rate_NA', 'Is_Married',
# 類別 one-hot(建議都納入讓模型自行判斷)
'Sex_female', 'Sex_male',
'Embarked_C', 'Embarked_Q', 'Embarked_S',
'Title_Master', 'Title_Miss', 'Title_Mr', 'Title_Mrs', 'Title_Rare',
'AgeGroup_Very Young', 'AgeGroup_Young', 'AgeGroup_Adult', 'AgeGroup_Middle Aged', 'AgeGroup_Old',
'FamilySizeGroup_Large', 'FamilySizeGroup_Medium', 'FamilySizeGroup_Small'
]
不管哪種工具都是利用模型訓練的資訊分析貢獻度跟方向的,所以我們還是要設定一組模型去訓練。
df_train = df_data[df_data['Survived'].notnull()].copy()
X_shap = df_train[all_features]
X_shap = X_shap.astype(float) # 保證都是 float 類型
# 訓練 RF 模型
rf_model = RandomForestClassifier(
n_estimators=1352,
max_depth=13,
min_samples_split=5,
min_samples_leaf=8,
max_features='log2',
oob_score=True,
random_state=42,
n_jobs=-1
)
rf_model.fit(X_shap, y)
訓練過就可以投入重要度分析以及shap分析
rf_importance = pd.Series(rf_model.feature_importances_, index=X_shap.columns)
rf_importance.sort_values(ascending=False).plot(kind='barh', figsize=(10,8))
plt.title('RF Feature Importance')
plt.tight_layout()
plt.show()
# 特徵解釋工具
import shap
# 全特徵重要度分析
all_features = [
# 數值與結構
'Pclass', 'Age', 'SibSp', 'Parch', 'Fare',
'FareBin_Code_5', 'FareBin_Code_4', 'FareBin_Code_6',
'FamilySize', 'IsAlone', 'SharedTicket', 'FarePerPerson',
'CabinLevel_High', 'CabinLevel_Low', 'CabinLevel_Missing',
# 生存率與頻率
'Ticket_Frequency', 'Ticket_Survival_Rate', 'Ticket_Survival_Rate_NA',
'Family_Survival_Rate', 'Family_Survival_Rate_NA', 'Is_Married',
# 類別 one-hot(建議都納入讓模型自行判斷)
'Sex_female', 'Sex_male',
'Embarked_C', 'Embarked_Q', 'Embarked_S',
'Title_Master', 'Title_Miss', 'Title_Mr', 'Title_Mrs', 'Title_Rare',
'AgeGroup_Very Young', 'AgeGroup_Young', 'AgeGroup_Adult', 'AgeGroup_Middle Aged', 'AgeGroup_Old',
'FamilySizeGroup_Large', 'FamilySizeGroup_Medium', 'FamilySizeGroup_Small'
]
不管哪種工具都是利用模型訓練的資訊分析貢獻度跟方向的,所以我們還是要設定一組模型去訓練。
df_train = df_data[df_data['Survived'].notnull()].copy()
X_shap = df_train[all_features]
X_shap = X_shap.astype(float) # 保證都是 float 類型
# 訓練 RF 模型
rf_model = RandomForestClassifier(
n_estimators=1352,
max_depth=13,
min_samples_split=5,
min_samples_leaf=8,
max_features='log2',
oob_score=True,
random_state=42,
n_jobs=-1
)
rf_model.fit(X_shap, y)
訓練過就可以投入重要度分析以及shap分析
rf_importance = pd.Series(rf_model.feature_importances_, index=X_shap.columns)
rf_importance.sort_values(ascending=False).plot(kind='barh', figsize=(10,8))
plt.title('RF Feature Importance')
plt.tight_layout()
plt.show()
這裡可以看到模型訓練特徵的倚重多寡,可以看到性別,以及Title這些關於性別的比重遠高於其他,而也有些比重不高的特徵出現,這邊可以初步評估哪些可以放棄掉,那些可以留著,但是feature_importances_ 有個缺點,它只能提供誰重要,卻不能知道為什麼重要、怎麼影響結果。因此,我們使用另一項工具 Shap。
訓練過後就可以shap裡解釋各特徵狀況。
# SHAP 解釋器與取值
explainer = shap.Explainer(rf_model, X_shap)
shap_values = explainer(X_shap)
# 取出類別 1 的解釋值(變成 shape=(n_samples, n_features))
shap_values_class1 = shap_values[..., 1]
# beeswarm 圖(推薦)
shap.plots.beeswarm(shap_values_class1, max_display=50)

由此可以看到Shap分析的圖,紅點就是正向(生存)貢獻,藍點是負向(死亡)貢獻,當紅藍點分得越開,代表該特徵越能讓模型分辨,越靠近0則越沒有分辨的能力,模型也會較少使用。
特徵評估:我們該留下誰?
當我們拿到這兩張圖(Feature Importance 與 SHAP Beeswarm),真正重要的事情其實是:這些特徵,值不值得被留下?
這裡提供幾個評估角度:
✅ 第一:兩圖都高的重要特徵,必留
像 Title_Mr、Sex_female、Pclass 這些特徵,不僅在 bar chart 裡排名前幾名,在 SHAP 圖中也展現出強烈的區分能力(左右展開幅度大、顏色集中),代表模型在整體與個體層面都非常仰賴它們。這種特徵通常是我們最該保留的核心。
⚠️ 第二:bar chart 高但 SHAP 展開不明顯,要小心
例如 Fare 在 bar chart 裡得分不低,但在 SHAP 中雖然略有分布,但紅藍點混雜,代表它的「高或低」對模型預測方向不一定一致,有可能被模型錯誤解讀或代表性不足,這類特徵需要再觀察(或做非線性轉換、分箱處理等)。
⚠️ 第三:SHAP 離散展開強但 bar chart 分數低的,也不能忽略
例如 CabinLevel_Missing、Family_Survival_Rate 這些特徵,在 SHAP 圖中雖然不在前幾名,但顏色與 SHAP 值的變化很有方向性,代表這些特徵雖小,但對特定樣本群很有效,可能是少數關鍵案例的主要依據,適合在提升模型多樣性或 ensemble 融合中使用。
❌ 第四:兩邊都低的特徵,大概率可以剃除
例如 Embarked_Q、CabinLevel_Low 這些特徵,在兩張圖中幾乎沒有表現,表示它們既不重要、也無貢獻預測方向,這類特徵可能只是背景噪音或資料稀疏造成的干擾,可以優先移除來簡化模型。
🧠 第五:注意特徵的語意重疊與共線性
雖然這兩張圖能幫助我們理解特徵重要性,但還是要加入一些「人類判斷」。例如 Title_Mr 與 Sex_male 這兩個特徵,本質上都代表「男性」這個群體,邏輯與 SHAP 分布也非常類似,這其實是共線性的具體展現。若同時保留,可能讓模型過度依賴「是否為男性」這一因素,產生過擬合。因此這類特徵就需要進一步觀察或考慮擇一使用。
🧪 OOB、KFold 分數觀察與解釋
特徵新增完了,但我們還是得回到現實:這些特徵真的有幫助嗎?
Random Forest 有一個很方便的功能叫做 OOB(Out-of-Bag)評估。它可以在不額外切分驗證資料的情況下,用內部沒被拿去訓練的樣本來估計模型表現。換句話說,是模型自己「在訓練過程中」幫我們偷看了成效。
除了 OOB,我也使用 KFold 交叉驗證來看準確率與波動情形。這兩者搭配來看,能讓我們初步判斷現在的特徵組合到底穩不穩、準不準。
首先,我們一樣建立我們挑選出來的特徵以及分割訓練及測試集。
# 準備資料
df_train = df_data[df_data['Survived'].notnull()].copy()
df_test = df_data[df_data['Survived'].isnull()].copy()
# 使用特徵(由後半段optuna以及shap分析去挑選的)
selected_features = [
'Sex_female', 'Title_Mr', 'FarePerPerson', 'Is_Married', 'Age', 'Pclass',
'IsAlone', 'Fare', 'Family_Survival_Rate', 'SharedTicket', 'Ticket_Frequency',
'FamilySize', 'SibSp', 'Title_Miss', 'Embarked_S', 'Ticket_Survival_Rate',
'Parch', 'CabinLevel_Missing', 'FamilySizeGroup_Small', 'Family_Survival_Rate_NA'
]
然後設定切割數量
# 設定交叉驗證與預測記錄
N = 5
kf = StratifiedKFold(n_splits=N, shuffle=True, random_state=42)
scores = []
oob_total = 0
然後設定模型
# 模型參數
for fold, (train_idx, val_idx) in enumerate(kf.split(X, y), 1):
print(f"🔁 Fold {fold}")
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
model = RandomForestClassifier(
n_estimators=1352,
max_depth=13,
min_samples_split=5,
min_samples_leaf=8,
max_features='log2',
oob_score=True,
random_state=42,
n_jobs=-1
)
model.fit(X_train, y_train)
# 預測驗證集
y_pred = model.predict(X_val)
acc = accuracy_score(y_val, y_pred)
scores.append(acc)
# OOB
oob_score = model.oob_score_
oob_total += oob_score / N
print(f"Fold {fold} OOB Score: {oob_score:.4f}")
print(f"Fold {fold} Accuracy: {acc:.4f}\\n")
最後輸出分數
# 統計結果
print(f"\\n平均準確率:{np.mean(scores):.5f}")
print(f"平均 OOB 分數:{oob_total:.5f}")
可以得到輸出
🔁 Fold 1
Fold 1 OOB Score: 0.8427
Fold 1 Accuracy: 0.8380
🔁 Fold 2
Fold 2 OOB Score: 0.8387
Fold 2 Accuracy: 0.8483
🔁 Fold 3
Fold 3 OOB Score: 0.8387
Fold 3 Accuracy: 0.8371
🔁 Fold 4
Fold 4 OOB Score: 0.8401
Fold 4 Accuracy: 0.8764
🔁 Fold 5
Fold 5 OOB Score: 0.8387
Fold 5 Accuracy: 0.8596
平均準確率:0.85187
平均 OOB 分數:0.83979
我們可以將他丟入kaggle Notebook跑分看看看。
# ✅ 單一模型訓練(全部資料)
rf_model = RandomForestClassifier(
n_estimators=1352,
max_depth=13,
min_samples_split=5,
min_samples_leaf=8,
max_features='log2',
oob_score=True,
random_state=42,
n_jobs=-1
)
rf_model.fit(X, y)
# ✅ 預測 test 機率與生存標籤
test_probs = rf_model.predict_proba(X_test)
test_preds = (test_probs[:, 1] >= 0.5).astype(int) # 機率 >= 0.5 視為生存
# ✅ 輸出 submission
submission = pd.DataFrame({
'PassengerId': df_test['PassengerId'],
'Survived': test_preds
})
submission.to_csv("/content/drive/My Drive/AItest/Titanic - Machine Learning from Disaster/submission optuna final.csv", index=False)

分數提高到了0.78229,有一定的提升,但提升幅度並不是太大,這時我注意到一件事情。
於剛剛5 StratifiedKFold交叉驗證中,雖然OOB分數接近,但在每一折的準確率卻有較大的差異,最大0.87最小到0.83,也就是說每一折的資料分布還是略有差異,那我為什麼不能利用這個方法來評估呢?
因此我修改五折分析,改讓每一折的訓練結果都去對test集做一次預測。
首先我們設定一個生存機率儲存
# 設定交叉驗證與預測記錄
N = 5
kf = StratifiedKFold(n_splits=N, shuffle=True, random_state=42)
scores = []
oob_total = 0
# 對每一折生存機率儲存
probs = pd.DataFrame(np.zeros((len(X_test), N * 2)), columns=[
f'Fold_{i}_Prob_0' for i in range(1, N + 1)
] + [
f'Fold_{i}_Prob_1' for i in range(1, N + 1)
])
importances = pd.DataFrame(np.zeros((X.shape[1], N)), columns=[f'Fold_{i}' for i in range(1, N + 1)], index=X.columns)
於每一折內都做一次對test的預測
# 模型參數
for fold, (train_idx, val_idx) in enumerate(kf.split(X, y), 1):
print(f"🔁 Fold {fold}")
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
.
.與平常設定模型一樣,這邊省略
.
# 預測驗證集
y_pred = model.predict(X_val)
acc = accuracy_score(y_val, y_pred)
scores.append(acc)
# 預測 test 資料<----這裡新增預測test資料
prob_test = model.predict_proba(X_test)
probs[f'Fold_{fold}_Prob_0'] = prob_test[:, 0]
probs[f'Fold_{fold}_Prob_1'] = prob_test[:, 1]
# OOB
oob_score = model.oob_score_
oob_total += oob_score / N
print(f"Fold {fold} OOB Score: {oob_score:.4f}")
print(f"Fold {fold} Accuracy: {acc:.4f}\\n")
這樣我們就儲存到每一折對test預測的結果。然後利用這些結果進行總結果的預測。
# 對 test 預測做平均(每一筆取平均機率)
class_survived = [col for col in probs.columns if col.endswith('Prob_1')]
class_not_survived = [col for col in probs.columns if col.endswith('Prob_0')]
# 依預測生存率寫上是否生存
probs['1'] = probs[class_survived].mean(axis=1)
probs['0'] = probs[class_not_survived].mean(axis=1)
probs['pred'] = (probs['1'] >= 0.5).astype(int)
# 輸出 submission 檔案
submission = pd.DataFrame({
'PassengerId': df_test['PassengerId'],
'Survived': probs['pred']
})
submission.to_csv("/content/drive/My Drive/AItest/Titanic - Machine Learning from Disaster/submission optuna 5kflod avg.csv", index=False)
submission.head()
再度丟上kaggle進行跑分。

成功突破0.8準確率,這樣的分數讚kaggle即便把那些準確率 1 與0.99這些加入排名,也有480名左右的成績,成功進入前10%的領域(實際是480/14902,大概top 3% 但這排名因為這比賽永遠開放,所以排名說實在的不是太有鑑別度)
至此,我們順利完成了這場預測挑戰,成功建立出一個準確率突破 80% 的生存預測模型。雖然這還不是 leaderboard 的極限分數,但作為一個邏輯清晰、泛化能力穩定的方案,它已經達成了我們的初衷。
補充資料:Optuna
礙於篇幅這邊已經寫不下去了,我另外開一個文章說明。
https://vocus.cc/article/68dc9e7dfd89780001916d4e
🧾 總結
終於完成了鐵達尼號這篇了。
沒想到一個看起來是入門題的 Kaggle 任務,也寫了這麼長的一段。不過越是基礎的題目,我反而覺得越要寫得清楚、看得透徹,因為所有日後會遇到的問題,在這裡其實都已經藏著了。
從一開始的資料清理、特徵工程,到後來嘗試交叉驗證、SHAP 分析、Optuna 最佳化,甚至觀察 CV 分數與 leaderboard 的落差,每一段都是練習、也是反思。
這篇紀錄不只是一份技術文件,更像是我把思路一次攤開、拆解,再逐步整理的過程。
它不是最強的解法,但至少是一個「可解釋、可複製、可調整」的起點。
如果未來還有新的想法、新的技巧,我會回來更新它。
至少在這個時間點,這份記錄代表了我當下能做到的全部努力。
Code(GItHub)
https://github.com/Noah-incoding/Titanic-MachineLearning.git



















