Discord

Checklist OAI 2026 Stage II · Task 3

SOTA-AI Editorial

Decision Trees

Automatically preprocess two-dimensional datasets and choose decision-tree hyperparameters so that the tree beats the organisers' baseline accuracy on as many of 100 hidden datasets as possible.

Written
8 October 2026
Result
66.0/100 on released collection A (55 of 100 datasets matched or beat baseline); full credit was not reached.
Points
66.0/100 (see result for validation and provenance)
Olympiad
Polish Olympiad in Artificial Intelligence (Olimpiada Sztucznej Inteligencji), 2026 · Stage II

Hints

Hint 1

Plot each dataset before choosing a tree strategy; the task is a collection of different geometries, not one shared distribution.

Hint 2

Check feature scale, covariance, and orientation. Can a data-driven rotation make the tree’s axis-aligned splits more useful?

Hint 3

Use the unlabeled test points to stabilize transductive feature preprocessing, while keeping their labels out of training and model selection.

Hint 4

Select pruning strength with stratified CV, and be cautious about tiny CV-score differences on small datasets.

The solution in one line

Symmetrically whiten pooled features, rotate them to reduce 24-bin histogram-estimated mutual information, then select pruning by repeated stratified CV with a prior based on the CV-best alpha; measured result: 66.0/100 on released collection A.

The editorial

Poland 2026 Stage 2 — Drzewa decyzyjne

Problem name: Drzewa decyzyjne (Decision Trees)
Problem domain: Classical ML, geometric preprocessing, model selection
Evaluation metric: Number of datasets on which a decision tree matches or beats the supplied baseline accuracy; 1.2 points per dataset, capped at 100
Abridged statement: For each of many independent two-dimensional binary classification datasets, preprocess the labeled training points and unlabeled test points, choose decision-tree hyperparameters, and predict the test labels. The released collection A is available for development; collection B is used for the hidden evaluation.

Dataset analysis and EDA

Collection A contains 100 separate datasets. Each has two numeric features and binary labels. Training-set sizes range from 74 to 2,043 points (median 677.5); the minority class is 25%–50% of each training set. The supplied baseline accuracies range from 0.572 to 0.976, with a median of 0.933.

Scatterplots show substantial variation in scale, orientation, and class geometry. Examples include diagonal bands, clusters, and overlapping or noisy boundaries. Many patterns are poorly aligned with the original feature axes, so a tree that only makes axis-aligned splits in raw coordinates can underperform. The two features remain the full input; preprocessing must preserve both matrix shapes.

EDA performed: point-cloud scatterplots, class-balance and sample-size summaries, feature covariance/eigenvalue inspection, and score comparisons for raw coordinates, whitening, ICA-style rotations, PCA, polar and polynomial maps, and spectral embeddings.

Experiments

Approach Collection A result Notes
Starter notebook: pooled whitening, moment-based ICA rotation, repeated-CV pruning 49/100 wins; 58.8 points; 88.54% mean accuracy Reproduced from the released notebook implementation.
Train-only, 16-bin mutual-information rotation with the original CV policy 51/100 wins; 61.2 points; 88.68% mean accuracy Improved over the starter, but later replaced.
Pooled, 24-bin mutual-information rotation; global alpha prior 54/100 wins; 64.8 points CV tolerance 0.001 and fixed prior 0.0015.
Pooled, 24-bin rotation; prior conditioned on training-only best-CV alpha 55/100 wins; 66.0 points; 88.70% mean accuracy Selected implementation below.
Pooled, 16-bin mutual-information rotation with repeated-CV pruning 55/100 wins; 66.0 points Tied the selected 24-bin method; did not improve it.
Pooled, 20-bin mutual-information rotation with repeated-CV pruning 51/100 wins; 61.2 points Tested as a bin-count sensitivity check.
Pooled, 16-bin rotation with one fixed ccp_alpha=0.00225 50/100 wins; 60.0 points Fixed alpha performed worse than cross-validated pruning.
k-nearest-neighbor mutual-information rotation (k=5, 256-point pooled subsample, 1° search) with repeated-CV pruning 45/100 wins; 54.0 points; 88.40% mean accuracy More flexible dependence estimate, but worse on collection A.
Expanded CV grid over pruning strength and minimum leaf size 48/100 wins; 57.6 points More hyperparameter choices did not improve the released A score.
Spectral embedding, sample of 34 datasets 0/34 wins with 8 or 12 neighbors; 2/34 with 20 neighbors Tested and discarded; this is a subset result, not a full-A score.
PCA, polar, and simple polynomial coordinate maps Did not improve the selected approach Tested as alternatives; not used in the final pipeline.

The final preprocessing rotation minimizes a discretized mutual-information estimate between the two whitened projections. It uses 24 equal-frequency bins per projection and searches rotations from 0° through 179°. Both whitening and the rotation objective use pooled train and test features without labels; the same transform is applied to both arrays.

The tree searches ccp_alpha values from 0.0015 to 0.0045 by repeated stratified cross-validation (up to four folds, two repeats). It finds the best mean CV alpha, then sets one global prior: 0.0015 when the CV-best alpha is at most 0.003, otherwise 0.00375. Among candidates within 0.1 percentage points of the best mean CV accuracy, it chooses the alpha closest to that prior. This condition uses only the current dataset’s training labels and CV scores.

Solution and validation

Implementation: decision_trees.py

On the 100 released A datasets, the final functions beat or match baseline_acc on 55 datasets, for 66.0/100 points. Full credit requires at least 84 wins, so the best tested rule is 29 datasets short. Mean test accuracy is 88.70%. The official shape assertions passed: preprocessed training and test arrays retain their input shapes.

Each dataset’s tree hyperparameters are chosen using only its training labels and stratified cross-validation. The unlabeled test features are used only with training features to estimate the shared whitening transform and rotation. The released A output labels were used to compare global solution variants during development, so 66.0/100 is a development/validation score, not an untouched estimate of collection B performance. Per-dataset oracle choices made after inspecting test labels were excluded from the final code and score. A diagnostic leave-one-family-out assessment of the prior policy scored 54/100 (64.8 points); family IDs were used only to create that diagnostic split, never by the solution.

Full credit was not reached. Collection B is hidden and its score is unknown. An independent search compared raw, PCA, whitening, histogram-MI, moment-based, polar, and kNN-MI transforms with global and CV-selected pruning. None exceeded 66.0/100. The high and varied baseline accuracies suggest the selected single-tree interface cannot match every geometry with one simple preprocessing strategy.

Compute and footprint

The solution uses CPU NumPy and scikit-learn only; it requires no GPU or external model. Preprocessing searches 180 two-dimensional rotations per dataset, followed by small decision-tree CV fits. It preserves the task’s two-feature input and output contract.

Code

Solution code solution.py · 118 lines
"""Decision-tree solution for the Polish 2026 Stage 2 task.

The input consists of many independent two-feature binary classification
problems.  We use the unlabeled test points to estimate a stable whitening
transform, rotate the axes to reduce dependence between the training
projections, then choose the tree pruning strength by repeated stratified CV.
"""

from __future__ import annotations

import numpy as np
from sklearn.model_selection import RepeatedStratifiedKFold
from sklearn.tree import DecisionTreeClassifier


def preprocess_data(X_train: np.ndarray, X_test: np.ndarray):
    """Whiten pooled features and choose a low-mutual-information rotation.

    The histogram objective is estimated from the pooled, unlabeled point
    cloud. Both train and test points share the resulting transform. The function keeps
    the required two-column shape and has a safe identity fallback for invalid
    or unexpected inputs.
    """
    X_train = np.asarray(X_train, dtype=np.float64)
    X_test = np.asarray(X_test, dtype=np.float64)

    if (
        X_train.ndim != 2
        or X_test.ndim != 2
        or X_train.shape[1] != 2
        or X_test.shape[1] != 2
        or len(X_train) < 2
        or len(X_test) == 0
    ):
        return X_train.copy(), X_test.copy()

    pooled = np.concatenate((X_train, X_test), axis=0)
    if not np.isfinite(pooled).all():
        return X_train.copy(), X_test.copy()

    center = pooled.mean(axis=0)
    covariance = np.cov(pooled, rowvar=False)
    eigenvalues, eigenvectors = np.linalg.eigh(covariance)
    floor = max(float(eigenvalues.max()), 1.0) * 1e-12

    # Symmetric whitening preserves the original coordinate frame while
    # correcting for scale and correlation.
    whitening = (
        eigenvectors
        @ np.diag(1.0 / np.sqrt(np.maximum(eigenvalues, floor)))
        @ eigenvectors.T
    )
    Z = (pooled - center) @ whitening

    n_train = len(X_train)
    angles = np.deg2rad(np.arange(180, dtype=np.float64))
    cosines = np.cos(angles)
    sines = np.sin(angles)
    first = Z[:, 0, None] * cosines + Z[:, 1, None] * sines
    second = -Z[:, 0, None] * sines + Z[:, 1, None] * cosines

    # Estimate mutual information from 24 equal-frequency bins on each axis.
    # The small pseudocount makes the estimate finite for sparse histograms.
    mutual_information = np.empty(len(angles), dtype=np.float64)
    quantiles = np.linspace(0.0, 1.0, 25)
    for j in range(len(angles)):
        edges_1 = np.unique(np.quantile(first[:, j], quantiles))
        edges_2 = np.unique(np.quantile(second[:, j], quantiles))
        if len(edges_1) < 2 or len(edges_2) < 2:
            mutual_information[j] = np.inf
            continue

        counts = np.histogram2d(
            first[:, j], second[:, j], bins=(edges_1, edges_2)
        )[0]
        counts += 1e-6
        joint = counts / counts.sum()
        marginal_1 = joint.sum(axis=1, keepdims=True)
        marginal_2 = joint.sum(axis=0, keepdims=True)
        mutual_information[j] = np.sum(
            joint * np.log(joint / (marginal_1 @ marginal_2))
        )

    best = int(np.argmin(mutual_information))
    c, s = cosines[best], sines[best]
    rotation = np.array([[c, -s], [s, c]])
    Z = Z @ rotation
    return Z[:n_train], Z[n_train:]


def get_decision_tree_hyperparameters(X_train: np.ndarray, y_train: np.ndarray):
    """Select cost-complexity pruning strength by repeated stratified CV."""
    X_train = np.asarray(X_train, dtype=np.float64)
    y_train = np.asarray(y_train).ravel()
    classes, counts = np.unique(y_train, return_counts=True)
    fallback_alpha = 0.0015

    if len(classes) < 2:
        return {"ccp_alpha": fallback_alpha, "random_state": 42}

    n_splits = min(4, int(counts.min()))
    if n_splits < 2:
        return {"ccp_alpha": fallback_alpha, "random_state": 42}

    alphas = np.round(0.0015 + 0.00025 * np.arange(13), 5)
    cv = RepeatedStratifiedKFold(
        n_splits=n_splits, n_repeats=2, random_state=1741
    )
    folds = list(cv.split(X_train, y_train))
    scores = np.zeros((len(alphas), len(folds)), dtype=np.float64)

    for alpha_index, alpha in enumerate(alphas):
        for fold_index, (train_index, valid_index) in enumerate(folds):
            tree = DecisionTreeClassifier(
                ccp_alpha=float(alpha), random_state=42
            )
            tree.fit(X_train[train_index], y_train[train_index])
            scores[alpha_index, fold_index] = tree.score(
                X_train[valid_index], y_train[valid_index]
            )

    mean_scores = scores.mean(axis=1)
    best_score = float(mean_scores.max())

    # The selected global policy uses a prior conditioned on the best CV alpha.
    # Both values are fixed across tasks; the condition depends only on this
    # dataset's training labels and repeated-CV scores.
    best_index = int(np.argmax(mean_scores))
    cv_best_alpha = float(alphas[best_index])
    prior_alpha = 0.0015 if cv_best_alpha <= 0.003 else 0.00375

    # Retain near-best candidates because individual CV estimates are noisy.
    eligible = np.flatnonzero(mean_scores >= best_score - 0.001)
    selected = min(
        eligible, key=lambda index: abs(float(alphas[index]) - prior_alpha)
    )
    return {"ccp_alpha": float(alphas[selected]), "random_state": 42}


def fit_tree(X_train: np.ndarray, y_train: np.ndarray) -> DecisionTreeClassifier:
    """Convenience helper for local use of the two task functions above."""
    params = get_decision_tree_hyperparameters(X_train, y_train)
    return DecisionTreeClassifier(**params).fit(X_train, y_train)

Download solution.py

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.