Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C

Trees Encoding

5.00/5 (3 votes)
15 Dec 2018CPOL3 min read 6.7K  
How to encode binary trees for algorithms such as RandomForest

Introduction

Recently, I was working on implementing a RandomForest algorithm in C++. If you are not familiar enough with Machine Learning, that's ok; this is quite a simple algorithm. I get a few binary trees, 200 in my case, that are all referring to a vector of values. Each node in the tree has an index to that vector and a threshold to compare to, if the value at that index in the vector is above the threshold, we walk the right edge of the node, if not we walk the left one. All of the leaves are values, usually 1 or 0. In the end, we average all of the values we get from all of the 200 trees and compare it to a detection threshold.

Image 1

Pretty simple, isn't it?
In this post, I'll focus on the implementation of it in C++. I won't talk about the Machine Learning part of creating these trees in the first place, the process called training or learning, but I'll start from the point in which I have the trees encoded in some canonical ASCII way such as:

(<100,2.0>0.0|(<111,5.0>(<112,3.0>1.0|0.20000000298)|(<113,6.0>2.5|1.20000004768)))

Defined as:

(<vector index, value threshold>left node|right node)

At first, I decided to do some preprocessing of the trees using Python to convert it into some binary format that will be easier to load in real time and will require less parsing code in C++. I think it's good practice to avoid real-time parsing as much as possible. From my experience, code for parsing tends to be more prone to errors and security problems. I encoded the trees into the following format:

Byte: 0x01 - Tree start symbol
Byte: 0x02 - Node symbol
Uint32: Vector index
Double (64bit Floating Point): Threshold
Left Sub Tree...
Right Sub Treeā€¦
    Byte: 0x03 - Leaf
        Double: Tree score
Byte: 0x0ff - End of data

Tip of the day: I created a reverse function to build the canonical form back from the binary form. I used it to validate that I get the exact same file as the input file to reduce the number of bugs drastically.

The C++ code for parsing that would simply build the trees into Vector of Nodes where Node is:

C++
class RandomForestNode
{
    public:
        bool isLeaf;
        uint32_t vectorIndex;
        union {
            double threshold;
            double prob;
        };
        uint32_t left;
        uint32_t right;
};

Apparently, this very straightforward naive approach is:

  • Slow. Loading new trees on runtime took too much time
  • Wasteful on memory
  • Wasteful on disk space, the binary encoding on disk

So the very first improvement I made was to change the binary encoding into one I can load straight to memory with very little parsing.

uint32: number of trees
uint32: number of nodes in tree
    uint16: is_leaf - [0, 1]
    if not leaf:
        uint16: vectorIndex
        uint16: index of left node
        uint16: index of right node
        double: threshold
    else:
        uint16: 0
        uint16: 0
        uint16: 0
        double: tree score
    .
    .
    .

In C++, a node struct looks like:

C++
struct RandomForestNode
{
    uint16_t isLeaf;
    uint16_t vectorIndex;
    uint16_t left;
    uint16_t right;
    union {
        double threshold;
        double prob;
    };
};

Now by simply reading the data of the tree into memory and just filling the vector of trees with pointers to different places in the data, I'm done with the loading of the trees.

But it still takes too much space on disk and memory, so I wanted to compact it.
I've created a lookup table for threshold and tree scores values. Many values, such as 0 and 1, repeated many times so using a 16 bits index to values table instead of 64bit double saved a lot of space.
The isLeaf has just two possible values, True or False, so 1 bit is enough for it. I've taken one bit off from the left index and one bit off from the right index and used it in order to tell if the left node is a leaf and if the right node is a leaf. Because the score value is now index that I'm able to encode in 15 bits, if the node is a leaf, the index could be of the value. That enables me to remove the leaf nodes entirely.

The new struct is as follows:

16 bits: vectorIndex
15 bits: leftIndex / scoreIndex
    1 bit: isLeftLeaf
15 bits: rightIndex / scoreIndex
    1 bit: isRightLeaf
16 bits: thresholdIndex

And it still stored on disk the same way it is stored in memory, so loading takes no time at all.
Next, I'm working on an algorithm to cut identical parts between trees and store them only once, but for now, it seems to be a rare case that is not worth the trouble.
If you have ideas on how to improve this struct farther, please do share.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)