---
title: "Bayesian Probabilistic Knowledge Structures^[Florian Wickelmaier,
Department of Psychology, University of Tübingen, Schleichstr. 4, 72076
Tübingen, Germany, wickelmaier@web.de. Portions of this material were
presented at the Psychoco International Workshop on Psychometric Computing,
February 5-6, 2026, Padua, Italy.]"
author:
- Florian Wickelmaier
- Julian Mollenhauer
- Alice Jenisch
date: "2026-09-21"
format:
html:
toc: true
code-download: true
code-tools: true
embed-resources: true
bibliography: "BayesianPKS.bib"
abstract: "Knowledge structure theory (KST) seeks to provide procedures for
the effective diagnosis of a student's knowledge state in a certain domain
(such as algebra, geometry, physics, or statistics; Doignon & Falmagne, 1999;
Heller & Stefanutti, 2024). Here, we introduce Bayesian versions of two
prevalent probabilistic KST models: the basic local independence model and the
simple learning model. We demonstrate how these models can be coded in Stan
and interfaced with R. The Bayesian framework can then be leveraged to assess
model adequacy via posterior predictive checks and for model comparison via
cross-validation. These procedures are illustrated with simulated responses
and a real-data example."
---
```{r}
#| message: false
#| code-fold: true
#| code-summary: "R setup"
rerun_dgp <- FALSE
refit_model <- FALSE
library(pks)
library(rstan)
options(mc.cores = 4)
set.seed(1617)
## For plotting with Rgraphviz
myattrs <- list(
graph = list(rankdir = "BT", bgcolor = "white"),
edge = list(arrowsize = NULL),
node = list(fillcolor = "lightblue", fontsize = 10,
shape = "rectangle", fixedsize = FALSE)
)
```
<!-- --------------------------------------------------------------------- -->
# Probabilistic models in knowledge structure theory
<!-- --------------------------------------------------------------------- -->
Knowledge structure theory (KST) seeks to provide procedures for the effective
diagnosis of a student's knowledge state in a certain domain, such as algebra,
geometry, physics, or statistics [@DoignonFalmagne99; @FalmagneKoppen90;
@HellerStefanutti24].
The basic notions in KST are
- the knowledge domain: a finite set $Q$ of (dichotomous) problems (also
called items);
- the knowledge state: the subset $K \subseteq Q$ of problems in the domain
$Q$ a student masters;
- the knowledge structure: a collection $\mathcal{K}$ of knowledge states that
contains at least the empty set $\emptyset$ and the set $Q$.
Only a subset of all possible response patterns $\mathcal{R}$ are also
knowledge states in $\mathcal{K}$.
## Example: High school geometry
@Lakshminarayan95 presented 959 undergraduate students with the five problems
in high school geometry shown in @fig-items.
::: {#fig-items layout-ncol=2}
{#fig-item_a}
{#fig-item_b}
{#fig-item_c}
{#fig-item_d}
{#fig-item_e}
Five high school geometry problems.
:::
With the knowledge domain $Q = \{a,b,c,d,e\}$ consisting of five items, there
are $2^{|Q|} = 32$ possible response patterns ranging from *no problem solved*
($00000$) to *all problems solved* ($11111$). Not all of them are plausible
knowledge states because solving a more difficult problem may require that a
simpler problem is solved first. @DoignonFalmagne99 introduced a knowledge
structure that is used here for illustration; it consists of $|\mathcal{K}| =
9$ knowledge states (@fig-K-DF7).
```{r}
#| label: fig-K-DF7
#| fig-cap: "Graph of a knowledge structure with nine knowledge states."
#| fig-width: 4
data(DoignonFalmagne7, package = "pks") # provides knowledge structure
K <- DoignonFalmagne7$K
rownames(K) <- as.pattern(K, useNames = TRUE)
rownames(K)[rownames(K) == "abcde"] <- "Q"
plot(relations::as.relation(is.subset(K)), attrs = myattrs, main = "")
```
## Deterministic and probabilistic theory
The knowledge structure in @fig-K-DF7 is special in two ways: First, it is
closed under union and thus called a knowledge space. Second, it is
well-graded: it can be decomposed into learning paths along which a student
can traverse -- by learning how to solve one problem at a time -- from a state
of ignorance $\emptyset$ to full mastery $Q$. Consequently, $\mathcal{K}$ is
downgradable: For any nonempty state $K$ there is some problem $q$ such that
$K \smallsetminus \{q\} \in \mathcal{K}$. The problems a student in state $K$
is ready to learn at each step,
$$
K^\mathcal{O} = \{q \notin K \mid K \cup \{q\} \in \mathcal{K}\},
$$
is called the outer fringe of $K$ (see @fig-K-DF7-PathFringe).
```{r}
#| label: fig-K-DF7-PathFringe
#| fig-cap: "Panel a: Knowledge states marked in blue are along a learning
#| path from $\\emptyset$ to $Q.$ Panel b: Knowledge states marked in green
#| are the ones relevant for obtaining the outer fringe of $\\{a, b\\}$."
#| fig-subcap:
#| - "Learning path $b \\to a \\to c \\to d \\to e$"
#| - "Outer fringe $\\{a, b\\}^\\mathcal{O} = \\{c, d\\}$"
#| layout-ncol: 2
#| fig-height: 7
#| fig-width: 6
#| code-fold: true
rel <- relations::as.relation(is.subset(K))
mycol <- setNames(rep("white", 9),
unlist(relations::relation_domain(rel)[[1]]))
mycol["{}"] <-
mycol["b"] <-
mycol["ab"] <-
mycol["abc"] <-
mycol["abcd"] <-
mycol["Q"] <- "lightblue"
plot(rel,
nodeAttrs = list(fillcolor = mycol),
attrs = myattrs, main = "")
mycol <- setNames(rep("white", 9),
unlist(relations::relation_domain(rel)[[1]]))
mycol["ab"] <- "lightblue"
mycol["abd"] <-
mycol["abc"] <- "lightgreen"
plot(rel,
nodeAttrs = list(fillcolor = mycol),
attrs = myattrs, main = "")
```
In order to formulate a probabilistic model, a distinction is introduced
between knowledge state and response pattern [@FalmagneDoignon88BJMSP;
@FalmagneDoignon88JMP]. A knowledge state $K$ is a latent class, and the
response pattern $R$ is a manifest indicator of the knowledge state. As a
consequence, this gives rise to two types of response errors,
- a careless error, which occurs if the response is incorrect although the
problem is contained in $K$;
- a lucky guess, which occurs if the response is correct although the problem
is not contained in $K$.
The latent knowledge states themselves are considered to be random variables
with a probability distribution. Formally, a probabilistic knowledge structure
consists of
- a knowledge structure $\mathcal{K}$ on a knowledge domain $Q$ of problems;
- the conditional probabilities $P(R \mid K)$ to observe response pattern $R$
given state $K$;
- the marginal distribution $P(K)$ on the knowledge states $K \in
\mathcal{K}$.
The probability of the response pattern $R \in \mathcal{R} = 2^Q$ then is
predicted by
$$
P(R) = \sum_{K \in \mathcal{K}} P(R \mid K) \cdot P(K),
$$
and the response frequencies $y = (N_R)_{R \in \mathcal{R}}$ follow a
multinomial likelihood:
$$
p(y \mid \theta) = \prod_{R \in \mathcal{R}} P(R)^{N_R}.
$$
Two prevalent models that make specific assumptions about the conditional and
marginal probabilities are the basic local independence model and the
simple learning model.
### The basic local independence model (BLIM)
For the conditional distribution $P(R \mid K)$, the BLIM assumes that given
the knowledge state $K$ of a student, responses are stochastically independent
over problems (local independence). This implies that the response to each
problem $q$ only depends on the probabilities of a careless error $\beta_q$
and a lucky guess $\eta_q$. Accordingly, the probability of the response
pattern $R$ given the knowledge state $K$ becomes
$$
P(R \mid K) =
\prod_{q \in K \setminus R} \beta_q
\cdot \prod_{q \in K \cap R} (1 - \beta_q)
\cdot \prod_{q \in R \setminus K} \eta_q
\cdot \prod_{q \in \bar{R} \cap \bar{K}} (1 - \eta_q).
$$
For example, the probability that a student in state $\{a,b\}$ solves the
problems $b$ and $d$ is
$$
P(01010 \mid \{a,b\}) =
\beta_a \cdot (1 - \beta_b) \cdot (1 - \eta_c) \cdot \eta_d \cdot
(1 - \eta_e).
$$
For the marginal distribution $P(K)$, the BLIM does not imply any
restrictions; for each knowledge state $K$ it introduces a parameter $\pi_K$
with $\pi_K \in (0, 1)$ and $\sum_K \pi_K = 1$. Thus, in total it has $2
\cdot |Q| + |\mathcal{K}| - 1$ free parameters.
### The simple learning model (SLM)
Just like the BLIM, the SLM assumes local independence for the conditional
distribution of responses given a knowledge state $P(R \mid K)$. On top of
that, the SLM establishes restrictions on the marginal distribution of
knowledge states $P(K)$. It requires $\mathcal{K}$ to be a well-graded
knowledge space, that is a knowledge space that is downgradable. For each
problem, it introduces a solvability parameter $g_q$, and the probability of a
knowledge state $K$ becomes
$$
P(K) = \prod_{q \in K} g_q \!
\prod_{q \in K^\mathcal{O}} \! (1 - g_q).
$$
This is the probability of mastering all problems in $K$ while not mastering
any problems accessible from $K$. Accessible here means: problems that are in
the outer fringe $K^\mathcal{O}$ of $K$. For example, with the well-graded
knowledge space in @fig-K-DF7-PathFringe, the probability of being in state
$\{a,b\}$ is
$$
P(\{a,b\}) = g_a \cdot g_b \cdot (1 - g_c) \cdot (1 - g_d).
$$
In total, the SLM has $3 \cdot |Q|$ free parameters.
<!-- --------------------------------------------------------------------- -->
# Fitting models to artificial responses
<!-- --------------------------------------------------------------------- -->
## The BLIM as data-generating model
### Simulating responses
Using the `simulate` function from the *pks* package, we generate response
patterns for 2000 students using the knowledge structure in @fig-K-DF7 and the
BLIM as data generating process (dgp). We assume a fixed distribution over the
knowledge states and set all careless error and guessing probabilities to 0.1.
```{r}
dgp <- function(K) {
nitem <- ncol(K)
m0 <- list(
P.K = setNames(c(0.15, 0.1, 0.1, 0.08, 0.1, 0.12, 0.1, 0.1, 0.15),
as.pattern(K)),
beta = rep(0.1, nitem),
eta = rep(0.1, nitem),
K = K,
ntotal = 2000
)
class(m0) <- "blim"
y <- simulate(m0, method = "multinomial", zeropad = TRUE)
attr(y, "dgp") <- m0
y
}
```
```{r, eval = rerun_dgp}
N.R <- dgp(K)
dput(N.R, file = "data/data_dgp_blim.R")
```
```{r}
N.R <- dget("data/data_dgp_blim.R")
datlist <- list(
R = as.binmat(N.R), # distinct response patterns
K = K, # knowledge states
Ko = getKFringe(K), # outer fringes
y = c(N.R), # by-pattern multinomial count
ycat = match(rep(names(N.R), N.R), names(N.R)), # by-subj response category
nstate = nrow(K),
nitem = ncol(K),
N = sum(N.R)
)
datlist$npattern <- nrow(datlist$R)
datlist$y |> print()
datlist$ycat |> table()
```
The list of data that is passed on to Stan includes two versions of the
generated responses: `y` is a named vector that contains the multinomial
counts $N_R$, some may be zero, but its length always is `npattern`, which is
the number of all possible distinct response patterns; `ycat` is a vector that
contains the response category of each student as a position index in `y`, it
must be a number between 1 and `npattern`. Whenever some response patterns
were not observed, the response frequency vector `y` should be completed with
zeros, as is achieved by the `zeropad = TRUE` setting above. The models below
do not account for missingness of single item responses [see
@deChiusoleStefanutti15].
### Fitting the BLIM
In Stan, the BLIM can be written as follows:
```{stan, file = "stan/blim.stan", eval = FALSE, output.var = ""}
```
The conditional distribution $P(R \mid K)$ is implemented as a separate
`getPRK` function, which is called in the model block to build the multinomial
likelihood. The weakly informative priors for the $\beta_q$ and $\eta_q$
parameters are beta distributions assuming low error rates. In addition, the
error rates are restricted to fall below 0.5; this is to avoid nonsensical
behavior, such as a correct response from guessing being more likely than
from mastering the problem. The prior for the $\pi_K$ parameters is a diffuse
Dirichlet distribution, as both high and low probabilities for the knowledge
states are plausible a priori.
In summary, the Bayesian BLIM can be written as:
\begin{align*}
\beta_q &\sim beta(2, 9), \quad \beta_q \in (0, 0.5)\\
\eta_q &\sim beta(2, 9), \quad \eta_q \in (0, 0.5)\\
\pi_K &\sim dirichlet(\mathbf{\alpha}), \quad
\mathbf{\alpha} = (1, \dots, 1)\\
y &\sim multinomial(P(R)), \quad y = (N_R)_{R \in \mathcal{R}},
\quad \sum_{R \in \mathcal{R}} N_R = N\\
P(R) &= \sum_{K \in \mathcal{K}} P(R \mid K) \cdot P(K)\\
P(R \mid K) &=
\prod_{q \in K \setminus R} \beta_q
\cdot \prod_{q \in K \cap R} (1 - \beta_q)
\cdot \prod_{q \in R \setminus K} \eta_q
\cdot \prod_{q \in \bar{R} \cap \bar{K}} (1 - \eta_q)\\
P(K) &= \pi_K, \quad \pi_K \in (0, 1),
\quad \sum_{K \in \mathcal{K}} \pi_K = 1,
\end{align*}
where $R$ denotes the response pattern, $N_R$ its frequency, and $K$ a
knowledge state.
Stan fits the model to the generated responses and draws posterior samples for
its parameters:
```{r, eval = refit_model}
## Code takes time to run; instead use saved output
blim1 <- stan("stan/blim.stan", data = datlist, seed = 1634)
saveRDS(blim1, "saved_fit/blim1.rds")
```
```{r}
blim1 <- readRDS(file = "saved_fit/blim1.rds")
print(blim1, pars = c("beta", "eta", "pi"), probs = c(.025, .975))
```
The convergence diagnostics and effective sample sizes look OK for all
parameters. @fig-recovery-blim shows the posterior distribution of the BLIM
parameters and their true values.
```{r}
#| label: fig-recovery-blim
#| fig-cap: "Posterior distribution of BLIM parameters and their true values."
#| fig-height: 6
#| fig-width: 5
#| code-fold: true
posteriorBlimPlot <- function(samples, npar = 5 + 5 + 9,
N.R = NULL, dgp = FALSE) {
with(samples,
boxplot(cbind(beta, eta, pi)[, npar:1], horizontal = TRUE,
border = "gray", axes = FALSE, xlab = "Parameter value")
)
points(rev(coef(attr(N.R, "dgp"))), 1:npar, pch = 4, col = "darkblue")
axis(1)
axis(2, at = 1:npar, rev(c(
expression(beta[a]),
expression(beta[b]),
expression(beta[c]),
expression(beta[d]),
expression(beta[e]),
expression(eta[a]),
expression(eta[b]),
expression(eta[c]),
expression(eta[d]),
expression(eta[e]),
expression(pi["{}"]),
expression(pi[a]),
expression(pi[b]),
expression(pi[ab]),
expression(pi[abc]),
expression(pi[abd]),
expression(pi[abcd]),
expression(pi[abce]),
expression(pi[Q])
)), las = 1)
legend("topright", if(dgp) "BLIM (dgp)" else "BLIM", bty = "n")
box()
}
par(mai = c(.6, .5, .02, .03),
mgp = c(2, .7, 0))
posteriorBlimPlot(extract(blim1), N.R = N.R, dgp = TRUE)
legend("bottomright", inset = c(0, -.02),
legend = c("true", "estimated"), pch = c(4, 16),
col = c("darkblue", "gray"), bty = "n", ncol = 2, xpd = NA)
```
Parameter recovery is not perfect, but certain tradeoffs between parameters
due to nonidentifiability were expected. As a consequence of the knowledge
structure being a well-graded knowledge space, some parameters of the BLIM are
not identifiable [@Heller17; @StefanuttiHeller12].
```{r}
c(
space = pks:::is.knowledgespace(K),
downgradable = is.downgradable(K),
forward = names(which(is.forward.graded(K))),
backward = names(which(is.backward.graded(K)))
)
```
The knowledge structure (@fig-K-DF7) is forward graded in problems $a$ and $b$
(they can be added to any state and still yield a state) and backward graded
in $d$ and $e$ (they can be removed from any state and still yield a state).
Thus, the error and guess probabilities of these problems trade off with some
of the state probabilities. The BLIM's partial nonidentifiability, however,
does not compromise its predictive performance as shown below
(@fig-postpred-blim).
### Fitting the SLM
In Stan, the SLM can be written like this:
```{stan, file = "stan/slm.stan", eval = FALSE, output.var = ""}
```
For the marginal state distribution $P(K)$, the code includes the `getPK`
function that is called in the model block. The solvability parameters $g_q$
have a beta prior. Its parameters should be chosen to produce an a priori
plausible marginal state distribution. The model-implied marginal distribution
evaluated at the prior mean can be checked via:
```{r}
getSlmPK(g = rep(5/6, 5), K = K, Ko = getKFringe(K))
```
In summary, the Bayesian SLM can be written as:
\begin{align*}
\beta_q &\sim beta(2, 9), \quad \beta_q \in (0, 0.5)\\
\eta_q &\sim beta(2, 9), \quad \eta_q \in (0, 0.5)\\
g_q &\sim beta(5, 1)\\
y &\sim multinomial(P(R)), \quad y = (N_R)_{R \in \mathcal{R}},
\quad \sum_{R \in \mathcal{R}} N_R = N\\
P(R) &= \sum_{K \in \mathcal{K}} P(R \mid K) \cdot P(K)\\
P(R \mid K) &=
\prod_{q \in K \setminus R} \beta_q
\cdot \prod_{q \in K \cap R} (1 - \beta_q)
\cdot \prod_{q \in R \setminus K} \eta_q
\cdot \prod_{q \in \bar{R} \cap \bar{K}} (1 - \eta_q)\\
P(K) &= \prod_{q \in K} g_q \! \prod_{q \in K^\mathcal{O}} \! (1 - g_q),
\end{align*}
where $R$ denotes the response pattern, $N_R$ its frequency, $K$ a knowledge
state, and $K^\mathcal{O}$ its outer fringe.
Fitting in Stan yields:
```{r, eval = refit_model, include = FALSE}
slm1 <- stan("stan/slm.stan", data = datlist, seed = 1652)
saveRDS(slm1, "saved_fit/slm1.rds")
```
```{r}
slm1 <- readRDS(file = "saved_fit/slm1.rds")
print(slm1, pars = c("beta", "eta", "g"), probs = c(.025, .975))
```
Also for the SLM, the convergence diagnostics and effective sample sizes look
OK for all parameters.
### Fitting the saturated multinomial model (MNM)
The MNM serves as a reference for the two substantive models. It can be
written like this:
```{stan, file = "stan/multinom.stan", eval = FALSE, output.var = ""}
```
The MNM has one parameter for each distinct response pattern, so in total
$2^{|Q|} - 1$ free parameters. We obtain the following abbreviated posterior
summary:
```{r, eval = refit_model, include = FALSE}
mnm1 <- stan("stan/multinom.stan", data = datlist, seed = 1721)
saveRDS(mnm1, "saved_fit/mnm1.rds")
```
```{r}
mnm1 <- readRDS(file = "saved_fit/mnm1.rds")
print(mnm1, pars = c("theta[1]", "theta[2]", "theta[32]"),
probs = c(.025, .975))
```
Again convergence diagnostics and effective sample sizes look good.
### Posterior predictive checks
@fig-postpred-blim shows the BLIM-generated and predicted frequencies for each
distinct response pattern. The BLIM achieves a close fit between observations
and predictions, not much worse than the MNM. The SLM's predictions are
further off; this is especially visible for patterns with higher frequency.
```{r}
#| label: fig-postpred-blim
#| fig-cap: "Predicted response frequencies for all models. Data were
#| generated by the BLIM."
#| fig-height: 5
#| fig-width: 8
#| code-fold: true
postPredPlot <- function(blim, slm, mnm, N.R, dgp = 1) {
par(mfrow = c(1, 3), omi = c(.4, .25, 0, 0), mai = c(.1, .1, .3, .02),
mgp = c(2, .7, 0))
blim$yrep |>
boxplot(border = "gray", horizontal = TRUE, axes = FALSE,
main = if(dgp == 1) "BLIM (dgp)" else "BLIM")
points(N.R, 1:32, pch = 4, col = "darkblue")
axis(1)
axis(2, 1:32, names(N.R), las = 1, tick = FALSE, line = -1,
outer = TRUE, cex.axis = 0.82)
box()
#
slm$yrep |>
boxplot(border = "gray", horizontal = TRUE, axes = FALSE,
main = if(dgp == 2) "SLM (dgp)" else "SLM")
points(N.R, 1:32, pch = 4, col = "darkblue")
axis(1)
axis(2, 1:32, labels = FALSE)
box()
#
mnm$yrep |>
boxplot(border = "gray", horizontal = TRUE, axes = FALSE, main = "MNM")
points(N.R, 1:32, pch = 4, col = "darkblue")
axis(1)
axis(2, 1:32, labels = FALSE)
box()
mtext(paste0("Frequency (N = ", sum(N.R), ")"),
side = 1, line = 1.5, outer = TRUE, cex = .8)
legend("bottomright", inset = c(0, -.12), pch = c(4, 16),
legend = c(if(dgp > 0) "generated" else "observed", "predicted"),
col = c("darkblue", "gray"), bty = "n", ncol = 2, xpd = NA)
}
postPredPlot(extract(blim1), extract(slm1), extract(mnm1), N.R)
```
### LOO-CV model comparison
When each model computes the observation-wise log-likelihood in the generated
quantities block, approximate leave-one-out cross-validation [LOO-CV,
@VehtariGelman17] is available to compare the models with respect to their
ability to predict out-of-sample observations.
```{r, cache = TRUE}
l1 <- loo(blim1) |> print()
l2 <- loo(slm1) |> print()
l3 <- loo(mnm1) |> print()
loo::loo_compare(list(BLIM = l1, SLM = l2, MNM = l3))
```
For all three models the approximation to LOO-CV is OK. The model most
successful at predicting new response frequencies is the BLIM, followed by the
MNM. The SLM is the least successful of the three. Remember that all three
models were estimated based on the BLIM-generated data.
## The SLM as data-generating model
### Simulating responses
We generate a new set of response frequencies from a rudimentary SLM. The
solvability parameters $g_q$ are all set to 0.8; this gives rise to a
distribution over the knowledge states that is different to the one above,
where the BLIM was the data generator. Otherwise, the settings are the same as
above.
```{r}
dgp <- function(K) {
nitem <- ncol(K)
m0 <- list(
beta = rep(0.1, nitem),
eta = rep(0.1, nitem),
g = rep(0.8, nitem),
K = K,
ntotal = 2000
)
m0$P.K <- getSlmPK(g = m0$g, K = K, Ko = getKFringe(K))
class(m0) <- c("slm", "blim")
y <- simulate(m0, method = "multinomial", zeropad = TRUE)
attr(y, "dgp") <- m0
y
}
```
```{r, eval = rerun_dgp}
N.R <- dgp(K)
dput(N.R, file = "data/data_dgp_slm.R")
```
```{r}
N.R <- dget("data/data_dgp_slm.R")
datlist$y <- c(N.R) |> print()
datlist$ycat <- match(rep(names(N.R), N.R), names(N.R))
datlist$N <- sum(N.R)
datlist$ycat |> table()
```
### SLM parameter recovery
```{r, eval = refit_model, include = FALSE}
blim2 <- stan("stan/blim.stan", data = datlist, seed = 1742)
saveRDS(blim2, "saved_fit/blim2.rds")
```
```{r, echo = FALSE, results = "hide"}
blim2 <- readRDS(file = "saved_fit/blim2.rds")
print(blim2, pars = c("beta", "eta", "pi"), probs = c(.025, .975))
```
```{r, eval = refit_model, include = FALSE}
slm2 <- stan("stan/slm.stan", data = datlist, seed = 1746)
saveRDS(slm2, "saved_fit/slm2.rds")
```
```{r}
slm2 <- readRDS(file = "saved_fit/slm2.rds")
print(slm2, pars = c("beta", "eta", "g"), probs = c(.025, .975))
```
@fig-recovery-slm shows the posterior distribution of SLM parameters and their
true values. Again, parameter recovery is not perfect. There is a visible
tradeoff between the solvability and careless-error parameters for problems
$d$ and $e$: Higher solvability goes along with more careless errors and vice
versa.
```{r}
#| label: fig-recovery-slm
#| fig-cap: "Posterior distribution of SLM parameters and their true values."
#| fig-height: 6
#| fig-width: 5
#| code-fold: true
posteriorSlmPlot <- function(samples, npar = 5 + 5 + 5,
N.R = NULL, dgp = FALSE) {
with(samples,
boxplot(cbind(beta, eta, g)[, npar:1], horizontal = TRUE,
border = "gray", axes = FALSE, xlab = "Parameter value")
)
points(rev(coef(attr(N.R, "dgp"))), 1:npar, pch = 4, col = "darkblue")
axis(1)
axis(2, at = 1:npar, rev(c(
expression(beta[a]),
expression(beta[b]),
expression(beta[c]),
expression(beta[d]),
expression(beta[e]),
expression(eta[a]),
expression(eta[b]),
expression(eta[c]),
expression(eta[d]),
expression(eta[e]),
expression(g[a]),
expression(g[b]),
expression(g[c]),
expression(g[d]),
expression(g[e])
)), las = 1)
legend("topright", if(dgp) "SLM (dgp)" else "SLM", bty = "n")
box()
}
par(mai = c(.6, .5, .02, .03),
mgp = c(2, .7, 0))
posteriorSlmPlot(extract(slm2), N.R = N.R, dgp = TRUE)
legend("bottomright", inset = c(0, -.02),
legend = c("true", "estimated"), pch = c(4, 16),
col = c("darkblue", "gray"), bty = "n", ncol = 2, xpd = NA)
```
```{r, eval = refit_model, include = FALSE}
mnm2 <- stan("stan/multinom.stan", data = datlist, seed = 1750)
saveRDS(mnm2, "saved_fit/mnm2.rds")
```
```{r, echo = FALSE, results = "hide"}
mnm2 <- readRDS(file = "saved_fit/mnm2.rds")
print(mnm2, pars = "theta", probs = c(.025, .975))
```
### Posterior predictive checks
In addition to the SLM, the BLIM and the MNM were fitted to the new data (not
shown, but see code supplement). @fig-postpred-slm shows the SLM-generated and
predicted frequencies for each distinct response pattern. All three models
achieve a close fit between observations and predictions.
```{r}
#| label: fig-postpred-slm
#| fig-cap: "Predicted response frequencies for all models. Data were
#| generated by the SLM."
#| fig-height: 5
#| fig-width: 8
#| code-fold: true
postPredPlot(extract(blim2), extract(slm2), extract(mnm2), N.R, dgp = 2)
```
### LOO-CV model comparison
```{r, cache = TRUE}
l1 <- loo(blim2) |> print()
l2 <- loo(slm2) |> print()
l3 <- loo(mnm2) |> print()
loo::loo_compare(list(BLIM = l1, SLM = l2, MNM = l3))
```
Now that the SLM has generated the responses that were used for parameter
estimation, it also turns out to be the model with the highest predictive
accuracy, closely followed by the BLIM. The MNM is worst of the three.
Considering the uncertainty as indicated by the `se_diff` and `p_worse`
values, the differences are not large.
<!-- --------------------------------------------------------------------- -->
# An empirical application
<!-- --------------------------------------------------------------------- -->
## High school geometry problems: Responses and knowledge structure
Lakshminarayan [-@Lakshminarayan95; -@Lakshminarayan96] presented the high
school geometry problems shown in @fig-items to 959 undergraduate students
before and after a lesson. The data were split into a training and a held-out
set; only the 800 responses in the training data set are reported. In this
application, the posttest responses (@fig-N.R) will be analyzed. They are
referred to by Doignon and Falmagne [-@DoignonFalmagne99, chap. 8].
```{r}
#| label: fig-N.R
#| fig-height: 7
#| fig-width: 6
#| fig-cap: "Response frequencies before and after a lesson on geometry."
data(hsgeometry, package = "pks")
dotchart(angles$N.R[, "posttest"], pch = 4,
main = "High school geometry problems",
xlab = "Response frequency (pre- [o] and posttest [x])")
points(angles$N.R[, "pretest"], 1:32)
mtext("Lakshminarayan (1995)", side = 3, line = 0.5)
N.R <- angles$N.R[, "posttest"] |>
print()
```
The knowledge structure proposed by @Lakshminarayan95 is displayed in
@fig-K-Lakshmi.
```{r}
#| label: fig-K-Lakshmi
#| fig-cap: "Graph of a knowledge structure with eleven knowledge states."
#| fig-width: 4
K <- angles$K
rownames(K) <- as.pattern(K, useNames = TRUE)
rownames(K)[rownames(K) == "abcde"] <- "Q"
plot(relations::as.relation(is.subset(K)), attrs = myattrs, main = "")
```
## Fitting no-guessing models in Stan
With an open-answer format like in this problem set, it is plausible to assume
that guessing the correct response is impossible. Therefore, all guessing
parameters $\eta_q$ are set to zero. Accordingly, the `getPRK` function has to
be adapted, so a no-guessing BLIM can be written in Stan like this:
```{stan, file = "stan/blim-noguess.stan", eval = FALSE, output.var = ""}
```
The above code assumes that when response patterns are missing, their
frequencies are set to zero such that the frequency vector is always complete.
An alternative strategy is to leave out missing response patterns: `R` only
contains the observed patterns and `y` their frequencies; `npattern` $<
2^{|Q|}$ is their number. Then, the likelihood in the model block should avoid
the `multinomial` distribution statement (which requires a simplex as an
argument). Instead, a custom likelihood can be coded like this:
```{stan, eval = FALSE, output.var = ""}
transformed parameters {
vector[npattern] logPR = log(getPRK(beta, eta_filter, K, R) * pi);
}
model {
beta ~ beta(2, 9);
pi ~ dirichlet(rep_row_vector(1.0, nstate));
target += sum(y .* logPR); // custom likelihood
}
```
### Posterior distributions
We pass the data to Stan and obtain posterior samples for the no-guessing
versions of the BLIM and the SLM.
```{r}
datlist <- list(
R = as.binmat(N.R),
K = K,
Ko = getKFringe(K),
y = N.R,
ycat = match(rep(names(N.R), N.R), names(N.R)),
nstate = nrow(K),
nitem = ncol(K),
N = sum(N.R)
)
datlist$npattern <- nrow(datlist$R)
```
```{r, eval = FALSE, include = FALSE}
blim3 <- stan("stan/blim-noguess.stan", data = datlist, seed = 1025)
saveRDS(blim3, "saved_fit/blim3.rds")
```
```{r}
blim3 <- readRDS(file = "saved_fit/blim3.rds")
print(blim3, pars = c("beta", "pi"), probs = c(.025, .975))
```
```{r, eval = FALSE, include = FALSE}
slm3 <- stan("stan/slm-noguess.stan", data = datlist, seed = 1026)
saveRDS(slm3, "saved_fit/slm3.rds")
```
```{r}
slm3 <- readRDS(file = "saved_fit/slm3.rds")
print(slm3, pars = c("beta", "g"), probs = c(.025, .975))
```
@fig-posterior-blimslm shows the posterior distribution of the BLIM and SLM
parameters.
```{r}
#| label: fig-posterior-blimslm
#| fig-cap: "Posterior distribution of BLIM and SLM parameters."
#| fig-height: 5
#| fig-width: 6
#| code-fold: true
posteriorBlimPlot <- function(samples, npar = 5 + 11,
N.R = NULL, dgp = FALSE) {
with(samples,
boxplot(cbind(beta, pi)[, npar:1], horizontal = TRUE,
xlim = c(1, 16), ylim = 0:1, border = "gray", axes = FALSE)
)
axis(1)
axis(2, at = 1:npar, rev(c(
expression(beta[a]),
expression(beta[b]),
expression(beta[c]),
expression(beta[d]),
expression(beta[e]),
expression(pi["{}"]),
expression(pi[a]),
expression(pi[b]),
expression(pi[ab]),
expression(pi[ad]),
expression(pi[bc]),
expression(pi[abc]),
expression(pi[abd]),
expression(pi[bcd]),
expression(pi[abcd]),
expression(pi[Q])
)), las = 1)
legend("topright", if(dgp) "BLIM (dgp)" else "BLIM", bty = "n")
box()
}
posteriorSlmPlot <- function(samples, npar = 5 + 5,
N.R = NULL, dgp = FALSE) {
with(samples,
boxplot(cbind(beta, g)[, npar:1], at = 1:npar + 6, horizontal = TRUE,
xlim = c(1, 16), ylim = 0:1, border = "gray", axes = FALSE)
)
axis(1)
axis(2, at = 1:npar + 6, rev(c(
expression(beta[a]),
expression(beta[b]),
expression(beta[c]),
expression(beta[d]),
expression(beta[e]),
expression(g[a]),
expression(g[b]),
expression(g[c]),
expression(g[d]),
expression(g[e])
)), las = 1)
legend("topright", if(dgp) "SLM (dgp)" else "SLM", bty = "n")
box()
}
par(mfrow = 1:2, mai = c(.4, .5, .02, .03), omi = c(.3, 0, 0, 0),
mgp = c(2, .7, 0))
posteriorBlimPlot(extract(blim3))
posteriorSlmPlot(extract(slm3))
mtext("Parameter value", side = 1, outer = TRUE)
```
```{r, eval = FALSE, include = FALSE}
mnm3 <- stan("stan/multinom.stan", data = datlist, seed = 1027)
saveRDS(mnm3, "saved_fit/mnm3.rds")
```
```{r, echo = FALSE, results = "hide"}
mnm3 <- readRDS(file = "saved_fit/mnm3.rds")
print(mnm3, pars = "theta", probs = c(.025, .975))
```
### Posterior predictive checks
@fig-postpred-hsgeometry shows observed response frequencies and their
predictions from the BLIM, SLM, and MNM. The BLIM predictions are almost as
close to the observations as the MNM predictions. The SLM is further off,
especially for response patterns with high frequency.
```{r}
#| label: fig-postpred-hsgeometry
#| fig-cap: "Observed and predicted response frequencies."
#| fig-height: 5
#| fig-width: 8
#| code-fold: true
postPredPlot(extract(blim3), extract(slm3), extract(mnm3), N.R, dgp = 0)
```
### LOO-CV model comparison
```{r, cache = TRUE}
l1 <- loo(blim3) |> print()
l2 <- loo(slm3) |> print()
l3 <- loo(mnm3) |> print()
loo::loo_compare(list(BLIM = l1, SLM = l2, MNM = l3))
```
According to LOO-CV, the BLIM is not much worse than the saturated multinomial
model at predicting replication data. The uncertainty reflected by the
standard error of this difference is high. The SLM is noticeably worse than
the other two models.
# Summary
In this case study, we introduced Bayesian versions of two prevalent models in
knowledge structure theory, the basic local independence model and the simple
learning model. We showed how to code these models in Stan, obtain posterior
estimates, run diagnostic checks, and compare them using cross validation. The
flexibility of the Bayesian framework makes it attractive for implementing
other KST models in the future.
# Computational details
The *pks* package version 0.8-0 [@HellerWickelmaier13] was used to generate
artificial responses, for data handling, for providing the high school
geometry data, and for plotting knowledge structures leveraging facilities
from *relations* [@relations] and *Rgraphviz* [@rgraphviz]. Stan was
interfaced from R using *rstan* version 2.32.7 [@RStan]. The *loo* package
version 2.10.1 [@loo] was used for running LOO-CV model comparison.
# References