Name-blind variable-role detection with rolescry

The problem: names lie, data does not

Real datasets arrive with column names that are missing, misleading, in the wrong language, or simply wrong: a column called category_code holding continuous lab values, a gender column that is actually a free numeric measurement, an outcome buried under an opaque v7. Any tool that decides a column’s statistical role from its name inherits every one of those lies.

rolescry decides roles from the data signature instead – the guiding principle is Data inspice, non nomen (“inspect the data, not the name”). Renaming every column to col_1, col_2, ... does not change a single role assignment. This is the turnusol (litmus) invariant, and it is the package’s keystone test.

A worked example

set.seed(1)
pre  <- rnorm(100, 10, 2)          # a measurement, before ...
d <- data.frame(
  arm  = rep(c("control", "treatment"), each = 50),  # a balanced 2-level grouping
  pre  = pre,
  post = pre + rnorm(100, 1, 1)                       # ... and again after (paired with pre)
)

res <- detect_roles(d)
res
#> <role_detection> 100 observations x 3 variables
#>   group_var          arm                          pct=100.0
#>   paired_pairs       pre, post                    pct=100.0
summary(res)
#>                  role found  columns pct
#> 1           group_var  TRUE      arm 100
#> 2        paired_pairs  TRUE pre,post 100
#> 3     agreement_pairs FALSE            0
#> 4       time_variable FALSE            0
#> 5      event_variable FALSE            0
#> 6        coupled_pair FALSE            0
#> 7          time_index FALSE            0
#> 8          subject_id FALSE            0
#> 9   repeated_measures FALSE            0
#> 10        scale_items FALSE            0
#> 11 outcome_continuous FALSE            0
#> 12     outcome_binary FALSE            0
#> 13          covariate FALSE            0

The same call on the name-stripped twin yields the same roles by position:

d_blind <- setNames(d, paste0("col_", seq_along(d)))
pos <- function(r, dat) match(r$roles$paired_pairs$columns, names(dat))
identical(pos(detect_roles(d), d), pos(detect_roles(d_blind), d_blind))
#> [1] TRUE

How a role is scored

Each column is first typed by value (continuous, binary, categorical, ID), never by name. Candidate roles are then scored by signatures that capture the statistical shape a role implies. Group variables are ranked by normalized entropy; paired versus agreement measurements are separated by whether the difference carries a systematic shift (a description-length criterion); ordinal scale items by a one-factor description-length code; survival, wide repeated measures and binary scale items still use point signatures (event-rate and right-skew, same-units correlation blocks, item endorsement). Every score is a transparent set of named components you can inspect (bits for derived roles, points for the rest):

res$roles$paired_pairs$components[[1]]
#> $name
#> [1] "consensus/difference MDL"
#> 
#> $score
#> [1] 103.7925
#> 
#> $max
#> [1] NA
#> 
#> $detail
#> [1] "pair bits=103.8, shift bits=47.4"

Shannon entropy

For a categorical column with level proportions \(p_1, \dots, p_k\), the normalized Shannon entropy

\[ H_{\text{norm}} = \frac{-\sum_i p_i \log_2 p_i}{\log_2 k} \in [0, 1] \]

measures how balanced the levels are. A grouping variable (treatment vs control) has high entropy (near-balanced); a near-constant flag has entropy near zero. Entropy drives both the value classifier and the group-balance signal.

Normalized mutual information

To ask – name-blind – whether a candidate grouping actually carries information about an outcome, rolescry uses normalized mutual information:

\[ \text{NMI}(X, Y) = \frac{I(X; Y)}{\min\{H(X),\, H(Y)\}} \in [0, 1], \]

which is 0 for independent variables and 1 for a deterministic association, and is comparable across variables with different numbers of levels. It is exposed directly:

set.seed(2)
g <- sample(c("A", "B", "C"), 300, replace = TRUE)
y <- ifelse(g == "A", "event", sample(c("event", "none"), 300, replace = TRUE))
compute_nmi(g, y)          # > 0: g informs y
#> [1] 0.2893517
compute_nmi(g, sample(g))  # ~ 0: shuffled -> independent
#> [1] 0.001503439

Honesty under ambiguity (0.3.0)

Some role questions have no answer in the data alone. A dependent pair of one binary and one positive continuous column is equally readable as a survival (time, event) pair or as a (cause, outcome) pair; rolescry assigns the survival roles only when the censoring geometry singles that reading out, and otherwise reports the pair as a pair and leaves the interpretation to you:

set.seed(3)
n <- 300; arm <- rep(0:1, each = n / 2)
end <- rnorm(n, 150, 15) - 10 * arm
rct <- data.frame(arm = arm, sbp_end = end,
                  resp = rbinom(n, 1, plogis(0.05 * (end - 140))))
cp <- detect_roles(rct)$roles$coupled_pair
cp$columns
#> [1] "sbp_end" "arm"
cp$interpretations
#> [1] "survival: time = continuous member, event = binary member"        
#> [2] "causal: cause = continuous member, binary outcome = binary member"

Beyond one domain

The signatures are domain-free, so the same call reads financial, social or experimental tables. Calendar columns (Date/POSIXct, or character columns whose every value parses as a date) are reported as time_index; a balanced long panel whose entity/time axes are value-symmetric is reported as axis-ambiguous (repeated_measures$panel_axes) unless serial dependence of the measures singles one reading out:

set.seed(5)
px <- data.frame(dt = as.Date("2024-01-02") + 0:199,
                 price = 500 + cumsum(rnorm(200)), volume = rlnorm(200, 11, 0.5))
detect_roles(px)$roles$time_index$columns
#> [1] "dt"

A finance keyword dictionary is available for the capped name channel: rolescry_default_name_bonus("finance") (or "all" for the union with the clinical dictionary).

The optional, capped name bonus

Names are not useless – they are just untrustworthy. When you do trust them, pass a keyword dictionary via name_bonus. Names then act only as a small, capped tie-breaker (at most a +10 point nudge, i.e. <= 10% of the selection score); the mathematical signature still dominates (>= 90%), the relationship enforced by score_gap_ok().

Here two columns both look like groupings; the data alone picks the perfectly balanced site, but a keyword dictionary nudges the choice to the intended treatment arm:

set.seed(4)
clin <- data.frame(
  site    = rep(c("north", "south"), length.out = 160),  # perfectly balanced (the math pick)
  treated = sample(c("no", "yes"), 160, replace = TRUE, prob = c(0.62, 0.38))
)
detect_roles(clin)$roles$group_var$columns                                  # data alone -> "site"
#> [1] "site"
detect_roles(clin, name_bonus = rolescry_default_name_bonus())$roles$group_var$columns  # name nudge -> "treated"
#> [1] "treated"

Verifying a declared analysis plan

verify_roles() asks whether the data can support a role assignment you declare. Here the claim wrongly pairs age with sbp_end; the real pre/post pair is left out.

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)

The paired claim is contradicted: age and sbp_end share no information beyond the noise floor, a falsification backed by a value statistic. id is consistent as a subject identifier (all values distinct). The two design claims are unverifiable(identifiability): which column is the grouping or the outcome is a design fact the joint distribution cannot reveal, so the data never call it consistent. A consistent verdict elsewhere means only “a reading the data support”, never “correct”, and unverifiable(coverage) means the package has no test or the evidence is empty. The last block lists the real pair sbp_base/sbp_end, which the claim did not mention.

Aligning roles across sites

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.

Header-aware loading

read_data() reads a file with the header row found by the same information-theoretic scorer (detect_header()), so messy exports with title rows or merged cells still load with sensible column names. Delimited text works with base R; spreadsheet and statistical formats use optional packages and degrade gracefully if they are not installed.

Attribution

rolescry is derived from Boynukara, C. (2026). MDStatR (v2.1.0 Veritas). Zenodo. https://doi.org/10.5281/zenodo.20707791. Run citation("rolescry") to cite the package and its parent engine.

rolescry has no MDStatR dependency and requires only base R; see “Relationship to MDStatR” in the README.