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.
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:
rolescry requires only base R (stats, utils). It does not
import, link to or call MDStatR, and MDStatR is not needed to install, run, test or
understand it.rolescry's functionality.rolescry and have no counterpart in MDStatR.The MDStatR reference is retained to attribute intellectual origin, nothing more.
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.
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)
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())
df <- read_data("messy_export.xlsx") # auto-detects the header row
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"))
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)
age and
sbp_end share no information, so they cannot be a pre/post pair. The exact
conditions: subject_id values repeat while a detected long panel has another
entity cluster; a paired/agreement pair shares no information, differs more than
it agrees, or has a constant member; a coupled_pair whose members are
independent; an event column that is not a two-level or 0/1/2 coding, or negative
times; repeated_measures that are not a detected panel's measures, or a wide set
with no common factor; ordinal scale_items with no common factor, or binary items
forming a one-hot partition; a time_index that is not a calendar column; an
all-distinct or calendar group_var; a non-numeric outcome_continuous; an
outcome_binary without exactly two observed values. A false alarm costs a
reviewer one read of the reason; a missed error costs far more.group_var, outcome_*,
covariate) are not functions of the joint distribution (Lemma 2), so the data
cannot confirm them.sbp_base/sbp_end.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.
map is the table above; checks adds descriptive rows per role found in two or
more sites (value type, event levels and coding, group-label Jaccard, range and
range overlap, battery size); site_only lists roles found at one site only.type_mismatch, coding_mismatch (the
normalized event coding differs: 0/1, 1/2 and TRUE/FALSE normalize to the same
two-level shape, which level is the event is not checked; a 0/1/2 status differs) and size_mismatch (item counts differ). Jaccard and range overlap are
reported, never flagged, and no numeric threshold decides a flag.covariate role is excluded.aligned means the detector found the role at every site; it does not establish that the columns measure the same thing; read checks (levels, Jaccard, ranges) before pooling. Cell order is alphabetical and carries no pairing direction.group_var is detected for character/factor groupings.survival::veteran, diagtime/prior), that
pair is reported instead of the documented one. Name-blind, the two cannot be told apart.paired_pairs and stay silent; lifting
that exclusion was measured to misassign more outcomes than it recovered.agreement_pairs therefore
means "no detectable systematic shift": biased method-comparison (Bland-Altman) data with
a real calibration bias is labeled paired_pairs, and the label is n-dependent (a
0.5-unit bias at SD 15 flips from agreement to paired between n of about 200 and 1000).
About 3% of unbiased pairs at n = 100 read as paired under the null. Exactly one pair is
reported across the two roles.name_bonus adds 10 points on the 100 * B group scale, where candidates usually differ
by 1-5 points, so a group keyword hint is decisive in practice (a no-op under NULL).repeated_measures and binary-item scale_items still use the ported
point scorers (pct >= 30). Survival: ranking pairs by association bits picks the
strongest dependence (on survival::pbc, bilirubin/status), and every variant that
repaired this added false positives to the survival roles. Wide repeated measures: a
one-factor code on standardized columns measures shared variance only and discards the
same-units structure (equal means and spreads across repeats) that the scorer uses; four
block-growth variants raised false positives from 15 to 163-207 tables (dev-band measurement, pass 5). Binary items: an
all-pairs dependence test rejected every simulated binary battery.scale_items candidate set is not pruned: a stray ordinal covariate can ride
along with a real battery.group_var candidacy.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.
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.
Apache License 2.0.
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.