Kaggle 经典竞赛泰坦尼克号:超级无敌爆炸详细基础逐行讲解Pytorch实现代码,看完保证你也会!!!
讲解代码分为3个步骤:有什么用,为什么需要他,如何使用
保证大家耐心看完一定大有裨益!如果有懂的可以跳过,不过建议可以看完,查漏补缺嘛。
现在开始吧!
项目目标
我们的目标是根据泰坦尼克号乘客的个人信息(如年龄、性别、船舱等级等),预测他是否能在海难中幸存下来。这是一个典型的二元分类问题。现在我们开始吧,这是原版测试集和训练集的下载地址:
链接: https://pan.baidu.com/s/1zc1dpc6Xm-BzHbeMrp4oSw?pwd=6688 提取码: 6688
由于原版训练测试集有很多数据遗漏,所以我们要对其梳理一下。
第一步,导入必要库与加载数据
import pandas as pd
import numpy as np# 加载数据
train_df = pd.read_csv('train.csv')
test_df = pd.read_csv('test.csv')# 打印数据信息,快速了解数据
print("训练数据信息:")
train_df.info()
print("\n测试数据信息:")
test_df.info()print("\n训练数据前5行:")
print(train_df.head())
1.pd.read_csv('train.csv'):
有什么用:
它的作用是读取一个CSV(逗号分隔值)文件,并将其内容加载到一个 pandas DataFrame
对象中。在这里,代码分别读取了 train.csv
和 test.csv
两个文件,并把它们存储在名为 train_df
和 test_df
的变量里。df
是 DataFrame
的常用缩写。
为什么要用它:
这是数据加载的核心步骤。没有这一步,我们的程序就无法访问和使用存储在文件中的训练数据和测试数据。在机器学习中,我们通常会将数据分成“训练集”(用于训练模型)和“测试集”(用于评估模型在未见过数据上的表现),所以这里会加载两个文件。
使用语法:
pandas.read_csv(filepath_or_buffer)
最主要的参数就是文件的路径。这里
'train.csv'
表示文件就在当前代码运行的目录下。这个函数还有很多可选参数,例如,如果你的数据不是用逗号分隔的,可以用
sep
参数指定分隔符,如pd.read_csv('data.txt', sep='\t')
用于读取用Tab分隔的文件。
2.train_df.info():
有什么用:
这是一个非常方便的函数,用于快速获取 DataFrame
的一个简洁摘要。
为什么需要:
在真正开始处理数据前,我们需要对数据有一个宏观的了解。.info()
方法能立刻告诉我们以下关键信息:
数据有多少行、多少列:了解数据规模。
每一列的名称是什么。
每一列有多少个非空值:这是发现“缺失数据”最快的方法。如果“非空值”数量少于总行数,就说明这一列有数据缺失。
每一列的数据类型(
Dtype
):比如是数字(int64
,float64
)、文本(object
)还是其他类型。这对于后续的数据预处理至关重要。
使用语法
它是一个
DataFrame
对象的方法,直接在变量名后调用即可,不需要参数。dataframe_variable.info()
3.train_df.head():
有什么用:
这个函数用于查看 DataFrame
的前几行数据,默认是前5行。
为什么需要它?
.info()
给了我们数据的“骨架”信息,而 .head()
则让我们能亲眼看到数据的内容长什么样。通过查看真实的数据样本,我们可以直观地了解每一列的数值范围、文本格式等,对数据建立一个具象的认识。如果数据集有几百万行,你不可能把它全部打印出来看,所以看头几行和尾几行(用.tail()
)是最高效的方式。
使用语法
dataframe_variable.head(n)
n
是你想查看的行数,是可选参数。如果省略,默认为5。例如,
train_df.head(10)
就会显示前10行数据。
运行结果:
Age, Cabin 有大量缺失值。Embarked 在训练集中有少量缺失。
Sex, Embarked 是文本类别,需要转换为数字。
PassengerId, Name, Ticket 对预测可能用处不大或者处理起来太复杂,我们初期可以先舍弃。Cabin 缺失太多,也先舍弃。
第二步,数据梳理准备
# 为了方便,我们将训练集和测试集合并处理,最后再分开
# 保存测试集的PassengerId用于最后提交
test_passenger_ids = test_df['PassengerId']# 我们只挑选部分特征开始
features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked']# 合并数据
all_df = pd.concat([train_df[features], test_df[features]], axis=0).reset_index(drop=True)# 1. 处理缺失值
# Age: 使用中位数填充,因为它对异常值不敏感
all_df['Age'] = all_df['Age'].fillna(all_df['Age'].median())
# Fare: 同样使用中位数填充
all_df['Fare'] = all_df['Fare'].fillna(all_df['Fare'].median())
# Embarked: 使用众数(出现次数最多的值)填充
all_df['Embarked'] = all_df['Embarked'].fillna(all_df['Embarked'].mode()[0])
print("\n处理完缺失值后的数据信息:")
all_df.info()
1. test_passenger_ids = test_df['PassengerId']
有什么用?
这行代码从测试集
test_df
中提取出'PassengerId'
这一列,并将其保存到一个新的变量test_passenger_ids
中。
为什么需要它?
在很多机器学习任务(尤其是像Kaggle竞赛)中,
PassengerId
这种ID列是用来唯一标识每一行数据的,但它本身不包含任何可供模型学习的“特征”信息(乘客的ID号与他能否生还无关)。因此,我们不应该把它作为特征喂给模型去训练。但它又非常重要。当我们用模型对测试集
test_df
做出预测后,我们需要将预测结果和每个乘客对应起来,才能生成最终的提交文件。所以,我们在这里提前把它单独存起来,等所有处理和预测都完成后,再用它来和预测结果配对。
使用语法
dataframe['column_name']
这是
pandas
中选择单个列的标准方法。test_df['PassengerId']
会返回一个pandas Series
对象,里面包含了所有测试集乘客的ID。
2. features = ['Pclass', 'Sex', 'Age', ...]
有什么用?
这行代码创建了一个Python列表(
list
),其中包含了我们初步决定要用来训练模型的特征列的名称。这个过程叫做特征选择(Feature Selection)。
为什么需要它?
一个原始数据表里可能有很多列,但并非所有列都对我们的预测目标(比如,预测乘客是否生还)有用。
排除无关特征:像
PassengerId
和Name
(乘客姓名)通常被认为是无关特征。简化模型:选择一个特征子集可以使我们的代码更清晰,模型更简单,训练速度更快。
方便管理:把所有要用的特征名放在一个列表里,后续可以很方便地从DataFrame中一次性把它们都选出来,也便于未来增删特征做实验。
使用语法
这是标准的Python列表定义语法:
list_name = [element1, element2, ...]
。
3. all_df = pd.concat([...]).reset_index(drop=True)
这一行做了两件大事:concat
和 reset_index
。我们分开看。
pd.concat([train_df[features], test_df[features]], axis=0)
有什么用?
将训练集和测试集中我们选定的特征列(
features
列表中的那些列)合并成一个大的DataFrameall_df
。
为什么需要它?
为了确保预处理的一致性。训练集和测试集常常需要进行相同的预处理操作,比如:
填充缺失值:
Age
列可能有缺失值。我们通常会用所有数据(训练集+测试集)的年龄平均值或中位数来填充,这样更准确。编码分类变量:
Sex
列是'male'/'female'这样的文本,需要转换成0/1这样的数字。Embarked
列也是同理。我们必须保证在训练集和测试集中,相同的文本被转换成相同的数字。
将它们合并后,我们只需要对
all_df
这一个DataFrame进行预处理,就可以保证操作的统一性,避免了对训练集和测试集重复写同样的代码,减少了出错的可能。处理完毕后,我们再将它们拆分回训练集和测试集。
使用语法
pd.concat(objs, axis=0)
objs
: 一个列表,包含了要合并的DataFrame对象。这里是[train_df[features], test_df[features]]
。注意train_df[features]
表示从train_df
中只选择features
列表里包含的那些列。axis=0
: 这是关键参数。axis=0
表示纵向合并(按行合并),也就是把第二个DataFrame接到第一个的下面,行数相加。这正是我们合并训练/测试集时想要的。如果用axis=1
,则是横向合并(按列合并)。
4.reset_index(drop=True)
有什么用?
为合并后的新DataFrame
all_df
创建一个全新的、连续的索引。
为什么需要它?
当你用
pd.concat
合并两个DataFrame时,默认会保留它们各自原来的索引。比如,训练集索引是 0 到 890,测试集索引是 0 到 417。合并后,新的all_df
的索引就会是0, 1, ..., 890, 0, 1, ..., 417
,存在大量重复。这会给后续的数据筛选和处理带来麻烦。reset_index()
会生成一个从0开始的全新连续索引 (0, 1, 2, ... , 1307
)。参数
drop=True
的作用是丢弃原来的旧索引。如果不加这个参数,原来的旧索引会被当作一个新列(列名为'index')保留下来,但我们通常不需要它,所以直接丢弃。
5.all_df['Age'] = all_df['Age'].fillna(all_df['Age'].median())
有什么用?
这两行代码分别找到了
Age
(年龄)列和Fare
(票价)列中所有的缺失值,并用这两列各自的中位数(median)来填充它们。
为什么需要它?(特别是,为什么用中位数?)
必要性:
Age
和Fare
是重要的数值特征,但它们存在缺失数据,必须填充。策略选择:对于数值型数据,常见的填充策略有使用平均值(mean)、中位数(median)或众数(mode)。
平均值:计算简单,但容易被“异常值”(outliers)影响。例如,有几个乘客买了天价船票,这会把平均票价拉得很高,用这个偏高的平均值去填充缺失票价可能就不太合理。
中位数:将所有数据排序后取中间的那个数。它最大的优点是对异常值不敏感(鲁棒性强)。即使有天价船票,中位数也不会受其影响,因此它往往能更好地代表数据的“一般水平”。正如代码注释所说,这是一个更稳妥的选择。
使用语法
代码结构为:
df['列名'] = df['列名'].fillna(要填充的值)
all_df['Age']
: 选中Age
这一列数据(这是一个Pandas Series)。.median()
: 这是Pandas Series的一个方法,调用它会计算出该列的中位数,返回一个数字。.fillna(value)
: 找到该列中所有的NaN
,并将它们替换为括号中提供的value
。整个流程是:先计算出
Age
列的中位数,然后用这个中位数去填充Age
列中的所有NaN
,最后将填充好的新列数据覆盖掉原来的Age
列。Fare
列同理。
6.all_df['Embarked'] = all_df['Embarked'].fillna(all_df['Embarked'].mode()[0])
有什么用?
这行代码找到了
Embarked
(登船港口)列中的所有缺失值,并用该列的众数(mode)来填充。众数就是数据中出现次数最多的那个值。
为什么需要它?(特别是,为什么用众数?)
Embarked
列是分类特征,它的值是文本(如'S', 'C', 'Q'),而不是数字。对于这种数据,我们无法计算平均值或中位数。最合乎逻辑的填充方法,就是用出现频率最高的值来填充。我们推断,缺失的值有最大的可能性是那个最常见的值。
使用语法
语法结构和上面类似,但有一个关键点
[0]
。.mode()
: 这个方法计算列的众数。为什么是
.mode()[0]
? 因为一列数据的众数可能不止一个(比如'S'和'C'的出现次数完全相同且都是最高)。所以.mode()
方法总是返回一个Pandas Series,里面包含一个或多个众数值。即便只有一个众数,它也依然被包在一个Series里。我们需要通过索引[0]
来把第一个(通常也是唯一一个)众数值取出来,作为一个单独的值去填充。
经过这步处理后,all_df
中这三列的“窟窿”就被补上了,数据变得更加完整,可以用于下一步的转换和建模。
最后的运行结果为:
# 2. 将分类特征转换为数值特征
# Sex: Male -> 0, Female -> 1
all_df['Sex'] = all_df['Sex'].map({'male': 0, 'female': 1})
# Embarked: 使用独热编码 (One-Hot Encoding)
all_df = pd.get_dummies(all_df, columns=['Embarked'], prefix='Embarked')print("\n转换分类特征后的数据前5行:")
print(all_df.head())
1.all_df['Sex'] = all_df['Sex'].map({'male': 0, 'female': 1})
有什么用?
这行代码将
Sex
列中的文本值 'male' 替换为数字0
,'female' 替换为数字1
。这是一种手动实现的标签编码(Label Encoding)。
为什么需要它?
Sex
列是一个典型的二元分类特征(只有两种可能的类别)。对于这种情况,最简单直接的方法就是将两个类别分别映射到一个数字。这样,'male'和'female'这两个字符串就被转换成了模型可以处理的0和1。
使用语法
Series.map(字典)
all_df['Sex']
选中Sex
这一列数据。.map()
是Pandas Series的一个方法,它可以接受一个字典作为参数。{'male': 0, 'female': 1}
就是这个映射字典。.map
方法会遍历Sex
列中的每一个元素,如果在字典的“键”(key)中找到了该元素(比如'male'),就把它替换成字典里对应的“值”(value)(也就是0
)。最后,将这个转换后的新Series赋值回
all_df['Sex']
列。
2.all_df = pd.get_dummies(all_df, columns=['Embarked'], prefix='Embarked')
有什么用?
这行代码对
Embarked
(登船港口)列进行了独热编码(One-Hot Encoding)。它会做两件事:
1.移除原来的Embarked
列。
2.根据Embarked
列中所有不重复的值(比如'S', 'C', 'Q'),创建出对应数量的新列(Embarked_S
, Embarked_C
, Embarked_Q
)。
对于每一行数据,如果原来的Embarked
值是'S',那么在新列Embarked_S
中,该行的值就为1
,而在Embarked_C
和Embarked_Q
中则为0
。
为什么需要它?(特别是,为什么不用map
方法映射成0, 1, 2?)
Embarked
列是多元(无序)分类特征。它有三个类别'S', 'C', 'Q'。陷阱:如果我们像处理
Sex
列一样,简单地把它们映射成S=0
,C=1
,Q=2
,模型可能会错误地理解它们之间存在某种顺序关系或大小关系(比如认为Q
>C
>S
)。但实际上,这三个港口只是地点不同,没有顺序或大小之分。这种错误的假设会干扰模型的学习。独热编码的优势:它通过将一个特征拆分成多个“是/否”的二元特征来完美地解决了这个问题。每个新列代表“是否是这个港口?”。这样,特征之间就变得相互独立,模型不会再做出错误的顺序假设。这是处理无序多元分类特征的标准做法。
使用语法
pd.get_dummies(data, columns, prefix)
data
: 需要进行独热编码的整个DataFrame,这里是all_df
。columns=['Embarked']
: 一个列表,指定要对哪些列进行独热编码。prefix='Embarked'
: 为新生成的列添加前缀。如果不指定,新列名可能就是'S', 'C', 'Q',可读性较差。加上前缀后,新列名会变成Embarked_S
,Embarked_C
,Embarked_Q
,非常清晰。这个函数会返回一个全新的、已经完成了编码的DataFrame,我们再把它赋值给
all_df
。
输出结果:
Sex
列已经变成了0和1。原来的
Embarked
列消失了。数据表的末尾多了几个
Embarked_
开头的新列。
# 3. 特征缩放 (Feature Scaling)
# 神经网络对特征的尺度很敏感,我们使用标准化(Standardization)
from sklearn.preprocessing import StandardScaler# 需要缩放的列
cols_to_scale = ['Age', 'Fare', 'Pclass', 'SibSp', 'Parch']
scaler = StandardScaler()
all_df[cols_to_scale] = scaler.fit_transform(all_df[cols_to_scale])print("\n特征缩放后的数据前5行:")
print(all_df.head())
有什么用?
这段代码的核心作用是对指定的数值特征列进行标准化(Standardization)。
标准化处理后,这些列的数据都会被转换成平均值为0,标准差为1的新数据。
为什么需要它?(这可能是最重要的一步)
不同特征的“量纲”差异巨大:观察我们的数据,
Age
的范围可能是0-80,Fare
的范围可能是0-500+,而Pclass
(舱位等级)只有1, 2, 3。这些特征的数值尺度(量纲)相差悬殊。对神经网络训练的影响:神经网络的权重更新依赖于梯度下降算法。如果特征尺度差异很大:
收敛速度变慢:尺度大的特征(如
Fare
)在计算损失和梯度时会占据主导地位,导致损失函数的“等高线图”变成一个又扁又长的椭圆形。模型优化的路径会像在一个狭长的山谷中来回震荡,而不是平稳地走向最低点,这会大大减慢模型的训练收敛速度。权重学习不公平:模型可能会错误地认为,数值范围更大的特征就更重要。通过缩放,我们将所有特征拉到了一个可比较的起跑线上,让模型能够根据特征真正的预测能力来学习其权重,而不是被它们的原始尺度所迷惑。
标准化 vs. 归一化:
标准化 (Standardization) (我们用的这种): 将数据处理成均值为0,标准差为1。它不把数据限制在特定范围内,对异常值的敏感度较低,是神经网络中最常用的缩放方法之一。
归一化 (Normalization / Min-Max Scaling): 将数据缩放到一个固定的区间,通常是[0, 1]。
使用语法
cols_to_scale = [...]
: 定义一个列表,清晰地指明哪些列需要被缩放。这里包含了我们所有的数值型特征(包括Pclass
这种有序分类特征,我们也可以当数值特征来处理和缩放)。scaler = StandardScaler()
: 创建一个StandardScaler
的实例(对象)。此时,这个scaler
对象还是“空的”,它不了解我们的数据。scaler.fit_transform(...)
: 这是最核心的一步,它是一个复合操作:fit()
(拟合):scaler
首先会“学习”我们提供的数据(all_df[cols_to_scale]
)。具体来说,它会计算出Age
,Fare
等每一列的平均值和标准差,并把这些统计值保存在scaler
对象内部。transform()
(转换):然后,scaler
会使用刚刚学到的平均值和标准差,对每一列的每一个数据点应用标准化公式:新值 = (原值 - 平均值) / 标准差
。fit_transform
将这两个步骤合并为一步,代码更简洁高效。它会返回一个包含所有转换后数据的新数组。
all_df[cols_to_scale] = ...
: 将fit_transform
返回的包含新值的数组,赋值回all_df
中对应的列,完成对原始数据的替换。
输出结果:
# 4. 分离回训练集和测试集
train_processed_df = all_df[:len(train_df)]
test_processed_df = all_df[len(train_df):]
target = 'Survived'
y_train = train_df[target] # 训练集的标签
有什么用?
这两行代码将我们之前合并并处理好的
all_df
,重新精确地拆分回处理后的训练集train_processed_df
和处理后的测试集test_processed_df
。
为什么需要它?
我们的策略是“合并处理,分离建模”。
合并是为了确保对训练集和测试集应用完全一致的预处理规则(比如用同一个中位数填充,用同一个缩放器
scaler
)。分离是机器学习的基本原则。我们必须用训练集 (
train_processed_df
和其对应的标签y_train
) 来训练模型,然后用训练好的模型去对测试集 (test_processed_df
) 进行预测。测试集是模拟的“未来未知数据”,在训练过程中绝对不能让模型看到测试集的数据(除了在预处理阶段为了更准确的统计而“借用”了一下),否则就是“作弊”,会导致模型评估结果过于乐观且不真实。
使用语法
这里利用了Pandas DataFrame对Python切片(slicing)语法的支持,非常巧妙。
len(train_df)
: 我们获取了原始训练集train_df
的行数。假设它有891行。all_df[:len(train_df)]
: 这句代码的意思是“从all_df
的第0行开始,取到第891行(不包括第891行)”。因为我们当初是先把train_df
放在前面进行合并的,所以这部分数据不多不少,正好就是属于原来训练集的那部分数据。all_df[len(train_df):]
: 这句代码的意思是“从all_df
的第891行开始,一直取到最后一行”。这部分数据正好就是属于原来测试集的那部分。
结果:
现在,我们拥有了:
train_processed_df
(X_train): 处理好的训练特征y_train
(y_train): 训练标签test_processed_df
(X_test): 处理好的测试特征
“万事俱备,只欠东风”,下一步就是将它们转换成PyTorch Tensor,搭建神经网络这个“东风”,来完成最终的训练和预测任务了!
我们先看看我们的训练集:
import torch
from torch.utils.data import Dataset, DataLoader, TensorDatasetprint(train_processed_df.info())
第三步,数据打包与搭建神经网络
# 直接转换为NumPy浮点数组 -> 再转PyTorch张量
X_np = train_processed_df.to_numpy(dtype=np.float32) # 强制指定float32
X_train_tensor = torch.from_numpy(X_np) # NumPy->PyTorch自动类型匹配# 标签处理(注意保持维度一致)
y_np = y_train.to_numpy(dtype=np.float32)
y_train_tensor = torch.from_numpy(y_np).view(-1, 1) # 相当于unsqueeze(1)
1.train_processed_df.to_numpy(dtype=np.float32)
有什么用?
将我们处理好的
train_processed_df
这个Pandas DataFrame,转换成一个NumPy数组X_np
。
为什么需要它?
PyTorch的亲密伙伴:PyTorch与NumPy的集成是无缝的。将数据从Pandas转换到PyTorch时,NumPy是最理想、最高效的中间桥梁。
强制指定
np.float32
:这是一个非常关键的细节。默认情况下,NumPy可能会创建float64
(双精度)的数组。但在深度学习中,几乎所有的计算都是在float32
(单精度)下进行的。这能在保证足够精度的情况下,将内存占用和计算量减半,尤其是在使用GPU时,性能提升非常明显。所以我们在这里强制指定数据类型为np.float32
。
使用语法
.to_numpy()
: 这是Pandas DataFrame或Series对象的方法,用于将其转换为NumPy数组。dtype=np.float32
: 是一个可选参数,用来指定转换后数组中元素的数据类型。
2.torch.from_numpy(X_np)
有什么用?
从NumPy数组
X_np
创建一个PyTorch张量X_train_tensor
。这正是我们的最终目的。
为什么需要它?
PyTorch框架中的所有运算,无论是模型的前向传播还是反向传播,都必须在
torch.Tensor
对象上进行。没有这一步,数据就无法进入PyTorch的生态系统。一个重要的特性:
torch.from_numpy()
创建的张量和原始的NumPy数组共享内存。这意味着它们指向计算机内存中的同一块数据。这样做的好处是效率极高,因为它避免了复制数据的开销。但也要注意,如果后续修改了NumPy数组,那么对应的PyTorch张量也会跟着改变,反之亦然。在这个场景下,这完全没问题,因为我们只是做最后的转换。
使用语法
torch.from_numpy(numpy_array)
: PyTorch的函数,输入一个NumPy数组,输出一个PyTorch张量。输出张量的数据类型会自动与输入的NumPy数组匹配(这也是我们上一步要指定float32
的原因)。
3.torch.from_numpy(y_np).view(-1, 1):
有什么用?
将标签NumPy数组
y_np
转换为PyTorch张量,并且调整其形状(维度)。
为什么需要
.view(-1, 1)
?原始形状:
torch.from_numpy(y_np)
执行后,得到的张量是一个一维向量,形状是[891]
(假设有891个样本)。模型输出的形状:我们的模型在进行二分类任务时,最后一层的输出通常是一个形状为
[批量大小, 1]
的二维张量。对于整个训练集,就是[891, 1]
。维度匹配:在计算损失时,PyTorch需要模型输出和标签张量的形状能够匹配。如果一个是
[891, 1]
,另一个是[891]
,可能会导致广播(broadcasting)错误或潜在的bug。解决方案:
.view(-1, 1)
的作用就是将形状从[891]
强制“重塑”为[891, 1]
。它把一个一维的“列表”变成了一个N行1列的“列向量”。这样就确保了我们的标签张量和模型输出张量的维度是完全一致的,可以让损失计算准确无误地进行。
使用语法
tensor.view(shape)
: 是一个改变张量形状的方法。view(-1, 1)
:这里的-1
是一个占位符,意思是PyTorch根据总元素数量和另一个维度(这里是1)自动计算出这个维度的大小。所以view(-1, 1)
就是告诉PyTorch:我们想要1列,行数你帮我算好。对于一维向量来说,
.view(-1, 1)
和.unsqueeze(1)
的效果是完全一样的,都是在最后增加一个维度。
现在,我们已经成功地将数据从最开始的CSV文件,一路过关斩将,变成了PyTorch可以直接使用的、格式干净、维度正确的张量:
X_train_tensor
: 训练特征张量,形状为[样本数, 特征数]
y_train_tensor
: 训练标签张量,形状为[样本数, 1]
这两份张量已经准备好,可以打包成Dataset
和DataLoader
# 2. 创建TensorDataset
# TensorDataset可以把特征和标签打包在一起
train_dataset = TensorDataset(X_train_tensor, y_train_tensor)# 3. 创建DataLoader
# DataLoader可以帮我们打乱数据、划分批次
BATCH_SIZE = 64
train_loader = DataLoader(dataset=train_dataset, batch_size=BATCH_SIZE, shuffle=True)
1.TensorDataset
:将特征和标签打包
有什么用?
TensorDataset
是一个PyTorch提供的工具类,它的作用就像一个拉链,将我们的特征张量X_train_tensor
和标签张量y_train_tensor
配对打包在一起。它能确保数据和标签一一对应。当你从
train_dataset
中取第i
个元素时,你会同时得到X_train_tensor
的第i
行和y_train_tensor
的第i
行。
为i什么需要它?
数据与标签的绑定:在训练时,模型需要同时拿到一条数据和它对应的正确答案。
TensorDataset
就是实现这种绑定的最简单直接的方式。遵循标准接口:它创建了一个符合PyTorch标准的
Dataset
对象。所有Dataset
对象都有共同的行为(比如可以用len()
获取总长度,可以用[]
索引来获取数据),这使得它可以无缝地被下一步的DataLoader
所使用。当你的数据已经全部在内存中并是Tensor格式时,TensorDataset
是创建数据集的首选。
使用语法
torch.utils.data.TensorDataset(*tensors)
你需要传入一个或多个张量作为参数。
关键要求:所有传入的张量的第一个维度(也就是样本数量)必须相等。在我们的例子中,
X_train_tensor
(形状[891, 特征数]
)和y_train_tensor
(形状[891, 1]
)的第一个维度都是891,所以满足这个要求。
2. DataLoader
:自动化数据加载器
有什么用?
DataLoader
是PyTorch中最核心的数据加载工具。它接收一个Dataset
对象(比如我们刚创建的train_dataset
),并把它包装成一个强大的Python迭代器(iterator)。在我们训练模型时,可以直接遍历这个
train_loader
,它会自动地、高效地为我们提供一个个小批量(mini-batch) 的数据。
为什么需要它?(这是PyTorch训练流程的精髓)
实现小批量梯度下降:我们不可能一次性把整个数据集(891条数据)都扔进模型去训练,这会占用巨大内存且效率低下。标准的做法是“小批量梯度下降(Mini-batch Gradient Descent)”,即每次只用一小部分数据(比如64条)来计算损失、更新模型权重,然后重复这个过程。
DataLoader
的核心工作就是帮我们自动完成这个分批操作。数据打乱(
shuffle=True
):这是防止模型过拟合、提升泛化能力的关键。设置为True
后,DataLoader
会在每个训练周期(epoch)开始前,都重新随机打乱数据的顺序。这样可以确保模型不会学到数据的排列顺序,而是学习数据本身内在的模式。在训练时,这个参数几乎必须设为True
。内存管理与效率:通过分批加载,我们无需一次性将所有数据载入到昂贵的GPU显存中,使得训练大数据集成为可能。
DataLoader
还支持多线程预加载数据(通过num_workers
参数),可以实现CPU加载数据和GPU计算的并行,大大提升训练效率。
使用语法
torch.utils.data.DataLoader(dataset, batch_size=1, shuffle=False, ...)
dataset
: 我们要加载的Dataset
对象,这里是train_dataset
。batch_size
: 每个批次包含的样本数量。64
是一个非常常见的大小,通常设为2的幂次方以提高硬件效率。最后一批的数量可能会小于batch_size
。shuffle=True
: 一个布尔值。True
表示在每个epoch开始时打乱数据顺序。对于训练集,我们用True
;对于验证集和测试集,我们通常用False
,因为评估时我们希望顺序是固定的,便于比较结果。
搭建网络
import torch.nn as nn
import torch.nn.functional as Fclass TitanicNet(nn.Module):def __init__(self, input_features):super(TitanicNet, self).__init__()# 定义网络层self.fc1 = nn.Linear(input_features, 64) # 输入层 -> 第一个隐藏层self.fc2 = nn.Linear(64, 32) # 第一个隐藏层 -> 第二个隐藏层self.fc3 = nn.Linear(32, 1) # 第二个隐藏层 -> 输出层self.dropout = nn.Dropout(0.2) # Dropout层,防止过拟合def forward(self, x):# 定义数据在网络中的流向(前向传播)x = F.relu(self.fc1(x)) # 使用ReLU激活函数x = self.dropout(x) # 应用dropoutx = F.relu(self.fc2(x)) # 使用ReLU激活函数x = self.dropout(x) # 应用dropoutx = self.fc3(x) # 输出层不需要激活函数,因为我们将使用BCEWithLogitsLossreturn x
1. 整体框架: class TitanicNet(nn.Module)
有什么用?
这段代码定义了一个名为
TitanicNet
的Python类,它就是我们神经网络的模型骨架。所有在PyTorch中自定义的模型,都应该**继承(inherit)**自
torch.nn.Module
这个基类。
为什么需要它?
继承
nn.Module
后,我们的TitanicNet
类就能自动获得PyTorch提供的各种强大功能,比如:自动追踪模型中所有可学习的参数(权重和偏置)。
方便地将整个模型移动到GPU上(用
.to(device)
)。轻松地在训练模式 (
.train()
) 和评估模式 (.eval()
) 之间切换(这对于Dropout和BatchNorm等层至关重要)。加载和保存整个模型的状态。
这是一种约定,是PyTorch约定俗成的东西。
2. __init__
方法:定义模型的网络层
有什么用?
__init__
方法是这个类的构造函数。在PyTorch模型中,它的核心任务是定义并初始化模型需要用到的所有网络层。这些网络层被定义好后,会作为类的属性(比如
self.fc1
)存储起来,等待在forward
方法中被调用。
为什么需要它?
这里是“蓝图规划”阶段。我们在这里一次性把所有需要的“砖瓦”都准备好,但先不关心它们怎么连接。这让模型的结构非常清晰。
语法详解
super(TitanicNet, self).__init__()
: 必须调用!这行代码的作用是调用父类nn.Module
的构造函数,完成一些必要的内部初始化,否则模型无法正常工作。self.fc1 = nn.Linear(input_features, 64)
:nn.Linear(in_features, out_features)
: 定义一个全连接层(Fully Connected Layer),也叫线性层。它会对输入数据做一次线性变换
in_features
: 输入特征的数量。input_features
就是我们处理好的数据X_train_tensor
的列数。out_features
: 输出特征的数量,也就是该层神经元的个数。这里是64。
self.fc2 = nn.Linear(64, 32)
: 定义第二个全连接层。注意,它的输入维度64
必须和上一层fc1
的输出维度64
完全一致。self.fc3 = nn.Linear(32, 1)
: 定义输出层。它的输出维度是1
,因为我们要做的是二分类任务(生还 vs. 未生还),最终只需要一个数值来代表预测结果。self.dropout = nn.Dropout(0.2)
:nn.Dropout(p)
: 定义一个Dropout层。它是一种非常有效的正则化手段,用于防止模型过拟合。p=0.2
: 表示在训练过程中,每次数据流过这个层时,都有20%的神经元会被随机“丢弃”(暂时使其输出为0)。这强迫网络不能过度依赖任何一个神经元,从而学习到更鲁棒的特征。重要:Dropout只在训练模式(
.train()
)下生效,在评估模式(.eval()
)下会自动失效.
3. forward
方法:连接网络层,定义数据流
语法详解
x = F.relu(self.fc1(x))
:self.fc1(x)
: 首先,输入数据x
流过第一个全连接层。F.relu(...)
:relu
是修正线性单元(Rectified Linear Unit),是一种激活函数。它的公式是 f(x)=max(0,x)。为什么需要激活函数? 如果没有像
ReLU
这样的非线性激活函数,那么无论我们堆叠多少个线性层,整个网络本质上还是一个线性模型,无法学习复杂的非线性规律。ReLU
是目前最常用、最高效的激活函数之一。
x = self.dropout(x)
: 在激活之后,应用dropout。x = self.fc3(x)
: 数据流过最后的输出层。为什么最后一层没有激活函数? 因为我们计划使用的损失函数是
BCEWithLogitsLoss
。这个损失函数内部已经集成了Sigmoid激活函数,并且这么做在数值上更稳定。所以,它期望的输入是未经激活的原始输出值,这些原始值被称为 logits。
# 确定输入特征的数量
input_dims = X_train_tensor.shape[1]
model = TitanicNet(input_features=input_dims)
print(model)
1. input_dims = X_train_tensor.shape[1]
有什么用?
这行代码的作用是动态地获取我们训练数据
X_train_tensor
的特征数量(也就是数据的列数),并将其存储在变量input_dims
中。
为什么需要它?
模型的“入口”尺寸必须匹配:回忆一下我们定义的
TitanicNet
模型,它的__init__
方法需要一个参数input_features
,这个参数决定了模型第一层nn.Linear(input_features, 64)
的输入神经元数量。这个数量必须和我们喂给它的数据的特征数完全一样,否则数据就塞不进模型的入口,程序会报错。代码的灵活性:我们当然可以手动数一下有几个特征,然后写一个固定的数字(比如
input_features=10
)。但这是非常不好的编程习惯。如果我们将来调整了数据预处理步骤,增删了某个特征,就必须手动回来修改这个数字,很容易忘记而出错。像现在这样,直接从数据X_train_tensor
的形状中获取特征数,意味着无论我们的数据有多少列,代码都能自动适应,无需任何修改。
使用语法
tensor.shape
: 这是PyTorch张量的一个属性,它返回一个元组(tuple),表示张量在各个维度上的大小。对于X_train_tensor
,它的形状是[样本数量, 特征数量]
。.shape[1]
: 我们通过索引[1]
来获取这个元组中的第二个元素,也就是特征数量。
2. model = TitanicNet(input_features=input_dims)
有什么用?
这行代码根据我们之前定义的
TitanicNet
类(蓝图),创建了一个具体、可用的模型实例(对象),并赋值给变量model
。
为什么需要它?
class TitanicNet(...)
只是它定义了模型应该长什么样。而model = TitanicNet(...)
这一步,才建造出一个实际的模型。这个
model
对象现在是一个包含了我们所有网络层(fc1
,fc2
,fc3
,dropout
)的集合体。PyTorch已经为这些层自动初始化了随机的权重(weights)和偏置(biases)。这个model
就是我们接下来要进行训练、评估和预测的主体。
使用语法
这是标准的Python类实例化语法。
TitanicNet(input_features=input_dims)
: 调用类的名字,并传入构造函数__init__
所需要的参数。这里,我们把刚刚动态获取的特征数input_dims
传递给了input_features
参数。
# 定义损失函数
# BCEWithLogitsLoss 结合了 Sigmoid 和 BCELoss,数值上更稳定
criterion = nn.BCEWithLogitsLoss()# 定义优化器
# Adam 是一种常用的自适应学习率优化算法
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)# 设置训练参数
EPOCHS = 100
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
model.to(device) # 将模型移动到GPU(如果可用)print(torch.__version__)
print(torch.version.cuda) # 应该显示 CUDA 版本
1. criterion = nn.BCEWithLogitsLoss()
:定义损失函数
有什么用?
这行代码定义了我们的损失函数(Loss Function),也叫代价函数(Cost Function)或评判标准(Criterion)。
它的核心任务是衡量模型的预测结果与真实标签之间的差距。差距越大,损失值就越高,说明模型预测得越“差”。
为什么需要它?
为训练提供目标:整个神经网络的训练过程,就是一个想方设法最小化损失函数值的过程。损失函数为模型的学习指明了方向和目标。
选择
BCEWithLogitsLoss
的原因:BCE
是二元交叉熵(Binary Cross-Entropy)的缩写,这是解决二分类问题(如我们的“生还/未生还”)的标准损失函数。WithLogits
是它的精髓所在。正如我们之前在定义模型时讨论的,我们的模型最后一层输出的是未经激活的原始数值(logits)。BCEWithLogitsLoss
这个损失函数会在内部自动帮我们完成Sigmoid激活,然后再计算二元交叉熵损失。数值稳定性:您的注释非常正确,将Sigmoid和BCE合并在一个函数里计算,可以避免在某些情况下(比如logits的绝对值很大时)出现浮点数溢出问题,比我们自己手动加Sigmoid层再用
BCELoss
要更稳定、更安全。
使用语法
criterion = nn.损失函数类()
: 我们通过实例化一个损失函数类来创建一个可用的损失函数对象。对于BCEWithLogitsLoss
的基础用法,不需要传入任何参数。
2. optimizer = torch.optim.Adam(...)
:定义优化器
有什么用?
这行代码定义了我们的优化器(Optimizer)。如果说损失函数是指出“错在哪里”,那么优化器就是负责“如何改正”的工具。
它根据损失函数计算出的梯度(指明了让损失变小的方向),来更新模型中所有的权重和偏置,从而让模型在下一次预测时表现得更好。
为什么需要它?
驱动模型学习:优化器是模型学习的引擎。在训练循环中,我们喊一声
optimizer.step()
,模型的所有参数就会根据计算好的梯度进行一次微调。没有优化器,模型就只是一个静态的结构,无法学习。选择
Adam
的原因:Adam
(自适应矩估计)是目前最流行、最通用的优化器之一。相比传统的随机梯度下降(SGD),Adam
能够为模型中的每一个参数自适应地调整学习率。在绝大多数任务中,它都能快速、稳定地收敛,是一个非常优秀的“万金油”选择,非常适合作为入门和首选。
使用语法
optimizer = torch.optim.优化器类(params, ...)
params = model.parameters()
: 这是最重要的参数。我们必须明确告诉优化器,它需要更新哪些参数。model.parameters()
是nn.Module
提供的一个便捷方法,它会自动返回我们模型中所有需要学习的参数(所有nn.Linear
等层里的权重和偏置)。lr=0.001
:lr
代表学习率(Learning Rate)。它控制了每次更新参数时的“步子”大小。这是一个至关重要的超参数。0.001
是Adam优化器一个非常常用且效果不错的初始值。
3. device = ...
和 model.to(device)
:配置计算设备
有什么用?
这几行代码的作用是自动检测当前环境是否有可用的NVIDIA GPU(以及CUDA环境),并将我们的模型迁移到指定的计算设备上。
为什么需要它?
大幅加速训练:神经网络的计算核心是海量的矩阵运算。GPU(图形处理器)的设计天生就擅长这种并行计算,其速度比CPU快几十甚至上百倍。对于任何稍具规模的模型,使用GPU训练都是必须的,否则可能需要花费数小时甚至数天的时间。
统一设备:在PyTorch中,要进行计算,模型和数据必须在同一个设备上。我们在这里先把模型用
.to(device)
放到了GPU上,之后在训练循环中,我们还需要把每一批次的数据也放到同一个device
上。
使用语法
EPOCHS = 100
: 定义训练的总轮数,即我们要把整个训练数据集从头到尾过多少遍。torch.cuda.is_available()
: 返回True
或False
,判断CUDA环境是否可用。device = torch.device(...)
: 创建一个设备对象。"cuda"
代表GPU,"cpu"
代表CPU。通过一个三元表达式,我们的代码实现了自动选择。model.to(device)
: 这是nn.Module
的方法,它会将模型内部所有的参数和缓冲区都移动到指定的device
上。
第四步,开始训练
# 训练循环
for epoch in range(EPOCHS):model.train() # 将模型设置为训练模式epoch_loss = 0.0for inputs, labels in train_loader:inputs, labels = inputs.to(device), labels.to(device)# 1. 梯度清零optimizer.zero_grad()# 2. 前向传播outputs = model(inputs)# 3. 计算损失loss = criterion(outputs, labels)# 4. 反向传播loss.backward()# 5. 更新权重optimizer.step()epoch_loss += loss.item()if (epoch + 1) % 10 == 0:print(f'Epoch [{epoch+1}/{EPOCHS}], Loss: {epoch_loss/len(train_loader):.4f}')
整体结构:Epoch 与 Batch 的双重循环
整个训练循环是一个双层for
循环:
外层循环 (
for epoch in range(EPOCHS):
):遍历世代(Epoch)。一个Epoch代表我们的模型已经完整地看过一遍所有的训练数据。我们让模型反复看很多遍(这里是100遍),以期它能充分学习到数据中的规律。内层循环 (
for inputs, labels in train_loader:
):遍历批次(Batch)。在每一个Epoch中,我们不是一次性处理所有数据,而是通过train_loader
,一小批一小批地处理数据。
训练循环详解
我们来逐步分析循环内部的每一个动作:
model.train()
有什么用?:在每个Epoch开始时,调用这行代码来明确地告诉PyTorch:“现在开始是训练模式了!”
为什么需要它?:这是非常关键的一步。它会“激活”模型中只在训练时才使用的层,比如我们之前定义的
Dropout
层。反之,在评估模型时,我们会调用model.eval()
来“关闭”这些层,以保证评估结果的确定性。
inputs, labels = inputs.to(device), labels.to(device)
有什么用?:将当前批次的数据(
inputs
)和标签(labels
)一起发送到我们之前设定的计算设备上(cpu
或cuda
)。为什么需要它?:为了进行计算,模型和它要处理的数据必须位于同一个设备上。我们之前已经把
model
放到了device
上,所以现在也必须把每一批数据放上去。
训练的核心五步
这五步是所有PyTorch标准训练流程,顺序几乎是固定的。
1. optimizer.zero_grad()
- 梯度清零
有什么用?:清除上一个批次计算出的所有参数的梯度。
为什么需要它?:PyTorch的梯度在反向传播时是累加的。如果我们不清零,当前批次计算出的梯度就会被加到上一个批次的梯度上,这会导致完全错误的更新方向。所以,在为新的一批数据计算梯度之前,必须“清扫”一下,确保每次更新都只基于当前批次的数据。
2. outputs = model(inputs)
- 前向传播
有什么用?:将输入数据
inputs
喂给模型,得到模型的预测输出outputs
(也就是logits)。为什么需要它?:这是模型进行“预测”的过程。数据按照我们在
forward
方法中定义的路径,在网络中走了一遍,得出了一个初步的答案。
3. loss = criterion(outputs, labels)
- 计算损失
有什么用?:用我们定义的损失函数
criterion
,来比较模型的预测outputs
和真实标签labels
之间的差距。为什么需要它?:这是为了量化模型“错得有多离谱”。得到的
loss
是一个单独的数值,这个数值就是我们接下来要努力减小的目标。
4. loss.backward()
- 反向传播
有什么用?:PyTorch的
autograd
(自动微分)引擎会根据loss
,自动计算出模型中每一个可学习参数(权重和偏置)的梯度。为什么需要它?:梯度指明了能让损失函数值上升最快的方向。因此,梯度的反方向就是我们调整参数、降低损失的最佳方向。这一步就是为了找到这个“方向图”。
5. optimizer.step()
- 更新权重
有什么用?:命令我们的优化器
optimizer
,根据上一步计算出的梯度,来更新模型的所有参数。为什么需要它?:这是真正发生“学习”的一步。
loss.backward()
只负责计算方向,optimizer.step()
则负责真正地朝着这个正确的方向“迈出一步”,对模型的权重进行微调。
epoch_loss += loss.item()
有什么用?:
loss
本身是一个带计算图的张量。.item()
方法可以从中提取出纯粹的Python数字。我们把每个batch的loss累加起来,是为了计算整个epoch的平均loss。为什么需要它?:这是为了监控训练过程。通过观察每个epoch的平均loss是否在稳步下降,我们就能判断模型是否在有效地学习。
print(f'Epoch ...')
有什么用?:每隔10个epoch,打印一次当前的epoch数和这个epoch的平均损失。
为什么需要它?:这里的
epoch_loss/len(train_loader)
就是用总损失除以总批次数,得到平均损失。
第五步,评估模型并且生成测试结果文件
X_np1 = test_processed_df.to_numpy(dtype=np.float32) # 强制指定float32
X_test_tensor = torch.from_numpy(X_np1) # NumPy->PyTorch自动类型匹配# 1. 进行预测
model.eval() # 将模型设置为评估模式,这会禁用Dropout
with torch.no_grad(): # 在这个代码块中,不计算梯度,以节省计算资源X_test_tensor = X_test_tensor.to(device)test_outputs = model(X_test_tensor)# test_outputs是logits,需要通过sigmoid转换为概率test_probs = torch.sigmoid(test_outputs).cpu()# 根据概率(阈值为0.5)转换为0或1的预测结果test_preds = (test_probs > 0.5).int().squeeze()# 2. 创建提交文件
submission_df = pd.DataFrame({'PassengerId': test_passenger_ids,'Survived': test_preds.numpy()
})submission_df.to_csv('submission_pytorch.csv', index=False)print("\n提交文件 'submission_pytorch.csv' 已生成!")
print(submission_df.head())
X_np1 = test_processed_df.to_numpy(dtype=np.float32) # 强制指定float32
X_test_tensor = torch.from_numpy(X_np1) # NumPy->PyTorch自动类型匹配
将我们预处理好的测试集特征test_processed_df
,转换成PyTorch模型能够接收的Tensor
格式。
进行预测(模型推理 )
这一部分是模型应用的核心,包含了几个非常关键的步骤。
model.eval()
有什么用?:将模型切换到评估模式。
为什么需要它?:这是
model.train()
的对应操作,至关重要。在评估模式下,PyTorch会自动关闭Dropout
层(因为预测时我们希望使用整个网络的能力,而不是随机丢弃神经元)和BatchNorm
层(如果模型中有的话)。这能保证我们的预测结果是确定性的、可复现的。
with torch.no_grad():
有什么用?:创建一个上下文管理器,临时关闭所有梯度的计算。
为什么需要它?:在预测阶段,我们只是单纯地让数据通过模型得到结果,完全不需要计算梯度(梯度是训练时用来更新权重的)。关闭梯度计算可以带来两大好处:
节省内存:不需要为反向传播保存中间状态。
加快速度:跳过了梯度计算的开销,让前向传播更快。
这是所有模型推理代码的标准优化操作。
预测流程详解
1.X_test_tensor.to(device)
: 将测试数据张量也放到与模型相同的设备上。
2.test_outputs = model(X_test_tensor)
: 进行预测。将测试数据喂给模型,得到模型的原始输出(logits)。
3.test_probs = torch.sigmoid(test_outputs).cpu()
: 转换成概率。
torch.sigmoid(...)
: 模型的输出是logits,为了将其解释为“生还的概率”(一个0到1之间的数),我们必须用sigmoid
函数对其进行激活。.cpu()
: 模型的计算可能在GPU上完成,得到的test_outputs
也在GPU上。为了方便后续使用NumPy或Pandas处理,我们通常会用.cpu()
方法将结果数据拷回CPU内存。
4.test_preds = (test_probs > 0.5).int().squeeze()
: 得出最终类别。
(test_probs > 0.5)
: 以0.5为阈值,将概率转换为布尔值(True
/False
)。概率大于0.5的被认为是True
(预测为生还)。.int()
: 将布尔值True
/False
转换为整数1
/0
,得到我们最终的预测类别。.squeeze()
: 此时张量的形状是[样本数, 1]
,.squeeze()
会移除所有大小为1的维度,将其变成[样本数]
,这更方便我们后续创建DataFrame。
submission_df = pd.DataFrame
({ 'PassengerId': test_passenger_ids, 'Survived': test_preds.numpy() })
submission_df.to_csv('submission_pytorch.csv', index=False)
有什么用?:将我们的预测结果与乘客ID配对,生成一个符合Kaggle等竞赛平台要求的CSV文件。
为什么需要它?:这是将我们的模型成果转化为最终交付物的步骤。
使用语法:
pd.DataFrame({...})
: 用一个字典来创建一个Pandas DataFrame。字典的键成为列名。'PassengerId': test_passenger_ids
: 还记得我们最开始就保存下来的测试集乘客ID吗?现在它派上了用场,确保每个预测结果都能和正确的乘客对应上。'Survived': test_preds.numpy()
: 为了将PyTorch张量放入DataFrame,需要先用.numpy()
方法将其转换回NumPy数组。.to_csv('...', index=False)
: 将DataFrame保存为CSV文件。index=False
是一个非常重要的参数,它告诉Pandas不要将DataFrame自身的行索引(0, 1, 2...)写入到文件中,避免提交格式错误。
最后,关于提高准确率
以上就是我们的整个竞赛流程,但是如果安装标准流程,实际上准确率并不够高,接下来我从几个方向帮助大家后续提高准确率
1. 特征工程
对于像泰坦尼克号这样的表格类数据问题,特征工程往往是提升模型表现最有效的方法。我们的模型能学到的上限,很大程度上取决于我们喂给它的数据质量。
创造更有信息的特征:创建FamilySize
(家庭大小): SibSp
(兄弟姐妹/配偶数) + Parch
(父母/子女数) + 1 (自己) = FamilySize
。家庭大小可能和生还率有关(例如,独自一人 vs. 小家庭 vs. 大家庭)。
2. 模型结构
增加或减少层的深度和宽度:
更宽: 可以尝试增加每层神经元的数量,比如 128 -> 64
。
更深: 可以再增加一个隐藏层,比如 128 -> 64 -> 32
。
注意: 更大更深的网络不一定更好,它会增加过拟合的风险,需要更强的正则化手段来配合。
使用批量归一化:
在全连接层之后、激活函数之前加入nn.BatchNorm1d
层。可以加速模型收敛,稳定训练过程,并在一定程度上起到正则化作用。
修改后的forward
可能像这样: x = self.batchnorm1(F.relu(self.fc1(x)))
。
3. 训练过程
调整超参数 :
学习率 :
0.001
是一个很好的起点,但可以尝试更小(如0.0005
)或更大(如0.005
)的值。优化器 (Optimizer):
Adam
很棒,但也可以试试AdamW
(Adam的改进版,带有权重衰减)或者RMSprop
。训练轮数 (Epochs): 训练更多轮可能会有提升,但要小心过拟合。
使用学习率调度器 :
在训练过程中动态地调整学习率,通常是逐渐降低。比如,使用
torch.optim.lr_scheduler.ReduceLROnPlateau
,当模型性能在几轮内没有提升时自动降低学习率。这在训练后期非常有帮助。
4. 更高级的策略
交叉验证 (Cross-Validation):
之前的做法只是简单地将原始训练集用于训练。更可靠的做法是使用K折交叉验证。
例如,进行5折交叉验证:将训练集分成5份,轮流用其中4份进行训练,剩下1份用于验证。这样我们就训练了5个模型。最终提交时,可以用这5个模型对测试集预测结果的平均值或投票,这通常会比单一模型的结果要稳定和准确得多。
模型集成 (Ensembling):
不要只依赖一个神经网络。可以训练几个不同结构的模型,或者完全不同类型的模型(比如经典的梯度提升树XGBoost, LightGBM,或随机森林),然后将它们的预测结果融合起来。模型集成是竞赛中刷榜常用方式。