sensopy 0.0.1__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.
- sensopy/__init__.py +12 -0
- sensopy/discrimination/__init__.py +50 -0
- sensopy/discrimination/discrimination.py +263 -0
- sensopy/discrimination/methods.py +537 -0
- sensopy/discrimination/mplusn.py +89 -0
- sensopy/py.typed +0 -0
- sensopy-0.0.1.dist-info/METADATA +127 -0
- sensopy-0.0.1.dist-info/RECORD +10 -0
- sensopy-0.0.1.dist-info/WHEEL +4 -0
- sensopy-0.0.1.dist-info/licenses/LICENSE +21 -0
sensopy/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Top-level package for SensoPy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
from .discrimination import DiscriminationTest
|
|
8
|
+
|
|
9
|
+
__version__ = version(__name__)
|
|
10
|
+
"""Package version"""
|
|
11
|
+
|
|
12
|
+
__all__ = ["DiscriminationTest"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Discrimination tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .discrimination import DiscriminationTest, SimulationResults, Statistic, TestResults
|
|
6
|
+
from .methods import (
|
|
7
|
+
DUAL_PAIR,
|
|
8
|
+
DUO_TRIO,
|
|
9
|
+
FOUR_AFC,
|
|
10
|
+
SPECIFIED_TETRAD,
|
|
11
|
+
THREE_AFC,
|
|
12
|
+
TRIANGLE,
|
|
13
|
+
TWO_AFC,
|
|
14
|
+
UNSPECIFIED_TETRAD,
|
|
15
|
+
DualPairMethod,
|
|
16
|
+
DuoTrioMethod,
|
|
17
|
+
FourAFCMethod,
|
|
18
|
+
MPlusNMethod,
|
|
19
|
+
MultipleAFCMethod,
|
|
20
|
+
SpecifiedTetradMethod,
|
|
21
|
+
ThreeAFCMethod,
|
|
22
|
+
TriangleMethod,
|
|
23
|
+
TwoAFCMethod,
|
|
24
|
+
UnspecifiedTetrad,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DUAL_PAIR",
|
|
29
|
+
"DUO_TRIO",
|
|
30
|
+
"FOUR_AFC",
|
|
31
|
+
"SPECIFIED_TETRAD",
|
|
32
|
+
"THREE_AFC",
|
|
33
|
+
"TRIANGLE",
|
|
34
|
+
"TWO_AFC",
|
|
35
|
+
"UNSPECIFIED_TETRAD",
|
|
36
|
+
"DiscriminationTest",
|
|
37
|
+
"DualPairMethod",
|
|
38
|
+
"DuoTrioMethod",
|
|
39
|
+
"FourAFCMethod",
|
|
40
|
+
"MPlusNMethod",
|
|
41
|
+
"MultipleAFCMethod",
|
|
42
|
+
"SimulationResults",
|
|
43
|
+
"SpecifiedTetradMethod",
|
|
44
|
+
"Statistic",
|
|
45
|
+
"TestResults",
|
|
46
|
+
"ThreeAFCMethod",
|
|
47
|
+
"TriangleMethod",
|
|
48
|
+
"TwoAFCMethod",
|
|
49
|
+
"UnspecifiedTetrad",
|
|
50
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Discrimination Test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
from scipy.optimize import fsolve
|
|
11
|
+
from scipy.stats import beta, binom
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .methods import DiscriminationMethod
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class Statistic:
|
|
19
|
+
"""Point estimate and confidence interval for a single test statistic."""
|
|
20
|
+
|
|
21
|
+
estimate: float
|
|
22
|
+
stderr: float
|
|
23
|
+
lower: float
|
|
24
|
+
upper: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class TestResults:
|
|
29
|
+
"""Full set of estimates from a discrimination test."""
|
|
30
|
+
|
|
31
|
+
pg: float
|
|
32
|
+
pc: Statistic
|
|
33
|
+
pd: Statistic
|
|
34
|
+
d_prime: Statistic
|
|
35
|
+
p_value: float
|
|
36
|
+
alpha: float
|
|
37
|
+
power: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class SimulationResults:
|
|
42
|
+
"""Outcome of simulating many discrimination tests."""
|
|
43
|
+
|
|
44
|
+
x: npt.NDArray[np.int_]
|
|
45
|
+
p_values: npt.NDArray[np.float64]
|
|
46
|
+
alpha: float
|
|
47
|
+
power: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DiscriminationTest:
|
|
51
|
+
"""Difference and equivalence tests for a single sensory discrimination method.
|
|
52
|
+
|
|
53
|
+
Implements the one-tailed difference test (H1: P_c > P_c0) and the one-tailed
|
|
54
|
+
equivalence test (H1: P_c < P_c0) described in Bi (2015, Chapters 4-5).
|
|
55
|
+
|
|
56
|
+
The test produces estimates of P_c, p_d, and d' together with their standard
|
|
57
|
+
errors and confidence limits derived from the binomial model and the delta method
|
|
58
|
+
(Bi, 2015, §2.3).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, method: DiscriminationMethod) -> None:
|
|
62
|
+
"""Initialize a discrimination test.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
method: The sensory discrimination method to use.
|
|
66
|
+
"""
|
|
67
|
+
self.method = method
|
|
68
|
+
|
|
69
|
+
def limits(
|
|
70
|
+
self,
|
|
71
|
+
x: int,
|
|
72
|
+
n: int,
|
|
73
|
+
pc: float,
|
|
74
|
+
pd: float,
|
|
75
|
+
pg: float,
|
|
76
|
+
d_prime: float,
|
|
77
|
+
alpha: float,
|
|
78
|
+
) -> tuple[Statistic, Statistic, Statistic]:
|
|
79
|
+
"""Compute point estimates, standard errors, and confidence limits.
|
|
80
|
+
|
|
81
|
+
Standard errors for P_c and p_d use the binomial formula. The standard
|
|
82
|
+
error for d' is obtained via the delta method (Bi, 2015, §2.3):
|
|
83
|
+
|
|
84
|
+
SE(d') = SE(P_c) / f'(d')
|
|
85
|
+
|
|
86
|
+
where f'(d') is the derivative of the psychometric function, approximated
|
|
87
|
+
here by a central finite difference. Confidence limits for P_c use the
|
|
88
|
+
exact beta distribution (Clopper-Pearson) interval.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
x: Number of correct responses.
|
|
92
|
+
n: Number of panelists (trials).
|
|
93
|
+
pc: Observed proportion correct P_c = x / n.
|
|
94
|
+
pd: Observed proportion of discriminators p_d.
|
|
95
|
+
pg: Guessing probability P_g for the method.
|
|
96
|
+
d_prime: Estimated Thurstonian distance d'.
|
|
97
|
+
alpha: Significance level (1 - confidence level).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A tuple of (pc_stat, pd_stat, d_prime_stat), each a Statistic
|
|
101
|
+
namedtuple with fields (estimate, stderr, lower, upper).
|
|
102
|
+
"""
|
|
103
|
+
pc_err = np.sqrt(pc * (1 - pc) / n)
|
|
104
|
+
pd_err = pc_err / (1 - pg)
|
|
105
|
+
dx = 1e-6
|
|
106
|
+
f = self.method.psychometric_function
|
|
107
|
+
der = (f(d_prime + dx) - f(d_prime - dx)) / (2 * dx)
|
|
108
|
+
d_prime_err = pc_err / der
|
|
109
|
+
|
|
110
|
+
# Lower limits
|
|
111
|
+
pc_lower = max(beta.ppf(alpha / 2, x, n - x + 1), pg)
|
|
112
|
+
pd_lower = (pc_lower - pg) / (1 - pg)
|
|
113
|
+
d_prime_lower = fsolve(lambda d: self.method.psychometric_function(d) - pc_lower, 1.0)[0] # type: ignore[arg-type,misc] # ty:ignore[invalid-argument-type]
|
|
114
|
+
|
|
115
|
+
# Upper limits
|
|
116
|
+
pc_upper = min(beta.ppf(1 - alpha / 2, x + 1, n - x), 1.0)
|
|
117
|
+
pd_upper = (pc_upper - pg) / (1 - pg)
|
|
118
|
+
d_prime_upper = fsolve(lambda d: self.method.psychometric_function(d) - pc_upper, 1.0)[0] # type: ignore[arg-type,misc] # ty:ignore[invalid-argument-type]
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
Statistic(pc, pc_err, pc_lower, pc_upper),
|
|
122
|
+
Statistic(pd, pd_err, pd_lower, pd_upper),
|
|
123
|
+
Statistic(d_prime, d_prime_err, d_prime_lower, d_prime_upper),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def difference(
|
|
127
|
+
self,
|
|
128
|
+
x: int,
|
|
129
|
+
n: int,
|
|
130
|
+
pd0: float = 0,
|
|
131
|
+
conf_level: float = 0.95,
|
|
132
|
+
) -> TestResults:
|
|
133
|
+
"""One-tailed difference test (Bi, 2015, Chapter 4).
|
|
134
|
+
|
|
135
|
+
Tests whether the sensory difference exceeds a specified threshold pd0:
|
|
136
|
+
|
|
137
|
+
H0: p_d ≤ pd0 (i.e., P_c ≤ P_c0)
|
|
138
|
+
H1: p_d > pd0 (i.e., P_c > P_c0)
|
|
139
|
+
|
|
140
|
+
where P_c0 = P_g + (1 - P_g) · pd0.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
x: Number of correct responses.
|
|
144
|
+
n: Number of panelists (trials).
|
|
145
|
+
pd0: Null-hypothesis proportion of discriminators (default 0).
|
|
146
|
+
conf_level: Confidence level for the interval estimates (default 0.95).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
TestResults with pg, pc, pd, d_prime (each a Statistic), p_value,
|
|
150
|
+
alpha, and power.
|
|
151
|
+
"""
|
|
152
|
+
alpha = 1 - conf_level
|
|
153
|
+
|
|
154
|
+
pg = self.method.guessing
|
|
155
|
+
pc = x / n
|
|
156
|
+
pd = (pc - pg) / (1 - pg)
|
|
157
|
+
d_prime = fsolve(lambda d: self.method.psychometric_function(d) - pc, 1.0)[0] # type: ignore[arg-type,misc] # ty:ignore[invalid-argument-type]
|
|
158
|
+
|
|
159
|
+
def stats(x: int, n: int, pc: float, pg: float, alpha: float) -> tuple[float, float]:
|
|
160
|
+
pc0 = pg + (1 - pg) * pd0
|
|
161
|
+
p_value = 1 - binom.cdf(x - 1, n, pc0)
|
|
162
|
+
xcrit = binom.ppf(1 - alpha, n, pc0) + 1
|
|
163
|
+
power = 1 - binom.cdf(xcrit - 1, n, pc)
|
|
164
|
+
return p_value, power
|
|
165
|
+
|
|
166
|
+
p_value, power = stats(x, n, pc, pg, alpha)
|
|
167
|
+
pc_stats, pd_stats, d_prime_stats = self.limits(x, n, pc, pd, pg, d_prime, alpha)
|
|
168
|
+
|
|
169
|
+
return TestResults(pg, pc_stats, pd_stats, d_prime_stats, p_value, alpha, power)
|
|
170
|
+
|
|
171
|
+
def equivalence(
|
|
172
|
+
self,
|
|
173
|
+
x: int,
|
|
174
|
+
n: int,
|
|
175
|
+
pd0: float = 0,
|
|
176
|
+
conf_level: float = 0.95,
|
|
177
|
+
) -> TestResults:
|
|
178
|
+
"""One-tailed equivalence (similarity) test (Bi, 2015, Chapter 5).
|
|
179
|
+
|
|
180
|
+
Tests whether the sensory difference is smaller than a specified threshold pd0:
|
|
181
|
+
|
|
182
|
+
H0: p_d ≥ pd0 (i.e., P_c ≥ P_c0)
|
|
183
|
+
H1: p_d < pd0 (i.e., P_c < P_c0)
|
|
184
|
+
|
|
185
|
+
where P_c0 = P_g + (1 - P_g) · pd0.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
x: Number of correct responses.
|
|
189
|
+
n: Number of panelists (trials).
|
|
190
|
+
pd0: Null-hypothesis proportion of discriminators (default 0).
|
|
191
|
+
conf_level: Confidence level for the interval estimates (default 0.95).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
TestResults with pg, pc, pd, d_prime (each a Statistic), p_value,
|
|
195
|
+
alpha, and power.
|
|
196
|
+
"""
|
|
197
|
+
alpha = 1 - conf_level
|
|
198
|
+
|
|
199
|
+
pg = self.method.guessing
|
|
200
|
+
pc = x / n
|
|
201
|
+
pd = (pc - pg) / (1 - pg)
|
|
202
|
+
d_prime = fsolve(lambda d: self.method.psychometric_function(d) - pc, 1.0)[0] # type: ignore[arg-type,misc] # ty:ignore[invalid-argument-type]
|
|
203
|
+
|
|
204
|
+
def stats(x: int, n: int, pc: float, pg: float, alpha: float) -> tuple[float, float]:
|
|
205
|
+
pc0 = pg + (1 - pg) * pd0
|
|
206
|
+
p_value = binom.cdf(x, n, pc0)
|
|
207
|
+
# Largest count whose lower-tail p-value is still <= alpha
|
|
208
|
+
xcrit = binom.ppf(alpha, n, pc0)
|
|
209
|
+
if binom.cdf(xcrit, n, pc0) > alpha:
|
|
210
|
+
xcrit -= 1
|
|
211
|
+
power = binom.cdf(xcrit, n, pc)
|
|
212
|
+
return p_value, power
|
|
213
|
+
|
|
214
|
+
p_value, power = stats(x, n, pc, pg, alpha)
|
|
215
|
+
pc_stats, pd_stats, d_prime_stats = self.limits(x, n, pc, pd, pg, d_prime, alpha)
|
|
216
|
+
|
|
217
|
+
return TestResults(pg, pc_stats, pd_stats, d_prime_stats, p_value, alpha, power)
|
|
218
|
+
|
|
219
|
+
def simulate(
|
|
220
|
+
self,
|
|
221
|
+
d: float,
|
|
222
|
+
n: int,
|
|
223
|
+
size: int = 1000,
|
|
224
|
+
*,
|
|
225
|
+
kind: Literal["difference", "equivalence"] = "difference",
|
|
226
|
+
pd0: float = 0,
|
|
227
|
+
conf_level: float = 0.95,
|
|
228
|
+
rng: np.random.Generator | int | None = None,
|
|
229
|
+
) -> SimulationResults:
|
|
230
|
+
"""Simulate repeated discrimination tests and estimate the empirical power.
|
|
231
|
+
|
|
232
|
+
Draws ``size`` experiments of ``n`` panelists with true distance ``d``, computes
|
|
233
|
+
the one-tailed exact binomial p-value of each against ``pd0``, and reports the
|
|
234
|
+
fraction that reject the null hypothesis at ``alpha = 1 - conf_level``.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
d: True Thurstonian distance d' used to generate the data.
|
|
238
|
+
n: Number of panelists per simulated test.
|
|
239
|
+
size: Number of tests to simulate (default 1000).
|
|
240
|
+
kind: ``"difference"`` (H1: P_c > P_c0) or ``"equivalence"`` (H1: P_c < P_c0).
|
|
241
|
+
pd0: Null-hypothesis proportion of discriminators (default 0).
|
|
242
|
+
conf_level: Confidence level (default 0.95).
|
|
243
|
+
rng: A NumPy random generator, or a seed used to create one.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
SimulationResults with the simulated counts, p-values, alpha and power.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ValueError: If ``kind`` is not recognized or ``size`` is not positive.
|
|
250
|
+
"""
|
|
251
|
+
if kind not in ("difference", "equivalence"):
|
|
252
|
+
msg = "kind must be 'difference' or 'equivalence'"
|
|
253
|
+
raise ValueError(msg)
|
|
254
|
+
if size < 1:
|
|
255
|
+
msg = "size must be a positive integer"
|
|
256
|
+
raise ValueError(msg)
|
|
257
|
+
|
|
258
|
+
alpha = 1 - conf_level
|
|
259
|
+
pg = self.method.guessing
|
|
260
|
+
pc0 = pg + (1 - pg) * pd0
|
|
261
|
+
x = self.method.simulate(d, n, size=size, rng=rng)
|
|
262
|
+
p_values = 1 - binom.cdf(x - 1, n, pc0) if kind == "difference" else binom.cdf(x, n, pc0)
|
|
263
|
+
return SimulationResults(x, p_values, alpha, float(np.mean(p_values <= alpha)))
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""Discrimination methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from typing import overload
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
import scipy.special
|
|
11
|
+
from scipy.integrate import trapezoid
|
|
12
|
+
from scipy.stats import norm
|
|
13
|
+
|
|
14
|
+
from . import mplusn
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DUAL_PAIR",
|
|
18
|
+
"DUO_TRIO",
|
|
19
|
+
"FOUR_AFC",
|
|
20
|
+
"SPECIFIED_TETRAD",
|
|
21
|
+
"THREE_AFC",
|
|
22
|
+
"TRIANGLE",
|
|
23
|
+
"TWO_AFC",
|
|
24
|
+
"UNSPECIFIED_TETRAD",
|
|
25
|
+
"MPlusNMethod",
|
|
26
|
+
"MultipleAFCMethod",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DiscriminationMethod(abc.ABC):
|
|
31
|
+
"""A sensory discrimination method."""
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def psychometric_function(self, d: float) -> float:
|
|
35
|
+
"""Psychometric function relating d' to the probability of a correct response.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
d: Thurstonian discriminal distance d' (δ), a method-independent measure
|
|
39
|
+
of sensory difference/similarity (Bi, 2015, §2.1).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Probability of a correct response P_c for the given d'.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abc.abstractmethod
|
|
48
|
+
def guessing(self) -> float:
|
|
49
|
+
"""Chance-level probability of a correct response when d' = 0."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def discriminators(self, d: float) -> float:
|
|
53
|
+
"""Proportion of discriminators (p_d) for a given d'.
|
|
54
|
+
|
|
55
|
+
p_d is the proportion of the population that can reliably detect the
|
|
56
|
+
sensory difference, estimated as (P_c - P_g) / (1 - P_g), where P_g
|
|
57
|
+
is the guessing rate (Bi, 2015, §4.1).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
d: Thurstonian discriminal distance d'.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Proportion of discriminators p_d.
|
|
64
|
+
"""
|
|
65
|
+
pc = self.psychometric_function(d)
|
|
66
|
+
pg = self.guessing
|
|
67
|
+
return (pc - pg) / (1 - pg)
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def simulate(
|
|
71
|
+
self,
|
|
72
|
+
d: float,
|
|
73
|
+
n: int,
|
|
74
|
+
size: None = None,
|
|
75
|
+
rng: np.random.Generator | int | None = None,
|
|
76
|
+
) -> int: ...
|
|
77
|
+
|
|
78
|
+
@overload
|
|
79
|
+
def simulate(
|
|
80
|
+
self,
|
|
81
|
+
d: float,
|
|
82
|
+
n: int,
|
|
83
|
+
size: int,
|
|
84
|
+
rng: np.random.Generator | int | None = None,
|
|
85
|
+
) -> npt.NDArray[np.int_]: ...
|
|
86
|
+
|
|
87
|
+
def simulate(
|
|
88
|
+
self,
|
|
89
|
+
d: float,
|
|
90
|
+
n: int,
|
|
91
|
+
size: int | None = None,
|
|
92
|
+
rng: np.random.Generator | int | None = None,
|
|
93
|
+
) -> int | npt.NDArray[np.int_]:
|
|
94
|
+
"""Simulate the number of correct responses in a discrimination test.
|
|
95
|
+
|
|
96
|
+
Each of the ``n`` panelists answers correctly with probability
|
|
97
|
+
P_c = ψ(d'), so the number of correct responses is distributed as
|
|
98
|
+
Binomial(n, P_c) (Bi, 2015, §2.3).
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
d: True Thurstonian discriminal distance d' (must be non-negative).
|
|
102
|
+
n: Number of panelists (trials) per simulated test.
|
|
103
|
+
size: Number of tests to simulate. If None, a single test is simulated
|
|
104
|
+
and a scalar is returned.
|
|
105
|
+
rng: A NumPy random generator, or a seed used to create one.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The number of correct responses, or an array of them if ``size`` is given.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If ``d`` is negative or ``n`` is not positive.
|
|
112
|
+
"""
|
|
113
|
+
if d < 0:
|
|
114
|
+
msg = "d' must be non-negative"
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
if n < 1:
|
|
117
|
+
msg = "n must be a positive integer"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
|
|
120
|
+
generator = np.random.default_rng(rng)
|
|
121
|
+
pc = min(max(self.psychometric_function(d), 0.0), 1.0)
|
|
122
|
+
result = generator.binomial(n, pc, size=size)
|
|
123
|
+
return int(result) if size is None else result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TriangleMethod(DiscriminationMethod):
|
|
127
|
+
"""Triangle (Triangular) discrimination method (Dawson and Harris 1951, Peryam 1958).
|
|
128
|
+
|
|
129
|
+
Three samples of two products, A and B, are presented to each panelist. Two of them
|
|
130
|
+
are the same. The possible sets of samples are AAB, ABA, BAA, ABB, BAB, and BBA.
|
|
131
|
+
The panelist is asked to select the odd sample and must select one even if they
|
|
132
|
+
cannot identify it (Bi, 2015, §1.5.1d).
|
|
133
|
+
|
|
134
|
+
The psychometric function is (Bi, 2015, eq. 2.2.5):
|
|
135
|
+
|
|
136
|
+
P_c = 2 ∫_0^∞ φ(x) { Φ[-√3·x + √(2/3)·δ] + Φ[-√3·x - √(2/3)·δ] } dx
|
|
137
|
+
|
|
138
|
+
Guessing probability: 1/3.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def psychometric_function(self, d: float) -> float:
|
|
142
|
+
"""Psychometric function for the Triangle method (Bi, 2015, eq. 2.2.5).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
d: Thurstonian discriminal distance d'.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Probability of a correct response P_c.
|
|
149
|
+
"""
|
|
150
|
+
f1 = norm.pdf
|
|
151
|
+
f2 = norm.cdf
|
|
152
|
+
|
|
153
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
154
|
+
x1 = -z * np.sqrt(3) + np.sqrt(2 / 3) * d
|
|
155
|
+
x2 = -z * np.sqrt(3) - np.sqrt(2 / 3) * d
|
|
156
|
+
return 2 * (f2(x1) + f2(x2)) * f1(z) # type: ignore[no-any-return]
|
|
157
|
+
|
|
158
|
+
x = np.linspace(0, 200, 10000)
|
|
159
|
+
y = _fi(x)
|
|
160
|
+
return trapezoid(y, x) # type: ignore[no-any-return]
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def guessing(self) -> float:
|
|
164
|
+
"""Chance-level probability for the Triangle method (1/3)."""
|
|
165
|
+
return 1 / 3
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TwoAFCMethod(DiscriminationMethod):
|
|
169
|
+
"""Two-Alternative Forced Choice (2-AFC) method (Green and Swets 1966).
|
|
170
|
+
|
|
171
|
+
Also called the paired comparison method (Dawson and Harris 1951, Peryam 1958).
|
|
172
|
+
The panelist receives a pair of coded samples, A and B, for comparison on the basis
|
|
173
|
+
of a specified sensory characteristic. The possible pairs are AB and BA. The panelist
|
|
174
|
+
selects the sample with the strongest (or weakest) characteristic and must choose
|
|
175
|
+
even if they cannot detect a difference (Bi, 2015, §1.5.1a).
|
|
176
|
+
|
|
177
|
+
The psychometric function is (Bi, 2015, eq. 2.2.1):
|
|
178
|
+
|
|
179
|
+
P_c = Φ(δ / √2)
|
|
180
|
+
|
|
181
|
+
Guessing probability: 1/2.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def psychometric_function(self, d: float) -> float:
|
|
185
|
+
"""Psychometric function for the 2-AFC method (Bi, 2015, eq. 2.2.1).
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
d: Thurstonian discriminal distance d'.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Probability of a correct response P_c.
|
|
192
|
+
"""
|
|
193
|
+
return norm.cdf(d / np.sqrt(2)) # type: ignore[no-any-return]
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def guessing(self) -> float:
|
|
197
|
+
"""Chance-level probability for the 2-AFC method (1/2)."""
|
|
198
|
+
return 1 / 2
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ThreeAFCMethod(DiscriminationMethod):
|
|
202
|
+
"""Three-Alternative Forced Choice (3-AFC) method (Green and Swets 1966).
|
|
203
|
+
|
|
204
|
+
Three samples of two products, A and B, are presented to each panelist. Two of them
|
|
205
|
+
are the same. The possible sets of samples are AAB, ABA, BAA or ABB, BAB, BBA.
|
|
206
|
+
The panelist selects the sample with the strongest or weakest characteristic and must
|
|
207
|
+
choose even if they cannot identify it (Bi, 2015, §1.5.1b).
|
|
208
|
+
|
|
209
|
+
The psychometric function is (Bi, 2015, eq. 2.2.2):
|
|
210
|
+
|
|
211
|
+
P_c = ∫_{-∞}^{∞} Φ²(u) φ(u - δ) du
|
|
212
|
+
|
|
213
|
+
Guessing probability: 1/3.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def psychometric_function(self, d: float) -> float:
|
|
217
|
+
"""Psychometric function for the 3-AFC method (Bi, 2015, eq. 2.2.2).
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
d: Thurstonian discriminal distance d'.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Probability of a correct response P_c.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
227
|
+
return (norm.cdf(z) ** 2) * norm.pdf(z - d)
|
|
228
|
+
|
|
229
|
+
x = np.linspace(-100, 100, 10000)
|
|
230
|
+
y = _fi(x)
|
|
231
|
+
return trapezoid(y, x) # type: ignore[no-any-return]
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def guessing(self) -> float:
|
|
235
|
+
"""Chance-level probability for the 3-AFC method (1/3)."""
|
|
236
|
+
return 1 / 3
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class FourAFCMethod(DiscriminationMethod):
|
|
240
|
+
"""Four-Alternative Forced Choice (4-AFC) method (Swets 1959).
|
|
241
|
+
|
|
242
|
+
Four samples of two products, A and B, are presented to each panelist. Three of them
|
|
243
|
+
are the same. The possible sets of samples are AAAB, AABA, ABAA, BAAA or BBBA,
|
|
244
|
+
BBAB, BABB, ABBB. The panelist selects the sample with the strongest or weakest
|
|
245
|
+
characteristic and must choose even if they cannot identify it (Bi, 2015, §1.5.1c).
|
|
246
|
+
|
|
247
|
+
The psychometric function is (Bi, 2015, eq. 2.2.3):
|
|
248
|
+
|
|
249
|
+
P_c = ∫_{-∞}^{∞} Φ³(u) φ(u - δ) du
|
|
250
|
+
|
|
251
|
+
Guessing probability: 1/4.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def psychometric_function(self, d: float) -> float:
|
|
255
|
+
"""Psychometric function for the 4-AFC method (Bi, 2015, eq. 2.2.3).
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
d: Thurstonian discriminal distance d'.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Probability of a correct response P_c.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
265
|
+
return (norm.cdf(z) ** 3) * norm.pdf(z - d)
|
|
266
|
+
|
|
267
|
+
x = np.linspace(-100, 100, 10000)
|
|
268
|
+
y = _fi(x)
|
|
269
|
+
return trapezoid(y, x) # type: ignore[no-any-return]
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def guessing(self) -> float:
|
|
273
|
+
"""Chance-level probability for the 4-AFC method (1/4)."""
|
|
274
|
+
return 1 / 4
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class MultipleAFCMethod(DiscriminationMethod):
|
|
278
|
+
"""m-Alternative Forced Choice (m-AFC) method.
|
|
279
|
+
|
|
280
|
+
Generalisation of the AFC family. m samples are presented; (m - 1) are from
|
|
281
|
+
product A and one from product B. The panelist selects the sample with the
|
|
282
|
+
strongest (or weakest) characteristic (Bi, 2015, §1.5.1).
|
|
283
|
+
|
|
284
|
+
The psychometric function is (Bi, 2015, eq. 2.2.3 generalised):
|
|
285
|
+
|
|
286
|
+
P_c = ∫_{-∞}^{∞} Φ^(m-1)(u) φ(u - δ) du
|
|
287
|
+
|
|
288
|
+
Guessing probability: 1/m.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(self, m: int) -> None:
|
|
292
|
+
"""Initialize an m-AFC discrimination method.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
m: Number of alternatives (must be ≥ 2).
|
|
296
|
+
"""
|
|
297
|
+
self.m = m
|
|
298
|
+
|
|
299
|
+
def psychometric_function(self, d: float) -> float:
|
|
300
|
+
"""Psychometric function for the m-AFC method (Bi, 2015, eq. 2.2.3 generalised).
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
d: Thurstonian discriminal distance d'.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Probability of a correct response P_c.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
310
|
+
return (norm.cdf(z) ** (self.m - 1)) * norm.pdf(z - d)
|
|
311
|
+
|
|
312
|
+
x = np.linspace(-100, 100, 10000)
|
|
313
|
+
y = _fi(x)
|
|
314
|
+
return trapezoid(y, x) # type: ignore[no-any-return]
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def guessing(self) -> float:
|
|
318
|
+
"""Chance-level probability for the m-AFC method (1/m)."""
|
|
319
|
+
return 1 / self.m
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class SpecifiedTetradMethod(DiscriminationMethod):
|
|
323
|
+
"""Specified Tetrad method (Wood 1949).
|
|
324
|
+
|
|
325
|
+
Four stimuli — two of A and two of B — are presented. A and B are confusable and
|
|
326
|
+
vary in the relative strengths of their sensory attributes. Panelists are told that
|
|
327
|
+
there are two pairs of putatively identical stimuli and must indicate the two stimuli
|
|
328
|
+
of the specified product (A or B) (Bi, 2015, §1.5.1g).
|
|
329
|
+
|
|
330
|
+
The psychometric function is (Bi, 2015, eq. 2.2.11):
|
|
331
|
+
|
|
332
|
+
P_c = 1 - 2 ∫_{-∞}^{∞} φ(x) Φ(x) { 2Φ(x - δ) - [Φ(x - δ)]² } dx
|
|
333
|
+
|
|
334
|
+
Guessing probability: 1/6.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
def psychometric_function(self, d: float) -> float:
|
|
338
|
+
"""Psychometric function for the Specified Tetrad method (Bi, 2015, eq. 2.2.11).
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
d: Thurstonian discriminal distance d'.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Probability of a correct response P_c.
|
|
345
|
+
"""
|
|
346
|
+
f1 = norm.pdf
|
|
347
|
+
f2 = norm.cdf
|
|
348
|
+
|
|
349
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
350
|
+
return 2 * (f1(z) * f2(z) * (2 * f2(z - d) - f2(z - d) ** 2))
|
|
351
|
+
|
|
352
|
+
x = np.linspace(-100, 100, 10000)
|
|
353
|
+
y = _fi(x)
|
|
354
|
+
i = trapezoid(y, x)
|
|
355
|
+
|
|
356
|
+
return 1 - i # type: ignore[no-any-return]
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def guessing(self) -> float:
|
|
360
|
+
"""Chance-level probability for the Specified Tetrad method (1/6)."""
|
|
361
|
+
return 1 / 6
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class UnspecifiedTetrad(DiscriminationMethod):
|
|
365
|
+
"""Unspecified Tetrad method (Lockhart 1951).
|
|
366
|
+
|
|
367
|
+
Four stimuli — two of A and two of B — are presented. A and B are confusable and
|
|
368
|
+
vary in the relative strengths of their sensory attributes. Panelists are told that
|
|
369
|
+
there are two pairs of putatively identical stimuli and must sort them into their
|
|
370
|
+
pairs, without being told which product to identify (Bi, 2015, §1.5.1f).
|
|
371
|
+
|
|
372
|
+
The psychometric function is (Bi, 2015, eq. 2.2.8):
|
|
373
|
+
|
|
374
|
+
P_c = 1 - 2 ∫_{-∞}^{∞} φ(x) { 2Φ(x)Φ(x - δ) - [Φ(x - δ)]² } dx
|
|
375
|
+
|
|
376
|
+
Guessing probability: 1/3.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def psychometric_function(self, d: float) -> float:
|
|
380
|
+
"""Psychometric function for the Unspecified Tetrad method (Bi, 2015, eq. 2.2.8).
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
d: Thurstonian discriminal distance d'.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Probability of a correct response P_c.
|
|
387
|
+
"""
|
|
388
|
+
f1 = norm.pdf
|
|
389
|
+
f2 = norm.cdf
|
|
390
|
+
|
|
391
|
+
def _fi(z: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
392
|
+
return 2 * (f1(z) * (2 * f2(z) * f2(z - d) - f2(z - d) ** 2))
|
|
393
|
+
|
|
394
|
+
x = np.linspace(-100, 100, 10000)
|
|
395
|
+
y = _fi(x)
|
|
396
|
+
i = trapezoid(y, x)
|
|
397
|
+
|
|
398
|
+
return 1 - i # type: ignore[no-any-return]
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def guessing(self) -> float:
|
|
402
|
+
"""Chance-level probability for the Unspecified Tetrad method (1/3)."""
|
|
403
|
+
return 1 / 3
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class DualPairMethod(DiscriminationMethod):
|
|
407
|
+
"""Dual Pair (4IAX) method (Macmillan et al. 1977).
|
|
408
|
+
|
|
409
|
+
Two pairs of samples are presented simultaneously to the panelist. One pair is
|
|
410
|
+
composed of samples of the same stimulus (AA or BB); the other is composed of
|
|
411
|
+
samples of different stimuli (AB or BA). The panelist selects the most different
|
|
412
|
+
pair of the two (Bi, 2015, §1.5.1h).
|
|
413
|
+
|
|
414
|
+
The psychometric function is (Bi, 2015, eq. 2.2.12):
|
|
415
|
+
|
|
416
|
+
P_c = [Φ(δ/2)]² + [Φ(-δ/2)]²
|
|
417
|
+
|
|
418
|
+
Guessing probability: 1/2.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def psychometric_function(self, d: float) -> float:
|
|
422
|
+
"""Psychometric function for the Dual Pair method (Bi, 2015, eq. 2.2.12).
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
d: Thurstonian discriminal distance d'.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Probability of a correct response P_c.
|
|
429
|
+
"""
|
|
430
|
+
return norm.cdf(d / 2) ** 2 + norm.cdf(-d / 2) ** 2
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def guessing(self) -> float:
|
|
434
|
+
"""Chance-level probability for the Dual Pair method (1/2)."""
|
|
435
|
+
return 1 / 2
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class DuoTrioMethod(DiscriminationMethod):
|
|
439
|
+
"""Duo-Trio method (Dawson and Harris 1951, Peryam 1958).
|
|
440
|
+
|
|
441
|
+
Three samples of two products, A and B, are presented to each panelist. Two of them
|
|
442
|
+
are the same. The possible sets of samples are A:AB, A:BA, B:AB, and B:BA. The first
|
|
443
|
+
sample is labelled the "control." The panelist selects which of the two test samples
|
|
444
|
+
matches the control and must choose even if they cannot identify the match
|
|
445
|
+
(Bi, 2015, §1.5.1e).
|
|
446
|
+
|
|
447
|
+
The psychometric function is (Bi, 2015, eq. 2.2.4):
|
|
448
|
+
|
|
449
|
+
P_c = 1 - Φ(δ/√2) - Φ(δ/√6) + 2 Φ(δ/√2) Φ(δ/√6)
|
|
450
|
+
|
|
451
|
+
Guessing probability: 1/2.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def psychometric_function(self, d: float) -> float:
|
|
455
|
+
"""Psychometric function for the Duo-Trio method (Bi, 2015, eq. 2.2.4).
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
d: Thurstonian discriminal distance d'.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Probability of a correct response P_c.
|
|
462
|
+
"""
|
|
463
|
+
x1 = d / np.sqrt(2)
|
|
464
|
+
x2 = d / np.sqrt(6)
|
|
465
|
+
return 1 - norm.cdf(x1) - norm.cdf(x2) + 2 * norm.cdf(x1) * norm.cdf(x2) # type: ignore[no-any-return]
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def guessing(self) -> float:
|
|
469
|
+
"""Chance-level probability for the Duo-Trio method (1/2)."""
|
|
470
|
+
return 1 / 2
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class MPlusNMethod(DiscriminationMethod):
|
|
474
|
+
"""M + N discrimination method (Lockhart 1951).
|
|
475
|
+
|
|
476
|
+
M + N samples are presented: M samples of product A and N samples of product B.
|
|
477
|
+
The panelist divides them into two groups of A and B. There are two versions:
|
|
478
|
+
specified (panelist is told which group is A) and unspecified (Bi, 2015, §1.5.1i).
|
|
479
|
+
|
|
480
|
+
This is a generalisation of many forced-choice methods, including m-AFC, Triangle,
|
|
481
|
+
and both Tetrad variants. For small M and N a binomial model applies; for larger
|
|
482
|
+
M and N (M = N > 3) a single set of samples can reach statistical significance
|
|
483
|
+
under a hypergeometric model (Bi, 2015, §2.5).
|
|
484
|
+
|
|
485
|
+
The psychometric function is estimated by Monte Carlo simulation (Bi, 2015, §2.5).
|
|
486
|
+
|
|
487
|
+
Guessing probability: 1/C(M+N, N) for specified or M > N; 2/C(M+N, N) otherwise.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def __init__(self, m: int, n: int, specified: bool = False, *, seed: int | None = None) -> None:
|
|
491
|
+
"""Initialize an M + N discrimination method.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
m: Number of samples from product A (must be ≥ n).
|
|
495
|
+
n: Number of samples from product B.
|
|
496
|
+
specified: If True, panelists are told which group is product A (specified
|
|
497
|
+
version); otherwise the unspecified version is used.
|
|
498
|
+
seed: Seed for the random number generator used in the Monte Carlo
|
|
499
|
+
simulation of the psychometric function. Pass an integer for
|
|
500
|
+
reproducible results; ``None`` (default) uses an unpredictable seed.
|
|
501
|
+
"""
|
|
502
|
+
self.m = m
|
|
503
|
+
self.n = n
|
|
504
|
+
self.specified = specified
|
|
505
|
+
self.psy_func = mplusn.mplusn_mc(m, n, specified=specified, seed=seed)
|
|
506
|
+
|
|
507
|
+
def psychometric_function(self, d: float) -> float:
|
|
508
|
+
"""Psychometric function for the M + N method (Monte Carlo estimate).
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
d: Thurstonian discriminal distance d'.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Probability of a correct response P_c (interpolated from simulation).
|
|
515
|
+
"""
|
|
516
|
+
return self.psy_func(d)
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def guessing(self) -> float:
|
|
520
|
+
"""Chance-level probability for the M + N method.
|
|
521
|
+
|
|
522
|
+
Returns 1/C(M+N, N) for the specified version or when M > N, and
|
|
523
|
+
2/C(M+N, N) for the unspecified version when M = N (Bi, 2015, §2.5).
|
|
524
|
+
"""
|
|
525
|
+
if self.m > self.n or self.specified:
|
|
526
|
+
return float(1 / scipy.special.binom(self.m + self.n, self.n))
|
|
527
|
+
return float(2 / scipy.special.binom(self.m + self.n, self.n))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
TRIANGLE = TriangleMethod()
|
|
531
|
+
TWO_AFC = TwoAFCMethod()
|
|
532
|
+
THREE_AFC = ThreeAFCMethod()
|
|
533
|
+
FOUR_AFC = FourAFCMethod()
|
|
534
|
+
SPECIFIED_TETRAD = SpecifiedTetradMethod()
|
|
535
|
+
UNSPECIFIED_TETRAD = UnspecifiedTetrad()
|
|
536
|
+
DUAL_PAIR = DualPairMethod()
|
|
537
|
+
DUO_TRIO = DuoTrioMethod()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""M plus N generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
from scipy import interpolate
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
SAMPLE_SIZE = 100000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mplusn_mc(
|
|
18
|
+
m: int,
|
|
19
|
+
n: int,
|
|
20
|
+
specified: bool = False,
|
|
21
|
+
max_delta: float = 10,
|
|
22
|
+
steps: int = 300,
|
|
23
|
+
seed: int | None = None,
|
|
24
|
+
sample_size: int = SAMPLE_SIZE,
|
|
25
|
+
) -> Callable[[float], float]:
|
|
26
|
+
"""Monte Carlo simulation for M + N method.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
m: TODO.
|
|
30
|
+
n: TODO.
|
|
31
|
+
specified: TODO.
|
|
32
|
+
max_delta: TODO.
|
|
33
|
+
steps: TODO.
|
|
34
|
+
seed: TODO.
|
|
35
|
+
sample_size: TODO.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
TODO.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If `m` is smaller than `n`.
|
|
42
|
+
"""
|
|
43
|
+
if m < n:
|
|
44
|
+
raise ValueError("Invalid combination of parameters. M >= N expected.")
|
|
45
|
+
|
|
46
|
+
delta = np.linspace(0, max_delta, steps)
|
|
47
|
+
prop = []
|
|
48
|
+
k = m - n
|
|
49
|
+
|
|
50
|
+
def _func1(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64]) -> float:
|
|
51
|
+
return np.mean(a[-1] < b[0]) # type: ignore[no-any-return]
|
|
52
|
+
|
|
53
|
+
def _func2(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64]) -> float:
|
|
54
|
+
cond1 = a[-1] < b[0]
|
|
55
|
+
cond2 = a[0] > b[-1]
|
|
56
|
+
return np.mean(cond1 | cond2) # type: ignore[no-any-return]
|
|
57
|
+
|
|
58
|
+
def _func3(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64]) -> float:
|
|
59
|
+
cond1 = ((b[k] - b[k - 1]) < (b[0] - a[n - 1])) & (a[-1] < b[0])
|
|
60
|
+
cond2 = ((b[n] - b[n - 1]) < (a[0] - b[m - 1])) & (a[0] > b[-1])
|
|
61
|
+
return np.mean(cond1 | cond2) # type: ignore[no-any-return]
|
|
62
|
+
|
|
63
|
+
# Specified test
|
|
64
|
+
if specified:
|
|
65
|
+
p = _func1
|
|
66
|
+
# Test with M = N
|
|
67
|
+
elif k == 0:
|
|
68
|
+
p = _func2
|
|
69
|
+
# Test with M > N
|
|
70
|
+
else:
|
|
71
|
+
p = _func3
|
|
72
|
+
|
|
73
|
+
# Seed the random number generator
|
|
74
|
+
rng = np.random.default_rng(seed=seed)
|
|
75
|
+
for d in delta:
|
|
76
|
+
# Samples from A ~ N(0,1)
|
|
77
|
+
a = rng.standard_normal(size=(n, sample_size))
|
|
78
|
+
|
|
79
|
+
# Samples from B ~ N(d,1)
|
|
80
|
+
b = rng.standard_normal(size=(m, sample_size)) + d
|
|
81
|
+
|
|
82
|
+
# Sort corresponding n-tuples
|
|
83
|
+
a.sort(axis=0)
|
|
84
|
+
b.sort(axis=0)
|
|
85
|
+
pc = p(a, b)
|
|
86
|
+
|
|
87
|
+
prop.append(pc)
|
|
88
|
+
|
|
89
|
+
return interpolate.interp1d(delta, prop) # type: ignore[return-value] # ty: ignore[invalid-return-type]
|
sensopy/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sensopy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python library for the design and analysis of sensory discrimination tests.
|
|
5
|
+
Project-URL: Issue Tracker, https://github.com/edgarrmondragon/SensoPy/issues
|
|
6
|
+
Author-email: Edgar Ramírez-Mondragón <edgarrm358@gmail.com>
|
|
7
|
+
Maintainer-email: Edgar Ramírez-Mondragón <edgarrm358@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: discrimination,sensory,triangle
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.13
|
|
25
|
+
Requires-Dist: numpy>=2.2
|
|
26
|
+
Requires-Dist: scipy>=1.15
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# SensoPy
|
|
30
|
+
|
|
31
|
+
[](https://scientific-python.org/specs/spec-0000/)
|
|
32
|
+
[](https://github.com/edgarrmondragon/SensoPy/actions/workflows/tests.yml)
|
|
33
|
+
[](https://codecov.io/gh/edgarrmondragon/SensoPy)
|
|
34
|
+
|
|
35
|
+
Python library for the design and analysis of sensory discrimination tests. I have plans to make this a full-fledged sensory data analysis library by including all common standard techniques (Preference Mapping, Penalty Analysis, DOE, etc.)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
pip install SensoPy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Difference test
|
|
46
|
+
|
|
47
|
+
One-tailed test of H0: p_d = 0 (no difference) against H1: p_d > 0 (a difference
|
|
48
|
+
exists). 19 correct responses out of 30 panelists using the Triangle method:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sensopy import DiscriminationTest
|
|
52
|
+
from sensopy.discrimination import TRIANGLE
|
|
53
|
+
|
|
54
|
+
test = DiscriminationTest(TRIANGLE)
|
|
55
|
+
result = test.difference(19, 30)
|
|
56
|
+
|
|
57
|
+
print(f"d' = {result.d_prime.estimate:.4f} [{result.d_prime.lower:.4f}, {result.d_prime.upper:.4f}]")
|
|
58
|
+
print(f"p_d = {result.pd.estimate:.4f} [{result.pd.lower:.4f}, {result.pd.upper:.4f}]")
|
|
59
|
+
print(f"p-value = {result.p_value:.4f}")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
d' = 2.1462 [1.1262, 3.1336]
|
|
64
|
+
p_d = 0.4500 [0.1578, 0.7011]
|
|
65
|
+
p-value = 0.0007
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **d' = 2.15** (95% CI: [1.13, 3.13]): estimated Thurstonian discriminal distance,
|
|
69
|
+
a method-independent measure of the sensory difference between the two products.
|
|
70
|
+
- **p_d = 0.45** (95% CI: [0.16, 0.70]): an estimated 45% of the panel can reliably
|
|
71
|
+
detect the difference.
|
|
72
|
+
- **p-value = 0.0007**: strong evidence against H0; the products are perceptibly
|
|
73
|
+
different at any conventional significance level.
|
|
74
|
+
|
|
75
|
+
### Equivalence test
|
|
76
|
+
|
|
77
|
+
One-tailed test of H0: p_d >= 0.30 against H1: p_d < 0.30 (the products are
|
|
78
|
+
sensorially similar). `pd0 = 0.30` sets the equivalence threshold: the maximum
|
|
79
|
+
proportion of discriminators we are willing to tolerate and still call the products
|
|
80
|
+
equivalent. 16 correct out of 30 panelists using the 2-AFC method:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from sensopy import DiscriminationTest
|
|
84
|
+
from sensopy.discrimination import TWO_AFC
|
|
85
|
+
|
|
86
|
+
test = DiscriminationTest(TWO_AFC)
|
|
87
|
+
result = test.equivalence(16, 30, pd0=0.30)
|
|
88
|
+
|
|
89
|
+
print(f"d' = {result.d_prime.estimate:.4f} [{result.d_prime.lower:.4f}, {result.d_prime.upper:.4f}]")
|
|
90
|
+
print(f"p_d = {result.pd.estimate:.4f} [{result.pd.lower:.4f}, {result.pd.upper:.4f}]")
|
|
91
|
+
print(f"p-value = {result.p_value:.4f}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
d' = 0.1183 [0.0000, 0.8099]
|
|
96
|
+
p_d = 0.0667 [0.0000, 0.4332]
|
|
97
|
+
p-value = 0.1263
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **d' = 0.12** (95% CI: [0.00, 0.81]): small estimated sensory distance, consistent
|
|
101
|
+
with near-identical products.
|
|
102
|
+
- **p_d = 0.07** (95% CI: [0.00, 0.43]): only ~7% of the panel estimated to reliably
|
|
103
|
+
discriminate the products.
|
|
104
|
+
- **p-value = 0.13**: insufficient evidence to conclude equivalence at α = 0.05 with
|
|
105
|
+
pd0 = 0.30 (i.e., we cannot rule out that ≥ 30% of consumers can detect the
|
|
106
|
+
difference); a larger panel would be needed to meet that threshold.
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
See [ROADMAP.md](ROADMAP.md).
|
|
111
|
+
|
|
112
|
+
## Citing
|
|
113
|
+
|
|
114
|
+
If you use SensoPy in your work, please cite it using [CITATION.cff](CITATION.cff);
|
|
115
|
+
GitHub's "Cite this repository" button exports it as APA or BibTeX.
|
|
116
|
+
|
|
117
|
+
## Credits
|
|
118
|
+
|
|
119
|
+
<ul>
|
|
120
|
+
<li>Edgar Ramírez Mondragón</li>
|
|
121
|
+
</ul>
|
|
122
|
+
|
|
123
|
+
## References
|
|
124
|
+
|
|
125
|
+
<ol>
|
|
126
|
+
<li>Bi, J. 2015. Sensory Discrimination Tests and Measurements. <em>Sensometrics in Sensory Evaluation</em>. Wiley Blackwell, Richmond, Virginia, USA.</li>
|
|
127
|
+
</ol>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sensopy/__init__.py,sha256=jTTg4CPauQ6xAZO8JdNXytNl0GITgFcqe9_VE4zGuew,250
|
|
2
|
+
sensopy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
sensopy/discrimination/__init__.py,sha256=RjGFelbAZ12c7_EDNB89o-XrJ39oFey4M3Ue_e8Sozg,994
|
|
4
|
+
sensopy/discrimination/discrimination.py,sha256=uSoTl595UdQMY67H1N6Sn1KP_tFwiHdZq9sRXj1fU9s,9412
|
|
5
|
+
sensopy/discrimination/methods.py,sha256=SC4msqjxVh1kTSBrvTX1ZqwNYRk2vU5R0XvDFDUPYYo,17897
|
|
6
|
+
sensopy/discrimination/mplusn.py,sha256=RTp21kCa4olZpQjP2W2rjVhn0cB7Tok3cCzjaoyG__g,2298
|
|
7
|
+
sensopy-0.0.1.dist-info/METADATA,sha256=CgY1Y4iZMsY0JBPMN1YQ0RIsrRfszM4oLRS1rLszTVc,4801
|
|
8
|
+
sensopy-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
sensopy-0.0.1.dist-info/licenses/LICENSE,sha256=gLnGgBQCs6EYHnKpg7Cs1mH8XJEZdL3khPhCjZSR8Ec,1083
|
|
10
|
+
sensopy-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019, Edgar Ramírez Mondragón
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|