ebele-entropy-tree 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ebelechukwu Umezude
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md LICENSE report.md PUBLISHING.md
2
+ recursive-include examples *.py *.csv
3
+ recursive-include tests *.py
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ebele-entropy-tree
3
+ Version: 0.1.0
4
+ Summary: An educational categorical ID3 decision tree built from scratch
5
+ Author: Ebelechukwu Umezude
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # Entropy decision tree from scratch
13
+
14
+ Educational implementation of ID3 for categorical data. The learning algorithm
15
+ uses only the Python standard library: no scikit-learn decision tree is used.
16
+ Prepared with AI assistance; review the code and report before submission.
17
+
18
+ ## Install locally and run
19
+
20
+ Open a terminal in this project folder (the folder containing pyproject.toml):
21
+
22
+ ```bash
23
+ python3 -m pip install -e .
24
+ python3 examples/tennis_demo.py
25
+ python3 -m unittest discover -s tests -v
26
+ ```
27
+
28
+ Open `tree.svg` in your browser for a graphical display. The SVG is generated
29
+ from the learned tree, not hard-coded. `model.display()` also prints the rules.
30
+
31
+ ## Use the package
32
+
33
+ ```python
34
+ from ebele_tree import DecisionTreeClassifier
35
+ X = [{"Outlook": "Sunny"}, {"Outlook": "Rain"}, {"Outlook": "Overcast"}]
36
+ y = ["No", "Yes", "Yes"]
37
+ model = DecisionTreeClassifier(max_depth=2).fit(X, y)
38
+ print(model.predict([{"Outlook": "Sunny"}]))
39
+ model.display()
40
+ model.export_svg("my_tree.svg")
41
+ ```
42
+
43
+ In a notebook, a pandas DataFrame can be converted with
44
+ `X.to_dict(orient="records")`; labels can be passed as `y.tolist()`.
45
+ To display the exported SVG in a notebook:
46
+
47
+ ```python
48
+ from IPython.display import SVG, display
49
+ model.export_svg("tree.svg")
50
+ display(SVG(filename="tree.svg"))
51
+ ```
52
+
53
+ ## How learning works
54
+
55
+ 1. Count the target labels and calculate H(S) = -sum(p * log2(p)).
56
+ 2. For each feature, group rows by category and calculate the weighted child entropy.
57
+ 3. Gain = parent entropy minus weighted child entropy.
58
+ 4. Choose the feature with the largest gain.
59
+ 5. Repeat separately within each branch, removing the selected feature.
60
+ 6. Stop at a pure node, no useful gain, no remaining feature, or a size limit.
61
+ 7. A stopped node predicts its majority class. Ties use first appearance order.
62
+
63
+ `max_depth` controls depth (root depth is 0). `min_samples_split` controls how
64
+ many examples are required before splitting. `min_gain` controls the minimum
65
+ useful gain. A category unseen during fitting uses the current node's majority.
66
+
67
+ ## Tennis example
68
+
69
+ The 14 rows reproduce the lecture's Play Tennis example. `Day` is excluded as
70
+ an identifier. Categories are used directly; no numeric encoding is needed.
71
+
72
+ | Feature | Root information gain |
73
+ |---|---:|
74
+ | Outlook | 0.246750 |
75
+ | Temperature | 0.029223 |
76
+ | Humidity | 0.151836 |
77
+ | Wind | 0.048127 |
78
+
79
+ The learned tree starts with Outlook; Sunny uses Humidity and Rain uses Wind.
80
+ Overcast immediately predicts Yes. With max_depth=2, training accuracy is 1.0.
81
+ That is a training demonstration, not evidence of perfect generalisation.
82
+
83
+ ## Scope and limitations
84
+
85
+ Categorical, hashable, nonmissing inputs only. Numeric values are treated as
86
+ discrete categories; continuous threshold splits are not implemented. This is
87
+ an educational implementation, not an efficient model for the large irrigation
88
+ dataset. Graphical boxes are sized for short labels such as the tennis example.
89
+
90
+ See PUBLISHING.md for GitHub and PyPI instructions. The package name
91
+ `ebele-entropy-tree` is provisional: availability has not been checked.
@@ -0,0 +1,84 @@
1
+ # Finish and publish
2
+
3
+ ## 1. Check locally
4
+
5
+ Extract the zip, open a terminal inside decision-tree-homework, and run:
6
+
7
+ ```bash
8
+ python3 -m venv .venv
9
+ source .venv/bin/activate
10
+ python -m pip install --upgrade pip
11
+ python -m pip install -e .
12
+ python examples/tennis_demo.py
13
+ python -m unittest discover -s tests -v
14
+ ```
15
+
16
+ Open tree.svg in a browser. Keep .venv out of GitHub.
17
+
18
+ ## 2. GitHub
19
+
20
+ Create a repository in your GitHub account, for example `decision-tree-from-scratch`.
21
+ Use GitHub's Add file > Upload files to upload the CONTENTS of the extracted
22
+ folder, preserving src/, examples/ and tests/. Do not upload just the zip.
23
+ If using a terminal instead, follow GitHub's displayed commands for your new
24
+ repository. Do not upload .venv/, build/, dist/, API tokens or account secrets.
25
+
26
+ Replace the GitHub placeholder in report.md with your actual repository URL.
27
+ Keep the large irrigation CSVs out of this homework repository.
28
+ The separate Kaggle repository should contain the completed irrigation notebook,
29
+ a README describing its dataset/metric/results and environment requirements.
30
+ Use a data-source link instead of copying competition data without permission.
31
+
32
+ ## 3. Prepare PyPI
33
+
34
+ PyPI (Python Package Index) is https://pypi.org, not the alternative interpreter PyPy.
35
+ Create/verify your account and complete the security setup required by PyPI.
36
+ Check whether the distribution name `ebele-entropy-tree` is available.
37
+ If taken, edit only project.name in pyproject.toml to a unique name; the import
38
+ name remains ebele_tree. Do this before building.
39
+
40
+ ## 4. Build and check
41
+
42
+ ```bash
43
+ python -m pip install --upgrade build twine
44
+ python -m build
45
+ python -m twine check dist/*
46
+ ```
47
+
48
+ This should produce a wheel (.whl) and source archive (.tar.gz).
49
+ If you change the package name/version, remove old files from dist/ before
50
+ rebuilding, so you do not accidentally upload a previous build.
51
+
52
+ ## 5. Publish to the real PyPI for the bonus
53
+
54
+ After you have reviewed the package and are ready to make it public:
55
+
56
+ ```bash
57
+ python -m twine upload dist/*
58
+ ```
59
+
60
+ Use a PyPI API token via the secure prompt (username __token__ if requested).
61
+ Never place tokens in code, report.md, GitHub, screenshots or this chat.
62
+ TestPyPI is a separate service and does not by itself satisfy a real PyPI release.
63
+
64
+ When upload succeeds, open the package's actual PyPI page. In a fresh environment,
65
+ install it using the exact distribution name you published:
66
+
67
+ ```bash
68
+ python -m pip install ebele-entropy-tree==0.1.0
69
+ python -c "from ebele_tree import entropy; print(entropy(['Yes', 'No']))"
70
+ ```
71
+
72
+ If you chose a different name, replace ebele-entropy-tree in the install command.
73
+ Expected output is 1.0. Add the actual PyPI URL and install command to report.md,
74
+ update the bonus status to describe what actually succeeded, then update GitHub.
75
+ Published versions cannot be overwritten: use a higher version for later fixes.
76
+
77
+ ## 6. Submit the assignment
78
+
79
+ Check report.md has real links and no unfinished placeholders. Read it and edit
80
+ the description to match your understanding and actual actions. Attach report.md
81
+ to the Teams assignment and click Turn in before the displayed 23:59 deadline.
82
+
83
+ Official packaging guide:
84
+ https://packaging.python.org/en/latest/tutorials/packaging-projects/
@@ -0,0 +1,80 @@
1
+ # Entropy decision tree from scratch
2
+
3
+ Educational implementation of ID3 for categorical data. The learning algorithm
4
+ uses only the Python standard library: no scikit-learn decision tree is used.
5
+ Prepared with AI assistance; review the code and report before submission.
6
+
7
+ ## Install locally and run
8
+
9
+ Open a terminal in this project folder (the folder containing pyproject.toml):
10
+
11
+ ```bash
12
+ python3 -m pip install -e .
13
+ python3 examples/tennis_demo.py
14
+ python3 -m unittest discover -s tests -v
15
+ ```
16
+
17
+ Open `tree.svg` in your browser for a graphical display. The SVG is generated
18
+ from the learned tree, not hard-coded. `model.display()` also prints the rules.
19
+
20
+ ## Use the package
21
+
22
+ ```python
23
+ from ebele_tree import DecisionTreeClassifier
24
+ X = [{"Outlook": "Sunny"}, {"Outlook": "Rain"}, {"Outlook": "Overcast"}]
25
+ y = ["No", "Yes", "Yes"]
26
+ model = DecisionTreeClassifier(max_depth=2).fit(X, y)
27
+ print(model.predict([{"Outlook": "Sunny"}]))
28
+ model.display()
29
+ model.export_svg("my_tree.svg")
30
+ ```
31
+
32
+ In a notebook, a pandas DataFrame can be converted with
33
+ `X.to_dict(orient="records")`; labels can be passed as `y.tolist()`.
34
+ To display the exported SVG in a notebook:
35
+
36
+ ```python
37
+ from IPython.display import SVG, display
38
+ model.export_svg("tree.svg")
39
+ display(SVG(filename="tree.svg"))
40
+ ```
41
+
42
+ ## How learning works
43
+
44
+ 1. Count the target labels and calculate H(S) = -sum(p * log2(p)).
45
+ 2. For each feature, group rows by category and calculate the weighted child entropy.
46
+ 3. Gain = parent entropy minus weighted child entropy.
47
+ 4. Choose the feature with the largest gain.
48
+ 5. Repeat separately within each branch, removing the selected feature.
49
+ 6. Stop at a pure node, no useful gain, no remaining feature, or a size limit.
50
+ 7. A stopped node predicts its majority class. Ties use first appearance order.
51
+
52
+ `max_depth` controls depth (root depth is 0). `min_samples_split` controls how
53
+ many examples are required before splitting. `min_gain` controls the minimum
54
+ useful gain. A category unseen during fitting uses the current node's majority.
55
+
56
+ ## Tennis example
57
+
58
+ The 14 rows reproduce the lecture's Play Tennis example. `Day` is excluded as
59
+ an identifier. Categories are used directly; no numeric encoding is needed.
60
+
61
+ | Feature | Root information gain |
62
+ |---|---:|
63
+ | Outlook | 0.246750 |
64
+ | Temperature | 0.029223 |
65
+ | Humidity | 0.151836 |
66
+ | Wind | 0.048127 |
67
+
68
+ The learned tree starts with Outlook; Sunny uses Humidity and Rain uses Wind.
69
+ Overcast immediately predicts Yes. With max_depth=2, training accuracy is 1.0.
70
+ That is a training demonstration, not evidence of perfect generalisation.
71
+
72
+ ## Scope and limitations
73
+
74
+ Categorical, hashable, nonmissing inputs only. Numeric values are treated as
75
+ discrete categories; continuous threshold splits are not implemented. This is
76
+ an educational implementation, not an efficient model for the large irrigation
77
+ dataset. Graphical boxes are sized for short labels such as the tennis example.
78
+
79
+ See PUBLISHING.md for GitHub and PyPI instructions. The package name
80
+ `ebele-entropy-tree` is provisional: availability has not been checked.
@@ -0,0 +1,15 @@
1
+ Day,Outlook,Temperature,Humidity,Wind,Play_Tennis
2
+ D1,Sunny,Hot,High,Weak,No
3
+ D2,Sunny,Hot,High,Strong,No
4
+ D3,Overcast,Hot,High,Weak,Yes
5
+ D4,Rain,Mild,High,Weak,Yes
6
+ D5,Rain,Cool,Normal,Weak,Yes
7
+ D6,Rain,Cool,Normal,Strong,No
8
+ D7,Overcast,Cool,Normal,Strong,Yes
9
+ D8,Sunny,Mild,High,Weak,No
10
+ D9,Sunny,Cool,Normal,Weak,Yes
11
+ D10,Rain,Mild,Normal,Weak,Yes
12
+ D11,Sunny,Mild,Normal,Strong,Yes
13
+ D12,Overcast,Mild,High,Strong,Yes
14
+ D13,Overcast,Hot,Normal,Weak,Yes
15
+ D14,Rain,Mild,High,Strong,No
@@ -0,0 +1,17 @@
1
+ import csv
2
+ from pathlib import Path
3
+ from ebele_tree import DecisionTreeClassifier, entropy, information_gain
4
+
5
+ with Path(__file__).with_name("tennis.csv").open() as f:
6
+ rows = list(csv.DictReader(f))
7
+ y = [row["Play_Tennis"] for row in rows]
8
+ X = [{k: v for k, v in row.items() if k not in ("Day", "Play_Tennis")} for row in rows]
9
+ print("Dataset entropy:", round(entropy(y), 6))
10
+ for feature in X[0]:
11
+ print(feature, "gain:", round(information_gain([r[feature] for r in X], y), 6))
12
+ model = DecisionTreeClassifier(max_depth=2).fit(X, y)
13
+ model.display()
14
+ print("Training accuracy (not unseen-data accuracy):", model.score(X, y))
15
+ print("New Sunny/High day:", model.predict([X[0]]))
16
+ model.export_svg("tree.svg")
17
+ print("Open tree.svg in your browser to see the tree.")
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ebele-entropy-tree"
7
+ version = "0.1.0"
8
+ description = "An educational categorical ID3 decision tree built from scratch"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{name = "Ebelechukwu Umezude"}]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,60 @@
1
+ # Decision Tree algorithm — report
2
+
3
+ Name: Ebelechukwu Umezude
4
+
5
+ GitHub repository: https://github.com/ebyumezude/decision-tree-from-scratch
6
+
7
+ PyPI package: **NOT PUBLISHED YET — replace with the actual public PyPI URL after publishing**
8
+
9
+ ## Objective
10
+
11
+ Implement a decision tree classifier from scratch using entropy and information
12
+ gain, with a hyperparameter controlling tree size and a way to display the tree.
13
+
14
+ ## Implementation
15
+
16
+ The package implements categorical ID3 using Python's standard library.
17
+ Entropy measures class impurity. For each candidate feature, information gain
18
+ subtracts the weighted entropy of its branches from the current node's entropy.
19
+ The feature with maximum gain becomes the split. The procedure repeats within
20
+ each branch until a stopping rule applies.
21
+
22
+ `max_depth` limits depth; the root is at depth zero. `min_samples_split` and
23
+ `min_gain` provide additional stopping rules. Leaves predict the majority class.
24
+ Unseen categories fall back to the majority class at the current node.
25
+ The row identifier Day is excluded. Categories are used directly without encoding.
26
+
27
+ ## Example and results
28
+
29
+ The lecture's 14-row tennis dataset has nine Yes labels and five No labels.
30
+ Its entropy is approximately 0.940286. Root gains are Outlook 0.246750,
31
+ Humidity 0.151836, Wind 0.048127 and Temperature 0.029223.
32
+ The learned root is Outlook. Sunny splits by Humidity; Rain splits by Wind;
33
+ Overcast predicts Yes directly. At max_depth=2 the tree fits all 14 training
34
+ examples. At max_depth=0 it predicts the overall majority class, Yes.
35
+ Training accuracy is not an estimate of performance on unseen data.
36
+
37
+ ## Display bonus
38
+
39
+ `display()` prints the learned rules. `export_svg()` generates a graphical
40
+ SVG tree that can be opened in a browser or displayed in a notebook.
41
+
42
+ ## Packaging bonus status
43
+
44
+ The project includes pyproject.toml, package source, README, a license and tests.
45
+ It is prepared for publishing, but being packaged locally or uploaded to GitHub
46
+ is not the same as being published on PyPI. Update this section after the
47
+ public release and include the real package URL and install command.
48
+
49
+ ## Verification and limitations
50
+
51
+ Tests cover known entropy/gain values, the learned tennis structure, depth
52
+ limits, unseen categories, stopping rules, invalid input, and SVG output.
53
+ The implementation supports categorical data, not continuous threshold splits.
54
+ Missing values must be cleaned first. The dataset is small and no independent
55
+ test accuracy is claimed.
56
+
57
+ ## Assistance
58
+
59
+ Code and explanatory material were prepared with ChatGPT assistance. I should
60
+ review, run and understand the implementation before submitting this report.
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ebele-entropy-tree
3
+ Version: 0.1.0
4
+ Summary: An educational categorical ID3 decision tree built from scratch
5
+ Author: Ebelechukwu Umezude
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # Entropy decision tree from scratch
13
+
14
+ Educational implementation of ID3 for categorical data. The learning algorithm
15
+ uses only the Python standard library: no scikit-learn decision tree is used.
16
+ Prepared with AI assistance; review the code and report before submission.
17
+
18
+ ## Install locally and run
19
+
20
+ Open a terminal in this project folder (the folder containing pyproject.toml):
21
+
22
+ ```bash
23
+ python3 -m pip install -e .
24
+ python3 examples/tennis_demo.py
25
+ python3 -m unittest discover -s tests -v
26
+ ```
27
+
28
+ Open `tree.svg` in your browser for a graphical display. The SVG is generated
29
+ from the learned tree, not hard-coded. `model.display()` also prints the rules.
30
+
31
+ ## Use the package
32
+
33
+ ```python
34
+ from ebele_tree import DecisionTreeClassifier
35
+ X = [{"Outlook": "Sunny"}, {"Outlook": "Rain"}, {"Outlook": "Overcast"}]
36
+ y = ["No", "Yes", "Yes"]
37
+ model = DecisionTreeClassifier(max_depth=2).fit(X, y)
38
+ print(model.predict([{"Outlook": "Sunny"}]))
39
+ model.display()
40
+ model.export_svg("my_tree.svg")
41
+ ```
42
+
43
+ In a notebook, a pandas DataFrame can be converted with
44
+ `X.to_dict(orient="records")`; labels can be passed as `y.tolist()`.
45
+ To display the exported SVG in a notebook:
46
+
47
+ ```python
48
+ from IPython.display import SVG, display
49
+ model.export_svg("tree.svg")
50
+ display(SVG(filename="tree.svg"))
51
+ ```
52
+
53
+ ## How learning works
54
+
55
+ 1. Count the target labels and calculate H(S) = -sum(p * log2(p)).
56
+ 2. For each feature, group rows by category and calculate the weighted child entropy.
57
+ 3. Gain = parent entropy minus weighted child entropy.
58
+ 4. Choose the feature with the largest gain.
59
+ 5. Repeat separately within each branch, removing the selected feature.
60
+ 6. Stop at a pure node, no useful gain, no remaining feature, or a size limit.
61
+ 7. A stopped node predicts its majority class. Ties use first appearance order.
62
+
63
+ `max_depth` controls depth (root depth is 0). `min_samples_split` controls how
64
+ many examples are required before splitting. `min_gain` controls the minimum
65
+ useful gain. A category unseen during fitting uses the current node's majority.
66
+
67
+ ## Tennis example
68
+
69
+ The 14 rows reproduce the lecture's Play Tennis example. `Day` is excluded as
70
+ an identifier. Categories are used directly; no numeric encoding is needed.
71
+
72
+ | Feature | Root information gain |
73
+ |---|---:|
74
+ | Outlook | 0.246750 |
75
+ | Temperature | 0.029223 |
76
+ | Humidity | 0.151836 |
77
+ | Wind | 0.048127 |
78
+
79
+ The learned tree starts with Outlook; Sunny uses Humidity and Rain uses Wind.
80
+ Overcast immediately predicts Yes. With max_depth=2, training accuracy is 1.0.
81
+ That is a training demonstration, not evidence of perfect generalisation.
82
+
83
+ ## Scope and limitations
84
+
85
+ Categorical, hashable, nonmissing inputs only. Numeric values are treated as
86
+ discrete categories; continuous threshold splits are not implemented. This is
87
+ an educational implementation, not an efficient model for the large irrigation
88
+ dataset. Graphical boxes are sized for short labels such as the tennis example.
89
+
90
+ See PUBLISHING.md for GitHub and PyPI instructions. The package name
91
+ `ebele-entropy-tree` is provisional: availability has not been checked.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLISHING.md
4
+ README.md
5
+ pyproject.toml
6
+ report.md
7
+ examples/tennis.csv
8
+ examples/tennis_demo.py
9
+ src/ebele_entropy_tree.egg-info/PKG-INFO
10
+ src/ebele_entropy_tree.egg-info/SOURCES.txt
11
+ src/ebele_entropy_tree.egg-info/dependency_links.txt
12
+ src/ebele_entropy_tree.egg-info/top_level.txt
13
+ src/ebele_tree/__init__.py
14
+ src/ebele_tree/tree.py
15
+ tests/test_tree.py
@@ -0,0 +1,3 @@
1
+ from .tree import DecisionTreeClassifier, entropy, information_gain
2
+
3
+ __all__ = ["DecisionTreeClassifier", "entropy", "information_gain"]
@@ -0,0 +1,158 @@
1
+ """Categorical ID3 tree. Uses only Python's standard library."""
2
+ from collections import Counter
3
+ from html import escape
4
+ from math import log2
5
+ from pathlib import Path
6
+
7
+
8
+ def entropy(labels):
9
+ """Shannon entropy in bits; an empty or pure group has entropy zero."""
10
+ labels = list(labels)
11
+ counts = Counter(labels)
12
+ return -sum((n / len(labels)) * log2(n / len(labels))
13
+ for n in counts.values()) if labels else 0.0
14
+
15
+
16
+ def information_gain(values, labels):
17
+ """Parent entropy minus weighted child entropies."""
18
+ values, labels = list(values), list(labels)
19
+ if len(values) != len(labels) or not labels:
20
+ raise ValueError("Provide equally sized, nonempty values and labels.")
21
+ groups = {}
22
+ for value, label in zip(values, labels):
23
+ groups.setdefault(value, []).append(label)
24
+ return entropy(labels) - sum(len(group) / len(labels) * entropy(group)
25
+ for group in groups.values())
26
+
27
+
28
+ class DecisionTreeClassifier:
29
+ """ID3 for categorical features, supplied as a list of dictionaries.
30
+
31
+ max_depth=0 gives a majority-class leaf; None allows unrestricted depth.
32
+ min_samples_split and min_gain provide additional stopping controls.
33
+ Unseen categories fall back to the current node's majority class.
34
+ Numeric values, if supplied, are discrete categories, NOT thresholds.
35
+ Missing values are not supported: clean/impute them before fitting.
36
+ """
37
+ def __init__(self, max_depth=None, min_samples_split=2, min_gain=1e-12):
38
+ if max_depth is not None and (type(max_depth) is not int or max_depth < 0):
39
+ raise ValueError("max_depth must be None or a nonnegative integer.")
40
+ if type(min_samples_split) is not int or min_samples_split < 2:
41
+ raise ValueError("min_samples_split must be an integer >= 2.")
42
+ if not isinstance(min_gain, (int, float)) or not 0 <= min_gain < float('inf'):
43
+ raise ValueError("min_gain must be a finite nonnegative number.")
44
+ self.max_depth = max_depth
45
+ self.min_samples_split = min_samples_split
46
+ self.min_gain = min_gain
47
+
48
+ def fit(self, X, y):
49
+ X, y = list(X), list(y)
50
+ if not X or len(X) != len(y):
51
+ raise ValueError("X and y must be nonempty and have equal lengths.")
52
+ if not all(isinstance(row, dict) for row in X):
53
+ raise ValueError("X must contain dictionaries.")
54
+ self.features_ = list(X[0])
55
+ if any(set(row) != set(self.features_) for row in X):
56
+ raise ValueError("All rows must have the same feature names.")
57
+ for value in [v for row in X for v in row.values()] + y:
58
+ if value is None or value != value:
59
+ raise ValueError("Missing values must be cleaned first.")
60
+ hash(value)
61
+ self.tree_ = self._build(X, y, self.features_, 0)
62
+ return self
63
+
64
+ def _build(self, X, y, features, depth):
65
+ # Counter preserves appearance order, making majority ties deterministic.
66
+ majority = Counter(y).most_common(1)[0][0]
67
+ node = {"prediction": majority, "samples": len(y), "entropy": entropy(y)}
68
+ if (len(set(y)) == 1 or not features or len(y) < self.min_samples_split
69
+ or (self.max_depth is not None and depth >= self.max_depth)):
70
+ return node
71
+ gains = {f: information_gain([row[f] for row in X], y) for f in features}
72
+ best = max(gains, key=gains.get)
73
+ if gains[best] <= self.min_gain:
74
+ return node
75
+ node.update(feature=best, gain=gains[best], children={})
76
+ remaining = [f for f in features if f != best]
77
+ for value in dict.fromkeys(row[best] for row in X):
78
+ indices = [i for i, row in enumerate(X) if row[best] == value]
79
+ node["children"][value] = self._build(
80
+ [X[i] for i in indices], [y[i] for i in indices], remaining, depth + 1)
81
+ return node
82
+
83
+ def _check_fitted(self):
84
+ if not hasattr(self, "tree_"):
85
+ raise ValueError("Call fit before predicting or displaying the tree.")
86
+
87
+ def predict(self, X):
88
+ self._check_fitted()
89
+ predictions = []
90
+ for row in X:
91
+ node = self.tree_
92
+ while "feature" in node:
93
+ feature = node["feature"]
94
+ if feature not in row:
95
+ raise ValueError("Missing prediction feature: " + feature)
96
+ if row[feature] not in node["children"]:
97
+ break
98
+ node = node["children"][row[feature]]
99
+ predictions.append(node["prediction"])
100
+ return predictions
101
+
102
+ def score(self, X, y):
103
+ y = list(y)
104
+ predictions = self.predict(X)
105
+ if not y or len(y) != len(predictions):
106
+ raise ValueError("Provide equally sized, nonempty X and y.")
107
+ return sum(a == b for a, b in zip(predictions, y)) / len(y)
108
+
109
+ def display(self):
110
+ """Print the learned rules with indentation showing each branch."""
111
+ self._check_fitted()
112
+ lines = []
113
+ def visit(node, indent=""):
114
+ if "feature" not in node:
115
+ lines.append(f"{indent}Predict {node['prediction']} (n={node['samples']})")
116
+ else:
117
+ lines.append(f"{indent}{node['feature']} (gain={node['gain']:.3f})")
118
+ for value, child in node["children"].items():
119
+ lines.append(f"{indent} If {node['feature']} = {value}:")
120
+ visit(child, indent + " ")
121
+ visit(self.tree_)
122
+ print("\n".join(lines))
123
+
124
+ def export_svg(self, filename="tree.svg"):
125
+ """Save a graphical tree, viewable directly in a web browser."""
126
+ self._check_fitted()
127
+ nodes, edges = [], []
128
+ leaf_count = 0
129
+ def layout(node, depth):
130
+ nonlocal leaf_count
131
+ children = []
132
+ for value, child in node.get("children", {}).items():
133
+ children.append((value, layout(child, depth + 1)))
134
+ if children:
135
+ x = sum(child[0] for _, child in children) / len(children)
136
+ else:
137
+ x = 140 + leaf_count * 240
138
+ leaf_count += 1
139
+ y = 60 + depth * 150
140
+ for value, child in children:
141
+ edges.append((x, y, child[0], child[1], str(value)))
142
+ label = str(node.get("feature", "Predict: " + str(node["prediction"])))
143
+ nodes.append((x, y, label, node['samples']))
144
+ return x, y
145
+ layout(self.tree_, 0)
146
+ width, height = max(280, leaf_count * 240 + 40), max(n[1] for n in nodes) + 60
147
+ svg = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
148
+ '<rect width="100%" height="100%" fill="white"/>']
149
+ for x1, y1, x2, y2, label in edges:
150
+ svg.append(f'<line x1="{x1}" y1="{y1+27}" x2="{x2}" y2="{y2-27}" stroke="#53677d"/>')
151
+ svg.append(f'<text x="{(x1+x2)/2}" y="{(y1+y2)/2}" text-anchor="middle" font-family="sans-serif" font-size="14">{escape(label)}</text>')
152
+ for x, y, label, samples in nodes:
153
+ svg.append(f'<rect x="{x-100}" y="{y-27}" width="200" height="54" rx="10" fill="#e4effb" stroke="#376aa0"/>')
154
+ svg.append(f'<text x="{x}" y="{y-3}" text-anchor="middle" font-family="sans-serif" font-size="15">{escape(label)}</text>')
155
+ svg.append(f'<text x="{x}" y="{y+17}" text-anchor="middle" font-family="sans-serif" font-size="12">n={samples}</text>')
156
+ svg.append('</svg>')
157
+ Path(filename).write_text("\n".join(svg), encoding="utf-8")
158
+ return str(filename)
@@ -0,0 +1,72 @@
1
+ import csv
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from xml.etree import ElementTree
6
+ from ebele_tree import DecisionTreeClassifier, entropy, information_gain
7
+
8
+ class TreeTests(unittest.TestCase):
9
+ def setUp(self):
10
+ with (Path(__file__).resolve().parents[1] / "examples/tennis.csv").open() as f:
11
+ rows = list(csv.DictReader(f))
12
+ self.y = [r["Play_Tennis"] for r in rows]
13
+ self.X = [{k:v for k,v in r.items() if k not in ("Day", "Play_Tennis")} for r in rows]
14
+
15
+ def test_entropy(self):
16
+ self.assertEqual(entropy([]), 0)
17
+ self.assertEqual(entropy(["Yes"] * 3), 0)
18
+ self.assertEqual(entropy(["Yes", "No"]), 1)
19
+ self.assertAlmostEqual(entropy(self.y), 0.9402859587)
20
+
21
+ def test_known_information_gain(self):
22
+ self.assertAlmostEqual(information_gain([r["Outlook"] for r in self.X], self.y), 0.2467498198)
23
+
24
+ def test_tennis_structure_and_predictions(self):
25
+ m = DecisionTreeClassifier().fit(self.X, self.y)
26
+ self.assertEqual(m.tree_["feature"], "Outlook")
27
+ self.assertEqual(m.tree_["children"]["Sunny"]["feature"], "Humidity")
28
+ self.assertEqual(m.tree_["children"]["Rain"]["feature"], "Wind")
29
+ self.assertEqual(m.predict(self.X), self.y)
30
+
31
+ def test_depth_limit(self):
32
+ m = DecisionTreeClassifier(max_depth=0).fit(self.X, self.y)
33
+ self.assertNotIn("feature", m.tree_)
34
+ self.assertEqual(m.predict(self.X), ["Yes"] * 14)
35
+ m = DecisionTreeClassifier(max_depth=1).fit(self.X, self.y)
36
+ self.assertTrue(all("feature" not in c for c in m.tree_["children"].values()))
37
+
38
+ def test_unseen_category_fallback(self):
39
+ m = DecisionTreeClassifier().fit(self.X, self.y)
40
+ row = dict(self.X[0], Outlook="Snow")
41
+ self.assertEqual(m.predict([row]), ["Yes"])
42
+
43
+ def test_no_gain_and_min_samples(self):
44
+ m = DecisionTreeClassifier().fit([{"a":"x"}, {"a":"x"}], ["N", "Y"])
45
+ self.assertNotIn("feature", m.tree_)
46
+ m = DecisionTreeClassifier(min_samples_split=15).fit(self.X, self.y)
47
+ self.assertNotIn("feature", m.tree_)
48
+
49
+ def test_svg_is_valid(self):
50
+ m = DecisionTreeClassifier().fit(self.X, self.y)
51
+ with tempfile.TemporaryDirectory() as tmp:
52
+ path = m.export_svg(Path(tmp) / "tree.svg")
53
+ root = ElementTree.parse(path).getroot()
54
+ texts = [e.text for e in root.iter() if e.tag.endswith("}text")]
55
+ self.assertIn("Outlook", texts)
56
+ self.assertIn("Humidity", texts)
57
+ self.assertIn("Wind", texts)
58
+
59
+ def test_invalid_inputs(self):
60
+ with self.assertRaises(ValueError):
61
+ DecisionTreeClassifier().predict(self.X)
62
+ with self.assertRaises(ValueError):
63
+ DecisionTreeClassifier(max_depth=-1)
64
+ with self.assertRaises(ValueError):
65
+ DecisionTreeClassifier().fit([], [])
66
+ with self.assertRaises(ValueError):
67
+ DecisionTreeClassifier().fit([{"a":None}], ["Y"])
68
+ with self.assertRaises(ValueError):
69
+ DecisionTreeClassifier().fit([{"a":1}, {"b":2}], ["Y", "N"])
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()