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 of a group had rain,
That’s 0 for a group that all agrees and 0.5 for an even mix. The 150 training days, with , score 0.4570. To score a split, we average the two sides’ impurities, weighted by their sizes.
The method
- 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.
- Split and repeat on each side.
- 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 . 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
| humidity | pressure | wind | rain | split |
|---|---|---|---|---|
| 54 | 1007.4 | 35 | no | train |
| 55 | 1030.0 | 37 | no | train |
| 48 | 1011.3 | 39 | no | train |
| 98 | 1026.5 | 2 | no | train |
| 44 | 1013.7 | 9 | no | train |
| 82 | 1012.6 | 28 | no | train |
| 55 | 993.9 | 11 | no | train |
| 58 | 1020.6 | 26 | no | train |
First 8 of 200 rows · Download data.csv
Expected results
Every implementation must print these, to within a relative tolerance of 10⁻⁴.
train_count | 150 |
test_count | 50 |
node_1_feature | pressure |
node_1_threshold | 1000.950000 |
node_2_feature | humidity |
node_2_threshold | 63.500000 |
node_3_feature | humidity |
node_3_threshold | 54.000000 |
node_4_predict | yes |
node_5_predict | no |
node_6_predict | yes |
node_7_feature | humidity |
node_7_threshold | 72.500000 |
node_8_feature | wind |
node_8_threshold | 31.500000 |
node_9_predict | no |
node_10_predict | no |
node_11_feature | pressure |
node_11_threshold | 1013.700000 |
node_12_predict | yes |
node_13_predict | no |
leaves | 7 |
train_accuracy | 0.926667 |
correct | 44 |
accuracy | 0.880000 |
Implementations
"""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.
-- 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.
⍝ 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
)OFFAPL 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.
# 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.
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 RETURNThis 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.
/* 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.
// 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.
*> 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.
;;;; 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.
# 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.
! 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_treeThe 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.
// 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.
-- 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.
// 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.
// 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.
# 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.
-- 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.
(* 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.
% 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.
{ 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.
# 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
// 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.
-- 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.
% 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.
# 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.
# 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.
//! 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.
// 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.
# 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.