Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **The R2 method for partial replication is reported as ``ma2``, not ``hybrid``.** The
docs described a per-cell hybrid for design state 3 (exact deviation where a cell has
n ≥ 2, moving average where n = 1) and said it was validated by Monte Carlo. The code
has not done that since cb037f8 (2026-03-05): with any singleton cell, R2 is Bishop's
ungrouped 2-point moving average over the full canonical sequence (Eq 13.7–13.9) for
every observation, and the 89 ADS 3 reference assertions hold against that. Only the word
survived, in six docs pages (three of which described it three different ways), the
detector's method token, the analysis-plan limitation text, two docstrings and two test
names. All now say what the code does. ``R2Method`` is ``Literal['exact', 'ma2']``;
``SDSRegistry.get_r2_method`` returns ``'ma2'`` where it returned ``'hybrid'``;
``get_sds_characteristics`` and ``SDSAnalysisPlan.residual_calculation_method`` use the
same two spellings in place of ``'within_cell'``, ``'moving_average'`` and ``'hybrid'``.
No arithmetic changed; none of these names is exported from the package top level.

### Fixed
- **``evaluate`` no longer raises on a constant column.** Binning a column with no spread
(every value identical) returned the documented no-spread result for ``equal_freq`` but
Expand Down
2 changes: 1 addition & 1 deletion docs/appendix/wheeler-terminology.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Wheeler identifies six design states that determine valid analysis approaches.

**ProcessBehavior Detection**: Some cells with n=1, others with n>=2

**Capabilities**: Hybrid variance estimation
**Capabilities**: R2 by moving average over the full sequence (mixed cell sizes)

---

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/key-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ See [DS Definitions](../reference/sds_definitions.md) for the formal classificat

The DS determines:
- Which chart types are valid
- How within-group variance is estimated (R2 method: exact, ma2, or hybrid)
- How within-group variance is estimated (R2 method: exact or ma2)
- Which VAS residuals can be computed
- What conclusions you can draw

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/sds_definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ For background on the lineage itself (Planned → Observed → Analytical), see
- **Grid Status**: Semi-Complete
- **Description**: Mix of cells - some have n=1, others have n≥2
- **Implications**:
- Hybrid R2 calculation required (exact where n≥2, moving average where n=1)
- R2 by moving average over the full sequence (any singleton cell forces `ma2` for every observation)
- **Most common in real-world data**
- Partial interaction effect estimation
- Requires careful handling of mixed replication
Expand Down Expand Up @@ -121,10 +121,10 @@ To determine the DS for a dataset:
| 0 | N/A | No VAS decomposition |
| 1 | Exact (within-cell) | Pooled within-cell variance |
| 2 | Moving Average | Approximate via sequential differences |
| 3 | Hybrid | Exact where n≥2, moving average where n=1 |
| 3 | Moving Average | Any singleton cell: `ma2` over the full sequence, same as DS 2 |
| 4 | Exact (present cells) | Within-cell for available data |
| 5 | Moving Average | All present cells are unreplicated |
| 6 | Hybrid | Complex handling of missing + mixed |
| 6 | Moving Average | Collapses to ADS 3 after cleaning; `ma2` over the full sequence |

## References

Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/chart-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ Do you have factors?
├── No → Use Xbar to compare factors
└── Yes → Do you have replication (n>=2 per cell)?
├── All cells → DS 1: Full Xbar-S + VAS residuals
├── Some cells → DS 3: Hybrid Xbar-S + limited VAS
├── Some cells → DS 3: Xbar-S with MA2-based limits + limited VAS
└── No cells → DS 2: Xbar-S with MR-based limits
```

Expand Down
17 changes: 10 additions & 7 deletions docs/user-guide/residuals.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ design states.
**Formula by DS**:
- **DS 1 (Full Replication)**: R2 = Y - Y̅<sub>kt</sub> (exact within-cell deviation)
- **DS 2 (No Replication)**: R2 = (Y<sub>j</sub> - Y<sub>j-1</sub>) / 2 (backward 2-point moving average)
- **DS 3 (Partial)**: Hybrid approach
- **DS 3 (Partial)**: same moving average as DS 2, over the full sequence

**Chart**: S chart with `value='R2'` (for replicated data) or X

Expand Down Expand Up @@ -255,17 +255,20 @@ Re-centering formulas:
|-----|----|----|----|----|-----|-----|
| 1 (Full Replication) | ✅ | ✅ Within-cell | ✅ | ✅ | ✅ | ✅ |
| 2 (No Replication) | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ |
| 3 (Partial) | ✅ | ✅ Hybrid | ✅ | ✅ | ✅ | ✅ |
| 3 (Partial) | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ |
| 4 (Incomplete, No Singletons) → ADS 1 | ✅ | ✅ Within-cell | ✅ | ✅ | ✅ | ✅ |
| 5 (Incomplete, No Replication) → ADS 2 | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ |
| 6 (Incomplete, With Singletons) → ADS 3 | ✅ | ✅ Hybrid | ✅ | ✅ | ✅ | ✅ |
| 6 (Incomplete, With Singletons) → ADS 3 | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ |

R6 requires factors (it is computed from R5 and R2 at `execute()` time).

**Note on R2 calculation**: R2 adapts to your sampling structure:
- **DS 1**: Within-cell deviation (`R2 = Y - Ȳ_kt`)
- **DS 2, 6**: Moving average method (`R2 = Y - MA2`) for unreplicated/sparse designs
- **DS 3, 4, 5**: Hybrid approach (within-cell for n>1 cells, zero for n=1 cells)
**Note on R2 calculation**: R2 is the one residual whose formula depends on structure, and the
choice is made on the analytical design state (ADS), after cleaning:
- **ADS 1** (every cell n ≥ 2): within-cell deviation, `R2 = Y - Ȳ_kt` (Eq 59)
- **ADS 2 and 3** (any singleton cell): 2-point moving average over the full canonical sequence,
`R2 = (Y_j - Y_{j-1}) / 2` (Eq 13.7–13.9), for every observation; no per-cell mixing

DS 4, 5 and 6 are sampling states; they collapse to ADS 1, 2 and 3 once empty cells are dropped.

## Analysis Workflow with Residuals

Expand Down
16 changes: 8 additions & 8 deletions docs/user-guide/sds-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,14 @@ The design report shows:
```

**Capabilities**:
- ⚠️ Hybrid R2 estimation (exact where n >= 2, ma2 where n = 1)
- ⚠️ R2 by the 2-point moving average over the full sequence (any singleton cell forces `ma2` for every observation)
- ⚠️ VAS residuals available but interpretation requires care
- ✅ Xbar-S analysis with hybrid limits
- ✅ Xbar-S analysis with R2-based limits

**Valid Charts**: Histogram, Xbar, S, X, mR

!!! note "Why Mixed is treated conservatively"
For R2 calculation, DS 3 uses the hybrid method: exact within-cell deviation where cells have n >= 2, and the ma2 (moving average) method where cells have n = 1. This conservative approach was validated by Monte Carlo simulation — it produces more reliable variance estimates than attempting to use only the replicated cells. The recommended chart is X (not Xbar) because the mixed replication makes subgroup-mean interpretation less straightforward.
!!! note "Why DS 3 does not mix methods"
For R2 in DS 3 the library does not combine an exact estimate from the replicated cells with a moving-average estimate from the singletons. Bishop's moving-average residual (Eq 13.7–13.9) is defined over the whole ordered sequence, j = 2 … J, with no grouping by cell. Once any cell is a singleton, every observation gets R2 = (Y_j − Y_{j−1}) / 2 on the canonical sort, and only the first observation in the sequence has no value. The replicated cells' within-cell deviations are not used for R2. This is the same calculation as DS 2, and the ADS 3 reference assertions in the [validation page](../reference/validation.md) hold against it. The recommended chart is X rather than Xbar because subgroup means over mixed cell sizes are uneven to interpret, not because the variance estimate differs.

## DS 4: Incomplete, No Singletons

Expand Down Expand Up @@ -379,7 +379,7 @@ The R2 method is determined by the tidy data structure (ADS), not the raw DS:
|-----|-----------|-------------|
| 1 | exact | Within-cell standard deviation (`R2 = Y - Ȳ_kt`) |
| 2 | ma2 | 2-point moving average for unreplicated designs |
| 3 | hybrid | Exact where n >= 2, ma2 where n = 1 |
| 3 | ma2 | Any singleton cell: moving average over the full sequence (same as DS 2) |

### 2. Available Charts

Expand All @@ -389,7 +389,7 @@ The R2 method is determined by the tidy data structure (ADS), not the raw DS:
|-----|--------|----------------|
| 1 | ✅ | ✅ |
| 2 | ✅ (MR-based limits) | ✅ |
| 3 | ✅ (hybrid limits) | ✅ |
| 3 | ✅ (R2-based limits) | ✅ |
| 4 | ❌ | ✅ |
| 5 | ❌ | ✅ |
| 6 | ❌ | ✅ |
Expand Down Expand Up @@ -486,10 +486,10 @@ print(cell_counts['n'].value_counts())
|----------------|-----|-----------------------|---------------|
| Full replication (n>=2 per cell) | 1 | 1 | Xbar with full VAS |
| One observation per cell | 2 | 2 | X with MA2-based R2 |
| Mixed replication | 3 | 3 | X with hybrid R2 |
| Mixed replication | 3 | 3 | X with MA2 R2 |
| Incomplete grid, all observed replicated | 4 | 1 | Xbar with full VAS |
| Incomplete grid, all observed n=1 | 5 | 2 | X with MA2-based R2 |
| Incomplete grid, mixed observed | 6 | 3 | X with hybrid R2 |
| Incomplete grid, mixed observed | 6 | 3 | X with MA2 R2 |

## Next Steps

Expand Down
2 changes: 1 addition & 1 deletion processbehavior/analysis_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def tidy_structure_summary(self) -> dict | None:
- n_cell_min: minimum observations per cell
- n_cell_max: maximum observations per cell
- K_obs: number of unique factor levels observed
- r2_method: 'exact', 'ma2', or 'hybrid'
- r2_method: 'exact' or 'ma2'
Returns None if structure stats haven't been computed.
"""
if self._structure_stats is None:
Expand Down
4 changes: 2 additions & 2 deletions processbehavior/datasets/synthetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,13 @@ def make_sds3( # noqa: C901
Characteristics:
---------------
- Some cells have n=1, others have n≥2
- Requires hybrid R2 estimation
- Mixed cell sizes: R2 by MA2 across the full sequence
- Reflects real-world data collection reality
- Most challenging to analyze correctly

Use Cases:
---------
- Testing hybrid variance estimation algorithms
- Testing R2 selection with mixed cell sizes
- Demonstrating real-world data structures
- Validating robustness to missing data
- Development of SDS detection logic
Expand Down
17 changes: 9 additions & 8 deletions processbehavior/residual_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,14 @@ def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.S
R2 is the ONLY structure-dependent residual. The method is determined by
observed cell sizes (via SDSRegistry.get_r2_method()):

- exact (state 1): R2 = Y - Ȳ_kt (Eq 59), all cells have n >= 2
- ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9),
- exact (ADS 1): R2 = Y - Ȳ_kt (Eq 59), every cell has n >= 2
- ma2 (ADS 2 and 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.7-13.9),
any cell has n = 1

When any cell has n=1, MA2 is applied to ALL observations across the
entire canonical-sorted stream — no grouping by rsg_key, no hybrid
per-cell selection. Bishop Eq 13.7-13.9 specify j=2,...,J with no
grouping; only j=1 gets R2=0.
entire canonical-sorted stream — no grouping by rsg_key, no per-cell
selection between exact and MA2. Bishop Eq 13.7-13.9 specify j=2,...,J
with no grouping; only j=1 has no value.

Parameters
----------
Expand All @@ -354,8 +354,9 @@ def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.S
y : str
Name of response variable
r2_method : R2Method
'exact', 'ma2', or 'hybrid' — retained for API compatibility.
Branching is driven by n_per_cell.
'exact' or 'ma2', as chosen by SDSRegistry.get_r2_method(). Kept for
callers and logging; the branch below re-derives it from n_per_cell so
the two can never disagree.
n_per_cell : pd.Series, optional
Pre-computed observations per cell. Pass from ADS to avoid recomputation.

Expand Down Expand Up @@ -427,7 +428,7 @@ def calculate_vas_residuals(
spec : FormulationSpec
Analysis specification
r2_method : R2Method
'exact', 'ma2', or 'hybrid' - determines R2 calculation
'exact' or 'ma2' - determines R2 calculation
n_per_cell : pd.Series, optional
Pre-computed observations per cell. Pass from ADS to avoid recomputation.
ybar_kt : pd.Series, optional
Expand Down
46 changes: 24 additions & 22 deletions processbehavior/sds_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
]

# R2 calculation method - structure-driven, not SDS-driven
R2Method = Literal['exact', 'ma2', 'hybrid']
R2Method = Literal['exact', 'ma2']


@dataclass(frozen=True)
Expand Down Expand Up @@ -116,7 +116,7 @@ class SDSAnalysisPlan:
residuals_available : list[str]
Which residuals can be calculated
residual_calculation_method : str
How R2 is calculated ('exact', 'moving_average', 'hybrid', or 'none')
How R2 is calculated ('exact', 'ma2', or 'none')
main_effects_supported : bool
Whether main effect calculations are supported
interaction_effects_supported : bool
Expand Down Expand Up @@ -158,7 +158,7 @@ class SDSAnalysisPlan:
# VAS residual capabilities
vas_residuals_supported: bool
residuals_available: list[str]
residual_calculation_method: str # 'exact', 'moving_average', 'hybrid', 'none'
residual_calculation_method: str # 'exact', 'ma2', 'none'

# Effects and interactions
main_effects_supported: bool
Expand Down Expand Up @@ -383,7 +383,7 @@ class SDSRegistry:

**SDS 3**: Semi-Complete - Partial replication
- Min N_kt = 1 and Max N_kt ≥ 2 (mixed)
- Hybrid variance estimation
- R2 by moving average across the full sequence (any singleton forces MA2)

**Incomplete** (has empty cells, requires sampling plan to detect):

Expand Down Expand Up @@ -664,7 +664,7 @@ def get_sds_characteristics(self, sds: int) -> dict:
>>> info['description']
'Full replication (all cells n≥2)'
>>> info['r2_method']
'within_cell'
'exact'
>>> info['capabilities']
['full_vas', 'all_residuals', 'interactions', 'main_effects']
"""
Expand All @@ -680,23 +680,23 @@ def get_sds_characteristics(self, sds: int) -> dict:
1: {
'description': 'Full replication (all cells n≥2)',
'replication_type': 'full',
'r2_method': 'within_cell',
'r2_method': 'exact',
'capabilities': ['full_vas', 'all_residuals', 'interactions', 'main_effects'],
'interaction_analysis': True,
'variance_decomposition': True,
},
2: {
'description': 'No replication (all cells n=1)',
'replication_type': 'none',
'r2_method': 'moving_average',
'r2_method': 'ma2',
'capabilities': ['all_residuals', 'limited_interactions', 'main_effects'],
'interaction_analysis': 'limited',
'variance_decomposition': True,
},
3: {
'description': 'Partial replication (mixed n=1 and n≥2)',
'replication_type': 'partial',
'r2_method': 'hybrid',
'r2_method': 'ma2', # Any singleton: MA2 over the full sequence (Bishop Eq 13.7-13.9)
'capabilities': ['all_residuals', 'partial_interactions', 'main_effects'],
'interaction_analysis': 'partial',
'variance_decomposition': True,
Expand All @@ -720,7 +720,7 @@ def get_sds_characteristics(self, sds: int) -> dict:
6: {
'description': 'Incomplete grid with singletons (has 0s, 1s, and ≥2s)',
'replication_type': 'partial',
'r2_method': 'hybrid',
'r2_method': 'ma2',
'capabilities': ['partial_vas', 'main_effects', 'stratification'],
'interaction_analysis': False,
'variance_decomposition': True,
Expand Down Expand Up @@ -751,23 +751,24 @@ def get_r2_method(self, stats: StructureStats) -> R2Method:
Returns
-------
R2Method
'exact' if all cells have replication (n_cell_min >= 2)
'ma2' if all cells are singletons (n_cell_max == 1)
'hybrid' for mixed replication (exact where n >= 2, MA2 where n = 1)
'exact' if every cell has replication (n_cell_min >= 2)
'ma2' if any cell is a singleton (n_cell_min == 1), whether all
cells are singletons (ADS 2) or only some (ADS 3)

Notes
-----
Deterministic rule based on Bishop methodology:
- Eq 59 (exact): R2 = Y - Ȳ_kt, requires replication
- Eq 66 (MA2): R2 = (Y_j - Y_{j-1}) / 2, for singletons
- Hybrid: exact where replicated, MA2 where singleton
- Eq 59 (exact): R2 = Y - Ȳ_kt, requires replication in every cell
- Eq 13.7-13.9 (MA2): R2 = (Y_j - Y_{j-1}) / 2 over the whole ordered
sequence, j = 2..J, with no grouping by cell. Once any cell is a
singleton this applies to every observation; the replicated cells'
within-cell deviations are not used for R2. There is no per-cell
mixing of the two (the earlier "hybrid" was removed in cb037f8, and
the ADS 3 reference assertions validate the ungrouped MA2).
"""
if stats.n_cell_min >= 2:
return 'exact'
elif stats.n_cell_max == 1:
return 'ma2'
else:
return 'hybrid'
return 'ma2'

def validate_sds_for_analysis(self, sds: int, analysis_type: str) -> bool:
"""
Expand Down Expand Up @@ -1296,7 +1297,7 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan:
invalid_charts=[],
vas_residuals_supported=True,
residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
residual_calculation_method='moving_average',
residual_calculation_method='ma2',
main_effects_supported=True,
interaction_effects_supported=True,
supports_stratification=True,
Expand Down Expand Up @@ -1324,7 +1325,7 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan:
invalid_charts=[],
vas_residuals_supported=True,
residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
residual_calculation_method='hybrid',
residual_calculation_method='ma2',
main_effects_supported=True,
interaction_effects_supported=True,
supports_stratification=True,
Expand All @@ -1335,7 +1336,8 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan:
'Pilot studies with targeted replication',
],
limitations=[
'R2 uses hybrid calculation (exact where possible, approximate elsewhere)',
'R2 uses the 2-point moving average over the full sequence (Bishop Eq 13.7-13.9); '
'the replicated cells contribute no separate within-cell estimate',
'Variance estimates less precise than SDS 1',
'May have unequal subgroup sizes',
],
Expand Down
Loading
Loading