一、前馈神经网络简介
前馈神经网络(Feedforward Neural Networks, FNN)是最基础的神经网络类型,其核心思想是信息在网络中单向流动。前馈神经网络包括以下几种主要类型:
- 感知器(Perceptron):最简单的神经网络单元,主要用于二分类任务。
- 多层感知器(MLP):包含一个或多个隐藏层,能够处理复杂的非线性问题。
- 卷积神经网络(CNN):通过卷积层提取特征,特别适合处理图像和序列数据
二、 实验要点
- 通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
- 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响
三、. 实验环境
- Python 3.6.7
四、. 附件目录
- 请将本实验所需数据文件(
surnames.csv
)上传至目录:/data/surnames/
. - 示例完整代码:
- exp4-In-Text-Examples.ipynb
- exp4-munging_surname_dataset.ipynb
- exp4-2D-Perceptron-MLP.ipynb
- exp4_4_Classify_Surnames_CNN.ipynb
- exp4_4_Classify_Surnames_MLP.ipynb
五、实验背景(感知机缺陷)
感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图4-1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。
这是因为单层感知机只能解决线性问题,对于线性不可分问题,它无法解决。
由此,我们引出多层感知机(MLP):在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,正如您稍后将看到的,这只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。
最简单的MLP,如图4-2所示,由三个表示阶段和两个线性层组成。
MLP的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。
XOR实验对比:
虽然在图中显示MLP有两个决策边界,这是它的优点,但它实际上只是一个决策边界!决策边界就是这样出现的,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。
六、多层感知机的代码实现
线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性,它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。
6.1初始化一个简单的MLP
import torch.nn as nn
import torch.nn.functional as F
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
"""
Args:
input_dim (int): the size of the input vectors
hidden_dim (int): the output size of the first Linear layer
output_dim (int): the output size of the second Linear layer
"""
super(MultilayerPerceptron, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim) # 定义第一层线性层,输入维度为 input_dim,输出维度为 hidden_dim
self.fc2 = nn.Linear(hidden_dim, output_dim) # 定义第二层线性层,输入维度为 hidden_dim,输出维度为 output_dim
def forward(self, x_in, apply_softmax=False):
"""The forward pass of the MLP
Args:
x_in (torch.Tensor): an input data tensor.
x_in.shape should be (batch, input_dim)
apply_softmax (bool): a flag for the softmax activation
should be false if used with the Cross Entropy losses
Returns:
the resulting tensor. tensor.shape should be (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 将输入数据传递到第一层线性层,并使用 ReLU 激活函数
output = self.fc2(intermediate)# 将中间结果传递到第二层线性层,得到最终输出
if apply_softmax:
output = F.softmax(output, dim=1)# 如果需要,应用 softmax 激活函数,将输出转换为概率分布
return output
为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。
batch_size = 2 # number of samples input at once
input_dim = 3
hidden_dim = 100
output_dim = 4
# Initialize model
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)
输出为:
MultilayerPerceptron( (fc1): Linear(in_features=3, out_features=100, bias=True) (fc2): Linear(in_features=100, out_features=4, bias=True) )
6.2基于多层感知机处理姓氏分类
6.2.1数据处理
import pandas as pd
import string
import torch
# 加载数据
data = pd.read_csv('data/surnames.csv')
print(data.head())
#数据转换
# 创建字母到索引的映射
all_letters = string.ascii_letters + " .,;'"
n_letters = len(all_letters)
def letter_to_index(letter):
return all_letters.find(letter)
def line_to_tensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for li, letter in enumerate(line):
tensor[li][0][letter_to_index(letter)] = 1
return tensor
#数据拆分与加载
from sklearn.model_selection import train_test_split
from torch.utils.data import Dataset, DataLoader
class SurnameDataset(Dataset):
def __init__(self, surnames, labels, transform=None):
self.surnames = surnames
self.labels = labels
self.transform = transform
def __len__(self):
return len(self.surnames)
def __getitem__(self, idx):
surname = self.surnames[idx]
label = self.labels[idx]
if self.transform:
surname = self.transform(surname)
return surname, label
# 拆分数据集
train_data, test_data = train_test_split(data, test_size=0.2, random_state=42)
train_surnames = train_data['Surname'].values
train_labels = train_data['Nationality'].values
test_surnames = test_data['Surname'].values
test_labels = test_data['Nationality'].values
# 创建数据加载器
train_dataset = SurnameDataset(train_surnames, train_labels, transform=line_to_tensor)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_dataset = SurnameDataset(test_surnames, test_labels, transform=line_to_tensor)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
6.2.2定义多层感知机模型
import torch.nn as nn
import torch.nn.functional as F
class MLP(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(MLP, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
def forward(self, x):
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
# 定义模型参数
input_size = n_letters
hidden_size = 128
output_size = len(set(train_labels)) # 不同国家的数量
# 实例化模型
model = MLP(input_size, hidden_size, output_size)
#构建损失函数
import torch.optim as optim
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
6.2.3训练模型
# 对于每个epoch
for epoch in range(EPOCHS):
# 将模型设置为训练模式
model.train()
# 对于训练数据加载器中的每个批次
for batch_idx, (data, target) in enumerate(train_loader):
# 将数据和目标标签移动到设备上
data, target = data.to(DEVICE), target.to(DEVICE)
# 梯度清零
optimizer.zero_grad()
# 将数据传递给模型,获取模型输出
output = model(data)
# 计算损失
loss = criterion(output, target)
# 反向传播
loss.backward()
# 更新参数
optimizer.step()
# 每隔100个批次打印一次训练状态
if batch_idx % 100 == 0:
# 打印训练状态信息
print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} ({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')
6.2.4模型测试
# 将模型设置为评估模式,这会关闭dropout和batch normalization
model.eval()
# 初始化测试损失和正确预测计数
test_loss = 0
correct = 0
# 在进行推理时,不需要计算梯度
with torch.no_grad():
# 对于测试数据加载器中的每个批次
for data, target in test_loader:
# 将数据和目标标签移动到设备上
data, target = data.to(DEVICE), target.to(DEVICE)
# 将数据传递给模型,获取模型输出
output = model(data)
# 计算损失并累加到测试损失中
test_loss += criterion(output, target).item()
# 找到每个样本预测的类别
pred = output.argmax(dim=1, keepdim=True)
# 统计正确预测的数量
correct += pred.eq(target.view_as(pred)).sum().item()
# 计算平均测试损失
test_loss /= len(test_loader.dataset)
# 打印测试结果
print(f'\nTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({100. * correct / len(test_loader.dataset):.0f}%)\n')
6.2.5实验结果可视化
classifier.load_state_dict(torch.load(train_state['model_filename']))
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 设置数据集为测试集
dataset.set_split('test')
# 初始化批处理生成器
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.
running_acc = 0.
# 设置模型为评估模式
classifier.eval()
# 遍历测试集的每个批次
for batch_index, batch_dict in enumerate(batch_generator):
# 进行预测
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 计算准确率
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# 存储测试集的损失和准确率
train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc
# 打印测试集的损失和准确率
print("测试损失: {};".format(train_state['test_loss']))
print("测试准确率: {}".format(train_state['test_acc']))
运行结果:
Test loss:1.7435305690765381
Test accuracy:47.875
6.2.6模型预测
给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。
def predict_nationality(name, classifier, vectorizer):
# 将姓氏向量化
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 进行预测
result = classifier(vectorized_name, apply_softmax=True)
# 获取概率值和对应的索引
probability_values, indices = result.max(dim=1)
index = indices.item()
# 根据索引查找预测的国籍
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
probability_value = probability_values.item()
return {'nationality': predicted_nationality,
'probability': probability_value}
# 获取用户输入的新姓氏
new_surname = input("请输入一个姓氏进行分类: ")
# 将分类器移动到CPU上
classifier = classifier.to("cpu")
# 进行国籍预测
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果和概率值
print("{} -> {} (概率={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
七、卷积神经网络(CNN)
为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。
7.1数据预处理
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化数据集
Args:
surname_df (pandas.DataFrame): 数据集
vectorizer (SurnameVectorizer): 用于数据向量化的工具
"""
self.surname_df = surname_df
self._vectorizer = vectorizer
self.train_df = self.surname_df[self.surname_df.split == 'train']
self.val_df = self.surname_df[self.surname_df.split == 'val']
self.test_df = self.surname_df[self.surname_df.split == 'test']
self._lookup_dict = {
'train': (self.train_df, len(self.train_df)),
'val': (self.val_df, len(self.val_df)),
'test': (self.test_df, len(self.test_df))
}
self.set_split('train')
# 类别权重
class_counts = surname_df.nationality.value_counts().to_dict()
def sort_key(item):
return self._vectorizer.nationality_vocab.lookup_token(item[0])
sorted_counts = sorted(class_counts.items(), key=sort_key)
frequencies = [count for _, count in sorted_counts]
self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
"""加载数据集并从头创建新的向量化器
Args:
surname_csv (str): 数据集的位置
Returns:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv)
train_surname_df = surname_df[surname_df.split == 'train']
return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))
@classmethod
def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
"""加载数据集和对应的向量化器(向量化器已缓存以便重用)
Args:
surname_csv (str): 数据集的位置
vectorizer_filepath (str): 已保存的向量化器的位置
Returns:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv)
vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
return cls(surname_df, vectorizer)
@staticmethod
def load_vectorizer_only(vectorizer_filepath):
"""从文件中加载向量化器
Args:
vectorizer_filepath (str): 序列化向量化器的位置
Returns:
SurnameVectorizer 的一个实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
"""将向量化器保存到磁盘
Args:
vectorizer_filepath (str): 保存向量化器的位置
"""
with open(vectorizer_filepath, "w") as fp:
json.dump(self._vectorizer.to_serializable(), fp)
def get_vectorizer(self):
"""返回向量化器"""
return self._vectorizer
def set_split(self, split="train"):
"""选择数据集的分割部分"""
self._target_split = split
self._target_df, self._target_size = self._lookup_dict[split]
def __len__(self):
return self._target_size
def __getitem__(self, index):
"""返回指定索引的数据点
Args:
index (int): 数据点的索引
Returns:
包含特征 (x_surname) 和标签 (y_nationality) 的字典
"""
row = self._target_df.iloc[index]
surname_vector = self._vectorizer.vectorize(row.surname)
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
return {'x_surname': surname_vector, 'y_nationality': nationality_index}
def get_num_batches(self, batch_size):
"""根据批次大小返回数据集中的批次数量
Args:
batch_size (int): 批次大小
Returns:
数据集中的批次数量
"""
return len(self) // batch_size
def generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"):
"""
包装 PyTorch DataLoader 的生成器函数,确保每个张量在正确的设备位置上
Args:
dataset (Dataset): PyTorch 数据集
batch_size (int): 批次大小
shuffle (bool): 是否随机打乱数据
drop_last (bool): 是否丢弃最后一个不完整的批次
device (str): 设备(如 "cpu" 或 "cuda")
Yields:
每个批次的数据字典
"""
dataloader = DataLoader(dataset=dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last)
for data_dict in dataloader:
out_data_dict = {}
for name, tensor in data_dict.items():
out_data_dict[name] = tensor.to(device)
yield out_data_dict
7.2创建神经网络模型
class SurnameClassifier(nn.Module):
def __init__(self, initial_num_channels, num_classes, num_channels):
"""
Args:
initial_num_channels (int): 输入特征向量的大小
num_classes (int): 输出预测向量的大小
num_channels (int): 网络中使用的常数通道大小
"""
super(SurnameClassifier, self).__init__()
# 定义卷积神经网络的结构
self.convnet = nn.Sequential(
nn.Conv1d(in_channels=initial_num_channels,
out_channels=num_channels, kernel_size=3),
nn.ELU(), # 使用ELU作为激活函数
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3),
nn.ELU()
)
# 全连接层
self.fc = nn.Linear(num_channels, num_classes)
def forward(self, x_surname, apply_softmax=False):
"""模型的前向传播
Args:
x_surname (torch.Tensor): 输入数据张量,x_surname.shape应为(batch, initial_num_channels, max_surname_length)
apply_softmax (bool): softmax激活的标志,如果用于交叉熵损失,则应为false
Returns:
结果张量,tensor.shape应为(batch, num_classes)
"""
# 卷积层计算特征张量
features = self.convnet(x_surname).squeeze(dim=2)
# 全连接层得到预测向量
prediction_vector = self.fc(features)
# 如果需要应用softmax,则在返回前进行softmax激活
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
7.3模型训练与评估
# 设置参数
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.csv",
vectorizer_file="vectorizer.json",
model_state_file="model.pth",
save_dir="model_storage/ch4/cnn",
# 模型超参数
hidden_dim=100,
num_channels=256,
# 训练超参数
seed=1337,
learning_rate=0.001,
batch_size=128,
num_epochs=100,
early_stopping_criteria=5,
dropout_p=0.1,
# 运行时选项
cuda=False,
reload_from_files=False,
expand_filepaths_to_save_dir=True,
catch_keyboard_interrupt=True
)
八、实验总结
通过本文的实验,我们对感知器、多层感知器(MLP)和卷积神经网络(CNN)在姓氏分类中的表现进行了详细的探讨和比较。
-
感知器模型:
- 感知器模型仅适用于线性可分的数据。在我们的实验中,感知器模型能够快速收敛,但其分类能力有限。
- 通过简单的代码实现,我们可以理解感知器的基本工作原理,但对于复杂的任务,它显然是不够的。
-
多层感知器(MLP):
- MLP引入了隐藏层,可以处理非线性可分的问题。在实验中,MLP显著提升了分类准确率。
- 使用Keras库构建MLP模型非常方便,代码简洁且易于扩展。我们发现MLP在处理文本数据时有较好的表现,但仍需要适当的特征提取和预处理。
-
卷积神经网络(CNN):
- CNN擅长提取局部特征,特别适合处理序列数据和图像数据。在我们的实验中,CNN在姓氏分类任务中表现出色,进一步提升了分类准确率。
- CNN通过卷积层和池化层提取特征,避免了手工特征提取的复杂过程,使模型具有更强的泛化能力。
-
实验结果分析:
- 实验结果显示,感知器模型的准确率较低,多层感知器(MLP)和卷积神经网络(CNN)的准确率显著提高,CNN表现最佳。
- 这一结果表明,随着神经网络结构的复杂化和深度学习技术的发展,我们可以更好地处理复杂的分类任务。