Advent of Code 2021 in pure TensorFlow - day 4


Using tensors for representing and manipulating data is very convenient. This representation allows changing shape, organizing, and applying generic transformations to the data. TensorFlow - by design - executes all the data manipulation in parallel whenever possible. The day 4 challenge is a nice showcase of how choosing the correct data representation can easily simplify a problem.

The challenge itself is not complicated, but similarly to day 3, we’ll need to use a very dynamic element offered by the framework, the TensorArray data structure.

Day 4: Giant Squid: part one

You can click on the title above to read the full text of the puzzle. The TLDR version is:

Let’s play bingo! Our puzzle input is a text file containing in the first line a comma separated list of drawn numbers, like

7,4,9,5,11,17,23,2,0,14,21,24,10,16,13,6,15,25,12,22,18,20,8,19,3,26,1

While the rest of the file contains the 5x5 boards. Each board is a made of 5 rows and 5 columns and each board is separated with an empty line.

22 13 17 11  0
 8  2 23  4 24
21  9 14 16  7
 6 10  3 18  5
 1 12 20 15 19

 3 15  0  2 22
 9 18 13 17  5
19  8  7 25 23
20 11 10 24  4
14 21 16 12  6

14 21 17 24  4
10 16 15  9 19
18  8 23 26 20
22 11 13  6  5
 2  0 12  3  7

The challenge consists in simulating the game. We need, thus, to keep track for every board of the drawn numbers. A player wins the game when one of its board has at least one complete row or column of marked numbers.

The text asks to calculate the score of the winning board as the sum of all the unmarked numbers on that board, multiplied by the number that was just called.

In the example, the last one is the winning board, and bingo has been made after drawing 7,4,9,5,11,17,23,2,0,14,21, and 24. Thus the sum of all the unmarked numbers is 188 and the final score is 188 * 24 = 4512.

Design phase

We need to simulate the complete game. Therefore we need some good representation of the boards that should allow us to easily find rows and columns with marked numbers. Representing every board as a tf.Tensor with shape=(5,5) can be the best choice.

We also need to find a way to keep track for every board of the drawn numbers. The TensorArray data structure, as shown in Day 3, is the perfect fit. This data structure allows us to store arbitrary-shaped tf.Tensor objects, thus we can easily create an array of 5x5 boards.

We are not interested in the value of the marked numbers, hence we can overwrite them with some value that will never be drawn (-1). After every extraction, we should just check if there’s a winner board, for stopping the execution and compute the score of the winning board.

The data representation we use is perhaps the most important part of the solution, and it’s all created in the data pipeline.

Input pipeline

We create a tf.data.Dataset object for reading the text file line-by-line as usual. But this time, since the data is heterogeneous, we create two different datasets. One dataset produces the drawn numbers, and the other produces the boards.

dataset = tf.data.TextLineDataset("input")

# The first row is a csv line containing the number extracted in sequence
extractions = (
    dataset.take(1)
    .map(lambda line: tf.strings.split(line, ","))
    .map(lambda strings: tf.strings.to_number(strings, out_type=tf.int64))
    .unbatch()
)

# All the other rows are the boards, every 5 lines containing an input
# is a board. We can organize the boards as elements of the dataset, a dataset of boards
boards = (
    dataset.skip(1)
    .filter(lambda line: tf.greater(tf.strings.length(line), 0))
    .map(tf.strings.split)
    .map(
        lambda string: tf.strings.to_number(string, out_type=tf.int64)
    )  # row with 5 numbers
    .batch(5)  # board 5 rows, 5 columns
)

extractions is a dataset that produces a scalar on every iteration. The unbatch method is used to unpack a single tf.Tensor containing all the numbers to a sequence of scalars. It’s worth noting that tf.strings.to_number is applied to a Tensor containing all the numbers in tf.string format, hence this application is executed in parallel on all the values.

boards is a dataset created skipping the first line (previously used), and by removing all the empty lines. The usual string to integer conversion is applied and we conclude by calling batch(5). Since every read line contains 5 elements, by batching them together we end up with a dataset that produces a tensor with a 5x5 shape on every iteration.

Computing the score

In the design phase, we decided to put a -1 on the board where there’s a match. Hence, we can easily define a helper function that computes and prints the score of a board.

def _score(board, number):
    tf.print("Winner board: ", board)
    tf.print("Last number: ", number)

    # Sum all unmarked numbers
    unmarked_sum = tf.reduce_sum(
        tf.gather_nd(board, tf.where(tf.not_equal(board, -1)))
    )
    tf.print("Unmarked sum: ", unmarked_sum)

    final_score = unmarked_sum * number
    tf.print("Final score: ", final_score)

The function requires the board to use and the last drawn number to compute the final score. Summing all the unmarked numbers is trivial, in fact, it’s just a matter of filtering for all the values different from -1, gathering the values, and passing them to tf.reduce_sum that when used without specifying the reduction dimension will sum up all the values in the input tensor producing a scalar value.

The final score is then easily computed.

Playing bingo

Our TensorFlow program needs a state. In particular, we need to know when to stop looping over the extractions (a boolean state), save the last drawn number, and also the winner board. These states should also be returned by our TensorFlow program, so to use the _score function previously defined.

Moreover, we need a TensorArray object for storing in a mutable data structure the various boards. I want to stress out that the TensorArray is one of the few totally mutable objects TensorFlow provides. Differently from tf.Variable the elements of a tf.TensorArray can change shape, and the array can grow past its original size.

class Bingo(tf.Module):
    def __init__(self):
        # Assign every board in a TensorArray so we can read/write every board
        self._ta = tf.TensorArray(dtype=tf.int64, size=1, dynamic_size=True)

        self._stop = tf.Variable(False, trainable=False)

        self._winner_board = tf.Variable(
            tf.zeros((5, 5), dtype=tf.int64), trainable=False
        )
        self._last_number = tf.Variable(0, trainable=False, dtype=tf.int64)

We need to decide when to stop the extraction loop, for doing so it will be useful to design an is_winner function that given a board checks if it has a row or a column full with marked objects.

During the design phase, we decided to apply -1 in the marked position, hence the is_winner function can be easily defined as follows.

@staticmethod
def is_winner(board: tf.Tensor) -> tf.Tensor:
    rows = tf.reduce_sum(board, axis=0)
    cols = tf.reduce_sum(board, axis=1)

    return tf.logical_or(
        tf.reduce_any(tf.equal(rows, -5)), tf.reduce_any(tf.equal(cols, -5))
    )

The axis parameter of tf.reduce_sum defines the reduction dimension to use. In practice, we are summing along the rows and the column (respectively in the two calls) getting two tensors with shape (5) containing the sum over these dimensions. If we marked a row/column, then one of these 5 values will be a -5.

Differently from what we made for solving the day 4 puzzle, where we only used the stack and unstack method of tf.TensorArray, this time we use only unstack to populate the TensorArray with the boards, and then we used read and write methods to read/write singularly every board.

# @tf.function
def __call__(
    self, extractions: tf.data.Dataset, boards: tf.data.Dataset
) -> Tuple[tf.Tensor, tf.Tensor]:
    # Convert the datasaet to a tensor  and assign it to the ta
    # use the tensor to get its shape and know the number of boards
    tensor_boards = tf.convert_to_tensor(list(boards))  # pun intended
    tot_boards = tf.shape(tensor_boards)[0]
    self._ta = self._ta.unstack(tensor_boards)

    # Remove the number from the board when extracted
    # The removal is just the set of the number to -1
    # When a row or a column becomes a line of -1s then bingo!
    for number in extractions:
        if self._stop:
            break
        for idx in tf.range(tot_boards):
            board = self._ta.read(idx)
            board = tf.where(tf.equal(number, board), -1, board)
            if self.is_winner(board):
                self._stop.assign(tf.constant(True))
                self._winner_board.assign(board)
                self._last_number.assign(number)
                break
            self._ta = self._ta.write(idx, board)
    return self._winner_board, self._last_number

Note that write produces an “operation” that must be assigned to itself (self._ta = self._ta.write(idx, board)).

Execution

We have all we need to play the game and compute the final result

bingo = Bingo()
winner_board, last_number = bingo(extractions, boards)
_score(winner_board, last_number)

Here we go, part 1 solved! We are ready for part 2.

Day 4: Giant Squid: part two

Part 2 requires to figure out which board will win last and compute, thus, the final score of this board.

Design phase - part two

Finding the last winning board requires drawing all the numbers, playing the game, ignore the winning boards until we reach the end of the loop. The idea is to remove from the game a board as soon as it becomes a winning board. In this way, we don’t place -1 where’s no more needed (otherwise we end up with all the boards full of -1).

Removing a board from the game is trivial thanks to our representation. Whenever we find a winning board (row/column full of -1) we can just set all the values of this board to a number that will never be drawn: 0.

Invalidating the boards

With a small modification of the call method, we can solve the puzzle.

def __call__(
    self,
    extractions: tf.data.Dataset,
    boards: tf.data.Dataset,
    first_winner: tf.Tensor = tf.constant(True),
) -> Tuple[tf.Tensor, tf.Tensor]:
    # Convert the datasaet to a tensor  and assign it to the ta
    # use the tensor to get its shape and know the numnber of boards
    tensor_boards = tf.convert_to_tensor(list(boards))  # pun intended
    tot_boards = tf.shape(tensor_boards)[0]
    self._ta = self._ta.unstack(tensor_boards)

    # Remove the number from the board when extracted
    # The removal is just the set of the number to -1
    # When a row or a column becomes a line of -1s then bingo!
    for number in extractions:
        if self._stop:
            break
        for idx in tf.range(tot_boards):
            board = self._ta.read(idx)
            board = tf.where(tf.equal(number, board), -1, board)
            if self.is_winner(board):
                self._winner_board.assign(board)
                self._last_number.assign(number)
                if first_winner:
                    self._stop.assign(tf.constant(True))
                    break
                # When searching for the last winner
                # we just invalidate every winning board
                # by setting all the values to zero
                board = tf.zeros_like(board)
            self._ta = self._ta.write(idx, board)
    return self._winner_board, self._last_number

We added the first_winner parameter that will change the behavior of the call method: when True it behaves like required for part 1, when false it finds the last winning board.

Execution - part two

Very easy

## --- Part Two ---
# Figure out the last board that will win
bingo = Bingo()
last_winner_board, last_number = bingo(extractions, boards, tf.constant(False))
_score(last_winner_board, last_number)

It works! Problem 4 is solved!

Conclusion

You can see the complete solution in folder 4 on the dedicated Github repository: https://github.com/galeone/tf-aoc.

We are using tf.TensorArray declaring them in the __init__ and this leads to the very same limitations we faced while solving the day 3 puzzle.

I already solved puzzles 5 and 6 and both have been fun. In particular, while solving the day 6 puzzle I realized that the limitation I discovered only depends on where we declare and use TensorArray. My solution for that puzzle uses a tf.TensorArray in a tf.function-decorated function and it works pretty well! So stay tuned for that article!

The next article, however, will be about my solution to the day 5 puzzle, which has been solved without using any tf.TensorArray but contains some easy but interesting mathematical concept that’s worth writing about :)

For any feedback or comment, please use the Disqus form below - thanks!

Don't you want to miss the next article? Do you want to be kept updated?
Subscribe to the newsletter!

Related Posts

Integrating third-party libraries as Unreal Engine plugins: ABI compatibility and Linux toolchain

The Unreal Build Tool (UBT) official documentation explains how to integrate a third-party library into Unreal Engine projects in a very broad way without focusing on the real problems that are (very) likely to occur while integrating the library. In particular, when the third-party library is a pre-built binary there are low-level details that must be known and that are likely to cause troubles during the integration - or even make it impossible!

Code Coverage of Unreal Engine projects

Code coverage is a widely used metric that measures the percentage of lines of code covered by automated tests. Unreal Engine doesn't come with out-of-the-box support for computing this metric, although it provides a quite good testing suite. In this article, we dive into the Unreal Build Tool (UBT) - particularly in the Linux Tool Chain - to understand what has to be modified to add the support, UBT-side, for the code coverage. Moreover, we'll show how to correctly use the lcov tool for generating the code coverage report.

Wrap up of Advent of Code 2021 in pure TensorFlow

A wrap up of my solutions to the Advent of Code 2021 puzzles in pure TensorFlow

Advent of Code 2021 in pure TensorFlow - day 12

Day 12 problem projects us the world of graphs. TensorFlow can be used to work on graphs pretty easily since a graph can be represented as an adjacency matrix, and thus, we can have a tf.Tensor containing our graph. However, the "natural" way of exploring a graph is using recursion, and as we'll see in this article, this prevents us to solve the problem using a pure TensorFlow program, but we have to work only in eager mode.

Advent of Code 2021 in pure TensorFlow - day 11

The Day 11 problem has lots in common with Day 9. In fact, will re-use some computer vision concepts like the pixel neighborhood, and we'll be able to solve both parts in pure TensorFlow by using only a tf.queue as a support data structure.

Advent of Code 2021 in pure TensorFlow - day 10

The day 10 challenge projects us in the world of syntax checkers and autocomplete tools. In this article, we'll see how TensorFlow can be used as a generic programming language for implementing a toy syntax checker and autocomplete.

Advent of Code 2021 in pure TensorFlow - day 9

The day 9 challenge can be seen as a computer vision problem. TensorFlow contains some computer vision utilities that we'll use - like the image gradient - but it's not a complete framework for computer vision (like OpenCV). Anyway, the framework offers primitive data types like tf.TensorArray and tf.queue that we can use for implementing a flood-fill algorithm in pure TensorFlow and solve the problem.

Advent of Code 2021 in pure TensorFlow - day 8

The day 8 challenge is, so far, the most boring challenge faced 😅. Designing a TensorFlow program - hence reasoning in graph mode - would have been too complicated since the solution requires lots of conditional branches. A known AutoGraph limitation forbids variables to be defined in only one branch of a TensorFlow conditional if the variable is used afterward. That's why the solution is in pure TensorFlow eager.

Advent of Code 2021 in pure TensorFlow - day 7

The day 7 challenge is easily solvable with the help of the TensorFlow ragged tensors. In this article, we'll solve the puzzle while learning what ragged tensors are and how to use them.

Advent of Code 2021 in pure TensorFlow - day 6

The day 6 challenge has been the first one that obliged me to completely redesign for part 2 the solution I developed for part 1. For this reason, in this article, we'll see two different approaches to the problem. The former will be computationally inefficient but will completely model the problem, hence it will be easy to understand. The latter, instead, will be completely different and it will focus on the puzzle goal instead of the complete modeling.