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,603 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Effects and interactions calculator for VAS (Variance Analysis System).
|
|
3
|
+
|
|
4
|
+
This module calculates:
|
|
5
|
+
- Main effects for each factor (mean of R5 per level)
|
|
6
|
+
- Time effects (mean of R1 per time point)
|
|
7
|
+
- Interaction effects (factor × time, factor × factor)
|
|
8
|
+
- Main effect scores (R2 + Main Effect for each factor)
|
|
9
|
+
|
|
10
|
+
These calculations help identify which factors and time periods contribute
|
|
11
|
+
most to process variation.
|
|
12
|
+
|
|
13
|
+
Follows the Pythonic Hadley philosophy:
|
|
14
|
+
- Pure functions where possible
|
|
15
|
+
- Clear, descriptive names
|
|
16
|
+
- Comprehensive documentation
|
|
17
|
+
- Type hints everywhere
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from .exceptions import ValidationError
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .formulation_spec import FormulationSpec
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ============================================================================
|
|
36
|
+
# Pure Functions: Main Effects
|
|
37
|
+
# ============================================================================
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def calculate_factor_main_effects(df: pd.DataFrame, factor: str) -> pd.DataFrame:
|
|
41
|
+
"""
|
|
42
|
+
Calculate main effect for a single factor.
|
|
43
|
+
|
|
44
|
+
Main Effect = mean(R5) per factor level
|
|
45
|
+
|
|
46
|
+
The main effect shows the average impact of each factor level on
|
|
47
|
+
the response, after accounting for within-cell variation.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
df : DataFrame
|
|
52
|
+
Data with R5 column
|
|
53
|
+
factor : str
|
|
54
|
+
Factor column name
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
DataFrame
|
|
59
|
+
Two columns: [factor, 'Main_Effect']
|
|
60
|
+
One row per factor level
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> df = pd.DataFrame({
|
|
65
|
+
... 'lane': ['A', 'A', 'B', 'B'],
|
|
66
|
+
... 'R5': [0.2, 0.3, -0.2, -0.3]
|
|
67
|
+
... })
|
|
68
|
+
>>> me = calculate_factor_main_effects(df, 'lane')
|
|
69
|
+
>>> me
|
|
70
|
+
lane Main_Effect
|
|
71
|
+
0 A 0.25
|
|
72
|
+
1 B -0.25
|
|
73
|
+
|
|
74
|
+
Notes
|
|
75
|
+
-----
|
|
76
|
+
Large positive main effects indicate factor levels that consistently
|
|
77
|
+
produce higher values. Large negative effects indicate lower values.
|
|
78
|
+
"""
|
|
79
|
+
if 'R5' not in df.columns:
|
|
80
|
+
raise ValidationError(
|
|
81
|
+
f'Cannot calculate main effects - R5 column missing.\n'
|
|
82
|
+
f'Available columns: {df.columns.tolist()}\n'
|
|
83
|
+
f'Fix: Calculate VAS residuals first'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if factor not in df.columns:
|
|
87
|
+
raise ValidationError(f"Factor '{factor}' not found in data.\nAvailable columns: {df.columns.tolist()}")
|
|
88
|
+
|
|
89
|
+
me = df.groupby(factor, sort=False, observed=True)['R5'].mean().rename('Main_Effect').reset_index()
|
|
90
|
+
|
|
91
|
+
# Validate uniqueness
|
|
92
|
+
if me.duplicated(subset=[factor]).any():
|
|
93
|
+
raise ValidationError(f"Duplicate levels found for factor '{factor}'")
|
|
94
|
+
|
|
95
|
+
return me[[factor, 'Main_Effect']]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def calculate_time_main_effects(df: pd.DataFrame, time_var: str) -> pd.DataFrame:
|
|
99
|
+
"""
|
|
100
|
+
Calculate main effect for time.
|
|
101
|
+
|
|
102
|
+
Time Effect = mean(R4) per time point
|
|
103
|
+
|
|
104
|
+
R4 = Ȳ_t - Ȳ + R2 preserves the time effect plus within-cell noise,
|
|
105
|
+
so mean(R4) per time point gives the time main effect (analogous to
|
|
106
|
+
mean(R5) per factor level for factor main effects).
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
df : DataFrame
|
|
111
|
+
Data with R4 column
|
|
112
|
+
time_var : str
|
|
113
|
+
Time variable name
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
DataFrame
|
|
118
|
+
Two columns: [time_var, 'PT_ME']
|
|
119
|
+
One row per time point
|
|
120
|
+
|
|
121
|
+
Examples
|
|
122
|
+
--------
|
|
123
|
+
>>> df = pd.DataFrame({
|
|
124
|
+
... 'pull': [1, 1, 2, 2],
|
|
125
|
+
... 'R4': [0.1, 0.2, -0.1, -0.2]
|
|
126
|
+
... })
|
|
127
|
+
>>> te = calculate_time_main_effects(df, 'pull')
|
|
128
|
+
>>> te
|
|
129
|
+
pull PT_ME
|
|
130
|
+
0 1 0.15
|
|
131
|
+
1 2 -0.15
|
|
132
|
+
"""
|
|
133
|
+
if 'R4' not in df.columns:
|
|
134
|
+
raise ValidationError('Cannot calculate time effects - R4 column missing.\nCalculate VAS residuals first.')
|
|
135
|
+
|
|
136
|
+
te = df.groupby(time_var, sort=False, observed=True)['R4'].mean().rename('PT_ME').reset_index()
|
|
137
|
+
|
|
138
|
+
return te[[time_var, 'PT_ME']]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def calculate_main_effect_scores(df: pd.DataFrame, factor: str, main_effects: pd.DataFrame) -> pd.DataFrame:
|
|
142
|
+
"""
|
|
143
|
+
Calculate main effect scores: R2 + Main Effect per row.
|
|
144
|
+
|
|
145
|
+
MEs = R2 + Main_Effect(factor_level)
|
|
146
|
+
|
|
147
|
+
This combines within-cell variation (R2) with the factor's main
|
|
148
|
+
effect to show each observation's contribution to factor variation.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
df : DataFrame
|
|
153
|
+
Data with R2 column and factor column
|
|
154
|
+
factor : str
|
|
155
|
+
Factor name
|
|
156
|
+
main_effects : DataFrame
|
|
157
|
+
Main effects from calculate_factor_main_effects()
|
|
158
|
+
Must have columns: [factor, 'Main_Effect']
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
DataFrame
|
|
163
|
+
Two columns: [factor, '{factor}_MEs']
|
|
164
|
+
Same number of rows as df
|
|
165
|
+
|
|
166
|
+
Examples
|
|
167
|
+
--------
|
|
168
|
+
>>> df = pd.DataFrame({
|
|
169
|
+
... 'lane': ['A', 'A', 'B'],
|
|
170
|
+
... 'R2': [0.1, -0.1, 0.05]
|
|
171
|
+
... })
|
|
172
|
+
>>> me = pd.DataFrame({
|
|
173
|
+
... 'lane': ['A', 'B'],
|
|
174
|
+
... 'Main_Effect': [0.25, -0.25]
|
|
175
|
+
... })
|
|
176
|
+
>>> mes = calculate_main_effect_scores(df, 'lane', me)
|
|
177
|
+
>>> mes
|
|
178
|
+
lane lane_MEs
|
|
179
|
+
0 A 0.35
|
|
180
|
+
1 A 0.15
|
|
181
|
+
2 B -0.20
|
|
182
|
+
"""
|
|
183
|
+
if 'R2' not in df.columns:
|
|
184
|
+
raise ValidationError('Cannot calculate MEs - R2 column missing')
|
|
185
|
+
|
|
186
|
+
if factor not in df.columns:
|
|
187
|
+
raise ValidationError(f"Factor '{factor}' not in data")
|
|
188
|
+
|
|
189
|
+
if not isinstance(main_effects, pd.DataFrame):
|
|
190
|
+
raise TypeError('main_effects must be a DataFrame')
|
|
191
|
+
|
|
192
|
+
required_cols = {factor, 'Main_Effect'}
|
|
193
|
+
if not required_cols.issubset(main_effects.columns):
|
|
194
|
+
raise ValidationError(
|
|
195
|
+
f'main_effects missing required columns.\nRequired: {required_cols}\nFound: {set(main_effects.columns)}'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Merge to add Main_Effect column
|
|
199
|
+
merged = df[[factor, 'R2']].merge(main_effects, on=factor, how='left', validate='many_to_one')
|
|
200
|
+
|
|
201
|
+
# Calculate score
|
|
202
|
+
label = f'{factor}_MEs'
|
|
203
|
+
merged[label] = merged['R2'] + merged['Main_Effect']
|
|
204
|
+
|
|
205
|
+
return merged[[factor, label]]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ============================================================================
|
|
209
|
+
# Pure Functions: Interactions
|
|
210
|
+
# ============================================================================
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def calculate_interaction_cell_means(df: pd.DataFrame, rsg_vars: list[str], time_var: str) -> pd.Series:
|
|
214
|
+
"""
|
|
215
|
+
Calculate cell-level interaction effect (factor × time).
|
|
216
|
+
|
|
217
|
+
Interaction = mean(R3) per cell
|
|
218
|
+
|
|
219
|
+
For SDS 1 (full replication), the cell-average of R3 equals the
|
|
220
|
+
interaction effect: Ȳ_kt - Ȳ_k - Ȳ_t + Ȳ
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
df : DataFrame
|
|
225
|
+
Data with R3 column
|
|
226
|
+
rsg_vars : list of str
|
|
227
|
+
Factor variable names
|
|
228
|
+
time_var : str
|
|
229
|
+
Time variable name
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
Series
|
|
234
|
+
Interaction effects broadcast to all rows
|
|
235
|
+
Length equals length of input DataFrame
|
|
236
|
+
Each row gets the mean R3 value for its factor×time cell
|
|
237
|
+
|
|
238
|
+
Examples
|
|
239
|
+
--------
|
|
240
|
+
>>> df = pd.DataFrame({
|
|
241
|
+
... 'lane': ['A', 'A', 'B', 'B'],
|
|
242
|
+
... 'pull': [1, 1, 2, 2],
|
|
243
|
+
... 'R3': [0.1, 0.15, -0.1, -0.05]
|
|
244
|
+
... })
|
|
245
|
+
>>> int_eff = calculate_interaction_cell_means(df, ['lane'], 'pull')
|
|
246
|
+
>>> int_eff # Broadcast to all rows
|
|
247
|
+
0 0.125
|
|
248
|
+
1 0.125
|
|
249
|
+
2 -0.075
|
|
250
|
+
3 -0.075
|
|
251
|
+
Name: R3, dtype: float64
|
|
252
|
+
"""
|
|
253
|
+
if 'R3' not in df.columns:
|
|
254
|
+
raise ValidationError('Cannot calculate interactions - R3 column missing')
|
|
255
|
+
|
|
256
|
+
keys = list(rsg_vars) + [time_var]
|
|
257
|
+
|
|
258
|
+
# Use transform to broadcast cell means back to all rows
|
|
259
|
+
return df.groupby(keys, sort=False, observed=True)['R3'].transform('mean')
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def calculate_pdc_by_time_sds2(
|
|
263
|
+
df: pd.DataFrame, ybar_kt: pd.Series, ybar_k: pd.Series, ybar_t: pd.Series, ybar: float
|
|
264
|
+
) -> pd.Series:
|
|
265
|
+
"""
|
|
266
|
+
Calculate process disruption component (PDC) for SDS 2.
|
|
267
|
+
|
|
268
|
+
PDC = Ȳ_kt - Ȳ_k - Ȳ_t + Ȳ
|
|
269
|
+
|
|
270
|
+
For SDS 2 (no replication), we calculate this directly from means
|
|
271
|
+
rather than from R3 averages.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
df : DataFrame
|
|
276
|
+
Input data (for index/length)
|
|
277
|
+
ybar_kt : Series
|
|
278
|
+
Cell means
|
|
279
|
+
ybar_k : Series
|
|
280
|
+
Factor means
|
|
281
|
+
ybar_t : Series
|
|
282
|
+
Time means
|
|
283
|
+
ybar : float
|
|
284
|
+
Grand mean
|
|
285
|
+
|
|
286
|
+
Returns
|
|
287
|
+
-------
|
|
288
|
+
Series
|
|
289
|
+
PDC values per row
|
|
290
|
+
"""
|
|
291
|
+
return ybar_kt - ybar_k - ybar_t + ybar
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def calculate_factor_interaction_effects(
|
|
295
|
+
df: pd.DataFrame, rsg_vars: list[str], main_effects: dict[str, pd.DataFrame]
|
|
296
|
+
) -> pd.DataFrame:
|
|
297
|
+
"""
|
|
298
|
+
Calculate two-factor interaction effects (F1 × F2).
|
|
299
|
+
|
|
300
|
+
Rx = mean(R5)_ij - ME_i - ME_j
|
|
301
|
+
|
|
302
|
+
Where:
|
|
303
|
+
- mean(R5)_ij is the average R5 for factor combination i,j
|
|
304
|
+
- ME_i is the main effect of factor 1 level i
|
|
305
|
+
- ME_j is the main effect of factor 2 level j
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
df : DataFrame
|
|
310
|
+
Data with R5 column and factor columns
|
|
311
|
+
rsg_vars : list of str
|
|
312
|
+
Factor names (at least 2 required)
|
|
313
|
+
main_effects : dict
|
|
314
|
+
Dict mapping factor name to its main effects DataFrame
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
DataFrame
|
|
319
|
+
Columns: [factor1, factor2, 'Rx']
|
|
320
|
+
|
|
321
|
+
Notes
|
|
322
|
+
-----
|
|
323
|
+
Only calculates for first 2 factors if more than 2 provided.
|
|
324
|
+
Higher-order interactions (3+) are not currently supported.
|
|
325
|
+
|
|
326
|
+
Examples
|
|
327
|
+
--------
|
|
328
|
+
>>> # With lanes A,B and heads 1,2
|
|
329
|
+
>>> # If lane A adds 0.2, head 1 adds 0.1, but A×1 adds 0.4 total
|
|
330
|
+
>>> # Then Rx = 0.4 - 0.2 - 0.1 = 0.1 (synergistic interaction)
|
|
331
|
+
"""
|
|
332
|
+
if len(rsg_vars) < 2:
|
|
333
|
+
logger.debug(f'Only {len(rsg_vars)} factor(s) - factor interaction requires at least 2. Skipping.')
|
|
334
|
+
return pd.DataFrame()
|
|
335
|
+
|
|
336
|
+
if 'R5' not in df.columns:
|
|
337
|
+
raise ValidationError('Cannot calculate interactions - R5 column missing')
|
|
338
|
+
|
|
339
|
+
# Use first 2 factors only
|
|
340
|
+
if len(rsg_vars) > 2:
|
|
341
|
+
logger.warning(f'More than 2 factors in RSG - calculating interaction for first 2: {rsg_vars[:2]}')
|
|
342
|
+
|
|
343
|
+
factor1, factor2 = rsg_vars[0], rsg_vars[1]
|
|
344
|
+
|
|
345
|
+
# Calculate mean R5 for each factor combination
|
|
346
|
+
rsg_r5 = df.groupby(list(rsg_vars[:2]), as_index=False, observed=True)['R5'].mean()
|
|
347
|
+
|
|
348
|
+
# Add main effects for each factor
|
|
349
|
+
for _i, factor in enumerate([factor1, factor2]):
|
|
350
|
+
me = main_effects.get(factor)
|
|
351
|
+
if me is None or not isinstance(me, pd.DataFrame):
|
|
352
|
+
raise ValidationError(
|
|
353
|
+
f"Main effects for '{factor}' not found or invalid.\nAvailable: {list(main_effects.keys())}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Validate structure
|
|
357
|
+
if not {factor, 'Main_Effect'}.issubset(me.columns):
|
|
358
|
+
raise ValidationError(f"Main effects for '{factor}' missing required columns")
|
|
359
|
+
|
|
360
|
+
rsg_r5 = rsg_r5.merge(me, on=factor, how='left', validate='many_to_one')
|
|
361
|
+
# Rename to distinguish
|
|
362
|
+
rsg_r5 = rsg_r5.rename(columns={'Main_Effect': f'ME_{factor}'})
|
|
363
|
+
|
|
364
|
+
# Calculate interaction residual
|
|
365
|
+
rsg_r5['Rx'] = rsg_r5['R5'] - rsg_r5[f'ME_{factor1}'] - rsg_r5[f'ME_{factor2}']
|
|
366
|
+
|
|
367
|
+
return rsg_r5[[factor1, factor2, 'Rx']]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def calculate_factor_interaction_scores(
|
|
371
|
+
df: pd.DataFrame, rsg_vars: list[str], factor_interactions: pd.DataFrame
|
|
372
|
+
) -> pd.DataFrame:
|
|
373
|
+
"""
|
|
374
|
+
Calculate factor interaction scores per row.
|
|
375
|
+
|
|
376
|
+
FIE = R2 + Rx
|
|
377
|
+
|
|
378
|
+
Where Rx is the two-factor interaction effect.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
df : DataFrame
|
|
383
|
+
Data with R2 column
|
|
384
|
+
rsg_vars : list of str
|
|
385
|
+
Factor names (first 2 used)
|
|
386
|
+
factor_interactions : DataFrame
|
|
387
|
+
From calculate_factor_interaction_effects()
|
|
388
|
+
Must have columns: [factor1, factor2, 'Rx']
|
|
389
|
+
|
|
390
|
+
Returns
|
|
391
|
+
-------
|
|
392
|
+
DataFrame
|
|
393
|
+
Columns: [factor1, factor2, 'factor_interaction_effects']
|
|
394
|
+
"""
|
|
395
|
+
if factor_interactions.empty:
|
|
396
|
+
return pd.DataFrame()
|
|
397
|
+
|
|
398
|
+
if 'R2' not in df.columns:
|
|
399
|
+
raise ValidationError('Cannot calculate interaction scores - R2 missing')
|
|
400
|
+
|
|
401
|
+
factor1, factor2 = rsg_vars[0], rsg_vars[1]
|
|
402
|
+
|
|
403
|
+
# Merge to add Rx to each row
|
|
404
|
+
merged = df[[factor1, factor2, 'R2']].merge(
|
|
405
|
+
factor_interactions, on=[factor1, factor2], how='left', validate='many_to_one'
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
merged['factor_interaction_effects'] = merged['R2'] + merged['Rx']
|
|
409
|
+
|
|
410
|
+
return merged[[factor1, factor2, 'factor_interaction_effects']]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ============================================================================
|
|
414
|
+
# Orchestration Class
|
|
415
|
+
# ============================================================================
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class EffectsCalculator:
|
|
419
|
+
"""
|
|
420
|
+
Calculates main effects and interactions from VAS residuals.
|
|
421
|
+
|
|
422
|
+
This class coordinates calculation of:
|
|
423
|
+
- Main effects (factor-level averages)
|
|
424
|
+
- Time effects (time-point averages)
|
|
425
|
+
- Main effect scores (per-row contributions)
|
|
426
|
+
- Interaction effects (factor × time, factor × factor)
|
|
427
|
+
|
|
428
|
+
Requires VAS residuals (R1, R2, R5) to be present in data.
|
|
429
|
+
|
|
430
|
+
Examples
|
|
431
|
+
--------
|
|
432
|
+
Calculate all effects:
|
|
433
|
+
|
|
434
|
+
>>> calc = EffectsCalculator()
|
|
435
|
+
>>> effects_dict = calc.calculate_all_effects(df, spec)
|
|
436
|
+
>>> effects_dict.keys()
|
|
437
|
+
dict_keys(['main_effect', 'lane', 'head', 'time', 'lane_MEs', ...])
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
def calculate_all_effects(self, df: pd.DataFrame, spec: FormulationSpec) -> dict:
|
|
441
|
+
"""
|
|
442
|
+
Calculate all main effects.
|
|
443
|
+
|
|
444
|
+
Returns a dictionary containing:
|
|
445
|
+
- 'main_effect': Main effects at RSG level (backward compatibility)
|
|
446
|
+
- '{factor}': Main effects for each factor
|
|
447
|
+
- 'time': Time main effects (if time variable specified)
|
|
448
|
+
- '{factor}_MEs': Main effect scores for each factor
|
|
449
|
+
|
|
450
|
+
Note: Interactions (factor_time, factor_factor) are calculated
|
|
451
|
+
separately via calculate_interactions().
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
df : DataFrame
|
|
456
|
+
Data with VAS residuals (R1, R2, R5)
|
|
457
|
+
spec : FormulationSpec
|
|
458
|
+
Analysis specification
|
|
459
|
+
|
|
460
|
+
Returns
|
|
461
|
+
-------
|
|
462
|
+
dict
|
|
463
|
+
Dictionary of effects DataFrames
|
|
464
|
+
|
|
465
|
+
Examples
|
|
466
|
+
--------
|
|
467
|
+
>>> calc = EffectsCalculator()
|
|
468
|
+
>>> effects = calc.calculate_all_effects(df, spec)
|
|
469
|
+
>>> effects['lane'] # Main effects for lane factor
|
|
470
|
+
lane Main_Effect
|
|
471
|
+
0 A 0.25
|
|
472
|
+
1 B -0.25
|
|
473
|
+
>>> effects['time'] # Time main effects
|
|
474
|
+
pull PT_ME
|
|
475
|
+
0 1 0.15
|
|
476
|
+
1 2 -0.15
|
|
477
|
+
"""
|
|
478
|
+
effects = {}
|
|
479
|
+
|
|
480
|
+
if not spec.rsg_vars:
|
|
481
|
+
logger.debug('No grouping variables - skipping effects calculation')
|
|
482
|
+
return effects
|
|
483
|
+
|
|
484
|
+
# Validate residuals present
|
|
485
|
+
self._validate_residuals(df)
|
|
486
|
+
|
|
487
|
+
# Calculate main effects for each factor
|
|
488
|
+
logger.debug('Calculating factor main effects')
|
|
489
|
+
for factor in spec.rsg_vars:
|
|
490
|
+
me = calculate_factor_main_effects(df, factor)
|
|
491
|
+
effects[factor] = me
|
|
492
|
+
|
|
493
|
+
# Calculate RSG-level main effect (for backward compatibility)
|
|
494
|
+
if spec.rsg_var_name:
|
|
495
|
+
rsg_me = calculate_factor_main_effects(df, spec.rsg_var_name)
|
|
496
|
+
effects['main_effect'] = rsg_me
|
|
497
|
+
|
|
498
|
+
# Calculate time main effects
|
|
499
|
+
if spec.has_time:
|
|
500
|
+
logger.debug('Calculating time main effects')
|
|
501
|
+
te = calculate_time_main_effects(df, spec.time_var)
|
|
502
|
+
effects['time'] = te
|
|
503
|
+
|
|
504
|
+
# Calculate main effect scores for each factor
|
|
505
|
+
logger.debug('Calculating main effect scores')
|
|
506
|
+
for factor in spec.rsg_vars:
|
|
507
|
+
me = effects[factor]
|
|
508
|
+
mes = calculate_main_effect_scores(df, factor, me)
|
|
509
|
+
effects[f'{factor}_MEs'] = mes
|
|
510
|
+
|
|
511
|
+
# Calculate factor interaction effects (if 2+ factors)
|
|
512
|
+
# Note: factor × factor interaction is stored in interactions dict, not effects
|
|
513
|
+
if len(spec.rsg_vars) >= 2:
|
|
514
|
+
logger.debug('Calculating factor interaction effects')
|
|
515
|
+
fi = calculate_factor_interaction_effects(df, spec.rsg_vars, effects)
|
|
516
|
+
if not fi.empty:
|
|
517
|
+
# Calculate per-row scores (kept in effects for backward compatibility)
|
|
518
|
+
fie = calculate_factor_interaction_scores(df, spec.rsg_vars, fi)
|
|
519
|
+
if not fie.empty:
|
|
520
|
+
effects['factor_interaction_effects'] = fie
|
|
521
|
+
|
|
522
|
+
logger.debug('All effects calculated successfully')
|
|
523
|
+
return effects
|
|
524
|
+
|
|
525
|
+
def calculate_interactions(
|
|
526
|
+
self, df: pd.DataFrame, spec: FormulationSpec, sds: int, effects: dict | None = None
|
|
527
|
+
) -> dict:
|
|
528
|
+
"""
|
|
529
|
+
Calculate interaction effects.
|
|
530
|
+
|
|
531
|
+
Calculates two types of interactions:
|
|
532
|
+
- factor_time: Factor × time interaction (PDC - Process Disruption Component)
|
|
533
|
+
- factor_factor: Factor × factor interaction (if 2+ factors)
|
|
534
|
+
|
|
535
|
+
Parameters
|
|
536
|
+
----------
|
|
537
|
+
df : DataFrame
|
|
538
|
+
Data with VAS residuals
|
|
539
|
+
spec : FormulationSpec
|
|
540
|
+
Analysis specification
|
|
541
|
+
sds : int
|
|
542
|
+
Sampling Design State
|
|
543
|
+
effects : dict, optional
|
|
544
|
+
Effects dict from calculate_all_effects() (needed for factor_factor)
|
|
545
|
+
|
|
546
|
+
Returns
|
|
547
|
+
-------
|
|
548
|
+
dict
|
|
549
|
+
Dictionary with:
|
|
550
|
+
- 'factor_time': Factor × time interaction (PDC)
|
|
551
|
+
- 'factor_factor': Factor × factor interaction (if 2+ factors)
|
|
552
|
+
"""
|
|
553
|
+
interactions = {}
|
|
554
|
+
|
|
555
|
+
# Factor × time interaction (requires grouping + time)
|
|
556
|
+
if spec.has_grouping and spec.has_time:
|
|
557
|
+
if 'R3' not in df.columns:
|
|
558
|
+
logger.warning('R3 not found - cannot calculate factor × time interaction')
|
|
559
|
+
else:
|
|
560
|
+
logger.debug('Calculating factor × time interaction (PDC)')
|
|
561
|
+
|
|
562
|
+
if sds == 1:
|
|
563
|
+
# Full replication: use cell averages of R3
|
|
564
|
+
pdc = calculate_interaction_cell_means(df, spec.rsg_vars, spec.time_var)
|
|
565
|
+
interactions['factor_time'] = pdc
|
|
566
|
+
|
|
567
|
+
elif sds == 2:
|
|
568
|
+
# No replication: direct calculation
|
|
569
|
+
pdc = calculate_pdc_by_time_sds2(
|
|
570
|
+
df,
|
|
571
|
+
df['Ybar_kt'],
|
|
572
|
+
df['Ybar_k'],
|
|
573
|
+
df['Ybar_t'],
|
|
574
|
+
df['Ybar'].iloc[0], # Grand mean (constant)
|
|
575
|
+
)
|
|
576
|
+
interactions['factor_time'] = pdc
|
|
577
|
+
|
|
578
|
+
else:
|
|
579
|
+
# SDS 3+: use SDS 1 approach
|
|
580
|
+
logger.debug(f'SDS {sds}: Using cell-average approach for PDC')
|
|
581
|
+
pdc = calculate_interaction_cell_means(df, spec.rsg_vars, spec.time_var)
|
|
582
|
+
interactions['factor_time'] = pdc
|
|
583
|
+
|
|
584
|
+
# Factor × factor interaction (requires 2+ factors)
|
|
585
|
+
if spec.rsg_vars and len(spec.rsg_vars) >= 2 and effects is not None:
|
|
586
|
+
logger.debug('Calculating factor × factor interaction')
|
|
587
|
+
fi = calculate_factor_interaction_effects(df, spec.rsg_vars, effects)
|
|
588
|
+
if not fi.empty:
|
|
589
|
+
interactions['factor_factor'] = fi
|
|
590
|
+
|
|
591
|
+
return interactions
|
|
592
|
+
|
|
593
|
+
def _validate_residuals(self, df: pd.DataFrame) -> None:
|
|
594
|
+
"""Validate that required residuals are present."""
|
|
595
|
+
required = {'R1', 'R2', 'R5'}
|
|
596
|
+
missing = required - set(df.columns)
|
|
597
|
+
|
|
598
|
+
if missing:
|
|
599
|
+
raise ValidationError(
|
|
600
|
+
f'Cannot calculate effects - missing residuals: {missing}\n'
|
|
601
|
+
f'Available columns: {df.columns.tolist()}\n'
|
|
602
|
+
f'Fix: Calculate VAS residuals before calling calculate_all_effects()'
|
|
603
|
+
)
|