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.
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 0The same call on the name-stripped twin yields the same roles by position:
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"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.
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:
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"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).
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"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.
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.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.
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.