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.
Files changed (56) hide show
  1. processbehavior/__init__.py +133 -0
  2. processbehavior/analysis.py +2944 -0
  3. processbehavior/analysis_dataset.py +361 -0
  4. processbehavior/analysis_result.py +1276 -0
  5. processbehavior/calibration.py +70 -0
  6. processbehavior/capability.py +720 -0
  7. processbehavior/data_preparation.py +792 -0
  8. processbehavior/datasets/__init__.py +37 -0
  9. processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
  10. processbehavior/datasets/loaders.py +66 -0
  11. processbehavior/datasets/synthetic.py +1711 -0
  12. processbehavior/derivations.py +602 -0
  13. processbehavior/effects_calculator.py +603 -0
  14. processbehavior/exceptions.py +216 -0
  15. processbehavior/exporters/__init__.py +5 -0
  16. processbehavior/exporters/excel_exporter.py +708 -0
  17. processbehavior/focused_result.py +329 -0
  18. processbehavior/formulation_spec.py +144 -0
  19. processbehavior/loss_function.py +517 -0
  20. processbehavior/maximum_information.py +285 -0
  21. processbehavior/plotting/__init__.py +46 -0
  22. processbehavior/plotting/_math.py +121 -0
  23. processbehavior/plotting/capability_chart.py +858 -0
  24. processbehavior/plotting/contracts.py +176 -0
  25. processbehavior/plotting/control_chart.py +206 -0
  26. processbehavior/plotting/effects_charts.py +640 -0
  27. processbehavior/plotting/lane_boundaries.py +78 -0
  28. processbehavior/plotting/limits.py +157 -0
  29. processbehavior/plotting/maximum_information_chart.py +395 -0
  30. processbehavior/plotting/plotter.py +1469 -0
  31. processbehavior/plotting/renderers.py +660 -0
  32. processbehavior/plotting/report.py +211 -0
  33. processbehavior/plotting/residuals.py +282 -0
  34. processbehavior/plotting/run_rules_viz.py +150 -0
  35. processbehavior/plotting/stats_box.py +183 -0
  36. processbehavior/plotting/themes.py +544 -0
  37. processbehavior/plotting/x_axis_layout.py +474 -0
  38. processbehavior/plotting/zones.py +127 -0
  39. processbehavior/process_behavior.py +1290 -0
  40. processbehavior/py.typed +0 -0
  41. processbehavior/residual_calculator.py +572 -0
  42. processbehavior/result_signals.py +149 -0
  43. processbehavior/result_tabular.py +296 -0
  44. processbehavior/sds_detector.py +1432 -0
  45. processbehavior/signals/__init__.py +14 -0
  46. processbehavior/signals/config.py +435 -0
  47. processbehavior/signals/detector.py +328 -0
  48. processbehavior/signals/detectors.py +368 -0
  49. processbehavior/signals/result.py +232 -0
  50. processbehavior/spc_constants.py +692 -0
  51. processbehavior/study.py +2733 -0
  52. processbehavior/types.py +154 -0
  53. processbehavior-0.2.0.dist-info/METADATA +279 -0
  54. processbehavior-0.2.0.dist-info/RECORD +56 -0
  55. processbehavior-0.2.0.dist-info/WHEEL +4 -0
  56. processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,517 @@
1
+ """
2
+ Taguchi Loss Function Analysis — Bishop Chapter 15.
3
+
4
+ Decomposes expected loss into 5 components that identify where variation
5
+ comes from: centering, unexplained (within-cell), process design conditions
6
+ (PDC), time, and PDC×time interaction. When multiple factors exist, PDC
7
+ further decomposes into per-factor main effect losses and factor interaction
8
+ loss.
9
+
10
+ References
11
+ ----------
12
+ Bishop, T. *Variance Analysis System* — Chapter 15.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING
19
+
20
+ import numpy as np
21
+
22
+ from .exceptions import ValidationError
23
+ from .spc_constants import c4
24
+
25
+ if TYPE_CHECKING:
26
+ from .analysis_dataset import AnalysisDataSet
27
+ from .study import Study
28
+
29
+
30
+ # ============================================================================
31
+ # Data Class
32
+ # ============================================================================
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LossResult:
37
+ """
38
+ Result of a Taguchi Loss Function analysis (Bishop Ch. 15).
39
+
40
+ Decomposes expected loss into 5 components: centering, unexplained,
41
+ PDC, time, and PDC×time interaction. When multiple factors exist,
42
+ PDC further decomposes into per-factor losses and factor interaction.
43
+
44
+ Attributes
45
+ ----------
46
+ target : float
47
+ Target value used for the analysis.
48
+ target_is_default : bool
49
+ True when target defaulted to grand mean (centering = 0).
50
+ y_bar : float
51
+ Grand mean of all observations.
52
+ n : int
53
+ Total observations.
54
+ K : int
55
+ Number of PDC levels (rsg_key groups).
56
+ T_periods : int
57
+ Number of time periods.
58
+ sds : int
59
+ Analytical SDS.
60
+ centering, unexplained, pdc, time, interaction : float
61
+ The 5 loss components (squared-loss units).
62
+ total : float
63
+ Sum of 5 components.
64
+ pct_centering, pct_unexplained, pct_pdc, pct_time, pct_interaction : float
65
+ Each component as percentage of total.
66
+ pdc_by_factor : dict[str, float] | None
67
+ Per-factor loss decomposition (multi-factor only).
68
+ pdc_factor_interaction : float | None
69
+ PDC minus sum of per-factor losses (multi-factor only).
70
+ round_to : int
71
+ Decimal places for display.
72
+ """
73
+
74
+ # Context
75
+ target: float
76
+ target_is_default: bool
77
+ y_bar: float
78
+ n: int
79
+ K: int
80
+ T_periods: int
81
+ sds: int
82
+
83
+ # 5 loss components (squared-loss units)
84
+ centering: float
85
+ unexplained: float
86
+ pdc: float
87
+ time: float
88
+ interaction: float
89
+ total: float
90
+
91
+ # Pareto (percentages of total)
92
+ pct_centering: float
93
+ pct_unexplained: float
94
+ pct_pdc: float
95
+ pct_time: float
96
+ pct_interaction: float
97
+
98
+ # PDC decomposition by factor (multi-factor only)
99
+ pdc_by_factor: dict[str, float] | None
100
+ pdc_factor_interaction: float | None
101
+
102
+ # Presentation
103
+ round_to: int = 3
104
+
105
+ # ------------------------------------------------------------------
106
+ # Visualization
107
+ # ------------------------------------------------------------------
108
+
109
+ def plot(
110
+ self,
111
+ *,
112
+ structured: bool = False,
113
+ orientation: str = 'vertical',
114
+ theme=None,
115
+ width: int = 700,
116
+ height: int = 400,
117
+ title: str | None = None,
118
+ ):
119
+ """
120
+ Pareto bar chart of loss components.
121
+
122
+ Parameters
123
+ ----------
124
+ structured : bool, default False
125
+ False: 5 bars (MEAN, UNEXPLAINED, PDC, PT, PDCxPT INT).
126
+ True: PDC expanded into per-factor bars + PDF INT.
127
+ orientation : str, default 'vertical'
128
+ ``'vertical'`` or ``'horizontal'``.
129
+ theme : ChartTheme, optional
130
+ Visual theme.
131
+ width, height : int
132
+ Figure dimensions.
133
+ title : str, optional
134
+ Chart title.
135
+
136
+ Returns
137
+ -------
138
+ plotly.graph_objects.Figure
139
+ """
140
+ import plotly.graph_objects as go
141
+
142
+ from .plotting.themes import ChartTheme, get_theme
143
+
144
+ if theme is None:
145
+ theme = ChartTheme()
146
+ elif isinstance(theme, str):
147
+ theme = get_theme(theme)
148
+
149
+ # Build (label, pct) pairs
150
+ if structured and self.pdc_by_factor and len(self.pdc_by_factor) >= 2:
151
+ items = [
152
+ ('MEAN', self.pct_centering),
153
+ ('UNEXPLAINED', self.pct_unexplained),
154
+ ]
155
+ for factor, loss_val in self.pdc_by_factor.items():
156
+ pct = (loss_val / self.total * 100) if self.total > 0 else 0.0
157
+ items.append((factor, pct))
158
+ pdc_int_pct = (self.pdc_factor_interaction / self.total * 100) if self.total > 0 else 0.0
159
+ items.append(('PDF INT', pdc_int_pct))
160
+ items.append(('PT', self.pct_time))
161
+ items.append(('PDCxPT INT', self.pct_interaction))
162
+ else:
163
+ items = [
164
+ ('MEAN', self.pct_centering),
165
+ ('UNEXPLAINED', self.pct_unexplained),
166
+ ('PDC', self.pct_pdc),
167
+ ('PT', self.pct_time),
168
+ ('PDCxPT INT', self.pct_interaction),
169
+ ]
170
+
171
+ # Pareto order (descending)
172
+ items.sort(key=lambda x: x[1], reverse=True)
173
+ labels = [x[0] for x in items]
174
+ values = [x[1] for x in items]
175
+
176
+ r = self.round_to
177
+ text = [f'{round(v, r)}%' for v in values]
178
+ horizontal = orientation.startswith('h')
179
+ axis_max = max(values) * 1.1 if values else 100
180
+
181
+ value_label = 'Contribution to Average Loss (%)'
182
+ component_label = 'Components Contributing to Average Loss'
183
+
184
+ if horizontal:
185
+ fig = go.Figure(
186
+ go.Bar(
187
+ y=labels[::-1],
188
+ x=values[::-1],
189
+ orientation='h',
190
+ text=text[::-1],
191
+ textposition='outside',
192
+ )
193
+ )
194
+ fig.update_layout(
195
+ xaxis_title=value_label,
196
+ yaxis_title=component_label,
197
+ xaxis_range=[0, axis_max],
198
+ width=width,
199
+ height=height,
200
+ margin=dict(l=120),
201
+ )
202
+ else:
203
+ fig = go.Figure(
204
+ go.Bar(
205
+ x=labels,
206
+ y=values,
207
+ orientation='v',
208
+ text=text,
209
+ textposition='outside',
210
+ )
211
+ )
212
+ fig.update_layout(
213
+ yaxis_title=value_label,
214
+ xaxis_title=component_label,
215
+ yaxis_range=[0, axis_max],
216
+ width=width,
217
+ height=height,
218
+ margin=dict(b=80),
219
+ )
220
+
221
+ layout = theme.to_layout_dict()
222
+ layout['title']['text'] = title or 'Taguchi Loss Function Analysis'
223
+ fig.update_layout(**layout)
224
+
225
+ return fig
226
+
227
+ # ------------------------------------------------------------------
228
+ # Presentation helpers
229
+ # ------------------------------------------------------------------
230
+
231
+ def as_dict(self, round_to: int | None = None) -> dict:
232
+ """Return results as a dict with values rounded for display."""
233
+ r = round_to if round_to is not None else self.round_to
234
+
235
+ d = {
236
+ 'target': round(self.target, r),
237
+ 'target_is_default': self.target_is_default,
238
+ 'y_bar': round(self.y_bar, r),
239
+ 'n': self.n,
240
+ 'K': self.K,
241
+ 'T_periods': self.T_periods,
242
+ 'sds': self.sds,
243
+ 'centering': round(self.centering, r),
244
+ 'unexplained': round(self.unexplained, r),
245
+ 'pdc': round(self.pdc, r),
246
+ 'time': round(self.time, r),
247
+ 'interaction': round(self.interaction, r),
248
+ 'total': round(self.total, r),
249
+ 'pct_centering': round(self.pct_centering, r),
250
+ 'pct_unexplained': round(self.pct_unexplained, r),
251
+ 'pct_pdc': round(self.pct_pdc, r),
252
+ 'pct_time': round(self.pct_time, r),
253
+ 'pct_interaction': round(self.pct_interaction, r),
254
+ }
255
+
256
+ if self.pdc_by_factor is not None:
257
+ d['pdc_by_factor'] = {k: round(v, r) for k, v in self.pdc_by_factor.items()}
258
+ d['pdc_factor_interaction'] = round(self.pdc_factor_interaction, r)
259
+
260
+ return d
261
+
262
+ def __repr__(self) -> str:
263
+ """Formatted summary."""
264
+ d = self.as_dict()
265
+ r = self.round_to
266
+ lines = ['LossResult:']
267
+ tgt_note = ' (default=grand mean)' if self.target_is_default else ''
268
+ lines.append(f' Target={d["target"]}{tgt_note}, Ybar={d["y_bar"]}, N={d["n"]}')
269
+ lines.append(f' K={d["K"]} PDC levels, T={d["T_periods"]} periods, SDS={d["sds"]}')
270
+ lines.append('')
271
+ lines.append(f' Expected Loss (EL) = {d["total"]}')
272
+ lines.append('')
273
+
274
+ # Pareto-ordered components
275
+ components = [
276
+ ('MEAN', d['pct_centering']),
277
+ ('UNEXPLAINED', d['pct_unexplained']),
278
+ ('PDC', d['pct_pdc']),
279
+ ('PT', d['pct_time']),
280
+ ('PDCxPT INT', d['pct_interaction']),
281
+ ]
282
+ components.sort(key=lambda x: x[1], reverse=True)
283
+
284
+ lines.append(' Loss Decomposition:')
285
+ for label, pct in components:
286
+ lines.append(f' {label:>15s}: {round(pct, r)}%')
287
+
288
+ if self.pdc_by_factor and len(self.pdc_by_factor) >= 2:
289
+ lines.append('')
290
+ lines.append(' PDC Decomposition:')
291
+ pdc_items = sorted(self.pdc_by_factor.items(), key=lambda x: x[1], reverse=True)
292
+ for factor, loss_val in pdc_items:
293
+ pct = (loss_val / self.total * 100) if self.total > 0 else 0.0
294
+ lines.append(f' {factor:>15s}: {round(pct, r)}%')
295
+ pdc_int_pct = (self.pdc_factor_interaction / self.total * 100) if self.total > 0 else 0.0
296
+ lines.append(f' {"PDF INT":>15s}: {round(pdc_int_pct, r)}%')
297
+
298
+ return '\n'.join(lines)
299
+
300
+
301
+ # ============================================================================
302
+ # Pure Functions
303
+ # ============================================================================
304
+
305
+
306
+ def _compute_centering(y_bar: float, target: float) -> float:
307
+ """Eq 15.13 term 1: (Ȳ.. - T)²."""
308
+ return (y_bar - target) ** 2
309
+
310
+
311
+ def _compute_unexplained_replicated(df, response_var: str) -> float:
312
+ """
313
+ Eq 15.16/15.17: within-cell variance for fully replicated designs.
314
+
315
+ unexplained = (1/KT) Σ (S_kt / c4(n_kt))²
316
+ """
317
+ cell_stats = df.groupby('cell_key', observed=True)[response_var].agg(['std', 'count'])
318
+ cell_stats['sigma_sq'] = (cell_stats['std'] / cell_stats['count'].apply(c4)) ** 2
319
+ return float(cell_stats['sigma_sq'].mean())
320
+
321
+
322
+ def _compute_unexplained_pooled(df) -> float:
323
+ """
324
+ Eq 15.18/15.19: pooled sigma from R2 for ADS 2/3.
325
+
326
+ Uses std(R2, ddof=1) / 0.7 then squares (Bishop Eq 15.18).
327
+ """
328
+ r2 = df['R2'].dropna().to_numpy(dtype=float)
329
+ sigma_hat = np.std(r2, ddof=1) / 0.7
330
+ return float(sigma_hat**2)
331
+
332
+
333
+ def _compute_pdc(df, rsg_var_name: str) -> float:
334
+ """Eq 15.20: (1/K) Σ (Ȳ_k. - Ȳ..)²."""
335
+ ybar = df['Ybar'].iloc[0]
336
+ ybar_k = df.groupby(rsg_var_name, observed=True)['Ybar_k'].first()
337
+ rho_k = ybar_k - ybar
338
+ return float((rho_k**2).mean())
339
+
340
+
341
+ def _compute_time(df, time_var: str) -> float:
342
+ """Eq 15.13 term 4: (1/T) Σ (Ȳ_.t - Ȳ..)²."""
343
+ ybar = df['Ybar'].iloc[0]
344
+ ybar_t = df.groupby(time_var, observed=True)['Ybar_t'].first()
345
+ tau_t = ybar_t - ybar
346
+ return float((tau_t**2).mean())
347
+
348
+
349
+ def _compute_interaction(df, time_var: str) -> float:
350
+ """Eq 15.13 term 5: (1/KT) Σ (ρτ_kt)²."""
351
+ ybar = df['Ybar'].iloc[0]
352
+ cell = df.groupby('cell_key', observed=True)[['Ybar_kt', 'Ybar_k', 'Ybar_t']].first()
353
+ rho_tau = cell['Ybar_kt'] - cell['Ybar_k'] - cell['Ybar_t'] + ybar
354
+ return float((rho_tau**2).mean())
355
+
356
+
357
+ def _compute_pdc_decomposition(
358
+ effects: dict, rsg_vars: tuple[str, ...], pdc_total: float
359
+ ) -> tuple[dict[str, float], float]:
360
+ """
361
+ Decompose PDC into per-factor main effect losses and factor interaction.
362
+
363
+ Per-factor: Loss(Fm) = (1/Im) Σ [Main_Effect_i]²
364
+ Factor interaction = PDC - Σ Loss(Fm)
365
+ """
366
+ pdc_by_factor: dict[str, float] = {}
367
+ for factor in rsg_vars:
368
+ me = effects[factor]['Main_Effect']
369
+ pdc_by_factor[factor] = float((me**2).mean())
370
+
371
+ pdc_factor_interaction = pdc_total - sum(pdc_by_factor.values())
372
+ return pdc_by_factor, pdc_factor_interaction
373
+
374
+
375
+ def _compute_pareto(
376
+ centering: float,
377
+ unexplained: float,
378
+ pdc: float,
379
+ time_loss: float,
380
+ interaction: float,
381
+ total: float,
382
+ ) -> tuple[float, float, float, float, float]:
383
+ """Each component as percentage of total. Zero-safe."""
384
+ if total == 0.0:
385
+ return 0.0, 0.0, 0.0, 0.0, 0.0
386
+ return (
387
+ centering / total * 100,
388
+ unexplained / total * 100,
389
+ pdc / total * 100,
390
+ time_loss / total * 100,
391
+ interaction / total * 100,
392
+ )
393
+
394
+
395
+ # ============================================================================
396
+ # Coercion
397
+ # ============================================================================
398
+
399
+
400
+ def _coerce_ads(source: Study | AnalysisDataSet) -> AnalysisDataSet:
401
+ """Accept Study or AnalysisDataSet and return ADS."""
402
+ if hasattr(source, '_ads'):
403
+ return source._ads
404
+ return source
405
+
406
+
407
+ # ============================================================================
408
+ # Orchestrator
409
+ # ============================================================================
410
+
411
+
412
+ def assess_loss(
413
+ source: Study | AnalysisDataSet,
414
+ target: float | None = None,
415
+ round_to: int = 3,
416
+ ) -> LossResult:
417
+ """
418
+ Assess Taguchi Loss Function decomposition (Bishop Ch. 15).
419
+
420
+ Parameters
421
+ ----------
422
+ source : Study | AnalysisDataSet
423
+ Data source with VAS residuals computed.
424
+ target : float, optional
425
+ Target value. Defaults to grand mean (centering = 0).
426
+ round_to : int, default 3
427
+ Decimal places for presentation.
428
+
429
+ Returns
430
+ -------
431
+ LossResult
432
+ Frozen dataclass with 5-component loss decomposition.
433
+
434
+ Raises
435
+ ------
436
+ ValidationError
437
+ If VAS residuals are not available (requires factors + time).
438
+ """
439
+ ads = _coerce_ads(source)
440
+
441
+ if not ads.has_vas_residuals:
442
+ raise ValidationError(
443
+ 'Loss function analysis requires VAS residuals '
444
+ '(factors + time design). '
445
+ f'Current design (SDS {ads.observed_design_state}) '
446
+ 'does not have VAS residuals.'
447
+ )
448
+
449
+ df = ads.analysis_dataset
450
+ spec = ads.spec
451
+ response_var = spec.response_var
452
+ rsg_var_name = spec.rsg_var_name
453
+ time_var = spec.time_var
454
+ rsg_vars = spec.rsg_vars
455
+
456
+ # Context
457
+ y_bar = float(df['Ybar'].iloc[0])
458
+ n = len(df)
459
+ K = df[rsg_var_name].nunique()
460
+ T_periods = df[time_var].nunique()
461
+ sds = ads.analytical_design_state.sds
462
+
463
+ # Default target
464
+ target_is_default = target is None
465
+ if target_is_default:
466
+ target = y_bar
467
+
468
+ # --- 5 components ---
469
+ centering = _compute_centering(y_bar, target)
470
+
471
+ # Unexplained: branch on cell replication
472
+ if ads._structure_stats.n_cell_min >= 2:
473
+ unexplained = _compute_unexplained_replicated(df, response_var)
474
+ else:
475
+ unexplained = _compute_unexplained_pooled(df)
476
+
477
+ pdc = _compute_pdc(df, rsg_var_name)
478
+ time_loss = _compute_time(df, time_var)
479
+ interaction = _compute_interaction(df, time_var)
480
+
481
+ total = centering + unexplained + pdc + time_loss + interaction
482
+
483
+ # Pareto
484
+ pct = _compute_pareto(centering, unexplained, pdc, time_loss, interaction, total)
485
+
486
+ # PDC decomposition by factor
487
+ pdc_by_factor = None
488
+ pdc_factor_interaction = None
489
+ if rsg_vars and len(rsg_vars) >= 2:
490
+ pdc_by_factor, pdc_factor_interaction = _compute_pdc_decomposition(ads.effects, rsg_vars, pdc)
491
+ elif rsg_vars and len(rsg_vars) == 1:
492
+ pdc_by_factor = {rsg_vars[0]: pdc}
493
+ pdc_factor_interaction = 0.0
494
+
495
+ return LossResult(
496
+ target=target,
497
+ target_is_default=target_is_default,
498
+ y_bar=y_bar,
499
+ n=n,
500
+ K=K,
501
+ T_periods=T_periods,
502
+ sds=sds,
503
+ centering=centering,
504
+ unexplained=unexplained,
505
+ pdc=pdc,
506
+ time=time_loss,
507
+ interaction=interaction,
508
+ total=total,
509
+ pct_centering=pct[0],
510
+ pct_unexplained=pct[1],
511
+ pct_pdc=pct[2],
512
+ pct_time=pct[3],
513
+ pct_interaction=pct[4],
514
+ pdc_by_factor=pdc_by_factor,
515
+ pdc_factor_interaction=pdc_factor_interaction,
516
+ round_to=round_to,
517
+ )