diff --git a/SODM.m b/SODM.m index 43667e9..293a98c 100644 --- a/SODM.m +++ b/SODM.m @@ -5,9 +5,7 @@ addpath(genpath('./lib')); addpath(genpath('./tasks/SODM')); -addpath('./tasks/MDM'); % Add subfolders we'll be using to path -% NOTE: genpath gets the directory and all its subdirectories %% Load config config = SODM_blockDefaults(); @@ -37,63 +35,9 @@ end end -% Disambiguate config here -monConfig = SODM_monetaryConfig(config); -medConfig = SODM_medicalConfig(config); - %% Generate trials if not generated already if ~isfield(Data, 'blocks') || isempty(Data.blocks) - % NOTE: Generating each one separately with two repeats, so that there isn't - % a cluster of high values in self vs. other - medSelfBlocks = generateBlocks(medConfig, medConfig.trial.generate.catchTrial, ... - medConfig.trial.generate.catchIdx); - medOtherBlocks = generateBlocks(medConfig, medConfig.trial.generate.catchTrial, ... - medConfig.trial.generate.catchIdx); - monSelfBlocks = generateBlocks(monConfig, monConfig.trial.generate.catchTrial, ... - monConfig.trial.generate.catchIdx); - monOtherBlocks = generateBlocks(monConfig, monConfig.trial.generate.catchTrial, ... - monConfig.trial.generate.catchIdx); - - medBlocks = [medSelfBlocks; medOtherBlocks]; - monBlocks = [monSelfBlocks; monOtherBlocks]; - - sortOrder = mod(Data.subjectId, 4); - selfIdx = [1 0 1 0]; % 0 is friend, 1 is self - medIdx = [1 1 0 0]; % 0 is monetary, 1 is medical - - switch sortOrder - case 0 - % Keep order - case 1 - selfIdx = 1 - selfIdx; - case 2 - medIdx = 1 - medIdx; - case 3 - selfIdx = 1 - selfIdx; - medIdx = 1 - medIdx; - end - % Repeat the order for the second round - selfIdx = repmat(selfIdx, 1, 2); - medIdx = repmat(medIdx, 1, 2); - beneficiaryLookup = {'Friend', 'Self'}; - - % Logic: Do mon/med blocks first, pass self/other to them depending on selfIdx - numBlocks = length(selfIdx); - Data.numFinishedBlocks = 0; - for blockIdx = 1:numBlocks - blockKind = medIdx(blockIdx); - beneficiaryKind = selfIdx(blockIdx); - beneficiaryStr = beneficiaryLookup{beneficiaryKind + 1}; - withinKindIdx = sum(medIdx(1 : blockIdx) == blockKind); - - if blockKind == 1 - medConfig.runSetup.conditions.beneficiary = beneficiaryStr; - Data = addGeneratedBlock(Data, medBlocks{withinKindIdx}, medConfig); - else - monConfig.runSetup.conditions.beneficiary = beneficiaryStr; - Data = addGeneratedBlock(Data, monBlocks{withinKindIdx}, monConfig); - end - end + Data.blocks = SODM_generateBlocks(subjectId, config); end % Select which blocks to run @@ -114,5 +58,5 @@ end % Close window -unloadPTB(monConfig, medConfig); +unloadPTB(config); end diff --git a/lib/helpers/addDispersedColumns.m b/lib/helpers/addDispersedColumns.m new file mode 100644 index 0000000..5fe618f --- /dev/null +++ b/lib/helpers/addDispersedColumns.m @@ -0,0 +1,21 @@ +function [ tbl ] = addDispersedColumns(tbl, values) +% For each field in `values`, spread its contents in a new column in `tbl` +% +% Args: +% tbl: a `table` with some rows +% values: a `struct` in which each field is a matrix of values that you'd +% like distributed evenly across rows of `tbl` +% +% Returns: +% tbl with additional columns, one for each field in `values`. +% +% Note: +% To fit each arbitrarily long field in `values` into an arbitrary number of +% rows, the function uses `cutArrayToSize`. If there's fewer +% elements than rows, the values will be repeated; if there's more values +% than rows, the first n terms in each field will be used. + +props = structfun(@(x) cutArrayToSize(x, height(tbl)), ... + values, 'UniformOutput', false); +tbl = [tbl, struct2table(props)]; +end diff --git a/lib/helpers/generateValuesForMissing.m b/lib/helpers/generateValuesForMissing.m new file mode 100644 index 0000000..2a8b814 --- /dev/null +++ b/lib/helpers/generateValuesForMissing.m @@ -0,0 +1,29 @@ +function tbl = generateValuesForMissing(tbl, levelConfig) +% If any of the columns in `tbl` have NaN in row, it will select a value +% randomly from the corresponding field of `levelConfig`. +% +% Args: +% tbl: An arbitrary table with some NaN values +% levelConfig: A struct that contains value options to replace NaN in the given columns +% +% Returns: +% tbl: A new table with missing values generated. +tblCols = tbl.Properties.VariableNames; +knownLevels = fieldnames(levelConfig); + +for k = 1:numel(tblCols) + colName = tblCols{k}; + if ~ismember(colName, knownLevels) + continue + end + col = tbl.(colName); + changable = isnan(col); + changableIdx = find(changable); % Get non-zero values + if sum(changable) > 0 + numOptions = length(levelConfig.(colName)); + for l = 1:length(changableIdx) + tbl(changableIdx(l), k) = {levelConfig.(colName)(randi(numOptions))}; + end + end +end +end diff --git a/lib/helpers/injectInBlocks.m b/lib/helpers/injectInBlocks.m new file mode 100644 index 0000000..5ef505b --- /dev/null +++ b/lib/helpers/injectInBlocks.m @@ -0,0 +1,22 @@ +function [ blocksOut ] = injectInBlocks(blockArray, fields, insertion) +% Adds/changes the value of nested config structs within a block array +% +% Args: +% blockArray: cell array of block structs +% fields: a cell array that gives the "adress" for injection. If you wish to +% change `config.runSetup.authorName` in every block, you would use +% `{'runSetup', 'authorName'}` +% insertion: if it is a function handle, it will be called with the value of +% the target field as argument, and the output will replace that value. If +% it is any other variable kind, it will simply be set as the value of the +% target field. + +if isFunction(insertion) + blocksOut = cellfun(@(block) setfield(s, fields{:}, ... + insertion(getfield(s, fields{:}))), ... + 'UniformOutput', false); +else + blocksOut = cellfun(@(block) setfield(s, fields{:}, insertion), ... + 'UniformOutput', false); +end +end diff --git a/lib/helpers/injectRowAtIndex.m b/lib/helpers/injectRowAtIndex.m new file mode 100644 index 0000000..98c73e1 --- /dev/null +++ b/lib/helpers/injectRowAtIndex.m @@ -0,0 +1,34 @@ +function tbl = injectRowAtIndex(tbl, row, rowIndex, levelConfig) +% Insert a tabular `row` into a table at all specified indices. +% +% If any of the named fields is NaN and levelConfig is provided, +% the function invokes `generateValuesForMissing` to generate a value from that +% field from levels. +% +% Args: +% tbl: The original table +% row: A (usually single-row) table to be inserted at specified indices +% rowIndex: A row index or a matrix of the row indices +% levelConfig (optional): A configuration level containing replacement +% information for use by `generateValuesForMissing` +% +% Returns: +% tbl: A new table with a row at every index. +% +% Warning: +% The function assumes that the row can be concatenated with the output of +% `tbl`. If the row doesn't have all the same columns as tbl, the injection +% will fail with a hard error. + +if exist('levelConfig', 'var') + row = generateValuesForMissing(row, levelConfig); +end + +rowIndex = sort(rowIndex); +for idx = 1:length(rowIndex) + tbl_pre = tbl(1:(rowIndex(idx) - 1), :); + tbl_post = tbl(rowIndex(idx):end, :); + % FIXME: Use join in case `row` orders the same variables differently + tbl = [tbl_pre; row; tbl_post]; +end +end diff --git a/lib/setup/generateBlocks.m b/lib/setup/generateBlocks.m index a65cb49..23ed0ac 100644 --- a/lib/setup/generateBlocks.m +++ b/lib/setup/generateBlocks.m @@ -88,63 +88,3 @@ plannedBlocks{k} = trialTbl; end end - -% Helper function -function tbl = injectRowAtIndex(tbl, row, rowIndex, levelConfig) - % Put a constant `row` at all indices in `rowIndex`. - % - % Assumes that the row can be concatenated with the output of `trialTbl`. - % If any of the named fields is NaN and levelConfig is provided, - % generate a value from that field from levels. - % - % Args: - % tbl: The original table - % row: An area in table where missing information has been filled in - % rowIndex: An array of the row indices - % levelConfig: A configuration level containing trial information - % - % Returns: - % tbl: A new table with a row at every index. - if exist('levelConfig', 'var') - row = generateValuesForMissing(row, levelConfig); - end - - rowIndex = sort(rowIndex); - for idx = 1:length(rowIndex) - tbl_pre = tbl(1:(rowIndex(idx) - 1), :); - tbl_post = tbl(rowIndex(idx):end, :); - % FIXME: Use join in case `row` orders the same variables differently - tbl = [tbl_pre; row; tbl_post]; - end -end - -%Helper function -function tbl = generateValuesForMissing(tbl, levelConfig) - % If any of the columns in `tbl` have NaN in row, - % it will insert a random value from corresponding field of `levelConfig`. - % - % Args: - % tbl: An original table - % levelConfig: A configuration level containing trial information - % - % Returns: - % tbl: A new table with missing values generated. - tblCols = tbl.Properties.VariableNames; - knownLevels = fieldnames(levelConfig); - - for k = 1:numel(tblCols) - colName = tblCols{k}; - if ~ismember(colName, knownLevels) - continue - end - col = tbl.(colName); - changable = isnan(col); - changableIdx = find(changable); % Get non-zero values - if sum(changable) > 0 - numOptions = length(levelConfig.(colName)); - for l = 1:length(changableIdx) - tbl(changableIdx(l), k) = {levelConfig.(colName)(randi(numOptions))}; - end - end - end -end diff --git a/lib/setup/generateCombinations.m b/lib/setup/generateCombinations.m new file mode 100644 index 0000000..951a830 --- /dev/null +++ b/lib/setup/generateCombinations.m @@ -0,0 +1,50 @@ +function [ combinations ] = generateCombinations(generative, complementary, startTable) +% Generates chosen combinations of generative and complementary properties +% +% Args: +% generative: a `struct` in which each field is a matrix of numerical values +% that you'd like all possible combinations of +% complementary (optional): a `struct` in which each field is a matrix of numerical values +% that you'd like distributed evenly, once per combination +% startTable (optional, deprecated): a `table` to vertically concatenate the +% results with prior to the addition of complementary values +% +% Returns: +% a `table` with duly combined and distributed values +% +% Warning: +% Strings in `generative` will not work. If you need them, use placeholder +% values and substitute the strings in later. + +if isstruct(generative) && ~isempty(generative) && length(fieldnames(generative)) > 0 + values = struct2cell(generative); + names = fieldnames(generative); + + % Trick to record an arbitrary # of outputs & pass an arbitrary # of inputs: + allComb = cell(1, numel(names)); + [allComb{:}] = ndgrid(values{:}); + allComb = cellfun(@(x) x(:), allComb, 'UniformOutput', false); + allGenerative = horzcat(allComb{:}); + + combinations = array2table(allGenerative, 'VariableNames', names); +else + combinations = table; + warning('No generative properties provided.'); +end + +if exist('startTable', 'var') + if istable(startTable) + combinations = vertcat(startTable, combinations); + else + warning('startTable must be type table; skipping.'); + end +end + +if isempty(combinations) + return; +end + +if exist('complementary', 'var') && isstruct(complementary) + combinations = addDispersedColumns(combinations, complementary); +end +end diff --git a/lib/setup/orderBlocksAcrossConditions.m b/lib/setup/orderBlocksAcrossConditions.m new file mode 100644 index 0000000..130c6e6 --- /dev/null +++ b/lib/setup/orderBlocksAcrossConditions.m @@ -0,0 +1,56 @@ +function [ blocksArray ] = orderBlocksAcrossConditions(orderMatrix, varargin) +% Given an order pattern, order blocks for any number of conditions +% +% Args: +% orderMatrix: a 1-m matrix of values ranging from 1 to n, where m is the +% number of blocks in the task and n is the number of conditions in the +% task. orderMatrix{m} = n means that m-th element of the matrix will be +% assigned from condition n. For example, [1 2 2 1] would be an order +% matrix that places blocks from condition 1 at the start and end of the +% task, and blocks from condition 2 in the middle. +% condition1_blocks, condition2_blocks, ...: cell array of blocks, as +% produced e.g. by `splitTrialsIntoBlocks` (although technically, the +% function doesn't care about the object class the block is represented +% with) + +% check that at least one condition was received +narginchk(2, Inf); + +% check that length of all blocks is equal of the number of blocks +% TODO: fix so that "some multiple" works too +orderCount = length(orderMatrix); +blocksPerConditionCount = cellfun(@(x) length(x), varargin); +blockCount = sum(blocksPerConditionCount); + +if blockCount ~= orderCount + error('orderMatrix doesn''t contain enough elements to account for all passed blocks') +end + +% check that length of all blocks is some multiple of the number of blocks +if rem(blockCount, orderCount) > 0 + error('Length of orderMatrix does not divide the overall number of blocks evenly.'); +end + +% check that all conditions are represented in orderMatrix +conditionCount = nargin - 1; +uniqueOrderElts = length(unique(orderMatrix(:))); +if uniqueOrderElts ~= conditionCount + error('orderMatrix does not represent all passed blocks.') +end + +% check that each condition is represented with the right number of blocksArray +orderEltsPerCondition = histcounts(orderMatrix(:)); +if blocksPerConditionCount ~= orderEltsPerCondition + warning('orderMatrix does not have the right number of blocks to represent actual block divisions'); +end + +%% Actual logic +blocksArray = cell(1, blockCount); +conditionCounts = zeros(1, conditionCount); +for k = 1:length(orderMatrix) + currentCondition = orderMatrix(k); + conditionCounts(currentCondition) = conditionCounts(currentCondition) + 1; + countInCondition = conditionCounts(currentCondition); + blocksArray{k} = varargin{currentCondition}{countInCondition}; +end +end diff --git a/lib/setup/rotateBlockOrder.m b/lib/setup/rotateBlockOrder.m new file mode 100644 index 0000000..312ac79 --- /dev/null +++ b/lib/setup/rotateBlockOrder.m @@ -0,0 +1,48 @@ +function [ newOrder ] = rotateBlockOrder(orderMatrix, determinantNumber, determinantFn) +% Deterministically ceunterbalances the order of blocks +% +% Args: +% orderMatrix: a 1-by-m matrix of values ranging from 1 to n, where m is the +% number of blocks in the task and n is the number of conditions in the +% task. orderMatrix{m} = n means that m-th element of the matrix will be +% assigned from condition n. +% determinantNumber: any integer (or, if you're supplying determinantFn, any +% input that it can process) +% determinantFn (optional): A function that, given determinantNumber as its +% only argument, willoutput an integer by which the orderMatrix values +% should be rotated. By default, it's rem(determinantNumber, n). +% +% Another useful variation on determinantFn is `@(x) ismember(mod(x, 10), [1 2 +% 5 7])`, which will flip the order for subjects whose last digit is in the +% array. (Longer, non-anonymous functions are easy as well, especially for +% cases where you have more than two conditions.) +% +% rotateBlockOrder assumes that you'll generally want to keep both contiguity +% of same-condition blocks and the pattern of such contiguities. You might wish +% to look at `circshift` for another way of rotating the order matrix or +% `randperm` for a completely random permutation. +% +% Note: +% If determinantNumber is NaN (e.g. because no subject ID was assigned for a +% practice run), rotateBlockOrder will return the original orderMatrix +% without running determinantFn. + +conditionCount = length(unique(orderMatrix)); + +if isnan(determinantNumber) + newOrder = orderMatrix; + return; +end + +if exist('determinantFn', 'var') + if ~isFunction(determinantFn) + error(['determinantFn is not a function handle. To provide a function ', ... + 'handle, place the @-sign in front of a function name, e.g. @circshift', ... + 'or @(x) mod(x, 2).']) + end +else + determinantFn = @(x) rem(x, conditionCount); +end + +newOrder = rem(orderMatrix + determinantFn(determinantNumber), conditionCount) + 1; +end diff --git a/lib/setup/splitTrialsIntoBlocks.m b/lib/setup/splitTrialsIntoBlocks.m new file mode 100644 index 0000000..914f9a0 --- /dev/null +++ b/lib/setup/splitTrialsIntoBlocks.m @@ -0,0 +1,75 @@ +function [ blocks ] = splitTrialsIntoBlocks(trials, blockConfig, conditions) +% Splits a table of trials into blocks based on provided configuration +% +% Args: +% trials: a `table` in which each row defines a single trial's properties +% blockConfig: +% conditions (optional): a struct with arbitrary fields that describe the +% condition that blockConfig defines +% +% Returns: +% a cell array of ready-to-run blocks + [ catchTrial, catchIdx ] = getCatchTrial(blockConfig); + catchTrialCount = height(catchTrial) * length(catchIdx); + + blockLengths = getBlockLengths(blockConfig, height(trials), catchTrialCount); + blockNum = length(blockLengths); + blocks = cell(blockNum, 1); % Pre-allocate + + for k = 1:blockNum + if k == 0 + startIdx = 1; + else + startIdx = sum(blockLengths(1:(k - 1))) + 1; + end + endIdx = sum(blockLengths(1:k)); + blockTrials = trials(startIdx:endIdx, :); + + % Insert a condition + if exist('conditions', 'var') + if ~isstruct(conditions) + warning('Warning: `conditions` is not a structure.'); + end + else + if isfield(blockConfig.runSetup, 'conditions') + % warning('`conditions` was not passed; using blockConfig.runSetup.conditions.'); + conditions = blockConfig.runSetup.conditions; + else + warning('`conditions` was neither passed nor defined; making conditions blank.'); + conditions = struct.empty; + end + end + + % Insert constant catch trial at catchIdx of each block + if ~isempty(catchTrial) + if isnan(catchIdx) + catchIdx = randi(height(blockTrials)); + end + + try + blockTrials = injectRowAtIndex(blockTrials, catchTrial, catchIdx); + catch ME + warning(['Attempt to add a catch trial raised the %s exception. ', ... + 'The catch trial was not added.'], ME.identifier); + end + end + + % blocks{k} = trialTbl; + blocks{k} = struct('trials', blockTrials, 'config', blockConfig, ... + 'conditions', conditions, 'data', table(), 'finished', false); + end +end + +function [ catchTrial, catchIdx ] = getCatchTrial(blockConfig) + catchTrial = []; + catchIdx = NaN; + if isfield(blockConfig, 'trial') && ... %isfield(blockConfig.trial, 'catchTrial') || ... + isfield(blockConfig.trial, 'generate') + if isfield(blockConfig.trial.generate, 'catchTrial') + catchTrial = blockConfig.trial.generate.catchTrial; + end + if isfield(blockConfig.trial.generate, 'catchIdx') + catchIdx = blockConfig.trial.generate.catchIdx; + end + end +end diff --git a/tasks/SODM/SODM_blockDefaults.m b/tasks/SODM/SODM_blockDefaults.m index 63a02aa..41f60ee 100644 --- a/tasks/SODM/SODM_blockDefaults.m +++ b/tasks/SODM/SODM_blockDefaults.m @@ -58,7 +58,7 @@ s.trial.generate.ITIs = s.trial.legacyPhases.intertrial.duration; s.trial.generate.catchIdx = 1; -catchVals = struct('stakes', 2, 'probs', NaN, 'ambigs', [], ... - 'stakes_loss', 1, 'reference', 2, 'colors', NaN, 'ITIs', 5); +catchVals = struct('stakes', 2, 'probs', 0.5, 'ambigs', [], ... + 'stakes_loss', 1, 'reference', 2, 'colors', 1, 'ITIs', 5); s.trial.generate.catchTrial = generateTrials(catchVals); end diff --git a/tasks/SODM/SODM_generateBlocks.m b/tasks/SODM/SODM_generateBlocks.m new file mode 100644 index 0000000..784d427 --- /dev/null +++ b/tasks/SODM/SODM_generateBlocks.m @@ -0,0 +1,111 @@ +function [out] = SODM_generateOrderedBlocks(subjectId, config) +% Generate a cell array of all blocks from all four SODM conditions +% +% Args: +% subjectId: a positive integer that identifies the participant +% config: the default config struct from which each condition's config will +% inherit +% +% Returns: +% cell array of block structs arranged in a proper counterbalanced order +if ~exist('config', 'var') + config = SODM_blockDefaults(); +end + +% Before we get started with condition-specific changes, let's sort out the +% alluvia in .generate: extract core and complementary properties & split +% them up +coreNames = {'probs', 'ambigs', 'stakes', 'stakes_loss', 'reference'}; +fillNames = {'colors', 'ITIs'}; +allGenerateNames = fieldnames(config.trial.generate); +removeAlways = {allGenerateNames{~ismember(allGenerateNames, coreNames) & ... + ~ismember(allGenerateNames, fillNames)}}; +config.trial.coreGenerate = rmfield(config.trial.generate, [removeAlways, fillNames]); +config.trial.fillGenerate = rmfield(config.trial.generate, [removeAlways, coreNames]); +% Matlab Lamentation #311: believe it or not, this is the most +% straightforward way to select multiple fields in a struct + +% Create separate full config structs for each of the four conditions +medSelfConfig = SODM_medicalConfig(config); +medSelfConfig.runSetup.conditions.beneficiary = 'Self'; +medOtherConfig = SODM_medicalConfig(config); +medOtherConfig.runSetup.conditions.beneficiary = 'Friend'; + +monSelfConfig = SODM_monetaryConfig(config); +monSelfConfig.runSetup.conditions.beneficiary = 'Self'; +monOtherConfig = SODM_monetaryConfig(config); +monOtherConfig.runSetup.conditions.beneficiary = 'Friend'; + +% This takes advantage of the fact that each config is treated the same way. +% We start with a cell array of the four configs we created... +allConditions = {medSelfConfig, monSelfConfig, medOtherConfig, monOtherConfig}; +% ...and then we call SODM_generateBlocksForCondition (a local function defined +% below) on them, the outcome of which is a cell array of blocks for each +% condition. We now have a cell array of four elements; each of the elements +% is itself a cell array of block structs: +allConditionsWithBlocks = cellfun(@SODM_generateBlocksForCondition, allConditions, ... + 'UniformOutput', false); + +% Penultimately, determine the order in which the blocks will be arranged. +% (Here, 1 refers to medSelfConfig-based blocks, 2 refers to monSelfConfig, +% and so on -- they're the indices of `allConditions`.) +orderToUse = [1 1 2 2 3 3 4 4]; +if exist('subjectId', 'var') + orderToUse = rotateBlockOrder(orderToUse, subjectId); +end + +% Finally, this function uses the order we derived to "flatten" +% allConditionsWithBlocks to create a cell array of block structs +out = orderBlocksAcrossConditions(orderToUse, allConditionsWithBlocks{:}); +end + + +%% Helpers +function [ blockArray ] = SODM_generateBlocksForCondition(condition) + % Generates the condition's trials, randomly sorts them, & splits them into blocks + % + % (Although this function is named with an SODM prefix, this really + % generalizes to all risk-and-ambiguity tasks.) + + % For the current iteration of R&A tasks, we are peculiar about the property + % combinations of our trials -- specifically, we only have ambiguous trials + % with an equal midpoint (P = 0.5), so we don't want to mix ambiguity and + % risk up: + fillOnly = condition.trial.fillGenerate; + coreOnly = condition.trial.coreGenerate; + ambiguityCore = coreOnly; + ambiguityCore.probs = 0.5; + riskCore = coreOnly; + riskCore.ambigs = 0; + + trialTblSingle = [generateCombinations(riskCore); ... + generateCombinations(ambiguityCore)]; + + % That only generated a single combination for each possibility, but we often + % want to repeat constellations. We certainly want to do that at least once: + if isfield(condition.trial.generate, 'repeats') + repeats = condition.trial.generate.repeats; + else + repeats = 1; + end + + trialTbl = table; + for k = 1:repeats + trialTbl = [trialTbl; trialTblSingle]; + end + + % Do note that we're doing this just to demonstrate how to generate trials + % programmatically; if we were importing ready-made combinations from a file, + % almost all of the code in this function thus far (and some of the code in + % the main function) would be made unnecessary. + + % Randomize the order of trials + trialTbl = trialTbl(randperm(height(trialTbl)), :); + + % Add the "non-core" columns to each row + trialTbl = addDispersedColumns(trialTbl, fillOnly); + + % Finally, split up the single table of trials into as many blocks as + % `condition` requires in its config. (See `getBlockLengths` for the rules.) + blockArray = splitTrialsIntoBlocks(trialTbl, condition); +end