openfll-python 0.2.0__cp314-cp314-win_amd64.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.
- PyFLL/PyFLL.pyi +306 -0
- PyFLL/__init__.py +97 -0
- PyFLL/_core.cp314-win_amd64.pyd +0 -0
- PyFLL/libgcc_s_seh-1.dll +0 -0
- PyFLL/libstdc++-6.dll +0 -0
- PyFLL/libwinpthread-1.dll +0 -0
- openfll/PyFLL.pyi +284 -0
- openfll/__init__.py +463 -0
- openfll/__init__.pyi +270 -0
- openfll/_helpers/__init__.py +6 -0
- openfll/_helpers/plotting.py +305 -0
- openfll/_helpers/sampling.py +143 -0
- openfll/sklearn.py +546 -0
- openfll_python-0.2.0.dist-info/METADATA +246 -0
- openfll_python-0.2.0.dist-info/RECORD +18 -0
- openfll_python-0.2.0.dist-info/WHEEL +5 -0
- openfll_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- openfll_python-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Batch-инференс: прогон множества входов через SugenoEngine.
|
|
2
|
+
|
|
3
|
+
Полезно для:
|
|
4
|
+
- Monte-Carlo валидации модели (проверить, что выход всегда конечный)
|
|
5
|
+
- Построения таблицы покрытия (coverage grid) для 2D-визуализации
|
|
6
|
+
- Стресстестов: проверить модель на 100k случайных точках за секунды
|
|
7
|
+
|
|
8
|
+
Не требует расширения C++ API — работает только через `set_input`/`calculate`/`get_output`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from openfll import SugenoEngine
|
|
17
|
+
|
|
18
|
+
__all__ = ["monte_carlo", "grid_2d", "check_output_finite"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def monte_carlo(
|
|
22
|
+
engine: "SugenoEngine",
|
|
23
|
+
var_inputs: list[tuple[str, tuple[float, float]]],
|
|
24
|
+
var_outputs: list[str],
|
|
25
|
+
n_samples: int = 1000,
|
|
26
|
+
seed: int | None = None,
|
|
27
|
+
) -> list[dict[str, float]]:
|
|
28
|
+
"""Случайные входы → батч результатов.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
engine: собранный SugenoEngine (после `build()`).
|
|
32
|
+
var_inputs: список (var_name, (min, max)) для каждого входа.
|
|
33
|
+
var_outputs: имена выходов, которые нужно собрать.
|
|
34
|
+
n_samples: количество случайных точек.
|
|
35
|
+
seed: зерно random.Random; None = системное.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Список длины n_samples; каждый элемент — dict {var_name: value}.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RuntimeError: если `engine` не собран (`build()` не вызван).
|
|
42
|
+
KeyError: если имя переменной не зарегистрировано в engine.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> rows = monte_carlo(
|
|
46
|
+
... engine,
|
|
47
|
+
... var_inputs=[("x1", (-10, 10)), ("x2", (0, 20))],
|
|
48
|
+
... var_outputs=["y"],
|
|
49
|
+
... n_samples=100, seed=42,
|
|
50
|
+
... )
|
|
51
|
+
>>> len(rows)
|
|
52
|
+
100
|
|
53
|
+
"""
|
|
54
|
+
rng = random.Random(seed)
|
|
55
|
+
results: list[dict[str, float]] = []
|
|
56
|
+
for _ in range(n_samples):
|
|
57
|
+
for var_name, (lo, hi) in var_inputs:
|
|
58
|
+
engine.set_input(var_name, rng.uniform(lo, hi))
|
|
59
|
+
engine.calculate()
|
|
60
|
+
results.append({v: engine.get_output(v) for v in var_outputs})
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def grid_2d(
|
|
65
|
+
engine: "SugenoEngine",
|
|
66
|
+
x_var: str,
|
|
67
|
+
y_var: str,
|
|
68
|
+
x_range: tuple[float, float],
|
|
69
|
+
y_range: tuple[float, float],
|
|
70
|
+
out_var: str,
|
|
71
|
+
n: int = 20,
|
|
72
|
+
) -> tuple[list[float], list[float], list[list[float]]]:
|
|
73
|
+
"""Регулярная 2D-сетка (x, y) → значения out_var.
|
|
74
|
+
|
|
75
|
+
Используется для heatmap/3D-визуализации выхода нечёткой модели.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
(xs, ys, grid): xs[i] и ys[j] — координаты, grid[i][j] — out_var(xs[i], ys[j]).
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
>>> xs, ys, g = grid_2d(e, "x1", "x2", (-10, 10), (0, 20), "y", n=20)
|
|
82
|
+
>>> len(xs) == len(ys) == 20
|
|
83
|
+
True
|
|
84
|
+
"""
|
|
85
|
+
xs = [_linspace(x_range, n, i) for i in range(n)]
|
|
86
|
+
ys = [_linspace(y_range, n, j) for j in range(n)]
|
|
87
|
+
grid: list[list[float]] = []
|
|
88
|
+
for x in xs:
|
|
89
|
+
row: list[float] = []
|
|
90
|
+
for y in ys:
|
|
91
|
+
engine.set_input(x_var, x)
|
|
92
|
+
engine.set_input(y_var, y)
|
|
93
|
+
engine.calculate()
|
|
94
|
+
row.append(engine.get_output(out_var))
|
|
95
|
+
grid.append(row)
|
|
96
|
+
return xs, ys, grid
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _linspace(rng: tuple[float, float], n: int, i: int) -> float:
|
|
100
|
+
lo, hi = rng
|
|
101
|
+
if n == 1:
|
|
102
|
+
return lo
|
|
103
|
+
return lo + (hi - lo) * i / (n - 1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def check_output_finite(
|
|
107
|
+
engine: "SugenoEngine",
|
|
108
|
+
var_inputs: list[tuple[str, tuple[float, float]]],
|
|
109
|
+
var_outputs: list[str],
|
|
110
|
+
n_samples: int = 1000,
|
|
111
|
+
seed: int | None = None,
|
|
112
|
+
) -> dict[str, int]:
|
|
113
|
+
"""Сэмплирует engine и считает, сколько раз выход был NaN/Inf.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
engine: собранный SugenoEngine.
|
|
117
|
+
var_inputs: список (var_name, (min, max)) для каждого входа.
|
|
118
|
+
var_outputs: имена выходов, которые нужно проверять.
|
|
119
|
+
n_samples: количество случайных точек.
|
|
120
|
+
seed: random.Random; None = системное.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
{"total": N, "bad": K, "ok": N - K} — счётчики.
|
|
124
|
+
Используйте для регрессионных тестов: assert stats["bad"] == 0.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
>>> stats = check_output_finite(
|
|
128
|
+
... e,
|
|
129
|
+
... var_inputs=[("x1", (-10, 10)), ("x2", (0, 20))],
|
|
130
|
+
... var_outputs=["y"],
|
|
131
|
+
... n_samples=100,
|
|
132
|
+
... )
|
|
133
|
+
>>> assert stats["bad"] == 0
|
|
134
|
+
"""
|
|
135
|
+
import math
|
|
136
|
+
bad = 0
|
|
137
|
+
total = 0
|
|
138
|
+
for sample in monte_carlo(engine, var_inputs, var_outputs, n_samples, seed):
|
|
139
|
+
for v in sample.values():
|
|
140
|
+
total += 1
|
|
141
|
+
if not math.isfinite(v):
|
|
142
|
+
bad += 1
|
|
143
|
+
return {"total": total, "bad": bad, "ok": total - bad}
|
openfll/sklearn.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""scikit-learn совместимый регрессор на базе нечёткой модели Sugeno нулевого порядка.
|
|
2
|
+
|
|
3
|
+
Предоставляет `SugenoRegressor` — эстимейтор для обучения числовых констант
|
|
4
|
+
заключений правил методом наименьших квадратов (МНК / Ridge) и выполнения
|
|
5
|
+
пакетного инференса через C++ ядро OpenFLL.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
import openfll
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from sklearn.base import BaseEstimator, RegressorMixin
|
|
19
|
+
from sklearn.exceptions import NotFittedError
|
|
20
|
+
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y
|
|
21
|
+
except ImportError:
|
|
22
|
+
# Заглушки для сред без установленного scikit-learn
|
|
23
|
+
class BaseEstimator: # type: ignore[no-redef]
|
|
24
|
+
def get_params(self, deep: bool = True) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"model_spec": getattr(self, "model_spec", None),
|
|
27
|
+
"default_t_norm": getattr(self, "default_t_norm", None),
|
|
28
|
+
"default_s_norm": getattr(self, "default_s_norm", None),
|
|
29
|
+
"solver": getattr(self, "solver", "lstsq"),
|
|
30
|
+
"alpha": getattr(self, "alpha", 0.0),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def set_params(self, **params: Any) -> Any:
|
|
34
|
+
for k, v in params.items():
|
|
35
|
+
setattr(self, k, v)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
class RegressorMixin: # type: ignore[no-redef]
|
|
39
|
+
def score(self, X: Any, y: Any) -> float:
|
|
40
|
+
y_pred = getattr(self, "predict")(X)
|
|
41
|
+
y_true = np.asarray(y, dtype=np.float64)
|
|
42
|
+
u = float(np.sum((y_true - y_pred) ** 2))
|
|
43
|
+
v = float(np.sum((y_true - np.mean(y_true)) ** 2))
|
|
44
|
+
if v == 0.0:
|
|
45
|
+
return 0.0
|
|
46
|
+
return 1.0 - (u / v)
|
|
47
|
+
|
|
48
|
+
class NotFittedError(ValueError, AttributeError): # type: ignore[no-redef]
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def check_is_fitted(estimator: Any, attributes: Sequence[str] | str) -> None: # type: ignore[misc]
|
|
52
|
+
attrs = [attributes] if isinstance(attributes, str) else attributes
|
|
53
|
+
if not all(hasattr(estimator, a) for a in attrs):
|
|
54
|
+
raise NotFittedError(f"This {type(estimator).__name__} instance is not fitted yet.")
|
|
55
|
+
|
|
56
|
+
def check_array(X: Any, **kwargs: Any) -> np.ndarray: # type: ignore[misc]
|
|
57
|
+
arr = np.asarray(X, dtype=np.float64)
|
|
58
|
+
if arr.ndim != 2:
|
|
59
|
+
raise ValueError(f"Expected 2D array, got {arr.ndim}D array instead")
|
|
60
|
+
if not np.all(np.isfinite(arr)):
|
|
61
|
+
raise ValueError("Input contains NaN, infinity or a value too large for dtype('float64')")
|
|
62
|
+
return arr
|
|
63
|
+
|
|
64
|
+
def check_X_y(X: Any, y: Any, **kwargs: Any) -> tuple[np.ndarray, np.ndarray]: # type: ignore[misc]
|
|
65
|
+
X_arr = check_array(X)
|
|
66
|
+
y_arr = np.asarray(y, dtype=np.float64)
|
|
67
|
+
if y_arr.ndim != 1:
|
|
68
|
+
raise ValueError(f"Expected 1D target array, got ndim={y_arr.ndim}")
|
|
69
|
+
if not np.all(np.isfinite(y_arr)):
|
|
70
|
+
raise ValueError("Input contains NaN, infinity or a value too large for dtype('float64')")
|
|
71
|
+
if X_arr.shape[0] != y_arr.shape[0]:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Found input variables with inconsistent numbers of samples: [{X_arr.shape[0]}, {y_arr.shape[0]}]"
|
|
74
|
+
)
|
|
75
|
+
return X_arr, y_arr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["FrozenDict", "SugenoModelSpec", "SugenoRegressor"]
|
|
79
|
+
|
|
80
|
+
_THEN_RE = re.compile(
|
|
81
|
+
r"\bTHEN\b\s+(?:\w+)\s+\bIS\b\s+[\"']?([^\"'\s,;]+)[\"']?",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FrozenDict(Mapping[str, Any]):
|
|
87
|
+
"""Неизменяемый словарь (immutable mapping) с поддержкой pickle, hash и unpack (**)."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
data: Mapping[Any, Any] | Sequence[tuple[Any, Any]] | None = None,
|
|
92
|
+
**kwargs: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
raw = dict(data or {}, **kwargs)
|
|
95
|
+
self._data: dict[Any, Any] = {k: _deep_freeze_structure(v) for k, v in raw.items()}
|
|
96
|
+
self._hash: int | None = None
|
|
97
|
+
|
|
98
|
+
def __getitem__(self, key: Any) -> Any:
|
|
99
|
+
return self._data[key]
|
|
100
|
+
|
|
101
|
+
def __iter__(self) -> Any:
|
|
102
|
+
return iter(self._data)
|
|
103
|
+
|
|
104
|
+
def __len__(self) -> int:
|
|
105
|
+
return len(self._data)
|
|
106
|
+
|
|
107
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
108
|
+
raise TypeError(f"'{type(self).__name__}' object does not support item assignment")
|
|
109
|
+
|
|
110
|
+
def __delitem__(self, key: Any) -> None:
|
|
111
|
+
raise TypeError(f"'{type(self).__name__}' object does not support item deletion")
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
return f"FrozenDict({self._data!r})"
|
|
115
|
+
|
|
116
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
117
|
+
return (FrozenDict, (self._data,))
|
|
118
|
+
|
|
119
|
+
def __hash__(self) -> int:
|
|
120
|
+
if self._hash is None:
|
|
121
|
+
self._hash = hash(frozenset(self._data.items()))
|
|
122
|
+
return self._hash
|
|
123
|
+
|
|
124
|
+
def copy(self) -> dict[Any, Any]:
|
|
125
|
+
return self._data.copy()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _deep_freeze_structure(obj: Any) -> Any:
|
|
129
|
+
"""Рекурсивно преобразует коллекции в неизменяемые структуры (tuple, frozenset и FrozenDict)."""
|
|
130
|
+
if isinstance(obj, FrozenDict):
|
|
131
|
+
return obj
|
|
132
|
+
if isinstance(obj, Mapping):
|
|
133
|
+
return FrozenDict(obj)
|
|
134
|
+
if isinstance(obj, np.ndarray):
|
|
135
|
+
if obj.ndim == 0:
|
|
136
|
+
return obj.item()
|
|
137
|
+
return tuple(_deep_freeze_structure(x) for x in obj.tolist())
|
|
138
|
+
if isinstance(obj, (set, frozenset)):
|
|
139
|
+
return frozenset(_deep_freeze_structure(x) for x in obj)
|
|
140
|
+
if isinstance(obj, (list, tuple)):
|
|
141
|
+
return tuple(_deep_freeze_structure(x) for x in obj)
|
|
142
|
+
return obj
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class SugenoModelSpec:
|
|
147
|
+
"""Декларативная полностью неизменяемая спецификация структуры модели Sugeno.
|
|
148
|
+
|
|
149
|
+
Корневой класс заморожен (@dataclass(frozen=True)), а все вложенные словари,
|
|
150
|
+
списки, NumPy-массивы и множества при инициализации глубоко замораживаются
|
|
151
|
+
в неизменяемые структуры (FrozenDict, tuple, frozenset), гарантируя полную
|
|
152
|
+
иммутабельность и защиту от рассинхронизации инференса обученного регрессора.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
inputs: неизменяемый словарь (FrozenDict) соответствия имени входа кортежу MF:
|
|
156
|
+
(mf_name, mf_type, params).
|
|
157
|
+
rules: кортеж текстовых правил, словарей (FrozenDict) или кортежей правил.
|
|
158
|
+
output_name: имя целевой выходной переменной.
|
|
159
|
+
consequents: явный кортеж имён констант заключений (если None, извлекаются из rules).
|
|
160
|
+
default_t_norm: t-норма по умолчанию для правил без явного указания.
|
|
161
|
+
default_s_norm: s-норма по умолчанию.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
inputs: Mapping[str, Sequence[Any]] = field(default_factory=dict)
|
|
165
|
+
rules: Sequence[Any] = field(default_factory=tuple)
|
|
166
|
+
output_name: str = "y"
|
|
167
|
+
consequents: Sequence[str] | None = None
|
|
168
|
+
default_t_norm: str | None = None
|
|
169
|
+
default_s_norm: str | None = None
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
frozen_inputs = FrozenDict(
|
|
173
|
+
{
|
|
174
|
+
str(var): tuple(_deep_freeze_structure(mf) for mf in mfs)
|
|
175
|
+
for var, mfs in self.inputs.items()
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
object.__setattr__(self, "inputs", frozen_inputs)
|
|
179
|
+
object.__setattr__(
|
|
180
|
+
self,
|
|
181
|
+
"rules",
|
|
182
|
+
tuple(_deep_freeze_structure(r) for r in self.rules),
|
|
183
|
+
)
|
|
184
|
+
if self.consequents is not None:
|
|
185
|
+
object.__setattr__(self, "consequents", tuple(str(c) for c in self.consequents))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class SugenoRegressor(BaseEstimator, RegressorMixin):
|
|
189
|
+
"""scikit-learn совместимый регрессор на базе нечёткой модели Sugeno нулевого порядка.
|
|
190
|
+
|
|
191
|
+
Обучает числовые константы заключений правил фиксированной нечёткой структуры
|
|
192
|
+
методом наименьших квадратов (МНК или Ridge) с использованием нативного
|
|
193
|
+
C++ ядра OpenFLL.
|
|
194
|
+
|
|
195
|
+
Методы fit и predict работают с матрицами признаков формы
|
|
196
|
+
``(n_samples, n_features)`` в соответствии с интерфейсом scikit-learn.
|
|
197
|
+
|
|
198
|
+
Предупреждение:
|
|
199
|
+
Инстанс не является потокобезопасным для инференса. Для многопоточной
|
|
200
|
+
обработки необходимо клонировать модель или создать независимые экземпляры.
|
|
201
|
+
|
|
202
|
+
Параметры:
|
|
203
|
+
model_spec: Декларативная спецификация модели (SugenoModelSpec или dict).
|
|
204
|
+
default_t_norm: t-норма по умолчанию для нечётких правил ("min", "prod_and", ...).
|
|
205
|
+
default_s_norm: s-норма по умолчанию ("max", "algebraic_sum", ...).
|
|
206
|
+
solver: Алгоритм решения задачи регрессии ("lstsq" или "ridge").
|
|
207
|
+
alpha: Параметр регуляризации (L2) для Ridge (требуется alpha > 0 при solver='ridge').
|
|
208
|
+
|
|
209
|
+
Атрибуты после обучения:
|
|
210
|
+
engine_: Обученный нативный экземпляр openfll.SugenoEngine.
|
|
211
|
+
coef_: 1D массив подобранных констант заключений правил (np.float64).
|
|
212
|
+
consequents_: Список имён констант заключений, соответствующих coef_.
|
|
213
|
+
rule_names_: Список имён правил ('rule_0', 'rule_1', ...).
|
|
214
|
+
n_features_in_: Число входных признаков обучающей выборки.
|
|
215
|
+
feature_names_in_: Имена признаков (если передавались через DataFrame).
|
|
216
|
+
rank_: Численный ранг матрицы влияния правил (design matrix).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
model_spec: Mapping[str, Any] | SugenoModelSpec | None = None,
|
|
222
|
+
*,
|
|
223
|
+
default_t_norm: str | None = None,
|
|
224
|
+
default_s_norm: str | None = None,
|
|
225
|
+
solver: str = "lstsq",
|
|
226
|
+
alpha: float = 0.0,
|
|
227
|
+
) -> None:
|
|
228
|
+
self.model_spec = model_spec
|
|
229
|
+
self.default_t_norm = default_t_norm
|
|
230
|
+
self.default_s_norm = default_s_norm
|
|
231
|
+
self.solver = solver
|
|
232
|
+
self.alpha = alpha
|
|
233
|
+
|
|
234
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
235
|
+
"""Возвращает состояние для сериализации через pickle/joblib без нативного engine_."""
|
|
236
|
+
state = self.__dict__.copy()
|
|
237
|
+
state.pop("engine_", None)
|
|
238
|
+
return state
|
|
239
|
+
|
|
240
|
+
def __setstate__(self, state: dict[str, Any]) -> None:
|
|
241
|
+
"""Восстанавливает состояние и пересоздаёт C++ engine_ при десериализации."""
|
|
242
|
+
self.__dict__.update(state)
|
|
243
|
+
if hasattr(self, "coef_") and hasattr(self, "consequents_") and self.model_spec is not None:
|
|
244
|
+
spec = self._normalize_spec()
|
|
245
|
+
fitted_vals = {name: float(self.coef_[m]) for m, name in enumerate(self.consequents_)}
|
|
246
|
+
self.engine_ = self._build_engine(spec, fitted_vals, self.consequents_)
|
|
247
|
+
|
|
248
|
+
def _normalize_spec(self) -> SugenoModelSpec:
|
|
249
|
+
"""Привести model_spec к нормализованному экземпляру SugenoModelSpec."""
|
|
250
|
+
if self.model_spec is None:
|
|
251
|
+
raise ValueError("model_spec must be specified before calling fit().")
|
|
252
|
+
if isinstance(self.model_spec, SugenoModelSpec):
|
|
253
|
+
return self.model_spec
|
|
254
|
+
if isinstance(self.model_spec, Mapping):
|
|
255
|
+
return SugenoModelSpec(
|
|
256
|
+
inputs=self.model_spec.get("inputs", {}),
|
|
257
|
+
rules=self.model_spec.get("rules", []),
|
|
258
|
+
output_name=self.model_spec.get("output_name", "y"),
|
|
259
|
+
consequents=self.model_spec.get("consequents", None),
|
|
260
|
+
default_t_norm=self.model_spec.get("default_t_norm", None),
|
|
261
|
+
default_s_norm=self.model_spec.get("default_s_norm", None),
|
|
262
|
+
)
|
|
263
|
+
raise TypeError(
|
|
264
|
+
f"model_spec must be SugenoModelSpec or Mapping, got {type(self.model_spec).__name__}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _extract_rule_consequents(self, rules: Sequence[Any]) -> list[str]:
|
|
268
|
+
"""Извлечь уникальные имена констант заключений из списка правил в порядке появления."""
|
|
269
|
+
consequents: list[str] = []
|
|
270
|
+
for rule in rules:
|
|
271
|
+
if isinstance(rule, str):
|
|
272
|
+
match = _THEN_RE.search(rule)
|
|
273
|
+
if not match:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"Could not parse consequent term from rule text: {rule!r}. "
|
|
276
|
+
f"Expected format: 'IF ... THEN <output_var> IS <consequent_term>'."
|
|
277
|
+
)
|
|
278
|
+
c_name = match.group(1)
|
|
279
|
+
if c_name not in consequents:
|
|
280
|
+
consequents.append(c_name)
|
|
281
|
+
elif isinstance(rule, Mapping):
|
|
282
|
+
consequent = rule.get("consequent")
|
|
283
|
+
if not consequent:
|
|
284
|
+
raise ValueError(f"Rule mapping missing 'consequent': {rule!r}")
|
|
285
|
+
for item in consequent:
|
|
286
|
+
if isinstance(item, (tuple, list)) and len(item) == 2:
|
|
287
|
+
c_name = str(item[1])
|
|
288
|
+
elif isinstance(item, str):
|
|
289
|
+
c_name = item
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"Invalid consequent item in rule: {item!r}")
|
|
292
|
+
if c_name not in consequents:
|
|
293
|
+
consequents.append(c_name)
|
|
294
|
+
elif isinstance(rule, (tuple, list)):
|
|
295
|
+
if len(rule) < 2:
|
|
296
|
+
raise ValueError(f"Tuple rule must have at least (antecedent, consequent): {rule!r}")
|
|
297
|
+
consequent = rule[1]
|
|
298
|
+
if isinstance(consequent, (tuple, list)):
|
|
299
|
+
for item in consequent:
|
|
300
|
+
if isinstance(item, (tuple, list)) and len(item) == 2:
|
|
301
|
+
c_name = str(item[1])
|
|
302
|
+
elif isinstance(item, str):
|
|
303
|
+
c_name = item
|
|
304
|
+
else:
|
|
305
|
+
raise ValueError(f"Invalid consequent item in tuple rule: {item!r}")
|
|
306
|
+
if c_name not in consequents:
|
|
307
|
+
consequents.append(c_name)
|
|
308
|
+
elif isinstance(consequent, str):
|
|
309
|
+
if consequent not in consequents:
|
|
310
|
+
consequents.append(consequent)
|
|
311
|
+
else:
|
|
312
|
+
raise TypeError(f"Unsupported rule format: {type(rule).__name__}")
|
|
313
|
+
|
|
314
|
+
return consequents
|
|
315
|
+
|
|
316
|
+
def _resolve_consequents(self, spec: SugenoModelSpec) -> list[str]:
|
|
317
|
+
"""Извлечь и провалидировать список уникальных имён констант заключений правил."""
|
|
318
|
+
rule_consequents = self._extract_rule_consequents(spec.rules)
|
|
319
|
+
if not rule_consequents:
|
|
320
|
+
raise ValueError("No consequent membership functions found in model_spec rules.")
|
|
321
|
+
|
|
322
|
+
if spec.consequents is not None:
|
|
323
|
+
consequents_list = list(spec.consequents)
|
|
324
|
+
if not consequents_list:
|
|
325
|
+
raise ValueError("model_spec.consequents cannot be empty.")
|
|
326
|
+
|
|
327
|
+
# Проверка на дубликаты
|
|
328
|
+
if len(set(consequents_list)) != len(consequents_list):
|
|
329
|
+
seen: set[str] = set()
|
|
330
|
+
dups: list[str] = []
|
|
331
|
+
for c in consequents_list:
|
|
332
|
+
if c in seen and c not in dups:
|
|
333
|
+
dups.append(c)
|
|
334
|
+
seen.add(c)
|
|
335
|
+
raise ValueError(f"model_spec.consequents contains duplicate names: {dups}")
|
|
336
|
+
|
|
337
|
+
# Проверка соответствия именам в правилах
|
|
338
|
+
rule_set = set(rule_consequents)
|
|
339
|
+
spec_set = set(consequents_list)
|
|
340
|
+
|
|
341
|
+
missing_in_spec = rule_set - spec_set
|
|
342
|
+
if missing_in_spec:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"model_spec.consequents is missing consequents referenced in rules: {sorted(missing_in_spec)}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
unused_in_rules = spec_set - rule_set
|
|
348
|
+
if unused_in_rules:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"model_spec.consequents defines consequents not referenced by any rule: {sorted(unused_in_rules)}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return consequents_list
|
|
354
|
+
|
|
355
|
+
return rule_consequents
|
|
356
|
+
|
|
357
|
+
def _build_engine(
|
|
358
|
+
self,
|
|
359
|
+
spec: SugenoModelSpec,
|
|
360
|
+
consequent_values: Mapping[str, float],
|
|
361
|
+
consequent_names: Sequence[str],
|
|
362
|
+
) -> openfll.SugenoEngine:
|
|
363
|
+
"""Построить и скомпилировать экземпляр SugenoEngine с заданными константами."""
|
|
364
|
+
engine = openfll.SugenoEngine()
|
|
365
|
+
|
|
366
|
+
t_norm = self.default_t_norm or spec.default_t_norm
|
|
367
|
+
s_norm = self.default_s_norm or spec.default_s_norm
|
|
368
|
+
if t_norm:
|
|
369
|
+
engine.set_default_t_norm(t_norm)
|
|
370
|
+
if s_norm:
|
|
371
|
+
engine.set_default_s_norm(s_norm)
|
|
372
|
+
|
|
373
|
+
for var_name, mfs in spec.inputs.items():
|
|
374
|
+
engine.add_input_var(var_name)
|
|
375
|
+
for mf in mfs:
|
|
376
|
+
if isinstance(mf, Mapping):
|
|
377
|
+
mf_name = mf["name"]
|
|
378
|
+
mf_type = mf["type"]
|
|
379
|
+
mf_params = mf["params"]
|
|
380
|
+
elif isinstance(mf, (tuple, list)) and len(mf) == 3:
|
|
381
|
+
mf_name, mf_type, mf_params = mf
|
|
382
|
+
else:
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"Invalid membership function definition for variable {var_name!r}: {mf!r}"
|
|
385
|
+
)
|
|
386
|
+
engine.add_membership_func(var_name, str(mf_name), str(mf_type), list(mf_params))
|
|
387
|
+
|
|
388
|
+
engine.add_output_var(spec.output_name)
|
|
389
|
+
|
|
390
|
+
for name in consequent_names:
|
|
391
|
+
val = float(consequent_values[name])
|
|
392
|
+
engine.add_membership_func(spec.output_name, name, "constant", [val])
|
|
393
|
+
|
|
394
|
+
for rule in spec.rules:
|
|
395
|
+
if isinstance(rule, str):
|
|
396
|
+
engine.add_rule(rule)
|
|
397
|
+
elif isinstance(rule, Mapping):
|
|
398
|
+
engine.add_rule(**rule)
|
|
399
|
+
elif isinstance(rule, (tuple, list)):
|
|
400
|
+
engine.add_rule(*rule)
|
|
401
|
+
else:
|
|
402
|
+
raise TypeError(f"Unsupported rule format: {type(rule).__name__}")
|
|
403
|
+
|
|
404
|
+
engine.build()
|
|
405
|
+
return engine
|
|
406
|
+
|
|
407
|
+
def fit(self, X: Any, y: Any) -> "SugenoRegressor":
|
|
408
|
+
"""Обучить константы заключений правил модели Sugeno нулевого порядка методом МНК."""
|
|
409
|
+
if self.model_spec is None:
|
|
410
|
+
raise ValueError("model_spec must be specified before calling fit().")
|
|
411
|
+
|
|
412
|
+
if isinstance(self.alpha, bool) or not np.isfinite(self.alpha) or self.alpha < 0.0:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"Regularization parameter alpha must be non-negative and finite, got {self.alpha}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
spec = self._normalize_spec()
|
|
418
|
+
if not spec.inputs:
|
|
419
|
+
raise ValueError("model_spec.inputs must define at least one input variable.")
|
|
420
|
+
if not spec.rules:
|
|
421
|
+
raise ValueError("model_spec.rules must define at least one fuzzy rule.")
|
|
422
|
+
|
|
423
|
+
feature_names = None
|
|
424
|
+
if hasattr(X, "columns"):
|
|
425
|
+
feature_names = [str(c) for c in X.columns]
|
|
426
|
+
|
|
427
|
+
X_arr, y_arr = check_X_y(
|
|
428
|
+
X,
|
|
429
|
+
y,
|
|
430
|
+
accept_sparse=False,
|
|
431
|
+
dtype=np.float64,
|
|
432
|
+
y_numeric=True,
|
|
433
|
+
multi_output=False,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
input_names = list(spec.inputs.keys())
|
|
437
|
+
if X_arr.shape[1] != len(input_names):
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"X has {X_arr.shape[1]} features, but model_spec expects {len(input_names)} "
|
|
440
|
+
f"features ({input_names})."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if feature_names is not None and feature_names != input_names:
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"Feature names in X {feature_names} do not match model_spec inputs {input_names}."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
consequent_names = self._resolve_consequents(spec)
|
|
449
|
+
if not consequent_names:
|
|
450
|
+
raise ValueError("No consequent membership functions found in model_spec rules.")
|
|
451
|
+
|
|
452
|
+
M = len(consequent_names)
|
|
453
|
+
N = X_arr.shape[0]
|
|
454
|
+
|
|
455
|
+
# Строим матрицу влияния consequents A(X) через нативный batch (метод базисной декомпозиции)
|
|
456
|
+
A = np.empty((N, M), dtype=np.float64)
|
|
457
|
+
for m, c_name in enumerate(consequent_names):
|
|
458
|
+
basis_vals = {name: (1.0 if name == c_name else 0.0) for name in consequent_names}
|
|
459
|
+
basis_engine = self._build_engine(spec, basis_vals, consequent_names)
|
|
460
|
+
col = basis_engine.predict_batch(X_arr, input_names=input_names)
|
|
461
|
+
A[:, m] = col
|
|
462
|
+
|
|
463
|
+
# Проверка на нулевую сумму активаций (NaN в строках матрицы A)
|
|
464
|
+
nan_mask = np.isnan(A).any(axis=1)
|
|
465
|
+
if np.any(nan_mask):
|
|
466
|
+
num_bad = int(np.sum(nan_mask))
|
|
467
|
+
raise ValueError(
|
|
468
|
+
f"Training data contains {num_bad} sample(s) outside the support of the fuzzy rule base "
|
|
469
|
+
f"(total rule activation is zero, producing NaN). Ensure membership functions and rules "
|
|
470
|
+
f"cover the domain of X."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Решение задачи наименьших квадратов
|
|
474
|
+
if self.solver == "lstsq":
|
|
475
|
+
if self.alpha != 0.0:
|
|
476
|
+
raise ValueError(
|
|
477
|
+
f"solver='lstsq' does not support L2 regularization (got alpha={self.alpha}). "
|
|
478
|
+
f"Set solver='ridge' to use L2 regularization, or set alpha=0.0."
|
|
479
|
+
)
|
|
480
|
+
coef, _, rank, _ = np.linalg.lstsq(A, y_arr, rcond=None)
|
|
481
|
+
elif self.solver == "ridge":
|
|
482
|
+
if self.alpha <= 0.0:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"solver='ridge' requires alpha > 0.0, got {self.alpha}. "
|
|
485
|
+
f"Use solver='lstsq' for unregularized least squares."
|
|
486
|
+
)
|
|
487
|
+
reg_mat = A.T @ A + self.alpha * np.eye(M, dtype=np.float64)
|
|
488
|
+
rhs = A.T @ y_arr
|
|
489
|
+
coef = np.linalg.solve(reg_mat, rhs)
|
|
490
|
+
rank = int(np.linalg.matrix_rank(A))
|
|
491
|
+
else:
|
|
492
|
+
raise ValueError(f"Unknown solver: {self.solver!r}. Supported solvers are 'lstsq' and 'ridge'.")
|
|
493
|
+
|
|
494
|
+
# Создаём и компилируем итоговый обученный engine с найденными константами
|
|
495
|
+
fitted_vals = {name: float(coef[m]) for m, name in enumerate(consequent_names)}
|
|
496
|
+
fitted_engine = self._build_engine(spec, fitted_vals, consequent_names)
|
|
497
|
+
|
|
498
|
+
# Сохраняем обученные атрибуты (в соответствии с конвенцией sklearn)
|
|
499
|
+
self.engine_ = fitted_engine
|
|
500
|
+
self.coef_ = np.asarray(coef, dtype=np.float64)
|
|
501
|
+
self.consequents_ = list(consequent_names)
|
|
502
|
+
self.rule_names_ = [f"rule_{i}" for i in range(len(spec.rules))]
|
|
503
|
+
self.rank_ = int(rank)
|
|
504
|
+
self.n_features_in_ = X_arr.shape[1]
|
|
505
|
+
if feature_names is not None:
|
|
506
|
+
self.feature_names_in_ = np.asarray(feature_names, dtype=object)
|
|
507
|
+
elif hasattr(self, "feature_names_in_"):
|
|
508
|
+
delattr(self, "feature_names_in_")
|
|
509
|
+
|
|
510
|
+
return self
|
|
511
|
+
|
|
512
|
+
def predict(self, X: Any) -> np.ndarray:
|
|
513
|
+
"""Выполнить batch inference на обученной модели.
|
|
514
|
+
|
|
515
|
+
Принимает матрицу признаков формы ``(n_samples, n_features)`` и всегда
|
|
516
|
+
возвращает одномерный массив NumPy формы ``(n_samples,)``.
|
|
517
|
+
|
|
518
|
+
Предупреждение:
|
|
519
|
+
Инстанс не является потокобезопасным для инференса. Для многопоточной
|
|
520
|
+
обработки необходимо клонировать модель или создать независимые экземпляры.
|
|
521
|
+
"""
|
|
522
|
+
check_is_fitted(self, attributes=["engine_", "coef_"])
|
|
523
|
+
|
|
524
|
+
feature_names = None
|
|
525
|
+
if hasattr(X, "columns"):
|
|
526
|
+
feature_names = [str(c) for c in X.columns]
|
|
527
|
+
|
|
528
|
+
X_arr = check_array(X, accept_sparse=False, dtype=np.float64)
|
|
529
|
+
|
|
530
|
+
if X_arr.shape[1] != self.n_features_in_:
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"X has {X_arr.shape[1]} features, but {type(self).__name__} is fitted with {self.n_features_in_} features"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if feature_names is not None and hasattr(self, "feature_names_in_"):
|
|
536
|
+
expected = list(self.feature_names_in_)
|
|
537
|
+
if feature_names != expected:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"The feature names should match those that were passed during fit.\n"
|
|
540
|
+
f"Feature names seen at fit time: {expected}\n"
|
|
541
|
+
f"Feature names seen at predict time: {feature_names}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
spec = self._normalize_spec()
|
|
545
|
+
input_names = list(spec.inputs.keys())
|
|
546
|
+
return self.engine_.predict_batch(X_arr, input_names=input_names)
|