UNPKG

@bdelab/jscat

Version:

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

256 lines (255 loc) 10.5 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")); const abilityPrior = (0, utils_1.normal)(); 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, prior: 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 * prior: the prior distribution * randomSeed: set a random seed to trace the simulation */ constructor({ method = 'MLE', itemSelect = 'MFI', nStartItems = 0, startSelect = 'middle', theta = 0, minTheta = -6, maxTheta = 6, prior = abilityPrior, randomSeed = null, } = {}) { this.method = Cat.validateMethod(method); this.itemSelect = Cat.validateItemSelect(itemSelect); this.startSelect = Cat.validateStartSelect(startSelect); this.minTheta = minTheta; this.maxTheta = maxTheta; this.prior = prior; 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); } 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; } 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]; 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.calculateSE(); } estimateAbilityEAP() { let num = 0; let nf = 0; this.prior.forEach(([theta, probability]) => { const like = this.likelihood(theta); 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 (0, clamp_1.default)(theta, this.minTheta, this.maxTheta); } 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;