Сегодня ясный и практический взгляд на теорию GCN. Мы рассмотрим реализацию PyTorch 🔦 GCN 👩🎓 от Kipf. Затем мы применим то, что узнали, к ненавистному набору данных Twitter 🔥
Мои предыдущие посты о графах и машинном обучении:
- Графовые нейронные сети: путь обучения с 2008 года — часть 1
- Графовые нейронные сети: путь обучения с 2008 года — Часть 2
- Graph Neural Networks: путь обучения с 2008 года — Deep Walk
- Graph Neural Networks: путь обучения с 2008 года — Python & Deep Walk
- Graph Neural Networks: путь обучения с 2008 года — Graph Convolutional Network
Поддержите меня, когда я присоединюсь к Medium по моей реферальной ссылке:
Добро пожаловать в мою серию статей о графовых нейронных сетях! В предыдущем посте мы исследовали теорию и математические основы графовых сверточных нейронных сетей. Сегодня мы рассмотрим реализация GCN на PyTorch от Kipf, попытаемся упростить реализацию и применить GCN к набору данных ненависть/обычные пользователи Twitter. Наслаждаться :)
Перевести теорию GCN на PyTorch
Наследование PyTorch nn.Module
В PyTorch обычной практикой является создание собственного модуля для создания модели с использованием torch.nn.Module
Это создает объект Python, который позволяет создавать сложные модули. Пользовательские модули — это классы, являющиеся дочерними элементами пакета torch.nn.Module
, унаследовавшие все методы и атрибуты:
import torch.nn as nn | |
class MyNetwork(nn.Module): | |
r""" Define a childern class of nn.Module, implementing my network""" | |
def __init__(self, in_size, output_size): | |
r""" Constructore, define input size and output size of the network""" | |
super(MyNetwork, self).__init__() | |
# define whatever layer you want to use | |
self.linear = nn.Linear(in_size, output_size) | |
def forward(self, x): | |
r""" You have to define the forward step""" | |
out = self.linear(x) | |
return out |
class MyNetwork(nn.Module)
определяет дочерний класс, который затем, аналогично nn.Module
, хочет, чтобы конструктор был определен с размером входной и выходной сети ( in_size
и output_size
соответственно). В конструкторе вызывается суперконструктор super()
. Это позволяет создать объект из пакета torch.nn.Module
внутри объекта MyNetwork
без его явной инициализации. Затем можно настроить сетевые слои и шаг forward
, который, опять же, унаследован от nn.Module
.
Применение nn.Module к базовой операции GCN:layers.py
По этой же схеме Kipf реализует логику GCN в layers.py
: https://github.com/tkipf/pygcn/blob/master/pygcn/layers.py. который представляет собой произведение матрицы графа смежности на входной массив признаков графа и веса i-го слоя:
import math | |
import torch | |
from torch.nn.parameter import Parameter | |
from torch.nn.modules.module import Module | |
class GraphConvolution(Module): | |
""" | |
Class to implement Graph Convlution NN | |
""" | |
def __init__(self, in_features, out_features, bias=True): | |
r""" Define the constructor. Here we need to specify the number | |
of input and output features | |
Parameters | |
---------- | |
in_features: int, number of nodes for a graph | |
out_features: int, number of targets | |
bias: bool, whether we want to have the bias term or not | |
""" | |
# define the super constructor | |
super(GraphConvolution, self).__init__() | |
self.in_features = in_features | |
self.out_features = out_features | |
# parameterise weights | |
self.weight = Parameter(torch.FloatTensor(in_features, out_features)) | |
if bias: | |
self.bias = Parameter(torch.FloatTensor(out_features)) | |
else: | |
self.register_parameter('bias', None) | |
self.reset_parameters() | |
def reset_parameters(self): | |
r""" Reset of parameters. Parameterized weights with auniform distribution""" | |
stdv = 1. / math.sqrt(self.weight.size(1)) | |
self.weight.data.uniform_(-stdv, stdv) | |
if self.bias is not None: | |
self.bias.data.uniform_(-stdv, stdv) | |
def forward(self, input, adj): | |
r""" Define the forward step. Here we perform a matrix multiplication | |
between input graph and weigths. | |
The output is computed as a sparse matrix multiplication between the adjacency | |
matrix and the support result. | |
Parameters | |
---------- | |
input: matrix/tensor input graph matrix | |
adj: self connected adjacency matrix from graph | |
""" | |
support = torch.mm(input, self.weight) | |
output = torch.spmm(adj, support) | |
if self.bias is not None: | |
return output + self.bias | |
else: | |
return output |
Параметры веса и смещения определяются как torch.nn.parameter
объектов и генерируются путем случайной выборки из равномерного распределения. Шаг forward
обновляет веса слоев, вычисляя уравнение 10 из предыдущей статьи.
Объединение слоев: модель GCN в models.py
Теперь, когда у нас реализован базовый строительный блок, мы можем приступить к реализации модели GCN. Согласно статье Кипфа, GCN состоит из 2 слоев, одного входного слоя и одного скрытого слоя, которые объединяются посредством активации ReLu. Кроме того, у нас может быть отсеваемый слой, в котором часть входных тренировочных данных отбрасывается, для дальнейшего прогнозирования слоев прочности. Конечный результат задается как логарифм softmax, экв. 1
import torch.nn as nn | |
import torch.nn.functional as F | |
# import the basic layer operation | |
from pygcn.layers import GraphConvolution | |
class GCN(nn.Module): | |
r""" Define the main GCN model""" | |
def __init__(self, nfeat, nhid, nclass, dropout): | |
r""" The model take the input feature dimension, the hidden dimension size | |
the number of classes and possible dropout probability | |
Parameter | |
--------- | |
nfeat: int, number of input features | |
nhid: int, number of features for the hidden layer | |
nclass: int, number of target classes | |
dropout: float, dropout percentage | |
""" | |
super(GCN, self).__init__() | |
self.gc1 = GraphConvolution(nfeat, nhid) | |
self.gc2 = GraphConvolution(nhid, nclass) | |
self.dropout = dropout | |
def forward(self, x, adj): | |
r""" Define the forward step | |
Parameters | |
----------- | |
x: array/tensor input features | |
adj: array/tensor self term adjacency matrix | |
""" | |
x = F.relu(self.gc1(x, adj)) | |
x = F.dropout(x, self.dropout, training=self.training) | |
x = self.gc2(x, adj) | |
return F.log_softmax(x, dim=1) |
Подготовка входных данных
После настройки базовой модели мы можем взглянуть на входные данные. Kipf предложил данные из датасета Cora (https://paperswithcode.com/dataset/cora;
CC0: Public Domain)[1,4]. Набор данных Cora состоит из документов по машинному обучению, которые классифицируются по 7 классам: case_based
, genetic_algorithms
, neural_networks
, probabilistic_methods
, reinforcement_learning
, rule_learning
, theory
. Общее количество бумаг равно 2708, что и будет количеством узлов. С удалением корней и стоп-слов окончательный набор данных будет содержать только 1433 уникальных слова, что и будет количеством признаков. Таким образом, граф может быть представлен матрицей 2708 x 1433, где единицы и нули зависят от наличия определенного слова. Из бумажного файла цитат можно получить список ребер и создать из него матрицу смежности 2708 x 2708. Данные создаются через служебный скрипт utils.py
. Как только эти элементы созданы, можно переходить к этапу обучения.
Обучение модели GCN с помощью набора данных Cora
Рис.4 суммирует все этапы GCN. Данные загружаются для создания матрицы признаков X
и матрицы смежности adj
. Эти элементы могут быть приняты моделью GCN, которая преобразует данные посредством умножения Лапласа, возвращая вероятность 7 классов для каждого узла.
На рис. 5 показан фрагмент кода Кипфа для обучения модели GCN. Основной бит — это функция поезда. Здесь изначально градиент устанавливается равным нулю через optimiser.zero_grad()
. Затем выходные данные модели вычисляются как output = model(features, adj)
. Наконец, выполняется проверка производительности и потери модели с вычислением отрицательного логарифмического правдоподобия, которое обновляет веса сети, и точности, которые оценивают качество модели по сравнению с набором данных для оценки.
import torch | |
import torch.nn.functional as F | |
import torch.optim as optim | |
from pygcn.utils import load_data, accuracy | |
from pygcn.models import | |
# Load data | |
adj, features, labels, idx_train, idx_val, idx_test = load_data() | |
# Model and optimizer - we have args to parsse | |
model = GCN(nfeat=features.shape[1], | |
nhid=args.hidden, | |
nclass=labels.max().item() + 1, | |
dropout=args.dropout) | |
# call an optimizer | |
optimizer = optim.Adam(model.parameters(), | |
lr=args.lr, weight_decay=args.weight_decay) | |
# define the training function | |
def train(epoch): | |
r""" Define a train function for the GCN model | |
Parameters | |
---------- | |
epoch: int, the number of epochs | |
""" | |
# model inherits train from nn.Module | |
model.train() | |
# set up the gradient | |
optimizer.zero_grad() | |
# output from the model | |
output = model(features, adj) | |
# compute the neg. likelihood loos | |
loss_train = F.nll_loss(output[idx_train], labels[idx_train]) | |
# compute the accuracy | |
acc_train = accuracy(output[idx_train], labels[idx_train]) | |
# spread the loss across the network | |
loss_train.backward() | |
# update the weights | |
optimizer.step() | |
if not args.fastmode: | |
# Evaluate validation set performance separately, | |
# deactivates dropout during validation run. | |
model.eval() | |
output = model(features, adj) | |
# evaluation step | |
loss_val = F.nll_loss(output[idx_val], labels[idx_val]) | |
acc_val = accuracy(output[idx_val], labels[idx_val]) | |
print('Epoch: {:04d}'.format(epoch+1), | |
'loss_train: {:.4f}'.format(loss_train.item()), | |
'acc_train: {:.4f}'.format(acc_train.item()), | |
'loss_val: {:.4f}'.format(loss_val.item()), | |
'acc_val: {:.4f}'.format(acc_val.item()), | |
'time: {:.4f}s'.format(time.time() - t)) | |
# Train model | |
t_total = time.time() | |
for epoch in range(args.epochs): | |
train(epoch) | |
print("Optimization Finished!") | |
print("Total time elapsed: {:.4f}s".format(time.time() - t_total)) |
Играйте с GCN и предсказывайте ненавистных пользователей в Twitter
Пришло время поиграть с причудливым набором данных и GCN. Набор данных — ненавистные пользователи Твиттера[2] (CC0: Public Domain License) с описаниями в его статье [2]. Набор данных имеет 100 000 пользователей. 5000 пользователей помечены как разжигающие ненависть, а именно, они размещают посты с ненавистью в Твиттере. У каждого пользователя есть набор определенных и разработанных вручную функций, users_neightborhood_anon.csv
, где твиты были закодированы с помощью вложений GloVe [3]. Цель состоит в том, чтобы использовать сетевую информацию, чтобы предсказать, какие пользователи также могут быть ненавистными. Пример свободно вдохновлен этим руководством: https://stellargraph.readthedocs.io/en/v1.0.0rc1/demos/interpretability/gcn/hateful-twitters-interpretability.html
Загрузка данных и предварительная обработка
Во-первых, нам нужно загрузить данные и разархивировать файлы. В этом примере мы собираемся использовать API Kaggle через блокнот Google Colab:
!pip install kaggle --upgrade # download from your kaggle account the token file kaggle.json !mkdir /root/.kaggle !cp kaggle.json /root/.kaggle/. !mkdir dataset !kaggle datasets download -d manoelribeiro/hateful-users-on-twitter -p dataset/ # unzip the files !unzip dataset/*.zip
Для этого примера нужны файлы users_neighborhood_anon.csv
и список ребер из users.edges
.
Функции узлов создаются вручную, однако преимущество графических нейронных сетей заключается в том, что они могут избавиться от этих функций, поскольку они могут извлекать сетевую информацию, которая имеет решающее значение для правильной классификации. По этой причине мы собираемся очистить входной набор данных и уменьшить размер признаков с 1039 до 206:
import networkx as nx | |
import pandas as pd | |
import numpy as np | |
import seaborn as sns | |
import itertools | |
import os | |
from sklearn.decomposition import PCA | |
from sklearn.manifold import TSNE | |
from sklearn.linear_model import LogisticRegressionCV | |
from sklearn import preprocessing, feature_extraction | |
from sklearn.model_selection import train_test_split | |
from sklearn import metrics | |
import matplotlib.pyplot as plt | |
import seaborn as sns | |
from scipy.sparse import csr_matrix, lil_matrix | |
%matplotlib inline | |
def data_cleaning(feat): | |
r""" Clean the input data, removing the manually engineered features | |
We are going to use the network information only. | |
Parameters | |
---------- | |
feat: pd.DataFrame, input dataframe with feature per node [100'000 x 1039] | |
Return | |
------ | |
feat: pd.DataFrame, clean features [100'000 x 206] | |
""" | |
feat = feat.drop(columns=["hate_neigh", "normal_neigh"]) | |
# Convert target values in hate column from strings to integers (0,1,2) | |
feat["hate"] = np.where( | |
feat["hate"] == "hateful", 1, np.where(feat["hate"] == "normal", 0, 2) | |
) | |
# missing information | |
number_of_missing = feat.isnull().sum() | |
number_of_missing[number_of_missing != 0] | |
# Replace NA with 0 | |
feat.fillna(0, inplace=True) | |
# dropping info about suspension and deletion as it is should not be use din the predictive model | |
feat.drop( | |
feat.columns[feat.columns.str.contains("is_|_glove|c_|sentiment")], | |
axis=1, | |
inplace=True, | |
) | |
# drop hashtag feature | |
feat.drop(["hashtags"], axis=1, inplace=True) | |
# drop centrality based measures | |
feat.drop( | |
columns=["betweenness", "eigenvector", "in_degree", "out_degree"], inplace=True | |
) | |
feat.drop(columns=["created_at"], inplace=True) | |
return feat | |
# features | |
users_feat = pd.read_csv("users_neighborhood_anon.csv") | |
# clear the data and reduce feats to 206 | |
features = data_cleaning(users_feat) | |
# normalize feature values | |
df_values = features.iloc[:, 2:].values | |
pt = preprocessing.PowerTransformer(method="yeo-johnson", standardize=True) | |
df_values_log = pt.fit_transform(df_values) | |
features.iloc[:, 2:] = df_values_log | |
features.index = features.index.map(str) | |
features.drop(columns=['user_id'], inplace=True) |
Отсюда мы можем начать подготовку набора данных для прогнозирования с помощью GCN. Во-первых, мы выбираем всех тех пользователей, которые были помечены как normal
и hate
(всего 4971). Затем можно прочитать ребра и выбрать подграф с индексами узлов, которые были отфильтрованы, и, наконец, мы можем создать список смежности.
import scipy.sparse as sp | |
# select all the users which have been labelled !=2 | |
annotated_users = features[features['hate']!=2] | |
# these are the nodes features | |
annotated_users_features = annotated_users.drop(columns=['hate']) | |
# these are the targets | |
annotated_users_targets = torch.LongTensor(annotated_users['hate'].to_list()) | |
# now read the edges | |
edges_nx = nx.read_edgelist("users.edges") | |
# and select the subgraph we need for all the users!=2 | |
idxs = annotated_users.index.tolist() | |
edges_needed = edges_nx.subgraph(idxs) | |
# create a sparse adjacency matrix and add the self term | |
adj = sp.coo_matrix(nx.adjacency_matrix(edges_needed)) | |
adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj) |
Матрица смежности — это разреженный тип, созданный с помощью scipy.sparse.coo_martrix
, как мы видели в коде Кипфа.
Последним шагом является подготовка матрицы смежности и функций для загрузки GCN. В этом случае мы собираемся нормализовать значения признаков, так как свойства смежности уже нормализованы, и мы собираемся определить некоторые выборочные показатели для обучения (0–1000), оценки (1000–1200) и тестирования ( 1200–1400).
def normalize(mx): | |
"""Row-normalize sparse matrix""" | |
rowsum = np.array(mx.sum(1)) | |
r_inv = np.power(rowsum, -1).flatten() | |
r_inv[np.isinf(r_inv)] = 0. | |
r_mat_inv = sp.diags(r_inv) | |
mx = r_mat_inv.dot(mx) | |
return mx | |
def sparse_mx_to_torch_sparse_tensor(sparse_mx): | |
"""Convert a scipy sparse matrix to a torch sparse tensor.""" | |
sparse_mx = sparse_mx.tocoo().astype(np.float32) | |
indices = torch.from_numpy( | |
np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64)) | |
values = torch.from_numpy(sparse_mx.data) | |
shape = torch.Size(sparse_mx.shape) | |
return torch.sparse.FloatTensor(indices, values, shape) | |
annotated_users_features = normalize(annotated_users_features) | |
final_features = torch.FloatTensor(np.array(annotated_users_features)) | |
adj = sparse_mx_to_torch_sparse_tensor(adj) | |
idx_train = np.arange(0,1000) | |
idx_val = np.arange(1000, 1200) | |
idx_test = np.arange(1200, 1400) |
Тренируйте GCN на ненавистном наборе данных Twitter
Последний шаг точно следует коду Кипфа. Мы собираемся назвать модель GCN и оптимизатор Адама. Оттуда мы запускаем программу обучения для заданного количества эпох и будем измерять точность.
import time | |
import numpy as np | |
import torch | |
import torch.nn.functional as F | |
import torch.optim as optim | |
def accuracy(output, labels): | |
preds = output.max(1)[1].type_as(labels) | |
correct = preds.eq(labels).double() | |
correct = correct.sum() | |
return correct / len(labels) | |
def train(model,\ | |
optimizer,\ | |
epoch,\ | |
features,\ | |
adj,\ | |
labels, | |
idx_train,\ | |
idx_val): | |
model.train() | |
optimizer.zero_grad() | |
output = model(features, adj) | |
loss_train = F.nll_loss(output[idx_train], labels[idx_train]) | |
acc_train = accuracy(output[idx_train], labels[idx_train]) | |
loss_train.backward() | |
optimizer.step() | |
loss_val = F.nll_loss(output[idx_val], labels[idx_val]) | |
acc_val = accuracy(output[idx_val], labels[idx_val]) | |
print('Epoch: {:04d}'.format(epoch+1), | |
'loss_train: {:.4f}'.format(loss_train.item()), | |
'acc_train: {:.4f}'.format(acc_train.item()), | |
'loss_val: {:.4f}'.format(loss_val.item()), | |
'acc_val: {:.4f}'.format(acc_val.item())) | |
model = GCN(nfeat = final_features.shape[1],\ | |
nhid = 16,\ | |
nclass = 2,\ | |
dropout = 0.0) | |
optimizer = optim.Adam(model.parameters(),\ | |
lr= 0.01,\ | |
weight_decay=5e-4) | |
for epoch in range(50): | |
train(model,\ | |
optimizer,\ | |
epoch,\ | |
final_features,\ | |
adj,\ | |
annotated_users_targets,\ | |
idx_train,\ | |
idx_val) |
В этом случае мы определили размер скрытого слоя равным 16, но не стесняйтесь изменять его, а количество выходных классов равно 2 (normal
или hate
). Мы можем собрать данные о потерях и точности по эпохам и увидеть сходимость после 50 эпох, в то время как точность достигает 0,89 для оценочного набора.
Это всего лишь небольшой пример красоты GCN. Вы можете взять это домашнее задание для дальнейшей практики и изучения возможностей GCN:
- изменить размер входного набора данных
- удалить дополнительные функции из входного набора данных
- извлеките информацию из второго слоя и постройте график с помощью алгоритма декомпозиции, чтобы увидеть, как сеть разделяет сеть во время обучения.
На сегодня это все :) Оставайтесь с нами для следующего графического приключения!
Пожалуйста, не стесняйтесь присылать мне электронное письмо с вопросами или комментариями по адресу: stefanobosisio1@gmail.com или прямо здесь, в Medium.
Библиография
- Сен, Притхвирадж и др. «Коллективная классификация сетевых данных». Журнал AI 29.3 (2008): 93–93.
- Рибейро, Маноэль Орта и др. «Характеризация и обнаружение ненавистных пользователей в Твиттере». Двенадцатая международная конференция AAAI в Интернете и социальных сетях. 2018.
- Пеннингтон, Джеффри, Ричард Сочер и Кристофер Д. Мэннинг. «Перчатка: глобальные векторы для представления слов». Материалы конференции 2014 года по эмпирическим методам обработки естественного языка (EMNLP). 2014.
- МакКаллум, Эндрю Качитес и др. «Автоматизация создания интернет-порталов с помощью машинного обучения». Информационный поиск 3.2 (2000): 127–163.