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,1290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProcessBehavior - Main entry point for process behavior analysis.
|
|
3
|
+
|
|
4
|
+
This module provides a user-friendly interface with IDE auto-completion for column names
|
|
5
|
+
and automatic chart selection driven by the analytical design state (ADS).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
import processbehavior as pb
|
|
9
|
+
|
|
10
|
+
# Step 1: formulate() - analyze structure and get recommendations
|
|
11
|
+
study = pb.formulate(df, response='Measurement', factors=['Operator'], time='ProductionTime')
|
|
12
|
+
|
|
13
|
+
# Step 2: execute() - run the chart
|
|
14
|
+
result = study.execute()
|
|
15
|
+
|
|
16
|
+
Use the ProcessBehavior class directly when you want the fluent derived-variable verbs,
|
|
17
|
+
which attach before formulating, or `.cols` auto-completion for column references:
|
|
18
|
+
|
|
19
|
+
pbd = ProcessBehavior(df)
|
|
20
|
+
study = pbd.transform('torque', 'log').formulate(response=pbd.cols.torque_log)
|
|
21
|
+
|
|
22
|
+
Note the instance is bound to `pbd`, not `pb`: `pb` is the conventional alias for the
|
|
23
|
+
module itself, and shadowing it makes `pb.formulate(...)` read as the module-level
|
|
24
|
+
function when it is an instance method.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import warnings
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from .derivations import Derivation
|
|
37
|
+
from .derivations import evaluate as _evaluate_derivation
|
|
38
|
+
from .derivations import validate as _validate_derivation
|
|
39
|
+
from .exceptions import ColumnNotFoundError, ProcessBehaviorWarning, ValidationError
|
|
40
|
+
from .formulation_spec import FormulationSpec
|
|
41
|
+
from .sds_detector import SDSRegistry, SDSResult
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from .study import Study
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Threshold for auto-converting object columns to numeric.
|
|
49
|
+
# If >= this fraction of non-NA values convert successfully, apply the conversion.
|
|
50
|
+
_NUMERIC_CONVERSION_THRESHOLD = 0.8
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_text_like(series: pd.Series) -> bool:
|
|
54
|
+
"""True when a column can hold a garbage *string* token.
|
|
55
|
+
|
|
56
|
+
Enumerates the dtypes to include rather than excluding numeric ones: a categorical or
|
|
57
|
+
a pandas ``string`` column holds strings and must still be scanned, but neither is
|
|
58
|
+
``object`` and a "not numeric" test would misclassify at least one of them.
|
|
59
|
+
"""
|
|
60
|
+
from pandas.api.types import is_object_dtype, is_string_dtype
|
|
61
|
+
|
|
62
|
+
dtype = series.dtype
|
|
63
|
+
if isinstance(dtype, pd.CategoricalDtype):
|
|
64
|
+
return True
|
|
65
|
+
return bool(is_object_dtype(series) or is_string_dtype(series))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _try_clean_numeric_strings(series: pd.Series) -> pd.Series | None:
|
|
69
|
+
"""Try to clean formatted numeric strings (currency, thousands sep, etc.).
|
|
70
|
+
|
|
71
|
+
Returns a numeric Series if cleaning succeeds for >= 80% of non-NA values,
|
|
72
|
+
or None if the column doesn't look numeric after cleaning.
|
|
73
|
+
|
|
74
|
+
Handles: $, EUR, GBP, JPY, unicode currency symbols, thousands commas,
|
|
75
|
+
accounting negatives like (1,234.56), percentage signs, and whitespace.
|
|
76
|
+
"""
|
|
77
|
+
from pandas.api.types import is_datetime64_any_dtype, is_numeric_dtype
|
|
78
|
+
|
|
79
|
+
# Don't "clean" already-typed columns. Crucially, datetime64 must be guarded:
|
|
80
|
+
# pd.to_numeric(datetime64) silently succeeds (int64 nanoseconds), which would
|
|
81
|
+
# otherwise replace a date column with huge integers. (Mirrors the datetime
|
|
82
|
+
# guard in DataPreparation._detect_and_convert_type.)
|
|
83
|
+
if is_numeric_dtype(series) or is_datetime64_any_dtype(series) or isinstance(series.dtype, pd.PeriodDtype):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Only process object columns with actual data
|
|
87
|
+
non_na = series.dropna()
|
|
88
|
+
if len(non_na) == 0:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Fast path: try direct pd.to_numeric first (handles plain numeric strings)
|
|
92
|
+
direct = pd.to_numeric(series, errors='coerce')
|
|
93
|
+
direct_success = direct.notna().sum()
|
|
94
|
+
original_non_na = non_na.shape[0]
|
|
95
|
+
if direct_success >= original_non_na * _NUMERIC_CONVERSION_THRESHOLD:
|
|
96
|
+
return direct
|
|
97
|
+
|
|
98
|
+
result = _coerce_formatted_numerics(series)
|
|
99
|
+
|
|
100
|
+
# Check success rate against non-NA values only
|
|
101
|
+
converted_count = result.notna().sum()
|
|
102
|
+
if converted_count >= original_non_na * _NUMERIC_CONVERSION_THRESHOLD:
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _coerce_formatted_numerics(series: pd.Series) -> pd.Series:
|
|
109
|
+
"""The value transform behind :func:`_try_clean_numeric_strings`, without the
|
|
110
|
+
accept/reject decision.
|
|
111
|
+
|
|
112
|
+
Split out so the decision can be scored against a different population than the one
|
|
113
|
+
transformed — see :func:`_clean_numeric_strings_via_uniques`, which transforms the
|
|
114
|
+
distinct values but must score frequency-weighted over the full column.
|
|
115
|
+
"""
|
|
116
|
+
# Convert to string for .str operations (handles mixed float/str columns)
|
|
117
|
+
s = series.astype(str)
|
|
118
|
+
# Preserve original NAs
|
|
119
|
+
original_na_mask = series.isna()
|
|
120
|
+
# Also treat the string 'nan' (from astype) as NA
|
|
121
|
+
str_nan_mask = s == 'nan'
|
|
122
|
+
|
|
123
|
+
# Strip whitespace
|
|
124
|
+
cleaned = s.str.strip()
|
|
125
|
+
# Accounting negatives: (xxx) -> -xxx
|
|
126
|
+
cleaned = cleaned.str.replace(r'^\((.*)\)$', r'-\1', regex=True)
|
|
127
|
+
# Remove currency symbols and optional adjacent whitespace
|
|
128
|
+
# Use literal Unicode chars (€£¥) — raw string \u escapes aren't valid in pyarrow regex
|
|
129
|
+
cleaned = cleaned.str.replace(r'[$€£¥]\s*', '', regex=True)
|
|
130
|
+
# Remove thousands separators (commas)
|
|
131
|
+
cleaned = cleaned.str.replace(',', '', regex=False)
|
|
132
|
+
# Remove percentage signs
|
|
133
|
+
cleaned = cleaned.str.replace('%', '', regex=False)
|
|
134
|
+
# Final strip (catches residual whitespace after symbol removal)
|
|
135
|
+
cleaned = cleaned.str.strip()
|
|
136
|
+
|
|
137
|
+
result = pd.to_numeric(cleaned, errors='coerce')
|
|
138
|
+
|
|
139
|
+
# Restore original NAs (don't count astype('str') artifacts as conversions)
|
|
140
|
+
result[original_na_mask | str_nan_mask] = pd.NA
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Above this share of distinct values, mapping through the uniques stops paying for
|
|
145
|
+
# itself and the direct path is used instead. Measured at 1M rows: label-like columns
|
|
146
|
+
# win at every cardinality (36x at 10 uniques, still 1.4x when every value is distinct),
|
|
147
|
+
# but numeric-like strings — where the fast to_numeric path already short-circuits —
|
|
148
|
+
# break even around 0.10. 0.05 keeps both kinds on the winning side.
|
|
149
|
+
_UNIQUE_MAP_MAX_RATIO = 0.05
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _clean_numeric_strings_via_uniques(series: pd.Series) -> pd.Series | None:
|
|
153
|
+
"""``_try_clean_numeric_strings`` applied to the distinct values, then mapped back.
|
|
154
|
+
|
|
155
|
+
Cleaning is per-value, so on a low-cardinality column — factor labels, coded levels,
|
|
156
|
+
anything categorical — the direct path repeats identical string work millions of
|
|
157
|
+
times. Converting the distinct values and mapping back is 19-36x faster at 1M rows
|
|
158
|
+
with identical output.
|
|
159
|
+
|
|
160
|
+
The threshold is the subtle part. ``_try_clean_numeric_strings`` keeps a conversion
|
|
161
|
+
when >= 80% of *non-NA values* convert, which is frequency-weighted; the same test
|
|
162
|
+
over distinct values is not. A column of 9,900 copies of ``'1'`` plus 100 distinct
|
|
163
|
+
labels converts on the full column (99%) but fails over uniques (1%). So the
|
|
164
|
+
conversion is re-scored here against the full column, never against the uniques.
|
|
165
|
+
"""
|
|
166
|
+
from pandas.api.types import is_object_dtype
|
|
167
|
+
|
|
168
|
+
# Plain object only. Series.map preserves an extension dtype — mapping a categorical
|
|
169
|
+
# returns a *categorical* of ints where the direct path returns int64, and a `string`
|
|
170
|
+
# column maps to int64 where the direct path returns nullable Int64. Both are real
|
|
171
|
+
# dtype changes (a categorical is not numeric, which would drop the column out of a
|
|
172
|
+
# client's "numeric columns" picker), and replicating pandas' dtype rules here would
|
|
173
|
+
# be more fragile than simply not taking the shortcut. Object is where the win is.
|
|
174
|
+
if not is_object_dtype(series):
|
|
175
|
+
return _try_clean_numeric_strings(series)
|
|
176
|
+
|
|
177
|
+
non_na = series.dropna()
|
|
178
|
+
if len(non_na) == 0:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
uniques = pd.Series(non_na.unique())
|
|
182
|
+
if len(uniques) > len(series) * _UNIQUE_MAP_MAX_RATIO:
|
|
183
|
+
return _try_clean_numeric_strings(series)
|
|
184
|
+
|
|
185
|
+
threshold = len(non_na) * _NUMERIC_CONVERSION_THRESHOLD
|
|
186
|
+
|
|
187
|
+
def _mapped(converted_uniques: pd.Series) -> pd.Series:
|
|
188
|
+
# Keys come from non-NA values only, so no NaN-is-not-equal-to-itself problem.
|
|
189
|
+
# Values absent from the mapping (the original NAs) come back as NaN, matching
|
|
190
|
+
# the direct path.
|
|
191
|
+
return series.map(dict(zip(uniques, converted_uniques, strict=False)))
|
|
192
|
+
|
|
193
|
+
# Fast path, then slow path — the same order and the same acceptance test as
|
|
194
|
+
# _try_clean_numeric_strings, but scored over the full column so the frequency
|
|
195
|
+
# weighting is preserved.
|
|
196
|
+
direct = _mapped(pd.to_numeric(uniques, errors='coerce'))
|
|
197
|
+
if direct.notna().sum() >= threshold:
|
|
198
|
+
return direct
|
|
199
|
+
|
|
200
|
+
formatted = _mapped(_coerce_formatted_numerics(uniques))
|
|
201
|
+
if formatted.notna().sum() >= threshold:
|
|
202
|
+
return formatted
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass
|
|
208
|
+
class ColumnRef:
|
|
209
|
+
"""
|
|
210
|
+
Column reference with level awareness for IDE discoverability.
|
|
211
|
+
|
|
212
|
+
NOT a str subclass to avoid pandas/numpy quirks, serialization issues,
|
|
213
|
+
and hashing/equality surprises. Implements __hash__ and __eq__ for
|
|
214
|
+
dict key usage. Compares equal to strings for flexibility.
|
|
215
|
+
|
|
216
|
+
Attributes
|
|
217
|
+
----------
|
|
218
|
+
name : str
|
|
219
|
+
The column name in the DataFrame
|
|
220
|
+
levels : list
|
|
221
|
+
Sorted unique values from the data (property)
|
|
222
|
+
count : int
|
|
223
|
+
Number of distinct levels (property)
|
|
224
|
+
|
|
225
|
+
Examples
|
|
226
|
+
--------
|
|
227
|
+
>>> pb = ProcessBehavior(df)
|
|
228
|
+
>>> pb.cols.Lane # Lane (4): [1, 2, 3, 4]
|
|
229
|
+
>>> pb.cols.Lane.levels # [1, 2, 3, 4]
|
|
230
|
+
>>> pb.cols.Lane.count # 4
|
|
231
|
+
|
|
232
|
+
Use in plan:
|
|
233
|
+
|
|
234
|
+
>>> study = pb.formulate(
|
|
235
|
+
... response=pb.cols.Weight,
|
|
236
|
+
... plan={pb.cols.Lane: [1, 2, 3, 4]}
|
|
237
|
+
... )
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
name: str
|
|
241
|
+
_df: pd.DataFrame = field(repr=False, compare=False)
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def levels(self) -> list:
|
|
245
|
+
"""Sorted unique values from the data."""
|
|
246
|
+
values = self._df[self.name].dropna().unique()
|
|
247
|
+
try:
|
|
248
|
+
return sorted(values.tolist())
|
|
249
|
+
except TypeError:
|
|
250
|
+
# Mixed types can't be sorted
|
|
251
|
+
return list(values)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def count(self) -> int:
|
|
255
|
+
"""Number of distinct levels."""
|
|
256
|
+
return len(self.levels)
|
|
257
|
+
|
|
258
|
+
def __str__(self) -> str:
|
|
259
|
+
return self.name
|
|
260
|
+
|
|
261
|
+
def __hash__(self) -> int:
|
|
262
|
+
return hash(self.name)
|
|
263
|
+
|
|
264
|
+
def __eq__(self, other: object) -> bool:
|
|
265
|
+
if isinstance(other, ColumnRef):
|
|
266
|
+
return self.name == other.name
|
|
267
|
+
if isinstance(other, str):
|
|
268
|
+
return self.name == other
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str:
|
|
272
|
+
lvls = self.levels
|
|
273
|
+
if len(lvls) <= 6:
|
|
274
|
+
return f'{self.name} ({len(lvls)}): {lvls}'
|
|
275
|
+
return f'{self.name} ({len(lvls)}): [{lvls[0]}..{lvls[-1]}]'
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class ColumnAccessor:
|
|
279
|
+
"""
|
|
280
|
+
Provides IDE auto-completion for DataFrame column names with level awareness.
|
|
281
|
+
|
|
282
|
+
Usage:
|
|
283
|
+
pb = ProcessBehavior(df)
|
|
284
|
+
pb.cols.Height # ColumnRef with auto-completion
|
|
285
|
+
pb.cols.Height.levels # [1.0, 1.5, 2.0, ...]
|
|
286
|
+
pb.cols.Height.count # Number of unique levels
|
|
287
|
+
|
|
288
|
+
This class dynamically creates ColumnRef attributes for each column in the
|
|
289
|
+
DataFrame, enabling IDE auto-completion, preventing typos, and providing
|
|
290
|
+
level discoverability for sampling plans. Columns are sorted alphabetically
|
|
291
|
+
for consistent tab-completion ordering.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, df: pd.DataFrame):
|
|
295
|
+
"""
|
|
296
|
+
Initialize accessor with DataFrame columns.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
df: The DataFrame whose columns will be accessible
|
|
300
|
+
"""
|
|
301
|
+
self._df = df
|
|
302
|
+
# Sort by string representation to handle mixed-type column names
|
|
303
|
+
self._columns = sorted(df.columns, key=str)
|
|
304
|
+
self._attr_to_col = {} # Track sanitized_name → original_column
|
|
305
|
+
|
|
306
|
+
# Dynamically add each column as a ColumnRef attribute
|
|
307
|
+
for col in self._columns:
|
|
308
|
+
# Convert column name to valid Python identifier if needed
|
|
309
|
+
attr_name = self._sanitize_column_name(col)
|
|
310
|
+
|
|
311
|
+
if attr_name in self._attr_to_col:
|
|
312
|
+
# Collision detected - warn and skip
|
|
313
|
+
existing_col = self._attr_to_col[attr_name]
|
|
314
|
+
logger.warning(
|
|
315
|
+
f"Column name collision: '{col}' and '{existing_col}' "
|
|
316
|
+
f"both sanitize to '{attr_name}'. "
|
|
317
|
+
f"'{col}' will only be accessible via pb.cols['{col}']."
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
# Avoid overwriting internal attributes (e.g., _df, _columns)
|
|
321
|
+
if hasattr(self, attr_name):
|
|
322
|
+
logger.warning(
|
|
323
|
+
f"Column '{col}' sanitizes to '{attr_name}' which conflicts "
|
|
324
|
+
f"with an internal attribute. Use pb.cols['{col}'] to access."
|
|
325
|
+
)
|
|
326
|
+
continue
|
|
327
|
+
self._attr_to_col[attr_name] = col
|
|
328
|
+
setattr(self, attr_name, ColumnRef(col, df))
|
|
329
|
+
|
|
330
|
+
def _sanitize_column_name(self, col_name: str) -> str:
|
|
331
|
+
"""
|
|
332
|
+
Convert column name to valid Python identifier.
|
|
333
|
+
|
|
334
|
+
Handles spaces, special characters, etc.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
col_name: Original column name
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Sanitized name safe for use as Python attribute
|
|
341
|
+
"""
|
|
342
|
+
# Normalize non-string column names (e.g., integers)
|
|
343
|
+
col_name = str(col_name)
|
|
344
|
+
|
|
345
|
+
# Replace spaces and special chars with underscores
|
|
346
|
+
safe_name = col_name.replace(' ', '_').replace('-', '_')
|
|
347
|
+
|
|
348
|
+
# Remove other special characters
|
|
349
|
+
safe_name = ''.join(c if c.isalnum() or c == '_' else '_' for c in safe_name)
|
|
350
|
+
|
|
351
|
+
# Handle empty names
|
|
352
|
+
if not safe_name:
|
|
353
|
+
return '_empty'
|
|
354
|
+
|
|
355
|
+
# Ensure doesn't start with number
|
|
356
|
+
if safe_name[0].isdigit():
|
|
357
|
+
safe_name = f'col_{safe_name}'
|
|
358
|
+
|
|
359
|
+
return safe_name
|
|
360
|
+
|
|
361
|
+
def __repr__(self) -> str:
|
|
362
|
+
"""Display available columns."""
|
|
363
|
+
return f'ColumnAccessor({self._columns})'
|
|
364
|
+
|
|
365
|
+
def __getitem__(self, col_name: str) -> ColumnRef:
|
|
366
|
+
"""
|
|
367
|
+
Access column by original name (dict-style).
|
|
368
|
+
|
|
369
|
+
Useful for columns with names that can't be valid Python identifiers
|
|
370
|
+
or when collisions occur during sanitization.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
col_name: Original column name
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
ColumnRef for the column (for use in formulate())
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
ColumnNotFoundError: If column doesn't exist
|
|
380
|
+
"""
|
|
381
|
+
resolved_name = col_name
|
|
382
|
+
|
|
383
|
+
# Support string lookup for non-string column labels (e.g., 123 -> "123")
|
|
384
|
+
if resolved_name not in self._df.columns and isinstance(resolved_name, str):
|
|
385
|
+
matching = [c for c in self._df.columns if str(c) == resolved_name]
|
|
386
|
+
if len(matching) == 1:
|
|
387
|
+
resolved_name = matching[0]
|
|
388
|
+
|
|
389
|
+
if resolved_name not in self._df.columns:
|
|
390
|
+
available = list(self._df.columns)
|
|
391
|
+
raise ColumnNotFoundError(
|
|
392
|
+
f"Column '{col_name}' not found. Available: {available}", column=col_name, available=available
|
|
393
|
+
)
|
|
394
|
+
return ColumnRef(resolved_name, self._df)
|
|
395
|
+
|
|
396
|
+
def __dir__(self):
|
|
397
|
+
"""Support for tab-completion in IPython/Jupyter."""
|
|
398
|
+
return list(self._attr_to_col.keys())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# formulate() helpers (module-level so they're easy to test in isolation)
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _validate_factors_or_plan_args(
|
|
407
|
+
factors: list | None, plan: dict | None, time: object | None = None
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Enforce the factors XOR plan contract for formulate().
|
|
410
|
+
|
|
411
|
+
Both provided is rejected (ambiguous source of factor structure).
|
|
412
|
+
|
|
413
|
+
Omitting both is fine as long as there is a ``time`` column: that is a
|
|
414
|
+
single measurement stream over time — the most common SPC study there is,
|
|
415
|
+
and an X/mR chart handles it. It used to be refused outright, while the
|
|
416
|
+
equivalent ``factors=[]`` was accepted and worked, so the two spellings of
|
|
417
|
+
"no factors" did opposite things and the refusal named neither.
|
|
418
|
+
|
|
419
|
+
With no factors, no plan *and* no time there is nothing to analyse
|
|
420
|
+
against: no grid, and no order to plot in.
|
|
421
|
+
"""
|
|
422
|
+
if factors is not None and plan is not None:
|
|
423
|
+
raise ValidationError(
|
|
424
|
+
"Cannot specify both 'factors' and 'plan'. Use either:\n"
|
|
425
|
+
' • factors=[...] to infer structure from observed data (complete designs)\n'
|
|
426
|
+
' • plan={col: [levels], ...} to specify expected structure (complete + incomplete designs)'
|
|
427
|
+
)
|
|
428
|
+
if factors is None and plan is None and time is None:
|
|
429
|
+
raise ValidationError(
|
|
430
|
+
'A study needs either a time order or a grouping structure — this has neither.\n\n'
|
|
431
|
+
'Add whichever describes your data:\n'
|
|
432
|
+
" • time='<column>' — a single measurement stream in order (X/mR charts)\n"
|
|
433
|
+
" • factors=['<column>', ...] — categorical variables defining subgroups\n"
|
|
434
|
+
' • plan={column: [levels], ...} — expected factor levels, which also\n'
|
|
435
|
+
' lets incomplete designs be detected\n\n'
|
|
436
|
+
'A single stream is the common case:\n'
|
|
437
|
+
" study = pb.formulate(df, response='weight', time='hour')"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _compute_pds(
|
|
442
|
+
detector: SDSRegistry,
|
|
443
|
+
sampling_plan: dict[str, list] | None,
|
|
444
|
+
T_planned: int | None,
|
|
445
|
+
N_planned: int | None,
|
|
446
|
+
) -> SDSResult | None:
|
|
447
|
+
"""Compute the Plan Design State (PDS) when a sampling plan was supplied.
|
|
448
|
+
|
|
449
|
+
PDS is `None` when no plan exists or any of T/N are missing — in that
|
|
450
|
+
case the study has only an ODS (observed) and ADS (analytical) state.
|
|
451
|
+
"""
|
|
452
|
+
if sampling_plan is None or T_planned is None or N_planned is None:
|
|
453
|
+
return None
|
|
454
|
+
from math import prod
|
|
455
|
+
|
|
456
|
+
K = prod(len(v) for v in sampling_plan.values())
|
|
457
|
+
pds = detector.classify_from_plan(K, T_planned, N_planned)
|
|
458
|
+
logger.debug('PDS: SDS %s (%s)', pds.sds, pds.reason)
|
|
459
|
+
return pds
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class ProcessBehavior:
|
|
463
|
+
"""
|
|
464
|
+
Main entry point for process behavior analysis with auto-completion.
|
|
465
|
+
|
|
466
|
+
This class makes analysis frictionless by:
|
|
467
|
+
1. Providing IDE auto-completion for column names
|
|
468
|
+
2. Auto-detecting the design-state lineage (PDS / ODS / ADS)
|
|
469
|
+
3. Showing valid chart types based on the analytical design state (ADS)
|
|
470
|
+
4. Recommending the best chart for the detected ADS
|
|
471
|
+
5. Two-step workflow: formulate() then execute()
|
|
472
|
+
|
|
473
|
+
Usage:
|
|
474
|
+
# Basic usage with auto-completion
|
|
475
|
+
pb = ProcessBehavior(df)
|
|
476
|
+
|
|
477
|
+
# Step 1: formulate() - analyze structure and get recommendations
|
|
478
|
+
study = pb.formulate(
|
|
479
|
+
response=pb.cols.Measurement,
|
|
480
|
+
time=pb.cols.Time,
|
|
481
|
+
factors=[pb.cols.Operator, pb.cols.Machine]
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Inspect the study
|
|
485
|
+
print(study.observed_design_state.sds) # Detected ODS
|
|
486
|
+
print(study.valid_charts) # What's available
|
|
487
|
+
print(study.recommended_chart) # Best choice
|
|
488
|
+
|
|
489
|
+
# Step 2: execute() - run the chart
|
|
490
|
+
result = study.execute() # Uses recommended chart
|
|
491
|
+
result = study.execute(chart='Xbar') # Or explicit chart
|
|
492
|
+
|
|
493
|
+
Attributes:
|
|
494
|
+
cols: ColumnAccessor for IDE auto-completion of column names
|
|
495
|
+
data: The underlying pandas DataFrame
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
def __init__(self, df: pd.DataFrame, na_values: list[str] | None = None):
|
|
499
|
+
"""
|
|
500
|
+
Initialize ProcessBehavior with data and optional NA value handling.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
df: pandas DataFrame containing process data
|
|
504
|
+
na_values: Additional values to treat as NA/missing (beyond pandas defaults).
|
|
505
|
+
Common garbage characters are handled automatically.
|
|
506
|
+
Examples: ['*', '?', '--', 'ND', 'BDL', '<LOD']
|
|
507
|
+
|
|
508
|
+
Examples:
|
|
509
|
+
# Basic usage - automatic garbage character handling
|
|
510
|
+
>>> pb = ProcessBehavior(df)
|
|
511
|
+
|
|
512
|
+
# Custom NA indicators (combined with defaults)
|
|
513
|
+
>>> pb = ProcessBehavior(df, na_values=['-999', '9999', 'MISSING'])
|
|
514
|
+
"""
|
|
515
|
+
if not isinstance(df, pd.DataFrame):
|
|
516
|
+
raise ValidationError(f'Expected pandas DataFrame, got {type(df).__name__}')
|
|
517
|
+
|
|
518
|
+
# Default garbage characters commonly found in real-world data
|
|
519
|
+
# These are NOT recognized by pandas by default
|
|
520
|
+
default_na = [
|
|
521
|
+
'*', # Common in lab data for missing/invalid
|
|
522
|
+
'?', # Question mark for unknown
|
|
523
|
+
'--', # Double dash for missing
|
|
524
|
+
'ND', # Not Detected
|
|
525
|
+
'BDL', # Below Detection Limit
|
|
526
|
+
'BQL', # Below Quantification Limit
|
|
527
|
+
'<LOD', # Below Limit of Detection
|
|
528
|
+
'>ULQ', # Above Upper Limit of Quantification
|
|
529
|
+
'N/D', # Not Detected (variant)
|
|
530
|
+
'n/d', # Not detected (lowercase)
|
|
531
|
+
'MISSING',
|
|
532
|
+
'missing',
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
# Combine default with user-specified NA values
|
|
536
|
+
all_na_values = default_na + (na_values or [])
|
|
537
|
+
|
|
538
|
+
# Clean the data - replace garbage characters with pd.NA
|
|
539
|
+
cleaned_df = df.copy()
|
|
540
|
+
|
|
541
|
+
# Track which columns had NA values for informative warning
|
|
542
|
+
columns_with_na = []
|
|
543
|
+
na_counts = {}
|
|
544
|
+
|
|
545
|
+
for col in cleaned_df.columns:
|
|
546
|
+
# A garbage token is a string, so only text-like columns can hold one. Scanning
|
|
547
|
+
# numeric/datetime/bool columns is a guaranteed no-op and was ~1.6s of a 3.6s
|
|
548
|
+
# init at 1M x 50. Note the test is "is text-like", not "is not numeric":
|
|
549
|
+
# a CATEGORICAL OF STRINGS can match, and excluding on numeric-ness alone
|
|
550
|
+
# would silently stop cleaning it.
|
|
551
|
+
if not _is_text_like(cleaned_df[col]):
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
# Count how many garbage values we find
|
|
555
|
+
na_mask = cleaned_df[col].isin(all_na_values)
|
|
556
|
+
na_count = na_mask.sum()
|
|
557
|
+
|
|
558
|
+
if na_count > 0:
|
|
559
|
+
columns_with_na.append(col)
|
|
560
|
+
na_counts[col] = na_count
|
|
561
|
+
# Replace with pd.NA
|
|
562
|
+
cleaned_df.loc[na_mask, col] = pd.NA
|
|
563
|
+
|
|
564
|
+
# Try to convert to numeric if it was originally numeric
|
|
565
|
+
# This handles cases like ['235.5', '*', '237.2'] -> [235.5, NaN, 237.2]
|
|
566
|
+
try:
|
|
567
|
+
# Try conversion - if it fails, keep original dtype
|
|
568
|
+
numeric_col = pd.to_numeric(cleaned_df[col])
|
|
569
|
+
cleaned_df[col] = numeric_col
|
|
570
|
+
except (ValueError, TypeError):
|
|
571
|
+
# Keep as-is if conversion fails (likely string data)
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
# Tell the user what we changed on their behalf. This is a warning, not
|
|
575
|
+
# a log record: the library configures no logging handler, so a
|
|
576
|
+
# logger.warning here was invisible in most sessions and could not be
|
|
577
|
+
# filtered or escalated by callers.
|
|
578
|
+
if columns_with_na:
|
|
579
|
+
total_na = sum(na_counts.values())
|
|
580
|
+
warnings.warn(
|
|
581
|
+
f'Found {total_na} garbage/NA values across {len(columns_with_na)} column(s):\n'
|
|
582
|
+
+ '\n'.join([f' • {col}: {count} values' for col, count in na_counts.items()])
|
|
583
|
+
+ '\n\nThese values were converted to NA and will be excluded from analysis.',
|
|
584
|
+
ProcessBehaviorWarning,
|
|
585
|
+
stacklevel=3,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# Phase 2: Clean numeric formatting (currency symbols, thousands
|
|
589
|
+
# separators, accounting negatives, percentages) in object columns
|
|
590
|
+
formatting_cleaned = {}
|
|
591
|
+
for col in cleaned_df.columns:
|
|
592
|
+
result = _clean_numeric_strings_via_uniques(cleaned_df[col])
|
|
593
|
+
if result is not None:
|
|
594
|
+
formatting_cleaned[col] = int(result.notna().sum())
|
|
595
|
+
cleaned_df[col] = result
|
|
596
|
+
|
|
597
|
+
if formatting_cleaned:
|
|
598
|
+
warnings.warn(
|
|
599
|
+
f'Cleaned numeric formatting in {len(formatting_cleaned)} column(s):\n'
|
|
600
|
+
+ '\n'.join([f' • {col}: {count} values converted' for col, count in formatting_cleaned.items()])
|
|
601
|
+
+ '\n\nCurrency symbols, thousands separators, and '
|
|
602
|
+
'accounting negatives were removed.',
|
|
603
|
+
ProcessBehaviorWarning,
|
|
604
|
+
stacklevel=3,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
self.data = cleaned_df
|
|
608
|
+
self.cols = ColumnAccessor(self.data)
|
|
609
|
+
# Pending derived-variable specs, accumulated by the fluent verbs.
|
|
610
|
+
# Immutable: each verb returns a new ProcessBehavior with an extended
|
|
611
|
+
# tuple. Materialized into the analytic dataset at formulate().
|
|
612
|
+
self._derivations: tuple[Derivation, ...] = ()
|
|
613
|
+
|
|
614
|
+
logger.info(f'ProcessBehavior: {len(df)} rows, {len(df.columns)} columns')
|
|
615
|
+
|
|
616
|
+
# =========================================================================
|
|
617
|
+
# Factory Methods: Read from files
|
|
618
|
+
# =========================================================================
|
|
619
|
+
|
|
620
|
+
@classmethod
|
|
621
|
+
def read_csv(cls, path: str, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
|
|
622
|
+
"""
|
|
623
|
+
Read data from a CSV file.
|
|
624
|
+
|
|
625
|
+
Parameters
|
|
626
|
+
----------
|
|
627
|
+
path : str
|
|
628
|
+
Path to the CSV file.
|
|
629
|
+
na_values : list of str, optional
|
|
630
|
+
Additional values to treat as NA/missing.
|
|
631
|
+
**kwargs
|
|
632
|
+
Additional arguments passed to pandas.read_csv().
|
|
633
|
+
|
|
634
|
+
Returns
|
|
635
|
+
-------
|
|
636
|
+
ProcessBehavior
|
|
637
|
+
ProcessBehavior instance with loaded data.
|
|
638
|
+
|
|
639
|
+
Examples
|
|
640
|
+
--------
|
|
641
|
+
>>> pb = ProcessBehavior.read_csv('fillweight_data.csv')
|
|
642
|
+
>>> pb = ProcessBehavior.read_csv('data.csv', encoding='latin-1')
|
|
643
|
+
"""
|
|
644
|
+
df = pd.read_csv(path, **kwargs)
|
|
645
|
+
return cls(df, na_values=na_values)
|
|
646
|
+
|
|
647
|
+
@classmethod
|
|
648
|
+
def read_excel(
|
|
649
|
+
cls, path: str, sheet_name: str | int = 0, na_values: list[str] | None = None, **kwargs
|
|
650
|
+
) -> ProcessBehavior:
|
|
651
|
+
"""
|
|
652
|
+
Read data from an Excel file.
|
|
653
|
+
|
|
654
|
+
Parameters
|
|
655
|
+
----------
|
|
656
|
+
path : str
|
|
657
|
+
Path to the Excel file (.xlsx, .xls).
|
|
658
|
+
sheet_name : str or int, default 0
|
|
659
|
+
Sheet name or index to read.
|
|
660
|
+
na_values : list of str, optional
|
|
661
|
+
Additional values to treat as NA/missing.
|
|
662
|
+
**kwargs
|
|
663
|
+
Additional arguments passed to pandas.read_excel().
|
|
664
|
+
|
|
665
|
+
Returns
|
|
666
|
+
-------
|
|
667
|
+
ProcessBehavior
|
|
668
|
+
ProcessBehavior instance with loaded data.
|
|
669
|
+
|
|
670
|
+
Examples
|
|
671
|
+
--------
|
|
672
|
+
>>> pb = ProcessBehavior.read_excel('data.xlsx')
|
|
673
|
+
>>> pb = ProcessBehavior.read_excel('data.xlsx', sheet_name='Sheet2')
|
|
674
|
+
"""
|
|
675
|
+
df = pd.read_excel(path, sheet_name=sheet_name, **kwargs)
|
|
676
|
+
return cls(df, na_values=na_values)
|
|
677
|
+
|
|
678
|
+
@classmethod
|
|
679
|
+
def read_parquet(cls, path: str, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
|
|
680
|
+
"""
|
|
681
|
+
Read data from a Parquet file.
|
|
682
|
+
|
|
683
|
+
Parameters
|
|
684
|
+
----------
|
|
685
|
+
path : str
|
|
686
|
+
Path to the Parquet file.
|
|
687
|
+
na_values : list of str, optional
|
|
688
|
+
Additional values to treat as NA/missing.
|
|
689
|
+
**kwargs
|
|
690
|
+
Additional arguments passed to pandas.read_parquet().
|
|
691
|
+
|
|
692
|
+
Returns
|
|
693
|
+
-------
|
|
694
|
+
ProcessBehavior
|
|
695
|
+
ProcessBehavior instance with loaded data.
|
|
696
|
+
|
|
697
|
+
Examples
|
|
698
|
+
--------
|
|
699
|
+
>>> pb = ProcessBehavior.read_parquet('data.parquet')
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
df = pd.read_parquet(path, **kwargs)
|
|
703
|
+
except ImportError:
|
|
704
|
+
raise ImportError(
|
|
705
|
+
'Reading Parquet files requires pyarrow or fastparquet. Install with: pip install pyarrow'
|
|
706
|
+
) from None
|
|
707
|
+
return cls(df, na_values=na_values)
|
|
708
|
+
|
|
709
|
+
@classmethod
|
|
710
|
+
def read_clipboard(cls, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
|
|
711
|
+
"""
|
|
712
|
+
Read data from the system clipboard.
|
|
713
|
+
|
|
714
|
+
Useful for quickly pasting data from Excel or Google Sheets.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
na_values : list of str, optional
|
|
719
|
+
Additional values to treat as NA/missing.
|
|
720
|
+
**kwargs
|
|
721
|
+
Additional arguments passed to pandas.read_clipboard().
|
|
722
|
+
|
|
723
|
+
Returns
|
|
724
|
+
-------
|
|
725
|
+
ProcessBehavior
|
|
726
|
+
ProcessBehavior instance with clipboard data.
|
|
727
|
+
|
|
728
|
+
Examples
|
|
729
|
+
--------
|
|
730
|
+
Copy data from Excel, then:
|
|
731
|
+
|
|
732
|
+
>>> pb = ProcessBehavior.read_clipboard()
|
|
733
|
+
"""
|
|
734
|
+
df = pd.read_clipboard(**kwargs)
|
|
735
|
+
return cls(df, na_values=na_values)
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _to_column_name(col: str | ColumnRef) -> str:
|
|
739
|
+
"""Extract column name from str or ColumnRef."""
|
|
740
|
+
return col.name if isinstance(col, ColumnRef) else col
|
|
741
|
+
|
|
742
|
+
def _validate_plan(self, plan: dict) -> tuple[dict[str, list], list[str], int, int]:
|
|
743
|
+
"""
|
|
744
|
+
Validate and normalize sampling plan.
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
plan : dict
|
|
749
|
+
Sampling plan with required 'factors' key and optional 'T', 'N'.
|
|
750
|
+
Example: {'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}
|
|
751
|
+
|
|
752
|
+
Returns
|
|
753
|
+
-------
|
|
754
|
+
tuple[dict[str, list], list[str], int | None, int | None]
|
|
755
|
+
(normalized_factors, factor_order, T_planned, N_planned)
|
|
756
|
+
|
|
757
|
+
Raises
|
|
758
|
+
------
|
|
759
|
+
ValidationError
|
|
760
|
+
If 'factors' key is missing
|
|
761
|
+
ColumnNotFoundError
|
|
762
|
+
If a plan column doesn't exist in the data
|
|
763
|
+
"""
|
|
764
|
+
# Require 'factors' key
|
|
765
|
+
if 'factors' not in plan:
|
|
766
|
+
raise ValidationError(
|
|
767
|
+
"Sampling plan must have 'factors' key.\n"
|
|
768
|
+
"Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
plan_factors = plan['factors']
|
|
772
|
+
|
|
773
|
+
# Require at least one factor
|
|
774
|
+
if not plan_factors:
|
|
775
|
+
raise ValidationError(
|
|
776
|
+
"Sampling plan 'factors' must contain at least one factor.\n"
|
|
777
|
+
"Example: plan={'factors': {'Lane': [1,2,3,4]}, 'T': 10}"
|
|
778
|
+
)
|
|
779
|
+
T_planned = plan.get('T')
|
|
780
|
+
N_planned = plan.get('N')
|
|
781
|
+
|
|
782
|
+
if T_planned is None:
|
|
783
|
+
raise ValidationError(
|
|
784
|
+
"Sampling plan must include 'T' (planned number of time periods).\n"
|
|
785
|
+
"Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
|
|
786
|
+
)
|
|
787
|
+
if N_planned is None:
|
|
788
|
+
raise ValidationError(
|
|
789
|
+
"Sampling plan must include 'N' (planned observations per cell).\n"
|
|
790
|
+
"Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
normalized: dict[str, list] = {}
|
|
794
|
+
factor_order: list[str] = []
|
|
795
|
+
|
|
796
|
+
for col, levels in plan_factors.items():
|
|
797
|
+
col_name = self._to_column_name(col)
|
|
798
|
+
|
|
799
|
+
# Validate column exists
|
|
800
|
+
if col_name not in self.data.columns:
|
|
801
|
+
available = list(self.data.columns)
|
|
802
|
+
raise ColumnNotFoundError(
|
|
803
|
+
f"Plan column '{col_name}' not found in data. Available: {available}",
|
|
804
|
+
column=col_name,
|
|
805
|
+
available=available,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# Validate levels is non-empty
|
|
809
|
+
if not levels:
|
|
810
|
+
raise ValidationError(
|
|
811
|
+
f"Factor '{col_name}' has empty level list in plan.\n"
|
|
812
|
+
f'Each factor must have at least one planned level.'
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
normalized[col_name] = list(levels)
|
|
816
|
+
factor_order.append(col_name)
|
|
817
|
+
|
|
818
|
+
# Check for extra observed levels (warn, don't error)
|
|
819
|
+
observed = set(self.data[col_name].dropna().unique())
|
|
820
|
+
planned = set(levels)
|
|
821
|
+
extra = observed - planned
|
|
822
|
+
|
|
823
|
+
if extra:
|
|
824
|
+
extra_list = sorted(extra, key=lambda x: (type(x).__name__, x))
|
|
825
|
+
observed_sorted = sorted(observed, key=lambda x: (type(x).__name__, x))
|
|
826
|
+
logger.warning(
|
|
827
|
+
f"Factor '{col_name}' has observed levels not in plan: {extra_list}\n"
|
|
828
|
+
f' Your plan: {levels}\n'
|
|
829
|
+
f' Observed: {observed_sorted}\n'
|
|
830
|
+
f'\n'
|
|
831
|
+
f' To update your plan:\n'
|
|
832
|
+
f" plan['factors']['{col_name}'] = pb.cols['{col_name}'].levels # Use observed\n"
|
|
833
|
+
f' # or\n'
|
|
834
|
+
f" plan['factors']['{col_name}'] = {observed_sorted} # Add manually"
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
return normalized, factor_order, T_planned, N_planned
|
|
838
|
+
|
|
839
|
+
# =========================================================================
|
|
840
|
+
# Derived variables (transforms + binning) — fluent, immutable verbs
|
|
841
|
+
# =========================================================================
|
|
842
|
+
|
|
843
|
+
@property
|
|
844
|
+
def derivations(self) -> tuple[Derivation, ...]:
|
|
845
|
+
"""Pending derived-variable specs attached to this ProcessBehavior."""
|
|
846
|
+
return self._derivations
|
|
847
|
+
|
|
848
|
+
def _with_derivations(self, specs: tuple[Derivation, ...]) -> ProcessBehavior:
|
|
849
|
+
"""Return a new ProcessBehavior sharing ``self.data`` with ``specs`` attached."""
|
|
850
|
+
new = ProcessBehavior.__new__(ProcessBehavior)
|
|
851
|
+
new.data = self.data
|
|
852
|
+
new.cols = self.cols
|
|
853
|
+
new._derivations = specs
|
|
854
|
+
return new
|
|
855
|
+
|
|
856
|
+
def _attach(self, spec: Derivation) -> ProcessBehavior:
|
|
857
|
+
"""Validate a spec against the current dataset context and attach it.
|
|
858
|
+
|
|
859
|
+
Raises ``ValidationError`` at attach time for column-not-found / non-numeric
|
|
860
|
+
source / output-name collision (the fluent path's commit point).
|
|
861
|
+
"""
|
|
862
|
+
existing = {d.output_name for d in self._derivations}
|
|
863
|
+
result = _validate_derivation(spec, self.data, existing_names=existing)
|
|
864
|
+
if not result.ok:
|
|
865
|
+
raise ValidationError(
|
|
866
|
+
f'Cannot add derived variable {spec.output_name!r}: {result.summary()}'
|
|
867
|
+
)
|
|
868
|
+
return self._with_derivations((*self._derivations, spec))
|
|
869
|
+
|
|
870
|
+
def transform(
|
|
871
|
+
self,
|
|
872
|
+
column: str | ColumnRef,
|
|
873
|
+
function: str,
|
|
874
|
+
*,
|
|
875
|
+
label: str | None = None,
|
|
876
|
+
shift: float | None = None,
|
|
877
|
+
exponent: float | None = None,
|
|
878
|
+
on_invalid: str = 'error',
|
|
879
|
+
) -> ProcessBehavior:
|
|
880
|
+
"""Attach a continuous→continuous transform; returns a new ProcessBehavior.
|
|
881
|
+
|
|
882
|
+
``function`` is one of ``log`` (``ln`` alias), ``log10``, ``sqrt``,
|
|
883
|
+
``arcsin`` (= ``arcsin(√x)``, x a proportion in ``[0, 1]``), ``inverse``,
|
|
884
|
+
``square``, ``power`` (needs ``exponent``), ``zscore``. The new column is
|
|
885
|
+
named ``label`` or ``{column}_{function}``. Domain violations are resolved
|
|
886
|
+
at formulate() per ``on_invalid`` (``'error'`` raises with a structured
|
|
887
|
+
count; ``'na'`` sets offenders missing); ``shift`` applies ``f(x + shift)``.
|
|
888
|
+
"""
|
|
889
|
+
spec = Derivation.transform(
|
|
890
|
+
self._to_column_name(column), function,
|
|
891
|
+
label=label, shift=shift, exponent=exponent, on_invalid=on_invalid,
|
|
892
|
+
)
|
|
893
|
+
return self._attach(spec)
|
|
894
|
+
|
|
895
|
+
def bin(
|
|
896
|
+
self,
|
|
897
|
+
column: str | ColumnRef,
|
|
898
|
+
*,
|
|
899
|
+
method: str = 'equal_freq',
|
|
900
|
+
n: int = 4,
|
|
901
|
+
breaks: list[float] | None = None,
|
|
902
|
+
bin_labels='range',
|
|
903
|
+
label: str | None = None,
|
|
904
|
+
right: bool = False,
|
|
905
|
+
) -> ProcessBehavior:
|
|
906
|
+
"""Attach a continuous→categorical binning; returns a new ProcessBehavior.
|
|
907
|
+
|
|
908
|
+
``method`` is ``equal_freq`` (default, n quantile bins), ``equal_width``,
|
|
909
|
+
``breaks`` (explicit cut points, out-of-range falls into below/above
|
|
910
|
+
categories), or ``sd`` (±1σ, ±2σ about the mean). The new column is an
|
|
911
|
+
ordered categorical named ``label`` or ``{column}_bin``. ``bin_labels`` is
|
|
912
|
+
``'range'`` (default, from fitted edges), ``'ordinal'``, ``'number'``, or
|
|
913
|
+
an explicit list. Intervals are left-closed ``[a, b)`` unless ``right=True``.
|
|
914
|
+
"""
|
|
915
|
+
spec = Derivation.bin(
|
|
916
|
+
self._to_column_name(column),
|
|
917
|
+
method=method, n=n, breaks=breaks, bin_labels=bin_labels, label=label, right=right,
|
|
918
|
+
)
|
|
919
|
+
return self._attach(spec)
|
|
920
|
+
|
|
921
|
+
def add_derived(self, *specs: Derivation) -> ProcessBehavior:
|
|
922
|
+
"""Attach one or more pre-built :class:`Derivation` specs (programmatic / app path)."""
|
|
923
|
+
pb = self
|
|
924
|
+
for spec in specs:
|
|
925
|
+
pb = pb._attach(spec)
|
|
926
|
+
return pb
|
|
927
|
+
|
|
928
|
+
def remove_derived(self, id: str) -> ProcessBehavior:
|
|
929
|
+
"""Return a new ProcessBehavior with the derivation ``id`` dropped."""
|
|
930
|
+
kept = tuple(d for d in self._derivations if d.id != id)
|
|
931
|
+
if len(kept) == len(self._derivations):
|
|
932
|
+
available = [d.id for d in self._derivations]
|
|
933
|
+
raise ValidationError(f'No derivation with id {id!r}. Attached ids: {available}.')
|
|
934
|
+
return self._with_derivations(kept)
|
|
935
|
+
|
|
936
|
+
def replace_derived(self, id: str, spec: Derivation) -> ProcessBehavior:
|
|
937
|
+
"""Return a new ProcessBehavior with derivation ``id`` swapped for ``spec`` (keeps position)."""
|
|
938
|
+
if id not in {d.id for d in self._derivations}:
|
|
939
|
+
available = [d.id for d in self._derivations]
|
|
940
|
+
raise ValidationError(f'No derivation with id {id!r}. Attached ids: {available}.')
|
|
941
|
+
# Validate the replacement against the dataset + the *other* pending names.
|
|
942
|
+
others = {d.output_name for d in self._derivations if d.id != id}
|
|
943
|
+
result = _validate_derivation(spec, self.data, existing_names=others)
|
|
944
|
+
if not result.ok:
|
|
945
|
+
raise ValidationError(
|
|
946
|
+
f'Cannot replace derivation {id!r} with {spec.output_name!r}: {result.summary()}'
|
|
947
|
+
)
|
|
948
|
+
swapped = tuple(spec if d.id == id else d for d in self._derivations)
|
|
949
|
+
return self._with_derivations(swapped)
|
|
950
|
+
|
|
951
|
+
def _materialize_derivations(self) -> tuple[pd.DataFrame, tuple[Derivation, ...]]:
|
|
952
|
+
"""Resolve pending specs into columns on a copy of the data (the augmented frame).
|
|
953
|
+
|
|
954
|
+
Evaluates each spec in attach order against the **original** columns
|
|
955
|
+
(chaining is deferred to v2), adds each ``output_name`` column, and returns
|
|
956
|
+
the augmented frame plus the fit-frozen specs. With no pending specs this
|
|
957
|
+
returns ``self.data`` untouched (the no-derivation path is identity).
|
|
958
|
+
``on_invalid='error'`` with domain violations raises a structured
|
|
959
|
+
``ValidationError`` here — the only commit-time raise.
|
|
960
|
+
"""
|
|
961
|
+
if not self._derivations:
|
|
962
|
+
return self.data, ()
|
|
963
|
+
|
|
964
|
+
frame = self.data.copy()
|
|
965
|
+
resolved: list[Derivation] = []
|
|
966
|
+
for spec in self._derivations:
|
|
967
|
+
res = _evaluate_derivation(spec, self.data[spec.column])
|
|
968
|
+
if (
|
|
969
|
+
spec.family == 'transform'
|
|
970
|
+
and spec.params.get('on_invalid', 'error') == 'error'
|
|
971
|
+
and res.n_invalid > 0
|
|
972
|
+
):
|
|
973
|
+
sample = list(res.invalid_index[:10])
|
|
974
|
+
raise ValidationError(
|
|
975
|
+
f"Derived variable '{spec.output_name}' ({spec.function} of "
|
|
976
|
+
f"'{spec.column}') has {res.n_invalid} domain violation(s) "
|
|
977
|
+
f'at rows {sample}{"..." if res.n_invalid > 10 else ""}. '
|
|
978
|
+
f"Resolve with shift=, on_invalid='na', or a different transform."
|
|
979
|
+
)
|
|
980
|
+
frame[spec.output_name] = res.values
|
|
981
|
+
resolved.append(spec.with_fitted(res.fitted))
|
|
982
|
+
return frame, tuple(resolved)
|
|
983
|
+
|
|
984
|
+
def formulate(
|
|
985
|
+
self,
|
|
986
|
+
response: str | ColumnRef,
|
|
987
|
+
factors: list[str | ColumnRef] | None = None,
|
|
988
|
+
time: str | ColumnRef | None = None,
|
|
989
|
+
plan: dict | None = None,
|
|
990
|
+
precision: int = 3,
|
|
991
|
+
unit_of_analysis: str | None = None,
|
|
992
|
+
) -> Study:
|
|
993
|
+
"""
|
|
994
|
+
Formulate a study for process behavior analysis.
|
|
995
|
+
|
|
996
|
+
This method prepares and enriches the dataset (including residuals and
|
|
997
|
+
effects as applicable) and returns a Study describing what analyses are
|
|
998
|
+
valid. Call study.execute() to perform chart-specific calculations and
|
|
999
|
+
produce an AnalysisResult.
|
|
1000
|
+
|
|
1001
|
+
Parameters
|
|
1002
|
+
----------
|
|
1003
|
+
response : str or ColumnRef
|
|
1004
|
+
The response variable (measurement) to analyze.
|
|
1005
|
+
Use pb.cols for IDE auto-completion.
|
|
1006
|
+
factors : list of str or ColumnRef, optional
|
|
1007
|
+
Grouping factors defining rational subgroups (e.g., ['Lane', 'Operator']).
|
|
1008
|
+
If provided, enables Xbar/S analysis. Cannot be used with `plan`.
|
|
1009
|
+
time : str or ColumnRef, optional
|
|
1010
|
+
Time/sequence variable for ordering observations.
|
|
1011
|
+
plan : dict, optional
|
|
1012
|
+
Sampling plan specifying expected factor levels. Must contain a
|
|
1013
|
+
``'factors'`` key mapping column names (or ColumnRefs) to lists of
|
|
1014
|
+
expected levels. Optionally include ``'T'`` (planned time points)
|
|
1015
|
+
and ``'N'`` (planned observations per cell). Enables ODS 4-6
|
|
1016
|
+
detection by comparing observed structure to planned structure.
|
|
1017
|
+
Cannot be used with ``factors``.
|
|
1018
|
+
|
|
1019
|
+
Example::
|
|
1020
|
+
|
|
1021
|
+
plan={
|
|
1022
|
+
'factors': {pb.cols.Lane: [1,2,3,4], pb.cols.Phase: [1,2,3]},
|
|
1023
|
+
'T': 10,
|
|
1024
|
+
'N': 2
|
|
1025
|
+
}
|
|
1026
|
+
precision : int, default 3
|
|
1027
|
+
Decimal places for output values.
|
|
1028
|
+
unit_of_analysis : str, optional
|
|
1029
|
+
The fundamental entity being measured. For example, in a manufacturing
|
|
1030
|
+
process producing cups filled with yogurt, the unit of analysis is
|
|
1031
|
+
'filled cup'. In a loan collection process, it would be 'loan contract'.
|
|
1032
|
+
This is informational metadata and does not affect calculations.
|
|
1033
|
+
|
|
1034
|
+
Returns
|
|
1035
|
+
-------
|
|
1036
|
+
Study
|
|
1037
|
+
A Study object with:
|
|
1038
|
+
- PDS / ODS / ADS design-state lineage
|
|
1039
|
+
- Valid and recommended chart types (routed by ADS)
|
|
1040
|
+
- Pre-calculated residuals and effects (via study.dataset)
|
|
1041
|
+
- study.execute() to run chart-specific analysis
|
|
1042
|
+
- study.design() to compare plan vs observed (when plan provided)
|
|
1043
|
+
|
|
1044
|
+
Examples
|
|
1045
|
+
--------
|
|
1046
|
+
Basic formulation (infer factors from data):
|
|
1047
|
+
|
|
1048
|
+
>>> pb = ProcessBehavior(df)
|
|
1049
|
+
>>> study = pb.formulate(response='weight')
|
|
1050
|
+
>>> print(study) # Shows PDS / ODS / ADS, valid charts, next steps
|
|
1051
|
+
|
|
1052
|
+
With factors (complete designs, ADS 1-3):
|
|
1053
|
+
|
|
1054
|
+
>>> study = pb.formulate(
|
|
1055
|
+
... response='fill_weight',
|
|
1056
|
+
... factors=['lane', 'phase'],
|
|
1057
|
+
... time='pull'
|
|
1058
|
+
... )
|
|
1059
|
+
|
|
1060
|
+
With sampling plan (enables SDS 4-6):
|
|
1061
|
+
|
|
1062
|
+
>>> study = pb.formulate(
|
|
1063
|
+
... response=pb.cols.Weight,
|
|
1064
|
+
... time=pb.cols.Pull,
|
|
1065
|
+
... plan={
|
|
1066
|
+
... 'factors': {
|
|
1067
|
+
... pb.cols.Lane: [1, 2, 3, 4],
|
|
1068
|
+
... pb.cols.Phase: [1, 2, 3] # Even if Phase 3 not in data
|
|
1069
|
+
... },
|
|
1070
|
+
... 'T': 10
|
|
1071
|
+
... }
|
|
1072
|
+
... )
|
|
1073
|
+
>>> study.design() # Shows planned vs observed structure
|
|
1074
|
+
|
|
1075
|
+
Notes
|
|
1076
|
+
-----
|
|
1077
|
+
**Implicit Time Ordering**
|
|
1078
|
+
|
|
1079
|
+
When no ``time`` parameter is specified, the system treats observation
|
|
1080
|
+
order as implicit time. This design decision is intentional:
|
|
1081
|
+
|
|
1082
|
+
1. Wheeler's XmR chart fundamentally assumes temporal ordering - moving
|
|
1083
|
+
ranges between consecutive observations only make sense in sequence.
|
|
1084
|
+
|
|
1085
|
+
2. The ``obs_id`` column (assigned during data preparation) serves as
|
|
1086
|
+
the implicit time dimension, preserving the order in which data was
|
|
1087
|
+
provided.
|
|
1088
|
+
|
|
1089
|
+
If your observations are NOT in temporal order, you MUST specify the
|
|
1090
|
+
``time`` parameter to ensure correct analysis.
|
|
1091
|
+
|
|
1092
|
+
**Factors are optional; structure is not**
|
|
1093
|
+
|
|
1094
|
+
Supply a grouping structure (``factors`` or ``plan``), a ``time``
|
|
1095
|
+
column, or both. Bishop's design-state classification works over a
|
|
1096
|
+
factor × time grid, and a single measurement stream in time order is
|
|
1097
|
+
the degenerate case of one — charted with X/mR::
|
|
1098
|
+
|
|
1099
|
+
study = pb.formulate(df, response='weight', time='hour')
|
|
1100
|
+
|
|
1101
|
+
Only a study with no factors, no plan *and* no time is refused: there
|
|
1102
|
+
is no grid to classify and no order to plot in.
|
|
1103
|
+
|
|
1104
|
+
**SDS Detection**
|
|
1105
|
+
|
|
1106
|
+
SDS detection runs on raw data (before NA rows are dropped) to preserve
|
|
1107
|
+
attempted-but-invalid cells. This ensures cells with all-NA responses
|
|
1108
|
+
are counted as empty (Nₖₜ=0) rather than vanishing from structure analysis.
|
|
1109
|
+
|
|
1110
|
+
See Also
|
|
1111
|
+
--------
|
|
1112
|
+
Study : The returned Study object
|
|
1113
|
+
ColumnRef : Column reference with .levels property for discoverability
|
|
1114
|
+
"""
|
|
1115
|
+
# 1. Validate the factors/plan combination + presence.
|
|
1116
|
+
_validate_factors_or_plan_args(factors, plan, time)
|
|
1117
|
+
|
|
1118
|
+
# 2. Normalize inputs into (response_str, time_str, factors_str,
|
|
1119
|
+
# sampling_plan, factor_order, T_planned, N_planned).
|
|
1120
|
+
response_str = self._to_column_name(response)
|
|
1121
|
+
time_str = self._to_column_name(time) if time is not None else None
|
|
1122
|
+
factors_str, sampling_plan, factor_order, T_planned, N_planned = (
|
|
1123
|
+
self._resolve_factors_and_plan(factors, plan)
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
# 3. Build the FormulationSpec.
|
|
1127
|
+
spec = FormulationSpec(
|
|
1128
|
+
response_var=response_str,
|
|
1129
|
+
rsg_vars=tuple(factors_str) if factors_str else None,
|
|
1130
|
+
time_var=time_str,
|
|
1131
|
+
round_to=precision,
|
|
1132
|
+
unit_of_analysis=unit_of_analysis,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
# 4. Materialize pending derived variables ONCE into the augmented frame,
|
|
1136
|
+
# before any frame consumer runs. _formulate_from_frame reads this single
|
|
1137
|
+
# frame at all three sites (column validation, ODS detection, ADS) and
|
|
1138
|
+
# never self.data — so a derived factor is seen consistently and the
|
|
1139
|
+
# materialize-once ordering cannot drift in a later refactor.
|
|
1140
|
+
frame, resolved_derivations = self._materialize_derivations()
|
|
1141
|
+
return self._formulate_from_frame(
|
|
1142
|
+
frame,
|
|
1143
|
+
spec,
|
|
1144
|
+
resolved_derivations,
|
|
1145
|
+
sampling_plan=sampling_plan,
|
|
1146
|
+
factor_order=factor_order,
|
|
1147
|
+
T_planned=T_planned,
|
|
1148
|
+
N_planned=N_planned,
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
def _formulate_from_frame(
|
|
1152
|
+
self,
|
|
1153
|
+
frame: pd.DataFrame,
|
|
1154
|
+
spec: FormulationSpec,
|
|
1155
|
+
derivations: tuple[Derivation, ...],
|
|
1156
|
+
*,
|
|
1157
|
+
sampling_plan,
|
|
1158
|
+
factor_order,
|
|
1159
|
+
T_planned,
|
|
1160
|
+
N_planned,
|
|
1161
|
+
) -> Study:
|
|
1162
|
+
"""Run column validation → ODS detection → ADS, all on the single ``frame``.
|
|
1163
|
+
|
|
1164
|
+
Consumes ``frame`` exclusively (never ``self.data``), so the augmented frame
|
|
1165
|
+
flows identically to every site. That is what makes the materialize-once
|
|
1166
|
+
ordering self-enforcing rather than convention: there is no raw frame in
|
|
1167
|
+
scope here to drift to, and no gap between sites to insert one into.
|
|
1168
|
+
"""
|
|
1169
|
+
from .analysis_dataset import AnalysisDataSet
|
|
1170
|
+
from .data_preparation import DataPreparation
|
|
1171
|
+
from .study import Study
|
|
1172
|
+
|
|
1173
|
+
# Validate columns (fail fast).
|
|
1174
|
+
DataPreparation().validate_columns(frame, spec)
|
|
1175
|
+
|
|
1176
|
+
# ODS detection on the (augmented) frame — NA response rows preserved so
|
|
1177
|
+
# cells with all-NA responses still count as attempted (Bishop's approach).
|
|
1178
|
+
detector = SDSRegistry()
|
|
1179
|
+
sds_result = detector.detect_sds_from_structure(
|
|
1180
|
+
frame,
|
|
1181
|
+
spec,
|
|
1182
|
+
response_col=spec.response_var,
|
|
1183
|
+
plan=sampling_plan,
|
|
1184
|
+
T_planned=T_planned,
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
# Plan Design State (PDS) — only when a plan was supplied.
|
|
1188
|
+
pds_result = _compute_pds(detector, sampling_plan, T_planned, N_planned)
|
|
1189
|
+
|
|
1190
|
+
# Full AnalysisDataSet (residuals R1-R5, RCR1-RCR5) — ADS post-cleansing.
|
|
1191
|
+
ads = AnalysisDataSet(frame, spec, observed_sds=sds_result.sds)
|
|
1192
|
+
|
|
1193
|
+
# ADS drives analysis: build analysis_plan from ADS, not ODS.
|
|
1194
|
+
analysis_plan = SDSRegistry.get_analysis_plan(
|
|
1195
|
+
ads.analytical_design_state.sds,
|
|
1196
|
+
min_cell_size=ads.analytical_design_state.min_cell_size,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
return Study(
|
|
1200
|
+
_pdf=self,
|
|
1201
|
+
_spec=spec,
|
|
1202
|
+
_plan=analysis_plan,
|
|
1203
|
+
_ads=ads,
|
|
1204
|
+
_sampling_plan=sampling_plan,
|
|
1205
|
+
_factor_order=factor_order,
|
|
1206
|
+
_T=T_planned,
|
|
1207
|
+
_N=N_planned,
|
|
1208
|
+
_sds_result=sds_result,
|
|
1209
|
+
_pds_result=pds_result,
|
|
1210
|
+
derivations=derivations,
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
def _resolve_factors_and_plan(
|
|
1214
|
+
self,
|
|
1215
|
+
factors: list[str | ColumnRef] | None,
|
|
1216
|
+
plan: dict | None,
|
|
1217
|
+
) -> tuple[list[str] | None, dict[str, list] | None, list[str] | None, int | None, int | None]:
|
|
1218
|
+
"""Normalize factors/plan inputs to the tuple formulate() consumes.
|
|
1219
|
+
|
|
1220
|
+
Exactly one of `factors` or `plan` is non-None (enforced by the
|
|
1221
|
+
caller). Returns `(factors_str, sampling_plan, factor_order,
|
|
1222
|
+
T_planned, N_planned)`.
|
|
1223
|
+
|
|
1224
|
+
- `plan` path: delegates to `_validate_plan` for normalization and
|
|
1225
|
+
extracts T_planned/N_planned.
|
|
1226
|
+
- `factors` path: simple ColumnRef → str normalization; the
|
|
1227
|
+
sampling-plan slots stay None.
|
|
1228
|
+
"""
|
|
1229
|
+
if plan is not None:
|
|
1230
|
+
sampling_plan, factor_order, T_planned, N_planned = self._validate_plan(plan)
|
|
1231
|
+
return factor_order, sampling_plan, factor_order, T_planned, N_planned
|
|
1232
|
+
factors_str = [self._to_column_name(f) for f in factors] if factors else None
|
|
1233
|
+
return factors_str, None, None, None, None
|
|
1234
|
+
|
|
1235
|
+
def __repr__(self) -> str:
|
|
1236
|
+
"""String representation."""
|
|
1237
|
+
return (
|
|
1238
|
+
f'ProcessBehavior({len(self.data)} rows × {len(self.data.columns)} columns)\n'
|
|
1239
|
+
f'Columns: {list(self.data.columns)}'
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
def __len__(self) -> int:
|
|
1243
|
+
"""Return number of rows."""
|
|
1244
|
+
return len(self.data)
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def formulate(
|
|
1248
|
+
data,
|
|
1249
|
+
response: str | ColumnRef,
|
|
1250
|
+
factors: list[str | ColumnRef] | None = None,
|
|
1251
|
+
time: str | ColumnRef | None = None,
|
|
1252
|
+
*,
|
|
1253
|
+
plan: dict | None = None,
|
|
1254
|
+
precision: int = 3,
|
|
1255
|
+
unit_of_analysis: str | None = None,
|
|
1256
|
+
) -> Study:
|
|
1257
|
+
"""Formulate a study directly from a DataFrame — the one-call entry point.
|
|
1258
|
+
|
|
1259
|
+
Equivalent to ``ProcessBehavior(data).formulate(...)``, which is what most analyses
|
|
1260
|
+
want: the intermediate object exists to carry derived-variable specs, and a study that
|
|
1261
|
+
declares none has no use for it.
|
|
1262
|
+
|
|
1263
|
+
>>> study = pb.formulate(df, response='weight', factors=['machine'], time='shift')
|
|
1264
|
+
>>> study.execute().plot()
|
|
1265
|
+
|
|
1266
|
+
Reach for :class:`ProcessBehavior` when you need the fluent derivation verbs, since
|
|
1267
|
+
those must be attached before formulating::
|
|
1268
|
+
|
|
1269
|
+
study = ProcessBehavior(df).transform('torque', 'log').formulate(response='torque_log')
|
|
1270
|
+
|
|
1271
|
+
Parameters
|
|
1272
|
+
----------
|
|
1273
|
+
data : pd.DataFrame
|
|
1274
|
+
Raw data. Garbage tokens are cleaned and numeric-looking columns converted, exactly
|
|
1275
|
+
as ``ProcessBehavior(data)`` does.
|
|
1276
|
+
response, factors, time, plan, precision, unit_of_analysis
|
|
1277
|
+
Passed through to :meth:`ProcessBehavior.formulate`.
|
|
1278
|
+
|
|
1279
|
+
Returns
|
|
1280
|
+
-------
|
|
1281
|
+
Study
|
|
1282
|
+
"""
|
|
1283
|
+
return ProcessBehavior(data).formulate(
|
|
1284
|
+
response=response,
|
|
1285
|
+
factors=factors,
|
|
1286
|
+
time=time,
|
|
1287
|
+
plan=plan,
|
|
1288
|
+
precision=precision,
|
|
1289
|
+
unit_of_analysis=unit_of_analysis,
|
|
1290
|
+
)
|