Data structures for rasch models

Joshua A. McGrane

library(rasch)

This vignette shows the data required by each model. The examples use plain data frames so that the required columns are visible. Column names can differ; name their roles in the fitting function.

Rasch, partial credit and rating scale models

Use one row per person and one column per item. Scores begin at zero. A dichotomous item is coded 0/1; an item with four categories is coded 0, 1, 2 or 3. Missing values may be NA; negative missing-value codes are also accepted through na_codes.

responses <- data.frame(
  person = c("P01", "P02", "P03", "P04"),
  group = c("control", "control", "treated", "treated"),
  I1 = c(0, 1, 1, 1),
  I2 = c(0, 1, 2, 2),
  I3 = c(1, 2, 3, NA)
)
responses
#>   person   group I1 I2 I3
#> 1    P01 control  0  0  1
#> 2    P02 control  1  1  2
#> 3    P03 treated  1  2  3
#> 4    P04 treated  1  2 NA

The identifier and person factors are not item responses. Name them explicitly, or nominate the item columns.

fit <- rasch(
  responses,
  id = "person",
  factors = "group",
  items = c("I1", "I2", "I3"),
  model = "PCM"
)

Use model = "RSM" only when the items share one rating-scale threshold structure. Items may have different maximum scores in the partial credit model.

External weights are supplied after fitting. Item weights are a named numeric vector. Set weights additionally need an item-to-set map, except for an EFRM fit, which already contains one.

item_weights <- c(I1 = 2, I2 = 1, I3 = 0.5)
weighted <- weighted_person_estimates(fit, item_weights)

set_of <- c(I1 = "core", I2 = "core", I3 = "extension")
set_weights <- c(core = 2, extension = 1)
weighted_person_estimates(fit, set_weights, by = "set", sets = set_of)

save_outputs(fit, "analysis", person_weights = weighted) # new or empty folder

Only relative weights matter. The weighted estimates are supplementary; they do not replace the Rasch estimates used for fit, reliability, targeting or DIF. In the application, use a CSV with item,weight, or item,set,weight for set weights. The computed table can also be passed to save_outputs(), report_html() or report_document().

Explanatory item and threshold models

The response data remain in the wide form above. A second data frame — the one uploaded as a CSV in the application — describes the items. Its shape depends on the level being explained, and that is the distinction to get right before anything else.

An item-level design has one row per item. The predictors describe the item as a whole, and the model explains the item’s location.

A threshold-level design has one row per fitted threshold, identified by item and threshold together. The predictors may vary within an item, and the model explains each threshold separately.

The number of rows a threshold design needs is fixed by the data, not chosen: an item scored 0 to m has m thresholds. For the responses above that is one for I1, two for I2 and three for I3 — six rows, not three.

items <- c("I1", "I2", "I3")
maxima <- vapply(responses[items], max, 0, na.rm = TRUE)
data.frame(item = items, max_score = maxima, thresholds = maxima,
           row.names = NULL)
#>   item max_score thresholds
#> 1   I1         1          1
#> 2   I2         2          2
#> 3   I3         3          3
sum(maxima)          # rows a threshold-level design must have
#> [1] 6

One row per item

item_design <- data.frame(
  item = items,
  format = c("selected", "constructed", "constructed"),
  demand = c(0.2, 0.7, 1.1)
)
item_design
#>   item      format demand
#> 1   I1    selected    0.2
#> 2   I2 constructed    0.7
#> 3   I3 constructed    1.1
fit <- rasch_explanatory(
  responses,
  predictors = item_design,
  formula = ~ format + demand + format:demand,
  level = "item",
  id = "person",
  factors = "group",
  items = items
)

One row per threshold

The same two predictors at threshold level. format is a property of the item, so it simply repeats down that item’s thresholds; demand now varies between them, which is the reason to work at this level at all. An item-level predictor is not excluded from a threshold design — it is constant within each item.

threshold_design <- data.frame(
  item = rep(items, maxima),
  threshold = sequence(maxima),
  format = rep(c("selected", "constructed", "constructed"), maxima),
  demand = c(0.2,
             0.5, 0.9,
             0.4, 0.8, 1.4)
)
threshold_design
#>   item threshold      format demand
#> 1   I1         1    selected    0.2
#> 2   I2         1 constructed    0.5
#> 3   I2         2 constructed    0.9
#> 4   I3         1 constructed    0.4
#> 5   I3         2 constructed    0.8
#> 6   I3         3 constructed    1.4
nrow(threshold_design) == sum(maxima)
#> [1] TRUE
fit <- rasch_explanatory(
  responses,
  predictors = threshold_design,
  formula = ~ format + demand,
  level = "threshold",
  id = "person",
  factors = "group",
  items = items
)

threshold numbers the thresholds within an item, from 1, in order. It is not a running count across the instrument: I2 has thresholds 1 and 2, and so does the first half of I3. sequence(maxima) above generates exactly that pattern, and is worth using in place of typing the column out.

Three mistakes account for most refused designs. Each is named precisely when it happens, so the message identifies the rows to fix:

Categorical predictors should be factors, continuous predictors numeric, and ordinal predictors ordered factors with their substantive order declared. In the application, upload the same table as a CSV: item plus the predictor columns at item level, and item,threshold plus the predictor columns at threshold level. The predictor type of each column is set after the upload, so a column read as text can still be declared ordinal with its order given.

Multiple ratings

Long data use one row per observed rating. The required roles are person, item, score and at least one facet. A rater, task or occasion can be a facet. A person-group variable is instead supplied through factors.

ratings <- data.frame(
  person = c("P01", "P01", "P02", "P02"),
  item = c("Essay1", "Essay2", "Essay1", "Essay2"),
  rater = c("R1", "R2", "R2", "R1"),
  occasion = c("first", "first", "first", "first"),
  score = c(2, 3, 1, 2)
)
ratings
#>   person   item rater occasion score
#> 1    P01 Essay1    R1    first     2
#> 2    P01 Essay2    R2    first     3
#> 3    P02 Essay1    R2    first     1
#> 4    P02 Essay2    R1    first     2
fit <- rasch_mfrm(
  ratings,
  person = "person",
  item = "item",
  score = "score",
  facets = c("rater", "occasion")
)

Wide multiple-ratings data use one row per person-by-facet combination and one score column per item.

wide_ratings <- data.frame(
  person = c("P01", "P01", "P02", "P02"),
  rater = c("R1", "R2", "R1", "R2"),
  Essay1 = c(2, 3, 1, 2),
  Essay2 = c(3, 2, 2, 2)
)
wide_ratings
#>   person rater Essay1 Essay2
#> 1    P01    R1      2      3
#> 2    P01    R2      3      2
#> 3    P02    R1      1      2
#> 4    P02    R2      2      2
fit <- rasch_mfrm(
  wide_ratings,
  person = "person",
  facets = "rater",
  items = c("Essay1", "Essay2")
)

Long data are usually easier to inspect when a facet varies within items.

Extended Frames

EFRM response data use one row per person and one column per item, together with a person-group column. A separate named map assigns each item to exactly one item set. Supplied person identifiers must not repeat; missing or blank identifiers are treated as different unknown persons.

frame_data <- data.frame(
  person = paste0("P", 1:6),
  group = rep(c("A", "B"), each = 3),
  S1I1 = c(0, 1, 1, 0, 1, 1),
  S1I2 = c(0, 0, 1, 0, 1, 1),
  S2I1 = c(0, 1, 1, 0, 0, 1),
  S2I2 = c(0, 1, 1, 0, 1, 1)
)
frame_data
#>   person group S1I1 S1I2 S2I1 S2I2
#> 1     P1     A    0    0    0    0
#> 2     P2     A    1    0    1    1
#> 3     P3     A    1    1    1    1
#> 4     P4     B    0    0    0    0
#> 5     P5     B    1    1    0    1
#> 6     P6     B    1    1    1    1

item_sets <- list(
  set1 = c("S1I1", "S1I2"),
  set2 = c("S2I1", "S2I2")
)
item_sets
#> $set1
#> [1] "S1I1" "S1I2"
#> 
#> $set2
#> [1] "S2I1" "S2I2"
fit <- rasch_efrm(
  frame_data,
  item_sets = item_sets,
  groups = "group",
  id = "person"
)

Persons must connect the item sets, and common items across person groups must identify the group units. Crossed person-group factors can be supplied as several column names in groups.

Comparative Judgement

Use one row per comparison. object_a and object_b identify the pair. For a dichotomous comparison, winner contains one of those two object names.

comparisons <- data.frame(
  object_a = c("A", "A", "B", "A"),
  object_b = c("B", "C", "C", "C"),
  winner = c("A", "C", "B", "A"),
  judge = c("J1", "J1", "J2", "J2")
)
comparisons
#>   object_a object_b winner judge
#> 1        A        B      A    J1
#> 2        A        C      C    J1
#> 3        B        C      B    J2
#> 4        A        C      A    J2
fit <- btl(
  comparisons,
  object_a = "object_a",
  object_b = "object_b",
  winner = "winner",
  judge = "judge"
)

For ordered comparisons, replace winner with an ordered response column whose categories run from preference for object B to preference for object A. A frequency column can represent repeated identical rows. A judgement-order column is needed to estimate exposure or carry-over dependence.

Explanatory Comparative Judgement adds one metadata row per object.

object_design <- data.frame(
  object = c("A", "B", "C"),
  genre = factor(c("essay", "essay", "report")),
  length = c(800, 950, 700)
)
object_design
#>   object  genre length
#> 1      A  essay    800
#> 2      B  essay    950
#> 3      C report    700
fit <- btl_explanatory(
  comparisons,
  predictors = object_design,
  formula = ~ genre + length,
  object_a = "object_a",
  object_b = "object_b",
  winner = "winner",
  judge = "judge"
)

Extended Frames for Comparative Judgement

The comparison rows additionally identify each judge’s panel. A named list assigns every object to one object set. Both within-set and cross-set comparisons are required to identify set units and origins.

object_sets <- list(
  set1 = c("S1A", "S1B", "S1C"),
  set2 = c("S2A", "S2B", "S2C")
)
object_sets
#> $set1
#> [1] "S1A" "S1B" "S1C"
#> 
#> $set2
#> [1] "S2A" "S2B" "S2C"

# the first four rows compare within a set, the last two across sets
frame_comparisons <- data.frame(
  object_a = c("S1A", "S1B", "S2A", "S2B", "S1A", "S1C"),
  object_b = c("S1B", "S1C", "S2B", "S2C", "S2A", "S2C"),
  winner   = c("S1A", "S1C", "S2A", "S2B", "S2A", "S1C"),
  judge    = c("J1", "J1", "J2", "J2", "J3", "J3"),
  panel    = c("east", "east", "west", "west", "east", "east")
)
frame_comparisons
#>   object_a object_b winner judge panel
#> 1      S1A      S1B    S1A    J1  east
#> 2      S1B      S1C    S1C    J1  east
#> 3      S2A      S2B    S2A    J2  west
#> 4      S2B      S2C    S2B    J2  west
#> 5      S1A      S2A    S2A    J3  east
#> 6      S1C      S2C    S1C    J3  east
fit <- btl_efrm(
  frame_comparisons,
  object_a = "object_a",
  object_b = "object_b",
  winner = "winner",
  judge = "judge",
  panels = "panel",
  object_sets = object_sets
)

Simulated data

The Simulate page in the Shiny application generates every structure above, assigns its roles and retains the generating values. The corresponding R functions are simulate_rasch(), simulate_btl(), simulate_mfrm(), simulate_efrm() and simulate_btl_efrm(). Explanatory simulations add the item or object metadata used to generate the locations. Each simulator stores its parameter values and planted departures in attr(data, "truth"). The app can download the data as a CSV or as a bundle containing the generating call, true values and explanatory metadata.

d <- simulate_efrm(n_per_group = 100, items_per_set = 5, seed = 4)
names(attr(d, "truth"))
#>  [1] "layout"          "description"     "person_id"       "theta"          
#>  [5] "difficulty"      "thresholds"      "departure_types" "alpha"          
#>  [9] "phi"             "item_sets"       "groups"          "item_drift"     
#> [13] "careless_idx"    "missing_cells"   "planted"