Task04:数据完整存储与内存的数据集类+节点预测与边预测任务实践
本文参考datawhale开源学习资料
一、数据完整存储与内存的数据集类
对于占用内存有限的数据集,我们可以将整个数据集的数据都存储到内存里。PyG为我们提供了方便的构造数据完全存于内存的数据集类,简称为InMemory
数据集类,在此小节我们就将学习构造InMemory
数据集类的方式。此外,大数据集一般不会直接加载到内存中,这时候构建数据集的时候需要继承父类Dataset
,本文不会讨论到,详细可参考PyG官方tutorial和这篇文章。
1. InMemoryDataset
基类简介
在PyG中,我们通过继承InMemoryDataset
类来自定义一个数据可全部存储到内存的数据集类。
class InMemoryDataset(root: Optional[str] = None, transform: Optional[Callable] = None, pre_transform: Optional[Callable] = None, pre_filter: Optional[Callable] = None)
InMemoryDataset
类初始化方法参数说明:
root
:字符串类型,存储数据集的文件夹的路径。该文件夹下有两个文件夹:- 一个文件夹为记录在**
raw_dir
,它用于存储未处理的文件,从网络上下载的数据集原始文件**会被存放到这里; - 另一个文件夹记录在**
processed_dir
**,处理后的数据被保存到这里,以后从此文件夹下加载文件即可获得Data
对象。 - 注:
raw_dir
和processed_dir
是属性方法,我们可以自定义要使用的文件夹。
- 一个文件夹为记录在**
transform
:函数类型,一个数据转换函数,它接收一个Data
对象并返回一个转换后的Data
对象。此函数在每一次数据获取过程中都会被执行。获取数据的函数首先使用此函数对Data
对象做转换,然后才返回数据。此函数应该用于数据增广(Data Augmentation)。该参数默认值为None
,表示不对数据做转换。pre_transform
:函数类型,一个数据转换函数,它接收一个Data
对象并返回一个转换后的Data
对象。此函数在Data
对象被保存到文件前调用。因此它应该用于只执行一次的数据预处理。该参数默认值为None
,表示不做数据预处理。pre_filter
:函数类型,一个检查数据是否要保留的函数,它接收一个Data
对象,返回此Data
对象是否应该被包含在最终的数据集中。此函数也在Data
对象被保存到文件前调用。该参数默认值为None
,表示不做数据检查,保留所有的数据。
参数 | 作用 |
---|---|
root | 存储数据的路径,raw_dir 和processed_dir |
transform | 数据增强(每次都会执行) |
pre_transform | 数据预处理(只会执行一次) |
pre_filter | 数据过滤 |
通过继承InMemoryDataset
类来构造一个我们自己的数据集类,我们需要实现四个基本方法:
方法 | 方法类型 | 作用 |
---|---|---|
raw_file_names() | 属性方法 | 返回一个数据集原始文件的文件名列表,应该能在raw_dir 文件夹中找到,否则调用download() 函数下载文件到raw_dir 文件夹 |
processed_file_names() | 属性方法 | 返回一个存储处理过的数据的文件的文件名列表,存储处理过的数据的文件应该能在processed_dir 文件夹中找到,否则调用process() 函数对样本做处理,然后保存处理过的数据到processed_dir 文件夹下的文件里 |
download() | —— | 下载数据集原始文件到raw_dir 文件夹 |
process() | —— | 处理数据,保存处理好的数据到processed_dir 文件夹下的文件 |
2. InMemoryDataset
数据集类实例
PlanetoidPubMed
数据集类如下所示:
import os.path as osp
import torch
from torch_geometric.data import (InMemoryDataset, download_url)
from torch_geometric.io import read_planetoid_data
class PlanetoidPubMed(InMemoryDataset):
r""" 节点代表文章,边代表引文关系。
训练、验证和测试的划分通过二进制掩码给出。
参数:
root (string): 存储数据集的文件夹的路径
transform (callable, optional): 数据转换函数,每一次获取数据时被调用。
pre_transform (callable, optional): 数据转换函数,数据保存到文件前被调用。
"""
url = 'https://github.com/kimiyoung/planetoid/raw/master/data'
def __init__(self, root, transform=None, pre_transform=None):
super(PlanetoidPubMed, self).__init__(root, transform, pre_transform)
self.data, self.slices = torch.load(self.processed_paths[0])
@property
def raw_dir(self):
return osp.join(self.root, 'raw')
@property
def processed_dir(self):
return osp.join(self.root, 'processed')
# 数据集原始文件有哪些
@property
def raw_file_names(self):
names = ['x', 'tx', 'allx', 'y', 'ty', 'ally', 'graph', 'test.index']
return ['ind.pubmed.{}'.format(name) for name in names]
#处理过的数据要保存在哪些文件里
@property
def processed_file_names(self):
return 'data.pt'
# 实现下载数据到`self.raw_dir`文件夹的逻辑
def download(self):
for name in self.raw_file_names:
download_url('{}/{}'.format(self.url, name), self.raw_dir)
# 实现数据处理的逻辑
def process(self):
data = read_planetoid_data(self.raw_dir, 'pubmed')
data = data if self.pre_transform is None else self.pre_transform(data)
torch.save(self.collate([data]), self.processed_paths[0])
def __repr__(self):
return '{}()'.format(self.name)
该类初始化方法的参数说明见代码。代码中还实现了raw_dir()
和processed_dir()
两个属性方法,通过修改返回值我们就可以修改要使用的文件夹。
该数据集类的使用
在我们生成一个PlanetoidPubMed
类的对象时,程序运行流程如下:
- 首先,检查数据原始文件是否已下载:
- 检查
self.raw_dir
目录下是否存在raw_file_names()
属性方法返回的每个文件, - 如有文件不存在,则调用
download()
方法执行原始文件下载。 self.raw_dir
为osp.join(self.root, 'raw')
。
- 检查
- 其次,检查数据是否经过处理:
- 首先,检查之前对数据做变换的方法:检查
self.processed_dir
目录下是否存在pre_transform.pt
文件:- 如果存在,意味着之前进行过数据变换,接着需要加载该文件,以获取之前所用的数据变换的方法,并检查它与当前
pre_transform
参数指定的方法是否相同,- 如果不相同则会报出一个警告,“The pre_transform argument differs from the one used in ……”。
self.processed_dir
为osp.join(self.root, 'processed')
。
- 如果存在,意味着之前进行过数据变换,接着需要加载该文件,以获取之前所用的数据变换的方法,并检查它与当前
- 其次,检查之前的样本过滤的方法:检查
self.processed_dir
目录下是否存在pre_filter.pt
文件:- 如果存在,则加载该文件并获取之前所用的样本过滤的方法,并检查它与当前
pre_filter
参数指定的方法是否相同,- 如果不相同则会报出一个警告,“The pre_filter argument differs from the one used in ……”。
- 如果存在,则加载该文件并获取之前所用的样本过滤的方法,并检查它与当前
- 接着,检查是否存在处理好的数据:检查
self.processed_dir
目录下是否存在self.processed_file_names
属性方法返回的所有文件,如有文件不存在,则需要执行以下的操作:- 调用
process()
方法,进行数据处理。- 首先,我们从数据集原始文件中读取样本并生成
Data
对象,所有样本的Data
对象保存在列表data_list
中。 - 其次,如果要对数据做过滤的话,我们执行数据过滤的过程。
- 接着,如果要对数据做处理的话,我们执行数据处理的过程。
- 最后,我们保存处理好的数据到文件。但由于python保存一个巨大的列表是相当慢的,我们需要先将所有
Data
对象合并成一个巨大的Data
对象再保存。collate()
函数接收一个列表的Data
对象,返回合并后的Data
对象以及用于从合并后的Data
对象重构各个原始Data
对象的切片字典slices
。最后我们将这个巨大的Data
对象和切片字典slices
保存到文件。
- 首先,我们从数据集原始文件中读取样本并生成
- 如果
pre_transform
参数不为None
,则调用pre_transform()
函数进行数据处理。 - 如果
pre_filter
参数不为None
,则进行样本过滤(此例子中不需要进行样本过滤,pre_filter
参数为None
)。 - 保存处理好的数据到文件,文件存储在**
processed_paths()
**属性方法返回的文件路径。如果将数据保存到多个文件中,则返回的路径有多个。processed_paths()
属性方法是在基类中定义的,它对self.processed_dir
文件夹与processed_file_names()
属性方法的返回每一个文件名做拼接,然后返回。
- 最后保存新的
pre_transform.pt
文件和pre_filter.pt
文件,它们分别存储当前使用的数据处理方法和样本过滤方法。
- 调用
- 首先,检查之前对数据做变换的方法:检查
最后让我们来查看这个数据集:
dataset = PlanetoidPubMed('dataset/PlanetoidPubMed')
print(dataset.num_classes)
print(dataset[0].num_nodes)
print(dataset[0].num_edges)
print(dataset[0].num_features)
# 3
# 19717
# 88648
# 500
可以看到这个数据集包含三个分类任务,共19,717个结点,88,648条边,节点特征维度为500。
二、节点预测任务实践
完整代码见datawhale开源项目。
1. 图神经网络定义
class GAT(torch.nn.Module):
def __init__(self, num_features, hidden_channels_list, num_classes):
super(GAT, self).__init__()
torch.manual_seed(12345)
hns = [num_features] + hidden_channels_list
conv_list = []
for idx in range(len(hidden_channels_list)):
conv_list.append((GATConv(hns[idx], hns[idx+1]), 'x, edge_index -> x'))
conv_list.append(ReLU(inplace=True),)
self.convseq = Sequential('x, edge_index', conv_list)
self.linear = Linear(hidden_channels_list[-1], num_classes)
def forward(self, x, edge_index):
x = self.convseq(x, edge_index)
x = F.dropout(x, p=0.5, training=self.training)
x = self.linear(x)
return x
我们通过hidden_channels_list
参数来设置每一层GATConv
的outchannel
,所以hidden_channels_list
长度即为GATConv
的层数。通过修改hidden_channels_list
,我们就可构造出不同的图神经网络。
神经网络由多个GATConv
顺序相连而构成,因此我们使用了torch_geometric.nn.Sequential
容器,详细内容可见于官方文档。
2.数据集定义与加载
# 数据集类的构造
class PlanetoidPubMed(InMemoryDataset):
r"""The citation network datasets "PubMed" from the
`"Revisiting Semi-Supervised Learning with Graph Embeddings"
<https://arxiv.org/abs/1603.08861>`_ paper.
Nodes represent documents and edges represent citation links.
Training, validation and test splits are given by binary masks.
Args:
root (string): Root directory where the dataset should be saved.
split (string): The type of dataset split
(:obj:`"public"`, :obj:`"full"`, :obj:`"random"`).
If set to :obj:`"public"`, the split will be the public fixed split
from the
`"Revisiting Semi-Supervised Learning with Graph Embeddings"
<https://arxiv.org/abs/1603.08861>`_ paper.
If set to :obj:`"full"`, all nodes except those in the validation
and test sets will be used for training (as in the
`"FastGCN: Fast Learning with Graph Convolutional Networks via
Importance Sampling" <https://arxiv.org/abs/1801.10247>`_ paper).
If set to :obj:`"random"`, train, validation, and test sets will be
randomly generated, according to :obj:`num_train_per_class`,
:obj:`num_val` and :obj:`num_test`. (default: :obj:`"public"`)
num_train_per_class (int, optional): The number of training samples
per class in case of :obj:`"random"` split. (default: :obj:`20`)
num_val (int, optional): The number of validation samples in case of
:obj:`"random"` split. (default: :obj:`500`)
num_test (int, optional): The number of test samples in case of
:obj:`"random"` split. (default: :obj:`1000`)
transform (callable, optional): A function/transform that takes in an
:obj:`torch_geometric.data.Data` object and returns a transformed
version. The data object will be transformed before every access.
(default: :obj:`None`)
pre_transform (callable, optional): A function/transform that takes in
an :obj:`torch_geometric.data.Data` object and returns a
transformed version. The data object will be transformed before
being saved to disk. (default: :obj:`None`)
"""
url = 'https://github.com/kimiyoung/planetoid/raw/master/data'
def __init__(self, root, split="public", num_train_per_class=20,
num_val=500, num_test=1000, transform=None,
pre_transform=None):
super(PlanetoidPubMed, self).__init__(root, transform, pre_transform)
self.data, self.slices = torch.load(self.processed_paths[0])
self.split = split
assert self.split in ['public', 'full', 'random']
if split == 'full':
data = self.get(0)
data.train_mask.fill_(True)
data.train_mask[data.val_mask | data.test_mask] = False
self.data, self.slices = self.collate([data])
elif split == 'random':
data = self.get(0)
data.train_mask.fill_(False)
for c in range(self.num_classes):
idx = (data.y == c).nonzero(as_tuple=False).view(-1)
idx = idx[torch.randperm(idx.size(0))[:num_train_per_class]]
data.train_mask[idx] = True
remaining = (~data.train_mask).nonzero(as_tuple=False).view(-1)
remaining = remaining[torch.randperm(remaining.size(0))]
data.val_mask.fill_(False)
data.val_mask[remaining[:num_val]] = True
data.test_mask.fill_(False)
data.test_mask[remaining[num_val:num_val + num_test]] = True
self.data, self.slices = self.collate([data])
@property
def raw_dir(self):
return osp.join(self.root, 'raw')
@property
def processed_dir(self):
return osp.join(self.root, 'processed')
@property
def raw_file_names(self):
names = ['x', 'tx', 'allx', 'y', 'ty', 'ally', 'graph', 'test.index']
return ['ind.pubmed.{}'.format(name) for name in names]
@property
def processed_file_names(self):
return 'data.pt'
def download(self):
for name in self.raw_file_names:
download_url('{}/{}'.format(self.url, name), self.raw_dir)
def process(self):
data = read_planetoid_data(self.raw_dir, 'pubmed')
data = data if self.pre_transform is None else self.pre_transform(data)
torch.save(self.collate([data]), self.processed_paths[0])
def __repr__(self):
return '{}()'.format(self.name)
# 数据的加载
dataset = PlanetoidPubMed(root='data/PlanetoidPubMed/', transform=NormalizeFeatures())
print('dataset.num_features:', dataset.num_features)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data = dataset[0].to(device)
# 输出
#dataset.num_features: 500
PlanetoidPubMed数据集包含三个分类任务,共19,717个结点,88,648条边,节点特征维度为500。
3. 训练和评估函数
def train():
model.train()
optimizer.zero_grad() # Clear gradients.
out = model(data.x, data.edge_index) # Perform a single forward pass.
# Compute the loss solely based on the training nodes.
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward() # Derive gradients.
optimizer.step() # Update parameters based on gradients.
return loss
def test():
model.eval()
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1) # Use the class with highest probability.
test_correct = pred[data.test_mask] == data.y[data.test_mask] # Check against ground-truth labels.
test_acc = int(test_correct.sum()) / int(data.test_mask.sum()) # Derive ratio of correct predictions.
return test_acc
4. 模型的加载与训练
model = GAT(num_features=dataset.num_features, hidden_channels_list=[200, 100], num_classes=dataset.num_classes).to(device)
print(model)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()
for epoch in range(1, 201):
loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
输出如下:
GAT(
(convseq): Sequential(
(0): GATConv(500, 200, heads=1)
(1): ReLU(inplace=True)
(2): GATConv(200, 100, heads=1)
(3): ReLU(inplace=True)
)
(linear): Linear(in_features=100, out_features=3, bias=True)
)
# 训练过程省略
Test Accuracy: 0.7560
三、边预测任务实践
完整代码见datawhale开源项目。
1. 获取数据集并进行分析
获取数据集并进行分析:
import os.path as osp
from torch_geometric.utils import negative_sampling
from torch_geometric.datasets import Planetoid
import torch_geometric.transforms as T
from torch_geometric.utils import train_test_split_edges
dataset = Planetoid('dataset', 'Cora', transform=T.NormalizeFeatures())
data = dataset[0]
data.train_mask = data.val_mask = data.test_mask = data.y = None # 不再有用
data = train_test_split_edges(data)
print(data.edge_index.shape)
# torch.Size([2, 10556])
for key in data.keys:
print(key, getattr(data, key).shape)
# x torch.Size([2708, 1433])
# val_pos_edge_index torch.Size([2, 263])
# test_pos_edge_index torch.Size([2, 527])
# train_pos_edge_index torch.Size([2, 8976])
# train_neg_adj_mask torch.Size([2708, 2708])
# val_neg_edge_index torch.Size([2, 263])
# test_neg_edge_index torch.Size([2, 527])
# 263 + 527 + 8976 = 9766 != 10556
# 263 + 527 + 8976/2 = 5278 = 10556/2
我们观察到训练集、验证集和测试集中正样本边的数量之和不等于原始边的数量。这是因为,现在所用的Cora
图是无向图,在统计原始边数量时,每一条边的正向与反向各统计了一次,训练集也包含边的正向与反向,但验证集与测试集都只包含了边的一个方向。
**为什么训练集要包含边的正向与反向,而验证集与测试集都只包含了边的一个方向?**这是因为,训练集用于训练,训练时一条边的两个端点要互传信息,只考虑一个方向的话,只能由一个端点传信息给另一个端点,而验证集与测试集的边用于衡量检验边预测的准确性,只需考虑一个方向的边即可。
2. 边预测图神经网络的构造
构造神经网络:
import torch
from torch_geometric.nn import GCNConv
class Net(torch.nn.Module):
def __init__(self, in_channels, out_channels):
super(Net, self).__init__()
self.conv1 = GCNConv(in_channels, 128)
self.conv2 = GCNConv(128, out_channels)
def encode(self, x, edge_index):
x = self.conv1(x, edge_index)
x = x.relu()
return self.conv2(x, edge_index)
def decode(self, z, pos_edge_index, neg_edge_index):
edge_index = torch.cat([pos_edge_index, neg_edge_index], dim=-1)
return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=-1)
def decode_all(self, z):
prob_adj = z @ z.t()
return (prob_adj > 0).nonzero(as_tuple=False).t()
用于做边预测的神经网络主要由两部分组成:其一是编码(encode),它与我们前面介绍的节点表征生成是一样的;其二是解码(decode),它根据边两端节点的表征生成边为真的几率(odds)。decode_all(self, z)
用于推理(inference)阶段,我们要对所有的节点对预测存在边的几率。
3. 边预测图神经网络的训练
定义单个epoch的训练过程
def get_link_labels(pos_edge_index, neg_edge_index):
num_links = pos_edge_index.size(1) + neg_edge_index.size(1)
link_labels = torch.zeros(num_links, dtype=torch.float)
link_labels[:pos_edge_index.size(1)] = 1.
return link_labels
def train(data, model, optimizer):
model.train()
neg_edge_index = negative_sampling(
edge_index=data.train_pos_edge_index,
num_nodes=data.num_nodes,
num_neg_samples=data.train_pos_edge_index.size(1))
def train(data, model, optimizer):
model.train()
neg_edge_index = negative_sampling(
edge_index=data.train_pos_edge_index,
num_nodes=data.num_nodes,
num_neg_samples=data.train_pos_edge_index.size(1))
# 作业三
# train_neg_edge_set = set(map(tuple, neg_edge_index.T.tolist()))
# val_pos_edge_set = set(map(tuple, data.val_pos_edge_index.T.tolist()))
# test_pos_edge_set = set(map(tuple, data.test_pos_edge_index.T.tolist()))
# if (len(train_neg_edge_set & val_pos_edge_set) > 0) or (len(train_neg_edge_set & test_pos_edge_set) > 0):
# print('wrong!')
optimizer.zero_grad()
z = model.encode(data.x, data.train_pos_edge_index)
link_logits = model.decode(z, data.train_pos_edge_index, neg_edge_index)
link_labels = get_link_labels(data.train_pos_edge_index, neg_edge_index).to(data.x.device)
loss = F.binary_cross_entropy_with_logits(link_logits, link_labels)
loss.backward()
optimizer.step()
return loss
optimizer.zero_grad()
z = model.encode(data.x, data.train_pos_edge_index)
link_logits = model.decode(z, data.train_pos_edge_index, neg_edge_index)
link_labels = get_link_labels(data.train_pos_edge_index, neg_edge_index).to(data.x.device)
loss = F.binary_cross_entropy_with_logits(link_logits, link_labels)
loss.backward()
optimizer.step()
return loss
通常,存在边的节点对的数量往往少于不存在边的节点对的数量。我们在每一个epoch
的训练过程中,都进行一次训练集负样本采样。采样到的样本数量与训练集正样本相同,但不同epoch
中采样到的样本是不同的。这样做,我们既能实现类别数量平衡,又能实现增加训练集负样本的多样性。在负样本采样时,我们传递了train_pos_edge_index
为参数,于是negative_sampling()
函数只会在训练集中不存在边的节点对中采样。get_link_labels()
函数用于生成完整训练集的标签。
注:在训练阶段,我们应该只见训练集,对验证集与测试集都是不可见的。所以我们没有使用所有的边,而是只用了训练集正样本边。
定义单个epoch验证与测试过程
@torch.no_grad()
def test(data, model):
model.eval()
z = model.encode(data.x, data.train_pos_edge_index)
results = []
for prefix in ['val', 'test']:
pos_edge_index = data[f'{prefix}_pos_edge_index']
neg_edge_index = data[f'{prefix}_neg_edge_index']
link_logits = model.decode(z, pos_edge_index, neg_edge_index)
link_probs = link_logits.sigmoid()
link_labels = get_link_labels(pos_edge_index, neg_edge_index)
results.append(roc_auc_score(link_labels.cpu(), link_probs.cpu()))
return results
注:在验证与测试阶段,我们也应该只见训练集,对验证集与测试集都是不可见的。所以在验证与测试阶段,我们依然只用训练集正样本边。
运行完整的训练、验证与测试
def main():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
dataset = 'Cora'
path = osp.join(osp.dirname(osp.realpath(__file__)), '..', 'data', dataset)
dataset = Planetoid(path, dataset, transform=T.NormalizeFeatures())
data = dataset[0]
ground_truth_edge_index = data.edge_index.to(device)
data.train_mask = data.val_mask = data.test_mask = data.y = None
data = train_test_split_edges(data)
data = data.to(device)
model = Net(dataset.num_features, 64).to(device)
optimizer = torch.optim.Adam(params=model.parameters(), lr=0.01)
best_val_auc = test_auc = 0
for epoch in range(1, 101):
loss = train(data, model, optimizer)
val_auc, tmp_test_auc = test(data, model)
if val_auc > best_val_auc:
best_val_auc = val_auc
test_auc = tmp_test_auc
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val: {val_auc:.4f}, '
f'Test: {test_auc:.4f}')
z = model.encode(data.x, data.train_pos_edge_index)
final_edge_index = model.decode_all(z)
if __name__ == "__main__":
main()
运行结果:
Epoch: 001, Loss: 0.6930, Val: 0.6675, Test: 0.6881
Epoch: 002, Loss: 0.6819, Val: 0.6647, Test: 0.6881
Epoch: 003, Loss: 0.7154, Val: 0.6736, Test: 0.6878
Epoch: 004, Loss: 0.6767, Val: 0.6912, Test: 0.7040
Epoch: 005, Loss: 0.6850, Val: 0.7226, Test: 0.7284
Epoch: 006, Loss: 0.6891, Val: 0.7569, Test: 0.7646
Epoch: 007, Loss: 0.6905, Val: 0.7218, Test: 0.7646
Epoch: 008, Loss: 0.6906, Val: 0.6770, Test: 0.7646
Epoch: 009, Loss: 0.6894, Val: 0.6618, Test: 0.7646
Epoch: 010, Loss: 0.6868, Val: 0.6597, Test: 0.7646
………
………
Epoch: 090, Loss: 0.4483, Val: 0.8941, Test: 0.8942
Epoch: 091, Loss: 0.4498, Val: 0.8932, Test: 0.8942
Epoch: 092, Loss: 0.4476, Val: 0.8931, Test: 0.8942
Epoch: 093, Loss: 0.4501, Val: 0.8955, Test: 0.8972
Epoch: 094, Loss: 0.4439, Val: 0.8975, Test: 0.8983
Epoch: 095, Loss: 0.4481, Val: 0.8981, Test: 0.8972
Epoch: 096, Loss: 0.4450, Val: 0.8979, Test: 0.8972
Epoch: 097, Loss: 0.4478, Val: 0.8971, Test: 0.8972
Epoch: 098, Loss: 0.4442, Val: 0.8993, Test: 0.9011
Epoch: 099, Loss: 0.4351, Val: 0.9013, Test: 0.9011
Epoch: 100, Loss: 0.4431, Val: 0.9014, Test: 0.9001
四、作业
-
实践问题一:尝试使用PyG中的不同的网络层去代替
GCNConv
,以及不同的层数和不同的out_channels
,来实现节点分类任务。 -
实践问题二:在边预测任务中,尝试用
torch_geometric.nn.Sequential
容器构造图神经网络。 -
思考问题三:如下方代码所示,我们以
data.train_pos_edge_index
为实际参数来进行训练集负样本采样,但这样采样得到的负样本可能包含一些验证集的正样本与测试集的正样本,即可能将真实的正样本标记为负样本,由此会产生冲突。但我们还是这么做,这是为什么?neg_edge_index = negative_sampling( edge_index=data.train_pos_edge_index, num_nodes=data.num_nodes, num_neg_samples=data.train_pos_edge_index.size(1))
1. 作业一
不同的网络层在上一节已经有过尝试,这里通过改变hidden_channels_list=[256, 128, 64]
来观察模型节点分类效果的改变:
class GAT(torch.nn.Module):
def __init__(self, num_features, hidden_channels_list, num_classes):
super(GAT, self).__init__()
torch.manual_seed(12345)
hns = [num_features] + hidden_channels_list
conv_list = []
for idx in range(len(hidden_channels_list)):
conv_list.append((GATConv(hns[idx], hns[idx+1]), 'x, edge_index -> x'))
conv_list.append(ReLU(inplace=True),)
self.convseq = Sequential('x, edge_index', conv_list)
self.linear = Linear(hidden_channels_list[-1], num_classes)
def forward(self, x, edge_index):
x = self.convseq(x, edge_index)
x = F.dropout(x, p=0.5, training=self.training)
x = self.linear(x)
return x
model = GAT(num_features=dataset.num_features, hidden_channels_list=[200, 100], num_classes=dataset.num_classes).to(device)
print(model)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()
for epoch in range(1, 201):
loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
模型输出(差别不大):
GAT(
(convseq): Sequential(
(0): GATConv(500, 256, heads=1)
(1): ReLU(inplace=True)
(2): GATConv(256, 128, heads=1)
(3): ReLU(inplace=True)
(4): GATConv(128, 64, heads=1)
(5): ReLU(inplace=True)
)
(linear): Linear(in_features=64, out_features=3, bias=True)
)
# 训练过程省略
Test Accuracy: 0.7550
2. 作业二
用torch_geometric.nn.Sequential
容器来改写Net()
:
import torch
from torch_geometric.nn import GCNConv
class Net(torch.nn.Module):
def __init__(self, in_channels, out_channels):
super(Net, self).__init__()
conv_list = []
conv_list.append((GCNConv(in_channelsin_channels, 128), 'x, edge_index -> x'))
conv_list.append(ReLU(inplace=True),)
conv_list.append((GCNConv(128, out_channels), 'x, edge_index -> x'))
self.convseq = Sequential('x, edge_index', conv_list)
def encode(self, x, edge_index):
x = self.convseq(x, edge_index)
return x
def decode(self, z, pos_edge_index, neg_edge_index):
edge_index = torch.cat([pos_edge_index, neg_edge_index], dim=-1)
return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=-1)
def decode_all(self, z):
prob_adj = z @ z.t()
return (prob_adj > 0).nonzero(as_tuple=False).t()
3. 作业三
这一题没有思绪,可以参考作业排名里面有位同学的解释。
- 负采样冲突样本数少,对模型的泛化能力没有很大影响?
- 测试过程中更多的是关注正样本的准确度?