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 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
+ [![SPEC 0](https://img.shields.io/badge/SPEC-0-green)](https://scientific-python.org/specs/spec-0000/)
32
+ [![Tests](https://github.com/edgarrmondragon/SensoPy/actions/workflows/tests.yml/badge.svg)](https://github.com/edgarrmondragon/SensoPy/actions/workflows/tests.yml)
33
+ [![codecov](https://codecov.io/gh/edgarrmondragon/SensoPy/branch/main/graph/badge.svg?token=k3m3CmIACa)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.