parzen_window 0.0.2__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.
- parzen_window/__init__.py +7 -0
- parzen_window/compaction.py +83 -0
- parzen_window/core.py +219 -0
- parzen_window/io.py +33 -0
- parzen_window/kernels.py +45 -0
- parzen_window/scheduler.py +82 -0
- parzen_window-0.0.2.dist-info/METADATA +224 -0
- parzen_window-0.0.2.dist-info/RECORD +10 -0
- parzen_window-0.0.2.dist-info/WHEEL +4 -0
- parzen_window-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Dataset compaction via convex-neighborhood analysis.
|
|
2
|
+
|
|
3
|
+
A training point contributes almost nothing to the decision boundary when
|
|
4
|
+
it is (a) surrounded entirely by same-class neighbors -- i.e. it sits deep
|
|
5
|
+
inside a convex, single-class neighborhood -- and (b) far from the nearest
|
|
6
|
+
point of any other class. Such points are safe to drop to free up memory,
|
|
7
|
+
since the kernel weight they contribute is dominated by points closer to
|
|
8
|
+
the actual boundary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from sklearn.neighbors import NearestNeighbors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_removal_scores(X: np.ndarray, y: np.ndarray, n_neighbors: int) -> np.ndarray:
|
|
18
|
+
"""Score each point by how safe it is to remove (higher = safer).
|
|
19
|
+
|
|
20
|
+
A point scores 0 (never removed) unless all of its ``n_neighbors``
|
|
21
|
+
nearest neighbors share its label; otherwise its score is the distance
|
|
22
|
+
to the nearest point of a *different* class -- a proxy for how far it
|
|
23
|
+
sits from the decision boundary.
|
|
24
|
+
"""
|
|
25
|
+
n_samples = len(X)
|
|
26
|
+
k = min(n_neighbors, n_samples - 1)
|
|
27
|
+
if k < 1:
|
|
28
|
+
return np.zeros(n_samples)
|
|
29
|
+
|
|
30
|
+
neighbors = NearestNeighbors(n_neighbors=k + 1).fit(X)
|
|
31
|
+
_, indices = neighbors.kneighbors(X)
|
|
32
|
+
indices = indices[:, 1:] # drop the point itself (distance 0)
|
|
33
|
+
homogeneous = (y[indices] == y[:, None]).all(axis=1)
|
|
34
|
+
|
|
35
|
+
margin = np.zeros(n_samples)
|
|
36
|
+
for label in np.unique(y):
|
|
37
|
+
own, other = y == label, y != label
|
|
38
|
+
if not other.any() or not own.any():
|
|
39
|
+
continue
|
|
40
|
+
boundary = NearestNeighbors(n_neighbors=1).fit(X[other])
|
|
41
|
+
boundary_distance, _ = boundary.kneighbors(X[own])
|
|
42
|
+
margin[own] = boundary_distance[:, 0]
|
|
43
|
+
|
|
44
|
+
return np.where(homogeneous, margin, 0.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def select_points_to_remove(
|
|
48
|
+
X: np.ndarray,
|
|
49
|
+
y: np.ndarray,
|
|
50
|
+
reduction_fraction: float,
|
|
51
|
+
n_neighbors: int = 5,
|
|
52
|
+
min_per_class: int = 2,
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""Pick indices that can be dropped without touching the decision boundary.
|
|
55
|
+
|
|
56
|
+
Points are removed greedily, safest (highest score) first, until either
|
|
57
|
+
``reduction_fraction`` of the dataset has been removed, points with a
|
|
58
|
+
zero score are reached (mixed neighborhood or near the boundary), or a
|
|
59
|
+
class would drop below ``min_per_class`` remaining points.
|
|
60
|
+
"""
|
|
61
|
+
if not 0.0 < reduction_fraction < 1.0:
|
|
62
|
+
raise ValueError("reduction_fraction must be in (0, 1)")
|
|
63
|
+
|
|
64
|
+
n_samples = len(X)
|
|
65
|
+
target_removals = int(n_samples * reduction_fraction)
|
|
66
|
+
if target_removals <= 0:
|
|
67
|
+
return np.array([], dtype=int)
|
|
68
|
+
|
|
69
|
+
scores = compute_removal_scores(X, y, n_neighbors)
|
|
70
|
+
order = np.argsort(scores)[::-1]
|
|
71
|
+
remaining_per_class = {label: int(np.sum(y == label)) for label in np.unique(y)}
|
|
72
|
+
|
|
73
|
+
to_remove = []
|
|
74
|
+
for idx in order:
|
|
75
|
+
if len(to_remove) >= target_removals or scores[idx] <= 0:
|
|
76
|
+
break
|
|
77
|
+
label = y[idx]
|
|
78
|
+
if remaining_per_class[label] - 1 < min_per_class:
|
|
79
|
+
continue
|
|
80
|
+
remaining_per_class[label] -= 1
|
|
81
|
+
to_remove.append(idx)
|
|
82
|
+
|
|
83
|
+
return np.array(sorted(to_remove), dtype=int)
|
parzen_window/core.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Parzen window classifier with incremental updates, compaction and persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.spatial.distance import cdist
|
|
7
|
+
from sklearn.base import BaseEstimator, ClassifierMixin
|
|
8
|
+
from sklearn.neighbors import NearestNeighbors
|
|
9
|
+
from sklearn.utils.validation import check_array, check_is_fitted
|
|
10
|
+
|
|
11
|
+
from . import io as _io
|
|
12
|
+
from .compaction import select_points_to_remove
|
|
13
|
+
from .kernels import resolve_kernel
|
|
14
|
+
from .scheduler import CompactionScheduler
|
|
15
|
+
|
|
16
|
+
_NOT_FITTED_MSG = "This ParzenWindowClassifier instance is not fitted yet. Call fit() first."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParzenWindowClassifier(BaseEstimator, ClassifierMixin):
|
|
20
|
+
"""Non-parametric classifier based on the Parzen window method.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
h : float, default=0.5
|
|
25
|
+
Kernel bandwidth. Used as-is when ``adaptive_bandwidth=False``; used
|
|
26
|
+
as a scale factor on the per-point bandwidth when it is ``True``.
|
|
27
|
+
kernel : {"gaussian", "epanechnikov", "quartic", "triangular", "rectangular"}
|
|
28
|
+
Kernel function shape.
|
|
29
|
+
adaptive_bandwidth : bool, default=False
|
|
30
|
+
When ``True``, each training point gets its own bandwidth equal to
|
|
31
|
+
``h`` times its distance to its ``bandwidth_neighbors``-th nearest
|
|
32
|
+
neighbor, instead of a single global ``h``. This lets the kernel
|
|
33
|
+
widen in sparse regions and narrow in dense ones (a variable-kernel
|
|
34
|
+
/ "balloon" estimator), which the fixed-bandwidth Parzen window
|
|
35
|
+
cannot do.
|
|
36
|
+
bandwidth_neighbors : int, default=5
|
|
37
|
+
Number of neighbors used to derive the adaptive bandwidth. Ignored
|
|
38
|
+
when ``adaptive_bandwidth=False``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
h: float = 0.5,
|
|
44
|
+
kernel: str = "gaussian",
|
|
45
|
+
adaptive_bandwidth: bool = False,
|
|
46
|
+
bandwidth_neighbors: int = 5,
|
|
47
|
+
):
|
|
48
|
+
self.h = h
|
|
49
|
+
self.kernel = kernel
|
|
50
|
+
self.adaptive_bandwidth = adaptive_bandwidth
|
|
51
|
+
self.bandwidth_neighbors = bandwidth_neighbors
|
|
52
|
+
self._scheduler = CompactionScheduler()
|
|
53
|
+
self._bandwidth_cache = None
|
|
54
|
+
|
|
55
|
+
# -- persistence-related plumbing --------------------------------
|
|
56
|
+
|
|
57
|
+
def __getstate__(self):
|
|
58
|
+
state = self.__dict__.copy()
|
|
59
|
+
state.pop("_scheduler", None)
|
|
60
|
+
return state
|
|
61
|
+
|
|
62
|
+
def __setstate__(self, state):
|
|
63
|
+
self.__dict__.update(state)
|
|
64
|
+
self._scheduler = CompactionScheduler()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_compacting(self) -> bool:
|
|
68
|
+
return self._scheduler.is_compacting
|
|
69
|
+
|
|
70
|
+
# -- fitting --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def fit(self, X, y):
|
|
73
|
+
"""(Re)fit the model from scratch, discarding any previous data."""
|
|
74
|
+
X = check_array(X)
|
|
75
|
+
y = np.asarray(y)
|
|
76
|
+
return self._scheduler.call(self._fit_impl, X, y)
|
|
77
|
+
|
|
78
|
+
def _fit_impl(self, X, y):
|
|
79
|
+
self.X_train_ = X
|
|
80
|
+
self.y_train_ = y
|
|
81
|
+
self.classes_ = np.unique(y)
|
|
82
|
+
self._bandwidth_cache = None
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def partial_fit(self, X, y):
|
|
86
|
+
"""Incrementally fit the model on a new batch, keeping prior data.
|
|
87
|
+
|
|
88
|
+
While a :meth:`compact` call is in progress on another thread, this
|
|
89
|
+
call does not fail -- it is queued and applied automatically right
|
|
90
|
+
after compaction finishes.
|
|
91
|
+
"""
|
|
92
|
+
check_is_fitted(self, "X_train_", msg=_NOT_FITTED_MSG)
|
|
93
|
+
X = check_array(X)
|
|
94
|
+
y = np.asarray(y)
|
|
95
|
+
return self._scheduler.call(self._partial_fit_impl, X, y)
|
|
96
|
+
|
|
97
|
+
def _partial_fit_impl(self, X, y):
|
|
98
|
+
self.X_train_ = np.vstack([self.X_train_, X])
|
|
99
|
+
self.y_train_ = np.concatenate([self.y_train_, y])
|
|
100
|
+
self.classes_ = np.unique(self.y_train_)
|
|
101
|
+
self._bandwidth_cache = None
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
# -- inference --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def predict(self, X):
|
|
107
|
+
"""Predict class labels. Queued automatically if compaction is running."""
|
|
108
|
+
check_is_fitted(self, "X_train_", msg=_NOT_FITTED_MSG)
|
|
109
|
+
X = check_array(X)
|
|
110
|
+
return self._scheduler.call(self._predict_impl, X)
|
|
111
|
+
|
|
112
|
+
def _predict_impl(self, X):
|
|
113
|
+
scores = self._class_scores(X)
|
|
114
|
+
return self.classes_[np.argmax(scores, axis=1)]
|
|
115
|
+
|
|
116
|
+
def predict_proba(self, X):
|
|
117
|
+
"""Predict per-class weights normalized to sum to 1 for each sample."""
|
|
118
|
+
check_is_fitted(self, "X_train_", msg=_NOT_FITTED_MSG)
|
|
119
|
+
X = check_array(X)
|
|
120
|
+
return self._scheduler.call(self._predict_proba_impl, X)
|
|
121
|
+
|
|
122
|
+
def _predict_proba_impl(self, X):
|
|
123
|
+
scores = self._class_scores(X)
|
|
124
|
+
totals = scores.sum(axis=1, keepdims=True)
|
|
125
|
+
totals[totals == 0] = 1.0 # query point has ~zero kernel weight everywhere
|
|
126
|
+
return scores / totals
|
|
127
|
+
|
|
128
|
+
def _class_scores(self, X):
|
|
129
|
+
kernel_fn = resolve_kernel(self.kernel)
|
|
130
|
+
distances = cdist(X, self.X_train_)
|
|
131
|
+
kernel_values = kernel_fn(distances / self._bandwidth())
|
|
132
|
+
scores = np.zeros((len(X), len(self.classes_)))
|
|
133
|
+
for i, label in enumerate(self.classes_):
|
|
134
|
+
scores[:, i] = kernel_values[:, self.y_train_ == label].sum(axis=1)
|
|
135
|
+
return scores
|
|
136
|
+
|
|
137
|
+
def _bandwidth(self):
|
|
138
|
+
if not self.adaptive_bandwidth:
|
|
139
|
+
return self.h
|
|
140
|
+
if self._bandwidth_cache is None:
|
|
141
|
+
self._bandwidth_cache = self._compute_adaptive_bandwidth()
|
|
142
|
+
return self._bandwidth_cache
|
|
143
|
+
|
|
144
|
+
def _compute_adaptive_bandwidth(self):
|
|
145
|
+
n_samples = len(self.X_train_)
|
|
146
|
+
k = min(self.bandwidth_neighbors, n_samples - 1)
|
|
147
|
+
if k < 1:
|
|
148
|
+
return np.full(n_samples, self.h)
|
|
149
|
+
neighbors = NearestNeighbors(n_neighbors=k + 1).fit(self.X_train_)
|
|
150
|
+
distances, _ = neighbors.kneighbors(self.X_train_)
|
|
151
|
+
bandwidth = self.h * distances[:, -1]
|
|
152
|
+
bandwidth[bandwidth == 0] = self.h
|
|
153
|
+
return bandwidth
|
|
154
|
+
|
|
155
|
+
# -- memory management --------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def compact(
|
|
158
|
+
self, reduction_fraction: float, n_neighbors: int = 5, min_per_class: int = 2
|
|
159
|
+
) -> int:
|
|
160
|
+
"""Drop training points that lie deep inside their own class's territory.
|
|
161
|
+
|
|
162
|
+
Uses convex-neighborhood analysis (see :mod:`parzen_window.compaction`)
|
|
163
|
+
to find points that are both surrounded entirely by same-class
|
|
164
|
+
neighbors and far from the decision boundary, then removes up to
|
|
165
|
+
``reduction_fraction`` of the dataset. While this runs, other threads'
|
|
166
|
+
``predict``/``partial_fit`` calls are queued and executed automatically
|
|
167
|
+
as soon as compaction completes, instead of failing.
|
|
168
|
+
|
|
169
|
+
Returns the number of points actually removed.
|
|
170
|
+
"""
|
|
171
|
+
check_is_fitted(self, "X_train_", msg=_NOT_FITTED_MSG)
|
|
172
|
+
return self._scheduler.run_exclusively(
|
|
173
|
+
self._compact_impl, reduction_fraction, n_neighbors, min_per_class
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _compact_impl(self, reduction_fraction, n_neighbors, min_per_class):
|
|
177
|
+
to_remove = select_points_to_remove(
|
|
178
|
+
self.X_train_, self.y_train_, reduction_fraction, n_neighbors, min_per_class
|
|
179
|
+
)
|
|
180
|
+
if len(to_remove) == 0:
|
|
181
|
+
return 0
|
|
182
|
+
mask = np.ones(len(self.X_train_), dtype=bool)
|
|
183
|
+
mask[to_remove] = False
|
|
184
|
+
self.X_train_ = self.X_train_[mask]
|
|
185
|
+
self.y_train_ = self.y_train_[mask]
|
|
186
|
+
self._bandwidth_cache = None
|
|
187
|
+
return len(to_remove)
|
|
188
|
+
|
|
189
|
+
# -- persistence --------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def save(self, path) -> None:
|
|
192
|
+
"""Save model weights to a ``.npz`` file for backup or transfer."""
|
|
193
|
+
check_is_fitted(self, "X_train_", msg=_NOT_FITTED_MSG)
|
|
194
|
+
self._scheduler.call(
|
|
195
|
+
_io.save_npz,
|
|
196
|
+
path,
|
|
197
|
+
h=self.h,
|
|
198
|
+
kernel=self.kernel,
|
|
199
|
+
adaptive_bandwidth=self.adaptive_bandwidth,
|
|
200
|
+
bandwidth_neighbors=self.bandwidth_neighbors,
|
|
201
|
+
X_train=self.X_train_,
|
|
202
|
+
y_train=self.y_train_,
|
|
203
|
+
classes=self.classes_,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def load(cls, path) -> ParzenWindowClassifier:
|
|
208
|
+
"""Load a model previously saved with :meth:`save`."""
|
|
209
|
+
data = _io.load_npz(path)
|
|
210
|
+
model = cls(
|
|
211
|
+
h=data["h"],
|
|
212
|
+
kernel=data["kernel"],
|
|
213
|
+
adaptive_bandwidth=data["adaptive_bandwidth"],
|
|
214
|
+
bandwidth_neighbors=data["bandwidth_neighbors"],
|
|
215
|
+
)
|
|
216
|
+
model.X_train_ = data["X_train"]
|
|
217
|
+
model.y_train_ = data["y_train"]
|
|
218
|
+
model.classes_ = data["classes"]
|
|
219
|
+
return model
|
parzen_window/io.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Persisting model weights to/from ``.npz`` files for backups and transfer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_npz(
|
|
9
|
+
path, *, h, kernel, adaptive_bandwidth, bandwidth_neighbors, X_train, y_train, classes
|
|
10
|
+
) -> None:
|
|
11
|
+
np.savez(
|
|
12
|
+
path,
|
|
13
|
+
h=np.asarray(h),
|
|
14
|
+
kernel=np.asarray(kernel),
|
|
15
|
+
adaptive_bandwidth=np.asarray(adaptive_bandwidth),
|
|
16
|
+
bandwidth_neighbors=np.asarray(bandwidth_neighbors),
|
|
17
|
+
X_train=X_train,
|
|
18
|
+
y_train=y_train,
|
|
19
|
+
classes=classes,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_npz(path) -> dict:
|
|
24
|
+
with np.load(path, allow_pickle=False) as data:
|
|
25
|
+
return {
|
|
26
|
+
"h": float(data["h"]),
|
|
27
|
+
"kernel": str(data["kernel"]),
|
|
28
|
+
"adaptive_bandwidth": bool(data["adaptive_bandwidth"]),
|
|
29
|
+
"bandwidth_neighbors": int(data["bandwidth_neighbors"]),
|
|
30
|
+
"X_train": data["X_train"],
|
|
31
|
+
"y_train": data["y_train"],
|
|
32
|
+
"classes": data["classes"],
|
|
33
|
+
}
|
parzen_window/kernels.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Kernel functions used by :class:`parzen_window.core.ParzenWindowClassifier`.
|
|
2
|
+
|
|
3
|
+
Every kernel accepts a numpy array of normalized distances ``u = distance / h``
|
|
4
|
+
and returns the corresponding weight, vectorized over arrays of any shape.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def gaussian(u: np.ndarray) -> np.ndarray:
|
|
13
|
+
return np.exp(-0.5 * u**2) / np.sqrt(2 * np.pi)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def epanechnikov(u: np.ndarray) -> np.ndarray:
|
|
17
|
+
return np.where(np.abs(u) <= 1, 0.75 * (1 - u**2), 0.0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def quartic(u: np.ndarray) -> np.ndarray:
|
|
21
|
+
return np.where(np.abs(u) <= 1, (15.0 / 16.0) * (1 - u**2) ** 2, 0.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def triangular(u: np.ndarray) -> np.ndarray:
|
|
25
|
+
return np.where(np.abs(u) <= 1, 1 - np.abs(u), 0.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rectangular(u: np.ndarray) -> np.ndarray:
|
|
29
|
+
return np.where(np.abs(u) <= 1, 0.5, 0.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
KERNELS = {
|
|
33
|
+
"gaussian": gaussian,
|
|
34
|
+
"epanechnikov": epanechnikov,
|
|
35
|
+
"quartic": quartic,
|
|
36
|
+
"triangular": triangular,
|
|
37
|
+
"rectangular": rectangular,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_kernel(name: str):
|
|
42
|
+
try:
|
|
43
|
+
return KERNELS[name]
|
|
44
|
+
except KeyError as exc:
|
|
45
|
+
raise ValueError(f"Unknown kernel {name!r}. Supported kernels: {sorted(KERNELS)}") from exc
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Thread-safety around dataset compaction.
|
|
2
|
+
|
|
3
|
+
While :meth:`~parzen_window.core.ParzenWindowClassifier.compact` runs, the
|
|
4
|
+
training set is being rewritten in place, so calls to ``predict`` or
|
|
5
|
+
``partial_fit`` made from other threads cannot run concurrently with it.
|
|
6
|
+
Rather than failing, such calls are queued and executed automatically, in
|
|
7
|
+
arrival order, right after compaction finishes -- acting as a small
|
|
8
|
+
scheduler for deferred work.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _PendingCall:
|
|
19
|
+
__slots__ = ("_func", "_args", "_kwargs", "_done", "_result", "_error")
|
|
20
|
+
|
|
21
|
+
def __init__(self, func: Callable, args: tuple, kwargs: dict):
|
|
22
|
+
self._func = func
|
|
23
|
+
self._args = args
|
|
24
|
+
self._kwargs = kwargs
|
|
25
|
+
self._done = threading.Event()
|
|
26
|
+
self._result = None
|
|
27
|
+
self._error = None
|
|
28
|
+
|
|
29
|
+
def run(self) -> None:
|
|
30
|
+
try:
|
|
31
|
+
self._result = self._func(*self._args, **self._kwargs)
|
|
32
|
+
except Exception as error: # re-raised in the waiting thread
|
|
33
|
+
self._error = error
|
|
34
|
+
finally:
|
|
35
|
+
self._done.set()
|
|
36
|
+
|
|
37
|
+
def wait(self):
|
|
38
|
+
self._done.wait()
|
|
39
|
+
if self._error is not None:
|
|
40
|
+
raise self._error
|
|
41
|
+
return self._result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CompactionScheduler:
|
|
45
|
+
"""Serializes access to model data and defers calls during compaction."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._lock = threading.RLock()
|
|
49
|
+
self._compacting = threading.Event()
|
|
50
|
+
self._queue: deque[_PendingCall] = deque()
|
|
51
|
+
self._queue_lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_compacting(self) -> bool:
|
|
55
|
+
return self._compacting.is_set()
|
|
56
|
+
|
|
57
|
+
def call(self, func: Callable, *args, **kwargs):
|
|
58
|
+
"""Run ``func`` now, or queue it while compaction is in progress."""
|
|
59
|
+
if self._compacting.is_set():
|
|
60
|
+
pending = _PendingCall(func, args, kwargs)
|
|
61
|
+
with self._queue_lock:
|
|
62
|
+
self._queue.append(pending)
|
|
63
|
+
return pending.wait()
|
|
64
|
+
with self._lock:
|
|
65
|
+
return func(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
def run_exclusively(self, func: Callable, *args, **kwargs):
|
|
68
|
+
"""Run ``func`` (compaction) with exclusive access to the data."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._compacting.set()
|
|
71
|
+
try:
|
|
72
|
+
return func(*args, **kwargs)
|
|
73
|
+
finally:
|
|
74
|
+
self._compacting.clear()
|
|
75
|
+
self._drain_queue()
|
|
76
|
+
|
|
77
|
+
def _drain_queue(self) -> None:
|
|
78
|
+
with self._queue_lock:
|
|
79
|
+
pending_calls = list(self._queue)
|
|
80
|
+
self._queue.clear()
|
|
81
|
+
for pending in pending_calls:
|
|
82
|
+
pending.run()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: parzen_window
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Parzen window classification with incremental learning, memory-efficient compaction and adaptive bandwidth.
|
|
5
|
+
Project-URL: Homepage, https://github.com/89605502155/parzen_window
|
|
6
|
+
Project-URL: Download, https://github.com/89605502155/parzen_window/archive/main.zip
|
|
7
|
+
Author-email: Andrey Ferubko <ferubko1999@yandex.ru>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: scikit-learn
|
|
24
|
+
Requires-Dist: scipy
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# parzen_window
|
|
28
|
+
|
|
29
|
+
[Русский](#русский) | [English](#english)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## English
|
|
34
|
+
|
|
35
|
+
`parzen_window` is a Python library implementing the Parzen window method for
|
|
36
|
+
classification, built on top of `numpy` and `scikit-learn` (`BaseEstimator`,
|
|
37
|
+
`ClassifierMixin`).
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
- Five kernel shapes: `gaussian`, `epanechnikov`, `quartic`, `triangular`, `rectangular`.
|
|
42
|
+
- **Adaptive bandwidth** — each training point can get its own kernel width,
|
|
43
|
+
derived from the distance to its `k`-th nearest neighbor, instead of a
|
|
44
|
+
single fixed `h` for the whole dataset (a variable-kernel / "balloon"
|
|
45
|
+
estimator). This is the library's scientific contribution over plain
|
|
46
|
+
fixed-bandwidth Parzen window implementations: it widens the kernel in
|
|
47
|
+
sparse regions and narrows it in dense ones automatically.
|
|
48
|
+
- **Incremental learning** — `partial_fit()` extends the model with a new
|
|
49
|
+
batch of data without discarding what it already learned.
|
|
50
|
+
- **Dataset compaction** — `compact()` reduces memory usage by removing
|
|
51
|
+
training points that are deep inside their own class's territory (found
|
|
52
|
+
via convex-neighborhood analysis: a point is dropped only if all its
|
|
53
|
+
nearest neighbors share its label *and* it is far from the nearest point
|
|
54
|
+
of a different class). This shrinks the stored dataset with minimal
|
|
55
|
+
impact on the decision boundary.
|
|
56
|
+
- **Thread-safe compaction with a call scheduler** — while `compact()` is
|
|
57
|
+
running, the training data is locked for writes. `predict()` and
|
|
58
|
+
`partial_fit()` calls from other threads do not fail during this time:
|
|
59
|
+
they are queued and executed automatically as soon as compaction
|
|
60
|
+
finishes.
|
|
61
|
+
- **`.npz` persistence** — `save()`/`load()` for backups and moving a model
|
|
62
|
+
between machines.
|
|
63
|
+
- Fully compatible with `pickle`/`joblib`.
|
|
64
|
+
|
|
65
|
+
### Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install parzen_window
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Quick start
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from parzen_window import ParzenWindowClassifier
|
|
75
|
+
from sklearn.datasets import load_iris
|
|
76
|
+
from sklearn.model_selection import train_test_split
|
|
77
|
+
|
|
78
|
+
X, y = load_iris(return_X_y=True)
|
|
79
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
|
|
80
|
+
|
|
81
|
+
model = ParzenWindowClassifier(h=1.0, kernel="quartic")
|
|
82
|
+
model.fit(X_train, y_train)
|
|
83
|
+
predictions = model.predict(X_test)
|
|
84
|
+
probabilities = model.predict_proba(X_test)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Incremental learning:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
model = ParzenWindowClassifier(h=0.5, kernel="gaussian")
|
|
91
|
+
model.fit(X_batch_1, y_batch_1)
|
|
92
|
+
model.partial_fit(X_batch_2, y_batch_2) # extends, does not discard X_batch_1
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Adaptive bandwidth:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
model = ParzenWindowClassifier(
|
|
99
|
+
h=1.0, kernel="gaussian", adaptive_bandwidth=True, bandwidth_neighbors=5
|
|
100
|
+
)
|
|
101
|
+
model.fit(X_train, y_train)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Compacting a large dataset to cut memory usage by ~25%:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
removed = model.compact(0.25) # returns the number of points actually removed
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Backups:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
model.save("model.npz")
|
|
114
|
+
restored = ParzenWindowClassifier.load("model.npz")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Development
|
|
118
|
+
|
|
119
|
+
The project uses [uv](https://docs.astral.sh/uv/) for dependency management.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv sync --dev # install dependencies
|
|
123
|
+
uv run ruff check . # lint
|
|
124
|
+
uv run ruff format . # format
|
|
125
|
+
uv build # build sdist + wheel
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Русский
|
|
131
|
+
|
|
132
|
+
`parzen_window` — библиотека на Python, реализующая метод Парзеновского окна
|
|
133
|
+
для классификации, построенная поверх `numpy` и `scikit-learn`
|
|
134
|
+
(`BaseEstimator`, `ClassifierMixin`).
|
|
135
|
+
|
|
136
|
+
### Возможности
|
|
137
|
+
|
|
138
|
+
- Пять видов ядер: `gaussian`, `epanechnikov`, `quartic`, `triangular`, `rectangular`.
|
|
139
|
+
- **Адаптивная ширина окна** — каждая точка обучающей выборки может получить
|
|
140
|
+
собственную ширину ядра, вычисленную по расстоянию до её `k`-го ближайшего
|
|
141
|
+
соседа, вместо единого фиксированного `h` на весь датасет (вариант
|
|
142
|
+
variable-kernel / "balloon"-оценки). Это научная новизна библиотеки по
|
|
143
|
+
сравнению с обычными реализациями Парзеновского окна с фиксированной
|
|
144
|
+
шириной: окно автоматически расширяется в разреженных областях и
|
|
145
|
+
сужается в плотных.
|
|
146
|
+
- **Дообучение** — `partial_fit()` расширяет модель новым батчем данных, не
|
|
147
|
+
теряя уже накопленное.
|
|
148
|
+
- **Разрядка датасета** — `compact()` уменьшает потребление памяти, удаляя
|
|
149
|
+
точки обучающей выборки, которые лежат глубоко на территории своего
|
|
150
|
+
класса (метод анализа выпуклых окрестностей: точка удаляется, только
|
|
151
|
+
если все её ближайшие соседи имеют ту же метку класса *и* она далека от
|
|
152
|
+
ближайшей точки другого класса). Это сокращает хранимый датасет с
|
|
153
|
+
минимальным влиянием на границу между классами.
|
|
154
|
+
- **Потокобезопасная разрядка с планировщиком вызовов** — пока выполняется
|
|
155
|
+
`compact()`, обучающие данные заблокированы для записи. Вызовы
|
|
156
|
+
`predict()` и `partial_fit()` из других потоков в это время не падают:
|
|
157
|
+
они встают в очередь и выполняются автоматически сразу после завершения
|
|
158
|
+
разрядки.
|
|
159
|
+
- **Сохранение в `.npz`** — `save()`/`load()` для бэкапов и переноса модели
|
|
160
|
+
между устройствами.
|
|
161
|
+
- Полная совместимость с `pickle`/`joblib`.
|
|
162
|
+
|
|
163
|
+
### Установка
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install parzen_window
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Быстрый старт
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from parzen_window import ParzenWindowClassifier
|
|
173
|
+
from sklearn.datasets import load_iris
|
|
174
|
+
from sklearn.model_selection import train_test_split
|
|
175
|
+
|
|
176
|
+
X, y = load_iris(return_X_y=True)
|
|
177
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
|
|
178
|
+
|
|
179
|
+
model = ParzenWindowClassifier(h=1.0, kernel="quartic")
|
|
180
|
+
model.fit(X_train, y_train)
|
|
181
|
+
predictions = model.predict(X_test)
|
|
182
|
+
probabilities = model.predict_proba(X_test)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Дообучение:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
model = ParzenWindowClassifier(h=0.5, kernel="gaussian")
|
|
189
|
+
model.fit(X_batch_1, y_batch_1)
|
|
190
|
+
model.partial_fit(X_batch_2, y_batch_2) # расширяет, не теряя X_batch_1
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Адаптивная ширина окна:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
model = ParzenWindowClassifier(
|
|
197
|
+
h=1.0, kernel="gaussian", adaptive_bandwidth=True, bandwidth_neighbors=5
|
|
198
|
+
)
|
|
199
|
+
model.fit(X_train, y_train)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Разрядка большого датасета, чтобы сократить потребление памяти примерно на 25%:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
removed = model.compact(0.25) # возвращает число реально удалённых точек
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Бэкапы:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
model.save("model.npz")
|
|
212
|
+
restored = ParzenWindowClassifier.load("model.npz")
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Разработка
|
|
216
|
+
|
|
217
|
+
Проект использует [uv](https://docs.astral.sh/uv/) для управления зависимостями.
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv sync --dev # установить зависимости
|
|
221
|
+
uv run ruff check . # линтер
|
|
222
|
+
uv run ruff format . # форматирование
|
|
223
|
+
uv build # сборка sdist + wheel
|
|
224
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
parzen_window/__init__.py,sha256=OspRBpbnhkD3XEzMp2BKf5MkmPx6ftrDr2krqo4CAWk,175
|
|
2
|
+
parzen_window/compaction.py,sha256=YPjxSig0rFZpsLr-vsND9QCisXLS4UVmoijiHt3pU3o,3130
|
|
3
|
+
parzen_window/core.py,sha256=yX3etp6I_eh0oDg9C_4qHeLcZ-fwlnP48eX0AVcUsXw,8636
|
|
4
|
+
parzen_window/io.py,sha256=VI4BisLjByXaHPvae39pmljwXqe9hU-v-TlFdTLIpJs,1020
|
|
5
|
+
parzen_window/kernels.py,sha256=M9GPPY93h-taYfkp05MAjWUdGJ8HSzj122bZYdg3Sko,1223
|
|
6
|
+
parzen_window/scheduler.py,sha256=5sRrw2mh320cRrQx9AUBwvC4HYBTyurcvyhvzit-Bx8,2764
|
|
7
|
+
parzen_window-0.0.2.dist-info/METADATA,sha256=_NRimgct2knqm5MnRE4OaqOO4pFn5cDF9mKELCmymOE,8822
|
|
8
|
+
parzen_window-0.0.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
parzen_window-0.0.2.dist-info/licenses/LICENSE,sha256=VnRHTC1ELnJKvHM4h2fDqX_VVD-O4p3FJNFmOK6MUJo,1092
|
|
10
|
+
parzen_window-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Andery Ferubko
|
|
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.
|