<jupyter_start><jupyter_text>Knowledge Enhanced Neural Networks - Tutorial Notebook IntroductionThis notebook is intended as a tutorial for the KENN2 (Knowledge Enhanced Neural Networks) framework. KENN2 (Knowledge Enhanced Neural Networks v2.1) is a library for Python 3 built on top of TensorFlow 2 that allows you to modify neural network models by providing logical knowledge in the form of a set of universally quantified FOL clauses. It does so by allowing the addition of a new final layer (KENN in the picture below) to the existing neural network. Such layer changes the original predictions of the standard neural network, enforcing the satisfaction of the provided knowledge.![title](imgs/KENN_overview.png)In the picture above, we used the symbol `z` to represent the output of the neural network to indicate that the input of KENN must be the preactivations of the predicted truth values. Indeed, KENN layer could be seen as a special type of activation function which enforces the logical knowledge.Notice also that the neural network is represented in gray. This is bacause, in general, KENN is agnostic to the architecture used and the input `z` could be only partially calculated by the neural network. For instance, in this tutorial, the binary predicates are given as inputs since the task is to predict only the unary predicates.To create the KENN layer, the library provides parsers functions, each of which represent a parser for a specific type of knowledge base file. The parser compiles the knowledge and returns the KENN layer, which can be used as a standard TensorFlow operator and can be inserted as a final layer of the model.The main parsers available in KENN are two: `unary_parser` and `relational_parser` (though it is possible to extend the library with new parsers). In the first case, the knowledge contains only unary predicates, and the `x` and `y` of the picture above corresponds to matrices (where rows represent different objects and columns different predicates). In the second case, the `relational_parser` takes as input a knowledge file with both unary and binary predicates and `x` and `y` are (usually) graphs.In this notebook, we focus on the relational case. In particular, here we focus on a simple application of KENN on the Citeseer Dataset, a citation network where the only binary predicate (which correponds the the edges of the graph) represent the citation relation between papers and the goal is to predict the topic of the papers given both the papers features and the citations among them.For a simple explanation of KENN2 usage with the unary parser, please check the README page of the [github page of KENN2](https://github.com/DanieleAlessandro/KENN2).  The Citeseer DatasetThe Citeseer Dataset is essentially a directed graph: the nodes are papers, while an edge between a paper `x` and another paper `y` means that `x` cites `y`. Each paper can be classified into one of six classes, namely: Agents, AI, DB, IR, ML and HCI. Moreover, for each paper a bag of words vector is provided as features.The task is to **correctly classify each scientific publication**, considering both the features of the paper and the relational information provided by the citation network.More specifically, the dataset consists of:- **3312 scientific publications**; - **4732 links**;- Each publication in the dataset is described by a 0/1-valued word vector indicating the absence/presence of the corresponding word from the dictionary. The dictionary consists of **3703 unique words**, which was obtained after stemming and removing stopwords.   Training, Validation and Test splitsFor this tutorial, we follow the **Inductive Learning** paradigm when splitting the whole dataset into Training, Validation and Test Set (i.e., only edges between nodes in the same set are used, while edges across the three sets are removed).Specifically, we use $8\%$ of the nodes (papers) as Training Set, $2\%$ for validation and the remaining $90\%$ as a Test Set. This split is particularly relevant since there are not a lot of samples available at training time. In this type of scenario, the additional information coming from the knowledge become particularly relevant since tha data is not enough to learn everything from scratch.More in depth experiments on CiteSeer are available on [GitHub](https://github.com/rmazzier/KENN-Citeseer-Experiments). The Prior KnowledgeThe Prior Knowledge we want to inject is pretty simple. Indeed, it is composed of only 6 clauses, one for each topic. More in details, given a topic `T`, we inject the clause$$\forall x \forall y \quad \lnot T(x) \lor \lnot Cite(x,y) \lor T(y)$$Such rule codifies the idea that papers cite works that are related to them (i.e. the topic of a paper is often the same of the paper it cites).   Data RepresentationBefore looking in details the model architecture, we need to specify how to represent a graph in terms of matrices. Picture below shows an example of a graph (left) and the corresponding representation used by KENN2 (right).In the example there are three unary predicates (labels of the nodes) and two binary predicates (red and green arrows). For both unary and binary predicates the numbers represent the preactivations of the truth values. To simplify the picture, many edges are set to false and not showed in the graph. Note that, since KENN layer works with preactivations, the false truth values' preactivations are set to a very small number (-500) in the matrices on the right.![title](imgs/KENN_graph.png) In KENN2, the graph is represented using two matrices and two indexes vectors:- The `Unary` matrix contains the truth values of unary predicates. In particular, each column corresponds to a specific predicate, while the rows represent the different objects of the domain (the nodes of the graph). To simplify the presentation, an additional column with the objects' indexes has been added to the picture;- The `Binary` matrix has a similar structure, but it contains the truth values of binary predicates. In this case, each row corresponds to a pair of nodes;- Finally, the two vectors `sx` and `sy` contain the indexes of the objects referred by the `Binary` matrix. As an example, in the picture above, the first row of `Binary` contains the binary predicates referring the pairs of nodes 0 and 1. Hence, the values in the first row of `sx` and `sy` contains 0 and 1 respectively.  KENN architecture on CiteSeer![title](imgs/KENN_architecture.png)The picture above depicts the architecture used in this tutorial. There are four steps, the first two can be seen as a pre-elaboration and they are applied only once:- **Step 1: pre-elaboration of the relational data.** Here the graph is stored using the previously defined representation. Notice that in this case there is only one column in the `Binary` Matrix (`relations` in the picture above). Such column represents the `Cite` predicate and it is setted to 500 for all the pairs. This is because the citations are given as input and we consider only the pairs `(x,y)` for which `Cite(x,y)` is true;- **Step 2: parsing the knowledge file.** Here the knowledge file is provided to the parser, which in turns generates the KENN layer.After the first two steps the model is defined and it can be trained like any neural network in TensorFlow. The model produces its predictions in the last two steps:- **Step 3: initial predictions provided by the neural network.** The NN uses the nodes' features to classify each paper. It returns the preactivations for each paper and each topic;- **Step 4: knowledge injection.** The KENN layer updates the predictions of the NN and returns the final results.  How to use KENN2 Imports and initial settingsLets now dive into the code, starting from the necessary imports. The first rows are standard python libraries. The only additional import required by KENN2 is the parser function, which allows to read a knowldge file for generating the KENN layer.<jupyter_code>import tensorflow as tf
import numpy as np 
import pandas as pd

from tensorflow import keras
from tensorflow.keras import Model
from tensorflow.keras import layers
from tensorflow.keras.activations import softmax

from KENN2.parsers import relational_parser<jupyter_output><empty_output><jupyter_text>In order to fix the code behaviour, we also set the random seed for TensorFlow and numpy. Feel free to experiment with different values of the seed or directly remove the entire cell.<jupyter_code>random_seed = 0
tf.random.set_seed(random_seed)
np.random.seed(random_seed)<jupyter_output><empty_output><jupyter_text>Finally, lets define the path of the dataset.<jupyter_code>dataset_folder = 'dataset/CiteSeer/'<jupyter_output><empty_output><jupyter_text>Import dataHere we import the relational data. In particular, the three matrices defined in the [KENN architecture on CiteSeer](architecture) section: `features`, `indexes` and `relations`.  Features: The features matrix contains a row for each node in the graph, while the columns take values in $\{0,1\}$, 0 meaning the absence of a word of the dictionary, 1 meaning its presence.<jupyter_code>training_features = np.genfromtxt(dataset_folder + 'training_features.csv', delimiter=',')
validation_features = np.genfromtxt(dataset_folder + 'validation_features.csv', delimiter=',')
test_features = np.genfromtxt(dataset_folder + 'test_features.csv', delimiter=',')
pd.DataFrame(training_features)<jupyter_output><empty_output><jupyter_text>Notice that the matrix above seems to contains only zeros just because it is very sparse. As an example, here the indexes of the words contained in the first document:<jupyter_code>print(np.where(training_features[0]==1)[0])<jupyter_output>[  32  211  249  383  407  493  507  609  619  731  744 1087 1118 1239
 1245 1548 1611 1619 1641 1841 2216 2395 2407 2448 2492 2539 2553 2563
 2568 2615 2741 2875 2902 2906 3122 3184 3463 3586 3594]<jupyter_text>Indexes:<jupyter_code>indexes_training = np.genfromtxt(dataset_folder + 'indexes_training.csv', delimiter=',', dtype=np.int32)
indexes_validation = np.genfromtxt(dataset_folder + 'indexes_validation.csv', delimiter=',', dtype=np.int32)
indexes_test = np.genfromtxt(dataset_folder + 'indexes_test.csv', delimiter=',', dtype=np.int32)
print("Shape of the training_indexes matrix: " + str(indexes_training.shape))
pd.DataFrame(indexes_training).head(10)<jupyter_output>Shape of the training_indexes matrix: (34, 2)<jupyter_text>We can note that there is a good amount of papers that seemingly cite themselves. This strange behaviour is probably due to the fact that Citeseer was generated with an automatic citation indexing system, which seems not capable to disambiguate between papers having the same author names.Check https://clgiles.ist.psu.edu/papers/DL-1998-citeseer.pdf for more information.  Relations: As we explained above, this vector contains the truth values of the connection between two nodes.Note that we just consider the couples of connected nodes, and exclude all the other non connected couples of nodes in the graph. For this reason the only value in the relations matrix is 500.<jupyter_code># IMPORT RELATIONS
relations_training = np.genfromtxt(dataset_folder + 'relations_training.csv', delimiter=',')
relations_validations = np.genfromtxt(dataset_folder + 'relations_validation.csv', delimiter=',')
relations_test = np.genfromtxt(dataset_folder + 'relations_test.csv', delimiter=',')

# Reshape relations arrays to be column vectors
relations_training = np.expand_dims(relations_training, axis=1)
relations_validations = np.expand_dims(relations_validations, axis=1)
relations_test = np.expand_dims(relations_test, axis=1)

n_features = training_features.shape[1]
print("Shape of the training_relations matrix: " + str(relations_training.shape))
pd.DataFrame(relations_training).head(10)<jupyter_output>Shape of the training_relations matrix: (34, 1)<jupyter_text>Labels: Finally, in order to perform training and evaluations, we need also to import the labels. Labels are encoded as a one-hot vector vector of length 6.<jupyter_code>training_labels = np.genfromtxt(dataset_folder + 'training_labels.csv', delimiter=',')
validation_labels = np.genfromtxt(dataset_folder + 'validation_labels.csv', delimiter=',')
test_labels = np.genfromtxt(dataset_folder + 'test_labels.csv', delimiter=',')
pd.DataFrame(training_labels)<jupyter_output><empty_output><jupyter_text>Define the modelsHere we define the standard model (a feedforward neural network with three hidden layers) and KENN model (the same architecture as the standard model with the addition of KENN layers).  Base NN Model<jupyter_code>class Standard(Model):
    def __init__(self):
        super(Standard, self).__init__()

    def build(self, input_shape):
        self.h1 = layers.Dense(50, input_shape=input_shape, activation='relu')
        self.d1 = layers.Dropout(0.5)
        self.h2 = layers.Dense(50, input_shape=(50,), activation='relu')
        self.d2 = layers.Dropout(0.5)
        self.h3 = layers.Dense(50, input_shape=(50,), activation='relu')
        self.d3 = layers.Dropout(0.5)

        self.last_layer = layers.Dense(
            6, input_shape=(50,), activation='linear')

    def preactivations(self, inputs):
        x = self.h1(inputs)
        x = self.d1(x)
        x = self.h2(x)
        x = self.d2(x)
        x = self.h3(x)
        x = self.d3(x)

        return self.last_layer(x)
        
    def call(self, inputs, **kwargs):
        z = self.preactivations(inputs)

        return z, softmax(z)<jupyter_output><empty_output><jupyter_text>KENN Model<jupyter_code>class Kenn(Standard):
    def __init__(self, knowledge_file, *args, **kwargs):
        super(Kenn, self).__init__(*args, **kwargs)
        self.knowledge = knowledge_file

    def build(self, input_shape):
        super(Kenn, self).build(input_shape)
        self.kenn_layer_1 = relational_parser(self.knowledge)
        self.kenn_layer_2 = relational_parser(self.knowledge)
        self.kenn_layer_3 = relational_parser(self.knowledge, activation=softmax)

    def call(self, inputs, **kwargs):
        features = inputs[0]
        relations = inputs[1]
        sx = inputs[2]
        sy = inputs[3]
        
        z = self.preactivations(features)
        z, _ = self.kenn_layer_1(z, relations, sx, sy)
        z, _ = self.kenn_layer_2(z, relations, sx, sy)
        z, _ = self.kenn_layer_3(z, relations, sx, sy)

        return z<jupyter_output><empty_output><jupyter_text>In the above cell we defined the KENN model by extending the Standard NN with 3 additional KENN layers. We add three layers instead of one since a single layer considers only the neighbours of each node. By adding three layers we allow for a propagation of the changes to farther nodes.As already mentioned previously, to generate a KENN layer is sufficient to call the `relational_parser` function, providing as input the knowledge file. More in details, the `relational_parser` function parses the knowledge file and returns a ```RelationalKenn``` object which is constructed by looking at the provided knowledge file. Such object can then be used with standard TensorFlow code, as we do below.Notice also that the third layer is created by passing an activation function (the `softmax`) to the parser. This is bacause, internally, the KENN layer just modifies the preactivations provided as input (based on the knowledge) and then applies an activation function (the default one is a linear activation).Lets now take a look at the knowldge base file:<jupyter_code>!cat knowledge_base<jupyter_output>_AI,_Agents,_DB,_HCI,_IR,_ML

Cite



>

_:n_AI(x),nCite(x.y),_AI(y)

_:n_Agents(x),nCite(x.y),_Agents(y)

_:n_DB(x),nCite(x.y),_DB(y)

_:n_HCI(x),nCite(x.y),_HCI(y)

_:n_IR(x),nCite(x.y),_IR(y)

_:n_ML(x),nCite(x.y),_ML(y)<jupyter_text>The first row contains the list of unary predicates separated by a comma with no spaces, while the second row contains the binary predicates (in our case, just one). Each predicate should start with a capital letter or another symbol, to ensure that we have no conflicts with other rules we'll define below.The other rows contain the clauses, which are also split in two groups: the first group contains only unary predicates, the second both unary and binary predicates. The two groups are separated by a row containing the > symbol.Each clause is in a separate row and must be written respecting this properties:- Logical disjunctions are represented with commas;- If a literal is negated, it must be preceded by the lowercase 'n';- They must contain only predicates specified in the first row;- There shouldn't be spaces.Additionally, each clause must be preceded by a positive weight that represents the strength of the clause. More precisely, the weight could be a numeric value or an underscore: in the first case, the weight is fixed and determined by the specified value, in the second case the weight is learned during training. Unary predicates are defined on a single variable (e.g. AI(x)) while binary predicates on two variables separated by a dot (e.g. Cite(x.y)).  Training setupLets now set the training hyper parameters:<jupyter_code># Training parameters
n_epochs = 300

# Early Stopping parameters
min_delta = 0.001
es_patience = 10


optimizer = keras.optimizers.Adam()
loss = keras.losses.CategoricalCrossentropy(from_logits=False)<jupyter_output><empty_output><jupyter_text>Early Stopping:In this tutorial we use Early Stopping which proved to be beneficial when applied to the KENN model.The `callback_early_stopping` function takes as argument the list with all the validation accuracies. If `patience`=$k$, it checks if the mean of the last $k$ accuracies is higher than the mean of the previous $k$ accuracies (i.e. we check that we are not overfitting). If not, it stops learning.<jupyter_code>def accuracy(predictions, labels):
    correctly_classified = tf.equal(
        tf.argmax(predictions, 1), tf.argmax(labels, 1))
    return tf.reduce_mean(tf.cast(correctly_classified, tf.float32))

def callback_early_stopping(AccList, min_delta=min_delta, patience=es_patience):
    if len(AccList)//patience < 2:
        return False
    
    mean_previous = np.mean(AccList[::-1][patience:2*patience])
    mean_recent = np.mean(AccList[::-1][:patience])
    delta = mean_recent - mean_previous

    if delta <= min_delta:
        print(
            "*CB_ES* Validation Accuracy didn't increase in the last %d epochs" % (patience))
        print("*CB_ES* delta:", delta)
    
    return delta <= min_delta<jupyter_output><empty_output><jupyter_text>Training the base NNHere we train the standard NN on its own. Later, we will compare its Test Accuracy with the one of KENN.<jupyter_code># Define and build model
standard_model = Standard()
standard_model.build((n_features,))


# Used for early stopping
valid_accuracies = []

for epoch in range(n_epochs):
    with tf.GradientTape() as tape:
        _, predictions = standard_model(training_features)
        training_loss = loss(predictions, training_labels)

        gradient = tape.gradient(training_loss, standard_model.variables)
        optimizer.apply_gradients(zip(gradient, standard_model.variables))

    
    _, v_predictions = standard_model(validation_features)
    v_accuracy = accuracy(v_predictions, validation_labels)
    valid_accuracies.append(v_accuracy.numpy())
    
    if epoch % 10 == 0:
        _, t_predictions = standard_model(training_features)
        t_loss = loss(t_predictions,training_labels)
        t_accuracy = accuracy(t_predictions, training_labels)
        
        v_loss = loss(v_predictions, validation_labels)

        print(
            "Epoch {}: Training Loss: {:5.4f} Validation Loss: {:5.4f} | Train Accuracy: {:5.4f} Validation Accuracy: {:5.4f};".format(
                epoch, t_loss, v_loss, t_accuracy, v_accuracy))


    # Early Stopping
    stopEarly = callback_early_stopping(valid_accuracies)
    if stopEarly:
        print("callback_early_stopping signal received at epoch= %d/%d" %
                (epoch, n_epochs))
        print("Terminating training ")
        break<jupyter_output>Epoch 0: Training Loss: 13.3542 Validation Loss: 13.4059 | Train Accuracy: 0.3598 Validation Accuracy: 0.2687;
Epoch 10: Training Loss: 12.2838 Validation Loss: 13.1026 | Train Accuracy: 0.8674 Validation Accuracy: 0.3881;
Epoch 20: Training Loss: 8.8543 Validation Loss: 11.9123 | Train Accuracy: 0.9432 Validation Accuracy: 0.4478;
Epoch 30: Training Loss: 3.1537 Validation Loss: 10.3593 | Train Accuracy: 0.9924 Validation Accuracy: 0.5672;
Epoch 40: Training Loss: 0.3242 Validation Loss: 9.5506 | Train Accuracy: 1.0000 Validation Accuracy: 0.5075;
*CB_ES* Validation Accuracy didn't increase in the last 10 epochs
*CB_ES* delta: -0.01791042
callback_early_stopping signal received at epoch= 44/300
Terminating training<jupyter_text>Training KENNThe code for training KENN is not particularly different from the standard NN model. Indeed, the only difference is in the inputs of the model that were composed of just the nodes' features in the case of the NN and now contain also the relational data.<jupyter_code>kenn_model = Kenn('knowledge_base')
kenn_model.build((n_features,))

valid_accuracies = []

for epoch in range(n_epochs):
    with tf.GradientTape() as tape:
        predictions_KENN = kenn_model(
            [training_features, relations_training, np.expand_dims(indexes_training[:,0], axis=1), np.expand_dims(indexes_training[:,1], axis=1)])

        l = loss(predictions_KENN, training_labels)

        gradient = tape.gradient(l, kenn_model.variables)
        optimizer.apply_gradients(zip(gradient, kenn_model.variables))
    
    v_predictions = kenn_model([validation_features, relations_validations, np.expand_dims(indexes_validation[:,0], axis=1), np.expand_dims(indexes_validation[:,1], axis=1)])
    v_accuracy = accuracy(v_predictions, validation_labels)
    valid_accuracies.append(v_accuracy)


    if epoch % 10 == 0:
        t_predictions = kenn_model(
                [training_features, relations_training, np.expand_dims(indexes_training[:,0], axis=1), np.expand_dims(indexes_training[:,1], axis=1)])
        t_loss = loss(t_predictions, training_labels)
        t_accuracy = accuracy(t_predictions, training_labels)


        v_loss = loss(v_predictions, validation_labels)

        print(
            "Epoch {}: Training Loss: {:5.4f} Validation Loss: {:5.4f} | Train Accuracy: {:5.4f} Validation Accuracy: {:5.4f};".format(
                epoch, t_loss, v_loss, t_accuracy, v_accuracy))

    # Early Stopping
    stopEarly = callback_early_stopping(valid_accuracies)
    if stopEarly:
        print("callback_early_stopping signal received at epoch= %d/%d" %
                (epoch, n_epochs))
        print("Terminating training ")
        break<jupyter_output>Epoch 0: Training Loss: 13.3580 Validation Loss: 13.4560 | Train Accuracy: 0.2879 Validation Accuracy: 0.1940;
Epoch 10: Training Loss: 11.5904 Validation Loss: 13.1699 | Train Accuracy: 0.8674 Validation Accuracy: 0.2687;
Epoch 20: Training Loss: 5.2270 Validation Loss: 11.7052 | Train Accuracy: 0.9886 Validation Accuracy: 0.3284;
Epoch 30: Training Loss: 0.3318 Validation Loss: 9.1088 | Train Accuracy: 1.0000 Validation Accuracy: 0.5522;
Epoch 40: Training Loss: 0.0201 Validation Loss: 7.6865 | Train Accuracy: 1.0000 Validation Accuracy: 0.6418;
Epoch 50: Training Loss: 0.0043 Validation Loss: 7.2665 | Train Accuracy: 1.0000 Validation Accuracy: 0.6567;
*CB_ES* Validation Accuracy didn't increase in the last 10 epochs
*CB_ES* delta: -5.9604645e-08
callback_early_stopping signal received at epoch= 56/300
Terminating training<jupyter_text>Evaluation on Test SetLets evaluate the NN model with and without the KENN layers.<jupyter_code>_, predictions_test = standard_model(test_features)
test_accuracy = accuracy(predictions_test, test_labels)
print("Standard model Test Accuracy: {:.5f}%".format(test_accuracy.numpy() * 100))
ind_x = np.expand_dims(indexes_test[:,0], axis=1)
ind_y = np.expand_dims(indexes_test[:,1], axis=1)

predictions_test_kenn = kenn_model(
    [test_features, relations_test, ind_x, ind_y])

test_accuracy_kenn = accuracy(predictions_test_kenn, test_labels)
print("KENN model Test Accuracy: {:.5f}%".format(test_accuracy_kenn.numpy() * 100))<jupyter_output>KENN model Test Accuracy: 61.15398%
{'fraction_non_alphanumeric': 0.07673074558347338, 'fraction_numerical': 0.02553592654834611, 'mean_word_length': 4.7393554457774645, 'pattern_counts': {'":': 0, '<': 61, '<?xml version=': 0, '>': 60, 'https://': 3, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 3, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '16', 'dir': 'jupyter-structured-clean-dedup', 'id': '321603', 'license': 'BSD-3-Clause', 'n_tokens_mistral': 7449, 'n_tokens_neox': 6772, 'n_words': 2844, 'path': 'KENN_Tutorial.ipynb', 'repo_name': 'rmazzier/KENN-Relational-Tutorial', 'size': '24397'}