Heuristics What Why and How?. Understanding and application of... | by Anuradha Wickramarachchi | Towards Data Science

A heuristic is an approximation that we use, which is not guaranteed to be optimal in achieving a goal. In this article, I will explain in-depth with an easy to understand example as to how we can use heuristics for algorithm development. So I will use A-Star (A*) algorithm to explain the use and implementation of heuristic with a complete example which involves the shortest path.
Deterministic approaches are guaranteed to provide the optimal solution. For example, Dijkstra’s algorithm can be considered as a deterministic algorithm to obtain the shortest path between any two points in a graph with positive edge weights.
The algorithm in its most basic form is as follows.
function Dijkstra(Graph, Start)    vertex_set = {}    for each vertex v in Graph        dist[v] = INFINITY        parent[v] = NULL    dist[start] = 0    while vertex_set not empty        u = min dist vertex from vertex_set        remove u from vertex_set                for neighbour v of u            path = dist[u]+length(u,v)            if path < dist[v]                dist[v] = path                parent[v] = u    return parent, dist
We can see that at the start of the algorithm no knowledge about the target node is considered. Hence, we essentially perform an exhaustive search of shortest paths to every other traversable vertex of the graph. Imagine that the graph is the road network in your country. Would this scale to that level? The best-case min-priority queue implementation of Dijkstra’s algorithm has a time complexity of O(|V|+|E|log|V|).
Instead of exhaustively searching for all the neighbours of the source vertex, we might as well pick the neighbours, that MIGHT give us a shorter path to the target vertex in a graph. The word MIGHT is important as heuristics often end up in suboptimal outputs.
How can we guess if a particular node might lead us to a shorter path?
This is where the heuristic comes. For example, in the case of the road network, we can assume that closer the junction we pick, faster it might get to the target city. For this, we can use the readily available GPS information to compute a not so elite, but a rough distance from the air between points. Although, it might not be correct, given you might see bends and roundabouts, we can be sure that the general direction of movement must be towards the target city.
Comparing the above guess, you might note that had we chosen Dijkstra’s algorithm we would also be computing distances to target nodes that are nowhere in the direction of our intended target. The solid implementation of the most famous shortest path algorithm with a heuristic is called the A* or the A-Star algorithm.
The pseudo-code would look like below. Read more in the wiki.
function AStar(start, end, heuristic=h)    open_set = {start}    closed_set = {}    # distance so far to node    distance = lookup_table(default=INFINITY)    # guess to the end    guess = lookup_table(default=INFINITY)    distance[start] = 0    guess[start] = h(start)    while open_set not empty        current = node with lowest guess from open_set        if current is goal: END                open_set.remove(current)        closed_set.add(current)        for neighbour of current            score = distance[current]+length(current,neighbour)            if score < guess[neighbour]                distance[neighbour] = score                parent[neighbour] = current                guess[neighbour] = distance[neighbour]+h(neighbour)                if neighbour not in closed_set                    open_set.add(neighbour)
One catch in the above code is that you have to scan an array to obtain the closes neighbour to the target node. However, if you would store the neighbours in a min-heap using the distance to the target node as key, you can do this in O(1) time. It's pretty neat how we can improve algorithms with the knowledge of data structures (Read this article to read more). Let us have a look at an implementation of this algorithm in python.
I will be using the following imports in my algorithm implementation.
import numpy as npimport matplotlib.pyplot as plt # for visualizationsimport matplotlib.cm as cm      # for visualizationsfrom collections import defaultdictimport networkx as nx           # storing the graph structureimport heapq                    # pythons heap implementation
I will create a wrapper class to contain vertices (nodes) of the graph so that I can use them right away in heapq implementation of python. I override the __lt__ function of the wrapper so that heapq will use its val attribute to heappush and heappop elements. This val corresponds to the distance to target node.
class NodeWrap:    def __init__(self, nid, dist_to_end):        self.nid = nid        self.val = dist_to_end            def __lt__(self, other):        return self.val < other.val
I use the following code to trace the shortest path once the algorithm runs and we have parents of the vertices we traverse.
def trace_path(parent_map, end):    node = end    path = []    while (node in parent_map):        path.append(node)        node = parent_map[node]    return path[::-1]
Since this is for learning purposes, I thought it would be very interesting to see how the algorithm runs in real-time. Hence, let’s model our problem as below. Imagine we have a 2D terrain and we want to get to top right corner from the bottom left corner. We have an obstacle on the way as shown in black.
I populate this terrain using the following code. It is a simple matrix of size 50x50. The image is a matrix with 3 channels for RGB. So the obstacles are simply indices with [0, 0, 0].
mat = np.zeros((50, 50, 3), dtype=np.float32)for x in range(50):    for y in range(50):        mat[x, y, 0] = 0.95        mat[x, y, 1] = 0.95        mat[x, y, 2] = 0.95        g.add_node("{},{}".format(x,y), x=x, y=y)# endmat[49, 0, 0] = .0mat[49, 0, 1] = .0mat[49, 0, 2] = 0.8        # startmat[0, 49, 0] = .0mat[0, 49, 1] = 0.8mat[0, 49, 2] = .0for x in range(20, 50):    for y in range(20, 50):        if x>y+5:            mat[x, y, 0] = .0            mat[x, y, 1] = .0            mat[x, y, 2] = .0for x in range(0, 30):    for y in range(0, 30):                    if x<y-5:            mat[x, y, 0] = .0            mat[x, y, 1] = .0            mat[x, y, 2] = .0for x in range(26, 30):    for y in range(10, 20):         mat[x, y, 0] = .0        mat[x, y, 1] = .0        mat[x, y, 2] = .0mat_copy = np.copy(mat)
Since graph operations are best performed on a graph like data structure with graph functionalities, let's migrate our terrain to a graph. Each pixel will be a node/vertex in our graph. From each node/vertex we can traverse in all 8 directions similar to the queen on a chessboard. In our case edges exist, unless the available reversible neighbour is black.
g = nx.Graph()def is_black(arr):    return sum(arr) == 0            for x in range(50):    for y in range(50):        xi = g.nodes["{},{}".format(x,y)]['x']        yi = g.nodes["{},{}".format(x,y)]['y']        for xj in range(xi-1, xi+2):            for yj in range(yi-1, yi+2):                if xi==xj and yi==yj or \                    xj<0 or yj<0 or \                    xj>49 or yj>49:                     continue                # if black not a neighbour                if is_black(mat[xi, yi]) \                    or is_black(mat[xj, yj]):                     continue                g.add_edge("{},{}".format(xi,yi), "{}, \                                     {}".format(xj,yj))
Now that I have prepared my graph, I want to implement and run A-Star. At the same time, when the algorithm searches neighbours, I want to snapshot the initial 2D terrain for visualization later. The complete python code for this is as follows. Here I use the Euclidean distance as the distance heuristic, which is the guess from any node to the target.
def store_image(world, node, i):    global mat    x = world.nodes[node]['x']    y = world.nodes[node]['y']        mat[x, y, 0] = 1    mat[x, y, 1] = .5    mat[x, y, 2] = .0        fig = plt.figure(figsize=(10, 10))    plt.imshow(mat)    plt.axis('off')    plt.savefig("im-{}.png".format(i))    plt.close()def euclidean_dist(world, node1, node2):    x1 = world.nodes[node1]['x']    x2 = world.nodes[node2]['x']    y1 = world.nodes[node1]['y']    y2 = world.nodes[node2]['y']        return ((x1-x2)**2 + (y1-y2)**2)**.5def a_star(world, start, end):    stepper = 1    open_set = []    close_set = set()    parent_map = {}        start_node = NodeWrap(start, 0)    heapq.heappush(open_set, start_node)        store_image(world, start, stepper)    stepper += 1         cost_to_reach_node = defaultdict(lambda: float('inf'))    cost_to_reach_node[start] = 0        guess_to_destination = defaultdict(lambda: float('inf'))    guess_to_destination[start] = euclidean_dist(world, start, end)        while len(open_set) > 0:        current = heapq.heappop(open_set)        if current.nid == end:            path = trace_path(parent_map, end)             return path        close_set.add(current.nid)                for neighbor in world.neighbors(current.nid):            tentative_score = cost_to_reach_node[current.nid] + \                      euclidean_dist(world, current.nid, neighbor)            if tentative_score < cost_to_reach_node[neighbor]:                parent_map[neighbor] = current.nid                cost_to_reach_node[neighbor] = tentative_score                guess_to_destination[neighbor] =                               cost_to_reach_node[neighbor] + \                          euclidean_dist(world, neighbor, end)                                if neighbor not in close_set:                    neighbor_node = NodeWrap(neighbor,                                         euclidean_dist(world, neighbor, end))                    heapq.heappush(open_set, neighbor_node)                    store_image(world, neighbor, stepper)                    stepper += 1
We can run the above algorithm using the following runner snippet. Note that I use a copy of the matrix since within the algorithm we update the initial mat object.
path = a_star(g, "49,0", "0,49")# trace path and visualize on terrainfor node in path:    x = g.nodes[node]['x']    y = g.nodes[node]['y']        mat_copy[x, y, 0] = 1    mat_copy[x, y, 1] = .5    mat_copy[x, y, 2] = .0fig = plt.figure(figsize=(10, 10))plt.imshow(mat_copy)plt.axis('off')
The beauty is more visible when we see how things happen in animation. So I will combine all my created images to form a gorgeous GIF. I use the following code to create the GIF out of my snapshot images.
import imageioimport globanim_file = 'out.gif'with imageio.get_writer(anim_file, mode='I', fps=10) as writer:    filenames = glob.glob('im-*.png')    filenames = sorted(filenames, \        key=lambda x: int(x.replace("im-", "").replace(".png", "")))    last = -1    for i,filename in enumerate(filenames):        frame = 2*(i**0.5)        if round(frame) > round(last):            last = frame        else:            continue        image = imageio.imread(filename)        writer.append_data(image)    image = imageio.imread(filename)    writer.append_data(image)    image = imageio.imread(filename)    writer.append_data(image)    image = imageio.imread(filename)    writer.append_data(image)
Having done this we will have the following animation.
We can clearly see that the traversal happens by a very narrow search space. This makes the search much faster and we do not need to do an exhaustive search over all the nodes.
In much larger graphs, techniques such as node labelling and indexing are done, so that the graph will not be required to load fully to the runtime memory. I hope to write more articles in line with this topic in future. For now, I hope you’ve enjoyed reading this article. You can see the complete notebook here.