{"id":10192,"date":"2021-01-11T23:22:00","date_gmt":"2021-01-11T20:22:00","guid":{"rendered":"https:\/\/handoli.com\/index.php\/2021\/01\/11\/the-sefr-classifier\/"},"modified":"2021-01-11T23:22:00","modified_gmt":"2021-01-11T20:22:00","slug":"the-sefr-classifier","status":"publish","type":"post","link":"https:\/\/handoli.com\/index.php\/2021\/01\/11\/the-sefr-classifier\/","title":{"rendered":"The SEFR classifier"},"content":{"rendered":"<p>If you thought machine learning on mobile is already a bit of a stretch, how about machine learning on microcontrollers?<\/p>\n<p>Take the <a href=\"https:\/\/store.arduino.cc\/usa\/arduino-uno-rev3\">Arduino Uno<\/a>. This has an 8-bit CPU running at 16 MHz, no real floating point support \u2014 it does not even have a division instruction \u2014 and only has 2KB of RAM. Is it even possible to do machine learning on these devices?<\/p>\n<p><strong>The answer is yes<\/strong>, but you need to be smart about the algorithm you\u2019re using.<\/p>\n<p>A <a href=\"https:\/\/github.com\/hollance\/TinyML-HelloWorld-ArduinoUno\">simple neural network<\/a> might work but a full-blown convnet will be way too slow without specialized hardware. My interest is in smart algorithms such as <a href=\"https:\/\/www.microsoft.com\/en-us\/research\/uploads\/prod\/2017\/06\/kumar17.pdf\">Bonsai<\/a> or SEFR, the topic of today\u2019s blog post.<\/p>\n<p>SEFR, which stands for <strong>Scalable, Efficient, and Fast classifieR<\/strong>, was first published last year <a href=\"https:\/\/arxiv.org\/abs\/2006.04620\">in a paper<\/a> from three Iranian researchers. Just for fun, I implemented the algorithm in Swift so we can run it on iOS or Mac devices.<\/p>\n<p>You can find the <a href=\"https:\/\/github.com\/hollance\/sefr-swift\">source code for this blog post<\/a> on GitHub, as usual.<\/p>\n<h2>How it works<\/h2>\n<p>The paper is a quick read, so definitely give that a go if you\u2019re interested in reading papers. As is usual for these kinds of papers, the algorithm is described using math. As a programmer, I find algorithms easier to understand when they\u2019re expressed in (pseudo)code. Fortunately, the SEFR algorithm is very straightforward.<\/p>\n<p>SEFR is a <strong>binary classifier<\/strong>, meaning it can tell apart two classes: the positive class and the negative class. Usually the positive class is the thing you\u2019re looking for, and the negative class is the absence of that thing.<\/p>\n<p>Binary classification is certainly important, but many interesting classification tasks involve multiple classes. You can extend SEFR to do multiclass classification as well, basically by running it multiple times. I\u2019ll show how to do this later on.<\/p>\n<p>Since SEFR is a <strong>supervised learning<\/strong> algorithm, you\u2019ll need to train it on a set of examples and their labels. The labels are 0 or 1, for the negative class and positive class, respectively. The examples themselves consist of numerical data.<\/p>\n<p>To illustrate this, here is a portion of a possible dataset:<\/p>\n<pre><code class=\"language-swift\">let examples: [[Float]] = [\n  [6.4, 3.1, 5.5, 1.8],\n  [5.4, 3.0, 4.5, 1.5],\n  [5.2, 3.5, 1.5, 0.2],\n  [6.1, 3.0, 4.9, 1.8],\n  [6.4, 2.8, 5.6, 2.2],\n  [5.2, 2.7, 3.9, 1.4],\n  [5.7, 3.8, 1.7, 0.3],\n  [6.0, 2.7, 5.1, 1.6],\n  ...\n]\n\nlet labels: [Int] = [\n  1, 1, 0, 1, 0, 1, 1, 1, ...\n]\n<\/code><\/pre>\n<p>Here, each example uses four <strong>features<\/strong>. In other words, each example is made up of four floating-point numbers.<\/p>\n<p>Usually <code>examples<\/code> will be a two-dimensional array of shape N\u00d7M where N is the number of examples and M is the number of features. The <code>labels<\/code> array will be of length N and contains integer values.<\/p>\n<p>The training set does not have to be randomly shuffled.<\/p>\n<div class=\"note\">\n<p><strong>Note:<\/strong> The paper says that the feature data must always be non-negative, i.e. <code>0.0<\/code> or larger, and recommends normalizing the data into the range [0, 1]. In my testing it didn\u2019t really make much difference, but I suppose negative numbers can mess up the math in interesting ways.<\/p>\n<\/div>\n<h2>It\u2019s all about the features<\/h2>\n<p>The key idea in SEFR is that we want to determine for each feature whether it helps to identify positive examples, or whether it helps to identify negative examples.<\/p>\n<p>During training, SEFR computes a <strong>weight<\/strong> value for each feature. If the training data has M features per example, then SEFR learns M weights in total.<\/p>\n<p>The weight is just a number that tells us how much an individual feature \u201cpulls\u201d the example towards the positive class (the weight is close to +1), or how much it pulls the example towards the negative class (the weight is close to -1).<\/p>\n<p>If the weight is close to 0, the feature isn\u2019t very useful to the classification process and can be considered irrelevant.<\/p>\n<p>That\u2019s basically all there is to it. Let\u2019s look at how to compute these weights.<\/p>\n<h2>The algorithm in Swift<\/h2>\n<p>Since SEFR is a really simple algorithm, I will describe it by stepping through the source code. You can follow along with the <a href=\"https:\/\/github.com\/hollance\/sefr-swift\/blob\/master\/SEFR.swift\">full version<\/a>.<\/p>\n<p>First, we need to define the parameters that SEFR will learn. I already mentioned the weights, but it also learns a <strong>bias<\/strong> value. This bias will determine the decision boundary between the two classes. More about this later. There will be M weights \u2014 one for each feature \u2014 but there is only a single bias.<\/p>\n<pre><code class=\"language-swift\">var weights: [Float] = []\nvar bias: Float = 0\n<\/code><\/pre>\n<p>To train the model, we define the function <code>fit()<\/code>. This takes an array of examples and another array of the training labels:<\/p>\n<pre><code class=\"language-swift\">func fit(examples: [[Float]], targets: [Int]) {\n  let numExamples = examples.count\n  let numFeatures = examples[0].count\n<\/code><\/pre>\n<p>Recall that <code>examples<\/code> is a two-dimensional array of size N\u00d7M, or <code>numExamples<\/code> by <code>numFeatures<\/code>, containing floating-point numbers; <code>targets<\/code> is an array containing <code>numExamples<\/code> labels, where each label is either 0 or 1.<\/p>\n<p>First, we will count <strong>how many<\/strong> positive examples and how many negative examples there are in the training data. If the label for an example is 1, the example belongs to the positive class. If the label is 0 \u2014 or really, anything except 1 \u2014 the example is from the negative class.<\/p>\n<pre><code class=\"language-swift\">  var countPos: Float = 0\n  var countNeg: Float = 0\n\n  for i in 0..&lt;numExamples {  \/\/ loop through all examples\n    if targets[i] == 1 {      \/\/ this example has positive label\n      countPos += 1\n    } else {                  \/\/ this example has negative label\n      countNeg += 1\n    }\n  }\n<\/code><\/pre>\n<div class=\"note\">\n<p><strong>Note:<\/strong> Even though the counts are whole numbers, we store them as <code>Float<\/code> to avoid having to cast them later. If you\u2019re worried about losing precision, have no fear: a <code>Float<\/code> can store integer values just fine (up to 24 bits).<\/p>\n<\/div>\n<p>Next, we look at each of the features in turn. For each individual feature, we compute its <strong>average value<\/strong> across all the positive examples. We also compute the average value over the negative examples.<\/p>\n<pre><code class=\"language-swift\">  for f in 0..&lt;numFeatures {      \/\/ loop through all features\n    var sumPos: Float = 0\n    var sumNeg: Float = 0\n\n    for i in 0..&lt;numExamples {    \/\/ loop through all examples\n      if targets[i] == 1 {        \/\/ this example has positive label\n        sumPos += examples[i][f]\n      } else {                    \/\/ this example has negative label\n        sumNeg += examples[i][f]\n      }\n    }\n\n    let avgPos = sumPos \/ countPos\n    let avgNeg = sumNeg \/ countNeg\n<\/code><\/pre>\n<p>Why do we do this? Let\u2019s say a particular feature usually has a large value when it appears in a positive example, but for any of the negative examples its value is close to zero. Then <code>avgPos<\/code> for that feature will be large and <code>avgNeg<\/code> will be small. That means this feature is highly correlated with predicting the positive class.<\/p>\n<p>If <code>avgNeg<\/code> is greater than <code>avgPos<\/code>, it\u2019s the other way around: this particular feature is more likely to predict the negative class. And if <code>avgPos<\/code> and <code>avgNeg<\/code> are more or less the same, this feature by itself doesn\u2019t really say anything about the example\u2019s class.<\/p>\n<p>We can now <strong>combine these two averages<\/strong> into a single weight value for this feature:<\/p>\n<pre><code class=\"language-swift\">    let weight = (avgPos - avgNeg) \/ (avgPos + avgNeg + 0.0000001);\n    weights.append(weight)\n  }\n<\/code><\/pre>\n<p>The formula used here accomplishes the following:<\/p>\n<ul>\n<li>\n<p>Since <code>avgPos<\/code> and <code>avgNeg<\/code> can be anything, we want to normalize them so that the weights are always in the range <strong>[-1, +1]<\/strong>. That\u2019s what the division is for. The small \u201cepsilon\u201d value in the denominator prevents division by zero.<\/p>\n<\/li>\n<li>\n<p>If <code>avgPos<\/code> for this feature is larger than <code>avgNeg<\/code>, it means the feature is more correlated with the positive class. The formula turns this into a positive weight value between 0 and +1, because the numerator is positive.<\/p>\n<\/li>\n<li>\n<p>However, if <code>avgNeg &gt; avgPos<\/code>, the feature is correlated with the negative class. The numerator is negative, making the weight a number between -1 and 0.<\/p>\n<\/li>\n<li>\n<p>If <code>avgPos<\/code> and <code>avgNeg<\/code> are identical or very similar, the numerator is a small number. The resulting weight is close to 0, meaning that the feature is not very important.<\/p>\n<\/li>\n<\/ul>\n<p>OK, still with me? At this point we have computed a weight for each individual feature by analyzing our training data.<\/p>\n<p>We can now multiply the data from a given training example with these weights to <strong>calculate a score<\/strong>. In math terms, we\u2019re taking the dot product between the weights and the example\u2019s features.<\/p>\n<p>The code for calculating the score looks like this:<\/p>\n<pre><code class=\"language-swift\">    var score: Float = 0\n    for f in 0..&lt;numFeatures {\n      score += examples[i][f] * weights[f]   \/\/ dot product!\n    }\n<\/code><\/pre>\n<p>A higher score implies this example is likely positive; a lower score implies the example likely belongs to the negative class. However\u2026 we don\u2019t know yet where the actual <strong>decision boundary<\/strong> is. We can\u2019t say for sure yet how large the score must be to mean \u201cthis example is positive\u201d.<\/p>\n<p>For instance, if one example has a score of +43.1 and another has a score of -7.8, does that mean the second example belongs to the positive class or the negative class? We don\u2019t know yet. First, we have to figure out where the decision boundary is. Say the decision boundary is at +10.0, then the second example belongs to the negative class. But if the decision boundary is at -15.4, that example still belongs to the positive class, and only examples with a score less than -15.4 are negative.<\/p>\n<p>In order to figure out the decision boundary, we first compute the <strong>average scores<\/strong> for all our training examples. As before, we do this separately for the positive and for the negative examples:<\/p>\n<pre><code class=\"language-swift\">  var sumPosScore: Float = 0\n  var sumNegScore: Float = 0\n  \n  for i in 0..&lt;numExamples {\n  \n    var score: Float = 0\n    for f in 0..&lt;numFeatures {\n      score += examples[i][f] * weights[f]\n    }\n\n    if targets[i] == 1 {    \/\/ example has positive label\n      sumPosScore += score\n    } else {                \/\/ example has negative label\n      sumNegScore += score\n    }\n  }\n\n  let avgPosScore = sumPosScore \/ countPos\n  let avgNegScore = sumNegScore \/ countNeg\n<\/code><\/pre>\n<p>Here, <code>avgPosScore<\/code> should be larger than <code>avgNegScore<\/code> because the weights for \u201cpositive\u201d features are greater than zero, while the weights for \u201cnegative\u201d features are less than zero.<\/p>\n<p>The final step in the training process is to <strong>compute the bias<\/strong> value:<\/p>\n<pre><code class=\"language-swift\">  bias = -(countNeg * avgPosScore + countPos * avgNegScore) \n            \/ (countNeg + countPos)\n}\n<\/code><\/pre>\n<p>As I explained, we need to know above which score an example is considered positive. That score is what I called the decision boundary. The bias is used to shift this decision boundary to 0 (hence the minus sign).<\/p>\n<p>To make a prediction, we can now simply take the dot product and add the bias:<\/p>\n<pre><code class=\"language-swift\">score = example \u2022 weight + bias\n<\/code><\/pre>\n<p>If this score is &gt;= 0, the example is positive. If this score is &lt; 0, the example is negative. Thanks to the bias, we don\u2019t need to worry about the decision boundary at all.<\/p>\n<p>In essence, the bias is simply the average of <code>avgPosScore<\/code> and <code>avgNegScore<\/code>. The formula looks a bit more complex because we use a weighted average, in case the number of positive examples in the training dataset is different from the number of negative examples. This is why we multiply by <code>countNeg<\/code> and <code>countPos<\/code>, and divide by the total number of examples.<\/p>\n<p>If you\u2019re wondering why we do <code>countNeg * avgPosScore<\/code> and vice versa: If there are fewer negative examples than positive examples, we want to the <code>avgPosScore<\/code> to count less.<\/p>\n<p>This is all you need to do to train the classifier. As you can see, it\u2019s just a bunch of for-loops. It doesn\u2019t get much simpler than that!<\/p>\n<div class=\"note\">\n<p><strong>Note:<\/strong> It should be possible to optimize this even more by using the routines from the Accelerate framework. Instead of computing the average using a for loop, you could use <code>vDSP.mean()<\/code> to do it in one go, for example. Also, if you split the training examples into two sets, one for the positive examples and one for the negative examples, you can optimize the implementation even more.<\/p>\n<\/div>\n<h2>After training comes sunshine<\/h2>\n<p>Once the model has been trained, making a prediction on a new example is very straightforward.<\/p>\n<p>I split this up into two functions. The first one computes the \u201craw\u201d score, just like we did above. This multiplies each feature from the example with its corresponding weight, adds them all up, and also adds the bias.<\/p>\n<pre><code class=\"language-swift\">func predictScore(example: [Float]) -&gt; Float {\n  var score = bias\n  for f in 0..&lt;weights.count {\n    score += example[f] * weights[f]\n  }    \n  return score\n}\n<\/code><\/pre>\n<p>Turning this into a yes\/no prediction is as simple as comparing the score to 0:<\/p>\n<pre><code class=\"language-swift\">public func predict(example: [Float]) -&gt; Bool {\n  predictScore(example: example) &gt;= 0\n}\n<\/code><\/pre>\n<p>Here, a <code>true<\/code> result means the example belongs to the positive class, while <code>false<\/code> means it belongs to the negative class.<\/p>\n<h2>Going multiclass<\/h2>\n<p>A common strategy to turn a binary classifier into a multiclass classifier is to use <strong>one-vs-rest<\/strong>. If there are, say, 3 classes, you train three separate binary classifiers:<\/p>\n<ol>\n<li>class A versus (class B and C)<\/li>\n<li>class B versus (class A and C)<\/li>\n<li>class C versus (class A and B)<\/li>\n<\/ol>\n<p>To train the first classifier, you give the examples from class A the label \u201cpositive\u201d and the examples from class B and C both get the label \u201cnegative\u201d. Similarly for the other classifiers.<\/p>\n<p>To make a prediction, you run the example through all three classifiers and pick the one that gave the highest score. Here, if the second classifier had the highest score, the predicted class is B.<\/p>\n<p>In the <a href=\"https:\/\/github.com\/hollance\/sefr-swift\/blob\/master\/SEFR.swift\">source code<\/a>, this is implemented in <code>SEFRMulticlass<\/code>. The target labels are still integers but they\u2019re no longer limited to 0 or 1.<\/p>\n<p>The code is really simple. I\u2019ll just highlight the function that is used for making predictions:<\/p>\n<pre><code class=\"language-swift\">func predict(example: [Float]) -&gt; Int {\n  var largest = -Float.greatestFiniteMagnitude\n  var bestClass = 0\n  for (label, clf) in zip(labels, classifiers) {\n    let pred = clf.predictScore(example: example)\n    if pred &gt; largest {\n      largest = pred\n      bestClass = label\n    }\n  }\n  return bestClass\n}\n<\/code><\/pre>\n<p>This loops through the different classifiers \u2014 one for each label in the dataset \u2014 and makes a prediction. It returns the class label belonging to the classifier that predicted the largest score. (In mathspeak, it takes the argmax.)<\/p>\n<h2>The demo app<\/h2>\n<p>It\u2019s easy enough to show that a classifier works well on a toy dataset, but what if you apply it to a real problem?<\/p>\n<p>As a more realistic demonstration, I made a basic app that uses SEFR to classify images. This is the <a href=\"https:\/\/github.com\/hollance\/sefr-swift\/blob\/master\/Demo.swift\">Demo.swift<\/a> source file in the repo. I\u2019m not going to step through the code here but I will explain what it does.<\/p>\n<p>To run the demo app for yourself, clone the repo and run the following from a Terminal window:<\/p>\n<pre><code class=\"language-bash\">$ cat SEFR.swift Demo.swift | swift -\n<\/code><\/pre>\n<p>This app is written for macOS, although the same code will also work on iOS. There is no Xcode project, it\u2019s just a set of Swift scripts, which is why you need to run it using the above incantation.<\/p>\n<p>The Demo.swift file scans the <strong>images\/train<\/strong> folder to see which subfolders it has. Each of these is considered to be a class. The demo includes the classes: balloon, cat, and sandwich. If you want to use your own images, simply make a folder for it and copy your images into that.<\/p>\n<p>Each of the train subfolders has 15 images in it. These images have been resized to 299\u00d7299, but that\u2019s not a requirement \u2014 any size image will work (they get resized by the Vision framework to 299\u00d7299 anyway).<\/p>\n<p>The demo app loops through all the images in these subfolders and creates <strong>feature vectors<\/strong> for them using Vision framework\u2019s <strong>FeaturePrint.Scene<\/strong> model. This is a neural network that is built into macOS and iOS that outputs a 2048-element vector for any image you give it. The numbers in this vector represent supposedly useful information about the image.<\/p>\n<p>You can stick these feature vectors into some kind of classifier. A typical neural network uses logistic regression as its classification layer \u2014 i.e. a fully-connected layer followed by softmax \u2014 but we\u2019ll use SEFR.<\/p>\n<p>Since there are three classes and each has 15 training images, the total training example array is of size 45 \u00d7 2048.<\/p>\n<p>The app also comes with a test set in the <strong>images\/test<\/strong> folder. This has 5 images per class. We also create feature vectors for these images, so we can evaluate how well the classifier works after it has been trained.<\/p>\n<div class=\"note\">\n<p><strong>Note:<\/strong> Recall that SEFR prefers that input data is in the range [0, 1] or at least has no negative values. It turns out that Vision FeaturePrint.Scene outputs vectors that only have numbers &gt;= 0.0, so we don\u2019t need to \u201cfix\u201d the data. If your data contains negative values, consider normalizing the training data. An easy formula is: <code>normalized = (data - min) \/ (max - min)<\/code>.<\/p>\n<\/div>\n<p>Reading the image files and generating the feature vectors is really the bulk of Demo.swift. Once we have the training and testing data, we do the following to train the multiclass model and test it:<\/p>\n<pre><code class=\"language-swift\">let model = SEFRMulticlass()\nmodel.fit(examples: trainExamples, targets: trainLabels)\nlet predictions = model.predict(examples: testExamples)\n<\/code><\/pre>\n<p>And then we compute the <strong>accuracy<\/strong> by counting how many of the <code>predictions<\/code> are the same as the <code>testLabels<\/code>.<\/p>\n<p>For the test images that are provided with the demo app, it accurately predicts <strong>100%<\/strong> of them. High fives all around!<\/p>\n<p>Before we get carried away with this success\u2026 It\u2019s important to realize that with 2048 features per image but only 45 images in the training set, the classification problem isn\u2019t very hard. So I\u2019d expect <em>any<\/em> classifier to score 100% correct on this problem.<\/p>\n<p>To see how well SEFR does <strong>with fewer features<\/strong>, set the <code>useRandomFeatures<\/code> variable at the top of Demo.swift to <code>true<\/code>, and choose <code>numFeatures<\/code> to be a small number. Instead of using the entire 2048-element feature vector, it will now pick a small number of features at random and try to do the classification with those.<\/p>\n<p>Sometimes it will still get a very high score even with one or two features, but the problem is definitely harder now!<\/p>\n<p>Maybe you can add some of your own images and classes to see how well it does on those. (I didn\u2019t want to upload a massive dataset to GitHub.)<\/p>\n<h2>How fast is this?<\/h2>\n<p>SEFR is fast and small enough to run on a puny Arduino Uno, and even to train the model on the device itself. Not bad! Of course, it runs even faster on an iPhone.<\/p>\n<p>Training time is linear: If you train it on twice as much data or with twice as many features, it will take twice as long. Training has O(M*N) time complexity, testing a single example is O(M). SEFR also doesn\u2019t need much (temporary) storage space.<\/p>\n<p>The fact that it\u2019s <strong>fast and uses little memory<\/strong> is a big advantage of SEFR over many other classifiers, and what makes it possible to run on tiny microcontrollers.<\/p>\n<p>Multiclass SEFR can be a little slow. With K classes, you also have to train K different instances of SEFR. To make a prediction, you have to run all of these classifiers and then figure out which gave the best result. The more classes you have, the slower this becomes. Other algorithms, such as Naive Bayes, can trivially handle multiple classes. So SEFR isn\u2019t optimal here.<\/p>\n<p>Of course, there is a downside to having a fast but simple classification algorithm: it\u2019s <strong>not as accurate<\/strong> as state-of-the-art classifiers such as CatBoost, LightGBM, and XGBoost. As always, each algorithm has its own tradeoffs. SEFR is just another tool in the box. Sometimes it will work better, other times it\u2019s not necessarily the best choice.<\/p>\n<p>Another benefit of SEFR is that the weights tell you <strong>how important<\/strong> a feature is. This lets you remove irrelevant features \u2014 those with a weight close to 0 \u2014 from your dataset, for an extra speedup.<\/p>\n<p>Even with its limitations, I do quite like SEFR because it is so stunningly simple. And I look forward to seeing more new, cool algorithms being developed for \u201cTiny ML\u201d!<\/p>","protected":false},"excerpt":{"rendered":"<p>If you thought machine learning on mobile is already a bit of a stretch, how about machine learning on microcontrollers? Take the Arduino Uno. This has an 8-bit CPU running at 16 MHz, no real floating point support \u2014 it does not even have a division instruction \u2014 and only has 2KB of RAM. Is [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rop_custom_images_group":[],"rop_custom_messages_group":[],"rop_publish_now":"initial","rop_publish_now_accounts":[],"rop_publish_now_history":[],"rop_publish_now_status":"pending","footnotes":""},"categories":[1],"tags":[],"class_list":["post-10192","post","type-post","status-publish","format-standard","hentry","category-explore"],"_links":{"self":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts\/10192","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/comments?post=10192"}],"version-history":[{"count":0,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts\/10192\/revisions"}],"wp:attachment":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/media?parent=10192"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/categories?post=10192"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/tags?post=10192"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}