Detection of Twitter users who use hateful lexicon using graph machine learning with Stellargraph

We consider the use-case of identifying hateful users on Twitter motivated by the work in [1] and using the dataset also published in [1]. Classification is based on a graph based on users’ retweets and attributes as related to their account activity, and the content of tweets.

We pose identifying hateful users as a binary classification problem. We demonstrate the advantage of connected vs unconnected data in a semi-supervised setting with few training examples.

For connected data, we use Graph Neural Network methods, GCN [2], GAT [3], and GraphSAGE [4] as implemented in the stellargraph library. We pose the problem of identifying hateful tweeter users as node attribute inference in graphs.

References

  1. “Like Sheep Among Wolves”: Characterizing Hateful Users on Twitter. M. H. Ribeiro, P. H. Calais, Y. A. Santos, V. A. F. Almeida, and W. Meira Jr. arXiv preprint arXiv:1801.00317 (2017).
  2. Semi-Supervised Classification with Graph Convolutional Networks. T. Kipf, M. Welling. ICLR 2017. arXiv:1609.02907
  3. Graph Attention Networks. P. Velickovic et al. ICLR 2018
  4. Inductive Representation Learning on Large Graphs. W.L. Hamilton, R. Ying, and J. Leskovec arXiv:1706.02216 [cs.SI], 2017.

Run the master version of this notebook:

[1]:
# install StellarGraph if running on Google Colab
import sys
if 'google.colab' in sys.modules:
  %pip install -q stellargraph[demos]==1.0.0rc1
[2]:
# verify that we're using the correct version of StellarGraph for this notebook
import stellargraph as sg

try:
    sg.utils.validate_notebook_version("1.0.0rc1")
except AttributeError:
    raise ValueError(
        f"This notebook requires StellarGraph version 1.0.0rc1, but a different version {sg.__version__} is installed.  Please see <https://github.com/stellargraph/stellargraph/issues/1172>."
    ) from None
[3]:
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

import stellargraph as sg
from stellargraph.mapper import GraphSAGENodeGenerator, FullBatchNodeGenerator
from stellargraph.layer import GraphSAGE, GCN, GAT
from stellargraph import globalvar

from tensorflow.keras import layers, optimizers, losses, metrics, Model, models
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

%matplotlib inline

Loading the data

Downloading the dataset:

The dataset for this demo was published in [1] and it is freely available to download from Kaggle here.

The following is the description of the datasets:

This dataset contains a network of 100k users, out of which ~5k were annotated as hateful or not. For each user, several content-related, network-related and activity related features were provided.

Additional files of hateful lexicon can be found here

Download the dataset and then set the data_dir variable to point to the download location.

[4]:
data_dir = os.path.expanduser("~/data/hateful-twitter-users")

First load and prepare the node features

Each node in the graph is associated with a large number of features (also referred to as attributes).

The list of features is given here. We repeated here for convenience.

hate :(“hateful”|”normal”|”other”) if user was annotated as hateful, normal, or not annotated.

(is_50|is_50_2) :bool whether user was deleted up to 12/12/17 or 14/01/18.

(is_63|is_63_2) :bool whether user was suspended up to 12/12/17 or 14/01/18.

(hate|normal)_neigh :bool is the user on the neighborhood of a (hateful|normal) user?

[c_] (statuses|follower|followees|favorites)_count :int number of (tweets|follower|followees|favorites) a user has.

[c_] listed_count:int number of lists a user is in.

[c_] (betweenness|eigenvector|in_degree|outdegree) :float centrality measurements for each user in the retweet graph.

[c_] *_empath :float occurrences of empath categories in the users latest 200 tweets.

[c_] *_glove :float
glove vector calculated for users latest 200 tweets.

[c_] (sentiment|subjectivity) :float average sentiment and subjectivity of users tweets.

[c_] (time_diff|time_diff_median) :float average and median time difference between tweets.

[c_] (tweet|retweet|quote) number :float percentage of direct tweets, retweets and quotes of an user.

[c_] (number urls|number hashtags|baddies|mentions) :float number of bad words|mentions|urls|hashtags per tweet in average.

[c_] status length :float average status length.

hashtags :string all hashtags employed by the user separated by spaces.

Notice that c_ are attributes calculated for the 1-neighborhood of a user in the retweet network (averaged out).

First, we are going to load the user features and prepare them for machine learning.

[5]:
users_feat = pd.read_csv(os.path.join(data_dir, "users_neighborhood_anon.csv"))
users_feat.head()
[5]:
user_id hate hate_neigh normal_neigh statuses_count followers_count followees_count favorites_count listed_count betweenness ... c_feminine_empath c_medieval_empath c_journalism_empath c_farming_empath c_plant_empath c_shopping_empath c_ship_empath c_religion_empath c_tourism_empath c_power_empath
0 0 normal True True 101767 3504 3673 81635 53 100467.895084 ... 0.001380 0.003288 0.000255 0.002189 0.000593 0.003689 0.003559 0.001634 0.002662 0.007487
1 1 other False False 2352 19609 309 61 197 0.000000 ... 0.000802 0.004465 0.000444 0.001632 0.001298 0.002183 0.008969 0.004975 0.000647 0.003419
2 2 other False False 1044 2371 2246 561 16 4897.117853 ... 0.000985 0.005284 0.000554 0.001084 0.001359 0.002041 0.016846 0.004881 0.001214 0.003347
3 3 other False False 167172 3004 298 3242 53 9.864754 ... 0.001391 0.002061 0.001116 0.001282 0.000000 0.001951 0.015423 0.000446 0.000446 0.005241
4 4 other False False 1998 17643 19355 485 239 0.000000 ... 0.000633 0.035382 0.000317 0.000475 0.000475 0.002431 0.007656 0.033273 0.072792 0.003698

5 rows × 1039 columns

Let’s have a look at the distribution of hateful, normal (not hateful), and other (unknown) users in the dataset

[6]:
print("Initial hateful/normal users distribution")
print(users_feat.shape)
print(users_feat.hate.value_counts())
Initial hateful/normal users distribution
(100386, 1039)
other      95415
normal      4427
hateful      544
Name: hate, dtype: int64

There is a clear imbalance on the number of users tagged as hateful vs normal and unknown.

Data cleaning and preprocessing

The dataset as given includes a large number of graph related features that are manually extracted.

Since we are going to employ modern graph neural networks methods for classification, we are going to drop these manually engineered features.

The power of Graph Neural Networks stems from their ability to learn useful graph-related features eliminating the need for manual feature engineering.

[7]:
def data_cleaning(feat):
    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)

    # droping 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_")], axis=1, inplace=True)

    # drop glove features
    feat.drop(feat.columns[feat.columns.str.contains("_glove")], axis=1, inplace=True)

    # drop c_ features
    feat.drop(feat.columns[feat.columns.str.contains("c_")], axis=1, inplace=True)

    # drop sentiment features for now
    feat.drop(feat.columns[feat.columns.str.contains("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
[8]:
node_data = data_cleaning(users_feat)

Of the original 1037 node features, we are keeping only 204 that are based on a user’s attributes and tweet lexicon. We have removed any manually engineered graph features since the graph neural network algorithms we are going to use will automatically determine the best features to use during training.

[9]:
node_data.shape
[9]:
(100386, 206)
[10]:
node_data.head()
[10]:
user_id hate statuses_count followers_count followees_count favorites_count listed_count negotiate_empath vehicle_empath science_empath ... number hashtags tweet number retweet number quote number status length number urls baddies mentions time_diff time_diff_median
0 0 0 101767 3504 3673 81635 53 0.000513 0.002564 0.002564 ... 16.0 121.0 79.0 5.0 75.565000 82.0 18.0 159.0 356.020101 74.0
1 1 2 2352 19609 309 61 197 0.003180 0.000867 0.003469 ... 40.0 199.0 0.0 0.0 101.713568 20.0 10.0 6.0 17519.116162 45.0
2 2 2 1044 2371 2246 561 16 0.005272 0.000000 0.005272 ... 328.0 113.0 87.0 0.0 128.130000 219.0 16.0 158.0 46417.758794 2010.0
3 3 2 167172 3004 298 3242 53 0.004016 0.005801 0.001339 ... 127.0 69.0 131.0 3.0 85.760000 149.0 10.0 26.0 656.889447 72.0
4 4 2 1998 17643 19355 485 239 0.001134 0.000000 0.000756 ... 1710.0 101.0 99.0 0.0 152.175000 198.0 35.0 7.0 55991.356784 48197.0

5 rows × 206 columns

The continous features in our dataset have distributions with very long tails. We apply normalization to correct for this.

[11]:
# Ignore the first two columns because those are user_id and hate (the target variable)
df_values = node_data.iloc[:, 2:].values
[12]:
pt = preprocessing.PowerTransformer(method="yeo-johnson", standardize=True)
[13]:
df_values_log = pt.fit_transform(df_values)

Let’s have a look at one of the normalized features before and after the power transform was applied.

The feature we are going to look at is a user’s number of followers.

[14]:
sns_rc = {"lines.linewidth": 3, "figure.figsize": (12, 6)}
sns.set_context("paper", rc=sns_rc)
sns.set_style("whitegrid", {"axes.grid": False})
[15]:
sns.kdeplot(df_values[:, 1])
s = plt.ylabel("Density", fontsize=18)
s = plt.xlabel("Feature value", fontsize=18)
s = plt.title("Number of followers, before Power Transform", fontsize=18)
../../_images/demos_use-cases_hateful-twitters_27_0.png
[16]:
sns.kdeplot(df_values_log[:, 1])
s = plt.ylabel("Density", fontsize=18)
s = plt.xlabel("Feature value", fontsize=18)
s = plt.title("Number of followers after Power Transform", fontsize=18)
../../_images/demos_use-cases_hateful-twitters_28_0.png

Feature normalization looks like it is doing the right thing as the raw features have long tails that are eliminated after applying the power transform.

So let us use the normalized features from now on.

[17]:
node_data.iloc[:, 2:] = df_values_log
[18]:
# Set the dataframe index to be the same as the user_id and drop the user_id columns
node_data.index = node_data.index.map(str)
node_data.drop(columns=["user_id"], inplace=True)

Node features are now ready for machine learning.

[19]:
node_data.head()
[19]:
hate statuses_count followers_count followees_count favorites_count listed_count negotiate_empath vehicle_empath science_empath timidity_empath ... number hashtags tweet number retweet number quote number status length number urls baddies mentions time_diff time_diff_median
0 0 1.541150 0.046773 1.104767 1.869391 0.017835 -1.752256 0.164900 0.181173 0.875069 ... -0.632488 -0.049013 0.321929 -0.369992 -1.036127 -0.796091 0.047430 0.356495 -1.888186 -1.299249
1 2 -0.700240 0.772450 -0.526061 -1.434183 0.613187 -0.735320 -0.864337 0.599279 1.610977 ... -0.071721 1.479066 -1.999580 -1.545285 -0.188945 -1.875745 -0.626192 -1.972207 0.160925 -1.512603
2 2 -1.077284 -0.127775 0.767345 -0.669050 -0.523882 -0.118440 -1.573040 1.211083 -0.154213 ... 1.618609 -0.201320 0.452537 -1.545285 0.637869 0.884530 -0.096918 0.348954 0.698841 0.122176
3 2 1.908494 -0.021575 -0.548705 0.078540 0.017835 -0.472125 1.281633 -0.544862 1.259492 ... 0.781915 -1.018822 1.085858 -0.662393 -0.701835 0.088472 -0.626192 -1.254997 -1.576801 -1.311031
4 2 -0.778589 0.729918 2.296049 -0.725089 0.700128 -1.488804 -1.573040 -0.969812 0.199834 ... 3.422701 -0.427866 0.638106 -1.545285 1.370832 0.655433 0.955922 -1.914894 0.803553 1.472247

5 rows × 205 columns

Next load the graph

Now that we have the node features prepared for machine learning, let us load the retweet graph.

[20]:
g_nx = nx.read_edgelist(path=os.path.expanduser(os.path.join(data_dir, "users.edges")))
[21]:
g_nx.number_of_nodes(), g_nx.number_of_edges()
[21]:
(100386, 2194979)

The graph has just over 100k nodes and approximately 2.2m edges.

We aim to train a graph neural network model that will predict the “hate”attribute on the nodes.

For computation convenience, we have mapped the target labels normal, hateful, and other to the numeric values 0, 1, and 2 respectively.

[22]:
print(set(node_data["hate"]))
{0, 1, 2}

Splitting the data

For machine learning we want to take a subset of the nodes for training, and use the rest for validation and testing. We’ll use scikit-learn again to split our data into training and test sets.

The total number of annotated nodes is very small when compared to the total number of nodes in the graph. We are only going to use 15% of the annotated nodes for training and the remaining 85% of nodes for testing.

First, we are going to select the subset of nodes that are annotated as hateful or normal. These will be the nodes that have ‘hate’ values that are either 0 or 1.

[23]:
# choose the nodes annotated with normal or hateful classes
annotated_users = node_data[node_data["hate"] != 2]
[24]:
annotated_users.head()
[24]:
hate statuses_count followers_count followees_count favorites_count listed_count negotiate_empath vehicle_empath science_empath timidity_empath ... number hashtags tweet number retweet number quote number status length number urls baddies mentions time_diff time_diff_median
0 0 1.541150 0.046773 1.104767 1.869391 0.017835 -1.752256 0.164900 0.181173 0.875069 ... -0.632488 -0.049013 0.321929 -0.369992 -1.036127 -0.796091 0.047430 0.356495 -1.888186 -1.299249
22 0 1.610235 -0.311474 0.224841 -1.350517 -0.883454 -1.178383 -1.035463 -0.172829 -0.253731 ... -1.291040 1.239586 -1.215177 1.557857 -1.689390 -0.914795 -0.023220 -0.029203 -0.850158 -1.073416
29 0 0.676296 0.907563 -0.583110 -0.473819 1.029022 0.133058 -0.272510 0.154618 1.168792 ... -0.448834 1.060920 -0.907346 0.969577 0.747312 -0.432903 0.529664 0.791479 -0.834524 -0.737971
44 0 -0.564326 -0.167266 0.174262 -0.364344 -0.689414 0.560514 -0.332782 0.226878 1.359779 ... 0.483518 -1.269935 1.264966 -0.254387 0.863865 0.543677 0.047430 0.661312 0.758658 1.137312
85 0 1.036340 -0.137409 -0.777300 1.135700 -0.145647 -0.882219 -1.107841 1.624682 0.028468 ... -0.476405 0.374573 -0.074243 0.355409 -0.302047 -0.340005 -0.524781 0.271756 -0.559000 -0.665185

5 rows × 205 columns

[25]:
annotated_users.shape
[25]:
(4971, 205)
[26]:
annotated_user_features = annotated_users.drop(columns=["hate"])
annotated_user_targets = annotated_users[["hate"]]

There are 4971 annoted nodes out of a possible, approximately, 100k nodes.

[27]:
print(annotated_user_targets.hate.value_counts())
0    4427
1     544
Name: hate, dtype: int64
[28]:
# split the data
train_data, test_data, train_targets, test_targets = train_test_split(
    annotated_user_features, annotated_user_targets, test_size=0.85, random_state=101
)
train_targets = train_targets.values
test_targets = test_targets.values
print("Sizes and class distributions for train/test data")
print("Shape train_data {}".format(train_data.shape))
print("Shape test_data {}".format(test_data.shape))
print(
    "Train data number of 0s {} and 1s {}".format(
        np.sum(train_targets == 0), np.sum(train_targets == 1)
    )
)
print(
    "Test data number of 0s {} and 1s {}".format(
        np.sum(test_targets == 0), np.sum(test_targets == 1)
    )
)
Sizes and class distributions for train/test data
Shape train_data (745, 204)
Shape test_data (4226, 204)
Train data number of 0s 664 and 1s 81
Test data number of 0s 3763 and 1s 463
[29]:
train_targets.shape, test_targets.shape
[29]:
((745, 1), (4226, 1))
[30]:
train_data.shape, test_data.shape
[30]:
((745, 204), (4226, 204))

We are going to use 745 nodes for training and 4226 nodes for testing.

[31]:
# choosing features to assign to a graph, excluding target variable
node_features = node_data.drop(columns=["hate"])

Dealing with imbalanced data

Because the training data exhibit high imbalance, we introduce class weights.

[32]:
from sklearn.utils.class_weight import compute_class_weight

class_weights = compute_class_weight(
    "balanced", np.unique(train_targets), train_targets[:, 0]
)
train_class_weights = dict(zip(np.unique(train_targets), class_weights))
train_class_weights
[32]:
{0: 0.5609939759036144, 1: 4.598765432098766}

Our data is now ready for machine learning.

Node features are stored in the Pandas DataFrame node_features.

The graph in networkx format is stored in the variable g_nx.

Specify global parameters

Here we specify some parameters that control the type of model we are going to use. For example, we specify the base model type, e.g., GCN, GraphSAGE, etc, as well as model-specific parameters.

[33]:
model_type = "graphsage"  # Can be either gcn, gat, or graphsage

if model_type == "graphsage":
    # For GraphSAGE model
    batch_size = 50
    num_samples = [20, 10]
    epochs = 30  # The number of training epochs
elif model_type == "gcn":
    # For GCN model
    epochs = 20  # The number of training epochs
elif model_type == "gat":
    # For GAT model
    layer_sizes = [8, 1]
    attention_heads = 8
    epochs = 20  # The number of training epochs

Creating the base graph machine learning model in Keras

Now create a StellarGraph object from the NetworkX graph and the node features and targets. It is StellarGraph objects that we use in this library to perform machine learning tasks on.

[34]:
G = sg.StellarGraph.from_networkx(g_nx, node_features=node_features)

To feed data from the graph to the Keras model we need a generator. The generators are specialized to the model and the learning task.

For training we map only the training nodes returned from our splitter and the target values.

[35]:
if model_type == "graphsage":
    generator = GraphSAGENodeGenerator(G, batch_size, num_samples)
    train_gen = generator.flow(train_data.index, train_targets, shuffle=True)
elif model_type == "gcn":
    generator = FullBatchNodeGenerator(G, method="gcn", sparse=True)
    train_gen = generator.flow(train_data.index, train_targets,)
elif model_type == "gat":
    generator = FullBatchNodeGenerator(G, method="gat", sparse=True)
    train_gen = generator.flow(train_data.index, train_targets,)

Next we create the GNN model. We need to specify model-specific parameters based on whether we want to use GCN, GAT, or GraphSAGE.

[36]:
if model_type == "graphsage":
    base_model = GraphSAGE(
        layer_sizes=[32, 32], generator=generator, bias=True, dropout=0.5,
    )
    x_inp, x_out = base_model.in_out_tensors()
    prediction = layers.Dense(units=1, activation="sigmoid")(x_out)
elif model_type == "gcn":
    base_model = GCN(
        layer_sizes=[32, 16],
        generator=generator,
        bias=True,
        dropout=0.5,
        activations=["elu", "elu"],
    )
    x_inp, x_out = base_model.in_out_tensors()
    prediction = layers.Dense(units=1, activation="sigmoid")(x_out)
elif model_type == "gat":
    base_model = GAT(
        layer_sizes=layer_sizes,
        attn_heads=attention_heads,
        generator=generator,
        bias=True,
        in_dropout=0.5,
        attn_dropout=0.5,
        activations=["elu", "sigmoid"],
        normalize=None,
    )
    x_inp, prediction = base_model.in_out_tensors()

Create a Keras model

Now let’s create the actual Keras model with the graph inputs x_inp provided by the base_model and outputs being the predictions from the softmax layer.

[37]:
model = Model(inputs=x_inp, outputs=prediction)

We compile our Keras model to use the Adam optimiser and the binary cross entropy loss.

[38]:
model.compile(
    optimizer=optimizers.Adam(lr=0.005), loss=losses.binary_crossentropy, metrics=["acc"],
)
[39]:
model
[39]:
<keras.engine.training.Model at 0x219e65da0>

Train the model, keeping track of its loss and accuracy on the training set, and its performance on the test set during the training. We don’t use the test set during training but only for measuring the trained model’s generalization performance.

[40]:
test_gen = generator.flow(test_data.index, test_targets)

Now we can train the model by calling the fit method.

[41]:
class_weight = None
if model_type == "graphsage":
    class_weight = train_class_weights
history = model.fit(
    train_gen,
    epochs=epochs,
    validation_data=test_gen,
    verbose=0,
    shuffle=False,
    class_weight=class_weight,
)
[42]:
sg.utils.plot_history(history)
../../_images/demos_use-cases_hateful-twitters_75_0.png
../../_images/demos_use-cases_hateful-twitters_75_1.png

Model Evaluation

Now we have trained the model, let’s evaluate it on the test set.

We are going to consider 4 evaluation metrics calculated on the test set: Accuracy, Area Under the ROC curve (AU-ROC), the ROC curve, and the confusion table.

[43]:
test_metrics = model.evaluate(test_gen)
print("\nTest Set Metrics:")
for name, val in zip(model.metrics_names, test_metrics):
    print("\t{}: {:0.4f}".format(name, val))

Test Set Metrics:
        loss: 0.3424
        acc: 0.8874

Let’s use the trained GNN model to make a prediction for each node in the graph.

Then, select only the predictions for the nodes in the test set and calculate the AU-ROC as another performance metric in addition to the accuracy shown above.

[44]:
all_nodes = node_data.index
all_gen = generator.flow(all_nodes)
[45]:
all_predictions = model.predict(all_gen).squeeze()[..., np.newaxis]
[46]:
all_predictions.shape
[46]:
(100386, 1)
[47]:
all_predictions_df = pd.DataFrame(all_predictions, index=node_data.index)

Let’s extract the predictions for the test data only.

[48]:
test_preds = all_predictions_df.loc[test_data.index, :]
[49]:
test_preds.shape
[49]:
(4226, 1)

The predictions are the probability of the true class that in this case is the probability of a user being hateful.

[50]:
test_preds.head()
[50]:
0
11717 0.978688
63824 0.027375
19161 0.021946
57412 0.019605
98222 0.052264
[51]:
test_predictions = test_preds.values
test_predictions_class = ((test_predictions > 0.5) * 1).flatten()
test_df = pd.DataFrame(
    {
        "Predicted_score": test_predictions.flatten(),
        "Predicted_class": test_predictions_class,
        "True": test_targets[:, 0],
    }
)
roc_auc = metrics.roc_auc_score(test_df["True"].values, test_df["Predicted_score"].values)
print("The AUC on test set:\n")
print(roc_auc)
The AUC on test set:

0.8845494008100929
[52]:
pd.crosstab(test_df["True"], test_df["Predicted_class"])
[52]:
Predicted_class 0 1
True
0 3414 349
1 136 327
[53]:
fpr, tpr, thresholds = metrics.roc_curve(
    test_df["True"], test_df["Predicted_score"], pos_label=1
)
plt.figure(figsize=(12, 6,))

lw = 2
plt.plot(
    fpr, tpr, color="darkblue", lw=lw, label="GNN ROC curve (area = %0.2f)" % roc_auc
)
plt.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--")
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel("False Positive Rate", fontsize=18)
plt.ylabel("True Positive Rate", fontsize=18)
plt.title("Receiver operating characteristic curve", fontsize=18)
plt.legend(loc="lower right")
plt.show()
../../_images/demos_use-cases_hateful-twitters_94_0.png

Visualisation of node embeddings

Evaluate node embeddings as activations of the output of one of the graph convolutional or aggregation layers in the Keras model, and visualise them, coloring nodes by their subject label.

You can find the index of the layer of interest by calling model.layers.

First, create a Keras model for calculating the embeddings

[54]:
model.layers
[54]:
[<keras.engine.input_layer.InputLayer at 0x2016edda0>,
 <keras.engine.input_layer.InputLayer at 0x2016edf98>,
 <keras.engine.input_layer.InputLayer at 0x2016ed940>,
 <keras.layers.core.Reshape at 0x2016edc88>,
 <keras.layers.core.Reshape at 0x2016edf28>,
 <keras.layers.core.Dropout at 0x2016edd30>,
 <keras.layers.core.Dropout at 0x2016ed908>,
 <keras.layers.core.Dropout at 0x2016edc50>,
 <keras.layers.core.Dropout at 0x20b369828>,
 <stellargraph.layer.graphsage.MeanAggregator at 0x12b7d48d0>,
 <keras.layers.core.Reshape at 0x2016edbe0>,
 <keras.layers.core.Dropout at 0x21bc2b6a0>,
 <keras.layers.core.Dropout at 0x2016edc18>,
 <stellargraph.layer.graphsage.MeanAggregator at 0x2016ed7b8>,
 <keras.layers.core.Lambda at 0x2016ed6d8>,
 <keras.layers.core.Reshape at 0x21bc2b588>,
 <keras.layers.core.Dense at 0x21bc2bf28>]
[55]:
if model_type == "graphsage":
    # For GraphSAGE, we are going to use the output activations
    # of the second GraphSAGE layer as the node embeddings
    # x_inp, prediction
    emb_model = Model(inputs=x_inp, outputs=model.layers[-4].output)
    emb = emb_model.predict(generator=all_gen,)
elif model_type == "gcn":
    # For GCN, we are going to use the output activations of
    # the second GCN layer as the node embeddings
    emb_model = Model(inputs=x_inp, outputs=model.layers[6].output)
    emb = emb_model.predict(generator=all_gen)
elif model_type == "gat":
    # For GAT, we are going to use the output activations of the
    # first Graph Attention layer as the node embeddings
    emb_model = Model(inputs=x_inp, outputs=model.layers[6].output)
    emb = emb_model.predict(generator=all_gen)
[56]:
emb.shape
[56]:
(100386, 1, 32)
[57]:
emb = emb.squeeze()
[58]:
if model_type == "graphsage":
    emb_all_df = pd.DataFrame(emb, index=node_data.index)
elif model_type == "gcn" or model_type == "gat":
    emb_all_df = pd.DataFrame(emb, index=G.nodes())

Select the embeddings for the test set. We are only going to visualise the test set embeddings.

[59]:
emb_test = emb_all_df.loc[test_data.index, :]

Project the embeddings to 2d using either TSNE or PCA transform, and visualise, coloring nodes by their subject label

[60]:
X = emb_test
y = test_targets
[61]:
X.shape
[61]:
(4226, 32)
[62]:
transform = TSNE  # or use PCA

trans = transform(n_components=2)
emb_transformed = pd.DataFrame(trans.fit_transform(X), index=test_data.index)
emb_transformed["label"] = y
[63]:
alpha = 0.7

fig, ax = plt.subplots(figsize=(14, 8,))
ax.scatter(
    emb_transformed[0],
    emb_transformed[1],
    c=emb_transformed["label"].astype("category"),
    cmap="jet",
    alpha=alpha,
)
ax.set(xlabel="$X_1$", ylabel="$X_2$")
plt.title(
    "{} visualization of embeddings for tweeter dataset".format(transform.__name__),
    fontsize=24,
)
plt.show()
../../_images/demos_use-cases_hateful-twitters_108_0.png

The node embeddings shown above indicate that the majority of hateful users tend to cluster together. However, some normal users are also in the same neighbourhood and these will be difficult to distinguish from hateful ones. Similarly, there is a small number of hateful users dispersed among normal users and these will also be difficult classify correctly.

Predictions using Logistic Regression

Finally, we train a Logistic Regression model on the same train and test data but this time ignoring the graph structure and focusing entirely on the node features.

The variables train_data, test_data, train_targets, and test_targets, hold the data we need to train the Logistic Regression classifier.

[64]:
lr = LogisticRegressionCV(
    cv=5, class_weight=class_weight, max_iter=10000
)  # Let's use the default parameters
[65]:
lr.fit(train_data, train_targets.ravel())
[65]:
LogisticRegressionCV(Cs=10,
           class_weight={0: 0.5609939759036144, 1: 4.598765432098766},
           cv=5, dual=False, fit_intercept=True, intercept_scaling=1.0,
           max_iter=10000, multi_class='warn', n_jobs=None, penalty='l2',
           random_state=None, refit=True, scoring=None, solver='lbfgs',
           tol=0.0001, verbose=0)

We can now use the trained model to predict the test data

[66]:
test_preds_lr = lr.predict_proba(test_data)
[67]:
test_preds_lr.shape
[67]:
(4226, 2)
[68]:
lr.score(test_data, test_targets)
[68]:
0.8589682915286323
[69]:
test_predictions_class_lr = ((test_preds_lr[:, 1] > 0.5) * 1).flatten()
test_df_lr = pd.DataFrame(
    {
        "Predicted_score": test_preds_lr[:, 1].flatten(),
        "Predicted_class": test_predictions_class_lr,
        "True": test_targets[:, 0],
    }
)
roc_auc_lr = metrics.roc_auc_score(
    test_df_lr["True"].values, test_df_lr["Predicted_score"].values
)
print("The AUC on test set:\n")
print(roc_auc_lr)
The AUC on test set:

0.8090002175324247
[70]:
pd.crosstab(test_df_lr["True"], test_df_lr["Predicted_class"])
[70]:
Predicted_class 0 1
True
0 3402 361
1 235 228
[71]:
fpr_lr, tpr_lr, thresholds_lr = metrics.roc_curve(
    test_df_lr["True"], test_df_lr["Predicted_score"], pos_label=1
)
plt.figure(figsize=(12, 6,))
lw = 2
plt.plot(
    fpr_lr,
    tpr_lr,
    color="darkorange",
    lw=lw,
    label="LR ROC curve (area = %0.2f)" % roc_auc_lr,
)
plt.plot(
    fpr, tpr, color="darkblue", lw=lw, label="GNN ROC curve (area = %0.2f)" % roc_auc
)
plt.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--")
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel("False Positive Rate", fontsize=18)
plt.ylabel("True Positive Rate", fontsize=18)
plt.title("Receiver operating characteristic curve", fontsize=18)
plt.legend(loc="lower right")
plt.show()
../../_images/demos_use-cases_hateful-twitters_124_0.png

Let’s have a closer look at the True Positive Rate for the GNN and Logistic Regression models at 2% False Positive Rate.

[72]:
print(
    "At 2% FPR, GNN TPR={:.3f}, LR TPR={:.3f}".format(
        np.interp(0.02, fpr, tpr), np.interp(0.02, fpr_lr, tpr_lr)
    )
)
At 2% FPR, GNN TPR=0.378, LR TPR=0.253

Note: This comparison is valid when comparing GraphSAGE with Logistic Regression using a specific split of the data. Using a different GNN algorithm will very likely produce different numerical results, although, the conclusion below still generally stands.

Comparing the ROC curves between the two machine leanring methods, we see that adding the relatioship information in our machine learning model via the training of a GNN, improves overall performance.

When classifying a user as hateful, it is important to minimise the number of false positives that is the number of normal users that are incorrectly classified as hateful. At the same time, we would like to classify as many hateful users as possible. We can achieve both of these goals by setting decision thresholds guided by the ROC curve.

If we are willing to tolerate 2% false positive rate, then the GNN model achieves a true positive rate of >10% higher than the logistic regression model. That is we can correctly identify more hateful users for the same, low number, of misclassified users.

Run the master version of this notebook: