├── .gitignore ├── Contents.m ├── LICENSE ├── README.md ├── contributed ├── qpNormCdfFromScratchDemo.m ├── qpPFRating.m ├── qpPFRatingParamsCheck.m ├── qpPFRatingParamsCheck2.m ├── qpPFStandardWeibull.m ├── qpPFStandardWeibullInv.m ├── qpPairwiseComparisonDemo.m ├── qpPairwiseStimCheck.m ├── qpQuestPlusRatingDemo.m └── qpQuestPlusRatingDemo2.m ├── dataproc ├── Contents.m ├── qpCounts.m ├── qpData.m ├── qpExampleData.m └── qpProportions.m ├── demos ├── Contents.m ├── qpDataProcDemo.m ├── qpPsiFunctionDemo.m ├── qpQuestPlusCSFDemo.m ├── qpQuestPlusCircularCatDemo.m ├── qpQuestPlusCoreFunctionDemo.m ├── qpQuestPlusMarginalizeDemo.m ├── qpQuestPlusNormCdfDemo.m ├── qpQuestPlusPaperSimpleExamplesDemo.m ├── qpRunAllDemos.m └── qpUtilityDemo.m ├── mathworkscentral ├── Contents.m ├── allcomb │ ├── allcomb.m │ └── license.txt └── von_mises_cdf │ └── von_mises_cdf.m ├── printplot ├── Contents.m ├── qpPrintParams.m ├── qpPrintStim.m ├── qpPrintStimCounts.m ├── qpPrintStimData.m ├── qpPrintStimProportions.m └── qpPrintTrialData.m ├── psifunctions ├── Contents.m ├── qpPFCircular.m ├── qpPFCircularParamsCheck.m ├── qpPFNormal.m ├── qpPFSTCSF.m ├── qpPFUTM.m ├── qpPFWeibull.m ├── qpPFWeibullInv.m ├── qpPFWeibullLog.m ├── qpPFWeibullLogInv.m ├── qpPF_GuessLapseParameterization.docx ├── qpPF_GuessLapseParameterization.pdf └── qpSimulatedObserver.m ├── questplus ├── Contents.m ├── qpFit.m ├── qpInitialize.m ├── qpMarginalizePosterior.m ├── qpParams.m ├── qpQuery.m ├── qpRun.m ├── qpUpdate.m └── qpUpdateExpectedNextEntropiesByStim.m └── utilities ├── Contents.m ├── qpArrayEntropy.m ├── qpDrawFromDomainList.m ├── qpFindNearestStimInDomain.m ├── qpFitError.m ├── qpGetBoundsFromDomainList.m ├── qpListMaxArg.m ├── qpListMinArg.m ├── qpLogLikelihood.m ├── qpNLogP.m ├── qpPosteriorMean.m ├── qpStimIndexToStim.m ├── qpStimToStimIndex.m ├── qpUniformArray.m └── qpUnitizeArray.m /.gitignore: -------------------------------------------------------------------------------- 1 | /mathematica 2 | /peterjonesversion 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus 2 | % 3 | % MATLAB implementation of Watson's QUEST+. 4 | % 5 | % The method and Mathematica code are described in the paper: Watson, A. B. 6 | % (2017). "QUEST+: A general multidimensional Bayesian adaptive 7 | % psychometric method". Journal of Vision, 17(3):10, 1-27, 8 | % http://jov.arvojournals.org/article.aspx?articleid=2611972. 9 | % 10 | % See README.md in the mQUESTPlus repository root directory for more info 11 | % (or read it on github at https://github.com/BrainardLab/mQUESTPlus). 12 | % 13 | % A good way to get started with mQUESTPlus is with the demos. See 14 | % "help mQUESTPlus/demos" for a complete list. Two key demos are: 15 | % 16 | % 1) qpQuestPlusPaperSimpleExampleDemo. This runs several of the basic 17 | % demonstrations from the Watson (2017) QUEST+ paper, showing usage for 18 | % function qpRun. 19 | % 20 | % 2) qpQuestPlusCoreFunctionDemo. This illustrates how you can call the 21 | % core functions of mQUESTPlus directly, rather than letting qpRun 22 | % orchestrate your experiment. 23 | % 24 | % contributed - Software (mostly demos) contributed by users 25 | % dataproc - Functions for data manipulation 26 | % demos - Demonstration/test programs. 27 | % mathworkscentral - Dependencies obtained from mathworks 28 | % central and other internet sources. 29 | % printplot - Support for printing and plotting. 30 | % psifunctions - Psychometric functions. 31 | % questplus - The core QUEST+ routines. 32 | % utilities - Support utilities. 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 David Brainard. 4 | 5 | If you make use of the software in support of a publication, please cite it 6 | (in addition to the Watson paper) as: Brainard, D. H. (2017) "mQUESTPlus: A 7 | Matlab implementation of QUEST+", https://github.com/brainardlab/mQUESTPlus. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mQUESTPlus 2 | 3 | ## Introduction 4 | 5 | mQUESTPlus is a MATLAB implementation of Watson's QUEST+. 6 | 7 | The method and Mathematica code are described in the paper: 8 | Watson, A. B. (2017). "QUEST+: A general multidimensional Bayesian adaptive psychometric method". 9 | Journal of Vision, 17(3):10, 1-27. 10 | 11 | This implementation follows quite closely the division into separate functions described in the paper 12 | and illustrated in the Mathematica notebook. The place to start is probably the demonstration/test 13 | progams in the demos directory. These reprise a number of figures in the Watson paper as well as 14 | calculations illustrated by the Mathematica notebook that accompanies the paper. 15 | 16 | One nice thing done in the Mathematica implementation that is not done here (sorry, life is short) is 17 | a set of plotting routines. Adding these would be a nice enhancement. 18 | 19 | The demo program qpQuestPlusPaperSimpleExampleDemos uses high-level function qpRun to reprise a number of 20 | figures in the QUEST+ paper. This would be a good thing to try just to make sure your installation 21 | is working properly and to convince yourself that this implementation reproduces basic properties 22 | of QUEST+. 23 | 24 | The demo program qpQuestPlusCoreFunctionDemo is a good place to look if you want to see how QUEST+'s 25 | core functions (qpParams, qpInitialize, qpQuery, qpUpdate, qpFit) might be used in a custom psychophysical 26 | experimental program, if you did not want to simply let qpRun orchestrate things for you. This demo is 27 | very short and will give a sense of how simple it is to use QUEST+. More lines of code are devoted 28 | to printing output and plotting than to the actual interface with QUEST+. 29 | 30 | The paper describes two high level functions to access the core routines, QuestPlus and QpRun. The latter 31 | provides an illustration of how to customize the use of the core routines. Here there is no qpQuestPlus function, 32 | just a qpRun. The qpRun function, however, behaves the way the qpQuestPlus function would, so it serves both 33 | purposes. Use of the qpRun function is illustrated in demos qpQuestPlusPaperSimpleExamplesDemo, 34 | qpQuestPlusCSFDemo, and qpQuestPlusCircularCatDemo. 35 | 36 | Use "help mQUESTPlus" at the Matlab prompt to access the contents of the package, and 37 | then from you can click through to the routines in each subdirectory. 38 | 39 | Similarly, "doc mQUESTPlus" at the Matlab prompt will bring up the Matlab documentation viewer and 40 | then you can click within that to explore the toolbox. 41 | 42 | For each individual function, using "help qpRoutineName" or "doc qpRoutineName" will provide usage for that routine. 43 | 44 | Note that the current implementation only allows specification of a uniform prior over the gridded parameters, and the current 45 | qpFit only provides a maximum likelihood fit. Extending to a user specified prior would not be difficult. See issue 46 | #2. 47 | 48 | ## Dependencies 49 | 50 | This implementation depends on the following Matlab toolboxes: 51 | optimization -- used for fmincon numerical optimization routine in qpFit. 52 | stats -- used for computing things related to probability distributions. 53 | The dependence on the stats toolbox could probably be worked around fairly easily. 54 | 55 | ## Installation 56 | 57 | If you use our ToolboxToolbox (highly recommended, https://github.com/toolboxhub/ToolboxToolbox), simply type 58 | "tbUse('mQUESTPlus')" at the Matlab prompt to obtain mQUESTPlus and put it onto your Matlab path. 59 | 60 | Alternately, you can obtain mQUESTPlus from https://github.com/brainardlab/mQUESTPlus, either by cloning the 61 | repository or by downloading and unpacking a zip file. Then add it to your Matlab path. 62 | 63 | ## Known issues and bugs 64 | 65 | See issues section of this (https://github.com/brainardlab/mQUESTPlus) gitHub site for a list known issues, 66 | limitations and possible future enhancements. Please post an issue if you encounter a bug or wish to make 67 | a suggestion. We are happy to review and incorporate improvements and enhancements, either via gitHub pull 68 | requests or if you just let us know via the issues or email (brainard@psych.upenn.edu). 69 | 70 | ## License 71 | 72 | mQUESTPlus is released under the MIT open source license. 73 | 74 | If you make use of the software in support of a publication, please cite it 75 | (in addition to the Watson paper) as: Brainard, D. H. (2017) "mQUESTPlus: A 76 | Matlab implementation of QUEST+", https://github.com/brainardlab/mQUESTPlus. 77 | 78 | ## Acknowledgments 79 | 80 | This implementation is due to David Brainard, (c) 2017 David Brainard. 81 | 82 | Sources include the paper above and the Mathematica notebook provided as supplemental material for the paper 83 | (an updated version of which was provided to me by Watson). 84 | 85 | There is separate earlier Matlab implementation of QUEST+ written by P. R. Jones (petejonze@gmail.com) 86 | from Joshua Solomon's laboratory (http://www.city.ac.uk/people/academics/joshua-solomon). The Jones implementation 87 | is organized differently from this one. Studying it was useful, however, for thinking about ways to translate Mathematica 88 | data structures into Matlab. The one place where there was fairly direct carryover in data structure 89 | format is noted specifically by a comment in the code. 90 | 91 | Denis Pelli provided useful feedback on the implementation and documentation. 92 | 93 | mQUESTPlus includes allcomb.m by Jos van der Geest, obtained from Matlab Central, along with its license. 94 | https://www.mathworks.com/matlabcentral/fileexchange/10064-allcomb-varargin- 95 | 96 | mQUESTPlus includes von_mises_cdf by Geoffrey Hill, MATLAB translation by John Burkardt. 97 | This is released under the the GNU LGPL license, according to its header comments. 98 | https://people.sc.fsu.edu/~jburkardt/m_src/prob/von_mises_cdf.m 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /contributed/qpNormCdfFromScratchDemo.m: -------------------------------------------------------------------------------- 1 | % qpNormCdfFromScratchDemo Demonstrates basic use of the QUEST procedure 2 | % 3 | % Description: 4 | % This script illustrates the Quest procedure using a simple example - 5 | % finding the mean of a cumulative Normal distribution. This does not 6 | % actually use the mQUESTPlus toolbox; rather it is meant to illustrate 7 | % the method from scratch. To that end, all core functionality is 8 | % implemented in the script itself rather than external functions. The 9 | % code is intended for demonstration purposes, and it is not optimized 10 | % for speed. It follows the mQuestPlus notes from dhb, and in certain 11 | % places borrows from the mQUESTPlus implementation. 12 | % 13 | % This is set up so that it does the same thing as 14 | % qpQuestPlusNormCdfDemo, but that program uses mQUESTPlus. 15 | % 16 | % See also: 17 | % qpQuestPlusNormCdfDemo 18 | 19 | % History: 20 | % 09/29/20 dce - Wrote it, based on the mQUESTPlus code 21 | % 10/06/20 dhb - Debugging and comments. 22 | 23 | % Clear and close 24 | clear; close all; 25 | 26 | % Define initial values 27 | cVec = linspace(0,1,100); % Possible values for stimulus intensity, ranging from 0 to 1 28 | muVec = linspace(0,1,100); % Possible parameter values for the mean 29 | sigma = 0.1; % Known standard deviation 30 | 31 | % Define the actual mean and the resulting cumulative Normal distribution 32 | <<<<<<< Updated upstream 33 | true_mu = 0.2; 34 | true_pdf = normcdf(cVec,true_mu,sigma); 35 | ======= 36 | true_mu = 0.4; 37 | true_pdf = guessRate + (1-guessRate)*normcdf(cVec,true_mu,sigma); 38 | >>>>>>> Stashed changes 39 | 40 | % Define the prior. The first trial uses the uniform prior p_mu, but 41 | % this is updated for later trials. 42 | prior = unifpdf(muVec,muVec(1),muVec(end))*1/100; % Uniform prior for mu 43 | if (abs(sum(prior)-1) > 1e-8) 44 | error('Initial prior does not sum to 1'); 45 | end 46 | 47 | % % Compute the probabilities by outcome for each c-mu combination: 48 | % % p (r |c_i,mu_j). The third dimension of arrays follow the convention 49 | % % [P(false), P(true)]. 50 | outcomeProbs = zeros(length(cVec),length(muVec),2); 51 | for i = 1:length(cVec) 52 | % Calculate probabilities for a given value of c at each value of mu 53 | pCorrect = normcdf(cVec(i),muVec,sigma); 54 | outcomeProbs(i,:,1) = 1-pCorrect; % P(incorrect) 55 | outcomeProbs(i,:,2) = pCorrect; % P(correct) 56 | end 57 | 58 | % Initialize arrays for storing data. 59 | nTrials = 64; % Number of trials 60 | trials = zeros(nTrials,2); % Trial data with format [c, outcome] 61 | posteriorsByOutcome = zeros(length(cVec),length(muVec),2); 62 | expected_entropies = zeros(length(cVec),1); 63 | 64 | % Loop through trials 65 | outcomeProbsByC = zeros(length(muVec),2); 66 | for kk = 1:nTrials 67 | % Marginalize the outcome probabilities over mu using prior, to get the 68 | % outcome probabilities for a given stimulus value c_i. 69 | for i = 1:length(cVec) 70 | outcomeProbsByC(i,:) = sum(squeeze(outcomeProbs(i,:,:).*prior))'; 71 | end 72 | if (any(abs(sum(outcomeProbsByC,2)-1) > 1e-8)) 73 | error('Outcome probabilties do not sum to 1 for some contrast'); 74 | end 75 | 76 | % Compute posteriors by outcome for each c-mu combination: p(mu_j|c_i,r). 77 | % Values of c are in the row index, values of mu are in the column 78 | % index, and the third dimension indexes outcomes 79 | % [P(false),P(true)]. 80 | for i = 1:length(cVec) 81 | for j = 1:length(muVec) 82 | for k = 1:2 83 | posteriorsByOutcome(i,j,k) = (outcomeProbs(i,j,k) .* prior(j)) ... 84 | ./ sum(outcomeProbs(i,:,k) .* prior); 85 | end 86 | end 87 | end 88 | for i = 1:length(cVec) 89 | for k = 1:2 90 | if (any(abs(sum(squeeze(posteriorsByOutcome(i,:,k)))-1) > 1e-8)) 91 | error('Table of posteriors does not sum to 1 for some contrast/outcome'); 92 | end 93 | end 94 | end 95 | 96 | % Compute expected entropies for each stimulus. 97 | % 98 | % e_false is the entropy for an incorrect response, e_correct is the entropy 99 | % for a correct response, and expected_entropy is an average of the two 100 | % weighted by their probabilities. 101 | for i = 1:length(cVec) 102 | e_incorrect = -nansum(posteriorsByOutcome(i,:,1).*log2(posteriorsByOutcome(i,:,1))); 103 | e_correct = -nansum(posteriorsByOutcome(i,:,2).*log2(posteriorsByOutcome(i,:,2))); 104 | <<<<<<< Updated upstream 105 | ======= 106 | 107 | >>>>>>> Stashed changes 108 | expected_entropies(i) = e_incorrect*outcomeProbsByC(i,1)+e_correct*outcomeProbsByC(i,2); 109 | end 110 | 111 | % Select the value of c which leads to the minimum expected entropy. 112 | % This will be the stimulus value for the next trial. 113 | [~,minCInd] = min(expected_entropies); 114 | 115 | % Using the chosen value of c, simulate a trial by sampling from a 116 | % multinomial distribution with outcome proportions [P(false), P(true)] 117 | pCorrect = normcdf(cVec(minCInd),true_mu,sigma); 118 | trialOutcomeProportions = [1 - pCorrect, pCorrect]; 119 | outcomeVector = mnrnd(1,trialOutcomeProportions); 120 | outcome = find(outcomeVector); 121 | 122 | % Now that the outcome has been determined, we know which of the 123 | % posteriors by outcome represents the actual posterior. This posterior 124 | % is used as the prior in subsequent trials. 125 | posterior = posteriorsByOutcome(minCInd,:,outcome); 126 | prior = posterior; 127 | trials(kk,:) = [cVec(minCInd),outcome]; % Update trials data 128 | 129 | % Check that posterior/new prior sums to 1 130 | if (abs(sum(posterior)-1) > 1e-8) 131 | error('Posterior does not sum to 1'); 132 | end 133 | end 134 | 135 | % Once all trials are completed, estimate the threshold as the maximum of 136 | % the posterior. 137 | [~,threshInd] = max(posterior); 138 | estThresh = muVec(threshInd); 139 | 140 | % Plot results. Trials are shown as open circles, with x positions 141 | % representing the value of c. Their y positions are either 0 (false) or 1 142 | % (true). 143 | figure; 144 | hold on; 145 | plot(cVec,true_pdf); 146 | plot(trials(:,1), trials(:,2)-1, 'o '); 147 | plot(true_mu,0.5,'m*'); 148 | plot(estThresh,0.5,'g*'); 149 | legend('True pdf','Trials','True Mean','Estimated Mean'); 150 | title('Quest Trial Sequence'); 151 | xlabel('Stimulus Value'); 152 | ylabel('Probability'); -------------------------------------------------------------------------------- /contributed/qpPFRating.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFRating(stimParams,psiParams,varargin) 2 | % qpPFRating Psychometric function for categorization/rating tasks 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFRating(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for categorization. 9 | % 10 | % This code parameterizes the boundaries as boundaries, rather 11 | % than first boundary and widths as in Mathematica code. 12 | % 13 | % Inputs: 14 | % stimParams Matrix, with each row being a vector of stimulus parameters. 15 | % Here that "row vector" is just a single number giving 16 | % the stimulus level 17 | % 18 | % psiParams Row vector of parameters. Each row has. 19 | % concentration Mean of normal 20 | % boundaries N-1 angles giving category boundaries in radians. 21 | % Reponse 1 corresponds to boundary1 <= stim < boundary2. 22 | % Response N corresponds to boundaryN-1 <= stim < boundary1, 23 | % Boundaries must be in increasing order. Otherwise this 24 | % routine returns a vector of NaN. 25 | % 26 | % Output: 27 | % predictedProportions Matrix, where each row is a vector of predicted proportions 28 | % for each outcome. 29 | % First entry of each row is for first category (outcome == 1) 30 | % Second entry of each row is second category (outcome == 2) 31 | % Nth entry is for nth category (outcome -- n) 32 | 33 | % 08/17/18 mna Wrote it. Heavily inspired by qpPFCircular function. 34 | 35 | %% Parse input 36 | % 37 | % This routine gets called many many times and should be as fast as 38 | % possible. The input parser is slow. So we forego arg checking and 39 | % optional key/value pairs. The code below shows how they would look. 40 | % 41 | % p = inputParser; 42 | % p.addRequired('stimParams',@isnumeric); 43 | % p.addRequired('psiParams',@isnumeric); 44 | % p.parse(stimParams,psiParams,varargin{:}); 45 | 46 | %% Here is the Matlab version 47 | if (size(psiParams,1) ~= 1) 48 | error('Expected a row vector of parameters'); 49 | end 50 | if (size(psiParams,2) < 2) 51 | error('Parameters vector has wrong length for qpPFRating'); 52 | end 53 | if (size(stimParams,2) ~= 1) 54 | error('Each row of stimParams should have only one entry'); 55 | end 56 | 57 | 58 | %% Grab params 59 | sd = psiParams(:,1); 60 | nStim = size(stimParams,1); 61 | nOutcomes = length(psiParams); 62 | 63 | %% Check whether boundary parameters are OK 64 | % 65 | paramsOK = qpPFRatingParamsCheck(psiParams); 66 | if (~paramsOK) 67 | predictedProportions = NaN*ones(nStim,nOutcomes); 68 | return; 69 | end 70 | 71 | % Get boundaries that are guaranteed to be increasing order because the 72 | % check above passed. 73 | boundaries = psiParams(2:end); 74 | 75 | 76 | %% Compute 77 | % 78 | predictedProportions = zeros(nStim,nOutcomes); 79 | predictedProportions(:,1) = 1-myNormCdf(stimParams,boundaries(1),sd); 80 | predictedProportions(:,2) = myNormCdf(stimParams,boundaries(1),sd); 81 | if nOutcomes > 2 82 | for ii = 3:nOutcomes 83 | predictedProportions(:,ii) = myNormCdf(stimParams,boundaries(ii-1),sd); 84 | predictedProportions(:,ii-1) = predictedProportions(:,ii-1) - predictedProportions(:,ii); 85 | end 86 | end 87 | 88 | end 89 | 90 | 91 | 92 | function proportions = myNormCdf(stimParams,mu,sd,lapse) 93 | 94 | if nargin < 4 95 | lapse = 0; 96 | end 97 | 98 | % The use of erf is faster than normcdf, and gets a step towards not 99 | % needing the stats toolbox. 100 | nStim = length(stimParams); 101 | proportions = nan(nStim,1); 102 | if (length(mu) > 1) 103 | for ii = 1:nStim 104 | adjustedSd = sd(ii)*sqrt(2); 105 | p2 = lapse(ii) + (1-2*lapse(ii))*0.5*(1+erf((stimParams(ii)-mu(ii))/adjustedSd)); 106 | proportions(ii) = p2; 107 | end 108 | else 109 | adjustedSd = sd*sqrt(2); 110 | for ii = 1:nStim 111 | p2 = lapse + (1-2*lapse)*0.5*(1+erf((stimParams(ii)-mu)/adjustedSd)); 112 | proportions(ii) = p2; 113 | end 114 | end 115 | 116 | end 117 | -------------------------------------------------------------------------------- /contributed/qpPFRatingParamsCheck.m: -------------------------------------------------------------------------------- 1 | function paramsOK = qpPFRatingParamsCheck(psiParams) 2 | % qpPFRatingParamsCheck Parameter check for qpPFRating 3 | % 4 | % Usage: 5 | % paramsOK = qpPFRatingParamsCheck(psiParams) 6 | % 7 | % Description: 8 | % Check whether passed parameters are valid for qpPFRating 9 | % 10 | % Inputs: 11 | % psiParams See qpPFRating. 12 | % 13 | % Output: 14 | % paramsOK Boolean, true if parameters are OK and false otherwise. 15 | 16 | % 08/18/18 mna Wrote it. 17 | 18 | %% Assume ok 19 | paramsOK = true; 20 | 21 | %% Check that sd is non-negative 22 | if (psiParams(1) < 0) 23 | paramsOK = false; 24 | end 25 | 26 | %% Check whether boundary parameters are OK 27 | % 28 | % This is signaled by returning NaN when the boundaries are 29 | % not in increasing order. 30 | [boundaries,sortIndex] = sort(psiParams(2:end),'ascend'); 31 | nOutcomes = length(boundaries); 32 | if (any(sortIndex ~= 1:nOutcomes)) 33 | paramsOK = false; 34 | end 35 | 36 | -------------------------------------------------------------------------------- /contributed/qpPFRatingParamsCheck2.m: -------------------------------------------------------------------------------- 1 | function paramsOK = qpPFRatingParamsCheck2(psiParams) 2 | % qpPFRatingParamsCheck2 Parameter check for qpPFRating 3 | % 4 | % Usage: 5 | % paramsOK = qpPFRatingParamsCheck(psiParams) 6 | % 7 | % Description: 8 | % Check whether passed parameters are valid for qpPFRating 9 | % 10 | % Inputs: 11 | % psiParams See qpPFRating. 12 | % 13 | % Output: 14 | % paramsOK Boolean, true if parameters are OK and false otherwise. 15 | 16 | % 08/18/18 mna Wrote it. 17 | 18 | %% Assume ok 19 | paramsOK = true; 20 | 21 | %% Check that sd is non-negative 22 | if any(psiParams(1:3) < 0) 23 | paramsOK = false; 24 | end 25 | 26 | %% Check whether boundary parameters are OK 27 | % 28 | % This is signaled by returning NaN when the boundaries are 29 | % not in increasing order. 30 | [boundaries,sortIndex] = sort(psiParams(4:end),'ascend'); 31 | nOutcomes = length(boundaries); 32 | if (any(sortIndex ~= 1:nOutcomes)) 33 | paramsOK = false; 34 | end 35 | 36 | -------------------------------------------------------------------------------- /contributed/qpPFStandardWeibull.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFStandardWeibull(stimParams,psiParams) 2 | %qpPFStandardWeibull Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFStandardWeibull(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for the Weibull psychometric 9 | % function. 10 | % 11 | % See docuent qpPF_GuessLapseParameterization.pdf for a discussion of 12 | % various ways to parameterize the lapse rate and how to convert 13 | % between them. In particular, that document describes the 14 | % parameterization used in this function. 15 | % 16 | % Note: This version of the PF function doesn't require the stimulus, 17 | % nor the threshold, to be expressed in dB or log10 units. 18 | % 19 | % Input: 20 | % stimParams Matrix, with each row being a vector of stimulus parameters. 21 | % Here the row vector is just a single number giving 22 | % the linear stimulus contrast level, or other stimulus parameter 23 | % in linear units rather than dB or log10 units. 24 | % 25 | % psiParams Row vector or matrix of parameters 26 | % threshold Threshold in linear units of stimulus 27 | % slope Slope 28 | % guess Guess rate 29 | % lapse Lapse rate 30 | % Parameterization matches the Mathematica code from the 31 | % Watson QUEST+ paper. If this is passed as a matrix, 32 | % must have same number of rows as stimParams and the 33 | % parameters are used from corresponding rows. If it is 34 | % passed as a row vector, that vector is taken as the 35 | % parameters for each stimulus row. 36 | % 37 | % Output: 38 | % predictedProportions Matrix, where each row is a vector of predicted proportions 39 | % for each outcome. 40 | % First entry of each row is for no/incorrect (outcome == 1) 41 | % Second entry of each row is for yes/correct (outcome == 2) 42 | % 43 | % Optional key/value pairs 44 | % None 45 | % 46 | % See also: qpStandardWeibullInv, qpPFWeibull, qpPFWeibullInv, 47 | % qpPFWeibullLog, qpPFWeibullLogInv. 48 | 49 | % 4/1/19 aer Wrote it. 50 | 51 | %% Parse input 52 | % 53 | % This routine gets called many many times and should be as fast as 54 | % possible. The input parser is slow. So we forego arg checking and 55 | % optional key/value pairs. The code below shows how they would look. 56 | % 57 | % p = inputParser; 58 | % p.addRequired('stimParams',@isnumeric); 59 | % p.addRequired('psiParams',@isnumeric); 60 | % p.parse(stimParams,psiParams,varargin{:}); 61 | 62 | %% Here is the Matlab version 63 | if (size(psiParams,2) ~= 4) 64 | error('Parameters vector has wrong length for qpPFStandardWeibull'); 65 | end 66 | if (size(stimParams,2) ~= 1) 67 | error('Each row of stimParams should have only one entry'); 68 | end 69 | threshold = psiParams(:,1); 70 | slope = psiParams(:,2); 71 | guess = psiParams(:,3); 72 | lapse = psiParams(:,4); 73 | nStim = size(stimParams,1); 74 | predictedProportions = zeros(nStim,2); 75 | 76 | %% Compute, handling the two calling cases. 77 | if (length(threshold) > 1) 78 | if (length(threshold) ~= nStim ) 79 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 80 | end 81 | 82 | for ii = 1:nStim 83 | p_success = guess(ii) + (1 - guess(ii) - lapse(ii))*(1-exp(-(stimParams(ii) / threshold(ii))^slope(ii))); 84 | predictedProportions(ii,:) = [1-p_success p_success]; 85 | end 86 | else 87 | for ii = 1:nStim 88 | p_success = guess + (1 - guess - lapse)*(1-exp(-(stimParams(ii) / threshold)^slope)); 89 | predictedProportions(ii,:) = [1-p_success p_success]; 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /contributed/qpPFStandardWeibullInv.m: -------------------------------------------------------------------------------- 1 | function stimVal = qpPFStandardWeibullInv(proportionCorrect,psiParams) 2 | %qpPFStandardWeibullInv Inverse Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % stimVal = qpPFStandardWeibullInv(proportionCorrect,psiParams) 6 | % 7 | % Description: 8 | % Compute the stimulus values that lead to the desired proportion 9 | % correct. 10 | % 11 | % Note: 12 | % This function doesn't use dB or log10 units for stimulus nor for 13 | % threshold. 14 | % 15 | % Input: 16 | % proportionCorrect Column vector, with each row being a proportion correct. 17 | % 18 | % psiParams Row vector or matrix of parameters 19 | % threshold Threshold 20 | % slope Slope 21 | % guess Guess rate 22 | % lapse Lapse rate 23 | % Parameterization matches the Mathematica code from the 24 | % Watson QUEST+ paper. If this is passed as a matrix, 25 | % must have same number of rows as proportionCorrect and the 26 | % parameters are used from corresponding rows. If it is 27 | % passed as a row vector, that vector is taken as the 28 | % parameters for each stimulus row. 29 | % 30 | % Output: 31 | % stimVal Vector of stimulus values in linear units rather than dB or log10. 32 | % 33 | % Optional key/value pairs 34 | % None 35 | % 36 | % See also: qpPFStandardWeibull, qpPFWeibull, qpPFWeibullInv 37 | % qpPFWeibullLog, qpPFWeibullLogInv. 38 | 39 | % 4/1/19 aer Wrote it. 40 | 41 | %% Here is the Matlab version 42 | if (size(psiParams,2) ~= 4) 43 | error('Parameters vector has wrong length for qpPFStandardWeibullInv'); 44 | end 45 | if (size(proportionCorrect,2) ~= 1) 46 | error('Each row of stimParams should have only one entry'); 47 | end 48 | threshold = psiParams(:,1); 49 | slope = psiParams(:,2); 50 | guess = psiParams(:,3); 51 | lapse = psiParams(:,4); 52 | nStim = size(proportionCorrect,1); 53 | stimVal = zeros(nStim,1); 54 | 55 | %% Compute, handling the two calling cases. 56 | if (length(threshold) > 1) 57 | if (length(threshold) ~= nStim ) 58 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 59 | end 60 | 61 | for ii = 1:nStim 62 | p1 = 1-proportionCorrect(ii); 63 | stimVal(ii) = threshold(ii)*(log((1-lapse(ii)-guess(ii))/(p1-lapse(ii))))^(1/slope(ii)); 64 | end 65 | else 66 | for ii = 1:nStim 67 | % This is the forward calculation from qpPFStandardWeibull: 68 | p1 = 1-proportionCorrect(ii); 69 | stimVal(ii) = threshold*(log((1-lapse-guess)/(p1-lapse)))^(1/slope); 70 | end 71 | end -------------------------------------------------------------------------------- /contributed/qpPairwiseComparisonDemo.m: -------------------------------------------------------------------------------- 1 | %qpPairwiseComparisonDemo Demonstrate/test QUEST+ at work on pairwise comparison 2 | % 3 | % Description: 4 | % Demonstrates the use of QUEST+ in a pairwise comparison experiment. Uses Thurstone Scaling, 5 | % explained in Watson 2017. 6 | % 7 | 8 | % xx/xx/18 mna wrote it. 9 | 10 | %% Initialize 11 | clearvars; 12 | close all 13 | clc; 14 | 15 | % Simulated parameters 16 | simulatedPsiParams = [-3.9 2.7 .01]; 17 | stimParams = linspace(1,5,15)'; 18 | numberOfTrials = 128; 19 | 20 | theta = @(x,threshold, slope) max(1,slope*(-x-threshold)); 21 | myFun = @(stimPair,psiParams) ... 22 | qpPFNormal(abs(theta(stimPair(:,1),psiParams(1), psiParams(2)) - ... 23 | theta(stimPair(:,2),psiParams(1), psiParams(2)))/sqrt(2),... 24 | [0,1,psiParams(3)]); 25 | 26 | N = 1; 27 | psiParamsFit = nan(N,length(simulatedPsiParams)); 28 | for i=1:N 29 | questData = qpRun(numberOfTrials, ... 30 | 'stimParamsDomainList',{stimParams stimParams}, ... 31 | 'psiParamsDomainList',{-(0:.2:4.8), 1:.5:5, 0:.01:0.02}, ... 32 | 'qpPF',myFun, ... 33 | 'filterStimParamsDomainFun',@qpPairwiseStimCheck, ... 34 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,myFun,simulatedPsiParams), ... 35 | 'nOutcomes', 2, ... 36 | 'verbose',true); 37 | 38 | 39 | psiParamsIndex = qpListMaxArg(questData.posterior); 40 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 41 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.2f\n', ... 42 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3)); 43 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.2f\n', ... 44 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3)); 45 | 46 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 47 | % parameter for the search, and impose as parameter bounds the range 48 | % provided to QUEST+. 49 | psiParamsFit(i,:) = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,... 50 | questData.nOutcomes,'lowerBounds', [-5 0.5 0],'upperBounds',[0 10 0.03]); 51 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.2f\n', ... 52 | psiParamsFit(i,1),psiParamsFit(i,2),psiParamsFit(i,3)); 53 | 54 | end 55 | 56 | figure; 57 | yyaxis left 58 | histogram(psiParamsFit(:,1)); hold on; 59 | plot(simulatedPsiParams(1)*[1 1],[0 40],'--k','linewidth',2); 60 | title('threshold') 61 | set(gca,'fontsize',14); 62 | yyaxis right 63 | histogram(psiParamsFit(:,2));hold on; 64 | plot(simulatedPsiParams(2)*[1 1],[0 50],'--k','linewidth',2); 65 | title('slope') 66 | set(gca,'fontsize',14); 67 | 68 | % % plot results 69 | % figure; 70 | % plot(-stimParams,theta(stimParams,simulatedPsiParams(1),simulatedPsiParams(2)),'-o') 71 | 72 | %% 73 | figure; 74 | [x1,x2] = meshgrid(linspace(1,5,100)'); 75 | x = [x1(:) x2(:)]; 76 | y = myFun(x,simulatedPsiParams); 77 | 78 | surf(-x1,-x2,reshape(y(:,2),size(x1,1),size(x1,2)),... 79 | 'Edgecolor','none','facealpha',.5); hold on; 80 | view(2); 81 | xlabel('x1'); ylabel('x2'); zlabel('prop. correct') 82 | set(gca,'fontsize',14); 83 | colormap(repmat(linspace(0.5,1,100)',1,3)); 84 | grid off; 85 | 86 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 87 | stim = zeros(length(stimCounts),questData.nStimParams); 88 | for cc = 1:length(stimCounts) 89 | stim(cc,:) = stimCounts(cc).stim; 90 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 91 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 92 | end 93 | for cc = 1:length(stimCounts) 94 | h = scatter3(-stim(cc,1),-stim(cc,2),pCorrect(cc), 150,'o','MarkerEdgeColor',... 95 | [1-pCorrect(cc) 0 pCorrect(cc)],'MarkerFaceColor',[1-pCorrect(cc) 0 pCorrect(cc)],... 96 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 97 | end 98 | 99 | 100 | axes('Position',[.7 .2 .2 .2]); 101 | plot(-stimParams, ... 102 | theta(stimParams,... 103 | median(psiParamsFit(:,1)),median(psiParamsFit(:,2))),'-o') 104 | xlabel('-(log-field rate)'); 105 | ylabel('JND'); 106 | set(gca,'fontsize',11); 107 | grid on; 108 | 109 | 110 | -------------------------------------------------------------------------------- /contributed/qpPairwiseStimCheck.m: -------------------------------------------------------------------------------- 1 | function paramsOK = qpPairwiseStimCheck(stimParams) 2 | % qpPairwiseStimCheck stim params check 3 | % 4 | % Usage: 5 | % paramsOK = qpPairwiseStimCheck(psiParams) 6 | % 7 | % Description: 8 | % Check whether passed parameters are valid for qpPFRating 9 | % 10 | % Inputs: 11 | % psiParams See qpPFRating. 12 | % 13 | % Output: 14 | % paramsOK Boolean, true if parameters are OK and false otherwise. 15 | 16 | % 02/07/18 mna Wrote it. 17 | 18 | %% Check that x1 is larger than x2 19 | paramsOK = (diff(stimParams,1,2) < 0); 20 | 21 | -------------------------------------------------------------------------------- /contributed/qpQuestPlusRatingDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusRatingDemo 2 | %qpQuestPlusRatingDemo Demonstrate/test QUEST+ at work on categorization variable. 3 | % 4 | % Description: 5 | % This script shows QUEST+ employed to estimate categorization 6 | % boundaries and precision on a categorical variable. 7 | % 8 | 9 | % 08/18/18 mna wrote it. 10 | 11 | %% Close out stray figures 12 | close all; 13 | clc; 14 | 15 | %% qpRun estimating categorization parameters. 16 | % 17 | % This code parameterizes the boundaries as boundaries, rather 18 | % than first boundary and widths as in Mathematica code. 19 | % 20 | % This reprises Figure 16 of the 2017 QUEST+ paper. 21 | fprintf('*** qpRun, Estimate categorization parameters:\n'); 22 | % rng('default'); rng(3010,'twister'); 23 | simulatedPsiParams = [.6 2.3 4.7]; 24 | stimParams = linspace(0,8,20)'; 25 | questData = qpRun(64, ... 26 | 'stimParamsDomainList',{stimParams}, ... 27 | 'psiParamsDomainList',{0.2:0.2:2, 0:.25:6 0:.25:6}, ... 28 | 'qpPF',@qpPFRating, ... 29 | 'filterPsiParamsDomainFun',@qpPFRatingParamsCheck, ... 30 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFRating,simulatedPsiParams), ... 31 | 'nOutcomes', length(simulatedPsiParams), ... 32 | 'verbose',true); 33 | 34 | psiParamsIndex = qpListMaxArg(questData.posterior); 35 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 36 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f\n', ... 37 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3)); 38 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f\n', ... 39 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3)); 40 | 41 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 42 | % parameter for the search, and impose as parameter bounds the range 43 | % provided to QUEST+. 44 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 45 | 'lowerBounds', [0 0 0],'upperBounds',[2 10 10]); 46 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f\n', ... 47 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3)); 48 | 49 | % Plot trial locations together with maximum likelihood fit. 50 | % 51 | % Point transparancy visualizes number of trials (more opaque -> more 52 | % trials), while point color visualizes dominant response. The proportion plotted 53 | % for each angle is the proportion of the dominant response. This isn't as fancy 54 | % as the Mathematica plot showin in Figure 17 of the paper, but conveys the same 55 | % general idea of what happened. 56 | figure; clf; hold on 57 | set(gca,'Color',[1 1 1]*.6); 58 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 59 | stimProportions = qpProportions(stimCounts,questData.nOutcomes); 60 | stim = zeros(length(stimCounts),questData.nStimParams); 61 | for cc = 1:length(stimCounts) 62 | stim(cc,:) = stimCounts(cc).stim; 63 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 64 | end 65 | cols = [240 133 125; 255 251 139; 149 246 136]/255; 66 | for cc = 1:length(stimCounts) 67 | for jj = 1:questData.nOutcomes 68 | switch (jj) 69 | case 1 70 | theColor = cols(1,:); 71 | case 2 72 | theColor = cols(2,:); 73 | case 3 74 | theColor = cols(3,:); 75 | otherwise 76 | error('Oops'); 77 | end 78 | h = scatter(stim(cc),stimProportions(cc).outcomeProportions(jj),100,'o','MarkerEdgeColor',theColor,'MarkerFaceColor',theColor,... 79 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 80 | end 81 | end 82 | plotStimParams = linspace(0,max(stimParams),100)'; 83 | outcomeProportions = qpPFRating(plotStimParams,psiParamsFit); 84 | for jj = 2:length(psiParamsFit) 85 | plot([psiParamsFit(jj) psiParamsFit(jj)],[0 1],'k:','linewidth',2); 86 | end 87 | 88 | for i=1:size(outcomeProportions,2) 89 | plot(plotStimParams,outcomeProportions(:,i),'-','Color',cols(i,:),'LineWidth',3); 90 | end 91 | ylim([0 1]); 92 | xlabel('stim value'); 93 | ylabel('Proportion'); 94 | title({'Rating',''}); 95 | set(gca,'fontsize',14) 96 | drawnow; 97 | 98 | -------------------------------------------------------------------------------- /contributed/qpQuestPlusRatingDemo2.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusRatingDemo2 2 | %qpQuestPlusRatingDemo2 Demonstrate/test QUEST+ at work on categorization variable. 3 | % 4 | % Description: 5 | % This script shows QUEST+ employed to estimate categorization 6 | % boundaries and precision on a categorical variable. 7 | % 8 | % This version has two independent stimulus variables. 9 | % 10 | 11 | % 08/18/18 mna wrote it. 12 | 13 | %% Close out stray figures 14 | close all; 15 | clc; 16 | 17 | %% qpRun estimating categorization parameters. 18 | % 19 | % This code parameterizes the boundaries as boundaries, rather 20 | % than first boundary and widths as in Mathematica code. 21 | % 22 | % This reprises Figure 16 of the 2017 QUEST+ paper. 23 | fprintf('*** qpRun, Estimate categorization parameters:\n'); 24 | rng('default'); rng(400,'twister'); 25 | simulatedPsiParams = [1.8 3.6 0.8 1.2 2.5]; 26 | stimParams = linspace(0,8,20)'; 27 | 28 | % define the pscyhometric function here 29 | % 5-parameter model 30 | myFun = @(x,y) qpPFRating(((x(:,1)/y(1)).^y(2) + (x(:,2)).^y(2)).^(1/y(2)),y(3:end)); 31 | 32 | questData = qpRun(40, ... 33 | 'stimParamsDomainList',{stimParams stimParams}, ... 34 | 'psiParamsDomainList',{logspace(log10(.2),log10(10),10), 2:5, 0.5:1:3,... 35 | logspace(log10(.1),log10(10),10), ... 36 | logspace(log10(.1),log10(10),10)}, ... 37 | 'qpPF',myFun, ... 38 | 'filterPsiParamsDomainFun',@qpPFRatingParamsCheck2, ... 39 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,myFun,simulatedPsiParams), ... 40 | 'nOutcomes', length(simulatedPsiParams)-2, ... 41 | 'verbose',true); 42 | 43 | psiParamsIndex = qpListMaxArg(questData.posterior); 44 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 45 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.1f, %0.1f\n', ... 46 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),... 47 | simulatedPsiParams(4),simulatedPsiParams(5)); 48 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.1f, %0.1f\n', ... 49 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),... 50 | psiParamsQuest(4),psiParamsQuest(5)); 51 | 52 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 53 | % parameter for the search, and impose as parameter bounds the range 54 | % provided to QUEST+. 55 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 56 | 'lowerBounds', [.01 1.5 0.01 0 0],'upperBounds',[5 5 3 5 5]); 57 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f, %0.1f, %0.1f\n', ... 58 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3),... 59 | psiParamsFit(4),psiParamsFit(5)); 60 | 61 | % Plot trial locations together with maximum likelihood fit. 62 | % 63 | % Point transparancy visualizes number of trials (more opaque -> more 64 | % trials), while point color visualizes dominant response. The proportion plotted 65 | % for each angle is the proportion of the dominant response. This isn't as fancy 66 | % as the Mathematica plot showin in Figure 17 of the paper, but conveys the same 67 | % general idea of what happened. 68 | figure; clf; hold on 69 | set(gca,'Color',[1 1 1]*.6); 70 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 71 | stimProportions = qpProportions(stimCounts,questData.nOutcomes); 72 | stim = zeros(length(stimCounts),questData.nStimParams); 73 | 74 | for cc = 1:length(stimCounts) 75 | stim(cc,:) = stimCounts(cc).stim; 76 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 77 | end 78 | 79 | cols = [240 133 125; 255 251 139; 149 246 136]/255; 80 | for cc = 1:length(stimCounts) 81 | for jj = 1:questData.nOutcomes 82 | switch (jj) 83 | case 1 84 | dx = 0.05; 85 | dy = 0.05; 86 | case 2 87 | dx = -0.05; 88 | dy = 0.05; 89 | case 3 90 | dx = 0; 91 | dy = -0.05; 92 | otherwise 93 | error('Oops'); 94 | end 95 | theColor = cols(jj,:); 96 | h = scatter3(stim(cc,1)+dx,stim(cc,2)+dy,... 97 | stimProportions(cc).outcomeProportions(jj),150,... 98 | 'o','MarkerEdgeColor',theColor,'MarkerFaceColor',theColor,... 99 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),... 100 | 'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 101 | end 102 | end 103 | 104 | view(2); 105 | x = linspace(min(questData.stimParamsDomainList{1}),... 106 | max(questData.stimParamsDomainList{1}),50)'; 107 | y = linspace(min(questData.stimParamsDomainList{2}),... 108 | max(questData.stimParamsDomainList{2}),50)'; 109 | [x,y] = meshgrid(x,y); 110 | outcomeProportions = myFun([x(:) y(:)],psiParamsFit); 111 | 112 | for i=1:size(outcomeProportions,2) 113 | mh(i) = mesh(x,y,reshape(outcomeProportions(:,i),size(x,1),size(x,2)),... 114 | 'EdgeColor',cols(i,:)); 115 | end 116 | 117 | miX = min(x(:)); 118 | maX = max(x(:)); 119 | miY = min(y(:)); 120 | maY = max(y(:)); 121 | plot3([miX maX maX miX miX miX maX maX miX miX],... 122 | [miY miY maY maY miY miY miY maY maY miY],... 123 | [0 0 0 0 0 1 1 1 1 1],'-','linewidth',1,'Color',[1 1 1]*.5); 124 | plot3(maX*[1 1],miY*[1 1],[0 1],'-','linewidth',1,'Color',[1 1 1]*.5); 125 | plot3(maX*[1 1],maY*[1 1],[0 1],'-','linewidth',1,'Color',[1 1 1]*.5); 126 | plot3(miX*[1 1],maY*[1 1],[0 1],'-','linewidth',1,'Color',[1 1 1]*.5); 127 | xlim([miX maX]); 128 | ylim([miY maY]); 129 | zlabel('probability'); 130 | xlabel('Stim. value 1'); 131 | ylabel('Stim. value 2'); 132 | set(gca,'fontsize',14); 133 | drawnow; 134 | view([50 18]); 135 | % axis equal 136 | 137 | -------------------------------------------------------------------------------- /dataproc/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - dataproc 2 | % 3 | % Data processing utilities for mQUESTPlus 4 | % 5 | % qpCounts - Convert stimulus data array to stimulus count data array. 6 | % qpData - Convert trial data array to stimulus data array. 7 | % qpExampleData - Return some example data, as would be generated by qpRun. 8 | % qpProportions - Convert stim count data array to stim proportion data array. 9 | 10 | -------------------------------------------------------------------------------- /dataproc/qpCounts.m: -------------------------------------------------------------------------------- 1 | function stimCounts = qpCounts(stimData,nOutcomes,varargin) 2 | %qpCounts Convert stimulus data array to count data array. 3 | % 4 | % Usage: 5 | % stimCounts = qpCounts(stimData) 6 | % 7 | % Description: 8 | % Take an stim data array as produced by qpData and convert the list 9 | % of trial by trial outcomes to a list ofhow many times each possible 10 | % outcome was chosen, for each stimulus. 11 | % 12 | % Input: 13 | % stimData A struct array with each stimulus value presented 14 | % in sorted order, and a vector of outcomes that happened 15 | % on trials for that stimulus value: 16 | % stimData(i).stim - Row vector of stimulus parameters 17 | % stimData(i).outcomes - Row vector of outcomes on 18 | % each trial where the corresponding stimulus was 19 | % presented. 20 | % This is what qpData produces as output 21 | % 22 | % Output: 23 | % stimCounts A struct array with each stimulus value presented 24 | % in sorted order, and a vector of the counts of each possible 25 | % outcome type that happened on trials for that stimulus value: 26 | % stimCounts(i).stim - Row vector of stimulus parameters 27 | % stimCounts(i).outcomeCounts - Row vector of length 28 | % nOutcomes with the number of times each outcome 29 | % happend for the given stimulus. 30 | % 31 | % Optional key/value pairs 32 | % None 33 | 34 | % 6/26/17 dhb Wrote it. 35 | 36 | %% Parse input 37 | p = inputParser; 38 | p.addRequired('stimData',@isstruct); 39 | p.addRequired('nOutcomes',@isscalar); 40 | p.parse(stimData,nOutcomes,varargin{:}); 41 | 42 | %% Get number of stimuli 43 | nStimuli = length(stimData); 44 | 45 | %% Go through and build the stimulus data array 46 | for ii = 1:nStimuli 47 | stimCounts(ii).stim = stimData(ii).stim; 48 | for jj = 1:nOutcomes 49 | stimCounts(ii).outcomeCounts(jj) = length(find(stimData(ii).outcomes == jj)); 50 | end 51 | end 52 | stimCounts = stimCounts'; 53 | -------------------------------------------------------------------------------- /dataproc/qpData.m: -------------------------------------------------------------------------------- 1 | function stimData = qpData(trialData,varargin) 2 | %qpData Convert trial data array to stimulus data array. 3 | % 4 | % Usage: 5 | % stimData = qpData(trialData) 6 | % 7 | % Description: 8 | % Take an trial data array describing what happened on each trial and 9 | % convert to a stimulus data array describing what happened for each 10 | % unique stimulus. 11 | % 12 | % Input: 13 | % trialData A trial data struct array: 14 | % trialData(i).stim - Row vector of stimulus parameters. 15 | % trialData(i).outcome - Outcome of the trial. 16 | % 17 | % Output: 18 | % stimData A struct array with each stimulus value presented 19 | % in sorted order, and a vector of outcomes that happened 20 | % on trials for that stimulus value: 21 | % stimData(i).stim - Row vector of stimulus parameters 22 | % stimData(i).outcomes - Row vector of outcomes on 23 | % each trial where the corresponding stimulus was 24 | % presented. 25 | % 26 | % Optional key/value pairs 27 | % None 28 | 29 | % 6/23/17 dhb Wrote it. 30 | 31 | %% Parse input 32 | p = inputParser; 33 | p.addRequired('trialData',@isstruct); 34 | p.parse(trialData,varargin{:}); 35 | 36 | %% Get the unique stimulus vectors 37 | for jj = 1:size(trialData,1) 38 | stimulusVectors(jj,:) = trialData(jj).stim; 39 | end 40 | 41 | % We'll accept however unique sorts these vectors as sorted. 42 | uniqueStimulusVectors = unique(stimulusVectors,'rows'); 43 | 44 | %% Go through and build the stimulus data array 45 | for ii = 1:size(uniqueStimulusVectors,1) 46 | stimData(ii).stim = uniqueStimulusVectors(ii,:); 47 | stimData(ii).outcomes = []; 48 | for jj = 1:size(stimulusVectors,1) 49 | if all(stimulusVectors(jj,:) == uniqueStimulusVectors(ii,:)) 50 | stimData(ii).outcomes = [stimData(ii).outcomes trialData(jj).outcome]; 51 | end 52 | end 53 | end 54 | stimData = stimData'; 55 | -------------------------------------------------------------------------------- /dataproc/qpExampleData.m: -------------------------------------------------------------------------------- 1 | function exampleResult = qpExampleData(varargin) 2 | %qpExampleData Return some example data, as would be generated by qpQuestPlus 3 | % 4 | % Usage: 5 | % exampleResult = qpExampleData(varargin) 6 | % 7 | % Description: 8 | % To exercise the functions, it is useful to have a set of example data, as would be returned by qpQuestPlus. 9 | % We define the example data here. 10 | % 11 | % exampleResult.paramEstimates - Vector of parameter estimates. 12 | % exampleResult.nOutcomes - Number of possible outcomes. 13 | % exampleResult.trialData - Struct array, with each entry: 14 | % trialData(i).stim - Row vector of stimulus parameters. 15 | % trialData(i).outcome - Outcome of the trial. 16 | % 17 | % Input: 18 | % None 19 | % 20 | % Output: 21 | % exampleResult The example result. 22 | % 23 | % Optional key/value pairs 24 | % 'multipleStimParams' boolean (default false) - Example with multiple stimulus params 25 | 26 | % 6/23/17 dhb Wrote it. 27 | 28 | %% Parse input 29 | p = inputParser; 30 | p.addParameter('multipleStimParams',false,@islogical); 31 | p.parse(varargin{:}); 32 | 33 | % Data in list format, and provide nOutcomes field. 34 | if (p.Results.multipleStimParams) 35 | % This is just made up for now 36 | exampleResultFromMathematica = {[-20, 3.5, 0.5, 0.02], {{{-18 4 2}, 2}, {{-22 4 1}, 1}, {{-12 4 1}, 2}, ... 37 | {{-13 5 1}, 2}, {{-15 4 2},2}, ... 38 | {{-18 4 1}, 2}, {{-18 4 2}, 2}, {{-18 4 2}, 2}, {{-19 5 2}, 2}, {{-19 5 1}, 2}, {{-19 5 2}, 1}, ... 39 | {{-19 5 2}, 2}, {{-19 5 1}, 4}}}; 40 | exampleResult.nOutcomes = 4; 41 | else 42 | % Here is the example result from Mathematica, pulled over from the 43 | % notebook and tweaked just a little. 44 | exampleResultFromMathematica = {[-20, 3.5, 0.5, 0.02], {{{-18}, 2}, {{-22}, 1}, {{-12}, 2}, {{-13}, 2}, {{-15},2}, ... 45 | {{-16}, 2}, {{-17}, 2}, {{-18}, 2}, {{-19}, 2}, {{-20}, 2}, {{-22}, 2}, {{-25}, 1}, {{-19}, 1}, {{-16}, 2}, ... 46 | {{-17}, 2}, {{-18}, 2}, {{-18}, 2}, {{-18}, 1}, {{-16}, 2}, {{-17}, 2}, {{-17}, 2}, {{-17}, 2}, {{-18}, 2}, {{-18}, 2}, ... 47 | {{-18}, 2}, {{-18}, 2}, {{-18}, 2}, {{-19}, 2}, {{-19}, 2}, {{-19}, 2}, {{-19}, 2}, {{-19}, 2}}}; 48 | exampleResult.nOutcomes = 2; 49 | end 50 | 51 | % Parameter estimates 52 | exampleResult.paramEstimates = exampleResultFromMathematica{1}; 53 | 54 | %% Trial data 55 | for ii = 1:length(exampleResultFromMathematica{2}) 56 | exampleResult.trialData(ii).stim = [exampleResultFromMathematica{2}{ii}{1}{:}]; 57 | exampleResult.trialData(ii).outcome = [exampleResultFromMathematica{2}{ii}{2}]; 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /dataproc/qpProportions.m: -------------------------------------------------------------------------------- 1 | function stimProportions = qpProportions(stimCounts,nOutcomes,varargin) 2 | %qpProportions Convert stim count data array to stim proportion data array. 3 | % 4 | % Usage: 5 | % stimProportions = qpProportions(stimCounts) 6 | % 7 | % Description: 8 | % Take an stim counts array as produced by qpCountes and convert the list 9 | % of counts of each outcome for each stimulus to a list of proportions 10 | % for each outcome for each stimulus. 11 | % 12 | % Input: 13 | % stimCounts A struct array with each stimulus value presented 14 | % in sorted order, and a vector of the counts of each possible 15 | % outcome type that happened on trials for that stimulus value: 16 | % stimCounts(i).stim - Row vector of stimulus parameters 17 | % stimCounts(i).outcomeCounts - Row vector of length 18 | % nOutcomes with the number of times each outcome 19 | % happened for the given stimulus. 20 | % This is produced by qpCounts. 21 | % 22 | % Output: 23 | % stimProportions A struct array with each stimulus value presented 24 | % in sorted order, and a vector of the proportions of each possible 25 | % outcome type that happened on trials for that stimulus value: 26 | % stimCounts(i).stim - Row vector of stimulus parameters 27 | % stimCounts(i).outcomeProprotions - Row vector of length 28 | % nOutcomes with the proportion of times each outcome 29 | % happened for the given stimulus. Each such vector 30 | % sums to 1. 31 | % 32 | % Optional key/value pairs 33 | % None 34 | 35 | % 6/26/17 dhb Wrote it. 36 | 37 | %% Parse input 38 | p = inputParser; 39 | p.addRequired('stimCounts',@isstruct); 40 | p.addRequired('nOutcomes',@isscalar); 41 | p.parse(stimCounts,nOutcomes,varargin{:}); 42 | 43 | %% Get number of stimuli 44 | nStimuli = length(stimCounts); 45 | 46 | %% Go through and build the stimulus data array 47 | for ii = 1:nStimuli 48 | stimProportions(ii).stim = stimCounts(ii).stim; 49 | stimProportions(ii).outcomeProportions = stimCounts(ii).outcomeCounts/sum(stimCounts(ii).outcomeCounts); 50 | end 51 | stimProportions = stimProportions'; 52 | -------------------------------------------------------------------------------- /demos/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - demos 2 | % 3 | % Demonstration/test programs. Two key demos are 4 | % 1) qpQuestPlusPaperSimpleExampleDemo. This runs several of the basic 5 | % demonstrations from the Watson (2017) QUEST+ paper, showing usage for 6 | % function qpRun. 7 | % 8 | % 2) qpQuestPlusCoreFunctionDemo. This illustrates how you can call the 9 | % core functions of mQUESTPlus directly, rather than letting qpRun 10 | % orchestrate your experiment. 11 | % 12 | % Also see README.md in the mQUESTPlus repository root directory for more info 13 | % (or read it on github at https://github.com/BrainardLab/mQUESTPlus). 14 | % 15 | % qpDataProcDemo - Demonstrate/test the data processsing routines. 16 | % qpPsiFunctionDemo - Demonstrate/test the psychometric function routines. 17 | % qpQuestPlusCoreFunctionDemo - Show basic use of QUEST+ core functions, 18 | % called directly. This is the one to look 19 | % at if you don't want to hand off control 20 | % of your experiment to qpRun. This 21 | % illustrates usage for estimating a 22 | % Weibull psychometric function. 23 | % qpQuestPlusCSFDemo - Demonstrate/test QUEST+ at work on parametric 24 | % CSF estimation. 25 | % qpQuestPlusCircularCatDemo - Demonstrate/test QUEST+ at work on 26 | % categorization of a circular variable. 27 | % qpQuestPlusMarginalizeDemo - Show how to run version that marginalizes 28 | % over nuisance variables. 29 | % qpQuestPlusNormCdfDemo - Show how to use QUEST+ for a cumulative 30 | % normal psychometric function. Based pm 31 | % qpQuestPlustCoreFunctionDemo. 32 | % qpQuestPlusPaperSimpleExamplesDemo - Demonstrate/test QUEST+ on some 33 | % simple example problems. Illustrates 34 | % basic usage of function qpRun. 35 | % qpRunAllDemos - Run through all of the QUEST+ demos. 36 | % qpUtilityDemo - Demonstrate/test the qp utility routines. 37 | -------------------------------------------------------------------------------- /demos/qpDataProcDemo.m: -------------------------------------------------------------------------------- 1 | function qpDataProcDemo 2 | % qpDataProcDemo Demonstrate/test the data processsing routines. 3 | % 4 | % Description: 5 | % This script shows the usage for the qp data processing routines, and checks 6 | % some basic assertions about what they should do. Key routines 7 | % demonstrated and tested are: 8 | % qpData 9 | % qpCounts 10 | % qpProportions 11 | % 12 | % These make use of qpExampleData, which returns example datasets. 13 | % 14 | % These demos/tests do their best to follow the examples in the QuestPlus.nb 15 | % Mathematica notebook that accompanies the Watson (2017) paper. But, I 16 | % got a more recent version than that published with the paper, so there 17 | % will be some minor differences. 18 | 19 | % 6/24/17 dhb Created. 20 | 21 | %% Get example results 22 | fprintf('*** qpExampleData:\n'); 23 | exampleResult = qpExampleData; 24 | qpPrintParams(exampleResult.paramEstimates); 25 | qpPrintTrialData(exampleResult.trialData); 26 | 27 | fprintf('\n*** qpData:\n'); 28 | stimData = qpData([exampleResult.trialData(:)]); 29 | qpPrintStimData(stimData); 30 | 31 | fprintf('\n*** qpCounts:\n'); 32 | stimCounts = qpCounts(stimData,exampleResult.nOutcomes); 33 | qpPrintStimCounts(stimCounts); 34 | 35 | fprintf('\n*** qpProportions:\n'); 36 | stimProortions = qpProportions(stimCounts,exampleResult.nOutcomes); 37 | qpPrintStimProportions(stimProortions); 38 | 39 | fprintf('\n*** qpLogLikelihood:\n'); 40 | logLikelihood = qpLogLikelihood(stimCounts,@qpPFWeibull,[-20, 3, 0.5, 0.02]); 41 | logLikelihood = round(logLikelihood*100000)/100000 42 | assert(logLikelihood == -9.15883,'qpLogLikelihood: Did not get expected log likelihood'); 43 | 44 | %% An example with multiple stimulus parameters 45 | fprintf('\n*** qpExampleData (multiple stim params):\n'); 46 | exampleResult = qpExampleData('multipleStimParams',true); 47 | qpPrintParams(exampleResult.paramEstimates); 48 | qpPrintTrialData(exampleResult.trialData); 49 | 50 | fprintf('\n*** qpData (multiple stim params):\n'); 51 | stimData = qpData([exampleResult.trialData(:)]); 52 | qpPrintStimData(stimData); 53 | 54 | fprintf('\n*** qpCounts (multiple stim params):\n'); 55 | stimCounts = qpCounts(stimData,exampleResult.nOutcomes); 56 | qpPrintStimCounts(stimCounts); 57 | 58 | fprintf('\n*** qpProportions (multiple stim params):\n'); 59 | stimProortions = qpProportions(stimCounts,exampleResult.nOutcomes); 60 | qpPrintStimProportions(stimProortions); 61 | -------------------------------------------------------------------------------- /demos/qpPsiFunctionDemo.m: -------------------------------------------------------------------------------- 1 | function qpPsiFunctionDemo 2 | %qpPsiFunctionDemo Demonstrate/test the psychometric function routines 3 | % 4 | % Description: 5 | % This script shows the usage for the qp psychometric function routines, and checks 6 | % some basic assertions about what they should do. 7 | % 8 | % These do their best to follow the examples in the QuestPlus.nb 9 | % Mathematica notebook. 10 | 11 | % 6/27/17 dhb Created. 12 | 13 | %% Weibull PF 14 | fprintf('*** qpPFWeibull:\n'); 15 | stimParams = linspace(-9,9,100)'; 16 | outcomeProportions = qpPFWeibull(stimParams,[0 3 0.5 0.01]); 17 | figure; clf; hold on 18 | plot(stimParams,outcomeProportions(:,1),'-','Color',[0.8 0.6 0.0],'LineWidth',3); 19 | plot(stimParams,outcomeProportions(:,2),'-','Color',[0.0 0.0 0.8],'LineWidth',3); 20 | xlabel('Stimulus Value'); 21 | ylabel('Proportion'); 22 | xlim([-10 10]); ylim([0 1]); 23 | title({'qpPFWeibull' ; ''}); 24 | legend({'Outcome 1','Outcome 2'},'Location','NorthWest'); 25 | 26 | %% Normal PF 27 | fprintf('*** qpPFNormal:\n'); 28 | stimParams = linspace(-9,9,100)'; 29 | outcomeProportions = qpPFNormal(stimParams,[0 3 0.01]); 30 | figure; clf; hold on 31 | plot(stimParams,outcomeProportions(:,1),'-','Color',[0.8 0.6 0.0],'LineWidth',3); 32 | plot(stimParams,outcomeProportions(:,2),'-','Color',[0.0 0.0 0.8],'LineWidth',3); 33 | xlabel('Stimulus Value'); 34 | ylabel('Proportion'); 35 | xlim([-10 10]); ylim([0 1]); 36 | title({'qpPFNormal' ; ''}); 37 | legend({'Outcome 1','Outcome 2'},'Location','NorthWest'); 38 | 39 | %% Simulate Weibull PF 40 | fprintf('*** qpSimulatedObserver:\n'); 41 | 42 | % Stimulus parameters and number simulated trials 43 | stimParams = linspace(-9,9,20)'; 44 | nTrialsPerStim = 10; 45 | 46 | % Set up function to pass to SimulatedObserver 47 | nOutcomes = 2; 48 | qpObserverFunction = @(x) (qpSimulatedObserver(x,@qpPFWeibull,[0, 3, 0, .01])); 49 | 50 | % Simulate each trial and build up trial data structure. 51 | % 52 | % Need to transpose structure because of Matlab defaults for vectors 53 | theTrial = 1; 54 | for tt = 1:length(stimParams); 55 | for jj = 1:nTrialsPerStim 56 | trialData(theTrial).stim = stimParams(tt); 57 | trialData(theTrial).outcome = qpObserverFunction(stimParams(tt)); 58 | theTrial = theTrial + 1; 59 | end 60 | end 61 | trialData = trialData'; 62 | 63 | % Process to get proportions of each outcome for each stimulus value and 64 | % then pull out second outcome, as that corresponds to correct 65 | stimProportions = qpProportions(qpCounts(qpData(trialData),nOutcomes),nOutcomes); 66 | for ss = 1:length(stimProportions) 67 | stim(ss) = stimProportions(ss).stim; 68 | proportionCorrect(ss) = stimProportions(ss).outcomeProportions(2); 69 | end 70 | 71 | % Plot 72 | figure; clf; hold on 73 | plot(stim,proportionCorrect,'o','Color',[0.0 0.0 0.8],'MarkerFaceColor',[0.0 0.0 0.8],'MarkerSize',10); 74 | xlabel('Stimulus Value'); 75 | ylabel('Proportion Correct'); 76 | xlim([-10 10]); ylim([0 1]); 77 | title({'qpPFWeibull' ; ''}); 78 | 79 | %% Weibull with multiple parameter rows 80 | fprintf('*** qpPFWeibull and qpPFNormal with multiple parameter rows:\n'); 81 | stimParams = linspace(-9,9,2)'; 82 | outcomeProportions = qpPFWeibull(stimParams,[-9 3 0.5 0.01 ; 9 3 0.5 0.01]); 83 | assert(outcomeProportions(1,1) == outcomeProportions(2,1),'Problem with multiple parameter rows in qpPFWeibull'); 84 | outcomeProportions = qpPFNormal(stimParams,[-9 3 0.01 ; 9 3 0.01]); 85 | assert(outcomeProportions(1,1) == outcomeProportions(2,1),'Problem with multiple parameter rows in qpPFNormal'); 86 | 87 | %% Circular PF with categories 88 | fprintf('*** qpPFCircular:\n'); 89 | psiParams = [4 pi/4 pi/2 pi 3*pi/2]; 90 | stimParams = linspace(0,2*pi,100)'; 91 | outcomeProportions = qpPFCircular(stimParams,psiParams); 92 | figure; clf; hold on 93 | for jj = 2:length(psiParams) 94 | plot([psiParams(jj) psiParams(jj)],[0 1],'k:'); 95 | end 96 | plot(stimParams,outcomeProportions(:,1),'r','LineWidth',3); 97 | plot(stimParams,outcomeProportions(:,2),'g','LineWidth',3); 98 | plot(stimParams,outcomeProportions(:,3),'b','LineWidth',3); 99 | plot(stimParams,outcomeProportions(:,4),'k','LineWidth',3); 100 | xlim([0 2*pi]); 101 | ylim([0 1]); 102 | xlabel('Angle (rad)'); 103 | ylabel('Proportion'); 104 | title({'Categories on a Circle',''}); 105 | 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /demos/qpQuestPlusCSFDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusCSFDemo 2 | %qpQuestPlusCSFDemo Demonstrate/test QUEST+ at work on parametric CSF estimation 3 | % 4 | % Description: 5 | % This script shows QUEST+ employed to estimate parametric spatial and 6 | % temporal CSFs. 7 | % 8 | % Reprises Figure 6 of the paper. 9 | 10 | % 07/03/17 dhb Created. 11 | 12 | %% Close out stray figures 13 | close all; 14 | 15 | %% qpRun estimating spatial CSF 16 | % 17 | % This uses the more general qpPFSTCSF, but the temporal frequency 18 | % and its parameter are locked to zero in all places where they come up. 19 | % 20 | % This reprises Figure 6 of the 2017 QUEST+ paper. 21 | fprintf('*** qpRun, Estimate parametric spatial CSF:\n'); 22 | rng('default'); rng(3008,'twister'); 23 | simulatedPsiParams = [-35, -50, 1.2 0]; 24 | questData = qpRun(128, ... 25 | 'stimParamsDomainList',{0:2:40, 0, -50:2:0}, ... 26 | 'psiParamsDomainList',{-50:2:-30, -60:2:-40, 0.8:0.2:1.6 0}, ... 27 | 'qpPF',@qpPFSTCSF, ... 28 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFSTCSF,simulatedPsiParams), ... 29 | 'nOutcomes', 2, ... 30 | 'verbose',true); 31 | psiParamsIndex = qpListMaxArg(questData.posterior); 32 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 33 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.1f\n', ... 34 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),simulatedPsiParams(4)); 35 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.1f\n', ... 36 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),psiParamsQuest(4)); 37 | psiParamsCheck = [-360000 -500000 12000 0]; 38 | assert(all(psiParamsCheck == round(10000*psiParamsQuest)),'No longer get same QUEST+ estimate for this case'); 39 | 40 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 41 | % parameter for the search, and impose as parameter bounds the range 42 | % provided to QUEST+. 43 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 44 | 'lowerBounds', [-50 -60 0.8 0],'upperBounds',[-30 -40 1.6 0]); 45 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f, %0.1f\n', ... 46 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3),psiParamsFit(4)); 47 | psiParamsCheck = [-359123 -485262 11325 0]; 48 | assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 49 | 50 | % Plot trial locations together with maximum likelihood fit. 51 | % 52 | % Point transparancy visualizes number of trials (more opaque -> more 53 | % trials), while point color visualizes percent correct (more blue -> more 54 | % correct). 55 | figure; clf; hold on 56 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 57 | stim = zeros(length(stimCounts),questData.nStimParams); 58 | for cc = 1:length(stimCounts) 59 | stim(cc,:) = stimCounts(cc).stim; 60 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 61 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 62 | end 63 | for cc = 1:length(stimCounts) 64 | h = scatter(stim(cc,1),stim(cc,3),100,'o','MarkerEdgeColor',[1-pCorrect(cc) 0 pCorrect(cc)],'MarkerFaceColor',[1-pCorrect(cc) 0 pCorrect(cc)],... 65 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 66 | end 67 | plotSfs = (0:2:40)'; 68 | [~,plotFitThresholds] = qpPFSTCSF(... 69 | [plotSfs zeros(size(plotSfs)) zeros(size(plotSfs))], ... 70 | psiParamsFit); 71 | plot(plotSfs,plotFitThresholds,'-','Color',[1 0.2 0.0],'LineWidth',3); 72 | xlabel('Spatial Frequency (c/deg)'); 73 | ylabel('Contrast (dB)'); 74 | xlim([0 40]); ylim([-50 0]); 75 | title({'Estimate spatial CSF', ''}); 76 | drawnow; 77 | 78 | -------------------------------------------------------------------------------- /demos/qpQuestPlusCircularCatDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusCircularCatDemo 2 | %qpQuestPlusCircularCatDemo Demonstrate/test QUEST+ at work on categorization of a circular variable. 3 | % 4 | % Description: 5 | % This script shows QUEST+ employed to estimate categorization 6 | % boundaries and precision on a circular variable. 7 | % 8 | % This reprises Figure 16 of the paper, but uses a different 9 | % parameterization. Rather than the first criterion and widths of 10 | % subsequent criteria, the psychometric function used here 11 | % (qpPFCircular) takes a list of the criterion locations. These must be 12 | % in increasing order, which is not possible to express using a grid. 13 | % 14 | % The way this is handled is that we pass a handle to 15 | % qpCircularParamsCheck to qpRun as a key/value pair. This causes 16 | % qpInitialize to use this function to filter out of the psiParamsDomain 17 | % any that do not have the boundaries in increasing order. This differs 18 | % from the Mathematica implementation. Function qpPFCircular also returns 19 | % NaN in this case, which allows routine qpFit to stay out of trouble by 20 | % respecting the NaN as indicating a very high fit error. 21 | % 22 | % There would be approaches to writing the psychometric function that did not 23 | % require this filtering and NaN return from the psychometric function, but 24 | % this seems like a generally useful mechanism. 25 | % 26 | % Reprises Figure 16 of the paper, although it differs in detail because 27 | % I didn't get as fancy about the plot, because of the different 28 | % parameterization, because of a different choice of parameters and 29 | % number of trials, and because of the filtering method above invoked in 30 | % the underlying routine. 31 | 32 | % 07/07/17 dhb Created. 33 | 34 | %% Close out stray figures 35 | close all; 36 | 37 | %% qpRun estimating circular categorization parameters. 38 | % 39 | % This code parameterizes the boundaries as boundaries, rather 40 | % than first boundary and widths as in Mathematica code. 41 | % 42 | % This reprises Figure 16 of the 2017 QUEST+ paper. 43 | fprintf('*** qpRun, Estimate circular categorization parameters:\n'); 44 | rng('default'); rng(3010,'twister'); 45 | simulatedPsiParams = [4, 2*pi/3 4*pi/3-0.5 2*pi-pi/8]; 46 | questData = qpRun(200, ... 47 | 'stimParamsDomainList',{0:pi/9:2*pi}, ... 48 | 'psiParamsDomainList',{1:8, 0:pi/9:2*pi 0:pi/9:2*pi 0:pi/9:2*pi}, ... 49 | 'qpPF',@qpPFCircular, ... 50 | 'filterPsiParamsDomainFun',@qpPFCircularParamsCheck, ... 51 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFCircular,simulatedPsiParams), ... 52 | 'nOutcomes', 3, ... 53 | 'verbose',true); 54 | psiParamsIndex = qpListMaxArg(questData.posterior); 55 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 56 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.1f\n', ... 57 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),simulatedPsiParams(4)); 58 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.1f\n', ... 59 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),psiParamsQuest(4)); 60 | psiParamsCheck = [30000 20944 38397 59341]; 61 | assert(all(psiParamsCheck == round(10000*psiParamsQuest)),'No longer get same QUEST+ estimate for this case'); 62 | 63 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 64 | % parameter for the search, and impose as parameter bounds the range 65 | % provided to QUEST+. 66 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 67 | 'lowerBounds', [-1 0 0 0],'upperBounds',[8 2*pi 2*pi 2*pi]); 68 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f\n', ... 69 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3)); 70 | psiParamsCheck = [38831 21951 37488 58822]; 71 | assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 72 | 73 | % Plot trial locations together with maximum likelihood fit. 74 | % 75 | % Point transparancy visualizes number of trials (more opaque -> more 76 | % trials), while point color visualizes dominant response. The proportion plotted 77 | % for each angle is the proportion of the dominant response. This isn't as fancy 78 | % as the Mathematica plot showin in Figure 17 of the paper, but conveys the same 79 | % general idea of what happened. 80 | figure; clf; hold on 81 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 82 | stimProportions = qpProportions(stimCounts,questData.nOutcomes); 83 | stim = zeros(length(stimCounts),questData.nStimParams); 84 | for cc = 1:length(stimCounts) 85 | stim(cc,:) = stimCounts(cc).stim; 86 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 87 | end 88 | for cc = 1:length(stimCounts) 89 | for jj = 1:questData.nOutcomes 90 | switch (jj) 91 | case 1 92 | theColor = 'r'; 93 | case 2 94 | theColor = 'g'; 95 | case 3 96 | theColor = 'b'; 97 | otherwise 98 | error('Oops'); 99 | end 100 | h = scatter(stim(cc),stimProportions(cc).outcomeProportions(jj),100,'o','MarkerEdgeColor',theColor,'MarkerFaceColor',theColor,... 101 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 102 | end 103 | end 104 | plotStimParams = linspace(0,2*pi,100)'; 105 | outcomeProportions = qpPFCircular(plotStimParams,psiParamsFit); 106 | for jj = 2:length(psiParamsFit) 107 | plot([psiParamsFit(jj) psiParamsFit(jj)],[0 1],'k:'); 108 | end 109 | plot(plotStimParams,outcomeProportions(:,1),'r','LineWidth',3); 110 | plot(plotStimParams,outcomeProportions(:,2),'g','LineWidth',3); 111 | plot(plotStimParams,outcomeProportions(:,3),'b','LineWidth',3); 112 | xlim([0 2*pi]); 113 | ylim([0 1]); 114 | xlabel('Angle (rad)'); 115 | ylabel('Proportion'); 116 | title({'Categories on a Circle',''}); 117 | drawnow; 118 | 119 | -------------------------------------------------------------------------------- /demos/qpQuestPlusCoreFunctionDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusCoreFunctionDemo 2 | %qpQuestPlusCoreFunctionDemo Show basic use of QUEST+ core functions, called directly 3 | % 4 | % Description: 5 | % his script shows how to call qpInitialize, qpQuery, and qpUpdate 6 | % directly. 7 | 8 | %% Initialize 9 | % 10 | % Set parameters, using key value pairs to override defaults 11 | % as needed. (See "help qpParams" for what is available.) 12 | % 13 | % Here we set the range for the stimulus (contrast in dB) and the 14 | % psychometric function parameters (see qpPFWeibull). 15 | % 16 | % Note that the space on which the stimulus is gridded affects the 17 | % prior used by QUEST+. QUEST+ assigns equal probability to each 18 | % listed stimulus, so that the prior implied if you grid contrast in 19 | % dB is different from that if you grid contrast on a linear scale. 20 | questData = qpInitialize('stimParamsDomainList',{[-40:1:0]}, ... 21 | 'psiParamsDomainList',{-40:0, 2:5, 0.5, 0:0.01:0.04}); 22 | 23 | %% Set up simulated observer 24 | % 25 | % Parameters of the simulated Weibull 26 | simulatedPsiParams = [-20, 3, .5, .02]; 27 | 28 | % Function handle that will take stimulus parameters x and simulate 29 | % a trial according to the parameters above. 30 | simulatedObserverFun = @(x) qpSimulatedObserver(x,@qpPFWeibull,simulatedPsiParams); 31 | 32 | % Freeze random number generator so output is repeatable 33 | rng('default'); rng(2004,'twister'); 34 | 35 | %% Run simulated trials, using QUEST+ to tell us what contrast to 36 | nTrials = 64; 37 | for tt = 1:nTrials 38 | % Get stimulus for this trial 39 | stim = qpQuery(questData); 40 | 41 | % Simulate outcome 42 | outcome = simulatedObserverFun(stim); 43 | 44 | % Update quest data structure 45 | questData = qpUpdate(questData,stim,outcome); 46 | end 47 | 48 | %% Find out QUEST+'s estimate of the stimulus parameters, obtained 49 | % on the gridded parameter domain. 50 | psiParamsIndex = qpListMaxArg(questData.posterior); 51 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 52 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 53 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),simulatedPsiParams(4)); 54 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 55 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),psiParamsQuest(4)); 56 | 57 | %% Find aximum likelihood fit. Use psiParams from QUEST+ as the starting 58 | % parameter for the search, and impose as parameter bounds the range 59 | % provided to QUEST+. 60 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 61 | 'lowerBounds', [-40 2 0.5 0],'upperBounds',[0 5 0.5 0.04]); 62 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 63 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3),psiParamsFit(4)); 64 | 65 | % Little unit test that this routine still does what it used to. 66 | psiParamsCheck = [-197856 20000 5000 0]; 67 | assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 68 | 69 | %% Plot of trial locations with maximum likelihood fit 70 | figure; clf; hold on 71 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 72 | stim = [stimCounts.stim]; 73 | stimFine = linspace(-40,0,100)'; 74 | plotProportionsFit = qpPFWeibull(stimFine,psiParamsFit); 75 | for cc = 1:length(stimCounts) 76 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 77 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 78 | end 79 | for cc = 1:length(stimCounts) 80 | h = scatter(stim(cc),pCorrect(cc),100,'o','MarkerEdgeColor',[0 0 1],'MarkerFaceColor',[0 0 1],... 81 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 82 | end 83 | plot(stimFine,plotProportionsFit(:,2),'-','Color',[1.0 0.2 0.0],'LineWidth',3); 84 | xlabel('Stimulus Value'); 85 | ylabel('Proportion Correct'); 86 | xlim([-40 00]); ylim([0 1]); 87 | title({'Estimate Weibull threshold, slope, and lapse', ''}); 88 | drawnow; 89 | 90 | % Plot results 91 | figure(); 92 | xlabel('Proportion Red'); 93 | ylabel('Test Intensity'); 94 | 95 | -------------------------------------------------------------------------------- /demos/qpQuestPlusMarginalizeDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusMarginalizeDemo 2 | %qpQuestPlusMarginalizeDemo Show how to margnialize over nuisance parameters 3 | % 4 | % Description: 5 | % This script shows how to initialize quest so that it optimizes 6 | % information about some of the PF parameters, by marginalizing the 7 | % posteriors computed as part of obtaining the expected entrop after the 8 | % next trial. The idea is to marginalize over nuisance parameters (e.g. 9 | % lapse rate) that are not of interest. 10 | % 11 | % The simulation is looped over, with parameters drawn from the prior 12 | % provided to quest on each loop, and the results of running both a 13 | % marginalized and not marginalized (normal) version of quest are 14 | % compared. 15 | % 16 | % For the example here, the marginalized quest does a little einsy bit 17 | % better in a squared error sense. 18 | % 19 | % The script also compares various ways of making a post estimate at 20 | % the end of a run. Using the posterior mean works best in a squared 21 | % error sense, although as with marginalized versus not marginalized 22 | % quest, the difference is small in an absolute sense. 23 | % 24 | 25 | %% Close old figures 26 | close all; 27 | 28 | %% Initialize 29 | % 30 | % Set parameters, using key value pairs to override defaults 31 | % as needed. (See "help qpParams" for what is available.) 32 | % 33 | % Here we set the range for the stimulus (contrast in dB) and the 34 | % psychometric function parameters (see qpPFWeibull). 35 | % 36 | % Note that the space on which the stimulus is gridded affects the 37 | % prior used by QUEST+. QUEST+ assigns equal probability to each 38 | % listed stimulus, so that the prior implied if you grid contrast in 39 | % dB is different from that if you grid contrast on a linear scale. 40 | % 41 | % Key thing in this call is the 'marginalize' key/value pair. The passed 42 | % vector of indices gives which parameters in the psiParamsDomainList to 43 | % marginalize over. The slope and lapse reat 44 | psiParamsDomainList = {-40:0, 1:5, 0.5, 0:0.01:0.1}; 45 | questDataMarginalizedRaw = qpInitialize('stimParamsDomainList',{[-40:1:0]}, ... 46 | 'psiParamsDomainList',psiParamsDomainList,'marginalize',[2 4]); 47 | 48 | % Also do it in the standard quest manner 49 | questDataNotMarginalizedRaw = qpInitialize('stimParamsDomainList',{[-40:1:0]}, ... 50 | 'psiParamsDomainList',psiParamsDomainList); 51 | 52 | % Freeze random number generator so output is repeatable 53 | rng('default'); rng(2004,'twister'); 54 | nParamSets = 50; 55 | nRunsPerParamSet = 1; 56 | nTrials = 64; 57 | 58 | %% Simulate over and over and over 59 | [domainVlb,domainVub] = qpGetBoundsFromDomainList(psiParamsDomainList); 60 | for ss = 1:nParamSets 61 | % Set up parameters for this run by random draw. But keep noise 62 | % fixed, as we can probably establish that separately. We define 63 | % the simulated observer fun here so it has the right parameters each 64 | % time through the loop. 65 | simulatedPsiParamsVecCell{ss} = qpDrawFromDomainList(psiParamsDomainList); 66 | simulatedPsiParamsVec = simulatedPsiParamsVecCell{ss}; 67 | simulatedObserverFun = @(stimParamsVec) qpSimulatedObserver(stimParamsVec,@qpPFWeibull,simulatedPsiParamsVec); 68 | 69 | % Do the runs 70 | for rr = 1:nRunsPerParamSet 71 | % Simulate run 72 | fprintf('*** Simluated run %d of %d for parameters set %d of %d:\n',rr,nRunsPerParamSet,ss,nParamSets); 73 | 74 | % Get a fresh quest structure 75 | questDataMarginalized{ss,rr} = questDataMarginalizedRaw; 76 | questDataNotMarginalized{ss,rr} = questDataNotMarginalizedRaw; 77 | 78 | % Run simulated trials, using QUEST+ to tell us what contrast to 79 | % use 80 | for tt = 1:nTrials 81 | % Simulate marginalized trial 82 | stim = qpQuery(questDataMarginalized{ss,rr}); 83 | outcome = simulatedObserverFun(stim); 84 | questDataMarginalized{ss,rr} = qpUpdate(questDataMarginalized{ss,rr},stim,outcome); 85 | 86 | % Simulate non marginalized trial 87 | stim = qpQuery(questDataNotMarginalized{ss,rr}); 88 | outcome = simulatedObserverFun(stim); 89 | questDataNotMarginalized{ss,rr} = qpUpdate(questDataNotMarginalized{ss,rr},stim,outcome); 90 | end 91 | 92 | % Find out QUEST+'s estimate of the stimulus parameters, 93 | % marginalized version. There are various ways to make the 94 | % estimate. 95 | % 96 | % Note that the mean of the posterior and the mean of the 97 | % marginalized posterior are the same for parameters they have 98 | % in common. 99 | % 100 | % Max of posterior 101 | tempIndex = qpListMaxArg(questDataMarginalized{ss,rr}.posterior); 102 | posteriorMaxMarginalized{ss,rr} = questDataMarginalized{ss,rr}.psiParamsDomain(tempIndex,:); 103 | 104 | % Mean of posterior 105 | posteriorMeanMarginalized{ss,rr} = ... 106 | qpPosteriorMean(questDataMarginalized{ss,rr}.posterior,questDataMarginalized{ss,rr}.psiParamsDomain); 107 | 108 | % Max/mean of marginal posterior 109 | [marginalPosterior,marginalPsiParamsDomain] = ... 110 | qpMarginalizePosterior(questDataMarginalized{ss,rr}.posterior,questDataMarginalized{ss,rr}.psiParamsDomain, ... 111 | questDataMarginalized{ss,rr}.marginalize); 112 | tempIndex = qpListMaxArg(marginalPosterior); 113 | marginalPosteriorMaxMarginalized{ss,rr} = marginalPsiParamsDomain(tempIndex,:); 114 | marginalPosteriorMeanMarginalized{ss,rr} = qpPosteriorMean(marginalPosterior,marginalPsiParamsDomain); 115 | 116 | % Maximum likelihood fit 117 | maxLikeliFitMarginalized{ss,rr} = qpFit(questDataMarginalized{ss,rr}.trialData,questDataMarginalized{ss,rr}.qpPF, ... 118 | posteriorMaxMarginalized{ss,rr},questDataMarginalized{ss,rr}.nOutcomes,... 119 | 'lowerBounds',domainVlb,'upperBounds',domainVub); 120 | % fprintf('\tSimulated parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 121 | % simulatedPsiParamsVec(1),simulatedPsiParamsVec(2),simulatedPsiParamsVec(3),simulatedPsiParamsVec(4)); 122 | % fprintf('\tMax posterior QUEST+ parameters, marginalized: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 123 | % psiParamsQuestMarginalized{ss,rr}(1),psiParamsQuestMarginalized{ss,rr}(2),psiParamsQuestMarginalized{ss,rr}(3),psiParamsQuestMarginalized{ss,rr}(4)); 124 | % fprintf('\tMaximum likelihood fit parameters, marginalized: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 125 | % psiParamsFitMarginalized{ss,rr}(1),psiParamsFitMarginalized{ss,rr}(2),psiParamsFitMarginalized{ss,rr}(3),psiParamsFitMarginalized{ss,rr}(4)); 126 | % fprintf('\tMarginal posterior mean, marginalized: %0.1f\n',posteriorMeanMarginalized{ss,rr}(1)); 127 | 128 | % And then same estimates for the not marginalized version 129 | tempIndex = qpListMaxArg(questDataNotMarginalized{ss,rr}.posterior); 130 | posteriorMaxNotMarginalized{ss,rr} = questDataNotMarginalized{ss,rr}.psiParamsDomain(tempIndex,:); 131 | posteriorMeanNotMarginalized{ss,rr} = ... 132 | qpPosteriorMean(questDataNotMarginalized{ss,rr}.posterior,questDataNotMarginalized{ss,rr}.psiParamsDomain); 133 | [marginalPosterior,marginalPsiParamsDomain] = ... 134 | qpMarginalizePosterior(questDataNotMarginalized{ss,rr}.posterior,questDataNotMarginalized{ss,rr}.psiParamsDomain, ... 135 | questDataMarginalized{ss,rr}.marginalize); 136 | tempIndex = qpListMaxArg(marginalPosterior); 137 | marginalPosteriorMaxNotMarginalized{ss,rr} = marginalPsiParamsDomain(tempIndex,:); 138 | marginalPosteriorMeanNotMarginalized{ss,rr} = qpPosteriorMean(marginalPosterior,marginalPsiParamsDomain); 139 | maxLikeliFitNotMarginalized{ss,rr} = qpFit(questDataNotMarginalized{ss,rr}.trialData,questDataNotMarginalized{ss,rr}.qpPF, ... 140 | posteriorMaxNotMarginalized{ss,rr},questDataNotMarginalized{ss,rr}.nOutcomes,... 141 | 'lowerBounds',domainVlb,'upperBounds',domainVub); 142 | % fprintf('\tMax posterior QUEST+ parameters, not marginalized: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 143 | % psiParamsQuestNotMarginalized{ss,rr}(1),psiParamsQuestNotMarginalized{ss,rr}(2),psiParamsQuestNotMarginalized{ss,rr}(3),psiParamsQuestNotMarginalized{ss,rr}(4)); 144 | % fprintf('\tMaximum likelihood fit parameters, not marginalized: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 145 | % psiParamsFitNotMarginalized{ss,rr}(1),psiParamsFitNotMarginalized{ss,rr}(2),psiParamsFitNotMarginalized{ss,rr}(3),psiParamsFitNotMarginalized{ss,rr}(4)); 146 | % fprintf('\tMarginal posterior mean,not marginalized %0.1f\n',posteriorMeanNotMarginalized{ss,rr}(1)); 147 | end 148 | end 149 | 150 | %% Put key values in vector form for error computation 151 | inIndex = 1; 152 | for ss = 1:nParamSets 153 | for rr = 1:nRunsPerParamSet 154 | simulatedThresh(inIndex) = simulatedPsiParamsVecCell{ss}(1); 155 | posteriorMaxMarginalizedThresh(inIndex) = posteriorMaxMarginalized{ss,rr}(1); 156 | posteriorMeanMarginalizedThresh(inIndex) = posteriorMeanMarginalized{ss,rr}(1); 157 | marginalPosteriorMaxMarginalizedThresh(inIndex) = marginalPosteriorMaxMarginalized{ss,rr}(1); 158 | marginalPosteriorMeanMarginalizedThresh(inIndex) = marginalPosteriorMeanMarginalized{ss,rr}(1); 159 | maxLikeliFitMarginalizedThresh(inIndex) = maxLikeliFitMarginalized{ss,rr}(1); 160 | 161 | posteriorMeanNotMarginalizedThresh(inIndex) = posteriorMeanNotMarginalized{ss,rr}(1); 162 | posteriorMaxNotMarginalizedThresh(inIndex) = posteriorMaxNotMarginalized{ss,rr}(1); 163 | marginalPosteriorMaxNotMarginalizedThresh(inIndex) = marginalPosteriorMaxNotMarginalized{ss,rr}(1); 164 | marginalPosteriorMeanNotMarginalizedThresh(inIndex) = marginalPosteriorMeanNotMarginalized{ss,rr}(1); 165 | maxLikeliFitNotMarginalizedThresh(inIndex) = maxLikeliFitNotMarginalized{ss,rr}(1); 166 | inIndex = inIndex+1; 167 | end 168 | end 169 | 170 | %% Check some theory 171 | if (any(abs(posteriorMeanMarginalizedThresh-marginalPosteriorMeanMarginalizedThresh) > 1e-7)) 172 | error('Do not understand mean estimates, marginalized and not'); 173 | end 174 | if (any(abs(posteriorMeanNotMarginalizedThresh-marginalPosteriorMeanNotMarginalizedThresh) > 1e-7)) 175 | error('Do not understand mean estimates, marginalized and not'); 176 | end 177 | 178 | %% Compute rmse estimation error and print out 179 | posteriorMaxMarginalizedErr = sqrt(sum((simulatedThresh(:) - posteriorMaxMarginalizedThresh(:)).^2)/length(simulatedThresh)); 180 | posteriorMeanMarginalizedErr = sqrt(sum((simulatedThresh(:) - posteriorMeanMarginalizedThresh(:)).^2)/length(simulatedThresh)); 181 | marginalPosteriorMaxMarginalizedErr = sqrt(sum((simulatedThresh(:) - marginalPosteriorMaxMarginalizedThresh(:)).^2)/length(simulatedThresh)); 182 | marginalPosteriorMeanMarginalizedErr = sqrt(sum((simulatedThresh(:) - marginalPosteriorMeanMarginalizedThresh(:)).^2)/length(simulatedThresh)); 183 | maxLikeliFitMarginalizedErr = sqrt(sum((simulatedThresh(:) - maxLikeliFitMarginalizedThresh(:)).^2)/length(simulatedThresh)); 184 | 185 | posteriorMaxNotMarginalizedErr = sqrt(sum((simulatedThresh(:) - posteriorMaxNotMarginalizedThresh(:)).^2)/length(simulatedThresh)); 186 | posteriorMeanNotMarginalizedErr = sqrt(sum((simulatedThresh(:) - posteriorMeanNotMarginalizedThresh(:)).^2)/length(simulatedThresh)); 187 | marginalPosteriorMaxNotMarginalizedErr = sqrt(sum((simulatedThresh(:) - marginalPosteriorMaxNotMarginalizedThresh(:)).^2)/length(simulatedThresh)); 188 | marginalPosteriorMeanNotMarginalizedErr = sqrt(sum((simulatedThresh(:) - marginalPosteriorMeanNotMarginalizedThresh(:)).^2)/length(simulatedThresh)); 189 | maxLikeliFitNotMarginalizedErr = sqrt(sum((simulatedThresh(:) - maxLikeliFitNotMarginalizedThresh(:)).^2)/length(simulatedThresh)); 190 | 191 | fprintf('Errors:\n'); 192 | fprintf('\tPosterior max, marginalized: %0.5f\n',posteriorMaxMarginalizedErr); 193 | fprintf('\tPosterior mean, marginalized: %0.5f\n',posteriorMeanMarginalizedErr); 194 | fprintf('\tMarginal posterior max, marginalized: %0.5f\n',marginalPosteriorMaxMarginalizedErr); 195 | fprintf('\tMarginal posterior mean, marginalized: %0.5f\n',marginalPosteriorMeanMarginalizedErr); 196 | fprintf('\tMax likelihood fit, marginalized: %0.5f\n',maxLikeliFitMarginalizedErr); 197 | 198 | fprintf('\tPosterior max, not marginalized: %0.5f\n',posteriorMaxNotMarginalizedErr); 199 | fprintf('\tPosterior mean, not marginalized: %0.5f\n',posteriorMeanNotMarginalizedErr); 200 | fprintf('\tMarginal posterior max, marginalized: %0.5f\n',marginalPosteriorMaxMarginalizedErr); 201 | fprintf('\tMarginal posterior mean, not marginalized: %0.5f\n',marginalPosteriorMeanNotMarginalizedErr); 202 | fprintf('\tMax likelihood fit, not marginalized: %0.5f\n',maxLikeliFitNotMarginalizedErr); 203 | 204 | % Threshold estimates versus simulated values, marginalized quest 205 | theParamIndex = 1; 206 | theParamName = 'Threshold, marginalized'; 207 | figure; clf; hold on 208 | plot(simulatedThresh(:),marginalPosteriorMeanMarginalizedThresh(:),'ro','MarkerFaceColor','r','MarkerSize',8); 209 | plot(simulatedThresh(:),posteriorMeanMarginalizedThresh(:),'ko','MarkerFaceColor','k','MarkerSize',4); 210 | %plot(simulatedThresh(:),maxLikeliFitMarginalizedThresh(:),'bo','MarkerFaceColor','b','MarkerSize',8); 211 | xlim([domainVlb(theParamIndex) domainVub(theParamIndex)]); 212 | ylim([domainVlb(theParamIndex) domainVub(theParamIndex)]); 213 | plot([domainVlb(theParamIndex) domainVub(theParamIndex)],[domainVlb(theParamIndex) domainVub(theParamIndex)],'k','LineWidth',1); 214 | axis('square'); 215 | xlabel('Simulated'); 216 | ylabel('Estimated'); 217 | title(theParamName); 218 | 219 | % Threshold estimates versus simulated values, not marginalized quest 220 | theParamIndex = 1; 221 | theParamName = 'Threshold, not marginalized'; 222 | figure; clf; hold on 223 | plot(simulatedThresh(:),marginalPosteriorMeanNotMarginalizedThresh(:),'ro','MarkerFaceColor','r','MarkerSize',8); 224 | plot(simulatedThresh(:),posteriorMeanNotMarginalizedThresh(:),'ko','MarkerFaceColor','k','MarkerSize',4); 225 | %plot(simulatedThresh(:),maxLikeliFitNotMarginalizedThresh(:),'bo','MarkerFaceColor','b','MarkerSize',8); 226 | xlim([domainVlb(theParamIndex) domainVub(theParamIndex)]); 227 | ylim([domainVlb(theParamIndex) domainVub(theParamIndex)]); 228 | plot([domainVlb(theParamIndex) domainVub(theParamIndex)],[domainVlb(theParamIndex) domainVub(theParamIndex)],'k','LineWidth',1); 229 | axis('square'); 230 | xlabel('Simulated'); 231 | ylabel('Estimated'); 232 | title(theParamName); 233 | 234 | % Plot abs estimation error from marginalized quest versus non-margialized. 235 | % Mean abs error is the black circle. 236 | theParamName = 'Effect of Marginalization'; 237 | figure; clf; hold on 238 | plot(abs(marginalPosteriorMeanNotMarginalizedThresh(:)-simulatedThresh(:)), ... 239 | abs(marginalPosteriorMeanMarginalizedThresh(:)-simulatedThresh(:)),'ro','MarkerFaceColor','r','MarkerSize',8); 240 | plot(mean(abs(marginalPosteriorMeanNotMarginalizedThresh(:)-simulatedThresh(:))), ... 241 | mean(abs(marginalPosteriorMeanMarginalizedThresh(:)-simulatedThresh(:))),'ko','MarkerFaceColor','k','MarkerSize',12); 242 | xlim([0 5]); 243 | ylim([0 5]); 244 | plot([0 5],[0 5],'k','LineWidth',1); 245 | axis('square'); 246 | xlabel('Not Marginalized Quest Abs Err'); 247 | ylabel('Marginalized Quest Abs Err'); 248 | title(theParamName); 249 | 250 | % Little unit test that this routine still does what it used to. 251 | assert(-924.0016 == round(sum(marginalPosteriorMeanMarginalizedThresh(:)),4),'No longer get same threshold estimate sum for marginalized case'); 252 | assert( -925.1793 == round(sum(marginalPosteriorMeanNotMarginalizedThresh(:)),4),'No longer get same threshold estimate sum for not marginalized case'); 253 | 254 | -------------------------------------------------------------------------------- /demos/qpQuestPlusNormCdfDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestNormCdfDemo 2 | %qpQuestNormCdfDemo Show basic use of QUEST+ core functions for normcdf 3 | % 4 | % Description: 5 | % This script shows how to call qpInitialize, qpQuery, and qpUpdate 6 | % directly for a normal cdf psychometric function. 7 | % 8 | % You can compare this function to questNormalCdfDemo, which implements 9 | % the same calculations from scratch and thus exposes each step more 10 | % explicitly. 11 | % 12 | % See also: questNormalCdfDemo. 13 | 14 | % History: 15 | % 09/26/20 dhb, dce Wrote this from a different qp demo program. 16 | 17 | %% Initialize 18 | % 19 | % Set parameters, using key value pairs to override defaults 20 | % as needed. (See "help qpParams" for what is available.) 21 | % 22 | % Here we set the range for the stimulus (contrast in dB) and the 23 | % psychometric function parameters (see qpPFWeibull). 24 | % 25 | % Note that the space on which the stimulus is gridded affects the 26 | % prior used by QUEST+. QUEST+ assigns equal probability to each 27 | % listed stimulus, so that the prior implied if you grid contrast in 28 | % dB is different from that if you grid contrast on a linear scale. 29 | questData = qpInitialize('stimParamsDomainList',{0:0.01:1}, ... 30 | 'psiParamsDomainList',{0:0.01:1, 0.1, 0},'qpPF',@qpPFNormal); 31 | 32 | %% Set up simulated observer 33 | % 34 | % Parameters of the simulated Normal 35 | simulatedPsiParams = [0.2, 0.1, 0]; 36 | 37 | % Function handle that will take stimulus parameters x and simulate 38 | % a trial according to the parameters above. 39 | simulatedObserverFun = @(x) qpSimulatedObserver(x,@qpPFNormal,simulatedPsiParams); 40 | 41 | % Freeze random number generator so output is repeatable 42 | rng('default'); rng(2004,'twister'); 43 | 44 | %% Run simulated trials, using QUEST+ to tell us what contrast to 45 | nTrials = 64; 46 | for tt = 1:nTrials 47 | % Get stimulus for this trial 48 | stim = qpQuery(questData); 49 | 50 | % Simulate outcome 51 | outcome = simulatedObserverFun(stim); 52 | 53 | % Update quest data structure 54 | questData = qpUpdate(questData,stim,outcome); 55 | end 56 | 57 | %% Find out QUEST+'s estimate of the stimulus parameters, obtained 58 | % on the gridded parameter domain. 59 | psiParamsIndex = qpListMaxArg(questData.posterior); 60 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 61 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, \n', ... 62 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3)); 63 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f\n', ... 64 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3)); 65 | 66 | %% Find maximum likelihood fit. Use psiParams from QUEST+ as the starting 67 | % parameter for the search, and impose as parameter bounds the range 68 | % provided to QUEST+. 69 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 70 | 'lowerBounds', [0 0.1 0],'upperBounds',[1 0.1 0]); 71 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f\n', ... 72 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3)); 73 | 74 | %% Plot of trial locations with maximum likelihood fit 75 | figure; clf; hold on 76 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 77 | stim = [stimCounts.stim]; 78 | stimFine = linspace(0,1,100)'; 79 | plotProportionsFit = qpPFNormal(stimFine,psiParamsFit); 80 | for cc = 1:length(stimCounts) 81 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 82 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 83 | end 84 | for cc = 1:length(stimCounts) 85 | h = scatter(stim(cc),pCorrect(cc),100,'o','MarkerEdgeColor',[0 0 1],'MarkerFaceColor',[0 0 1],... 86 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 87 | end 88 | plot(stimFine,plotProportionsFit(:,2),'-','Color',[1.0 0.2 0.0],'LineWidth',3); 89 | xlabel('Stimulus Value'); 90 | ylabel('Proportion Correct'); 91 | xlim([0 1]); ylim([0 1]); 92 | title({'Estimate Normal threshold', ''}); 93 | drawnow; 94 | 95 | -------------------------------------------------------------------------------- /demos/qpQuestPlusPaperSimpleExamplesDemo.m: -------------------------------------------------------------------------------- 1 | function qpQuestPlusPaperSimpleExamplesDemo 2 | %qpQuestPlusPaperSimpleExamplesDemo Demonstrate/test QUEST+ on some simple example problems 3 | % 4 | % Description: 5 | % This script shows the usage for QUEST+ for some basic applications. 6 | % 7 | % It also shows the default output of qpInitialize, which can 8 | % be useful for understanding the questData structure in this 9 | % implementation. 10 | % 11 | % It then uses qpRun and qpFIt to produce figures that reprises Figures 12 | % 2, 3 and 4 of the Watson (2017) QUEST+ paper. Note that qpRun itself 13 | % has the primary purpose of demonstrating how to integrate QUEST+ into 14 | % an experimental program. Don't panic: qpRun is very short. 15 | 16 | % 07/01/17 dhb Created. 17 | % 07/02/17 dhb Added additional examples. 18 | % 07/04/17 dhb Add qpFit. 19 | % 07/23/17 dhb Name change, note about effect of stimulus grid on impliicit prior. 20 | % 09/06/18 dhb Show how to use qpPFWeibullInv to get threshold values for 21 | % arbitrary criterion correct. 22 | 23 | %% Close out stray figures 24 | close all; 25 | 26 | %% qpInitialize 27 | fprintf('*** qpInitialize:\n'); 28 | questData = qpInitialize 29 | 30 | %% qpRun with its defaults 31 | % 32 | % This runs a test of estimating a Weibull threshold using 33 | % TAFC trials. THe default in qpParams sets up a stimulus 34 | % grid appropriate for this case. 35 | % 36 | % See last example in this file for how to pass a stimulus grid, 37 | % as well as a note about the possible effect of what space 38 | % you choose to grid the stimuli on. 39 | fprintf('*** qpRun, Weibull estimate threshold:\n'); 40 | rng('default'); rng(2002,'twister'); 41 | simulatedPsiParams = [-20, 3.5, .5, .02]; 42 | questData = qpRun(32, ... 43 | 'stimParamsDomainList',{-40:1:0}, ... 44 | 'psiParamsDomainList',{-40:0, 3.5, 0.5, 0.02}, ... 45 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFWeibull,simulatedPsiParams), ... 46 | 'verbose',false); 47 | psiParamsIndex = qpListMaxArg(questData.posterior); 48 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 49 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 50 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),simulatedPsiParams(4)); 51 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 52 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),psiParamsQuest(4)); 53 | 54 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 55 | % parameter for the search, and impose as parameter bounds the range 56 | % provided to QUEST+. You could also relax the bounds on slope and lapse, 57 | % but if you did it would make more sense to let QUEST+ place trials to 58 | % lock those down well. 59 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 60 | 'lowerBounds', [-40 3.5 0.5 0.02],'upperBounds',[0 3.5 0.5 0.02]); 61 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 62 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3),psiParamsFit(4)); 63 | psiParamsCheck = [-188163 35000 5000 200]; 64 | assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 65 | 66 | % Plot with maximum likelhood fit 67 | figure; clf; hold on 68 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 69 | stim = [stimCounts.stim]; 70 | stimFine = linspace(-40,0,100)'; 71 | plotProportionsFit = qpPFWeibull(stimFine,psiParamsFit); 72 | for cc = 1:length(stimCounts) 73 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 74 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 75 | end 76 | for cc = 1:length(stimCounts) 77 | h = scatter(stim(cc),pCorrect(cc),100,'o','MarkerEdgeColor',[0 0 1],'MarkerFaceColor',[0 0 1],... 78 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 79 | end 80 | plot(stimFine,plotProportionsFit(:,2),'-','Color',[1 0.2 0.0],'LineWidth',3); 81 | xlabel('Stimulus Value'); 82 | ylabel('Proportion Correct'); 83 | xlim([-40 00]); ylim([0 1]); 84 | title({'Estimate Weibull threshold', ''}); 85 | drawnow; 86 | 87 | % Print out thresholds for various criteria. 88 | % Illustrates use of qpPFWeibullInv. 89 | thresholdProportions = [0.6 0.75 0.80 0.92]'; 90 | stimContrasts = qpPFWeibullInv(thresholdProportions,psiParamsFit); 91 | checkThresholdProportions = qpPFWeibull(stimContrasts,psiParamsFit); 92 | if (max(abs(checkThresholdProportions(:,2) - thresholdProportions)) > 1e-6) 93 | error('Weibull PF does not invert properly'); 94 | end 95 | 96 | %% qpRun estimating three parameters of a Weibull 97 | % 98 | % This runs a test of estimating a Weibull threshold, slope 99 | % and lapse using TAFC trials. 100 | fprintf('\n*** qpRun, Weibull estimate threshold, slope and lapse:\n'); 101 | rng('default'); rng(2004,'twister'); 102 | simulatedPsiParams = [-20, 3, .5, .02]; 103 | questData = qpRun(200, ... 104 | 'stimParamsDomainList',{-40:1:0}, ... 105 | 'psiParamsDomainList',{-40:0, 2:5, 0.5, 0:0.01:0.04}, ... 106 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFWeibull,simulatedPsiParams), ... 107 | 'verbose',false); 108 | psiParamsIndex = qpListMaxArg(questData.posterior); 109 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 110 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 111 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3),simulatedPsiParams(4)); 112 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 113 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3),psiParamsQuest(4)); 114 | 115 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 116 | % parameter for the search, and impose as parameter bounds the range 117 | % provided to QUEST+. 118 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 119 | 'lowerBounds', [-40 2 0.5 0],'upperBounds',[0 5 0.5 0.04]); 120 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.1f, %0.2f\n', ... 121 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3),psiParamsFit(4)); 122 | psiParamsCheck = [-197856 20000 5000 0]; 123 | %assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 124 | 125 | % Plot with maximum likelihood fit 126 | figure; clf; hold on 127 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 128 | stim = [stimCounts.stim]; 129 | stimFine = linspace(-40,0,100)'; 130 | plotProportionsFit = qpPFWeibull(stimFine,psiParamsFit); 131 | for cc = 1:length(stimCounts) 132 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 133 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 134 | end 135 | for cc = 1:length(stimCounts) 136 | h = scatter(stim(cc),pCorrect(cc),100,'o','MarkerEdgeColor',[0 0 1],'MarkerFaceColor',[0 0 1],... 137 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 138 | end 139 | plot(stimFine,plotProportionsFit(:,2),'-','Color',[1.0 0.2 0.0],'LineWidth',3); 140 | xlabel('Stimulus Value'); 141 | ylabel('Proportion Correct'); 142 | xlim([-40 00]); ylim([0 1]); 143 | title({'Estimate Weibull threshold, slope, and lapse', ''}); 144 | drawnow; 145 | 146 | %% qpRun estimating normal mean and standard deviation 147 | % 148 | % This runs a test of estimating a Weibull threshold using 149 | % y/n trials. Note that here we explicitly pass a stimulus 150 | % grid, because we don't want qpParams' default for this example. 151 | % 152 | % Also note that each stimulus on this list is assigned equal prior 153 | % probability in the standard QUEST+ algorithm. Thus the space in which 154 | % you grid the stimuli (e.g. linear versus log) implicitly affects the 155 | % prior, and it is worth a little thought about what space you choose to 156 | % grid the stimuli on. 157 | fprintf('\n*** qpRun, Normal estimate mean, sd and lapse:\n'); 158 | rng('default'); rng(2008,'twister'); 159 | simulatedPsiParams = [1, 3, .02]; 160 | questData = qpRun(128, ... 161 | 'stimParamsDomainList',{-10:10}, ... 162 | 'psiParamsDomainList',{-5:5, 1:10, 0.00:0.01:0.04}, ... 163 | 'qpPF',@qpPFNormal, ... 164 | 'qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFNormal,simulatedPsiParams), ... 165 | 'nOutcomes', 2, ... 166 | 'verbose',false); 167 | psiParamsIndex = qpListMaxArg(questData.posterior); 168 | psiParamsQuest = questData.psiParamsDomain(psiParamsIndex,:); 169 | fprintf('Simulated parameters: %0.1f, %0.1f, %0.2f\n', ... 170 | simulatedPsiParams(1),simulatedPsiParams(2),simulatedPsiParams(3)); 171 | fprintf('Max posterior QUEST+ parameters: %0.1f, %0.1f, %0.2f\n', ... 172 | psiParamsQuest(1),psiParamsQuest(2),psiParamsQuest(3)); 173 | 174 | % Maximum likelihood fit. Use psiParams from QUEST+ as the starting 175 | % parameter for the search, and impose as parameter bounds the range 176 | % provided to QUEST+. 177 | psiParamsFit = qpFit(questData.trialData,questData.qpPF,psiParamsQuest,questData.nOutcomes,... 178 | 'lowerBounds', [-5 1 0],'upperBounds',[5 10 0.04]); 179 | fprintf('Maximum likelihood fit parameters: %0.1f, %0.1f, %0.2f\n', ... 180 | psiParamsFit(1),psiParamsFit(2),psiParamsFit(3)); 181 | psiParamsCheck = [13318 27742 0]; 182 | assert(all(psiParamsCheck == round(10000*psiParamsFit)),'No longer get same ML estimate for this case'); 183 | 184 | % Plot with maximum likelihood fit 185 | figure; clf; hold on 186 | stimCounts = qpCounts(qpData(questData.trialData),questData.nOutcomes); 187 | stim = [stimCounts.stim]; 188 | stimFine = linspace(-10,10,100)'; 189 | plotProportionsFit = qpPFNormal(stimFine,psiParamsFit); 190 | for cc = 1:length(stimCounts) 191 | nTrials(cc) = sum(stimCounts(cc).outcomeCounts); 192 | pCorrect(cc) = stimCounts(cc).outcomeCounts(2)/nTrials(cc); 193 | end 194 | for cc = 1:length(stimCounts) 195 | h = scatter(stim(cc),pCorrect(cc),100,'o','MarkerEdgeColor',[0 0 1],'MarkerFaceColor',[0 0 1],... 196 | 'MarkerFaceAlpha',nTrials(cc)/max(nTrials),'MarkerEdgeAlpha',nTrials(cc)/max(nTrials)); 197 | end 198 | plot(stimFine,plotProportionsFit(:,2),'-','Color',[1 0.2 0.0],'LineWidth',3); 199 | xlabel('Stimulus Value'); 200 | ylabel('Proportion Correct'); 201 | xlim([-10 10]); ylim([0 1]); 202 | title({'Estimate Normal mean, sd and lapse', ''}); 203 | drawnow; 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /demos/qpRunAllDemos.m: -------------------------------------------------------------------------------- 1 | % qpRunAllDemos Run through all of the QUEST+ demos 2 | % 3 | % Description: 4 | % Script to run all of the demos. Useful as a test that everthing 5 | % still works. 6 | 7 | % 07/06/17 dhb Wrote it 8 | 9 | %% Here we go 10 | qpUtilityDemo; 11 | qpDataProcDemo; 12 | qpPsiFunctionDemo; 13 | qpQuestPlusCoreFunctionDemo; 14 | qpQuestPlusPaperSimpleExamplesDemo 15 | qpQuestPlusCSFDemo; 16 | qpQuestPlusCircularCatDemo; 17 | qpQuestPlusMarginalizeDemo; 18 | 19 | %% Contributed demos 20 | qpQuestPlusRatingDemo; 21 | qpQuestPlusRatingDemo2; 22 | qpPairwiseComparisonDemo; 23 | 24 | %% Close up 25 | close all; -------------------------------------------------------------------------------- /demos/qpUtilityDemo.m: -------------------------------------------------------------------------------- 1 | function qpUtilityDemo 2 | %qpUtilityDemo Demonstrate/test the qp utility routines 3 | % 4 | % Description: 5 | % This script shows the usage for the qp utility routines, and checks 6 | % some basic assertions about what they should do. 7 | % 8 | % These do their best to follow the examples in the QuestPlus.nb 9 | % Mathematica notebook. 10 | 11 | % 6/24/17 dhb Created. 12 | % 01/26/18 dhb Update for columnwise usage of basic functions. 13 | 14 | %% qpUnitizeArray 15 | fprintf('*** qpUnitizeArray:\n'); 16 | testArray = qpUnitizeArray(zeros(2,2)); 17 | assert(all(sum(testArray,1) == 1),'qpUnitizeArray: Columns of array from input of zeros do not sum to 1'); 18 | 19 | testArray = qpUnitizeArray([1 2 ; 3 4]) 20 | assert(all(sum(testArray,1) == 1),'qpUnitizeArray: Columns of array do not sum to 1'); 21 | 22 | %% qpUniformArray 23 | fprintf('*** qpUniformArray:\n'); 24 | testArray = qpUniformArray([3 4]) 25 | assert(all(sum(testArray,1) == 1),'qpUniformArray: Columns of uniform array do not sum to 1'); 26 | 27 | % %% qpArrayEntropy 28 | % 29 | % This first call is like the Mathematic notebook example. It gives a similar number. Round 30 | % to three places so we can check that the code still does the same thing 31 | % later in life. Note setting of rng seed so this is replicable. 32 | fprintf('*** qpArrayEntropy:\n'); 33 | rng('default'); rng(1,'twister'); 34 | theArray = rand(10,10); 35 | theEntropy = qpArrayEntropy(theArray); 36 | theEntropy = round(sum(theEntropy)*1000)/1000 37 | assert(theEntropy == 35.513,'qpArrayEntropy: Computed entropy of unnormalized array is no longer what it used to be'); 38 | 39 | %% qpListMinArg/qpListMaxArg 40 | fprintf('*** qpListMinArg/qpListMaxArg:\n'); 41 | theArray = [2 3 4 ; 1 2 6]; 42 | minIndex = qpListMinArg(theArray) 43 | assert(theArray(minIndex) == min(theArray(:)),'qpListMinArg: Indexing into array with return value does not retrieve minimum'); 44 | maxIndex = qpListMaxArg(theArray) 45 | assert(theArray(maxIndex) == max(theArray(:)),'qpListMaxArg: Indexing into array with return value does not retrieve maximum'); 46 | 47 | %% qpNLogP 48 | fprintf('*** qpNLogP\n'); 49 | nLogP = qpNLogP([.1 0 2],[0 0 .1]) 50 | assert(nLogP(1) == -1*realmax,'qpNLogP: Value for input (.1,0) not as expected.'); 51 | assert(nLogP(2) == 0,'qpNLogP: Value for input (0,0) not as expected.'); 52 | 53 | -------------------------------------------------------------------------------- /mathworkscentral/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - mathworkscentral 2 | % 3 | % Dependencies obtained from Mathworks central or other internet sources. See README for URLs. 4 | % 5 | % allcomb - Cartesian product of input vectors. 6 | % von_mises_cdf - CDF of the von Mises distribution. 7 | 8 | -------------------------------------------------------------------------------- /mathworkscentral/allcomb/allcomb.m: -------------------------------------------------------------------------------- 1 | function A = allcomb(varargin) 2 | %allcomb All combinations of input (Cartesian product) 3 | % 4 | % B = ALLCOMB(A1,A2,A3,...,AN) returns all combinations of the elements 5 | % in the arrays A1, A2, ..., and AN. B is P-by-N matrix is which P is the product 6 | % of the number of elements of the N inputs. This functionality is also 7 | % known as the Cartesian Product. The arguments can be numerical and/or 8 | % characters, or they can be cell arrays. 9 | % 10 | % Examples: 11 | % allcomb([1 3 5],[-3 8],[0 1]) % numerical input: 12 | % % -> [ 1 -3 0 13 | % % 1 -3 1 14 | % % 1 8 0 15 | % % ... 16 | % % 5 -3 1 17 | % % 5 8 1 ] ; % a 12-by-3 array 18 | % 19 | % allcomb('abc','XY') % character arrays 20 | % % -> [ aX ; aY ; bX ; bY ; cX ; cY] % a 6-by-2 character array 21 | % 22 | % allcomb('xy',[65 66]) % a combination 23 | % % -> ['xA' ; 'xB' ; 'yA' ; 'yB'] % a 4-by-2 character array 24 | % 25 | % allcomb({'hello','Bye'},{'Joe', 10:12},{99999 []}) % all cell arrays 26 | % % -> { 'hello' 'Joe' [99999] 27 | % % 'hello' 'Joe' [] 28 | % % 'hello' [1x3 double] [99999] 29 | % % 'hello' [1x3 double] [] 30 | % % 'Bye' 'Joe' [99999] 31 | % % 'Bye' 'Joe' [] 32 | % % 'Bye' [1x3 double] [99999] 33 | % % 'Bye' [1x3 double] [] } ; % a 8-by-3 cell array 34 | % 35 | % ALLCOMB(..., 'matlab') causes the first column to change fastest which 36 | % is consistent with matlab indexing. Example: 37 | % allcomb(1:2,3:4,5:6,'matlab') 38 | % % -> [ 1 3 5 ; 1 4 5 ; 1 3 6 ; ... ; 2 4 6 ] 39 | % 40 | % If one of the arguments is empty, ALLCOMB returns a 0-by-N empty array. 41 | % 42 | % See also NCHOOSEK, PERMS, NDGRID 43 | % and NCHOOSE, COMBN, KTHCOMBN (Matlab Central FEX) 44 | 45 | % Tested in Matlab R2015a 46 | % version 4.1 (feb 2016) 47 | % (c) Jos van der Geest 48 | % email: samelinoa@gmail.com 49 | 50 | % History 51 | % 1.1 (feb 2006), removed minor bug when entering empty cell arrays; 52 | % added option to let the first input run fastest (suggestion by JD) 53 | % 1.2 (jan 2010), using ii as an index on the left-hand for the multiple 54 | % output by NDGRID. Thanks to Jan Simon, for showing this little trick 55 | % 2.0 (dec 2010). Bruno Luong convinced me that an empty input should 56 | % return an empty output. 57 | % 2.1 (feb 2011). A cell as input argument caused the check on the last 58 | % argument (specifying the order) to crash. 59 | % 2.2 (jan 2012). removed a superfluous line of code (ischar(..)) 60 | % 3.0 (may 2012) removed check for doubles so character arrays are accepted 61 | % 4.0 (feb 2014) added support for cell arrays 62 | % 4.1 (feb 2016) fixed error for cell array input with last argument being 63 | % 'matlab'. Thanks to Richard for pointing this out. 64 | 65 | narginchk(1,Inf) ; 66 | 67 | NC = nargin ; 68 | 69 | % check if we should flip the order 70 | if ischar(varargin{end}) && (strcmpi(varargin{end},'matlab') || strcmpi(varargin{end},'john')), 71 | % based on a suggestion by JD on the FEX 72 | NC = NC-1 ; 73 | ii = 1:NC ; % now first argument will change fastest 74 | else 75 | % default: enter arguments backwards, so last one (AN) is changing fastest 76 | ii = NC:-1:1 ; 77 | end 78 | 79 | args = varargin(1:NC) ; 80 | % check for empty inputs 81 | if any(cellfun('isempty',args)), 82 | warning('ALLCOMB:EmptyInput','One of more empty inputs result in an empty output.') ; 83 | A = zeros(0,NC) ; 84 | elseif NC > 1 85 | isCellInput = cellfun(@iscell,args) ; 86 | if any(isCellInput) 87 | if ~all(isCellInput) 88 | error('ALLCOMB:InvalidCellInput', ... 89 | 'For cell input, all arguments should be cell arrays.') ; 90 | end 91 | % for cell input, we use to indices to get all combinations 92 | ix = cellfun(@(c) 1:numel(c), args,'un',0) ; 93 | 94 | % flip using ii if last column is changing fastest 95 | [ix{ii}] = ndgrid(ix{ii}) ; 96 | 97 | A = cell(numel(ix{1}),NC) ; % pre-allocate the output 98 | for k=1:NC, 99 | % combine 100 | A(:,k) = reshape(args{k}(ix{k}),[],1) ; 101 | end 102 | else 103 | % non-cell input, assuming all numerical values or strings 104 | % flip using ii if last column is changing fastest 105 | [A{ii}] = ndgrid(args{ii}) ; 106 | % concatenate 107 | A = reshape(cat(NC+1,A{:}),[],NC) ; 108 | end 109 | elseif NC==1, 110 | A = args{1}(:) ; % nothing to combine 111 | 112 | else % NC==0, there was only the 'matlab' flag argument 113 | A = zeros(0,0) ; % nothing 114 | end 115 | -------------------------------------------------------------------------------- /mathworkscentral/allcomb/license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Jos (10584) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the distribution 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 18 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /mathworkscentral/von_mises_cdf/von_mises_cdf.m: -------------------------------------------------------------------------------- 1 | function cdf = von_mises_cdf ( x, a, b ) 2 | % von_mises_cdf Evaluates the von Mises CDF. 3 | % 4 | % Discussion: 5 | % 6 | % Thanks to Cameron Huddleston-Holmes for pointing out a discrepancy 7 | % in the MATLAB version of this routine, caused by overlooking an 8 | % implicit conversion to integer arithmetic in the original FORTRAN, 9 | % JVB, 21 September 2005. 10 | % 11 | % Licensing: 12 | % 13 | % This code is distributed under the GNU LGPL license. 14 | % 15 | % Modified: 16 | % 17 | % 17 November 2006 18 | % 19 | % Author: 20 | % 21 | % Geoffrey Hill 22 | % 23 | % MATLAB translation by John Burkardt. 24 | % 25 | % Reference: 26 | % 27 | % Geoffrey Hill, 28 | % ACM TOMS Algorithm 518, 29 | % Incomplete Bessel Function I0: The von Mises Distribution, 30 | % ACM Transactions on Mathematical Software, 31 | % Volume 3, Number 3, September 1977, pages 279-284. 32 | % 33 | % Kanti Mardia, Peter Jupp, 34 | % Directional Statistics, 35 | % Wiley, 2000, QA276.M335 36 | % 37 | % Parameters: 38 | % 39 | % Input, real X, the argument of the CDF. 40 | % A - PI <= X <= A + PI. 41 | % 42 | % Input, real A, B, the parameters of the PDF. 43 | % -PI <= A <= PI, 44 | % 0.0 < B. 45 | % 46 | % Output, real CDF, the value of the CDF. 47 | % 48 | a1 = 12.0; 49 | a2 = 0.8; 50 | a3 = 8.0; 51 | a4 = 1.0; 52 | c1 = 56.0; 53 | ck = 10.5; 54 | % 55 | % We expect -PI <= X - A <= PI. 56 | % 57 | if ( x - a <= -pi ) 58 | cdf = 0.0; 59 | return 60 | end 61 | 62 | if ( pi <= x - a ) 63 | cdf = 1.0; 64 | return 65 | end 66 | % 67 | % Convert the angle (X - A) modulo 2 PI to the range ( 0, 2 * PI ). 68 | % 69 | z = b; 70 | 71 | u = mod ( x - a + pi, 2.0 * pi ); 72 | 73 | if ( u < 0.0 ) 74 | u = u + 2.0 * pi; 75 | end 76 | 77 | y = u - pi; 78 | % 79 | % For small B, sum IP terms by backwards recursion. 80 | % 81 | if ( z <= ck ) 82 | 83 | v = 0.0; 84 | 85 | if ( 0.0 < z ) 86 | 87 | ip = floor ( z * a2 - a3 / ( z + a4 ) + a1 ); 88 | p = ip; 89 | s = sin ( y ); 90 | c = cos ( y ); 91 | y = p * y; 92 | sn = sin ( y ); 93 | cn = cos ( y ); 94 | r = 0.0; 95 | z = 2.0 / z; 96 | 97 | for n = 2 : ip 98 | p = p - 1.0; 99 | y = sn; 100 | sn = sn * c - cn * s; 101 | cn = cn * c + y * s; 102 | r = 1.0 / ( p * z + r ); 103 | v = ( sn / p + v ) * r; 104 | end 105 | 106 | end 107 | 108 | cdf = ( u * 0.5 + v ) / pi; 109 | % 110 | % For large B, compute the normal approximation and left tail. 111 | % 112 | else 113 | 114 | c = 24.0 * z; 115 | v = c - c1; 116 | r = sqrt ( ( 54.0 / ( 347.0 / v + 26.0 - c ) - 6.0 + c ) / 12.0 ); 117 | z = sin ( 0.5 * y ) * r; 118 | s = 2.0 * z * z; 119 | v = v - s + 3.0; 120 | y = ( c - s - s - 16.0 ) / 3.0; 121 | y = ( ( s + 1.75 ) * s + 83.5 ) / v - y; 122 | arg = z * ( 1.0 - s / y^2 ); 123 | erfx = r8_error_f ( arg ); 124 | cdf = 0.5 * erfx + 0.5; 125 | 126 | end 127 | 128 | cdf = max ( cdf, 0.0 ); 129 | cdf = min ( cdf, 1.0 ); 130 | 131 | return 132 | end 133 | -------------------------------------------------------------------------------- /printplot/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - printplot 2 | % 3 | % Support for printing and plotting. Well, no plotting yet. 4 | % 5 | % qpPrintParams - Print out a parameters vector. 6 | % qpPrintStim - Print out stimulus vector. 7 | % qpPrintStimCounts - Print out stimulus count data array. 8 | % qpPrintStimData - Print out stimulus data array. 9 | % qpPrintStimProportions - Print out stimulus proportions data array. 10 | % qpPrintTrialData - Print out trial data array. 11 | -------------------------------------------------------------------------------- /printplot/qpPrintParams.m: -------------------------------------------------------------------------------- 1 | function qpPrintParams(params,varargin) 2 | %qpPrintParams Print out a parameters vector 3 | % 4 | % Usage: 5 | % qpPrintParams(params,varargin) 6 | 7 | % Description: 8 | % Print out parameters vector. 9 | % 10 | % Input: 11 | % params Row vector with parameters 12 | % 13 | % Output: 14 | % None 15 | % 16 | % Optional key/value pairs 17 | % None 18 | 19 | % 6/25/17 dhb Wrote it. 20 | 21 | %% Parse input 22 | p = inputParser; 23 | p.addRequired('params',@isnumeric); 24 | p.parse(params,varargin{:}); 25 | 26 | %% Print out each inside of [ ] 27 | fprintf('Parameters:[ '); 28 | for jj = 1:length(params) 29 | if (jj > 1) 30 | fprintf(' '); 31 | end 32 | fprintf('%0.2g',params(jj)); 33 | end 34 | fprintf(']\n'); 35 | end 36 | 37 | -------------------------------------------------------------------------------- /printplot/qpPrintStim.m: -------------------------------------------------------------------------------- 1 | function qpPrintStim(stim,varargin) 2 | %qpPrintStim Print out stimulus vector 3 | % 4 | % Usage: 5 | % qpPrintStim(stim,varargin) 6 | 7 | % Description: 8 | % Print out stimulus vector. 9 | % 10 | % Input: 11 | % stim Row vector with stimulus parameters 12 | 13 | % Output: 14 | % None 15 | % 16 | % Optional key/value pairs 17 | % None 18 | 19 | % 6/25/17 dhb Wrote it. 20 | 21 | %% Parse input 22 | p = inputParser; 23 | p.addRequired('stim',@isnumeric); 24 | p.parse(stim,varargin{:}); 25 | 26 | %% Print out each inside of [ ] 27 | fprintf('Stimulus:[ '); 28 | for jj = 1:length(stim) 29 | if (jj > 1) 30 | fprintf(' '); 31 | end 32 | fprintf('%0.2g',stim(jj)); 33 | end 34 | fprintf(']\n'); 35 | end 36 | 37 | -------------------------------------------------------------------------------- /printplot/qpPrintStimCounts.m: -------------------------------------------------------------------------------- 1 | function qpPrintStimCounts(stimCounts,varargin) 2 | %qpPrintStimCounts Print out stimulus count data array 3 | % 4 | % Usage: 5 | % qpPrintStimCounts(stimCounts,varargin) 6 | 7 | % Description: 8 | % Print out stim count data array. 9 | % 10 | % Input: 11 | % stimCounts A struct array with each stimulus value presented 12 | % in sorted order, and a vector of the counts of each possible 13 | % outcome type that happened on trials for that stimulus value: 14 | % stimCounts(i).stim - Row vector of stimulus parameters 15 | % stimCounts(i).outcomeCounts - Row vector of length 16 | % nOutcomes with the number of times each outcome 17 | % happend for the given stimulus. 18 | % This is what qpCounts produces. 19 | % 20 | % Output: 21 | % None 22 | % 23 | % Optional key/value pairs 24 | % None 25 | 26 | % 6/25/17 dhb Wrote it. 27 | 28 | %% Parse input 29 | p = inputParser; 30 | p.addRequired('stimCounts',@isstruct); 31 | p.parse(stimCounts,varargin{:}); 32 | 33 | %% Print out each entry 34 | fprintf('Stimulus count data:'); 35 | counter = 0; 36 | for ii = 1:length(stimCounts) 37 | if (counter == 0) 38 | fprintf('\n\t['); 39 | else 40 | fprintf('; ['); 41 | end 42 | for jj = 1:length(stimCounts(ii).stim) 43 | if (jj > 1) 44 | fprintf(' '); 45 | end 46 | fprintf('%0.2g',stimCounts(ii).stim(jj)); 47 | end 48 | fprintf('], ['); 49 | 50 | for jj = 1:length(stimCounts(ii).outcomeCounts) 51 | if (jj > 1) 52 | fprintf(' '); 53 | end 54 | fprintf('%d',stimCounts(ii).outcomeCounts(jj)); 55 | end 56 | fprintf(']'); 57 | 58 | counter = rem(counter+1,4); 59 | end 60 | fprintf('\n'); 61 | end 62 | -------------------------------------------------------------------------------- /printplot/qpPrintStimData.m: -------------------------------------------------------------------------------- 1 | function qpPrintStimData(stimData,varargin) 2 | %qpPrintStimData Print out stimulus data array 3 | % 4 | % Usage: 5 | % qpPrintStimData(stimData,varargin) 6 | 7 | % Description: 8 | % Print out trial data array. 9 | % 10 | % Input: 11 | % stimData A struct array with each stimulus value and a vector of outcomes 12 | % stimData(i).stim - Row vector of stimulus parameters 13 | % stimData(i).outcomes - Row vector of outcomes 14 | % 15 | % Output: 16 | % None 17 | % 18 | % Optional key/value pairs 19 | % None 20 | 21 | % 6/25/17 dhb Wrote it. 22 | 23 | %% Parse input 24 | p = inputParser; 25 | p.addRequired('stimData',@isstruct); 26 | p.parse(stimData,varargin{:}); 27 | 28 | %% Print out each entry 29 | fprintf('Stimulus data:'); 30 | counter = 0; 31 | for ii = 1:length(stimData) 32 | if (counter == 0) 33 | fprintf('\n\t['); 34 | else 35 | fprintf('; ['); 36 | end 37 | for jj = 1:length(stimData(ii).stim) 38 | if (jj > 1) 39 | fprintf(' '); 40 | end 41 | fprintf('%0.2g',stimData(ii).stim(jj)); 42 | end 43 | fprintf('], ['); 44 | 45 | for jj = 1:length(stimData(ii).outcomes) 46 | if (jj > 1) 47 | fprintf(' '); 48 | end 49 | fprintf('%d',stimData(ii).outcomes(jj)); 50 | end 51 | fprintf(']'); 52 | 53 | counter = rem(counter+1,4); 54 | end 55 | fprintf('\n'); 56 | end 57 | -------------------------------------------------------------------------------- /printplot/qpPrintStimProportions.m: -------------------------------------------------------------------------------- 1 | function qpPrintStimProportions(stimProportions,varargin) 2 | %qpPrintStimProportions Print out stimulus proportions data array 3 | % 4 | % Usage: 5 | % qpPrintStimProportions(stimCounts,varargin) 6 | 7 | % Description: 8 | % Print out stim proportion array. 9 | % 10 | % Input: 11 | % stimProportions A struct array with each stimulus value presented 12 | % in sorted order, and a vector of the proportions of each possible 13 | % outcome type that happened on trials for that stimulus value: 14 | % stimCounts(i).stim - Row vector of stimulus parameters 15 | % stimCounts(i).outcomeProprotions - Row vector of length 16 | % nOutcomes with the proportion of times each outcome 17 | % happened for the given stimulus. Each such vector 18 | % sums to 1. 19 | % This is what qpProportions produces. 20 | % 21 | % Output: 22 | % None 23 | % 24 | % Optional key/value pairs 25 | % None 26 | 27 | % 6/25/17 dhb Wrote it. 28 | 29 | %% Parse input 30 | p = inputParser; 31 | p.addRequired('stimProportions',@isstruct); 32 | p.parse(stimProportions,varargin{:}); 33 | 34 | %% Print out each entry 35 | fprintf('Stimulus proportion data:'); 36 | counter = 0; 37 | for ii = 1:length(stimProportions) 38 | if (counter == 0) 39 | fprintf('\n\t['); 40 | else 41 | fprintf('; ['); 42 | end 43 | for jj = 1:length(stimProportions(ii).stim) 44 | if (jj > 1) 45 | fprintf(' '); 46 | end 47 | fprintf('%0.2g',stimProportions(ii).stim(jj)); 48 | end 49 | fprintf('], ['); 50 | 51 | for jj = 1:length(stimProportions(ii).outcomeProportions) 52 | if (jj > 1) 53 | fprintf(' '); 54 | end 55 | fprintf('%0.3f',stimProportions(ii).outcomeProportions(jj)); 56 | end 57 | fprintf(']'); 58 | 59 | counter = rem(counter+1,4); 60 | end 61 | fprintf('\n'); 62 | end 63 | -------------------------------------------------------------------------------- /printplot/qpPrintTrialData.m: -------------------------------------------------------------------------------- 1 | function qpPrintTrialData(trialData,varargin) 2 | %qpPrintTrialData Print out trial data array 3 | % 4 | % Usage: 5 | % qpPrintTrialData(trialData,varargin) 6 | 7 | % Description: 8 | % Print out trial data array. 9 | % 10 | % Input: 11 | % trialData A trial data struct array: 12 | % trialData(i).stim - Row vector of stimulus parameters. 13 | % trialData(i).outcome - Outcome of the trial. 14 | % 15 | % Output: 16 | % None 17 | % 18 | % Optional key/value pairs 19 | % None 20 | 21 | % 6/25/17 dhb Wrote it. 22 | 23 | %% Parse input 24 | p = inputParser; 25 | p.addRequired('trialData',@isstruct); 26 | p.parse(trialData,varargin{:}); 27 | 28 | %% Print out each entry 29 | fprintf('Trial data:'); 30 | counter = 0; 31 | for ii = 1:length(trialData) 32 | if (counter == 0) 33 | fprintf('\n\t['); 34 | else 35 | fprintf('; ['); 36 | end 37 | for jj = 1:length(trialData(ii).stim) 38 | if (jj > 1) 39 | fprintf(' '); 40 | end 41 | fprintf('%0.2g',trialData(ii).stim(jj)); 42 | end 43 | fprintf('], %d',trialData(ii).outcome); 44 | counter = rem(counter+1,4); 45 | end 46 | fprintf('\n'); 47 | end 48 | -------------------------------------------------------------------------------- /psifunctions/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - psifunctions 2 | % 3 | % Psychometric functions. 4 | % 5 | % qpPFCircular - Psychometric function for categorization of a circular variable. 6 | % qpPFCircularParamsCheck - Parameter check for qpPFCicular. 7 | % qpPFNormal - Normal cdf psychometric function. 8 | % qpPFSTCSF - Psychometric function for parametric spatial/temporal CSF. 9 | % qpPFWeibull - Weibull cdf psychometric function. Works in dB. 10 | % qpPFWeibullInv - Weibull cdf psychometric function. Works in dB. 11 | % qpPFWeibullLog - Weibull cdf psychometric function. Works in log10 units. 12 | % qpPFWeibullLogInv - Weibull cdf psychometric function. Works in log10 units. 13 | % qpSimulateObserver - Simulate a trial of an experiment for a passed psychometric funtion 14 | 15 | -------------------------------------------------------------------------------- /psifunctions/qpPFCircular.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFCircular(stimParams,psiParams,varargin) 2 | % qpPFCircular Psychometric function for categorization of a circular variable 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFCircular(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for categorization on a circle. 9 | % 10 | % This code parameterizes the boundaries as boundaries, rather 11 | % than first boundary and widths as in Mathematica code. 12 | % 13 | % This could deal more robustly with wrapping issues and argument 14 | % checking. It is good enough to allow the demo to run, but maybe 15 | % not for real work. 16 | % 17 | % Inputs: 18 | % stimParams Matrix, with each row being a vector of stimulus parameters. 19 | % Here that "row vector" is just a single number giving 20 | % the stimulus angle in radians 21 | % 22 | % psiParams Row vector of parameters. Each row has. 23 | % concentration Mean of normal 24 | % boundaries N-1 angles giving category boundaries in radians. 25 | % Reponse 1 corresponds to boundary1 <= stim < boundary2. 26 | % Response N corresponds to boundaryN-1 <= stim < boundary1, 27 | % Boundaries must be in increasing order. Otherwise this 28 | % routine returns a vector of NaN. 29 | % 30 | % Output: 31 | % predictedProportions Matrix, where each row is a vector of predicted proportions 32 | % for each outcome. 33 | % First entry of each row is for first category (outcome == 1) 34 | % Second entry of each row is second category (outcome == 2) 35 | % Nth entry is for nth category (outcome -- n) 36 | 37 | % 07/07/17 dhb Wrote it. 38 | 39 | %% Parse input 40 | % 41 | % This routine gets called many many times and should be as fast as 42 | % possible. The input parser is slow. So we forego arg checking and 43 | % optional key/value pairs. The code below shows how they would look. 44 | % 45 | % p = inputParser; 46 | % p.addRequired('stimParams',@isnumeric); 47 | % p.addRequired('psiParams',@isnumeric); 48 | % p.parse(stimParams,psiParams,varargin{:}); 49 | 50 | %% Here is the Matlab version 51 | if (size(psiParams,1) ~= 1) 52 | error('Expected a row vector of parameters'); 53 | end 54 | if (size(psiParams,2) < 2) 55 | error('Parameters vector has wrong length for qpPFCircular'); 56 | end 57 | if (size(stimParams,2) ~= 1) 58 | error('Each row of stimParams should have only one entry'); 59 | end 60 | if (any(stimParams < 0 | stimParams > 2*pi)) 61 | error('Stimuli must be greater than or equal to zero and less than 2*pi'); 62 | end 63 | 64 | %% Grab params 65 | concentration = psiParams(1); 66 | nStim = size(stimParams,1); 67 | nOutcomes = length(psiParams)-1; 68 | 69 | % Check that parameters are OK. 70 | paramsOK = qpPFCircularParamsCheck(psiParams); 71 | if (~paramsOK) 72 | predictedProportions = NaN*ones(nStim,nOutcomes); 73 | return; 74 | end 75 | 76 | % Get boundaries are guananteed to be increasing order because the 77 | % check above passed. 78 | boundaries = psiParams(2:end); 79 | 80 | %% Check that boundaries are within the circle, within tolerance. 81 | if (any(boundaries < 0-1e-7 | boundaries > 2*pi+1e-7)) 82 | error('Passed boundaries must be greater than or equal to zero and less than 2*pi'); 83 | end 84 | 85 | % Convert to -pi origin for boundaries and stimuli for our internal calculations 86 | stimParams = stimParams - pi; 87 | boundaries = boundaries - pi; 88 | 89 | %% Compute 90 | % 91 | % This is not terribly efficient, yet. 92 | predictedProportions = zeros(nStim,nOutcomes); 93 | for ii = 1:nStim 94 | prevProportion0 = von_mises_cdf(boundaries(1),stimParams(ii),concentration); 95 | prevProportion = prevProportion0; 96 | predictedProportions(ii,1) = von_mises_cdf(boundaries(2),stimParams(ii),concentration) - prevProportion; 97 | prevProportion = prevProportion + predictedProportions(ii,1); 98 | for jj = 2:nOutcomes-1 99 | predictedProportions(ii,jj) = von_mises_cdf(boundaries(jj+1),stimParams(ii),concentration) - prevProportion; 100 | prevProportion = prevProportion + predictedProportions(ii,jj); 101 | end 102 | predictedProportions(ii,nOutcomes) = von_mises_cdf(2*pi,stimParams(ii),concentration) - prevProportion + prevProportion0; 103 | predictedProportions(ii,:) = predictedProportions(ii,:)/sum(predictedProportions(ii,:)); 104 | end 105 | 106 | end 107 | 108 | 109 | -------------------------------------------------------------------------------- /psifunctions/qpPFCircularParamsCheck.m: -------------------------------------------------------------------------------- 1 | function paramsOK = qpPFCircularParamsCheck(psiParams) 2 | % qpPFCircularParamsCheck Parameter check for qpPFCicular 3 | % 4 | % Usage: 5 | % paramsOK = qpPFCircularParamCheck(psiParams) 6 | % 7 | % Description: 8 | % Check whether passed parameters are valid for qpPFCircular 9 | % 10 | % Inputs: 11 | % psiParams See qpPFCircular. 12 | % 13 | % Output: 14 | % paramsOK Boolean, true if parameters are OK and false otherwise. 15 | 16 | % 07/22/17 dhb Wrote it. 17 | 18 | %% Assume ok 19 | paramsOK = true; 20 | 21 | %% Check that concentration is non-negative 22 | if (psiParams(1) < 0) 23 | paramsOK = false; 24 | end 25 | 26 | %% Check whether boundary parameters are OK 27 | % 28 | % This is signaled by returning NaN when the boundaries are 29 | % not in increasing order. 30 | [boundaries,sortIndex] = sort(psiParams(2:end),'ascend'); 31 | nOutcomes = length(boundaries); 32 | if (any(sortIndex ~= 1:nOutcomes)) 33 | paramsOK = false; 34 | end 35 | 36 | -------------------------------------------------------------------------------- /psifunctions/qpPFNormal.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFNormal(stimParams,psiParams) 2 | % qpPFNormal Normal cdf psychometric function 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFNormal(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for the Normal psychometric 9 | % function 10 | % 11 | % Inputs: 12 | % stimParams Matrix, with each row being a vector of stimulus parameters. 13 | % Here that "row vector" is just a single number giving 14 | % the stimulus level. 15 | % 16 | % psiParams Row vector or matrix of parameters. Each row has. 17 | % mean Mean of normal 18 | % sd Standard deviation of normal 19 | % lapse Lapse rate 20 | % Parameterization matches the Mathematica code from the paper. 21 | % If this is passed as a matrix, must have same number 22 | % of rows as stimParams and the parameters are used from 23 | % corresponding rows. If it is passed as a row vector, that 24 | % vector is taken as the parameters for each stimulus 25 | % row. 26 | % 27 | % Output: 28 | % predictedProportions Matrix, where each row is a vector of predicted proportions 29 | % for each outcome. 30 | % First entry of each row is for no/incorrect (outcome == 1) 31 | % Second entry of each row is for yes/correct (outcome == 2) 32 | % 33 | % Optional key/value pairs 34 | % None 35 | 36 | % 07/02/17 dhb Wrote it. 37 | 38 | %% Parse input 39 | % 40 | % This routine gets called many many times and should be as fast as 41 | % possible. The input parser is slow. So we forego arg checking and 42 | % optional key/value pairs. The code below shows how they would look. 43 | % 44 | % p = inputParser; 45 | % p.addRequired('stimParams',@isnumeric); 46 | % p.addRequired('psiParams',@isnumeric); 47 | % p.parse(stimParams,psiParams,varargin{:}); 48 | 49 | %% Here is the Matlab version 50 | if (size(psiParams,2) ~= 3) 51 | error('Parameters vector has wrong length for qpPFNormal'); 52 | end 53 | if (size(stimParams,2) ~= 1) 54 | error('Each row of stimParams should have only one entry'); 55 | end 56 | 57 | %% Grab params 58 | mean = psiParams(:,1); 59 | sd = psiParams(:,2); 60 | lapse = psiParams(:,3); 61 | nStim = size(stimParams,1); 62 | predictedProportions = zeros(nStim,2); 63 | 64 | %% Compute, handling the two calling cases. 65 | % 66 | % The use of erf is faster than normcdf, and gets a step towards not 67 | % needing the stats toolbox. 68 | if (length(mean) > 1) 69 | if (length(mean) ~= nStim ) 70 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 71 | end 72 | 73 | for ii = 1:nStim 74 | adjustedSd = sd(ii)*sqrt(2); 75 | p2 = lapse(ii) + (1-2*lapse(ii))*0.5*(1+erf((stimParams(ii)-mean(ii))/adjustedSd)); 76 | predictedProportions(ii,:) = [1-p2 p2]; 77 | end 78 | else 79 | adjustedSd = sd*sqrt(2); 80 | for ii = 1:nStim 81 | p2 = lapse + (1-2*lapse)*0.5*(1+erf((stimParams(ii)-mean)/adjustedSd)); 82 | predictedProportions(ii,:) = [1-p2 p2]; 83 | end 84 | end 85 | 86 | 87 | -------------------------------------------------------------------------------- /psifunctions/qpPFSTCSF.m: -------------------------------------------------------------------------------- 1 | function [predictedProportions,predictedContrastThreshold] = qpPFSTCSF(stimParams,psiParams,varargin) 2 | % qpPFSTCSF Psychometric function for parametric spatial/temporal CSF 3 | % 4 | % Usage: 5 | % [predictedProportions,predictedContrastThreshold] = qpPFSTCSF(stimParams,psiParams); 6 | % 7 | % Description: 8 | % Psychometric function for spatial temporal CSF. This computes 9 | % predicted response proportions given the parametric spatio-temporal 10 | % contrast sensitivity function described in Watson's 2017 QUEST+ 11 | % paper. 12 | % 13 | % Input: 14 | % stimParams Matrix, with each row being a vector of stimulus parameters. 15 | % Here the row vector is: 16 | % f Spatial frequency in c/deg 17 | % w Temporal frequency in Hz 18 | % c Contrast in dB 19 | % 20 | % psiParams Row vector of parameters 21 | % minThresh Minimum threshold (dB) 22 | % c0 Intercept of rising portion 23 | % cf Slope of spatial frequency dependence 24 | % cw Slope of temportal frequency dependence 25 | % Parameterization matches the Mathematica code from the paper. 26 | % 27 | % Output: 28 | % predictedProportions Matrix, where each row is a vector of predicted proportions 29 | % for each outcome. 30 | % First entry of each row is for no/incorrect (outcome == 1) 31 | % Second entry of each row is for yes/correct (outcome == 2) 32 | % 33 | % predictedContrastThreshold As the name indicates, in dB. This is not 34 | % needed for QUEST+ per se, but can be 35 | % useful when plotting. This is independent 36 | % of passed stimulus contrast. 37 | % 38 | % Optional key/value pairs 39 | % 'slope' Slope of underlying Weibull (default 3) 40 | % 'guess' Guess rate of underlying Weibull (default 0.5) 41 | % 'lapse' Lapse rate of underlying Weibull (default 0.01); 42 | 43 | % 07/03/17 dhb Wrote it 44 | % 08/12/19 dhb Noticed should use addParameter not addOptional in 45 | % inputParser setup, and fixed. 46 | 47 | %% Parse input 48 | p = inputParser; 49 | p.addRequired('stimParams',@isnumeric); 50 | p.addRequired('psiParams',@isnumeric); 51 | p.addParameter('slope',3,@isscalar); 52 | p.addParameter('guess',0.5,@isscalar); 53 | p.addParameter('lapse',0.01,@isscalar); 54 | p.parse(stimParams,psiParams,varargin{:}); 55 | 56 | %% Pull out parameters in readable form 57 | f = stimParams(:,1); 58 | w = stimParams(:,2); 59 | c = stimParams(:,3); 60 | nStim = length(c); 61 | 62 | minThresh = psiParams(1); 63 | c0 = psiParams(2); 64 | cf = psiParams(3); 65 | cw = psiParams(4); 66 | 67 | %% Get CSF's contrast threshold using Eqn 8 of Watson QUEST+ paper 68 | cpred = max([minThresh*ones(size(f)),c0 + cf*f + cw*w],[],2); 69 | 70 | %% Call the Weibull to get the proportions 71 | predictedProportions = zeros(nStim,2); 72 | for ii = 1:nStim 73 | predictedProportions(ii,:) = qpPFWeibull(c(ii),[cpred(ii) p.Results.slope p.Results.guess p.Results.lapse]); 74 | end 75 | 76 | %% Return the predicted contrast threshold as well. 77 | % 78 | % This is independent of passed contrast. 79 | predictedContrastThreshold = cpred; 80 | 81 | -------------------------------------------------------------------------------- /psifunctions/qpPFUTM.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFWeibull(stimParams,psiParams) 2 | %qpPFUTM Uncertain template matching psychometric function 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFUTM(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for the UTM psychometric 9 | % function described by Geisler, W. S. (2018), "Psychomtric functions of 10 | % uncertain template matching observers", Journal of Vision, 18(2):1, 11 | % 1-10. 12 | % 13 | % This function implements equation 17 of the paper, with the addition 14 | % of guess and lapse parameters. 15 | % 16 | % Input: 17 | % stimParams Matrix, with each row being a vector of stimulus parameters. 18 | % Here the row vector is just a single number giving 19 | % the stimulus level. 20 | % 21 | % psiParams Row vector or matrix of parameters 22 | % alpha Alpha parameter 23 | % beta Beta parameter 24 | % guess Guess rate 25 | % lapse Lapse rate 26 | % u Stimulus origin 27 | % Parameterization of alpha, beta, and matches Equation 28 | % Parameterization of alpha, beta, and matches Equation 29 | % 17 of Geisler 2018. Psychomtric function ranges from 30 | % guess to lapse. 31 | % 32 | % Output: 33 | % predictedProportions Matrix, where each row is a vector of predicted proportions 34 | % for each outcome. 35 | % First entry of each row is for no/incorrect (outcome == 1) 36 | % Second entry of each row is for yes/correct (outcome == 2) 37 | % 38 | % Optional key/value pairs 39 | % None 40 | 41 | % 02/04/18 dhb Wrote it. 42 | 43 | %% Here is the Matlab version 44 | if (size(psiParams,2) ~= 4) 45 | error('Parameters vector has wrong length for qpPFWeibull'); 46 | end 47 | if (size(stimParams,2) ~= 1) 48 | error('Each row of stimParams should have only one entry'); 49 | end 50 | threshold = psiParams(:,1); 51 | slope = psiParams(:,2); 52 | guess = psiParams(:,3); 53 | lapse = psiParams(:,4); 54 | nStim = size(stimParams,1); 55 | predictedProportions = zeros(nStim,2); 56 | 57 | %% Compute, handling the two calling cases. 58 | if (length(threshold) > 1) 59 | if (length(threshold) ~= nStim ) 60 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 61 | end 62 | 63 | for ii = 1:nStim 64 | p1 = lapse(ii) - (guess(ii) + lapse(ii) - 1)*exp(-10^(slope(ii)*(stimParams(ii) - threshold(ii))/20)); 65 | predictedProportions(ii,:) = [p1 1-p1]; 66 | end 67 | else 68 | for ii = 1:nStim 69 | p1 = lapse - (guess + lapse - 1)*exp(-10^(slope*(stimParams(ii) - threshold)/20)); 70 | predictedProportions(ii,:) = [p1 1-p1]; 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /psifunctions/qpPFWeibull.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFWeibull(stimParams,psiParams) 2 | %qpPFWeibull Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFWeibull(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for the Weibull psychometric 9 | % function. 10 | % 11 | % See docuent qpPF_GuessLapseParameterization.pdf for a discussion of 12 | % various ways to parameterize the lapse rate and how to convert 13 | % between them. In particular, that document describes the 14 | % parameterization used in this function. 15 | % 16 | % Note that this version works in stimulus units of dB (20*log10(x)) 17 | % where x is the stimulus value. Use qpPFWeibullLog for log units, and 18 | % qpPFStandardWeibull for linear units. 19 | % 20 | % Input: 21 | % stimParams Matrix, with each row being a vector of stimulus parameters. 22 | % Here the row vector is just a single number giving 23 | % the stimulus level in dB. dB defined as 24 | % 20*log10(x). 25 | % 26 | % psiParams Row vector or matrix of parameters 27 | % threshold Threshold in dB 28 | % slope Slope 29 | % guess Guess rate 30 | % lapse Lapse rate 31 | % Parameterization matches the Mathematica code from the 32 | % Watson QUEST+ paper. If this is passed as a matrix, 33 | % must have same number of rows as stimParams and the 34 | % parameters are used from corresponding rows. If it is 35 | % passed as a row vector, that vector is taken as the 36 | % parameters for each stimulus row. 37 | % 38 | % Output: 39 | % predictedProportions Matrix, where each row is a vector of predicted proportions 40 | % for each outcome. 41 | % First entry of each row is for no/incorrect (outcome == 1) 42 | % Second entry of each row is for yes/correct (outcome == 2) 43 | % 44 | % Optional key/value pairs 45 | % None 46 | % 47 | % See also: qpPFWeibullInv, qpPFWeibullLog, qpPFWeibullLogInv, qpPFStandardWeibull. 48 | % qpPFStandardWeibullInv. 49 | 50 | % 6/27/17 dhb Wrote it. 51 | % 07/21/18 dhb Added note about qpPF_GuessLapseParameterization document 52 | % that I added to this directory. 53 | 54 | % Examples: 55 | %{ 56 | stim = 20*0.5; 57 | params = [stim 2.2 0.5 0]; 58 | predProportions = qpPFWeibull(stim,params); 59 | check = qpPFWeibullInv(predProportions(2),params); 60 | if (abs(check-stim) > 1e-10) 61 | error('PF does not invert properly'); 62 | end 63 | %} 64 | 65 | %% Parse input 66 | % 67 | % This routine gets called many many times and should be as fast as 68 | % possible. The input parser is slow. So we forego arg checking and 69 | % optional key/value pairs. The code below shows how they would look. 70 | % 71 | % p = inputParser; 72 | % p.addRequired('stimParams',@isnumeric); 73 | % p.addRequired('psiParams',@isnumeric); 74 | % p.parse(stimParams,psiParams,varargin{:}); 75 | 76 | %% Here is the Matlab version 77 | if (size(psiParams,2) ~= 4) 78 | error('Parameters vector has wrong length for qpPFWeibull'); 79 | end 80 | if (size(stimParams,2) ~= 1) 81 | error('Each row of stimParams should have only one entry'); 82 | end 83 | threshold = psiParams(:,1); 84 | slope = psiParams(:,2); 85 | guess = psiParams(:,3); 86 | lapse = psiParams(:,4); 87 | nStim = size(stimParams,1); 88 | predictedProportions = zeros(nStim,2); 89 | 90 | %% Compute, handling the two calling cases. 91 | if (length(threshold) > 1) 92 | if (length(threshold) ~= nStim ) 93 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 94 | end 95 | 96 | for ii = 1:nStim 97 | p1 = lapse(ii) - (guess(ii) + lapse(ii) - 1)*exp(-10^(slope(ii)*(stimParams(ii) - threshold(ii))/20)); 98 | predictedProportions(ii,:) = [p1 1-p1]; 99 | end 100 | else 101 | for ii = 1:nStim 102 | p1 = lapse - (guess + lapse - 1)*exp(-10^(slope*(stimParams(ii) - threshold)/20)); 103 | predictedProportions(ii,:) = [p1 1-p1]; 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /psifunctions/qpPFWeibullInv.m: -------------------------------------------------------------------------------- 1 | function stimContrast = qpPFWeibullInv(proportionCorrect,psiParams) 2 | %qpPFWeibullInv Inverse Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % stimContrast = qpPFWeibullInv(proportionCorrect,psiParams) 6 | % 7 | % Description: 8 | % Compute the stimulus proportions that lead to the desired proportion 9 | % correct. 10 | % 11 | % Note that this version works in stimulus units of dB (20*log10(x)) 12 | % where x is the stimulus value. Use qpPFWeibullLogInv for log units, and 13 | % qpPFStandardWeibullInv for linear units. 14 | % 15 | % Input: 16 | % proportionCorrect Column vector, with each row being a proportion correct. 17 | % 18 | % psiParams Row vector or matrix of parameters 19 | % threshold Threshold in dB 20 | % slope Slope 21 | % guess Guess rate 22 | % lapse Lapse rate 23 | % Parameterization matches the Mathematica code from the 24 | % Watson QUEST+ paper. If this is passed as a matrix, 25 | % must have same number of rows as proportionCorrect and the 26 | % parameters are used from corresponding rows. If it is 27 | % passed as a row vector, that vector is taken as the 28 | % parameters for each stimulus row. 29 | % 30 | % Output: 31 | % stimContrast Vector of stimulus contrasts in dB. dB defined as 32 | % 20*log10(x), with x being the stimulus 33 | % variable (often contrast). 34 | % 35 | % Optional key/value pairs 36 | % None 37 | % 38 | % See also: qpPFWeibull, qpPFWeibullLog, qpPFWeibullLogInv, qpPFStandardWeibull. 39 | % qpPFStandardWeibullInv. 40 | 41 | % 9/6/18 dhb Wrote it. 42 | 43 | %% Here is the Matlab version 44 | if (size(psiParams,2) ~= 4) 45 | error('Parameters vector has wrong length for qpPFWeibullInv'); 46 | end 47 | if (size(proportionCorrect,2) ~= 1) 48 | error('Each row of stimParams should have only one entry'); 49 | end 50 | threshold = psiParams(:,1); 51 | slope = psiParams(:,2); 52 | guess = psiParams(:,3); 53 | lapse = psiParams(:,4); 54 | nStim = size(proportionCorrect,1); 55 | stimContrast = zeros(nStim,1); 56 | 57 | %% Compute, handling the two calling cases. 58 | if (length(threshold) > 1) 59 | if (length(threshold) ~= nStim ) 60 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 61 | end 62 | 63 | for ii = 1:nStim 64 | p1 = 1-proportionCorrect(ii); 65 | stimContrast(ii) = (20*log10(-log(-(p1-lapse(ii))/(guess(ii)+lapse(ii)-1))))/slope(ii) + threshold(ii); 66 | end 67 | else 68 | for ii = 1:nStim 69 | % This is the forward calculation from qpPFWeibull: 70 | % p1 = lapse - (guess + lapse - 1)*exp(-10^(slope*(stimParams(ii) - threshold)/20)); 71 | % A little algebra inverts it. p1 is proportion incorrect. 72 | p1 = 1-proportionCorrect(ii); 73 | stimContrast(ii) = (20*log10(-log(-(p1-lapse)/(guess+lapse-1))))/slope + threshold; 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /psifunctions/qpPFWeibullLog.m: -------------------------------------------------------------------------------- 1 | function predictedProportions = qpPFWeibullLog(stimParams,psiParams) 2 | %qpPFWeibullLog Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % predictedProportions = qpPFWeibullLog(stimParams,psiParams) 6 | % 7 | % Description: 8 | % Compute the proportions of each outcome for the Weibull psychometric 9 | % function. 10 | % 11 | % See docuent qpPF_GuessLapseParameterization.pdf for a discussion of 12 | % various ways to parameterize the lapse rate and how to convert 13 | % between them. In particular, that document describes the 14 | % parameterization used in this function. 15 | % 16 | % Note that this version works in log units log10(x), where x is the 17 | % stimulus value. The function qpPFWeibull works in dB. 18 | % 19 | % Input: 20 | % stimParams Matrix, with each row being a vector of stimulus parameters. 21 | % Here the row vector is just a single number giving 22 | % the stimulus level in log10 units. 23 | % 24 | % psiParams Row vector or matrix of parameters 25 | % threshold Threshold parameter in log units 26 | % slope Slope 27 | % guess Guess rate 28 | % lapse Lapse rate 29 | % Parameterization matches the Mathematica code from the 30 | % Watson QUEST+ paper. If this is passed as a matrix, 31 | % must have same number of rows as stimParams and the 32 | % parameters are used from corresponding rows. If it is 33 | % passed as a row vector, that vector is taken as the 34 | % parameters for each stimulus row. 35 | % 36 | % Output: 37 | % predictedProportions Matrix, where each row is a vector of predicted proportions 38 | % for each outcome. 39 | % First entry of each row is for no/incorrect (outcome == 1) 40 | % Second entry of each row is for yes/correct (outcome == 2) 41 | % 42 | % Optional key/value pairs 43 | % None 44 | % 45 | % See also: qpPFWeibull, qpPFWeibullInv, qpPFWeibullLogInv, qpPFStandardWeibull. 46 | % qpPFStandardWeibullInv. 47 | 48 | % 12/13/21 dhb Wrote it. 49 | 50 | % Examples: 51 | %{ 52 | stim = 0.5; 53 | params = [stim 2.2 0.5 0]; 54 | predProportions = qpPFWeibullLog(stim,params); 55 | check = qpPFWeibullLogInv(predProportions(2),params); 56 | if (abs(check-stim) > 1e-10) 57 | error('PF does not invert properly'); 58 | end 59 | %} 60 | 61 | %% Parse input 62 | % 63 | % This routine gets called many many times and should be as fast as 64 | % possible. The input parser is slow. So we forego arg checking and 65 | % optional key/value pairs. The code below shows how they would look. 66 | % 67 | % p = inputParser; 68 | % p.addRequired('stimParams',@isnumeric); 69 | % p.addRequired('psiParams',@isnumeric); 70 | % p.parse(stimParams,psiParams,varargin{:}); 71 | 72 | %% Here is the Matlab version 73 | if (size(psiParams,2) ~= 4) 74 | error('Parameters vector has wrong length for qpPFWeibull'); 75 | end 76 | if (size(stimParams,2) ~= 1) 77 | error('Each row of stimParams should have only one entry'); 78 | end 79 | threshold = psiParams(:,1); 80 | slope = psiParams(:,2); 81 | guess = psiParams(:,3); 82 | lapse = psiParams(:,4); 83 | nStim = size(stimParams,1); 84 | predictedProportions = zeros(nStim,2); 85 | 86 | %% Compute, handling the two calling cases. 87 | if (length(threshold) > 1) 88 | if (length(threshold) ~= nStim ) 89 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 90 | end 91 | 92 | for ii = 1:nStim 93 | p1 = lapse(ii) - (guess(ii) + lapse(ii) - 1)*exp(-10^(slope(ii)*(stimParams(ii) - threshold(ii)))); 94 | predictedProportions(ii,:) = [p1 1-p1]; 95 | end 96 | else 97 | for ii = 1:nStim 98 | p1 = lapse - (guess + lapse - 1)*exp(-10^(slope*(stimParams(ii) - threshold))); 99 | predictedProportions(ii,:) = [p1 1-p1]; 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /psifunctions/qpPFWeibullLogInv.m: -------------------------------------------------------------------------------- 1 | function stimContrast = qpPFWeibullLogInv(proportionCorrect,psiParams) 2 | %qpPFWeibullLogInv Inverse Weibull cdf psychometric function 3 | % 4 | % Usage: 5 | % stimContrast = qpPFWeibullLogInv(proportionCorrect,psiParams) 6 | % 7 | % Description: 8 | % Compute the stimulus proportions that lead to the desired proportion 9 | % correct. 10 | % 11 | % Note that this version works in log units, log10(x) 12 | % where x is the stimulus value. Use qpPFWeibullInv for units of dB, and 13 | % qpPFStandardWeibullInv for linear units. 14 | % 15 | % Input: 16 | % proportionCorrect Column vector, with each row being a proportion correct. 17 | % 18 | % psiParams Row vector or matrix of parameters 19 | % threshold Threshold parameter in log units 20 | % slope Slope 21 | % guess Guess rate 22 | % lapse Lapse rate 23 | % Parameterization matches the Mathematica code from the 24 | % Watson QUEST+ paper. If this is passed as a matrix, 25 | % must have same number of rows as proportionCorrect and the 26 | % parameters are used from corresponding rows. If it is 27 | % passed as a row vector, that vector is taken as the 28 | % parameters for each stimulus row. 29 | % 30 | % Output: 31 | % stimContrast Vector of stimulus values in log10 units. Often the 32 | % stimulus value is contrast. 33 | % 34 | % Optional key/value pairs 35 | % None 36 | % 37 | % See also: qpPFWeibull, qpPFWeibullInv, qpPFWeibullLog, qpPFStandardWeibull. 38 | % qpPFStandardWeibullInv. 39 | 40 | % 12/13/21 dhb Wrote it. 41 | 42 | %% Here is the Matlab version 43 | if (size(psiParams,2) ~= 4) 44 | error('Parameters vector has wrong length for qpPFWeibullInv'); 45 | end 46 | if (size(proportionCorrect,2) ~= 1) 47 | error('Each row of stimParams should have only one entry'); 48 | end 49 | threshold = psiParams(:,1); 50 | slope = psiParams(:,2); 51 | guess = psiParams(:,3); 52 | lapse = psiParams(:,4); 53 | nStim = size(proportionCorrect,1); 54 | stimContrast = zeros(nStim,1); 55 | 56 | %% Compute, handling the two calling cases. 57 | if (length(threshold) > 1) 58 | if (length(threshold) ~= nStim ) 59 | error('Number of parameter vectors passed is not one and does not match number of stimuli passed'); 60 | end 61 | 62 | for ii = 1:nStim 63 | p1 = 1-proportionCorrect(ii); 64 | stimContrast(ii) = (log10(-log(-(p1-lapse(ii))/(guess(ii)+lapse(ii)-1))))/slope(ii) + threshold(ii); 65 | end 66 | else 67 | for ii = 1:nStim 68 | % This is the forward calculation from qpPFWeibull: 69 | % p1 = lapse - (guess + lapse - 1)*exp(-10^(slope*(stimParams(ii) - threshold)/20)); 70 | % A little algebra inverts it. p1 is proportion incorrect. 71 | p1 = 1-proportionCorrect(ii); 72 | stimContrast(ii) = (log10(-log(-(p1-lapse)/(guess+lapse-1))))/slope + threshold; 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /psifunctions/qpPF_GuessLapseParameterization.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrainardLab/mQUESTPlus/adb471184da4d69f9ffaf38ac8d5cd811f5d2b9e/psifunctions/qpPF_GuessLapseParameterization.docx -------------------------------------------------------------------------------- /psifunctions/qpPF_GuessLapseParameterization.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrainardLab/mQUESTPlus/adb471184da4d69f9ffaf38ac8d5cd811f5d2b9e/psifunctions/qpPF_GuessLapseParameterization.pdf -------------------------------------------------------------------------------- /psifunctions/qpSimulatedObserver.m: -------------------------------------------------------------------------------- 1 | function outcome = qpSimulatedObserver(stimParams,qpPF,psiParams,varargin) 2 | %qpSimulatedObserver Simulate a trial of an experiment for a passed psychometric funtion 3 | % 4 | % Usage: 5 | % outcome = qpSimulatedObserver(stimParams,qpPF,psiParams 6 | % 7 | % Description: 8 | % Simulate a trial of an experiment to produce an outcome. 9 | % 10 | % Input: 11 | % stimParams Row vector of stimulus parameters 12 | % 13 | % qpPF Handle to a qpPF routine (e.g. qpPFWeibull). 14 | % 15 | % psiParams Row vector of parameters for the passed psychometric 16 | % function. 17 | % 18 | % Output: 19 | % outcome Integer specifying outcome, integer in range 1 to N 20 | % where N is the number of possible outcomes. The 21 | % number and meaning of the outcomes is determined by 22 | % the passed psychometric funtion. 23 | % 24 | % Optional key/value pairs 25 | % None. 26 | 27 | % 6/27/17 dhb Wrote it. 28 | % 6/22/18 dhb Improve help comments. 29 | 30 | %% Parse input 31 | p = inputParser; 32 | p.addRequired('stimParams',@isnumeric); 33 | p.addRequired('qpPF',@(x) isa(x,'function_handle')); 34 | p.addRequired('psiParams',@isnumeric); 35 | p.parse(stimParams,qpPF,psiParams,varargin{:}); 36 | 37 | outcomeProportions = qpPF(stimParams,psiParams); 38 | outcomeVector = mnrnd(1,outcomeProportions); 39 | outcome = find(outcomeVector); -------------------------------------------------------------------------------- /questplus/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - questplus 2 | % 3 | % MATLAB implementation of Watson's QUEST+. The core QUEST+ routines. 4 | % 5 | % The method and Mathematica code are described in the paper: Watson, A. B. 6 | % (2017). "QUEST+: A general multidimensional Bayesian adaptive 7 | % psychometric method". Journal of Vision, 17(3):10, 1-27, 8 | % http://jov.arvojournals.org/article.aspx?articleid=2611972. 9 | % 10 | % See README.md in the mQUESTPlus repository root directory for more info 11 | % (or read it on github at https://github.com/BrainardLab/mQUESTPlus). 12 | % 13 | % A good way to get started with mQUESTPlus is with the demos. See 14 | % "help mQUESTPlus/demos" for a complete list. Two key demos are: 15 | % 16 | % 1) qpQuestPlusPaperSimpleExampleDemo. This runs several of the basic 17 | % demonstrations from the Watson (2017) QUEST+ paper, showing usage for 18 | % function qpRun. 19 | % 20 | % 2) qpQuestPlusCoreFunctionDemo. This illustrates how you can call the 21 | % core functions of mQUESTPlus directly, rather than letting qpRun 22 | % orchestrate your experiment. 23 | % 24 | % qpFit - Maximum likelihood fit of a psychometric 25 | % function to a trial data array. 26 | % qpInitialize - Initialize the questData structure for a 27 | % QUEST+ experiment. 28 | % qpParams - Set user defined parameters for a QUEST+ 29 | % run. Called by qpInitialize. 30 | % qpMarginalizePosterior - Marginalize a posterior over specified 31 | % parameters. 32 | % qpQuery - Use questData structure to get next 33 | % recommended stimulus index and stimulus 34 | % qpRun - High level function that runs a QUEST+ 35 | % experiment. You can call this to run your 36 | % experiment, or you can call the pieces of 37 | % mQUESTPlus separately. See the paper for 38 | % a longer discussion of the role of qpRun 39 | % and alternatives. See 40 | % qpQuestPlusCoreFunctionDemo for example 41 | % of how to call the pieces separately. 42 | % qpUpdate - Update the questData structure for the 43 | % trial stimulus and outcome. 44 | % qpUpdateUpdateExpectedNextEntropiesByStim - Update the table of expected 45 | % next entropies for each stimulus. 46 | -------------------------------------------------------------------------------- /questplus/qpFit.m: -------------------------------------------------------------------------------- 1 | function psiParams = qpFit(trialData,qpPF,startingParams,nOutcomes,varargin) 2 | %qpFit Maximum likelihood fit of a psychometric function to a trial data array 3 | % 4 | % Usage: 5 | % psiParams = qpFit(trialData,qpPF,startingParams,nOutcomes,varargin) 6 | % 7 | % Description: 8 | % Maximum likelihood fit of psychometric functdion parameters to the 9 | % data. This is performed using numerical optimization, with Matlab's 10 | % fmincon. It does a fit over the continuous parameter space, and is 11 | % not limited by the bounds or grid spacing of the parameter grid set 12 | % up for QUEST+. See description below for ways to put bounds on the 13 | % parameters, and otherwise restrict the parameter search space. 14 | % 15 | % It is highly recommended that you pass with key/value pairs sensible 16 | % lower and upper and lower bounds on the parameters. These can be the 17 | % range over which QUEST+ did its work. Without sensible bounds, 18 | % strange and unfortunate things can happen in the search. This is 19 | % particularly true when one or more of the underlying parameters 20 | % should be locked to a particular specified value, which is 21 | % accomplished here when the lower and upper bounds for a parameter are 22 | % equal to each other and to the passed starting value for the 23 | % parameter. 24 | % 25 | % Sometimes just lower and upper bounds are not sufficient to appropriately 26 | % restrict the parameter domain. This routine interpets a vector of NaNs 27 | % returned by the psychometric function to indicate invalid parameters, and 28 | % sets the log likelihood to -1*realmax in these cases. That tends to steer 29 | % the search away from such values. This convention differs from 30 | % the Mathematica implementation. 31 | % 32 | % This routine requires that you have the Matlab optimization toolbox 33 | % installed. 34 | % 35 | % Examples of usage are provided in the demo programs. To get started, 36 | % see qpQuestPlusPaperSimpleExamplesDemo. This provides several 37 | % examples that illustrate setting bounds on the parameters for qpFIt. 38 | % 39 | % Input: 40 | % trialData A trial data struct array: 41 | % trialData(i).stim - Row vector of stimulus parameters. 42 | % trialData(i).outcome - Outcome of the trial. 43 | % 44 | % qpPF Handle to a qpPF routine (e.g. qpPFWeibull). 45 | % 46 | % startingParams Where to start search. Typically this will be the parameter 47 | % estimates obtained by QUEST+. 48 | % 49 | % nOutcomes Number of possible outcomes of the experiment. 50 | % 51 | % Output: 52 | % psiParams Row vector of parameter estimates. 53 | % 54 | % Optional key/value pairs 55 | % 'lowerBounds' Lower bounds for parameters (default []). 56 | % 'upperBounds' Upper bounds for parameters (default []). 57 | % 'diagnostics' Setting for fmincon Diagnostics option (default 'off'). 58 | % Set to 'on' for more verbose fmincon output. Useful 59 | % for debugging. 60 | % 'display' Setting for fmincon Display option (default 'off') 61 | % Set to 'iter' for more verbose fmincon output. 62 | % Useful for debugging. 63 | 64 | % 07/04/17 dhb Wrote it. 65 | % 03/14/18 dhb Pulled out qpFitError so we can call it directly. 66 | % 08/12/19 dhb Noticed should use addParameter not addOptional in 67 | % inputParser setup, and fixed. 68 | 69 | %% Parse input 70 | p = inputParser; 71 | p.addRequired('trialData',@isstruct); 72 | p.addRequired('qpPF',@(x) isa(x,'function_handle')); 73 | p.addRequired('startingParams',@isnumeric); 74 | p.addRequired('nOutcomes',@isscalar) 75 | p.addParameter('upperBounds',[],@isnumeric); 76 | p.addParameter('lowerBounds',[],@isnumeric); 77 | p.addParameter('diagnostics','off',@ischar); 78 | p.addParameter('display','off',@ischar); 79 | p.parse(trialData,qpPF,startingParams,nOutcomes,varargin{:}); 80 | 81 | %% Get stimulus counts 82 | stimCounts = qpCounts(qpData(trialData),nOutcomes); 83 | 84 | %% Set up fmincon 85 | options = optimset('fmincon'); 86 | options = optimset(options,'Diagnostics',p.Results.diagnostics,'Display',p.Results.display,'LargeScale','off','Algorithm','active-set'); 87 | 88 | %% Run fmincon 89 | psiParams = fmincon(@(x)qpFitError(x,stimCounts,qpPF),startingParams,[],[],[],[],p.Results.lowerBounds,p.Results.upperBounds,[],options); 90 | 91 | end 92 | 93 | 94 | -------------------------------------------------------------------------------- /questplus/qpInitialize.m: -------------------------------------------------------------------------------- 1 | function questData = qpInitialize(varargin) 2 | %qpInitialize Initialize the questData structure for a QUEST+ experiment 3 | % 4 | % Usage: 5 | % questData = qpInitialize(questParams); 6 | % 7 | % Description: 8 | % Initialize the data structure for QUEST+ experiment. This begins by 9 | % passing the passed key value pairs through qpParams to set the user 10 | % defined parameters of the experiment. It then initializes the 11 | % necessary fields, starting with those parameters. 12 | % 13 | % See "help qpParams" for a description of the user defined parameters. 14 | % Routine qpParams is called by this routine, and is not typically 15 | % called directly. But all of the key/value pairs you can pass are 16 | % handled and documented there. 17 | % 18 | % In some applications, it may be difficult to arrange a stimulus 19 | % parameterization where all the values on the grid are valid and/or 20 | % represent unique states of the observer. If the psychometric 21 | % function returns a vector of NaNs for these cases, the initialization 22 | % removes from the psychometric function parameter domain those 23 | % parameter sets that lead to a NaN return vector. This convention 24 | % differs from the Mathematica implementation. 25 | % 26 | % See qpQuestPlusCoreFunctionDemo for an example of how qpInitialize 27 | % can be called. 28 | % 29 | % Inputs: 30 | % questParams Optional parameter structure, typically generated with 31 | % function qpParams. The fields need to be keys 32 | % accepted by qpParams. 33 | % 34 | % Outputs: 35 | % questData A structure with all of the fields set by qpParams, plus: 36 | % stimParamsDomain - Matrix defining all of the possible combinations 37 | % of stimulus parameters. This is the cartesian produce of the lists 38 | % set in qpParams. Each row is one possible set of stimulus parameters. 39 | % nStimParamsDomain - The number of stimulus parameter combinations (row 40 | % size of stimParamsDomain. 41 | % nStimParams - Number of stimulus parameters (column size of stimParamsDomain). 42 | % psiParamsDomain - Matrix defining all of the possible combinations 43 | % of psychometric function parameters. This is the cartesian produce of 44 | % the lists set in qpParams. Each row is one possible set of psychometric 45 | % function parameters. 46 | % nPsiParamsDomain - Number of psychometric fuction parameter combinations (row 47 | % size of psiParamsDomain. 48 | % nPsiParams - Number of psychometric function parameters (column size of 49 | % psiParamsDomain). 50 | % logLikelihood - nPsiParamsDomain dimensional column vector with Current natural log likelihood of the data 51 | % for every choice of parameters in psiParamsDomain. Initialized to 0 for each parameter choice. 52 | % posterior - nPsiParamsDomain dimensional column vector with osterior over the 53 | % psychometric function parameters. Initialized according to priorType field which 54 | % is set in qpParams. Typically this is a uniform prior. 55 | % precomputedOutcomeProportions - nStimDomainParams by nPsiParamsDomain by nOutcomes 56 | % matrix giving the predicted proportion of each outcome for every combination of 57 | % stimulus and psychometric function parameters. Initializing this can take a little 58 | % while. 59 | % expectedNextEntropiesByStim - nStimParamsDomain dimensional column vector with 60 | % the expected entropy after presentation of each possible stimulus. This is initialized 61 | % with the initial prior, unless noentropy flag is 62 | % set in which case it is empty. 63 | % 64 | % Optional key/value pairs 65 | % See qpParams for list of key/value pairs that may be specified. 66 | % These are also set as fields in the returned structure. 67 | % 68 | % See also: qpParams, qpUpdate, qpQuery, qpRun. 69 | 70 | % 07/04/17 dhb Sped up using profiler. 71 | % 07/22/17 dhb More flexible stimulus and parameter filtering. 72 | % 08/02/18 dhb Initialize fields used by qpUpdate as th empty matrix. 73 | % 08/16/19 dhb In likelihood compute loop, print out a message every 74 | % minute if questData.verbose is true. 75 | 76 | %% Start with the parameters 77 | % 78 | % Pass any key/value pairs through qpParams to get the fields set by the 79 | % user. 80 | questData = qpParams(varargin{:}); 81 | 82 | %% Convert list specifying stimlus parameters domain ... 83 | % to a matrix, where each row of the matrix is the parameters for one of 84 | % the possible stimuli in the domain. 85 | % 86 | % The idea of using combvec to convert the domain list to this particular 87 | % matrix format, here and just below, originated with a separate and 88 | % earlier (2016) Matlab implementation% of QUEST+ written by P R Jones 89 | % . I subsequently switched to the allcomb function 90 | % from Matlab Central, to avoid making people own the nnet toolbox just to 91 | % have combvec. 92 | stimParamsDomainRaw = allcomb(questData.stimParamsDomainList{:}); 93 | 94 | % Filter stim params domain list, if desired. This may be used, for example, to 95 | % eliminate stimuli that are out of display gamut. 96 | someStimOK = false; 97 | if (~isempty(questData.filterStimParamsDomainFun)) 98 | stimParamsDomainIndex = 1; 99 | for jj = 1:size(stimParamsDomainRaw,1) 100 | stimOK = questData.filterStimParamsDomainFun(stimParamsDomainRaw(jj,:)); 101 | if (stimOK) 102 | someStimOK = true; 103 | questData.stimParamsDomain(stimParamsDomainIndex,:) = stimParamsDomainRaw(jj,:); 104 | stimParamsDomainIndex = stimParamsDomainIndex + 1; 105 | end 106 | end 107 | if (~someStimOK) 108 | error('No stimuli OK'); 109 | end 110 | else 111 | questData.stimParamsDomain = stimParamsDomainRaw; 112 | end 113 | 114 | % Set stim params domain parameters 115 | [questData.nStimParamsDomain,questData.nStimParams] = size(questData.stimParamsDomain); 116 | 117 | %% Convert psi params domain list to a matrix,... 118 | % where each row of the matrix is the parameters for one of 119 | % the possibilities in the domain. 120 | psiParamsDomainRaw = allcomb(questData.psiParamsDomainList{:}); 121 | 122 | % Filter psi params domain list, if desired. This may be used, for example, 123 | % to eliminate parameters that don't make sense but yet show up in the 124 | % parameter hypercube. 125 | if (~isempty(questData.filterPsiParamsDomainFun)) 126 | psiParamsDomainIndex = 1; 127 | for jj = 1:size(psiParamsDomainRaw,1) 128 | paramsOK = questData.filterPsiParamsDomainFun(psiParamsDomainRaw(jj,:)); 129 | if (paramsOK) 130 | questData.psiParamsDomain(psiParamsDomainIndex,:) = psiParamsDomainRaw(jj,:); 131 | psiParamsDomainIndex = psiParamsDomainIndex + 1; 132 | end 133 | end 134 | else 135 | questData.psiParamsDomain = psiParamsDomainRaw; 136 | end 137 | 138 | % Set psi params domain parameters 139 | [questData.nPsiParamsDomain,questData.nPsiParams] = size(questData.psiParamsDomain); 140 | 141 | %% Initilize logLikeihood and posterior 142 | questData.logLikelihood = zeros(questData.nPsiParamsDomain,1); 143 | switch(questData.priorType) 144 | case 'constant' 145 | questData.posterior = qpUnitizeArray(ones(questData.nPsiParamsDomain,1)); 146 | otherwise 147 | error('Unknown prior type specified'); 148 | end 149 | 150 | %% If marginalizing, computer marginal posterior 151 | if (~isempty(questData.marginalize)) 152 | questData.marginalPosterior = qpMarginalizePosterior(questData.posterior,questData.psiParamsDomain,questData.marginalize); 153 | end 154 | 155 | %% Precompute table with expected proportions for each outcome given each stimulus 156 | questData.precomputedOutcomeProportions = ... 157 | zeros(questData.nStimParamsDomain,questData.nPsiParamsDomain,questData.nOutcomes); 158 | 159 | startTime = tic; 160 | lastPrintTime = toc(startTime); 161 | for jj = 1:questData.nPsiParamsDomain 162 | questData.precomputedOutcomeProportions(:,jj,:) = .... 163 | questData.qpPF(questData.stimParamsDomain,questData.psiParamsDomain(jj,:)); 164 | if (any(questData.precomputedOutcomeProportions(:,jj,:) < 0)) 165 | error('Psychometric function has returned negative probability for an outcome'); 166 | end 167 | if (any(questData.precomputedOutcomeProportions(:,jj,:) > 1)) 168 | error('Psychometric function has returned probability that exceeds one for an outcome'); 169 | end 170 | nowTime = toc(startTime); 171 | if (questData.verbose) 172 | if ((nowTime-lastPrintTime) > 60) 173 | fprintf('Computed %d of %d likelihoods, %0.3f percent in %0.3f minutes\n',... 174 | jj,questData.nPsiParamsDomain,100*jj/questData.nPsiParamsDomain,round(nowTime/60)); 175 | fprintf('\tProjected total time: %0.2f days\n',(nowTime/(24*3600))/(jj/questData.nPsiParamsDomain)); 176 | lastPrintTime = nowTime; 177 | end 178 | end 179 | end 180 | 181 | %% Initialize table of expected entropies 182 | if (~questData.noentropy) 183 | questData.expectedNextEntropiesByStim = qpUpdateExpectedNextEntropiesByStim(questData); 184 | else 185 | questData.expectedNextEntropiesByStim = []; 186 | end 187 | 188 | %% Set up fields that will be filled in later 189 | % 190 | % This prevends errors if you try to update the structure in place 191 | % and add those fields. 192 | questData.trialData = []; 193 | questData.stimIndices = []; 194 | questData.entropyAfterTrial = []; 195 | 196 | end 197 | -------------------------------------------------------------------------------- /questplus/qpMarginalizePosterior.m: -------------------------------------------------------------------------------- 1 | function [marginalPosterior,marginalPsiParamsDomain,marginalLabels] = qpMarginalizePosterior(posterior,psiParamsDomain,whichParamsToMarginalize,paramLabels) 2 | % Marginalize a QUEST+ posterior 3 | % 4 | % Syntax: 5 | % [marginalPosterior,marginalPsiParamsDomain,marginalParamLabels] = qpMarginalizePosterio(posterior,psiParamsDomain,whichParamsToMarginalize,[paramLabels]) 6 | % 7 | % Description: 8 | % Sum discretely sampled posterior over parameters indexed by whichParamsToMarginalize, to produce a 9 | % discretely sampled marginal posterior distribution. 10 | % 11 | % Inputs: 12 | % posterior - Matrix where each column give probability of each 13 | % of N possible outcomes. Each column should sum to 14 | % unity. If just one posterior is passed, it 15 | % should be a column vector. 16 | % psiParamsDomain - N by Nparams matrix. Each row gives the 17 | % parameter values for the corresponding entry 18 | % of the posterior. 19 | % whichParamsToMarginalize - Row vector giving K distinct indices in range 20 | % [1,Nparams]. These specify the parameters to 21 | % marginalize over. 22 | % paramLabels - Cell array of string names for each parameter. 23 | % 24 | % Outputs: 25 | % marginalPosterior - Column vector giving probability of each 26 | % of the M outcomes in the marginal posterior. 27 | % marginalPsiParamsDomain - M by NParams-K matrix. Each row gives the 28 | % parameter values for the corresponding entry 29 | % of the marginal posterior. 30 | % marginalParamLabels - If labels passed, this is a cell array of the 31 | % labels for the remaining parameters. If 32 | % labels not passed, returned as the empty cell 33 | % array. 34 | % 35 | % Optional key/value pairs: 36 | % None. 37 | % 38 | % See also: 39 | % 40 | 41 | % History 42 | % 09/09/19 dhb Pulled out of tutorial where I developed the basic code 43 | % 09/22/19 dhb Allow matrix input so we can marginalize multiple 44 | % posteriors. 45 | 46 | %% Get remaining index and handle labels if passed 47 | remainingParamsIndex = setdiff(1:size(psiParamsDomain,2),whichParamsToMarginalize); 48 | if (nargin > 3 && ~isempty(paramLabels)) 49 | marginalLabels = paramLabels{remainingParamsIndex}; 50 | else 51 | marginalLabels = {}; 52 | end 53 | 54 | %% Get full psi params domain but without variables we're going to marginalize 55 | % over 56 | remainingPsiParamsDomain = psiParamsDomain(:,remainingParamsIndex); 57 | 58 | %% Find unique rows in remainingPsiParamsDomain. 59 | % 60 | % Note that: 61 | % uniqueRemainingPsiParamsDomain = remainingPsiParamsDomain(IA,:) 62 | % remainingPsiParamsDomain = uniqueRemainingPsiParamsDomain(IC,:) 63 | [marginalPsiParamsDomain,~,IC] = unique(remainingPsiParamsDomain,'rows','stable'); 64 | 65 | %% Marginalize 66 | % 67 | % Initialize posterior with zeros, and also a counter for a check. 68 | marginalPosterior = zeros(size(marginalPsiParamsDomain,1),size(posterior,2)); 69 | ICEntriesAccountedFor = 0; 70 | %marginalPosterior1 = zeros(size(marginalPsiParamsDomain,1),size(posterior,2)); 71 | 72 | % For each entry in the marginal posterior domain, 73 | % we sum up probabilities over the entries in the full 74 | % posterior that correspond to each entry. 75 | for ii = 1:size(marginalPsiParamsDomain,1) 76 | % Get the index of the entries we need to sum. 77 | % There should always be at least one entry. 78 | startTime = tic; 79 | index = find(IC == ii); 80 | if (isempty(index)) 81 | error('Oops.') 82 | end 83 | 84 | % Now do the summing. Add to the entry of the posterior we're working 85 | % on, and bump counter. 86 | for kk = 1:length(index) 87 | marginalPosterior(ii,:) = marginalPosterior(ii,:) + posterior(index(kk),:); 88 | ICEntriesAccountedFor = ICEntriesAccountedFor + 1; 89 | end 90 | 91 | % This vectorized way seems like it would be faster, but 92 | % it is about twice as slow. There is variation depending 93 | % on the input, though. 94 | % marginalPosterior1(ii,:) = sum(posterior(IC == ii,:),1); 95 | end 96 | 97 | % Check two ways of computing marginal 98 | % 99 | % This check passsed whenI had it in 100 | % if (any(marginalPosterior ~= marginalPosterior1)) 101 | % error('Faster way not working right'); 102 | % end 103 | 104 | % Every entry of the full posterior should have been added once. 105 | if (ICEntriesAccountedFor ~= size(posterior,1)) 106 | error('Did not marginalize properly'); 107 | end 108 | 109 | % The marginal posterior should sum to unity. 110 | posteriorSums = sum(marginalPosterior,1); 111 | if (any(abs(posteriorSums-1) > 1e-8)) 112 | error('At least one computed marginal posterior does not sum to 1'); 113 | end 114 | -------------------------------------------------------------------------------- /questplus/qpParams.m: -------------------------------------------------------------------------------- 1 | function questData = qpParams(varargin) 2 | %qpParams Set user defined parameters for a QUEST+ run. Called by qpInitialize. 3 | % 4 | % Usage: 5 | % questData = qpParams(varargin) 6 | % 7 | % Description: 8 | % Set the user defined parameters needed for a run of QUEST+. These are 9 | % specified by key/value pairs. The defaults are for a simple Weibull 10 | % threshold estimation example. 11 | % 12 | % This works by allowing the user to pass a set of key value pairs for 13 | % each possible user defined parameter. 14 | % 15 | % This routine is not intended to be called directly. Rather, it is 16 | % invoked by qpInitialize, which accepts the same set of key/value 17 | % pairs and passes them through. In addition, qpRun takes the same set 18 | % of parameters and passes them through to qpInitialize. 19 | % 20 | % Inputs: 21 | % None required. See key/value pairs below for what can be set 22 | % 23 | % Outputs: 24 | % questData Structure with one field each corresponding to the 25 | % keys below. Each field has the same name as the 26 | % key. 27 | % 28 | % Optional key/value pairs. 29 | % qpPF Handle to psychometric function. 30 | % qpOutcomeF Handle to function for performaing a trial 31 | % and reporting outcome. We think this is only 32 | % used by qpRun and can be passed as empty if 33 | % you are not using qpRun. 34 | % nOutcomes Number of possible response outcomes for qpPF and 35 | % qpOutcomeF, which should be the same as each other. 36 | % stimParamsDomainList Cell array of row vectors, specifing the domain of each 37 | % stimulus parameter. Note that each stimulus 38 | % on this list is assigned equal prior probability in the standard 39 | % QUEST+ algorithm. Thus the space in which you grid the stimuli 40 | % (e.g. linear versus log) implicitly affects the prior, and it is 41 | % worth a little thought about what space you choose to grid the stimuli 42 | % on. 43 | % filterStimParamsDomainFun Function handle for stimulus domain filtering (default []). 44 | % psiParamsDomainList Cell array of row vectors, specifying the domain of each 45 | % parameter of the psychometric function. 46 | % filterPsiParamDomainFun Function handle for parameter domain filtering (default []). See 47 | % qpQuestPlusCircularCatDemo for a demonstration of the use of this 48 | % key/value pair. 49 | % priorType String specifiying type of prior to use. 50 | % 'constant' - Equal values over all parameter combinations 51 | % stopRule String specifying rule for stopping the run 52 | % 'nTrials' - After specified number of trials. 53 | % chooseRule String specifying how to choose next stimulus (default 'best'). 54 | % 'best' - Take stimulus that maximally reduces expected entropy. 55 | % 'randomFromBestN' - Take stimulus at random from the top N 56 | % with respect to expected next entropy. N is determined by 57 | % chooseRuleN field. 58 | % choseRuleN Integer given the N to choose from, if chooseRule is 'randomFromBestN' 59 | % (default 1). 60 | % verbose Boolean, true for more printout (default false). 61 | % noentropy Boolean (default false). Skip entropy 62 | % computation. This could be useful if you are 63 | % just putting your actual trials into the 64 | % QUEST+ structure, so that you can (e.g.) 65 | % call qpFit. Speeds things up for this case. 66 | % One time you might want to do this is if you 67 | % are running several interleaved quests and 68 | % want to use a single overall quest to keep 69 | % track of all the trials run for later 70 | % analysis. 71 | % marginalize Default empty. If not empty, this is a row 72 | % vector that contains the indices of the 73 | % parameter vector to marginalize over before 74 | % computing entropy. 75 | % 76 | % See also: qpInitialize, qpUpdate, qpQuery, qpRun. 77 | 78 | % 6/30/17 dhb Started on this. 79 | % 7/22/17 dhb Params filtering key/value pairs 80 | 81 | %% Parse inputs and set defaults 82 | p = inputParser; 83 | p.addParameter('qpPF',@qpPFWeibull,@(x) isa(x,'function_handle')); 84 | p.addParameter('qpOutcomeF',@(x) qpSimulatedObserver(x,@qpPFWeibull,[-20, 3.5, .5, .02]),@(x) (isempty(x) | isa(x,'function_handle'))); 85 | p.addParameter('nOutcomes',2,@isscalar); 86 | p.addParameter('stimParamsDomainList',{[-40:1:0]},@iscell); 87 | p.addParameter('filterStimParamsDomainFun',[],@(x) (isempty(x) | isa(x,'function_handle'))); 88 | p.addParameter('psiParamsDomainList',{[-40:1:0], [3.5], [.5], [0.02]},@iscell); 89 | p.addParameter('filterPsiParamsDomainFun',[],@(x) (isempty(x) | isa(x,'function_handle'))); 90 | p.addParameter('priorType','constant',@ischar); 91 | p.addParameter('stopRule','nTrials',@ischar); 92 | p.addParameter('chooseRule','best',@ischar); 93 | p.addParameter('chooseRuleN',1,@isnumeric); 94 | p.addParameter('verbose',false,@islogical); 95 | p.addParameter('noentropy',false,@islogical); 96 | p.addParameter('marginalize',[],@(x) (isempty(x) | isnumeric(x))); 97 | p.parse(varargin{:}); 98 | 99 | %% Return structure 100 | questData = p.Results; 101 | -------------------------------------------------------------------------------- /questplus/qpQuery.m: -------------------------------------------------------------------------------- 1 | function [stimParams,sortedNextEntropies,sortedStimIndices] = qpQuery(questData) 2 | % qpQuery Use questData structure to get next recommended stimulus index and stimulus 3 | % 4 | % Usage: 5 | % [stimParams] = qpQuery(questData) 6 | % [stimParams,sortedNextEntropies,sortedStimIndices] = qpQuery(questData) 7 | % 8 | % Description: 9 | % Use questData structure to get next recommended stimulus. 10 | % The data structure is assumed to be up to date, as after a call to 11 | % qpUpdate. 12 | % 13 | % Inputs: 14 | % questData The questData structure. See qpParams, qpInitialize and qpUpdate for 15 | % description of what is in this structure. 16 | % 17 | % Outputs: 18 | % stimParams The corresponding row vector stimulus parameters. 19 | % 20 | % sortedNextEntropies The expected entropies after the next trial, sorted in 21 | % increasing order. 22 | % 23 | % sortedStimIndices List of indices into questData.stimParamsDomain. First indexes stimulus the 24 | % leads to minimum expected next entropy, second is next best, 25 | % etc. sortedStimIndices(1) should generally be equal to stimIndex, 26 | % unless there were ties in which case bets are off. 27 | % 28 | % Optional key/value pairs: 29 | % None. 30 | % 31 | % See also: qpParams, qpInitialize, qpUpdate, qpRun. 32 | 33 | % 07/22/17 dhb Change to work directly with stim, rather than using stimIndex as a middleman. 34 | 35 | %% Find minimum entropy stimulus entry and get stimulus from index 36 | stimIndex = qpListMinArg(questData.expectedNextEntropiesByStim); 37 | stimParams = qpStimIndexToStim(stimIndex,questData.stimParamsDomain); 38 | 39 | [sortedNextEntropies,sortedStimIndices] = sort(questData.expectedNextEntropiesByStim,'ascend'); 40 | 41 | end 42 | -------------------------------------------------------------------------------- /questplus/qpRun.m: -------------------------------------------------------------------------------- 1 | function questData = qpRun(nTrials,varargin) 2 | %qpRun High level function that runs a QUEST+ experiment 3 | % 4 | % Usage: 5 | % questData = qpQuestPlus(nTrials) 6 | % 7 | % Description: 8 | % This function may be used to orchestrate an experiment using QUEST+. 9 | % The parameters of the experiment are set with key/value pairs. See 10 | % "help qpParams" for more on parameters. 11 | % 12 | % Mpte that qpRun is a high-level interface to QUEST+. See the following demos for 13 | % exammples of its use: 14 | % qpQuestPlusPaperSimpleExamplesDemo 15 | % qpQuestPlusCSFDemo 16 | % qpQuestPlusCircularCatDemo 17 | % 18 | % These demos follow the corresponding ones presented in the paper: 19 | % Watson, A. B. (2017). "QUEST+: A general multidimensional Bayesian 20 | % adaptive psychometric method". Journal of Vision, 17(3):10, 1-27, 21 | % http://jov.arvojournals.org/article.aspx?articleid=2611972. 22 | % 23 | % As also discussed in the paper, you may prefer not to use qpRun 24 | % but rather call the pieces of QUEST+ directly. The demo 25 | % qpQuestPlusCoreFunctionDemo 26 | % shows how to do this. The source code of this function (qpRun) is also 27 | % illustrative in this regard. 28 | % 29 | % Inputs: 30 | % nTrials Number of trials to run. 31 | % 32 | % Outputs: 33 | % questData Structure containing results of the run. 34 | % 35 | % Optional key/value pairs 36 | % See qpParams for list of key/value pairs that may be specified. 37 | % 38 | % See also: qpParams, qpInitialize, qpUpdate, qpQuery. 39 | 40 | % 06/30/17 dhb Started on this. Don't quite have design clear yet. 41 | % 07/07/17 dhb Tidy up. 42 | 43 | %% Initialize. 44 | % 45 | % This can be slow to compute. For a particular project you could save 46 | % it out in a .mat file and load it back in each time it is needed, rather 47 | % than recomputing each time. 48 | questData = qpInitialize(varargin{:}); 49 | if (questData.verbose), fprintf('qpRun:\n'); end 50 | 51 | %% Loop over trials doing smart things each time 52 | for tt = 1:nTrials 53 | % Get stimulus for this trial 54 | if (questData.verbose & rem(tt,10) == 0), fprintf('\tTrial %d, query ...',tt); end 55 | switch (questData.chooseRule) 56 | case 'best' 57 | stim = qpQuery(questData); 58 | 59 | case 'randomFromBestN' 60 | [~,sortedNextEntropies,sortedStimIndices] = qpQuery(questData); 61 | if (size(questData.stimParamsDomain,1) < questData.chooseRuleN) 62 | error('Chosen chooseRuleN is larger than number of available stimuli'); 63 | end 64 | stimIndex = sortedStimIndices(randi(questData.chooseRuleN)); 65 | stim = qpStimIndexToStim(stimIndex,questData.stimParamsDomain); 66 | if (questData.verbose & rem(tt,10) == 0) 67 | fprintf('\n\t\tChoosing stimulus with expected next entropy %0.1f, best would be %0.1f, second best %0.1f, worst %0.1f\n\t\t...', ... 68 | sortedNextEntropies(stimIndex),sortedNextEntropies(1),sortedNextEntropies(2),sortedNextEntropies(end)); 69 | end 70 | 71 | otherwise 72 | error('Unknown choose rule specified'); 73 | end 74 | 75 | % Get outcome 76 | if (questData.verbose & rem(tt,10) == 0), fprintf('simulate ...'); end 77 | outcome = questData.qpOutcomeF(stim); 78 | if (length(outcome) > 1) 79 | error('More than one outcome returned for a single trial'); 80 | end 81 | 82 | % Update quest data structure 83 | if (questData.verbose & rem(tt,10) == 0), fprintf('update ...'); end 84 | questData = qpUpdate(questData,stim,outcome); 85 | if (questData.verbose & rem(tt,10) == 0), fprintf('done\n'); end 86 | end 87 | 88 | end 89 | 90 | -------------------------------------------------------------------------------- /questplus/qpUpdate.m: -------------------------------------------------------------------------------- 1 | function questData = qpUpdate(questData,stim,outcome,varargin) 2 | % qpUpdate Update the questData structure for the trial stimulus and outcome 3 | % 4 | % Usage: 5 | % questData = qpUpdate(questData,stim,outcome) 6 | % 7 | % Description: 8 | % Update the questData strucgure given the stimulus and outcome of 9 | % a trial. Computes the new likelihood of the whole data stream given 10 | % the stimulus/outcomes so far, updates the posterior, entropy, etc. 11 | % 12 | % If qpInitialize was called with 'noentropy' set to true, then entropy 13 | % calculations are skipped. See help on qpParams for a note as to why 14 | % you might want to do this. 15 | % 16 | % Input: 17 | % questData questData structure before the trial. 18 | % 19 | % stim Stimulus parameters on trial (row vector). Must be contained in 20 | % questData.stimParamsDomain, otherwise an error is thrown. 21 | % 22 | % If this is passed as [], the updating by trials is 23 | % skipped, but the final entropy calculation is done, 24 | % even when the noentropy field is true. By controlling 25 | % this carefully along with noentropy, you can update a 26 | % bunch of stimuli in a batch faster than doing all the 27 | % calculations for each stimulus update. Useful 28 | % particularly for simulations where it may be 29 | % efficient to compute responses for many trials of the 30 | % same stimulus in a batch. 31 | % 32 | % outcome What happened on the trial. 33 | % 34 | % Output: 35 | % questData Updated questData structure. This adds and/or keeps up to date the following 36 | % fields of the questData structure. 37 | % trialData - Trial data array, a struct array containing stimulus and outcome for each trial. 38 | % Initialized on the first call and updated thereafter. This has subfields for both stimulus 39 | % and outcome. 40 | % stimIndices - Index into stimulus domain for stimulus used on each trial. This can be useful 41 | % for looking at how much and which parts of the stimulus domain were used in a run. 42 | % logLikelihood - Updated for trial outcome. 43 | % posterior - Update for trial outcome. 44 | % entropyAfterTrial - The entropy of the posterior after the trail. Initialized on the first 45 | % call and updated thereafter. 46 | % expectedNextEntropiesByStim - Updated for trial outcome. 47 | % 48 | % Optional key/value pairs 49 | % None. 50 | % 51 | % See also: qpParams, qpInitialize, qpQuery, qpRun. 52 | 53 | % 07/01/17 dhb Started writing. 54 | % 12/21/17 dhb Make sure to put in stimIndex on first trial. 55 | % 01/14/18 dhb Added check on range of outcome, at suggestion of Denis 56 | % Pelli. 57 | % 04/18/23 dhb Added option to pass stim as empty, to avoid updating but 58 | % still do entropy calculation. 59 | 60 | %% Update for stimulus if not passed as empty 61 | if (~isempty(stim)) 62 | % Get stimulus index from stimulus 63 | stimIndex = qpStimToStimIndex(stim,questData.stimParamsDomain); 64 | if (stimIndex == 0) 65 | error('Trying to update with a stimulus outside the domain'); 66 | end 67 | 68 | % Check for legal outcome 69 | if (round(outcome) ~= outcome | outcome < 1 | outcome > questData.nOutcomes) 70 | error('Illegal value provided for outcome, given initialization'); 71 | end 72 | 73 | % Add trial data to list 74 | % 75 | % Create first element of the array if necessary. 76 | if (isfield(questData,'trialData')) 77 | nTrials = length(questData.trialData); 78 | questData.trialData(nTrials+1,1).stim = stim; 79 | questData.trialData(nTrials+1,1).outcome = outcome; 80 | questData.stimIndices(nTrials+1,1) = stimIndex; 81 | else 82 | nTrials = 0; 83 | questData.trialData.stim = stim; 84 | questData.trialData.outcome = outcome; 85 | questData.stimIndices = stimIndex; 86 | end 87 | 88 | % Update posterior 89 | % 90 | % We have the predicted proportions precomputed for every combintation of 91 | % stimulus parmameters, psi parameters and outcome. So given stimulus index 92 | % and outcome, we just look up the likelihood of the outcome for every set of 93 | % psychometric parameters, multiply by the previous posterior (which we 94 | % take as our prior here, and then normalize to get new posterior.) 95 | questData.posterior = qpUnitizeArray(questData.posterior .* squeeze(questData.precomputedOutcomeProportions(stimIndex,:,outcome))'); 96 | else 97 | % Set nTrials in case of no stim update. We subtract 1 so that when 98 | % 1 gets added back in below it matches current nTrials. 99 | nTrials = length(questData.trialData) - 1; 100 | end 101 | 102 | %% Update table of expected entropies 103 | if (~questData.noentropy || isempty(stim)) 104 | if (~isempty(questData.marginalize)) 105 | questData.marginalPosterior = qpMarginalizePosterior(questData.posterior,questData.psiParamsDomain,questData.marginalize); 106 | questData.entropyAfterTrial(nTrials+1,1) = qpArrayEntropy(questData.marginalPosterior); 107 | questData.expectedNextEntropiesByStim = qpUpdateExpectedNextEntropiesByStim(questData); 108 | else 109 | questData.entropyAfterTrial(nTrials+1,1) = qpArrayEntropy(questData.posterior); 110 | questData.expectedNextEntropiesByStim = qpUpdateExpectedNextEntropiesByStim(questData); 111 | end 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /questplus/qpUpdateExpectedNextEntropiesByStim.m: -------------------------------------------------------------------------------- 1 | function expectedNextEntropiesByStim = qpUpdateExpectedNextEntropiesByStim(questData) 2 | % qpUpdateExpectedNextEntropiesByStim Update the table of expected next entropies for each stimulus 3 | % 4 | % Usage: 5 | % expectedNextEntropiesByStim = qpUpdateExpectedNextEntropiesByStim(questData) 6 | % 7 | % Description: 8 | % Uses posterior and precomputed elements of questData to update the 9 | % table that gives expected entropy after a trial of each stimulus in 10 | % the stimulus parameter domain. 11 | % 12 | % This is broken out into a separate routine so we can call it both 13 | % from qpInitialize and qpUpdate. 14 | % 15 | % This in fact the guts of the QUEST+ method. 16 | % 17 | % Input: 18 | % questData The questData structure. 19 | % 20 | % Output: 21 | % expectedNextEntropiesByStim The updated table. 22 | % 23 | % Optional key/value pairs 24 | % None 25 | 26 | % 07/04/17 dhb Try to make this faster using profile. 27 | % 01/26/18 dhb Vectorized/profiled to optimize execution time. 28 | % 09/22/19 dhb Add code to marginalize next posteriors before entropy 29 | % calculation, if maginalization is specified. 30 | 31 | %% Compute the expected outcomes for each stimulus by averaging over the posterior. 32 | % 33 | % This is vectorized and reasonably optimized. The precompued variable is 34 | % also used below. 35 | precomputedPosteriorTimesProportions = ... 36 | bsxfun(@times,questData.posterior',questData.precomputedOutcomeProportions); 37 | expectedOutcomesByStim = squeeze(sum(precomputedPosteriorTimesProportions,2)); 38 | 39 | %% Compute the entropy for each outcome 40 | % 41 | % This is the entropy we'd get if we run a trial and update assuming that 42 | % outcome. Given an outcome, we can say what the posterior will be. From 43 | % that we can compute the expected entropy given that outcome. We do this 44 | % here. 45 | 46 | % This takes advantage of the precomputed variable from above. 47 | % 48 | % Vectorizing the loop might gain a little time, but qpUnitizeArray and 49 | % qpArrayEntropy each only know about 2-D matrices. Usually nOutcomes is 50 | % small, so the loop doesn't cost too much. 51 | nextEntropiesByStimOutcome = zeros(questData.nStimParamsDomain,questData.nOutcomes); 52 | for oo = 1:questData.nOutcomes 53 | nextPosteriorsByStimOutcome1 = qpUnitizeArray(precomputedPosteriorTimesProportions(:,:,oo)'); 54 | 55 | % If we want to compute the entropy of the marginalized posterior, then 56 | % we need to marginalize each posterior here. 57 | if (~isempty(questData.marginalize)) 58 | nextMarginalPosteriorsByStimOutcome1 = qpMarginalizePosterior(nextPosteriorsByStimOutcome1,questData.psiParamsDomain,questData.marginalize); 59 | nextEntropiesByStimOutcome(:,oo) = qpArrayEntropy(nextMarginalPosteriorsByStimOutcome1)'; 60 | else 61 | nextEntropiesByStimOutcome(:,oo) = qpArrayEntropy(nextPosteriorsByStimOutcome1)'; 62 | end 63 | end 64 | 65 | %% Compute the expected entropy for each stimulus by averaging entropies over each outcome 66 | % 67 | % For each stimulus, we know the probability of each outcome from the first calculation above. 68 | % And for each outcome, we know the entropy we'd get. So we can get the expected entropy 69 | % corresponding to each stimulus, which is what we want. 70 | expectedNextEntropiesByStim = sum(expectedOutcomesByStim .* nextEntropiesByStimOutcome,2); 71 | 72 | end 73 | -------------------------------------------------------------------------------- /utilities/Contents.m: -------------------------------------------------------------------------------- 1 | % mQUESTPlus - utilities 2 | % 3 | % Support utilities. 4 | % 5 | % qpArrayEntropy - Compute the zero order entropy of an array of probabilities. 6 | % qpDrawFromDomainList - Draw a paremter vector from the range specified by the domain list. 7 | % qpFindNearestStimInDomain - Find stimulus in the stimulus domain nearest to the passed stimulus. 8 | % qpFitError - Error function for qpFit. 9 | % qpGetBoundsFromDomainList - Get lower and upper bounds vectors from domain list. 10 | % qpListMaxArg - Return the index of the smallest element in a vector/matrix. 11 | % qpListMinArg - Return the index of the smallest element in a vector/matrix. 12 | % qpLogLikelihood - Compute log likelihood of a stimulus count data array. 13 | % qpNLogP - Compute n*log(p) and handle cases where one or both args are zero. 14 | % qpPosteriorMean - Find the posterior mean 15 | % qpStimIndexToStim - Find stimulus in stimDomain corresonding to stimIndex. 16 | % qpStimToStimIndex - Find index of passed stimulus in the stimulus domain. 17 | % qpUniformArray - Create an array of passed size whose values sum to 1. 18 | % qpUnitizeArray - Scale the passed vector/matrix so that the sum of its entries is 1. -------------------------------------------------------------------------------- /utilities/qpArrayEntropy.m: -------------------------------------------------------------------------------- 1 | function arrayEntropy = qpArrayEntropy(probArray) 2 | %qpArrayEntropy Compute the zero order entropy of an array of probabilities 3 | % 4 | % Usage: 5 | % arrayEntropy = qpArrayEntropy(probArray,varargin) 6 | % 7 | % Description: 8 | % Compute the entropy of the probability values in the passed array, 9 | % with repsect to base 2 (i.e. entropy in bits). 10 | % 11 | % Each column is handled separately. 12 | % 13 | % Input: 14 | % probArray An array of probabilities for the possible outcomes. 15 | % These should sum to 1. 16 | % 17 | % Output: 18 | % arrayEntropy The computed entropy of the array. 19 | % 20 | % Optional key/value pairs 21 | % None 22 | 23 | % 6/23/17 dhb Wrote it. 24 | % 07/04/17 dhb Remove key value pairs to speed this up. 25 | % 01/25/18 dhb Handle each column separately. 26 | 27 | %% Parse input 28 | % 29 | % This routine gets called many many times and should be as fast as 30 | % possible. The input parser is slow. So we forego arg checking and 31 | % optional key/value pairs. The code below shows how they would look. 32 | % 33 | % p = inputParser; 34 | % p.addRequired('probArray',@isnumeric); 35 | % p.parse(probArray,varargin{:}); 36 | 37 | %% Check that probabilities sum to something close to 1 38 | % tolerance = 1e-7; 39 | % assert(abs(sum(probArray(:))-1) < tolerance); 40 | 41 | %% Compute the log probs 42 | logProbs = log2(probArray); 43 | 44 | %% Compute the entropy 45 | % 46 | % Using the nansum skips adding in any terms where the 47 | % probability is zero, where log2(0) returns NaN. This 48 | % is the fastest way I've found of doing this. 49 | arrayEntropy = -nansum(probArray .* logProbs,1); 50 | -------------------------------------------------------------------------------- /utilities/qpDrawFromDomainList.m: -------------------------------------------------------------------------------- 1 | function [v] = qpDrawFromDomainList(domainList) 2 | % Draw parameters from cell array of domain for each parameter 3 | % 4 | % Syntax: 5 | % [v] = qpDrawFromDomainList(domainList) 6 | % 7 | % Description: 8 | % Determine lower and upper parameter bound for each parameter from the 9 | % domain list, and then draw uniformly from the range. 10 | % 11 | % Inputs: 12 | % domainList - Cell array where each entry is 13 | % the domain for the 14 | % corresponding parameter, in the 15 | % form used by qpInitialize. 16 | % 17 | % Outputs: 18 | % v - The random draw in row vector 19 | % form. 20 | % 21 | % Optional key/value pairs: 22 | % None. 23 | % 24 | % See also: qpGetBoundsFromDomainList, qpInitialize 25 | % 26 | 27 | % History: 28 | % 08/25/19 dhb Wrote it 29 | 30 | % Examples: 31 | %{ 32 | psiParamsDomainList = {0, 0, -20:5:20, -20:5:20, 0, -4:1:4, -4:1:4, 0, 0.02}; 33 | v = DrawFromDomainList(psiParamsDomainList) 34 | %} 35 | 36 | [vlb, vub] = qpGetBoundsFromDomainList(domainList); 37 | for ii = 1:length(domainList) 38 | v(ii) = unifrnd(vlb(ii),vub(ii)); 39 | end -------------------------------------------------------------------------------- /utilities/qpFindNearestStimInDomain.m: -------------------------------------------------------------------------------- 1 | function nearestStim = qpFindNearestStimInDomain(stim,stimDomain,varargin) 2 | %qpFindNearestStimInDomain Find stimulus in the stimulus domain nearest to the passed stimulus 3 | % 4 | % Usage: 5 | % nearestStim = qpFindNearestStimInDomain(stim,stimDomain) 6 | % 7 | % Description: 8 | % Take the passed stimulus parameters (row vector) and find out which 9 | % stimulus with the domain is nearest to it, in a squared error sense. 10 | % 11 | % Input: 12 | % stim Row vector of stimulus parameters. 13 | % 14 | % stimDomain Matrix where each row describes one of the possible 15 | % stimuli that quest is dealing with. 16 | % 17 | % Output: 18 | % nearestStim Row vector of nearest stim params. 19 | % 20 | % Optional key/value pairs 21 | % None 22 | % 23 | % See also: qpStimIndexToStim, qpStimToStimIndex 24 | 25 | % 7/22/17 dhb Wrote it. 26 | 27 | %% Initialize 28 | nStim = size(stimDomain,1); 29 | minDistance = Inf; 30 | 31 | %% Search 32 | for ii = 1:nStim 33 | theDistance = norm(stim-stimDomain(ii,:)); 34 | if (theDistance < minDistance) 35 | minDistance = theDistance; 36 | nearestStim = stimDomain(ii,:); 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /utilities/qpFitError.m: -------------------------------------------------------------------------------- 1 | function f = qpFitError(psiParams,stimCounts,qpPF) 2 | % Error function minimized by qpFit to minimize 3 | % 4 | % Syntax: 5 | % f = qpFitError(psiParams,stimCounts,qpPF) 6 | % 7 | % Description: 8 | % Compute negative log likelihood of the data given the parameters 9 | % and the psychometric function. Used by qpFit but may be called 10 | % directly. 11 | % 12 | % Inputs: 13 | % psiParams Psychometric function parameters. 14 | % stimCounts Obtained from qp trial data structure via 15 | % qpCounts(qpData(trialData),nOutcomes); 16 | % qpPF Handle to a qpPF routine (e.g. qpPFWeibull). 17 | % 18 | % Outputs: 19 | % f The negative log likelihood (natural log) 20 | 21 | % History: 22 | % 03/14/18 dhb Pulled out of qpFit so we can call it directly. 23 | 24 | logLikelihood = qpLogLikelihood(stimCounts,qpPF,psiParams); 25 | 26 | %% Handle case where search has wandered into an invalid portion of the parameter spact 27 | % 28 | % qpPF can return NaN to signal this, and that is propagated back through the logLikelihood. 29 | if (isnan(logLikelihood)) 30 | f = realmax; 31 | else 32 | f = -logLikelihood; 33 | end 34 | 35 | end -------------------------------------------------------------------------------- /utilities/qpGetBoundsFromDomainList.m: -------------------------------------------------------------------------------- 1 | function [vlb, vub] = qpGetBoundsFromDomainList(domainList) 2 | % Get parameter bounds from cell array of domain for each parameter 3 | % 4 | % Syntax: 5 | % [vlb, vub] = qpGetBoundsFromDomainList(domainList) 6 | % 7 | % Description: 8 | % Determine lower and upper parameter bound for each parameter from the 9 | % domain list. 10 | % 11 | % Inputs: 12 | % domainList - Cell array where each entry is 13 | % the domain for the 14 | % corresponding parameter, in the 15 | % form used by qpInitialize. 16 | % 17 | % Outputs: 18 | % vlb - Lower bound in row vector form. 19 | % vub - Upper bound in row vector form. 20 | % 21 | % Optional key/value pairs: 22 | % None. 23 | % 24 | % See also: qpDrawFromDomainList, qpInitialize 25 | % 26 | 27 | % History: 28 | % 08/25/19 dhb Wrote it 29 | 30 | % Examples: 31 | %{ 32 | psiParamsDomainList = {0, 0, -20:5:20, -20:5:20, 0, -4:1:4, -4:1:4, 0, 0.02}; 33 | [vlb, vub] = GetBoundsFromDomainList(psiParamsDomainList) 34 | %} 35 | 36 | for ii = 1:length(domainList) 37 | vlb(ii) = min(domainList{ii}); 38 | vub(ii) = max(domainList{ii}); 39 | end -------------------------------------------------------------------------------- /utilities/qpListMaxArg.m: -------------------------------------------------------------------------------- 1 | function maxIndex = qpListMaxArg(theArray,varargin) 2 | %qpListMaxArg Return the index of the largest element in a vector/matrix 3 | % 4 | % Usage: 5 | % maxIndex = qpListMaxArg(theArray) 6 | % 7 | % Description: 8 | % Find the maxIndex to the maximum value in an array. 9 | % 10 | % NOTE: This returns a single number, rather than a 11 | % vector of indices into each dimension into the array, 12 | % and thus differs from the Mathematica version. This is 13 | % the more natural way in Matlab -- it is hard to index 14 | % N-dimensional arrays with a vector in Matlab. 15 | % 16 | % Input: 17 | % theArray An array of values. 18 | % 19 | % Output: 20 | % maxIndex Index to maximum value. 21 | % 22 | % Optional key/value pairs 23 | % None 24 | 25 | % 6/23/17 dhb Wrote it. 26 | 27 | %% Parse input 28 | p = inputParser; 29 | p.parse(varargin{:}); 30 | 31 | %% Get the index 32 | [maxVal,maxIndex] = max(theArray(:)); 33 | -------------------------------------------------------------------------------- /utilities/qpListMinArg.m: -------------------------------------------------------------------------------- 1 | function minIndex = qpListMinArg(theArray,varargin) 2 | %qpListMinArg Return the index of the smallest element in a vector/matrix 3 | % 4 | % Usage: 5 | % minIndex = qpListMinArg(theArray) 6 | % 7 | % Description: 8 | % Find the minIndex to the minimum value in an array. 9 | % 10 | % NOTE: This returns a single number, rather than a 11 | % vector of indices into each dimension into the array, 12 | % and thus differs from the Mathematica version. This is 13 | % the more natural way in Matlab -- it is hard to index 14 | % N-dimensional arrays with a vector in Matlab. 15 | % 16 | % Input: 17 | % theArray An array of values. 18 | % 19 | % Output: 20 | % minIndex Index to minimum value. 21 | % 22 | % Optional key/value pairs 23 | % None 24 | 25 | % 6/23/17 dhb Wrote it. 26 | 27 | %% Parse input 28 | p = inputParser; 29 | p.parse(varargin{:}); 30 | 31 | %% Get the index 32 | [minVal,minIndex] = min(theArray(:)); 33 | -------------------------------------------------------------------------------- /utilities/qpLogLikelihood.m: -------------------------------------------------------------------------------- 1 | function logLikelihood = qpLogLikelihood(stimCounts,qpPF,psiParams,varargin) 2 | %qpLogLikelihood Compute log likelihood of a stimulus count array 3 | % 4 | % Usage: 5 | % logLikelihood = qpLogLikelihood(stimCounts,qpPF,psiParams) 6 | % 7 | % Description: 8 | % Compute log likelihood of the data in stimCounts, with respect to the 9 | % passed psychometric function and paramsVec. 10 | % 11 | % Input: 12 | % stimCounts A struct array with each stimulus value presented 13 | % in sorted order, and a vector of the counts of each possible 14 | % outcome type that happened on trials for that stimulus value: 15 | % stimCounts(i).stim - Row vector of stimulus parameters 16 | % stimCounts(i).outcomeCounts - Row vector of length 17 | % nOutcomes with the number of times each outcome 18 | % happend for the given stimulus. 19 | % 20 | % qpPF Handle to a qpPF routine (e.g. qpPFWeibull). 21 | % 22 | % psiParams Row vector of parameters for the passed psychometric 23 | % function. 24 | % 25 | % Output: 26 | % logLikelihood Log likelihood of the data. If the psychometric function 27 | % returns NaN for any of its inputs, the logLikelihood is returned 28 | % as NaN. 29 | % 30 | % Optional key/value pairs 31 | % check boolean (false) - Run some checks on the data upacking. Slows things down. 32 | 33 | % 6/27/17 dhb Wrote it. 34 | 35 | %% Parse input 36 | p = inputParser; 37 | p.addRequired('stimCounts',@isstruct); 38 | p.addRequired('qpPF',@(x) isa(x,'function_handle')); 39 | p.addRequired('psiParams',@isnumeric); 40 | p.addParameter('check',false,@islogical); 41 | p.parse(stimCounts,qpPF,psiParams,varargin{:}); 42 | 43 | %% Get number of stimuli, stimulus parameter dimension and number of outcomes 44 | nStim = size(stimCounts,1); 45 | stimDim = size(stimCounts(1).stim,2); 46 | nOutcomes = length(stimCounts(1).outcomeCounts); 47 | 48 | %% Get stimulus matrix with parameters along each column. 49 | if (stimDim == 1) 50 | stimMat = [stimCounts.stim]'; 51 | else 52 | stimMat = reshape([stimCounts.stim],stimDim,nStim)'; 53 | end 54 | 55 | %% Get predicted proportions for each stimulus 56 | predictedProportions = qpPF(stimMat,psiParams); 57 | 58 | %% Get the outcomes 59 | % 60 | % The reshape here is a little tricky, but seems to be correct. 61 | % tricky. 62 | nStim = length(stimCounts); 63 | outcomeCounts = reshape([stimCounts(:).outcomeCounts],nOutcomes,nStim)'; 64 | 65 | % Here is a slower way to do it the reshape, but that seems very likely to be correct 66 | % This check passed on 07/06/17 when it seemed like things were generally working. 67 | if (p.Results.check) 68 | outcomeCounts1 = zeros(nStim,nOutcomes); 69 | for ii = 1:nStim 70 | outcomeCounts1(ii,:) = stimCounts(ii).outcomeCounts; 71 | end 72 | if (any(outcomeCounts ~= outcomeCounts1)) 73 | error('Two ways of unpacking outcome counts do not match.'); 74 | end 75 | end 76 | 77 | %% Compute the log likilihood 78 | if (any(isnan(predictedProportions))) 79 | logLikelihood = NaN; 80 | else 81 | nLogP = qpNLogP(outcomeCounts,predictedProportions); 82 | logLikelihood = sum(nLogP(:)); 83 | end 84 | -------------------------------------------------------------------------------- /utilities/qpNLogP.m: -------------------------------------------------------------------------------- 1 | function nLogP = qpNLogP(n,p,varargin) 2 | %qpNLogP Compute n*log(p) and handle cases where one or both args are zero 3 | % 4 | % Usage: 5 | % stimData = qpNLogP(trialData) 6 | % 7 | % Description: 8 | % Compute n*log(p) and handle cases where one or both args are zero. 9 | % 10 | % Input: 11 | % n Number of trials. Can be a number, vector or match. 12 | % p Probability. Needs to be the same size as n. 13 | % 14 | % Both n and p must contain non-negative values. 15 | % 16 | % Output: 17 | % nLogP n*log(p). Same size as n and p. For each entry: 18 | % Returns -1*real max if p == 0 && n > 0. 19 | % Returns 0 if p == 0 && n == 0. 20 | % 21 | % Optional key/value pairs 22 | % None 23 | 24 | % 6/27/17 dhb Wrote it. 25 | 26 | %% Parse input 27 | theP = inputParser; 28 | theP.addRequired('n',@isnumeric); 29 | theP.addRequired('p',@isnumeric); 30 | theP.parse(n,p,varargin{:}); 31 | 32 | %% Sanity check 33 | if (any(n < 0)) 34 | error('Each passed n must be non-negative'); 35 | end 36 | if (any(p < 0)) 37 | error('Each passed p must be non-negative'); 38 | end 39 | sizeN = size(n); 40 | sizeP = size(p); 41 | if (length(sizeN) ~= length(sizeP) | any(sizeN ~= sizeP)) 42 | error('Passed n and p must have the same size'); 43 | end 44 | 45 | %% Follow the conditionals 46 | nLogP = n.*log(p); 47 | index = p == 0 & n > 0; 48 | nLogP(index) = -1*realmax; 49 | index = p == 0 & n == 0; 50 | nLogP(index) = 0; 51 | -------------------------------------------------------------------------------- /utilities/qpPosteriorMean.m: -------------------------------------------------------------------------------- 1 | function posteriorMean = qpPosteriorMean(posterior,psiParamsDomainList,varargin) 2 | %qpPosteriorMean Compute the posterior mean of the PF parameters 3 | % 4 | % Usage: 5 | % posteriorMean = qpPosteriorMean(posterior,psiParamsDomain) 6 | % 7 | % Description: 8 | % Compute the posterior mean 9 | % 10 | % Input: 11 | % posterior Column vector giving posterior over parameters 12 | % This should sum to 1. 13 | % psiParamsDomain Matrix. Each row gives a vector of PF 14 | % parameters. The number of rows should match 15 | % the number of entries in posterior. 16 | % 17 | % Output: 18 | % posteriorMean The mean of the posterior 19 | % 20 | % Optional key/value pairs 21 | % None 22 | 23 | % 09/23/19 dhb Wrote it. 24 | 25 | %% Parse input 26 | % 27 | % This routine gets called many many times and should be as fast as 28 | % possible. The input parser is slow. So we forego arg checking and 29 | % optional key/value pairs. The code below shows how they would look. 30 | % 31 | % p = inputParser; 32 | % p.addRequired('posterior',@isnumeric); 33 | % p.addRequired('psiParamsDomainList',@isnumeric); 34 | % p.parse(probArray,varargin{:}); 35 | 36 | %% Check that probabilities sum to something close to 1 37 | tolerance = 1e-7; 38 | assert(abs(sum(posterior(:))-1) < tolerance); 39 | 40 | %% Dimension checks 41 | if (~iscolumn(posterior)) 42 | error('Passed posterior must be a column vector'); 43 | end 44 | if (length(posterior) ~= size(psiParamsDomainList,1)) 45 | error('Posterior and psiParamsDomainList are not matched in dimensions'); 46 | end 47 | 48 | %% Compute the posterior mean 49 | posteriorMean = sum(bsxfun(@times,psiParamsDomainList,posterior),1); 50 | 51 | -------------------------------------------------------------------------------- /utilities/qpStimIndexToStim.m: -------------------------------------------------------------------------------- 1 | function stim= qpStimIndexToStim(stimIndex,stimDomain,varargin) 2 | %qpStimIndexToStim Find stimulus in stimDomain corresonding to stimIndex 3 | % 4 | % Usage: 5 | % stim = qpStimIndexToStim(stimIndex,stimDomain) 6 | % 7 | % Description: 8 | % Take the passed stimulus index and grab out the stimulus. This is 9 | % so trivial that it is not clear it is worth having a function, but 10 | % there you go. 11 | % 12 | % Input: 13 | % stimIndex Row index into stimDomain where stimulus lives. 14 | % 15 | % stimDomain Matrix where each row describes one of the possible 16 | % stimuli that quest is dealing with. 17 | % 18 | % Output: 19 | % stim Row vector of stimulus parameters. 20 | % 21 | % Optional key/value pairs 22 | % None 23 | % 24 | % See also: qpStimToStimIndex, , qpFindNearestStimInDomain 25 | 26 | % 7/22/17 dhb Wrote it. 27 | 28 | %% Grab stim 29 | stim = stimDomain(stimIndex,:); 30 | -------------------------------------------------------------------------------- /utilities/qpStimToStimIndex.m: -------------------------------------------------------------------------------- 1 | function stimIndex = qpStimToStimIndex(stim,stimDomain,varargin) 2 | %qpStimToStimIndex Find index of passed stimulus in the stimulus domain 3 | % 4 | % Usage: 5 | % stimIndex = qpStimToStimIndex(stim,stimDomain) 6 | % 7 | % Description: 8 | % Take the passed stimulus parameters (row vector) and find out which 9 | % row they are found in, in the passed stimDomain matrix. If there 10 | % is just one parameter, stimDomain must be a column vector and stim 11 | % stim is a scalar. 12 | % 13 | % Does not check if more than one row of stimDomain has the same value, 14 | % just returns the row of the first instance in this case. 15 | % 16 | % Comparison is done to precision significant digits to avoid numerical 17 | % precision weirdness. Precision is set to 10 currently. 18 | % 19 | % Return 0 if stimulus is not found. 20 | % 21 | % Input: 22 | % stim Row vector of stimulus parameters. 23 | % 24 | % stimDomain Matrix where each row describes one of the possible 25 | % stimuli that quest is dealing with. 26 | % 27 | % Output: 28 | % stimIndex Row index into stimDomain where stimulus lives. 29 | % 30 | % Optional key/value pairs 31 | % None 32 | % 33 | % See also: qpStimIndexToStim, qpFindNearestStimInDomain 34 | 35 | % 07/22/17 dhb Wrote it. 36 | % 09/19/19 dhb Added Josh Solomon's fix for handling small numerical 37 | % error in specification of stimDomain. 38 | % 09/20/19 dhb Move to 'significant' type in round, more robust I 39 | % think. 40 | 41 | % Examples: 42 | %{ 43 | stimDomain = (-5:0.2:0)'; 44 | stim = -3.6; 45 | qpStimToStimIndex(stim,stimDomain) 46 | %} 47 | 48 | %% Initialize 49 | nStim = size(stimDomain,1); 50 | stimIndex = 0; 51 | precision = 10; 52 | 53 | %% Search 54 | for ii = 1:nStim 55 | if (all(round(stim,precision,'significant') == round(stimDomain(ii,:),precision,'significant'))) 56 | stimIndex = ii; 57 | return; 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /utilities/qpUniformArray.m: -------------------------------------------------------------------------------- 1 | function uniformArray = qpUniformArray(sz,varargin) 2 | %qpUniformArray Create an array of passed size whose values sum to 1 3 | % 4 | % Usage: 5 | % uniformArray = qpUniformArray(sz) 6 | % 7 | % Description: 8 | % Create an array of passed size whose columns sum to 1. 9 | % 10 | % Input: 11 | % sz N-dimensional row vector with array size along of its N dimensions. 12 | % 13 | % Output: 14 | % uniformArray The desired array. 15 | % 16 | % Optional key/value pairs 17 | % None 18 | 19 | % 6/23/17 dhb Wrote it. 20 | % 01/25/18 dhb Work columnwise. 21 | 22 | %% Parse input 23 | p = inputParser; 24 | p.parse(varargin{:}); 25 | 26 | uniformArray = ones(sz); 27 | uniformArray = qpUnitizeArray(uniformArray); 28 | 29 | -------------------------------------------------------------------------------- /utilities/qpUnitizeArray.m: -------------------------------------------------------------------------------- 1 | function uniformArray = qpUnitizeArray(inputArray) 2 | %qpUnitizeArray Scale the passed vector/matrix so that the sum of its entries is 1 3 | % 4 | % Usage: 5 | % uniformArray = qpUnitizeArray(inputArray) 6 | % 7 | % Description: 8 | % Scale the passed array so that the sum of its entries is 1. 9 | % 10 | % If the entries of the passed array sum to 0, then a uniform array of 11 | % the same size, whose entries sum to 1, is returned. 12 | % 13 | % This operates independently on the columns of its input. 14 | % 15 | % Input: 16 | % inputArray An array of values. 17 | % 18 | % Output: 19 | % uniformArray The normalized array. 20 | % 21 | % Optional key/value pairs 22 | % None 23 | 24 | % 6/23/17 dhb Wrote it. 25 | % 01/25/18 dhb Make it work columnwise. 26 | 27 | %% Parse input 28 | % 29 | % This routine gets called many many times and should be as fast as 30 | % possible. The input parser is slow. So we forego arg checking and 31 | % optional key/value pairs. The code below shows how they would look. 32 | % 33 | % p = inputParser; 34 | % p.parse(varargin{:}); 35 | 36 | %% Get summed values for each column 37 | % 38 | % I fussed with this code in the profiler, but couldn't get it to run 39 | % much faster than it does. The code inside the if doesn't get used very 40 | % often, in cases I tried. I suppose one could live dangerously and not do 41 | % the check for the zero divide. But I don't think pain if it ever failed 42 | % to be caught is worth the risk. 43 | sumOfValues = sum(inputArray,1); 44 | uniformArray = bsxfun(@rdivide,inputArray,sumOfValues); 45 | if (any(sumOfValues == 0)) 46 | index = find(sumOfValues == 0); 47 | [m,~] = size(inputArray); 48 | uniformColumn = qpUniformArray([m 1]); 49 | uniformArray(:,index) = repmat(uniformColumn,1,length(index)); 50 | end 51 | --------------------------------------------------------------------------------