README.md

rolescry

Name-blind variable-role detection by data signature. Data inspice, non nomen -- inspect the data, not the name.

rolescry assigns statistical roles to the columns of a tabular dataset from any domain -- a clinical trial, a loan book, a firm-month panel, a survey -- group variable, continuous/binary outcome, survival time and event, paired and agreement measurement pairs, repeated measures, scale items, subject identifier, and covariates -- using only each column's information-theoretic signature (Shannon entropy, normalized mutual information, distributional shape and inter-column structure), never the column names. Renaming every column to col_1, col_2, ... does not change the result. No large language models, no external data transmission; detection is deterministic.

Detection is backed by two structural invariance guarantees -- renaming (RELABEL) and reordering (S_n) columns never change a result -- and a single pre-registered, held-out confirmatory run (OSF osf.io/8ecau) validated the estimator on a synthetic data-generating process. Real-data and external validity are a separate, ongoing question.

Relationship to MDStatR

rolescry began as a variable-role detection component written during development of MDStatR, an unreleased personal research codebase by the same author. It was separated into an independent project and is released on its own terms.

Version 0.2.0 rebuilt part of the detection engine on an information-theoretic basis (measurement-pair and outcome detection on minimum description length scoring over a distance-correlation backbone, an additive-noise-model direction test, a bit-margin abstention rule). Version 0.3.0 moves three more roles onto derived criteria: group_var (normalized Shannon entropy), paired versus agreement pairs (the sign of the MDL shift bits) and ordinal scale_items (a one-factor Gaussian two-part code). Three rules remain on the original ported signature scorers -- survival, wide repeated_measures and binary-item scale_items -- because derived replacements were measured and rejected under a no-regression rule (see Known limitations); the source files record provenance individually.

For anyone evaluating or using rolescry, the practical position is:

The MDStatR reference is retained to attribute intellectual origin, nothing more.

Installation

From CRAN:

install.packages("rolescry")

From r-universe (development builds):

install.packages("rolescry", repos = "https://canboynukara.r-universe.dev")

From GitHub:

# install.packages("remotes")
remotes::install_github("canboynukara/rolescry")

The package needs only base R + stats. Optional packages (readxl/openxlsx/haven for file reading; moments/diptest/stringdist for extra refinements) are used only if installed.

Quick start

library(rolescry)

set.seed(1)
n <- 200
pre <- rnorm(n, 10, 2)
d <- data.frame(
  arm  = rep(c("control", "treatment"), each = n / 2),  # balanced 2-level grouping
  pre  = pre,                                           # a measurement ...
  post = pre + rnorm(n, 1, 1),                          # ... repeated after (paired)
  z1   = rnorm(n),                                      # two standardized predictors
  z2   = rnorm(n)
)
d$resp <- rbinom(n, 1, plogis(1.2 * d$z1 + 1.2 * d$z2)) # binary outcome caused by z1, z2

res <- detect_roles(d)
res$roles$group_var$columns        #> "arm"
res$roles$paired_pairs$columns     #> "pre" "post"
res$roles$outcome_binary$columns   #> "resp"
res$roles$covariate$columns        #> "z1" "z2"   (columns no other role claimed)
summary(res)

The name-blindness guarantee

Detection is purely mathematical by default (name_bonus = NULL):

pos <- function(res, dat) match(res$roles$outcome_binary$columns, names(dat))
d_blind <- setNames(d, paste0("col_", seq_along(d)))
identical(pos(detect_roles(d), d), pos(detect_roles(d_blind), d_blind))
#> TRUE  -- the SAME columns (by position) are detected, named or col_N

Column names can be used only as a small, capped tie-breaker (at most a +10 point nudge, i.e. <= 10%) by passing a keyword dictionary; the mathematical signature still dominates:

detect_roles(d, name_bonus = rolescry_default_name_bonus())

Header-aware loading

df <- read_data("messy_export.xlsx")   # auto-detects the header row

Beyond biostatistics

The detectors are information-theoretic, not domain-bound. The same calls work on financial data:

# a loan book: duration in months, rare default indicator
set.seed(31); n <- 1000
dur <- round(rweibull(n, 1.2, 36)) + 1
def <- rbinom(n, 1, plogis(-1 - 0.08 * dur))
loans <- data.frame(amount = rlnorm(n, 10, 0.6), rate = rnorm(n, 0.08, 0.02),
                    dur = dur, def = def)
detect_roles(loans)$roles$time_variable$columns   #> "dur"   (duration/event pair)

# a daily price series: the Date column is a calendar axis (time_index)
prices <- data.frame(dt = as.Date("2024-01-01") + 0:199,
                     price = cumsum(rnorm(200)) + 100)
detect_roles(prices)$roles$time_index$columns     #> "dt"

# finance keyword dictionary for the optional name hint
detect_roles(loans, name_bonus = rolescry_default_name_bonus("finance"))

Verifying a declared analysis plan

verify_roles(data, claim) turns the detector around: you state which columns play which role, and the data are asked whether they can support it. The check is name-blind (the claim is mapped to column positions once) and returns one verdict per claimed role.

set.seed(21); n <- 300
arm <- rep(c("drug", "placebo"), each = n / 2)
base <- rnorm(n, 150, 15)
rct <- data.frame(id = sample.int(99999, n), arm, age = round(rnorm(n, 60, 10)),
                  sbp_base = base, sbp_end = base - 10 * (arm == "drug") + rnorm(n, 0, 8))
rct$y <- rbinom(n, 1, plogis(-1 + 0.05 * (rct$sbp_end - 140)))
verify_roles(rct, list(subject_id = "id", group_var = "arm", paired_pairs = c("age", "sbp_end"),
                       outcome_binary = "y"))
#> <role_verification> 4 claims: 2 testable, 1 contradicted, 1 consistent, 2 unverifiable
#>            role      columns      verdict      sub_reason   statistic
#>      subject_id           id   consistent            <NA> unique=TRUE
#>       group_var          arm unverifiable identifiability     B=1.000
#>    paired_pairs age, sbp_end contradicted            <NA>  bits=-57.8
#>  outcome_binary            y unverifiable identifiability    unique=2
#>                                                                                  reason
#>            Every observed value is distinct and the column has an identifier signature.
#>  The data cannot say whether a column is the grouping; the entropy balance is reported.
#>  The two columns share no information beyond the independence noise floor (bits=-57.8).
#>                             The data cannot say which two-valued column is the outcome.
#> Structure not in the claim:
#>   paired_pairs       sbp_base, sbp_end (bits=240.1)
#>   coupled_pair       sbp_end, y (pct=69)

Aligning roles across sites without a codebook

Pooling data from several centers starts with finding which column plays which role in each file. harmonize_roles(sites) runs the name-blind detector on every site and tabulates, per role, the column each site holds (aligned = found everywhere, partial, absent). Here the two sites share no column names and the order differs, and the event is coded 0/1 at one site and 1/2 at the other.

site <- function(seed, nm, n = 200, code = 0:1) {
  set.seed(seed); b <- rnorm(n, 150, 15); t <- rweibull(n, 1.3, exp(5 - rnorm(n)))
  cens <- runif(n, 0, quantile(t, .9))
  d <- data.frame(sample.int(99999, n), sample(c("a", "b"), n, TRUE), b, b - 5 + rnorm(n, 0, 6),
                  round(pmin(t, cens), 1), code[1 + (t <= cens)])
  setNames(d, nm)
}
north <- site(1, c("id", "arm", "sbp_base", "sbp_end", "time", "dead"))
south <- site(2, c("pid", "group", "bp0", "bp1", "ftime", "status"), code = 1:2)[, c(3, 1, 6, 2, 5, 4)]
harmonize_roles(list(north = north, south = south))
#> <role_harmonization> 2 sites: 5 aligned, 0 partial roles
#> 
#>            role  status             north    south
#>       group_var aligned               arm    group
#>    paired_pairs aligned sbp_base, sbp_end bp0, bp1
#>   time_variable aligned              time    ftime
#>  event_variable aligned              dead   status
#>      subject_id aligned                id      pid
#> 
#> Flagged and overlap checks
#>           role         check north south flag
#>      group_var level_jaccard  1.00  1.00 <NA>
#>  time_variable range_overlap  0.72  0.72 <NA>
#> 
#> Descriptive checks (levels, Jaccard, ranges) are in $checks; flags mark structural mismatches only.

Known limitations

How it works

detect_roles() types each column from its values (.build_var_info), scores candidate roles with information-theoretic and distributional signatures (compute_nmi() exposes the normalized mutual information directly), and returns a structured role_detection object with per-role confidence and a component breakdown. See vignette("rolescry") for the method and the name-blind guarantee.

Citation & attribution

rolescry has its own archival DOI: 10.5281/zenodo.21003941 (Zenodo). It is derived from the MDStatR engine: Boynukara, C. (2026). MDStatR (v2.1.0 Veritas). Zenodo. https://doi.org/10.5281/zenodo.20707791

Run citation("rolescry") to cite the package (with its archival DOI) and its parent engine.

License

Apache License 2.0.



Try the rolescry package in your browser

Any scripts or data that you put into this service are public.

rolescry documentation built on Oct. 21, 2026, 5:14 p.m.