Selected work · 06 / 08Algorithms · BSc thesis

Native Categorical Multiway-Split Decision Trees

My BSc thesis: a decision tree that splits the way the data actually is. Written from scratch in Python, it gives every categorical value its own branch, handles numbers in three different ways, and was benchmarked against scikit-learn on ten datasets.

Multiway split, live. Click for new data.Play Tennis
splitleaf: yesleaf: nostill mixed
10datasets benchmarked against scikit-learn
51,840recorded evaluations, all archived
3strategies for numerical features
2impurity criteria: entropy and Gini
2datasets where my tree beat scikit-learn's best
01 / The question

Why only two branches?

A categorical feature can have three, five or twenty values. Yet popular libraries such as scikit-learn build binary trees, and they need categorical data to be turned into numbers first: one-hot columns or arbitrary codes. The tree you get back speaks the language of the encoding, not of the data.

The thesis starts from that question. Its answer is MixedDecisionTree, a classifier written from first principles on top of pandas and NumPy, where a categorical split creates one branch per observed value: outlook splits into sunny, overcast and rain, exactly as in the demo at the top. The goal was a tree that people can read, more than a tree that trains fast.

02 / How it learns

Hunt's algorithm, one split at a time.

The learner grows the tree recursively, in the style of ID3: at each node it measures how mixed the classes are, tries every candidate feature, and keeps the split that reduces the impurity the most.

Gain(S,A)=I(S)−∑v∈values(A)|Sv||S|I(Sv)
Information gain of a multiway split on A

The impurity I can be the entropy or the Gini index, both implemented:

H(S)=−∑cpclog2pcGini(S)=1−∑cpc2
Entropy and Gini impurity

Growth stops when a node is pure, when no feature is left, or when a pre-pruning limit kicks in: maximum depth, minimum samples to split, minimum samples per child. Every leaf gets the majority class, and categories never seen in training fall back to the overall majority.

MixedDecisionTree.py · _get_impurity()
def _get_impurity(self, attribute):    # attribute is a pd.Series: NumPy computes the class frequencies    values, counts = np.unique(attribute, return_counts=True)    impurity_values = []    if self.criterion == 'entropy':        for i in range(len(values)):            impurity_values.append(-(counts[i] / np.sum(counts)) * (np.log2(counts[i] / np.sum(counts))))        impurity_measure = np.sum(impurity_values)    elif self.criterion == 'gini':        for i in range(len(values)):            impurity_values.append((counts[i] / np.sum(counts)) ** 2)        impurity_measure = 1.0 - np.sum(impurity_values)    else:        raise Exception("Wrong criteria input, possible choices are: 'entropy' or 'gini'")    return impurity_measure
The impurity function exactly as in the repository, with the comment translated into English.
03 / Three modes

Three ways to read a number.

Categorical columns always branch natively. Numerical columns are the open design question, so the tree offers three representations and the thesis compares them:

ModeNumerical featuresTrade-off
classicevery distinct training value becomes a binary condition, such as PetalLengthCm <= 1.9the finest thresholds, the highest training cost
medianvalues are binned and replaced by interval midpoints, then turned into conditionsfar fewer candidate thresholds
binnedvalues become interval categories, so numbers split into multiple branches toono threshold expansion, quality depends on the bins
MixedDecisionTree.py · _classic_mode()
# classic mode: every distinct training value of a numerical feature becomes a binary conditionfor feature in continuous_features:    feature_values_list = X_train[feature].values.tolist()    training_classes = np.unique(X_train[feature])    for i in range(len(training_classes)):        attribute_name = feature + " <= " + str(training_classes[i])        cond_var_list.clear()        for value in feature_values_list:            if value <= training_classes[i]:                cond_var_list.append(1.0)            else:                cond_var_list.append(0.0)        # a join instead of repeated inserts: this solved the memory problem        to_be_added_df = pd.DataFrame({attribute_name: cond_var_list})        X_train = X_train.reset_index(drop=True).join(to_be_added_df.reset_index(drop=True), how='left')    del X_train[feature]
The heart of the classic mode, from the repository, with the comments translated into English.
04 / Benchmark

Against scikit-learn, on ten datasets.

Both trees ran on the same ten datasets, the same stratified holdout (a third of the data for testing) and a full grid: three modes, two criteria, four bin counts and many pre-pruning settings, 2,592 configurations per model per dataset. All 51,840 results are archived in the repository with accuracy, weighted F1 and training time.

Best archived accuracy per modelmy treescikit-learn
Iris150 rows · 3 classes
100%
100%
HouseVotes84434 rows · 16 features
95.83%
95.83%
Sacramento932 rows · 3 classes
94.16%
95.13%
Carseats279 rows · 10 features
93.55%
93.55%
parkinsons100 rows · 22 features
90.91%
87.88%
prostate97 rows · 7 features
90.91%
90.91%
churn500 rows · 13 features
84.85%
85.45%
diabetes393 rows · 8 features
83.08%
82.31%
Auto392 rows · 3 classes
80.00%
82.31%
breast-cancer271 rows · 8 features
66.67%
74.44%

For each model, the highest holdout accuracy among all its archived configurations, selected independently, as recorded in the result files.

05 / Findings

Readable, and just as accurate.

  • Comparable accuracy. The same best accuracy on four datasets, higher on diabetes and parkinsons, within 2.5 points on three more, with similar weighted F1.
  • Readable trees. A node with many outgoing branches, one per value, can be read by a person without decoding anything. The tree exports to text, condition by condition, with the gain of every split.
  • The price is training time. scikit-learn trains much faster and uses less memory, which was never the goal here. Classifying a new example is just as fast: in both trees it costs one step per level of depth.

The thesis covers the theory behind it all, from decision-tree foundations and Hunt's algorithm to impurity measures, then the implementation of the three modes and the full experimental analysis.

A tree built from first principles.

The learner, the experiment runners, all 51,840 archived results and the exported trees are on GitHub, under the MIT License.

Next case study · 07 / 08Yeo-Johnson Likelihood Inference in R