<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Pranav Joshi on Medium]]></title>
        <description><![CDATA[Stories by Pranav Joshi on Medium]]></description>
        <link>https://medium.com/@pranav.joshi_16395?source=rss-8fbecc8a385d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*rfws2-xojcwV8BK3DnKlUg.jpeg</url>
            <title>Stories by Pranav Joshi on Medium</title>
            <link>https://medium.com/@pranav.joshi_16395?source=rss-8fbecc8a385d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 23 May 2026 15:49:34 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@pranav.joshi_16395/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Usual Graph Algorithms]]></title>
            <link>https://medium.com/@pranav.joshi_16395/usual-graph-algorithms-48cda75da952?source=rss-8fbecc8a385d------2</link>
            <guid isPermaLink="false">https://medium.com/p/48cda75da952</guid>
            <category><![CDATA[graph-theory]]></category>
            <category><![CDATA[algorithms]]></category>
            <dc:creator><![CDATA[Pranav Joshi]]></dc:creator>
            <pubDate>Sat, 18 Nov 2023 11:17:11 GMT</pubDate>
            <atom:updated>2023-11-18T11:17:11.939Z</atom:updated>
            <content:encoded><![CDATA[<p>This article is going to follow the book “Algorithms” by Jeff. I’m assuming you already know the basics, and so I’ll skip straight to the theorems and algorithms.</p><h4>Depth First Search</h4><p>This is the most trivial way of traversing a graph. The algorithm goes like this :</p><p>1) Iterate over the children of current node and check if one of them is unvisited (not unprocessed, but unvisited). If it is, put it on stack (a sort of to do list with “Last In First Out” principle)</p><p>2) Check if the last node is unvisited (it should be if it was added to stack in the last step).</p><p>3a) If yes, make that the new current node and then “visit” it (maintain a “visited” array at all times and do visited[current_node] = 1).</p><p>3b) If no, remove it from the stack. (Optional : do visited[current_node]=2 to show that this node is “dead”)</p><p>4) Go back to step 1.</p><p>A simple example :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*V65t7cTCaZy-k12F5TifgA.gif" /><figcaption>A represents “active” i.e. visited and currently on stack and D represents “done” i.e. visited and removed from stack</figcaption></figure><p>Now, that’s good and all, but this looks pretty useless. Why are we traversing the graph in the first place ? Usually, we do this because we want to process all the nodes. Maybe you are building a 2*2*2 cube solver and want to find a sequence of steps that takes you from an unsolved state (node) to a solved state and want to verify (process) whether the current state is solved or not.</p><p>To do this, the sensible thing to do would be to check if the state is solved before putting more nodes on the stack and processing them (before step 1 that is). This is called “pre-processing”. Similarly, processing a node after its children are processed (just before step 3b) is called “post-processing” , and the order in which we pre-process or post-process nodes is called pre-order and post-order.</p><h4>Dependency Graphs and Topological Sort</h4><p>In problems like finding the first n Fibonacci numbers, it makes sense to calculate in a “ground up” approach. This is because finding the n-th number is only possible if you know the (n-1)-th and (n-2)-th number. If we think of these numbers as being attributes of nodes in a graph of n nodes, then we can say that the node ’n’ depends on node ‘n-1’ and node ‘n-2’ . To represent this, we draw an edge from node ’n’ to ‘n-1’ and ‘n-2’ . Such a graph is called a dependency graph. (Take a breather and try some examples. I know you need it.) . If we start at the n-th node and do a DFS, the post-order we would get is the order in which we want to compute the values for the nodes.</p><p>This animation explains it better :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*MShekFrsnSwJNH0yCmBvSQ.gif" /></figure><p>The post-order is in this case, the reverse topological order (ik, ik, … heavy words) . First let’s define what a topological order is :</p><p>An ordering of nodes such that for any node X that precedes node Y , we can go from X to Y , but not the opposite.</p><p>It’s clear that any graph that has a cycle can’t have such an ordering and neither can an un-directed graph. Thus, we have a topological order only for a Directed A-cyclic Graph (DAG) .</p><p>In fact, for any DAG , the post-order is the reverse of the topological order. If you don’t believe me, let me prove it to you :</p><p>Define v.post as the index of node v in the post order for any v. If v.post &gt; u.post , then it’s clear by the recursive nature of DFS. that u is reachable from v. But this means v is not reachable by u because we are talking about a DAG. Thus v comes before u in the topological order.</p><h4>Minimum Spanning Trees</h4><p>This is a tree spanning the whole graph (if it’s connected) such that the total weight of the tree, that is the sum of the weights of the edges that the tree contains is minimum. The most popular algorithms to compute MSTs do some flavour of this property which holds when all the edge weights are distinct :</p><p>Suppose that the weights of the edges in the graph are all distinct. Now suppose you are somehow given a sub-graph S of the main graph G that you know is also a sub-graph of the minimum spanning tree T. Then for all connected components C of S , the edge (if it exists) of minimum weight that is adjacent from node u in C to node v in G-C is called a safe edge. It can be proven that T contains all safe edges for any S. Thus, if you want to find T, just keep adding these safe edges to S until S becomes T and there are no more safe edges. The order in which you do this is where the algorithms diverge.</p><p>One popular algorithm is Jarnik’s algorithm where S has a single connected component at all stages. Naively this algoritms runs in O(VE) since we compute a safe edge in O(E) and do so V times. If we maintain a priority queue that contains all the edges in G-S , maybe as a min-binary-heap , then we can do improve our time to O(V logE). But creating a priority queue by itself requires O(E logE) = O(E logV), giving us the complexity O(E log V). Actually, the best data structure for such a priority queue is a Fibonacci heap which allows us to run the algorithm in O(E+VlogV) time because we keep vertices in the queue in that case, and not edges.</p><p>Another simple, yet effective algorithm is Kruskal’s algorithm, where we just sort the edges first, then iterate over them and add to S if the edge is safe. This runs in O(E logV) = O(E logV) as well.</p><h4>Breadth First Search</h4><p>This algorithm uses a queue , which is once again, a To-Do-list of nodes, but follows the First In First Out principle (as the name suggests) . We start off with one node in queue, which is marked ‘visited’ immediately. Then we repeat these steps until the Que is empty :</p><ol><li>Process the current node (right at the start of the queue), if you want to.</li><li>For all children of the current node, check if they are un-visited one by one. If they are, put them (all the un-visited children) at the end of queue and process them (if you want to).</li><li>Remove the current node from the queue, thus making the next node the new “current node”</li></ol><p>The last step isn’t done by literally popping the element from the array and renaming all the indices, but by maintaining a variable that keeps track of the index of the first element of queue in the actual array used to store the queue elements (or in case of C/C++ , simply incrementing the base address of the array)</p><h4>Shortest Path Trees</h4><p>The concept is simple ; It’s a tree made of (not all) edges of the original Graph such that all the paths from the “source node”, which is the root of the tree to any particular node are of minimum length. Note that here, the original graph can be cyclic, un-directed or weighted. The only constraint is that the weights should not be negative. Before we go in, some notation :</p><ol><li>‘s’ refers to the source node</li><li>v.dist is the length of the current best known path from s to v</li><li>w(u,v) is the weight of edge u-&gt;v</li><li>v.prev is the the node that comes before v in the shortest <em>known</em> (computed) path from s to v.</li></ol><p>Alright, now let’s start with an algorithm called Dijkstra’s algorithm :</p><ol><li>Set v.dist to infinity for all v</li><li>Set s.dist = 0 (obviously)</li><li>Put ALL the elements in a queue.</li><li>Find the node u in queue with minimum u.dist. This value can be proved to be correct for non-negative weights.</li><li>For ever child ‘v’ of current node ‘u’ check if v.dist &gt; u.dist + w(u,v)</li><li>If yes, assign u.dist + w(u,v) as the new value of v.dist and assign u as v.prev.</li><li>Remove u from queue.</li><li>If Queue is empty, we are done, else jump to step 4 .</li></ol><p>Now, why does this work ? We said that every time we find ‘u’ , its u.dist is the correct value. To prove this, consider the graph made out of all the nodes that you have removed from the queue. Call that S. Thus, the queue contains all and only the nodes from G-S. Make an induction hypothesis that for any node w in S , w.dist is the correct value. Now, for every such w , we have also calculated the tentative u.dist values for every child u of w that is in G-S , that is to say that we have found the tentative u.dist for every u in G-S connected to S directly. Now, suppose we have found a u that has minimum u.dist, call it u_0 , then u.dist is correct because there cannot exist any other shorter path to u_0. Because such a path would have to pass from some u_1 (different from u_0) for which we have found the tentative u_1.dist, given by (u_1.prev).dist + w(u_1.prev,u_1) for u_1.prev in S , since if it doesn’t then u.dist should have been reached by that path by now. Now, we know that such a path has this structure : s -&gt; u_1.prev -&gt; u_1 -&gt; u_0 , which clearly has a bigger or equal length than the path s -&gt; u_1.prev -&gt; u_1 which has the length u_1.dist , which is already bigger u_0.dist , meaning that such a path isn’t possible.</p><p>As you can guess, this algorithm doesn’t have a complexity of O(V+E) , but we can actually find the Shortest Path Tree in O(V+E) in special circumstances, namely if all the weights are the same or if the graph is a DAG. In these cases, the algorithm to use is just simple BFS ! Basically, in these cases, you go “level” by level. First you compute v.dist for s , then for all children of s (call that layer 1) , then for all the children of the nodes in layer 1 (call that layer 2) , and so on. We are guaranteed to never have to compute v.dist for v in a layer that we have already explored. It’s pretty clear why this is the case in the case of DAGs , but what about the first case ? The thing is, in the first case, v.dist is just the level that v is in (immediately and forever).</p><p>Ok, but like, what IS the complexity ? Well it’s O(V²) . Every iteration we remove one node from the queue and every iteration we have to do at most V checks to find the node u with minimum u.dist. Thus, the full complexity is O(V²) . Now this can be improved to O(V + E logV) using a priority queue. Basically you maintain the queue to be in increasing order at all times using a heap data structure. So, every time you update v.dist for any node v that is in the queue, you have to inject the node, which needs a time of logV (using the usual “push-down” algorithm for heaps used in heap-sort). This happens for every edge once at worst (remember, you are updating all u.dist and THEN finding the minimum before adding that minimum node to S). Plus, we have to process V nodes in any case.</p><p>Another algorithm is the Bellerman-Ford algoritms which goes as follows :</p><ol><li>Set v.dist = inf for all v</li><li>Set s.dist = 0</li><li>set i = 0</li><li>For all edges (u,v) , if u.dist + w(u,v) &lt; v.dist , set v.dist = u.dist + w(u,v)</li><li>i++</li><li>If i = V-1, stop</li><li>Jump to step 4</li></ol><p>Every time we run step 4 and 5, we are guaranteed that v.dist ≤ dist(v,i) , which is the length of “the” (not current) shortest path from s to v, consisting of less than or equal to ‘i’ edges. Since, when i=V-1, dist(v,i) is the length of the actual shortest path (assuming no negative cycles), so after V-1 iterations, v.dist will also be this value, that is to say the tree can’t be improved anymore. So, if after V-1 iterations, the tree CAN somehow be improved, we can conclude that the graph has negative cycles. The book implements the algorithm like this :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/267/1*KO7wW9XpmRXrACcpClqWzQ.png" /></figure><p>This algorithm has the complexity O(VE). The same algorithm (with some modifications) can be implemented using a queue, giving us what’s called Moore’s improvement. The idea is that we just run BFS many-many times and push the children v of current node u for which we have corrected edges (u,v) in the queue again. When the queue doesn’t contain any element, or if we have gone through V “phases”, we stop the algorithm. The book explains the concept of phases nicely :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/583/1*y7AfoBVOEVMyARZulcfr7g.png" /></figure><p>What the book means by RELAX(u-&gt;v) is “update v.dist to u.dist + w(u,v)” and by “u-&gt;v” is tense, it means that this correction is possible.</p><p>In every phase, this new algorithm avoids checking the edges that are trivially not possible to be updated in that phase.</p><h4>All pair shortest paths</h4><p>In the last problem, we used v.dist to mean the minimum distance from s to v. Here, we use dist(u,v) to mean the minimum distance from any node u to any node v. And we use dist(u,v,l) to mean the minimum distance of path from from u to v with at most ‘l’ edges. The solution can be written with dynamic programming as :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*E32CgzR4hwCHu3PkbWdCWQ.png" /></figure><p>We can remove the last dimension of ‘l’ by updating dist(u,v) (which represents the current best known value) like this :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/381/1*zD4qpRksSeaz3lJwy6Gblg.png" /></figure><p>and initially, setting dist(u,v) to infinity when u != v and 0 when u==v .</p><h4>Strong components</h4><p>A strong component of a graph G is a sub-graph S such that for any two nodes u,v in S, there exists a path from u to v and from v to u in S.</p><p>A strong component graph scg(G) of G is the graph obtained by shrinking every strong component S of G into a single node s with edge (s_1,s_2) representing any edge from u to v with u in S_1 and v in S_2. For example, in this picture, the graph in right is the strong component graph of the graph in left.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/399/1*H5dpdidgGV-A-EW-_Cx7pw.png" /></figure><p>Computing scg(G) can be done in O(V+E) using this property :</p><p>For any depth first tree T (a tree formed on doing depth first traversal) of G, each strong component S of G contains exactly one node u that does not have a parent in S for the graph T.</p><p>This is because if we enter a strong component S via ‘u’ , we explore all of S during DFS(u). That is to say, that if reach(u) denotes all the nodes that are explored during DFS(u), then reach(u) contains all the nodes in S. Now, there are two ways we could enter S via a different node ‘v’ after we have entered S through ‘u’. Either we re’enter S during DFS(u) or we enter after DFS(u) has been executed. The first case is not possible since then ‘v’ would also be in S since it’s reachable by S and S is reachable from v . And the second case if impossible because after DFS(u) has been executed, every node in S has been visited.</p><p>There’s another property that we need, for which I’ll have to define some more terms:</p><p>The low-link value of a node u, written as u.low represents the minimum value of v.pre for all v that we try to visit (check if it’s visited) during DFS(u) . It’s easy to see that u.low ≤ u.pre . (Note : if you’ve forgotten what u.pre was, it’s the ‘starting’ time for u, that it, the number of nodes visited before u is pre-processed). Computing u.low is straightforward using the fact that u.low is the minimum of all v.low for all children v of u in the DFS tree T and v.pre for all children v of u in the original graph G and finally, u.pre. So during the post-processing of u during DFS, we can compute u.low. It’s also easy to see that all the nodes in any strong component have the same low-link value.</p><p>A sink component is a strongly connected component S such that no other strong component is reachable from S. Similarly, a source component is a strong component, which isn’t reachable by any other strong component.</p><p>Now, onto the property :</p><p>A node u is the root of a sink component if and only if u.low = u.pre and for no node v in reach(u), you have v.low = v.pre .</p><p>The algorithm that we’ll use is called Tarjan’s algorithm and it goes as follows :</p><ol><li>During DFS of the graph, update u.low everytime you return to u.</li><li>If u.low = u.pre , mark reach(u) as a strong component and “remove” reach(u) from G for the remaining DFS. (This ensures that at any point we run into u.low = u.pre, it’s impossible that v.low = v.pre from some v in reach(u) for the current graph G, since we would have removed reach(v) if it happened, and thus u is the root of a sink component in G at that stage.)</li></ol><p>When we say “remove” reach(u) from G, we actually do this implicitly by marking every node in reach(u) as ‘non-existent’. For non-existent nodes, we don’t update low-link value upon visiting their parents. Now, here’s a big issue : how do you compute reach(u) <strong>after</strong> you have finished DFS(u) (since that is when we check for u.low = u.pre) ? You can’t just make nodes in reach(u) non-existent on-the-fly when you are doing DFS. What you need to do is to maintain a list of the nodes in reach(u) for every u that you haven’t made non-existent. This can be done by maintaining a 2nd stack (the first stack is what we are using for the actual DFS) where you don’t remove nodes from stack when they are explored, but rather when you encounter u.low = u.pre . You do that by marking the last node as non-existent and then popping it off.</p><h4>End</h4><p>I wouldn’t be surprised if you end up being more confused after reading this than before. If you are a visual learner (which helps immensely in a subject so “graph”ical like this one), then take a look at this playlist :</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fvideoseries%3Flist%3DPLDV1Zeh2NRsDGO4--qE8yH72HFL1Km93P&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fplaylist%3Flist%3DPLDV1Zeh2NRsDGO4--qE8yH72HFL1Km93P%26feature%3Dshared&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FDgXR2OWQnLc%2Fhqdefault.jpg%3Fsqp%3D-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ%3D%3D%26rs%3DAOn4CLBaOe0S1bdSTJLQ0TfT9sCV5ebYsQ%26days_since_epoch%3D19679&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=youtube" width="853" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/ed9aa866ca4c56dbf5f4fb40d4372769/href">https://medium.com/media/ed9aa866ca4c56dbf5f4fb40d4372769/href</a></iframe><p>It contains more than enough information to understand the algorithms.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=48cda75da952" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rubik’s cube and Group Theory]]></title>
            <link>https://medium.com/@pranav.joshi_16395/rubiks-cube-and-group-theory-7e7c88ad9b55?source=rss-8fbecc8a385d------2</link>
            <guid isPermaLink="false">https://medium.com/p/7e7c88ad9b55</guid>
            <category><![CDATA[rubiks-cube]]></category>
            <category><![CDATA[cubing]]></category>
            <category><![CDATA[group-theory]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Pranav Joshi]]></dc:creator>
            <pubDate>Sat, 11 Nov 2023 15:52:31 GMT</pubDate>
            <atom:updated>2023-11-13T15:42:23.522Z</atom:updated>
            <content:encoded><![CDATA[<p>The standard Rubik’s cube is a very popular puzzle that is solved when all faces of the cube are of the same color. There is a big community of cubers, mathematicians and computer scientists fascinated by this puzzle and its several variants.</p><h4>Disclaimer</h4><p>As the title suggests, this article is directed towards people who know Group theory (mainly commutators and conjugates) good enough and wish to apply it to the standard cube. If you are a cuber or a programmer who is reading this, you might have a little difficulty, but fear not ; go through my full group theory notes (which is also the source of this article) and you’ll understand more than enough :</p><p><a href="https://pranavjoshiiitgn.notion.site/Group-Theory-7d3e40ae73db47cf80aa2d7a250bcf5d?pvs=4">Group Theory</a></p><h4>Introduction to the puzzle</h4><p>Since you are reading this article, I assume you at least know how to solve the standard cube. I case you don’t have a cube at hand, this simulation can come in handy for the rest of this article :</p><p><a href="https://alg.cubing.net/?alg=LR-DRUR-D-RU-L-&amp;view=playback">alg.cubing.net</a></p><p>Throughout this article, I’ll be using “r” and “R^(-1)” for what they call “ R’ ” .</p><h4>Representing a cube on paper</h4><p>To represent a certain state of the cube, keeping the faces fixed, you need to describe :</p><ul><li>The position of the corner cubies , given by the permutations ‘v’ of numbers from 1 to 8 (indices really).</li><li>The position of the edge cubies, given by the permutations ‘w’ of numbers from 1 to 12 .</li><li>The orientation of the permuted corner cubies, given by the array ‘r’ of size 8 made of (not necessarily distinct) numbers from 0 to 2 (both inclusive)</li><li>The orientation of the permuted edge cubies given by the the array ‘s’ of size 12 made of (not necessarily distinct) numbers which are either 0 or 1.</li></ul><p>The way that the orientations of corners are determined is that you label the faces of each corner cubie in the solved state as 0,1,2 in such a way that when you curl you right hand fingers in the direction “0 to 1 to 2” , your thumb points away from the cube and the face labeled 0 must be facing Front or Back. Then, once the corners are permuted, you note down the number on the face that is facing the Front or Back direction. Then, due to the chirality (handedness) of the corner cubies, given only this number we can figure out the full orientation of that cubie (given its position, of course) ,using the right hand rule.</p><p>Similarly, for the edges, you label the faces of edge cubies in the solved state as 0 and 1 , with 0 facing Front, Back, Top, or Bottom in that particular priority order. Then again, you note down the number of the face of the permuted cubie, that is facing Front, Back, Top, or Bottom.</p><p>Thus, the 4-tuple (v,r,w,s) gives a full description of the cube’s state. So, you might be tempted to say that the Rubik’s cube can be modelled by the group S_8 *(Z_<em>3)⁸ * S_</em>12 * (Z_2)¹² . But in fact, most of the tuples that are in this group are just not possible, because they are “illegal” , that is, unattainable by the 12 basic operations you can do on the cube. To get the actual structure of the group modelling the cube, we must find the constraints that any legal 4-tuple must satisfy. But before that, let’s play around with the cube a bit.</p><p>Firstly, notice that every state of the Rubik’s cube can also be represented by the sequence of steps that lead to this state. So, there is a notion of “combining” two states given by x_1 =(v_1,r_1,w_1,s_1) and x_2 =(v_2,r_2,w_2,s_2) , by considering the state that we would get by doing the sequences that lead to x_1 and x_2 one after another. In keeping with the way we deal with permutations and like, I will write this new state, or sequence, however you see fit, as x_3 = x_2 x_1 where we do the sequence for x_1 first, and then the sequence for x_2 . For example, for x_1 = F and x_2 = U , the sequence (or state) x_3 = x_2 x_1 is UF , which usually is written as FU in every tutorial on solving the cube. But we’ll use UF and read right to left. For any arbitrary x_1,x_2, we can do this combination as :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CIUnoNUi5fm1aBS6q3s7uQ.png" /></figure><p>Here P(v_1) and Q(w_2) are matrices that are applied on vectors r_2 and s_2 , and the operation ‘+’ is done modulo 3 for corners and modulo 2 for edges.</p><p>Since it doesn’t look easy to deal with orientations, we should probably deal with permutations first.</p><h4>Permuatations of corners</h4><p>To keep things tidy, whenever we’ll use one of the 12 basic moves in the few following passages, we’ll be referring to only the ‘v’ part of the move that is in fact fully expressed by the 4-tuple (v,r,w,s) .</p><p>Say you had a sequence of moves ‘x’ (that is now a permutation, and not a 4-tuple) that changes only one corner cubie $a$ on the top layer, then the commutator of ‘x’ and ‘U’ is a 3-cycle since supp(x) and supp(U) have only that cubie in common. But first, we must find such an x . On good stratergy to find it is to consider a move that does not interfere with the top layer at all, namely D, and conjugate it with a move like ‘r’ that moves only one cubie from the support of D to the top layer, that too particularly where ‘a’ is sitting. This conjugate will basically involve ‘a’ in the permutation, while not involving any other cubie from the top layer. Thus x = RDr moves only one cubie from the top layer. So, the commutator</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NX3rHn3EuOgDCM3mR_w-fA.png" /></figure><p>is a three cycle. Now, once you have a three cycle, you can get more three cycles by looking a the conjugates of this. For example, conjugating with L gives us the 3-cycle</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QzYawrJoc3gp5M-fR_9z1A.png" /></figure><p>that permutes 3 corner cubies in the top layer among themselves. Try it out on the 2nd simulator that I linked ! Just remember, that we are reading from right to left and the whole world reads from left to right. So what you would have to input is :</p><pre>LR&#39;DRUR&#39;D&#39;RU&#39;L&#39;</pre><p>Notice that you can move one of these corner cubies that is adjacent to a corner that we don’t touch, in any (corner) position on the cube without moving the other 2 cubies that are moved by our 3-cycle. What this fact means is that ANY 3-cycle of corner cubies is legally possible.</p><p>Moreover, since all even permutations (of all 8 corners) can be written as 3-cycles, thus, we can in fact invert all such states of the cube which are formed by even permutations, since the inverse, being an even permutation too can be constructed by three cycles.</p><p>What about the odd permutations ? We can just apply one of the 12 basic moves first on the given state, which will make the new state an even permutation, since any basic move is a 4 cycle, which is odd. But this is ignoring the effects of the basic move on the edges, which will become important later.</p><h4>Permutations of edges</h4><p>Now, just like we did for the corners, for edges, we’ll only consider the ‘w’ part of any sequence. Once again, doing the thing with conjugates, and commutators, we can prove that every 3-cycle of edges is possible legally. To do that you first need one such cycle. Since F and R have only one edge peice common in their supports, their commutator might work, but then you would be messing around with the corner peices too. Once again, to simplify this, you can take a conjugate of D that has exactly one edge cubie from the top layer in the support, and no corner cubies from the top layer, if you view it as a permutation of corners. Then take a commutator of this conjugate with U . Particularly, we are talking about (U . f_M(D)) where M is the move that rotates the middle slice by 90 degree. (Yes, yes, we could represent this as LR and then, a change of orientation of the cube, but we’ll use M, since it makes thinking easier. Although M is actually not a basic move, but we’ll allow this since we know what M means. In essence, by f_M(D), we mean lrFRL )</p><p>Then, you can just take its conjugates.</p><p>And thus, again, every even permutation of edges is invertable. Moreover, since every basic move is a 4 cycle in edges too, for the odd permutations, we can just do one basic move, and then invert the resulting state using 3-cycles. Once again, we are ignoring the effects of this basic move on the corners.</p><h4>Characteristics of a legal permutation</h4><p>Since every legal state is derived from the solved state by a sequence of basic moves, we should look at what is invariant under these basic moves. We already know that a basic move is a 4-cycle in both the edges and the corners. Initially, the parity of both v and w is even, since they are the identity permutations. Now, on every move, the parity of v and w , both change <strong>simultaneously.</strong> What this means is that either both v and w are even, or both are odd. Basically, they have the same parity.</p><p>Thus, states with different parities of v and w are illegal.</p><h4>When is a permutation legally invertable</h4><p>So now that we know that v and w will have the same parity, if both v and w are even, we can invert them by 3-cycles, and if both are odd, we can do a basic move, making them both even, and then again, invert them using 3-cycles.</p><p>This means, that a permutation of edges and cubes (not considering their orientations), described by (v,w) is legal <strong>if and only if</strong> v and w have the same parity.</p><h4>Orientation of edges and corners</h4><p>A sequence that is the identity <strong>permutation</strong> might not be the “blank”, or “do nothing” sequence, because it might be changing the orientations of the cubies. Thus, after some deep thinking, one can come up with these useful sequences :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*WdhwtkMKiNE7eZXbooRHVg.png" /></figure><p>The first sequence is RF’R’FRF’R’F (read left to right).</p><p>Now, by the symmetry of the cube, you can change the orientations of any 2 adjacent corner and adjacent edges. Moreover, by composing 2 such moves, you can in fact switch the orientation of any 2 corners (adjacent or not) and any 2 edges.</p><p>Notice that there what these two moves are doing algebraically is add 1 and subtract 1 (modulo 3 and 2 for corners and edges) from coordinates of ‘r’ and ‘s’ that correspond to the altered cubies. Say for example, you have r = (1,0,1,1,1,1,2,2) for a state of the cube in which you have the correct permutations of cubies, and just unsolved orientations. What you can do is switch the corners that correspond to first 2 coordinates in r , using the move that we have figured out, in order to transform r into (0,1,1,1,1,1,2,2) . And then do the same thing for the 2nd and 3rd coordinate to get (0,0,2,2,0,1,2,2), and then for 3rd and 4th to get (0,0,0,1,0,1,2,2), and so on. You can do a similar thing for s too, just modulo 2 instead of 3.</p><p>Notice that under these moves, the sums of coordinates of r and s (modulo 3 and 2 respectively), namely</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*I-2aV_Y6RMNtryG7w-EGZw.png" /></figure><p>are invariant, since we add 1 and subtract 1 from the sums in every move. So, what this means, is that once we make the 2nd last coordinate in r to be 0, the last element will be R . That is, r can be transformed to (0,0,0,0,0,0,0,R). Similarly, s can be transformed to (0,0,…,0,S) .</p><p>Now, for any legal state of the cube, R=0 and S = 0 because initially, in the solved state, this is true, and the sums are in fact invariant under each of the 12 basic moves (Don’t take my word for it, test it out please) .</p><p>So, what this means that, for any legal state where the cubies are <em>permuted</em> correctly, we are able to transform r to (0,0,0,0,0,0,0,0) and s to (0,0,0,0,0,0,0,0,0,0,0,0) .</p><p>And in fact, since ANY sequence doesn’t change the sums, for any legal state, even if the the cubies aren’t permuted correctly, if we can permute them correctly first, then we would only have to correct the orientations of the cubies with the same sums as the original configuration. The condition to be able to permute the cubies correctly is that v,w must have the same parity. So, if this condition is satisfied, and, if R = 0 and S = 0 in the original unsolved state, then since this is also the case in the new unsolved state with correct permutation of the cubies, we can correct their orientation.</p><h4>When is the cube solvable</h4><p>In the last para of the last section, we showed that IF the cube is in a state given by the 4-tuple (v,r,w,s) such that :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/345/1*H23XayZdPki6-DFGfZFroA.png" /></figure><p>then the cube can be solved by first correcting the permutation of the cubies, and then correcting the resulting orientations of the cubies.</p><p>We have also shown along the way that each of these 3 conditions is followed by any legal state of the cube. Thus, the cube is solvable ONLY IF each of these 3 hold.</p><p>Thus, these three are the necessary and sufficient conditions for a state of the cube to be solvable.</p><p>This is also referred to as the “Fundamental theorem of Cube theory”</p><h4>A question</h4><p>If you have ever come across a certain tutorial on how to solve the cube, you probably know that there are two main sequences of moves used to solve the 2nd layer of the cube. These sequences have the effect of moving the edge cubie on the intersection of top and front face to the position of the edge cubie at the intersection of the front and left face, or the front and right face respectively. For cubers who have cubed for so long that they have forgotten the notation and instead have the moves in their muscle memory, and for the people who have never seen the tutorial, I want you to try and recreate this sequence on your own.</p><h4>Hint</h4><p>It has to do with conjugation and 3-cycles. You already know about the three cycle that cycles 3 edge cubies on the top face. Make use of that.</p><h4>Answer</h4><p><strong>I encourage not reading this and trying on your own.</strong></p><p>It’s these sequences :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PVUevP52G3JXZIwihyTe2g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sobPU91Sv1O0PuSvAySfwQ.png" /></figure><p>which when written in the way that the world writes are :</p><pre>R&#39;U&#39;L&#39;RF&#39;R&#39;LUL&#39;RFR&#39;LR<br>LURL&#39;FLR&#39;U&#39;RL&#39;F&#39;LR&#39;L&#39;</pre><p>Of course, there can be multiple answers, since there are multiple sequences. This exercise is just to show that it’s very possible to create your own algorithms to do clever things with the cube.</p><h4>Homework (for programmers)</h4><p>Now that you have some idea on the kind of legality conditions of cubes, try making a cube solver for the 2*2*2 cube and thus verify the legality condition for corners in 3*3*3 case (think about how these are related) . A good place to start is BFS . If you don’t know how to start despite reading the whole article, have a look at this picture :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*ytS4RKP22ZXX0yA2j4a56w.png" /></figure><p>This is an example of encoding that you could use.</p><h4>Thanks</h4><p>Most of the Group theory that I’ve read till now has been from the Abstract Algebra playlist on YouTube by Michael Penn.</p><p><a href="https://youtube.com/playlist?list=PL22w63XsKjqz0Mgn2SOb1lolNbkRVsLLs&amp;feature=shared">Abstract Algebra | Preliminaries</a></p><p>The reason I started reading Group Theory was because it was part of the Discrete Mathematics (ES214) course at IITGN, which at the time I am writing this is still going on.</p><p><a href="https://www.neeldhara.com/courses/2023/03-es214/">Neeldhara — ES 214 | Aug-Nov 2023</a></p><p>The 2*2*2 cube solver problem was given to me and my team as an assignment in the Data Structures and Algorithms course being held at IITGN this semester, taught by Prof. Balgopal Komarath.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7e7c88ad9b55" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Visualisations for Graph theory and Gradient Descent]]></title>
            <link>https://medium.com/@pranav.joshi_16395/visualisations-for-graph-theory-and-gradient-descent-164b90368842?source=rss-8fbecc8a385d------2</link>
            <guid isPermaLink="false">https://medium.com/p/164b90368842</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[graphviz]]></category>
            <category><![CDATA[graph-theory]]></category>
            <category><![CDATA[calculus]]></category>
            <category><![CDATA[gradient-descent]]></category>
            <dc:creator><![CDATA[Pranav Joshi]]></dc:creator>
            <pubDate>Mon, 06 Nov 2023 19:01:14 GMT</pubDate>
            <atom:updated>2023-11-06T19:01:14.039Z</atom:updated>
            <content:encoded><![CDATA[<p>There are plenty of libraries and tools which can help you visualise a graph. They take in the abstract definition of a graph through an adjacency matrix, or an adjacency list, or something similar. These tools create a graph embedding in a 2D plane, like the ones you’ll see in a standard graph theory course, and they do it automatically! Positioning, shapes of edges, everything is done by the computer; no manual input required. But how do this happen ?</p><h4>What is a 2D embedding ?</h4><p>It’s basically the graph drawn out, with nodes and edges plotted on the Cartesian plane. To create a simple, but ugly looking embedding, you can use any plotting library (I am using matplotlib) and just scatter plot the nodes with random positions. Then connect the nodes by lines or curves, which will serve as edges.</p><h4>So what’s the issue ?</h4><p>The thing is; humans don’t like ugly things, and the embedding you created would certainly look ugly, since after all, it’s randomly generated. So how do we draw a good looking graph ?</p><h4>What are the characteristics of a good looking graph ?</h4><p>It’s hard to give a definite list, since beauty lies in the eyes of the beholder, but usually, the accepted criterion is that the nodes should be placed at an ideal distance (say <em>lo</em>) with adjacent nodes given higher priority. That is, for any error in the distance of two nodes, penalise the graph by a higher amount if the nodes were adjacent.</p><h4>The problem statement</h4><p>Given an adjacency matrix <em>A , </em>figure out the best possible positions (x_i,y_i) for all nodes ‘i’ in order to minimise the squared error given by :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q817eb4NbM78x3XrKYhzMQ.png" /></figure><p>Here <strong>x,y </strong>are vectors made of x_i,y_i values. Notice that I have added an extra factor of ‘alpha’ . This is the default priority given to the distance between a pair of nodes. The thing that we are squaring is the error in the distance between two nodes. And yes, I’m over-counting by a factor of 2, but that doesn’t matter, since it’s a constant factor.</p><h4>The solution</h4><p>Simple ; just gradient descent! This is a popular technique to solve all sorts of continuous optimisation problems. First, let’s find the partial derivative of our loss function L with respect to a single coordinate, say x_i.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PFMJuOvgCxDrRn2FB8i8ug.png" /></figure><p>Now, we calculate the gradient of L with respect to the whole of vector <strong>x </strong>by combining these derivatives.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/917/1*Bw_1_dnPCuIvh29zZZlTcA.png" /></figure><p>Finally, we update the vector <strong>x</strong> iteratively by addind to it the negative of this gradient times the step size. We do the same for <strong>y</strong> as well.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/475/1*XoJK72YSOIu9yGf9rIVFAA.png" /></figure><p>Ok, now let’s implement this in python.</p><pre>from numpy import *<br>from matplotlib.pyplot import *<br>def Solve(A,lo=0.3,dt=0.01,iterations=10000):<br>    cW = 1<br>    cX = 100<br>    xlim(0,cW*cX)<br>    ylim(0,cW*cX)<br>    Ao = A<br>    A = A+A.T<br>    N = A.shape[0]<br>    A = A + sum(A)/N**2<br>    x = cW*random.random(N)<br>    y = cW*random.random(N)<br>    for i in range(N):<br>        for j in range(N):<br>            if(Ao[i][j]):<br>                plot([cX*x[i],cX*x[j]],[cX*y[i],cX*y[j]],&#39;--g&#39;)  <br>    scatter(cX*x,cX*y,50,&#39;red&#39;)<br>    title(&quot;before&quot;)<br>    Xd = (x[newaxis,:]-x[:,newaxis])<br>    Yd = (y[newaxis,:]-y[:,newaxis])<br>    I = identity(N)<br>    print(sum(((Xd**2+Yd**2)**0.5-lo)**2))<br>    for i in range(iterations):<br>        Xd = (x[newaxis,:]-x[:,newaxis])<br>        Yd = (y[newaxis,:]-y[:,newaxis])<br>        distace = (Xd**2+Yd**2)**0.5+I<br>        repulse = (distace**-1 - I)*lo<br>        Fx = -2*A*Xd*(1-repulse)<br>        Fy = -2*A*Yd*(1-repulse)<br>        fx = sum(Fx,0)<br>        fy = sum(Fy,0)<br>        x += fx*dt<br>        y += fy*dt<br>    figure()<br>    xlim(0,cW*cX)<br>    ylim(0,cW*cX)<br>    for i in range(N):<br>        for j in range(N):<br>            if(Ao[i][j]):<br>                plot([cX*x[i],cX*x[j]],[cX*y[i],cX*y[j]],&#39;--g&#39;)  <br>    scatter(cX*x,cX*y,50,&#39;red&#39;,alpha=1)<br>    title(&quot;after&quot;)<br>    print(sum(((Xd**2+Yd**2)**0.5-lo)**2))<br># A test case<br>#N=7<br>#A = zeros((7,7))<br>#A[0][1]=1<br>#A[0][2]=1<br>#A[0][3]=1<br>#A[1][4]=1<br>#A[1][5]=1<br>#A[2][6]=1<br><br># Another test case<br>N=10<br>A = random.randint(-N/3+1,2,(N,N))&gt;0<br><br>Solve(A,0.3,0.0001*N)</pre><p>The typical output is something like this :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/565/1*NTJvymJVLyCCwQ0BLB3Tjw.png" /></figure><p>Loss function value : 8.14</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/565/1*5S0xuG_JYxOWRmD_TCWKSQ.png" /></figure><p>Loss function value : 1.9</p><p>As you can see, after gradient descent, the graph isn’t an eyesore anymore, like it was before.</p><p>This method is called force directed graph drawing. Essentially, we can think of each edge acting as a spring, applying force on the nodes proportional to the deformation in the spring.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=164b90368842" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Neural Network from scratch]]></title>
            <link>https://medium.com/@pranav.joshi_16395/neural-network-from-scratch-d36d0e71ce3e?source=rss-8fbecc8a385d------2</link>
            <guid isPermaLink="false">https://medium.com/p/d36d0e71ce3e</guid>
            <category><![CDATA[backpropagation]]></category>
            <category><![CDATA[automatic-differentiation]]></category>
            <category><![CDATA[gradient-descent]]></category>
            <category><![CDATA[neural-networks]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Pranav Joshi]]></dc:creator>
            <pubDate>Wed, 01 Nov 2023 20:33:04 GMT</pubDate>
            <atom:updated>2023-11-21T08:59:37.596Z</atom:updated>
            <content:encoded><![CDATA[<p>This isn’t one of those articles where you’ll be given an overview of the structure of a neural net. Neither is this a “for dummies” article where you’ll be taught how to use sklearn and scipy. Instead, this is more of a walk-through, where I provide all the necessary code to build a neural net from scratch, using no libraries whatsoever (well, except numpy and some visualisation related libraries, and sklearn.datasets to get a dataset), as well as information for everything to make sense. Firstly, it’s important for you to read one of those “overview” articles before reading this, because this isn’t a topic I can cover in a post of rational length. Anyway, let’s start.</p><h4>Gradient Descent</h4><p>This topic is something that you’ll find LOTS of resources on. For example, 3 Blue 1 Brown has a really nice video on it. So, I’ll just be skipping this. In the end, all you have to know is that for a function f(x), you update x by (-f’(x)*dt) where f’(x) is the gradient for ‘ f ’ wrt ‘ x ’ (x may be a vector) . dt is called the step size. It also doesn’t hurt to learn about Jacobians, inner dot product and tensors.</p><h4>Automatic differentiation</h4><p>The reason that gradient descent works so smoothly for incredibly complicated functions, with even more complicated gradients (as functions) is because, just like most problems, we can break big problems into smaller ones. This is done by the automatic differentiation, which is basically a clever way of using the multi-variable chain rule, which is given by :</p><figure><img alt="Multivariable Chain Rule" src="https://cdn-images-1.medium.com/max/175/0*xj3Zz3XcV1wt5uDD" /><figcaption>Multivariable Chain Rule</figcaption></figure><p>Here, ‘t’ is a variable (possibly a vector or a matrix) that all ‘x_i’ are dependent on (among other dependencies), and ‘f’ is dependent on ‘x_i’ (and thus, ultimately on t) . Now, all of this is good, but how does this help us ?</p><h4>Functions as Graphs</h4><p>You must already be thinking of functions as “things” that take in stuff and spit out stuff. Rather than just “things”, let’s use a word that’s a bit more formal. How about “nodes” ?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/244/1*Dirfx7zin1DVyHbVIOGJMw.png" /></figure><p>Actually, each node will be slightly more than just the function. It’ll store all (or sufficiently many) of these things : the input values (t) , the output value (x), the gradient of the final function (f ) with respect to the output of the node and the gradient of the final function (f) with respect to the input values. Usually, at any instant we need to apply the chain rule, we’ll already know the gradient of final function (f) wrt output (x) and we can compute the gradient of output (x) wrt any of the inputs (t). Now, here’s where I make an important distinctions : the input to a node (seen as a variable) and the input node (also a variable) that the input is identical to, are actually separate entities. When I say “gradient of f wrt input t” , I mean just that, and NOT “gradient of f wrt input node t” . This is because the input node could also be the input node of many other nodes, all of which will change on changing the value of the input node, but changing the value of the input doesn’t change the value of other nodes. Okay, enough details.</p><p>Simple, but fundamental functions, like addition, multiplication, etc. are what we can easily compute gradients for. So, once we implement these as nodes, we can implement complicated functions, like polynomials, sigmoid, etc. as graphs made of these nodes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/177/1*8sFLpNxWCtYAD92Az80Y4w.png" /><figcaption>The function sin(0.01 x²) implemented as a graph</figcaption></figure><p>For example, in the above graph, “sin” is the final node and thus the output function. Obviously, the gradient of output function (f = “sin”) wrt the output of the node “sin” is 1 . So far, I’ve talked about the gradient of final function wrt output in a verbose manner. lets name it something. How about a property of the node objects, say <em>node.grad</em>. Similarly, let’s name the gradient of output wrt input as <em>node.dxdt</em> . So, the gradient of ‘f’ wrt input ‘t’ is given by… <em>node.grad * node.dxdt </em>. Here, the * operation represents either matrix multiplication, or inner product, depending on the context.</p><p>But this isn’t the end. For any input node, <em>t </em>, the value<em> t.grad </em>is caluculated by summing all the values of <em>node.grad * node.dxdt </em>for all the ouptut nodes of <em>t .</em></p><p>Okay, now equipped with this knowledge, let’s implement this in python :</p><pre>from matplotlib.pyplot import *<br>from numpy import *<br>from numpy.linalg import eig<br>from graphviz import Digraph<br>from IPython.display import Image<br>from sklearn.datasets import load_breast_cancer<br><br>g = Digraph(&quot;Network&quot;)<br>NodeCollection = []<br>Variables = []<br>counter = 0<br>dt = 0.01<br>def Reset():<br>    global g,NodeCollection,Variables,counter,dt<br>    g = Digraph(&quot;Network&quot;)<br>    NodeCollection = []<br>    Variables = []<br>    counter = 0<br>    dt = 0.01<br>class Node:<br>    name = None<br>    COLOR = &quot;black&quot;<br>    def __init__(self,name=None,draw=True):<br>        global counter,NodeCollection<br>        self.value = None<br>        self.outputNodes = []<br>        self.inputNodes = []<br>        self.id = counter<br>        self.draw = draw<br>        if self.name==None and name == None:<br>            self.name = &quot;node &quot;+str(self.id)<br>        elif name!=None:<br>            self.name = name<br>        if draw:<br>            g.node(str(self.id),self.name,color=self.COLOR)<br>        NodeCollection.append(self)<br>        counter += 1<br>    def __repr__(self):<br>        return f&quot;name : {self.name}\n value : \n{self.value}\n grad : \n{self.grad}\n&quot;<br>    def __add__(self,other):<br>        return Add([self,toNode(other)],&quot;+&quot;)<br>    def __mul__(self,other):<br>        return Mul([self,toNode(other)],&quot;*&quot;)<br>    def __pow__(self,other):<br>        return Pow([self,toNode(other)],&quot;**&quot;)<br>    def __div__(self,other):<br>        return self*(other**(-1))<br>    def __neg__(self):<br>        return Neg([self],&quot;-&quot;)<br>    def __sub__(self,other):<br>        return self + (-toNode(other))<br>    def recieve(self):<br>        self.grad = 0<br>        for n in self.outputNodes:<br>            DFDX = n.dfdx_value[self.id]<br>            GRAD = n.grad<br>            #print(&quot;recievong from&quot;,n.id,&quot;aka&quot;,n.name)<br>            #print(&quot;DFDX&quot;,DFDX.shape)<br>            #print(&quot;GRAD&quot;,GRAD.shape)<br>            if len(DFDX.shape)==1 and GRAD.shape==(1,DFDX.shape[0]) and len(self.value.shape)==2:<br>                self.grad += GRAD.T @ DFDX[newaxis,:]<br>            elif DFDX.shape==(1,) or GRAD.shape ==(1,):<br>                self.grad += GRAD*DFDX<br>            else:<br>                #self.grad += dot(GRAD,DFDX)<br>                self.grad += GRAD @ DFDX<br>class Function(Node):<br>    COLOR = &quot;green&quot;<br>    f = None<br>    dfdx = None<br>    dfdx_value = None<br>    def __init__(self,inputNodes,name=None,draw=True):<br>        super().__init__(name,draw)<br>        for n in inputNodes:<br>            if self.draw and n.draw:<br>                g.edge(str(n.id),str(self.id))<br>            n.outputNodes.append(self)<br>        self.inputNodes = inputNodes<br>        self.forward()<br>    def __repr__(self):<br>        return f&quot;name : {self.name}\n value : \n{self.value}\n grad : \n{self.grad}\n dfdx : \n{self.dfdx_value}\n&quot;<br>    def forward(self):<br>        self.inputs = dict([(node.id,node.value) for node in self.inputNodes])<br>        self.value = self.f(self.inputs)<br>        self.dfdx_value = self.dfdx(self.inputs)<br>        n = int(prod(self.value.shape))<br>        #if n &gt; 1:<br>        #    self.grad = identity(n)    <br>        #else :<br>        #    self.grad = array(1)<br>        self.grad = identity(n)<br>    def backward(self):<br>        for n in self.inputNodes:<br>            n.recieve()<br>            n.backward()<br>class Variable(Node):<br>    COLOR = &quot;red&quot;<br>    def __init__(self,value,name=None,draw=True):<br>        super().__init__(name,draw)<br>        self.value = value<br>        n = prod(self.value.shape)<br>        self.grad = identity(n)<br>        Variables.append(self)<br>    def backward(self):<br>        pass<br>    def forward(self):<br>        global dt<br>        self.grad.resize(self.value.shape)<br>        self.value = self.value - self.grad*dt<br>class Constant(Variable):<br>    COLOR=&quot;black&quot;<br>    def recieve(self):<br>        pass<br>    def forward(self):<br>        pass<br>def toNode(other,draw=True):<br>    name = None<br>    if isinstance(other,Node):<br>        return other<br>    if type(other) != ndarray:<br>        if type(other) != iterable:<br>            name = str(other)<br>            other = array([other])<br>        else:<br>            other = array(other)<br>    return Constant(other,name,draw)</pre><p>Now, let’s add some fundamental functions.</p><pre>class Add(Function):<br>    name = &quot;+&quot;<br>    def f(self,inputs):<br>        S = 0<br>        for id in inputs:<br>            S = S + inputs[id]<br>        return S<br>    def dfdx(self,inputs):<br>        G = dict()<br>        for id in inputs:<br>            n = prod(inputs[id].shape)<br>            if n&gt;1:<br>                G[id] = identity(n)<br>            else:<br>                G[id] = ones(prod(self.value.shape))[:,newaxis]<br>        return G<br>class Mul(Function):<br>    name = &quot;*&quot;<br>    def f(self,inputs):<br>        S = 1<br>        for id in inputs:<br>            S = S*inputs[id]<br>        return S<br>    def dfdx(self,inputs):<br>        G = dict()<br>        for id in inputs:<br>            S = 1<br>            for Id in inputs:<br>                if Id == id:<br>                    continue<br>                S = S*inputs[Id]<br>            S = S.flatten()<br>            n = prod(inputs[id].shape)<br>            if n &gt; 1:<br>                m, = S.shape<br>                if m &gt; 1:<br>                    S = diag(S)<br>                else :<br>                    S = S * identity(n)<br>            else:<br>                S = S[:,newaxis]<br>            G[id] = S<br>        return G<br>class Exp(Function):<br>    name = &quot;exp&quot;<br>    def f(self,inputs):<br>        return exp(next(iter(inputs.values())))<br>    def dfdx(self,inputs):<br>        id = next(iter(inputs.keys()))<br>        x = next(iter(inputs.values()))<br>        n = prod(x.shape)<br>        x = exp(x)<br>        x = x.flatten()<br>        if n&gt;1:<br>            return {id:diagflat(x)}<br>        return {id:x[:,newaxis]}<br>class Pow(Function):<br>    name = &quot;**&quot;<br>    def f(self,inputs):<br>        x,n = inputs.values()<br>        return x**n<br>    def dfdx(self,inputs):<br>        ids = list(inputs.keys())<br>        x,n = inputs.values()<br>        m = prod(x.shape)<br>        if m &gt; 1:<br>            return {ids[0]:diagflat(n*x**(n-1))}#,ids[1]:(log(x)*x**n).flatten()[:,newaxis]}<br>        return {ids[0]:(n*x**(n-1)).flatten()[:,newaxis]}#,ids[1]:(log(x)*x**n).flatten()[:,newaxis]}<br>class Neg(Function):<br>    name = &quot;-&quot;<br>    def f(self,inputs):<br>        return -next(iter(inputs.values()))<br>    def dfdx(self,inputs):<br>        id = next(iter(inputs.keys()))<br>        x = next(iter(inputs.values()))<br>        n = int(prod(x.shape))<br>        #if n&gt;1:<br>        #    return {id:-identity(n)}<br>        #return {id:-array([1])}<br>        return {id:-identity(n)}<br>class Dot(Function):<br>    name = &quot;.&quot;<br>    def f(self,inputs):<br>        x,y = inputs.values()<br>        d = dot(x,y)<br>        if len(d.shape)&gt;0:<br>            return d<br>        return array([d])<br>    def dfdx(self,inputs):<br>        id1,id2 = inputs.keys()<br>        x,y = inputs.values()<br>        if len(x.shape) == 1:<br>            x = x[newaxis,:]<br>            y = y[newaxis,:]<br>        return {id1:y,id2:x}<br>class Sum(Function):<br>    name = &quot;sum&quot;<br>    def f(self,inputs):<br>        x, = inputs.values()<br>        return array([sum(x)])<br>    def dfdx(self,inputs):<br>        x, = inputs.values()<br>        id, = inputs.keys()<br>        n = prod(x.shape)<br>        return {id:ones((1,n))}<br>class Sin(Function):<br>    name = &quot;sin&quot;<br>    def f(self,inputs):<br>        x, = inputs.values()<br>        return sin(x)<br>    def dfdx(self,inputs):<br>        x, = inputs.values()<br>        id, = inputs.keys()<br>        n = prod(x.shape)<br>        return {id:diagflat(cos(x))}<br>class Cos(Function):<br>    name = &quot;cos&quot;<br>    def f(self,inputs):<br>        x, = inputs.values()<br>        return cos(x)<br>    def dfdx(self,inputs):<br>        x, = inputs.values()<br>        id, = inputs.keys()<br>        n = prod(x.shape)<br>        return {id:diagflat(sin(x))}<br>class MatFunc(Function):<br>    def forward(self):<br>        self.inputs = dict([(node.id,node.value) for node in self.inputNodes])<br>        self.value = self.f(self.inputs)<br>        n = int(prod(self.value.shape))<br>        self.grad = identity(n)<br>    def __init__(self, inputNodes, name=None, draw=True):<br>        super().__init__(inputNodes, name, draw)<br>        for n in self.inputNodes:<br>            if isinstance(n,MatFunc):<br>                n.recieve = n.send<br>            else:<br>                n.recieve = lambda:None<br>    def recieve(self):<br>        super().recieve()<br>        self.send()<br>class MatMul(MatFunc):<br>    name = &quot;@&quot;<br>    def f(self,inputs):<br>        W,X = inputs.values()<br>        return W @ X<br>    def send(self):<br>        w,x = self.inputNodes<br>        W = w.value<br>        X = x.value<br>        G = self.grad<br>        w.grad = G @ X.T<br>        x.grad = W.T @ G<br>class SigmM(MatFunc):<br>    name = &quot;Sigma&quot;<br>    def f(self,inputs):<br>        X, = inputs.values()<br>        return sigmoid(X)<br>    def send(self):<br>        x, = self.inputNodes<br>        X = x.value<br>        G = self.grad<br>        sig = sigmoid(X)<br>        x.grad = G*(sig**2/exp(X))<br>class SqNorM(MatFunc):<br>    name = &quot;Norm&quot;<br>    def f(self,inputs):<br>        X, = inputs.values()<br>        return array([linalg.norm(X,&#39;fro&#39;)])**2<br>    def send(self):<br>        x = self.inputNodes[0]<br>        x.grad = 2*x.value*self.grad<br>class DotM(MatFunc):<br>    name = &quot;.&quot;<br>    def f(self,inputs):<br>        X,Y = inputs.values()<br>        return sum(X*Y)<br>    def send(self):<br>        x,y = self.inputNodes<br>        X,Y = x.value,y.value<br>        G = self.grad # assumed to be a scalar<br>        x.grad = Y*G<br>        y.grad = X*G<br>class AddM(MatFunc):<br>    name = &quot;+&quot;<br>    def f(self,inputs):<br>        S = 0<br>        for X in inputs.values():<br>            S = S + X<br>        return S<br>    def send(self):<br>        for n in self.inputNodes:<br>            if len(n.value.shape) == 1 or n.value.shape[0] == 1:<br>                n.grad = sum(self.grad,axis=0)<br>            else :<br>                n.grad = self.grad<br>class NegM(MatFunc):<br>    name = &quot;-&quot;<br>    def f(self,inputs):<br>        X, = inputs.values()<br>        return -X<br>    def send(self):<br>        x = self.inputNodes[0]<br>        x.grad = -self.grad</pre><h4>Back Propogation</h4><p>Remember what we are doing all of this for. We want to do gradient descent on the nodes that are not dependent on any other nodes. We know we can calculate the gradient of f wrt the value of a node, given all the output nodes have the latest value of the gradient wrt their value. This means, we must compute these values in the reverse topological order (order of creation, in this case) . And, of course, for computing the outputs of the nodes, we need to move in forward order. The first process is called “back propogation” . Similarly, I name the 2nd process as “forward propogation” .</p><pre>def BacProp(show=False):<br>    global NodeCollection<br>    for i in range(len(NodeCollection)-2,-1,-1):<br>        n=NodeCollection[i]<br>        n.recieve()<br>        if show:<br>            print(n)<br>def forProp(show=False):<br>    global NodeCollection<br>    for i in range(len(NodeCollection)):<br>        n=NodeCollection[i]<br>        n.forward()<br>        if show:<br>            print(n)<br>def Descend(iterations=100):<br>    global dt,NodeCollection,Variables<br>    L = NodeCollection[-1]<br>    print(&quot;before&quot;,L.value)<br>    for i in range(iterations):<br>        BacProp()<br>        forProp()<br>    print(&quot;after&quot;,L.value)</pre><p>In fact, with all of this, we are in a position to build a small neural network right away. First, let’s make a perceptron.</p><h4>Perceptron</h4><p>It’s a function that outputs the sigmoid of a linear combination of different features (nodes in preceding layer) . The weights are variables (hyperparameters really). There’s a lot of information on this topic as well, so I’ll just shut up and show you the code.</p><pre>def Sigm(inputNode,name=&quot;S&quot;,draw=True):<br>    out = Neg([inputNode],None,False)<br>    out = Exp([out],None,False)<br>    out = Add([out,Constant(array([1]),None,False)],None,False)<br>    out = Pow([out,Constant(array([-1]),None,False)],name,True)<br>    if draw and inputNode.draw:<br>        g.edge(str(inputNode.id),str(out.id))<br>    return out<br>def perceptron(layer,draw=True):<br>    nl = []<br>    for n in layer :<br>        nl.append(Mul([n,Variable(random.random(1),None,False)],None,False))<br>    S = Add(nl,&quot;+&quot;,False)<br>    S = Sigm(S,&quot;P&quot;,draw)<br>    for n in layer:<br>        if draw and n.draw:<br>            g.edge(str(n.id),str(S.id))<br>    S.nl = nl<br>    return S</pre><h4>Loss function</h4><p>Let’s also make a function that gives the Euclidian norm between two vectors. This is what’s called a loss function (there are many varieties) which tells us how bad was our prediction.</p><pre>def SqEr(inputNodes,name=&quot;L&quot;,draw=True):<br>    x,y = inputNodes<br>    out = Neg([y],None,False)<br>    out = Add([x,out],None,False)<br>    two = Constant(array([2]),None,False)<br>    out = Pow([out,two],None,False)<br>    out = Sum([out],name,draw)<br>    for n in inputNodes:<br>        if draw and n.draw:<br>            g.edge(str(n.id),str(out.id))<br>    return out</pre><h4>Neural Net</h4><p>And finally, let’s make the whole neural net.</p><pre>class PerceptronNet:<br>    def __init__(self,LS=[3,3,3]):<br>        Reset()<br>        n = LS.pop(0)<br>        layer0 = [Constant(ones(1)) for _ in range(n)]<br>        layers = [layer0]<br>        for n in LS:<br>            last_layer = layers[-1]<br>            layers.append([perceptron(last_layer) for _ in range(n)])<br>        P = perceptron(layers[-1])<br>        y = Constant(ones(1),&quot;y&quot;)<br>        L = SqEr([P,y])<br>        self.y = y<br>        self.layer0 = layer0<br>        self.P = P<br>        self.L = L<br>        self.layers = layers<br>    def assign(self,X,y):<br>        if X.shape[1]!= len(self.layer0):<br>            print(&quot;Can&#39;t deal with this many variables.&quot;)<br>            return<br>        self.y.value = y<br>        for c in range(X.shape[1]):<br>            self.layer0[c].value = X[:,c]<br>        forProp()<br>    def predict(self):<br>        forProp()<br>        loss = self.L.value<br>        y_pred = self.P.value<br>        return y_pred,loss</pre><h4>Usage</h4><p>We can use this on the breast cancer dataset like this :</p><pre>X,y = load_breast_cancer(return_X_y=True)<br><br># PCA<br>mu = mean(X,0)<br>sd = var(X,0)**0.5<br>X = (X-mu)/sd<br>## Assume X = Y @ R.T and Y.T @ Y = D<br>C = X.T @ X<br>D,RT = eig(C)<br>idx = argsort(D)[-7:]<br>D = D[idx]<br>RT = RT[:,idx]<br>X = X @ RT<br>X = real(X)<br><br>#Neural Net<br>net = PerceptronNet([7,5])<br>net.assign(X,y)<br>dt = 0.001<br>print(mean(around(net.P.value)==net.y.value)*100)<br>Descend(100)<br>print(mean(around(net.P.value)==net.y.value)*100)<br>Show(g,200)</pre><p>This gives this graph :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/939/1*goYDdwHa6c5vFTV_1Pm1ug.png" /></figure><p>And an accuracy of 90-95% on the training data itself. We can increase this drastically by descending for longer. But to do that, we need a more efficient model. This can be done by using matrices for layers, rather than individual perceptrons.</p><h4>Layers as Matrices</h4><pre>def Layer(X,pin,pout,name=None,bias=False):<br>    W = random.random((pin,pout))<br>    W = Variable(W,&quot;W&quot;)<br>    Y = MatMul([X,W])<br>    if bias:<br>        b = random.random(pout)<br>        b = Variable(b,&quot;b&quot;)<br>        Y = AddM([Y,b]) <br>    Y = SigmM([Y],name)<br>    return Y<br>def SqErM(inputNodes):<br>    yp,y = inputNodes<br>    myp = NegM([yp])<br>    er = AddM([myp,y])<br>    s = SqNorM([er],&quot;L&quot;)+0<br>    return s<br>class LayerNet:<br>    def __init__(self,X,y,middle=[3,3,3],bias=False,normalise=False):<br>        if isinstance(bias,bool):<br>            bias = [bias for _ in range(len(middle))]<br>        Reset()<br>        m = X.shape[1]<br>        n = y.shape[1]<br>        mu = mean(X,axis=0)<br>        sdi = diag(var(X,axis=0)**(-0.5))<br>        X = Constant(X,&quot;X&quot;)<br>        y = Constant(y,&quot;y&quot;)<br>        self.y = y<br>        self.X = X<br>        if normalise:<br>            nmu = Constant(-mu,&#39;b&#39;)<br>            sdi = Constant(sdi,&quot;W&quot;)<br>            X = AddM([X,nmu],&quot;shifted X&quot;)<br>            X = MatMul([X,sdi],&quot;normalised X&quot;)<br>        out = Layer(X,m,middle[0],&quot;layer1&quot;,True)<br>        for i in range(len(middle)-1):<br>            out = Layer(out,middle[i],middle[i+1],f&quot;layer {i+1}&quot;,bias[i])<br>        y_pred = Layer(out,middle[-1],n,&#39;y_pred&#39;,bias[-1])<br>        L = SqErM([y_pred,y])<br>        self.y_pred = y_pred<br>        self.L = L<br>    def predict(self,X,y,testName = &quot;&quot;):<br>        self.X.value = X<br>        self.y.value = y<br>        forProp()<br>        y_pred = self.y_pred.value<br>        print(&quot;accuracy on test &quot;,testName,&quot; : &quot;,round(mean(around(y_pred)==y)*100,2),&quot;%&quot;)<br>        return y_pred<br>    def train(self,iterations=100,dtvalue=0.01):<br>        global dt<br>        dtold = dt<br>        dt = dtvalue<br>        y_pred = self.y_pred.value<br>        y = self.y.value<br>        print(&quot;accuracy before training : &quot;,round(mean(around(y_pred)==y)*100,2),&quot;%&quot;)<br>        Descend(iterations)<br>        dt = dtold<br>        y_pred = self.y_pred.value<br>        y = self.y.value<br>        print(&quot;accuracy on training : &quot;,round(mean(around(y_pred)==y)*100,2),&quot;%&quot;)</pre><p>Using this for the breast cancer dataset, without even doing PCA, we get 96–98% accuracy on a testing data</p><pre>X,y = load_breast_cancer(return_X_y=True)<br>m = X.shape[1]<br>X = X[:]<br>y = y[:,newaxis]<br>X,Xtest = X[:300,:],X[300:,:]<br>y,ytest = y[:300,:],y[300:,:]<br><br>net = LayerNet(X,y,[5],normalise=True)<br>net.train(100,0.01)<br>net.predict(Xtest,ytest)<br>Show(g)</pre><p>This looks like this :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/177/1*QzKSexyNoqCdzdfCb9Xoxg.png" /><figcaption>A neural net with middle layer of 5 perceptrons</figcaption></figure><p>Now, you might think that neural networks aren’t so good after all, since we got only 96–98% accuracy. Well, the thing is, I chose the wrong activation function and loss function. We usually use the sigmoid and squared error for regression tasks. In this case, we want to perform a classification task, for which we should use the soft-max activation function and cross entropy loss function. Using that and 2 hidden layers with 10 and 3 perceptrons, we get this output :</p><pre>accuracy before training :  48.67 %<br>before [2.77841405]<br>after [0.00597004]<br>accuracy on training :  100.0 %<br>accuracy on test    :  96.28 %</pre><p>The model was trained on 300 samples and tested on 300 samples from the breast cancer dataset. To view the final code click on the link :</p><p><a href="https://github.com/pranav-joshi-iitgn/ML_Journey/blob/main/AutoDif.ipynb">ML_Journey/AutoDif.ipynb at main · pranav-joshi-iitgn/ML_Journey</a></p><p>A more complicated example would be a hand written digit classifier.</p><p><a href="https://github.com/pranav-joshi-iitgn/ML_Journey/blob/main/Digits.ipynb">ML_Journey/Digits.ipynb at main · pranav-joshi-iitgn/ML_Journey</a></p><h4>End</h4><p>Finally, I’ll like to admit that this was my first time building a neural network as well. I’m NOT an expert in this field, so take everything (especially notation) with a grain (brick) of salt. Hopefully, this helps some lost soul, just as an older Medium post on the same topic helped me.</p><h4>Contact</h4><p>Linked In : <a href="https://www.linkedin.com/in/pranav-joshi-77487b232/">https://www.linkedin.com/in/pranav-joshi-77487b232/</a></p><p>YouTube : <a href="https://youtube.com/@iknowsomestuff7131?feature=shared">https://youtube.com/@iknowsomestuff7131?feature=shared</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d36d0e71ce3e" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>