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.
Files changed (68) hide show
  1. mlscratch/__init__.py +56 -0
  2. mlscratch/__main__.py +118 -0
  3. mlscratch/bayesian/__init__.py +53 -0
  4. mlscratch/bayesian/bayesian_linear_regression.py +171 -0
  5. mlscratch/bayesian/bayesian_network.py +248 -0
  6. mlscratch/bayesian/bayesian_nn.py +315 -0
  7. mlscratch/bayesian/gaussian_process.py +207 -0
  8. mlscratch/bayesian/hmm.py +277 -0
  9. mlscratch/bayesian/init.py +52 -0
  10. mlscratch/bayesian/kalman_filter.py +182 -0
  11. mlscratch/bayesian/naive_bayes.py +209 -0
  12. mlscratch/metrics/__init__.py +59 -0
  13. mlscratch/metrics/classification.py +365 -0
  14. mlscratch/metrics/regression.py +79 -0
  15. mlscratch/neural/__init__.py +121 -0
  16. mlscratch/neural/attention.py +420 -0
  17. mlscratch/neural/autoencoder.py +543 -0
  18. mlscratch/neural/boltzmann.py +231 -0
  19. mlscratch/neural/cnn.py +593 -0
  20. mlscratch/neural/cvnn.py +322 -0
  21. mlscratch/neural/gan.py +364 -0
  22. mlscratch/neural/hopfield.py +193 -0
  23. mlscratch/neural/perceptron.py +398 -0
  24. mlscratch/neural/rbf_network.py +230 -0
  25. mlscratch/neural/recurrent.py +569 -0
  26. mlscratch/preprocessing/__init__.py +38 -0
  27. mlscratch/preprocessing/encoders.py +140 -0
  28. mlscratch/preprocessing/model_selection.py +119 -0
  29. mlscratch/preprocessing/polynomial.py +105 -0
  30. mlscratch/preprocessing/scalers.py +220 -0
  31. mlscratch/py.typed +0 -0
  32. mlscratch/reinforcement/__init__.py +59 -0
  33. mlscratch/reinforcement/ddpg.py +363 -0
  34. mlscratch/reinforcement/dqn.py +319 -0
  35. mlscratch/reinforcement/ppo.py +452 -0
  36. mlscratch/reinforcement/q_learning.py +352 -0
  37. mlscratch/reinforcement/sac.py +382 -0
  38. mlscratch/reinforcement/utils.py +594 -0
  39. mlscratch/supervised/__init__.py +76 -0
  40. mlscratch/supervised/_validation.py +50 -0
  41. mlscratch/supervised/adaboost.py +255 -0
  42. mlscratch/supervised/decision_tree.py +495 -0
  43. mlscratch/supervised/gradient_boosting.py +354 -0
  44. mlscratch/supervised/knn.py +234 -0
  45. mlscratch/supervised/lasso_regression.py +125 -0
  46. mlscratch/supervised/linear_models.py +459 -0
  47. mlscratch/supervised/linear_regression.py +197 -0
  48. mlscratch/supervised/logistic_regression.py +119 -0
  49. mlscratch/supervised/naive_bayes.py +113 -0
  50. mlscratch/supervised/random_forest.py +321 -0
  51. mlscratch/supervised/ridge_regression.py +93 -0
  52. mlscratch/supervised/svm.py +356 -0
  53. mlscratch/unsupervised/__init__.py +39 -0
  54. mlscratch/unsupervised/apriori.py +178 -0
  55. mlscratch/unsupervised/dbscan.py +141 -0
  56. mlscratch/unsupervised/gmm.py +204 -0
  57. mlscratch/unsupervised/hierarchical_clustering.py +137 -0
  58. mlscratch/unsupervised/ica.py +167 -0
  59. mlscratch/unsupervised/kmeans.py +135 -0
  60. mlscratch/unsupervised/kmedoids.py +133 -0
  61. mlscratch/unsupervised/pca.py +103 -0
  62. mlscratch/unsupervised/tsne.py +200 -0
  63. scratchkit-0.2.0.dist-info/METADATA +241 -0
  64. scratchkit-0.2.0.dist-info/RECORD +68 -0
  65. scratchkit-0.2.0.dist-info/WHEEL +5 -0
  66. scratchkit-0.2.0.dist-info/entry_points.txt +2 -0
  67. scratchkit-0.2.0.dist-info/licenses/LICENSE +201 -0
  68. 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
+ ]