@bdelab/jscat
Advanced tools
Comparing version 4.0.0 to 5.0.0
@@ -1,90 +0,4 @@ | ||
import { Stimulus, Zeta } from './type'; | ||
export declare const abilityPrior: number[][]; | ||
export interface CatInput { | ||
method?: string; | ||
itemSelect?: string; | ||
nStartItems?: number; | ||
startSelect?: string; | ||
theta?: number; | ||
minTheta?: number; | ||
maxTheta?: number; | ||
prior?: number[][]; | ||
randomSeed?: string | null; | ||
} | ||
export declare class Cat { | ||
method: string; | ||
itemSelect: string; | ||
minTheta: number; | ||
maxTheta: number; | ||
prior: number[][]; | ||
private readonly _zetas; | ||
private readonly _resps; | ||
private _nItems; | ||
private _theta; | ||
private _seMeasurement; | ||
nStartItems: number; | ||
startSelect: string; | ||
private readonly _rng; | ||
/** | ||
* 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, itemSelect, nStartItems, startSelect, theta, minTheta, maxTheta, prior, randomSeed, }?: CatInput); | ||
get theta(): number; | ||
get seMeasurement(): number; | ||
get nItems(): number; | ||
get resps(): (0 | 1)[]; | ||
get zetas(): Zeta[]; | ||
private static validateMethod; | ||
private static validateItemSelect; | ||
private static validateStartSelect; | ||
/** | ||
* 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: Zeta | Zeta[], answer: (0 | 1) | (0 | 1)[], method?: string): void; | ||
private estimateAbilityEAP; | ||
private estimateAbilityMLE; | ||
private negLikelihood; | ||
private likelihood; | ||
/** | ||
* calculate the standard error of ability estimation | ||
*/ | ||
private calculateSE; | ||
/** | ||
* 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: Stimulus[], itemSelect?: string, deepCopy?: boolean): { | ||
nextStimulus: Stimulus; | ||
remainingStimuli: Stimulus[]; | ||
}; | ||
private selectorMFI; | ||
private selectorMiddle; | ||
private selectorClosest; | ||
private selectorRandom; | ||
/** | ||
* 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 | ||
*/ | ||
private randomInteger; | ||
} | ||
export { Cat, CatInput } from './cat'; | ||
export { Clowder, ClowderInput } from './clowder'; | ||
export { prepareClowderCorpus } from './corpus'; | ||
export { EarlyStopping, StopAfterNItems, StopOnSEMeasurementPlateau, StopIfSEMeasurementBelowThreshold, } from './stopping'; |
253
lib/index.js
"use strict"; | ||
var __importDefault = (this && this.__importDefault) || function (mod) { | ||
return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.Cat = exports.abilityPrior = void 0; | ||
const optimization_js_1 = require("optimization-js"); | ||
const lodash_1 = require("lodash"); | ||
const utils_1 = require("./utils"); | ||
const seedrandom_1 = __importDefault(require("seedrandom")); | ||
exports.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 = exports.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._nItems = 0; | ||
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; | ||
} | ||
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']; | ||
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']; // 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]; | ||
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); | ||
let theta = solution.argument[0]; | ||
if (theta > this.maxTheta) { | ||
theta = this.maxTheta; | ||
} | ||
else if (theta < this.minTheta) { | ||
theta = this.minTheta; | ||
} | ||
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, lodash_1.cloneDeep)(stimuli); | ||
} | ||
else { | ||
arr = stimuli; | ||
} | ||
if (this.nItems < this.nStartItems) { | ||
selector = this.startSelect; | ||
} | ||
if (selector !== 'mfi') { | ||
// 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 | ||
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 { | ||
return this.selectorMFI(arr); | ||
} | ||
} | ||
selectorMFI(arr) { | ||
const stimuliAddFisher = arr.map((element) => (Object.assign({ fisherInformation: (0, utils_1.fisherInformation)(this._theta, { | ||
a: element.a || 1, | ||
b: element.difficulty || 0, | ||
c: element.c || 0, | ||
d: element.d || 1, | ||
}) }, 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; | ||
if (arr.length < this.nStartItems) { | ||
index = Math.floor(arr.length / 2); | ||
} | ||
else { | ||
index = | ||
Math.floor(arr.length / 2) + | ||
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 = Math.floor(this._rng() * arr.length); | ||
const nextItem = arr.splice(index, 1)[0]; | ||
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; | ||
exports.StopIfSEMeasurementBelowThreshold = exports.StopOnSEMeasurementPlateau = exports.StopAfterNItems = exports.EarlyStopping = exports.prepareClowderCorpus = exports.Clowder = exports.Cat = void 0; | ||
var cat_1 = require("./cat"); | ||
Object.defineProperty(exports, "Cat", { enumerable: true, get: function () { return cat_1.Cat; } }); | ||
var clowder_1 = require("./clowder"); | ||
Object.defineProperty(exports, "Clowder", { enumerable: true, get: function () { return clowder_1.Clowder; } }); | ||
var corpus_1 = require("./corpus"); | ||
Object.defineProperty(exports, "prepareClowderCorpus", { enumerable: true, get: function () { return corpus_1.prepareClowderCorpus; } }); | ||
var stopping_1 = require("./stopping"); | ||
Object.defineProperty(exports, "EarlyStopping", { enumerable: true, get: function () { return stopping_1.EarlyStopping; } }); | ||
Object.defineProperty(exports, "StopAfterNItems", { enumerable: true, get: function () { return stopping_1.StopAfterNItems; } }); | ||
Object.defineProperty(exports, "StopOnSEMeasurementPlateau", { enumerable: true, get: function () { return stopping_1.StopOnSEMeasurementPlateau; } }); | ||
Object.defineProperty(exports, "StopIfSEMeasurementBelowThreshold", { enumerable: true, get: function () { return stopping_1.StopIfSEMeasurementBelowThreshold; } }); |
@@ -1,2 +0,2 @@ | ||
export declare type Zeta = { | ||
export declare type ZetaSymbolic = { | ||
a: number; | ||
@@ -7,5 +7,25 @@ b: number; | ||
}; | ||
export interface Stimulus { | ||
difficulty: number; | ||
export interface Zeta { | ||
a?: number; | ||
b?: number; | ||
c?: number; | ||
d?: number; | ||
discrimination?: number; | ||
difficulty?: number; | ||
guessing?: number; | ||
slipping?: number; | ||
} | ||
export interface Stimulus extends Zeta { | ||
[key: string]: any; | ||
} | ||
export declare type ZetaCatMap = { | ||
cats: string[]; | ||
zeta: Zeta; | ||
}; | ||
export interface MultiZetaStimulus { | ||
zetas: ZetaCatMap[]; | ||
[key: string]: any; | ||
} | ||
export declare type CatMap<T> = { | ||
[name: string]: T; | ||
}; |
import { Stimulus, Zeta } from './type'; | ||
/** | ||
* calculates the probability that someone with a given ability level theta will answer correctly an item. Uses the 4 parameters logistic model | ||
* @param theta - ability estimate | ||
* @param zeta - item params | ||
* 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 | ||
@@ -10,5 +12,6 @@ */ | ||
/** | ||
* a 3PL Fisher information function | ||
* @param theta - ability estimate | ||
* @param zeta - item params | ||
* A 3PL Fisher information function | ||
* | ||
* @param {number} theta - ability estimate | ||
* @param {Zeta} zeta - item params | ||
* @returns {number} - the expected value of the observed information | ||
@@ -18,8 +21,9 @@ */ | ||
/** | ||
* return a Gaussian distribution within a given range | ||
* @param mean | ||
* @param stdDev | ||
* @param min | ||
* @param max | ||
* @param stepSize - the quantization (step size) of the internal table, default = 0.1 | ||
* 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 | ||
@@ -29,3 +33,3 @@ */ | ||
/** | ||
* find the item in a given array that has the difficulty closest to the target value | ||
* Find the item in a given array that has the difficulty closest to the target value | ||
* | ||
@@ -35,6 +39,6 @@ * @remarks | ||
* | ||
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty | ||
* @param target number - ability estimate | ||
* @returns {number} the index of arr | ||
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty | ||
* @param {number} target - ability estimate | ||
* @returns {number} the index of stimuli | ||
*/ | ||
export declare const findClosest: (arr: Array<Stimulus>, target: number) => number; | ||
export declare const findClosest: (inputStimuli: Array<Stimulus>, target: number) => number; |
@@ -7,32 +7,40 @@ "use strict"; | ||
exports.findClosest = exports.normal = exports.fisherInformation = exports.itemResponseFunction = void 0; | ||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ | ||
const binary_search_1 = __importDefault(require("binary-search")); | ||
const corpus_1 = require("./corpus"); | ||
/** | ||
* calculates the probability that someone with a given ability level theta will answer correctly an item. Uses the 4 parameters logistic model | ||
* @param theta - ability estimate | ||
* @param zeta - item params | ||
* 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 | ||
*/ | ||
const itemResponseFunction = (theta, zeta) => { | ||
return zeta.c + (zeta.d - zeta.c) / (1 + Math.exp(-zeta.a * (theta - zeta.b))); | ||
const _zeta = (0, corpus_1.fillZetaDefaults)(zeta, 'symbolic'); | ||
return _zeta.c + (_zeta.d - _zeta.c) / (1 + Math.exp(-_zeta.a * (theta - _zeta.b))); | ||
}; | ||
exports.itemResponseFunction = itemResponseFunction; | ||
/** | ||
* a 3PL Fisher information function | ||
* @param theta - ability estimate | ||
* @param zeta - item params | ||
* A 3PL Fisher information function | ||
* | ||
* @param {number} theta - ability estimate | ||
* @param {Zeta} zeta - item params | ||
* @returns {number} - the expected value of the observed information | ||
*/ | ||
const fisherInformation = (theta, zeta) => { | ||
const p = (0, exports.itemResponseFunction)(theta, zeta); | ||
const _zeta = (0, corpus_1.fillZetaDefaults)(zeta, 'symbolic'); | ||
const p = (0, exports.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 Math.pow(_zeta.a, 2) * (q / p) * (Math.pow(p - _zeta.c, 2) / Math.pow(1 - _zeta.c, 2)); | ||
}; | ||
exports.fisherInformation = fisherInformation; | ||
/** | ||
* return a Gaussian distribution within a given range | ||
* @param mean | ||
* @param stdDev | ||
* @param min | ||
* @param max | ||
* @param stepSize - the quantization (step size) of the internal table, default = 0.1 | ||
* 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 | ||
@@ -52,3 +60,3 @@ */ | ||
/** | ||
* find the item in a given array that has the difficulty closest to the target value | ||
* Find the item in a given array that has the difficulty closest to the target value | ||
* | ||
@@ -58,13 +66,14 @@ * @remarks | ||
* | ||
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty | ||
* @param target number - ability estimate | ||
* @returns {number} the index of arr | ||
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty | ||
* @param {number} target - ability estimate | ||
* @returns {number} the index of stimuli | ||
*/ | ||
const findClosest = (arr, target) => { | ||
const findClosest = (inputStimuli, target) => { | ||
const stimuli = inputStimuli.map((stim) => (0, corpus_1.fillZetaDefaults)(stim, 'semantic')); | ||
// Let's consider the edge cases first | ||
if (target <= arr[0].difficulty) { | ||
if (target <= stimuli[0].difficulty) { | ||
return 0; | ||
} | ||
else if (target >= arr[arr.length - 1].difficulty) { | ||
return arr.length - 1; | ||
else if (target >= stimuli[stimuli.length - 1].difficulty) { | ||
return stimuli.length - 1; | ||
} | ||
@@ -74,3 +83,3 @@ const comparitor = (element, needle) => { | ||
}; | ||
const indexOfTarget = (0, binary_search_1.default)(arr, target, comparitor); | ||
const indexOfTarget = (0, binary_search_1.default)(stimuli, target, comparitor); | ||
if (indexOfTarget >= 0) { | ||
@@ -88,4 +97,4 @@ // `bs` returns a positive integer index if it found an exact match. | ||
// low values, respectively | ||
const lowDiff = Math.abs(arr[lowIndex].difficulty - target); | ||
const highDiff = Math.abs(arr[highIndex].difficulty - target); | ||
const lowDiff = Math.abs(stimuli[lowIndex].difficulty - target); | ||
const highDiff = Math.abs(stimuli[highIndex].difficulty - target); | ||
if (lowDiff < highDiff) { | ||
@@ -92,0 +101,0 @@ return lowIndex; |
{ | ||
"name": "@bdelab/jscat", | ||
"version": "4.0.0", | ||
"version": "5.0.0", | ||
"description": "A library to support IRT-based computer adaptive testing in JavaScript", | ||
@@ -36,3 +36,3 @@ "main": "lib/index.js", | ||
"devDependencies": { | ||
"@types/jest": "^28.1.6", | ||
"@types/jest": "^28.1.8", | ||
"@types/lodash": "^4.14.182", | ||
@@ -45,4 +45,5 @@ "@types/seedrandom": "^3.0.2", | ||
"jest": "^28.1.3", | ||
"jest-extended": "^4.0.2", | ||
"prettier": "^2.7.1", | ||
"ts-jest": "^28.0.7", | ||
"ts-jest": "^28.0.8", | ||
"tsdoc": "^0.0.4", | ||
@@ -49,0 +50,0 @@ "typescript": "^4.7.4" |
@@ -12,3 +12,3 @@ --- | ||
orcid: 0000-0002-2686-1293 | ||
affiliation: "1, 2" | ||
affiliation: "1, 2, 3" | ||
- name: Adam Richie-Halford | ||
@@ -18,7 +18,9 @@ orcid: 0000-0001-9276-9084 | ||
affiliations: | ||
- name: # TODO (Anya) Add GSE affiliation | ||
index: 1 | ||
- name: # TODO (Anya) Add DBP affiliation | ||
- name: Stanford University Graduate School of Education | ||
index: 1 | ||
- name: Stanford University School of Medicine, Division of Developmental Behavioral Pediatrics | ||
index: 2 | ||
date: 13 March 2023 | ||
- name: Stanford University Department of Psychology | ||
index: 3 | ||
date: 5 October 2023 | ||
bibliography: paper.bib | ||
@@ -38,9 +40,7 @@ --- | ||
jsCAT is a JavaScript library to support a fully client-side adaptive testing | ||
environment. Computerized Adaptive Testing (CAT) is a type of assessment that | ||
uses computer algorithms to dynamically adjust the difficulty of questions based | ||
on a test-taker's performance. Although CAT has been widely researched and | ||
applied in areas such as education and psychology (e.g., GRE and Duolingo), | ||
designing a CAT solution suitable for large-scale school administration remains | ||
a challenge. Imagine we are administering 500 children in a school at the same | ||
jsCAT is a JavaScript library to support fully client-side computerized adaptive testing. Computerized Adaptive Testing | ||
is an approach to assessment that uses an algorithm to select the most informative item to present to a test-taker | ||
based on their previous responses. Although CAT has been widely researched and applied in areas such as education | ||
and psychology (e.g., GRE and Duolingo), designing a CAT solution suitable for large-scale school administration | ||
remains a challenge. Imagine we are administering 500 children in a school at the same | ||
time, where reliable wifi is not guaranteed at all times, which means that we | ||
@@ -84,2 +84,2 @@ should minimize data communications between the client and servers to prevent | ||
<!-- TODO (Anya): Add references in the paper.bib file --> | ||
<!-- TODO (Anya): Add references in the paper.bib file --> |
249
README.md
@@ -19,2 +19,4 @@ [](https://github.com/yeatmanlab/jsCAT/actions/workflows/ci.yml) | ||
For existing jsCAT users: to make your applications compatible to the updated jsCAT version, you will need to pass the stimuli in the following way: | ||
```js | ||
@@ -30,3 +32,11 @@ // import jsCAT | ||
// update the abilitiy estimate by adding test items | ||
// option 1 to input stimuli: | ||
const zeta = {[{discrimination: 1, difficulty: 0, guessing: 0, slipping: 1}, {discrimination: 1, difficulty: 0.5, guessing: 0, slipping: 1}]} | ||
// option 2 to input stimuli: | ||
const zeta = {[{a: 1, b: 0, c: 0, d: 1}, {a: 1, b: 0.5, c: 0, d: 1}]} | ||
const answer = {[1, 0]} | ||
// update the ability estimate by adding test items | ||
cat.updateAbilityEstimate(zeta, answer); | ||
@@ -42,12 +52,245 @@ | ||
const stimuli = [{difficulty: -3, item: 'item1'}, {difficulty: -2, item: 'item2'}]; | ||
> **Note:** For existing jsCAT users: To make your applications compatible with the updated jsCAT version, you will need to pass the stimuli in the following way: | ||
const stimuli = [{ discrimination: 1, difficulty: -2, guessing: 0, slipping: 1, item = "item1" },{ discrimination: 1, difficulty: 3, guessing: 0, slipping: 1, item = "item2" }]; | ||
const nextItem = cat.findNextItem(stimuli, 'MFI'); | ||
``` | ||
## Validation | ||
### Validation of theta estimate and theta standard error | ||
Reference software: mirt (Chalmers, 2012) | ||
 | ||
### Validation of MFI algorithm | ||
Reference software: catR (Magis et al., 2017) | ||
 | ||
# Clowder Usage Guide | ||
The `Clowder` class is a powerful tool for managing multiple `Cat` instances and handling stimuli corpora in adaptive testing scenarios. This guide provides an overview of integrating `Clowder` into your application, with examples and explanations for key features. | ||
--- | ||
## Key Changes from Single `Cat` to `Clowder` | ||
### Why Use Clowder? | ||
- **Multi-CAT Support**: Manage multiple `Cat` instances simultaneously. | ||
- **Centralized Corpus Management**: Handle validated and unvalidated items across Cats. | ||
- **Advanced Trial Management**: Dynamically update Cats and retrieve stimuli based on configurable rules. | ||
- **Early Stopping Mechanisms**: Optimize testing by integrating conditions to stop trials automatically. | ||
--- | ||
## Transitioning to Clowder | ||
### 1. Replacing Single `Cat` Usage | ||
#### Single `Cat` Example: | ||
```typescript | ||
const cat = new Cat({ method: 'MLE', theta: 0.5 }); | ||
const nextItem = cat.findNextItem(stimuli); | ||
``` | ||
#### Clowder Equivalent: | ||
```typescript | ||
const clowder = new Clowder({ | ||
cats: { cat1: { method: 'MLE', theta: 0.5 } }, | ||
corpus: stimuli, | ||
}); | ||
const nextItem = clowder.updateCatAndGetNextItem({ | ||
catToSelect: 'cat1', | ||
}); | ||
``` | ||
--- | ||
### 2. Using a Corpus with Multi-Zeta Stimuli | ||
The `Clowder` corpus supports multi-zeta stimuli, allowing each stimulus to define parameters for multiple Cats. Use the following tools to prepare the corpus: | ||
#### Fill Default Zeta Parameters: | ||
```typescript | ||
import { fillZetaDefaults } from './corpus'; | ||
const filledStimuli = stimuli.map((stim) => fillZetaDefaults(stim)); | ||
``` | ||
**What is `fillZetaDefaults`?** | ||
The function `fillZetaDefaults` ensures that each stimulus in the corpus has Zeta parameters defined. If any parameters are missing, it fills them with the default Zeta values. | ||
The default values are: | ||
```typescript | ||
export const defaultZeta = (desiredFormat: 'symbolic' | 'semantic' = 'symbolic'): Zeta => { | ||
const defaultZeta: Zeta = { | ||
a: 1, | ||
b: 0, | ||
c: 0, | ||
d: 1, | ||
}; | ||
return convertZeta(defaultZeta, desiredFormat); | ||
}; | ||
``` | ||
- If desiredFormat is not specified, it defaults to 'symbolic'. | ||
- This ensures consistency across different stimuli and prevents errors from missing Zeta parameters. | ||
- You can pass 'semantic' as an argument to convert the default Zeta values into a different representation. | ||
#### Validate the Corpus: | ||
```typescript | ||
import { checkNoDuplicateCatNames } from './corpus'; | ||
checkNoDuplicateCatNames(corpus); | ||
``` | ||
#### Filter Stimuli for a Specific Cat: | ||
```typescript | ||
import { filterItemsByCatParameterAvailability } from './corpus'; | ||
const { available, missing } = filterItemsByCatParameterAvailability(corpus, 'cat1'); | ||
``` | ||
--- | ||
### 3. Adding Early Stopping | ||
Integrate early stopping mechanisms to optimize the testing process. | ||
#### Example: Stop After N Items | ||
```typescript | ||
import { StopAfterNItems } from './stopping'; | ||
const earlyStopping = new StopAfterNItems({ | ||
requiredItems: { cat1: 2 }, | ||
}); | ||
const clowder = new Clowder({ | ||
cats: { cat1: { method: 'MLE', theta: 0.5 } }, | ||
corpus: stimuli, | ||
earlyStopping: earlyStopping, | ||
}); | ||
``` | ||
## Early Stopping Criteria Combinations | ||
To clarify the available combinations for early stopping, here’s a breakdown of the options you can use: | ||
### 1. Logical Operations | ||
You can combine multiple stopping criteria using one of the following logical operations: | ||
- **`and`**: All conditions need to be met to trigger early stopping. | ||
- **`or`**: Any one condition being met will trigger early stopping. | ||
- **`only`**: Only a specific condition is considered (you need to specify the cat to evaluate). | ||
### 2. Stopping Criteria Classes | ||
There are different types of stopping criteria you can configure: | ||
- **`StopAfterNItems`**: Stops the process after a specified number of items. | ||
- **`StopOnSEMeasurementPlateau`**: Stops if the standard error (SE) of measurement remains stable (within a defined tolerance) for a specified number of items. | ||
- **`StopIfSEMeasurementBelowThreshold`**: Stops if the SE measurement drops below a set threshold. | ||
### How Combinations Work | ||
You can mix and match these criteria with different logical operations, giving you a range of configurations for early stopping. For example: | ||
- Using **`and`** with both `StopAfterNItems` and `StopIfSEMeasurementBelowThreshold` means stopping will only occur if both conditions are satisfied. | ||
- Using **`or`** with `StopOnSEMeasurementPlateau` and `StopAfterNItems` allows early stopping if either condition is met. | ||
--- | ||
## Clowder Example | ||
Here’s a complete example demonstrating how to configure and use `Clowder`: | ||
```typescript | ||
import { Clowder } from './clowder'; | ||
import { createMultiZetaStimulus, createZetaCatMap } from './utils'; | ||
import { StopAfterNItems } from './stopping'; | ||
// Define the Cats | ||
const catConfigs = { | ||
cat1: { method: 'MLE', theta: 0.5 }, // Cat1 uses Maximum Likelihood Estimation | ||
cat2: { method: 'EAP', theta: -1.0 }, // Cat2 uses Expected A Posteriori | ||
}; | ||
// Define the corpus | ||
const corpus = [ | ||
createMultiZetaStimulus('item1', [ | ||
createZetaCatMap(['cat1'], { a: 1, b: 0.5, c: 0.2, d: 0.8 }), | ||
createZetaCatMap(['cat2'], { a: 2, b: 0.7, c: 0.3, d: 0.9 }), | ||
]), | ||
createMultiZetaStimulus('item2', [createZetaCatMap(['cat1'], { a: 1.5, b: 0.4, c: 0.1, d: 0.85 })]), | ||
createMultiZetaStimulus('item3', [createZetaCatMap(['cat2'], { a: 2.5, b: 0.6, c: 0.25, d: 0.95 })]), | ||
createMultiZetaStimulus('item4', []), // Unvalidated item | ||
]; | ||
// Optional: Add an early stopping strategy | ||
const earlyStopping = new StopAfterNItems({ | ||
requiredItems: { cat1: 2, cat2: 2 }, | ||
}); | ||
// Initialize the Clowder | ||
const clowder = new Clowder({ | ||
cats: catConfigs, | ||
corpus: corpus, | ||
earlyStopping: earlyStopping, | ||
}); | ||
// Running Trials | ||
const nextItem = clowder.updateCatAndGetNextItem({ | ||
catToSelect: 'cat1', | ||
catsToUpdate: ['cat1', 'cat2'], // Update responses for both Cats | ||
items: [clowder.corpus[0]], // Previously seen item | ||
answers: [1], // Response for the previously seen item | ||
}); | ||
console.log('Next item to present:', nextItem); | ||
// Check stopping condition | ||
if (clowder.earlyStopping?.earlyStop) { | ||
console.log('Early stopping triggered:', clowder.stoppingReason); | ||
} | ||
``` | ||
--- | ||
By integrating `Clowder`, your application can efficiently manage adaptive testing scenarios with robust trial and stimuli handling, multi-CAT configurations, and stopping conditions to ensure optimal performance. | ||
## References | ||
Lucas Duailibe, irt-js, (2019), GitHub repository, https://github.com/geekie/irt-js | ||
- Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R environment. Journal of Statistical Software. | ||
- Magis, D., & Barrada, J. R. (2017). Computerized adaptive testing with R: Recent updates of the package catR. Journal of Statistical Software, 76, 1-19. | ||
- Lucas Duailibe, irt-js, (2019), GitHub repository, https://github.com/geekie/irt-js | ||
## License | ||
jsCAT is distributed under the [ISC license](LICENSE). | ||
## Contributors | ||
jsCAT is contributed by Wanjing Anya Ma, Emily Judith Arteaga Garcia, Jason D. Yeatman, and Adam Richie-Halford. | ||
## Citation | ||
If you are use jsCAT for your web applications, please cite us: | ||
Ma, W. A., Richie-Halford, A., Burkhardt, A. K., Kanopka, K., Chou, C., Domingue, B. W., & Yeatman, J. D. (2025). ROAR-CAT: Rapid Online Assessment of Reading ability with Computerized Adaptive Testing. Behavior Research Methods, 57(1), 1-17. https://doi.org/10.3758/s13428-024-02578-y | ||
@article{ma2025roar, | ||
title={ROAR-CAT: Rapid Online Assessment of Reading ability with Computerized Adaptive Testing}, | ||
author={Ma, Wanjing Anya and Richie-Halford, Adam and Burkhardt, Amy K and Kanopka, Klint and Chou, Clementine and Domingue, Benjamin W and Yeatman, Jason D}, | ||
journal={Behavior Research Methods}, | ||
volume={57}, | ||
number={1}, | ||
pages={1--17}, | ||
year={2025}, | ||
publisher={Springer} | ||
} |
@@ -5,7 +5,5 @@ import { itemResponseFunction, fisherInformation, findClosest } from '../utils'; | ||
it('correctly calculates the probability', () => { | ||
expect(0.7234).toBeCloseTo(itemResponseFunction(0, { a: 1, b: -0.3, c: 0.35, d: 1 }), 2); | ||
expect(0.5).toBeCloseTo(itemResponseFunction(0, { a: 1, b: 0, c: 0, d: 1 }), 2); | ||
expect(0.625).toBeCloseTo(itemResponseFunction(0, { a: 0.5, b: 0, c: 0.25, d: 1 }), 2); | ||
expect(itemResponseFunction(0, { a: 1, b: -0.3, c: 0.35, d: 1 })).toBeCloseTo(0.7234, 2); | ||
expect(itemResponseFunction(0, { a: 1, b: 0, c: 0, d: 1 })).toBeCloseTo(0.5, 2); | ||
expect(itemResponseFunction(0, { a: 0.5, b: 0, c: 0.25, d: 1 })).toBeCloseTo(0.625, 2); | ||
}); | ||
@@ -16,5 +14,4 @@ }); | ||
it('correctly calculates the information', () => { | ||
expect(0.206).toBeCloseTo(fisherInformation(0, { a: 1.53, b: -0.5, c: 0.5, d: 1 }), 2); | ||
expect(0.1401).toBeCloseTo(fisherInformation(2.35, { a: 1, b: 2, c: 0.3, d: 1 }), 2); | ||
expect(fisherInformation(0, { a: 1.53, b: -0.5, c: 0.5, d: 1 })).toBeCloseTo(0.206, 2); | ||
expect(fisherInformation(2.35, { a: 1, b: 2, c: 0.3, d: 1 })).toBeCloseTo(0.1401, 2); | ||
}); | ||
@@ -24,21 +21,40 @@ }); | ||
describe('findClosest', () => { | ||
const stimuli = [ | ||
{ difficulty: 1, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 4, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 10, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 11, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
]; | ||
it('correctly selects the first item if appropriate', () => { | ||
expect(0).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 0)); | ||
expect(findClosest(stimuli, 0)).toBe(0); | ||
}); | ||
it('correctly selects the last item if appropriate', () => { | ||
expect(3).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 1000)); | ||
expect(findClosest(stimuli, 1000)).toBe(3); | ||
}); | ||
it('correctly selects a middle item if it equals exactly', () => { | ||
expect(2).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 10)); | ||
expect(findClosest(stimuli, 10)).toBe(2); | ||
}); | ||
it('correctly selects the one item closest to the target if less than', () => { | ||
expect(1).toBe( | ||
findClosest([{ difficulty: 1.1 }, { difficulty: 4.2 }, { difficulty: 10.3 }, { difficulty: 11.4 }], 5.1), | ||
); | ||
const stimuliWithDecimal = [ | ||
{ difficulty: 1.1, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 4.2, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 10.3, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 11.4, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
]; | ||
expect(findClosest(stimuliWithDecimal, 5.1)).toBe(1); | ||
}); | ||
it('correctly selects the one item closest to the target if greater than', () => { | ||
expect(2).toBe( | ||
findClosest([{ difficulty: 1.1 }, { difficulty: 4.2 }, { difficulty: 10.3 }, { difficulty: 11.4 }], 9.1), | ||
); | ||
const stimuliWithDecimal = [ | ||
{ difficulty: 1.1, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 4.2, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 10.3, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
{ difficulty: 11.4, discrimination: 1, guessing: 0.25, slipping: 0.75 }, | ||
]; | ||
expect(findClosest(stimuliWithDecimal, 9.1)).toBe(2); | ||
}); | ||
}); |
310
src/index.ts
@@ -1,301 +0,9 @@ | ||
import { minimize_Powell } from 'optimization-js'; | ||
import { cloneDeep } from 'lodash'; | ||
import { Stimulus, Zeta } from './type'; | ||
import { itemResponseFunction, fisherInformation, normal, findClosest } from './utils'; | ||
import seedrandom from 'seedrandom'; | ||
export const abilityPrior = normal(); | ||
export interface CatInput { | ||
method?: string; | ||
itemSelect?: string; | ||
nStartItems?: number; | ||
startSelect?: string; | ||
theta?: number; | ||
minTheta?: number; | ||
maxTheta?: number; | ||
prior?: number[][]; | ||
randomSeed?: string | null; | ||
} | ||
export class Cat { | ||
public method: string; | ||
public itemSelect: string; | ||
public minTheta: number; | ||
public maxTheta: number; | ||
public prior: number[][]; | ||
private readonly _zetas: Zeta[]; | ||
private readonly _resps: (0 | 1)[]; | ||
private _nItems: number; | ||
private _theta: number; | ||
private _seMeasurement: number; | ||
public nStartItems: number; | ||
public startSelect: string; | ||
private readonly _rng: ReturnType<seedrandom>; | ||
/** | ||
* 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, | ||
}: CatInput = {}) { | ||
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._nItems = 0; | ||
this._seMeasurement = Number.MAX_VALUE; | ||
this.nStartItems = nStartItems; | ||
this._rng = randomSeed === null ? seedrandom() : seedrandom(randomSeed); | ||
} | ||
public get theta() { | ||
return this._theta; | ||
} | ||
public get seMeasurement() { | ||
return this._seMeasurement; | ||
} | ||
public get nItems() { | ||
return this._resps.length; | ||
} | ||
public get resps() { | ||
return this._resps; | ||
} | ||
public get zetas() { | ||
return this._zetas; | ||
} | ||
private static validateMethod(method: string) { | ||
const lowerMethod = method.toLowerCase(); | ||
const validMethods: Array<string> = ['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; | ||
} | ||
private static validateItemSelect(itemSelect: string) { | ||
const lowerItemSelect = itemSelect.toLowerCase(); | ||
const validItemSelect: Array<string> = ['mfi', 'random', 'closest']; | ||
if (!validItemSelect.includes(lowerItemSelect)) { | ||
throw new Error('The itemSelector you provided is not in the list of valid methods'); | ||
} | ||
return lowerItemSelect; | ||
} | ||
private static validateStartSelect(startSelect: string) { | ||
const lowerStartSelect = startSelect.toLowerCase(); | ||
const validStartSelect: Array<string> = ['random', 'middle']; // 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 | ||
*/ | ||
public updateAbilityEstimate(zeta: Zeta | Zeta[], answer: (0 | 1) | (0 | 1)[], method: string = this.method) { | ||
method = Cat.validateMethod(method); | ||
zeta = Array.isArray(zeta) ? zeta : [zeta]; | ||
answer = Array.isArray(answer) ? answer : [answer]; | ||
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(); | ||
} | ||
private 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; | ||
} | ||
private estimateAbilityMLE() { | ||
const theta0 = [0]; | ||
const solution = minimize_Powell(this.negLikelihood.bind(this), theta0); | ||
let theta = solution.argument[0]; | ||
if (theta > this.maxTheta) { | ||
theta = this.maxTheta; | ||
} else if (theta < this.minTheta) { | ||
theta = this.minTheta; | ||
} | ||
return theta; | ||
} | ||
private negLikelihood(thetaArray: Array<number>) { | ||
return -this.likelihood(thetaArray[0]); | ||
} | ||
private likelihood(theta: number) { | ||
return this._zetas.reduce((acc, zeta, i) => { | ||
const irf = 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 | ||
*/ | ||
private calculateSE() { | ||
const sum = this._zetas.reduce((previousValue, zeta) => previousValue + 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>} | ||
*/ | ||
public findNextItem(stimuli: Stimulus[], itemSelect: string = this.itemSelect, deepCopy = true) { | ||
let arr: Array<Stimulus>; | ||
let selector = Cat.validateItemSelect(itemSelect); | ||
if (deepCopy) { | ||
arr = cloneDeep(stimuli); | ||
} else { | ||
arr = stimuli; | ||
} | ||
if (this.nItems < this.nStartItems) { | ||
selector = this.startSelect; | ||
} | ||
if (selector !== 'mfi') { | ||
// 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 | ||
arr.sort((a: Stimulus, b: Stimulus) => 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 { | ||
return this.selectorMFI(arr); | ||
} | ||
} | ||
private selectorMFI(arr: Stimulus[]) { | ||
const stimuliAddFisher = arr.map((element: Stimulus) => ({ | ||
fisherInformation: fisherInformation(this._theta, { | ||
a: element.a || 1, | ||
b: element.difficulty || 0, | ||
c: element.c || 0, | ||
d: element.d || 1, | ||
}), | ||
...element, | ||
})); | ||
stimuliAddFisher.sort((a, b) => b.fisherInformation - a.fisherInformation); | ||
stimuliAddFisher.forEach((stimulus: Stimulus) => { | ||
delete stimulus['fisherInformation']; | ||
}); | ||
return { | ||
nextStimulus: stimuliAddFisher[0], | ||
remainingStimuli: stimuliAddFisher.slice(1).sort((a: Stimulus, b: Stimulus) => a.difficulty - b.difficulty), | ||
}; | ||
} | ||
private selectorMiddle(arr: Stimulus[]) { | ||
let index: number; | ||
if (arr.length < this.nStartItems) { | ||
index = Math.floor(arr.length / 2); | ||
} else { | ||
index = | ||
Math.floor(arr.length / 2) + | ||
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, | ||
}; | ||
} | ||
private selectorClosest(arr: Stimulus[]) { | ||
//findClosest requires arr is sorted by difficulty | ||
const index = findClosest(arr, this._theta + 0.481); | ||
const nextItem = arr[index]; | ||
arr.splice(index, 1); | ||
return { | ||
nextStimulus: nextItem, | ||
remainingStimuli: arr, | ||
}; | ||
} | ||
private selectorRandom(arr: Stimulus[]) { | ||
const index = Math.floor(this._rng() * arr.length); | ||
const nextItem = arr.splice(index, 1)[0]; | ||
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 | ||
*/ | ||
private randomInteger(min: number, max: number) { | ||
return Math.floor(this._rng() * (max - min + 1)) + min; | ||
} | ||
} | ||
export { Cat, CatInput } from './cat'; | ||
export { Clowder, ClowderInput } from './clowder'; | ||
export { prepareClowderCorpus } from './corpus'; | ||
export { | ||
EarlyStopping, | ||
StopAfterNItems, | ||
StopOnSEMeasurementPlateau, | ||
StopIfSEMeasurementBelowThreshold, | ||
} from './stopping'; |
@@ -1,7 +0,40 @@ | ||
export type Zeta = { a: number; b: number; c: number; d: number }; | ||
export type ZetaSymbolic = { | ||
// Symbolic parameter names | ||
a: number; // Discrimination (slope of the curve) | ||
b: number; // Difficulty (location of the curve) | ||
c: number; // Guessing (lower asymptote) | ||
d: number; // Slipping (upper asymptote) | ||
}; | ||
export interface Stimulus { | ||
difficulty: number; | ||
export interface Zeta { | ||
// Symbolic parameter names | ||
a?: number; // Discrimination (slope of the curve) | ||
b?: number; // Difficulty (location of the curve) | ||
c?: number; // Guessing (lower asymptote) | ||
d?: number; // Slipping (upper asymptote) | ||
// Semantic parameter names | ||
discrimination?: number; | ||
difficulty?: number; | ||
guessing?: number; | ||
slipping?: number; | ||
} | ||
export interface Stimulus extends Zeta { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
[key: string]: any; | ||
} | ||
export type ZetaCatMap = { | ||
cats: string[]; | ||
zeta: Zeta; | ||
}; | ||
export interface MultiZetaStimulus { | ||
zetas: ZetaCatMap[]; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
[key: string]: any; | ||
} | ||
export type CatMap<T> = { | ||
[name: string]: T; | ||
}; |
@@ -0,33 +1,41 @@ | ||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ | ||
import bs from 'binary-search'; | ||
import { Stimulus, Zeta } from './type'; | ||
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 theta - ability estimate | ||
* @param zeta - item params | ||
* 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) => { | ||
return zeta.c + (zeta.d - zeta.c) / (1 + Math.exp(-zeta.a * (theta - zeta.b))); | ||
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 theta - ability estimate | ||
* @param zeta - item params | ||
* 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 p = itemResponseFunction(theta, 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 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 mean | ||
* @param stdDev | ||
* @param min | ||
* @param max | ||
* @param stepSize - the quantization (step size) of the internal table, default = 0.1 | ||
* 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 | ||
@@ -48,3 +56,3 @@ */ | ||
/** | ||
* find the item in a given array that has the difficulty closest to the target value | ||
* Find the item in a given array that has the difficulty closest to the target value | ||
* | ||
@@ -54,18 +62,19 @@ * @remarks | ||
* | ||
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty | ||
* @param target number - ability estimate | ||
* @returns {number} the index of arr | ||
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty | ||
* @param {number} target - ability estimate | ||
* @returns {number} the index of stimuli | ||
*/ | ||
export const findClosest = (arr: Array<Stimulus>, target: number) => { | ||
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 <= arr[0].difficulty) { | ||
if (target <= stimuli[0].difficulty!) { | ||
return 0; | ||
} else if (target >= arr[arr.length - 1].difficulty) { | ||
return arr.length - 1; | ||
} else if (target >= stimuli[stimuli.length - 1].difficulty!) { | ||
return stimuli.length - 1; | ||
} | ||
const comparitor = (element: Stimulus, needle: number) => { | ||
return element.difficulty - needle; | ||
return element.difficulty! - needle; | ||
}; | ||
const indexOfTarget = bs(arr, target, comparitor); | ||
const indexOfTarget = bs(stimuli, target, comparitor); | ||
@@ -84,4 +93,4 @@ if (indexOfTarget >= 0) { | ||
// low values, respectively | ||
const lowDiff = Math.abs(arr[lowIndex].difficulty - target); | ||
const highDiff = Math.abs(arr[highIndex].difficulty - target); | ||
const lowDiff = Math.abs(stimuli[lowIndex].difficulty! - target); | ||
const highDiff = Math.abs(stimuli[highIndex].difficulty! - target); | ||
@@ -88,0 +97,0 @@ if (lowDiff < highDiff) { |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
561177
54
5083
293
13
1