@bdelab/jscat
Version:
A library to support IRT-based computer adaptive testing in JavaScript
101 lines (91 loc) • 3.55 kB
text/typescript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import bs from 'binary-search';
import { Stimulus, Zeta, ZetaSymbolic } from './type';
import { fillZetaDefaults } from './corpus';
/**
* Calculates the probability that someone with a given ability level theta will
* answer correctly an item. Uses the 4 parameters logistic model
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} the probability
*/
export const itemResponseFunction = (theta: number, zeta: Zeta) => {
const _zeta = fillZetaDefaults(zeta, 'symbolic') as ZetaSymbolic;
return _zeta.c + (_zeta.d - _zeta.c) / (1 + Math.exp(-_zeta.a * (theta - _zeta.b)));
};
/**
* A 3PL Fisher information function
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} - the expected value of the observed information
*/
export const fisherInformation = (theta: number, zeta: Zeta) => {
const _zeta = fillZetaDefaults(zeta, 'symbolic') as ZetaSymbolic;
const p = itemResponseFunction(theta, _zeta);
const q = 1 - p;
return Math.pow(_zeta.a, 2) * (q / p) * (Math.pow(p - _zeta.c, 2) / Math.pow(1 - _zeta.c, 2));
};
/**
* Return a Gaussian distribution within a given range
*
* @param {number} mean
* @param {number} stdDev
* @param {number} min
* @param {number} max
* @param {number} stepSize - the quantization (step size) of the internal table, default = 0.1
* @returns {Array<[number, number]>} - a normal distribution
*/
export const normal = (mean = 0, stdDev = 1, min = -4, max = 4, stepSize = 0.1) => {
const distribution = [];
for (let i = min; i <= max; i += stepSize) {
distribution.push([i, y(i)]);
}
return distribution;
function y(x: number) {
return (1 / (Math.sqrt(2 * Math.PI) * stdDev)) * Math.exp(-Math.pow(x - mean, 2) / (2 * Math.pow(stdDev, 2)));
}
};
/**
* Find the item in a given array that has the difficulty closest to the target value
*
* @remarks
* The input array of stimuli must be sorted by difficulty.
*
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty
* @param {number} target - ability estimate
* @returns {number} the index of stimuli
*/
export const findClosest = (inputStimuli: Array<Stimulus>, target: number) => {
const stimuli = inputStimuli.map((stim) => fillZetaDefaults(stim, 'semantic'));
// Let's consider the edge cases first
if (target <= stimuli[0].difficulty!) {
return 0;
} else if (target >= stimuli[stimuli.length - 1].difficulty!) {
return stimuli.length - 1;
}
const comparitor = (element: Stimulus, needle: number) => {
return element.difficulty! - needle;
};
const indexOfTarget = bs(stimuli, target, comparitor);
if (indexOfTarget >= 0) {
// `bs` returns a positive integer index if it found an exact match.
return indexOfTarget;
} else {
// If the value is not in the array, then -(index + 1) is returned, where
// index is where the value should be inserted into the array to maintain
// sorted order. Thus, the target is between the values at
const lowIndex = -2 - indexOfTarget;
const highIndex = -1 - indexOfTarget;
// So we simply compare the differences between the target and the high and
// low values, respectively
const lowDiff = Math.abs(stimuli[lowIndex].difficulty! - target);
const highDiff = Math.abs(stimuli[highIndex].difficulty! - target);
if (lowDiff < highDiff) {
return lowIndex;
} else {
return highIndex;
}
}
};