CINXE.COM

Getting started - Spektral

<!DOCTYPE html> <!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]--> <!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]--> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="author" content="Daniele Grattarola"> <link rel="canonical" href="https://graphneural.network/getting-started/"> <link rel="shortcut icon" href="../img/favicon.ico"> <title>Getting started - Spektral</title> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lato:400,700|Roboto+Slab:400,700|Inconsolata:400,700" /> <link rel="stylesheet" href="../css/theme.css" /> <link rel="stylesheet" href="../css/theme_extra.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/styles/github.min.css" /> <link href="../stylesheets/extra.css" rel="stylesheet" /> <script> // Current page data var mkdocs_page_name = "Getting started"; var mkdocs_page_input_path = "getting-started.md"; var mkdocs_page_url = "/getting-started/"; </script> <script src="../js/jquery-2.1.1.min.js" defer></script> <script src="../js/modernizr-2.8.3.min.js" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/highlight.min.js"></script> <script>hljs.initHighlightingOnLoad();</script> <script> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); ga('create', 'UA-125823175-1', 'auto'); ga('send', 'pageview'); </script> </head> <body class="wy-body-for-nav" role="document"> <div class="wy-grid-for-nav"> <nav data-toggle="wy-nav-shift" class="wy-nav-side stickynav"> <div class="wy-side-scroll"> <div class="wy-side-nav-search"> <a href=".." class="icon icon-home"> Spektral</a> <div role="search"> <form id ="rtd-search-form" class="wy-form" action="../search.html" method="get"> <input type="text" name="q" placeholder="Search docs" title="Type search term here" /> </form> </div> </div> <div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation"> <ul> <li class="toctree-l1"><a class="reference internal" href="..">Home</a> </li> </ul> <p class="caption"><span class="caption-text">Tutorials</span></p> <ul class="current"> <li class="toctree-l1 current"><a class="reference internal current" href="./">Getting started</a> <ul class="current"> <li class="toctree-l2"><a class="reference internal" href="#graphs">Graphs</a> <ul> <li class="toctree-l3"><a class="reference internal" href="#adjacency-matrix-grapha">Adjacency matrix (graph.a)</a> </li> <li class="toctree-l3"><a class="reference internal" href="#node-features-graphx">Node features (graph.x)</a> </li> <li class="toctree-l3"><a class="reference internal" href="#edge-features-graphe">Edge features (graph.e)</a> </li> <li class="toctree-l3"><a class="reference internal" href="#labels-graphy">Labels (graph.y)</a> </li> </ul> </li> <li class="toctree-l2"><a class="reference internal" href="#datasets">Datasets</a> </li> <li class="toctree-l2"><a class="reference internal" href="#creating-a-gnn">Creating a GNN</a> </li> <li class="toctree-l2"><a class="reference internal" href="#training-the-gnn">Training the GNN</a> <ul> <li class="toctree-l3"><a class="reference internal" href="#loaders">Loaders</a> </li> </ul> </li> <li class="toctree-l2"><a class="reference internal" href="#evaluating-the-gnn">Evaluating the GNN</a> </li> <li class="toctree-l2"><a class="reference internal" href="#node-level-learning">Node-level learning</a> </li> <li class="toctree-l2"><a class="reference internal" href="#go-create">Go create!</a> </li> </ul> </li> <li class="toctree-l1"><a class="reference internal" href="../data-modes/">Data modes</a> </li> <li class="toctree-l1"><a class="reference internal" href="../creating-dataset/">Creating a dataset</a> </li> <li class="toctree-l1"><a class="reference internal" href="../creating-layer/">Creating a layer</a> </li> <li class="toctree-l1"><a class="reference internal" href="../examples/">Examples</a> </li> </ul> <p class="caption"><span class="caption-text">Layers</span></p> <ul> <li class="toctree-l1"><a class="reference internal" href="../layers/convolution/">Convolutional layers</a> </li> <li class="toctree-l1"><a class="reference internal" href="../layers/pooling/">Pooling layers</a> </li> <li class="toctree-l1"><a class="reference internal" href="../layers/base/">Base layers</a> </li> <li class="toctree-l1"><a class="reference internal" href="../models/">Models</a> </li> </ul> <p class="caption"><span class="caption-text">Data</span></p> <ul> <li class="toctree-l1"><a class="reference internal" href="../data/">Containers</a> </li> <li class="toctree-l1"><a class="reference internal" href="../datasets/">Datasets</a> </li> <li class="toctree-l1"><a class="reference internal" href="../loaders/">Loaders</a> </li> <li class="toctree-l1"><a class="reference internal" href="../transforms/">Transforms</a> </li> </ul> <p class="caption"><span class="caption-text">Utils</span></p> <ul> <li class="toctree-l1"><a class="reference internal" href="../utils/convolution/">Convolution</a> </li> <li class="toctree-l1"><a class="reference internal" href="../utils/sparse/">Sparse</a> </li> <li class="toctree-l1"><a class="reference internal" href="../utils/misc/">Miscellaneous</a> </li> </ul> <p class="caption"><span class="caption-text">Other</span></p> <ul> <li class="toctree-l1"><a class="reference internal" href="../external/">External resources</a> </li> <li class="toctree-l1"><a class="reference internal" href="../about/">About</a> </li> </ul> </div> </div> </nav> <section data-toggle="wy-nav-shift" class="wy-nav-content-wrap"> <nav class="wy-nav-top" role="navigation" aria-label="top navigation"> <i data-toggle="wy-nav-top" class="fa fa-bars"></i> <a href="..">Spektral</a> </nav> <div class="wy-nav-content"> <div class="rst-content"> <div role="navigation" aria-label="breadcrumbs navigation"> <ul class="wy-breadcrumbs"> <li><a href="..">Docs</a> &raquo;</li> <li>Tutorials &raquo;</li> <li>Getting started</li> <li class="wy-breadcrumbs-aside"> </li> </ul> <hr/> </div> <div role="main"> <div class="section"> <h2 id="getting-started">Getting started</h2> <p>Spektral is designed according to the guiding principles of Keras to make things extremely simple for beginners while maintaining flexibility for experts. </p> <p>In this tutorial, we will go over the main features of Spektral while creating a graph neural network for graph classification. </p> <h3 id="graphs">Graphs</h3> <p>A graph is a mathematical object that represents relations between entities. We call the entities "nodes" and the relations "edges". </p> <p>Both the nodes and the edges can have vector <strong>features</strong>.</p> <p>In Spektral, graphs are represented with instances of <code>spektral.data.Graph</code>. A graph can have four main attributes: </p> <ul> <li><code>a</code>: the <strong>adjacency matrix</strong></li> <li><code>x</code>: the <strong>node features</strong></li> <li><code>e</code>: the <strong>edge features</strong></li> <li><code>y</code>: the <strong>labels</strong></li> </ul> <p>A graph can have all of these attributes or none of them. Since Graphs are just plain Python objects, you can also add extra attributes if you want. For instance, see <code>graph.n_nodes</code>, <code>graph.n_node_features</code>, etc.</p> <h4 id="adjacency-matrix-grapha">Adjacency matrix (<code>graph.a</code>)</h4> <p>Each entry <code>a[i, j]</code> of the adjacency matrix is non-zero if there exists an edge going from node <code>i</code> to node <code>j</code>, and zero otherwise. </p> <p>We can represent <code>a</code> as a dense <code>np.array</code> or as a Scipy sparse matrix of shape <code>[n_nodes, n_nodes]</code>. Using an <code>np.array</code> to represent the adjacency matrix can be expensive, since we need to store a lot of 0s in memory, so sparse matrices are usually preferable.</p> <p>With sparse matrices, we only need to store the non-zero entries of <code>a</code>. In practice, we can implement a sparse matrix by only storing the indices and values of the non-zero entries in a list, and assuming that if a pair of indices is missing from the list then its corresponding value will be 0.<br /> This is called the <em>COOrdinate format</em> and it is the format used by TensorFlow to represent sparse tensors.</p> <p>For example, the adjacency matrix of a weighted ring graph with 4 nodes:</p> <pre><code>[[0, 1, 0, 2], [3, 0, 4, 0], [0, 5, 0, 6], [7, 0, 8, 0]] </code></pre> <p>can be represented in COOrdinate format as follows: </p> <pre><code class="language-python">R, C, V 0, 1, 1 0, 3, 2 1, 0, 3 1, 2, 4 2, 1, 5 2, 3, 6 3, 0, 7 3, 2, 8 </code></pre> <p>where <code>R</code> indicates the "row" indices, <code>C</code> the columns, and <code>V</code> the non-zero values <code>a[i, j]</code>. For example, in the second line, we see that there is an edge that goes <strong>from node 0 to node 3</strong> with weight 2.</p> <p>We also see that, in this case, all edges have a corresponding edge that goes in the opposite direction. For the sake of this example, all edges have been assigned a different weight. In practice, however, edge <code>i, j</code> will often have the same weight as edge <code>j, i</code> and the adjacency matrix will be symmetric.</p> <p>Many convolutional and pooling layers in Spektral use this sparse representation of matrices to do their computation, and sometimes you will see in the documentation a comment saying that <strong>"This layer expects a sparse adjacency matrix."</strong></p> <h4 id="node-features-graphx">Node features (<code>graph.x</code>)</h4> <p>When working with graph neural networks, we usually associate a vector of features with each node of a graph. This is no different from how every pixel in an image has an <code>[R, G, B, A]</code> vector associated with it. </p> <p>Since we have <code>n_nodes</code> nodes and each node has a feature vector of size <code>n_node_features</code>, we can stack all features in a matrix <code>x</code> of shape <code>[n_nodes, n_node_features]</code>.</p> <p>In Spektral, <code>x</code> is always represented with a dense <code>np.array</code> (since in this case we don't run the risk of storing many useless zeros -- at least not often).</p> <h4 id="edge-features-graphe">Edge features (<code>graph.e</code>)</h4> <p>Similar to node features, we can also have features associated with edges. These are usually different from the <em>edge weights</em> that we saw for the adjacency matrix, and often represent the kind of relation between two nodes (e.g., acquaintances, friends, or partners).</p> <p>When representing edge features, we run into the same problems that we have for the adjacency matrix. </p> <p>If we store them in a dense <code>np.array</code>, then the array will have shape <code>[n_nodes, n_nodes, n_edge_features]</code> and most of its entries will be zeros. Unfortunately, order-3 tensors cannot be represented as Scipy sparse matrices, so we need to be smart about it. </p> <p>Similar to how we stored the adjacency matrix as a list of entries <code>r, c, v</code>, here we can use the COOrdinate format to represent our edge features. Assume that, in the example above, each edge has <code>n_edge_features=3</code> features. We could do something like: </p> <pre><code class="language-python">R, C, V 0, 1, [ef_1, ef_2, ef_3] 0, 3, [ef_1, ef_2, ef_3] 1, 0, [ef_1, ef_2, ef_3] 1, 2, [ef_1, ef_2, ef_3] 2, 1, [ef_1, ef_2, ef_3] 2, 3, [ef_1, ef_2, ef_3] 3, 0, [ef_1, ef_2, ef_3] 3, 2, [ef_1, ef_2, ef_3] </code></pre> <p>Since we already have the information of <code>R</code> and <code>C</code> in the adjacency matrix, we only need to store the <code>V</code> column as a matrix <code>e</code> of shape <code>[n_edges, n_edge_features]</code>. In this case, <code>n_edges</code> indicates the number of non-zero entries in the adjacency matrix. </p> <p>Note that, since we have separated the edge features from the edge indices of the adjacency matrix, the order in which we store the edge features is very important. We must not break the correspondence between the edges in <code>a</code> and the edges in <code>e</code>.</p> <p><strong>In Spektral, we always assume that edges are sorted in the row-major ordering (we first sort by row, then by column, like in the example above). This is not important when building the adjacency matrix, but it is important when building <code>e</code>.</strong></p> <p>You can use <code>spektral.utils.sparse.reorder</code> to sort a matrix of edge features in the correct row-major order given by an <em>edge index</em> (i.e., the matrix obtained by stacking the <code>R</code> and <code>C</code> columns).</p> <h4 id="labels-graphy">Labels (<code>graph.y</code>)</h4> <p>Finally, in many machine learning tasks we want to predict a label given an input. When working with GNNs, labels can be of two types: </p> <ol> <li><strong>Graph labels</strong> represent some global properties of an entire graph;</li> <li><strong>Node labels</strong> represent some properties of each individual node in a graph;</li> </ol> <p>Spektral supports both kinds. </p> <p>Labels are dense <code>np.array</code>s or scalars, stored in the <code>y</code> attribute of a <code>Graph</code> object. <br /> Graph-level labels can be either scalars or 1-dimensional arrays of shape <code>[n_labels, ]</code>. <br /> Node-level labels can be 1-dimensional arrays of shape <code>[n_nodes, ]</code> (representing a scalar label for each node), or 2-dimensional arrays of shape <code>[n_nodes, n_labels]</code>.</p> <p>This difference is relevant only when using a <a href="/loaders/#disjointloader"><code>DisjointLoader</code></a> (<a href="/data-modes/#disjoint-mode">read more here</a>).</p> <h3 id="datasets">Datasets</h3> <p>The <code>spektral.data.Dataset</code> container provides some useful functionality to manipulate collections of graphs.</p> <p>Let's load a popular benchmark dataset for graph classification: </p> <pre><code class="language-python">&gt;&gt;&gt; from spektral.datasets import TUDataset &gt;&gt;&gt; dataset = TUDataset('PROTEINS') &gt;&gt;&gt; dataset TUDataset(n_graphs=1113) </code></pre> <p>We can now retrieve individual graphs:</p> <pre><code class="language-python">&gt;&gt;&gt; dataset[0] Graph(n_nodes=42, n_node_features=4, n_edge_features=None, y=[1. 0.]) </code></pre> <p>or shuffle the data:</p> <pre><code class="language-python">&gt;&gt;&gt; np.random.shuffle(dataset) </code></pre> <p>or slice the dataset into sub-datsets: </p> <pre><code class="language-python">&gt;&gt;&gt; dataset[:100] TUDataset(n_graphs=100) </code></pre> <p>Datasets also provide methods for applying <strong>transforms</strong> to each datum: </p> <ul> <li><code>apply(transform)</code> - modifies the dataset in-place, by applying the <code>transform</code> to each graph;</li> <li><code>map(transform)</code> - returns a list obtained by applying the <code>transform</code> to each graph;</li> <li><code>filter(function)</code> - removes from the dataset any graph for which <code>function(graph)</code> is <code>False</code>. This is also an in-place operation.</li> </ul> <p>For example, let's modify our dataset so that we only have graphs with less than 500 nodes:</p> <pre><code class="language-python">&gt;&gt;&gt; dataset.filter(lambda g: g.n_nodes &lt; 500) &gt;&gt;&gt; dataset TUDataset(n_graphs=1111) # removed 2 graphs </code></pre> <p>Now let's apply some transforms to our graphs. For example, we can modify each graph so that the node features also contain the one-hot-encoded degree of the nodes.</p> <p>First, we compute the maximum degree of the dataset, so that we know the size of the one-hot vectors: </p> <pre><code class="language-python">&gt;&gt;&gt; max_degree = dataset.map(lambda g: g.a.sum(-1).max(), reduce=max) &gt;&gt;&gt; max_degree 12 </code></pre> <p>Try to go over the lambda function to see what it does. Also, notice that we passed a reduction function to the method, using the <code>reduce</code> keyword. This will be run on the output list computed by the map.</p> <p>Now we are ready to augment our node features with the one-hot-encoded degree. Spektral has a lot of pre-implemented <code>transforms</code> that we can use: </p> <pre><code class="language-python">&gt;&gt;&gt; from spektral.transforms import Degree &gt;&gt;&gt; dataset.apply(Degree(max_degree)) </code></pre> <p>We can see that it worked because now we have an extra <code>max_degree + 1</code> node features:</p> <pre><code class="language-python">&gt;&gt;&gt; dataset[0] Graph(n_nodes=42, n_node_features=17, n_edge_features=None, y=[1. 0.]) </code></pre> <p>Since we will be using a <code>GCNConv</code> layer in our GNN, we also want to follow the <a href="https://arxiv.org/abs/1609.02907">original paper</a> that introduced this layer and do some extra pre-processing of the adjacency matrix. </p> <p>Since this is a fairly common operation, Spektral has a transform to do it: </p> <pre><code class="language-python">&gt;&gt;&gt; from spektral.transforms import GCNFilter &gt;&gt;&gt; dataset.apply(GCNFilter()) </code></pre> <p>Many layers will require you to do some form of preprocessing. If you don't want to go back to the literature every time, every convolutional layer in Spektral has a <code>preprocess(a)</code> method that you can use to transform the adjacency matrix as needed. <br> Have a look at the handy <a href="/transforms/#layerpreprocess"><code>LayerPreprocess</code> transform</a>.</p> <h3 id="creating-a-gnn">Creating a GNN</h3> <p>Creating GNNs is where Spektral really shines. Since Spektral is designed as an extension of Keras, you can plug any Spektral layer into a Keras <code>Model</code> without modifications.<br /> We just need to use the functional API because GNN layers usually need two or more inputs (so no <code>Sequential</code> models for now). </p> <p>For our first GNN, we will create a simple network that first does a bit of graph convolution, then sums all the nodes together (known as "global pooling"), and finally classifies the result with a dense softmax layer. We will also use dropout for regularization.</p> <p>Let's start by importing the necessary layers:</p> <pre><code class="language-python">from tensorflow.keras.models import Model from tensorflow.keras.layers import Dense, Dropout from spektral.layers import GCNConv, GlobalSumPool </code></pre> <p>Now we can use model subclassing to define our model:</p> <pre><code class="language-python">class MyFirstGNN(Model): def __init__(self, n_hidden, n_labels): super().__init__() self.graph_conv = GCNConv(n_hidden) self.pool = GlobalSumPool() self.dropout = Dropout(0.5) self.dense = Dense(n_labels, 'softmax') def call(self, inputs): out = self.graph_conv(inputs) out = self.dropout(out) out = self.pool(out) out = self.dense(out) return out </code></pre> <p>And that's it!</p> <p>Note how we mixed layers from Spektral and Keras interchangeably: it's all just computation with tensors underneath.</p> <p>This also means that if you want to break free from <code>Graph</code> and <code>Dataset</code> and every other feature of Spektral, you can. </p> <p><strong>Note:</strong> If you don't want to subclass <code>Model</code> to implement your GNN, you can also use the classical declarative style. You just need to pay attention to the <code>Input</code> and leave "node" dimensions unspecified (so <code>None</code> instead of <code>n_nodes</code>).</p> <h3 id="training-the-gnn">Training the GNN</h3> <p>Now we're ready to train the GNN. First, we instantiate and compile our model: </p> <pre><code class="language-python">model = MyFirstGNN(32, dataset.n_labels) model.compile('adam', 'categorical_crossentropy') </code></pre> <p>and we're almost there!</p> <p>However, here's where graphs get in our way. Unlike regular data, like images or sequences, graphs cannot be stretched, cut, or reshaped so that we can fit them into tensors of pre-defined shapes. If a graph has 10 nodes and another one has 4, we have to keep them that way. </p> <p>This means that iterating over a dataset in mini-batches is not trivial and we cannot simply use the <code>model.fit()</code> method of Keras as-is. </p> <p>We have to use a data <code>Loader</code>.</p> <h4 id="loaders">Loaders</h4> <p>Loaders iterate over a graph dataset to create mini-batches. They hide a lot of the complexity behind the process so that you don't need to think about it. You only need to go to <a href="/data-modes">this page</a> and read up on <strong>data modes</strong>, so that you know which loader to use. </p> <p>Each loader has a <code>load()</code> method that returns a data generator that Keras can process. </p> <p>Since we're doing graph-level classification, we can use a <code>BatchLoader</code>. It's a bit slow and memory intensive (a <code>DisjointLoader</code> would have been better), but it lets us simplify the definition of <code>MyFirstGNN</code>. Again, go read about <a href="/data-modes">data modes</a> after this tutorial.</p> <p>Let's create a data loader:</p> <pre><code class="language-python">from spektral.data import BatchLoader loader = BatchLoader(dataset_train, batch_size=32) </code></pre> <p>and we can finally train our GNN!</p> <p>Since loaders are essentially generators, we need to provide the <code>steps_per_epoch</code> keyword to <code>model.fit()</code> and we don't need to specify a batch size:</p> <pre><code class="language-python">model.fit(loader.load(), steps_per_epoch=loader.steps_per_epoch, epochs=10) </code></pre> <p>Done!</p> <h3 id="evaluating-the-gnn">Evaluating the GNN</h3> <p>Evaluating the performance of our model, be it for testing or validation, follows a similar workflow. </p> <p>We create a data loader: </p> <pre><code class="language-python">from spektral.data import BatchLoader loader = BatchLoader(dataset_test, batch_size=32) </code></pre> <p>and feed it to the model by calling <code>load()</code>:</p> <pre><code class="language-python">loss = model.evaluate(loader.load(), steps=loader.steps_per_epoch) print('Test loss: {}'.format(loss)) </code></pre> <h3 id="node-level-learning">Node-level learning</h3> <p>Besides learning to predict labels for the whole graph, like in this tutorial, GNNs are very effective at learning to predict labels for each node. This is called "node-level learning" and we usually do it for datasets with one big graph (think a social network).</p> <p>For example, reproducing the results of the <a href="https://arxiv.org/abs/1609.02907">GCN paper for classifying nodes in a citation network</a> can be done with <code>GCNConv</code> layers, the <code>Citation</code> dataset, and a <code>SingleLoader</code>: check out <a href="https://github.com/danielegrattarola/spektral/blob/master/examples/node_prediction/citation_gcn.py">this example</a>.</p> <p>As a matter of fact, check out <a href="/examples">all the examples</a>.</p> <h3 id="go-create">Go create!</h3> <p>You are now ready to use Spektral to create your own GNNs. </p> <p>If you want to build a GNN for a specific task, chances are that everything you need is already in Spektral. Check out the <a href="https://github.com/danielegrattarola/spektral/tree/master/examples">examples</a> for some ideas and practical tips.</p> <p>Remember to read the <a href="/data-modes">data modes section</a> to learn about representing graphs and creating mini-batches. </p> <p>Make sure to read the documentation, and get in touch <a href="https://github.com/danielegrattarola/spektral">on Github</a> if you have a feature that you want to see implemented. </p> <p>If you want to cite Spektral in your work, refer to our paper: </p> <blockquote> <p><a href="https://arxiv.org/abs/2006.12138">Graph Neural Networks in TensorFlow and Keras with Spektral</a> <br> Daniele Grattarola and Cesare Alippi </p> </blockquote> </div> </div> <footer> <div class="rst-footer-buttons" role="navigation" aria-label="footer navigation"> <a href="../data-modes/" class="btn btn-neutral float-right" title="Data modes">Next <span class="icon icon-circle-arrow-right"></span></a> <a href=".." class="btn btn-neutral" title="Home"><span class="icon icon-circle-arrow-left"></span> Previous</a> </div> <hr/> <div role="contentinfo"> <!-- Copyright etc --> </div> Built with <a href="https://www.mkdocs.org/">MkDocs</a> using a <a href="https://github.com/snide/sphinx_rtd_theme">theme</a> provided by <a href="https://readthedocs.org">Read the Docs</a>. </footer> </div> </div> </section> </div> <div class="rst-versions" role="note" aria-label="versions"> <span class="rst-current-version" data-toggle="rst-current-version"> <span> <a href="https://github.com/danielegrattarola/spektral/" class="fa fa-github" style="color: #fcfcfc"> GitHub</a> </span> <span><a href=".." style="color: #fcfcfc">&laquo; Previous</a></span> <span><a href="../data-modes/" style="color: #fcfcfc">Next &raquo;</a></span> </span> </div> <script>var base_url = '..';</script> <script src="../js/theme_extra.js" defer></script> <script src="../js/theme.js" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML" defer></script> <script src="../js/macros.js" defer></script> <script src="../search/main.js" defer></script> <script defer> window.onload = function () { SphinxRtdTheme.Navigation.enable(true); }; </script> </body> </html>

Pages: 1 2 3 4 5 6 7 8 9 10