This document describes the process of adding custom item models to rpf as was done by a contributor to the rpf package (Carl F. Falk) with assistance from the package author (Joshua N. Pritikin) for the logistic function of a monotonic polynomial (LMP) item model (Falk & Cai, in press).
This document assumes experience in editing R and C++ code and installing R packages from source. In addition, use of versioning control software (e.g., git) is also helpful. Not mentioned are platform-specific tools or compilers that need to be in place in order to compile/install packages from source.
The rpf package is modular in the sense that one only needs to write the underlying functions specific to the item model, such as the traceline (ICC, IRF, etc.), derivatives w.r.t. item parameters and the latent trait(s), and a few utility functions. As a byproduct, estimation of a measurement model that utilizes the item model may be possible via use of an external package (e.g., OpenMx)
Obtain rpf source code from CRAN or another source such as GitHub. I used the latter as I wanted to use git to track my own changes and then later merge with the rpf package.
Most work needs to be done in C++ using the first file mentioned in the subsequent section. Testing appears to be easiest from R, however, with some functions in the rpf package yielding access to the underlying C++ functions. It is therefore possible to also glean some insight into what the underyling C++ functions ought to do, and their input by examining rpf documentation and experimenting with associated functions.
The bulk of work that needs to be done to implement an item model takes place in the src/libifa-rpf.cpp file.
For example, librpf_model[]
towards the end of the
file contains available item models with associated C++
functions required to implement each item model. As a
concrete example, the following code chunk
effectively lists all functions used by the
unidimensional LMP item model:
{"lmp",
irt_rpf_1dim_lmp_numSpec,
irt_rpf_1dim_lmp_numParam,
irt_rpf_1dim_lmp_paramInfo,
irt_rpf_1dim_lmp_prob,
irt_rpf_logprob_adapter,
irt_rpf_1dim_lmp_deriv1,
irt_rpf_1dim_lmp_deriv2,
irt_rpf_1dim_lmp_dTheta,
irt_rpf_1dim_lmp_rescale,
}
To add a novel item model, one would need to add an
analogous chunk of code to librpf_model[]
with
the given functions pertaining to those used by the
novel item model. Each type of function takes input
in the same way regardless of the item model. This
is a feature that helps make rpf modular.
It is easiest to understand the purpose of each function by focusing on the suffixes for the functions above. These suffixes are referenced below. The prefixes often tell us which item model the function pertains to. Below also effectively contains stubs that could be used to start constructing a new item model.
numSpec
Each item model (e.g., when created by R) contains
a vector that contains a specification for the item
model. This will contain information such as the
number of categories, factors, and other item-specific
information. I will discuss how this specification is
constructed from R input at a later section of this
document. I assume the numSpec
function
merely returns the number of elements of this vector.
For example:
static int
irt_rpf_1dim_lmp_numSpec(const double *spec)
{ return RPF_ISpecCount; }
numParam
Based only on information in the item specification, return an integer that indicates how many parameters the item has. For example, the LMP model has a user-specified integer k that determines the number of parameters and appears as the last element in the specification vector.
static int
irt_rpf_1dim_lmp_numParam(const double *spec)
{
int k = spec[RPF_ISpecCount];
return(2+2*k);
}
paramInfo
Returns information (in pointers) regarding a single
item parameter based on the item specification *spec
,
the index of the item parameter param
. This generally assumes
that the item parameters always appear in a particular
order for the item model, which can be determined based
only on the item specification. For instance, a
two-parameter logistic item model will always have a
slope and intercept term, which appear in that order,
and the number of slopes depends on the number of factors.
Returned information may include the type of
parameter **type
which is a text description of
the item parameter (e.g., “Slope” or “Intercept”), and
the upper and lower bounds for the item parameter, *upper
and *lower
, which may be set to nan("unset")
if
bounds are not applicable. Below is only a stub, not the full
function for the LMP model.
static void
irt_rpf_1dim_lmp_paramInfo(const double *spec, const int param,
const char **type, double *upper, double *lower)
{
\\ Code implementing paramInfo goes here
}
prob
The traceline (ICC, IRF, etc.) function for the item model.
This function should compute the probability of response
to each category (stored in *out
in that order),
based on a fixed vector of item parameters,*param
, and
at a single point of the latent space, *th
. That is,
if the item model is dichotomous, *out
will contain
two values, and if the latent trait has three dimensions,
*th
will have three values. In unidimensional models,
*th
will have only a single value.
static void
irt_rpf_1dim_lmp_prob(const double *spec,
const double *param, const double *th,
double *out)
{
\\ Code implementing prob goes here
}
logprob_adapter
or logprob
I assume this function computes the log of the traceline,
which in some cases can be done simply by obtaining
information from the prob
function corresponding
to the item model and is what logprob_adapter
does.
irt_rpf_logprob_adapter(const double *spec,
const double *param, const double *th,
double *out)
{
\\ Code implementing logprob goes here
}
deriv1
This function computes first and second-order complete data derivatives the negative log-likelihood for a single item with respect to item parameters. e.g., for use in optimization at the M-Step of the EM algorithm.
This function assumes complete data on the latent trait as is often the case after the E-Step of the EM algorithm has effectively computed expected counts for each possible category at each quadrature node.
Note that derivatives are at a single point of the latent
trait, *where
which is analogous to *th
from
the traceline function. This may represent a single
quadrature node, or if complete data were available it
might represent a single respondent’s score on the
latent trait.
*weight
is a vector that determines how much
weight to give to each response category. If input to
the function were to assume a single respondent, this
vector is effectively an indicator function that will
have a “1” in the proper location indicating the
person’s response. In the case of EM, the weight vector
may contain expected counts at this particular quadrature
node for each of the item’s possible response categories.
*out
will contain the derivatives for use
elsewhere. Note that this vector may not be empty
when entering the function, e.g., in the case that
deriv1
is repeatedly called to compute
derivatives summing across multiple
quadrature points or respondents. From the rpf
documentation, the order of elements in *out
is…
“For p parameters, the first p values are the first
derivative and the next p(p+1)/2 columns are the lower
triangle of the second derivative.”
static void
irt_rpf_1dim_lmp_deriv1(const double *spec,
const double *param,
const double *where,
const double *weight, double *out)
{
// Code implementing deriv1 goes here
}
deriv2
This function implements derivatives or computations
that may occur once any time derivatives are taken,
regardless of how many quadrature nodes or respondents.
Whereas deriv1
for example, may be called repeatedly
(once for for each quadrature node, deriv2
would
only be called once when computing derivatives across
such quadrature nodes. That is, currently the
rpf.dLL
function in R from rpf may do deriv1
multiple times followed by deriv2
before returning derivatives to the user.
static void irt_rpf_1dim_lmp_deriv2(const double *spec,
const double *param,
double *out)
{
\\ Code implementing deriv2 goes here
}
dTheta
Compute derivatives of the traceline w.r.t. the latent trait, e.g., for use in computing item information.
These derivative computations happen at a single point
of the latent trait, *where
. Derivatives
for each category response function should appear,
in order from least to gretest, in *grad
(first-order),
and *hess
(second-order).
The vector *dir
is a basis vector used in the
case of multidimensional models.
static void irt_rpf_1dim_lmp_dTheta(const double *spec, const double *param,
const double *where, const double *dir,
double *grad, double *hess)
{
\\ Code implementing dTheta goes here
}
rescale
Currently this function is not yet used and could go unimplemented. However, the intuition behind it was based on Schilling & Bock (2005), equations 22 and 23 for implementation of adaptive quadrature.
static void
irt_rpf_1dim_lmp_rescale(const double *spec, double *param, const int *paramMask,
const double *mean, const double *cov)
{
error("Rescale for LMP model not implemented");
}
The the R/ subfolder, each item model or class of item models has their own associated R file. For example, “grm.R” or lmp.R” for the Graded Response model and the LMP item model.
A function common to each of these files is one that will construct the item specification based on user input. An example for the Graded Response Models is:
rpf.grm <- function(outcomes=2, factors=1, multidimensional=TRUE) {
if (!multidimensional && factors > 1) {
stop("More than 1 dimension must use a multidimensional model")
}
m <- NULL
id <- -1
if (!multidimensional) {
stop("The old parameterization is no longer available")
} else {
id <- rpf.id_of("grm")
m <- new("rpf.mdim.grm",
outcomes=outcomes,
factors=factors)
}
m@spec <- c(id, m@outcomes, m@factors)
m
}
Note that input to the function may be item specific,
however, what the function returns ought to be in a
standard format. Specifically, a list containing the id
of the item model (as determined by rpf.id_of
, which
requires a String that matches that added to the end
of librpf_model[]
), the number of categories (or
outcomes), and number of factors is required. Additional
optional elements may be appended to the specification
as required by the item model. For example, LMP requires
that k be added to the end of the specification to
determine the order of a polynomial and number of item
parameters, and does not currently have a multidimensional
version.
rpf.lmp <- function(q=1, multidimensional=FALSE) {
if(!(q%%1==0)){
stop("q must be an integer >= 0")
}
if(multidimensional){
stop("Multidimensional LMP model is not yet supported")
}
m <- NULL
id <- -1
id <- rpf.id_of("lmp")
m <- new("rpf.1dim.lmp",
outcomes=2,
factors=1)
m@spec <- c(id, 2, m@factors, q)
m
}
Other functions in such an .R file appear to be
optional, but may include those that specify how random
parameters be generated for the item model, e.g.,
rpf.rparam
, or how an item model may be modified
to create a similar item model rpf.modify
Note also that these functions ought to have documentation for the item model that is in a format usable by roxygen2
Any new files created in the previous step ought to be added to the “Collate” statement in the DESCRIPTION file.
A class ought to be added to this file for the item model. This will allow generic functions or wrappers to access item specific C++ code. It is difficult to provide specific recommendations on how to add the class other than to look at other examples. For instance, the following code snippet defines the LMP dichotomous response model, “rpf.lmp.drm”, as a subclass of the unidimensional response model superclass, “rpf.1dim”.
##' Unidimensional logistic function of a monotonic polynomial
##'
##' @export
##' @name Class rpf.1dim.lmp
##' @rdname rpf.1dim.lmp-class
##' @aliases rpf.1dim.lmp-class
##'
setClass("rpf.1dim.lmp", contains='rpf.1dim')
Thus, the above is all that was required to add a class for the LMP item model. Alternative item models may use the multidimensional superclass, “rpf.mdim” or a specialized class for graded response models.
To avoid headaches in debugging, it may be useful
to write code incrementally and test/debug before
continuing to write more. For instance, after
writing stubs for C++ code, see if the code will
compile, perhaps by using R CMD check rpf
or some such from the command line of the OS.
Not all pieces need to be in place before testing
in R. For instance, it may make practical sense
to implement the traceline function in C++, with
suffix, prob
, and test via R (see below)
before continuing to write deriv1
and dTheta
functions and so on.
Examination of the accompanying R functions,
their documentation in the rpf package,
and their output for well-known item models
may also provide insight into how the underlyling
C++ functions operate.
To test the underlying C++ from R, all
modifications to R files as mentioned in the
previous section ought to be done. However,
only C++ functions that you want to test
need to be implemented. Compile and install
the package for testing, e.g.,
R CMD INSTALL rpf
.
Fire up R and begin testing.
library(rpf)
lmp.item<-rpf.lmp(q=2) # create item w/ 5th order polynomial
par<-c(.69,.71,-.5,-8.48,.52,-3.32) # item parameters
theta<-seq(-3,3,.1) # grid for latent trait
## Test the traceline or "prob" C++ function
P<-rpf.prob(lmp.item, par, theta)
## Prettier plots than this are of course possible
plot(theta, P[2,], type="l", ylim=c(0,1), xlab="Theta", ylab="P(Theta)")
The plot above should look like Example 2 from Falk and Cai (in press).
Access to derivatives w.r.t. item parameters and
latent traits is also possible. Note that rpf.dLL
appears to call both deriv1
and deriv2
C++ functions.
## Derivatives of negative log-likelihood at arbitrary point with arbitrary weights
## Rounding only for easy reading for tutorial
round(rpf.dLL(lmp.item, par, theta[1], weight=c(5,7)),2)
## [1] 19.73 -6.11 -58.72 0.11 25.32 0.40 28.35 -2.67 0.83
## [10] -84.40 7.95 1171.18 0.16 -0.02 -0.15 0.11 36.40 -3.43
## [19] -190.32 0.29 36.38 0.58 -0.05 -4.09 0.01 0.22 0.40
For latent traits, with easy ways of testing accuracy against numerical derivatives - at least for a unidimensional model.
## Analytical derivatives "deriv1" followed by "deriv2"
rpf.dTheta(lmp.item, par, where=-.5, dir=1)
## $gradient
## [1] -0.4404183 0.4404183
##
## $hessian
## [1] -0.3160515 0.3160515
## Numerical derivatives
library(numDeriv)
dTheta.wrap<-function(theta, spec, par, cat=1){
rpf.prob(spec, par, theta)[cat]
}
## should match first element of gradient from rpf.dTheta
grad(dTheta.wrap, -.5, spec=lmp.item, par=par)
## [1] -0.4404183
## should match first element of hessian from rpf.dTheta
hessian(dTheta.wrap, -.5, spec=lmp.item, par=par)
## [,1]
## [1,] -0.3160515
One could also test numerical derivatives of the log-likelihood w.r.t. item parameters in a similar manner. However, this may take a little extra work, and note that numerical derivatives may not always work well, especially when testing during estimation and when parameter estimates are far from the MLE.
If any formal testing is done that the user wishes to be reproducible, which is highly encouraged and some may argue is necessary, these formal tests can currently be added to inst/tests/
Forthcoming.
Forthcoming.