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,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .data_preparation import DataPreparation
|
|
8
|
+
from .effects_calculator import EffectsCalculator
|
|
9
|
+
from .formulation_spec import FormulationSpec
|
|
10
|
+
from .residual_calculator import calculate_vas_residuals
|
|
11
|
+
from .sds_detector import SDSRegistry, SDSResult, StructureStats
|
|
12
|
+
|
|
13
|
+
# Configure module logger
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnalysisDataSet:
|
|
18
|
+
"""
|
|
19
|
+
Orchestrates statistical process control analysis using Bishop's VAS methodology.
|
|
20
|
+
|
|
21
|
+
This class coordinates the workflow:
|
|
22
|
+
1. Data preparation and validation
|
|
23
|
+
2. VAS residual calculation (R1-R5) based on provided SDS
|
|
24
|
+
3. Effects and interactions analysis
|
|
25
|
+
|
|
26
|
+
SDS (Sampling Design State) is required and must be detected at the entry
|
|
27
|
+
point (ProcessBehavior) before creating an AnalysisDataSet. This ensures
|
|
28
|
+
SDS is detected exactly once per workflow.
|
|
29
|
+
|
|
30
|
+
Uses composition pattern - delegates to focused classes for each concern.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, df: pd.DataFrame, spec: FormulationSpec, observed_sds: int):
|
|
34
|
+
"""
|
|
35
|
+
Initialize analysis with data and specification.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
df : pd.DataFrame
|
|
40
|
+
Raw input data
|
|
41
|
+
spec : FormulationSpec
|
|
42
|
+
Structural configuration for the analysis
|
|
43
|
+
observed_sds : int
|
|
44
|
+
Observed Design State detected on raw data (ODS). This is the
|
|
45
|
+
SDS detected at the entry point (ProcessBehavior) on raw data
|
|
46
|
+
before NA filtering. Used for diagnostic logging.
|
|
47
|
+
The Analytical Design State (ADS) is computed internally on
|
|
48
|
+
tidy data and drives analysis decisions.
|
|
49
|
+
"""
|
|
50
|
+
# Store inputs
|
|
51
|
+
self.spec = spec
|
|
52
|
+
self.observed_design_state: int = observed_sds
|
|
53
|
+
|
|
54
|
+
# Initialize output containers
|
|
55
|
+
self.interactions = {}
|
|
56
|
+
self.effects = {}
|
|
57
|
+
|
|
58
|
+
# Structure stats (computed once in _initialize)
|
|
59
|
+
self._structure_stats: StructureStats | None = None
|
|
60
|
+
self._n_per_cell: pd.Series | None = None
|
|
61
|
+
self._ybar_kt: pd.Series | None = None
|
|
62
|
+
|
|
63
|
+
# ADS result (computed in _initialize on tidy data)
|
|
64
|
+
self._ads_result: SDSResult | None = None
|
|
65
|
+
|
|
66
|
+
# Composition - each component has one job (Single Responsibility Principle)
|
|
67
|
+
# SDS detection was done on raw data by ProcessBehavior. Here we only use
|
|
68
|
+
# SDSRegistry for characteristic lookups and R2 method selection on tidy
|
|
69
|
+
# structure. When the tidy-state concept is formalized, its classification
|
|
70
|
+
# would live here alongside SDSRegistry.
|
|
71
|
+
self._prep = DataPreparation()
|
|
72
|
+
self._sds_registry = SDSRegistry()
|
|
73
|
+
self._effects_calc = EffectsCalculator()
|
|
74
|
+
|
|
75
|
+
# Run the analysis workflow
|
|
76
|
+
self._initialize(df)
|
|
77
|
+
|
|
78
|
+
def _initialize(self, raw_df: pd.DataFrame):
|
|
79
|
+
"""
|
|
80
|
+
Execute the analysis workflow.
|
|
81
|
+
|
|
82
|
+
Clear orchestration that reads like a recipe:
|
|
83
|
+
1. Validate and prepare data
|
|
84
|
+
2. Apply SDS (passed from entry point)
|
|
85
|
+
3. Compute structure stats on tidy data
|
|
86
|
+
4. Calculate VAS residuals (if appropriate for SDS)
|
|
87
|
+
5. Calculate effects and interactions (if appropriate)
|
|
88
|
+
"""
|
|
89
|
+
# Step 1: Validate and prepare data
|
|
90
|
+
logger.debug('Preparing dataset')
|
|
91
|
+
self._prep.validate_columns(raw_df, self.spec)
|
|
92
|
+
self.analysis_dataset = self._prep.prepare_dataset(raw_df, self.spec)
|
|
93
|
+
self.analysis_dataset = self._prep.build_keys(self.analysis_dataset, self.spec)
|
|
94
|
+
|
|
95
|
+
# Step 2: Use the provided ODS (detected at entry point on raw data)
|
|
96
|
+
logger.debug(f'Using ODS: {self.observed_design_state}')
|
|
97
|
+
self.raw_sds_characteristics = self._sds_registry.get_sds_characteristics(self.observed_design_state)
|
|
98
|
+
logger.debug(f'ODS {self.observed_design_state} - {self.raw_sds_characteristics["description"]}')
|
|
99
|
+
|
|
100
|
+
# Step 3: Compute structure stats once on tidy data (single source of truth)
|
|
101
|
+
# This enables R2 method selection and availability checks.
|
|
102
|
+
# The tidy structure may differ from the raw SDS — e.g., SDS 3 (partial
|
|
103
|
+
# replication) on raw data can become structurally equivalent to SDS 1
|
|
104
|
+
# (full replication) after NA rows are dropped.
|
|
105
|
+
self._compute_structure_stats()
|
|
106
|
+
|
|
107
|
+
# Step 3b: Compute Analytical Design State (ADS) on tidy data
|
|
108
|
+
self._compute_ads()
|
|
109
|
+
|
|
110
|
+
# Step 4: Calculate VAS residuals when we have both grouping AND time
|
|
111
|
+
# (need Ybar_k for factor effects and Ybar_t for time effects)
|
|
112
|
+
needs_residuals = self.spec.has_grouping and self.spec.has_time
|
|
113
|
+
|
|
114
|
+
if needs_residuals:
|
|
115
|
+
logger.debug('Calculating VAS residuals (R1-R5)')
|
|
116
|
+
|
|
117
|
+
# _compute_structure_stats() ran above; structure_stats is populated.
|
|
118
|
+
if self._structure_stats is None:
|
|
119
|
+
raise RuntimeError('_structure_stats not populated; _compute_structure_stats must run first')
|
|
120
|
+
r2_method = self._sds_registry.get_r2_method(self._structure_stats)
|
|
121
|
+
logger.debug(f'Using R2 method: {r2_method}')
|
|
122
|
+
|
|
123
|
+
self.analysis_dataset = calculate_vas_residuals(
|
|
124
|
+
self.analysis_dataset,
|
|
125
|
+
self.spec,
|
|
126
|
+
r2_method,
|
|
127
|
+
n_per_cell=self._n_per_cell,
|
|
128
|
+
ybar_kt=self._ybar_kt,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Calculate centered residuals
|
|
132
|
+
self._calculate_centered_residuals()
|
|
133
|
+
|
|
134
|
+
# Step 5: Calculate effects and interactions
|
|
135
|
+
# ADS drives method selection (not ODS)
|
|
136
|
+
logger.debug('Calculating effects and interactions')
|
|
137
|
+
self.effects = self._effects_calc.calculate_all_effects(self.analysis_dataset, self.spec)
|
|
138
|
+
self.interactions = self._effects_calc.calculate_interactions(
|
|
139
|
+
self.analysis_dataset, self.spec, self._ads_result.sds, effects=self.effects
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
logger.debug(
|
|
143
|
+
f'Skipping VAS residuals: requires both grouping and time. '
|
|
144
|
+
f'has_grouping={self.spec.has_grouping}, has_time={self.spec.has_time}'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Log final analysis summary (after all calculations are complete)
|
|
148
|
+
logger.debug(self.analysis_summary)
|
|
149
|
+
|
|
150
|
+
# =========================================================================
|
|
151
|
+
# Properties (for backward compatibility and convenience)
|
|
152
|
+
# =========================================================================
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def has_vas_residuals(self) -> bool:
|
|
156
|
+
"""Check if VAS residuals were calculated."""
|
|
157
|
+
return 'R1' in self.analysis_dataset.columns
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def structure_stats(self) -> StructureStats | None:
|
|
161
|
+
"""Get structure statistics (computed once during initialization)."""
|
|
162
|
+
return self._structure_stats
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def n_per_cell(self) -> pd.Series | None:
|
|
166
|
+
"""Get observation counts per cell_key (computed once during initialization)."""
|
|
167
|
+
return self._n_per_cell
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def tidy_structure_summary(self) -> dict | None:
|
|
171
|
+
"""
|
|
172
|
+
Summary of the tidy data structure after NA drops and preparation.
|
|
173
|
+
|
|
174
|
+
The tidy structure drives R2 method selection and may differ from
|
|
175
|
+
what the raw SDS (detected on uncleansed data) would predict.
|
|
176
|
+
For example, SDS 3 (partial replication) on raw data can become
|
|
177
|
+
structurally equivalent to full replication after tidying.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
dict or None
|
|
182
|
+
Dictionary with tidy structure details:
|
|
183
|
+
- n_cell_min: minimum observations per cell
|
|
184
|
+
- n_cell_max: maximum observations per cell
|
|
185
|
+
- K_obs: number of unique factor levels observed
|
|
186
|
+
- r2_method: 'exact', 'ma2', or 'hybrid'
|
|
187
|
+
Returns None if structure stats haven't been computed.
|
|
188
|
+
"""
|
|
189
|
+
if self._structure_stats is None:
|
|
190
|
+
return None
|
|
191
|
+
return {
|
|
192
|
+
'n_cell_min': self._structure_stats.n_cell_min,
|
|
193
|
+
'n_cell_max': self._structure_stats.n_cell_max,
|
|
194
|
+
'K_obs': self._structure_stats.K_obs,
|
|
195
|
+
'r2_method': self._sds_registry.get_r2_method(self._structure_stats),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def _compute_structure_stats(self) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Compute and cache structure statistics once.
|
|
201
|
+
|
|
202
|
+
This is the single source of truth for:
|
|
203
|
+
- n_per_cell: observation counts per cell_key
|
|
204
|
+
- structure_stats: StructureStats with min/max cell sizes
|
|
205
|
+
|
|
206
|
+
These drive R2 method selection and availability checks.
|
|
207
|
+
"""
|
|
208
|
+
df = self.analysis_dataset
|
|
209
|
+
y = self.spec.response_var
|
|
210
|
+
|
|
211
|
+
# Compute n_per_cell and cell means once
|
|
212
|
+
self._n_per_cell = df.groupby('cell_key', observed=True)[y].transform('size')
|
|
213
|
+
self._ybar_kt = df.groupby('cell_key', observed=True)[y].transform('mean')
|
|
214
|
+
|
|
215
|
+
# Handle edge case: empty data or all NaN
|
|
216
|
+
if len(self._n_per_cell) == 0 or self._n_per_cell.isna().all():
|
|
217
|
+
n_cell_min = 0
|
|
218
|
+
n_cell_max = 0
|
|
219
|
+
else:
|
|
220
|
+
n_cell_min = int(self._n_per_cell.min())
|
|
221
|
+
n_cell_max = int(self._n_per_cell.max())
|
|
222
|
+
|
|
223
|
+
# Compute structure stats
|
|
224
|
+
self._structure_stats = StructureStats(
|
|
225
|
+
has_grouping=bool(self.spec.rsg_vars),
|
|
226
|
+
has_order='obs_id' in df.columns,
|
|
227
|
+
n_cell_min=n_cell_min,
|
|
228
|
+
n_cell_max=n_cell_max,
|
|
229
|
+
K_obs=df['rsg_key'].nunique() if 'rsg_key' in df.columns else 0,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
r2_method = self._sds_registry.get_r2_method(self._structure_stats)
|
|
233
|
+
|
|
234
|
+
logger.debug(
|
|
235
|
+
f'Tidy structure: n_cell_min={n_cell_min}, '
|
|
236
|
+
f'n_cell_max={n_cell_max}, '
|
|
237
|
+
f'K_obs={self._structure_stats.K_obs}, '
|
|
238
|
+
f'effective R2 method: {r2_method}'
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _compute_ads(self) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Compute Analytical Design State (ADS) on tidy data.
|
|
244
|
+
|
|
245
|
+
The ADS reflects the structure of data that is fit for analysis,
|
|
246
|
+
after NA filtering and tidying. It may differ from the ODS when
|
|
247
|
+
tidying removes empty or singleton cells.
|
|
248
|
+
"""
|
|
249
|
+
df = self.analysis_dataset
|
|
250
|
+
y = self.spec.response_var
|
|
251
|
+
|
|
252
|
+
# Compute N_kt from tidy data grouped by cell_key
|
|
253
|
+
if 'cell_key' in df.columns and len(df) > 0:
|
|
254
|
+
tidy_nkt = df.groupby('cell_key', observed=True)[y].size()
|
|
255
|
+
else:
|
|
256
|
+
tidy_nkt = pd.Series(dtype=int)
|
|
257
|
+
|
|
258
|
+
# Guard: empty tidy data → ADS 0
|
|
259
|
+
if len(tidy_nkt) == 0:
|
|
260
|
+
self._ads_result = SDSResult(sds=0, reason=None, min_cell_size=0, n_empty_cells=0)
|
|
261
|
+
logger.debug('ADS 0: no valid observations after tidying')
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
has_empty_cells = (tidy_nkt == 0).any()
|
|
265
|
+
n_empty = int((tidy_nkt == 0).sum())
|
|
266
|
+
valid_nkt = tidy_nkt[tidy_nkt > 0]
|
|
267
|
+
min_cell_size = int(valid_nkt.min()) if len(valid_nkt) > 0 else 0
|
|
268
|
+
|
|
269
|
+
sds, reason = self._sds_registry._classify_by_nkt(tidy_nkt, has_empty_cells)
|
|
270
|
+
self._ads_result = SDSResult(sds=sds, min_cell_size=min_cell_size, reason=reason, n_empty_cells=n_empty)
|
|
271
|
+
|
|
272
|
+
if self._ads_result.sds != self.observed_design_state:
|
|
273
|
+
logger.info(
|
|
274
|
+
f'Design state drift: ODS {self.observed_design_state} → '
|
|
275
|
+
f'ADS {self._ads_result.sds} ({self._ads_result.reason})'
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
logger.debug(f'ADS {self._ads_result.sds} ({self._ads_result.reason})')
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def analytical_design_state(self) -> SDSResult:
|
|
282
|
+
"""
|
|
283
|
+
Analytical Design State (ADS) computed on tidy data.
|
|
284
|
+
|
|
285
|
+
The ADS drives analysis decisions: valid charts, residual
|
|
286
|
+
availability, R2 method, and interaction method selection.
|
|
287
|
+
"""
|
|
288
|
+
if self._ads_result is None:
|
|
289
|
+
raise RuntimeError('_ads_result not populated; __init__ must complete first')
|
|
290
|
+
return self._ads_result
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def analysis_summary(self) -> dict:
|
|
294
|
+
"""
|
|
295
|
+
Get comprehensive summary of the analysis dataset.
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
dict
|
|
300
|
+
Dictionary with analysis metadata including:
|
|
301
|
+
- sds: Detected sampling design state
|
|
302
|
+
- sds_info: Full SDS characteristics
|
|
303
|
+
- has_vas: Whether VAS residuals calculated
|
|
304
|
+
- n_observations: Total observations
|
|
305
|
+
- analysis_type: Type of analysis being performed
|
|
306
|
+
"""
|
|
307
|
+
summary = {
|
|
308
|
+
'observed_sds': self.observed_design_state,
|
|
309
|
+
'analytical_sds': self._ads_result.sds if self._ads_result else self.observed_design_state,
|
|
310
|
+
'sds_info': self.raw_sds_characteristics,
|
|
311
|
+
'has_vas_residuals': self.has_vas_residuals,
|
|
312
|
+
'n_observations': len(self.analysis_dataset),
|
|
313
|
+
}
|
|
314
|
+
return summary
|
|
315
|
+
|
|
316
|
+
# =========================================================================
|
|
317
|
+
# Centered Residuals
|
|
318
|
+
# =========================================================================
|
|
319
|
+
|
|
320
|
+
def _calculate_centered_residuals(self):
|
|
321
|
+
"""
|
|
322
|
+
Calculate centered residuals (Rbar and RCR values).
|
|
323
|
+
|
|
324
|
+
These calculations center residuals by their means and reconstruct
|
|
325
|
+
Y from variance components to verify decomposition correctness.
|
|
326
|
+
|
|
327
|
+
Note: This method intentionally mutates self.analysis_dataset in-place
|
|
328
|
+
(adding columns directly to the DataFrame). This differs from
|
|
329
|
+
calculate_vas_residuals() which returns a new DataFrame.
|
|
330
|
+
The mutation is safe here because this is called once
|
|
331
|
+
during __init__ as part of the analysis pipeline, and the columns
|
|
332
|
+
added (Rbar_kt, Rbar_k, Rbar_t, RCR1-RCR5) are outputs of the
|
|
333
|
+
analysis that become part of the dataset.
|
|
334
|
+
|
|
335
|
+
Calculates:
|
|
336
|
+
- Rbar_kt: Mean of R1 per cell (factor x time)
|
|
337
|
+
- Rbar_k: Mean of R1 per factor level
|
|
338
|
+
- Rbar_t: Mean of R1 per time point
|
|
339
|
+
- RCR1-RCR5: Reconstructed Y values from centered residuals
|
|
340
|
+
"""
|
|
341
|
+
if not self.spec.has_grouping:
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
df = self.analysis_dataset
|
|
345
|
+
|
|
346
|
+
# Calculate centered residual means
|
|
347
|
+
rsg_time_groups = df.groupby([self.spec.rsg_var_name, self.spec.time_var], observed=True)
|
|
348
|
+
df['Rbar_kt'] = rsg_time_groups['R1'].transform('mean')
|
|
349
|
+
df['Rbar_k'] = df.groupby([self.spec.rsg_var_name], observed=True)['R1'].transform('mean')
|
|
350
|
+
df['Rbar_t'] = df.groupby([self.spec.time_var], observed=True)['R1'].transform('mean')
|
|
351
|
+
|
|
352
|
+
# Calculate RCR (Reconstructed Centered Residuals)
|
|
353
|
+
# These verify that Y can be reconstructed from components
|
|
354
|
+
df['RCR1'] = df['Ybar'] + df['R1'] # Y = Ybar + R1
|
|
355
|
+
df['RCR2'] = df['Ybar_kt'] + df['R2'] # Y = Ybar_kt + R2
|
|
356
|
+
# Y = (Ybar_k + Ybar_t - Ybar) + R3
|
|
357
|
+
df['RCR3'] = (df['Ybar_k'] + df['Ybar_t'] - df['Ybar']) + df['R3']
|
|
358
|
+
# Y = (Ybar + Ybar_kt - Ybar_t) + R4
|
|
359
|
+
df['RCR4'] = (df['Ybar'] + df['Ybar_kt'] - df['Ybar_t']) + df['R4']
|
|
360
|
+
# Y = (Ybar + Ybar_kt - Ybar_k) + R5
|
|
361
|
+
df['RCR5'] = (df['Ybar'] + df['Ybar_kt'] - df['Ybar_k']) + df['R5']
|