{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Input Data & Equivariances\n", "\n", "Molecular graphs and structures (xyz coordinates) are the fundamental features in molecules and materials. As discussed, often these are converted into *molecular descriptors* or some other representation. Why is that? Why can we not work with the data directly? For example, let's say we have a butane molecule and would like to predict its potential energy from its position. You could train a linear model $\\hat{E}$ that predicts energy\n", "\n", "\\begin{equation}\n", " \\hat{E} = \\textbf{X}\\textbf{W} + b\n", "\\end{equation}\n", "\n", "where $\\textbf{X}$ is $14$ (atoms) $\\times$ $3$ (xyz coordinates) matrix containing positions and $\\textbf{W}, b$ are trainable parameters. So far, this is all reasonable. Now what if I translate all the coordinates by -10:\n", "\n", "\\begin{equation}\n", "\\left(\\textbf{X} - 10\\right)\\textbf{W} + b = \\textbf{X} + b - 10 |\\textbf{W}|\n", "\\end{equation}\n", "\n", "We know the energy should not change if we translate all the coordinates equally -- the molecule is not changing conformations. However, our linear regression will change by $- 10 |\\textbf{W}|$. We have accidentally made our model sensitive to the origin of our coordinate system, which is not physical. This is **translational variance** -- our model changes when we translate the coordinates. I made this word up by the way; it's just easier than saying \"non-translational invariant.\" We want our models to be insensitive to this --- **translationally invariant.**\n", "\n", "{admonition} Audience & Objectives\n", "This chapter builds on {doc}gnn and a basic knowledge of linear algebra. After completing this chapter, you should be able to \n", "\n", " * Define invariance vs equivariance\n", " * Define translation, rotation, and permutation equivariance\n", " * Choose features that have desired invariances\n", "\n", "\n", "\n", "Consider another example from our butane molecule. What if we swapped the order of the atoms in our $\\textbf{X}$ matrix. There is no such thing as a \"left\" or \"right\" side of our molecule, so it should not matter. However, you'll see that changing the order of $\\textbf{X}$ changes which weights are multiplied and thus the predicted energy will change. This is called a **permutation variance**. Our model changes if we re-order our inputs, even though from our knowledge of chemistry this should not matter. Similarly, our output energy should not be sensitive to a rotation of the molecular coordinates. Namely they should be permutation invariant and rotationally invariant. \n", "\n", "{margin}\n", "You could teach your model to learn permutation variance of left/right in this example, either by making your training data contain multiple orderings of $\\textbf{X}$ or somehow making your $\\textbf{W}$ be symmetric. You can accomplish this via data augmentation. However, this is inefficient because the number of permutations you want to train the model to ignore is combinatorially large. \n", "\n", "\n", "## Equivariances \n", "\n", "Models that work with molecules should be permutation equivariant if they are outputting a per-atom or per-bond quantity. Permutation equivariant means that if you rearrange the order of atoms, the output changes in the same way. For example, if you're predicting partial charge per atom $\\vec{y} = f(\\textbf{X})$ where $f(\\textbf{X})$ is our model function. If you rearrange $\\textbf{X}$, you expect the $\\vec{y}$ to rearrange to match that. Let's try to state this with an equation. Consider $\\mathcal{P}_{34}$ to be the *permutation operator*. It swaps indices 3 and 4 in axis 0 of a tensor. Then a permutation equivariant model equation should have:\n", "\n", "\n", "\\begin{equation}\n", " f\\left(\\mathcal{P}_{ij}\\left[\\textbf{X}\\right]\\right) = \\mathcal{P}_{ij}\\left[\\vec{y}\\right], \\quad \\forall i,j\n", "\\end{equation}\n", "\n", "where $i,j$ are indices of $\\textbf{X}$ (atom indices). For example, consider $f(\\textbf{X})$ to predict partial charge given a molecule. If the input is water, our input atoms could be arranged HOH and $f(\\textbf{X}) = (0.3, -0.6, 0.3)$. Now if we swap atoms 0 and 1, our input is arranged OHH. If our function is permutation equivariant, then it should output $f\\left(\\mathcal{P}_{01}\\left[\\textbf{X}\\right]\\right) = \\mathcal{P}_{01}\\left[\\vec{y}\\right] = (-0.6, 0.3, 0.3)$.\n", "\n", "You can find a more general form of permutation equivariance in {cite}thomas2018tensor. Now what happens if we output a scalar like energy? Then our permutation operator does nothing:\n", "\n", "\n", "\\begin{equation}\n", " f\\left(\\mathcal{P}_{ij}\\left[\\textbf{X}\\right]\\right) = \\mathcal{P}_{ij}\\left[\\hat{E}\\right] = \\hat{E}\n", "\\end{equation}\n", "\n", "we call this case a **permutation invariance**.\n", "\n", "{margin}\n", "The classic way to introduce equivariance is through group theory. Rather than teach group theory here, I'll use simpler equations that do not quite fully capture the ideas and power of equivariances but get the point across quickly. \n", "\n", "\n", "{note}\n", "An invariance is special type of equivariance. If something is equivariant, you can easily make it invariant (e.g., averaging over your equivariant axes). \n", "\n", "\n", "## Equivariances of Coordinates\n", "\n", "When we work with molecular coordinates as features we need to be a bit more careful in distinguishing between the \"features\" that might be element identity and those which specify the location in space. This kind of data is referred to as **point clouds** in computer science. Let's break our features into $(\\vec{r}, \\vec{x})$ where $\\vec{r}$ is the location of the atom/point and $\\vec{x}$ are its features (e.g., element, charge, spin, etc.). Similarly, we may have labels at each point so that we write our labels as $(\\vec{r}', \\vec{y})$. The label might be direction $\\vec{r}'$ and force magnitude $y$ or perhaps a field at $\\vec{r}'$ with vectors $\\vec{y}$. You may not have $\\vec{r}'$ like if you're predicting energy. It may be that $\\vec{r} = \\vec{r}'$. With this notation, we can write out **translation equivariance** as:\n", "\n", "\\begin{equation}\n", " f\\left(\\vec{r} + \\vec{t}, \\vec{x}\\right) = (\\vec{r}' + \\vec{t}, \\vec{y}), \\quad \\forall\\vec{t}\n", "\\end{equation}\n", "\n", "You can also write this out if you have a matrix of atom positions (like a molecule):\n", "\n", "\\begin{equation}\n", " f\\left(\\textbf{R} + \\vec{t}, \\textbf{X}\\right) = (\\textbf{R}' + \\vec{t}, \\vec{y}_i), \\quad \\forall\\vec{t}\n", "\\end{equation}\n", "\n", "where $\\textbf{R}$ is the matrix of all position vectors $\\vec{r}_i$. In the case that you do not have output coordinates $\\vec{r}'$, then it becomes:\n", "\n", "\\begin{equation}\n", " f\\left(\\textbf{R} + \\vec{t}, \\textbf{X}\\right) = \\vec{y}_i, \\quad \\forall\\vec{t}\n", "\\end{equation}\n", "\n", "which we call **translation invariance.** It's important to note that these equations do not apply to some specific $\\vec{t}$, but any $\\vec{t}$. \n", "\n", "\n", "**Rotational equivariance** can be similarly defined. Consider $\\mathcal{R}$ to be a rotation operator (e.g., a quaternion). Then our rotation equivariance equation is \n", "\n", "\\begin{equation}\n", " f\\left(\\mathcal{R}\\left[\\textbf{R}\\right], \\textbf{X}\\right) = (\\mathcal{R}\\left[\\textbf{R}'\\right], \\vec{y}_i), \\quad \\forall\\mathcal{R}\n", "\\end{equation}\n", "\n", "an example might be again that $(\\textbf{R}', \\vec{y}_i)$ defines some field and our equivariance says that if we rotate our input points, our output points will obey the same rotation. Again, we can also have **rotation invariance** if our model does not output $\\textbf{R}'$." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Constructing Equivariant Models \n", "\n", "There has been some work to unify equivariances into a single \"layer\" type so that you can just pick what equivariances you want like you would a hyperparameter {cite}thomas2018tensor,weiler20183d. The chapter {doc}Equivariant covers these and a recent review may be found in {cite}esteves2020theoretical. A popular implementation is [available here](https://github.com/e3nn/e3nn). A recent application in molecules was in predicting dipole moments, a great example of where rotation invariance would fail because dipole moments should rotate the same way when the molecule rotates{cite}miller2020relevance.\n", "\n", "If you do not need full equivariances, you can often use a data transformation to make the model invariant regardless of its architecture. Some of the common transforms are summarized in the table below and you can find a much more detailed discussion of data transformations in Musil et al. {cite}musil2021physicsinspired. The list below omits [data augmentation](https://en.wikipedia.org/wiki/Data_augmentation) where you try to teach your model these invariances through training --- which is a common and effective strategy in image deep learning. Alternatively, if your invariants are finite (e.g., only 90 degree rotations or you have small permutation invariant sets) you can just apply each possible transformation (rotation/permutation) and average the results to make a quick and easy invariant network{cite}zaheer2017deep. That is sometimes called **test augmentation**.\n", "\n", "\n", "| Data Transformation | Equivariance |\n", "|:-------|--------:|\n", "| Matrix Determinant | Permutation Invariance|\n", "| Eigendecomposition | Permutation Invariance |\n", "| Reduction (sum, mean) | Permutation Invariance|\n", "| Pairwise Vector/Distance | Translation/Rotation Invariance |\n", "| Angles | Translation/Rotation Invariance |\n", "| Atom-centered Symmetry Functions | Rotation/Translation Invariance |\n", "| Trajectory Alignment | Rotation/Translation Invariance | \n", "| Molecular Descriptors | All invariant |\n", "\n", "\n", "### Matrix Determinant\n", "\n", "A matrix determinant is not quite permutation invariant. If you swap two rows in a matrix, it makes the determinant change signs. However, you can easily just square the determinant to remove the sign change. How would you use a determinant in a model? It's just a building block. You could build a matrix of all neighbor features in a molecular graph and then do a determinant to arrive at a permutation invariant output. The determinant has two major disadvantages: (i) it outputs a single number (not that expressive) and (ii) it's really expensive $O(n^3)$. Thus you won't see a determinant too frequently in deep learning.\n", "\n", "### Eigendecomposition\n", "\n", "The eigendecomposition is when you factorize a matrix into its eigenvalues and eigenvectors. The (sorted) eigenvalues of a matrix are permutation invariant. Compared with the determinant, you get more eigenvalues from a matrix than determinants so there is less loss of information. Computing eigenvalues is still expensive at $O(n^3)$ and they are not differentiable. Nevertheless, you will see this strategy in kernel learning where you do not need to propagate derivatives through the kernel. One important application of this is in some of the early work on quantum machine learning {cite}rupp2012fast.\n", "\n", "### Reductions\n", "\n", "An obvious way to remove permutation variance is to just sum over the points or atoms. More generally, you can use any kind of reduction like a mean or product. Like a determinant, this results in a single number or at least removal of an axis. Reductions are fast and differentiable. \n", "\n", "\n", "### Pairwise Distance\n", "\n", "Using pairwise distances or vectors is the standard solution to translation invariance. Rarely are we actually that concerned with translational equivariance. If using pairwise vectors between atoms instead of the xyz coordinates, this naturally removes the choice of origin and thus makes the model translation invariant. If we go further and use pairwise distance instead of vectors, this also removes the effect of rotations giving a rotation invariance. This is fast, differentiable, and the usual approach to add translation and rotation invariance.\n", "\n", "### Angles\n", "\n", "Angles are rotation, translation, and scale invariant. You can define angles any way, but often they are done between consecutive bonds (3 atoms total). Combining angles and pairwise distances (along bonds only) are called **internal coordinates.** \n", "\n", "### Convolutional Layers\n", "\n", "Convolutional layers are well-known to be translationally equivariant. Remember that they work in 3D as well, so they can be an input layer if translational equivariance is desired. However, convolutions work on pixels or voxels in 3D, so you must first bin your coordinates into a voxel grid. See Chew et al. for an example {cite}chew2020fast.\n", "\n", "### Atom-centered Symmetry Functions \n", "\n", "These are a large class of functions described by Behler{cite}behler2011atom that transform from the input coordinate/features $(\\mathbf{R}, \\mathbf{X})$ to a new set of features $\\mathbf{X}'$ that obey rotational and translational symmetry. This makes them translational and rotationally invariant. Behler didn't propose a single function to get these features, but instead explored the choices and theory. Bartók et al. {cite}Bart provided a specific recipe which is called a SOAP descriptor. These are drop-in replacements for $(\\mathbf{R}, \\mathbf{X})$ that are translation and rotation invariant but do not lose much information. They are differentiable, although complex to implement. \n", "\n", "### Trajectory Alignment\n", "\n", "One special case is when your points are part of a time-dependent trajectory. This usually implies that the order of the points does not change. Thus, we do not need to worry about permutation invariance. This is the case when analyzing results from a molecular dynamics trajectory, like a dynamic simulation of a protein. One way to make our data translation and rotation invariant in this case is to align it to some reference coordinates. For example, you could always make the center of mass of the trajectory be at the origin. This will make the points translation invariant, because you always center a new set of points. A natural question is if it must be the center of mass? No, because your points are permutation invariant, you could just pick point 0 as the origin. Then if the points are translated, this will be undone when you move the points such that point 0 is the origin. To be more precise, we always apply a centering function $c\\left(\\mathbf{R}\\right)$ that redefines the origin before processing a set of points. We can also define a rotation function $r\\left(\\mathbf{R}\\right)$ that will be applied where we align to some definite set of three Euler angles (two vectors). See the example below for this. \n", "\n", "### Molecular Descriptors\n", "\n", "Molecular descriptors are the classic way to convert molecules into translation/rotation/permutation invariant features. There exists 3D descriptors as well that can treat structure. They are also called **fingerprints**. Fingerprints is a broad term for converting a molecular structure to a binary sequence. Commonly, each bit indicates the presence or absence of a specific substructure. We won't focus on these in this course because they are untrainable and choosing the correct combination of descriptors is an unsolved problem which has no clear process." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Examples\n", "\n", "Let's demonstrate with some code how to go about creating functions that obey these equivariances. We won't be training these models because training has no effect on equivariances, but you should train your models if you're doing learning 😉\n", "\n", "{margin}\n", "There are ways to make training *enforce* equivariances{cite}ravanbakhsh2017equivariance, but it's a somewhat complex and rarely used strategy. This is different than data augmentation, where we hope it learns these.\n", "\n", "\n", "I'll define my butane molecule as a set of coordinates $\\mathbf{R}_i$ and features $\\mathbf{X}_i$. My features are just one-hot vectors indicating if a particular point is a carbon atom $[1,0]$ or a hydrogen atom $[0,1]$. In our example, we will just be interested in predicting energy. We will not train our models, so the energy will not be accurate. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Running This Notebook\n", "\n", "\n", "Click the    above to launch this page as an interactive Google Colab. See details below on installing packages.\n", "\n", "{tip} My title\n", ":class: dropdown\n", "To install packages, execute this code in a new cell. \n", "\n", "\n", "!pip install dmol-book\n", "\n", "\n", "If you find install problems, you can get the latest working versions of packages used in [this book here](https://github.com/whitead/dmol-book/blob/master/package/requirements.txt)\n", "\n", "" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "hide-cell" ] }, "outputs": [], "source": [ "import numpy as np\n", "\n", "R_i = np.array(\n", " [\n", " -0.5630,\n", " 0.5160,\n", " 0.0071,\n", " 0.5630,\n", " -0.5159,\n", " 0.0071,\n", " -1.9293,\n", " -0.1506,\n", " -0.0071,\n", " 1.9294,\n", " 0.1505,\n", " -0.0071,\n", " -0.4724,\n", " 1.1666,\n", " -0.8706,\n", " -0.4825,\n", " 1.1551,\n", " 0.8940,\n", " 0.4825,\n", " -1.1551,\n", " 0.8940,\n", " 0.4723,\n", " -1.1665,\n", " -0.8706,\n", " -2.0542,\n", " -0.7710,\n", " -0.9003,\n", " -2.0651,\n", " -0.7856,\n", " 0.8742,\n", " -2.7203,\n", " 0.6060,\n", " -0.0058,\n", " 2.0542,\n", " 0.7709,\n", " -0.9003,\n", " 2.7202,\n", " -0.6062,\n", " -0.0059,\n", " 2.0652,\n", " 0.7854,\n", " 0.8743,\n", " ]\n", ").reshape(-1, 3)\n", "N = R_i.shape\n", "X_i = np.zeros((N, 2))\n", "X_i[:4, 0] = 1\n", "X_i[4:, 1] = 1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### No equivariances\n", "\n", "A one-hidden layer dense neural network is an example of a model with no equivariances. To fit our data into this dense layer, we'll just stack the positions and features into a large input tensor and output energy. We'll use a tanh as activation, 16 hidden layer dimension, and our output layer has no activation because we're doing regression to energy. Our weights will always be randomly initialized." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# our 1-hidden layer model\n", "def hidden_model(r, x, w1, w2, b1, b2):\n", " # stack into one large input\n", " i = np.concatenate((r, x), axis=1).flatten()\n", " v = np.tanh(i @ w1 + b1)\n", " v = v @ w2 + b2\n", " return v\n", "\n", "\n", "# initialize our weights\n", "w1 = np.random.normal(size=(N * 5, 16)) # 5 -> 3 xyz + 2 features\n", "b1 = np.random.normal(size=(16,))\n", "w2 = np.random.normal(size=(16,))\n", "b2 = np.random.normal()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let's see what the predicted energy is with our coordinates. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hidden_model(R_i, X_i, w1, w2, b1, b2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This is not trained, so we aren't that concerned about the value. Now let's see if our model is sensitive to translation." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hidden_model(R_i + np.array([1, 2, 3]), X_i, w1, w2, b1, b2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As expected, it is sensitive to translation. I added the vector $(1, 2, 3)$ to all input points and the energy changed. This model is not translation invariant. The choice of $(1,2,3)$ is arbitrary, the model should not change output regardless of the choice of the translation vector if the model is translation invariant. Rotations can be done using the scipy.transformation library which takes care of some of the messiness of working with quaternions, which are the operators that perform rotations. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import scipy.spatial.transform as trans\n", "\n", "# rotate around x coord by 45 degrees\n", "rot = trans.Rotation.from_euler(\"x\", 45, degrees=True)\n", "\n", "print(\"No rotation\", hidden_model(R_i, X_i, w1, w2, b1, b2))\n", "print(\"Rotated\", hidden_model(rot.apply(R_i), X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Our model is affected by the rotation, meaning it is not rotation invariant. Permutation invariance comes from swapping indices. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# swap 0, 1 rows\n", "perm_R_i = np.copy(R_i)\n", "perm_R_i, perm_R_i = R_i, R_i\n", "# we do not need to swap X_i 0,1 because they are identical\n", "\n", "print(\"original\", hidden_model(R_i, X_i, w1, w2, b1, b2))\n", "print(\"permuted\", hidden_model(perm_R_i, X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Our model is not permutation invariant!" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Permutation Invariant\n", "\n", "We will use a reduction to achieve permutation invariance. All that is needed is to ensure that weights are not a function of our atom number axis and then do a reduction (sum) prior to the output layer. Here is the implementation." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# our 1-hidden layer model with perm inv\n", "def hidden_model_pi(r, x, w1, w2, b1, b2):\n", " # stack into one large input\n", " i = np.concatenate((r, x), axis=1)\n", " v = np.tanh(i @ w1 + b1)\n", " # reduction\n", " v = np.sum(v, axis=0)\n", " v = v @ w2 + b2\n", " return v\n", "\n", "\n", "# initialize our weights\n", "w1 = np.random.normal(size=(5, 16)) # note it no lonegr has N!\n", "b1 = np.random.normal(size=(16,))\n", "w2 = np.random.normal(size=(16,))\n", "b2 = np.random.normal()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We made three changes: we kept the atom axis (no more flatten), we removed the atom axis after the hidden layer (sum), and we made our weights not depend on the atom axis. Now let's observe if this is indeed permutation invariant." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(\"original\", hidden_model_pi(R_i, X_i, w1, w2, b1, b2))\n", "print(\"permuted\", hidden_model_pi(perm_R_i, X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "It is!" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Translation Invariant\n", "\n", "The next change we will make is to convert our $N\\times3$ shaped coordinates into $N\\times N\\times 3$ pairwise vectors. This gives us translation invariance. This causes an issue because our distance features went from being $3$ per atom to $N \\times 3$ per atom. Thus we've introduced a dependence on atom number in our distance features and that means it's easy to accidentally break our permutation invariance. We can just sum over this new axis though. Let's see an implementation:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# our 1-hidden layer model with perm inv, trans inv\n", "def hidden_model_pti(r, x, w1, w2, b1, b2):\n", " # compute pairwise distances using broadcasting\n", " d = r - r[:, np.newaxis]\n", " # stack into one large input of N x N x 5\n", " # concatenate doesn't broadcast, so I manually broadcast the Nx2 x matrix\n", " # into N x N x 2\n", " i = np.concatenate((d, np.broadcast_to(x, (d.shape[:-1] + x.shape[-1:]))), axis=-1)\n", " v = np.tanh(i @ w1 + b1)\n", " # reduction over both axes\n", " v = np.sum(v, axis=(0, 1))\n", " v = v @ w2 + b2\n", " return v" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(\"original\", hidden_model_pti(R_i, X_i, w1, w2, b1, b2))\n", "print(\"permuted\", hidden_model_pti(perm_R_i, X_i, w1, w2, b1, b2))\n", "print(\"translated\", hidden_model_pti(R_i + np.array([-2, 3, 4]), X_i, w1, w2, b1, b2))\n", "print(\"rotated\", hidden_model_pti(rot.apply(R_i), X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "It is now translation and permutation invariant. But not yet rotation invariant.\n", "\n", "### Rotation Invariant\n", "\n", "It is a simple change to make it rotationally invariant. We just convert the pairwise vectors into pairwise distances. We'll use squared distances for simplicity. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# our 1-hidden layer model with perm, trans, rot inv.\n", "def hidden_model_ptri(r, x, w1, w2, b1, b2):\n", " # compute pairwise distances using broadcasting\n", " d = r - r[:, np.newaxis]\n", " # x^2 + y^2 + z^2 of pairwise vectors\n", " # keepdims so we get an N x N x 1 output\n", " d2 = np.sum(d**2, axis=-1, keepdims=True)\n", " # stack into one large input of N x N x 3\n", " # concatenate doesn't broadcast, so I manually broadcast the Nx2 x matrix\n", " # into N x N x 2\n", " i = np.concatenate(\n", " (d2, np.broadcast_to(x, (d2.shape[:-1] + x.shape[-1:]))), axis=-1\n", " )\n", " v = np.tanh(i @ w1 + b1)\n", " # reduction over both axes\n", " v = np.sum(v, axis=(0, 1))\n", " v = v @ w2 + b2\n", " return v\n", "\n", "\n", "# initialize our weights\n", "w1 = np.random.normal(size=(3, 16)) # now just 1 dist feature\n", "b1 = np.random.normal(size=(16,))\n", "w2 = np.random.normal(size=(16,))\n", "b2 = np.random.normal()\n", "\n", "# test it\n", "\n", "print(\"original\", hidden_model_ptri(R_i, X_i, w1, w2, b1, b2))\n", "print(\"permuted\", hidden_model_ptri(perm_R_i, X_i, w1, w2, b1, b2))\n", "print(\"translated\", hidden_model_ptri(R_i + np.array([-2, 3, 4]), X_i, w1, w2, b1, b2))\n", "print(\"rotated\", hidden_model_ptri(rot.apply(R_i), X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We have achieved our invariances! Remember that you could use different choices to achieve these invariances. Also, you may not want an invariance sometimes. You may want equivariances or are not concerned at all. For example, if you're always working with one molecule you may never need to switch around atom orders. \n", "\n", "Finally as a sanity check, let's make sure that if we change the coordinates our predicted energy changes. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "R_i = 2.0\n", "print(\"changed\", hidden_model_ptri(R_i, X_i, w1, w2, b1, b2))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Our model is still sensitive to the input features" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Trajectory Alignment Example\n", "\n", "As described above, if you're working with a trajectory there is no requirement to have permutation equivariance. To achieve translation and rotation invariance, we can align to some fixed points that will be present in all structures (like center of mass). As we'll see below, trajectory alignment is fraught with issues like rotation ambiguities, unphysical rotations, and creating fictitious covariances between far away points due to alignment. Internal coordinates (pairwise dist, angles) are almost always better. However, trajectory alignment has good scaling properties. \n", "\n", "The movie below shows an example trajectory that we'll examine. I've made it 2D to make it simple to visualize, but the same principles apply to 3D.\n", "\n", "\n", "
\n", " \n", "
\n", "\n", "Let's start by loading the trajectory and defining/testing our centering function. The trajectory is a tensor that is shape time, point number, xy -> (2048, 12, 2). I'll examine two centering functions: align to center of mass and align to point 0 to see what the two look like." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "hide-cell" ] }, "outputs": [], "source": [ "# new imports\n", "import matplotlib.pyplot as plt\n", "import urllib\n", "import dmol" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "urllib.request.urlretrieve(\n", " \"https://github.com/whitead/dmol-book/raw/master/data/paths.npz\", \"paths.npz\"\n", ")\n", "paths = np.load(\"paths.npz\")[\"arr\"]\n", "# plot the first point\n", "plt.title(\"First Frame\")\n", "plt.plot(paths[0, :, 0], paths[0, :, 1], \"o-\")\n", "plt.xticks([])\n", "plt.yticks([])\n", "plt.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def center_com(paths):\n", " \"\"\"Align paths to COM at each frame\"\"\"\n", " coms = np.mean(paths, axis=-2, keepdims=True)\n", " return paths - coms\n", "\n", "\n", "def center_point(paths):\n", " \"\"\"Align paths to particle 0\"\"\"\n", " return paths - paths[:, :1]\n", "\n", "\n", "ccpaths = center_com(paths)\n", "cppaths = center_point(paths)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To compare, we'll draw a sample of frames on top of one another to see the now translationally invariant coordinates. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axs = plt.subplots(ncols=3, squeeze=True, figsize=(16, 4))\n", "\n", "axs.set_title(\"No Center\")\n", "axs.set_title(\"COM Center\")\n", "axs.set_title(\"Point 0 Center\")\n", "cmap = plt.get_cmap(\"cool\")\n", "for i in range(0, 2048, 16):\n", " axs.plot(paths[i, :, 0], paths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048))\n", " axs.plot(\n", " ccpaths[i, :, 0], ccpaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048)\n", " )\n", " axs.plot(\n", " cppaths[i, :, 0], cppaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048)\n", " )\n", "for i in range(3):\n", " axs[i].set_xticks([])\n", " axs[i].set_yticks([])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The color indicates time. You can see that having no alignment makes the spatial coordinates depend on time implicitly because the points drift over time. Both aligning to COM or point 0 removes this effect. The COM implicitly removes 2 degrees of freedom. Point 0 alignment makes point 0 have no variance (also remove degrees of freedom), which could affect how you design or interpret your model.\n", "\n", "Now we will align by rotation. We need to define a unique rotation. A simple way is to choose 1 (or 2 in 3D) vectors that define our coordinate system directions. For example, we could choose that the vector from point 0 to point 1 defines the positive direction of the x-axis. A more sophisticated way is to find the principal axes of our points and align along these. For 2D, we only need to align to one of them. Again, this implicitly removes a degree of freedom. We will examine both. Computing principle axes requires an eigenvalue decomposition, so it's a bit more numerically intense {cite}foote2000relation. \n", "\n", "{warning}\n", "For all rotation alignment methods, you must have already centered the points. \n", "" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def make_2drot(angle):\n", " mats = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])\n", " # swap so batch axis is first\n", " return np.swapaxes(mats, 0, -1)\n", "\n", "\n", "def align_point(paths):\n", " \"\"\"Align to 0-1 vector assuming 2D data\"\"\"\n", " vecs = paths[:, 0, :] - paths[:, 1, :]\n", " # find angle to rotate so these are pointed towards pos x\n", " cur_angle = np.arctan2(vecs[:, 1], vecs[:, 0])\n", " rot_angle = -cur_angle\n", " rot_mat = make_2drot(rot_angle)\n", " # to mat mult at each frame\n", " return paths @ rot_mat\n", "\n", "\n", "def find_principle_axis(points, naxis=2):\n", " \"\"\"Compute single principle axis for points\"\"\"\n", " inertia = points.T @ points\n", " evals, evecs = np.linalg.eig(inertia)\n", " order = np.argsort(evals)[::-1]\n", " # return largest vectors\n", " return evecs[:, order[:naxis]].T\n", "\n", "\n", "def align_principle(paths, axis_finder=find_principle_axis):\n", " # someone should tell me how to vectorize this in numpy\n", " vecs = [axis_finder(p) for p in paths]\n", " vecs = np.array(vecs)\n", " # find angle to rotate so these are pointed towards pos x\n", " cur_angle = np.arctan2(vecs[:, 0, 1], vecs[:, 0, 0])\n", " cross = np.cross(vecs[:, 0], vecs[:, 1])\n", " rot_angle = -cur_angle - (cross < 0) * np.pi\n", " rot_mat = make_2drot(rot_angle)\n", " # rotate at each frame\n", " rpaths = paths @ rot_mat\n", " return rpaths\n", "\n", "\n", "appaths = align_point(cppaths)\n", "apapaths = align_principle(ccpaths)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "scrolled": true }, "outputs": [], "source": [ "fig, axs = plt.subplots(ncols=3, squeeze=True, figsize=(16, 4))\n", "\n", "axs.set_title(\"No Align\")\n", "axs.set_title(\"COM Align\")\n", "axs.set_title(\"Point 0 Align\")\n", "for i in range(0, 2048, 16):\n", " axs.plot(paths[i, :, 0], paths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048))\n", " axs.plot(\n", " apapaths[i, :, 0], apapaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048)\n", " )\n", " axs.plot(\n", " appaths[i, :, 0], appaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / 2048)\n", " )\n", "for i in range(3):\n", " axs[i].set_xticks([])\n", " axs[i].set_yticks([])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can see how points far away on the chain from 0 have much more variance in the point 0 align, whereas the COM alignment looks better spread. Remember, to apply these methods you must do them to your both your training data and any prediction points. Thus, they should be viewed as part of your neural network. We can now check that rotating has no effect on these. The plots below have the trajectory rotated by 1 radian and you can see that both alignment methods have no change (the lines are overlapping)." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "hide-input" ] }, "outputs": [], "source": [ "rot_paths = paths @ make_2drot(1)\n", "rot_appaths = align_point(center_point(rot_paths))\n", "rot_apapaths = align_principle(center_com(rot_paths))\n", "fig, axs = plt.subplots(ncols=3, squeeze=True, figsize=(16, 4))\n", "\n", "axs.set_title(\"No Align\")\n", "axs.set_title(\"COM Align\")\n", "axs.set_title(\"Point 0 Align\")\n", "for i in range(0, 500, 20):\n", " axs.plot(paths[i, :, 0], paths[i, :, 1], \".-\", alpha=1, color=\"C1\")\n", " axs.plot(apapaths[i, :, 0], apapaths[i, :, 1], \".-\", alpha=1, color=\"C1\")\n", " axs.plot(appaths[i, :, 0], appaths[i, :, 1], \".-\", alpha=1, color=\"C1\")\n", " axs.plot(rot_paths[i, :, 0], rot_paths[i, :, 1], \".-\", alpha=0.2, color=\"C2\")\n", " axs.plot(\n", " rot_apapaths[i, :, 0], rot_apapaths[i, :, 1], \".-\", alpha=0.2, color=\"C2\"\n", " )\n", " axs.plot(rot_appaths[i, :, 0], rot_appaths[i, :, 1], \".-\", alpha=0.2, color=\"C2\")\n", "# plot again to get handles\n", "axs.plot(np.nan, np.nan, \".-\", alpha=1, color=\"C2\", label=\"rotated\")\n", "axs.plot(np.nan, np.nan, \".-\", alpha=1, color=\"C1\", label=\"non-rotated\")\n", "axs.legend()\n", "for i in range(3):\n", " axs[i].set_xticks([])\n", " axs[i].set_yticks([])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now which method is better? Aligning based on arbitrary points is indeed easier, but it creates an unusual new variance in your features. For example, let's see what happens if we make a small perturbation to one conformation. The code is hidden for simplicity. We try changing point 1, then point 0, then point 11 to see the effects of perturbations along the chain. " ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "hide-input" ] }, "outputs": [], "source": [ "NP = 16\n", "\n", "\n", "def perturb_paths(perturb_point):\n", " perturbation = np.zeros_like(paths[:NP])\n", " perturbation[:, perturb_point, 0] = np.linspace(0, 0.2, NP)\n", " test_paths = paths[0:1] - perturbation\n", "\n", " # compute aligned trajs\n", " appaths = align_point(center_point(test_paths))\n", " apapaths = align_principle(center_com(test_paths))\n", "\n", " fig, axs = plt.subplots(ncols=3, squeeze=True, figsize=(16, 4))\n", " axs.set_title(f\"Perturb {perturb_point} - No Align\")\n", " axs.set_title(f\"Perturb {perturb_point} - COM Align\")\n", " axs.set_title(f\"Perturb {perturb_point} - Point 0 Align\")\n", " for i in range(NP):\n", " axs.plot(\n", " test_paths[i, :, 0],\n", " test_paths[i, :, 1],\n", " \".-\",\n", " alpha=0.2,\n", " color=cmap(i / NP),\n", " )\n", " axs.plot(\n", " apapaths[i, :, 0], apapaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / NP)\n", " )\n", " axs.plot(\n", " appaths[i, :, 0], appaths[i, :, 1], \".-\", alpha=0.2, color=cmap(i / NP)\n", " )\n", " for i in range(3):\n", " axs[i].set_xticks([])\n", " axs[i].set_yticks([])\n", "\n", "\n", "perturb_paths(0)\n", "perturb_paths(1)\n", "perturb_paths(11)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As you can see, perturbing one point alters all others after alignment. This makes these transformed features sensitive to noise, especially aligning to point 0 or 1. More importantly, this effect is uneven in the alignment to point 0. This can in-turn make training quite difficult. Of course, neural networks are universal approximators so in theory this should not matter. However, I expect that using the COM alignment approach will give better training because the network will not need to account for this unusual variance structure. \n", "\n", "{margin}\n", "The alignment changes due to small changes in input points is described by the Jacobian of our transform which measures how changes to one input dimension affects all output dimension.\n", "" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "remove-cell" ] }, "outputs": [], "source": [ "from moviepy.editor import VideoClip\n", "from moviepy.video.io.bindings import mplfig_to_npimage\n", "from matplotlib.collections import LineCollection\n", "\n", "pas = [find_principle_axis(p, 2) for p in ccpaths]\n", "coms = np.mean(paths, axis=1)\n", "# convert to plottable lines\n", "pvecs = [\n", " ([c - p[0, 0], c, c + p[1, 0]], [c - p[0, 1], c, c + p[1, 1]])\n", " for c, p in zip(coms, pas)\n", "]\n", "\n", "\n", "def make_segments(data, time_index):\n", " points = np.array([data[time_index, :, 0], data[time_index, :, 1]]).T.reshape(\n", " -1, 1, 2\n", " )\n", " segments = np.concatenate([points[:-1], points[1:]], axis=1)\n", " return segments\n", "\n", "\n", "dpi = 96\n", "fig, axs = plt.subplots(ncols=3, figsize=(1920 / dpi, 1080 / dpi / 2), dpi=dpi)\n", "fps = 60\n", "all_paths = [paths, apapaths, appaths]\n", "fronts = []\n", "for i, p in enumerate(all_paths):\n", " fronts.append(\n", " axs[i].plot(p[-1][:, 0], p[-1][:, 1], \"o\", zorder=0, color=\"C0\", markersize=12)[\n", " 0\n", " ]\n", " )\n", " axs[i].set_xlim(np.min(p[..., 0]), np.max(p[..., 0]))\n", " axs[i].set_ylim(np.min(p[..., 1]), np.max(p[..., 1]))\n", " axs[i].set_xticks([])\n", " axs[i].set_yticks([])\n", "\n", "paplot = axs.plot(*pvecs, \"-\", alpha=1, color=\"C1\", label=\"Principle Axes\")\n", "axs.legend(loc=\"upper left\")\n", "\n", "axs.set_title(\"Trajectory\", fontsize=28)\n", "axs.set_title(\"COM/Princ Axes Aligned\", fontsize=28)\n", "axs.set_title(\"Point 0, Vec 0-1 Aligned\", fontsize=28)\n", "\n", "plt.tight_layout()\n", "\n", "T = paths.shape\n", "line_collections = []\n", "line_segments = [[] for _ in all_paths]\n", "aline_segments = []\n", "for i, p in enumerate(all_paths):\n", " for j in range(T):\n", " seg = make_segments(p, j)\n", " line_segments[i].append(seg)\n", "\n", "\n", "def make_frame(t):\n", " frame = int(fps * t)\n", " if len(line_collections) == 0:\n", " for i, p in enumerate(all_paths):\n", " lc = LineCollection(\n", " line_segments[i][frame], color=\"C0\", norm=plt.Normalize(0, 1), alpha=0.2\n", " )\n", " axs[i].add_collection(lc)\n", " line_collections.append(lc)\n", " j = min(frame, T - 1)\n", " # Set the values used for colormapping\n", " for i, p in enumerate(all_paths):\n", " line_collections[i].set_segments(line_segments[i][j])\n", " fronts[i].set_data(p[j][:, 0], p[j][:, 1])\n", " paplot.set_data(*pvecs[j])\n", " return mplfig_to_npimage(fig)\n", "\n", "\n", "duration = T / fps\n", "animation = VideoClip(make_frame, duration=duration)\n", "animation.write_videofile(filename=\"../_static/images/pas_traj.mp4\", fps=fps)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The final analysis shows a video of the two frames. One thing you'll note are the jumps, when the principle axes swap direction. You can see that the ambiguity caused by these can create artifacts.\n", "\n", "\n", "
\n", " \n", "
\n", "\n", "\n", "{margin}\n", "Some people refer to principle axes finding as a kind of PCA. It is. But when we discuss PCA, we mean identifying the sources of variances across the trajectory, not across points in a single frame. You could do PCA in a single frame and use that to define your trans/rot invariant frame. It's mathematically equivalent to principle axes finding.\n", "\n", "\n", "### Using Unsupervised Methods for Alignment\n", "\n", "There are additional methods for aligning trajectories. You could define one frame as the \"reference\" and find the translation and rotations that best align with that reference. This could give some interpretability to your rotation and translation alignment. A tempting option is to use dimensionality reduction (like PCA), but these are not rotation invariant. This is confusing at first, because remember PCA should remove translation and rotation. It removes it though from the training data and not an arbitrary frame because it examines motion along a trajectory. You can easily see this getting principle components and then trying to align a new frame to them and a rotated version of the new frame. You'll get different results. Another important consideration is if the unsupervised method can handle new data. Manifold embeddings do not provide a linear transform that can handle new data for inference. Manifold embeddings are also not necessarily rotation invariant or equivariant. " ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "remove-cell" ] }, "outputs": [], "source": [ "def pca(paths, keep=3):\n", " # get covariance matrix along dimensions of system\n", " x = paths.reshape(paths.shape, -1)\n", " # Get eigs\n", " cmat = np.cov(x.T)\n", " evals, evecs = np.linalg.eigh(cmat)\n", " # sort by size\n", " order = np.argsort(evals)[::-1]\n", " # only keep a few\n", " return evecs[:, order[-keep:]], evals[order[-keep:]]\n", "\n", "\n", "def align_pca_naive(paths, a):\n", " # Remove degrees of freedom from each conformation\n", " offsets = paths.reshape(-1, len(a)) @ a\n", " print(offsets.shape)\n", " trans = offsets @ a.T\n", " new_paths = paths - trans.reshape(paths.shape)\n", " return new_paths\n", "\n", "\n", "def align_pca(paths, a):\n", " def get_pca_axis(p, a=a):\n", " return np.sum(a.reshape(-1, 2) * p, axis=1)\n", "\n", " return align_principle(paths, get_pca_axis)\n", "\n", "\n", "a, e = pca(paths, 3)\n", "print(e)\n", "pcapaths = align_pca_naive(paths, a)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "tags": [ "remove-cell" ] }, "outputs": [], "source": [ "pcapaths2 = align_pca_naive(paths @ make_2drot(1), a)\n", "print(pcapaths2.shape)\n", "for i in range(0, 2048, 256):\n", " plt.plot(pcapaths[i, :, 0], pcapaths[i, :, 1], \".-\", alpha=1, color=\"C0\", zorder=1)\n", " plt.plot(\n", " pcapaths2[i, :, 0], pcapaths2[i, :, 1], \".-\", alpha=1, color=\"C1\", zorder=0\n", " )\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Distance Features\n", "\n", "One more topic on parsing data is treating distances. As we saw above, pairwise distance is a wise transformation because it is translation and rotation invariant. However, we may want to sometimes transform this further. One obvious choice is to use $1 / r$ as the input to our neural network. This is because most properties of atoms (and thus molecules) are affected by their closest nearby atoms. An oxygen nearby a carbon is much more important than an oxygen that is 100 nanometers away. Choosing $1 / r$ as an input makes it easier for a neural network to train because it encodes this physical insight about local interactions being the most important. Of course, a neural network could learn to turn $r$ into $1/r$ because they are universal approximators. Yet this approach means we do not need to waste training data and weights on learning to change $r$ into $1 / r$. *This approach will not work on non-local or mildly-non-local effects like electrostatics or multi-scale phenomena.*\n", "\n", "Another detail on distances is that we often want to \"featurize\" them; we'd like to go from one a single number like $r = 3.2$ to a vector of reals. Why? Well that's just how neural networks learn. Hidden-layers need to have more than 1 dimension to be expressive enough to model any function. This seems like an obvious point though: if you used $r$ in a neural network it would obviously get larger as it goes through hidden layers. However, there are a few \"standard\" ways that people like to do this process. There are valid reasons, like making it smoothly differentiable, that you might choose one of these special \"featurizing\" functions.\n", "\n", "### Repeating\n", "\n", "The first approach is to just repeat $r$ up to the desired hidden dimension. This is representable as a dense neural network with no activation.\n", "\n", "### Binning\n", "\n", "As explored in Gilmer et al. {cite}gilmer2017neural and others, you can bin your distances to be a one-hot vector. Essentially, you histogram $r$ into fixed bins so that you only have one bin being \"hot\". Each bin will represent a segment of positions (e.g., 4.5-5.0). This has discontinuous derivatives with respect to distance and is rarely used.\n", "\n", "### Radial Basis Functions\n", "\n", "Radial basis functions are a commonly used procedure for converting a scalar into a fixed number of features and were first used in interpolation{cite}powell1977restart. Radial basis functions use the following equation:\n", "\n", "\\begin{equation}\n", " e_i = \\exp\\left[{-\\left(r - d_i\\right)^2 / w}\\right]\n", "\\end{equation}\n", "\n", "where $d_i$ is an equally spaced vector of distances (e.g., $[1.5, 3.0, 4.5, 6.0]$) and $w$ is a trainable (or hyper) parameter. This computes a Gaussian kernel between $r$ and all distances $d_i$. What is nice about this expression is the smooth well-behaved derivatives with respect to $r$ and lack of trainable parameters. You can (almost) represent this with a dense layer and a softmax activation.\n", "\n", "\n", "### Sub NN\n", "\n", "Another strategy used in Gilmer et al. {cite}gilmer2017neural is to just put your distances through a series of dense layers to get features. For example, if you're going to use the distance in a graph neural network you could run it through three dense layers first to get a larger feature dimension. Remember that repeating and radial basis functions are equivalent to dense layers (assuming correct activation choice), so this strategy can be a simple solution to the above choice." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Chapter Summary\n", "\n", "* Machine learning models that work with molecules must be permutation invariant, such that if the atoms are rearranged, the output will not change. \n", "* Translational invariance of molecular coordinates is when the coordinates are shifted and the resulting output does not change. \n", "* Rotational invariance is similar, except the molecular coordinates are rotated.\n", "* Data augmentation is when you try to teach your model the various types of equivariances by rotating and translating your training data to create additional examples.\n", "* There are various techniques, such as eigendecomposition or pairwise distance to make molecular coordinates invariant.\n", "* A one-hidden layer dense neural network is an example of a model with no equivariances. \n", "* You can try alignment for trajectories, where each training example has the same ordering and number of atoms." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Cited References\n", "\n", "{bibliography}\n", ":style: unsrtalpha\n", ":filter: docname in docnames\n", "" ] } ], "metadata": { "celltoolbar": "Tags", "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.12" } }, "nbformat": 4, "nbformat_minor": 4 }