jsPsych/packages/jspsych/src/modules/randomization.ts

340 lines
9.7 KiB
TypeScript

import rw from "random-words";
import seedrandom from "seedrandom/lib/alea";
/**
* Uses the `seedrandom` package to replace Math.random() with a seedable PRNG.
*
* @param seed An optional seed. If none is given, a random seed will be generated.
* @returns The seed value.
*/
export function setSeed(seed: string = Math.random().toString()) {
Math.random = seedrandom(seed);
return seed;
}
export function repeat(array, repetitions, unpack = false) {
const arr_isArray = Array.isArray(array);
const rep_isArray = Array.isArray(repetitions);
// if array is not an array, then we just repeat the item
if (!arr_isArray) {
if (!rep_isArray) {
array = [array];
repetitions = [repetitions];
} else {
repetitions = [repetitions[0]];
console.log(
"Unclear parameters given to randomization.repeat. Multiple set sizes specified, but only one item exists to sample. Proceeding using the first set size."
);
}
} else {
// if repetitions is not an array, but array is, then we
// repeat repetitions for each entry in array
if (!rep_isArray) {
let reps = [];
for (let i = 0; i < array.length; i++) {
reps.push(repetitions);
}
repetitions = reps;
} else {
if (array.length != repetitions.length) {
console.warn(
"Unclear parameters given to randomization.repeat. Items and repetitions are unequal lengths. Behavior may not be as expected."
);
// throw warning if repetitions is too short, use first rep ONLY.
if (repetitions.length < array.length) {
let reps = [];
for (let i = 0; i < array.length; i++) {
reps.push(repetitions);
}
repetitions = reps;
} else {
// throw warning if too long, and then use the first N
repetitions = repetitions.slice(0, array.length);
}
}
}
}
// should be clear at this point to assume that array and repetitions are arrays with == length
let allsamples = [];
for (let i = 0; i < array.length; i++) {
for (let j = 0; j < repetitions[i]; j++) {
if (array[i] == null || typeof array[i] != "object") {
allsamples.push(array[i]);
} else {
allsamples.push(Object.assign({}, array[i]));
}
}
}
let out: any = shuffle(allsamples);
if (unpack) {
out = unpackArray(out);
}
return out;
}
export function shuffle(array: Array<any>) {
if (!Array.isArray(array)) {
console.error("Argument to shuffle() must be an array.");
}
const copy_array = array.slice(0);
let m = copy_array.length,
t,
i;
// While there remain elements to shuffle…
while (m) {
// Pick a remaining element…
i = Math.floor(Math.random() * m--);
// And swap it with the current element.
t = copy_array[m];
copy_array[m] = copy_array[i];
copy_array[i] = t;
}
return copy_array;
}
export function shuffleNoRepeats(arr: Array<any>, equalityTest: (a: any, b: any) => boolean) {
if (!Array.isArray(arr)) {
console.error("First argument to shuffleNoRepeats() must be an array.");
}
if (typeof equalityTest !== "undefined" && typeof equalityTest !== "function") {
console.error("Second argument to shuffleNoRepeats() must be a function.");
}
// define a default equalityTest
if (typeof equalityTest == "undefined") {
equalityTest = function (a, b) {
if (a === b) {
return true;
} else {
return false;
}
};
}
const random_shuffle = shuffle(arr);
for (let i = 0; i < random_shuffle.length - 1; i++) {
if (equalityTest(random_shuffle[i], random_shuffle[i + 1])) {
// neighbors are equal, pick a new random neighbor to swap (not the first or last element, to avoid edge cases)
let random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
// test to make sure the new neighbor isn't equal to the old one
while (
equalityTest(random_shuffle[i + 1], random_shuffle[random_pick]) ||
equalityTest(random_shuffle[i + 1], random_shuffle[random_pick + 1]) ||
equalityTest(random_shuffle[i + 1], random_shuffle[random_pick - 1])
) {
random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
}
const new_neighbor = random_shuffle[random_pick];
random_shuffle[random_pick] = random_shuffle[i + 1];
random_shuffle[i + 1] = new_neighbor;
}
}
return random_shuffle;
}
export function shuffleAlternateGroups(arr_groups, random_group_order = false) {
const n_groups = arr_groups.length;
if (n_groups == 1) {
console.warn(
"shuffleAlternateGroups() was called with only one group. Defaulting to simple shuffle."
);
return shuffle(arr_groups[0]);
}
let group_order = [];
for (let i = 0; i < n_groups; i++) {
group_order.push(i);
}
if (random_group_order) {
group_order = shuffle(group_order);
}
const randomized_groups = [];
let min_length = null;
for (let i = 0; i < n_groups; i++) {
min_length =
min_length === null ? arr_groups[i].length : Math.min(min_length, arr_groups[i].length);
randomized_groups.push(shuffle(arr_groups[i]));
}
const out = [];
for (let i = 0; i < min_length; i++) {
for (let j = 0; j < group_order.length; j++) {
out.push(randomized_groups[group_order[j]][i]);
}
}
return out;
}
export function sampleWithoutReplacement(arr, size) {
if (!Array.isArray(arr)) {
console.error("First argument to sampleWithoutReplacement() must be an array");
}
if (size > arr.length) {
console.error("Cannot take a sample larger than the size of the set of items to sample.");
}
return shuffle(arr).slice(0, size);
}
export function sampleWithReplacement(arr, size, weights?) {
if (!Array.isArray(arr)) {
console.error("First argument to sampleWithReplacement() must be an array");
}
const normalized_weights = [];
if (typeof weights !== "undefined") {
if (weights.length !== arr.length) {
console.error(
"The length of the weights array must equal the length of the array " +
"to be sampled from."
);
}
let weight_sum = 0;
for (const weight of weights) {
weight_sum += weight;
}
for (const weight of weights) {
normalized_weights.push(weight / weight_sum);
}
} else {
for (let i = 0; i < arr.length; i++) {
normalized_weights.push(1 / arr.length);
}
}
const cumulative_weights = [normalized_weights[0]];
for (let i = 1; i < normalized_weights.length; i++) {
cumulative_weights.push(normalized_weights[i] + cumulative_weights[i - 1]);
}
const samp = [];
for (let i = 0; i < size; i++) {
const rnd = Math.random();
let index = 0;
while (rnd > cumulative_weights[index]) {
index++;
}
samp.push(arr[index]);
}
return samp;
}
export function factorial(factors: Record<string, any>, repetitions = 1, unpack = false) {
let design = [{}];
for (const [factorName, factor] of Object.entries(factors)) {
const new_design = [];
for (const level of factor) {
for (const cell of design) {
new_design.push({ ...cell, [factorName]: level });
}
}
design = new_design;
}
return repeat(design, repetitions, unpack);
}
export function randomID(length = 32) {
let result = "";
const chars = "0123456789abcdefghjklmnopqrstuvwxyz";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/**
* Generate a random integer from `lower` to `upper`, inclusive of both end points.
* @param lower The lowest value it is possible to generate
* @param upper The highest value it is possible to generate
* @returns A random integer
*/
export function randomInt(lower: number, upper: number) {
if (upper < lower) {
throw new Error("Upper boundary must be less than or equal to lower boundary");
}
return lower + Math.floor(Math.random() * (upper - lower + 1));
}
/**
* Generates a random sample from a Bernoulli distribution.
* @param p The probability of sampling 1.
* @returns 0, with probability 1-p, or 1, with probability p.
*/
export function sampleBernoulli(p: number) {
return Math.random() <= p ? 1 : 0;
}
export function sampleNormal(mean: number, standard_deviation: number) {
return randn_bm() * standard_deviation + mean;
}
export function sampleExponential(rate: number) {
return -Math.log(Math.random()) / rate;
}
export function sampleExGaussian(
mean: number,
standard_deviation: number,
rate: number,
positive = false
) {
let s = sampleNormal(mean, standard_deviation) + sampleExponential(rate);
if (positive) {
while (s <= 0) {
s = sampleNormal(mean, standard_deviation) + sampleExponential(rate);
}
}
return s;
}
/**
* Generate one or more random words.
*
* This is a wrapper function for the {@link https://www.npmjs.com/package/random-words `random-words` npm package}.
*
* @param opts An object with optional properties `min`, `max`, `exactly`,
* `join`, `maxLength`, `wordsPerString`, `separator`, and `formatter`.
*
* @returns An array of words or a single string, depending on parameter choices.
*/
export function randomWords(opts) {
return rw(opts);
}
// Box-Muller transformation for a random sample from normal distribution with mean = 0, std = 1
// https://stackoverflow.com/a/36481059/3726673
function randn_bm() {
var u = 0,
v = 0;
while (u === 0) u = Math.random(); //Converting [0,1) to (0,1)
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
function unpackArray(array) {
const out = {};
for (const x of array) {
for (const key of Object.keys(x)) {
if (typeof out[key] === "undefined") {
out[key] = [];
}
out[key].push(x[key]);
}
}
return out;
}