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.
- ebele_entropy_tree-0.1.0/LICENSE +21 -0
- ebele_entropy_tree-0.1.0/MANIFEST.in +3 -0
- ebele_entropy_tree-0.1.0/PKG-INFO +91 -0
- ebele_entropy_tree-0.1.0/PUBLISHING.md +84 -0
- ebele_entropy_tree-0.1.0/README.md +80 -0
- ebele_entropy_tree-0.1.0/examples/tennis.csv +15 -0
- ebele_entropy_tree-0.1.0/examples/tennis_demo.py +17 -0
- ebele_entropy_tree-0.1.0/pyproject.toml +16 -0
- ebele_entropy_tree-0.1.0/report.md +60 -0
- ebele_entropy_tree-0.1.0/setup.cfg +4 -0
- ebele_entropy_tree-0.1.0/src/ebele_entropy_tree.egg-info/PKG-INFO +91 -0
- ebele_entropy_tree-0.1.0/src/ebele_entropy_tree.egg-info/SOURCES.txt +15 -0
- ebele_entropy_tree-0.1.0/src/ebele_entropy_tree.egg-info/dependency_links.txt +1 -0
- ebele_entropy_tree-0.1.0/src/ebele_entropy_tree.egg-info/top_level.txt +1 -0
- ebele_entropy_tree-0.1.0/src/ebele_tree/__init__.py +3 -0
- ebele_entropy_tree-0.1.0/src/ebele_tree/tree.py +158 -0
- ebele_entropy_tree-0.1.0/tests/test_tree.py +72 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ebele_tree
|
|
@@ -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()
|