simple-sklearn 0.1.0__py3-none-any.whl

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,25 @@
1
+ """Simple implementations of standard machine learning algorithms.
2
+
3
+ This library provides implementations of various standard machine learning models.
4
+ Designed to integrate seamlessly with scikit-learn's
5
+ [estimator API](https://scikit-learn.org/stable/developers/develop.html),
6
+ all estimators in this package inherit from
7
+ [`sklearn.base.BaseEstimator`][] and the appropriate mixins
8
+ ([`ClassifierMixin`][sklearn.base.ClassifierMixin] or [`ClusterMixin`][sklearn.base.ClusterMixin]).
9
+
10
+ This package is divided into two primary subpackages:
11
+ - [`classification`][simple_sklearn.classification]:
12
+ Supervised machine learning algorithms for classification tasks.
13
+ - [`clustering`][simple_sklearn.clustering]:
14
+ Unsupervised machine learning algorithms for clustering tasks.
15
+ """
16
+
17
+ from . import classification, clustering
18
+
19
+ try:
20
+ # _version.py file is auto-generated by setuptools_scm during the build process
21
+ from ._version import __version__
22
+ except ImportError:
23
+ __version__ = "unknown"
24
+
25
+ __all__ = ["__version__", "classification", "clustering"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,28 @@
1
+ """Supervised machine learning algorithms for classification tasks.
2
+
3
+ This subpackage contains implementations of standard classification models.
4
+ All estimators in this module inherit from
5
+ [`sklearn.base.BaseEstimator`][] and [`sklearn.base.ClassifierMixin`][].
6
+
7
+ Available models:
8
+ - [`OneRClassifier`][simple_sklearn.classification.OneRClassifier]:
9
+ 1R (One Rule) classification.
10
+ - [`NaiveBayesClassifier`][simple_sklearn.classification.NaiveBayesClassifier]:
11
+ Categorical Naive Bayes classification.
12
+ - [`KNeighborsClassifier`][simple_sklearn.classification.KNeighborsClassifier]:
13
+ K-Nearest Neighbors classification.
14
+ - [`DecisionTreeClassifier`][simple_sklearn.classification.DecisionTreeClassifier]:
15
+ Decision Tree classification using the ID3 algorithm.
16
+ """
17
+
18
+ from ._decision_tree import DecisionTreeClassifier
19
+ from ._k_neighbors import KNeighborsClassifier
20
+ from ._naive_bayes import NaiveBayesClassifier
21
+ from ._one_r import OneRClassifier
22
+
23
+ __all__ = [
24
+ "DecisionTreeClassifier",
25
+ "KNeighborsClassifier",
26
+ "NaiveBayesClassifier",
27
+ "OneRClassifier",
28
+ ]
@@ -0,0 +1,210 @@
1
+ """Decision Tree Classification.
2
+
3
+ This module provides the `DecisionTreeClassifier` class and its associated node structures.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from numpy.typing import NDArray
11
+ from sklearn.base import BaseEstimator, ClassifierMixin
12
+ from sklearn.utils.multiclass import type_of_target
13
+ from sklearn.utils.validation import check_is_fitted, validate_data
14
+ from typing_extensions import Self
15
+
16
+
17
+ class DecisionTreeClassifier(ClassifierMixin, BaseEstimator): # type: ignore
18
+ """Perform Decision Tree classification using the ID3 algorithm.
19
+
20
+ The Decision Tree classifier builds a tree from categorical features by recursively
21
+ partitioning the data based on the feature that maximizes information gain
22
+ (decreases entropy).
23
+
24
+ Note:
25
+ This implementation is strictly for discrete/categorical feature data.
26
+ Passing continuous features will result in severe overfitting.
27
+
28
+ Attributes:
29
+ classes_: The unique class labels observed in the training data.
30
+ num_features_: The number of features seen during fitting.
31
+ feature_unique_values_: A list of sets containing the unique values encountered for each feature.
32
+ tree_: The root node of the fitted decision tree (a `DecisionTreeNode` instance).
33
+ Nodes store integer-encoded labels mapped to the `classes_` array.
34
+ """
35
+
36
+ classes_: NDArray[Any]
37
+ num_features_: int
38
+ feature_unique_values_: list[set[Any]]
39
+ tree_: "DecisionTreeNode"
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__()
43
+
44
+ def fit(self, X: Any, y: Any) -> Self:
45
+ """Fit the Decision Tree classification model.
46
+
47
+ Args:
48
+ X: Training instances to fit the model. Can be an array-like of shape `(n_samples, n_features)`.
49
+ y: Target values (class labels) for the training instances. Array-like of shape `(n_samples,)`.
50
+
51
+ Returns:
52
+ The fitted instance.
53
+
54
+ Raises:
55
+ ValueError: If `y` is of a continuous type.
56
+ """
57
+ X, y = validate_data(self, X, y)
58
+ X = np.array(X)
59
+
60
+ if type_of_target(y) in ("continuous", "continuous-multioutput"):
61
+ raise ValueError(f"Unknown label type: {type_of_target(y)}")
62
+ self.classes_, y = np.unique(y, return_inverse=True)
63
+
64
+ self.num_features_ = X.shape[1]
65
+ feat_indices = list(range(self.num_features_))
66
+ df = pd.DataFrame(X, columns=feat_indices)
67
+ df["y"] = y
68
+
69
+ self.feature_unique_values_ = [set(df[feat_index]) for feat_index in feat_indices]
70
+ self.tree_ = self._id3_algorithm(df, set(feat_indices))
71
+
72
+ return self
73
+
74
+ def predict(self, X: Any) -> NDArray[Any]:
75
+ """Predict class labels for the given input data.
76
+
77
+ Traverses the fitted decision tree for each sample. If an unseen feature value
78
+ is encountered at a split node, the model falls back to predicting the majority
79
+ class label of that specific node.
80
+
81
+ Args:
82
+ X: Instances to predict. Can be an array-like of shape `(n_samples, n_features)`.
83
+
84
+ Returns:
85
+ An array of shape `(n_samples,)` containing the predicted class labels for each sample.
86
+ """
87
+ check_is_fitted(self)
88
+ X = validate_data(self, X, reset=False)
89
+ X = np.array(X)
90
+
91
+ y_pred = []
92
+ for x in X:
93
+ curr_node = self.tree_
94
+ while True:
95
+ match curr_node:
96
+ case LeafNode():
97
+ y_pred.append(curr_node.label)
98
+ break
99
+ case SplitterNode():
100
+ feat_value = x[curr_node.feat_index]
101
+ if feat_value in curr_node.children:
102
+ curr_node = curr_node.children[feat_value]
103
+ else:
104
+ y_pred.append(curr_node.majority_label)
105
+ break
106
+ case _:
107
+ raise TypeError(f"Unknown node type encountered: {type(curr_node)}")
108
+
109
+ return np.asarray(self.classes_[y_pred])
110
+
111
+ def _id3_algorithm(self, df: pd.DataFrame, feat_indices: set[int]) -> "DecisionTreeNode":
112
+ """Recursively build the decision tree using the ID3 algorithm.
113
+
114
+ Args:
115
+ df: A pandas.DataFrame containing the training data and target labels for the current node.
116
+ feat_indices: A set of remaining feature indices available for splitting.
117
+
118
+ Returns:
119
+ A `DecisionTreeNode` representing the root of the constructed subtree
120
+ (either a `SplitterNode` or `LeafNode`).
121
+ """
122
+ y_counts = df["y"].value_counts()
123
+ most_frequent_y = y_counts.index[0]
124
+
125
+ if len(y_counts) == 1 or not feat_indices:
126
+ return LeafNode(label=most_frequent_y)
127
+
128
+ best_feat = max(feat_indices, key=lambda x: self._information_gain(df, x))
129
+ node = SplitterNode(feat_index=best_feat, majority_label=most_frequent_y)
130
+
131
+ for best_feat_value, df_group in df.groupby(by=best_feat):
132
+ child_node = self._id3_algorithm(df_group, feat_indices - {best_feat})
133
+ node.children[best_feat_value] = child_node
134
+
135
+ all_best_feat_values = self.feature_unique_values_[best_feat]
136
+ df_best_feat_values = set(df[best_feat])
137
+ unseen_best_feat_values = all_best_feat_values - df_best_feat_values
138
+ for best_feat_value in unseen_best_feat_values:
139
+ node.children[best_feat_value] = LeafNode(label=most_frequent_y)
140
+
141
+ return node
142
+
143
+ def _information_gain(self, df: pd.DataFrame, feat_index: int) -> float:
144
+ """Calculate the information gain for a potential feature split.
145
+
146
+ Args:
147
+ df: A pandas.DataFrame containing the current node's data and labels.
148
+ feat_index: The index of the feature to evaluate.
149
+
150
+ Returns:
151
+ The calculated information gain (reduction in entropy).
152
+ """
153
+ total_entropy = self._calc_entropy(df)
154
+ values = df[feat_index].unique()
155
+ weighted_entropy = 0.0
156
+
157
+ for value in values:
158
+ subset = df[df[feat_index] == value]
159
+ weight = len(subset) / len(df)
160
+ weighted_entropy += weight * self._calc_entropy(subset)
161
+
162
+ return total_entropy - weighted_entropy
163
+
164
+ def _calc_entropy(self, df: pd.DataFrame) -> float:
165
+ """Calculate the Shannon entropy of the target labels in the given data.
166
+
167
+ Args:
168
+ df: A pandas.DataFrame containing the target labels in a 'y' column.
169
+
170
+ Returns:
171
+ The calculated entropy value.
172
+ """
173
+ value_counts = df["y"].value_counts(normalize=True)
174
+ entropy = float(-np.sum(value_counts * np.log2(value_counts)))
175
+ return entropy
176
+
177
+
178
+ class DecisionTreeNode:
179
+ """Base class for nodes in the decision tree."""
180
+
181
+ pass
182
+
183
+
184
+ class SplitterNode(DecisionTreeNode):
185
+ """A decision tree node representing a feature split.
186
+
187
+ Args:
188
+ feat_index: The index of the feature used for splitting at this node.
189
+ majority_label: The integer-encoded majority class label at this node,
190
+ used as a fallback during prediction.
191
+
192
+ Attributes:
193
+ children: A dictionary mapping feature values to child `DecisionTreeNode` instances.
194
+ """
195
+
196
+ def __init__(self, feat_index: int, majority_label: int) -> None:
197
+ self.feat_index: int = feat_index
198
+ self.majority_label: int = majority_label
199
+ self.children: dict[Any, DecisionTreeNode] = {}
200
+
201
+
202
+ class LeafNode(DecisionTreeNode):
203
+ """A decision tree node representing a terminal leaf.
204
+
205
+ Args:
206
+ label: The integer-encoded predicted class label for this leaf.
207
+ """
208
+
209
+ def __init__(self, label: int) -> None:
210
+ self.label: int = label
@@ -0,0 +1,220 @@
1
+ """K-Nearest Neighbors Classification.
2
+
3
+ This module provides the `KNeighborsClassifier` class.
4
+ """
5
+
6
+ import heapq
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+ from sklearn.base import BaseEstimator, ClassifierMixin
12
+ from sklearn.utils.multiclass import type_of_target
13
+ from sklearn.utils.validation import check_is_fitted, validate_data
14
+ from typing_extensions import Self
15
+
16
+
17
+ class KNeighborsClassifier(ClassifierMixin, BaseEstimator): # type: ignore
18
+ """Perform K-Nearest Neighbors classification.
19
+
20
+ The k-nearest neighbors algorithm classifies a new data point based on the
21
+ majority class among its `n_neighbors` closest neighbors in the training set. It
22
+ supports uniform voting weight as well as weighting by the inverse of the
23
+ distance or squared distance.
24
+
25
+ Args:
26
+ n_neighbors: The number of nearest neighbors.
27
+ weights: The weight function used in prediction. Must be one of "uniform",
28
+ "distance", or "distance_squared".
29
+ eps: A small float added to distances to prevent division by zero when
30
+ calculating weights.
31
+
32
+ Attributes:
33
+ classes_: The unique class labels observed in the training data.
34
+ fitted_x_: The validated training input data array.
35
+ fitted_y_: The validated and label-encoded target values array.
36
+ """
37
+
38
+ classes_: NDArray[Any]
39
+ fitted_x_: NDArray[Any]
40
+ fitted_y_: NDArray[Any]
41
+
42
+ def __init__(self, n_neighbors: int = 5, weights: str = "uniform", eps: float = 1e-9) -> None:
43
+ super().__init__()
44
+ self.n_neighbors = n_neighbors
45
+ self.weights = weights
46
+ self.eps = eps
47
+
48
+ def fit(self, X: Any, y: Any) -> Self:
49
+ """Fit the k-nearest neighbors classification model.
50
+
51
+ Args:
52
+ X: Training instances to fit the model. Can be an array-like of shape `(n_samples, n_features)`.
53
+ y: Target values (class labels) for the training instances. Array-like of shape `(n_samples,)`.
54
+
55
+ Returns:
56
+ The fitted instance.
57
+
58
+ Raises:
59
+ ValueError: If `y` is of a continuous type or if any hyperparameters are invalid.
60
+ """
61
+ X, y = validate_data(self, X, y)
62
+ X = np.array(X)
63
+ self._validate_self_params()
64
+
65
+ if type_of_target(y) in ("continuous", "continuous-multioutput"):
66
+ raise ValueError(f"Unknown label type: {type_of_target(y)}")
67
+ self.classes_, y = np.unique(y, return_inverse=True)
68
+
69
+ self.fitted_x_ = X
70
+ self.fitted_y_ = y
71
+
72
+ return self
73
+
74
+ def predict(self, X: Any) -> NDArray[Any]:
75
+ """Predict class labels for the given input data.
76
+
77
+ Args:
78
+ X: Instances to predict. Can be an array-like of shape `(n_samples, n_features)`.
79
+
80
+ Returns:
81
+ An array of shape `(n_samples,)` containing the predicted class labels for each sample.
82
+ """
83
+ check_is_fitted(self)
84
+ X = validate_data(self, X, reset=False)
85
+ X = np.array(X)
86
+
87
+ decision_scores = self._decision_function(X)
88
+ return np.asarray(self.classes_[np.argmax(decision_scores, axis=1)])
89
+
90
+ def kneighbors(self, X: Any) -> tuple[NDArray[Any], NDArray[Any]]:
91
+ """Find the k-nearest neighbors of each sample in the given input data.
92
+
93
+ Args:
94
+ X: Instances to evaluate. Can be an array-like of shape `(n_samples, n_features)`.
95
+
96
+ Returns:
97
+ distances: An array of shape `(n_samples, n_neighbors)` representing the
98
+ Euclidean distances to the nearest points.
99
+ indices: An array of shape `(n_samples, n_neighbors)` representing the
100
+ indices of the nearest points in the fitted training data.
101
+ """
102
+ check_is_fitted(self)
103
+ X = validate_data(self, X, reset=False)
104
+
105
+ return self._kneighbors(X)
106
+
107
+ def _decision_function(self, X: NDArray[Any]) -> NDArray[Any]:
108
+ """Compute the decision scores for each class based on neighbor votes.
109
+
110
+ Calculates the accumulated weights or unweighted counts for each class
111
+ based on the `self.n_neighbors` nearest neighbors.
112
+
113
+ Args:
114
+ X: Instances to evaluate. An array of shape `(n_samples, n_features)`.
115
+
116
+ Returns:
117
+ An array of shape `(n_samples, n_classes)` containing the decision scores
118
+ (accumulated votes) for each sample and class.
119
+ """
120
+ decision_scores = []
121
+ for x in X:
122
+ x_neigh_indices = self._find_kneighbors_indices(x, self.n_neighbors)
123
+ x_neigh_labels = self.fitted_y_[x_neigh_indices]
124
+ x_neigh_distances, x_neigh_distances_squared = self._calc_distances(
125
+ x, X_targets=self.fitted_x_[x_neigh_indices]
126
+ )
127
+ if self.weights == "distance":
128
+ weights = 1 / (x_neigh_distances + self.eps)
129
+ x_decision_scores = np.bincount(x_neigh_labels, minlength=len(self.classes_), weights=weights)
130
+ elif self.weights == "distance_squared":
131
+ weights = 1 / (x_neigh_distances_squared + self.eps)
132
+ x_decision_scores = np.bincount(x_neigh_labels, minlength=len(self.classes_), weights=weights)
133
+ else: # self.weights == 'uniform'
134
+ x_decision_scores = np.bincount(x_neigh_labels, minlength=len(self.classes_))
135
+ decision_scores.append(x_decision_scores)
136
+
137
+ return np.array(decision_scores)
138
+
139
+ def _kneighbors(self, X: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]:
140
+ """Compute distances and indices of k-nearest neighbors of each sample.
141
+
142
+ Args:
143
+ X: Instances to evaluate. An array of shape `(n_samples, n_features)`.
144
+
145
+ Returns:
146
+ neigh_distances: Array of shape `(n_samples, n_neighbors)` containing Euclidean distances.
147
+ neigh_indices: Array of shape `(n_samples, n_neighbors)` containing indices in `self.fitted_x_`.
148
+ """
149
+ neigh_distances = []
150
+ neigh_indices = []
151
+ for x in X:
152
+ x_neigh_indices = self._find_kneighbors_indices(x, self.n_neighbors)
153
+ x_neigh_distances, _ = self._calc_distances(x, X_targets=self.fitted_x_[x_neigh_indices])
154
+ neigh_distances.append(x_neigh_distances)
155
+ neigh_indices.append(x_neigh_indices)
156
+
157
+ return np.array(neigh_distances), np.array(neigh_indices)
158
+
159
+ def _find_kneighbors_indices(self, x: NDArray[Any], n_neighbors: int) -> NDArray[Any]:
160
+ """Find the indices of the nearest neighbors for a single sample.
161
+
162
+ Uses a heap queue to efficiently find the `n_neighbors` samples from `self.fitted_x_`
163
+ with the smallest squared Euclidean distance.
164
+
165
+ Args:
166
+ x: A single instance to evaluate. An array-like of shape `(n_features,)`.
167
+ n_neighbors: The number of nearest neighbors to find.
168
+
169
+ Returns:
170
+ An array of shape `(n_neighbors,)` containing the indices of the nearest
171
+ neighbors in the training data.
172
+ """
173
+ indices = list(range(self.fitted_x_.shape[0]))
174
+ _, distances_squared = self._calc_distances(x)
175
+ neigh_indices = heapq.nsmallest(n_neighbors, indices, key=lambda i: distances_squared[i])
176
+ return np.array(neigh_indices)
177
+
178
+ def _calc_distances(
179
+ self, x_source: NDArray[Any], X_targets: NDArray[Any] | None = None
180
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
181
+ """Calculate the Euclidean and squared Euclidean distances from a source point.
182
+
183
+ Args:
184
+ x_source: The source instance. An array of shape `(n_features,)`.
185
+ X_targets: The target instances to measure against. If `None`, distances are
186
+ calculated against all fitted training instances. An array of shape
187
+ `(n_targets, n_features)`.
188
+
189
+ Returns:
190
+ distances: An array of shape `(n_targets,)` containing Euclidean distances.
191
+ distances_squared: An array of shape `(n_targets,)` containing squared Euclidean distances.
192
+ """
193
+ if X_targets is None:
194
+ X_targets = self.fitted_x_
195
+ distances_squared = np.sum((X_targets - x_source) ** 2, axis=1)
196
+ distances = np.sqrt(distances_squared)
197
+ return distances, distances_squared
198
+
199
+ def _validate_self_params(self) -> None:
200
+ """Validate the hyperparameters.
201
+
202
+ Raises:
203
+ ValueError: If `n_neighbors` is not a positive integer, if `weights` is not
204
+ one of the supported string literals, or if `eps` is not a float in the range (0, 1).
205
+ """
206
+ if not isinstance(self.n_neighbors, int) or self.n_neighbors < 1:
207
+ raise ValueError(
208
+ f"The 'n_neighbors' parameter of KNeighborsClassifier must be an int in the range [1, inf). "
209
+ f"Got {self.n_neighbors} instead."
210
+ )
211
+ if self.weights not in ("distance", "distance_squared", "uniform"):
212
+ raise ValueError(
213
+ f"The 'weights' parameter of KNeighborsClassifier must be a str among "
214
+ f"{{'distance', 'distance_squared', 'uniform'}}. Got '{self.weights}' instead."
215
+ )
216
+ if not isinstance(self.eps, float) or not 0 < self.eps < 1:
217
+ raise ValueError(
218
+ f"The 'eps' parameter of KNeighborsClassifier must be a float in the range (0.0, 1). "
219
+ f"Got {self.eps} instead."
220
+ )