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
processbehavior/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VAS (Variance Analysis System) residual calculations for process behavior analysis.
|
|
3
|
+
|
|
4
|
+
This module calculates the Bishop VAS residuals (R1-R5) that decompose
|
|
5
|
+
total variation into interpretable components:
|
|
6
|
+
|
|
7
|
+
- R1: Total deviation from grand mean (pure algebra)
|
|
8
|
+
- R2: Within-cell (unexplained) variation (structure-dependent)
|
|
9
|
+
- R3: Interaction effects (factor × time) (pure algebra)
|
|
10
|
+
- R4: Time effects + unexplained (pure algebra given R2)
|
|
11
|
+
- R5: Factor effects + unexplained (pure algebra given R2)
|
|
12
|
+
|
|
13
|
+
R2 is the ONLY residual whose calculation varies by structure:
|
|
14
|
+
- exact (state 1): R2 = Y - Ȳ_kt (Eq 59), when all cells have n >= 2
|
|
15
|
+
- ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9),
|
|
16
|
+
when any cell has n = 1 — applied to ALL observations across the
|
|
17
|
+
entire sorted stream, no grouping
|
|
18
|
+
|
|
19
|
+
All other residuals (R1, R3, R4, R5) are pure algebraic transformations
|
|
20
|
+
that don't depend on structure once the means are defined.
|
|
21
|
+
|
|
22
|
+
Module structure:
|
|
23
|
+
- Pure mean functions: calculate_grand_mean, calculate_factor_means,
|
|
24
|
+
calculate_time_means, calculate_cell_means
|
|
25
|
+
- Pure residual functions: calculate_r1_residual, calculate_r3_residual,
|
|
26
|
+
calculate_r4_residual, calculate_r5_residual
|
|
27
|
+
- Consolidated R2: calculate_r2(df, y, r2_method, n_per_cell)
|
|
28
|
+
- Orchestration: calculate_vas_residuals(df, spec, r2_method, ...)
|
|
29
|
+
- Request residuals: resolve_r6_groupby, calculate_r6_residuals — R6/RCR6 are
|
|
30
|
+
computed per execute() request from that request's by=, never stored on the
|
|
31
|
+
study-level frame (see spc_constants.REQUEST_RESIDUALS)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
|
|
40
|
+
import pandas as pd
|
|
41
|
+
|
|
42
|
+
from .exceptions import ValidationError
|
|
43
|
+
from .sds_detector import R2Method
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from .formulation_spec import FormulationSpec
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ============================================================================
|
|
52
|
+
# Pure Functions: Means (Ybar calculations)
|
|
53
|
+
# ============================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def calculate_grand_mean(df: pd.DataFrame, response_var: str) -> float:
|
|
57
|
+
"""
|
|
58
|
+
Calculate grand mean (Ȳ) - average of all observations.
|
|
59
|
+
|
|
60
|
+
This is the baseline for R1 residuals.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
df : DataFrame
|
|
65
|
+
Input data
|
|
66
|
+
response_var : str
|
|
67
|
+
Name of response variable column
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
float
|
|
72
|
+
Grand mean (Ȳ)
|
|
73
|
+
|
|
74
|
+
Examples
|
|
75
|
+
--------
|
|
76
|
+
>>> df = pd.DataFrame({'weight': [10.0, 10.5, 9.5, 10.0]})
|
|
77
|
+
>>> calculate_grand_mean(df, 'weight')
|
|
78
|
+
10.0
|
|
79
|
+
"""
|
|
80
|
+
return df[response_var].mean()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def calculate_factor_means(df: pd.DataFrame, response_var: str, rsg_var_name: str) -> pd.Series:
|
|
84
|
+
"""
|
|
85
|
+
Calculate factor-level means (Ȳ_k) - average for each factor level.
|
|
86
|
+
|
|
87
|
+
Broadcasts the mean for each factor level to all rows in that level.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
df : DataFrame
|
|
92
|
+
Input data
|
|
93
|
+
response_var : str
|
|
94
|
+
Name of response variable
|
|
95
|
+
rsg_var_name : str
|
|
96
|
+
Name of the composite rational subgroup column (e.g., 'rsg')
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
Series
|
|
101
|
+
Factor means, same length as df (broadcast to rows)
|
|
102
|
+
|
|
103
|
+
Examples
|
|
104
|
+
--------
|
|
105
|
+
>>> df = pd.DataFrame({
|
|
106
|
+
... 'lane': ['A', 'A', 'B', 'B'],
|
|
107
|
+
... 'weight': [10.0, 10.5, 9.0, 9.5]
|
|
108
|
+
... })
|
|
109
|
+
>>> calculate_factor_means(df, 'weight', 'lane')
|
|
110
|
+
0 10.25
|
|
111
|
+
1 10.25
|
|
112
|
+
2 9.25
|
|
113
|
+
3 9.25
|
|
114
|
+
Name: weight, dtype: float64
|
|
115
|
+
"""
|
|
116
|
+
return df.groupby(rsg_var_name, observed=True)[response_var].transform('mean')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def calculate_time_means(df: pd.DataFrame, response_var: str, time_var: str) -> pd.Series:
|
|
120
|
+
"""
|
|
121
|
+
Calculate time-level means (Ȳ_t) - average for each time point.
|
|
122
|
+
|
|
123
|
+
Broadcasts the mean for each time point to all rows at that time.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
df : DataFrame
|
|
128
|
+
Input data
|
|
129
|
+
response_var : str
|
|
130
|
+
Name of response variable
|
|
131
|
+
time_var : str
|
|
132
|
+
Name of time variable
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
Series
|
|
137
|
+
Time means, same length as df (broadcast to rows)
|
|
138
|
+
|
|
139
|
+
Examples
|
|
140
|
+
--------
|
|
141
|
+
>>> df = pd.DataFrame({
|
|
142
|
+
... 'pull': [1, 1, 2, 2],
|
|
143
|
+
... 'weight': [10.0, 10.5, 9.0, 9.5]
|
|
144
|
+
... })
|
|
145
|
+
>>> calculate_time_means(df, 'weight', 'pull')
|
|
146
|
+
0 10.25
|
|
147
|
+
1 10.25
|
|
148
|
+
2 9.25
|
|
149
|
+
3 9.25
|
|
150
|
+
Name: weight, dtype: float64
|
|
151
|
+
"""
|
|
152
|
+
return df.groupby(time_var, observed=True)[response_var].transform('mean')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def calculate_cell_means(df: pd.DataFrame, response_var: str, rsg_var_name: str, time_var: str) -> pd.Series:
|
|
156
|
+
"""
|
|
157
|
+
Calculate cell means (Ȳ_kt) - average for each (factor × time) cell.
|
|
158
|
+
|
|
159
|
+
Broadcasts the mean for each cell to all rows in that cell.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
df : DataFrame
|
|
164
|
+
Input data
|
|
165
|
+
response_var : str
|
|
166
|
+
Name of response variable
|
|
167
|
+
rsg_var_name : str
|
|
168
|
+
Name of the composite rational subgroup column (e.g., 'rsg')
|
|
169
|
+
time_var : str
|
|
170
|
+
Name of time variable
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
Series
|
|
175
|
+
Cell means, same length as df (broadcast to rows)
|
|
176
|
+
|
|
177
|
+
Examples
|
|
178
|
+
--------
|
|
179
|
+
>>> df = pd.DataFrame({
|
|
180
|
+
... 'lane': ['A', 'A', 'B', 'B'],
|
|
181
|
+
... 'pull': [1, 1, 2, 2],
|
|
182
|
+
... 'weight': [10.0, 10.5, 9.0, 9.5]
|
|
183
|
+
... })
|
|
184
|
+
>>> calculate_cell_means(df, 'weight', 'lane', 'pull')
|
|
185
|
+
0 10.25
|
|
186
|
+
1 10.25
|
|
187
|
+
2 9.25
|
|
188
|
+
3 9.25
|
|
189
|
+
Name: weight, dtype: float64
|
|
190
|
+
"""
|
|
191
|
+
return df.groupby([rsg_var_name, time_var], observed=True)[response_var].transform('mean')
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ============================================================================
|
|
195
|
+
# Pure Functions: Residuals (R1, R3, R4, R5)
|
|
196
|
+
# ============================================================================
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def calculate_r1_residual(df: pd.DataFrame, response_var: str, grand_mean: float) -> pd.Series:
|
|
200
|
+
"""
|
|
201
|
+
Calculate R1 residual: total deviation from grand mean.
|
|
202
|
+
|
|
203
|
+
R1 = Y - Ȳ (Bishop Equation 56)
|
|
204
|
+
|
|
205
|
+
R1 represents the total variation of each observation around the
|
|
206
|
+
overall average. It's the foundation for all other residuals.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
df : DataFrame
|
|
211
|
+
Input data
|
|
212
|
+
response_var : str
|
|
213
|
+
Name of response variable column
|
|
214
|
+
grand_mean : float
|
|
215
|
+
Grand mean (Ȳ)
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
Series
|
|
220
|
+
R1 residuals, same length as df
|
|
221
|
+
|
|
222
|
+
Examples
|
|
223
|
+
--------
|
|
224
|
+
>>> df = pd.DataFrame({'weight': [10.1, 10.3, 9.9]})
|
|
225
|
+
>>> r1 = calculate_r1_residual(df, 'weight', 10.1)
|
|
226
|
+
>>> r1.tolist()
|
|
227
|
+
[0.0, 0.19999999999999929, -0.20000000000000107]
|
|
228
|
+
|
|
229
|
+
Notes
|
|
230
|
+
-----
|
|
231
|
+
R1 is the simplest residual - it just shows how far each observation
|
|
232
|
+
is from the overall average. The sum of all R1 values is always zero.
|
|
233
|
+
"""
|
|
234
|
+
return df[response_var] - grand_mean
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def calculate_r3_residual(
|
|
238
|
+
df: pd.DataFrame, response_var: str, factor_means: pd.Series, time_means: pd.Series, grand_mean: float
|
|
239
|
+
) -> pd.Series:
|
|
240
|
+
"""
|
|
241
|
+
Calculate R3 residual: interaction effects (factor × time).
|
|
242
|
+
|
|
243
|
+
R3 = Y - Ȳ_k - Ȳ_t + Ȳ (Bishop Equation 66)
|
|
244
|
+
|
|
245
|
+
R3 captures the interaction between factors and time. It represents
|
|
246
|
+
variation that can't be explained by factor effects or time effects alone.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
df : DataFrame
|
|
251
|
+
Input data
|
|
252
|
+
response_var : str
|
|
253
|
+
Name of response variable
|
|
254
|
+
factor_means : Series
|
|
255
|
+
Factor means (Ȳ_k)
|
|
256
|
+
time_means : Series
|
|
257
|
+
Time means (Ȳ_t)
|
|
258
|
+
grand_mean : float
|
|
259
|
+
Grand mean (Ȳ)
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
Series
|
|
264
|
+
R3 residuals
|
|
265
|
+
|
|
266
|
+
Examples
|
|
267
|
+
--------
|
|
268
|
+
>>> df = pd.DataFrame({'y': [10.0, 10.5, 9.0, 9.5]})
|
|
269
|
+
>>> factor_means = pd.Series([10.25, 10.25, 9.25, 9.25])
|
|
270
|
+
>>> time_means = pd.Series([9.75, 9.75, 10.0, 10.0])
|
|
271
|
+
>>> r3 = calculate_r3_residual(df, 'y', factor_means, time_means, 9.875)
|
|
272
|
+
"""
|
|
273
|
+
return df[response_var] - factor_means - time_means + grand_mean
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def calculate_r4_residual(time_means: pd.Series, grand_mean: float, r2: pd.Series) -> pd.Series:
|
|
277
|
+
"""
|
|
278
|
+
Calculate R4 residual: time effects + unexplained.
|
|
279
|
+
|
|
280
|
+
R4 = Ȳ_t - Ȳ + R2 (Bishop Equation 72)
|
|
281
|
+
|
|
282
|
+
R4 represents time effects plus within-cell variation. Used to
|
|
283
|
+
assess if time contributes meaningful variation.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
time_means : Series
|
|
288
|
+
Time means (Ȳ_t)
|
|
289
|
+
grand_mean : float
|
|
290
|
+
Grand mean (Ȳ)
|
|
291
|
+
r2 : Series
|
|
292
|
+
R2 residuals
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
Series
|
|
297
|
+
R4 residuals
|
|
298
|
+
"""
|
|
299
|
+
return time_means - grand_mean + r2
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def calculate_r5_residual(factor_means: pd.Series, grand_mean: float, r2: pd.Series) -> pd.Series:
|
|
303
|
+
"""
|
|
304
|
+
Calculate R5 residual: factor effects + unexplained.
|
|
305
|
+
|
|
306
|
+
R5 = Ȳ_k - Ȳ + R2 (Bishop Equation 75)
|
|
307
|
+
|
|
308
|
+
R5 represents factor effects plus within-cell variation. Used to
|
|
309
|
+
assess if factors contribute meaningful variation and to calculate
|
|
310
|
+
main effects.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
factor_means : Series
|
|
315
|
+
Factor means (Ȳ_k)
|
|
316
|
+
grand_mean : float
|
|
317
|
+
Grand mean (Ȳ)
|
|
318
|
+
r2 : Series
|
|
319
|
+
R2 residuals
|
|
320
|
+
|
|
321
|
+
Returns
|
|
322
|
+
-------
|
|
323
|
+
Series
|
|
324
|
+
R5 residuals
|
|
325
|
+
"""
|
|
326
|
+
return factor_means - grand_mean + r2
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ============================================================================
|
|
330
|
+
# R2: Structure-dependent residual (consolidated)
|
|
331
|
+
# ============================================================================
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.Series | None = None) -> pd.Series:
|
|
335
|
+
"""
|
|
336
|
+
Calculate R2 residual using the specified method.
|
|
337
|
+
|
|
338
|
+
R2 is the ONLY structure-dependent residual. The method is determined by
|
|
339
|
+
observed cell sizes (via SDSRegistry.get_r2_method()):
|
|
340
|
+
|
|
341
|
+
- exact (state 1): R2 = Y - Ȳ_kt (Eq 59), all cells have n >= 2
|
|
342
|
+
- ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9),
|
|
343
|
+
any cell has n = 1
|
|
344
|
+
|
|
345
|
+
When any cell has n=1, MA2 is applied to ALL observations across the
|
|
346
|
+
entire canonical-sorted stream — no grouping by rsg_key, no hybrid
|
|
347
|
+
per-cell selection. Bishop Eq 13.7-13.9 specify j=2,...,J with no
|
|
348
|
+
grouping; only j=1 gets R2=0.
|
|
349
|
+
|
|
350
|
+
Parameters
|
|
351
|
+
----------
|
|
352
|
+
df : pd.DataFrame
|
|
353
|
+
Input data with 'cell_key', 'sort_key', 'Ybar_kt' columns
|
|
354
|
+
y : str
|
|
355
|
+
Name of response variable
|
|
356
|
+
r2_method : R2Method
|
|
357
|
+
'exact', 'ma2', or 'hybrid' — retained for API compatibility.
|
|
358
|
+
Branching is driven by n_per_cell.
|
|
359
|
+
n_per_cell : pd.Series, optional
|
|
360
|
+
Pre-computed observations per cell. Pass from ADS to avoid recomputation.
|
|
361
|
+
|
|
362
|
+
Returns
|
|
363
|
+
-------
|
|
364
|
+
pd.Series
|
|
365
|
+
R2 residuals with name="R2"
|
|
366
|
+
"""
|
|
367
|
+
if n_per_cell is None:
|
|
368
|
+
n_per_cell = df.groupby('cell_key', observed=True)[y].transform('size')
|
|
369
|
+
|
|
370
|
+
# State 1: all cells replicated → exact (Eq 59)
|
|
371
|
+
if (n_per_cell >= 2).all():
|
|
372
|
+
return pd.Series(df[y] - df['Ybar_kt'], index=df.index, name='R2')
|
|
373
|
+
|
|
374
|
+
# States 2 & 3: any singletons → MA2 for ALL observations (Eq 13.8-13.9)
|
|
375
|
+
# MA2 runs across the entire canonical-sorted stream — no grouping.
|
|
376
|
+
# j=1 has no predecessor → R2 is NaN (Bishop leaves it blank).
|
|
377
|
+
df_sorted = df.sort_values('sort_key')
|
|
378
|
+
y_sorted = df_sorted[y]
|
|
379
|
+
r2 = (y_sorted - y_sorted.shift(1)) / 2
|
|
380
|
+
return pd.Series(r2.loc[df.index], index=df.index, name='R2')
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ============================================================================
|
|
384
|
+
# Orchestration: calculate_vas_residuals
|
|
385
|
+
# ============================================================================
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _validate_prerequisites(df: pd.DataFrame) -> None:
|
|
389
|
+
"""
|
|
390
|
+
Ensure required keys exist before computing residuals.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
df : pd.DataFrame
|
|
395
|
+
Input data
|
|
396
|
+
|
|
397
|
+
Raises
|
|
398
|
+
------
|
|
399
|
+
ValueError
|
|
400
|
+
If required columns are missing
|
|
401
|
+
"""
|
|
402
|
+
required = {'rsg_key', 'obs_id', 'cell_key'}
|
|
403
|
+
missing = required - set(df.columns)
|
|
404
|
+
if missing:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f'Missing required columns for VAS residuals: {missing}. Ensure data_preparation.build_keys() was called.'
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def calculate_vas_residuals(
|
|
411
|
+
df: pd.DataFrame,
|
|
412
|
+
spec: FormulationSpec,
|
|
413
|
+
r2_method: R2Method,
|
|
414
|
+
n_per_cell: pd.Series | None = None,
|
|
415
|
+
ybar_kt: pd.Series | None = None,
|
|
416
|
+
) -> pd.DataFrame:
|
|
417
|
+
"""
|
|
418
|
+
Calculate all VAS residuals using structure-driven R2 method.
|
|
419
|
+
|
|
420
|
+
This is the primary entry point. The r2_method should be determined by
|
|
421
|
+
SDSRegistry.get_r2_method() based on observed cell sizes.
|
|
422
|
+
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
df : pd.DataFrame
|
|
426
|
+
Input data (prepared, with keys: rsg_key, obs_id, cell_key)
|
|
427
|
+
spec : FormulationSpec
|
|
428
|
+
Analysis specification
|
|
429
|
+
r2_method : R2Method
|
|
430
|
+
'exact', 'ma2', or 'hybrid' - determines R2 calculation
|
|
431
|
+
n_per_cell : pd.Series, optional
|
|
432
|
+
Pre-computed observations per cell. Pass from ADS to avoid recomputation.
|
|
433
|
+
ybar_kt : pd.Series, optional
|
|
434
|
+
Pre-computed cell means (Ȳ_kt). Pass from ADS to avoid recomputation.
|
|
435
|
+
|
|
436
|
+
Returns
|
|
437
|
+
-------
|
|
438
|
+
pd.DataFrame
|
|
439
|
+
Input data with added columns:
|
|
440
|
+
- Ybar, Ybar_k, Ybar_t, Ybar_kt (means)
|
|
441
|
+
- R1, R2, R3, R4, R5 (residuals)
|
|
442
|
+
|
|
443
|
+
Raises
|
|
444
|
+
------
|
|
445
|
+
ValueError
|
|
446
|
+
If required columns are missing or no grouping structure
|
|
447
|
+
"""
|
|
448
|
+
_validate_prerequisites(df)
|
|
449
|
+
|
|
450
|
+
if not spec.has_grouping:
|
|
451
|
+
raise ValueError('VAS residuals require grouping structure.\nNo grouping variables specified in spec.')
|
|
452
|
+
|
|
453
|
+
out = df.copy()
|
|
454
|
+
y = spec.response_var
|
|
455
|
+
|
|
456
|
+
# Step 1: Compute cell means FIRST (foundation for everything)
|
|
457
|
+
logger.debug('Calculating means (Ybar_kt, then unweighted Ybar, Ybar_k, Ybar_t)')
|
|
458
|
+
out['Ybar_kt'] = ybar_kt if ybar_kt is not None else calculate_cell_means(out, y, spec.rsg_var_name, spec.time_var)
|
|
459
|
+
|
|
460
|
+
# Step 2: Derive marginal means from cell means (unweighted means analysis)
|
|
461
|
+
# Bishop VAS uses mean of cell means, giving each experimental
|
|
462
|
+
# condition equal weight regardless of sample size within cells.
|
|
463
|
+
cell_means_unique = out.groupby([spec.rsg_var_name, spec.time_var], observed=True)['Ybar_kt'].first()
|
|
464
|
+
|
|
465
|
+
grand_mean = cell_means_unique.mean()
|
|
466
|
+
out['Ybar'] = grand_mean
|
|
467
|
+
|
|
468
|
+
factor_means = cell_means_unique.groupby(level=0, observed=True).mean()
|
|
469
|
+
out['Ybar_k'] = out[spec.rsg_var_name].map(factor_means).astype(float)
|
|
470
|
+
|
|
471
|
+
time_means = cell_means_unique.groupby(level=1, observed=True).mean()
|
|
472
|
+
out['Ybar_t'] = out[spec.time_var].map(time_means).astype(float)
|
|
473
|
+
|
|
474
|
+
# Step 3: Calculate R1 (pure algebra)
|
|
475
|
+
logger.debug('Calculating R1 residual')
|
|
476
|
+
out['R1'] = calculate_r1_residual(out, y, grand_mean)
|
|
477
|
+
|
|
478
|
+
# Step 4: Calculate R2 (structure-dependent)
|
|
479
|
+
logger.debug(f'Calculating R2 residual (method: {r2_method})')
|
|
480
|
+
out['R2'] = calculate_r2(out, y, r2_method, n_per_cell=n_per_cell)
|
|
481
|
+
|
|
482
|
+
# Step 5: Calculate R3 (interaction effects)
|
|
483
|
+
# Unified formula: Ybar_kt - Ybar_k - Ybar_t + Ybar + R2
|
|
484
|
+
# For exact (state 1): R2 = Y - Ybar_kt, so this simplifies to
|
|
485
|
+
# Y - Ybar_k - Ybar_t + Ybar (algebraically identical to old formula).
|
|
486
|
+
# For MA2 (states 2-3): adds R2 to each row per Bishop.
|
|
487
|
+
logger.debug('Calculating R3 residual')
|
|
488
|
+
out['R3'] = out['Ybar_kt'] - out['Ybar_k'] - out['Ybar_t'] + grand_mean + out['R2']
|
|
489
|
+
|
|
490
|
+
# Step 6: Calculate R4 (pure algebra given R2)
|
|
491
|
+
logger.debug('Calculating R4 residual')
|
|
492
|
+
out['R4'] = calculate_r4_residual(out['Ybar_t'], grand_mean, out['R2'])
|
|
493
|
+
|
|
494
|
+
# Step 7: Calculate R5 (pure algebra given R2)
|
|
495
|
+
logger.debug('Calculating R5 residual')
|
|
496
|
+
out['R5'] = calculate_r5_residual(out['Ybar_k'], grand_mean, out['R2'])
|
|
497
|
+
|
|
498
|
+
logger.debug('VAS residuals calculated successfully')
|
|
499
|
+
return out
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ============================================================================
|
|
503
|
+
# Request Residuals: R6 / RCR6 (computed per execute() request, never stored)
|
|
504
|
+
# ============================================================================
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def resolve_r6_groupby(by: Sequence[str] | None, factors: Sequence[str]) -> str | list[str]:
|
|
508
|
+
"""Resolve which factor(s) an R6 request groups on, or raise the reason it cannot.
|
|
509
|
+
|
|
510
|
+
One rule, two callers: ``Study._validate_execute_request`` calls this for its
|
|
511
|
+
ValidationErrors (validation stays pure — no frame is touched), and
|
|
512
|
+
``Analysis._build_request_frame`` calls it again to compute. They cannot drift.
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
by : Sequence[str] | None
|
|
517
|
+
The request's ``by=`` (list or ChartRequest tuple).
|
|
518
|
+
factors : Sequence[str]
|
|
519
|
+
The study's factors (``spec.rsg_vars_list``).
|
|
520
|
+
|
|
521
|
+
Returns
|
|
522
|
+
-------
|
|
523
|
+
str | list[str]
|
|
524
|
+
A single factor name, or the list of factors when ``by`` names several —
|
|
525
|
+
the exact shape ``DataFrame.groupby`` receives.
|
|
526
|
+
"""
|
|
527
|
+
if not factors:
|
|
528
|
+
raise ValidationError('R6 requires factors. No factors defined in this study.')
|
|
529
|
+
|
|
530
|
+
# Determine which factor(s) from by
|
|
531
|
+
if by is not None:
|
|
532
|
+
by_factors = [b for b in by if b in factors]
|
|
533
|
+
if not by_factors:
|
|
534
|
+
raise ValidationError(
|
|
535
|
+
f'R6 requires at least one factor in by=.\n'
|
|
536
|
+
f'Available factors: {factors}\n'
|
|
537
|
+
f"Example: study.execute(chart='Xbar', value='R6', by=['{factors[0]}'])"
|
|
538
|
+
)
|
|
539
|
+
return by_factors if len(by_factors) > 1 else by_factors[0]
|
|
540
|
+
if len(factors) == 1:
|
|
541
|
+
return factors[0]
|
|
542
|
+
raise ValidationError(
|
|
543
|
+
f'R6 requires by=[factor(s)] to specify which factor(s).\n'
|
|
544
|
+
f'Available factors: {factors}\n'
|
|
545
|
+
f"Example: study.execute(chart='Xbar', value='R6', by=['{factors[0]}'])"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def calculate_r6_residuals(df: pd.DataFrame, groupby_key: str | list[str], recentered: bool) -> pd.DataFrame:
|
|
550
|
+
"""Return a new frame carrying R6 (and RCR6 when recentered). Never mutates ``df``.
|
|
551
|
+
|
|
552
|
+
R6 = α_i + R2 where α_i = mean(R5 | factor level(s)).
|
|
553
|
+
|
|
554
|
+
Bit-identity constraints — this math moved verbatim from the old
|
|
555
|
+
``Study._compute_r6`` and must keep producing identical floats:
|
|
556
|
+
|
|
557
|
+
- ``groupby`` keeps its default kwargs (no ``observed=``, no ``dropna=``) to
|
|
558
|
+
preserve row alignment and values exactly.
|
|
559
|
+
- RCR6 is ``Ybar + alpha + R2`` evaluated left-to-right. Do not rewrite it as
|
|
560
|
+
``Ybar + R6``: float addition is non-associative, so the two differ in the
|
|
561
|
+
last ulp.
|
|
562
|
+
|
|
563
|
+
A recentered request carries **both** columns: the mR-of-RCR6 path reads the
|
|
564
|
+
plain R6 column (``_resolve_mr_source_column('RCR6') == 'R6'``).
|
|
565
|
+
"""
|
|
566
|
+
df = df.copy()
|
|
567
|
+
alpha = df.groupby(groupby_key)['R5'].transform('mean')
|
|
568
|
+
|
|
569
|
+
df['R6'] = alpha + df['R2']
|
|
570
|
+
if recentered:
|
|
571
|
+
df['RCR6'] = df['Ybar'] + alpha + df['R2']
|
|
572
|
+
return df
|