UNPKG

@bdelab/jscat

Version:

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

295 lines (294 loc) 13 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Cat = void 0; /* eslint-disable @typescript-eslint/no-non-null-assertion */ const optimization_js_1 = require("optimization-js"); const utils_1 = require("./utils"); const corpus_1 = require("./corpus"); const seedrandom_1 = __importDefault(require("seedrandom")); const clamp_1 = __importDefault(require("lodash/clamp")); const cloneDeep_1 = __importDefault(require("lodash/cloneDeep")); class Cat { /** * Create a Cat object. This expects an single object parameter with the following keys * @param {{method: string, itemSelect: string, nStartItems: number, startSelect:string, theta: number, minTheta: number, maxTheta: number, priorDist: string, priorPar: number[]}=} destructuredParam * method: ability estimator, e.g. MLE or EAP, default = 'MLE' * itemSelect: the method of item selection, e.g. "MFI", "random", "closest", default method = 'MFI' * nStartItems: first n trials to keep non-adaptive selection * startSelect: rule to select first n trials * theta: initial theta estimate * minTheta: lower bound of theta * maxTheta: higher bound of theta * priorDist: the prior distribution type (only applies to EAP estimator) * priorPar: the prior distribution parameters (only applies to EAP estimator) * randomSeed: set a random seed to trace the simulation */ constructor({ method = 'MLE', itemSelect = 'MFI', nStartItems = 0, startSelect = 'middle', theta = 0, minTheta = -6, maxTheta = 6, priorDist = 'norm', // only applies to EAP estimator priorPar = priorDist === 'unif' ? [-4, 4] : [0, 1], // only applies to EAP estimator randomSeed = null, } = {}) { this.method = Cat.validateMethod(method); this.itemSelect = Cat.validateItemSelect(itemSelect); this.startSelect = Cat.validateStartSelect(startSelect); this.minTheta = minTheta; this.maxTheta = maxTheta; this.priorDist = priorDist; this.priorPar = priorPar; this._zetas = []; this._resps = []; this._theta = theta; this._seMeasurement = Number.MAX_VALUE; this.nStartItems = nStartItems; this._rng = randomSeed === null ? (0, seedrandom_1.default)() : (0, seedrandom_1.default)(randomSeed); this._prior = this.method === 'eap' ? Cat.validatePrior(priorDist, priorPar, minTheta, maxTheta) : []; } get theta() { return this._theta; } get seMeasurement() { return this._seMeasurement; } /** * Return the number of items that have been observed so far. */ get nItems() { return this._resps.length; } get resps() { return this._resps; } get zetas() { return this._zetas; } get prior() { return this._prior; } static validatePrior(priorDist, priorPar, minTheta, maxTheta) { if (priorDist === 'norm') { if (priorPar.length !== 2) { throw new Error(`The prior distribution parameters should be an array of two numbers. Received ${priorPar}.`); } const [mean, sd] = priorPar; if (sd <= 0) { throw new Error(`Expected a positive prior distribution standard deviation. Received ${sd}`); } if (mean < minTheta || mean > maxTheta) { throw new Error(`Expected the prior distribution mean to be between the min and max theta. Received mean: ${mean}, min: ${minTheta}, max: ${maxTheta}`); } return (0, utils_1.normal)(mean, sd, minTheta, maxTheta); } else if (priorDist === 'unif') { if (priorPar.length !== 2) { throw new Error(`The prior distribution parameters should be an array of two numbers. Received ${priorPar}.`); } const [minSupport, maxSupport] = priorPar; if (minSupport >= maxSupport) { throw new Error(`The uniform distribution bounds you provided are not valid (min must be less than max). Received min: ${minSupport} and max: ${maxSupport}`); } if (minSupport < minTheta || maxSupport > maxTheta) { throw new Error(`The uniform distribution bounds you provided are not within theta bounds. Received minTheta: ${minTheta}, minSupport: ${minSupport}, maxSupport: ${maxSupport}, maxTheta: ${maxTheta}.`); } return (0, utils_1.uniform)(minSupport, maxSupport, 0.1, minTheta, maxTheta); } throw new Error(`priorDist must be "unif" or "norm." Received ${priorDist} instead.`); } static validateMethod(method) { const lowerMethod = method.toLowerCase(); const validMethods = ['mle', 'eap']; // TO DO: add staircase if (!validMethods.includes(lowerMethod)) { throw new Error('The abilityEstimator you provided is not in the list of valid methods'); } return lowerMethod; } static validateItemSelect(itemSelect) { const lowerItemSelect = itemSelect.toLowerCase(); const validItemSelect = ['mfi', 'random', 'closest', 'fixed']; if (!validItemSelect.includes(lowerItemSelect)) { throw new Error('The itemSelector you provided is not in the list of valid methods'); } return lowerItemSelect; } static validateStartSelect(startSelect) { const lowerStartSelect = startSelect.toLowerCase(); const validStartSelect = ['random', 'middle', 'fixed']; // TO DO: add staircase if (!validStartSelect.includes(lowerStartSelect)) { throw new Error('The startSelect you provided is not in the list of valid methods'); } return lowerStartSelect; } /** * use previous response patterns and item params to calculate the estimate ability based on a defined method * @param zeta - last item param * @param answer - last response pattern * @param method */ updateAbilityEstimate(zeta, answer, method = this.method) { method = Cat.validateMethod(method); zeta = Array.isArray(zeta) ? zeta : [zeta]; answer = Array.isArray(answer) ? answer : [answer]; // Ensure zeta parameters are numbers to prevent string concatenation issues zeta = zeta.map((z) => (0, corpus_1.ensureZetaNumericValues)(z)); zeta.forEach((z) => (0, corpus_1.validateZetaParams)(z, true)); if (zeta.length !== answer.length) { throw new Error('Unmatched length between answers and item params'); } this._zetas.push(...zeta); this._resps.push(...answer); if (method === 'eap') { this._theta = this.estimateAbilityEAP(); } else if (method === 'mle') { this._theta = this.estimateAbilityMLE(); } this._theta = (0, clamp_1.default)(this._theta, this.minTheta, this.maxTheta); this.calculateSE(); } estimateAbilityEAP() { let num = 0; let nf = 0; this._prior.forEach(([theta, probability]) => { const like = Math.exp(this.likelihood(theta)); // Convert back to probability num += theta * like * probability; nf += like * probability; }); return num / nf; } estimateAbilityMLE() { const theta0 = [0]; const solution = (0, optimization_js_1.minimize_Powell)(this.negLikelihood.bind(this), theta0); const theta = solution.argument[0]; return theta; } negLikelihood(thetaArray) { return -this.likelihood(thetaArray[0]); } likelihood(theta) { return this._zetas.reduce((acc, zeta, i) => { const irf = (0, utils_1.itemResponseFunction)(theta, zeta); return this._resps[i] === 1 ? acc + Math.log(irf) : acc + Math.log(1 - irf); }, 1); } /** * calculate the standard error of ability estimation */ calculateSE() { const sum = this._zetas.reduce((previousValue, zeta) => previousValue + (0, utils_1.fisherInformation)(this._theta, zeta), 0); this._seMeasurement = 1 / Math.sqrt(sum); } /** * find the next available item from an input array of stimuli based on a selection method * * remainingStimuli is sorted by fisher information to reduce the computation complexity for future item selection * @param stimuli - an array of stimulus * @param itemSelect - the item selection method * @param deepCopy - default deepCopy = true * @returns {nextStimulus: Stimulus, remainingStimuli: Array<Stimulus>} */ findNextItem(stimuli, itemSelect = this.itemSelect, deepCopy = true) { let arr; let selector = Cat.validateItemSelect(itemSelect); if (deepCopy) { arr = (0, cloneDeep_1.default)(stimuli); } else { arr = stimuli; } arr = arr.map((stim) => (0, corpus_1.fillZetaDefaults)(stim, 'semantic')); if (this.nItems < this.nStartItems) { selector = this.startSelect; } if (selector !== 'mfi' && selector !== 'fixed') { // for mfi, we sort the arr by fisher information in the private function to select the best item, // and then sort by difficulty to return the remainingStimuli // for fixed, we want to keep the corpus order as input arr.sort((a, b) => a.difficulty - b.difficulty); } if (selector === 'middle') { // middle will only be used in startSelect return this.selectorMiddle(arr); } else if (selector === 'closest') { return this.selectorClosest(arr); } else if (selector === 'random') { return this.selectorRandom(arr); } else if (selector === 'fixed') { return this.selectorFixed(arr); } else { return this.selectorMFI(arr); } } selectorMFI(inputStimuli) { const stimuli = inputStimuli.map((stim) => (0, corpus_1.fillZetaDefaults)(stim, 'semantic')); const stimuliAddFisher = stimuli.map((element) => (Object.assign({ fisherInformation: (0, utils_1.fisherInformation)(this._theta, (0, corpus_1.fillZetaDefaults)(element, 'symbolic')) }, element))); stimuliAddFisher.sort((a, b) => b.fisherInformation - a.fisherInformation); stimuliAddFisher.forEach((stimulus) => { delete stimulus['fisherInformation']; }); return { nextStimulus: stimuliAddFisher[0], remainingStimuli: stimuliAddFisher.slice(1).sort((a, b) => a.difficulty - b.difficulty), }; } selectorMiddle(arr) { let index; index = Math.floor(arr.length / 2); if (arr.length >= this.nStartItems) { index += this.randomInteger(-Math.floor(this.nStartItems / 2), Math.floor(this.nStartItems / 2)); } const nextItem = arr[index]; arr.splice(index, 1); return { nextStimulus: nextItem, remainingStimuli: arr, }; } selectorClosest(arr) { //findClosest requires arr is sorted by difficulty const index = (0, utils_1.findClosest)(arr, this._theta + 0.481); const nextItem = arr[index]; arr.splice(index, 1); return { nextStimulus: nextItem, remainingStimuli: arr, }; } selectorRandom(arr) { const index = this.randomInteger(0, arr.length - 1); const nextItem = arr.splice(index, 1)[0]; return { nextStimulus: nextItem, remainingStimuli: arr, }; } /** * Picks the next item in line from the given list of stimuli. * It grabs the first item from the list, removes it, and then returns it along with the rest of the list. * * @param arr - The list of stimuli to choose from. * @returns {Object} - An object with the next item and the updated list. * @returns {Stimulus} return.nextStimulus - The item that was picked from the list. * @returns {Stimulus[]} return.remainingStimuli - The list of what's left after picking the item. */ selectorFixed(arr) { const nextItem = arr.shift(); return { nextStimulus: nextItem, remainingStimuli: arr, }; } /** * return a random integer between min and max * @param min - The minimum of the random number range (include) * @param max - The maximum of the random number range (include) * @returns {number} - random integer within the range */ randomInteger(min, max) { return Math.floor(this._rng() * (max - min + 1)) + min; } } exports.Cat = Cat;