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.
@@ -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