Psychometric research in R (Federdeck endpoints)
This guide shows a practical workflow for doing CTT + IRT work in R using Federdeck’s indexed endpoints. It focuses on fetching data, handling pagination, shaping into matrices, and fitting models.
What you need
- Federdeck instance base URL (example:
https://federdeck.com) - Deck AT-URI or Item AT-URI
- R packages:
httr2,jsonlite,dplyr,tidyr,tibble, plusmirt(recommended)
Endpoints used
/xrpc/com.federdeck.getResponsesByDeck?deck=AT_URI&limit=200&cursor=.../xrpc/com.federdeck.getResponsesByItem?item=AT_URI&limit=200&cursor=...
1) Install packages
install.packages(c("httr2", "jsonlite", "dplyr", "tidyr", "tibble"))
install.packages("mirt")
2) Fetch all pages from an endpoint (cursor pagination)
library(httr2)
library(jsonlite)
library(dplyr)
library(tidyr)
library(tibble)
ds_fetch_all <- function(url, query, max_pages = Inf, verbose = TRUE) {
out <- list()
cursor <- NULL
page <- 1
repeat {
q <- query
if (!is.null(cursor)) q$cursor <- cursor
resp <- request(url) |>
req_url_query(!!!q) |>
req_perform()
txt <- resp_body_string(resp)
obj <- fromJSON(txt, simplifyVector = TRUE)
if (!is.null(obj$responses) && length(obj$responses) > 0) {
out[[length(out) + 1]] <- as_tibble(obj$responses)
}
cursor <- obj$cursor
if (verbose) {
n <- ifelse(is.null(obj$responses), 0, nrow(as_tibble(obj$responses)))
message("page=", page, " rows=", n)
}
page <- page + 1
if (is.null(cursor)) break
if (page > max_pages) break
}
if (length(out) == 0) return(tibble())
bind_rows(out)
}
3) Example: fetch responses by deck
BASE <- "https://federdeck.com"
DECK <- "at://did:plc:.../com.federdeck.deck/3k...."
responses <- ds_fetch_all(
url = paste0(BASE, "/xrpc/com.federdeck.getResponsesByDeck"),
query = list(deck = DECK, limit = 200)
)
glimpse(responses)
4) Clean and decide attempt policy
Psychometrics usually needs one scored response per person×item. If a person answered the same item multiple times, choose a policy. Common: first attempt for “initial difficulty” or last attempt for “final performance”.
responses_clean <- responses |>
transmute(
user = userDid,
item = itemId,
correct = as.integer(correct),
answered_at = answeredAt,
response_time = responseTime
) |>
filter(!is.na(user), user != "", !is.na(item), item != "")
# last attempt policy
responses_one <- responses_clean |>
arrange(user, item, answered_at) |>
group_by(user, item) |>
slice_tail(n = 1) |>
ungroup()
5) Create person × item matrix (0/1/NA)
mat_df <- responses_one |>
select(user, item, correct) |>
distinct() |>
pivot_wider(names_from = item, values_from = correct)
person_id <- mat_df$user
mat <- as.matrix(select(mat_df, -user))
storage.mode(mat) <- "numeric"
6) Quick CTT checks
# Item p-values (difficulty proxies)
p_values <- colMeans(mat, na.rm = TRUE)
# Missingness per item
missing_rate <- colMeans(is.na(mat))
ctt <- tibble(
item = colnames(mat),
p = p_values,
missing = missing_rate
) |>
arrange(p)
print(ctt, n = 25)
7) Fit Rasch (1PL) / 2PL using mirt
library(mirt)
mod_rasch <- mirt(mat, 1, itemtype = "Rasch", verbose = FALSE)
mod_2pl <- mirt(mat, 1, itemtype = "2PL", verbose = FALSE)
summary(mod_rasch)
summary(mod_2pl)
8) Extract parameters and scores
# Item parameters (IRT parameterization)
ip_rasch <- coef(mod_rasch, IRTpars = TRUE, simplify = TRUE)$items
ip_2pl <- coef(mod_2pl, IRTpars = TRUE, simplify = TRUE)$items
# Person scores (EAP)
theta_rasch <- fscores(mod_rasch, method = "EAP")
theta_2pl <- fscores(mod_2pl, method = "EAP")
Reproducibility checklist (recommended)
- Record the queried deck/item AT-URI(s).
- Record the instance base URL (index coverage differs by instance).
- Record retrieval timestamp and your attempt policy (first vs last).
- Keep the raw JSON pages if you need full auditability.