{"id":1777,"date":"2022-08-15T17:08:15","date_gmt":"2022-08-15T16:08:15","guid":{"rendered":"https:\/\/wp.coventry.domains\/e2edu\/?page_id=1777"},"modified":"2022-08-24T15:06:44","modified_gmt":"2022-08-24T14:06:44","slug":"pose-sequence-generation-rnnmdn","status":"publish","type":"page","link":"https:\/\/wp.coventry.domains\/e2edu\/pose-sequence-generation-rnnmdn\/","title":{"rendered":"Pose Sequence Generation (RNN+MDN)"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The following tutorial introduces the use of an autoregressive model for generating sequences of dance poses. This model extends the model introduced in an <a rel=\"noreferrer noopener\" href=\"https:\/\/wp.coventry.domains\/e2edu\/pose-sequence-generation\/\" target=\"_blank\">earlier article<\/a> in that it combines a long short term memory (LSTM) network with a mixture density network (MDN).  A MDN outputs the parameters for multiple gaussian distributions. In the autoregressive model employed here, each gaussian distribution can be sampled from to obtain one candidate pose for continuing a pose sequence. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This tutorial forms part of a series of tutorials on using PyTorch to create and train generative deep learning models. The code for these tutorials is available <a rel=\"noreferrer noopener\" href=\"https:\/\/github.coventry.ac.uk\/ad5041\/PyTorch_ML_Tutorials\" target=\"_blank\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After 1000 epochs of training, the model generates pose sequences that look like this when rendered as skeleton animations.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo wp-embed-aspect-18-9 wp-has-aspect-ratio\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Original Versus Predicted Dance Pose Sequence\" src=\"https:\/\/player.vimeo.com\/video\/739704966?h=f28407de55&amp;dnt=1&amp;app_id=122963\" width=\"788\" height=\"394\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen><\/iframe>\n<\/div><figcaption>Original Versus Predicted Dance Pose Sequence. The predicted dance sequence has been created by the autoregressive model described in this article. The model has been trained for 1000 epochs on the following motion capture recording: MUR_Fluidity_Body_Take1_mb_proc_rh.p<br><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Mixture Density Networks<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The conventional autoregressive model employed in the previous article predicts the continuation of a sequence as a deterministic output. This is problematic since many sequences are probabilistic in nature. Accordingly, their predicted continuation involves uncertainty. MDNs allow to take this into account by treating each feature of the predicted output as a random variable. They do so by outputting for every feature the parameters for a probability distribution. An actual value for the feature can then be obtained by sampling from the probability distribution. The  term &#8220;Mixture Density&#8221; refers to the fact that a complicated probability distribution can be created by combining (&#8220;mixing&#8221;) multiple simple probability distributions. The simple distributions are typically Gaussian distributions. The MDN generates for each feature a predefined number of Gaussian distributions for each of which it outputs its parameters (mean and standard deviation) and mixing coefficient. Training involves approximating the true probability distributions of the features by tuning the Gaussian parameters and mixing coefficients. <\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"448\" src=\"https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN-1024x448.png\" alt=\"\" class=\"wp-image-1791\" srcset=\"https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN-1024x448.png 1024w, https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN-300x131.png 300w, https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN-768x336.png 768w, https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN-788x345.png 788w, https:\/\/wp.coventry.domains\/e2edu\/wp-content\/uploads\/sites\/3486\/2022\/08\/MDN.png 1400w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption>Schematic Depiction of a Mixture Density Network. In this example, the network outputs parameters and mixing coefficients for two Gaussian distributions, which, when combined, produce the mixture distribution shown on the right. \u00a9Oliver Borchers<\/figcaption><\/figure>\n<\/div>\n\n\n<p class=\"wp-block-paragraph\">From an application point of view, the addition of an MDN to an autoregressive model leads to a predicted sequence continuation that is less likely to stagnate after a few iterations than is the case with a conventional autoregressive model.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Create Model<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This article skips an explanation of the code that deals with importing python modules and motion capture data and creating datasets, since all these steps are identical with the ones that have been explained <a rel=\"noreferrer noopener\" href=\"https:\/\/wp.coventry.domains\/e2edu\/pose-sequence-generation\/\" target=\"_blank\">previously<\/a>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The model that is created consists of two networks. A first network that consists of LSTM layers and a second network that operates as MDN. The first network takes as input a sequence of poses. Its output is then used as input for the MDN. The MDN outputs three tensors, one for each of the following parameters of a mixture of Gaussian distributions: mean values (mu), standard deviations (sigma), and mixture coefficients (alpha). <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The MDN is implemented as a conventional artificial neural network. In this network, all layers except the last one are shared when creating the three output tensors. The last layer is split into three separate layers, one for each parameter of the distributions. The last layer that outputs the mixing coefficients employs an activation function, the others don&#8217;t. This activation function is Softmax to ensure that all mixing coefficients sum up to one. Another difference between the three last layers is their number of output dimensions. The layers that output mean and sigma values do so for each pose dimension and Gaussian distribution. The layer that outputs the mixing coefficients only outputs one value per Gaussian distribution. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The model is implemented by subclassing the nn.Module class. The class definition is as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class AutoRegressor(nn.Module):\n    def __init__(self, pose_dim, rnn_layer_count, rnn_layer_size, dense_layer_sizes, mix_count):\n        super(AutoRegressor, self).__init__()\n        \n        self.pose_dim = pose_dim\n        self.rnn_layer_count = rnn_layer_count\n        self.rnn_layer_size = rnn_layer_size\n        self.dense_layer_sizes = dense_layer_sizes\n        self.mix_count = mix_count\n        \n        # create recurrent layers\n        rnn_layers = &#091;]\n        \n        rnn_layers.append((\"autoreg_rnn_0\", nn.LSTM(self.pose_dim, self.rnn_layer_size, self.rnn_layer_count, batch_first=True)))\n        self.rnn_layers = nn.Sequential(OrderedDict(rnn_layers))\n        \n        # create dense layers\n        dense_layers = &#091;]\n        dense_layer_count = len(self.dense_layer_sizes)\n        \n        if dense_layer_count &gt; 0:\n            dense_layers.append((\"autoreg_dense_0\", nn.Linear(self.rnn_layer_size, self.dense_layer_sizes&#091;0])))\n            dense_layers.append((\"autoreg_dense_relu_0\", nn.ReLU()))\n\n            for layer_index in range(1, dense_layer_count):\n                dense_layers.append( (\"autoreg_dense_{}\".format( layer_index ), nn.Linear( self.dense_layer_sizes&#091; layer_index - 1 ], self.dense_layer_sizes&#091; layer_index ] ) ) )\n                dense_layers.append( (\"autoreg_dense_relu_{}\".format( layer_index ), nn.ReLU() ) )\n        \n            dense_layers.append( (\"autoregr_dense_{}\".format( len(self.dense_layer_sizes) ), nn.Linear( self.dense_layer_sizes&#091;-1], self.pose_dim) ) )\n        else:\n            dense_layers.append((\"autoreg_dense_0\", nn.Linear(self.rnn_layer_size, self.pose_dim)))\n        \n        self.dense_layers = nn.Sequential(OrderedDict(dense_layers))\n        \n        # mdn mu layers\n        mdn_mu_layers = &#091;]\n        mdn_mu_layers.append((\"autoreg_mdn_mu_dense\", nn.Linear(self.pose_dim, self.pose_dim * self.mix_count)))\n        self.mdn_mu_layers = nn.Sequential(OrderedDict(mdn_mu_layers))\n        \n        # mdn sigma layers\n        mdn_sigma_layers = &#091;]\n        mdn_sigma_layers.append((\"autoreg_mdn_sigma_dense\", nn.Linear(self.pose_dim, self.pose_dim * self.mix_count)))\n        self.mdn_sigma_layers = nn.Sequential(OrderedDict(mdn_sigma_layers))\n        \n        # mdn alpha layers\n        mdn_alpha_layers = &#091;]\n        mdn_alpha_layers.append((\"autoreg_mdn_alpha_dense\", nn.Linear(self.pose_dim, self.mix_count)))\n        mdn_alpha_layers.append((\"autoreg_mdn_alpha_softmax\", nn.Softmax(dim=1)))\n        self.mdn_alpha_layers = nn.Sequential(OrderedDict(mdn_alpha_layers))\n    \n    def forward(self, x):\n        #print(\"x 1 \", x.shape)\n        x, (_, _) = self.rnn_layers(x)\n        #print(\"x 2 \", x.shape)\n        x = x&#091;:, -1, :] # only last time step \n        #print(\"x 3 \", x.shape)\n        x = self.dense_layers(x)\n        #print(\"x \", x.shape)\n        mu = self.mdn_mu_layers(x)\n        mu = mu.view((-1, self.mix_count, self.pose_dim))\n        #print(\"mus \", mus.shape)\n        sigma = self.mdn_sigma_layers(x)\n        sigma = torch.exp(sigma)\n        sigma = sigma.view((-1, self.mix_count, self.pose_dim))\n        #print(\"sigmas \", sigmas.shape)\n        alpha = self.mdn_alpha_layers(x)\n        alpha = alpha.view((-1, self.mix_count))\n        #print(\"alphas \", alphas.shape)\n        return mu, sigma, alpha<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The constructor of the model class takes the following arguments: the dimension of a single pose, the number of recurrent layers to create, the number of units in each LSTM layer, a list of units per layer in the artificial neural network that follows the LSTM network, and the number of Gaussian distributions. The model class can be instantiated as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ar_rnn_layer_count = 2\nar_rnn_layer_size = 512\nar_dense_layer_sizes = &#091; ]\nar_mdn_mix_count = 4\n\nautoreg = AutoRegressor(pose_dim, ar_rnn_layer_count, ar_rnn_layer_size, ar_dense_layer_sizes, ar_mdn_mix_count).to(device)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The shapes of the input and output tensors for this model are as follows:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>input tensor: batch_size x sequence_length x pose_dim<\/li><li>output tensor mu: batch_size x pose_dim * mixture count<\/li><li>output tensor sigma: batch_size x pose_dim * mixture count<\/li><li>output tensor alpha: batch_size x mixture count<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Optimiser and Loss Functions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The loss function that has been previously used to calculate the reconstruction error between the target and predicted joint rotations is replaced here by a new loss function. This function is named &#8220;mdn_loss&#8221; and calculates the loss based on the negative log likelihood of obtaining the correct target features when sampling from the current mixture distribution. The negative log likelihood is a common measure for calculating the error of probabilistic models. The reason why a negative log likelihood is used instead of a direct probability is as follows: logarithms are mathematically easier to deal with since multiplication become additions and divisions become subtractions, and since gradient descent minimises a loss, the log likelihood has to be made negative. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The &#8220;mdn_loss&#8221; function is implemented as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def mdn_loss(y, mu, sigma, alpha):\n    \"\"\"Calculates the error, given the MoG parameters and the target\n    The loss is the negative log likelihood of the data given the MoG\n    parameters.\n    \"\"\"\n    normal = Normal(mu, sigma+1e-7) # avoid a standard deviation of zero\n    loglik = normal.log_prob(y.expand_as(sigma))\n\n    loglik = torch.mean(loglik, dim=2)\n    loss = -torch.logsumexp(torch.log(alpha) + loglik, dim=1)\n    \n    return torch.mean(loss)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The overall loss function is now as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def ar_loss(y, mu, sigma, alpha):\n\n    _norm_loss = ar_norm_loss(mu)\n    _mdn_loss = mdn_loss(y, mu, sigma, alpha)\n    \n    #print(\"_mdn_loss \", _mdn_loss)\n    \n    _total_loss = 0.0\n    _total_loss += _norm_loss * ar_norm_loss_scale\n    _total_loss += _mdn_loss * ar_mdn_loss_scale\n    \n    return _total_loss, _norm_loss, _mdn_loss<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Training and Testing Functions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The functions for training and testing differ minimally from their implementations in the previous article:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def ar_train_step(pose_sequences, target_poses):\n\n    mu, sigma, alpha = autoreg(pose_sequences)\n\n    _ar_loss, _ar_norm_loss, _ar_mdn_loss = ar_loss(target_poses, mu, sigma, alpha) \n\n    #print(\"_ae_pos_loss \", _ae_pos_loss)\n    \n    # Backpropagation\n    ar_optimizer.zero_grad()\n    _ar_loss.backward()\n\n    ar_optimizer.step()\n    \n    return _ar_loss, _ar_norm_loss, _ar_mdn_loss\n\ndef ar_test_step(pose_sequences, target_poses):\n    \n    autoreg.eval()\n \n    with torch.no_grad():\n        mu, sigma, alpha = autoreg(pose_sequences)\n        _ar_loss, _ar_norm_loss, _ar_mdn_loss = ar_loss(target_poses, mu, sigma, alpha) \n    \n    autoreg.train()\n    \n    return _ar_loss, _ar_norm_loss, _ar_mdn_loss\n\ndef train(train_dataloader, test_dataloader, epochs):\n    \n    loss_history = {}\n    loss_history&#091;\"ar train\"] = &#091;]\n    loss_history&#091;\"ar test\"] = &#091;]\n    loss_history&#091;\"ar norm\"] = &#091;]\n    loss_history&#091;\"ar mdn\"] = &#091;]\n\n    for epoch in range(epochs):\n        start = time.time()\n        \n        ar_train_loss_per_epoch = &#091;]\n        ar_norm_loss_per_epoch = &#091;]\n        ar_mdn_loss_per_epoch = &#091;]\n\n        for train_batch in train_dataloader:\n            input_pose_sequences = train_batch&#091;0].to(device)\n            target_poses = train_batch&#091;1].to(device)\n            \n            _ar_loss, _ar_norm_loss, _ar_mdn_loss = ar_train_step(input_pose_sequences, target_poses)\n            \n            _ar_loss = _ar_loss.detach().cpu().numpy()\n            _ar_norm_loss = _ar_norm_loss.detach().cpu().numpy()\n            _ar_mdn_loss = _ar_mdn_loss.detach().cpu().numpy()\n            \n            ar_train_loss_per_epoch.append(_ar_loss)\n            ar_norm_loss_per_epoch.append(_ar_norm_loss)\n            ar_mdn_loss_per_epoch.append(_ar_mdn_loss)\n\n        ar_train_loss_per_epoch = np.mean(np.array(ar_train_loss_per_epoch))\n        ar_norm_loss_per_epoch = np.mean(np.array(ar_norm_loss_per_epoch))\n        ar_mdn_loss_per_epoch = np.mean(np.array(ar_mdn_loss_per_epoch))\n\n        ar_test_loss_per_epoch = &#091;]\n        \n        for test_batch in test_dataloader:\n            input_pose_sequences = train_batch&#091;0].to(device)\n            target_poses = train_batch&#091;1].to(device)\n            \n            _ar_loss, _, _ = ar_train_step(input_pose_sequences, target_poses)\n            \n            _ar_loss = _ar_loss.detach().cpu().numpy()\n            \n            ar_test_loss_per_epoch.append(_ar_loss)\n        \n        ar_test_loss_per_epoch = np.mean(np.array(ar_test_loss_per_epoch))\n        \n        if epoch % model_save_interval == 0 and save_weights == True:\n            autoreg.save_weights(\"results\/weights\/autoreg_weights_epoch_{}\".format(epoch))\n        \n        loss_history&#091;\"ar train\"].append(ar_train_loss_per_epoch)\n        loss_history&#091;\"ar test\"].append(ar_test_loss_per_epoch)\n        loss_history&#091;\"ar norm\"].append(ar_norm_loss_per_epoch)\n        loss_history&#091;\"ar mdn\"].append(ar_mdn_loss_per_epoch)\n        \n        print ('epoch {} : ar train: {:01.4f} ar test: {:01.4f} norm {:01.4f} mdn {:01.4f} time {:01.2f}'.format(epoch + 1, ar_train_loss_per_epoch, ar_test_loss_per_epoch, ar_norm_loss_per_epoch, ar_mdn_loss_per_epoch, time.time()-start))\n    \n    return loss_history<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The train function is again called as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>epochs = 100\nmodel_save_interval = 100\nsave_weights = False\n\n# fit model\nloss_history = train(train_dataloader, test_dataloader, epochs)<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Generate and Visualise Predicted Poses Sequences<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The code for saving the training history and model parameters is identical with the previous example and skipped here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What is new when working with a probabilistic model is the need to sample from the generated probability distributions in order to obtain actual feature values that represent a pose. Included here are two functions that represent two possibilities for conducting a sampling. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A function named &#8220;sample&#8221; only uses sampling to select one Gaussian distribution. This is done by creating a &#8220;Categorical&#8221; distribution from the mixing values and then sampling from it. Once a Gaussian distribution has been chosen, its mean is used as actual feature values. <\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def sample(mu, sigma, alpha):\n    alpha_i = Categorical(alpha).sample()\n    return mu&#091;:,alpha_i,:]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A function named &#8220;sample2&#8221; uses sampling for selecting a Gaussian distribution and subsequently samples the selected distribution to obtain actual feature values. <\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def sample2(mu, sigma, alpha):\n    alpha_i = Categorical(alpha).sample()\n    normal = Normal(mu&#091;:,alpha_i,:], sigma&#091;:,alpha_i,:]+1e-7)\n    return normal.sample()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When generating pose sequences for subsequent rendering as skeleton animations, only the first sample method is employed. This is because the second sample method causes the resulting animation to jitter in the rendering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The modified version of the function named &#8220;create_pred_sequence_anim&#8221; which creates animations from predicted pose sequences is as follows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def create_pred_sequence_anim(start_pose_index, pose_count, file_name):\n    autoreg.eval()\n    \n    start_pose_index = max(start_pose_index, sequence_length)\n    pose_count = min(pose_count, pose_sequence_length - start_pose_index)\n    \n    start_seq = pose_sequence&#091;start_pose_index - sequence_length:start_pose_index, :]\n    start_seq = torch.from_numpy(start_seq).to(device)\n    \n    next_seq = start_seq\n    \n    pred_poses = &#091;]\n    \n    for i in range(pose_count):\n        with torch.no_grad():\n            mu, sigma, alpha = autoreg(torch.unsqueeze(next_seq, axis=0))\n            pred_pose = sample(mu, sigma, alpha)\n    \n        # normalize pred pose\n        pred_pose = torch.squeeze(pred_pose)\n        pred_pose = pred_pose.view((-1, 4))\n        pred_pose = nn.functional.normalize(pred_pose, p=2, dim=1)\n        pred_pose = pred_pose.view((1, pose_dim))\n\n        pred_poses.append(pred_pose)\n    \n        #print(\"next_seq s \", next_seq.shape)\n        #print(\"pred_pose s \", pred_pose.shape)\n\n        next_seq = torch.cat(&#091;next_seq&#091;1:,:], pred_pose], axis=0)\n    \n        print(\"predict time step \", i)\n\n    pred_poses = torch.cat(pred_poses, dim=0)\n    pred_poses = pred_poses.view((1, pose_count, joint_count, joint_dim))\n\n\n    zero_trajectory = torch.tensor(np.zeros((1, pose_count, 3), dtype=np.float32))\n    zero_trajectory = zero_trajectory.to(device)\n    \n    skel_poses = skeleton.forward_kinematics(pred_poses, zero_trajectory)\n    \n    skel_poses = skel_poses.detach().cpu().numpy()\n    skel_poses = np.squeeze(skel_poses)\n    \n    view_min, view_max = utils.get_equal_mix_max_positions(skel_poses)\n    pose_images = poseRenderer.create_pose_images(skel_poses, view_min, view_max, view_ele, view_azi, view_line_width, view_size, view_size)\n\n    pose_images&#091;0].save(file_name, save_all=True, append_images=pose_images&#091;1:], optimize=False, duration=33.0, loop=0) \n\n    autoreg.train()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Calling this function works the same as before. <\/p>\n","protected":false},"excerpt":{"rendered":"<p>Summary The following tutorial introduces the use of an autoregressive model for generating sequences of dance poses. This model extends the model introduced in an earlier article in that it combines a long short term memory (LSTM) network with a mixture density network (MDN). A MDN outputs the parameters for multiple gaussian distributions. In the [&hellip;]<\/p>\n","protected":false},"author":2154,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_monsterinsights_skip_tracking":false,"_monsterinsights_sitenote_active":false,"_monsterinsights_sitenote_note":"","_monsterinsights_sitenote_category":0,"_coblocks_attr":"","_coblocks_dimensions":"","_coblocks_responsive_height":"","_coblocks_accordion_ie_support":"","footnotes":""},"class_list":["post-1777","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/pages\/1777","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/users\/2154"}],"replies":[{"embeddable":true,"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/comments?post=1777"}],"version-history":[{"count":31,"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/pages\/1777\/revisions"}],"predecessor-version":[{"id":3208,"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/pages\/1777\/revisions\/3208"}],"wp:attachment":[{"href":"https:\/\/wp.coventry.domains\/e2edu\/wp-json\/wp\/v2\/media?parent=1777"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}