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,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data validation and preparation for process behavior analysis.
|
|
3
|
+
|
|
4
|
+
This module handles the initial stages of analysis:
|
|
5
|
+
- Column validation (types, presence)
|
|
6
|
+
- Grouping variable creation
|
|
7
|
+
- Data filtering and sorting
|
|
8
|
+
- Key generation for stable merges
|
|
9
|
+
|
|
10
|
+
Follows the Pythonic Hadley philosophy:
|
|
11
|
+
- Pure functions where possible
|
|
12
|
+
- Fail fast with helpful errors
|
|
13
|
+
- Explicit over implicit
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from functools import reduce
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
from natsort import natsorted
|
|
25
|
+
from pandas.api.types import is_numeric_dtype
|
|
26
|
+
|
|
27
|
+
from .exceptions import ColumnNotFoundError, FactorNotFoundError, ValidationError
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .formulation_spec import FormulationSpec
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def natural_sort_key(s: str) -> list:
|
|
36
|
+
"""
|
|
37
|
+
Key function for natural sorting of strings with embedded numbers.
|
|
38
|
+
|
|
39
|
+
This ensures '1_10' comes after '1_2', not before (lexicographic would put 10 before 2).
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
s : str
|
|
44
|
+
String to generate sort key for
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
list
|
|
49
|
+
Sort key that handles embedded numbers correctly
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> sorted(['1_2', '1_10', '1_1'], key=natural_sort_key)
|
|
54
|
+
['1_1', '1_2', '1_10']
|
|
55
|
+
"""
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def encode_rsg(factor_values, delimiter: str = '_') -> str:
|
|
62
|
+
"""
|
|
63
|
+
Encode factor values into RSG (Rational Subgroup) string.
|
|
64
|
+
|
|
65
|
+
This is the single source of truth for RSG encoding.
|
|
66
|
+
Used by both:
|
|
67
|
+
- DataPreparation._add_composite_column() for observed data
|
|
68
|
+
- Plan expansion for expected data (coverage calculation, missing_combos)
|
|
69
|
+
- analysis_result.py for stratum comparisons
|
|
70
|
+
- plotter.py for chart IDs
|
|
71
|
+
|
|
72
|
+
Handles both tuple/list (multi-factor) and scalar (single-factor) strata.
|
|
73
|
+
|
|
74
|
+
Note: RSG identity assumes canonical factor ordering defined upstream.
|
|
75
|
+
Factor order in encode_rsg() calls must match order in RSG column creation.
|
|
76
|
+
The `by` parameter only affects layout/presentation, not stratum identity.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
factor_values : tuple, list, or scalar
|
|
81
|
+
Values for each factor, in factor order.
|
|
82
|
+
Example: (1, 'A'), [2, 'B'], or just 'A' for single factor
|
|
83
|
+
delimiter : str
|
|
84
|
+
Delimiter between values (default: '_')
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
str
|
|
89
|
+
Encoded RSG string (e.g., "1_A", "2_B", "A")
|
|
90
|
+
|
|
91
|
+
Examples
|
|
92
|
+
--------
|
|
93
|
+
>>> encode_rsg((1, 'A'))
|
|
94
|
+
'1_A'
|
|
95
|
+
>>> encode_rsg([2, 'B'], delimiter='-')
|
|
96
|
+
'2-B'
|
|
97
|
+
>>> encode_rsg((42,))
|
|
98
|
+
'42'
|
|
99
|
+
>>> encode_rsg('Machine_1') # Scalar - returned as-is (not iterated!)
|
|
100
|
+
'Machine_1'
|
|
101
|
+
>>> encode_rsg(42) # Numeric scalar
|
|
102
|
+
'42'
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(factor_values, (tuple, list)):
|
|
105
|
+
return delimiter.join(str(v) for v in factor_values)
|
|
106
|
+
return str(factor_values)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _column_as_rsg_strings(series: pd.Series) -> pd.Series:
|
|
110
|
+
"""One column's values as the strings :func:`encode_rsg` would produce for them.
|
|
111
|
+
|
|
112
|
+
``astype(str)`` matches ``str(scalar)`` for every dtype we encounter except
|
|
113
|
+
datetime64, where a midnight timestamp renders as ``'2024-01-01'`` rather than
|
|
114
|
+
``'2024-01-01 00:00:00'``. Datetime factors are rare, so that branch takes the slower
|
|
115
|
+
element-wise path rather than risk a different stratum identity.
|
|
116
|
+
"""
|
|
117
|
+
from pandas.api.types import is_datetime64_any_dtype
|
|
118
|
+
|
|
119
|
+
if is_datetime64_any_dtype(series):
|
|
120
|
+
return series.apply(str)
|
|
121
|
+
return series.astype(str)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def encode_rsg_series(df: pd.DataFrame, cols: list[str], delimiter: str = '_') -> pd.Series:
|
|
125
|
+
"""Vectorized :func:`encode_rsg` over whole columns — the same encoding, column-wise.
|
|
126
|
+
|
|
127
|
+
Use this instead of ``df[cols].apply(lambda row: encode_rsg(tuple(row)), axis=1)``,
|
|
128
|
+
which materialises every row as a Series (~31x slower at 1M rows) and, worse, silently
|
|
129
|
+
changes the answer: a row Series **upcasts mixed dtypes**, so an int factor beside a
|
|
130
|
+
float factor encodes as ``'1.0'`` instead of ``'1'``. Because plan expansion encodes
|
|
131
|
+
from Python values (``study.py``), that upcast made observed rsg keys unable to match
|
|
132
|
+
expected ones, and ``DesignReport.missing_combos`` / ``extra_combos`` then reported
|
|
133
|
+
every combination as both missing and extra. Converting per column keeps each column
|
|
134
|
+
on its own dtype and matches the plan-side encoding.
|
|
135
|
+
"""
|
|
136
|
+
if not cols:
|
|
137
|
+
raise ValidationError('encode_rsg_series requires at least one column.')
|
|
138
|
+
|
|
139
|
+
parts = [_column_as_rsg_strings(df[c]) for c in cols]
|
|
140
|
+
return reduce(lambda left, right: left + delimiter + right, parts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DataPreparation:
|
|
144
|
+
"""
|
|
145
|
+
Validates and prepares raw data for statistical process control analysis.
|
|
146
|
+
|
|
147
|
+
This class handles the transformation from raw user data to a clean,
|
|
148
|
+
validated dataset ready for analysis. It:
|
|
149
|
+
|
|
150
|
+
1. Validates column existence and types
|
|
151
|
+
2. Creates composite grouping variables (e.g., "lane_head")
|
|
152
|
+
3. Filters out invalid subgroups (n ≤ 1)
|
|
153
|
+
4. Sorts data appropriately
|
|
154
|
+
5. Generates stable keys for reproducible analysis
|
|
155
|
+
|
|
156
|
+
The class is designed to be reusable across different analysis types
|
|
157
|
+
and testable in isolation from the heavier analysis logic.
|
|
158
|
+
|
|
159
|
+
Examples
|
|
160
|
+
--------
|
|
161
|
+
Basic usage with grouping:
|
|
162
|
+
|
|
163
|
+
>>> from processbehavior.formulation_spec import FormulationSpec
|
|
164
|
+
>>> spec = FormulationSpec(
|
|
165
|
+
... response_var='weight',
|
|
166
|
+
... rsg_vars=('lane', 'head'),
|
|
167
|
+
... time_var='pull'
|
|
168
|
+
... )
|
|
169
|
+
>>> prep = DataPreparation()
|
|
170
|
+
>>> clean_df = prep.prepare_dataset(raw_df, spec)
|
|
171
|
+
|
|
172
|
+
The prepared dataset will have:
|
|
173
|
+
- A 'rsg' column: "lane_head"
|
|
174
|
+
- A 'n' column: observations per group
|
|
175
|
+
- Sorted by ['rsg', 'pull']
|
|
176
|
+
- Only groups with n > 1
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def _get_sort_cols(self, spec: FormulationSpec) -> list[str]:
|
|
180
|
+
"""Sort order for prepared data."""
|
|
181
|
+
if spec.has_grouping and spec.has_time:
|
|
182
|
+
return [spec.rsg_var_name, spec.time_var]
|
|
183
|
+
elif spec.has_time:
|
|
184
|
+
return [spec.time_var]
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
def _get_output_cols(self, spec: FormulationSpec) -> list[str]:
|
|
188
|
+
"""Columns to keep after data preparation."""
|
|
189
|
+
cols = [spec.response_var]
|
|
190
|
+
if spec.has_grouping:
|
|
191
|
+
cols = [spec.rsg_var_name, 'n'] + spec.rsg_vars_list + cols
|
|
192
|
+
if spec.has_time and spec.time_var not in cols:
|
|
193
|
+
cols.insert(0, spec.time_var)
|
|
194
|
+
# Preserve the source-row lineage id stamped at the top of prepare_dataset.
|
|
195
|
+
cols.append('obs_id')
|
|
196
|
+
return cols
|
|
197
|
+
|
|
198
|
+
def prepare_dataset(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
|
|
199
|
+
"""
|
|
200
|
+
Prepare raw data for analysis with automatic type conversion.
|
|
201
|
+
|
|
202
|
+
Takes user's raw DataFrame and specification, returns a clean dataset
|
|
203
|
+
ready for statistical analysis. This is a pure transformation - the
|
|
204
|
+
input DataFrame is never modified.
|
|
205
|
+
|
|
206
|
+
**Type Conversion for Correct Sorting:**
|
|
207
|
+
- String-numeric columns ('1', '2', '10') → numeric (1, 2, 10)
|
|
208
|
+
- String-date columns ('2024-01-01') → datetime
|
|
209
|
+
- Numeric, date, datetime, categorical, Period types → unchanged
|
|
210
|
+
- RSG column → categorical with natural sort order
|
|
211
|
+
|
|
212
|
+
This ensures correct sorting for:
|
|
213
|
+
- Time series: 1, 2, 3, 10 (not '1', '10', '2', '3')
|
|
214
|
+
- Factor levels: Lane 1, Lane 2, Lane 10 (not Lane 1, Lane 10, Lane 2)
|
|
215
|
+
- Moving range calculations (adjacent observations)
|
|
216
|
+
- Signal detection rules (consecutive points)
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
df : DataFrame
|
|
221
|
+
Raw input data from user
|
|
222
|
+
spec : FormulationSpec
|
|
223
|
+
Configuration specifying analysis parameters
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
DataFrame
|
|
228
|
+
Validated, filtered, sorted dataset with:
|
|
229
|
+
- All required columns present and validated
|
|
230
|
+
- Type conversion applied for correct sorting
|
|
231
|
+
- Composite grouping column if needed (categorical with natural sort)
|
|
232
|
+
- Invalid groups removed (n ≤ 1)
|
|
233
|
+
- Sorted appropriately
|
|
234
|
+
- Only requested output columns
|
|
235
|
+
- No missing values
|
|
236
|
+
|
|
237
|
+
Raises
|
|
238
|
+
------
|
|
239
|
+
ValueError
|
|
240
|
+
If required columns are missing, wrong type, or all groups invalid
|
|
241
|
+
|
|
242
|
+
Examples
|
|
243
|
+
--------
|
|
244
|
+
>>> spec = FormulationSpec(
|
|
245
|
+
... response_var='weight',
|
|
246
|
+
... rsg_vars=('lane',),
|
|
247
|
+
... time_var='pull'
|
|
248
|
+
... )
|
|
249
|
+
>>> df = pd.DataFrame({
|
|
250
|
+
... 'lane': ['A', 'A', 'B'],
|
|
251
|
+
... 'pull': [1, 2, 1],
|
|
252
|
+
... 'weight': [10.1, 10.3, 9.9]
|
|
253
|
+
... })
|
|
254
|
+
>>> prep = DataPreparation()
|
|
255
|
+
>>> result = prep.prepare_dataset(df, spec)
|
|
256
|
+
>>> 'rsg' in result.columns
|
|
257
|
+
True
|
|
258
|
+
>>> result['rsg'].tolist()
|
|
259
|
+
['A', 'A', 'B']
|
|
260
|
+
"""
|
|
261
|
+
logger.debug('Entering prepare_dataset')
|
|
262
|
+
out = df.copy()
|
|
263
|
+
# Stamp a 1-based SOURCE-ROW id (obs_id) as a COLUMN before any row filtering.
|
|
264
|
+
# As a column it survives both cleaning (dropped rows leave gaps in the values)
|
|
265
|
+
# and the groupby/merge in _add_group_sizes (which rebuilds the frame's index).
|
|
266
|
+
# This is the raw→analytic lineage key: a flagged observation traces back to its
|
|
267
|
+
# source-file row (gaps mark rows removed in cleaning).
|
|
268
|
+
out['obs_id'] = np.arange(1, len(out) + 1, dtype=np.int64)
|
|
269
|
+
|
|
270
|
+
# Validate columns early (fail fast!)
|
|
271
|
+
self.validate_columns(out, spec)
|
|
272
|
+
|
|
273
|
+
# Convert types for correct sorting (time_var and factor columns)
|
|
274
|
+
# This handles string-numeric ('1', '10') and string-dates
|
|
275
|
+
if spec.has_time:
|
|
276
|
+
out[spec.time_var], msg = self._detect_and_convert_type(out[spec.time_var], spec.time_var)
|
|
277
|
+
|
|
278
|
+
if spec.has_grouping:
|
|
279
|
+
for col in spec.rsg_vars:
|
|
280
|
+
out[col], msg = self._detect_and_convert_type(out[col], col)
|
|
281
|
+
|
|
282
|
+
# Add composite grouping variable if needed
|
|
283
|
+
if spec.has_grouping:
|
|
284
|
+
out = self._add_grouping_column(out, spec)
|
|
285
|
+
|
|
286
|
+
# Drop rows with missing values in analysis-critical columns BEFORE
|
|
287
|
+
# calculating n. This ensures n reflects actual usable observations,
|
|
288
|
+
# not the raw count which may include rows that will be dropped later.
|
|
289
|
+
drop_cols = [spec.response_var, spec.rsg_var_name]
|
|
290
|
+
if spec.has_time:
|
|
291
|
+
drop_cols.append(spec.time_var)
|
|
292
|
+
out = out.dropna(subset=drop_cols)
|
|
293
|
+
|
|
294
|
+
# FormulationSpec is chart-agnostic — filtering for Xbar/S (n≥2)
|
|
295
|
+
# happens at analysis time, not during data preparation.
|
|
296
|
+
# Just add group sizes without filtering.
|
|
297
|
+
out = self._add_group_sizes(out, spec)
|
|
298
|
+
|
|
299
|
+
# Make RSG categorical with natural sort order
|
|
300
|
+
# This ensures 'Lane_1', 'Lane_2', 'Lane_10' (not 'Lane_1', 'Lane_10', 'Lane_2')
|
|
301
|
+
out[spec.rsg_var_name] = self._make_categorical_rsg(out[spec.rsg_var_name], spec.rsg_var_name)
|
|
302
|
+
|
|
303
|
+
# Sort if required
|
|
304
|
+
sort_cols = self._get_sort_cols(spec)
|
|
305
|
+
if sort_cols:
|
|
306
|
+
out = out.sort_values(sort_cols, kind='stable')
|
|
307
|
+
|
|
308
|
+
# Keep only requested columns
|
|
309
|
+
output_cols = self._get_output_cols(spec)
|
|
310
|
+
out = out[output_cols]
|
|
311
|
+
|
|
312
|
+
# Final safety net: drop any remaining rows with missing data
|
|
313
|
+
# (Primary dropna for grouped data happens earlier, before n calculation)
|
|
314
|
+
out = out.dropna()
|
|
315
|
+
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
def validate_columns(self, df: pd.DataFrame, spec: FormulationSpec) -> None:
|
|
319
|
+
"""
|
|
320
|
+
Validate that required columns exist and have correct types.
|
|
321
|
+
|
|
322
|
+
Checks:
|
|
323
|
+
- All grouping variables exist
|
|
324
|
+
- Time variable exists (if specified)
|
|
325
|
+
- Response variable exists and is numeric
|
|
326
|
+
- No column holds two roles (response/factor/time), and no factor
|
|
327
|
+
is listed twice
|
|
328
|
+
|
|
329
|
+
This function follows "Fail Fast, Fail Helpful" - it raises
|
|
330
|
+
immediately with a message that explains WHAT is wrong, WHY
|
|
331
|
+
it matters, and HOW to fix it.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
df : DataFrame
|
|
336
|
+
Input data to validate
|
|
337
|
+
spec : FormulationSpec
|
|
338
|
+
Specification with column requirements
|
|
339
|
+
|
|
340
|
+
Raises
|
|
341
|
+
------
|
|
342
|
+
ValueError
|
|
343
|
+
If required columns missing or wrong type, with helpful message
|
|
344
|
+
suggesting fixes
|
|
345
|
+
|
|
346
|
+
Examples
|
|
347
|
+
--------
|
|
348
|
+
>>> spec = FormulationSpec(
|
|
349
|
+
... response_var='weight',
|
|
350
|
+
... rsg_vars=('lane',)
|
|
351
|
+
... )
|
|
352
|
+
>>> df = pd.DataFrame({'weight': [10.1, 10.2]})
|
|
353
|
+
>>> prep = DataPreparation()
|
|
354
|
+
>>> prep.validate_columns(df, spec) # Raises: lane not found
|
|
355
|
+
Traceback (most recent call last):
|
|
356
|
+
...
|
|
357
|
+
ValueError: One or more grouping variables not found...
|
|
358
|
+
"""
|
|
359
|
+
df_cols = df.columns.tolist()
|
|
360
|
+
|
|
361
|
+
# Validate grouping variables
|
|
362
|
+
if spec.has_grouping:
|
|
363
|
+
missing = set(spec.rsg_vars) - set(df_cols)
|
|
364
|
+
if missing:
|
|
365
|
+
raise FactorNotFoundError(
|
|
366
|
+
f'One or more grouping variables not found in dataset.\n'
|
|
367
|
+
f'Missing: {sorted(missing)}\n'
|
|
368
|
+
f'Required: {spec.rsg_vars}\n'
|
|
369
|
+
f'Available columns: {df_cols}\n'
|
|
370
|
+
f'Fix: Check spelling or provide correct column names',
|
|
371
|
+
factor=str(sorted(missing)),
|
|
372
|
+
available=df_cols,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Validate time variable
|
|
376
|
+
if spec.has_time and spec.time_var not in df_cols:
|
|
377
|
+
raise ColumnNotFoundError(
|
|
378
|
+
f"Time variable '{spec.time_var}' not found in dataset.\n"
|
|
379
|
+
f'Available columns: {df_cols}\n'
|
|
380
|
+
f'Fix: Check spelling or specify correct time column',
|
|
381
|
+
column=spec.time_var,
|
|
382
|
+
available=df_cols,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Validate response variable
|
|
386
|
+
if spec.response_var not in df_cols:
|
|
387
|
+
raise ColumnNotFoundError(
|
|
388
|
+
f"Response variable '{spec.response_var}' not found in dataset.\n"
|
|
389
|
+
f'Available columns: {df_cols}\n'
|
|
390
|
+
f'Fix: Check spelling or specify correct measurement column',
|
|
391
|
+
column=spec.response_var,
|
|
392
|
+
available=df_cols,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Validate response variable is numeric
|
|
396
|
+
if not is_numeric_dtype(df[spec.response_var]):
|
|
397
|
+
raise ValidationError(
|
|
398
|
+
f"Response variable '{spec.response_var}' must be numeric.\n"
|
|
399
|
+
f'Current type: {df[spec.response_var].dtype}\n'
|
|
400
|
+
f'Fix: Convert to numeric or choose a different column'
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Validate one column, one role. Without this, a column named as both
|
|
404
|
+
# response and factor survives into the analysis frame twice, and the
|
|
405
|
+
# duplicate label surfaces much later as an opaque pandas error while
|
|
406
|
+
# building cell means.
|
|
407
|
+
self._validate_column_roles(spec)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _validate_column_roles(spec: FormulationSpec) -> None:
|
|
411
|
+
"""Reject a column that holds more than one role, or a repeated factor.
|
|
412
|
+
|
|
413
|
+
Note what is *not* rejected: the time column may also appear in
|
|
414
|
+
``factors``. That is a supported VAS pattern — declaring time as a
|
|
415
|
+
factor makes each cell a single (factor, time) combination — and
|
|
416
|
+
``_get_output_cols`` guards the column against being selected twice.
|
|
417
|
+
Only the response has no such guard, and no meaning as a factor.
|
|
418
|
+
"""
|
|
419
|
+
factors = spec.rsg_vars_list
|
|
420
|
+
|
|
421
|
+
if spec.response_var in factors:
|
|
422
|
+
raise ValidationError(
|
|
423
|
+
f"Column '{spec.response_var}' is named as both the response and a factor.\n"
|
|
424
|
+
f'A column can hold only one role in a study.\n'
|
|
425
|
+
f'Fix: choose a different response, or drop '
|
|
426
|
+
f"'{spec.response_var}' from factors."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if spec.has_time and spec.time_var == spec.response_var:
|
|
430
|
+
raise ValidationError(
|
|
431
|
+
f"Column '{spec.response_var}' is named as both the response and the time variable.\n"
|
|
432
|
+
f'A column can hold only one role in a study.\n'
|
|
433
|
+
f'Fix: choose a different response or a different time column.'
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
duplicates = sorted({f for f in factors if factors.count(f) > 1})
|
|
437
|
+
if duplicates:
|
|
438
|
+
raise ValidationError(
|
|
439
|
+
f'Factor(s) listed more than once: {duplicates}.\n'
|
|
440
|
+
f'Each factor must appear exactly once.\n'
|
|
441
|
+
f'Fix: remove the duplicate entries from factors.'
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
logger.debug('Column validation passed')
|
|
445
|
+
|
|
446
|
+
def build_keys(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
|
|
447
|
+
"""
|
|
448
|
+
Add stable key columns for reproducible analysis.
|
|
449
|
+
|
|
450
|
+
Creates four types of keys:
|
|
451
|
+
- obs_id: Row identity (assigned BEFORE canonical sort, for merges/joins)
|
|
452
|
+
- rsg_key: Tuple key for factor combinations
|
|
453
|
+
- cell_key: Tuple key for (factor × time) cells
|
|
454
|
+
- sort_key: Canonical ordering (assigned AFTER canonical sort)
|
|
455
|
+
|
|
456
|
+
Canonical sort order: (cell_key ascending, obs_id ascending as tie-breaker)
|
|
457
|
+
|
|
458
|
+
Dual-column strategy:
|
|
459
|
+
- **rsg_key (tuple)**: Available for internal operations (fast lookups, hierarchical ops)
|
|
460
|
+
Preserves factor types after type conversion: (1, 1), (1, 2), (1, 10)
|
|
461
|
+
- **rsg (string)**: Used for display (chart labels, user output)
|
|
462
|
+
Created separately in _add_grouping_column() as categorical with natural sort
|
|
463
|
+
|
|
464
|
+
Note: sort_key is study-instance specific and must never be persisted/compared
|
|
465
|
+
across different Studies unless paired with formulation context.
|
|
466
|
+
|
|
467
|
+
Parameters
|
|
468
|
+
----------
|
|
469
|
+
df : DataFrame
|
|
470
|
+
Input data (must already have grouping/time columns)
|
|
471
|
+
spec : FormulationSpec
|
|
472
|
+
Specification with grouping and time variables
|
|
473
|
+
|
|
474
|
+
Returns
|
|
475
|
+
-------
|
|
476
|
+
DataFrame
|
|
477
|
+
Input data with added key columns, sorted by canonical order
|
|
478
|
+
|
|
479
|
+
Examples
|
|
480
|
+
--------
|
|
481
|
+
>>> df = pd.DataFrame({
|
|
482
|
+
... 'lane': ['A', 'B'],
|
|
483
|
+
... 'pull': [1, 1],
|
|
484
|
+
... 'weight': [10.1, 9.9]
|
|
485
|
+
... })
|
|
486
|
+
>>> spec = FormulationSpec(
|
|
487
|
+
... response_var='weight',
|
|
488
|
+
... rsg_vars=('lane',),
|
|
489
|
+
... time_var='pull'
|
|
490
|
+
... )
|
|
491
|
+
>>> prep = DataPreparation()
|
|
492
|
+
>>> result = prep.build_keys(df, spec)
|
|
493
|
+
>>> 'obs_id' in result.columns
|
|
494
|
+
True
|
|
495
|
+
>>> 'sort_key' in result.columns
|
|
496
|
+
True
|
|
497
|
+
"""
|
|
498
|
+
out = df.copy()
|
|
499
|
+
|
|
500
|
+
# 1. obs_id — the 1-based SOURCE-ROW id (raw→analytic lineage). Normally stamped
|
|
501
|
+
# in prepare_dataset before cleaning and carried through here; only assign it if a
|
|
502
|
+
# caller invoked build_keys directly without it (kept 1-based for consistency).
|
|
503
|
+
if 'obs_id' not in out.columns:
|
|
504
|
+
out['obs_id'] = np.arange(1, len(out) + 1, dtype=np.int64)
|
|
505
|
+
|
|
506
|
+
k_vars = spec.rsg_vars_list
|
|
507
|
+
t = spec.time_var
|
|
508
|
+
|
|
509
|
+
# 2. Build tuple keys
|
|
510
|
+
# RSG key: tuple of factor values
|
|
511
|
+
if k_vars:
|
|
512
|
+
out['rsg_key'] = list(map(tuple, out[k_vars].astype(object).values))
|
|
513
|
+
else:
|
|
514
|
+
out['rsg_key'] = [()] * len(out)
|
|
515
|
+
|
|
516
|
+
# Cell key: tuple of (factor + time) values
|
|
517
|
+
if k_vars and t:
|
|
518
|
+
out['cell_key'] = list(map(tuple, out[k_vars + [t]].astype(object).values))
|
|
519
|
+
elif t:
|
|
520
|
+
out['cell_key'] = out[t].astype(object).apply(lambda x: (x,))
|
|
521
|
+
else:
|
|
522
|
+
out['cell_key'] = out['rsg_key']
|
|
523
|
+
|
|
524
|
+
# 3. Sort by canonical order: (cell_key, obs_id)
|
|
525
|
+
out = out.sort_values(['cell_key', 'obs_id'], kind='stable')
|
|
526
|
+
|
|
527
|
+
# 4. Assign sort_key AFTER sorting (canonical ordering 0..N-1)
|
|
528
|
+
out['sort_key'] = np.arange(len(out), dtype=np.int64)
|
|
529
|
+
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
# ========================================================================
|
|
533
|
+
# Private Helper Methods
|
|
534
|
+
# ========================================================================
|
|
535
|
+
|
|
536
|
+
def _add_grouping_column(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
|
|
537
|
+
"""
|
|
538
|
+
Add composite grouping column (e.g., 'lane_head').
|
|
539
|
+
|
|
540
|
+
Creates a user-friendly string column combining multiple factors.
|
|
541
|
+
For single factors, just copies the column with a standard name.
|
|
542
|
+
|
|
543
|
+
Note: This creates the 'rsg' STRING column for user-facing display
|
|
544
|
+
(chart labels, summaries). The 'rsg_key' TUPLE column (created in
|
|
545
|
+
build_keys()) is used for internal computation to ensure correct
|
|
546
|
+
numeric sorting.
|
|
547
|
+
"""
|
|
548
|
+
out = df.copy()
|
|
549
|
+
|
|
550
|
+
# Drop 'n' column if it exists (will be recalculated)
|
|
551
|
+
if 'n' in out.columns:
|
|
552
|
+
logger.debug('Dropping existing "n" column (will be recalculated)')
|
|
553
|
+
out = out.drop(columns=['n'])
|
|
554
|
+
|
|
555
|
+
# Create composite column (always as string for display)
|
|
556
|
+
if len(spec.rsg_vars) > 1:
|
|
557
|
+
# Multiple factors: combine with delimiter
|
|
558
|
+
out = self._add_composite_column(
|
|
559
|
+
df=out, cols_to_combine=spec.rsg_vars_list, col_name=spec.rsg_var_name, col_delim=spec.rsg_var_delim
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
# Single factor: copy with standard name.
|
|
563
|
+
out = self._add_column(df=out, new_col_name=spec.rsg_var_name, existing_column=spec.rsg_vars[0])
|
|
564
|
+
src = out[spec.rsg_var_name]
|
|
565
|
+
if isinstance(src.dtype, pd.CategoricalDtype) and src.cat.ordered:
|
|
566
|
+
# Preserve an explicitly-ordered categorical factor's order (e.g. a
|
|
567
|
+
# binned ordinal factor Low < ... < High) through the string rsg
|
|
568
|
+
# column, rather than losing it to a plain str cast + later natsort.
|
|
569
|
+
ordered_cats = [str(c) for c in src.cat.categories]
|
|
570
|
+
out[spec.rsg_var_name] = pd.Categorical(
|
|
571
|
+
src.astype(str), categories=ordered_cats, ordered=True
|
|
572
|
+
)
|
|
573
|
+
else:
|
|
574
|
+
# Ensure RSG is string (even if source column is numeric)
|
|
575
|
+
out[spec.rsg_var_name] = src.astype(str)
|
|
576
|
+
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
def _add_group_sizes(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
|
|
580
|
+
"""
|
|
581
|
+
Add 'n' column with kt cell sizes without filtering.
|
|
582
|
+
|
|
583
|
+
Groups by kt (factor × time) columns to get true subgroup sizes.
|
|
584
|
+
Used for IMR/R analyses and SDS detection where n=1 per cell is valid.
|
|
585
|
+
"""
|
|
586
|
+
# Build kt grouping columns (factor + time if both present)
|
|
587
|
+
kt_cols = [spec.rsg_var_name, spec.time_var] if spec.has_time else [spec.rsg_var_name]
|
|
588
|
+
|
|
589
|
+
grouped = df.groupby(kt_cols, observed=True).size()
|
|
590
|
+
grouped = grouped.reset_index(name='n')
|
|
591
|
+
|
|
592
|
+
out = pd.merge(df, grouped, how='left', on=kt_cols)
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
def _add_column(self, df: pd.DataFrame, new_col_name: str, existing_column: str) -> pd.DataFrame:
|
|
596
|
+
"""Copy an existing column with a new name."""
|
|
597
|
+
if existing_column not in df.columns:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"Cannot copy column '{existing_column}' - not in dataset.\nAvailable: {df.columns.tolist()}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
out = df.copy()
|
|
603
|
+
out[new_col_name] = df[existing_column]
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
def _add_composite_column(
|
|
607
|
+
self, df: pd.DataFrame, cols_to_combine: list[str], col_name: str, col_delim: str = '_'
|
|
608
|
+
) -> pd.DataFrame:
|
|
609
|
+
"""
|
|
610
|
+
Create composite column by combining multiple columns.
|
|
611
|
+
|
|
612
|
+
Uses encode_rsg() for consistent encoding with plan expansion.
|
|
613
|
+
|
|
614
|
+
Example: ['lane', 'head'] → 'lane_head' with values like 'A_1', 'B_2'
|
|
615
|
+
"""
|
|
616
|
+
missing = set(cols_to_combine) - set(df.columns)
|
|
617
|
+
if missing:
|
|
618
|
+
raise ValueError(
|
|
619
|
+
f'Cannot create composite column - some columns missing.\n'
|
|
620
|
+
f'Missing: {sorted(missing)}\n'
|
|
621
|
+
f'Available: {df.columns.tolist()}'
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Validate no missing values in source columns before encoding
|
|
625
|
+
# str(None) → "None" and str(np.nan) → "nan" which won't trigger isna()
|
|
626
|
+
# on the output, so we must check inputs
|
|
627
|
+
if df[cols_to_combine].isna().any().any():
|
|
628
|
+
missing_counts = df[cols_to_combine].isna().sum()
|
|
629
|
+
raise ValidationError(
|
|
630
|
+
f"Cannot build RSG '{col_name}': missing values in factor columns. "
|
|
631
|
+
f'Missing counts: {missing_counts.to_dict()}'
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
if len(cols_to_combine) == 1:
|
|
635
|
+
# Single column - just copy
|
|
636
|
+
return self._add_column(df, col_name, cols_to_combine[0])
|
|
637
|
+
|
|
638
|
+
# Multiple columns - combine with delimiter using shared encode_rsg()
|
|
639
|
+
out = df.copy()
|
|
640
|
+
out[col_name] = encode_rsg_series(df, cols_to_combine, delimiter=col_delim)
|
|
641
|
+
|
|
642
|
+
return out
|
|
643
|
+
|
|
644
|
+
def _detect_and_convert_type(self, series: pd.Series, col_name: str) -> tuple[pd.Series, str | None]:
|
|
645
|
+
"""
|
|
646
|
+
Detect and convert string columns to appropriate types.
|
|
647
|
+
|
|
648
|
+
Attempts conversion in this order:
|
|
649
|
+
1. Try pd.to_numeric() for numeric strings ('1', '2', '10' → 1, 2, 10)
|
|
650
|
+
2. Try pd.to_datetime() for date strings ('2024-01-01' → datetime)
|
|
651
|
+
3. Keep original if both fail
|
|
652
|
+
|
|
653
|
+
This ensures correct sorting:
|
|
654
|
+
- Numeric: 1, 2, 10 (not '1', '10', '2')
|
|
655
|
+
- Date/datetime: chronological order
|
|
656
|
+
- Categorical/string: natural sort will be applied
|
|
657
|
+
|
|
658
|
+
Parameters
|
|
659
|
+
----------
|
|
660
|
+
series : pd.Series
|
|
661
|
+
Column to potentially convert
|
|
662
|
+
col_name : str
|
|
663
|
+
Name of column (for logging)
|
|
664
|
+
|
|
665
|
+
Returns
|
|
666
|
+
-------
|
|
667
|
+
tuple[pd.Series, str | None]
|
|
668
|
+
- Converted series (or original if no conversion)
|
|
669
|
+
- Conversion message (None if no conversion)
|
|
670
|
+
|
|
671
|
+
Examples
|
|
672
|
+
--------
|
|
673
|
+
>>> prep = DataPreparation()
|
|
674
|
+
>>> s = pd.Series(['1', '2', '10'])
|
|
675
|
+
>>> converted, msg = prep._detect_and_convert_type(s, 'time')
|
|
676
|
+
>>> converted.dtype
|
|
677
|
+
dtype('int64')
|
|
678
|
+
>>> '1' in msg # Should mention conversion
|
|
679
|
+
True
|
|
680
|
+
"""
|
|
681
|
+
# Skip if already numeric or datetime
|
|
682
|
+
if pd.api.types.is_numeric_dtype(series):
|
|
683
|
+
return series, None
|
|
684
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
685
|
+
return series, None
|
|
686
|
+
if isinstance(series.dtype, pd.PeriodDtype):
|
|
687
|
+
return series, None
|
|
688
|
+
|
|
689
|
+
# Skip if contains Python date/datetime objects (already proper type)
|
|
690
|
+
if len(series) > 0 and isinstance(series.iloc[0], (type(None), type(pd.NaT))):
|
|
691
|
+
# Handle NaT/None
|
|
692
|
+
first_valid_idx = series.first_valid_index()
|
|
693
|
+
if first_valid_idx is not None:
|
|
694
|
+
first_val = series.loc[first_valid_idx]
|
|
695
|
+
else:
|
|
696
|
+
return series, None
|
|
697
|
+
else:
|
|
698
|
+
first_val = series.iloc[0] if len(series) > 0 else None
|
|
699
|
+
|
|
700
|
+
if first_val is not None:
|
|
701
|
+
import datetime
|
|
702
|
+
|
|
703
|
+
if isinstance(first_val, (datetime.date, datetime.datetime)):
|
|
704
|
+
return series, None
|
|
705
|
+
|
|
706
|
+
# Skip if already categorical (assume user set ordering intentionally)
|
|
707
|
+
if isinstance(series.dtype, pd.CategoricalDtype):
|
|
708
|
+
return series, None
|
|
709
|
+
|
|
710
|
+
# Try numeric conversion
|
|
711
|
+
try:
|
|
712
|
+
numeric_vals = pd.to_numeric(series, errors='coerce')
|
|
713
|
+
# Only convert if ALL values succeeded (no NaNs introduced)
|
|
714
|
+
if (not numeric_vals.isna().any() or series.isna().any()) and numeric_vals.notna().any():
|
|
715
|
+
msg = (
|
|
716
|
+
f"Converted column '{col_name}' from string to numeric "
|
|
717
|
+
f"for correct sorting (example: '{series.iloc[0]}' → {numeric_vals.iloc[0]})"
|
|
718
|
+
)
|
|
719
|
+
logger.info(msg)
|
|
720
|
+
return numeric_vals, msg
|
|
721
|
+
except (ValueError, TypeError):
|
|
722
|
+
pass
|
|
723
|
+
|
|
724
|
+
# Try datetime conversion
|
|
725
|
+
try:
|
|
726
|
+
datetime_vals = pd.to_datetime(series, errors='coerce', format='mixed')
|
|
727
|
+
# Only convert if most values succeeded
|
|
728
|
+
success_rate = datetime_vals.notna().sum() / len(series)
|
|
729
|
+
if success_rate > 0.5: # At least 50% converted
|
|
730
|
+
msg = f"Converted column '{col_name}' from string to datetime for correct chronological sorting"
|
|
731
|
+
logger.info(msg)
|
|
732
|
+
return datetime_vals, msg
|
|
733
|
+
except (ValueError, TypeError):
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
# No conversion possible/needed
|
|
737
|
+
return series, None
|
|
738
|
+
|
|
739
|
+
def _make_categorical_rsg(self, series: pd.Series, col_name: str) -> pd.Series:
|
|
740
|
+
"""
|
|
741
|
+
Convert RSG column to categorical with natural sort order.
|
|
742
|
+
|
|
743
|
+
Uses natsort to handle numeric parts in strings correctly:
|
|
744
|
+
- Natural sort: 'Lane_1', 'Lane_2', 'Lane_10'
|
|
745
|
+
- Lexicographic: 'Lane_1', 'Lane_10', 'Lane_2' (wrong!)
|
|
746
|
+
|
|
747
|
+
This ensures groupby, sort_values, and plotting operations
|
|
748
|
+
all respect the correct numeric ordering within strings.
|
|
749
|
+
|
|
750
|
+
Parameters
|
|
751
|
+
----------
|
|
752
|
+
series : pd.Series
|
|
753
|
+
RSG column (string values like 'Lane_1_Head_10')
|
|
754
|
+
col_name : str
|
|
755
|
+
Name of column (for logging)
|
|
756
|
+
|
|
757
|
+
Returns
|
|
758
|
+
-------
|
|
759
|
+
pd.Series
|
|
760
|
+
Categorical series with natural-sorted categories
|
|
761
|
+
|
|
762
|
+
Examples
|
|
763
|
+
--------
|
|
764
|
+
>>> prep = DataPreparation()
|
|
765
|
+
>>> s = pd.Series(['Lane_1_Head_10', 'Lane_1_Head_2', 'Lane_10_Head_1'])
|
|
766
|
+
>>> cat = prep._make_categorical_rsg(s, 'rsg')
|
|
767
|
+
>>> cat.dtype.ordered
|
|
768
|
+
True
|
|
769
|
+
>>> list(cat.cat.categories)
|
|
770
|
+
['Lane_1_Head_2', 'Lane_1_Head_10', 'Lane_10_Head_1']
|
|
771
|
+
"""
|
|
772
|
+
# Preserve an already-ordered categorical's order (e.g. a binned ordinal
|
|
773
|
+
# factor Low < ... < High); natsort would alphabetize it incorrectly.
|
|
774
|
+
if isinstance(series.dtype, pd.CategoricalDtype) and series.cat.ordered:
|
|
775
|
+
logger.info(
|
|
776
|
+
f"Preserved explicit categorical order for '{col_name}' "
|
|
777
|
+
f'({len(series.cat.categories)} categories)'
|
|
778
|
+
)
|
|
779
|
+
return series
|
|
780
|
+
|
|
781
|
+
# Get unique values and sort naturally
|
|
782
|
+
unique_vals = series.unique()
|
|
783
|
+
sorted_categories = natsorted(unique_vals)
|
|
784
|
+
|
|
785
|
+
# Create ordered categorical
|
|
786
|
+
categorical = pd.Categorical(series, categories=sorted_categories, ordered=True)
|
|
787
|
+
|
|
788
|
+
logger.info(
|
|
789
|
+
f"Created categorical column '{col_name}' with natural sort order ({len(sorted_categories)} categories)"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
return pd.Series(categorical, index=series.index)
|