scratchkit 0.2.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.
- mlscratch/__init__.py +56 -0
- mlscratch/__main__.py +118 -0
- mlscratch/bayesian/__init__.py +53 -0
- mlscratch/bayesian/bayesian_linear_regression.py +171 -0
- mlscratch/bayesian/bayesian_network.py +248 -0
- mlscratch/bayesian/bayesian_nn.py +315 -0
- mlscratch/bayesian/gaussian_process.py +207 -0
- mlscratch/bayesian/hmm.py +277 -0
- mlscratch/bayesian/init.py +52 -0
- mlscratch/bayesian/kalman_filter.py +182 -0
- mlscratch/bayesian/naive_bayes.py +209 -0
- mlscratch/metrics/__init__.py +59 -0
- mlscratch/metrics/classification.py +365 -0
- mlscratch/metrics/regression.py +79 -0
- mlscratch/neural/__init__.py +121 -0
- mlscratch/neural/attention.py +420 -0
- mlscratch/neural/autoencoder.py +543 -0
- mlscratch/neural/boltzmann.py +231 -0
- mlscratch/neural/cnn.py +593 -0
- mlscratch/neural/cvnn.py +322 -0
- mlscratch/neural/gan.py +364 -0
- mlscratch/neural/hopfield.py +193 -0
- mlscratch/neural/perceptron.py +398 -0
- mlscratch/neural/rbf_network.py +230 -0
- mlscratch/neural/recurrent.py +569 -0
- mlscratch/preprocessing/__init__.py +38 -0
- mlscratch/preprocessing/encoders.py +140 -0
- mlscratch/preprocessing/model_selection.py +119 -0
- mlscratch/preprocessing/polynomial.py +105 -0
- mlscratch/preprocessing/scalers.py +220 -0
- mlscratch/py.typed +0 -0
- mlscratch/reinforcement/__init__.py +59 -0
- mlscratch/reinforcement/ddpg.py +363 -0
- mlscratch/reinforcement/dqn.py +319 -0
- mlscratch/reinforcement/ppo.py +452 -0
- mlscratch/reinforcement/q_learning.py +352 -0
- mlscratch/reinforcement/sac.py +382 -0
- mlscratch/reinforcement/utils.py +594 -0
- mlscratch/supervised/__init__.py +76 -0
- mlscratch/supervised/_validation.py +50 -0
- mlscratch/supervised/adaboost.py +255 -0
- mlscratch/supervised/decision_tree.py +495 -0
- mlscratch/supervised/gradient_boosting.py +354 -0
- mlscratch/supervised/knn.py +234 -0
- mlscratch/supervised/lasso_regression.py +125 -0
- mlscratch/supervised/linear_models.py +459 -0
- mlscratch/supervised/linear_regression.py +197 -0
- mlscratch/supervised/logistic_regression.py +119 -0
- mlscratch/supervised/naive_bayes.py +113 -0
- mlscratch/supervised/random_forest.py +321 -0
- mlscratch/supervised/ridge_regression.py +93 -0
- mlscratch/supervised/svm.py +356 -0
- mlscratch/unsupervised/__init__.py +39 -0
- mlscratch/unsupervised/apriori.py +178 -0
- mlscratch/unsupervised/dbscan.py +141 -0
- mlscratch/unsupervised/gmm.py +204 -0
- mlscratch/unsupervised/hierarchical_clustering.py +137 -0
- mlscratch/unsupervised/ica.py +167 -0
- mlscratch/unsupervised/kmeans.py +135 -0
- mlscratch/unsupervised/kmedoids.py +133 -0
- mlscratch/unsupervised/pca.py +103 -0
- mlscratch/unsupervised/tsne.py +200 -0
- scratchkit-0.2.0.dist-info/METADATA +241 -0
- scratchkit-0.2.0.dist-info/RECORD +68 -0
- scratchkit-0.2.0.dist-info/WHEEL +5 -0
- scratchkit-0.2.0.dist-info/entry_points.txt +2 -0
- scratchkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- scratchkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Categorical Encoders
|
|
3
|
+
======================
|
|
4
|
+
LabelEncoder
|
|
5
|
+
-------------
|
|
6
|
+
Maps a 1-D array of arbitrary hashable labels to integer codes
|
|
7
|
+
``0..K-1`` (sorted) — typically used to encode a target column.
|
|
8
|
+
|
|
9
|
+
OneHotEncoder
|
|
10
|
+
--------------
|
|
11
|
+
Maps each column of a 2-D categorical array independently to a block
|
|
12
|
+
of binary indicator columns, one per observed category.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import ArrayLike, NDArray
|
|
19
|
+
|
|
20
|
+
FloatArray = NDArray[np.float64]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LabelEncoder:
|
|
24
|
+
"""Encode a single 1-D array of labels as integers ``0..K-1``.
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
classes_ : sorted unique labels seen during fit
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.classes_: NDArray | None = None
|
|
33
|
+
|
|
34
|
+
def fit(self, y: ArrayLike) -> LabelEncoder:
|
|
35
|
+
y_arr = np.asarray(y).flatten()
|
|
36
|
+
if y_arr.shape[0] == 0:
|
|
37
|
+
raise ValueError("y must not be empty.")
|
|
38
|
+
self.classes_ = np.unique(y_arr)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def transform(self, y: ArrayLike) -> NDArray[np.int64]:
|
|
42
|
+
if self.classes_ is None:
|
|
43
|
+
raise RuntimeError("Call fit() before transform().")
|
|
44
|
+
y_arr = np.asarray(y).flatten()
|
|
45
|
+
label_to_idx = {label: i for i, label in enumerate(self.classes_)}
|
|
46
|
+
unseen = sorted(set(np.unique(y_arr).tolist()) - set(self.classes_.tolist()))
|
|
47
|
+
if unseen:
|
|
48
|
+
raise ValueError(f"y contains previously unseen labels: {unseen}")
|
|
49
|
+
return np.array([label_to_idx[v] for v in y_arr], dtype=np.int64)
|
|
50
|
+
|
|
51
|
+
def fit_transform(self, y: ArrayLike) -> NDArray[np.int64]:
|
|
52
|
+
return self.fit(y).transform(y)
|
|
53
|
+
|
|
54
|
+
def inverse_transform(self, y: ArrayLike) -> NDArray:
|
|
55
|
+
if self.classes_ is None:
|
|
56
|
+
raise RuntimeError("Call fit() before inverse_transform().")
|
|
57
|
+
y_arr = np.asarray(y, dtype=np.int64).flatten()
|
|
58
|
+
if np.any((y_arr < 0) | (y_arr >= self.classes_.size)):
|
|
59
|
+
raise ValueError("y contains codes outside the range of known classes.")
|
|
60
|
+
return self.classes_[y_arr]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OneHotEncoder:
|
|
64
|
+
"""One-hot encode every column of a 2-D categorical array independently.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
drop_first : bool, default=False
|
|
69
|
+
If True, drop the first category of each column to avoid the
|
|
70
|
+
"dummy variable trap" (perfect multicollinearity for linear models).
|
|
71
|
+
handle_unknown : str, default='error'
|
|
72
|
+
``'error'`` raises on categories not seen during fit;
|
|
73
|
+
``'ignore'`` encodes them as an all-zero row.
|
|
74
|
+
|
|
75
|
+
Attributes
|
|
76
|
+
----------
|
|
77
|
+
categories_ : list of per-column arrays of observed categories
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, drop_first: bool = False, handle_unknown: str = "error") -> None:
|
|
81
|
+
if handle_unknown not in ("error", "ignore"):
|
|
82
|
+
raise ValueError("handle_unknown must be 'error' or 'ignore'.")
|
|
83
|
+
self.drop_first = drop_first
|
|
84
|
+
self.handle_unknown = handle_unknown
|
|
85
|
+
self.categories_: list[NDArray] | None = None
|
|
86
|
+
self.n_features_in_: int | None = None
|
|
87
|
+
|
|
88
|
+
def fit(self, X: ArrayLike) -> OneHotEncoder:
|
|
89
|
+
X_arr = np.asarray(X)
|
|
90
|
+
if X_arr.ndim == 1:
|
|
91
|
+
X_arr = X_arr.reshape(-1, 1)
|
|
92
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
93
|
+
self.categories_ = [np.unique(X_arr[:, j]) for j in range(X_arr.shape[1])]
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
97
|
+
if self.categories_ is None:
|
|
98
|
+
raise RuntimeError("Call fit() before transform().")
|
|
99
|
+
X_arr = np.asarray(X)
|
|
100
|
+
if X_arr.ndim == 1:
|
|
101
|
+
X_arr = X_arr.reshape(-1, 1)
|
|
102
|
+
if X_arr.shape[1] != self.n_features_in_:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"X has {X_arr.shape[1]} columns but encoder was fit on {self.n_features_in_}."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
blocks = []
|
|
108
|
+
for j, cats in enumerate(self.categories_):
|
|
109
|
+
start = 1 if self.drop_first else 0
|
|
110
|
+
col = X_arr[:, j]
|
|
111
|
+
cat_to_idx = {c: i for i, c in enumerate(cats)}
|
|
112
|
+
block = np.zeros((X_arr.shape[0], cats.size), dtype=np.float64)
|
|
113
|
+
for row, value in enumerate(col):
|
|
114
|
+
idx = cat_to_idx.get(value)
|
|
115
|
+
if idx is None:
|
|
116
|
+
if self.handle_unknown == "error":
|
|
117
|
+
raise ValueError(f"Unknown category {value!r} in column {j}.")
|
|
118
|
+
continue
|
|
119
|
+
block[row, idx] = 1.0
|
|
120
|
+
blocks.append(block[:, start:])
|
|
121
|
+
return np.hstack(blocks)
|
|
122
|
+
|
|
123
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
124
|
+
return self.fit(X).transform(X)
|
|
125
|
+
|
|
126
|
+
def get_feature_names(self, input_features: list[str] | None = None) -> list[str]:
|
|
127
|
+
"""Return human-readable ``"<feature>_<category>"`` output column names."""
|
|
128
|
+
if self.categories_ is None:
|
|
129
|
+
raise RuntimeError("Call fit() before get_feature_names().")
|
|
130
|
+
prefixes = (
|
|
131
|
+
input_features
|
|
132
|
+
if input_features is not None
|
|
133
|
+
else [f"x{i}" for i in range(self.n_features_in_)]
|
|
134
|
+
)
|
|
135
|
+
names = []
|
|
136
|
+
for prefix, cats in zip(prefixes, self.categories_, strict=True):
|
|
137
|
+
start = 1 if self.drop_first else 0
|
|
138
|
+
for cat in cats[start:]:
|
|
139
|
+
names.append(f"{prefix}_{cat}")
|
|
140
|
+
return names
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Train/Test Splitting
|
|
3
|
+
======================
|
|
4
|
+
A minimal, dependency-free re-implementation of the classic
|
|
5
|
+
``train_test_split`` utility, with optional stratification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import ArrayLike
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def train_test_split(
|
|
15
|
+
*arrays: ArrayLike,
|
|
16
|
+
test_size: float | int = 0.25,
|
|
17
|
+
train_size: float | int | None = None,
|
|
18
|
+
shuffle: bool = True,
|
|
19
|
+
stratify: ArrayLike | None = None,
|
|
20
|
+
random_state: int | None = None,
|
|
21
|
+
) -> list[np.ndarray]:
|
|
22
|
+
"""Split one or more array-likes into random train/test subsets.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
*arrays : array-like
|
|
27
|
+
One or more arrays, all sharing the same length along axis 0
|
|
28
|
+
(e.g. ``X, y``).
|
|
29
|
+
test_size : float | int, default=0.25
|
|
30
|
+
Fraction in ``(0, 1)`` or an absolute sample count for the test split.
|
|
31
|
+
train_size : float | int | None, default=None
|
|
32
|
+
Complementary to ``test_size`` if given; otherwise inferred as
|
|
33
|
+
everything not in the test split.
|
|
34
|
+
shuffle : bool, default=True
|
|
35
|
+
Whether to shuffle before splitting (ignored if ``stratify`` is given,
|
|
36
|
+
which always shuffles within each class).
|
|
37
|
+
stratify : array-like | None, default=None
|
|
38
|
+
If given, the split preserves this array's class proportions.
|
|
39
|
+
random_state : int | None, default=None
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
A flat list ``[arr1_train, arr1_test, arr2_train, arr2_test, ...]``,
|
|
44
|
+
in the same order as the inputs (mirrors scikit-learn's signature).
|
|
45
|
+
"""
|
|
46
|
+
if len(arrays) == 0:
|
|
47
|
+
raise ValueError("At least one array is required.")
|
|
48
|
+
arrays_np = [np.asarray(a) for a in arrays]
|
|
49
|
+
n = arrays_np[0].shape[0]
|
|
50
|
+
for a in arrays_np[1:]:
|
|
51
|
+
if a.shape[0] != n:
|
|
52
|
+
raise ValueError("All input arrays must have the same first dimension.")
|
|
53
|
+
|
|
54
|
+
n_test = _resolve_size(test_size, n, "test_size")
|
|
55
|
+
n_train = _resolve_size(train_size, n, "train_size") if train_size is not None else n - n_test
|
|
56
|
+
if n_train + n_test > n:
|
|
57
|
+
raise ValueError("train_size + test_size exceeds the number of available samples.")
|
|
58
|
+
if n_train <= 0 or n_test <= 0:
|
|
59
|
+
raise ValueError("Both the train and test splits must contain at least one sample.")
|
|
60
|
+
|
|
61
|
+
rng = np.random.default_rng(random_state)
|
|
62
|
+
|
|
63
|
+
if stratify is not None:
|
|
64
|
+
strat = np.asarray(stratify).flatten()
|
|
65
|
+
if strat.shape[0] != n:
|
|
66
|
+
raise ValueError("stratify must have the same length as the input arrays.")
|
|
67
|
+
train_idx, test_idx = _stratified_indices(strat, n_train, n_test, rng)
|
|
68
|
+
else:
|
|
69
|
+
idx = np.arange(n)
|
|
70
|
+
if shuffle:
|
|
71
|
+
rng.shuffle(idx)
|
|
72
|
+
test_idx = idx[:n_test]
|
|
73
|
+
train_idx = idx[n_test : n_test + n_train]
|
|
74
|
+
|
|
75
|
+
result: list[np.ndarray] = []
|
|
76
|
+
for a in arrays_np:
|
|
77
|
+
result.append(a[train_idx])
|
|
78
|
+
result.append(a[test_idx])
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_size(size: float | int, n: int, name: str) -> int:
|
|
83
|
+
if isinstance(size, float):
|
|
84
|
+
if not (0.0 < size < 1.0):
|
|
85
|
+
raise ValueError(f"{name} as a float must be in (0, 1).")
|
|
86
|
+
return int(np.ceil(size * n))
|
|
87
|
+
if isinstance(size, (int, np.integer)):
|
|
88
|
+
if not (0 < size <= n):
|
|
89
|
+
raise ValueError(f"{name} as an int must be in (0, {n}].")
|
|
90
|
+
return int(size)
|
|
91
|
+
raise ValueError(f"{name} must be a float or an int.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _stratified_indices(
|
|
95
|
+
strat: np.ndarray, n_train: int, n_test: int, rng: np.random.Generator
|
|
96
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
97
|
+
"""Split indices class-by-class so train/test class proportions
|
|
98
|
+
approximately match the proportions of ``strat`` as a whole."""
|
|
99
|
+
classes, y_idx = np.unique(strat, return_inverse=True)
|
|
100
|
+
n = strat.shape[0]
|
|
101
|
+
train_parts, test_parts = [], []
|
|
102
|
+
for k in range(classes.size):
|
|
103
|
+
cls_idx = np.flatnonzero(y_idx == k)
|
|
104
|
+
rng.shuffle(cls_idx)
|
|
105
|
+
cls_n_test = min(cls_idx.size, max(1, int(round(n_test * cls_idx.size / n))))
|
|
106
|
+
test_parts.append(cls_idx[:cls_n_test])
|
|
107
|
+
train_parts.append(cls_idx[cls_n_test:])
|
|
108
|
+
|
|
109
|
+
train_idx = np.concatenate(train_parts)
|
|
110
|
+
test_idx = np.concatenate(test_parts)
|
|
111
|
+
rng.shuffle(train_idx)
|
|
112
|
+
rng.shuffle(test_idx)
|
|
113
|
+
|
|
114
|
+
# Trim to the exact requested sizes where the per-class rounding overshot.
|
|
115
|
+
if train_idx.size > n_train:
|
|
116
|
+
train_idx = train_idx[:n_train]
|
|
117
|
+
if test_idx.size > n_test:
|
|
118
|
+
test_idx = test_idx[:n_test]
|
|
119
|
+
return train_idx, test_idx
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Polynomial Features
|
|
3
|
+
=====================
|
|
4
|
+
Expand the input features into all polynomial and interaction
|
|
5
|
+
combinations up to a given degree. For ``degree=2`` and input
|
|
6
|
+
features ``[a, b]``, the output (with bias) is::
|
|
7
|
+
|
|
8
|
+
[1, a, b, a^2, a*b, b^2]
|
|
9
|
+
|
|
10
|
+
Used to let a linear model fit non-linear relationships in the
|
|
11
|
+
original feature space.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from itertools import combinations, combinations_with_replacement
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import ArrayLike, NDArray
|
|
21
|
+
|
|
22
|
+
FloatArray = NDArray[np.float64]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PolynomialFeatures:
|
|
26
|
+
"""Generate polynomial and interaction features up to ``degree``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
degree : int, default=2
|
|
31
|
+
include_bias : bool, default=True
|
|
32
|
+
Whether to prepend a constant ``1`` column.
|
|
33
|
+
interaction_only : bool, default=False
|
|
34
|
+
If True, only products of *distinct* input features are
|
|
35
|
+
produced (no ``x_i^2``, ``x_i^3``, ... pure powers).
|
|
36
|
+
|
|
37
|
+
Attributes
|
|
38
|
+
----------
|
|
39
|
+
powers_ : list of tuples
|
|
40
|
+
For each output column, the tuple of input-feature indices
|
|
41
|
+
multiplied together (an empty tuple is the bias column).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, degree: int = 2, include_bias: bool = True, interaction_only: bool = False
|
|
46
|
+
) -> None:
|
|
47
|
+
if degree < 1:
|
|
48
|
+
raise ValueError("degree must be >= 1.")
|
|
49
|
+
self.degree = degree
|
|
50
|
+
self.include_bias = include_bias
|
|
51
|
+
self.interaction_only = interaction_only
|
|
52
|
+
self.n_features_in_: int | None = None
|
|
53
|
+
self.powers_: list[tuple[int, ...]] | None = None
|
|
54
|
+
|
|
55
|
+
def fit(self, X: ArrayLike) -> PolynomialFeatures:
|
|
56
|
+
X_arr = np.asarray(X, dtype=np.float64)
|
|
57
|
+
if X_arr.ndim != 2:
|
|
58
|
+
raise ValueError("X must be a 2D array of shape (n_samples, n_features).")
|
|
59
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
60
|
+
|
|
61
|
+
terms: list[tuple[int, ...]] = [()] if self.include_bias else []
|
|
62
|
+
combo_fn = combinations if self.interaction_only else combinations_with_replacement
|
|
63
|
+
for d in range(1, self.degree + 1):
|
|
64
|
+
terms.extend(combo_fn(range(self.n_features_in_), d))
|
|
65
|
+
self.powers_ = terms
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
69
|
+
if self.powers_ is None:
|
|
70
|
+
raise RuntimeError("Call fit() before transform().")
|
|
71
|
+
X_arr = np.asarray(X, dtype=np.float64)
|
|
72
|
+
if X_arr.ndim != 2:
|
|
73
|
+
raise ValueError("X must be a 2D array of shape (n_samples, n_features).")
|
|
74
|
+
if X_arr.shape[1] != self.n_features_in_:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"X has {X_arr.shape[1]} features but transformer was fit on {self.n_features_in_}."
|
|
77
|
+
)
|
|
78
|
+
out = np.empty((X_arr.shape[0], len(self.powers_)), dtype=np.float64)
|
|
79
|
+
for j, combo in enumerate(self.powers_):
|
|
80
|
+
out[:, j] = 1.0 if not combo else np.prod(X_arr[:, combo], axis=1)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
84
|
+
return self.fit(X).transform(X)
|
|
85
|
+
|
|
86
|
+
def get_feature_names(self, input_features: list[str] | None = None) -> list[str]:
|
|
87
|
+
"""Return human-readable names such as ``"x0 x1^2"`` for each output column."""
|
|
88
|
+
if self.powers_ is None:
|
|
89
|
+
raise RuntimeError("Call fit() before get_feature_names().")
|
|
90
|
+
names_in = (
|
|
91
|
+
input_features
|
|
92
|
+
if input_features is not None
|
|
93
|
+
else [f"x{i}" for i in range(self.n_features_in_)]
|
|
94
|
+
)
|
|
95
|
+
names = []
|
|
96
|
+
for combo in self.powers_:
|
|
97
|
+
if not combo:
|
|
98
|
+
names.append("1")
|
|
99
|
+
continue
|
|
100
|
+
counts = Counter(combo)
|
|
101
|
+
parts = [
|
|
102
|
+
f"{names_in[i]}^{c}" if c > 1 else names_in[i] for i, c in sorted(counts.items())
|
|
103
|
+
]
|
|
104
|
+
names.append(" ".join(parts))
|
|
105
|
+
return names
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Feature Scalers
|
|
3
|
+
================
|
|
4
|
+
Stateful, sklearn-style fit/transform preprocessors for rescaling
|
|
5
|
+
feature columns prior to model fitting.
|
|
6
|
+
|
|
7
|
+
StandardScaler
|
|
8
|
+
---------------
|
|
9
|
+
.. math::
|
|
10
|
+
x' = \frac{x - \mu}{\sigma}
|
|
11
|
+
|
|
12
|
+
MinMaxScaler
|
|
13
|
+
-------------
|
|
14
|
+
.. math::
|
|
15
|
+
x' = \frac{x - x_{\min}}{x_{\max}-x_{\min}} \cdot (b-a) + a
|
|
16
|
+
|
|
17
|
+
RobustScaler
|
|
18
|
+
-------------
|
|
19
|
+
Centres on the median and scales by the interquartile range, so it is
|
|
20
|
+
not skewed by outliers the way StandardScaler's mean/std are.
|
|
21
|
+
|
|
22
|
+
.. math::
|
|
23
|
+
x' = \frac{x - \mathrm{median}(x)}{Q_3(x) - Q_1(x)}
|
|
24
|
+
|
|
25
|
+
Normalizer
|
|
26
|
+
-----------
|
|
27
|
+
Row-wise (per-sample) rescaling to a unit L1, L2, or max norm —
|
|
28
|
+
stateless: every row is scaled independently of every other row, so
|
|
29
|
+
``fit`` is a no-op kept only for API symmetry with the other
|
|
30
|
+
transformers (and so it can be dropped into a uniform preprocessing
|
|
31
|
+
pipeline).
|
|
32
|
+
|
|
33
|
+
Complexity
|
|
34
|
+
----------
|
|
35
|
+
O(n d) fit and transform for all four transformers.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
from numpy.typing import ArrayLike, NDArray
|
|
42
|
+
|
|
43
|
+
FloatArray = NDArray[np.float64]
|
|
44
|
+
_EPS = 1e-12
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_x(X: ArrayLike) -> FloatArray:
|
|
48
|
+
X_arr = np.asarray(X, dtype=np.float64)
|
|
49
|
+
if X_arr.ndim != 2:
|
|
50
|
+
raise ValueError("X must be a 2D array of shape (n_samples, n_features).")
|
|
51
|
+
return X_arr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_n_features(X_arr: FloatArray, expected: int) -> None:
|
|
55
|
+
if X_arr.shape[1] != expected:
|
|
56
|
+
raise ValueError(f"X has {X_arr.shape[1]} features but transformer was fit on {expected}.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StandardScaler:
|
|
60
|
+
"""Standardise features to zero mean and unit variance.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
with_mean : bool, default=True
|
|
65
|
+
with_std : bool, default=True
|
|
66
|
+
|
|
67
|
+
Attributes
|
|
68
|
+
----------
|
|
69
|
+
mean_, scale_ : per-feature mean and standard deviation used to
|
|
70
|
+
transform (``scale_`` is floored at 1.0 for constant columns,
|
|
71
|
+
so they pass through as all-zero rather than producing NaNs).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, with_mean: bool = True, with_std: bool = True) -> None:
|
|
75
|
+
self.with_mean = with_mean
|
|
76
|
+
self.with_std = with_std
|
|
77
|
+
self.mean_: FloatArray | None = None
|
|
78
|
+
self.scale_: FloatArray | None = None
|
|
79
|
+
self.n_features_in_: int | None = None
|
|
80
|
+
|
|
81
|
+
def fit(self, X: ArrayLike) -> StandardScaler:
|
|
82
|
+
X_arr = _validate_x(X)
|
|
83
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
84
|
+
self.mean_ = X_arr.mean(axis=0) if self.with_mean else np.zeros(X_arr.shape[1])
|
|
85
|
+
std = X_arr.std(axis=0) if self.with_std else np.ones(X_arr.shape[1])
|
|
86
|
+
self.scale_ = np.where(std > _EPS, std, 1.0)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
90
|
+
if self.mean_ is None:
|
|
91
|
+
raise RuntimeError("Call fit() before transform().")
|
|
92
|
+
X_arr = _validate_x(X)
|
|
93
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
94
|
+
return (X_arr - self.mean_) / self.scale_
|
|
95
|
+
|
|
96
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
97
|
+
return self.fit(X).transform(X)
|
|
98
|
+
|
|
99
|
+
def inverse_transform(self, X: ArrayLike) -> FloatArray:
|
|
100
|
+
if self.mean_ is None:
|
|
101
|
+
raise RuntimeError("Call fit() before inverse_transform().")
|
|
102
|
+
X_arr = _validate_x(X)
|
|
103
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
104
|
+
return X_arr * self.scale_ + self.mean_
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MinMaxScaler:
|
|
108
|
+
"""Linearly rescale features into ``feature_range`` (default ``[0, 1]``)."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, feature_range: tuple[float, float] = (0.0, 1.0)) -> None:
|
|
111
|
+
lo, hi = feature_range
|
|
112
|
+
if lo >= hi:
|
|
113
|
+
raise ValueError("feature_range must satisfy min < max.")
|
|
114
|
+
self.feature_range = feature_range
|
|
115
|
+
self.data_min_: FloatArray | None = None
|
|
116
|
+
self.data_max_: FloatArray | None = None
|
|
117
|
+
self.n_features_in_: int | None = None
|
|
118
|
+
|
|
119
|
+
def fit(self, X: ArrayLike) -> MinMaxScaler:
|
|
120
|
+
X_arr = _validate_x(X)
|
|
121
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
122
|
+
self.data_min_ = X_arr.min(axis=0)
|
|
123
|
+
self.data_max_ = X_arr.max(axis=0)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
127
|
+
if self.data_min_ is None:
|
|
128
|
+
raise RuntimeError("Call fit() before transform().")
|
|
129
|
+
X_arr = _validate_x(X)
|
|
130
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
131
|
+
data_range = np.where(
|
|
132
|
+
self.data_max_ - self.data_min_ > _EPS, self.data_max_ - self.data_min_, 1.0
|
|
133
|
+
)
|
|
134
|
+
lo, hi = self.feature_range
|
|
135
|
+
return (X_arr - self.data_min_) / data_range * (hi - lo) + lo
|
|
136
|
+
|
|
137
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
138
|
+
return self.fit(X).transform(X)
|
|
139
|
+
|
|
140
|
+
def inverse_transform(self, X: ArrayLike) -> FloatArray:
|
|
141
|
+
if self.data_min_ is None:
|
|
142
|
+
raise RuntimeError("Call fit() before inverse_transform().")
|
|
143
|
+
X_arr = _validate_x(X)
|
|
144
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
145
|
+
data_range = self.data_max_ - self.data_min_
|
|
146
|
+
lo, hi = self.feature_range
|
|
147
|
+
return (X_arr - lo) / (hi - lo) * data_range + self.data_min_
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RobustScaler:
|
|
151
|
+
"""Centre on the median and scale by the interquartile range (IQR),
|
|
152
|
+
so outliers (which inflate mean/std) don't dominate the scaling."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, quantile_range: tuple[float, float] = (25.0, 75.0)) -> None:
|
|
155
|
+
q_min, q_max = quantile_range
|
|
156
|
+
if not (0.0 <= q_min < q_max <= 100.0):
|
|
157
|
+
raise ValueError("quantile_range must satisfy 0 <= q_min < q_max <= 100.")
|
|
158
|
+
self.quantile_range = quantile_range
|
|
159
|
+
self.center_: FloatArray | None = None
|
|
160
|
+
self.scale_: FloatArray | None = None
|
|
161
|
+
self.n_features_in_: int | None = None
|
|
162
|
+
|
|
163
|
+
def fit(self, X: ArrayLike) -> RobustScaler:
|
|
164
|
+
X_arr = _validate_x(X)
|
|
165
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
166
|
+
self.center_ = np.median(X_arr, axis=0)
|
|
167
|
+
q_min, q_max = self.quantile_range
|
|
168
|
+
iqr = np.percentile(X_arr, q_max, axis=0) - np.percentile(X_arr, q_min, axis=0)
|
|
169
|
+
self.scale_ = np.where(iqr > _EPS, iqr, 1.0)
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
173
|
+
if self.center_ is None:
|
|
174
|
+
raise RuntimeError("Call fit() before transform().")
|
|
175
|
+
X_arr = _validate_x(X)
|
|
176
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
177
|
+
return (X_arr - self.center_) / self.scale_
|
|
178
|
+
|
|
179
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
180
|
+
return self.fit(X).transform(X)
|
|
181
|
+
|
|
182
|
+
def inverse_transform(self, X: ArrayLike) -> FloatArray:
|
|
183
|
+
if self.center_ is None:
|
|
184
|
+
raise RuntimeError("Call fit() before inverse_transform().")
|
|
185
|
+
X_arr = _validate_x(X)
|
|
186
|
+
_check_n_features(X_arr, self.n_features_in_)
|
|
187
|
+
return X_arr * self.scale_ + self.center_
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Normalizer:
|
|
191
|
+
"""Rescale each *row* (sample) independently to unit norm.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
norm : str, default='l2'
|
|
196
|
+
``'l1'``, ``'l2'``, or ``'max'``.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, norm: str = "l2") -> None:
|
|
200
|
+
if norm not in ("l1", "l2", "max"):
|
|
201
|
+
raise ValueError("norm must be 'l1', 'l2', or 'max'.")
|
|
202
|
+
self.norm = norm
|
|
203
|
+
|
|
204
|
+
def fit(self, X: ArrayLike) -> Normalizer:
|
|
205
|
+
_validate_x(X) # validate only; this transformer is stateless
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def transform(self, X: ArrayLike) -> FloatArray:
|
|
209
|
+
X_arr = _validate_x(X)
|
|
210
|
+
if self.norm == "l1":
|
|
211
|
+
norms = np.sum(np.abs(X_arr), axis=1)
|
|
212
|
+
elif self.norm == "l2":
|
|
213
|
+
norms = np.sqrt(np.sum(X_arr**2, axis=1))
|
|
214
|
+
else:
|
|
215
|
+
norms = np.max(np.abs(X_arr), axis=1)
|
|
216
|
+
norms = np.where(norms > _EPS, norms, 1.0)
|
|
217
|
+
return X_arr / norms[:, None]
|
|
218
|
+
|
|
219
|
+
def fit_transform(self, X: ArrayLike) -> FloatArray:
|
|
220
|
+
return self.fit(X).transform(X)
|
mlscratch/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mlscratch.reinforcement
|
|
3
|
+
========================
|
|
4
|
+
From-scratch implementations of Reinforcement Learning algorithms.
|
|
5
|
+
Pure numpy — no PyTorch, TensorFlow, or gym dependency required.
|
|
6
|
+
|
|
7
|
+
Algorithms
|
|
8
|
+
----------
|
|
9
|
+
QLearning – Tabular Q-Learning (Watkins & Dayan, 1992)
|
|
10
|
+
DoubleQLearning – Double Q-Learning (van Hasselt, 2010)
|
|
11
|
+
LinearQLearning – Q-Learning with linear function approximation
|
|
12
|
+
DQN – Deep Q-Network with Double DQN + Dueling + PER
|
|
13
|
+
DuelingMLP – Dueling network architecture (stand-alone)
|
|
14
|
+
DDPG – Deep Deterministic Policy Gradient
|
|
15
|
+
TD3 – Twin Delayed DDPG (Fujimoto et al., 2018)
|
|
16
|
+
PPO – Proximal Policy Optimization (clip & KL variants)
|
|
17
|
+
SAC – Soft Actor-Critic with auto-entropy tuning
|
|
18
|
+
|
|
19
|
+
Shared utilities (mlscratch.reinforcement.utils)
|
|
20
|
+
-------------------------------------------------
|
|
21
|
+
GridWorld – Tabular grid-world environment
|
|
22
|
+
ContinuousEnv – 1-D point-mass continuous control environment
|
|
23
|
+
DiscreteEnv – Discrete-action wrapper for ContinuousEnv
|
|
24
|
+
ReplayBuffer – Uniform experience replay
|
|
25
|
+
PrioritizedReplayBuffer – Prioritised experience replay (sum-tree)
|
|
26
|
+
MLP – Pure-numpy MLP with Adam + backprop
|
|
27
|
+
OrnsteinUhlenbeckNoise – Temporally-correlated exploration noise
|
|
28
|
+
GaussianNoise – i.i.d. Gaussian exploration noise
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .q_learning import QLearning, DoubleQLearning, LinearQLearning # noqa: F401
|
|
32
|
+
from .dqn import DQN, DuelingMLP # noqa: F401
|
|
33
|
+
from .ddpg import DDPG, TD3 # noqa: F401
|
|
34
|
+
from .ppo import PPO # noqa: F401
|
|
35
|
+
from .sac import SAC # noqa: F401
|
|
36
|
+
from .utils import ( # noqa: F401
|
|
37
|
+
GridWorld,
|
|
38
|
+
ContinuousEnv,
|
|
39
|
+
DiscreteEnv,
|
|
40
|
+
ReplayBuffer,
|
|
41
|
+
PrioritizedReplayBuffer,
|
|
42
|
+
MLP,
|
|
43
|
+
OrnsteinUhlenbeckNoise,
|
|
44
|
+
GaussianNoise,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Q-Learning family
|
|
49
|
+
"QLearning", "DoubleQLearning", "LinearQLearning",
|
|
50
|
+
# Deep RL
|
|
51
|
+
"DQN", "DuelingMLP",
|
|
52
|
+
"DDPG", "TD3",
|
|
53
|
+
"PPO",
|
|
54
|
+
"SAC",
|
|
55
|
+
# Environments & utilities
|
|
56
|
+
"GridWorld", "ContinuousEnv", "DiscreteEnv",
|
|
57
|
+
"ReplayBuffer", "PrioritizedReplayBuffer",
|
|
58
|
+
"MLP", "OrnsteinUhlenbeckNoise", "GaussianNoise",
|
|
59
|
+
]
|