Getting Started with rebalancedNoise
getting-started.RmdThe EZS Method
The rebalancedNoise package implements the EZS method as
described by Sabolová et al. (2025). The method adds record-level
noise to numeric survey variables. Every record carries a random
direction (either 1 or -1) and a noise multiplier. Cells identified as
sensitive keep their fixed direction, which guarantees protection. In
non-sensitive cells the algorithm adjusts the directions so that the
noise cancels out within table hierarchies. The result is disclosure
control with low distortion and additive consistency across all
aggregation levels.
Two Ways to Work
The package offers two interchangeable interfaces. Both produce identical results and can be mixed freely.
- The R6 engine.
rn_setup()creates one object that keeps all state. You work with methods like$rebalance()and$perturb(). Tables are stored under names, results are cached, and one rebalancing serves any number of publication tables. - The functional interface. Plain functions take a
typed object and return a new one. You chain them with the pipe operator
|>:rn_init()returns anrn_initializedobject,rn_rebalance()returns anrn_rebalancedobject, andrn_perturb()returns anrn_perturbedobject. There is no hidden state, so each pipeline is self-contained.
The table below maps the corresponding entry points.
| Task | R6 engine | Functional interface |
|---|---|---|
| Initialize | rn_setup() |
rn_init() |
| Rebalance | sdc$rebalance() |
rn_rebalance() |
| Perturb | sdc$perturb() |
rn_perturb() |
| Retrieve a table | sdc$get_results() |
rn_format() |
| Evaluate a table | sdc$summarize() |
rn_summarize() |
| Microdata with directions | sdc$get_microdata() |
as.data.table(state) |
| Reuse in another session | sdc$export() |
saveRDS() and rn_init()
|
| Start over after rebalancing | sdc$reset() |
rebalance again, no reset needed |
| Overview of stored tables | sdc$list_tables() |
not needed, you hold the objects |
The R6 engine suits interactive work where you create many tables
from the same rebalanced microdata and want caching and naming support.
The functional interface suits scripts and reproducible pipelines
because each step is an explicit, testable function call. The vignette
vignette("workflows", package = "rebalancedNoise") covers
all details of both styles.
Setup
The following data is used throughout this vignette. It contains two dimensions (country and industry) and three numeric variables (turnover, workers, assets). Country probabilities are unequal on purpose so that some cells hold fewer than five records and are flagged as sensitive.
library(rebalancedNoise)
#> Welcome to rebalancedNoise 0.3.1.
library(data.table)
#>
#> Attaching package: 'data.table'
#> The following object is masked from 'package:base':
#>
#> %notin%
library(sdcHierarchies)
#> Loading required package: shinythemes
#> Package 'sdcHierarchies' 0.23.1 has been loaded.
set.seed(1)
N <- 300
countries <- c("AT", "DE", "NL", "SE", "FR", "IT")
industries <- c("Tech", "Mfg", "Retail", "Services")
country_probs <- c(0.25, 0.25, 0.20, 0.15, 0.10, 0.05)
dt <- data.table(
country = sample(countries, N, replace = TRUE, prob = country_probs),
industry = sample(industries, N, replace = TRUE),
turnover = runif(N, 1000, 10000),
workers = sample(10:500, N, replace = TRUE),
assets = runif(N, 10000, 100000),
direction = sample(c(1, -1), N, replace = TRUE),
noise_multiplier = sample(seq(0.75, 1.25, by = 0.01), N, replace = TRUE)
)
# Detailed hierarchy for rebalancing (country x industry)
dims_detailed <- list(
country = hier_create("Total", nodes = countries),
industry = hier_create("Total", nodes = industries)
)
# Coarse hierarchies for publication tables
dims_country_only <- list(
country = hier_create("Total", nodes = countries)
)
dims_industry_only <- list(
industry = hier_create("Total", nodes = industries)
)
head(dt)
#> country industry turnover workers assets direction noise_multiplier
#> <char> <char> <num> <int> <num> <num> <num>
#> 1: AT Tech 8328.266 257 94908.25 1 0.80
#> 2: AT Tech 9358.995 89 97960.83 1 0.85
#> 3: NL Retail 2327.329 478 13497.99 1 0.86
#> 4: FR Tech 7748.395 53 62895.50 -1 1.05
#> 5: DE Retail 9780.916 469 12701.05 -1 0.94
#> 6: FR Tech 9773.132 487 60875.31 -1 1.15
# Cell sizes, the smallest cells will be sensitive
dt[, .N, by = .(country, industry)][order(N)]
#> country industry N
#> <char> <char> <int>
#> 1: IT Tech 2
#> 2: IT Retail 2
#> 3: IT Mfg 3
#> 4: IT Services 3
#> 5: FR Services 5
#> 6: DE Mfg 7
#> 7: FR Tech 8
#> 8: SE Retail 8
#> 9: SE Services 8
#> 10: FR Retail 8
#> 11: SE Mfg 9
#> 12: FR Mfg 11
#> 13: NL Tech 11
#> 14: NL Services 12
#> 15: AT Mfg 15
#> 16: SE Tech 16
#> 17: NL Retail 17
#> 18: DE Retail 17
#> 19: NL Mfg 17
#> 20: AT Retail 19
#> 21: DE Services 23
#> 22: DE Tech 25
#> 23: AT Services 25
#> 24: AT Tech 29
#> country industry N
#> <char> <char> <int>Every input record must provide the two EZS columns
direction (1 or -1) and noise_multiplier (0 or
greater). Set Sys.setenv(SDC_LOG_LEVEL = "OFF") if you do
not want the engine to print status messages.
The Same Workflow Twice
The workflow has three steps: initialize the data, rebalance once on the detailed structure, and perturb a publication table at a coarser level. The two following sections run exactly this workflow, first with the R6 engine and then with the functional interface.
Using the R6 engine
# One object holds all state
sdc <- rn_setup(data = dt, sensitive_params = list(n_threshold = 5))
#> ℹ Initialization started...
#> ✔ Initialization complete.
# Rebalance once on the detailed structure
sdc$rebalance(dim_list = dims_detailed, num_var = "turnover")
#> ℹ Performing rebalancing on detailed table structure...
#> ✔ Rebalancing complete. Updated "direction_rebalanced" column.
# Perturb a publication table at country level, stored under a name
sdc$perturb(dim_list = dims_country_only, variables = "turnover", name = "table_a")
#> ✔ Created new table "table_a" with variables `turnover`.
# Read the stored table
res_r6 <- sdc$get_results("table_a")
head(res_r6)
#> country n_obs is_internal turnover turnover_init turnover_pert
#> <char> <num> <lgcl> <num> <num> <num>
#> 1: Total 300 FALSE 1651113.7 1853460.8 1679243.9
#> 2: AT 88 TRUE 544080.2 631265.0 548794.9
#> 3: DE 72 TRUE 335581.4 281955.3 334307.4
#> 4: NL 57 TRUE 313695.9 372977.6 316609.1
#> 5: SE 41 TRUE 216463.0 289693.4 215228.8
#> 6: FR 32 TRUE 197918.8 200338.4 187072.6
#> is_sens_turnover
#> <lgcl>
#> 1: FALSE
#> 2: FALSE
#> 3: FALSE
#> 4: FALSE
#> 5: FALSE
#> 6: FALSEUsing the functional interface
# Each step returns a typed object, the pipe passes it to the next step
res_fun <- dt |>
rn_init(sensitive_params = list(n_threshold = 5)) |>
rn_rebalance(dim_list = dims_detailed, num_var = "turnover") |>
rn_perturb(dim_list = dims_country_only, variables = "turnover")
#> ℹ Initialization started...
#> ✔ Initialization complete.
#> ℹ Performing rebalancing on detailed table structure...
#> ✔ Rebalancing complete. Updated "direction_rebalanced" column.
# The pipeline result is an rn_perturbed object
res_fun
#> <rn_perturbed>
#> table: 7 cells x 8 columns
#> dimensions: country
#> variables: turnover
#> round: FALSE | hash: 8087832319fd
# Convert it to a data.table in wide format
res_fun_wide <- rn_format(res_fun)
head(res_fun_wide)
#> country n_obs is_internal turnover turnover_init turnover_pert
#> <char> <num> <lgcl> <num> <num> <num>
#> 1: Total 300 FALSE 1651113.7 1853460.8 1679243.9
#> 2: AT 88 TRUE 544080.2 631265.0 548794.9
#> 3: DE 72 TRUE 335581.4 281955.3 334307.4
#> 4: NL 57 TRUE 313695.9 372977.6 316609.1
#> 5: SE 41 TRUE 216463.0 289693.4 215228.8
#> 6: FR 32 TRUE 197918.8 200338.4 187072.6
#> is_sens_turnover
#> <lgcl>
#> 1: FALSE
#> 2: FALSE
#> 3: FALSE
#> 4: FALSE
#> 5: FALSE
#> 6: FALSEBoth routes compute the same table, and the results are identical:
identical(as.data.frame(res_r6), as.data.frame(res_fun_wide))
#> [1] TRUEThe intermediate object rn_perturbed carries the
hierarchy, the SDC parameters, and a hash, so the pipeline stays
reproducible without any external bookkeeping. Each function checks the
class of its input strictly and rejects wrong types with a hint about
the required preceding step. A plain data.table, even one
that contains a direction_rebalanced column, is not
accepted by rn_perturb().
Reading the Results
Both interfaces provide the same output formats, and the resulting
data.table columns are the same in both cases. Use
sdc$get_results() or rn_format() with
format = "wide" (the default) or
format = "long".
Wide Format
Wide format has one row per table cell. Every perturbed variable contributes a group of columns.
head(res_r6)
#> country n_obs is_internal turnover turnover_init turnover_pert
#> <char> <num> <lgcl> <num> <num> <num>
#> 1: Total 300 FALSE 1651113.7 1853460.8 1679243.9
#> 2: AT 88 TRUE 544080.2 631265.0 548794.9
#> 3: DE 72 TRUE 335581.4 281955.3 334307.4
#> 4: NL 57 TRUE 313695.9 372977.6 316609.1
#> 5: SE 41 TRUE 216463.0 289693.4 215228.8
#> 6: FR 32 TRUE 197918.8 200338.4 187072.6
#> is_sens_turnover
#> <lgcl>
#> 1: FALSE
#> 2: FALSE
#> 3: FALSE
#> 4: FALSE
#> 5: FALSE
#> 6: FALSELong Format
Long format has one row per cell and variable, with standardized column names. It is convenient for plotting and further processing.
head(sdc$get_results("table_a", format = "long"))
#> country n_obs is_internal variable val_pert_init val_orig val_pert
#> <char> <num> <lgcl> <char> <num> <num> <num>
#> 1: Total 300 FALSE turnover 1853460.8 1651113.7 1679243.9
#> 2: AT 88 TRUE turnover 631265.0 544080.2 548794.9
#> 3: DE 72 TRUE turnover 281955.3 335581.4 334307.4
#> 4: NL 57 TRUE turnover 372977.6 313695.9 316609.1
#> 5: SE 41 TRUE turnover 289693.4 216463.0 215228.8
#> 6: FR 32 TRUE turnover 200338.4 197918.8 187072.6
#> is_sensitive diff_init_pct diff_final_pct
#> <lgcl> <num> <num>
#> 1: FALSE 12.255 1.704
#> 2: FALSE 16.024 0.867
#> 3: FALSE -15.980 -0.380
#> 4: FALSE 18.898 0.929
#> 5: FALSE 33.830 -0.570
#> 6: FALSE 1.223 -5.480
head(rn_format(res_fun, format = "long"))
#> country n_obs is_internal variable val_pert_init val_orig val_pert
#> <char> <num> <lgcl> <char> <num> <num> <num>
#> 1: Total 300 FALSE turnover 1853460.8 1651113.7 1679243.9
#> 2: AT 88 TRUE turnover 631265.0 544080.2 548794.9
#> 3: DE 72 TRUE turnover 281955.3 335581.4 334307.4
#> 4: NL 57 TRUE turnover 372977.6 313695.9 316609.1
#> 5: SE 41 TRUE turnover 289693.4 216463.0 215228.8
#> 6: FR 32 TRUE turnover 200338.4 197918.8 187072.6
#> is_sensitive diff_init_pct diff_final_pct
#> <lgcl> <num> <num>
#> 1: FALSE 12.255 1.704
#> 2: FALSE 16.024 0.867
#> 3: FALSE -15.980 -0.380
#> 4: FALSE 18.898 0.929
#> 5: FALSE 33.830 -0.570
#> 6: FALSE 1.223 -5.480Column Reference
| Format | Column | Content |
|---|---|---|
| both | [dim] |
dimension value, one column per dimension |
| both | n_obs |
number of records contributing to the cell |
| both | is_internal |
TRUE for base cells, FALSE
for aggregates |
| wide | [var] |
unperturbed original value |
| wide | [var]_init |
perturbation with the original random directions |
| wide | [var]_pert |
final perturbed value after rebalancing |
| wide | is_sens_[var] |
TRUE if the cell is sensitive for that
variable |
| long | variable |
name of the variable of this row |
| long |
val_orig, val_pert_init,
val_pert
|
the three values of [var],
[var]_init, and [var]_pert
|
| long | is_sensitive |
TRUE if the cell is sensitive for this
variable |
| long |
diff_init_pct,
diff_final_pct
|
percentage deviation of the initial and final values from the original |
The comparison between the three versions of each variable is what
makes the method auditable. [var] is the truth,
[var]_init shows what plain perturbation would have
produced, and [var]_pert shows the final published
value.
You can restrict the output to selected variables with the
variables argument, which both
sdc$get_results() and rn_format() accept. A
variable that is not part of the table causes an error that lists the
available ones.
Combining Both Interfaces
The two interfaces are interchangeable at every point in the workflow. An R6 object can hand its state to the functional interface, and an R6 result table can be evaluated with the functional tools.
# Feed the R6 engine state into the functional pipeline
state <- sdc$get_state() # rn_rebalanced
t_b <- rn_perturb(state, dim_list = dims_industry_only, variables = "workers")
head(rn_format(t_b))
#> industry n_obs is_internal workers workers_init workers_pert is_sens_workers
#> <char> <num> <lgcl> <num> <num> <num> <lgcl>
#> 1: Total 300 FALSE 73686 83914.68 76617.98 FALSE
#> 2: Tech 91 TRUE 20800 25302.16 21261.32 FALSE
#> 3: Mfg 62 TRUE 15925 18689.39 16782.93 FALSE
#> 4: Retail 71 TRUE 16140 17937.37 16788.53 FALSE
#> 5: Services 76 TRUE 20821 21985.76 21785.20 FALSE
# Evaluate a stored R6 table with the functional summary
rn_summarize(sdc$get_table("table_a"))
#>
#> ── EZS Perturbation Summary: "rn_perturbed" ────────────────────────────────────
#>
#> ── Variable: "turnover" ──
#>
#> ── OVERALL
#> ℹ nrCells: 7 | MAPE (Initial): 25.181% | MAPE (Final): 12.57% | Noise Reduction: 50.08%
#> Percentiles - Relative (%) Initial:
#> -15.98 | -14.948 | -10.819 | 6.739 | 16.024 | 26.364 | 64.789 | 75.403 | 78.057
#> Percentiles - Relative (%) Final:
#> -5.48 | -5.185 | -4.007 | -0.475 | 0.867 | 1.316 | 55.151 | 73.476 | 78.057
#> Percentiles - Absolute (Units) Final:
#> -10846.157 | -10271.829 | -7974.517 | -1254.123 | 2913.184 | 16422.458 |
#> 32138.769 | 33513.132 | 33856.722
#>
#> ── Summary Statistics
#> • Sensitivity Rate: 0% (0/7 cells)
#> • Internal Cells: 6 (85.7%)
#> • Noise Reduction: 50.08%The reverse direction works as well. An object created with
rn_init() can be passed to
rebalancedNoise$new() to continue with R6 methods.
Next Steps
The vignette
vignette("workflows", package = "rebalancedNoise")
describes the remaining features in both styles: multiple variables and
multiple tables from one rebalancing, rebalancing on linear
combinations, table naming and caching in the R6 engine, rounding,
evaluation with summarize(), export and import, and
parallelization.