This document outlines a plan to refactor the nested count layer implementation in Tplyr to: 1. Improve performance through proper vectorization 2. Enable arbitrary nesting depth (not limited to 2 levels) 3. Maintain full backwards compatibility with existing user interfaces
do() pattern - Sequential group-by-group processing:nested.R:88-90: group_by(target_var[[1]]) %>% do(filter_nested_inner_layer(...))sort.R:219-236: group_by(row_label1) %>% do(add_data_order_nested(...))
Repeated string operations - Indentation prefix added/removed multiple times during processing
Non-vectorized sorting - Sort variables created per-group rather than vectorized across all data
by and target_var for layer rebuildingFor target_vars = [level_1, level_2, ..., level_n]:
Level 1: group_by(by_vars) → count level_1
Level 2: group_by(by_vars, level_1) → count level_2
Level 3: group_by(by_vars, level_1, level_2) → count level_3
...
Level n: group_by(by_vars, level_1, ..., level_n-1) → count level_n
Final: reduce(bind_rows) with proper indentation at each level
Each nesting level is processed as a complete count operation using existing process_count_n() machinery:
process_nested_level <- function(level_index, target_vars, by_vars, ...) {
# Outer variables become additional by variables
outer_vars <- target_vars[seq_len(level_index - 1)]
current_target <- target_vars[[level_index]]
# Process using existing vectorized count logic
process_count_n(
target_var = current_target,
by = c(outer_vars, by_vars),
...
)
}
Denominators follow existing configuration via set_denoms_by(), set_denom_where(), set_denom_ignore():
denoms_by as configuredsummary_var in denoms_by with the outer target variable(s)This preserves the current behavior where:
- If denoms_by includes summary_var, it refers to the appropriate outer level
- Population data denominators work unchanged
Each level gets cumulative indentation:
- Level 1: No indentation (outermost)
- Level 2: indentation (1x)
- Level 3: indentation + indentation (2x)
- Level n: (n-1) * indentation
Indentation is applied once after numeric processing, not during.
Sort variables are created per-level in a vectorized manner:
# For each nesting level, create ord_layer_X
# Level 1 sort is independent
# Level 2 sort is within Level 1 groups
# etc.
# Instead of do(), use vectorized joins:
numeric_data %>%
left_join(level_1_order, by = join_keys) %>%
left_join(level_2_order, by = join_keys) %>%
...
The implementation uses purrr::reduce() or equivalent:
process_nested_count_target <- function(x) {
target_vars <- x$target_var
n_levels <- length(target_vars)
# Process each level
level_results <- map(seq_len(n_levels), function(i) {
process_nested_level(i, target_vars, by_vars, ...)
})
# Add indentation to each level
level_results <- imap(level_results, function(result, i) {
add_level_indentation(result, level = i, indentation = indentation)
})
# Combine results
numeric_data <- reduce(level_results, bind_rows)
...
}
File: R/nested.R
process_nested_count_target() to:process_count_n()reduce() pattern for combining resultsApply indentation vectorized after processing
Replace filter_nested_inner_layer() with vectorized filtering:
Use vectorized %in% filtering instead of do()
Update filter_nested_numeric() to handle n-levels
File: R/denom.R
denoms_by replacement logic for n-levelsget_denom_total() works correctly with multi-level groupingset_denoms_by(), set_denom_where(), set_denom_ignore() behavior preservedFile: R/sort.R
do(add_data_order_nested()) with vectorized approach:Avoid per-group function application
Update add_data_order_nested() or replace entirely:
File: R/count.R
process_summaries.count_layer():length(target_var) > 1Remove assumption of exactly 2 target variables
Verify all helper functions work with new structure
| Feature | Compatibility Strategy |
|---------|----------------------|
| group_count(vars(outer, inner)) | Unchanged API |
| set_denoms_by() | Unchanged behavior |
| set_denom_where() | Unchanged behavior |
| set_denom_ignore() | Unchanged behavior |
| set_count_row_prefix() / indentation | Unchanged, extended to n-levels |
| set_nest_count() | Unchanged behavior |
| add_total_row() error on nested | Unchanged - error preserved |
| add_missing_subjects_row() | Unchanged - applies to inner levels |
| Sort order variables (ord_layer_*) | Same output structure |
| numeric_cutoff on nested | Unchanged behavior |
| All existing error messages | Preserved unless compelling reason |
| Risk | Mitigation | |------|------------| | Subtle behavior changes | Comprehensive test suite must pass | | Denominator calculation differences | Careful review of denom.Rmd examples | | Sort order differences | Compare outputs before/after | | Performance regression in edge cases | Profile with various data sizes |
Goal: Refactor nested count layer implementation for performance while maintaining exact backwards compatibility.
Scope: - Maintain support for exactly 2 nesting levels (current behavior) - Preserve all existing error messages (including "total rows not allowed in nested") - Keep current text/symbol pattern handling unchanged - All existing unit tests must pass without modification - Performance target: Minimum 2x speedup, with potential for significantly more
Not in scope for Phase 1: - 3+ levels of nesting - New text/symbol pattern combinations - Changes to total row behavior - Any new user-facing features
Scope (to be refined later): - Support for 3+ levels of nesting - Potential new text/symbol pattern combinations - Any additional features that build on the refactored foundation
Baseline benchmarks recorded on 2025-01-22. See tests/benchmarks/nested_count_benchmark.R for the benchmark script.
Using multiplied tplyr_adae data with set_order_count_method("bycount", break_ties="desc"):
| Scale | Rows | Outer Values | Inner Values | Time (sec) | |-------|------|--------------|--------------|------------| | 10x | 2,760 | 10 | 210 | 0.44 | | 25x | 6,900 | 25 | 525 | 0.57 | | 50x | 13,800 | 50 | 1,050 | 0.84 | | 75x | 20,700 | 75 | 1,575 | 1.28 | | 100x | 27,600 | 100 | 2,100 | 1.73 | | 150x | 41,400 | 150 | 3,150 | 2.81 |
Model fitting (R² values - higher = better fit): - O(n) linear: 0.9792 - O(n²) quadratic: 0.9841 - O(n log n): 0.9932 - O(n + n²) mixed: 0.9992
Finding: The complexity appears to be O(n log n) or slightly superlinear, not purely quadratic. The time per outer value stabilizes at ~0.017-0.019 sec for larger datasets.
| Function | % Total Time | Notes |
|----------|--------------|-------|
| do() | 59.6% | Primary bottleneck |
| process_formatting.count_layer | 52.4% | Includes nested formatting |
| process_nested_count_target | 44.5% | Nested count logic |
| add_order_columns.count_layer | 41.8% | Sort variable creation |
| add_data_order_nested | 41.2% | Nested sorting bottleneck |
| select | 32.9% | Column selection overhead |
| pivot_wider | 32.2% | Reshaping |
| get_data_order_bycount | 29.5% | Count-based ordering |
Key insight: The do() function takes 60% of total execution time. The sorting logic (add_data_order_nested + get_data_order_bycount) accounts for ~41% of time and is called within do(). Replacing the do() pattern with vectorized operations should yield 2-3x improvement.
| File | Changes |
|------|---------|
| R/nested.R | Major rewrite - core of the refactor |
| R/sort.R | Significant changes to add_order_columns.count_layer() and add_data_order_nested() |
| R/denom.R | Minor updates for n-level support |
| R/count.R | Minor dispatch logic updates |
| File | Reason |
|------|--------|
| All exported function interfaces | Backwards compatibility |
| R/layer.R | No changes needed |
| R/table.R | No changes needed |
| vignettes/* | Document existing behavior |
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.