PyTorch入门: Kaggle 泰坦尼克幸存者预测

刚刚学了PyTorch,写个神经网络试试……

Kaggle比赛地址

给定泰坦尼克号上$891$名乘客的信息: 姓名、性别、年龄、船票等级、家属等,以及这些乘客是否存活。目标是判断另外$418$名乘客是否存活。

使用PyTorch训练一个神经网络来完成这个任务。

数据预处理

首先描述一下数据预处理的过程。

观察一下,主观臆断初步分析和感觉可以得出: 乘客编号(PassengerId)应该是完全没用的信息;姓名(Name)似乎和存活关系也不大;船票号(Ticket)由于数据太杂乱了,有用,但是应该会很麻烦;如果知道泰坦尼克号每个隔间的位置以及逃生通道的位置,隔间号(Cabin)应该是非常重要的信息,然而数据缺失特别严重,这意味着将其作为重要标准会有问题。

因此只利用其他数据: 船票等级(Pclass), 姓名(Name), 性别(Sex), 年龄(Age), 登船兄弟个数(SibSp), 登船长辈个数(Parch), 票价(Fare), 登船港口(Embarked)。还需要对这些数据进行预处理。


补足Age数据

看一眼就可以发现在Excel中筛选或用pandas的isnull()函数检查发现,训练集和测试集年龄数据都存在大量缺失。存在大规模数据缺失问题,一般可以采取的方法有: 均值填补、中位数填补、众数填补、矩阵分解补全、随机森林、预设未知项等等。尝试发现均值和中位数填补方法效果不好,票价很多导致众数在这道题中不够明显,矩阵分解补全和随机森林又比较复杂(不过可以引用一些写好的库),因此采用预设未知项。补充UknAge属性表示不知道年龄,对于Age缺失的数据将此项设置为$1$。(感性理解可以认为将来神经网络会利用UknAge的权重来动态为缺失项填补一个估计值)。

(网上的随机森林做法我感到非常疑惑,如果说兄弟个数和长辈个数还勉强可以反映年龄(即使这里只记录了登船的亲属个数),那为什么年龄和性别会有关联?用性别参与预测年龄有什么依据?)


补足Embarked数据

某次运行Python程序得到少量NaN输出结果于是发现在Excel中筛选或用pandas的isnull()函数检查发现,训练集登船港口数据有2人缺失。登船港口应该会和什么有关呢?个人感觉登船港口不同可能导致里程不同,从而影响票价,发现缺失数据的$2$人票价都为$80$。同时为了控制变量,性别、船票等级都会影响票价,因此筛选所有票价在$75 \sim 85$之间的男性头等舱船票。

发现大多数来自于C港口。因此可以假定缺失数据的$2$人从C港口登陆。


补足Fare数据

再次运行Python程序得到少量NaN输出结果于是发现在Excel中筛选或用pandas的isnull()函数检查发现,测试集有$1$人票价数据缺失。

根据此前对票价影响因素的分析,用相同登船港口、船票等级、性别的乘客取中位数来作为预测票价(均值明显偏高)。得出$14.4$。


数据离散化(One-hot Encoding)

为了方便神经网络处理,应该将离散数据都拆成多项。例如将登船港口拆分为$3$个属性,”是否从C港口登船”,”是否从Q港口登船”,”是否从S港口登船”,每个属性都是$0/1$二值。因此经过离散化,最终得到了$26$个不同的属性(加上是否存活的label一共$27$个属性):

1
2
3
4
5
6
7
8
NEW_INDEX = ['Age', 'UknAge', 'Fare',
'Pclass_0', 'Pclass_1', 'Pclass_2',
'Sex_0', 'Sex_1',
'SibSp_0', 'SibSp_1', 'SibSp_2', 'SibSp_3', 'SibSp_4', 'SibSp_5', 'SibSp_8',
'Parch_0', 'Parch_1', 'Parch_2', 'Parch_3', 'Parch_4', 'Parch_5', 'Parch_6', 'Parch_9',
'Embarked_0', 'Embarked_1', 'Embarked_2',
'Survived'
]


数据归一化(Normalization)

为了方便神经网络处理,将所有数值转化为介于$0 \sim 1$之间的数值。叒运行Python程序全部得到NaN输出结果于是注意到,不能直接全部除以最大值,而需要判断最大值是否非$0$(刚刚用$0$填充NaN的列最大值可能为$0$)。


代码实现

最开始直接裸写……那叫一个痛苦,代码又臭又长……对于压行教信徒的我简直是奇耻大辱

于是去学了numpy和pandas入门……重构了无数次代码以后变成了下面勉强可以接受的样子……

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
37
38
39
40
41
42
43
44
# Configurations
OLD_INDEX = ['Pclass','Sex','Age','UknAge','SibSp','Parch','Fare','Embarked','Survived']
NEW_INDEX = ['Age', 'UknAge', 'Fare',
'Pclass_0', 'Pclass_1', 'Pclass_2',
'Sex_0', 'Sex_1',
'SibSp_0', 'SibSp_1', 'SibSp_2', 'SibSp_3', 'SibSp_4', 'SibSp_5', 'SibSp_8',
'Parch_0', 'Parch_1', 'Parch_2', 'Parch_3', 'Parch_4', 'Parch_5', 'Parch_6', 'Parch_9',
'Embarked_0', 'Embarked_1', 'Embarked_2',
'Survived'
]
MAP_Sex = {'male':0,'female':1}
MAP_Embarked = {'C':0,'Q':1,'S':2}
ONE_HOT = [[1,0],[0,1]]
FEATURES = 26

def preprocess( data ):
# Data Cleaning
data = pd.DataFrame(data,columns=OLD_INDEX)
data['UknAge'] = data['UknAge'].fillna(0)
data['Survived'] = data['Survived'].fillna(0)
#### print(data[data['Age'].isnull()])
data.loc[data['Age'].isnull(),'UknAge'] = 1
data['Age'] = data['Age'].fillna(0)
#### print(data[data['Fare'].isnull()])
data['Fare'] = data['Fare'].fillna(14.4)
#### print(data[data['Embarked'].isnull()])
data['Embarked'] = data['Embarked'].fillna('C')
#### One-hot Encoding
data['Pclass'] -= 1
data['Sex'] = data['Sex'].map(MAP_Sex)
data['Embarked'] = data['Embarked'].map(MAP_Embarked)
data = pd.get_dummies(data,columns=['Pclass','Sex','SibSp','Parch','Embarked'])
data = pd.DataFrame(data,columns=NEW_INDEX)
data = data.fillna(0)
#### Normalization
for col in NEW_INDEX:
maximum = data[col].max()
if maximum > 0:
data[col] /= maximum
#### To List
temp = np.array(data)
data = [[torch.FloatTensor(temp[j][:FEATURES]),
torch.FloatTensor(ONE_HOT[int(temp[j][FEATURES])])] for j in range(temp.shape[0])]
return data


PyTorch构建全连接深度神经网络

本来的目的就是初学PyTorch,写个神经网络测试一下,才误打误撞找到了Kaggle这个比赛。直接通过nn.Sequential()add_module()构建一个可以自由调参数的神经网络模板。激活函数都可以从若干中随意设定(为了方便选择以及输出参数采用了字典形式)。

注意nn.Softmax()是个坑人函数,其是针对多维设计的,必须要传入axis=?表示对第几维度求Softmax,因此一维Softmax应该为nn.Softmax(0)

训练集总共$891$个数据,实际训练时将$800$个数据设定为真·训练集,其余$91$个数据为验证集。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
ACTIVATION = {'sigmoid':nn.Sigmoid(),
'tanh':nn.Tanh(),
'ReLU':nn.ReLU(),
'softplus':nn.Softplus(),
'LeakyReLU':nn.LeakyReLU(),
'logsigmoid':nn.LogSigmoid(),
'PReLU':nn.PReLU(),
'ReLU6':nn.ReLU6(),
'softsign':nn.Softsign()
}

class FCDNN( nn.Module ):

def __init__( self, nodes, activation ):
super(FCDNN,self).__init__()
self.layers = nn.Sequential()
self.name = 'FCDNN('
for i in range(len(nodes)-1):
self.layers.add_module('fc-{0}'.format(i),nn.Linear(nodes[i],nodes[i+1]))
self.layers.add_module('activation-{0}'.format(i),ACTIVATION[activation])
self.name += str(nodes[i])+'-'
self.name += str(nodes[len(nodes)-1])+','+activation+')'
self.layers.add_module('softmax',nn.Softmax(0))

def forward( self, x ):
x = self.layers(x)
return x

def train( self, loss_func, optimizer, eta, decay, train_data, validate_data, epoch, batch_num, batch_size ):
print('['+self.name+' {0}*{1}*{2}'.format(epoch,batch_num,batch_size)+'] with optimizer ['+str(type(optimizer))+']:')
n = len(train_data)
m = len(validate_data)
for E in range(epoch):
np.random.shuffle(train_data)
p = 0
for T in range(batch_num):
optimizer.zero_grad()
for j in range(batch_size):
loss = loss_func(self.forward(train_data[p][0]),train_data[p][1])
loss.backward()
p = (p+1)%n
optimizer.step()
eta *= decay
if eta < 1e-6:
break
for p in optimizer.param_groups:
p['lr'] *= decay
with torch.no_grad():
L = 0.
R = 0.
for j in train_data:
t = self.forward(j[0])
L += loss_func(t,j[1])
R += float((t[0]<t[1]) != (j[1][0]<j[1][1]))
print("\tTraining Loss = %.6f"%(L/n))
print("\tTraining Error Rate = %.4f%%"%(R/n*100))
L = 0.
R = 0.
for j in validate_data:
t = self.forward(j[0])
L += loss_func(t,j[1])
R += float((t[0]<t[1]) != (j[1][0]<j[1][1]))
print("\tValidation Loss = %.6f"%(L/m))
print("\tValidation Error Rate = %.4f%%"%(R/m*100))

具体运行流程经历了多个版本。最开始是手动瞎猜调节神经网络的各个参数,后来开始手动编写网格式搜索参数,但是由于速度实在过慢令人难以忍受,于是随机化。

如此生成一个随机的全连接深度神经网络:

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
ACTIVATION = {'sigmoid':nn.Sigmoid(),
'tanh':nn.Tanh(),
'ReLU':nn.ReLU(),
'softplus':nn.Softplus(),
'LeakyReLU':nn.LeakyReLU(),
'logsigmoid':nn.LogSigmoid(),
'PReLU':nn.PReLU(),
'ReLU6':nn.ReLU6(),
'softsign':nn.Softsign()
}
EPOCH = 500

def RandomFCDNN( train_data, validate_data ):
layers = [FEATURES]
L = np.random.randint(1,5)
for j in range(L):
layers.append(2 ** np.random.randint(1,7))
layers.append(2)
A = np.random.choice(list(ACTIVATION))
E = np.random.random()
W = np.random.randint(1,8)
B = 0.99+W/1200.
D = np.random.choice([1,2,4,5,8,10,16,20,25,32])
N = FCDNN(layers,A)
O = np.random.choice([optim.SGD(N.parameters(),lr=E,momentum=np.random.random()*0.9),
optim.Adam(N.parameters(),lr=E),
optim.Adagrad(N.parameters(),lr=E),
optim.RMSprop(N.parameters(),lr=E,momentum=np.random.random()*0.9)
])
N.train(nn.MSELoss(),O,E,B,train_data,validate_data,EPOCH*W,800//D,D)
return N.evaluate(validate_data,target_data)

每一层神经网络节点数都是2的整倍数,介于$2 \sim 128$之间。初始学习率为$0 \sim 1$之间的小数,然后以$\beta = 0.99 + \frac{\mathtt{epoch}/500}{1200}$的速度进行衰减(手动构造+检验表明,当$\mathtt{epoch}$介于$500 \sim 5000$之间时,这样一个衰减速率可以使得最终学习率保持在一个合理区间内)。由于训练集数据量比较小,batch_size也比较小,直接取了测试集大小$800$的部分小约数。后来也尝试了直接固定学习率。

运行起来大约是这个样子:

1
2
3
4
5
[FCDNN(26-32-2-64-2,softplus) 3000*160*5] with optimizer [<class 'torch.optim.adam.Adam'>]:
Training Loss = 0.111061
Training Error Rate = 14.5000%
Validation Loss = 0.123357
Validation Error Rate = 14.2857%


集成(Ensemble)

集成就是对若干个不同参数的神经网络进行综合。要平等不要平均,对每个神经网络按照其在验证集上的表现进行加权,具体地,考虑错误率:

具体实现对于神经网络自身,定义评估权重的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class FCDNN( nn.Module ):

...

def evaluate( self, validate_data, target_data ):
with torch.no_grad():
E = 0.
for j in validate_data:
t = self.forward(j[0])
E += (t[0]<t[1]) != (j[1][0]<j[1][1])
E /= len(validate_data)
weight = math.log(math.sqrt((1-E)/E))
prediction = torch.zeros(len(target_data),2)
for i in range(len(target_data)):
prediction[i] = self.forward(target_data[i][0])

# Special Limitation
if E > 0.25:
print("Discarded!")
return torch.zeros(len(target_data),2)
return prediction*weight

因为某些优化器表现似乎不太稳定,于是添加了特殊限制: 验证集错误率超过$0.25$的网络的输出方案将被直接作废。

最终运行(其实一次划分真·测试集和验证集足够,激进冒险主义者为了引入更多随机性,每个网络重新划分):

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
def output( prediction, filedir ):
submission = []
for i in range(892,1310):
submission.append([i,int(prediction[i-892][0]<prediction[i-892][1])])
submission = pd.DataFrame(submission)
submission.columns = ['PassengerId','Survived']
submission.to_csv(filedir,index=0)

origin_data = preprocess(pd.read_csv(PATH+"train.csv"))
target_data = preprocess(pd.read_csv(PATH+"test.csv"))
# predictions = evaluate_gender_submission(validate_data,target_data)
predictions = torch.zeros(len(target_data),2)

begin = time.time()

while 1:
np.random.shuffle(origin_data)
train_data = origin_data[:800].copy()
validate_data = origin_data[800:].copy()

start = time.time()
predictions += RandomFCDNN(train_data,validate_data)
output(predictions,PATH+"submission.csv")
end = time.time()
print('Time cost: %.4f s, total time cost: %.4f s'%(end-start,end-begin))


完整代码

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import torch
import torchvision
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import math
import time
import os
# np.random.seed(998244353)
# torch.manual_seed(998244353)

# Configurations
OLD_INDEX = ['Pclass','Sex','Age','UknAge','SibSp','Parch','Fare','Embarked','Survived']
NEW_INDEX = ['Age', 'UknAge', 'Fare',
'Pclass_0', 'Pclass_1', 'Pclass_2',
'Sex_0', 'Sex_1',
'SibSp_0', 'SibSp_1', 'SibSp_2', 'SibSp_3', 'SibSp_4', 'SibSp_5', 'SibSp_8',
'Parch_0', 'Parch_1', 'Parch_2', 'Parch_3', 'Parch_4', 'Parch_5', 'Parch_6', 'Parch_9',
'Embarked_0', 'Embarked_1', 'Embarked_2',
'Survived'
]
MAP_Sex = {'male':0,'female':1}
MAP_Embarked = {'C':0,'Q':1,'S':2}
ONE_HOT = [[1,0],[0,1]]
FEATURES = 26
ACTIVATION = {'sigmoid':nn.Sigmoid(),
'tanh':nn.Tanh(),
'ReLU':nn.ReLU(),
'softplus':nn.Softplus(),
'LeakyReLU':nn.LeakyReLU(),
'logsigmoid':nn.LogSigmoid(),
'PReLU':nn.PReLU(),
'ReLU6':nn.ReLU6(),
'softsign':nn.Softsign()
}
EPOCH = 500
PATH = ""

def preprocess( data ):
# Data Cleaning
data = pd.DataFrame(data,columns=OLD_INDEX)
data['UknAge'] = data['UknAge'].fillna(0)
data['Survived'] = data['Survived'].fillna(0)
#### print(data[data['Age'].isnull()])
data.loc[data['Age'].isnull(),'UknAge'] = 1
data['Age'] = data['Age'].fillna(0)
#### print(data[data['Fare'].isnull()])
data['Fare'] = data['Fare'].fillna(14.4)
#### print(data[data['Embarked'].isnull()])
data['Embarked'] = data['Embarked'].fillna('C')
#### One-hot Encoding
data['Pclass'] -= 1
data['Sex'] = data['Sex'].map(MAP_Sex)
data['Embarked'] = data['Embarked'].map(MAP_Embarked)
data = pd.get_dummies(data,columns=['Pclass','Sex','SibSp','Parch','Embarked'])
data = pd.DataFrame(data,columns=NEW_INDEX)
data = data.fillna(0)
#### Normalization
for col in NEW_INDEX:
maximum = data[col].max()
if maximum > 0:
data[col] /= maximum
#### To List
temp = np.array(data)
data = [[torch.FloatTensor(temp[j][:FEATURES]),
torch.FloatTensor(ONE_HOT[int(temp[j][FEATURES])])] for j in range(temp.shape[0])]
return data

class FCDNN( nn.Module ):

def __init__( self, nodes, activation ):
super(FCDNN,self).__init__()
self.layers = nn.Sequential()
self.name = 'FCDNN'
for i in range(len(nodes)-1):
self.layers.add_module('fc-{0}'.format(i),nn.Linear(nodes[i],nodes[i+1]))
self.layers.add_module('activation-{0}'.format(i),ACTIVATION[activation])
self.name += str(nodes[i])+'-'
self.name += str(nodes[len(nodes)-1])+','+activation+')'
self.layers.add_module('softmax',nn.Softmax(0))

def forward( self, x ):
x = self.layers(x)
return x

def train( self, loss_func, optimizer, eta, decay, train_data, validate_data, epoch, batch_num, batch_size ):
print('['+self.name+' {0}*{1}*{2}'.format(epoch,batch_num,batch_size)+'] with optimizer ['+str(type(optimizer))+']:')
n = len(train_data)
m = len(validate_data)
for E in range(epoch):
np.random.shuffle(train_data)
p = 0
for T in range(batch_num):
optimizer.zero_grad()
for j in range(batch_size):
loss = loss_func(self.forward(train_data[p][0]),train_data[p][1])
loss.backward()
p = (p+1)%n
optimizer.step()
eta *= decay
if eta < 1e-6:
break
for p in optimizer.param_groups:
p['lr'] *= decay
with torch.no_grad():
L = 0.
R = 0.
for j in train_data:
t = self.forward(j[0])
L += loss_func(t,j[1])
R += float((t[0]<t[1]) != (j[1][0]<j[1][1]))
print("\tTraining Loss = %.6f"%(L/n))
print("\tTraining Error Rate = %.4f%%"%(R/n*100))
L = 0.
R = 0.
for j in validate_data:
t = self.forward(j[0])
L += loss_func(t,j[1])
R += float((t[0]<t[1]) != (j[1][0]<j[1][1]))
print("\tValidation Loss = %.6f"%(L/m))
print("\tValidation Error Rate = %.4f%%"%(R/m*100))

def evaluate( self, validate_data, target_data ):
with torch.no_grad():
R = 0.
for j in validate_data:
t = self.forward(j[0])
R += float((t[0]<t[1]) != (j[1][0]<j[1][1]))
R /= len(validate_data)
weight = math.log(math.sqrt((1-R)/R))
prediction = torch.zeros(len(target_data),2)
for i in range(len(target_data)):
prediction[i] = self.forward(target_data[i][0])

# Special Limitation
if R > 0.2:
print("Discarded!")
return torch.zeros(len(target_data),2)
return prediction*weight

def output( prediction, filedir ):
submission = []
for i in range(892,1310):
submission.append([i,int(prediction[i-892][0]<prediction[i-892][1])])
submission = pd.DataFrame(submission)
submission.columns = ['PassengerId','Survived']
submission.to_csv(filedir,index=0)

def RandomFCDNN( train_data, validate_data ):
layers = [FEATURES]
L = np.random.randint(1,5)
for j in range(L):
layers.append(2 ** np.random.randint(1,7))
layers.append(2)
A = np.random.choice(list(ACTIVATION))
E = 0.0001
W = np.random.randint(1,8)
B = 1
D = np.random.choice([1,2,4,5,8,10,16,20,25,32])
N = FCDNN(layers,A)
O = np.random.choice([optim.SGD(N.parameters(),lr=E,momentum=np.random.random()*0.9),
optim.Adam(N.parameters(),lr=E),
optim.Adagrad(N.parameters(),lr=E),
optim.RMSprop(N.parameters(),lr=E,momentum=np.random.random()*0.9)
])
N.train(nn.MSELoss(),O,E,B,train_data,validate_data,EPOCH*W,800//D,D)
return N.evaluate(validate_data,target_data)



origin_data = preprocess(pd.read_csv(PATH+"train.csv"))
target_data = preprocess(pd.read_csv(PATH+"test.csv"))
# predictions = evaluate_gender_submission(validate_data,target_data)
predictions = torch.zeros(len(target_data),2)

begin = time.time()

while 1:
np.random.shuffle(origin_data)
train_data = origin_data[:800].copy()
validate_data = origin_data[800:].copy()

start = time.time()
predictions += RandomFCDNN(train_data,validate_data)
output(predictions,PATH+"submission.csv")
end = time.time()
print('Time cost: %.4f s, total time cost: %.4f s'%(end-start,end-begin))

注: 采用了固定学习率;验证集错误率限制改为$0.2$;注意PATH未定义。Kaggle Kernel版本


效果

泰坦尼克数据集令人尴尬的一点在于,由于”妇女和儿童先走”,直接判断性别女为存活性别男为不存活就可以实现$76.555\%$的正确率。同时由于来自于真实的历史事件,数据是公开的,因此”可以实现”$100\%$正确率。

目前实现的最优解为$80.861\%$,在Top $6\%$的水平(作为Kaggle入门比赛,榜单每三个月会清零一次),很可惜的是没能记录下参数。另外一个事实是,这一最优解是在没有Ensemble的情况下做到的(某次运行中第一个训练完成的神经网络)。

评论区中也有人猜测$83\%$左右是最高的正确率,鉴于训练集过小,更高的模型都可能存在潜在的过拟合。


扫描二维码即可在手机上查看这篇文章,或者转发二维码来分享这篇文章:


文章作者: Magolor
文章链接: https://magolor.cn/2020/01/12/2020-01-12-blog-01/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Magolor
扫描二维码在手机上查看或转发二维码以分享Magolor的博客