generate-test-data-for-wknn 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.3
2
+ Name: generate-test-data-for-wknn
3
+ Version: 0.0.1
4
+ Summary: Test-data generator for validating the weighted k-NN (wkNN) method.
5
+ Keywords: knn,weighted-knn,wknn,machine-learning,test-data
6
+ Author: Ferubko Andrey, Kazakov Oleg
7
+ Author-email: Ferubko Andrey <andrey.ferubko@petsplace.ru>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Dist: numpy>=1.24
21
+ Maintainer: Ferubko Andrey, Kazakov Oleg
22
+ Maintainer-email: Ferubko Andrey <andrey.ferubko@petsplace.ru>
23
+ Requires-Python: >=3.10
24
+ Project-URL: Homepage, https://github.com/89605502155/generate-test-data-for-wknn
25
+ Project-URL: Repository, https://github.com/89605502155/generate-test-data-for-wknn
26
+ Project-URL: Issues, https://github.com/89605502155/generate-test-data-for-wknn/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # generate-test-data-for-wknn
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/generate-test-data-for-wknn.svg)](https://pypi.org/project/generate-test-data-for-wknn/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/generate-test-data-for-wknn.svg)](https://pypi.org/project/generate-test-data-for-wknn/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
34
+
35
+ **EN** — Synthetic test-data generator for validating the **weighted k-NN (wkNN)** method.
36
+ **RU** — Генератор синтетических тестовых данных для проверки метода **взвешенного k-NN (wkNN)**.
37
+
38
+ *Authors / Авторы:* Ferubko Andrey, Kazakov Oleg
39
+ *Organization / Организация:* Bryansk State Technological University of Engineering /
40
+ Брянский государственный инженерно-технологический университет.
41
+
42
+ ---
43
+
44
+ ## Installation / Установка
45
+
46
+ ```bash
47
+ pip install generate-test-data-for-wknn
48
+ # or with uv:
49
+ uv add generate-test-data-for-wknn
50
+ ```
51
+
52
+ ---
53
+
54
+ ## English documentation
55
+
56
+ The library exposes a single public function, `generate`, which builds a
57
+ labelled dataset `(x, y)` for testing weighted k-NN.
58
+
59
+ Each class is given a random **anchor** point inside the cube
60
+ `[min_coord, max_abs_coord]^dim`. Every other random point is assigned to the
61
+ class whose **total weight** is largest. Weights are functions of the
62
+ per-coordinate absolute differences `x_i = |p_i - anchor_i|` and must
63
+ **decrease** as distance grows (otherwise a `ValueError` is raised).
64
+
65
+ ### Weight functions and parameter layout
66
+
67
+ | `weight_type` | Formula | `params` length | Constraint |
68
+ |----------------|---------|-----------------|------------|
69
+ | `"constant"` | `w = 1` | ignored | always valid (reduces to plain kNN) |
70
+ | `"linear"` | `w = max(Σ aᵢ·xᵢ + f, 0)` | `dim + 1` → `[a₁,…,a_d, f]` | all `aᵢ ≤ 0` |
71
+ | `"inverse"` | `w = Σ aᵢ / xᵢ^bᵢ + f` | `2·dim + 1` → `[a₁,b₁,…,a_d,b_d, f]` | `aᵢ ≥ 0`, `bᵢ > 0` |
72
+ | `"exponential"`| `w = Σ aᵢ·exp(−bᵢ·xᵢ) + f` | `2·dim + 1` → `[a₁,b₁,…,a_d,b_d, f]` | `aᵢ ≥ 0`, `bᵢ > 0` |
73
+
74
+ > Example (from the spec): for `dim=2`, `linear` with `params=[-1, -8, 9]`
75
+ > corresponds to `w = max(-1·x₁ + -8·x₂ + 9, 0)`.
76
+
77
+ ### Parameters
78
+
79
+ | Name | Default | Meaning |
80
+ |------|---------|---------|
81
+ | `n_classes` | `2` | number of classes (binary by default) |
82
+ | `dim` | `2` | dimensionality of each point |
83
+ | `n_samples` | `30` | number of objects in the dataset |
84
+ | `weight_type` | `"inverse"` | one of the four weighting schemes |
85
+ | `params` | `None` | coefficient array (see table) |
86
+ | `max_abs_coord` | `1e4` | upper coordinate bound (abs value cap) |
87
+ | `min_coord` | `-1e4` | lower coordinate bound (use `0` for e.g. time series) |
88
+ | `outlier_ratio` | `0.0` | fraction of **borderline** labels to flip, must be in `[0, 1]` |
89
+ | `random_state` | `None` | RNG seed |
90
+
91
+ **Returns:** `(x, y)` — `x` is `float64` of shape `(m, dim)`, `y` is `int64` of
92
+ shape `(m,)`, where `m ≤ n_samples` (tie points are removed).
93
+
94
+ ### Behavioural guarantees
95
+
96
+ - **Stratification:** classes are balanced (counts differ by at most 1).
97
+ - **Ties dropped:** points where the top-2 class weights are equal are removed.
98
+ - **Outliers:** the requested fraction of the *most borderline* points is flipped
99
+ to the runner-up class, spread evenly across the cube (not from a single plane).
100
+ - **Monotonicity guard:** non-decreasing weight parameters raise `ValueError`.
101
+
102
+ ### Example
103
+
104
+ ```python
105
+ import numpy as np
106
+ from generate_test_data_for_wknn import generate
107
+
108
+ # Binary, inverse-distance weights, 2-D, reproducible
109
+ x, y = generate(
110
+ n_classes=2,
111
+ dim=2,
112
+ n_samples=50,
113
+ weight_type="inverse",
114
+ params=np.array([2.0, 1.0, 3.0, 1.5, 0.1]), # a1,b1,a2,b2,f
115
+ max_abs_coord=100.0,
116
+ min_coord=0.0, # non-negative coords (e.g. time series)
117
+ outlier_ratio=0.1,
118
+ random_state=42,
119
+ )
120
+ print(x.shape, y.shape, np.bincount(y))
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Документация на русском
126
+
127
+ Библиотека предоставляет одну публичную функцию `generate`, которая строит
128
+ размеченный набор данных `(x, y)` для проверки взвешенного k-NN.
129
+
130
+ Для каждого класса создаётся случайная **опорная точка** внутри куба
131
+ `[min_coord, max_abs_coord]^dim`. Каждой другой случайной точке присваивается
132
+ класс с наибольшим **суммарным весом**. Веса зависят от модулей разностей
133
+ координат `x_i = |p_i − anchor_i|` и **должны убывать** с ростом расстояния
134
+ (иначе возбуждается `ValueError`).
135
+
136
+ ### Весовые функции и формат параметров
137
+
138
+ | `weight_type` | Формула | Длина `params` | Ограничение |
139
+ |----------------|---------|----------------|-------------|
140
+ | `"constant"` | `w = 1` | не используется | всегда допустимо (обычный kNN) |
141
+ | `"linear"` | `w = max(Σ aᵢ·xᵢ + f, 0)` | `dim + 1` | все `aᵢ ≤ 0` |
142
+ | `"inverse"` | `w = Σ aᵢ / xᵢ^bᵢ + f` | `2·dim + 1` | `aᵢ ≥ 0`, `bᵢ > 0` |
143
+ | `"exponential"`| `w = Σ aᵢ·exp(−bᵢ·xᵢ) + f` | `2·dim + 1` | `aᵢ ≥ 0`, `bᵢ > 0` |
144
+
145
+ ### Параметры
146
+
147
+ | Имя | По умолчанию | Смысл |
148
+ |-----|--------------|-------|
149
+ | `n_classes` | `2` | число классов (по умолчанию бинарная задача) |
150
+ | `dim` | `2` | размерность точки |
151
+ | `n_samples` | `30` | число объектов |
152
+ | `weight_type` | `"inverse"` | схема взвешивания |
153
+ | `params` | `None` | массив коэффициентов (см. таблицу) |
154
+ | `max_abs_coord` | `1e4` | верхняя граница координат по модулю |
155
+ | `min_coord` | `-1e4` | нижняя граница (для временных рядов задайте `0`) |
156
+ | `outlier_ratio` | `0.0` | доля переставляемых **пограничных** меток, из `[0, 1]` |
157
+ | `random_state` | `None` | зерно генератора |
158
+
159
+ **Возвращает:** `(x, y)` — массивы NumPy; `m ≤ n_samples` (точки с ничьей удаляются).
160
+
161
+ ### Гарантии поведения
162
+
163
+ - **Стратификация:** классы сбалансированы (разница ≤ 1 объект).
164
+ - **Удаление ничьих:** точки с равными весами двух лучших классов удаляются.
165
+ - **Выбросы:** заданная доля наиболее **пограничных** точек переставляется в
166
+ класс-«второе место», равномерно по всему кубу (а не из одной плоскости).
167
+ - **Проверка монотонности:** невозрастающие/неубывающие параметры → `ValueError`.
168
+
169
+ ### Пример
170
+
171
+ ```python
172
+ import numpy as np
173
+ from generate_test_data_for_wknn import generate
174
+
175
+ x, y = generate(
176
+ n_classes=3,
177
+ dim=2,
178
+ n_samples=60,
179
+ weight_type="exponential",
180
+ params=np.array([5.0, 0.01, 4.0, 0.02, 0.0]), # a1,b1,a2,b2,f
181
+ outlier_ratio=0.15,
182
+ random_state=7,
183
+ )
184
+ print(x.shape, y.shape, np.bincount(y))
185
+ ```
186
+
187
+ ---
188
+
189
+ ## License / Лицензия
190
+
191
+ MIT © Ferubko Andrey, Kazakov Oleg — Bryansk State Technological University of Engineering.
@@ -0,0 +1,163 @@
1
+ # generate-test-data-for-wknn
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/generate-test-data-for-wknn.svg)](https://pypi.org/project/generate-test-data-for-wknn/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/generate-test-data-for-wknn.svg)](https://pypi.org/project/generate-test-data-for-wknn/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ **EN** — Synthetic test-data generator for validating the **weighted k-NN (wkNN)** method.
8
+ **RU** — Генератор синтетических тестовых данных для проверки метода **взвешенного k-NN (wkNN)**.
9
+
10
+ *Authors / Авторы:* Ferubko Andrey, Kazakov Oleg
11
+ *Organization / Организация:* Bryansk State Technological University of Engineering /
12
+ Брянский государственный инженерно-технологический университет.
13
+
14
+ ---
15
+
16
+ ## Installation / Установка
17
+
18
+ ```bash
19
+ pip install generate-test-data-for-wknn
20
+ # or with uv:
21
+ uv add generate-test-data-for-wknn
22
+ ```
23
+
24
+ ---
25
+
26
+ ## English documentation
27
+
28
+ The library exposes a single public function, `generate`, which builds a
29
+ labelled dataset `(x, y)` for testing weighted k-NN.
30
+
31
+ Each class is given a random **anchor** point inside the cube
32
+ `[min_coord, max_abs_coord]^dim`. Every other random point is assigned to the
33
+ class whose **total weight** is largest. Weights are functions of the
34
+ per-coordinate absolute differences `x_i = |p_i - anchor_i|` and must
35
+ **decrease** as distance grows (otherwise a `ValueError` is raised).
36
+
37
+ ### Weight functions and parameter layout
38
+
39
+ | `weight_type` | Formula | `params` length | Constraint |
40
+ |----------------|---------|-----------------|------------|
41
+ | `"constant"` | `w = 1` | ignored | always valid (reduces to plain kNN) |
42
+ | `"linear"` | `w = max(Σ aᵢ·xᵢ + f, 0)` | `dim + 1` → `[a₁,…,a_d, f]` | all `aᵢ ≤ 0` |
43
+ | `"inverse"` | `w = Σ aᵢ / xᵢ^bᵢ + f` | `2·dim + 1` → `[a₁,b₁,…,a_d,b_d, f]` | `aᵢ ≥ 0`, `bᵢ > 0` |
44
+ | `"exponential"`| `w = Σ aᵢ·exp(−bᵢ·xᵢ) + f` | `2·dim + 1` → `[a₁,b₁,…,a_d,b_d, f]` | `aᵢ ≥ 0`, `bᵢ > 0` |
45
+
46
+ > Example (from the spec): for `dim=2`, `linear` with `params=[-1, -8, 9]`
47
+ > corresponds to `w = max(-1·x₁ + -8·x₂ + 9, 0)`.
48
+
49
+ ### Parameters
50
+
51
+ | Name | Default | Meaning |
52
+ |------|---------|---------|
53
+ | `n_classes` | `2` | number of classes (binary by default) |
54
+ | `dim` | `2` | dimensionality of each point |
55
+ | `n_samples` | `30` | number of objects in the dataset |
56
+ | `weight_type` | `"inverse"` | one of the four weighting schemes |
57
+ | `params` | `None` | coefficient array (see table) |
58
+ | `max_abs_coord` | `1e4` | upper coordinate bound (abs value cap) |
59
+ | `min_coord` | `-1e4` | lower coordinate bound (use `0` for e.g. time series) |
60
+ | `outlier_ratio` | `0.0` | fraction of **borderline** labels to flip, must be in `[0, 1]` |
61
+ | `random_state` | `None` | RNG seed |
62
+
63
+ **Returns:** `(x, y)` — `x` is `float64` of shape `(m, dim)`, `y` is `int64` of
64
+ shape `(m,)`, where `m ≤ n_samples` (tie points are removed).
65
+
66
+ ### Behavioural guarantees
67
+
68
+ - **Stratification:** classes are balanced (counts differ by at most 1).
69
+ - **Ties dropped:** points where the top-2 class weights are equal are removed.
70
+ - **Outliers:** the requested fraction of the *most borderline* points is flipped
71
+ to the runner-up class, spread evenly across the cube (not from a single plane).
72
+ - **Monotonicity guard:** non-decreasing weight parameters raise `ValueError`.
73
+
74
+ ### Example
75
+
76
+ ```python
77
+ import numpy as np
78
+ from generate_test_data_for_wknn import generate
79
+
80
+ # Binary, inverse-distance weights, 2-D, reproducible
81
+ x, y = generate(
82
+ n_classes=2,
83
+ dim=2,
84
+ n_samples=50,
85
+ weight_type="inverse",
86
+ params=np.array([2.0, 1.0, 3.0, 1.5, 0.1]), # a1,b1,a2,b2,f
87
+ max_abs_coord=100.0,
88
+ min_coord=0.0, # non-negative coords (e.g. time series)
89
+ outlier_ratio=0.1,
90
+ random_state=42,
91
+ )
92
+ print(x.shape, y.shape, np.bincount(y))
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Документация на русском
98
+
99
+ Библиотека предоставляет одну публичную функцию `generate`, которая строит
100
+ размеченный набор данных `(x, y)` для проверки взвешенного k-NN.
101
+
102
+ Для каждого класса создаётся случайная **опорная точка** внутри куба
103
+ `[min_coord, max_abs_coord]^dim`. Каждой другой случайной точке присваивается
104
+ класс с наибольшим **суммарным весом**. Веса зависят от модулей разностей
105
+ координат `x_i = |p_i − anchor_i|` и **должны убывать** с ростом расстояния
106
+ (иначе возбуждается `ValueError`).
107
+
108
+ ### Весовые функции и формат параметров
109
+
110
+ | `weight_type` | Формула | Длина `params` | Ограничение |
111
+ |----------------|---------|----------------|-------------|
112
+ | `"constant"` | `w = 1` | не используется | всегда допустимо (обычный kNN) |
113
+ | `"linear"` | `w = max(Σ aᵢ·xᵢ + f, 0)` | `dim + 1` | все `aᵢ ≤ 0` |
114
+ | `"inverse"` | `w = Σ aᵢ / xᵢ^bᵢ + f` | `2·dim + 1` | `aᵢ ≥ 0`, `bᵢ > 0` |
115
+ | `"exponential"`| `w = Σ aᵢ·exp(−bᵢ·xᵢ) + f` | `2·dim + 1` | `aᵢ ≥ 0`, `bᵢ > 0` |
116
+
117
+ ### Параметры
118
+
119
+ | Имя | По умолчанию | Смысл |
120
+ |-----|--------------|-------|
121
+ | `n_classes` | `2` | число классов (по умолчанию бинарная задача) |
122
+ | `dim` | `2` | размерность точки |
123
+ | `n_samples` | `30` | число объектов |
124
+ | `weight_type` | `"inverse"` | схема взвешивания |
125
+ | `params` | `None` | массив коэффициентов (см. таблицу) |
126
+ | `max_abs_coord` | `1e4` | верхняя граница координат по модулю |
127
+ | `min_coord` | `-1e4` | нижняя граница (для временных рядов задайте `0`) |
128
+ | `outlier_ratio` | `0.0` | доля переставляемых **пограничных** меток, из `[0, 1]` |
129
+ | `random_state` | `None` | зерно генератора |
130
+
131
+ **Возвращает:** `(x, y)` — массивы NumPy; `m ≤ n_samples` (точки с ничьей удаляются).
132
+
133
+ ### Гарантии поведения
134
+
135
+ - **Стратификация:** классы сбалансированы (разница ≤ 1 объект).
136
+ - **Удаление ничьих:** точки с равными весами двух лучших классов удаляются.
137
+ - **Выбросы:** заданная доля наиболее **пограничных** точек переставляется в
138
+ класс-«второе место», равномерно по всему кубу (а не из одной плоскости).
139
+ - **Проверка монотонности:** невозрастающие/неубывающие параметры → `ValueError`.
140
+
141
+ ### Пример
142
+
143
+ ```python
144
+ import numpy as np
145
+ from generate_test_data_for_wknn import generate
146
+
147
+ x, y = generate(
148
+ n_classes=3,
149
+ dim=2,
150
+ n_samples=60,
151
+ weight_type="exponential",
152
+ params=np.array([5.0, 0.01, 4.0, 0.02, 0.0]), # a1,b1,a2,b2,f
153
+ outlier_ratio=0.15,
154
+ random_state=7,
155
+ )
156
+ print(x.shape, y.shape, np.bincount(y))
157
+ ```
158
+
159
+ ---
160
+
161
+ ## License / Лицензия
162
+
163
+ MIT © Ferubko Andrey, Kazakov Oleg — Bryansk State Technological University of Engineering.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "generate-test-data-for-wknn"
3
+ version = "0.0.1"
4
+ description = "Test-data generator for validating the weighted k-NN (wkNN) method."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Ferubko Andrey", email = "andrey.ferubko@petsplace.ru" },
10
+ { name = "Kazakov Oleg" },
11
+ ]
12
+ maintainers = [
13
+ { name = "Ferubko Andrey", email = "andrey.ferubko@petsplace.ru" },
14
+ { name = "Kazakov Oleg" },
15
+ ]
16
+ keywords = ["knn", "weighted-knn", "wknn", "machine-learning", "test-data"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Scientific/Engineering",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ ]
30
+ dependencies = [
31
+ "numpy>=1.24",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/89605502155/generate-test-data-for-wknn"
36
+ Repository = "https://github.com/89605502155/generate-test-data-for-wknn"
37
+ Issues = "https://github.com/89605502155/generate-test-data-for-wknn/issues"
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.11.9,<0.12.0"]
41
+ build-backend = "uv_build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/generate_test_data_for_wknn"]
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ "pytest>=9.1.1",
49
+ ]
@@ -0,0 +1,4 @@
1
+ from ._core import WknnDataGenerator, WeightType, generate
2
+
3
+ __all__ = ["WknnDataGenerator", "generate", "WeightType"]
4
+ __version__ = "0.0.1"
@@ -0,0 +1,230 @@
1
+ """Core implementation of the weighted-kNN test-data generator.
2
+
3
+ Everything lives inside :class:`WknnDataGenerator` — a stateless utility class
4
+ (no ``__init__``): call :meth:`WknnDataGenerator.generate` directly on the class.
5
+ All heavy lifting is delegated to NumPy (vectorised).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import ClassVar, Literal
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ WeightType = Literal["constant", "linear", "inverse", "exponential"]
16
+
17
+
18
+ class WknnDataGenerator:
19
+ """Stateless generator of synthetic datasets for testing weighted k-NN.
20
+
21
+ The class is never instantiated: use the classmethod directly, e.g.
22
+ ``x, y = WknnDataGenerator.generate(...)``. The module-level ``generate``
23
+ alias forwards to this method for convenience.
24
+ """
25
+
26
+ # ---- configuration constants ---------------------------------------
27
+ _LEN_DIM: ClassVar[frozenset[str]] = frozenset({"linear"})
28
+ _LEN_2DIM: ClassVar[frozenset[str]] = frozenset({"inverse", "exponential"})
29
+ _EPS: ClassVar[float] = 1e-9 # guards divisions / powers against zero
30
+ _MAX_ATTEMPTS: ClassVar[int] = 50
31
+
32
+ # ---- private helpers (static: no state needed) ----------------------
33
+ @staticmethod
34
+ def _default_params(weight_type: WeightType, dim: int) -> NDArray[np.float64] | None:
35
+ """Sensible, *decreasing* default coefficients so ``generate()`` works."""
36
+ if weight_type == "constant":
37
+ return None
38
+ if weight_type == "linear":
39
+ # w = max(sum(-1 * x_i) + 1, 0): decreasing (all slopes <= 0).
40
+ return np.concatenate([-np.ones(dim), [1.0]])
41
+ if weight_type in ("inverse", "exponential"):
42
+ # inverse: w = sum(1 / x_i^1) + 0 (a=1>=0, b=1>0)
43
+ # exponential: w = sum(1 * exp(-1*x_i)) (a=1>=0, b=1>0)
44
+ return np.concatenate([np.tile([1.0, 1.0], dim), [0.0]])
45
+ raise ValueError(f"unknown weight_type={weight_type!r}.")
46
+
47
+ @classmethod
48
+ def _validate_params(
49
+ cls,
50
+ weight_type: WeightType,
51
+ params: NDArray[np.float64] | None,
52
+ dim: int,
53
+ ) -> tuple[NDArray[np.float64], float]:
54
+ """Validate params, enforce a *decreasing* weight, return (coeffs, bias)."""
55
+ if weight_type == "constant":
56
+ return np.zeros(dim, dtype=float), 1.0
57
+
58
+ if params is None:
59
+ params = cls._default_params(weight_type, dim)
60
+ params = np.asarray(params, dtype=float).ravel()
61
+
62
+ if weight_type in cls._LEN_DIM:
63
+ if params.size != dim + 1:
64
+ raise ValueError(
65
+ f"linear weights need dim+1={dim + 1} params, got {params.size}."
66
+ )
67
+ a, f = params[:dim], float(params[dim])
68
+ if np.any(a > 0):
69
+ raise ValueError(
70
+ "linear weight is non-decreasing: every coefficient a_i must be <= 0."
71
+ )
72
+ return a, f
73
+
74
+ if weight_type in cls._LEN_2DIM:
75
+ if params.size != 2 * dim + 1:
76
+ raise ValueError(
77
+ f"{weight_type} weights need 2*dim+1={2 * dim + 1} params, "
78
+ f"got {params.size}."
79
+ )
80
+ ab, f = params[: 2 * dim].reshape(dim, 2), float(params[2 * dim])
81
+ a, b = ab[:, 0], ab[:, 1]
82
+ if np.any(a < 0) or np.any(b <= 0):
83
+ raise ValueError(
84
+ f"{weight_type} weight is non-decreasing: need a_i >= 0 and b_i > 0."
85
+ )
86
+ return ab.ravel(), f
87
+
88
+ raise ValueError(f"unknown weight_type={weight_type!r}.")
89
+
90
+ @classmethod
91
+ def _weights(
92
+ cls,
93
+ dx: NDArray[np.float64],
94
+ weight_type: WeightType,
95
+ coeffs: NDArray[np.float64],
96
+ bias: float,
97
+ ) -> NDArray[np.float64]:
98
+ """Vectorised weight for *normalised* |coordinate differences| (..., dim)."""
99
+ if weight_type == "constant":
100
+ return np.ones(dx.shape[:-1], dtype=float)
101
+ if weight_type == "linear":
102
+ return np.maximum(dx @ coeffs + bias, 0.0)
103
+
104
+ ab = coeffs.reshape(-1, 2)
105
+ a, b = ab[:, 0], ab[:, 1]
106
+ dxs = np.maximum(dx, cls._EPS)
107
+ if weight_type == "inverse":
108
+ return np.sum(a / np.power(dxs, b), axis=-1) + bias
109
+ return np.sum(a * np.exp(-b * dx), axis=-1) + bias # exponential
110
+
111
+ @classmethod
112
+ def _assign(
113
+ cls,
114
+ x: NDArray[np.float64],
115
+ anchors: NDArray[np.float64],
116
+ scale: float,
117
+ weight_type: WeightType,
118
+ coeffs: NDArray[np.float64],
119
+ bias: float,
120
+ ) -> tuple[NDArray[np.int64], NDArray[np.float64]]:
121
+ """Return (labels, per-class score matrix); winner is ``argmax`` of score.
122
+
123
+ For the *constant* weight (plain kNN) all weights are equal, so the
124
+ class is decided by the *nearest anchor*: the score is ``-distance``,
125
+ which yields a proper, non-degenerate decision and meaningful margins.
126
+ """
127
+ diff = x[:, None, :] - anchors[None, :, :] # (n, n_classes, dim)
128
+ if weight_type == "constant":
129
+ dist = np.linalg.norm(diff, axis=-1)
130
+ return np.argmax(-dist, axis=1).astype(np.int64), -dist
131
+ dx = np.abs(diff) / scale
132
+ w = cls._weights(dx, weight_type, coeffs, bias)
133
+ return np.argmax(w, axis=1).astype(np.int64), w
134
+
135
+ # ---- public API -----------------------------------------------------
136
+ @classmethod
137
+ def generate(
138
+ cls,
139
+ n_classes: int = 2,
140
+ dim: int = 2,
141
+ n_samples: int = 30,
142
+ weight_type: WeightType = "inverse",
143
+ params: NDArray[np.float64] | None = None,
144
+ max_abs_coord: float = 1e4,
145
+ min_coord: float = -1e4,
146
+ outlier_ratio: float = 0.0,
147
+ *,
148
+ random_state: int | None = None,
149
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64]]:
150
+ """Generate a synthetic dataset for testing weighted k-NN classification.
151
+
152
+ See README for the full parameter/weight-function reference. Returns
153
+ ``(x, y)`` where ``x`` is float64 ``(m, dim)`` and ``y`` is int64
154
+ ``(m,)``, with ``m <= n_samples`` (tie points are dropped).
155
+ """
156
+ # ---- validation --------------------------------------------------
157
+ if n_classes < 2:
158
+ raise ValueError("n_classes must be >= 2.")
159
+ if dim < 1:
160
+ raise ValueError("dim must be >= 1.")
161
+ if n_samples < n_classes:
162
+ raise ValueError("n_samples must be >= n_classes.")
163
+ if not (min_coord < max_abs_coord):
164
+ raise ValueError("min_coord must be < max_abs_coord.")
165
+ if not (0.0 <= outlier_ratio <= 1.0):
166
+ raise ValueError("outlier_ratio must be within [0, 1].")
167
+
168
+ coeffs, bias = cls._validate_params(weight_type, params, dim)
169
+ rng = np.random.default_rng(random_state)
170
+ lo, hi = float(min_coord), float(max_abs_coord)
171
+ # Normalise distances so weights vary meaningfully regardless of cube size.
172
+ scale = max((hi - lo) / 4.0, cls._EPS)
173
+
174
+ per_class = n_samples // n_classes
175
+ remainder = n_samples - per_class * n_classes
176
+ takes = [per_class + (1 if c < remainder else 0) for c in range(n_classes)]
177
+
178
+ # ---- retry with fresh geometry until every class can be filled ---
179
+ last_counts: NDArray[np.int64] | None = None
180
+ for _attempt in range(cls._MAX_ATTEMPTS):
181
+ anchors = rng.uniform(lo, hi, size=(n_classes, dim))
182
+ pool = max(n_samples * 40, n_classes * 200)
183
+ cand = rng.uniform(lo, hi, size=(pool, dim))
184
+ labels, w = cls._assign(cand, anchors, scale, weight_type, coeffs, bias)
185
+
186
+ # Drop ties (top-2 class scores exactly equal).
187
+ top2 = np.sort(w, axis=1)[:, -2:]
188
+ keep = top2[:, 1] > top2[:, 0]
189
+ cand, labels, w = cand[keep], labels[keep], w[keep]
190
+
191
+ counts = np.bincount(labels, minlength=n_classes)
192
+ last_counts = counts
193
+ if np.all(counts >= np.asarray(takes)):
194
+ break
195
+ else:
196
+ raise ValueError(
197
+ "could not build a balanced, non-degenerate sample after "
198
+ f"{cls._MAX_ATTEMPTS} attempts (class counts {last_counts.tolist()}, "
199
+ f"needed {takes}). The chosen weight/params make one class dominate "
200
+ "the whole cube; try a different weight_type, params, or random_state."
201
+ )
202
+
203
+ # ---- stratified, balanced selection ------------------------------
204
+ chosen: list[int] = []
205
+ for c in range(n_classes):
206
+ idx_c = np.flatnonzero(labels == c)
207
+ chosen.extend(rng.choice(idx_c, size=takes[c], replace=False).tolist())
208
+ chosen = np.asarray(chosen, dtype=np.int64)
209
+ rng.shuffle(chosen)
210
+ x, y, w = cand[chosen], labels[chosen], w[chosen]
211
+
212
+ # ---- inject outliers on the borderline, spread across the cube ---
213
+ n_out = int(round(outlier_ratio * x.shape[0]))
214
+ if n_out > 0:
215
+ sw = np.sort(w, axis=1)
216
+ margin = sw[:, -1] - sw[:, -2] # smaller => more borderline
217
+ rank = np.empty(x.shape[0], dtype=np.int64)
218
+ rank[np.argsort(margin)] = np.arange(x.shape[0])
219
+ picked: list[int] = []
220
+ for bucket in np.array_split(np.argsort(x[:, 0]), n_out):
221
+ if bucket.size:
222
+ picked.append(int(bucket[np.argmin(rank[bucket])]))
223
+ for i in picked[:n_out]:
224
+ y[i] = int(np.argsort(w[i])[-2]) # flip to runner-up class
225
+
226
+ return x, y
227
+
228
+
229
+ # Backwards-compatible functional alias: `generate(...)` == class method.
230
+ generate = WknnDataGenerator.generate