SweetSpot 1.0.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SweetSpot/AdaptiveProcedures.py +791 -0
- SweetSpot/MPLExtras.py +304 -0
- SweetSpot/PsychometricFunctions.py +1111 -0
- SweetSpot/Quest.py +172 -0
- SweetSpot/RandomSeeds.py +123 -0
- SweetSpot/__init__.py +74 -0
- SweetSpot/__main__.py +82 -0
- sweetspot-1.0.1.dist-info/METADATA +67 -0
- sweetspot-1.0.1.dist-info/RECORD +12 -0
- sweetspot-1.0.1.dist-info/WHEEL +6 -0
- sweetspot-1.0.1.dist-info/licenses/LICENSE.txt +121 -0
- sweetspot-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
# $BEGIN_SWEETSPOT_LICENSE$
|
|
2
|
+
#
|
|
3
|
+
# This file is part of the SweetSpot project, a Python package that
|
|
4
|
+
# implements adaptive psychophysical procedures with minimal dependencies.
|
|
5
|
+
#
|
|
6
|
+
# Author: Jeremy Hill (2018-)
|
|
7
|
+
# Development was supported by the NIH, NYS SCIRB, Veterans Affairs RRD,
|
|
8
|
+
# and the Stratton VA Medical Center.
|
|
9
|
+
#
|
|
10
|
+
# No Copyright
|
|
11
|
+
# ============
|
|
12
|
+
# The author has dedicated this work to the public domain under the terms of
|
|
13
|
+
# Creative Commons' CC0 1.0 Universal legal code, waiving all of his rights to
|
|
14
|
+
# the work worldwide under copyright law, including all related and neighboring
|
|
15
|
+
# rights, to the extent allowed by law.
|
|
16
|
+
#
|
|
17
|
+
# You can copy, modify, distribute and perform the work, even for commercial
|
|
18
|
+
# purposes, all without asking permission. See Other Information below.
|
|
19
|
+
#
|
|
20
|
+
# Other Information
|
|
21
|
+
# =================
|
|
22
|
+
# In no way are the patent or trademark rights of any person affected by CC0,
|
|
23
|
+
# nor are the rights that other persons may have in the work or in how the work
|
|
24
|
+
# is used, such as publicity or privacy rights.
|
|
25
|
+
#
|
|
26
|
+
# The author makes no warranties about the work, and disclaims liability for
|
|
27
|
+
# all uses of the work, to the fullest extent permitted by applicable law. When
|
|
28
|
+
# using or citing the work, you are requested to preserve the author attribution
|
|
29
|
+
# and this copyright waiver, but you should not imply endorsement by the author.
|
|
30
|
+
#
|
|
31
|
+
# $END_SWEETSPOT_LICENSE$
|
|
32
|
+
"""
|
|
33
|
+
This submodule defines the abstract `AdaptiveProcedure` base class and two subclasses:
|
|
34
|
+
`WUD` (Kaernbach 1991) and `PsiMarginal` (Prins 2013). It also provides the global
|
|
35
|
+
function `Simulate()` for performance testing.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import absolute_import
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
'AdaptiveProcedure', # generic superclass
|
|
42
|
+
'WUD', 'PsiMarginal', # two example subclasses
|
|
43
|
+
'Simulate',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
import math
|
|
47
|
+
import itertools
|
|
48
|
+
|
|
49
|
+
import numpy # NB: matplotlib is also imported in the particular methods that need it
|
|
50
|
+
|
|
51
|
+
from .PsychometricFunctions import PsychophysicalData, AbstractClassError # used in the AdaptiveProcedure class
|
|
52
|
+
from .PsychometricFunctions import PsychometricFunction, Logistic, Chain # used in the Simulate() function
|
|
53
|
+
from .MPLExtras import ResolveFigure, ResolveAxes, UpdateAxesTitle, MakeSubplots, PdfMaker, KeyboardInteractor, RaiseFigure
|
|
54
|
+
|
|
55
|
+
def ModeEstimate( pr, values, tolerance=1e-10 ):
|
|
56
|
+
maxPr = max( pr.flat )
|
|
57
|
+
return float( numpy.median( [ v for v, p in zip( values.flat, pr.flat ) if abs( p - maxPr ) <= tolerance ] ) )
|
|
58
|
+
|
|
59
|
+
class AdaptiveProcedure( object ):
|
|
60
|
+
"""
|
|
61
|
+
Abstract superclass for a number of different adaptive procedures
|
|
62
|
+
"""
|
|
63
|
+
isStaircase = property( lambda self: True )
|
|
64
|
+
def __init__( self, start, min=None, max=None ):
|
|
65
|
+
self.__start = None
|
|
66
|
+
self.__pendingLevel = None
|
|
67
|
+
self.__dataXRN = []
|
|
68
|
+
self.__reversals = []
|
|
69
|
+
self.__relationships = {}
|
|
70
|
+
self.min = min
|
|
71
|
+
self.max = max
|
|
72
|
+
self.Reset( start )
|
|
73
|
+
|
|
74
|
+
def Reset( self, start=None ):
|
|
75
|
+
if start is not None: self.__start = start
|
|
76
|
+
del self.__reversals[ : ]
|
|
77
|
+
del self.__dataXRN[ : ]
|
|
78
|
+
self.__pendingLevel = self.__start
|
|
79
|
+
return self.__pendingLevel
|
|
80
|
+
|
|
81
|
+
def Observe( self, r, n=1, x=None ):
|
|
82
|
+
"""
|
|
83
|
+
When you have performed a new trial, call:
|
|
84
|
+
|
|
85
|
+
- `.Observe(1)` to record a correct (or positive) response, or
|
|
86
|
+
- `.Observe(0)` to record an incorrect (or negative) response.
|
|
87
|
+
|
|
88
|
+
The trial will be assumed to have occurred with stimulus intensity set to the
|
|
89
|
+
current `.PendingValue()`, unless you explicitly supply its value as `x`.
|
|
90
|
+
|
|
91
|
+
By default, it will be assumed that `n=1` trials were performed at that intensity,
|
|
92
|
+
but you can record a larger batch of trials by specifying a different `n`, in
|
|
93
|
+
which case the first argument `r` should be equal to the number of correct responses.
|
|
94
|
+
(In the internal logic for monitoring "reversals", an observation will be considered
|
|
95
|
+
"successful" if r >= n/2.)
|
|
96
|
+
|
|
97
|
+
After recording the response, the adaptive procedure will automatically check
|
|
98
|
+
whether it is `.Finished()` - if so, this method will return `None`; if not, the
|
|
99
|
+
procedure will call its `.ComputeNextValue()` method and store the result as the
|
|
100
|
+
next pending value, which this method will then return.
|
|
101
|
+
"""
|
|
102
|
+
if self.Finished(): return None
|
|
103
|
+
if n <= 0: return self.__pendingLevel
|
|
104
|
+
outcomeIndex = len( self.__dataXRN )
|
|
105
|
+
if outcomeIndex > 0: # this is the logic for computing whether a trial is a reversal *before* recording nHits and nTrials (so it does not call .Successful() or .IsReversal(), which are designed to be called after)
|
|
106
|
+
xPrev, rPrev, nPrev = self.__dataXRN[ -1 ]
|
|
107
|
+
successfulLastTime = ( rPrev >= 0.5 * nPrev )
|
|
108
|
+
successfulThisTime = ( r >= 0.5 * n )
|
|
109
|
+
if successfulThisTime != successfulLastTime:
|
|
110
|
+
self.__reversals.append( outcomeIndex )
|
|
111
|
+
if x is None: x = self.__pendingLevel
|
|
112
|
+
self.__dataXRN.append( ( float( x ), float( r ), float( n ) ) )
|
|
113
|
+
if self.Finished():
|
|
114
|
+
next = None
|
|
115
|
+
else:
|
|
116
|
+
next = float( self.ComputeNextValue() )
|
|
117
|
+
if self.min is not None: next = max( self.min, next )
|
|
118
|
+
if self.max is not None: next = min( self.max, next )
|
|
119
|
+
self.__pendingLevel = next
|
|
120
|
+
return next
|
|
121
|
+
|
|
122
|
+
def Relate( self, name, base, hitFactor, floor=None, ceiling=None ):
|
|
123
|
+
self.__relationships[ name ] = dict( base=float( base ), hitFactor=float( hitFactor ), floor=floor, ceiling=ceiling )
|
|
124
|
+
|
|
125
|
+
def PhysicalToDetectability( self, physicalValue, relationship ):
|
|
126
|
+
r = self.__relationships[ relationship ]
|
|
127
|
+
physicalValue = numpy.array( physicalValue, dtype=float )
|
|
128
|
+
valid = numpy.array( [ True ] * physicalValue.size )
|
|
129
|
+
detectabilityValue = physicalValue * numpy.nan
|
|
130
|
+
if r[ 'floor' ] is not None: valid[ physicalValue.ravel() < r[ 'floor' ] ] = False
|
|
131
|
+
if r[ 'ceiling' ] is not None: valid[ physicalValue.ravel() > r[ 'ceiling' ] ] = False
|
|
132
|
+
|
|
133
|
+
if r[ 'hitFactor' ] == 1.0: valid.flat = False
|
|
134
|
+
else: detectabilityValue.flat[ valid ] = numpy.log( r[ 'base' ] / physicalValue[ valid ].ravel() ) / numpy.log( r[ 'hitFactor' ] )
|
|
135
|
+
|
|
136
|
+
try: detectabilityValue = float( detectabilityValue )
|
|
137
|
+
except: pass
|
|
138
|
+
return detectabilityValue
|
|
139
|
+
|
|
140
|
+
def DetectabilityToPhysical( self, detectabilityValue, relationship=None ):
|
|
141
|
+
if relationship is None: return dict( [ ( k, self.DetectabilityToPhysical( detectabilityValue, k ) ) for k in self.__relationships ] )
|
|
142
|
+
r = self.__relationships[ relationship ]
|
|
143
|
+
detectabilityValue = numpy.array( detectabilityValue, dtype=float )
|
|
144
|
+
|
|
145
|
+
physicalValue = r[ 'base' ] * r[ 'hitFactor' ] ** -detectabilityValue
|
|
146
|
+
|
|
147
|
+
if r[ 'floor' ] is not None: physicalValue = physicalValue.clip( min=r[ 'floor' ] ) # NB: this is done in two lines because .clip() doesn't seem to behave
|
|
148
|
+
if r[ 'ceiling' ] is not None: physicalValue = physicalValue.clip( max=r[ 'ceiling' ] ) # correctly if performed all in one call with min=nonNone, max=None
|
|
149
|
+
try: physicalValue = float( physicalValue )
|
|
150
|
+
except: pass
|
|
151
|
+
return physicalValue
|
|
152
|
+
|
|
153
|
+
def Observation( self, trialIndex=-1 ):
|
|
154
|
+
try: x, r, n = self.__dataXRN[ trialIndex ]
|
|
155
|
+
except IndexError: return None, None, None
|
|
156
|
+
return x, r, n
|
|
157
|
+
|
|
158
|
+
def IsReversal( self, trialIndex=-1 ): # NB: this is designed as a utility to be called *only* from a subclass's ComputeNextValue - i.e. after the current outcome has been recorded
|
|
159
|
+
if trialIndex < 0: trialIndex += len( self.__dataXRN )
|
|
160
|
+
return trialIndex in self.__reversals
|
|
161
|
+
|
|
162
|
+
def Successful( self, trialIndex=-1 ): # NB: this is designed as a utility to be called *only* from a subclass's ComputeNextValue - i.e. after the current outcome has been recorded
|
|
163
|
+
try: x, r, n = self.__dataXRN[ trialIndex ]
|
|
164
|
+
except IndexError: return None
|
|
165
|
+
return r >= n / 2.0
|
|
166
|
+
|
|
167
|
+
def Data( self ): return PsychophysicalData( data=self.__dataXRN, format='xrn' )
|
|
168
|
+
def NumberOfPoints( self ): return len( self.__dataXRN )
|
|
169
|
+
def Reversals( self ): return tuple( self.__reversals )
|
|
170
|
+
def PendingValue( self ): return self.__pendingLevel
|
|
171
|
+
def OverridePendingValue( self, value ): self.__pendingLevel = value
|
|
172
|
+
def PendingStep( self ):
|
|
173
|
+
if len( self.__dataXRN ) < 1 or self.__pendingLevel is None: return None
|
|
174
|
+
return self.__pendingLevel - self.__dataXRN[ -1 ][ 0 ]
|
|
175
|
+
|
|
176
|
+
def Label( self ):
|
|
177
|
+
return self.__class__.__name__
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def data( self ):
|
|
181
|
+
return self.Data()
|
|
182
|
+
|
|
183
|
+
# overshadow these:
|
|
184
|
+
def Finished( self ): raise AbstractClassError( self )
|
|
185
|
+
def ComputeNextValue( self ): raise AbstractClassError( self )
|
|
186
|
+
def Estimate( self ): raise AbstractClassError( self )
|
|
187
|
+
|
|
188
|
+
#####################################################################################
|
|
189
|
+
|
|
190
|
+
def Test( self, truePF, start='random', n=1, callback=None, parallel=() ):
|
|
191
|
+
"""
|
|
192
|
+
`truePF` can either be a `PsychometricFunction` instance, or a `PsychophysicalData`
|
|
193
|
+
instance---the latter case is just "playback".
|
|
194
|
+
|
|
195
|
+
`start` can be a numeric stimulus intensity value, or it can be `None` (in which
|
|
196
|
+
case the first stimulus value is the current `.PendingValue()`), or it can be
|
|
197
|
+
'random'. Note that 'random' consults the *true* psychometric function to come
|
|
198
|
+
up with a ballpark of where to start---this tacitly assumes some prior knowledge
|
|
199
|
+
about the psychometric function. This is basically a quick hack for non-Bayesian
|
|
200
|
+
methods. If you're using a Bayesian method, prior knowledge would be better
|
|
201
|
+
expressed by being encoding properly in the priors of the `PsychometricFunction`
|
|
202
|
+
instance with which the `AdaptiveProcedure` was initialized, and then letting the
|
|
203
|
+
procedure do its thing naturally with `start=None`.
|
|
204
|
+
|
|
205
|
+
`n` is the number of binary trials per batch of observations. It can be a scalar,
|
|
206
|
+
or it can be a sequence. If it is a sequence, the number of batches is capped
|
|
207
|
+
at the number of elements of `n`. In either case, the procedure will also stop if
|
|
208
|
+
its `.Finished()` method returns true.
|
|
209
|
+
|
|
210
|
+
`callback`, if provided, is called before each observation (or batch of
|
|
211
|
+
observations). When called, its sole argument will be the `AdaptiveProcedure`
|
|
212
|
+
instance `self`. If it returns a true value, the test will abort (this can be
|
|
213
|
+
used, for example, to allow the user to abort manually via an interactive prompt).
|
|
214
|
+
|
|
215
|
+
`parallel`, if provided, should be a sequence of `AdaptiveProcedure` instances.
|
|
216
|
+
Each such instance also has `.Observe()` called for each batch of observations.
|
|
217
|
+
This can be useful, for example, to allow a `PsiMarginal` instance to track the
|
|
218
|
+
expected entropy of the parameters, even when the stimulus intensity decisions
|
|
219
|
+
are being driven by a different algorithm `self`.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
playbackData = None
|
|
223
|
+
parallel = set( parallel )
|
|
224
|
+
parallel.add( self )
|
|
225
|
+
if isinstance( truePF, PsychophysicalData ):
|
|
226
|
+
playbackData, truePF = truePF, None
|
|
227
|
+
n = playbackData.n.flat
|
|
228
|
+
start = playbackData.x.flat[ 0 ]
|
|
229
|
+
else:
|
|
230
|
+
try: len( n )
|
|
231
|
+
except: n = itertools.repeat( n )
|
|
232
|
+
if start == 'random':
|
|
233
|
+
lower, = truePF.Inverse( f=0.5 ).flat
|
|
234
|
+
upper, = truePF.Inverse( f=0.95 ).flat
|
|
235
|
+
upper = lower + ( upper - lower ) * 3
|
|
236
|
+
mean = ( lower + upper ) / 2.0
|
|
237
|
+
std = ( upper - lower ) / 6.0
|
|
238
|
+
start = numpy.random.normal() * std + mean
|
|
239
|
+
for ap in parallel: ap.Reset( start=start )
|
|
240
|
+
for i, ni in enumerate( n ):
|
|
241
|
+
if callback and callback( self ): break
|
|
242
|
+
if self.Finished(): break
|
|
243
|
+
if playbackData:
|
|
244
|
+
xi = playbackData.x.flat[ i ]
|
|
245
|
+
ri = playbackData.r.flat[ i ]
|
|
246
|
+
else:
|
|
247
|
+
xi = self.PendingValue()
|
|
248
|
+
ri = truePF.SimulatedResponse( x=xi, n=ni )
|
|
249
|
+
for ap in parallel: ap.Observe( x=xi, r=ri, n=ni )
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def PlotStaircase( self, figure=None, axes=None, hold=False, title=None, raiseFigure=False, **kwargs ):
|
|
253
|
+
xline, yline, xhits, yhits, xmisses, ymisses, xreversals, yreversals = [], [], [], [], [], [], [], []
|
|
254
|
+
for i, ( x, r, n ) in enumerate( self.__dataXRN ):
|
|
255
|
+
xline += [ i, i + 1 ]; yline += [ float( x ), float( x ) ]
|
|
256
|
+
if self.Successful( i ): xhits.append( i + 1 ); yhits.append( float( x ) )
|
|
257
|
+
else: xmisses.append( i + 1 ); ymisses.append( float( x ) )
|
|
258
|
+
if self.IsReversal( i ): xreversals.append( i + 1 ); yreversals.append( float( x ) )
|
|
259
|
+
axes = ResolveAxes( figure=figure, axes=axes, hold=hold )
|
|
260
|
+
args = dict( kwargs ); args[ 'marker' ] = 'None'
|
|
261
|
+
line, = axes.plot( xline, yline, **args )
|
|
262
|
+
color = line.get_color()
|
|
263
|
+
kwargs.setdefault( 'color', color )
|
|
264
|
+
kwargs.setdefault( 'marker', 's' )
|
|
265
|
+
kwargs.setdefault( 'markersize', 10 )
|
|
266
|
+
kwargs.update( dict( linestyle='none', mec=color, mfc=color ) )
|
|
267
|
+
hits, = axes.plot( xhits, yhits, **kwargs )
|
|
268
|
+
kwargs.update( dict( mfc='w' ) )
|
|
269
|
+
misses, = axes.plot( xmisses, ymisses, **kwargs )
|
|
270
|
+
kwargs.update( dict( mfc='none', markersize=kwargs[ 'markersize' ] * 2 ) )
|
|
271
|
+
reversals, = axes.plot( xreversals, yreversals, **kwargs )
|
|
272
|
+
if self.Finished():
|
|
273
|
+
est = self.Estimate()
|
|
274
|
+
if isinstance( est, float ):
|
|
275
|
+
kwargs.update( dict( marker='None', linestyle='--', linewidth=2 ) )
|
|
276
|
+
estimate = axes.plot( [0, i + 1], [ est, est ], **kwargs )
|
|
277
|
+
axes.set( xlabel='Trial Number', ylabel='Stimulus Level' )
|
|
278
|
+
if title: axes.set_title( title )
|
|
279
|
+
if raiseFigure: RaiseFigure( axes.figure )
|
|
280
|
+
|
|
281
|
+
class WUD( AdaptiveProcedure ):
|
|
282
|
+
"""
|
|
283
|
+
The Weighted Up-Down of Kaernbach (1991).
|
|
284
|
+
|
|
285
|
+
- Kaernbach C (1991): Simple adaptive testing with the weighted up-down method.
|
|
286
|
+
Perception & Psychophysics 49(3):227-9. https://doi.org/10.3758/bf03214307
|
|
287
|
+
|
|
288
|
+
WUD( start=None, hitEffect=-0.1 ).Test( PsychometricFunction( gamma=0.01, lambda_=0.01 ) ).PlotStaircase()
|
|
289
|
+
"""
|
|
290
|
+
isStaircase = property( lambda self: True )
|
|
291
|
+
def __init__( self, start, hitEffect, target=0.75, reversals=8, reversalsToUse=6, multiplicative=False ):
|
|
292
|
+
self.hitEffect = hitEffect
|
|
293
|
+
self.target = target
|
|
294
|
+
self.numberOfReversals = reversals
|
|
295
|
+
self.reversalsToUse = reversalsToUse
|
|
296
|
+
self.multiplicative = multiplicative
|
|
297
|
+
if self.multiplicative and self.hitEffect <= 0.0: raise ValueError( 'in multiplicative mode, hitEffect must be > 0' )
|
|
298
|
+
if self.hitEffect == 0.0 and not self.multiplicative: raise ValueError( 'hitEffect=0.0 has no effect in additive mode' )
|
|
299
|
+
if self.hitEffect == 1.0 and self.multiplicative: raise ValueError( 'hitEffect=1.0 has no effect in multiplicative mode' )
|
|
300
|
+
AdaptiveProcedure.__init__( self, start=start )
|
|
301
|
+
|
|
302
|
+
def Reset( self, start=None ):
|
|
303
|
+
self.adjustedHitEffect = self.hitEffect
|
|
304
|
+
AdaptiveProcedure.Reset( self, start=start )
|
|
305
|
+
|
|
306
|
+
def ToAdditiveSpace( self, value ): return math.log( value ) if self.multiplicative else value
|
|
307
|
+
def FromAdditiveSpace( self, value ): return math.exp( value ) if self.multiplicative else value
|
|
308
|
+
|
|
309
|
+
def ComputeNextValue( self ):
|
|
310
|
+
if self.IsReversal():
|
|
311
|
+
revNumber = len( self.Reversals() ) # (one-based) reversal counter
|
|
312
|
+
# TODO: could include a rule for narrowing self.adjustedHitEffect after a certain number of reversals based on revNumber
|
|
313
|
+
step = self.ToAdditiveSpace( self.adjustedHitEffect )
|
|
314
|
+
xPrev, _, _ = self.Observation( trialIndex=-1 )
|
|
315
|
+
if not self.Successful( trialIndex=-1 ): step *= -self.target / ( 1.0 - self.target )
|
|
316
|
+
return self.FromAdditiveSpace( self.ToAdditiveSpace( xPrev ) + step )
|
|
317
|
+
|
|
318
|
+
def Finished( self ):
|
|
319
|
+
return len( self.Reversals() ) >= self.numberOfReversals
|
|
320
|
+
|
|
321
|
+
def Estimate( self, mode='median' ):
|
|
322
|
+
if not self.Finished(): raise RuntimeError( "staircase is not finished" )
|
|
323
|
+
if mode == 'all': return [ ( mode, self.Estimate( mode ) ) for mode in [ 'median' ] ]
|
|
324
|
+
reversals = [ self.Observation( index )[ 0 ] for index in self.Reversals()[ -self.reversalsToUse: ] ]
|
|
325
|
+
if mode == 'median': return float( numpy.median( reversals ) )
|
|
326
|
+
raise ValueError( 'unrecognized mode %r' % mode )
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class PsiMarginal( AdaptiveProcedure ):
|
|
330
|
+
"""
|
|
331
|
+
A discrete grid approximation to the psi-marginal method of Prins (2013).
|
|
332
|
+
|
|
333
|
+
- Prins N (2013): The psi-marginal adaptive method: How to give nuisance
|
|
334
|
+
parameters the attention they deserve (no more, no less).
|
|
335
|
+
Journal of Vision 13(7):3. https://doi.org/10.1167/13.7.3
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
isStaircase = property( lambda self: False )
|
|
339
|
+
def __init__( self, pf, xCandidates, start='auto', maxIterations=50, blockSize=1, mode='MAP', mercy=0.0, maxNegativeStep=None, pdfFile=None, parameterOfInterest='alpha' ):
|
|
340
|
+
"""
|
|
341
|
+
The first argument, `pf`, is a PsychometricFunction instance which must be
|
|
342
|
+
initialized with:
|
|
343
|
+
|
|
344
|
+
- a shape, operating point and experimental design, e.g.
|
|
345
|
+
`pf = PsychometricFunction( shape=Logistic( 0.5 ), gamma=0.5 )`
|
|
346
|
+
|
|
347
|
+
- a search grid, defined via `pf.Grid()`
|
|
348
|
+
|
|
349
|
+
- prior information on parameter spread, defined via `pf.SetPrior()` (unless
|
|
350
|
+
you explicitly and genuinely want flat priors)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
The second argument `xCandidates`, should be a discrete array of possible
|
|
354
|
+
stimulus intensity values to consider.
|
|
355
|
+
|
|
356
|
+
The `mode` argument can be 'MLE', 'MAP' or 'AVG' and it does no more than
|
|
357
|
+
dictate the default value of the `mode` argument to `.Estimate()`.
|
|
358
|
+
|
|
359
|
+
The `.ComputeNextValue()` method chooses the stimulus value that minimizes
|
|
360
|
+
expected posterior entropy. Two options, implemented in the `.Psychology()`
|
|
361
|
+
method, can be turned on to modulate this slightly, in an attempt to minimize
|
|
362
|
+
the psychological impact of order effects:
|
|
363
|
+
|
|
364
|
+
- with `mercy > 0.0`, following an incorrect response, "difficult" stimulus
|
|
365
|
+
values (i.e. those for whom `F(x)<mercy` according to current estimates,
|
|
366
|
+
or harder than the one you just got wrong) are not considered, in an
|
|
367
|
+
attempt to avoid subjects getting too discouraged.
|
|
368
|
+
|
|
369
|
+
- if `maxNegativeStep` (default: `None`) is set to a numeric value, then the
|
|
370
|
+
stimulus intensity will never be reduced (or, more accurately, will never be
|
|
371
|
+
changed in the direction that `self.pf.flipX` dictates is "more difficult")
|
|
372
|
+
by more than the specified amount in a single step.
|
|
373
|
+
|
|
374
|
+
"""
|
|
375
|
+
self.pf = pf
|
|
376
|
+
self.xCandidates = xCandidates
|
|
377
|
+
self.parameterOfInterest = parameterOfInterest
|
|
378
|
+
self.maxIterations = maxIterations
|
|
379
|
+
self.maxNegativeStep = maxNegativeStep
|
|
380
|
+
self.blockSize = blockSize
|
|
381
|
+
self.mode = mode.upper()
|
|
382
|
+
self.pdfFile = None
|
|
383
|
+
self.pdfFileName = None
|
|
384
|
+
self.entropyHistory = []
|
|
385
|
+
self.mercy = mercy
|
|
386
|
+
if self.mode not in [ 'MAP', 'MLE', 'AVG' ]: raise ValueError( "mode should be 'MAP', 'MLE' or 'AVG'" )
|
|
387
|
+
if start is None: start = 'auto'
|
|
388
|
+
if pdfFile:
|
|
389
|
+
self.pdfFileName = pdfFile
|
|
390
|
+
self.pdfFile = PdfMaker.MultipagePdfHandle( pdfFile )
|
|
391
|
+
AdaptiveProcedure.__init__( self, start=start )
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def mercy( self ):
|
|
395
|
+
"""
|
|
396
|
+
A value in the range [0.0, 1.0) - non-zero values enable a heuristic that
|
|
397
|
+
attempts to prevent subjects getting overly discouraged by failure (see
|
|
398
|
+
class constructor doc).
|
|
399
|
+
"""
|
|
400
|
+
return self.__mercy
|
|
401
|
+
@mercy.setter
|
|
402
|
+
def mercy( self, value ):
|
|
403
|
+
if isinstance( value, bool ) and value: value = 0.5 # default value for mercy=True
|
|
404
|
+
if not ( value < 1.0 ): raise ValueError( 'mercy must be < 1.0' )
|
|
405
|
+
if value < 0.0: raise ValueError( 'mercy cannot be negative (you scare me)' )
|
|
406
|
+
self.__mercy = value
|
|
407
|
+
|
|
408
|
+
def Label( self ):
|
|
409
|
+
t = self.__class__.__name__
|
|
410
|
+
poi = self.parameterOfInterest
|
|
411
|
+
if isinstance( poi, str ): poi = poi.replace( ',', ' ' ).split()
|
|
412
|
+
t += '(%s)' % ','.join( poi )
|
|
413
|
+
return t
|
|
414
|
+
|
|
415
|
+
def Observe( self, r, n=1, x=None ):
|
|
416
|
+
if x is None: x = self.PendingValue()
|
|
417
|
+
self.pf.Observe( x=x, r=r, n=n )
|
|
418
|
+
result = AdaptiveProcedure.Observe( self, r=r, n=n, x=x )
|
|
419
|
+
self.entropyHistory.append( self.entropy )
|
|
420
|
+
PdfMaker.DrawToFile( self.pdfFile, self.PlotPredictions )
|
|
421
|
+
if self.Finished(): self.ClosePdfFile()
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
def Reset( self, start=None ):
|
|
425
|
+
self.pf.Reset()
|
|
426
|
+
auto = ( isinstance( start, str ) and start == 'auto' )
|
|
427
|
+
if auto: start = None
|
|
428
|
+
AdaptiveProcedure.Reset( self, start=start ) # blank slate
|
|
429
|
+
recommendation = self.ComputeNextValue() # computes entropy etc as a side effect
|
|
430
|
+
if auto: AdaptiveProcedure.Reset( self, start=recommendation )
|
|
431
|
+
self.entropyHistory[ : ] = [ self.entropy ]
|
|
432
|
+
PdfMaker.DrawToFile( self.pdfFile, self.pf.VisualizeDistributions, self.PlotPredictions )
|
|
433
|
+
|
|
434
|
+
def Finished( self ):
|
|
435
|
+
#uncertainty = self.entropy
|
|
436
|
+
# TODO: could set a criterion based on uncertainty
|
|
437
|
+
return self.NumberOfPoints() >= self.maxIterations
|
|
438
|
+
|
|
439
|
+
def ComputeNextValue( self ):
|
|
440
|
+
self.expectedEntropy = self.pf.ExpectedEntropy( x=self.xCandidates, n=self.blockSize, params=self.parameterOfInterest )
|
|
441
|
+
self.adjustedExpectedEntropy = self.expectedEntropy.copy()
|
|
442
|
+
self.Psychology() # if this method finds self.adjustedExpectedEntropy, it will adjust it
|
|
443
|
+
return self.xCandidates[ numpy.nanargmin( self.adjustedExpectedEntropy ) ]
|
|
444
|
+
|
|
445
|
+
def WhicheverIsHardest( self, *x ):
|
|
446
|
+
x = [ xi for xi in x if xi is not None ]
|
|
447
|
+
if len( x ) == 0: return None
|
|
448
|
+
if len( x ) == 1:
|
|
449
|
+
try: float( x[ 0 ] )
|
|
450
|
+
except: pass
|
|
451
|
+
else: return x[ 0 ]
|
|
452
|
+
return max( *x ) if self.pf.flipX else min( *x )
|
|
453
|
+
def WhicheverIsEasiest( self, *x ):
|
|
454
|
+
x = [ xi for xi in x if xi is not None ]
|
|
455
|
+
if len( x ) == 0: return None
|
|
456
|
+
if len( x ) == 1:
|
|
457
|
+
try: float( x[ 0 ] )
|
|
458
|
+
except: pass
|
|
459
|
+
else: return x[ 0 ]
|
|
460
|
+
return min( *x ) if self.pf.flipX else max( *x )
|
|
461
|
+
def IsHarder ( self, x, thanWhat): return ( x > thanWhat ) if self.pf.flipX else ( x < thanWhat )
|
|
462
|
+
def IsEasier ( self, x, thanWhat): return ( x < thanWhat ) if self.pf.flipX else ( x > thanWhat )
|
|
463
|
+
def MakeHarder ( self, x, byHowMuch): return ( x + byHowMuch ) if self.pf.flipX else ( x - byHowMuch )
|
|
464
|
+
def MakeEasier ( self, x, byHowMuch): return ( x - byHowMuch ) if self.pf.flipX else ( x + byHowMuch )
|
|
465
|
+
|
|
466
|
+
def Psychology( self ):
|
|
467
|
+
"""
|
|
468
|
+
Overshadowable method implementing non-Bayesian heuristics that are theoretically
|
|
469
|
+
non-optimal but which we suspect help to patch non-stationarities in human behavior.
|
|
470
|
+
"""
|
|
471
|
+
lastX, lastR, lastN = self.Observation( -1 )
|
|
472
|
+
hardestAllowableX = []
|
|
473
|
+
debug = False
|
|
474
|
+
|
|
475
|
+
# (A) `mercy > 0.0` means "do not present a 'difficult' stimulus immediately after a miss".
|
|
476
|
+
# The motivation is to avoid subjects losing heart (the short-term learned helplessness of
|
|
477
|
+
# "this is stupid, there's nothing on the screen!"):
|
|
478
|
+
if self.mercy > 0.0:
|
|
479
|
+
psychologicalF = self.mercy # let's use an absolute definition of the operating point for psychological
|
|
480
|
+
# purposes, rather than whatever the self.pf.shape happens to have configured.
|
|
481
|
+
# The value we choose here is a psychological hyperparameter, up for debate...
|
|
482
|
+
# 0.5 might be a sensible value to try.
|
|
483
|
+
cutoffPerformanceLevel = self.pf.OperatingPoint( psychologicalF, scaled=True )
|
|
484
|
+
if lastX is not None and float( lastR ) / lastN < cutoffPerformanceLevel: # if the last observation can be considered negative
|
|
485
|
+
|
|
486
|
+
# Two criteria are applied, to exclude stimuli that are...
|
|
487
|
+
# (1) harder than the one you just missed:
|
|
488
|
+
hardestAllowableX.append( lastX )
|
|
489
|
+
if debug: print( '%+4.2f is a bound because that was the value of the missed stimulus' % hardestAllowableX[ -1 ] )
|
|
490
|
+
|
|
491
|
+
# or (2) predicted "hard" according to the model's best (marginalized) guess;
|
|
492
|
+
|
|
493
|
+
# Here is the old method - evaluate the marginalized prediction at all xCandidates and compare
|
|
494
|
+
# against the cutoff - which only works for entropy-/candidate-based methods. For other methods,
|
|
495
|
+
# (what we *really* want to do is invert the marginalized prediction, which we could in principle
|
|
496
|
+
# do by a bisection algorithm...)
|
|
497
|
+
#xCandidates = numpy.asarray( self.xCandidates )
|
|
498
|
+
#marginalizedPrediction = self.pf.MarginalizedPrediction( xCandidates )
|
|
499
|
+
#allowedX = xCandidates[ marginalizedPrediction >= cutoffPerformanceLevel ]
|
|
500
|
+
#hardestAllowableX.append( self.WhicheverIsHardest( *allowedX ) )
|
|
501
|
+
# NB: the even the MarginalizedPrediction(xCandidates) call above is somewhat inefficient in
|
|
502
|
+
# entropy-based procedures since something very similar to this will have been computed
|
|
503
|
+
# inside ExpectedEntropy() (but note that it is only executed here after negative trials).
|
|
504
|
+
# It added about 170ms in one 2018/2019 measurement with resolutions
|
|
505
|
+
# x=100, alpha=100, beta=10, gamma=6, lambda_=6
|
|
506
|
+
|
|
507
|
+
# ...but a quicker general solution than inverting the marginalized prediction (not exactly
|
|
508
|
+
# equivalent, but hey we're in heuristicville anyway) is to get a single set of
|
|
509
|
+
# posterior-averaged parameter values and invert the single function they define:
|
|
510
|
+
averagePF = PsychometricFunction(
|
|
511
|
+
shape = self.pf.shape,
|
|
512
|
+
preprocessing = self.pf.preprocessing,
|
|
513
|
+
flipX = self.pf.flipX,
|
|
514
|
+
**self.pf.AveragedParameters( 'all', 'posterior', asDict=True )
|
|
515
|
+
)
|
|
516
|
+
hardestAllowableX.append( float( averagePF.Inverse( f=psychologicalF ).item() ) )
|
|
517
|
+
|
|
518
|
+
if debug: print( '%+4.2f is a bound because anything beyond that is probably "hard"' % hardestAllowableX[ -1 ] )
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# (B) `maxNegativeStep` can be used to prevent surprisingly/offputtingly large sudden increases
|
|
522
|
+
# in difficulty:
|
|
523
|
+
if lastX is not None and self.maxNegativeStep:
|
|
524
|
+
deltaX = abs( self.maxNegativeStep )
|
|
525
|
+
hardestAllowableX.append( self.MakeHarder( lastX, deltaX ) )
|
|
526
|
+
if debug: print( '%+4.2f is a bound because anything beyond that would be too big a step' % hardestAllowableX[ -1 ] )
|
|
527
|
+
|
|
528
|
+
hardestAllowableX = self.WhicheverIsEasiest( *hardestAllowableX )
|
|
529
|
+
if hardestAllowableX is not None and getattr( self, 'adjustedExpectedEntropy', None ) is not None:
|
|
530
|
+
if debug: print( 'final exclusion cutoff: %+4.2f' % hardestAllowableX )
|
|
531
|
+
exclude = self.IsHarder( numpy.asarray( self.xCandidates ), hardestAllowableX )
|
|
532
|
+
self.adjustedExpectedEntropy[ exclude ] = numpy.nan
|
|
533
|
+
# if this wipes out the minimum, an entropy-based procedure is free to choose the next local minimum, which is not necessarily hardestAllowableX
|
|
534
|
+
|
|
535
|
+
return hardestAllowableX # non entropy-based methods can use this return value to clamp their stimulus recommendation
|
|
536
|
+
|
|
537
|
+
def Estimate( self, mode=None, params=None ):
|
|
538
|
+
if params is None: params = self.parameterOfInterest
|
|
539
|
+
if isinstance( params, str ): params = params.replace( ',', ' ' ).split()
|
|
540
|
+
|
|
541
|
+
if mode is None: mode = self.mode
|
|
542
|
+
if mode.upper() == 'ALL': return [ ( mode, self.Estimate( mode ) ) for mode in 'MLE MAP AVG'.split() ]
|
|
543
|
+
elif mode.upper() == 'AVG':
|
|
544
|
+
avg = self.pf.AveragedParameters( params, 'posterior', asDict=True )
|
|
545
|
+
if len( params ) == 1: return list( avg.values() )[ 0 ]
|
|
546
|
+
else: return avg
|
|
547
|
+
elif mode.upper() == 'CI':
|
|
548
|
+
if len( params ) > 1: raise NotImplementedError( 'CI mode only implemented for one parameterOfInterest at a time' ) # need to find the maximum over the multi-dimensional distro and the respective parameter values corresponding to the coordinates of that point in the different dimensions
|
|
549
|
+
return self.pf.PosteriorConfidenceInterval( params, 0.95 )
|
|
550
|
+
elif mode.upper() == 'MAP': distro = self.pf.LogPosterior()
|
|
551
|
+
elif mode.upper() == 'MLE': distro = self.pf.LogScore()
|
|
552
|
+
else: raise ValueError( 'unknown mode %r' % mode )
|
|
553
|
+
distro = self.pf.Distribution( distro, params=params )
|
|
554
|
+
if len( params ) == 1:
|
|
555
|
+
paramIndex, possibleParameterValues = self.pf.FindParameter( params[ 0 ] )
|
|
556
|
+
return ModeEstimate( distro.ravel(), possibleParameterValues.ravel() )
|
|
557
|
+
else:
|
|
558
|
+
maxLocation = numpy.unravel_index( numpy.argmax( distro ), distro.shape )
|
|
559
|
+
best = {}
|
|
560
|
+
for param in params:
|
|
561
|
+
paramIndex, possibleParameterValues = self.pf.FindParameter( param )
|
|
562
|
+
best[ param ] = float( possibleParameterValues.flat[ maxLocation[ paramIndex + 1 ] ] )
|
|
563
|
+
return best
|
|
564
|
+
|
|
565
|
+
def PlotInference( self, parameterName, figure=None, axes=None ):
|
|
566
|
+
possibleParameterValues = self.pf.FindParameter( parameterName )[ 1 ].ravel()
|
|
567
|
+
distros = dict(
|
|
568
|
+
prior = self.pf.Distribution( self.pf.LogPrior(), params=parameterName ).ravel(),
|
|
569
|
+
likelihood = self.pf.Distribution( self.pf.LogScore(), params=parameterName ).ravel(),
|
|
570
|
+
posterior = self.pf.Distribution( self.pf.LogPosterior(), params=parameterName ).ravel(),
|
|
571
|
+
)
|
|
572
|
+
axes = ResolveAxes( figure=figure, axes=axes, hold=False )
|
|
573
|
+
for k, v in distros.items(): axes.plot( possibleParameterValues, v, label=parameterName + ' ' + k )
|
|
574
|
+
axes.legend()
|
|
575
|
+
|
|
576
|
+
def PlotPredictions( self, truePF=None, figure=None, title='iterations done: {}', addSeparateVisualizerLabel=False ):
|
|
577
|
+
|
|
578
|
+
params = self.parameterOfInterest
|
|
579
|
+
if isinstance( params, str ): params = params.replace( ',', ' ' ).split()
|
|
580
|
+
if len( params ) > 1:
|
|
581
|
+
raise ValueError( 'PlotPredictions() cannot plot when there is more than one parameter of interest' )
|
|
582
|
+
# If you came here from `Simulate()`, consider preparing your own multidimensional `PsiMarginal`
|
|
583
|
+
# instance and passing it as the `driver`, instead of passing `parameterOfInterest='alpha,beta'
|
|
584
|
+
# to the function directly. Then your `PsiMarginal` can drive and the default one-dimensional
|
|
585
|
+
# `PsiMarginal` will tag along and call `PlotPredictions()`
|
|
586
|
+
possibleParameterValues = self.pf.FindParameter( self.parameterOfInterest )[ 1 ].ravel()
|
|
587
|
+
posterior = self.pf.Distribution( self.pf.LogPosterior(), params=self.parameterOfInterest ).ravel()
|
|
588
|
+
likelihood = self.pf.Distribution( self.pf.LogScore(), params=self.parameterOfInterest ).ravel()
|
|
589
|
+
uncertainty = self.entropyHistory[ -1 ]
|
|
590
|
+
marginalizedPrediction = self.pf.MarginalizedPrediction( self.xCandidates )
|
|
591
|
+
|
|
592
|
+
mle = ModeEstimate( likelihood, possibleParameterValues )
|
|
593
|
+
map = ModeEstimate( posterior, possibleParameterValues )
|
|
594
|
+
avg = self.pf.AveragedParameters( self.parameterOfInterest, 'posterior', asDict=False )[ 0 ]
|
|
595
|
+
|
|
596
|
+
data = self.Data()
|
|
597
|
+
xlim = [ min( self.xCandidates ), max( self.xCandidates ) ]
|
|
598
|
+
lastXstr = ( '%4g' % data.x.ravel()[ -1 ] ) if data.NumberOfPoints() else 'None'
|
|
599
|
+
figure, subplots = MakeSubplots( 3, figure=figure, hspace=0.4 )
|
|
600
|
+
axes = subplots.flat[ 0 ]
|
|
601
|
+
if axes:
|
|
602
|
+
axes.plot( self.xCandidates, marginalizedPrediction, label=( self.Label() + ' m' if addSeparateVisualizerLabel else 'M' ) + 'arginalized prediction' )
|
|
603
|
+
data.Plot( axes=axes, hold=True )
|
|
604
|
+
if truePF: truePF.Plot( axes=axes, hold=True, xlim=xlim, label='True function' )
|
|
605
|
+
if data.NumberOfPoints(): axes.plot( data.x[ -1 ], data.y[ -1 ], marker='x', color='r', markersize=15, clip_on=False )
|
|
606
|
+
axes.set( xlim=xlim, ylim=[ -0.04, 1.04 ], xlabel='x', ylabel='p', title=title.format( data.NumberOfPoints() ) )
|
|
607
|
+
axes.legend( loc='lower left' if self.xCandidates[ numpy.argmin( marginalizedPrediction ) ] > self.xCandidates[ numpy.argmax( marginalizedPrediction ) ] else 'lower right' )
|
|
608
|
+
axes = subplots.flat[ 1 ]
|
|
609
|
+
if axes:
|
|
610
|
+
try: hLike = axes.stem( possibleParameterValues, likelihood, label='Likelihood', use_line_collection=True )
|
|
611
|
+
except: hLike = axes.stem( possibleParameterValues, likelihood, label='Likelihood' )
|
|
612
|
+
try: hPost = axes.stem( possibleParameterValues, posterior, label='Posterior', use_line_collection=True )
|
|
613
|
+
except: hPost = axes.stem( possibleParameterValues, posterior, label='Posterior' ) # you backward-compatibility-breaking mfs
|
|
614
|
+
|
|
615
|
+
hLike.baseline.set_visible( False ); hPost.baseline.set_visible( False )
|
|
616
|
+
try: hLike = [ hLike.markerline ] + list( hLike.stemlines )
|
|
617
|
+
except: hLike = [ hLike.markerline, hLike.stemlines ]
|
|
618
|
+
for h in hLike: h.set_color( 'r' )
|
|
619
|
+
if self.parameterOfInterest == 'alpha' and not self.pf.preprocessing: axes.set_xlim( xlim )
|
|
620
|
+
axes.set( xlabel=self.parameterOfInterest, title=( self.Label() + ' scores -- ' if addSeparateVisualizerLabel else '' ) + 'MLE = %4g; MAP = %4g; AVG = %4g; entropy = %4g' % ( mle, map, avg, uncertainty ) )
|
|
621
|
+
axes.grid( True )
|
|
622
|
+
axes.legend()
|
|
623
|
+
axes = subplots.flat[ 2 ]
|
|
624
|
+
if axes:
|
|
625
|
+
axes.plot( self.xCandidates, self.expectedEntropy, 'b:' )
|
|
626
|
+
axes.plot( self.xCandidates, self.adjustedExpectedEntropy, 'bx-' ) # NaN for "low" stimuli after a miss, if psimarg was initialized with mercy > 0.0
|
|
627
|
+
pv = self.PendingValue()
|
|
628
|
+
pv = 'None' if pv is None else '%4g' % pv
|
|
629
|
+
axes.set( xlim=xlim, xlabel='potential next x', ylabel='expected entropy (%s)' % self.parameterOfInterest, title=( self.Label() + ' objective function -- ' if addSeparateVisualizerLabel else '' ) + 'last x = %s; next x = %s' % ( lastXstr, pv ) )
|
|
630
|
+
axes.grid( True )
|
|
631
|
+
figure.tight_layout()
|
|
632
|
+
figure.canvas.draw()
|
|
633
|
+
|
|
634
|
+
@property
|
|
635
|
+
def entropy( self ):
|
|
636
|
+
value, = self.pf.Entropy( self.pf.LogPosterior(), params=self.parameterOfInterest ).flat
|
|
637
|
+
return float( value )
|
|
638
|
+
|
|
639
|
+
def __del__( self ):
|
|
640
|
+
self.ClosePdfFile()
|
|
641
|
+
|
|
642
|
+
def ClosePdfFile( self ):
|
|
643
|
+
if self.pdfFile:
|
|
644
|
+
self.pdfFile.close()
|
|
645
|
+
self.pdfFile = None
|
|
646
|
+
|
|
647
|
+
# def Demo() has now been renamed to:
|
|
648
|
+
def Simulate( forcedAlternatives=0, interactive=True, truePF=None, xCandidates=None, shape=None, preprocessing=None, f0=None, start='random', blockSize=1, driver=None, figure=None, gridArgs=(), priorArgs=(), pdfFile=None, **kwargs ):
|
|
649
|
+
"""
|
|
650
|
+
`forcedAlternatives` is the number of nAFC alternatives---for subjective designs,
|
|
651
|
+
pass either 0 or 1 (doesn't matter which).
|
|
652
|
+
|
|
653
|
+
`interactive` can be False, True, 'go' or 'stop' (True is the same as 'stop').
|
|
654
|
+
|
|
655
|
+
`truePF` if you cannot supply a true `PsychometricFunction` instance, one will be
|
|
656
|
+
appointed for you by the court.
|
|
657
|
+
|
|
658
|
+
`xCandidates` is the finite set of stimulus intensity values to consider.
|
|
659
|
+
|
|
660
|
+
`start` can be a numeric stimulus intensity value, or it can be `None` (in which
|
|
661
|
+
case the first stimulus value is the current `.PendingValue()`), or it can be
|
|
662
|
+
'random'. Note that 'random' consults the *true* psychometric function to come
|
|
663
|
+
up with a ballpark of where to start---this tacitly assumes some prior knowledge
|
|
664
|
+
about the psychometric function. This is basically a quick hack for non-Bayesian
|
|
665
|
+
methods. If you're using a Bayesian method, prior knowledge would be better
|
|
666
|
+
expressed by being encoding properly in the priors of the `PsychometricFunction`
|
|
667
|
+
instance with which the `AdaptiveProcedure` was initialized, and then letting the
|
|
668
|
+
procedure do its thing naturally with `start=None`.
|
|
669
|
+
|
|
670
|
+
`f0` is the operating point of the `PsychometricLinkFunction` instance that will
|
|
671
|
+
be used for fitting.
|
|
672
|
+
|
|
673
|
+
`driver` is an instance of an AdaptiveProcudure subclass that is responsible for
|
|
674
|
+
choosing the next stimulus value. If `driver` is omitted (None), then the `PsiMarginal`
|
|
675
|
+
method is used. Even if a different `driver` is specified, a `PsiMarginal` instance
|
|
676
|
+
will still run in parallel for visualization purposes (it just will not drive the
|
|
677
|
+
decisions).
|
|
678
|
+
|
|
679
|
+
`figure` is a `matplotlib.Figure` instance or a positive integer denoting a
|
|
680
|
+
matplotlib figure number.
|
|
681
|
+
|
|
682
|
+
`**kwargs` are passed forward into the `PsiMarginal` constructor (whether or not
|
|
683
|
+
`PsiMarginal` is the driver).
|
|
684
|
+
|
|
685
|
+
Return value: `(driver, psimarg)` (If `PsiMarginal` *was* the driver, these will be
|
|
686
|
+
two references to the same instance.)
|
|
687
|
+
"""
|
|
688
|
+
gridArgs = dict( gridArgs )
|
|
689
|
+
gridArgs.setdefault( 'alpha', ( -5, +5, 101 ) )
|
|
690
|
+
gridArgs.setdefault( 'beta', ( -4, +4, 31 ) )
|
|
691
|
+
gridArgs.setdefault( 'gamma', ( 0.001, 0.101, 11 ) )
|
|
692
|
+
gridArgs.setdefault( 'lambda_', ( 0.001, 0.101, 11 ) )
|
|
693
|
+
priorArgs = dict( priorArgs )
|
|
694
|
+
priorArgs.setdefault( 'alpha', ( 0.0, 2.0 ) )
|
|
695
|
+
priorArgs.setdefault( 'beta', ( 0.0, 1.0 ) )
|
|
696
|
+
priorArgs.setdefault( 'gamma', ( 0.0, 0.01 ) )
|
|
697
|
+
priorArgs.setdefault( 'lambda_', ( 0.0, 1.0 ) ) # let's be pretty agnostic about lambda
|
|
698
|
+
|
|
699
|
+
forcedAlternatives = int( forcedAlternatives )
|
|
700
|
+
if forcedAlternatives in [ 0, 1 ]:
|
|
701
|
+
gamma = 0.01
|
|
702
|
+
else:
|
|
703
|
+
gamma = 1.0 / forcedAlternatives
|
|
704
|
+
gridArgs[ 'gamma' ] = None
|
|
705
|
+
priorArgs[ 'gamma' ] = None
|
|
706
|
+
|
|
707
|
+
# with f0 = 0.5, the x=alpha point is around 50% positive for Yes/No, 75% correct for 2AFC, etc. (give or take the effects of gamma and/or lambda)
|
|
708
|
+
if truePF is None:
|
|
709
|
+
if shape is None: shape = Logistic
|
|
710
|
+
if f0 is None: f0 = 0.5 if isinstance( shape, type ) else shape.f0 # ready-made instances will have an .f0
|
|
711
|
+
if not isinstance( shape, type ): shape = type( shape )
|
|
712
|
+
truePF = PsychometricFunction( alpha=-2.5, shape=shape( f0 ), preprocessing=preprocessing, gamma=gamma, lambda_=0.01 )
|
|
713
|
+
|
|
714
|
+
if preprocessing is None: preprocessing = truePF.preprocessing
|
|
715
|
+
if shape is None: shape = truePF.shape
|
|
716
|
+
if f0 is None: f0 = 0.5 if isinstance( shape, type ) else shape.f0
|
|
717
|
+
if not isinstance( shape, type ): shape = type( shape )
|
|
718
|
+
|
|
719
|
+
if xCandidates is None:
|
|
720
|
+
xCandidates = numpy.linspace( -5, 5, 51 )
|
|
721
|
+
xCandidates = Chain( xCandidates, preprocessing, inverse=True )
|
|
722
|
+
|
|
723
|
+
psimarg = PsiMarginal( # this 1-D PsiMarginal will be the visualizer regardless of driver, and is also the default driver
|
|
724
|
+
parameterOfInterest = 'alpha',
|
|
725
|
+
xCandidates = xCandidates,
|
|
726
|
+
blockSize = blockSize, # used in expected entropy calculations (how many new trials to expect)
|
|
727
|
+
pf = PsychometricFunction(
|
|
728
|
+
gamma = gamma,
|
|
729
|
+
shape = shape( f0 ),
|
|
730
|
+
preprocessing = preprocessing,
|
|
731
|
+
).Grid( **gridArgs ).SetPrior( **priorArgs ),
|
|
732
|
+
**kwargs
|
|
733
|
+
)
|
|
734
|
+
if driver is None:
|
|
735
|
+
driver = psimarg
|
|
736
|
+
elif driver in [ '1D', '1-D' ]:
|
|
737
|
+
driver = psimarg
|
|
738
|
+
elif driver in [ '2D', '2-D' ]:
|
|
739
|
+
driver = PsiMarginal(
|
|
740
|
+
parameterOfInterest = [ 'alpha', 'beta' ],
|
|
741
|
+
xCandidates = xCandidates,
|
|
742
|
+
blockSize = blockSize, # used in expected entropy calculations (how many new trials to expect)
|
|
743
|
+
pf = PsychometricFunction(
|
|
744
|
+
shape = shape( f0 ),
|
|
745
|
+
preprocessing = preprocessing,
|
|
746
|
+
gamma = gamma,
|
|
747
|
+
).Grid( **gridArgs ).SetPrior( **priorArgs ),
|
|
748
|
+
**kwargs
|
|
749
|
+
)
|
|
750
|
+
elif isinstance( driver, type ):
|
|
751
|
+
driver = driver()
|
|
752
|
+
else:
|
|
753
|
+
pass # assume `driver` is a ready-made instance
|
|
754
|
+
|
|
755
|
+
if pdfFile and not interactive: raise NotImplementedError( 'for Simulate(), we must be in interactive mode to save a pdf' )
|
|
756
|
+
# NB: currently there's no provision for saving a `pdfFile` if we're NOT `interactive`.
|
|
757
|
+
# While this would be easy to support for simple cases (just pass the filename
|
|
758
|
+
# `pdfFile` to the `PsiMarginal` constructor), it would get misleading in cases
|
|
759
|
+
# where `Simulate()` is using a separate driver and visualizer (the visualizer
|
|
760
|
+
# `psimarg` would be the one to save the figures, and those figures would contain
|
|
761
|
+
# no visible acknowledgment that `psigmarg` was not itself the driver; they would
|
|
762
|
+
# also not show the truePF).
|
|
763
|
+
|
|
764
|
+
kb = None
|
|
765
|
+
callback = None
|
|
766
|
+
if callable( interactive):
|
|
767
|
+
callback = interactive
|
|
768
|
+
elif interactive:
|
|
769
|
+
if pdfFile: pdfFile = PdfMaker.MultipagePdfHandle( pdfFile )
|
|
770
|
+
figure = ResolveFigure( figure, defaultFigureSizeInches=[ 10, 8.6 ], interactive=True )
|
|
771
|
+
RaiseFigure()
|
|
772
|
+
if interactive == True: interactive = 'stop'
|
|
773
|
+
kb = KeyboardInteractor( state=interactive, figure=figure )
|
|
774
|
+
def callback( driver ):
|
|
775
|
+
with PdfMaker( pdfFile, figure=figure ):
|
|
776
|
+
psimarg.PlotPredictions( truePF=truePF, figure=figure, title=driver.Label() + ' iterations done: {}', addSeparateVisualizerLabel=( driver is not psimarg ) )
|
|
777
|
+
if driver.Finished(): state, stopping = 'finished', True
|
|
778
|
+
elif kb and not kb.KeyboardAdvance( waitingStatus='waiting for user...' ): state, stopping = 'aborted', True
|
|
779
|
+
else: state, stopping = 'computing...', False # TODO: this status could be force-drawn with figure.canvas.flush_events(), but displayed somewhere else so as not to make the title jump around.
|
|
780
|
+
if figure.axes: UpdateAxesTitle( figure.axes[ 0 ], '{} - ' + state )
|
|
781
|
+
return stopping
|
|
782
|
+
driver.Test( truePF, start=start, n=blockSize, callback=callback, parallel=[ psimarg ] )
|
|
783
|
+
if kb: kb.QToClose()
|
|
784
|
+
if pdfFile and hasattr( pdfFile, 'close' ): pdfFile.close()
|
|
785
|
+
|
|
786
|
+
visQualifier = ''
|
|
787
|
+
if driver is not psimarg:
|
|
788
|
+
print( 'Estimate from driver %s:\n\t%s' % ( driver.Label(), driver.Estimate() ) )
|
|
789
|
+
visQualifier = 'visualizer '
|
|
790
|
+
print( 'Estimates from %s%s\n\t%s' % ( visQualifier, psimarg.Label(), psimarg.Estimate( 'all' ) ) )
|
|
791
|
+
return driver, psimarg
|