catpie 0.1.0__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.
- catpie/__init__.py +193 -0
- catpie/eap.py +158 -0
- catpie/estimators.py +312 -0
- catpie/irf.py +170 -0
- catpie/math.py +298 -0
- catpie/selection.py +119 -0
- catpie/simulation.py +328 -0
- catpie-0.1.0.dist-info/METADATA +274 -0
- catpie-0.1.0.dist-info/RECORD +11 -0
- catpie-0.1.0.dist-info/WHEEL +4 -0
- catpie-0.1.0.dist-info/licenses/LICENSE +674 -0
catpie/__init__.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
catpie - Computerized Adaptive Testing (CAT) in pure Python.
|
|
3
|
+
|
|
4
|
+
A faithful translation of the most relevant subset of the R package ``catR``:
|
|
5
|
+
the 4PL item response function, EAP/BM/ML/WL ability estimation with standard
|
|
6
|
+
errors, and MFI/bOpt item selection, plus the simulation helpers
|
|
7
|
+
``genPattern``, ``checkStopRule`` and a minimal ``randomCAT`` loop.
|
|
8
|
+
|
|
9
|
+
This is a Python port of the R package ``catR`` (and mirrors the sibling
|
|
10
|
+
JavaScript port ``catjs-irt``). It is not written or endorsed by the catR
|
|
11
|
+
authors. Numbers are computed with the exact same formulas and grid as catR
|
|
12
|
+
(defaults: D=1, priorDist="norm", priorPar=(0,1), parInt=(-4,4,33), trapezoid
|
|
13
|
+
integration over 33 points), so results match catR to floating point precision
|
|
14
|
+
for the same inputs.
|
|
15
|
+
|
|
16
|
+
An "item" is a 4-tuple ``(a, b, c, d)`` (discrimination, difficulty, guessing,
|
|
17
|
+
inattention). Item indices are 0-indexed throughout the public API.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import NamedTuple, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
from .eap import eapEst, eapSem
|
|
25
|
+
from .estimators import semTheta, thetaEst
|
|
26
|
+
from .irf import IiResult, Item, JiResult, PiResult, ii, ji, pi
|
|
27
|
+
from .math import (
|
|
28
|
+
OptimizeResult,
|
|
29
|
+
dnorm,
|
|
30
|
+
integrateCatR,
|
|
31
|
+
linspace,
|
|
32
|
+
optimizeScalar,
|
|
33
|
+
qnorm,
|
|
34
|
+
uniroot,
|
|
35
|
+
)
|
|
36
|
+
from .selection import NextItemResult, nextItem
|
|
37
|
+
from .simulation import (
|
|
38
|
+
RandomCatResult,
|
|
39
|
+
StopRuleResult,
|
|
40
|
+
checkStopRule,
|
|
41
|
+
genPattern,
|
|
42
|
+
randomCAT,
|
|
43
|
+
simulateRespondents,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
# IRF
|
|
50
|
+
"pi",
|
|
51
|
+
"ii",
|
|
52
|
+
"ji",
|
|
53
|
+
"PiResult",
|
|
54
|
+
"IiResult",
|
|
55
|
+
"JiResult",
|
|
56
|
+
# estimation
|
|
57
|
+
"eapEst",
|
|
58
|
+
"eapSem",
|
|
59
|
+
"thetaEst",
|
|
60
|
+
"semTheta",
|
|
61
|
+
# selection
|
|
62
|
+
"nextItem",
|
|
63
|
+
"NextItemResult",
|
|
64
|
+
# simulation
|
|
65
|
+
"genPattern",
|
|
66
|
+
"simulateRespondents",
|
|
67
|
+
"checkStopRule",
|
|
68
|
+
"randomCAT",
|
|
69
|
+
"StopRuleResult",
|
|
70
|
+
"RandomCatResult",
|
|
71
|
+
# high-level helpers
|
|
72
|
+
"estimateTheta",
|
|
73
|
+
"selectNextItem",
|
|
74
|
+
"ThetaResult",
|
|
75
|
+
# math helpers (mirror catR/R base functions)
|
|
76
|
+
"dnorm",
|
|
77
|
+
"linspace",
|
|
78
|
+
"integrateCatR",
|
|
79
|
+
"qnorm",
|
|
80
|
+
"uniroot",
|
|
81
|
+
"optimizeScalar",
|
|
82
|
+
"OptimizeResult",
|
|
83
|
+
"__version__",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ThetaResult(NamedTuple):
|
|
88
|
+
"""Ability estimate and its standard error (high-level helper)."""
|
|
89
|
+
|
|
90
|
+
theta: float
|
|
91
|
+
se: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def estimateTheta(
|
|
95
|
+
itemBank: Sequence[Item],
|
|
96
|
+
administered: Sequence[int],
|
|
97
|
+
responses: Sequence[int],
|
|
98
|
+
method: str = "EAP",
|
|
99
|
+
priorDist: str = "norm",
|
|
100
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
101
|
+
D: float = 1.0,
|
|
102
|
+
range: Tuple[float, float] = (-4, 4),
|
|
103
|
+
parInt: Tuple[float, float, int] = (-4, 4, 33),
|
|
104
|
+
semType: str = "classic",
|
|
105
|
+
) -> ThetaResult:
|
|
106
|
+
"""
|
|
107
|
+
Estimate ability and its standard error from the items given so far.
|
|
108
|
+
|
|
109
|
+
This is the highest-level estimation helper: hand it the whole item bank,
|
|
110
|
+
the indices of the items already administered, and the person's 0/1
|
|
111
|
+
responses, and you get back ``(theta, se)`` in one call. It is exactly the
|
|
112
|
+
helper the EWM experiment's ``estimate_theta_catr(...)`` uses.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
itemBank: The full item bank, as ``(a, b, c, d)`` tuples.
|
|
116
|
+
administered: 0-based indices (into ``itemBank``) of the items
|
|
117
|
+
administered so far.
|
|
118
|
+
responses: 0/1 responses in the same order as ``administered``.
|
|
119
|
+
method: Estimation method (default ``"EAP"``; also ``"BM"``,
|
|
120
|
+
``"ML"``, ``"WL"``).
|
|
121
|
+
priorDist: Prior distribution (``"norm"`` default).
|
|
122
|
+
priorPar: Prior parameters (default ``(0, 1)``).
|
|
123
|
+
D: Scale constant (default ``1.0``).
|
|
124
|
+
range: Search interval for BM/ML/WL (default ``(-4, 4)``).
|
|
125
|
+
parInt: EAP integration grid (default ``(-4, 4, 33)``).
|
|
126
|
+
semType: ``"classic"`` (default) SE form.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A :class:`ThetaResult` with fields ``theta`` and ``se``. If no items
|
|
130
|
+
have been administered yet, returns the prior estimate ``(0.0, inf)``.
|
|
131
|
+
"""
|
|
132
|
+
if len(administered) == 0:
|
|
133
|
+
return ThetaResult(0.0, float("inf"))
|
|
134
|
+
it = [itemBank[i] for i in administered]
|
|
135
|
+
theta = thetaEst(
|
|
136
|
+
it,
|
|
137
|
+
responses,
|
|
138
|
+
method=method,
|
|
139
|
+
priorDist=priorDist,
|
|
140
|
+
priorPar=priorPar,
|
|
141
|
+
D=D,
|
|
142
|
+
range=range,
|
|
143
|
+
parInt=parInt,
|
|
144
|
+
)
|
|
145
|
+
se = semTheta(
|
|
146
|
+
theta,
|
|
147
|
+
it,
|
|
148
|
+
responses,
|
|
149
|
+
method=method,
|
|
150
|
+
priorDist=priorDist,
|
|
151
|
+
priorPar=priorPar,
|
|
152
|
+
D=D,
|
|
153
|
+
parInt=parInt,
|
|
154
|
+
semType=semType,
|
|
155
|
+
)
|
|
156
|
+
return ThetaResult(theta, se)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def selectNextItem(
|
|
160
|
+
itemBank: Sequence[Item],
|
|
161
|
+
theta: float,
|
|
162
|
+
administered: Sequence[int],
|
|
163
|
+
criterion: str = "MFI",
|
|
164
|
+
randomesque: int = 1,
|
|
165
|
+
D: float = 1.0,
|
|
166
|
+
) -> NextItemResult:
|
|
167
|
+
"""
|
|
168
|
+
Select the next item to administer (default criterion: MFI).
|
|
169
|
+
|
|
170
|
+
High-level wrapper around :func:`nextItem` that makes the most common call
|
|
171
|
+
shorter. It is exactly the helper the EWM experiment's
|
|
172
|
+
``select_next_item_catr(...)`` uses.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
itemBank: The full item bank, as ``(a, b, c, d)`` tuples.
|
|
176
|
+
theta: Current ability estimate (a float, typically ``[-4, 4]``).
|
|
177
|
+
administered: 0-based indices of the items already administered (they
|
|
178
|
+
will not be selected again).
|
|
179
|
+
criterion: ``"MFI"`` (default) or ``"bOpt"``.
|
|
180
|
+
randomesque: Only the catR default ``1`` is supported.
|
|
181
|
+
D: Scale constant (default ``1.0``).
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A :class:`NextItemResult`; see :func:`nextItem` for its fields.
|
|
185
|
+
"""
|
|
186
|
+
return nextItem(
|
|
187
|
+
itemBank,
|
|
188
|
+
theta,
|
|
189
|
+
administered,
|
|
190
|
+
criterion=criterion,
|
|
191
|
+
randomesque=randomesque,
|
|
192
|
+
D=D,
|
|
193
|
+
)
|
catpie/eap.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Expected A Posteriori (EAP) estimation, mirroring catR's ``eapEst()`` and
|
|
3
|
+
``eapSem()`` exactly (including the grid, the prior and the trapezoid
|
|
4
|
+
integration via ``integrate.catR``).
|
|
5
|
+
|
|
6
|
+
Supported priors (catR's ``priorDist``): "norm" (N(priorPar[0], priorPar[1])),
|
|
7
|
+
"unif" (uniform over priorPar), "Jeffreys" (weight sqrt(sum(Ii))).
|
|
8
|
+
|
|
9
|
+
This mirrors catjs-irt's ``src/eap.js``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from typing import Callable, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
from .irf import Item, ii, pi
|
|
18
|
+
from .math import dnorm, integrateCatR, linspace
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def priorWeight(
|
|
22
|
+
s: float,
|
|
23
|
+
priorDist: str,
|
|
24
|
+
priorPar: Tuple[float, float],
|
|
25
|
+
it: Sequence[Item],
|
|
26
|
+
D: float = 1.0,
|
|
27
|
+
) -> float:
|
|
28
|
+
"""
|
|
29
|
+
Prior density term used in the EAP integrand, matching catR's
|
|
30
|
+
``switch(...)`` inside ``eapEst``/``eapSem``. For "norm" returns
|
|
31
|
+
``dnorm(s)``; for "unif" returns ``dunif(s)`` (1/(b-a) inside [a, b], 0
|
|
32
|
+
outside); for "Jeffreys" returns ``sqrt(sum(Ii(s, it)))``.
|
|
33
|
+
"""
|
|
34
|
+
if priorDist == "norm":
|
|
35
|
+
return dnorm(s, priorPar[0], priorPar[1])
|
|
36
|
+
if priorDist == "unif":
|
|
37
|
+
a, b = priorPar[0], priorPar[1]
|
|
38
|
+
return 1.0 / (b - a) if a <= s <= b else 0.0
|
|
39
|
+
if priorDist == "Jeffreys":
|
|
40
|
+
return math.sqrt(sum(ii(s, item, D).Ii for item in it))
|
|
41
|
+
raise ValueError("eapEst: priorDist %r not implemented" % (priorDist,))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def makeLikelihood(
|
|
45
|
+
it: Sequence[Item], x: Sequence[int], D: float = 1.0
|
|
46
|
+
) -> Callable[[float], float]:
|
|
47
|
+
"""
|
|
48
|
+
Build the likelihood L(th) = prod_i P_i^x_i * (1 - P_i)^(1 - x_i),
|
|
49
|
+
exactly as in catR's ``eapEst``:
|
|
50
|
+
|
|
51
|
+
L <- function(th, it, x) prod(Pi(th, it, D=D)$Pi^x *
|
|
52
|
+
(1-Pi(th, it, D=D)$Pi)^(1-x))
|
|
53
|
+
"""
|
|
54
|
+
def likelihood(th: float) -> float:
|
|
55
|
+
res = 1.0
|
|
56
|
+
for i, item in enumerate(it):
|
|
57
|
+
P = pi(th, item, D).P
|
|
58
|
+
res *= P ** x[i] * (1 - P) ** (1 - x[i])
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
return likelihood
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def eapEst(
|
|
65
|
+
it: Sequence[Item],
|
|
66
|
+
x: Sequence[int],
|
|
67
|
+
D: float = 1.0,
|
|
68
|
+
priorDist: str = "norm",
|
|
69
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
70
|
+
lower: float = -4,
|
|
71
|
+
upper: float = 4,
|
|
72
|
+
nqp: int = 33,
|
|
73
|
+
) -> float:
|
|
74
|
+
"""
|
|
75
|
+
Ability estimate by Expected A Posteriori (EAP) estimation.
|
|
76
|
+
|
|
77
|
+
EAP is a *Bayesian* estimate: it combines what the responses tell us (the
|
|
78
|
+
likelihood) with a *prior* belief about the ability distribution. It is the
|
|
79
|
+
most robust of the implemented estimators and the default choice of this
|
|
80
|
+
package (and of the EWM experiment).
|
|
81
|
+
|
|
82
|
+
The estimate is computed by numerical integration over a grid of ability
|
|
83
|
+
values (catR's default: 33 points from ``-4`` to ``4``), so the result is
|
|
84
|
+
always within ``[lower, upper]``.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
it: The items that were administered, as a list of ``(a, b, c, d)``
|
|
88
|
+
tuples. Length must equal ``len(x)``.
|
|
89
|
+
x: The responses, as 0/1 integers (1 = correct) in the same order as
|
|
90
|
+
``it``.
|
|
91
|
+
D: Scale constant (default ``1.0``).
|
|
92
|
+
priorDist: The prior distribution, one of ``"norm"`` (normal),
|
|
93
|
+
``"unif"`` (uniform) or ``"Jeffreys"``.
|
|
94
|
+
priorPar: Prior parameters. For ``"norm"``: ``(mean, sd)`` — catR
|
|
95
|
+
default ``(0, 1)`` (standard normal). For ``"unif"``: ``(lower,
|
|
96
|
+
upper)`` of the uniform interval.
|
|
97
|
+
lower: Lower bound of the integration grid (default ``-4``).
|
|
98
|
+
upper: Upper bound of the integration grid (default ``4``).
|
|
99
|
+
nqp: Number of quadrature points in the grid (catR default ``33``).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The estimated ability ``theta`` (a float, always within
|
|
103
|
+
``[lower, upper]``).
|
|
104
|
+
"""
|
|
105
|
+
L = makeLikelihood(it, x, D)
|
|
106
|
+
X = linspace(lower, upper, nqp)
|
|
107
|
+
|
|
108
|
+
def w(s: float) -> float:
|
|
109
|
+
return priorWeight(s, priorDist, priorPar, it, D)
|
|
110
|
+
|
|
111
|
+
g = [s * w(s) * L(s) for s in X]
|
|
112
|
+
h = [w(s) * L(s) for s in X]
|
|
113
|
+
return integrateCatR(X, g) / integrateCatR(X, h)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def eapSem(
|
|
117
|
+
thEst: float,
|
|
118
|
+
it: Sequence[Item],
|
|
119
|
+
x: Sequence[int],
|
|
120
|
+
D: float = 1.0,
|
|
121
|
+
priorDist: str = "norm",
|
|
122
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
123
|
+
lower: float = -4,
|
|
124
|
+
upper: float = 4,
|
|
125
|
+
nqp: int = 33,
|
|
126
|
+
) -> float:
|
|
127
|
+
"""
|
|
128
|
+
Standard error of an EAP ability estimate.
|
|
129
|
+
|
|
130
|
+
The standard error tells you how confident you can be in the ability
|
|
131
|
+
estimate :func:`eapEst`: smaller = more confidence. It is the standard
|
|
132
|
+
deviation of the posterior distribution, computed by the same numerical
|
|
133
|
+
integration over the grid.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
thEst: The ability estimate (the output of :func:`eapEst`).
|
|
137
|
+
it: The administered items, as ``(a, b, c, d)`` tuples.
|
|
138
|
+
x: The responses (0/1), same order as ``it``.
|
|
139
|
+
D: Scale constant (default ``1.0``).
|
|
140
|
+
priorDist: Prior distribution, ``"norm"`` / ``"unif"`` / ``"Jeffreys"``.
|
|
141
|
+
priorPar: Prior parameters (see :func:`eapEst`).
|
|
142
|
+
lower: Lower bound of the integration grid (default ``-4``).
|
|
143
|
+
upper: Upper bound of the integration grid (default ``4``).
|
|
144
|
+
nqp: Number of quadrature points (catR default ``33``).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The standard error (a non-negative float; typically a few tenths, and
|
|
148
|
+
smaller after more informative items are administered).
|
|
149
|
+
"""
|
|
150
|
+
L = makeLikelihood(it, x, D)
|
|
151
|
+
X = linspace(lower, upper, nqp)
|
|
152
|
+
|
|
153
|
+
def w(s: float) -> float:
|
|
154
|
+
return priorWeight(s, priorDist, priorPar, it, D)
|
|
155
|
+
|
|
156
|
+
g = [(s - thEst) ** 2 * w(s) * L(s) for s in X]
|
|
157
|
+
h = [w(s) * L(s) for s in X]
|
|
158
|
+
return math.sqrt(integrateCatR(X, g) / integrateCatR(X, h))
|
catpie/estimators.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ability (theta) estimation and its standard error, mirroring catR's
|
|
3
|
+
``thetaEst()`` and ``semTheta()`` for the dichotomous model:
|
|
4
|
+
|
|
5
|
+
- method: "EAP" | "BM" | "ML" | "WL"
|
|
6
|
+
- priorDist: "norm" | "unif" | "Jeffreys" (used by EAP and BM)
|
|
7
|
+
- defaults: D=1, priorPar=(0,1), parInt=(-4,4,33), range=(-4,4)
|
|
8
|
+
|
|
9
|
+
The non-EAP methods replicate catR's exact algorithm: solve the score equation
|
|
10
|
+
T(th) = r0(th) + r(th) = 0 by bisection (R's ``uniroot``) over ``range``, with
|
|
11
|
+
catR's ``optimize()``-based fallback when T does not change sign.
|
|
12
|
+
|
|
13
|
+
This mirrors catjs-irt's ``src/estimators.js``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from typing import NamedTuple, Optional, Sequence, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from .eap import eapEst, eapSem
|
|
22
|
+
from .irf import Item, ii, ji, pi
|
|
23
|
+
from .math import OptimizeResult, _sqrt, optimizeScalar, uniroot
|
|
24
|
+
|
|
25
|
+
Number = Union[int, float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SumDersResult(NamedTuple):
|
|
29
|
+
"""Summed information and its derivatives over a set of items."""
|
|
30
|
+
|
|
31
|
+
Ii: float
|
|
32
|
+
dIi: float
|
|
33
|
+
d2Ii: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_missing(v: object) -> bool:
|
|
37
|
+
"""True for NaN/None, matching catjs-irt's NaN/undefined/null filter."""
|
|
38
|
+
if v is None:
|
|
39
|
+
return True
|
|
40
|
+
try:
|
|
41
|
+
return math.isnan(v) # type: ignore[arg-type]
|
|
42
|
+
except TypeError:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dropMissing(
|
|
47
|
+
it: Sequence[Item], x: Sequence[Number]
|
|
48
|
+
) -> Tuple[Sequence[Item], Sequence[Number]]:
|
|
49
|
+
"""
|
|
50
|
+
Filter out NaN/None responses and the corresponding items, matching catR's
|
|
51
|
+
``thetaEst``: ``ind <- which(!is.na(x)); it <- it[ind, ]; x <- x[ind]``.
|
|
52
|
+
"""
|
|
53
|
+
idx = [i for i, v in enumerate(x) if not _is_missing(v)]
|
|
54
|
+
return [it[i] for i in idx], [x[i] for i in idx]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sumIi(th: float, it: Sequence[Item], D: float) -> float:
|
|
58
|
+
"""Sum of item information at ``th`` over ``it``."""
|
|
59
|
+
return sum(ii(th, item, D).Ii for item in it)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sumDers(th: float, it: Sequence[Item], D: float) -> SumDersResult:
|
|
63
|
+
"""Sum of item information derivatives at ``th`` over ``it``."""
|
|
64
|
+
Ii = 0.0
|
|
65
|
+
dIi = 0.0
|
|
66
|
+
d2Ii = 0.0
|
|
67
|
+
for item in it:
|
|
68
|
+
res = ii(th, item, D)
|
|
69
|
+
Ii += res.Ii
|
|
70
|
+
dIi += res.dIi
|
|
71
|
+
d2Ii += res.d2Ii
|
|
72
|
+
return SumDersResult(Ii, dIi, d2Ii)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def r0(
|
|
76
|
+
th: float,
|
|
77
|
+
it: Sequence[Item],
|
|
78
|
+
method: str = "BM",
|
|
79
|
+
priorDist: str = "norm",
|
|
80
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
81
|
+
D: float = 1.0,
|
|
82
|
+
) -> float:
|
|
83
|
+
"""catR's ``r0`` term (prior / method correction) in the score equation."""
|
|
84
|
+
if method == "ML":
|
|
85
|
+
return 0.0
|
|
86
|
+
if method == "WL":
|
|
87
|
+
sumJ = sum(ji(th, item, D).Ji for item in it)
|
|
88
|
+
return sumJ / (2 * sumIi(th, it, D))
|
|
89
|
+
# BM
|
|
90
|
+
if priorDist == "norm":
|
|
91
|
+
return (priorPar[0] - th) / priorPar[1] ** 2
|
|
92
|
+
if priorDist == "unif":
|
|
93
|
+
return 0.0
|
|
94
|
+
if priorDist == "Jeffreys":
|
|
95
|
+
ders = sumDers(th, it, D)
|
|
96
|
+
return ders.dIi / (2 * ders.Ii)
|
|
97
|
+
raise ValueError("thetaEst: priorDist %r not implemented" % (priorDist,))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def r(th: float, it: Sequence[Item], x: Sequence[Number], D: float = 1.0) -> float:
|
|
101
|
+
"""catR's ``r`` term: sum of dP*(x - P)/(P*Q) over items."""
|
|
102
|
+
res = 0.0
|
|
103
|
+
for i, item in enumerate(it):
|
|
104
|
+
pr = pi(th, item, D)
|
|
105
|
+
P = pr.P
|
|
106
|
+
Q = 1 - P
|
|
107
|
+
dP = pr.dP
|
|
108
|
+
res += (dP * (x[i] - P)) / (P * Q)
|
|
109
|
+
return res
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def thetaEst(
|
|
113
|
+
it: Sequence[Item],
|
|
114
|
+
x: Sequence[Number],
|
|
115
|
+
method: str = "EAP",
|
|
116
|
+
priorDist: str = "norm",
|
|
117
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
118
|
+
D: float = 1.0,
|
|
119
|
+
range: Tuple[float, float] = (-4, 4),
|
|
120
|
+
parInt: Tuple[float, float, int] = (-4, 4, 33),
|
|
121
|
+
) -> float:
|
|
122
|
+
"""
|
|
123
|
+
Estimate a person's ability (theta) from their responses.
|
|
124
|
+
|
|
125
|
+
This is the main estimation function. Give it the items that were
|
|
126
|
+
administered and the person's 0/1 responses, and it returns the best
|
|
127
|
+
estimate of their ability. The higher the ability, the "better" the
|
|
128
|
+
person performed.
|
|
129
|
+
|
|
130
|
+
Four estimation methods are available:
|
|
131
|
+
|
|
132
|
+
- ``"EAP"`` (default): Bayesian, most robust, uses the prior; the result
|
|
133
|
+
always lies within the integration grid (default ``[-4, 4]``).
|
|
134
|
+
- ``"BM"``: Bayesian modal estimate (posterior maximum), uses the prior.
|
|
135
|
+
- ``"ML"``: maximum likelihood; uses only the responses (no prior). Can
|
|
136
|
+
be extreme (and is unstable on degenerate item banks, exactly like catR).
|
|
137
|
+
- ``"WL"``: weighted likelihood; reduces the bias of ML.
|
|
138
|
+
|
|
139
|
+
Missing responses (``NaN``/``None``) and their items are ignored.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
it: The administered items as ``(a, b, c, d)`` tuples (same length as
|
|
143
|
+
``x``).
|
|
144
|
+
x: The responses as 0/1 integers (1 = correct); ``NaN``/``None``
|
|
145
|
+
entries are dropped together with the corresponding items.
|
|
146
|
+
method: One of ``"EAP"`` (default), ``"BM"``, ``"ML"``, ``"WL"``.
|
|
147
|
+
priorDist: Prior distribution for ``EAP``/``BM``: ``"norm"``
|
|
148
|
+
(default), ``"unif"``, ``"Jeffreys"``.
|
|
149
|
+
priorPar: Prior parameters ``(mean, sd)`` for ``"norm"`` (default
|
|
150
|
+
``(0, 1)``) or ``(lower, upper)`` for ``"unif"``.
|
|
151
|
+
D: Scale constant (default ``1.0``).
|
|
152
|
+
range: The search interval ``(lower, upper)`` for the BM/ML/WL root
|
|
153
|
+
finding (default ``(-4, 4)``). Not used by EAP.
|
|
154
|
+
parInt: ``(lower, upper, npoints)`` of the integration grid for EAP
|
|
155
|
+
(default ``(-4, 4, 33)``).
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The estimated ability as a float (a higher value = higher estimated
|
|
159
|
+
ability). For EAP it lies within the integration grid; BM/ML/WL may
|
|
160
|
+
return a range boundary when no interior root exists (like catR).
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValueError: If ``method`` is not one of EAP/BM/ML/WL, or ``priorDist``
|
|
164
|
+
is not supported.
|
|
165
|
+
"""
|
|
166
|
+
fit, fx = dropMissing(it, x)
|
|
167
|
+
|
|
168
|
+
if method == "EAP":
|
|
169
|
+
return eapEst(
|
|
170
|
+
fit,
|
|
171
|
+
fx,
|
|
172
|
+
D=D,
|
|
173
|
+
priorDist=priorDist,
|
|
174
|
+
priorPar=priorPar,
|
|
175
|
+
lower=parInt[0],
|
|
176
|
+
upper=parInt[1],
|
|
177
|
+
nqp=parInt[2],
|
|
178
|
+
)
|
|
179
|
+
if method not in ("BM", "ML", "WL"):
|
|
180
|
+
raise ValueError("thetaEst: method %r not implemented" % (method,))
|
|
181
|
+
|
|
182
|
+
# catR: f is T(th) = r0(th) + r(th), except BM+unif which reduces to ML
|
|
183
|
+
# and searches over the prior interval.
|
|
184
|
+
usePrior = not (method == "BM" and priorDist == "unif")
|
|
185
|
+
|
|
186
|
+
def f(th: float) -> float:
|
|
187
|
+
t = r(th, fit, fx, D)
|
|
188
|
+
if usePrior:
|
|
189
|
+
return (
|
|
190
|
+
r0(
|
|
191
|
+
th,
|
|
192
|
+
fit,
|
|
193
|
+
method=method,
|
|
194
|
+
priorDist=priorDist,
|
|
195
|
+
priorPar=priorPar,
|
|
196
|
+
D=D,
|
|
197
|
+
)
|
|
198
|
+
+ t
|
|
199
|
+
)
|
|
200
|
+
return t
|
|
201
|
+
|
|
202
|
+
RANGE = priorPar if (method == "BM" and priorDist == "unif") else range
|
|
203
|
+
|
|
204
|
+
fLo = f(RANGE[0])
|
|
205
|
+
fHi = f(RANGE[1])
|
|
206
|
+
if (fLo < 0 < fHi) or (fLo > 0 > fHi):
|
|
207
|
+
return uniroot(f, RANGE[0], RANGE[1])
|
|
208
|
+
|
|
209
|
+
# catR fallback: minimize f; if min > 0 -> upper bound; maximize; if max < 0
|
|
210
|
+
# -> lower bound; else root between the argmax and argmin.
|
|
211
|
+
pr: OptimizeResult = optimizeScalar(f, RANGE[0], RANGE[1])
|
|
212
|
+
if pr.y > 0:
|
|
213
|
+
return RANGE[1]
|
|
214
|
+
pr2: OptimizeResult = optimizeScalar(f, RANGE[0], RANGE[1], maximize=True)
|
|
215
|
+
if pr2.y < 0:
|
|
216
|
+
return RANGE[0]
|
|
217
|
+
lo = min(pr2.x, pr.x)
|
|
218
|
+
hi = max(pr2.x, pr.x)
|
|
219
|
+
return uniroot(f, lo, hi)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def semTheta(
|
|
223
|
+
thEst: float,
|
|
224
|
+
it: Sequence[Item],
|
|
225
|
+
x: Sequence[Number],
|
|
226
|
+
method: str = "EAP",
|
|
227
|
+
priorDist: str = "norm",
|
|
228
|
+
priorPar: Tuple[float, float] = (0, 1),
|
|
229
|
+
D: float = 1.0,
|
|
230
|
+
parInt: Tuple[float, float, int] = (-4, 4, 33),
|
|
231
|
+
semType: str = "classic",
|
|
232
|
+
range: Tuple[float, float] = (-4, 4),
|
|
233
|
+
) -> float:
|
|
234
|
+
"""
|
|
235
|
+
Standard error (SE) of an ability estimate.
|
|
236
|
+
|
|
237
|
+
The SE quantifies the uncertainty of an ability estimate :func:`thetaEst`:
|
|
238
|
+
small SE = confident estimate, large SE = uncertain. It is used by the
|
|
239
|
+
stopping rules (e.g. stop once ``se`` drops below a threshold) and to build
|
|
240
|
+
confidence intervals for classification.
|
|
241
|
+
|
|
242
|
+
The method should match the one used for estimation (``method=...``).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
thEst: The ability estimate (output of :func:`thetaEst`).
|
|
246
|
+
it: The administered items as ``(a, b, c, d)`` tuples.
|
|
247
|
+
x: The responses (0/1); ``NaN``/``None`` are dropped with their items.
|
|
248
|
+
method: Estimation method: ``"EAP"`` (default), ``"BM"``, ``"ML"``,
|
|
249
|
+
``"WL"``.
|
|
250
|
+
priorDist: Prior distribution for EAP/BM (``"norm"`` default,
|
|
251
|
+
``"unif"``, ``"Jeffreys"``).
|
|
252
|
+
priorPar: Prior parameters (see :func:`thetaEst`).
|
|
253
|
+
D: Scale constant (default ``1.0``).
|
|
254
|
+
parInt: Integration grid ``(lower, upper, npoints)`` for EAP (default
|
|
255
|
+
``(-4, 4, 33)``).
|
|
256
|
+
semType: ``"classic"`` (default) or ``"new"`` (only implemented for
|
|
257
|
+
the classic form in this port).
|
|
258
|
+
range: Accepted for catR signature parity; not used by the implemented
|
|
259
|
+
methods.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The standard error as a non-negative float. As a rough guide, values
|
|
263
|
+
below ~0.3 are often considered "precise enough" for adaptive testing.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
ValueError: If ``method`` is not one of EAP/BM/ML/WL or ``priorDist``
|
|
267
|
+
is not supported.
|
|
268
|
+
"""
|
|
269
|
+
fit, fx = dropMissing(it, x)
|
|
270
|
+
|
|
271
|
+
if method == "EAP":
|
|
272
|
+
return eapSem(
|
|
273
|
+
thEst,
|
|
274
|
+
fit,
|
|
275
|
+
fx,
|
|
276
|
+
D=D,
|
|
277
|
+
priorDist=priorDist,
|
|
278
|
+
priorPar=priorPar,
|
|
279
|
+
lower=parInt[0],
|
|
280
|
+
upper=parInt[1],
|
|
281
|
+
nqp=parInt[2],
|
|
282
|
+
)
|
|
283
|
+
if method not in ("BM", "ML", "WL"):
|
|
284
|
+
raise ValueError("semTheta: method %r not implemented" % (method,))
|
|
285
|
+
|
|
286
|
+
info = sumIi(thEst, fit, D)
|
|
287
|
+
|
|
288
|
+
if method == "ML":
|
|
289
|
+
return 1.0 / _sqrt(info)
|
|
290
|
+
|
|
291
|
+
if method == "WL":
|
|
292
|
+
# classic: 1/sqrt(info); new: sqrt(info)/abs(info - dr0) -- classic only
|
|
293
|
+
# is implemented (the catR new-type WL needs Ji derivatives).
|
|
294
|
+
if semType == "new":
|
|
295
|
+
dr0wl = 0.0
|
|
296
|
+
return _sqrt(info) / abs(info - dr0wl)
|
|
297
|
+
return 1.0 / _sqrt(info)
|
|
298
|
+
|
|
299
|
+
# BM
|
|
300
|
+
if priorDist == "norm":
|
|
301
|
+
dr0 = -1.0 / priorPar[1] ** 2
|
|
302
|
+
elif priorDist == "unif":
|
|
303
|
+
dr0 = 0.0
|
|
304
|
+
elif priorDist == "Jeffreys":
|
|
305
|
+
ders = sumDers(thEst, fit, D)
|
|
306
|
+
dr0 = (ders.d2Ii * ders.Ii - ders.dIi * ders.dIi) / (2 * ders.Ii * ders.Ii)
|
|
307
|
+
else:
|
|
308
|
+
raise ValueError("semTheta: priorDist %r not implemented" % (priorDist,))
|
|
309
|
+
|
|
310
|
+
if semType == "classic":
|
|
311
|
+
return 1.0 / _sqrt(info - dr0)
|
|
312
|
+
return _sqrt(info) / abs(info - dr0)
|