Skip to content

Commit 58beaa8

Browse files
authored
Merge pull request #184 from pberkes/lambda_gamma_in_sigmoid
Move rescaling of psychometric function related to lambda and gamma in `Sigmoid` object
2 parents 2673ee0 + b47307b commit 58beaa8

File tree

5 files changed

+107
-52
lines changed

5 files changed

+107
-52
lines changed

psignifit/_posterior.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ def log_posterior(data: np.ndarray, sigmoid: Sigmoid, priors: Dict[str, Prior],
146146

147147
if gamma is None:
148148
gamma = lambd
149-
scale = 1 - gamma - lambd
150149

151150
pbin = 0
152151
p = 0
@@ -159,7 +158,7 @@ def log_posterior(data: np.ndarray, sigmoid: Sigmoid, priors: Dict[str, Prior],
159158
if trials == 0:
160159
continue
161160

162-
psi = sigmoid(level, thres, width) * scale + gamma
161+
psi = sigmoid(level, thres, width, gamma=gamma, lambd=lambd)
163162

164163
# Separate cases to avoid warnings problems with np.log(...)
165164
# for correct_trials == 0 or (trials - correct_trials) == 0

psignifit/psigniplot.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,13 @@ def plot_psychometric_function(result: Result, # noqa: C901, this function is t
5656
x = np.linspace(x_data.min(), x_data.max(), num=1000)
5757
x_low = np.linspace(x[0] - extrapolate_stimulus * (x[-1] - x[0]), x[0], num=100)
5858
x_high = np.linspace(x[-1], x[-1] + extrapolate_stimulus * (x[-1] - x[0]), num=100)
59-
y = sigmoid(np.r_[x_low, x, x_high], params['threshold'], params['width'])
60-
y = (1 - params['gamma'] - params['lambda']) * y + params['gamma']
59+
y = sigmoid(
60+
np.r_[x_low, x, x_high],
61+
threshold=params['threshold'],
62+
width=params['width'],
63+
gamma=params['gamma'],
64+
lambd=params['lambda'],
65+
)
6166
ax.plot(x, y[len(x_low):-len(x_high)], c=line_color, lw=line_width, clip_on=False)
6267
ax.plot(x_low, y[:len(x_low)], '--', c=line_color, lw=line_width, clip_on=False)
6368
ax.plot(x_high, y[-len(x_high):], '--', c=line_color, lw=line_width, clip_on=False)
@@ -110,8 +115,13 @@ def _plot_residuals(x_values: np.ndarray,
110115
data = result.data
111116
sigmoid = result.configuration.make_sigmoid()
112117

113-
std_model = params['gamma'] + (1 - params['lambda'] - params['gamma']) * sigmoid(
114-
data[:, 0], params['threshold'], params['width'])
118+
std_model = sigmoid(
119+
data[:, 0],
120+
threshold=params['threshold'],
121+
width=params['width'],
122+
gamma=params['gamma'],
123+
lambd=params['lambda'],
124+
)
115125
deviance = data[:, 1] / data[:, 2] - std_model
116126
std_model = np.sqrt(std_model * (1 - std_model))
117127
deviance = deviance / std_model
@@ -349,8 +359,13 @@ def plot_prior(result: Result,
349359
for param_value, color in zip(x_percentiles, colors):
350360
this_sigmoid_params = dict(sigmoid_params)
351361
this_sigmoid_params[param] = param_value
352-
y = sigmoid(sigmoid_x, this_sigmoid_params['threshold'], this_sigmoid_params['width'])
353-
y = (1 - estimate['gamma'] - this_sigmoid_params['lambda']) * y + estimate['gamma']
362+
y = sigmoid(
363+
sigmoid_x,
364+
threshold=this_sigmoid_params['threshold'],
365+
width=this_sigmoid_params['width'],
366+
gamma=estimate['gamma'],
367+
lambd=this_sigmoid_params['lambda'],
368+
)
354369
plt.plot(sigmoid_x, y, linewidth=line_width, color=color)
355370

356371
plt.scatter(data[:, 0], np.zeros(data[:, 0].shape), s=marker_size*.75, c='k', clip_on=False)
@@ -457,7 +472,7 @@ def plot_bias_analysis(data: np.ndarray, compare_data: np.ndarray,
457472

458473
fig = plt.figure(constrained_layout=True, figsize=(5, 15))
459474
gs = fig.add_gridspec(6, 1)
460-
475+
461476
ax1 = fig.add_subplot(gs[0:2, 0])
462477
plot_psychometric_function(result_combined, ax=ax1, estimate_type=estimate_type)
463478
plot_psychometric_function(result_data, ax=ax1, line_color=[1, 0, 0], data_color=[1, 0, 0],
@@ -469,25 +484,25 @@ def plot_bias_analysis(data: np.ndarray, compare_data: np.ndarray,
469484
ax3 = fig.add_subplot(gs[3, 0])
470485
ax4 = fig.add_subplot(gs[4, 0])
471486
ax5 = fig.add_subplot(gs[5, 0])
472-
487+
473488
axesmarginals = [ax2, ax3, ax4, ax5]
474-
489+
475490
for param, ax in zip(['threshold', 'width', 'lambda', 'gamma'], axesmarginals):
476491

477-
plot_marginal(result_combined, param, ax=ax, plot_prior=False,
492+
plot_marginal(result_combined, param, ax=ax, plot_prior=False,
478493
line_color=[0, 0, 0], estimate_type=estimate_type,
479494
plot_ci=False)
480-
495+
481496
plot_marginal(result_data, param, ax=ax, plot_prior=False,
482-
line_color=[1, 0, 0], estimate_type=estimate_type,
497+
line_color=[1, 0, 0], estimate_type=estimate_type,
483498
plot_ci=False)
484-
485-
499+
500+
486501
plot_marginal(result_compare_data, param, ax=ax, plot_prior=False,
487502
line_color=[0, 0, 1], estimate_type=estimate_type,
488503
plot_ci=False)
489-
504+
490505
for ax in axesmarginals:
491506
ax.autoscale()
492-
507+
493508

psignifit/sigmoids.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,33 +58,38 @@ def __eq__(self, o: object) -> bool:
5858
and o.alpha == self.alpha
5959
and o.negative == self.negative)
6060

61-
def __call__(self, stimulus_level: N, threshold: N, width: N) -> N:
61+
def __call__(self, stimulus_level: N, threshold: N, width: N, gamma: N = 0, lambd: N = 0) -> N:
6262
""" Calculate the sigmoid value at specified stimulus levels.
6363
64+
See Eq 1 in Schuett, Harmeling, Macke and Wichmann (2016).
65+
6466
Args:
65-
stimulus_level: Stimulus level value at which to calculate the slope
66-
threshold: Parameter value for threshold at PC
67-
width: Parameter value for width of the sigmoid
67+
stimulus_level: Stimulus level value
68+
threshold: Threshold at `PC`
69+
width: Width of the sigmoid
70+
gamma: Guess rate (lower asymptote of the sigmoid)
71+
lambd: Lapse rate (upper asymptote of the sigmoid)
6872
Returns:
6973
Proportion correct at the stimulus values.
7074
"""
7175

7276
value = self._value(stimulus_level, threshold, width)
77+
value = gamma + (1.0 - lambd - gamma) * value
7378

7479
if self.negative:
75-
return 1 - value
76-
else:
77-
return value
80+
value = 1 - value
81+
82+
return value
7883

7984
def slope(self, stimulus_level: N, threshold: N, width: N, gamma: N = 0, lambd: N = 0) -> N:
8085
""" Calculate the slope at specified stimulus levels.
8186
8287
Args:
8388
stimulus_level: Stimulus level value at which to calculate the slope
84-
threshold: Parameter value for threshold at PC
85-
width: Parameter value for width of the sigmoid
86-
gamma: Parameter value for the lower offset of the sigmoid
87-
lambd: Parameter value for the upper offset of the sigmoid
89+
threshold: Threshold at `PC`
90+
width: Width of the sigmoid
91+
gamma: Guess rate (lower asymptote of the sigmoid)
92+
lambd: Lapse rate (upper asymptote of the sigmoid)
8893
Returns:
8994
Slope at the stimulus values.
9095
"""
@@ -105,10 +110,10 @@ def inverse(self, prop_correct: N, threshold: N, width: N,
105110
106111
Args:
107112
prop_correct: Proportion correct at the threshold to calculate.
108-
threshold: Parameter value for threshold at PC
109-
width: Parameter value for width of the sigmoid
110-
gamma: Parameter value for the lower offset of the sigmoid
111-
lambd: Parameter value for the upper offset of the sigmoid
113+
threshold: Threshold at `PC`
114+
width: Width of the sigmoid
115+
gamma: Guess rate (lower asymptote of the sigmoid)
116+
lambd: Lapse rate (upper asymptote of the sigmoid)
112117
Returns:
113118
Threshold at the proportion correct values.
114119
"""
@@ -151,7 +156,7 @@ def _value(self, stimulus_level: N, threshold: N, width: N) -> N:
151156
152157
Args:
153158
stimulus_level: Stimulus level values at which to calculate the sigmoid value
154-
threshold: Threshold value at PC
159+
threshold: Threshold at `PC`
155160
width: Width of the sigmoid
156161
Returns:
157162
Proportion correct at the stimulus values.
@@ -168,7 +173,7 @@ def _slope(self, stimulus_level: N, threshold: N, width: N) -> N:
168173
169174
Args:
170175
stimulus_level: Stimulus level value at which to calculate the slope
171-
threshold: Threshold value at PC
176+
threshold: Threshold at `PC`
172177
width: Width of the sigmoid
173178
Returns:
174179
Slope at the stimulus level value
@@ -185,7 +190,7 @@ def _inverse(self, prop_correct: N, threshold: N, width: N) -> N:
185190
186191
Args:
187192
prop_correct: Proportion correct values at which to calculate the stimulus level values.
188-
threshold: Threshold value at PC
193+
threshold: Threshold at `PC`
189194
width: Width of the sigmoid
190195
Returns:
191196
Stimulus values corresponding to the proportion correct values.
@@ -206,7 +211,7 @@ def _standard_parameters(self, threshold: N, width: N) -> list:
206211
For negative slope sigmoids, we return the same parameters as for the positive ones.
207212
208213
Args:
209-
threshold: Threshold value at PC
214+
threshold: Threshold at `PC`
210215
width: Width of the sigmoid
211216
Returns:
212217
Standard parameters (loc, scale) for the sigmoid subclass.
@@ -376,8 +381,8 @@ def assert_sigmoid_sanity_checks(sigmoid, n_samples: int, threshold: float, widt
376381
377382
Args:
378383
n_samples: Number of stimulus levels between 0 (exclusive) and 1 for tests
379-
threshold: Parameter value for threshold at PC
380-
width: Width of the sigmoid
384+
threshold: Threshold at `PC`
385+
width: Width of the sigmoid
381386
Raises:
382387
AssertionError if a sanity check fails.
383388
"""

psignifit/tests/test_sigmoids.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ def test_sigmoid_by_name(sigmoid_name):
4545
def test_sigmoid_values(subclass, expected_y):
4646
sigmoid = subclass(PC=0.6, alpha=0.1)
4747
x = np.array([9.5, 10.0, 11.5])
48-
y = sigmoid(x, threshold=10, width=3)
48+
y = sigmoid(x, threshold=10, width=3, gamma=0.08, lambd=0.12)
49+
# Rescale expected_y to take into account gamma and lambda
50+
expected_y = 0.08 + expected_y * 0.8
4951
np.testing.assert_allclose(y, expected_y, atol=1e-6)
5052

5153

psignifit/tools.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ def pool_blocks(data: np.ndarray, max_tol=0, max_gap=np.inf, max_length=np.inf):
5454
def psychometric(stimulus_level, threshold, width, gamma, lambda_, sigmoid_name):
5555
""" Psychometric function aka proportion correct function.
5656
57-
Generates proportion correct values for a range of stimulus levels given a
58-
sigmoid.
59-
Implementation of Eq 1 in Schuett, Harmeling, Macke and Wichmann (2016)
57+
This is a convenience function used mostly for testing and demos, to make the intent of creating a psychometric
58+
function more explicit. The implementation simply combines creating a `Sigmoid` object using the sigmoid name,
59+
and calling the object to generate the proportion correct values corresponding to `stimulus_level`.
6060
6161
Parameters:
6262
stimulus_level: array
@@ -69,25 +69,59 @@ def psychometric(stimulus_level, threshold, width, gamma, lambda_, sigmoid_name)
6969
Guess rate
7070
lambda_: float
7171
Lapse rate
72-
sigmoid: callable
73-
Sigmoid function to use. Default is Gaussian
72+
sigmoid_name: callable
73+
Name of sigmoid function to use. See `psignifit.sigmoids.ALL_SIGMOID_NAMES` for the list of available
74+
sigmoids.
7475
7576
Returns:
7677
psi: array
77-
proportion correct values for each stimulus level
78+
Proportion correct values for each stimulus level
7879
7980
"""
80-
# we use the defaults for pc and alpha in the sigmoids:
81-
# pc = 0.5
82-
# alpha = 0.05
8381
sigmoid = sigmoid_by_name(sigmoid_name)
84-
sigmoid_values = sigmoid(stimulus_level, threshold=threshold, width=width)
85-
psi = gamma + (1.0 - lambda_ - gamma) * sigmoid_values
82+
psi = sigmoid(stimulus_level, threshold=threshold, width=width, gamma=gamma, lambd=lambda_)
8683
return psi
8784

8885

8986
def psychometric_with_eta(stimulus_level, threshold, width, gamma, lambda_,
90-
sigmoid_name, eta, random_state=np.random.RandomState(42)):
87+
sigmoid_name, eta, random_state=None):
88+
""" Psychometric function with overdispersion.
89+
90+
This is a convenience function used mostly for testing and demos. Just like the function `psychometric`, it
91+
computes proportion correct values for a given psychometric function type, specified by name. In addition,
92+
it adds some additional noise to the data, so that its variance is compatible with the overdispersion
93+
parameter `eta`.
94+
95+
See Section 2.2 in Schuett, Harmeling, Macke and Wichmann (2016).
96+
97+
Parameters:
98+
stimulus_level: array
99+
Values of the stimulus value
100+
threshold: float
101+
Threshold of the psychometric function
102+
width: float
103+
Width of the psychometric function
104+
gamma: float
105+
Guess rate
106+
lambda_: float
107+
Lapse rate
108+
sigmoid_name: callable
109+
Name of sigmoid function to use. See `psignifit.sigmoids.ALL_SIGMOID_NAMES` for the list of available
110+
sigmoids.
111+
eta: float
112+
Overdispersion parameter
113+
random_state: np.RandomState
114+
Random state used to generate the additional variance in the data.
115+
If None, NumPy's default random number generator is used.
116+
117+
Returns:
118+
psi: array
119+
Proportion correct values for each stimulus level
120+
121+
"""
122+
123+
if random_state is None:
124+
random_state = np.random.default_rng()
91125

92126
psi = psychometric(stimulus_level, threshold, width, gamma, lambda_, sigmoid_name)
93127
new_psi = []

0 commit comments

Comments
 (0)