UNPKG

@bdelab/jscat

Version:

A library to support IRT-based computer adaptive testing in JavaScript

131 lines (115 loc) 4.64 kB
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import bs from 'binary-search'; import { Stimulus, Zeta, ZetaSymbolic } from './type'; import { fillZetaDefaults } from './corpus'; import _range from 'lodash/range'; import _round from 'lodash/round'; /** * 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): Array<[number, number]> => { const x = _range(min, max + stepSize, stepSize); function y(x: number) { return (1 / (Math.sqrt(2 * Math.PI) * stdDev)) * Math.exp(-Math.pow(x - mean, 2) / (2 * Math.pow(stdDev, 2))); } return x.map((x) => [_round(x, 6), y(x)]); }; /** * Return a uniform distribution within a given range * * @param {number} min - lower bound of the uniform distribution * @param {number} max - upper bound of the uniform distribution * @param {number} stepSize - the quantization (step size) of the internal table, default = 0.1 * @param {number} fullMin - full range minimum (defaults to min) * @param {number} fullMax - full range maximum (defaults to max) * @returns {Array<[number, number]>} - a uniform distribution */ export const uniform = ( min = -4, max = 4, stepSize = 0.1, fullMin?: number, fullMax?: number, ): Array<[number, number]> => { const actualMin = fullMin ?? min; const actualMax = fullMax ?? max; // Create the grid with rounding const x = _range(actualMin, actualMax + stepSize / 2, stepSize).map((n) => _round(n, 6)); const support = x.filter((theta) => theta >= min && theta <= max); const probabilityMass = 1 / support.length; return x.map((theta) => [theta, theta >= min && theta <= max ? probabilityMass : 0]); }; /** * 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; } } };