@bdelab/jscat
Version:
A library to support IRT-based computer adaptive testing in JavaScript
307 lines (306 loc) • 15.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Clowder = void 0;
const cat_1 = require("./cat");
const corpus_1 = require("./corpus");
const cloneDeep_1 = __importDefault(require("lodash/cloneDeep"));
const differenceWith_1 = __importDefault(require("lodash/differenceWith"));
const isEqual_1 = __importDefault(require("lodash/isEqual"));
const mapValues_1 = __importDefault(require("lodash/mapValues"));
const omit_1 = __importDefault(require("lodash/omit"));
const unzip_1 = __importDefault(require("lodash/unzip"));
const zip_1 = __importDefault(require("lodash/zip"));
const seedrandom_1 = __importDefault(require("seedrandom"));
/**
* The Clowder class is responsible for managing a collection of Cat instances
* along with a corpus of stimuli. It maintains a list of named Cat instances
* and a corpus where each item in the coprpus may have IRT parameters
* corresponding to each named Cat. Clowder provides methods for updating the
* ability estimates of each of its Cats, and selecting the next item to present
* to the participant.
*/
class Clowder {
/**
* Create a Clowder object.
*
* @param {ClowderInput} input - An object containing arrays of Cat configurations and corpora.
* @param {CatMap<CatInput>} input.cats - An object containing Cat configurations for each Cat instance.
* @param {MultiZetaStimulus[]} input.corpus - An array of stimuli representing each corpus.
*
* @throws {Error} - Throws an error if any item in the corpus has duplicated IRT parameters for any Cat name.
*/
constructor({ cats, corpus, randomSeed = null, earlyStopping }) {
// TODO: Add some imput validation to both the cats and the corpus to make sure that "unvalidated" is not used as a cat name.
// If so, throw an error saying that "unvalidated" is a reserved name and may not be used.
// TODO: Also add a test of this behavior.
this._cats = Object.assign(Object.assign({}, (0, mapValues_1.default)(cats, (catInput) => new cat_1.Cat(catInput))), { unvalidated: new cat_1.Cat({ itemSelect: 'random', randomSeed }) });
this._seenItems = [];
(0, corpus_1.checkNoDuplicateCatNames)(corpus);
this._corpus = corpus;
this._remainingItems = (0, cloneDeep_1.default)(corpus);
this._rng = randomSeed === null ? (0, seedrandom_1.default)() : (0, seedrandom_1.default)(randomSeed);
this._earlyStopping = earlyStopping;
this._stoppingReason = null;
}
/**
* Validate the provided Cat name against the existing Cat instances.
* Throw an error if the Cat name is not found.
*
* @param {string} catName - The name of the Cat instance to validate.
* @param {boolean} allowUnvalidated - Whether to allow the reserved 'unvalidated' name.
*
* @throws {Error} - Throws an error if the provided Cat name is not found among the existing Cat instances.
*/
_validateCatName(catName, allowUnvalidated = false) {
const allowedCats = allowUnvalidated ? this._cats : this.cats;
if (!Object.prototype.hasOwnProperty.call(allowedCats, catName)) {
throw new Error(`Invalid Cat name. Expected one of ${Object.keys(allowedCats).join(', ')}. Received ${catName}.`);
}
}
/**
* The named Cat instances that this Clowder manages.
*/
get cats() {
return (0, omit_1.default)(this._cats, ['unvalidated']);
}
/**
* The corpus that was provided to this Clowder when it was created.
*/
get corpus() {
return this._corpus;
}
/**
* The subset of the input corpus that this Clowder has not yet "seen".
*/
get remainingItems() {
return this._remainingItems;
}
/**
* The subset of the input corpus that this Clowder has "seen" so far.
*/
get seenItems() {
return this._seenItems;
}
/**
* The theta estimates for each Cat instance.
*/
get theta() {
return (0, mapValues_1.default)(this.cats, (cat) => cat.theta);
}
/**
* The standard error of measurement estimates for each Cat instance.
*/
get seMeasurement() {
return (0, mapValues_1.default)(this.cats, (cat) => cat.seMeasurement);
}
/**
* The number of items presented to each Cat instance.
*/
get nItems() {
return (0, mapValues_1.default)(this.cats, (cat) => cat.nItems);
}
/**
* The responses received by each Cat instance.
*/
get resps() {
return (0, mapValues_1.default)(this.cats, (cat) => cat.resps);
}
/**
* The zeta (item parameters) received by each Cat instance.
*/
get zetas() {
return (0, mapValues_1.default)(this.cats, (cat) => cat.zetas);
}
/**
* The early stopping condition in the Clowder configuration.
*/
get earlyStopping() {
return this._earlyStopping;
}
/**
* The stopping reason in the Clowder configuration.
*/
get stoppingReason() {
return this._stoppingReason;
}
/**
* Updates the ability estimates for the specified Cat instances.
*
* @param {string[]} catNames - The names of the Cat instances to update.
* @param {Zeta | Zeta[]} zeta - The item parameter(s) (zeta) for the given stimuli.
* @param {(0 | 1) | (0 | 1)[]} answer - The corresponding answer(s) (0 or 1) for the given stimuli.
* @param {string} [method] - Optional method for updating ability estimates. If none is provided, it will use the default method for each Cat instance.
*
* @throws {Error} If any `catName` is not found among the existing Cat instances.
*/
updateAbilityEstimates(catNames, zeta, answer, method) {
catNames.forEach((catName) => {
this._validateCatName(catName, false);
});
for (const catName of catNames) {
this.cats[catName].updateAbilityEstimate(zeta, answer, method);
}
}
/**
* Update the ability estimates for the specified `catsToUpdate` and select the next stimulus for the `catToSelect`.
* This function processes previous items and answers, updates internal state, and selects the next stimulus
* based on the remaining stimuli and `catToSelect`.
*
* @param {Object} input - The parameters for updating the Cat instance and selecting the next stimulus.
* @param {string} input.catToSelect - The Cat instance to use for selecting the next stimulus.
* @param {string | string[]} [input.catsToUpdate=[]] - A single Cat or array of Cats for which to update ability estimates.
* @param {Stimulus[]} [input.items=[]] - An array of previously presented stimuli.
* @param {(0 | 1) | (0 | 1)[]} [input.answers=[]] - An array of answers (0 or 1) corresponding to `items`.
* @param {string} [input.method] - Optional method for updating ability estimates (if applicable).
* @param {string} [input.itemSelect] - Optional item selection method (if applicable).
* @param {boolean} [input.randomlySelectUnvalidated=false] - Optional flag indicating whether to randomly select an unvalidated item for `catToSelect`.
*
* @returns {Stimulus | undefined} - The next stimulus to present, or `undefined` if no further validated stimuli are available.
*
* @throws {Error} If `items` and `answers` lengths do not match.
* @throws {Error} If any `items` are not found in the Clowder's corpora (validated or unvalidated).
*
* The function operates in several steps:
* 1. Validate:
* a. Validates the `catToSelect` and `catsToUpdate`.
* b. Ensures `items` and `answers` arrays are properly formatted.
* 2. Update:
* a. Updates the internal list of seen items.
* b. Updates the ability estimates for the `catsToUpdate`.
* 3. Select:
* a. Selects the next item using `catToSelect`, considering only remaining items that are valid for that cat.
* b. If desired, randomly selects an unvalidated item for catToSelect.
*/
updateCatAndGetNextItem({ catToSelect, catsToUpdate = [], items = [], answers = [], method, itemSelect, randomlySelectUnvalidated = false, returnUndefinedOnExhaustion = true, }) {
// +----------+
// ----------| Update |----------|
// +----------+
this._validateCatName(catToSelect, true);
catsToUpdate = Array.isArray(catsToUpdate) ? catsToUpdate : [catsToUpdate];
catsToUpdate.forEach((cat) => {
this._validateCatName(cat, false);
});
// Convert items and answers to arrays
items = Array.isArray(items) ? items : [items];
answers = Array.isArray(answers) ? answers : [answers];
// Ensure that the lengths of items and answers match
if (items.length !== answers.length) {
throw new Error('Previous items and answers must have the same length.');
}
// +----------+
// ----------| Update |----------|
// +----------+
// Update the seenItems with the provided previous items
this._seenItems.push(...items);
// Remove the provided previous items from the remainingItems
this._remainingItems = (0, differenceWith_1.default)(this._remainingItems, items, isEqual_1.default);
// Create a new zip array of items and answers. This will be useful in
// filtering operations below. It ensures that items and their corresponding
// answers "stay together."
const itemsAndAnswers = (0, zip_1.default)(items, answers);
// Update the ability estimate for all validated cats
for (const catName of catsToUpdate) {
const itemsAndAnswersForCat = itemsAndAnswers.filter(([stim]) =>
// We are dealing with a single item in this function. This single item
// has an array of zeta parameters for a bunch of different Cats. We
// need to determine if `catName` is present in that list. So we first
// reduce the zetas to get all of the applicabe cat names.
// Now that we have the subset of items that can apply to this cat,
// retrieve only the item parameters that apply to this cat.
stim.zetas.some((zeta) => zeta.cats.includes(catName)));
if (itemsAndAnswersForCat.length > 0) {
const zetasAndAnswersForCat = itemsAndAnswersForCat
.map(([stim, _answer]) => {
const zetaForCat = stim.zetas.find((zeta) => zeta.cats.includes(catName));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [zetaForCat.zeta, _answer]; // Optional chaining in case zetaForCat is undefined
})
.filter(([zeta]) => zeta !== undefined); // Filter out undefined zeta values
// Unzip the zetas and answers, making sure the zetas array contains only Zeta types
const [zetas, answers] = (0, unzip_1.default)(zetasAndAnswersForCat);
// Now call updateAbilityEstimates for this cat
this.updateAbilityEstimates([catName], zetas, answers, method);
}
}
if (this._earlyStopping) {
this._earlyStopping.update(this.cats, catToSelect);
if (this._earlyStopping.earlyStop) {
this._stoppingReason = 'Early stopping';
return undefined;
}
}
// Handle the 'unvalidated' cat selection
// +----------+
// ----------| Select |----------|
// +----------+
// We inspect the remaining items and find ones that have zeta parameters for `catToSelect`
const { available, missing } = (0, corpus_1.filterItemsByCatParameterAvailability)(this._remainingItems, catToSelect);
// Handle the 'unvalidated' cat selection
if (catToSelect === 'unvalidated') {
const unvalidatedRemainingItems = this._remainingItems.filter((stim) => !stim.zetas.some((zeta) => zeta.cats.length > 0));
if (unvalidatedRemainingItems.length === 0) {
// If returnUndefinedOnExhaustion is false, return an item from 'missing'
if (!returnUndefinedOnExhaustion && missing.length > 0) {
const randInt = Math.floor(this._rng() * missing.length);
return missing[randInt];
}
this._stoppingReason = 'No unvalidated items remaining';
return undefined;
}
else {
const randInt = Math.floor(this._rng() * unvalidatedRemainingItems.length);
return unvalidatedRemainingItems[randInt];
}
}
// The cat expects an array of Stimulus objects, with the zeta parameters
// spread at the top-level of each Stimulus object. So we need to convert
// the MultiZetaStimulus array to an array of Stimulus objects.
const availableCatInput = available.map((item) => {
const zetasForCat = item.zetas.find((zeta) => zeta.cats.includes(catToSelect));
return Object.assign(Object.assign({}, zetasForCat.zeta), item);
});
// Use the catForSelect to determine the next stimulus
const cat = this.cats[catToSelect];
const { nextStimulus } = cat.findNextItem(availableCatInput, itemSelect);
const nextStimulusWithoutZeta = (0, omit_1.default)(nextStimulus, [
'a',
'b',
'c',
'd',
'discrimination',
'difficulty',
'guessing',
'slipping',
]);
// Again `nextStimulus` will be a Stimulus object, or `undefined` if no further validated stimuli are available.
// We need to convert the Stimulus object back to a MultiZetaStimulus object to return to the user.
const returnStimulus = available.find((stim) => (0, isEqual_1.default)((0, omit_1.default)(stim, ['a', 'b', 'c', 'd', 'discrimination', 'difficulty', 'guessing', 'slipping']), nextStimulusWithoutZeta));
// Determine behavior based on returnUndefinedOnExhaustion
if (available.length === 0) {
// If returnUndefinedOnExhaustion is true and no validated items remain for the specified catToSelect, return undefined.
if (returnUndefinedOnExhaustion) {
this._stoppingReason = 'No validated items remaining for specified catToSelect';
return undefined; // Return undefined if no validated items remain
}
else {
// If returnUndefinedOnExhaustion is false, proceed with the fallback mechanism to select an item from other available categories.
return missing[Math.floor(this._rng() * missing.length)];
}
}
else if (missing.length === 0 || !randomlySelectUnvalidated) {
return returnStimulus; // Return validated item if available
}
else {
// Randomly decide whether to return a validated or unvalidated item
const random = Math.random();
const numRemaining = { available: available.length, missing: missing.length };
return random < numRemaining.missing / (numRemaining.available + numRemaining.missing)
? missing[Math.floor(this._rng() * missing.length)]
: returnStimulus;
}
}
}
exports.Clowder = Clowder;