8 x 8 Knight's Tour generated by Neural Network

I recently came across this post by Dimitry Brant: Knight’s Tours Using a Neural Network. This version is implemented in C++ with flat arrays. A properly vectorized numpy re-implementation is long overdue, so I decided to give it a shot.

A knight's tour is a sequence of moves of a knight on a chessboard such that the knight visits every square exactly once. There are at-least 4 ways to solve this: Backtracking, Divide an Conquer, Warnsdorff's Heuristic and what most interesting of all, using a Neural Network.

This is neither the most efficient nor practical algorithm to solve this problem, but it's definitely the most elegant way. Also, is it really a NN solution though? It feels more like a graph-theory problem to me.

Algorithm

1. Setup a Knight's Graph, A graph of all possible paths on a given chessboard. We represent this using a np array of shape (4, N). The 4 columns representing a Neuron's links in the chess board<x0, y0, x1, y1>. N being the number of neurons. For an 8x8 chessboard N = 168.
2. Initialize Vt, a vector of size N, initialized randomly with 0/1. This vector is the Neural Network's state. Each element in Vt represents one Neuron.
3. Initialize Ut, a zeros vector of size N.
4. Repeat following
1. Ut_1 = Ut + 3 - sum(Vt of Corresponding Neighboring Neurons) - Vt
2. Set Vt = 0 in indexes where Ut_1 < 0
3. Set Vt = 1 in indexes where Ut_1 > 3
4. Ut_1 = Ut
5. Check if Active Neurons (Neurons's with 1s in Vt) graph is 2-degreed.
    1. If so, Check if a Hamiltonian Tour exists. If yes, we have a solution.
6. If No solution has been found after an arbitrary number of attempts, try with new initial conditions.

Code

import numpy as np
import itertools

knight_moves = np.array(
    [[-2, -1], [-2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2], [2, -1], [2, 1]])


def create_neurons(width, height):
    neurons = set()
    for i, j, (k, l) in itertools.product(range(width), range(height), knight_moves):
        u, v = i+k, j+l
        if 0 <= u < width and 0 <= v < height and (u, v, i, j) not in neurons:
            neurons.add((i, j, u, v))
    return np.array(list(neurons))


def create_neighbour_bitfield(neurons):
    neis = []
    for i, j, u, v in neurons:
        n1 = np.all(neurons[:, :2] == [i, j], axis=1)
        n2 = np.all(neurons[:, 2:] == [i, j], axis=1)
        n3 = np.all(neurons[:, :2] == [u, v], axis=1)
        n4 = np.all(neurons[:, 2:] == [u, v], axis=1)
        neigh = np.zeros(shape=(len(neurons)), dtype=np.int16)
        neigh[np.argwhere(n1 | n2 | n3 | n4)] = 1
        neis.append(neigh)
    neis = np.array(neis)
    np.fill_diagonal(neis, 0)
    return neis.astype(np.bool)


def is_2_degree(Vt, N):
    active_neurons = N[Vt.astype(np.bool)]
    catted = np.concatenate(
        (active_neurons[:, 2:], active_neurons[:, :2]), axis=0)
    _, counts = np.unique(catted, return_counts=True, axis=0)
    return len(counts[counts != 2]) == 0


def hamiltonian_cycle(N, Vt, width, height):
    active_neurons = N[Vt.astype(np.bool)]
    curr = [0, 0]
    board = np.zeros((width, height), dtype=np.int16)
    index = 0
    while len(active_neurons) > 0:
        board[curr[0], curr[1]] = index
        index += 1
        active_neighbours = active_neurons[
            np.all(active_neurons[:, 2:] == curr, axis=1) |
            np.all(active_neurons[:, :2] == curr, axis=1)
        ]
        if len(active_neighbours) == 0:
            return (False, [])
        nex = active_neighbours[0]
        active_neurons = active_neurons[np.logical_not(
            np.all(active_neurons == nex, axis=1))]
        curr = nex[2:] if np.all(nex[:2] == curr) else nex[:2]
    return (True, board)


def knights_tour(width, height):
    while True:
        N = create_neurons(width, height)
        Vt = np.random.randint(2, size=(len(N)), dtype=np.int16)
        Ut = np.zeros(shape=(len(N)), dtype=np.int16)
        G = create_neighbour_bitfield(N)

        for _ in range(40):
            Vt_tile = np.tile(Vt, (len(N), 1))
            Ut_1 = Ut + 3 - np.sum(Vt_tile * G, axis=1) - Vt
            if np.count_nonzero(Ut != Ut_1) == 0:
                break
            Vt[np.argwhere(Ut_1 < 0).ravel()] = 0
            Vt[np.argwhere(Ut_1 > 3).ravel()] = 1
            Ut = Ut_1

            if is_2_degree(Vt, N):
                tour_found, tour = hamiltonian_cycle(N, Vt, width, height)
                if tour_found:
                    return tour


if __name__ == "__main__":
    print("running knights tour NN..")
    print(list(knights_tour(8, 8).flatten()))