@bdelab/jscat
Version:
A library to support IRT-based computer adaptive testing in JavaScript
394 lines (358 loc) • 15.9 kB
text/typescript
import { Cat, CatInput } from './cat';
import { CatMap, MultiZetaStimulus, Stimulus, Zeta, ZetaCatMap } from './type';
import { filterItemsByCatParameterAvailability, checkNoDuplicateCatNames } from './corpus';
import _cloneDeep from 'lodash/cloneDeep';
import _differenceWith from 'lodash/differenceWith';
import _isEqual from 'lodash/isEqual';
import _mapValues from 'lodash/mapValues';
import _omit from 'lodash/omit';
import _unzip from 'lodash/unzip';
import _zip from 'lodash/zip';
import seedrandom from 'seedrandom';
import { EarlyStopping } from './stopping';
export interface ClowderInput {
/**
* An object containing Cat configurations for each Cat instance.
* Keys correspond to Cat names, while values correspond to Cat configurations.
*/
cats: CatMap<CatInput>;
/**
* An object containing arrays of stimuli for each corpus.
*/
corpus: MultiZetaStimulus[];
/**
* A random seed for reproducibility. If not provided, a random seed will be generated.
*/
randomSeed?: string | null;
/**
* An optional EarlyStopping instance to use for early stopping.
*/
earlyStopping?: EarlyStopping;
}
/**
* 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.
*/
export class Clowder {
private _cats: CatMap<Cat>;
private _corpus: MultiZetaStimulus[];
private _remainingItems: MultiZetaStimulus[];
private _seenItems: Stimulus[];
private _earlyStopping?: EarlyStopping;
private readonly _rng: ReturnType<seedrandom>;
private _stoppingReason: string | null;
/**
* 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 }: ClowderInput) {
// 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 = {
..._mapValues(cats, (catInput) => new Cat(catInput)),
unvalidated: new Cat({ itemSelect: 'random', randomSeed }), // Add 'unvalidated' cat
};
this._seenItems = [];
checkNoDuplicateCatNames(corpus);
this._corpus = corpus;
this._remainingItems = _cloneDeep(corpus);
this._rng = randomSeed === null ? seedrandom() : seedrandom(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.
*/
private _validateCatName(catName: string, allowUnvalidated = false): void {
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.
*/
public get cats() {
return _omit(this._cats, ['unvalidated']);
}
/**
* The corpus that was provided to this Clowder when it was created.
*/
public get corpus() {
return this._corpus;
}
/**
* The subset of the input corpus that this Clowder has not yet "seen".
*/
public get remainingItems() {
return this._remainingItems;
}
/**
* The subset of the input corpus that this Clowder has "seen" so far.
*/
public get seenItems() {
return this._seenItems;
}
/**
* The theta estimates for each Cat instance.
*/
public get theta() {
return _mapValues(this.cats, (cat) => cat.theta);
}
/**
* The standard error of measurement estimates for each Cat instance.
*/
public get seMeasurement() {
return _mapValues(this.cats, (cat) => cat.seMeasurement);
}
/**
* The number of items presented to each Cat instance.
*/
public get nItems() {
return _mapValues(this.cats, (cat) => cat.nItems);
}
/**
* The responses received by each Cat instance.
*/
public get resps() {
return _mapValues(this.cats, (cat) => cat.resps);
}
/**
* The zeta (item parameters) received by each Cat instance.
*/
public get zetas() {
return _mapValues(this.cats, (cat) => cat.zetas);
}
/**
* The early stopping condition in the Clowder configuration.
*/
public get earlyStopping() {
return this._earlyStopping;
}
/**
* The stopping reason in the Clowder configuration.
*/
public 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.
*/
public updateAbilityEstimates(catNames: string[], zeta: Zeta | Zeta[], answer: (0 | 1) | (0 | 1)[], method?: string) {
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.
*/
public updateCatAndGetNextItem({
catToSelect,
catsToUpdate = [],
items = [],
answers = [],
method,
itemSelect,
randomlySelectUnvalidated = false,
returnUndefinedOnExhaustion = true,
}: {
catToSelect: string;
catsToUpdate?: string | string[];
items?: MultiZetaStimulus | MultiZetaStimulus[];
answers?: (0 | 1) | (0 | 1)[];
method?: string;
itemSelect?: string;
randomlySelectUnvalidated?: boolean;
returnUndefinedOnExhaustion?: boolean; // New parameter type
}): Stimulus | undefined {
// +----------+
// ----------| 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 = _differenceWith(this._remainingItems, items, _isEqual);
// 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 = _zip(items, answers) as [Stimulus, 0 | 1][];
// 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: ZetaCatMap) => zeta.cats.includes(catName)),
);
if (itemsAndAnswersForCat.length > 0) {
const zetasAndAnswersForCat = itemsAndAnswersForCat
.map(([stim, _answer]) => {
const zetaForCat: ZetaCatMap | undefined = stim.zetas.find((zeta: ZetaCatMap) =>
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] = _unzip(zetasAndAnswersForCat) as [Zeta[], (0 | 1)[]];
// 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 } = filterItemsByCatParameterAvailability(this._remainingItems, catToSelect);
// Handle the 'unvalidated' cat selection
if (catToSelect === 'unvalidated') {
const unvalidatedRemainingItems = this._remainingItems.filter(
(stim) => !stim.zetas.some((zeta: ZetaCatMap) => 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 {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...zetasForCat!.zeta,
...item,
};
});
// Use the catForSelect to determine the next stimulus
const cat = this.cats[catToSelect];
const { nextStimulus } = cat.findNextItem(availableCatInput, itemSelect);
const nextStimulusWithoutZeta = _omit(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: MultiZetaStimulus | undefined = available.find((stim) =>
_isEqual(
_omit(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;
}
}
}