Concept · intermediate

Decision tree

Learn yes-or-no rules by repeatedly splitting the data where it separates best.

A decision tree predicts by asking a short series of yes-or-no questions, such as “Is the pressure at most 1000.95?” Each answer sends you down one branch until you reach a leaf, which holds the prediction. You can read the finished model out loud, which is why trees are popular, and they’re the building block of random forests and gradient boosting.

The dataset

data.csv holds 200 synthetic days with humidity (%), air pressure (hPa), and wind speed (km/h), and whether it rained the next day (rain is yes or no). We drew it once with make_data.py (fixed seed 20261102) from a simple rule: rain when humidity is at least 75 and pressure is below 1012, or whenever pressure is below 1000. Wind plays no part. Then we flipped 10% of the labels at random, because real weather never follows a rule exactly. The split column marks 150 rows as train and 50 as test, and 53 of the 150 training days had rain.

Measuring purity

A good question splits the days into groups that mostly agree. The Gini impurity measures disagreement: if a fraction pp of a group had rain,

G=1−p2−(1−p)2G = 1 - p^2 - (1 - p)^2

That’s 0 for a group that all agrees and 0.5 for an even mix. The 150 training days, with p=53/150p = 53/150, score 0.4570. To score a split, we average the two sides’ impurities, weighted by their sizes.

The method

  1. Try every split. For each feature, sort its distinct values and try a threshold halfway between each neighboring pair: 228 thresholds at the start. Keep the one with the lowest weighted impurity. On a tie, the first one tried wins: features in column order, thresholds from smallest to largest.
  2. Split and repeat on each side.
  3. Stop when a group is pure, has fewer than 5 days, or is 3 questions deep, or when no split lowers the impurity. A leaf predicts the majority, and an even split predicts no rain.

The first question is easy to check by hand. Splitting at pressure 1000.95 leaves 34 rainy days out of 36 on one side (impurity 0.1049) and 19 out of 114 on the other (0.2778), for a weighted score of (36×0.1049+114×0.2778)/150=0.2363(36 \times 0.1049 + 114 \times 0.2778) / 150 = 0.2363. The best humidity split only reaches 0.4322, and the best wind split 0.4494.

Reading the tree

pressure <= 1000.95?
├─ yes: humidity <= 63.5?
│  ├─ yes: humidity <= 54?
│  │  ├─ yes: rain     14 of 14 rained
│  │  └─ no:  no rain   1 of 3
│  └─ no:  rain        19 of 19
└─ no:  humidity <= 72.5?
   ├─ yes: wind <= 31.5?
   │  ├─ yes: no rain   2 of 52
   │  └─ no:  no rain   3 of 18
   └─ no:  pressure <= 1013.7?
      ├─ yes: rain     11 of 13
      └─ no:  no rain   3 of 31

The tree found the rule’s shape, with cutoffs of 1000.95, 72.5, and 1013.7 against the true 1000, 75, and 1012. It also learned noise. The three days with humidity between 54 and 63.5 are a pocket carved out for two flipped labels, and the wind question sharpens the impurity without changing a single prediction: both of its leaves say no rain.

On the 50 test days, the tree is right 44 times, an accuracy of 0.88. Always predicting no rain would score 0.72. Take a test day with humidity 82, pressure 1012.8, and wind 24. The tree answers no, no, then yes, and predicts rain. The true rule says no rain, because 1012.8 isn’t below 1012, and the day was dry.

That’s overfitting in miniature. On the training days the tree scores 0.927, better than the true rule’s 0.913. On the test days it scores 0.88 to the true rule’s 0.92.

What to watch for across languages

None of these implementations calls a ready-made tree. The usual one in R, rpart(), is a recommended package that this site’s R image leaves out, and none of the other twenty-eight languages ships one. They use general tools: sorting, counting, and arithmetic.

Watch how each holds the tree, since a tree is the first data structure on this site that contains itself. Rust uses an enum whose children must be boxed, Swift an indirect enum that boxes them for you, Ruby uses two Data classes and pattern matching, Julia uses two types and multiple dispatch, Haskell and OCaml use an algebraic data type that names itself with no boxes or pointers, Java uses a sealed interface and two records, Common Lisp uses a nested list shaped like its own code, Tcl a nested list such as {leaf yes}, Lua nested tables such as {predict = "yes"}, Perl nested hash references such as { leaf => 'yes' }, PHP keyed arrays such as ['leaf' => 'yes'], Elixir tagged tuples such as {:leaf, "yes"} with one function clause per shape, Prolog uses a plain term with one clause per kind of node, Go and C use a struct with nil or NULL children, Pascal a record whose children are pointers from New, Ada a variant record whose Is_Leaf field decides which other fields it has, C++ uses a struct whose std::unique_ptr children free themselves, Octave nests structs inside structs, and Python, R, and JavaScript use tuples, nested lists, or plain objects. Fortran keeps an array of node records, and awk, APL, and BASIC flatten the tree into parallel arrays, all in preorder.

Watch, too, how each grows it. Twenty-six languages write grow as a function that calls itself on each side; Fortran has to mark it recursive, and OCaml writes let rec. COBOL and BASIC can’t: a paragraph can perform itself, and a subroutine can GOSUB itself, but every level shares the same variables, so both keep an explicit stack of nodes. PostgreSQL grows the tree one level at a time with WITH RECURSIVE, with each row naming its node by a path such as 'LR'.

And watch the split search. R, APL, and Octave build a table of days by thresholds and score every threshold at once, with no loop over thresholds. The rest try each threshold in turn.

Data preview

humiditypressurewindrainsplit
541007.435notrain
551030.037notrain
481011.339notrain
981026.52notrain
441013.79notrain
821012.628notrain
55993.911notrain
581020.626notrain

First 8 of 200 rows · Download data.csv

Expected results

Every implementation must print these, to within a relative tolerance of 10⁻⁴.

train_count150
test_count50
node_1_featurepressure
node_1_threshold1000.950000
node_2_featurehumidity
node_2_threshold63.500000
node_3_featurehumidity
node_3_threshold54.000000
node_4_predictyes
node_5_predictno
node_6_predictyes
node_7_featurehumidity
node_7_threshold72.500000
node_8_featurewind
node_8_threshold31.500000
node_9_predictno
node_10_predictno
node_11_featurepressure
node_11_threshold1013.700000
node_12_predictyes
node_13_predictno
leaves7
train_accuracy0.926667
correct44
accuracy0.880000

Implementations

Python

From scratchTested automaticallyCPython 3.15.2
main.pyView on GitHub
"""A decision tree classifier (CART with Gini impurity), standard library only.

Grows a tree on the training days by always making the split that leaves the
two sides purest, then predicts rain for the test days. Prints the tree in
preorder: each node is either a split (feature and threshold) or a leaf
(its prediction).
"""

import csv
import sys

FEATURES = ("humidity", "pressure", "wind")
MAX_DEPTH = 3
MIN_SIZE = 5


def read(path):
    train, test = [], []
    with open(path, newline="") as f:
        for row in csv.DictReader(f):
            day = ([float(row[name]) for name in FEATURES], row["rain"])
            (train if row["split"] == "train" else test).append(day)
    return train, test


def gini(labels):
    # Chance that two days drawn at random (with replacement) disagree.
    p = labels.count("yes") / len(labels)
    return 1 - p * p - (1 - p) * (1 - p)


def majority(labels):
    # An even split predicts "no".
    return "yes" if labels.count("yes") > len(labels) / 2 else "no"


def best_split(days):
    """The (score, feature, threshold) with the lowest weighted Gini impurity.
    Features are tried in column order and thresholds from smallest to largest;
    only a strictly lower score replaces the best so far."""
    best = None
    for f in range(len(FEATURES)):
        values = sorted({x[f] for x, _ in days})
        for a, b in zip(values, values[1:]):
            threshold = (a + b) / 2
            left = [label for x, label in days if x[f] <= threshold]
            right = [label for x, label in days if x[f] > threshold]
            score = (len(left) * gini(left) + len(right) * gini(right)) / len(days)
            if best is None or score < best[0]:
                best = (score, f, threshold)
    return best


def grow(days, depth=0):
    """A leaf is a label; a split is (feature, threshold, left, right)."""
    labels = [label for _, label in days]
    if gini(labels) == 0 or len(days) < MIN_SIZE or depth == MAX_DEPTH:
        return majority(labels)
    split = best_split(days)
    if split is None or split[0] >= gini(labels):
        return majority(labels)  # no split makes the sides purer
    _, f, threshold = split
    left = [d for d in days if d[0][f] <= threshold]
    right = [d for d in days if d[0][f] > threshold]
    return (f, threshold, grow(left, depth + 1), grow(right, depth + 1))


def predict(node, x):
    while isinstance(node, tuple):
        f, threshold, left, right = node
        node = left if x[f] <= threshold else right
    return node


def preorder(node):
    yield node
    if isinstance(node, tuple):
        yield from preorder(node[2])
        yield from preorder(node[3])


def main():
    train, test = read(sys.argv[1])
    tree = grow(train)

    print(f"train_count {len(train)}")
    print(f"test_count {len(test)}")
    leaves = 0
    for k, node in enumerate(preorder(tree), start=1):
        if isinstance(node, tuple):
            print(f"node_{k}_feature {FEATURES[node[0]]}")
            print(f"node_{k}_threshold {node[1]:.6f}")
        else:
            print(f"node_{k}_predict {node}")
            leaves += 1
    print(f"leaves {leaves}")

    train_correct = sum(predict(tree, x) == label for x, label in train)
    correct = sum(predict(tree, x) == label for x, label in test)
    print(f"train_accuracy {train_correct / len(train):.6f}")
    print(f"correct {correct}")
    print(f"accuracy {correct / len(test):.6f}")


if __name__ == "__main__":
    main()

Python’s standard library has no decision tree, and scikit-learn’s DecisionTreeClassifier isn’t installed, so there was no built-in to skip. The tree is plain data: a leaf is the string "yes" or "no", and a split is a tuple (feature, threshold, left, right). predict walks down until isinstance(node, tuple) is false, and grow calls itself on each side, which is the recursion the method describes.

The search is brute force. At the root it tries 228 thresholds (61 for humidity, 129 for pressure, and 38 for wind), rebuilding both sides for each one. With 150 days that still takes a fraction of a second.

Ada

From scratchTested automaticallyGNAT 14.2
main.adbView on GitHub
--  A decision tree classifier (CART with Gini impurity), using only Ada's
--  standard library.
--
--  Grows a tree on the training days by always making the split that leaves
--  the two sides purest, then predicts rain for the test days. Prints the
--  tree in preorder: each node is either a split (feature and threshold) or
--  a leaf (its prediction). Ada has no decision tree or classifier to skip.

with Ada.Text_IO;                use Ada.Text_IO;
with Ada.Long_Float_Text_IO;     use Ada.Long_Float_Text_IO;
with Ada.Command_Line;
with Ada.Characters.Handling;    use Ada.Characters.Handling;
with Ada.Strings.Fixed;          use Ada.Strings.Fixed;
with Ada.Containers.Vectors;
with Ada.Containers.Generic_Array_Sort;

procedure Main is
   type Feature is (Humidity, Pressure, Wind);   --  also the column names
   type Features is array (Feature) of Long_Float;

   type Day is record
      X    : Features;
      Rain : Boolean;
   end record;
   type Day_Array is array (Positive range <>) of Day;
   package Day_Vectors is new Ada.Containers.Vectors (Positive, Day);

   function To_Array (V : Day_Vectors.Vector) return Day_Array is
     [for I in 1 .. Natural (V.Length) => V (I)];

   type Float_Array is array (Positive range <>) of Long_Float;
   procedure Sort is new Ada.Containers.Generic_Array_Sort
     (Positive, Long_Float, Float_Array);

   Max_Depth : constant := 3;
   Min_Size  : constant := 5;

   --  A variant record: the discriminant Is_Leaf, fixed when a node is
   --  made, decides which fields the node has. A Node_Access points to a
   --  node, so a split can hold its children. The first line announces
   --  Node (an incomplete declaration) so that Node_Access can name it.
   type Node (Is_Leaf : Boolean);
   type Node_Access is access Node;
   type Node (Is_Leaf : Boolean) is record
      case Is_Leaf is
         when True =>
            Rain : Boolean;
         when False =>
            On          : Feature;
            Threshold   : Long_Float;
            Left, Right : Node_Access;
      end case;
   end record;

   --  The text of field number Col (counting from 1) in a CSV line.
   function Field (Line : String; Col : Positive) return String is
      Start : Positive := Line'First;
   begin
      for I in 1 .. Col - 1 loop
         Start := Index (Line (Start .. Line'Last), ",") + 1;
      end loop;
      declare
         Stop : constant Natural := Index (Line (Start .. Line'Last), ",");
      begin
         return (if Stop = 0 then Line (Start .. Line'Last)
                 else Line (Start .. Stop - 1));
      end;
   end Field;

   function Column (Header, Name : String) return Positive is
   begin
      for C in 1 .. Ada.Strings.Fixed.Count (Header, ",") + 1 loop
         if Field (Header, C) = Name then
            return C;
         end if;
      end loop;
      raise Constraint_Error with "column not found: " & Name;
   end Column;

   procedure Read (Path : String; Train, Test : out Day_Vectors.Vector) is
      File : File_Type;
   begin
      Open (File, In_File, Path);
      declare
         Header : constant String := Get_Line (File);
         --  F'Image is an enumeration value's name in capitals: "WIND".
         Cols : constant array (Feature) of Positive :=
           [for F in Feature => Column (Header, To_Lower (F'Image))];
         Rain_Col  : constant Positive := Column (Header, "rain");
         Split_Col : constant Positive := Column (Header, "split");
      begin
         while not End_Of_File (File) loop
            declare
               Line : constant String := Get_Line (File);
               D    : Day;
            begin
               for F in Feature loop
                  D.X (F) := Long_Float'Value (Field (Line, Cols (F)));
               end loop;
               D.Rain := Field (Line, Rain_Col) = "yes";
               if Field (Line, Split_Col) = "train" then
                  Train.Append (D);
               else
                  Test.Append (D);
               end if;
            end;
         end loop;
      end;
      Close (File);
   end Read;

   function Yes_Count (Days : Day_Array) return Natural is
      N : Natural := 0;
   begin
      for D of Days loop
         if D.Rain then
            N := N + 1;
         end if;
      end loop;
      return N;
   end Yes_Count;

   --  Chance that two days drawn at random (with replacement) disagree.
   function Gini (Days : Day_Array) return Long_Float is
      P : constant Long_Float :=
        Long_Float (Yes_Count (Days)) / Long_Float (Days'Length);
   begin
      return 1.0 - P * P - (1.0 - P) * (1.0 - P);
   end Gini;

   --  An even split predicts "no".
   function Majority (Days : Day_Array) return Boolean is
     (2 * Yes_Count (Days) > Days'Length);

   --  The days on one side of a threshold: Left means X (F) <= T.
   function Side (Days : Day_Array; F : Feature; T : Long_Float;
                  Left : Boolean) return Day_Array is
      Kept : Day_Vectors.Vector;
   begin
      for D of Days loop
         if (D.X (F) <= T) = Left then
            Kept.Append (D);
         end if;
      end loop;
      return To_Array (Kept);
   end Side;

   --  Features are tried in order and thresholds from smallest to largest;
   --  only a strictly lower weighted Gini replaces the best so far.
   procedure Best_Split (Days : Day_Array; Found : out Boolean;
                         Score : out Long_Float; On : out Feature;
                         Threshold : out Long_Float) is
   begin
      Found := False;
      Score := Long_Float'Last;
      On := Feature'First;
      Threshold := 0.0;
      for F in Feature loop
         declare
            Values : Float_Array := [for I in Days'Range => Days (I).X (F)];
         begin
            Sort (Values);
            for I in Values'First .. Values'Last - 1 loop
               if Values (I) /= Values (I + 1) then   --  skip repeats
                  declare
                     T : constant Long_Float :=
                       (Values (I) + Values (I + 1)) / 2.0;
                     L : constant Day_Array := Side (Days, F, T, True);
                     R : constant Day_Array := Side (Days, F, T, False);
                     S : constant Long_Float :=
                       (Long_Float (L'Length) * Gini (L)
                        + Long_Float (R'Length) * Gini (R))
                       / Long_Float (Days'Length);
                  begin
                     if S < Score then
                        Found := True;
                        Score := S;
                        On := F;
                        Threshold := T;
                     end if;
                  end;
               end if;
            end loop;
         end;
      end loop;
   end Best_Split;

   function Grow (Days : Day_Array; Depth : Natural) return Node_Access is
      Found     : Boolean;
      Score, T  : Long_Float;
      F         : Feature;
      Impurity  : constant Long_Float := Gini (Days);
      function Leaf return Node_Access is
        (new Node'(Is_Leaf => True, Rain => Majority (Days)));
   begin
      if Impurity = 0.0 or Days'Length < Min_Size or Depth = Max_Depth then
         return Leaf;
      end if;
      Best_Split (Days, Found, Score, F, T);
      if not Found or Score >= Impurity then
         return Leaf;   --  no split makes the sides purer
      end if;
      return new Node'(Is_Leaf   => False, On => F, Threshold => T,
                       Left  => Grow (Side (Days, F, T, True), Depth + 1),
                       Right => Grow (Side (Days, F, T, False), Depth + 1));
   end Grow;

   function Predict (Tree : Node_Access; X : Features) return Boolean is
      N : Node_Access := Tree;
   begin
      while not N.Is_Leaf loop
         N := (if X (N.On) <= N.Threshold then N.Left else N.Right);
      end loop;
      return N.Rain;
   end Predict;

   procedure Say (Name : String; Value : Long_Float) is
   begin
      Put (Name & " ");
      Put (Value, Fore => 1, Aft => 6, Exp => 0);   --  six decimals, no E
      New_Line;
   end Say;

   K, Leaves : Natural := 0;

   --  Number each node in preorder and print it.
   procedure Print (N : Node_Access) is
   begin
      K := K + 1;
      declare
         Name : constant String := "node_" & Trim (K'Image, Ada.Strings.Left);
      begin
         if N.Is_Leaf then
            Put_Line (Name & "_predict " & (if N.Rain then "yes" else "no"));
            Leaves := Leaves + 1;
         else
            Put_Line (Name & "_feature " & To_Lower (N.On'Image));
            Say (Name & "_threshold", N.Threshold);
            Print (N.Left);
            Print (N.Right);
         end if;
      end;
   end Print;

   function Correct (Tree : Node_Access; Days : Day_Array) return Natural is
      N : Natural := 0;
   begin
      for D of Days loop
         if Predict (Tree, D.X) = D.Rain then
            N := N + 1;
         end if;
      end loop;
      return N;
   end Correct;

   Train_V, Test_V : Day_Vectors.Vector;
begin
   Read (Ada.Command_Line.Argument (1), Train_V, Test_V);
   declare
      Train : constant Day_Array := To_Array (Train_V);
      Test  : constant Day_Array := To_Array (Test_V);
      Tree  : constant Node_Access := Grow (Train, 0);
      Hits  : constant Natural := Correct (Tree, Test);
   begin
      Put_Line ("train_count" & Train'Length'Image);
      Put_Line ("test_count" & Test'Length'Image);
      Print (Tree);
      Put_Line ("leaves" & Leaves'Image);
      Say ("train_accuracy",
           Long_Float (Correct (Tree, Train)) / Long_Float (Train'Length));
      Put_Line ("correct" & Hits'Image);
      Say ("accuracy", Long_Float (Hits) / Long_Float (Test'Length));
   end;
end Main;

The tree is a variant record, Node (Is_Leaf : Boolean): the discriminant, fixed when new makes a node, decides whether it holds a prediction or a feature, a threshold, and two Node_Access children, and reading Left on a leaf raises Constraint_Error. We chose it over the Indefinite_Multiway_Trees in Ada.Containers, since a split has exactly two sides. Because Node_Access names Node before its fields exist, Ada needs the incomplete declaration type Node (Is_Leaf : Boolean); first. Grow calls itself on each side, and the split search tries each threshold in turn. Ada has no classifier, so there was none to skip; we sort each feature with Generic_Array_Sort and skip repeats.

APL

From scratchTested automaticallyGNU APL 2.0
main.aplView on GitHub
⍝ A decision tree classifier (CART with Gini impurity), from scratch in GNU
⍝ APL. Grows a tree on the training days by always making the split that
⍝ leaves the two sides purest, then predicts rain for the test days.
⍝ APL reads right to left, with no operator precedence: 2×3+4 is 2×(3+4).
⍝ ⍝ starts a comment; ← assigns; ⍵ is a function's right argument.

∇NAME SAY N
 ⍝ Print "name value" for a whole number. ⍕ formats a number as text.
 ⎕←NAME,' ',⍕N
∇

∇NAME SAY6 X;T
 ⍝ Print "name value" with six decimal places.
 T←0 6⍕|X                    ⍝ |X is the size of X; 0 6⍕ gives 6 decimals
 T←(∨\T≠' ')/T               ⍝ drop the leading blank ⍕ adds
 T←((T[1]='.')/'0'),T        ⍝ ⍕ writes .94 for 0.94, so put the 0 back
 ⎕←NAME,' ',((X<0)/'-'),T    ⍝ sign as -, since APL's own minus is ¯
∇

FEATURES←'humidity' 'pressure' 'wind'
MAX_DEPTH←3 ⋄ MIN_SIZE←5

⍝ Read the file. ⎕ARG is the command line; ↑¯1↑ takes its last item, the
⍝ data path. ⎕FIO[26] reads the whole file as one string.
TEXT←⎕FIO[26]↑¯1↑⎕ARG
LINES←(TEXT≠⎕UCS 10)⊂TEXT   ⍝ ⊂ splits at the 0s of the mask: one item per line
⍝ Split each (¨) line at commas; ⊃ stacks them into a table.
ROWS←⊃{(⍵≠',')⊂⍵}¨LINES
⍝ A column by header name (⍳), minus the header (1↓).
COLUMN←{1↓ROWS[;ROWS[1;]⍳⊂⍵]}

X←⍉⊃{⍎¨COLUMN ⍵}¨FEATURES    ⍝ one row per day; ⍎ turns text into a number
Y←(COLUMN 'rain')≡¨⊂'yes'    ⍝ 1 for a rainy day (≡ matches whole strings)
TRAIN←(COLUMN 'split')≡¨⊂'train'
TX←TRAIN⌿X ⋄ TY←TRAIN/Y      ⍝ ⌿ keeps the training rows of a table

⍝ Chance that two days drawn at random disagree, from the share ⍵ of rain.
GINI←{1-(⍵*2)+(1-⍵)*2}

∇R←Y SPLIT V;U;T;M;N;NL;YL;S
 ⍝ The best (score, threshold) for one feature's values V at a node.
 U←∪V ⋄ U←U[⍋U]              ⍝ the distinct values, sorted (⍋ is grade up)
 R←1 0 ⋄ →(1=≢U)/0           ⍝ one value: no split (score 1 never wins)
 T←((¯1↓U)+1↓U)÷2            ⍝ midpoints of neighboring values
 ⍝ ∘.≤ makes a table, one row per day and one column per threshold, with
 ⍝ a 1 where the day goes left. +⌿ counts down each column, and Y+.×
 ⍝ counts the rainy days among them, for every threshold at once.
 M←V∘.≤T ⋄ N←≢V ⋄ NL←+⌿M ⋄ YL←Y+.×M
 S←((NL×GINI YL÷NL)+(N-NL)×GINI((+/Y)-YL)÷N-NL)÷N
 R←(⌊/S),T[S⍳⌊/S]            ⍝ ⍳ finds the first minimum: smallest wins
∇

⍝ The tree is four vectors, one item per node in preorder. A split node has
⍝ a feature number (FEAT) and threshold; a leaf has FEAT 0 and a prediction.
⍝ A left child always comes next in preorder; RIGHT records the right one.
FEAT←THR←PRED←RIGHT←⍬

∇DEPTH GROW I;Y;N;G;B;F;K;L
 ⍝ Grow the node holding training rows I, then (recursively) its children.
 Y←TY[I] ⋄ N←≢I ⋄ G←GINI(+/Y)÷N
 ⍝ Add it as a leaf first; an even split predicts "no".
 ⍝ GNU APL has no FEAT,←0 shorthand, so each vector is rebuilt with , (join).
 FEAT←FEAT,0 ⋄ THR←THR,0 ⋄ RIGHT←RIGHT,0 ⋄ PRED←PRED,(+/Y)>N÷2
 K←≢FEAT ⋄ →((G=0)∨(N<MIN_SIZE)∨DEPTH=MAX_DEPTH)/0
 ⍝ ⊂[1] gives each feature's column; ⊃ stacks the results, one row each.
 B←⊃(⊂Y)SPLIT¨⊂[1]TX[I;]
 F←B[;1]⍳⌊/B[;1]             ⍝ the first feature with the lowest score
 →(B[F;1]≥G)/0               ⍝ no split makes the sides purer: stay a leaf
 FEAT[K]←F ⋄ THR[K]←B[F;2]
 L←TX[I;F]≤THR[K]
 (DEPTH+1)GROW L/I
 RIGHT[K]←1+≢FEAT            ⍝ the right subtree starts after the left
 (DEPTH+1)GROW(~L)/I
∇

0 GROW⍳≢TY

∇P←WALK DAY;K
 ⍝ Follow the splits from the root (node 1) down to a leaf.
 K←1
NEXT: →(FEAT[K]=0)/LEAF          ⍝ → jumps to a label when its argument is 1
 ⍝ The two children are K+1 and RIGHT[K]; > picks the right one (index 2).
 K←((K+1),RIGHT[K])[1+DAY[FEAT[K]]>THR[K]]
 →NEXT
LEAF: P←PRED[K]
∇

⍝ How many of the days in rows ⍵ the tree gets right.
CORRECT←{+/Y[⍵]=WALK¨⊂[2]X[⍵;]}
TRAINED←CORRECT TRAIN/⍳≢Y ⋄ TESTED←CORRECT(~TRAIN)/⍳≢Y

∇SHOW K;NODE
 NODE←'node_',(⍕K),'_'
 →(FEAT[K]=0)/LEAF
 ⎕←NODE,'feature ',⊃FEATURES[FEAT[K]]
 (NODE,'threshold')SAY6 THR[K]
 →0
LEAF: ⎕←NODE,'predict ',⊃('no' 'yes')[1+PRED[K]]
∇

'train_count' SAY +/TRAIN
'test_count' SAY +/~TRAIN
SHOW¨⍳≢FEAT
'leaves' SAY +/FEAT=0
'train_accuracy' SAY6 TRAINED÷+/TRAIN
'correct' SAY TESTED
'accuracy' SAY6 TESTED÷+/~TRAIN
)OFF

APL has no tree type, so we keep the tree as four flat vectors, one item per node in preorder: a feature number (0 for a leaf), a threshold, a prediction, and the right child’s index. A left child always comes next, so GROW appends a node, recurses left, and then records where the right side starts.

The split search is array style. V∘.≤T builds a table of days by thresholds, +⌿ and Y+.× count every threshold’s left side at once, and ⍳ finds the first minimum. GNU APL 2.0 rejects the shorthand FEAT,←0 and dfn guards like {⍵=0:1 ⋄ 2}, so we write FEAT←FEAT,0 and walk the tree with a ∇ loop. There is no tree built-in to avoid.

awk

From scratchTested automaticallymawk 1.3.4
main.awkView on GitHub
# A decision tree classifier (CART with Gini impurity), in POSIX awk.
#
# Grows a tree on the training days by always making the split that leaves the
# two sides purest, then predicts rain for the test days. Nodes are numbered in
# preorder as they are grown, so the tree prints in that order.

BEGIN {
    FS = ","
    nf = split("humidity,pressure,wind", feature, ",")
    MAX_DEPTH = 3; MIN_SIZE = 5
}

# The header line: find each column by name.
NR == 1 {
    for (i = 1; i <= NF; i++) {
        for (f = 1; f <= nf; f++)
            if ($i == feature[f]) fcol[f] = i
        if ($i == "rain") c_lab = i
        if ($i == "split") c_split = i
    }
    for (f = 1; f <= nf; f++)
        if (!fcol[f]) missing = missing " " feature[f]
    if (!c_lab) missing = missing " rain"
    if (!c_split) missing = missing " split"
    if (missing != "") {
        print "missing column(s):" missing > "/dev/stderr"
        failed = 1
        exit 1
    }
    next
}

# Every other line goes into the training or the test arrays. x[i, f] is
# feature f of training day i; awk fakes 2-D arrays with a joined key.
$c_split == "train" {
    n++
    for (f = 1; f <= nf; f++) x[n, f] = $fcol[f] + 0
    y[n] = $c_lab
    next
}
$c_split == "test" {
    m++
    for (f = 1; f <= nf; f++) q[m, f] = $fcol[f] + 0
    qy[m] = $c_lab
}

# Chance that two days drawn at random (with replacement) disagree.
function gini(yes, total,    p) {
    p = yes / total
    return 1 - p * p - (1 - p) * (1 - p)
}

# Grow the node holding the training days listed in days[1..count], and return
# its number. awk has no records, so node k lives in parallel arrays: split_f,
# split_t, left, and right for a split, and predict for a leaf. Locals are
# declared as extra parameters, so each recursive call gets its own.
function grow(days, count, depth,
              k, i, j, s, f, v, nv, a, t, nl, yl, yes, score, node_gini,
              best, best_f, best_t, ldays, rdays, nr) {
    k = ++nodes
    yes = 0
    for (i = 1; i <= count; i++) if (y[days[i]] == "yes") yes++
    # An even split predicts "no".
    predict[k] = (yes > count / 2) ? "yes" : "no"
    node_gini = gini(yes, count)
    if (node_gini == 0 || count < MIN_SIZE || depth == MAX_DEPTH) return k

    # Features in column order, thresholds from smallest to largest; only a
    # strictly lower score replaces the best, so the first of a tie wins.
    best = ""
    for (f = 1; f <= nf; f++) {
        # The distinct values, kept sorted by insertion (POSIX awk has no sort).
        nv = 0
        for (i = 1; i <= count; i++) {
            a = x[days[i], f]
            for (j = nv; j > 0 && v[j] > a; j--) ;  # find a's place
            if (j > 0 && v[j] == a) continue         # already listed
            for (s = nv; s > j; s--) v[s + 1] = v[s]
            v[j + 1] = a; nv++
        }
        for (j = 1; j < nv; j++) {
            t = (v[j] + v[j + 1]) / 2
            nl = yl = 0
            for (i = 1; i <= count; i++)
                if (x[days[i], f] <= t) { nl++; if (y[days[i]] == "yes") yl++ }
            score = (nl * gini(yl, nl) + (count - nl) * gini(yes - yl, count - nl)) / count
            if (best == "" || score < best) { best = score; best_f = f; best_t = t }
        }
    }
    if (best == "" || best >= node_gini) return k  # no split makes the sides purer

    nl = nr = 0
    for (i = 1; i <= count; i++)
        if (x[days[i], best_f] <= best_t) ldays[++nl] = days[i]
        else rdays[++nr] = days[i]
    split_f[k] = best_f; split_t[k] = best_t
    left[k] = grow(ldays, nl, depth + 1)
    right[k] = grow(rdays, nr, depth + 1)
    return k
}

# Walk from the root to a leaf; z[f] holds the day's features.
function classify(z,    k) {
    k = 1
    while (k in split_f) k = (z[split_f[k]] <= split_t[k]) ? left[k] : right[k]
    return predict[k]
}

END {
    if (failed) exit 1

    for (i = 1; i <= n; i++) all[i] = i
    grow(all, n, 0)

    printf "train_count %d\n", n
    printf "test_count %d\n", m
    for (k = 1; k <= nodes; k++) {
        if (k in split_f) {
            printf "node_%d_feature %s\n", k, feature[split_f[k]]
            printf "node_%d_threshold %.6f\n", k, split_t[k]
        } else {
            printf "node_%d_predict %s\n", k, predict[k]
            leaves++
        }
    }
    printf "leaves %d\n", leaves

    for (i = 1; i <= n; i++) {
        for (f = 1; f <= nf; f++) z[f] = x[i, f]
        if (classify(z) == y[i]) train_correct++
    }
    for (i = 1; i <= m; i++) {
        for (f = 1; f <= nf; f++) z[f] = q[i, f]
        if (classify(z) == qy[i]) correct++
    }
    printf "train_accuracy %.6f\n", train_correct / n
    printf "correct %d\n", correct
    printf "accuracy %.6f\n", correct / m
}

A tree is recursive, and so is the grow function. Each call gets the ids of its days in an array, finds the best split, and calls itself on the two halves. That works since locals declared as extra parameters, arrays like ldays and rdays included, are fresh in every call. There are no records, so node k lives in parallel arrays, split_f, split_t, left, right, and predict, numbered in preorder as it’s grown.

POSIX awk has no sort (mawk answers asort with “function asort never defined”), so each feature’s distinct values are kept sorted by insertion. awk has no decision tree built in. It runs unchanged on mawk, which the tests use, and on macOS awk.

BASIC

From scratchTested automaticallyBywater BASIC 2.20
main.basView on GitHub
10 REM A decision tree classifier (CART with Gini impurity), in classic BASIC.
20 REM
30 REM Grows a tree on the training days by always making the split that
40 REM leaves the two sides purest, then predicts rain for the test days.
50 REM GOSUB can call itself, but every variable is global, so a recursive
60 REM grow would lose its place. Instead, nodes wait on a stack: popping
70 REM the left child before the right visits them in preorder, and each
80 REM node is printed as soon as it is decided.
90 REM
100 REM run.sh passes the data path in the DATA environment variable,
110 REM because bwbasic can't read command-line arguments.
120 MAXDEPTH = 3 : MINSIZE = 5
130 DIM NAME$(3), FL$(20)
140 NAME$(1) = "humidity" : NAME$(2) = "pressure" : NAME$(3) = "wind"
150 REM Each day has three features, a label, and DN, the tree node it sits
160 REM in while the tree grows (0 for a test day, so it is never counted).
170 DIM X(1000, 3), RAIN$(1000), ISTRAIN(1000), DN(1000)
180 REM Depth 3 allows at most 15 nodes. A split node has a feature (0 for
190 REM a leaf), a threshold, and two children; a leaf has a prediction.
200 DIM DEPTH(15), FEAT(15), THR(15), LFT(15), RGT(15), PRED$(15), STACK(15)
210 GOSUB 1000 : REM read the days
220 PRINT "train_count"; NTRAIN
230 PRINT "test_count"; NTEST
240 REM Every training day starts in the root, node 1, at depth 0.
250 NODES = 1 : DEPTH(1) = 0
260 FOR I = 1 TO NDAYS
270 IF ISTRAIN(I) THEN DN(I) = 1
280 NEXT I
290 SP = 1 : STACK(1) = 1 : PREORDER = 0 : LEAVES = 0
300 WHILE SP > 0
310 K = STACK(SP) : SP = SP - 1
320 GOSUB 2000 : REM decide node K
330 WEND
340 PRINT "leaves"; LEAVES
350 TRAINOK = 0 : TESTOK = 0
360 FOR I = 1 TO NDAYS
370 GOSUB 8000 : REM predict day I into P$
380 REM A second IF after THEN on the same line is unreliable in bwbasic,
390 REM so both tests go in one IF, joined by AND.
400 IF P$ = RAIN$(I) AND ISTRAIN(I) = 1 THEN TRAINOK = TRAINOK + 1
410 IF P$ = RAIN$(I) AND ISTRAIN(I) = 0 THEN TESTOK = TESTOK + 1
420 NEXT I
430 L$ = "train_accuracy" : Y = TRAINOK / NTRAIN : GOSUB 3000
440 PRINT "correct"; TESTOK
450 L$ = "accuracy" : Y = TESTOK / NTEST : GOSUB 3000
460 SYSTEM : REM without SYSTEM, bwbasic waits at its prompt
1000 REM Read every day into X, RAIN$, and ISTRAIN, finding the columns
1010 REM by their names in the header.
1020 F$ = ENVIRON$("DATA")
1030 OPEN "I", #1, F$
1040 LINE INPUT #1, L$
1050 GOSUB 4000
1060 DIM COL(3)
1070 FOR C = 1 TO NF
1080 FOR F = 1 TO 3
1090 IF FL$(C) = NAME$(F) THEN COL(F) = C
1100 NEXT F
1110 IF FL$(C) = "rain" THEN RAINCOL = C
1120 IF FL$(C) = "split" THEN SPLITCOL = C
1130 NEXT C
1140 NDAYS = 0 : NTRAIN = 0
1150 IF EOF(1) THEN 1250
1160 LINE INPUT #1, L$
1170 GOSUB 4000
1180 NDAYS = NDAYS + 1
1190 FOR F = 1 TO 3
1200 X(NDAYS, F) = VAL(FL$(COL(F)))
1210 NEXT F
1220 RAIN$(NDAYS) = FL$(RAINCOL)
1230 IF FL$(SPLITCOL) = "train" THEN ISTRAIN(NDAYS) = 1 : NTRAIN = NTRAIN + 1
1240 GOTO 1150
1250 CLOSE #1 : NTEST = NDAYS - NTRAIN
1260 RETURN
2000 REM Decide node K: make it a leaf, or split it and push its two
2010 REM children, right first so the left one is popped next.
2020 PREORDER = PREORDER + 1 : ID$ = "node_" + MID$(STR$(PREORDER), 2)
2030 N = 0 : YES = 0
2040 FOR I = 1 TO NDAYS
2050 IF DN(I) = K THEN N = N + 1
2060 IF DN(I) = K AND RAIN$(I) = "yes" THEN YES = YES + 1
2070 NEXT I
2080 P = YES / N : G = 1 - P * P - (1 - P) * (1 - P)
2090 BEST = 9 : REM higher than any Gini score, so "no split found"
2100 IF G > 0 AND N >= MINSIZE AND DEPTH(K) < MAXDEPTH THEN GOSUB 5000
2110 REM No split was tried, or none makes the sides purer: a leaf.
2120 IF BEST < G THEN 2180
2130 LEAVES = LEAVES + 1
2140 REM An even split predicts "no".
2150 IF YES * 2 > N THEN PRED$(K) = "yes" ELSE PRED$(K) = "no"
2160 PRINT ID$; "_predict "; PRED$(K)
2170 RETURN
2180 FEAT(K) = BESTF : THR(K) = BESTT
2190 PRINT ID$; "_feature "; NAME$(BESTF)
2200 L$ = ID$ + "_threshold" : Y = BESTT : GOSUB 3000
2210 LFT(K) = NODES + 1 : RGT(K) = NODES + 2 : NODES = NODES + 2
2220 DEPTH(LFT(K)) = DEPTH(K) + 1 : DEPTH(RGT(K)) = DEPTH(K) + 1
2230 FOR I = 1 TO NDAYS
2240 IF DN(I) <> K THEN 2260 : REM skip days in other nodes
2250 IF X(I, BESTF) <= BESTT THEN DN(I) = LFT(K) ELSE DN(I) = RGT(K)
2260 NEXT I
2270 SP = SP + 1 : STACK(SP) = RGT(K)
2280 SP = SP + 1 : STACK(SP) = LFT(K)
2290 RETURN
3000 REM Print L$ and Y with six decimals.
3010 PRINT L$; " ";
3020 PRINT USING "#.######"; Y
3030 RETURN
4000 REM Split L$ at its commas into FL$(1) to FL$(NF).
4010 NF = 0 : P = 1
4020 Q = INSTR(P, L$, ",")
4030 NF = NF + 1
4040 IF Q = 0 THEN FL$(NF) = MID$(L$, P) : RETURN
4050 FL$(NF) = MID$(L$, P, Q - P) : P = Q + 1
4060 GOTO 4020
5000 REM The lowest weighted Gini impurity over every feature and threshold
5010 REM for node K, into BEST, BESTF, and BESTT. Features go in column
5020 REM order, thresholds from smallest to largest, and only a strictly
5030 REM lower score replaces the best so far.
5040 FOR F = 1 TO 3
5050 REM Start at the node's smallest value, then step through the
5060 REM distinct values in order without sorting them.
5070 LOW = 1E+30
5080 FOR I = 1 TO NDAYS
5090 IF DN(I) = K AND X(I, F) < LOW THEN LOW = X(I, F)
5100 NEXT I
5110 GOSUB 6000 : REM the next value above LOW, into NXT
5120 WHILE HASNEXT
5130 T = (LOW + NXT) / 2
5140 GOSUB 7000 : REM score the split at T, into S
5150 IF S < BEST THEN BEST = S : BESTF = F : BESTT = T
5160 LOW = NXT
5170 GOSUB 6000
5180 WEND
5190 NEXT F
5200 RETURN
6000 REM The smallest value of feature F in node K above LOW, into NXT,
6010 REM with HASNEXT = 0 if there is none.
6020 HASNEXT = 0
6030 FOR I = 1 TO NDAYS
6040 IF DN(I) <> K THEN 6070
6050 IF X(I, F) <= LOW THEN 6070
6060 IF HASNEXT = 0 OR X(I, F) < NXT THEN NXT = X(I, F) : HASNEXT = 1
6070 NEXT I
6080 RETURN
7000 REM (nL * giniL + nR * giniR) / n for feature F at threshold T, into
7010 REM S. Left is value <= T. Both sides hold at least one day, since T
7020 REM lies between two values present in the node.
7030 NL = 0 : YL = 0
7040 FOR I = 1 TO NDAYS
7050 IF DN(I) <> K OR X(I, F) > T THEN 7080
7060 NL = NL + 1
7070 IF RAIN$(I) = "yes" THEN YL = YL + 1
7080 NEXT I
7090 P = YL / NL : GL = 1 - P * P - (1 - P) * (1 - P)
7100 P = (YES - YL) / (N - NL) : GR = 1 - P * P - (1 - P) * (1 - P)
7110 S = (NL * GL + (N - NL) * GR) / N
7120 RETURN
8000 REM Walk day I down from the root to a leaf, into P$.
8010 K = 1
8020 WHILE FEAT(K) > 0
8030 REM X(I, FEAT(K)) in one step reads the wrong element in bwbasic, so
8040 REM copy FEAT(K) into F first.
8050 F = FEAT(K)
8060 IF X(I, F) <= THR(K) THEN K = LFT(K) ELSE K = RGT(K)
8070 WEND
8080 P$ = PRED$(K)
8090 RETURN

This grows the tree without recursion. GOSUB can call itself, but when we tried it, every level shared the same variables, so nodes wait on a stack instead. We pop a node, decide it, print it, and push its right child before its left, which visits the nodes in preorder. The tree lives in parallel arrays (FEAT, THR, LFT, RGT, and PRED$), and each training day records the node it sits in, DN.

There’s no sort, so subroutine 6000 scans for the next distinct value above the current one. Two bwbasic surprises shaped the code: an IF after THEN ignores its own test, and X(I, FEAT(K)) reads the wrong element, so line 8050 copies FEAT(K) into F first.

C

From scratchTested automaticallyGCC 14.2
main.cView on GitHub
/* A decision tree classifier (CART with Gini impurity), using only the C
 * standard library.
 *
 * Grows a tree on the training days by always making the split that leaves
 * the two sides purest, then predicts rain for the test days. Prints the tree
 * in preorder: each node is either a split (feature and threshold) or a leaf
 * (its prediction). */

#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define N_FEATURES 3
#define MAX_DEPTH 3
#define MIN_SIZE 5
#define MAX_LINE 1024
#define MAX_FIELDS 16

static const char *FEATURES[N_FEATURES] = {"humidity", "pressure", "wind"};

struct day {
    double x[N_FEATURES];
    bool rain;
};

/* One struct serves for both kinds of node. A split has two children; a leaf
 * has NULL children and a label. The struct can point to itself because a
 * pointer's size is known before the struct is complete. */
struct node {
    int feature;
    double threshold;
    struct node *left, *right;
    bool label;
};

static bool is_leaf(const struct node *n) { return n->left == NULL; }

/* Split a line at its commas into fields[], returning how many there are.
 * strtok replaces each comma with '\0', so the fields point into line. */
static int split_fields(char *line, char *fields[]) {
    int count = 0;
    for (char *f = strtok(line, ",\r\n"); f && count < MAX_FIELDS; f = strtok(NULL, ",\r\n"))
        fields[count++] = f;
    return count;
}

/* Return the 0-based position of name among the header fields, or exit. */
static int column(char *header[], int count, const char *name) {
    for (int i = 0; i < count; i++)
        if (strcmp(header[i], name) == 0) return i;
    fprintf(stderr, "no column %s\n", name);
    exit(1);
}

/* Append one day to a growable array, doubling its capacity when full. */
static void push(struct day **days, size_t *n, size_t *capacity, struct day d) {
    if (*n == *capacity) {
        *capacity = *capacity ? *capacity * 2 : 64;
        *days = realloc(*days, *capacity * sizeof **days);
    }
    (*days)[(*n)++] = d;
}

static void read_days(const char *path, struct day **train, size_t *n_train,
                      struct day **test, size_t *n_test) {
    FILE *file = fopen(path, "r");
    if (!file) { perror(path); exit(1); }

    char header_line[MAX_LINE], line[MAX_LINE];
    char *fields[MAX_FIELDS];
    if (!fgets(header_line, sizeof header_line, file)) { fprintf(stderr, "empty file\n"); exit(1); }
    int count = split_fields(header_line, fields);
    int feature_column[N_FEATURES];
    for (int f = 0; f < N_FEATURES; f++) feature_column[f] = column(fields, count, FEATURES[f]);
    int rain_column = column(fields, count, "rain");
    int split_column = column(fields, count, "split");

    size_t train_capacity = 0, test_capacity = 0;
    *train = *test = NULL;
    *n_train = *n_test = 0;
    while (fgets(line, sizeof line, file)) {
        if (split_fields(line, fields) < count) continue;
        struct day d;
        for (int f = 0; f < N_FEATURES; f++) d.x[f] = strtod(fields[feature_column[f]], NULL);
        d.rain = strcmp(fields[rain_column], "yes") == 0;
        if (strcmp(fields[split_column], "train") == 0)
            push(train, n_train, &train_capacity, d);
        else
            push(test, n_test, &test_capacity, d);
    }
    fclose(file);
}

static size_t count_yes(const struct day *days, size_t n) {
    size_t yes = 0;
    for (size_t i = 0; i < n; i++) yes += days[i].rain;
    return yes;
}

/* The chance that two days drawn at random (with replacement) disagree. */
static double gini(const struct day *days, size_t n) {
    double p = (double)count_yes(days, n) / n;
    return 1 - p * p - (1 - p) * (1 - p);
}

/* "yes" only for a strict majority; an even split predicts "no". */
static bool majority(const struct day *days, size_t n) {
    return 2 * count_yes(days, n) > n;
}

/* Copy the days at or below the threshold into left, the rest into right.
 * Both arrays are the caller's, each with room for n days. */
static void partition(const struct day *days, size_t n, int f, double threshold,
                      struct day *left, size_t *n_left, struct day *right, size_t *n_right) {
    *n_left = *n_right = 0;
    for (size_t i = 0; i < n; i++) {
        if (days[i].x[f] <= threshold) left[(*n_left)++] = days[i];
        else right[(*n_right)++] = days[i];
    }
}

/* qsort calls this to compare two elements, passed as untyped pointers. */
static int compare_doubles(const void *a, const void *b) {
    double x = *(const double *)a, y = *(const double *)b;
    return (x > y) - (x < y);
}

/* Find the split with the lowest weighted Gini impurity. Features are tried
 * in column order and thresholds from smallest to largest; only a strictly
 * lower score replaces the best so far. Returns false if no feature has two
 * distinct values. */
static bool best_split(const struct day *days, size_t n,
                       double *score, int *feature, double *threshold) {
    bool found = false;
    double *values = malloc(n * sizeof *values);
    struct day *left = malloc(n * sizeof *left), *right = malloc(n * sizeof *right);
    for (int f = 0; f < N_FEATURES; f++) {
        for (size_t i = 0; i < n; i++) values[i] = days[i].x[f];
        qsort(values, n, sizeof *values, compare_doubles);
        /* Drop repeats, which sit next to each other once sorted. */
        size_t distinct = 1;
        for (size_t i = 1; i < n; i++)
            if (values[i] != values[distinct - 1]) values[distinct++] = values[i];

        for (size_t i = 1; i < distinct; i++) {
            double t = (values[i - 1] + values[i]) / 2;
            size_t n_left, n_right;
            partition(days, n, f, t, left, &n_left, right, &n_right);
            double s = (n_left * gini(left, n_left) + n_right * gini(right, n_right)) / n;
            if (!found || s < *score) {
                *score = s; *feature = f; *threshold = t;
                found = true;
            }
        }
    }
    free(values); free(left); free(right);
    return found;
}

static struct node *new_leaf(const struct day *days, size_t n) {
    struct node *leaf = malloc(sizeof *leaf);
    *leaf = (struct node){.left = NULL, .right = NULL, .label = majority(days, n)};
    return leaf;
}

/* Grow a tree on days, calling itself on each side of the best split. */
static struct node *grow(const struct day *days, size_t n, int depth) {
    double g = gini(days, n);
    if (g == 0 || n < MIN_SIZE || depth == MAX_DEPTH) return new_leaf(days, n);

    double score, threshold;
    int f;
    if (!best_split(days, n, &score, &f, &threshold) || score >= g)
        return new_leaf(days, n); /* no split makes the sides purer */

    struct day *left = malloc(n * sizeof *left), *right = malloc(n * sizeof *right);
    size_t n_left, n_right;
    partition(days, n, f, threshold, left, &n_left, right, &n_right);

    struct node *split = malloc(sizeof *split);
    *split = (struct node){.feature = f, .threshold = threshold,
                           .left = grow(left, n_left, depth + 1),
                           .right = grow(right, n_right, depth + 1)};
    free(left); free(right); /* the children keep no pointers into these */
    return split;
}

static bool predict(const struct node *n, const double x[]) {
    while (!is_leaf(n)) n = x[n->feature] <= n->threshold ? n->left : n->right;
    return n->label;
}

/* Print every node, parents before children, left before right. *k is the
 * running node number and *leaves the running leaf count. */
static void print_preorder(const struct node *n, int *k, int *leaves) {
    int id = ++*k;
    if (is_leaf(n)) {
        printf("node_%d_predict %s\n", id, n->label ? "yes" : "no");
        ++*leaves;
        return;
    }
    printf("node_%d_feature %s\n", id, FEATURES[n->feature]);
    printf("node_%d_threshold %.6f\n", id, n->threshold);
    print_preorder(n->left, k, leaves);
    print_preorder(n->right, k, leaves);
}

static size_t count_correct(const struct node *tree, const struct day *days, size_t n) {
    size_t correct = 0;
    for (size_t i = 0; i < n; i++) correct += predict(tree, days[i].x) == days[i].rain;
    return correct;
}

/* Nothing frees memory for you: release the children, then the node. */
static void free_tree(struct node *n) {
    if (!is_leaf(n)) { free_tree(n->left); free_tree(n->right); }
    free(n);
}

int main(int argc, char **argv) {
    if (argc < 2) { fprintf(stderr, "usage: %s data.csv\n", argv[0]); return 1; }

    struct day *train, *test;
    size_t n_train, n_test;
    read_days(argv[1], &train, &n_train, &test, &n_test);
    struct node *tree = grow(train, n_train, 0);

    printf("train_count %zu\n", n_train);
    printf("test_count %zu\n", n_test);
    int k = 0, leaves = 0;
    print_preorder(tree, &k, &leaves);
    printf("leaves %d\n", leaves);

    size_t correct = count_correct(tree, test, n_test);
    printf("train_accuracy %.6f\n", (double)count_correct(tree, train, n_train) / n_train);
    printf("correct %zu\n", correct);
    printf("accuracy %.6f\n", (double)correct / n_test);

    free_tree(tree);
    free(train);
    free(test);
    return 0;
}

The tree is a struct node with a feature, a threshold, a label, and two child pointers, struct node *left, *right. A struct can’t hold a copy of itself, only a pointer to one, and a leaf is a node whose children are NULL. grow calls itself on each side, getting every node from malloc and copying each side’s days into fresh arrays, which it frees once the children are built. Nothing cleans up for you, so free_tree releases the children before their parent. To list a feature’s distinct values, we sort with qsort and drop repeats in one pass. C has no tree or classifier in its standard library, so there was none to skip.

C++

From scratchTested automaticallyGCC 14.2 (g++, C++23)
main.cppView on GitHub
// A decision tree classifier (CART with Gini impurity), using only the C++
// standard library.
//
// Grows a tree on the training days by always making the split that leaves
// the two sides purest, then predicts rain for the test days. Prints the tree
// in preorder: each node is either a split (feature and threshold) or a leaf
// (its prediction).

#include <algorithm>
#include <array>
#include <fstream>
#include <memory>
#include <optional>
#include <print>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

constexpr std::array<const char*, 3> FEATURES = {"humidity", "pressure", "wind"};
constexpr int MAX_DEPTH = 3;
constexpr std::size_t MIN_SIZE = 5;

struct Day {
    std::array<double, FEATURES.size()> x;
    bool rain;
};

// One struct serves for both kinds of node; a leaf has no children. A
// std::unique_ptr owns the node it points to and deletes it when it goes out
// of scope, so dropping the root frees the whole tree with no free_tree.
// It can't be copied, only moved, so each node has exactly one owner.
struct Node {
    int feature = 0;
    double threshold = 0;
    std::unique_ptr<Node> left, right;
    bool rain = false;

    bool is_leaf() const { return left == nullptr; }
};

// A candidate split and its weighted impurity, before any children exist.
struct Candidate {
    double score;
    int feature;
    double threshold;
};

// Split one line at every comma.
std::vector<std::string> split(const std::string& line) {
    std::vector<std::string> fields;
    std::stringstream stream(line);
    std::string field;
    while (std::getline(stream, field, ',')) {
        fields.push_back(field);
    }
    return fields;
}

// The named column of a CSV file, as strings; the caller parses numbers.
std::vector<std::string> read_column(const std::string& path, const std::string& name) {
    std::ifstream file(path);
    std::string line;
    std::getline(file, line);
    std::vector<std::string> header = split(line);
    auto found = std::find(header.begin(), header.end(), name);
    if (found == header.end()) {
        throw std::runtime_error("no column " + name);
    }
    std::size_t index = found - header.begin();

    std::vector<std::string> values;
    while (std::getline(file, line)) {
        if (!line.empty()) {
            values.push_back(split(line).at(index));
        }
    }
    return values;
}

std::size_t count_yes(const std::vector<Day>& days) {
    // count_if counts the elements the lambda (an inline function) accepts.
    return std::ranges::count_if(days, [](const Day& d) { return d.rain; });
}

// The chance that two days drawn at random (with replacement) disagree.
double gini(const std::vector<Day>& days) {
    double p = static_cast<double>(count_yes(days)) / days.size();
    return 1 - p * p - (1 - p) * (1 - p);
}

// Rain only on a strict majority; an even split predicts no rain.
bool majority(const std::vector<Day>& days) {
    return 2 * count_yes(days) > days.size();
}

// The days at or below the threshold (left) or above it (right).
std::vector<Day> side(const std::vector<Day>& days, int f, double threshold, bool left) {
    std::vector<Day> out;
    for (const Day& d : days) {
        if ((d.x[f] <= threshold) == left) {
            out.push_back(d);
        }
    }
    return out;
}

// The candidate with the lowest weighted Gini impurity, or nothing
// (std::nullopt) if no feature has two distinct values. Features are tried in
// column order and thresholds from smallest to largest; only a strictly lower
// score replaces the best so far.
std::optional<Candidate> best_split(const std::vector<Day>& days) {
    std::optional<Candidate> best;
    for (int f = 0; f < static_cast<int>(FEATURES.size()); f++) {
        // A std::set keeps its values sorted and drops repeats.
        std::set<double> values;
        for (const Day& d : days) {
            values.insert(d.x[f]);
        }
        std::optional<double> previous;
        for (double value : values) {
            if (previous) {
                double t = (*previous + value) / 2;
                std::vector<Day> left = side(days, f, t, true);
                std::vector<Day> right = side(days, f, t, false);
                double score = (left.size() * gini(left) + right.size() * gini(right))
                    / days.size();
                if (!best || score < best->score) {
                    best = Candidate{score, f, t};
                }
            }
            previous = value;
        }
    }
    return best;
}

std::unique_ptr<Node> leaf(const std::vector<Day>& days) {
    auto node = std::make_unique<Node>();
    node->rain = majority(days);
    return node;
}

// Grow a tree on days, calling itself on each side of the best split.
std::unique_ptr<Node> grow(const std::vector<Day>& days, int depth) {
    double impurity = gini(days);
    if (impurity == 0 || days.size() < MIN_SIZE || depth == MAX_DEPTH) {
        return leaf(days);
    }
    std::optional<Candidate> best = best_split(days);
    if (!best || best->score >= impurity) {
        return leaf(days); // no split makes the sides purer
    }
    auto node = std::make_unique<Node>();
    node->feature = best->feature;
    node->threshold = best->threshold;
    node->left = grow(side(days, best->feature, best->threshold, true), depth + 1);
    node->right = grow(side(days, best->feature, best->threshold, false), depth + 1);
    return node;
}

bool predict(const Node& node, const Day& day) {
    const Node* n = &node;
    while (!n->is_leaf()) {
        n = day.x[n->feature] <= n->threshold ? n->left.get() : n->right.get();
    }
    return n->rain;
}

// Print every node, parents before children, left before right. k is the
// running node number and leaves the running leaf count, both passed by
// reference (&) so the calls below share them.
void print_preorder(const Node& node, int& k, int& leaves) {
    int id = ++k;
    if (node.is_leaf()) {
        std::println("node_{}_predict {}", id, node.rain ? "yes" : "no");
        leaves++;
        return;
    }
    std::println("node_{}_feature {}", id, FEATURES[node.feature]);
    std::println("node_{}_threshold {:.6f}", id, node.threshold);
    print_preorder(*node.left, k, leaves);
    print_preorder(*node.right, k, leaves);
}

std::size_t count_correct(const Node& tree, const std::vector<Day>& days) {
    return std::ranges::count_if(days, [&](const Day& d) { return predict(tree, d) == d.rain; });
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::println(stderr, "usage: program data.csv");
        return 1;
    }
    std::array<std::vector<std::string>, FEATURES.size()> columns;
    for (std::size_t f = 0; f < FEATURES.size(); f++) {
        columns[f] = read_column(argv[1], FEATURES[f]);
    }
    std::vector<std::string> rain = read_column(argv[1], "rain");
    std::vector<std::string> split_name = read_column(argv[1], "split");

    std::vector<Day> train, test;
    for (std::size_t row = 0; row < rain.size(); row++) {
        Day day{};
        for (std::size_t f = 0; f < FEATURES.size(); f++) {
            day.x[f] = std::stod(columns[f][row]);
        }
        day.rain = rain[row] == "yes"; // std::string compares by content
        (split_name[row] == "train" ? train : test).push_back(day);
    }
    std::unique_ptr<Node> tree = grow(train, 0);

    std::println("train_count {}", train.size());
    std::println("test_count {}", test.size());
    int k = 0, leaves = 0;
    print_preorder(*tree, k, leaves);
    std::println("leaves {}", leaves);

    std::size_t correct = count_correct(*tree, test);
    std::println("train_accuracy {:.6f}",
                 static_cast<double>(count_correct(*tree, train)) / train.size());
    std::println("correct {}", correct);
    std::println("accuracy {:.6f}", static_cast<double>(correct) / test.size());
    // No cleanup: when tree goes out of scope, every node is deleted.
}

Like C’s version, the tree is one struct Node with a feature, a threshold, a label, and two children, and a leaf is a node with no children. Here the children are std::unique_ptr<Node>, which owns the node it points to and deletes it when it goes out of scope, so there’s no malloc, no free, and no free_tree: when main ends, the whole tree goes with the root. A unique_ptr can’t be copied, only moved, so each node has one owner. grow calls itself on each side, and we let a std::set sort each feature’s values and drop repeats. C++ has no tree or classifier to skip.

COBOL

From scratchTested automaticallyGnuCOBOL 3.2
main.cobView on GitHub
      *> A decision tree classifier (CART with Gini impurity), in COBOL.
      *>
      *> Grows a tree on the training days by always making the split
      *> that leaves the two sides purest, then predicts rain for the
      *> test days. COBOL paragraphs can't recurse, so nodes wait on an
      *> explicit stack; popping left before right visits them in
      *> preorder, and each node is printed as soon as it is decided.
       IDENTIFICATION DIVISION.
       PROGRAM-ID. DECISION-TREE.

       ENVIRONMENT DIVISION.
       INPUT-OUTPUT SECTION.
       FILE-CONTROL.
           SELECT DATA-FILE ASSIGN TO DYNAMIC WS-FILE-NAME
               ORGANIZATION IS LINE SEQUENTIAL.

       DATA DIVISION.
       FILE SECTION.
       FD  DATA-FILE.
       01  DATA-LINE                PIC X(200).

       WORKING-STORAGE SECTION.
       01  WS-FILE-NAME             PIC X(256).
       01  WS-MAX-DEPTH             PIC 9 VALUE 3.
       01  WS-MIN-SIZE              PIC 9 VALUE 5.
       01  WS-EOF                   PIC X VALUE "N".
           88  END-OF-FILE          VALUE "Y".

      *> One CSV line split into fields, and where the columns are.
       01  WS-FIELDS.
           05  WS-FIELD             PIC X(40) OCCURS 20 TIMES.
       01  WS-FIELD-COUNT           PIC 99.
       01  WS-POINTER               PIC 999.
       01  WS-LINE-LENGTH           PIC 999.
       01  WS-FEATURE-NAMES.
           05  FILLER               PIC X(40) VALUE "humidity".
           05  FILLER               PIC X(40) VALUE "pressure".
           05  FILLER               PIC X(40) VALUE "wind".
       01  FILLER REDEFINES WS-FEATURE-NAMES.
           05  WS-FEATURE-NAME      PIC X(40) OCCURS 3 TIMES.
       01  WS-FEATURE-COLUMNS.
           05  WS-FEATURE-COLUMN    PIC 99 OCCURS 3 TIMES VALUE 0.
       01  WS-LABEL-COLUMN          PIC 99 VALUE 0.
       01  WS-SPLIT-COLUMN          PIC 99 VALUE 0.

      *> Every day, train and test. WS-DAY-NODE is the tree node a
      *> training day currently sits in while the tree grows.
       01  WS-DAY-COUNT             PIC 9(4) VALUE 0.
       01  WS-DAYS.
           05  WS-DAY               OCCURS 5000 TIMES.
               10  WS-X             PIC S9(6)V9(6) OCCURS 3 TIMES.
               10  WS-RAIN          PIC X(3).
               10  WS-IS-TRAIN      PIC X.
               10  WS-DAY-NODE      PIC 99.

      *> The tree: depth 3 allows at most 15 nodes. A split node has a
      *> feature, a threshold, and two children; a leaf has a label.
       01  WS-NODE-COUNT            PIC 99 VALUE 0.
       01  WS-NODES.
           05  WS-NODE              OCCURS 15 TIMES.
               10  WS-NODE-DEPTH    PIC 9.
               10  WS-NODE-FEATURE  PIC 9 VALUE 0.
               10  WS-NODE-THRESHOLD    PIC S9(6)V9(6).
               10  WS-NODE-LEFT     PIC 99.
               10  WS-NODE-RIGHT    PIC 99.
               10  WS-NODE-PREDICT  PIC X(3).
       01  WS-STACK-SIZE            PIC 99 VALUE 0.
       01  WS-STACK.
           05  WS-STACK-NODE        PIC 99 OCCURS 15 TIMES.

       01  I                        PIC 9(4).
       01  F                        PIC 9.
       01  K                        PIC 99.
       01  WS-PREORDER              PIC 99 VALUE 0.
       01  WS-LEAVES                PIC 99 VALUE 0.
       01  WS-N                     PIC 9(4).
       01  WS-YES                   PIC 9(4).
       01  WS-N-LEFT                PIC 9(4).
       01  WS-YES-LEFT              PIC 9(4).
       01  WS-GINI                  PIC S9V9(18).
       01  WS-GINI-LEFT             PIC S9V9(18).
       01  WS-GINI-RIGHT            PIC S9V9(18).
       01  WS-P                     PIC S9V9(18).
       01  WS-SCORE                 PIC S9V9(18).
       01  WS-BEST-SCORE            PIC S9V9(18).
       01  WS-BEST-FEATURE          PIC 9.
       01  WS-BEST-THRESHOLD        PIC S9(6)V9(6).
       01  WS-LOW                   PIC S9(6)V9(6).
       01  WS-NEXT                  PIC S9(6)V9(6).
       01  WS-HAS-NEXT              PIC X.
       01  WS-THRESHOLD             PIC S9(6)V9(6).
       01  WS-PREDICTED             PIC X(3).
       01  WS-TRAIN-COUNT           PIC 9(4) VALUE 0.
       01  WS-TEST-COUNT            PIC 9(4) VALUE 0.
       01  WS-TRAIN-CORRECT         PIC 9(4) VALUE 0.
       01  WS-CORRECT               PIC 9(4) VALUE 0.
       01  WS-ACCURACY              PIC S9(3)V9(12).
       01  WS-DECIMAL-OUT           PIC -(7)9.9(6).
       01  WS-COUNT-OUT             PIC Z(8)9.

       PROCEDURE DIVISION.
       MAIN.
           ACCEPT WS-FILE-NAME FROM ARGUMENT-VALUE
           OPEN INPUT DATA-FILE

           READ DATA-FILE
               AT END SET END-OF-FILE TO TRUE
           END-READ
           PERFORM SPLIT-LINE
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-FIELD-COUNT
               PERFORM VARYING F FROM 1 BY 1 UNTIL F > 3
                   IF WS-FIELD(I) = WS-FEATURE-NAME(F)
                       MOVE I TO WS-FEATURE-COLUMN(F)
                   END-IF
               END-PERFORM
               IF WS-FIELD(I) = "rain"
                   MOVE I TO WS-LABEL-COLUMN
               END-IF
               IF WS-FIELD(I) = "split"
                   MOVE I TO WS-SPLIT-COLUMN
               END-IF
           END-PERFORM
           IF WS-FEATURE-COLUMN(1) = 0 OR WS-FEATURE-COLUMN(2) = 0
                   OR WS-FEATURE-COLUMN(3) = 0
                   OR WS-LABEL-COLUMN = 0 OR WS-SPLIT-COLUMN = 0
               DISPLAY "missing column" UPON SYSERR
               MOVE 1 TO RETURN-CODE
               STOP RUN
           END-IF

           PERFORM UNTIL END-OF-FILE
               READ DATA-FILE
                   AT END SET END-OF-FILE TO TRUE
                   NOT AT END PERFORM ADD-DAY
               END-READ
           END-PERFORM
           CLOSE DATA-FILE

           MOVE WS-TRAIN-COUNT TO WS-COUNT-OUT
           DISPLAY "train_count " FUNCTION TRIM(WS-COUNT-OUT)
           MOVE WS-TEST-COUNT TO WS-COUNT-OUT
           DISPLAY "test_count " FUNCTION TRIM(WS-COUNT-OUT)

      *>   Every training day starts in the root, node 1, at depth 0.
           MOVE 1 TO WS-NODE-COUNT
           MOVE 0 TO WS-NODE-DEPTH(1)
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               MOVE 1 TO WS-DAY-NODE(I)
           END-PERFORM
           MOVE 1 TO WS-STACK-SIZE
           MOVE 1 TO WS-STACK-NODE(1)
           PERFORM UNTIL WS-STACK-SIZE = 0
               MOVE WS-STACK-NODE(WS-STACK-SIZE) TO K
               SUBTRACT 1 FROM WS-STACK-SIZE
               PERFORM GROW-NODE
           END-PERFORM
           MOVE WS-LEAVES TO WS-COUNT-OUT
           DISPLAY "leaves " FUNCTION TRIM(WS-COUNT-OUT)

           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               PERFORM PREDICT
               IF WS-PREDICTED = WS-RAIN(I)
                   IF WS-IS-TRAIN(I) = "Y"
                       ADD 1 TO WS-TRAIN-CORRECT
                   ELSE
                       ADD 1 TO WS-CORRECT
                   END-IF
               END-IF
           END-PERFORM
           COMPUTE WS-ACCURACY ROUNDED =
               WS-TRAIN-CORRECT / WS-TRAIN-COUNT
           COMPUTE WS-DECIMAL-OUT ROUNDED = WS-ACCURACY
           DISPLAY "train_accuracy " FUNCTION TRIM(WS-DECIMAL-OUT)
           MOVE WS-CORRECT TO WS-COUNT-OUT
           DISPLAY "correct " FUNCTION TRIM(WS-COUNT-OUT)
           COMPUTE WS-ACCURACY ROUNDED = WS-CORRECT / WS-TEST-COUNT
           COMPUTE WS-DECIMAL-OUT ROUNDED = WS-ACCURACY
           DISPLAY "accuracy " FUNCTION TRIM(WS-DECIMAL-OUT)

           STOP RUN.

      *> Split DATA-LINE on commas into WS-FIELD(1), WS-FIELD(2), ...
       SPLIT-LINE.
           MOVE SPACES TO WS-FIELDS
           MOVE 0 TO WS-FIELD-COUNT
           MOVE 1 TO WS-POINTER
           MOVE FUNCTION LENGTH(FUNCTION TRIM(DATA-LINE TRAILING))
               TO WS-LINE-LENGTH
           PERFORM UNTIL WS-POINTER > WS-LINE-LENGTH
               ADD 1 TO WS-FIELD-COUNT
               UNSTRING DATA-LINE DELIMITED BY ","
                   INTO WS-FIELD(WS-FIELD-COUNT)
                   WITH POINTER WS-POINTER
               END-UNSTRING
           END-PERFORM.

      *> Store one data line as a day, marked train or test.
       ADD-DAY.
           PERFORM SPLIT-LINE
           ADD 1 TO WS-DAY-COUNT
           PERFORM VARYING F FROM 1 BY 1 UNTIL F > 3
               COMPUTE WS-X(WS-DAY-COUNT, F) =
                   FUNCTION NUMVAL(WS-FIELD(WS-FEATURE-COLUMN(F)))
           END-PERFORM
           MOVE WS-FIELD(WS-LABEL-COLUMN) TO WS-RAIN(WS-DAY-COUNT)
           IF WS-FIELD(WS-SPLIT-COLUMN) = "train"
               MOVE "Y" TO WS-IS-TRAIN(WS-DAY-COUNT)
               ADD 1 TO WS-TRAIN-COUNT
           ELSE
               MOVE "N" TO WS-IS-TRAIN(WS-DAY-COUNT)
               ADD 1 TO WS-TEST-COUNT
           END-IF.

      *> Decide node K: make it a leaf, or split it and push its two
      *> children, right first so the left one is popped next.
       GROW-NODE.
           ADD 1 TO WS-PREORDER
           MOVE 0 TO WS-N WS-YES
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               IF WS-IS-TRAIN(I) = "Y" AND WS-DAY-NODE(I) = K
                   ADD 1 TO WS-N
                   IF WS-RAIN(I) = "yes"
                       ADD 1 TO WS-YES
                   END-IF
               END-IF
           END-PERFORM
           COMPUTE WS-P ROUNDED = WS-YES / WS-N
           COMPUTE WS-GINI ROUNDED = 1 - WS-P ** 2 - (1 - WS-P) ** 2

           MOVE 9 TO WS-BEST-SCORE
           IF WS-GINI > 0 AND WS-N >= WS-MIN-SIZE
                   AND WS-NODE-DEPTH(K) < WS-MAX-DEPTH
               PERFORM BEST-SPLIT
           END-IF
      *>   No split was tried, or none makes the sides purer.
           IF WS-BEST-SCORE >= WS-GINI
               ADD 1 TO WS-LEAVES
      *>       An even split predicts "no".
               IF WS-YES * 2 > WS-N
                   MOVE "yes" TO WS-NODE-PREDICT(K)
               ELSE
                   MOVE "no" TO WS-NODE-PREDICT(K)
               END-IF
               MOVE WS-PREORDER TO WS-COUNT-OUT
               DISPLAY "node_" FUNCTION TRIM(WS-COUNT-OUT) "_predict "
                   FUNCTION TRIM(WS-NODE-PREDICT(K))
               EXIT PARAGRAPH
           END-IF

           MOVE WS-BEST-FEATURE TO WS-NODE-FEATURE(K)
           MOVE WS-BEST-THRESHOLD TO WS-NODE-THRESHOLD(K)
           MOVE WS-PREORDER TO WS-COUNT-OUT
           DISPLAY "node_" FUNCTION TRIM(WS-COUNT-OUT) "_feature "
               FUNCTION TRIM(WS-FEATURE-NAME(WS-BEST-FEATURE))
           MOVE WS-BEST-THRESHOLD TO WS-DECIMAL-OUT
           DISPLAY "node_" FUNCTION TRIM(WS-COUNT-OUT) "_threshold "
               FUNCTION TRIM(WS-DECIMAL-OUT)

           COMPUTE WS-NODE-LEFT(K) = WS-NODE-COUNT + 1
           COMPUTE WS-NODE-RIGHT(K) = WS-NODE-COUNT + 2
           ADD 2 TO WS-NODE-COUNT
           COMPUTE WS-NODE-DEPTH(WS-NODE-LEFT(K)) = WS-NODE-DEPTH(K) + 1
           COMPUTE WS-NODE-DEPTH(WS-NODE-RIGHT(K)) =
               WS-NODE-DEPTH(K) + 1
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               IF WS-IS-TRAIN(I) = "Y" AND WS-DAY-NODE(I) = K
                   IF WS-X(I, WS-BEST-FEATURE) <= WS-BEST-THRESHOLD
                       MOVE WS-NODE-LEFT(K) TO WS-DAY-NODE(I)
                   ELSE
                       MOVE WS-NODE-RIGHT(K) TO WS-DAY-NODE(I)
                   END-IF
               END-IF
           END-PERFORM
           ADD 1 TO WS-STACK-SIZE
           MOVE WS-NODE-RIGHT(K) TO WS-STACK-NODE(WS-STACK-SIZE)
           ADD 1 TO WS-STACK-SIZE
           MOVE WS-NODE-LEFT(K) TO WS-STACK-NODE(WS-STACK-SIZE).

      *> The lowest weighted Gini impurity over every feature and
      *> threshold for node K. Features go in column order, thresholds
      *> from smallest to largest, and only a strictly lower score
      *> replaces the best so far.
       BEST-SPLIT.
           PERFORM VARYING F FROM 1 BY 1 UNTIL F > 3
      *>       Start at the node's smallest value, then step through
      *>       the distinct values in order without sorting them.
               MOVE 999999 TO WS-LOW
               PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
                   IF WS-IS-TRAIN(I) = "Y" AND WS-DAY-NODE(I) = K
                           AND WS-X(I, F) < WS-LOW
                       MOVE WS-X(I, F) TO WS-LOW
                   END-IF
               END-PERFORM
               MOVE "Y" TO WS-HAS-NEXT
               PERFORM UNTIL WS-HAS-NEXT = "N"
                   PERFORM FIND-NEXT-VALUE
                   IF WS-HAS-NEXT = "Y"
                       COMPUTE WS-THRESHOLD = (WS-LOW + WS-NEXT) / 2
                       PERFORM SCORE-SPLIT
                       IF WS-SCORE < WS-BEST-SCORE
                           MOVE WS-SCORE TO WS-BEST-SCORE
                           MOVE F TO WS-BEST-FEATURE
                           MOVE WS-THRESHOLD TO WS-BEST-THRESHOLD
                       END-IF
                       MOVE WS-NEXT TO WS-LOW
                   END-IF
               END-PERFORM
           END-PERFORM.

      *> The smallest value of feature F in node K above WS-LOW, into
      *> WS-NEXT, or WS-HAS-NEXT = "N" if there is none.
       FIND-NEXT-VALUE.
           MOVE "N" TO WS-HAS-NEXT
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               IF WS-IS-TRAIN(I) = "Y" AND WS-DAY-NODE(I) = K
                       AND WS-X(I, F) > WS-LOW
                   IF WS-HAS-NEXT = "N" OR WS-X(I, F) < WS-NEXT
                       MOVE WS-X(I, F) TO WS-NEXT
                       MOVE "Y" TO WS-HAS-NEXT
                   END-IF
               END-IF
           END-PERFORM.

      *> (nL * giniL + nR * giniR) / n for feature F at WS-THRESHOLD,
      *> into WS-SCORE. Left is value <= threshold.
       SCORE-SPLIT.
           MOVE 0 TO WS-N-LEFT WS-YES-LEFT
           PERFORM VARYING I FROM 1 BY 1 UNTIL I > WS-DAY-COUNT
               IF WS-IS-TRAIN(I) = "Y" AND WS-DAY-NODE(I) = K
                       AND WS-X(I, F) <= WS-THRESHOLD
                   ADD 1 TO WS-N-LEFT
                   IF WS-RAIN(I) = "yes"
                       ADD 1 TO WS-YES-LEFT
                   END-IF
               END-IF
           END-PERFORM
      *>   Both sides hold at least one day, since the threshold lies
      *>   between two values present in the node.
           COMPUTE WS-P ROUNDED = WS-YES-LEFT / WS-N-LEFT
           COMPUTE WS-GINI-LEFT ROUNDED =
               1 - WS-P ** 2 - (1 - WS-P) ** 2
           COMPUTE WS-P ROUNDED =
               (WS-YES - WS-YES-LEFT) / (WS-N - WS-N-LEFT)
           COMPUTE WS-GINI-RIGHT ROUNDED =
               1 - WS-P ** 2 - (1 - WS-P) ** 2
           COMPUTE WS-SCORE ROUNDED =
               (WS-N-LEFT * WS-GINI-LEFT
                + (WS-N - WS-N-LEFT) * WS-GINI-RIGHT) / WS-N.

      *> Walk day I down from the root to a leaf, into WS-PREDICTED.
       PREDICT.
           MOVE 1 TO K
           PERFORM UNTIL WS-NODE-FEATURE(K) = 0
               IF WS-X(I, WS-NODE-FEATURE(K)) <= WS-NODE-THRESHOLD(K)
                   MOVE WS-NODE-LEFT(K) TO K
               ELSE
                   MOVE WS-NODE-RIGHT(K) TO K
               END-IF
           END-PERFORM
           MOVE WS-NODE-PREDICT(K) TO WS-PREDICTED.

GnuCOBOL lets a paragraph PERFORM itself, but there are no local variables: when we tried it, every level of the recursion saw the same counter. So the tree grows from an explicit stack of node numbers. We pop a node, print it, and push its right child before its left, which visits the nodes in preorder. Instead of copying subsets, each training day records the node it sits in, WS-DAY-NODE.

Thresholds come without a sort: FIND-NEXT-VALUE scans for the smallest value above the current one, stepping through the distinct values in order. COBOL has no decision tree to avoid. At 360 lines, the program is about three and a half times the 106-line Python.

Common Lisp

From scratchTested automaticallySBCL 2.5.2
main.lispView on GitHub
;;;; A decision tree classifier (CART with Gini impurity), using only
;;;; standard Common Lisp.
;;;;
;;;; Grows a tree on the training days by always making the split that leaves
;;;; the two sides purest, then predicts rain for the test days. Prints the
;;;; tree in preorder: each node is either a split (feature and threshold) or
;;;; a leaf (its prediction).

;; Not *features*: that name belongs to Common Lisp, which keeps its list of
;; implementation features there.
(defparameter *feature-names* '("humidity" "pressure" "wind"))
(defparameter *max-depth* 3)
(defparameter *min-size* 5)

;; Read 1000.95 as a double, not a single float with about 7 digits.
;; eval-when makes the setting apply while the compiler reads this file, too,
;; not only once the program runs.
(eval-when (:compile-toplevel :load-toplevel :execute)
  (setf *read-default-float-format* 'double-float))

;;; The tree is a plain nested list, the same shape as Lisp code. A leaf is
;;; (:leaf rain), with rain t or nil, and a split is
;;; (:split feature threshold left right), where feature is a position in
;;; *feature-names* and left and right are smaller trees. Lists can hold
;;; lists, so a tree needs no type of its own, and you could print one with
;;; ~s and read it back. ecase picks the branch for a node's first item, the
;;; keyword, and destructuring-bind names the rest.

(defun split-on-comma (line)
  "The fields of LINE, split at every comma."
  (loop for start = 0 then (1+ end)
        for end = (position #\, line :start start)
        collect (subseq line start end)
        while end))

(defun parse-number (text)
  "The number written in TEXT, read by the Lisp reader. Binding *read-eval*
to nil stops the reader from running code written as #.(...)."
  (let ((*read-eval* nil))
    (coerce (read-from-string text) 'double-float)))

(defun read-days (path)
  "The training days and the test days, as two values. Each day is a cons:
the list of feature values, in the order of *feature-names*, and t if it
rained."
  (with-open-file (in path)
    (let ((header (split-on-comma (read-line in)))
          (train '())
          (test '()))
      (flet ((at (name)
               (or (position name header :test #'string=)
                   (error "no column ~a" name))))
        (loop for line = (read-line in nil)
              while line
              unless (string= line "")
                do (let* ((fields (split-on-comma line))
                          (day (cons (mapcar (lambda (name)
                                               (parse-number (nth (at name) fields)))
                                             *feature-names*)
                                     (string= (nth (at "rain") fields) "yes"))))
                     (if (string= (nth (at "split") fields) "train")
                         (push day train)
                         (push day test)))))
      ;; push adds to the front, so reverse restores the file's order.
      (values (reverse train) (reverse test)))))

(defun value (day f)
  "Feature F of DAY."
  (nth f (car day)))

(defun gini (days)
  "Chance that two days drawn at random (with replacement) disagree."
  (let ((p (/ (count-if #'cdr days) (length days))))
    (- 1 (* p p) (* (- 1 p) (- 1 p)))))

(defun majority (days)
  "T when strictly more than half the days rained, so an even split is nil."
  (> (* 2 (count-if #'cdr days)) (length days)))

(defun divide (days f threshold)
  "The days at or below THRESHOLD on feature F, then the rest, as two values."
  (values (remove-if-not (lambda (day) (<= (value day f) threshold)) days)
          (remove-if-not (lambda (day) (> (value day f) threshold)) days)))

(defun best-split (days)
  "The lowest weighted Gini impurity, with its feature and threshold, as
three values, or nil when every feature has a single value. Features go in
column order and thresholds from smallest to largest; only a strictly lower
score replaces the best so far, so the first split tried wins ties."
  (let ((best-score nil) best-f best-threshold)
    (dotimes (f (length *feature-names*))
      ;; remove-duplicates returns a fresh list, which sort may take apart.
      (let ((sorted (sort (remove-duplicates
                           (mapcar (lambda (day) (value day f)) days))
                          #'<)))
        ;; on walks the list's tails, so a and b are neighbors.
        (loop for (a b) on sorted
              while b
              do (let ((threshold (/ (+ a b) 2)))
                   (multiple-value-bind (left right) (divide days f threshold)
                     (let ((score (/ (+ (* (length left) (gini left))
                                        (* (length right) (gini right)))
                                     (length days))))
                       (when (or (null best-score) (< score best-score))
                         (setf best-score score
                               best-f f
                               best-threshold threshold))))))))
    (values best-score best-f best-threshold)))

(defun grow (days &optional (depth 0))
  "The tree for DAYS. A node becomes a leaf when it is pure, has fewer than
*min-size* days, reaches *max-depth*, or no split makes the sides purer."
  (let ((leaf (list :leaf (majority days)))
        (impurity (gini days)))
    (if (or (zerop impurity) (< (length days) *min-size*) (= depth *max-depth*))
        leaf
        (multiple-value-bind (score f threshold) (best-split days)
          (if (or (null score) (>= score impurity))
              leaf
              (multiple-value-bind (left right) (divide days f threshold)
                (list :split f threshold
                      (grow left (1+ depth))
                      (grow right (1+ depth)))))))))

(defun predict (tree x)
  "Follow the splits down to a leaf for the feature values X."
  (ecase (first tree)
    (:leaf (second tree))
    (:split (destructuring-bind (f threshold left right) (rest tree)
              (predict (if (<= (nth f x) threshold) left right) x)))))

(defun preorder (tree)
  "Every node in preorder: the node itself, then its left side, then its right."
  (ecase (first tree)
    (:leaf (list tree))
    (:split (destructuring-bind (f threshold left right) (rest tree)
              (declare (ignore f threshold))
              (cons tree (append (preorder left) (preorder right)))))))

(defun count-correct (tree days)
  "How many DAYS the tree labels correctly."
  (count-if (lambda (day) (eq (predict tree (car day)) (cdr day))) days))

;; With --script, the data path is the second item of *posix-argv*.
(multiple-value-bind (train test) (read-days (second sb-ext:*posix-argv*))
  (let* ((tree (grow train))
         (nodes (preorder tree))
         (correct (count-correct tree test)))
    ;; ~d prints an integer, ~a a string, ~,6f six decimals, ~% a newline.
    (format t "train_count ~d~%" (length train))
    (format t "test_count ~d~%" (length test))
    (loop for node in nodes
          for k from 1
          do (ecase (first node)
               (:leaf (format t "node_~d_predict ~a~%" k (if (second node) "yes" "no")))
               (:split (format t "node_~d_feature ~a~%"
                               k (nth (second node) *feature-names*))
                       (format t "node_~d_threshold ~,6f~%" k (third node)))))
    (format t "leaves ~d~%" (count :leaf nodes :key #'first))
    (format t "train_accuracy ~,6f~%" (/ (count-correct tree train) (length train)))
    (format t "correct ~d~%" correct)
    (format t "accuracy ~,6f~%" (/ correct (length test)))))

The tree is a plain nested list: a leaf is (:leaf t) or (:leaf nil), and a split is (:split feature threshold left right). predict takes a node apart with ecase and destructuring-bind. Because the tree is ordinary list data, you can print it with ~s, read it back, and equal calls the two the same. grow calls itself on each side, and best-split loops over each feature’s midpoints, keeping only a strictly lower score, so the first split tried wins ties. The standard has no tree to avoid. Watch the names, though: *features* belongs to Common Lisp, and redefining it stops the compiler with a package lock error.

Elixir

From scratchTested automaticallyElixir 1.18.3
main.exsView on GitHub
# A decision tree classifier (CART with Gini impurity), using only Elixir's
# standard library.
#
# Grows a tree on the training days by always making the split that leaves
# the two sides purest, then predicts rain for the test days. Prints the tree
# in preorder: each node is either a split (feature and threshold) or a leaf
# (its prediction).
#
# A tree is a tagged tuple: {:leaf, label} or
# {:split, feature, threshold, left, right}. Atoms such as :leaf are named
# constants, and each function below has one clause per shape.

defmodule Tree do
  @features ["humidity", "pressure", "wind"]
  @max_depth 3
  @min_size 5

  def features, do: @features

  # Each day is {values, label}, with values in the order of @features.
  # Returns {train, test}.
  def read(path) do
    [header | rows] = path |> File.read!() |> String.split("\n", trim: true)
    names = String.split(header, ",")
    col = fn name -> Enum.find_index(names, &(&1 == name)) end

    rows
    |> Enum.map(fn row ->
      fields = String.split(row, ",")
      # Float.parse returns {number, rest}; "54" parses as 54.0 too.
      values =
        Enum.map(@features, fn name ->
          {value, ""} = fields |> Enum.at(col.(name)) |> Float.parse()
          value
        end)

      {Enum.at(fields, col.("split")), {values, Enum.at(fields, col.("rain"))}}
    end)
    # split_with keeps the rows that pass in the first list, the rest in the
    # second, so this is {train, test}.
    |> Enum.split_with(fn {split, _day} -> split == "train" end)
    |> then(fn {train, test} -> {Enum.map(train, &elem(&1, 1)), Enum.map(test, &elem(&1, 1))} end)
  end

  defp count_yes(days), do: Enum.count(days, fn {_, label} -> label == "yes" end)

  # Chance that two days drawn at random (with replacement) disagree.
  defp gini(days) do
    p = count_yes(days) / length(days)
    1 - p * p - (1 - p) * (1 - p)
  end

  # Strictly more than half must be rainy, so an even split predicts "no".
  defp majority(days), do: if(2 * count_yes(days) > length(days), do: "yes", else: "no")

  # The days at or below the threshold, then the days above it.
  defp divide(days, f, threshold),
    do: Enum.split_with(days, fn {x, _} -> Enum.at(x, f) <= threshold end)

  # The weighted Gini impurity left after this split.
  defp score(days, f, threshold) do
    {left, right} = divide(days, f, threshold)
    (length(left) * gini(left) + length(right) * gini(right)) / length(days)
  end

  # Every candidate: features in column order and, for each, the midpoints
  # between neighboring distinct values, from smallest to largest.
  # chunk_every(2, 1, :discard) gives each neighboring pair [a, b].
  # The for comprehension loops over both generators, like nested loops.
  defp candidates(days) do
    for f <- 0..(length(@features) - 1), [a, b] <- neighbors(days, f), do: {f, (a + b) / 2}
  end

  defp neighbors(days, f) do
    days
    |> Enum.map(fn {x, _} -> Enum.at(x, f) end)
    |> Enum.sort()
    |> Enum.dedup()
    |> Enum.chunk_every(2, 1, :discard)
  end

  # {score, feature, threshold} with the lowest score, or nil when every
  # feature has a single value. min_by keeps the first of equal scores, so
  # the first split tried wins ties.
  defp best_split(days) do
    days
    |> candidates()
    |> Enum.map(fn {f, t} -> {score(days, f, t), f, t} end)
    |> Enum.min_by(&elem(&1, 0), fn -> nil end)
  end

  # A node becomes a leaf when it is pure, has fewer than @min_size days,
  # reaches @max_depth, or no split makes the sides purer. == compares
  # numbers by value, so 0.0 == 0 is true.
  def grow(days, depth \\ 0) do
    impurity = gini(days)

    if impurity == 0 or length(days) < @min_size or depth == @max_depth do
      {:leaf, majority(days)}
    else
      case best_split(days) do
        {s, f, t} when s < impurity ->
          {left, right} = divide(days, f, t)
          {:split, f, t, grow(left, depth + 1), grow(right, depth + 1)}

        _ ->
          {:leaf, majority(days)}
      end
    end
  end

  # Follow the splits down to a leaf.
  def predict({:leaf, label}, _x), do: label

  def predict({:split, f, t, left, right}, x),
    do: predict(if(Enum.at(x, f) <= t, do: left, else: right), x)

  # Every node in preorder: the node itself, then its left side, then its
  # right. ++ joins two lists.
  def preorder({:leaf, _} = node), do: [node]
  def preorder({:split, _, _, left, right} = node), do: [node | preorder(left) ++ preorder(right)]

  def correct(tree, days), do: Enum.count(days, fn {x, label} -> predict(tree, x) == label end)
end

six = fn value -> :erlang.float_to_binary(value, decimals: 6) end

[path] = System.argv()
{train, test} = Tree.read(path)
tree = Tree.grow(train)
nodes = Tree.preorder(tree)

IO.puts("train_count #{length(train)}")
IO.puts("test_count #{length(test)}")

# with_index numbers the nodes, here from 1.
nodes
|> Enum.with_index(1)
|> Enum.each(fn
  {{:leaf, label}, k} ->
    IO.puts("node_#{k}_predict #{label}")

  {{:split, f, t, _, _}, k} ->
    IO.puts("node_#{k}_feature #{Enum.at(Tree.features(), f)}")
    IO.puts("node_#{k}_threshold #{six.(t)}")
end)

IO.puts("leaves #{Enum.count(nodes, &match?({:leaf, _}, &1))}")
correct = Tree.correct(tree, test)
IO.puts("train_accuracy #{six.(Tree.correct(tree, train) / length(train))}")
IO.puts("correct #{correct}")
IO.puts("accuracy #{six.(correct / length(test))}")

Elixir’s standard library has no decision tree, and no Hex package is installed, so we write out the split search, the growing, and the predicting. The tree is a tagged tuple, {:leaf, "yes"} or {:split, feature, threshold, left, right}, and grow calls itself on each side. predict and preorder have one function clause per shape. Leave one out and Elixir compiles without a warning, then raises a FunctionClauseError when it meets that shape. A for comprehension lists every feature and midpoint, and Enum.min_by keeps the first of equal scores, so the first split tried wins ties. The purity test is impurity == 0, since matching the pattern 0.0 draws a warning in OTP 27, which fails the build here.

Fortran

From scratchTested automaticallyGNU Fortran 14.2
main.f90View on GitHub
! A decision tree classifier (CART with Gini impurity), using only standard
! Fortran.
!
! Grows a tree on the training days by always making the split that leaves the
! two sides purest, then predicts rain for the test days. Prints the tree in
! preorder: each node is either a split (feature and threshold) or a leaf
! (its prediction).

program decision_tree
  implicit none
  integer, parameter :: dp = kind(1.0d0)   ! double precision
  integer, parameter :: n_features = 3, max_depth = 3, min_size = 5
  integer, parameter :: max_nodes = 2**(max_depth + 1) - 1   ! a full tree
  character(len=*), parameter :: features(n_features) = &
    [character(len=8) :: "humidity", "pressure", "wind"]

  ! The tree is a table of nodes, filled in the order grow creates them,
  ! which is preorder. A leaf has feature 0; a split has its two children.
  type node
    integer :: feature = 0
    real(dp) :: threshold = 0
    integer :: left = 0, right = 0
    logical :: rain = .false.   ! a leaf's prediction
  end type node
  type(node) :: tree(max_nodes)
  integer :: nodes = 0

  real(dp), allocatable :: x(:, :), x_test(:, :)   ! one row per day
  logical, allocatable :: rain(:), rain_test(:)
  integer :: k, i, root, leaves, train_correct, correct

  call read_days(x, rain, x_test, rain_test)
  ! The training days are passed around as a list of row numbers.
  root = grow([(i, i = 1, size(rain))], 0)   ! [(i, i = 1, n)] is 1, 2, ..., n

  print "(a, 1x, i0)", "train_count", size(rain)
  print "(a, 1x, i0)", "test_count", size(rain_test)
  leaves = 0
  do k = 1, nodes
    if (tree(k)%feature == 0) then
      ! merge needs equal-length strings, so "no " has a space that trim drops.
      print "(a, i0, a, 1x, a)", "node_", k, "_predict", &
        trim(merge("yes", "no ", tree(k)%rain))
      leaves = leaves + 1
    else
      print "(a, i0, a, 1x, a)", "node_", k, "_feature", &
        trim(features(tree(k)%feature))
      call say_node(k, tree(k)%threshold)
    end if
  end do
  print "(a, 1x, i0)", "leaves", leaves

  train_correct = count([(predict(x(i, :)) .eqv. rain(i), i = 1, size(rain))])
  correct = count([(predict(x_test(i, :)) .eqv. rain_test(i), &
                    i = 1, size(rain_test))])
  call say("train_accuracy", real(train_correct, dp) / size(rain))
  print "(a, 1x, i0)", "correct", correct
  call say("accuracy", real(correct, dp) / size(rain_test))

contains

  ! Chance that two days drawn at random (with replacement) disagree.
  real(dp) function gini(rows)
    integer, intent(in) :: rows(:)
    real(dp) :: p

    p = real(count(rain(rows)), dp) / size(rows)   ! rain(rows) picks those days
    gini = 1 - p * p - (1 - p) * (1 - p)
  end function gini

  ! An even split predicts "no".
  logical function majority(rows)
    integer, intent(in) :: rows(:)
    majority = count(rain(rows)) > size(rows) / 2.0_dp
  end function majority

  ! The split with the lowest weighted Gini impurity. Features are tried in
  ! column order and thresholds from smallest to largest; only a strictly
  ! lower score replaces the best, so the first of any tie wins.
  subroutine best_split(rows, best_score, best_feature, best_threshold)
    integer, intent(in) :: rows(:)
    real(dp), intent(out) :: best_score, best_threshold
    integer, intent(out) :: best_feature
    real(dp), allocatable :: values(:)
    real(dp) :: threshold, score
    integer :: f, i

    best_score = huge(1.0_dp)   ! the largest double, so any split beats it
    best_feature = 0
    best_threshold = 0
    do f = 1, n_features
      values = sorted_distinct(x(rows, f))
      do i = 1, size(values) - 1
        threshold = (values(i) + values(i + 1)) / 2
        ! pack keeps the rows where the condition holds.
        associate (left => pack(rows, x(rows, f) <= threshold), &
                   right => pack(rows, x(rows, f) > threshold))
          score = (size(left) * gini(left) + size(right) * gini(right)) &
                  / size(rows)
        end associate
        if (score < best_score) then
          best_score = score
          best_feature = f
          best_threshold = threshold
        end if
      end do
    end do
  end subroutine best_split

  ! Add a node for these rows, then its children, and return its number.
  ! Numbering a node before its children is what makes the table preorder.
  recursive integer function grow(rows, depth) result(k)
    integer, intent(in) :: rows(:), depth
    real(dp) :: impurity, score, threshold
    integer :: f, left, right

    nodes = nodes + 1
    k = nodes
    tree(k)%rain = majority(rows)
    impurity = gini(rows)
    if (impurity == 0 .or. size(rows) < min_size .or. depth == max_depth) return
    call best_split(rows, score, f, threshold)
    if (score >= impurity) return   ! no split makes the sides purer
    tree(k)%feature = f
    tree(k)%threshold = threshold
    ! Call grow in two statements so the left subtree is numbered first.
    left = grow(pack(rows, x(rows, f) <= threshold), depth + 1)
    right = grow(pack(rows, x(rows, f) > threshold), depth + 1)
    tree(k)%left = left
    tree(k)%right = right
  end function grow

  ! Walk from the root (node 1) down to a leaf.
  logical function predict(day)
    real(dp), intent(in) :: day(:)
    integer :: k

    k = root
    do while (tree(k)%feature /= 0)
      if (day(tree(k)%feature) <= tree(k)%threshold) then
        k = tree(k)%left
      else
        k = tree(k)%right
      end if
    end do
    predict = tree(k)%rain
  end function predict

  ! The distinct values of a column, smallest first (an insertion sort that
  ! skips repeats).
  function sorted_distinct(column) result(values)
    real(dp), intent(in) :: column(:)
    real(dp), allocatable :: values(:)
    integer :: i, j

    allocate(values(0))
    do i = 1, size(column)
      if (any(values == column(i))) cycle
      j = count(values < column(i))   ! how many go before it
      values = [values(:j), column(i), values(j + 1:)]
    end do
  end function sorted_distinct

  ! Read the CSV into training and test days, sorted by the "split" column.
  subroutine read_days(x, rain, x_test, rain_test)
    real(dp), allocatable, intent(out) :: x(:, :), x_test(:, :)
    logical, allocatable, intent(out) :: rain(:), rain_test(:)
    character(len=256) :: path, header, line, text
    integer :: unit, status, cols(n_features), c_rain, c_split, j
    real(dp) :: day(n_features)

    call get_command_argument(1, path)
    open(newunit=unit, file=path, status="old", action="read")
    read(unit, "(a)") header
    do j = 1, n_features
      cols(j) = field_index(header, trim(features(j)))
    end do
    c_rain = field_index(header, "rain")
    c_split = field_index(header, "split")
    allocate(x(0, n_features), rain(0), x_test(0, n_features), rain_test(0))
    do
      read(unit, "(a)", iostat=status) line
      if (status /= 0) exit
      do j = 1, n_features
        text = field(line, cols(j))
        read(text, *) day(j)   ! an internal read parses the number
      end do
      if (field(line, c_split) == "train") then
        call append(x, rain, day, field(line, c_rain) == "yes")
      else
        call append(x_test, rain_test, day, field(line, c_rain) == "yes")
      end if
    end do
    close(unit)
  end subroutine read_days

  ! Add one day to a matrix and its label to a vector. Fortran stores
  ! matrices column by column, so we rebuild the matrix one row longer.
  subroutine append(x, rain, day, is_rain)
    real(dp), allocatable, intent(inout) :: x(:, :)
    logical, allocatable, intent(inout) :: rain(:)
    real(dp), intent(in) :: day(:)
    logical, intent(in) :: is_rain
    real(dp), allocatable :: grown(:, :)

    allocate(grown(size(x, 1) + 1, size(x, 2)))
    grown(:size(x, 1), :) = x
    grown(size(x, 1) + 1, :) = day
    call move_alloc(grown, x)   ! x takes grown's storage
    rain = [rain, is_rain]      ! grows the vector by one
  end subroutine append

  ! The position of a name in a comma-separated header line.
  integer function field_index(header, name)
    character(len=*), intent(in) :: header, name
    integer :: i, fields

    fields = 1
    do i = 1, len_trim(header)
      if (header(i:i) == ",") fields = fields + 1
    end do
    do field_index = 1, fields
      if (field(header, field_index) == name) return
    end do
    error stop "column not found: " // name
  end function field_index

  ! The k-th comma-separated field of a line.
  function field(line, k) result(text)
    character(len=*), intent(in) :: line
    integer, intent(in) :: k
    character(len=:), allocatable :: text
    integer :: start, i, comma

    start = 1
    do i = 1, k - 1
      start = start + index(line(start:), ",")
    end do
    comma = index(line(start:), ",")
    if (comma == 0) then
      text = trim(line(start:))
    else
      text = line(start:start + comma - 2)
    end if
  end function field

  subroutine say_node(k, threshold)
    integer, intent(in) :: k
    real(dp), intent(in) :: threshold
    character(len=24) :: name

    write(name, "(a, i0, a)") "node_", k, "_threshold"
    call say(trim(name), threshold)
  end subroutine say_node

  ! Print "name value" with six decimals. The f0.6 format drops the zero
  ! before the point (0.5 prints as .500000), so we put it back.
  subroutine say(name, x)
    character(len=*), intent(in) :: name
    real(dp), intent(in) :: x
    character(len=40) :: text

    write(text, "(f0.6)") x
    if (text(1:1) == ".") text = "0" // text
    if (text(1:2) == "-.") text = "-0" // text(2:)
    print "(a, 1x, a)", name, trim(text)
  end subroutine say

end program decision_tree

The tree is an array of node values, a derived type with a feature, a threshold, the numbers of its two children, and a prediction. grow takes a node number before it grows the children, so the table is already in preorder and printing it is a plain loop. grow calls itself, and gfortran 14 rejects that unless the function is marked recursive, even with -std=f2018. Rows are passed as lists of row numbers, and pack(rows, x(rows, f) <= threshold) keeps the ones that go left. Logicals compare with .eqv., since == on them is an error. Fortran has no tree or sort intrinsic, so the sort of distinct values is written out.

Go

From scratchTested automaticallyGo 1.27.3
main.goView on GitHub
// A decision tree classifier (CART with Gini impurity), using only the
// standard library.
//
// Grows a tree on the training days by always making the split that leaves
// the two sides purest, then predicts rain for the test days. Prints the tree
// in preorder: each node is either a split (feature and threshold) or a leaf
// (its prediction).
package main

import (
	"encoding/csv"
	"fmt"
	"os"
	"slices"
	"strconv"
)

var features = []string{"humidity", "pressure", "wind"}

const (
	maxDepth = 3
	minSize  = 5
)

type day struct {
	x    []float64
	rain string
}

// One struct serves for both kinds of node. A split has two children; a leaf
// has nil children and a label.
type node struct {
	feature     int
	threshold   float64
	left, right *node
	label       string
}

func (n *node) isLeaf() bool { return n.left == nil }

func read(path string) (train, test []day, err error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, nil, err
	}
	defer f.Close()

	records, err := csv.NewReader(f).ReadAll()
	if err != nil {
		return nil, nil, err
	}
	// Map each header name to its column number.
	column := map[string]int{}
	for i, name := range records[0] {
		column[name] = i
	}

	for _, record := range records[1:] {
		d := day{rain: record[column["rain"]]}
		for _, name := range features {
			v, err := strconv.ParseFloat(record[column[name]], 64)
			if err != nil {
				return nil, nil, err
			}
			d.x = append(d.x, v)
		}
		if record[column["split"]] == "train" {
			train = append(train, d)
		} else {
			test = append(test, d)
		}
	}
	return train, test, nil
}

// gini is the chance that two days drawn at random (with replacement) disagree.
func gini(days []day) float64 {
	p := float64(countYes(days)) / float64(len(days))
	return 1 - p*p - (1-p)*(1-p)
}

func countYes(days []day) int {
	yes := 0
	for _, d := range days {
		if d.rain == "yes" {
			yes++
		}
	}
	return yes
}

// majority predicts "yes" only for a strict majority; an even split is "no".
func majority(days []day) string {
	if 2*countYes(days) > len(days) {
		return "yes"
	}
	return "no"
}

// partition splits days into those at or below the threshold and the rest.
func partition(days []day, f int, threshold float64) (left, right []day) {
	for _, d := range days {
		if d.x[f] <= threshold {
			left = append(left, d)
		} else {
			right = append(right, d)
		}
	}
	return left, right
}

// bestSplit returns the split with the lowest weighted Gini impurity. Features
// are tried in column order and thresholds from smallest to largest; only a
// strictly lower score replaces the best so far. found is false if no feature
// has two distinct values.
func bestSplit(days []day) (score float64, feature int, threshold float64, found bool) {
	n := float64(len(days))
	for f := range features {
		values := make([]float64, len(days))
		for i, d := range days {
			values[i] = d.x[f]
		}
		slices.Sort(values)
		values = slices.Compact(values) // drop repeats, leaving the distinct values
		for i := 1; i < len(values); i++ {
			t := (values[i-1] + values[i]) / 2
			left, right := partition(days, f, t)
			s := (float64(len(left))*gini(left) + float64(len(right))*gini(right)) / n
			if !found || s < score {
				score, feature, threshold, found = s, f, t, true
			}
		}
	}
	return score, feature, threshold, found
}

func grow(days []day, depth int) *node {
	g := gini(days)
	if g == 0 || len(days) < minSize || depth == maxDepth {
		return &node{label: majority(days)}
	}
	score, f, t, found := bestSplit(days)
	if !found || score >= g {
		return &node{label: majority(days)} // no split makes the sides purer
	}
	left, right := partition(days, f, t)
	return &node{feature: f, threshold: t, left: grow(left, depth+1), right: grow(right, depth+1)}
}

func predict(n *node, x []float64) string {
	for !n.isLeaf() {
		if x[n.feature] <= n.threshold {
			n = n.left
		} else {
			n = n.right
		}
	}
	return n.label
}

// preorder lists every node, parents before children, left before right.
func preorder(n *node) []*node {
	if n.isLeaf() {
		return []*node{n}
	}
	return append(append([]*node{n}, preorder(n.left)...), preorder(n.right)...)
}

func accuracy(tree *node, days []day) (correct int, share float64) {
	for _, d := range days {
		if predict(tree, d.x) == d.rain {
			correct++
		}
	}
	return correct, float64(correct) / float64(len(days))
}

func main() {
	train, test, err := read(os.Args[1])
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	tree := grow(train, 0)

	fmt.Printf("train_count %d\n", len(train))
	fmt.Printf("test_count %d\n", len(test))
	leaves := 0
	for i, n := range preorder(tree) {
		k := i + 1
		if n.isLeaf() {
			fmt.Printf("node_%d_predict %s\n", k, n.label)
			leaves++
		} else {
			fmt.Printf("node_%d_feature %s\n", k, features[n.feature])
			fmt.Printf("node_%d_threshold %.6f\n", k, n.threshold)
		}
	}
	fmt.Printf("leaves %d\n", leaves)

	_, trainAccuracy := accuracy(tree, train)
	correct, testAccuracy := accuracy(tree, test)
	fmt.Printf("train_accuracy %.6f\n", trainAccuracy)
	fmt.Printf("correct %d\n", correct)
	fmt.Printf("accuracy %.6f\n", testAccuracy)
}

One node struct serves for both kinds of node, and the children are pointers, left, right *node. A leaf is a node whose children are nil; its feature and threshold keep their zero values and are never read. The one-line method isLeaf gives that check a name. To list the distinct values of a feature, we sort with slices.Sort and then call slices.Compact, which removes only repeats that sit next to each other: on the unsorted [3 1 3 2 2] it returns [3 1 3 2]. The only decision tree in Go’s standard library is an unexported one in net/http that matches requests to patterns, so there was no classifier to skip.

Haskell

From scratchTested automaticallyGHC 9.6.6
main.hsView on GitHub
-- A decision tree classifier (CART with Gini impurity), using only Haskell's
-- base library.
--
-- Grows a tree on the training days by always making the split that leaves
-- the two sides purest, then predicts rain for the test days. Prints the tree
-- in preorder: each node is either a split (feature and threshold) or a leaf
-- (its prediction).

import Data.List (elemIndex, minimumBy, nub, partition, sort)
import Data.Ord (comparing)
import System.Environment (getArgs)
import Text.Printf (printf)

features :: [String]
features = ["humidity", "pressure", "wind"]

maxDepth, minSize :: Int
maxDepth = 3
minSize = 5

-- One day: its feature values, in the order of features, and whether it rained.
type Day = ([Double], Bool)

-- A tree is either a leaf holding its prediction or a split holding a feature
-- position, a threshold, and two smaller trees. The type refers to itself,
-- which is all Haskell needs for a recursive data structure.
data Tree = Leaf Bool | Split Int Double Tree Tree

-- Split a line at every comma. break returns the text before the first comma
-- and the rest, which still starts with the comma.
splitOnComma :: String -> [String]
splitOnComma line = case break (== ',') line of
  (field, ',' : rest) -> field : splitOnComma rest
  (field, _) -> [field]

-- The training days and the test days. The header gives each column's position.
readDays :: String -> ([Day], [Day])
readDays text = case lines text of
  header : rows ->
    let names = splitOnComma header
        at name = case elemIndex name names of
          Just index -> index
          Nothing -> error ("no column " ++ name)
        parse row =
          let fields = splitOnComma row
           in ( fields !! at "split" == "train"
              , ([read (fields !! at name) | name <- features], fields !! at "rain" == "yes")
              )
        tagged = [parse row | row <- rows, not (null row)]
        (train, test) = partition fst tagged
     in (map snd train, map snd test)
  [] -> error "empty file"

-- Chance that two days drawn at random (with replacement) disagree.
gini :: [Bool] -> Double
gini labels = 1 - p * p - (1 - p) * (1 - p)
  where
    p = fromIntegral (length (filter id labels)) / fromIntegral (length labels)

-- Strictly more than half must be rainy to predict rain, so an even split
-- predicts "no".
majority :: [Bool] -> Bool
majority labels = 2 * length (filter id labels) > length labels

-- The days at or below the threshold, then the days above it.
divide :: Int -> Double -> [Day] -> ([Day], [Day])
divide f threshold = partition (\(x, _) -> x !! f <= threshold)

-- The weighted Gini impurity left after splitting at this feature and threshold.
score :: [Day] -> Int -> Double -> Double
score days f threshold =
  (size left * gini (map snd left) + size right * gini (map snd right)) / size days
  where
    (left, right) = divide f threshold days
    size = fromIntegral . length

-- Every candidate split: features in column order, and for each, the
-- midpoints between neighboring distinct values, from smallest to largest.
candidates :: [Day] -> [(Int, Double)]
candidates days =
  [ (f, (a + b) / 2)
  | f <- [0 .. length features - 1]
  , let values = sort (nub [x !! f | (x, _) <- days])
  , (a, b) <- zip values (tail values)
  ]

-- The candidate with the lowest score, with that score, or Nothing when every
-- feature has a single value. On a tie minimumBy keeps the earlier candidate,
-- so the first split tried wins.
bestSplit :: [Day] -> Maybe (Double, Int, Double)
bestSplit days = case candidates days of
  [] -> Nothing
  splits -> Just (minimumBy (comparing (\(s, _, _) -> s)) scored)
    where
      scored = [(score days f t, f, t) | (f, t) <- splits]

-- Grow the tree from the root at depth 0. A node becomes a leaf when it is
-- pure, has fewer than minSize days, reaches maxDepth, or no split makes the
-- sides purer. The guards (|) are tried from top to bottom.
grow :: Int -> [Day] -> Tree
grow depth days
  | gini labels == 0 || length days < minSize || depth == maxDepth = leaf
  | otherwise = case bestSplit days of
      Just (s, f, threshold)
        | s < gini labels ->
            let (left, right) = divide f threshold days
             in Split f threshold (grow (depth + 1) left) (grow (depth + 1) right)
      _ -> leaf
  where
    labels = map snd days
    leaf = Leaf (majority labels)

-- Follow the splits down to a leaf. Each equation matches one kind of node.
predict :: Tree -> [Double] -> Bool
predict (Leaf rain) _ = rain
predict (Split f threshold left right) x
  | x !! f <= threshold = predict left x
  | otherwise = predict right x

-- Every node in preorder: the node itself, then its left side, then its right.
preorder :: Tree -> [Tree]
preorder node@(Split _ _ left right) = node : preorder left ++ preorder right
preorder leaf = [leaf]

-- The output lines for one node, given its number.
describe :: Int -> Tree -> [String]
describe k (Leaf rain) = [printf "node_%d_predict %s" k (if rain then "yes" else "no" :: String)]
describe k (Split f threshold _ _) =
  [ printf "node_%d_feature %s" k (features !! f)
  , printf "node_%d_threshold %.6f" k threshold
  ]

isLeaf :: Tree -> Bool
isLeaf (Leaf _) = True
isLeaf _ = False

-- How many days the tree labels correctly.
countCorrect :: Tree -> [Day] -> Int
countCorrect tree days = length [() | (x, rain) <- days, predict tree x == rain]

main :: IO ()
main = do
  [path] <- getArgs
  text <- readFile path
  let (train, test) = readDays text
      tree = grow 0 train
      nodes = preorder tree
      correct = countCorrect tree test
      fraction n days = fromIntegral n / fromIntegral (length days) :: Double
  printf "train_count %d\n" (length train)
  printf "test_count %d\n" (length test)
  -- zip pairs each node with its number, counting from 1.
  mapM_ putStrLn (concat (zipWith describe [1 ..] nodes))
  printf "leaves %d\n" (length (filter isLeaf nodes))
  printf "train_accuracy %.6f\n" (fraction (countCorrect tree train) train)
  printf "correct %d\n" correct
  printf "accuracy %.6f\n" (fraction correct test)

The tree is an algebraic data type, data Tree = Leaf Bool | Split Int Double Tree Tree, which names itself with no boxes or pointers. predict and describe each have one equation per kind of node, so drop the Leaf equation and GHC’s -Wall reports “Pattern match(es) are non-exhaustive”, which -Werror makes fatal. grow calls itself on each side, and its stopping rules are guards tried from top to bottom. For the split search, minimumBy keeps the first of several equal scores, so the first split tried wins ties. To number nodes, preorder flattens the tree into a list and zipWith pairs it with [1 ..]. Base has no decision tree, so there was no built-in to skip.

Java

From scratchTested automaticallyOpenJDK 21
main.javaView on GitHub
// A decision tree classifier (CART with Gini impurity), using only the Java
// standard library.
//
// Grows a tree on the training days by always making the split that leaves
// the two sides purest, then predicts rain for the test days. Prints the tree
// in preorder: each node is either a split (feature and threshold) or a leaf
// (its prediction).

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.TreeSet;

class Main {
    static final String[] FEATURES = {"humidity", "pressure", "wind"};
    static final int MAX_DEPTH = 3;
    static final int MIN_SIZE = 5;

    // A record is a small immutable class: Java writes the constructor, the
    // accessors (day.rain()), equals, and toString for us.
    record Day(double[] x, boolean rain) {}

    // "sealed ... permits" says a Node is a Leaf or a Split and nothing else,
    // so a switch over a Node that covers both needs no default case.
    sealed interface Node permits Leaf, Split {}
    record Leaf(boolean rain) implements Node {}
    record Split(int feature, double threshold, Node left, Node right) implements Node {}

    // A candidate split and its weighted impurity, before any children exist.
    record Candidate(double score, int feature, double threshold) {}

    // The named column of a CSV file, as strings. Reading a file can fail
    // with an IOException, and Java makes us say so with "throws".
    static String[] readColumn(Path path, String name) throws IOException {
        List<String> lines = Files.readAllLines(path);
        int index = Arrays.asList(lines.get(0).split(",")).indexOf(name);
        if (index < 0) {
            throw new IllegalArgumentException("no column " + name);
        }
        String[] values = new String[lines.size() - 1];
        for (int row = 1; row < lines.size(); row++) {
            values[row - 1] = lines.get(row).split(",")[index];
        }
        return values;
    }

    static int countYes(List<Day> days) {
        int yes = 0;
        for (Day day : days) {
            if (day.rain()) {
                yes++;
            }
        }
        return yes;
    }

    // Chance that two days drawn at random (with replacement) disagree.
    static double gini(List<Day> days) {
        double p = (double) countYes(days) / days.size();
        return 1 - p * p - (1 - p) * (1 - p);
    }

    // Rain only on a strict majority; an even split predicts no rain.
    static boolean majority(List<Day> days) {
        return 2 * countYes(days) > days.size();
    }

    // The days at or below the threshold (left) or above it (right).
    static List<Day> side(List<Day> days, int feature, double threshold, boolean left) {
        List<Day> out = new ArrayList<>();
        for (Day day : days) {
            if ((day.x()[feature] <= threshold) == left) {
                out.add(day);
            }
        }
        return out;
    }

    // The candidate with the lowest weighted Gini impurity, or null if no
    // feature has two distinct values. Features are tried in column order and
    // thresholds from smallest to largest; only a strictly lower score
    // replaces the best so far.
    static Candidate bestSplit(List<Day> days) {
        Candidate best = null;
        for (int f = 0; f < FEATURES.length; f++) {
            // A TreeSet keeps its values sorted and drops repeats.
            TreeSet<Double> values = new TreeSet<>();
            for (Day day : days) {
                values.add(day.x()[f]);
            }
            Double previous = null;
            for (double value : values) {
                if (previous != null) {
                    double threshold = (previous + value) / 2;
                    List<Day> left = side(days, f, threshold, true);
                    List<Day> right = side(days, f, threshold, false);
                    double score = (left.size() * gini(left) + right.size() * gini(right))
                        / days.size();
                    if (best == null || score < best.score()) {
                        best = new Candidate(score, f, threshold);
                    }
                }
                previous = value;
            }
        }
        return best;
    }

    static Node grow(List<Day> days, int depth) {
        double impurity = gini(days);
        if (impurity == 0 || days.size() < MIN_SIZE || depth == MAX_DEPTH) {
            return new Leaf(majority(days));
        }
        Candidate best = bestSplit(days);
        if (best == null || best.score() >= impurity) {
            return new Leaf(majority(days)); // no split makes the sides purer
        }
        int f = best.feature();
        double t = best.threshold();
        return new Split(f, t,
            grow(side(days, f, t, true), depth + 1),
            grow(side(days, f, t, false), depth + 1));
    }

    // A switch with record patterns matches the kind of node and pulls out
    // its fields in one step. Leaving out a case is a compile error.
    static boolean predict(Node node, double[] x) {
        return switch (node) {
            case Leaf(boolean rain) -> rain;
            case Split(int f, double t, Node left, Node right) ->
                predict(x[f] <= t ? left : right, x);
        };
    }

    // Every node, parents before children, left subtree before right.
    static void preorder(Node node, List<Node> out) {
        out.add(node);
        if (node instanceof Split split) {
            preorder(split.left(), out);
            preorder(split.right(), out);
        }
    }

    static int correct(Node tree, List<Day> days) {
        int right = 0;
        for (Day day : days) {
            if (predict(tree, day.x()) == day.rain()) {
                right++;
            }
        }
        return right;
    }

    public static void main(String[] args) throws IOException {
        Path path = Path.of(args[0]);
        String[][] columns = new String[FEATURES.length][];
        for (int f = 0; f < FEATURES.length; f++) {
            columns[f] = readColumn(path, FEATURES[f]);
        }
        String[] rain = readColumn(path, "rain");
        String[] split = readColumn(path, "split");

        // Strings are compared with equals, not ==, which compares objects.
        List<Day> train = new ArrayList<>();
        List<Day> test = new ArrayList<>();
        for (int row = 0; row < rain.length; row++) {
            double[] x = new double[FEATURES.length];
            for (int f = 0; f < FEATURES.length; f++) {
                x[f] = Double.parseDouble(columns[f][row]);
            }
            Day day = new Day(x, rain[row].equals("yes"));
            (split[row].equals("train") ? train : test).add(day);
        }
        Node tree = grow(train, 0);

        Locale root = Locale.ROOT;
        System.out.printf(root, "train_count %d%n", train.size());
        System.out.printf(root, "test_count %d%n", test.size());
        List<Node> nodes = new ArrayList<>();
        preorder(tree, nodes);
        int leaves = 0;
        for (int i = 0; i < nodes.size(); i++) {
            int k = i + 1;
            switch (nodes.get(i)) {
                case Leaf leaf -> {
                    System.out.printf(root, "node_%d_predict %s%n", k, leaf.rain() ? "yes" : "no");
                    leaves++;
                }
                case Split s -> {
                    System.out.printf(root, "node_%d_feature %s%n", k, FEATURES[s.feature()]);
                    System.out.printf(root, "node_%d_threshold %.6f%n", k, s.threshold());
                }
            }
        }
        System.out.printf(root, "leaves %d%n", leaves);

        int testCorrect = correct(tree, test);
        System.out.printf(root, "train_accuracy %.6f%n", (double) correct(tree, train) / train.size());
        System.out.printf(root, "correct %d%n", testCorrect);
        System.out.printf(root, "accuracy %.6f%n", (double) testCorrect / test.size());
    }
}

The tree is a sealed interface, Node, that permits exactly two records: Leaf holds a prediction, and Split holds a feature, a threshold, and two children. Records write their own constructor and accessors, and their fields are final. Because Node is sealed, predict can switch on a node with record patterns and no default; drop the Leaf case and javac stops with “the switch expression does not cover all possible input values”. grow calls itself on each side. Each feature’s values go into a TreeSet, which sorts them and drops repeats, and we try the midpoint of each neighboring pair. The JDK has no decision tree, so there was no built-in to skip.

JavaScript

From scratchTested automaticallyNode.js 26.13.0
main.jsView on GitHub
// A decision tree classifier (CART with Gini impurity), using only Node's
// built-in modules. Grows a tree on the training days by always making the
// split that leaves the two sides purest, then predicts rain for the test
// days. Prints the tree in preorder: each node is either a split (feature and
// threshold) or a leaf (its prediction).

import { readFileSync } from "node:fs";

const FEATURES = ["humidity", "pressure", "wind"];
const MAX_DEPTH = 3;
const MIN_SIZE = 5;

// Node has no CSV parser. No field here is quoted, so we split on commas and
// turn each line into an object keyed by the header names.
function readRows(path) {
  const [header, ...lines] = readFileSync(path, "utf8").trim().split("\n");
  const names = header.split(",");
  return lines.map((line) => {
    const fields = line.split(",");
    return Object.fromEntries(names.map((name, i) => [name, fields[i]]));
  });
}

const countYes = (days) => days.filter((day) => day.rain === "yes").length;

// Chance that two days drawn at random (with replacement) disagree.
function gini(days) {
  const p = countYes(days) / days.length;
  return 1 - p * p - (1 - p) * (1 - p);
}

// An even split predicts "no".
const majority = (days) => (countYes(days) > days.length / 2 ? "yes" : "no");

function partition(days, f, threshold) {
  return [days.filter((day) => day.x[f] <= threshold), days.filter((day) => day.x[f] > threshold)];
}

// The split with the lowest weighted Gini impurity. Features are tried in
// column order and thresholds from smallest to largest; only a strictly lower
// score replaces the best so far.
function bestSplit(days) {
  let best = null;
  FEATURES.forEach((_, f) => {
    // A Set drops repeats. The comparator matters: with none, sort() would
    // compare the numbers as strings.
    const values = [...new Set(days.map((day) => day.x[f]))].sort((a, b) => a - b);
    for (let i = 1; i < values.length; i++) {
      const threshold = (values[i - 1] + values[i]) / 2;
      const [left, right] = partition(days, f, threshold);
      const score = (left.length * gini(left) + right.length * gini(right)) / days.length;
      if (best === null || score < best.score) best = { score, feature: f, threshold };
    }
  });
  return best;
}

// A leaf is { label }; a split is { feature, threshold, left, right }. Plain
// objects need no class: the presence of `label` tells the two apart.
function grow(days, depth = 0) {
  const impurity = gini(days);
  if (impurity === 0 || days.length < MIN_SIZE || depth === MAX_DEPTH) {
    return { label: majority(days) };
  }
  const split = bestSplit(days);
  if (split === null || split.score >= impurity) {
    return { label: majority(days) }; // no split makes the sides purer
  }
  const { feature, threshold } = split;
  const [left, right] = partition(days, feature, threshold);
  return { feature, threshold, left: grow(left, depth + 1), right: grow(right, depth + 1) };
}

function predict(node, x) {
  while (!("label" in node)) {
    node = x[node.feature] <= node.threshold ? node.left : node.right;
  }
  return node.label;
}

// Every node, parents before children, left subtree before right. A
// generator function (function*) hands back one node per yield.
function* preorder(node) {
  yield node;
  if (!("label" in node)) {
    yield* preorder(node.left);
    yield* preorder(node.right);
  }
}

const days = readRows(process.argv[2]).map((row) => ({
  x: FEATURES.map((name) => Number(row[name])),
  rain: row.rain,
  split: row.split,
}));
const train = days.filter((day) => day.split === "train");
const test = days.filter((day) => day.split === "test");
const tree = grow(train);

console.log(`train_count ${train.length}`);
console.log(`test_count ${test.length}`);
let k = 0;
let leaves = 0;
for (const node of preorder(tree)) {
  k++;
  if ("label" in node) {
    console.log(`node_${k}_predict ${node.label}`);
    leaves++;
  } else {
    console.log(`node_${k}_feature ${FEATURES[node.feature]}`);
    console.log(`node_${k}_threshold ${node.threshold.toFixed(6)}`);
  }
}
console.log(`leaves ${leaves}`);

const countCorrect = (days) => days.filter((day) => predict(tree, day.x) === day.rain).length;
const correct = countCorrect(test);
console.log(`train_accuracy ${(countCorrect(train) / train.length).toFixed(6)}`);
console.log(`correct ${correct}`);
console.log(`accuracy ${(correct / test.length).toFixed(6)}`);

The tree is plain objects with no class: a leaf is { label } and a split is { feature, threshold, left, right }, so "label" in node tells them apart. Printing walks the tree with a generator, function* preorder, which yields a node and then hands off to each subtree with yield*, and a for...of loop numbers the nodes as they arrive. A Set drops repeated values before sorting. Leave out the comparator and sort() compares the numbers as strings, putting 1013.7 before 999.2. On this data the tree still comes out the same, but pressure would be tried at 1010, the midpoint of two values that aren’t neighbors. None of Node’s built-in modules is a decision tree.

Julia

From scratchTested automaticallyJulia 1.13.3
main.jlView on GitHub
# A decision tree classifier (CART with Gini impurity), using only Base Julia.
#
# Grows a tree on the training days by always making the split that leaves the
# two sides purest, then predicts rain for the test days. Prints the tree in
# preorder: each node is either a split (feature and threshold) or a leaf
# (its prediction).

using Printf

const FEATURES = ["humidity", "pressure", "wind"]
const MAX_DEPTH = 3
const MIN_SIZE = 5

# A node is either a Leaf or a Split; Julia picks the right method of
# predict and show_tree by the node's type (multiple dispatch).
abstract type Node end

struct Leaf <: Node
    label::String
end

struct Split <: Node
    feature::Int
    threshold::Float64
    left::Node
    right::Node
end

struct Day
    x::Vector{Float64}
    rain::String
end

function read_days(path)
    train, test = Day[], Day[]
    for line in Iterators.drop(eachline(path), 1)  # skip the header
        humidity, pressure, wind, rain, split_name = split(line, ",")
        day = Day(parse.(Float64, [humidity, pressure, wind]), rain)
        push!(split_name == "train" ? train : test, day)
    end
    return train, test
end

function gini(days)
    # Chance that two days drawn at random (with replacement) disagree.
    p = count(d -> d.rain == "yes", days) / length(days)
    return 1 - p^2 - (1 - p)^2
end

# An even split predicts "no".
majority(days) = count(d -> d.rain == "yes", days) > length(days) / 2 ? "yes" : "no"

function best_split(days)
    # Features in column order, thresholds from smallest to largest; only a
    # strictly lower score replaces the best, so the first of any tie wins.
    best_score, best_feature, best_threshold = Inf, 0, 0.0
    for f in eachindex(FEATURES)
        values = sort(unique(d.x[f] for d in days))
        for i in 1:length(values)-1
            threshold = (values[i] + values[i+1]) / 2
            left = filter(d -> d.x[f] <= threshold, days)
            right = filter(d -> d.x[f] > threshold, days)
            weighted = length(left) * gini(left) + length(right) * gini(right)
            score = weighted / length(days)
            if score < best_score
                best_score, best_feature, best_threshold = score, f, threshold
            end
        end
    end
    return best_score, best_feature, best_threshold
end

function grow(days, depth = 0)
    impurity = gini(days)
    if impurity == 0 || length(days) < MIN_SIZE || depth == MAX_DEPTH
        return Leaf(majority(days))
    end
    score, f, threshold = best_split(days)
    score >= impurity && return Leaf(majority(days))  # no split makes the sides purer
    left = filter(d -> d.x[f] <= threshold, days)
    right = filter(d -> d.x[f] > threshold, days)
    return Split(f, threshold, grow(left, depth + 1), grow(right, depth + 1))
end

predict(node::Leaf, x) = node.label
function predict(node::Split, x)
    return predict(x[node.feature] <= node.threshold ? node.left : node.right, x)
end

# Print the tree in preorder; k counts nodes and is returned for the caller.
function show_tree(node::Leaf, k)
    println("node_$(k)_predict $(node.label)")
    return k + 1
end

function show_tree(node::Split, k)
    println("node_$(k)_feature $(FEATURES[node.feature])")
    @printf("node_%d_threshold %.6f\n", k, node.threshold)
    return show_tree(node.right, show_tree(node.left, k + 1))
end

count_leaves(node::Leaf) = 1
count_leaves(node::Split) = count_leaves(node.left) + count_leaves(node.right)

function main(path)
    train, test = read_days(path)
    tree = grow(train)

    println("train_count $(length(train))")
    println("test_count $(length(test))")
    show_tree(tree, 1)
    println("leaves $(count_leaves(tree))")

    train_correct = count(d -> predict(tree, d.x) == d.rain, train)
    correct = count(d -> predict(tree, d.x) == d.rain, test)
    @printf("train_accuracy %.6f\n", train_correct / length(train))
    println("correct $correct")
    @printf("accuracy %.6f\n", correct / length(test))
end

main(ARGS[1])

The tree uses Julia’s types. Leaf and Split are both subtypes of an abstract Node, and predict, show_tree, and count_leaves each have one method per type. Julia chooses the method from the node’s type at run time (multiple dispatch), so none of them checks what kind of node it has.

show_tree prints in preorder by passing the node number down and returning the next one: show_tree(node.right, show_tree(node.left, k + 1)). Neither Base nor Statistics has a decision tree, and the DecisionTree.jl package isn’t installed, so there was nothing to skip.

Lua

From scratchTested automaticallyLua 5.4.7
main.luaView on GitHub
-- A decision tree classifier (CART with Gini impurity), using only Lua's
-- standard library.
--
-- Lua has no decision tree, so the split search, the growing, and the
-- predicting are all written out. The tree is made of tables: a leaf is
-- {predict = "yes"}, and a split is
-- {feature = 1, threshold = 63.5, left = NODE, right = NODE}.

local FEATURES = { "humidity", "pressure", "wind" }
local MAX_DEPTH, MIN_SIZE = 3, 5

-- Each day is {x = {humidity, pressure, wind}, label = "yes" or "no"}.
local function read_days(path)
  local f = assert(io.open(path, "r"))
  local col = {}                   -- column name -> position
  local i = 0
  for name in f:read("l"):gmatch("[^,]+") do
    i = i + 1
    col[name] = i
  end
  local train, test = {}, {}
  for line in f:lines() do
    local row = {}
    for field in line:gmatch("[^,]+") do row[#row + 1] = field end
    local x = {}
    for k, name in ipairs(FEATURES) do x[k] = tonumber(row[col[name]]) end
    local days = row[col.split] == "train" and train or test
    days[#days + 1] = { x = x, label = row[col.rain] }
  end
  f:close()
  return train, test
end

local function count_yes(days)
  local yes = 0
  for _, d in ipairs(days) do
    if d.label == "yes" then yes = yes + 1 end
  end
  return yes
end

-- Chance that two days drawn at random (with replacement) disagree.
local function gini(days)
  local p = count_yes(days) / #days
  return 1 - p * p - (1 - p) * (1 - p)
end

-- An even split predicts "no".
local function majority(days)
  return count_yes(days) > #days / 2 and "yes" or "no"
end

-- Splits the days in two on feature f at threshold t.
local function partition(days, f, t)
  local left, right = {}, {}
  for _, d in ipairs(days) do
    local side = d.x[f] <= t and left or right
    side[#side + 1] = d
  end
  return left, right
end

-- The score, feature, and threshold with the lowest weighted Gini impurity,
-- or nil if no feature has two distinct values. Features are tried in column
-- order and thresholds from smallest to largest; only a strictly lower score
-- replaces the best so far.
local function best_split(days)
  local best, bf, bt
  for f = 1, #FEATURES do
    local seen, values = {}, {}    -- a table's keys make a set
    for _, d in ipairs(days) do
      if not seen[d.x[f]] then
        seen[d.x[f]] = true
        values[#values + 1] = d.x[f]
      end
    end
    table.sort(values)
    for i = 2, #values do
      local t = (values[i - 1] + values[i]) / 2
      local left, right = partition(days, f, t)
      local score = (#left * gini(left) + #right * gini(right)) / #days
      if best == nil or score < best then
        best, bf, bt = score, f, t
      end
    end
  end
  return best, bf, bt
end

local function grow(days, depth)
  local impurity = gini(days)
  if impurity == 0 or #days < MIN_SIZE or depth == MAX_DEPTH then
    return { predict = majority(days) }
  end
  local score, f, t = best_split(days)
  if score == nil or score >= impurity then
    return { predict = majority(days) }  -- no split makes the sides purer
  end
  local left, right = partition(days, f, t)
  return { feature = f, threshold = t,
           left = grow(left, depth + 1), right = grow(right, depth + 1) }
end

local function predict(node, x)
  while node.predict == nil do
    node = x[node.feature] <= node.threshold and node.left or node.right
  end
  return node.predict
end

local function say(name, value)
  print(string.format("%s %.6f", name, value))
end

-- Prints each node in preorder, numbering from k. Returns the next number
-- and how many leaves it printed.
local function show(node, k)
  if node.predict then
    print("node_" .. k .. "_predict " .. node.predict)
    return k + 1, 1
  end
  print("node_" .. k .. "_feature " .. FEATURES[node.feature])
  say("node_" .. k .. "_threshold", node.threshold)
  local next_k, left_leaves = show(node.left, k + 1)
  local last_k, right_leaves = show(node.right, next_k)
  return last_k, left_leaves + right_leaves
end

local function count_correct(tree, days)
  local correct = 0
  for _, d in ipairs(days) do
    if predict(tree, d.x) == d.label then correct = correct + 1 end
  end
  return correct
end

local train, test = read_days(arg[1])
local tree = grow(train, 0)

print("train_count " .. #train)
print("test_count " .. #test)
local _, leaves = show(tree, 1)
print("leaves " .. leaves)

local correct = count_correct(tree, test)
say("train_accuracy", count_correct(tree, train) / #train)
print("correct " .. correct)
say("accuracy", correct / #test)

Lua’s only data structure is the table, so we build the tree from tables: a leaf is {predict = "yes"}, and a split is {feature = 1, threshold = 63.5, left = ..., right = ...}, where each side is another table. grow calls itself on each side. A table’s keys also make a set, which the split search uses to collect each feature’s distinct values before table.sort orders them. Lua has no ?:, so x <= t and left or right picks a side; it works because neither side is false or nil. Lua has no decision tree, and no LuaRocks module is installed, so you see every split tried as a plain loop.

OCaml

From scratchTested automaticallyOCaml 5.3.0
main.mlView on GitHub
(* A decision tree classifier (CART with Gini impurity), using only OCaml's
   standard library.

   Grows a tree on the training days by always making the split that leaves
   the two sides purest, then predicts rain for the test days. Prints the tree
   in preorder: each node is either a split (feature and threshold) or a leaf
   (its prediction). *)

let features = [| "humidity"; "pressure"; "wind" |]
let max_depth = 3
let min_size = 5

(* One day: its feature values, in the order of features, and "yes" or "no". *)
type day = { x : float array; label : string }

(* A tree is a leaf holding its prediction or a split holding a feature
   position, a threshold, and two smaller trees. The type names itself, and
   Split carries an inline record, so its fields have names. *)
type tree =
  | Leaf of string
  | Split of { feature : int; threshold : float; left : tree; right : tree }

(* The training days and the test days. The header gives each column's
   position. *)
let read_days path =
  In_channel.with_open_text path (fun ic ->
      match In_channel.input_lines ic with
      | [] -> failwith "empty file"
      | header :: rows ->
          let names = String.split_on_char ',' header in
          let col name =
            match List.find_index (String.equal name) names with
            | Some i -> i
            | None -> failwith ("column not found: " ^ name)
          in
          let parse row =
            let fields = Array.of_list (String.split_on_char ',' row) in
            let x = Array.map (fun name -> float_of_string fields.(col name)) features in
            (fields.(col "split") = "train", { x; label = fields.(col "rain") })
          in
          let tagged = List.map parse (List.filter (fun row -> row <> "") rows) in
          let train, test = List.partition fst tagged in
          (List.map snd train, List.map snd test))

let count_yes days = List.length (List.filter (fun d -> d.label = "yes") days)
let size days = float_of_int (List.length days)

(* Chance that two days drawn at random (with replacement) disagree. *)
let gini days =
  let p = float_of_int (count_yes days) /. size days in
  1. -. (p *. p) -. ((1. -. p) *. (1. -. p))

(* Strictly more than half must be rainy, so an even split predicts "no". *)
let majority days = if 2 * count_yes days > List.length days then "yes" else "no"

(* The days at or below the threshold, then the days above it. *)
let divide f threshold days = List.partition (fun d -> d.x.(f) <= threshold) days

(* The weighted Gini impurity left after splitting at this feature and
   threshold. *)
let score days f threshold =
  let left, right = divide f threshold days in
  ((size left *. gini left) +. (size right *. gini right)) /. size days

(* Every candidate split: features in column order, and for each, the
   midpoints between neighboring distinct values, from smallest to largest.
   List.sort_uniq sorts and drops duplicates. *)
let candidates days =
  let rec midpoints f = function
    | a :: (b :: _ as rest) -> (f, (a +. b) /. 2.) :: midpoints f rest
    | _ -> []
  in
  List.concat_map
    (fun f -> midpoints f (List.sort_uniq compare (List.map (fun d -> d.x.(f)) days)))
    (List.init (Array.length features) Fun.id)

(* Some (score, feature, threshold) with the lowest score, or None when every
   feature has a single value. Only a strictly lower score replaces the best
   so far, so on a tie the first split tried wins. *)
let best_split days =
  List.fold_left
    (fun best (f, t) ->
      let s = score days f t in
      match best with
      | Some (best_score, _, _) when best_score <= s -> best
      | _ -> Some (s, f, t))
    None (candidates days)

(* Grow the tree from the root at depth 0. A node becomes a leaf when it is
   pure, has fewer than min_size days, reaches max_depth, or no split makes
   the sides purer. let rec lets grow call itself. *)
let rec grow depth days =
  let impurity = gini days in
  let leaf = Leaf (majority days) in
  if impurity = 0. || List.length days < min_size || depth = max_depth then leaf
  else
    match best_split days with
    | Some (s, feature, threshold) when s < impurity ->
        let left, right = divide feature threshold days in
        Split { feature; threshold; left = grow (depth + 1) left; right = grow (depth + 1) right }
    | _ -> leaf

(* Follow the splits down to a leaf. The match must cover both kinds of node,
   or the compiler warns. *)
let rec predict tree x =
  match tree with
  | Leaf label -> label
  | Split { feature; threshold; left; right } ->
      predict (if x.(feature) <= threshold then left else right) x

(* Every node in preorder: the node itself, then its left side, then its
   right. *)
let rec preorder tree =
  match tree with
  | Leaf _ -> [ tree ]
  | Split { left; right; _ } -> (tree :: preorder left) @ preorder right

let count_correct tree days =
  List.length (List.filter (fun d -> predict tree d.x = d.label) days)

let () =
  let train, test = read_days Sys.argv.(1) in
  let tree = grow 0 train in
  let nodes = preorder tree in
  Printf.printf "train_count %d\n" (List.length train);
  Printf.printf "test_count %d\n" (List.length test);
  (* iteri numbers the nodes from 0, so each k is i + 1. *)
  List.iteri
    (fun i node ->
      match node with
      | Leaf label -> Printf.printf "node_%d_predict %s\n" (i + 1) label
      | Split { feature; threshold; _ } ->
          Printf.printf "node_%d_feature %s\n" (i + 1) features.(feature);
          Printf.printf "node_%d_threshold %.6f\n" (i + 1) threshold)
    nodes;
  let is_leaf = function Leaf _ -> true | Split _ -> false in
  Printf.printf "leaves %d\n" (List.length (List.filter is_leaf nodes));
  let correct = count_correct tree test in
  Printf.printf "train_accuracy %.6f\n" (float_of_int (count_correct tree train) /. size train);
  Printf.printf "correct %d\n" correct;
  Printf.printf "accuracy %.6f\n" (float_of_int correct /. size test)

OCaml’s standard library has no decision tree, and no opam package is installed, so the split search, the growing, and the predicting are all written out. The tree is a variant type: Leaf of string, or Split with an inline record of a feature, a threshold, and two subtrees. The type names itself, with no pointers or boxes.

grow is marked let rec, because without it OCaml won’t let a function call itself. predict matches on both kinds of node, and a match that leaves one out fails the build here, since every warning is an error. List.sort_uniq sorts each feature’s values and drops repeats, and a fold keeps the first lowest score.

Octave

From scratchTested automaticallyGNU Octave 9.4
main.mView on GitHub
% A decision tree classifier (CART with Gini impurity), using only core GNU
% Octave.
%
% Grows a tree on the training days by always making the split that leaves the
% two sides purest, then predicts rain for the test days. Prints the tree in
% preorder: each node is either a split (feature and threshold) or a leaf
% (its prediction). Core Octave has no tree function, and the Octave Forge
% statistics package isn't installed.

% The named column of a CSV file, as a cell array of strings (one per row).
% str2double turns a numeric column into numbers afterward.
function values = read_column(path, name)
  lines = strsplit(strtrim(fileread(path)), "\n");
  header = strsplit(strtrim(lines{1}), ",");
  index = find(strcmp(header, name));
  if isempty(index)
    error("no column %s", name);
  end
  values = cell(numel(lines) - 1, 1);
  for row = 2:numel(lines)
    fields = strsplit(strtrim(lines{row}), ",");
    values{row - 1} = fields{index};
  end
end

% Chance that two days drawn at random (with replacement) disagree, from the
% share p of rainy days. The .^ squares each element, so p can be a whole row
% of shares at once.
function g = gini(p)
  g = 1 - p .^ 2 - (1 - p) .^ 2;
end

% A leaf's prediction. An even split predicts "no".
function node = leaf(rain)
  if sum(rain) > numel(rain) / 2
    node = struct("label", "yes");
  else
    node = struct("label", "no");
  end
end

% The best split of the days in X (one row per day, one column per feature)
% with rain labels in the logical column rain.
function [best_score, best_f, best_threshold] = best_split(X, rain)
  n = rows(X);
  best_score = Inf;
  best_f = 0;
  best_threshold = 0;
  for f = 1:columns(X)
    values = unique(X(:, f));  % unique also sorts, smallest first
    thresholds = (values(1:end-1) + values(2:end))' / 2;  % one row
    % Comparing a column of days with a row of thresholds broadcasts to a
    % table: one row per day, one column per threshold, true where the day
    % goes left. sum adds down each column, so every threshold is scored at
    % once, with no loop over thresholds.
    goes_left = X(:, f) <= thresholds;
    n_left = sum(goes_left);
    yes_left = sum(goes_left & rain);
    n_right = n - n_left;
    yes_right = sum(rain) - yes_left;
    scores = (n_left .* gini(yes_left ./ n_left) ...
              + n_right .* gini(yes_right ./ n_right)) / n;
    % min returns the first position of the smallest score, so the smallest
    % threshold wins a tie; only a strictly lower score replaces an earlier
    % feature's best.
    [score, i] = min(scores);
    if score < best_score
      best_score = score;
      best_f = f;
      best_threshold = thresholds(i);
    end
  end
end

% A node is a struct. A leaf has one field, label; a split has feature,
% threshold, left, and right, and its children are whole structs stored
% inside it. Nesting structs reads like the tree itself and lets grow call
% itself on each side, the way the method is written.
function node = grow(X, rain, depth)
  impurity = gini(sum(rain) / numel(rain));
  if impurity == 0 || numel(rain) < 5 || depth == 3
    node = leaf(rain);
    return;
  end
  [score, f, threshold] = best_split(X, rain);
  if score >= impurity
    node = leaf(rain);  % no split makes the sides purer
    return;
  end
  left = X(:, f) <= threshold;  % logical indexing picks each side's rows
  node.feature = f;
  node.threshold = threshold;
  node.left = grow(X(left, :), rain(left), depth + 1);
  node.right = grow(X(~left, :), rain(~left), depth + 1);
end

function label = predict(node, x)
  while ~isfield(node, "label")
    if x(node.feature) <= node.threshold
      node = node.left;
    else
      node = node.right;
    end
  end
  label = node.label;
end

% Print the tree in preorder. k is this node's number; next is the number
% for whatever comes after its subtree. leaves counts the leaves printed.
function [next, leaves] = show_tree(node, k, names)
  if isfield(node, "label")
    printf("node_%d_predict %s\n", k, node.label);
    next = k + 1;
    leaves = 1;
  else
    printf("node_%d_feature %s\n", k, names{node.feature});
    printf("node_%d_threshold %.6f\n", k, node.threshold);
    [k, left_leaves] = show_tree(node.left, k + 1, names);
    [next, right_leaves] = show_tree(node.right, k, names);
    leaves = left_leaves + right_leaves;
  end
end

% How many days in X the tree labels correctly.
function correct = count_correct(tree, X, rain)
  correct = 0;
  for d = 1:rows(X)
    correct = correct + (strcmp(predict(tree, X(d, :)), "yes") == rain(d));
  end
end

args = argv();
names = {"humidity", "pressure", "wind"};
X = zeros(0, 0);
for f = 1:numel(names)
  X(:, f) = str2double(read_column(args{1}, names{f}));
end
rain = strcmp(read_column(args{1}, "rain"), "yes");  % true or false per day
is_train = strcmp(read_column(args{1}, "split"), "train");

tree = grow(X(is_train, :), rain(is_train), 0);

printf("train_count %d\n", sum(is_train));
printf("test_count %d\n", sum(~is_train));
[~, leaves] = show_tree(tree, 1, names);
printf("leaves %d\n", leaves);

train_correct = count_correct(tree, X(is_train, :), rain(is_train));
correct = count_correct(tree, X(~is_train, :), rain(~is_train));
printf("train_accuracy %.6f\n", train_correct / sum(is_train));
printf("correct %d\n", correct);
printf("accuracy %.6f\n", correct / sum(~is_train));

Core Octave has no tree function to skip, and the Octave Forge statistics package isn’t installed. We hold the tree as nested structs. A leaf has one field, label, and a split keeps its left and right children as whole structs inside it, so grow calls itself on each side and isfield(node, "label") tells the two apart.

The split search has no loop over thresholds. Comparing the column of days with a row of thresholds broadcasts to a table of days by thresholds, and sum counts each column, so every threshold is scored at once. min returns the first position of the smallest score, which makes the smallest threshold win a tie.

Pascal

From scratchTested automaticallyFree Pascal 3.2.2
main.pasView on GitHub
{ A decision tree classifier (CART with Gini impurity), using only Free
  Pascal's run-time library.

  Grows a tree on the training days by always making the split that leaves
  the two sides purest, then predicts rain for the test days. Prints the
  tree in preorder: each node is either a split (feature and threshold) or
  a leaf (its prediction). The run-time library has no tree or classifier,
  so there is none to skip; sorting uses TArrayHelper from
  Generics.Collections. }
program DecisionTree;

{$mode objfpc}{$H+}  // 32-bit Integer, and strings longer than 255 characters

uses
  SysUtils, Generics.Collections;

const
  NFeatures = 3;
  MaxDepth = 3;
  MinSize = 5;
  Features: array[0..NFeatures - 1] of String = ('humidity', 'pressure', 'wind');

type
  TDoubleArray = array of Double;

  TDay = record
    X: array[0..NFeatures - 1] of Double;
    Rain: Boolean;
  end;
  TDayArray = array of TDay;

  { ^TNode is a pointer to a TNode. Pascal lets PNode name TNode before
    TNode is declared, as long as both are in the same type section. A leaf
    is a node whose children are nil. }
  PNode = ^TNode;
  TNode = record
    Feature: Integer;
    Threshold: Double;
    Left, Right: PNode;
    Rain: Boolean;
  end;

function Column(const Header: TStringArray; const Name: String): Integer;
var
  I: Integer;
begin
  for I := 0 to High(Header) do
    if Header[I] = Name then
      Exit(I);
  raise Exception.Create('column not found: ' + Name);
end;

procedure Append(var Days: TDayArray; const D: TDay);
begin
  SetLength(Days, Length(Days) + 1);  // grow by one
  Days[High(Days)] := D;
end;

procedure ReadDays(const Path: String; out Train, Test: TDayArray);
var
  F: TextFile;
  Line: String;
  Fields: TStringArray;
  Cols: array[0..NFeatures - 1] of Integer;
  RainCol, SplitCol, I: Integer;
  D: TDay;
begin
  Train := nil;
  Test := nil;
  AssignFile(F, Path);
  Reset(F);
  ReadLn(F, Line);
  Fields := Line.Split(',');
  for I := 0 to NFeatures - 1 do
    Cols[I] := Column(Fields, Features[I]);
  RainCol := Column(Fields, 'rain');
  SplitCol := Column(Fields, 'split');
  while not Eof(F) do
  begin
    ReadLn(F, Line);
    Fields := Line.Split(',');
    for I := 0 to NFeatures - 1 do
      D.X[I] := StrToFloat(Fields[Cols[I]]);
    D.Rain := Fields[RainCol] = 'yes';
    if Fields[SplitCol] = 'train' then
      Append(Train, D)
    else
      Append(Test, D);
  end;
  CloseFile(F);
end;

function CountYes(const Days: TDayArray): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 0 to High(Days) do
    if Days[I].Rain then
      Inc(Result);
end;

{ The chance that two days drawn at random (with replacement) disagree. }
function Gini(const Days: TDayArray): Double;
var
  P: Double;
begin
  P := CountYes(Days) / Length(Days);
  Result := 1 - Sqr(P) - Sqr(1 - P);
end;

{ The days at or below the threshold go left, the rest go right. }
procedure Partition(const Days: TDayArray; F: Integer; Threshold: Double;
  out Left, Right: TDayArray);
var
  I: Integer;
begin
  Left := nil;
  Right := nil;
  for I := 0 to High(Days) do
    if Days[I].X[F] <= Threshold then
      Append(Left, Days[I])
    else
      Append(Right, Days[I]);
end;

{ Find the split with the lowest weighted Gini impurity. Features are tried
  in column order and thresholds from smallest to largest; only a strictly
  lower score replaces the best so far. Returns False if no feature has two
  distinct values. }
function BestSplit(const Days: TDayArray; out Score: Double;
  out Feature: Integer; out Threshold: Double): Boolean;
var
  Values: TDoubleArray;
  Left, Right: TDayArray;
  F, I, Distinct, N: Integer;
  T, S: Double;
begin
  Result := False;
  Score := 0;
  Feature := 0;
  Threshold := 0;
  N := Length(Days);
  SetLength(Values, N);
  for F := 0 to NFeatures - 1 do
  begin
    for I := 0 to N - 1 do
      Values[I] := Days[I].X[F];
    specialize TArrayHelper<Double>.Sort(Values);
    // Drop repeats, which sit next to each other once sorted.
    Distinct := 1;
    for I := 1 to N - 1 do
      if Values[I] <> Values[Distinct - 1] then
      begin
        Values[Distinct] := Values[I];
        Inc(Distinct);
      end;

    for I := 1 to Distinct - 1 do
    begin
      T := (Values[I - 1] + Values[I]) / 2;
      Partition(Days, F, T, Left, Right);
      S := (Length(Left) * Gini(Left) + Length(Right) * Gini(Right)) / N;
      if (not Result) or (S < Score) then
      begin
        Score := S;
        Feature := F;
        Threshold := T;
        Result := True;
      end;
    end;
  end;
end;

{ New allocates a node on the heap and points the given pointer at it. }
function NewLeaf(const Days: TDayArray): PNode;
begin
  New(Result);
  Result^.Left := nil;
  Result^.Right := nil;
  Result^.Rain := 2 * CountYes(Days) > Length(Days);  // an even split says no
end;

{ Grow a tree on Days, calling itself on each side of the best split. }
function Grow(const Days: TDayArray; Depth: Integer): PNode;
var
  G, Score, Threshold: Double;
  F: Integer;
  Left, Right: TDayArray;
begin
  G := Gini(Days);
  if (G = 0) or (Length(Days) < MinSize) or (Depth = MaxDepth) then
    Exit(NewLeaf(Days));
  if not BestSplit(Days, Score, F, Threshold) or (Score >= G) then
    Exit(NewLeaf(Days));  // no split makes the sides purer

  Partition(Days, F, Threshold, Left, Right);
  New(Result);
  Result^.Feature := F;
  Result^.Threshold := Threshold;
  Result^.Left := Grow(Left, Depth + 1);
  Result^.Right := Grow(Right, Depth + 1);
  Result^.Rain := False;  // unused on a split
end;

function Predict(Node: PNode; const D: TDay): Boolean;
begin
  while Node^.Left <> nil do
    if D.X[Node^.Feature] <= Node^.Threshold then
      Node := Node^.Left
    else
      Node := Node^.Right;
  Result := Node^.Rain;
end;

procedure Say(const Name: String; Value: Double);
begin
  WriteLn(Name, ' ', Value:0:6);  // width 0, six decimals
end;

{ Print every node, parents before children, left before right. K is the
  running node number and Leaves the running leaf count; var passes them by
  reference so the calls share one copy. }
procedure PrintPreorder(Node: PNode; var K, Leaves: Integer);
var
  Id: String;
begin
  Inc(K);
  Id := 'node_' + IntToStr(K);
  if Node^.Left = nil then
  begin
    if Node^.Rain then
      WriteLn(Id, '_predict yes')
    else
      WriteLn(Id, '_predict no');
    Inc(Leaves);
    Exit;
  end;
  WriteLn(Id, '_feature ', Features[Node^.Feature]);
  Say(Id + '_threshold', Node^.Threshold);
  PrintPreorder(Node^.Left, K, Leaves);
  PrintPreorder(Node^.Right, K, Leaves);
end;

function CountCorrect(Tree: PNode; const Days: TDayArray): Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 0 to High(Days) do
    if Predict(Tree, Days[I]) = Days[I].Rain then
      Inc(Result);
end;

{ Nothing frees heap memory for you: dispose of the children, then the node. }
procedure FreeTree(Node: PNode);
begin
  if Node^.Left <> nil then
  begin
    FreeTree(Node^.Left);
    FreeTree(Node^.Right);
  end;
  Dispose(Node);
end;

var
  Train, Test: TDayArray;
  Tree: PNode;
  K, Leaves, Correct: Integer;
begin
  ReadDays(ParamStr(1), Train, Test);
  Tree := Grow(Train, 0);

  WriteLn('train_count ', Length(Train));
  WriteLn('test_count ', Length(Test));
  K := 0;
  Leaves := 0;
  PrintPreorder(Tree, K, Leaves);
  WriteLn('leaves ', Leaves);

  Correct := CountCorrect(Tree, Test);
  Say('train_accuracy', CountCorrect(Tree, Train) / Length(Train));
  WriteLn('correct ', Correct);
  Say('accuracy', Correct / Length(Test));

  FreeTree(Tree);
end.

The tree is a TNode record with a feature, a threshold, a prediction, and two children of type PNode, declared as ^TNode, a pointer to a node. Pascal lets PNode name TNode before it is declared, but only within the same type section. A leaf is a node whose children are nil. Grow calls itself on each side, getting each node from New, and FreeTree hands every node back with Dispose, children first. The run-time library has no tree or classifier, so there was none to skip. To list a feature’s distinct values, we sort with TArrayHelper<Double>.Sort and drop repeats in one pass.

Perl

From scratchTested automaticallyPerl 5.40.1
main.plView on GitHub
# A decision tree classifier (CART with Gini impurity), using only Perl and
# its core modules. Grows a tree on the training days by always making the
# split that leaves the two sides purest, then predicts rain for the test
# days. Prints the tree in preorder: each node is either a split (feature and
# threshold) or a leaf (its prediction).
use strict;
use warnings;

my @FEATURES = qw(humidity pressure wind);
my $MAX_DEPTH = 3;
my $MIN_SIZE = 5;

# Each day is a hash ref: { x => [humidity, pressure, wind], label => 'yes' }.
# Braces build an anonymous hash and brackets an anonymous array; -> reaches
# inside a reference.
sub read_days {
    my ($path) = @_;
    open my $fh, '<', $path or die "can't open $path: $!";
    chomp(my $header = <$fh>);
    my @names = split /,/, $header;
    my (@train, @test);
    while (my $line = <$fh>) {
        chomp $line;
        my %row;
        @row{@names} = split /,/, $line;    # a hash slice: many keys at once
        my $day = { x => [map { $row{$_} + 0 } @FEATURES], label => $row{rain} };
        push @{ $row{split} eq 'train' ? \@train : \@test }, $day;
    }
    close $fh;
    return (\@train, \@test);
}

# Chance that two days drawn at random (with replacement) disagree.
sub gini {
    my @labels = @_;
    my $p = (grep { $_ eq 'yes' } @labels) / @labels;   # grep counts matches
    return 1 - $p * $p - (1 - $p) * (1 - $p);
}

# An even split predicts "no".
sub majority {
    my @labels = @_;
    return (grep { $_ eq 'yes' } @labels) > @labels / 2 ? 'yes' : 'no';
}

# The [score, feature, threshold] with the lowest weighted Gini impurity.
# Features are tried in column order and thresholds from smallest to largest;
# only a strictly lower score replaces the best so far.
sub best_split {
    my @days = @_;
    my $best;
    for my $f (0 .. $#FEATURES) {
        # A hash's keys make a set of the distinct values; <=> sorts numbers.
        my %seen = map { $_->{x}[$f] => 1 } @days;
        my @values = sort { $a <=> $b } keys %seen;
        for my $i (0 .. $#values - 1) {
            my $threshold = ($values[$i] + $values[$i + 1]) / 2;
            my @left  = map { $_->{label} } grep { $_->{x}[$f] <= $threshold } @days;
            my @right = map { $_->{label} } grep { $_->{x}[$f] >  $threshold } @days;
            my $score = (@left * gini(@left) + @right * gini(@right)) / @days;
            $best = [$score, $f, $threshold] if !$best || $score < $best->[0];
        }
    }
    return $best;
}

# A leaf is { leaf => 'yes' }; a split is
# { feature => 0, threshold => 63.5, left => ..., right => ... }.
sub grow {
    my ($days, $depth) = @_;
    my @labels = map { $_->{label} } @$days;
    my $impurity = gini(@labels);
    if ($impurity == 0 || @$days < $MIN_SIZE || $depth == $MAX_DEPTH) {
        return { leaf => majority(@labels) };
    }
    my $split = best_split(@$days);
    # No split makes the sides purer.
    return { leaf => majority(@labels) } if !$split || $split->[0] >= $impurity;
    my (undef, $f, $threshold) = @$split;
    return {
        feature   => $f,
        threshold => $threshold,
        left      => grow([grep { $_->{x}[$f] <= $threshold } @$days], $depth + 1),
        right     => grow([grep { $_->{x}[$f] >  $threshold } @$days], $depth + 1),
    };
}

sub predict {
    my ($node, $x) = @_;
    until (exists $node->{leaf}) {
        $node = $x->[$node->{feature}] <= $node->{threshold}
            ? $node->{left} : $node->{right};
    }
    return $node->{leaf};
}

# Every node, parents before children, left subtree before right.
sub preorder {
    my ($node) = @_;
    return $node if exists $node->{leaf};
    return ($node, preorder($node->{left}), preorder($node->{right}));
}

my ($train, $test) = read_days($ARGV[0]);
my $tree = grow($train, 0);

printf "train_count %d\n", scalar @$train;
printf "test_count %d\n", scalar @$test;
my ($k, $leaves) = (0, 0);
for my $node (preorder($tree)) {
    $k++;
    if (exists $node->{leaf}) {
        print "node_${k}_predict $node->{leaf}\n";
        $leaves++;
    } else {
        print "node_${k}_feature $FEATURES[$node->{feature}]\n";
        printf "node_%d_threshold %.6f\n", $k, $node->{threshold};
    }
}
print "leaves $leaves\n";

my $train_correct = grep { predict($tree, $_->{x}) eq $_->{label} } @$train;
my $correct = grep { predict($tree, $_->{x}) eq $_->{label} } @$test;
printf "train_accuracy %.6f\n", $train_correct / @$train;
print "correct $correct\n";
printf "accuracy %.6f\n", $correct / @$test;

Perl has no decision tree, and no CPAN module is installed, so we grow the tree from hash references. A leaf is { leaf => 'yes' }, and a split holds feature, threshold, left, and right, where each side is another hash ref. grow calls itself on each side, and predict walks down until it reaches a node with a leaf key.

A hash’s keys also make a set, which the split search uses to collect each feature’s distinct values before sort { $a <=> $b } orders them. You count matches with grep itself: in scalar context it returns how many items passed, so no counting loop is needed.

PHP

From scratchTested automaticallyPHP 8.4.24
main.phpView on GitHub
<?php
// A decision tree classifier (CART with Gini impurity), using only PHP and
// the extensions built into it.
//
// Grows a tree on the training days by always making the split that leaves
// the two sides purest, then predicts rain for the test days. Prints the tree
// in preorder: each node is either a split (feature and threshold) or a leaf
// (its prediction).
declare(strict_types=1);

const FEATURES = ['humidity', 'pressure', 'wind'];
const MAX_DEPTH = 3;
const MIN_SIZE = 5;

// Each day is [[humidity, pressure, wind], label].
function read_days(string $path): array
{
    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    $header = array_flip(explode(',', array_shift($lines)));  // name => column
    $train = $test = [];
    foreach ($lines as $line) {
        $row = explode(',', $line);
        $x = array_map(fn($name) => (float) $row[$header[$name]], FEATURES);
        $day = [$x, $row[$header['rain']]];
        if ($row[$header['split']] === 'train') {
            $train[] = $day;
        } else {
            $test[] = $day;
        }
    }
    return [$train, $test];
}

function count_yes(array $labels): int
{
    return count(array_filter($labels, fn($l) => $l === 'yes'));
}

// Chance that two days drawn at random (with replacement) disagree.
function gini(array $labels): float
{
    $p = count_yes($labels) / count($labels);
    return 1 - $p * $p - (1 - $p) * (1 - $p);
}

// An even split predicts "no".
function majority(array $labels): string
{
    return count_yes($labels) > count($labels) / 2 ? 'yes' : 'no';
}

// The [score, feature, threshold] with the lowest weighted Gini impurity, or
// null. Features are tried in column order and thresholds from smallest to
// largest; only a strictly lower score replaces the best so far.
function best_split(array $days): ?array
{
    $best = null;
    foreach (array_keys(FEATURES) as $f) {
        $values = array_map(fn($d) => $d[0][$f], $days);
        sort($values);
        for ($i = 1; $i < count($values); $i++) {
            if ($values[$i] === $values[$i - 1]) {
                continue;  // only midpoints between distinct values
            }
            $threshold = ($values[$i - 1] + $values[$i]) / 2;
            $left = $right = [];
            foreach ($days as [$x, $label]) {
                if ($x[$f] <= $threshold) {
                    $left[] = $label;
                } else {
                    $right[] = $label;
                }
            }
            $score = (count($left) * gini($left) + count($right) * gini($right))
                / count($days);
            if ($best === null || $score < $best[0]) {
                $best = [$score, $f, $threshold];
            }
        }
    }
    return $best;
}

// A leaf is ['leaf' => label]; a split is
// ['feature' => f, 'threshold' => t, 'left' => node, 'right' => node].
function grow(array $days, int $depth = 0): array
{
    $labels = array_column($days, 1);
    if (gini($labels) == 0 || count($days) < MIN_SIZE || $depth === MAX_DEPTH) {
        return ['leaf' => majority($labels)];
    }
    $split = best_split($days);
    if ($split === null || $split[0] >= gini($labels)) {
        return ['leaf' => majority($labels)];  // no split makes the sides purer
    }
    [, $f, $threshold] = $split;
    $left = array_filter($days, fn($d) => $d[0][$f] <= $threshold);
    $right = array_filter($days, fn($d) => $d[0][$f] > $threshold);
    return [
        'feature' => $f,
        'threshold' => $threshold,
        // array_filter keeps the original keys; array_values renumbers them.
        'left' => grow(array_values($left), $depth + 1),
        'right' => grow(array_values($right), $depth + 1),
    ];
}

function predict(array $node, array $x): string
{
    while (!isset($node['leaf'])) {
        $node = $x[$node['feature']] <= $node['threshold'] ? $node['left'] : $node['right'];
    }
    return $node['leaf'];
}

// Appends the node, then its left subtree, then its right subtree.
function preorder(array $node, array &$out): void
{
    $out[] = $node;
    if (!isset($node['leaf'])) {
        preorder($node['left'], $out);
        preorder($node['right'], $out);
    }
}

function accuracy_count(array $tree, array $days): int
{
    return count(array_filter($days, fn($d) => predict($tree, $d[0]) === $d[1]));
}

[$train, $test] = read_days($argv[1]);
$tree = grow($train);

echo 'train_count ', count($train), "\n";
echo 'test_count ', count($test), "\n";
$nodes = [];
preorder($tree, $nodes);
$leaves = 0;
foreach ($nodes as $i => $node) {
    $k = $i + 1;
    if (isset($node['leaf'])) {
        echo "node_{$k}_predict {$node['leaf']}\n";
        $leaves++;
    } else {
        echo "node_{$k}_feature ", FEATURES[$node['feature']], "\n";
        printf("node_%d_threshold %.6f\n", $k, $node['threshold']);
    }
}
echo "leaves $leaves\n";

$correct = accuracy_count($tree, $test);
printf("train_accuracy %.6f\n", accuracy_count($tree, $train) / count($train));
echo "correct $correct\n";
printf("accuracy %.6f\n", $correct / count($test));

PHP has no decision tree of its own, so every step is written out. A node is a keyed array: a leaf is ['leaf' => 'yes'], and a split holds a feature, a threshold, and left and right subtrees. grow calls itself on each side, and predict walks down until it reaches a key named leaf.

We sort each feature’s values with sort and skip repeats by comparing neighbors, rather than collecting them as array keys, since PHP truncates float keys to integers. One quirk: array_filter keeps the original keys, so array_values renumbers each side before it is grown again.

PostgreSQL

From scratchTested automaticallyPostgreSQL 18.6
main.sqlView on GitHub
-- A decision tree classifier (CART with Gini impurity), in PostgreSQL.
--
-- Grows a tree on the training days by always making the split that leaves the
-- two sides purest, then predicts rain for the test days. The tree grows one
-- level at a time in a WITH RECURSIVE query: each row is a node, named by its
-- path from the root ('' is the root, 'LR' is the root's left child's right
-- child), holding the ids of the days that reach it. Test days ride along
-- without being counted, so each one ends up in the leaf that predicts it.

WITH RECURSIVE days AS (
    SELECT row_id AS id, split, rain = 'yes' AS yes, humidity, pressure, wind FROM data
),
features AS (
    -- One row per (day, feature), so every feature is split the same way.
    -- f numbers the features in column order, which decides ties.
    SELECT d.id, d.split, d.yes, v.f, v.value
    FROM days d,
    LATERAL (VALUES (1, d.humidity), (2, d.pressure), (3, d.wind)) AS v (f, value)
),
tree (path, depth, members, split_f, split_t) AS (
    SELECT '', 0, array_agg(id ORDER BY id), NULL::int, NULL::double precision
    FROM days

    UNION ALL

    -- Split a node into two children. Each child row also records the split
    -- that made it (the parent's feature and threshold). A node that should
    -- stay a leaf fails the WHERE clause and gets no children.
    SELECT s.path || side, s.depth + 1, child.members, best.f, best.threshold
    FROM tree s,
    LATERAL (
        -- The node's training days and its Gini impurity, 1 - p^2 - (1 - p)^2,
        -- with p the share of "yes".
        SELECT n, 1 - (yes / n) ^ 2 - (1 - yes / n) ^ 2 AS gini
        FROM (
            SELECT count(*)::double precision AS n, count(*) FILTER (WHERE yes) AS yes
            FROM days
            WHERE split = 'train' AND id = ANY (s.members)
        ) AS counts
    ) AS node,
    LATERAL (
        -- Every candidate split and its weighted Gini; the lowest wins, and a
        -- tie goes to the earlier feature, then the smaller threshold.
        SELECT c.f, c.threshold,
               (nl * (1 - (yl / nl) ^ 2 - (1 - yl / nl) ^ 2)
                + nr * (1 - (yr / nr) ^ 2 - (1 - yr / nr) ^ 2)) / node.n AS score
        FROM (
            -- Thresholds: midpoints of consecutive distinct values.
            SELECT f, (value + lead(value) OVER (PARTITION BY f ORDER BY value)) / 2 AS threshold
            FROM (
                SELECT DISTINCT f, value FROM features
                WHERE split = 'train' AND id = ANY (s.members)
            ) AS distinct_values
        ) AS c,
        LATERAL (
            -- Left is value <= threshold. The casts avoid integer division.
            SELECT count(*) FILTER (WHERE value <= c.threshold)::double precision AS nl,
                   count(*) FILTER (WHERE value <= c.threshold AND yes) AS yl,
                   count(*) FILTER (WHERE value > c.threshold)::double precision AS nr,
                   count(*) FILTER (WHERE value > c.threshold AND yes) AS yr
            FROM features
            WHERE f = c.f AND split = 'train' AND id = ANY (s.members)
        ) AS sides
        WHERE c.threshold IS NOT NULL
        ORDER BY score, c.f, c.threshold
        LIMIT 1
    ) AS best,
    LATERAL (VALUES ('L'), ('R')) AS sides (side),
    LATERAL (
        -- Route every day, training or test, to its side.
        SELECT array_agg(id ORDER BY id) AS members
        FROM features
        WHERE f = best.f AND id = ANY (s.members)
          AND (value <= best.threshold) = (side = 'L')
    ) AS child
    -- Stop at a pure node, a node with fewer than 5 days, depth 3, or when no
    -- split makes the sides purer.
    WHERE node.gini > 0 AND node.n >= 5 AND s.depth < 3 AND best.score < node.gini
),
nodes AS (
    -- Number the nodes in preorder. In byte order ("C" collation) a path
    -- sorts right after its parent's, and 'L' before 'R'.
    SELECT t.path, t.members,
           row_number() OVER (ORDER BY t.path COLLATE "C") AS k,
           left_child.split_f, left_child.split_t
    FROM tree t
    LEFT JOIN tree left_child ON left_child.path = t.path || 'L'
),
leaves AS (
    -- A leaf predicts "yes" only if most of its training days are "yes";
    -- an even split predicts "no".
    SELECT k, members,
           (SELECT count(*) FILTER (WHERE yes) > count(*) / 2.0
            FROM days WHERE split = 'train' AND id = ANY (members)) AS predict_yes
    FROM nodes
    WHERE split_f IS NULL
),
scores AS (
    -- Every day sits in exactly one leaf, whose prediction it gets.
    SELECT count(*) FILTER (WHERE d.split = 'train') AS train_count,
           count(*) FILTER (WHERE d.split = 'train' AND d.yes = l.predict_yes) AS train_correct,
           count(*) FILTER (WHERE d.split = 'test') AS test_count,
           count(*) FILTER (WHERE d.split = 'test' AND d.yes = l.predict_yes) AS correct
    FROM leaves l
    JOIN days d ON d.id = ANY (l.members)
)
SELECT name, value
FROM (
    SELECT 1 AS position, 0 AS line, 'train_count' AS name, train_count::text AS value FROM scores
    UNION ALL
    SELECT 2, 0, 'test_count', test_count::text FROM scores
    UNION ALL
    -- A split prints two lines and a leaf one, all in preorder.
    SELECT 3, 2 * k, format('node_%s_feature', k),
           (ARRAY['humidity', 'pressure', 'wind'])[split_f]
    FROM nodes WHERE split_f IS NOT NULL
    UNION ALL
    SELECT 3, 2 * k + 1, format('node_%s_threshold', k), round(split_t::numeric, 6)::text
    FROM nodes WHERE split_f IS NOT NULL
    UNION ALL
    SELECT 3, 2 * k, format('node_%s_predict', k), CASE WHEN predict_yes THEN 'yes' ELSE 'no' END
    FROM leaves
    UNION ALL
    SELECT 4, 0, 'leaves', count(*)::text FROM leaves
    UNION ALL
    SELECT 5, 0, 'train_accuracy', round(train_correct::numeric / train_count, 6)::text FROM scores
    UNION ALL
    SELECT 6, 0, 'correct', correct::text FROM scores
    UNION ALL
    SELECT 7, 0, 'accuracy', round(correct::numeric / test_count, 6)::text FROM scores
) AS results
ORDER BY position, line;

A tree is recursive, and WITH RECURSIVE grows it one level at a time. Each row is a node, named by its path from the root ('', 'L', 'LR'), holding an array of the ids of the days that reach it; this tree takes 13 rows. The recursive term can’t read the tree from a subquery (“recursive reference … must not appear within a subquery”) or aggregate it, so each node’s split search runs in LATERAL subqueries over its own members.

Test days ride along in those arrays, so each one lands in the leaf that predicts it, with no walk down the tree. Sorting paths in the "C" collation gives preorder. Core PostgreSQL has no decision tree to skip.

Prolog

From scratchTested automaticallySWI-Prolog 9.2.9
main.plView on GitHub
% A decision tree classifier (CART with Gini impurity), using SWI-Prolog
% and the libraries that come with it.
%
% Grows a tree on the training days by always making the split that leaves
% the two sides purest, then predicts rain for the test days. Prints the
% tree in preorder: each node is either a split (feature and threshold) or
% a leaf (its prediction).
%
% In Prolog the tree is just a term, a nested piece of data such as
% split(1, 1000.95, leaf(yes), leaf(no)). There's nothing to declare:
% leaf(Rain) and split(Feature, Threshold, Left, Right) are names we pick,
% and a split simply holds two more trees inside it.

:- initialization(main, main).

features([humidity, pressure, wind]).
max_depth(3).
min_size(5).

% read_days(+Path, -Train, -Test): each day is day(Values, Rain), where
% Values are its features in the order of features/1 and Rain is the atom
% yes or no. The header gives each column's position.
read_days(Path, Train, Test) :-
    read_file_to_string(Path, Text, []),
    split_string(Text, "\n", "", [HeaderLine|Lines]),
    split_string(HeaderLine, ",", "", Header),
    features(Names),
    findall(Split-day(Values, Rain),
            ( member(Line, Lines),
              Line \== "",
              split_string(Line, ",", "", Fields),
              maplist(feature_value(Header, Fields), Names, Values),
              field(Header, Fields, rain, Rain),
              field(Header, Fields, split, Split) ),
            Tagged),
    findall(Day, member(train-Day, Tagged), Train),
    findall(Day, member(test-Day, Tagged), Test).

% field(+Header, +Fields, +Name, -Atom): the field in column Name, as an atom.
field(Header, Fields, Name, Atom) :-
    atom_string(Name, Column),
    nth0(Index, Header, Column),
    nth0(Index, Fields, Field),
    atom_string(Atom, Field).

% feature_value(+Header, +Fields, +Name, -Value): the field as a float.
% "54" would read as the integer 54, so float/1 makes every value alike.
feature_value(Header, Fields, Name, Value) :-
    field(Header, Fields, Name, Atom),
    atom_number(Atom, Number),
    Value is float(Number).

rain_of(day(_, Rain), Rain).

% gini(+Labels, -G): the chance that two days drawn at random (with
% replacement) disagree. include/3 keeps the labels equal to yes.
gini(Labels, G) :-
    length(Labels, N),
    include(==(yes), Labels, Yes),
    length(Yes, Rainy),
    P is Rainy / N,
    G is 1 - P * P - (1 - P) * (1 - P).

% majority(+Labels, -Rain): strictly more than half must be rainy to
% predict rain, so an even split predicts no.
majority(Labels, Rain) :-
    length(Labels, N),
    include(==(yes), Labels, Yes),
    length(Yes, Rainy),
    (   2 * Rainy > N -> Rain = yes ; Rain = no ).

% divide(+F, +Threshold, +Days, -Left, -Right): the days at or below the
% threshold, then the days above it.
at_or_below(F, Threshold, day(Values, _)) :-
    nth0(F, Values, X),
    X =< Threshold.

divide(F, Threshold, Days, Left, Right) :-
    partition(at_or_below(F, Threshold), Days, Left, Right).

% score(+Days, +F, +Threshold, -Score): the weighted Gini impurity left
% after splitting at this feature and threshold.
score(Days, F, Threshold, Score) :-
    divide(F, Threshold, Days, Left, Right),
    maplist(rain_of, Left, LeftLabels),
    maplist(rain_of, Right, RightLabels),
    length(Left, NL),
    length(Right, NR),
    gini(LeftLabels, GL),
    gini(RightLabels, GR),
    Score is (NL * GL + NR * GR) / (NL + NR).

% candidate(+Days, -F, -Threshold): true for each candidate split. On
% backtracking it yields features in column order and, for each, the
% midpoints between neighboring distinct values from smallest to largest.
% sort/2 drops repeated values, which is exactly what we want here.
candidate(Days, F, Threshold) :-
    features(Names),
    nth0(F, Names, _),
    findall(X, ( member(day(Values, _), Days), nth0(F, Values, X) ), Xs),
    sort(Xs, Distinct),
    append(_, [A, B|_], Distinct),
    Threshold is (A + B) / 2.

% best_split(+Days, -Best): Best is Score-F-Threshold with the lowest
% score. findall lists every candidate in the order tried; foldl walks the
% list, and only a strictly lower score replaces the best so far, so the
% first split tried wins ties. Fails when there are no candidates.
best_split(Days, Best) :-
    findall(S-F-T, ( candidate(Days, F, T), score(Days, F, T, S) ),
            [First|Rest]),
    foldl(lower, Rest, First, Best).

lower(S-F-T, BestS-_-_, S-F-T) :- S < BestS, !.
lower(_, Best, Best).

% grow(+Days, +Depth, -Tree): Tree is the tree grown from Days, starting
% at depth 0. This is a relation, not a function: grow states what Tree
% is, and the split clause holds when its two children are themselves
% trees grown from each side. The first clause catches every stopping
% rule; the cut (!) commits to it so the split clause isn't tried.
grow(Days, Depth, leaf(Rain)) :-
    maplist(rain_of, Days, Labels),
    majority(Labels, Rain),
    gini(Labels, G),
    length(Days, N),
    min_size(MinSize),
    max_depth(MaxDepth),
    (   G =:= 0
    ;   N < MinSize
    ;   Depth =:= MaxDepth
    ;   \+ ( best_split(Days, S-_-_), S < G )  % no split makes the sides purer
    ),
    !.
grow(Days, Depth, split(F, Threshold, Left, Right)) :-
    best_split(Days, _-F-Threshold),
    divide(F, Threshold, Days, LeftDays, RightDays),
    Next is Depth + 1,
    grow(LeftDays, Next, Left),
    grow(RightDays, Next, Right).

% predict(+Tree, +Values, -Rain): one clause per kind of node, and Prolog
% picks the clause whose head matches the term. Reaching a leaf ends the
% walk; a split asks its question and walks down one side.
predict(leaf(Rain), _, Rain).
predict(split(F, Threshold, Left, Right), Values, Rain) :-
    nth0(F, Values, X),
    (   X =< Threshold
    ->  predict(Left, Values, Rain)
    ;   predict(Right, Values, Rain)
    ).

% preorder(+Tree, -Nodes): every node, then its left side, then its right.
preorder(leaf(Rain), [leaf(Rain)]).
preorder(split(F, T, L, R), [split(F, T, L, R)|Nodes]) :-
    preorder(L, LeftNodes),
    preorder(R, RightNodes),
    append(LeftNodes, RightNodes, Nodes).

is_leaf(leaf(_)).

% describe(+K, +Node): print the output lines for node number K.
describe(K, leaf(Rain)) :-
    format("node_~d_predict ~w~n", [K, Rain]).
describe(K, split(F, Threshold, _, _)) :-
    features(Names),
    nth0(F, Names, Name),
    format("node_~d_feature ~w~n", [K, Name]),
    format("node_~d_threshold ~6f~n", [K, Threshold]).

% count_correct(+Tree, +Days, -Correct): how many days the tree labels
% correctly. The goal inside findall succeeds once per such day.
count_correct(Tree, Days, Correct) :-
    findall(x, ( member(day(Values, Rain), Days),
                 predict(Tree, Values, Rain) ),
            Hits),
    length(Hits, Correct).

main :-
    current_prolog_flag(argv, [Path|_]),
    read_days(Path, Train, Test),
    grow(Train, 0, Tree),
    length(Train, NTrain),
    length(Test, NTest),
    format("train_count ~d~n", [NTrain]),
    format("test_count ~d~n", [NTest]),
    preorder(Tree, Nodes),
    % nth1 numbers the nodes from 1 as forall walks them.
    forall(nth1(K, Nodes, Node), describe(K, Node)),
    include(is_leaf, Nodes, Leaves),
    length(Leaves, NLeaves),
    format("leaves ~d~n", [NLeaves]),
    count_correct(Tree, Train, TrainCorrect),
    count_correct(Tree, Test, Correct),
    TrainAccuracy is TrainCorrect / NTrain,
    Accuracy is Correct / NTest,
    format("train_accuracy ~6f~n", [TrainAccuracy]),
    format("correct ~d~n", [Correct]),
    format("accuracy ~6f~n", [Accuracy]).

The tree is a plain term: leaf(yes), or split(Feature, Threshold, Left, Right) with two more trees inside, so print shows the whole model on one line. The library has red-black trees for lookups but no decision tree. grow is a recursive relation whose split clause holds when both children are trees grown from each side, and predict has one clause per kind of node, picked by matching the term. candidate yields thresholds on backtracking, sort keeps each distinct value once, and foldl keeps only a strictly lower score, so the first split tried wins ties. One trap: we count leaves with include(is_leaf, ...), because include(=(leaf(_)), ...) binds the _ to the first leaf’s label and finds 3 leaves, not 7.

R

From scratchTested automaticallyR 4.6.2
main.RView on GitHub
# A decision tree classifier (CART with Gini impurity), using only base R.
#
# Grows a tree on the training days by always making the split that leaves the
# two sides purest, then predicts rain for the test days. Prints the tree in
# preorder: each node is either a split (feature and threshold) or a leaf.
# rpart() would grow a tree like this in one call; it is a recommended package,
# which this image removes.

features <- c("humidity", "pressure", "wind")
max_depth <- 3
min_size <- 5

args <- commandArgs(trailingOnly = TRUE)
data <- read.csv(args[1])
data$rain <- data$rain == "yes"  # TRUE/FALSE, so sum() counts the rainy days
train <- data[data$split == "train", ]
test <- data[data$split == "test", ]

# Chance that two days drawn at random (with replacement) disagree, from the
# share p of rainy days. Works on a whole vector of shares at once.
gini <- function(p) 1 - p^2 - (1 - p)^2

# An even split predicts "no".
majority <- function(rain) if (sum(rain) > length(rain) / 2) "yes" else "no"

# The best split as list(score, feature, threshold), or NULL if there is none.
best_split <- function(days) {
  best <- NULL
  n <- nrow(days)
  for (f in features) {
    values <- sort(unique(days[[f]]))
    if (length(values) < 2) next
    thresholds <- (head(values, -1) + tail(values, -1)) / 2
    # A table with one row per day and one column per threshold: TRUE where
    # the day goes left. colSums counts each column, so every threshold's
    # left-side size and rainy count come out at once.
    goes_left <- outer(days[[f]], thresholds, "<=")
    n_left <- colSums(goes_left)
    yes_left <- colSums(goes_left * days$rain)
    n_right <- n - n_left
    yes_right <- sum(days$rain) - yes_left
    scores <- (n_left * gini(yes_left / n_left) +
               n_right * gini(yes_right / n_right)) / n
    i <- which.min(scores)  # first minimum, so the smallest threshold wins ties
    if (is.null(best) || scores[i] < best$score) {
      best <- list(score = scores[i], feature = f, threshold = thresholds[i])
    }
  }
  best
}

# A leaf is "yes" or "no"; a split is a list with feature, threshold, left,
# and right. Recursion grows each side as a smaller tree.
grow <- function(days, depth = 0) {
  node_gini <- gini(sum(days$rain) / nrow(days))
  if (node_gini == 0 || nrow(days) < min_size || depth == max_depth) {
    return(majority(days$rain))
  }
  split <- best_split(days)
  if (is.null(split) || split$score >= node_gini) {
    return(majority(days$rain))  # no split makes the sides purer
  }
  left <- days[[split$feature]] <= split$threshold
  list(feature = split$feature, threshold = split$threshold,
       left = grow(days[left, ], depth + 1),
       right = grow(days[!left, ], depth + 1))
}

predict_day <- function(node, day) {
  while (is.list(node)) {
    node <- if (day[[node$feature]] <= node$threshold) node$left else node$right
  }
  node
}

# Every node, root first, then the left subtree, then the right.
preorder <- function(node) {
  if (!is.list(node)) return(list(node))
  c(list(node), preorder(node$left), preorder(node$right))
}

tree <- grow(train)
count_correct <- function(days) {
  predicted <- sapply(seq_len(nrow(days)),
                      function(i) predict_day(tree, days[i, ]))
  sum(predicted == ifelse(days$rain, "yes", "no"))
}

cat(sprintf("train_count %d\n", nrow(train)))
cat(sprintf("test_count %d\n", nrow(test)))
nodes <- preorder(tree)
for (k in seq_along(nodes)) {
  node <- nodes[[k]]
  if (is.list(node)) {
    cat(sprintf("node_%d_feature %s\n", k, node$feature))
    cat(sprintf("node_%d_threshold %.6f\n", k, node$threshold))
  } else {
    cat(sprintf("node_%d_predict %s\n", k, node))
  }
}
cat(sprintf("leaves %d\n", sum(!sapply(nodes, is.list))))
correct <- count_correct(test)
cat(sprintf("train_accuracy %.6f\n", count_correct(train) / nrow(train)))
cat(sprintf("correct %d\n", correct))
cat(sprintf("accuracy %.6f\n", correct / nrow(test)))

The split search has no loop over thresholds. outer(days[[f]], thresholds, "<=") builds a TRUE/FALSE table with one row per day and one column per threshold, so colSums() gives every threshold’s left-side size at once, and multiplying the table by the rain column first gives the rainy counts. gini() then scores all the thresholds in one vector, and which.min() takes the first minimum, which is the tie rule.

The tree is nested lists: a leaf is the string "yes" or "no", a split is a list holding its two subtrees, and is.list() tells them apart. We avoided mean(). The usual tool, rpart(), is one of R’s recommended packages, which this image removes, so it isn’t installed here.

Ruby

From scratchTested automaticallyCRuby 4.0.9
main.rbView on GitHub
# A decision tree classifier (CART with Gini impurity), using only the standard
# library. Grows a tree on the training days by always making the split that
# leaves the two sides purest, then predicts rain for the test days. Prints the
# tree in preorder: each node is either a split (feature and threshold) or a
# leaf (its prediction).

FEATURES = %w[humidity pressure wind].freeze
MAX_DEPTH = 3
MIN_SIZE = 5

# Data.define makes a small immutable class with named fields, so the two kinds
# of node are two types, and pattern matching can tell them apart.
Leaf = Data.define(:label)
Split = Data.define(:feature, :threshold, :left, :right)

# The csv library is a bundled gem since Ruby 3.4, so split lines by hand
# (no quoted fields here) and turn each row into a hash keyed by header name.
def read_rows(path)
  header, *lines = File.readlines(path, chomp: true)
  names = header.split(",")
  lines.map { |line| names.zip(line.split(",")).to_h }
end

# Chance that two days drawn at random (with replacement) disagree.
def gini(labels)
  p = labels.count("yes").fdiv(labels.length)
  1 - p * p - (1 - p) * (1 - p)
end

# An even split predicts "no".
def majority(labels)
  labels.count("yes") > labels.length / 2.0 ? "yes" : "no"
end

# The [score, feature, threshold] with the lowest weighted Gini impurity.
# Features are tried in column order and thresholds from smallest to largest;
# only a strictly lower score replaces the best so far.
def best_split(days)
  best = nil
  FEATURES.each_index do |f|
    days.map { |x, _| x[f] }.uniq.sort.each_cons(2) do |a, b|
      threshold = (a + b) / 2
      left, right = days.partition { |x, _| x[f] <= threshold }
      score = (left.length * gini(left.map(&:last)) +
               right.length * gini(right.map(&:last))) / days.length
      best = [score, f, threshold] if best.nil? || score < best[0]
    end
  end
  best
end

def grow(days, depth = 0)
  labels = days.map(&:last)
  if gini(labels).zero? || days.length < MIN_SIZE || depth == MAX_DEPTH
    return Leaf.new(majority(labels))
  end

  score, f, threshold = best_split(days)
  return Leaf.new(majority(labels)) if score.nil? || score >= gini(labels) # no purer split

  left, right = days.partition { |x, _| x[f] <= threshold }
  Split.new(f, threshold, grow(left, depth + 1), grow(right, depth + 1))
end

def predict(node, x)
  # case/in matches on the node's class and pulls out its fields.
  case node
  in Leaf(label) then label
  in Split(feature:, threshold:, left:, right:)
    predict(x[feature] <= threshold ? left : right, x)
  end
end

# Every node, parents before children, left subtree before right.
def preorder(node)
  return [node] if node in Leaf

  [node, *preorder(node.left), *preorder(node.right)]
end

rows = read_rows(ARGV[0])
days = rows.map { |row| [FEATURES.map { |f| Float(row[f]) }, row["rain"], row["split"]] }
train, test = days.partition { |_, _, split| split == "train" }
train = train.map { |x, label, _| [x, label] }
test = test.map { |x, label, _| [x, label] }
tree = grow(train)

puts "train_count #{train.length}"
puts "test_count #{test.length}"
nodes = preorder(tree)
nodes.each.with_index(1) do |node, k|
  case node
  in Leaf(label)
    puts "node_#{k}_predict #{label}"
  in Split(feature:, threshold:)
    puts "node_#{k}_feature #{FEATURES[feature]}"
    puts format("node_%d_threshold %.6f", k, threshold)
  end
end
puts "leaves #{nodes.count { |node| node in Leaf }}"

train_correct = train.count { |x, label| predict(tree, x) == label }
correct = test.count { |x, label| predict(tree, x) == label }
puts format("train_accuracy %.6f", train_correct.fdiv(train.length))
puts "correct #{correct}"
puts format("accuracy %.6f", correct.fdiv(test.length))

Each kind of node is its own class, made with Data.define: Leaf holds a label, and Split holds a feature, a threshold, and two children. Data objects are frozen and have no setters, so leaf.label = "no" raises NoMethodError, which suits a tree that never changes once grown. predict and the printing loop use case/in to match on the class and pull out the fields in one step. If no pattern matched, Ruby would raise NoMatchingPatternError rather than return nil. The candidate thresholds come from uniq.sort.each_cons(2), which yields each pair of neighboring values. Neither the standard library nor the bundled gems include a decision tree, so there was no built-in to skip.

Rust

From scratchTested automaticallyRust 1.99.1
main.rsView on GitHub
//! A decision tree classifier (CART with Gini impurity), using only the
//! standard library.
//!
//! Grows a tree on the training days by always making the split that leaves
//! the two sides purest, then predicts rain for the test days. Prints the tree
//! in preorder: each node is either a split (feature and threshold) or a leaf
//! (its prediction).

use std::env;
use std::fs;

const FEATURES: [&str; 3] = ["humidity", "pressure", "wind"];
const MAX_DEPTH: usize = 3;
const MIN_SIZE: usize = 5;

struct Day {
    x: [f64; 3],
    rain: bool,
}

/// A node is exactly one of two things, and the enum says so. A tree is
/// recursive, so the children sit behind a Box (a pointer to the heap);
/// otherwise a Node would have to contain itself and have no fixed size.
enum Node {
    Leaf(bool),
    Split {
        feature: usize,
        threshold: f64,
        left: Box<Node>,
        right: Box<Node>,
    },
}

/// Splits the file into training and test days. There is no CSV parser in
/// the standard library; the file has no quoted fields, so commas suffice.
fn read(path: &str) -> (Vec<Day>, Vec<Day>) {
    let text = fs::read_to_string(path).expect("cannot read data file");
    let mut lines = text.lines();
    let header: Vec<&str> = lines.next().expect("empty file").split(',').collect();
    let column = |name| {
        header
            .iter()
            .position(|&h| h == name)
            .expect("column not found")
    };
    let (feature_ix, rain_ix, split_ix) = (FEATURES.map(column), column("rain"), column("split"));

    let (mut train, mut test) = (Vec::new(), Vec::new());
    for line in lines {
        let fields: Vec<&str> = line.split(',').collect();
        let day = Day {
            x: feature_ix.map(|i| fields[i].parse().unwrap()),
            rain: fields[rain_ix] == "yes",
        };
        if fields[split_ix] == "train" {
            train.push(day);
        } else {
            test.push(day);
        }
    }
    (train, test)
}

fn count_yes(days: &[&Day]) -> usize {
    days.iter().filter(|d| d.rain).count()
}

/// Chance that two days drawn at random (with replacement) disagree.
fn gini(days: &[&Day]) -> f64 {
    let p = count_yes(days) as f64 / days.len() as f64;
    1.0 - p * p - (1.0 - p) * (1.0 - p)
}

/// Rain only on a strict majority; an even split predicts no rain.
fn majority(days: &[&Day]) -> bool {
    2 * count_yes(days) > days.len()
}

/// The (score, feature, threshold) with the lowest weighted Gini impurity.
/// Features are tried in column order and thresholds from smallest to largest;
/// only a strictly lower score replaces the best so far.
fn best_split(days: &[&Day]) -> Option<(f64, usize, f64)> {
    let mut best: Option<(f64, usize, f64)> = None;
    for f in 0..FEATURES.len() {
        let mut values: Vec<f64> = days.iter().map(|d| d.x[f]).collect();
        values.sort_by(f64::total_cmp); // f64 isn't Ord, so sort() won't compile
        values.dedup(); // drop repeats, leaving the distinct values
        for pair in values.windows(2) {
            let threshold = (pair[0] + pair[1]) / 2.0;
            let (left, right): (Vec<&Day>, Vec<&Day>) =
                days.iter().partition(|d| d.x[f] <= threshold);
            let score = (left.len() as f64 * gini(&left) + right.len() as f64 * gini(&right))
                / days.len() as f64;
            if best.is_none_or(|(s, _, _)| score < s) {
                best = Some((score, f, threshold));
            }
        }
    }
    best
}

/// Days are passed as references, so growing the tree never copies them.
fn grow(days: &[&Day], depth: usize) -> Node {
    let impurity = gini(days);
    if impurity == 0.0 || days.len() < MIN_SIZE || depth == MAX_DEPTH {
        return Node::Leaf(majority(days));
    }
    match best_split(days) {
        Some((score, feature, threshold)) if score < impurity => {
            let (left, right): (Vec<&Day>, Vec<&Day>) =
                days.iter().partition(|d| d.x[feature] <= threshold);
            Node::Split {
                feature,
                threshold,
                left: Box::new(grow(&left, depth + 1)),
                right: Box::new(grow(&right, depth + 1)),
            }
        }
        _ => Node::Leaf(majority(days)), // no split makes the sides purer
    }
}

fn predict(node: &Node, x: &[f64; 3]) -> bool {
    match node {
        Node::Leaf(rain) => *rain,
        Node::Split { feature, threshold, left, right } => {
            predict(if x[*feature] <= *threshold { left } else { right }, x)
        }
    }
}

/// Every node, parents before children, left subtree before right.
fn preorder<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
    out.push(node);
    if let Node::Split { left, right, .. } = node {
        preorder(left, out);
        preorder(right, out);
    }
}

fn yes_no(rain: bool) -> &'static str {
    if rain { "yes" } else { "no" }
}

fn main() {
    let path = env::args().nth(1).expect("usage: main <data.csv>");
    let (train, test) = read(&path);
    let tree = grow(&train.iter().collect::<Vec<&Day>>(), 0);

    println!("train_count {}", train.len());
    println!("test_count {}", test.len());
    let mut nodes = Vec::new();
    preorder(&tree, &mut nodes);
    let mut leaves = 0;
    for (i, node) in nodes.iter().enumerate() {
        let k = i + 1;
        match node {
            Node::Leaf(rain) => {
                println!("node_{k}_predict {}", yes_no(*rain));
                leaves += 1;
            }
            Node::Split { feature, threshold, .. } => {
                println!("node_{k}_feature {}", FEATURES[*feature]);
                println!("node_{k}_threshold {threshold:.6}");
            }
        }
    }
    println!("leaves {leaves}");

    let correct = |days: &[Day]| days.iter().filter(|d| predict(&tree, &d.x) == d.rain).count();
    println!("train_accuracy {:.6}", correct(&train) as f64 / train.len() as f64);
    println!("correct {}", correct(&test));
    println!("accuracy {:.6}", correct(&test) as f64 / test.len() as f64);
}

The tree is an enum: a Node is either a Leaf(bool) or a Split with a feature, a threshold, and two children. The children are Box<Node>; without the box, rustc rejects the type with “recursive type Node has infinite size”. Every match on a node must cover both variants, so dropping the Leaf arm from predict is a compile error, “non-exhaustive patterns”. The days are borrowed, not copied: each node works on a Vec<&Day>. Sorting the feature values takes sort_by(f64::total_cmp), because sort() needs Ord, which f64 lacks, and dedup then drops the repeats. The standard library has no decision tree, so there was no built-in to skip.

Swift

From scratchTested automaticallySwift 6.0.3
main.swiftView on GitHub
// A decision tree classifier (CART with Gini impurity), using only Swift's
// standard library and Foundation, which ships with it.
//
// Grows a tree on the training days by always making the split that leaves
// the two sides purest, then predicts rain for the test days. Prints the tree
// in preorder: each node is either a split (feature and threshold) or a leaf
// (its prediction).

import Foundation

let features = ["humidity", "pressure", "wind"]
let maxDepth = 3
let minSize = 5

struct Day {
    let x: [Double]
    let rain: Bool
}

// A node is exactly one of two things, and the enum says so. A case that
// holds a Node inside a Node must be marked indirect, which tells Swift to
// keep the children behind a reference it manages for us.
indirect enum Node {
    case leaf(Bool)
    case split(feature: Int, threshold: Double, left: Node, right: Node)
}

// The named column of a CSV file, as text. A function that can fail is
// marked throws, and every call to it is marked try.
func readColumn(_ path: String, _ name: String) throws -> [String] {
    let text = try String(contentsOfFile: path, encoding: .utf8)
    // split skips empty pieces, so a blank last line doesn't become a row.
    var lines = text.split(separator: "\n")
    let header = lines.removeFirst().split(separator: ",").map(String.init)
    guard let index = header.firstIndex(of: name) else {
        fatalError("no column \(name)")
    }
    return lines.map { line in
        String(line.split(separator: ",", omittingEmptySubsequences: false)[index])
    }
}

func countYes(_ days: [Day]) -> Int {
    days.filter { $0.rain }.count
}

// Chance that two days drawn at random (with replacement) disagree.
func gini(_ days: [Day]) -> Double {
    let p = Double(countYes(days)) / Double(days.count)
    return 1 - p * p - (1 - p) * (1 - p)
}

// Rain only on a strict majority; an even split predicts no rain.
func majority(_ days: [Day]) -> Bool {
    2 * countYes(days) > days.count
}

// The (score, feature, threshold) with the lowest weighted Gini impurity, or
// nil if no feature has two distinct values. Features are tried in column
// order and thresholds from smallest to largest; only a strictly lower score
// replaces the best so far.
func bestSplit(_ days: [Day]) -> (score: Double, feature: Int, threshold: Double)? {
    var best: (score: Double, feature: Int, threshold: Double)? = nil
    for f in features.indices {
        // A Set keeps one copy of each value; sorted() turns it into an array.
        let values = Set(days.map { $0.x[f] }).sorted()
        for (a, b) in zip(values, values.dropFirst()) {
            let threshold = (a + b) / 2
            let left = days.filter { $0.x[f] <= threshold }
            let right = days.filter { $0.x[f] > threshold }
            let score = (Double(left.count) * gini(left) + Double(right.count) * gini(right))
                / Double(days.count)
            // best?.score is nil while best is nil, and ?? supplies infinity.
            if score < (best?.score ?? .infinity) {
                best = (score, f, threshold)
            }
        }
    }
    return best
}

func grow(_ days: [Day], depth: Int = 0) -> Node {
    let impurity = gini(days)
    if impurity == 0 || days.count < minSize || depth == maxDepth {
        return .leaf(majority(days))
    }
    // guard let unwraps the optional, or leaves the function if it can't.
    guard let best = bestSplit(days), best.score < impurity else {
        return .leaf(majority(days)) // no split makes the sides purer
    }
    let left = days.filter { $0.x[best.feature] <= best.threshold }
    let right = days.filter { $0.x[best.feature] > best.threshold }
    return .split(feature: best.feature, threshold: best.threshold,
                  left: grow(left, depth: depth + 1),
                  right: grow(right, depth: depth + 1))
}

// A switch on an enum must cover every case, or it won't compile.
func predict(_ node: Node, _ x: [Double]) -> Bool {
    switch node {
    case .leaf(let rain):
        return rain
    case .split(let feature, let threshold, let left, let right):
        return predict(x[feature] <= threshold ? left : right, x)
    }
}

// Every node, parents before children, left subtree before right.
func preorder(_ node: Node) -> [Node] {
    switch node {
    case .leaf:
        return [node]
    case .split(_, _, let left, let right):
        return [node] + preorder(left) + preorder(right)
    }
}

let path = CommandLine.arguments[1]
let columns = try features.map { try readColumn(path, $0).map { Double($0)! } }
let rain = try readColumn(path, "rain")
let split = try readColumn(path, "split")

var train: [Day] = []
var test: [Day] = []
for i in rain.indices {
    let day = Day(x: columns.map { $0[i] }, rain: rain[i] == "yes")
    if split[i] == "train" { train.append(day) } else { test.append(day) }
}

let tree = grow(train)

print("train_count", train.count)
print("test_count", test.count)
var leaves = 0
for (i, node) in preorder(tree).enumerated() {
    let k = i + 1
    switch node {
    case .leaf(let rain):
        print("node_\(k)_predict", rain ? "yes" : "no")
        leaves += 1
    case .split(let feature, let threshold, _, _):
        print("node_\(k)_feature", features[feature])
        print("node_\(k)_threshold", String(format: "%.6f", threshold))
    }
}
print("leaves", leaves)

func correct(_ days: [Day]) -> Int {
    days.filter { predict(tree, $0.x) == $0.rain }.count
}

print("train_accuracy", String(format: "%.6f", Double(correct(train)) / Double(train.count)))
print("correct", correct(test))
print("accuracy", String(format: "%.6f", Double(correct(test)) / Double(test.count)))

The tree is an enum: a Node is either .leaf(Bool) or .split with a feature, a threshold, and two children. Because the enum holds itself, we mark it indirect, and Swift stores the children behind references it manages, with no Box to write; leave the keyword off and swiftc stops with “recursive enum ‘Node’ is not marked ‘indirect’”. A switch on a node must cover both cases, so dropping .leaf from predict fails with “switch must be exhaustive”. grow calls itself on each side. Each feature’s values go into a Set, which drops repeats, then sorted(), and we try the midpoint of each neighboring pair. Swift has no decision tree, so there was no built-in to skip.

Tcl

From scratchTested automaticallyTcl 9.0.1
main.tclView on GitHub
# A decision tree classifier (CART with Gini impurity), using only Tcl's
# built-in commands.
#
# Tcl has no decision tree (nor does Tcllib, which isn't installed anyway),
# so the split search, the growing, and the predicting are all written out.
# The tree is a nested list: a leaf is {leaf yes}, and a split is
# {split feature threshold LEFT RIGHT}, where LEFT and RIGHT are trees too.

set FEATURES {humidity pressure wind}
set MAX_DEPTH 3
set MIN_SIZE 5

# Each day is a two-item list: {humidity pressure wind} and its label.
proc read_days {path} {
    set f [open $path r]
    set header [split [gets $f] ,]
    set cols {}
    foreach name $::FEATURES {
        lappend cols [lsearch -exact $header $name]
    }
    set rain [lsearch -exact $header rain]
    set part [lsearch -exact $header split]
    set train {}
    set test {}
    while {[gets $f line] >= 0} {
        set row [split $line ,]
        set x {}
        foreach c $cols {
            lappend x [lindex $row $c]
        }
        if {[lindex $row $part] eq "train"} {
            lappend train [list $x [lindex $row $rain]]
        } else {
            lappend test [list $x [lindex $row $rain]]
        }
    }
    close $f
    return [list $train $test]
}

# Chance that two days drawn at random (with replacement) disagree.
proc gini {labels} {
    set p [expr {double([llength [lsearch -all -exact $labels yes]]) / [llength $labels]}]
    return [expr {1 - $p * $p - (1 - $p) * (1 - $p)}]
}

# An even split predicts "no".
proc majority {labels} {
    set yes [llength [lsearch -all -exact $labels yes]]
    return [expr {$yes > [llength $labels] / 2.0 ? "yes" : "no"}]
}

proc labels_of {days} {
    set labels {}
    foreach day $days {
        lappend labels [lindex $day 1]
    }
    return $labels
}

# The {score feature threshold} with the lowest weighted Gini impurity, or
# an empty list if no feature has two distinct values. Features are tried in
# column order and thresholds from smallest to largest; only a strictly
# lower score replaces the best so far.
proc best_split {days} {
    set best {}
    set n [llength $days]
    for {set f 0} {$f < [llength $::FEATURES]} {incr f} {
        set column {}
        foreach day $days {
            lappend column [lindex $day 0 $f]
        }
        set values [lsort -real -unique $column]
        for {set i 1} {$i < [llength $values]} {incr i} {
            # 2.0, not 2: humidity is whole numbers, and (63 + 64) / 2 would be 63.
            set threshold [expr {([lindex $values $i-1] + [lindex $values $i]) / 2.0}]
            set left {}
            set right {}
            foreach day $days {
                if {[lindex $day 0 $f] <= $threshold} {
                    lappend left [lindex $day 1]
                } else {
                    lappend right [lindex $day 1]
                }
            }
            set score [expr {([llength $left] * [gini $left]
                              + [llength $right] * [gini $right]) / $n}]
            if {$best eq {} || $score < [lindex $best 0]} {
                set best [list $score $f $threshold]
            }
        }
    }
    return $best
}

proc grow {days {depth 0}} {
    set labels [labels_of $days]
    set impurity [gini $labels]
    if {$impurity == 0 || [llength $days] < $::MIN_SIZE || $depth == $::MAX_DEPTH} {
        return [list leaf [majority $labels]]
    }
    set best [best_split $days]
    if {$best eq {} || [lindex $best 0] >= $impurity} {
        return [list leaf [majority $labels]]   ;# no split makes the sides purer
    }
    lassign $best score f threshold
    set left {}
    set right {}
    foreach day $days {
        if {[lindex $day 0 $f] <= $threshold} {
            lappend left $day
        } else {
            lappend right $day
        }
    }
    return [list split $f $threshold \
        [grow $left [expr {$depth + 1}]] [grow $right [expr {$depth + 1}]]]
}

proc predict {node x} {
    while {[lindex $node 0] eq "split"} {
        lassign $node - f threshold left right
        set node [expr {[lindex $x $f] <= $threshold ? $left : $right}]
    }
    return [lindex $node 1]
}

# Prints each node in preorder, numbering from k, and returns the next
# number. upvar lets the proc add to the caller's leaf count.
proc show {node k leavesVar} {
    upvar $leavesVar leaves
    if {[lindex $node 0] eq "leaf"} {
        puts "node_${k}_predict [lindex $node 1]"
        incr leaves
        return [expr {$k + 1}]
    }
    lassign $node - f threshold left right
    puts "node_${k}_feature [lindex $::FEATURES $f]"
    say node_${k}_threshold $threshold
    set k [show $left [expr {$k + 1}] leaves]
    return [show $right $k leaves]
}

proc count_correct {tree days} {
    set correct 0
    foreach day $days {
        lassign $day x label
        if {[predict $tree $x] eq $label} {
            incr correct
        }
    }
    return $correct
}

proc say {name value} {
    puts "$name [format %.6f $value]"
}

lassign [read_days [lindex $argv 0]] train test
set tree [grow $train]

puts "train_count [llength $train]"
puts "test_count [llength $test]"
set leaves 0
show $tree 1 leaves
puts "leaves $leaves"

set correct [count_correct $tree $test]
say train_accuracy [expr {double([count_correct $tree $train]) / [llength $train]}]
puts "correct $correct"
say accuracy [expr {double($correct) / [llength $test]}]

Since every Tcl value is a string, we hold the tree as a nested list: a leaf is {leaf yes}, and a split is {split feature threshold LEFT RIGHT}, where each side is another tree. grow calls itself on each side, and lindex takes several indexes at once, so [lindex $tree 4 3 1] walks down into a branch. lsort -real -unique gives each feature’s distinct values, and you divide neighbors by 2.0, not 2, because (63 + 64) / 2 is 63. upvar lets the printing proc count leaves in its caller. Tcllib’s struct::tree isn’t installed, and Tcl has no decision tree, so the split search is a plain loop.