Design decisions#
The implementations begin from published algorithms and depart from them where measurement justified it. This page records the departures and the numbers behind them, so that a reader can disagree with a choice on the evidence rather than on taste. Every figure below comes from running the code: three seeds of stratified five-fold cross-validation, features standardised on the training fold only, both arms of each comparison sharing seeds and folds.
Growth must not preserve the function exactly#
Deepening a soft tree looks like it should be done without disturbing what the tree has learned. Turn every leaf into a gate with all-zero parameters, so it sends half the arriving mass each way, and give both children the parent’s class distribution. The mixture is unchanged and the extra capacity is available.
This does not work, and the reason is exact rather than statistical. With \(Q_L = Q_R\) the subtree’s output does not depend on the gate at all, so
and the gate’s gradient is identically zero. With the gate at \(g = 1/2\) the two children receive identical gradients, so they stay identical, so the gate’s gradient stays zero. The state reproduces itself at every step. It is a fixed point of the optimiser. The added level is dead weight while costing full parameters and compute. We checked this numerically rather than assuming it: in single precision the new gates’ gradients are exactly zero and sibling leaves receive bitwise identical gradients, and the regression test asserts exact equality.
Perturbing the child distributions slightly at insertion breaks the symmetry. The function is then preserved only approximately, which is the price of the level being able to learn anything.
Growth |
Iris |
Wine |
Digits |
|---|---|---|---|
Exactly function-preserving |
0.762 ± 0.103 |
0.786 ± 0.145 |
0.369 ± 0.035 |
Perturbed by 0.05 |
0.942 ± 0.041 |
0.979 ± 0.020 |
0.917 ± 0.034 |
Perturbed by 0.2 (the default) |
0.942 ± 0.044 |
0.979 ± 0.020 |
0.921 ± 0.027 |
Perturbed by 0.5 |
0.938 ± 0.050 |
0.983 ± 0.018 |
0.917 ± 0.027 |
Same depth, trained from scratch |
0.958 ± 0.032 |
0.978 ± 0.016 |
0.925 ± 0.029 |
Exact preservation costs about twenty points on Iris and Wine and fifty-six on Digits, and it inflates the fold-to-fold standard deviation as well. The perturbation needs to be nonzero and otherwise needs no tuning: a tenfold change in it moves the mean by at most four tenths of a point on any of the three. The combined 5x2cv F test against the default perturbation gives p = 0.0596 on Iris, which we do not call significant, p = 0.0097 on Wine and p < 0.0001 on Digits.
New units should be fitted to the residual#
The GAL network installs a new hidden unit with random weights. Such a unit perturbs every output logit the moment it arrives, damaging a network that was just judged to have stopped improving, and then has to be trained from noise.
growth_init="residual" freezes the network, trains the candidate unit to
correlate with the residual error, and installs it with zero outgoing weights.
That is safe here, unlike the tree case above, because the outgoing weights
have a nonzero gradient as soon as the incoming weights are informative.
Dataset |
New unit |
Accuracy |
Hidden units |
5x2cv F |
|---|---|---|---|---|
Iris |
|
0.936 ± 0.046 |
17.2 ± 2.4 |
p = 0.181 |
|
0.956 ± 0.037 |
6.9 ± 1.5 |
||
Wine |
|
0.985 ± 0.015 |
6.3 ± 2.2 |
p = 0.633 |
|
0.985 ± 0.015 |
4.3 ± 0.5 |
||
Digits |
|
0.949 ± 0.013 |
7.5 ± 0.6 |
p = 0.0012 |
|
0.928 ± 0.015 |
5.7 ± 0.5 |
The size effect is consistent and the accuracy effect is not. Every dataset
gives a smaller network: random units arrive unhelpful, the error does not
fall, and the growth criterion fires again, so the network grows because
growth is not working. Accuracy rose on Iris and was identical on Wine, and the
F test rejects equality on neither; on Digits it fell by 2.1 points and the test
does reject equality, in favour of random initialisation. Read this as a
parsimony intervention, not an accuracy intervention. It is still the default
because a smaller network is what the growth mechanism is for, but if the
problem has enough classes and features to need the extra units, "random"
is the setting to try.
Stopping on validation loss under-grows#
growth_policy="validation" holds out a fifth of the training data and
changes the architecture only when validation loss stops improving. It is
the more principled rule and it is not the default, because on six datasets
it lost accuracy on five. Three seeds of stratified five-fold
cross-validation, 150 epochs, accuracy ± sd / mean hidden units:
Dataset |
threshold, random |
threshold, residual |
validation, random |
validation, residual |
|---|---|---|---|---|
Iris |
0.936 ± 0.046 / 17.2 |
0.956 ± 0.037 / 6.9 |
0.898 ± 0.044 / 9.1 |
0.947 ± 0.033 / 11.5 |
Wine |
0.985 ± 0.015 / 6.3 |
0.985 ± 0.015 / 4.3 |
0.981 ± 0.017 / 8.8 |
0.978 ± 0.016 / 12.2 |
Digits |
0.949 ± 0.013 / 7.5 |
0.928 ± 0.015 / 5.7 |
0.878 ± 0.035 / 5.3 |
0.894 ± 0.046 / 6.3 |
vehicle |
0.786 ± 0.027 / 31.9 |
0.803 ± 0.026 / 32.0 |
0.714 ± 0.035 / 6.0 |
0.775 ± 0.031 / 7.9 |
segment |
0.940 ± 0.012 / 8.9 |
0.942 ± 0.012 / 6.7 |
0.900 ± 0.023 / 5.2 |
0.909 ± 0.025 / 5.1 |
satimage |
0.883 ± 0.009 / 30.0 |
0.892 ± 0.010 / 17.5 |
0.857 ± 0.008 / 7.1 |
0.872 ± 0.010 / 9.2 |
Under the validation rule the network is smaller where the threshold rule grew large (vehicle, satimage, segment) and larger on the two small problems (Iris and Wine, where it holds out a fifth of a few dozen samples per class), and it is less accurate on every dataset but Wine: by 3.4 to 7.1 points on Digits, 2.9 to 7.3 on vehicle, 3.3 to 4.0 on segment and 2.0 to 2.6 on satimage, the smaller loss in each pair being the residual initialisation. The mechanism is the one measured on synthetic data in the class docstring: a network that needs more capacity keeps lowering its validation loss slowly, so “loss still falling” never says that a unit is what is missing, and the rule stops growing too early. The threshold rule has the opposite failure: on vehicle it grew at every check and stopped at the epoch budget’s cap of 32 units (two initial units plus one per five-epoch check over 150 epochs), so its network size there is the budget, not a decision. Neither rule knows what a new unit would buy; a stopping rule that does is the open problem, and until it exists the default is the rule that errs toward accuracy.
The residual initialisation keeps its earlier reading on the wider set:
under the threshold rule a smaller network on five of six datasets and the
capped 32 on vehicle, with accuracy that moves both ways, up on Iris and
vehicle, down on Digits.
The script is paper/arxiv/olcum/gal_politika.py and the numbers above
are read from its output file.
Node-level significance does not compose#
The omnivariate tree picks each node’s split type by cross-validated accuracy, which prefers a nonlinear split for any improvement at all, however small. Demanding statistical significance instead sounds strictly better: simpler nodes at little cost.
Measured at maximum depth 3, it is worse on both axes on Breast Cancer and larger for the same accuracy on Digits.
Dataset |
|
Accuracy |
Nodes |
|---|---|---|---|
Breast Cancer |
|
0.971 ± 0.020 |
4.7 ± 1.8 |
|
0.958 ± 0.016 |
7.0 ± 1.9 |
|
Digits |
|
0.599 ± 0.036 |
12.2 ± 1.0 |
|
0.596 ± 0.055 |
13.7 ± 1.4 |
The rule does what it was asked to do at each node and the tree is not better
for it. A simpler split separates its node’s data less cleanly, so its children
inherit harder problems and must themselves be split: the tree grows by half
on Breast Cancer while losing a point of accuracy, and by a node on Digits for
no gain. Parsimony enforced locally is paid for globally. The Digits accuracies
are low for a reason unrelated to the rule: a depth-3 tree has at most eight
leaves and Digits has ten classes, so the row is a comparison between the
rules, not a claim about how well omnivariate trees classify digits. These
numbers were re-measured after the two-group construction was fixed (#104);
the script is paper/arxiv/olcum/omni_yenile.py.
On Iris and Wine the rule never fires, because the test needs a minimum sample count per node and almost every node below the root falls under it. A significance criterion is unavailable exactly where a recursive method does most of its work, since nodes get smaller with depth by construction.
This is why selection="test" is opt-in rather than the default.
The hard export reports its agreement#
to_hard_tree() could present itself as a faithful re-encoding of the soft
model. It is not one: a mixture over leaves is not a single path, and reading
each gate as a hard decision discards the mixing. Over fifteen folds on Wine the
export agreed with its source model on 0.998 of held-out samples on average,
with 0.971 in the worst fold, and predicted about four times faster. The method
reports that agreement rather than assuming it.
Four models that did not work#
An early release shipped four models that were broken, and none of them announced it. They were found by writing tests, not by reading code.
HierarchicalMixtureOfExpertsCould not take a single gradient step. Child weights were written in place into one preallocated tensor, which autograd had saved for the multiplication backward, so every call to
backward()raised.OmnivariateDecisionTreeComputed its node classifier’s decision, discarded it, and always descended right. Every sample reached the same leaf; five-fold accuracy at depth three was 0.000 on Iris.
GALNetworkTook one full-batch gradient step per epoch, so growth and pruning decisions were made on a network that had barely moved from its initialisation. It scored 0.333 on three well-separated blobs, which is chance for three classes.
WeightedKNNCondensed nearest neighbour made a single pass over the training set, leaving a prototype store that did not classify that set correctly.
The test suite now has 283 tests at 95% coverage, gated in CI, and every
estimator is checked against
sklearn.utils.estimator_checks.check_estimator().