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.
@@ -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)