diff --git a/package-lock.json b/package-lock.json index a42bc2c..1fa3128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,20 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "lodash": "^4.17.21", "optimization-js": "^1.5.0" }, "devDependencies": { + "@types/lodash": "^4.14.182", "typescript": "^4.7.4" } }, + "node_modules/@types/lodash": { + "version": "4.14.182", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", + "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", + "dev": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1281,7 +1289,8 @@ "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true }, "node_modules/ci-info": { "version": "1.6.0", @@ -9895,6 +9904,12 @@ } }, "dependencies": { + "@types/lodash": { + "version": "4.14.182", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", + "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -11039,7 +11054,8 @@ "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true }, "ci-info": { "version": "1.6.0", diff --git a/package.json b/package.json index 9e761e5..f93e38b 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ }, "homepage": "https://github.com/yeatmanlab/jsCAT#readme", "devDependencies": { + "@types/lodash": "^4.14.182", "typescript": "^4.7.4" }, "dependencies": { + "lodash": "^4.17.21", "optimization-js": "^1.5.0" } } diff --git a/src/index.ts b/src/index.ts index e047802..06e0540 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ import {minimize_Powell} from "optimization-js"; +import { cloneDeep } from "lodash"; export type Zeta = { a: number, b: number, c: number, d: number }; +export interface Stimulus { + difficulty: number; + [key: string]: any; +} + export const fisherInformation = (theta: number, zeta: Zeta) => { const p = itemResponseFunction(zeta, theta) const q = 1-p @@ -12,7 +18,7 @@ export const itemResponseFunction = (zeta: Zeta, theta: number) => { return zeta.c + (zeta.d - zeta.c) / (1 + Math.exp(-zeta.a * (theta - zeta.b))); } -export const findClosest = (arr: Array, target: number) => { +export const findClosest = (arr: Array, target: number) => { let n = arr.length; // Corner cases if (target <= arr[0].difficulty) @@ -46,7 +52,7 @@ export const findClosest = (arr: Array, target: number) => { return mid; } -export const getClosest = (arr: Array, val1:number, val2: number, target: number) => { +export const getClosest = (arr: Array, val1:number, val2: number, target: number) => { if (target - arr[val1].difficulty >= arr[val2].difficulty - target) return val2; else @@ -54,17 +60,8 @@ export const getClosest = (arr: Array, val1:number, val2: number, targe } export const estimateAbilityMLE = (answers: Array, zetas: Array, min_theta: number, max_theta: number) => { - let max_like = -Infinity; - let theta0 = [0]; - - const solution = minimize_Powell(negLikelihood, theta0) - // for (let theta = min_theta; theta <= max_theta; theta += learning_rate) { - // let like = likelihood(theta); - // if (like > max_like){ - // max_like = like; - // res_theta = theta; - // } - // } + const theta0 = [0]; + const solution = minimize_Powell(negLikelihood, theta0); let theta = solution.argument[0]; if (theta > max_theta) { @@ -76,24 +73,24 @@ export const estimateAbilityMLE = (answers: Array, zetas: Array, m function likelihood(theta: number) { return zetas.reduce((acc: number, zeta: Zeta, i: number) => { - let irf = itemResponseFunction(theta, zeta); + let irf = itemResponseFunction(zeta, theta); return answers[i] === 1 ? acc + Math.log(irf) : acc + Math.log(1 - irf); }, 0); } - function negLikelihood(thetaArray) { + function negLikelihood(thetaArray: Array) { return -likelihood(thetaArray[0]) } } export const normal = (mean: number, stdDev: number) => { - let distr = []; + let distribution = []; for (let i = -4; i <= 4; i += 0.1) { - distr.push([i, y(i)]); + distribution.push([i, y(i)]); } - return distr; + return distribution; - function y(x) { + function y(x: number) { return ( (1 / (Math.sqrt(2 * Math.PI) * stdDev)) * Math.exp(-Math.pow(x - mean, 2) / (2 * Math.pow(stdDev, 2))) @@ -115,8 +112,70 @@ export const estimateAbilityEAP = (answers: Array, zetas: Array) return num / nf; function likelihood(theta: number) { return zetas.reduce((acc, zeta, i) => { - let irf = itemResponseFunction(theta, zeta); + let irf = itemResponseFunction(zeta, theta); return answers[i] === 1 ? acc * irf : acc * (1 - irf); }, 1); } +} +/** + * 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 + */ +export const randomInteger = (min: number, max: number) => { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * find the next available item from an input array of stimuli based on a selection method + * @param stimuli {Array} - an array of stimulus + * @param theta {number} - the theta estimate, default theta = 0 + * @param method {string} - the method of item selection, e.g. "MI", "random", "closest", default method = 'MI' + * @param deepCopy {boolean} - default deepCopy = true + * @returns {nextStimulus: Stimulus, + remainingStimuli: Array } + */ +export const findNextItem = (stimuli: Array, theta = 0, method = 'MI', deepCopy = true) => { + method = method.toLowerCase(); + const validMethod: Array = ['mi', 'random', 'closest']; + if (!validMethod.includes(method)){ + throw new Error('The method you provided is not in the list of valid methods'); + } + let arr: Array; + if (deepCopy) { + arr = cloneDeep(stimuli); + } else { + arr = stimuli; + } + arr.sort((a, b) => a.difficulty - b.difficulty); + + method = method.toLowerCase(); + if (method === 'mi'){ + const stimuliAddFisher = arr.map((element) => ({fisherInformation: fisherInformation(theta, + {a: 1, b: element.difficulty, c: 0.5, d: 1}), ...element})); + stimuliAddFisher.sort((a,b) => b.fisherInformation - a.fisherInformation); + return { + nextStimulus: stimuliAddFisher[0], + remainingStimuli: stimuliAddFisher.slice(1) + }; + } else if (method == 'random') { + let index: number; + if (arr.length < 5){ + index = Math.floor(arr.length / 2); + } else { + index = Math.floor(arr.length / 2) + randomInteger(-2, 2); + } + return { + nextStimulus: arr[index], + remainingStimuli: arr.splice(index, 1) + }; + } else if (method == 'closest') { + //findClosest requires arr is sorted by difficulty + const index = findClosest(arr, theta + 0.481); + return { + nextStimulus: arr[index], + remainingStimuli: arr.splice(index, 1) + }; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 80304c9..2386b80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2016", "module": "commonjs", "declaration": true, "outDir": "./lib",