processbehavior 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- processbehavior/__init__.py +133 -0
- processbehavior/analysis.py +2944 -0
- processbehavior/analysis_dataset.py +361 -0
- processbehavior/analysis_result.py +1276 -0
- processbehavior/calibration.py +70 -0
- processbehavior/capability.py +720 -0
- processbehavior/data_preparation.py +792 -0
- processbehavior/datasets/__init__.py +37 -0
- processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
- processbehavior/datasets/loaders.py +66 -0
- processbehavior/datasets/synthetic.py +1711 -0
- processbehavior/derivations.py +602 -0
- processbehavior/effects_calculator.py +603 -0
- processbehavior/exceptions.py +216 -0
- processbehavior/exporters/__init__.py +5 -0
- processbehavior/exporters/excel_exporter.py +708 -0
- processbehavior/focused_result.py +329 -0
- processbehavior/formulation_spec.py +144 -0
- processbehavior/loss_function.py +517 -0
- processbehavior/maximum_information.py +285 -0
- processbehavior/plotting/__init__.py +46 -0
- processbehavior/plotting/_math.py +121 -0
- processbehavior/plotting/capability_chart.py +858 -0
- processbehavior/plotting/contracts.py +176 -0
- processbehavior/plotting/control_chart.py +206 -0
- processbehavior/plotting/effects_charts.py +640 -0
- processbehavior/plotting/lane_boundaries.py +78 -0
- processbehavior/plotting/limits.py +157 -0
- processbehavior/plotting/maximum_information_chart.py +395 -0
- processbehavior/plotting/plotter.py +1469 -0
- processbehavior/plotting/renderers.py +660 -0
- processbehavior/plotting/report.py +211 -0
- processbehavior/plotting/residuals.py +282 -0
- processbehavior/plotting/run_rules_viz.py +150 -0
- processbehavior/plotting/stats_box.py +183 -0
- processbehavior/plotting/themes.py +544 -0
- processbehavior/plotting/x_axis_layout.py +474 -0
- processbehavior/plotting/zones.py +127 -0
- processbehavior/process_behavior.py +1290 -0
- processbehavior/py.typed +0 -0
- processbehavior/residual_calculator.py +572 -0
- processbehavior/result_signals.py +149 -0
- processbehavior/result_tabular.py +296 -0
- processbehavior/sds_detector.py +1432 -0
- processbehavior/signals/__init__.py +14 -0
- processbehavior/signals/config.py +435 -0
- processbehavior/signals/detector.py +328 -0
- processbehavior/signals/detectors.py +368 -0
- processbehavior/signals/result.py +232 -0
- processbehavior/spc_constants.py +692 -0
- processbehavior/study.py +2733 -0
- processbehavior/types.py +154 -0
- processbehavior-0.2.0.dist-info/METADATA +279 -0
- processbehavior-0.2.0.dist-info/RECORD +56 -0
- processbehavior-0.2.0.dist-info/WHEEL +4 -0
- processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Derived Variables — serializable specifications for transforms and binning.
|
|
3
|
+
|
|
4
|
+
A :class:`Derivation` is a *spec* (data, not code) that describes a new column
|
|
5
|
+
derived from an existing one: a continuous→continuous **transform** (``log``,
|
|
6
|
+
``sqrt``, ``arcsin(√x)``, ``zscore``, …) or a continuous→categorical **bin**
|
|
7
|
+
(``equal_freq``/``equal_width``/``breaks``/``sd``). Specs are evaluated against a
|
|
8
|
+
source column by :func:`evaluate`, which is the single primitive shared by the
|
|
9
|
+
fluent ``ProcessBehavior`` verbs (attach path) and the application's attach-free
|
|
10
|
+
live preview.
|
|
11
|
+
|
|
12
|
+
Design notes
|
|
13
|
+
------------
|
|
14
|
+
- The spec carries an opaque ``id`` (identity, distinct from the editable
|
|
15
|
+
``label``) and round-trips to/from a plain dict via :meth:`Derivation.to_dict`
|
|
16
|
+
/ :meth:`Derivation.from_dict`.
|
|
17
|
+
- Data-dependent fits (bin edges, z-score μ/σ) are resolved by ``evaluate`` and
|
|
18
|
+
written into ``fitted``; they freeze when a study is formulated.
|
|
19
|
+
- ``evaluate`` / :func:`validate` never raise on routine user or domain states —
|
|
20
|
+
domain violations are returned as structured data. Exceptions are reserved for
|
|
21
|
+
programmer error and the explicit ``on_invalid='error'`` commit at formulation.
|
|
22
|
+
|
|
23
|
+
Box–Cox is intentionally out of scope for v1 (it would need scipy); the transform
|
|
24
|
+
registry leaves a clean slot to add it behind an optional import later.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass, field, replace
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from .exceptions import ValidationError
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# Enums
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
# Canonical transform functions (v1). ``ln`` is an accepted alias for ``log``.
|
|
44
|
+
TRANSFORM_FUNCTIONS: tuple[str, ...] = (
|
|
45
|
+
'log', 'log10', 'sqrt', 'arcsin', 'inverse', 'square', 'power', 'zscore',
|
|
46
|
+
)
|
|
47
|
+
BIN_METHODS: tuple[str, ...] = ('equal_freq', 'equal_width', 'breaks', 'sd')
|
|
48
|
+
BIN_LABEL_STYLES: tuple[str, ...] = ('range', 'ordinal', 'number')
|
|
49
|
+
|
|
50
|
+
# Closed-boundary clamp tolerance: float noise this close to a *closed* domain
|
|
51
|
+
# boundary is clamped to it (valid), not flagged as a domain violation.
|
|
52
|
+
_CLAMP_EPS = 1e-9
|
|
53
|
+
|
|
54
|
+
# Curated ordinal labels keyed on the *fitted* bin count.
|
|
55
|
+
_ORDINAL_LABELS: dict[int, list[str]] = {
|
|
56
|
+
2: ['Low', 'High'],
|
|
57
|
+
3: ['Low', 'Medium', 'High'],
|
|
58
|
+
4: ['Low', 'Medium-Low', 'Medium-High', 'High'],
|
|
59
|
+
5: ['Low', 'Medium-Low', 'Medium', 'Medium-High', 'High'],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ============================================================================
|
|
64
|
+
# JSON-safe (de)serialization for params / fitted (handles ±inf, nan)
|
|
65
|
+
# ============================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _jsonify(obj):
|
|
69
|
+
"""Recursively make a value JSON-safe, encoding non-finite floats as tags."""
|
|
70
|
+
if isinstance(obj, bool):
|
|
71
|
+
return obj
|
|
72
|
+
if isinstance(obj, float):
|
|
73
|
+
if math.isinf(obj):
|
|
74
|
+
return 'Infinity' if obj > 0 else '-Infinity'
|
|
75
|
+
if math.isnan(obj):
|
|
76
|
+
return 'NaN'
|
|
77
|
+
return obj
|
|
78
|
+
if isinstance(obj, dict):
|
|
79
|
+
return {k: _jsonify(v) for k, v in obj.items()}
|
|
80
|
+
if isinstance(obj, (list, tuple)):
|
|
81
|
+
return [_jsonify(v) for v in obj]
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dejsonify(obj):
|
|
86
|
+
"""Inverse of :func:`_jsonify`."""
|
|
87
|
+
if obj == 'Infinity':
|
|
88
|
+
return math.inf
|
|
89
|
+
if obj == '-Infinity':
|
|
90
|
+
return -math.inf
|
|
91
|
+
if obj == 'NaN':
|
|
92
|
+
return math.nan
|
|
93
|
+
if isinstance(obj, dict):
|
|
94
|
+
return {k: _dejsonify(v) for k, v in obj.items()}
|
|
95
|
+
if isinstance(obj, list):
|
|
96
|
+
return [_dejsonify(v) for v in obj]
|
|
97
|
+
return obj
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ascending(seq) -> bool:
|
|
101
|
+
return all(a < b for a, b in zip(seq, seq[1:], strict=False))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ============================================================================
|
|
105
|
+
# Result objects
|
|
106
|
+
# ============================================================================
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class EvalResult:
|
|
111
|
+
"""Outcome of evaluating one :class:`Derivation` against a column.
|
|
112
|
+
|
|
113
|
+
Attributes
|
|
114
|
+
----------
|
|
115
|
+
values : pandas.Series
|
|
116
|
+
The derived column (numeric for transforms, ordered Categorical for bins).
|
|
117
|
+
n_invalid : int
|
|
118
|
+
Count of domain violations among non-NA inputs (never counts NA).
|
|
119
|
+
invalid_index : pandas.Index
|
|
120
|
+
Row labels of the violating inputs (contains no NA-row labels).
|
|
121
|
+
fitted : dict
|
|
122
|
+
Resolved data-dependent params to write back onto the spec
|
|
123
|
+
(e.g. ``{'mu', 'sigma'}`` for z-score, ``{'edges', 'n_bins', …}`` for bins).
|
|
124
|
+
message : str or None
|
|
125
|
+
Human-readable note (e.g. a qcut bin-count drop), or None.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
values: pd.Series
|
|
129
|
+
n_invalid: int
|
|
130
|
+
invalid_index: pd.Index
|
|
131
|
+
fitted: dict
|
|
132
|
+
message: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class ValidationResult:
|
|
137
|
+
"""Structured pre-commit check (no exceptions for routine states)."""
|
|
138
|
+
|
|
139
|
+
ok: bool
|
|
140
|
+
issues: list[dict]
|
|
141
|
+
|
|
142
|
+
def summary(self) -> str:
|
|
143
|
+
return '; '.join(i.get('message', i.get('code', '')) for i in self.issues)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ============================================================================
|
|
147
|
+
# Derivation spec
|
|
148
|
+
# ============================================================================
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Derivation:
|
|
153
|
+
"""A serializable derived-variable specification.
|
|
154
|
+
|
|
155
|
+
Construct via the :meth:`transform` / :meth:`bin` factories rather than
|
|
156
|
+
directly. Equality is by content (``family``/``column``/``function``/
|
|
157
|
+
``label``/``params``); ``id`` and ``fitted`` are excluded from equality —
|
|
158
|
+
``id`` is opaque identity, ``fitted`` is resolved state.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
family: str
|
|
162
|
+
column: str
|
|
163
|
+
function: str
|
|
164
|
+
label: str | None = None
|
|
165
|
+
params: dict = field(default_factory=dict)
|
|
166
|
+
fitted: dict = field(default_factory=dict, compare=False)
|
|
167
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12], compare=False)
|
|
168
|
+
|
|
169
|
+
# -- validation (branches on family) -----------------------------------
|
|
170
|
+
def __post_init__(self) -> None:
|
|
171
|
+
if not self.column or not isinstance(self.column, str):
|
|
172
|
+
raise ValidationError('Derivation.column must be a non-empty string.')
|
|
173
|
+
|
|
174
|
+
if self.family == 'transform':
|
|
175
|
+
if self.function not in TRANSFORM_FUNCTIONS:
|
|
176
|
+
raise ValidationError(
|
|
177
|
+
f"Unknown transform function {self.function!r}. "
|
|
178
|
+
f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).'
|
|
179
|
+
)
|
|
180
|
+
if self.function == 'power' and 'exponent' not in self.params:
|
|
181
|
+
raise ValidationError("transform 'power' requires an 'exponent' param.")
|
|
182
|
+
|
|
183
|
+
elif self.family == 'bin':
|
|
184
|
+
if self.function != 'bin':
|
|
185
|
+
raise ValidationError(
|
|
186
|
+
f"bin derivations must have function='bin', got {self.function!r}."
|
|
187
|
+
)
|
|
188
|
+
method = self.params.get('method')
|
|
189
|
+
if method not in BIN_METHODS:
|
|
190
|
+
raise ValidationError(
|
|
191
|
+
f"Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}."
|
|
192
|
+
)
|
|
193
|
+
if method == 'breaks':
|
|
194
|
+
breaks = self.params.get('breaks')
|
|
195
|
+
if not breaks or len(breaks) < 1 or not _ascending(breaks):
|
|
196
|
+
raise ValidationError(
|
|
197
|
+
"bin method 'breaks' requires an ascending list of cut points."
|
|
198
|
+
)
|
|
199
|
+
elif method in ('equal_freq', 'equal_width'):
|
|
200
|
+
n = self.params.get('n')
|
|
201
|
+
if not (isinstance(n, int) and n > 0):
|
|
202
|
+
raise ValidationError(
|
|
203
|
+
f"bin method {method!r} requires an integer n > 0, got {n!r}."
|
|
204
|
+
)
|
|
205
|
+
bl = self.params.get('bin_labels', 'range')
|
|
206
|
+
if isinstance(bl, str) and bl not in BIN_LABEL_STYLES:
|
|
207
|
+
raise ValidationError(
|
|
208
|
+
f"Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} "
|
|
209
|
+
'or an explicit list of names.'
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
raise ValidationError(
|
|
213
|
+
f"Derivation.family must be 'transform' or 'bin', got {self.family!r}."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# -- factories ---------------------------------------------------------
|
|
217
|
+
@classmethod
|
|
218
|
+
def transform(
|
|
219
|
+
cls,
|
|
220
|
+
column: str,
|
|
221
|
+
function: str,
|
|
222
|
+
*,
|
|
223
|
+
label: str | None = None,
|
|
224
|
+
shift: float | None = None,
|
|
225
|
+
exponent: float | None = None,
|
|
226
|
+
on_invalid: str = 'error',
|
|
227
|
+
) -> Derivation:
|
|
228
|
+
"""Build a transform spec. ``ln`` canonicalizes to ``log``."""
|
|
229
|
+
fn = 'log' if function == 'ln' else function
|
|
230
|
+
params: dict = {'on_invalid': on_invalid}
|
|
231
|
+
if shift is not None:
|
|
232
|
+
params['shift'] = shift
|
|
233
|
+
if exponent is not None:
|
|
234
|
+
params['exponent'] = exponent
|
|
235
|
+
return cls(family='transform', column=column, function=fn, label=label, params=params)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def bin(
|
|
239
|
+
cls,
|
|
240
|
+
column: str,
|
|
241
|
+
*,
|
|
242
|
+
method: str = 'equal_freq',
|
|
243
|
+
n: int = 4,
|
|
244
|
+
breaks: list[float] | None = None,
|
|
245
|
+
bin_labels='range',
|
|
246
|
+
label: str | None = None,
|
|
247
|
+
right: bool = False,
|
|
248
|
+
) -> Derivation:
|
|
249
|
+
"""Build a binning spec."""
|
|
250
|
+
params: dict = {'method': method, 'bin_labels': bin_labels, 'right': right}
|
|
251
|
+
if method == 'breaks':
|
|
252
|
+
params['breaks'] = list(breaks) if breaks is not None else None
|
|
253
|
+
else:
|
|
254
|
+
params['n'] = n
|
|
255
|
+
return cls(family='bin', column=column, function='bin', label=label, params=params)
|
|
256
|
+
|
|
257
|
+
# -- naming / serialization -------------------------------------------
|
|
258
|
+
@property
|
|
259
|
+
def output_name(self) -> str:
|
|
260
|
+
"""Output column name — ``label`` or ``{column}_{function}`` (bins → ``{column}_bin``)."""
|
|
261
|
+
return self.label or f'{self.column}_{self.function}'
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> dict:
|
|
264
|
+
"""Plain JSON-safe dict (includes ``id`` and frozen ``fitted``)."""
|
|
265
|
+
return {
|
|
266
|
+
'id': self.id,
|
|
267
|
+
'family': self.family,
|
|
268
|
+
'column': self.column,
|
|
269
|
+
'function': self.function,
|
|
270
|
+
'label': self.label,
|
|
271
|
+
'params': _jsonify(self.params),
|
|
272
|
+
'fitted': _jsonify(self.fitted),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def from_dict(cls, d: dict) -> Derivation:
|
|
277
|
+
"""Reconstruct from :meth:`to_dict`. Takes ``id`` from the dict (never mints)."""
|
|
278
|
+
return cls(
|
|
279
|
+
family=d['family'],
|
|
280
|
+
column=d['column'],
|
|
281
|
+
function=d['function'],
|
|
282
|
+
label=d.get('label'),
|
|
283
|
+
params=_dejsonify(d.get('params', {})),
|
|
284
|
+
fitted=_dejsonify(d.get('fitted', {})),
|
|
285
|
+
id=d['id'],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def with_fitted(self, fitted: dict) -> Derivation:
|
|
289
|
+
"""Return a copy with resolved ``fitted`` values frozen on (same ``id``)."""
|
|
290
|
+
return replace(self, fitted=dict(fitted))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ============================================================================
|
|
294
|
+
# Transform registry
|
|
295
|
+
# ============================================================================
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _bounded_domain(lower=None, lower_closed=True, upper=None, upper_closed=True):
|
|
299
|
+
"""Return a ``(x, present) -> (clamped, violation)`` domain checker.
|
|
300
|
+
|
|
301
|
+
Closed boundaries clamp float-noise within ``_CLAMP_EPS`` to the boundary
|
|
302
|
+
(valid, not counted); open boundaries flag the boundary value itself.
|
|
303
|
+
NA is never folded into the violation mask (``present`` gates every term).
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def check(x: pd.Series, present: pd.Series):
|
|
307
|
+
clamped = x.copy()
|
|
308
|
+
violation = pd.Series(False, index=x.index)
|
|
309
|
+
if lower is not None:
|
|
310
|
+
if lower_closed:
|
|
311
|
+
near = present & (x < lower) & (x >= lower - _CLAMP_EPS)
|
|
312
|
+
clamped = clamped.mask(near, lower)
|
|
313
|
+
violation = violation | (present & (x < lower - _CLAMP_EPS))
|
|
314
|
+
else:
|
|
315
|
+
violation = violation | (present & (x <= lower))
|
|
316
|
+
if upper is not None:
|
|
317
|
+
if upper_closed:
|
|
318
|
+
near = present & (x > upper) & (x <= upper + _CLAMP_EPS)
|
|
319
|
+
clamped = clamped.mask(near, upper)
|
|
320
|
+
violation = violation | (present & (x > upper + _CLAMP_EPS))
|
|
321
|
+
else:
|
|
322
|
+
violation = violation | (present & (x >= upper))
|
|
323
|
+
return clamped, violation
|
|
324
|
+
|
|
325
|
+
return check
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# name -> (numpy fn applied to clamped input, domain checker)
|
|
329
|
+
_TRANSFORM_REGISTRY: dict[str, tuple[Callable, Callable]] = {
|
|
330
|
+
'log': (np.log, _bounded_domain(lower=0.0, lower_closed=False)),
|
|
331
|
+
'log10': (np.log10, _bounded_domain(lower=0.0, lower_closed=False)),
|
|
332
|
+
'sqrt': (np.sqrt, _bounded_domain(lower=0.0, lower_closed=True)),
|
|
333
|
+
'arcsin': (
|
|
334
|
+
lambda a: np.arcsin(np.sqrt(a)),
|
|
335
|
+
_bounded_domain(lower=0.0, lower_closed=True, upper=1.0, upper_closed=True),
|
|
336
|
+
),
|
|
337
|
+
'square': (lambda a: np.power(a, 2), _bounded_domain()),
|
|
338
|
+
'inverse': (lambda a: 1.0 / a, lambda x, present: (x, present & (x == 0))),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
|
|
343
|
+
x = pd.to_numeric(col, errors='coerce').astype('float64')
|
|
344
|
+
present = x.notna()
|
|
345
|
+
params = spec.params
|
|
346
|
+
shift = params.get('shift')
|
|
347
|
+
if shift is not None:
|
|
348
|
+
x = x + shift
|
|
349
|
+
|
|
350
|
+
fitted: dict = {}
|
|
351
|
+
message: str | None = None
|
|
352
|
+
|
|
353
|
+
if spec.function == 'zscore':
|
|
354
|
+
clamped, violation = x, pd.Series(False, index=x.index)
|
|
355
|
+
vals = x[present]
|
|
356
|
+
mu = float(vals.mean()) if present.any() else math.nan
|
|
357
|
+
sigma = float(vals.std(ddof=1)) if present.sum() > 1 else math.nan
|
|
358
|
+
fitted = {'mu': mu, 'sigma': sigma}
|
|
359
|
+
with np.errstate(all='ignore'):
|
|
360
|
+
y = (x - mu) / sigma
|
|
361
|
+
if not (sigma and math.isfinite(sigma) and sigma > 0):
|
|
362
|
+
message = 'zero or undefined variance; zscore is undefined'
|
|
363
|
+
|
|
364
|
+
elif spec.function == 'power':
|
|
365
|
+
exponent = params['exponent']
|
|
366
|
+
with np.errstate(all='ignore'):
|
|
367
|
+
y = pd.Series(np.power(x.to_numpy(), exponent), index=x.index)
|
|
368
|
+
# A power is a domain violation where it cannot be represented as a
|
|
369
|
+
# finite real (negative base to a fractional power, 0 to a negative power).
|
|
370
|
+
violation = present & ~np.isfinite(y)
|
|
371
|
+
|
|
372
|
+
else:
|
|
373
|
+
fn, domain = _TRANSFORM_REGISTRY[spec.function]
|
|
374
|
+
clamped, violation = domain(x, present)
|
|
375
|
+
with np.errstate(all='ignore'):
|
|
376
|
+
y = pd.Series(fn(clamped.to_numpy()), index=x.index)
|
|
377
|
+
|
|
378
|
+
# Violations -> NaN in the output (NA inputs already produce NaN).
|
|
379
|
+
y = y.where(~violation)
|
|
380
|
+
n_invalid = int(violation.sum())
|
|
381
|
+
invalid_index = x.index[violation]
|
|
382
|
+
return EvalResult(
|
|
383
|
+
values=y, n_invalid=n_invalid, invalid_index=invalid_index, fitted=fitted, message=message
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ============================================================================
|
|
388
|
+
# Binning
|
|
389
|
+
# ============================================================================
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _fmt(v: float) -> str:
|
|
393
|
+
return f'{v:g}'
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _range_labels(edges, right: bool) -> list[str]:
|
|
397
|
+
labels = []
|
|
398
|
+
count = len(edges) - 1
|
|
399
|
+
for i in range(count):
|
|
400
|
+
a, b = edges[i], edges[i + 1]
|
|
401
|
+
if math.isinf(a):
|
|
402
|
+
labels.append(f'< {_fmt(b)}' if not right else f'<= {_fmt(b)}')
|
|
403
|
+
elif math.isinf(b):
|
|
404
|
+
labels.append(f'>= {_fmt(a)}' if not right else f'> {_fmt(a)}')
|
|
405
|
+
else:
|
|
406
|
+
left = '[' if not right else '('
|
|
407
|
+
rb = ')' if not right else ']'
|
|
408
|
+
# final (right=False) / first (right=True) interval is closed on the
|
|
409
|
+
# bounded side so the extreme value is included.
|
|
410
|
+
if not right and i == count - 1:
|
|
411
|
+
rb = ']'
|
|
412
|
+
if right and i == 0:
|
|
413
|
+
left = '['
|
|
414
|
+
labels.append(f'{left}{_fmt(a)}, {_fmt(b)}{rb}')
|
|
415
|
+
return labels
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _bin_label_names(bin_labels, edges, right: bool):
|
|
419
|
+
"""Resolve the ordered label names for the fitted edges. Returns (labels, message)."""
|
|
420
|
+
count = len(edges) - 1
|
|
421
|
+
if isinstance(bin_labels, (list, tuple)):
|
|
422
|
+
if len(bin_labels) != count:
|
|
423
|
+
return _range_labels(edges, right), (
|
|
424
|
+
f'{len(bin_labels)} labels supplied but {count} bins fitted; using range labels'
|
|
425
|
+
)
|
|
426
|
+
return list(bin_labels), None
|
|
427
|
+
if bin_labels == 'ordinal':
|
|
428
|
+
if count in _ORDINAL_LABELS:
|
|
429
|
+
return list(_ORDINAL_LABELS[count]), None
|
|
430
|
+
return [f'Bin {i + 1}' for i in range(count)], None # fall back to number
|
|
431
|
+
if bin_labels == 'number':
|
|
432
|
+
return [f'Bin {i + 1}' for i in range(count)], None
|
|
433
|
+
return _range_labels(edges, right), None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _fit_edges(spec: Derivation, present_vals: pd.Series):
|
|
437
|
+
"""Return (edges, message) for the requested method, fitted on non-NA values."""
|
|
438
|
+
params = spec.params
|
|
439
|
+
method = params['method']
|
|
440
|
+
message = None
|
|
441
|
+
|
|
442
|
+
if method == 'breaks':
|
|
443
|
+
edges = [-math.inf, *[float(b) for b in params['breaks']], math.inf]
|
|
444
|
+
elif method == 'sd':
|
|
445
|
+
mu = float(present_vals.mean())
|
|
446
|
+
sigma = float(present_vals.std(ddof=1)) if len(present_vals) > 1 else math.nan
|
|
447
|
+
edges = [-math.inf, mu - 2 * sigma, mu - sigma, mu + sigma, mu + 2 * sigma, math.inf]
|
|
448
|
+
elif method == 'equal_width':
|
|
449
|
+
n = params['n']
|
|
450
|
+
lo, hi = float(present_vals.min()), float(present_vals.max())
|
|
451
|
+
edges = list(np.linspace(lo, hi, n + 1))
|
|
452
|
+
else: # equal_freq
|
|
453
|
+
n = params['n']
|
|
454
|
+
qs = np.linspace(0.0, 1.0, n + 1)
|
|
455
|
+
edges = list(np.unique(np.quantile(present_vals, qs)))
|
|
456
|
+
if len(edges) - 1 != n:
|
|
457
|
+
message = f'requested {n} bins, ties produced {len(edges) - 1}'
|
|
458
|
+
return edges, message
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
|
|
462
|
+
x = pd.to_numeric(col, errors='coerce').astype('float64')
|
|
463
|
+
present = x.notna()
|
|
464
|
+
params = spec.params
|
|
465
|
+
right = params.get('right', False)
|
|
466
|
+
empty_index = x.index[[]]
|
|
467
|
+
|
|
468
|
+
if present.sum() == 0:
|
|
469
|
+
return EvalResult(
|
|
470
|
+
values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
|
|
471
|
+
n_invalid=0, invalid_index=empty_index, fitted={}, message='no non-NA values to bin',
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
edges, fit_msg = _fit_edges(spec, x[present])
|
|
475
|
+
|
|
476
|
+
# Degenerate fit (no spread) — cannot bin.
|
|
477
|
+
if len(edges) < 2 or any(not math.isfinite(e) for e in edges[1:-1]):
|
|
478
|
+
return EvalResult(
|
|
479
|
+
values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
|
|
480
|
+
n_invalid=0, invalid_index=empty_index, fitted={'method': params['method']},
|
|
481
|
+
message='column has no spread; cannot bin',
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
labels, label_msg = _bin_label_names(params.get('bin_labels', 'range'), edges, right)
|
|
485
|
+
|
|
486
|
+
# Close the top edge so the maximum lands in a bin — which is what _range_labels already
|
|
487
|
+
# promises by rendering the final interval with a closed bracket. `include_lowest` is
|
|
488
|
+
# pandas' equivalent for the *bottom* edge and covers right=True; nothing covered the
|
|
489
|
+
# top, so under right=False (the default) every observation equal to the maximum binned
|
|
490
|
+
# to NaN and silently left the analysis. Only right=False needs this: the minimum was
|
|
491
|
+
# always included, by include_lowest for right=True and by the half-open interval
|
|
492
|
+
# otherwise. nextafter is the minimal correct widening — no epsilon to tune. `edges`
|
|
493
|
+
# itself is untouched, so fitted['edges'] and the labels keep the true cut points.
|
|
494
|
+
cut_edges = list(edges)
|
|
495
|
+
if not right and math.isfinite(cut_edges[-1]):
|
|
496
|
+
cut_edges[-1] = float(np.nextafter(cut_edges[-1], math.inf))
|
|
497
|
+
|
|
498
|
+
cats = pd.cut(x, bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True)
|
|
499
|
+
values = pd.Series(cats, index=x.index)
|
|
500
|
+
|
|
501
|
+
fitted = {
|
|
502
|
+
'method': params['method'],
|
|
503
|
+
'edges': [float(e) for e in edges],
|
|
504
|
+
'n_bins': len(edges) - 1,
|
|
505
|
+
'right': right,
|
|
506
|
+
'labels': list(labels),
|
|
507
|
+
}
|
|
508
|
+
if params['method'] == 'sd':
|
|
509
|
+
fitted['mu'] = float(x[present].mean())
|
|
510
|
+
fitted['sigma'] = float(x[present].std(ddof=1)) if present.sum() > 1 else math.nan
|
|
511
|
+
|
|
512
|
+
message = '; '.join(m for m in (fit_msg, label_msg) if m) or None
|
|
513
|
+
return EvalResult(
|
|
514
|
+
values=values, n_invalid=0, invalid_index=empty_index, fitted=fitted, message=message
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ============================================================================
|
|
519
|
+
# Public engine
|
|
520
|
+
# ============================================================================
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def evaluate(spec: Derivation, column: pd.Series) -> EvalResult:
|
|
524
|
+
"""Evaluate one derivation against a column — the shared preview/attach primitive.
|
|
525
|
+
|
|
526
|
+
Pre-existing NA passes through as NA; ``n_invalid`` counts only domain
|
|
527
|
+
violations among non-NA values. Never raises.
|
|
528
|
+
"""
|
|
529
|
+
if spec.family == 'transform':
|
|
530
|
+
return _evaluate_transform(spec, column)
|
|
531
|
+
return _evaluate_bin(spec, column)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def validate(spec: Derivation, dataset: pd.DataFrame, existing_names=None) -> ValidationResult:
|
|
535
|
+
"""Structured pre-commit check (label collision, dtype, bin-label count, breaks order).
|
|
536
|
+
|
|
537
|
+
Evaluates the spec against ``dataset[spec.column]`` so a bin's explicit-label
|
|
538
|
+
count is checked against the **fitted** bin count (post-qcut), not the
|
|
539
|
+
requested ``n``. Domain violations are *not* failures — they live in the
|
|
540
|
+
:class:`EvalResult` data channel. Never raises.
|
|
541
|
+
"""
|
|
542
|
+
issues: list[dict] = []
|
|
543
|
+
existing = set(existing_names or set()) | set(dataset.columns)
|
|
544
|
+
|
|
545
|
+
if spec.column not in dataset.columns:
|
|
546
|
+
issues.append({
|
|
547
|
+
'code': 'column_not_found',
|
|
548
|
+
'message': f"Column '{spec.column}' not found. Available: {list(dataset.columns)}",
|
|
549
|
+
})
|
|
550
|
+
return ValidationResult(ok=False, issues=issues)
|
|
551
|
+
|
|
552
|
+
if not pd.api.types.is_numeric_dtype(dataset[spec.column]):
|
|
553
|
+
issues.append({
|
|
554
|
+
'code': 'not_numeric',
|
|
555
|
+
'message': f"Column '{spec.column}' is not numeric; cannot derive from it.",
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
name = spec.output_name
|
|
559
|
+
if name in existing:
|
|
560
|
+
issues.append({
|
|
561
|
+
'code': 'label_collision',
|
|
562
|
+
'message': f"Output name '{name}' already exists; choose a different label.",
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
if spec.family == 'bin' and spec.params.get('method') == 'breaks':
|
|
566
|
+
breaks = spec.params.get('breaks') or []
|
|
567
|
+
if not _ascending(breaks):
|
|
568
|
+
issues.append({'code': 'breaks_order', 'message': 'breaks must be strictly ascending.'})
|
|
569
|
+
|
|
570
|
+
# Evaluate to check bin-label count against the *fitted* bin count.
|
|
571
|
+
if not any(i['code'] in ('not_numeric',) for i in issues):
|
|
572
|
+
res = evaluate(spec, dataset[spec.column])
|
|
573
|
+
if spec.family == 'bin':
|
|
574
|
+
bl = spec.params.get('bin_labels')
|
|
575
|
+
fitted_n = res.fitted.get('n_bins')
|
|
576
|
+
if isinstance(bl, (list, tuple)) and fitted_n is not None and len(bl) != fitted_n:
|
|
577
|
+
issues.append({
|
|
578
|
+
'code': 'label_count',
|
|
579
|
+
'message': f'{len(bl)} labels supplied but {fitted_n} bins fitted.',
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
return ValidationResult(ok=len(issues) == 0, issues=issues)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ============================================================================
|
|
586
|
+
# Free-function conveniences (delegate to ProcessBehavior methods)
|
|
587
|
+
# ============================================================================
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def derivations(pb) -> tuple:
|
|
591
|
+
"""Return the pending derivation specs attached to ``pb``."""
|
|
592
|
+
return pb.derivations
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def remove_derived(pb, id: str):
|
|
596
|
+
"""Return a new ProcessBehavior with the derivation ``id`` removed."""
|
|
597
|
+
return pb.remove_derived(id)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def replace_derived(pb, id: str, spec: Derivation):
|
|
601
|
+
"""Return a new ProcessBehavior with the derivation ``id`` replaced by ``spec``."""
|
|
602
|
+
return pb.replace_derived(id, spec)
|