How to pickle class() objects with children or neighbour relationships without hitting recursion limits and retain objects when loading

Viewed 223

There is a TLDR at the bottom. I have a python class as follows which is used to make nodes for D* Pathfinding (Pathfinding process not included):

import numpy as np
from tqdm import tqdm 
import sys
class Node():
    
    """A node class for D* Pathfinding"""

    def __init__(self,parent, position):
        self.parent = parent
        self.position = position
        self.tag = "New"
        self.state = "Empty"
        self.h = 0
        self.k = 0
        self.neighbours = []

    def __eq__(self, other):
        if type(other) != Node : return False
        else: return self.position == other.position
    def __lt__(self, other):
        return self.k < other.k

def makenodes(grid):
    
    """Given all the enpoints, make nodes for them"""
    endpoints = getendpoints(grid) 
    nodes = [Node(None,pos) for pos in endpoints]
    t = tqdm(nodes, desc='Making Nodes') #Just creates a progress bar
    for node in t:
        t.refresh()
        node.neighbours = getneighbours(node,grid,nodes)
    return nodes
def getneighbours(current_node,grid,spots):
'''Given the current node, link it to it's neighbours''' 
    neighbours = []
    for new_position in [(0, -1), (0, 1), (-1, 0), (1, 0)]: # Adjacent nodes

            # Get node position
            node_position = (int(current_node.position[0] + new_position[0]), int(current_node.position[1] + new_position[1]))

            # Make sure within range
            if node_position[0] > (len(maze) - 1) or node_position[0] < 0 or node_position[1] > (len(maze[len(maze)-1]) -1) or node_position[1] < 0:
                continue

            # Create new node
            new_node = spots[spots.index(Node(None, node_position))]
            neighbours.append(new_node)
    return neighbours

def getendpoints(grid):
    
    """returns all locations on the grid"""
    
    x,y = np.where(grid == 0)
    endpoints = [(x,y) for (x,y) in zip(x,y)]
    return endpoints

"""Actually Making The Nodes Goes as Follows"""
grid = np.zeros((100,100))
spots = makenodes(grid)
sys.setrecursionlimit(40000)
grid = np.zeros((100,100))
spots = makenodes(grid)

with open('100x100 Nodes Init 4 Connected', 'wb') as f:
    pickle.dump(spots, f)
print('Done 100x100')

The program runs well however this node making process takes 3min on 100x100 but over a week on 1000x1000. To counter this, I am using pickle.dump() to save the all the nodes, then when I run my program I can use spots = pickle.load(...). On grid=np.zeros((40,50)) I have to set the recursion limit to around 16000, on 100x100, I increase the recursion limit to 40000 but the kernel dies.

To help you visualise how what the nodes are and how they are connected to neighbours, refer to the image I drew where black: Node and white: connection. Image Showing Nodes(black) and their connections(white)

If I change, getneighbours() to return tuples of (x,y) coordinates (instead of node objects) then I am able to pickle the nodes as they're no longer recursive. This approach slows down the rest of the program because each time I want to refer to a node I have to reconstruct it and search for it in the list spots. (I have simplified this part of the explanation as the question is already very long).

How can I save these node objects while maintaining their neighbours as nodes for larger grids?

TLDR: I have a node class that is recursive in that each node contains a reference to it's neighbours making it highly recursive. I can save the nodes using pickle for grid = np.zeros((40,50)) and setting a fairly high recursion limit of 16000. For larger grids I reach a point where I reach the max system recursion when calling pickle.dump

1 Answers

Once again TLDR included(Just so you can check if this will work for you).I found a workaround which preserves the time saving benefits of pickling but allows the nodes to be pickled even at larger sizes. Tests included to demonstrate this:

I changed the structure of each Node() in makenodes() that is to be pickled by stripping it of it's neighbours so that there is less recursion. With the method mentioned in the question, a grid = np.zeros((40,50)) would require sys.setrecursionlimt(16000) or so. With this approach, Python's in-built recursion limit of 1000 is never hit and the intended structure can be reconstructed upon loading the pickled file.

Essentially when pickling, the following procedure is followed.

  1. Make a list of all nodes with with their corresponding indexes so that:
In [1]: nodes
Out [1]: [[0, <__main__.Node at 0x7fbfbebf60f0>],...]
  1. Do the same for the neighbours such that:
In [2]: neighbours
Out [2]: [[0, [<__main__.Node at 0x7fbfbebf6f60>, <__main__.Node at 0x7fbfbec068d0>]],...]

The above is an example of what both nodes and neighbours look like for any size of maze. '...' indicates that the list continues for as many elements as it contains and an additional note is that len(nodes) should equal len(neighbours. The index is not necessary however I have included it as an additional check.

The changes are as follows:

def makenodes(grid):
    
    """Given all the enpoints, make nodes for them"""
    
    endpoints = getendpoints(grid)
    spots = [Node(None,pos) for pos in endpoints]
    neighbours = []
    nodes = []
    t = tqdm(spots, desc='Making Nodes')
    i = 0
    for node in t:
        t.refresh()
        neighbour = getneighbours(node,grid,spots)
        nodes.append([i,node])
        neighbours.append([i,neighbour])
        i+=1
            
    return nodes, neighbours

"""Actually Making the Nodes"""
grid = np.zeros((100,100))
nodes, neighbours = makenodes(grid)
arr = np.array([nodes,neighbours])
with open('100x100 Nodes Init 4 Connected.pickle', 'wb') as f:
    pickle.dump(arr,f)
print('Done 100x100')
def reconstruct(filename):

'''Reconstruct the relationships by re-assigning neighbours'''
    with open(filename, 'rb') as file:
        f = pickle.load(file)
    nodes = f[0]
    neighbours = f[1]
    reconstructed = []
    for node in nodes:
        i = node[0]
        for neighbour in neighbours[i][1]:
            node[1].neighbours.append(neighbour)
            reconstructed.append(node[1])
    return reconstructed

nodes_in = reconstruct('100x100 Nodes Init 4 Connected.pickle')

This achieves the desired output and can re-establish neighbour/children relationships when reloading(reconstructing). Additionally, the objects in the neighbours list of each node, point directly to their corresponding node such that if you run this on just the picked array without reconstructing it:

In [3]: nodes2[1][1] is neighbours2[0][1][0]
Out[3]: True

Time Differences Mean time to create and save a 50x50 grid was. Average time of doing this 60 times:

  1. Original Way: 9.613s
  2. Adapted Way (with reconstruction): 9.822s

Mean time to load:

  1. Original Way: 0.0582s
  2. Adapted Way: 0.04071s

Both File Sizes: 410KB
Conclusion

The adapted way can handle larger sizes and judging by the times, has the same performance. Both methods create files of the same size because pickle only stores each object once, read the documentation for this. Please note that the choice to use a 50x50 grid is to prevent the original method from hitting recursion limits. The maximum size I have been able to pickle with the adapted way is 500x500. I haven't done larger simply because the node making process took almost 2 days at that size on my machine. However loading 500x500 takes less than 1 sec so the benefits are even more apparent.

**TLDR: **I managed to save the nodes and their corresponding neighbours as an array of np.array([nodes.neighbours]) so that when pickling, the necessary data to re-establish relationships when un-pickling was available. In essence, the necessary data is computed and saved in a way that you don't need to raise the recursion limit. You can then reconstruct the data quickly when loading.

Related